pi-code 1.0.5 → 1.0.7

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.
@@ -43,7 +43,10 @@
43
43
  *
44
44
  * Config is merged from ~/.claude/settings.json (always) plus the project's
45
45
  * .claude/settings.json and settings.local.json (only when the project is
46
- * trusted, since hooks execute arbitrary shell). Matchers follow Claude's rule:
46
+ * trusted, since hooks execute arbitrary shell). Claude's `disableAllHooks`
47
+ * setting (managed settings or any honored file in that chain) short-circuits
48
+ * the load entirely, so no event fires any hook; /hooks prints the resolved
49
+ * chain per event with each entry's source settings file. Matchers follow Claude's rule:
47
50
  * `*`/empty match all, plain names are exact (with `|`/`,` list separators), and
48
51
  * anything with other regex characters is an unanchored regex. Claude matchers
49
52
  * are PascalCase (`Bash`); pi tool names are lowercase (`bash`), so comparison
@@ -60,6 +63,7 @@ import type { Api, Model } from '@earendil-works/pi-ai'
60
63
  import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
61
64
  import { runAgent } from './internal/agent-run.js'
62
65
  import { INSTRUCTIONS_CHANNEL, isInstructionLoadEvent } from './internal/instruction-events.js'
66
+ import { readManagedSettings } from './internal/managed-settings.js'
63
67
  import { isMcpToolAliases, MCP_TOOLS_CHANNEL } from './internal/mcp-alias.js'
64
68
  import { callMcpTool } from './internal/mcp-call.js'
65
69
  import { completeText } from './internal/model-complete.js'
@@ -94,7 +98,7 @@ interface HookCommand {
94
98
  model?: string
95
99
  systemPrompt?: string
96
100
  }
97
- interface HookMatcher {
101
+ export interface HookMatcher {
98
102
  matcher?: string
99
103
  hooks: HookCommand[]
100
104
  }
@@ -133,7 +137,62 @@ export function hookFiles(cwd: string, home: string, trusted: boolean): string[]
133
137
  return files
134
138
  }
135
139
 
