pi-code 1.0.29 → 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.
@@ -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
@@ -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'
@@ -257,7 +260,14 @@ export default function hooksExtension(pi: ExtensionAPI) {
257
260
  if (hook.type === 'mcp_tool') return runMcpToolHook(hook, merged, ms)
258
261
  return runHookCommand(hook.command, merged, ms, projectDir, hook.args, onChild)
259
262
  }
260
- 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())
261
271
  let kill: (() => void) | undefined
262
272
  void dispatch((registered) => {
263
273
  kill = registered
@@ -274,6 +284,15 @@ export default function hooksExtension(pi: ExtensionAPI) {
274
284
  })
275
285
  return Promise.resolve({ code: 0, stdout: '', stderr: '', timedOut: false })
276
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
+
277
296
  // Claude matchers name MCP tools mcp__<server>__<tool>; pi-code registers them as
278
297
  // <server>_<tool>. The mcp extension publishes the mapping on pi's shared bus.
279
298
  const mcpAliases = new Map<string, string>()
@@ -345,15 +364,23 @@ export default function hooksExtension(pi: ExtensionAPI) {
345
364
  const files = hookFiles(ctx.cwd, os.homedir(), trusted)
346
365
  hookSources.clear()
347
366
  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) {
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) {
352
374
  config = {}
353
375
  pendingSessionContext = []
354
376
  return
355
377
  }
356
- 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]
357
384
  // Plugins are user-installed and enabled by user settings (see installedPlugins),
358
385
  // so a checked-out repo cannot toggle which code-bearing plugin hooks run.
359
386
  loadPluginHooks(config, installedPlugins(os.homedir()), hookSources)
@@ -639,7 +666,9 @@ export default function hooksExtension(pi: ExtensionAPI) {
639
666
  pi.registerCommand('hooks', {
640
667
  description: 'Show the hook configuration resolved from settings',
641
668
  handler: async (_args, ctx) => {
642
- 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) {
643
672
  ctx.ui.notify('All hooks are disabled by the disableAllHooks setting.', 'info')
644
673
  return
645
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,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.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",