pi-code 1.0.4 → 1.0.6
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 +25 -13
- package/extensions/claude-rules.ts +158 -54
- package/extensions/commands.ts +417 -41
- package/extensions/context-imports.ts +446 -61
- package/extensions/hooks.ts +473 -73
- package/extensions/init.ts +81 -0
- package/extensions/internal/agent-run.ts +42 -0
- package/extensions/internal/bash-rules.ts +27 -0
- package/extensions/internal/command-file.ts +423 -66
- package/extensions/internal/html-markdown.ts +71 -0
- package/extensions/internal/instruction-events.ts +70 -0
- package/extensions/internal/managed-settings.ts +38 -0
- package/extensions/internal/mcp-call.ts +28 -0
- package/extensions/internal/mcp-oauth.ts +177 -0
- package/extensions/internal/model-complete.ts +68 -0
- package/extensions/internal/path-rules.ts +80 -0
- package/extensions/internal/plugins.ts +138 -0
- package/extensions/internal/project-approval.ts +2 -3
- package/extensions/internal/project-root.ts +78 -0
- package/extensions/internal/shell-split.ts +65 -0
- package/extensions/internal/strip-comments.ts +100 -0
- package/extensions/internal/web-transport.ts +3 -1
- package/extensions/mcp.ts +579 -30
- package/extensions/memory.ts +158 -35
- package/extensions/notify.ts +76 -4
- package/extensions/output-styles.ts +34 -6
- package/extensions/plan-mode/utils.ts +3 -57
- package/extensions/question.ts +2 -2
- package/extensions/skills.ts +11 -1
- package/extensions/status-line.ts +100 -5
- package/extensions/subagent/agents.ts +72 -61
- package/extensions/subagent/background.ts +25 -6
- package/extensions/subagent/index.ts +310 -31
- package/extensions/web.ts +93 -15
- package/package.json +1 -1
package/extensions/hooks.ts
CHANGED
|
@@ -14,8 +14,16 @@
|
|
|
14
14
|
* with stop_hook_active as the loop guard)
|
|
15
15
|
* - PreCompact -> pi `session_before_compact` (fire-and-forget)
|
|
16
16
|
* - PostCompact -> pi `session_compact` (fire-and-forget)
|
|
17
|
-
* - PostToolUseFailure -> pi `tool_result` error branch (
|
|
17
|
+
* - PostToolUseFailure -> pi `tool_result` error branch (stderr/additionalContext
|
|
18
|
+
* appended to the failed result; it cannot block, the tool failed)
|
|
18
19
|
* - SessionEnd -> pi `session_shutdown` (fire-and-forget)
|
|
20
|
+
* - InstructionsLoaded -> bridged from the shared instruction-events bus:
|
|
21
|
+
* context-imports publishes session_start for the context
|
|
22
|
+
* files that survived claudeMdExcludes (it owns exclusion,
|
|
23
|
+
* so a file it removed from the prompt never announces)
|
|
24
|
+
* and include for resolved @imports; claude-rules publishes
|
|
25
|
+
* path_glob_match. Strictly observational: exit codes and
|
|
26
|
+
* JSON output, systemMessage included, are ignored.
|
|
19
27
|
*
|
|
20
28
|
* Every payload carries session_id, transcript_path (pi's session file), cwd,
|
|
21
29
|
* permission_mode (plan-mode state off the shared bus) and effort; tool events add
|
|
@@ -35,7 +43,10 @@
|
|
|
35
43
|
*
|
|
36
44
|
* Config is merged from ~/.claude/settings.json (always) plus the project's
|
|
37
45
|
* .claude/settings.json and settings.local.json (only when the project is
|
|
38
|
-
* trusted, since hooks execute arbitrary shell).
|
|
46
|
+
* trusted, since hooks execute arbitrary shell). Claude's `disableAllHooks`
|
|
47
|
+
* setting (managed settings or any honored file in that chain) short-circuits
|
|
48
|
+
* the load entirely, so no event fires any hook; /hooks prints the resolved
|
|
49
|
+
* chain per event with each entry's source settings file. Matchers follow Claude's rule:
|
|
39
50
|
* `*`/empty match all, plain names are exact (with `|`/`,` list separators), and
|
|
40
51
|
* anything with other regex characters is an unanchored regex. Claude matchers
|
|
41
52
|
* are PascalCase (`Bash`); pi tool names are lowercase (`bash`), so comparison
|
|
@@ -48,21 +59,46 @@ import { type ChildProcess, spawn } from 'node:child_process'
|
|
|
48
59
|
import * as fs from 'node:fs'
|
|
49
60
|
import * as os from 'node:os'
|
|
50
61
|
import * as path from 'node:path'
|
|
62
|
+
import type { Api, Model } from '@earendil-works/pi-ai'
|
|
51
63
|
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
|
|
52
|
-
|
|
64
|
+
import { runAgent } from './internal/agent-run.js'
|
|
65
|
+
import { INSTRUCTIONS_CHANNEL, isInstructionLoadEvent } from './internal/instruction-events.js'
|
|
66
|
+
import { readManagedSettings } from './internal/managed-settings.js'
|
|
53
67
|
import { isMcpToolAliases, MCP_TOOLS_CHANNEL } from './internal/mcp-alias.js'
|
|
68
|
+
import { callMcpTool } from './internal/mcp-call.js'
|
|
69
|
+
import { completeText } from './internal/model-complete.js'
|
|
54
70
|
import { isPlanModeState, PLAN_MODE_CHANNEL } from './internal/plan-mode-state.js'
|
|
71
|
+
import { type InstalledPlugin, installedPlugins, substitutePluginVars } from './internal/plugins.js'
|
|
55
72
|
import { isProjectApproved } from './internal/project-approval.js'
|
|
73
|
+
import { findNearestFile, repoRoot } from './internal/project-root.js'
|
|
56
74
|
import { isSubagentPhaseEvent, SUBAGENT_CHANNEL } from './internal/subagent-events.js'
|
|
57
75
|
|
|
76
|
+
// Claude defaults to 600s and lets a timed-out hook proceed; here a timed-out
|
|
77
|
+
// PreToolUse or UserPromptSubmit hook fails closed (pi has no permission prompt
|
|
78
|
+
// to fall back on), so ten minutes of default budget would wedge the turn for
|
|
79
|
+
// ten minutes on a hung hook. Hooks that legitimately run long can raise their
|
|
80
|
+
// own per-hook `timeout`.
|
|
58
81
|
const DEFAULT_TIMEOUT_S = 60
|
|
59
82
|
|
|
60
83
|
interface HookCommand {
|
|
61
84
|
type?: string
|
|
62
85
|
command: string
|
|
63
86
|
timeout?: number
|
|
87
|
+
/** http entries: the endpoint POSTed to; `command` mirrors it for dedup and display. */
|
|
88
|
+
url?: string
|
|
89
|
+
headers?: Record<string, string>
|
|
90
|
+
allowedEnvVars?: string[]
|
|
91
|
+
/** prompt entries: the prompt sent to the model (`$ARGUMENTS` = the event JSON). */
|
|
92
|
+
prompt?: string
|
|
93
|
+
/** mcp_tool entries: the connected server and tool to call, with optional input. */
|
|
94
|
+
server?: string
|
|
95
|
+
tool?: string
|
|
96
|
+
input?: Record<string, unknown>
|
|
97
|
+
/** prompt/agent entries: an optional model override; agent adds a system prompt. */
|
|
98
|
+
model?: string
|
|
99
|
+
systemPrompt?: string
|
|
64
100
|
}
|
|
65
|
-
interface HookMatcher {
|
|
101
|
+
export interface HookMatcher {
|
|
66
102
|
matcher?: string
|
|
67
103
|
hooks: HookCommand[]
|
|
68
104
|
}
|
|
@@ -71,6 +107,9 @@ export type HooksConfig = Record<string, HookMatcher[]>
|
|
|
71
107
|
export interface HookDecision {
|
|
72
108
|
block: boolean
|
|
73
109
|
reason?: string
|
|
110
|
+
/** Claude's `permissionDecision: "ask"`: the caller should prompt the user and
|
|
111
|
+
* block only on decline. `block` stays true as the no-UI fallback. */
|
|
112
|
+
ask?: boolean
|
|
74
113
|
}
|
|
75
114
|
export interface HookRunResult {
|
|
76
115
|
code: number
|
|
@@ -81,37 +120,100 @@ export interface HookRunResult {
|
|
|
81
120
|
/** The process errored before delivering a verdict (spawn failure, EIO). */
|
|
82
121
|
spawnFailed?: boolean
|
|
83
122
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
/**
|
|
123
|
+
/** Runs one configured hook entry, whatever its type; boundRunner dispatches. */
|
|
124
|
+
export type HookRunner = (hook: HookCommand, payload: unknown, timeoutMs: number) => Promise<HookRunResult>
|
|
125
|
+
/** The shell path specifically; the statusline reuses it for its own command. */
|
|
126
|
+
export type HookCommandRunner = (command: string, payload: unknown, timeoutMs: number, projectDir?: string) => Promise<HookRunResult>
|
|
127
|
+
|
|
128
|
+
/** Settings files to read, newest-winning. Project files load only when trusted, each
|
|
129
|
+
* the nearest of its name at or above cwd (bounded at the repository root, matching
|
|
130
|
+
* the approval walk), so a subdirectory session reads the settings that gated it. */
|
|
87
131
|
export function hookFiles(cwd: string, home: string, trusted: boolean): string[] {
|
|
88
132
|
const files = [path.join(home, '.claude', 'settings.json')]
|
|
89
|
-
if (trusted) files
|
|
133
|
+
if (!trusted) return files
|
|
134
|
+
for (const name of ['settings.json', 'settings.local.json']) {
|
|
135
|
+
files.push(findNearestFile(cwd, path.join('.claude', name)) ?? path.join(cwd, '.claude', name))
|
|
136
|
+
}
|
|
90
137
|
return files
|
|
91
138
|
}
|
|
92
139
|
|
|
93
|
-
|
|
140
|
+
/** Claude's `disableAllHooks` setting: the escape hatch a user reaches for when a
|
|
141
|
+
* hook misbehaves, so it is honored before any hook runs. Disabled when managed
|
|
142
|
+
* settings or ANY file in the settings chain sets it to `true`; deliberately not
|
|
143
|
+
* last-file-wins, since a repository file re-enabling the hooks the user just
|
|
144
|
+
* disabled in their own settings would defeat the escape hatch. The chain itself
|
|
145
|
+
* already gates project files on trust (see hookFiles). */
|
|
146
|
+
export function readDisableAllHooks(files: string[], managed: Record<string, unknown> = readManagedSettings()): boolean {
|
|
147
|
+
if (managed.disableAllHooks === true) return true
|
|
148
|
+
for (const file of files) {
|
|
149
|
+
try {
|
|
150
|
+
const parsed: unknown = JSON.parse(fs.readFileSync(file, 'utf-8'))
|
|
151
|
+
if (isRecord(parsed) && parsed.disableAllHooks === true) return true
|
|
152
|
+
} catch {
|
|
153
|
+
// missing or invalid file: skip
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return false
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function loadHooks(files: string[], sources?: Map<HookMatcher, string>): HooksConfig {
|
|
94
160
|
const config: HooksConfig = {}
|
|
95
161
|
for (const file of files) {
|
|
96
|
-
let
|
|
162
|
+
let raw: string
|
|
97
163
|
try {
|
|
98
|
-
|
|
164
|
+
raw = fs.readFileSync(file, 'utf-8')
|
|
99
165
|
} catch {
|
|
100
166
|
continue
|
|
101
167
|
}
|
|
102
|
-
|
|
103
|
-
if (!Array.isArray(matchers)) continue
|
|
104
|
-
// Entries are validated here rather than where they run: a hand-edited settings
|
|
105
|
-
// file that writes `hooks` as an object instead of a list used to throw out of
|
|
106
|
-
// the tool_call handler, and pi turns that into an error result, so every tool
|
|
107
|
-
// call for the rest of the session failed with an opaque type error.
|
|
108
|
-
const usable = matchers.filter((entry) => isUsableMatcher(entry, file, event))
|
|
109
|
-
if (usable.length > 0) config[event] = [...(config[event] ?? []), ...usable]
|
|
110
|
-
}
|
|
168
|
+
mergeHooksJson(config, raw, file, sources)
|
|
111
169
|
}
|
|
112
170
|
return config
|
|
113
171
|
}
|
|
114
172
|
|
|
173
|
+
function mergeHooksJson(config: HooksConfig, raw: string, source: string, sources?: Map<HookMatcher, string>): void {
|
|
174
|
+
let parsed: { hooks?: HooksConfig }
|
|
175
|
+
try {
|
|
176
|
+
parsed = JSON.parse(raw)
|
|
177
|
+
} catch {
|
|
178
|
+
return
|
|
179
|
+
}
|
|
180
|
+
for (const [event, matchers] of Object.entries(parsed?.hooks ?? {})) {
|
|
181
|
+
if (!Array.isArray(matchers)) continue
|
|
182
|
+
// Entries are validated here rather than where they run: a hand-edited settings
|
|
183
|
+
// file that writes `hooks` as an object instead of a list used to throw out of
|
|
184
|
+
// the tool_call handler, and pi turns that into an error result, so every tool
|
|
185
|
+
// call for the rest of the session failed with an opaque type error.
|
|
186
|
+
const usable = matchers.filter((entry) => isUsableMatcher(entry, source, event))
|
|
187
|
+
if (usable.length === 0) continue
|
|
188
|
+
config[event] = [...(config[event] ?? []), ...usable]
|
|
189
|
+
// Each parse produces fresh entry objects, so object identity keys the /hooks
|
|
190
|
+
// viewer's source attribution without touching the entries themselves.
|
|
191
|
+
for (const entry of usable) sources?.set(entry, source)
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Each enabled plugin's hooks (hooks/hooks.json, or wherever the manifest points),
|
|
196
|
+
* with ${CLAUDE_PLUGIN_ROOT}/${CLAUDE_PLUGIN_DATA} substituted before parsing so a
|
|
197
|
+
* hook can name its bundled scripts by real path. */
|
|
198
|
+
export function loadPluginHooks(config: HooksConfig, plugins: InstalledPlugin[], sources?: Map<HookMatcher, string>): void {
|
|
199
|
+
for (const plugin of plugins) {
|
|
200
|
+
const declared = plugin.manifest.hooks
|
|
201
|
+
// An inline hooks object; an array is not a valid hooks map (it would parse to
|
|
202
|
+
// numeric event keys), so it falls through to the default path rather than
|
|
203
|
+
// silently registering nothing.
|
|
204
|
+
if (declared !== null && typeof declared === 'object' && !Array.isArray(declared)) {
|
|
205
|
+
mergeHooksJson(config, substitutePluginVars(JSON.stringify({ hooks: declared }), plugin), `${plugin.name} (plugin.json)`, sources)
|
|
206
|
+
continue
|
|
207
|
+
}
|
|
208
|
+
const file = path.resolve(plugin.root, typeof declared === 'string' ? declared : path.join('hooks', 'hooks.json'))
|
|
209
|
+
try {
|
|
210
|
+
mergeHooksJson(config, substitutePluginVars(fs.readFileSync(file, 'utf-8'), plugin), file, sources)
|
|
211
|
+
} catch {
|
|
212
|
+
// a plugin without hooks contributes nothing
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
115
217
|
/** Claude's rule: a matcher of only letters, digits, `_`, `-`, spaces, `,` and `|`
|
|
116
218
|
* is a list of exact names; anything else is an unanchored regex. */
|
|
117
219
|
const EXACT_MATCHER = /^[\w\- ,|]*$/
|
|
@@ -163,12 +265,34 @@ function matcherApplies(matcher: string | undefined, names: readonly string[]):
|
|
|
163
265
|
}
|
|
164
266
|
}
|
|
165
267
|
|
|
166
|
-
/**
|
|
167
|
-
*
|
|
268
|
+
/** A hook entry pi-code can run: a shell command, an http POST, an in-process
|
|
269
|
+
* prompt, an mcp_tool call, or an agent subagent. An agent hook with no runner
|
|
270
|
+
* registered is still matched here and resolves non-blocking at run time, the same
|
|
271
|
+
* way a prompt hook with no model does. */
|
|
168
272
|
function isRunnableHook(hook: HookCommand): boolean {
|
|
273
|
+
if (hook.type === 'http') return typeof hook.url === 'string' && /^https?:\/\//.test(hook.url)
|
|
274
|
+
if (hook.type === 'prompt' || hook.type === 'agent') return typeof hook.prompt === 'string' && hook.prompt.length > 0
|
|
275
|
+
if (hook.type === 'mcp_tool') return typeof hook.server === 'string' && typeof hook.tool === 'string'
|
|
169
276
|
return typeof hook.command === 'string' && (hook.type === undefined || hook.type === 'command')
|
|
170
277
|
}
|
|
171
278
|
|
|
279
|
+
/** The synthetic identity of a non-shell hook entry: an http/prompt/agent/mcp_tool
|
|
280
|
+
* entry has no `command`, so its url / prompt / server:tool stands in. A shell hook
|
|
281
|
+
* (undefined or `command` type) already has one, so this is undefined. */
|
|
282
|
+
function syntheticCommand(hook: HookCommand): string | undefined {
|
|
283
|
+
if (hook.type === 'http') return hook.url
|
|
284
|
+
if (hook.type === 'prompt' || hook.type === 'agent') return hook.prompt
|
|
285
|
+
if (hook.type === 'mcp_tool') return `${hook.server}:${hook.tool}`
|
|
286
|
+
return undefined
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** A matched entry with its `command` filled in: mirroring the synthetic identity into
|
|
290
|
+
* `command` keeps dedup, timeout messages and display working for non-shell hooks. */
|
|
291
|
+
function withCommand(raw: HookCommand): HookCommand {
|
|
292
|
+
const identity = syntheticCommand(raw)
|
|
293
|
+
return identity !== undefined && typeof raw.command !== 'string' ? { ...raw, command: identity } : raw
|
|
294
|
+
}
|
|
295
|
+
|
|
172
296
|
/** Command specs whose matcher applies to any of the given tool/source names.
|
|
173
297
|
* Multiple candidates let one event offer both the pi name and its Claude alias. */
|
|
174
298
|
export function matchingCommands(matchers: HookMatcher[] | undefined, names: string | readonly string[]): HookCommand[] {
|
|
@@ -177,7 +301,8 @@ export function matchingCommands(matchers: HookMatcher[] | undefined, names: str
|
|
|
177
301
|
const seen = new Set<string>()
|
|
178
302
|
for (const entry of matchers ?? []) {
|
|
179
303
|
if (!matcherApplies(entry.matcher, candidates)) continue
|
|
180
|
-
for (const
|
|
304
|
+
for (const raw of (entry.hooks ?? []).filter(isRunnableHook)) {
|
|
305
|
+
const hook = withCommand(raw)
|
|
181
306
|
// Claude runs a handler defined in more than one settings file once.
|
|
182
307
|
if (seen.has(hook.command)) continue
|
|
183
308
|
seen.add(hook.command)
|
|
@@ -187,6 +312,43 @@ export function matchingCommands(matchers: HookMatcher[] | undefined, names: str
|
|
|
187
312
|
return result
|
|
188
313
|
}
|
|
189
314
|
|
|
315
|
+
/** A hook entry's display identity for the /hooks viewer: the command for shell
|
|
316
|
+
* hooks, otherwise the type-qualified url / prompt / server:tool. A missing field
|
|
317
|
+
* is named rather than hidden, since a misconfigured entry is exactly what the
|
|
318
|
+
* viewer exists to surface. */
|
|
319
|
+
function hookIdentity(hook: HookCommand | null | undefined): string {
|
|
320
|
+
// A hand-edited settings file can leave a null (or otherwise empty) entry in a
|
|
321
|
+
// hooks array; name it rather than let it crash the viewer that exists to surface
|
|
322
|
+
// exactly this kind of misconfiguration.
|
|
323
|
+
const record: Partial<HookCommand> = hook ?? {}
|
|
324
|
+
const type = record.type ?? 'command'
|
|
325
|
+
if (type === 'http') return `http: ${record.url ?? record.command ?? '(missing url)'}`
|
|
326
|
+
if (type === 'prompt' || type === 'agent') return `${type}: ${record.prompt ?? record.command ?? '(missing prompt)'}`
|
|
327
|
+
if (type === 'mcp_tool') return `mcp_tool: ${record.server ?? '(missing server)'}:${record.tool ?? '(missing tool)'}`
|
|
328
|
+
return `command: ${record.command ?? '(missing command)'}`
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Render the resolved hooks config as a readable per-event summary for /hooks:
|
|
332
|
+
* one line per configured hook with its matcher, identity and, when known, the
|
|
333
|
+
* settings file it came from. Pure formatting of already-resolved data. */
|
|
334
|
+
export function formatHooksSummary(config: HooksConfig, sources?: Map<HookMatcher, string>): string {
|
|
335
|
+
const lines: string[] = []
|
|
336
|
+
for (const [event, matchers] of Object.entries(config)) {
|
|
337
|
+
const entryLines: string[] = []
|
|
338
|
+
for (const entry of matchers) {
|
|
339
|
+
const matcher = entry.matcher || '*'
|
|
340
|
+
const source = sources?.get(entry)
|
|
341
|
+
const suffix = source ? ` (${source})` : ''
|
|
342
|
+
for (const hook of entry.hooks ?? []) {
|
|
343
|
+
entryLines.push(` [${matcher}] ${hookIdentity(hook)}${suffix}`)
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
if (entryLines.length > 0) lines.push(`${event}:`, ...entryLines)
|
|
347
|
+
}
|
|
348
|
+
if (lines.length === 0) return 'No hooks configured. Add a "hooks" section to ~/.claude/settings.json or .claude/settings.json.'
|
|
349
|
+
return lines.join('\n')
|
|
350
|
+
}
|
|
351
|
+
|
|
190
352
|
function tryParseJson(text: string): { hookSpecificOutput?: { permissionDecision?: string; permissionDecisionReason?: string; additionalContext?: string; updatedInput?: unknown }; decision?: string; reason?: string; continue?: boolean; stopReason?: string; systemMessage?: string } | undefined {
|
|
191
353
|
try {
|
|
192
354
|
return JSON.parse(text)
|
|
@@ -200,9 +362,11 @@ export function interpretHookResult(code: number, stdout: string, stderr: string
|
|
|
200
362
|
if (code === 2) return { block: true, reason: stderr.trim() || 'Blocked by hook' }
|
|
201
363
|
const parsed = tryParseJson(stdout)
|
|
202
364
|
const specific = parsed?.hookSpecificOutput
|
|
203
|
-
//
|
|
204
|
-
//
|
|
205
|
-
|
|
365
|
+
// Claude's "ask" prompts the user; the tool_call handler turns this into a
|
|
366
|
+
// ctx.ui.confirm and blocks only on decline. block:true is the fallback for a
|
|
367
|
+
// headless run with no dialog to show, which is the safe reading on a gated path.
|
|
368
|
+
if (specific?.permissionDecision === 'ask') return { block: true, ask: true, reason: specific.permissionDecisionReason ?? 'A hook asks you to confirm this tool call.' }
|
|
369
|
+
if (specific?.permissionDecision === 'deny') return { block: true, reason: specific.permissionDecisionReason ?? 'Blocked by hook' }
|
|
206
370
|
if (parsed?.decision === 'block') return { block: true, reason: parsed.reason ?? 'Blocked by hook' }
|
|
207
371
|
if (parsed?.continue === false) return { block: true, reason: parsed.stopReason ?? 'Blocked by hook' }
|
|
208
372
|
return { block: false }
|
|
@@ -231,7 +395,7 @@ function killTree(child: ChildProcess): void {
|
|
|
231
395
|
child.kill('SIGKILL')
|
|
232
396
|
}
|
|
233
397
|
|
|
234
|
-
export const runHookCommand:
|
|
398
|
+
export const runHookCommand: HookCommandRunner = (command, payload, timeoutMs, projectDir) =>
|
|
235
399
|
new Promise((resolve) => {
|
|
236
400
|
// Absolute path so the shell can't be resolved through an attacker-controlled PATH.
|
|
237
401
|
// `detached` makes the shell its own process group leader so the timeout can kill
|
|
@@ -276,6 +440,159 @@ export const runHookCommand: HookRunner = (command, payload, timeoutMs, projectD
|
|
|
276
440
|
child.stdin?.end(JSON.stringify(payload))
|
|
277
441
|
})
|
|
278
442
|
|
|
443
|
+
/** `$VAR` / `${VAR}` in header values, from allowlisted env vars only; a reference
|
|
444
|
+
* to an unlisted variable becomes an empty string, as Claude documents. */
|
|
445
|
+
function interpolateHeaders(headers: Record<string, string> | undefined, allowed: string[] | undefined): Record<string, string> {
|
|
446
|
+
const allowedSet = new Set(allowed ?? [])
|
|
447
|
+
const out: Record<string, string> = {}
|
|
448
|
+
for (const [key, value] of Object.entries(headers ?? {})) {
|
|
449
|
+
out[key] = value.replace(/\$(?:\{([A-Za-z_]\w*)\}|([A-Za-z_]\w*))/g, (_token, braced?: string, bare?: string) => {
|
|
450
|
+
const name = braced ?? bare ?? ''
|
|
451
|
+
return allowedSet.has(name) ? (process.env[name] ?? '') : ''
|
|
452
|
+
})
|
|
453
|
+
}
|
|
454
|
+
return out
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Claude's `type: "http"` hook: the payload POSTs as JSON and only a 2xx response
|
|
459
|
+
* with a valid JSON body renders a decision, read exactly like command stdout.
|
|
460
|
+
* Everything else, including non-2xx statuses, connection failures and timeouts,
|
|
461
|
+
* is a non-blocking error by contract, so none of these outcomes ever reports
|
|
462
|
+
* `timedOut`, which PreToolUse fails closed on. The user wrote the URL into their
|
|
463
|
+
* own settings, so it carries the same trust as a command hook's shell string and
|
|
464
|
+
* gets no SSRF screening.
|
|
465
|
+
*/
|
|
466
|
+
export async function runHttpHook(hook: { type?: string; command: string; url?: string; headers?: Record<string, string>; allowedEnvVars?: string[] }, payload: unknown, timeoutMs: number): Promise<HookRunResult> {
|
|
467
|
+
const url = hook.url ?? hook.command
|
|
468
|
+
try {
|
|
469
|
+
const response = await fetch(url, {
|
|
470
|
+
method: 'POST',
|
|
471
|
+
headers: { 'content-type': 'application/json', ...interpolateHeaders(hook.headers, hook.allowedEnvVars) },
|
|
472
|
+
body: JSON.stringify(payload),
|
|
473
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
474
|
+
})
|
|
475
|
+
const body = (await response.text()).slice(0, MAX_HOOK_OUTPUT)
|
|
476
|
+
if (!response.ok) return { code: 1, stdout: '', stderr: `HTTP ${response.status} from ${url}`, timedOut: false }
|
|
477
|
+
if (body.trim().length === 0) return { code: 0, stdout: '', stderr: '', timedOut: false }
|
|
478
|
+
try {
|
|
479
|
+
JSON.parse(body)
|
|
480
|
+
} catch {
|
|
481
|
+
return { code: 1, stdout: '', stderr: `non-JSON response from ${url}`, timedOut: false }
|
|
482
|
+
}
|
|
483
|
+
return { code: 0, stdout: body, stderr: '', timedOut: false }
|
|
484
|
+
} catch (error) {
|
|
485
|
+
return { code: 1, stdout: '', stderr: error instanceof Error ? error.message : String(error), timedOut: false }
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** System prompt turning a prompt hook into a structured decision, so its reply
|
|
490
|
+
* flows through interpretHookResult exactly like a command hook's stdout. */
|
|
491
|
+
const PROMPT_HOOK_SYSTEM = [
|
|
492
|
+
'You are a Claude Code hook evaluating whether an action should proceed.',
|
|
493
|
+
'Respond with ONLY a JSON object and nothing else:',
|
|
494
|
+
'{"hookSpecificOutput":{"permissionDecision":"allow"|"deny"|"ask","permissionDecisionReason":"<short reason>"}}',
|
|
495
|
+
'Use "allow" to let the action proceed, "deny" to block it, "ask" to require the user to confirm.',
|
|
496
|
+
].join('\n')
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Claude's `type: "prompt"` hook: the prompt (with `$ARGUMENTS` replaced by the
|
|
500
|
+
* event JSON) is evaluated by the model, which returns a JSON decision. pi runs it
|
|
501
|
+
* in-process via completeText and returns the reply as stdout so the existing
|
|
502
|
+
* decision parser handles it. No model (headless) or a provider error is
|
|
503
|
+
* non-blocking; only an abort at the timeout fails closed, like the other hooks.
|
|
504
|
+
*/
|
|
505
|
+
/** Replace `$ARGUMENTS` with the event JSON via a replacer function, so `$`-sequences
|
|
506
|
+
* in the payload (`$$`, `$&`, `` $` ``, `$'`) are inserted literally, not read as
|
|
507
|
+
* `String.replace` patterns. Prompt and agent hooks feed the result to the model. */
|
|
508
|
+
function substituteArguments(prompt: string | undefined, payload: unknown): string {
|
|
509
|
+
const json = JSON.stringify(payload)
|
|
510
|
+
return (prompt ?? '').replaceAll('$ARGUMENTS', () => json)
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/** Classify a model/agent failure: the deadline is authoritative via the signal (the
|
|
514
|
+
* subagent runner rejects with a plain Error on abort, so an error-name check alone
|
|
515
|
+
* fails open), so a fired signal is a timeout (PreToolUse fails closed); anything else
|
|
516
|
+
* produced no verdict and is non-blocking. */
|
|
517
|
+
function abortAwareFailure(signal: AbortSignal, error: unknown): HookRunResult {
|
|
518
|
+
const aborted = signal.aborted || (error instanceof Error && (error.name === 'AbortError' || error.name === 'TimeoutError'))
|
|
519
|
+
return { code: aborted ? TIMEOUT_EXIT_CODE : 1, stdout: '', stderr: error instanceof Error ? error.message : String(error), timedOut: aborted }
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
export async function runPromptHook(hook: HookCommand, payload: unknown, model: Model<Api> | undefined, timeoutMs: number): Promise<HookRunResult> {
|
|
523
|
+
if (!model) return { code: 1, stdout: '', stderr: 'no model available for prompt hook', timedOut: false }
|
|
524
|
+
// A replacer function, so `$$`/`$&`/`` $` ``/`$'` inside the payload JSON are inserted
|
|
525
|
+
// verbatim rather than read as replacement patterns (a Bash `echo $$` is a common trigger).
|
|
526
|
+
const prompt = substituteArguments(hook.prompt, payload)
|
|
527
|
+
const signal = AbortSignal.timeout(timeoutMs)
|
|
528
|
+
try {
|
|
529
|
+
const answer = await completeText(model, prompt, { system: PROMPT_HOOK_SYSTEM, maxTokens: 512, signal })
|
|
530
|
+
return { code: 0, stdout: answer, stderr: '', timedOut: false }
|
|
531
|
+
} catch (error) {
|
|
532
|
+
return abortAwareFailure(signal, error)
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Claude's `type: "mcp_tool"` hook: call a tool on an already-connected MCP server
|
|
538
|
+
* and treat its text output like command stdout. pi reaches the server through the
|
|
539
|
+
* mcp-call seam the mcp extension registers. Like http, it never fails closed: a
|
|
540
|
+
* missing server, a tool error, or the deadline is non-blocking.
|
|
541
|
+
*/
|
|
542
|
+
export async function runMcpToolHook(hook: HookCommand, payload: unknown, timeoutMs: number): Promise<HookRunResult> {
|
|
543
|
+
if (!hook.server || !hook.tool) return { code: 1, stdout: '', stderr: 'mcp_tool hook needs server and tool', timedOut: false }
|
|
544
|
+
const input = hook.input && typeof hook.input === 'object' ? hook.input : (payload as Record<string, unknown>)
|
|
545
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
546
|
+
const deadline = new Promise<HookRunResult>((resolve) => {
|
|
547
|
+
timer = setTimeout(() => resolve({ code: 1, stdout: '', stderr: `mcp_tool hook timed out after ${timeoutMs}ms`, timedOut: false }), timeoutMs)
|
|
548
|
+
})
|
|
549
|
+
const call = callMcpTool(hook.server, hook.tool, input)
|
|
550
|
+
.then((result): HookRunResult => ({ code: result.isError ? 1 : 0, stdout: result.text, stderr: '', timedOut: false }))
|
|
551
|
+
.catch((error): HookRunResult => ({ code: 1, stdout: '', stderr: error instanceof Error ? error.message : String(error), timedOut: false }))
|
|
552
|
+
try {
|
|
553
|
+
return await Promise.race([call, deadline])
|
|
554
|
+
} finally {
|
|
555
|
+
// Left running, the deadline timer pins the event loop for the full timeout
|
|
556
|
+
// after the call resolves, delaying exit in a one-shot headless run.
|
|
557
|
+
clearTimeout(timer)
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Claude's experimental `type: "agent"` hook: spawn a subagent (Read/Grep/Glob) to
|
|
563
|
+
* verify a condition, then return its final text as a JSON decision, parsed by the
|
|
564
|
+
* same interpreter as a command hook. pi reaches the subagent through the agent-run
|
|
565
|
+
* seam the subagent extension registers. Like the prompt hook, only an abort at the
|
|
566
|
+
* deadline fails closed; a missing runner or a crashed agent is non-blocking.
|
|
567
|
+
*/
|
|
568
|
+
export async function runAgentHook(hook: HookCommand, payload: unknown, timeoutMs: number, sessionModelId: string | undefined): Promise<HookRunResult> {
|
|
569
|
+
const prompt = substituteArguments(hook.prompt, payload)
|
|
570
|
+
const signal = AbortSignal.timeout(timeoutMs)
|
|
571
|
+
try {
|
|
572
|
+
const answer = await runAgent({ prompt, model: hook.model ?? sessionModelId, systemPrompt: hook.systemPrompt, signal })
|
|
573
|
+
return { code: 0, stdout: answer, stderr: '', timedOut: false }
|
|
574
|
+
} catch (error) {
|
|
575
|
+
return abortAwareFailure(signal, error)
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/** The text of the last assistant message in a turn, for Claude's Stop-hook
|
|
580
|
+
* `last_assistant_message`. Thinking and tool calls are dropped; a plain-string
|
|
581
|
+
* content is returned as-is. */
|
|
582
|
+
export function lastAssistantText(messages: ReadonlyArray<{ role: string; content: unknown }>): string {
|
|
583
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
584
|
+
const message = messages[i]
|
|
585
|
+
if (message.role !== 'assistant') continue
|
|
586
|
+
if (typeof message.content === 'string') return message.content
|
|
587
|
+
if (!Array.isArray(message.content)) return ''
|
|
588
|
+
return message.content
|
|
589
|
+
.filter((part): part is { type: 'text'; text: string } => typeof part === 'object' && part !== null && (part as { type?: unknown }).type === 'text')
|
|
590
|
+
.map((part) => part.text)
|
|
591
|
+
.join('')
|
|
592
|
+
}
|
|
593
|
+
return ''
|
|
594
|
+
}
|
|
595
|
+
|
|
279
596
|
/** Above 2^31-1 ms Node clamps a timer to 1ms, which would kill the hook instantly. */
|
|
280
597
|
const MAX_TIMEOUT_S = 2_147_483
|
|
281
598
|
|
|
@@ -319,7 +636,7 @@ export async function runPreToolUse(config: HooksConfig, toolName: string, toolI
|
|
|
319
636
|
const commands = matchingCommands(config.PreToolUse, names)
|
|
320
637
|
const results = await Promise.all(
|
|
321
638
|
commands.map((command) =>
|
|
322
|
-
runner(command
|
|
639
|
+
runner(command, { hook_event_name: 'PreToolUse', tool_name: claudeName ?? toolName, tool_input: toolInput }, timeoutMs(command)).then((result) => {
|
|
323
640
|
const updated = tryParseJson(result.stdout)?.hookSpecificOutput?.updatedInput
|
|
324
641
|
if (isRecord(updated) && isRecord(toolInput)) replaceRecord(toolInput, updated)
|
|
325
642
|
return result
|
|
@@ -333,15 +650,19 @@ export async function runPreToolUse(config: HooksConfig, toolName: string, toolI
|
|
|
333
650
|
if (result.timedOut) return { block: true, reason: `Hook timed out after ${timeoutMs(commands[i])}ms: ${commands[i].command}` }
|
|
334
651
|
}
|
|
335
652
|
if (onSystemMessage) surfaceSystemMessages(results, onSystemMessage)
|
|
653
|
+
// A hard deny wins over an ask, matching Claude's deny > ask > allow precedence:
|
|
654
|
+
// scan for any deny first, and only fall back to the first ask.
|
|
655
|
+
let ask: HookDecision | undefined
|
|
336
656
|
for (const result of results) {
|
|
337
657
|
const decision = interpretHookResult(result.code, result.stdout, result.stderr)
|
|
338
|
-
if (decision.block) return decision
|
|
658
|
+
if (decision.block && !decision.ask) return decision
|
|
659
|
+
if (decision.ask && ask === undefined) ask = decision
|
|
339
660
|
}
|
|
340
|
-
return { block: false }
|
|
661
|
+
return ask ?? { block: false }
|
|
341
662
|
}
|
|
342
663
|
|
|
343
664
|
async function runNotifyHooks(commands: HookCommand[], payload: unknown, runner: HookRunner): Promise<HookRunResult[]> {
|
|
344
|
-
return await Promise.all(commands.map((command) => runner(command
|
|
665
|
+
return await Promise.all(commands.map((command) => runner(command, payload, timeoutMs(command))))
|
|
345
666
|
}
|
|
346
667
|
|
|
347
668
|
type SystemMessageSink = (message: string) => void
|
|
@@ -373,7 +694,7 @@ function promptContext(stdout: string): string {
|
|
|
373
694
|
* in config order for injection ahead of the prompt. */
|
|
374
695
|
export async function runUserPromptSubmit(config: HooksConfig, prompt: string, runner: HookRunner, onSystemMessage?: SystemMessageSink): Promise<PromptDecision> {
|
|
375
696
|
const commands = matchingCommands(config.UserPromptSubmit, 'UserPromptSubmit')
|
|
376
|
-
const results = await Promise.all(commands.map((command) => runner(command
|
|
697
|
+
const results = await Promise.all(commands.map((command) => runner(command, { hook_event_name: 'UserPromptSubmit', prompt }, timeoutMs(command))))
|
|
377
698
|
surfaceHookFailures(commands, results, onSystemMessage)
|
|
378
699
|
for (const [i, result] of results.entries()) {
|
|
379
700
|
if (result.timedOut) return { block: true, reason: `Hook timed out after ${timeoutMs(commands[i])}ms: ${commands[i].command}`, context: '' }
|
|
@@ -402,12 +723,32 @@ function claudeSpelling(map: Record<string, string>, raw: string): { names: stri
|
|
|
402
723
|
return { names: value === raw ? [raw] : [raw, value], value }
|
|
403
724
|
}
|
|
404
725
|
|
|
726
|
+
/** The feedback lines one PostToolUse/PostToolUseFailure result appends next to the
|
|
727
|
+
* tool result: a block notice (exit-2 stderr, or decision:block on success) followed
|
|
728
|
+
* by any additionalContext. A failed tool cannot be blocked, so its stderr is shown
|
|
729
|
+
* but never a decision:block verdict. */
|
|
730
|
+
function postToolFeedback(result: HookRunResult, eventName: string, isError: boolean): string[] {
|
|
731
|
+
const lines: string[] = []
|
|
732
|
+
const parsed = tryParseJson(result.stdout)
|
|
733
|
+
// A failed tool cannot be blocked, but the hook's stderr is still shown; on
|
|
734
|
+
// success, exit-2 / decision:block feed back as a block notice.
|
|
735
|
+
if (!result.timedOut && result.code === 2) lines.push(`${eventName} hook: ${result.stderr.trim() || (isError ? 'hook reported an error' : 'Blocked by hook')}`)
|
|
736
|
+
else if (!isError && parsed?.decision === 'block') lines.push(`PostToolUse hook: ${parsed.reason ?? 'Blocked by hook'}`)
|
|
737
|
+
const context = parsed?.hookSpecificOutput?.additionalContext
|
|
738
|
+
if (context) lines.push(context)
|
|
739
|
+
return lines
|
|
740
|
+
}
|
|
741
|
+
|
|
405
742
|
export default function hooksExtension(pi: ExtensionAPI) {
|
|
406
743
|
let config: HooksConfig = {}
|
|
407
744
|
let projectDir = ''
|
|
408
745
|
let pendingSessionContext: string[] = []
|
|
409
746
|
let stopHookActive = false
|
|
410
747
|
let sessionCtx: ExtensionContext | undefined
|
|
748
|
+
/** Claude's disableAllHooks escape hatch was set somewhere in the honored chain. */
|
|
749
|
+
let hooksDisabled = false
|
|
750
|
+
/** Which settings file each resolved entry came from, for the /hooks viewer. */
|
|
751
|
+
const hookSources = new Map<HookMatcher, string>()
|
|
411
752
|
/** Claude sends session_id, transcript_path, cwd and effort on every payload. */
|
|
412
753
|
const commonPayload = (ctx: ExtensionContext): Record<string, unknown> => {
|
|
413
754
|
const common: Record<string, unknown> = { session_id: ctx.sessionManager.getSessionId(), cwd: ctx.cwd, permission_mode: permissionMode }
|
|
@@ -416,11 +757,18 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
416
757
|
if (ctx.thinkingLevel) common.effort = { level: ctx.thinkingLevel }
|
|
417
758
|
return common
|
|
418
759
|
}
|
|
419
|
-
/** A runner bound to the firing context, filling the common fields into each
|
|
760
|
+
/** A runner bound to the firing context, filling the common fields into each
|
|
761
|
+
* payload and dispatching on the entry's type. */
|
|
420
762
|
const boundRunner =
|
|
421
763
|
(ctx: ExtensionContext, extra?: Record<string, unknown>): HookRunner =>
|
|
422
|
-
(
|
|
423
|
-
|
|
764
|
+
(hook, payload, ms) => {
|
|
765
|
+
const merged = { ...commonPayload(ctx), ...extra, ...(payload as Record<string, unknown>) }
|
|
766
|
+
if (hook.type === 'http') return runHttpHook(hook, merged, ms)
|
|
767
|
+
if (hook.type === 'prompt') return runPromptHook(hook, merged, ctx.model, ms)
|
|
768
|
+
if (hook.type === 'agent') return runAgentHook(hook, merged, ms, (ctx.model as { id?: string } | undefined)?.id)
|
|
769
|
+
if (hook.type === 'mcp_tool') return runMcpToolHook(hook, merged, ms)
|
|
770
|
+
return runHookCommand(hook.command, merged, ms, projectDir)
|
|
771
|
+
}
|
|
424
772
|
// Claude matchers name MCP tools mcp__<server>__<tool>; pi-code registers them as
|
|
425
773
|
// <server>_<tool>. The mcp extension publishes the mapping on pi's shared bus.
|
|
426
774
|
const mcpAliases = new Map<string, string>()
|
|
@@ -435,6 +783,30 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
435
783
|
pi.events.on(PLAN_MODE_CHANNEL, (data) => {
|
|
436
784
|
if (isPlanModeState(data)) permissionMode = data.active ? 'plan' : 'default'
|
|
437
785
|
})
|
|
786
|
+
// Claude's InstructionsLoaded hook has NO decision control: exit codes are
|
|
787
|
+
// ignored and every JSON output field (systemMessage included) is discarded, so
|
|
788
|
+
// dispatch is fire-and-forget on all paths. Two documented load reasons can
|
|
789
|
+
// never fire honestly and are deliberate gaps, not approximations:
|
|
790
|
+
// `nested_traversal` (pi does not lazily load a nested CLAUDE.md on subdirectory
|
|
791
|
+
// entry) and `compact` (pi does not re-load instruction files after compaction).
|
|
792
|
+
const fireInstructionsLoaded = (payload: Record<string, unknown>): void => {
|
|
793
|
+
if (!sessionCtx) return
|
|
794
|
+
const commands = matchingCommands(config.InstructionsLoaded, String(payload.load_reason))
|
|
795
|
+
if (commands.length === 0) return
|
|
796
|
+
void runNotifyHooks(commands, { hook_event_name: 'InstructionsLoaded', ...payload }, boundRunner(sessionCtx)).catch(() => {})
|
|
797
|
+
}
|
|
798
|
+
// Every load rides the shared bus: context-imports publishes session_start for
|
|
799
|
+
// the context files that survived claudeMdExcludes and include for resolved
|
|
800
|
+
// @imports (deduped there, once per file per session); claude-rules publishes
|
|
801
|
+
// path_glob_match when a scoped rule attaches. Consuming the bus rather than
|
|
802
|
+
// iterating raw contextFiles keeps this extension from announcing a file the
|
|
803
|
+
// exclusion removed from the prompt; bus emit is synchronous, so the events
|
|
804
|
+
// arrive regardless of extension load order.
|
|
805
|
+
pi.events.on(INSTRUCTIONS_CHANNEL, (data) => {
|
|
806
|
+
if (!isInstructionLoadEvent(data)) return
|
|
807
|
+
fireInstructionsLoaded({ ...data })
|
|
808
|
+
})
|
|
809
|
+
|
|
438
810
|
// Subagent lifecycle arrives over the bus without a pi context; the session context
|
|
439
811
|
// captured at session_start supplies the common payload fields.
|
|
440
812
|
pi.events.on(SUBAGENT_CHANNEL, async (data) => {
|
|
@@ -449,8 +821,24 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
449
821
|
pi.on('session_start', async (event, ctx) => {
|
|
450
822
|
sessionCtx = ctx
|
|
451
823
|
const trusted = await isProjectApproved(ctx)
|
|
452
|
-
|
|
453
|
-
|
|
824
|
+
// Claude's CLAUDE_PROJECT_DIR is the project root, not the session cwd; a hook
|
|
825
|
+
// referencing $CLAUDE_PROJECT_DIR/.claude/hooks/helper.sh must resolve from a
|
|
826
|
+
// subdirectory session too.
|
|
827
|
+
projectDir = repoRoot(ctx.cwd) ?? ctx.cwd
|
|
828
|
+
const files = hookFiles(ctx.cwd, os.homedir(), trusted)
|
|
829
|
+
hookSources.clear()
|
|
830
|
+
// The disableAllHooks escape hatch, checked before any config loads: with no
|
|
831
|
+
// config resolved, no event, plugin hooks included, can fire a hook.
|
|
832
|
+
hooksDisabled = readDisableAllHooks(files)
|
|
833
|
+
if (hooksDisabled) {
|
|
834
|
+
config = {}
|
|
835
|
+
pendingSessionContext = []
|
|
836
|
+
return
|
|
837
|
+
}
|
|
838
|
+
config = loadHooks(files, hookSources)
|
|
839
|
+
// Plugins are user-installed and enabled by user settings (see installedPlugins),
|
|
840
|
+
// so a checked-out repo cannot toggle which code-bearing plugin hooks run.
|
|
841
|
+
loadPluginHooks(config, installedPlugins(os.homedir()), hookSources)
|
|
454
842
|
// "reload" re-fires in-process with the same conversation and would double-run hooks;
|
|
455
843
|
// a fork is a genuine session begin, which Claude reports as source "fork".
|
|
456
844
|
if (event.reason === 'reload') return
|
|
@@ -458,14 +846,16 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
458
846
|
const commands = matchingCommands(config.SessionStart, source.names)
|
|
459
847
|
const payload = { hook_event_name: 'SessionStart', source: source.value }
|
|
460
848
|
const run = boundRunner(ctx)
|
|
461
|
-
const results = await Promise.all(commands.map((command) => run(command
|
|
849
|
+
const results = await Promise.all(commands.map((command) => run(command, payload, timeoutMs(command))))
|
|
462
850
|
surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
|
|
463
851
|
pendingSessionContext = results.map((result) => promptContext(result.stdout)).filter(Boolean)
|
|
464
852
|
})
|
|
465
853
|
|
|
466
854
|
// Claude adds a SessionStart hook's additionalContext (or plain stdout) to the
|
|
467
855
|
// conversation before the first prompt; pi's seam for that is a message injected
|
|
468
|
-
// on the next agent start.
|
|
856
|
+
// on the next agent start. The session_start InstructionsLoaded events arrive
|
|
857
|
+
// over the bus from context-imports, which owns claudeMdExcludes; announcing
|
|
858
|
+
// the raw contextFiles here would fire for a file the exclusion removed.
|
|
469
859
|
pi.on('before_agent_start', async () => {
|
|
470
860
|
if (pendingSessionContext.length === 0) return
|
|
471
861
|
const content = pendingSessionContext.join('\n')
|
|
@@ -476,47 +866,32 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
476
866
|
pi.on('tool_call', async (event, ctx) => {
|
|
477
867
|
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'))
|
|
478
868
|
if (!decision.block) return undefined
|
|
869
|
+
// Claude's "ask": prompt the user and let the call through if they approve.
|
|
870
|
+
// With no UI (headless) the block stands, which is the safe default.
|
|
871
|
+
if (decision.ask && ctx.hasUI) {
|
|
872
|
+
const approved = await ctx.ui.confirm(`Allow ${event.toolName}?`, decision.reason ?? 'A hook asks you to confirm this tool call.')
|
|
873
|
+
return approved ? undefined : { block: true, reason: decision.reason }
|
|
874
|
+
}
|
|
479
875
|
return { block: true, reason: decision.reason }
|
|
480
876
|
})
|
|
481
877
|
|
|
482
|
-
// Claude's PostToolUse
|
|
483
|
-
// a decision:block reason (or exit-2
|
|
484
|
-
//
|
|
485
|
-
//
|
|
878
|
+
// Claude's PostToolUse (success) and PostToolUseFailure (error) both feed their
|
|
879
|
+
// hook's output back next to the tool result: a decision:block reason (or exit-2
|
|
880
|
+
// stderr) and additionalContext are appended, which is where Claude documents they
|
|
881
|
+
// land. The failure branch shows the hook's stderr to the model too ("Shows stderr
|
|
882
|
+
// to Claude; the tool already failed"), it just cannot block a call that failed.
|
|
486
883
|
pi.on('tool_result', async (event, ctx) => {
|
|
487
884
|
const alias = mcpAliases.get(event.toolName)
|
|
488
885
|
const names = alias ? [event.toolName, alias] : [event.toolName]
|
|
489
886
|
const response = { content: event.content, details: event.details, isError: event.isError }
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
if (event.isError) {
|
|
493
|
-
const failCommands = matchingCommands(config.PostToolUseFailure, names)
|
|
494
|
-
if (failCommands.length === 0) return
|
|
495
|
-
const run = boundRunner(ctx, { tool_use_id: event.toolCallId })
|
|
496
|
-
const failPayload = { hook_event_name: 'PostToolUseFailure', tool_name: alias ?? event.toolName, tool_input: event.input, tool_response: response }
|
|
497
|
-
const failResults = await Promise.all(failCommands.map((command) => run(command.command, failPayload, timeoutMs(command))))
|
|
498
|
-
surfaceSystemMessages(failResults, (message) => ctx.ui.notify(message, 'warning'))
|
|
499
|
-
return
|
|
500
|
-
}
|
|
501
|
-
const commands = matchingCommands(config.PostToolUse, names)
|
|
887
|
+
const eventName = event.isError ? 'PostToolUseFailure' : 'PostToolUse'
|
|
888
|
+
const commands = matchingCommands(event.isError ? config.PostToolUseFailure : config.PostToolUse, names)
|
|
502
889
|
if (commands.length === 0) return
|
|
503
|
-
const payload = {
|
|
504
|
-
hook_event_name: 'PostToolUse',
|
|
505
|
-
tool_name: alias ?? event.toolName,
|
|
506
|
-
tool_input: event.input,
|
|
507
|
-
tool_response: response,
|
|
508
|
-
}
|
|
890
|
+
const payload = { hook_event_name: eventName, tool_name: alias ?? event.toolName, tool_input: event.input, tool_response: response }
|
|
509
891
|
const run = boundRunner(ctx, { tool_use_id: event.toolCallId })
|
|
510
|
-
const results = await Promise.all(commands.map((command) => run(command
|
|
892
|
+
const results = await Promise.all(commands.map((command) => run(command, payload, timeoutMs(command))))
|
|
511
893
|
surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
|
|
512
|
-
const feedback
|
|
513
|
-
for (const result of results) {
|
|
514
|
-
const parsed = tryParseJson(result.stdout)
|
|
515
|
-
if (!result.timedOut && result.code === 2) feedback.push(`PostToolUse hook: ${result.stderr.trim() || 'Blocked by hook'}`)
|
|
516
|
-
else if (parsed?.decision === 'block') feedback.push(`PostToolUse hook: ${parsed.reason ?? 'Blocked by hook'}`)
|
|
517
|
-
const context = parsed?.hookSpecificOutput?.additionalContext
|
|
518
|
-
if (context) feedback.push(context)
|
|
519
|
-
}
|
|
894
|
+
const feedback = results.flatMap((result) => postToolFeedback(result, eventName, event.isError))
|
|
520
895
|
if (feedback.length === 0) return
|
|
521
896
|
return { content: [...event.content, ...feedback.map((text) => ({ type: 'text' as const, text }))] }
|
|
522
897
|
})
|
|
@@ -541,15 +916,26 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
541
916
|
// turn, and stop_hook_active in the payload tells the next firing it is already
|
|
542
917
|
// continuing from a stop hook, which is the hook script's documented loop guard.
|
|
543
918
|
// Only exit 2 and decision:"block" continue; continue:false means "stay stopped".
|
|
544
|
-
pi.on('agent_end', async (
|
|
919
|
+
pi.on('agent_end', async (event, ctx) => {
|
|
920
|
+
// Claude's Notification event, for the one type pi can honestly source: the
|
|
921
|
+
// agent finished and is waiting for input (idle_prompt). Observational only;
|
|
922
|
+
// exit codes and JSON output are ignored, as Claude documents for this event.
|
|
923
|
+
const notifyCommands = matchingCommands(config.Notification, ['idle_prompt'])
|
|
924
|
+
if (notifyCommands.length > 0) {
|
|
925
|
+
void runNotifyHooks(notifyCommands, { hook_event_name: 'Notification', notification_type: 'idle_prompt', message: 'pi is waiting for your input' }, boundRunner(ctx)).catch(() => {})
|
|
926
|
+
}
|
|
927
|
+
|
|
545
928
|
const commands = matchingCommands(config.Stop, 'Stop')
|
|
546
929
|
if (commands.length === 0) {
|
|
547
930
|
stopHookActive = false
|
|
548
931
|
return
|
|
549
932
|
}
|
|
550
|
-
|
|
933
|
+
// Claude's Stop payload carries the turn's final assistant text so a hook need
|
|
934
|
+
// not re-read the transcript; included only when there is one.
|
|
935
|
+
const lastText = lastAssistantText((event as { messages?: Array<{ role: string; content: unknown }> }).messages ?? [])
|
|
936
|
+
const payload = { hook_event_name: 'Stop', stop_hook_active: stopHookActive, ...(lastText ? { last_assistant_message: lastText } : {}) }
|
|
551
937
|
const run = boundRunner(ctx)
|
|
552
|
-
const results = await Promise.all(commands.map((command) => run(command
|
|
938
|
+
const results = await Promise.all(commands.map((command) => run(command, payload, timeoutMs(command))))
|
|
553
939
|
surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
|
|
554
940
|
const block = results
|
|
555
941
|
.filter((result) => !result.timedOut)
|
|
@@ -581,4 +967,18 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
581
967
|
const results = await runNotifyHooks(matchingCommands(config.SessionEnd, reason.names), { hook_event_name: 'SessionEnd', reason: reason.value }, boundRunner(ctx))
|
|
582
968
|
surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
|
|
583
969
|
})
|
|
970
|
+
|
|
971
|
+
// Claude's /hooks manages hook configuration; pi-code's is a viewer: hook failures
|
|
972
|
+
// are otherwise opaque, so showing the resolved chain per event, with the settings
|
|
973
|
+
// file each entry came from, is the debugging surface.
|
|
974
|
+
pi.registerCommand('hooks', {
|
|
975
|
+
description: 'Show the hook configuration resolved from settings',
|
|
976
|
+
handler: async (_args, ctx) => {
|
|
977
|
+
if (hooksDisabled) {
|
|
978
|
+
ctx.ui.notify('All hooks are disabled by the disableAllHooks setting.', 'info')
|
|
979
|
+
return
|
|
980
|
+
}
|
|
981
|
+
ctx.ui.notify(formatHooksSummary(config, hookSources), 'info')
|
|
982
|
+
},
|
|
983
|
+
})
|
|
584
984
|
}
|