pi-crew 0.11.2 → 0.11.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/CHANGELOG.md +83 -1
  2. package/README.md +1 -1
  3. package/dist/index.mjs +492 -493
  4. package/docs/actions-reference.md +22 -0
  5. package/index.ts +9 -5
  6. package/package.json +12 -9
  7. package/scripts/analyze-run.mjs +6 -3
  8. package/skills/real-test-pi-crew/SKILL.md +20 -12
  9. package/src/extension/async-notifier.ts +32 -8
  10. package/src/extension/crew-input-router.ts +9 -2
  11. package/src/extension/plan-orchestrate.ts +19 -3
  12. package/src/extension/registration/commands/shared.ts +11 -10
  13. package/src/extension/registration/foreground-run-controller.ts +2 -2
  14. package/src/extension/registration/lazy-configurers.ts +4 -1
  15. package/src/extension/registration/lifecycle-handlers.ts +16 -10
  16. package/src/extension/registration/lifecycle.ts +2 -0
  17. package/src/extension/registration/observability.ts +27 -18
  18. package/src/extension/registration/team-tool.ts +9 -2
  19. package/src/extension/registration/wire-cross-extension.ts +2 -0
  20. package/src/extension/team-manager-command.ts +3 -2
  21. package/src/extension/team-tool/dispatch/index.ts +4 -3
  22. package/src/extension/team-tool/dispatch/status.ts +7 -3
  23. package/src/extension/team-tool/goal-wrap.ts +7 -0
  24. package/src/extension/team-tool/goal.ts +12 -0
  25. package/src/extension/team-tool/inspect.ts +90 -0
  26. package/src/extension/team-tool/parallel-dispatch.ts +16 -1
  27. package/src/extension/team-tool/run-intent.ts +1 -1
  28. package/src/extension/team-tool/run.ts +1 -1
  29. package/src/runtime/async-runner.ts +36 -0
  30. package/src/runtime/broker/crew-broker.ts +61 -62
  31. package/src/runtime/broker/delegate/delegate-event.ts +12 -8
  32. package/src/runtime/broker/protocol/connection-state.ts +10 -0
  33. package/src/runtime/broker/protocol/lock-busy.ts +76 -0
  34. package/src/runtime/delegate-spawn.ts +16 -2
  35. package/src/runtime/deterministic-ast.ts +14 -2
  36. package/src/runtime/goal-workflow/dynamic-workflow-context.ts +78 -58
  37. package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +14 -2
  38. package/src/runtime/goal-workflow/goal-loop-runner.ts +32 -0
  39. package/src/runtime/group-join.ts +11 -3
  40. package/src/runtime/live-session/live-session-runtime.ts +1 -1
  41. package/src/runtime/model/runtime-warmup.ts +2 -0
  42. package/src/runtime/output/output-validator.ts +10 -5
  43. package/src/runtime/plan-approval.ts +14 -1
  44. package/src/runtime/recovery/crash-recovery.ts +80 -52
  45. package/src/runtime/stale-reconciler.ts +8 -0
  46. package/src/runtime/subagent-manager.ts +43 -13
  47. package/src/runtime/task-output-context.ts +11 -1
  48. package/src/runtime/task-runner/child-executor.ts +17 -6
  49. package/src/runtime/task-runner/post-execution.ts +46 -14
  50. package/src/runtime/task-runner/pre-execution.ts +29 -21
  51. package/src/runtime/task-runner/prompt-builder.ts +8 -0
  52. package/src/runtime/task-runner/retrieval-orchestrator.ts +36 -14
  53. package/src/runtime/task-runner.ts +7 -0
  54. package/src/runtime/team-runner.ts +4 -1
  55. package/src/runtime/verification/verification-worktree.ts +3 -0
  56. package/src/schema/team-tool-schema.ts +2 -1
  57. package/src/state/coordination/mailbox.ts +7 -2
  58. package/src/state/stores/state-store.ts +8 -1
  59. package/src/ui/dashboard-panes/agents-pane.ts +11 -8
  60. package/src/ui/inline-panel/index.ts +1 -1
  61. package/src/ui/render-diff.ts +19 -6
  62. package/src/ui/run-dashboard.ts +11 -1
  63. package/src/ui/run-snapshot-cache.ts +9 -3
  64. package/src/ui/widget/task-list.ts +8 -5
  65. package/src/ui/widget/widget-model.ts +3 -1
  66. package/tsconfig.json +2 -0
  67. package/src/ui/card-colors.ts +0 -168
  68. package/src/utils/completion-dedupe.ts +0 -63
  69. package/src/utils/resolve-shell.ts +0 -36
