pi-code 1.0.17 → 1.0.18
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.
|
@@ -123,7 +123,14 @@ Fields with no pi seam are ignored, each verified against pi's CLI rather than
|
|
|
123
123
|
assumed: `mcpServers` (a child reads MCP config from files, and writing config
|
|
124
124
|
into the workspace to fake it would be worse than the gap). `maxTurns` and
|
|
125
125
|
`memory` are honored (turn cap enforced at the turn boundary; per-agent memory
|
|
126
|
-
directories injected into the child's prompt).
|
|
126
|
+
directories injected into the child's prompt). `isolation: worktree` is honored:
|
|
127
|
+
the child runs in a temporary git worktree branched from the repository's default
|
|
128
|
+
branch, removed afterwards when the agent made no changes and reported in the
|
|
129
|
+
run's output when kept; a run that cannot get its worktree fails rather than
|
|
130
|
+
touching the real checkout, and an unrecognized `isolation` value rejects the
|
|
131
|
+
definition. Divergence: pi sets the child's working directory into the worktree
|
|
132
|
+
but does not police commands that navigate back out, which Claude additionally
|
|
133
|
+
enforces per call.
|
|
127
134
|
|
|
128
135
|
**Locations:**
|
|
129
136
|
- `~/.claude/agents/*.md`, `~/.pi/agent/agents/*.md` - User-level (always loaded; `~/.pi` wins a name conflict)
|
|
@@ -179,6 +179,13 @@ function parseAgentFile(content: string, source: AgentSource, filePath: string):
|
|
|
179
179
|
}
|
|
180
180
|
const disallowedTools = parseToolsField(frontmatter.disallowedTools, false)
|
|
181
181
|
if (disallowedTools === null) return null
|
|
182
|
+
const isolation = parseIsolationField(frontmatter.isolation)
|
|
183
|
+
if (isolation === null) {
|
|
184
|
+
// isolation is a declared safety boundary: an unrecognized value must reject
|
|
185
|
+
// the definition rather than run the agent against the real checkout.
|
|
186
|
+
console.warn(`pi-code-subagent: ignoring agent ${filePath}: isolation value ${JSON.stringify(frontmatter.isolation)} is not supported (only "worktree" is)`)
|
|
187
|
+
return null
|
|
188
|
+
}
|
|
182
189
|
return {
|
|
183
190
|
name,
|
|
184
191
|
description,
|
|
@@ -190,12 +197,22 @@ function parseAgentFile(content: string, source: AgentSource, filePath: string):
|
|
|
190
197
|
skills: parseSkillsField(frontmatter.skills),
|
|
191
198
|
memory: parseMemoryField(frontmatter.memory),
|
|
192
199
|
maxTurns: parseMaxTurns(frontmatter.maxTurns),
|
|
200
|
+
isolation,
|
|
193
201
|
systemPrompt: body,
|
|
194
202
|
source,
|
|
195
203
|
filePath,
|
|
196
204
|
}
|
|
197
205
|
}
|
|
198
206
|
|
|
207
|
+
/** Claude's `isolation:` field: `worktree` (case-insensitive) runs the child in a
|
|
208
|
+
* temporary git worktree. Absent is fine (undefined); any other value is null so
|
|
209
|
+
* the caller rejects the definition instead of silently dropping the boundary. */
|
|
210
|
+
function parseIsolationField(raw: unknown): 'worktree' | undefined | null {
|
|
211
|
+
if (raw === undefined) return undefined
|
|
212
|
+
if (typeof raw === 'string' && raw.trim().toLowerCase() === 'worktree') return 'worktree'
|
|
213
|
+
return null
|
|
214
|
+
}
|
|
215
|
+
|
|
199
216
|
/** Claude's `maxTurns`: a positive integer cap on the subagent's agentic turns.
|
|
200
217
|
* Anything else (0, negative, non-number) is ignored, so the run is uncapped. */
|
|
201
218
|
function parseMaxTurns(raw: unknown): number | undefined {
|
|
@@ -219,6 +236,8 @@ export interface AgentConfig {
|
|
|
219
236
|
memory?: AgentMemoryScope
|
|
220
237
|
/** Cap on the child's agentic turns, enforced by killing at the turn boundary. */
|
|
221
238
|
maxTurns?: number
|
|
239
|
+
/** Claude's `isolation: worktree`: run the child in a temporary git worktree. */
|
|
240
|
+
isolation?: 'worktree'
|
|
222
241
|
systemPrompt: string
|
|
223
242
|
source: AgentSource
|
|
224
243
|
filePath: string
|
|
@@ -166,12 +166,16 @@ export function backgroundRun(id: string): BackgroundRun | undefined {
|
|
|
166
166
|
/** Re-spawn a finished run's session with a new task. The child is started with the
|
|
167
167
|
* same --session-id, so it continues with everything it already saw rather than
|
|
168
168
|
* re-deriving context the parent would have to repeat. */
|
|
169
|
-
export function resumeBackgroundRun(id: string, task: string, onComplete: (run: BackgroundRun) => void): 'resumed' | 'still-running' | 'at-capacity' | 'unknown' {
|
|
169
|
+
export function resumeBackgroundRun(id: string, task: string, onComplete: (run: BackgroundRun) => void): 'resumed' | 'still-running' | 'at-capacity' | 'cwd-gone' | 'unknown' {
|
|
170
170
|
const run = runs.get(id)
|
|
171
171
|
if (!run) return 'unknown'
|
|
172
172
|
if (run.state === 'running' || run.live) return 'still-running'
|
|
173
173
|
// A resume spawns a child like a fresh start does, so it counts against the cap.
|
|
174
174
|
if (activeBackgroundRuns() >= MAX_BACKGROUND_RUNS) return 'at-capacity'
|
|
175
|
+
// A worktree-isolated run's directory is removed once the run ends without
|
|
176
|
+
// changes; a resume cannot re-enter it, and spawning in a missing cwd would
|
|
177
|
+
// only produce an opaque ENOENT.
|
|
178
|
+
if (!fs.existsSync(run.spawn.cwd)) return 'cwd-gone'
|
|
175
179
|
// Persisted so the rebuild happens once: rebuilding per resume leaked one temp
|
|
176
180
|
// prompt dir every follow-up.
|
|
177
181
|
const rebuilt = withRebuiltPrompt(run.spawn)
|
|
@@ -34,6 +34,7 @@ import { skillDirs } from '../skills.js'
|
|
|
34
34
|
import { type AgentConfig, type AgentMemoryScope, type AgentScope, type AgentSource, discoverAgents, resolveModelAlias, withPreloadedSkills } from './agents.js'
|
|
35
35
|
import { activeBackgroundRuns, type BackgroundRun, backgroundRun, backgroundStatusText, cancelAllBackgroundRuns, cancelBackgroundRun, MAX_BACKGROUND_RUNS, resumeBackgroundRun, startBackgroundRun } from './background.js'
|
|
36
36
|
import { type DisplayItem, formatToolCall, formatUsageStats, getDisplayItems, getFinalOutput } from './render.js'
|
|
37
|
+
import { type AgentWorktree, cleanupAgentWorktree, createAgentWorktree } from './worktree.js'
|
|
37
38
|
|
|
38
39
|
// Re-exported so the render formatters stay importable from the subagent entry point,
|
|
39
40
|
// where the tests and the tool itself have always reached for them.
|
|
@@ -158,6 +159,20 @@ interface RunAgentOptions {
|
|
|
158
159
|
/** Publishes a child run's start/stop for the hooks extension's SubagentStart/Stop. */
|
|
159
160
|
type SubagentPhaseSink = (phase: 'start' | 'stop', agentType: string, agentId: string) => void
|
|
160
161
|
|
|
162
|
+
/** Tell the parent where a kept worktree lives: appended to the final assistant
|
|
163
|
+
* message so it rides the run's normal output; stderr when there is none. */
|
|
164
|
+
function appendWorktreeNote(result: SingleResult, worktree: AgentWorktree): void {
|
|
165
|
+
const note = `[isolation: worktree kept at ${worktree.dir} (branch ${worktree.branch}); the agent's changes live there]`
|
|
166
|
+
for (let i = result.messages.length - 1; i >= 0; i--) {
|
|
167
|
+
const msg = result.messages[i]
|
|
168
|
+
if (msg.role === 'assistant') {
|
|
169
|
+
msg.content.push({ type: 'text', text: note })
|
|
170
|
+
return
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
result.stderr = result.stderr ? `${result.stderr}\n${note}` : note
|
|
174
|
+
}
|
|
175
|
+
|
|
161
176
|
async function runSingleAgent(options: RunAgentOptions): Promise<SingleResult> {
|
|
162
177
|
const agent = options.agents.find((a) => a.name === options.agentName)
|
|
163
178
|
if (!agent) return runSingleAgentInner(options)
|
|
@@ -189,6 +204,26 @@ async function runSingleAgentInner(options: RunAgentOptions): Promise<SingleResu
|
|
|
189
204
|
}
|
|
190
205
|
|
|
191
206
|
const runCwd = cwd ?? defaultCwd
|
|
207
|
+
// Claude's isolation: worktree gives the child an isolated copy of the repository.
|
|
208
|
+
// A boundary that cannot be created fails the run: running against the real
|
|
209
|
+
// checkout would silently drop the isolation the agent declared.
|
|
210
|
+
let worktree: AgentWorktree | undefined
|
|
211
|
+
if (agent.isolation === 'worktree') {
|
|
212
|
+
const created = await createAgentWorktree(runCwd, agent.name)
|
|
213
|
+
if ('error' in created) {
|
|
214
|
+
return {
|
|
215
|
+
agent: agentName,
|
|
216
|
+
agentSource: agent.source,
|
|
217
|
+
task,
|
|
218
|
+
exitCode: 1,
|
|
219
|
+
messages: [],
|
|
220
|
+
stderr: `isolation: worktree could not be created: ${created.error}`,
|
|
221
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
|
|
222
|
+
step,
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
worktree = created
|
|
226
|
+
}
|
|
192
227
|
// Project/local memory is anchored at the SESSION project (defaultCwd), not the
|
|
193
228
|
// model-supplied runCwd: projectApproved gates the session's repo, so anchoring the
|
|
194
229
|
// store on a different (possibly unapproved) cwd would inject that repo's memory as
|
|
@@ -238,7 +273,7 @@ async function runSingleAgentInner(options: RunAgentOptions): Promise<SingleResu
|
|
|
238
273
|
const exitCode = await new Promise<number>((resolve) => {
|
|
239
274
|
const invocation = getPiInvocation(args)
|
|
240
275
|
const proc = spawn(invocation.command, invocation.args, {
|
|
241
|
-
cwd: runCwd,
|
|
276
|
+
cwd: worktree?.dir ?? runCwd,
|
|
242
277
|
shell: false,
|
|
243
278
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
244
279
|
// Its own group, so an abort reaches grandchildren too: killing only the
|
|
@@ -337,6 +372,11 @@ async function runSingleAgentInner(options: RunAgentOptions): Promise<SingleResu
|
|
|
337
372
|
if (wasAborted) throw new Error('Subagent was aborted')
|
|
338
373
|
return currentResult
|
|
339
374
|
} finally {
|
|
375
|
+
// Cleanup runs on abort too: it only removes a pristine worktree, so an
|
|
376
|
+
// interrupted agent's changes always survive.
|
|
377
|
+
if (worktree && (await cleanupAgentWorktree(runCwd, worktree)) === 'kept') {
|
|
378
|
+
appendWorktreeNote(currentResult, worktree)
|
|
379
|
+
}
|
|
340
380
|
if (tmpPromptPath)
|
|
341
381
|
try {
|
|
342
382
|
fs.unlinkSync(tmpPromptPath)
|
|
@@ -430,6 +470,7 @@ export function resumeResultText(id: string, task: string | undefined, onComplet
|
|
|
430
470
|
}
|
|
431
471
|
if (outcome === 'still-running') return `Background run ${id} is still running; wait for it or cancel it first.`
|
|
432
472
|
if (outcome === 'at-capacity') return `Background run cap reached (${MAX_BACKGROUND_RUNS} concurrent); wait for a run to finish before resuming ${id}.`
|
|
473
|
+
if (outcome === 'cwd-gone') return `Background run ${id} ran in a working directory that no longer exists (an isolation worktree is cleaned up after an unchanged run); start a new run instead.`
|
|
433
474
|
return `Unknown background run: ${id}.\n\n${backgroundStatusText()}`
|
|
434
475
|
}
|
|
435
476
|
|
|
@@ -708,6 +749,18 @@ async function runBackgroundMode(params: SubagentParamsStatic, context: Backgrou
|
|
|
708
749
|
return backgroundCapResult(makeDetails)
|
|
709
750
|
}
|
|
710
751
|
const runCwd = params.cwd ?? defaultCwd
|
|
752
|
+
// The same isolation boundary as the foreground path: no worktree, no run.
|
|
753
|
+
let worktree: AgentWorktree | undefined
|
|
754
|
+
if (agent.isolation === 'worktree') {
|
|
755
|
+
const created = await createAgentWorktree(runCwd, agent.name)
|
|
756
|
+
if ('error' in created) {
|
|
757
|
+
return {
|
|
758
|
+
content: [{ type: 'text', text: `isolation: worktree could not be created for ${agent.name}: ${created.error}` }],
|
|
759
|
+
details: makeDetails('single')([]),
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
worktree = created
|
|
763
|
+
}
|
|
711
764
|
// Anchor project/local memory at the session project (defaultCwd), which is the one
|
|
712
765
|
// projectApproved gated; see the foreground path for why runCwd must not be used.
|
|
713
766
|
const memorySection = agentMemoryPromptSection(agent, defaultCwd, projectApproved)
|
|
@@ -720,13 +773,32 @@ async function runBackgroundMode(params: SubagentParamsStatic, context: Backgrou
|
|
|
720
773
|
}
|
|
721
774
|
args.push(`Task: ${task}`)
|
|
722
775
|
const invocation = getPiInvocation(args)
|
|
723
|
-
const id = startBackgroundRun(agent.name, task, { command: invocation.command, args: invocation.args, cwd: runCwd, promptBody: tmpPrompt ? promptBody : undefined, maxTurns: agent.maxTurns }, (run) => {
|
|
776
|
+
const id = startBackgroundRun(agent.name, task, { command: invocation.command, args: invocation.args, cwd: worktree?.dir ?? runCwd, promptBody: tmpPrompt ? promptBody : undefined, maxTurns: agent.maxTurns }, (run) => {
|
|
724
777
|
removeTmpPrompt(tmpPrompt)
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
778
|
+
const finish = (): void => {
|
|
779
|
+
// Both calls throw once the session that started the run is disposed. driveRun's
|
|
780
|
+
// catch covers the synchronous path, but the worktree branch reaches here from an
|
|
781
|
+
// async continuation outside it, so the guard must live in finish itself.
|
|
782
|
+
try {
|
|
783
|
+
pi.events.emit(SUBAGENT_CHANNEL, { phase: 'stop', agentType: run.agent, agentId: run.id })
|
|
784
|
+
pi.sendMessage({ customType: 'subagent-background', content: backgroundCompletionText(run), display: true }, { triggerTurn: true })
|
|
785
|
+
} catch {
|
|
786
|
+
// Session disposed after the run outlived it; nothing to notify.
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
if (!worktree) {
|
|
790
|
+
finish()
|
|
791
|
+
return
|
|
792
|
+
}
|
|
793
|
+
// Cleanup only removes a pristine worktree; a kept one is reported in the
|
|
794
|
+
// completion text so the parent knows where the changes live.
|
|
795
|
+
const keptWorktree = worktree
|
|
796
|
+
void cleanupAgentWorktree(runCwd, keptWorktree)
|
|
797
|
+
.then((outcome) => {
|
|
798
|
+
if (outcome === 'kept') run.output = `${run.output ?? ''}\n[isolation: worktree kept at ${keptWorktree.dir} (branch ${keptWorktree.branch}); the agent's changes live there]`.trim()
|
|
799
|
+
})
|
|
800
|
+
.catch(() => {})
|
|
801
|
+
.finally(finish)
|
|
730
802
|
})
|
|
731
803
|
if (id === null) {
|
|
732
804
|
// Lost the cap race to a parallel batch: the atomic check inside startBackgroundRun refused.
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude's subagent `isolation: worktree`: a temporary git worktree giving the
|
|
3
|
+
* child an isolated copy of the repository, branched from the repository's default
|
|
4
|
+
* branch (origin/HEAD, falling back to main/master, then the current HEAD) rather
|
|
5
|
+
* than the parent session's HEAD, and automatically cleaned up when the subagent
|
|
6
|
+
* makes no changes. Divergence, documented in the subagent README: pi sets the
|
|
7
|
+
* child's working directory into the worktree but does not police commands that
|
|
8
|
+
* navigate back out, which Claude additionally enforces per call.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { execFile } from 'node:child_process'
|
|
12
|
+
import { randomUUID } from 'node:crypto'
|
|
13
|
+
import * as os from 'node:os'
|
|
14
|
+
import * as path from 'node:path'
|
|
15
|
+
import { promisify } from 'node:util'
|
|
16
|
+
|
|
17
|
+
const git = async (cwd: string, ...args: string[]): Promise<string> => {
|
|
18
|
+
const { stdout } = await promisify(execFile)('git', args, { cwd })
|
|
19
|
+
return stdout.trim()
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface AgentWorktree {
|
|
23
|
+
dir: string
|
|
24
|
+
branch: string
|
|
25
|
+
/** The commit the worktree started from; unchanged HEAD plus a clean tree means
|
|
26
|
+
* the agent made no changes and the worktree can go. */
|
|
27
|
+
baseSha: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function defaultBranch(repoCwd: string): Promise<string> {
|
|
31
|
+
try {
|
|
32
|
+
return await git(repoCwd, 'symbolic-ref', '--short', 'refs/remotes/origin/HEAD')
|
|
33
|
+
} catch {
|
|
34
|
+
// No origin/HEAD (local-only repo, or never fetched): try the conventional names.
|
|
35
|
+
}
|
|
36
|
+
for (const name of ['main', 'master']) {
|
|
37
|
+
try {
|
|
38
|
+
await git(repoCwd, 'show-ref', '--verify', `refs/heads/${name}`)
|
|
39
|
+
return name
|
|
40
|
+
} catch {
|
|
41
|
+
// Not this one.
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return 'HEAD'
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Create the temporary worktree, or explain why it cannot exist (not a git
|
|
48
|
+
* repository, git failure): the caller must fail the run rather than silently
|
|
49
|
+
* dropping the isolation boundary the agent declared. */
|
|
50
|
+
export async function createAgentWorktree(repoCwd: string, agentName: string): Promise<AgentWorktree | { error: string }> {
|
|
51
|
+
try {
|
|
52
|
+
await git(repoCwd, 'rev-parse', '--is-inside-work-tree')
|
|
53
|
+
} catch {
|
|
54
|
+
return { error: `${repoCwd} is not a git repository` }
|
|
55
|
+
}
|
|
56
|
+
const suffix = randomUUID().slice(0, 8)
|
|
57
|
+
const safeName = agentName.replace(/[^A-Za-z0-9_-]+/g, '-')
|
|
58
|
+
const dir = path.join(os.tmpdir(), `pi-agent-worktree-${safeName}-${suffix}`)
|
|
59
|
+
const branch = `agent/${safeName}-${suffix}`
|
|
60
|
+
try {
|
|
61
|
+
await git(repoCwd, 'worktree', 'add', '-b', branch, dir, await defaultBranch(repoCwd))
|
|
62
|
+
return { dir, branch, baseSha: await git(dir, 'rev-parse', 'HEAD') }
|
|
63
|
+
} catch (error) {
|
|
64
|
+
return { error: error instanceof Error ? error.message : String(error) }
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Remove the worktree and its branch when the agent made no changes (clean tree,
|
|
69
|
+
* HEAD still at the base), as Claude documents; keep both otherwise so the changes
|
|
70
|
+
* survive for the parent to inspect. A cleanup that fails keeps the worktree:
|
|
71
|
+
* losing work is the only unacceptable outcome here. */
|
|
72
|
+
export async function cleanupAgentWorktree(repoCwd: string, worktree: AgentWorktree): Promise<'removed' | 'kept'> {
|
|
73
|
+
try {
|
|
74
|
+
const status = await git(worktree.dir, 'status', '--porcelain')
|
|
75
|
+
const head = await git(worktree.dir, 'rev-parse', 'HEAD')
|
|
76
|
+
if (status.length > 0 || head !== worktree.baseSha) return 'kept'
|
|
77
|
+
await git(repoCwd, 'worktree', 'remove', worktree.dir)
|
|
78
|
+
await git(repoCwd, 'branch', '-D', worktree.branch)
|
|
79
|
+
return 'removed'
|
|
80
|
+
} catch {
|
|
81
|
+
return 'kept'
|
|
82
|
+
}
|
|
83
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-code",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.18",
|
|
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",
|