pi-code 0.5.0 → 0.6.0
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/README.md
CHANGED
|
@@ -38,7 +38,7 @@ One `pi install` and everything below loads on the next start. `pi list` shows w
|
|
|
38
38
|
| Global + project rules | `~/.claude/rules`, `.claude/rules` (+ `paths:` frontmatter scoping) | `claude-rules.ts` |
|
|
39
39
|
| Custom slash commands | `.claude/commands/*.md` → pi prompt templates | `commands.ts` |
|
|
40
40
|
| Skills | `.claude/skills` → pi skill discovery (pi reads `name`, `description`, `disable-model-invocation`; `allowed-tools` is inert in pi's loader) | `skills.ts` |
|
|
41
|
-
| Hooks | `.claude/settings.json` hooks: PreToolUse, PostToolUse, SessionStart, UserPromptSubmit (blocks and injects context), Stop, PreCompact, SessionEnd | `hooks.ts` |
|
|
41
|
+
| Hooks | `.claude/settings.json` hooks: PreToolUse (blocks, rewrites input via `updatedInput`), PostToolUse (feedback and `additionalContext` land next to the tool result), PostToolUseFailure, SessionStart (context injection), UserPromptSubmit (blocks and injects context), Stop (a block continues the conversation), SubagentStart/SubagentStop, PreCompact, PostCompact, SessionEnd; Claude matcher semantics incl. `mcp__server__tool` names; payloads carry session_id, transcript_path, cwd, permission_mode, effort | `hooks.ts` |
|
|
42
42
|
| Output styles | `.claude/output-styles` + active `outputStyle`, `/output-style` switcher | `output-styles.ts` |
|
|
43
43
|
| CLAUDE.md `@imports` | resolves `@path` imports pi's native loader skips; loads `CLAUDE.local.md` (approval-gated) | `context-imports.ts` |
|
|
44
44
|
| MCP servers | user `~/.claude.json` (incl. per-project `projects[cwd]` local scope), `~/.pi/agent/mcp.json`; project `.mcp.json`, `.pi/mcp.json` (once approved); stdio/HTTP/SSE by `type`; `${VAR:-default}` expansion; `MCP_TIMEOUT`/`MCP_TOOL_TIMEOUT` | `mcp.ts` |
|
package/extensions/hooks.ts
CHANGED
|
@@ -3,17 +3,30 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Runs Claude Code's `.claude/settings.json` hooks on pi's lifecycle events, so
|
|
5
5
|
* a project's existing hooks work under pi:
|
|
6
|
-
* - PreToolUse -> pi `tool_call` (can block the tool)
|
|
7
|
-
* - PostToolUse -> pi `
|
|
8
|
-
*
|
|
6
|
+
* - PreToolUse -> pi `tool_call` (can block the tool or rewrite its input)
|
|
7
|
+
* - PostToolUse -> pi `tool_result` (block reasons and additionalContext are
|
|
8
|
+
* appended next to the tool result, as Claude documents)
|
|
9
|
+
* - SessionStart -> pi `session_start` (stdout/additionalContext is injected as
|
|
10
|
+
* context before the first prompt via `before_agent_start`)
|
|
9
11
|
* - UserPromptSubmit-> pi `input` (can block the prompt via `handled`, or inject
|
|
10
12
|
* additional context by transforming the submitted text)
|
|
11
|
-
* - Stop -> pi `agent_end` (
|
|
13
|
+
* - Stop -> pi `agent_end` (a block feeds its reason back as a new turn,
|
|
14
|
+
* with stop_hook_active as the loop guard)
|
|
12
15
|
* - PreCompact -> pi `session_before_compact` (fire-and-forget)
|
|
16
|
+
* - PostCompact -> pi `session_compact` (fire-and-forget)
|
|
17
|
+
* - PostToolUseFailure -> pi `tool_result` error branch (fire-and-forget)
|
|
13
18
|
* - SessionEnd -> pi `session_shutdown` (fire-and-forget)
|
|
14
19
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
20
|
+
* Every payload carries session_id, transcript_path (pi's session file), cwd,
|
|
21
|
+
* permission_mode (plan-mode state off the shared bus) and effort; tool events add
|
|
22
|
+
* tool_use_id. Every event honors the universal `systemMessage` output (a
|
|
23
|
+
* user-facing warning).
|
|
24
|
+
* `suppressOutput` is accepted and inert: pi never echoes hook stdout to the
|
|
25
|
+
* transcript in the first place.
|
|
26
|
+
*
|
|
27
|
+
* SubagentStart/SubagentStop ride pi-code's own subagent extension, which publishes
|
|
28
|
+
* child-run lifecycle on the shared bus (notify-style: a child has already exited by
|
|
29
|
+
* the time SubagentStop fires, so its exit-2 block semantics cannot be honored).
|
|
17
30
|
*
|
|
18
31
|
* Hook commands run via `sh -c` with the event JSON on stdin. A PreToolUse
|
|
19
32
|
* hook blocks the tool by exiting 2 (stderr becomes the reason) or by printing
|
|
@@ -35,10 +48,12 @@ import { type ChildProcess, spawn } from 'node:child_process'
|
|
|
35
48
|
import * as fs from 'node:fs'
|
|
36
49
|
import * as os from 'node:os'
|
|
37
50
|
import * as path from 'node:path'
|
|
38
|
-
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
51
|
+
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
|
|
39
52
|
|
|
40
53
|
import { isMcpToolAliases, MCP_TOOLS_CHANNEL } from './internal/mcp-alias.js'
|
|
54
|
+
import { isPlanModeState, PLAN_MODE_CHANNEL } from './internal/plan-mode-state.js'
|
|
41
55
|
import { isProjectApproved } from './internal/project-approval.js'
|
|
56
|
+
import { isSubagentPhaseEvent, SUBAGENT_CHANNEL } from './internal/subagent-events.js'
|
|
42
57
|
|
|
43
58
|
const DEFAULT_TIMEOUT_S = 60
|
|
44
59
|
|
|
@@ -144,7 +159,7 @@ export function matchingCommands(matchers: HookMatcher[] | undefined, names: str
|
|
|
144
159
|
return result
|
|
145
160
|
}
|
|
146
161
|
|
|
147
|
-
function tryParseJson(text: string): { hookSpecificOutput?: { permissionDecision?: string; permissionDecisionReason?: string; additionalContext?: string }; decision?: string; reason?: string; continue?: boolean; stopReason?: string } | undefined {
|
|
162
|
+
function tryParseJson(text: string): { hookSpecificOutput?: { permissionDecision?: string; permissionDecisionReason?: string; additionalContext?: string; updatedInput?: unknown }; decision?: string; reason?: string; continue?: boolean; stopReason?: string; systemMessage?: string } | undefined {
|
|
148
163
|
try {
|
|
149
164
|
return JSON.parse(text)
|
|
150
165
|
} catch {
|
|
@@ -242,24 +257,50 @@ function timeoutMs(command: HookCommand): number {
|
|
|
242
257
|
return seconds * 1000
|
|
243
258
|
}
|
|
244
259
|
|
|
260
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
261
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Claude's updatedInput replaces the whole tool_input, and pi's tool_call contract is
|
|
265
|
+
* in-place mutation, so the target object is emptied and refilled rather than reassigned. */
|
|
266
|
+
function replaceRecord(target: Record<string, unknown>, next: Record<string, unknown>): void {
|
|
267
|
+
for (const key of Object.keys(target)) delete target[key]
|
|
268
|
+
Object.assign(target, next)
|
|
269
|
+
}
|
|
270
|
+
|
|
245
271
|
/** Run PreToolUse hooks for a tool; the first blocking verdict wins. For MCP tools the
|
|
246
272
|
* matcher sees both the pi name and the Claude alias, and the payload reports the alias,
|
|
247
|
-
* which is the name a Claude-written hook script expects in tool_name.
|
|
248
|
-
|
|
273
|
+
* which is the name a Claude-written hook script expects in tool_name. A hook's
|
|
274
|
+
* hookSpecificOutput.updatedInput replaces the tool input in place before the permission
|
|
275
|
+
* decision applies, and later hooks see the rewritten input in their payload. */
|
|
276
|
+
export async function runPreToolUse(config: HooksConfig, toolName: string, toolInput: unknown, runner: HookRunner, claudeName?: string, onSystemMessage?: SystemMessageSink): Promise<HookDecision> {
|
|
249
277
|
const names = claudeName ? [toolName, claudeName] : [toolName]
|
|
250
278
|
for (const command of matchingCommands(config.PreToolUse, names)) {
|
|
251
279
|
const result = await runner(command.command, { hook_event_name: 'PreToolUse', tool_name: claudeName ?? toolName, tool_input: toolInput }, timeoutMs(command))
|
|
252
280
|
// A killed hook never reached its verdict, and SIGKILL leaves a null exit code that
|
|
253
281
|
// would otherwise read as a clean allow. Fail closed instead.
|
|
254
282
|
if (result.timedOut) return { block: true, reason: `Hook timed out after ${timeoutMs(command)}ms: ${command.command}` }
|
|
283
|
+
if (onSystemMessage) surfaceSystemMessages([result], onSystemMessage)
|
|
284
|
+
const updated = tryParseJson(result.stdout)?.hookSpecificOutput?.updatedInput
|
|
285
|
+
if (isRecord(updated) && isRecord(toolInput)) replaceRecord(toolInput, updated)
|
|
255
286
|
const decision = interpretHookResult(result.code, result.stdout, result.stderr)
|
|
256
287
|
if (decision.block) return decision
|
|
257
288
|
}
|
|
258
289
|
return { block: false }
|
|
259
290
|
}
|
|
260
291
|
|
|
261
|
-
async function runNotifyHooks(commands: HookCommand[], payload: unknown, runner: HookRunner): Promise<
|
|
262
|
-
await Promise.all(commands.map((command) => runner(command.command, payload, timeoutMs(command))))
|
|
292
|
+
async function runNotifyHooks(commands: HookCommand[], payload: unknown, runner: HookRunner): Promise<HookRunResult[]> {
|
|
293
|
+
return await Promise.all(commands.map((command) => runner(command.command, payload, timeoutMs(command))))
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
type SystemMessageSink = (message: string) => void
|
|
297
|
+
|
|
298
|
+
/** Claude's universal systemMessage output field: a warning surfaced to the user. */
|
|
299
|
+
function surfaceSystemMessages(results: HookRunResult[], notify: SystemMessageSink): void {
|
|
300
|
+
for (const result of results) {
|
|
301
|
+
const message = tryParseJson(result.stdout)?.systemMessage
|
|
302
|
+
if (message) notify(message)
|
|
303
|
+
}
|
|
263
304
|
}
|
|
264
305
|
|
|
265
306
|
export interface PromptDecision {
|
|
@@ -278,11 +319,12 @@ function promptContext(stdout: string): string {
|
|
|
278
319
|
|
|
279
320
|
/** Run UserPromptSubmit hooks: the first blocking verdict wins; otherwise their
|
|
280
321
|
* additional context is concatenated for injection ahead of the prompt. */
|
|
281
|
-
export async function runUserPromptSubmit(config: HooksConfig, prompt: string, runner: HookRunner): Promise<PromptDecision> {
|
|
322
|
+
export async function runUserPromptSubmit(config: HooksConfig, prompt: string, runner: HookRunner, onSystemMessage?: SystemMessageSink): Promise<PromptDecision> {
|
|
282
323
|
const contexts: string[] = []
|
|
283
324
|
for (const command of matchingCommands(config.UserPromptSubmit, 'UserPromptSubmit')) {
|
|
284
325
|
const result = await runner(command.command, { hook_event_name: 'UserPromptSubmit', prompt }, timeoutMs(command))
|
|
285
326
|
if (result.timedOut) return { block: true, reason: `Hook timed out after ${timeoutMs(command)}ms: ${command.command}`, context: '' }
|
|
327
|
+
if (onSystemMessage) surfaceSystemMessages([result], onSystemMessage)
|
|
286
328
|
const decision = interpretHookResult(result.code, result.stdout, result.stderr)
|
|
287
329
|
if (decision.block) return { block: true, reason: decision.reason, context: '' }
|
|
288
330
|
const context = promptContext(result.stdout)
|
|
@@ -291,9 +333,6 @@ export async function runUserPromptSubmit(config: HooksConfig, prompt: string, r
|
|
|
291
333
|
return { block: false, context: contexts.join('\n') }
|
|
292
334
|
}
|
|
293
335
|
|
|
294
|
-
/** Bound on remembered tool inputs, in case a blocked or aborted call never ends. */
|
|
295
|
-
const MAX_PENDING_INPUTS = 100
|
|
296
|
-
|
|
297
336
|
/** pi's lifecycle vocabularies differ from Claude's documented ones. The matcher is
|
|
298
337
|
* offered both spellings so existing configs keep firing either way, and the payload
|
|
299
338
|
* reports the Claude value, which is what a Claude-written hook script parses. */
|
|
@@ -310,10 +349,22 @@ function claudeSpelling(map: Record<string, string>, raw: string): { names: stri
|
|
|
310
349
|
export default function hooksExtension(pi: ExtensionAPI) {
|
|
311
350
|
let config: HooksConfig = {}
|
|
312
351
|
let projectDir = ''
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
352
|
+
let pendingSessionContext: string[] = []
|
|
353
|
+
let stopHookActive = false
|
|
354
|
+
let sessionCtx: ExtensionContext | undefined
|
|
355
|
+
/** Claude sends session_id, transcript_path, cwd and effort on every payload. */
|
|
356
|
+
const commonPayload = (ctx: ExtensionContext): Record<string, unknown> => {
|
|
357
|
+
const common: Record<string, unknown> = { session_id: ctx.sessionManager.getSessionId(), cwd: ctx.cwd, permission_mode: permissionMode }
|
|
358
|
+
const transcript = ctx.sessionManager.getSessionFile()
|
|
359
|
+
if (transcript) common.transcript_path = transcript
|
|
360
|
+
if (ctx.thinkingLevel) common.effort = { level: ctx.thinkingLevel }
|
|
361
|
+
return common
|
|
362
|
+
}
|
|
363
|
+
/** A runner bound to the firing context, filling the common fields into each stdin. */
|
|
364
|
+
const boundRunner =
|
|
365
|
+
(ctx: ExtensionContext, extra?: Record<string, unknown>): HookRunner =>
|
|
366
|
+
(command, payload, ms) =>
|
|
367
|
+
runHookCommand(command, { ...commonPayload(ctx), ...extra, ...(payload as Record<string, unknown>) }, ms, projectDir)
|
|
317
368
|
// Claude matchers name MCP tools mcp__<server>__<tool>; pi-code registers them as
|
|
318
369
|
// <server>_<tool>. The mcp extension publishes the mapping on pi's shared bus.
|
|
319
370
|
const mcpAliases = new Map<string, string>()
|
|
@@ -322,8 +373,25 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
322
373
|
mcpAliases.clear()
|
|
323
374
|
for (const entry of data) mcpAliases.set(entry.pi, entry.claude)
|
|
324
375
|
})
|
|
376
|
+
// Claude's permission_mode: pi has no permission system, but pi-code's plan mode is
|
|
377
|
+
// the documented "plan" mode; its extension publishes the state on the shared bus.
|
|
378
|
+
let permissionMode = 'default'
|
|
379
|
+
pi.events.on(PLAN_MODE_CHANNEL, (data) => {
|
|
380
|
+
if (isPlanModeState(data)) permissionMode = data.active ? 'plan' : 'default'
|
|
381
|
+
})
|
|
382
|
+
// Subagent lifecycle arrives over the bus without a pi context; the session context
|
|
383
|
+
// captured at session_start supplies the common payload fields.
|
|
384
|
+
pi.events.on(SUBAGENT_CHANNEL, async (data) => {
|
|
385
|
+
if (!isSubagentPhaseEvent(data) || !sessionCtx) return
|
|
386
|
+
const ctx = sessionCtx
|
|
387
|
+
const eventName = data.phase === 'start' ? 'SubagentStart' : 'SubagentStop'
|
|
388
|
+
const payload = { hook_event_name: eventName, agent_type: data.agentType, agent_id: data.agentId }
|
|
389
|
+
const results = await runNotifyHooks(matchingCommands(config[eventName], data.agentType), payload, boundRunner(ctx))
|
|
390
|
+
surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
|
|
391
|
+
})
|
|
325
392
|
|
|
326
393
|
pi.on('session_start', async (event, ctx) => {
|
|
394
|
+
sessionCtx = ctx
|
|
327
395
|
const trusted = await isProjectApproved(ctx)
|
|
328
396
|
projectDir = ctx.cwd
|
|
329
397
|
config = loadHooks(hookFiles(ctx.cwd, os.homedir(), trusted))
|
|
@@ -331,37 +399,77 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
331
399
|
// a fork is a genuine session begin, which Claude reports as source "fork".
|
|
332
400
|
if (event.reason === 'reload') return
|
|
333
401
|
const source = claudeSpelling(SESSION_START_SOURCE, event.reason)
|
|
334
|
-
|
|
402
|
+
const commands = matchingCommands(config.SessionStart, source.names)
|
|
403
|
+
const payload = { hook_event_name: 'SessionStart', source: source.value }
|
|
404
|
+
const run = boundRunner(ctx)
|
|
405
|
+
const results = await Promise.all(commands.map((command) => run(command.command, payload, timeoutMs(command))))
|
|
406
|
+
surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
|
|
407
|
+
pendingSessionContext = results.map((result) => promptContext(result.stdout)).filter(Boolean)
|
|
335
408
|
})
|
|
336
409
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
410
|
+
// Claude adds a SessionStart hook's additionalContext (or plain stdout) to the
|
|
411
|
+
// conversation before the first prompt; pi's seam for that is a message injected
|
|
412
|
+
// on the next agent start.
|
|
413
|
+
pi.on('before_agent_start', async () => {
|
|
414
|
+
if (pendingSessionContext.length === 0) return
|
|
415
|
+
const content = pendingSessionContext.join('\n')
|
|
416
|
+
pendingSessionContext = []
|
|
417
|
+
return { message: { customType: 'claude-hook-context', content, display: false } }
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
pi.on('tool_call', async (event, ctx) => {
|
|
421
|
+
const decision = await runPreToolUse(config, event.toolName, event.input, boundRunner(ctx, { tool_use_id: event.toolCallId }), mcpAliases.get(event.toolName), (message) => ctx.ui.notify(message, 'warning'))
|
|
344
422
|
if (!decision.block) return undefined
|
|
345
|
-
// pi still emits tool_execution_end (isError) for a blocked call, which also
|
|
346
|
-
// cleans up; deleting here just avoids relying on that host detail.
|
|
347
|
-
pendingInputs.delete(event.toolCallId)
|
|
348
423
|
return { block: true, reason: decision.reason }
|
|
349
424
|
})
|
|
350
425
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
426
|
+
// Claude's PostToolUse runs after a successful call and feeds back into the result:
|
|
427
|
+
// a decision:block reason (or exit-2 stderr) and additionalContext are appended next
|
|
428
|
+
// to the tool result, which is where Claude documents they land. Failed executions
|
|
429
|
+
// are skipped (Claude routes those to PostToolUseFailure, not bridged yet).
|
|
430
|
+
pi.on('tool_result', async (event, ctx) => {
|
|
355
431
|
const alias = mcpAliases.get(event.toolName)
|
|
356
432
|
const names = alias ? [event.toolName, alias] : [event.toolName]
|
|
357
|
-
|
|
433
|
+
const response = { content: event.content, details: event.details, isError: event.isError }
|
|
434
|
+
// A failed execution fires Claude's PostToolUseFailure instead: notify-style, no
|
|
435
|
+
// result patch, since the error content is already what the model sees.
|
|
436
|
+
if (event.isError) {
|
|
437
|
+
const failCommands = matchingCommands(config.PostToolUseFailure, names)
|
|
438
|
+
if (failCommands.length === 0) return
|
|
439
|
+
const run = boundRunner(ctx, { tool_use_id: event.toolCallId })
|
|
440
|
+
const failPayload = { hook_event_name: 'PostToolUseFailure', tool_name: alias ?? event.toolName, tool_input: event.input, tool_response: response }
|
|
441
|
+
const failResults = await Promise.all(failCommands.map((command) => run(command.command, failPayload, timeoutMs(command))))
|
|
442
|
+
surfaceSystemMessages(failResults, (message) => ctx.ui.notify(message, 'warning'))
|
|
443
|
+
return
|
|
444
|
+
}
|
|
445
|
+
const commands = matchingCommands(config.PostToolUse, names)
|
|
446
|
+
if (commands.length === 0) return
|
|
447
|
+
const payload = {
|
|
448
|
+
hook_event_name: 'PostToolUse',
|
|
449
|
+
tool_name: alias ?? event.toolName,
|
|
450
|
+
tool_input: event.input,
|
|
451
|
+
tool_response: response,
|
|
452
|
+
}
|
|
453
|
+
const run = boundRunner(ctx, { tool_use_id: event.toolCallId })
|
|
454
|
+
const results = await Promise.all(commands.map((command) => run(command.command, payload, timeoutMs(command))))
|
|
455
|
+
surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
|
|
456
|
+
const feedback: string[] = []
|
|
457
|
+
for (const result of results) {
|
|
458
|
+
const parsed = tryParseJson(result.stdout)
|
|
459
|
+
if (!result.timedOut && result.code === 2) feedback.push(`PostToolUse hook: ${result.stderr.trim() || 'Blocked by hook'}`)
|
|
460
|
+
else if (parsed?.decision === 'block') feedback.push(`PostToolUse hook: ${parsed.reason ?? 'Blocked by hook'}`)
|
|
461
|
+
const context = parsed?.hookSpecificOutput?.additionalContext
|
|
462
|
+
if (context) feedback.push(context)
|
|
463
|
+
}
|
|
464
|
+
if (feedback.length === 0) return
|
|
465
|
+
return { content: [...event.content, ...feedback.map((text) => ({ type: 'text' as const, text }))] }
|
|
358
466
|
})
|
|
359
467
|
|
|
360
468
|
pi.on('input', async (event, ctx) => {
|
|
361
469
|
// Only genuine user input; extension-injected messages (plan-mode, subagent) are not
|
|
362
470
|
// prompts the user submitted.
|
|
363
471
|
if (event.source === 'extension') return { action: 'continue' }
|
|
364
|
-
const decision = await runUserPromptSubmit(config, event.text,
|
|
472
|
+
const decision = await runUserPromptSubmit(config, event.text, boundRunner(ctx), (message) => ctx.ui.notify(message, 'warning'))
|
|
365
473
|
if (decision.block) {
|
|
366
474
|
// pi's input result has no reason channel, so surface why before consuming it.
|
|
367
475
|
ctx.ui.notify(decision.reason ?? 'Prompt blocked by hook', 'error')
|
|
@@ -373,19 +481,48 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
373
481
|
return { action: 'continue' }
|
|
374
482
|
})
|
|
375
483
|
|
|
376
|
-
//
|
|
377
|
-
//
|
|
378
|
-
|
|
379
|
-
|
|
484
|
+
// Claude's Stop hook can prevent stopping: a block feeds its reason back as a new
|
|
485
|
+
// turn, and stop_hook_active in the payload tells the next firing it is already
|
|
486
|
+
// continuing from a stop hook, which is the hook script's documented loop guard.
|
|
487
|
+
// Only exit 2 and decision:"block" continue; continue:false means "stay stopped".
|
|
488
|
+
pi.on('agent_end', async (_event, ctx) => {
|
|
489
|
+
const commands = matchingCommands(config.Stop, 'Stop')
|
|
490
|
+
if (commands.length === 0) {
|
|
491
|
+
stopHookActive = false
|
|
492
|
+
return
|
|
493
|
+
}
|
|
494
|
+
const payload = { hook_event_name: 'Stop', stop_hook_active: stopHookActive }
|
|
495
|
+
const run = boundRunner(ctx)
|
|
496
|
+
const results = await Promise.all(commands.map((command) => run(command.command, payload, timeoutMs(command))))
|
|
497
|
+
surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
|
|
498
|
+
const block = results
|
|
499
|
+
.filter((result) => !result.timedOut)
|
|
500
|
+
.map((result) => {
|
|
501
|
+
if (result.code === 2) return { block: true, reason: result.stderr.trim() || 'Stop blocked by hook' }
|
|
502
|
+
const parsed = tryParseJson(result.stdout)
|
|
503
|
+
if (parsed?.decision === 'block') return { block: true, reason: parsed.reason ?? 'Stop blocked by hook' }
|
|
504
|
+
return { block: false, reason: '' }
|
|
505
|
+
})
|
|
506
|
+
.find((verdict) => verdict.block)
|
|
507
|
+
stopHookActive = block !== undefined
|
|
508
|
+
if (block) pi.sendMessage({ customType: 'claude-stop-hook', content: block.reason, display: true }, { triggerTurn: true })
|
|
509
|
+
})
|
|
510
|
+
|
|
511
|
+
pi.on('session_before_compact', async (event, ctx) => {
|
|
512
|
+
const trigger = claudeSpelling(PRECOMPACT_TRIGGER, event.reason)
|
|
513
|
+
const results = await runNotifyHooks(matchingCommands(config.PreCompact, trigger.names), { hook_event_name: 'PreCompact', trigger: trigger.value }, boundRunner(ctx))
|
|
514
|
+
surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
|
|
380
515
|
})
|
|
381
516
|
|
|
382
|
-
pi.on('
|
|
517
|
+
pi.on('session_compact', async (event, ctx) => {
|
|
383
518
|
const trigger = claudeSpelling(PRECOMPACT_TRIGGER, event.reason)
|
|
384
|
-
await runNotifyHooks(matchingCommands(config.
|
|
519
|
+
const results = await runNotifyHooks(matchingCommands(config.PostCompact, trigger.names), { hook_event_name: 'PostCompact', trigger: trigger.value }, boundRunner(ctx))
|
|
520
|
+
surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
|
|
385
521
|
})
|
|
386
522
|
|
|
387
|
-
pi.on('session_shutdown', async (event) => {
|
|
523
|
+
pi.on('session_shutdown', async (event, ctx) => {
|
|
388
524
|
const reason = claudeSpelling(SESSION_END_REASON, event.reason)
|
|
389
|
-
await runNotifyHooks(matchingCommands(config.SessionEnd, reason.names), { hook_event_name: 'SessionEnd', reason: reason.value },
|
|
525
|
+
const results = await runNotifyHooks(matchingCommands(config.SessionEnd, reason.names), { hook_event_name: 'SessionEnd', reason: reason.value }, boundRunner(ctx))
|
|
526
|
+
surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
|
|
390
527
|
})
|
|
391
528
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Channel and payload for the plan-mode state the plan-mode extension publishes on
|
|
3
|
+
* pi's shared extension event bus. Hooks report Claude's permission_mode from it;
|
|
4
|
+
* pi loads extensions without a shared module cache, so state rides the bus.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export const PLAN_MODE_CHANNEL = 'pi-code:plan-mode'
|
|
8
|
+
|
|
9
|
+
export interface PlanModeState {
|
|
10
|
+
active: boolean
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function isPlanModeState(data: unknown): data is PlanModeState {
|
|
14
|
+
return typeof (data as PlanModeState)?.active === 'boolean'
|
|
15
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Channel and payload for subagent lifecycle events the subagent extension publishes
|
|
3
|
+
* on pi's shared extension event bus. Hooks bridge them to Claude's SubagentStart and
|
|
4
|
+
* SubagentStop; pi loads extensions without a shared module cache, so state rides the bus.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export const SUBAGENT_CHANNEL = 'pi-code:subagent'
|
|
8
|
+
|
|
9
|
+
export interface SubagentPhaseEvent {
|
|
10
|
+
phase: 'start' | 'stop'
|
|
11
|
+
agentType: string
|
|
12
|
+
agentId: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function isSubagentPhaseEvent(data: unknown): data is SubagentPhaseEvent {
|
|
16
|
+
const event = data as SubagentPhaseEvent
|
|
17
|
+
return (event?.phase === 'start' || event?.phase === 'stop') && typeof event.agentType === 'string' && typeof event.agentId === 'string'
|
|
18
|
+
}
|
|
@@ -17,6 +17,8 @@ import type { AssistantMessage, TextContent } from '@earendil-works/pi-ai'
|
|
|
17
17
|
import type { ExtensionAPI, ExtensionContext, SessionEntry } from '@earendil-works/pi-coding-agent'
|
|
18
18
|
import { Key } from '@earendil-works/pi-tui'
|
|
19
19
|
import { Type } from 'typebox'
|
|
20
|
+
|
|
21
|
+
import { PLAN_MODE_CHANNEL } from '../internal/plan-mode-state.js'
|
|
20
22
|
import { extractTodoItems, isSafeCommand, markCompletedSteps, planToTodos, type TodoItem } from './utils.js'
|
|
21
23
|
|
|
22
24
|
// Tools
|
|
@@ -70,6 +72,11 @@ export default function planModeExtension(pi: ExtensionAPI): void {
|
|
|
70
72
|
}
|
|
71
73
|
}
|
|
72
74
|
|
|
75
|
+
/** Hooks report Claude's permission_mode from this bus state. */
|
|
76
|
+
function publishPlanState(): void {
|
|
77
|
+
pi.events.emit(PLAN_MODE_CHANNEL, { active: planModeEnabled })
|
|
78
|
+
}
|
|
79
|
+
|
|
73
80
|
pi.registerFlag('plan', {
|
|
74
81
|
description: 'Start in plan mode (read-only exploration)',
|
|
75
82
|
type: 'boolean',
|
|
@@ -116,6 +123,7 @@ export default function planModeExtension(pi: ExtensionAPI): void {
|
|
|
116
123
|
}
|
|
117
124
|
// Persist the toggle so a resume does not restore a state the user left.
|
|
118
125
|
persistState()
|
|
126
|
+
publishPlanState()
|
|
119
127
|
updateStatus(ctx)
|
|
120
128
|
}
|
|
121
129
|
|
|
@@ -158,6 +166,7 @@ export default function planModeExtension(pi: ExtensionAPI): void {
|
|
|
158
166
|
executionMode = todoItems.length > 0
|
|
159
167
|
planFromTool = false
|
|
160
168
|
restoreTools()
|
|
169
|
+
publishPlanState()
|
|
161
170
|
updateStatus(ctx)
|
|
162
171
|
|
|
163
172
|
// Persist before the turn: a crash before the first turn_end must resume into
|
|
@@ -377,6 +386,7 @@ After completing a step, include a [DONE:n] tag in your response.`,
|
|
|
377
386
|
todoItems = planModeEntry.data.todos ?? todoItems
|
|
378
387
|
executionMode = planModeEntry.data.executing ?? executionMode
|
|
379
388
|
}
|
|
389
|
+
publishPlanState()
|
|
380
390
|
|
|
381
391
|
// On resume: re-scan messages after the last "plan-mode-execute" to rebuild
|
|
382
392
|
// completion state without picking up [DONE:n] from previous plans
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import { spawn } from 'node:child_process'
|
|
16
|
+
import { randomUUID } from 'node:crypto'
|
|
16
17
|
import * as fs from 'node:fs'
|
|
17
18
|
import * as os from 'node:os'
|
|
18
19
|
import * as path from 'node:path'
|
|
@@ -24,6 +25,7 @@ import { Container, Markdown, Spacer, Text } from '@earendil-works/pi-tui'
|
|
|
24
25
|
import { type Static, Type } from 'typebox'
|
|
25
26
|
import { capForContext } from '../internal/output-guard.js'
|
|
26
27
|
import { isProjectApproved } from '../internal/project-approval.js'
|
|
28
|
+
import { SUBAGENT_CHANNEL } from '../internal/subagent-events.js'
|
|
27
29
|
import { type AgentConfig, type AgentScope, discoverAgents } from './agents.js'
|
|
28
30
|
import { activeBackgroundRuns, backgroundStatusText, MAX_BACKGROUND_RUNS, startBackgroundRun } from './background.js'
|
|
29
31
|
|
|
@@ -256,9 +258,25 @@ interface RunAgentOptions {
|
|
|
256
258
|
signal?: AbortSignal
|
|
257
259
|
onUpdate?: OnUpdateCallback
|
|
258
260
|
makeDetails: (results: SingleResult[]) => SubagentDetails
|
|
261
|
+
onPhase?: SubagentPhaseSink
|
|
259
262
|
}
|
|
260
263
|
|
|
264
|
+
/** Publishes a child run's start/stop for the hooks extension's SubagentStart/Stop. */
|
|
265
|
+
type SubagentPhaseSink = (phase: 'start' | 'stop', agentType: string, agentId: string) => void
|
|
266
|
+
|
|
261
267
|
async function runSingleAgent(options: RunAgentOptions): Promise<SingleResult> {
|
|
268
|
+
const agent = options.agents.find((a) => a.name === options.agentName)
|
|
269
|
+
if (!agent) return runSingleAgentInner(options)
|
|
270
|
+
const agentId = `fg-${randomUUID().slice(0, 8)}`
|
|
271
|
+
options.onPhase?.('start', agent.name, agentId)
|
|
272
|
+
try {
|
|
273
|
+
return await runSingleAgentInner(options)
|
|
274
|
+
} finally {
|
|
275
|
+
options.onPhase?.('stop', agent.name, agentId)
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function runSingleAgentInner(options: RunAgentOptions): Promise<SingleResult> {
|
|
262
280
|
const { defaultCwd, agents, agentName, task, cwd, step, signal, onUpdate, makeDetails } = options
|
|
263
281
|
const agent = agents.find((a) => a.name === agentName)
|
|
264
282
|
|
|
@@ -478,6 +496,7 @@ interface ModeContext {
|
|
|
478
496
|
signal: AbortSignal | undefined
|
|
479
497
|
onUpdate: OnUpdateCallback | undefined
|
|
480
498
|
makeDetails: MakeDetails
|
|
499
|
+
onPhase?: SubagentPhaseSink
|
|
481
500
|
}
|
|
482
501
|
|
|
483
502
|
async function checkProjectAgentGate(params: SubagentParamsStatic, agents: AgentConfig[], ctx: ExtensionContext, projectAgentsDir: string | null, gateMode: SubagentMode, makeDetails: MakeDetails): Promise<ToolResult | null> {
|
|
@@ -572,6 +591,7 @@ async function runBackgroundMode(params: SubagentParamsStatic, agents: AgentConf
|
|
|
572
591
|
const invocation = getPiInvocation(args)
|
|
573
592
|
const id = startBackgroundRun(agent.name, task, { command: invocation.command, args: invocation.args, cwd: params.cwd ?? defaultCwd }, (run) => {
|
|
574
593
|
removeTmpPrompt(tmpPrompt)
|
|
594
|
+
pi.events.emit(SUBAGENT_CHANNEL, { phase: 'stop', agentType: run.agent, agentId: run.id })
|
|
575
595
|
const output = capForContext(run.output ?? '') || '(no output)'
|
|
576
596
|
pi.sendMessage(
|
|
577
597
|
{
|
|
@@ -587,6 +607,7 @@ async function runBackgroundMode(params: SubagentParamsStatic, agents: AgentConf
|
|
|
587
607
|
removeTmpPrompt(tmpPrompt)
|
|
588
608
|
return backgroundCapResult(makeDetails)
|
|
589
609
|
}
|
|
610
|
+
pi.events.emit(SUBAGENT_CHANNEL, { phase: 'start', agentType: agent.name, agentId: id })
|
|
590
611
|
return {
|
|
591
612
|
content: [{ type: 'text', text: `Started background run ${id} (${agent.name}). A notification will arrive on completion; check progress with {status: true}.` }],
|
|
592
613
|
details: makeDetails('single')([]),
|
|
@@ -628,6 +649,7 @@ async function runChainMode(chain: ChainStepParam[], mode: ModeContext): Promise
|
|
|
628
649
|
signal,
|
|
629
650
|
onUpdate: chainUpdate,
|
|
630
651
|
makeDetails: makeDetails('chain'),
|
|
652
|
+
onPhase: mode.onPhase,
|
|
631
653
|
})
|
|
632
654
|
results.push(result)
|
|
633
655
|
|
|
@@ -695,6 +717,7 @@ async function runParallelMode(tasks: TaskItemParam[], mode: ModeContext): Promi
|
|
|
695
717
|
task: t.task,
|
|
696
718
|
cwd: t.cwd,
|
|
697
719
|
signal,
|
|
720
|
+
onPhase: mode.onPhase,
|
|
698
721
|
// Per-task update callback
|
|
699
722
|
onUpdate: (partial) => {
|
|
700
723
|
const live = partial.details?.results[0]
|
|
@@ -741,6 +764,7 @@ async function runSingleMode(agentName: string, task: string, cwd: string | unde
|
|
|
741
764
|
signal,
|
|
742
765
|
onUpdate,
|
|
743
766
|
makeDetails: makeDetails('single'),
|
|
767
|
+
onPhase: mode.onPhase,
|
|
744
768
|
})
|
|
745
769
|
const isError = result.exitCode !== 0 || result.stopReason === 'error' || result.stopReason === 'aborted'
|
|
746
770
|
if (isError) {
|
|
@@ -1109,7 +1133,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
1109
1133
|
|
|
1110
1134
|
if (params.background) return runBackgroundMode(params, agents, ctx.cwd, pi, makeDetails)
|
|
1111
1135
|
|
|
1112
|
-
const mode: ModeContext = { agents, defaultCwd: ctx.cwd, signal, onUpdate, makeDetails }
|
|
1136
|
+
const mode: ModeContext = { agents, defaultCwd: ctx.cwd, signal, onUpdate, makeDetails, onPhase: (phase, agentType, agentId) => pi.events.emit(SUBAGENT_CHANNEL, { phase, agentType, agentId }) }
|
|
1113
1137
|
|
|
1114
1138
|
if (params.chain?.length) return runChainMode(params.chain, mode)
|
|
1115
1139
|
if (params.tasks?.length) return runParallelMode(params.tasks, mode)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-code",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
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",
|