pi-code 1.0.16 → 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.
package/README.md CHANGED
@@ -45,7 +45,7 @@ One `pi install` and everything below loads on the next start. `pi list` shows w
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
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` |
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); 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` |
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` |
51
51
  | Settings `env` | `env` blocks from `managed-settings.json`, `~/.claude/settings.json`, and the project `.claude/settings.json`/`settings.local.json` exported into the session (per-key `managed > user > project`); the project scope is approval-gated (a repo's env can redirect providers), a shell `export` outranks user and project but a managed key overrides even that, and a key an approved project set is unset once a later session no longer defines it | `env-settings.ts` |
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Claude-vocabulary translation for hook payloads on pi's built-in tools.
3
+ *
4
+ * Claude-written hook scripts branch on documented names ("Bash", "Edit") and read
5
+ * documented input shapes (`tool_input.file_path`); pi's tools carry their own
6
+ * names (`bash`, `edit`) and shapes (`path`, `edits[]`). Payloads report the
7
+ * Claude form, exactly as MCP aliases and user_bash already do, and the decision
8
+ * outputs that reference input/output shapes (`updatedInput`, `updatedToolOutput`)
9
+ * are translated back. Mappings against pi's schemas in
10
+ * node_modules/@earendil-works/pi-coding-agent/dist/core/tools and Claude's hooks
11
+ * reference (per-tool input tables, PostToolUse response shapes).
12
+ *
13
+ * Translation choices the shapes force, each documented on its function:
14
+ * - pi's multi-entry `edits[]` maps to Claude's single Edit with the first entry in
15
+ * `old_string`/`new_string` and the full array carried alongside as `edits`.
16
+ * - Bash `timeout` converts between pi's seconds and Claude's milliseconds.
17
+ * - pi's bash output is one combined stream, so the Bash response reports it all as
18
+ * `stdout` with an empty `stderr`.
19
+ */
20
+
21
+ import * as os from 'node:os'
22
+ import * as path from 'node:path'
23
+
24
+ /** pi built-in -> Claude tool name for hook payloads and matchers. MCP tools ride
25
+ * the alias bus instead; pi tools with no Claude counterpart (ls) stay untranslated. */
26
+ const CLAUDE_NAMES: Record<string, string> = { bash: 'Bash', edit: 'Edit', write: 'Write', read: 'Read', grep: 'Grep', find: 'Glob' }
27
+
28
+ export function claudeToolName(piName: string): string | undefined {
29
+ return CLAUDE_NAMES[piName]
30
+ }
31
+
32
+ /** Claude file-tool paths are always absolute with `~` expanded before hooks run,
33
+ * so a path guard cannot be bypassed by a relative or `~` spelling of the same path. */
34
+ function absolutePath(value: unknown, cwd: string): unknown {
35
+ if (typeof value !== 'string' || value.length === 0) return value
36
+ const expanded = value === '~' || value.startsWith('~/') ? path.join(os.homedir(), value.slice(1)) : value
37
+ return path.resolve(cwd, expanded)
38
+ }
39
+
40
+ const record = (value: unknown): Record<string, unknown> | undefined => (value !== null && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined)
41
+
42
+ /** Only the entries whose value passes the filter, so optional fields stay absent
43
+ * rather than arriving as explicit undefined. */
44
+ function pick(entries: Record<string, unknown>): Record<string, unknown> {
45
+ return Object.fromEntries(Object.entries(entries).filter(([, value]) => value !== undefined))
46
+ }
47
+
48
+ type InputMapper = (raw: Record<string, unknown>, cwd: string) => Record<string, unknown>
49
+
50
+ const TO_CLAUDE_INPUT: Record<string, InputMapper> = {
51
+ // pi timeout is seconds, Claude's is milliseconds.
52
+ bash: (raw) => pick({ command: raw.command, timeout: typeof raw.timeout === 'number' ? raw.timeout * 1000 : undefined }),
53
+ write: (raw, cwd) => ({ file_path: absolutePath(raw.path, cwd), content: raw.content }),
54
+ read: (raw, cwd) => pick({ file_path: absolutePath(raw.path, cwd), offset: raw.offset, limit: raw.limit }),
55
+ // Claude's Edit is a single replacement; pi's edit call carries one or more. The
56
+ // documented fields expose the first entry, and the full array rides along as
57
+ // `edits` so a hook auditing the whole call loses nothing.
58
+ edit: (raw, cwd) => {
59
+ const edits = Array.isArray(raw.edits) ? raw.edits : []
60
+ const first = record(edits[0]) ?? {}
61
+ return { file_path: absolutePath(raw.path, cwd), old_string: first.oldText ?? '', new_string: first.newText ?? '', replace_all: false, ...(edits.length > 1 ? { edits } : {}) }
62
+ },
63
+ grep: (raw, cwd) => pick({ pattern: raw.pattern, path: raw.path === undefined ? undefined : absolutePath(raw.path, cwd), glob: raw.glob, '-i': raw.ignoreCase === true ? true : undefined }),
64
+ find: (raw, cwd) => pick({ pattern: raw.pattern, path: raw.path === undefined ? undefined : absolutePath(raw.path, cwd) }),
65
+ }
66
+
67
+ /** pi input -> Claude `tool_input` for the translated built-ins; undefined keeps the
68
+ * pi shape (MCP and unknown tools). */
69
+ export function claudeToolInput(piName: string, input: unknown, cwd: string): Record<string, unknown> | undefined {
70
+ const raw = record(input)
71
+ if (!raw) return undefined
72
+ return TO_CLAUDE_INPUT[piName]?.(raw, cwd)
73
+ }
74
+
75
+ type RewriteMapper = (updated: Record<string, unknown>) => Record<string, unknown> | undefined
76
+
77
+ const FROM_CLAUDE_INPUT: Record<string, RewriteMapper> = {
78
+ bash: (updated) => (typeof updated.command === 'string' ? pick({ command: updated.command, timeout: typeof updated.timeout === 'number' ? updated.timeout / 1000 : undefined }) : undefined),
79
+ write: (updated) => (typeof updated.file_path === 'string' && typeof updated.content === 'string' ? { path: updated.file_path, content: updated.content } : undefined),
80
+ read: (updated) => (typeof updated.file_path === 'string' ? pick({ path: updated.file_path, offset: updated.offset, limit: updated.limit }) : undefined),
81
+ edit: (updated) => {
82
+ if (typeof updated.file_path !== 'string') return undefined
83
+ if (Array.isArray(updated.edits)) return { path: updated.file_path, edits: updated.edits }
84
+ if (typeof updated.old_string !== 'string' || typeof updated.new_string !== 'string') return undefined
85
+ return { path: updated.file_path, edits: [{ oldText: updated.old_string, newText: updated.new_string }] }
86
+ },
87
+ grep: (updated) => (typeof updated.pattern === 'string' ? pick({ pattern: updated.pattern, path: updated.path, glob: updated.glob, ignoreCase: updated['-i'] === true ? true : undefined }) : undefined),
88
+ find: (updated) => (typeof updated.pattern === 'string' ? pick({ pattern: updated.pattern, path: updated.path }) : undefined),
89
+ }
90
+
91
+ /** Claude-shaped `updatedInput` back into pi's input shape for a translated tool.
92
+ * Returns undefined when the rewrite is missing the tool's required fields, so the
93
+ * caller keeps the ORIGINAL input rather than handing pi a corrupted one; for an
94
+ * untranslated tool the caller applies the rewrite verbatim. Claude's Edit
95
+ * `replace_all` has no pi counterpart (pi requires a unique oldText) and is dropped. */
96
+ export function piToolInput(piName: string, updated: Record<string, unknown>): Record<string, unknown> | undefined {
97
+ return FROM_CLAUDE_INPUT[piName]?.(updated)
98
+ }
99
+
100
+ /** pi result -> Claude `tool_response` where the docs pin a shape: Bash's structured
101
+ * object (pi's single combined stream reported as stdout) and Write's
102
+ * `{filePath, success}`. Other tools keep pi's `{content, details, isError}`. */
103
+ export function claudeToolResponse(piName: string, input: unknown, text: string, isError: boolean, cwd: string): Record<string, unknown> | undefined {
104
+ const raw = record(input)
105
+ switch (piName) {
106
+ case 'bash':
107
+ return { stdout: text, stderr: '', interrupted: false, isImage: false }
108
+ case 'write':
109
+ return { filePath: absolutePath(raw?.path, cwd), success: !isError }
110
+ default:
111
+ return undefined
112
+ }
113
+ }
114
+
115
+ /** A hook's `updatedToolOutput` back into pi text content. Claude validates built-in
116
+ * replacements against the tool's output schema and ignores mismatches (returns
117
+ * undefined here, keeping the original); MCP output passes through unvalidated. */
118
+ export function piToolOutput(piName: string, value: unknown, isMcp: boolean): string | undefined {
119
+ if (isMcp) {
120
+ if (typeof value === 'string') return value
121
+ return value === undefined ? undefined : JSON.stringify(value)
122
+ }
123
+ if (piName === 'bash') {
124
+ const raw = record(value)
125
+ if (!raw || typeof raw.stdout !== 'string') return undefined
126
+ const stderr = typeof raw.stderr === 'string' && raw.stderr.length > 0 ? `\n${raw.stderr}` : ''
127
+ return `${raw.stdout}${stderr}`
128
+ }
129
+ // pi's other tool outputs are text content, so a string replacement is
130
+ // shape-valid; a structured value has no pi counterpart and is ignored.
131
+ return typeof value === 'string' ? value : undefined
132
+ }
@@ -25,6 +25,9 @@ export interface HookCommand {
25
25
  * the next turn, and any still running are killed at session end. */
26
26
  async?: boolean
27
27
  asyncRewake?: boolean
28
+ /** Claude's permission-rule filter (`"Bash(git *)"`, `"Edit(*.ts)"`): evaluated only
29
+ * on tool events; on any other event a hook carrying `if` never runs. */
30
+ if?: string
28
31
  /** http entries: the endpoint POSTed to; `command` mirrors it for dedup and display. */
29
32
  url?: string
30
33
  headers?: Record<string, string>
@@ -5,8 +5,10 @@
5
5
  */
6
6
 
7
7
  import type { ToolCallEventResult } from '@earendil-works/pi-coding-agent'
8
+ import type { PathAnchors } from '../internal/path-rules.js'
9
+ import { claudeToolInput, claudeToolName, piToolInput } from './claude-tools.js'
8
10
  import { type HookCommand, type HooksConfig, isRecord } from './config.js'
9
- import { matchingCommands } from './matcher.js'
11
+ import { allCommands, matchingCommands, passesIfFilter } from './matcher.js'
10
12
  import { type HookRunner, type HookRunResult, timeoutMs } from './runners.js'
11
13
 
12
14
  export interface HookDecision {
@@ -17,7 +19,19 @@ export interface HookDecision {
17
19
  ask?: boolean
18
20
  }
19
21
 
20
- export function tryParseJson(text: string): { hookSpecificOutput?: { permissionDecision?: string; permissionDecisionReason?: string; additionalContext?: string; updatedInput?: unknown }; decision?: string; reason?: string; continue?: boolean; stopReason?: string; systemMessage?: string } | undefined {
22
+ export function tryParseJson(text: string):
23
+ | {
24
+ hookSpecificOutput?: { permissionDecision?: string; permissionDecisionReason?: string; additionalContext?: string; updatedInput?: unknown }
25
+ decision?: string
26
+ reason?: string
27
+ continue?: boolean
28
+ stopReason?: string
29
+ systemMessage?: string
30
+ updatedToolOutput?: unknown
31
+ updatedMCPToolOutput?: unknown
32
+ ok?: boolean
33
+ }
34
+ | undefined {
21
35
  try {
22
36
  return JSON.parse(text)
23
37
  } catch {
@@ -25,16 +39,42 @@ export function tryParseJson(text: string): { hookSpecificOutput?: { permissionD
25
39
  }
26
40
  }
27
41
 
42
+ /** The reason a JSON body's blocking decision carries, whichever spelling made it. */
43
+ function jsonBlockingReason(parsed: ReturnType<typeof tryParseJson>): string | undefined {
44
+ if (parsed?.hookSpecificOutput?.permissionDecision === 'deny') return parsed.hookSpecificOutput.permissionDecisionReason
45
+ if (parsed?.decision === 'block') return parsed.reason
46
+ if (parsed?.continue === false) return parsed.stopReason
47
+ return undefined
48
+ }
49
+
50
+ /** A blocking verdict in any of the JSON spellings a hook can answer with: the
51
+ * command-hook fields, and the prompt/agent reply schemas (`permissionDecision:
52
+ * "deny"` from pi's prompt-hook system prompt, `ok: false` from Claude's documented
53
+ * prompt-hook response). Undefined when the body renders no block. */
54
+ export function jsonBlockVerdict(parsed: ReturnType<typeof tryParseJson>, fallback: string): { reason: string } | undefined {
55
+ if (parsed?.decision === 'block') return { reason: parsed.reason ?? fallback }
56
+ if (parsed?.hookSpecificOutput?.permissionDecision === 'deny') return { reason: parsed.hookSpecificOutput.permissionDecisionReason ?? fallback }
57
+ if (parsed?.ok === false) return { reason: parsed.reason ?? fallback }
58
+ return undefined
59
+ }
60
+
28
61
  /** Map a hook's exit code / output to a block-or-allow decision. */
29
62
  export function interpretHookResult(code: number, stdout: string, stderr: string): HookDecision {
30
- if (code === 2) return { block: true, reason: stderr.trim() || 'Blocked by hook' }
31
63
  const parsed = tryParseJson(stdout)
64
+ // Claude: on exit 2 the blocking message is the JSON blocking decision's reason
65
+ // when it makes one, and the stderr text otherwise.
66
+ if (code === 2) return { block: true, reason: jsonBlockingReason(parsed) ?? (stderr.trim() || 'Blocked by hook') }
32
67
  const specific = parsed?.hookSpecificOutput
33
68
  // Claude's "ask" prompts the user; the tool_call handler turns this into a
34
69
  // ctx.ui.confirm and blocks only on decline. block:true is the fallback for a
35
70
  // headless run with no dialog to show, which is the safe reading on a gated path.
36
71
  if (specific?.permissionDecision === 'ask') return { block: true, ask: true, reason: specific.permissionDecisionReason ?? 'A hook asks you to confirm this tool call.' }
37
72
  if (specific?.permissionDecision === 'deny') return { block: true, reason: specific.permissionDecisionReason ?? 'Blocked by hook' }
73
+ // Claude's "defer" exits gracefully so the tool can be resumed later; pi cannot
74
+ // resume a deferred call, so running it now would invert the intent. The block
75
+ // carries its own explanation (the hook's reason is ignored for defer, as
76
+ // documented).
77
+ if (specific?.permissionDecision === 'defer') return { block: true, reason: 'Tool call deferred by hook; pi cannot resume a deferred call, so it was not run.' }
38
78
  if (parsed?.decision === 'block') return { block: true, reason: parsed.reason ?? 'Blocked by hook' }
39
79
  if (parsed?.continue === false) return { block: true, reason: parsed.stopReason ?? 'Blocked by hook' }
40
80
  return { block: false }
@@ -57,36 +97,80 @@ function surfaceHookFailures(commands: HookCommand[], results: HookRunResult[],
57
97
  }
58
98
  }
59
99
 
100
+ /** What PreToolUse resolved to: the decision, plus any additionalContext strings
101
+ * the hooks contributed, delivered alongside the eventual tool result. */
102
+ export interface PreToolUseOutcome extends HookDecision {
103
+ context?: string[]
104
+ }
105
+
60
106
  /** Run PreToolUse hooks for a tool, in parallel as Claude does; the first blocking
61
- * verdict in config order wins. For MCP tools the matcher sees both the pi name and
62
- * the Claude alias, and the payload reports the alias, which is the name a
63
- * Claude-written hook script expects in tool_name. Every hook sees the original
64
- * tool input; hookSpecificOutput.updatedInput replaces the input in place as each
65
- * hook completes, so with several rewrites the last to finish takes effect (the
66
- * docs leave multi-rewrite ordering unspecified). */
67
- export async function runPreToolUse(config: HooksConfig, toolName: string, toolInput: unknown, runner: HookRunner, claudeName?: string, onSystemMessage?: SystemMessageSink): Promise<HookDecision> {
68
- const names = claudeName ? [toolName, claudeName] : [toolName]
69
- const commands = matchingCommands(config.PreToolUse, names)
70
- const results = await Promise.all(
71
- commands.map((command) =>
72
- runner(command, { hook_event_name: 'PreToolUse', tool_name: claudeName ?? toolName, tool_input: toolInput }, timeoutMs(command)).then((result) => {
73
- const updated = tryParseJson(result.stdout)?.hookSpecificOutput?.updatedInput
74
- if (isRecord(updated) && isRecord(toolInput)) replaceRecord(toolInput, updated)
75
- return result
76
- }),
77
- ),
78
- )
79
- surfaceHookFailures(commands, results, onSystemMessage)
107
+ * verdict in config order wins. The payload reports the Claude vocabulary: the MCP
108
+ * alias for MCP tools, and the documented name and tool_input shape for pi's
109
+ * built-ins (see claude-tools). Every hook sees the original tool input;
110
+ * hookSpecificOutput.updatedInput replaces the input in place as each hook
111
+ * completes, translated back to the pi shape for a built-in (an incomplete rewrite
112
+ * keeps the original input rather than corrupting it), so with several rewrites
113
+ * the last to finish takes effect (the docs leave multi-rewrite ordering
114
+ * unspecified). */
115
+ /** Apply a hook's updatedInput rewrite in place, translating a built-in rewrite
116
+ * back to the pi shape; an incomplete built-in rewrite keeps the original input
117
+ * rather than corrupting it. */
118
+ function applyUpdatedInput(toolName: string, toolInput: unknown, translated: boolean, stdout: string): void {
119
+ const updated = tryParseJson(stdout)?.hookSpecificOutput?.updatedInput
120
+ if (!isRecord(updated) || !isRecord(toolInput)) return
121
+ const replacement = translated ? piToolInput(toolName, updated) : updated
122
+ if (replacement) replaceRecord(toolInput, replacement)
123
+ }
124
+
125
+ /** The fail-closed scan for the gated events: a timed-out or never-spawned hook
126
+ * reached no verdict, and its silence must not read as an allow. */
127
+ function failClosedVerdict(commands: HookCommand[], results: HookRunResult[]): HookDecision | undefined {
80
128
  for (const [i, result] of results.entries()) {
81
- // A killed hook never reached its verdict, and SIGKILL leaves a null exit code that
82
- // would otherwise read as a clean allow. Fail closed instead.
129
+ // A killed hook never reached its verdict, and SIGKILL leaves a null exit code
130
+ // that would otherwise read as a clean allow.
83
131
  if (result.timedOut) return { block: true, reason: `Hook timed out after ${timeoutMs(commands[i])}ms: ${commands[i].command}` }
84
132
  // A hook that never spawned (EMFILE, missing /bin/sh) reached no verdict either;
85
133
  // its code 0 must fail closed like a timeout, not read as an allow exactly when
86
134
  // the machine is degraded.
87
135
  if (result.spawnFailed) return { block: true, reason: `Hook failed to run: ${commands[i].command}: ${result.stderr.trim() || 'unknown error'}` }
88
136
  }
137
+ return undefined
138
+ }
139
+
140
+ /** additionalContext strings the PreToolUse hooks contributed; delivered alongside
141
+ * the tool result, so a deferring hook's context is discarded, as Claude documents. */
142
+ function preToolContexts(results: HookRunResult[]): string[] {
143
+ return results.flatMap((result) => {
144
+ const parsed = tryParseJson(result.stdout)
145
+ if (parsed?.hookSpecificOutput?.permissionDecision === 'defer') return []
146
+ const text = parsed?.hookSpecificOutput?.additionalContext
147
+ return typeof text === 'string' && text.length > 0 ? [text] : []
148
+ })
149
+ }
150
+
151
+ export async function runPreToolUse(config: HooksConfig, toolName: string, toolInput: unknown, runner: HookRunner, claudeName?: string, onSystemMessage?: SystemMessageSink, anchors?: PathAnchors): Promise<PreToolUseOutcome> {
152
+ const cwd = anchors?.cwd ?? process.cwd()
153
+ const translatedName = claudeName ?? claudeToolName(toolName)
154
+ // A built-in's payload input is the translated Claude shape; MCP and unknown
155
+ // tools keep the pi shape (MCP input passes through untranslated in Claude too).
156
+ const translatedInput = claudeName === undefined ? claudeToolInput(toolName, toolInput, cwd) : undefined
157
+ const names = translatedName ? [toolName, translatedName] : [toolName]
158
+ const target = anchors ? { piName: toolName, claudeName: translatedName, input: toolInput, anchors } : undefined
159
+ const commands = matchingCommands(config.PreToolUse, names).filter((command) => passesIfFilter(command, target))
160
+ const payload = { hook_event_name: 'PreToolUse', tool_name: translatedName ?? toolName, tool_input: translatedInput ?? toolInput }
161
+ const results = await Promise.all(
162
+ commands.map((command) =>
163
+ runner(command, payload, timeoutMs(command)).then((result) => {
164
+ applyUpdatedInput(toolName, toolInput, translatedInput !== undefined, result.stdout)
165
+ return result
166
+ }),
167
+ ),
168
+ )
169
+ surfaceHookFailures(commands, results, onSystemMessage)
170
+ const failClosed = failClosedVerdict(commands, results)
171
+ if (failClosed) return failClosed
89
172
  if (onSystemMessage) surfaceSystemMessages(results, onSystemMessage)
173
+ const context = preToolContexts(results)
90
174
  // A hard deny wins over an ask, matching Claude's deny > ask > allow precedence:
91
175
  // scan for any deny first, and only fall back to the first ask.
92
176
  let ask: HookDecision | undefined
@@ -95,7 +179,7 @@ export async function runPreToolUse(config: HooksConfig, toolName: string, toolI
95
179
  if (decision.block && !decision.ask) return decision
96
180
  if (decision.ask && ask === undefined) ask = decision
97
181
  }
98
- return ask ?? { block: false }
182
+ return ask ?? { block: false, context: context.length > 0 ? context : undefined }
99
183
  }
100
184
 
101
185
  type SystemMessageSink = (message: string) => void
@@ -124,9 +208,10 @@ export function promptContext(stdout: string): string {
124
208
 
125
209
  /** Run UserPromptSubmit hooks, in parallel as Claude does: the first blocking
126
210
  * verdict in config order wins; otherwise their additional context is concatenated
127
- * in config order for injection ahead of the prompt. */
211
+ * in config order for injection ahead of the prompt. The event has no matcher
212
+ * support (a stray matcher is ignored) and an `if`-carrying hook never runs here. */
128
213
  export async function runUserPromptSubmit(config: HooksConfig, prompt: string, runner: HookRunner, onSystemMessage?: SystemMessageSink): Promise<PromptDecision> {
129
- const commands = matchingCommands(config.UserPromptSubmit, 'UserPromptSubmit')
214
+ const commands = allCommands(config.UserPromptSubmit).filter((command) => passesIfFilter(command, undefined))
130
215
  const results = await Promise.all(commands.map((command) => runner(command, { hook_event_name: 'UserPromptSubmit', prompt }, timeoutMs(command))))
131
216
  surfaceHookFailures(commands, results, onSystemMessage)
132
217
  for (const [i, result] of results.entries()) {
@@ -155,7 +240,12 @@ export function postToolFeedback(result: HookRunResult, eventName: string, isErr
155
240
  // A failed tool cannot be blocked, but the hook's stderr is still shown; on
156
241
  // success, exit-2 / decision:block feed back as a block notice.
157
242
  if (!result.timedOut && result.code === 2) lines.push(`${eventName} hook: ${result.stderr.trim() || (isError ? 'hook reported an error' : 'Blocked by hook')}`)
158
- else if (!isError && parsed?.decision === 'block') lines.push(`PostToolUse hook: ${parsed.reason ?? 'Blocked by hook'}`)
243
+ else if (!isError) {
244
+ // Any JSON blocking spelling feeds back, including the prompt/agent hook reply
245
+ // schemas (permissionDecision deny, ok:false), which arrive as stdout here.
246
+ const verdict = jsonBlockVerdict(parsed, 'Blocked by hook')
247
+ if (verdict) lines.push(`PostToolUse hook: ${verdict.reason}`)
248
+ }
159
249
  const context = parsed?.hookSpecificOutput?.additionalContext
160
250
  if (context) lines.push(context)
161
251
  return lines
@@ -39,6 +39,17 @@
39
39
  * InstructionsLoaded) ignore it, as Claude documents for them.
40
40
  * `suppressOutput` is accepted and inert: pi never echoes hook stdout to the
41
41
  * transcript in the first place.
42
+ * Payloads speak Claude's vocabulary for pi's built-in tools: tool_name Bash/Edit/
43
+ * Write/Read/Grep/Glob, the documented tool_input shapes with absolute file_path,
44
+ * and the documented Bash/Write tool_response shapes, with `updatedInput`
45
+ * translated back to pi's shape (see claude-tools; an incomplete rewrite keeps
46
+ * the original input). PreToolUse `additionalContext` lands next to the tool
47
+ * result; `updatedToolOutput` replaces the output the model sees (schema-checked
48
+ * for built-ins, unvalidated for MCP); `permissionDecision: "defer"` blocks the
49
+ * call, since pi cannot resume a deferred one. The `if` permission-rule filter is
50
+ * honored on tool events, and a hook carrying it never runs elsewhere; Stop and
51
+ * UserPromptSubmit ignore a stray matcher, and a Stop hook's `additionalContext`
52
+ * continues the conversation under the same block cap.
42
53
  * `async`/`asyncRewake` (command hooks only, as Claude documents) run in the
43
54
  * background on every event: they never block or delay the event that fired them
44
55
  * and render no decision. An asyncRewake hook exiting 2 wakes the model with its
@@ -80,9 +91,10 @@ import { installedPlugins } from '../internal/plugins.js'
80
91
  import { isProjectApproved } from '../internal/project-approval.js'
81
92
  import { repoRoot } from '../internal/project-root.js'
82
93
  import { isSubagentPhaseEvent, SUBAGENT_CHANNEL } from '../internal/subagent-events.js'
94
+ import { claudeToolInput, claudeToolName, claudeToolResponse, piToolOutput } from './claude-tools.js'
83
95
  import { formatHooksSummary, type HookCommand, type HookMatcher, type HooksConfig, hookFiles, isBackgroundHook, loadHooks, loadPluginHooks, readAllowedHttpHookUrls, readDisableAllHooks } from './config.js'
84
- import { blockedToolCall, postToolFeedback, promptContext, runPreToolUse, runUserPromptSubmit, surfaceSystemMessages, tryParseJson } from './decisions.js'
85
- import { matchingCommands } from './matcher.js'
96
+ import { blockedToolCall, jsonBlockVerdict, postToolFeedback, promptContext, runPreToolUse, runUserPromptSubmit, surfaceSystemMessages, tryParseJson } from './decisions.js'
97
+ import { allCommands, matchingCommands, passesIfFilter } from './matcher.js'
86
98
  import { type HookRunner, type HookRunResult, runAgentHook, runHookCommand, runHttpHook, runMcpToolHook, runPromptHook, timeoutMs } from './runners.js'
87
99
 
88
100
  export * from './config.js'
@@ -122,7 +134,17 @@ export function stopHookBlockCap(env: Record<string, string | undefined> = proce
122
134
  }
123
135
 
124
136
  async function runNotifyHooks(commands: HookCommand[], payload: unknown, runner: HookRunner): Promise<HookRunResult[]> {
125
- return await Promise.all(commands.map((command) => runner(command, payload, timeoutMs(command))))
137
+ // Non-tool events: a hook carrying `if` never runs, as Claude documents.
138
+ const runnable = commands.filter((command) => passesIfFilter(command, undefined))
139
+ return await Promise.all(runnable.map((command) => runner(command, payload, timeoutMs(command))))
140
+ }
141
+
142
+ /** The text of a result's content blocks, for Claude-shaped tool_response fields. */
143
+ function textContent(content: ReadonlyArray<{ type: string; text?: string }>): string {
144
+ return content
145
+ .filter((block) => block.type === 'text' && typeof block.text === 'string')
146
+ .map((block) => block.text)
147
+ .join('\n')
126
148
  }
127
149
 
128
150
  /** pi's lifecycle vocabularies differ from Claude's documented ones. The matcher is
@@ -153,6 +175,8 @@ export default function hooksExtension(pi: ExtensionAPI) {
153
175
  let hooksDisabled = false
154
176
  /** Which settings file each resolved entry came from, for the /hooks viewer. */
155
177
  const hookSources = new Map<HookMatcher, string>()
178
+ /** PreToolUse additionalContext per tool call, delivered alongside its result. */
179
+ const pendingToolContext = new Map<string, string[]>()
156
180
  /** Claude sends session_id, transcript_path, cwd and effort on every payload. */
157
181
  const commonPayload = (ctx: ExtensionContext): Record<string, unknown> => {
158
182
  const common: Record<string, unknown> = { session_id: ctx.sessionManager.getSessionId(), cwd: ctx.cwd, permission_mode: permissionMode }
@@ -280,6 +304,7 @@ export default function hooksExtension(pi: ExtensionAPI) {
280
304
  // carry into the next session, so reset before any early return (disableAllHooks below).
281
305
  stopHookActive = false
282
306
  stopHookBlockCount = 0
307
+ pendingToolContext.clear()
283
308
  const trusted = await isProjectApproved(ctx)
284
309
  // Claude's CLAUDE_PROJECT_DIR is the project root, not the session cwd; a hook
285
310
  // referencing $CLAUDE_PROJECT_DIR/.claude/hooks/helper.sh must resolve from a
@@ -325,8 +350,14 @@ export default function hooksExtension(pi: ExtensionAPI) {
325
350
  })
326
351
 
327
352
  pi.on('tool_call', async (event, ctx) => {
328
- const decision = await runPreToolUse(config, event.toolName, event.input, boundRunner(ctx, { tool_use_id: event.toolCallId }), mcpAliases.get(event.toolName), (message) => ctx.ui.notify(message, 'warning'))
329
- if (!decision.block) return undefined
353
+ const anchors = { cwd: ctx.cwd, projectRoot: projectDir || ctx.cwd, home: os.homedir() }
354
+ const decision = await runPreToolUse(config, event.toolName, event.input, boundRunner(ctx, { tool_use_id: event.toolCallId }), mcpAliases.get(event.toolName), (message) => ctx.ui.notify(message, 'warning'), anchors)
355
+ if (!decision.block) {
356
+ // additionalContext is delivered alongside the tool result, so stash it for
357
+ // this call's tool_result to append.
358
+ if (decision.context && decision.context.length > 0) pendingToolContext.set(event.toolCallId, decision.context)
359
+ return undefined
360
+ }
330
361
  // Claude's "ask": prompt the user and let the call through if they approve.
331
362
  // With no UI (headless) the block stands, which is the safe default.
332
363
  if (decision.ask && ctx.hasUI) {
@@ -341,20 +372,45 @@ export default function hooksExtension(pi: ExtensionAPI) {
341
372
  // stderr) and additionalContext are appended, which is where Claude documents they
342
373
  // land. The failure branch shows the hook's stderr to the model too ("Shows stderr
343
374
  // to Claude; the tool already failed"), it just cannot block a call that failed.
375
+ // Payloads report the Claude vocabulary (names, input shapes, and the documented
376
+ // Bash/Write response shapes; see claude-tools), and a schema-valid
377
+ // updatedToolOutput replaces the output the model sees.
344
378
  pi.on('tool_result', async (event, ctx) => {
345
379
  const alias = mcpAliases.get(event.toolName)
346
- const names = alias ? [event.toolName, alias] : [event.toolName]
347
- const response = { content: event.content, details: event.details, isError: event.isError }
380
+ const translatedName = alias ?? claudeToolName(event.toolName)
381
+ const names = translatedName ? [event.toolName, translatedName] : [event.toolName]
348
382
  const eventName = event.isError ? 'PostToolUseFailure' : 'PostToolUse'
349
- const commands = matchingCommands(event.isError ? config.PostToolUseFailure : config.PostToolUse, names)
350
- if (commands.length === 0) return
351
- const payload = { hook_event_name: eventName, tool_name: alias ?? event.toolName, tool_input: event.input, tool_response: response }
383
+ // Contexts stashed by this call's PreToolUse hooks land next to the result even
384
+ // when no PostToolUse hook is configured.
385
+ const pending = pendingToolContext.get(event.toolCallId) ?? []
386
+ pendingToolContext.delete(event.toolCallId)
387
+ const anchors = { cwd: ctx.cwd, projectRoot: projectDir || ctx.cwd, home: os.homedir() }
388
+ const target = { piName: event.toolName, claudeName: translatedName, input: event.input, anchors }
389
+ const commands = matchingCommands(event.isError ? config.PostToolUseFailure : config.PostToolUse, names).filter((command) => passesIfFilter(command, target))
390
+ if (commands.length === 0 && pending.length === 0) return
391
+ const translatedInput = alias === undefined ? claudeToolInput(event.toolName, event.input, ctx.cwd) : undefined
392
+ const response = (alias === undefined && !event.isError ? claudeToolResponse(event.toolName, event.input, textContent(event.content), event.isError, ctx.cwd) : undefined) ?? { content: event.content, details: event.details, isError: event.isError }
393
+ const payload = { hook_event_name: eventName, tool_name: translatedName ?? event.toolName, tool_input: translatedInput ?? event.input, tool_response: response }
352
394
  const run = boundRunner(ctx, { tool_use_id: event.toolCallId })
353
395
  const results = await Promise.all(commands.map((command) => run(command, payload, timeoutMs(command))))
354
396
  surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
355
- const feedback = results.flatMap((result) => postToolFeedback(result, eventName, event.isError))
356
- if (feedback.length === 0) return
357
- return { content: [...event.content, ...feedback.map((text) => ({ type: 'text' as const, text }))] }
397
+ // Claude's updatedToolOutput replaces the output the model sees; a value that
398
+ // doesn't match the tool's output schema is ignored, MCP output passes through
399
+ // unvalidated, and a failed call keeps its error output.
400
+ const replacement = event.isError
401
+ ? undefined
402
+ : results
403
+ .filter((result) => !result.timedOut)
404
+ .map((result) => {
405
+ const parsed = tryParseJson(result.stdout)
406
+ const value = alias !== undefined ? (parsed?.updatedMCPToolOutput ?? parsed?.updatedToolOutput) : parsed?.updatedToolOutput
407
+ return value === undefined ? undefined : piToolOutput(event.toolName, value, alias !== undefined)
408
+ })
409
+ .find((text) => text !== undefined)
410
+ const feedback = [...pending, ...results.flatMap((result) => postToolFeedback(result, eventName, event.isError))]
411
+ if (replacement === undefined && feedback.length === 0) return
412
+ const base = replacement !== undefined ? [{ type: 'text' as const, text: replacement }] : event.content
413
+ return { content: [...base, ...feedback.map((text) => ({ type: 'text' as const, text }))] }
358
414
  })
359
415
 
360
416
  // Claude's PreToolUse for Bash, extended to a command the user runs directly with the
@@ -370,7 +426,7 @@ export default function hooksExtension(pi: ExtensionAPI) {
370
426
  // no execution result and fires only before the command runs, so there is deliberately
371
427
  // no PostToolUse for it.
372
428
  pi.on('user_bash', async (event, ctx) => {
373
- const decision = await runPreToolUse(config, 'bash', { command: event.command }, boundRunner(ctx), 'Bash', (message) => ctx.ui.notify(message, 'warning'))
429
+ const decision = await runPreToolUse(config, 'bash', { command: event.command }, boundRunner(ctx), 'Bash', (message) => ctx.ui.notify(message, 'warning'), { cwd: ctx.cwd, projectRoot: projectDir || ctx.cwd, home: os.homedir() })
374
430
  if (!decision.block) return undefined
375
431
  // Claude's "ask": prompt before running and let the command through on approval; with
376
432
  // no UI (headless) the block stands, the same safe default as the tool_call path.
@@ -420,7 +476,9 @@ export default function hooksExtension(pi: ExtensionAPI) {
420
476
  void runNotifyHooks(notifyCommands, { hook_event_name: 'Notification', notification_type: 'idle_prompt', message: 'pi is waiting for your input' }, boundRunner(ctx)).catch(() => {})
421
477
  }
422
478
 
423
- const commands = matchingCommands(config.Stop, 'Stop')
479
+ // Stop has no matcher support (a stray matcher is ignored, as Claude documents)
480
+ // and an `if`-carrying hook never runs on a non-tool event.
481
+ const commands = allCommands(config.Stop).filter((command) => passesIfFilter(command, undefined))
424
482
  if (commands.length === 0) {
425
483
  stopHookActive = false
426
484
  return
@@ -435,9 +493,18 @@ export default function hooksExtension(pi: ExtensionAPI) {
435
493
  const block = results
436
494
  .filter((result) => !result.timedOut)
437
495
  .map((result) => {
438
- if (result.code === 2) return { block: true, reason: result.stderr.trim() || 'Stop blocked by hook' }
439
496
  const parsed = tryParseJson(result.stdout)
440
- if (parsed?.decision === 'block') return { block: true, reason: parsed.reason ?? 'Stop blocked by hook' }
497
+ if (result.code === 2) return { block: true, reason: jsonBlockVerdict(parsed, 'Stop blocked by hook')?.reason ?? (result.stderr.trim() || 'Stop blocked by hook') }
498
+ // Any JSON blocking spelling counts, including the prompt/agent hook reply
499
+ // schemas (permissionDecision deny, ok:false), which arrive as stdout here.
500
+ const verdict = jsonBlockVerdict(parsed, 'Stop blocked by hook')
501
+ if (verdict) return { block: true, reason: verdict.reason }
502
+ // Claude's non-error continue: additionalContext feeds back and the
503
+ // conversation continues so Claude can act on it. It rides the same
504
+ // continuation path (and the same block cap) so a hook emitting it every
505
+ // firing cannot loop the turn forever.
506
+ const context = parsed?.hookSpecificOutput?.additionalContext
507
+ if (typeof context === 'string' && context.length > 0) return { block: true, reason: context }
441
508
  return { block: false, reason: '' }
442
509
  })
443
510
  .find((verdict) => verdict.block)
@@ -4,6 +4,8 @@
4
4
  * commands an event fires. Owns the module-level compiled-matcher cache.
5
5
  */
6
6
 
7
+ import { matchesBashRules } from '../internal/bash-rules.js'
8
+ import { matchesPathRules, type PathAnchors } from '../internal/path-rules.js'
7
9
  import type { HookCommand, HookMatcher } from './config.js'
8
10
 
9
11
  /** Claude's rule: a matcher of only letters, digits, `_`, `-`, spaces, `,` and `|`
@@ -106,14 +108,11 @@ function withCommand(raw: HookCommand): HookCommand {
106
108
  return identity !== undefined && typeof raw.command !== 'string' ? { ...raw, command: identity } : raw
107
109
  }
108
110
 
109
- /** Command specs whose matcher applies to any of the given tool/source names.
110
- * Multiple candidates let one event offer both the pi name and its Claude alias. */
111
- export function matchingCommands(matchers: HookMatcher[] | undefined, names: string | readonly string[]): HookCommand[] {
112
- const candidates = typeof names === 'string' ? [names] : names
111
+ function collectCommands(matchers: HookMatcher[] | undefined, applies: (entry: HookMatcher) => boolean): HookCommand[] {
113
112
  const result: HookCommand[] = []
114
113
  const seen = new Set<string>()
115
114
  for (const entry of matchers ?? []) {
116
- if (!matcherApplies(entry.matcher, candidates)) continue
115
+ if (!applies(entry)) continue
117
116
  for (const raw of (entry.hooks ?? []).filter(isRunnableHook)) {
118
117
  const hook = withCommand(raw)
119
118
  // Claude runs a handler defined in more than one settings file once.
@@ -124,3 +123,53 @@ export function matchingCommands(matchers: HookMatcher[] | undefined, names: str
124
123
  }
125
124
  return result
126
125
  }
126
+
127
+ /** Command specs whose matcher applies to any of the given tool/source names.
128
+ * Multiple candidates let one event offer both the pi name and its Claude alias. */
129
+ export function matchingCommands(matchers: HookMatcher[] | undefined, names: string | readonly string[]): HookCommand[] {
130
+ const candidates = typeof names === 'string' ? [names] : names
131
+ return collectCommands(matchers, (entry) => matcherApplies(entry.matcher, candidates))
132
+ }
133
+
134
+ /** Command specs for an event without matcher support (Stop, UserPromptSubmit): a
135
+ * stray `matcher` on such an event is silently ignored, as Claude documents, so
136
+ * every entry's hooks run. */
137
+ export function allCommands(matchers: HookMatcher[] | undefined): HookCommand[] {
138
+ return collectCommands(matchers, () => true)
139
+ }
140
+
141
+ /** The tool call an `if` filter evaluates against; absent on non-tool events. */
142
+ export interface IfFilterTarget {
143
+ piName: string
144
+ claudeName?: string
145
+ input: unknown
146
+ anchors: PathAnchors
147
+ }
148
+
149
+ /** Claude's `if` handler field: permission-rule syntax evaluated only on tool
150
+ * events; on any other event a hook carrying `if` never runs. A bare tool name
151
+ * matches by name; `Bash(pattern)` evaluates against the command via the shared
152
+ * bash-rule matcher and file-tool patterns against the path via the shared
153
+ * permission path rules. A pattern for any other tool matches nothing, which is
154
+ * also what an unparseable rule does. */
155
+ export function passesIfFilter(hook: HookCommand, target: IfFilterTarget | undefined): boolean {
156
+ if (hook.if === undefined) return true
157
+ if (target === undefined) return false
158
+ const parsed = /^([A-Za-z_|]+?)(?:\((.*)\))?$/.exec(hook.if.trim())
159
+ if (!parsed) return false
160
+ const fold = (name: string): string => name.toLowerCase().replaceAll('-', '_')
161
+ const ruleTools = new Set(parsed[1].split('|').map(fold))
162
+ const toolMatches = ruleTools.has(fold(target.piName)) || (target.claudeName !== undefined && ruleTools.has(fold(target.claudeName)))
163
+ if (!toolMatches) return false
164
+ const pattern = parsed[2]
165
+ if (pattern === undefined) return true
166
+ const input = target.input as Record<string, unknown> | null
167
+ if (fold(target.piName) === 'bash' || (target.claudeName !== undefined && fold(target.claudeName) === 'bash')) {
168
+ const command = typeof input?.command === 'string' ? input.command : ''
169
+ return command.length > 0 && matchesBashRules(command, [pattern])
170
+ }
171
+ let filePath = ''
172
+ if (typeof input?.path === 'string') filePath = input.path
173
+ else if (typeof input?.file_path === 'string') filePath = input.file_path
174
+ return filePath.length > 0 && matchesPathRules(filePath, [pattern], target.anchors)
175
+ }
@@ -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.16",
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",