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

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 (46) hide show
  1. package/README.md +124 -37
  2. package/package.json +8 -8
  3. package/src/agent-file-toggle.ts +6 -4
  4. package/src/agent-manager.ts +131 -28
  5. package/src/agent-runner.ts +155 -62
  6. package/src/cross-extension-rpc.ts +11 -36
  7. package/src/custom-agents.ts +9 -5
  8. package/src/default-agents.ts +2 -4
  9. package/src/enabled-models.ts +8 -7
  10. package/src/enterprise-local.ts +5 -5
  11. package/src/home-paths.ts +27 -0
  12. package/src/index.ts +191 -90
  13. package/src/invocation-config.ts +3 -16
  14. package/src/memory.ts +9 -9
  15. package/src/mention-clone.ts +9 -5
  16. package/src/model-resolver.ts +41 -11
  17. package/src/model-scope.ts +2 -1
  18. package/src/model-selection.ts +159 -0
  19. package/src/model-tiers.ts +171 -0
  20. package/src/nested-tools.ts +22 -26
  21. package/src/output-file.ts +23 -1
  22. package/src/package-resources.ts +25 -10
  23. package/src/process-budget.ts +6 -3
  24. package/src/quota-admission.ts +21 -9
  25. package/src/resource-loader.ts +51 -0
  26. package/src/schedule.ts +36 -18
  27. package/src/settings.ts +42 -12
  28. package/src/skill-loader.ts +8 -7
  29. package/src/stale-ctx.ts +16 -0
  30. package/src/types.ts +27 -0
  31. package/src/ui/agent-row.ts +13 -3
  32. package/src/ui/agent-widget.ts +15 -4
  33. package/src/ui/conversation-viewer.ts +6 -3
  34. package/src/ui/fallback-flag.ts +90 -0
  35. package/src/ui/fleet-list.ts +363 -53
  36. package/src/ui/fleet-state.ts +154 -0
  37. package/src/ui/notification-view.ts +18 -2
  38. package/src/ui/workflow-card.ts +5 -1
  39. package/src/ui/workflow-dialog.ts +10 -0
  40. package/src/workflow/host.ts +43 -45
  41. package/src/workflow/journal.ts +13 -1
  42. package/src/workflow/progress.ts +4 -0
  43. package/src/workflow/runtime.ts +25 -8
  44. package/src/workflow/saved.ts +19 -17
  45. package/src/workflow/task.ts +10 -0
  46. package/src/workflow/worker-source.ts +156 -38
@@ -14,10 +14,9 @@ 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,
@@ -28,8 +27,11 @@ import { runInChildSessionContext } from "./child-context.js";
28
27
  import { buildParentContext, extractText } from "./context.js";
29
28
  import { DEFAULT_AGENTS } from "./default-agents.js";
30
29
  import { detectEnv } from "./env.js";
30
+ import { normalizeHomePath, resolveAgentDir } from "./home-paths.js";
31
31
  import { buildMemoryBlock, buildReadOnlyMemoryBlock } from "./memory.js";
32
32
  import type { ModelCallEvent } from "./model-call.js";
