@nredd/pi-subagents 0.22.0-nredd.6 → 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 +160 -43
  2. package/package.json +8 -8
  3. package/src/agent-file-toggle.ts +6 -4
  4. package/src/agent-manager.ts +418 -118
  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 +231 -107
  13. package/src/invocation-config.ts +8 -35
  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 +31 -26
  21. package/src/output-file.ts +23 -1
  22. package/src/package-resources.ts +25 -10
  23. package/src/process-budget.ts +346 -0
  24. package/src/quota-admission.ts +21 -9
  25. package/src/resource-loader.ts +51 -0
  26. package/src/schedule.ts +41 -19
  27. package/src/settings.ts +111 -8
  28. package/src/skill-loader.ts +8 -7
  29. package/src/stale-ctx.ts +16 -0
  30. package/src/types.ts +33 -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
package/src/index.ts CHANGED
@@ -12,13 +12,13 @@
12
12
 
13
13
  import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
14
14
  import { isAbsolute, join } from "node:path";
15
- import { defineTool, type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext, type ExtensionFactory, getAgentDir, getSettingsListTheme } from "@earendil-works/pi-coding-agent";
15
+ import { defineTool, type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext, type ExtensionFactory, getSettingsListTheme } from "@earendil-works/pi-coding-agent";
16
16
  import { Container, isKeyRelease, Key, matchesKey, type ScrollViewScrollbar, type SettingItem, SettingsList, Spacer, Text } from "@earendil-works/pi-tui";
17
17
  import { nanoid } from "nanoid";
18
18
  import { Type } from "typebox";
19
19
  import { abortable } from "./abortable.js";
20
20
  import { hasAgentBadge, renderAgentName } from "./agent-color.js";
21
- import { buildNewAgentFile, disableInContent, enableInContent, isEmptyStub, locateAgentFile, personalAgentsDir, projectAgentsDir, serializeAgentFile } from "./agent-file-toggle.js";
21
+ import { buildNewAgentFile, disableInContent, enableInContent, isEmptyStub, locateAgentFile as locateAgentFileAtHome, personalAgentsDir as personalAgentsDirAtHome, projectAgentsDir, serializeAgentFile } from "./agent-file-toggle.js";
22
22
  import { AgentManager, describeEvicted, isTopLevelAgent, markConsumed } from "./agent-manager.js";
23
23
  import { getAgentConversation, getDefaultMaxTurns, getGraceTurns, getRememberAgents, normalizeMaxTurns, resolveEffectiveMaxTurns, SUBAGENT_TOOL_NAMES, setDefaultMaxTurns, setGraceTurns, setRememberAgents, steerAgent } from "./agent-runner.js";
24
24
  import { rowDescription, sourceIndicator, sourceLegend } from "./agent-source-badge.js";
@@ -39,15 +39,19 @@ import {
39
39
  } from "./detached-results.js";
40
40
  import { readEnabledModels, resolveEnabledModels } from "./enabled-models.js";
41
41
  import { GroupJoinManager } from "./group-join.js";
42
+ import { resolveAgentDir } from "./home-paths.js";
42
43
  import { cwdParam, isolationParam, resolveAgentInvocationConfig, resolveCwdParam, resolveJoinMode } from "./invocation-config.js";
43
44
  import { describeMention, handleBase, isReservedHandle, parseMention, resolveHandleToType, stripAgentPrefix } from "./mention.js";
44
45
  import { runMentionClone } from "./mention-clone.js";
45
46
  import { applyModelCallEvent, type ModelCallEvent } from "./model-call.js";
46
- import { describeModel, describeVirtualModel, isVirtualModel, type ModelRegistry, resolveModel, setAgentModels, setModelAliases, setModelScopeProvider } from "./model-resolver.js";
47
- import { checkModelScope, isScopeModelsEnabled, setScopeModelsEnabled } from "./model-scope.js";
47
+ import { type ModelFallback, type ModelRegistry, setAgentModels, setModelAliases, setModelScopeProvider } from "./model-resolver.js";
48
+ import { isScopeModelsEnabled, setScopeModelsEnabled } from "./model-scope.js";
49
+ import { admitModelSelection, describeSelection, modelRequest, modelSelectionNote, selectModel } from "./model-selection.js";
50
+ import { tierDisclosure } from "./model-tiers.js";
48
51
  import { getMaxSubagentDepth, setMaxSubagentDepth } from "./nested-tools.js";
49
- import { createOutputFilePath, ensureOutputFile, getOutputTranscriptDefault, sessionTaskDir, setOutputTranscriptDefault, streamToOutputFile, writeInitialEntry } from "./output-file.js";
52
+ import { appendModelFallbackEntry, appendModelTierEntry, createOutputFilePath, ensureOutputFile, getOutputTranscriptDefault, sessionTaskDir, setOutputTranscriptDefault, streamToOutputFile, writeInitialEntry } from "./output-file.js";
50
53
  import { getPackageAgentsGate, getPackageWorkflowsGate, invalidatePackageCache, isProjectTrusted, packageNameForPath, setPackageAgentsGate, setPackageWorkflowsGate, setProjectTrusted } from "./package-resources.js";
54
+ import { getBudgetPolicy, setBudgetPolicy } from "./process-budget.js";
51
55
  import { checkAdmission, describeBlock, mayPark, modelRef, QUOTA_RESTART_GRACE_MS } from "./quota-admission.js";
52
56
  import { SubagentScheduler } from "./schedule.js";
53
57
  import { resolveStorePath, ScheduleStore } from "./schedule-store.js";
@@ -74,6 +78,7 @@ import {
74
78
  } from "./ui/agent-widget.js";
75
79
  import { ConversationViewer, getConversationOverlayOptions, type ViewerBackgroundAction } from "./ui/conversation-viewer.js";
76
80
  import { FleetList, type FleetUICtx, type FleetWorkflow } from "./ui/fleet-list.js";
81
+ import { FLEET_STATE_ENTRY, FLEET_STATE_VERSION } from "./ui/fleet-state.js";
77
82
  import { renderNotification } from "./ui/notification-view.js";
78
83
  import { showSchedulesMenu } from "./ui/schedule-menu.js";
79
84
  import { selectItem } from "./ui/select-item.js";
