pi-code 1.0.24 → 1.0.25
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.
- package/extensions/hooks/index.ts +25 -3
- package/extensions/hooks/runners.ts +22 -2
- package/package.json +1 -1
|
@@ -95,7 +95,7 @@ import { claudeToolInput, claudeToolName, claudeToolResponse, piToolOutput } fro
|
|
|
95
95
|
import { formatHooksSummary, type HookCommand, type HookMatcher, type HooksConfig, hookFiles, isBackgroundHook, loadHooks, loadPluginHooks, readAllowedHttpHookUrls, readDisableAllHooks } from './config.js'
|
|
96
96
|
import { blockedToolCall, jsonBlockVerdict, postToolFeedback, promptContext, runPreToolUse, runUserPromptSubmit, surfaceSystemMessages, tryParseJson } from './decisions.js'
|
|
97
97
|
import { allCommands, matchingCommands, passesIfFilter } from './matcher.js'
|
|
98
|
-
import { type HookRunner, type HookRunResult, runAgentHook, runHookCommand, runHttpHook, runMcpToolHook, runPromptHook, timeoutMs } from './runners.js'
|
|
98
|
+
import { type HookRunner, type HookRunResult, runAgentHook, runHookCommand, runHttpHook, runMcpToolHook, runPromptHook, sessionEndTimeoutMs, timeoutMs } from './runners.js'
|
|
99
99
|
|
|
100
100
|
export * from './config.js'
|
|
101
101
|
export * from './decisions.js'
|
|
@@ -185,6 +185,16 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
185
185
|
if (ctx.thinkingLevel) common.effort = { level: ctx.thinkingLevel }
|
|
186
186
|
return common
|
|
187
187
|
}
|
|
188
|
+
/** Claude's prompt-hook `model` override, resolved against the models this user
|
|
189
|
+
* can run (exact id first, then a substring match); the session model otherwise. */
|
|
190
|
+
const resolveHookModel = (ctx: ExtensionContext, override: string | undefined): ExtensionContext['model'] => {
|
|
191
|
+
if (!override) return ctx.model
|
|
192
|
+
const available = (ctx as { modelRegistry?: { getAvailable?: () => ReadonlyArray<{ id: string; name?: string }> } }).modelRegistry?.getAvailable?.() ?? []
|
|
193
|
+
const needle = override.toLowerCase()
|
|
194
|
+
const match = available.find((model) => model.id.toLowerCase() === needle) ?? available.find((model) => model.id.toLowerCase().includes(needle) || model.name?.toLowerCase().includes(needle))
|
|
195
|
+
return (match as ExtensionContext['model']) ?? ctx.model
|
|
196
|
+
}
|
|
197
|
+
|
|
188
198
|
/** Kills for background hooks still running; Claude kills async hooks at teardown,
|
|
189
199
|
* so session_shutdown reaps anything left rather than let a hung hook pin the
|
|
190
200
|
* event loop past a one-shot run's end. */
|
|
@@ -220,7 +230,7 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
220
230
|
const merged = { ...commonPayload(ctx), ...extra, ...(payload as Record<string, unknown>) }
|
|
221
231
|
const dispatch = (onChild?: (kill: () => void) => void): Promise<HookRunResult> => {
|
|
222
232
|
if (hook.type === 'http') return runHttpHook(hook, merged, ms, allowedHttpHookUrls)
|
|
223
|
-
if (hook.type === 'prompt') return runPromptHook(hook, merged, ctx.model, ms)
|
|
233
|
+
if (hook.type === 'prompt') return runPromptHook(hook, merged, resolveHookModel(ctx, hook.model), ms)
|
|
224
234
|
if (hook.type === 'agent') return runAgentHook(hook, merged, ms, (ctx.model as { id?: string } | undefined)?.id)
|
|
225
235
|
if (hook.type === 'mcp_tool') return runMcpToolHook(hook, merged, ms)
|
|
226
236
|
return runHookCommand(hook.command, merged, ms, projectDir, hook.args, onChild)
|
|
@@ -483,6 +493,14 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
483
493
|
stopHookActive = false
|
|
484
494
|
return
|
|
485
495
|
}
|
|
496
|
+
// Claude: Stop does not run when the stoppage was a user interrupt; pi marks
|
|
497
|
+
// the aborted turn's final assistant message stopReason "aborted".
|
|
498
|
+
const turnMessages = (event as { messages?: Array<{ role: string; stopReason?: string }> }).messages ?? []
|
|
499
|
+
const lastAssistant = [...turnMessages].reverse().find((message) => message.role === 'assistant')
|
|
500
|
+
if (lastAssistant?.stopReason === 'aborted') {
|
|
501
|
+
stopHookActive = false
|
|
502
|
+
return
|
|
503
|
+
}
|
|
486
504
|
// Claude's Stop payload carries the turn's final assistant text so a hook need
|
|
487
505
|
// not re-read the transcript; included only when there is one.
|
|
488
506
|
const lastText = lastAssistantText((event as { messages?: Array<{ role: string; content: unknown }> }).messages ?? [])
|
|
@@ -543,7 +561,11 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
543
561
|
|
|
544
562
|
pi.on('session_shutdown', async (event, ctx) => {
|
|
545
563
|
const reason = claudeSpelling(SESSION_END_REASON, event.reason)
|
|
546
|
-
|
|
564
|
+
// SessionEnd rides Claude's short shared budget (see sessionEndTimeoutMs) so a
|
|
565
|
+
// slow hook cannot stall session exit, /new or /resume.
|
|
566
|
+
const sessionEndCommands = matchingCommands(config.SessionEnd, reason.names).filter((command) => passesIfFilter(command, undefined))
|
|
567
|
+
const runner = boundRunner(ctx)
|
|
568
|
+
const results = await Promise.all(sessionEndCommands.map((command) => runner(command, { hook_event_name: 'SessionEnd', reason: reason.value }, sessionEndTimeoutMs(command))))
|
|
547
569
|
surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
|
|
548
570
|
// Claude kills async hooks still running at teardown; the session that spawned
|
|
549
571
|
// these is over, and their delivery would target a disposed context anyway.
|
|
@@ -20,6 +20,12 @@ import { type HookCommand, httpUrlAllowed, isBackgroundHook } from './config.js'
|
|
|
20
20
|
// raise their own per-hook `timeout`.
|
|
21
21
|
const DEFAULT_TIMEOUT_S = 60
|
|
22
22
|
|
|
23
|
+
/** Claude's per-type defaults where they are safe to mirror: 30s for `prompt`
|
|
24
|
+
* hooks and 60s for `agent` hooks. Command/http/mcp_tool keep the flat 60s
|
|
25
|
+
* documented divergence from Claude's 600 (a gated hook fails closed here, so ten
|
|
26
|
+
* minutes of default budget would wedge the turn). */
|
|
27
|
+
const TYPE_DEFAULT_TIMEOUT_S: Record<string, number> = { prompt: 30, agent: 60 }
|
|
28
|
+
|
|
23
29
|
export interface HookRunResult {
|
|
24
30
|
code: number
|
|
25
31
|
stdout: string
|
|
@@ -49,10 +55,20 @@ export function timeoutMs(command: HookCommand): number {
|
|
|
49
55
|
// Non-positive values fall back to the default: a 0ms timer would fire before the
|
|
50
56
|
// hook runs, and a timed-out PreToolUse hook fails closed, bricking the tool.
|
|
51
57
|
const declared = command.timeout
|
|
52
|
-
const
|
|
58
|
+
const fallback = TYPE_DEFAULT_TIMEOUT_S[command.type ?? 'command'] ?? DEFAULT_TIMEOUT_S
|
|
59
|
+
const seconds = typeof declared === 'number' && declared > 0 ? Math.min(declared, MAX_TIMEOUT_S) : fallback
|
|
53
60
|
return seconds * 1000
|
|
54
61
|
}
|
|
55
62
|
|
|
63
|
+
/** Claude's SessionEnd budget: hooks share 1.5 seconds so session exit (and /new,
|
|
64
|
+
* /resume) cannot stall on a slow hook; a declared per-hook `timeout` raises the
|
|
65
|
+
* budget to match, up to 60 seconds. */
|
|
66
|
+
export function sessionEndTimeoutMs(command: HookCommand): number {
|
|
67
|
+
const declared = command.timeout
|
|
68
|
+
if (typeof declared === 'number' && declared > 0) return Math.min(declared, 60) * 1000
|
|
69
|
+
return 1500
|
|
70
|
+
}
|
|
71
|
+
|
|
56
72
|
/** Memory backstop for a runaway hook. A decision payload is orders of magnitude smaller. */
|
|
57
73
|
const MAX_HOOK_OUTPUT = 1_000_000
|
|
58
74
|
|
|
@@ -217,7 +233,11 @@ export async function runPromptHook(hook: HookCommand, payload: unknown, model:
|
|
|
217
233
|
if (!model) return { code: 1, stdout: '', stderr: 'no model available for prompt hook', timedOut: false }
|
|
218
234
|
// A replacer function, so `$$`/`$&`/`` $` ``/`$'` inside the payload JSON are inserted
|
|
219
235
|
// verbatim rather than read as replacement patterns (a Bash `echo $$` is a common trigger).
|
|
220
|
-
|
|
236
|
+
// Claude: when $ARGUMENTS is not present, the input JSON is appended to the
|
|
237
|
+
// prompt, so the model never evaluates blind.
|
|
238
|
+
const template = hook.prompt ?? ''
|
|
239
|
+
const withInput = template.includes('$ARGUMENTS') ? template : `${template}\n\n$ARGUMENTS`
|
|
240
|
+
const prompt = substituteArguments(withInput, payload)
|
|
221
241
|
const signal = AbortSignal.timeout(timeoutMs)
|
|
222
242
|
try {
|
|
223
243
|
const { text: answer } = await completeText(model, prompt, { system: PROMPT_HOOK_SYSTEM, maxTokens: 512, signal })
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-code",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.25",
|
|
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",
|