pi-code 0.5.0 → 0.7.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,19 +38,19 @@ 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` |
42
- | Output styles | `.claude/output-styles` + active `outputStyle`, `/output-style` switcher | `output-styles.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
+ | Output styles | `.claude/output-styles` + active `outputStyle`; Claude replace semantics with `keep-coding-instructions`; bundled Explanatory/Learning/Proactive; `/output-style [name]` | `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
- | 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` |
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; `enabledMcpjsonServers`/`disabledMcpjsonServers`/`enableAllProjectMcpServers` honored, consent keys only from non-repo settings); stdio/HTTP/SSE by `type`; `${VAR:-default}` expansion; `MCP_TIMEOUT`/`MCP_TOOL_TIMEOUT` | `mcp.ts` |
45
45
  | Project trust | prompts before loading project config (MCP servers, hooks, agents, rules, output styles) that pi would otherwise trust silently | `internal/project-approval.ts` |
46
- | Subagents / Task | `~/.claude/agents` and `~/.pi/agent/agents`, plus project `.claude/agents` and `.pi/agents`; background runs | `subagent/` |
46
+ | Subagents / Task | builtin Explore/Plan/general-purpose agents, `~/.claude/agents` and `~/.pi/agent/agents`, plus project `.claude/agents` and `.pi/agents`; agent roster with descriptions in the system prompt; background runs | `subagent/` |
47
47
  | Plan mode | `plan_mode_complete` tool, exact tool snapshot/restore | `plan-mode/` |
48
48
  | Todo list | persistent overlay, status machine, compaction-safe | `todo.ts` |
49
49
  | Checkpoints / rewind | shadow-repo snapshots; restore overwrites checkpointed files, keeps files created later | `git-checkpoint.ts` |
50
50
  | Persistent memory | per-project memories, index injected each session | `memory.ts` |
51
51
  | WebSearch / WebFetch | key-free DuckDuckGo search, SSRF-guarded fetch | `web.ts` |
52
52
  | AskUserQuestion | one question with `header`, single- or `multiSelect` options, plus free-text; no multi-question batching | `question.ts` |
53
- | Statusline | turn state + session cost | `status-line.ts` |
53
+ | Statusline | Claude `statusLine` command contract (stdin JSON, `padding`, `refreshInterval`); built-in turn state + session cost fallback | `status-line.ts` |
54
54
  | Notifications | vendored example | `notify.ts` |
55
55
 
56
56
  `CLAUDE.md` itself needs no extension: pi loads `CLAUDE.md` / `AGENTS.md` context files natively (global + walking cwd to root). `context-imports.ts` only adds the `@import` resolution pi's loader lacks, appending the imported files without re-injecting the base.
@@ -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
+ name: Explanatory
3
+ description: Educational insights while completing tasks
4
+ keep-coding-instructions: true
5
+ ---
6
+
7
+ Between completing software engineering tasks, provide brief educational "Insights" that help the user understand implementation choices and codebase patterns.
8
+
9
+ Mark each one clearly:
10
+
11
+ `✻ Insight ─────`
12
+ (2-3 sentences on why this approach, what pattern it follows, or what tradeoff it makes)
13
+ `─────`
14
+
15
+ Share an insight when there is a genuine decision or pattern worth understanding, not after every step. Keep the work itself unchanged: insights are commentary, never a substitute for doing the task.
@@ -0,0 +1,15 @@
1
+ ---
2
+ name: Learning
3
+ description: Collaborative learn-by-doing with small human-written pieces
4
+ keep-coding-instructions: true
5
+ ---
6
+
7
+ Work collaboratively, learn-by-doing style: share brief "Insights" explaining implementation choices as you work, and regularly ask the user to contribute small, strategic pieces of code themselves.
8
+
9
+ When a piece is well-scoped for the user to write (a condition, a small function body, a test assertion), leave a marker instead of writing it:
10
+
11
+ ```
12
+ // TODO(human): <one sentence describing exactly what to implement here>
13
+ ```
14
+
15
+ Then stop and ask the user to fill it in, explaining what the piece needs to do and why it matters. Choose pieces that teach something about the codebase or the problem, not busywork. Keep your own contributions moving the task forward between their turns.
@@ -0,0 +1,9 @@
1
+ ---
2
+ name: Proactive
3
+ description: Execute immediately, prefer action over planning
4
+ keep-coding-instructions: true
5
+ ---
6
+
7
+ Execute immediately. Make reasonable assumptions instead of pausing for routine decisions, and prefer action over planning: when a step is reversible and follows from the task, do it rather than proposing it.
8
+
9
+ Ask only when a decision is genuinely the user's to make (destructive actions, real scope changes). Report what you did and why afterward, concisely.
@@ -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
+ }
@@ -69,6 +69,16 @@ const APPROVAL_BODY = 'It ships Claude Code configuration that pi-code loads. MC
69
69
  * `defaultProjectTrust` at all, so there is no user preference to fall back on. A run
