pi-code 1.0.2 → 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
@@ -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` |
@@ -87,8 +87,15 @@ export default function commandsExtension(pi: ExtensionAPI) {
87
87
  // list, leaving the command running with everything enabled.
88
88
  if (parsed.allowedTools) {
89
89
  const saved = pi.getActiveTools()
90
- pendingRestore = saved
91
- pi.setActiveTools(parsed.allowedTools.filter((tool) => saved.includes(tool)))
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)
92
99
  }
93
100
  pi.sendUserMessage(expanded)
94
101
  }
@@ -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
@@ -39,30 +41,97 @@ const CLAUDE_TOOL_MAP: Record<string, string> = {
39
41
  grep: 'grep',
40
42
  glob: 'find',
41
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',
42
54
  }
43
55
 
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
+ */
44
63
  export function normalizeToolName(name: string): string {
45
64
  const lower = name.trim().toLowerCase()
46
- return CLAUDE_TOOL_MAP[lower] ?? lower
65
+ const scope = lower.indexOf('(')
66
+ const base = (scope === -1 ? lower : lower.slice(0, scope)).trim()
67
+ return CLAUDE_TOOL_MAP[base] ?? base
47
68
  }
48
69
 
49
- function field(frontmatter: string, key: string): string {
50
- const match = new RegExp(String.raw`^\s*${key}\s*:\s*(.+)$`, 'm').exec(frontmatter)
51
- return match ? match[1].trim().replace(/^["']|["']$/g, '') : ''
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)
52
95
  }
53
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
+
54
121
  export function parseCommandFile(content: string): ParsedCommand {
55
- const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(content)
56
- const frontmatter = match ? match[1] : ''
57
- const body = (match ? content.slice(match[0].length) : content).trim()
58
- 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()
59
127
  const firstLine = body.split('\n').find((line) => line.trim().length > 0) ?? ''
128
+ const disable = frontmatter['disable-model-invocation']
60
129
  return {
61
- description: field(frontmatter, 'description') || firstLine.slice(0, 60),
62
- argumentHint: field(frontmatter, 'argument-hint') || undefined,
63
- allowedTools: tools ? tools.split(',').map(normalizeToolName).filter(Boolean) : undefined,
64
- model: field(frontmatter, 'model') || undefined,
65
- 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',
66
135
  body,
67
136
  }
68
137
  }
@@ -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
@@ -37,8 +37,25 @@ const CLAUDE_SHAPED = [
37
37
  path.join('.pi', 'agents'),
38
38
  ]
39
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. */
40
50
  export function hasClaudeShapedConfig(cwd: string): boolean {
41
- 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
+ }
42
59
  }
43
60
 
44
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.
@@ -222,12 +223,14 @@ export type ToolContent = { type: 'text'; text: string } | { type: 'image'; data
222
223
  export function mapContent(content: McpContentBlock[] | undefined, structured?: unknown): ToolContent[] {
223
224
  // capForContext every text output, whatever its source: a server can blow the tool-output
224
225
  // budget through a resource block, a JSON-stringified block, or the structured fallback,
225
- // 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.
226
229
  const text = (value: string): ToolContent => ({ type: 'text', text: capForContext(value) })
227
230
  if (!content || content.length === 0) {
228
231
  return [text(structured !== undefined ? JSON.stringify(structured, null, 2) : '(empty result)')]
229
232
  }
230
- return content.map((block) => {
233
+ const mapped: ToolContent[] = content.map((block): ToolContent => {
231
234
  if (block.type === 'text') {
232
235
  return text(block.text ?? '')
233
236
  }
@@ -239,6 +242,50 @@ export function mapContent(content: McpContentBlock[] | undefined, structured?:
239
242
  }
240
243
  return text(JSON.stringify(block))
241
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
242
289
  }
243
290
 
244
291
  function isStdio(config: ServerConfig): config is StdioServerConfig {
@@ -379,7 +426,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
379
426
  if (result.isError) {
380
427
  details.error = 'tool_error'
381
428
  const hint = JSON.stringify(normalizeSchema(tool.inputSchema))
382
- 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}`) })
383
430
  }
384
431
  return { content, details }
385
432
  },
@@ -411,6 +458,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
411
458
  }
412
459
 
