@bacnh85/pi-subagent 0.15.3 → 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.
@@ -60,13 +60,15 @@ import {
60
60
  } from "./render.ts";
61
61
  import { type SubagentThread, threadStore } from "./threads.ts";
62
62
  import { SUBAGENT_REQUEST_EVENT, runNamedAgent, type SubagentRunRequest } from "./service.ts";
63
- 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";
64
65
  import { ThreadViewer, type ThreadViewerCallbacks } from "./thread-viewer.ts";
65
66
  import { createTaskWidgetController, renderLiveThreadLine, type TaskWidgetController } from "./widget.ts";
66
67
  import {
67
68
  startBackgroundTask,
68
69
  cancelBackgroundTask,
69
70
  getBackgroundTask,
71
+ getAllBackgroundTasks,
70
72
  snapshotTask,
71
73
  clearBackgroundTasks,
72
74
  } from "./background.ts";
@@ -193,10 +195,13 @@ export default function (pi: ExtensionAPI) {
193
195
  trustedProjectAgentDirs.clear();
194
196
  // Clear any widget from a prior session.
195
197
  widget.clearWidgetIfIdle();
196
- // 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.
197
201
  // ponytail: honest about the in-process ceiling — no live-session resume.
198
202
  try {
199
- 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);
200
205
  } catch { /* history file not writable — non-fatal */ }
201
206
  });
202
207
 
