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
- 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 {
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
- return JSON.parse(text)
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) plus the project's
77
- * .claude/settings.json and settings.local.json (only when the project is
78
- * trusted, since hooks execute arbitrary shell). Claude's `disableAllHooks`
79
- * setting (managed settings or any honored file in that chain) short-circuits
80
- * the load entirely, so no event fires any hook; /hooks prints the resolved
81
- * 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:
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
- if (!isBackgroundHook(hook)) return dispatch()
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: with no
349
- // config resolved, no event, plugin hooks included, can fire a hook.
350
- hooksDisabled = readDisableAllHooks(files)
351
- if (hooksDisabled) {
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 = loadHooks(files, hookSources)
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
- return approved ? undefined : blockedToolCall(decision.reason)
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 payload = { hook_event_name: eventName, tool_name: translatedName ?? event.toolName, tool_input: translatedInput ?? event.input, tool_response: response }
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
- if (decision.context) return { action: 'transform', text: `${decision.context}\n\n${event.text}` }
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
- const results = await runNotifyHooks(matchingCommands(config.PreCompact, trigger.names), { hook_event_name: 'PreCompact', trigger: trigger.value }, boundRunner(ctx))
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
- const results = await runNotifyHooks(matchingCommands(config.PostCompact, trigger.names), { hook_event_name: 'PostCompact', trigger: trigger.value }, boundRunner(ctx))
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
- if (hooksDisabled) {
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
- 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,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
+ }
@@ -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.29",
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",