pi-code 1.0.29 → 1.0.31
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.
|
@@ -44,6 +44,11 @@ export interface HookCommand {
|
|
|
44
44
|
/** Dedup scope: unset for settings files (identical handlers collapse across
|
|
45
45
|
* them); a plugin's or skill's copy carries its origin and stays separate. */
|
|
46
46
|
origin?: string
|
|
47
|
+
/** Claude's `once`: remove after the first successful run. Honored only for
|
|
48
|
+
* skill-frontmatter hooks; ignored in settings files and agent frontmatter. */
|
|
49
|
+
once?: boolean
|
|
50
|
+
/** Set after a once-hook's first successful run; collection skips spent hooks. */
|
|
51
|
+
spent?: boolean
|
|
47
52
|
}
|
|
48
53
|
export interface HookMatcher {
|
|
49
54
|
matcher?: string
|
|
@@ -76,7 +81,13 @@ export function hookFiles(cwd: string, home: string, trusted: boolean): string[]
|
|
|
76
81
|
* disabled in their own settings would defeat the escape hatch. The chain itself
|
|
77
82
|
* already gates project files on trust (see hookFiles). */
|
|
78
83
|
export function readDisableAllHooks(files: string[], managed: Record<string, unknown> = readManagedSettings()): boolean {
|
|
79
|
-
|
|
84
|
+
return managed.disableAllHooks === true || readSettingsDisableAllHooks(files)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The settings-chain half of disableAllHooks alone. Claude: user/project/local
|
|
88
|
+
* disableAllHooks cannot disable hooks configured through managed policy settings,
|
|
89
|
+
* so the caller keeps managed hooks running when only this half is set. */
|
|
90
|
+
export function readSettingsDisableAllHooks(files: string[]): boolean {
|
|
80
91
|
for (const file of files) {
|
|
81
92
|
try {
|
|
82
93
|
const parsed: unknown = JSON.parse(fs.readFileSync(file, 'utf-8'))
|
|
@@ -88,6 +99,22 @@ export function readDisableAllHooks(files: string[], managed: Record<string, unk
|
|
|
88
99
|
return false
|
|
89
100
|
}
|
|
90
101
|
|
|
102
|
+
/** Hooks from managed policy settings, one of Claude's hook locations. They run
|
|
103
|
+
* even when user/project/local disableAllHooks is set; only the managed level's
|
|
104
|
+
* own disableAllHooks turns them off (the caller checks that tier). */
|
|
105
|
+
export function loadManagedHooks(sources?: Map<HookMatcher, string>, managed: Record<string, unknown> = readManagedSettings()): HooksConfig {
|
|
106
|
+
const config: HooksConfig = {}
|
|
107
|
+
if (isRecord(managed.hooks)) mergeHooksJson(config, JSON.stringify({ hooks: managed.hooks }), 'managed settings', sources)
|
|
108
|
+
return config
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Hooks a skill's frontmatter declares, registered when the skill is invoked and
|
|
112
|
+
* kept for the rest of the session, as Claude documents. They carry a skill origin
|
|
113
|
+
* so dedup keeps them separate from settings copies and `once` is honored. */
|
|
114
|
+
export function mergeSkillHooks(config: HooksConfig, skillName: string, hooks: unknown, sources?: Map<HookMatcher, string>): void {
|
|
115
|
+
mergeHooksJson(config, JSON.stringify({ hooks }), `${skillName} (skill)`, sources, `skill:${skillName}`)
|
|
116
|
+
}
|
|
117
|
+
|
|
91
118
|
/** Claude's `allowedHttpHookUrls` setting: URL patterns http hooks may target, with
|
|
92
119
|
* `*` as a wildcard. Per Claude's documentation: undefined (no source sets the key)
|
|
93
120
|
* means no restrictions, an empty array blocks every http hook, and arrays merge
|
|
@@ -19,9 +19,14 @@ export interface HookDecision {
|
|
|
19
19
|
ask?: boolean
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
/** Claude's stdout shape rule: only output that starts with `{` and ends with `}`
|
|
23
|
+
* (ignoring surrounding whitespace) is read as JSON output; a JSON array, a quoted
|
|
24
|
+
* string, or a bare number is plain text. Multi-line output whose lines each parse
|
|
25
|
+
* as JSON on their own with no output field set is plain text too (that case
|
|
26
|
+
* arrives here as a parse failure and hookJsonError sorts it from a real error). */
|
|
22
27
|
export function tryParseJson(text: string):
|
|
23
28
|
| {
|
|
24
|
-
hookSpecificOutput?: { permissionDecision?: string; permissionDecisionReason?: string; additionalContext?: string; updatedInput?: unknown }
|
|
29
|
+
hookSpecificOutput?: { permissionDecision?: string; permissionDecisionReason?: string; additionalContext?: string; updatedInput?: unknown; suppressOriginalPrompt?: boolean }
|
|
25
30
|
decision?: string
|
|
26
31
|
reason?: string
|
|
27
32
|
continue?: boolean
|
|
@@ -32,13 +37,58 @@ export function tryParseJson(text: string):
|
|
|
32
37
|
ok?: boolean
|
|
33
38
|
}
|
|
34
39
|
| undefined {
|
|
40
|
+
const trimmed = text.trim()
|
|
41
|
+
if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) return undefined
|
|
35
42
|
try {
|
|
36
|
-
|
|
43
|
+
const parsed: unknown = JSON.parse(trimmed)
|
|
44
|
+
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? (parsed as ReturnType<typeof tryParseJson>) : undefined
|
|
37
45
|
} catch {
|
|
38
46
|
return undefined
|
|
39
47
|
}
|
|
40
48
|
}
|
|
41
49
|
|
|
50
|
+
/** Top-level JSON output fields; a multi-line output where a line sets one of
|
|
51
|
+
* these is a parse failure rather than plain text, as Claude documents. */
|
|
52
|
+
const OUTPUT_FIELDS = new Set(['decision', 'reason', 'continue', 'stopReason', 'systemMessage', 'suppressOutput', 'hookSpecificOutput', 'updatedToolOutput', 'updatedMCPToolOutput', 'ok'])
|
|
53
|
+
|
|
54
|
+
/** Claude reports a `<hook> hook error` notice when {..}-shaped stdout cannot be
|
|
55
|
+
* read as JSON output, and does not treat that stdout as plain text. Returns the
|
|
56
|
+
* error message for that case; undefined for valid JSON output and for output the
|
|
57
|
+
* shape rule already reads as plain text (including the multi-line case). */
|
|
58
|
+
export function hookJsonError(text: string): string | undefined {
|
|
59
|
+
const trimmed = text.trim()
|
|
60
|
+
if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) return undefined
|
|
61
|
+
let parseError: string
|
|
62
|
+
try {
|
|
63
|
+
JSON.parse(trimmed)
|
|
64
|
+
return undefined
|
|
65
|
+
} catch (error) {
|
|
66
|
+
parseError = error instanceof Error ? error.message : String(error)
|
|
67
|
+
}
|
|
68
|
+
const lines = trimmed
|
|
69
|
+
.split('\n')
|
|
70
|
+
.map((line) => line.trim())
|
|
71
|
+
.filter((line) => line !== '')
|
|
72
|
+
if (lines.length >= 2 && !multiLineSetsOutputField(lines)) return undefined
|
|
73
|
+
return `invalid JSON output: ${parseError}`
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Whether every line parses as JSON and at least one sets an output field; a
|
|
77
|
+
* non-JSON line means the multi-line rule does not apply (still a parse error). */
|
|
78
|
+
function multiLineSetsOutputField(lines: string[]): boolean {
|
|
79
|
+
let setsField = false
|
|
80
|
+
for (const line of lines) {
|
|
81
|
+
let parsed: unknown
|
|
82
|
+
try {
|
|
83
|
+
parsed = JSON.parse(line)
|
|
84
|
+
} catch {
|
|
85
|
+
return true // not all-JSON: the whole output is a parse failure
|
|
86
|
+
}
|
|
87
|
+
if (parsed !== null && typeof parsed === 'object' && Object.keys(parsed).some((key) => OUTPUT_FIELDS.has(key))) setsField = true
|
|
88
|
+
}
|
|
89
|
+
return setsField
|
|
90
|
+
}
|
|
91
|
+
|
|
42
92
|
/** The reason a JSON body's blocking decision carries, whichever spelling made it. */
|
|
43
93
|
function jsonBlockingReason(parsed: ReturnType<typeof tryParseJson>): string | undefined {
|
|
44
94
|
if (parsed?.hookSpecificOutput?.permissionDecision === 'deny') return parsed.hookSpecificOutput.permissionDecisionReason
|
|
@@ -94,6 +144,10 @@ function surfaceHookFailures(commands: HookCommand[], results: HookRunResult[],
|
|
|
94
144
|
if (!notify) return
|
|
95
145
|
for (const [i, result] of results.entries()) {
|
|
96
146
|
if (result.spawnFailed) notify(`Hook failed to run: ${commands[i].command}: ${result.stderr.trim() || 'unknown error'}`)
|
|
147
|
+
// Claude shows a `<hook> hook error` notice when {..}-shaped stdout cannot be
|
|
148
|
+
// read as JSON output (exit 2 still blocks and reads its own channels).
|
|
149
|
+
const jsonError = result.code === 2 ? undefined : hookJsonError(result.stdout)
|
|
150
|
+
if (jsonError !== undefined) notify(`${commands[i].command} hook error: ${jsonError}`)
|
|
97
151
|
}
|
|
98
152
|
}
|
|
99
153
|
|
|
@@ -196,6 +250,8 @@ export interface PromptDecision {
|
|
|
196
250
|
block: boolean
|
|
197
251
|
reason?: string
|
|
198
252
|
context: string
|
|
253
|
+
/** Claude's suppressOriginalPrompt: the hook's context replaces the prompt. */
|
|
254
|
+
suppress?: boolean
|
|
199
255
|
}
|
|
200
256
|
|
|
201
257
|
/** Additional context a UserPromptSubmit hook contributes: an explicit
|
|
@@ -203,6 +259,8 @@ export interface PromptDecision {
|
|
|
203
259
|
export function promptContext(stdout: string): string {
|
|
204
260
|
const parsed = tryParseJson(stdout)
|
|
205
261
|
if (parsed) return parsed.hookSpecificOutput?.additionalContext ?? ''
|
|
262
|
+
// Malformed JSON output is an error, not plain text: no context is added.
|
|
263
|
+
if (hookJsonError(stdout) !== undefined) return ''
|
|
206
264
|
return stdout.trim()
|
|
207
265
|
}
|
|
208
266
|
|
|
@@ -221,13 +279,17 @@ export async function runUserPromptSubmit(config: HooksConfig, prompt: string, r
|
|
|
221
279
|
}
|
|
222
280
|
if (onSystemMessage) surfaceSystemMessages(results, onSystemMessage)
|
|
223
281
|
const contexts: string[] = []
|
|
282
|
+
let suppress = false
|
|
224
283
|
for (const result of results) {
|
|
225
284
|
const decision = interpretHookResult(result.code, result.stdout, result.stderr)
|
|
226
285
|
if (decision.block) return { block: true, reason: decision.reason, context: '' }
|
|
286
|
+
// Claude's suppressOriginalPrompt: any hook setting it hides the original
|
|
287
|
+
// prompt, and the collected context is what reaches the model.
|
|
288
|
+
if (tryParseJson(result.stdout)?.hookSpecificOutput?.suppressOriginalPrompt === true) suppress = true
|
|
227
289
|
const context = promptContext(result.stdout)
|
|
228
290
|
if (context) contexts.push(context)
|
|
229
291
|
}
|
|
230
|
-
return { block: false, context: contexts.join('\n') }
|
|
292
|
+
return { block: false, context: contexts.join('\n'), suppress }
|
|
231
293
|
}
|
|
232
294
|
|
|
233
295
|
/** The feedback lines one PostToolUse/PostToolUseFailure result appends next to the
|
|
@@ -73,12 +73,13 @@
|
|
|
73
73
|
* `{"hookSpecificOutput": {"permissionDecision": "deny", ...}}` (or the older
|
|
74
74
|
* `{"decision": "block"}`).
|
|
75
75
|
*
|
|
76
|
-
* Config is merged from ~/.claude/settings.json (always)
|
|
77
|
-
* .claude/settings.json and settings.local.json (only when the
|
|
78
|
-
* trusted, since hooks execute arbitrary shell)
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
76
|
+
* Config is merged from managed policy settings, ~/.claude/settings.json (always),
|
|
77
|
+
* the project's .claude/settings.json and settings.local.json (only when the
|
|
78
|
+
* project is trusted, since hooks execute arbitrary shell), plugins, and invoked
|
|
79
|
+
* skills' frontmatter. Claude's `disableAllHooks` is tiered: at the managed level
|
|
80
|
+
* it turns everything off; in any honored settings file it disables the
|
|
81
|
+
* non-managed hooks while managed policy hooks keep running. /hooks prints the
|
|
82
|
+
* resolved chain per event with each entry's source. Matchers follow Claude's rule:
|
|
82
83
|
* `*`/empty match all, plain names are exact (with `|`/`,` list separators), and
|
|
83
84
|
* anything with other regex characters is an unanchored regex. Claude matchers
|
|
84
85
|
* are PascalCase (`Bash`); pi tool names are lowercase (`bash`), so comparison
|
|
@@ -90,14 +91,16 @@
|
|
|
90
91
|
import * as os from 'node:os'
|
|
91
92
|
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
|
|
92
93
|
import { INSTRUCTIONS_CHANNEL, isInstructionLoadEvent } from '../internal/instruction-events.js'
|
|
94
|
+
import { readManagedSettings } from '../internal/managed-settings.js'
|
|
93
95
|
import { isMcpToolAliases, MCP_TOOLS_CHANNEL } from '../internal/mcp-alias.js'
|
|
94
96
|
import { isPlanModeState, PLAN_MODE_CHANNEL } from '../internal/plan-mode-state.js'
|
|
95
97
|
import { installedPlugins } from '../internal/plugins.js'
|
|
96
98
|
import { isProjectApproved } from '../internal/project-approval.js'
|
|
97
99
|
import { repoRoot } from '../internal/project-root.js'
|
|
100
|
+
import { isSkillHooksEvent, SKILL_HOOKS_CHANNEL } from '../internal/skill-hooks.js'
|
|
98
101
|
import { isSubagentPhaseEvent, SUBAGENT_CHANNEL } from '../internal/subagent-events.js'
|
|
99
102
|
import { claudeToolInput, claudeToolName, claudeToolResponse, piToolOutput } from './claude-tools.js'
|
|
100
|
-
import { formatHooksSummary, type HookCommand, type HookMatcher, type HooksConfig, hookFiles, isBackgroundHook, loadHooks, loadPluginHooks, readAllowedHttpHookUrls, readDisableAllHooks } from './config.js'
|
|
103
|
+
import { formatHooksSummary, type HookCommand, type HookMatcher, type HooksConfig, hookFiles, isBackgroundHook, loadHooks, loadManagedHooks, loadPluginHooks, mergeSkillHooks, readAllowedHttpHookUrls, readDisableAllHooks, readSettingsDisableAllHooks } from './config.js'
|
|
101
104
|
import { blockedToolCall, jsonBlockVerdict, postToolFeedback, promptContext, runPreToolUse, runUserPromptSubmit, surfaceSystemMessages, tryParseJson } from './decisions.js'
|
|
102
105
|
import { allCommands, matchingCommands, passesIfFilter } from './matcher.js'
|
|
103
106
|
import { type HookRunner, type HookRunResult, runAgentHook, runHookCommand, runHttpHook, runMcpToolHook, runPromptHook, sessionEndTimeoutMs, timeoutMs } from './runners.js'
|
|
@@ -184,6 +187,9 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
184
187
|
/** Consecutive Stop-hook blocks with no user progress between them. Reset on user input
|
|
185
188
|
* and on a non-blocking Stop; at the cap the continuation is suppressed and the turn ends. */
|
|
186
189
|
let stopHookBlockCount = 0
|
|
190
|
+
/** Tool start times per call id, for Claude's duration_ms on PostToolUse: the
|
|
191
|
+
* clock starts after PreToolUse hooks and any confirm dialog resolve. */
|
|
192
|
+
const toolStartTimes = new Map<string, number>()
|
|
187
193
|
/** The pending idle_prompt notification: Claude fires it when the turn ended about
|
|
188
194
|
* 60 seconds ago and the user hasn't typed since, so it arms on agent_end and is
|
|
189
195
|
* canceled by input or the next turn. */
|
|
@@ -257,7 +263,14 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
257
263
|
if (hook.type === 'mcp_tool') return runMcpToolHook(hook, merged, ms)
|
|
258
264
|
return runHookCommand(hook.command, merged, ms, projectDir, hook.args, onChild)
|
|
259
265
|
}
|
|
260
|
-
|
|
266
|
+
// Claude's `once` (skill-frontmatter hooks only): removed after the first
|
|
267
|
+
// successful run; a failure, block, or timeout leaves it in place.
|
|
268
|
+
const markOnce = async (run: Promise<HookRunResult>): Promise<HookRunResult> => {
|
|
269
|
+
const result = await run
|
|
270
|
+
if (hook.once === true && hook.origin?.startsWith('skill:') === true && result.code === 0 && !result.timedOut) hook.spent = true
|
|
271
|
+
return result
|
|
272
|
+
}
|
|
273
|
+
if (!isBackgroundHook(hook)) return markOnce(dispatch())
|
|
261
274
|
let kill: (() => void) | undefined
|
|
262
275
|
void dispatch((registered) => {
|
|
263
276
|
kill = registered
|
|
@@ -274,6 +287,15 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
274
287
|
})
|
|
275
288
|
return Promise.resolve({ code: 0, stdout: '', stderr: '', timedOut: false })
|
|
276
289
|
}
|
|
290
|
+
// Hooks a skill's frontmatter declares arrive over the shared bus when the skill
|
|
291
|
+
// is invoked (see skills.ts) and stay registered for the rest of the session, as
|
|
292
|
+
// Claude documents; a session restart reloads config and drops them.
|
|
293
|
+
pi.events.on(SKILL_HOOKS_CHANNEL, (data) => {
|
|
294
|
+
if (!isSkillHooksEvent(data)) return
|
|
295
|
+
if (hooksDisabled) return
|
|
296
|
+
mergeSkillHooks(config, data.skillName, data.hooks, hookSources)
|
|
297
|
+
})
|
|
298
|
+
|
|
277
299
|
// Claude matchers name MCP tools mcp__<server>__<tool>; pi-code registers them as
|
|
278
300
|
// <server>_<tool>. The mcp extension publishes the mapping on pi's shared bus.
|
|
279
301
|
const mcpAliases = new Map<string, string>()
|
|
@@ -345,15 +367,23 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
345
367
|
const files = hookFiles(ctx.cwd, os.homedir(), trusted)
|
|
346
368
|
hookSources.clear()
|
|
347
369
|
allowedHttpHookUrls = readAllowedHttpHookUrls(files)
|
|
348
|
-
// The disableAllHooks escape hatch, checked before any config loads
|
|
349
|
-
//
|
|
350
|
-
|
|
351
|
-
|
|
370
|
+
// The disableAllHooks escape hatch, checked before any config loads. The tiers
|
|
371
|
+
// differ, as Claude documents: managed-level disableAllHooks turns everything
|
|
372
|
+
// off, while a settings-level one cannot disable the hooks an administrator
|
|
373
|
+
// configured through managed policy settings.
|
|
374
|
+
const managedSettings = readManagedSettings()
|
|
375
|
+
hooksDisabled = readDisableAllHooks(files, managedSettings)
|
|
376
|
+
if (managedSettings.disableAllHooks === true) {
|
|
352
377
|
config = {}
|
|
353
378
|
pendingSessionContext = []
|
|
354
379
|
return
|
|
355
380
|
}
|
|
356
|
-
config =
|
|
381
|
+
config = loadManagedHooks(hookSources, managedSettings)
|
|
382
|
+
if (readSettingsDisableAllHooks(files)) {
|
|
383
|
+
pendingSessionContext = []
|
|
384
|
+
return
|
|
385
|
+
}
|
|
386
|
+
for (const [event, matchers] of Object.entries(loadHooks(files, hookSources))) config[event] = [...(config[event] ?? []), ...matchers]
|
|
357
387
|
// Plugins are user-installed and enabled by user settings (see installedPlugins),
|
|
358
388
|
// so a checked-out repo cannot toggle which code-bearing plugin hooks run.
|
|
359
389
|
loadPluginHooks(config, installedPlugins(os.homedir()), hookSources)
|
|
@@ -390,13 +420,18 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
390
420
|
// additionalContext is delivered alongside the tool result, so stash it for
|
|
391
421
|
// this call's tool_result to append.
|
|
392
422
|
if (decision.context && decision.context.length > 0) pendingToolContext.set(event.toolCallId, decision.context)
|
|
423
|
+
// Claude's duration_ms excludes PreToolUse hook time, so the clock starts here.
|
|
424
|
+
toolStartTimes.set(event.toolCallId, Date.now())
|
|
393
425
|
return undefined
|
|
394
426
|
}
|
|
395
427
|
// Claude's "ask": prompt the user and let the call through if they approve.
|
|
396
428
|
// With no UI (headless) the block stands, which is the safe default.
|
|
397
429
|
if (decision.ask && ctx.hasUI) {
|
|
398
430
|
const approved = await ctx.ui.confirm(`Allow ${event.toolName}?`, decision.reason ?? 'A hook asks you to confirm this tool call.')
|
|
399
|
-
|
|
431
|
+
if (!approved) return blockedToolCall(decision.reason)
|
|
432
|
+
// Claude's duration_ms also excludes time in permission prompts.
|
|
433
|
+
toolStartTimes.set(event.toolCallId, Date.now())
|
|
434
|
+
return undefined
|
|
400
435
|
}
|
|
401
436
|
return blockedToolCall(decision.reason)
|
|
402
437
|
})
|
|
@@ -424,7 +459,9 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
424
459
|
if (commands.length === 0 && pending.length === 0) return
|
|
425
460
|
const translatedInput = alias === undefined ? claudeToolInput(event.toolName, event.input, ctx.cwd) : undefined
|
|
426
461
|
const response = (alias === undefined && !event.isError ? claudeToolResponse(event.toolName, event.input, textContent(event.content), event.isError, ctx.cwd) : undefined) ?? { content: event.content, details: event.details, isError: event.isError }
|
|
427
|
-
const
|
|
462
|
+
const startedAt = toolStartTimes.get(event.toolCallId)
|
|
463
|
+
toolStartTimes.delete(event.toolCallId)
|
|
464
|
+
const payload = { hook_event_name: eventName, tool_name: translatedName ?? event.toolName, tool_input: translatedInput ?? event.input, tool_response: response, ...(startedAt === undefined ? {} : { duration_ms: Date.now() - startedAt }) }
|
|
428
465
|
const run = boundRunner(ctx, { tool_use_id: event.toolCallId })
|
|
429
466
|
const results = await Promise.all(commands.map((command) => run(command, payload, timeoutMs(command))))
|
|
430
467
|
surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
|
|
@@ -488,8 +525,10 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
488
525
|
return { action: 'handled' }
|
|
489
526
|
}
|
|
490
527
|
// Claude injects a UserPromptSubmit hook's context ahead of the prompt; transform is
|
|
491
|
-
// pi's seam for rewriting the submitted text.
|
|
492
|
-
|
|
528
|
+
// pi's seam for rewriting the submitted text. With suppressOriginalPrompt the
|
|
529
|
+
// context replaces the prompt entirely (honored only when context exists, since
|
|
530
|
+
// an empty submission would be no turn at all).
|
|
531
|
+
if (decision.context) return { action: 'transform', text: decision.suppress ? decision.context : `${decision.context}\n\n${event.text}` }
|
|
493
532
|
return { action: 'continue' }
|
|
494
533
|
})
|
|
495
534
|
|
|
@@ -582,13 +621,19 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
582
621
|
|
|
583
622
|
pi.on('session_before_compact', async (event, ctx) => {
|
|
584
623
|
const trigger = claudeSpelling(PRECOMPACT_TRIGGER, event.reason)
|
|
585
|
-
|
|
624
|
+
// Claude's custom_instructions: the /compact arguments on a manual run, empty
|
|
625
|
+
// on an automatic one; pi carries them on the event directly.
|
|
626
|
+
const payload = { hook_event_name: 'PreCompact', trigger: trigger.value, custom_instructions: event.customInstructions ?? '' }
|
|
627
|
+
const results = await runNotifyHooks(matchingCommands(config.PreCompact, trigger.names), payload, boundRunner(ctx))
|
|
586
628
|
surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
|
|
587
629
|
})
|
|
588
630
|
|
|
589
631
|
pi.on('session_compact', async (event, ctx) => {
|
|
590
632
|
const trigger = claudeSpelling(PRECOMPACT_TRIGGER, event.reason)
|
|
591
|
-
|
|
633
|
+
// Claude's compact_summary: the summary that replaced the compacted history.
|
|
634
|
+
const summary = (event as { compactionEntry?: { summary?: unknown } }).compactionEntry?.summary
|
|
635
|
+
const payload = { hook_event_name: 'PostCompact', trigger: trigger.value, ...(typeof summary === 'string' ? { compact_summary: summary } : {}) }
|
|
636
|
+
const results = await runNotifyHooks(matchingCommands(config.PostCompact, trigger.names), payload, boundRunner(ctx))
|
|
592
637
|
surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
|
|
593
638
|
// Claude also fires SessionStart with source "compact" when the session
|
|
594
639
|
// continues after compaction; its stdout context rides the next agent start,
|
|
@@ -639,7 +684,9 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
639
684
|
pi.registerCommand('hooks', {
|
|
640
685
|
description: 'Show the hook configuration resolved from settings',
|
|
641
686
|
handler: async (_args, ctx) => {
|
|
642
|
-
|
|
687
|
+
// With a settings-level disable, managed policy hooks stay active and the
|
|
688
|
+
// viewer still shows them; only a fully empty config reports disabled.
|
|
689
|
+
if (hooksDisabled && Object.keys(config).length === 0) {
|
|
643
690
|
ctx.ui.notify('All hooks are disabled by the disableAllHooks setting.', 'info')
|
|
644
691
|
return
|
|
645
692
|
}
|
|
@@ -104,8 +104,14 @@ function syntheticCommand(hook: HookCommand): string | undefined {
|
|
|
104
104
|
/** A matched entry with its `command` filled in: mirroring the synthetic identity into
|
|
105
105
|
* `command` keeps dedup, timeout messages and display working for non-shell hooks. */
|
|
106
106
|
function withCommand(raw: HookCommand): HookCommand {
|
|
107
|
-
|
|
108
|
-
|
|
107
|
+
// Fill the identity onto the config entry itself rather than a clone: the runner
|
|
108
|
+
// must receive the same object collection reads, so a once-hook marked spent
|
|
109
|
+
// after a successful run is the object the next collection filters out.
|
|
110
|
+
if (typeof raw.command !== 'string') {
|
|
111
|
+
const identity = syntheticCommand(raw)
|
|
112
|
+
if (identity !== undefined) raw.command = identity
|
|
113
|
+
}
|
|
114
|
+
return raw
|
|
109
115
|
}
|
|
110
116
|
|
|
111
117
|
function collectCommands(matchers: HookMatcher[] | undefined, applies: (entry: HookMatcher) => boolean): HookCommand[] {
|
|
@@ -114,6 +120,8 @@ function collectCommands(matchers: HookMatcher[] | undefined, applies: (entry: H
|
|
|
114
120
|
for (const entry of matchers ?? []) {
|
|
115
121
|
if (!applies(entry)) continue
|
|
116
122
|
for (const raw of (entry.hooks ?? []).filter(isRunnableHook)) {
|
|
123
|
+
// A once-hook that already ran successfully is removed, as Claude documents.
|
|
124
|
+
if (raw.spent === true) continue
|
|
117
125
|
const hook = withCommand(raw)
|
|
118
126
|
// Claude runs a handler defined in more than one settings file once; a
|
|
119
127
|
// plugin's or skill's copy of the same handler stays separate, and http
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The bus channel skills.ts publishes frontmatter hooks on. Claude registers a
|
|
3
|
+
* skill's hooks when the skill is invoked and keeps them for the rest of the
|
|
4
|
+
* session; the hooks extension owns running them, so the skill side only
|
|
5
|
+
* announces the declaration.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const SKILL_HOOKS_CHANNEL = 'pi-code:skill-hooks'
|
|
9
|
+
|
|
10
|
+
export interface SkillHooksEvent {
|
|
11
|
+
skillName: string
|
|
12
|
+
hooks: Record<string, unknown>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function isSkillHooksEvent(data: unknown): data is SkillHooksEvent {
|
|
16
|
+
if (typeof data !== 'object' || data === null) return false
|
|
17
|
+
const event = data as { skillName?: unknown; hooks?: unknown }
|
|
18
|
+
return typeof event.skillName === 'string' && typeof event.hooks === 'object' && event.hooks !== null && !Array.isArray(event.hooks)
|
|
19
|
+
}
|
package/extensions/skills.ts
CHANGED
|
@@ -30,6 +30,7 @@ import { claudeConfigDir } from './internal/config-dir.js'
|
|
|
30
30
|
import { installedPlugins } from './internal/plugins.js'
|
|
31
31
|
import { isProjectApprovedSilently } from './internal/project-approval.js'
|
|
32
32
|
import { ancestorDirs } from './internal/project-root.js'
|
|
33
|
+
import { SKILL_HOOKS_CHANNEL } from './internal/skill-hooks.js'
|
|
33
34
|
|
|
34
35
|
function isDirectory(target: string): boolean {
|
|
35
36
|
try {
|
|
@@ -139,14 +140,23 @@ async function expandSkillInvocation(pi: ExtensionAPI, rawText: string, ctx: Ext
|
|
|
139
140
|
const found = findClaudeSkill(name, skillDirs(ctx.cwd, os.homedir(), trusted))
|
|
140
141
|
if (!found) return
|
|
141
142
|
let parsed: ReturnType<typeof parseCommandFile>
|
|
143
|
+
let content: string
|
|
142
144
|
try {
|
|
143
|
-
|
|
145
|
+
content = fs.readFileSync(found.filePath, 'utf-8')
|
|
146
|
+
parsed = parseCommandFile(content)
|
|
144
147
|
} catch {
|
|
145
148
|
// Unreadable, or malformed frontmatter: pass through to pi's plain expansion
|
|
146
149
|
// (the loader registered the skill and delivers the raw body), rather than
|
|
147
150
|
// failing the invocation over the dynamic features it cannot have.
|
|
148
151
|
return
|
|
149
152
|
}
|
|
153
|
+
// Claude registers hooks a skill's frontmatter declares when the skill is
|
|
154
|
+
// invoked, for the rest of the session; the hooks extension owns running them,
|
|
155
|
+
// so the declaration is announced over the shared bus.
|
|
156
|
+
const declaredHooks = parseFrontmatter<Record<string, unknown>>(content).frontmatter.hooks
|
|
157
|
+
if (declaredHooks !== null && typeof declaredHooks === 'object' && !Array.isArray(declaredHooks)) {
|
|
158
|
+
pi.events?.emit(SKILL_HOOKS_CHANNEL, { skillName: name, hooks: declaredHooks })
|
|
159
|
+
}
|
|
150
160
|
const expanded = await expandCommand(pi, parsed, args, { cwd: ctx.cwd }, found.filePath, undefined, { allowShell: !shellExecutionDisabled(ctx.cwd, os.homedir(), trusted) })
|
|
151
161
|
return { action: 'transform', text: `<skill name="${name}" location="${found.filePath}">\nReferences are relative to ${found.baseDir}.\n\n${expanded}\n</skill>` }
|
|
152
162
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-code",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.31",
|
|
4
4
|
"description": "Claude Code experience for the pi coding agent: reads your .claude config (rules, commands, skills, hooks, output styles, MCP servers, agents) and adds todo, checkpoints, memory, web, and subagents",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi",
|