pi-code 1.0.2 → 1.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/extensions/commands.ts +9 -2
- package/extensions/hooks.ts +28 -2
- package/extensions/internal/command-file.ts +82 -13
- package/extensions/internal/output-guard.ts +12 -1
- package/extensions/internal/project-approval.ts +18 -1
- package/extensions/mcp.ts +70 -14
- package/extensions/plan-mode/index.ts +23 -2
- package/extensions/subagent/agents.ts +10 -21
- package/extensions/subagent/background.ts +15 -1
- package/extensions/subagent/index.ts +6 -13
- package/extensions/todo.ts +2 -2
- 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
|
@@ -87,8 +87,15 @@ export default function commandsExtension(pi: ExtensionAPI) {
|
|
|
87
87
|
// list, leaving the command running with everything enabled.
|
|
88
88
|
if (parsed.allowedTools) {
|
|
89
89
|
const saved = pi.getActiveTools()
|
|
90
|
-
|
|
91
|
-
|
|
90
|
+
const granted = parsed.allowedTools.filter((tool) => saved.includes(tool))
|
|
91
|
+
// Only the first restriction in a turn knows the unrestricted set; a second
|
|
92
|
+
// command would otherwise record the first one's narrowed set as the thing to
|
|
93
|
+
// restore, and the tools the first command dropped would never come back.
|
|
94
|
+
pendingRestore ??= saved
|
|
95
|
+
// `allowed-tools: []` says no tools, and is honored. A non-empty list that
|
|
96
|
+
// intersects to nothing named only tools pi has none of: that restriction cannot
|
|
97
|
+
// be expressed, and applying it as "no tools" is not what the command asked for.
|
|
98
|
+
if (granted.length > 0 || parsed.allowedTools.length === 0) pi.setActiveTools(granted)
|
|
92
99
|
}
|
|
93
100
|
pi.sendUserMessage(expanded)
|
|
94
101
|
}
|
package/extensions/hooks.ts
CHANGED
|
@@ -97,8 +97,14 @@ export function loadHooks(files: string[]): HooksConfig {
|
|
|
97
97
|
} catch {
|
|
98
98
|
continue
|
|
99
99
|
}
|
|
100
|
-
for (const [event, matchers] of Object.entries(parsed
|
|
101
|
-
if (Array.isArray(matchers))
|
|
100
|
+
for (const [event, matchers] of Object.entries(parsed?.hooks ?? {})) {
|
|
101
|
+
if (!Array.isArray(matchers)) continue
|
|
102
|
+
// Entries are validated here rather than where they run: a hand-edited settings
|
|
103
|
+
// file that writes `hooks` as an object instead of a list used to throw out of
|
|
104
|
+
// the tool_call handler, and pi turns that into an error result, so every tool
|
|
105
|
+
// call for the rest of the session failed with an opaque type error.
|
|
106
|
+
const usable = matchers.filter((entry) => isUsableMatcher(entry, file, event))
|
|
107
|
+
if (usable.length > 0) config[event] = [...(config[event] ?? []), ...usable]
|
|
102
108
|
}
|
|
103
109
|
}
|
|
104
110
|
return config
|
|
@@ -124,6 +130,26 @@ function exactListApplies(matcher: string, names: readonly string[]): boolean {
|
|
|
124
130
|
return names.some((name) => tokens.has(foldName(name)))
|
|
125
131
|
}
|
|
126
132
|
|
|
133
|
+
/** A matcher entry pi-code can run: an object whose `hooks` is a list. Anything else
|
|
134
|
+
* is reported by name and skipped, so one bad entry costs its own hooks, not the
|
|
135
|
+
* session's tool calls. */
|
|
136
|
+
function isUsableMatcher(entry: unknown, file: string, event: string): entry is HookMatcher {
|
|
137
|
+
const candidate = entry as HookMatcher | null
|
|
138
|
+
if (candidate === null || typeof candidate !== 'object') {
|
|
139
|
+
console.warn(`pi-code-hooks: ignoring a non-object ${event} entry in ${file}`)
|
|
140
|
+
return false
|
|
141
|
+
}
|
|
142
|
+
if (candidate.hooks !== undefined && !Array.isArray(candidate.hooks)) {
|
|
143
|
+
console.warn(`pi-code-hooks: ignoring ${event} entry in ${file}: "hooks" must be a list`)
|
|
144
|
+
return false
|
|
145
|
+
}
|
|
146
|
+
if (candidate.matcher !== undefined && typeof candidate.matcher !== 'string') {
|
|
147
|
+
console.warn(`pi-code-hooks: ignoring ${event} entry in ${file}: "matcher" must be a string`)
|
|
148
|
+
return false
|
|
149
|
+
}
|
|
150
|
+
return true
|
|
151
|
+
}
|
|
152
|
+
|
|
127
153
|
function matcherApplies(matcher: string | undefined, names: readonly string[]): boolean {
|
|
128
154
|
if (!matcher || matcher === '*') return true
|
|
129
155
|
if (EXACT_MATCHER.test(matcher)) return exactListApplies(matcher, names)
|
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
import * as fs from 'node:fs'
|
|
13
13
|
import * as path from 'node:path'
|
|
14
14
|
|
|
15
|
+
import { parseFrontmatter } from '@earendil-works/pi-coding-agent'
|
|
16
|
+
|
|
15
17
|
export interface ParsedCommand {
|
|
16
18
|
description: string
|
|
17
19
|
argumentHint?: string
|
|
@@ -39,30 +41,97 @@ const CLAUDE_TOOL_MAP: Record<string, string> = {
|
|
|
39
41
|
grep: 'grep',
|
|
40
42
|
glob: 'find',
|
|
41
43
|
ls: 'ls',
|
|
44
|
+
// Claude's names for the tools this package registers itself. Without these a
|
|
45
|
+
// perfectly ordinary `allowed-tools: WebFetch, WebSearch` matched no pi tool and
|
|
46
|
+
// the intersection left the turn with nothing.
|
|
47
|
+
webfetch: 'web_fetch',
|
|
48
|
+
websearch: 'web_search',
|
|
49
|
+
todowrite: 'todo',
|
|
50
|
+
todoread: 'todo',
|
|
51
|
+
task: 'subagent',
|
|
52
|
+
askuserquestion: 'question',
|
|
53
|
+
exitplanmode: 'plan_mode_complete',
|
|
42
54
|
}
|
|
43
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Claude scopes a grant to arguments: `Bash(git add:*)` allows exactly those commands.
|
|
58
|
+
* pi's active-tool list is per tool, with no argument dimension, so the scope is
|
|
59
|
+
* dropped and the base tool is granted. Keeping the scope in the name matched nothing
|
|
60
|
+
* when the list was intersected with the active tools, which left a command declaring
|
|
61
|
+
* only scoped grants running with no tools at all.
|
|
62
|
+
*/
|
|
44
63
|
export function normalizeToolName(name: string): string {
|
|
45
64
|
const lower = name.trim().toLowerCase()
|
|
46
|
-
|
|
65
|
+
const scope = lower.indexOf('(')
|
|
66
|
+
const base = (scope === -1 ? lower : lower.slice(0, scope)).trim()
|
|
67
|
+
return CLAUDE_TOOL_MAP[base] ?? base
|
|
47
68
|
}
|
|
48
69
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
70
|
+
/**
|
|
71
|
+
* Entries are comma-separated, except a comma inside an argument scope belongs to the
|
|
72
|
+
* scope: `Bash(cat, tail)` is one grant, not three. Splitting on every comma made the
|
|
73
|
+
* fragments between them top-level entries, so a command naming only `Bash` came away
|
|
74
|
+
* with pi's `edit` tool active.
|
|
75
|
+
*
|
|
76
|
+
* Scanned rather than matched with a regex: the pattern form is quadratic on an input
|
|
77
|
+
* of unclosed parens, and a command file comes from the repository.
|
|
78
|
+
*/
|
|
79
|
+
export function toolEntries(raw: string): string[] {
|
|
80
|
+
const entries: string[] = []
|
|
81
|
+
let current = ''
|
|
82
|
+
let depth = 0
|
|
83
|
+
for (const ch of raw) {
|
|
84
|
+
if (ch === '(') depth++
|
|
85
|
+
else if (ch === ')') depth = Math.max(0, depth - 1)
|
|
86
|
+
if (ch === ',' && depth === 0) {
|
|
87
|
+
entries.push(current)
|
|
88
|
+
current = ''
|
|
89
|
+
} else {
|
|
90
|
+
current += ch
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
entries.push(current)
|
|
94
|
+
return entries.map((entry) => entry.trim()).filter(Boolean)
|
|
52
95
|
}
|
|
53
96
|
|
|
97
|
+
/**
|
|
98
|
+
* A tool grant is either a comma-separated string or a YAML list, and the two mean the
|
|
99
|
+
* same thing. An empty list is not the same as an absent one: it says no tools, so it
|
|
100
|
+
* comes back as an empty array rather than undefined.
|
|
101
|
+
*/
|
|
102
|
+
export function parseToolList(raw: unknown): string[] | undefined {
|
|
103
|
+
if (raw === undefined || raw === null) return undefined
|
|
104
|
+
let items: unknown[]
|
|
105
|
+
if (Array.isArray(raw)) items = raw
|
|
106
|
+
else if (typeof raw === 'string') items = toolEntries(raw)
|
|
107
|
+
else return undefined
|
|
108
|
+
if (items.some((item) => typeof item !== 'string')) return undefined
|
|
109
|
+
return [...new Set((items as string[]).map(normalizeToolName).filter(Boolean))]
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** YAML types a bare scalar, so a model named `3.5` arrives as a number, not a string. */
|
|
113
|
+
const text = (value: unknown): string => {
|
|
114
|
+
if (typeof value === 'string') return value.trim()
|
|
115
|
+
return typeof value === 'number' || typeof value === 'boolean' ? String(value) : ''
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Claude writes `argument-hint: [pr]`, which YAML reads as a list; render it back. */
|
|
119
|
+
const hint = (value: unknown): string => (Array.isArray(value) ? `[${value.join(', ')}]` : text(value))
|
|
120
|
+
|
|
54
121
|
export function parseCommandFile(content: string): ParsedCommand {
|
|
55
|
-
|
|
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
|
}
|
|
@@ -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,26 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
419
467
|
console.warn(`pi-code-mcp: skipping duplicate server name ${name}`)
|
|
420
468
|
continue
|
|
421
469
|
}
|
|
422
|
-
|
|
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
|
+
} catch (error) {
|
|
486
|
+
status.set(name, { state: `failed: ${error instanceof Error ? error.message : String(error)}`, tools: 0 })
|
|
487
|
+
}
|
|
488
|
+
}),
|
|
489
|
+
)
|
|
434
490
|
}
|
|
435
491
|
|
|
436
492
|
/** Connect the project scope under the per-server policy. Returns whether the scope
|
|
@@ -132,6 +132,11 @@ export default function planModeExtension(pi: ExtensionAPI): void {
|
|
|
132
132
|
enabled: planModeEnabled,
|
|
133
133
|
todos: todoItems,
|
|
134
134
|
executing: executionMode,
|
|
135
|
+
// The pre-plan tool set has to survive with the state that caused it to shrink.
|
|
136
|
+
// /reload rebuilds this extension with an empty snapshot while pi carries the
|
|
137
|
+
// restricted tools into the new runtime, so a restore has no way to work out
|
|
138
|
+
// what was active before plan mode unless it was written down here.
|
|
139
|
+
savedTools,
|
|
135
140
|
})
|
|
136
141
|
}
|
|
137
142
|
|
|
@@ -379,7 +384,7 @@ After completing a step, include a [DONE:n] tag in your response.`,
|
|
|
379
384
|
const entries = ctx.sessionManager.getEntries()
|
|
380
385
|
|
|
381
386
|
// Restore persisted state
|
|
382
|
-
const planModeEntry = findLast(entries, (e: { type: string; customType?: string }) => e.type === 'custom' && e.customType === 'plan-mode') as { data?: { enabled: boolean; todos?: TodoItem[]; executing?: boolean } } | undefined
|
|
387
|
+
const planModeEntry = findLast(entries, (e: { type: string; customType?: string }) => e.type === 'custom' && e.customType === 'plan-mode') as { data?: { enabled: boolean; todos?: TodoItem[]; executing?: boolean; savedTools?: string[] } } | undefined
|
|
383
388
|
|
|
384
389
|
if (planModeEntry?.data) {
|
|
385
390
|
planModeEnabled = planModeEntry.data.enabled ?? planModeEnabled
|
|
@@ -396,7 +401,23 @@ After completing a step, include a [DONE:n] tag in your response.`,
|
|
|
396
401
|
}
|
|
397
402
|
|
|
398
403
|
if (planModeEnabled) {
|
|
399
|
-
|
|
404
|
+
// Restoring into plan mode is the only case the recorded snapshot is for.
|
|
405
|
+
// Re-reading the active set here would capture the restriction pi carried
|
|
406
|
+
// across /reload and cost the session edit and write for good; applying the
|
|
407
|
+
// snapshot when plan mode is off would instead push a stale set over whatever
|
|
408
|
+
// pi has registered since, so it stays scoped to this branch.
|
|
409
|
+
savedTools = planModeEntry?.data?.savedTools ?? pi.getActiveTools()
|
|
410
|
+
pi.setActiveTools(PLAN_MODE_TOOLS.filter((t) => savedTools.includes(t)))
|
|
411
|
+
// --plan enters plan mode without ever toggling, so nothing has persisted yet
|
|
412
|
+
// and a /reload would find no snapshot to restore from. Record it now, while
|
|
413
|
+
// the active set still says what was there before the restriction.
|
|
414
|
+
//
|
|
415
|
+
// Only with no entry at all: an entry written before this field existed means
|
|
416
|
+
// the active set has already been through a restore and may be the restriction
|
|
417
|
+
// itself. Those tools are lost for this process either way, but persisting a
|
|
418
|
+
// guess would write the loss into the session file, where a later resume would
|
|
419
|
+
// inherit it instead of starting over.
|
|
420
|
+
if (!planModeEntry) persistState()
|
|
400
421
|
} else {
|
|
401
422
|
// A prior session in this instance may have shrunk the tool set; undo that when
|
|
402
423
|
// the restored/fresh state is not plan mode.
|
|
@@ -7,21 +7,10 @@ import * as os from 'node:os'
|
|
|
7
7
|
import * as path from 'node:path'
|
|
8
8
|
import { getAgentDir, parseFrontmatter, stripFrontmatter } from '@earendil-works/pi-coding-agent'
|
|
9
9
|
|
|
10
|
-
//
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
edit: 'edit',
|
|
15
|
-
bash: 'bash',
|
|
16
|
-
grep: 'grep',
|
|
17
|
-
glob: 'find',
|
|
18
|
-
ls: 'ls',
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
function normalizeToolName(tool: string): string {
|
|
22
|
-
const lower = tool.toLowerCase()
|
|
23
|
-
return CLAUDE_TOOL_MAP[lower] ?? lower
|
|
24
|
-
}
|
|
10
|
+
// The same mapping a command's `allowed-tools` gets: an agent's `tools:` is the same
|
|
11
|
+
// Claude field, and `--tools` is an exact-name allowlist, so a name pi has no tool for
|
|
12
|
+
// is not merely ignored, it narrows the child's registry.
|
|
13
|
+
import { parseToolList } from '../internal/command-file.js'
|
|
25
14
|
|
|
26
15
|
/**
|
|
27
16
|
* `tools:` may be a comma-separated string (the Claude Code format) or a YAML block
|
|
@@ -30,12 +19,12 @@ function normalizeToolName(tool: string): string {
|
|
|
30
19
|
*/
|
|
31
20
|
function parseToolsField(raw: unknown): string[] | undefined | null {
|
|
32
21
|
if (raw === undefined) return undefined
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
22
|
+
// Shares the command parser's splitting, so a comma inside an argument scope stays
|
|
23
|
+
// inside it here too: `Bash(mv, write, cp)` used to hand the child pi's real `write`.
|
|
24
|
+
if (raw !== null && !Array.isArray(raw) && typeof raw !== 'string') return null
|
|
25
|
+
if (Array.isArray(raw) && raw.some((item) => typeof item !== 'string')) return null
|
|
26
|
+
const tools = parseToolList(raw)
|
|
27
|
+
if (!tools) return null
|
|
39
28
|
return tools.length > 0 ? tools : undefined
|
|
40
29
|
}
|
|
41
30
|
|
|
@@ -175,11 +175,25 @@ function driveRun(run: BackgroundRun, invocation: BackgroundSpawn, onComplete: (
|
|
|
175
175
|
const complete = (): void => {
|
|
176
176
|
if (completed) return
|
|
177
177
|
completed = true
|
|
178
|
-
|
|
178
|
+
// A run outlives the session that started it, and pi's loader wires assertActive()
|
|
179
|
+
// into every runtime call, so notifying a disposed session throws. This fires from
|
|
180
|
+
// the child's 'close'/'error' listener, where nothing upstream catches: an escaping
|
|
181
|
+
// error reaches Node as an uncaughtException and takes pi down with it. The run
|
|
182
|
+
// state is already recorded by this point, so there is nothing to do but drop the
|
|
183
|
+
// notification for a session that is no longer there to receive it.
|
|
184
|
+
try {
|
|
185
|
+
onComplete(run)
|
|
186
|
+
} catch {
|
|
187
|
+
// the session that asked for this run is gone
|
|
188
|
+
}
|
|
179
189
|
}
|
|
180
190
|
proc.stdout.on('data', (data) => {
|
|
181
191
|
stdout += data.toString()
|
|
182
192
|
})
|
|
193
|
+
// An 'error' on a stream with no listener is rethrown by EventEmitter, and this one
|
|
194
|
+
// belongs to a detached child, so a pipe read failure would exit pi the same way an
|
|
195
|
+
// unguarded completion would. The foreground runner guards its streams the same way.
|
|
196
|
+
proc.stdout.on('error', () => {})
|
|
183
197
|
proc.on('close', (code) => {
|
|
184
198
|
const { text, turns } = parseFinalOutputFromJsonl(stdout)
|
|
185
199
|
run.kill = undefined
|
|
@@ -628,15 +628,11 @@ async function runBackgroundMode(params: SubagentParamsStatic, agents: AgentConf
|
|
|
628
628
|
const invocation = getPiInvocation(args)
|
|
629
629
|
const id = startBackgroundRun(agent.name, task, { command: invocation.command, args: invocation.args, cwd: params.cwd ?? defaultCwd, promptBody: tmpPrompt ? promptWithSkills : undefined }, (run) => {
|
|
630
630
|
removeTmpPrompt(tmpPrompt)
|
|
631
|
+
// Both calls throw once the session that started the run is disposed; driveRun
|
|
632
|
+
// catches for the whole callback, so neither can escape into the child's close
|
|
633
|
+
// listener and become an uncaughtException.
|
|
631
634
|
pi.events.emit(SUBAGENT_CHANNEL, { phase: 'stop', agentType: run.agent, agentId: run.id })
|
|
632
|
-
|
|
633
|
-
// that session is disposed; an escaping error here would reach Node as an
|
|
634
|
-
// uncaughtException and take the process down with it.
|
|
635
|
-
try {
|
|
636
|
-
pi.sendMessage({ customType: 'subagent-background', content: backgroundCompletionText(run), display: true }, { triggerTurn: true })
|
|
637
|
-
} catch {
|
|
638
|
-
// the session that asked for this run is gone; nothing left to notify
|
|
639
|
-
}
|
|
635
|
+
pi.sendMessage({ customType: 'subagent-background', content: backgroundCompletionText(run), display: true }, { triggerTurn: true })
|
|
640
636
|
})
|
|
641
637
|
if (id === null) {
|
|
642
638
|
// Lost the cap race to a parallel batch: the atomic check inside startBackgroundRun refused.
|
|
@@ -1113,11 +1109,8 @@ function renderParallelResult(results: SingleResult[], expanded: boolean, theme:
|
|
|
1113
1109
|
|
|
1114
1110
|
export default function subagentExtension(pi: ExtensionAPI) {
|
|
1115
1111
|
const notifyBackgroundCompletion = (run: { id: string; agent: string; state: string; turns: number; output?: string }): void => {
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
} catch {
|
|
1119
|
-
// same as above: the session that started the run may already be gone
|
|
1120
|
-
}
|
|
1112
|
+
// Runs through driveRun's guard, same as the background-mode callback above.
|
|
1113
|
+
pi.sendMessage({ customType: 'subagent-background', content: backgroundCompletionText(run), display: true }, { triggerTurn: true })
|
|
1121
1114
|
}
|
|
1122
1115
|
|
|
1123
1116
|
// Claude surfaces each agent's description so the model can pick one autonomously.
|
package/extensions/todo.ts
CHANGED
|
@@ -337,8 +337,8 @@ export default function todoExtension(pi: ExtensionAPI) {
|
|
|
337
337
|
|
|
338
338
|
const details = msg.details as (Omit<TodoDetails, 'todos'> & { todos?: LegacyTodo[] }) | undefined
|
|
339
339
|
// pi persists a failed tool call as `details: {}`, which is truthy: a rejected
|
|
340
|
-
// or blocked
|
|
341
|
-
//
|
|
340
|
+
// or blocked call would otherwise throw here and break replay for the rest of
|
|
341
|
+
// the session, losing the list on every resume, fork and compaction.
|
|
342
342
|
if (Array.isArray(details?.todos)) {
|
|
343
343
|
replayTodos = details.todos.map(normalizeTodo)
|
|
344
344
|
replayNextId = details.nextId
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-code",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.3",
|
|
4
4
|
"description": "Claude Code experience for the pi coding agent: reads your .claude config (rules, commands, skills, hooks, output styles, MCP servers, agents) and adds todo, checkpoints, memory, web, and subagents",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi",
|