pi-code 1.0.70 → 1.0.71

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.
@@ -335,12 +335,16 @@ export default function commandsExtension(pi: ExtensionAPI) {
335
335
  // setModel can reject (e.g. auth resolution fails), and a floated rejection would
336
336
  // escape as unhandled; surface it as a no-op instead of leaving the session silently
337
337
  // on the command's override model.
338
- set: (model) => {
338
+ set: async (model) => {
339
339
  // A refused switch leaves the turn on the session model rather than the one the
340
- // command named, and the reply gives no sign of it, so the refusal is reported.
341
- void pi.setModel(model as Parameters<typeof pi.setModel>[0]).catch((error: unknown) => {
340
+ // command named, and the reply gives no sign of it, so the refusal is reported. The
341
+ // promise never rejects, so a caller that does not await it (agent_settled) cannot
342
+ // float a rejection; the shutdown handler awaits it so the exit cannot outrun it.
343
+ try {
344
+ await pi.setModel(model as Parameters<typeof pi.setModel>[0])
345
+ } catch (error) {
342
346
  console.warn(`pi-code-commands: could not switch to ${typeof model === 'object' && model !== null && 'id' in model ? String((model as { id: unknown }).id) : String(model)}: ${errorMessage(error)}`)
343
- })
347
+ }
344
348
  },
345
349
  })
346
350
  /** The thinking level to restore after a command's `effort:` override drove its run,
@@ -363,14 +367,23 @@ export default function commandsExtension(pi: ExtensionAPI) {
363
367
  pendingAgentRules = undefined
364
368
  pendingSkillRules = undefined
365
369
  pendingPathRules = undefined
366
- modelOverride.settle()
367
- effortOverride.settle()
370
+ void modelOverride.settle()
371
+ void effortOverride.settle()
368
372
  if (pendingRestore) {
369
373
  pi.setActiveTools(pendingRestore)
370
374
  pendingRestore = undefined
371
375
  }
372
376
  })
373
377
 
378
+ // agent_settled never fires when pi is quit mid-run (double Ctrl+C, Ctrl+D, a closed
379
+ // terminal, SIGTERM), and the switch is already in the transcript: `pi -c` resumed on the
380
+ // command's override model at its raised level. A run that settled has nothing armed, so
381
+ // this restores nothing twice.
382
+ pi.on('session_shutdown', async () => {
383
+ await modelOverride.settle()
384
+ await effortOverride.settle()
385
+ })
386
+
374
387
  // The active-tool set has no argument dimension, so a scoped grant hands the turn
375
388
  // the whole tool; the scope is enforced here instead, when the call arrives. Same
376
389
  // steering-not-sandbox caveat as plan mode's guard.
@@ -223,6 +223,12 @@ export default function hooksExtension(pi: ExtensionAPI) {
223
223
  let managedHooksOnly = false
224
224
  /** Skill hooks registered this session, re-applied when a settings edit reloads. */
225
225
  const registeredSkillHooks: Array<{ skillName: string; hooks: Record<string, unknown> }> = []
226
+ /** Whether this process is a subagent's child. Claude runs settings hooks inside a
227
+ * subagent for tool events, but Stop is the main agent's, a subagent completes with
228
+ * SubagentStop, and a subagent run is neither a session (SessionStart, SessionEnd) nor
229
+ * a prompt the user submitted (UserPromptSubmit). The child is a full pi process loading
230
+ * the same settings, so without this every one of them fired in it. */
231
+ const inSubagentChild = (): boolean => process.env.PI_CODE_SUBAGENT === '1'
226
232
  /** Stops the settings watcher of the previous session. */
227
233
  let disposeSettingsWatch: () => void = () => {}
228
234
  /** Claude's disableAllHooks escape hatch was set somewhere in the honored chain. */
