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
- if (managed.disableAllHooks === true) return true
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
- function mergeHooksJson(config: HooksConfig, raw: string, source: string, sources?: Map<HookMatcher, string>): void {
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) plus the project's
72
- * .claude/settings.json and settings.local.json (only when the project is
73
- * trusted, since hooks execute arbitrary shell). Claude's `disableAllHooks`
74
- * setting (managed settings or any honored file in that chain) short-circuits
75
- * the load entirely, so no event fires any hook; /hooks prints the resolved
76
- * chain per event with each entry's source settings file. Matchers follow Claude's rule:
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
- if (!isBackgroundHook(hook)) return dispatch()
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: with no
327
- // config resolved, no event, plugin hooks included, can fire a hook.
328
- hooksDisabled = readDisableAllHooks(files)
329
- if (hooksDisabled) {
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 = loadHooks(files, hookSources)
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 (idle_prompt). Observational only;
483
- // exit codes and JSON output are ignored, as Claude documents for this event.
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
- void runNotifyHooks(notifyCommands, { hook_event_name: 'Notification', notification_type: 'idle_prompt', message: 'pi is waiting for your input' }, boundRunner(ctx)).catch(() => {})
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
- if (hooksDisabled) {
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
- const identity = syntheticCommand(raw)
108
- return identity !== undefined && typeof raw.command !== 'string' ? { ...raw, command: identity } : raw
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
- if (seen.has(hook.command)) continue
120
- seen.add(hook.command)
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
- const input = hook.input && typeof hook.input === 'object' ? hook.input : (payload as Record<string, unknown>)
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
+ }
@@ -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
- parsed = parseCommandFile(fs.readFileSync(found.filePath, 'utf-8'))
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.28",
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",