@nredd/pi-subagents 0.22.0-nredd.8 → 0.22.0-nredd.9

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.
Files changed (50) hide show
  1. package/README.md +136 -55
  2. package/package.json +14 -11
  3. package/src/abortable.ts +1 -1
  4. package/src/agent-file-toggle.ts +0 -1
  5. package/src/agent-manager.ts +360 -103
  6. package/src/agent-runner.ts +121 -63
  7. package/src/child-context.ts +19 -1
  8. package/src/completion-outbox.ts +22 -3
  9. package/src/cross-extension-rpc.ts +10 -6
  10. package/src/custom-agents.ts +2 -1
  11. package/src/default-agents.ts +19 -17
  12. package/src/detached-results.ts +18 -2
  13. package/src/enterprise-local.ts +2 -1
  14. package/src/error-message.ts +11 -0
  15. package/src/git-path.ts +66 -0
  16. package/src/group-join.ts +4 -10
  17. package/src/index.ts +731 -231
  18. package/src/invocation-config.ts +1 -5
  19. package/src/mention-clone.ts +4 -3
  20. package/src/model-resolver.ts +51 -14
  21. package/src/model-selection.ts +17 -6
  22. package/src/model-tiers.ts +5 -0
  23. package/src/nested-tools.ts +80 -21
  24. package/src/notification-result.ts +118 -0
  25. package/src/plan-mode-children.ts +176 -0
  26. package/src/prompts.ts +7 -2
  27. package/src/quota-admission.ts +16 -6
  28. package/src/resource-loader.ts +20 -6
  29. package/src/schedule.ts +65 -19
  30. package/src/settings.ts +21 -1
  31. package/src/skill-loader.ts +244 -94
  32. package/src/status-note.ts +28 -5
  33. package/src/types.ts +52 -2
  34. package/src/ui/agent-mention.ts +5 -2
  35. package/src/ui/agent-widget.ts +41 -22
  36. package/src/ui/conversation-viewer.ts +4 -2
  37. package/src/ui/fallback-flag.ts +28 -4
  38. package/src/ui/fleet-list.ts +89 -54
  39. package/src/ui/notification-view.ts +4 -1
  40. package/src/ui/workflow-card.ts +1 -8
  41. package/src/ui/workflow-dialog.ts +31 -18
  42. package/src/workflow/host.ts +4 -5
  43. package/src/workflow/meta.ts +7 -4
  44. package/src/workflow/progress.ts +4 -3
  45. package/src/workflow/runtime.ts +23 -7
  46. package/src/workflow/saved.ts +2 -4
  47. package/src/workflow/task.ts +12 -7
  48. package/src/workflow/tool-description.ts +2 -2
  49. package/src/workflow/worker-source.ts +6 -2
  50. package/src/worktree.ts +309 -19
@@ -23,7 +23,8 @@ import {
23
23
  } from "@earendil-works/pi-coding-agent";
24
24
  import { Type } from "typebox";
25
25
  import { BUILTIN_TOOL_NAMES, getAgentConfig, getConfig, getMemoryToolNames, getReadOnlyMemoryToolNames, getToolNamesForType } from "./agent-types.js";
26
- import { runInChildSessionContext } from "./child-context.js";
26
+ import { bindChildResourceLoader, runInChildSessionContext } from "./child-context.js";
27
+ import { type Clock, realClock } from "./clock.js";
27
28
  import { buildParentContext, extractText } from "./context.js";
28
29
  import { DEFAULT_AGENTS } from "./default-agents.js";
29
30
  import { detectEnv } from "./env.js";
@@ -35,10 +36,12 @@ import type { ModelSelection } from "./model-selection.js";
35
36
  import {
36
37
  createNestedSubagentTools,
37
38
  getMaxSubagentDepth,
39
+ hasNestableTypes,
38
40
  NESTED_WORKFLOW_ERROR,
39
41
  NESTED_WORKFLOW_NO_AGENT_ERROR,
40
42
  type NestedAgentManager,
41
43
  } from "./nested-tools.js";
44
+ import { type PlanGate, planGateReason } from "./plan-mode-children.js";
42
45
  import { buildAgentPrompt, type PromptExtras } from "./prompts.js";
43
46
  import { createHomeResourceLoader } from "./resource-loader.js";
44
47
  import { preloadSkills } from "./skill-loader.js";
@@ -59,6 +62,7 @@ export const SUBAGENT_TOOL_NAMES = {
59
62
  WORKFLOW: "SubagentWorkflow",
60
63
  GET_RESULT: "get_subagent_result",
61
64
  STEER: "steer_subagent",
65
+ STOP: "stop_subagent",
62
66
  } as const;
63
67
 
64
68
  /**
@@ -302,11 +306,10 @@ export function installExtensionToolScope(
302
306
  /** Receives the scope check the child's nested-call gate extension applies. */
303
307
  nestedCallGate: NestedCallGate;
304
308
  /** Why workflow tools are refused. */
305
- workflowError?: string;
309
+ workflowError: string;
306
310
  },
