pi-code 0.4.2 → 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
@@ -22,9 +35,11 @@
22
35
  *
23
36
  * Config is merged from ~/.claude/settings.json (always) plus the project's
24
37
  * .claude/settings.json and settings.local.json (only when the project is
25
- * trusted, since hooks execute arbitrary shell). Claude tool matchers are
26
- * PascalCase (`Bash`); pi tool names are lowercase (`bash`), so matchers are
27
- * applied case-insensitively.
38
+ * trusted, since hooks execute arbitrary shell). Matchers follow Claude's rule:
39
+ * `*`/empty match all, plain names are exact (with `|`/`,` list separators), and
40
+ * anything with other regex characters is an unanchored regex. Claude matchers
41
+ * are PascalCase (`Bash`); pi tool names are lowercase (`bash`), so comparison
42
+ * is case-insensitive and folds `-` to `_`.
28
43
  *
29
44
  * Docs: https://code.claude.com/docs/en/hooks.md
30
45
  */
@@ -33,9 +48,12 @@ import { type ChildProcess, spawn } from 'node:child_process'
33
48
  import * as fs from 'node:fs'
34
49
  import * as os from 'node:os'
35
50
  import * as path from 'node:path'
36
- import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
51
+ import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
37
52
 
53
+ import { isMcpToolAliases, MCP_TOOLS_CHANNEL } from './internal/mcp-alias.js'
54
+ import { isPlanModeState, PLAN_MODE_CHANNEL } from './internal/plan-mode-state.js'
38
55
  import { isProjectApproved } from './internal/project-approval.js'
56
+ import { isSubagentPhaseEvent, SUBAGENT_CHANNEL } from './internal/subagent-events.js'
39
57
 
40
58
  const DEFAULT_TIMEOUT_S = 60
41
59
 
@@ -86,12 +104,34 @@ export function loadHooks(files: string[]): HooksConfig {
86
104
  return config
87
105
  }
88
106
 
