pi-code 1.0.30 → 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.
@@ -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
@@ -187,6 +187,9 @@ export default function hooksExtension(pi: ExtensionAPI) {
187
187
  /** Consecutive Stop-hook blocks with no user progress between them. Reset on user input
188
188
  * and on a non-blocking Stop; at the cap the continuation is suppressed and the turn ends. */
189
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>()
190
193
  /** The pending idle_prompt notification: Claude fires it when the turn ended about
191
194
  * 60 seconds ago and the user hasn't typed since, so it arms on agent_end and is
192
195
  * canceled by input or the next turn. */
@@ -417,13 +420,18 @@ export default function hooksExtension(pi: ExtensionAPI) {
417
420
  // additionalContext is delivered alongside the tool result, so stash it for
418
421
  // this call's tool_result to append.
419
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())
420
425
  return undefined
421
426
  }
422
427
  // Claude's "ask": prompt the user and let the call through if they approve.
423
428
  // With no UI (headless) the block stands, which is the safe default.
424
429
  if (decision.ask && ctx.hasUI) {
425
430
  const approved = await ctx.ui.confirm(`Allow ${event.toolName}?`, decision.reason ?? 'A hook asks you to confirm this tool call.')
426
- 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
427
435
  }
428
436
  return blockedToolCall(decision.reason)
429
437
  })
@@ -451,7 +459,9 @@ export default function hooksExtension(pi: ExtensionAPI) {
451
459
  if (commands.length === 0 && pending.length === 0) return
452
460
  const translatedInput = alias === undefined ? claudeToolInput(event.toolName, event.input, ctx.cwd) : undefined
453
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 }
454
- 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 }) }
455
465
  const run = boundRunner(ctx, { tool_use_id: event.toolCallId })
456
466
  const results = await Promise.all(commands.map((command) => run(command, payload, timeoutMs(command))))
457
467
  surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
@@ -515,8 +525,10 @@ export default function hooksExtension(pi: ExtensionAPI) {
515
525
  return { action: 'handled' }
516
526
  }
517
527
  // Claude injects a UserPromptSubmit hook's context ahead of the prompt; transform is
518
- // pi's seam for rewriting the submitted text.
519
- 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}` }
520
532
  return { action: 'continue' }
521
533
  })
522
534
 
@@ -609,13 +621,19 @@ export default function hooksExtension(pi: ExtensionAPI) {
609
621
 
610
622
  pi.on('session_before_compact', async (event, ctx) => {
611
623
  const trigger = claudeSpelling(PRECOMPACT_TRIGGER, event.reason)
612
- 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))
613
628
  surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
614
629
  })
615
630
 
616
631
  pi.on('session_compact', async (event, ctx) => {
617
632
  const trigger = claudeSpelling(PRECOMPACT_TRIGGER, event.reason)
618
- 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))
619
637
  surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
620
638
  // Claude also fires SessionStart with source "compact" when the session
621
639
  // continues after compaction; its stdout context rides the next agent start,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-code",
3
- "version": "1.0.30",
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",