136
- export function loadHooks(files: string[]): HooksConfig {
140
+ /** Claude's `disableAllHooks` setting: the escape hatch a user reaches for when a
141
+ * hook misbehaves, so it is honored before any hook runs. Disabled when managed
142
+ * settings or ANY file in the settings chain sets it to `true`; deliberately not
143
+ * last-file-wins, since a repository file re-enabling the hooks the user just
144
+ * disabled in their own settings would defeat the escape hatch. The chain itself
145
+ * already gates project files on trust (see hookFiles). */
146
+ export function readDisableAllHooks(files: string[], managed: Record<string, unknown> = readManagedSettings()): boolean {
147
+ if (managed.disableAllHooks === true) return true
148
+ for (const file of files) {
149
+ try {
150
+ const parsed: unknown = JSON.parse(fs.readFileSync(file, 'utf-8'))
151
+ if (isRecord(parsed) && parsed.disableAllHooks === true) return true
152
+ } catch {
153
+ // missing or invalid file: skip
154
+ }
155
+ }
156
+ return false
157
+ }
158
+
159
+ /** Claude's `allowedHttpHookUrls` setting: URL patterns http hooks may target, with
160
+ * `*` as a wildcard. Per Claude's documentation: undefined (no source sets the key)
161
+ * means no restrictions, an empty array blocks every http hook, and arrays merge
162
+ * across settings sources. Merging is a union of managed settings plus every file in
163
+ * the chain; the chain already gates project files on trust (see hookFiles), and a
164
+ * trusted project can run arbitrary shell hooks anyway, so letting it extend the
165
+ * allowlist is no escalation. */
166
+ export function readAllowedHttpHookUrls(files: string[], managed: Record<string, unknown> = readManagedSettings()): string[] | undefined {
167
+ let found: string[] | undefined
168
+ const collect = (value: unknown): void => {
169
+ if (!Array.isArray(value)) return
170
+ found = [...(found ?? []), ...value.filter((entry): entry is string => typeof entry === 'string')]
171
+ }
172
+ collect(managed.allowedHttpHookUrls)
173
+ for (const file of files) {
174
+ try {
175
+ const parsed: unknown = JSON.parse(fs.readFileSync(file, 'utf-8'))
176
+ if (isRecord(parsed)) collect(parsed.allowedHttpHookUrls)
177
+ } catch {
178
+ // missing or invalid file: skip
179
+ }
180
+ }
181
+ return found
182
+ }
183
+
184
+ /** Whether an http hook may target `url`. `*` in an allowlist entry matches any run
185
+ * of characters; everything else is literal and the whole URL must match. An
186
+ * undefined allowlist means the setting is absent, so there are no restrictions. */
187
+ export function httpUrlAllowed(url: string, allowlist: string[] | undefined): boolean {
188
+ if (allowlist === undefined) return true
189
+ return allowlist.some((pattern) => {
190
+ const literal = pattern.split('*').map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`))
191
+ return new RegExp(`^${literal.join('.*')}$`).test(url)
192
+ })
193
+ }
194
+
195
+ export function loadHooks(files: string[], sources?: Map<HookMatcher, string>): HooksConfig {
137
196
  const config: HooksConfig = {}
138
197
  for (const file of files) {
139
198
  let raw: string
@@ -142,12 +201,12 @@ export function loadHooks(files: string[]): HooksConfig {
142
201
  } catch {
143
202
  continue
144
203
  }
145
- mergeHooksJson(config, raw, file)
204
+ mergeHooksJson(config, raw, file, sources)
146
205
  }
147
206
  return config
148
207
  }
149
208
 
150
- function mergeHooksJson(config: HooksConfig, raw: string, source: string): void {
209
+ function mergeHooksJson(config: HooksConfig, raw: string, source: string, sources?: Map<HookMatcher, string>): void {
151
210
  let parsed: { hooks?: HooksConfig }
152
211
  try {
153
212
  parsed = JSON.parse(raw)
@@ -161,26 +220,30 @@ function mergeHooksJson(config: HooksConfig, raw: string, source: string): void
161
220
  // the tool_call handler, and pi turns that into an error result, so every tool
162
221
  // call for the rest of the session failed with an opaque type error.
163
222
  const usable = matchers.filter((entry) => isUsableMatcher(entry, source, event))
164
- if (usable.length > 0) config[event] = [...(config[event] ?? []), ...usable]
223
+ if (usable.length === 0) continue
224
+ config[event] = [...(config[event] ?? []), ...usable]
225
+ // Each parse produces fresh entry objects, so object identity keys the /hooks
226
+ // viewer's source attribution without touching the entries themselves.
227
+ for (const entry of usable) sources?.set(entry, source)
165
228
  }
166
229
  }
167
230
 
168
231
  /** Each enabled plugin's hooks (hooks/hooks.json, or wherever the manifest points),
169
232
  * with ${CLAUDE_PLUGIN_ROOT}/${CLAUDE_PLUGIN_DATA} substituted before parsing so a
170
233
  * hook can name its bundled scripts by real path. */
171
- export function loadPluginHooks(config: HooksConfig, plugins: InstalledPlugin[]): void {
234
+ export function loadPluginHooks(config: HooksConfig, plugins: InstalledPlugin[], sources?: Map<HookMatcher, string>): void {
172
235
  for (const plugin of plugins) {
173
236
  const declared = plugin.manifest.hooks
174
237
  // An inline hooks object; an array is not a valid hooks map (it would parse to
175
238
  // numeric event keys), so it falls through to the default path rather than
176
239
  // silently registering nothing.
177
240
  if (declared !== null && typeof declared === 'object' && !Array.isArray(declared)) {
178
- mergeHooksJson(config, substitutePluginVars(JSON.stringify({ hooks: declared }), plugin), `${plugin.name} (plugin.json)`)
241
+ mergeHooksJson(config, substitutePluginVars(JSON.stringify({ hooks: declared }), plugin), `${plugin.name} (plugin.json)`, sources)
179
242
  continue
180
243
  }
181
244
  const file = path.resolve(plugin.root, typeof declared === 'string' ? declared : path.join('hooks', 'hooks.json'))
182
245
  try {
183
- mergeHooksJson(config, substitutePluginVars(fs.readFileSync(file, 'utf-8'), plugin), file)
246
+ mergeHooksJson(config, substitutePluginVars(fs.readFileSync(file, 'utf-8'), plugin), file, sources)
184
247
  } catch {
185
248
  // a plugin without hooks contributes nothing
186
249
  }
@@ -249,6 +312,23 @@ function isRunnableHook(hook: HookCommand): boolean {
249
312
  return typeof hook.command === 'string' && (hook.type === undefined || hook.type === 'command')
250
313
  }
251
314
 
315
+ /** The synthetic identity of a non-shell hook entry: an http/prompt/agent/mcp_tool
316
+ * entry has no `command`, so its url / prompt / server:tool stands in. A shell hook
317
+ * (undefined or `command` type) already has one, so this is undefined. */
318
+ function syntheticCommand(hook: HookCommand): string | undefined {
319
+ if (hook.type === 'http') return hook.url
320
+ if (hook.type === 'prompt' || hook.type === 'agent') return hook.prompt
321
+ if (hook.type === 'mcp_tool') return `${hook.server}:${hook.tool}`
322
+ return undefined
323
+ }
324
+
325
+ /** A matched entry with its `command` filled in: mirroring the synthetic identity into
326
+ * `command` keeps dedup, timeout messages and display working for non-shell hooks. */
327
+ function withCommand(raw: HookCommand): HookCommand {
328
+ const identity = syntheticCommand(raw)
329
+ return identity !== undefined && typeof raw.command !== 'string' ? { ...raw, command: identity } : raw
330
+ }
331
+
252
332
  /** Command specs whose matcher applies to any of the given tool/source names.
253
333
  * Multiple candidates let one event offer both the pi name and its Claude alias. */
254
334
  export function matchingCommands(matchers: HookMatcher[] | undefined, names: string | readonly string[]): HookCommand[] {
@@ -258,11 +338,7 @@ export function matchingCommands(matchers: HookMatcher[] | undefined, names: str
258
338
  for (const entry of matchers ?? []) {
259
339
  if (!matcherApplies(entry.matcher, candidates)) continue
260
340
  for (const raw of (entry.hooks ?? []).filter(isRunnableHook)) {
261
- // An http/prompt/agent/mcp_tool entry has no `command`; its identity is the
262
- // url / prompt / server:tool. Mirroring it into `command` keeps dedup, timeout
263
- // messages and display working.
264
- const identity = raw.type === 'http' ? raw.url : raw.type === 'prompt' || raw.type === 'agent' ? raw.prompt : raw.type === 'mcp_tool' ? `${raw.server}:${raw.tool}` : undefined
265
- const hook = identity !== undefined && typeof raw.command !== 'string' ? { ...raw, command: identity } : raw
341
+ const hook = withCommand(raw)
266
342
  // Claude runs a handler defined in more than one settings file once.
267
343
  if (seen.has(hook.command)) continue
268
344
  seen.add(hook.command)
@@ -272,6 +348,43 @@ export function matchingCommands(matchers: HookMatcher[] | undefined, names: str
272
348
  return result
273
349
  }
274
350
 
351
+ /** A hook entry's display identity for the /hooks viewer: the command for shell
352
+ * hooks, otherwise the type-qualified url / prompt / server:tool. A missing field
353
+ * is named rather than hidden, since a misconfigured entry is exactly what the
354
+ * viewer exists to surface. */
355
+ function hookIdentity(hook: HookCommand | null | undefined): string {
356
+ // A hand-edited settings file can leave a null (or otherwise empty) entry in a
357
+ // hooks array; name it rather than let it crash the viewer that exists to surface
358
+ // exactly this kind of misconfiguration.
359
+ const record: Partial<HookCommand> = hook ?? {}
360
+ const type = record.type ?? 'command'
361
+ if (type === 'http') return `http: ${record.url ?? record.command ?? '(missing url)'}`
362
+ if (type === 'prompt' || type === 'agent') return `${type}: ${record.prompt ?? record.command ?? '(missing prompt)'}`
363
+ if (type === 'mcp_tool') return `mcp_tool: ${record.server ?? '(missing server)'}:${record.tool ?? '(missing tool)'}`
364
+ return `command: ${record.command ?? '(missing command)'}`
365
+ }
366
+
367
+ /** Render the resolved hooks config as a readable per-event summary for /hooks:
368
+ * one line per configured hook with its matcher, identity and, when known, the
369
+ * settings file it came from. Pure formatting of already-resolved data. */
370
+ export function formatHooksSummary(config: HooksConfig, sources?: Map<HookMatcher, string>): string {
371
+ const lines: string[] = []
372
+ for (const [event, matchers] of Object.entries(config)) {
373
+ const entryLines: string[] = []
374
+ for (const entry of matchers) {
375
+ const matcher = entry.matcher || '*'
376
+ const source = sources?.get(entry)
377
+ const suffix = source ? ` (${source})` : ''
378
+ for (const hook of entry.hooks ?? []) {
379
+ entryLines.push(` [${matcher}] ${hookIdentity(hook)}${suffix}`)
380
+ }
381
+ }
382
+ if (entryLines.length > 0) lines.push(`${event}:`, ...entryLines)
383
+ }
384
+ if (lines.length === 0) return 'No hooks configured. Add a "hooks" section to ~/.claude/settings.json or .claude/settings.json.'
385
+ return lines.join('\n')
386
+ }
387
+
275
388
  function tryParseJson(text: string): { hookSpecificOutput?: { permissionDecision?: string; permissionDecisionReason?: string; additionalContext?: string; updatedInput?: unknown }; decision?: string; reason?: string; continue?: boolean; stopReason?: string; systemMessage?: string } | undefined {
276
389
  try {
277
390
  return JSON.parse(text)
@@ -369,7 +482,7 @@ function interpolateHeaders(headers: Record<string, string> | undefined, allowed
369
482
  const allowedSet = new Set(allowed ?? [])
370
483
  const out: Record<string, string> = {}
371
484
  for (const [key, value] of Object.entries(headers ?? {})) {
372
- out[key] = value.replace(/\$(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))/g, (_token, braced?: string, bare?: string) => {
485
+ out[key] = value.replace(/\$(?:\{([A-Za-z_]\w*)\}|([A-Za-z_]\w*))/g, (_token, braced?: string, bare?: string) => {
373
486
  const name = braced ?? bare ?? ''
374
487
  return allowedSet.has(name) ? (process.env[name] ?? '') : ''
375
488
  })
@@ -382,12 +495,15 @@ function interpolateHeaders(headers: Record<string, string> | undefined, allowed
382
495
  * with a valid JSON body renders a decision, read exactly like command stdout.
383
496
  * Everything else, including non-2xx statuses, connection failures and timeouts,
384
497
  * is a non-blocking error by contract, so none of these outcomes ever reports
385
- * `timedOut`, which PreToolUse fails closed on. The user wrote the URL into their
386
- * own settings, so it carries the same trust as a command hook's shell string and
387
- * gets no SSRF screening.
498
+ * `timedOut`, which PreToolUse fails closed on. Claude's `allowedHttpHookUrls`
499
+ * allowlist gates the fetch itself: a URL matching no entry is never contacted,
500
+ * so a settings file cannot point a hook at an arbitrary endpoint and exfiltrate
501
+ * the payload; when the setting is absent there are no restrictions, as Claude
502
+ * documents. A blocked hook renders no decision, like every other http failure.
388
503
  */
389
- export async function runHttpHook(hook: { type?: string; command: string; url?: string; headers?: Record<string, string>; allowedEnvVars?: string[] }, payload: unknown, timeoutMs: number): Promise<HookRunResult> {
504
+ export async function runHttpHook(hook: { type?: string; command: string; url?: string; headers?: Record<string, string>; allowedEnvVars?: string[] }, payload: unknown, timeoutMs: number, allowedUrls?: string[]): Promise<HookRunResult> {
390
505
  const url = hook.url ?? hook.command
506
+ if (!httpUrlAllowed(url, allowedUrls)) return { code: 1, stdout: '', stderr: `${url} does not match allowedHttpHookUrls; the hook was not called`, timedOut: false }
391
507
  try {
392
508
  const response = await fetch(url, {
393
509
  method: 'POST',
@@ -538,8 +654,9 @@ function replaceRecord(target: Record<string, unknown>, next: Record<string, unk
538
654
  Object.assign(target, next)
539
655
  }
540
656
 
541
- /** Claude surfaces a hook error notice and the action proceeds; silence would read a
542
- * guard that never ran as a clean allow. */
657
+ /** Claude surfaces a hook error notice; on ungated events the action proceeds, while
658
+ * PreToolUse and UserPromptSubmit additionally fail closed on the same results (see
659
+ * their spawnFailed checks). Silence would hide that a guard never ran. */
543
660
  function surfaceHookFailures(commands: HookCommand[], results: HookRunResult[], notify?: SystemMessageSink): void {
544
661
  if (!notify) return
545
662
  for (const [i, result] of results.entries()) {
@@ -571,6 +688,10 @@ export async function runPreToolUse(config: HooksConfig, toolName: string, toolI
571
688
  // A killed hook never reached its verdict, and SIGKILL leaves a null exit code that
572
689
  // would otherwise read as a clean allow. Fail closed instead.
573
690
  if (result.timedOut) return { block: true, reason: `Hook timed out after ${timeoutMs(commands[i])}ms: ${commands[i].command}` }
691
+ // A hook that never spawned (EMFILE, missing /bin/sh) reached no verdict either;
692
+ // its code 0 must fail closed like a timeout, not read as an allow exactly when
693
+ // the machine is degraded.
694
+ if (result.spawnFailed) return { block: true, reason: `Hook failed to run: ${commands[i].command}: ${result.stderr.trim() || 'unknown error'}` }
574
695
  }
575
696
  if (onSystemMessage) surfaceSystemMessages(results, onSystemMessage)
576
697
  // A hard deny wins over an ask, matching Claude's deny > ask > allow precedence:
@@ -621,6 +742,8 @@ export async function runUserPromptSubmit(config: HooksConfig, prompt: string, r
621
742
  surfaceHookFailures(commands, results, onSystemMessage)
622
743
  for (const [i, result] of results.entries()) {
623
744
  if (result.timedOut) return { block: true, reason: `Hook timed out after ${timeoutMs(commands[i])}ms: ${commands[i].command}`, context: '' }
745
+ // No verdict was delivered, so fail closed like a timeout (see runPreToolUse).
746
+ if (result.spawnFailed) return { block: true, reason: `Hook failed to run: ${commands[i].command}: ${result.stderr.trim() || 'unknown error'}`, context: '' }
624
747
  }
625
748
  if (onSystemMessage) surfaceSystemMessages(results, onSystemMessage)
626
749
  const contexts: string[] = []
@@ -646,12 +769,34 @@ function claudeSpelling(map: Record<string, string>, raw: string): { names: stri
646
769
  return { names: value === raw ? [raw] : [raw, value], value }
647
770
  }
648
771
 
772
+ /** The feedback lines one PostToolUse/PostToolUseFailure result appends next to the
773
+ * tool result: a block notice (exit-2 stderr, or decision:block on success) followed
774
+ * by any additionalContext. A failed tool cannot be blocked, so its stderr is shown
775
+ * but never a decision:block verdict. */
776
+ function postToolFeedback(result: HookRunResult, eventName: string, isError: boolean): string[] {
777
+ const lines: string[] = []
778
+ const parsed = tryParseJson(result.stdout)
779
+ // A failed tool cannot be blocked, but the hook's stderr is still shown; on
780
+ // success, exit-2 / decision:block feed back as a block notice.
781
+ if (!result.timedOut && result.code === 2) lines.push(`${eventName} hook: ${result.stderr.trim() || (isError ? 'hook reported an error' : 'Blocked by hook')}`)
782
+ else if (!isError && parsed?.decision === 'block') lines.push(`PostToolUse hook: ${parsed.reason ?? 'Blocked by hook'}`)
783
+ const context = parsed?.hookSpecificOutput?.additionalContext
784
+ if (context) lines.push(context)
785
+ return lines
786
+ }
787
+
649
788
  export default function hooksExtension(pi: ExtensionAPI) {
650
789
  let config: HooksConfig = {}
651
790
  let projectDir = ''
791
+ /** Claude's allowedHttpHookUrls allowlist, resolved from the settings chain. */
792
+ let allowedHttpHookUrls: string[] | undefined
652
793
  let pendingSessionContext: string[] = []
653
794
  let stopHookActive = false
654
795
  let sessionCtx: ExtensionContext | undefined
796
+ /** Claude's disableAllHooks escape hatch was set somewhere in the honored chain. */
797
+ let hooksDisabled = false
798
+ /** Which settings file each resolved entry came from, for the /hooks viewer. */
799
+ const hookSources = new Map<HookMatcher, string>()
655
800
  /** Claude sends session_id, transcript_path, cwd and effort on every payload. */
656
801
  const commonPayload = (ctx: ExtensionContext): Record<string, unknown> => {
657
802
  const common: Record<string, unknown> = { session_id: ctx.sessionManager.getSessionId(), cwd: ctx.cwd, permission_mode: permissionMode }
@@ -666,7 +811,7 @@ export default function hooksExtension(pi: ExtensionAPI) {
666
811
  (ctx: ExtensionContext, extra?: Record<string, unknown>): HookRunner =>
667
812
  (hook, payload, ms) => {
668
813
  const merged = { ...commonPayload(ctx), ...extra, ...(payload as Record<string, unknown>) }
669
- if (hook.type === 'http') return runHttpHook(hook, merged, ms)
814
+ if (hook.type === 'http') return runHttpHook(hook, merged, ms, allowedHttpHookUrls)
670
815
  if (hook.type === 'prompt') return runPromptHook(hook, merged, ctx.model, ms)
671
816
  if (hook.type === 'agent') return runAgentHook(hook, merged, ms, (ctx.model as { id?: string } | undefined)?.id)
672
817
  if (hook.type === 'mcp_tool') return runMcpToolHook(hook, merged, ms)
@@ -717,8 +862,14 @@ export default function hooksExtension(pi: ExtensionAPI) {
717
862
  const ctx = sessionCtx
718
863
  const eventName = data.phase === 'start' ? 'SubagentStart' : 'SubagentStop'
719
864
  const payload = { hook_event_name: eventName, agent_type: data.agentType, agent_id: data.agentId }
720
- const results = await runNotifyHooks(matchingCommands(config[eventName], data.agentType), payload, boundRunner(ctx))
721
- surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
865
+ try {
866
+ const results = await runNotifyHooks(matchingCommands(config[eventName], data.agentType), payload, boundRunner(ctx))
867
+ surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
868
+ } catch {
869
+ // The bus outlives the session: an event landing between /new disposing this
870
+ // ctx and the next session_start hits disposed getters, and nothing awaits a
871
+ // bus listener, so a throw here would escape as an unhandled rejection.
872
+ }
722
873
  })
723
874
 
724
875
  pi.on('session_start', async (event, ctx) => {
@@ -728,10 +879,21 @@ export default function hooksExtension(pi: ExtensionAPI) {
728
879
  // referencing $CLAUDE_PROJECT_DIR/.claude/hooks/helper.sh must resolve from a
729
880
  // subdirectory session too.
730
881
  projectDir = repoRoot(ctx.cwd) ?? ctx.cwd
731
- config = loadHooks(hookFiles(ctx.cwd, os.homedir(), trusted))
882
+ const files = hookFiles(ctx.cwd, os.homedir(), trusted)
883
+ hookSources.clear()
884
+ allowedHttpHookUrls = readAllowedHttpHookUrls(files)
885
+ // The disableAllHooks escape hatch, checked before any config loads: with no
886
+ // config resolved, no event, plugin hooks included, can fire a hook.
887
+ hooksDisabled = readDisableAllHooks(files)
888
+ if (hooksDisabled) {
889
+ config = {}
890
+ pendingSessionContext = []
891
+ return
892
+ }
893
+ config = loadHooks(files, hookSources)
732
894
  // Plugins are user-installed and enabled by user settings (see installedPlugins),
733
895
  // so a checked-out repo cannot toggle which code-bearing plugin hooks run.
734
- loadPluginHooks(config, installedPlugins(os.homedir()))
896
+ loadPluginHooks(config, installedPlugins(os.homedir()), hookSources)
735
897
  // "reload" re-fires in-process with the same conversation and would double-run hooks;
736
898
  // a fork is a genuine session begin, which Claude reports as source "fork".
737
899
  if (event.reason === 'reload') return
@@ -784,16 +946,7 @@ export default function hooksExtension(pi: ExtensionAPI) {
784
946
  const run = boundRunner(ctx, { tool_use_id: event.toolCallId })
785
947
  const results = await Promise.all(commands.map((command) => run(command, payload, timeoutMs(command))))
786
948
  surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
787
- const feedback: string[] = []
788
- for (const result of results) {
789
- const parsed = tryParseJson(result.stdout)
790
- // A failed tool cannot be blocked, but the hook's stderr is still shown; on
791
- // success, exit-2 / decision:block feed back as a block notice.
792
- if (!result.timedOut && result.code === 2) feedback.push(`${eventName} hook: ${result.stderr.trim() || (event.isError ? 'hook reported an error' : 'Blocked by hook')}`)
793
- else if (!event.isError && parsed?.decision === 'block') feedback.push(`PostToolUse hook: ${parsed.reason ?? 'Blocked by hook'}`)
794
- const context = parsed?.hookSpecificOutput?.additionalContext
795
- if (context) feedback.push(context)
796
- }
949
+ const feedback = results.flatMap((result) => postToolFeedback(result, eventName, event.isError))
797
950
  if (feedback.length === 0) return
798
951
  return { content: [...event.content, ...feedback.map((text) => ({ type: 'text' as const, text }))] }
799
952
  })
@@ -869,4 +1022,18 @@ export default function hooksExtension(pi: ExtensionAPI) {
869
1022
  const results = await runNotifyHooks(matchingCommands(config.SessionEnd, reason.names), { hook_event_name: 'SessionEnd', reason: reason.value }, boundRunner(ctx))
870
1023
  surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
871
1024
  })
1025
+
1026
+ // Claude's /hooks manages hook configuration; pi-code's is a viewer: hook failures
1027
+ // are otherwise opaque, so showing the resolved chain per event, with the settings
1028
+ // file each entry came from, is the debugging surface.
1029
+ pi.registerCommand('hooks', {
1030
+ description: 'Show the hook configuration resolved from settings',
1031
+ handler: async (_args, ctx) => {
1032
+ if (hooksDisabled) {
1033
+ ctx.ui.notify('All hooks are disabled by the disableAllHooks setting.', 'info')
1034
+ return
1035
+ }
1036
+ ctx.ui.notify(formatHooksSummary(config, hookSources), 'info')
1037
+ },
1038
+ })
872
1039
  }
@@ -16,6 +16,9 @@ import { parseFrontmatter } from '@earendil-works/pi-coding-agent'
16
16
 
17
17
  import { splitSegments } from './shell-split.js'
18
18
 
19
+ /** The pi file tools a Claude path rule can govern. */
20
+ export type PathRuleTool = 'read' | 'edit' | 'write'
21
+
19
22
  export interface ParsedCommand {
20
23
  description: string
21
24
  argumentHint?: string
@@ -23,7 +26,7 @@ export interface ParsedCommand {
23
26
  /** Claude `Bash(...)` specifiers, present only when every bash grant is scoped. */
24
27
  bashRules?: string[]
25
28
  /** Claude path rules per pi file tool, from Read(...)/Edit(...)/Write(...) grants. */
26
- pathRules?: Partial<Record<'read' | 'edit' | 'write', string[]>>
29
+ pathRules?: Partial<Record<PathRuleTool, string[]>>
27
30
  /** Names from the `arguments:` frontmatter list, mapped to positions in order. */
28
31
  argumentNames?: string[]
29
32
  /** Tools removed from the pool while the command's turn runs. */
@@ -66,6 +69,10 @@ const CLAUDE_TOOL_MAP: Record<string, string> = {
66
69
  task: 'subagent',
67
70
  askuserquestion: 'question',
68
71
  exitplanmode: 'plan_mode_complete',
72
+ // Claude's name for the tool this package registers so the model can run user slash
73
+ // commands; without it `allowed-tools: SlashCommand` matched nothing and the grant
74
+ // could neither keep nor drop the tool.
75
+ slashcommand: 'slash_command',
69
76
  }
70
77
 
71
78
  /**
@@ -118,18 +125,78 @@ export interface ToolGrants {
118
125
  /** Claude path rules per pi file tool, absent for a tool with an unscoped grant.
119
126
  * Edit scopes govern writes too, as Claude documents; Write scopes are honored
120
127
  * rather than Claude's accept-and-warn-then-ignore, which would fail open here. */
121
- pathRules?: Partial<Record<'read' | 'edit' | 'write', string[]>>
128
+ pathRules?: Partial<Record<PathRuleTool, string[]>>
122
129
  /** Entries that carried an argument scope, in their original spelling. */
123
130
  scopedEntries: string[]
124
131
  }
125
132
 
126
133
  /** The pi file tools one Claude path-ruled entry governs. */
127
- const PATH_RULE_TOOLS: Record<string, Array<'read' | 'edit' | 'write'>> = {
134
+ const PATH_RULE_TOOLS: Record<string, Array<PathRuleTool>> = {
128
135
  read: ['read'],
129
136
  edit: ['edit', 'write'],
130
137
  write: ['write'],
131
138
  }
132
139
 
140
+ /** The tools, scopes, and path rules accumulated while scanning one grant list. */
141
+ interface GrantAccumulator {
142
+ tools: string[]
143
+ scopedEntries: string[]
144
+ bashRules: string[]
145
+ bashUnscoped: boolean
146
+ pathScopes: Record<PathRuleTool, string[]>
147
+ pathUnscoped: Set<PathRuleTool>
148
+ }
149
+
150
+ function createGrantAccumulator(): GrantAccumulator {
151
+ return { tools: [], scopedEntries: [], bashRules: [], bashUnscoped: false, pathScopes: { read: [], edit: [], write: [] }, pathUnscoped: new Set() }
152
+ }
153
+
154
+ /** Coerce a raw grant value to its string entries: a YAML list stays a list, a
155
+ * comma-separated string is split, and anything else (or a list with a non-string
156
+ * member) is rejected as undefined, the same "not a grant" signal as an absent field. */
157
+ function coerceGrantItems(raw: unknown): string[] | undefined {
158
+ let items: unknown[]
159
+ if (Array.isArray(raw)) items = raw
160
+ else if (typeof raw === 'string') items = toolEntries(raw)
161
+ else return undefined
162
+ if (items.some((item) => typeof item !== 'string')) return undefined
163
+ return items as string[]
164
+ }
165
+
166
+ /** Fold one grant entry into the accumulator: the base tool is granted, an unscoped
167
+ * entry marks its tools wide, and a scoped entry records the scope for bash and the
168
+ * file tools it governs. */
169
+ function addGrantEntry(acc: GrantAccumulator, item: string): void {
170
+ const entry = item.trim()
171
+ const name = normalizeToolName(entry)
172
+ if (!name) return
173
+ if (!acc.tools.includes(name)) acc.tools.push(name)
174
+ const open = entry.indexOf('(')
175
+ if (open === -1) {
176
+ if (name === 'bash') acc.bashUnscoped = true
177
+ for (const tool of PATH_RULE_TOOLS[name] ?? []) acc.pathUnscoped.add(tool)
178
+ return
179
+ }
180
+ acc.scopedEntries.push(entry)
181
+ const scope = entry.slice(open + 1, entry.endsWith(')') ? -1 : undefined).trim()
182
+ // An empty specifier (`Bash()`, `Read()`) matches nothing and must not read as
183
+ // the unscoped grant it explicitly is not: it is recorded so the tool stays
184
+ // restricted, and the matchers treat an empty rule as matching no input.
185
+ if (name === 'bash') acc.bashRules.push(scope)
186
+ for (const tool of PATH_RULE_TOOLS[name] ?? []) acc.pathScopes[tool].push(scope)
187
+ }
188
+
189
+ /** The per-tool path rules from a scan: a tool with any unscoped grant is omitted
190
+ * (it is wide), one with only scoped grants keeps them, and the whole map is absent
191
+ * when no tool carries a rule. */
192
+ function buildPathRules(acc: GrantAccumulator): ToolGrants['pathRules'] {
193
+ const pathRules: NonNullable<ToolGrants['pathRules']> = {}
194
+ for (const tool of ['read', 'edit', 'write'] as const) {
195
+ if (!acc.pathUnscoped.has(tool) && acc.pathScopes[tool].length > 0) pathRules[tool] = acc.pathScopes[tool]
196
+ }
197
+ return Object.keys(pathRules).length > 0 ? pathRules : undefined
198
+ }
199
+
133
200
  /**
134
201
  * A tool grant is either a comma-separated string or a YAML list, and the two mean the
135
202
  * same thing. An empty list is not the same as an absent one: it says no tools, so it
@@ -143,47 +210,15 @@ const PATH_RULE_TOOLS: Record<string, Array<'read' | 'edit' | 'write'>> = {
143
210
  * whose widening reaches everything, so it is the one enforced.
144
211
  */
145
212
  export function parseToolGrants(raw: unknown): ToolGrants | undefined {
146
- if (raw === undefined || raw === null) return undefined
147
- let items: unknown[]
148
- if (Array.isArray(raw)) items = raw
149
- else if (typeof raw === 'string') items = toolEntries(raw)
150
- else return undefined
151
- if (items.some((item) => typeof item !== 'string')) return undefined
152
-
153
- const tools: string[] = []
154
- const scopedEntries: string[] = []
155
- const bashRules: string[] = []
156
- let bashUnscoped = false
157
- const pathScopes: Record<'read' | 'edit' | 'write', string[]> = { read: [], edit: [], write: [] }
158
- const pathUnscoped = new Set<'read' | 'edit' | 'write'>()
159
- for (const item of items as string[]) {
160
- const entry = item.trim()
161
- const name = normalizeToolName(entry)
162
- if (!name) continue
163
- if (!tools.includes(name)) tools.push(name)
164
- const open = entry.indexOf('(')
165
- if (open === -1) {
166
- if (name === 'bash') bashUnscoped = true
167
- for (const tool of PATH_RULE_TOOLS[name] ?? []) pathUnscoped.add(tool)
168
- continue
169
- }
170
- scopedEntries.push(entry)
171
- const scope = entry.slice(open + 1, entry.endsWith(')') ? -1 : undefined).trim()
172
- // An empty specifier (`Bash()`, `Read()`) matches nothing and must not read as
173
- // the unscoped grant it explicitly is not: it is recorded so the tool stays
174
- // restricted, and the matchers treat an empty rule as matching no input.
175
- if (name === 'bash') bashRules.push(scope)
176
- for (const tool of PATH_RULE_TOOLS[name] ?? []) pathScopes[tool].push(scope)
177
- }
178
- const pathRules: ToolGrants['pathRules'] = {}
179
- for (const tool of ['read', 'edit', 'write'] as const) {
180
- if (!pathUnscoped.has(tool) && pathScopes[tool].length > 0) pathRules[tool] = pathScopes[tool]
181
- }
213
+ const items = coerceGrantItems(raw)
214
+ if (items === undefined) return undefined
215
+ const acc = createGrantAccumulator()
216
+ for (const item of items) addGrantEntry(acc, item)
182
217
  return {
183
- tools,
184
- scopedEntries,
185
- bashRules: !bashUnscoped && bashRules.length > 0 ? bashRules : undefined,
186
- pathRules: Object.keys(pathRules).length > 0 ? pathRules : undefined,
218
+ tools: acc.tools,
219
+ scopedEntries: acc.scopedEntries,
220
+ bashRules: !acc.bashUnscoped && acc.bashRules.length > 0 ? acc.bashRules : undefined,
221
+ pathRules: buildPathRules(acc),
187
222
  }
188
223
  }
189
224
 
@@ -193,17 +228,25 @@ const text = (value: unknown): string => {
193
228
  return typeof value === 'number' || typeof value === 'boolean' ? String(value) : ''
194
229
  }
195
230
 
231
+ /** YAML's affirmative boolean spellings. Claude documents `disable-model-invocation:
232
+ * true`, but a command file is hand-written YAML where `yes`, `on`, and `1` are all
233
+ * ordinary spellings of true, and pi's parser hands those back as the raw string or
234
+ * number rather than a boolean. A flag that gates a command off from the model has to
235
+ * honor them, or a command the user marked off-limits is silently offered to it. */
236
+ const YAML_TRUE = new Set(['true', 'yes', 'on', 'y', '1'])
237
+ const isFlagEnabled = (value: unknown): boolean => value === true || YAML_TRUE.has(text(value).toLowerCase())
238
+
196
239
  /** Claude writes `argument-hint: [pr]`, which YAML reads as a list; render it back. */
197
240
  const hint = (value: unknown): string => (Array.isArray(value) ? `[${value.join(', ')}]` : text(value))
198
241
 
199
- const ARGUMENT_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/
242
+ const ARGUMENT_NAME = /^[A-Za-z_]\w*$/
200
243
 
201
244
  /** The `arguments:` frontmatter: a YAML list or a space- or comma-separated string
202
245
  * of names mapping to positions in order. Invalid names are dropped, and ARGUMENTS
203
246
  * itself is reserved by the built-in placeholder. */
204
247
  function parseArgumentNames(raw: unknown): string[] | undefined {
205
248
  let items: string[]
206
- if (Array.isArray(raw)) items = raw.map((entry) => String(entry))
249
+ if (Array.isArray(raw)) items = raw.map(String)
207
250
  else if (typeof raw === 'string') items = raw.split(/[\s,]+/)
208
251
  else return undefined
209
252
  const names = items.map((name) => name.trim()).filter((name) => ARGUMENT_NAME.test(name) && name !== 'ARGUMENTS')
@@ -233,7 +276,7 @@ export function parseCommandFile(content: string): ParsedCommand {
233
276
  disallowedTools: parseToolGrants(frontmatter['disallowed-tools'])?.tools,
234
277
  shell: SHELLS.has(shell) ? shell : undefined,
235
278
  model: text(frontmatter.model) || undefined,
236
- disableModelInvocation: disable === true || text(disable) === 'true',
279
+ disableModelInvocation: isFlagEnabled(disable),
237
280
  body,
238
281
  }
239
282
  }
@@ -300,8 +343,8 @@ export function substituteArgsDetailed(body: string, args: string, names: string
300
343
  return value
301
344
  }
302
345
  const text = body.replaceAll(argPattern(names), (token, bracketIdx?: string, defIdx?: string, defVal?: string, argsDefault?: string, shorthandIdx?: string, name?: string) => {
303
- if (token === '\\\\') return token
304
- if (token === '\\$') return '$'
346
+ if (token === String.raw`\\`) return token
347
+ if (token === String.raw`\$`) return '$'
305
348
  if (bracketIdx !== undefined) return fill(parts[Number(bracketIdx)], token)
306
349
  if (defIdx !== undefined) return fill(parts[Number(defIdx)], defVal ?? '')
307
350
  if (argsDefault !== undefined) {
@@ -20,6 +20,16 @@ function decodeAllEntities(text: string): string {
20
20
 
21
21
  const stripInnerTags = (html: string): string => html.replace(/<[^<>]*>/g, '')
22
22
 
23
+ // Strip leading and trailing newline runs in linear time. The equivalent
24
+ // /^\n+|\n+$/g backtracks super-linearly on a long run of newlines (S8786).
25
+ const trimNewlines = (value: string): string => {
26
+ let start = 0
27
+ let end = value.length
28
+ while (start < end && value[start] === '\n') start++
29
+ while (end > start && value[end - 1] === '\n') end--
30
+ return value.slice(start, end)
31
+ }
32
+
23
33
  export function htmlToMarkdown(html: string): string {
24
34
  // Pre blocks are lifted out first so no later transform touches their content.
25
35
  const preBodies: string[] = []
@@ -27,7 +37,7 @@ export function htmlToMarkdown(html: string): string {
27
37
  .replace(/<!--[\s\S]*?-->/g, ' ')
28
38
  .replace(/<(script|style|noscript|head|svg)\b[^<>]*>[\s\S]*?<\/\1[^<>]*>/gi, ' ')
29
39
  .replace(/<pre\b[^<>]*>([\s\S]*?)<\/pre>/gi, (_whole, inner: string) => {
30
- preBodies.push(decodeAllEntities(stripInnerTags(inner)).replace(/^\n+|\n+$/g, ''))
40
+ preBodies.push(trimNewlines(decodeAllEntities(stripInnerTags(inner))))
31
41
  return `\n\n\uE000PRE${preBodies.length - 1}\uE000\n\n`
32
42
  })
33
43