413
460
  async function connectServers(servers: Record<string, ServerConfig>): Promise<void> {
461
+ const pending: [string, ServerConfig][] = []
414
462
  for (const [name, config] of Object.entries(servers)) {
415
463
  // A later scope must not take the name of a server that already connected: it
416
464
  // would evict that client from the map, leaking it at shutdown, and misreport
@@ -419,18 +467,26 @@ export default async function mcpExtension(pi: ExtensionAPI) {
419
467
  console.warn(`pi-code-mcp: skipping duplicate server name ${name}`)
420
468
  continue
421
469
  }
422
- warnOnTypelessUrl(name, config)
423
- try {
424
- const client = await connect(name, config)
425
- clients.set(name, client)
426
- const tools = await withTimeout(listAllTools(client), connectTimeoutMs(), `list tools ${name}`)
427
- const count = registerTools(name, config, client, tools)
428
- subscribeToToolChanges(name, config, client)
429
- status.set(name, { state: 'connected', tools: count })
430
- } catch (error) {
431
- status.set(name, { state: `failed: ${error instanceof Error ? error.message : String(error)}`, tools: 0 })
432
- }
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])
433
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
+ )
434
490
  }
435
491
 
436
492
  /** Connect the project scope under the per-server policy. Returns whether the scope
@@ -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.
@@ -7,21 +7,10 @@ import * as os from 'node:os'
7
7
  import * as path from 'node:path'
8
8
  import { getAgentDir, parseFrontmatter, stripFrontmatter } from '@earendil-works/pi-coding-agent'
9
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
- }
20
-
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
 
@@ -175,11 +175,25 @@ function driveRun(run: BackgroundRun, invocation: BackgroundSpawn, onComplete: (
175
175
  const complete = (): void => {
176
176
  if (completed) return
177
177
  completed = true
178
- 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
+ }
179
189
  }
180
190
  proc.stdout.on('data', (data) => {
181
191
  stdout += data.toString()
182
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', () => {})
183
197
  proc.on('close', (code) => {
184
198
  const { text, turns } = parseFinalOutputFromJsonl(stdout)
185
199
  run.kill = undefined
@@ -628,15 +628,11 @@ async function runBackgroundMode(params: SubagentParamsStatic, agents: AgentConf
628
628
  const invocation = getPiInvocation(args)
629
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
- // The run outlives the session that started it, and every pi call throws once
633
- // that session is disposed; an escaping error here would reach Node as an
634
- // uncaughtException and take the process down with it.
635
- try {
636
- pi.sendMessage({ customType: 'subagent-background', content: backgroundCompletionText(run), display: true }, { triggerTurn: true })
637
- } catch {
638
- // the session that asked for this run is gone; nothing left to notify
639
- }
635
+ pi.sendMessage({ customType: 'subagent-background', content: backgroundCompletionText(run), display: true }, { triggerTurn: true })
640
636
  })
641
637
  if (id === null) {
642
638
  // Lost the cap race to a parallel batch: the atomic check inside startBackgroundRun refused.
@@ -1113,11 +1109,8 @@ function renderParallelResult(results: SingleResult[], expanded: boolean, theme:
1113
1109
 
1114
1110
  export default function subagentExtension(pi: ExtensionAPI) {
1115
1111
  const notifyBackgroundCompletion = (run: { id: string; agent: string; state: string; turns: number; output?: string }): void => {
1116
- try {
1117
- pi.sendMessage({ customType: 'subagent-background', content: backgroundCompletionText(run), display: true }, { triggerTurn: true })
1118
- } catch {
1119
- // same as above: the session that started the run may already be gone
1120
- }
1112
+ // Runs through driveRun's guard, same as the background-mode callback above.
1113
+ pi.sendMessage({ customType: 'subagent-background', content: backgroundCompletionText(run), display: true }, { triggerTurn: true })
1121
1114
  }
1122
1115
 
1123
1116
  // Claude surfaces each agent's description so the model can pick one autonomously.
@@ -337,8 +337,8 @@ export default function todoExtension(pi: ExtensionAPI) {
337
337
 
338
338
  const details = msg.details as (Omit<TodoDetails, 'todos'> & { todos?: LegacyTodo[] }) | undefined
339
339
  // pi persists a failed tool call as `details: {}`, which is truthy: a rejected
340
- // or blocked todo call would otherwise throw here and break replay for the rest
341
- // of the session, losing the list on every resume, fork and compaction.
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
342
  if (Array.isArray(details?.todos)) {
343
343
  replayTodos = details.todos.map(normalizeTodo)
344
344
  replayNextId = details.nextId
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-code",
3
- "version": "1.0.2",
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",