@bacnh85/pi-subagent 0.15.3 → 0.16.1

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,21 @@ 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)",
332
+ getArgumentCompletions: (prefix) => {
333
+ const ctx = currentCtx;
334
+ const keywords = ["list", "all", "agents", "roles", "reload", "refresh", "history"];
335
+ const vocab = [...keywords];
336
+ if (ctx) {
337
+ const discovery = discoverAgents(ctx.cwd, "both", bundledAgentsDir);
338
+ vocab.push(...discovery.agents.map((a) => a.name));
339
+ try { vocab.push(...Object.keys(readSubagentRoles(ctx).roles).map((r) => `@${r}`)); } catch { /* roles optional */ }
340
+ }
341
+ const q = prefix.trim().toLowerCase();
342
+ const items = vocab.filter((v) => v.toLowerCase().startsWith(q))
343
+ .map((v) => ({ value: v, label: v, description: keywords.includes(v) ? "subagent command" : "agent / role" }));
344
+ return items.length > 0 ? items : null;
345
+ },
321
346
  handler: async (args, ctx) => {
322
347
  const cmd = args.trim().toLowerCase();
323
348
  const discovery = discoverAgents(ctx.cwd, "both", bundledAgentsDir);
@@ -351,6 +376,68 @@ export default function (pi: ExtensionAPI) {
351
376
  return;
352
377
  }
353
378
 
379
+ const openRolesEditor = async (): Promise<void> => {
380
+ // Role mapping editor: panel in TUI, plain text otherwise.
381
+ const [{ openConfigPanel }, { buildRows, buildRolesPanelCfg, cfgToPatch, preserveUnknownAgentModels, writeSubagentSection }] = await Promise.all([
382
+ import("@bacnh85/pi-config-panel"),
383
+ import("./roles-panel.ts"),
384
+ ]);
385
+ if (ctx.mode !== "tui" || !ctx.hasUI) {
386
+ const rolesCfg = readSubagentRoles(ctx);
387
+ const lines = discovery.agents.map((a) => ` ${a.name.padEnd(16)} ${describeAgentModels(a, rolesCfg)}`);
388
+ pi.sendMessage({
389
+ customType: "pi-subagent",
390
+ content: [
391
+ "Model roles (edit ~/.pi/agent/settings.json → subagent.roles, or run /subagent in a TUI):",
392
+ ...Object.entries(rolesCfg.roles).map(([name, chain]) =>
393
+ ` @${name} = ${Array.isArray(chain) ? chain.join(", ") : chain}`),
394
+ "",
395
+ "Effective models per agent:",
396
+ ...lines,
397
+ ].join("\n"),
398
+ display: true,
399
+ });
400
+ return;
401
+ }
402
+ const current = readSubagentRolesGlobal();
403
+ const working = buildRolesPanelCfg(discovery.agents, current);
404
+ const panelOptions = {
405
+ models: () => {
406
+ try { return ctx.modelRegistry.getAvailable().map((m) => `${m.provider}/${m.id}`); }
407
+ catch { return []; }
408
+ },
409
+ roles: () => [...Object.keys(DEFAULT_ROLES), ...Object.keys(readSubagentRolesGlobal().roles)],
410
+ };
411
+ await openConfigPanel({
412
+ ctx,
413
+ cfg: working,
414
+ build: (cfg) => buildRows(cfg, discovery.agents, panelOptions),
415
+ title: "Subagent model roles",
416
+ onSave: (saved, editedKeys) => {
417
+ if (!(saved && editedKeys && editedKeys.size > 0)) return;
418
+ const patch = cfgToPatch(working);
419
+ patch.agentModels = preserveUnknownAgentModels(
420
+ patch.agentModels,
421
+ discovery.agents.map((a) => a.name),
422
+ current.agentModels,
423
+ );
424
+ try {
425
+ writeSubagentSection(patch);
426
+ invalidateAgentCache();
427
+ ctx.ui.notify("Model roles saved to settings.json", "info");
428
+ } catch (err) {
429
+ ctx.ui.notify(`Not saved — ${err instanceof Error ? err.message : String(err)}`, "error");
430
+ }
431
+ },
432
+ });
433
+ return;
434
+ };
435
+
436
+ if (cmd === "roles") {
437
+ await openRolesEditor();
438
+ return;
439
+ }
440
+
354
441
  if (cmd === "reload" || cmd === "refresh") {
355
442
  invalidateAgentCache();
356
443
  const fresh = discoverAgents(ctx.cwd, "both", bundledAgentsDir);
@@ -379,28 +466,61 @@ export default function (pi: ExtensionAPI) {
379
466
  : "";
380
467
  pi.sendMessage({
381
468
  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.`,
469
+ 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
470
  display: true,
384
471
  });
385
472
  return;
386
473
  }
387
474
 
388
475
  if (cmd) {
389
- // Show details for a specific agent
476
+ // Show details for a specific agent; fall back to a role detail view
477
+ // when the name matches a model role (e.g. "/subagent coder").
390
478
  const agent = discovery.agents.find(
391
479
  (a) => a.name.toLowerCase() === cmd,
392
480
  );
393
481
  if (!agent) {
394
- ctx.ui.notify(`Unknown agent: "${args.trim()}". Use /subagent to list all.`, "error");
482
+ const rolesCfg = readSubagentRoles(ctx);
483
+ const arg = args.trim().toLowerCase();
484
+ const roleName = arg.startsWith("@") ? arg.slice(1) : arg;
485
+ // Resolve the role key case-insensitively (role names are free-form).
486
+ const roleKey = Object.keys(rolesCfg.roles).find((k) => k.toLowerCase() === roleName);
487
+ const roleChain = roleKey !== undefined ? rolesCfg.roles[roleKey] : undefined;
488
+ const role = roleKey !== undefined && roleChain !== undefined
489
+ ? { key: roleKey, chain: roleChain }
490
+ : undefined;
491
+ if (role) {
492
+ const { key, chain } = role;
493
+ const chainText = Array.isArray(chain) ? chain.join(" → ") : String(chain);
494
+ const users = discovery.agents.filter((a) => getModelCandidates(a).some((c) => c.toLowerCase().split(":")[0] === `@${key.toLowerCase()}`));
495
+ const overrides = Object.entries(rolesCfg.agentModels).filter(([, v]) => v.toLowerCase().split(":")[0] === `@${key.toLowerCase()}`);
496
+ const defaultText = DEFAULT_ROLES[key] !== undefined
497
+ ? (Array.isArray(DEFAULT_ROLES[key]) ? (DEFAULT_ROLES[key] as string[]).join(" → ") : String(DEFAULT_ROLES[key]))
498
+ : "(custom role)";
499
+ pi.sendMessage({
500
+ customType: "pi-subagent",
501
+ content: [
502
+ `Role: @${key}`,
503
+ `Chain: ${chainText} → parent fallback`,
504
+ `Default: ${defaultText}`,
505
+ users.length > 0 ? `Agents using @${key}: ${users.map((a) => a.name).join(", ")}` : `No agent references @${key} yet`,
506
+ overrides.length > 0 ? `Overrides via @${key}: ${overrides.map(([n]) => n).join(", ")}` : "",
507
+ "",
508
+ `Edit with /subagent (roles editor) or ~/.pi/agent/settings.json → subagent.roles.`,
509
+ ].filter(Boolean).join("\n"),
510
+ display: true,
511
+ });
512
+ return;
513
+ }
514
+ ctx.ui.notify(`Unknown agent: "${args.trim()}". Use /subagent list to list all.`, "error");
395
515
  return;
396
516
  }
397
- const candidates = getModelCandidates(agent);
517
+ const rolesCfg = readSubagentRoles(ctx);
398
518
  pi.sendMessage({
399
519
  customType: "pi-subagent",
400
520
  content: [
401
521
  `Agent: ${agent.name} (${agent.source})`,
402
522
  `Description: ${agent.description}`,
403
- `Models: ${candidates.length > 0 ? `${candidates.join(" → ")} → parent fallback` : "parent fallback"}`,
523
+ `Models: ${describeAgentModels(agent, rolesCfg)}`,
404
524
  `Thinking: ${agent.thinking || "off"}`,
405
525
  `Tools: ${agent.tools?.join(", ") || "all default"}`,
406
526
  `Source file: ${agent.filePath}`,
@@ -413,18 +533,8 @@ export default function (pi: ExtensionAPI) {
413
533
  return;
414
534
  }
415
535
 
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
- });
536
+ // Bare /subagent — open the roles editor (list moved to /subagent list).
537
+ await openRolesEditor();
428
538
  },
429
539
  });
430
540
 
@@ -475,7 +585,7 @@ export default function (pi: ExtensionAPI) {
475
585
  "Bundled agents: scout (fast recon), tester (verification), worker (implementation), general-purpose (fallback), planner (planning), reviewer (review).",
476
586
  "For background single tasks use background:true — you will be notified on completion; DO NOT poll or sleep.",
477
587
  "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.",
588
+ "Use /subagent list to list all available agents, /subagent <name> for agent details, /subagent @role for role detail.",
479
589
  ],
480
590
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
481
591
  // Surface env-var timeout warnings collected at module load. The
@@ -584,6 +694,7 @@ export default function (pi: ExtensionAPI) {
584
694
  },
585
695
  ],
586
696
  details: makeDetails("single")([]),
697
+ isError: true,
587
698
  };
588
699
  }
589
700
 
@@ -637,6 +748,10 @@ export default function (pi: ExtensionAPI) {
637
748
  const modelRuntime = (modelRegistry as any).runtime;
638
749
  const authStorage = (modelRegistry as any).authStorage;
639
750
 
751
+ // Roles + per-agent overrides are read once per execute() call so every
752
+ // child in this run sees a consistent mapping.
753
+ const rolesCfg: RolesConfig = readSubagentRoles(ctx);
754
+
640
755
  // Parent session's registered tool names. Agents that omit `tools` inherit
641
756
  // the full set (minus the denylist); agents with an explicit `tools` line
642
757
  // are validated against built-ins ∪ this set.
@@ -652,9 +767,30 @@ export default function (pi: ExtensionAPI) {
652
767
  return safe.path;
653
768
  }
654
769
 
770
+ // Helper: stable history id for a foreground run (shared between the
771
+ // running entry written at start and the completion entry).
772
+ function makeForegroundHistoryId(startedAt: number): string {
773
+ return `fg-${startedAt.toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
774
+ }
775
+
776
+ // Helper: record a running foreground task so a crash mid-run shows as
777
+ // "interrupted" after restart (completion upserts by id and replaces it).
778
+ function recordForegroundStart(entryId: string, agentName: string, taskText: string, startedAt: number): void {
779
+ try {
780
+ appendHistory(path.join(ctx.cwd, CONFIG_DIR_NAME), {
781
+ id: entryId,
782
+ agent: agentName,
783
+ task: taskText,
784
+ status: "running",
785
+ startedAt,
786
+ });
787
+ } catch { /* history file not writable — non-fatal */ }
788
+ }
789
+
655
790
  // Helper: record a completed foreground task to the history registry.
656
791
  // ponytail: best-effort — history is non-fatal metadata for /subagent history.
657
792
  function recordForegroundHistory(
793
+ entryId: string,
658
794
  agentName: string,
659
795
  taskText: string,
660
796
  result: SubAgentResult,
@@ -672,7 +808,7 @@ export default function (pi: ExtensionAPI) {
672
808
  : "failed"
673
809
  : "completed";
674
810
  appendHistory(path.join(ctx.cwd, CONFIG_DIR_NAME), {
675
- id: `fg-${startedAt.toString(36)}-${Math.random().toString(36).slice(2, 6)}`,
811
+ id: entryId,
676
812
  agent: agentName,
677
813
  task: taskText,
678
814
  status,
@@ -743,7 +879,8 @@ export default function (pi: ExtensionAPI) {
743
879
  };
744
880
  }
745
881
 
746
- const resolved = await resolveModel(getModelCandidates(agent), ctx.model, ctx.modelRegistry);
882
+ const agentChain = resolveAgentModelChain(agent, rolesCfg);
883
+ const resolved = await resolveModel(agentChain.candidates, ctx.model, ctx.modelRegistry);
747
884
  if (!resolved.model) {
748
885
  const tried = resolved.attempted.join(", ") || "none";
749
886
  const parentInfo = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "none";
@@ -786,9 +923,10 @@ export default function (pi: ExtensionAPI) {
786
923
  };
787
924
  }
788
925
 
789
- // Retry loop: rate-limit model fallback
790
- const candidates = getModelCandidates(agent);
791
- const triedModels: string[] = [];
926
+ // Retry loop: rate-limit model fallback (candidates already role-expanded).
927
+ // Shared with the service path — single source of truth for triedModels
928
+ // bookkeeping and per-candidate `:thinking` resolution.
929
+ const candidates = agentChain.candidates;
792
930
 
793
931
  // Transport keep-alive only: resets parent idle timeout so a long child
794
932
  // run isn't killed. The visible progress now lives in the live widget;
@@ -799,32 +937,47 @@ export default function (pi: ExtensionAPI) {
799
937
  widget.requestRender();
800
938
  }) : undefined;
801
939
  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,
940
+ return await runWithModelFallback<SubAgentResult>({
941
+ candidates,
942
+ parentModel: ctx.model,
943
+ modelRegistry: ctx.modelRegistry,
944
+ thinkingByCandidate: agentChain.thinkingByCandidate,
945
+ defaultThinking: agent.thinking,
946
+ runAttempt: (model, thinkingLevel) =>
947
+ runSubAgent({
948
+ cwd: safeCwd,
949
+ sandbox: agent.sandbox === "worktree" ? "worktree" : undefined,
950
+ systemPrompt: params.instructions
951
+ ? `${agent.systemPrompt}\n\n## Task Contract\n${params.instructions.slice(0, MAX_INSTRUCTIONS_LENGTH)}`
952
+ : agent.systemPrompt,
809
953
  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.
954
+ tools,
955
+ model,
956
+ modelRuntime,
957
+ authStorage,
958
+ modelRegistry,
959
+ signal: parentSignal,
960
+ timeoutMs: effectiveTimeoutMs,
961
+ agentName,
962
+ thinkingLevel,
963
+ onMessage: onProgress,
964
+ onProgress: onActivity,
965
+ loadExtensions,
966
+ projectTrusted,
967
+ }),
968
+ isRateLimited: (result) => Boolean(result.errorMessage && isRateLimitError(result.errorMessage)),
969
+ onExhausted: (reason, triedModels, remaining) => {
970
+ const exhaustedStderr = reason === "no-model"
971
+ ? [
972
+ `All models rate-limited or unavailable.`,
973
+ `Tried: ${triedModels.join(" → ") || "(none)"}.`,
974
+ `Remaining candidates: ${remaining.join(", ") || "none"}.`,
975
+ `Parent: ${ctx.model?.provider}/${ctx.model?.id}.`,
976
+ ].join(" ")
977
+ : [
978
+ `All available models exhausted.`,
979
+ `Tried: ${triedModels.join(" → ")}.`,
980
+ ].join(" ");
828
981
  return {
829
982
  agent: agentName,
830
983
  task,
@@ -832,69 +985,14 @@ export default function (pi: ExtensionAPI) {
832
985
  status: "error" as const,
833
986
  stopReason: "error" as const,
834
987
  messages: [],
835
- stderr: [
836
- `All available models exhausted.`,
837
- `Tried: ${triedModels.join(" → ")}.`,
838
- ].join(" "),
988
+ stderr: exhaustedStderr,
839
989
  usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
840
- errorMessage: `All available models exhausted (tried: ${triedModels.join("")})`,
990
+ errorMessage: reason === "no-model"
991
+ ? `All models exhausted (tried: ${triedModels.join(" → ") || "none"})`
992
+ : `All available models exhausted (tried: ${triedModels.join(" → ")})`,
841
993
  };
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();
994
+ },
995
+ });
898
996
  } finally {
899
997
  stopHeartbeat?.();
900
998
  }
@@ -917,6 +1015,8 @@ export default function (pi: ExtensionAPI) {
917
1015
  color: agentToThemeColor(step.agent),
918
1016
  });
919
1017
  if (ctx.mode === "tui") widget.ensureWidget(ctx);
1018
+ const historyId = makeForegroundHistoryId(thread.createdAt);
1019
+ recordForegroundStart(historyId, step.agent, taskWithContext, thread.createdAt);
920
1020
  const result = await runOne(
921
1021
  step.agent, taskWithContext, step.cwd,
922
1022
  signal, step.timeout ?? params.timeout,
@@ -929,7 +1029,7 @@ export default function (pi: ExtensionAPI) {
929
1029
  status: isFailedResult(result) ? (result.stopReason === "aborted" ? "aborted" : "failed") : "completed",
930
1030
  result,
931
1031
  });
932
- recordForegroundHistory(step.agent, taskWithContext, result, thread.createdAt);
1032
+ recordForegroundHistory(historyId, step.agent, taskWithContext, result, thread.createdAt);
933
1033
  results.push(result);
934
1034
 
935
1035
  const isError = isFailedResult(result);
@@ -1075,6 +1175,8 @@ export default function (pi: ExtensionAPI) {
1075
1175
  emitParallelUpdate();
1076
1176
  return skippedResult;
1077
1177
  }
1178
+ const historyId = makeForegroundHistoryId(parallelThreads[index].createdAt);
1179
+ recordForegroundStart(historyId, t.agent, t.task, parallelThreads[index].createdAt);
1078
1180
  const result = await runOne(
1079
1181
  t.agent, t.task, t.cwd,
1080
1182
  parallelController.signal, t.timeout ?? params.timeout,
@@ -1088,7 +1190,7 @@ export default function (pi: ExtensionAPI) {
1088
1190
  status: isFailedResult(result) ? (result.stopReason === "aborted" ? "aborted" : "failed") : "completed",
1089
1191
  result,
1090
1192
  });
1091
- recordForegroundHistory(t.agent, t.task, result, parallelThreads[index].createdAt);
1193
+ recordForegroundHistory(historyId, t.agent, t.task, result, parallelThreads[index].createdAt);
1092
1194
  // Early-abort: if this task failed and abortOnFailure is set
1093
1195
  if (abortOnFailure && isFailedResult(result) && !abortCause) {
1094
1196
  abortCause = result.stopReason === "timeout" ? "timeout" : "sibling";
@@ -1152,6 +1254,8 @@ export default function (pi: ExtensionAPI) {
1152
1254
  color: agentToThemeColor(params.agent),
1153
1255
  });
1154
1256
  if (ctx.mode === "tui") widget.ensureWidget(ctx);
1257
+ const historyId = makeForegroundHistoryId(thread.createdAt);
1258
+ recordForegroundStart(historyId, params.agent, params.task, thread.createdAt);
1155
1259
  const result = await runOne(
1156
1260
  params.agent, params.task, params.cwd,
1157
1261
  signal, params.timeout,
@@ -1164,7 +1268,7 @@ export default function (pi: ExtensionAPI) {
1164
1268
  status: isFailedResult(result) ? (result.stopReason === "aborted" ? "aborted" : "failed") : "completed",
1165
1269
  result,
1166
1270
  });
1167
- recordForegroundHistory(params.agent, params.task, result, thread.createdAt);
1271
+ recordForegroundHistory(historyId, params.agent, params.task, result, thread.createdAt);
1168
1272
  const isError = isFailedResult(result);
1169
1273
 
1170
1274
  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
+ }