pi-code 1.0.1 → 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 +3 -3
- package/extensions/commands.ts +28 -12
- package/extensions/hooks.ts +28 -2
- package/extensions/internal/command-file.ts +107 -20
- package/extensions/internal/output-guard.ts +12 -1
- package/extensions/internal/project-approval.ts +20 -1
- package/extensions/mcp.ts +87 -24
- package/extensions/plan-mode/README.md +3 -3
- package/extensions/plan-mode/index.ts +23 -2
- package/extensions/status-line.ts +11 -1
- package/extensions/subagent/README.md +1 -1
- package/extensions/subagent/agents.ts +32 -33
- package/extensions/subagent/background.ts +43 -2
- package/extensions/subagent/index.ts +9 -1
- package/extensions/todo.ts +5 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -38,7 +38,7 @@ One `pi install` and everything below loads on the next start. `pi list` shows w
|
|
|
38
38
|
| Feature | Reads / provides | Extension |
|
|
39
39
|
|---|---|---|
|
|
40
40
|
| Global + project rules | `~/.claude/rules`, `.claude/rules` (+ `paths:` frontmatter scoping) | `claude-rules.ts` |
|
|
41
|
-
| Custom slash commands | `.claude/commands/**/*.md` (namespaced `/dir:name`), `$ARGUMENTS`/`$1`, `` !`cmd` `` bash output, `@file` inlining, `allowed-tools`/`
|
|
41
|
+
| Custom slash commands | `.claude/commands/**/*.md` (namespaced `/dir:name`), `$ARGUMENTS`/`$1`, `` !`cmd` `` bash output, `@file` inlining, `allowed-tools`/`argument-hint` frontmatter (`model` is parsed but not yet applied); project commands gated on approval | `commands.ts` |
|
|
42
42
|
| Skills | `.claude/skills` → pi skill discovery, project skills gated on approval (pi reads `name`, `description`, `disable-model-invocation`; `allowed-tools` is inert in pi's loader) | `skills.ts` |
|
|
43
43
|
| Hooks | `.claude/settings.json` hooks: PreToolUse (blocks, rewrites input via `updatedInput`), PostToolUse (feedback and `additionalContext` land next to the tool result), PostToolUseFailure, SessionStart (context injection), UserPromptSubmit (blocks and injects context), Stop (a block continues the conversation), SubagentStart/SubagentStop, PreCompact, PostCompact, SessionEnd; Claude matcher semantics incl. `mcp__server__tool` names; payloads carry session_id, transcript_path, cwd, permission_mode, effort | `hooks.ts` |
|
|
44
44
|
| Output styles | `.claude/output-styles` + active `outputStyle`; Claude replace semantics with `keep-coding-instructions`; bundled Explanatory/Learning/Proactive; `/output-style [name]` | `output-styles.ts` |
|
|
@@ -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` |
|
|
@@ -57,7 +57,7 @@ One `pi install` and everything below loads on the next start. `pi list` shows w
|
|
|
57
57
|
|
|
58
58
|
`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.
|
|
59
59
|
|
|
60
|
-
`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),
|
|
60
|
+
`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), `project-approval.ts` (the trust decision above), `command-file.ts` (slash-command parsing and dynamic content), and the shared-bus contracts `mcp-alias.ts`, `plan-mode-state.ts` and `subagent-events.ts`. The extensions use them; only `internal/` keeps them out of pi's extension scan.
|
|
61
61
|
|
|
62
62
|
Vendored bases (`question`, `notify`, `status-line`) come from pi's MIT example extensions (see [LICENSE](LICENSE)).
|
|
63
63
|
|
package/extensions/commands.ts
CHANGED
|
@@ -6,8 +6,10 @@
|
|
|
6
6
|
* is what makes the rest of Claude's command contract reachable: namespaced
|
|
7
7
|
* subdirectories (`frontend/build.md` is `/frontend:build`), `$ARGUMENTS` and
|
|
8
8
|
* positional substitution, `` !`cmd` `` bash output, `@file` inlining, and the
|
|
9
|
-
* `allowed-tools`
|
|
10
|
-
*
|
|
9
|
+
* `allowed-tools` and `argument-hint` frontmatter. `model` and
|
|
10
|
+
* `disable-model-invocation` are parsed but not applied yet: pi has seams for both
|
|
11
|
+
* (`pi.setModel`, and commands are user-invoked anyway), so they are a gap rather
|
|
12
|
+
* than an impossibility.
|
|
11
13
|
*
|
|
12
14
|
* A project command body is repository-controlled text that can now run shell
|
|
13
15
|
* commands and read files, so project commands load only once the project is
|
|
@@ -59,6 +61,14 @@ export function collectCommands(dirs: string[]): DiscoveredCommand[] {
|
|
|
59
61
|
|
|
60
62
|
export default function commandsExtension(pi: ExtensionAPI) {
|
|
61
63
|
const registered = new Set<string>()
|
|
64
|
+
/** Tool set to put back once the turn a restricted command drove has ended. */
|
|
65
|
+
let pendingRestore: string[] | undefined
|
|
66
|
+
|
|
67
|
+
pi.on('turn_end', async () => {
|
|
68
|
+
if (!pendingRestore) return
|
|
69
|
+
pi.setActiveTools(pendingRestore)
|
|
70
|
+
pendingRestore = undefined
|
|
71
|
+
})
|
|
62
72
|
|
|
63
73
|
async function runCommand(parsed: ParsedCommand, args: string, ctx: ExtensionCommandContext): Promise<void> {
|
|
64
74
|
const withArgs = substituteArgs(parsed.body, args)
|
|
@@ -71,17 +81,23 @@ export default function commandsExtension(pi: ExtensionAPI) {
|
|
|
71
81
|
return { stdout: result.stdout, stderr: result.stderr, code: result.code }
|
|
72
82
|
})
|
|
73
83
|
|
|
74
|
-
// allowed-tools restricts the turn the command drives,
|
|
75
|
-
// restored
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
+
// allowed-tools restricts the turn the command drives, and the previous set is
|
|
85
|
+
// restored when that turn ends. Restoring inline does not work: sendUserMessage is
|
|
86
|
+
// fire-and-forget, so the restore would land before the agent ever read the tool
|
|
87
|
+
// list, leaving the command running with everything enabled.
|
|
88
|
+
if (parsed.allowedTools) {
|
|
89
|
+
const saved = pi.getActiveTools()
|
|
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)
|
|
84
99
|
}
|
|
100
|
+
pi.sendUserMessage(expanded)
|
|
85
101
|
}
|
|
86
102
|
|
|
87
103
|
pi.on('session_start', async (_event, ctx) => {
|
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
|
|
@@ -27,28 +29,109 @@ export interface DiscoveredCommand {
|
|
|
27
29
|
filePath: string
|
|
28
30
|
}
|
|
29
31
|
|
|
30
|
-
/** Claude tool names are PascalCase
|
|
31
|
-
|
|
32
|
-
|
|
32
|
+
/** Claude tool names are PascalCase and do not all exist in pi: `Glob` is pi's
|
|
33
|
+
* `find`. Lowercasing alone left `glob` in the list, and since pi has no tool by
|
|
34
|
+
* that name the grant was silently dropped when the list was intersected with the
|
|
35
|
+
* active tools. Shared with the subagent's own frontmatter parsing. */
|
|
36
|
+
const CLAUDE_TOOL_MAP: Record<string, string> = {
|
|
37
|
+
read: 'read',
|
|
38
|
+
write: 'write',
|
|
39
|
+
edit: 'edit',
|
|
40
|
+
bash: 'bash',
|
|
41
|
+
grep: 'grep',
|
|
42
|
+
glob: 'find',
|
|
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',
|
|
33
54
|
}
|
|
34
55
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
+
*/
|
|
63
|
+
export function normalizeToolName(name: string): string {
|
|
64
|
+
const lower = name.trim().toLowerCase()
|
|
65
|
+
const scope = lower.indexOf('(')
|
|
66
|
+
const base = (scope === -1 ? lower : lower.slice(0, scope)).trim()
|
|
67
|
+
return CLAUDE_TOOL_MAP[base] ?? base
|
|
38
68
|
}
|
|
39
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))]
|
|
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
|
+
|
|
40
121
|
export function parseCommandFile(content: string): ParsedCommand {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
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()
|
|
45
127
|
const firstLine = body.split('\n').find((line) => line.trim().length > 0) ?? ''
|
|
128
|
+
const disable = frontmatter['disable-model-invocation']
|
|
46
129
|
return {
|
|
47
|
-
description:
|
|
48
|
-
argumentHint:
|
|
49
|
-
allowedTools:
|
|
50
|
-
model:
|
|
51
|
-
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',
|
|
52
135
|
body,
|
|
53
136
|
}
|
|
54
137
|
}
|
|
@@ -128,12 +211,16 @@ const inRanges = (ranges: Array<[number, number]>, index: number): boolean => ra
|
|
|
128
211
|
/** Read a `@path` reference, confined to the working directory. Returns undefined
|
|
129
212
|
* when the path escapes it or cannot be read, so the reference stays literal. */
|
|
130
213
|
function readReference(cwd: string, reference: string): string | undefined {
|
|
131
|
-
const resolved = path.resolve(cwd, reference)
|
|
132
|
-
const root = path.resolve(cwd)
|
|
133
|
-
if (resolved !== root && !resolved.startsWith(root + path.sep)) return undefined
|
|
134
214
|
try {
|
|
135
|
-
|
|
136
|
-
|
|
215
|
+
// Both sides canonicalised: on macOS /var is itself a symlink, so comparing a
|
|
216
|
+
// resolved path against an unresolved root rejects every legitimate read.
|
|
217
|
+
const root = fs.realpathSync(cwd)
|
|
218
|
+
// Confinement is checked after symlinks resolve: a lexical check passes a link
|
|
219
|
+
// that points outside the project, and the read would follow it.
|
|
220
|
+
const real = fs.realpathSync(path.resolve(cwd, reference))
|
|
221
|
+
if (real !== root && !real.startsWith(root + path.sep)) return undefined
|
|
222
|
+
if (!fs.statSync(real).isFile()) return undefined
|
|
223
|
+
return fs.readFileSync(real, 'utf-8')
|
|
137
224
|
} catch {
|
|
138
225
|
return undefined
|
|
139
226
|
}
|
|
@@ -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
|
|
@@ -29,14 +29,33 @@ const CLAUDE_SHAPED = [
|
|
|
29
29
|
path.join('.claude', 'hooks'),
|
|
30
30
|
path.join('.claude', 'output-styles'),
|
|
31
31
|
path.join('.claude', 'rules'),
|
|
32
|
+
path.join('.claude', 'skills'),
|
|
33
|
+
path.join('.claude', 'commands'),
|
|
32
34
|
'CLAUDE.local.md',
|
|
33
35
|
'.mcp.json',
|
|
34
36
|
path.join('.pi', 'mcp.json'),
|
|
35
37
|
path.join('.pi', 'agents'),
|
|
36
38
|
]
|
|
37
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. */
|
|
38
50
|
export function hasClaudeShapedConfig(cwd: string): boolean {
|
|
39
|
-
|
|
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
|
+
}
|
|
40
59
|
}
|
|
41
60
|
|
|
42
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.
|
|
@@ -32,7 +33,7 @@ import { ToolListChangedNotificationSchema } from '@modelcontextprotocol/sdk/typ
|
|
|
32
33
|
import { Type } from 'typebox'
|
|
33
34
|
import { MCP_TOOLS_CHANNEL, type McpToolAlias } from './internal/mcp-alias.js'
|
|
34
35
|
import { capForContext } from './internal/output-guard.js'
|
|
35
|
-
import { isProjectApproved } from './internal/project-approval.js'
|
|
36
|
+
import { isProjectApproved, isProjectApprovedSilently } from './internal/project-approval.js'
|
|
36
37
|
|
|
37
38
|
const DEFAULT_CONNECT_TIMEOUT_MS = 10_000
|
|
38
39
|
const DEFAULT_CALL_TIMEOUT_MS = 120_000
|
|
@@ -103,13 +104,18 @@ export interface ProjectServerPolicy {
|
|
|
103
104
|
consentAll: boolean
|
|
104
105
|
}
|
|
105
106
|
|
|
106
|
-
/** Claude's per-server approvals for project .mcp.json servers.
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
|
|
107
|
+
/** Claude's per-server approvals for project .mcp.json servers.
|
|
108
|
+
*
|
|
109
|
+
* Consent-granting keys (enabledMcpjsonServers, enableAllProjectMcpServers) count
|
|
110
|
+
* from the user's own settings always, and from the project's settings.local.json
|
|
111
|
+
* only once the project itself is approved. That file is gitignored by convention,
|
|
112
|
+
* not by enforcement: a repository can commit one, and honoring it unconditionally
|
|
113
|
+
* let a hostile repo self-approve a server whose `command` runs on connect, even
|
|
114
|
+
* after the user declined the trust prompt.
|
|
115
|
+
*
|
|
116
|
+
* disabledMcpjsonServers counts from every file, including the repo's own, and wins
|
|
117
|
+
* over consent: a repo may always restrict itself further, never less. */
|
|
118
|
+
export function projectServerPolicy(cwd: string, home: string, projectApproved: boolean): ProjectServerPolicy {
|
|
113
119
|
const read = (file: string): Record<string, unknown> => {
|
|
114
120
|
try {
|
|
115
121
|
return JSON.parse(fs.readFileSync(file, 'utf-8'))
|
|
@@ -122,7 +128,7 @@ export function projectServerPolicy(cwd: string, home: string): ProjectServerPol
|
|
|
122
128
|
const projectSettings = read(path.join(cwd, '.claude', 'settings.json'))
|
|
123
129
|
const localSettings = read(path.join(cwd, '.claude', 'settings.local.json'))
|
|
124
130
|
const disabled = new Set([...names(userSettings.disabledMcpjsonServers), ...names(projectSettings.disabledMcpjsonServers), ...names(localSettings.disabledMcpjsonServers)])
|
|
125
|
-
const consentSources = [userSettings, localSettings]
|
|
131
|
+
const consentSources = projectApproved ? [userSettings, localSettings] : [userSettings]
|
|
126
132
|
const consented = new Set(consentSources.flatMap((settings) => names(settings.enabledMcpjsonServers)))
|
|
127
133
|
const consentAll = consentSources.some((settings) => settings.enableAllProjectMcpServers === true)
|
|
128
134
|
return { disabled, consented, consentAll }
|
|
@@ -217,12 +223,14 @@ export type ToolContent = { type: 'text'; text: string } | { type: 'image'; data
|
|
|
217
223
|
export function mapContent(content: McpContentBlock[] | undefined, structured?: unknown): ToolContent[] {
|
|
218
224
|
// capForContext every text output, whatever its source: a server can blow the tool-output
|
|
219
225
|
// budget through a resource block, a JSON-stringified block, or the structured fallback,
|
|
220
|
-
// 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.
|
|
221
229
|
const text = (value: string): ToolContent => ({ type: 'text', text: capForContext(value) })
|
|
222
230
|
if (!content || content.length === 0) {
|
|
223
231
|
return [text(structured !== undefined ? JSON.stringify(structured, null, 2) : '(empty result)')]
|
|
224
232
|
}
|
|
225
|
-
|
|
233
|
+
const mapped: ToolContent[] = content.map((block): ToolContent => {
|
|
226
234
|
if (block.type === 'text') {
|
|
227
235
|
return text(block.text ?? '')
|
|
228
236
|
}
|
|
@@ -234,6 +242,50 @@ export function mapContent(content: McpContentBlock[] | undefined, structured?:
|
|
|
234
242
|
}
|
|
235
243
|
return text(JSON.stringify(block))
|
|
236
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
|
|
237
289
|
}
|
|
238
290
|
|
|
239
291
|
function isStdio(config: ServerConfig): config is StdioServerConfig {
|
|
@@ -374,7 +426,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
374
426
|
if (result.isError) {
|
|
375
427
|
details.error = 'tool_error'
|
|
376
428
|
const hint = JSON.stringify(normalizeSchema(tool.inputSchema))
|
|
377
|
-
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}`) })
|
|
378
430
|
}
|
|
379
431
|
return { content, details }
|
|
380
432
|
},
|
|
@@ -406,6 +458,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
406
458
|
}
|
|
407
459
|
|
|
408
460
|
async function connectServers(servers: Record<string, ServerConfig>): Promise<void> {
|
|
461
|
+
const pending: [string, ServerConfig][] = []
|
|
409
462
|
for (const [name, config] of Object.entries(servers)) {
|
|
410
463
|
// A later scope must not take the name of a server that already connected: it
|
|
411
464
|
// would evict that client from the map, leaking it at shutdown, and misreport
|
|
@@ -414,24 +467,34 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
414
467
|
console.warn(`pi-code-mcp: skipping duplicate server name ${name}`)
|
|
415
468
|
continue
|
|
416
469
|
}
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
const tools = await withTimeout(listAllTools(client), connectTimeoutMs(), `list tools ${name}`)
|
|
422
|
-
const count = registerTools(name, config, client, tools)
|
|
423
|
-
subscribeToToolChanges(name, config, client)
|
|
424
|
-
status.set(name, { state: 'connected', tools: count })
|
|
425
|
-
} catch (error) {
|
|
426
|
-
status.set(name, { state: `failed: ${error instanceof Error ? error.message : String(error)}`, tools: 0 })
|
|
427
|
-
}
|
|
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])
|
|
428
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
|
+
)
|
|
429
490
|
}
|
|
430
491
|
|
|
431
492
|
/** Connect the project scope under the per-server policy. Returns whether the scope
|
|
432
493
|
* is settled, so a refused confirm can be retried on a later session start. */
|
|
433
494
|
async function connectProjectScope(ctx: ExtensionContext): Promise<boolean> {
|
|
434
|
-
|
|
495
|
+
// The stored decision, read without prompting: consent recorded inside the
|
|
496
|
+
// project only counts once the project itself has been approved.
|
|
497
|
+
const policy = projectServerPolicy(ctx.cwd, os.homedir(), isProjectApprovedSilently(ctx))
|
|
435
498
|
const { consented, gated } = splitByPolicy(loadConfigFrom(projectConfigPaths(ctx.cwd)), policy)
|
|
436
499
|
if (Object.keys(consented).length > 0) await connectServers(consented)
|
|
437
500
|
if (Object.keys(gated).length === 0) return true
|
|
@@ -4,9 +4,9 @@ Read-only exploration mode for safe code analysis.
|
|
|
4
4
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
7
|
-
- **Read-only tools**: Restricts available tools to read, bash, grep, find, ls, question
|
|
7
|
+
- **Read-only tools**: Restricts available tools to read, bash, grep, find, ls, question, and `plan_mode_complete`
|
|
8
8
|
- **Bash allowlist**: Only read-only bash commands are allowed
|
|
9
|
-
- **Plan extraction**:
|
|
9
|
+
- **Plan extraction**: Takes the plan from the `plan_mode_complete` tool call, falling back to numbered steps under a `Plan:` header when the model writes prose instead
|
|
10
10
|
- **Progress tracking**: Widget shows completion status during execution
|
|
11
11
|
- **[DONE:n] markers**: Explicit step completion tracking
|
|
12
12
|
- **Session persistence**: State survives session resume
|
|
@@ -21,7 +21,7 @@ Read-only exploration mode for safe code analysis.
|
|
|
21
21
|
|
|
22
22
|
1. Enable plan mode with `/plan` or `--plan` flag
|
|
23
23
|
2. Ask the agent to analyze code and create a plan
|
|
24
|
-
3. The agent
|
|
24
|
+
3. The agent calls `plan_mode_complete` with the finished plan. If it writes prose instead, a numbered plan under a `Plan:` header is still picked up:
|
|
25
25
|
|
|
26
26
|
```
|
|
27
27
|
Plan:
|
|
@@ -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.
|
|
@@ -83,6 +83,7 @@ export default function statusLine(pi: ExtensionAPI) {
|
|
|
83
83
|
let sessionCtx: ExtensionContext | undefined
|
|
84
84
|
let commandLine: string | undefined
|
|
85
85
|
let permissionMode = 'default'
|
|
86
|
+
let projectApproved = false
|
|
86
87
|
let refreshTimer: ReturnType<typeof setInterval> | undefined
|
|
87
88
|
let debounceTimer: ReturnType<typeof setTimeout> | undefined
|
|
88
89
|
let running = false
|
|
@@ -103,7 +104,9 @@ export default function statusLine(pi: ExtensionAPI) {
|
|
|
103
104
|
/** The stdin payload per Claude's documented statusline contract. */
|
|
104
105
|
function buildPayload(ctx: ExtensionContext): Record<string, unknown> {
|
|
105
106
|
const usage = ctx.getContextUsage() ?? { tokens: null, contextWindow: 0, percent: null }
|
|
106
|
-
|
|
107
|
+
// Same gate as the config read above: an unapproved project's style is not applied,
|
|
108
|
+
// so reporting it here would describe a style the session is not using.
|
|
109
|
+
const styleName = readActiveStyleName(settingsFiles(ctx.cwd, os.homedir(), projectApproved))
|
|
107
110
|
const payload: Record<string, unknown> = {
|
|
108
111
|
session_id: ctx.sessionManager.getSessionId(),
|
|
109
112
|
cwd: ctx.cwd,
|
|
@@ -128,11 +131,17 @@ export default function statusLine(pi: ExtensionAPI) {
|
|
|
128
131
|
}
|
|
129
132
|
running = true
|
|
130
133
|
try {
|
|
134
|
+
// Everything below can touch ctx after an await, and every ctx getter throws
|
|
135
|
+
// once the session is disposed. This promise is started from a timer with no
|
|
136
|
+
// awaiter, so an escaping rejection becomes an uncaughtException and exits pi.
|
|
131
137
|
const result = await runHookCommand(config.command, buildPayload(ctx), COMMAND_TIMEOUT_MS)
|
|
132
138
|
const first = result.stdout.split('\n')[0].trimEnd()
|
|
133
139
|
const pad = ' '.repeat(config.padding)
|
|
134
140
|
commandLine = first ? `${pad}${first}${pad}` : undefined
|
|
135
141
|
show(ctx, segmentText(ctx, ctx.ui.theme.fg('dim', '○')))
|
|
142
|
+
} catch {
|
|
143
|
+
// A replaced or reloaded session invalidates ctx while the command is in
|
|
144
|
+
// flight; there is nothing left to update, and the next session starts fresh.
|
|
136
145
|
} finally {
|
|
137
146
|
running = false
|
|
138
147
|
if (rerunQueued) {
|
|
@@ -168,6 +177,7 @@ export default function statusLine(pi: ExtensionAPI) {
|
|
|
168
177
|
// approval at session start, and a second prompt stacks over the first and eats
|
|
169
178
|
// the keys meant for it. An undecided project simply skips project settings.
|
|
170
179
|
const trusted = isProjectApprovedSilently(ctx)
|
|
180
|
+
projectApproved = trusted
|
|
171
181
|
config = readStatusLineConfig(hookFiles(ctx.cwd, os.homedir(), trusted))
|
|
172
182
|
if (config?.refreshInterval) {
|
|
173
183
|
refreshTimer = setInterval(() => scheduleRefresh(), config.refreshInterval * 1000)
|
|
@@ -41,7 +41,7 @@ This tool executes a separate `pi` subprocess with a delegated system prompt and
|
|
|
41
41
|
|
|
42
42
|
To enable project-local agents (`.claude/agents`, `.pi/agents`), pass `agentScope: "both"` (or `"project"`). Only do this for repositories you trust.
|
|
43
43
|
|
|
44
|
-
When running interactively, the tool prompts for confirmation before running project-local agents.
|
|
44
|
+
When running interactively, the tool prompts for confirmation before running project-local agents. `confirmProjectAgents: false` skips that prompt for a project you have already approved; an unapproved project is still asked about.
|
|
45
45
|
|
|
46
46
|
## Usage
|
|
47
47
|
|
|
@@ -5,23 +5,12 @@
|
|
|
5
5
|
import * as fs from 'node:fs'
|
|
6
6
|
import * as os from 'node:os'
|
|
7
7
|
import * as path from 'node:path'
|
|
8
|
-
import { getAgentDir, parseFrontmatter } from '@earendil-works/pi-coding-agent'
|
|
9
|
-
|
|
10
|
-
// Claude Code tool names -> pi tool names; unmapped names pass through lowercased
|
|
11
|
-
const CLAUDE_TOOL_MAP: Record<string, string> = {
|
|
12
|
-
read: 'read',
|
|
13
|
-
write: 'write',
|
|
14
|
-
edit: 'edit',
|
|
15
|
-
bash: 'bash',
|
|
16
|
-
grep: 'grep',
|
|
17
|
-
glob: 'find',
|
|
18
|
-
ls: 'ls',
|
|
19
|
-
}
|
|
8
|
+
import { getAgentDir, parseFrontmatter, stripFrontmatter } from '@earendil-works/pi-coding-agent'
|
|
20
9
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
|
@@ -110,21 +99,31 @@ export function withPreloadedSkills(prompt: string, skills: string[] | undefined
|
|
|
110
99
|
* prompt sent to the model, so a traversal would be an arbitrary-file read. */
|
|
111
100
|
const SKILL_NAME = /^[A-Za-z0-9_.-]+$/
|
|
112
101
|
|
|
102
|
+
/** Read one candidate file, but only if it really sits under `root` once symlinks
|
|
103
|
+
* are resolved. Both sides are canonicalised: on macOS /var is itself a symlink, so
|
|
104
|
+
* comparing a resolved path against an unresolved root rejects every valid read. */
|
|
105
|
+
function readConfined(candidate: string, root: string): string | undefined {
|
|
106
|
+
try {
|
|
107
|
+
const real = fs.realpathSync(candidate)
|
|
108
|
+
if (real !== root && !real.startsWith(root + path.sep)) return undefined
|
|
109
|
+
return stripFrontmatter(fs.readFileSync(real, 'utf-8'))
|
|
110
|
+
} catch {
|
|
111
|
+
return undefined
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
113
115
|
function readSkillBody(name: string, skillDirs: string[]): string | undefined {
|
|
114
116
|
if (!SKILL_NAME.test(name) || name === '.' || name === '..') return undefined
|
|
115
117
|
for (const dir of skillDirs) {
|
|
116
|
-
|
|
118
|
+
let root: string
|
|
119
|
+
try {
|
|
120
|
+
root = fs.realpathSync(dir)
|
|
121
|
+
} catch {
|
|
122
|
+
continue // a skills directory that does not exist simply contributes nothing
|
|
123
|
+
}
|
|
117
124
|
for (const candidate of [path.join(dir, name, 'SKILL.md'), path.join(dir, `${name}.md`)]) {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
if (!path.resolve(candidate).startsWith(root + path.sep)) continue
|
|
121
|
-
try {
|
|
122
|
-
const content = fs.readFileSync(candidate, 'utf-8')
|
|
123
|
-
const match = /^---\r?\n[\s\S]*?\r?\n---/.exec(content)
|
|
124
|
-
return match ? content.slice(match[0].length) : content
|
|
125
|
-
} catch {
|
|
126
|
-
// try the next shape
|
|
127
|
-
}
|
|
125
|
+
const body = readConfined(candidate, root)
|
|
126
|
+
if (body !== undefined) return body
|
|
128
127
|
}
|
|
129
128
|
}
|
|
130
129
|
return undefined
|
|
@@ -7,6 +7,9 @@
|
|
|
7
7
|
|
|
8
8
|
import { spawn } from 'node:child_process'
|
|
9
9
|
import { randomUUID } from 'node:crypto'
|
|
10
|
+
import * as fs from 'node:fs'
|
|
11
|
+
import * as os from 'node:os'
|
|
12
|
+
import * as path from 'node:path'
|
|
10
13
|
|
|
11
14
|
export interface BackgroundRun {
|
|
12
15
|
id: string
|
|
@@ -28,6 +31,10 @@ export interface BackgroundSpawn {
|
|
|
28
31
|
command: string
|
|
29
32
|
args: string[]
|
|
30
33
|
cwd: string
|
|
34
|
+
/** The --append-system-prompt body, kept so a resume can rebuild the file the
|
|
35
|
+
* completing run deleted. Without it the resumed child is handed a path that no
|
|
36
|
+
* longer exists, and pi falls back to using that path as the prompt text. */
|
|
37
|
+
promptBody?: string
|
|
31
38
|
}
|
|
32
39
|
|
|
33
40
|
const runs = new Map<string, BackgroundRun>()
|
|
@@ -97,7 +104,7 @@ export function resumeBackgroundRun(id: string, task: string, onComplete: (run:
|
|
|
97
104
|
const run = runs.get(id)
|
|
98
105
|
if (!run) return 'unknown'
|
|
99
106
|
if (run.state === 'running') return 'still-running'
|
|
100
|
-
const args = run.spawn.
|
|
107
|
+
const args = withRebuiltPrompt(run.spawn).map((arg) => (arg.startsWith('Task: ') ? `Task: ${task}` : arg))
|
|
101
108
|
run.state = 'running'
|
|
102
109
|
run.task = task
|
|
103
110
|
run.output = undefined
|
|
@@ -106,6 +113,26 @@ export function resumeBackgroundRun(id: string, task: string, onComplete: (run:
|
|
|
106
113
|
return 'resumed'
|
|
107
114
|
}
|
|
108
115
|
|
|
116
|
+
/** Re-point --append-system-prompt at a fresh file when the original is gone. */
|
|
117
|
+
function withRebuiltPrompt(spawnSpec: BackgroundSpawn): string[] {
|
|
118
|
+
const flag = spawnSpec.args.indexOf('--append-system-prompt')
|
|
119
|
+
if (flag === -1 || !spawnSpec.promptBody) return spawnSpec.args
|
|
120
|
+
const current = spawnSpec.args[flag + 1]
|
|
121
|
+
if (current && fs.existsSync(current)) return spawnSpec.args
|
|
122
|
+
try {
|
|
123
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pi-subagent-'))
|
|
124
|
+
const file = path.join(dir, 'prompt.md')
|
|
125
|
+
fs.writeFileSync(file, spawnSpec.promptBody, { mode: 0o600 })
|
|
126
|
+
const rebuilt = [...spawnSpec.args]
|
|
127
|
+
rebuilt[flag + 1] = file
|
|
128
|
+
return rebuilt
|
|
129
|
+
} catch {
|
|
130
|
+
// Cannot rewrite it: drop the pair rather than hand pi a path it will treat as
|
|
131
|
+
// prompt text, which would replace the agent persona with a temp path.
|
|
132
|
+
return spawnSpec.args.filter((_arg, i) => i !== flag && i !== flag + 1)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
109
136
|
export function startBackgroundRun(agent: string, task: string, invocation: BackgroundSpawn, onComplete: (run: BackgroundRun) => void): string | null {
|
|
110
137
|
// Checked here, synchronously with registration: callers await temp-file writes
|
|
111
138
|
// between any check of their own and this call, so a parallel tool-call batch
|
|
@@ -148,11 +175,25 @@ function driveRun(run: BackgroundRun, invocation: BackgroundSpawn, onComplete: (
|
|
|
148
175
|
const complete = (): void => {
|
|
149
176
|
if (completed) return
|
|
150
177
|
completed = true
|
|
151
|
-
|
|
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
|
+
}
|
|
152
189
|
}
|
|
153
190
|
proc.stdout.on('data', (data) => {
|
|
154
191
|
stdout += data.toString()
|
|
155
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', () => {})
|
|
156
197
|
proc.on('close', (code) => {
|
|
157
198
|
const { text, turns } = parseFinalOutputFromJsonl(stdout)
|
|
158
199
|
run.kill = undefined
|
|
@@ -626,8 +626,11 @@ async function runBackgroundMode(params: SubagentParamsStatic, agents: AgentConf
|
|
|
626
626
|
}
|
|
627
627
|
args.push(`Task: ${task}`)
|
|
628
628
|
const invocation = getPiInvocation(args)
|
|
629
|
-
const id = startBackgroundRun(agent.name, task, { command: invocation.command, args: invocation.args, cwd: params.cwd ?? defaultCwd }, (run) => {
|
|
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
635
|
pi.sendMessage({ customType: 'subagent-background', content: backgroundCompletionText(run), display: true }, { triggerTurn: true })
|
|
633
636
|
})
|
|
@@ -749,6 +752,10 @@ async function runParallelMode(tasks: TaskItemParam[], mode: ModeContext): Promi
|
|
|
749
752
|
cwd: t.cwd,
|
|
750
753
|
signal,
|
|
751
754
|
onPhase: mode.onPhase,
|
|
755
|
+
// Same context single and chain mode pass: without these, an agent's skills
|
|
756
|
+
// preload and its model tier alias silently do nothing in parallel mode only.
|
|
757
|
+
skillRoots: mode.skillRoots,
|
|
758
|
+
availableModels: mode.availableModels,
|
|
752
759
|
// Per-task update callback
|
|
753
760
|
onUpdate: (partial) => {
|
|
754
761
|
const live = partial.details?.results[0]
|
|
@@ -1102,6 +1109,7 @@ function renderParallelResult(results: SingleResult[], expanded: boolean, theme:
|
|
|
1102
1109
|
|
|
1103
1110
|
export default function subagentExtension(pi: ExtensionAPI) {
|
|
1104
1111
|
const notifyBackgroundCompletion = (run: { id: string; agent: string; state: string; turns: number; output?: string }): void => {
|
|
1112
|
+
// Runs through driveRun's guard, same as the background-mode callback above.
|
|
1105
1113
|
pi.sendMessage({ customType: 'subagent-background', content: backgroundCompletionText(run), display: true }, { triggerTurn: true })
|
|
1106
1114
|
}
|
|
1107
1115
|
|
package/extensions/todo.ts
CHANGED
|
@@ -335,8 +335,11 @@ export default function todoExtension(pi: ExtensionAPI) {
|
|
|
335
335
|
const msg = entry.message
|
|
336
336
|
if (msg.role !== 'toolResult' || msg.toolName !== TOOL_NAME) continue
|
|
337
337
|
|
|
338
|
-
const details = msg.details as (Omit<TodoDetails, 'todos'> & { todos
|
|
339
|
-
|
|
338
|
+
const details = msg.details as (Omit<TodoDetails, 'todos'> & { todos?: LegacyTodo[] }) | undefined
|
|
339
|
+
// pi persists a failed tool call as `details: {}`, which is truthy: a rejected
|
|
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
|
+
if (Array.isArray(details?.todos)) {
|
|
340
343
|
replayTodos = details.todos.map(normalizeTodo)
|
|
341
344
|
replayNextId = details.nextId
|
|
342
345
|
}
|
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",
|