@bacnh85/pi-subagent 0.15.2 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -39,6 +39,7 @@ import {
39
39
  startHeartbeat,
40
40
  } from "./runner.ts";
41
41
  import {
42
+ flushWarnings,
42
43
  isRateLimitError,
43
44
  normalizeTimeout,
44
45
  resolveSafeCwd,
@@ -59,13 +60,15 @@ import {
59
60
  } from "./render.ts";
60
61
  import { type SubagentThread, threadStore } from "./threads.ts";
61
62
  import { SUBAGENT_REQUEST_EVENT, runNamedAgent, type SubagentRunRequest } from "./service.ts";
62
- import { resolveModel } from "./model.ts";
63
+ import { resolveModel, runWithModelFallback } from "./model.ts";
64
+ import { DEFAULT_ROLES, describeAgentModels, readSubagentRoles, readSubagentRolesGlobal, resolveAgentModelChain, type RolesConfig } from "./roles.ts";
63
65
  import { ThreadViewer, type ThreadViewerCallbacks } from "./thread-viewer.ts";
64
66
  import { createTaskWidgetController, renderLiveThreadLine, type TaskWidgetController } from "./widget.ts";
65
67
  import {
66
68
  startBackgroundTask,
67
69
  cancelBackgroundTask,
68
70
  getBackgroundTask,
71
+ getAllBackgroundTasks,
69
72
  snapshotTask,
70
73
  clearBackgroundTasks,
71
74
  } from "./background.ts";
@@ -107,14 +110,14 @@ const TaskItem = Type.Object({
107
110
  agent: Type.String({ description: "Name of the agent to invoke" }),
108
111
  task: Type.String({ description: "Task to delegate to the agent" }),
109
112
  cwd: Type.Optional(Type.String({ description: "Working directory for the agent" })),
110
- timeout: Type.Optional(Type.Number({ description: "Inactivity timeout in milliseconds for this task; real child activity resets it (default 3 minutes, absolute cap 20 minutes)" })),
113
+ timeout: Type.Optional(Type.Number({ description: "Inactivity timeout in ms; aborts on no activity within timeout. Default: 3 min (PI_SUBAGENT_INACTIVITY_TIMEOUT_MINS). The agent always has a lifetime cap: default 20 min or (PI_SUBAGENT_HARD_TIMEOUT_MINS)." })),
111
114
  });
112
115
 
113
116
  const ChainItem = Type.Object({
114
117
  agent: Type.String({ description: "Name of the agent to invoke" }),
115
118
  task: Type.String({ description: "Task with optional {previous} placeholder for prior output" }),
116
119
  cwd: Type.Optional(Type.String({ description: "Working directory for the agent" })),
117
- timeout: Type.Optional(Type.Number({ description: "Inactivity timeout in milliseconds for this step; real child activity resets it (default 3 minutes, absolute cap 20 minutes)" })),
120
+ timeout: Type.Optional(Type.Number({ description: "Inactivity timeout in ms; aborts on no activity within timeout. Default: 3 min (PI_SUBAGENT_INACTIVITY_TIMEOUT_MINS). The agent always has a lifetime cap: default 20 min or (PI_SUBAGENT_HARD_TIMEOUT_MINS)." })),
118
121
  });
119
122
 
120
123
  const AgentScopeSchema = StringEnum(["user", "project", "both"] as const, {
@@ -153,7 +156,7 @@ const SubagentParams = Type.Object({
153
156
  // Project-agent confirmation is enforced via trusted configuration.
154
157
  // See Security model section in README.
155
158
  cwd: Type.Optional(Type.String({ description: "Working directory (single mode, must be inside workspace)" })),
156
- timeout: Type.Optional(Type.Number({ description: "Global inactivity timeout in milliseconds (default 3 minutes; real activity resets it; fixed 20-minute absolute cap)" })),
159
+ timeout: Type.Optional(Type.Number({ description: "Inactivity timeout for the whole run, in ms; resets on activity, aborts on silence. Default 3 min (PI_SUBAGENT_INACTIVITY_TIMEOUT_MINS). Lifetime cap: 20 min or (PI_SUBAGENT_HARD_TIMEOUT_MINS)." })),
157
160
  instructions: Type.Optional(Type.String({ description: "Bounded repository/task instructions passed to each child (max 16 KB)" })),
158
161
  abortOnFailure: Type.Optional(Type.Boolean({ description: "In parallel mode, cancel remaining tasks when one fails. Default: false.", default: false })),
159
162
  });
@@ -192,10 +195,13 @@ export default function (pi: ExtensionAPI) {
192
195
  trustedProjectAgentDirs.clear();
193
196
  // Clear any widget from a prior session.
194
197
  widget.clearWidgetIfIdle();
195
- // Mark prior-session running tasks as interrupted (we can't resume them).
198
+ // Mark prior-session running tasks as interrupted (we can't resume them),
199
+ // but keep entries for background tasks still live in this process — only
200
+ // shutdown aborts them, so a session reload must not mislabel them.
196
201
  // ponytail: honest about the in-process ceiling — no live-session resume.
197
202
  try {
198
- markInterruptedOnRestart(path.join(ctx.cwd, CONFIG_DIR_NAME));
203
+ const liveBgIds = new Set(getAllBackgroundTasks().map((t) => t.id));
204
+ markInterruptedOnRestart(path.join(ctx.cwd, CONFIG_DIR_NAME), liveBgIds);
199
205
  } catch { /* history file not writable — non-fatal */ }
200
206
  });
201
207
 
@@ -212,12 +218,17 @@ export default function (pi: ExtensionAPI) {
212
218
  pi.on("before_agent_start", async (event) => {
213
219
  const ctx = currentCtx;
214
220
  const discovery = discoverAgents(ctx?.cwd ?? process.cwd(), "both", bundledAgentsDir);
215
- const catalog = discovery.agents
221
+ const projectTrusted = ctx?.isProjectTrusted?.() ?? false;
222
+ // Security: project agents are repo-controlled (untrusted until the user
223
+ // approves them). Never let their description text reach the parent's
224
+ // system prompt unless the project is trusted — same gate as AGENTS.md.
225
+ const catalogAgents = discovery.agents.filter(
226
+ (agent) => projectTrusted || agent.source !== "project",
227
+ );
228
+ const rolesCfg = readSubagentRoles(ctx);
229
+ const catalog = catalogAgents
216
230
  .map((agent) => {
217
- const candidates = getModelCandidates(agent);
218
- const modelInfo = candidates.length > 0
219
- ? ` (models: ${candidates.join(" → ")} → parent fallback)`
220
- : " (parent fallback)";
231
+ const modelInfo = ` (models: ${describeAgentModels(agent, rolesCfg)})`;
221
232
  const thinkingInfo = agent.thinking ? `, thinking: ${agent.thinking}` : "";
222
233
  const sandboxInfo = agent.sandbox ? `, sandbox: ${agent.sandbox}` : "";
223
234
  // ponytail: one-line inheritance hint; the model picks agents by description, this just sets expectations.
@@ -262,6 +273,7 @@ export default function (pi: ExtensionAPI) {
262
273
  instructions: request.instructions,
263
274
  signal: request.signal,
264
275
  readOnly: request.readOnly,
276
+ allowExternalCwd: getTrustedConfig(ctx).allowExternalCwd,
265
277
  onMessage: (result) => threadStore.updateThread(thread.id, { result }),
266
278
  onProgress: (progress) => { threadStore.updateProgress(thread.id, progress); request.onProgress?.(progress); },
267
279
  }).then((result) => {
@@ -316,7 +328,7 @@ export default function (pi: ExtensionAPI) {
316
328
  return container;
317
329
  });
318
330
  pi.registerCommand("subagent", {
319
- description: "List available sub-agents, reload agent definitions, or show agent details",
331
+ description: "Configure model roles (/subagent), list agents (/subagent list), agent details (/subagent <name>), role detail (/subagent @role), reload definitions (/subagent reload), history (/subagent history)",
320
332
  handler: async (args, ctx) => {
321
333
  const cmd = args.trim().toLowerCase();
322
334
  const discovery = discoverAgents(ctx.cwd, "both", bundledAgentsDir);
@@ -350,6 +362,61 @@ export default function (pi: ExtensionAPI) {
350
362
  return;
351
363
  }
352
364
 
365
+ const openRolesEditor = async (): Promise<void> => {
366
+ // Role mapping editor: panel in TUI, plain text otherwise.
367
+ const [{ openConfigPanel }, { buildRows, buildRolesPanelCfg, cfgToPatch, preserveUnknownAgentModels, writeSubagentSection }] = await Promise.all([
368
+ import("@bacnh85/pi-config-panel"),
369
+ import("./roles-panel.ts"),
370
+ ]);
371
+ if (ctx.mode !== "tui" || !ctx.hasUI) {
372
+ const rolesCfg = readSubagentRoles(ctx);
373
+ const lines = discovery.agents.map((a) => ` ${a.name.padEnd(16)} ${describeAgentModels(a, rolesCfg)}`);
374
+ pi.sendMessage({
375
+ customType: "pi-subagent",
376
+ content: [
377
+ "Model roles (edit ~/.pi/agent/settings.json → subagent.roles, or run /subagent in a TUI):",
378
+ ...Object.entries(rolesCfg.roles).map(([name, chain]) =>
379
+ ` @${name} = ${Array.isArray(chain) ? chain.join(", ") : chain}`),
380
+ "",
381
+ "Effective models per agent:",
382
+ ...lines,
383
+ ].join("\n"),
384
+ display: true,
385
+ });
386
+ return;
387
+ }
388
+ const current = readSubagentRolesGlobal();
389
+ const working = buildRolesPanelCfg(discovery.agents, current);
390
+ await openConfigPanel({
391
+ ctx,
392
+ cfg: working,
393
+ build: (cfg) => buildRows(cfg, discovery.agents),
394
+ title: "Subagent model roles",
395
+ onSave: (saved, editedKeys) => {
396
+ if (!(saved && editedKeys && editedKeys.size > 0)) return;
397
+ const patch = cfgToPatch(working);
398
+ patch.agentModels = preserveUnknownAgentModels(
399
+ patch.agentModels,
400
+ discovery.agents.map((a) => a.name),
401
+ current.agentModels,
402
+ );
403
+ try {
404
+ writeSubagentSection(patch);
405
+ invalidateAgentCache();
406
+ ctx.ui.notify("Model roles saved to settings.json", "info");
407
+ } catch (err) {
408
+ ctx.ui.notify(`Not saved — ${err instanceof Error ? err.message : String(err)}`, "error");
409
+ }
410
+ },
411
+ });
412
+ return;
413
+ };
414
+
415
+ if (cmd === "roles") {
416
+ await openRolesEditor();
417
+ return;
418
+ }
419
+
353
420
  if (cmd === "reload" || cmd === "refresh") {
354
421
  invalidateAgentCache();
355
422
  const fresh = discoverAgents(ctx.cwd, "both", bundledAgentsDir);
@@ -378,28 +445,61 @@ export default function (pi: ExtensionAPI) {
378
445
  : "";
379
446
  pi.sendMessage({
380
447
  customType: "pi-subagent",
381
- content: `Available agents (${discovery.agents.length}):\n ${list.text}${extra}${diagText}\n\nScopes searched:\n user: ${path.join(getAgentDir(), "agents")}${dirs}\n bundled: ${bundledAgentsDir}\n\nUse /subagent <name> for agent details, /subagent reload to refresh.`,
448
+ content: `Available agents (${discovery.agents.length}):\n ${list.text}${extra}${diagText}\n\nScopes searched:\n user: ${path.join(getAgentDir(), "agents")}${dirs}\n bundled: ${bundledAgentsDir}\n\nUse /subagent <name> for agent details, /subagent @role for role detail, /subagent reload to refresh.`,
382
449
  display: true,
383
450
  });
384
451
  return;
385
452
  }
386
453
 
387
454
  if (cmd) {
388
- // Show details for a specific agent
455
+ // Show details for a specific agent; fall back to a role detail view
456
+ // when the name matches a model role (e.g. "/subagent coder").
389
457
  const agent = discovery.agents.find(
390
458
  (a) => a.name.toLowerCase() === cmd,
391
459
  );
392
460
  if (!agent) {
393
- ctx.ui.notify(`Unknown agent: "${args.trim()}". Use /subagent to list all.`, "error");
461
+ const rolesCfg = readSubagentRoles(ctx);
462
+ const arg = args.trim().toLowerCase();
463
+ const roleName = arg.startsWith("@") ? arg.slice(1) : arg;
464
+ // Resolve the role key case-insensitively (role names are free-form).
465
+ const roleKey = Object.keys(rolesCfg.roles).find((k) => k.toLowerCase() === roleName);
466
+ const roleChain = roleKey !== undefined ? rolesCfg.roles[roleKey] : undefined;
467
+ const role = roleKey !== undefined && roleChain !== undefined
468
+ ? { key: roleKey, chain: roleChain }
469
+ : undefined;
470
+ if (role) {
471
+ const { key, chain } = role;
472
+ const chainText = Array.isArray(chain) ? chain.join(" → ") : String(chain);
473
+ const users = discovery.agents.filter((a) => getModelCandidates(a).some((c) => c.toLowerCase().split(":")[0] === `@${key.toLowerCase()}`));
474
+ const overrides = Object.entries(rolesCfg.agentModels).filter(([, v]) => v.toLowerCase().split(":")[0] === `@${key.toLowerCase()}`);
475
+ const defaultText = DEFAULT_ROLES[key] !== undefined
476
+ ? (Array.isArray(DEFAULT_ROLES[key]) ? (DEFAULT_ROLES[key] as string[]).join(" → ") : String(DEFAULT_ROLES[key]))
477
+ : "(custom role)";
478
+ pi.sendMessage({
479
+ customType: "pi-subagent",
480
+ content: [
481
+ `Role: @${key}`,
482
+ `Chain: ${chainText} → parent fallback`,
483
+ `Default: ${defaultText}`,
484
+ users.length > 0 ? `Agents using @${key}: ${users.map((a) => a.name).join(", ")}` : `No agent references @${key} yet`,
485
+ overrides.length > 0 ? `Overrides via @${key}: ${overrides.map(([n]) => n).join(", ")}` : "",
486
+ "",
487
+ `Edit with /subagent (roles editor) or ~/.pi/agent/settings.json → subagent.roles.`,
488
+ ].filter(Boolean).join("\n"),
489
+ display: true,
490
+ });
491
+ return;
492
+ }
493
+ ctx.ui.notify(`Unknown agent: "${args.trim()}". Use /subagent list to list all.`, "error");
394
494
  return;
395
495
  }
396
- const candidates = getModelCandidates(agent);
496
+ const rolesCfg = readSubagentRoles(ctx);
397
497
  pi.sendMessage({
398
498
  customType: "pi-subagent",
399
499
  content: [
400
500
  `Agent: ${agent.name} (${agent.source})`,
401
501
  `Description: ${agent.description}`,
402
- `Models: ${candidates.length > 0 ? `${candidates.join(" → ")} → parent fallback` : "parent fallback"}`,
502
+ `Models: ${describeAgentModels(agent, rolesCfg)}`,
403
503
  `Thinking: ${agent.thinking || "off"}`,
404
504
  `Tools: ${agent.tools?.join(", ") || "all default"}`,
405
505
  `Source file: ${agent.filePath}`,
@@ -412,18 +512,8 @@ export default function (pi: ExtensionAPI) {
412
512
  return;
413
513
  }
414
514
 
415
- // List all agents
416
- const list = formatAgentList(discovery.agents, 20);
417
- const extra = list.remaining > 0 ? `\n ... +${list.remaining} more` : "";
418
- const dirs = discovery.projectAgentsDir ? `\n project: ${discovery.projectAgentsDir}` : "";
419
- const diagText = discovery.diagnostics.length > 0
420
- ? "\n\nWarnings:\n" + discovery.diagnostics.map(d => ` - [${d.severity}] ${d.filePath}: ${d.issue}`).join("\n")
421
- : "";
422
- pi.sendMessage({
423
- customType: "pi-subagent",
424
- content: `Available agents (${discovery.agents.length}):\n ${list.text}${extra}${diagText}\n\nScopes searched:\n user: ${path.join(getAgentDir(), "agents")}${dirs}\n bundled: ${bundledAgentsDir}\n\nUse /subagent <name> for agent details, /subagent reload to refresh.`,
425
- display: true,
426
- });
515
+ // Bare /subagent — open the roles editor (list moved to /subagent list).
516
+ await openRolesEditor();
427
517
  },
428
518
  });
429
519
 
@@ -474,9 +564,14 @@ export default function (pi: ExtensionAPI) {
474
564
  "Bundled agents: scout (fast recon), tester (verification), worker (implementation), general-purpose (fallback), planner (planning), reviewer (review).",
475
565
  "For background single tasks use background:true — you will be notified on completion; DO NOT poll or sleep.",
476
566
  "Use operation: \"status\" with taskId to inspect a running/completed background task; operation: \"cancel\" to abort one.",
477
- "Use /subagent to list all available agents or /subagent <name> for agent details.",
567
+ "Use /subagent list to list all available agents, /subagent <name> for agent details, /subagent @role for role detail.",
478
568
  ],
479
569
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
570
+ // Surface env-var timeout warnings collected at module load. The
571
+ // interactive TUI swallows module-load stderr, so notify on launch.
572
+ for (const msg of flushWarnings()) {
573
+ ctx.ui?.notify?.(msg, "warning");
574
+ }
480
575
  const agentScope: AgentScope = params.agentScope ?? "user";
481
576
  const discovery = discoverAgents(ctx.cwd, agentScope, bundledAgentsDir);
482
577
  const agents = discovery.agents;
@@ -578,6 +673,7 @@ export default function (pi: ExtensionAPI) {
578
673
  },
579
674
  ],
580
675
  details: makeDetails("single")([]),
676
+ isError: true,
581
677
  };
582
678
  }
583
679
 
@@ -631,6 +727,10 @@ export default function (pi: ExtensionAPI) {
631
727
  const modelRuntime = (modelRegistry as any).runtime;
632
728
  const authStorage = (modelRegistry as any).authStorage;
633
729
 
730
+ // Roles + per-agent overrides are read once per execute() call so every
731
+ // child in this run sees a consistent mapping.
732
+ const rolesCfg: RolesConfig = readSubagentRoles(ctx);
733
+
634
734
  // Parent session's registered tool names. Agents that omit `tools` inherit
635
735
  // the full set (minus the denylist); agents with an explicit `tools` line
636
736
  // are validated against built-ins ∪ this set.
@@ -646,9 +746,30 @@ export default function (pi: ExtensionAPI) {
646
746
  return safe.path;
647
747
  }
648
748
 
749
+ // Helper: stable history id for a foreground run (shared between the
750
+ // running entry written at start and the completion entry).
751
+ function makeForegroundHistoryId(startedAt: number): string {
752
+ return `fg-${startedAt.toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
753
+ }
754
+
755
+ // Helper: record a running foreground task so a crash mid-run shows as
756
+ // "interrupted" after restart (completion upserts by id and replaces it).
757
+ function recordForegroundStart(entryId: string, agentName: string, taskText: string, startedAt: number): void {
758
+ try {
759
+ appendHistory(path.join(ctx.cwd, CONFIG_DIR_NAME), {
760
+ id: entryId,
761
+ agent: agentName,
762
+ task: taskText,
763
+ status: "running",
764
+ startedAt,
765
+ });
766
+ } catch { /* history file not writable — non-fatal */ }
767
+ }
768
+
649
769
  // Helper: record a completed foreground task to the history registry.
650
770
  // ponytail: best-effort — history is non-fatal metadata for /subagent history.
651
771
  function recordForegroundHistory(
772
+ entryId: string,
652
773
  agentName: string,
653
774
  taskText: string,
654
775
  result: SubAgentResult,
@@ -666,7 +787,7 @@ export default function (pi: ExtensionAPI) {
666
787
  : "failed"
667
788
  : "completed";
668
789
  appendHistory(path.join(ctx.cwd, CONFIG_DIR_NAME), {
669
- id: `fg-${startedAt.toString(36)}-${Math.random().toString(36).slice(2, 6)}`,
790
+ id: entryId,
670
791
  agent: agentName,
671
792
  task: taskText,
672
793
  status,
@@ -737,7 +858,8 @@ export default function (pi: ExtensionAPI) {
737
858
  };
738
859
  }
739
860
 
740
- const resolved = await resolveModel(getModelCandidates(agent), ctx.model, ctx.modelRegistry);
861
+ const agentChain = resolveAgentModelChain(agent, rolesCfg);
862
+ const resolved = await resolveModel(agentChain.candidates, ctx.model, ctx.modelRegistry);
741
863
  if (!resolved.model) {
742
864
  const tried = resolved.attempted.join(", ") || "none";
743
865
  const parentInfo = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "none";
@@ -780,9 +902,10 @@ export default function (pi: ExtensionAPI) {
780
902
  };
781
903
  }
782
904
 
783
- // Retry loop: rate-limit model fallback
784
- const candidates = getModelCandidates(agent);
785
- const triedModels: string[] = [];
905
+ // Retry loop: rate-limit model fallback (candidates already role-expanded).
906
+ // Shared with the service path — single source of truth for triedModels
907
+ // bookkeeping and per-candidate `:thinking` resolution.
908
+ const candidates = agentChain.candidates;
786
909
 
787
910
  // Transport keep-alive only: resets parent idle timeout so a long child
788
911
  // run isn't killed. The visible progress now lives in the live widget;
@@ -793,32 +916,47 @@ export default function (pi: ExtensionAPI) {
793
916
  widget.requestRender();
794
917
  }) : undefined;
795
918
  try {
796
- const tryWithFallback = async (): Promise<SubAgentResult> => {
797
- const remaining = candidates.filter(m => !triedModels.includes(m));
798
- const isParentFallback = remaining.length === 0;
799
- const fallbackResolved = await resolveModel(remaining, ctx.model, ctx.modelRegistry);
800
- if (!fallbackResolved.model) {
801
- return {
802
- agent: agentName,
919
+ return await runWithModelFallback<SubAgentResult>({
920
+ candidates,
921
+ parentModel: ctx.model,
922
+ modelRegistry: ctx.modelRegistry,
923
+ thinkingByCandidate: agentChain.thinkingByCandidate,
924
+ defaultThinking: agent.thinking,
925
+ runAttempt: (model, thinkingLevel) =>
926
+ runSubAgent({
927
+ cwd: safeCwd,
928
+ sandbox: agent.sandbox === "worktree" ? "worktree" : undefined,
929
+ systemPrompt: params.instructions
930
+ ? `${agent.systemPrompt}\n\n## Task Contract\n${params.instructions.slice(0, MAX_INSTRUCTIONS_LENGTH)}`
931
+ : agent.systemPrompt,
803
932
  task,
804
- exitCode: 1,
805
- status: "error" as const,
806
- stopReason: "error" as const,
807
- messages: [],
808
- stderr: [
809
- `All models rate-limited or unavailable.`,
810
- `Tried: ${triedModels.join(" → ") || "(none)"}.`,
811
- `Remaining candidates: ${remaining.join(", ") || "none"}.`,
812
- `Parent: ${ctx.model?.provider}/${ctx.model?.id}.`,
813
- ].join(" "),
814
- usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
815
- errorMessage: `All models exhausted (tried: ${triedModels.join(" → ") || "none"})`,
816
- };
817
- }
818
- const triedName = `${fallbackResolved.model!.provider}/${fallbackResolved.model!.id}`;
819
- if (triedModels.includes(triedName)) {
820
- // Already tried this model (e.g., all candidates unavailable
821
- // and parent fallback) — no further options.
933
+ tools,
934
+ model,
935
+ modelRuntime,
936
+ authStorage,
937
+ modelRegistry,
938
+ signal: parentSignal,
939
+ timeoutMs: effectiveTimeoutMs,
940
+ agentName,
941
+ thinkingLevel,
942
+ onMessage: onProgress,
943
+ onProgress: onActivity,
944
+ loadExtensions,
945
+ projectTrusted,
946
+ }),
947
+ isRateLimited: (result) => Boolean(result.errorMessage && isRateLimitError(result.errorMessage)),
948
+ onExhausted: (reason, triedModels, remaining) => {
949
+ const exhaustedStderr = reason === "no-model"
950
+ ? [
951
+ `All models rate-limited or unavailable.`,
952
+ `Tried: ${triedModels.join(" → ") || "(none)"}.`,
953
+ `Remaining candidates: ${remaining.join(", ") || "none"}.`,
954
+ `Parent: ${ctx.model?.provider}/${ctx.model?.id}.`,
955
+ ].join(" ")
956
+ : [
957
+ `All available models exhausted.`,
958
+ `Tried: ${triedModels.join(" → ")}.`,
959
+ ].join(" ");
822
960
  return {
823
961
  agent: agentName,
824
962
  task,
@@ -826,69 +964,14 @@ export default function (pi: ExtensionAPI) {
826
964
  status: "error" as const,
827
965
  stopReason: "error" as const,
828
966
  messages: [],
829
- stderr: [
830
- `All available models exhausted.`,
831
- `Tried: ${triedModels.join(" → ")}.`,
832
- ].join(" "),
967
+ stderr: exhaustedStderr,
833
968
  usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
834
- errorMessage: `All available models exhausted (tried: ${triedModels.join("")})`,
969
+ errorMessage: reason === "no-model"
970
+ ? `All models exhausted (tried: ${triedModels.join(" → ") || "none"})`
971
+ : `All available models exhausted (tried: ${triedModels.join(" → ")})`,
835
972
  };
836
- }
837
- triedModels.push(triedName);
838
- // Also track the raw candidate name so candidates.filter() can
839
- // exclude it even when the agent uses unqualified names.
840
- // Avoid duplicating when candidate name is already qualified (matchedCandidate === triedName).
841
- if (fallbackResolved.matchedCandidate && fallbackResolved.matchedCandidate !== triedName) {
842
- triedModels.push(fallbackResolved.matchedCandidate);
843
- }
844
-
845
- const result = await runSubAgent({
846
- cwd: safeCwd,
847
- sandbox: agent.sandbox === "worktree" ? "worktree" : undefined,
848
- systemPrompt: params.instructions
849
- ? `${agent.systemPrompt}\n\n## Task Contract\n${params.instructions.slice(0, MAX_INSTRUCTIONS_LENGTH)}`
850
- : agent.systemPrompt,
851
- task,
852
- tools,
853
- model: fallbackResolved.model,
854
- modelRuntime,
855
- authStorage,
856
- modelRegistry,
857
- signal: parentSignal,
858
- timeoutMs: effectiveTimeoutMs,
859
- agentName,
860
- thinkingLevel: agent.thinking,
861
- onMessage: onProgress,
862
- onProgress: onActivity,
863
- loadExtensions,
864
- projectTrusted,
865
- });
866
-
867
- if (result.errorMessage && isRateLimitError(result.errorMessage)) {
868
- // If the model that just rate-limited was the parent fallback
869
- // (no remaining candidates), stop — no further options.
870
- if (isParentFallback) {
871
- return {
872
- agent: agentName,
873
- task,
874
- exitCode: 1,
875
- status: "error" as const,
876
- stopReason: "error" as const,
877
- messages: [],
878
- stderr: [
879
- `All available models exhausted.`,
880
- `Tried: ${triedModels.join(" → ")}.`,
881
- ].join(" "),
882
- usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
883
- errorMessage: `All available models exhausted (tried: ${triedModels.join(" → ")})`,
884
- };
885
- }
886
- return tryWithFallback();
887
- }
888
- return result;
889
- };
890
-
891
- return tryWithFallback();
973
+ },
974
+ });
892
975
  } finally {
893
976
  stopHeartbeat?.();
894
977
  }
@@ -911,6 +994,8 @@ export default function (pi: ExtensionAPI) {
911
994
  color: agentToThemeColor(step.agent),
912
995
  });
913
996
  if (ctx.mode === "tui") widget.ensureWidget(ctx);
997
+ const historyId = makeForegroundHistoryId(thread.createdAt);
998
+ recordForegroundStart(historyId, step.agent, taskWithContext, thread.createdAt);
914
999
  const result = await runOne(
915
1000
  step.agent, taskWithContext, step.cwd,
916
1001
  signal, step.timeout ?? params.timeout,
@@ -923,7 +1008,7 @@ export default function (pi: ExtensionAPI) {
923
1008
  status: isFailedResult(result) ? (result.stopReason === "aborted" ? "aborted" : "failed") : "completed",
924
1009
  result,
925
1010
  });
926
- recordForegroundHistory(step.agent, taskWithContext, result, thread.createdAt);
1011
+ recordForegroundHistory(historyId, step.agent, taskWithContext, result, thread.createdAt);
927
1012
  results.push(result);
928
1013
 
929
1014
  const isError = isFailedResult(result);
@@ -1069,6 +1154,8 @@ export default function (pi: ExtensionAPI) {
1069
1154
  emitParallelUpdate();
1070
1155
  return skippedResult;
1071
1156
  }
1157
+ const historyId = makeForegroundHistoryId(parallelThreads[index].createdAt);
1158
+ recordForegroundStart(historyId, t.agent, t.task, parallelThreads[index].createdAt);
1072
1159
  const result = await runOne(
1073
1160
  t.agent, t.task, t.cwd,
1074
1161
  parallelController.signal, t.timeout ?? params.timeout,
@@ -1082,7 +1169,7 @@ export default function (pi: ExtensionAPI) {
1082
1169
  status: isFailedResult(result) ? (result.stopReason === "aborted" ? "aborted" : "failed") : "completed",
1083
1170
  result,
1084
1171
  });
1085
- recordForegroundHistory(t.agent, t.task, result, parallelThreads[index].createdAt);
1172
+ recordForegroundHistory(historyId, t.agent, t.task, result, parallelThreads[index].createdAt);
1086
1173
  // Early-abort: if this task failed and abortOnFailure is set
1087
1174
  if (abortOnFailure && isFailedResult(result) && !abortCause) {
1088
1175
  abortCause = result.stopReason === "timeout" ? "timeout" : "sibling";
@@ -1146,6 +1233,8 @@ export default function (pi: ExtensionAPI) {
1146
1233
  color: agentToThemeColor(params.agent),
1147
1234
  });
1148
1235
  if (ctx.mode === "tui") widget.ensureWidget(ctx);
1236
+ const historyId = makeForegroundHistoryId(thread.createdAt);
1237
+ recordForegroundStart(historyId, params.agent, params.task, thread.createdAt);
1149
1238
  const result = await runOne(
1150
1239
  params.agent, params.task, params.cwd,
1151
1240
  signal, params.timeout,
@@ -1158,7 +1247,7 @@ export default function (pi: ExtensionAPI) {
1158
1247
  status: isFailedResult(result) ? (result.stopReason === "aborted" ? "aborted" : "failed") : "completed",
1159
1248
  result,
1160
1249
  });
1161
- recordForegroundHistory(params.agent, params.task, result, thread.createdAt);
1250
+ recordForegroundHistory(historyId, params.agent, params.task, result, thread.createdAt);
1162
1251
  const isError = isFailedResult(result);
1163
1252
 
1164
1253
  if (onUpdate) {
@@ -1711,4 +1800,4 @@ export default function (pi: ExtensionAPI) {
1711
1800
  }
1712
1801
  }, { overlay: true, overlayOptions: { maxHeight: "70%" } }); // Overlay: editor stays visible below
1713
1802
  }
1714
- }
1803
+ }