pi-code 1.0.63 → 1.0.64

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.
Files changed (48) hide show
  1. package/README.md +1 -1
  2. package/extensions/commands.ts +9 -6
  3. package/extensions/context-imports.ts +3 -3
  4. package/extensions/git-checkpoint.ts +137 -21
  5. package/extensions/hooks/config.ts +16 -10
  6. package/extensions/hooks/decisions.ts +9 -9
  7. package/extensions/hooks/index.ts +13 -9
  8. package/extensions/hooks/matcher.ts +12 -7
  9. package/extensions/hooks/runners.ts +7 -28
  10. package/extensions/internal/agent-run.ts +1 -1
  11. package/extensions/internal/bash-rules.ts +1 -2
  12. package/extensions/internal/claude-tool-names.ts +4 -7
  13. package/extensions/internal/command-file.ts +2 -3
  14. package/extensions/internal/external-imports.ts +4 -5
  15. package/extensions/internal/goal-evaluator.ts +4 -3
  16. package/extensions/internal/instruction-events.ts +5 -5
  17. package/extensions/internal/managed-settings.ts +1 -1
  18. package/extensions/internal/mcp-oauth.ts +13 -3
  19. package/extensions/internal/model-complete.ts +7 -1
  20. package/extensions/internal/model-lookup.ts +10 -3
  21. package/extensions/internal/path-rules.ts +10 -13
  22. package/extensions/internal/plugins.ts +5 -5
  23. package/extensions/internal/process-tree.ts +39 -0
  24. package/extensions/internal/project-approval.ts +7 -7
  25. package/extensions/internal/project-root.ts +13 -13
  26. package/extensions/internal/settings-chain.ts +19 -0
  27. package/extensions/internal/settings-watch.ts +3 -1
  28. package/extensions/internal/shell-resolve.ts +3 -1
  29. package/extensions/internal/tool-target.ts +4 -5
  30. package/extensions/internal/values.ts +20 -4
  31. package/extensions/mcp/config.ts +11 -4
  32. package/extensions/mcp/index.ts +40 -4
  33. package/extensions/mcp/policy.ts +14 -12
  34. package/extensions/mcp/transport.ts +2 -8
  35. package/extensions/memory.ts +4 -4
  36. package/extensions/notify.ts +2 -7
  37. package/extensions/output-styles.ts +22 -17
  38. package/extensions/question.ts +2 -7
  39. package/extensions/skills.ts +5 -5
  40. package/extensions/subagent/agents.ts +4 -4
  41. package/extensions/subagent/background.ts +35 -41
  42. package/extensions/subagent/child.ts +2 -2
  43. package/extensions/subagent/modes.ts +18 -13
  44. package/extensions/subagent/params.ts +1 -2
  45. package/extensions/subagent/run.ts +3 -14
  46. package/extensions/thinking.ts +3 -3
  47. package/extensions/web.ts +3 -4
  48. package/package.json +1 -1
package/README.md CHANGED
@@ -56,7 +56,7 @@ Each topic links to its own doc with the full contract and any divergences from
56
56
  - **[Goal](docs/goal.md)**: `/goal <condition>` keeps the session working until a separate model check confirms the condition holds, with status, clear, block cap, background-work deferral, and resume.
57
57
  - **[Session extras](docs/session-extras.md)** — project trust, plan mode, todos, checkpoints/rewind, AskUserQuestion, notifications, think keywords, session titles, `/context`, `/init`.
58
58
 
59
- Slash commands: `/init`, `/context`, `/goal`, `/memory`, `/todos`, `/rewind`, `/tasks`, `/agents`, `/plan`, `/mcp`, `/hooks`, and `/output-style`, alongside your own `/dir:name` commands, `/skill:name` skills, `/plugin:name` plugin commands, and each connected server's `/mcp__server__prompt` prompts.
59
+ Slash commands: `/init`, `/context`, `/goal`, `/memory`, `/todos`, `/rewind`, `/tasks`, `/agents`, `/plan`, `/plan-todos`, `/mcp`, `/hooks`, and `/output-style`, alongside your own `/dir:name` commands, `/skill:name` skills, `/plugin:name` plugin commands, and each connected server's `/mcp__server__prompt` prompts.
60
60
 
