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 +1 -1
- package/extensions/commands.ts +27 -30
- package/extensions/context-imports.ts +6 -14
- package/extensions/hooks/config.ts +234 -0
- package/extensions/hooks/decisions.ts +169 -0
- package/extensions/hooks/index.ts +497 -0
- package/extensions/hooks/matcher.ts +126 -0
- package/extensions/hooks/runners.ts +269 -0
- package/extensions/internal/command-file.ts +1 -1
- package/extensions/internal/managed-settings.ts +5 -0
- package/extensions/internal/output-guard.ts +1 -1
- package/extensions/internal/settings-chain.ts +24 -0
- package/extensions/internal/stat-token.ts +14 -0
- package/extensions/internal/turn-override.ts +73 -0
- package/extensions/mcp/config.ts +159 -0
- package/extensions/mcp/index.ts +513 -0
- package/extensions/mcp/listing.ts +92 -0
- package/extensions/mcp/mapping.ts +173 -0
- package/extensions/mcp/oauth-flow.ts +80 -0
- package/extensions/mcp/policy.ts +140 -0
- package/extensions/mcp/transport.ts +258 -0
- package/extensions/memory.ts +6 -10
- package/extensions/output-styles.ts +2 -6
- package/extensions/plan-mode/utils.ts +1 -1
- package/extensions/question.ts +2 -2
- package/extensions/status-line.ts +1 -1
- package/extensions/subagent/agents.ts +1 -1
- package/extensions/subagent/index.ts +6 -123
- package/extensions/subagent/render.ts +131 -0
- package/extensions/thinking.ts +25 -31
- package/package.json +1 -1
- package/extensions/hooks.ts +0 -1179
- package/extensions/mcp.ts +0 -1358
package/extensions/hooks.ts
DELETED
|
@@ -1,1179 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Claude Hooks Extension
|
|
3
|
-
*
|
|
4
|
-
* Runs Claude Code's `.claude/settings.json` hooks on pi's lifecycle events, so
|
|
5
|
-
* a project's existing hooks work under pi:
|
|
6
|
-
* - PreToolUse -> pi `tool_call` (can block the tool or rewrite its input), plus
|
|
7
|
-
* pi `user_bash` for a `!`/`!!` command the user runs directly (the
|
|
8
|
-
* model never issues these, so a deny-list guard would otherwise miss
|
|
9
|
-
* them). No pi tool call exists there, so the payload reports the
|
|
10
|
-
* Claude name "Bash"; a deny hands pi a synthetic failed result so
|
|
11
|
-
* the command never runs. UserBashEvent carries no execution result
|
|
12
|
-
* and fires only before the command runs, so it has no PostToolUse
|
|
13
|
-
* counterpart (pi never delivers the output to observe).
|
|
14
|
-
* - PostToolUse -> pi `tool_result` (block reasons and additionalContext are
|
|
15
|
-
* appended next to the tool result, as Claude documents)
|
|
16
|
-
* - SessionStart -> pi `session_start` (stdout/additionalContext is injected as
|
|
17
|
-
* context before the first prompt via `before_agent_start`)
|
|
18
|
-
* - UserPromptSubmit-> pi `input` (can block the prompt via `handled`, or inject
|
|
19
|
-
* additional context by transforming the submitted text)
|
|
20
|
-
* - Stop -> pi `agent_end` (a block feeds its reason back as a new turn,
|
|
21
|
-
* with stop_hook_active as the loop guard)
|
|
22
|
-
* - PreCompact -> pi `session_before_compact` (fire-and-forget)
|
|
23
|
-
* - PostCompact -> pi `session_compact` (fire-and-forget)
|
|
24
|
-
* - PostToolUseFailure -> pi `tool_result` error branch (stderr/additionalContext
|
|
25
|
-
* appended to the failed result; it cannot block, the tool failed)
|
|
26
|
-
* - SessionEnd -> pi `session_shutdown` (fire-and-forget)
|
|
27
|
-
* - InstructionsLoaded -> bridged from the shared instruction-events bus:
|
|
28
|
-
* context-imports publishes session_start for the context
|
|
29
|
-
* files that survived claudeMdExcludes (it owns exclusion,
|
|
30
|
-
* so a file it removed from the prompt never announces)
|
|
31
|
-
* and include for resolved @imports; claude-rules publishes
|
|
32
|
-
* path_glob_match. Strictly observational: exit codes and
|
|
33
|
-
* JSON output, systemMessage included, are ignored.
|
|
34
|
-
*
|
|
35
|
-
* Every payload carries session_id, transcript_path (pi's session file), cwd,
|
|
36
|
-
* permission_mode (plan-mode state off the shared bus) and effort; tool events add
|
|
37
|
-
* tool_use_id. Every event honors the universal `systemMessage` output (a
|
|
38
|
-
* user-facing warning).
|
|
39
|
-
* `suppressOutput` is accepted and inert: pi never echoes hook stdout to the
|
|
40
|
-
* transcript in the first place.
|
|
41
|
-
*
|
|
42
|
-
* SubagentStart/SubagentStop ride pi-code's own subagent extension, which publishes
|
|
43
|
-
* child-run lifecycle on the shared bus (notify-style: a child has already exited by
|
|
44
|
-
* the time SubagentStop fires, so its exit-2 block semantics cannot be honored).
|
|
45
|
-
*
|
|
46
|
-
* Hook commands run via `sh -c` with the event JSON on stdin. A PreToolUse
|
|
47
|
-
* hook blocks the tool by exiting 2 (stderr becomes the reason) or by printing
|
|
48
|
-
* `{"hookSpecificOutput": {"permissionDecision": "deny", ...}}` (or the older
|
|
49
|
-
* `{"decision": "block"}`).
|
|
50
|
-
*
|
|
51
|
-
* Config is merged from ~/.claude/settings.json (always) plus the project's
|
|
52
|
-
* .claude/settings.json and settings.local.json (only when the project is
|
|
53
|
-
* trusted, since hooks execute arbitrary shell). Claude's `disableAllHooks`
|
|
54
|
-
* setting (managed settings or any honored file in that chain) short-circuits
|
|
55
|
-
* the load entirely, so no event fires any hook; /hooks prints the resolved
|
|
56
|
-
* chain per event with each entry's source settings file. Matchers follow Claude's rule:
|
|
57
|
-
* `*`/empty match all, plain names are exact (with `|`/`,` list separators), and
|
|
58
|
-
* anything with other regex characters is an unanchored regex. Claude matchers
|
|
59
|
-
* are PascalCase (`Bash`); pi tool names are lowercase (`bash`), so comparison
|
|
60
|
-
* is case-insensitive and folds `-` to `_`.
|
|
61
|
-
*
|
|
62
|
-
* Docs: https://code.claude.com/docs/en/hooks.md
|
|
63
|
-
*/
|
|
64
|
-
|
|
65
|
-
import { type ChildProcess, spawn } from 'node:child_process'
|
|
66
|
-
import * as fs from 'node:fs'
|
|
67
|
-
import * as os from 'node:os'
|
|
68
|
-
import * as path from 'node:path'
|
|
69
|
-
import type { Api, Model } from '@earendil-works/pi-ai'
|
|
70
|
-
import type { ExtensionAPI, ExtensionContext, ToolCallEventResult } from '@earendil-works/pi-coding-agent'
|
|
71
|
-
import { runAgent } from './internal/agent-run.js'
|
|
72
|
-
import { claudeConfigDir } from './internal/config-dir.js'
|
|
73
|
-
import { INSTRUCTIONS_CHANNEL, isInstructionLoadEvent } from './internal/instruction-events.js'
|
|
74
|
-
import { readManagedSettings } from './internal/managed-settings.js'
|
|
75
|
-
import { isMcpToolAliases, MCP_TOOLS_CHANNEL } from './internal/mcp-alias.js'
|
|
76
|
-
import { callMcpTool } from './internal/mcp-call.js'
|
|
77
|
-
import { completeText } from './internal/model-complete.js'
|
|
78
|
-
import { isPlanModeState, PLAN_MODE_CHANNEL } from './internal/plan-mode-state.js'
|
|
79
|
-
import { type InstalledPlugin, installedPlugins, substitutePluginVars } from './internal/plugins.js'
|
|
80
|
-
import { isProjectApproved } from './internal/project-approval.js'
|
|
81
|
-
import { findNearestFile, repoRoot } from './internal/project-root.js'
|
|
82
|
-
import { isSubagentPhaseEvent, SUBAGENT_CHANNEL } from './internal/subagent-events.js'
|
|
83
|
-
|
|
84
|
-
// Claude defaults to 600s and lets a timed-out hook proceed; here a timed-out
|
|
85
|
-
// PreToolUse or UserPromptSubmit hook fails closed (pi has no permission prompt
|
|
86
|
-
// to fall back on), so ten minutes of default budget would wedge the turn for
|
|
87
|
-
// ten minutes on a hung hook. Hooks that legitimately run long can raise their
|
|
88
|
-
// own per-hook `timeout`.
|
|
89
|
-
const DEFAULT_TIMEOUT_S = 60
|
|
90
|
-
|
|
91
|
-
interface HookCommand {
|
|
92
|
-
type?: string
|
|
93
|
-
command: string
|
|
94
|
-
/** exec-form: spawn `command` directly with these args and no shell (shell-form when
|
|
95
|
-
* absent). $ARGUMENTS in each arg is replaced with the event JSON. */
|
|
96
|
-
args?: string[]
|
|
97
|
-
timeout?: number
|
|
98
|
-
/** http entries: the endpoint POSTed to; `command` mirrors it for dedup and display. */
|
|
99
|
-
url?: string
|
|
100
|
-
headers?: Record<string, string>
|
|
101
|
-
allowedEnvVars?: string[]
|
|
102
|
-
/** prompt entries: the prompt sent to the model (`$ARGUMENTS` = the event JSON). */
|
|
103
|
-
prompt?: string
|
|
104
|
-
/** mcp_tool entries: the connected server and tool to call, with optional input. */
|
|
105
|
-
server?: string
|
|
106
|
-
tool?: string
|
|
107
|
-
input?: Record<string, unknown>
|
|
108
|
-
/** prompt/agent entries: an optional model override; agent adds a system prompt. */
|
|
109
|
-
model?: string
|
|
110
|
-
systemPrompt?: string
|
|
111
|
-
}
|
|
112
|
-
export interface HookMatcher {
|
|
113
|
-
matcher?: string
|
|
114
|
-
hooks: HookCommand[]
|
|
115
|
-
}
|
|
116
|
-
export type HooksConfig = Record<string, HookMatcher[]>
|
|
117
|
-
|
|
118
|
-
export interface HookDecision {
|
|
119
|
-
block: boolean
|
|
120
|
-
reason?: string
|
|
121
|
-
/** Claude's `permissionDecision: "ask"`: the caller should prompt the user and
|
|
122
|
-
* block only on decline. `block` stays true as the no-UI fallback. */
|
|
123
|
-
ask?: boolean
|
|
124
|
-
}
|
|
125
|
-
export interface HookRunResult {
|
|
126
|
-
code: number
|
|
127
|
-
stdout: string
|
|
128
|
-
stderr: string
|
|
129
|
-
/** The hook was killed at its timeout, so its exit code carries no verdict. */
|
|
130
|
-
timedOut: boolean
|
|
131
|
-
/** The process errored before delivering a verdict (spawn failure, EIO). */
|
|
132
|
-
spawnFailed?: boolean
|
|
133
|
-
}
|
|
134
|
-
/** Runs one configured hook entry, whatever its type; boundRunner dispatches. */
|
|
135
|
-
export type HookRunner = (hook: HookCommand, payload: unknown, timeoutMs: number) => Promise<HookRunResult>
|
|
136
|
-
/** The shell path specifically; the statusline reuses it for its own command. With an
|
|
137
|
-
* `args` array it becomes the exec path: `command` is spawned directly with those args. */
|
|
138
|
-
export type HookCommandRunner = (command: string, payload: unknown, timeoutMs: number, projectDir?: string, args?: string[]) => Promise<HookRunResult>
|
|
139
|
-
|
|
140
|
-
/** Settings files to read, newest-winning. Project files load only when trusted, each
|
|
141
|
-
* the nearest of its name at or above cwd (bounded at the repository root, matching
|
|
142
|
-
* the approval walk), so a subdirectory session reads the settings that gated it. */
|
|
143
|
-
export function hookFiles(cwd: string, home: string, trusted: boolean): string[] {
|
|
144
|
-
const files = [path.join(claudeConfigDir(home), 'settings.json')]
|
|
145
|
-
if (!trusted) return files
|
|
146
|
-
for (const name of ['settings.json', 'settings.local.json']) {
|
|
147
|
-
files.push(findNearestFile(cwd, path.join('.claude', name)) ?? path.join(cwd, '.claude', name))
|
|
148
|
-
}
|
|
149
|
-
return files
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/** Claude's `disableAllHooks` setting: the escape hatch a user reaches for when a
|
|
153
|
-
* hook misbehaves, so it is honored before any hook runs. Disabled when managed
|
|
154
|
-
* settings or ANY file in the settings chain sets it to `true`; deliberately not
|
|
155
|
-
* last-file-wins, since a repository file re-enabling the hooks the user just
|
|
156
|
-
* disabled in their own settings would defeat the escape hatch. The chain itself
|
|
157
|
-
* already gates project files on trust (see hookFiles). */
|
|
158
|
-
export function readDisableAllHooks(files: string[], managed: Record<string, unknown> = readManagedSettings()): boolean {
|
|
159
|
-
if (managed.disableAllHooks === true) return true
|
|
160
|
-
for (const file of files) {
|
|
161
|
-
try {
|
|
162
|
-
const parsed: unknown = JSON.parse(fs.readFileSync(file, 'utf-8'))
|
|
163
|
-
if (isRecord(parsed) && parsed.disableAllHooks === true) return true
|
|
164
|
-
} catch {
|
|
165
|
-
// missing or invalid file: skip
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
return false
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
/** Claude's `allowedHttpHookUrls` setting: URL patterns http hooks may target, with
|
|
172
|
-
* `*` as a wildcard. Per Claude's documentation: undefined (no source sets the key)
|
|
173
|
-
* means no restrictions, an empty array blocks every http hook, and arrays merge
|
|
174
|
-
* across settings sources. Merging is a union of managed settings plus every file in
|
|
175
|
-
* the chain; the chain already gates project files on trust (see hookFiles), and a
|
|
176
|
-
* trusted project can run arbitrary shell hooks anyway, so letting it extend the
|
|
177
|
-
* allowlist is no escalation. */
|
|
178
|
-
export function readAllowedHttpHookUrls(files: string[], managed: Record<string, unknown> = readManagedSettings()): string[] | undefined {
|
|
179
|
-
let found: string[] | undefined
|
|
180
|
-
const collect = (value: unknown): void => {
|
|
181
|
-
if (!Array.isArray(value)) return
|
|
182
|
-
found = [...(found ?? []), ...value.filter((entry): entry is string => typeof entry === 'string')]
|
|
183
|
-
}
|
|
184
|
-
collect(managed.allowedHttpHookUrls)
|
|
185
|
-
for (const file of files) {
|
|
186
|
-
try {
|
|
187
|
-
const parsed: unknown = JSON.parse(fs.readFileSync(file, 'utf-8'))
|
|
188
|
-
if (isRecord(parsed)) collect(parsed.allowedHttpHookUrls)
|
|
189
|
-
} catch {
|
|
190
|
-
// missing or invalid file: skip
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
return found
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
/** Whether an http hook may target `url`. `*` in an allowlist entry matches any run
|
|
197
|
-
* of characters; everything else is literal and the whole URL must match. An
|
|
198
|
-
* undefined allowlist means the setting is absent, so there are no restrictions. */
|
|
199
|
-
export function httpUrlAllowed(url: string, allowlist: string[] | undefined): boolean {
|
|
200
|
-
if (allowlist === undefined) return true
|
|
201
|
-
return allowlist.some((pattern) => {
|
|
202
|
-
const literal = pattern.split('*').map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`))
|
|
203
|
-
return new RegExp(`^${literal.join('.*')}$`).test(url)
|
|
204
|
-
})
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
export function loadHooks(files: string[], sources?: Map<HookMatcher, string>): HooksConfig {
|
|
208
|
-
const config: HooksConfig = {}
|
|
209
|
-
for (const file of files) {
|
|
210
|
-
let raw: string
|
|
211
|
-
try {
|
|
212
|
-
raw = fs.readFileSync(file, 'utf-8')
|
|
213
|
-
} catch {
|
|
214
|
-
continue
|
|
215
|
-
}
|
|
216
|
-
mergeHooksJson(config, raw, file, sources)
|
|
217
|
-
}
|
|
218
|
-
return config
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
function mergeHooksJson(config: HooksConfig, raw: string, source: string, sources?: Map<HookMatcher, string>): void {
|
|
222
|
-
let parsed: { hooks?: HooksConfig }
|
|
223
|
-
try {
|
|
224
|
-
parsed = JSON.parse(raw)
|
|
225
|
-
} catch {
|
|
226
|
-
return
|
|
227
|
-
}
|
|
228
|
-
for (const [event, matchers] of Object.entries(parsed?.hooks ?? {})) {
|
|
229
|
-
if (!Array.isArray(matchers)) continue
|
|
230
|
-
// Entries are validated here rather than where they run: a hand-edited settings
|
|
231
|
-
// file that writes `hooks` as an object instead of a list used to throw out of
|
|
232
|
-
// the tool_call handler, and pi turns that into an error result, so every tool
|
|
233
|
-
// call for the rest of the session failed with an opaque type error.
|
|
234
|
-
const usable = matchers.filter((entry) => isUsableMatcher(entry, source, event))
|
|
235
|
-
if (usable.length === 0) continue
|
|
236
|
-
config[event] = [...(config[event] ?? []), ...usable]
|
|
237
|
-
// Each parse produces fresh entry objects, so object identity keys the /hooks
|
|
238
|
-
// viewer's source attribution without touching the entries themselves.
|
|
239
|
-
for (const entry of usable) sources?.set(entry, source)
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
/** Each enabled plugin's hooks (hooks/hooks.json, or wherever the manifest points),
|
|
244
|
-
* with ${CLAUDE_PLUGIN_ROOT}/${CLAUDE_PLUGIN_DATA} substituted before parsing so a
|
|
245
|
-
* hook can name its bundled scripts by real path. */
|
|
246
|
-
export function loadPluginHooks(config: HooksConfig, plugins: InstalledPlugin[], sources?: Map<HookMatcher, string>): void {
|
|
247
|
-
for (const plugin of plugins) {
|
|
248
|
-
const declared = plugin.manifest.hooks
|
|
249
|
-
// An inline hooks object; an array is not a valid hooks map (it would parse to
|
|
250
|
-
// numeric event keys), so it falls through to the default path rather than
|
|
251
|
-
// silently registering nothing.
|
|
252
|
-
if (declared !== null && typeof declared === 'object' && !Array.isArray(declared)) {
|
|
253
|
-
mergeHooksJson(config, substitutePluginVars(JSON.stringify({ hooks: declared }), plugin), `${plugin.name} (plugin.json)`, sources)
|
|
254
|
-
continue
|
|
255
|
-
}
|
|
256
|
-
const file = path.resolve(plugin.root, typeof declared === 'string' ? declared : path.join('hooks', 'hooks.json'))
|
|
257
|
-
try {
|
|
258
|
-
mergeHooksJson(config, substitutePluginVars(fs.readFileSync(file, 'utf-8'), plugin), file, sources)
|
|
259
|
-
} catch {
|
|
260
|
-
// a plugin without hooks contributes nothing
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
/** Claude's rule: a matcher of only letters, digits, `_`, `-`, spaces, `,` and `|`
|
|
266
|
-
* is a list of exact names; anything else is an unanchored regex. */
|
|
267
|
-
const EXACT_MATCHER = /^[\w\- ,|]*$/
|
|
268
|
-
|
|
269
|
-
/** Claude names are PascalCase and keep dashes (`Bash`, `mcp__brave-search__x`);
|
|
270
|
-
* pi names are lowercase with underscores, so comparison folds both. */
|
|
271
|
-
function foldName(name: string): string {
|
|
272
|
-
return name.toLowerCase().replaceAll('-', '_')
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
/** A matcher string's compiled form: a set of folded exact names, or a regex. */
|
|
276
|
-
type CompiledMatcher = { tokens: Set<string> } | { regex: RegExp }
|
|
277
|
-
|
|
278
|
-
function exactTokens(matcher: string): Set<string> {
|
|
279
|
-
return new Set(
|
|
280
|
-
matcher
|
|
281
|
-
.split(/[|,]/)
|
|
282
|
-
.map((token) => foldName(token.trim()))
|
|
283
|
-
.filter(Boolean),
|
|
284
|
-
)
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
/** Hook config is static per session and dispatch consults every matcher on every
|
|
288
|
-
* event, so each matcher string compiles once. Matchers are few; the bound is a
|
|
289
|
-
* safety net, clearing the (cheap to rebuild) cache rather than evicting. */
|
|
290
|
-
const compiledMatchers = new Map<string, CompiledMatcher>()
|
|
291
|
-
const COMPILED_MATCHER_BOUND = 1000
|
|
292
|
-
|
|
293
|
-
/** A matcher entry pi-code can run: an object whose `hooks` is a list. Anything else
|
|
294
|
-
* is reported by name and skipped, so one bad entry costs its own hooks, not the
|
|
295
|
-
* session's tool calls. */
|
|
296
|
-
function isUsableMatcher(entry: unknown, file: string, event: string): entry is HookMatcher {
|
|
297
|
-
const candidate = entry as HookMatcher | null
|
|
298
|
-
if (candidate === null || typeof candidate !== 'object') {
|
|
299
|
-
console.warn(`pi-code-hooks: ignoring a non-object ${event} entry in ${file}`)
|
|
300
|
-
return false
|
|
301
|
-
}
|
|
302
|
-
if (candidate.hooks !== undefined && !Array.isArray(candidate.hooks)) {
|
|
303
|
-
console.warn(`pi-code-hooks: ignoring ${event} entry in ${file}: "hooks" must be a list`)
|
|
304
|
-
return false
|
|
305
|
-
}
|
|
306
|
-
if (candidate.matcher !== undefined && typeof candidate.matcher !== 'string') {
|
|
307
|
-
console.warn(`pi-code-hooks: ignoring ${event} entry in ${file}: "matcher" must be a string`)
|
|
308
|
-
return false
|
|
309
|
-
}
|
|
310
|
-
return true
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
let matcherCompiles = 0
|
|
314
|
-
|
|
315
|
-
/** Test seam: matcher compilations performed, for asserting memoization. */
|
|
316
|
-
export function matcherCompileCount(): number {
|
|
317
|
-
return matcherCompiles
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
/** Test seam: drop compiled matchers so a test observes fresh compiles. */
|
|
321
|
-
export function resetMatcherCache(): void {
|
|
322
|
-
compiledMatchers.clear()
|
|
323
|
-
matcherCompiles = 0
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
function compileMatcher(matcher: string): CompiledMatcher {
|
|
327
|
-
const cached = compiledMatchers.get(matcher)
|
|
328
|
-
if (cached !== undefined) return cached
|
|
329
|
-
matcherCompiles += 1
|
|
330
|
-
let compiled: CompiledMatcher
|
|
331
|
-
if (EXACT_MATCHER.test(matcher)) {
|
|
332
|
-
compiled = { tokens: exactTokens(matcher) }
|
|
333
|
-
} else {
|
|
334
|
-
try {
|
|
335
|
-
compiled = { regex: new RegExp(matcher, 'i') }
|
|
336
|
-
} catch {
|
|
337
|
-
// An invalid regex matcher falls back to exact-name matching, as before.
|
|
338
|
-
compiled = { tokens: exactTokens(matcher) }
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
|
-
if (compiledMatchers.size >= COMPILED_MATCHER_BOUND) compiledMatchers.clear()
|
|
342
|
-
compiledMatchers.set(matcher, compiled)
|
|
343
|
-
return compiled
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
function matcherApplies(matcher: string | undefined, names: readonly string[]): boolean {
|
|
347
|
-
if (!matcher || matcher === '*') return true
|
|
348
|
-
const compiled = compileMatcher(matcher)
|
|
349
|
-
if ('regex' in compiled) {
|
|
350
|
-
const { regex } = compiled
|
|
351
|
-
return names.some((name) => regex.test(name))
|
|
352
|
-
}
|
|
353
|
-
const { tokens } = compiled
|
|
354
|
-
return names.some((name) => tokens.has(foldName(name)))
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
/** A hook entry pi-code can run: a shell command, an http POST, an in-process
|
|
358
|
-
* prompt, an mcp_tool call, or an agent subagent. An agent hook with no runner
|
|
359
|
-
* registered is still matched here and resolves non-blocking at run time, the same
|
|
360
|
-
* way a prompt hook with no model does. */
|
|
361
|
-
function isRunnableHook(hook: HookCommand): boolean {
|
|
362
|
-
if (hook.type === 'http') return typeof hook.url === 'string' && /^https?:\/\//.test(hook.url)
|
|
363
|
-
if (hook.type === 'prompt' || hook.type === 'agent') return typeof hook.prompt === 'string' && hook.prompt.length > 0
|
|
364
|
-
if (hook.type === 'mcp_tool') return typeof hook.server === 'string' && typeof hook.tool === 'string'
|
|
365
|
-
return typeof hook.command === 'string' && (hook.type === undefined || hook.type === 'command')
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
/** The synthetic identity of a non-shell hook entry: an http/prompt/agent/mcp_tool
|
|
369
|
-
* entry has no `command`, so its url / prompt / server:tool stands in. A shell hook
|
|
370
|
-
* (undefined or `command` type) already has one, so this is undefined. */
|
|
371
|
-
function syntheticCommand(hook: HookCommand): string | undefined {
|
|
372
|
-
if (hook.type === 'http') return hook.url
|
|
373
|
-
if (hook.type === 'prompt' || hook.type === 'agent') return hook.prompt
|
|
374
|
-
if (hook.type === 'mcp_tool') return `${hook.server}:${hook.tool}`
|
|
375
|
-
return undefined
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
/** A matched entry with its `command` filled in: mirroring the synthetic identity into
|
|
379
|
-
* `command` keeps dedup, timeout messages and display working for non-shell hooks. */
|
|
380
|
-
function withCommand(raw: HookCommand): HookCommand {
|
|
381
|
-
const identity = syntheticCommand(raw)
|
|
382
|
-
return identity !== undefined && typeof raw.command !== 'string' ? { ...raw, command: identity } : raw
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
/** Command specs whose matcher applies to any of the given tool/source names.
|
|
386
|
-
* Multiple candidates let one event offer both the pi name and its Claude alias. */
|
|
387
|
-
export function matchingCommands(matchers: HookMatcher[] | undefined, names: string | readonly string[]): HookCommand[] {
|
|
388
|
-
const candidates = typeof names === 'string' ? [names] : names
|
|
389
|
-
const result: HookCommand[] = []
|
|
390
|
-
const seen = new Set<string>()
|
|
391
|
-
for (const entry of matchers ?? []) {
|
|
392
|
-
if (!matcherApplies(entry.matcher, candidates)) continue
|
|
393
|
-
for (const raw of (entry.hooks ?? []).filter(isRunnableHook)) {
|
|
394
|
-
const hook = withCommand(raw)
|
|
395
|
-
// Claude runs a handler defined in more than one settings file once.
|
|
396
|
-
if (seen.has(hook.command)) continue
|
|
397
|
-
seen.add(hook.command)
|
|
398
|
-
result.push(hook)
|
|
399
|
-
}
|
|
400
|
-
}
|
|
401
|
-
return result
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
/** A hook entry's display identity for the /hooks viewer: the command for shell
|
|
405
|
-
* hooks, otherwise the type-qualified url / prompt / server:tool. A missing field
|
|
406
|
-
* is named rather than hidden, since a misconfigured entry is exactly what the
|
|
407
|
-
* viewer exists to surface. */
|
|
408
|
-
function hookIdentity(hook: HookCommand | null | undefined): string {
|
|
409
|
-
// A hand-edited settings file can leave a null (or otherwise empty) entry in a
|
|
410
|
-
// hooks array; name it rather than let it crash the viewer that exists to surface
|
|
411
|
-
// exactly this kind of misconfiguration.
|
|
412
|
-
const record: Partial<HookCommand> = hook ?? {}
|
|
413
|
-
const type = record.type ?? 'command'
|
|
414
|
-
if (type === 'http') return `http: ${record.url ?? record.command ?? '(missing url)'}`
|
|
415
|
-
if (type === 'prompt' || type === 'agent') return `${type}: ${record.prompt ?? record.command ?? '(missing prompt)'}`
|
|
416
|
-
if (type === 'mcp_tool') return `mcp_tool: ${record.server ?? '(missing server)'}:${record.tool ?? '(missing tool)'}`
|
|
417
|
-
return `command: ${record.command ?? '(missing command)'}`
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
/** Render the resolved hooks config as a readable per-event summary for /hooks:
|
|
421
|
-
* one line per configured hook with its matcher, identity and, when known, the
|
|
422
|
-
* settings file it came from. Pure formatting of already-resolved data. */
|
|
423
|
-
export function formatHooksSummary(config: HooksConfig, sources?: Map<HookMatcher, string>): string {
|
|
424
|
-
const lines: string[] = []
|
|
425
|
-
for (const [event, matchers] of Object.entries(config)) {
|
|
426
|
-
const entryLines: string[] = []
|
|
427
|
-
for (const entry of matchers) {
|
|
428
|
-
const matcher = entry.matcher || '*'
|
|
429
|
-
const source = sources?.get(entry)
|
|
430
|
-
const suffix = source ? ` (${source})` : ''
|
|
431
|
-
for (const hook of entry.hooks ?? []) {
|
|
432
|
-
entryLines.push(` [${matcher}] ${hookIdentity(hook)}${suffix}`)
|
|
433
|
-
}
|
|
434
|
-
}
|
|
435
|
-
if (entryLines.length > 0) lines.push(`${event}:`, ...entryLines)
|
|
436
|
-
}
|
|
437
|
-
if (lines.length === 0) return 'No hooks configured. Add a "hooks" section to ~/.claude/settings.json or .claude/settings.json.'
|
|
438
|
-
return lines.join('\n')
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
function tryParseJson(text: string): { hookSpecificOutput?: { permissionDecision?: string; permissionDecisionReason?: string; additionalContext?: string; updatedInput?: unknown }; decision?: string; reason?: string; continue?: boolean; stopReason?: string; systemMessage?: string } | undefined {
|
|
442
|
-
try {
|
|
443
|
-
return JSON.parse(text)
|
|
444
|
-
} catch {
|
|
445
|
-
return undefined
|
|
446
|
-
}
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
/** Map a hook's exit code / output to a block-or-allow decision. */
|
|
450
|
-
export function interpretHookResult(code: number, stdout: string, stderr: string): HookDecision {
|
|
451
|
-
if (code === 2) return { block: true, reason: stderr.trim() || 'Blocked by hook' }
|
|
452
|
-
const parsed = tryParseJson(stdout)
|
|
453
|
-
const specific = parsed?.hookSpecificOutput
|
|
454
|
-
// Claude's "ask" prompts the user; the tool_call handler turns this into a
|
|
455
|
-
// ctx.ui.confirm and blocks only on decline. block:true is the fallback for a
|
|
456
|
-
// headless run with no dialog to show, which is the safe reading on a gated path.
|
|
457
|
-
if (specific?.permissionDecision === 'ask') return { block: true, ask: true, reason: specific.permissionDecisionReason ?? 'A hook asks you to confirm this tool call.' }
|
|
458
|
-
if (specific?.permissionDecision === 'deny') return { block: true, reason: specific.permissionDecisionReason ?? 'Blocked by hook' }
|
|
459
|
-
if (parsed?.decision === 'block') return { block: true, reason: parsed.reason ?? 'Blocked by hook' }
|
|
460
|
-
if (parsed?.continue === false) return { block: true, reason: parsed.stopReason ?? 'Blocked by hook' }
|
|
461
|
-
return { block: false }
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
/** Memory backstop for a runaway hook. A decision payload is orders of magnitude smaller. */
|
|
465
|
-
const MAX_HOOK_OUTPUT = 1_000_000
|
|
466
|
-
|
|
467
|
-
/** Conventional exit code for a killed-on-timeout command, as `timeout(1)` reports it. */
|
|
468
|
-
const TIMEOUT_EXIT_CODE = 124
|
|
469
|
-
|
|
470
|
-
/**
|
|
471
|
-
* Kill the shell and everything it spawned. `sh -c 'a; b'` forks, so signalling the
|
|
472
|
-
* direct child alone leaves a grandchild alive holding stdout/stderr.
|
|
473
|
-
*/
|
|
474
|
-
function killTree(child: ChildProcess): void {
|
|
475
|
-
try {
|
|
476
|
-
// Negative pid targets the whole process group, which `detached` gave the shell.
|
|
477
|
-
if (child.pid) {
|
|
478
|
-
process.kill(-child.pid, 'SIGKILL')
|
|
479
|
-
return
|
|
480
|
-
}
|
|
481
|
-
} catch {
|
|
482
|
-
// Group already reaped, or the platform refused it; fall through to the direct kill.
|
|
483
|
-
}
|
|
484
|
-
child.kill('SIGKILL')
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
export const runHookCommand: HookCommandRunner = (command, payload, timeoutMs, projectDir, args) =>
|
|
488
|
-
new Promise((resolve) => {
|
|
489
|
-
// Absolute path so the shell can't be resolved through an attacker-controlled PATH.
|
|
490
|
-
// `detached` makes the shell its own process group leader so the timeout can kill
|
|
491
|
-
// the descendants too. CLAUDE_PROJECT_DIR is Claude's documented way for a hook to
|
|
492
|
-
// reference project files regardless of the shell's cwd. CLAUDECODE=1 marks every
|
|
493
|
-
// subprocess Claude spawns, so it is set on the child unconditionally.
|
|
494
|
-
const env: NodeJS.ProcessEnv = { ...process.env, CLAUDECODE: '1' }
|
|
495
|
-
if (projectDir) env.CLAUDE_PROJECT_DIR = projectDir
|
|
496
|
-
// An exec-form hook (an `args` array) spawns the executable directly with those args
|
|
497
|
-
// and no shell, so shell metacharacters in the args arrive literally; $ARGUMENTS in
|
|
498
|
-
// each arg is replaced with the event JSON by a replacer function (so $$/$& in the
|
|
499
|
-
// payload survive verbatim). Without args it stays the shell path. Both share the
|
|
500
|
-
// same detached process group, so killTree reaches the descendants either way.
|
|
501
|
-
const file = Array.isArray(args) ? command : '/bin/sh'
|
|
502
|
-
const spawnArgs = Array.isArray(args) ? args.map((arg) => substituteArguments(arg, payload)) : ['-c', command]
|
|
503
|
-
const child = spawn(file, spawnArgs, { stdio: ['pipe', 'pipe', 'pipe'], detached: true, env })
|
|
504
|
-
let stdout = ''
|
|
505
|
-
let stderr = ''
|
|
506
|
-
let settled = false
|
|
507
|
-
const finish = (result: HookRunResult): void => {
|
|
508
|
-
if (settled) return
|
|
509
|
-
settled = true
|
|
510
|
-
clearTimeout(timer)
|
|
511
|
-
resolve(result)
|
|
512
|
-
}
|
|
513
|
-
// Resolve from the timer itself rather than waiting for `close`: `close` fires only
|
|
514
|
-
// once every stdio pipe is closed, and a grandchild that inherited them can hold the
|
|
515
|
-
// promise pending long past the timeout, stalling the tool call that awaits it.
|
|
516
|
-
const timer = setTimeout(() => {
|
|
517
|
-
killTree(child)
|
|
518
|
-
finish({ code: TIMEOUT_EXIT_CODE, stdout, stderr, timedOut: true })
|
|
519
|
-
}, timeoutMs)
|
|
520
|
-
// Decode on the stream: concatenating Buffers as strings mangles a multi-byte
|
|
521
|
-
// character split across chunks, and a mangled byte in a hook's deny decision makes
|
|
522
|
-
// it unparseable, which reads as an allow.
|
|
523
|
-
child.stdout?.setEncoding('utf8')
|
|
524
|
-
child.stderr?.setEncoding('utf8')
|
|
525
|
-
child.stdout?.on('data', (chunk: string) => {
|
|
526
|
-
if (stdout.length < MAX_HOOK_OUTPUT) stdout += chunk
|
|
527
|
-
})
|
|
528
|
-
child.stderr?.on('data', (chunk: string) => {
|
|
529
|
-
if (stderr.length < MAX_HOOK_OUTPUT) stderr += chunk
|
|
530
|
-
})
|
|
531
|
-
child.on('close', (code) => finish({ code: code ?? 0, stdout, stderr, timedOut: false }))
|
|
532
|
-
// Marked rather than silently read as a clean run: under fd exhaustion a
|
|
533
|
-
// deny-list guard that never spawned would otherwise pass as an allow.
|
|
534
|
-
child.on('error', (error) => finish({ code: 0, stdout, stderr: stderr || error.message, timedOut: false, spawnFailed: true }))
|
|
535
|
-
// A hook that exits without reading stdin (e.g. `exit 2`) closes the pipe first,
|
|
536
|
-
// so ignore EPIPE on this write rather than crashing the host process.
|
|
537
|
-
child.stdin?.on('error', () => {})
|
|
538
|
-
child.stdin?.end(JSON.stringify(payload))
|
|
539
|
-
})
|
|
540
|
-
|
|
541
|
-
/** `$VAR` / `${VAR}` in header values, from allowlisted env vars only; a reference
|
|
542
|
-
* to an unlisted variable becomes an empty string, as Claude documents. */
|
|
543
|
-
function interpolateHeaders(headers: Record<string, string> | undefined, allowed: string[] | undefined): Record<string, string> {
|
|
544
|
-
const allowedSet = new Set(allowed ?? [])
|
|
545
|
-
const out: Record<string, string> = {}
|
|
546
|
-
for (const [key, value] of Object.entries(headers ?? {})) {
|
|
547
|
-
out[key] = value.replace(/\$(?:\{([A-Za-z_]\w*)\}|([A-Za-z_]\w*))/g, (_token, braced?: string, bare?: string) => {
|
|
548
|
-
const name = braced ?? bare ?? ''
|
|
549
|
-
return allowedSet.has(name) ? (process.env[name] ?? '') : ''
|
|
550
|
-
})
|
|
551
|
-
}
|
|
552
|
-
return out
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
/**
|
|
556
|
-
* Claude's `type: "http"` hook: the payload POSTs as JSON and only a 2xx response
|
|
557
|
-
* with a valid JSON body renders a decision, read exactly like command stdout.
|
|
558
|
-
* Everything else, including non-2xx statuses, connection failures and timeouts,
|
|
559
|
-
* is a non-blocking error by contract, so none of these outcomes ever reports
|
|
560
|
-
* `timedOut`, which PreToolUse fails closed on. Claude's `allowedHttpHookUrls`
|
|
561
|
-
* allowlist gates the fetch itself: a URL matching no entry is never contacted,
|
|
562
|
-
* so a settings file cannot point a hook at an arbitrary endpoint and exfiltrate
|
|
563
|
-
* the payload; when the setting is absent there are no restrictions, as Claude
|
|
564
|
-
* documents. A blocked hook renders no decision, like every other http failure.
|
|
565
|
-
*/
|
|
566
|
-
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> {
|
|
567
|
-
const url = hook.url ?? hook.command
|
|
568
|
-
if (!httpUrlAllowed(url, allowedUrls)) return { code: 1, stdout: '', stderr: `${url} does not match allowedHttpHookUrls; the hook was not called`, timedOut: false }
|
|
569
|
-
try {
|
|
570
|
-
const response = await fetch(url, {
|
|
571
|
-
method: 'POST',
|
|
572
|
-
headers: { 'content-type': 'application/json', ...interpolateHeaders(hook.headers, hook.allowedEnvVars) },
|
|
573
|
-
body: JSON.stringify(payload),
|
|
574
|
-
signal: AbortSignal.timeout(timeoutMs),
|
|
575
|
-
})
|
|
576
|
-
const body = (await response.text()).slice(0, MAX_HOOK_OUTPUT)
|
|
577
|
-
if (!response.ok) return { code: 1, stdout: '', stderr: `HTTP ${response.status} from ${url}`, timedOut: false }
|
|
578
|
-
if (body.trim().length === 0) return { code: 0, stdout: '', stderr: '', timedOut: false }
|
|
579
|
-
try {
|
|
580
|
-
JSON.parse(body)
|
|
581
|
-
} catch {
|
|
582
|
-
return { code: 1, stdout: '', stderr: `non-JSON response from ${url}`, timedOut: false }
|
|
583
|
-
}
|
|
584
|
-
return { code: 0, stdout: body, stderr: '', timedOut: false }
|
|
585
|
-
} catch (error) {
|
|
586
|
-
return { code: 1, stdout: '', stderr: error instanceof Error ? error.message : String(error), timedOut: false }
|
|
587
|
-
}
|
|
588
|
-
}
|
|
589
|
-
|
|
590
|
-
/** System prompt turning a prompt hook into a structured decision, so its reply
|
|
591
|
-
* flows through interpretHookResult exactly like a command hook's stdout. */
|
|
592
|
-
const PROMPT_HOOK_SYSTEM = [
|
|
593
|
-
'You are a Claude Code hook evaluating whether an action should proceed.',
|
|
594
|
-
'Respond with ONLY a JSON object and nothing else:',
|
|
595
|
-
'{"hookSpecificOutput":{"permissionDecision":"allow"|"deny"|"ask","permissionDecisionReason":"<short reason>"}}',
|
|
596
|
-
'Use "allow" to let the action proceed, "deny" to block it, "ask" to require the user to confirm.',
|
|
597
|
-
].join('\n')
|
|
598
|
-
|
|
599
|
-
/**
|
|
600
|
-
* Claude's `type: "prompt"` hook: the prompt (with `$ARGUMENTS` replaced by the
|
|
601
|
-
* event JSON) is evaluated by the model, which returns a JSON decision. pi runs it
|
|
602
|
-
* in-process via completeText and returns the reply as stdout so the existing
|
|
603
|
-
* decision parser handles it. No model (headless) or a provider error is
|
|
604
|
-
* non-blocking; only an abort at the timeout fails closed, like the other hooks.
|
|
605
|
-
*/
|
|
606
|
-
/** Replace `$ARGUMENTS` with the event JSON via a replacer function, so `$`-sequences
|
|
607
|
-
* in the payload (`$$`, `$&`, `` $` ``, `$'`) are inserted literally, not read as
|
|
608
|
-
* `String.replace` patterns. Prompt and agent hooks feed the result to the model. */
|
|
609
|
-
function substituteArguments(prompt: string | undefined, payload: unknown): string {
|
|
610
|
-
const json = JSON.stringify(payload)
|
|
611
|
-
return (prompt ?? '').replaceAll('$ARGUMENTS', () => json)
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
/** Classify a model/agent failure: the deadline is authoritative via the signal (the
|
|
615
|
-
* subagent runner rejects with a plain Error on abort, so an error-name check alone
|
|
616
|
-
* fails open), so a fired signal is a timeout (PreToolUse fails closed); anything else
|
|
617
|
-
* produced no verdict and is non-blocking. */
|
|
618
|
-
function abortAwareFailure(signal: AbortSignal, error: unknown): HookRunResult {
|
|
619
|
-
const aborted = signal.aborted || (error instanceof Error && (error.name === 'AbortError' || error.name === 'TimeoutError'))
|
|
620
|
-
return { code: aborted ? TIMEOUT_EXIT_CODE : 1, stdout: '', stderr: error instanceof Error ? error.message : String(error), timedOut: aborted }
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
export async function runPromptHook(hook: HookCommand, payload: unknown, model: Model<Api> | undefined, timeoutMs: number): Promise<HookRunResult> {
|
|
624
|
-
if (!model) return { code: 1, stdout: '', stderr: 'no model available for prompt hook', timedOut: false }
|
|
625
|
-
// A replacer function, so `$$`/`$&`/`` $` ``/`$'` inside the payload JSON are inserted
|
|
626
|
-
// verbatim rather than read as replacement patterns (a Bash `echo $$` is a common trigger).
|
|
627
|
-
const prompt = substituteArguments(hook.prompt, payload)
|
|
628
|
-
const signal = AbortSignal.timeout(timeoutMs)
|
|
629
|
-
try {
|
|
630
|
-
const { text: answer } = await completeText(model, prompt, { system: PROMPT_HOOK_SYSTEM, maxTokens: 512, signal })
|
|
631
|
-
return { code: 0, stdout: answer, stderr: '', timedOut: false }
|
|
632
|
-
} catch (error) {
|
|
633
|
-
return abortAwareFailure(signal, error)
|
|
634
|
-
}
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
/**
|
|
638
|
-
* Claude's `type: "mcp_tool"` hook: call a tool on an already-connected MCP server
|
|
639
|
-
* and treat its text output like command stdout. pi reaches the server through the
|
|
640
|
-
* mcp-call seam the mcp extension registers. Like http, it never fails closed: a
|
|
641
|
-
* missing server, a tool error, or the deadline is non-blocking.
|
|
642
|
-
*/
|
|
643
|
-
export async function runMcpToolHook(hook: HookCommand, payload: unknown, timeoutMs: number): Promise<HookRunResult> {
|
|
644
|
-
if (!hook.server || !hook.tool) return { code: 1, stdout: '', stderr: 'mcp_tool hook needs server and tool', timedOut: false }
|
|
645
|
-
const input = hook.input && typeof hook.input === 'object' ? hook.input : (payload as Record<string, unknown>)
|
|
646
|
-
let timer: ReturnType<typeof setTimeout> | undefined
|
|
647
|
-
const deadline = new Promise<HookRunResult>((resolve) => {
|
|
648
|
-
timer = setTimeout(() => resolve({ code: 1, stdout: '', stderr: `mcp_tool hook timed out after ${timeoutMs}ms`, timedOut: false }), timeoutMs)
|
|
649
|
-
})
|
|
650
|
-
const call = callMcpTool(hook.server, hook.tool, input)
|
|
651
|
-
.then((result): HookRunResult => ({ code: result.isError ? 1 : 0, stdout: result.text, stderr: '', timedOut: false }))
|
|
652
|
-
.catch((error): HookRunResult => ({ code: 1, stdout: '', stderr: error instanceof Error ? error.message : String(error), timedOut: false }))
|
|
653
|
-
try {
|
|
654
|
-
return await Promise.race([call, deadline])
|
|
655
|
-
} finally {
|
|
656
|
-
// Left running, the deadline timer pins the event loop for the full timeout
|
|
657
|
-
// after the call resolves, delaying exit in a one-shot headless run.
|
|
658
|
-
clearTimeout(timer)
|
|
659
|
-
}
|
|
660
|
-
}
|
|
661
|
-
|
|
662
|
-
/**
|
|
663
|
-
* Claude's experimental `type: "agent"` hook: spawn a subagent (Read/Grep/Glob) to
|
|
664
|
-
* verify a condition, then return its final text as a JSON decision, parsed by the
|
|
665
|
-
* same interpreter as a command hook. pi reaches the subagent through the agent-run
|
|
666
|
-
* seam the subagent extension registers. Like the prompt hook, only an abort at the
|
|
667
|
-
* deadline fails closed; a missing runner or a crashed agent is non-blocking.
|
|
668
|
-
*/
|
|
669
|
-
export async function runAgentHook(hook: HookCommand, payload: unknown, timeoutMs: number, sessionModelId: string | undefined): Promise<HookRunResult> {
|
|
670
|
-
const prompt = substituteArguments(hook.prompt, payload)
|
|
671
|
-
const signal = AbortSignal.timeout(timeoutMs)
|
|
672
|
-
try {
|
|
673
|
-
const answer = await runAgent({ prompt, model: hook.model ?? sessionModelId, systemPrompt: hook.systemPrompt, signal })
|
|
674
|
-
return { code: 0, stdout: answer, stderr: '', timedOut: false }
|
|
675
|
-
} catch (error) {
|
|
676
|
-
return abortAwareFailure(signal, error)
|
|
677
|
-
}
|
|
678
|
-
}
|
|
679
|
-
|
|
680
|
-
/** The text of the last assistant message in a turn, for Claude's Stop-hook
|
|
681
|
-
* `last_assistant_message`. Thinking and tool calls are dropped; a plain-string
|
|
682
|
-
* content is returned as-is. */
|
|
683
|
-
export function lastAssistantText(messages: ReadonlyArray<{ role: string; content: unknown }>): string {
|
|
684
|
-
for (let i = messages.length - 1; i >= 0; i--) {
|
|
685
|
-
const message = messages[i]
|
|
686
|
-
if (message.role !== 'assistant') continue
|
|
687
|
-
if (typeof message.content === 'string') return message.content
|
|
688
|
-
if (!Array.isArray(message.content)) return ''
|
|
689
|
-
return message.content
|
|
690
|
-
.filter((part): part is { type: 'text'; text: string } => typeof part === 'object' && part !== null && (part as { type?: unknown }).type === 'text')
|
|
691
|
-
.map((part) => part.text)
|
|
692
|
-
.join('')
|
|
693
|
-
}
|
|
694
|
-
return ''
|
|
695
|
-
}
|
|
696
|
-
|
|
697
|
-
/** Claude overrides a Stop hook after it blocks this many times in a row with no user
|
|
698
|
-
* progress, ending the turn with a warning rather than looping forever. */
|
|
699
|
-
const DEFAULT_STOP_HOOK_BLOCK_CAP = 8
|
|
700
|
-
|
|
701
|
-
/** The consecutive-block cap for the Stop hook: CLAUDE_CODE_STOP_HOOK_BLOCK_CAP when it
|
|
702
|
-
* is a positive integer, else the default. A non-positive or malformed value falls back
|
|
703
|
-
* to the default rather than capping at zero (which would suppress the very first block). */
|
|
704
|
-
export function stopHookBlockCap(env: Record<string, string | undefined> = process.env): number {
|
|
705
|
-
const override = Number.parseInt(env.CLAUDE_CODE_STOP_HOOK_BLOCK_CAP ?? '', 10)
|
|
706
|
-
return Number.isInteger(override) && override > 0 ? override : DEFAULT_STOP_HOOK_BLOCK_CAP
|
|
707
|
-
}
|
|
708
|
-
|
|
709
|
-
/** Above 2^31-1 ms Node clamps a timer to 1ms, which would kill the hook instantly. */
|
|
710
|
-
const MAX_TIMEOUT_S = 2_147_483
|
|
711
|
-
|
|
712
|
-
function timeoutMs(command: HookCommand): number {
|
|
713
|
-
// Non-positive values fall back to the default: a 0ms timer would fire before the
|
|
714
|
-
// hook runs, and a timed-out PreToolUse hook fails closed, bricking the tool.
|
|
715
|
-
const declared = command.timeout
|
|
716
|
-
const seconds = typeof declared === 'number' && declared > 0 ? Math.min(declared, MAX_TIMEOUT_S) : DEFAULT_TIMEOUT_S
|
|
717
|
-
return seconds * 1000
|
|
718
|
-
}
|
|
719
|
-
|
|
720
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
721
|
-
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
722
|
-
}
|
|
723
|
-
|
|
724
|
-
/** Claude's updatedInput replaces the whole tool_input, and pi's tool_call contract is
|
|
725
|
-
* in-place mutation, so the target object is emptied and refilled rather than reassigned. */
|
|
726
|
-
function replaceRecord(target: Record<string, unknown>, next: Record<string, unknown>): void {
|
|
727
|
-
for (const key of Object.keys(target)) delete target[key]
|
|
728
|
-
Object.assign(target, next)
|
|
729
|
-
}
|
|
730
|
-
|
|
731
|
-
/** Claude surfaces a hook error notice; on ungated events the action proceeds, while
|
|
732
|
-
* PreToolUse and UserPromptSubmit additionally fail closed on the same results (see
|
|
733
|
-
* their spawnFailed checks). Silence would hide that a guard never ran. */
|
|
734
|
-
function surfaceHookFailures(commands: HookCommand[], results: HookRunResult[], notify?: SystemMessageSink): void {
|
|
735
|
-
if (!notify) return
|
|
736
|
-
for (const [i, result] of results.entries()) {
|
|
737
|
-
if (result.spawnFailed) notify(`Hook failed to run: ${commands[i].command}: ${result.stderr.trim() || 'unknown error'}`)
|
|
738
|
-
}
|
|
739
|
-
}
|
|
740
|
-
|
|
741
|
-
/** Run PreToolUse hooks for a tool, in parallel as Claude does; the first blocking
|
|
742
|
-
* verdict in config order wins. For MCP tools the matcher sees both the pi name and
|
|
743
|
-
* the Claude alias, and the payload reports the alias, which is the name a
|
|
744
|
-
* Claude-written hook script expects in tool_name. Every hook sees the original
|
|
745
|
-
* tool input; hookSpecificOutput.updatedInput replaces the input in place as each
|
|
746
|
-
* hook completes, so with several rewrites the last to finish takes effect, which
|
|
747
|
-
* is Claude's documented (non-deterministic) behavior. */
|
|
748
|
-
export async function runPreToolUse(config: HooksConfig, toolName: string, toolInput: unknown, runner: HookRunner, claudeName?: string, onSystemMessage?: SystemMessageSink): Promise<HookDecision> {
|
|
749
|
-
const names = claudeName ? [toolName, claudeName] : [toolName]
|
|
750
|
-
const commands = matchingCommands(config.PreToolUse, names)
|
|
751
|
-
const results = await Promise.all(
|
|
752
|
-
commands.map((command) =>
|
|
753
|
-
runner(command, { hook_event_name: 'PreToolUse', tool_name: claudeName ?? toolName, tool_input: toolInput }, timeoutMs(command)).then((result) => {
|
|
754
|
-
const updated = tryParseJson(result.stdout)?.hookSpecificOutput?.updatedInput
|
|
755
|
-
if (isRecord(updated) && isRecord(toolInput)) replaceRecord(toolInput, updated)
|
|
756
|
-
return result
|
|
757
|
-
}),
|
|
758
|
-
),
|
|
759
|
-
)
|
|
760
|
-
surfaceHookFailures(commands, results, onSystemMessage)
|
|
761
|
-
for (const [i, result] of results.entries()) {
|
|
762
|
-
// A killed hook never reached its verdict, and SIGKILL leaves a null exit code that
|
|
763
|
-
// would otherwise read as a clean allow. Fail closed instead.
|
|
764
|
-
if (result.timedOut) return { block: true, reason: `Hook timed out after ${timeoutMs(commands[i])}ms: ${commands[i].command}` }
|
|
765
|
-
// A hook that never spawned (EMFILE, missing /bin/sh) reached no verdict either;
|
|
766
|
-
// its code 0 must fail closed like a timeout, not read as an allow exactly when
|
|
767
|
-
// the machine is degraded.
|
|
768
|
-
if (result.spawnFailed) return { block: true, reason: `Hook failed to run: ${commands[i].command}: ${result.stderr.trim() || 'unknown error'}` }
|
|
769
|
-
}
|
|
770
|
-
if (onSystemMessage) surfaceSystemMessages(results, onSystemMessage)
|
|
771
|
-
// A hard deny wins over an ask, matching Claude's deny > ask > allow precedence:
|
|
772
|
-
// scan for any deny first, and only fall back to the first ask.
|
|
773
|
-
let ask: HookDecision | undefined
|
|
774
|
-
for (const result of results) {
|
|
775
|
-
const decision = interpretHookResult(result.code, result.stdout, result.stderr)
|
|
776
|
-
if (decision.block && !decision.ask) return decision
|
|
777
|
-
if (decision.ask && ask === undefined) ask = decision
|
|
778
|
-
}
|
|
779
|
-
return ask ?? { block: false }
|
|
780
|
-
}
|
|
781
|
-
|
|
782
|
-
async function runNotifyHooks(commands: HookCommand[], payload: unknown, runner: HookRunner): Promise<HookRunResult[]> {
|
|
783
|
-
return await Promise.all(commands.map((command) => runner(command, payload, timeoutMs(command))))
|
|
784
|
-
}
|
|
785
|
-
|
|
786
|
-
type SystemMessageSink = (message: string) => void
|
|
787
|
-
|
|
788
|
-
/** Claude's universal systemMessage output field: a warning surfaced to the user. */
|
|
789
|
-
function surfaceSystemMessages(results: HookRunResult[], notify: SystemMessageSink): void {
|
|
790
|
-
for (const result of results) {
|
|
791
|
-
const message = tryParseJson(result.stdout)?.systemMessage
|
|
792
|
-
if (message) notify(message)
|
|
793
|
-
}
|
|
794
|
-
}
|
|
795
|
-
|
|
796
|
-
export interface PromptDecision {
|
|
797
|
-
block: boolean
|
|
798
|
-
reason?: string
|
|
799
|
-
context: string
|
|
800
|
-
}
|
|
801
|
-
|
|
802
|
-
/** Additional context a UserPromptSubmit hook contributes: an explicit
|
|
803
|
-
* hookSpecificOutput.additionalContext, or the raw stdout of a plain exit-0 hook. */
|
|
804
|
-
function promptContext(stdout: string): string {
|
|
805
|
-
const parsed = tryParseJson(stdout)
|
|
806
|
-
if (parsed) return parsed.hookSpecificOutput?.additionalContext ?? ''
|
|
807
|
-
return stdout.trim()
|
|
808
|
-
}
|
|
809
|
-
|
|
810
|
-
/** Run UserPromptSubmit hooks, in parallel as Claude does: the first blocking
|
|
811
|
-
* verdict in config order wins; otherwise their additional context is concatenated
|
|
812
|
-
* in config order for injection ahead of the prompt. */
|
|
813
|
-
export async function runUserPromptSubmit(config: HooksConfig, prompt: string, runner: HookRunner, onSystemMessage?: SystemMessageSink): Promise<PromptDecision> {
|
|
814
|
-
const commands = matchingCommands(config.UserPromptSubmit, 'UserPromptSubmit')
|
|
815
|
-
const results = await Promise.all(commands.map((command) => runner(command, { hook_event_name: 'UserPromptSubmit', prompt }, timeoutMs(command))))
|
|
816
|
-
surfaceHookFailures(commands, results, onSystemMessage)
|
|
817
|
-
for (const [i, result] of results.entries()) {
|
|
818
|
-
if (result.timedOut) return { block: true, reason: `Hook timed out after ${timeoutMs(commands[i])}ms: ${commands[i].command}`, context: '' }
|
|
819
|
-
// No verdict was delivered, so fail closed like a timeout (see runPreToolUse).
|
|
820
|
-
if (result.spawnFailed) return { block: true, reason: `Hook failed to run: ${commands[i].command}: ${result.stderr.trim() || 'unknown error'}`, context: '' }
|
|
821
|
-
}
|
|
822
|
-
if (onSystemMessage) surfaceSystemMessages(results, onSystemMessage)
|
|
823
|
-
const contexts: string[] = []
|
|
824
|
-
for (const result of results) {
|
|
825
|
-
const decision = interpretHookResult(result.code, result.stdout, result.stderr)
|
|
826
|
-
if (decision.block) return { block: true, reason: decision.reason, context: '' }
|
|
827
|
-
const context = promptContext(result.stdout)
|
|
828
|
-
if (context) contexts.push(context)
|
|
829
|
-
}
|
|
830
|
-
return { block: false, context: contexts.join('\n') }
|
|
831
|
-
}
|
|
832
|
-
|
|
833
|
-
/** pi's lifecycle vocabularies differ from Claude's documented ones. The matcher is
|
|
834
|
-
* offered both spellings so existing configs keep firing either way, and the payload
|
|
835
|
-
* reports the Claude value, which is what a Claude-written hook script parses. */
|
|
836
|
-
const SESSION_START_SOURCE: Record<string, string> = { startup: 'startup', new: 'clear', resume: 'resume', fork: 'fork' }
|
|
837
|
-
const PRECOMPACT_TRIGGER: Record<string, string> = { manual: 'manual', threshold: 'auto', overflow: 'auto' }
|
|
838
|
-
const SESSION_END_REASON: Record<string, string> = { quit: 'prompt_input_exit', new: 'clear', resume: 'resume', reload: 'other', fork: 'other' }
|
|
839
|
-
|
|
840
|
-
/** The raw pi value plus its Claude spelling, deduplicated, for matcher candidates. */
|
|
841
|
-
function claudeSpelling(map: Record<string, string>, raw: string): { names: string[]; value: string } {
|
|
842
|
-
const value = map[raw] ?? raw
|
|
843
|
-
return { names: value === raw ? [raw] : [raw, value], value }
|
|
844
|
-
}
|
|
845
|
-
|
|
846
|
-
/** The feedback lines one PostToolUse/PostToolUseFailure result appends next to the
|
|
847
|
-
* tool result: a block notice (exit-2 stderr, or decision:block on success) followed
|
|
848
|
-
* by any additionalContext. A failed tool cannot be blocked, so its stderr is shown
|
|
849
|
-
* but never a decision:block verdict. */
|
|
850
|
-
function postToolFeedback(result: HookRunResult, eventName: string, isError: boolean): string[] {
|
|
851
|
-
const lines: string[] = []
|
|
852
|
-
const parsed = tryParseJson(result.stdout)
|
|
853
|
-
// A failed tool cannot be blocked, but the hook's stderr is still shown; on
|
|
854
|
-
// success, exit-2 / decision:block feed back as a block notice.
|
|
855
|
-
if (!result.timedOut && result.code === 2) lines.push(`${eventName} hook: ${result.stderr.trim() || (isError ? 'hook reported an error' : 'Blocked by hook')}`)
|
|
856
|
-
else if (!isError && parsed?.decision === 'block') lines.push(`PostToolUse hook: ${parsed.reason ?? 'Blocked by hook'}`)
|
|
857
|
-
const context = parsed?.hookSpecificOutput?.additionalContext
|
|
858
|
-
if (context) lines.push(context)
|
|
859
|
-
return lines
|
|
860
|
-
}
|
|
861
|
-
|
|
862
|
-
/** A blocked tool_call verdict carrying pi's `terminate` flag (#7715): with it set on
|
|
863
|
-
* an all-terminating tool batch, pi skips the automatic follow-up model call that a plain
|
|
864
|
-
* block would otherwise pay for. */
|
|
865
|
-
function blockedToolCall(reason: string | undefined): ToolCallEventResult {
|
|
866
|
-
return { block: true, reason, terminate: true }
|
|
867
|
-
}
|
|
868
|
-
|
|
869
|
-
export default function hooksExtension(pi: ExtensionAPI) {
|
|
870
|
-
let config: HooksConfig = {}
|
|
871
|
-
let projectDir = ''
|
|
872
|
-
/** Claude's allowedHttpHookUrls allowlist, resolved from the settings chain. */
|
|
873
|
-
let allowedHttpHookUrls: string[] | undefined
|
|
874
|
-
let pendingSessionContext: string[] = []
|
|
875
|
-
let stopHookActive = false
|
|
876
|
-
/** Consecutive Stop-hook blocks with no user progress between them. Reset on user input
|
|
877
|
-
* and on a non-blocking Stop; at the cap the continuation is suppressed and the turn ends. */
|
|
878
|
-
let stopHookBlockCount = 0
|
|
879
|
-
let sessionCtx: ExtensionContext | undefined
|
|
880
|
-
/** Claude's disableAllHooks escape hatch was set somewhere in the honored chain. */
|
|
881
|
-
let hooksDisabled = false
|
|
882
|
-
/** Which settings file each resolved entry came from, for the /hooks viewer. */
|
|
883
|
-
const hookSources = new Map<HookMatcher, string>()
|
|
884
|
-
/** Claude sends session_id, transcript_path, cwd and effort on every payload. */
|
|
885
|
-
const commonPayload = (ctx: ExtensionContext): Record<string, unknown> => {
|
|
886
|
-
const common: Record<string, unknown> = { session_id: ctx.sessionManager.getSessionId(), cwd: ctx.cwd, permission_mode: permissionMode }
|
|
887
|
-
const transcript = ctx.sessionManager.getSessionFile()
|
|
888
|
-
if (transcript) common.transcript_path = transcript
|
|
889
|
-
if (ctx.thinkingLevel) common.effort = { level: ctx.thinkingLevel }
|
|
890
|
-
return common
|
|
891
|
-
}
|
|
892
|
-
/** A runner bound to the firing context, filling the common fields into each
|
|
893
|
-
* payload and dispatching on the entry's type. */
|
|
894
|
-
const boundRunner =
|
|
895
|
-
(ctx: ExtensionContext, extra?: Record<string, unknown>): HookRunner =>
|
|
896
|
-
(hook, payload, ms) => {
|
|
897
|
-
const merged = { ...commonPayload(ctx), ...extra, ...(payload as Record<string, unknown>) }
|
|
898
|
-
if (hook.type === 'http') return runHttpHook(hook, merged, ms, allowedHttpHookUrls)
|
|
899
|
-
if (hook.type === 'prompt') return runPromptHook(hook, merged, ctx.model, ms)
|
|
900
|
-
if (hook.type === 'agent') return runAgentHook(hook, merged, ms, (ctx.model as { id?: string } | undefined)?.id)
|
|
901
|
-
if (hook.type === 'mcp_tool') return runMcpToolHook(hook, merged, ms)
|
|
902
|
-
return runHookCommand(hook.command, merged, ms, projectDir, hook.args)
|
|
903
|
-
}
|
|
904
|
-
// Claude matchers name MCP tools mcp__<server>__<tool>; pi-code registers them as
|
|
905
|
-
// <server>_<tool>. The mcp extension publishes the mapping on pi's shared bus.
|
|
906
|
-
const mcpAliases = new Map<string, string>()
|
|
907
|
-
pi.events.on(MCP_TOOLS_CHANNEL, (data) => {
|
|
908
|
-
if (!isMcpToolAliases(data)) return
|
|
909
|
-
mcpAliases.clear()
|
|
910
|
-
for (const entry of data) mcpAliases.set(entry.pi, entry.claude)
|
|
911
|
-
})
|
|
912
|
-
// Claude's permission_mode: pi has no permission system, but pi-code's plan mode is
|
|
913
|
-
// the documented "plan" mode; its extension publishes the state on the shared bus.
|
|
914
|
-
let permissionMode = 'default'
|
|
915
|
-
pi.events.on(PLAN_MODE_CHANNEL, (data) => {
|
|
916
|
-
if (isPlanModeState(data)) permissionMode = data.active ? 'plan' : 'default'
|
|
917
|
-
})
|
|
918
|
-
// Claude's InstructionsLoaded hook has NO decision control: exit codes are
|
|
919
|
-
// ignored and every JSON output field (systemMessage included) is discarded, so
|
|
920
|
-
// dispatch is fire-and-forget on all paths. Two documented load reasons can
|
|
921
|
-
// never fire honestly and are deliberate gaps, not approximations:
|
|
922
|
-
// `nested_traversal` (pi does not lazily load a nested CLAUDE.md on subdirectory
|
|
923
|
-
// entry) and `compact` (pi does not re-load instruction files after compaction).
|
|
924
|
-
const fireInstructionsLoaded = (payload: Record<string, unknown>): void => {
|
|
925
|
-
if (!sessionCtx) return
|
|
926
|
-
const commands = matchingCommands(config.InstructionsLoaded, String(payload.load_reason))
|
|
927
|
-
if (commands.length === 0) return
|
|
928
|
-
void runNotifyHooks(commands, { hook_event_name: 'InstructionsLoaded', ...payload }, boundRunner(sessionCtx)).catch(() => {})
|
|
929
|
-
}
|
|
930
|
-
// Every load rides the shared bus: context-imports publishes session_start for
|
|
931
|
-
// the context files that survived claudeMdExcludes and include for resolved
|
|
932
|
-
// @imports (deduped there, once per file per session); claude-rules publishes
|
|
933
|
-
// path_glob_match when a scoped rule attaches. Consuming the bus rather than
|
|
934
|
-
// iterating raw contextFiles keeps this extension from announcing a file the
|
|
935
|
-
// exclusion removed from the prompt; bus emit is synchronous, so the events
|
|
936
|
-
// arrive regardless of extension load order.
|
|
937
|
-
pi.events.on(INSTRUCTIONS_CHANNEL, (data) => {
|
|
938
|
-
if (!isInstructionLoadEvent(data)) return
|
|
939
|
-
fireInstructionsLoaded({ ...data })
|
|
940
|
-
})
|
|
941
|
-
|
|
942
|
-
// Subagent lifecycle arrives over the bus without a pi context; the session context
|
|
943
|
-
// captured at session_start supplies the common payload fields.
|
|
944
|
-
pi.events.on(SUBAGENT_CHANNEL, async (data) => {
|
|
945
|
-
if (!isSubagentPhaseEvent(data) || !sessionCtx) return
|
|
946
|
-
const ctx = sessionCtx
|
|
947
|
-
const eventName = data.phase === 'start' ? 'SubagentStart' : 'SubagentStop'
|
|
948
|
-
const payload = { hook_event_name: eventName, agent_type: data.agentType, agent_id: data.agentId }
|
|
949
|
-
try {
|
|
950
|
-
const results = await runNotifyHooks(matchingCommands(config[eventName], data.agentType), payload, boundRunner(ctx))
|
|
951
|
-
surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
|
|
952
|
-
} catch {
|
|
953
|
-
// The bus outlives the session: an event landing between /new disposing this
|
|
954
|
-
// ctx and the next session_start hits disposed getters, and nothing awaits a
|
|
955
|
-
// bus listener, so a throw here would escape as an unhandled rejection.
|
|
956
|
-
}
|
|
957
|
-
})
|
|
958
|
-
|
|
959
|
-
pi.on('session_start', async (event, ctx) => {
|
|
960
|
-
sessionCtx = ctx
|
|
961
|
-
// One extension instance serves every session. A mid-turn /new fires session_start on
|
|
962
|
-
// the same instance while a Stop-hook continuation streak is in flight; it must not
|
|
963
|
-
// carry into the next session, so reset before any early return (disableAllHooks below).
|
|
964
|
-
stopHookActive = false
|
|
965
|
-
stopHookBlockCount = 0
|
|
966
|
-
const trusted = await isProjectApproved(ctx)
|
|
967
|
-
// Claude's CLAUDE_PROJECT_DIR is the project root, not the session cwd; a hook
|
|
968
|
-
// referencing $CLAUDE_PROJECT_DIR/.claude/hooks/helper.sh must resolve from a
|
|
969
|
-
// subdirectory session too.
|
|
970
|
-
projectDir = repoRoot(ctx.cwd) ?? ctx.cwd
|
|
971
|
-
const files = hookFiles(ctx.cwd, os.homedir(), trusted)
|
|
972
|
-
hookSources.clear()
|
|
973
|
-
allowedHttpHookUrls = readAllowedHttpHookUrls(files)
|
|
974
|
-
// The disableAllHooks escape hatch, checked before any config loads: with no
|
|
975
|
-
// config resolved, no event, plugin hooks included, can fire a hook.
|
|
976
|
-
hooksDisabled = readDisableAllHooks(files)
|
|
977
|
-
if (hooksDisabled) {
|
|
978
|
-
config = {}
|
|
979
|
-
pendingSessionContext = []
|
|
980
|
-
return
|
|
981
|
-
}
|
|
982
|
-
config = loadHooks(files, hookSources)
|
|
983
|
-
// Plugins are user-installed and enabled by user settings (see installedPlugins),
|
|
984
|
-
// so a checked-out repo cannot toggle which code-bearing plugin hooks run.
|
|
985
|
-
loadPluginHooks(config, installedPlugins(os.homedir()), hookSources)
|
|
986
|
-
// "reload" re-fires in-process with the same conversation and would double-run hooks;
|
|
987
|
-
// a fork is a genuine session begin, which Claude reports as source "fork".
|
|
988
|
-
if (event.reason === 'reload') return
|
|
989
|
-
const source = claudeSpelling(SESSION_START_SOURCE, event.reason)
|
|
990
|
-
const commands = matchingCommands(config.SessionStart, source.names)
|
|
991
|
-
const payload = { hook_event_name: 'SessionStart', source: source.value }
|
|
992
|
-
const run = boundRunner(ctx)
|
|
993
|
-
const results = await Promise.all(commands.map((command) => run(command, payload, timeoutMs(command))))
|
|
994
|
-
surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
|
|
995
|
-
pendingSessionContext = results.map((result) => promptContext(result.stdout)).filter(Boolean)
|
|
996
|
-
})
|
|
997
|
-
|
|
998
|
-
// Claude adds a SessionStart hook's additionalContext (or plain stdout) to the
|
|
999
|
-
// conversation before the first prompt; pi's seam for that is a message injected
|
|
1000
|
-
// on the next agent start. The session_start InstructionsLoaded events arrive
|
|
1001
|
-
// over the bus from context-imports, which owns claudeMdExcludes; announcing
|
|
1002
|
-
// the raw contextFiles here would fire for a file the exclusion removed.
|
|
1003
|
-
pi.on('before_agent_start', async () => {
|
|
1004
|
-
if (pendingSessionContext.length === 0) return
|
|
1005
|
-
const content = pendingSessionContext.join('\n')
|
|
1006
|
-
pendingSessionContext = []
|
|
1007
|
-
return { message: { customType: 'claude-hook-context', content, display: false } }
|
|
1008
|
-
})
|
|
1009
|
-
|
|
1010
|
-
pi.on('tool_call', async (event, ctx) => {
|
|
1011
|
-
const decision = await runPreToolUse(config, event.toolName, event.input, boundRunner(ctx, { tool_use_id: event.toolCallId }), mcpAliases.get(event.toolName), (message) => ctx.ui.notify(message, 'warning'))
|
|
1012
|
-
if (!decision.block) return undefined
|
|
1013
|
-
// Claude's "ask": prompt the user and let the call through if they approve.
|
|
1014
|
-
// With no UI (headless) the block stands, which is the safe default.
|
|
1015
|
-
if (decision.ask && ctx.hasUI) {
|
|
1016
|
-
const approved = await ctx.ui.confirm(`Allow ${event.toolName}?`, decision.reason ?? 'A hook asks you to confirm this tool call.')
|
|
1017
|
-
return approved ? undefined : blockedToolCall(decision.reason)
|
|
1018
|
-
}
|
|
1019
|
-
return blockedToolCall(decision.reason)
|
|
1020
|
-
})
|
|
1021
|
-
|
|
1022
|
-
// Claude's PostToolUse (success) and PostToolUseFailure (error) both feed their
|
|
1023
|
-
// hook's output back next to the tool result: a decision:block reason (or exit-2
|
|
1024
|
-
// stderr) and additionalContext are appended, which is where Claude documents they
|
|
1025
|
-
// land. The failure branch shows the hook's stderr to the model too ("Shows stderr
|
|
1026
|
-
// to Claude; the tool already failed"), it just cannot block a call that failed.
|
|
1027
|
-
pi.on('tool_result', async (event, ctx) => {
|
|
1028
|
-
const alias = mcpAliases.get(event.toolName)
|
|
1029
|
-
const names = alias ? [event.toolName, alias] : [event.toolName]
|
|
1030
|
-
const response = { content: event.content, details: event.details, isError: event.isError }
|
|
1031
|
-
const eventName = event.isError ? 'PostToolUseFailure' : 'PostToolUse'
|
|
1032
|
-
const commands = matchingCommands(event.isError ? config.PostToolUseFailure : config.PostToolUse, names)
|
|
1033
|
-
if (commands.length === 0) return
|
|
1034
|
-
const payload = { hook_event_name: eventName, tool_name: alias ?? event.toolName, tool_input: event.input, tool_response: response }
|
|
1035
|
-
const run = boundRunner(ctx, { tool_use_id: event.toolCallId })
|
|
1036
|
-
const results = await Promise.all(commands.map((command) => run(command, payload, timeoutMs(command))))
|
|
1037
|
-
surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
|
|
1038
|
-
const feedback = results.flatMap((result) => postToolFeedback(result, eventName, event.isError))
|
|
1039
|
-
if (feedback.length === 0) return
|
|
1040
|
-
return { content: [...event.content, ...feedback.map((text) => ({ type: 'text' as const, text }))] }
|
|
1041
|
-
})
|
|
1042
|
-
|
|
1043
|
-
// Claude's PreToolUse for Bash, extended to a command the user runs directly with the
|
|
1044
|
-
// `!`/`!!` prefix. pi fires user_bash before executing it, and the model never sees it,
|
|
1045
|
-
// so without this a guard that blocks `git push -f` from the model would not stop the
|
|
1046
|
-
// same command typed by hand. There is no pi tool call, so the matcher sees both pi's
|
|
1047
|
-
// "bash" and the Claude name "Bash" (exactly as an MCP alias is bridged) and the payload
|
|
1048
|
-
// reports "Bash", the tool_name a Claude-written PreToolUse Bash hook expects. The
|
|
1049
|
-
// payload carries no tool_use_id (no model tool call produced it). UserBashEventResult
|
|
1050
|
-
// exposes no block flag: a deny is enforced through `result` ("extension handled
|
|
1051
|
-
// execution, use this result"), a synthetic failed BashResult that stands in for the
|
|
1052
|
-
// command so it never runs and its deny reason shows as the output. The event delivers
|
|
1053
|
-
// no execution result and fires only before the command runs, so there is deliberately
|
|
1054
|
-
// no PostToolUse for it.
|
|
1055
|
-
pi.on('user_bash', async (event, ctx) => {
|
|
1056
|
-
const decision = await runPreToolUse(config, 'bash', { command: event.command }, boundRunner(ctx), 'Bash', (message) => ctx.ui.notify(message, 'warning'))
|
|
1057
|
-
if (!decision.block) return undefined
|
|
1058
|
-
// Claude's "ask": prompt before running and let the command through on approval; with
|
|
1059
|
-
// no UI (headless) the block stands, the same safe default as the tool_call path.
|
|
1060
|
-
if (decision.ask && ctx.hasUI) {
|
|
1061
|
-
const approved = await ctx.ui.confirm('Allow this command?', decision.reason ?? 'A hook asks you to confirm this command.')
|
|
1062
|
-
if (approved) return undefined
|
|
1063
|
-
}
|
|
1064
|
-
const reason = decision.reason ?? 'Command blocked by hook'
|
|
1065
|
-
return { result: { output: `Blocked by hook: ${reason}`, exitCode: 1, cancelled: false, truncated: false } }
|
|
1066
|
-
})
|
|
1067
|
-
|
|
1068
|
-
pi.on('input', async (event, ctx) => {
|
|
1069
|
-
// Only genuine user input; extension-injected messages (plan-mode, subagent) are not
|
|
1070
|
-
// prompts the user submitted.
|
|
1071
|
-
if (event.source === 'extension') return { action: 'continue' }
|
|
1072
|
-
// Genuine user input is progress, so it breaks a Stop-hook continuation streak: the
|
|
1073
|
-
// block cap counts only consecutive blocks with nothing from the user in between.
|
|
1074
|
-
stopHookBlockCount = 0
|
|
1075
|
-
const decision = await runUserPromptSubmit(config, event.text, boundRunner(ctx), (message) => ctx.ui.notify(message, 'warning'))
|
|
1076
|
-
if (decision.block) {
|
|
1077
|
-
// pi's input result has no reason channel, so surface why before consuming it.
|
|
1078
|
-
ctx.ui.notify(decision.reason ?? 'Prompt blocked by hook', 'error')
|
|
1079
|
-
return { action: 'handled' }
|
|
1080
|
-
}
|
|
1081
|
-
// Claude injects a UserPromptSubmit hook's context ahead of the prompt; transform is
|
|
1082
|
-
// pi's seam for rewriting the submitted text.
|
|
1083
|
-
if (decision.context) return { action: 'transform', text: `${decision.context}\n\n${event.text}` }
|
|
1084
|
-
return { action: 'continue' }
|
|
1085
|
-
})
|
|
1086
|
-
|
|
1087
|
-
// Claude's Stop hook can prevent stopping: a block feeds its reason back as a new
|
|
1088
|
-
// turn, and stop_hook_active in the payload tells the next firing it is already
|
|
1089
|
-
// continuing from a stop hook, which is the hook script's documented loop guard.
|
|
1090
|
-
// Only exit 2 and decision:"block" continue; continue:false means "stay stopped".
|
|
1091
|
-
//
|
|
1092
|
-
// On agent_end rather than agent_settled: agent_settled is only emitted after every
|
|
1093
|
-
// agent_end handler returns, and a peer extension (plan mode) blocks its agent_end
|
|
1094
|
-
// handler on a UI dialog, which would starve the Stop hook and idle notification
|
|
1095
|
-
// until the user answers it. agent_end can fire slightly early before a rare
|
|
1096
|
-
// automatic retry or compaction; that is the better tradeoff.
|
|
1097
|
-
pi.on('agent_end', async (event, ctx) => {
|
|
1098
|
-
// Claude's Notification event, for the one type pi can honestly source: the
|
|
1099
|
-
// agent finished and is waiting for input (idle_prompt). Observational only;
|
|
1100
|
-
// exit codes and JSON output are ignored, as Claude documents for this event.
|
|
1101
|
-
const notifyCommands = matchingCommands(config.Notification, ['idle_prompt'])
|
|
1102
|
-
if (notifyCommands.length > 0) {
|
|
1103
|
-
void runNotifyHooks(notifyCommands, { hook_event_name: 'Notification', notification_type: 'idle_prompt', message: 'pi is waiting for your input' }, boundRunner(ctx)).catch(() => {})
|
|
1104
|
-
}
|
|
1105
|
-
|
|
1106
|
-
const commands = matchingCommands(config.Stop, 'Stop')
|
|
1107
|
-
if (commands.length === 0) {
|
|
1108
|
-
stopHookActive = false
|
|
1109
|
-
return
|
|
1110
|
-
}
|
|
1111
|
-
// Claude's Stop payload carries the turn's final assistant text so a hook need
|
|
1112
|
-
// not re-read the transcript; included only when there is one.
|
|
1113
|
-
const lastText = lastAssistantText((event as { messages?: Array<{ role: string; content: unknown }> }).messages ?? [])
|
|
1114
|
-
const payload = { hook_event_name: 'Stop', stop_hook_active: stopHookActive, ...(lastText ? { last_assistant_message: lastText } : {}) }
|
|
1115
|
-
const run = boundRunner(ctx)
|
|
1116
|
-
const results = await Promise.all(commands.map((command) => run(command, payload, timeoutMs(command))))
|
|
1117
|
-
surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
|
|
1118
|
-
const block = results
|
|
1119
|
-
.filter((result) => !result.timedOut)
|
|
1120
|
-
.map((result) => {
|
|
1121
|
-
if (result.code === 2) return { block: true, reason: result.stderr.trim() || 'Stop blocked by hook' }
|
|
1122
|
-
const parsed = tryParseJson(result.stdout)
|
|
1123
|
-
if (parsed?.decision === 'block') return { block: true, reason: parsed.reason ?? 'Stop blocked by hook' }
|
|
1124
|
-
return { block: false, reason: '' }
|
|
1125
|
-
})
|
|
1126
|
-
.find((verdict) => verdict.block)
|
|
1127
|
-
if (!block) {
|
|
1128
|
-
// A non-blocking Stop breaks the streak: the next block starts a fresh count.
|
|
1129
|
-
stopHookActive = false
|
|
1130
|
-
stopHookBlockCount = 0
|
|
1131
|
-
return
|
|
1132
|
-
}
|
|
1133
|
-
stopHookBlockCount += 1
|
|
1134
|
-
const cap = stopHookBlockCap()
|
|
1135
|
-
if (stopHookBlockCount >= cap) {
|
|
1136
|
-
// Claude overrides a Stop hook that has blocked cap times in a row with no user
|
|
1137
|
-
// progress: suppress the continuation, warn, and let the turn end so the loop cannot
|
|
1138
|
-
// run forever. Reset the count so a later run (or user turn) starts clean.
|
|
1139
|
-
stopHookActive = false
|
|
1140
|
-
stopHookBlockCount = 0
|
|
1141
|
-
ctx.ui.notify(`Stop hook block cap reached (${cap} consecutive blocks); ending the turn.`, 'warning')
|
|
1142
|
-
return
|
|
1143
|
-
}
|
|
1144
|
-
stopHookActive = true
|
|
1145
|
-
pi.sendMessage({ customType: 'claude-stop-hook', content: block.reason, display: true }, { triggerTurn: true })
|
|
1146
|
-
})
|
|
1147
|
-
|
|
1148
|
-
pi.on('session_before_compact', async (event, ctx) => {
|
|
1149
|
-
const trigger = claudeSpelling(PRECOMPACT_TRIGGER, event.reason)
|
|
1150
|
-
const results = await runNotifyHooks(matchingCommands(config.PreCompact, trigger.names), { hook_event_name: 'PreCompact', trigger: trigger.value }, boundRunner(ctx))
|
|
1151
|
-
surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
|
|
1152
|
-
})
|
|
1153
|
-
|
|
1154
|
-
pi.on('session_compact', async (event, ctx) => {
|
|
1155
|
-
const trigger = claudeSpelling(PRECOMPACT_TRIGGER, event.reason)
|
|
1156
|
-
const results = await runNotifyHooks(matchingCommands(config.PostCompact, trigger.names), { hook_event_name: 'PostCompact', trigger: trigger.value }, boundRunner(ctx))
|
|
1157
|
-
surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
|
|
1158
|
-
})
|
|
1159
|
-
|
|
1160
|
-
pi.on('session_shutdown', async (event, ctx) => {
|
|
1161
|
-
const reason = claudeSpelling(SESSION_END_REASON, event.reason)
|
|
1162
|
-
const results = await runNotifyHooks(matchingCommands(config.SessionEnd, reason.names), { hook_event_name: 'SessionEnd', reason: reason.value }, boundRunner(ctx))
|
|
1163
|
-
surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
|
|
1164
|
-
})
|
|
1165
|
-
|
|
1166
|
-
// Claude's /hooks manages hook configuration; pi-code's is a viewer: hook failures
|
|
1167
|
-
// are otherwise opaque, so showing the resolved chain per event, with the settings
|
|
1168
|
-
// file each entry came from, is the debugging surface.
|
|
1169
|
-
pi.registerCommand('hooks', {
|
|
1170
|
-
description: 'Show the hook configuration resolved from settings',
|
|
1171
|
-
handler: async (_args, ctx) => {
|
|
1172
|
-
if (hooksDisabled) {
|
|
1173
|
-
ctx.ui.notify('All hooks are disabled by the disableAllHooks setting.', 'info')
|
|
1174
|
-
return
|
|
1175
|
-
}
|
|
1176
|
-
ctx.ui.notify(formatHooksSummary(config, hookSources), 'info')
|
|
1177
|
-
},
|
|
1178
|
-
})
|
|
1179
|
-
}
|