pi-code 1.0.1 → 1.0.2
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 +20 -11
- package/extensions/internal/command-file.ts +26 -8
- package/extensions/internal/project-approval.ts +2 -0
- package/extensions/mcp.ts +17 -10
- package/extensions/plan-mode/README.md +3 -3
- package/extensions/status-line.ts +11 -1
- package/extensions/subagent/README.md +1 -1
- package/extensions/subagent/agents.ts +22 -12
- package/extensions/subagent/background.ts +28 -1
- package/extensions/subagent/index.ts +18 -3
- 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` |
|
|
@@ -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,16 @@ 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
|
-
|
|
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
|
+
pendingRestore = saved
|
|
78
91
|
pi.setActiveTools(parsed.allowedTools.filter((tool) => saved.includes(tool)))
|
|
79
92
|
}
|
|
80
|
-
|
|
81
|
-
pi.sendUserMessage(expanded)
|
|
82
|
-
} finally {
|
|
83
|
-
if (saved) pi.setActiveTools(saved)
|
|
84
|
-
}
|
|
93
|
+
pi.sendUserMessage(expanded)
|
|
85
94
|
}
|
|
86
95
|
|
|
87
96
|
pi.on('session_start', async (_event, ctx) => {
|
|
@@ -27,9 +27,23 @@ export interface DiscoveredCommand {
|
|
|
27
27
|
filePath: string
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
/** Claude tool names are PascalCase
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
/** Claude tool names are PascalCase and do not all exist in pi: `Glob` is pi's
|
|
31
|
+
* `find`. Lowercasing alone left `glob` in the list, and since pi has no tool by
|
|
32
|
+
* that name the grant was silently dropped when the list was intersected with the
|
|
33
|
+
* active tools. Shared with the subagent's own frontmatter parsing. */
|
|
34
|
+
const CLAUDE_TOOL_MAP: Record<string, string> = {
|
|
35
|
+
read: 'read',
|
|
36
|
+
write: 'write',
|
|
37
|
+
edit: 'edit',
|
|
38
|
+
bash: 'bash',
|
|
39
|
+
grep: 'grep',
|
|
40
|
+
glob: 'find',
|
|
41
|
+
ls: 'ls',
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function normalizeToolName(name: string): string {
|
|
45
|
+
const lower = name.trim().toLowerCase()
|
|
46
|
+
return CLAUDE_TOOL_MAP[lower] ?? lower
|
|
33
47
|
}
|
|
34
48
|
|
|
35
49
|
function field(frontmatter: string, key: string): string {
|
|
@@ -128,12 +142,16 @@ const inRanges = (ranges: Array<[number, number]>, index: number): boolean => ra
|
|
|
128
142
|
/** Read a `@path` reference, confined to the working directory. Returns undefined
|
|
129
143
|
* when the path escapes it or cannot be read, so the reference stays literal. */
|
|
130
144
|
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
145
|
try {
|
|
135
|
-
|
|
136
|
-
|
|
146
|
+
// Both sides canonicalised: on macOS /var is itself a symlink, so comparing a
|
|
147
|
+
// resolved path against an unresolved root rejects every legitimate read.
|
|
148
|
+
const root = fs.realpathSync(cwd)
|
|
149
|
+
// Confinement is checked after symlinks resolve: a lexical check passes a link
|
|
150
|
+
// that points outside the project, and the read would follow it.
|
|
151
|
+
const real = fs.realpathSync(path.resolve(cwd, reference))
|
|
152
|
+
if (real !== root && !real.startsWith(root + path.sep)) return undefined
|
|
153
|
+
if (!fs.statSync(real).isFile()) return undefined
|
|
154
|
+
return fs.readFileSync(real, 'utf-8')
|
|
137
155
|
} catch {
|
|
138
156
|
return undefined
|
|
139
157
|
}
|
|
@@ -29,6 +29,8 @@ 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'),
|
package/extensions/mcp.ts
CHANGED
|
@@ -32,7 +32,7 @@ import { ToolListChangedNotificationSchema } from '@modelcontextprotocol/sdk/typ
|
|
|
32
32
|
import { Type } from 'typebox'
|
|
33
33
|
import { MCP_TOOLS_CHANNEL, type McpToolAlias } from './internal/mcp-alias.js'
|
|
34
34
|
import { capForContext } from './internal/output-guard.js'
|
|
35
|
-
import { isProjectApproved } from './internal/project-approval.js'
|
|
35
|
+
import { isProjectApproved, isProjectApprovedSilently } from './internal/project-approval.js'
|
|
36
36
|
|
|
37
37
|
const DEFAULT_CONNECT_TIMEOUT_MS = 10_000
|
|
38
38
|
const DEFAULT_CALL_TIMEOUT_MS = 120_000
|
|
@@ -103,13 +103,18 @@ export interface ProjectServerPolicy {
|
|
|
103
103
|
consentAll: boolean
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
-
/** Claude's per-server approvals for project .mcp.json servers.
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
|
|
106
|
+
/** Claude's per-server approvals for project .mcp.json servers.
|
|
107
|
+
*
|
|
108
|
+
* Consent-granting keys (enabledMcpjsonServers, enableAllProjectMcpServers) count
|
|
109
|
+
* from the user's own settings always, and from the project's settings.local.json
|
|
110
|
+
* only once the project itself is approved. That file is gitignored by convention,
|
|
111
|
+
* not by enforcement: a repository can commit one, and honoring it unconditionally
|
|
112
|
+
* let a hostile repo self-approve a server whose `command` runs on connect, even
|
|
113
|
+
* after the user declined the trust prompt.
|
|
114
|
+
*
|
|
115
|
+
* disabledMcpjsonServers counts from every file, including the repo's own, and wins
|
|
116
|
+
* over consent: a repo may always restrict itself further, never less. */
|
|
117
|
+
export function projectServerPolicy(cwd: string, home: string, projectApproved: boolean): ProjectServerPolicy {
|
|
113
118
|
const read = (file: string): Record<string, unknown> => {
|
|
114
119
|
try {
|
|
115
120
|
return JSON.parse(fs.readFileSync(file, 'utf-8'))
|
|
@@ -122,7 +127,7 @@ export function projectServerPolicy(cwd: string, home: string): ProjectServerPol
|
|
|
122
127
|
const projectSettings = read(path.join(cwd, '.claude', 'settings.json'))
|
|
123
128
|
const localSettings = read(path.join(cwd, '.claude', 'settings.local.json'))
|
|
124
129
|
const disabled = new Set([...names(userSettings.disabledMcpjsonServers), ...names(projectSettings.disabledMcpjsonServers), ...names(localSettings.disabledMcpjsonServers)])
|
|
125
|
-
const consentSources = [userSettings, localSettings]
|
|
130
|
+
const consentSources = projectApproved ? [userSettings, localSettings] : [userSettings]
|
|
126
131
|
const consented = new Set(consentSources.flatMap((settings) => names(settings.enabledMcpjsonServers)))
|
|
127
132
|
const consentAll = consentSources.some((settings) => settings.enableAllProjectMcpServers === true)
|
|
128
133
|
return { disabled, consented, consentAll }
|
|
@@ -431,7 +436,9 @@ export default async function mcpExtension(pi: ExtensionAPI) {
|
|
|
431
436
|
/** Connect the project scope under the per-server policy. Returns whether the scope
|
|
432
437
|
* is settled, so a refused confirm can be retried on a later session start. */
|
|
433
438
|
async function connectProjectScope(ctx: ExtensionContext): Promise<boolean> {
|
|
434
|
-
|
|
439
|
+
// The stored decision, read without prompting: consent recorded inside the
|
|
440
|
+
// project only counts once the project itself has been approved.
|
|
441
|
+
const policy = projectServerPolicy(ctx.cwd, os.homedir(), isProjectApprovedSilently(ctx))
|
|
435
442
|
const { consented, gated } = splitByPolicy(loadConfigFrom(projectConfigPaths(ctx.cwd)), policy)
|
|
436
443
|
if (Object.keys(consented).length > 0) await connectServers(consented)
|
|
437
444
|
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:
|
|
@@ -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,7 +5,7 @@
|
|
|
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'
|
|
8
|
+
import { getAgentDir, parseFrontmatter, stripFrontmatter } from '@earendil-works/pi-coding-agent'
|
|
9
9
|
|
|
10
10
|
// Claude Code tool names -> pi tool names; unmapped names pass through lowercased
|
|
11
11
|
const CLAUDE_TOOL_MAP: Record<string, string> = {
|
|
@@ -110,21 +110,31 @@ export function withPreloadedSkills(prompt: string, skills: string[] | undefined
|
|
|
110
110
|
* prompt sent to the model, so a traversal would be an arbitrary-file read. */
|
|
111
111
|
const SKILL_NAME = /^[A-Za-z0-9_.-]+$/
|
|
112
112
|
|
|
113
|
+
/** Read one candidate file, but only if it really sits under `root` once symlinks
|
|
114
|
+
* are resolved. Both sides are canonicalised: on macOS /var is itself a symlink, so
|
|
115
|
+
* comparing a resolved path against an unresolved root rejects every valid read. */
|
|
116
|
+
function readConfined(candidate: string, root: string): string | undefined {
|
|
117
|
+
try {
|
|
118
|
+
const real = fs.realpathSync(candidate)
|
|
119
|
+
if (real !== root && !real.startsWith(root + path.sep)) return undefined
|
|
120
|
+
return stripFrontmatter(fs.readFileSync(real, 'utf-8'))
|
|
121
|
+
} catch {
|
|
122
|
+
return undefined
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
113
126
|
function readSkillBody(name: string, skillDirs: string[]): string | undefined {
|
|
114
127
|
if (!SKILL_NAME.test(name) || name === '.' || name === '..') return undefined
|
|
115
128
|
for (const dir of skillDirs) {
|
|
116
|
-
|
|
129
|
+
let root: string
|
|
130
|
+
try {
|
|
131
|
+
root = fs.realpathSync(dir)
|
|
132
|
+
} catch {
|
|
133
|
+
continue // a skills directory that does not exist simply contributes nothing
|
|
134
|
+
}
|
|
117
135
|
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
|
-
}
|
|
136
|
+
const body = readConfined(candidate, root)
|
|
137
|
+
if (body !== undefined) return body
|
|
128
138
|
}
|
|
129
139
|
}
|
|
130
140
|
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
|
|
@@ -626,10 +626,17 @@ 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
631
|
pi.events.emit(SUBAGENT_CHANNEL, { phase: 'stop', agentType: run.agent, agentId: run.id })
|
|
632
|
-
|
|
632
|
+
// The run outlives the session that started it, and every pi call throws once
|
|
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
|
+
}
|
|
633
640
|
})
|
|
634
641
|
if (id === null) {
|
|
635
642
|
// Lost the cap race to a parallel batch: the atomic check inside startBackgroundRun refused.
|
|
@@ -749,6 +756,10 @@ async function runParallelMode(tasks: TaskItemParam[], mode: ModeContext): Promi
|
|
|
749
756
|
cwd: t.cwd,
|
|
750
757
|
signal,
|
|
751
758
|
onPhase: mode.onPhase,
|
|
759
|
+
// Same context single and chain mode pass: without these, an agent's skills
|
|
760
|
+
// preload and its model tier alias silently do nothing in parallel mode only.
|
|
761
|
+
skillRoots: mode.skillRoots,
|
|
762
|
+
availableModels: mode.availableModels,
|
|
752
763
|
// Per-task update callback
|
|
753
764
|
onUpdate: (partial) => {
|
|
754
765
|
const live = partial.details?.results[0]
|
|
@@ -1102,7 +1113,11 @@ function renderParallelResult(results: SingleResult[], expanded: boolean, theme:
|
|
|
1102
1113
|
|
|
1103
1114
|
export default function subagentExtension(pi: ExtensionAPI) {
|
|
1104
1115
|
const notifyBackgroundCompletion = (run: { id: string; agent: string; state: string; turns: number; output?: string }): void => {
|
|
1105
|
-
|
|
1116
|
+
try {
|
|
1117
|
+
pi.sendMessage({ customType: 'subagent-background', content: backgroundCompletionText(run), display: true }, { triggerTurn: true })
|
|
1118
|
+
} catch {
|
|
1119
|
+
// same as above: the session that started the run may already be gone
|
|
1120
|
+
}
|
|
1106
1121
|
}
|
|
1107
1122
|
|
|
1108
1123
|
// Claude surfaces each agent's description so the model can pick one autonomously.
|
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 todo call would otherwise throw here and break replay for the rest
|
|
341
|
+
// of 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.2",
|
|
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",
|