pi-code 1.0.17 → 1.0.19

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 CHANGED
@@ -44,7 +44,7 @@ One `pi install` and everything below loads on the next start. `pi list` shows w
44
44
  | Global + project rules | `~/.claude/rules`, `.claude/rules` (nearest at or above cwd); unscoped rules inlined in full, `paths:`-scoped rules surfaced as pointers and auto-attached (the rule body is appended to a read/edit/write result when a matching file is touched, once per rule per session) | `claude-rules.ts` |
45
45
  | Custom slash commands | `.claude/commands/**/*.md` (namespaced `/dir:name`); `$ARGUMENTS` (with the `ARGUMENTS:` append when unused), 0-based `$ARGUMENTS[N]`/`$N`, named `arguments:` frontmatter, `${CLAUDE_SESSION_ID}`/`${CLAUDE_EFFORT}`/`${CLAUDE_SKILL_DIR}`/`${CLAUDE_PROJECT_DIR}` (in bodies and `allowed-tools` rules); `` !`cmd` `` and multi-line ```` ```! ```` bash (whitespace-bounded, merged stderr, 2-minute budget, a failure aborts the invocation with the documented exit-1 carveout), `@file` inlining; `allowed-tools` with `Bash(...)` and `Read`/`Edit`/`Write` path scopes enforced at call time (gitignore anchors, Edit governs writes), `disallowed-tools`, `argument-hint`, `model` (switches the session model for the command's turn, restored after), `effort` (raises reasoning for the turn, restored after), `shell: powershell` (injected spans run through PowerShell when a `pwsh` binary is present, else `/bin/sh`); the model can also run a command itself through the `SlashCommand` tool (Claude's `SlashCommand` in `allowed-tools`), steered by `when_to_use` and opted out per file with `disable-model-invocation` (`user-invocable: false` hides a command from the menu while still exposing it to the model); `disableSkillShellExecution` (managed and user always, project when trusted) replaces every `!` span with a policy-disabled placeholder; project commands gated on approval | `commands.ts` |
46
46
  | `/init` | generates a project context file: detects an existing `AGENTS.md`/`CLAUDE.md` (proposes improvements) or none (creates `AGENTS.md`, pi's preferred name), ingesting `.cursor/rules`, `.cursorrules`, and `.github/copilot-instructions.md` when present; drives the main agent with full tools via a prompt (not a tool-less completion) so it analyzes the codebase and writes the file itself | `init.ts` |
47
- | 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` |
47
+ | 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); invoking `/skill:name` expands the body through the command pipeline, as Claude documents commands and skills "work the same way": `` !`cmd` `` spans, `@file` references, `$ARGUMENTS`/positional substitution and `${CLAUDE_*}` variables, with a malformed-frontmatter skill degrading to pi's plain expansion | `skills.ts` |
48
48
  | 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, Notification (idle_prompt, the type pi can source), InstructionsLoaded (observational: fires per loaded context file at session start, plus `path_glob_match` on a scoped-rule attach and `include` per resolved `@import`; deduped per session; `nested_traversal`/`compact` reasons never fire since pi does not lazily load nested CLAUDE.md or reload after compaction); `type: http` entries POST the payload (a 2xx JSON body renders the decision, everything else is non-blocking per Claude's contract), `type: prompt` evaluates in-process against the session model, `type: mcp_tool` calls a connected server's tool, and `type: agent` (experimental) spawns a read-only Read/Grep/Glob subagent that returns the JSON decision (a missing model/server/runner is non-blocking, only a PreToolUse timeout fails closed); Claude matcher semantics incl. `mcp__server__tool` names; payloads carry session_id, transcript_path, cwd, permission_mode, effort; `permissionDecision: "ask"` prompts via a confirm dialog (blocks when headless), SubagentStop/PostToolUseFailure are notify-only, and a timed-out PreToolUse/UserPromptSubmit hook fails closed at a 60s default (Claude: 600s for PreToolUse, 30s for UserPromptSubmit, both non-blocking) since pi has no permission backstop; `async`/`asyncRewake` command hooks run in the background on every event (never blocking, no decision, no timeout enforced on `async` while asyncRewake keeps its own; an asyncRewake exit 2 wakes the model with the hook's stderr as a new turn, other completions deliver `systemMessage`/`additionalContext` to the model on the next turn, and hooks still running at session end are killed); payloads use Claude's vocabulary for pi's built-ins (Bash/Edit/Write/Read/Grep/Glob names, documented input and Bash/Write response shapes, absolute `file_path`, `updatedInput` translated back); PreToolUse `additionalContext` lands beside the tool result, `updatedToolOutput`/`updatedMCPToolOutput` replace what the model sees, `permissionDecision: "defer"` blocks (pi cannot resume a deferred call), the `if` permission-rule filter runs on tool events only (a hook carrying it never runs elsewhere), Stop/UserPromptSubmit ignore stray matchers, and a Stop hook's `additionalContext` continues the conversation under the block cap; a `command` hook may use exec form (`command` as an argv array, run with no shell) and executes with `CLAUDECODE=1` and `CLAUDE_PROJECT_DIR` set; a user-typed `!`/`!!` bash line runs PreToolUse (there is no PostToolUse for it); `type: http` targets are gated by `allowedHttpHookUrls` (union of managed and settings scopes; unset allows all, `[]` blocks every http hook); a Stop hook may block at most 8 times before the turn ends (`CLAUDE_CODE_STOP_HOOK_BLOCK_CAP`); `disableAllHooks` in any scope turns the system off; `/hooks` prints the resolved configuration | `hooks.ts` |
49
49
  | Output styles | `.claude/output-styles` + active `outputStyle`; plus styles shipped by enabled plugins (manifest `outputStyles`, default `output-styles/`, ranked below the user's and project's own); Claude replace semantics with `keep-coding-instructions`; bundled Explanatory/Learning/Proactive; `/output-style [name]` | `output-styles.ts` |
50
50
  | CLAUDE.md `@imports` and rewriting | resolves `@path` imports pi's native loader skips (4-hop depth, budget-capped); loads the user `~/.claude/CLAUDE.md` and the project `.claude/CLAUDE.md` (approval-gated, deduped against the repo-root `CLAUDE.md`/`AGENTS.md` pi loads natively) that pi's own loader does not; loads every `CLAUDE.local.md` from the repo root down to cwd (approval-gated, root first); injects the managed `CLAUDE.md` (a per-OS file beside `managed-settings.json`) and the `claudeMd` string from `managed-settings.json` at the top of context (managed content is never excludable); honors `claudeMdExcludes` (glob/absolute-path skip list from user, approved-project, and managed settings, merged; managed content never excluded); strips block-level HTML comments from CLAUDE.md, rule, and imported bodies (fenced-code comments preserved), so a commented-out `@import` does not expand; with `CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD` set, loads `CLAUDE.md`/`.claude/CLAUDE.md`/`.claude/rules/*.md`/`CLAUDE.local.md` from each `--add-dir` directory (comma-separated for several, since pi's flag is single-value) | `context-imports.ts` |
@@ -7,14 +7,25 @@
7
7
  * pi implements the Agent Skills standard, so `SKILL.md` directories work
8
8
  * unchanged and register as `/skill:name`.
9
9
  *
10
+ * Claude documents that a command file and a skill "work the same way", so a
11
+ * SKILL.md body carries the dynamic features command bodies do: `` !`cmd` ``
12
+ * spans, `@file` references, `$ARGUMENTS`/positional substitution and
13
+ * `${CLAUDE_*}` variables. pi's loader delivers the raw text, so this extension
14
+ * intercepts `/skill:name` input for the skills it contributed, expands the body
15
+ * through the shared command pipeline, and hands pi the already-expanded content
16
+ * in pi's own skill-block format (pi emits the input event before its own
17
+ * expansion, and skips text that no longer starts with `/`).
18
+ *
10
19
  * Docs: https://code.claude.com/docs/en/skills.md, https://agentskills.io
11
20
  */
12
21
 
13
22
  import * as fs from 'node:fs'
14
23
  import * as os from 'node:os'
15
24
  import * as path from 'node:path'
16
- import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
25
+ import { type ExtensionAPI, type ExtensionContext, parseFrontmatter } from '@earendil-works/pi-coding-agent'
17
26
 
27
+ import { expandCommand, shellExecutionDisabled } from './commands.js'
28
+ import { parseCommandFile } from './internal/command-file.js'
18
29
  import { claudeConfigDir } from './internal/config-dir.js'
19
30
  import { installedPlugins } from './internal/plugins.js'
20
31
  import { isProjectApprovedSilently } from './internal/project-approval.js'
@@ -28,7 +39,6 @@ function isDirectory(target: string): boolean {
28
39
  }
29
40
  }
30
41
 
31
- /** Existing `.claude/skills` directories, user first then project. */
32
42
  /** Existing `.claude/skills` directories, user first then project. The project
33
43
  * directory is included only for approved projects: pi's loader surfaces every skill's
34
44
  * name and description to the model, so an untrusted repository would otherwise get
@@ -51,6 +61,50 @@ export function skillDirs(cwd: string, home: string, trusted: boolean): string[]
51
61
  return dirs
52
62
  }
53
63
 
64
+ interface FoundSkill {
65
+ filePath: string
66
+ baseDir: string
67
+ }
68
+
69
+ /** The skill a directory entry holds, named as pi's loader names it (frontmatter
70
+ * `name`, else the directory name); undefined without a readable SKILL.md. */
71
+ function skillAt(root: string, dirName: string): { name: string; filePath: string } | undefined {
72
+ const filePath = path.join(root, dirName, 'SKILL.md')
73
+ let content: string
74
+ try {
75
+ content = fs.readFileSync(filePath, 'utf-8')
76
+ } catch {
77
+ return undefined
78
+ }
79
+ let name = dirName
80
+ try {
81
+ const declared = parseFrontmatter<Record<string, unknown>>(content).frontmatter.name
82
+ if (typeof declared === 'string' && declared.trim()) name = declared.trim()
83
+ } catch {
84
+ // Malformed frontmatter: pi's loader falls back to the directory name too.
85
+ }
86
+ return { name, filePath }
87
+ }
88
+
89
+ /** A Claude-contributed skill by the name pi's loader gives it. One directory
90
+ * level, the standard layout. */
91
+ export function findClaudeSkill(name: string, roots: string[]): FoundSkill | undefined {
92
+ for (const root of roots) {
93
+ let entries: fs.Dirent[]
94
+ try {
95
+ entries = fs.readdirSync(root, { withFileTypes: true })
96
+ } catch {
97
+ continue
98
+ }
99
+ for (const entry of entries) {
100
+ if (!entry.isDirectory()) continue
101
+ const skill = skillAt(root, entry.name)
102
+ if (skill?.name === name) return { filePath: skill.filePath, baseDir: path.dirname(skill.filePath) }
103
+ }
104
+ }
105
+ return undefined
106
+ }
107
+
54
108
  export default function skillsExtension(pi: ExtensionAPI) {
55
109
  pi.on('resources_discover', async (_event, ctx) => {
56
110
  // resources_discover fires after session_start, so the approval is already
@@ -58,4 +112,38 @@ export default function skillsExtension(pi: ExtensionAPI) {
58
112
  const skillPaths = skillDirs(ctx.cwd, os.homedir(), isProjectApprovedSilently(ctx))
59
113
  return skillPaths.length > 0 ? { skillPaths } : undefined
60
114
  })
115
+
116
+ // The dynamic-content shim: only for skills this extension contributed; pi's own
117
+ // `.pi/skills` (or an unknown name) pass through to pi's plain expansion.
118
+ pi.on('input', async (event, ctx) => {
119
+ if (event.source === 'extension') return
120
+ return expandSkillInvocation(pi, event.text, ctx)
121
+ })
122
+ }
123
+
124
+ /** A `/skill:name args` invocation into its expanded skill block, or undefined to
125
+ * pass the input through to pi untouched. The expanded body is wrapped in pi's
126
+ * skill-block format so downstream behavior (the baseDir note for relative
127
+ * references) matches an untouched invocation. */
128
+ async function expandSkillInvocation(pi: ExtensionAPI, rawText: string, ctx: ExtensionContext): Promise<{ action: 'transform'; text: string } | undefined> {
129
+ const text = rawText.trimStart()
130
+ if (!text.startsWith('/skill:')) return
131
+ const space = text.indexOf(' ')
132
+ const name = (space === -1 ? text.slice(7) : text.slice(7, space)).trim()
133
+ const args = space === -1 ? '' : text.slice(space + 1).trim()
134
+ if (!name) return
135
+ const trusted = isProjectApprovedSilently(ctx)
136
+ const found = findClaudeSkill(name, skillDirs(ctx.cwd, os.homedir(), trusted))
137
+ if (!found) return
138
+ let parsed: ReturnType<typeof parseCommandFile>
139
+ try {
140
+ parsed = parseCommandFile(fs.readFileSync(found.filePath, 'utf-8'))
141
+ } catch {
142
+ // Unreadable, or malformed frontmatter: pass through to pi's plain expansion
143
+ // (the loader registered the skill and delivers the raw body), rather than
144
+ // failing the invocation over the dynamic features it cannot have.
145
+ return
146
+ }
147
+ const expanded = await expandCommand(pi, parsed, args, { cwd: ctx.cwd }, found.filePath, undefined, { allowShell: !shellExecutionDisabled(ctx.cwd, os.homedir(), trusted) })
148
+ return { action: 'transform', text: `<skill name="${name}" location="${found.filePath}">\nReferences are relative to ${found.baseDir}.\n\n${expanded}\n</skill>` }
61
149
  }
@@ -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
- // Both calls throw once the session that started the run is disposed; driveRun
726
- // catches for the whole callback, so neither can escape into the child's close
727
- // listener and become an uncaughtException.
728
- pi.events.emit(SUBAGENT_CHANNEL, { phase: 'stop', agentType: run.agent, agentId: run.id })
729
- pi.sendMessage({ customType: 'subagent-background', content: backgroundCompletionText(run), display: true }, { triggerTurn: true })
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.17",
3
+ "version": "1.0.19",
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",