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
|
-
|
|
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)
|
|
77
|
-
* .claude/settings.json and settings.local.json (only when the
|
|
78
|
-
* trusted, since hooks execute arbitrary shell)
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
76
|
+
* Config is merged from managed policy settings, ~/.claude/settings.json (always),
|
|
77
|
+
* the project's .claude/settings.json and settings.local.json (only when the
|
|
78
|
+
* project is trusted, since hooks execute arbitrary shell), plugins, and invoked
|
|
79
|
+
* skills' frontmatter. Claude's `disableAllHooks` is tiered: at the managed level
|
|
80
|
+
* it turns everything off; in any honored settings file it disables the
|
|
81
|
+
* non-managed hooks while managed policy hooks keep running. /hooks prints the
|
|
82
|
+
* resolved chain per event with each entry's source. Matchers follow Claude's rule:
|
|
82
83
|
* `*`/empty match all, plain names are exact (with `|`/`,` list separators), and
|
|
83
84
|
* anything with other regex characters is an unanchored regex. Claude matchers
|
|
84
85
|
* are PascalCase (`Bash`); pi tool names are lowercase (`bash`), so comparison
|
|
@@ -90,14 +91,16 @@
|
|
|
90
91
|
import * as os from 'node:os'
|
|
91
92
|
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
|
|
92
93
|
import { INSTRUCTIONS_CHANNEL, isInstructionLoadEvent } from '../internal/instruction-events.js'
|
|
94
|
+
import { readManagedSettings } from '../internal/managed-settings.js'
|
|
93
95
|
import { isMcpToolAliases, MCP_TOOLS_CHANNEL } from '../internal/mcp-alias.js'
|
|
94
96
|
import { isPlanModeState, PLAN_MODE_CHANNEL } from '../internal/plan-mode-state.js'
|
|
95
97
|
import { installedPlugins } from '../internal/plugins.js'
|
|
96
98
|
import { isProjectApproved } from '../internal/project-approval.js'
|
|
97
99
|
import { repoRoot } from '../internal/project-root.js'
|
|
100
|
+
import { isSkillHooksEvent, SKILL_HOOKS_CHANNEL } from '../internal/skill-hooks.js'
|
|
98
101
|
import { isSubagentPhaseEvent, SUBAGENT_CHANNEL } from '../internal/subagent-events.js'
|
|
99
102
|
import { claudeToolInput, claudeToolName, claudeToolResponse, piToolOutput } from './claude-tools.js'
|
|
100
|
-
import { formatHooksSummary, type HookCommand, type HookMatcher, type HooksConfig, hookFiles, isBackgroundHook, loadHooks, loadPluginHooks, readAllowedHttpHookUrls, readDisableAllHooks } from './config.js'
|
|
103
|
+
import { formatHooksSummary, type HookCommand, type HookMatcher, type HooksConfig, hookFiles, isBackgroundHook, loadHooks, loadManagedHooks, loadPluginHooks, mergeSkillHooks, readAllowedHttpHookUrls, readDisableAllHooks, readSettingsDisableAllHooks } from './config.js'
|
|
101
104
|
import { blockedToolCall, jsonBlockVerdict, postToolFeedback, promptContext, runPreToolUse, runUserPromptSubmit, surfaceSystemMessages, tryParseJson } from './decisions.js'
|
|
102
105
|
import { allCommands, matchingCommands, passesIfFilter } from './matcher.js'
|
|
103
106
|
import { type HookRunner, type HookRunResult, runAgentHook, runHookCommand, runHttpHook, runMcpToolHook, runPromptHook, sessionEndTimeoutMs, timeoutMs } from './runners.js'
|
|
@@ -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
|
-
|
|
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
|
|
349
|
-
//
|
|
350
|
-
|
|
351
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
108
|
-
|
|
107
|
+
// Fill the identity onto the config entry itself rather than a clone: the runner
|
|
108
|
+
// must receive the same object collection reads, so a once-hook marked spent
|
|
109
|
+
// after a successful run is the object the next collection filters out.
|
|
110
|
+
if (typeof raw.command !== 'string') {
|
|
111
|
+
const identity = syntheticCommand(raw)
|
|
112
|
+
if (identity !== undefined) raw.command = identity
|
|
113
|
+
}
|
|
114
|
+
return raw
|
|
109
115
|
}
|
|
110
116
|
|
|
111
117
|
function collectCommands(matchers: HookMatcher[] | undefined, applies: (entry: HookMatcher) => boolean): HookCommand[] {
|
|
@@ -114,6 +120,8 @@ function collectCommands(matchers: HookMatcher[] | undefined, applies: (entry: H
|
|
|
114
120
|
for (const entry of matchers ?? []) {
|
|
115
121
|
if (!applies(entry)) continue
|
|
116
122
|
for (const raw of (entry.hooks ?? []).filter(isRunnableHook)) {
|
|
123
|
+
// A once-hook that already ran successfully is removed, as Claude documents.
|
|
124
|
+
if (raw.spent === true) continue
|
|
117
125
|
const hook = withCommand(raw)
|
|
118
126
|
// Claude runs a handler defined in more than one settings file once; a
|
|
119
127
|
// plugin's or skill's copy of the same handler stays separate, and http
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The bus channel skills.ts publishes frontmatter hooks on. Claude registers a
|
|
3
|
+
* skill's hooks when the skill is invoked and keeps them for the rest of the
|
|
4
|
+
* session; the hooks extension owns running them, so the skill side only
|
|
5
|
+
* announces the declaration.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const SKILL_HOOKS_CHANNEL = 'pi-code:skill-hooks'
|
|
9
|
+
|
|
10
|
+
export interface SkillHooksEvent {
|
|
11
|
+
skillName: string
|
|
12
|
+
hooks: Record<string, unknown>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function isSkillHooksEvent(data: unknown): data is SkillHooksEvent {
|
|
16
|
+
if (typeof data !== 'object' || data === null) return false
|
|
17
|
+
const event = data as { skillName?: unknown; hooks?: unknown }
|
|
18
|
+
return typeof event.skillName === 'string' && typeof event.hooks === 'object' && event.hooks !== null && !Array.isArray(event.hooks)
|
|
19
|
+
}
|
package/extensions/skills.ts
CHANGED
|
@@ -30,6 +30,7 @@ import { claudeConfigDir } from './internal/config-dir.js'
|
|
|
30
30
|
import { installedPlugins } from './internal/plugins.js'
|
|
31
31
|
import { isProjectApprovedSilently } from './internal/project-approval.js'
|
|
32
32
|
import { ancestorDirs } from './internal/project-root.js'
|
|
33
|
+
import { SKILL_HOOKS_CHANNEL } from './internal/skill-hooks.js'
|
|
33
34
|
|
|
34
35
|
function isDirectory(target: string): boolean {
|
|
35
36
|
try {
|
|
@@ -139,14 +140,23 @@ async function expandSkillInvocation(pi: ExtensionAPI, rawText: string, ctx: Ext
|
|
|
139
140
|
const found = findClaudeSkill(name, skillDirs(ctx.cwd, os.homedir(), trusted))
|
|
140
141
|
if (!found) return
|
|
141
142
|
let parsed: ReturnType<typeof parseCommandFile>
|
|
143
|
+
let content: string
|
|
142
144
|
try {
|
|
143
|
-
|
|
145
|
+
content = fs.readFileSync(found.filePath, 'utf-8')
|
|
146
|
+
parsed = parseCommandFile(content)
|
|
144
147
|
} catch {
|
|
145
148
|
// Unreadable, or malformed frontmatter: pass through to pi's plain expansion
|
|
146
149
|
// (the loader registered the skill and delivers the raw body), rather than
|
|
147
150
|
// failing the invocation over the dynamic features it cannot have.
|
|
148
151
|
return
|
|
149
152
|
}
|
|
153
|
+
// Claude registers hooks a skill's frontmatter declares when the skill is
|
|
154
|
+
// invoked, for the rest of the session; the hooks extension owns running them,
|
|
155
|
+
// so the declaration is announced over the shared bus.
|
|
156
|
+
const declaredHooks = parseFrontmatter<Record<string, unknown>>(content).frontmatter.hooks
|
|
157
|
+
if (declaredHooks !== null && typeof declaredHooks === 'object' && !Array.isArray(declaredHooks)) {
|
|
158
|
+
pi.events?.emit(SKILL_HOOKS_CHANNEL, { skillName: name, hooks: declaredHooks })
|
|
159
|
+
}
|
|
150
160
|
const expanded = await expandCommand(pi, parsed, args, { cwd: ctx.cwd }, found.filePath, undefined, { allowShell: !shellExecutionDisabled(ctx.cwd, os.homedir(), trusted) })
|
|
151
161
|
return { action: 'transform', text: `<skill name="${name}" location="${found.filePath}">\nReferences are relative to ${found.baseDir}.\n\n${expanded}\n</skill>` }
|
|
152
162
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-code",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.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",
|