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.
- package/README.md +1 -1
- package/extensions/commands.ts +9 -6
- package/extensions/context-imports.ts +46 -24
- package/extensions/git-checkpoint.ts +141 -21
- package/extensions/hooks/config.ts +16 -10
- package/extensions/hooks/decisions.ts +9 -9
- package/extensions/hooks/index.ts +14 -10
- package/extensions/hooks/matcher.ts +12 -7
- package/extensions/hooks/runners.ts +17 -29
- package/extensions/internal/agent-run.ts +1 -1
- package/extensions/internal/bash-rules.ts +1 -2
- package/extensions/internal/claude-tool-names.ts +4 -7
- package/extensions/internal/command-file.ts +2 -3
- package/extensions/internal/external-imports.ts +4 -5
- package/extensions/internal/goal-evaluator.ts +4 -3
- package/extensions/internal/instruction-events.ts +5 -5
- package/extensions/internal/managed-settings.ts +1 -1
- package/extensions/internal/mcp-oauth.ts +28 -6
- package/extensions/internal/model-complete.ts +7 -1
- package/extensions/internal/model-lookup.ts +10 -3
- package/extensions/internal/path-rules.ts +51 -41
- package/extensions/internal/plugins.ts +5 -5
- package/extensions/internal/process-tree.ts +39 -0
- package/extensions/internal/project-approval.ts +7 -7
- package/extensions/internal/project-root.ts +13 -13
- package/extensions/internal/settings-chain.ts +19 -0
- package/extensions/internal/settings-watch.ts +3 -1
- package/extensions/internal/shell-resolve.ts +19 -6
- package/extensions/internal/tool-target.ts +4 -5
- package/extensions/internal/values.ts +20 -4
- package/extensions/mcp/config.ts +11 -4
- package/extensions/mcp/index.ts +78 -19
- package/extensions/mcp/policy.ts +14 -12
- package/extensions/mcp/transport.ts +12 -8
- package/extensions/memory.ts +4 -4
- package/extensions/notify.ts +2 -7
- package/extensions/output-styles.ts +22 -17
- package/extensions/question.ts +142 -12
- package/extensions/session-title.ts +5 -0
- package/extensions/skills.ts +11 -6
- package/extensions/subagent/agents.ts +4 -4
- package/extensions/subagent/background.ts +35 -41
- package/extensions/subagent/child.ts +19 -4
- package/extensions/subagent/modes.ts +18 -13
- package/extensions/subagent/params.ts +1 -2
- package/extensions/subagent/run.ts +34 -20
- package/extensions/thinking.ts +3 -3
- package/extensions/web.ts +3 -4
- 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
|
|
package/extensions/commands.ts
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
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
|
-
|
|
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
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
}
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
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
|
-
|
|
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
|
|
7
|
-
*
|
|
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.
|
|
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
|
|
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
|
-
*
|
|
39
|
-
*
|
|
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
|
-
|
|
48
|
-
|
|
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(
|
|
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
|
|
246
|
-
if (
|
|
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
|
-
|
|
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
|
|
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 ?
|
|
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
|
|
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(
|
|
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
|
|
213
|
-
//
|
|
214
|
-
// the
|
|
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 =
|
|
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.
|
|
341
|
-
//
|
|
342
|
-
// `
|
|
343
|
-
//
|
|
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
|
-
|
|
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 ${
|
|
720
|
+
ctx.ui.notify(`Compaction blocked by ${commands[index]?.command ?? 'hook'}: ${blocked.reason}`, 'warning')
|
|
717
721
|
return { cancel: true }
|
|
718
722
|
}
|
|
719
723
|
})
|