pi-crew 0.9.61 → 0.9.62

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/CHANGELOG.md CHANGED
@@ -3,6 +3,13 @@
3
3
  > **Note:** `atomic-write-v2.ts` / `AtomicWriter` mentioned in historical entries below was consolidated into `atomic-write.ts` as of v0.9.42. This changelog is preserved as historical record — the migration was completed (the v2 class was never adopted; v1 won on simplicity + symlink-safety + link+unlink atomicity). See `docs/migration/atomic-write-v2-migration.md` for the decision rationale.
4
4
 
5
5
 
6
+ ## [0.9.62] — provider-quota attribution per live-session agent + dead-worker alert re-fire fix (2026-08-06)
7
+
8
+ ### Bug fixes
9
+
10
+ - **Provider-quota responses were attributed to the wrong provider in the opt-in `live-session` runtime.** pi's `after_provider_response` event carries no provider/sessionId, so quota was keyed off the shared module-scoped `currentSessionModel()`: a provider-B response (e.g. a 429) was recorded under the main session's anchor provider A — mis-deprioritizing A, or overwriting a genuine exhaustion signal so the “deprioritize exhausted providers” contract was not reliably honored. (Only reachable via the opt-in `live-session` runtime; the default `child-process` runtime is unaffected since each worker is its own process with anchor==serving provider.) Fix: carry each in-process live agent's resolved model through `AsyncLocalStorage` and attribute quota from it (`resolveProviderForResponse()`, `src/runtime/model/session-model.ts`); **skip** attribution (rather than guess) when live agents are active but the async context is absent; the default `child-process` path is byte-identical to before. Hardening from review: `unregisterLiveAgentModel` now runs **first** in the live-session `finally` block (a thrown `terminateLiveAgent` on the user-cancel path previously skipped it, permanently blinding quota process-wide via `hasActiveLiveAgents()` stuck `true`); `liveAgentModels` is capped at 5,000 entries matching `MAX_LIVE_AGENTS`. Tests in `test/unit/runtime/model/live-agent-quota-attribution.test.ts`. Investigation/spec in `docs/bugs/model-quota-attribution.md`.
11
+ - **Dead-worker dashboard alert re-fired every 5 min for already-terminal runs.** A stale snapshot cache (not auto-invalidated when a run transitions to terminal) kept task status as "running" with an aged heartbeat, so the health loop re-classified it as dead and re-emitted the alert every 5 min; and there was no dismiss/clear path for health notifications. Fix: (1) the health loop in `renderTick` now re-verifies each run against a FRESH manifest-cache read before emitting; terminal runs are skipped and their stale snapshot is purged; (2) `NotificationDescriptor` gains an optional `clear` flag — when a run goes terminal, previously-emitted `recovery_dead_workers` / `recovery_missing_heartbeat` notifications are cleared and their cooldown entries are removed. Genuinely running runs with stale workers still alert (regression-guarded). Tests in `notification-router.test.ts` and `heartbeat-aggregator.test.ts`. Investigation in `docs/bugs/dead-worker-alert-refire.md`.
12
+
6
13
  ## [0.9.61] — bundle republish: bug-44 fix shipped in dist (2026-08-05)
7
14
 
8
15
  ### Bug fixes
package/dist/index.mjs CHANGED
@@ -22303,6 +22303,32 @@ var init_model_fallback = __esm({
22303
22303
  });
22304
22304
 
22305
22305
  // src/runtime/model/session-model.ts
