pi-code 1.0.54 → 1.0.56
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 +2 -2
- package/extensions/commands.ts +11 -3
- package/extensions/hooks/config.ts +4 -1
- package/extensions/hooks/matcher.ts +4 -2
- package/extensions/hooks/runners.ts +7 -2
- package/extensions/internal/plugins.ts +11 -2
- package/extensions/mcp/transport.ts +13 -1
- package/extensions/memory.ts +10 -3
- package/extensions/subagent/README.md +3 -3
- package/extensions/subagent/agents.ts +5 -2
- package/extensions/subagent/background.ts +7 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
Claude Code experience for the [pi](https://pi.dev) coding agent, in one package. Point pi at a project that already has a `.claude/` directory and it reads your existing config: rules, commands, skills, hooks, output styles, MCP servers, and agents. It also adds the Claude Code features pi lacks: a todo overlay, checkpoints, memory, web search, subagents, and goals.
|
|
15
15
|
|
|
16
|
-
What a repository ships is treated as untrusted until you approve it: project MCP servers, hooks, agents, rules, output styles, commands and skills load only once you say yes.
|
|
16
|
+
What a repository ships is treated as untrusted until you approve it: project MCP servers, hooks, agents, rules, output styles, commands and skills load only once you say yes. A headless run (`pi -p`) cannot ask, so an undecided project loads none of them there, which is stricter than Claude, where a headless run uses them without showing the dialog.
|
|
17
17
|
|
|
18
18
|

|
|
19
19
|
|
|
@@ -45,7 +45,7 @@ Each topic links to its own doc with the full contract and any divergences from
|
|
|
45
45
|
- **[MCP servers](docs/mcp.md)** — every Claude config scope, all four transports, OAuth, managed policy, timeouts, prompts, and resources.
|
|
46
46
|
- **[Custom slash commands](docs/commands.md)** — `.claude/commands` with arguments, bash spans, `@file` inlining, frontmatter, and model invocation.
|
|
47
47
|
- **[Skills](docs/skills.md)** — `.claude/skills` discovery plus the same dynamic content commands get.
|
|
48
|
-
- **[Subagents
|
|
48
|
+
- **[Subagents](docs/subagents.md)** — built-in and custom agents, background runs, per-agent memory, worktree isolation.
|
|
49
49
|
- **[CLAUDE.md, @imports, and rules](docs/claude-md.md)** — the context files and path-scoped rules pi does not load natively.
|
|
50
50
|
- **[Settings `env`](docs/settings-env.md)** — env blocks from every settings scope, exported with Claude's precedence.
|
|
51
51
|
- **[Output styles](docs/output-styles.md)** — replace semantics, bundled built-ins, `/output-style`.
|
package/extensions/commands.ts
CHANGED
|
@@ -325,7 +325,11 @@ export default function commandsExtension(pi: ExtensionAPI) {
|
|
|
325
325
|
// escape as unhandled; surface it as a no-op instead of leaving the session silently
|
|
326
326
|
// on the command's override model.
|
|
327
327
|
set: (model) => {
|
|
328
|
-
|
|
328
|
+
// A refused switch leaves the turn on the session model rather than the one the
|
|
329
|
+
// command named, and the reply gives no sign of it, so the refusal is reported.
|
|
330
|
+
void pi.setModel(model as Parameters<typeof pi.setModel>[0]).catch((error: unknown) => {
|
|
331
|
+
console.warn(`pi-code-commands: could not switch to ${typeof model === 'object' && model !== null && 'id' in model ? String((model as { id: unknown }).id) : String(model)}: ${error instanceof Error ? error.message : String(error)}`)
|
|
332
|
+
})
|
|
329
333
|
},
|
|
330
334
|
})
|
|
331
335
|
/** The thinking level to restore after a command's `effort:` override drove its run,
|
|
@@ -505,8 +509,12 @@ export default function commandsExtension(pi: ExtensionAPI) {
|
|
|
505
509
|
let parsed: ParsedCommand
|
|
506
510
|
try {
|
|
507
511
|
parsed = parseCommandFile(fs.readFileSync(command.filePath, 'utf-8'))
|
|
508
|
-
} catch {
|
|
509
|
-
|
|
512
|
+
} catch (error) {
|
|
513
|
+
// An unreadable file must not take down session start, but the command is then
|
|
514
|
+
// absent from /help and unresolvable by the model, which looks like one that was
|
|
515
|
+
// never written.
|
|
516
|
+
console.warn(`pi-code-commands: ignoring ${command.filePath}: ${error instanceof Error ? error.message : String(error)}`)
|
|
517
|
+
continue
|
|
510
518
|
}
|
|
511
519
|
discovered.set(command.name, command)
|
|
512
520
|
// A user-only command stays off the tool description; it is still in the
|
|
@@ -200,7 +200,10 @@ function mergeHooksJson(config: HooksConfig, raw: string, source: string, source
|
|
|
200
200
|
let parsed: { hooks?: HooksConfig }
|
|
201
201
|
try {
|
|
202
202
|
parsed = JSON.parse(raw)
|
|
203
|
-
} catch {
|
|
203
|
+
} catch (error) {
|
|
204
|
+
// Every hook this source declares is now absent, a policy hook among them, so the
|
|
205
|
+
// failure is named rather than left to look like a file with no hooks in it.
|
|
206
|
+
console.warn(`pi-code-hooks: ignoring the hooks in ${source}: ${error instanceof Error ? error.message : String(error)}`)
|
|
204
207
|
return
|
|
205
208
|
}
|
|
206
209
|
for (const [event, matchers] of Object.entries(parsed?.hooks ?? {})) {
|
|
@@ -59,8 +59,10 @@ function compileMatcher(matcher: string): CompiledMatcher {
|
|
|
59
59
|
} else {
|
|
60
60
|
try {
|
|
61
61
|
compiled = { regex: new RegExp(matcher, 'i') }
|
|
62
|
-
} catch {
|
|
63
|
-
//
|
|
62
|
+
} catch (error) {
|
|
63
|
+
// The fallback matches the literal text, which almost never matches a tool name, so
|
|
64
|
+
// the hook simply never fires. Say so: the matcher reads as merely wrong otherwise.
|
|
65
|
+
console.warn(`pi-code-hooks: matcher ${matcher} is not a valid regular expression (${error instanceof Error ? error.message : String(error)}); it will only match a tool of that exact name`)
|
|
64
66
|
compiled = { tokens: exactTokens(matcher) }
|
|
65
67
|
}
|
|
66
68
|
}
|
|
@@ -224,7 +224,11 @@ export async function runHttpHook(hook: { type?: string; command: string; url?:
|
|
|
224
224
|
}
|
|
225
225
|
return { code: 0, stdout: body, stderr: '', timedOut: false }
|
|
226
226
|
} catch (error) {
|
|
227
|
-
|
|
227
|
+
// A timeout is the abort that AbortSignal.timeout raises. Mark it as one so a gated
|
|
228
|
+
// event fails closed on it, exactly as a command hook that ran out of time does: the
|
|
229
|
+
// hook never answered, whichever transport it used.
|
|
230
|
+
const name = error instanceof Error ? error.name : ''
|
|
231
|
+
return { code: 1, stdout: '', stderr: error instanceof Error ? error.message : String(error), timedOut: name === 'TimeoutError' || name === 'AbortError' }
|
|
228
232
|
}
|
|
229
233
|
}
|
|
230
234
|
|
|
@@ -319,7 +323,8 @@ export async function runMcpToolHook(hook: HookCommand, payload: unknown, timeou
|
|
|
319
323
|
const input = hook.input && typeof hook.input === 'object' ? (substituteInputPaths(hook.input, payload) as Record<string, unknown>) : {}
|
|
320
324
|
let timer: ReturnType<typeof setTimeout> | undefined
|
|
321
325
|
const deadline = new Promise<HookRunResult>((resolve) => {
|
|
322
|
-
|
|
326
|
+
// Marked as a timeout so a gated event fails closed on it, like a command hook.
|
|
327
|
+
timer = setTimeout(() => resolve({ code: 1, stdout: '', stderr: `mcp_tool hook timed out after ${timeoutMs}ms`, timedOut: true }), timeoutMs)
|
|
323
328
|
})
|
|
324
329
|
const call = callMcpTool(hook.server, hook.tool, input)
|
|
325
330
|
.then((result): HookRunResult => ({ code: result.isError ? 1 : 0, stdout: result.text, stderr: '', timedOut: false }))
|
|
@@ -31,10 +31,19 @@ export interface InstalledPlugin {
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
function readJson(file: string): Record<string, unknown> {
|
|
34
|
+
let raw: string
|
|
34
35
|
try {
|
|
35
|
-
|
|
36
|
-
return parsed !== null && typeof parsed === 'object' ? parsed : {}
|
|
36
|
+
raw = fs.readFileSync(file, 'utf-8')
|
|
37
37
|
} catch {
|
|
38
|
+
return {} // no such file: nothing to read, and most callers expect that
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
const parsed = JSON.parse(raw)
|
|
42
|
+
return parsed !== null && typeof parsed === 'object' ? parsed : {}
|
|
43
|
+
} catch (error) {
|
|
44
|
+
// A manifest that does not parse leaves the plugin with no components at all, and
|
|
45
|
+
// settings that do not parse drop the enablement or configuration they carried.
|
|
46
|
+
console.warn(`pi-code-plugins: ignoring ${file}: ${error instanceof Error ? error.message : String(error)}`)
|
|
38
47
|
return {}
|
|
39
48
|
}
|
|
40
49
|
}
|
|
@@ -345,8 +345,20 @@ export function runHeadersHelper(command: string, env: NodeJS.ProcessEnv, resolv
|
|
|
345
345
|
resolve({})
|
|
346
346
|
return
|
|
347
347
|
}
|
|
348
|
+
const server = env.CLAUDE_CODE_MCP_SERVER_NAME ?? 'the server'
|
|
348
349
|
execFile(shell.file, shell.argsFor(command), { timeout: 10_000, env }, (error, stdout) => {
|
|
349
|
-
|
|
350
|
+
if (error) {
|
|
351
|
+
// The connect proceeds unauthenticated and the server answers 401, which reads as
|
|
352
|
+
// a login problem rather than a helper that never produced a header.
|
|
353
|
+
console.warn(`pi-code-mcp: the headersHelper for ${server} failed: ${error.message}; connecting without the headers it would have supplied`)
|
|
354
|
+
resolve({})
|
|
355
|
+
return
|
|
356
|
+
}
|
|
357
|
+
const headers = parseHelperHeaders(stdout)
|
|
358
|
+
if (Object.keys(headers).length === 0 && stdout.trim().length > 0) {
|
|
359
|
+
console.warn(`pi-code-mcp: the headersHelper for ${server} produced no usable headers; it must print a JSON object of header names to string values`)
|
|
360
|
+
}
|
|
361
|
+
resolve(headers)
|
|
350
362
|
})
|
|
351
363
|
})
|
|
352
364
|
}
|
package/extensions/memory.ts
CHANGED
|
@@ -149,8 +149,10 @@ export function migrateLegacyStore(cwd: string): void {
|
|
|
149
149
|
if (legacy === current || !fs.existsSync(legacy)) continue
|
|
150
150
|
try {
|
|
151
151
|
fs.renameSync(legacy, current)
|
|
152
|
-
} catch {
|
|
153
|
-
// A failed migration must not take down session start
|
|
152
|
+
} catch (error) {
|
|
153
|
+
// A failed migration must not take down session start, but the session then has no
|
|
154
|
+
// memories while they sit under the old slug, which reads as having lost them.
|
|
155
|
+
console.warn(`pi-code-memory: could not move ${legacy} to ${current}: ${error instanceof Error ? error.message : String(error)}; this session starts without those memories`)
|
|
154
156
|
}
|
|
155
157
|
return
|
|
156
158
|
}
|
|
@@ -230,7 +232,12 @@ function readMemory(dir: string, name: string): MemoryToolResult {
|
|
|
230
232
|
try {
|
|
231
233
|
const body = fs.readFileSync(path.join(dir, `${name}.md`), 'utf-8')
|
|
232
234
|
return { content: [{ type: 'text', text: capForContext(body) }], details: {} }
|
|
233
|
-
} catch {
|
|
235
|
+
} catch (error) {
|
|
236
|
+
// Only a missing file is "no such memory"; anything else (a directory in its place, a
|
|
237
|
+
// permission problem) sends the model hunting for a name that is actually there.
|
|
238
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
239
|
+
return { content: [{ type: 'text', text: `Memory ${name} could not be read: ${error instanceof Error ? error.message : String(error)}` }], details: {} }
|
|
240
|
+
}
|
|
234
241
|
return { content: [{ type: 'text', text: `No memory named ${name}.` }], details: {} }
|
|
235
242
|
}
|
|
236
243
|
}
|
|
@@ -39,7 +39,7 @@ This tool executes a separate `pi` subprocess with a delegated system prompt and
|
|
|
39
39
|
|
|
40
40
|
**Default behavior:** Loads the bundled builtin agents (Explore, Plan, general-purpose) plus **user-level agents** from `~/.claude/agents` and `~/.pi/agent/agents`. A user or project agent with the same name overrides a builtin. Discovered agents and their descriptions are listed in the system prompt each turn, so the model can pick one itself; project agent descriptions appear only once the project is approved.
|
|
41
41
|
|
|
42
|
-
|
|
42
|
+
Project-local agents (`.claude/agents`, `.pi/agents`) load once the project is approved: a default call resolves `agentScope` to `"both"` for an approved project and `"user"` otherwise, and an explicit `agentScope` still narrows or widens it. Every invocation passes the project-agent gate either way.
|
|
43
43
|
|
|
44
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
|
|
|
@@ -134,9 +134,9 @@ enforces per call.
|
|
|
134
134
|
|
|
135
135
|
**Locations:**
|
|
136
136
|
- `~/.claude/agents/*.md`, `~/.pi/agent/agents/*.md` - User-level (always loaded; `~/.pi` wins a name conflict)
|
|
137
|
-
- `.claude/agents/*.md`, `.pi/agents/*.md` - Project-level (
|
|
137
|
+
- `.claude/agents/*.md`, `.pi/agents/*.md` - Project-level (an approved project, or an explicit `agentScope` of `"project"`/`"both"`; `.pi` wins a name conflict)
|
|
138
138
|
|
|
139
|
-
Project agents override user agents with the same name when
|
|
139
|
+
Project agents override user agents with the same name when both scopes are in play.
|
|
140
140
|
|
|
141
141
|
## Builtin Agents
|
|
142
142
|
|
|
@@ -177,8 +177,11 @@ function parseAgentFile(content: string, source: AgentSource, filePath: string,
|
|
|
177
177
|
let parsed: { frontmatter: Record<string, unknown>; body: string }
|
|
178
178
|
try {
|
|
179
179
|
parsed = parseFrontmatter<Record<string, unknown>>(content)
|
|
180
|
-
} catch {
|
|
181
|
-
|
|
180
|
+
} catch (error) {
|
|
181
|
+
// Malformed YAML must not abort discovery for the whole directory, but a silent drop
|
|
182
|
+
// reads as "that agent does not exist", so it is named like the other rejections here.
|
|
183
|
+
console.warn(`pi-code-subagent: ignoring agent ${filePath}: its frontmatter could not be parsed (${error instanceof Error ? error.message : String(error)})`)
|
|
184
|
+
return null
|
|
182
185
|
}
|
|
183
186
|
const { frontmatter, body } = parsed
|
|
184
187
|
const name = agentName(frontmatter, filePath, pluginName)
|
|
@@ -204,7 +204,7 @@ export function resumeBackgroundRun(id: string, task: string, onComplete: (run:
|
|
|
204
204
|
if (!fs.existsSync(run.spawn.cwd)) return 'cwd-gone'
|
|
205
205
|
// Persisted so the rebuild happens once: rebuilding per resume leaked one temp
|
|
206
206
|
// prompt dir every follow-up.
|
|
207
|
-
const rebuilt = withRebuiltPrompt(run.spawn)
|
|
207
|
+
const rebuilt = withRebuiltPrompt(run.spawn, run.agent)
|
|
208
208
|
run.spawn = { ...run.spawn, args: rebuilt.args }
|
|
209
209
|
if (rebuilt.dir) run.rebuiltPromptDir = rebuilt.dir
|
|
210
210
|
// The task prompt is always the final argument: both spawn paths push it last and the
|
|
@@ -228,7 +228,7 @@ export function resumeBackgroundRun(id: string, task: string, onComplete: (run:
|
|
|
228
228
|
|
|
229
229
|
/** Re-point --system-prompt at a fresh file when the original is gone; `dir` is the
|
|
230
230
|
* temp dir created for it, which the run then owns. */
|
|
231
|
-
function withRebuiltPrompt(spawnSpec: BackgroundSpawn): { args: string[]; dir?: string } {
|
|
231
|
+
function withRebuiltPrompt(spawnSpec: BackgroundSpawn, agent: string): { args: string[]; dir?: string } {
|
|
232
232
|
const flag = spawnSpec.args.indexOf('--system-prompt')
|
|
233
233
|
if (flag === -1 || !spawnSpec.promptBody) return { args: spawnSpec.args }
|
|
234
234
|
const current = spawnSpec.args[flag + 1]
|
|
@@ -240,9 +240,12 @@ function withRebuiltPrompt(spawnSpec: BackgroundSpawn): { args: string[]; dir?:
|
|
|
240
240
|
const rebuilt = [...spawnSpec.args]
|
|
241
241
|
rebuilt[flag + 1] = file
|
|
242
242
|
return { args: rebuilt, dir }
|
|
243
|
-
} catch {
|
|
243
|
+
} catch (error) {
|
|
244
244
|
// Cannot rewrite it: drop the pair rather than hand pi a path it will treat as
|
|
245
|
-
// prompt text, which would replace the agent persona with a temp path.
|
|
245
|
+
// prompt text, which would replace the agent persona with a temp path. The child then
|
|
246
|
+
// runs as a plain assistant instead of the agent asked for, and nothing in its output
|
|
247
|
+
// says so, hence the notice.
|
|
248
|
+
console.warn(`pi-code-subagent: resuming ${agent} without its agent prompt: ${error instanceof Error ? error.message : String(error)}`)
|
|
246
249
|
return { args: spawnSpec.args.filter((_arg, i) => i !== flag && i !== flag + 1) }
|
|
247
250
|
}
|
|
248
251
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-code",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.56",
|
|
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, subagents, and goals",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi",
|