33
+ import type { ModelFallback } from "./model-resolver.js";
34
+ import type { ModelSelection } from "./model-selection.js";
33
35
  import {
34
36
  createNestedSubagentTools,
35
37
  getMaxSubagentDepth,
@@ -38,7 +40,9 @@ import {
38
40
  type NestedAgentManager,
39
41
  } from "./nested-tools.js";
40
42
  import { buildAgentPrompt, type PromptExtras } from "./prompts.js";
43
+ import { createHomeResourceLoader } from "./resource-loader.js";
41
44
  import { preloadSkills } from "./skill-loader.js";
45
+ import { isStaleCtxError } from "./stale-ctx.js";
42
46
  import { createStructuredCapture, createStructuredOutputTool, structuredRetryPrompt } from "./structured-output.js";
43
47
  import type { SubagentType, ThinkingLevel } from "./types.js";
44
48
  import type { LifetimeUsage } from "./usage.js";
@@ -187,6 +191,7 @@ export function extensionCanonicalNames(extPath: string): string[] {
187
191
  export function parseExtensionsSpec(
188
192
  entries: string[],
189
193
  cwd: string,
194
+ homeDir = homedir(),
190
195
  ): { names: Set<string>; paths: string[]; wildcard: boolean } {
191
196
  const names = new Set<string>();
192
197
  const paths: string[] = [];
@@ -202,10 +207,10 @@ export function parseExtensionsSpec(
202
207
  names.add(entry.toLowerCase());
203
208
  continue;
204
209
  }
210
+ // This frontmatter caller historically expands both slash forms on every
211
+ // platform, without interpreting file URLs or Windows shell drive forms.
205
212
  let p = entry;
206
- if (p === "~" || p.startsWith("~/") || p.startsWith("~\\")) {
207
- p = homedir() + p.slice(1);
208
- }
213
+ if (p === "~" || p.startsWith("~/") || p.startsWith("~\\")) p = homeDir + p.slice(1);
209
214
  const abs = isAbsolute(p) ? p : resolve(cwd, p);
210
215
  paths.push(abs);
211
216
  names.add(extensionCanonicalName(abs));
@@ -474,37 +479,6 @@ const FINAL_REPORT_PROMPT =
474
479
  + "Write your complete final report now: everything you found, concrete file paths and line numbers, "
475
480
  + "what you changed or concluded, and anything unfinished or unverified.";
476
481
 
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
482
  /** Info about a tool event in the subagent. */
509
483
  export interface ToolActivity {
510
484
  type: "start" | "end";
@@ -519,11 +493,15 @@ export interface AssistantMessageInfo {
519
493
  }
520
494
 
521
495
  export interface RunOptions {
496
+ /** Backoff before each stale-ctx re-prompt (#145); defaults to 1s, 3s, 9s. `[]` disables recovery. */
497
+ staleRetryDelaysMs?: readonly number[];
522
498
  /** ExtensionAPI instance — used for pi.exec() instead of execSync. */
523
499
  pi: ExtensionAPI;
524
500
  /** Manager-assigned id; suffixes session name to disambiguate parallel spawns (e.g. `Explore#a1b2c3d4`). */
525
501
  agentId?: string;
526
502
  model?: Model<any>;
503
+ /** Required admitted spawn outcome. The runner never resolves or admits models. */
504
+ modelSelection: ModelSelection;
527
505
  maxTurns?: number;
528
506
  signal?: AbortSignal;
529
507
  /** Applies only when the agent file leaves `isolated:` unset — frontmatter is authoritative. */
@@ -557,6 +535,8 @@ export interface RunOptions {
557
535
  workflow?: boolean;
558
536
  /** Override working directory (e.g. for worktree isolation). */
559
537
  cwd?: string;
538
+ /** Home directory for tilde expansion and user-scoped storage. Defaults at run time. */
539
+ homeDir?: string;
560
540
  /**
561
541
  * Directory the worktree copy was created from. Set only when `cwd` points
562
542
  * into a worktree — the prompt then tells the agent to stay in the copy
@@ -592,6 +572,8 @@ export interface RunOptions {
592
572
  /** Called for every streaming event of each model call, so a UI can say what an in-flight call is doing. */
593
573
  onModelCall?: (event: ModelCallEvent) => void;
594
574
  onSessionCreated?: (session: AgentSession) => void;
575
+ /** Direct runner callers can observe the same configured fallback as the shared spawn. */
576
+ onModelFallback?: (fallback: ModelFallback) => void;
595
577
  /** Called at the end of each agentic turn with the cumulative count. */
596
578
  onTurnEnd?: (turnCount: number) => void;
597
579
  /**
@@ -801,6 +783,105 @@ function finalTurnError(session: AgentSession, startIndex = 0): string | undefin
801
783
  return undefined;
802
784
  }
803
785
 
786
+ /** Waits before each re-prompt of a step lost to a stale ctx: a reload re-registers within seconds. */
787
+ const STALE_CTX_RETRY_DELAYS_MS: readonly number[] = [1_000, 3_000, 9_000];
788
+
789
+ /** Sent when the session's last step died on a stale ctx; the transcript already holds the work so far. */
790
+ 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
+
792
+ /** Longest tool output echoed into a partial result. */
793
+ const PARTIAL_TOOL_OUTPUT_CHARS = 1_500;
794
+
795
+ /**
796
+ * Resolve after `ms`, or as soon as `signal` aborts. Never rejects.
797
+ */
798
+ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
799
+ return new Promise((resolve) => {
800
+ if (signal?.aborted) return resolve();
801
+ const done = () => {
802
+ clearTimeout(timer);
803
+ signal?.removeEventListener("abort", done);
804
+ resolve();
805
+ };
806
+ const timer = setTimeout(done, ms);
807
+ signal?.addEventListener("abort", done, { once: true });
808
+ });
809
+ }
810
+
811
+ /**
812
+ * Re-prompt a session whose final step died on a stale extension ctx, backing
813
+ * off between attempts so a reload in progress can re-register its routes.
814
+ * Returns once the final step no longer fails that way, retries are spent, or
815
+ * `signal` aborts. A permanently dead runtime (the parent session was replaced)
816
+ * exhausts the retries and is reported by `staleCtxOutcome`.
817
+ *
818
+ * Why appending a continue prompt is safe, and re-sending the task is not: the
819
+ * failed step died in model routing or an observer, before the model produced
820
+ * anything, so the transcript already holds every tool call and result the run
821
+ * made. Re-sending the task would show the model the same work twice and invite
822
+ * it to repeat side effects (a push, a PR). The continue prompt adds one user
823
+ * turn and nothing else, so the tool batch is never replayed; a tool that did
824
+ * run is in context as a result.
825
+ */
826
+ export async function retryStaleCtx(
827
+ session: AgentSession,
828
+ startIndex: number,
829
+ signal: AbortSignal | undefined,
830
+ delays: readonly number[] = STALE_CTX_RETRY_DELAYS_MS,
831
+ ): Promise<void> {
832
+ for (const delay of delays) {
833
+ if (!isStaleCtxError(finalTurnError(session, startIndex))) return;
834
+ await sleep(delay, signal);
835
+ if (signal?.aborted) return;
836
+ await session.prompt(STALE_CTX_RETRY_PROMPT);
837
+ }
838
+ }
839
+
840
+ /**
841
+ * Whether a follow-up prompt (the structured-output retry, the forced final
842
+ * report) may still be sent. Not once the run was stopped, and not while the
843
+ * final step is still a stale ctx: the runtime is dead, so the prompt would
844
+ * fail the same way and mask the real outcome behind a doomed turn.
845
+ */
846
+ function mayFollowUp(session: AgentSession, startIndex: number, signal: AbortSignal | undefined): boolean {
847
+ return !signal?.aborted && staleCtxOutcome(session, startIndex) === undefined;
848
+ }
849
+
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
+ */
855
+ function partialProgress(session: AgentSession, startIndex: number): string {
856
+ const calls: string[] = [];
857
+ let lastOutput = "";
858
+ for (let i = session.messages.length - 1; i >= startIndex && calls.length < 5; i--) {
859
+ 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
+ }
866
+ }
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}` : ""}`;
870
+ }
871
+
872
+ /**
873
+ * The failure and partial result for a run whose final step is still a stale
874
+ * ctx after retries; undefined for any other outcome.
875
+ */
876
+ export function staleCtxOutcome(session: AgentSession, startIndex: number): { failure: string; partial: string } | undefined {
877
+ const error = finalTurnError(session, startIndex);
878
+ if (!isStaleCtxError(error)) return undefined;
879
+ return {
880
+ failure: "interrupted by a session reload or replacement; the agent's model runtime went stale and retries did not recover it",
881
+ partial: partialProgress(session, startIndex),
882
+ };
883
+ }
884
+
804
885
  /** The per-run callbacks a spawn and a resume share, plus the resolved turn limit. */
805
886
  type RunObserverOptions = Pick<RunOptions, "onTurnEnd" | "onTextDelta" | "onModelCall" | "onToolActivity" | "onAssistantUsage" | "onCompaction"> & {
806
887
  /** Already resolved: undefined = unlimited. */
@@ -823,9 +904,8 @@ function observeRun(session: AgentSession, options: RunObserverOptions) {
823
904
  steered: false,
824
905
  aborted: false,
825
906
  finalizing: false,
826
- unsubscribe: () => {},
827
907
  };
828
- run.unsubscribe = session.subscribe((event: AgentSessionEvent) => {
908
+ const unsubscribe = session.subscribe((event: AgentSessionEvent) => {
829
909
  if (event.type === "turn_end") {
830
910
  turnCount++;
831
911
  options.onTurnEnd?.(turnCount);
@@ -874,7 +954,8 @@ function observeRun(session: AgentSession, options: RunObserverOptions) {
874
954
  options.onCompaction?.({ reason: event.reason, tokensBefore: event.result.tokensBefore });
875
955
  }
876
956
  });
877
- return run;
957
+ // Keep the live state object captured by the listener, adding only the real cleanup.
958
+ return Object.assign(run, { unsubscribe });
878
959
  }
879
960
 
880
961
  /**
@@ -888,9 +969,9 @@ function forwardAbortSignal(session: AgentSession, signal?: AbortSignal): () =>
888
969
  return () => signal.removeEventListener("abort", onAbort);
889
970
  }
890
971
 
891
- function resolveConfiguredSessionDir(sessionDir: string | undefined, cwd: string): string | undefined {
972
+ function resolveConfiguredSessionDir(sessionDir: string | undefined, cwd: string, homeDir = homedir()): string | undefined {
892
973
  if (!sessionDir) return undefined;
893
- if (sessionDir === "~" || sessionDir.startsWith("~/")) return resolve(homedir(), sessionDir.slice(2));
974
+ if (sessionDir === "~" || sessionDir.startsWith("~/")) return resolve(homeDir, sessionDir.slice(2));
894
975
  if (isAbsolute(sessionDir)) return sessionDir;
895
976
  return resolve(cwd, sessionDir);
896
977
  }
@@ -932,7 +1013,7 @@ export async function runAgent(
932
1013
 
933
1014
  // Skill preloading: when skills is string[], preload their content into prompt
934
1015
  if (Array.isArray(skills)) {
935
- const loaded = preloadSkills(skills, configCwd);
1016
+ const loaded = preloadSkills(skills, configCwd, options.homeDir);
936
1017
  if (loaded.length > 0) {
937
1018
  extras.skillBlocks = loaded;
938
1019
  }
@@ -952,12 +1033,12 @@ export async function runAgent(
952
1033
  // Read-write memory: add any missing memory tool names (read/write/edit)
953
1034
  const extraNames = getMemoryToolNames(existingNames);
954
1035
  if (extraNames.length > 0) toolNames = [...toolNames, ...extraNames];
955
- extras.memoryBlock = buildMemoryBlock(agentConfig.name, agentConfig.memory, configCwd);
1036
+ extras.memoryBlock = buildMemoryBlock(agentConfig.name, agentConfig.memory, configCwd, options.homeDir);
956
1037
  } else {
957
1038
  // Read-only memory: only add read tool name, use read-only prompt
958
1039
  const extraNames = getReadOnlyMemoryToolNames(existingNames);
959
1040
  if (extraNames.length > 0) toolNames = [...toolNames, ...extraNames];
960
- extras.memoryBlock = buildReadOnlyMemoryBlock(agentConfig.name, agentConfig.memory, configCwd);
1041
+ extras.memoryBlock = buildReadOnlyMemoryBlock(agentConfig.name, agentConfig.memory, configCwd, options.homeDir);
961
1042
  }
962
1043
  }
963
1044
 
@@ -993,7 +1074,7 @@ export async function runAgent(
993
1074
  // Still pass noSkills: true since we don't need the skill loader to load them again.
994
1075
  const noSkills = skills === false || Array.isArray(skills);
995
1076
 
996
- const agentDir = getAgentDir();
1077
+ const agentDir = resolveAgentDir(options.homeDir);
997
1078
 
998
1079
  // Extension loading:
999
1080
  // - true → all default-discovered extensions
@@ -1018,7 +1099,7 @@ export async function runAgent(
1018
1099
  const noExtensions = extensions === false;
1019
1100
 
1020
1101
  const extensionsSpec = Array.isArray(extensions)
1021
- ? parseExtensionsSpec(extensions, configCwd)
1102
+ ? parseExtensionsSpec(extensions, configCwd, options.homeDir)
1022
1103
  : undefined;
1023
1104
  const keepNames = extensionsSpec?.names ?? new Set<string>();
1024
1105
  // `exclude_extensions:` is a denylist applied AFTER the include set — exclude wins.
@@ -1073,7 +1154,7 @@ export async function runAgent(
1073
1154
  });
1074
1155
  const nestedCallGate: NestedCallGate = {};
1075
1156
 
1076
- const loader = new DefaultResourceLoader({
1157
+ const loader = createHomeResourceLoader({
1077
1158
  settingsManager,
1078
1159
  // pi's CLI hands its built-in extensions to the loader it builds; this one starts
1079
1160
  // with none. Forwarded as `builtin:` resources they load as in the main session —
@@ -1105,7 +1186,7 @@ export async function runAgent(
1105
1186
  noContextFiles: true,
1106
1187
  systemPromptOverride: () => systemPrompt,
1107
1188
  appendSystemPromptOverride: () => [],
1108
- });
1189
+ }, options.homeDir);
1109
1190
  await runInChildSessionContext(() => loader.reload());
1110
1191
 
1111
1192
  // Plain entries in `tools:` are expected to be built-in names (extension tools
@@ -1177,10 +1258,8 @@ export async function runAgent(
1177
1258
  }
1178
1259
  }
1179
1260
 
1180
- // Resolve model: explicit option > config.model > parent model
1181
- const model = options.model ?? resolveDefaultModel(
1182
- ctx.model, ctx.modelRegistry, agentConfig?.model,
1183
- );
1261
+ const model = options.modelSelection.model;
1262
+ if (options.modelSelection.fallback) options.onModelFallback?.(options.modelSelection.fallback);
1184
1263
 
1185
1264
  // Resolve thinking level: explicit option > agent config > undefined (inherit)
1186
1265
  const thinkingLevel = options.thinkingLevel ?? agentConfig?.thinking;
@@ -1201,6 +1280,7 @@ export async function runAgent(
1201
1280
  depth: nestedRuntime.depth,
1202
1281
  maxSubagentDepth: effectiveMaxDepth,
1203
1282
  allowedSubagents: agentConfig.allowedSubagents,
1283
+ homeDir: options.homeDir,
1204
1284
  configCwd,
1205
1285
  projectTrusted,
1206
1286
  })
@@ -1301,8 +1381,17 @@ export async function runAgent(
1301
1381
  sessionExcludeTools = [...denyTools];
1302
1382
  }
1303
1383
 
1304
- const configuredSessionDir = resolveConfiguredSessionDir(agentConfig?.sessionDir, effectiveCwd);
1305
- const defaultSessionDir = process.env.PI_CODING_AGENT_SESSION_DIR ?? settingsManager.getSessionDir?.();
1384
+ const configuredSessionDir = resolveConfiguredSessionDir(agentConfig?.sessionDir, effectiveCwd, options.homeDir);
1385
+ const sessionDirSetting = process.env.PI_CODING_AGENT_SESSION_DIR ?? settingsManager.getSessionDir?.();
1386
+ // SessionManager's implicit default consults the host home again. Pin the
1387
+ // same cwd encoding under our injected agentDir instead when isolating a run.
1388
+ // SDK settings go straight through SessionManager's native normalization;
1389
+ // unlike frontmatter, file URLs expand and relative settings stay relative.
1390
+ const defaultSessionDir = options.homeDir === undefined
1391
+ ? sessionDirSetting
1392
+ : sessionDirSetting
1393
+ ? normalizeHomePath(sessionDirSetting, options.homeDir)
1394
+ : join(resolve(agentDir), "sessions", `--${resolve(effectiveCwd).replace(/^[/\\\\]/, "").replace(/[/\\\\:]/g, "-")}--`);
1306
1395
  // Frontmatter wins when it says anything; otherwise the project default,
1307
1396
  // which `rememberAgents` supplies for top-level agents only. Same precedence
1308
1397
  // as `outputTranscript`.
@@ -1417,12 +1506,13 @@ export async function runAgent(
1417
1506
  // pi's `session.abort()` is a no-op until a run is active — so a child whose
1418
1507
  // parent is already gone is not prompted at all.
1419
1508
  if (options.signal?.aborted !== true) await session.prompt(effectivePrompt);
1509
+ await retryStaleCtx(session, startLen, options.signal, options.staleRetryDelaysMs);
1420
1510
 
1421
1511
  // A cap fired. The wrap-up steer is only a request: a model deep in a tool
1422
1512
  // loop keeps calling tools through the grace turns and is then aborted
1423
1513
  // mid-turn, leaving its last narration as the "result". Demand the report
1424
1514
  // in a turn that cannot call tools.
1425
- if (run.steered && options.signal?.aborted !== true && !endedWithReport(session, startLen)) {
1515
+ if (run.steered && mayFollowUp(session, startLen, options.signal) && !endedWithReport(session, startLen)) {
1426
1516
  run.finalizing = true;
1427
1517
  const reported = await forceFinalReport(session, startLen, options.signal);
1428
1518
  // A delivered report is a wrap-up, not a failure: the agent did hand back
@@ -1436,7 +1526,7 @@ export async function runAgent(
1436
1526
  // the abort forwarding are still live: torn down first, a retry would be
1437
1527
  // unkillable.
1438
1528
  if (structuredCapture !== undefined && structuredCapture.json === undefined
1439
- && !run.aborted && options.signal?.aborted !== true) {
1529
+ && !run.aborted && mayFollowUp(session, startLen, options.signal)) {
1440
1530
  structuredRetried = true;
1441
1531
  await session.prompt(structuredRetryPrompt(structuredCapture));
1442
1532
  }
@@ -1446,7 +1536,8 @@ export async function runAgent(
1446
1536
  cleanupAbort();
1447
1537
  }
1448
1538
 
1449
- const responseText = collector.getText().trim() || getLastAssistantText(session, startLen);
1539
+ const stale = staleCtxOutcome(session, startLen);
1540
+ const responseText = collector.getText().trim() || getLastAssistantText(session, startLen) || stale?.partial || "";
1450
1541
  // A child asked for structured output that never gave any has failed, however
1451
1542
  // articulate its prose was. Reported through `failure` so it travels the same
1452
1543
  // path as a provider error rather than arriving as a successful empty answer.
@@ -1460,7 +1551,7 @@ export async function runAgent(
1460
1551
  session,
1461
1552
  aborted: run.aborted,
1462
1553
  steered: run.steered,
1463
- failure: finalTurnError(session, startLen) ?? structuredFailure,
1554
+ failure: stale?.failure ?? finalTurnError(session, startLen) ?? structuredFailure,
1464
1555
  ...(structuredCapture?.json !== undefined ? { structuredJson: structuredCapture.json } : {}),
1465
1556
  ...(structuredRetried ? { structuredRetried } : {}),
1466
1557
  };
@@ -1475,7 +1566,7 @@ export async function runAgent(
1475
1566
  export async function resumeAgent(
1476
1567
  session: AgentSession,
1477
1568
  prompt: string,
1478
- options: Pick<RunOptions, "maxTurns" | "onTurnEnd" | "onTextDelta" | "onModelCall" | "onToolActivity" | "onAssistantUsage" | "onCompaction" | "signal"> & {
1569
+ options: Pick<RunOptions, "staleRetryDelaysMs" | "maxTurns" | "onTurnEnd" | "onTextDelta" | "onModelCall" | "onToolActivity" | "onAssistantUsage" | "onCompaction" | "signal"> & {
1479
1570
  /** The agent's type, for its own `max_turns`. */
1480
1571
  type: SubagentType;
1481
1572
  },
@@ -1491,7 +1582,8 @@ export async function resumeAgent(
1491
1582
  try {
1492
1583
  // Already aborted: not prompted, as in `runAgent`.
1493
1584
  if (options.signal?.aborted !== true) await session.prompt(prompt);
1494
- if (run.steered && options.signal?.aborted !== true && !endedWithReport(session, startLen)) {
1585
+ await retryStaleCtx(session, startLen, options.signal, options.staleRetryDelaysMs);
1586
+ if (run.steered && mayFollowUp(session, startLen, options.signal) && !endedWithReport(session, startLen)) {
1495
1587
  run.finalizing = true;
1496
1588
  if (await forceFinalReport(session, startLen, options.signal)) run.aborted = false;
1497
1589
  }
@@ -1501,9 +1593,10 @@ export async function resumeAgent(
1501
1593
  cleanupAbort();
1502
1594
  }
1503
1595
 
1596
+ const stale = staleCtxOutcome(session, startLen);
1504
1597
  return {
1505
- text: collector.getText().trim() || getLastAssistantText(session, startLen),
1506
- failure: finalTurnError(session, startLen),
1598
+ text: collector.getText().trim() || getLastAssistantText(session, startLen) || stale?.partial || "",
1599
+ failure: stale?.failure ?? finalTurnError(session, startLen),
1507
1600
  aborted: run.aborted,
1508
1601
  steered: run.steered,
1509
1602
  };
@@ -14,8 +14,8 @@
14
14
  */
15
15
 
16
16
  import { isTopLevelAgent } from "./agent-manager.js";
17
- import { type ModelRegistry, resolveModel } from "./model-resolver.js";
18
- import { checkModelScope } from "./model-scope.js";
17
+ import type { ModelRegistry } from "./model-resolver.js";
18
+ import { selectModel } from "./model-selection.js";
19
19
  import type { AgentRecord } from "./types.js";
20
20
 
21
21
  /** Minimal event bus interface needed by the RPC handlers. */
@@ -58,6 +58,7 @@ export interface SpawnCapable {
58
58
  }
59
59
 
60
60
  export interface RpcDeps {
61
+ homeDir?: string;
61
62
  events: EventBus;
62
63
  pi: unknown; // passed through to manager.spawn
63
64
  getCtx: () => unknown | undefined; // returns current ExtensionContext
@@ -112,13 +113,9 @@ export function registerRpcHandlers(deps: RpcDeps): RpcHandle {
112
113
  const ctx = getCtx();
113
114
  if (!ctx) throw new Error("No active session");
114
115
 
115
- // Cross-extension RPC callers (e.g. pi-tasks TaskExecute) naturally
116
- // forward serializable values, so options.model can be a string like
117
- // "openai-codex/gpt-5.5". Resolve it to a real Model instance here
118
- // — same pattern the scheduler path already uses — so the spawned
119
- // agent's auth lookup doesn't crash with "No API key found for
120
- // undefined".
121
- let normalizedOptions = options ?? {};
116
+ // Validate serializable overrides through the shared selector. Preserve
117
+ // the raw request for spawn, which records its source and outcome.
118
+ const normalizedOptions = options ?? {};
122
119
  // `!= null` on purpose: a JSON-forwarding caller can serialize an unset
123
120
  // field as null, and the runner reads `options.model ?? default`, so null
124
121
  // means "inherit" — not an override to resolve or scope-check.
@@ -131,34 +128,12 @@ export function registerRpcHandlers(deps: RpcDeps): RpcHandle {
131
128
  if (!modelRegistry) {
132
129
  throw new Error(`Model override "${label}" provided but ctx.modelRegistry is unavailable`);
133
130
  }
134
- let model = override;
135
- if (typeof override === "string") {
136
- const resolved = resolveModel(override, modelRegistry);
137
- if (typeof resolved === "string") {
138
- // resolveModel returns a human-readable error string when the
139
- // input doesn't match any available model. Surface it instead of
140
- // silently falling back so the caller sees the auth/typo issue.
141
- throw new Error(resolved);
142
- }
143
- model = resolved;
144
- normalizedOptions = { ...normalizedOptions, model: resolved };
145
- }
146
-
147
- // A model on the RPC payload is an orchestrator-level choice, exactly
148
- // like Agent({ model }) — so it gets the Agent tool's hard error, never
149
- // the frontmatter warn (#240). The check reads the RESOLVED model:
150
- // resolveModel is fuzzy, so a bare "sonnet" can land on a provider the
151
- // caller never named. Frontmatter-pinned and parent-inherited models are
152
- // resolved later, in agent-runner, and keep warn-and-proceed.
153
- const verdict = checkModelScope({
154
- model,
155
- cwd: cwd ?? process.cwd(),
156
- modelRegistry,
157
- callerSupplied: true,
158
- agentLabel: type,
159
- modelInput: label,
131
+ const selection = selectModel({
132
+ homeDir: deps.homeDir,
133
+ agentType: type, paramModel: override, parentModel: undefined,
134
+ registry: modelRegistry, cwd: cwd ?? process.cwd(),
160
135
  });
161
- if (verdict.kind === "error") throw new Error(verdict.message);
136
+ if (typeof selection === "string") throw new Error(selection);
162
137
  }
163
138
 
164
139
  const id = manager.spawn(pi, ctx, type, prompt, normalizedOptions);
@@ -4,8 +4,9 @@
4
4
 
5
5
  import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
6
6
  import { basename, join } from "node:path";
7
- import { getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
7
+ import { parseFrontmatter } from "@earendil-works/pi-coding-agent";
8
8
  import { BUILTIN_TOOL_NAMES } from "./agent-types.js";
9
+ import { resolveAgentDir } from "./home-paths.js";
9
10
  import {
10
11
  isPackageResourceExcluded,
11
12
  isProjectTrusted,
@@ -55,6 +56,8 @@ export interface LoadCustomAgentsOptions {
55
56
  * answer is recorded.
56
57
  */
57
58
  projectTrusted?: boolean;
59
+ /** Home directory for the default global agent directory. Defaults at call time. */
60
+ homeDir?: string;
58
61
  }
59
62
 
60
63
  /**
@@ -94,16 +97,17 @@ export function loadCustomAgents(
94
97
  strict = false,
95
98
  opts: LoadCustomAgentsOptions = {},
96
99
  ): Map<string, AgentConfig> {
97
- const globalDir = join(getAgentDir(), "agents");
100
+ const globalDir = join(resolveAgentDir(opts.homeDir), "agents");
98
101
  const workspaceProjectDir = join(cwd, ".agents", "agents");
99
102
  const projectDir = join(cwd, ".pi", "agents");
100
103
 
101
104
  const projectTrusted = opts.projectTrusted ?? isProjectTrusted();
102
- const pkgDirs = opts.packageDirs ?? packageAgentDirs(cwd, { projectTrusted });
103
- const pkgFiles = opts.packageFiles ?? packageAgentFiles(cwd, { projectTrusted });
105
+ const discovery = { projectTrusted, homeDir: opts.homeDir };
106
+ const pkgDirs = opts.packageDirs ?? packageAgentDirs(cwd, discovery);
107
+ const pkgFiles = opts.packageFiles ?? packageAgentFiles(cwd, discovery);
104
108
  // `!./agents/wip.md` alongside `./agents` excludes one file out of a directory
105
109
  // that is only enumerated here, so the check has to run per file.
106
- const excluded: Excluder = opts.packageExcluded ?? (path => isPackageResourceExcluded(path, cwd, "agents", { projectTrusted }));
110
+ const excluded: Excluder = opts.packageExcluded ?? (path => isPackageResourceExcluded(path, cwd, "agents", discovery));
107
111
 
108
112
  const agents = new Map<string, AgentConfig>();
109
113
  for (const dir of pkgDirs) loadFromDir(dir, agents, "package", strict, excluded); // lowest priority
@@ -34,10 +34,8 @@ export const DEFAULT_AGENTS: Map<string, AgentConfig> = new Map([
34
34
  builtinToolNames: READ_ONLY_TOOLS,
35
35
  extensions: true,
36
36
  skills: true,
37
- // Fast/cheap portable default for read-only search. A target-local alias
38
- // can select its canonical provider; otherwise fuzzy resolution finds a
39
- // matching enabled model under any provider.
40
- model: "claude-haiku-4-5",
37
+ // Bind the role per machine, not to a provider or model generation.
38
+ model: "@cheap",
41
39
  systemPrompt: `# CRITICAL: READ-ONLY MODE - NO FILE MODIFICATIONS
42
40
  You are a file search specialist. You excel at thoroughly navigating and exploring codebases.
43
41
  Your role is EXCLUSIVELY to search and analyze existing code. You do NOT have access to file editing tools.
@@ -28,7 +28,7 @@
28
28
 
29
29
  import { existsSync, readFileSync, statSync } from "node:fs";
30
30
  import { join } from "node:path";
31
- import { getAgentDir } from "@earendil-works/pi-coding-agent";
31
+ import { resolveAgentDir } from "./home-paths.js";
32
32
  import type { ModelEntry } from "./model-resolver.js";
33
33
 
34
34
  /** Minimal registry shape — only the methods resolveEnabledModels actually calls. */
@@ -38,10 +38,10 @@ export interface ModelRegistryRef {
38
38
  }
39
39
 
40
40
  /** Paths to pi's settings.json files: [project, global] (project takes precedence). */
41
- function settingsPaths(cwd: string): [project: string, global: string] {
41
+ function settingsPaths(cwd: string, homeDir?: string): [project: string, global: string] {
42
42
  return [
43
43
  join(cwd, ".pi", "settings.json"),
44
- join(getAgentDir(), "settings.json"),
44
+ join(resolveAgentDir(homeDir), "settings.json"),
45
45
  ];
46
46
  }
47
47
 
@@ -63,8 +63,8 @@ function readField(path: string): string[] | undefined {
63
63
  * (and matches our own loadSettings precedence in src/settings.ts).
64
64
  * Returns undefined when neither file has the field.
65
65
  */
66
- export function readEnabledModels(cwd: string): string[] | undefined {
67
- const [project, global] = settingsPaths(cwd);
66
+ export function readEnabledModels(cwd: string, homeDir?: string): string[] | undefined {
67
+ const [project, global] = settingsPaths(cwd, homeDir);
68
68
  return readField(project) ?? readField(global);
69
69
  }
70
70
 
@@ -102,11 +102,12 @@ export function resolveEnabledModels(
102
102
  patterns: string[] | undefined,
103
103
  registry: ModelRegistryRef,
104
104
  cwd: string = process.cwd(),
105
+ homeDir?: string,
105
106
  ): Set<string> | undefined {
106
107
  // Fast path: check cache (stat both project and global settings.json files)
107
108
  const patternsKey = JSON.stringify(patterns);
108
- const [project, global] = settingsPaths(cwd);
109
- const fileHash = `${hashOf(project)};${hashOf(global)}`;
109
+ const [project, global] = settingsPaths(cwd, homeDir);
110
+ const fileHash = `${project}:${hashOf(project)};${global}:${hashOf(global)}`;
110
111
 
111
112
  if (fileHash === cachedHash && patternsKey === cachedPatternsKey) {
112
113
  return cachedAllowed;
@@ -8,7 +8,7 @@
8
8
 
9
9
  import { existsSync, readFileSync, statSync } from "node:fs";
10
10
  import { join } from "node:path";
11
- import { getAgentDir } from "@earendil-works/pi-coding-agent";
11
+ import { resolveAgentDir } from "./home-paths.js";
12
12
 
13
13
  const MANIFEST_VERSION = 1;
14
14
  const MANIFEST_NAME = "enterprise.local.json";
@@ -21,16 +21,16 @@ export interface EnterpriseLocalManifest {
21
21
  }
22
22
 
23
23
  /** Return the target-local manifest path without reading it. */
24
- export function enterpriseLocalPath(): string {
25
- return join(getAgentDir(), MANIFEST_NAME);
24
+ export function enterpriseLocalPath(homeDir?: string): string {
25
+ return join(resolveAgentDir(homeDir), MANIFEST_NAME);
26
26
  }
27
27
 
28
28
  /**
29
29
  * Read and minimally validate the machine-local manifest. It never exposes its
30
30
  * contents in diagnostics: providers can include command-backed credentials.
31
31
  */
32
- export function readEnterpriseLocalManifest(): EnterpriseLocalManifest | undefined {
33
- const path = enterpriseLocalPath();
32
+ export function readEnterpriseLocalManifest(homeDir?: string): EnterpriseLocalManifest | undefined {
33
+ const path = enterpriseLocalPath(homeDir);
34
34
  if (!existsSync(path)) return undefined;
35
35
 
36
36
  try {