61
61
  pi has no general permission system, so most of what Claude routes through a permission prompt maps to hard behavior here: `allowed-tools` restricts the turn's tool set instead of pre-approving calls, and a hook that times out on PreToolUse or UserPromptSubmit fails closed. A hook's `permissionDecision: "ask"` is the exception: it shows a confirm dialog and lets the call through when you approve (a headless run has no dialog, so it blocks). Where a Claude restriction cannot be expressed at all (an argument-scoped grant in an agent's `tools:`), the definition is rejected rather than widened.
62
62
 
@@ -49,6 +49,7 @@ import { type CommandExec, expandDynamicContent, resolvePowershellBinary, spanEx
49
49
  import { claudeConfigDir } from './internal/config-dir.js'
50
50
  import { claudeEffortLevel } from './internal/effort.js'
51
51
  import { managedSettingsFile, readManagedSettings } from './internal/managed-settings.js'
52
+ import { findModel } from './internal/model-lookup.js'
52
53
  import { capForContext } from './internal/output-guard.js'
53
54
  import { matchesPathRules } from './internal/path-rules.js'
54
55
  import { type InstalledPlugin, installedPlugins, pluginComponentPath } from './internal/plugins.js'
@@ -56,8 +57,9 @@ import { isProjectApproved } from './internal/project-approval.js'
56
57
  import { ancestorDirs, repoRoot } from './internal/project-root.js'
57
58
  import { agentNamesIn, matchesAgentRules, matchesDomainRules, matchesSkillRules } from './internal/scope-rules.js'
58
59
  import { claudeSettingsChain } from './internal/settings-chain.js'
60
+ import { fileToolTarget } from './internal/tool-target.js'
59
61
  import { createTurnOverride } from './internal/turn-override.js'
60
- import { errorMessage, isDirectory } from './internal/values.js'
62
+ import { errorMessage, isDirectory, parseNumericEnv } from './internal/values.js'
61
63
 
62
64
  /** Just enough of pi's Model to match and restore; getAvailable returns these. */
63
65
  interface ModelLike {
@@ -72,8 +74,7 @@ interface ModelLike {
72
74
  * subagent uses). `inherit` and an unresolvable name leave the model unchanged. */
73
75
  function resolveCommandModel(model: string | undefined, available: ReadonlyArray<ModelLike>): ModelLike | undefined {
74
76
  if (!model || model.toLowerCase() === 'inherit') return undefined
75
- const needle = model.toLowerCase()
76
- return available.find((m) => m.id.toLowerCase() === needle) ?? available.find((m) => m.id.toLowerCase().includes(needle) || (m.name ?? '').toLowerCase().includes(needle))
77
+ return findModel(model, available)
77
78
  }
78
79
 
79
80
  /** Substitute ${CLAUDE_*} into a path rule. A variable that expands to an absolute
@@ -276,8 +277,8 @@ const DEFAULT_TOOL_CHAR_BUDGET = 15_000
276
277
  * 1% of the model's context window (1% of the tokens at ~4 characters per token
277
278
  * is window / 25), else Claude's documented default. */
278
279
  export function slashCommandBudget(contextWindow: number | undefined, env: Record<string, string | undefined> = process.env): number {
279
- const override = Number.parseInt(env.SLASH_COMMAND_TOOL_CHAR_BUDGET ?? '', 10)
280
- if (Number.isInteger(override) && override > 0) return override
280
+ const override = parseNumericEnv(env.SLASH_COMMAND_TOOL_CHAR_BUDGET)
281
+ if (override !== undefined && Number.isInteger(override) && override > 0) return override
281
282
  if (contextWindow && contextWindow > 0) return Math.floor(contextWindow / 25)
282
283
  return DEFAULT_TOOL_CHAR_BUDGET
283
284
  }
@@ -394,7 +395,9 @@ export default function commandsExtension(pi: ExtensionAPI) {
394
395
  if (event.toolName === 'slash_command') return scopeVerdict(pendingSkillRules, 'slash_command', 'Command', text(input.command), (rules) => matchesSkillRules(text(input.command), rules))
395
396
  const rules = pendingPathRules?.[event.toolName as PathRuleTool]
396
397
  if (!rules) return
397
- const filePath = typeof input.path === 'string' ? input.path : ''
398
+ // pi's read/edit/write accept `file_path` as an alias for `path`; the shared reader
399
+ // handles both, and the paired guard in hooks/matcher.ts reads both too.
400
+ const filePath = fileToolTarget(event) ?? ''
398
401
  const anchors = { cwd: ctx.cwd, projectRoot: repoRoot(ctx.cwd) ?? ctx.cwd, home: os.homedir() }
399
402
  if (filePath && matchesPathRules(filePath, rules, anchors)) return
400
403
  return {
@@ -743,9 +743,6 @@ function additionalDirsAddition(extras: Array<{ path: string; content: string; d
743
743
  return addition
744
744
  }
745
745
 
746
- /** The `## Imported context (@)` section for every resolved @import, with the
747
- * budget-exhaustion notice, announcing each as an `include`. Empty when nothing
748
- * was imported. */
749
746
  /** The refusal notice: every existing file an @import named that its importer may not
750
747
  * reach. Claude asks about these through an approval dialog and loads the ones you
751
748
  * allow; pi-code refuses them, and this is what says so. Silence was the real defect:
@@ -759,6 +756,9 @@ function refusedImportsAddition(refused: Set<string>): string {
759
756
  return `\n\n## Imports not loaded (@)\n\nThese files resolve outside what the file importing them may read, so their contents are not in context:\n\n${list}`
760
757
  }
761
758
 
759
+ /** The `## Imported context (@)` section for every resolved @import, with the
760
+ * budget-exhaustion notice, announcing each as an `include`. Empty when nothing
761
+ * was imported. */
762
762
  function importedAddition(imported: ImportedFile[], budget: ImportBudget, home: string, projectRoot: string, announce: (event: InstructionLoadEvent) => void): string {
763
763
  if (imported.length === 0) return ''
764
764
  const section = imported.map((entry) => `### ${entry.path}\n\n${stripBlockComments(entry.body)}`).join('\n\n')
@@ -3,10 +3,13 @@
3
3
  *
4
4
  * Claude Code style /rewind built on a per-session shadow git repo.
5
5
  *
6
- * Snapshots commit the entire working tree (untracked files included, the
7
- * project's .gitignore is honored) into a bare repo under
6
+ * Snapshots commit the files this session's edit tools touched (the project's
7
+ * .gitignore and .git/info/exclude are honored) into a bare repo under
8
8
  * ~/.pi/agent/checkpoints/<session>, using --git-dir/--work-tree so the
9
- * project's own git state is never touched. Each user prompt gets one
9
+ * project's own git state is never touched. Scope is the edit set, not the
10
+ * working tree: Claude tracks "only files that have been edited within the
11
+ * current session", and a whole-tree snapshot instead sizes every checkpoint
12
+ * by cwd, which has no bound when cwd is $HOME. Each user prompt gets one
10
13
  * checkpoint persisted as {entryId, ref, prompt, createdAt} in the session
11
14
  * file, so /rewind works across restarts, resumes, and forks. Code restore
12
15
  * checks the snapshot out over the working tree, resetting file contents to
@@ -17,13 +20,22 @@ import { createHash } from 'node:crypto'
17
20
  import * as fs from 'node:fs'
18
21
  import * as os from 'node:os'
19
22
  import * as path from 'node:path'
20
- import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from '@earendil-works/pi-coding-agent'
23
+ import { type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext, getAgentDir } from '@earendil-works/pi-coding-agent'
21
24
  import { claudeConfigDir } from './internal/config-dir.js'
25
+ import { readSettingsFile } from './internal/settings-chain.js'
26
+ import { fileToolTarget } from './internal/tool-target.js'
22
27
  import { contentText, errorMessage } from './internal/values.js'
23
28
 
24
29
  const CUSTOM_TYPE = 'git-checkpoint'
25
30
  /** Sidecar inside the bare shadow repo recording the work tree it snapshots. */
26
31
  const WORK_TREE_FILE = 'pi-work-tree'
32
+ /** pi's tools that change a file. `read` is deliberately absent: checkpoint scope is
33
+ * the set of files the session edited, and that set is what bounds the snapshot. */
34
+ const EDIT_TOOLS: ReadonlySet<string> = new Set(['edit', 'write'])
35
+ /** A run's checkpoint commit is published under a ref rather than recorded as a raw
36
+ * sha, so a file first edited in a later turn of the same run can still fold its
37
+ * pre-edit baseline into that run's checkpoint by moving the ref. */
38
+ const CHECKPOINT_REF_PREFIX = 'refs/pi-code/checkpoints'
27
39
  const PROMPT_SNIPPET_LENGTH = 60
28
40
  const RESTORE_MODES = ['Code and conversation', 'Conversation only', 'Code only']
29
41
 
@@ -34,9 +46,9 @@ interface Checkpoint {
34
46
  createdAt: string
35
47
  }
36
48
 
37
- /** Claude deletes checkpoints after 30 days (cleanupPeriodDays). Shadow repos hold
38
- * full snapshots of every non-ignored file, so unbounded retention grows under $HOME
39
- * for the life of the machine. */
49
+ /** Claude deletes checkpoints after 30 days (cleanupPeriodDays). Shadow repos hold a
50
+ * snapshot per edited file, so unbounded retention grows under $HOME for the life of
51
+ * the machine. */
40
52
  export const CHECKPOINT_RETENTION_DAYS = 30
41
53
 
42
54
  /** The retention period in effect: Claude keeps checkpoints for 30 days and says to
@@ -44,13 +56,8 @@ export const CHECKPOINT_RETENTION_DAYS = 30
44
56
  * setting about the user's own disk belongs; a non-positive or unreadable value keeps the
45
57
  * default rather than sweeping everything away. */
46
58
  export function checkpointRetentionDays(home: string = os.homedir()): number {
47
- try {
48
- const parsed: unknown = JSON.parse(fs.readFileSync(path.join(claudeConfigDir(home), 'settings.json'), 'utf-8'))
49
- const declared = (parsed as { cleanupPeriodDays?: unknown }).cleanupPeriodDays
50
- if (typeof declared === 'number' && Number.isFinite(declared) && declared > 0) return declared
51
- } catch {
52
- // No user settings, or unreadable: the default period stands.
53
- }
59
+ const declared = readSettingsFile(path.join(claudeConfigDir(home), 'settings.json'))?.cleanupPeriodDays
60
+ if (typeof declared === 'number' && Number.isFinite(declared) && declared > 0) return declared
54
61
  return CHECKPOINT_RETENTION_DAYS
55
62
  }
56
63
 
@@ -163,6 +170,13 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
163
170
  let runNeedsSnapshot = true
164
171
  let shadowDir: string | undefined
165
172
  let workTree: string | undefined
173
+ // Absolute paths this session's edit tools targeted: the whole of what a checkpoint
174
+ // captures. Seeded on resume from the last commit, so a resumed session keeps
175
+ // snapshotting the files it was already tracking.
176
+ const touched = new Set<string>()
177
+ // The ref holding the in-flight run's checkpoint, moved as later baselines arrive.
178
+ let runRef: string | undefined
179
+ let refSeq = 0
166
180
 
167
181
  function gitShadow(args: string[]): ReturnType<ExtensionAPI['exec']> {
168
182
  if (!shadowDir || !workTree) return Promise.resolve({ stdout: '', stderr: 'shadow repo not initialized', code: 1, killed: false })
@@ -175,7 +189,7 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
175
189
  async function ensureShadow(ctx: ExtensionContext): Promise<void> {
176
190
  workTree = ctx.cwd
177
191
  const sessionFile = (ctx.sessionManager as { getSessionFile?: () => string | undefined }).getSessionFile?.()
178
- const checkpointsRoot = path.join(os.homedir(), '.pi', 'agent', 'checkpoints')
192
+ const checkpointsRoot = path.join(getAgentDir(), 'checkpoints')
179
193
  shadowDir = path.join(checkpointsRoot, sessionSlug(sessionFile))
180
194
  // A resumed session can arrive from a different directory than the one the shadow
181
195
  // snapshotted; restoring those commits here would silently overwrite unrelated
@@ -233,6 +247,79 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
233
247
  }
234
248
  }
235
249
 
250
+ /** A tracked file as git names it: a work-tree-relative slash-separated path, or
251
+ * undefined for anything outside the work tree, which git refuses as a pathspec.
252
+ * Paths are compared in this form throughout, never as absolutes: git echoes a
253
+ * pathspec back verbatim, while the same file can spell its absolute path two ways
254
+ * on Windows (a short 8.3 temp directory against its long form). */
255
+ function workTreePath(file: string): string | undefined {
256
+ if (!workTree) return undefined
257
+ const rel = path.relative(workTree, file)
258
+ if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) return undefined
259
+ return rel.split(path.sep).join('/')
260
+ }
261
+
262
+ /** The paths the last checkpoint commit holds. A tracked file deleted since then still
263
+ * matches a pathspec through this, so its removal is staged instead of the stale index
264
+ * entry silently riding along into the next checkpoint. */
265
+ async function committedPaths(): Promise<Set<string>> {
266
+ const listed = await gitShadow(['ls-tree', '-r', '--name-only', 'HEAD'])
267
+ if (listed.code !== 0) return new Set()
268
+ return new Set(listed.stdout.split('\n').filter(Boolean))
269
+ }
270
+
271
+ /** git rejects the whole pathspec when one entry is ignored, so the ignored paths are
272
+ * dropped before the add rather than costing the run its checkpoint. */
273
+ async function withoutIgnored(files: string[]): Promise<string[]> {
274
+ const checked = await gitShadow(['-c', 'core.quotePath=false', 'check-ignore', '--', ...files])
275
+ if (checked.code !== 0) return files
276
+ const ignored = new Set(checked.stdout.split('\n').filter(Boolean))
277
+ return files.filter((file) => !ignored.has(file))
278
+ }
279
+
280
+ /** The tracked files git will accept: inside the work tree, and either on disk or in
281
+ * the last commit. A pathspec matching neither aborts the entire add, taking the
282
+ * checkpoint with it. */
283
+ async function addablePaths(): Promise<string[]> {
284
+ const inside: Array<{ file: string; rel: string }> = []
285
+ for (const file of touched) {
286
+ const rel = workTreePath(file)
287
+ if (rel !== undefined) inside.push({ file, rel })
288
+ }
289
+ if (inside.length === 0) return []
290
+ const committed = await committedPaths()
291
+ const live = inside.filter(({ file, rel }) => fs.existsSync(file) || committed.has(rel)).map(({ rel }) => rel)
292
+ return live.length === 0 ? [] : withoutIgnored(live)
293
+ }
294
+
295
+ /** Point this run's ref at the commit. Publishing a ref rather than the raw sha lets a
296
+ * baseline captured later in the run move the checkpoint forward without rewriting a
297
+ * commit that an earlier checkpoint may share. */
298
+ async function publishRun(sha: string, createdAt: string): Promise<{ ref: string; createdAt: string }> {
299
+ const ref = `${CHECKPOINT_REF_PREFIX}/${Date.now().toString(36)}-${++refSeq}`
300
+ const update = await gitShadow(['update-ref', ref, sha])
301
+ if (update.code !== 0) return { ref: sha, createdAt }
302
+ runRef = ref
303
+ return { ref, createdAt }
304
+ }
305
+
306
+ /** Fold a file's pre-edit content into the run's checkpoint. A file first edited part
307
+ * way through a run is absent from that run's pre-run snapshot, so without this its
308
+ * checkpoint holds no baseline and /rewind cannot undo that first edit. */
309
+ async function captureBaseline(file: string): Promise<void> {
310
+ const rel = workTreePath(file)
311
+ if (!runRef || rel === undefined || !fs.existsSync(file)) return
312
+ if ((await withoutIgnored([rel])).length === 0) return
313
+ const add = await gitShadow(['add', '--', rel])
314
+ if (add.code !== 0) return
315
+ // A commit, never an amend: the run's checkpoint can be a commit an earlier
316
+ // checkpoint also points at, and rewriting it would strand that one.
317
+ const commit = await commitShadow([])
318
+ if (commit.code !== 0) return
319
+ const sha = await gitShadow(['rev-parse', 'HEAD'])
320
+ if (sha.code === 0) await gitShadow(['update-ref', runRef, sha.stdout.trim()])
321
+ }
322
+
236
323
  /** `checkout -f <ref> -- .` errors when the ref's tree holds no files, so an empty
237
324
  * snapshot restores as a no-op rather than vetoing the whole rewind. */
238
325
  async function snapshotIsEmpty(ref: string): Promise<boolean> {
@@ -242,16 +329,21 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
242
329
 
243
330
  async function snapshot(): Promise<{ ref: string; createdAt: string } | undefined> {
244
331
  const createdAt = new Date().toISOString()
245
- const add = await gitShadow(['add', '-A'])
246
- if (add.code !== 0) return undefined
332
+ const paths = await addablePaths()
333
+ if (paths.length > 0) {
334
+ const add = await gitShadow(['add', '--', ...paths])
335
+ if (add.code !== 0) return undefined
336
+ }
247
337
  // Decide "nothing changed" from the index, not from the commit exit code: a commit can
248
338
  // also fail on the user's global signing or hooks config, and reusing HEAD then would
249
339
  // record a ref that predates the current tree, so /rewind restores the wrong state.
250
- const status = await gitShadow(['status', '--porcelain'])
340
+ // Untracked files are excluded because the work tree is full of files this session
341
+ // never edited; counting them would make every run look changed.
342
+ const status = await gitShadow(['status', '--porcelain', '--untracked-files=no'])
251
343
  const nothingChanged = status.code === 0 && status.stdout.trim() === ''
252
344
  if (nothingChanged) {
253
345
  const head = await gitShadow(['rev-parse', 'HEAD'])
254
- if (head.code === 0) return { ref: head.stdout.trim(), createdAt }
346
+ if (head.code === 0) return publishRun(head.stdout.trim(), createdAt)
255
347
  const empty = await commitShadow(['--allow-empty'])
256
348
  if (empty.code !== 0) return undefined
257
349
  } else {
@@ -259,7 +351,7 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
259
351
  if (commit.code !== 0) return undefined // real failure: do not record a stale ref
260
352
  }
261
353
  const sha = await gitShadow(['rev-parse', 'HEAD'])
262
- return sha.code === 0 ? { ref: sha.stdout.trim(), createdAt } : undefined
354
+ return sha.code === 0 ? publishRun(sha.stdout.trim(), createdAt) : undefined
263
355
  }
264
356
 
265
357
  /** Commit in the shadow repo, isolated from the user's global signing and hook config. */
@@ -300,7 +392,10 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
300
392
  // own tree even though the prior run left it false.
301
393
  pending = undefined
302
394
  runNeedsSnapshot = true
395
+ runRef = undefined
303
396
  await ensureShadow(ctx)
397
+ touched.clear()
398
+ for (const rel of await committedPaths()) touched.add(path.resolve(ctx.cwd, rel))
304
399
  checkpoints.clear()
305
400
  const stored: Checkpoint[] = []
306
401
  for (const entry of ctx.sessionManager.getEntries()) {
@@ -338,13 +433,34 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
338
433
  pending = await snapshot()
339
434
  })
340
435
 
436
+ // A checkpoint covers the files the session edited, so the tracked set grows as the
437
+ // model works. The baseline is taken here, before the tool writes, because once the
438
+ // tool has run the pre-edit content is gone.
439
+ pi.on('tool_call', async (event, ctx) => {
440
+ if (!EDIT_TOOLS.has(event.toolName)) return
441
+ const target = fileToolTarget(event)
442
+ if (target === undefined) return
443
+ const file = path.resolve(ctx.cwd, target)
444
+ if (touched.has(file)) return
445
+ touched.add(file)
446
+ await captureBaseline(file)
447
+ })
448
+
341
449
  pi.on('turn_end', async (_event, ctx) => {
342
450
  const snap = pending
343
451
  pending = undefined
344
452
  if (!snap) return
345
453
 
346
454
  const target = findLastUserMessage(ctx)
347
- if (!target || checkpoints.has(target.entryId)) return
455
+ if (!target) return
456
+ const recorded = checkpoints.get(target.entryId)
457
+ if (recorded) {
458
+ // A retry or follow-up re-ran agent_start and took a fresh snapshot, but this user
459
+ // message already has its checkpoint. Discard the new one and keep folding later
460
+ // baselines into the checkpoint that is actually recorded.
461
+ runRef = recorded.ref
462
+ return
463
+ }
348
464
 
349
465
  const checkpoint: Checkpoint = { entryId: target.entryId, ref: snap.ref, prompt: target.prompt, createdAt: snap.createdAt }
350
466
  checkpoints.set(checkpoint.entryId, checkpoint)
@@ -9,7 +9,7 @@ import * as path from 'node:path'
9
9
  import { readManagedSettings } from '../internal/managed-settings.js'
10
10
  import { type InstalledPlugin, pluginComponentPath, substitutePluginVars } from '../internal/plugins.js'
11
11
  import { claudeSettingsChain, readSettingsChain } from '../internal/settings-chain.js'
12
- import { errorMessage, isRecord } from '../internal/values.js'
12
+ import { errorMessage, escapeRegExp, isRecord } from '../internal/values.js'
13
13
 
14
14
  export interface HookCommand {
15
15
  type?: string
@@ -159,7 +159,7 @@ export function readAllowedHttpHookUrls(files: string[], managed: Record<string,
159
159
  export function httpUrlAllowed(url: string, allowlist: string[] | undefined): boolean {
160
160
  if (allowlist === undefined) return true
161
161
  return allowlist.some((pattern) => {
162
- const literal = pattern.split('*').map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`))
162
+ const literal = pattern.split('*').map(escapeRegExp)
163
163
  return new RegExp(`^${literal.join('.*')}$`).test(url)
164
164
  })
165
165
  }
@@ -182,7 +182,7 @@ export function loadHooks(files: string[], sources?: Map<HookMatcher, string>):
182
182
  * stays separate, so plugin entries carry their origin into the dedup key. */
183
183
  function stampOrigin(entries: HookMatcher[], origin: string): void {
184
184
  for (const entry of entries) {
185
- for (const hook of entry.hooks ?? []) hook.origin = origin
185
+ for (const hook of entry.hooks ?? []) if (isRecord(hook)) hook.origin = origin
186
186
  }
187
187
  }
188
188
 
@@ -209,10 +209,9 @@ function mergeHooksJson(config: HooksConfig, raw: string, source: string, source
209
209
  }
210
210
  for (const [event, matchers] of Object.entries(parsed?.hooks ?? {})) {
211
211
  if (!Array.isArray(matchers)) continue
212
- // Entries are validated here rather than where they run: a hand-edited settings
213
- // file that writes `hooks` as an object instead of a list used to throw out of
214
- // the tool_call handler, and pi turns that into an error result, so every tool
215
- // call for the rest of the session failed with an opaque type error.
212
+ // Entries are validated here rather than where they run: a malformed entry that
213
+ // throws from the tool_call handler becomes an error result there, so every tool
214
+ // call for the rest of the session fails with an opaque type error.
216
215
  const usable = matchers.filter((entry) => isUsableMatcher(entry, source, event))
217
216
  if (usable.length === 0) continue
218
217
  if (origin !== undefined) stampOrigin(usable, origin)
@@ -224,9 +223,6 @@ function mergeHooksJson(config: HooksConfig, raw: string, source: string, source
224
223
  }
225
224
  }
226
225
 
227
- /** Each enabled plugin's hooks (hooks/hooks.json, or wherever the manifest points),
228
- * with ${CLAUDE_PLUGIN_ROOT}/${CLAUDE_PLUGIN_DATA} substituted before parsing so a
229
- * hook can name its bundled scripts by real path. */
230
226
  /** The substitution here lands inside raw JSON, so values must arrive escaped:
231
227
  * an unescaped Windows root injected \U-style sequences, the parse threw, and
232
228
  * every hook the plugin declared silently vanished. */
@@ -270,6 +266,9 @@ function withoutUserConfigShellCommands(raw: string, source: string): string {
270
266
  return dropped ? JSON.stringify(parsed) : raw
271
267
  }
272
268
 
269
+ /** Each enabled plugin's hooks (hooks/hooks.json, or wherever the manifest points),
270
+ * with ${CLAUDE_PLUGIN_ROOT}/${CLAUDE_PLUGIN_DATA} substituted before parsing so a
271
+ * hook can name its bundled scripts by real path. */
273
272
  export function loadPluginHooks(config: HooksConfig, plugins: InstalledPlugin[], sources?: Map<HookMatcher, string>): void {
274
273
  for (const plugin of plugins) {
275
274
  const declared = plugin.manifest.hooks
@@ -304,6 +303,13 @@ function isUsableMatcher(entry: unknown, file: string, event: string): entry is
304
303
  console.warn(`pi-code-hooks: ignoring ${event} entry in ${file}: "hooks" must be a list`)
305
304
  return false
306
305
  }
306
+ // The list's entries too: the dispatcher reads `.type` of each, so one `null` (a
307
+ // hand-edit that removed an entry's body) failed every tool call for the session.
308
+ if (Array.isArray(candidate.hooks)) {
309
+ const objects = candidate.hooks.filter((hook: unknown) => isRecord(hook))
310
+ if (objects.length !== candidate.hooks.length) console.warn(`pi-code-hooks: ignoring ${candidate.hooks.length - objects.length} non-object hook(s) in a ${event} entry in ${file}`)
311
+ candidate.hooks = objects as HookCommand[]
312
+ }
307
313
  if (candidate.matcher !== undefined && typeof candidate.matcher !== 'string') {
308
314
  console.warn(`pi-code-hooks: ignoring ${event} entry in ${file}: "matcher" must be a string`)
309
315
  return false
@@ -170,15 +170,6 @@ export interface PreToolUseOutcome extends HookDecision {
170
170
  context?: string[]
171
171
  }
172
172
 
173
- /** Run PreToolUse hooks for a tool, in parallel as Claude does; the first blocking
174
- * verdict in config order wins. The payload reports the Claude vocabulary: the MCP
175
- * alias for MCP tools, and the documented name and tool_input shape for pi's
176
- * built-ins (see claude-tools). Every hook sees the original tool input;
177
- * hookSpecificOutput.updatedInput replaces the input in place as each hook
178
- * completes, translated back to the pi shape for a built-in (an incomplete rewrite
179
- * keeps the original input rather than corrupting it), so with several rewrites
180
- * the last to finish takes effect (the docs leave multi-rewrite ordering
181
- * unspecified). */
182
173
  /** Apply a hook's updatedInput rewrite in place, translating a built-in rewrite
183
174
  * back to the pi shape; an incomplete built-in rewrite keeps the original input
184
175
  * rather than corrupting it. */
@@ -215,6 +206,15 @@ function preToolContexts(results: HookRunResult[]): string[] {
215
206
  })
216
207
  }
217
208
 
209
+ /** Run PreToolUse hooks for a tool, in parallel as Claude does; the first blocking
210
+ * verdict in config order wins. The payload reports the Claude vocabulary: the MCP
211
+ * alias for MCP tools, and the documented name and tool_input shape for pi's
212
+ * built-ins (see claude-tools). Every hook sees the original tool input;
213
+ * hookSpecificOutput.updatedInput replaces the input in place as each hook
214
+ * completes, translated back to the pi shape for a built-in (an incomplete rewrite
215
+ * keeps the original input rather than corrupting it), so with several rewrites
216
+ * the last to finish takes effect (the docs leave multi-rewrite ordering
217
+ * unspecified). */
218
218
  export async function runPreToolUse(config: HooksConfig, toolName: string, toolInput: unknown, runner: HookRunner, claudeName?: string, onSystemMessage?: SystemMessageSink, anchors?: PathAnchors): Promise<PreToolUseOutcome> {
219
219
  const cwd = anchors?.cwd ?? process.cwd()
220
220
  const translatedName = claudeName ?? claudeToolName(toolName)
@@ -109,7 +109,7 @@ import { watchSettingsFiles } from '../internal/settings-watch.js'
109
109
  import { isSkillHooksEvent, SKILL_HOOKS_CHANNEL } from '../internal/skill-hooks.js'
110
110
  import { isSubagentPhaseEvent, SUBAGENT_CHANNEL } from '../internal/subagent-events.js'
111
111
  import { setSubagentStartHookRunner } from '../internal/subagent-hooks.js'
112
- import { contentText } from '../internal/values.js'
112
+ import { contentText, parseNumericEnv } from '../internal/values.js'
113
113
  import { claudeToolInput, claudeToolName, claudeToolResponse, piToolOutput } from './claude-tools.js'
114
114
  import { formatHooksSummary, type HookCommand, type HookMatcher, type HooksConfig, hookFiles, isBackgroundHook, loadHooks, loadManagedHooks, loadPluginHooks, mergeAgentEnvHooks, mergeSkillHooks, readAllowedHttpHookUrls, readDisableAllHooks, readSettingsDisableAllHooks } from './config.js'
115
115
  import { blockedToolCall, jsonBlockVerdict, postToolFeedback, promptContext, runPreToolUse, runUserPromptSubmit, surfaceSystemMessages, tryParseJson } from './decisions.js'
@@ -142,9 +142,9 @@ const DEFAULT_STOP_HOOK_BLOCK_CAP = 8
142
142
  * zero-cap that would suppress the very first block), else the default for a negative
143
143
  * or malformed value. */
144
144
  export function stopHookBlockCap(env: Record<string, string | undefined> = process.env): number {
145
- const override = Number.parseInt(env.CLAUDE_CODE_STOP_HOOK_BLOCK_CAP ?? '', 10)
145
+ const override = parseNumericEnv(env.CLAUDE_CODE_STOP_HOOK_BLOCK_CAP)
146
146
  if (override === 0) return Number.POSITIVE_INFINITY
147
- return Number.isInteger(override) && override > 0 ? override : DEFAULT_STOP_HOOK_BLOCK_CAP
147
+ return override !== undefined && Number.isInteger(override) && override > 0 ? override : DEFAULT_STOP_HOOK_BLOCK_CAP
148
148
  }
149
149
 
150
150
  async function runNotifyHooks(commands: HookCommand[], payload: unknown, runner: HookRunner): Promise<HookRunResult[]> {
@@ -337,10 +337,10 @@ export default function hooksExtension(pi: ExtensionAPI) {
337
337
  })
338
338
  // Claude's InstructionsLoaded hook has NO decision control: exit codes are
339
339
  // ignored and every JSON output field (systemMessage included) is discarded, so
340
- // dispatch is fire-and-forget on all paths. Two documented load reasons can
341
- // never fire honestly and are deliberate gaps, not approximations:
342
- // `nested_traversal` (pi does not lazily load a nested CLAUDE.md on subdirectory
343
- // entry) and `compact` (pi does not re-load instruction files after compaction).
340
+ // dispatch is fire-and-forget on all paths. `nested_traversal` arrives from
341
+ // context-imports when a CLAUDE.md below cwd is attached on a file touch. The
342
+ // `compact` reason can never fire honestly and is a deliberate gap, not an
343
+ // approximation: pi does not re-load instruction files after compaction.
344
344
  const fireInstructionsLoaded = (payload: Record<string, unknown>): void => {
345
345
  if (!sessionCtx) return
346
346
  const commands = matchingCommands(config.InstructionsLoaded, String(payload.load_reason))
@@ -703,7 +703,11 @@ export default function hooksExtension(pi: ExtensionAPI) {
703
703
  // Claude's custom_instructions: the /compact arguments on a manual run, empty
704
704
  // on an automatic one; pi carries them on the event directly.
705
705
  const payload = { hook_event_name: 'PreCompact', trigger: trigger.value, custom_instructions: event.customInstructions ?? '' }
706
- const results = await runNotifyHooks(matchingCommands(config.PreCompact, trigger.names), payload, boundRunner(ctx))
706
+ // Filtered here the way runNotifyHooks filters, so `results[index]` and
707
+ // `commands[index]` name the same hook: indexing the unfiltered list named the
708
+ // wrong command in the notice whenever an `if`-carrying hook preceded the blocker.
709
+ const commands = matchingCommands(config.PreCompact, trigger.names).filter((command) => passesIfFilter(command, undefined))
710
+ const results = await runNotifyHooks(commands, payload, boundRunner(ctx))
707
711
  surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
708
712
  // Claude: "Exit with code 2 to block compaction. For a manual /compact, the stderr
709
713
  // message is shown to the user. You can also block by returning JSON with
@@ -713,7 +717,7 @@ export default function hooksExtension(pi: ExtensionAPI) {
713
717
  const parsed = tryParseJson(result.stdout)
714
718
  const blocked = result.code === 2 && !result.timedOut ? { reason: result.stderr.trim() || 'Compaction blocked by hook' } : jsonBlockVerdict(parsed, 'Compaction blocked by hook')
715
719
  if (!blocked) continue
716
- ctx.ui.notify(`Compaction blocked by ${matchingCommands(config.PreCompact, trigger.names)[index]?.command ?? 'hook'}: ${blocked.reason}`, 'warning')
720
+ ctx.ui.notify(`Compaction blocked by ${commands[index]?.command ?? 'hook'}: ${blocked.reason}`, 'warning')
717
721
  return { cancel: true }
718
722
  }
719
723
  })
@@ -93,7 +93,13 @@ function isRunnableHook(hook: HookCommand): boolean {
93
93
  if (hook.type === 'http') return typeof hook.url === 'string' && /^https?:\/\//.test(hook.url)
94
94
  if (hook.type === 'prompt' || hook.type === 'agent') return typeof hook.prompt === 'string' && hook.prompt.length > 0
95
95
  if (hook.type === 'mcp_tool') return typeof hook.server === 'string' && typeof hook.tool === 'string'
96
- return typeof hook.command === 'string' && (hook.type === undefined || hook.type === 'command')
96
+ // A command hook needs a non-empty command, and exec-form args must all be strings:
97
+ // spawn('') throws ERR_INVALID_ARG_VALUE and a number has no replaceAll for argument
98
+ // substitution, both inside the runner's Promise executor, so the event's handler
99
+ // rejected instead of the hook reporting spawnFailed.
100
+ if (typeof hook.command !== 'string' || hook.command.length === 0) return false
101
+ if (hook.args !== undefined && !(Array.isArray(hook.args) && hook.args.every((arg) => typeof arg === 'string'))) return false
102
+ return hook.type === undefined || hook.type === 'command'
97
103
  }
98
104
 
99
105
  /** The synthetic identity of a non-shell hook entry: an http/prompt/agent/mcp_tool
@@ -188,18 +194,17 @@ export function passesIfFilter(hook: HookCommand, target: IfFilterTarget | undef
188
194
 
189
195
  /** One `if` pattern against a call's arguments. Claude evaluates the rule "against
190
196
  * the tool name and arguments together" in permission-rule syntax, so each tool uses
191
- * the same specifier engine its permission rules use:
197
+ * the same specifier engine its permission rules use: a command pattern for Bash, a
198
+ * `domain:` host for WebFetch, an agent name for Agent, a skill name for Skill, and a
199
+ * path rule for the file tools. A tool with no specifier syntax matches nothing,
200
+ * which is also what an unparseable rule does.
192
201
  *
193
202
  * PAIRED WITH commands.ts's tool_call guard, which dispatches the same tools to the
194
203
  * same matchers for `allowed-tools` scopes. The two are deliberately NOT merged: bash
195
204
  * differs on purpose (an allow scope requires every segment to match, an `if` filter is
196
205
  * best effort and errs toward running the hook), and merging would hide that. They are
197
206
  * two lists that must agree, so a new scoped tool has to be added in both places; the
198
- * shared roster is ARG_RULE_TOOLS in internal/command-file.ts.
199
- * a command pattern for Bash, a
200
- * `domain:` host for WebFetch, an agent name for Agent, a skill name for Skill, and a
201
- * path rule for the file tools. A tool with no specifier syntax matches nothing,
202
- * which is also what an unparseable rule does. */
207
+ * shared roster is ARG_RULE_TOOLS in internal/command-file.ts. */
203
208
  function matchesToolPattern(piName: string, input: Record<string, unknown> | null, pattern: string, anchors: PathAnchors): boolean {
204
209
  const str = (value: unknown): string => (typeof value === 'string' ? value : '')
205
210
  switch (piName) {
@@ -6,11 +6,11 @@
6
6
 
7
7
  import { type ChildProcess, spawn } from 'node:child_process'
8
8
  import * as fs from 'node:fs'
9
- import * as path from 'node:path'
10
9
  import type { Api, Model } from '@earendil-works/pi-ai'
11
10
  import { runAgent } from '../internal/agent-run.js'
12
11
  import { callMcpTool } from '../internal/mcp-call.js'
13
12
  import { completeText } from '../internal/model-complete.js'
13
+ import { killProcessTree } from '../internal/process-tree.js'
14
14
  import { resolveShell } from '../internal/shell-resolve.js'
15
15
  import { errorMessage } from '../internal/values.js'
16
16
  import { type HookCommand, httpUrlAllowed, isBackgroundHook } from './config.js'
@@ -41,10 +41,6 @@ export interface HookRunResult {
41
41
  }
42
42
  /** Runs one configured hook entry, whatever its type; boundRunner dispatches. */
43
43
  export type HookRunner = (hook: HookCommand, payload: unknown, timeoutMs: number) => Promise<HookRunResult>
44
- /** The shell path specifically; the statusline reuses it for its own command. With an
45
- * `args` array it becomes the exec path: `command` is spawned directly with those args.
46
- * `onChild` hands the caller a kill for the spawned tree, so a background hook that is
47
- * still running at session end can be reaped (Claude kills async hooks at teardown). */
48
44
  /** How one command hook is spawned, beyond the payload and its budget. Grouped rather
49
45
  * than trailing off the parameter list: the exec form, the shell choice and the
50
46
  * declaring plugin are all per-hook fields that arrive together from one HookCommand. */
@@ -96,30 +92,9 @@ const MAX_HOOK_OUTPUT = 1_000_000
96
92
  /** Conventional exit code for a killed-on-timeout command, as `timeout(1)` reports it. */
97
93
  const TIMEOUT_EXIT_CODE = 124
98
94
 
99
- /**
100
- * Kill the shell and everything it spawned. `sh -c 'a; b'` forks, so signalling the
101
- * direct child alone leaves a grandchild alive holding stdout/stderr.
102
- */
95
+ /** Kill the shell and everything it spawned; see internal/process-tree. */
103
96
  function killTree(child: ChildProcess): void {
104
- if (process.platform === 'win32') {
105
- // Windows has no process groups: taskkill /T ends the shell's whole tree. By
106
- // absolute path, so a writable PATH entry cannot stand in for it. If taskkill itself
107
- // cannot start, the direct kill is all that is left.
108
- const taskkill = path.join(process.env.SystemRoot ?? String.raw`C:\Windows`, 'System32', 'taskkill.exe')
109
- if (child.pid) spawn(taskkill, ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true }).on('error', () => child.kill('SIGKILL'))
110
- else child.kill('SIGKILL')
111
- return
112
- }
113
- try {
114
- // Negative pid targets the whole process group, which `detached` gave the shell.
115
- if (child.pid) {
116
- process.kill(-child.pid, 'SIGKILL')
117
- return
118
- }
119
- } catch {
120
- // Group already reaped, or the platform refused it; fall through to the direct kill.
121
- }
122
- child.kill('SIGKILL')
97
+ killProcessTree(child, 'SIGKILL')
123
98
  }
124
99
 
125
100
  /** Create the plugin data directory the moment its path is handed to a child. Claude
@@ -140,6 +115,10 @@ function shellInvocation(command: string, shell: string | undefined): { file: st
140
115
  return resolved ? { file: resolved.file, spawnArgs: resolved.argsFor(command) } : undefined
141
116
  }
142
117
 
118
+ /** The shell path specifically; the statusline reuses it for its own command. With an
119
+ * `args` array it becomes the exec path: `command` is spawned directly with those args.
120
+ * `onChild` hands the caller a kill for the spawned tree, so a background hook that is
121
+ * still running at session end can be reaped (Claude kills async hooks at teardown). */
143
122
  export const runHookCommand: HookCommandRunner = (command, payload, timeoutMs, { projectDir, args, onChild, shell, plugin, sessionId } = {}) =>
144
123
  new Promise((resolve) => {
145
124
  // /bin/sh by absolute path off Windows, so the shell can't be resolved through an
@@ -36,7 +36,7 @@ export function setAgentRunner(fn: AgentRunner | undefined): void {
36
36
  runner = fn
37
37
  }
38
38
 
39
- /** Whether a runner is registered, so agent hooks can be reported as runnable. */
39
+ /** Test seam: whether a runner is registered. */
40
40
  export function hasAgentRunner(): boolean {
41
41
  return runner !== undefined
42
42
  }
@@ -11,8 +11,7 @@
11
11
  */
12
12
 
13
13
  import { hasSubstitution, splitSegments } from './shell-split.js'
14
-
15
- const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`)
14
+ import { escapeRegExp } from './values.js'
16
15
 
17
16
  /**
18
17
  * One rule against one command segment, per Claude's permission table: