@cr1ms0n/pi-subagent 0.8.8 → 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";
@@ -22,15 +21,9 @@ import {
22
21
  import { createGetPiCommand, getLaunchResolution } from "./launch.js";
23
22
  import { abortAsPromise } from "./maintenance.js";
24
23
  import { sweepSessionsLifecycle } from "./distill.js";
25
- import { runTasksWithContextPolicy } from "./orchestrator.js";
24
+ import { runTasks } from "./orchestrator.js";
26
25
  import { OutputManager } from "./output.js";
27
- import {
28
- CONTEXT_MANAGEMENT_TOOL_NAMES,
29
- readContextManagementPolicy,
30
- synthesisToolsForModel,
31
- type ContextManagementPolicy,
32
- } from "./context-policy.js";
33
- import { 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";
34
27
  import type { ChildRunner } from "./runner.js";
35
28
  import { ProcessLockManager } from "./process-lock.js";
36
29
  import { SessionScopedRunRegistry, snapshotFromLiveRun } from "./registry.js";
@@ -46,13 +39,18 @@ import { BTW_ENTRY_TYPE, btwLabel, type BtwEntry } from "./btw.js";
46
39
  import { Semaphore } from "./semaphore.js";
47
40
  import type { RunSnapshot, TaskResult, TaskSpec, UsageStats } from "./types.js";
48
41
  import { emptyUsage } from "./types.js";
49
- import { addUsage, buildUsageLedger, formatLedger, hasBilledUsage, toPiUsage, type UsageLedger } from "./usage.js";
42
+ import { addUsage, buildUsageLedger, formatLedger, hasBilledUsage, routingUsage, toPiUsage, type UsageLedger } from "./usage.js";
50
43
  import { resolveBackendSessionFilePath, resolveSessionFilePath } from "./transcript.js";
51
44
  import { CompletionBatcher, COMPLETION_MESSAGE_TYPE, type CompletionDetails, type CompletionDetailsRun, type CompletionDetailsTask } from "./notifications.js";
52
45
  import { describeCatalog, discoverAgents, type AgentDefinition } from "./agents.js";
53
46
  import { createSubagentsOverlay, FooterStatusModel, type SubagentAdapter } from "./ui.js";
54
47
  import { WorktreeManager } from "./worktree.js";
55
- 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";
56
54
 
57
55
  interface SessionRuntime {
58
56
  key: string;
@@ -82,6 +80,11 @@ interface SessionRuntime {
82
80
  ledgerDirty: boolean;
83
81
  closed: boolean;
84
82
  depth: number;
83
+ routingGeneration: number;
84
+ routingPaused: boolean;
85
+ pendingRoutes: Map<AbortController, Promise<void>>;
86
+ pendingRoutingEvents: Map<string, PersistedRoutingEvent>;
87
+ reconcileRouting?: () => void;
85
88
  }
86
89
 
87
90
  function sessionKey(ctx: ExtensionContext): string {
@@ -98,6 +101,7 @@ function activeEntries(runtime: SessionRuntime): readonly unknown[] {
98
101
  * on every footer refresh or live-text tick.
99
102
  */
100
103
  function ledger(runtime: SessionRuntime): UsageLedger {
104
+ runtime.reconcileRouting?.();
101
105
  if (!runtime.ledgerDirty && runtime.ledgerValue) return runtime.ledgerValue;
102
106
  const entries = activeEntries(runtime);
103
107
  runtime.ledgerValue = buildUsageLedger(
@@ -107,6 +111,7 @@ function ledger(runtime: SessionRuntime): UsageLedger {
107
111
  ...runtime.registry.getSnapshots(runtime.key),
108
112
  ],
109
113
  runtime.pendingRootMessages,
114
+ [...runtime.pendingRoutingEvents.values()],
110
115
  );
111
116
  runtime.ledgerDirty = false;
112
117
  return runtime.ledgerValue;
@@ -299,6 +304,7 @@ function compactDetails(
299
304
  const perResultText = Math.max(256, Math.floor(maxDetailsTextBytes / Math.max(1, results.length) / 2));
300
305
  return {
301
306
  mode,
307
+ routingCurrency: results.some((result) => result.routing) ? "unreported" : undefined,
302
308
  state: run?.state,
303
309
  startedAt: run?.startedAt,
304
310
  endedAt: run?.endedAt,
@@ -312,6 +318,7 @@ function compactDetails(
312
318
  errorMessage: result.errorMessage?.slice(0, 1_000),
313
319
  usage: result.usage ?? emptyUsage(),
314
320
  model: result.model,
321
+ routing: result.routing,
315
322
  thinking: result.thinking,
316
323
  profile: result.profile,
317
324
  canWrite: result.canWrite,
@@ -386,8 +393,9 @@ function deliveredResult<TDetails>(
386
393
  text: string,
387
394
  details: TDetails,
388
395
  results: ReadonlyArray<{ usage: UsageStats }>,
396
+ selectorUsage: UsageStats = emptyUsage(),
389
397
  ): { content: Array<{ type: "text"; text: string }>; details: TDetails; usage?: Usage } {
390
- const total = addUsage(...results.map((result) => result.usage));
398
+ const total = addUsage(selectorUsage, ...results.map((result) => result.usage));
391
399
  return {
392
400
  content: [{ type: "text", text }],
393
401
  details,
@@ -411,33 +419,15 @@ function resumableSessionLines(
411
419
 
412
420
  async function runPlanPreflights(
413
421
  runtime: SessionRuntime,
414
- tasks: ResolvedTask[],
422
+ tasks: PreparedTask[],
415
423
  parentCwd: string,
424
+ scope: { controller: AbortController; assertOwner(): void },
416
425
  ): Promise<void> {
417
- for (let index = 0; index < tasks.length; index++) {
418
- const task = tasks[index]!;
419
- const taskCwd = task.cwd ?? parentCwd;
420
- if (task.isolation === "worktree") {
421
- if (!(await runtime.worktrees.isGitRepo(taskCwd))) {
422
- fail(`Task ${index + 1}: ${taskCwd} is not a git repository`);
423
- }
424
- }
425
- if (task.contextFork) {
426
- const sessionFile = task.parentSessionFile;
427
- if (!sessionFile) fail(`Task ${index + 1}: context:'fork' requires a persisted parent session file`);
428
- await fs.access(sessionFile).catch(() => {
429
- fail(`context:'fork' failed: parent session file ${sessionFile} is not readable.`);
430
- });
431
- }
432
- if (task.output) {
433
- const parentDir = path.dirname(task.output);
434
- const stat = await fs.stat(parentDir).catch(() => undefined);
435
- if (!stat?.isDirectory()) fail(`Task ${index + 1}: output parent directory does not exist: ${parentDir}`);
436
- await fs.access(parentDir, fsConstants.W_OK).catch(() => {
437
- fail(`Task ${index + 1}: output parent directory is not writable: ${parentDir}`);
438
- });
439
- }
440
- }
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
+ });
441
431
  }
442
432
 
443
433
  function formatPlanEntry(task: ResolvedTask, index: number) {
@@ -453,6 +443,7 @@ function formatPlanEntry(task: ResolvedTask, index: number) {
453
443
  profile: task.profile,
454
444
  access: task.canWrite ? "RW" : "RO" as const,
455
445
  tools: task.effectiveTools,
446
+ routing: task.routing,
456
447
  budgets: {
457
448
  timeoutMs: task.timeoutMs,
458
449
  maxTurns: task.maxTurns,
@@ -465,7 +456,7 @@ function formatPlanEntry(task: ResolvedTask, index: number) {
465
456
  }
466
457
 
467
458
  function formatPlanText(mode: "single" | "parallel", plan: ReturnType<typeof formatPlanEntry>[]): string {
468
- 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"}:`;
469
460
  const body = plan.map((entry) => {
470
461
  const budgets = [
471
462
  `timeout_ms=${entry.budgets.timeoutMs}`,
@@ -503,14 +494,15 @@ function guidelines(catalog?: Map<string, AgentDefinition>): string[] {
503
494
  : [];
504
495
  return [
505
496
  ...agentLines,
506
- "**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.",
507
499
  "Delegate independent, read-heavy exploration or clean-context review; keep tightly coupled work in the parent.",
508
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.",
509
501
  "Give every task a short description label (3-5 words) so runs are scannable in UIs and result indexes.",
510
- "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.",
511
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.",
512
- "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.",
513
- "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.",
514
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.",
515
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.",
516
508
  "For parallel research, add synthesis:'<instruction>' to have one read-only child fold all outputs into a single brief, delivered first.",
@@ -529,14 +521,8 @@ async function runSynthesis(
529
521
  runtime: SessionRuntime,
530
522
  instruction: string,
531
523
  results: TaskResult[],
532
- options: {
533
- runId: string;
534
- modelPolicy: ModelPolicySnapshot;
535
- contextPolicy: ContextManagementPolicy;
536
- parentContextTools: readonly string[];
537
- signal: AbortSignal;
538
- },
539
- ): Promise<TaskResult | undefined> {
524
+ options: { runId: string; signal: AbortSignal; select(): Promise<ResolvedTask>; assertOwner(): void },
525
+ ): Promise<{ result?: TaskResult; diagnostic?: string }> {
540
526
  const sections = results.map((result, index) => {
541
527
  // Typed handoff: validated structured results feed the synthesis child
542
528
  // clean JSON instead of prose tails.
@@ -565,51 +551,35 @@ async function runSynthesis(
565
551
  ...sections,
566
552
  ].filter(Boolean).join("\n\n");
567
553
  try {
568
- const { runSubagent } = await import("./runner.js");
569
- const route = resolveModelRoute(options.modelPolicy);
570
- // runSubagent is a trusted low-level SDK primitive. Re-validate the
571
- // internally constructed request here so synthesis remains inside the
572
- // Extension's modelPolicy boundary rather than becoming a bypass path.
573
- const approved = validateModelRequest(options.modelPolicy, {
574
- model: route.model,
575
- fallbackModels: [...route.fallbackModels],
576
- 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,
577
569
  });
578
- if (approved.error || !approved.route) throw new Error(approved.error ?? "synthesis model policy validation failed");
579
- const synth = await runSubagent(
580
- {
581
- task,
582
- label: "synthesis",
583
- profile: "review",
584
- canWrite: false,
585
- // Same exact target-model gate as ordinary children: a disallowed
586
- // synthesis model receives no context-manager tools at all.
587
- tools: synthesisToolsForModel(options.contextPolicy, approved.route.model, options.parentContextTools),
588
- model: approved.route.model,
589
- fallbackModels: [...approved.route.fallbackModels],
590
- thinking: approved.route.thinking ?? "low",
591
- timeoutMs: Math.min(runtime.config.defaultTimeoutMs, 5 * 60_000),
592
- maxTurns: 8,
593
- },
594
- {
595
- semaphore: runtime.semaphore,
596
- getPiCommand: runtime.getPiCommand,
597
- sessionDir: runtime.config.sessionDir,
598
- killGraceMs: runtime.config.killGraceMs,
599
- locks: runtime.locks,
600
- runId: `${options.runId}:synthesis`,
601
- parentSessionKey: runtime.key,
602
- signal: options.signal,
603
- },
604
- );
605
- 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.
606
572
  synth.label = "synthesis";
607
- return synth;
608
- } catch {
609
- 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)}` };
610
576
  }
611
577
  }
612
578
 
579
+ function synthesisDiagnostic(summary?: string): string | undefined {
580
+ return summary?.startsWith("Optional synthesis blocked:") ? summary.split("\n", 1)[0] : undefined;
581
+ }
582
+
613
583
  /** Compact completion payload for notification messages (LLM + renderer facing). */
614
584
  function buildCompletionDetails(runtime: SessionRuntime, runIds: string[]): CompletionDetails {
615
585
  const runs: CompletionDetailsRun[] = [];
@@ -726,9 +696,148 @@ export default function registerSubagent(pi: ExtensionAPI): void {
726
696
  // for further nesting. Accidental-recursion guard only; not a security boundary.
727
697
  if (parseSpawnPolicy(process.env[SPAWNS_ENV_VAR]).kind === "disabled") return;
728
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
+
729
835
  async function teardown(runtime: SessionRuntime): Promise<void> {
730
836
  if (runtime.closed) return;
837
+ await stopPendingRouting(runtime);
731
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++;
732
841
  runtime.closed = true;
733
842
  runtime.unsubscribe?.();
734
843
  runtime.unsubscribeLedger?.();
@@ -741,14 +850,8 @@ export default function registerSubagent(pi: ExtensionAPI): void {
741
850
  }
742
851
 
743
852
  pi.on("before_agent_start", async (event) => {
744
- let policy: ModelPolicySnapshot | undefined;
745
- let error: string | undefined;
746
- try {
747
- policy = await readModelPolicyFile();
748
- } catch (caught) {
749
- error = caught instanceof Error ? caught.message : String(caught);
750
- }
751
- 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)}` };
752
855
  });
753
856
 
754
857
  pi.on("session_start", async (_event, ctx) => {
@@ -771,6 +874,11 @@ export default function registerSubagent(pi: ExtensionAPI): void {
771
874
  runtime.agents = discoverAgents(ctx.cwd);
772
875
  runtime.agentsLoadedAt = Date.now();
773
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);
774
882
  runtime.ledgerDirty = true;
775
883
  runtime.closed = false;
776
884
  runtime.registry = new SessionScopedRunRegistry(runtime.config, {
@@ -871,12 +979,22 @@ export default function registerSubagent(pi: ExtensionAPI): void {
871
979
  const runtime = current;
872
980
  if (!runtime || runtime.closed) return;
873
981
  // Finish persistence on the originating leaf before Pi moves the branch pointer.
982
+ await stopPendingRouting(runtime);
874
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++;
875
990
  });
876
991
 
877
992
  pi.on("session_tree", async () => {
878
993
  const runtime = current;
879
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();
880
998
  runtime.pendingRootMessages = [];
881
999
  runtime.ledgerDirty = true;
882
1000
  runtime.registry.refreshSnapshots(runtime.key);
@@ -986,36 +1104,35 @@ export default function registerSubagent(pi: ExtensionAPI): void {
986
1104
  fail(`Invalid parameters: ${errors}`);
987
1105
  }
988
1106
 
989
- // Re-read the policy on every dispatch so changes take effect without
990
- // restarting the parent session; no provider catalog or credentials are
991
- // loaded by this path.
992
- const dispatchConfig = loadConfig(await readConfigFile());
993
- // One Remote Context snapshot per dispatch: the same operator-owned
994
- // gateway allowlist gates initial validation, every fallback attempt,
995
- // and the internally constructed synthesis child. Missing/invalid/off
996
- // toolkit configuration fails closed to an empty allowlist.
997
- const contextPolicy = await readContextManagementPolicy();
998
- const parentToolNames = pi.getAllTools().map((tool) => tool.name);
999
- const parentContextTools = CONTEXT_MANAGEMENT_TOOL_NAMES.filter((tool) => parentToolNames.includes(tool));
1000
- const model = ctx.model;
1001
- 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 = {
1002
1120
  cwd: ctx.cwd,
1003
- model: model ? `${model.provider}/${model.id}` : undefined,
1004
- thinking: pi.getThinkingLevel() as TaskSpec["thinking"],
1005
- availableTools: parentToolNames,
1006
- activeTools: pi.getActiveTools(),
1007
- depth: parseDepth(),
1121
+ thinking: management ? undefined : pi.getThinkingLevel() as TaskSpec["thinking"],
1122
+ availableTools: parentTools.map((tool) => tool.name),
1123
+ depth: runtime.depth,
1008
1124
  sessionFile: ctx.sessionManager.getSessionFile() ?? undefined,
1009
- }, {
1125
+ };
1126
+ const preparation: PreparationOptions = {
1010
1127
  maxDepth: runtime.config.maxDepth,
1011
1128
  maxTasks: runtime.config.maxTasksPerRun,
1012
1129
  defaultTimeoutMs: runtime.config.defaultTimeoutMs,
1013
1130
  taskDefaults: dispatchConfig.taskDefaults,
1014
- agents: agentCatalog(runtime),
1015
- modelPolicy: dispatchConfig.modelPolicy,
1016
- modelPolicyError: dispatchConfig.modelPolicyError,
1017
- contextPolicy,
1018
- });
1131
+ agents: management ? undefined : agentCatalog(runtime),
1132
+ jevRouting: dispatchConfig.jevRouting,
1133
+ jevRoutingError: dispatchConfig.jevRoutingError,
1134
+ };
1135
+ const validated = validateSubagentRequest(params, parent, preparation);
1019
1136
  if (!validated.ok) fail(validated.error);
1020
1137
 
1021
1138
  const details = (mode: "single" | "parallel", results: Array<TaskResult | RunSnapshot["results"][number]>, run?: RunMeta) =>
@@ -1168,64 +1285,90 @@ export default function registerSubagent(pi: ExtensionAPI): void {
1168
1285
  };
1169
1286
  }
1170
1287
  }
1288
+ if (!ownsRouting(runtime, requestGeneration)) fail("Wait belonged to a previous session/branch; collect the run from its originating branch.");
1171
1289
  const refreshed = runtime.registry.lookup(snapshot.id, runtime.key);
1172
1290
  const terminal = refreshed.status === "found" && refreshed.run
1173
1291
  ? "controller" in refreshed.run ? snapshotFromLiveRun(refreshed.run) : refreshed.run
1174
1292
  : snapshot;
1293
+ const selectorUsage = await claimRoutingUsage(runtime, { runId: terminal.id });
1294
+ if (!ownsRouting(runtime, requestGeneration)) fail("Wait belonged to a previous session/branch.");
1175
1295
  if (!runtime.registry.markDelivered(terminal.id, runtime.key)) {
1176
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) };
1177
1297
  }
1178
1298
  const delivered = runtime.output.capOutputForDelivery(terminal.results);
1179
- 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");
1180
1300
  // Locate runs and partial/timeout deliveries still return content; hard
1181
1301
  // failures and “lost with resume blocked” raise so the agent notices.
1182
1302
  // (Thrown deliveries cannot carry native usage; the extension ledger
1183
1303
  // still counts them from persisted entries.)
1184
1304
  if (terminal.state === "failed" || terminal.state === "lost") fail(text);
1185
- 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);
1186
1306
  }
1187
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
+ };
1188
1333
  if (validated.planOnly) {
1189
- await runPlanPreflights(runtime, validated.tasks, ctx.cwd);
1190
- 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));
1191
1352
  const mode = validated.mode as "single" | "parallel";
1192
- return {
1193
- content: [{ type: "text", text: formatPlanText(mode, plan) }],
1194
- details: { mode, plan },
1195
- };
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);
1196
1357
  }
1197
1358
 
1198
- const specs: TaskSpec[] = validated.tasks.map((task: ResolvedTask) => ({
1199
- backend: task.backend,
1200
- task: task.task,
1201
- label: task.label,
1202
- systemPrompt: task.systemPrompt,
1203
- model: task.model,
1204
- thinking: task.thinking,
1205
- tools: task.effectiveTools,
1206
- profile: task.profile,
1207
- canWrite: task.canWrite,
1208
- cwd: task.cwd,
1209
- timeoutMs: task.timeoutMs,
1210
- maxTurns: task.maxTurns,
1211
- maxCost: task.maxCost,
1212
- output: task.output,
1213
- outputMode: task.outputMode,
1214
- outputSchema: task.outputSchema,
1215
- resume: task.resume,
1216
- forkResume: task.forkResume,
1217
- isolation: task.isolation,
1218
- allowSharedWrites: task.allowSharedWrites,
1219
- keepBackground: task.keepBackground,
1220
- graceTurns: task.graceTurns,
1221
- fallbackModels: task.fallbackModels,
1222
- maxRetries: task.maxRetries,
1223
- contextFork: task.contextFork,
1224
- parentSessionFile: task.parentSessionFile,
1225
- spawns: task.spawns,
1359
+ const specs: TaskSpec[] = resolved.map(({ effectiveTools, resolutionNotes: _notes, ...task }) => ({
1360
+ ...task, tools: effectiveTools, fallbackModels: [],
1226
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;
1227
1366
  const runId = runtime.registry.allocateRunId();
1228
- 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!);
1229
1372
  const lock = runtime.registry.acquireResumeLocks(directResumes, runId, runtime.key);
1230
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.`);
1231
1374
 
@@ -1236,12 +1379,15 @@ export default function registerSubagent(pi: ExtensionAPI): void {
1236
1379
  let resolveDone!: () => void;
1237
1380
  const done = new Promise<void>((resolve) => { resolveDone = resolve; });
1238
1381
  try {
1239
- 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);
1240
1383
  } catch (error) {
1384
+ signal?.removeEventListener("abort", parentAbort);
1241
1385
  for (const session of directResumes) runtime.registry.releaseResumeLock(session, runtime.key, runId);
1242
1386
  throw error;
1243
1387
  }
1244
1388
 
1389
+ routingScope.finish(); // Ownership transfers to the registered run/controller.
1390
+
1245
1391
  // Throttle streamed tool updates with a trailing-edge flush: structural
1246
1392
  // changes (state transition, new session id, billed turn) emit
1247
1393
  // immediately; live-text ticks coalesce into at most one deferred emit
@@ -1285,7 +1431,7 @@ export default function registerSubagent(pi: ExtensionAPI): void {
1285
1431
 
1286
1432
  const work = (async () => {
1287
1433
  try {
1288
- const result = await runTasksWithContextPolicy(specs, {
1434
+ const result = await runTasks(specs, {
1289
1435
  semaphore: runtime.semaphore,
1290
1436
  getPiCommand: runtime.getPiCommand,
1291
1437
  sessionDir: runtime.config.sessionDir,
@@ -1299,15 +1445,13 @@ export default function registerSubagent(pi: ExtensionAPI): void {
1299
1445
  stallAfterMs: runtime.config.stallAfterMs,
1300
1446
  stallKillAfterMs: runtime.config.stallKillAfterMs,
1301
1447
  maxRetries: runtime.config.maxRetries,
1302
- contextPolicy,
1303
- parentContextTools,
1304
1448
  onRunnerCreated: (index, runner) => {
1305
1449
  let runners = runtime.liveRunners.get(runId);
1306
1450
  if (!runners) runtime.liveRunners.set(runId, (runners = new Map()));
1307
1451
  runners.set(index, runner);
1308
1452
  },
1309
1453
  onTaskProgress: (index, partial) => {
1310
- if (runtime.closed || current !== runtime) return;
1454
+ if (!ownsRouting(runtime, executionGeneration)) return;
1311
1455
  // Keep the durable run record's childSessionId in sync the first
1312
1456
  // time we learn it (also used by orphan reclaim).
1313
1457
  if (partial.sessionId && partial.process) {
@@ -1345,24 +1489,56 @@ export default function registerSubagent(pi: ExtensionAPI): void {
1345
1489
  // single brief, delivered first. Failures degrade to raw results.
1346
1490
  if (validated.synthesis && result.results.length > 1 && !controller.signal.aborted) {
1347
1491
  const synthesized = await runSynthesis(runtime, validated.synthesis, result.results, {
1348
- runId,
1349
- modelPolicy: dispatchConfig.modelPolicy!,
1350
- contextPolicy,
1351
- parentContextTools,
1352
- 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
+ },
1353
1511
  });
1354
- 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}`;
1355
1514
  }
1356
- 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);
1357
1516
  return result;
1358
- } catch (error: any) {
1359
- const failed: TaskResult = {
1360
- label: "task-1", task: specs[0]?.task ?? "", state: "failed", exitCode: 1,
1361
- messages: [], stderr: "", usage: emptyUsage(), stopReason: "error", errorMessage: error?.message ?? String(error),
1362
- protocol: { headerSeen: false, assistantEndSeen: false, agentEndSeen: false, agentSettledSeen: false, validEvents: 0, parseErrors: 0 },
1363
- };
1364
- runtime.registry.complete(runId, runtime.key, "failed", failed.errorMessage, [failed]);
1365
- 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 };
1366
1542
  } finally {
1367
1543
  if (pendingFlush) { clearTimeout(pendingFlush); pendingFlush = undefined; }
1368
1544
  runtime.liveRunners.delete(runId);
@@ -1374,14 +1550,19 @@ export default function registerSubagent(pi: ExtensionAPI): void {
1374
1550
 
1375
1551
  if (validated.async) {
1376
1552
  runtime.asyncRuns.add(runId);
1377
- 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);
1378
1556
  }
1379
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.");
1380
1559
  // First delivery wins the native usage attachment: a rare concurrent
1381
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.");
1382
1563
  const firstDelivery = runtime.registry.markDelivered(runId, runtime.key);
1383
1564
  const delivered = runtime.output.capOutputForDelivery(result.results);
1384
- const text = delivered.text || result.summary;
1565
+ const text = [synthesisDiagnostic(result.summary), delivered.text || result.summary].filter(Boolean).join("\n\n");
1385
1566
  const finished = runtime.registry.lookup(runId, runtime.key);
1386
1567
  const meta: RunMeta | undefined = finished.status === "found" && finished.run && !("controller" in finished.run)
1387
1568
  ? finished.run
@@ -1390,8 +1571,11 @@ export default function registerSubagent(pi: ExtensionAPI): void {
1390
1571
  if (result.state === "failed") fail(text);
1391
1572
  const resultDetails = details(result.mode, delivered.cappedResults as any, meta);
1392
1573
  return firstDelivery
1393
- ? deliveredResult(text, resultDetails, result.results)
1574
+ ? deliveredResult(text, resultDetails, result.results, selectorUsage)
1394
1575
  : { content: [{ type: "text", text }], details: resultDetails };
1576
+ } finally {
1577
+ routingScope?.finish();
1578
+ }
1395
1579
  },
1396
1580
  renderCall(args, theme, context) {
1397
1581
  // Stable component identity: reuse the previous block and swap content.
@@ -1402,7 +1586,7 @@ export default function registerSubagent(pi: ExtensionAPI): void {
1402
1586
  renderResult(result, options: ToolRenderResultOptions, theme, context) {
1403
1587
  const block = (context.lastComponent instanceof LineBlock ? context.lastComponent : new LineBlock()) as LineBlock;
1404
1588
  const detailsValue = result.details as ReturnType<typeof compactDetails> | undefined;
1405
- if (!detailsValue?.results.length) {
1589
+ if (!detailsValue?.results?.length) {
1406
1590
  const text = result.content.find((item) => item.type === "text")?.text ?? "(no output)";
1407
1591
  block.set((width) => String(text).split("\n").map((line) => truncateToWidth(theme.fg("toolOutput", line), width)));
1408
1592
  return block;
@@ -1417,6 +1601,7 @@ export default function registerSubagent(pi: ExtensionAPI): void {
1417
1601
  state: task.state,
1418
1602
  usage: task.usage,
1419
1603
  model: task.model,
1604
+ routing: task.routing,
1420
1605
  stopReason: task.stopReason,
1421
1606
  timeoutPhase: task.timeoutPhase,
1422
1607
  errorMessage: task.errorMessage,
@@ -1505,16 +1690,12 @@ export default function registerSubagent(pi: ExtensionAPI): void {
1505
1690
  try {
1506
1691
  // Reuse the tool's own execute so /btw inherits validation, profiles,
1507
1692
  // budgets, semaphore + process locks, and output capping unchanged.
1508
- const policy = await readModelPolicyFile().catch(() => undefined);
1509
- const route = policy ? resolveModelRoute(policy) : undefined;
1510
1693
  const result = await subagentTool.execute(
1511
1694
  `btw-${Date.now()}`,
1512
1695
  {
1513
1696
  task: question,
1514
1697
  profile: "explore",
1515
1698
  description: label,
1516
- model: route?.model,
1517
- fallback_models: route?.fallbackModels,
1518
1699
  } as SubagentParams,
1519
1700
  undefined,
1520
1701
  undefined,