@cr1ms0n/pi-subagent 0.8.9 → 0.9.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.
package/src/extension.ts CHANGED
@@ -1,6 +1,5 @@
1
1
  import { Buffer } from "node:buffer";
2
2
  import * as fs from "node:fs/promises";
3
- import { constants as fsConstants } from "node:fs";
4
3
  import * as path from "node:path";
5
4
  import type { ExtensionAPI, ExtensionContext, Theme, ToolRenderResultOptions } from "@earendil-works/pi-coding-agent";
6
5
  import { truncateToWidth, type Component, type TUI } from "@earendil-works/pi-tui";
@@ -24,7 +23,7 @@ import { abortAsPromise } from "./maintenance.js";
24
23
  import { sweepSessionsLifecycle } from "./distill.js";
25
24
  import { runTasks } from "./orchestrator.js";
26
25
  import { OutputManager } from "./output.js";
27
- import { CONTEXT_MANAGEMENT_TOOLS, parseDepth, parseSpawnPolicy, SPAWNS_ENV_VAR, validateSubagentRequest, type ResolvedTask } from "./policy.js";
26
+ import { parseDepth, parseSpawnPolicy, SPAWNS_ENV_VAR, validateSubagentRequest, type PreparedTask, type ParentContext, type PreparationOptions, type ResolvedTask } from "./policy.js";
28
27
  import type { ChildRunner } from "./runner.js";
29
28
  import { ProcessLockManager } from "./process-lock.js";
30
29
  import { SessionScopedRunRegistry, snapshotFromLiveRun } from "./registry.js";
@@ -40,13 +39,18 @@ import { BTW_ENTRY_TYPE, btwLabel, type BtwEntry } from "./btw.js";
40
39
  import { Semaphore } from "./semaphore.js";
41
40
  import type { RunSnapshot, TaskResult, TaskSpec, UsageStats } from "./types.js";
42
41
  import { emptyUsage } from "./types.js";
43
- import { addUsage, buildUsageLedger, formatLedger, hasBilledUsage, toPiUsage, type UsageLedger } from "./usage.js";
42
+ import { addUsage, buildUsageLedger, formatLedger, hasBilledUsage, routingUsage, toPiUsage, type UsageLedger } from "./usage.js";
44
43
  import { resolveBackendSessionFilePath, resolveSessionFilePath } from "./transcript.js";
45
44
  import { CompletionBatcher, COMPLETION_MESSAGE_TYPE, type CompletionDetails, type CompletionDetailsRun, type CompletionDetailsTask } from "./notifications.js";
46
45
  import { describeCatalog, discoverAgents, type AgentDefinition } from "./agents.js";
47
46
  import { createSubagentsOverlay, FooterStatusModel, type SubagentAdapter } from "./ui.js";
48
47
  import { WorktreeManager } from "./worktree.js";
49
- import { formatModelPolicyPrompt, readModelPolicyFile, resolveModelRoute, validateModelRequest, type ModelPolicySnapshot } from "./model-policy.js";
48
+ import { eligibleModelCandidates, formatJevRoutingPrompt, toToolCandidates } from "./routing-policy.js";
49
+ import { JevRouter } from "./jev-router.js";
50
+ import { routePreparedTasks, type RoutingCatalog } from "./dispatch-routing.js";
51
+ import { runLocalPreflights } from "./dispatch-preflight.js";
52
+ import type { RoutingReceipt } from "./routing-types.js";
53
+ import { buildRoutingEvent, foldRoutingReceipts, MAX_ROUTING_DELIVERY_IDS, ROUTING_ENTRY_TYPE, type PersistedRoutingEvent } from "./persistence.js";
50
54
 