@@ -203,6 +208,7 @@ function formatTaskNotification(record: AgentRecord, resultMaxLen: number, showC
203
208
  record.outputFile ? `<output-file>${escapeXml(record.outputFile)}</output-file>` : null,
204
209
  `<status>${escapeXml(status)}</status>`,
205
210
  `<summary>Agent "${escapeXml(record.description)}" ${record.status}${getStatusNote(record.status)}</summary>`,
211
+ modelSelectionNote(record) ? `<model-note>${escapeXml(modelSelectionNote(record))}</model-note>` : null,
206
212
  `<result>${escapeXml(resultPreview)}</result>`,
207
213
  `<usage><total_tokens>${totalTokens}</total_tokens><tool_uses>${record.toolUses}</tool_uses>${ctxXml}${compactXml}${costXml}<duration_ms>${durationMs}</duration_ms></usage>`,
208
214
  `</task-notification>`,
@@ -211,7 +217,7 @@ function formatTaskNotification(record: AgentRecord, resultMaxLen: number, showC
211
217
 
212
218
  /** Build AgentDetails from a base + record-specific fields. */
213
219
  function buildDetails(
214
- base: Pick<AgentDetails, "displayName" | "description" | "subagentType" | "modelName" | "tags">,
220
+ base: Pick<AgentDetails, "displayName" | "description" | "subagentType" | "modelName" | "modelNote" | "modelFallback" | "modelTier" | "tags">,
215
221
  record: { toolUses: number; startedAt: number; completedAt?: number; status: string; error?: string; id?: string; session?: any; outputFile?: string; lifetimeUsage: LifetimeUsage },
216
222
  activity?: AgentActivity,
217
223
  overrides?: Partial<AgentDetails>,
@@ -254,6 +260,8 @@ function buildNotificationDetails(record: AgentRecord, resultMaxLen: number, act
254
260
  durationMs: record.completedAt ? record.completedAt - record.startedAt : 0,
255
261
  outputFile: record.outputFile,
256
262
  error: record.error,
263
+ modelNote: modelSelectionNote(record) || undefined,
264
+ modelTier: record.modelTier,
257
265
  resultPreview: record.result
258
266
  ? record.result.length > resultMaxLen
259
267
  ? record.result.slice(0, resultMaxLen) + "…"
@@ -286,12 +294,18 @@ function buildNotificationDetails(record: AgentRecord, resultMaxLen: number, act
286
294
  * agents and those with `extensions: false`.
287
295
  */
288
296
  export function formatToolsSuffix(cfg: AgentConfig | undefined): string {
289
- const tools = cfg?.builtinToolNames;
297
+ const denied = cfg?.disallowedTools ?? [];
298
+ const tools = denied.length > 0
299
+ ? (cfg?.builtinToolNames ?? BUILTIN_TOOL_NAMES).filter((tool) => !denied.includes(tool))
300
+ : cfg?.builtinToolNames;
301
+ // Only claim what is known at registration: extensions may supply command
302
+ // tools (including ssh_exec), so missing bash is not proof of no remote access.
303
+ const shellNote = tools && !tools.includes("bash") ? "; no built-in shell: cannot run bash commands" : "";
290
304
  // `isolated` overrides extensions to false in the runner, so both mean the
291
305
  // agent has no extension tools.
292
306
  const noExtensionTools = cfg?.isolated === true || cfg?.extensions === false;
293
307
  if (tools && tools.length === 0) {
294
- return noExtensionTools ? "none" : "no built-ins, extension tools only";
308
+ return (noExtensionTools ? "none" : "no built-ins, extension tools only") + shellNote;
295
309
  }
296
310
  const isFullSet = !tools
297
311
  || (tools.length === BUILTIN_TOOL_NAMES.length && BUILTIN_TOOL_NAMES.every((t) => tools.includes(t)));
@@ -299,7 +313,7 @@ export function formatToolsSuffix(cfg: AgentConfig | undefined): string {
299
313
  // Extension tools resolve at run time, so they cannot be enumerated here, but
300
314
  // omitting the fact reads as "built-ins only": an agent that can search the
301
315
  // web through an extension then looks web-less, and the work gets routed away.
302
- return noExtensionTools ? builtins : `${builtins} + extension tools`;
316
+ return (noExtensionTools ? builtins : `${builtins} + extension tools`) + shellNote;
303
317
  }
304
318
 
305
319
  /** CLI flag that runs a workflow script at session start. */
@@ -319,6 +333,8 @@ export type { Clock, TimerHandle } from "./clock.js";
319
333
 
320
334
  /** Optional host dependencies, shared by the scheduler and completion delivery. */
321
335
  export interface SubagentsExtensionOptions {
336
+ /** Home for every user-scoped read and child run. Omitted uses host defaults at call time. */
337
+ homeDir?: string;
322
338
  clock?: Clock;
323
339
  }
324
340
 
@@ -331,11 +347,29 @@ export default createSubagentsExtension();
331
347
 
332
348
  function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions) {
333
349
  const clock = options.clock ?? realClock;
350
+ const getAgentDir = () => resolveAgentDir(options.homeDir);
351
+ const personalAgentsDir = () => personalAgentsDirAtHome(options.homeDir);
352
+ const locateAgentFile: typeof locateAgentFileAtHome = (name, path, cwd, source) =>
353
+ locateAgentFileAtHome(name, path, cwd, source, options.homeDir);
334
354
  // Child AgentSessions load normal extensions. Re-entering this extension there
335
355
  // would create another manager and leak handlers. Nested orchestration is
336
356
  // injected as scoped custom tools by the existing manager instead.
337
357
  if (inChildSessionContext()) return;
338
358
 
359
+ // A factory throw is only a load warning. Check shared-state compatibility
360
+ // before registering anything, and make an incompatible reload fail the
361
+ // bind through the host's fatal session_start path instead of loading half
362
+ // an extension (or continuing without the process-wide budget).
363
+ const StartupError = hostStartupErrorClass();
364
+ try {
365
+ getBudgetPolicy();
366
+ } catch (error) {
367
+ const message = error instanceof Error ? error.message : String(error);
368
+ if (!StartupError) throw new Error(message + " This host also lacks ExtensionStartupError; upgrade pi before retrying.", { cause: error });
369
+ pi.on("session_start", async () => { throw new StartupError(message, { cause: error }); });
370
+ return;
371
+ }
372
+
339
373
  // ---- Register custom notification renderer ----
340
374
  // Core collapses a custom message to this renderer's first line and draws the
341
375
  // ▸/▾ gutter itself; see ui/notification-view.ts.
@@ -368,16 +402,15 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
368
402
  });
369
403
 
370
404
  // Fuzzy model names ("opus") prefer what the user enabled in pi's settings.
371
- setModelScopeProvider(registry => resolveEnabledModels(readEnabledModels(process.cwd()), registry, process.cwd()));
405
+ setModelScopeProvider(registry => resolveEnabledModels(readEnabledModels(process.cwd(), options.homeDir), registry, process.cwd(), options.homeDir));
372
406
 
373
407
  // Read directly rather than waiting for applyAndEmitLoaded below: these decide
374
408
  // the initial load, which happens hundreds of lines before settings are applied.
375
- const bootSettings = loadSettings(process.cwd());
409
+ const bootSettings = loadSettings(process.cwd(), options.homeDir);
376
410
  let strictAgentFiles = bootSettings.strictAgentFiles === true;
377
411
  // Strict agent files fail a bind by throwing the host's `ExtensionStartupError`
378
412
  // from `session_start`. A host without that class would silently degrade to
379
413
  // warnings, so refuse here, before any project agent definition is read.
380
- const StartupError = hostStartupErrorClass();
381
414
  if (strictAgentFiles && !StartupError) {
382
415
  throw new Error(
383
416
  "[pi-subagents] strictAgentFiles requires a pi host that exports ExtensionStartupError, which this host lacks. " +
@@ -398,7 +431,7 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
398
431
 
399
432
  /** Reload agents from package/global/project agent dirs and merge with defaults (called on init and each Agent invocation). */
400
433
  const reloadCustomAgents = (strict = false) => {
401
- const userAgents = loadCustomAgents(process.cwd(), strict);
434
+ const userAgents = loadCustomAgents(process.cwd(), strict, { homeDir: options.homeDir });
402
435
  registerAgents(userAgents);
403
436
  };
404
437
 
@@ -557,6 +590,8 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
557
590
  durationMs,
558
591
  tokens,
559
592
  usage,
593
+ // Present only on a fallback, so the payload keeps its shape otherwise.
594
+ ...fallbackFields(record),
560
595
  };
561
596
  }
562
597
 
@@ -566,6 +601,26 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
566
601
  */
567
602
  let detachedSessionFile: string | undefined;
568
603
 
604
+ /** `modelFallback` + `modelNote` event fields, or nothing. Only what is KNOWN when the event fires. */
605
+ function fallbackFields(record: AgentRecord | undefined): Record<string, unknown> {
606
+ return {
607
+ ...(record?.modelTier ? { modelTier: record.modelTier } : {}),
608
+ ...(record?.modelFallback ? { modelFallback: record.modelFallback } : {}),
609
+ ...(modelSelectionNote(record) ? { modelNote: modelSelectionNote(record) } : {}),
610
+ };
611
+ }
612
+
613
+ /** The `subagents:record` history entry; the ONE builder, shared by live completion and the detached spool. */
614
+ function historyRecord(record: AgentRecord): Record<string, unknown> {
615
+ return {
616
+ id: record.id, type: record.type, description: record.description,
617
+ status: record.status, result: record.result, error: record.error,
618
+ startedAt: record.startedAt, completedAt: record.completedAt,
619
+ ...(record.modelFallback ? { modelFallback: record.modelFallback } : {}),
620
+ ...(record.modelTier ? { modelTier: record.modelTier } : {}),
621
+ };
622
+ }
623
+
569
624
  function spoolDetachedResult(sessionFile: string, record: AgentRecord) {
570
625
  if (record.resultConsumed) return;
571
626
  const footer = record.outputFile ? `\nFull transcript available at: ${record.outputFile}` : "";
@@ -574,11 +629,7 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
574
629
  id: record.id,
575
630
  content: formatTaskNotification(record, 500, showCost) + footer,
576
631
  details: buildNotificationDetails(record, 500),
577
- record: {
578
- id: record.id, type: record.type, description: record.description,
579
- status: record.status, result: record.result, error: record.error,
580
- startedAt: record.startedAt, completedAt: record.completedAt,
581
- },
632
+ record: historyRecord(record),
582
633
  });
583
634
  } catch { /* spool unwritable: the transcript file still has the result */ }
584
635
  }
@@ -590,7 +641,11 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
590
641
 
591
642
  function deliverDetachedResult(result: DetachedResult) {
592
643
  try {
593
- if (result.record) pi.appendEntry("subagents:record", result.record);
644
+ if (result.record) {
645
+ pi.appendEntry("subagents:record", result.record);
646
+ fleet.ingestFailure(result.record);
647
+ fleet.update();
648
+ }
594
649
  const details = result.details as NotificationDetails;
595
650
  outbox.add({
596
651
  ids: [details.id, ...(details.others?.map(other => other.id) ?? [])],
@@ -617,6 +672,7 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
617
672
  return;
618
673
  }
619
674
 
675
+ if (!record.resultConsumed) fleet.ingestFailure(record);
620
676
  // Emit lifecycle event based on terminal status
621
677
  const isError = record.status === "error" || record.status === "stopped" || record.status === "aborted";
622
678
  const eventData = buildEventData(record);
@@ -627,11 +683,7 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
627
683
  }
628
684
 
629
685
  // Persist final record for cross-extension history reconstruction
630
- pi.appendEntry("subagents:record", {
631
- id: record.id, type: record.type, description: record.description,
632
- status: record.status, result: record.result, error: record.error,
633
- startedAt: record.startedAt, completedAt: record.completedAt,
634
- });
686
+ pi.appendEntry("subagents:record", historyRecord(record));
635
687
 
636
688
  // Skip notification if result was already consumed via get_subagent_result
637
689
  if (record.resultConsumed) {
@@ -671,6 +723,7 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
671
723
  id: record.id,
672
724
  type: record.type,
673
725
  description: record.description,
726
+ ...fallbackFields(record),
674
727
  });
675
728
  }, (record, info) => {
676
729
  if (!isTopLevelAgent(record)) return;
@@ -682,6 +735,7 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
682
735
  reason: info.reason,
683
736
  tokensBefore: info.tokensBefore,
684
737
  compactionCount: record.compactionCount,
738
+ ...fallbackFields(record),
685
739
  });