307
311
  ): void {
308
- const { loader, toolNames, disallowedSet, extNames, narrowing, readmitToolNames, nestedCallGate } = ctx;
309
- const workflowError = ctx.workflowError ?? NESTED_WORKFLOW_ERROR;
312
+ const { loader, toolNames, disallowedSet, extNames, narrowing, readmitToolNames, nestedCallGate, workflowError } = ctx;
310
313
 
311
314
  // The names allowed right now. Mirrors the `ext:` opt-in flip: when any `ext:`
312
315
  // selector is present, extension tools become an explicit allowlist — a loaded
@@ -394,8 +397,14 @@ export function installExtensionToolScope(
394
397
  };
395
398
  }
396
399
 
397
- /** Set once the child's tool scope is installed; until then the gate admits everything. */
398
- type NestedCallGate = { allows?: (name: string) => boolean };
400
+ /**
401
+ * Set once the child's tool scope (and, while planning, its Plan check) is
402
+ * installed; until then the gate admits everything.
403
+ */
404
+ type NestedCallGate = {
405
+ allows?: (name: string) => boolean;
406
+ planReason?: (toolName: string, input: unknown, cwd: string) => string | undefined;
407
+ };
399
408
 
400
409
  /**
401
410
  * Inline extension that applies the subagent's tool scope to calls a tool makes
@@ -411,11 +420,13 @@ function scopeGateExtension(gate: NestedCallGate): InlineExtension {
411
420
  name: SCOPE_GATE_NAME,
412
421
  hidden: true,
413
422
  factory: (ext) => {
414
- ext.on("tool_call", (event) =>
415
- gate.allows?.(event.toolName) === false
416
- ? { block: true, reason: `Tool "${event.toolName}" is not available to this subagent.` }
417
- : undefined,
418
- );
423
+ ext.on("tool_call", (event, ctx) => {
424
+ if (gate.allows?.(event.toolName) === false) {
425
+ return { block: true, reason: `Tool "${event.toolName}" is not available to this subagent.` };
426
+ }
427
+ const reason = gate.planReason?.(event.toolName, event.input, ctx.cwd);
428
+ return reason === undefined ? undefined : { block: true, reason };
429
+ });
419
430
  },
420
431
  };
421
432
  }
@@ -495,6 +506,8 @@ export interface AssistantMessageInfo {
495
506
  export interface RunOptions {
496
507
  /** Backoff before each stale-ctx re-prompt (#145); defaults to 1s, 3s, 9s. `[]` disables recovery. */
497
508
  staleRetryDelaysMs?: readonly number[];
509
+ /** Clock that times the stale-ctx backoff; the manager passes its own. Defaults to native timers. */
510
+ clock?: Clock;
498
511
  /** ExtensionAPI instance — used for pi.exec() instead of execSync. */
499
512
  pi: ExtensionAPI;
500
513
  /** Manager-assigned id; suffixes session name to disambiguate parallel spawns (e.g. `Explore#a1b2c3d4`). */