@@ -213,12 +218,17 @@ export default function (pi: ExtensionAPI) {
213
218
  pi.on("before_agent_start", async (event) => {
214
219
  const ctx = currentCtx;
215
220
  const discovery = discoverAgents(ctx?.cwd ?? process.cwd(), "both", bundledAgentsDir);
216
- 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
217
230
  .map((agent) => {
218
- const candidates = getModelCandidates(agent);
219
- const modelInfo = candidates.length > 0
220
- ? ` (models: ${candidates.join(" → ")} → parent fallback)`
221
- : " (parent fallback)";
231
+ const modelInfo = ` (models: ${describeAgentModels(agent, rolesCfg)})`;
222
232
  const thinkingInfo = agent.thinking ? `, thinking: ${agent.thinking}` : "";
223
233
  const sandboxInfo = agent.sandbox ? `, sandbox: ${agent.sandbox}` : "";
224
234
  // ponytail: one-line inheritance hint; the model picks agents by description, this just sets expectations.
@@ -263,6 +273,7 @@ export default function (pi: ExtensionAPI) {
263
273
  instructions: request.instructions,
264
274
  signal: request.signal,
265
275
  readOnly: request.readOnly,
276
+ allowExternalCwd: getTrustedConfig(ctx).allowExternalCwd,
266
277
  onMessage: (result) => threadStore.updateThread(thread.id, { result }),
267
278
  onProgress: (progress) => { threadStore.updateProgress(thread.id, progress); request.onProgress?.(progress); },
268
279
  }).then((result) => {
@@ -317,7 +328,7 @@ export default function (pi: ExtensionAPI) {
317
328
  return container;
318
329
  });
319
330
  pi.registerCommand("subagent", {
320
- 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)",
321
332
  handler: async (args, ctx) => {
322
333
  const cmd = args.trim().toLowerCase();
323
334
  const discovery = discoverAgents(ctx.cwd, "both", bundledAgentsDir);
@@ -351,6 +362,61 @@ export default function (pi: ExtensionAPI) {
351
362
  return;
352
363
  }
353
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
+
354
420
  if (cmd === "reload" || cmd === "refresh") {
355
421
  invalidateAgentCache();
356
422
  const fresh = discoverAgents(ctx.cwd, "both", bundledAgentsDir);
@@ -379,28 +445,61 @@ export default function (pi: ExtensionAPI) {
379
445
  : "";
380
446
  pi.sendMessage({
381
447
  customType: "pi-subagent",
382
- 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.`,
383
449
  display: true,
384
450
  });
385
451
  return;
386
452
  }
387
453
 
388
454
  if (cmd) {
389
- // 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").
390
457
  const agent = discovery.agents.find(
391
458
  (a) => a.name.toLowerCase() === cmd,
392
459
  );
393
460
  if (!agent) {
394
- 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");
395
494
  return;
396
495
  }
397
- const candidates = getModelCandidates(agent);
496
+ const rolesCfg = readSubagentRoles(ctx);
398
497
  pi.sendMessage({
399
498
  customType: "pi-subagent",
400
499
  content: [
401
500
  `Agent: ${agent.name} (${agent.source})`,
402
501
  `Description: ${agent.description}`,
403
- `Models: ${candidates.length > 0 ? `${candidates.join(" → ")} → parent fallback` : "parent fallback"}`,
502
+ `Models: ${describeAgentModels(agent, rolesCfg)}`,
404
503
  `Thinking: ${agent.thinking || "off"}`,
405
504
  `Tools: ${agent.tools?.join(", ") || "all default"}`,
406
505
  `Source file: ${agent.filePath}`,
@@ -413,18 +512,8 @@ export default function (pi: ExtensionAPI) {
413
512
  return;
414
513
  }
415
514
 
416
- // List all agents
417
- const list = formatAgentList(discovery.agents, 20);
418
- const extra = list.remaining > 0 ? `\n ... +${list.remaining} more` : "";
419
- const dirs = discovery.projectAgentsDir ? `\n project: ${discovery.projectAgentsDir}` : "";
420
- const diagText = discovery.diagnostics.length > 0
421
- ? "\n\nWarnings:\n" + discovery.diagnostics.map(d => ` - [${d.severity}] ${d.filePath}: ${d.issue}`).join("\n")
422
- : "";
423
- pi.sendMessage({
424
- customType: "pi-subagent",
425
- 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.`,
426
- display: true,
427
- });
515
+ // Bare /subagent — open the roles editor (list moved to /subagent list).
516
+ await openRolesEditor();
428
517
  },
429
518
  });
430
519
 
@@ -475,7 +564,7 @@ export default function (pi: ExtensionAPI) {
475
564
  "Bundled agents: scout (fast recon), tester (verification), worker (implementation), general-purpose (fallback), planner (planning), reviewer (review).",
476
565
  "For background single tasks use background:true — you will be notified on completion; DO NOT poll or sleep.",
477
566
  "Use operation: \"status\" with taskId to inspect a running/completed background task; operation: \"cancel\" to abort one.",
478
- "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.",
479
568
  ],
480
569
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
481
570
  // Surface env-var timeout warnings collected at module load. The
@@ -584,6 +673,7 @@ export default function (pi: ExtensionAPI) {
584
673
  },
585
674
  ],
586
675
  details: makeDetails("single")([]),
676
+ isError: true,
587
677
  };
588
678
  }
589
679
 
@@ -637,6 +727,10 @@ export default function (pi: ExtensionAPI) {
637
727
  const modelRuntime = (modelRegistry as any).runtime;
638
728
  const authStorage = (modelRegistry as any).authStorage;
639
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
+
640
734
  // Parent session's registered tool names. Agents that omit `tools` inherit
641
735
  // the full set (minus the denylist); agents with an explicit `tools` line
642
736
  // are validated against built-ins ∪ this set.
@@ -652,9 +746,30 @@ export default function (pi: ExtensionAPI) {
652
746
  return safe.path;
653
747
  }
654
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
+
655
769
  // Helper: record a completed foreground task to the history registry.
656
770
  // ponytail: best-effort — history is non-fatal metadata for /subagent history.
657
771
  function recordForegroundHistory(
772
+ entryId: string,
658
773
  agentName: string,
659
774
  taskText: string,
660
775
  result: SubAgentResult,
@@ -672,7 +787,7 @@ export default function (pi: ExtensionAPI) {
672
787
  : "failed"
673
788
  : "completed";
674
789
  appendHistory(path.join(ctx.cwd, CONFIG_DIR_NAME), {
675
- id: `fg-${startedAt.toString(36)}-${Math.random().toString(36).slice(2, 6)}`,
790
+ id: entryId,
676
791
  agent: agentName,
677
792
  task: taskText,
678
793
  status,
@@ -743,7 +858,8 @@ export default function (pi: ExtensionAPI) {
743
858
  };
744
859
  }
745
860
 
746
- 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);
747
863
  if (!resolved.model) {
748
864
  const tried = resolved.attempted.join(", ") || "none";
749
865
  const parentInfo = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "none";
@@ -786,9 +902,10 @@ export default function (pi: ExtensionAPI) {
786
902
  };
787
903
  }
788
904
 
789
- // Retry loop: rate-limit model fallback
790
- const candidates = getModelCandidates(agent);
791
- 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;
792
909
 
793
910
  // Transport keep-alive only: resets parent idle timeout so a long child
794
911
  // run isn't killed. The visible progress now lives in the live widget;
@@ -799,32 +916,47 @@ export default function (pi: ExtensionAPI) {
799
916
  widget.requestRender();
800
917
  }) : undefined;
801
918
  try {
802
- const tryWithFallback = async (): Promise<SubAgentResult> => {
803
- const remaining = candidates.filter(m => !triedModels.includes(m));
804
- const isParentFallback = remaining.length === 0;
805
- const fallbackResolved = await resolveModel(remaining, ctx.model, ctx.modelRegistry);
806
- if (!fallbackResolved.model) {
807
- return {
808
- 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,
809
932
  task,
810
- exitCode: 1,
811
- status: "error" as const,
812
- stopReason: "error" as const,
813
- messages: [],
814
- stderr: [
815
- `All models rate-limited or unavailable.`,
816
- `Tried: ${triedModels.join(" → ") || "(none)"}.`,
817
- `Remaining candidates: ${remaining.join(", ") || "none"}.`,
818
- `Parent: ${ctx.model?.provider}/${ctx.model?.id}.`,
819
- ].join(" "),
820
- usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
821
- errorMessage: `All models exhausted (tried: ${triedModels.join(" → ") || "none"})`,
822
- };
823
- }
824
- const triedName = `${fallbackResolved.model!.provider}/${fallbackResolved.model!.id}`;
825
- if (triedModels.includes(triedName)) {
826
- // Already tried this model (e.g., all candidates unavailable
827
- // 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(" ");
828
960
  return {
829
961
  agent: agentName,
830
962
  task,
@@ -832,69 +964,14 @@ export default function (pi: ExtensionAPI) {
832
964
  status: "error" as const,
833
965
  stopReason: "error" as const,
834
966
  messages: [],
835
- stderr: [
836
- `All available models exhausted.`,
837
- `Tried: ${triedModels.join(" → ")}.`,
838
- ].join(" "),
967
+ stderr: exhaustedStderr,
839
968
  usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
840
- 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(" → ")})`,
841
972
  };
842
- }
843
- triedModels.push(triedName);
844
- // Also track the raw candidate name so candidates.filter() can
845
- // exclude it even when the agent uses unqualified names.
846
- // Avoid duplicating when candidate name is already qualified (matchedCandidate === triedName).
847
- if (fallbackResolved.matchedCandidate && fallbackResolved.matchedCandidate !== triedName) {
848
- triedModels.push(fallbackResolved.matchedCandidate);
849
- }
850
-
851
- const result = await runSubAgent({
852
- cwd: safeCwd,
853
- sandbox: agent.sandbox === "worktree" ? "worktree" : undefined,
854
- systemPrompt: params.instructions
855
- ? `${agent.systemPrompt}\n\n## Task Contract\n${params.instructions.slice(0, MAX_INSTRUCTIONS_LENGTH)}`
856
- : agent.systemPrompt,
857
- task,
858
- tools,
859
- model: fallbackResolved.model,
860
- modelRuntime,
861
- authStorage,
862
- modelRegistry,
863
- signal: parentSignal,
864
- timeoutMs: effectiveTimeoutMs,
865
- agentName,
866
- thinkingLevel: agent.thinking,
867
- onMessage: onProgress,
868
- onProgress: onActivity,
869
- loadExtensions,
870
- projectTrusted,
871
- });
872
-
873
- if (result.errorMessage && isRateLimitError(result.errorMessage)) {
874
- // If the model that just rate-limited was the parent fallback
875
- // (no remaining candidates), stop — no further options.
876
- if (isParentFallback) {
877
- return {
878
- agent: agentName,
879
- task,
880
- exitCode: 1,
881
- status: "error" as const,
882
- stopReason: "error" as const,
883
- messages: [],
884
- stderr: [
885
- `All available models exhausted.`,
886
- `Tried: ${triedModels.join(" → ")}.`,
887
- ].join(" "),
888
- usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
889
- errorMessage: `All available models exhausted (tried: ${triedModels.join(" → ")})`,
890
- };
891
- }
892
- return tryWithFallback();
893
- }
894
- return result;
895
- };
896
-
897
- return tryWithFallback();
973
+ },
974
+ });
898
975
  } finally {
899
976
  stopHeartbeat?.();
900
977
  }
@@ -917,6 +994,8 @@ export default function (pi: ExtensionAPI) {
917
994
  color: agentToThemeColor(step.agent),
918
995
  });