686
740
  }, (record, usage) => {
687
741
  // Every assistant message from every agent — nested included, exactly once.
@@ -717,6 +771,7 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
717
771
  // clamped it to the model, or an agent file outranked it), so a ledger
718
772
  // can show `low (asked minimal)` instead of silently relabelling.
719
773
  ...(invocation?.requestedThinking !== undefined ? { requestedThinking: invocation.requestedThinking } : {}),
774
+ ...fallbackFields(record),
720
775
  depth: record.depth ?? 1,
721
776
  ...(record.parentAgentId !== undefined ? { parentAgentId: record.parentAgentId } : {}),
722
777
  ...(record.workflowId !== undefined ? { workflowId: record.workflowId } : {}),
@@ -725,7 +780,7 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
725
780
  }, (record, message) => {
726
781
  if (!isTopLevelAgent(record)) return;
727
782
  pi.events.emit("subagents:steered", { id: record.id, message });
728
- }, clock);
783
+ }, clock, options.homeDir);
729
784
 
730
785
  // Expose manager via Symbol.for() global registry for cross-package access.
731
786
  // Standard Node.js pattern for cross-package singletons (used by OpenTelemetry, etc.).
@@ -800,6 +855,13 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
800
855
  // awaits nothing. A forged `blocking` would charge it to the foreground
801
856
  // pool and could defer it behind a queue whose gate nobody is holding.
802
857
  delete safeOptions.blocking;
858
+ // The spawn path's own disclosure; a caller-supplied one could hide a
859
+ // fallback or invent one. The runner reports the real outcome itself.
860
+ delete safeOptions.modelFallback;
861
+ delete safeOptions.modelSelection;
862
+ delete safeOptions.modelTier;
863
+ delete safeOptions.previousModelTier;
864
+ delete safeOptions.previousModelFallback;
803
865
  return spawnResolved(piRef, ctxRef, type, prompt, safeOptions);
804
866
  };
805
867
 
@@ -833,6 +895,7 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
833
895
 
834
896
  // --- Cross-extension RPC via pi.events ---
835
897
  let currentCtx: ExtensionContext | undefined;
898
+ let unwatchFleetSchedules: (() => void) | undefined;
836
899
  // RPC handlers + the `subagents:ready` broadcast are wired on `session_start`
837
900
  // (a bound lifecycle event), not at factory time. pi runs every extension
838
901
  // factory before the `extensions:` filter and only fires lifecycle events for
@@ -848,7 +911,7 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
848
911
  // Session-scoped: store is constructed inside session_start once sessionId
849
912
  // is available. Mirrors pi-chonky-tasks's session-scoped task store —
850
913
  // schedules reset on /new, restore on /resume.
851
- const scheduler = new SubagentScheduler({ clock: options.clock });
914
+ const scheduler = new SubagentScheduler({ clock: options.clock, homeDir: options.homeDir });
852
915
 