@@ -466,7 +472,7 @@ export default function hooksExtension(pi: ExtensionAPI) {
466
472
  pendingSessionContext = []
467
473
  // "reload" re-fires in-process with the same conversation and would double-run hooks;
468
474
  // a fork is a genuine session begin, which Claude reports as source "fork".
469
- if (event.reason === 'reload') return
475
+ if (event.reason === 'reload' || inSubagentChild()) return
470
476
  const source = claudeSpelling(SESSION_START_SOURCE, event.reason)
471
477
  const commands = matchingCommands(config.SessionStart, source.names)
472
478
  const payload = { hook_event_name: 'SessionStart', source: source.value }
@@ -593,7 +599,7 @@ export default function hooksExtension(pi: ExtensionAPI) {
593
599
  pi.on('input', async (event, ctx) => {
594
600
  // Only genuine user input; extension-injected messages (plan-mode, subagent) are not
595
601
  // prompts the user submitted.
596
- if (event.source === 'extension') return { action: 'continue' }
602
+ if (event.source === 'extension' || inSubagentChild()) return { action: 'continue' }
597
603
  // The user typed, so the pending idle_prompt no longer applies.
598
604
  cancelIdlePrompt()
599
605
  // Genuine user input is progress, so it breaks a Stop-hook continuation streak: the
@@ -605,12 +611,15 @@ export default function hooksExtension(pi: ExtensionAPI) {
605
611
  ctx.ui.notify(decision.reason ?? 'Prompt blocked by hook', 'error')
606
612
  return { action: 'handled' }
607
613
  }
608
- // Claude injects a UserPromptSubmit hook's context ahead of the prompt; transform is
609
- // pi's seam for rewriting the submitted text. The prompt itself always survives:
610
614
  // "UserPromptSubmit: can't replace the prompt; it only injects additionalContext
611
- // alongside it". suppressOriginalPrompt scopes to the block message, which never
615
+ // alongside it", as a system reminder with no visible transcript entry. So the context
616
+ // is a hidden message next to the prompt and the prompt text is left alone: prepended,
617
+ // it moved a `/skill:` or `/template` invocation off position 0 and pi stopped
618
+ // expanding it. pi appends a "nextTurn" message to this very prompt (after this handler
619
+ // runs); one typed mid-stream never reaches before_agent_start, so it is queued in the
620
+ // prompt's own mode. suppressOriginalPrompt scopes to the block message, which never
612
621
  // carries the prompt here, so it needs nothing of its own.
613
- if (decision.context) return { action: 'transform', text: `${decision.context}\n\n${event.text}` }
622
+ if (decision.context) pi.sendMessage({ customType: 'claude-hook-context', content: decision.context, display: false }, { deliverAs: event.streamingBehavior ?? 'nextTurn' })
614
623
  return { action: 'continue' }
615
624
  })
616
625
 
@@ -630,6 +639,7 @@ export default function hooksExtension(pi: ExtensionAPI) {
630
639
  * turn cancels it. Observational only; exit codes and JSON output are ignored. */
631
640
  const armIdlePrompt = (ctx: ExtensionContext): void => {
632
641
  cancelIdlePrompt()
642
+ if (inSubagentChild()) return
633
643
  const notifyCommands = matchingCommands(config.Notification, ['idle_prompt'])
634
644
  if (notifyCommands.length > 0) {
635
645
  const runner = boundRunner(ctx)
@@ -639,21 +649,26 @@ export default function hooksExtension(pi: ExtensionAPI) {
639
649
  idlePromptTimer.unref?.()
640
650
  }
641
651
  }
652
+ /** In a subagent child, the agent-frontmatter Stop hooks were converted to SubagentStop
653
+ * and fire at the child's own end, notify-style, before the Stop early-returns, which do
654
+ * not apply to them. Only the agent's own frontmatter hooks (stamped agent:<name>): the
655
+ * settings-level SubagentStop is fired by the parent when the child completes, so the
656
+ * child firing it too ran it twice. */
657
+ const fireChildSubagentStop = async (event: { messages?: Array<{ role: string; content: unknown }> }, ctx: ExtensionContext): Promise<void> => {
658
+ if (!agentIdentity) return
659
+ const subStop = matchingCommands(config.SubagentStop, agentIdentity.agent).filter((command) => command.origin?.startsWith('agent:'))
660
+ if (subStop.length === 0) return
661
+ const subText = lastAssistantText(event.messages ?? [])
662
+ const subPayload = { hook_event_name: 'SubagentStop', agent_type: agentIdentity.agent, ...(agentIdentity.id ? { agent_id: agentIdentity.id } : {}), stop_hook_active: false, ...(subText ? { last_assistant_message: subText } : {}) }
663
+ await runNotifyHooks(subStop, subPayload, boundRunner(ctx)).catch(() => {})
664
+ }
665
+
642
666
  pi.on('agent_end', async (event, ctx) => {
643
667
  armIdlePrompt(ctx)
668
+ await fireChildSubagentStop(event as { messages?: Array<{ role: string; content: unknown }> }, ctx)
644
669
 
645
- // In a subagent child, the agent-frontmatter Stop hooks were converted to
646
- // SubagentStop and fire here, at the child's own end, notify-style; before the
647
- // Stop early-returns, which do not apply to them.
648
- if (agentIdentity) {
649
- const subStop = matchingCommands(config.SubagentStop, agentIdentity.agent)
650
- if (subStop.length > 0) {
651
- const subText = lastAssistantText((event as { messages?: Array<{ role: string; content: unknown }> }).messages ?? [])
652
- const subPayload = { hook_event_name: 'SubagentStop', agent_type: agentIdentity.agent, ...(agentIdentity.id ? { agent_id: agentIdentity.id } : {}), stop_hook_active: false, ...(subText ? { last_assistant_message: subText } : {}) }
653
- await runNotifyHooks(subStop, subPayload, boundRunner(ctx)).catch(() => {})
654
- }
655
- }
656
-
670
+ // Stop is the main agent's event; the child's completion was handled above.
671
+ if (inSubagentChild()) return
657
672
  // Stop has no matcher support (a stray matcher is ignored, as Claude documents)
658
673
  // and an `if`-carrying hook never runs on a non-tool event.
659
674
  const commands = allCommands(config.Stop).filter((command) => passesIfFilter(command, undefined))
@@ -771,13 +786,16 @@ export default function hooksExtension(pi: ExtensionAPI) {
771
786
  const reason = claudeSpelling(SESSION_END_REASON, event.reason)
772
787
  // SessionEnd rides Claude's short shared budget (see sessionEndTimeoutMs) so a
773
788
  // slow hook cannot stall session exit, /new or /resume.
774
- const sessionEndCommands = matchingCommands(config.SessionEnd, reason.names).filter((command) => passesIfFilter(command, undefined))
789
+ const sessionEndCommands = inSubagentChild() ? [] : matchingCommands(config.SessionEnd, reason.names).filter((command) => passesIfFilter(command, undefined))
775
790
  const runner = boundRunner(ctx)
776
791
  const results = await Promise.all(sessionEndCommands.map((command) => runner(command, { hook_event_name: 'SessionEnd', reason: reason.value }, sessionEndTimeoutMs(command))))
777
792
  surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
778
- // Claude kills async hooks still running at teardown; the session that spawned
779
- // these is over, and their delivery would target a disposed context anyway.
780
- for (const kill of backgroundKills) kill()
793
+ // Claude: "In non-interactive mode with the -p flag, Claude Code kills any async hook
794
+ // still running at teardown". Only there: an interactive session leaves them running, so
795
+ // an async SessionEnd hook, started by this very shutdown, can finish instead of being
796
+ // killed a microtask after it spawned. Their delivery targets a disposed context and is
797
+ // caught (see deliverBackgroundResult).
798
+ if (ctx.hasUI !== true) for (const kill of backgroundKills) kill()
781
799
  backgroundKills.clear()
782
800
  })
783
801
 
@@ -26,14 +26,15 @@ export interface TurnOverride<T> {
26
26
  reset(): void
27
27
  /** Restore the captured prior via `set` and clear the capture. A conditional override
28
28
  * stands down when the current value has moved off the target. No-op when nothing is
29
- * armed. */
30
- settle(): void
29
+ * armed. Returns what `set` returns, so a caller that must not exit before an async
30
+ * restore has landed (a shutdown) can await it. */
31
+ settle(): void | Promise<void>
31
32
  }
32
33
 
33
34
  export function createTurnOverride<T>(opts: {
34
35
  /** Applies the restore value. Consumers wrap their setter here (async-with-catch for
35
36
  * commands' model, plain for the thinking level). */
36
- set: (value: T) => void
37
+ set: (value: T) => void | Promise<void>
37
38
  /** Reads the current value, for a conditional restore. Only consulted when
38
39
  * `conditional` is set. */
39
40
  get?: () => T | undefined
@@ -67,7 +68,7 @@ export function createTurnOverride<T>(opts: {
67
68
  const current = opts.get?.()
68
69
  if (current !== undefined && current !== movedTo) return
69
70
  }
70
- opts.set(restore)
71
+ return opts.set(restore)
71
72
  },
72
73
  }
73
74
  }
@@ -91,11 +91,17 @@ export default function thinkingExtension(pi: ExtensionAPI) {
91
91
  // the keyword in the prompt.
92
92
  })
93
93
 
94
+ // agent_settled never fires when pi is quit mid-run, and the escalation is already in the
95
+ // transcript: `pi -c` would resume at the raised level for good.
96
+ pi.on('session_shutdown', async () => {
97
+ await override.settle()
98
+ })
99
+
94
100
  pi.on('agent_settled', () => {
95
101
  // Restore the pre-escalation level, but only if nothing else moved it since (a
96
102
  // command's effort restore, a manual change): the conditional override stands down in
97
103
  // that case and restores unconditionally when the level cannot be read, the prior
98
104
  // best-effort behavior.
99
- override.settle()
105
+ void override.settle()
100
106
  })
101
107
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-code",
3
- "version": "1.0.70",
3
+ "version": "1.0.71",
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, subagents, and goals",
5
5
  "keywords": [
6
6
  "pi",