@@ -71,7 +71,11 @@ export interface ObservabilityDeps {
71
71
  * newer session. Reuse this counter — do not invent a parallel one.
72
72
  */
73
73
  getSessionGeneration: () => number;
74
- reconcileStaleRuns: (cwd: string, cache: ReturnType<ObservabilityDeps["getManifestCache"]>, currentSessionId?: string) => unknown[];
74
+ reconcileStaleRuns: (
75
+ cwd: string,
76
+ cache: ReturnType<ObservabilityDeps["getManifestCache"]>,
77
+ currentSessionId?: string,
78
+ ) => unknown[] | Promise<unknown[] | undefined>;
75
79
  reconcileOrphanedTempWorkspaces: (now: number, opts: { cleanupOrphanedTempDirs?: boolean }) => unknown;
76
80
  cleanupOrphanTempDirs: () => { cleaned: number; scanned: number; failed: number };
77
81
  cleanupLegacyOrphanTempDirs: () => { cleaned: number; scanned: number; failed: number };
@@ -253,26 +257,31 @@ async function configureObservabilityInternal(ctx: ExtensionContext, state: Obse
253
257
  if (autoRepairIntervalMs > 0) {
254
258
  state.autoRepairTimer = setInterval(() => {
255
259
  if (deps.isCleanedUp()) return;
256
- try {
257
- const staleResults = deps.reconcileStaleRuns(ctx.cwd, deps.getManifestCache(ctx.cwd), extractSessionId(ctx));
258
- if (Array.isArray(staleResults) && staleResults.length > 0) {
259
- for (const result of staleResults) {
260
- const repaired = (result as { repaired?: boolean }).repaired;
261
- if (repaired) {
262
- deps.notifyOperator({
263
- id: `auto_repair_${(result as { runId: string }).runId}`,
264
- severity: "info",
265
- source: "auto-repair",
266
- runId: (result as { runId: string }).runId,
267
- title: `Auto-repaired stale run`,
268
- body: (result as { detail?: string }).detail ?? "",
269
- });
260
+ // RR-021 WI-1.5: reconcileStaleRuns may be async (mapConcurrent) —
261
+ // settle the result before inspecting, keep errors inside the log.
262
+ void (async () => {
263
+ try {
264
+ const settled = await deps.reconcileStaleRuns(ctx.cwd, deps.getManifestCache(ctx.cwd), extractSessionId(ctx));
265
+ const staleResults = Array.isArray(settled) ? settled : [];
266
+ if (staleResults.length > 0) {
267
+ for (const result of staleResults) {
268
+ const repaired = (result as { repaired?: boolean }).repaired;
269
+ if (repaired) {
270
+ deps.notifyOperator({
271
+ id: `auto_repair_${(result as { runId: string }).runId}`,
272
+ severity: "info",
273
+ source: "auto-repair",
274
+ runId: (result as { runId: string }).runId,
275
+ title: `Auto-repaired stale run`,
276
+ body: (result as { detail?: string }).detail ?? "",
277
+ });
278
+ }
270
279
  }
271
280
  }
281
+ } catch (error) {
282
+ logInternalError("register.autoRepair", error);
272
283
  }
273
- } catch (error) {
274
- logInternalError("register.autoRepair", error);
275
- }
284
+ })();
276
285
  }, autoRepairIntervalMs);
277
286
  state.autoRepairTimer.unref();
278
287
 
@@ -2,7 +2,7 @@ import { statSync } from "node:fs";
2
2
  import type { ExtensionAPI, ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding-agent";
3
3
  import { Text } from "@earendil-works/pi-tui";
4
4
  import type { TObject } from "@sinclair/typebox";
5
- import { Value } from "@sinclair/typebox/value";
5
+ import { TypeCompiler } from "@sinclair/typebox/compiler";
6
6
  import { loadConfig } from "../../config/config.ts";
7
7
  import { findClosestKey } from "../../config/suggestions.ts";
8
8
  import type { MetricRegistry } from "../../observability/metric-registry.ts";
@@ -36,6 +36,12 @@ import { withSessionId } from "../team-tool/context.ts";
36
36
  import { formatTeamToolParamError } from "../team-tool/param-error.ts";
37
37
  import { toolResult } from "../tool-result.ts";
38
38
 
39
+ // RR-021 WI-4.3g: compile the team-tool schema ONCE. Value.Check re-walks the
40
+ // whole (large) schema object on every tool call; TypeCompiler.Compile (STATIC
41
+ // method on @sinclair/typebox/compiler) builds a specialized checker at module
42
+ // load. The error formatter keeps using the schema + Value.Errors (cold path).
43
+ const compiledTeamToolParams = TypeCompiler.Compile(TeamToolParams);
44
+
39
45
  /**
40
46
  * Normalize model-generated tool params before schema validation.
41
47
  *
@@ -228,7 +234,8 @@ export function registerTeamTool(pi: ExtensionAPI, deps: RegisterTeamToolDeps):
228
234
  // validation so strict unions/patterns accept "unset" as omitted.
229
235
  const normalized = normalizeTeamParams(params);
230
236
  // Defense-in-depth: validate params at runtime even though Pi framework already does
231
- if (!Value.Check(TeamToolParams, normalized)) {
237
+ // (RR-021 WI-4.3g: precompiled checker — same semantics as Value.Check.)
238
+ if (!compiledTeamToolParams.Check(normalized)) {
232
239
  return toolResult(formatTeamToolParamError(TeamToolParams, normalized), { action: "list", status: "error" }, true);
233
240
  }
234
241
  // EXT-1: additionalProperties:true lets unknown (typo'd) keys slip past
@@ -28,6 +28,7 @@ export function installCrossExtensionWiring(pi: ExtensionAPI, ctx: RegistrationC
28
28
  // in the entire runtime chain) out of the cold-start module graph. EXT-9:
29
29
  // the registry now lives in module-scoped state, not globalThis.
30
30
  void import("../team-tool.ts").then(({ installCrewGlobalRegistry }) => {
31
+ // LAZY: defer team-tool until cross-extension wiring fires
31
32
  const manifestCacheForRegistry = ctx.getManifestCache(ctx.currentCtx?.cwd ?? process.cwd());
32
33
  installCrewGlobalRegistry({
33
34
  manifestCache: manifestCacheForRegistry,
@@ -57,6 +58,7 @@ export function refreshCrossExtensionWiringForSession(pi: ExtensionAPI, ctx: Reg
57
58
  return;
58
59
  }
59
60
  void import("../team-tool.ts").then(({ installCrewGlobalRegistry }) => {
61
+ // LAZY: defer team-tool until cross-extension wiring fires
60
62
  installCrewGlobalRegistry({
61
63
  manifestCache: ctx.getManifestCache(ctx.currentCtx?.cwd ?? process.cwd()),
62
64
  cwdProvider: () => ctx.currentCtx?.cwd ?? process.cwd(),
@@ -1,5 +1,5 @@
1
1
  import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
- import { listRuns } from "./run-index.ts";
2
+ import { listRecentRuns } from "./run-index.ts";
3
3
  // Lazy-loaded: team-tool.ts pulls in entire runtime chain.
4
4
  import type { handleTeamTool as HandleTeamToolFn } from "./team-tool.ts";
5
5
 
@@ -122,7 +122,8 @@ export async function handleTeamManagerCommand(_args: string, ctx: ExtensionComm
122
122
  return;
123
123
  }
124
124
 
125
- const runs = listRuns(ctx.cwd).slice(0, 20);
125
+ // RR-021 WI-4.1: bounded recent-runs read instead of a full index scan.
126
+ const runs = listRecentRuns(ctx.cwd, 20);
126
127
  if (runs.length === 0) {
127
128
  ctx.ui.notify("No pi-crew runs found.", "info");
128
129
  return;
@@ -7,8 +7,8 @@
7
7
  *
8
8
  * 54→5 mapping:
9
9
  * run (9): run, parallel, plan, orchestrate, resume, retry, wait, steer, goal
10
- * status (16): status, list, get, events, artifacts, summary, graph, search,
11
- * health, worktrees, checkpoint, cache, explain, onboard, recommend, help
10
+ * status (17): status, list, get, events, artifacts, breakdown, summary, graph,
11
+ * search, health, worktrees, checkpoint, cache, explain, onboard, recommend, help
12
12
  * control (7): cancel, invalidate, respond, cleanup, prune, forget, doctor
13
13
  * manage (17): create, update, delete, init, config, validate, autonomy, settings,
14
14
  * workflow-create/get/list/save/delete, import, imports, export, compare
@@ -36,12 +36,13 @@ const ACTION_TO_DOMAIN: Record<string, TeamDomain> = {
36
36
  steer: "run",
37
37
  goal: "run",
38
38
 
39
- // status domain (16)
39
+ // status domain (17)
40
40
  status: "status",
41
41
  list: "status",
42
42
  get: "status",
43
43
  events: "status",
44
44
  artifacts: "status",
45
+ breakdown: "status",
45
46
  summary: "status",
46
47
  graph: "status",
47
48
  search: "status",
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * API-5 facade dispatch — Status domain router.
3
3
  *
4
- * Actions: status, list, get, events, artifacts, summary, graph, search,
5
- * health, worktrees, checkpoint, cache, explain, onboard, recommend, help.
4
+ * Actions: status, list, get, events, artifacts, breakdown, summary, graph,
5
+ * search, health, worktrees, checkpoint, cache, explain, onboard, recommend,
6
+ * help.
6
7
  *
7
8
  * Inline cases (graph, search, onboard, explain, cache, checkpoint, recommend,
8
9
  * help) are moved verbatim from the former handleTeamTool switch.
@@ -28,7 +29,7 @@ import type { PiTeamsToolResult } from "../../tool-result.ts";
28
29
  import { result, type TeamContext } from "../context.ts";
29
30
  import { handleExplain } from "../explain.ts";
30
31
  import { handleHealthMonitor } from "../health-monitor.ts";
31
- import { handleArtifacts, handleEvents, handleSummary } from "../inspect.ts";
32
+ import { handleArtifacts, handleBreakdown, handleEvents, handleSummary } from "../inspect.ts";
32
33
  import { handleWorktrees } from "../lifecycle-actions.ts";
33
34
  import { handleStatus } from "../status.ts";
34
35
 
@@ -45,6 +46,7 @@ export const STATUS_DOMAIN_ACTIONS = [
45
46
  "get",
46
47
  "events",
47
48
  "artifacts",
49
+ "breakdown",
48
50
  "summary",
49
51
  "graph",
50
52
  "search",
@@ -73,6 +75,8 @@ export async function handleStatusDomain(params: TeamToolParamsValue, ctx: TeamC
73
75
  return handleEvents(params, ctx);
74
76
  case "artifacts":
75
77
  return handleArtifacts(params, ctx);
78
+ case "breakdown":
79
+ return handleBreakdown(params, ctx);
76
80
  case "worktrees":
77
81
  return handleWorktrees(params, ctx);
78
82
  case "summary":
@@ -21,6 +21,7 @@ import { loadConfig } from "../../config/config.ts";
21
21
  import type { GoalWrapWorkflowConfig } from "../../config/types.ts";
22
22
  import { spawnBackgroundTeamRun } from "../../runtime/async-runner.ts";
23
23
  import { GoalStore } from "../../runtime/goal-workflow/goal-state-store.ts";
24
+ import { captureRunModelContext } from "../../runtime/model/session-model.ts";
24
25
  import { snapshotManifests } from "../../runtime/verification/verification-integrity.ts";
25
26
  import type { TeamToolParamsValue } from "../../schema/team-tool-schema.ts";
26
27
  import { atomicWriteJson } from "../../state/atomic-write.ts";
@@ -248,6 +249,11 @@ export async function startGoalWrappedRun(
248
249
  store.save(goalState);
249
250
 
250
251
  const paths = createRunPaths(cwd, goalId);
252
+ // Finding #1 (battery 2026-09-29): persist the parent session's model
253
+ // routing on the goal-loop manifest — the DETACHED runner re-loads this
254
+ // manifest and has no ExtensionContext to re-resolve it. Without it,
255
+ // goal-wrap turns silently default-chain (minimax 402 family).
256
+ const modelContext = captureRunModelContext(ctx, typeof params.model === "string" ? params.model : undefined);
251
257
  const goalLoopManifest: TeamRunManifest = {
252
258
  schemaVersion: 1,
253
259
  runId: goalId,
@@ -267,6 +273,7 @@ export async function startGoalWrappedRun(
267
273
  artifacts: [],
268
274
  ownerSessionId,
269
275
  runKind: "goal-loop",
276
+ ...(modelContext ? { modelContext } : {}),
270
277
  };
271
278
  await saveRunManifestAsync(goalLoopManifest);
272
279
  await appendEventAsync(paths.eventsPath, {
@@ -16,6 +16,7 @@
16
16
 
17
17
  import { spawnBackgroundTeamRun } from "../../runtime/async-runner.ts";
18
18
  import { GoalStore } from "../../runtime/goal-workflow/goal-state-store.ts";
19
+ import { captureRunModelContext } from "../../runtime/model/session-model.ts";
19
20
  import { snapshotManifests } from "../../runtime/verification/verification-integrity.ts";
20
21
  import { isWorkspaceBusy } from "../../runtime/workspace-lock.ts";
21
22
  import type { TeamToolParamsValue } from "../../schema/team-tool-schema.ts";
@@ -173,6 +174,12 @@ async function handleStart(input: GoalSubActionInput): Promise<ReturnType<typeof
173
174
  // runs/<goalId>/ → "Run not found" → silent death. (Review #2 F1.)
174
175
  const paths = createRunPaths(cwd, goalId);
175
176
  const now2 = new Date().toISOString();
177
+ // Finding #1 (battery 2026-09-29): the goal-loop manifest is loaded by the
178
+ // DETACHED background-runner (no ExtensionContext). Persist the parent
179
+ // session's model routing so goal-loop turns restore it instead of falling
180
+ // back to the default chain (minimax 402 incident family). goalState keeps
181
+ // workerModel/evaluatorModel as the explicit per-role picks.
182
+ const modelContext = captureRunModelContext(ctx, typeof params.model === "string" ? params.model : undefined);
176
183
  const goalLoopManifest: TeamRunManifest = {
177
184
  schemaVersion: 1,
178
185
  runId: goalId, // paths.runId === goalId by construction
@@ -192,6 +199,7 @@ async function handleStart(input: GoalSubActionInput): Promise<ReturnType<typeof
192
199
  artifacts: [],
193
200
  ownerSessionId,
194
201
  runKind: "goal-loop",
202
+ ...(modelContext ? { modelContext } : {}),
195
203
  };
196
204
  await saveRunManifestAsync(goalLoopManifest);
197
205
  await appendEventAsync(paths.eventsPath, {
@@ -449,6 +457,9 @@ async function handleResume(input: GoalSubActionInput): Promise<ReturnType<typeo
449
457
  // Re-spawn the background loop. The loop checks goal.state === "running" before each
450
458
  // turn; since we just set it to running, it proceeds.
451
459
  try {
460
+ // Finding #1: re-capture at resume time (same battery) — the resuming
461
+ // session's live model is authoritative for subsequent turns.
462
+ const modelContext = captureRunModelContext(ctx, typeof params.model === "string" ? params.model : undefined);
452
463
  const manifest: TeamRunManifest = {
453
464
  schemaVersion: 1,
454
465
  runId: goalId,
@@ -468,6 +479,7 @@ async function handleResume(input: GoalSubActionInput): Promise<ReturnType<typeo
468
479
  artifacts: [],
469
480
  ownerSessionId: existing.ownerSessionId,
470
481
  runKind: "goal-loop",
482
+ ...(modelContext ? { modelContext } : {}),
471
483
  };
472
484
  const spawned = await spawnBackgroundTeamRun(manifest);
473
485
  return result(
@@ -1,3 +1,5 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
1
3
  import type { TeamToolParamsValue } from "../../schema/team-tool-schema.ts";
2
4
  import { readEventsCursor } from "../../state/event-log/event-log.ts";
3
5
  import { loadRunManifestById } from "../../state/stores/state-store.ts";
@@ -110,3 +112,91 @@ export function handleSummary(params: TeamToolParamsValue, ctx: TeamContext): Pi
110
112
  artifactsRoot: loaded.manifest.artifactsRoot,
111
113
  });
112
114
  }
115
+
116
+ // ─── G18 (SDD 2026-09-30 WI-3): prompt-token breakdown surface ──────────────
117
+
118
+ /** File suffix of the per-task breakdown artifact written by pre-execution
119
+ * (`metadata/<taskId>.prompt-breakdown.json`) when PI_CREW_PROMPT_BREAKDOWN=1. */
120
+ const BREAKDOWN_FILE_SUFFIX = ".prompt-breakdown.json";
121
+
122
+ /** Shape of one section entry in a prompt-breakdown JSON artifact
123
+ * (see writeArtifact call in src/runtime/task-runner/pre-execution.ts). */
124
+ interface PromptBreakdownSection {
125
+ chars: number;
126
+ estTokens: number;
127
+ }
128
+
129
+ /**
130
+ * G18: read the opt-in per-task prompt-token breakdown artifacts of a run and
131
+ * surface per-task totals + the top-5 largest sections. Consumers: the leader
132
+ * prompt (future W-F token decisions, G15/G17) and humans debugging prompt
133
+ * bloat. The artifacts exist only when the host session was started with
134
+ * PI_CREW_PROMPT_BREAKDOWN=1 (default off — zero cost when off, SR-02).
135
+ * Mirrors the handleArtifacts pattern: resolve run → read the manifest
136
+ * artifacts index → filter kind=metadata breakdown entries → read files.
137
+ */
138
+ export function handleBreakdown(params: TeamToolParamsValue, ctx: TeamContext): PiTeamsToolResult {
139
+ if (!params.runId)
140
+ return result(
141
+ paramRequired("breakdown", "runId", "{ action: 'breakdown', runId: 'team_...' }"),
142
+ { action: "breakdown", status: "error" },
143
+ true,
144
+ );
145
+ const runCwd = locateRunCwd(params.runId, ctx.cwd);
146
+ if (!runCwd) return result(`Run '${params.runId}' not found.${RUN_NOT_FOUND_HINT}`, { action: "breakdown", status: "error" }, true);
147
+ const loaded = loadRunManifestById(runCwd, params.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
148
+ if (!loaded) return result(`Run '${params.runId}' not found.${RUN_NOT_FOUND_HINT}`, { action: "breakdown", status: "error" }, true);
149
+ // Index-driven lookup (no disk scan): the manifest artifacts index carries
150
+ // one metadata entry per task with a prompt breakdown.
151
+ const breakdownArtifacts = loaded.manifest.artifacts.filter(
152
+ (artifact) => artifact.kind === "metadata" && path.basename(artifact.path).endsWith(BREAKDOWN_FILE_SUFFIX),
153
+ );
154
+ const perTask: Array<{
155
+ taskId: string;
156
+ totalEstTokens: number;
157
+ topSections: Array<{ section: string; estTokens: number; chars: number }>;
158
+ }> = [];
159
+ for (const artifact of breakdownArtifacts) {
160
+ const taskId = path.basename(artifact.path).slice(0, -BREAKDOWN_FILE_SUFFIX.length);
161
+ if (params.taskId && taskId !== params.taskId) continue;
162
+ let sections: Record<string, PromptBreakdownSection>;
163
+ try {
164
+ sections = JSON.parse(fs.readFileSync(artifact.path, "utf-8")) as Record<string, PromptBreakdownSection>;
165
+ } catch {
166
+ continue; // unreadable/corrupt breakdown JSON — skip rather than fail the whole action
167
+ }
168
+ const entries = Object.entries(sections).filter((entry): entry is [string, PromptBreakdownSection] => {
169
+ const value = entry[1];
170
+ return value !== null && typeof value === "object" && typeof value.estTokens === "number";
171
+ });
172
+ const totalEstTokens = entries.reduce((sum, [, value]) => sum + value.estTokens, 0);
173
+ const topSections = [...entries]
174
+ .sort((a, b) => b[1].estTokens - a[1].estTokens)
175
+ .slice(0, 5)
176
+ .map(([section, value]) => ({ section, estTokens: value.estTokens, chars: value.chars ?? 0 }));
177
+ perTask.push({ taskId, totalEstTokens, topSections });
178
+ }
179
+ if (perTask.length === 0) {
180
+ const scope = params.taskId ? ` for task '${params.taskId}'` : "";
181
+ return result(
182
+ `No prompt-breakdown artifacts${scope} for ${loaded.manifest.runId}. Breakdown capture is opt-in: start the host Pi session with PI_CREW_PROMPT_BREAKDOWN=1 and re-run to record per-section token estimates.`,
183
+ { action: "breakdown", status: "ok", runId: loaded.manifest.runId, data: { runTotal: 0, perTask: [] } },
184
+ );
185
+ }
186
+ const runTotal = perTask.reduce((sum, task) => sum + task.totalEstTokens, 0);
187
+ const lines = [
188
+ `Prompt-token breakdown for ${loaded.manifest.runId}:`,
189
+ ...perTask.flatMap((task) => [
190
+ `- ${task.taskId}: ~${task.totalEstTokens} tokens`,
191
+ ...task.topSections.map((section) => ` - ${section.section}: ~${section.estTokens} tokens (${section.chars} chars)`),
192
+ ]),
193
+ `Run total: ~${runTotal} tokens`,
194
+ ];
195
+ return result(lines.join("\n"), {
196
+ action: "breakdown",
197
+ status: "ok",
198
+ runId: loaded.manifest.runId,
199
+ artifactsRoot: loaded.manifest.artifactsRoot,
200
+ data: { runTotal, perTask },
201
+ });
202
+ }
@@ -10,6 +10,7 @@ import { discoverAgents } from "../../agents/discover-agents.ts";
10
10
  import { loadConfig } from "../../config/config.ts";
11
11
  import { spawnBackgroundTeamRun } from "../../runtime/async-runner.ts";
12
12
  import { resolveCrewRuntime } from "../../runtime/model/runtime-resolver.ts";
13
+ import { captureRunModelContext } from "../../runtime/model/session-model.ts";
13
14
  import type { TeamToolParamsValue } from "../../schema/team-tool-schema.ts";
14
15
  import { appendEventAsync } from "../../state/event-log/event-log.ts";
15
16
  import { createRunManifest } from "../../state/stores/state-store.ts";
@@ -66,13 +67,23 @@ export async function handleParallel(params: TeamToolParamsValue, ctx: TeamConte
66
67
 
67
68
  const runtime = await resolveCrewRuntime(config.config);
68
69
 
70
+ // Finding #1 (full battery 2026-09-29): parallel-dispatched runs re-enter
71
+ // through the detached background-runner, which has no ExtensionContext.
72
+ // Without a persisted modelContext, restoredModelRouting() returns {} and
73
+ // every worker silently falls back to the DEFAULT chain (live: 3 runs died
74
+ // on minimax/MiniMax-M3 402 while the parent session ran zai/glm-5.3).
75
+ // Captured ONCE here (in the parent session's process) so resolveParentModel()
76
+ // sees the live session model; undefined when nothing is worth persisting
77
+ // (older manifests stay byte-identical).
78
+ const modelContext = captureRunModelContext(ctx, typeof params.model === "string" ? params.model : undefined);
79
+
69
80
  const launched: Array<{ runId: string; goal: string; agent: string }> = [];
70
81
  const errors: Array<{ goal: string; error: string }> = [];
71
82
 
72
83
  // C1: Enforce concurrency limit with batched spawning
73
84
  for (let batchStart = 0; batchStart < tasksParam.length; batchStart += concurrency) {
74
85
  const batch = tasksParam.slice(batchStart, batchStart + concurrency);
75
- const batchPromises = batch.map((task) => spawnSingleTask(task, ctx, allAgentsList, team, workflow, runtime));
86
+ const batchPromises = batch.map((task) => spawnSingleTask(task, ctx, allAgentsList, team, workflow, runtime, modelContext));
76
87
  const batchResults = await Promise.allSettled(batchPromises);
77
88
  for (const res of batchResults) {
78
89
  if (res.status === "fulfilled" && res.value.ok) {
@@ -121,6 +132,7 @@ async function spawnSingleTask(
121
132
  team: TeamConfig,
122
133
  workflow: WorkflowConfig,
123
134
  runtime: { available: boolean; kind: string },
135
+ modelContext: ReturnType<typeof captureRunModelContext>,
124
136
  ): Promise<SpawnResult> {
125
137
  try {
126
138
  const taskRec = task as Record<string, unknown>;
@@ -163,6 +175,9 @@ async function spawnSingleTask(
163
175
  // skipped and any session can cancel/retry parallel-dispatched runs.
164
176
  ownerSessionId: ctx.sessionId,
165
177
  runKind: "team-run",
178
+ // Finding #1: persist the parent session's model routing so the detached
179
+ // runner restores it instead of default-chaining (see handleParallel).
180
+ modelContext,
166
181
  });
167
182
 
168
183
  await appendEventAsync(created.manifest.eventsPath, {
@@ -73,7 +73,7 @@ const MAX_ANALYSIS_BYTES = 100_000;
73
73
  var crewInitPromise: Promise<typeof import("../../state/crew-init.ts")> | undefined;
74
74
  function loadCrewInit(): Promise<typeof import("../../state/crew-init.ts")> {
75
75
  if (!crewInitPromise) {
76
- crewInitPromise = import("../../state/crew-init.ts");
76
+ crewInitPromise = import("../../state/crew-init.ts"); // LAZY: defer crew-init until first run intent
77
77
  }
78
78
  return crewInitPromise;
79
79
  }
@@ -61,7 +61,7 @@ import type { RuntimeResolutionState, TeamRunManifest, TeamTaskState } from "../
61
61
  var crewInitPromise: Promise<typeof import("../../state/crew-init.ts")> | undefined;
62
62
  function loadCrewInit(): Promise<typeof import("../../state/crew-init.ts")> {
63
63
  if (!crewInitPromise) {
64
- crewInitPromise = import("../../state/crew-init.ts");
64
+ crewInitPromise = import("../../state/crew-init.ts"); // LAZY: defer crew-init until first run intent
65
65
  }
66
66
  return crewInitPromise;
67
67
  }
@@ -240,6 +240,16 @@ export const BACKGROUND_RUNNER_ENV_ALLOWLIST: string[] = [
240
240
  "HERDR_SOCKET_PATH",
241
241
  "HERDR_WORKSPACE_ID",
242
242
  "HERDR_PING_TIMEOUT_MS",
243
+ // Model-selection control vars (Finding #1, full battery 2026-09-29): the
244
+ // detached runner resolves subagent model routing from env (PI_CREW_MODEL →
245
+ // resolveDefaultSubagentModel; PI_CREW_MODEL_FALLBACK_ORDER →
246
+ // resolveModelFallbackPolicy) for manifests without a persisted modelContext
247
+ // (older manifests) and as a user override channel. Stripping them made
248
+ // every async run silently fall back to the DEFAULT chain (live: 3 parallel
249
+ // runs died on minimax/MiniMax-M3 402 while the parent ran zai/glm-5.3).
250
+ // PI_CREW_MODEL_REQUIRE_CREDENTIALS is NOT here — see forwardModelControlEnv.
251
+ "PI_CREW_MODEL",
252
+ "PI_CREW_MODEL_FALLBACK_ORDER",
243
253
  ];
244
254
 
245
255
  /**
@@ -256,6 +266,29 @@ export function buildBackgroundRunnerEnv(env: NodeJS.ProcessEnv): NodeJS.Process
256
266
  return { ...env, PI_CREW_ASYNC_RUN: "1" };
257
267
  }
258
268
 
269
+ /**
270
+ * Model-selection control vars that CANNOT live on
271
+ * {@link BACKGROUND_RUNNER_ENV_ALLOWLIST}: isSecretKey() matches the
272
+ * "_CREDENTIALS" suffix of PI_CREW_MODEL_REQUIRE_CREDENTIALS (keyword
273
+ * "credential"), and sanitizeEnvSecrets THROWS on secret-looking allowlist
274
+ * entries that are absent from the spawning env — so allowlisting it would
275
+ * break EVERY async spawn whenever the flag is unset. It is a declared
276
+ * control flag (config/env-vars.ts, read by resolveModelFallbackPolicy),
277
+ * never a secret, so we forward it explicitly when set instead.
278
+ * Exported pure so tests pin the contract (async-runner.test.ts).
279
+ */
280
+ export const MODEL_CONTROL_ENV_EXPLICIT: readonly string[] = ["PI_CREW_MODEL_REQUIRE_CREDENTIALS"];
281
+
282
+ /** Copy the explicitly-forwarded model control vars (set ones only). */
283
+ export function forwardModelControlEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
284
+ const forwarded: NodeJS.ProcessEnv = {};
285
+ for (const key of MODEL_CONTROL_ENV_EXPLICIT) {
286
+ const value = env[key];
287
+ if (value !== undefined) forwarded[key] = value;
288
+ }
289
+ return forwarded;
290
+ }
291
+
259
292
  /** F4 v2: the ONE line written to the background-runner's stdin carrying
260
293
  * PER-TASK compound broker tokens (see stdin-handshake.ts for why compound —
261
294
  * ADR-0 item 6 rejects bare-runId tokens for wait.*). Kept pure + exported so
@@ -377,6 +410,9 @@ export async function spawnBackgroundTeamRun(manifest: TeamRunManifest): Promise
377
410
  const childEnv = buildBackgroundRunnerEnv({
378
411
  ...(peerDepDir ? { ...filteredEnv, [PEER_DEP_DIR_ENV]: peerDepDir } : filteredEnv),
379
412
  PI_CREW_BACKGROUND_RUNNER_ENTRY: "1",
413
+ // Finding #1: model control vars that cannot ride the allowlist (see
414
+ // forwardModelControlEnv). Copied from the ORIGINAL env, post-sanitize.
415
+ ...forwardModelControlEnv(process.env),
380
416
  });
381
417
 
382
418
  const loader = resolveTypeScriptLoader();