853
916
  function startScheduler(ctx: ExtensionContext) {
854
917
  try {
@@ -870,6 +933,7 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
870
933
  // bound session_start, so a filtered-out activation never advertises (#142).
871
934
  pi.on("session_start", async (_event, ctx) => {
872
935
  currentCtx = ctx;
936
+ restoreFleetDisclosure(ctx);
873
937
  // Pi resolves project trust before the first session starts, so this is the
874
938
  // earliest honest answer. The activation-time load defaulted to untrusted,
875
939
  // so a trusted project's `packages[]` only becomes visible now.
@@ -897,6 +961,7 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
897
961
  widget.setUICtx(ctx.ui);
898
962
  fleet.setUICtx(ctx.ui as any);
899
963
  watchHandoffKey(ctx.ui);
964
+ unwatchFleetSchedules ??= pi.events.on("subagents:scheduled", () => fleet.update());
900
965
  }
901
966
  manager.clearCompleted(true);
902
967
  const sessionFile = ctx.sessionManager?.getSessionFile?.();
@@ -911,6 +976,7 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
911
976
  // fires once per activation, but a double-bind must not leak listeners.
912
977
  if (!rpcHandle) {
913
978
  rpcHandle = registerRpcHandlers({
979
+ homeDir: options.homeDir,
914
980
  events: pi.events,
915
981
  pi,
916
982
  getCtx: () => currentCtx,
@@ -930,6 +996,7 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
930
996
  if (!record || record.parentAgentId) return false;
931
997
  if (record.status === "running" || record.status === "queued") return false;
932
998
  markConsumed(record, clock.now());
999
+ fleet.acknowledgeFailure(record.id);
933
1000
  outbox.cancel(record.id);
934
1001
  return true;
935
1002
  },
@@ -942,6 +1009,7 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
942
1009
  pi.events.emit("subagents:ready", {});
943
1010
  }
944
1011
  if (isSchedulingEnabled() && !scheduler.isActive()) startScheduler(ctx);
1012
+ fleet.update(); // restored quota-parked jobs have no agent record
945
1013
  // Stack `@handle` suggestions on pi's built-in autocomplete. Registered at
946
1014
  // most once per activation: pi appends wrappers to a list it never prunes,
947
1015
  // so a second call would layer a duplicate provider on the first. TUI only
@@ -1102,6 +1170,9 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
1102
1170
  description: entry.description,
1103
1171
  reclaim: { handle: entry.handle, alias: entry.alias },
1104
1172
  resumeSessionFile: entry.sessionFile,
1173
+ // Historical disclosures never replace the continuation's current selection.
1174
+ ...(entry.modelFallback ? { previousModelFallback: entry.modelFallback } : {}),
1175
+ ...(entry.modelTier ? { previousModelTier: entry.modelTier } : {}),
1105
1176
  isBackground: true,
1106
1177
  });
1107
1178
  // The agent may still be starting — wait, so a startup failure lands in
@@ -1155,7 +1226,7 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
1155
1226
  // Not awaited: the clone runs a full model turn, and prompt() is blocked
1156
1227
  // until this hook returns. The user gets their prompt back immediately
1157
1228
  // and the agent appears in the widget when it starts.
1158
- void runMentionClone({ ctx, type, message: mention.message, agentTool: registeredAgentTool })
1229
+ void runMentionClone({ ctx, type, message: mention.message, agentTool: registeredAgentTool, homeDir: options.homeDir })
1159
1230
  .then(async (result) => {
1160
1231
  if (result.spawned) return;
1161
1232
  // A clone that could not run must not swallow the mention: start the
@@ -1181,7 +1252,7 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
1181
1252
  }
1182
1253
 
1183
1254
  try {
1184
- // Nothing else to pass: runAgent resolves model, thinking, max turns and
1255
+ // Nothing else to pass: the manager resolves model; runAgent resolves thinking, max turns and
1185
1256
  // `isolated` from the agent's own config when the spawn omits them, and the
1186
1257
  // manager's onStart/onComplete callbacks own the widget, the fleet list
1187
1258
  // and the completion notification — the same contract the scheduler and
@@ -1213,6 +1284,7 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
1213
1284
  // to this session (see detached-results.ts). Only agents that cannot outlive the
1214
1285
  // session are aborted.
1215
1286
  pi.on("session_shutdown", async (event: { reason?: string }) => {
1287
+ fleet.flushState(); // appendEntry must run before this activation loses its context
1216
1288
  const sessionFile = currentCtx?.sessionManager?.getSessionFile?.();
1217
1289
  unwatchDetached?.();
1218
1290
  unwatchDetached = undefined;
@@ -1262,6 +1334,8 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
1262
1334
  widget.dispose();
1263
1335
  outbox.dispose();
1264
1336
  fleet.dispose();
1337
+ unwatchFleetSchedules?.();
1338
+ unwatchFleetSchedules = undefined;
1265
1339
  unsubHandoffKey?.();
1266
1340
  unsubHandoffKey = undefined;
1267
1341
  handoffKeyUi = undefined;
@@ -1334,6 +1408,7 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
1334
1408
  type: record.type,
1335
1409
  description: record.description,
1336
1410
  isBackground: true,
1411
+ ...fallbackFields(record),
1337
1412
  });
1338
1413
  }
1339
1414
  }
@@ -1398,6 +1473,17 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
1398
1473
  getFullscreenScrollbar, backgroundActionFor, isShowModelEnabled,
1399
1474
  // Agents the previous instance of this session left running (/reload, /new, ...).
1400
1475
  () => listRunningDetached(currentCtx?.sessionManager?.getSessionFile?.()), clock);
1476
+ fleet.setParkedSource(() => scheduler.list().filter(job => job.enabled && job.quotaParked && job.lastStatus !== "running").length);
1477
+ /** Replay UI-only state from the active branch, like workflow/goal custom snapshots. */
1478
+ function restoreFleetDisclosure(ctx: ExtensionContext): void {
1479
+ fleet.restoreSession(() => ctx.sessionManager?.getBranch?.() ?? [], state => {
1480
+ pi.appendEntry(FLEET_STATE_ENTRY, { ...state, version: FLEET_STATE_VERSION });
1481
+ });
1482
+ }
1483
+ pi.on("session_tree", (_event, ctx) => {
1484
+ restoreFleetDisclosure(ctx);
1485
+ fleet.update();
1486
+ });
1401
1487
  /** What the Agent tool row reads at render time, so its summary stays live. */
1402
1488
  const agentRowLive: AgentRowLive = {
1403
1489
  getRecord: (id) => manager.getRecord(id),
@@ -1570,6 +1656,9 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
1570
1656
  if (opts.outputTranscript) {
1571
1657
  existing.outputFile = createOutputFilePath(ctx.cwd, id, ctx.sessionManager.getSessionId());
1572
1658
  ensureOutputFile(existing.outputFile);
1659
+ // A fresh transcript for a later resume must still say what the agent ran on.
1660
+ if (existing.modelFallback) appendModelFallbackEntry(existing.outputFile, id, existing.modelFallback);
1661
+ if (existing.modelTier) appendModelTierEntry(existing.outputFile, id, existing.modelTier);
1573
1662
  }
1574
1663
  // Anchor streaming past the turns already on disk, captured BEFORE the
1575
1664
  // run starts. The resumed prompt lands as an ordinary user message at
@@ -1628,6 +1717,7 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
1628
1717
  type: existing.type,
1629
1718
  description: existing.description,
1630
1719
  isBackground: true,
1720
+ ...fallbackFields(existing),
1631
1721
  });
1632
1722
 
1633
1723
  return record;
@@ -1683,6 +1773,13 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
1683
1773
  return name.replace(/-\d{8}$/, "");
1684
1774
  }
1685
1775
 
1776
+ /** The "queued" line of a background result; names the process budget only when it is on. */
1777
+ const queuedPositionLine = (): string => {
1778
+ const { maxActive } = getBudgetPolicy();
1779
+ const budget = maxActive > 0 ? `, ${maxActive} process-wide` : "";
1780
+ return `Position: queued (max ${manager.getMaxConcurrent()} concurrent${budget})\n`;
1781
+ };
1782
+
1686
1783
  // Apply persisted settings on startup and emit `subagents:settings_loaded`.
1687
1784
  // Global + project merged; missing → defaults; corrupt file emits a warning
1688
1785
  // to stderr and falls back to defaults.
