pi-code 1.0.13 → 1.0.14

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.
@@ -51,7 +51,9 @@ import { capForContext } from './internal/output-guard.js'
51
51
  import { matchesPathRules } from './internal/path-rules.js'
52
52
  import { type InstalledPlugin, installedPlugins } from './internal/plugins.js'
53
53
  import { isProjectApproved } from './internal/project-approval.js'
54
- import { findNearestDir, findNearestFile, repoRoot } from './internal/project-root.js'
54
+ import { findNearestDir, repoRoot } from './internal/project-root.js'
55
+ import { claudeSettingsChain } from './internal/settings-chain.js'
56
+ import { createTurnOverride } from './internal/turn-override.js'
55
57
 
56
58
  type PathRuleTool = 'read' | 'edit' | 'write'
57
59
 
@@ -136,7 +138,7 @@ export function collectCommands(dirs: string[]): DiscoveredCommand[] {
136
138
 
137
139
  /** A plugin's command files, namespaced `plugin:name` as Claude registers them.
138
140
  * The manifest may point `commands` somewhere else; the default is `commands/`. */
139
- export function pluginCommands(plugins: InstalledPlugin[]): DiscoveredCommand[] {
141
+ function pluginCommands(plugins: InstalledPlugin[]): DiscoveredCommand[] {
140
142
  const found: DiscoveredCommand[] = []
141
143
  for (const plugin of plugins) {
142
144
  const declared = plugin.manifest.commands
@@ -166,12 +168,7 @@ type CommandPlugin = NonNullable<DiscoveredCommand['plugin']>
166
168
  */
167
169
  export function shellExecutionDisabled(cwd: string, home: string, trusted: boolean): boolean {
168
170
  if (readManagedSettings().disableSkillShellExecution === true) return true
169
- const files = [path.join(claudeConfigDir(home), 'settings.json')]
170
- if (trusted) {
171
- for (const name of ['settings.json', 'settings.local.json']) {
172
- files.push(findNearestFile(cwd, path.join('.claude', name)) ?? path.join(cwd, '.claude', name))
173
- }
174
- }
171
+ const files = claudeSettingsChain(cwd, home, trusted)
175
172
  return files.some((file) => {
176
173
  try {
177
174
  return (JSON.parse(fs.readFileSync(file, 'utf-8')) as Record<string, unknown>).disableSkillShellExecution === true
@@ -307,10 +304,21 @@ export default function commandsExtension(pi: ExtensionAPI) {
307
304
  let pendingBashRules: string[] | undefined
308
305
  /** Read/Edit path scopes enforced the same way, per pi file tool. */
309
306
  let pendingPathRules: Partial<Record<PathRuleTool, string[]>> | undefined
310
- /** The session model to restore after a command's `model:` override drove its run. */
311
- let pendingModelRestore: ModelLike | undefined
312
- /** The thinking level to restore after a command's `effort:` override drove its run. */
313
- let pendingEffortRestore: string | undefined
307
+ /** The session model to restore after a command's `model:` override drove its run,
308
+ * captured once per turn so a second command restores the original session model. */
309
+ const modelOverride = createTurnOverride<ModelLike>({
310
+ // setModel can reject (e.g. auth resolution fails), and a floated rejection would
311
+ // escape as unhandled; surface it as a no-op instead of leaving the session silently
312
+ // on the command's override model.
313
+ set: (model) => {
314
+ void pi.setModel(model as Parameters<typeof pi.setModel>[0]).catch(() => {})
315
+ },
316
+ })
317
+ /** The thinking level to restore after a command's `effort:` override drove its run,
318
+ * captured once per turn the same way. */
319
+ const effortOverride = createTurnOverride<string>({
320
+ set: (level) => pi.setThinkingLevel(level as Parameters<typeof pi.setThinkingLevel>[0]),
321
+ })
314
322
 
315
323
  // Claude's contract is "the grant clears when you send your next message", and
316
324
  // pi's turn_end fires after every assistant step: restoring there stripped a
@@ -323,19 +331,8 @@ export default function commandsExtension(pi: ExtensionAPI) {
323
331
  pi.on('agent_settled', async () => {
324
332
  pendingBashRules = undefined
325
333
  pendingPathRules = undefined
326
- if (pendingModelRestore) {
327
- const restore = pendingModelRestore as Parameters<typeof pi.setModel>[0]
328
- pendingModelRestore = undefined
329
- // setModel can reject (e.g. auth resolution fails), and a floated rejection would
330
- // escape as unhandled; surface it instead of leaving the session silently on the
331
- // command's override model.
332
- void pi.setModel(restore).catch(() => {})
333
- }
334
- if (pendingEffortRestore) {
335
- const level = pendingEffortRestore as Parameters<typeof pi.setThinkingLevel>[0]
336
- pendingEffortRestore = undefined
337
- pi.setThinkingLevel(level)
338
- }
334
+ modelOverride.settle()
335
+ effortOverride.settle()
339
336
  if (pendingRestore) {
340
337
  pi.setActiveTools(pendingRestore)
341
338
  pendingRestore = undefined
@@ -412,7 +409,7 @@ export default function commandsExtension(pi: ExtensionAPI) {
412
409
  async function applyModelOverride(parsed: ParsedCommand, varCtx: VarContext): Promise<void> {
413
410
  const target = resolveCommandModel(parsed.model, varCtx.modelRegistry?.getAvailable() ?? [])
414
411
  if (target && varCtx.model && target.id !== varCtx.model.id) {
415
- pendingModelRestore = pendingModelRestore ?? varCtx.model
412
+ modelOverride.arm(varCtx.model)
416
413
  await pi.setModel(target as Parameters<typeof pi.setModel>[0])
417
414
  }
418
415
  }
@@ -421,11 +418,11 @@ export default function commandsExtension(pi: ExtensionAPI) {
421
418
  * the session level resumes; restore happens on agent_settled like the model restore.
422
419
  * Applied before sendUserMessage so the run it drives happens at the new level. Only the
423
420
  * first override in a turn records the restore target, so a second command restores to
424
- * the original session level rather than the first command's override (as pendingModelRestore). */
421
+ * the original session level rather than the first command's override (as the model override). */
425
422
  function applyEffortOverride(parsed: ParsedCommand, varCtx: VarContext): void {
426
423
  const target = parsed.effort
427
424
  if (target && varCtx.thinkingLevel && target !== varCtx.thinkingLevel) {
428
- pendingEffortRestore = pendingEffortRestore ?? varCtx.thinkingLevel
425
+ effortOverride.arm(varCtx.thinkingLevel)
429
426
  pi.setThinkingLevel(target as Parameters<typeof pi.setThinkingLevel>[0])
430
427
  }
431
428
  }
@@ -474,8 +471,8 @@ export default function commandsExtension(pi: ExtensionAPI) {
474
471
  pendingRestore = undefined
475
472
  pendingBashRules = undefined
476
473
  pendingPathRules = undefined
477
- pendingModelRestore = undefined
478
- pendingEffortRestore = undefined
474
+ modelOverride.reset()
475
+ effortOverride.reset()
479
476
  const trusted = await isProjectApproved(ctx)
480
477
  projectApproved = trusted
481
478
  // A resume/fork/new session can switch projects in-process. pi cannot unregister a
@@ -75,6 +75,8 @@ import { managedSettingsPath, readManagedSettings } from './internal/managed-set
75
75
  import { globToRegExpSource } from './internal/path-rules.js'
76
76
  import { isProjectApproved, isProjectApprovedSilently } from './internal/project-approval.js'
77
77
  import { ancestorFiles, findNearestFile, repoRoot } from './internal/project-root.js'
78
+ import { claudeSettingsChain } from './internal/settings-chain.js'
79
+ import { statToken } from './internal/stat-token.js'
78
80
  import { fenceMarker, stripBlockComments } from './internal/strip-comments.js'
79
81
 
80
82
  /** Claude documents "a maximum depth of four hops" for recursive imports. */
@@ -93,7 +95,7 @@ function isUnder(target: string, roots: string[]): boolean {
93
95
  }
94
96
 
95
97
  /** Realpath the roots that exist; used both to seed and to bound the import search. */
96
- export function realRoots(candidates: string[]): string[] {
98
+ function realRoots(candidates: string[]): string[] {
97
99
  const roots: string[] = []
98
100
  for (const candidate of candidates) {
99
101
  try {
@@ -249,7 +251,7 @@ export function rootsForImporter(importer: string, home: string, cwd: string): s
249
251
  }
250
252
 
251
253
  /** Claude's env gate for loading memory files from --add-dir directories. */
252
- export const ADDITIONAL_DIRS_ENV = 'CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD'
254
+ const ADDITIONAL_DIRS_ENV = 'CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD'
253
255
 
254
256
  /** Whether the env gate is on. Claude documents `=1`; any value that is not
255
257
  * empty/0/false/no counts, so `=true` behaves as a user would expect. */
@@ -319,7 +321,7 @@ export function managedClaudeMdPath(): string {
319
321
  }
320
322
 
321
323
  /** The managed CLAUDE.md file body, or '' when absent or unreadable. */
322
- export function readManagedClaudeMdFile(): string {
324
+ function readManagedClaudeMdFile(): string {
323
325
  try {
324
326
  return fs.readFileSync(managedClaudeMdPath(), 'utf-8')
325
327
  } catch {
@@ -375,12 +377,7 @@ function withTopBlock(prompt: string, block: string): string {
375
377
  * settings.local.json (nearest at or above cwd) only when the project is
376
378
  * approved. Managed settings are read separately by the caller. */
377
379
  export function claudeMdExcludeFiles(cwd: string, home: string, approved: boolean): string[] {
378
- const files = [path.join(claudeConfigDir(home), 'settings.json')]
379
- if (!approved) return files
380
- for (const name of ['settings.json', 'settings.local.json']) {
381
- files.push(findNearestFile(cwd, path.join('.claude', name)) ?? path.join(cwd, '.claude', name))
382
- }
383
- return files
380
+ return claudeSettingsChain(cwd, home, approved)
384
381
  }
385
382
 
386
383
  /** Merged `claudeMdExcludes` globs across the settings chain plus managed
@@ -641,11 +638,6 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
641
638
  tokens: Array<[string, string]>
642
639
  }
643
640
  | undefined
644
- // mtime plus size, so a same-mtime rewrite of a different length still invalidates.
645
- const statToken = (file: string): string => {
646
- const stat = fs.statSync(file)
647
- return `${stat.mtimeMs}:${stat.size}`
648
- }
649
641
  const memoIsFresh = (memo: NonNullable<typeof importMemo>): boolean => {
650
642
  try {
651
643
  return memo.tokens.every(([file, token]) => statToken(file) === token)
@@ -0,0 +1,219 @@
1
+ /**
2
+ * Hook configuration: the settings-chain resolution, the hooks loaders (settings
3
+ * files and plugins), the disableAllHooks / allowedHttpHookUrls readers, and the
4
+ * /hooks viewer formatting. Pure config-shape types and loading, no execution.
5
+ */
6
+
7
+ import * as fs from 'node:fs'
8
+ import * as path from 'node:path'
9
+ import { readManagedSettings } from '../internal/managed-settings.js'
10
+ import { type InstalledPlugin, substitutePluginVars } from '../internal/plugins.js'
11
+ import { claudeSettingsChain } from '../internal/settings-chain.js'
12
+
13
+ export interface HookCommand {
14
+ type?: string
15
+ command: string
16
+ /** exec-form: spawn `command` directly with these args and no shell (shell-form when
17
+ * absent). $ARGUMENTS in each arg is replaced with the event JSON. */
18
+ args?: string[]
19
+ timeout?: number
20
+ /** http entries: the endpoint POSTed to; `command` mirrors it for dedup and display. */
21
+ url?: string
22
+ headers?: Record<string, string>
23
+ allowedEnvVars?: string[]
24
+ /** prompt entries: the prompt sent to the model (`$ARGUMENTS` = the event JSON). */
25
+ prompt?: string
26
+ /** mcp_tool entries: the connected server and tool to call, with optional input. */
27
+ server?: string
28
+ tool?: string
29
+ input?: Record<string, unknown>
30
+ /** prompt/agent entries: an optional model override; agent adds a system prompt. */
31
+ model?: string
32
+ systemPrompt?: string
33
+ }
34
+ export interface HookMatcher {
35
+ matcher?: string
36
+ hooks: HookCommand[]
37
+ }
38
+ export type HooksConfig = Record<string, HookMatcher[]>
39
+
40
+ export function isRecord(value: unknown): value is Record<string, unknown> {
41
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
42
+ }
43
+
44
+ /** Settings files to read, newest-winning. Project files load only when trusted, each
45
+ * the nearest of its name at or above cwd (bounded at the repository root, matching
46
+ * the approval walk), so a subdirectory session reads the settings that gated it. */
47
+ export function hookFiles(cwd: string, home: string, trusted: boolean): string[] {
48
+ return claudeSettingsChain(cwd, home, trusted)
49
+ }
50
+
51
+ /** Claude's `disableAllHooks` setting: the escape hatch a user reaches for when a
52
+ * hook misbehaves, so it is honored before any hook runs. Disabled when managed
53
+ * settings or ANY file in the settings chain sets it to `true`; deliberately not
54
+ * last-file-wins, since a repository file re-enabling the hooks the user just
55
+ * disabled in their own settings would defeat the escape hatch. The chain itself
56
+ * already gates project files on trust (see hookFiles). */
57
+ export function readDisableAllHooks(files: string[], managed: Record<string, unknown> = readManagedSettings()): boolean {
58
+ if (managed.disableAllHooks === true) return true
59
+ for (const file of files) {
60
+ try {
61
+ const parsed: unknown = JSON.parse(fs.readFileSync(file, 'utf-8'))
62
+ if (isRecord(parsed) && parsed.disableAllHooks === true) return true
63
+ } catch {
64
+ // missing or invalid file: skip
65
+ }
66
+ }
67
+ return false
68
+ }
69
+
70
+ /** Claude's `allowedHttpHookUrls` setting: URL patterns http hooks may target, with
71
+ * `*` as a wildcard. Per Claude's documentation: undefined (no source sets the key)
72
+ * means no restrictions, an empty array blocks every http hook, and arrays merge
73
+ * across settings sources. Merging is a union of managed settings plus every file in
74
+ * the chain; the chain already gates project files on trust (see hookFiles), and a
75
+ * trusted project can run arbitrary shell hooks anyway, so letting it extend the
76
+ * allowlist is no escalation. */
77
+ export function readAllowedHttpHookUrls(files: string[], managed: Record<string, unknown> = readManagedSettings()): string[] | undefined {
78
+ let found: string[] | undefined
79
+ const collect = (value: unknown): void => {
80
+ if (!Array.isArray(value)) return
81
+ found = [...(found ?? []), ...value.filter((entry): entry is string => typeof entry === 'string')]
82
+ }
83
+ collect(managed.allowedHttpHookUrls)
84
+ for (const file of files) {
85
+ try {
86
+ const parsed: unknown = JSON.parse(fs.readFileSync(file, 'utf-8'))
87
+ if (isRecord(parsed)) collect(parsed.allowedHttpHookUrls)
88
+ } catch {
89
+ // missing or invalid file: skip
90
+ }
91
+ }
92
+ return found
93
+ }
94
+
95
+ /** Whether an http hook may target `url`. `*` in an allowlist entry matches any run
96
+ * of characters; everything else is literal and the whole URL must match. An
97
+ * undefined allowlist means the setting is absent, so there are no restrictions. */
98
+ export function httpUrlAllowed(url: string, allowlist: string[] | undefined): boolean {
99
+ if (allowlist === undefined) return true
100
+ return allowlist.some((pattern) => {
101
+ const literal = pattern.split('*').map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`))
102
+ return new RegExp(`^${literal.join('.*')}$`).test(url)
103
+ })
104
+ }
105
+
106
+ export function loadHooks(files: string[], sources?: Map<HookMatcher, string>): HooksConfig {
107
+ const config: HooksConfig = {}
108
+ for (const file of files) {
109
+ let raw: string
110
+ try {
111
+ raw = fs.readFileSync(file, 'utf-8')
112
+ } catch {
113
+ continue
114
+ }
115
+ mergeHooksJson(config, raw, file, sources)
116
+ }
117
+ return config
118
+ }
119
+
120
+ function mergeHooksJson(config: HooksConfig, raw: string, source: string, sources?: Map<HookMatcher, string>): void {
121
+ let parsed: { hooks?: HooksConfig }
122
+ try {
123
+ parsed = JSON.parse(raw)
124
+ } catch {
125
+ return
126
+ }
127
+ for (const [event, matchers] of Object.entries(parsed?.hooks ?? {})) {
128
+ if (!Array.isArray(matchers)) continue
129
+ // Entries are validated here rather than where they run: a hand-edited settings
130
+ // file that writes `hooks` as an object instead of a list used to throw out of
131
+ // the tool_call handler, and pi turns that into an error result, so every tool
132
+ // call for the rest of the session failed with an opaque type error.
133
+ const usable = matchers.filter((entry) => isUsableMatcher(entry, source, event))
134
+ if (usable.length === 0) continue
135
+ config[event] = [...(config[event] ?? []), ...usable]
136
+ // Each parse produces fresh entry objects, so object identity keys the /hooks
137
+ // viewer's source attribution without touching the entries themselves.
138
+ for (const entry of usable) sources?.set(entry, source)
139
+ }
140
+ }
141
+
142
+ /** Each enabled plugin's hooks (hooks/hooks.json, or wherever the manifest points),
143
+ * with ${CLAUDE_PLUGIN_ROOT}/${CLAUDE_PLUGIN_DATA} substituted before parsing so a
144
+ * hook can name its bundled scripts by real path. */
145
+ export function loadPluginHooks(config: HooksConfig, plugins: InstalledPlugin[], sources?: Map<HookMatcher, string>): void {
146
+ for (const plugin of plugins) {
147
+ const declared = plugin.manifest.hooks
148
+ // An inline hooks object; an array is not a valid hooks map (it would parse to
149
+ // numeric event keys), so it falls through to the default path rather than
150
+ // silently registering nothing.
151
+ if (declared !== null && typeof declared === 'object' && !Array.isArray(declared)) {
152
+ mergeHooksJson(config, substitutePluginVars(JSON.stringify({ hooks: declared }), plugin), `${plugin.name} (plugin.json)`, sources)
153
+ continue
154
+ }
155
+ const file = path.resolve(plugin.root, typeof declared === 'string' ? declared : path.join('hooks', 'hooks.json'))
156
+ try {
157
+ mergeHooksJson(config, substitutePluginVars(fs.readFileSync(file, 'utf-8'), plugin), file, sources)
158
+ } catch {
159
+ // a plugin without hooks contributes nothing
160
+ }
161
+ }
162
+ }
163
+
164
+ /** A matcher entry pi-code can run: an object whose `hooks` is a list. Anything else
165
+ * is reported by name and skipped, so one bad entry costs its own hooks, not the
166
+ * session's tool calls. */
167
+ function isUsableMatcher(entry: unknown, file: string, event: string): entry is HookMatcher {
168
+ const candidate = entry as HookMatcher | null
169
+ if (candidate === null || typeof candidate !== 'object') {
170
+ console.warn(`pi-code-hooks: ignoring a non-object ${event} entry in ${file}`)
171
+ return false
172
+ }
173
+ if (candidate.hooks !== undefined && !Array.isArray(candidate.hooks)) {
174
+ console.warn(`pi-code-hooks: ignoring ${event} entry in ${file}: "hooks" must be a list`)
175
+ return false
176
+ }
177
+ if (candidate.matcher !== undefined && typeof candidate.matcher !== 'string') {
178
+ console.warn(`pi-code-hooks: ignoring ${event} entry in ${file}: "matcher" must be a string`)
179
+ return false
180
+ }
181
+ return true
182
+ }
183
+
184
+ /** A hook entry's display identity for the /hooks viewer: the command for shell
185
+ * hooks, otherwise the type-qualified url / prompt / server:tool. A missing field
186
+ * is named rather than hidden, since a misconfigured entry is exactly what the
187
+ * viewer exists to surface. */
188
+ function hookIdentity(hook: HookCommand | null | undefined): string {
189
+ // A hand-edited settings file can leave a null (or otherwise empty) entry in a
190
+ // hooks array; name it rather than let it crash the viewer that exists to surface
191
+ // exactly this kind of misconfiguration.
192
+ const record: Partial<HookCommand> = hook ?? {}
193
+ const type = record.type ?? 'command'
194
+ if (type === 'http') return `http: ${record.url ?? record.command ?? '(missing url)'}`
195
+ if (type === 'prompt' || type === 'agent') return `${type}: ${record.prompt ?? record.command ?? '(missing prompt)'}`
196
+ if (type === 'mcp_tool') return `mcp_tool: ${record.server ?? '(missing server)'}:${record.tool ?? '(missing tool)'}`
197
+ return `command: ${record.command ?? '(missing command)'}`
198
+ }
199
+
200
+ /** Render the resolved hooks config as a readable per-event summary for /hooks:
201
+ * one line per configured hook with its matcher, identity and, when known, the
202
+ * settings file it came from. Pure formatting of already-resolved data. */
203
+ export function formatHooksSummary(config: HooksConfig, sources?: Map<HookMatcher, string>): string {
204
+ const lines: string[] = []
205
+ for (const [event, matchers] of Object.entries(config)) {
206
+ const entryLines: string[] = []
207
+ for (const entry of matchers) {
208
+ const matcher = entry.matcher || '*'
209
+ const source = sources?.get(entry)
210
+ const suffix = source ? ` (${source})` : ''
211
+ for (const hook of entry.hooks ?? []) {
212
+ entryLines.push(` [${matcher}] ${hookIdentity(hook)}${suffix}`)
213
+ }
214
+ }
215
+ if (entryLines.length > 0) lines.push(`${event}:`, ...entryLines)
216
+ }
217
+ if (lines.length === 0) return 'No hooks configured. Add a "hooks" section to ~/.claude/settings.json or .claude/settings.json.'
218
+ return lines.join('\n')
219
+ }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Turning hook output into decisions: parsing a hook's exit code / JSON into a
3
+ * block-or-allow verdict, running the gated PreToolUse and UserPromptSubmit passes,
4
+ * and shaping the PostToolUse feedback and system messages surfaced to the user.
5
+ */
6
+
7
+ import type { ToolCallEventResult } from '@earendil-works/pi-coding-agent'
8
+ import { type HookCommand, type HooksConfig, isRecord } from './config.js'
9
+ import { matchingCommands } from './matcher.js'
10
+ import { type HookRunner, type HookRunResult, timeoutMs } from './runners.js'
11
+
12
+ export interface HookDecision {
13
+ block: boolean
14
+ reason?: string
15
+ /** Claude's `permissionDecision: "ask"`: the caller should prompt the user and
16
+ * block only on decline. `block` stays true as the no-UI fallback. */
17
+ ask?: boolean
18
+ }
19
+
20
+ export function tryParseJson(text: string): { hookSpecificOutput?: { permissionDecision?: string; permissionDecisionReason?: string; additionalContext?: string; updatedInput?: unknown }; decision?: string; reason?: string; continue?: boolean; stopReason?: string; systemMessage?: string } | undefined {
21
+ try {
22
+ return JSON.parse(text)
23
+ } catch {
24
+ return undefined
25
+ }
26
+ }
27
+
28
+ /** Map a hook's exit code / output to a block-or-allow decision. */
29
+ export function interpretHookResult(code: number, stdout: string, stderr: string): HookDecision {
30
+ if (code === 2) return { block: true, reason: stderr.trim() || 'Blocked by hook' }
31
+ const parsed = tryParseJson(stdout)
32
+ const specific = parsed?.hookSpecificOutput
33
+ // Claude's "ask" prompts the user; the tool_call handler turns this into a
34
+ // ctx.ui.confirm and blocks only on decline. block:true is the fallback for a
35
+ // headless run with no dialog to show, which is the safe reading on a gated path.
36
+ if (specific?.permissionDecision === 'ask') return { block: true, ask: true, reason: specific.permissionDecisionReason ?? 'A hook asks you to confirm this tool call.' }
37
+ if (specific?.permissionDecision === 'deny') return { block: true, reason: specific.permissionDecisionReason ?? 'Blocked by hook' }
38
+ if (parsed?.decision === 'block') return { block: true, reason: parsed.reason ?? 'Blocked by hook' }
39
+ if (parsed?.continue === false) return { block: true, reason: parsed.stopReason ?? 'Blocked by hook' }
40
+ return { block: false }
41
+ }
42
+
43
+ /** Claude's updatedInput replaces the whole tool_input, and pi's tool_call contract is
44
+ * in-place mutation, so the target object is emptied and refilled rather than reassigned. */
45
+ function replaceRecord(target: Record<string, unknown>, next: Record<string, unknown>): void {
46
+ for (const key of Object.keys(target)) delete target[key]
47
+ Object.assign(target, next)
48
+ }
49
+
50
+ /** Claude surfaces a hook error notice; on ungated events the action proceeds, while
51
+ * PreToolUse and UserPromptSubmit additionally fail closed on the same results (see
52
+ * their spawnFailed checks). Silence would hide that a guard never ran. */
53
+ function surfaceHookFailures(commands: HookCommand[], results: HookRunResult[], notify?: SystemMessageSink): void {
54
+ if (!notify) return
55
+ for (const [i, result] of results.entries()) {
56
+ if (result.spawnFailed) notify(`Hook failed to run: ${commands[i].command}: ${result.stderr.trim() || 'unknown error'}`)
57
+ }
58
+ }
59
+
60
+ /** Run PreToolUse hooks for a tool, in parallel as Claude does; the first blocking
61
+ * verdict in config order wins. For MCP tools the matcher sees both the pi name and
62
+ * the Claude alias, and the payload reports the alias, which is the name a
63
+ * Claude-written hook script expects in tool_name. Every hook sees the original
64
+ * tool input; hookSpecificOutput.updatedInput replaces the input in place as each
65
+ * hook completes, so with several rewrites the last to finish takes effect, which
66
+ * is Claude's documented (non-deterministic) behavior. */
67
+ export async function runPreToolUse(config: HooksConfig, toolName: string, toolInput: unknown, runner: HookRunner, claudeName?: string, onSystemMessage?: SystemMessageSink): Promise<HookDecision> {
68
+ const names = claudeName ? [toolName, claudeName] : [toolName]
69
+ const commands = matchingCommands(config.PreToolUse, names)
70
+ const results = await Promise.all(
71
+ commands.map((command) =>
72
+ runner(command, { hook_event_name: 'PreToolUse', tool_name: claudeName ?? toolName, tool_input: toolInput }, timeoutMs(command)).then((result) => {
73
+ const updated = tryParseJson(result.stdout)?.hookSpecificOutput?.updatedInput
74
+ if (isRecord(updated) && isRecord(toolInput)) replaceRecord(toolInput, updated)
75
+ return result
76
+ }),
77
+ ),
78
+ )
79
+ surfaceHookFailures(commands, results, onSystemMessage)
80
+ for (const [i, result] of results.entries()) {
81
+ // A killed hook never reached its verdict, and SIGKILL leaves a null exit code that
82
+ // would otherwise read as a clean allow. Fail closed instead.
83
+ if (result.timedOut) return { block: true, reason: `Hook timed out after ${timeoutMs(commands[i])}ms: ${commands[i].command}` }
84
+ // A hook that never spawned (EMFILE, missing /bin/sh) reached no verdict either;
85
+ // its code 0 must fail closed like a timeout, not read as an allow exactly when
86
+ // the machine is degraded.
87
+ if (result.spawnFailed) return { block: true, reason: `Hook failed to run: ${commands[i].command}: ${result.stderr.trim() || 'unknown error'}` }
88
+ }
89
+ if (onSystemMessage) surfaceSystemMessages(results, onSystemMessage)
90
+ // A hard deny wins over an ask, matching Claude's deny > ask > allow precedence:
91
+ // scan for any deny first, and only fall back to the first ask.
92
+ let ask: HookDecision | undefined
93
+ for (const result of results) {
94
+ const decision = interpretHookResult(result.code, result.stdout, result.stderr)
95
+ if (decision.block && !decision.ask) return decision
96
+ if (decision.ask && ask === undefined) ask = decision
97
+ }
98
+ return ask ?? { block: false }
99
+ }
100
+
101
+ type SystemMessageSink = (message: string) => void
102
+
103
+ /** Claude's universal systemMessage output field: a warning surfaced to the user. */
104
+ export function surfaceSystemMessages(results: HookRunResult[], notify: SystemMessageSink): void {
105
+ for (const result of results) {
106
+ const message = tryParseJson(result.stdout)?.systemMessage
107
+ if (message) notify(message)
108
+ }
109
+ }
110
+
111
+ export interface PromptDecision {
112
+ block: boolean
113
+ reason?: string
114
+ context: string
115
+ }
116
+
117
+ /** Additional context a UserPromptSubmit hook contributes: an explicit
118
+ * hookSpecificOutput.additionalContext, or the raw stdout of a plain exit-0 hook. */
119
+ export function promptContext(stdout: string): string {
120
+ const parsed = tryParseJson(stdout)
121
+ if (parsed) return parsed.hookSpecificOutput?.additionalContext ?? ''
122
+ return stdout.trim()
123
+ }
124
+
125
+ /** Run UserPromptSubmit hooks, in parallel as Claude does: the first blocking
126
+ * verdict in config order wins; otherwise their additional context is concatenated
127
+ * in config order for injection ahead of the prompt. */
128
+ export async function runUserPromptSubmit(config: HooksConfig, prompt: string, runner: HookRunner, onSystemMessage?: SystemMessageSink): Promise<PromptDecision> {
129
+ const commands = matchingCommands(config.UserPromptSubmit, 'UserPromptSubmit')
130
+ const results = await Promise.all(commands.map((command) => runner(command, { hook_event_name: 'UserPromptSubmit', prompt }, timeoutMs(command))))
131
+ surfaceHookFailures(commands, results, onSystemMessage)
132
+ for (const [i, result] of results.entries()) {
133
+ if (result.timedOut) return { block: true, reason: `Hook timed out after ${timeoutMs(commands[i])}ms: ${commands[i].command}`, context: '' }
134
+ // No verdict was delivered, so fail closed like a timeout (see runPreToolUse).
135
+ if (result.spawnFailed) return { block: true, reason: `Hook failed to run: ${commands[i].command}: ${result.stderr.trim() || 'unknown error'}`, context: '' }
136
+ }
137
+ if (onSystemMessage) surfaceSystemMessages(results, onSystemMessage)
138
+ const contexts: string[] = []
139
+ for (const result of results) {
140
+ const decision = interpretHookResult(result.code, result.stdout, result.stderr)
141
+ if (decision.block) return { block: true, reason: decision.reason, context: '' }
142
+ const context = promptContext(result.stdout)
143
+ if (context) contexts.push(context)
144
+ }
145
+ return { block: false, context: contexts.join('\n') }
146
+ }
147
+
148
+ /** The feedback lines one PostToolUse/PostToolUseFailure result appends next to the
149
+ * tool result: a block notice (exit-2 stderr, or decision:block on success) followed
150
+ * by any additionalContext. A failed tool cannot be blocked, so its stderr is shown
151
+ * but never a decision:block verdict. */
152
+ export function postToolFeedback(result: HookRunResult, eventName: string, isError: boolean): string[] {
153
+ const lines: string[] = []
154
+ const parsed = tryParseJson(result.stdout)
155
+ // A failed tool cannot be blocked, but the hook's stderr is still shown; on
156
+ // success, exit-2 / decision:block feed back as a block notice.
157
+ if (!result.timedOut && result.code === 2) lines.push(`${eventName} hook: ${result.stderr.trim() || (isError ? 'hook reported an error' : 'Blocked by hook')}`)
158
+ else if (!isError && parsed?.decision === 'block') lines.push(`PostToolUse hook: ${parsed.reason ?? 'Blocked by hook'}`)
159
+ const context = parsed?.hookSpecificOutput?.additionalContext
160
+ if (context) lines.push(context)
161
+ return lines
162
+ }
163
+
164
+ /** A blocked tool_call verdict carrying pi's `terminate` flag (#7715): with it set on
165
+ * an all-terminating tool batch, pi skips the automatic follow-up model call that a plain
166
+ * block would otherwise pay for. */
167
+ export function blockedToolCall(reason: string | undefined): ToolCallEventResult {
168
+ return { block: true, reason, terminate: true }
169
+ }