pi-code 1.0.28 → 1.0.29
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,9 @@ 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
|
|
44
47
|
}
|
|
45
48
|
export interface HookMatcher {
|
|
46
49
|
matcher?: string
|
|
@@ -135,7 +138,15 @@ export function loadHooks(files: string[], sources?: Map<HookMatcher, string>):
|
|
|
135
138
|
return config
|
|
136
139
|
}
|
|
137
140
|
|
|
138
|
-
|
|
141
|
+
/** Claude dedups identical handlers across settings files only; a plugin's copy
|
|
142
|
+
* stays separate, so plugin entries carry their origin into the dedup key. */
|
|
143
|
+
function stampOrigin(entries: HookMatcher[], origin: string): void {
|
|
144
|
+
for (const entry of entries) {
|
|
145
|
+
for (const hook of entry.hooks ?? []) hook.origin = origin
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function mergeHooksJson(config: HooksConfig, raw: string, source: string, sources?: Map<HookMatcher, string>, origin?: string): void {
|
|
139
150
|
let parsed: { hooks?: HooksConfig }
|
|
140
151
|
try {
|
|
141
152
|
parsed = JSON.parse(raw)
|
|
@@ -150,6 +161,7 @@ function mergeHooksJson(config: HooksConfig, raw: string, source: string, source
|
|
|
150
161
|
// call for the rest of the session failed with an opaque type error.
|
|
151
162
|
const usable = matchers.filter((entry) => isUsableMatcher(entry, source, event))
|
|
152
163
|
if (usable.length === 0) continue
|
|
164
|
+
if (origin !== undefined) stampOrigin(usable, origin)
|
|
153
165
|
config[event] = [...(config[event] ?? []), ...usable]
|
|
154
166
|
// Each parse produces fresh entry objects, so object identity keys the /hooks
|
|
155
167
|
// viewer's source attribution without touching the entries themselves.
|
|
@@ -167,12 +179,12 @@ export function loadPluginHooks(config: HooksConfig, plugins: InstalledPlugin[],
|
|
|
167
179
|
// numeric event keys), so it falls through to the default path rather than
|
|
168
180
|
// silently registering nothing.
|
|
169
181
|
if (declared !== null && typeof declared === 'object' && !Array.isArray(declared)) {
|
|
170
|
-
mergeHooksJson(config, substitutePluginVars(JSON.stringify({ hooks: declared }), plugin), `${plugin.name} (plugin.json)`, sources)
|
|
182
|
+
mergeHooksJson(config, substitutePluginVars(JSON.stringify({ hooks: declared }), plugin), `${plugin.name} (plugin.json)`, sources, `plugin:${plugin.name}`)
|
|
171
183
|
continue
|
|
172
184
|
}
|
|
173
185
|
const file = path.resolve(plugin.root, typeof declared === 'string' ? declared : path.join('hooks', 'hooks.json'))
|
|
174
186
|
try {
|
|
175
|
-
mergeHooksJson(config, substitutePluginVars(fs.readFileSync(file, 'utf-8'), plugin), file, sources)
|
|
187
|
+
mergeHooksJson(config, substitutePluginVars(fs.readFileSync(file, 'utf-8'), plugin), file, sources, `plugin:${plugin.name}`)
|
|
176
188
|
} catch {
|
|
177
189
|
// a plugin without hooks contributes nothing
|
|
178
190
|
}
|
|
@@ -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)
|
|
@@ -160,6 +165,15 @@ function claudeSpelling(map: Record<string, string>, raw: string): { names: stri
|
|
|
160
165
|
return { names: value === raw ? [raw] : [raw, value], value }
|
|
161
166
|
}
|
|
162
167
|
|
|
168
|
+
/** Claude: idle_prompt fires when "Claude finished responding about 60 seconds ago
|
|
169
|
+
* and you haven't typed since". */
|
|
170
|
+
const IDLE_PROMPT_DELAY_MS = 60_000
|
|
171
|
+
|
|
172
|
+
/** pi's model_select sources in Claude's PostModelSwitch vocabulary: an explicit
|
|
173
|
+
* set is a command-style request, cycling is the picker, and restore is the model
|
|
174
|
+
* Claude Code restores on resume. */
|
|
175
|
+
const MODEL_SELECT_SOURCE: Record<string, string> = { set: 'command', cycle: 'picker', restore: 'resume' }
|
|
176
|
+
|
|
163
177
|
export default function hooksExtension(pi: ExtensionAPI) {
|
|
164
178
|
let config: HooksConfig = {}
|
|
165
179
|
let projectDir = ''
|
|
@@ -170,6 +184,14 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
170
184
|
/** Consecutive Stop-hook blocks with no user progress between them. Reset on user input
|
|
171
185
|
* and on a non-blocking Stop; at the cap the continuation is suppressed and the turn ends. */
|
|
172
186
|
let stopHookBlockCount = 0
|
|
187
|
+
/** The pending idle_prompt notification: Claude fires it when the turn ended about
|
|
188
|
+
* 60 seconds ago and the user hasn't typed since, so it arms on agent_end and is
|
|
189
|
+
* canceled by input or the next turn. */
|
|
190
|
+
let idlePromptTimer: ReturnType<typeof setTimeout> | undefined
|
|
191
|
+
const cancelIdlePrompt = (): void => {
|
|
192
|
+
clearTimeout(idlePromptTimer)
|
|
193
|
+
idlePromptTimer = undefined
|
|
194
|
+
}
|
|
173
195
|
let sessionCtx: ExtensionContext | undefined
|
|
174
196
|
/** Claude's disableAllHooks escape hatch was set somewhere in the honored chain. */
|
|
175
197
|
let hooksDisabled = false
|
|
@@ -353,6 +375,8 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
353
375
|
// over the bus from context-imports, which owns claudeMdExcludes; announcing
|
|
354
376
|
// the raw contextFiles here would fire for a file the exclusion removed.
|
|
355
377
|
pi.on('before_agent_start', async () => {
|
|
378
|
+
// A new turn is beginning, so the session is no longer idle.
|
|
379
|
+
cancelIdlePrompt()
|
|
356
380
|
if (pendingSessionContext.length === 0) return
|
|
357
381
|
const content = pendingSessionContext.join('\n')
|
|
358
382
|
pendingSessionContext = []
|
|
@@ -452,6 +476,8 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
452
476
|
// Only genuine user input; extension-injected messages (plan-mode, subagent) are not
|
|
453
477
|
// prompts the user submitted.
|
|
454
478
|
if (event.source === 'extension') return { action: 'continue' }
|
|
479
|
+
// The user typed, so the pending idle_prompt no longer applies.
|
|
480
|
+
cancelIdlePrompt()
|
|
455
481
|
// Genuine user input is progress, so it breaks a Stop-hook continuation streak: the
|
|
456
482
|
// block cap counts only consecutive blocks with nothing from the user in between.
|
|
457
483
|
stopHookBlockCount = 0
|
|
@@ -479,11 +505,18 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
479
505
|
// automatic retry or compaction; that is the better tradeoff.
|
|
480
506
|
pi.on('agent_end', async (event, ctx) => {
|
|
481
507
|
// Claude's Notification event, for the one type pi can honestly source: the
|
|
482
|
-
// agent finished and is waiting for input
|
|
483
|
-
//
|
|
508
|
+
// agent finished and is waiting for input. Per Claude, idle_prompt fires when
|
|
509
|
+
// the turn ended about 60 seconds ago and the user hasn't typed since, so it
|
|
510
|
+
// arms here and input or the next turn cancels it. Observational only; exit
|
|
511
|
+
// codes and JSON output are ignored, as Claude documents for this event.
|
|
512
|
+
cancelIdlePrompt()
|
|
484
513
|
const notifyCommands = matchingCommands(config.Notification, ['idle_prompt'])
|
|
485
514
|
if (notifyCommands.length > 0) {
|
|
486
|
-
|
|
515
|
+
const runner = boundRunner(ctx)
|
|
516
|
+
idlePromptTimer = setTimeout(() => {
|
|
517
|
+
void runNotifyHooks(notifyCommands, { hook_event_name: 'Notification', notification_type: 'idle_prompt', message: 'pi is waiting for your input' }, runner).catch(() => {})
|
|
518
|
+
}, IDLE_PROMPT_DELAY_MS)
|
|
519
|
+
idlePromptTimer.unref?.()
|
|
487
520
|
}
|
|
488
521
|
|
|
489
522
|
// Stop has no matcher support (a stray matcher is ignored, as Claude documents)
|
|
@@ -557,6 +590,33 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
557
590
|
const trigger = claudeSpelling(PRECOMPACT_TRIGGER, event.reason)
|
|
558
591
|
const results = await runNotifyHooks(matchingCommands(config.PostCompact, trigger.names), { hook_event_name: 'PostCompact', trigger: trigger.value }, boundRunner(ctx))
|
|
559
592
|
surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
|
|
593
|
+
// Claude also fires SessionStart with source "compact" when the session
|
|
594
|
+
// continues after compaction; its stdout context rides the next agent start,
|
|
595
|
+
// the same as any other SessionStart context.
|
|
596
|
+
const sessionStart = matchingCommands(config.SessionStart, 'compact')
|
|
597
|
+
if (sessionStart.length > 0) {
|
|
598
|
+
const startResults = await runNotifyHooks(sessionStart, { hook_event_name: 'SessionStart', source: 'compact' }, boundRunner(ctx))
|
|
599
|
+
surfaceSystemMessages(startResults, (message) => ctx.ui.notify(message, 'warning'))
|
|
600
|
+
pendingSessionContext.push(...startResults.map((result) => promptContext(result.stdout)).filter(Boolean))
|
|
601
|
+
}
|
|
602
|
+
})
|
|
603
|
+
|
|
604
|
+
pi.on('model_select', async (event, ctx) => {
|
|
605
|
+
// Claude's PostModelSwitch: runs after the session's model changes, matched
|
|
606
|
+
// against the model switched to; it can't block. PreModelSwitch stays
|
|
607
|
+
// unbridged: pi's model_select has no veto seam, and a "Pre" hook whose block
|
|
608
|
+
// decision is silently ignored would be worse than an absent event.
|
|
609
|
+
const { model, previousModel, source } = event as { model: { id: string }; previousModel?: { id: string }; source: string }
|
|
610
|
+
if (!previousModel || previousModel.id === model.id) return
|
|
611
|
+
const commands = matchingCommands(config.PostModelSwitch, model.id)
|
|
612
|
+
if (commands.length === 0) return
|
|
613
|
+
// requested_model is null: pi does not carry the alias the request named.
|
|
614
|
+
const payload = { hook_event_name: 'PostModelSwitch', from_model: previousModel.id, to_model: model.id, requested_model: null, source: MODEL_SELECT_SOURCE[source] ?? 'command' }
|
|
615
|
+
const results = await runNotifyHooks(commands, payload, boundRunner(ctx))
|
|
616
|
+
surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
|
|
617
|
+
// Claude delivers the hook's stdout (or additionalContext) to Claude with the
|
|
618
|
+
// next request after the switch; pi's seam for that is the next agent start.
|
|
619
|
+
pendingSessionContext.push(...results.map((result) => promptContext(result.stdout)).filter(Boolean))
|
|
560
620
|
})
|
|
561
621
|
|
|
562
622
|
pi.on('session_shutdown', async (event, ctx) => {
|
|
@@ -115,9 +115,12 @@ function collectCommands(matchers: HookMatcher[] | undefined, applies: (entry: H
|
|
|
115
115
|
if (!applies(entry)) continue
|
|
116
116
|
for (const raw of (entry.hooks ?? []).filter(isRunnableHook)) {
|
|
117
117
|
const hook = withCommand(raw)
|
|
118
|
-
// Claude runs a handler defined in more than one settings file once
|
|
119
|
-
|
|
120
|
-
|
|
118
|
+
// Claude runs a handler defined in more than one settings file once; a
|
|
119
|
+
// plugin's or skill's copy of the same handler stays separate, and http
|
|
120
|
+
// handlers with the same URL but different headers are distinct.
|
|
121
|
+
const key = `${hook.origin ?? 'settings'}\n${hook.command}\n${hook.headers ? JSON.stringify(hook.headers) : ''}`
|
|
122
|
+
if (seen.has(key)) continue
|
|
123
|
+
seen.add(key)
|
|
121
124
|
result.push(hook)
|
|
122
125
|
}
|
|
123
126
|
}
|
|
@@ -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)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-code",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.29",
|
|
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",
|