pi-code 1.0.2 → 1.0.4

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` |
@@ -86,9 +86,16 @@ export default function commandsExtension(pi: ExtensionAPI) {
86
86
  // fire-and-forget, so the restore would land before the agent ever read the tool
87
87
  // list, leaving the command running with everything enabled.
88
88
  if (parsed.allowedTools) {
89
- const saved = pi.getActiveTools()
90
- pendingRestore = saved
91
- pi.setActiveTools(parsed.allowedTools.filter((tool) => saved.includes(tool)))
89
+ // Only the first restriction in a turn sees the unrestricted set; a second
90
+ // command must grant and restore against that original set, or its own tools
91
+ // are intersected away by the first command's narrowing.
92
+ const original = pendingRestore ?? pi.getActiveTools()
93
+ pendingRestore = original
94
+ const granted = parsed.allowedTools.filter((tool) => original.includes(tool))
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
  }
@@ -110,11 +110,14 @@ function readImport(target: string, fromDir: string, home: string, allowedRoots:
110
110
  return null
111
111
  }
112
112
  if (seen.has(real)) return null
113
- seen.add(real)
114
113
  if (!isUnder(real, allowedRoots)) return null
115
114
  try {
116
115
  // real may be a directory (EISDIR) or vanish after the realpath (ENOENT/EACCES).
117
- return { real, body: fs.readFileSync(real, 'utf-8') }
116
+ const body = fs.readFileSync(real, 'utf-8')
117
+ // Only a consumed file dedupes: marking a blocked or unreadable target seen
118
+ // would let one reader's failure suppress the import for a later, allowed one.
119
+ seen.add(real)
120
+ return { real, body }
118
121
  } catch {
119
122
  return null
120
123
  }
@@ -137,12 +137,25 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
137
137
  pruneCheckpointRepos(checkpointsRoot, CHECKPOINT_RETENTION_DAYS, shadowDir)
138
138
  const check = await pi.exec('git', ['--git-dir', shadowDir, 'rev-parse', '--git-dir'], { cwd: ctx.cwd })
139
139
  if (check.code !== 0) {
140
- await pi.exec('git', ['init', '--bare', '-b', 'main', shadowDir], { cwd: ctx.cwd })
140
+ const init = await pi.exec('git', ['init', '--bare', '-b', 'main', shadowDir], { cwd: ctx.cwd })
141
+ if (init.code !== 0) {
142
+ // Every later snapshot fails against the missing repo, so without this the
143
+ // user first learns /rewind is dead at the moment they need it.
144
+ ctx.ui.notify(`Checkpoints disabled: ${init.stderr.trim() || 'git init failed'}`, 'warning')
145
+ return
146
+ }
141
147
  await pi.exec('git', ['--git-dir', shadowDir, 'config', 'user.email', 'checkpoint@pi-code'], { cwd: ctx.cwd })
142
148
  await pi.exec('git', ['--git-dir', shadowDir, 'config', 'user.name', 'pi-code-checkpoint'], { cwd: ctx.cwd })
143
149
  }
144
150
  }
145
151
 
152
+ /** `checkout -f <ref> -- .` errors when the ref's tree holds no files, so an empty
153
+ * snapshot restores as a no-op rather than vetoing the whole rewind. */
154
+ async function snapshotIsEmpty(ref: string): Promise<boolean> {
155
+ const files = await gitShadow(['ls-tree', '-r', '--name-only', ref])
156
+ return files.code === 0 && files.stdout.trim() === ''
157
+ }
158
+
146
159
  async function snapshot(): Promise<{ ref: string; createdAt: string } | undefined> {
147
160
  const createdAt = new Date().toISOString()
148
161
  const add = await gitShadow(['add', '-A'])
@@ -175,6 +188,10 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
175
188
  ctx.ui.notify('Checkpoint has no code snapshot; code left untouched', 'warning')
176
189
  return true
177
190
  }
191
+ if (await snapshotIsEmpty(checkpoint.ref)) {
192
+ ctx.ui.notify('Checkpoint has no files; code left untouched', 'warning')
193
+ return true
194
+ }
178
195
  const result = await gitShadow(['checkout', '-f', checkpoint.ref, '--', '.'])
179
196
  if (result.code !== 0) {
180
197
  ctx.ui.notify(`Code restore failed: ${result.stderr.trim()}`, 'warning')
@@ -248,6 +265,10 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
248
265
 
249
266
  const choice = await ctx.ui.select('Restore code state?', ['Yes, restore code to that point', 'No, keep current code'])
250
267
  if (choice?.startsWith('Yes')) {
268
+ if (await snapshotIsEmpty(checkpoint.ref)) {
269
+ ctx.ui.notify('Checkpoint has no files; code left untouched', 'warning')
270
+ return
271
+ }
251
272
  const result = await gitShadow(['checkout', '-f', checkpoint.ref, '--', '.'])
252
273
  ctx.ui.notify(result.code === 0 ? 'Code restored to checkpoint' : `Restore failed: ${result.stderr.trim()}`, result.code === 0 ? 'info' : 'warning')
253
274
  }
@@ -78,6 +78,8 @@ export interface HookRunResult {
78
78
  stderr: string
79
79
  /** The hook was killed at its timeout, so its exit code carries no verdict. */
80
80
  timedOut: boolean
81
+ /** The process errored before delivering a verdict (spawn failure, EIO). */
82
+ spawnFailed?: boolean
81
83
  }
82
84
  export type HookRunner = (command: string, payload: unknown, timeoutMs: number, projectDir?: string) => Promise<HookRunResult>
83
85
 
@@ -97,8 +99,14 @@ export function loadHooks(files: string[]): HooksConfig {
97
99
  } catch {
98
100
  continue
99
101
  }
100
- for (const [event, matchers] of Object.entries(parsed.hooks ?? {})) {
101
- if (Array.isArray(matchers)) config[event] = [...(config[event] ?? []), ...matchers]
102
+ for (const [event, matchers] of Object.entries(parsed?.hooks ?? {})) {
103
+ if (!Array.isArray(matchers)) continue
104
+ // Entries are validated here rather than where they run: a hand-edited settings
105
+ // file that writes `hooks` as an object instead of a list used to throw out of
106
+ // the tool_call handler, and pi turns that into an error result, so every tool
107
+ // call for the rest of the session failed with an opaque type error.
108
+ const usable = matchers.filter((entry) => isUsableMatcher(entry, file, event))
109
+ if (usable.length > 0) config[event] = [...(config[event] ?? []), ...usable]
102
110
  }
103
111
  }
104
112
  return config
@@ -124,6 +132,26 @@ function exactListApplies(matcher: string, names: readonly string[]): boolean {
124
132
  return names.some((name) => tokens.has(foldName(name)))
125
133
  }
126
134
 
135
+ /** A matcher entry pi-code can run: an object whose `hooks` is a list. Anything else
136
+ * is reported by name and skipped, so one bad entry costs its own hooks, not the
137
+ * session's tool calls. */
138
+ function isUsableMatcher(entry: unknown, file: string, event: string): entry is HookMatcher {
139
+ const candidate = entry as HookMatcher | null
140
+ if (candidate === null || typeof candidate !== 'object') {
141
+ console.warn(`pi-code-hooks: ignoring a non-object ${event} entry in ${file}`)
142
+ return false
143
+ }
144
+ if (candidate.hooks !== undefined && !Array.isArray(candidate.hooks)) {
145
+ console.warn(`pi-code-hooks: ignoring ${event} entry in ${file}: "hooks" must be a list`)
146
+ return false
147
+ }
148
+ if (candidate.matcher !== undefined && typeof candidate.matcher !== 'string') {
149
+ console.warn(`pi-code-hooks: ignoring ${event} entry in ${file}: "matcher" must be a string`)
150
+ return false
151
+ }
152
+ return true
153
+ }
154
+
127
155
  function matcherApplies(matcher: string | undefined, names: readonly string[]): boolean {
128
156
  if (!matcher || matcher === '*') return true
129
157
  if (EXACT_MATCHER.test(matcher)) return exactListApplies(matcher, names)
@@ -239,7 +267,9 @@ export const runHookCommand: HookRunner = (command, payload, timeoutMs, projectD
239
267
  if (stderr.length < MAX_HOOK_OUTPUT) stderr += chunk
240
268
  })
241
269
  child.on('close', (code) => finish({ code: code ?? 0, stdout, stderr, timedOut: false }))
242
- child.on('error', () => finish({ code: 0, stdout, stderr, timedOut: false }))
270
+ // Marked rather than silently read as a clean run: under fd exhaustion a
271
+ // deny-list guard that never spawned would otherwise pass as an allow.
272
+ child.on('error', (error) => finish({ code: 0, stdout, stderr: stderr || error.message, timedOut: false, spawnFailed: true }))
243
273
  // A hook that exits without reading stdin (e.g. `exit 2`) closes the pipe first,
244
274
  // so ignore EPIPE on this write rather than crashing the host process.
245
275
  child.stdin?.on('error', () => {})
@@ -268,21 +298,42 @@ function replaceRecord(target: Record<string, unknown>, next: Record<string, unk
268
298
  Object.assign(target, next)
269
299
  }
270
300
 
271
- /** Run PreToolUse hooks for a tool; the first blocking verdict wins. For MCP tools the
272
- * matcher sees both the pi name and the Claude alias, and the payload reports the alias,
273
- * which is the name a Claude-written hook script expects in tool_name. A hook's
274
- * hookSpecificOutput.updatedInput replaces the tool input in place before the permission
275
- * decision applies, and later hooks see the rewritten input in their payload. */
301
+ /** Claude surfaces a hook error notice and the action proceeds; silence would read a
302
+ * guard that never ran as a clean allow. */
303
+ function surfaceHookFailures(commands: HookCommand[], results: HookRunResult[], notify?: SystemMessageSink): void {
304
+ if (!notify) return
305
+ for (const [i, result] of results.entries()) {
306
+ if (result.spawnFailed) notify(`Hook failed to run: ${commands[i].command}: ${result.stderr.trim() || 'unknown error'}`)
307
+ }
308
+ }
309
+
310
+ /** Run PreToolUse hooks for a tool, in parallel as Claude does; the first blocking
311
+ * verdict in config order wins. For MCP tools the matcher sees both the pi name and
312
+ * the Claude alias, and the payload reports the alias, which is the name a
313
+ * Claude-written hook script expects in tool_name. Every hook sees the original
314
+ * tool input; hookSpecificOutput.updatedInput replaces the input in place as each
315
+ * hook completes, so with several rewrites the last to finish takes effect, which
316
+ * is Claude's documented (non-deterministic) behavior. */
276
317
  export async function runPreToolUse(config: HooksConfig, toolName: string, toolInput: unknown, runner: HookRunner, claudeName?: string, onSystemMessage?: SystemMessageSink): Promise<HookDecision> {
277
318
  const names = claudeName ? [toolName, claudeName] : [toolName]
278
- for (const command of matchingCommands(config.PreToolUse, names)) {
279
- const result = await runner(command.command, { hook_event_name: 'PreToolUse', tool_name: claudeName ?? toolName, tool_input: toolInput }, timeoutMs(command))
319
+ const commands = matchingCommands(config.PreToolUse, names)
320
+ const results = await Promise.all(
321
+ commands.map((command) =>
322
+ runner(command.command, { hook_event_name: 'PreToolUse', tool_name: claudeName ?? toolName, tool_input: toolInput }, timeoutMs(command)).then((result) => {
323
+ const updated = tryParseJson(result.stdout)?.hookSpecificOutput?.updatedInput
324
+ if (isRecord(updated) && isRecord(toolInput)) replaceRecord(toolInput, updated)
325
+ return result
326
+ }),
327
+ ),
328
+ )
329
+ surfaceHookFailures(commands, results, onSystemMessage)
330
+ for (const [i, result] of results.entries()) {
280
331
  // A killed hook never reached its verdict, and SIGKILL leaves a null exit code that
281
332
  // would otherwise read as a clean allow. Fail closed instead.
282
- if (result.timedOut) return { block: true, reason: `Hook timed out after ${timeoutMs(command)}ms: ${command.command}` }
283
- if (onSystemMessage) surfaceSystemMessages([result], onSystemMessage)
284
- const updated = tryParseJson(result.stdout)?.hookSpecificOutput?.updatedInput
285
- if (isRecord(updated) && isRecord(toolInput)) replaceRecord(toolInput, updated)
333
+ if (result.timedOut) return { block: true, reason: `Hook timed out after ${timeoutMs(commands[i])}ms: ${commands[i].command}` }
334
+ }
335
+ if (onSystemMessage) surfaceSystemMessages(results, onSystemMessage)
336
+ for (const result of results) {
286
337
  const decision = interpretHookResult(result.code, result.stdout, result.stderr)
287
338
  if (decision.block) return decision
288
339
  }
@@ -317,14 +368,19 @@ function promptContext(stdout: string): string {
317
368
  return stdout.trim()
318
369
  }
319
370
 
320
- /** Run UserPromptSubmit hooks: the first blocking verdict wins; otherwise their
321
- * additional context is concatenated for injection ahead of the prompt. */
371
+ /** Run UserPromptSubmit hooks, in parallel as Claude does: the first blocking
372
+ * verdict in config order wins; otherwise their additional context is concatenated
373
+ * in config order for injection ahead of the prompt. */
322
374
  export async function runUserPromptSubmit(config: HooksConfig, prompt: string, runner: HookRunner, onSystemMessage?: SystemMessageSink): Promise<PromptDecision> {
375
+ const commands = matchingCommands(config.UserPromptSubmit, 'UserPromptSubmit')
376
+ const results = await Promise.all(commands.map((command) => runner(command.command, { hook_event_name: 'UserPromptSubmit', prompt }, timeoutMs(command))))
377
+ surfaceHookFailures(commands, results, onSystemMessage)
378
+ for (const [i, result] of results.entries()) {
379
+ if (result.timedOut) return { block: true, reason: `Hook timed out after ${timeoutMs(commands[i])}ms: ${commands[i].command}`, context: '' }
380
+ }
381
+ if (onSystemMessage) surfaceSystemMessages(results, onSystemMessage)
323
382
  const contexts: string[] = []
324
- for (const command of matchingCommands(config.UserPromptSubmit, 'UserPromptSubmit')) {
325
- const result = await runner(command.command, { hook_event_name: 'UserPromptSubmit', prompt }, timeoutMs(command))
326
- if (result.timedOut) return { block: true, reason: `Hook timed out after ${timeoutMs(command)}ms: ${command.command}`, context: '' }
327
- if (onSystemMessage) surfaceSystemMessages([result], onSystemMessage)
383
+ for (const result of results) {
328
384
  const decision = interpretHookResult(result.code, result.stdout, result.stderr)
329
385
  if (decision.block) return { block: true, reason: decision.reason, context: '' }
330
386
  const context = promptContext(result.stdout)
@@ -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
68
+ }
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))]
47
110
  }
48
111
 
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, '') : ''
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) : ''
52
116
  }
53
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
  }
@@ -79,16 +148,21 @@ export function splitArgs(args: string): string[] {
79
148
  return out
80
149
  }
81
150
 
82
- /** Claude's substitutions: `$ARGUMENTS`, `$@`, `$1`..`$n`, `${n:-default}`. An
83
- * unfilled positional becomes empty rather than leaking its literal token. */
151
+ /** Claude's substitutions: `$ARGUMENTS`, `$@`, `$1`..`$n`, `${n:-default}`, and `\$`
152
+ * for a literal dollar. An unfilled positional becomes empty rather than leaking its
153
+ * literal token. One pass with a replacer function: sequential string passes both
154
+ * interpreted `$&`-style metacharacters in the arguments and re-scanned substituted
155
+ * text, so `$` sequences the user typed were consumed as tokens. */
84
156
  export function substituteArgs(body: string, args: string): string {
85
157
  const parts = splitArgs(args)
86
- return body
87
- .replaceAll(/\$\{(\d+):-([^}]*)\}/g, (_m, index: string, fallback: string) => parts[Number(index) - 1] ?? fallback)
88
- .replaceAll(/\$\{ARGUMENTS:-([^}]*)\}/g, (_m, fallback: string) => (args.trim() ? args.trim() : fallback))
89
- .replaceAll(/\$ARGUMENTS\b/g, args.trim())
90
- .replaceAll('$@', args.trim())
91
- .replaceAll(/\$(\d+)/g, (_m, index: string) => parts[Number(index) - 1] ?? '')
158
+ const all = args.trim()
159
+ return body.replaceAll(/\\\$|\$\{(\d+):-([^}]*)\}|\$\{ARGUMENTS:-([^}]*)\}|\$ARGUMENTS\b|\$@|\$(\d+)/g, (token, index?: string, fallback?: string, argsFallback?: string, position?: string) => {
160
+ if (token === '\\$') return '$'
161
+ if (index !== undefined) return parts[Number(index) - 1] ?? fallback ?? ''
162
+ if (argsFallback !== undefined) return all || argsFallback
163
+ if (position !== undefined) return parts[Number(position) - 1] ?? ''
164
+ return all
165
+ })
92
166
  }
93
167
 
94
168
  /** `a/b/c.md` becomes Claude's `a:b:c`. */
@@ -170,12 +244,17 @@ export async function expandDynamicContent(body: string, cwd: string, exec: Comm
170
244
  bashMatch = bashPattern.exec(body)
171
245
  }
172
246
 
173
- let expanded = body
247
+ // Splice by recorded position: a textual replace would interpret `$` sequences in
248
+ // the command's output and could hit an identical fenced copy of the span instead.
249
+ let expanded = ''
250
+ let cursor = 0
174
251
  for (const entry of commands) {
175
252
  const result = await exec(entry.command)
176
253
  const output = result.code === 0 ? result.stdout.trimEnd() : `(command failed: ${entry.command})\n${result.stderr.trim() || result.stdout.trim()}`
177
- expanded = expanded.replace(entry.span, output)
254
+ expanded += body.slice(cursor, entry.index) + output
255
+ cursor = entry.index + entry.span.length
178
256
  }
257
+ expanded += body.slice(cursor)
179
258
 
180
259
  // Ranges are recomputed: command output can change offsets.
181
260
  const fencedAfter = fencedRanges(expanded)
@@ -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,42 @@ 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
+ // A server that dies mid-session would otherwise stay "connected" in /mcp
486
+ // while every call fails with the SDK's bare "Not connected"; flip the
487
+ // status and free the name so a later session start can reconnect it.
488
+ client.onclose = () => {
489
+ if (clients.get(name) !== client) return
490
+ clients.delete(name)
491
+ status.set(name, { state: 'disconnected', tools: 0 })
492
+ }
493
+ } catch (error) {
494
+ status.set(name, { state: `failed: ${error instanceof Error ? error.message : String(error)}`, tools: 0 })
495
+ // Connected but failed after (tool listing hung or errored): left in the
496
+ // map, the client idles its process for the whole session and the
497
+ // duplicate-name guard blocks the name for every later attempt.
498
+ const leaked = clients.get(name)
499
+ if (leaked) {
500
+ clients.delete(name)
501
+ void leaked.close().catch(() => {})
502
+ }
503
+ }
504
+ }),
505
+ )
434
506
  }
435
507
 
436
508
  /** Connect the project scope under the per-server policy. Returns whether the scope
@@ -447,16 +519,15 @@ export default async function mcpExtension(pi: ExtensionAPI) {
447
519
  return true
448
520
  }
449
521
 
450
- let userConnected = false
451
522
  let projectConnected = false
452
523
 
453
524
  pi.on('session_start', async (_event, ctx) => {
454
525
  // Connecting spawns processes and opens sockets, so it belongs here rather than in
455
526
  // the factory: pi runs the factory for invocations that never start a session.
456
- if (!userConnected) {
457
- userConnected = true
458
- await connectServers(loadUserScope(os.homedir(), ctx.cwd))
459
- }
527
+ // Names still connected are filtered out, so a later session start only retries
528
+ // servers that failed or whose transport dropped, without duplicate-name warnings.
529
+ const userServers = Object.fromEntries(Object.entries(loadUserScope(os.homedir(), ctx.cwd)).filter(([name]) => !clients.has(name)))
530
+ if (Object.keys(userServers).length > 0) await connectServers(userServers)
460
531
  // A project .mcp.json can run arbitrary commands on connect, so only honor it once
461
532
  // the project is trusted. Per-server settings refine that: disabled servers never
462
533
  // connect, servers the user consented to individually connect without the