@@ -600,6 +613,11 @@ export interface RunOptions {
600
613
  * fails at the call that wrote it rather than inside the child.
601
614
  */
602
615
  structuredOutput?: CompiledSchema;
616
+ /**
617
+ * The Plan restriction for this child's runs (#437). Installed once on the
618
+ * session and read per call; the manager sets `policy` per run.
619
+ */
620
+ planGate?: PlanGate;
603
621
  /** Runtime bridge for opt-in child-safe nested delegation. */
604
622
  nestedRuntime?: {
605
623
  manager: NestedAgentManager;
@@ -646,28 +664,40 @@ export interface RunResult {
646
664
  */
647
665
  function collectResponseText(session: AgentSession) {
648
666
  let text = "";
667
+ let laterTool = false;
668
+ let previousText = "";
669
+ let previousLaterTool = false;
649
670
  const unsubscribe = session.subscribe((event: AgentSessionEvent) => {
650
671
  // message_start also fires for user and toolResult messages — resetting on
651
672
  // those would wipe assistant text already collected. Reset only when a new
652
673
  // ASSISTANT message begins, so getText() is the last assistant message's text.
653
674
  if (event.type === "message_start" && event.message.role === "assistant") {
675
+ if (text.trim()) {
676
+ previousText = text;
677
+ previousLaterTool = laterTool;
678
+ }
654
679
  text = "";
680
+ laterTool = false;
655
681
  }
656
682
  if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
657
683
  text += event.assistantMessageEvent.delta;
658
684
  }
685
+ if (event.type === "tool_execution_end") {
686
+ if (text.trim()) laterTool = true;
687
+ else if (previousText.trim()) previousLaterTool = true;
688
+ }
659
689
  });
660
- return { getText: () => text, unsubscribe };
690
+ return { getText: () => text, getPreviousText: () => previousText, hasLaterTool: () => text.trim() ? laterTool : previousLaterTool, unsubscribe };
661
691
  }
662
692
 
663
693
  /**
664
694
  * Get the last non-empty assistant text produced during THIS invocation.
665
695
  * `startIndex` is the message count captured before the prompt, so the walk-back
666
696
  * never crosses into a previous turn: on a resume whose new turn failed empty,
667
- * this returns "" instead of the prior turn's answer (#144). Defaults to 0 (a
668
- * fresh spawn, where the whole history belongs to this run).
697
+ * this returns "" instead of the prior turn's answer (#144). A fresh spawn passes
698
+ * 0, since the whole history belongs to that run.
669
699
  */
670
- function getLastAssistantText(session: AgentSession, startIndex = 0): string {
700
+ function getLastAssistantText(session: AgentSession, startIndex: number): string {
671
701
  for (let i = session.messages.length - 1; i >= startIndex; i--) {
672
702
  const msg = session.messages[i];
673
703
  if (msg.role !== "assistant") continue;
@@ -703,7 +733,7 @@ export function forwardModelCall(event: AgentSessionEvent, emit: (event: ModelCa
703
733
  * no tool call pending, and a stop that was not an abort, a tool-use handoff or
704
734
  * a token-limit cut. Anything else is a teaser or a cut-off report.
705
735
  */
706
- function endedWithReport(session: AgentSession, startIndex = 0): boolean {
736
+ function endedWithReport(session: AgentSession, startIndex: number): boolean {
707
737
  for (let i = session.messages.length - 1; i >= startIndex; i--) {
708
738
  const msg = session.messages[i];
709
739
  if (msg.role !== "assistant") continue;
@@ -768,7 +798,7 @@ async function forceFinalReport(session: AgentSession, startIndex: number, signa
768
798
  * Bounded by `startIndex` (like the text fallback) so a resume that produced no
769
799
  * assistant message of its own never inherits a PRIOR turn's stop reason.
770
800
  */
771
- function finalTurnError(session: AgentSession, startIndex = 0): string | undefined {
801
+ function finalTurnError(session: AgentSession, startIndex: number): string | undefined {
772
802
  for (let i = session.messages.length - 1; i >= startIndex; i--) {
773
803
  const msg = session.messages[i];
774
804
  if (msg.role !== "assistant") continue;
@@ -789,21 +819,18 @@ const STALE_CTX_RETRY_DELAYS_MS: readonly number[] = [1_000, 3_000, 9_000];
789
819
  /** Sent when the session's last step died on a stale ctx; the transcript already holds the work so far. */
790
820
  const STALE_CTX_RETRY_PROMPT = "Your previous step was interrupted by a session reload before it produced output. Continue the task from where you left off.";
791
821
 
792
- /** Longest tool output echoed into a partial result. */
793
- const PARTIAL_TOOL_OUTPUT_CHARS = 1_500;
794
-
795
822
  /**
796
823
  * Resolve after `ms`, or as soon as `signal` aborts. Never rejects.
797
824
  */
798
- function sleep(ms: number, signal?: AbortSignal): Promise<void> {
825
+ function sleep(ms: number, signal: AbortSignal | undefined, clock: Clock): Promise<void> {
799
826
  return new Promise((resolve) => {
800
827
  if (signal?.aborted) return resolve();
801
828
  const done = () => {
802
- clearTimeout(timer);
829
+ timer.cancel();
803
830
  signal?.removeEventListener("abort", done);
804
831
  resolve();
805
832
  };
806
- const timer = setTimeout(done, ms);
833
+ const timer = clock.setTimeout(done, ms);
807
834
  signal?.addEventListener("abort", done, { once: true });
808
835
  });
809
836
  }
@@ -822,16 +849,22 @@ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
822
849
  * it to repeat side effects (a push, a PR). The continue prompt adds one user
823
850
  * turn and nothing else, so the tool batch is never replayed; a tool that did
824
851
  * run is in context as a result.
852
+ *
853
+ * Kept for stale-ctx failures that routing no longer causes itself: core now hands
854
+ * `route` the requesting session's context (#581), so another session's reload or
855
+ * disposal cannot invalidate this request, but an extension callback that captured
856
+ * a dead ctx can still throw, and only this turns that into a bounded retry and a report.
825
857
  */
826
858
  export async function retryStaleCtx(
827
859
  session: AgentSession,
828
860
  startIndex: number,
829
861
  signal: AbortSignal | undefined,
830
862
  delays: readonly number[] = STALE_CTX_RETRY_DELAYS_MS,
863
+ clock: Clock = realClock,
831
864
  ): Promise<void> {
832
865
  for (const delay of delays) {
833
866
  if (!isStaleCtxError(finalTurnError(session, startIndex))) return;
834
- await sleep(delay, signal);
867
+ await sleep(delay, signal, clock);
835
868
  if (signal?.aborted) return;
836
869
  await session.prompt(STALE_CTX_RETRY_PROMPT);
837
870
  }
@@ -847,26 +880,29 @@ function mayFollowUp(session: AgentSession, startIndex: number, signal: AbortSig
847
880
  return !signal?.aborted && staleCtxOutcome(session, startIndex) === undefined;
848
881
  }
849
882
 
850
- /**
851
- * What a run that died on a stale ctx had done: the last tool calls with the
852
- * last tool output, so the parent can see side effects (a pushed branch, an
853
- * opened PR) instead of "No output". Empty when the run did nothing.
854
- */
883
+ /** Bounded activity only: tool arguments, results and unknown names are never copied. */
855
884
  function partialProgress(session: AgentSession, startIndex: number): string {
856
885
  const calls: string[] = [];
857
- let lastOutput = "";
858
886
  for (let i = session.messages.length - 1; i >= startIndex && calls.length < 5; i--) {
859
887
  const msg = session.messages[i];
860
- if (msg.role === "toolResult" && !lastOutput) lastOutput = extractText(msg.content).trim();
861
- if (msg.role === "assistant") {
862
- for (const part of [...msg.content].reverse()) {
863
- if (part.type === "toolCall" && calls.length < 5) calls.unshift(part.name);
864
- }
865
- }
888
+ if (msg.role !== "toolResult") continue;
889
+ const name = BUILTIN_TOOL_NAMES.includes(msg.toolName) ? msg.toolName : "other tool";
890
+ calls.unshift(`${name} (${msg.isError ? "failed" : "completed"})`);
866
891
  }
867
- if (calls.length === 0) return "";
868
- const clipped = lastOutput.length > PARTIAL_TOOL_OUTPUT_CHARS ? `${lastOutput.slice(0, PARTIAL_TOOL_OUTPUT_CHARS)}...` : lastOutput;
869
- return `Last tool calls: ${calls.join(", ")}.${clipped ? `\nLast tool output:\n${clipped}` : ""}`;
892
+ return calls.length ? `Latest activity: ${calls.join(", ")}.` : "";
893
+ }
894
+
895
+ /** Mark prose as earlier progress when subsequent tool results exist. */
896
+ function failureText(session: AgentSession, startIndex: number, text: string, partial: string, laterTool: boolean, streamed: boolean): string {
897
+ if (!text) return partial;
898
+ if (streamed) return laterTool ? `Earlier progress:\n${text}\n\n${partial}` : text;
899
+ let hasLaterResult = false;
900
+ for (let i = session.messages.length - 1; i >= startIndex; i--) {
901
+ const msg = session.messages[i];
902
+ if (msg.role === "assistant" && extractText(msg.content).trim() === text) break;
903
+ if (msg.role === "toolResult") hasLaterResult = true;
904
+ }
905
+ return hasLaterResult ? `Earlier progress:\n${text}\n\n${partial}` : text;
870
906
  }
871
907
 
872
908
  /**
@@ -877,7 +913,7 @@ export function staleCtxOutcome(session: AgentSession, startIndex: number): { fa
877
913
  const error = finalTurnError(session, startIndex);
878
914
  if (!isStaleCtxError(error)) return undefined;
879
915
  return {
880
- failure: "interrupted by a session reload or replacement; the agent's model runtime went stale and retries did not recover it",
916
+ failure: `interrupted by a session reload or replacement; the agent's model runtime went stale and retries did not recover it: ${error}`,
881
917
  partial: partialProgress(session, startIndex),
882
918
  };
883
919
  }
@@ -995,11 +1031,20 @@ export async function runAgent(
995
1031
 
996
1032
  // Get parent system prompt for append-mode agents
997
1033
  const parentSystemPrompt = ctx.getSystemPrompt();
1034
+ const agentDir = resolveAgentDir(options.homeDir);
1035
+ // The child inherits the parent's project-trust decision. Pi otherwise defaults
1036
+ // SettingsManager to trusted, which could load project MCP configuration; resolve
1037
+ // it before resource loading so untrusted skills cannot shadow trusted resources.
1038
+ const projectTrusted = options.configCwd !== undefined && options.configProjectTrusted !== undefined
1039
+ ? options.configProjectTrusted
1040
+ : ctx.isProjectTrusted();
1041
+ const settingsManager = SettingsManager.create(configCwd, agentDir, { projectTrusted });
998
1042
 
999
1043
  // Build prompt extras (memory, skill preloading)
1000
1044
  const extras: PromptExtras = {};
1001
1045
  if (options.worktreeBase) extras.worktreeBase = options.worktreeBase;
1002
1046
  if (options.workflow && !options.structuredOutput) extras.workflowChild = true;
1047
+ if (options.planGate?.policy) extras.planMode = true;
1003
1048
 
1004
1049
  // Resolved here, not by each caller: mention and RPC spawns pass no `isolated`,
1005
1050
  // and the agent file's value must hold on every path.
@@ -1013,7 +1058,7 @@ export async function runAgent(
1013
1058
 
1014
1059
  // Skill preloading: when skills is string[], preload their content into prompt
1015
1060
  if (Array.isArray(skills)) {
1016
- const loaded = preloadSkills(skills, configCwd, options.homeDir);
1061
+ const loaded = await preloadSkills(skills, configCwd, { agentDir, settingsManager, homeDir: options.homeDir });
1017
1062
  if (loaded.length > 0) {
1018
1063
  extras.skillBlocks = loaded;
1019
1064
  }
@@ -1052,10 +1097,13 @@ export async function runAgent(
1052
1097
  ? options.nestedRuntime
1053
1098
  : undefined;
1054
1099
  // One answer for the prompt and the workflow refusal: does this child end up with `Agent`?
1055
- const agentAvailable = Boolean(
1100
+ // An allowlist that resolves to no enabled agent leaves Agent nothing to
1101
+ // accept; omit the tools rather than advertise "none" as a type (#331, #333).
1102
+ const nestingPossible = Boolean(
1056
1103
  agentConfig?.allowedSubagents && nestedRuntime && !isolated
1057
- && !agentConfig.disallowedTools?.includes("Agent"),
1104
+ && hasNestableTypes(agentConfig.allowedSubagents, { homeDir: options.homeDir, configCwd, projectTrusted }),
1058
1105
  );
1106
+ const agentAvailable = nestingPossible && !agentConfig?.disallowedTools?.includes("Agent");
1059
1107
  if (agentConfig?.allowedSubagents && !agentAvailable) extras.nestingDisabled = true;
1060
1108
 
1061
1109
  // Build system prompt from agent config
@@ -1074,8 +1122,6 @@ export async function runAgent(
1074
1122
  // Still pass noSkills: true since we don't need the skill loader to load them again.
1075
1123
  const noSkills = skills === false || Array.isArray(skills);
1076
1124
 
1077
- const agentDir = resolveAgentDir(options.homeDir);
1078
-
1079
1125
  // Extension loading:
1080
1126
  // - true → all default-discovered extensions
1081
1127
  // - false → none (noExtensions)
@@ -1133,14 +1179,6 @@ export async function runAgent(
1133
1179
  };
1134
1180
  };
1135
1181
 
1136
- // The child inherits the parent's project-trust decision. pi's SettingsManager
1137
- // defaults to trusted, so without this a subagent in a project the user declined
1138
- // to trust would load its `.pi` extensions and settings, and pi's built-in MCP
1139
- // would start the stdio servers in its `.pi/mcp.json`.
1140
- const projectTrusted = options.configCwd !== undefined && options.configProjectTrusted !== undefined
1141
- ? options.configProjectTrusted
1142
- : ctx.isProjectTrusted();
1143
- const settingsManager = SettingsManager.create(configCwd, agentDir, { projectTrusted });
1144
1182
  // That trust covers the parent's project (`configCwd`), not a directory a spawn
1145
1183
  // was sent into. The session answers `ctx.isProjectTrusted()` for its own cwd,
1146
1184
  // which pi's MCP uses to read `<cwd>/.pi/mcp.json` — so a cross-directory session
@@ -1187,7 +1225,7 @@ export async function runAgent(
1187
1225
  systemPromptOverride: () => systemPrompt,
1188
1226
  appendSystemPromptOverride: () => [],
1189
1227
  }, options.homeDir);
1190
- await runInChildSessionContext(() => loader.reload());
1228
+ await bindChildResourceLoader(loader).reload();
1191
1229
 
1192
1230
  // Plain entries in `tools:` are expected to be built-in names (extension tools
1193
1231
  // go through `ext:`), so an unknown name there is unambiguously a typo. Previously
@@ -1272,7 +1310,7 @@ export async function runAgent(
1272
1310
  // set `allowed_subagents` and a nestedRuntime was provided — and never when
1273
1311
  // isolated. Their names collide with EXCLUDED_TOOL_NAMES by design, so the
1274
1312
  // scoping below re-admits them explicitly (registry deny + active-set narrow).
1275
- const nestedTools = agentConfig?.allowedSubagents && nestedRuntime && !isolated
1313
+ const nestedTools = agentConfig?.allowedSubagents && nestedRuntime && nestingPossible
1276
1314
  ? createNestedSubagentTools({
1277
1315
  manager: nestedRuntime.manager,
1278
1316
  pi: options.pi,
@@ -1481,6 +1519,21 @@ export async function runAgent(
1481
1519
  });
1482
1520
  }
1483
1521
 
1522
+ // Plan mode (#437): outermost, so a call Plan refuses reaches no other hook.
1523
+ // Read per call, because the manager decides the gate per run (spawn, resume).
1524
+ const planGate = options.planGate;
1525
+ if (planGate) {
1526
+ const planReason = (toolName: string, input: unknown, cwd: string) =>
1527
+ planGateReason(planGate, { toolName, input, cwd }, () => session.getAllTools(), readmitToolNames);
1528
+ nestedCallGate.planReason = planReason;
1529
+ const priorBeforeToolCall = session.agent.beforeToolCall;
1530
+ session.agent.beforeToolCall = async (context, signal) => {
1531
+ const reason = planReason(context.toolCall.name, context.args, effectiveCwd);
1532
+ if (reason !== undefined) return { block: true, reason };
1533
+ return priorBeforeToolCall?.(context, signal);
1534
+ };
1535
+ }
1536
+
1484
1537
  options.onSessionCreated?.(session);
1485
1538
 
1486
1539
  const run = observeRun(session, { ...options, maxTurns: resolveEffectiveMaxTurns(type, options.maxTurns) });
@@ -1506,7 +1559,7 @@ export async function runAgent(
1506
1559
  // pi's `session.abort()` is a no-op until a run is active — so a child whose
1507
1560
  // parent is already gone is not prompted at all.
1508
1561
  if (options.signal?.aborted !== true) await session.prompt(effectivePrompt);
1509
- await retryStaleCtx(session, startLen, options.signal, options.staleRetryDelaysMs);
1562
+ await retryStaleCtx(session, startLen, options.signal, options.staleRetryDelaysMs, options.clock);
1510
1563
 
1511
1564
  // A cap fired. The wrap-up steer is only a request: a model deep in a tool
1512
1565
  // loop keeps calling tools through the grace turns and is then aborted
@@ -1537,7 +1590,7 @@ export async function runAgent(
1537
1590
  }
1538
1591
 
1539
1592
  const stale = staleCtxOutcome(session, startLen);
1540
- const responseText = collector.getText().trim() || getLastAssistantText(session, startLen) || stale?.partial || "";
1593
+ const text = collector.getText().trim() || getLastAssistantText(session, startLen);
1541
1594
  // A child asked for structured output that never gave any has failed, however
1542
1595
  // articulate its prose was. Reported through `failure` so it travels the same
1543
1596
  // path as a provider error rather than arriving as a successful empty answer.
@@ -1546,12 +1599,14 @@ export async function runAgent(
1546
1599
  ? `The agent's StructuredOutput call did not match the required schema: ${structuredCapture.lastError}`
1547
1600
  : "The agent did not report its answer through StructuredOutput."
1548
1601
  : undefined;
1602
+ const failure = stale?.failure ?? finalTurnError(session, startLen) ?? structuredFailure;
1603
+ const streamed = collector.getText().trim() || collector.getPreviousText().trim();
1549
1604
  return {
1550
- responseText,
1605
+ responseText: failure ? failureText(session, startLen, streamed || text, stale?.partial ?? partialProgress(session, startLen), collector.hasLaterTool(), Boolean(streamed)) : text,
1551
1606
  session,
1552
1607
  aborted: run.aborted,
1553
1608
  steered: run.steered,
1554
- failure: stale?.failure ?? finalTurnError(session, startLen) ?? structuredFailure,
1609
+ failure,
1555
1610
  ...(structuredCapture?.json !== undefined ? { structuredJson: structuredCapture.json } : {}),
1556
1611
  ...(structuredRetried ? { structuredRetried } : {}),
1557
1612
  };
@@ -1566,7 +1621,7 @@ export async function runAgent(
1566
1621
  export async function resumeAgent(
1567
1622
  session: AgentSession,
1568
1623
  prompt: string,
1569
- options: Pick<RunOptions, "staleRetryDelaysMs" | "maxTurns" | "onTurnEnd" | "onTextDelta" | "onModelCall" | "onToolActivity" | "onAssistantUsage" | "onCompaction" | "signal"> & {
1624
+ options: Pick<RunOptions, "staleRetryDelaysMs" | "clock" | "maxTurns" | "onTurnEnd" | "onTextDelta" | "onModelCall" | "onToolActivity" | "onAssistantUsage" | "onCompaction" | "signal"> & {
1570
1625
  /** The agent's type, for its own `max_turns`. */
1571
1626
  type: SubagentType;
1572
1627
  },
@@ -1582,7 +1637,7 @@ export async function resumeAgent(
1582
1637
  try {
1583
1638
  // Already aborted: not prompted, as in `runAgent`.
1584
1639
  if (options.signal?.aborted !== true) await session.prompt(prompt);
1585
- await retryStaleCtx(session, startLen, options.signal, options.staleRetryDelaysMs);
1640
+ await retryStaleCtx(session, startLen, options.signal, options.staleRetryDelaysMs, options.clock);
1586
1641
  if (run.steered && mayFollowUp(session, startLen, options.signal) && !endedWithReport(session, startLen)) {
1587
1642
  run.finalizing = true;
1588
1643
  if (await forceFinalReport(session, startLen, options.signal)) run.aborted = false;
@@ -1594,9 +1649,12 @@ export async function resumeAgent(
1594
1649
  }
1595
1650
 
1596
1651
  const stale = staleCtxOutcome(session, startLen);
1652
+ const failure = stale?.failure ?? finalTurnError(session, startLen);
1653
+ const text = collector.getText().trim() || getLastAssistantText(session, startLen);
1654
+ const streamed = collector.getText().trim() || collector.getPreviousText().trim();
1597
1655
  return {
1598
- text: collector.getText().trim() || getLastAssistantText(session, startLen) || stale?.partial || "",
1599
- failure: stale?.failure ?? finalTurnError(session, startLen),
1656
+ text: failure ? failureText(session, startLen, streamed || text, stale?.partial ?? partialProgress(session, startLen), collector.hasLaterTool(), Boolean(streamed)) : text,
1657
+ failure,
1600
1658
  aborted: run.aborted,
1601
1659
  steered: run.steered,
1602
1660
  };
@@ -4,7 +4,13 @@ import { AsyncLocalStorage } from "node:async_hooks";
4
4
  * Marks resource loading/session construction performed for a subagent. This is
5
5
  * async-context-local so concurrent top-level extension work is unaffected.
6
6
  */
7
- const childSessionContext = new AsyncLocalStorage<boolean>();
7
+ // Pi reloads extension modules with jiti's module cache disabled. The parent
8
+ // runner and the discovered child extension can therefore hold different module
9
+ // instances, but must consult the same async-local session marker.
10
+ const contextKey = Symbol.for("pi-subagents:child-session-context");
11
+ const globals = globalThis as typeof globalThis & { [contextKey]?: AsyncLocalStorage<boolean> };
12
+ const childSessionContext = globals[contextKey] ?? new AsyncLocalStorage<boolean>();
13
+ globals[contextKey] = childSessionContext;
8
14
 
9
15
  export function inChildSessionContext(): boolean {
10
16
  return childSessionContext.getStore() === true;
@@ -13,3 +19,15 @@ export function inChildSessionContext(): boolean {
13
19
  export function runInChildSessionContext<T>(fn: () => Promise<T>): Promise<T> {
14
20
  return childSessionContext.run(true, fn);
15
21
  }
22
+
23
+ /**
24
+ * Make every future `reload()` of a child session's loader run in the child
25
+ * context. A session reload (e.g. an extension calling `ctx.reload()`) calls
26
+ * `loader.reload()` again and re-runs each extension factory, long after the
27
+ * construction-time {@link runInChildSessionContext} has exited.
28
+ */
29
+ export function bindChildResourceLoader<T extends { reload(): Promise<void> }>(loader: T): T {
30
+ const reload = loader.reload.bind(loader);
31
+ loader.reload = () => runInChildSessionContext(reload);
32
+ return loader;
33
+ }
@@ -27,18 +27,28 @@ export const NOTIFICATION_HOLD_MS = 200;
27
27
  export interface OutboxMessage {
28
28
  content: string;
29
29
  details: NotificationDetails;
30
+ /** Agents whose whole result the content carries: sending it reads their result. */
31
+ delivered?: string[];
32
+ /** Agents whose whole result the content carries, failures included: the parent has seen it, though it stays unread in FleetView. */
33
+ noticed?: string[];
30
34
  }
31
35
 
32
36
  /**
33
37
  * A notification for one or more agents, built when they finished: a resume
34
38
  * before the send must not rewrite it. Reading an agent's result cancels its
35
- * id, and an item left with none is dropped; `build` gets the ids still pending.
39
+ * id, and an item left with none is dropped; `build` gets the ids still pending
40
+ * and the result length the batch allots each (see `notification-result.ts`).
36
41
  */
37
42
  export interface OutboxItem {
38
43
  ids: string[];
39
- build(ids: readonly string[]): OutboxMessage;
44
+ /** Each agent's full result length, for the batch to split its budget by. */
45
+ resultChars?: Readonly<Record<string, number>>;
46
+ build(ids: readonly string[], caps: Readonly<Record<string, number>>): OutboxMessage;
40
47
  }
41
48
 
49
+ /** Result length per agent of one batch, from each agent's full length. */
50
+ export type ResultAllotment = (chars: Readonly<Record<string, number>>) => Record<string, number>;
51
+
42
52
  export class CompletionOutbox {
43
53
  private items: OutboxItem[] = [];
44
54
  private timer: TimerHandle | undefined;
@@ -50,6 +60,8 @@ export class CompletionOutbox {
50
60
  private parentRunning: () => boolean,
51
61
  /** Shared with the scheduler by hosts that control time in-process. */
52
62
  private clock: Clock = realClock,
63
+ /** Splits a batch's result budget; by default nothing is allotted and each item keeps its own cap. */
64
+ private allot: ResultAllotment = () => ({}),
53
65
  ) {}
54
66
 
55
67
  /** A newer completion of the same agent (a resume) replaces its pending one. */
@@ -101,7 +113,10 @@ export class CompletionOutbox {
101
113
  this.timer = undefined;
102
114
  const items = this.items;
103
115
  this.items = [];
104
- const messages = items.map(item => item.build(item.ids));
116
+ const chars: Record<string, number> = {};
117
+ for (const item of items) for (const id of item.ids) if (item.resultChars?.[id] !== undefined) chars[id] = item.resultChars[id];
118
+ const caps = this.allot(chars);
119
+ const messages = items.map(item => item.build(item.ids, caps));
105
120
  return messages.length > 0 ? combine(messages) : undefined;
106
121
  }
107
122
 
@@ -118,8 +133,12 @@ export class CompletionOutbox {
118
133
  function combine(messages: OutboxMessage[]): OutboxMessage {
119
134
  if (messages.length === 1) return messages[0];
120
135
  const [first, ...rest] = messages.flatMap(({ details: { others, ...own } }) => [own, ...(others ?? [])]);
136
+ const delivered = messages.flatMap(m => m.delivered ?? []);
137
+ const noticed = messages.flatMap(m => m.noticed ?? []);
121
138
  return {
122
139
  content: `${messages.length} background tasks finished.\n\n${messages.map(m => m.content).join("\n\n")}`,
123
140
  details: { ...first, others: rest },
141
+ ...(delivered.length > 0 ? { delivered } : {}),
142
+ ...(noticed.length > 0 ? { noticed } : {}),
124
143
  };
125
144
  }
@@ -16,6 +16,7 @@
16
16
  import { isTopLevelAgent } from "./agent-manager.js";
17
17
  import type { ModelRegistry } from "./model-resolver.js";
18
18
  import { selectModel } from "./model-selection.js";
19
+ import { planSteerBlocked, planSteerRefusal } from "./plan-mode-children.js";
19
20
  import type { AgentRecord } from "./types.js";
20
21
 
21
22
  /** Minimal event bus interface needed by the RPC handlers. */
@@ -39,11 +40,11 @@ export interface SpawnCapable {
39
40
  awaitStartup(id: string): Promise<void>;
40
41
  abort(id: string): boolean;
41
42
  /**
42
- * The record behind an id, for the stop handler's ownership check. Narrowed
43
- * to the two fields `isTopLevelAgent` reads, so the RPC layer keeps its
44
- * deliberately shallow view of the manager.
43
+ * The record behind an id, for the stop and steer handlers' ownership check
44
+ * and the steer handler's Plan check. Narrowed to the fields those read, so
45
+ * the RPC layer keeps its deliberately shallow view of the manager.
45
46
  */
46
- getRecord(id: string): Pick<AgentRecord, "parentAgentId" | "workflowId"> | undefined;
47
+ getRecord(id: string): Pick<AgentRecord, "parentAgentId" | "workflowId" | "status" | "planGate"> | undefined;
47
48
  /**
48
49
  * Mark a settled agent's result as read by the caller, suppressing the
49
50
  * completion notification — what `get_subagent_result` does when it returns
@@ -152,10 +153,11 @@ export function registerRpcHandlers(deps: RpcDeps): RpcHandle {
152
153
  // a live hole: no RPC hands out agent ids, so a caller has no ordinary way to
153
154
  // name one it does not own — but the guard is cheap and the id may leak some
154
155
  // other way. Same refuse-what-we-should-not-touch stance as `consume` below.
155
- const assertOwned = (agentId: string): void => {
156
+ const assertOwned = (agentId: string) => {
156
157
  const record = manager.getRecord(agentId);
157
158
  if (!record) throw new Error("Agent not found");
158
159
  if (!isTopLevelAgent(record)) throw new Error("Agent is owned by another agent or workflow");
160
+ return record;
159
161
  };
160
162
 
161
163
  const unsubStop = handleRpc<{ requestId: string; agentId: string }>(
@@ -187,7 +189,9 @@ export function registerRpcHandlers(deps: RpcDeps): RpcHandle {
187
189
  const unsubSteer = handleRpc<{ requestId: string; agentId: string; message: unknown }>(
188
190
  events, "subagents:rpc:steer", ({ agentId, message }) => {
189
191
  if (typeof message !== "string" || message.trim().length === 0) throw new Error("Steer message must be a non-empty string");
190
- assertOwned(agentId);
192
+ const record = assertOwned(agentId);
193
+ // Same refusal as `steer_subagent`, so another extension cannot steer a pre-Plan agent into edits.
194
+ if (planSteerBlocked(record, events)) throw new Error(planSteerRefusal(agentId));
191
195
  if (!manager.steer(agentId, message)) throw new Error("Agent is not running");
192
196
  },
193
197
  );
@@ -6,6 +6,7 @@ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
6
6
  import { basename, join } from "node:path";
7
7
  import { parseFrontmatter } from "@earendil-works/pi-coding-agent";
8
8
  import { BUILTIN_TOOL_NAMES } from "./agent-types.js";
9
+ import { errorMessage } from "./error-message.js";
9
10
  import { resolveAgentDir } from "./home-paths.js";
10
11
  import {
11
12
  isPackageResourceExcluded,
@@ -290,7 +291,7 @@ function readAgentFile(path: string, strict: boolean): { frontmatter: Record<str
290
291
  try {
291
292
  return parseAgentFrontmatter<Record<string, unknown>>(readFileSync(path, "utf-8"));
292
293
  } catch (err) {
293
- const reason = err instanceof Error ? err.message : String(err);
294
+ const reason = errorMessage(err);
294
295
  if (strict) throw new Error(`${path}: ${reason}`);
295
296
  warnIfNew(`Skipping agent file ${path}: ${reason}`);
296
297
  return undefined;