pi-code 1.0.28 → 1.0.30
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.
|
@@ -41,6 +41,14 @@ export interface HookCommand {
|
|
|
41
41
|
/** prompt/agent entries: an optional model override; agent adds a system prompt. */
|
|
42
42
|
model?: string
|
|
43
43
|
systemPrompt?: string
|
|
44
|
+
/** Dedup scope: unset for settings files (identical handlers collapse across
|
|
45
|
+
* them); a plugin's or skill's copy carries its origin and stays separate. */
|
|
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
|
|
44
52
|
}
|
|
45
53
|
export interface HookMatcher {
|
|
46
54
|
matcher?: string
|
|
@@ -73,7 +81,13 @@ export function hookFiles(cwd: string, home: string, trusted: boolean): string[]
|
|
|
73
81
|
* disabled in their own settings would defeat the escape hatch. The chain itself
|
|
74
82
|
* already gates project files on trust (see hookFiles). */
|
|
75
83
|
export function readDisableAllHooks(files: string[], managed: Record<string, unknown> = readManagedSettings()): boolean {
|
|
76
|
-
|
|
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 {
|
|
77
91
|
for (const file of files) {
|
|
78
92
|
try {
|
|
79
93
|
const parsed: unknown = JSON.parse(fs.readFileSync(file, 'utf-8'))
|
|
@@ -85,6 +99,22 @@ export function readDisableAllHooks(files: string[], managed: Record<string, unk
|
|
|
85
99
|
return false
|
|
86
100
|
}
|
|
87
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
|
+
|
|
88
118
|
/** Claude's `allowedHttpHookUrls` setting: URL patterns http hooks may target, with
|
|
89
119
|
* `*` as a wildcard. Per Claude's documentation: undefined (no source sets the key)
|
|
90
120
|
* means no restrictions, an empty array blocks every http hook, and arrays merge
|
|
@@ -135,7 +165,15 @@ export function loadHooks(files: string[], sources?: Map<HookMatcher, string>):
|
|
|
135
165
|
return config
|
|
136
166
|
}
|
|
137
167
|
|
|
138
|
-
|
|
168
|
+
/** Claude dedups identical handlers across settings files only; a plugin's copy
|
|
169
|
+
* stays separate, so plugin entries carry their origin into the dedup key. */
|
|
170
|
+
function stampOrigin(entries: HookMatcher[], origin: string): void {
|
|
171
|
+
for (const entry of entries) {
|
|
172
|
+
for (const hook of entry.hooks ?? []) hook.origin = origin
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function mergeHooksJson(config: HooksConfig, raw: string, source: string, sources?: Map<HookMatcher, string>, origin?: string): void {
|
|
139
177
|
let parsed: { hooks?: HooksConfig }
|
|
140
178
|
try {
|
|
141
179
|
parsed = JSON.parse(raw)
|
|
@@ -150,6 +188,7 @@ function mergeHooksJson(config: HooksConfig, raw: string, source: string, source
|
|
|
150
188
|
// call for the rest of the session failed with an opaque type error.
|
|
151
189
|
const usable = matchers.filter((entry) => isUsableMatcher(entry, source, event))
|
|
152
190
|
if (usable.length === 0) continue
|
|
191
|
+
if (origin !== undefined) stampOrigin(usable, origin)
|
|
153
192
|
config[event] = [...(config[event] ?? []), ...usable]
|
|
154
193
|
// Each parse produces fresh entry objects, so object identity keys the /hooks
|
|
155
194
|
// viewer's source attribution without touching the entries themselves.
|
|
@@ -167,12 +206,12 @@ export function loadPluginHooks(config: HooksConfig, plugins: InstalledPlugin[],
|
|
|
167
206
|
// numeric event keys), so it falls through to the default path rather than
|
|
168
207
|
// silently registering nothing.
|
|
169
208
|
if (declared !== null && typeof declared === 'object' && !Array.isArray(declared)) {
|
|
170
|
-
mergeHooksJson(config, substitutePluginVars(JSON.stringify({ hooks: declared }), plugin), `${plugin.name} (plugin.json)`, sources)
|
|
209
|
+
mergeHooksJson(config, substitutePluginVars(JSON.stringify({ hooks: declared }), plugin), `${plugin.name} (plugin.json)`, sources, `plugin:${plugin.name}`)
|
|
171
210
|
continue
|
|
172
211
|
}
|
|
173
212
|
const file = path.resolve(plugin.root, typeof declared === 'string' ? declared : path.join('hooks', 'hooks.json'))
|
|
174
213
|
try {
|
|
175
|
-
mergeHooksJson(config, substitutePluginVars(fs.readFileSync(file, 'utf-8'), plugin), file, sources)
|
|
214
|
+
mergeHooksJson(config, substitutePluginVars(fs.readFileSync(file, 'utf-8'), plugin), file, sources, `plugin:${plugin.name}`)
|
|
176
215
|
} catch {
|
|
177
216
|
// a plugin without hooks contributes nothing
|
|
178
217
|
}
|
|
@@ -20,7 +20,12 @@
|
|
|
20
20
|
* - Stop -> pi `agent_end` (a block feeds its reason back as a new turn,
|
|
21
21
|
* with stop_hook_active as the loop guard)
|
|
22
22
|
* - PreCompact -> pi `session_before_compact` (fire-and-forget)
|
|
23
|
-
* - PostCompact -> pi `session_compact` (fire-and-forget)
|
|
23
|
+
* - PostCompact -> pi `session_compact` (fire-and-forget); the same event also
|
|
24
|
+
* fires SessionStart with source "compact", as Claude does
|
|
25
|
+
* when a session continues after compaction
|
|
26
|
+
* - PostModelSwitch -> pi `model_select` (after the change, matched against the new
|
|
27
|
+
* model id; stdout context rides the next agent start;
|
|
28
|
+
* PreModelSwitch stays unbridged, pi has no veto seam)
|
|
24
29
|
* - PostToolUseFailure -> pi `tool_result` error branch (stderr/additionalContext
|
|
25
30
|
* appended to the failed result; it cannot block, the tool failed)
|
|
26
31
|
* - SessionEnd -> pi `session_shutdown` (fire-and-forget)
|
|
@@ -68,12 +73,13 @@
|
|
|
68
73
|
* `{"hookSpecificOutput": {"permissionDecision": "deny", ...}}` (or the older
|
|
69
74
|
* `{"decision": "block"}`).
|
|
70
75
|
*
|
|
71
|
-
* Config is merged from ~/.claude/settings.json (always)
|
|
72
|
-
* .claude/settings.json and settings.local.json (only when the
|
|
73
|
-
* trusted, since hooks execute arbitrary shell)
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
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:
|
|
77
83
|
* `*`/empty match all, plain names are exact (with `|`/`,` list separators), and
|
|
78
84
|
* anything with other regex characters is an unanchored regex. Claude matchers
|
|
79
85
|
* are PascalCase (`Bash`); pi tool names are lowercase (`bash`), so comparison
|
|
@@ -85,14 +91,16 @@
|
|
|
85
91
|
import * as os from 'node:os'
|
|
86
92
|
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
|
|
87
93
|
import { INSTRUCTIONS_CHANNEL, isInstructionLoadEvent } from '../internal/instruction-events.js'
|
|
94
|
+
import { readManagedSettings } from '../internal/managed-settings.js'
|
|
88
95
|
import { isMcpToolAliases, MCP_TOOLS_CHANNEL } from '../internal/mcp-alias.js'
|
|
89
96
|
import { isPlanModeState, PLAN_MODE_CHANNEL } from '../internal/plan-mode-state.js'
|
|
90
97
|
import { installedPlugins } from '../internal/plugins.js'
|
|
91
98
|
import { isProjectApproved } from '../internal/project-approval.js'
|
|
92
99
|
import { repoRoot } from '../internal/project-root.js'
|
|
100
|
+
import { isSkillHooksEvent, SKILL_HOOKS_CHANNEL } from '../internal/skill-hooks.js'
|
|
93
101
|
import { isSubagentPhaseEvent, SUBAGENT_CHANNEL } from '../internal/subagent-events.js'
|
|
94
102
|
import { claudeToolInput, claudeToolName, claudeToolResponse, piToolOutput } from './claude-tools.js'
|
|
95
|
-
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'
|
|
96
104
|
import { blockedToolCall, jsonBlockVerdict, postToolFeedback, promptContext, runPreToolUse, runUserPromptSubmit, surfaceSystemMessages, tryParseJson } from './decisions.js'
|
|
97
105
|
import { allCommands, matchingCommands, passesIfFilter } from './matcher.js'
|
|
98
106
|
import { type HookRunner, type HookRunResult, runAgentHook, runHookCommand, runHttpHook, runMcpToolHook, runPromptHook, sessionEndTimeoutMs, timeoutMs } from './runners.js'
|
|
@@ -160,6 +168,15 @@ function claudeSpelling(map: Record<string, string>, raw: string): { names: stri
|
|
|
160
168
|
return { names: value === raw ? [raw] : [raw, value], value }
|
|
161
169
|
}
|
|
162
170
|
|
|
171
|
+
/** Claude: idle_prompt fires when "Claude finished responding about 60 seconds ago
|
|
172
|
+
* and you haven't typed since". */
|
|
173
|
+
const IDLE_PROMPT_DELAY_MS = 60_000
|
|
174
|
+
|
|
175
|
+
/** pi's model_select sources in Claude's PostModelSwitch vocabulary: an explicit
|
|
176
|
+
* set is a command-style request, cycling is the picker, and restore is the model
|
|
177
|
+
* Claude Code restores on resume. */
|
|
178
|
+
const MODEL_SELECT_SOURCE: Record<string, string> = { set: 'command', cycle: 'picker', restore: 'resume' }
|
|
179
|
+
|
|
163
180
|
export default function hooksExtension(pi: ExtensionAPI) {
|
|
164
181
|
let config: HooksConfig = {}
|
|
165
182
|
let projectDir = ''
|
|
@@ -170,6 +187,14 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
170
187
|
/** Consecutive Stop-hook blocks with no user progress between them. Reset on user input
|
|
171
188
|
* and on a non-blocking Stop; at the cap the continuation is suppressed and the turn ends. */
|
|
172
189
|
let stopHookBlockCount = 0
|
|
190
|
+
/** The pending idle_prompt notification: Claude fires it when the turn ended about
|
|
191
|
+
* 60 seconds ago and the user hasn't typed since, so it arms on agent_end and is
|
|
192
|
+
* canceled by input or the next turn. */
|
|
193
|
+
let idlePromptTimer: ReturnType<typeof setTimeout> | undefined
|
|
194
|
+
const cancelIdlePrompt = (): void => {
|
|
195
|
+
clearTimeout(idlePromptTimer)
|
|
196
|
+
idlePromptTimer = undefined
|
|
197
|
+
}
|
|
173
198
|
let sessionCtx: ExtensionContext | undefined
|
|
174
199
|
/** Claude's disableAllHooks escape hatch was set somewhere in the honored chain. */
|
|
175
200
|
let hooksDisabled = false
|
|
@@ -235,7 +260,14 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
235
260
|
if (hook.type === 'mcp_tool') return runMcpToolHook(hook, merged, ms)
|
|
236
261
|
return runHookCommand(hook.command, merged, ms, projectDir, hook.args, onChild)
|
|
237
262
|
}
|
|
238
|
-
|
|
263
|
+
// Claude's `once` (skill-frontmatter hooks only): removed after the first
|
|
264
|
+
// successful run; a failure, block, or timeout leaves it in place.
|
|
265
|
+
const markOnce = async (run: Promise<HookRunResult>): Promise<HookRunResult> => {
|
|
266
|
+
const result = await run
|
|
267
|
+
if (hook.once === true && hook.origin?.startsWith('skill:') === true && result.code === 0 && !result.timedOut) hook.spent = true
|
|
268
|
+
return result
|
|
269
|
+
}
|
|
270
|
+
if (!isBackgroundHook(hook)) return markOnce(dispatch())
|
|
239
271
|
let kill: (() => void) | undefined
|
|
240
272
|
void dispatch((registered) => {
|
|
241
273
|
kill = registered
|
|
@@ -252,6 +284,15 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
252
284
|
})
|
|
253
285
|
return Promise.resolve({ code: 0, stdout: '', stderr: '', timedOut: false })
|
|
254
286
|
}
|
|
287
|
+
// Hooks a skill's frontmatter declares arrive over the shared bus when the skill
|
|
288
|
+
// is invoked (see skills.ts) and stay registered for the rest of the session, as
|
|
289
|
+
// Claude documents; a session restart reloads config and drops them.
|
|
290
|
+
pi.events.on(SKILL_HOOKS_CHANNEL, (data) => {
|
|
291
|
+
if (!isSkillHooksEvent(data)) return
|
|
292
|
+
if (hooksDisabled) return
|
|
293
|
+
mergeSkillHooks(config, data.skillName, data.hooks, hookSources)
|
|
294
|
+
})
|
|
295
|
+
|
|
255
296
|
// Claude matchers name MCP tools mcp__<server>__<tool>; pi-code registers them as
|
|
256
297
|
// <server>_<tool>. The mcp extension publishes the mapping on pi's shared bus.
|
|
257
298
|
const mcpAliases = new Map<string, string>()
|
|
@@ -323,15 +364,23 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
323
364
|
const files = hookFiles(ctx.cwd, os.homedir(), trusted)
|
|
324
365
|
hookSources.clear()
|
|
325
366
|
allowedHttpHookUrls = readAllowedHttpHookUrls(files)
|
|
326
|
-
// The disableAllHooks escape hatch, checked before any config loads
|
|
327
|
-
//
|
|
328
|
-
|
|
329
|
-
|
|
367
|
+
// The disableAllHooks escape hatch, checked before any config loads. The tiers
|
|
368
|
+
// differ, as Claude documents: managed-level disableAllHooks turns everything
|
|
369
|
+
// off, while a settings-level one cannot disable the hooks an administrator
|
|
370
|
+
// configured through managed policy settings.
|
|
371
|
+
const managedSettings = readManagedSettings()
|
|
372
|
+
hooksDisabled = readDisableAllHooks(files, managedSettings)
|
|
373
|
+
if (managedSettings.disableAllHooks === true) {
|
|
330
374
|
config = {}
|
|
331
375
|
pendingSessionContext = []
|
|
332
376
|
return
|
|
333
377
|
}
|
|
334
|
-
config =
|
|
378
|
+
config = loadManagedHooks(hookSources, managedSettings)
|
|
379
|
+
if (readSettingsDisableAllHooks(files)) {
|
|
380
|
+
pendingSessionContext = []
|
|
381
|
+
return
|
|
382
|
+
}
|
|
383
|
+
for (const [event, matchers] of Object.entries(loadHooks(files, hookSources))) config[event] = [...(config[event] ?? []), ...matchers]
|
|
335
384
|
// Plugins are user-installed and enabled by user settings (see installedPlugins),
|
|
336
385
|
// so a checked-out repo cannot toggle which code-bearing plugin hooks run.
|
|
337
386
|
loadPluginHooks(config, installedPlugins(os.homedir()), hookSources)
|
|
@@ -353,6 +402,8 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
353
402
|
// over the bus from context-imports, which owns claudeMdExcludes; announcing
|
|
354
403
|
// the raw contextFiles here would fire for a file the exclusion removed.
|
|
355
404
|
pi.on('before_agent_start', async () => {
|
|
405
|
+
// A new turn is beginning, so the session is no longer idle.
|
|
406
|
+
cancelIdlePrompt()
|
|
356
407
|
if (pendingSessionContext.length === 0) return
|
|
357
408
|
const content = pendingSessionContext.join('\n')
|
|
358
409
|
pendingSessionContext = []
|
|
@@ -452,6 +503,8 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
452
503
|
// Only genuine user input; extension-injected messages (plan-mode, subagent) are not
|
|
453
504
|
// prompts the user submitted.
|
|
454
505
|
if (event.source === 'extension') return { action: 'continue' }
|
|
506
|
+
// The user typed, so the pending idle_prompt no longer applies.
|
|
507
|
+
cancelIdlePrompt()
|
|
455
508
|
// Genuine user input is progress, so it breaks a Stop-hook continuation streak: the
|
|
456
509
|
// block cap counts only consecutive blocks with nothing from the user in between.
|
|
457
510
|
stopHookBlockCount = 0
|
|
@@ -479,11 +532,18 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
479
532
|
// automatic retry or compaction; that is the better tradeoff.
|
|
480
533
|
pi.on('agent_end', async (event, ctx) => {
|
|
481
534
|
// Claude's Notification event, for the one type pi can honestly source: the
|
|
482
|
-
// agent finished and is waiting for input
|
|
483
|
-
//
|
|
535
|
+
// agent finished and is waiting for input. Per Claude, idle_prompt fires when
|
|
536
|
+
// the turn ended about 60 seconds ago and the user hasn't typed since, so it
|
|
537
|
+
// arms here and input or the next turn cancels it. Observational only; exit
|
|
538
|
+
// codes and JSON output are ignored, as Claude documents for this event.
|
|
539
|
+
cancelIdlePrompt()
|
|
484
540
|
const notifyCommands = matchingCommands(config.Notification, ['idle_prompt'])
|
|
485
541
|
if (notifyCommands.length > 0) {
|
|
486
|
-
|
|
542
|
+
const runner = boundRunner(ctx)
|
|
543
|
+
idlePromptTimer = setTimeout(() => {
|
|
544
|
+
void runNotifyHooks(notifyCommands, { hook_event_name: 'Notification', notification_type: 'idle_prompt', message: 'pi is waiting for your input' }, runner).catch(() => {})
|
|
545
|
+
}, IDLE_PROMPT_DELAY_MS)
|
|
546
|
+
idlePromptTimer.unref?.()
|
|
487
547
|
}
|
|
488
548
|
|
|
489
549
|
// Stop has no matcher support (a stray matcher is ignored, as Claude documents)
|
|
@@ -557,6 +617,33 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
557
617
|
const trigger = claudeSpelling(PRECOMPACT_TRIGGER, event.reason)
|
|
558
618
|
const results = await runNotifyHooks(matchingCommands(config.PostCompact, trigger.names), { hook_event_name: 'PostCompact', trigger: trigger.value }, boundRunner(ctx))
|
|
559
619
|
surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
|
|
620
|
+
// Claude also fires SessionStart with source "compact" when the session
|
|
621
|
+
// continues after compaction; its stdout context rides the next agent start,
|
|
622
|
+
// the same as any other SessionStart context.
|
|
623
|
+
const sessionStart = matchingCommands(config.SessionStart, 'compact')
|
|
624
|
+
if (sessionStart.length > 0) {
|
|
625
|
+
const startResults = await runNotifyHooks(sessionStart, { hook_event_name: 'SessionStart', source: 'compact' }, boundRunner(ctx))
|
|
626
|
+
surfaceSystemMessages(startResults, (message) => ctx.ui.notify(message, 'warning'))
|
|
627
|
+
pendingSessionContext.push(...startResults.map((result) => promptContext(result.stdout)).filter(Boolean))
|
|
628
|
+
}
|
|
629
|
+
})
|
|
630
|
+
|
|
631
|
+
pi.on('model_select', async (event, ctx) => {
|
|
632
|
+
// Claude's PostModelSwitch: runs after the session's model changes, matched
|
|
633
|
+
// against the model switched to; it can't block. PreModelSwitch stays
|
|
634
|
+
// unbridged: pi's model_select has no veto seam, and a "Pre" hook whose block
|
|
635
|
+
// decision is silently ignored would be worse than an absent event.
|
|
636
|
+
const { model, previousModel, source } = event as { model: { id: string }; previousModel?: { id: string }; source: string }
|
|
637
|
+
if (!previousModel || previousModel.id === model.id) return
|
|
638
|
+
const commands = matchingCommands(config.PostModelSwitch, model.id)
|
|
639
|
+
if (commands.length === 0) return
|
|
640
|
+
// requested_model is null: pi does not carry the alias the request named.
|
|
641
|
+
const payload = { hook_event_name: 'PostModelSwitch', from_model: previousModel.id, to_model: model.id, requested_model: null, source: MODEL_SELECT_SOURCE[source] ?? 'command' }
|
|
642
|
+
const results = await runNotifyHooks(commands, payload, boundRunner(ctx))
|
|
643
|
+
surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
|
|
644
|
+
// Claude delivers the hook's stdout (or additionalContext) to Claude with the
|
|
645
|
+
// next request after the switch; pi's seam for that is the next agent start.
|
|
646
|
+
pendingSessionContext.push(...results.map((result) => promptContext(result.stdout)).filter(Boolean))
|
|
560
647
|
})
|
|
561
648
|
|
|
562
649
|
pi.on('session_shutdown', async (event, ctx) => {
|
|
@@ -579,7 +666,9 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
579
666
|
pi.registerCommand('hooks', {
|
|
580
667
|
description: 'Show the hook configuration resolved from settings',
|
|
581
668
|
handler: async (_args, ctx) => {
|
|
582
|
-
|
|
669
|
+
// With a settings-level disable, managed policy hooks stay active and the
|
|
670
|
+
// viewer still shows them; only a fully empty config reports disabled.
|
|
671
|
+
if (hooksDisabled && Object.keys(config).length === 0) {
|
|
583
672
|
ctx.ui.notify('All hooks are disabled by the disableAllHooks setting.', 'info')
|
|
584
673
|
return
|
|
585
674
|
}
|
|
@@ -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,10 +120,15 @@ 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
|
-
// Claude runs a handler defined in more than one settings file once
|
|
119
|
-
|
|
120
|
-
|
|
126
|
+
// Claude runs a handler defined in more than one settings file once; a
|
|
127
|
+
// plugin's or skill's copy of the same handler stays separate, and http
|
|
128
|
+
// handlers with the same URL but different headers are distinct.
|
|
129
|
+
const key = `${hook.origin ?? 'settings'}\n${hook.command}\n${hook.headers ? JSON.stringify(hook.headers) : ''}`
|
|
130
|
+
if (seen.has(key)) continue
|
|
131
|
+
seen.add(key)
|
|
121
132
|
result.push(hook)
|
|
122
133
|
}
|
|
123
134
|
}
|
|
@@ -247,6 +247,33 @@ export async function runPromptHook(hook: HookCommand, payload: unknown, model:
|
|
|
247
247
|
}
|
|
248
248
|
}
|
|
249
249
|
|
|
250
|
+
/** A dotted path into the hook's JSON input, or undefined when any step is missing. */
|
|
251
|
+
function lookupPath(payload: unknown, dotted: string): unknown {
|
|
252
|
+
let current: unknown = payload
|
|
253
|
+
for (const key of dotted.split('.')) {
|
|
254
|
+
if (current === null || typeof current !== 'object') return undefined
|
|
255
|
+
current = (current as Record<string, unknown>)[key]
|
|
256
|
+
}
|
|
257
|
+
return current
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Claude's ${path} substitution for mcp_tool input: string values may reference the
|
|
261
|
+
* hook's JSON input, such as ${tool_input.file_path}. Arrays and nested objects are
|
|
262
|
+
* walked; an unresolvable path stays literal; non-string looked-up values are
|
|
263
|
+
* JSON-encoded into the string. */
|
|
264
|
+
function substituteInputPaths(value: unknown, payload: unknown): unknown {
|
|
265
|
+
if (typeof value === 'string') {
|
|
266
|
+
return value.replace(/\$\{([\w.]+)\}/g, (matchText, dotted: string) => {
|
|
267
|
+
const found = lookupPath(payload, dotted)
|
|
268
|
+
if (found === undefined) return matchText
|
|
269
|
+
return typeof found === 'string' ? found : JSON.stringify(found)
|
|
270
|
+
})
|
|
271
|
+
}
|
|
272
|
+
if (Array.isArray(value)) return value.map((entry) => substituteInputPaths(entry, payload))
|
|
273
|
+
if (value !== null && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, substituteInputPaths(entry, payload)]))
|
|
274
|
+
return value
|
|
275
|
+
}
|
|
276
|
+
|
|
250
277
|
/**
|
|
251
278
|
* Claude's `type: "mcp_tool"` hook: call a tool on an already-connected MCP server
|
|
252
279
|
* and treat its text output like command stdout. pi reaches the server through the
|
|
@@ -255,7 +282,9 @@ export async function runPromptHook(hook: HookCommand, payload: unknown, model:
|
|
|
255
282
|
*/
|
|
256
283
|
export async function runMcpToolHook(hook: HookCommand, payload: unknown, timeoutMs: number): Promise<HookRunResult> {
|
|
257
284
|
if (!hook.server || !hook.tool) return { code: 1, stdout: '', stderr: 'mcp_tool hook needs server and tool', timedOut: false }
|
|
258
|
-
|
|
285
|
+
// Claude: `input` is the arguments passed to the tool; without it the tool is
|
|
286
|
+
// called with no arguments, never handed the whole event payload.
|
|
287
|
+
const input = hook.input && typeof hook.input === 'object' ? (substituteInputPaths(hook.input, payload) as Record<string, unknown>) : {}
|
|
259
288
|
let timer: ReturnType<typeof setTimeout> | undefined
|
|
260
289
|
const deadline = new Promise<HookRunResult>((resolve) => {
|
|
261
290
|
timer = setTimeout(() => resolve({ code: 1, stdout: '', stderr: `mcp_tool hook timed out after ${timeoutMs}ms`, timedOut: false }), timeoutMs)
|
|
@@ -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.30",
|
|
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",
|