pi-code 1.0.62 → 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 (49) hide show
  1. package/README.md +1 -1
  2. package/extensions/commands.ts +9 -6
  3. package/extensions/context-imports.ts +46 -24
  4. package/extensions/git-checkpoint.ts +141 -21
  5. package/extensions/hooks/config.ts +16 -10
  6. package/extensions/hooks/decisions.ts +9 -9
  7. package/extensions/hooks/index.ts +14 -10
  8. package/extensions/hooks/matcher.ts +12 -7
  9. package/extensions/hooks/runners.ts +17 -29
  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 +28 -6
  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 +51 -41
  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 +19 -6
  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 +78 -19
  33. package/extensions/mcp/policy.ts +14 -12
  34. package/extensions/mcp/transport.ts +12 -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 +142 -12
  39. package/extensions/session-title.ts +5 -0
  40. package/extensions/skills.ts +11 -6
  41. package/extensions/subagent/agents.ts +4 -4
  42. package/extensions/subagent/background.ts +35 -41
  43. package/extensions/subagent/child.ts +19 -4
  44. package/extensions/subagent/modes.ts +18 -13
  45. package/extensions/subagent/params.ts +1 -2
  46. package/extensions/subagent/run.ts +34 -20
  47. package/extensions/thinking.ts +3 -3
  48. package/extensions/web.ts +3 -4
  49. 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')
@@ -943,22 +943,25 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
943
943
  type: 'string',
944
944
  })
945
945
 
