@nredd/pi-subagents 0.22.0-nredd.7 → 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 (61) hide show
  1. package/README.md +245 -77
  2. package/package.json +16 -13
  3. package/src/abortable.ts +1 -1
  4. package/src/agent-file-toggle.ts +6 -5
  5. package/src/agent-manager.ts +489 -129
  6. package/src/agent-runner.ts +244 -93
  7. package/src/child-context.ts +19 -1
  8. package/src/completion-outbox.ts +22 -3
  9. package/src/cross-extension-rpc.ts +21 -42
  10. package/src/custom-agents.ts +11 -6
  11. package/src/default-agents.ts +21 -21
  12. package/src/detached-results.ts +18 -2
  13. package/src/enabled-models.ts +8 -7
  14. package/src/enterprise-local.ts +7 -6
  15. package/src/error-message.ts +11 -0
  16. package/src/git-path.ts +66 -0
  17. package/src/group-join.ts +4 -10
  18. package/src/home-paths.ts +27 -0
  19. package/src/index.ts +898 -297
  20. package/src/invocation-config.ts +4 -21
  21. package/src/memory.ts +9 -9
  22. package/src/mention-clone.ts +13 -8
  23. package/src/model-resolver.ts +92 -25
  24. package/src/model-scope.ts +2 -1
  25. package/src/model-selection.ts +170 -0
  26. package/src/model-tiers.ts +176 -0
  27. package/src/nested-tools.ts +99 -44
  28. package/src/notification-result.ts +118 -0
  29. package/src/output-file.ts +23 -1
  30. package/src/package-resources.ts +25 -10
  31. package/src/plan-mode-children.ts +176 -0
  32. package/src/process-budget.ts +6 -3
  33. package/src/prompts.ts +7 -2
  34. package/src/quota-admission.ts +35 -13
  35. package/src/resource-loader.ts +65 -0
  36. package/src/schedule.ts +94 -30
  37. package/src/settings.ts +63 -13
  38. package/src/skill-loader.ts +244 -93
  39. package/src/stale-ctx.ts +16 -0
  40. package/src/status-note.ts +28 -5
  41. package/src/types.ts +79 -2
  42. package/src/ui/agent-mention.ts +5 -2
  43. package/src/ui/agent-row.ts +13 -3
  44. package/src/ui/agent-widget.ts +55 -25
  45. package/src/ui/conversation-viewer.ts +10 -5
  46. package/src/ui/fallback-flag.ts +114 -0
  47. package/src/ui/fleet-list.ts +431 -86
  48. package/src/ui/fleet-state.ts +154 -0
  49. package/src/ui/notification-view.ts +22 -3
  50. package/src/ui/workflow-card.ts +6 -9
  51. package/src/ui/workflow-dialog.ts +36 -13
  52. package/src/workflow/host.ts +47 -50
  53. package/src/workflow/journal.ts +13 -1
  54. package/src/workflow/meta.ts +7 -4
  55. package/src/workflow/progress.ts +8 -3
  56. package/src/workflow/runtime.ts +48 -15
  57. package/src/workflow/saved.ts +21 -21
  58. package/src/workflow/task.ts +22 -7
  59. package/src/workflow/tool-description.ts +2 -2
  60. package/src/workflow/worker-source.ts +162 -40
  61. package/src/worktree.ts +309 -19
@@ -14,31 +14,38 @@ import {
14
14
  createCodemodeExtension,
15
15
  createMcpExtension,
16
16
  createToolSearchExtension,
17
- DefaultResourceLoader,
17
+ type DefaultResourceLoader,
18
18
  defineTool,
19
19
  type ExtensionAPI,
20
- getAgentDir,
21
20
  type InlineExtension,
22
21
  SessionManager,
23
22
  SettingsManager,
24
23
  } from "@earendil-works/pi-coding-agent";
25
24
  import { Type } from "typebox";
26
25
  import { BUILTIN_TOOL_NAMES, getAgentConfig, getConfig, getMemoryToolNames, getReadOnlyMemoryToolNames, getToolNamesForType } from "./agent-types.js";
27
- import { runInChildSessionContext } from "./child-context.js";
26
+ import { bindChildResourceLoader, runInChildSessionContext } from "./child-context.js";
27
+ import { type Clock, realClock } from "./clock.js";
28
28
  import { buildParentContext, extractText } from "./context.js";
29
29
  import { DEFAULT_AGENTS } from "./default-agents.js";
30
30
  import { detectEnv } from "./env.js";
31
+ import { normalizeHomePath, resolveAgentDir } from "./home-paths.js";
31
32
  import { buildMemoryBlock, buildReadOnlyMemoryBlock } from "./memory.js";
32
33
  import type { ModelCallEvent } from "./model-call.js";
