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
- function mergeHooksJson(config: HooksConfig, raw: string, source: string, sources?: Map<HookMatcher, string>): void {
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 (idle_prompt). Observational only;
483
- // exit codes and JSON output are ignored, as Claude documents for this event.
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
- void runNotifyHooks(notifyCommands, { hook_event_name: 'Notification', notification_type: 'idle_prompt', message: 'pi is waiting for your input' }, boundRunner(ctx)).catch(() => {})
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
- if (seen.has(hook.command)) continue
120
- seen.add(hook.command)
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
- 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)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-code",
3
- "version": "1.0.28",
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",