946
- pi.on('session_start', async (_event, ctx) => {
947
- // pi's ctx.reload() rebuilds extension instances, so this set never survives
948
- // a reload anyway; a reload simply re-fires InstructionsLoaded once per file,
949
- // which is fine, since a reload re-loads the instruction files.
950
- announced.clear()
951
- envCache = undefined
952
- importMemo = undefined
953
- localContexts = []
954
- userContext = undefined
955
- projectDotClaude = undefined
946
+ /** The session-scope memory session_start loads: the user's own CLAUDE.md, plus
947
+ * CLAUDE.local.md and ./.claude/CLAUDE.md from an approved project. Claude:
948
+ * CLAUDE_CODE_DISABLE_CLAUDE_MDS "prevent[s] loading any CLAUDE.md memory files
949
+ * into context, including user, project, and auto memory files", so a disabled run
950
+ * returns empty state and skips isProjectApproved's trust prompt entirely: there is
951
+ * nothing left for it to gate. Extracted so session_start itself stays a thin
952
+ * dispatcher; before_agent_start's own excluded() check covers pi's native files,
953
+ * which this cannot reach since pi loads those itself. */
954
+ async function loadSessionMemory(ctx: ExtensionContext): Promise<{ userContext?: { path: string; content: string }; localContexts: Array<{ path: string; content: string }>; projectDotClaude?: { path: string; content: string } }> {
955
+ if (process.env.CLAUDE_CODE_DISABLE_CLAUDE_MDS === '1') return { localContexts: [] }
956
956
 
957
957
  // ~/.claude/CLAUDE.md, Claude's user-scope memory. The user's own file, so no
958
958
  // project approval is required; a missing file simply leaves it unset.
959
959
  const userClaudeMd = path.join(claudeConfigDir(os.homedir()), 'CLAUDE.md')
960
960
  const userContent = readContextFile(userClaudeMd)
961
- if (userContent !== undefined) userContext = { path: userClaudeMd, content: userContent }
961
+ const memory: { userContext?: { path: string; content: string }; localContexts: Array<{ path: string; content: string }>; projectDotClaude?: { path: string; content: string } } = {
962
+ userContext: userContent !== undefined ? { path: userClaudeMd, content: userContent } : undefined,
963
+ localContexts: [],
964
+ }
962
965
 
963
966
  // CLAUDE.local.md is Claude Code's personal sidecar of CLAUDE.md; pi's own loader
964
967
  // skips it. A cloned repo can ship one, so it is gated like other project config.
@@ -969,16 +972,30 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
969
972
  // ride the one approval decision.
970
973
  const candidates = ancestorFiles(ctx.cwd, 'CLAUDE.local.md')
971
974
  const dotClaudeMd = findNearestFile(ctx.cwd, path.join('.claude', 'CLAUDE.md'))
972
- if ((candidates.length > 0 || dotClaudeMd !== null) && (await isProjectApproved(ctx))) {
973
- for (const candidate of candidates) {
974
- const content = readContextFile(candidate)
975
- if (content !== undefined) localContexts.push({ path: candidate, content })
976
- }
977
- if (dotClaudeMd !== null) {
978
- const content = readContextFile(dotClaudeMd)
979
- if (content !== undefined) projectDotClaude = { path: dotClaudeMd, content }
980
- }
975
+ if ((candidates.length === 0 && dotClaudeMd === null) || !(await isProjectApproved(ctx))) return memory
976
+
977
+ for (const candidate of candidates) {
978
+ const content = readContextFile(candidate)
979
+ if (content !== undefined) memory.localContexts.push({ path: candidate, content })
980
+ }
981
+ if (dotClaudeMd !== null) {
982
+ const content = readContextFile(dotClaudeMd)
983
+ if (content !== undefined) memory.projectDotClaude = { path: dotClaudeMd, content }
981
984
  }
985
+ return memory
986
+ }
987
+
988
+ pi.on('session_start', async (_event, ctx) => {
989
+ // pi's ctx.reload() rebuilds extension instances, so this set never survives
990
+ // a reload anyway; a reload simply re-fires InstructionsLoaded once per file,
991
+ // which is fine, since a reload re-loads the instruction files.
992
+ announced.clear()
993
+ envCache = undefined
994
+ importMemo = undefined
995
+ const memory = await loadSessionMemory(ctx)
996
+ localContexts = memory.localContexts
997
+ userContext = memory.userContext
998
+ projectDotClaude = memory.projectDotClaude
982
999
  // Read after the local-context flow so an approval it just recorded is honored.
983
1000
  projectApproved = isProjectApprovedSilently(ctx)
984
1001
  gatedFilesApproved = isGatedFileApproved(ctx)
@@ -1014,7 +1031,12 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
1014
1031
  envCache = { cwd, managed: managedNow, excludeGlobs: readClaudeMdExcludes(claudeMdExcludeFiles(cwd, home, projectApproved), managedNow), projectRoot: repoRoot(cwd) ?? cwd }
1015
1032
  }
1016
1033
  const { managed, excludeGlobs, projectRoot } = envCache
1017
- const excluded = (absPath: string): boolean => isExcludedPath(absPath, excludeGlobs, home)
1034
+ // CLAUDE_CODE_DISABLE_CLAUDE_MDS also covers pi's own auto-discovered native context
1035
+ // files ("including... auto memory files"), which session_start's gate cannot reach
1036
+ // since pi loads them itself. Routing through the exclusion path already used for
1037
+ // claudeMdExcludes drops the block, strips the InstructionsLoaded event, and skips
1038
+ // import expansion for it, exactly as an excluded file already does.
1039
+ const excluded = (absPath: string): boolean => process.env.CLAUDE_CODE_DISABLE_CLAUDE_MDS === '1' || isExcludedPath(absPath, excludeGlobs, home)
1018
1040
 
1019
1041
  // claudeMdExcludes drops an excluded file's block from the assembled prompt and
1020
1042
  // from import expansion; surviving blocks get block-level comments stripped.
@@ -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()) {
@@ -331,16 +426,41 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
331
426
  pi.on('turn_start', async () => {
332
427
  if (!runNeedsSnapshot) return
333
428
  runNeedsSnapshot = false
429
+ // Claude: "Set to 1 to disable file checkpointing. The /rewind command will not be
430
+ // able to restore code changes." No snapshot means turn_end's `if (!snap) return`
431
+ // always fires, so no checkpoint is ever recorded.
432
+ if (process.env.CLAUDE_CODE_DISABLE_FILE_CHECKPOINTING === '1') return
334
433
  pending = await snapshot()
335
434
  })
336
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
+
337
449
  pi.on('turn_end', async (_event, ctx) => {
338
450
  const snap = pending
339
451
  pending = undefined
340
452
  if (!snap) return
341
453
 
342
454
  const target = findLastUserMessage(ctx)
343
- 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
+ }
344
464
 
345
465
  const checkpoint: Checkpoint = { entryId: target.entryId, ref: snap.ref, prompt: target.prompt, createdAt: snap.createdAt }
346
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[]> {
@@ -283,7 +283,7 @@ export default function hooksExtension(pi: ExtensionAPI) {
283
283
  if (hook.type === 'prompt') return runPromptHook(hook, merged, resolveHookModel(ctx, hook.model), ms)
284
284
  if (hook.type === 'agent') return runAgentHook(hook, merged, ms, (ctx.model as { id?: string } | undefined)?.id)
285
285
  if (hook.type === 'mcp_tool') return runMcpToolHook(hook, merged, ms)
286
- return runHookCommand(hook.command, merged, ms, { projectDir, args: hook.args, onChild, shell: hook.shell, plugin: hook.pluginRoot !== undefined && hook.pluginDataDir !== undefined ? { root: hook.pluginRoot, dataDir: hook.pluginDataDir } : undefined })
286
+ return runHookCommand(hook.command, merged, ms, { projectDir, args: hook.args, onChild, shell: hook.shell, plugin: hook.pluginRoot !== undefined && hook.pluginDataDir !== undefined ? { root: hook.pluginRoot, dataDir: hook.pluginDataDir } : undefined, sessionId: merged.session_id as string | undefined })
287
287
  }
288
288
  // Claude's `once` (skill-frontmatter hooks only): removed after the first
289
289
  // successful run; a failure, block, or timeout leaves it in place.
@@ -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
  })