89
- function matcherApplies(matcher: string | undefined, name: string): boolean {
107
+ /** Claude's rule: a matcher of only letters, digits, `_`, `-`, spaces, `,` and `|`
108
+ * is a list of exact names; anything else is an unanchored regex. */
109
+ const EXACT_MATCHER = /^[\w\- ,|]*$/
110
+
111
+ /** Claude names are PascalCase and keep dashes (`Bash`, `mcp__brave-search__x`);
112
+ * pi names are lowercase with underscores, so comparison folds both. */
113
+ function foldName(name: string): string {
114
+ return name.toLowerCase().replaceAll('-', '_')
115
+ }
116
+
117
+ function exactListApplies(matcher: string, names: readonly string[]): boolean {
118
+ const tokens = new Set(
119
+ matcher
120
+ .split(/[|,]/)
121
+ .map((token) => foldName(token.trim()))
122
+ .filter(Boolean),
123
+ )
124
+ return names.some((name) => tokens.has(foldName(name)))
125
+ }
126
+
127
+ function matcherApplies(matcher: string | undefined, names: readonly string[]): boolean {
90
128
  if (!matcher || matcher === '*') return true
129
+ if (EXACT_MATCHER.test(matcher)) return exactListApplies(matcher, names)
91
130
  try {
92
- return new RegExp(`^(?:${matcher})$`, 'i').test(name)
131
+ const regex = new RegExp(matcher, 'i')
132
+ return names.some((name) => regex.test(name))
93
133
  } catch {
94
- return matcher.toLowerCase() === name.toLowerCase()
134
+ return exactListApplies(matcher, names)
95
135
  }
96
136
  }
97
137
 
@@ -101,16 +141,25 @@ function isRunnableHook(hook: HookCommand): boolean {
101
141
  return typeof hook.command === 'string' && (hook.type === undefined || hook.type === 'command')
102
142
  }
103
143
 
104
- /** Command specs whose matcher applies to the given tool/source name. */
105
- export function matchingCommands(matchers: HookMatcher[] | undefined, name: string): HookCommand[] {
144
+ /** Command specs whose matcher applies to any of the given tool/source names.
145
+ * Multiple candidates let one event offer both the pi name and its Claude alias. */
146
+ export function matchingCommands(matchers: HookMatcher[] | undefined, names: string | readonly string[]): HookCommand[] {
147
+ const candidates = typeof names === 'string' ? [names] : names
106
148
  const result: HookCommand[] = []
149
+ const seen = new Set<string>()
107
150
  for (const entry of matchers ?? []) {
108
- if (matcherApplies(entry.matcher, name)) result.push(...(entry.hooks ?? []).filter(isRunnableHook))
151
+ if (!matcherApplies(entry.matcher, candidates)) continue
152
+ for (const hook of (entry.hooks ?? []).filter(isRunnableHook)) {
153
+ // Claude runs a handler defined in more than one settings file once.
154
+ if (seen.has(hook.command)) continue
155
+ seen.add(hook.command)
156
+ result.push(hook)
157
+ }
109
158
  }
110
159
  return result
111
160
  }
112
161
 
113
- 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 {
114
163
  try {
115
164
  return JSON.parse(text)
116
165
  } catch {
@@ -208,21 +257,50 @@ function timeoutMs(command: HookCommand): number {
208
257
  return seconds * 1000
209
258
  }
210
259
 
211
- /** Run PreToolUse hooks for a tool; the first blocking verdict wins. */
212
- export async function runPreToolUse(config: HooksConfig, toolName: string, toolInput: unknown, runner: HookRunner): Promise<HookDecision> {
213
- for (const command of matchingCommands(config.PreToolUse, toolName)) {
214
- const result = await runner(command.command, { hook_event_name: 'PreToolUse', tool_name: toolName, tool_input: toolInput }, timeoutMs(command))
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
+
271
+ /** Run PreToolUse hooks for a tool; the first blocking verdict wins. For MCP tools the
272
+ * matcher sees both the pi name and the Claude alias, and the payload reports the alias,
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> {
277
+ const names = claudeName ? [toolName, claudeName] : [toolName]
278
+ for (const command of matchingCommands(config.PreToolUse, names)) {
279
+ const result = await runner(command.command, { hook_event_name: 'PreToolUse', tool_name: claudeName ?? toolName, tool_input: toolInput }, timeoutMs(command))
215
280
  // A killed hook never reached its verdict, and SIGKILL leaves a null exit code that
216
281
  // would otherwise read as a clean allow. Fail closed instead.
217
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)
218
286
  const decision = interpretHookResult(result.code, result.stdout, result.stderr)
219
287
  if (decision.block) return decision
220
288
  }
221
289
  return { block: false }
222
290
  }
223
291
 
224
- async function runNotifyHooks(commands: HookCommand[], payload: unknown, runner: HookRunner): Promise<void> {
225
- 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
+ }
226
304
  }
227
305
 
228
306
  export interface PromptDecision {
@@ -241,11 +319,12 @@ function promptContext(stdout: string): string {
241
319
 
242
320
  /** Run UserPromptSubmit hooks: the first blocking verdict wins; otherwise their
243
321
  * additional context is concatenated for injection ahead of the prompt. */
244
- 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> {
245
323
  const contexts: string[] = []
246
324
  for (const command of matchingCommands(config.UserPromptSubmit, 'UserPromptSubmit')) {
247
325
  const result = await runner(command.command, { hook_event_name: 'UserPromptSubmit', prompt }, timeoutMs(command))
248
326
  if (result.timedOut) return { block: true, reason: `Hook timed out after ${timeoutMs(command)}ms: ${command.command}`, context: '' }
327
+ if (onSystemMessage) surfaceSystemMessages([result], onSystemMessage)
249
328
  const decision = interpretHookResult(result.code, result.stdout, result.stderr)
250
329
  if (decision.block) return { block: true, reason: decision.reason, context: '' }
251
330
  const context = promptContext(result.stdout)
@@ -254,53 +333,143 @@ export async function runUserPromptSubmit(config: HooksConfig, prompt: string, r
254
333
  return { block: false, context: contexts.join('\n') }
255
334
  }
256
335
 
257
- /** Bound on remembered tool inputs, in case a blocked or aborted call never ends. */
258
- const MAX_PENDING_INPUTS = 100
336
+ /** pi's lifecycle vocabularies differ from Claude's documented ones. The matcher is
337
+ * offered both spellings so existing configs keep firing either way, and the payload
338
+ * reports the Claude value, which is what a Claude-written hook script parses. */
339
+ const SESSION_START_SOURCE: Record<string, string> = { startup: 'startup', new: 'clear', resume: 'resume', fork: 'fork' }
340
+ const PRECOMPACT_TRIGGER: Record<string, string> = { manual: 'manual', threshold: 'auto', overflow: 'auto' }
341
+ const SESSION_END_REASON: Record<string, string> = { quit: 'prompt_input_exit', new: 'clear', resume: 'resume', reload: 'other', fork: 'other' }
342
+
343
+ /** The raw pi value plus its Claude spelling, deduplicated, for matcher candidates. */
344
+ function claudeSpelling(map: Record<string, string>, raw: string): { names: string[]; value: string } {
345
+ const value = map[raw] ?? raw
346
+ return { names: value === raw ? [raw] : [raw, value], value }
347
+ }
259
348
 
260
349
  export default function hooksExtension(pi: ExtensionAPI) {
261
350
  let config: HooksConfig = {}
262
351
  let projectDir = ''
263
- // tool_execution_end does not carry the tool's input, but Claude's PostToolUse
264
- // contract does, so remember it from tool_call keyed by the call id.
265
- const pendingInputs = new Map<string, unknown>()
266
- 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)
368
+ // Claude matchers name MCP tools mcp__<server>__<tool>; pi-code registers them as
369
+ // <server>_<tool>. The mcp extension publishes the mapping on pi's shared bus.
370
+ const mcpAliases = new Map<string, string>()
371
+ pi.events.on(MCP_TOOLS_CHANNEL, (data) => {
372
+ if (!isMcpToolAliases(data)) return
373
+ mcpAliases.clear()
374
+ for (const entry of data) mcpAliases.set(entry.pi, entry.claude)
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
+ })
267
392
 
268
393
  pi.on('session_start', async (event, ctx) => {
394
+ sessionCtx = ctx
269
395
  const trusted = await isProjectApproved(ctx)
270
396
  projectDir = ctx.cwd
271
397
  config = loadHooks(hookFiles(ctx.cwd, os.homedir(), trusted))
272
- // Only fire SessionStart hooks on a genuine session begin, matched by source (Claude uses
273
- // "startup"/"resume"/...). "reload" and "fork" re-fire in-process and would double-run hooks.
274
- if (event.reason === 'reload' || event.reason === 'fork') return
275
- await runNotifyHooks(matchingCommands(config.SessionStart, event.reason), { hook_event_name: 'SessionStart', source: event.reason }, runner)
398
+ // "reload" re-fires in-process with the same conversation and would double-run hooks;
399
+ // a fork is a genuine session begin, which Claude reports as source "fork".
400
+ if (event.reason === 'reload') return
401
+ const source = claudeSpelling(SESSION_START_SOURCE, event.reason)
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)
276
408
  })
277
409
 
278
- pi.on('tool_call', async (event) => {
279
- pendingInputs.set(event.toolCallId, event.input)
280
- if (pendingInputs.size > MAX_PENDING_INPUTS) {
281
- const oldest = pendingInputs.keys().next().value
282
- if (oldest !== undefined) pendingInputs.delete(oldest)
283
- }
284
- const decision = await runPreToolUse(config, event.toolName, event.input, runner)
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'))
285
422
  if (!decision.block) return undefined
286
- // pi still emits tool_execution_end (isError) for a blocked call, which also
287
- // cleans up; deleting here just avoids relying on that host detail.
288
- pendingInputs.delete(event.toolCallId)
289
423
  return { block: true, reason: decision.reason }
290
424
  })
291
425
 
292
- pi.on('tool_execution_end', async (event) => {
293
- const toolInput = pendingInputs.get(event.toolCallId)
294
- pendingInputs.delete(event.toolCallId)
295
- if (event.isError) return
296
- await runNotifyHooks(matchingCommands(config.PostToolUse, event.toolName), { hook_event_name: 'PostToolUse', tool_name: event.toolName, tool_input: toolInput, tool_response: event.result }, runner)
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) => {
431
+ const alias = mcpAliases.get(event.toolName)
432
+ const names = alias ? [event.toolName, alias] : [event.toolName]
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 }))] }
297
466
  })
298
467
 
299
468
  pi.on('input', async (event, ctx) => {
300
469
  // Only genuine user input; extension-injected messages (plan-mode, subagent) are not
301
470
  // prompts the user submitted.
302
471
  if (event.source === 'extension') return { action: 'continue' }
303
- const decision = await runUserPromptSubmit(config, event.text, runner)
472
+ const decision = await runUserPromptSubmit(config, event.text, boundRunner(ctx), (message) => ctx.ui.notify(message, 'warning'))
304
473
  if (decision.block) {
305
474
  // pi's input result has no reason channel, so surface why before consuming it.
306
475
  ctx.ui.notify(decision.reason ?? 'Prompt blocked by hook', 'error')
@@ -312,17 +481,48 @@ export default function hooksExtension(pi: ExtensionAPI) {
312
481
  return { action: 'continue' }
313
482
  })
314
483
 
315
- // Notify-style Claude events with a matching pi lifecycle seam. None can block: pi's
316
- // agent_end, session_before_compact and session_shutdown are fire-and-forget here.
317
- pi.on('agent_end', async () => {
318
- 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'))
319
515
  })
320
516
 
321
- pi.on('session_before_compact', async (event) => {
322
- await runNotifyHooks(matchingCommands(config.PreCompact, event.reason), { hook_event_name: 'PreCompact', trigger: event.reason }, runner)
517
+ pi.on('session_compact', async (event, ctx) => {
518
+ const trigger = claudeSpelling(PRECOMPACT_TRIGGER, event.reason)
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'))
323
521
  })
324
522
 
325
- pi.on('session_shutdown', async (event) => {
326
- await runNotifyHooks(matchingCommands(config.SessionEnd, event.reason), { hook_event_name: 'SessionEnd', reason: event.reason }, runner)
523
+ pi.on('session_shutdown', async (event, ctx) => {
524
+ const reason = claudeSpelling(SESSION_END_REASON, event.reason)
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'))
327
527
  })
328
528
  }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Channel and payload for the MCP tool-name registry the mcp extension publishes on
3
+ * pi's shared extension event bus. Claude Code names MCP tools `mcp__<server>__<tool>`
4
+ * (original names, dashes preserved); pi-code registers them as `<server>_<tool>` with
5
+ * dashes folded to underscores. Hook matchers written for Claude need the mapping, and
6
+ * pi loads every extension without a shared module cache, so cross-extension state must
7
+ * ride the bus rather than a module singleton.
8
+ */
9
+
10
+ export const MCP_TOOLS_CHANNEL = 'pi-code:mcp-tools'
11
+
12
+ export interface McpToolAlias {
13
+ /** Tool name as registered in pi, e.g. `github_create_issue`. */
14
+ pi: string
15
+ /** Claude Code's name for the same tool, e.g. `mcp__github__create_issue`. */
16
+ claude: string
17
+ }
18
+
19
+ export function isMcpToolAliases(data: unknown): data is McpToolAlias[] {
20
+ return Array.isArray(data) && data.every((entry) => typeof (entry as McpToolAlias)?.pi === 'string' && typeof (entry as McpToolAlias)?.claude === 'string')
21
+ }
@@ -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
+ }
package/extensions/mcp.ts CHANGED
@@ -29,6 +29,7 @@ import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js' //
29
29
  import { getDefaultEnvironment, StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
30
30
  import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
31
31
  import { Type } from 'typebox'
32
+ import { MCP_TOOLS_CHANNEL, type McpToolAlias } from './internal/mcp-alias.js'
32
33
  import { capForContext } from './internal/output-guard.js'
33
34
  import { isProjectApproved } from './internal/project-approval.js'
34
35
 
@@ -261,6 +262,8 @@ export default async function mcpExtension(pi: ExtensionAPI) {
261
262
  const clients = new Map<string, Client>()
262
263
  const status = new Map<string, { state: string; tools: number }>()
263
264
  const registered = new Set<string>()
265
+ // Original server/tool names per registered pi name, for Claude-style hook matchers.
266
+ const aliases: McpToolAlias[] = []
264
267
 
265
268
  async function connectServers(servers: Record<string, ServerConfig>): Promise<void> {
266
269
  for (const [name, config] of Object.entries(servers)) {
@@ -283,6 +286,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
283
286
  continue
284
287
  }
285
288
  registered.add(toolName)
289
+ aliases.push({ pi: toolName, claude: `mcp__${name}__${tool.name}` })
286
290
  count++
287
291
  pi.registerTool({
288
292
  name: toolName,
@@ -328,6 +332,8 @@ export default async function mcpExtension(pi: ExtensionAPI) {
328
332
  await connectServers(loadConfigFrom(projectConfigPaths(ctx.cwd)))
329
333
  }
330
334
 
335
+ pi.events.emit(MCP_TOOLS_CHANNEL, [...aliases])
336
+
331
337
  const connected = [...status.values()].filter((s) => s.state === 'connected')
332
338
  const failed = [...status.entries()].filter(([, s]) => s.state !== 'connected')
333
339
  if (connected.length > 0 || failed.length > 0) {
@@ -18,7 +18,10 @@ import { capForContext } from './internal/output-guard.js'
18
18
  const INDEX_FILE = 'MEMORY.md'
19
19
 
20
20
  export function projectSlug(cwd: string): string {
21
- return cwd.replace(/[/\\]/g, '-').replace(/^-+/, '-')
21
+ return cwd
22
+ .replace(/^([A-Za-z]):(?=[/\\])/, '$1')
23
+ .replace(/[/\\]/g, '-')
24
+ .replace(/^-+/, '-')
22
25
  }
23
26
 
24
27
  export function memoryDir(cwd: string): string {
@@ -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.4.2",
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",