22306
+ import { AsyncLocalStorage as AsyncLocalStorage2 } from "node:async_hooks";
22307
+ function registerLiveAgentModel(agentId, model) {
22308
+ if (liveAgentModels.size >= MAX_LIVE_AGENT_MODELS && !liveAgentModels.has(agentId)) {
22309
+ const oldestKey = liveAgentModels.keys().next().value;
22310
+ if (oldestKey !== void 0) {
22311
+ logInternalError(
22312
+ "session-model.liveAgentModels.cap",
22313
+ new Error(`liveAgentModels at cap ${MAX_LIVE_AGENT_MODELS}; evicting oldest ${oldestKey}`)
22314
+ );
22315
+ liveAgentModels.delete(oldestKey);
22316
+ }
22317
+ }
22318
+ liveAgentModels.set(agentId, model);
22319
+ }
22320
+ function unregisterLiveAgentModel(agentId) {
22321
+ liveAgentModels.delete(agentId);
22322
+ }
22323
+ function hasActiveLiveAgents() {
22324
+ return liveAgentModels.size > 0;
22325
+ }
22326
+ function resolveProviderForResponse() {
22327
+ const ctx = liveAgentContext.getStore();
22328
+ if (ctx) return providerOfModelRef(ctx.modelRef);
22329
+ if (hasActiveLiveAgents()) return void 0;
22330
+ return providerOfModelRef(currentSessionModel());
22331
+ }
22306
22332
  function noteSessionModel(model, source = "model_select") {
22307
22333
  const normalized = modelRefToString(model);
22308
22334
  if (!normalized) return;
@@ -22342,12 +22368,16 @@ function captureRunModelContext(ctx, override) {
22342
22368
  function sessionModelSnapshot() {
22343
22369
  return { ...state };
22344
22370
  }
22345
- var state;
22371
+ var state, liveAgentContext, liveAgentModels, MAX_LIVE_AGENT_MODELS;
22346
22372
  var init_session_model = __esm({
22347
22373
  "src/runtime/model/session-model.ts"() {
22348
22374
  "use strict";
22375
+ init_internal_error();
22349
22376
  init_model_fallback();
22350
22377
  state = { source: "none" };
22378
+ liveAgentContext = new AsyncLocalStorage2();
22379
+ liveAgentModels = /* @__PURE__ */ new Map();
22380
+ MAX_LIVE_AGENT_MODELS = 5e3;
22351
22381
  }
22352
22382
  });
22353
22383
 
@@ -33237,6 +33267,7 @@ async function runLiveSessionTask(input) {
33237
33267
  scopeModelsPatterns: await resolveScopeModelsPatterns(input.manifest.cwd)
33238
33268
  });
33239
33269
  const resolvedModel = modelFromRegistry(input.modelRegistry, modelRouting.candidates[0] ?? modelRouting.requested) ?? input.parentModel;
33270
+ const resolvedModelRef = modelRefToString(resolvedModel) ?? modelRouting.candidates[0];
33240
33271
  if (modelRouting.droppedRequested) {
33241
33272
  appendEventFireAndForget(input.manifest.eventsPath, {
33242
33273
  type: "task.model_dropped",
@@ -33335,6 +33366,7 @@ async function runLiveSessionTask(input) {
33335
33366
  appendEvent,
33336
33367
  input.manifest.eventsPath
33337
33368
  );
33369
+ registerLiveAgentModel(agentId, resolvedModelRef ?? "");
33338
33370
  streamOut = createStreamingOutput(input.manifest, input.task.id);
33339
33371
  let controlCursor = { offset: 0 };
33340
33372
  const seenControlRequestIds = /* @__PURE__ */ new Set();
@@ -33470,7 +33502,10 @@ ${input.prompt}` : input.prompt;
33470
33502
  });
33471
33503
  const sessionTimeoutMs = DEFAULT_LIVE_SESSION.responseTimeoutMs;
33472
33504
  try {
33473
- await promptWithTimeout(session, effectivePrompt, sessionTimeoutMs, "Live-session");
33505
+ await liveAgentContext.run(
33506
+ { agentId, modelRef: resolvedModelRef ?? "" },
33507
+ () => promptWithTimeout(session, effectivePrompt, sessionTimeoutMs, "Live-session")
33508
+ );
33474
33509
  } catch (promptError) {
33475
33510
  const msg = promptError instanceof Error ? promptError.message : String(promptError);
33476
33511
  appendEventFireAndForget(input.manifest.eventsPath, {
@@ -33643,6 +33678,7 @@ ${input.prompt}` : input.prompt;
33643
33678
  error: message
33644
33679
  };
33645
33680
  } finally {
33681
+ unregisterLiveAgentModel(agentId);
33646
33682
  unsubscribe?.();
33647
33683
  unsubscribeControlRealtime?.();
33648
33684
  if (onSignalAbort) input.signal?.removeEventListener("abort", onSignalAbort);
@@ -33681,6 +33717,7 @@ var init_live_session_runtime = __esm({
33681
33717
  init_model_scope();
33682
33718
  init_runtime_resolver();
33683
33719
  init_runtime_warmup();
33720
+ init_session_model();
33684
33721
  init_sidechain_output();
33685
33722
  init_streaming_output();
33686
33723
  init_sensitive_paths();
@@ -49170,7 +49207,7 @@ var init_dispatch = __esm({
49170
49207
  });
49171
49208
 
49172
49209
  // src/observability/correlation.ts
49173
- import { AsyncLocalStorage as AsyncLocalStorage2 } from "node:async_hooks";
49210
+ import { AsyncLocalStorage as AsyncLocalStorage3 } from "node:async_hooks";
49174
49211
  import { randomBytes as randomBytes2 } from "node:crypto";
49175
49212
  function withCorrelation(ctx, fn) {
49176
49213
  return storage.run(ctx, fn);
@@ -49194,7 +49231,7 @@ var storage;
49194
49231
  var init_correlation = __esm({
49195
49232
  "src/observability/correlation.ts"() {
49196
49233
  "use strict";
49197
- storage = new AsyncLocalStorage2();
49234
+ storage = new AsyncLocalStorage3();
49198
49235
  }
49199
49236
  });
49200
49237
 
@@ -69560,6 +69597,19 @@ var init_notification_router = __esm({
69560
69597
  ...notification,
69561
69598
  timestamp: notification.timestamp ?? now
69562
69599
  };
69600
+ if (withTime.clear) {
69601
+ const clearKey = notificationKey(withTime);
69602
+ const wasInSeen = this.seen.delete(clearKey);
69603
+ if (wasInSeen) {
69604
+ try {
69605
+ this.opts.sink?.(withTime);
69606
+ } catch (sinkError) {
69607
+ logInternalError("notification-sink", sinkError);
69608
+ }
69609
+ this.deliver(withTime);
69610
+ }
69611
+ return true;
69612
+ }
69563
69613
  try {
69564
69614
  this.opts.sink?.(withTime);
69565
69615
  } catch (sinkError) {
@@ -70092,11 +70142,15 @@ async function configureNotifications(ctx, state2, deps) {
70092
70142
  sink: (notification) => state2.notificationSink?.write(notification)
70093
70143
  },
70094
70144
  (notification) => {
70095
- deps.widgetState.notificationCount = (deps.widgetState.notificationCount ?? 0) + 1;
70096
- sendFollowUp2(
70097
- deps.pi,
70098
- [notification.title, notification.body, notification.runId ? `Run: ${notification.runId}` : void 0].filter((line4) => Boolean(line4)).join("\n")
70099
- );
70145
+ if (notification.clear) {
70146
+ deps.widgetState.notificationCount = Math.max(0, (deps.widgetState.notificationCount ?? 0) - 1);
70147
+ } else {
70148
+ deps.widgetState.notificationCount = (deps.widgetState.notificationCount ?? 0) + 1;
70149
+ sendFollowUp2(
70150
+ deps.pi,
70151
+ [notification.title, notification.body, notification.runId ? `Run: ${notification.runId}` : void 0].filter((line4) => Boolean(line4)).join("\n")
70152
+ );
70153
+ }
70100
70154
  const currentCtx = deps.getCurrentCtx();
70101
70155
  if (currentCtx) {
70102
70156
  const uiConfig = loadConfig(currentCtx.cwd).config.ui;
@@ -76664,7 +76718,6 @@ function safeStringify(value) {
76664
76718
  // src/extension/registration/lifecycle-handlers.ts
76665
76719
  init_child_pi();
76666
76720
  init_live_agent_manager();
76667
- init_model_fallback();
76668
76721
  init_pi_args();
76669
76722
  init_provider_quota();
76670
76723
  init_session_model();
@@ -76963,8 +77016,7 @@ function installModelTrackingHandlers(pi) {
76963
77016
  noteSessionThinking(event.level);
76964
77017
  });
76965
77018
  pi.on("after_provider_response", (event) => {
76966
- const model = currentSessionModel();
76967
- const provider = model ? providerOfModelRef(model) : void 0;
77019
+ const provider = resolveProviderForResponse();
76968
77020
  if (provider) noteProviderResponse(provider, event.status, event.headers);
76969
77021
  });
76970
77022
  }
@@ -77361,12 +77413,40 @@ function setupRenderLoop(pi, ctx, extensionCtx, loadedConfig) {
77361
77413
  const currentSessionId = ctx.currentCtx?.sessionManager?.getSessionId();
77362
77414
  const sessionManifests = filterManifestsForHealthNotifications(manifests, currentSessionId);
77363
77415
  const now = Date.now();
77416
+ const clearHealthNotifications = (runId) => {
77417
+ for (const kind of ["recovery_dead_workers", "recovery_missing_heartbeat"]) {
77418
+ const key = `${kind}_${runId}`;
77419
+ ctx.autoRecoveryLast.delete(key);
77420
+ ctx.notifyOperator({
77421
+ id: key,
77422
+ clear: true,
77423
+ severity: "info",
77424
+ source: "health",
77425
+ runId,
77426
+ title: `Cleared ${kind} for ${runId}`
77427
+ });
77428
+ }
77429
+ };
77364
77430
  for (const run of sessionManifests) {
77365
- if (run.status !== "running") continue;
77431
+ if (run.status !== "running") {
77432
+ snapshotCache.invalidate(run.runId);
77433
+ clearHealthNotifications(run.runId);
77434
+ continue;
77435
+ }
77366
77436
  try {
77437
+ const freshManifest = ctx.getManifestCache(extensionCtx.cwd).get(run.runId);
77438
+ if (freshManifest?.status !== "running") {
77439
+ snapshotCache.invalidate(run.runId);
77440
+ clearHealthNotifications(run.runId);
77441
+ continue;
77442
+ }
77367
77443
  const snapshot = snapshotCache.get(run.runId);
77368
77444
  if (!snapshot) continue;
77369
- if (snapshot.manifest.status !== "running") continue;
77445
+ if (snapshot.manifest.status !== "running") {
77446
+ snapshotCache.invalidate(run.runId);
77447
+ clearHealthNotifications(run.runId);
77448
+ continue;
77449
+ }
77370
77450
  const summary = summarizeHeartbeats(snapshot, { now });
77371
77451
  const maybeNotifyHealth = (kind, count2, title, body) => {
77372
77452
  if (count2 <= 0) return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-crew",
3
- "version": "0.9.61",
3
+ "version": "0.9.62",
4
4
  "description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
5
5
  "author": "baphuongna",
6
6
  "license": "MIT",
@@ -10,6 +10,8 @@ export interface NotificationDescriptor {
10
10
  title: string;
11
11
  body?: string;
12
12
  timestamp?: number;
13
+ /** When true, this is a dismiss/clear request for a previously-emitted notification (same id). Bypasses dedup/severity/quiet-hours; the deliver callback drops the matching notification. */
14
+ clear?: boolean;
13
15
  }
14
16
 
15
17
  export interface NotificationRouterOptions {
@@ -99,6 +101,29 @@ export class NotificationRouter {
99
101
  ...notification,
100
102
  timestamp: notification.timestamp ?? now,
101
103
  };
104
+ // Clear path: remove the id from `seen` so a future genuine re-occurrence
105
+ // can re-notify. Delivery is guarded on whether the id was actually in
106
+ // `seen` to keep the clear idempotent (bypassing dedup / severity filter /
107
+ // quiet hours when it does deliver — a clear must go through so the
108
+ // dashboard can drop the previously-emitted notification).
109
+ if (withTime.clear) {
110
+ const clearKey = notificationKey(withTime);
111
+ // Idempotent: only deliver the clear if the id was actually in `seen`
112
+ // (i.e., a notification for it was previously emitted and not yet
113
+ // cleared). This prevents multi-fire drift when the render loop calls
114
+ // clearHealthNotifications on consecutive ticks for the same terminal
115
+ // run — each clear after the first is a silent no-op that returns true.
116
+ const wasInSeen = this.seen.delete(clearKey);
117
+ if (wasInSeen) {
118
+ try {
119
+ this.opts.sink?.(withTime);
120
+ } catch (sinkError) {
121
+ logInternalError("notification-sink", sinkError);
122
+ }
123
+ this.deliver(withTime);
124
+ }
125
+ return true;
126
+ }
102
127
  try {
103
128
  this.opts.sink?.(withTime);
104
129
  } catch (sinkError) {
@@ -25,10 +25,9 @@ import { CrewBroker } from "../../runtime/broker/crew-broker.ts";
25
25
  import { terminateActiveChildPiProcesses } from "../../runtime/child-pi/child-pi.ts";
26
26
  import { listLiveAgents } from "../../runtime/live-session/live-agent-manager.ts";
27
27
  import type { createManifestCache } from "../../runtime/manifest-cache.ts";
28
- import { providerOfModelRef } from "../../runtime/model/model-fallback.ts";
29
28
  import { cleanupLegacyOrphanTempDirs, cleanupOrphanTempDirs, currentCrewDepth } from "../../runtime/model/pi-args.ts";
30
29
  import { clearProviderQuotaCache, noteProviderResponse } from "../../runtime/model/provider-quota.ts";
31
- import { currentSessionModel, noteSessionModel, noteSessionThinking } from "../../runtime/model/session-model.ts";
30
+ import { noteSessionModel, noteSessionThinking, resolveProviderForResponse } from "../../runtime/model/session-model.ts";
32
31
  import { cleanupOrphanWorkers } from "../../runtime/orphan-worker-registry.ts";
33
32
  import { reconcileAllStaleRuns } from "../../runtime/recovery/crash-recovery.ts";
34
33
  import { CrewScheduler, type ScheduledJob } from "../../runtime/scheduling/scheduler.ts";
@@ -93,8 +92,7 @@ function installModelTrackingHandlers(pi: ExtensionAPI): void {
93
92
  // providers. The event doesn't carry a provider field, so we attribute it
94
93
  // to the currently tracked session model's provider.
95
94
  pi.on("after_provider_response", (event) => {
96
- const model = currentSessionModel();
97
- const provider = model ? providerOfModelRef(model) : undefined;
95
+ const provider = resolveProviderForResponse();
98
96
  if (provider) noteProviderResponse(provider, event.status, event.headers);
99
97
  });
100
98
  }
@@ -666,12 +664,55 @@ function setupRenderLoop(
666
664
  const currentSessionId = ctx.currentCtx?.sessionManager?.getSessionId();
667
665
  const sessionManifests = filterManifestsForHealthNotifications(manifests, currentSessionId);
668
666
  const now = Date.now();
667
+ // FIX #2: clear path — when a run is detected terminal, dismiss any
668
+ // previously-emitted health notification for it AND drop its cooldown
669
+ // (autoRecoveryLast) so a future genuine re-occurrence can re-notify.
670
+ // Keeps the dashboard clean and stops the 5-min re-fire cycle.
671
+ const clearHealthNotifications = (runId: string): void => {
672
+ for (const kind of ["recovery_dead_workers", "recovery_missing_heartbeat"]) {
673
+ const key = `${kind}_${runId}`;
674
+ ctx.autoRecoveryLast.delete(key);
675
+ ctx.notifyOperator({
676
+ id: key,
677
+ clear: true,
678
+ severity: "info",
679
+ source: "health",
680
+ runId,
681
+ title: `Cleared ${kind} for ${runId}`,
682
+ });
683
+ }
684
+ };
669
685
  for (const run of sessionManifests) {
670
- if (run.status !== "running") continue;
686
+ if (run.status !== "running") {
687
+ // GATE 1 — preloaded manifest says terminal. Purge any stale snapshot
688
+ // and clear previously-emitted health notifications so the dashboard
689
+ // stays clean (belt-and-suspenders with the FIX #1 fresh-read gate).
690
+ snapshotCache.invalidate(run.runId);
691
+ clearHealthNotifications(run.runId);
692
+ continue;
693
+ }
671
694
  try {
695
+ // FIX #1: re-verify against a FRESH manifest read. The preloaded `run`
696
+ // (from lastPreloadedManifests) can lag the on-disk terminal
697
+ // transition; the manifest cache has a 500ms TTL + file watcher so it
698
+ // is the source of truth. A terminal run must NEVER reach
699
+ // maybeNotifyHealth. Also purge the stale snapshot + clear any
700
+ // previously-emitted health notification for this run.
701
+ const freshManifest = ctx.getManifestCache(extensionCtx.cwd).get(run.runId);
702
+ if (freshManifest?.status !== "running") {
703
+ snapshotCache.invalidate(run.runId);
704
+ clearHealthNotifications(run.runId);
705
+ continue;
706
+ }
672
707
  const snapshot = snapshotCache.get(run.runId);
673
708
  if (!snapshot) continue;
674
- if (snapshot.manifest.status !== "running") continue;
709
+ if (snapshot.manifest.status !== "running") {
710
+ // GATE 2 — a running snapshot paired with a now-terminal manifest is
711
+ // stale. Purge it so subsequent ticks get a fresh view, and clear.
712
+ snapshotCache.invalidate(run.runId);
713
+ clearHealthNotifications(run.runId);
714
+ continue;
715
+ }
675
716
  const summary = summarizeHeartbeats(snapshot, { now });
676
717
  const maybeNotifyHealth = (kind: string, count: number, title: string, body: string): void => {
677
718
  if (count <= 0) return;
@@ -148,13 +148,19 @@ export async function configureNotifications(ctx: ExtensionContext, state: Lifec
148
148
  sink: (notification) => state.notificationSink?.write(notification),
149
149
  },
150
150
  (notification: NotificationDescriptor) => {
151
- deps.widgetState.notificationCount = (deps.widgetState.notificationCount ?? 0) + 1;
152
- sendFollowUp(
153
- deps.pi,
154
- [notification.title, notification.body, notification.runId ? `Run: ${notification.runId}` : undefined]
155
- .filter((line): line is string => Boolean(line))
156
- .join("\n"),
157
- );
151
+ if (notification.clear) {
152
+ // Clear/dismiss: decrement the counter (floor 0) and skip the
153
+ // follow-up message — this is a dismissal, not a new alert.
154
+ deps.widgetState.notificationCount = Math.max(0, (deps.widgetState.notificationCount ?? 0) - 1);
155
+ } else {
156
+ deps.widgetState.notificationCount = (deps.widgetState.notificationCount ?? 0) + 1;
157
+ sendFollowUp(
158
+ deps.pi,
159
+ [notification.title, notification.body, notification.runId ? `Run: ${notification.runId}` : undefined]
160
+ .filter((line): line is string => Boolean(line))
161
+ .join("\n"),
162
+ );
163
+ }
158
164
  const currentCtx = deps.getCurrentCtx();
159
165
  if (currentCtx) {
160
166
  const uiConfig = loadConfig(currentCtx.cwd).config.ui;
@@ -26,6 +26,7 @@ import {
26
26
  import { readEnabledModelsPatterns } from "../model/model-scope.ts";
27
27
  import { isLiveSessionRuntimeAvailable } from "../model/runtime-resolver.ts";
28
28
  import { awaitRuntimeWarmup } from "../model/runtime-warmup.ts";
29
+ import { liveAgentContext, registerLiveAgentModel, unregisterLiveAgentModel } from "../model/session-model.ts";
29
30
  import { eventToSidechainType, sidechainOutputPath, writeSidechainEntry } from "../output/sidechain-output.ts";
30
31
  // NOTE: buildMemoryBlock is intentionally NOT imported here. The agent memory
31
32
  // block is injected via renderTaskPrompt().full (the USER prompt), which is
@@ -705,6 +706,7 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
705
706
  });
706
707
  const resolvedModel =
707
708
  modelFromRegistry(input.modelRegistry, modelRouting.candidates[0] ?? modelRouting.requested) ?? input.parentModel;
709
+ const resolvedModelRef = modelRefToString(resolvedModel) ?? modelRouting.candidates[0];
708
710
  // Surface a warning when the caller's requested model was silently replaced.
709
711
  if (modelRouting.droppedRequested) {
710
712
  appendEventFireAndForget(input.manifest.eventsPath, {
@@ -839,6 +841,7 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
839
841
  appendEvent,
840
842
  input.manifest.eventsPath,
841
843
  );
844
+ registerLiveAgentModel(agentId, resolvedModelRef ?? "");
842
845
  streamOut = createStreamingOutput(input.manifest, input.task.id);
843
846
  let controlCursor: LiveAgentControlCursor = { offset: 0 };
844
847
  const seenControlRequestIds = new Set<string>();
@@ -988,7 +991,9 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
988
991
  // Phase 3: Wrap session.prompt with timeout for graceful cancellation
989
992
  const sessionTimeoutMs = DEFAULT_LIVE_SESSION.responseTimeoutMs;
990
993
  try {
991
- await promptWithTimeout(session, effectivePrompt, sessionTimeoutMs, "Live-session");
994
+ await liveAgentContext.run({ agentId, modelRef: resolvedModelRef ?? "" }, () =>
995
+ promptWithTimeout(session!, effectivePrompt, sessionTimeoutMs, "Live-session"),
996
+ );
992
997
  } catch (promptError) {
993
998
  const msg = promptError instanceof Error ? promptError.message : String(promptError);
994
999
  // P7: fire-and-forget — return value not needed.
@@ -1181,6 +1186,12 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
1181
1186
  error: message,
1182
1187
  };
1183
1188
  } finally {
1189
+ // Unregister the live-agent model FIRST: a synchronous Map.delete that must run on
1190
+ // every exit path. If skipped (e.g. terminateLiveAgent throws on session.abort()),
1191
+ // hasActiveLiveAgents() stays true for the process lifetime and permanently disables
1192
+ // quota attribution (priority-2 skip) — including the main session's own tracking.
1193
+ // (Review finding H1/M1.)
1194
+ unregisterLiveAgentModel(agentId);
1184
1195
  // H6: Unsubscribe listeners FIRST before clearing timer to prevent race
1185
1196
  unsubscribe?.();
1186
1197
  unsubscribeControlRealtime?.();
@@ -17,8 +17,10 @@
17
17
  * the pi events; the spawn paths read it through {@link resolveParentModel}.
18
18
  */
19
19
 
20
+ import { AsyncLocalStorage } from "node:async_hooks";
20
21
  import type { RunModelContext } from "../../state/types.ts";
21
- import { availableModelInfosFromRegistry, modelRefToString } from "./model-fallback.ts";
22
+ import { logInternalError } from "../../utils/internal-error.ts";
23
+ import { availableModelInfosFromRegistry, modelRefToString, providerOfModelRef } from "./model-fallback.ts";
22
24
 
23
25
  export type SessionModelSource = "model_select" | "session_start" | "none";
24
26
 
@@ -31,6 +33,78 @@ interface SessionModelState {
31
33
 
32
34
  const state: SessionModelState = { source: "none" };
33
35
 
36
+ // --- Live-session per-agent quota attribution ---
37
+ //
38
+ // In the opt-in `live-session` runtime, multiple in-process subagents share
39
+ // this ONE module-scoped tracker. The `after_provider_response` event carries
40
+ // no sessionId/model field, so the global `currentSessionModel()` returns the
41
+ // MAIN session's model regardless of which in-process agent actually produced
42
+ // the response. That mis-attributes quota (e.g. a provider-B 429 written under
43
+ // provider-A's key).
44
+ //
45
+ // AsyncLocalStorage propagates each live agent's known model through the
46
+ // async call chain. `resolveProviderForResponse()` checks it first, then falls
47
+ // back to a guard (skip attribution when live agents are active but context
48
+ // is absent — prevents contamination), then the original global tracker (the
49
+ // default child-process path, unchanged).
50
+
51
+ /** Per-agent async context for live-session quota attribution. */
52
+ export const liveAgentContext = new AsyncLocalStorage<{ agentId: string; modelRef: string }>();
53
+
54
+ /** Registered live-session agent models (agentId → "provider/id"). */
55
+ const liveAgentModels = new Map<string, string>();
56
+
57
+ // Cap the tracker to prevent unbounded growth if a caller registers an agent
58
+ // but fails to unregister it (e.g. a crashed/disposed live agent). Matches the
59
+ // precedent in live-agent-manager.ts (MAX_LIVE_AGENTS). When at cap, evict the
60
+ // oldest insertion (Map preserves insertion order); a leaked entry also pins
61
+ // hasActiveLiveAgents()=true, so bounding it matters beyond raw memory.
62
+ const MAX_LIVE_AGENT_MODELS = 5_000;
63
+
64
+ /** Record a live-session agent's resolved model for quota attribution. */
65
+ export function registerLiveAgentModel(agentId: string, model: string): void {
66
+ if (liveAgentModels.size >= MAX_LIVE_AGENT_MODELS && !liveAgentModels.has(agentId)) {
67
+ const oldestKey = liveAgentModels.keys().next().value;
68
+ if (oldestKey !== undefined) {
69
+ logInternalError(
70
+ "session-model.liveAgentModels.cap",
71
+ new Error(`liveAgentModels at cap ${MAX_LIVE_AGENT_MODELS}; evicting oldest ${oldestKey}`),
72
+ );
73
+ liveAgentModels.delete(oldestKey);
74
+ }
75
+ }
76
+ liveAgentModels.set(agentId, model);
77
+ }
78
+
79
+ /** Remove a live-session agent's model (called in the finally block). */
80
+ export function unregisterLiveAgentModel(agentId: string): void {
81
+ liveAgentModels.delete(agentId);
82
+ }
83
+
84
+ /** Whether any live-session agents are currently registered. */
85
+ export function hasActiveLiveAgents(): boolean {
86
+ return liveAgentModels.size > 0;
87
+ }
88
+
89
+ /**
90
+ * Resolve the provider for an `after_provider_response` event.
91
+ *
92
+ * Priority:
93
+ * 1. Async context from the live-session agent that issued the request
94
+ * (correct per-agent attribution — pi-crew knows each agent's model).
95
+ * 2. Live agents are active but the context didn't propagate → skip
96
+ * attribution entirely (return undefined) to PREVENT cross-agent
97
+ * contamination.
98
+ * 3. No live agents (default child-process runtime) → original behavior:
99
+ * attribute to the global session model's provider.
100
+ */
101
+ export function resolveProviderForResponse(): string | undefined {
102
+ const ctx = liveAgentContext.getStore();
103
+ if (ctx) return providerOfModelRef(ctx.modelRef);
104
+ if (hasActiveLiveAgents()) return undefined;
105
+ return providerOfModelRef(currentSessionModel());
106
+ }
107
+
34
108
  /**
35
109
  * Record the model the main session is running. Accepts pi's `Model` object
36
110
  * (`{ provider, id }`) or a `"provider/id"` string; anything unrecognized is
@@ -132,4 +206,5 @@ export function __test_resetSessionModel(): void {
132
206
  state.thinking = undefined;
133
207
  state.source = "none";
134
208
  state.updatedAt = undefined;
209
+ liveAgentModels.clear();
135
210
  }