51
55
  interface SessionRuntime {
52
56
  key: string;
@@ -76,6 +80,11 @@ interface SessionRuntime {
76
80
  ledgerDirty: boolean;
77
81
  closed: boolean;
78
82
  depth: number;
83
+ routingGeneration: number;
84
+ routingPaused: boolean;
85
+ pendingRoutes: Map<AbortController, Promise<void>>;
86
+ pendingRoutingEvents: Map<string, PersistedRoutingEvent>;
87
+ reconcileRouting?: () => void;
79
88
  }
80
89
 
81
90
  function sessionKey(ctx: ExtensionContext): string {
@@ -92,6 +101,7 @@ function activeEntries(runtime: SessionRuntime): readonly unknown[] {
92
101
  * on every footer refresh or live-text tick.
93
102
  */
94
103
  function ledger(runtime: SessionRuntime): UsageLedger {
104
+ runtime.reconcileRouting?.();
95
105
  if (!runtime.ledgerDirty && runtime.ledgerValue) return runtime.ledgerValue;
96
106
  const entries = activeEntries(runtime);
97
107
  runtime.ledgerValue = buildUsageLedger(
@@ -101,6 +111,7 @@ function ledger(runtime: SessionRuntime): UsageLedger {
101
111
  ...runtime.registry.getSnapshots(runtime.key),
102
112
  ],
103
113
  runtime.pendingRootMessages,
114
+ [...runtime.pendingRoutingEvents.values()],
104
115
  );
105
116
  runtime.ledgerDirty = false;
106
117
  return runtime.ledgerValue;
@@ -293,6 +304,7 @@ function compactDetails(
293
304
  const perResultText = Math.max(256, Math.floor(maxDetailsTextBytes / Math.max(1, results.length) / 2));
294
305
  return {
295
306
  mode,
307
+ routingCurrency: results.some((result) => result.routing) ? "unreported" : undefined,
296
308
  state: run?.state,
297
309
  startedAt: run?.startedAt,
298
310
  endedAt: run?.endedAt,
@@ -306,6 +318,7 @@ function compactDetails(
306
318
  errorMessage: result.errorMessage?.slice(0, 1_000),
307
319
  usage: result.usage ?? emptyUsage(),
308
320
  model: result.model,
321
+ routing: result.routing,
309
322
  thinking: result.thinking,
310
323
  profile: result.profile,
311
324
  canWrite: result.canWrite,
@@ -380,8 +393,9 @@ function deliveredResult<TDetails>(
380
393
  text: string,
381
394
  details: TDetails,
382
395
  results: ReadonlyArray<{ usage: UsageStats }>,
396
+ selectorUsage: UsageStats = emptyUsage(),
383
397
  ): { content: Array<{ type: "text"; text: string }>; details: TDetails; usage?: Usage } {
384
- const total = addUsage(...results.map((result) => result.usage));
398
+ const total = addUsage(selectorUsage, ...results.map((result) => result.usage));
385
399
  return {
386
400
  content: [{ type: "text", text }],
387
401
  details,
@@ -405,33 +419,15 @@ function resumableSessionLines(
405
419
 
406
420
  async function runPlanPreflights(
407
421
  runtime: SessionRuntime,
408
- tasks: ResolvedTask[],
422
+ tasks: PreparedTask[],
409
423
  parentCwd: string,
424
+ scope: { controller: AbortController; assertOwner(): void },
410
425
  ): Promise<void> {
411
- for (let index = 0; index < tasks.length; index++) {
412
- const task = tasks[index]!;
413
- const taskCwd = task.cwd ?? parentCwd;
414
- if (task.isolation === "worktree") {
415
- if (!(await runtime.worktrees.isGitRepo(taskCwd))) {
416
- fail(`Task ${index + 1}: ${taskCwd} is not a git repository`);
417
- }
418
- }
419
- if (task.contextFork) {
420
- const sessionFile = task.parentSessionFile;
421
- if (!sessionFile) fail(`Task ${index + 1}: context:'fork' requires a persisted parent session file`);
422
- await fs.access(sessionFile).catch(() => {
423
- fail(`context:'fork' failed: parent session file ${sessionFile} is not readable.`);
424
- });
425
- }
426
- if (task.output) {
427
- const parentDir = path.dirname(task.output);
428
- const stat = await fs.stat(parentDir).catch(() => undefined);
429
- if (!stat?.isDirectory()) fail(`Task ${index + 1}: output parent directory does not exist: ${parentDir}`);
430
- await fs.access(parentDir, fsConstants.W_OK).catch(() => {
431
- fail(`Task ${index + 1}: output parent directory is not writable: ${parentDir}`);
432
- });
433
- }
434
- }
426
+ await runLocalPreflights(tasks, parentCwd, {
427
+ signal: scope.controller.signal, assertOwner: scope.assertOwner,
428
+ checkResumeAvailability: (items) => runtime.registry.checkResumeAvailability(items, runtime.key),
429
+ isGitRepo: (cwd, signal) => runtime.worktrees.isGitRepo(cwd, signal),
430
+ });
435
431
  }
436
432
 
437
433
  function formatPlanEntry(task: ResolvedTask, index: number) {
@@ -447,6 +443,7 @@ function formatPlanEntry(task: ResolvedTask, index: number) {
447
443
  profile: task.profile,
448
444
  access: task.canWrite ? "RW" : "RO" as const,
449
445
  tools: task.effectiveTools,
446
+ routing: task.routing,
450
447
  budgets: {
451
448
  timeoutMs: task.timeoutMs,
452
449
  maxTurns: task.maxTurns,
@@ -459,7 +456,7 @@ function formatPlanEntry(task: ResolvedTask, index: number) {
459
456
  }
460
457
 
461
458
  function formatPlanText(mode: "single" | "parallel", plan: ReturnType<typeof formatPlanEntry>[]): string {
462
- const header = `Plan (dry-run, nothing spawned) — ${mode}, ${plan.length} task${plan.length === 1 ? "" : "s"}:`;
459
+ const header = `Plan (Jev selection billed; no child spawned) — ${mode}, ${plan.length} task${plan.length === 1 ? "" : "s"}:`;
463
460
  const body = plan.map((entry) => {
464
461
  const budgets = [
465
462
  `timeout_ms=${entry.budgets.timeoutMs}`,
@@ -497,14 +494,15 @@ function guidelines(catalog?: Map<string, AgentDefinition>): string[] {
497
494
  : [];
498
495
  return [
499
496
  ...agentLines,
500
- "**model is REQUIRED on every spawn call (task/tasks).** Pass the exact model mapped by the current model policy; agent-file/taskDefaults/parent-session model fields never apply. Omit model only for management actions.",
497
+ "Omit model and fallback_models on all new work. Jev selects the execution model from the user-maintained dedicated candidate list and selects individual locally permitted tools. Explicit legacy model/fallback fields are rejected.",
498
+ "action:plan calls Jev and may incur selector fees, but starts no child. Later dispatch selects again. Jev failure stops new dispatch; existing-run management requires no routing config or key.",
501
499
  "Delegate independent, read-heavy exploration or clean-context review; keep tightly coupled work in the parent.",
502
500
  "Prefer agent:'<name>' when a named agent matches the task — its persona prompt is usually better than an improvised one. Compose fields manually only when no agent fits.",
503
501
  "Give every task a short description label (3-5 words) so runs are scannable in UIs and result indexes.",
504
- "Profiles: explore/review are strictly read-only (safe for fanout); general inherits the parent's active tools and may write. Single tasks default to general, parallel tasks to explore.",
502
+ "Profiles: explore/review are strictly read-only (safe for fanout); general offers the full available locally permitted catalog to Jev and may write. Explicit tools are a ceiling; agent tool defaults do not narrow candidates. Single tasks default to general, parallel tasks to explore.",
505
503
  "Parallel writers need isolation:'worktree' (each gets an isolated checkout; changed work lands on a branch). After a worktree run finishes, use action:'diff' to inspect, then 'apply' to bring changes into the main checkout or 'discard' to drop them.",
506
- "Set budgets: at max_turns/max_cost the child is steered to wrap up and given grace turns for a final answer (grace_turns tunes this); results end as 'partial' with wrappedUp:true when the child concluded. timeout_ms includes queue time; timeout results report the phase.",
507
- "Transient failures (provider errors, stalls, queue timeouts) retry automatically; pass fallback_models:['…'] to escalate models across attempts. Task-quality failures never retry.",
504
+ "Set budgets: at max_turns/max_cost the child is steered to wrap up and given grace turns for a final answer (grace_turns tunes this); results end as 'partial' with wrappedUp:true when the child concluded. timeout_ms includes Jev selection, setup, queue and retries; max_cost excludes unreported TypeSafe currency; timeout results report the phase.",
505
+ "Transient child failures may retry on the already selected model/tools within the original deadline; no selector retries or fallback models. Task-quality failures never retry.",
508
506
  "context:'fork' starts a single child from a branched copy of this conversation — use it when the task depends on discussion context instead of re-explaining. Single-task only.",
509
507
  "Use async:true only when you have independent work meanwhile; then use action:'wait' with the run id (interruptible, does not cancel). action:'steer' injects mid-run guidance into a running child instead of cancel + retry.",
510
508
  "For parallel research, add synthesis:'<instruction>' to have one read-only child fold all outputs into a single brief, delivered first.",
@@ -523,8 +521,8 @@ async function runSynthesis(
523
521
  runtime: SessionRuntime,
524
522
  instruction: string,
525
523
  results: TaskResult[],
526
- options: { runId: string; modelPolicy: ModelPolicySnapshot; signal: AbortSignal },
527
- ): Promise<TaskResult | undefined> {
524
+ options: { runId: string; signal: AbortSignal; select(): Promise<ResolvedTask>; assertOwner(): void },
525
+ ): Promise<{ result?: TaskResult; diagnostic?: string }> {
528
526
  const sections = results.map((result, index) => {
529
527
  // Typed handoff: validated structured results feed the synthesis child
530
528
  // clean JSON instead of prose tails.
@@ -553,49 +551,35 @@ async function runSynthesis(
553
551
  ...sections,
554
552
  ].filter(Boolean).join("\n\n");
555
553
  try {
556
- const { runSubagent } = await import("./runner.js");
557
- const route = resolveModelRoute(options.modelPolicy);
558
- // runSubagent is a trusted low-level SDK primitive. Re-validate the
559
- // internally constructed request here so synthesis remains inside the
560
- // Extension's modelPolicy boundary rather than becoming a bypass path.
561
- const approved = validateModelRequest(options.modelPolicy, {
562
- model: route.model,
563
- fallbackModels: [...route.fallbackModels],
564
- fallbackModelsProvided: true,
554
+ const selected = await options.select();
555
+ options.assertOwner();
556
+ const run = await runTasks([{ ...selected, task, label: "synthesis" }], {
557
+ semaphore: runtime.semaphore,
558
+ getPiCommand: runtime.getPiCommand,
559
+ sessionDir: runtime.config.sessionDir,
560
+ killGraceMs: runtime.config.killGraceMs,
561
+ locks: runtime.locks,
562
+ runId: `${options.runId}:synthesis`,
563
+ parentSessionKey: runtime.key,
564
+ signal: options.signal,
565
+ graceTurns: runtime.config.graceTurns,
566
+ maxRetries: runtime.config.maxRetries,
567
+ stallAfterMs: runtime.config.stallAfterMs,
568
+ stallKillAfterMs: runtime.config.stallKillAfterMs,
565
569
  });
566
- if (approved.error || !approved.route) throw new Error(approved.error ?? "synthesis model policy validation failed");
567
- const synth = await runSubagent(
568
- {
569
- task,
570
- label: "synthesis",
571
- profile: "review",
572
- canWrite: false,
573
- tools: ["read", ...CONTEXT_MANAGEMENT_TOOLS],
574
- model: approved.route.model,
575
- fallbackModels: [...approved.route.fallbackModels],
576
- thinking: approved.route.thinking ?? "low",
577
- timeoutMs: Math.min(runtime.config.defaultTimeoutMs, 5 * 60_000),
578
- maxTurns: 8,
579
- },
580
- {
581
- semaphore: runtime.semaphore,
582
- getPiCommand: runtime.getPiCommand,
583
- sessionDir: runtime.config.sessionDir,
584
- killGraceMs: runtime.config.killGraceMs,
585
- locks: runtime.locks,
586
- runId: `${options.runId}:synthesis`,
587
- parentSessionKey: runtime.key,
588
- signal: options.signal,
589
- },
590
- );
591
- if (synth.state !== "completed" && synth.state !== "partial") return undefined;
570
+ const synth = run.results[0]!;
571
+ // Even a failed paid synthesis is returned so its reported usage is never lost.
592
572
  synth.label = "synthesis";
593
- return synth;
594
- } catch {
595
- return undefined;
573
+ return { result: synth };
574
+ } catch (error) {
575
+ return { diagnostic: `Optional synthesis blocked: ${oneLine(error instanceof Error ? error.message : "routing or startup failed", 800)}` };
596
576
  }
597
577
  }
598
578
 
579
+ function synthesisDiagnostic(summary?: string): string | undefined {
580
+ return summary?.startsWith("Optional synthesis blocked:") ? summary.split("\n", 1)[0] : undefined;
581
+ }
582
+
599
583
  /** Compact completion payload for notification messages (LLM + renderer facing). */
600
584
  function buildCompletionDetails(runtime: SessionRuntime, runIds: string[]): CompletionDetails {
601
585
  const runs: CompletionDetailsRun[] = [];
@@ -712,9 +696,148 @@ export default function registerSubagent(pi: ExtensionAPI): void {
712
696
  // for further nesting. Accidental-recursion guard only; not a security boundary.
713
697
  if (parseSpawnPolicy(process.env[SPAWNS_ENV_VAR]).kind === "disabled") return;
714
698
 
699
+ function ownsRouting(runtime: SessionRuntime, generation: number): boolean {
700
+ return current === runtime && !runtime.closed && runtime.routingGeneration === generation
701
+ && sessionKey(runtime.ctx) === runtime.key;
702
+ }
703
+
704
+ const MAX_PENDING_ROUTING_RECEIPTS = 1024;
705
+
706
+ function reconcileRouting(runtime: SessionRuntime): void {
707
+ const visible = foldRoutingReceipts(activeEntries(runtime), [], runtime.key);
708
+ for (const [id, pending] of runtime.pendingRoutingEvents) {
709
+ const entry = visible.get(id);
710
+ if (entry && entry.timestamp >= pending.timestamp
711
+ && (pending.runId === undefined || entry.runId === pending.runId)
712
+ && (!pending.delivered || entry.delivered)
713
+ && JSON.stringify(entry.receipt) === JSON.stringify(pending.receipt)) {
714
+ runtime.pendingRoutingEvents.delete(id);
715
+ }
716
+ }
717
+ }
718
+
719
+ async function flushRouting(runtime: SessionRuntime): Promise<boolean> {
720
+ const generation = runtime.routingGeneration;
721
+ for (let attempt = 0; attempt < 3; attempt++) {
722
+ if (!ownsRouting(runtime, generation)) return runtime.pendingRoutingEvents.size === 0;
723
+ reconcileRouting(runtime);
724
+ if (!runtime.pendingRoutingEvents.size) return true;
725
+ for (const event of runtime.pendingRoutingEvents.values()) {
726
+ try { pi.appendEntry(ROUTING_ENTRY_TYPE, event); }
727
+ catch { /* A bounded persistence retry, never a repeated selector request. */ }
728
+ }
729
+ reconcileRouting(runtime);
730
+ if (!runtime.pendingRoutingEvents.size) return true;
731
+ await new Promise<void>((resolve) => { const timer = setTimeout(resolve, 20); timer.unref?.(); });
732
+ }
733
+ return false;
734
+ }
735
+
736
+ async function requireRoutingPersistence(runtime: SessionRuntime): Promise<void> {
737
+ if (!(await flushRouting(runtime))) fail("Routing receipts could not be durably confirmed on the current branch. No new child was started. Restore session persistence and retry; selector usage may already have been incurred.");
738
+ }
739
+
740
+ function recordRouting(runtime: SessionRuntime, generation: number, receipt: RoutingReceipt, runId?: string, delivered?: boolean): void {
741
+ if (!ownsRouting(runtime, generation)) fail("Routing owner changed; stale receipts cannot be appended into another session.");
742
+ const event = buildRoutingEvent(runtime.key, receipt, runId, delivered);
743
+ runtime.pendingRoutingEvents.set(receipt.requestId, event);
744
+ runtime.ledgerDirty = true;
745
+ try { pi.appendEntry(ROUTING_ENTRY_TYPE, event); }
746
+ catch { /* Keep the staged receipt; the bounded flush owns persistence retries. */ }
747
+ reconcileRouting(runtime);
748
+ }
749
+
750
+ async function claimRoutingUsage(runtime: SessionRuntime, options: { ids?: ReadonlySet<string>; runId?: string }): Promise<UsageStats> {
751
+ const generation = runtime.routingGeneration;
752
+ await requireRoutingPersistence(runtime);
753
+ if (!ownsRouting(runtime, generation)) fail("Routing delivery belongs to a previous session/branch.");
754
+ const folded = foldRoutingReceipts(activeEntries(runtime), [...runtime.pendingRoutingEvents.values()], runtime.key);
755
+ const selected = [...folded.values()].filter((entry) => !entry.delivered
756
+ && (!options.ids || options.ids.has(entry.requestId)) && (!options.runId || entry.runId === options.runId));
757
+ if (!options.runId && selected.length > MAX_ROUTING_DELIVERY_IDS) fail(`Native routing delivery exceeds ${MAX_ROUTING_DELIVERY_IDS} selector receipts. Split this plan/background request into smaller invocations; selector usage is retained in the ledger.`);
758
+ if (!options.runId && selected.length) {
759
+ // Plan and async-start have no run-delivery transaction. Commit the entire
760
+ // native attachment as one event, so a throwing append cannot consume a prefix.
761
+ const event = { schemaVersion: 1, kind: "native-delivery", sessionKey: runtime.key,
762
+ timestamp: Date.now(), requestIds: selected.map((entry) => entry.requestId) };
763
+ let persisted = false;
764
+ for (let attempt = 0; attempt < 3; attempt++) {
765
+ if (!ownsRouting(runtime, generation)) fail("Routing delivery belongs to a previous session/branch.");
766
+ try { pi.appendEntry(ROUTING_ENTRY_TYPE, event); persisted = true; break; }
767
+ catch { if (attempt < 2) await new Promise<void>((resolve) => { const timer = setTimeout(resolve, 20); timer.unref?.(); }); }
768
+ }
769
+ if (!persisted) fail("Routing usage delivery could not be persisted; run results remain collectable and selector requests were not retried.");
770
+ // Cover delayed getBranch visibility after a successful append. Reconciliation
771
+ // removes these overlays once the single batch delivery event is exposed.
772
+ for (const entry of selected) runtime.pendingRoutingEvents.set(entry.requestId,
773
+ { ...buildRoutingEvent(runtime.key, entry.receipt, entry.runId, true), timestamp: entry.timestamp });
774
+ runtime.ledgerDirty = true;
775
+ reconcileRouting(runtime);
776
+ }
777
+ // Linked run receipts are consumed by registry.markDelivered's single event.
778
+ // The caller performs that synchronous commit only after this await succeeds.
779
+ return routingUsage(selected.map((entry) => entry.receipt));
780
+ }
781
+
782
+ function beginRouting(runtime: SessionRuntime, signal?: AbortSignal, runId?: string) {
783
+ const generation = runtime.routingGeneration;
784
+ const controller = new AbortController();
785
+ const receipts = new Map<string, RoutingReceipt>();
786
+ const onAbort = () => controller.abort();
787
+ if (signal?.aborted) controller.abort();
788
+ else signal?.addEventListener("abort", onAbort, { once: true });
789
+ let done!: () => void;
790
+ const settled = new Promise<void>((resolve) => { done = resolve; });
791
+ runtime.pendingRoutes.set(controller, settled);
792
+ let finished = false;
793
+ return {
794
+ controller, generation, receipts,
795
+ assertOwner() {
796
+ if (!ownsRouting(runtime, generation) || runtime.routingPaused || controller.signal.aborted) {
797
+ fail("Subagent routing cancelled or its session/branch changed; no child was started.");
798
+ }
799
+ },
800
+ record(receipt: RoutingReceipt) {
801
+ receipts.set(receipt.requestId, receipt);
802
+ try { recordRouting(runtime, generation, receipt, runId); }
803
+ finally {
804
+ if (runtime.pendingRoutingEvents.size > MAX_PENDING_ROUTING_RECEIPTS) {
805
+ controller.abort();
806
+ fail("Routing receipt persistence backlog reached its local bound; further selector requests were cancelled.");
807
+ }
808
+ }
809
+ },
810
+ finish() {
811
+ if (finished) return;
812
+ finished = true;
813
+ signal?.removeEventListener("abort", onAbort);
814
+ runtime.pendingRoutes.delete(controller);
815
+ done();
816
+ },
817
+ };
818
+ }
819
+
820
+ async function stopPendingRouting(runtime: SessionRuntime): Promise<void> {
821
+ runtime.routingPaused = true;
822
+ const pending = [...runtime.pendingRoutes];
823
+ for (const [controller] of pending) controller.abort();
824
+ let timer: NodeJS.Timeout | undefined;
825
+ try {
826
+ await Promise.race([
827
+ Promise.allSettled(pending.map(([, done]) => done)),
828
+ new Promise<void>((resolve) => { timer = setTimeout(resolve, 8_000); timer.unref?.(); }),
829
+ ]);
830
+ } finally {
831
+ if (timer) clearTimeout(timer);
832
+ }
833
+ }
834
+
715
835
  async function teardown(runtime: SessionRuntime): Promise<void> {
716
836
  if (runtime.closed) return;
837
+ await stopPendingRouting(runtime);
717
838
  await runtime.registry.shutdown(runtime.key, 8_000);
839
+ if (!(await flushRouting(runtime))) runtime.ctx.ui.notify("Subagent routing receipts could not be persisted before shutdown; selector usage may be missing from durable history.", "error");
840
+ runtime.routingGeneration++;
718
841
  runtime.closed = true;
719
842
  runtime.unsubscribe?.();
720
843
  runtime.unsubscribeLedger?.();
@@ -727,14 +850,8 @@ export default function registerSubagent(pi: ExtensionAPI): void {
727
850
  }
728
851
 
729
852
  pi.on("before_agent_start", async (event) => {
730
- let policy: ModelPolicySnapshot | undefined;
731
- let error: string | undefined;
732
- try {
733
- policy = await readModelPolicyFile();
734
- } catch (caught) {
735
- error = caught instanceof Error ? caught.message : String(caught);
736
- }
737
- return { systemPrompt: `${event.systemPrompt}\n\n${formatModelPolicyPrompt(policy, error)}` };
853
+ const config = loadConfig(await readConfigFile());
854
+ return { systemPrompt: `${event.systemPrompt}\n\n${formatJevRoutingPrompt(config.jevRouting, config.jevRoutingError)}` };
738
855
  });
739
856
 
740
857
  pi.on("session_start", async (_event, ctx) => {
@@ -757,6 +874,11 @@ export default function registerSubagent(pi: ExtensionAPI): void {
757
874
  runtime.agents = discoverAgents(ctx.cwd);
758
875
  runtime.agentsLoadedAt = Date.now();
759
876
  runtime.pendingRootMessages = [];
877
+ runtime.routingGeneration = 0;
878
+ runtime.routingPaused = false;
879
+ runtime.pendingRoutes = new Map();
880
+ runtime.pendingRoutingEvents = new Map();
881
+ runtime.reconcileRouting = () => reconcileRouting(runtime);
760
882
  runtime.ledgerDirty = true;
761
883
  runtime.closed = false;
762
884
  runtime.registry = new SessionScopedRunRegistry(runtime.config, {
@@ -857,12 +979,22 @@ export default function registerSubagent(pi: ExtensionAPI): void {
857
979
  const runtime = current;
858
980
  if (!runtime || runtime.closed) return;
859
981
  // Finish persistence on the originating leaf before Pi moves the branch pointer.
982
+ await stopPendingRouting(runtime);
860
983
  await runtime.registry.shutdown(runtime.key, 8_000);
984
+ if (!(await flushRouting(runtime))) {
985
+ runtime.routingPaused = false;
986
+ runtime.ctx.ui.notify("Branch change cancelled: routing receipts are not durably confirmed. Restore session persistence before changing branches.", "error");
987
+ return { cancel: true };
988
+ }
989
+ runtime.routingGeneration++;
861
990
  });
862
991
 
863
992
  pi.on("session_tree", async () => {
864
993
  const runtime = current;
865
994
  if (!runtime || runtime.closed) return;
995
+ runtime.routingPaused = false;
996
+ if (runtime.pendingRoutingEvents.size) runtime.ctx.ui.notify("Unconfirmed routing receipts remained during an unexpected branch move; they cannot be appended to the new branch. Durable selector usage may be incomplete.", "error");
997
+ runtime.pendingRoutingEvents.clear();
866
998
  runtime.pendingRootMessages = [];
867
999
  runtime.ledgerDirty = true;
868
1000
  runtime.registry.refreshSnapshots(runtime.key);
@@ -972,28 +1104,35 @@ export default function registerSubagent(pi: ExtensionAPI): void {
972
1104
  fail(`Invalid parameters: ${errors}`);
973
1105
  }
974
1106
 
975
- // Re-read the policy on every dispatch so changes take effect without
976
- // restarting the parent session; no provider catalog or credentials are
977
- // loaded by this path.
978
- const dispatchConfig = loadConfig(await readConfigFile());
979
- const model = ctx.model;
980
- const validated = validateSubagentRequest(params, {
1107
+ const requestGeneration = runtime.routingGeneration;
1108
+ const invocationStartedAt = Date.now();
1109
+ const management = params.action !== undefined && params.action !== "plan";
1110
+ const routingScope = management ? undefined : beginRouting(runtime, signal);
1111
+ try {
1112
+ routingScope?.assertOwner();
1113
+ if (routingScope) { await requireRoutingPersistence(runtime); routingScope.assertOwner(); }
1114
+ // Management does not read a config file, credential or model/tool catalog.
1115
+ const dispatchConfig = management ? runtime.config : loadConfig(await readConfigFile());
1116
+ routingScope?.assertOwner();
1117
+ const parentTools = management ? [] : pi.getAllTools()
1118
+ .filter((tool) => tool.sourceInfo?.source !== "sdk" && !tool.sourceInfo?.path?.startsWith("<sdk:"));
1119
+ const parent: ParentContext = {
981
1120
  cwd: ctx.cwd,
982
- model: model ? `${model.provider}/${model.id}` : undefined,
983
- thinking: pi.getThinkingLevel() as TaskSpec["thinking"],
984
- availableTools: pi.getAllTools().map((tool) => tool.name),
985
- activeTools: pi.getActiveTools(),
986
- depth: parseDepth(),
1121
+ thinking: management ? undefined : pi.getThinkingLevel() as TaskSpec["thinking"],
1122
+ availableTools: parentTools.map((tool) => tool.name),
1123
+ depth: runtime.depth,
987
1124
  sessionFile: ctx.sessionManager.getSessionFile() ?? undefined,
988
- }, {
1125
+ };
1126
+ const preparation: PreparationOptions = {
989
1127
  maxDepth: runtime.config.maxDepth,
990
1128
  maxTasks: runtime.config.maxTasksPerRun,
991
1129
  defaultTimeoutMs: runtime.config.defaultTimeoutMs,
992
1130
  taskDefaults: dispatchConfig.taskDefaults,
993
- agents: agentCatalog(runtime),
994
- modelPolicy: dispatchConfig.modelPolicy,
995
- modelPolicyError: dispatchConfig.modelPolicyError,
996
- });
1131
+ agents: management ? undefined : agentCatalog(runtime),
1132
+ jevRouting: dispatchConfig.jevRouting,
1133
+ jevRoutingError: dispatchConfig.jevRoutingError,
1134
+ };
1135
+ const validated = validateSubagentRequest(params, parent, preparation);
997
1136
  if (!validated.ok) fail(validated.error);
998
1137
 
999
1138
  const details = (mode: "single" | "parallel", results: Array<TaskResult | RunSnapshot["results"][number]>, run?: RunMeta) =>
@@ -1146,63 +1285,90 @@ export default function registerSubagent(pi: ExtensionAPI): void {
1146
1285
  };
1147
1286
  }
1148
1287
  }
1288
+ if (!ownsRouting(runtime, requestGeneration)) fail("Wait belonged to a previous session/branch; collect the run from its originating branch.");
1149
1289
  const refreshed = runtime.registry.lookup(snapshot.id, runtime.key);
1150
1290
  const terminal = refreshed.status === "found" && refreshed.run
1151
1291
  ? "controller" in refreshed.run ? snapshotFromLiveRun(refreshed.run) : refreshed.run
1152
1292
  : snapshot;
1293
+ const selectorUsage = await claimRoutingUsage(runtime, { runId: terminal.id });
1294
+ if (!ownsRouting(runtime, requestGeneration)) fail("Wait belonged to a previous session/branch.");
1153
1295
  if (!runtime.registry.markDelivered(terminal.id, runtime.key)) {
1154
1296
  return { content: [{ type: "text", text: `Run ${terminal.id} was already delivered. Artifacts and sessions remain available in /subagents.` }], details: details(terminal.mode, terminal.results, terminal) };
1155
1297
  }
1156
1298
  const delivered = runtime.output.capOutputForDelivery(terminal.results);
1157
- const text = delivered.text || terminal.summary || "(no output)";
1299
+ const text = [synthesisDiagnostic(terminal.summary), delivered.text || terminal.summary || "(no output)"].filter(Boolean).join("\n\n");
1158
1300
  // Locate runs and partial/timeout deliveries still return content; hard
1159
1301
  // failures and “lost with resume blocked” raise so the agent notices.
1160
1302
  // (Thrown deliveries cannot carry native usage; the extension ledger
1161
1303
  // still counts them from persisted entries.)
1162
1304
  if (terminal.state === "failed" || terminal.state === "lost") fail(text);
1163
- return deliveredResult(text, details(terminal.mode, delivered.cappedResults as any, terminal), terminal.results);
1305
+ return deliveredResult(text, details(terminal.mode, delivered.cappedResults as any, terminal), terminal.results, selectorUsage);
1164
1306
  }
1165
1307
 
1308
+ if (!routingScope) fail("Internal routing scope is missing.");
1309
+ routingScope.assertOwner();
1310
+ const catalog: RoutingCatalog = {
1311
+ models: eligibleModelCandidates(dispatchConfig.jevRouting!, ctx.modelRegistry.getAvailable().map((model) => `${model.provider}/${model.id}`)),
1312
+ tools: toToolCandidates(parentTools),
1313
+ };
1314
+ if (!catalog.models.length) fail("No configured Jev candidate is locally available. Check exact model IDs and configured provider authentication.");
1315
+ const router = new JevRouter({ config: dispatchConfig.jevRouting!, onReceipt: routingScope.record });
1316
+ const prepared = validated.tasks.map((task) => ({ ...task, deadline: invocationStartedAt + task.timeoutMs }));
1317
+ await runPlanPreflights(runtime, prepared, ctx.cwd, routingScope);
1318
+ routingScope.assertOwner();
1319
+ const resolved = await routePreparedTasks(prepared, catalog, router, {
1320
+ purpose: validated.planOnly ? "plan" : "dispatch",
1321
+ signal: routingScope.controller.signal, assertOwner: routingScope.assertOwner,
1322
+ });
1323
+ routingScope.assertOwner();
1324
+ const prepareSynthesis = () => {
1325
+ const normalized = validateSubagentRequest({
1326
+ task: `Synthesize completed worker outputs into one read-only brief. Instruction: ${validated.synthesis}`,
1327
+ description: "synthesis", profile: "review", max_turns: 8,
1328
+ timeout_ms: Math.min(runtime.config.defaultTimeoutMs, 5 * 60_000),
1329
+ }, parent, preparation);
1330
+ if (!normalized.ok) fail(normalized.error);
1331
+ return normalized.tasks.map((task) => ({ ...task, deadline: Date.now() + task.timeoutMs }));
1332
+ };
1166
1333
  if (validated.planOnly) {
1167
- await runPlanPreflights(runtime, validated.tasks, ctx.cwd);
1168
- const plan = validated.tasks.map((task, index) => formatPlanEntry(task, index));
1334
+ let synthesis: { state: "resolved"; plan: ReturnType<typeof formatPlanEntry> } | { state: "blocked"; error: string } | undefined;
1335
+ if (validated.synthesis && prepared.length > 1) {
1336
+ try {
1337
+ const synthetic = prepareSynthesis();
1338
+ await runPlanPreflights(runtime, synthetic, ctx.cwd, routingScope);
1339
+ const planned = await routePreparedTasks(synthetic, catalog, router, {
1340
+ purpose: "plan", signal: routingScope.controller.signal, assertOwner: routingScope.assertOwner,
1341
+ });
1342
+ synthesis = { state: "resolved", plan: formatPlanEntry(planned[0]!, 0) };
1343
+ } catch (error) {
1344
+ routingScope.assertOwner();
1345
+ synthesis = { state: "blocked", error: error instanceof Error ? error.message : "Optional synthesis routing failed." };
1346
+ }
1347
+ }
1348
+ routingScope.assertOwner();
1349
+ await requireRoutingPersistence(runtime);
1350
+ routingScope.assertOwner();
1351
+ const plan = resolved.map((task, index) => formatPlanEntry(task, index));
1169
1352
  const mode = validated.mode as "single" | "parallel";
1170
- return {
1171
- content: [{ type: "text", text: formatPlanText(mode, plan) }],
1172
- details: { mode, plan },
1173
- };
1353
+ const receipts = [...routingScope.receipts.values()];
1354
+ const selectorUsage = await claimRoutingUsage(runtime, { ids: new Set(receipts.map((receipt) => receipt.requestId)) });
1355
+ const text = [formatPlanText(mode, plan), synthesis ? `Optional synthesis: ${synthesis.state}${synthesis.state === "blocked" ? ` — ${synthesis.error}` : ` (${synthesis.plan.model})`}` : "", "Selector tokens are reported separately; TypeSafe currency is unreported. A later dispatch selects again."].filter(Boolean).join("\n");
1356
+ return deliveredResult(text, { mode, plan, synthesis, routingReceipts: receipts, routingCurrency: "unreported" }, [], selectorUsage);
1174
1357
  }
1175
1358
 
1176
- const specs: TaskSpec[] = validated.tasks.map((task: ResolvedTask) => ({
1177
- task: task.task,
1178
- label: task.label,
1179
- systemPrompt: task.systemPrompt,
1180
- model: task.model,
1181
- thinking: task.thinking,
1182
- tools: task.effectiveTools,
1183
- profile: task.profile,
1184
- canWrite: task.canWrite,
1185
- cwd: task.cwd,
1186
- timeoutMs: task.timeoutMs,
1187
- maxTurns: task.maxTurns,
1188
- maxCost: task.maxCost,
1189
- output: task.output,
1190
- outputMode: task.outputMode,
1191
- outputSchema: task.outputSchema,
1192
- resume: task.resume,
1193
- forkResume: task.forkResume,
1194
- isolation: task.isolation,
1195
- allowSharedWrites: task.allowSharedWrites,
1196
- keepBackground: task.keepBackground,
1197
- graceTurns: task.graceTurns,
1198
- fallbackModels: task.fallbackModels,
1199
- maxRetries: task.maxRetries,
1200
- contextFork: task.contextFork,
1201
- parentSessionFile: task.parentSessionFile,
1202
- spawns: task.spawns,
1359
+ const specs: TaskSpec[] = resolved.map(({ effectiveTools, resolutionNotes: _notes, ...task }) => ({
1360
+ ...task, tools: effectiveTools, fallbackModels: [],
1203
1361
  }));
1362
+ await requireRoutingPersistence(runtime);
1363
+ routingScope.assertOwner();
1364
+ if (validated.async && routingScope.receipts.size > MAX_ROUTING_DELIVERY_IDS) fail(`Background routing exceeds ${MAX_ROUTING_DELIVERY_IDS} selector receipts. No child was started; split this request into smaller invocations. Selector usage is retained in the ledger.`);
1365
+ const executionGeneration = routingScope.generation;
1204
1366
  const runId = runtime.registry.allocateRunId();
1205
- const directResumes = validated.tasks.filter((task) => task.resume && !task.forkResume).map((task) => task.resume!);
1367
+ const workerReceiptIds = new Set(routingScope.receipts.keys());
1368
+ for (const receipt of routingScope.receipts.values()) recordRouting(runtime, executionGeneration, receipt, runId);
1369
+ await requireRoutingPersistence(runtime);
1370
+ routingScope.assertOwner();
1371
+ const directResumes = resolved.filter((task) => task.resume && !task.forkResume).map((task) => task.resume!);
1206
1372
  const lock = runtime.registry.acquireResumeLocks(directResumes, runId, runtime.key);
1207
1373
  if (!lock.ok) fail(`Child session ${lock.conflict!.sessionId} is already active in run ${lock.conflict!.runId}. Use fork_resume:true for an independent continuation.`);
1208
1374
 
@@ -1213,12 +1379,15 @@ export default function registerSubagent(pi: ExtensionAPI): void {
1213
1379
  let resolveDone!: () => void;
1214
1380
  const done = new Promise<void>((resolve) => { resolveDone = resolve; });
1215
1381
  try {
1216
- runtime.registry.start(runtime.key, validated.mode as "single" | "parallel", specs, controller, done, validated.tasks.map((task) => task.label), runId);
1382
+ runtime.registry.start(runtime.key, validated.mode as "single" | "parallel", specs, controller, done, resolved.map((task) => task.label), runId);
1217
1383
  } catch (error) {
1384
+ signal?.removeEventListener("abort", parentAbort);
1218
1385
  for (const session of directResumes) runtime.registry.releaseResumeLock(session, runtime.key, runId);
1219
1386
  throw error;
1220
1387
  }
1221
1388
 
1389
+ routingScope.finish(); // Ownership transfers to the registered run/controller.
1390
+
1222
1391
  // Throttle streamed tool updates with a trailing-edge flush: structural
1223
1392
  // changes (state transition, new session id, billed turn) emit
1224
1393
  // immediately; live-text ticks coalesce into at most one deferred emit
@@ -1282,7 +1451,7 @@ export default function registerSubagent(pi: ExtensionAPI): void {
1282
1451
  runners.set(index, runner);
1283
1452
  },
1284
1453
  onTaskProgress: (index, partial) => {
1285
- if (runtime.closed || current !== runtime) return;
1454
+ if (!ownsRouting(runtime, executionGeneration)) return;
1286
1455
  // Keep the durable run record's childSessionId in sync the first
1287
1456
  // time we learn it (also used by orphan reclaim).
1288
1457
  if (partial.sessionId && partial.process) {
@@ -1320,22 +1489,56 @@ export default function registerSubagent(pi: ExtensionAPI): void {
1320
1489
  // single brief, delivered first. Failures degrade to raw results.
1321
1490
  if (validated.synthesis && result.results.length > 1 && !controller.signal.aborted) {
1322
1491
  const synthesized = await runSynthesis(runtime, validated.synthesis, result.results, {
1323
- runId,
1324
- modelPolicy: dispatchConfig.modelPolicy!,
1325
- signal: controller.signal,
1492
+ runId, signal: controller.signal,
1493
+ assertOwner() {
1494
+ if (!ownsRouting(runtime, executionGeneration) || runtime.routingPaused || controller.signal.aborted) fail("Synthesis cancelled or its session changed.");
1495
+ },
1496
+ async select() {
1497
+ const scope = beginRouting(runtime, controller.signal, runId);
1498
+ try {
1499
+ scope.assertOwner();
1500
+ const preparedSynthesis = prepareSynthesis();
1501
+ await runPlanPreflights(runtime, preparedSynthesis, ctx.cwd, scope);
1502
+ scope.assertOwner();
1503
+ const selected = await routePreparedTasks(preparedSynthesis, catalog,
1504
+ new JevRouter({ config: dispatchConfig.jevRouting!, onReceipt: scope.record }),
1505
+ { purpose: "synthesis", signal: scope.controller.signal, assertOwner: scope.assertOwner });
1506
+ await requireRoutingPersistence(runtime);
1507
+ scope.assertOwner();
1508
+ return selected[0]!;
1509
+ } finally { scope.finish(); }
1510
+ },
1326
1511
  });
1327
- if (synthesized) result.results = [synthesized, ...result.results];
1512
+ if (synthesized.result) result.results = [synthesized.result, ...result.results];
1513
+ if (synthesized.diagnostic) result.summary = `${synthesized.diagnostic}\n\n${result.summary}`;
1328
1514
  }
1329
- runtime.registry.complete(runId, runtime.key, result.state, result.summary, result.results);
1515
+ if (ownsRouting(runtime, executionGeneration)) runtime.registry.complete(runId, runtime.key, result.state, result.summary, result.results);
1330
1516
  return result;
1331
- } catch (error: any) {
1332
- const failed: TaskResult = {
1333
- label: "task-1", task: specs[0]?.task ?? "", state: "failed", exitCode: 1,
1334
- messages: [], stderr: "", usage: emptyUsage(), stopReason: "error", errorMessage: error?.message ?? String(error),
1335
- protocol: { headerSeen: false, assistantEndSeen: false, agentEndSeen: false, agentSettledSeen: false, validEvents: 0, parseErrors: 0 },
1336
- };
1337
- runtime.registry.complete(runId, runtime.key, "failed", failed.errorMessage, [failed]);
1338
- return { mode: "single" as const, results: [failed], state: "failed" as const, summary: failed.errorMessage! };
1517
+ } catch (error: unknown) {
1518
+ controller.abort();
1519
+ const message = error instanceof Error ? error.message : String(error);
1520
+ const live = runtime.registry.getLiveRuns(runtime.key).find((run) => run.id === runId);
1521
+ // Preserve every routed task and any usage already checkpointed. Never collapse
1522
+ // a failed fanout to a synthetic, unbilled task-1 result.
1523
+ const results = specs.map<TaskResult>((spec, index) => {
1524
+ const previous = live?.results[index];
1525
+ return {
1526
+ ...previous,
1527
+ index, label: spec.label || `task-${index + 1}`, task: spec.task,
1528
+ model: previous?.model ?? spec.model, routing: spec.routing,
1529
+ thinking: spec.thinking, profile: spec.profile, backend: spec.backend ?? "pi",
1530
+ canWrite: spec.canWrite, outputFile: previous?.outputFile ?? spec.output, outputMode: spec.outputMode,
1531
+ state: previous && !isActiveState(previous.state) ? previous.state : "failed",
1532
+ exitCode: previous && !isActiveState(previous.state) ? previous.exitCode : 1,
1533
+ messages: previous?.messages ?? [], stderr: previous?.stderr ?? "",
1534
+ usage: previous?.usage ?? emptyUsage(), stopReason: previous?.stopReason ?? "error",
1535
+ errorMessage: [previous?.errorMessage, message].filter(Boolean).join("; "),
1536
+ protocol: previous?.protocol ?? { headerSeen: false, assistantEndSeen: false, agentEndSeen: false, agentSettledSeen: false, validEvents: 0, parseErrors: 0 },
1537
+ };
1538
+ });
1539
+ const state = results.some((result) => result.state === "completed" || result.state === "partial") ? "partial" as const : "failed" as const;
1540
+ if (ownsRouting(runtime, executionGeneration)) runtime.registry.complete(runId, runtime.key, state, message, results);
1541
+ return { mode: validated.mode as "single" | "parallel", results, state, summary: message };
1339
1542
  } finally {
1340
1543
  if (pendingFlush) { clearTimeout(pendingFlush); pendingFlush = undefined; }
1341
1544
  runtime.liveRunners.delete(runId);
@@ -1347,14 +1550,19 @@ export default function registerSubagent(pi: ExtensionAPI): void {
1347
1550
 
1348
1551
  if (validated.async) {
1349
1552
  runtime.asyncRuns.add(runId);
1350
- return { content: [{ type: "text", text: `Started run ${runId}. You will be notified on completion; use status/wait/cancel with this full id, or open /subagents.` }], details: details(validated.mode as "single" | "parallel", []) };
1553
+ const selectorUsage = await claimRoutingUsage(runtime, { ids: workerReceiptIds });
1554
+ return deliveredResult(`Started run ${runId}. You will be notified on completion; use status/wait/cancel with this full id, or open /subagents. Selector currency is unreported.`,
1555
+ { ...details(validated.mode as "single" | "parallel", []), routingReceipts: [...routingScope.receipts.values()], routingCurrency: "unreported" }, [], selectorUsage);
1351
1556
  }
1352
1557
  const result = await work;
1558
+ if (!ownsRouting(runtime, executionGeneration)) fail("Subagent execution belonged to a previous session/branch; its final state remains on the originating branch.");
1353
1559
  // First delivery wins the native usage attachment: a rare concurrent
1354
1560
  // wait/dismiss that already consumed this run must not double-bill.
1561
+ const selectorUsage = await claimRoutingUsage(runtime, { runId });
1562
+ if (!ownsRouting(runtime, executionGeneration)) fail("Delivery belonged to a previous session/branch.");
1355
1563
  const firstDelivery = runtime.registry.markDelivered(runId, runtime.key);
1356
1564
  const delivered = runtime.output.capOutputForDelivery(result.results);
1357
- const text = delivered.text || result.summary;
1565
+ const text = [synthesisDiagnostic(result.summary), delivered.text || result.summary].filter(Boolean).join("\n\n");
1358
1566
  const finished = runtime.registry.lookup(runId, runtime.key);
1359
1567
  const meta: RunMeta | undefined = finished.status === "found" && finished.run && !("controller" in finished.run)
1360
1568
  ? finished.run
@@ -1363,8 +1571,11 @@ export default function registerSubagent(pi: ExtensionAPI): void {
1363
1571
  if (result.state === "failed") fail(text);
1364
1572
  const resultDetails = details(result.mode, delivered.cappedResults as any, meta);
1365
1573
  return firstDelivery
1366
- ? deliveredResult(text, resultDetails, result.results)
1574
+ ? deliveredResult(text, resultDetails, result.results, selectorUsage)
1367
1575
  : { content: [{ type: "text", text }], details: resultDetails };
1576
+ } finally {
1577
+ routingScope?.finish();
1578
+ }
1368
1579
  },
1369
1580
  renderCall(args, theme, context) {
1370
1581
  // Stable component identity: reuse the previous block and swap content.
@@ -1375,7 +1586,7 @@ export default function registerSubagent(pi: ExtensionAPI): void {
1375
1586
  renderResult(result, options: ToolRenderResultOptions, theme, context) {
1376
1587
  const block = (context.lastComponent instanceof LineBlock ? context.lastComponent : new LineBlock()) as LineBlock;
1377
1588
  const detailsValue = result.details as ReturnType<typeof compactDetails> | undefined;
1378
- if (!detailsValue?.results.length) {
1589
+ if (!detailsValue?.results?.length) {
1379
1590
  const text = result.content.find((item) => item.type === "text")?.text ?? "(no output)";
1380
1591
  block.set((width) => String(text).split("\n").map((line) => truncateToWidth(theme.fg("toolOutput", line), width)));
1381
1592
  return block;
@@ -1390,6 +1601,7 @@ export default function registerSubagent(pi: ExtensionAPI): void {
1390
1601
  state: task.state,
1391
1602
  usage: task.usage,
1392
1603
  model: task.model,
1604
+ routing: task.routing,
1393
1605
  stopReason: task.stopReason,
1394
1606
  timeoutPhase: task.timeoutPhase,
1395
1607
  errorMessage: task.errorMessage,
@@ -1478,16 +1690,12 @@ export default function registerSubagent(pi: ExtensionAPI): void {
1478
1690
  try {
1479
1691
  // Reuse the tool's own execute so /btw inherits validation, profiles,
1480
1692
  // budgets, semaphore + process locks, and output capping unchanged.
1481
- const policy = await readModelPolicyFile().catch(() => undefined);
1482
- const route = policy ? resolveModelRoute(policy) : undefined;
1483
1693
  const result = await subagentTool.execute(
1484
1694
  `btw-${Date.now()}`,
1485
1695
  {
1486
1696
  task: question,
1487
1697
  profile: "explore",
1488
1698
  description: label,
1489
- model: route?.model,
1490
- fallback_models: route?.fallbackModels,
1491
1699
  } as SubagentParams,
1492
1700
  undefined,
1493
1701
  undefined,