@@ -1690,6 +1787,7 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
1690
1787
  {
1691
1788
  setMaxConcurrent: (n) => manager.setMaxConcurrent(n),
1692
1789
  setMaxConcurrentForeground: (n) => manager.setMaxConcurrentForeground(n),
1790
+ setBudgetPolicy,
1693
1791
  setDefaultMaxTurns,
1694
1792
  setGraceTurns,
1695
1793
  setDefaultJoinMode,
@@ -1718,6 +1816,8 @@ function activateSubagents(pi: ExtensionAPI, options: SubagentsExtensionOptions)
1718
1816
  setPackageWorkflows: setPackageWorkflowsGate,
1719
1817
  },
1720
1818
  (event, payload) => pi.events.emit(event, payload),
1819
+ process.cwd(),
1820
+ options.homeDir,
1721
1821
  );
1722
1822
 
1723
1823
  // ---- Agent tool ----
@@ -1800,7 +1900,7 @@ If the target is already known, use a direct tool — \`read\` for a known path,
1800
1900
  - Use steer_subagent to send mid-run messages to a running background agent.
1801
1901
  - Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, etc.), since it is not aware of the user's intent.
1802
1902
  - If an agent's description says it should be used proactively, try to use it without the user having to ask for it first.
1803
- - Use model to specify a different model (as "provider/modelId", or fuzzy e.g. "haiku", "sonnet").
1903
+ - Use model to select a configured tier (e.g. "@cheap"), an exact "provider/modelId", or a fuzzy tool-call input (e.g. "haiku").
1804
1904
  - Use thinking to control extended thinking level.
1805
1905
  - Use inherit_context if the agent needs the parent conversation history.${isolationGuideline}${scheduleGuideline}
1806
1906
 
@@ -1902,7 +2002,7 @@ Terse command-style prompts produce shallow, generic work.
1902
2002
  model: Type.Optional(
1903
2003
  Type.String({
1904
2004
  description:
1905
- 'Optional model override. Accepts "provider/modelId" or fuzzy name (e.g. "haiku", "sonnet"). Omit to use the agent type\'s default.',
2005
+ 'Optional model override. Accepts a configured tier (e.g. "@cheap"), "provider/modelId", or fuzzy name (e.g. "haiku"). Explicit values win over agentModels and the agent definition.',
1906
2006
  }),
1907
2007
  ),
1908
2008
  thinking: Type.Optional(
@@ -2018,7 +2118,7 @@ Terse command-style prompts produce shallow, generic work.
2018
2118
  // foreground path. Resume deliberately doesn't: it replays the stored
2019
2119
  // session and ignores `subagent_type` entirely, so a note about type
2020
2120
  // substitution would be describing something that didn't happen.
2021
- const fallbackNote = dispatch.ok && dispatch.fellBackFrom !== undefined
2121
+ const typeNote = dispatch.ok && dispatch.fellBackFrom !== undefined
2022
2122
  ? `Note: Unknown agent type "${dispatch.fellBackFrom}" — using ${resolveType(subagentType) ? subagentType : "the fallback agent config"}.\n\n`
2023
2123
  : "";
2024
2124
 
@@ -2028,36 +2128,22 @@ Terse command-style prompts produce shallow, generic work.
2028
2128
  const customConfig = getAgentConfig(subagentType);
2029
2129
 
2030
2130
  const resolvedConfig = resolveAgentInvocationConfig(customConfig, params, {
2031
- agentType: subagentType,
2032
2131
  worktreeAllowed: isWorktreeIsolationEnabled(),
2033
2132
  defaultRunInBackground: getBackgroundByDefault(),
2034
2133
  });
2035
2134
 
2036
- // Resolve an explicit tool-call model first; agent config supplies only a default.
2037
- let model = ctx.model;
2038
- if (resolvedConfig.modelInput) {
2039
- const resolved = resolveModel(resolvedConfig.modelInput, ctx.modelRegistry);
2040
- if (typeof resolved === "string") {
2041
- if (resolvedConfig.modelFromParams) return textResult(resolved);
2042
- // config-specified: silent fallback to parent
2043
- } else {
2044
- model = resolved;
2045
- }
2046
- }
2047
-
2048
- // Scope validation: the effective resolved model is checked against the
2049
- // user's enabledModels list. Policy (hard error vs warn-and-proceed) lives
2050
- // in model-scope.ts so the nested delegation tools apply the same rule.
2051
- const scopeVerdict = checkModelScope({
2052
- model,
2053
- cwd: ctx.cwd,
2054
- modelRegistry: ctx.modelRegistry,
2055
- callerSupplied: resolvedConfig.modelFromParams,
2056
- agentLabel: customConfig?.displayName ?? subagentType,
2057
- modelInput: resolvedConfig.modelInput,
2135
+ // A live resume keeps its record's selection, ignoring new model inputs.
2136
+ const selection = params.resume ? undefined : selectModel({
2137
+ homeDir: options.homeDir,
2138
+ agentType: subagentType, definitionModel: customConfig?.model, paramModel: params.model,
2139
+ parentModel: ctx.model, registry: ctx.modelRegistry, cwd: ctx.cwd, agentLabel: customConfig?.displayName,
2058
2140
  });
2059
- if (scopeVerdict.kind === "error") return textResult(scopeVerdict.message);
2060
- if (scopeVerdict.kind === "warn") ctx.ui.notify(scopeVerdict.message, "warning");
2141
+ if (typeof selection === "string") return { ...textResult(selection), isError: true };
2142
+ const tierBlock = selection?.tierBinding && !params.schedule ? await admitModelSelection(selection, ctx.modelRegistry, pi.events) : undefined;
2143
+ const model = selection?.model;
2144
+ const modelFallback = selection?.fallback;
2145
+
2146
+ if (selection?.scopeWarning) ctx.ui.notify(selection.scopeWarning, "warning");
2061
2147
 
2062
2148
  const thinking = resolvedConfig.thinking;
2063
2149
  const inheritContext = resolvedConfig.inheritContext;
@@ -2066,7 +2152,7 @@ Terse command-style prompts produce shallow, generic work.
2066
2152
  const isolation = resolvedConfig.isolation;
2067
2153
  // Undefined = the session cwd. The manager validates it (absolute,
2068
2154
  // existing directory) at spawn and again at start.
2069
- const cwd = resolveCwdParam(params.cwd, ctx.cwd);
2155
+ const cwd = resolveCwdParam(params.cwd, ctx.cwd, options.homeDir);
2070
2156
  // Whether this spawn writes its .output transcript. Per-agent
2071
2157
  // frontmatter (`output_transcript`) wins; otherwise the project/global
2072
2158
  // default applies. `attachTranscript` below is the SOLE gate — every
@@ -2084,17 +2170,12 @@ Terse command-style prompts produce shallow, generic work.
2084
2170
  // parent's model used to show the level with nothing to attach it to.
2085
2171
  // This is the pre-session snapshot — agent-manager overwrites it with the
2086
2172
  // effective values the moment a session reports them.
2087
- const { modelName, modelId } = model
2088
- ? isVirtualModel(model) ? describeVirtualModel(model) : describeModel(model)
2089
- : { modelName: undefined, modelId: undefined };
2173
+ const { modelName, modelId } = describeSelection(selection, ctx.modelRegistry);
2090
2174
  const effectiveMaxTurns = normalizeMaxTurns(resolvedConfig.maxTurns ?? getDefaultMaxTurns());
2091
2175
  const agentInvocation: AgentInvocation = {
2092
2176
  modelName,
2093
2177
  modelId,
2094
2178
  thinking,
2095
- // Only set where the agent file outranked the caller, so the surfaces can
2096
- // disclose a parameter that was accepted but could not take effect (#182).
2097
- requestedThinking: resolvedConfig.overridden?.thinking,
2098
2179
  // Explicit value only — the default fallback would just add noise.
2099
2180
  // Normalize so `0` (unlimited) doesn't surface as a misleading "max turns: 0".
2100
2181
  maxTurns: normalizeMaxTurns(resolvedConfig.maxTurns),
@@ -2107,11 +2188,33 @@ Terse command-style prompts produce shallow, generic work.
2107
2188
  const modeLabel = getPromptModeLabel(subagentType);
2108
2189
  const { tags: invocationTags } = buildInvocationTags(agentInvocation);
2109
2190
  const agentTags = modeLabel ? [modeLabel, ...invocationTags] : invocationTags;
2191
+ /**
2192
+ * The lead-in for a result: the type note, then the model note. The model
2193
+ * note is read off the record the shared spawn path created. Before a
2194
+ * record exists (scheduling) it is this call's own resolution, the only
2195
+ * one there is. Never recomputed for a resume, which runs on its old session.
2196
+ */
2197
+ const modelNoteOf = (rec: AgentRecord | undefined): string | undefined =>
2198
+ modelSelectionNote(rec ?? { modelFallback, modelTier: selection?.tier }) || undefined;
2199
+ const modelFallbackOf = (rec: AgentRecord | undefined): ModelFallback | undefined =>
2200
+ rec ? rec.modelFallback : modelFallback;
2201
+ /** A resume's lead-in: the record's note only (the type note describes a spawn, not a resume). */
2202
+ const resumeNoteFor = (rec: AgentRecord): string => {
2203
+ const note = modelNoteOf(rec);
2204
+ return note ? `${note}\n\n` : "";
2205
+ };
2206
+ const fallbackNoteFor = (rec: AgentRecord | undefined): string => {
2207
+ const modelNote = modelNoteOf(rec);
2208
+ return typeNote + (modelNote ? `${modelNote}\n\n` : "");
2209
+ };
2110
2210
  const detailBase = {
2111
2211
  displayName,
2112
2212
  description: params.description,
2113
2213
  subagentType,
2114
2214
  modelName,
2215
+ modelNote: modelNoteOf(undefined),
2216
+ modelFallback: modelFallbackOf(undefined),
2217
+ modelTier: selection?.tier ? tierDisclosure(selection.tier) : undefined,
2115
2218
  tags: agentTags.length > 0 ? agentTags : undefined,
2116
2219
  };
2117
2220
 
@@ -2129,7 +2232,10 @@ Terse command-style prompts produce shallow, generic work.
2129
2232
  * buildInvocationTags would silently drop `twin`.
2130
2233
  */
2131
2234
  const detailBaseFor = (rec: AgentRecord | undefined): typeof detailBase => {
2132
- if (!rec?.invocation) return detailBase;
2235
+ // A record's disclosure outranks this call's, even before its session
2236
+ // reports an invocation: resume reuses the old session, so this call's
2237
+ // config says nothing about what ran.
2238
+ if (!rec?.invocation) return rec ? { ...detailBase, modelNote: modelNoteOf(rec), modelFallback: modelFallbackOf(rec), modelTier: rec.modelTier } : detailBase;
2133
2239
  const type = rec.type;
2134
2240
  const { modelName: recModelName, tags } = buildInvocationTags(rec.invocation);
2135
2241
  const recModeLabel = getPromptModeLabel(type);
@@ -2139,6 +2245,9 @@ Terse command-style prompts produce shallow, generic work.
2139
2245
  description: rec.description,
2140
2246
  subagentType: type,
2141
2247
  modelName: recModelName,
2248
+ modelNote: modelNoteOf(rec),
2249
+ modelFallback: modelFallbackOf(rec),
2250
+ modelTier: rec.modelTier,
2142
2251
  tags: recTags.length > 0 ? recTags : undefined,
2143
2252
  };
2144
2253
  };
@@ -2172,7 +2281,7 @@ Terse command-style prompts produce shallow, generic work.
2172
2281
  // at fire time, and the original is what a user edits.
2173
2282
  subagent_type: requestedType,
2174
2283
  prompt: params.prompt as string,
2175
- model: params.model as string | undefined,
2284
+ model: selection?.requested?.startsWith("@") ? selection.requested : params.model as string | undefined,
2176
2285
  thinking: thinking,
2177
2286
  max_turns: effectiveMaxTurns,
2178
2287
  isolated: isolated,
@@ -2180,7 +2289,7 @@ Terse command-style prompts produce shallow, generic work.
2180
2289
  });
2181
2290
  const next = scheduler.getNextRun(job.id);
2182
2291
  return textResult(
2183
- `${fallbackNote}Scheduled "${job.name}" (id: ${job.id}, type: ${job.scheduleType}). ` +
2292
+ `${fallbackNoteFor(undefined)}Scheduled "${job.name}" (id: ${job.id}, type: ${job.scheduleType}). ` +
2184
2293
  `Next run: ${next ?? "(unknown)"}. ` +
2185
2294
  `Manage via /agents → Scheduled jobs.`,
2186
2295
  );
@@ -2206,19 +2315,21 @@ Terse command-style prompts produce shallow, generic work.
2206
2315
  // a background spawn. Previously run_in_background was silently ignored
2207
2316
  // on resume (this branch returned before the background branch below),
2208
2317
  // so a resumed agent always blocked the main loop until it finished.
2318
+ // A detached resume hands control back while the record stays
2319
+ // "running", and parallel tool calls can resume one agent twice in a
2320
+ // turn, so nothing stops the model from resuming an agent mid-run.
2321
+ // manager.resume() refuses that on both paths (it would orphan the live
2322
+ // run's abort controller); say why here, where the model can act on it,
2323
+ // instead of letting it read as a generic failure.
2324
+ if (existing.status === "running" || existing.status === "queued") {
2325
+ return textResult(
2326
+ `Agent "${params.resume}" is still ${existing.status} — it can only be resumed once its current run finishes.\n` +
2327
+ `Use steer_subagent to send it a message mid-run, or get_subagent_result to wait for it.`,
2328
+ );
2329
+ }
2330
+
2209
2331
  if (runInBackground) {
2210
2332
  const id = existing.id;
2211
- // A detached resume hands control back while the record stays
2212
- // "running", so nothing stops the model from resuming the same agent
2213
- // again mid-run. manager.resume() refuses that (it would orphan the
2214
- // live run's abort controller); say why here, where the model can act
2215
- // on it, instead of letting it read as a generic failure.
2216
- if (existing.status === "running" || existing.status === "queued") {
2217
- return textResult(
2218
- `Agent "${params.resume}" is still ${existing.status} — it can only be resumed once its current run finishes.\n` +
2219
- `Use steer_subagent to send it a message mid-run, or get_subagent_result to wait for it.`,
2220
- );
2221
- }
2222
2333
 
2223
2334
  const record = await startBackgroundResume(ctx, existing, params.prompt, {
2224
2335
  outputTranscript,
@@ -2231,11 +2342,11 @@ Terse command-style prompts produce shallow, generic work.
2231
2342
 
2232
2343
  const isQueued = record.status === "queued";
2233
2344
  return textResult(
2234
- `Agent ${isQueued ? "queued" : "resumed"} in background.\n` +
2345
+ `${resumeNoteFor(record)}Agent ${isQueued ? "queued" : "resumed"} in background.\n` +
2235
2346
  `Agent ID: ${id}\n` +
2236
2347
  `Type: ${existing.type}\n` +
2237
2348
  (record.outputFile ? `Output file: ${record.outputFile}\n` : "") +
2238
- (isQueued ? `Position: queued (max ${manager.getMaxConcurrent()} concurrent)\n` : "") +
2349
+ (isQueued ? queuedPositionLine() : "") +
2239
2350
  `\nYou will be notified when this agent completes.\n` +
2240
2351
  `Use get_subagent_result to retrieve full results, or steer_subagent to send it messages.`,
2241
2352
  { ...detailBaseFor(record), toolUses: record.toolUses, tokens: "", durationMs: 0, status: "background" as const, agentId: id },
@@ -2273,10 +2384,10 @@ Terse command-style prompts produce shallow, generic work.
2273
2384
  // A failed resume surfaces the error, plus any partial output THIS
2274
2385
  // resume produced (never the previous turn's answer, #144).
2275
2386
  if (record.status === "error") {
2276
- return textResult(`Agent failed: ${record.error}${partialOutputSuffix(record)}`, buildDetails(detailBaseFor(record), record, resumeState));
2387
+ return textResult(`${resumeNoteFor(record)}Agent failed: ${record.error}${partialOutputSuffix(record)}`, buildDetails(detailBaseFor(record), record, resumeState));
2277
2388
  }
2278
2389
  return textResult(
2279
- record.result?.trim() || "No output.",
2390
+ `${resumeNoteFor(record)}${record.result?.trim() || "No output."}`,
2280
2391
  buildDetails(detailBaseFor(record), record, resumeState),
2281
2392
  );
2282
2393
  }
@@ -2287,12 +2398,12 @@ Terse command-style prompts produce shallow, generic work.
2287
2398
  const backgroundResult = (id: string, record: AgentRecord | undefined) => {
2288
2399
  const isQueued = record?.status === "queued";
2289
2400
  return textResult(
2290
- `${fallbackNote}Agent ${isQueued ? "queued" : "started"} in background.\n` +
2401
+ `${fallbackNoteFor(record)}Agent ${isQueued ? "queued" : "started"} in background.\n` +
2291
2402
  `Agent ID: ${id}\n` +
2292
2403
  `Type: ${displayName}\n` +
2293
2404
  `Description: ${params.description}\n` +
2294
2405
  (record?.outputFile ? `Output file: ${record.outputFile}\n` : "") +
2295
- (isQueued ? `Position: queued (max ${manager.getMaxConcurrent()} concurrent)\n` : "") +
2406
+ (isQueued ? queuedPositionLine() : "") +
2296
2407
  `\nYou will be notified when this agent completes.\n` +
2297
2408
  `Use get_subagent_result to retrieve full results, or steer_subagent to send it messages.\n` +
2298
2409
  `Do not duplicate this agent's work.`,