34
+ import type { ModelFallback } from "./model-resolver.js";
35
+ import type { ModelSelection } from "./model-selection.js";
33
36
  import {
34
37
  createNestedSubagentTools,
35
38
  getMaxSubagentDepth,
39
+ hasNestableTypes,
36
40
  NESTED_WORKFLOW_ERROR,
37
41
  NESTED_WORKFLOW_NO_AGENT_ERROR,
38
42
  type NestedAgentManager,
39
43
  } from "./nested-tools.js";
44
+ import { type PlanGate, planGateReason } from "./plan-mode-children.js";
40
45
  import { buildAgentPrompt, type PromptExtras } from "./prompts.js";
46
+ import { createHomeResourceLoader } from "./resource-loader.js";
41
47
  import { preloadSkills } from "./skill-loader.js";
48
+ import { isStaleCtxError } from "./stale-ctx.js";
42
49
  import { createStructuredCapture, createStructuredOutputTool, structuredRetryPrompt } from "./structured-output.js";
43
50
  import type { SubagentType, ThinkingLevel } from "./types.js";
44
51
  import type { LifetimeUsage } from "./usage.js";
@@ -55,6 +62,7 @@ export const SUBAGENT_TOOL_NAMES = {
55
62
  WORKFLOW: "SubagentWorkflow",
56
63
  GET_RESULT: "get_subagent_result",
57
64
  STEER: "steer_subagent",
65
+ STOP: "stop_subagent",
58
66
  } as const;
59
67
 
60
68
  /**
@@ -187,6 +195,7 @@ export function extensionCanonicalNames(extPath: string): string[] {
187
195
  export function parseExtensionsSpec(
188
196
  entries: string[],
189
197
  cwd: string,
198
+ homeDir = homedir(),
190
199
  ): { names: Set<string>; paths: string[]; wildcard: boolean } {
191
200
  const names = new Set<string>();
192
201
  const paths: string[] = [];
@@ -202,10 +211,10 @@ export function parseExtensionsSpec(
202
211
  names.add(entry.toLowerCase());
203
212
  continue;
204
213
  }
214
+ // This frontmatter caller historically expands both slash forms on every
215
+ // platform, without interpreting file URLs or Windows shell drive forms.
205
216
  let p = entry;
206
- if (p === "~" || p.startsWith("~/") || p.startsWith("~\\")) {
207
- p = homedir() + p.slice(1);
208
- }
217
+ if (p === "~" || p.startsWith("~/") || p.startsWith("~\\")) p = homeDir + p.slice(1);
209
218
  const abs = isAbsolute(p) ? p : resolve(cwd, p);
210
219
  paths.push(abs);
211
220
  names.add(extensionCanonicalName(abs));
@@ -297,11 +306,10 @@ export function installExtensionToolScope(
297
306
  /** Receives the scope check the child's nested-call gate extension applies. */
298
307
  nestedCallGate: NestedCallGate;
299
308
  /** Why workflow tools are refused. */
300
- workflowError?: string;
309
+ workflowError: string;
301
310
  },
302
311
  ): void {
303
- const { loader, toolNames, disallowedSet, extNames, narrowing, readmitToolNames, nestedCallGate } = ctx;
304
- const workflowError = ctx.workflowError ?? NESTED_WORKFLOW_ERROR;
312
+ const { loader, toolNames, disallowedSet, extNames, narrowing, readmitToolNames, nestedCallGate, workflowError } = ctx;
305
313
 
306
314
  // The names allowed right now. Mirrors the `ext:` opt-in flip: when any `ext:`
307
315
  // selector is present, extension tools become an explicit allowlist — a loaded
@@ -389,8 +397,14 @@ export function installExtensionToolScope(
389
397
  };
390
398
  }
391
399
 
392
- /** Set once the child's tool scope is installed; until then the gate admits everything. */
393
- 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
+ };
394
408
 
395
409
  /**
396
410
  * Inline extension that applies the subagent's tool scope to calls a tool makes
@@ -406,11 +420,13 @@ function scopeGateExtension(gate: NestedCallGate): InlineExtension {
406
420
  name: SCOPE_GATE_NAME,
407
421
  hidden: true,
408
422
  factory: (ext) => {
409
- ext.on("tool_call", (event) =>
410
- gate.allows?.(event.toolName) === false
411
- ? { block: true, reason: `Tool "${event.toolName}" is not available to this subagent.` }
412
- : undefined,
413
- );
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
+ });
414
430
  },
415
431
  };
416
432
  }
@@ -474,37 +490,6 @@ const FINAL_REPORT_PROMPT =
474
490
  + "Write your complete final report now: everything you found, concrete file paths and line numbers, "
475
491
  + "what you changed or concluded, and anything unfinished or unverified.";
476
492
 
477
- /**
478
- * Try to find the right model for an agent type.
479
- * Priority: explicit option > config.model > parent model.
480
- */
481
- export function resolveDefaultModel(
482
- parentModel: Model<any> | undefined,
483
- registry: { find(provider: string, modelId: string): Model<any> | undefined; getAvailable?(): Model<any>[] },
484
- configModel?: string,
485
- ): Model<any> | undefined {
486
- if (configModel) {
487
- const slashIdx = configModel.indexOf("/");
488
- if (slashIdx !== -1) {
489
- const provider = configModel.slice(0, slashIdx);
490
- const modelId = configModel.slice(slashIdx + 1);
491
-
492
- // Build a set of available model keys for fast lookup
493
- const available = registry.getAvailable?.();
494
- const availableKeys = available
495
- ? new Set(available.map((m: any) => `${m.provider}/${m.id}`))
496
- : undefined;
497
- const isAvailable = (p: string, id: string) =>
498
- !availableKeys || availableKeys.has(`${p}/${id}`);
499
-
500
- const found = registry.find(provider, modelId);
501
- if (found && isAvailable(provider, modelId)) return found;
502
- }
503
- }
504
-
505
- return parentModel;
506
- }
507
-
508
493
  /** Info about a tool event in the subagent. */
