pi-code 1.0.13 → 1.0.15

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 CHANGED
@@ -45,7 +45,7 @@ One `pi install` and everything below loads on the next start. `pi list` shows w
45
45
  | Custom slash commands | `.claude/commands/**/*.md` (namespaced `/dir:name`); `$ARGUMENTS` (with the `ARGUMENTS:` append when unused), 0-based `$ARGUMENTS[N]`/`$N`, named `arguments:` frontmatter, `${CLAUDE_SESSION_ID}`/`${CLAUDE_EFFORT}`/`${CLAUDE_SKILL_DIR}`/`${CLAUDE_PROJECT_DIR}` (in bodies and `allowed-tools` rules); `` !`cmd` `` and multi-line ```` ```! ```` bash (whitespace-bounded, merged stderr, 2-minute budget, a failure aborts the invocation with the documented exit-1 carveout), `@file` inlining; `allowed-tools` with `Bash(...)` and `Read`/`Edit`/`Write` path scopes enforced at call time (gitignore anchors, Edit governs writes), `disallowed-tools`, `argument-hint`, `model` (switches the session model for the command's turn, restored after), `effort` (raises reasoning for the turn, restored after), `shell: powershell` (injected spans run through PowerShell when a `pwsh` binary is present, else `/bin/sh`); the model can also run a command itself through the `SlashCommand` tool (Claude's `SlashCommand` in `allowed-tools`), steered by `when_to_use` and opted out per file with `disable-model-invocation` (`user-invocable: false` hides a command from the menu while still exposing it to the model); `disableSkillShellExecution` (managed and user always, project when trusted) replaces every `!` span with a policy-disabled placeholder; project commands gated on approval | `commands.ts` |
46
46
  | `/init` | generates a project context file: detects an existing `AGENTS.md`/`CLAUDE.md` (proposes improvements) or none (creates `AGENTS.md`, pi's preferred name), ingesting `.cursor/rules`, `.cursorrules`, and `.github/copilot-instructions.md` when present; drives the main agent with full tools via a prompt (not a tool-less completion) so it analyzes the codebase and writes the file itself | `init.ts` |
47
47
  | Skills | `.claude/skills` → pi skill discovery, project skills gated on approval (pi reads `name`, `description`, `disable-model-invocation`; `allowed-tools` is inert in pi's loader) | `skills.ts` |
48
- | Hooks | `.claude/settings.json` hooks: PreToolUse (blocks, rewrites input via `updatedInput`), PostToolUse (feedback and `additionalContext` land next to the tool result), PostToolUseFailure, SessionStart (context injection), UserPromptSubmit (blocks and injects context), Stop (a block continues the conversation), SubagentStart/SubagentStop, PreCompact, PostCompact, SessionEnd, Notification (idle_prompt, the type pi can source), InstructionsLoaded (observational: fires per loaded context file at session start, plus `path_glob_match` on a scoped-rule attach and `include` per resolved `@import`; deduped per session; `nested_traversal`/`compact` reasons never fire since pi does not lazily load nested CLAUDE.md or reload after compaction); `type: http` entries POST the payload (a 2xx JSON body renders the decision, everything else is non-blocking per Claude's contract), `type: prompt` evaluates in-process against the session model, `type: mcp_tool` calls a connected server's tool, and `type: agent` (experimental) spawns a read-only Read/Grep/Glob subagent that returns the JSON decision (a missing model/server/runner is non-blocking, only a PreToolUse timeout fails closed); Claude matcher semantics incl. `mcp__server__tool` names; payloads carry session_id, transcript_path, cwd, permission_mode, effort; `permissionDecision: "ask"` prompts via a confirm dialog (blocks when headless), SubagentStop/PostToolUseFailure are notify-only, and a timed-out PreToolUse/UserPromptSubmit hook fails closed at a 60s default (Claude: 600s, non-blocking) since pi has no permission backstop; a `command` hook may use exec form (`command` as an argv array, run with no shell) and executes with `CLAUDECODE=1` and `CLAUDE_PROJECT_DIR` set; a user-typed `!`/`!!` bash line runs PreToolUse (there is no PostToolUse for it); `type: http` targets are gated by `allowedHttpHookUrls` (union of managed and settings scopes; unset allows all, `[]` blocks every http hook); a Stop hook may block at most 8 times before the turn ends (`CLAUDE_CODE_STOP_HOOK_BLOCK_CAP`); `disableAllHooks` in any scope turns the system off; `/hooks` prints the resolved configuration | `hooks.ts` |
48
+ | Hooks | `.claude/settings.json` hooks: PreToolUse (blocks, rewrites input via `updatedInput`), PostToolUse (feedback and `additionalContext` land next to the tool result), PostToolUseFailure, SessionStart (context injection), UserPromptSubmit (blocks and injects context), Stop (a block continues the conversation), SubagentStart/SubagentStop, PreCompact, PostCompact, SessionEnd, Notification (idle_prompt, the type pi can source), InstructionsLoaded (observational: fires per loaded context file at session start, plus `path_glob_match` on a scoped-rule attach and `include` per resolved `@import`; deduped per session; `nested_traversal`/`compact` reasons never fire since pi does not lazily load nested CLAUDE.md or reload after compaction); `type: http` entries POST the payload (a 2xx JSON body renders the decision, everything else is non-blocking per Claude's contract), `type: prompt` evaluates in-process against the session model, `type: mcp_tool` calls a connected server's tool, and `type: agent` (experimental) spawns a read-only Read/Grep/Glob subagent that returns the JSON decision (a missing model/server/runner is non-blocking, only a PreToolUse timeout fails closed); Claude matcher semantics incl. `mcp__server__tool` names; payloads carry session_id, transcript_path, cwd, permission_mode, effort; `permissionDecision: "ask"` prompts via a confirm dialog (blocks when headless), SubagentStop/PostToolUseFailure are notify-only, and a timed-out PreToolUse/UserPromptSubmit hook fails closed at a 60s default (Claude: 600s, non-blocking) since pi has no permission backstop; `async`/`asyncRewake` command hooks run in the background on every event (never blocking, no decision, no timeout enforced on `async` while asyncRewake keeps its own; an asyncRewake exit 2 wakes the model with the hook's stderr as a new turn, other completions deliver `systemMessage`/`additionalContext` to the model on the next turn, and hooks still running at session end are killed); a `command` hook may use exec form (`command` as an argv array, run with no shell) and executes with `CLAUDECODE=1` and `CLAUDE_PROJECT_DIR` set; a user-typed `!`/`!!` bash line runs PreToolUse (there is no PostToolUse for it); `type: http` targets are gated by `allowedHttpHookUrls` (union of managed and settings scopes; unset allows all, `[]` blocks every http hook); a Stop hook may block at most 8 times before the turn ends (`CLAUDE_CODE_STOP_HOOK_BLOCK_CAP`); `disableAllHooks` in any scope turns the system off; `/hooks` prints the resolved configuration | `hooks.ts` |
49
49
  | Output styles | `.claude/output-styles` + active `outputStyle`; plus styles shipped by enabled plugins (manifest `outputStyles`, default `output-styles/`, ranked below the user's and project's own); Claude replace semantics with `keep-coding-instructions`; bundled Explanatory/Learning/Proactive; `/output-style [name]` | `output-styles.ts` |
50
50
  | CLAUDE.md `@imports` and rewriting | resolves `@path` imports pi's native loader skips (4-hop depth, budget-capped); loads the user `~/.claude/CLAUDE.md` and the project `.claude/CLAUDE.md` (approval-gated, deduped against the repo-root `CLAUDE.md`/`AGENTS.md` pi loads natively) that pi's own loader does not; loads every `CLAUDE.local.md` from the repo root down to cwd (approval-gated, root first); injects the managed `CLAUDE.md` (a per-OS file beside `managed-settings.json`) and the `claudeMd` string from `managed-settings.json` at the top of context (managed content is never excludable); honors `claudeMdExcludes` (glob/absolute-path skip list from user, approved-project, and managed settings, merged; managed content never excluded); strips block-level HTML comments from CLAUDE.md, rule, and imported bodies (fenced-code comments preserved), so a commented-out `@import` does not expand; with `CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD` set, loads `CLAUDE.md`/`.claude/CLAUDE.md`/`.claude/rules/*.md`/`CLAUDE.local.md` from each `--add-dir` directory (comma-separated for several, since pi's flag is single-value) | `context-imports.ts` |
51
51
  | Settings `env` | `env` blocks from `managed-settings.json`, `~/.claude/settings.json`, and the project `.claude/settings.json`/`settings.local.json` exported into the session (per-key `managed > user > project`); the project scope is approval-gated (a repo's env can redirect providers), a shell `export` outranks user and project but a managed key overrides even that, and a key an approved project set is unset once a later session no longer defines it | `env-settings.ts` |
@@ -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,234 @@
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
+ /** Claude's background contract, honored on `type: "command"` hooks only: `async` runs
21
+ * without blocking its event and with no timeout enforced; `asyncRewake` also runs in
22
+ * the background but keeps its timeout, and wakes the model when the hook exits 2
23
+ * (stderr, or stdout when stderr is empty, feeds back as a new turn). Background hooks
24
+ * render no decision; their JSON `systemMessage`/`additionalContext` reach the model on
25
+ * the next turn, and any still running are killed at session end. */
26
+ async?: boolean
27
+ asyncRewake?: boolean
28
+ /** http entries: the endpoint POSTed to; `command` mirrors it for dedup and display. */
29
+ url?: string
30
+ headers?: Record<string, string>
31
+ allowedEnvVars?: string[]
32
+ /** prompt entries: the prompt sent to the model (`$ARGUMENTS` = the event JSON). */
33
+ prompt?: string
34
+ /** mcp_tool entries: the connected server and tool to call, with optional input. */
35
+ server?: string
36
+ tool?: string
37
+ input?: Record<string, unknown>
38
+ /** prompt/agent entries: an optional model override; agent adds a system prompt. */
39
+ model?: string
40
+ systemPrompt?: string
41
+ }
42
+ export interface HookMatcher {
43
+ matcher?: string
44
+ hooks: HookCommand[]
45
+ }
46
+
47
+ /** Whether a hook runs in the background. Claude documents `async`/`asyncRewake` on
48
+ * `type: "command"` hooks only; on any other type the fields are inert and the hook
49
+ * blocks, exactly as Claude runs it. */
50
+ export function isBackgroundHook(hook: HookCommand): boolean {
51
+ return (hook.type === undefined || hook.type === 'command') && (hook.async === true || hook.asyncRewake === true)
52
+ }
53
+ export type HooksConfig = Record<string, HookMatcher[]>
54
+
55
+ export function isRecord(value: unknown): value is Record<string, unknown> {
56
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
57
+ }
58
+
59
+ /** Settings files to read, newest-winning. Project files load only when trusted, each
60
+ * the nearest of its name at or above cwd (bounded at the repository root, matching
61
+ * the approval walk), so a subdirectory session reads the settings that gated it. */
62
+ export function hookFiles(cwd: string, home: string, trusted: boolean): string[] {
63
+ return claudeSettingsChain(cwd, home, trusted)
64
+ }
65
+
66
+ /** Claude's `disableAllHooks` setting: the escape hatch a user reaches for when a
67
+ * hook misbehaves, so it is honored before any hook runs. Disabled when managed
68
+ * settings or ANY file in the settings chain sets it to `true`; deliberately not
69
+ * last-file-wins, since a repository file re-enabling the hooks the user just
70
+ * disabled in their own settings would defeat the escape hatch. The chain itself
71
+ * already gates project files on trust (see hookFiles). */
72
+ export function readDisableAllHooks(files: string[], managed: Record<string, unknown> = readManagedSettings()): boolean {
73
+ if (managed.disableAllHooks === true) return true
74
+ for (const file of files) {
75
+ try {
76
+ const parsed: unknown = JSON.parse(fs.readFileSync(file, 'utf-8'))
77
+ if (isRecord(parsed) && parsed.disableAllHooks === true) return true
78
+ } catch {
79
+ // missing or invalid file: skip
80
+ }
81
+ }
82
+ return false
83
+ }
84
+
85
+ /** Claude's `allowedHttpHookUrls` setting: URL patterns http hooks may target, with
86
+ * `*` as a wildcard. Per Claude's documentation: undefined (no source sets the key)
87
+ * means no restrictions, an empty array blocks every http hook, and arrays merge
88
+ * across settings sources. Merging is a union of managed settings plus every file in
89
+ * the chain; the chain already gates project files on trust (see hookFiles), and a
90
+ * trusted project can run arbitrary shell hooks anyway, so letting it extend the
91
+ * allowlist is no escalation. */
92
+ export function readAllowedHttpHookUrls(files: string[], managed: Record<string, unknown> = readManagedSettings()): string[] | undefined {
93
+ let found: string[] | undefined
94
+ const collect = (value: unknown): void => {
95
+ if (!Array.isArray(value)) return
96
+ found = [...(found ?? []), ...value.filter((entry): entry is string => typeof entry === 'string')]
97
+ }
98
+ collect(managed.allowedHttpHookUrls)
99
+ for (const file of files) {
100
+ try {
101
+ const parsed: unknown = JSON.parse(fs.readFileSync(file, 'utf-8'))
102
+ if (isRecord(parsed)) collect(parsed.allowedHttpHookUrls)
103
+ } catch {
104
+ // missing or invalid file: skip
105
+ }
106
+ }
107
+ return found
108
+ }
109
+
110
+ /** Whether an http hook may target `url`. `*` in an allowlist entry matches any run
111
+ * of characters; everything else is literal and the whole URL must match. An
112
+ * undefined allowlist means the setting is absent, so there are no restrictions. */
113
+ export function httpUrlAllowed(url: string, allowlist: string[] | undefined): boolean {
114
+ if (allowlist === undefined) return true
115
+ return allowlist.some((pattern) => {
116
+ const literal = pattern.split('*').map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`))
117
+ return new RegExp(`^${literal.join('.*')}$`).test(url)
118
+ })
119
+ }
120
+
121
+ export function loadHooks(files: string[], sources?: Map<HookMatcher, string>): HooksConfig {
122
+ const config: HooksConfig = {}
123
+ for (const file of files) {
124
+ let raw: string
125
+ try {
126
+ raw = fs.readFileSync(file, 'utf-8')
127
+ } catch {
128
+ continue
129
+ }
130
+ mergeHooksJson(config, raw, file, sources)
131
+ }
132
+ return config
133
+ }
134
+
135
+ function mergeHooksJson(config: HooksConfig, raw: string, source: string, sources?: Map<HookMatcher, string>): void {
136
+ let parsed: { hooks?: HooksConfig }
137
+ try {
138
+ parsed = JSON.parse(raw)
139
+ } catch {
140
+ return
141
+ }
142
+ for (const [event, matchers] of Object.entries(parsed?.hooks ?? {})) {
143
+ if (!Array.isArray(matchers)) continue
144
+ // Entries are validated here rather than where they run: a hand-edited settings
145
+ // file that writes `hooks` as an object instead of a list used to throw out of
146
+ // the tool_call handler, and pi turns that into an error result, so every tool
147
+ // call for the rest of the session failed with an opaque type error.
148
+ const usable = matchers.filter((entry) => isUsableMatcher(entry, source, event))
149
+ if (usable.length === 0) continue
150
+ config[event] = [...(config[event] ?? []), ...usable]
151
+ // Each parse produces fresh entry objects, so object identity keys the /hooks
152
+ // viewer's source attribution without touching the entries themselves.
153
+ for (const entry of usable) sources?.set(entry, source)
154
+ }
155
+ }
156
+
157
+ /** Each enabled plugin's hooks (hooks/hooks.json, or wherever the manifest points),
158
+ * with ${CLAUDE_PLUGIN_ROOT}/${CLAUDE_PLUGIN_DATA} substituted before parsing so a
159
+ * hook can name its bundled scripts by real path. */
160
+ export function loadPluginHooks(config: HooksConfig, plugins: InstalledPlugin[], sources?: Map<HookMatcher, string>): void {
161
+ for (const plugin of plugins) {
162
+ const declared = plugin.manifest.hooks
163
+ // An inline hooks object; an array is not a valid hooks map (it would parse to
164
+ // numeric event keys), so it falls through to the default path rather than
165
+ // silently registering nothing.
166
+ if (declared !== null && typeof declared === 'object' && !Array.isArray(declared)) {
167
+ mergeHooksJson(config, substitutePluginVars(JSON.stringify({ hooks: declared }), plugin), `${plugin.name} (plugin.json)`, sources)
168
+ continue
169
+ }
170
+ const file = path.resolve(plugin.root, typeof declared === 'string' ? declared : path.join('hooks', 'hooks.json'))
171
+ try {
172
+ mergeHooksJson(config, substitutePluginVars(fs.readFileSync(file, 'utf-8'), plugin), file, sources)
173
+ } catch {
174
+ // a plugin without hooks contributes nothing
175
+ }
176
+ }
177
+ }
178
+
179
+ /** A matcher entry pi-code can run: an object whose `hooks` is a list. Anything else
180
+ * is reported by name and skipped, so one bad entry costs its own hooks, not the
181
+ * session's tool calls. */
182
+ function isUsableMatcher(entry: unknown, file: string, event: string): entry is HookMatcher {
183
+ const candidate = entry as HookMatcher | null
184
+ if (candidate === null || typeof candidate !== 'object') {
185
+ console.warn(`pi-code-hooks: ignoring a non-object ${event} entry in ${file}`)
186
+ return false
187
+ }
188
+ if (candidate.hooks !== undefined && !Array.isArray(candidate.hooks)) {
189
+ console.warn(`pi-code-hooks: ignoring ${event} entry in ${file}: "hooks" must be a list`)
190
+ return false
191
+ }
192
+ if (candidate.matcher !== undefined && typeof candidate.matcher !== 'string') {
193
+ console.warn(`pi-code-hooks: ignoring ${event} entry in ${file}: "matcher" must be a string`)
194
+ return false
195
+ }
196
+ return true
197
+ }
198
+
199
+ /** A hook entry's display identity for the /hooks viewer: the command for shell
200
+ * hooks, otherwise the type-qualified url / prompt / server:tool. A missing field
201
+ * is named rather than hidden, since a misconfigured entry is exactly what the
202
+ * viewer exists to surface. */
203
+ function hookIdentity(hook: HookCommand | null | undefined): string {
204
+ // A hand-edited settings file can leave a null (or otherwise empty) entry in a
205
+ // hooks array; name it rather than let it crash the viewer that exists to surface
206
+ // exactly this kind of misconfiguration.
207
+ const record: Partial<HookCommand> = hook ?? {}
208
+ const type = record.type ?? 'command'
209
+ if (type === 'http') return `http: ${record.url ?? record.command ?? '(missing url)'}`
210
+ if (type === 'prompt' || type === 'agent') return `${type}: ${record.prompt ?? record.command ?? '(missing prompt)'}`
211
+ if (type === 'mcp_tool') return `mcp_tool: ${record.server ?? '(missing server)'}:${record.tool ?? '(missing tool)'}`
212
+ return `command: ${record.command ?? '(missing command)'}`
213
+ }
214
+
215
+ /** Render the resolved hooks config as a readable per-event summary for /hooks:
216
+ * one line per configured hook with its matcher, identity and, when known, the
217
+ * settings file it came from. Pure formatting of already-resolved data. */
218
+ export function formatHooksSummary(config: HooksConfig, sources?: Map<HookMatcher, string>): string {
219
+ const lines: string[] = []
220
+ for (const [event, matchers] of Object.entries(config)) {
221
+ const entryLines: string[] = []
222
+ for (const entry of matchers) {
223
+ const matcher = entry.matcher || '*'
224
+ const source = sources?.get(entry)
225
+ const suffix = source ? ` (${source})` : ''
226
+ for (const hook of entry.hooks ?? []) {
227
+ entryLines.push(` [${matcher}] ${hookIdentity(hook)}${suffix}`)
228
+ }
229
+ }
230
+ if (entryLines.length > 0) lines.push(`${event}:`, ...entryLines)
231
+ }
232
+ if (lines.length === 0) return 'No hooks configured. Add a "hooks" section to ~/.claude/settings.json or .claude/settings.json.'
233
+ return lines.join('\n')
234
+ }
@@ -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
+ }