@@ -2306,13 +2417,14 @@ Terse command-style prompts produce shallow, generic work.
2306
2417
  // exhaustion policy says fail now (`wait: false`). Anything else fails
2307
2418
  // now, with the reset, rather than starting on an exhausted provider.
2308
2419
  const dispatchModel = model ?? ctx.model;
2309
- const quotaBlock = dispatchModel ? await checkAdmission(pi.events, modelRef(dispatchModel), undefined, clock) : undefined;
2420
+ const quotaBlock = selection?.tierBinding ? tierBlock
2421
+ : dispatchModel ? await checkAdmission(pi.events, modelRef(dispatchModel), undefined, clock) : undefined;
2310
2422
  if (quotaBlock) {
2311
2423
  // A parked job is a scheduled job, which has no `cwd` to carry.
2312
2424
  const canPark = runInBackground && !inheritContext && cwd === undefined && mayPark(quotaBlock)
2313
2425
  && isSchedulingEnabled() && scheduler.isActive();
2314
2426
  if (!canPark || !mayPark(quotaBlock)) {
2315
- throw new Error(`${describeBlock(quotaBlock)} The agent was not started.`);
2427
+ throw new Error(`${selection?.tier ? `${modelNoteOf(undefined)}\n` : ""}${describeBlock(quotaBlock)} The agent was not started.`);
2316
2428
  }
2317
2429
  const job = scheduler.addJob({
2318
2430
  name: `${params.description} (quota ${nanoid(4)})`,
@@ -2320,7 +2432,9 @@ Terse command-style prompts produce shallow, generic work.
2320
2432
  schedule: new Date(quotaBlock.resetAt + QUOTA_RESTART_GRACE_MS).toISOString(),
2321
2433
  subagent_type: requestedType,
2322
2434
  prompt: params.prompt as string,
2323
- model: dispatchModel ? modelRef(dispatchModel) : undefined,
2435
+ // Keep the request, not today's configured/parent pick. Fire-time
2436
+ // selection must still apply agentModels and aliases.
2437
+ model: selection?.requested?.startsWith("@") ? selection.requested : params.model,
2324
2438
  thinking,
2325
2439
  max_turns: effectiveMaxTurns,
2326
2440
  isolated,
@@ -2328,7 +2442,7 @@ Terse command-style prompts produce shallow, generic work.
2328
2442
  quotaParked: true,
2329
2443
  });
2330
2444
  return textResult(
2331
- `${fallbackNote}${describeBlock(quotaBlock)} Queued "${job.name}" (id: ${job.id}) to start at ` +
2445
+ `${fallbackNoteFor(undefined)}${describeBlock(quotaBlock)} Queued "${job.name}" (id: ${job.id}) to start at ` +
2332
2446
  `${scheduler.getNextRun(job.id) ?? job.schedule}. Manage via /agents → Scheduled jobs.`,
2333
2447
  );
2334
2448
  }
