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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|