919
996
  if (ctx.mode === "tui") widget.ensureWidget(ctx);
997
+ const historyId = makeForegroundHistoryId(thread.createdAt);
998
+ recordForegroundStart(historyId, step.agent, taskWithContext, thread.createdAt);
920
999
  const result = await runOne(
921
1000
  step.agent, taskWithContext, step.cwd,
922
1001
  signal, step.timeout ?? params.timeout,
@@ -929,7 +1008,7 @@ export default function (pi: ExtensionAPI) {
929
1008
  status: isFailedResult(result) ? (result.stopReason === "aborted" ? "aborted" : "failed") : "completed",
930
1009
  result,
931
1010
  });
932
- recordForegroundHistory(step.agent, taskWithContext, result, thread.createdAt);
1011
+ recordForegroundHistory(historyId, step.agent, taskWithContext, result, thread.createdAt);
933
1012
  results.push(result);
934
1013
 
935
1014
  const isError = isFailedResult(result);
@@ -1075,6 +1154,8 @@ export default function (pi: ExtensionAPI) {
1075
1154
  emitParallelUpdate();
1076
1155
  return skippedResult;
1077
1156
  }
1157
+ const historyId = makeForegroundHistoryId(parallelThreads[index].createdAt);
1158
+ recordForegroundStart(historyId, t.agent, t.task, parallelThreads[index].createdAt);
1078
1159
  const result = await runOne(
1079
1160
  t.agent, t.task, t.cwd,
1080
1161
  parallelController.signal, t.timeout ?? params.timeout,
@@ -1088,7 +1169,7 @@ export default function (pi: ExtensionAPI) {
1088
1169
  status: isFailedResult(result) ? (result.stopReason === "aborted" ? "aborted" : "failed") : "completed",
1089
1170
  result,
1090
1171
  });