@@ -2354,6 +2468,7 @@ Terse command-style prompts produce shallow, generic work.
2354
2468
  // tool call failed, whereas a returned message reads to the model as a
2355
2469
  // subagent that ran and reported this (#179).
2356
2470
  id = manager.spawn(pi, ctx, subagentType, params.prompt, {
2471
+ modelSelection: selection,
2357
2472
  description: params.description,
2358
2473
  name: params.name as string | undefined,
2359
2474
  model,
@@ -2399,6 +2514,7 @@ Terse command-style prompts produce shallow, generic work.
2399
2514
  type: subagentType,
2400
2515
  description: params.description,
2401
2516
  isBackground: true,
2517
+ ...fallbackFields(manager.getRecord(id)),
2402
2518
  });
2403
2519
 
2404
2520
  return backgroundResult(id, record);
@@ -2507,6 +2623,7 @@ Terse command-style prompts produce shallow, generic work.
2507
2623
  let fgResult: { record: AgentRecord } | undefined;
2508
2624
  try {
2509
2625
  fgResult = await Promise.race([manager.spawnAndWait(pi, ctx, subagentType, params.prompt, {
2626
+ modelSelection: selection,
2510
2627
  description: params.description,
2511
2628
  name: params.name as string | undefined,
2512
2629
  model,
@@ -2570,7 +2687,7 @@ Terse command-style prompts produce shallow, generic work.
2570
2687
 
2571
2688
  if (record.status === "error") {
2572
2689
  // Error headline + any partial output the run produced before failing.
2573
- return textResult(`${fallbackNote}Agent failed: ${record.error}${partialOutputSuffix(record)}`, details);
2690
+ return textResult(`${fallbackNoteFor(record)}Agent failed: ${record.error}${partialOutputSuffix(record)}`, details);
2574
2691
  }
2575
2692
 
2576
2693
  const durationMs = (record.completedAt ?? Date.now()) - record.startedAt;
@@ -2581,7 +2698,7 @@ Terse command-style prompts produce shallow, generic work.
2581
2698
  if (costText) statsParts.push(costText);
2582
2699
  }
2583
2700
  return textResult(
2584
- `${fallbackNote}Agent completed in ${formatMs(durationMs)} (${statsParts.join(", ")})${getForegroundOutcomeNote(record.status)}.\n\n` +
2701
+ `${fallbackNoteFor(record)}Agent completed in ${formatMs(durationMs)} (${statsParts.join(", ")})${getForegroundOutcomeNote(record.status)}.\n\n` +
2585
2702
  (record.result?.trim() || "No output."),
2586
2703
  details,
2587
2704
  );
@@ -2671,6 +2788,7 @@ Terse command-style prompts produce shallow, generic work.
2671
2788
  startedAt: task.startTime,
2672
2789
  ...(task.endTime !== undefined ? { completedAt: task.endTime } : {}),
2673
2790
  tokens: task.totalTokens,
2791
+ observationKey: task,
2674
2792
  }));
2675
2793
  }
