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` |
@@ -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 `tool_execution_end` (fire-and-forget)
8
- * - SessionStart -> pi `session_start` (fire-and-forget)
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` (fire-and-forget; cannot prevent stopping)
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
- * Claude's SubagentStop has no pi lifecycle seam (the subagent tool spawns child pi
16
- * processes, and pi emits no subagent-completion event), so it is not bridged.
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
- export async function runPreToolUse(config: HooksConfig, toolName: string, toolInput: unknown, runner: HookRunner, claudeName?: string): Promise<HookDecision> {
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<void> {
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
- // tool_execution_end does not carry the tool's input, but Claude's PostToolUse
314
- // contract does, so remember it from tool_call keyed by the call id.
315
- const pendingInputs = new Map<string, unknown>()
316
- const runner: HookRunner = (command, payload, ms) => runHookCommand(command, payload, ms, projectDir)
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
- await runNotifyHooks(matchingCommands(config.SessionStart, source.names), { hook_event_name: 'SessionStart', source: source.value }, runner)
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
- pi.on('tool_call', async (event) => {
338
- pendingInputs.set(event.toolCallId, event.input)
339
- if (pendingInputs.size > MAX_PENDING_INPUTS) {
340
- const oldest = pendingInputs.keys().next().value
341
- if (oldest !== undefined) pendingInputs.delete(oldest)
342
- }
343
- const decision = await runPreToolUse(config, event.toolName, event.input, runner, mcpAliases.get(event.toolName))
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
- pi.on('tool_execution_end', async (event) => {
352
- const toolInput = pendingInputs.get(event.toolCallId)
353
- pendingInputs.delete(event.toolCallId)
354
- if (event.isError) return
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
- await runNotifyHooks(matchingCommands(config.PostToolUse, names), { hook_event_name: 'PostToolUse', tool_name: alias ?? event.toolName, tool_input: toolInput, tool_response: event.result }, runner)
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, runner)
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
- // Notify-style Claude events with a matching pi lifecycle seam. None can block: pi's
377
- // agent_end, session_before_compact and session_shutdown are fire-and-forget here.
378
- pi.on('agent_end', async () => {
379
- await runNotifyHooks(matchingCommands(config.Stop, 'Stop'), { hook_event_name: 'Stop' }, runner)
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('session_before_compact', async (event) => {
517
+ pi.on('session_compact', async (event, ctx) => {
383
518
  const trigger = claudeSpelling(PRECOMPACT_TRIGGER, event.reason)
384
- await runNotifyHooks(matchingCommands(config.PreCompact, trigger.names), { hook_event_name: 'PreCompact', trigger: trigger.value }, runner)
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 }, runner)
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.5.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",