1091
- recordForegroundHistory(t.agent, t.task, result, parallelThreads[index].createdAt);
1172
+ recordForegroundHistory(historyId, t.agent, t.task, result, parallelThreads[index].createdAt);
1092
1173
  // Early-abort: if this task failed and abortOnFailure is set
1093
1174
  if (abortOnFailure && isFailedResult(result) && !abortCause) {
1094
1175
  abortCause = result.stopReason === "timeout" ? "timeout" : "sibling";
@@ -1152,6 +1233,8 @@ export default function (pi: ExtensionAPI) {
1152
1233
  color: agentToThemeColor(params.agent),
1153
1234
  });
1154
1235
  if (ctx.mode === "tui") widget.ensureWidget(ctx);
1236
+ const historyId = makeForegroundHistoryId(thread.createdAt);
1237
+ recordForegroundStart(historyId, params.agent, params.task, thread.createdAt);
1155
1238
  const result = await runOne(
1156
1239
  params.agent, params.task, params.cwd,
1157
1240
  signal, params.timeout,
@@ -1164,7 +1247,7 @@ export default function (pi: ExtensionAPI) {
1164
1247
  status: isFailedResult(result) ? (result.stopReason === "aborted" ? "aborted" : "failed") : "completed",
1165
1248
  result,
1166
1249
  });
