pi-code 0.2.4 → 0.3.1

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
@@ -37,16 +37,16 @@ One `pi install` and everything below loads on the next start. `pi list` shows w
37
37
  |---|---|---|
38
38
  | Global + project rules | `~/.claude/rules`, `.claude/rules` (+ `paths:` frontmatter scoping) | `claude-rules.ts` |
39
39
  | Custom slash commands | `.claude/commands/*.md` → pi prompt templates | `commands.ts` |
40
- | Skills | `.claude/skills` → pi skill discovery | `skills.ts` |
40
+ | Skills | `.claude/skills` → pi skill discovery (pi reads `name`, `description`, `disable-model-invocation`; `allowed-tools` is inert in pi's loader) | `skills.ts` |
41
41
  | Hooks | `.claude/settings.json` hooks on pi lifecycle events | `hooks.ts` |
42
42
  | Output styles | `.claude/output-styles` + active `outputStyle`, `/output-style` switcher | `output-styles.ts` |
43
- | CLAUDE.md `@imports` | resolves `@path` imports pi's native loader skips | `context-imports.ts` |
43
+ | CLAUDE.md `@imports` | resolves `@path` imports pi's native loader skips; loads `CLAUDE.local.md` (approval-gated) | `context-imports.ts` |
44
44
  | MCP servers | user `~/.claude.json`, `~/.pi/agent/mcp.json` (loaded on session start); project `.mcp.json`, `.pi/mcp.json` (only once the project is approved); stdio, HTTP, SSE | `mcp.ts` |
45
- | Project trust | prompts before loading project config (MCP servers, hooks, agents) that pi would otherwise trust silently | `project-approval.ts` |
45
+ | Project trust | prompts before loading project config (MCP servers, hooks, agents, rules, output styles) that pi would otherwise trust silently | `internal/project-approval.ts` |
46
46
  | Subagents / Task | `~/.claude/agents` and `~/.pi/agent/agents`, plus project `.claude/agents` and `.pi/agents`; background runs | `subagent/` |
47
47
  | Plan mode | `plan_mode_complete` tool, exact tool snapshot/restore | `plan-mode/` |
48
48
  | Todo list | persistent overlay, status machine, compaction-safe | `todo.ts` |
49
- | Checkpoints / rewind | shadow-repo snapshots, hard-reset restore | `git-checkpoint.ts` |
49
+ | Checkpoints / rewind | shadow-repo snapshots; restore overwrites checkpointed files, keeps files created later | `git-checkpoint.ts` |
50
50
  | Persistent memory | per-project memories, index injected each session | `memory.ts` |
51
51
  | WebSearch / WebFetch | key-free DuckDuckGo search, SSRF-guarded fetch | `web.ts` |
52
52
  | AskUserQuestion | vendored example | `question.ts` |
@@ -55,7 +55,7 @@ One `pi install` and everything below loads on the next start. `pi list` shows w
55
55
 
56
56
  `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.
57
57
 
58
- `output-guard.ts` and `web-transport.ts` are shared internals (context-budget truncation, DNS-pinned fetch) with no feature of their own; the tools above use them.
58
+ `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.
59
59
 
60
60
  Vendored bases (`question`, `notify`, `status-line`) come from pi's MIT example extensions (see [LICENSE](LICENSE)).
61
61
 
@@ -64,7 +64,8 @@ Vendored bases (`question`, `notify`, `status-line`) come from pi's MIT example
64
64
  ```bash
65
65
  npm install
66
66
  npm run check # biome + strict tsc + vitest, the whole gate
67
- scripts/e2e.sh # drives the real pi TUI via tmux (needs a working model)
67
+ scripts/e2e.sh # quick smoke of the real pi TUI via tmux (needs a working model)
68
+ scripts/e2e-full.sh # every README feature end to end, model turns included (5-15 min)
68
69
  scripts/record-demos.sh # re-records demos/*.tape with vhs at low thinking
69
70
  ```
70
71
 
@@ -2,13 +2,14 @@
2
2
  * Claude Rules Extension
3
3
  *
4
4
  * Replicates Claude Code's rules loading:
5
- * - Global rules (~/.claude/rules/*.md) are inlined in full into the system prompt.
6
- * - Project rules (.claude/rules/*.md) are listed as pointers the agent can read on demand.
5
+ * - Unscoped global rules (~/.claude/rules/*.md) are inlined in full into the system prompt.
6
+ * - Path-scoped global rules and all project rules (.claude/rules/*.md) are listed as
7
+ * pointers the agent reads on demand.
7
8
  *
8
9
  * Path-scoped rules: a rule file may declare `paths:` frontmatter (a glob or
9
- * list of globs). Project-rule pointers surface that scope so the agent knows
10
- * to read the rule when working on matching files. Frontmatter is stripped
11
- * from inlined global rules.
10
+ * list of globs). Pointers surface that scope so the agent knows to read the
11
+ * rule when working on matching files. Frontmatter is stripped from inlined
12
+ * global rules.
12
13
  *
13
14
  * Adapted from the pi v0.74.2 claude-rules example.
14
15
  */
@@ -18,6 +19,8 @@ import * as os from 'node:os'
18
19
  import * as path from 'node:path'
19
20
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
20
21
 
22
+ import { isProjectApproved } from './internal/project-approval.js'
23
+
21
24
  export interface Frontmatter {
22
25
  paths: string[]
23
26
  body: string
@@ -61,39 +64,82 @@ export function parseFrontmatter(content: string): Frontmatter {
61
64
  return { paths: parsePaths(match[1]), body: content.slice(match[0].length) }
62
65
  }
63
66
 
64
- /** A project-rule pointer line, annotated with its path scope when present. */
65
- export function formatRulePointer(rel: string, paths: string[]): string {
66
- const ref = `- .claude/rules/${rel}`
67
+ /** A rule pointer line, annotated with its path scope when present. */
68
+ export function formatRulePointer(rel: string, paths: string[], base = '.claude/rules'): string {
69
+ const ref = `- ${base}/${rel}`
67
70
  return paths.length > 0 ? `${ref} — applies when working on: ${paths.join(', ')}` : ref
68
71
  }
69
72
 
70
- /** Recursively find all .md files in a directory. */
71
- function findMarkdownFiles(dir: string, basePath = ''): string[] {
72
- if (!fs.existsSync(dir)) return []
73
+ /** A dirent's kind with symlinks resolved; nulls a dangling link. */
74
+ function classifyEntry(entry: fs.Dirent, fullPath: string): { isDir: boolean; isFile: boolean } | null {
75
+ if (!entry.isSymbolicLink()) return { isDir: entry.isDirectory(), isFile: entry.isFile() }
76
+ try {
77
+ const stat = fs.statSync(fullPath)
78
+ return { isDir: stat.isDirectory(), isFile: stat.isFile() }
79
+ } catch {
80
+ return null
81
+ }
82
+ }
83
+
84
+ /** Recursively find all .md files, following symlinks (Claude Code documents symlinked
85
+ * shared rule dirs); `visited` realpaths keep a circular link from recursing forever. */
86
+ function findMarkdownFiles(dir: string, basePath = '', visited = new Set<string>()): string[] {
73
87
  const results: string[] = []
74
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
88
+ try {
89
+ const real = fs.realpathSync(dir)
90
+ if (visited.has(real)) return results
91
+ visited.add(real)
92
+ } catch {
93
+ return results
94
+ }
95
+ let entries: fs.Dirent[]
96
+ try {
97
+ entries = fs.readdirSync(dir, { withFileTypes: true })
98
+ } catch {
99
+ return results // a missing or unreadable directory must not take down session start
100
+ }
101
+ for (const entry of entries) {
75
102
  const relativePath = basePath ? `${basePath}/${entry.name}` : entry.name
76
- if (entry.isDirectory()) {
77
- results.push(...findMarkdownFiles(path.join(dir, entry.name), relativePath))
78
- } else if (entry.isFile() && entry.name.endsWith('.md')) {
103
+ const fullPath = path.join(dir, entry.name)
104
+ const kind = classifyEntry(entry, fullPath)
105
+ if (!kind) continue
106
+ if (kind.isDir) {
107
+ results.push(...findMarkdownFiles(fullPath, relativePath, visited))
108
+ } else if (kind.isFile && entry.name.endsWith('.md')) {
79
109
  results.push(relativePath)
80
110
  }
81
111
  }
82
112
  return results
83
113
  }
84
114
 
85
- function readGlobalRules(globalRulesDir: string): string {
86
- return findMarkdownFiles(globalRulesDir)
87
- .map((file) => parseFrontmatter(fs.readFileSync(path.join(globalRulesDir, file), 'utf-8')).body.trim())
88
- .filter((content) => content.length > 0)
89
- .join('\n\n')
90
- }
91
-
92
115
  interface ProjectRule {
93
116
  rel: string
94
117
  paths: string[]
95
118
  }
96
119
 
120
+ interface GlobalRules {
121
+ inline: string
122
+ scoped: ProjectRule[]
123
+ }
124
+
125
+ /** Unscoped global rules are inlined; path-scoped ones keep their scope as pointers,
126
+ * mirroring Claude Code, where scoped rules attach only to matching files. */
127
+ function readGlobalRules(globalRulesDir: string): GlobalRules {
128
+ const inline: string[] = []
129
+ const scoped: ProjectRule[] = []
130
+ for (const file of findMarkdownFiles(globalRulesDir)) {
131
+ let parsed: Frontmatter
132
+ try {
133
+ parsed = parseFrontmatter(fs.readFileSync(path.join(globalRulesDir, file), 'utf-8'))
134
+ } catch {
135
+ continue // one unreadable rule must not take down session start
136
+ }
137
+ if (parsed.paths.length > 0) scoped.push({ rel: file, paths: parsed.paths })
138
+ else if (parsed.body.trim().length > 0) inline.push(parsed.body.trim())
139
+ }
140
+ return { inline: inline.join('\n\n'), scoped }
141
+ }
142
+
97
143
  function readProjectRules(projectRulesDir: string): ProjectRule[] {
98
144
  return findMarkdownFiles(projectRulesDir).map((rel) => {
99
145
  try {
@@ -106,26 +152,34 @@ function readProjectRules(projectRulesDir: string): ProjectRule[] {
106
152
 
107
153
  export default function claudeRulesExtension(pi: ExtensionAPI) {
108
154
  const globalRulesDir = path.join(os.homedir(), '.claude', 'rules')
109
- let globalRules = ''
155
+ let globalRules: GlobalRules = { inline: '', scoped: [] }
110
156
  let projectRules: ProjectRule[] = []
111
157
 
112
158
  pi.on('session_start', async (_event, ctx) => {
113
159
  globalRules = readGlobalRules(globalRulesDir)
114
- // Project rule filenames and their paths: frontmatter are surfaced in the system prompt,
115
- // so read them only for a trusted project.
116
- const trusted = ctx.isProjectTrusted?.() ?? false
117
- projectRules = trusted ? readProjectRules(path.join(ctx.cwd, '.claude', 'rules')) : []
118
-
119
- if (globalRules.length > 0 || projectRules.length > 0) {
120
- ctx.ui.notify(`Rules loaded: global ${globalRules.length > 0 ? 'yes' : 'no'}, project ${projectRules.length}`, 'info')
160
+ // Project rule filenames and their paths: frontmatter are surfaced in the system prompt.
161
+ // isProjectTrusted alone is true for a repo pi never asked about; see project-approval.
162
+ const approved = await isProjectApproved(ctx)
163
+ projectRules = approved ? readProjectRules(path.join(ctx.cwd, '.claude', 'rules')) : []
164
+
165
+ const hasGlobal = globalRules.inline.length > 0 || globalRules.scoped.length > 0
166
+ if (hasGlobal || projectRules.length > 0) {
167
+ ctx.ui.notify(`Rules loaded: global ${hasGlobal ? 'yes' : 'no'}, project ${projectRules.length}`, 'info')
121
168
  }
122
169
  })
123
170
 
124
171
  pi.on('before_agent_start', async (event) => {
125
172
  let addition = ''
126
173
 
127
- if (globalRules.length > 0) {
128
- addition += `\n\n## Global Rules\n\nThese rules always apply:\n\n${globalRules}`
174
+ if (globalRules.inline.length > 0 || globalRules.scoped.length > 0) {
175
+ addition += `\n\n## Global Rules`
176
+ if (globalRules.inline.length > 0) {
177
+ addition += `\n\nThese rules always apply:\n\n${globalRules.inline}`
178
+ }
179
+ if (globalRules.scoped.length > 0) {
180
+ const scopedList = globalRules.scoped.map((rule) => formatRulePointer(rule.rel, rule.paths, '~/.claude/rules')).join('\n')
181
+ addition += `\n\nPath-scoped global rules, available in ~/.claude/rules/:\n\n${scopedList}\n\nRead the relevant rule file with the read tool before working on the files it covers.`
182
+ }
129
183
  }
130
184
 
131
185
  if (projectRules.length > 0) {
@@ -2,12 +2,13 @@
2
2
  * Context Imports Extension
3
3
  *
4
4
  * pi loads CLAUDE.md / AGENTS.md context files natively but does not resolve
5
- * Claude Code's `@path` imports inside them. This fills that one gap: on
6
- * before_agent_start it reads the already-loaded context files from
7
- * systemPromptOptions, resolves any `@path` imports (recursive, depth-capped,
8
- * cycle-safe, budget-capped; ~ expands to home, relative paths resolve against
9
- * the importing file), and appends ONLY the imported content. pi already
10
- * injected the base files, so nothing is duplicated.
5
+ * Claude Code's `@path` imports inside them, and skips CLAUDE.local.md
6
+ * entirely. This fills both gaps: on before_agent_start it reads the
7
+ * already-loaded context files from systemPromptOptions, resolves any `@path`
8
+ * imports (recursive, depth-capped, cycle-safe, budget-capped; ~ expands to
9
+ * home, relative paths resolve against the importing file), and appends the
10
+ * imported content plus the approval-gated CLAUDE.local.md body. The base
11
+ * files pi already injected are never re-appended.
11
12
  *
12
13
  * Security: context files can come from an untrusted project, so imports are
13
14
  * confined (after resolving symlinks) to the working directory and the user's
@@ -26,6 +27,8 @@ import * as os from 'node:os'
26
27
  import * as path from 'node:path'
27
28
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
28
29
 
30
+ import { isProjectApproved } from './internal/project-approval.js'
31
+
29
32
  const MAX_IMPORT_DEPTH = 5
30
33
  export const MAX_IMPORT_FILES = 50
31
34
  export const MAX_IMPORT_BYTES = 256 * 1024
@@ -70,17 +73,29 @@ export interface ImportBudget {
70
73
 
71
74
  export const createImportBudget = (): ImportBudget => ({ files: MAX_IMPORT_FILES, bytes: MAX_IMPORT_BYTES, dropped: 0 })
72
75
 
73
- /** The `@path` targets of a context file, in document order, skipping fenced code blocks. */
76
+ function fenceMarker(lineStart: string): string | null {
77
+ if (lineStart.startsWith('```')) return '`'
78
+ if (lineStart.startsWith('~~~')) return '~'
79
+ return null
80
+ }
81
+
82
+ /** The `@path` targets of a context file, in document order. Claude Code evaluates
83
+ * imports neither in fenced code blocks (backtick or tilde) nor in inline spans. */
74
84
  function importTargets(content: string): string[] {
75
85
  const targets: string[] = []
76
- let inFence = false
86
+ // A fence only closes with the character that opened it: a backtick-fenced
87
+ // example may legitimately contain tilde-fence lines, and vice versa.
88
+ let fence: string | null = null
77
89
  for (const line of content.split('\n')) {
78
- if (line.trimStart().startsWith('```')) {
79
- inFence = !inFence
90
+ const marker = fenceMarker(line.trimStart())
91
+ if (marker !== null && (fence === null || fence === marker)) {
92
+ fence = fence === null ? marker : null
80
93
  continue
81
94
  }
82
- if (inFence) continue
83
- for (const match of line.matchAll(/(^|\s)@(\S+)/g)) targets.push(match[2])
95
+ if (fence !== null) continue
96
+ // Backreference so a multi-backtick span (``literal `@x` backticks``) strips whole.
97
+ const withoutSpans = line.replace(/(`+)[^`]*?\1/g, '')
98
+ for (const match of withoutSpans.matchAll(/(^|\s)@(\S+)/g)) targets.push(match[2])
84
99
  }
85
100
  return targets
86
101
  }
@@ -146,8 +161,25 @@ export function rootsForImporter(importer: string, home: string, cwd: string): s
146
161
  }
147
162
 
148
163
  export default function contextImportsExtension(pi: ExtensionAPI) {
164
+ let localContext: { path: string; content: string } | null = null
165
+
166
+ pi.on('session_start', async (_event, ctx) => {
167
+ // CLAUDE.local.md is Claude Code's personal sidecar of CLAUDE.md; pi's own loader
168
+ // skips it. A cloned repo can ship one, so it is gated like other project config.
169
+ localContext = null
170
+ const candidate = path.join(ctx.cwd, 'CLAUDE.local.md')
171
+ if (!fs.existsSync(candidate)) return
172
+ if (!(await isProjectApproved(ctx))) return
173
+ try {
174
+ localContext = { path: candidate, content: fs.readFileSync(candidate, 'utf-8') }
175
+ } catch {
176
+ // unreadable: treat as absent
177
+ }
178
+ })
179
+
149
180
  pi.on('before_agent_start', async (event) => {
150
- const contextFiles: Array<{ path: string; content: string }> = event.systemPromptOptions?.contextFiles ?? []
181
+ const contextFiles: Array<{ path: string; content: string }> = [...(event.systemPromptOptions?.contextFiles ?? [])]
182
+ if (localContext) contextFiles.push(localContext)
151
183
  if (contextFiles.length === 0) return
152
184
 
153
185
  const home = os.homedir()
@@ -164,10 +196,18 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
164
196
  const allowedRoots = rootsForImporter(file.path, home, cwd)
165
197
  imported.push(...collectImports(file.content, path.dirname(file.path), home, allowedRoots, seenSet, budget))
166
198
  }
167
- if (imported.length === 0) return
168
199
 
169
- const section = imported.map((entry) => `### ${entry.path}\n\n${entry.body}`).join('\n\n')
170
- const notice = budget.dropped === 0 ? '' : `\n\n${budget.dropped} further @imports were skipped: the import budget (${MAX_IMPORT_FILES} files, ${MAX_IMPORT_BYTES} bytes) is spent.`
171
- return { systemPrompt: `${event.systemPrompt}\n\n## Imported context (@)\n\n${section}${notice}` }
200
+ let addition = ''
201
+ if (localContext && localContext.content.trim().length > 0) {
202
+ addition += `\n\n## CLAUDE.local.md\n\n${localContext.content.trim()}`
203
+ }
204
+ if (imported.length > 0) {
205
+ const section = imported.map((entry) => `### ${entry.path}\n\n${entry.body}`).join('\n\n')
206
+ const notice = budget.dropped === 0 ? '' : `\n\n${budget.dropped} further @imports were skipped: the import budget (${MAX_IMPORT_FILES} files, ${MAX_IMPORT_BYTES} bytes) is spent.`
207
+ addition += `\n\n## Imported context (@)\n\n${section}${notice}`
208
+ }
209
+ if (addition.length === 0) return
210
+
211
+ return { systemPrompt: event.systemPrompt + addition }
172
212
  })
173
213
  }
@@ -27,7 +27,7 @@ import * as os from 'node:os'
27
27
  import * as path from 'node:path'
28
28
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
29
29
 
30
- import { isProjectApproved } from './project-approval.js'
30
+ import { isProjectApproved } from './internal/project-approval.js'
31
31
 
32
32
  const DEFAULT_TIMEOUT_S = 60
33
33
 
@@ -53,7 +53,7 @@ export interface HookRunResult {
53
53
  /** The hook was killed at its timeout, so its exit code carries no verdict. */
54
54
  timedOut: boolean
55
55
  }
56
- export type HookRunner = (command: string, payload: unknown, timeoutMs: number) => Promise<HookRunResult>
56
+ export type HookRunner = (command: string, payload: unknown, timeoutMs: number, projectDir?: string) => Promise<HookRunResult>
57
57
 
58
58
  /** Settings files to read, newest-winning. Project files load only when trusted. */
59
59
  export function hookFiles(cwd: string, home: string, trusted: boolean): string[] {
@@ -87,11 +87,17 @@ function matcherApplies(matcher: string | undefined, name: string): boolean {
87
87
  }
88
88
  }
89
89
 
90
+ /** Claude settings may carry prompt/agent hook types with no command; running one
91
+ * through `sh -c undefined` would throw out of the tool_call handler. */
92
+ function isRunnableHook(hook: HookCommand): boolean {
93
+ return typeof hook.command === 'string' && (hook.type === undefined || hook.type === 'command')
94
+ }
95
+
90
96
  /** Command specs whose matcher applies to the given tool/source name. */
91
97
  export function matchingCommands(matchers: HookMatcher[] | undefined, name: string): HookCommand[] {
92
98
  const result: HookCommand[] = []
93
99
  for (const entry of matchers ?? []) {
94
- if (matcherApplies(entry.matcher, name)) result.push(...(entry.hooks ?? []))
100
+ if (matcherApplies(entry.matcher, name)) result.push(...(entry.hooks ?? []).filter(isRunnableHook))
95
101
  }
96
102
  return result
97
103
  }
@@ -137,12 +143,14 @@ function killTree(child: ChildProcess): void {
137
143
  child.kill('SIGKILL')
138
144
  }
139
145
 
140
- export const runHookCommand: HookRunner = (command, payload, timeoutMs) =>
146
+ export const runHookCommand: HookRunner = (command, payload, timeoutMs, projectDir) =>
141
147
  new Promise((resolve) => {
142
148
  // Absolute path so the shell can't be resolved through an attacker-controlled PATH.
143
149
  // `detached` makes the shell its own process group leader so the timeout can kill
144
- // the descendants too.
145
- const child = spawn('/bin/sh', ['-c', command], { stdio: ['pipe', 'pipe', 'pipe'], detached: true })
150
+ // the descendants too. CLAUDE_PROJECT_DIR is Claude's documented way for a hook to
151
+ // reference project files regardless of the shell's cwd.
152
+ const env = projectDir ? { ...process.env, CLAUDE_PROJECT_DIR: projectDir } : process.env
153
+ const child = spawn('/bin/sh', ['-c', command], { stdio: ['pipe', 'pipe', 'pipe'], detached: true, env })
146
154
  let stdout = ''
147
155
  let stderr = ''
148
156
  let settled = false
@@ -178,8 +186,15 @@ export const runHookCommand: HookRunner = (command, payload, timeoutMs) =>
178
186
  child.stdin?.end(JSON.stringify(payload))
179
187
  })
180
188
 
189
+ /** Above 2^31-1 ms Node clamps a timer to 1ms, which would kill the hook instantly. */
190
+ const MAX_TIMEOUT_S = 2_147_483
191
+
181
192
  function timeoutMs(command: HookCommand): number {
182
- return (command.timeout ?? DEFAULT_TIMEOUT_S) * 1000
193
+ // Non-positive values fall back to the default: a 0ms timer would fire before the
194
+ // hook runs, and a timed-out PreToolUse hook fails closed, bricking the tool.
195
+ const declared = command.timeout
196
+ const seconds = typeof declared === 'number' && declared > 0 ? Math.min(declared, MAX_TIMEOUT_S) : DEFAULT_TIMEOUT_S
197
+ return seconds * 1000
183
198
  }
184
199
 
185
200
  /** Run PreToolUse hooks for a tool; the first blocking verdict wins. */
@@ -199,25 +214,45 @@ async function runNotifyHooks(commands: HookCommand[], payload: unknown, runner:
199
214
  await Promise.all(commands.map((command) => runner(command.command, payload, timeoutMs(command))))
200
215
  }
201
216
 
217
+ /** Bound on remembered tool inputs, in case a blocked or aborted call never ends. */
218
+ const MAX_PENDING_INPUTS = 100
219
+
202
220
  export default function hooksExtension(pi: ExtensionAPI) {
203
221
  let config: HooksConfig = {}
222
+ let projectDir = ''
223
+ // tool_execution_end does not carry the tool's input, but Claude's PostToolUse
224
+ // contract does, so remember it from tool_call keyed by the call id.
225
+ const pendingInputs = new Map<string, unknown>()
226
+ const runner: HookRunner = (command, payload, ms) => runHookCommand(command, payload, ms, projectDir)
204
227
 
205
228
  pi.on('session_start', async (event, ctx) => {
206
229
  const trusted = await isProjectApproved(ctx)
230
+ projectDir = ctx.cwd
207
231
  config = loadHooks(hookFiles(ctx.cwd, os.homedir(), trusted))
208
232
  // Only fire SessionStart hooks on a genuine session begin, matched by source (Claude uses
209
233
  // "startup"/"resume"/...). "reload" and "fork" re-fire in-process and would double-run hooks.
210
234
  if (event.reason === 'reload' || event.reason === 'fork') return
211
- await runNotifyHooks(matchingCommands(config.SessionStart, event.reason), { hook_event_name: 'SessionStart', source: event.reason }, runHookCommand)
235
+ await runNotifyHooks(matchingCommands(config.SessionStart, event.reason), { hook_event_name: 'SessionStart', source: event.reason }, runner)
212
236
  })
213
237
 
214
238
  pi.on('tool_call', async (event) => {
215
- const decision = await runPreToolUse(config, event.toolName, event.input, runHookCommand)
216
- return decision.block ? { block: true, reason: decision.reason } : undefined
239
+ pendingInputs.set(event.toolCallId, event.input)
240
+ if (pendingInputs.size > MAX_PENDING_INPUTS) {
241
+ const oldest = pendingInputs.keys().next().value
242
+ if (oldest !== undefined) pendingInputs.delete(oldest)
243
+ }
244
+ const decision = await runPreToolUse(config, event.toolName, event.input, runner)
245
+ if (!decision.block) return undefined
246
+ // pi still emits tool_execution_end (isError) for a blocked call, which also
247
+ // cleans up; deleting here just avoids relying on that host detail.
248
+ pendingInputs.delete(event.toolCallId)
249
+ return { block: true, reason: decision.reason }
217
250
  })
218
251
 
219
252
  pi.on('tool_execution_end', async (event) => {
253
+ const toolInput = pendingInputs.get(event.toolCallId)
254
+ pendingInputs.delete(event.toolCallId)
220
255
  if (event.isError) return
221
- await runNotifyHooks(matchingCommands(config.PostToolUse, event.toolName), { hook_event_name: 'PostToolUse', tool_name: event.toolName }, runHookCommand)
256
+ await runNotifyHooks(matchingCommands(config.PostToolUse, event.toolName), { hook_event_name: 'PostToolUse', tool_name: event.toolName, tool_input: toolInput, tool_response: event.result }, runner)
222
257
  })
223
258
  }
@@ -22,7 +22,18 @@ import * as path from 'node:path'
22
22
  import { getAgentDir, hasTrustRequiringProjectResources, ProjectTrustStore } from '@earendil-works/pi-coding-agent'
23
23
 
24
24
  /** Project files pi-code acts on that pi's own trust check does not look for. */
25
- const CLAUDE_SHAPED = [path.join('.claude', 'settings.json'), path.join('.claude', 'settings.local.json'), path.join('.claude', 'agents'), path.join('.claude', 'hooks'), path.join('.claude', 'output-styles'), '.mcp.json', path.join('.pi', 'mcp.json'), path.join('.pi', 'agents')]
25
+ const CLAUDE_SHAPED = [
26
+ path.join('.claude', 'settings.json'),
27
+ path.join('.claude', 'settings.local.json'),
28
+ path.join('.claude', 'agents'),
29
+ path.join('.claude', 'hooks'),
30
+ path.join('.claude', 'output-styles'),
31
+ path.join('.claude', 'rules'),
32
+ 'CLAUDE.local.md',
33
+ '.mcp.json',
34
+ path.join('.pi', 'mcp.json'),
35
+ path.join('.pi', 'agents'),
36
+ ]
26
37
 
27
38
  export function hasClaudeShapedConfig(cwd: string): boolean {
28
39
  return CLAUDE_SHAPED.some((entry) => fs.existsSync(path.join(cwd, entry)))
package/extensions/mcp.ts CHANGED
@@ -28,8 +28,8 @@ import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js' //
28
28
  import { getDefaultEnvironment, StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
29
29
  import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
30
30
  import { Type } from 'typebox'
31
- import { capForContext } from './output-guard.js'
32
- import { isProjectApproved } from './project-approval.js'
31
+ import { capForContext } from './internal/output-guard.js'
32
+ import { isProjectApproved } from './internal/project-approval.js'
33
33
 
34
34
  const CONNECT_TIMEOUT_MS = 10_000
35
35
  const CALL_TIMEOUT_MS = 120_000
@@ -41,6 +41,7 @@ const CALL_TIMEOUT_MS = 120_000
41
41
  const RESERVED_NAMES = new Set(['web_fetch', 'web_search', 'plan_mode_complete'])
42
42
 
43
43
  export interface StdioServerConfig {
44
+ type?: 'stdio'
44
45
  command: string
45
46
  args?: string[]
46
47
  env?: Record<string, string>
@@ -48,6 +49,7 @@ export interface StdioServerConfig {
48
49
  }
49
50
 
50
51
  export interface HttpServerConfig {
52
+ type?: 'http' | 'streamable-http' | 'sse'
51
53
  url: string
52
54
  headers?: Record<string, string>
53
55
  bearerToken?: string
@@ -56,8 +58,14 @@ export interface HttpServerConfig {
56
58
 
57
59
  export type ServerConfig = StdioServerConfig | HttpServerConfig
58
60
 
61
+ /** Claude's .mcp.json expansion: ${VAR}, and ${VAR:-default}. The syntax borrows
62
+ * shell's `:-`, which substitutes when the variable is unset OR empty. */
59
63
  export function interpolateEnv(value: string, env: NodeJS.ProcessEnv = process.env): string {
60
- return value.replace(/\$\{(\w+)\}/g, (_, name) => env[name] ?? '')
64
+ return value.replace(/\$\{(\w+)(:-([^}]*))?\}/g, (_, name, hasDefault, fallback) => {
65
+ const current = env[name]
66
+ if (hasDefault !== undefined) return current || fallback
67
+ return current ?? ''
68
+ })
61
69
  }
62
70
 
63
71
  /** User-scoped MCP config (the user's own; safe to load without project trust). */
@@ -123,7 +131,8 @@ export function mapContent(content: McpContentBlock[] | undefined, structured?:
123
131
  }
124
132
 
125
133
  function isStdio(config: ServerConfig): config is StdioServerConfig {
126
- return 'command' in config
134
+ // An explicit type wins; without one, a command field means stdio.
135
+ return 'command' in config && (config.type === undefined || config.type === 'stdio')
127
136
  }
128
137
 
129
138
  async function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
@@ -150,8 +159,8 @@ async function connect(name: string, config: ServerConfig): Promise<Client> {
150
159
  const env: Record<string, string> = { ...getDefaultEnvironment() }
151
160
  for (const [key, value] of Object.entries(config.env ?? {})) env[key] = interpolateEnv(value)
152
161
  const transport = new StdioClientTransport({
153
- command: config.command,
154
- args: config.args ?? [],
162
+ command: interpolateEnv(config.command),
163
+ args: (config.args ?? []).map((arg) => interpolateEnv(arg)),
155
164
  env,
156
165
  cwd: config.cwd?.replace(/^~(?=\/|$)/, os.homedir()),
157
166
  stderr: 'ignore',
@@ -164,12 +173,18 @@ async function connect(name: string, config: ServerConfig): Promise<Client> {
164
173
  const token = config.bearerToken ?? (config.bearerTokenEnv ? process.env[config.bearerTokenEnv] : undefined)
165
174
  if (token) headers.Authorization = `Bearer ${token}`
166
175
  const url = new URL(interpolateEnv(config.url))
176
+ if (config.type === 'sse') {
177
+ const transport = new SSEClientTransport(url, { requestInit: { headers } }) // NOSONAR: explicitly declared legacy transport
178
+ await withTimeout(client.connect(transport), CONNECT_TIMEOUT_MS, `connect ${name} (sse)`)
179
+ return client
180
+ }
167
181
  try {
168
182
  const transport = new StreamableHTTPClientTransport(url, { requestInit: { headers } })
169
183
  await withTimeout(client.connect(transport), CONNECT_TIMEOUT_MS, `connect ${name}`)
170
184
  return client
171
185
  } catch (error) {
172
- if (String(error).includes('Unauthorized')) throw error
186
+ // An explicitly declared streamable transport must not silently degrade to SSE.
187
+ if (config.type !== undefined || String(error).includes('Unauthorized')) throw error
173
188
  const fallback = new Client({ name: 'pi-code-mcp', version: '0.1.0' })
174
189
  const transport = new SSEClientTransport(url, { requestInit: { headers } }) // NOSONAR: deliberate legacy fallback
175
190
  await withTimeout(fallback.connect(transport), CONNECT_TIMEOUT_MS, `connect ${name} (sse)`)
@@ -195,6 +210,13 @@ export default async function mcpExtension(pi: ExtensionAPI) {
195
210
 
196
211
  async function connectServers(servers: Record<string, ServerConfig>): Promise<void> {
197
212
  for (const [name, config] of Object.entries(servers)) {
213
+ // A later scope must not take the name of a server that already connected: it
214
+ // would evict that client from the map, leaking it at shutdown, and misreport
215
+ // the earlier server's status.
216
+ if (clients.has(name)) {
217
+ console.warn(`pi-code-mcp: skipping duplicate server name ${name}`)
218
+ continue
219
+ }
198
220
  try {
199
221
  const client = await connect(name, config)
200
222
  clients.set(name, client)
@@ -13,7 +13,7 @@ import * as path from 'node:path'
13
13
  import { StringEnum } from '@earendil-works/pi-ai'
14
14
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
15
15
  import { Type } from 'typebox'
16
- import { capForContext } from './output-guard.js'
16
+ import { capForContext } from './internal/output-guard.js'
17
17
 
18
18
  const INDEX_FILE = 'MEMORY.md'
19
19
 
@@ -35,17 +35,22 @@ export function slugifyName(name: string): string {
35
35
  )
36
36
  }
37
37
 
38
+ /** The exact key prefix of a memory's index line; matching on a substring would also
39
+ * hit another entry whose description merely mentions this memory. */
40
+ const entryPrefix = (name: string): string => `- [${name}](${name}.md):`
41
+
38
42
  /** Add or replace this memory's line in the index, keyed by its markdown link target. */
39
43
  export function upsertIndexLine(index: string, name: string, description: string): string {
40
- const line = `- [${name}](${name}.md): ${description}`
41
- const lines = index.split('\n').filter((l) => l.trim().length > 0 && !l.includes(`](${name}.md)`))
44
+ // One line per memory: a newline in the description would break line-based matching.
45
+ const line = `${entryPrefix(name)} ${description.replace(/\s+/g, ' ').trim()}`
46
+ const lines = index.split('\n').filter((l) => l.trim().length > 0 && !l.startsWith(entryPrefix(name)))
42
47
  if (lines.length === 0 || !lines[0].startsWith('#')) lines.unshift('# Memory index')
43
48
  lines.push(line)
44
49
  return `${lines.join('\n')}\n`
45
50
  }
46
51
 
47
52
  export function removeIndexLine(index: string, name: string): string {
48
- const lines = index.split('\n').filter((l) => l.trim().length > 0 && !l.includes(`](${name}.md)`))
53
+ const lines = index.split('\n').filter((l) => l.trim().length > 0 && !l.startsWith(entryPrefix(name)))
49
54
  return lines.length > 0 ? `${lines.join('\n')}\n` : ''
50
55
  }
51
56
 
@@ -38,6 +38,8 @@ function notifyWindows(title: string, body: string): void {
38
38
  }
39
39
 
40
40
  function notify(title: string, body: string): void {
41
+ // Piped or headless stdout (pi -p, CI) must not receive raw escape bytes.
42
+ if (!process.stdout.isTTY) return
41
43
  if (process.env.WT_SESSION) {
42
44
  notifyWindows(title, body)
43
45
  } else if (process.env.KITTY_WINDOW_ID) {
@@ -19,6 +19,8 @@ import * as os from 'node:os'
19
19
  import * as path from 'node:path'
20
20
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
21
21
 
22
+ import { isProjectApproved } from './internal/project-approval.js'
23
+
22
24
  export interface OutputStyle {
23
25
  name: string
24
26
  description: string
@@ -69,7 +71,13 @@ export function loadStyles(dirs: string[]): OutputStyle[] {
69
71
  }
70
72
  for (const entry of entries) {
71
73
  if (!entry.endsWith('.md')) continue
72
- const style = parseStyle(fs.readFileSync(path.join(dir, entry), 'utf-8'), entry.replace(/\.md$/, ''))
74
+ let content: string
75
+ try {
76
+ content = fs.readFileSync(path.join(dir, entry), 'utf-8')
77
+ } catch {
78
+ continue // a directory named *.md or an unreadable file must not take down session start
79
+ }
80
+ const style = parseStyle(content, entry.replace(/\.md$/, ''))
73
81
  byName.set(style.name, style)
74
82
  }
75
83
  }
@@ -121,8 +129,9 @@ export default function outputStylesExtension(pi: ExtensionAPI) {
121
129
  pi.on('session_start', async (_event, ctx) => {
122
130
  const home = os.homedir()
123
131
  // A project style body is injected verbatim into the system prompt, so only honor
124
- // project styles / selection once the project is trusted.
125
- const trusted = ctx.isProjectTrusted?.() ?? false
132
+ // project styles / selection once the project is approved. isProjectTrusted alone
133
+ // is true for a repo pi never asked about; see project-approval.
134
+ const trusted = await isProjectApproved(ctx)
126
135
  styles = loadStyles(styleDirs(ctx.cwd, home, trusted))
127
136
  localSettingsPath = path.join(ctx.cwd, '.claude', 'settings.local.json')
128
137
  activeName = readActiveStyleName(settingsFiles(ctx.cwd, home, trusted))
@@ -14,7 +14,7 @@ Read-only exploration mode for safe code analysis.
14
14
  ## Commands
15
15
 
16
16
  - `/plan` - Toggle plan mode
17
- - `/todos` - Show current plan progress
17
+ - `/plan-todos` - Show current plan progress
18
18
  - `Ctrl+Alt+P` - Toggle plan mode (shortcut)
19
19
 
20
20
  ## Usage
@@ -161,6 +161,9 @@ export default function planModeExtension(pi: ExtensionAPI): void {
161
161
  const execMessage = todoItems.length > 0 ? `Execute the plan. Start with: ${todoItems[0].text}` : 'Execute the plan you just created.'
162
162
  pi.sendMessage({ customType: 'plan-mode-execute', content: execMessage, display: true }, { triggerTurn: true })
163
163
  } else if (choice === 'Refine the plan') {
164
+ // The refined turn may answer in prose; without this reset agent_end would skip
165
+ // deriveTodosFromProse and re-display the superseded todo list.
166
+ planFromTool = false
164
167
  const refinement = await ctx.ui.editor('Refine the plan:', '')
165
168
  if (refinement?.trim()) {
166
169
  pi.sendUserMessage(refinement.trim())
@@ -240,6 +243,7 @@ export default function planModeExtension(pi: ExtensionAPI): void {
240
243
  messages: event.messages.filter((m) => {
241
244
  const msg = m as AgentMessage & { customType?: string }
242
245
  if (msg.customType === 'plan-mode-context') return false
246
+ if (msg.customType === 'plan-execution-context' && !executionMode) return false
243
247
  if (msg.role !== 'user') return true
244
248
 
245
249
  const content = msg.content
@@ -1,4 +1,4 @@
1
- # Subagent Example
1
+ # Subagent Extension
2
2
 
3
3
  Delegate tasks to specialized subagents with isolated context windows.
4
4
 
@@ -7,6 +7,8 @@ Delegate tasks to specialized subagents with isolated context windows.
7
7
  - **Isolated context**: Each subagent runs in a separate `pi` process
8
8
  - **Streaming output**: See tool calls and progress as they happen
9
9
  - **Parallel streaming**: All parallel tasks stream updates simultaneously
10
+ - **Background runs**: Fire-and-forget with a completion notification; max 8 running at once
11
+ - **Bounded fan-out**: A subagent refuses to spawn subagents of its own (an env marker the tool honors: steering, not a sandbox)
10
12
  - **Markdown rendering**: Final output rendered with proper formatting (expanded view)
11
13
  - **Usage tracking**: Shows turns, tokens, cost, and context usage per agent
12
14
  - **Abort support**: Ctrl+C propagates to kill subagent processes
@@ -18,6 +20,7 @@ subagent/
18
20
  ├── README.md # This file
19
21
  ├── index.ts # The extension (entry point)
20
22
  ├── agents.ts # Agent discovery logic
23
+ ├── background.ts # Background run registry and spawning
21
24
  ├── agents/ # Sample agent definitions
22
25
  │ ├── scout.md # Fast recon, returns compressed context
23
26
  │ ├── planner.md # Creates implementation plans
@@ -31,26 +34,7 @@ subagent/
31
34
 
32
35
  ## Installation
33
36
 
34
- From the repository root, symlink the files:
35
-
36
- ```bash
37
- # Symlink the extension (must be in a subdirectory with index.ts)
38
- mkdir -p ~/.pi/agent/extensions/subagent
39
- ln -sf "$(pwd)/packages/coding-agent/examples/extensions/subagent/index.ts" ~/.pi/agent/extensions/subagent/index.ts
40
- ln -sf "$(pwd)/packages/coding-agent/examples/extensions/subagent/agents.ts" ~/.pi/agent/extensions/subagent/agents.ts
41
-
42
- # Symlink agents
43
- mkdir -p ~/.pi/agent/agents
44
- for f in packages/coding-agent/examples/extensions/subagent/agents/*.md; do
45
- ln -sf "$(pwd)/$f" ~/.pi/agent/agents/$(basename "$f")
46
- done
47
-
48
- # Symlink workflow prompts
49
- mkdir -p ~/.pi/agent/prompts
50
- for f in packages/coding-agent/examples/extensions/subagent/prompts/*.md; do
51
- ln -sf "$(pwd)/$f" ~/.pi/agent/prompts/$(basename "$f")
52
- done
53
- ```
37
+ Installed with pi-code (`pi install npm:pi-code`); nothing to set up separately.
54
38
 
55
39
  ## Security Model
56
40
 
@@ -58,9 +42,9 @@ This tool executes a separate `pi` subprocess with a delegated system prompt and
58
42
 
59
43
  **Project-local agents** (`.pi/agents/*.md`) are repo-controlled prompts that can instruct the model to read files, run bash commands, etc.
60
44
 
61
- **Default behavior:** Only loads **user-level agents** from `~/.pi/agent/agents`.
45
+ **Default behavior:** Only loads **user-level agents** from `~/.claude/agents` and `~/.pi/agent/agents`.
62
46
 
63
- To enable project-local agents, pass `agentScope: "both"` (or `"project"`). Only do this for repositories you trust.
47
+ To enable project-local agents (`.claude/agents`, `.pi/agents`), pass `agentScope: "both"` (or `"project"`). Only do this for repositories you trust.
64
48
 
65
49
  When running interactively, the tool prompts for confirmation before running project-local agents. Set `confirmProjectAgents: false` to disable.
66
50
 
@@ -129,15 +113,25 @@ Agents are markdown files with YAML frontmatter:
129
113
  name: my-agent
130
114
  description: What this agent does
131
115
  tools: read, grep, find, ls
132
- model: claude-haiku-4-5
116
+ disallowedTools: write, edit
117
+ model: gpt-oss:20b
118
+ effort: high
133
119
  ---
134
120
 
135
121
  System prompt for the agent goes here.
136
122
  ```
137
123
 
124
+ Claude Code fields map onto pi where a sensible seam exists: `tools` and
125
+ `disallowedTools` (comma string or YAML list) become pi's `--tools` /
126
+ `--exclude-tools`; `effort` becomes the `:thinking` suffix on a pinned model;
127
+ `permissionMode: plan` selects a read-only toolset unless `tools` is set. Model
128
+ aliases (`sonnet`, `opus`, `haiku`, `inherit`) run on the session's default
129
+ model. Fields with no pi equivalent are ignored: `skills`, `memory`,
130
+ `mcpServers`, `maxTurns`.
131
+
138
132
  **Locations:**
139
- - `~/.pi/agent/agents/*.md` - User-level (always loaded)
140
- - `.pi/agents/*.md` - Project-level (only with `agentScope: "project"` or `"both"`)
133
+ - `~/.claude/agents/*.md`, `~/.pi/agent/agents/*.md` - User-level (always loaded; `~/.pi` wins a name conflict)
134
+ - `.claude/agents/*.md`, `.pi/agents/*.md` - Project-level (only with `agentScope: "project"` or `"both"`; `.pi` wins a name conflict)
141
135
 
142
136
  Project agents override user agents with the same name when `agentScope: "both"`.
143
137
 
@@ -23,13 +23,88 @@ function normalizeToolName(tool: string): string {
23
23
  return CLAUDE_TOOL_MAP[lower] ?? lower
24
24
  }
25
25
 
26
+ /**
27
+ * `tools:` may be a comma-separated string (the Claude Code format) or a YAML block
28
+ * list. Anything else returns null: a restriction that failed to parse must not run
29
+ * the agent unrestricted.
30
+ */
31
+ function parseToolsField(raw: unknown): string[] | undefined | null {
32
+ 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)
39
+ return tools.length > 0 ? tools : undefined
40
+ }
41
+
42
+ /**
43
+ * Claude Code model aliases name Anthropic tiers. pi's resolver would partial-match
44
+ * one against an authenticated Anthropic provider, but for every other setup the
45
+ * child exits at boot on an unresolvable --model. Running on the session model
46
+ * (Claude's `inherit`) is the degradation that works everywhere; users who want a
47
+ * tier pinned should name a concrete model id.
48
+ */
49
+ const CLAUDE_MODEL_ALIASES = new Set(['sonnet', 'opus', 'haiku', 'inherit'])
50
+
51
+ function parseModelField(raw: unknown): string | undefined {
52
+ if (typeof raw !== 'string') return undefined
53
+ const model = raw.trim()
54
+ return model && !CLAUDE_MODEL_ALIASES.has(model.toLowerCase()) ? model : undefined
55
+ }
56
+
57
+ /** pi's extended thinking levels; Claude's effort values are a subset, so they map 1:1. */
58
+ const THINKING_LEVELS = new Set(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'])
59
+
60
+ function parseEffortField(raw: unknown): string | undefined {
61
+ if (typeof raw !== 'string') return undefined
62
+ const effort = raw.trim().toLowerCase()
63
+ return THINKING_LEVELS.has(effort) ? effort : undefined
64
+ }
65
+
66
+ /** permissionMode has no pi equivalent; 'plan' means a research agent, so translate
67
+ * the intent into a read-only toolset unless the file pins tools itself. */
68
+ const READ_ONLY_TOOLS = ['read', 'grep', 'find', 'ls']
69
+
70
+ /** Parse one agent markdown file; null when it is not a usable agent definition. */
71
+ function parseAgentFile(content: string, source: 'user' | 'project', filePath: string): AgentConfig | null {
72
+ let parsed: { frontmatter: Record<string, unknown>; body: string }
73
+ try {
74
+ parsed = parseFrontmatter<Record<string, unknown>>(content)
75
+ } catch {
76
+ return null // malformed YAML must not abort discovery for the whole directory
77
+ }
78
+ const { frontmatter, body } = parsed
79
+ const name = typeof frontmatter.name === 'string' ? frontmatter.name : ''
80
+ const description = typeof frontmatter.description === 'string' ? frontmatter.description : ''
81
+ if (!name || !description) return null
82
+ const tools = parseToolsField(frontmatter.tools)
83
+ if (tools === null) return null
84
+ const disallowedTools = parseToolsField(frontmatter.disallowedTools)
85
+ if (disallowedTools === null) return null
86
+ return {
87
+ name,
88
+ description,
89
+ tools: tools ?? (frontmatter.permissionMode === 'plan' ? [...READ_ONLY_TOOLS] : undefined),
90
+ disallowedTools,
91
+ model: parseModelField(frontmatter.model),
92
+ effort: parseEffortField(frontmatter.effort),
93
+ systemPrompt: body,
94
+ source,
95
+ filePath,
96
+ }
97
+ }
98
+
26
99
  export type AgentScope = 'user' | 'project' | 'both'
27
100
 
28
101
  export interface AgentConfig {
29
102
  name: string
30
103
  description: string
31
104
  tools?: string[]
105
+ disallowedTools?: string[]
32
106
  model?: string
107
+ effort?: string
33
108
  systemPrompt: string
34
109
  source: 'user' | 'project'
35
110
  filePath: string
@@ -66,26 +141,8 @@ function loadAgentsFromDir(dir: string, source: 'user' | 'project'): AgentConfig
66
141
  continue
67
142
  }
68
143
 
69
- const { frontmatter, body } = parseFrontmatter<Record<string, string>>(content)
70
-
71
- if (!frontmatter.name || !frontmatter.description) {
72
- continue
73
- }
74
-
75
- const tools = frontmatter.tools
76
- ?.split(',')
77
- .map((t: string) => normalizeToolName(t.trim()))
78
- .filter(Boolean)
79
-
80
- agents.push({
81
- name: frontmatter.name,
82
- description: frontmatter.description,
83
- tools: tools && tools.length > 0 ? tools : undefined,
84
- model: frontmatter.model,
85
- systemPrompt: body,
86
- source,
87
- filePath,
88
- })
144
+ const agent = parseAgentFile(content, source, filePath)
145
+ if (agent) agents.push(agent)
89
146
  }
90
147
 
91
148
  return agents
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Background subagent runs: fire-and-forget children whose completion wakes
3
- * the parent agent via a notification message. Session-scoped (children die
4
- * with pi); state lives in an in-memory registry queried via {action:"status"}.
3
+ * the parent agent via a notification message. State lives in an in-memory
4
+ * registry queried via {status: true}; it is lost on restart, and a child
5
+ * still running when pi exits finishes on its own rather than being killed.
5
6
  */
6
7
 
7
8
  import { spawn } from 'node:child_process'
@@ -25,6 +26,13 @@ export interface BackgroundSpawn {
25
26
 
26
27
  const runs = new Map<string, BackgroundRun>()
27
28
 
29
+ /** Cap on simultaneously running background children. */
30
+ export const MAX_BACKGROUND_RUNS = 8
31
+
32
+ export function activeBackgroundRuns(): number {
33
+ return [...runs.values()].filter((run) => run.state === 'running').length
34
+ }
35
+
28
36
  /** Extract the final assistant text and turn count from a pi --mode json stdout stream. */
29
37
  export function parseFinalOutputFromJsonl(jsonl: string): { text: string; turns: number } {
30
38
  let text = ''
@@ -58,7 +66,11 @@ export function backgroundStatusText(): string {
58
66
  return formatStatus(runs.values())
59
67
  }
60
68
 
61
- export function startBackgroundRun(agent: string, task: string, invocation: BackgroundSpawn, onComplete: (run: BackgroundRun) => void): string {
69
+ export function startBackgroundRun(agent: string, task: string, invocation: BackgroundSpawn, onComplete: (run: BackgroundRun) => void): string | null {
70
+ // Checked here, synchronously with registration: callers await temp-file writes
71
+ // between any check of their own and this call, so a parallel tool-call batch
72
+ // could otherwise all pass that earlier check and overshoot the cap.
73
+ if (activeBackgroundRuns() >= MAX_BACKGROUND_RUNS) return null
62
74
  const id = `bg-${randomUUID().slice(0, 8)}`
63
75
  const run: BackgroundRun = { id, agent, task, state: 'running', turns: 0 }
64
76
  runs.set(id, run)
@@ -67,6 +79,8 @@ export function startBackgroundRun(agent: string, task: string, invocation: Back
67
79
  cwd: invocation.cwd,
68
80
  shell: false,
69
81
  stdio: ['ignore', 'pipe', 'ignore'],
82
+ // The marker lets the child's subagent tool refuse to nest further.
83
+ env: { ...process.env, PI_CODE_SUBAGENT: '1' },
70
84
  })
71
85
  let stdout = ''
72
86
  proc.stdout.on('data', (data) => {
@@ -22,9 +22,10 @@ import { StringEnum } from '@earendil-works/pi-ai'
22
22
  import { type ExtensionAPI, type ExtensionContext, getMarkdownTheme, type Theme, withFileMutationQueue } from '@earendil-works/pi-coding-agent'
23
23
  import { Container, Markdown, Spacer, Text } from '@earendil-works/pi-tui'
24
24
  import { type Static, Type } from 'typebox'
25
- import { isProjectApproved } from '../project-approval.js'
25
+ import { capForContext } from '../internal/output-guard.js'
26
+ import { isProjectApproved } from '../internal/project-approval.js'
26
27
  import { type AgentConfig, type AgentScope, discoverAgents } from './agents.js'
27
- import { backgroundStatusText, startBackgroundRun } from './background.js'
28
+ import { activeBackgroundRuns, backgroundStatusText, MAX_BACKGROUND_RUNS, startBackgroundRun } from './background.js'
28
29
 
29
30
  const MAX_PARALLEL_TASKS = 8
30
31
  const MAX_CONCURRENCY = 4
@@ -274,9 +275,7 @@ async function runSingleAgent(options: RunAgentOptions): Promise<SingleResult> {
274
275
  }
275
276
  }
276
277
 
277
- const args: string[] = ['--mode', 'json', '-p', '--no-session']
278
- if (agent.model) args.push('--model', agent.model)
279
- if (agent.tools && agent.tools.length > 0) args.push('--tools', agent.tools.join(','))
278
+ const args = agentInvocationArgs(agent)
280
279
 
281
280
  let tmpPromptDir: string | null = null
282
281
  let tmpPromptPath: string | null = null
@@ -319,6 +318,8 @@ async function runSingleAgent(options: RunAgentOptions): Promise<SingleResult> {
319
318
  cwd: cwd ?? defaultCwd,
320
319
  shell: false,
321
320
  stdio: ['ignore', 'pipe', 'pipe'],
321
+ // The marker lets the child's subagent tool refuse to nest further.
322
+ env: { ...process.env, PI_CODE_SUBAGENT: '1' },
322
323
  })
323
324
  let buffer = ''
324
325
 
@@ -504,6 +505,38 @@ async function checkProjectAgentGate(params: SubagentParamsStatic, agents: Agent
504
505
  return null
505
506
  }
506
507
 
508
+ /** CLI args shared by foreground and background children, from the agent's config. */
509
+ function agentInvocationArgs(agent: AgentConfig): string[] {
510
+ const args: string[] = ['--mode', 'json', '-p', '--no-session']
511
+ // pi reads a thinking level from the model pattern's :suffix, so Claude's effort
512
+ // has a seam only when the agent pins a concrete model.
513
+ if (agent.model) args.push('--model', agent.effort ? `${agent.model}:${agent.effort}` : agent.model)
514
+ if (agent.tools && agent.tools.length > 0) args.push('--tools', agent.tools.join(','))
515
+ if (agent.disallowedTools && agent.disallowedTools.length > 0) args.push('--exclude-tools', agent.disallowedTools.join(','))
516
+ return args
517
+ }
518
+
519
+ function backgroundCapResult(makeDetails: MakeDetails): ToolResult {
520
+ return {
521
+ content: [{ type: 'text', text: `Too many background runs (max ${MAX_BACKGROUND_RUNS} running). Wait for one to finish; check progress with {status: true}.` }],
522
+ details: makeDetails('single')([]),
523
+ }
524
+ }
525
+
526
+ function removeTmpPrompt(tmpPrompt: { dir: string; filePath: string } | undefined): void {
527
+ if (!tmpPrompt) return
528
+ try {
529
+ fs.unlinkSync(tmpPrompt.filePath)
530
+ } catch {
531
+ /* ignore */
532
+ }
533
+ try {
534
+ fs.rmdirSync(tmpPrompt.dir)
535
+ } catch {
536
+ /* ignore */
537
+ }
538
+ }
539
+
507
540
  async function runBackgroundMode(params: SubagentParamsStatic, agents: AgentConfig[], defaultCwd: string, pi: ExtensionAPI, makeDetails: MakeDetails): Promise<ToolResult> {
508
541
  const task = params.task
509
542
  const agentName = params.agent
@@ -521,9 +554,10 @@ async function runBackgroundMode(params: SubagentParamsStatic, agents: AgentConf
521
554
  details: makeDetails('single')([]),
522
555
  }
523
556
  }
524
- const args: string[] = ['--mode', 'json', '-p', '--no-session']
525
- if (agent.model) args.push('--model', agent.model)
526
- if (agent.tools && agent.tools.length > 0) args.push('--tools', agent.tools.join(','))
557
+ if (activeBackgroundRuns() >= MAX_BACKGROUND_RUNS) {
558
+ return backgroundCapResult(makeDetails)
559
+ }
560
+ const args = agentInvocationArgs(agent)
527
561
  let tmpPrompt: { dir: string; filePath: string } | undefined
528
562
  if (agent.systemPrompt.trim()) {
529
563
  tmpPrompt = await writePromptToTempFile(agent.name, agent.systemPrompt)
@@ -532,19 +566,8 @@ async function runBackgroundMode(params: SubagentParamsStatic, agents: AgentConf
532
566
  args.push(`Task: ${task}`)
533
567
  const invocation = getPiInvocation(args)
534
568
  const id = startBackgroundRun(agent.name, task, { command: invocation.command, args: invocation.args, cwd: params.cwd ?? defaultCwd }, (run) => {
535
- if (tmpPrompt) {
536
- try {
537
- fs.unlinkSync(tmpPrompt.filePath)
538
- } catch {
539
- /* ignore */
540
- }
541
- try {
542
- fs.rmdirSync(tmpPrompt.dir)
543
- } catch {
544
- /* ignore */
545
- }
546
- }
547
- const output = run.output || '(no output)'
569
+ removeTmpPrompt(tmpPrompt)
570
+ const output = capForContext(run.output ?? '') || '(no output)'
548
571
  pi.sendMessage(
549
572
  {
550
573
  customType: 'subagent-background',
@@ -554,6 +577,11 @@ async function runBackgroundMode(params: SubagentParamsStatic, agents: AgentConf
554
577
  { triggerTurn: true },
555
578
  )
556
579
  })
580
+ if (id === null) {
581
+ // Lost the cap race to a parallel batch: the atomic check inside startBackgroundRun refused.
582
+ removeTmpPrompt(tmpPrompt)
583
+ return backgroundCapResult(makeDetails)
584
+ }
557
585
  return {
558
586
  content: [{ type: 'text', text: `Started background run ${id} (${agent.name}). A notification will arrive on completion; check progress with {status: true}.` }],
559
587
  details: makeDetails('single')([]),
@@ -567,7 +595,8 @@ async function runChainMode(chain: ChainStepParam[], mode: ModeContext): Promise
567
595
 
568
596
  for (let i = 0; i < chain.length; i++) {
569
597
  const step = chain[i]
570
- const taskWithContext = step.task.replaceAll('{previous}', previousOutput)
598
+ // Function replacement: a string here would interpret $-patterns in the output.
599
+ const taskWithContext = step.task.replaceAll('{previous}', () => previousOutput)
571
600
 
572
601
  // Create update callback that includes all previous results
573
602
  const chainUpdate: OnUpdateCallback | undefined = onUpdate
@@ -601,14 +630,14 @@ async function runChainMode(chain: ChainStepParam[], mode: ModeContext): Promise
601
630
  if (isError) {
602
631
  const errorMsg = result.errorMessage || result.stderr || getFinalOutput(result.messages) || '(no output)'
603
632
  return {
604
- content: [{ type: 'text', text: `Chain stopped at step ${i + 1} (${step.agent}): ${errorMsg}` }],
633
+ content: [{ type: 'text', text: capForContext(`Chain stopped at step ${i + 1} (${step.agent}): ${errorMsg}`) }],
605
634
  details: makeDetails('chain')(results),
606
635
  }
607
636
  }
608
637
  previousOutput = getFinalOutput(result.messages)
609
638
  }
610
639
  return {
611
- content: [{ type: 'text', text: getFinalOutput(results.at(-1)?.messages ?? []) || '(no output)' }],
640
+ content: [{ type: 'text', text: capForContext(getFinalOutput(results.at(-1)?.messages ?? [])) || '(no output)' }],
612
641
  details: makeDetails('chain')(results),
613
642
  }
614
643
  }
@@ -678,15 +707,15 @@ async function runParallelMode(tasks: TaskItemParam[], mode: ModeContext): Promi
678
707
  const successCount = results.filter((r) => r.exitCode === 0).length
679
708
  const summaries = results.map((r) => {
680
709
  const output = getFinalOutput(r.messages)
681
- const preview = output.slice(0, 100) + (output.length > 100 ? '...' : '')
682
710
  const status = r.exitCode === 0 ? 'completed' : 'failed'
683
- return `[${r.agent}] ${status}: ${preview || '(no output)'}`
711
+ return `[${r.agent}] ${status}: ${output || '(no output)'}`
684
712
  })
685
713
  return {
686
714
  content: [
687
715
  {
688
716
  type: 'text',
689
- text: `Parallel: ${successCount}/${results.length} succeeded\n\n${summaries.join('\n\n')}`,
717
+ // Full reports, so a fan-out can be synthesized from; capped at pi's tool-output budget.
718
+ text: capForContext(`Parallel: ${successCount}/${results.length} succeeded\n\n${summaries.join('\n\n')}`),
690
719
  },
691
720
  ],
692
721
  details: makeDetails('parallel')(results),
@@ -709,12 +738,12 @@ async function runSingleMode(agentName: string, task: string, cwd: string | unde
709
738
  if (isError) {
710
739
  const errorMsg = result.errorMessage || result.stderr || getFinalOutput(result.messages) || '(no output)'
711
740
  return {
712
- content: [{ type: 'text', text: `Agent ${result.stopReason || 'failed'}: ${errorMsg}` }],
741
+ content: [{ type: 'text', text: capForContext(`Agent ${result.stopReason || 'failed'}: ${errorMsg}`) }],
713
742
  details: makeDetails('single')([result]),
714
743
  }
715
744
  }
716
745
  return {
717
- content: [{ type: 'text', text: getFinalOutput(result.messages) || '(no output)' }],
746
+ content: [{ type: 'text', text: capForContext(getFinalOutput(result.messages)) || '(no output)' }],
718
747
  details: makeDetails('single')([result]),
719
748
  }
720
749
  }
@@ -1014,13 +1043,21 @@ export default function subagentExtension(pi: ExtensionAPI) {
1014
1043
  'Delegate tasks to specialized subagents with isolated context.',
1015
1044
  'Modes: single (agent + task), parallel (tasks array), chain (sequential with {previous} placeholder).',
1016
1045
  'Single mode also supports background: true for long tasks; a notification arrives on completion and {status: true} lists runs.',
1017
- 'Default agent scope is "user" (from ~/.pi/agent/agents).',
1018
- 'To enable project-local agents in .pi/agents, set agentScope: "both" (or "project").',
1046
+ 'Default agent scope is "user" (from ~/.claude/agents and ~/.pi/agent/agents).',
1047
+ 'To enable project-local agents in .claude/agents or .pi/agents, set agentScope: "both" (or "project").',
1019
1048
  ].join(' '),
1020
1049
  parameters: SubagentParams,
1021
1050
 
1022
1051
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
1023
1052
  const agentScope: AgentScope = params.agentScope ?? 'user'
1053
+ // Children carry PI_CODE_SUBAGENT; without this check they could spawn
1054
+ // grandchildren without limit.
1055
+ if (process.env.PI_CODE_SUBAGENT) {
1056
+ return {
1057
+ content: [{ type: 'text', text: 'Nested subagent runs are not allowed: this session is already a subagent.' }],
1058
+ details: { mode: 'single', agentScope, projectAgentsDir: null, results: [] },
1059
+ }
1060
+ }
1024
1061
  const discovery = discoverAgents(ctx.cwd, agentScope)
1025
1062
  const agents = discovery.agents
1026
1063
 
package/extensions/web.ts CHANGED
@@ -12,7 +12,8 @@ import type { LookupFunction } from 'node:net'
12
12
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
13
13
  import { Type } from 'typebox'
14
14
 
15
- import { httpFetch } from './web-transport.js'
15
+ import { capForContext } from './internal/output-guard.js'
16
+ import { httpFetch } from './internal/web-transport.js'
16
17
 
17
18
  const SEARCH_ENDPOINT = 'https://html.duckduckgo.com/html/?q='
18
19
  const USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) pi-code-web/0.1'
@@ -59,16 +60,18 @@ export function resolveResultUrl(href: string): string {
59
60
  export function parseSearchResults(html: string, limit: number): SearchResult[] {
60
61
  const results: SearchResult[] = []
61
62
  const anchorPattern = /<a[^>]*class="result__a"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/g
62
- const snippetPattern = /<a[^>]*class="result__snippet"[^>]*>([\s\S]*?)<\/a>/g
63
- const snippets = [...html.matchAll(snippetPattern)].map((m) => stripTags(m[1]))
64
- let index = 0
65
- for (const match of html.matchAll(anchorPattern)) {
66
- if (results.length >= limit) break
63
+ const snippetPattern = /<a[^>]*class="result__snippet"[^>]*>([\s\S]*?)<\/a>/
64
+ const anchors = [...html.matchAll(anchorPattern)]
65
+ for (let i = 0; i < anchors.length && results.length < limit; i++) {
66
+ const match = anchors[i]
67
67
  const url = resolveResultUrl(match[1])
68
68
  const title = stripTags(match[2])
69
69
  if (!title || url.includes('duckduckgo.com/y.js')) continue
70
- results.push({ title, url, snippet: snippets[index] ?? '' })
71
- index++
70
+ // A result's snippet sits between its anchor and the next one; pairing by block
71
+ // keeps attribution right when an ad anchor (skipped above) carries a snippet too.
72
+ const block = html.slice((match.index ?? 0) + match[0].length, anchors[i + 1]?.index ?? html.length)
73
+ const snippet = snippetPattern.exec(block)
74
+ results.push({ title, url, snippet: snippet ? stripTags(snippet[1]) : '' })
72
75
  }
73
76
  return results
74
77
  }
@@ -173,6 +176,9 @@ async function readCapped(response: Response): Promise<string> {
173
176
  if (done) break
174
177
  if (value) text += decoder.decode(value, { stream: true })
175
178
  }
179
+ // Flush: bytes of a character cut off by the end of the stream become U+FFFD
180
+ // instead of vanishing silently.
181
+ text += decoder.decode()
176
182
  await reader.cancel().catch(() => {})
177
183
  return text.slice(0, MAX_RAW_CHARS)
178
184
  }
@@ -233,7 +239,9 @@ export default function webExtension(pi: ExtensionAPI) {
233
239
  }
234
240
  const { text, contentType } = await fetchText(params.url)
235
241
  const body = contentType.includes('html') ? htmlToText(text) : text.slice(0, MAX_FETCH_CHARS)
236
- return { content: [{ type: 'text' as const, text: body || '(empty response)' }], details: {} }
242
+ // The char cap alone admits thousands of short lines; pi's tool-output budget
243
+ // bounds lines too, which the shared guard enforces.
244
+ return { content: [{ type: 'text' as const, text: capForContext(body) || '(empty response)' }], details: {} }
237
245
  },
238
246
  })
239
247
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-code",
3
- "version": "0.2.4",
3
+ "version": "0.3.1",
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-package"
@@ -47,10 +47,10 @@
47
47
  },
48
48
  "devDependencies": {
49
49
  "@biomejs/biome": "^2.5.4",
50
- "@earendil-works/pi-agent-core": "^0.80.10",
51
- "@earendil-works/pi-ai": "^0.80.10",
52
- "@earendil-works/pi-coding-agent": "^0.80.10",
53
- "@earendil-works/pi-tui": "^0.80.10",
50
+ "@earendil-works/pi-agent-core": "^0.81.1",
51
+ "@earendil-works/pi-ai": "^0.81.1",
52
+ "@earendil-works/pi-coding-agent": "^0.81.1",
53
+ "@earendil-works/pi-tui": "^0.81.1",
54
54
  "@types/node": "^26.1.1",
55
55
  "@vitest/coverage-v8": "^4.1.10",
56
56
  "typescript": "^7.0.2",