2676
2794
 
@@ -2687,6 +2805,7 @@ Terse command-style prompts produce shallow, generic work.
2687
2805
  args: task.args,
2688
2806
  signal: task.abortController.signal,
2689
2807
  host: createWorkflowHost({
2808
+ homeDir: options.homeDir,
2690
2809
  pi,
2691
2810
  ctx,
2692
2811
  manager,
@@ -2844,6 +2963,7 @@ Terse command-style prompts produce shallow, generic work.
2844
2963
  ? { scriptPath: resumeFrom.scriptPath }
2845
2964
  : params,
2846
2965
  ctx.cwd,
2966
+ options.homeDir,
2847
2967
  );
2848
2968
  if (!resolved.ok) return textResult(resolved.message);
2849
2969
 
@@ -3129,7 +3249,9 @@ Terse command-style prompts produce shallow, generic work.
3129
3249
  let output =
3130
3250
  `Agent: ${record.id}\n` +
3131
3251
  `Type: ${displayName} | Status: ${record.status}${getStatusNote(record.status)} | ${statsParts.join(" | ")}\n` +
3132
- `Description: ${record.description}\n\n`;
3252
+ `Description: ${record.description}\n` +
3253
+ (modelSelectionNote(record) ? `${modelSelectionNote(record)}\n` : "") +
3254
+ `\n`;
3133
3255
 
3134
3256
  if (record.status === "running") {
3135
3257
  output += "Agent is still running. Use wait: true or check back later.";
@@ -3142,6 +3264,7 @@ Terse command-style prompts produce shallow, generic work.
3142
3264
  // Mark result as consumed — suppresses the completion notification
3143
3265
  if (record.status !== "running" && record.status !== "queued") {
3144
3266
  markConsumed(record, clock.now());
3267
+ fleet.acknowledgeFailure(record.id);
3145
3268
  outbox.cancel(record.id);
3146
3269
  }
3147
3270
 
@@ -3224,19 +3347,21 @@ Terse command-style prompts produce shallow, generic work.
3224
3347
 
3225
3348
  function getModelLabel(type: string, registry?: ModelRegistry): string {
3226
3349
  const cfg = getAgentConfig(type);
3227
- if (!cfg?.model) return "inherit"; // no model configured → really inherits parent
3228
- const label = getModelLabelFromConfig(cfg.model);
3350
+ const input = modelRequest(type, cfg?.model).requested;
3351
+ if (!input) return "inherit";
3352
+ const label = getModelLabelFromConfig(input);
3229
3353
  if (!registry) return label;
3230
- const resolved = resolveModel(cfg.model, registry);
3231
- // Configured but unresolvable: the runtime silently falls back to the parent
3232
- // model, so flag it (and the fallback) rather than hiding the config.
3233
- if (typeof resolved === "string") return `${label} (unavailable, fallback: inherit)`;
3354
+ const selection = selectModel({ homeDir: options.homeDir, agentType: type, definitionModel: cfg?.model, parentModel: undefined, registry, cwd: currentCtx?.cwd ?? process.cwd() });
3355
+ if (typeof selection === "string" || selection.outcome === "fallback" || !selection.model) {
3356
+ return `${label} (unavailable, fallback: inherit)`;
3357
+ }
3358
+ const resolved = selection.model;
3234
3359
  // Surface what it actually resolved to when that differs from the config —
3235
3360
  // e.g. a provider fallback or a looser version pin. Cosmetic separator/date
3236
3361
  // differences are normalized away so an effectively-identical match stays quiet.
3237
3362
  const resolvedFull = `${resolved.provider}/${resolved.id}`;
3238
3363
  const norm = (s: string) => s.toLowerCase().replace(/\./g, "-").replace(/-\d{8}$/, "");
3239
- if (norm(cfg.model) === norm(resolvedFull)) return label;
3364
+ if (norm(input) === norm(resolvedFull)) return label;
3240
3365
  return `${label} (→ ${resolvedFull.replace(/-\d{8}$/, "")})`;
3241
3366
  }
3242
3367
 
@@ -3316,7 +3441,7 @@ Terse command-style prompts produce shallow, generic work.
3316
3441
  */
3317
3442
  function packageNameOf(cfg: AgentConfig | undefined): string | undefined {
3318
3443
  if (cfg?.source !== "package" || !cfg.sourcePath) return undefined;
3319
- return packageNameForPath(cfg.sourcePath, process.cwd());
3444
+ return packageNameForPath(cfg.sourcePath, process.cwd(), "agents", { homeDir: options.homeDir });
3320
3445
  }
3321
3446
 
3322
3447
  async function showAllAgentsList(ctx: ExtensionCommandContext) {
@@ -3370,8 +3495,9 @@ Terse command-style prompts produce shallow, generic work.
3370
3495
  };
3371
3496
  });
3372
3497
 
3373
- if (selected && getAgentConfig(selected)) {
3374
- await showAgentDetail(ctx, selected);
3498
+ const cfg = selected ? getAgentConfig(selected) : undefined;
3499
+ if (selected && cfg) {
3500
+ await showAgentDetail(ctx, selected, cfg);
3375
3501
  await showAllAgentsList(ctx);
3376
3502
  }
3377
3503
  }
@@ -3425,13 +3551,7 @@ Terse command-style prompts produce shallow, generic work.
3425
3551
  );
3426
3552
  }
3427
3553
 
3428
- async function showAgentDetail(ctx: ExtensionCommandContext, name: string) {
3429
- const cfg = getAgentConfig(name);
3430
- if (!cfg) {
3431
- ctx.ui.notify(`Agent config not found for "${name}".`, "warning");
3432
- return;
3433
- }
3434
-
3554
+ async function showAgentDetail(ctx: ExtensionCommandContext, name: string, cfg: AgentConfig) {
3435
3555
  const isPackage = cfg.source === "package";
3436
3556
  const file = locateAgentFile(name, cfg.sourcePath, undefined, cfg.source);
3437
3557
  const isDefault = cfg.isDefault === true;
@@ -3838,7 +3958,11 @@ Write the file using the write tool. Only write the file, nothing else.`;
3838
3958
  // goes away. undefined is dropped by JSON.stringify.
3839
3959
  fallbackSubagent: getFallbackSubagent(),
3840
3960
  // Global-only (see loadSettings): never written into the project file.
3961
+ maxActiveSubagents: undefined,
3962
+ nestedSubagentReserve: undefined,
3841
3963
  modelAliases: undefined,
3964
+ modelTiers: undefined,
3965
+ modelResolution: undefined,
3842
3966
  agentModels: undefined,
3843
3967
  reportUsage: isReportUsageEnabled(),
3844
3968
  showCost: isShowCostEnabled(),