1167
- recordForegroundHistory(params.agent, params.task, result, thread.createdAt);
1250
+ recordForegroundHistory(historyId, params.agent, params.task, result, thread.createdAt);
1168
1251
  const isError = isFailedResult(result);
1169
1252
 
1170
1253
  if (onUpdate) {
@@ -13,12 +13,15 @@
13
13
 
14
14
  import type { Model } from "@earendil-works/pi-ai";
15
15
  import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
16
+ import { splitThinkingSuffix, type SubagentThinkingLevel } from "./roles.ts";
16
17
 
17
18
  export interface ResolvedModel {
18
19
  model: Model<any> | null;
19
20
  attempted: string[];
20
21
  /** The raw candidate name that matched, if a candidate resolved. Undefined for parent fallback. */
21
22
  matchedCandidate?: string;
23
+ /** Thinking level carried by the matched candidate's `:level` suffix, if any. */
24
+ matchedThinking?: SubagentThinkingLevel;
22
25
  }
23
26
 
24
27
  /** Known provider prefixes for unqualified model names. */
@@ -46,19 +49,20 @@ export async function resolveModel(
46
49
  };
47
50
 
48
51
  for (const modelName of [...new Set(modelNames.map((name) => name.trim()).filter(Boolean))]) {
49
- const idx = modelName.indexOf("/");
52
+ const { name: bareName, thinking } = splitThinkingSuffix(modelName);
53
+ const idx = bareName.indexOf("/");
50
54
  if (idx > 0) {
51
- const found = tryAvailable(modelName);
52
- if (found) return { model: found, attempted, matchedCandidate: modelName };
55
+ const found = tryAvailable(bareName);
56
+ if (found) return { model: found, attempted, matchedCandidate: modelName, matchedThinking: thinking };
53
57
  continue;
54
58
  }
55
59
  for (const [provider, pattern] of KNOWN_PROVIDERS) {
56
- if (!pattern.test(modelName)) continue;
57
- const found = tryAvailable(`${provider}/${modelName}`);
58
- if (found) return { model: found, attempted, matchedCandidate: modelName };
60
+ if (!pattern.test(bareName)) continue;
61
+ const found = tryAvailable(`${provider}/${bareName}`);
62
+ if (found) return { model: found, attempted, matchedCandidate: modelName, matchedThinking: thinking };
59
63
  }
60
- const found = tryAvailable(`anthropic/${modelName}`);
61
- if (found) return { model: found, attempted, matchedCandidate: modelName };
64
+ const found = tryAvailable(`anthropic/${bareName}`);
65
+ if (found) return { model: found, attempted, matchedCandidate: modelName, matchedThinking: thinking };
62
66
  }
63
67
 
64
68
  if (parentModel) {
@@ -67,3 +71,85 @@ export async function resolveModel(
67
71
  }
68
72
  return { model: null, attempted };
69
73
  }
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Rate-limit model fallback (shared by tool path and service path)
77
+ // ---------------------------------------------------------------------------
78
+
79
+ export type ModelFallbackExhaustReason = "no-model" | "already-tried" | "parent-rate-limited";
80
+
81
+ export interface ModelFallbackOptions<T> {
82
+ candidates: readonly string[];
83
+ parentModel: Model<any> | undefined;
84
+ modelRegistry?: ModelRegistry;
85
+ /** thinking suffix per stripped candidate name (from resolveAgentModelChain). */
86
+ thinkingByCandidate: ReadonlyMap<string, SubagentThinkingLevel>;
87
+ /** Fallback thinking when no candidate carries a `:level` suffix. */
88
+ defaultThinking?: SubagentThinkingLevel;
89
+ runAttempt: (model: Model<any>, thinkingLevel: SubagentThinkingLevel | undefined) => Promise<T>;
90
+ isRateLimited: (result: T) => boolean;
91
+ /** Build the terminal value when all models are exhausted (path-specific error mapping). */
92
+ onExhausted: (reason: ModelFallbackExhaustReason, triedModels: string[], remaining: string[]) => T;
93
+ }
94
+
95
+ /**
96
+ * Retry loop shared by the tool handler (index.ts) and the event-driven
97
+ * service path (service.ts): try candidates in priority order, falling back to
98
+ * the parent model, advancing on rate-limit errors. Single source of truth for
99
+ * `triedModels` bookkeeping and per-candidate `:thinking` resolution.
100
+ */
101
+ export async function runWithModelFallback<T>(options: ModelFallbackOptions<T>): Promise<T> {
102
+ const {
103
+ candidates,
104
+ parentModel,
105
+ modelRegistry,
106
+ thinkingByCandidate,
107
+ defaultThinking,
108
+ runAttempt,
109
+ isRateLimited,
110
+ onExhausted,
111
+ } = options;
112
+ const triedModels: string[] = [];
113
+
114
+ const attempt = async (): Promise<T> => {
115
+ const remaining = candidates.filter((m) => !triedModels.includes(m));
116
+ const isParentFallback = remaining.length === 0;
117
+ const fallbackResolved = await resolveModel(remaining, parentModel, modelRegistry);
118
+ if (!fallbackResolved.model) {
119
+ return onExhausted("no-model", triedModels, remaining);
120
+ }
121
+ const triedName = `${fallbackResolved.model!.provider}/${fallbackResolved.model!.id}`;
122
+ if (triedModels.includes(triedName)) {
123
+ // Already tried this model (e.g., all candidates unavailable
124
+ // and parent fallback) — no further options.
125
+ return onExhausted("already-tried", triedModels, remaining);
126
+ }
127
+ triedModels.push(triedName);
128
+ // Also track the raw candidate name so candidates.filter() can
129
+ // exclude it even when the agent uses unqualified names.
130
+ // Avoid duplicating when candidate name is already qualified (matchedCandidate === triedName).
131
+ if (fallbackResolved.matchedCandidate && fallbackResolved.matchedCandidate !== triedName) {
132
+ triedModels.push(fallbackResolved.matchedCandidate);
133
+ }
134
+
135
+ // The `:thinking` suffix lives on the candidate as written; resolve it from
136
+ // the stripped-name map (matchedCandidate is the raw candidate string).
137
+ const thinkingLevel =
138
+ thinkingByCandidate.get(fallbackResolved.matchedCandidate ?? triedName) ??
139
+ fallbackResolved.matchedThinking ??
140
+ defaultThinking;
141
+
142
+ const result = await runAttempt(fallbackResolved.model, thinkingLevel);
143
+ if (result && isRateLimited(result)) {
144
+ // If the model that just rate-limited was the parent fallback
145
+ // (no remaining candidates), stop — no further options.
146
+ if (isParentFallback) {
147
+ return onExhausted("parent-rate-limited", triedModels, remaining);
148
+ }
149
+ return attempt();
150
+ }
151
+ return result;
152
+ };
153
+
154
+ return attempt();
155
+ }