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 +1 -1
- package/extensions/commands.ts +10 -3
- package/extensions/context-imports.ts +5 -2
- package/extensions/git-checkpoint.ts +22 -1
- package/extensions/hooks.ts +76 -20
- package/extensions/internal/command-file.ts +102 -23
- package/extensions/internal/output-guard.ts +12 -1
- package/extensions/internal/project-approval.ts +18 -1
- package/extensions/mcp.ts +90 -19
- package/extensions/memory.ts +39 -7
- package/extensions/notify.ts +1 -1
- package/extensions/plan-mode/index.ts +78 -11
- package/extensions/status-line.ts +4 -1
- package/extensions/subagent/agents.ts +10 -21
- package/extensions/subagent/background.ts +106 -22
- package/extensions/subagent/index.ts +39 -28
- package/extensions/todo.ts +2 -2
- package/extensions/web.ts +7 -1
- package/package.json +1 -1
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,
|
|
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` |
|
package/extensions/commands.ts
CHANGED
|
@@ -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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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
|
}
|
package/extensions/hooks.ts
CHANGED
|
@@ -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
|
|
101
|
-
if (Array.isArray(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
|
-
|
|
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
|
-
/**
|
|
272
|
-
*
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
279
|
-
|
|
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(
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
|
321
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
const
|
|
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:
|
|
62
|
-
argumentHint:
|
|
63
|
-
allowedTools:
|
|
64
|
-
model:
|
|
65
|
-
disableModelInvocation:
|
|
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}
|
|
83
|
-
* unfilled positional becomes empty rather than leaking its
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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
|
-
|
|
457
|
-
|
|
458
|
-
|
|
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
|