509
494
  export interface ToolActivity {
510
495
  type: "start" | "end";
@@ -519,11 +504,17 @@ export interface AssistantMessageInfo {
519
504
  }
520
505
 
521
506
  export interface RunOptions {
507
+ /** Backoff before each stale-ctx re-prompt (#145); defaults to 1s, 3s, 9s. `[]` disables recovery. */
508
+ staleRetryDelaysMs?: readonly number[];
509
+ /** Clock that times the stale-ctx backoff; the manager passes its own. Defaults to native timers. */
510
+ clock?: Clock;
522
511
  /** ExtensionAPI instance — used for pi.exec() instead of execSync. */
523
512
  pi: ExtensionAPI;
524
513
  /** Manager-assigned id; suffixes session name to disambiguate parallel spawns (e.g. `Explore#a1b2c3d4`). */
525
514
  agentId?: string;
526
515
  model?: Model<any>;
516
+ /** Required admitted spawn outcome. The runner never resolves or admits models. */
517
+ modelSelection: ModelSelection;
527
518
  maxTurns?: number;
528
519
  signal?: AbortSignal;
529
520
  /** Applies only when the agent file leaves `isolated:` unset — frontmatter is authoritative. */
@@ -557,6 +548,8 @@ export interface RunOptions {
557
548
  workflow?: boolean;
558
549
  /** Override working directory (e.g. for worktree isolation). */
559
550
  cwd?: string;
551
+ /** Home directory for tilde expansion and user-scoped storage. Defaults at run time. */
552
+ homeDir?: string;
560
553
  /**
561
554
  * Directory the worktree copy was created from. Set only when `cwd` points
562
555
  * into a worktree — the prompt then tells the agent to stay in the copy
@@ -592,6 +585,8 @@ export interface RunOptions {
592
585
  /** Called for every streaming event of each model call, so a UI can say what an in-flight call is doing. */
593
586
  onModelCall?: (event: ModelCallEvent) => void;
594
587
  onSessionCreated?: (session: AgentSession) => void;
588
+ /** Direct runner callers can observe the same configured fallback as the shared spawn. */
589
+ onModelFallback?: (fallback: ModelFallback) => void;
595
590
  /** Called at the end of each agentic turn with the cumulative count. */
596
591
  onTurnEnd?: (turnCount: number) => void;
597
592
  /**
@@ -618,6 +613,11 @@ export interface RunOptions {
618
613
  * fails at the call that wrote it rather than inside the child.
619
614
  */
620
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;
621
621
  /** Runtime bridge for opt-in child-safe nested delegation. */
622
622
  nestedRuntime?: {
623
623
  manager: NestedAgentManager;
@@ -664,28 +664,40 @@ export interface RunResult {
664
664
  */
665
665
  function collectResponseText(session: AgentSession) {
666
666
  let text = "";
667
+ let laterTool = false;
668
+ let previousText = "";
669
+ let previousLaterTool = false;
667
670
  const unsubscribe = session.subscribe((event: AgentSessionEvent) => {
668
671
  // message_start also fires for user and toolResult messages — resetting on
669
672
  // those would wipe assistant text already collected. Reset only when a new
670
673
  // ASSISTANT message begins, so getText() is the last assistant message's text.
671
674
  if (event.type === "message_start" && event.message.role === "assistant") {
675
+ if (text.trim()) {
676
+ previousText = text;
677
+ previousLaterTool = laterTool;
678
+ }
672
679
  text = "";
680
+ laterTool = false;
673
681
  }
674
682
  if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
675
683
  text += event.assistantMessageEvent.delta;
676
684
  }
685
+ if (event.type === "tool_execution_end") {
686
+ if (text.trim()) laterTool = true;
687
+ else if (previousText.trim()) previousLaterTool = true;
688
+ }
677
689
  });
678
- return { getText: () => text, unsubscribe };
690
+ return { getText: () => text, getPreviousText: () => previousText, hasLaterTool: () => text.trim() ? laterTool : previousLaterTool, unsubscribe };
679
691
  }
680
692
 
681
693
  /**
682
694
  * Get the last non-empty assistant text produced during THIS invocation.
683
695
  * `startIndex` is the message count captured before the prompt, so the walk-back
684
696
  * never crosses into a previous turn: on a resume whose new turn failed empty,
685
- * this returns "" instead of the prior turn's answer (#144). Defaults to 0 (a
686
- * 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.
687
699
  */
688
- function getLastAssistantText(session: AgentSession, startIndex = 0): string {
700
+ function getLastAssistantText(session: AgentSession, startIndex: number): string {
689
701
  for (let i = session.messages.length - 1; i >= startIndex; i--) {
690
702
  const msg = session.messages[i];
691
703
  if (msg.role !== "assistant") continue;
@@ -721,7 +733,7 @@ export function forwardModelCall(event: AgentSessionEvent, emit: (event: ModelCa
721
733
  * no tool call pending, and a stop that was not an abort, a tool-use handoff or
722
734
  * a token-limit cut. Anything else is a teaser or a cut-off report.
723
735
  */
724
- function endedWithReport(session: AgentSession, startIndex = 0): boolean {
736
+ function endedWithReport(session: AgentSession, startIndex: number): boolean {
725
737
  for (let i = session.messages.length - 1; i >= startIndex; i--) {
726
738
  const msg = session.messages[i];
727
739
  if (msg.role !== "assistant") continue;
@@ -786,7 +798,7 @@ async function forceFinalReport(session: AgentSession, startIndex: number, signa
786
798
  * Bounded by `startIndex` (like the text fallback) so a resume that produced no
787
799
  * assistant message of its own never inherits a PRIOR turn's stop reason.
788
800
  */
789
- function finalTurnError(session: AgentSession, startIndex = 0): string | undefined {
801
+ function finalTurnError(session: AgentSession, startIndex: number): string | undefined {
790
802
  for (let i = session.messages.length - 1; i >= startIndex; i--) {
791
803
  const msg = session.messages[i];
792
804
  if (msg.role !== "assistant") continue;
@@ -801,6 +813,111 @@ function finalTurnError(session: AgentSession, startIndex = 0): string | undefin
801
813
  return undefined;
802
814
  }
803
815
 
816
+ /** Waits before each re-prompt of a step lost to a stale ctx: a reload re-registers within seconds. */
817
+ const STALE_CTX_RETRY_DELAYS_MS: readonly number[] = [1_000, 3_000, 9_000];
818
+
819
+ /** Sent when the session's last step died on a stale ctx; the transcript already holds the work so far. */
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.";
821
+
822
+ /**
823
+ * Resolve after `ms`, or as soon as `signal` aborts. Never rejects.
824
+ */
825
+ function sleep(ms: number, signal: AbortSignal | undefined, clock: Clock): Promise<void> {
826
+ return new Promise((resolve) => {
827
+ if (signal?.aborted) return resolve();
828
+ const done = () => {
829
+ timer.cancel();
830
+ signal?.removeEventListener("abort", done);
831
+ resolve();
832
+ };
833
+ const timer = clock.setTimeout(done, ms);
834
+ signal?.addEventListener("abort", done, { once: true });
835
+ });
836
+ }
837
+
838
+ /**
839
+ * Re-prompt a session whose final step died on a stale extension ctx, backing
840
+ * off between attempts so a reload in progress can re-register its routes.
841
+ * Returns once the final step no longer fails that way, retries are spent, or
842
+ * `signal` aborts. A permanently dead runtime (the parent session was replaced)
843
+ * exhausts the retries and is reported by `staleCtxOutcome`.
844
+ *
845
+ * Why appending a continue prompt is safe, and re-sending the task is not: the
846
+ * failed step died in model routing or an observer, before the model produced
847
+ * anything, so the transcript already holds every tool call and result the run
848
+ * made. Re-sending the task would show the model the same work twice and invite
849
+ * it to repeat side effects (a push, a PR). The continue prompt adds one user
850
+ * turn and nothing else, so the tool batch is never replayed; a tool that did
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.
857
+ */
858
+ export async function retryStaleCtx(
859
+ session: AgentSession,
860
+ startIndex: number,
861
+ signal: AbortSignal | undefined,
862
+ delays: readonly number[] = STALE_CTX_RETRY_DELAYS_MS,
863
+ clock: Clock = realClock,
864
+ ): Promise<void> {
865
+ for (const delay of delays) {
866
+ if (!isStaleCtxError(finalTurnError(session, startIndex))) return;
867
+ await sleep(delay, signal, clock);
868
+ if (signal?.aborted) return;
869
+ await session.prompt(STALE_CTX_RETRY_PROMPT);
870
+ }
871
+ }
872
+
873
+ /**
874
+ * Whether a follow-up prompt (the structured-output retry, the forced final
875
+ * report) may still be sent. Not once the run was stopped, and not while the
876
+ * final step is still a stale ctx: the runtime is dead, so the prompt would
877
+ * fail the same way and mask the real outcome behind a doomed turn.
878
+ */
879
+ function mayFollowUp(session: AgentSession, startIndex: number, signal: AbortSignal | undefined): boolean {
880
+ return !signal?.aborted && staleCtxOutcome(session, startIndex) === undefined;
881
+ }
882
+
883
+ /** Bounded activity only: tool arguments, results and unknown names are never copied. */
884
+ function partialProgress(session: AgentSession, startIndex: number): string {
885
+ const calls: string[] = [];
886
+ for (let i = session.messages.length - 1; i >= startIndex && calls.length < 5; i--) {
887
+ const msg = session.messages[i];
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"})`);
891
+ }
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;
906
+ }
907
+
908
+ /**
909
+ * The failure and partial result for a run whose final step is still a stale
910
+ * ctx after retries; undefined for any other outcome.
911
+ */
912
+ export function staleCtxOutcome(session: AgentSession, startIndex: number): { failure: string; partial: string } | undefined {
913
+ const error = finalTurnError(session, startIndex);
914
+ if (!isStaleCtxError(error)) return undefined;
915
+ return {
916
+ failure: `interrupted by a session reload or replacement; the agent's model runtime went stale and retries did not recover it: ${error}`,
917
+ partial: partialProgress(session, startIndex),
918
+ };
919
+ }
920
+
804
921
  /** The per-run callbacks a spawn and a resume share, plus the resolved turn limit. */
805
922
  type RunObserverOptions = Pick<RunOptions, "onTurnEnd" | "onTextDelta" | "onModelCall" | "onToolActivity" | "onAssistantUsage" | "onCompaction"> & {
806
923
  /** Already resolved: undefined = unlimited. */
@@ -823,9 +940,8 @@ function observeRun(session: AgentSession, options: RunObserverOptions) {
823
940
  steered: false,
824
941
  aborted: false,
825
942
  finalizing: false,
826
- unsubscribe: () => {},
827
943
  };
828
- run.unsubscribe = session.subscribe((event: AgentSessionEvent) => {
944
+ const unsubscribe = session.subscribe((event: AgentSessionEvent) => {
829
945
  if (event.type === "turn_end") {
830
946
  turnCount++;
831
947
  options.onTurnEnd?.(turnCount);
@@ -874,7 +990,8 @@ function observeRun(session: AgentSession, options: RunObserverOptions) {
874
990
  options.onCompaction?.({ reason: event.reason, tokensBefore: event.result.tokensBefore });
875
991
  }
876
992
  });
877
- return run;
993
+ // Keep the live state object captured by the listener, adding only the real cleanup.
994
+ return Object.assign(run, { unsubscribe });
878
995
  }
879
996
 
880
997
  /**
@@ -888,9 +1005,9 @@ function forwardAbortSignal(session: AgentSession, signal?: AbortSignal): () =>
888
1005
  return () => signal.removeEventListener("abort", onAbort);
889
1006
  }
890
1007
 
891
- function resolveConfiguredSessionDir(sessionDir: string | undefined, cwd: string): string | undefined {
1008
+ function resolveConfiguredSessionDir(sessionDir: string | undefined, cwd: string, homeDir = homedir()): string | undefined {
892
1009
  if (!sessionDir) return undefined;
893
- if (sessionDir === "~" || sessionDir.startsWith("~/")) return resolve(homedir(), sessionDir.slice(2));
1010
+ if (sessionDir === "~" || sessionDir.startsWith("~/")) return resolve(homeDir, sessionDir.slice(2));
894
1011
  if (isAbsolute(sessionDir)) return sessionDir;
895
1012
  return resolve(cwd, sessionDir);
896
1013
  }
@@ -914,11 +1031,20 @@ export async function runAgent(
914
1031
 
915
1032
  // Get parent system prompt for append-mode agents
916
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 });
917
1042
 
918
1043
  // Build prompt extras (memory, skill preloading)
919
1044
  const extras: PromptExtras = {};
920
1045
  if (options.worktreeBase) extras.worktreeBase = options.worktreeBase;
921
1046
  if (options.workflow && !options.structuredOutput) extras.workflowChild = true;
1047
+ if (options.planGate?.policy) extras.planMode = true;
922
1048
 
923
1049
  // Resolved here, not by each caller: mention and RPC spawns pass no `isolated`,
924
1050
  // and the agent file's value must hold on every path.
@@ -932,7 +1058,7 @@ export async function runAgent(
932
1058
 
933
1059
  // Skill preloading: when skills is string[], preload their content into prompt
934
1060
  if (Array.isArray(skills)) {
935
- const loaded = preloadSkills(skills, configCwd);
1061
+ const loaded = await preloadSkills(skills, configCwd, { agentDir, settingsManager, homeDir: options.homeDir });
936
1062
  if (loaded.length > 0) {
937
1063
  extras.skillBlocks = loaded;
938
1064
  }
@@ -952,12 +1078,12 @@ export async function runAgent(
952
1078
  // Read-write memory: add any missing memory tool names (read/write/edit)
953
1079
  const extraNames = getMemoryToolNames(existingNames);
954
1080
  if (extraNames.length > 0) toolNames = [...toolNames, ...extraNames];
955
- extras.memoryBlock = buildMemoryBlock(agentConfig.name, agentConfig.memory, configCwd);
1081
+ extras.memoryBlock = buildMemoryBlock(agentConfig.name, agentConfig.memory, configCwd, options.homeDir);
956
1082
  } else {
957
1083
  // Read-only memory: only add read tool name, use read-only prompt
958
1084
  const extraNames = getReadOnlyMemoryToolNames(existingNames);
959
1085
  if (extraNames.length > 0) toolNames = [...toolNames, ...extraNames];
960
- extras.memoryBlock = buildReadOnlyMemoryBlock(agentConfig.name, agentConfig.memory, configCwd);
1086
+ extras.memoryBlock = buildReadOnlyMemoryBlock(agentConfig.name, agentConfig.memory, configCwd, options.homeDir);
961
1087
  }
962
1088
  }
963
1089
 
@@ -971,10 +1097,13 @@ export async function runAgent(
971
1097
  ? options.nestedRuntime
972
1098
  : undefined;
973
1099
  // One answer for the prompt and the workflow refusal: does this child end up with `Agent`?
974
- 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(
975
1103
  agentConfig?.allowedSubagents && nestedRuntime && !isolated
976
- && !agentConfig.disallowedTools?.includes("Agent"),
1104
+ && hasNestableTypes(agentConfig.allowedSubagents, { homeDir: options.homeDir, configCwd, projectTrusted }),
977
1105
  );
1106
+ const agentAvailable = nestingPossible && !agentConfig?.disallowedTools?.includes("Agent");
978
1107
  if (agentConfig?.allowedSubagents && !agentAvailable) extras.nestingDisabled = true;
979
1108
 
980
1109
  // Build system prompt from agent config
@@ -993,8 +1122,6 @@ export async function runAgent(
993
1122
  // Still pass noSkills: true since we don't need the skill loader to load them again.
994
1123
  const noSkills = skills === false || Array.isArray(skills);
995
1124
 
996
- const agentDir = getAgentDir();
997
-
998
1125
  // Extension loading:
999
1126
  // - true → all default-discovered extensions
1000
1127
  // - false → none (noExtensions)
@@ -1018,7 +1145,7 @@ export async function runAgent(
1018
1145
  const noExtensions = extensions === false;
1019
1146
 
1020
1147
  const extensionsSpec = Array.isArray(extensions)
1021
- ? parseExtensionsSpec(extensions, configCwd)
1148
+ ? parseExtensionsSpec(extensions, configCwd, options.homeDir)
1022
1149
  : undefined;
1023
1150
  const keepNames = extensionsSpec?.names ?? new Set<string>();
1024
1151
  // `exclude_extensions:` is a denylist applied AFTER the include set — exclude wins.
@@ -1052,14 +1179,6 @@ export async function runAgent(
1052
1179
  };
1053
1180
  };
1054
1181
 
1055
- // The child inherits the parent's project-trust decision. pi's SettingsManager
1056
- // defaults to trusted, so without this a subagent in a project the user declined
1057
- // to trust would load its `.pi` extensions and settings, and pi's built-in MCP
1058
- // would start the stdio servers in its `.pi/mcp.json`.
1059
- const projectTrusted = options.configCwd !== undefined && options.configProjectTrusted !== undefined
1060
- ? options.configProjectTrusted
1061
- : ctx.isProjectTrusted();
1062
- const settingsManager = SettingsManager.create(configCwd, agentDir, { projectTrusted });
1063
1182
  // That trust covers the parent's project (`configCwd`), not a directory a spawn
1064
1183
  // was sent into. The session answers `ctx.isProjectTrusted()` for its own cwd,
1065
1184
  // which pi's MCP uses to read `<cwd>/.pi/mcp.json` — so a cross-directory session
@@ -1073,7 +1192,7 @@ export async function runAgent(
1073
1192
  });
1074
1193
  const nestedCallGate: NestedCallGate = {};
1075
1194
 
1076
- const loader = new DefaultResourceLoader({
1195
+ const loader = createHomeResourceLoader({
1077
1196
  settingsManager,
1078
1197
  // pi's CLI hands its built-in extensions to the loader it builds; this one starts
1079
1198
  // with none. Forwarded as `builtin:` resources they load as in the main session —
@@ -1105,8 +1224,8 @@ export async function runAgent(
1105
1224
  noContextFiles: true,
1106
1225
  systemPromptOverride: () => systemPrompt,
1107
1226
  appendSystemPromptOverride: () => [],
1108
- });
1109
- await runInChildSessionContext(() => loader.reload());
1227
+ }, options.homeDir);
1228
+ await bindChildResourceLoader(loader).reload();
1110
1229
 
1111
1230
  // Plain entries in `tools:` are expected to be built-in names (extension tools
1112
1231
  // go through `ext:`), so an unknown name there is unambiguously a typo. Previously
@@ -1177,10 +1296,8 @@ export async function runAgent(
1177
1296
  }
1178
1297
  }
1179
1298
 
1180
- // Resolve model: explicit option > config.model > parent model
1181
- const model = options.model ?? resolveDefaultModel(
1182
- ctx.model, ctx.modelRegistry, agentConfig?.model,
1183
- );
1299
+ const model = options.modelSelection.model;
1300
+ if (options.modelSelection.fallback) options.onModelFallback?.(options.modelSelection.fallback);
1184
1301
 
1185
1302
  // Resolve thinking level: explicit option > agent config > undefined (inherit)
1186
1303
  const thinkingLevel = options.thinkingLevel ?? agentConfig?.thinking;
@@ -1193,7 +1310,7 @@ export async function runAgent(
1193
1310
  // set `allowed_subagents` and a nestedRuntime was provided — and never when
1194
1311
  // isolated. Their names collide with EXCLUDED_TOOL_NAMES by design, so the
1195
1312
  // scoping below re-admits them explicitly (registry deny + active-set narrow).
1196
- const nestedTools = agentConfig?.allowedSubagents && nestedRuntime && !isolated
1313
+ const nestedTools = agentConfig?.allowedSubagents && nestedRuntime && nestingPossible
1197
1314
  ? createNestedSubagentTools({
1198
1315
  manager: nestedRuntime.manager,
1199
1316
  pi: options.pi,
@@ -1201,6 +1318,7 @@ export async function runAgent(
1201
1318
  depth: nestedRuntime.depth,
1202
1319
  maxSubagentDepth: effectiveMaxDepth,
1203
1320
  allowedSubagents: agentConfig.allowedSubagents,
1321
+ homeDir: options.homeDir,
1204
1322
  configCwd,
1205
1323
  projectTrusted,
1206
1324
  })
@@ -1301,8 +1419,17 @@ export async function runAgent(
1301
1419
  sessionExcludeTools = [...denyTools];
1302
1420
  }
1303
1421
 
1304
- const configuredSessionDir = resolveConfiguredSessionDir(agentConfig?.sessionDir, effectiveCwd);
1305
- const defaultSessionDir = process.env.PI_CODING_AGENT_SESSION_DIR ?? settingsManager.getSessionDir?.();
1422
+ const configuredSessionDir = resolveConfiguredSessionDir(agentConfig?.sessionDir, effectiveCwd, options.homeDir);
1423
+ const sessionDirSetting = process.env.PI_CODING_AGENT_SESSION_DIR ?? settingsManager.getSessionDir?.();
1424
+ // SessionManager's implicit default consults the host home again. Pin the
1425
+ // same cwd encoding under our injected agentDir instead when isolating a run.
1426
+ // SDK settings go straight through SessionManager's native normalization;
1427
+ // unlike frontmatter, file URLs expand and relative settings stay relative.
1428
+ const defaultSessionDir = options.homeDir === undefined
1429
+ ? sessionDirSetting
1430
+ : sessionDirSetting
1431
+ ? normalizeHomePath(sessionDirSetting, options.homeDir)
1432
+ : join(resolve(agentDir), "sessions", `--${resolve(effectiveCwd).replace(/^[/\\\\]/, "").replace(/[/\\\\:]/g, "-")}--`);
1306
1433
  // Frontmatter wins when it says anything; otherwise the project default,
1307
1434
  // which `rememberAgents` supplies for top-level agents only. Same precedence
1308
1435
  // as `outputTranscript`.
@@ -1392,6 +1519,21 @@ export async function runAgent(
1392
1519
  });
1393
1520
  }
1394
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
+
1395
1537
  options.onSessionCreated?.(session);
1396
1538
 
1397
1539
  const run = observeRun(session, { ...options, maxTurns: resolveEffectiveMaxTurns(type, options.maxTurns) });
@@ -1417,12 +1559,13 @@ export async function runAgent(
1417
1559
  // pi's `session.abort()` is a no-op until a run is active — so a child whose
1418
1560
  // parent is already gone is not prompted at all.
1419
1561
  if (options.signal?.aborted !== true) await session.prompt(effectivePrompt);
1562
+ await retryStaleCtx(session, startLen, options.signal, options.staleRetryDelaysMs, options.clock);
1420
1563
 
1421
1564
  // A cap fired. The wrap-up steer is only a request: a model deep in a tool
1422
1565
  // loop keeps calling tools through the grace turns and is then aborted
1423
1566
  // mid-turn, leaving its last narration as the "result". Demand the report
1424
1567
  // in a turn that cannot call tools.
1425
- if (run.steered && options.signal?.aborted !== true && !endedWithReport(session, startLen)) {
1568
+ if (run.steered && mayFollowUp(session, startLen, options.signal) && !endedWithReport(session, startLen)) {
1426
1569
  run.finalizing = true;
1427
1570
  const reported = await forceFinalReport(session, startLen, options.signal);
1428
1571
  // A delivered report is a wrap-up, not a failure: the agent did hand back
@@ -1436,7 +1579,7 @@ export async function runAgent(
1436
1579
  // the abort forwarding are still live: torn down first, a retry would be
1437
1580
  // unkillable.
1438
1581
  if (structuredCapture !== undefined && structuredCapture.json === undefined
1439
- && !run.aborted && options.signal?.aborted !== true) {
1582
+ && !run.aborted && mayFollowUp(session, startLen, options.signal)) {
1440
1583
  structuredRetried = true;
1441
1584
  await session.prompt(structuredRetryPrompt(structuredCapture));
1442
1585
  }
@@ -1446,7 +1589,8 @@ export async function runAgent(
1446
1589
  cleanupAbort();
1447
1590
  }
1448
1591
 
1449
- const responseText = collector.getText().trim() || getLastAssistantText(session, startLen);
1592
+ const stale = staleCtxOutcome(session, startLen);
1593
+ const text = collector.getText().trim() || getLastAssistantText(session, startLen);
1450
1594
  // A child asked for structured output that never gave any has failed, however
1451
1595
  // articulate its prose was. Reported through `failure` so it travels the same
1452
1596
  // path as a provider error rather than arriving as a successful empty answer.
@@ -1455,12 +1599,14 @@ export async function runAgent(
1455
1599
  ? `The agent's StructuredOutput call did not match the required schema: ${structuredCapture.lastError}`
1456
1600
  : "The agent did not report its answer through StructuredOutput."
1457
1601
  : undefined;
1602
+ const failure = stale?.failure ?? finalTurnError(session, startLen) ?? structuredFailure;
1603
+ const streamed = collector.getText().trim() || collector.getPreviousText().trim();
1458
1604
  return {
1459
- responseText,
1605
+ responseText: failure ? failureText(session, startLen, streamed || text, stale?.partial ?? partialProgress(session, startLen), collector.hasLaterTool(), Boolean(streamed)) : text,
1460
1606
  session,
1461
1607
  aborted: run.aborted,
1462
1608
  steered: run.steered,
1463
- failure: finalTurnError(session, startLen) ?? structuredFailure,
1609
+ failure,
1464
1610
  ...(structuredCapture?.json !== undefined ? { structuredJson: structuredCapture.json } : {}),
1465
1611
  ...(structuredRetried ? { structuredRetried } : {}),
1466
1612
  };
@@ -1475,7 +1621,7 @@ export async function runAgent(
1475
1621
  export async function resumeAgent(
1476
1622
  session: AgentSession,
1477
1623
  prompt: string,
1478
- options: Pick<RunOptions, "maxTurns" | "onTurnEnd" | "onTextDelta" | "onModelCall" | "onToolActivity" | "onAssistantUsage" | "onCompaction" | "signal"> & {
1624
+ options: Pick<RunOptions, "staleRetryDelaysMs" | "clock" | "maxTurns" | "onTurnEnd" | "onTextDelta" | "onModelCall" | "onToolActivity" | "onAssistantUsage" | "onCompaction" | "signal"> & {
1479
1625
  /** The agent's type, for its own `max_turns`. */
1480
1626
  type: SubagentType;
1481
1627
  },
@@ -1491,7 +1637,8 @@ export async function resumeAgent(
1491
1637
  try {
1492
1638
  // Already aborted: not prompted, as in `runAgent`.
1493
1639
  if (options.signal?.aborted !== true) await session.prompt(prompt);
1494
- if (run.steered && options.signal?.aborted !== true && !endedWithReport(session, startLen)) {
1640
+ await retryStaleCtx(session, startLen, options.signal, options.staleRetryDelaysMs, options.clock);
1641
+ if (run.steered && mayFollowUp(session, startLen, options.signal) && !endedWithReport(session, startLen)) {
1495
1642
  run.finalizing = true;
1496
1643
  if (await forceFinalReport(session, startLen, options.signal)) run.aborted = false;
1497
1644
  }
@@ -1501,9 +1648,13 @@ export async function resumeAgent(
1501
1648
  cleanupAbort();
1502
1649
  }
1503
1650
 
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();
1504
1655
  return {
1505
- text: collector.getText().trim() || getLastAssistantText(session, startLen),
1506
- failure: finalTurnError(session, startLen),
1656
+ text: failure ? failureText(session, startLen, streamed || text, stale?.partial ?? partialProgress(session, startLen), collector.hasLaterTool(), Boolean(streamed)) : text,
1657
+ failure,
1507
1658
  aborted: run.aborted,
1508
1659
  steered: run.steered,
1509
1660
  };