70
70
  * that cannot ask has not been approved.
71
71
  */
72
+ /** The same decision as isProjectApproved, but never prompts: an undecided project
73
+ * reads as unapproved. For surfaces that only display project config, like the
74
+ * subagent roster, where a mid-turn dialog would be wrong. */
75
+ export function isProjectApprovedSilently(ctx: Pick<ApprovalContext, 'cwd' | 'isProjectTrusted'>, deps: ApprovalDeps = defaultDeps): boolean {
76
+ if (ctx.isProjectTrusted?.() !== true) return false
77
+ if (!deps.hasClaudeShaped(ctx.cwd)) return true
78
+ if (deps.piWouldAsk(ctx.cwd)) return true
79
+ return deps.savedDecision(ctx.cwd) === true
80
+ }
81
+
72
82
  export async function isProjectApproved(ctx: ApprovalContext, deps: ApprovalDeps = defaultDeps): Promise<boolean> {
73
83
  if (ctx.isProjectTrusted?.() !== true) return false // pi already declined, or never trusted
74
84
  if (!deps.hasClaudeShaped(ctx.cwd)) return true // nothing here pi's own check would miss
@@ -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
@@ -92,6 +92,37 @@ export function projectConfigPaths(cwd: string): string[] {
92
92
  return [path.join(cwd, '.mcp.json'), path.join(cwd, '.pi', 'mcp.json')]
93
93
  }
94
94
 
95
+ export interface ProjectServerPolicy {
96
+ disabled: Set<string>
97
+ consented: Set<string>
98
+ consentAll: boolean
99
+ }
100
+
101
+ /** Claude's per-server approvals for project .mcp.json servers. Consent-granting keys
102
+ * (enabledMcpjsonServers, enableAllProjectMcpServers) count only from files the repo
103
+ * does not control (user settings and settings.local.json), so a checked-in
104
+ * settings.json cannot approve its own servers. disabledMcpjsonServers counts from
105
+ * every file and wins over consent. Lists union across files: for denies the union is
106
+ * the restrictive reading, and consent is the union of the user's own two files. */
107
+ export function projectServerPolicy(cwd: string, home: string): ProjectServerPolicy {
108
+ const read = (file: string): Record<string, unknown> => {
109
+ try {
110
+ return JSON.parse(fs.readFileSync(file, 'utf-8'))
111
+ } catch {
112
+ return {}
113
+ }
114
+ }
115
+ const names = (value: unknown): string[] => (Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : [])
116
+ const userSettings = read(path.join(home, '.claude', 'settings.json'))
117
+ const projectSettings = read(path.join(cwd, '.claude', 'settings.json'))
118
+ const localSettings = read(path.join(cwd, '.claude', 'settings.local.json'))
119
+ const disabled = new Set([...names(userSettings.disabledMcpjsonServers), ...names(projectSettings.disabledMcpjsonServers), ...names(localSettings.disabledMcpjsonServers)])
120
+ const consentSources = [userSettings, localSettings]
121
+ const consented = new Set(consentSources.flatMap((settings) => names(settings.enabledMcpjsonServers)))
122
+ const consentAll = consentSources.some((settings) => settings.enableAllProjectMcpServers === true)
123
+ return { disabled, consented, consentAll }
124
+ }
125
+
95
126
  export function loadConfigFrom(files: string[]): Record<string, ServerConfig> {
96
127
  const servers: Record<string, ServerConfig> = {}
97
128
  for (const file of files) {
@@ -325,11 +356,27 @@ export default async function mcpExtension(pi: ExtensionAPI) {
325
356
  userConnected = true
326
357
  await connectServers(loadUserScope(os.homedir(), ctx.cwd))
327
358
  }
328
- // A project .mcp.json can run arbitrary commands on connect, so only honor it once the project is trusted.
329
- // isProjectTrusted alone is true for a repo pi never asked about; see project-approval.
330
- if (!projectConnected && (await isProjectApproved(ctx))) {
331
- projectConnected = true
332
- await connectServers(loadConfigFrom(projectConfigPaths(ctx.cwd)))
359
+ // A project .mcp.json can run arbitrary commands on connect, so only honor it once
360
+ // the project is trusted. Per-server settings refine that: disabled servers never
361
+ // connect, servers the user consented to individually connect without the
362
+ // whole-project confirm, and the rest stay behind it. Reconnect attempts after a
363
+ // refusal are safe: connectServers skips names that already connected.
364
+ if (!projectConnected) {
365
+ const policy = projectServerPolicy(ctx.cwd, os.homedir())
366
+ const candidates = loadConfigFrom(projectConfigPaths(ctx.cwd))
367
+ const consented: Record<string, ServerConfig> = {}
368
+ const gated: Record<string, ServerConfig> = {}
369
+ for (const [name, config] of Object.entries(candidates)) {
370
+ if (policy.disabled.has(name)) continue
371
+ if (policy.consentAll || policy.consented.has(name)) consented[name] = config
372
+ else gated[name] = config
373
+ }
374
+ if (Object.keys(consented).length > 0) await connectServers(consented)
375
+ if (Object.keys(gated).length === 0) projectConnected = true
376
+ else if (await isProjectApproved(ctx)) {
377
+ projectConnected = true
378
+ await connectServers(gated)
379
+ }
333
380
  }
334
381
 
335
382
  pi.events.emit(MCP_TOOLS_CHANNEL, [...aliases])
@@ -8,8 +8,11 @@
8
8
  * tone and role. `/output-style` lists the styles and persists a choice to the
9
9
  * project's settings.local.json.
10
10
  *
11
- * pi keeps its own base system prompt (tools, safety); the style is layered on
12
- * top rather than replacing it wholesale.
11
+ * Claude semantics: a style replaces the built-in coding instructions unless its
12
+ * frontmatter sets `keep-coding-instructions: true`. The replacement excises pi's
13
+ * default coding prose up to a stable marker line and keeps everything after it
14
+ * (append text, project context, skills, other extensions' additions); when the
15
+ * marker is absent (custom SYSTEM.md), the style falls back to appending.
13
16
  *
14
17
  * Docs: https://code.claude.com/docs/en/output-styles.md
15
18
  */
@@ -25,6 +28,7 @@ export interface OutputStyle {
25
28
  name: string
26
29
  description: string
27
30
  body: string
31
+ keepCodingInstructions: boolean
28
32
  }
29
33
 
30
34
  function field(frontmatter: string, key: string): string {
@@ -37,7 +41,27 @@ export function parseStyle(content: string, fallbackName: string): OutputStyle {
37
41
  const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(content)
38
42
  const frontmatter = match ? match[1] : ''
39
43
  const body = match ? content.slice(match[0].length) : content
40
- return { name: field(frontmatter, 'name') || fallbackName, description: field(frontmatter, 'description'), body: body.trim() }
44
+ return { name: field(frontmatter, 'name') || fallbackName, description: field(frontmatter, 'description'), body: body.trim(), keepCodingInstructions: field(frontmatter, 'keep-coding-instructions') === 'true' }
45
+ }
46
+
47
+ /** Equivalents of Claude's built-in styles, shipped with pi-code as the
48
+ * lowest-precedence source: a user or project style of the same name wins. */
49
+ export const BUILTIN_STYLES_DIR = path.join(import.meta.dirname, 'internal', 'builtin-styles')
50
+
51
+ /** The last line of pi's default coding instructions. Everything after it (append
52
+ * text, project context, skills, cwd, other extensions' additions) survives a style
53
+ * replacement. Tracks pi's dist/core/system-prompt.js; a canary test pins it. */
54
+ export const CODING_BASE_MARKER = '- Always read pi .md files completely and follow links to related docs (e.g., tui.md for TUI API details)'
55
+
56
+ /** Apply a style per Claude semantics: replace the coding instructions unless the
57
+ * style keeps them; fall back to appending when the marker is absent. */
58
+ export function applyStyle(systemPrompt: string, style: OutputStyle): string {
59
+ const styleSection = `## Output Style: ${style.name}\n\n${style.body}`
60
+ if (!style.keepCodingInstructions) {
61
+ const idx = systemPrompt.indexOf(CODING_BASE_MARKER)
62
+ if (idx !== -1) return `${styleSection}${systemPrompt.slice(idx + CODING_BASE_MARKER.length)}`
63
+ }
64
+ return `${systemPrompt}\n\n${styleSection}`
41
65
  }
42
66
 
43
67
  function isDirectory(target: string): boolean {
@@ -132,7 +156,7 @@ export default function outputStylesExtension(pi: ExtensionAPI) {
132
156
  // project styles / selection once the project is approved. isProjectTrusted alone
133
157
  // is true for a repo pi never asked about; see project-approval.
134
158
  const trusted = await isProjectApproved(ctx)
135
- styles = loadStyles(styleDirs(ctx.cwd, home, trusted))
159
+ styles = loadStyles([BUILTIN_STYLES_DIR, ...styleDirs(ctx.cwd, home, trusted)])
136
160
  localSettingsPath = path.join(ctx.cwd, '.claude', 'settings.local.json')
137
161
  activeName = readActiveStyleName(settingsFiles(ctx.cwd, home, trusted))
138
162
  const active = styleForName(styles, activeName)
@@ -142,12 +166,24 @@ export default function outputStylesExtension(pi: ExtensionAPI) {
142
166
  pi.on('before_agent_start', async (event) => {
143
167
  const active = styleForName(styles, activeName)
144
168
  if (!active || active.body.length === 0) return
145
- return { systemPrompt: `${event.systemPrompt}\n\n## Output Style: ${active.name}\n\n${active.body}` }
169
+ return { systemPrompt: applyStyle(event.systemPrompt, active) }
146
170
  })
147
171
 
148
172
  pi.registerCommand('output-style', {
149
- description: 'Choose the active Claude output style',
150
- handler: async (_args, ctx) => {
173
+ description: 'Choose the active Claude output style (or /output-style <name>)',
174
+ handler: async (args, ctx) => {
175
+ const requested = args.trim()
176
+ if (requested) {
177
+ const picked = styles.find((style) => style.name.toLowerCase() === requested.toLowerCase())
178
+ if (!picked) {
179
+ ctx.ui.notify(`Unknown output style: ${requested}. Available: ${styles.map((style) => style.name).join(', ')}`, 'error')
180
+ return
181
+ }
182
+ activeName = picked.name
183
+ persistActiveStyle(localSettingsPath, picked.name)
184
+ ctx.ui.notify(`Output style set to ${picked.name} (applies next turn)`, 'info')
185
+ return
186
+ }
151
187
  if (!ctx.hasUI) {
152
188
  ctx.ui.notify('/output-style requires interactive mode', 'error')
153
189
  return