@oh-my-matrix/autopilot 2.1.1 → 3.0.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.
Files changed (65) hide show
  1. package/README.md +105 -10
  2. package/dist/index.d.ts +2 -1
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +308 -92
  5. package/dist/index.js.map +1 -1
  6. package/dist/src/autopilot-state.d.ts +17 -0
  7. package/dist/src/autopilot-state.d.ts.map +1 -1
  8. package/dist/src/autopilot-state.js +52 -8
  9. package/dist/src/autopilot-state.js.map +1 -1
  10. package/dist/src/command-runner.d.ts +2 -6
  11. package/dist/src/command-runner.d.ts.map +1 -1
  12. package/dist/src/command-runner.js +4 -34
  13. package/dist/src/command-runner.js.map +1 -1
  14. package/dist/src/completion-detector.d.ts.map +1 -1
  15. package/dist/src/completion-detector.js +16 -8
  16. package/dist/src/completion-detector.js.map +1 -1
  17. package/dist/src/effort-injection.d.ts +21 -2
  18. package/dist/src/effort-injection.d.ts.map +1 -1
  19. package/dist/src/effort-injection.js +33 -9
  20. package/dist/src/effort-injection.js.map +1 -1
  21. package/dist/src/event-shape.contract.d.ts +21 -0
  22. package/dist/src/event-shape.contract.d.ts.map +1 -0
  23. package/dist/src/event-shape.contract.js +10 -0
  24. package/dist/src/event-shape.contract.js.map +1 -0
  25. package/dist/src/logger.d.ts.map +1 -1
  26. package/dist/src/logger.js +42 -7
  27. package/dist/src/logger.js.map +1 -1
  28. package/dist/src/model-routing.d.ts +36 -0
  29. package/dist/src/model-routing.d.ts.map +1 -0
  30. package/dist/src/model-routing.js +106 -0
  31. package/dist/src/model-routing.js.map +1 -0
  32. package/dist/src/orchestrator.d.ts +34 -1
  33. package/dist/src/orchestrator.d.ts.map +1 -1
  34. package/dist/src/orchestrator.js +113 -12
  35. package/dist/src/orchestrator.js.map +1 -1
  36. package/dist/src/projection.d.ts +8 -2
  37. package/dist/src/projection.d.ts.map +1 -1
  38. package/dist/src/projection.js +15 -1
  39. package/dist/src/projection.js.map +1 -1
  40. package/dist/src/stall-detector.d.ts +4 -1
  41. package/dist/src/stall-detector.d.ts.map +1 -1
  42. package/dist/src/stall-detector.js +7 -3
  43. package/dist/src/stall-detector.js.map +1 -1
  44. package/dist/src/tool-error-tracker.d.ts +16 -0
  45. package/dist/src/tool-error-tracker.d.ts.map +1 -1
  46. package/dist/src/tool-error-tracker.js +16 -0
  47. package/dist/src/tool-error-tracker.js.map +1 -1
  48. package/dist/src/types.d.ts +67 -5
  49. package/dist/src/types.d.ts.map +1 -1
  50. package/dist/src/types.js +45 -1
  51. package/dist/src/types.js.map +1 -1
  52. package/dist/src/workflow-config.d.ts.map +1 -1
  53. package/dist/src/workflow-config.js +108 -14
  54. package/dist/src/workflow-config.js.map +1 -1
  55. package/openclaw.plugin.json +34 -2
  56. package/package.json +23 -13
  57. package/LICENSE +0 -21
  58. package/dist/src/audit-persister.d.ts +0 -25
  59. package/dist/src/audit-persister.d.ts.map +0 -1
  60. package/dist/src/audit-persister.js +0 -162
  61. package/dist/src/audit-persister.js.map +0 -1
  62. package/dist/src/permission-policy.d.ts +0 -36
  63. package/dist/src/permission-policy.d.ts.map +0 -1
  64. package/dist/src/permission-policy.js +0 -265
  65. package/dist/src/permission-policy.js.map +0 -1
package/dist/index.js CHANGED
@@ -11,6 +11,7 @@ const continuation_engine_1 = require("./src/continuation-engine");
11
11
  const tool_error_tracker_1 = require("./src/tool-error-tracker");
12
12
  const stall_detector_1 = require("./src/stall-detector");
13
13
  const effort_injection_1 = require("./src/effort-injection");
14
+ const model_routing_1 = require("./src/model-routing");
14
15
  const logger_1 = require("./src/logger");
15
16
  const autopilot_state_1 = require("./src/autopilot-state");
16
17
  const goal_manager_1 = require("./src/goal-manager");
@@ -48,7 +49,9 @@ function validateWorkspacePath(p) {
48
49
  }
49
50
  exports.id = 'autopilot';
50
51
  exports.name = 'Autopilot Continuous Mode';
51
- exports.version = '2.0.0';
52
+ // S14 (audit 2026-06-30): keep in sync with package.json + openclaw.plugin.json.
53
+ // These three are the release-time single source of truth for the plugin version.
54
+ exports.version = '3.0.0';
52
55
  /** GAP-25: Maximum number of concurrent run states before eviction kicks in */
53
56
  const MAX_RUN_STATES = 50;
54
57
  /** GAP-26: Health check threshold — sessions inactive for 24h are orphaned */
@@ -57,7 +60,51 @@ let stateByRun = new Map();
57
60
  let sessionIdToKey = new Map();
58
61
  let sessionKeyToRunId = new Map();
59
62
  let canaryFired = new Set();
63
+ // S10: one-shot "host not reporting usage" warn per session — avoids log spam
64
+ // while making the silent tokenBudget no-op observable to operators.
65
+ let noUsageWarned = new Set();
60
66
  let stallInterval = null;
67
+ let enqueueInjectionFn;
68
+ /**
69
+ * PROD-7 / LOGIC-4: actuator for stall / programmatic-resume recovery. The
70
+ * reducer moves a run to `claimed` (retry_due or resume_requested) and marks
71
+ * needsCrossTurnResume, but a claimed run cannot start an agent turn on its own —
72
+ * the host only fires agent_turn_prepare when it dispatches a turn. For a
73
+ * genuinely dead agent (stall) or a gateway resume with no follow-up user
74
+ * message, no turn ever comes, so the run would sit in `claimed` until the 24h
75
+ * orphan sweep. This kicks a cross-turn injection so the host actually restarts
76
+ * execution. idempotencyKey (keyed on lastActivityAt) dedupes double-injection
77
+ * of the same recovery event while allowing successive retries to each fire.
78
+ */
79
+ function kickResumedTurn(runId, state) {
80
+ if (state.orchestrationState !== 'claimed')
81
+ return;
82
+ const enqueue = enqueueInjectionFn;
83
+ if (typeof enqueue !== 'function')
84
+ return;
85
+ void Promise.resolve(enqueue({
86
+ sessionKey: state.sessionKey,
87
+ text: (0, continuation_engine_1.buildRetryInstruction)(state),
88
+ idempotencyKey: `autopilot-resume-${runId}-${state.lastActivityAt ?? state.totalContinuations}`,
89
+ placement: 'prepend_context',
90
+ // REV-2 fix: match the stall timeout, not the raw default. A no-budget run
91
+ // stalls at ×2 (600s); the injection TTL must outlive it or the host boots
92
+ // a resumed turn after the injection already expired.
93
+ ttlMs: state.workflow?.stallTimeoutMs ?? defaultStallTimeoutMs(!!state.tokenBudget),
94
+ })).catch((err) => (0, logger_1.warn)(`[autopilot] resume kick enqueue failed for session=${state.sessionKey}: ${err}`));
95
+ }
96
+ /**
97
+ * L3: resolve the stall timeout fallback when a run has no per-run config.
98
+ * Runs without a token budget get double the default (undocumented ops tuning —
99
+ * no-budget runs tend to be long-running and tolerate longer silence before
100
+ * being declared stalled). Extracted to a named helper so the ×2 rationale is
101
+ * documented in one place, not duplicated at two call sites.
102
+ */
103
+ function defaultStallTimeoutMs(hasTokenBudget) {
104
+ return hasTokenBudget
105
+ ? workflow_config_1.DEFAULT_WORKFLOW_CONFIG.stallTimeoutMs
106
+ : workflow_config_1.DEFAULT_WORKFLOW_CONFIG.stallTimeoutMs * 2;
107
+ }
61
108
  /**
62
109
  * before_tool_call priority — must be higher than matrixassistant-audit (priority 9).
63
110
  * Ensures autopilot audit trail is recorded before audit can short-circuit.
@@ -97,6 +144,8 @@ function _resetForTest() {
97
144
  sessionIdToKey = new Map();
98
145
  sessionKeyToRunId = new Map();
99
146
  canaryFired = new Set();
147
+ noUsageWarned = new Set();
148
+ enqueueInjectionFn = undefined;
100
149
  if (stallInterval) {
101
150
  clearInterval(stallInterval);
102
151
  stallInterval = null;
@@ -161,28 +210,37 @@ function _triggerRetryCheckForTest(overrides) {
161
210
  state.retry.nextRetryAt <= now) {
162
211
  const updated = (0, orchestrator_1.orchestratorReducer)(state, { type: 'retry_due', runId, now });
163
212
  setState(runId, updated);
213
+ // PROD-7: retry_due→claimed is a dead end without an actuator — kick a turn.
214
+ if (updated.orchestrationState === 'claimed')
215
+ kickResumedTurn(runId, updated);
164
216
  }
165
217
  return stateByRun.get(runId);
166
218
  }
167
- /** GAP-25: Evict oldest runs when Map exceeds MAX_RUN_STATES */
219
+ /** GAP-25: Evict least-recently-active runs when Map exceeds MAX_RUN_STATES */
168
220
  function evictOldestRuns() {
169
221
  while (stateByRun.size > MAX_RUN_STATES) {
170
- // Find the run with the earliest startedAt (FIFO eviction)
222
+ // Find the run with the earliest lastActivityAt (LRU eviction).
223
+ // Falls back to startedAt when lastActivityAt is unset (pre-orchestrator runs).
171
224
  let oldestRunId = null;
172
225
  let oldestAt = Infinity;
173
226
  for (const [runId, state] of stateByRun) {
174
- if ((state.startedAt ?? Infinity) < oldestAt) {
175
- oldestAt = state.startedAt ?? Infinity;
227
+ const at = state.lastActivityAt ?? state.startedAt ?? Infinity;
228
+ if (at < oldestAt) {
229
+ oldestAt = at;
176
230
  oldestRunId = runId;
177
231
  }
178
232
  }
179
233
  if (oldestRunId == null)
180
234
  break;
181
235
  const oldestState = stateByRun.get(oldestRunId);
236
+ // S8: release audit refCount for an evicted still-running run before delete.
237
+ if (oldestState?.status === 'running')
238
+ setAuditMode('active');
182
239
  stateByRun.delete(oldestRunId);
183
240
  if (oldestState) {
184
241
  sessionKeyToRunId.delete(oldestState.sessionKey);
185
242
  canaryFired.delete(oldestState.sessionKey);
243
+ noUsageWarned.delete(oldestState.sessionKey);
186
244
  // GAP-25: also clean up sessionIdToKey to prevent orphaned sid→skey entries
187
245
  for (const [sid, skey] of sessionIdToKey) {
188
246
  if (skey === oldestState.sessionKey) {
@@ -200,15 +258,20 @@ function setState(runId, state) {
200
258
  }
201
259
  /** GAP-23: Cleanup all state on shutdown */
202
260
  function cleanupAll() {
203
- // Release audit monitor refCount for every active run before clearing state.
204
- // Each run acquired one refCount via setAuditMode('monitor'), so each needs a matching release.
205
- for (let i = 0; i < stateByRun.size; i++) {
206
- setAuditMode('active');
261
+ // Release audit monitor refCount ONLY for runs that still hold one. A run
262
+ // acquires it on activate and releases on pause/complete/stop, so only
263
+ // status==='running' runs still hold an unreleased refCount. Releasing for
264
+ // paused/done/idle runs would over-release — the audit plugin's refCount could
265
+ // go negative depending on its clamp semantics.
266
+ for (const state of stateByRun.values()) {
267
+ if (state.status === 'running')
268
+ setAuditMode('active');
207
269
  }
208
270
  stateByRun.clear();
209
271
  sessionIdToKey.clear();
210
272
  sessionKeyToRunId.clear();
211
273
  canaryFired.clear();
274
+ noUsageWarned.clear();
212
275
  if (stallInterval) {
213
276
  clearInterval(stallInterval);
214
277
  stallInterval = null;
@@ -221,18 +284,43 @@ function findRunBySession(sessionKey) {
221
284
  const state = stateByRun.get(runId);
222
285
  return state ? [runId, state] : undefined;
223
286
  }
287
+ // ponytail: production passes sessionKey on ctx, test mocks put it on event — one helper handles both
288
+ function resolveSessionKey(event, ctx) {
289
+ const c = ctx;
290
+ const e = event;
291
+ const sessionId = c?.sessionId ?? e?.sessionId;
292
+ return c?.sessionKey ?? e?.sessionKey ?? (sessionId != null ? sessionIdToKey.get(sessionId) : undefined);
293
+ }
294
+ const CROSS_TURN_FALLBACK_TEXT = 'Continue from where you left off.';
295
+ function buildCrossTurnReviseFallback(runId, state, instruction) {
296
+ const fallbackState = { ...(0, autopilot_state_1.incrementTotal)(state), turnAttempts: 0 };
297
+ setState(runId, fallbackState);
298
+ return {
299
+ action: 'revise',
300
+ retry: {
301
+ instruction: instruction || CROSS_TURN_FALLBACK_TEXT,
302
+ idempotencyKey: `autopilot-${runId}-${fallbackState.totalContinuations}`,
303
+ maxAttempts: fallbackState.maxAttemptsPerTurn,
304
+ },
305
+ };
306
+ }
224
307
  /** Generate a unique run ID using crypto.randomUUID (exported for testing). */
225
308
  function _generateRunIdForTest() {
226
309
  return `run-${crypto.randomUUID()}`;
227
310
  }
228
311
  function generateRunId() {
229
- return _generateRunIdForTest();
312
+ return `run-${crypto.randomUUID()}`;
230
313
  }
231
314
  function register(api) {
315
+ // PROD-7 / LOGIC-4: stash the cross-turn injection actuator so the stall
316
+ // interval and gateway resume (both outside per-call closures) can restart a
317
+ // claimed run. Optional-chained: undefined on hosts without the 5.28+ facade.
318
+ enqueueInjectionFn = api.session?.workflow?.enqueueNextTurnInjection;
232
319
  // Read user config from OpenClaw, merge with defaults
233
320
  // pluginConfig is Record<string, unknown> — coerce values to expected types
234
321
  const uc = api.pluginConfig ?? {};
235
322
  const numOrUndefined = (v) => typeof v === 'number' ? v : undefined;
323
+ const modelRouting = (0, model_routing_1.parseModelRouting)(uc.modelRouting);
236
324
  const config = {
237
325
  ...types_1.DEFAULT_CONFIG,
238
326
  ...(numOrUndefined(uc.maxAttemptsPerTurn) != null ? { maxAttemptsPerTurn: numOrUndefined(uc.maxAttemptsPerTurn) } : {}),
@@ -242,6 +330,11 @@ function register(api) {
242
330
  ...(Array.isArray(uc.highRiskTools) ? { highRiskTools: uc.highRiskTools } : {}),
243
331
  ...(numOrUndefined(uc.tokenBudget) != null ? { tokenBudget: numOrUndefined(uc.tokenBudget) } : {}),
244
332
  ...(numOrUndefined(uc.maxConcurrentAutopilot) != null ? { maxConcurrentAutopilot: numOrUndefined(uc.maxConcurrentAutopilot) } : {}),
333
+ ...(typeof uc.thinkingIntensity === 'string' && ['low', 'medium', 'high'].includes(uc.thinkingIntensity)
334
+ ? { thinkingIntensity: uc.thinkingIntensity }
335
+ : {}),
336
+ ...(modelRouting ? { modelRouting } : {}),
337
+ ...(typeof uc.trustWorkspace === 'boolean' ? { trustWorkspace: uc.trustWorkspace } : {}),
245
338
  };
246
339
  (0, logger_1.log)(`[autopilot] config: maxAttemptsPerTurn=${config.maxAttemptsPerTurn} maxTotalContinuations=${config.maxTotalContinuations} toolErrorThreshold=${config.toolErrorThreshold} excludedAgents=${JSON.stringify(config.excludedAgents)} highRiskTools=${JSON.stringify(config.highRiskTools)} tokenBudget=${config.tokenBudget}`);
247
340
  // --- Hooks (use api.on for typed hooks when available, registerHook as fallback) ---
@@ -251,7 +344,7 @@ function register(api) {
251
344
  return;
252
345
  }
253
346
  registerHook('before_agent_finalize', async (event) => {
254
- const sessionKey = event.sessionKey ?? sessionIdToKey.get(event.sessionId);
347
+ const sessionKey = resolveSessionKey(event);
255
348
  if (sessionKey)
256
349
  canaryFired.add(sessionKey);
257
350
  if (!sessionKey)
@@ -273,6 +366,15 @@ function register(api) {
273
366
  });
274
367
  (0, logger_1.log)(`[autopilot] before_agent_finalize: session=${sessionKey} action=${decision.action} turn=${state.turnAttempts}/${state.maxAttemptsPerTurn} total=${state.totalContinuations}/${state.maxTotalContinuations}`);
275
368
  switch (decision.action) {
369
+ case 'finalize': {
370
+ // S3 (audit 2026-06-30): decideContinuation returns 'finalize' when the
371
+ // run is disabled/non-running, or when stopHookActive is set (user hit
372
+ // stop). Previously this fell through to default and was silently
373
+ // rewritten to 'continue', leaving status='running' so stall/agent_end
374
+ // could revive a run the user had asked to stop. Match pause/complete:
375
+ // emit {action:'finalize'} so the host stops injecting revisions.
376
+ return { action: 'finalize' };
377
+ }
276
378
  case 'revise': {
277
379
  const updated = (0, autopilot_state_1.incrementTotal)((0, autopilot_state_1.incrementTurn)(state));
278
380
  setState(runId, updated);
@@ -295,49 +397,21 @@ function register(api) {
295
397
  text: decision.retryInstruction || 'Continue from where you left off.',
296
398
  idempotencyKey: `autopilot-cross-${sessionKey}-${updated.totalContinuations}`,
297
399
  placement: 'prepend_context',
298
- ttlMs: 300000,
400
+ ttlMs: workflow_config_1.DEFAULT_WORKFLOW_CONFIG.stallTimeoutMs,
299
401
  });
300
402
  if (result && typeof result === 'object' && result.enqueued === false) {
301
403
  (0, logger_1.warn)(`[autopilot] enqueueNextTurnInjection rejected for session=${sessionKey}, falling back to revise`);
302
- const fallbackState = { ...(0, autopilot_state_1.incrementTotal)(state), turnAttempts: 0 };
303
- setState(runId, fallbackState);
304
- return {
305
- action: 'revise',
306
- retry: {
307
- instruction: decision.retryInstruction || 'Continue from where you left off.',
308
- idempotencyKey: `autopilot-${runId}-${fallbackState.totalContinuations}`,
309
- maxAttempts: fallbackState.maxAttemptsPerTurn,
310
- },
311
- };
404
+ return buildCrossTurnReviseFallback(runId, state, decision.retryInstruction);
312
405
  }
313
406
  setState(runId, updated);
314
407
  return { action: 'finalize' };
315
408
  }
316
409
  catch (err) {
317
410
  (0, logger_1.warn)(`[autopilot] enqueueNextTurnInjection failed for session=${sessionKey}: ${err}, falling back to revise`);
318
- const fallbackState = { ...(0, autopilot_state_1.incrementTotal)(state), turnAttempts: 0 };
319
- setState(runId, fallbackState);
320
- return {
321
- action: 'revise',
322
- retry: {
323
- instruction: decision.retryInstruction || '请从上次中断的位置继续执行。',
324
- idempotencyKey: `autopilot-${runId}-${fallbackState.totalContinuations}`,
325
- maxAttempts: fallbackState.maxAttemptsPerTurn,
326
- },
327
- };
411
+ return buildCrossTurnReviseFallback(runId, state, decision.retryInstruction);
328
412
  }
329
413
  }
330
- // Fallback: injection API unavailable, use same-turn revise instead
331
- const fallbackState = { ...(0, autopilot_state_1.incrementTotal)(state), turnAttempts: 0 };
332
- setState(runId, fallbackState);
333
- return {
334
- action: 'revise',
335
- retry: {
336
- instruction: decision.retryInstruction || '请从上次中断的位置继续执行。',
337
- idempotencyKey: `autopilot-${runId}-${fallbackState.totalContinuations}`,
338
- maxAttempts: fallbackState.maxAttemptsPerTurn,
339
- },
340
- };
414
+ return buildCrossTurnReviseFallback(runId, state, decision.retryInstruction);
341
415
  }
342
416
  case 'pause': {
343
417
  setState(runId, (0, autopilot_state_1.pause)(state, decision.pauseReason));
@@ -363,14 +437,24 @@ function register(api) {
363
437
  });
364
438
  }
365
439
  catch (err) {
366
- // Fail-open: if evidence evaluation throws, treat as skipped to avoid zombie sessions
367
- (0, logger_1.warn)(`[autopilot] evidence gate error (failing open): ${err} session=${sessionKey}`);
440
+ // Fail-open (skipped) to avoid zombie sessions — runValidationCommands
441
+ // never throws and evaluateEvidence is pure, so this only trips on a
442
+ // future bug. But do NOT let that bug pass silently: emit it at error
443
+ // level with the failureReason so a monitor can tell an evaluation
444
+ // failure apart from a normal no-commands skip (both end as 'skipped').
445
+ (0, logger_1.logWithContext)('error', 'evidence gate evaluation error (failing open → skipped)', { sessionKey, runId, error: String(err) });
368
446
  evidenceSummary = { status: 'skipped', diffSummary: '', commands: [], completedAt: now, failureReason: 'evaluation error' };
369
447
  }
370
- // Dispatch evidence events only when orchestration state machine is active (released state)
448
+ // Dispatch orchestrator events to advance orchState to 'done'.
449
+ // Close the agent turn (running → released) so evidence events can fire.
371
450
  let updated = state;
372
- if (state.orchestrationState === 'released') {
373
- updated = (0, orchestrator_1.orchestratorReducer)(state, { type: 'evidence_started', runId, now });
451
+ if (updated.orchestrationState === 'running') {
452
+ updated = (0, orchestrator_1.orchestratorReducer)(updated, {
453
+ type: 'agent_turn_finished', runId, success: true, now,
454
+ });
455
+ }
456
+ if (updated.orchestrationState === 'released') {
457
+ updated = (0, orchestrator_1.orchestratorReducer)(updated, { type: 'evidence_started', runId, now });
374
458
  updated = (0, orchestrator_1.orchestratorReducer)(updated, {
375
459
  type: 'evidence_finished',
376
460
  runId,
@@ -378,13 +462,29 @@ function register(api) {
378
462
  evidence: evidenceSummary,
379
463
  });
380
464
  }
381
- // Apply evidence to state + mark done.
382
- // Guard (H1): evidence_finished (passed/skipped) already sets status:'done' via orchestrator.
383
- // complete() requires status='running' and would throw skip it when orchestrator completed.
384
- setState(runId, updated.status === 'done'
465
+ // Apply evidence result to state. The decision branches on evidence outcome:
466
+ // passed/skipped mark the run done; failed → retry or block (NOT done).
467
+ // Before this fix (H1), the code checked `updated.status === 'done'`, but the
468
+ // reducer leaves status='running' on evidence FAILURE, so failed evidence fell
469
+ // into complete() — producing a false 'done' + enabled:false, which stranded
470
+ // the run (the stall interval's retry_due guard checks state.enabled).
471
+ // See docs/audits/autopilot-correctness-review-2026-07-04.md HIGH finding.
472
+ setState(runId,
473
+ // failed → the reducer moved to retry_queued (will retry) or blocked (max
474
+ // retries). Keep the run enabled in retry_queued so the stall interval can
475
+ // fire retry_due. When blocked, the reducer already set orchState='blocked'
476
+ // + blockedReason + derived status='paused' — do NOT call pause() (it would
477
+ // throw: pause() requires status='running' but the reducer derived 'paused').
478
+ // Just attach the evidence summary; the state is already correct.
479
+ evidenceSummary.status === 'failed'
385
480
  ? { ...updated, evidence: evidenceSummary }
386
- : (0, autopilot_state_1.complete)({ ...updated, evidence: evidenceSummary }));
387
- (0, logger_1.logWithContext)('info', 'evidence gate result', { sessionKey, runId, evidenceStatus: evidenceSummary.status });
481
+ // passed/skipped reducer already set orchState='done' + status='done'
482
+ // when the evidence reducer ran; if it didn't run (orchState unchanged),
483
+ // complete() advances status to done. Either way the run finishes.
484
+ : updated.status === 'done'
485
+ ? { ...updated, evidence: evidenceSummary }
486
+ : (0, autopilot_state_1.complete)({ ...updated, evidence: evidenceSummary }));
487
+ (0, logger_1.logWithContext)('info', 'evidence gate result', { sessionKey, runId, evidenceStatus: evidenceSummary.status, failureReason: evidenceSummary.failureReason });
388
488
  // Release audit monitor when task completes — session is done, no more tool calls needed.
389
489
  setAuditMode('active');
390
490
  return { action: 'finalize' };
@@ -393,8 +493,8 @@ function register(api) {
393
493
  return { action: 'continue' };
394
494
  }
395
495
  });
396
- registerHook('after_tool_call', (event) => {
397
- const sessionKey = event.sessionKey ?? sessionIdToKey.get(event.sessionId);
496
+ registerHook('after_tool_call', (event, ctx) => {
497
+ const sessionKey = resolveSessionKey(event, ctx);
398
498
  if (!sessionKey)
399
499
  return;
400
500
  const entry = findRunBySession(sessionKey);
@@ -419,10 +519,10 @@ function register(api) {
419
519
  error: (event.error ?? '').substring(0, 200),
420
520
  });
421
521
  setState(runId, withError);
422
- (0, logger_1.log)(`[autopilot] after_tool_call error: session=${sessionKey} tool=${event.toolName} errCount=${withError.toolErrorCount}/${state.toolErrorThreshold}`);
522
+ (0, logger_1.warn)(`[autopilot] after_tool_call error: session=${sessionKey} tool=${event.toolName} errCount=${withError.toolErrorCount}/${state.toolErrorThreshold}`);
423
523
  });
424
- registerHook('before_compaction', (event) => {
425
- const sessionKey = event.sessionKey ?? sessionIdToKey.get(event.sessionId);
524
+ registerHook('before_compaction', (_event, ctx) => {
525
+ const sessionKey = resolveSessionKey(_event, ctx);
426
526
  if (!sessionKey)
427
527
  return;
428
528
  const entry = findRunBySession(sessionKey);
@@ -431,8 +531,8 @@ function register(api) {
431
531
  setState(entry[0], (0, goal_manager_1.preserveGoalBeforeCompaction)(entry[1]));
432
532
  }
433
533
  });
434
- registerHook('after_compaction', (event) => {
435
- const sessionKey = event.sessionKey ?? sessionIdToKey.get(event.sessionId);
534
+ registerHook('after_compaction', (_event, ctx) => {
535
+ const sessionKey = resolveSessionKey(_event, ctx);
436
536
  if (!sessionKey)
437
537
  return;
438
538
  const entry = findRunBySession(sessionKey);
@@ -442,7 +542,7 @@ function register(api) {
442
542
  }
443
543
  });
444
544
  registerHook('agent_turn_prepare', (event, ctx) => {
445
- const sessionKey = ctx?.sessionKey ?? event.sessionKey;
545
+ const sessionKey = ctx?.sessionKey;
446
546
  if (!sessionKey)
447
547
  return;
448
548
  const entry = findRunBySession(sessionKey);
@@ -456,6 +556,9 @@ function register(api) {
456
556
  runId,
457
557
  now: Date.now(),
458
558
  });
559
+ // Persist the orchState transition (claimed → running) immediately.
560
+ if (updated !== state)
561
+ setState(runId, updated);
459
562
  // Capture goal from first user prompt if not already set
460
563
  if (!updated.goal && event.prompt) {
461
564
  updated = (0, goal_manager_1.captureGoal)(updated, event.prompt);
@@ -489,14 +592,46 @@ function register(api) {
489
592
  }
490
593
  if (parts.length === 0)
491
594
  return;
492
- // Effort injection: ensure high effort for every autopilot turn (TD-1)
493
- const effortCtx = (0, effort_injection_1.buildEffortInjection)(updated.status);
595
+ // Effort injection: graduated intensity by execution phase (TD-1)
596
+ const intensity = (0, effort_injection_1.resolveThinkingIntensity)(updated.totalContinuations, updated.evidence?.status, config.thinkingIntensity);
597
+ const effortCtx = (0, effort_injection_1.buildEffortInjection)(updated.status, intensity);
494
598
  if (effortCtx)
495
599
  parts.push(effortCtx);
496
600
  // Completion awareness instruction
497
601
  parts.push('[Autopilot] When all tasks are complete, explicitly state "All tasks completed".');
498
602
  return { appendContext: parts.join('\n') };
499
603
  });
604
+ // Model routing: override model per execution phase. Consumed by Gateway via
605
+ // before_model_resolve -> { modelOverride }. No modelIds => no override (inherit).
606
+ // Read-only: state is read without a lock while other hooks mutate it — a turn
607
+ // straddling an evidence-status transition may pick the wrong tier for one turn.
608
+ // Acceptable for a routing heuristic (no data loss, self-corrects next turn).
609
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
610
+ registerHook('before_model_resolve', (_event, ctx) => {
611
+ const sessionKey = ctx?.sessionKey;
612
+ if (!sessionKey)
613
+ return;
614
+ // Find the autopilot run: direct, or via parent session for subagents
615
+ // (subagent keys: agent:<main>:subagent:<sub>).
616
+ const direct = findRunBySession(sessionKey);
617
+ const entry = direct
618
+ ?? ((0, model_routing_1.isSubagentSession)(sessionKey)
619
+ ? findRunBySession((0, model_routing_1.extractParentSessionKey)(sessionKey) ?? '')
620
+ : undefined);
621
+ if (!entry?.[1].enabled || entry[1].status !== 'running')
622
+ return;
623
+ const [, state] = entry;
624
+ // WORKFLOW.md model_routing wins over plugin config when present.
625
+ const routing = state.workflow?.modelRouting ?? modelRouting;
626
+ if (!routing?.modelIds)
627
+ return;
628
+ const tier = (0, model_routing_1.resolveModelTier)(state.totalContinuations, state.evidence?.status, (0, model_routing_1.isSubagentSession)(sessionKey), routing);
629
+ const modelId = (0, model_routing_1.resolveModelId)(tier, routing);
630
+ if (modelId) {
631
+ (0, logger_1.log)(`[autopilot] before_model_resolve: session=${sessionKey} tier=${tier} model=${modelId}`);
632
+ return { modelOverride: modelId };
633
+ }
634
+ });
500
635
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
501
636
  registerHook('before_agent_run', (_event, ctx) => {
502
637
  const sessionKey = ctx?.sessionKey;
@@ -556,6 +691,10 @@ function register(api) {
556
691
  commandClass,
557
692
  outcome: decision.outcome,
558
693
  reason: decision.reason,
694
+ // F3: persist the per-call working dir so the audit trail is forensically
695
+ // useful — aligns with dynamic-workflows' audit entry. eventCwd already
696
+ // resolved above (params.workdir / cd / git -C); fall back to the workspace.
697
+ cwd: eventCwd ?? state.workspace?.path,
559
698
  };
560
699
  const MAX_AUDIT = 200;
561
700
  const prevAudit = withActivity.permissionAudit ?? [];
@@ -575,7 +714,7 @@ function register(api) {
575
714
  // Using requireApproval+timeoutMs:1 was broken — it still walked the approval
576
715
  // pipeline which has no handler, causing 10s+ "Approval timed out" errors.
577
716
  if (decision.outcome === 'block') {
578
- (0, logger_1.logWithContext)('info', 'before_tool_call BLOCKED', { sessionKey, runId, toolName, reason: decision.reason });
717
+ (0, logger_1.logWithContext)('warn', 'before_tool_call BLOCKED', { sessionKey, runId, toolName, reason: decision.reason });
579
718
  return {
580
719
  block: true,
581
720
  blockReason: decision.message,
@@ -586,12 +725,29 @@ function register(api) {
586
725
  const sessionKey = ctx?.sessionKey;
587
726
  if (!sessionKey)
588
727
  return;
589
- const entry = findRunBySession(sessionKey);
728
+ // Count subagent tokens toward the PARENT run's budget. before_model_resolve
729
+ // already resolves subagents via their parent (agent:<main>:subagent:<id>);
730
+ // token accounting must match, or a run's tokenBudget under-counts real spend
731
+ // (the budget is enforced at the parent's before_agent_finalize turn boundary).
732
+ const direct = findRunBySession(sessionKey);
733
+ const entry = direct
734
+ ?? ((0, model_routing_1.isSubagentSession)(sessionKey)
735
+ ? findRunBySession((0, model_routing_1.extractParentSessionKey)(sessionKey) ?? '')
736
+ : undefined);
590
737
  if (!entry?.[1].enabled)
591
738
  return;
592
739
  const usage = event.usage;
593
- if (!usage?.total)
740
+ if (!usage?.total) {
741
+ // S10: when the host omits usage on a run WITH a configured tokenBudget,
742
+ // the budget silently never enforces (totalTokensUsed stays 0). Warn once
743
+ // per session so operators can diagnose "budget not working" instead of
744
+ // silently burning tokens. Skip when no budget is configured (irrelevant).
745
+ if (entry[1].tokenBudget && !noUsageWarned.has(sessionKey)) {
746
+ noUsageWarned.add(sessionKey);
747
+ (0, logger_1.warn)(`[autopilot] llm_output: host did not report token usage for session=${sessionKey} but a tokenBudget is configured — budget enforcement is a no-op until the host reports usage`);
748
+ }
594
749
  return;
750
+ }
595
751
  // H4: Guard against NaN / negative / non-finite token counts
596
752
  const added = typeof usage.total === 'number' && Number.isFinite(usage.total) && usage.total >= 0
597
753
  ? usage.total : 0;
@@ -622,15 +778,20 @@ function register(api) {
622
778
  return;
623
779
  const entry = findRunBySession(sessionKey);
624
780
  if (entry) {
625
- const [runId] = entry;
781
+ const [runId, state] = entry;
782
+ // S8: release the audit monitor refCount for still-running runs before
783
+ // deleting state, mirroring cleanupAll. A leak here pins monitor mode.
784
+ if (state.status === 'running')
785
+ setAuditMode('active');
626
786
  stateByRun.delete(runId);
627
787
  sessionKeyToRunId.delete(sessionKey);
628
788
  canaryFired.delete(sessionKey);
789
+ noUsageWarned.delete(sessionKey);
629
790
  }
630
791
  (0, logger_1.log)(`[autopilot] session_end: session=${sessionKey} state cleaned up`);
631
792
  });
632
- registerHook('agent_end', async (event) => {
633
- const sessionKey = event.sessionKey ?? sessionIdToKey.get(event.sessionId);
793
+ registerHook('agent_end', async (event, ctx) => {
794
+ const sessionKey = resolveSessionKey(event, ctx);
634
795
  if (!sessionKey)
635
796
  return;
636
797
  const entry = findRunBySession(sessionKey);
@@ -660,7 +821,7 @@ function register(api) {
660
821
  text: (0, continuation_engine_1.buildRetryInstruction)(continued),
661
822
  idempotencyKey: `autopilot-degraded-${sessionKey}-${continued.totalContinuations}`,
662
823
  placement: 'prepend_context',
663
- ttlMs: 300000,
824
+ ttlMs: workflow_config_1.DEFAULT_WORKFLOW_CONFIG.stallTimeoutMs,
664
825
  });
665
826
  if (injectResult && typeof injectResult === 'object' && injectResult.enqueued === false) {
666
827
  (0, logger_1.warn)(`[autopilot] agent_end: degraded fallback enqueue rejected for session=${sessionKey}`);
@@ -728,7 +889,7 @@ function register(api) {
728
889
  return undefined;
729
890
  const entry = findRunBySession(ctx.sessionKey);
730
891
  if (entry)
731
- return (0, projection_1.projectState)(entry[1]);
892
+ return (0, projection_1.projectState)(entry[1], config);
732
893
  return {
733
894
  status: 'idle',
734
895
  enabled: false,
@@ -746,8 +907,17 @@ function register(api) {
746
907
  cleanup: (ctx) => {
747
908
  if (ctx.sessionKey) {
748
909
  const entry = findRunBySession(ctx.sessionKey);
749
- if (entry)
910
+ if (entry) {
911
+ // S8: release audit refCount for still-running runs before delete.
912
+ if (entry[1].status === 'running')
913
+ setAuditMode('active');
750
914
  stateByRun.delete(entry[0]);
915
+ }
916
+ // PROD-2: clear the reverse-index and canary set too, else session-key
917
+ // teardown (without a session_end) leaves dangling map entries.
918
+ sessionKeyToRunId.delete(ctx.sessionKey);
919
+ canaryFired.delete(ctx.sessionKey);
920
+ noUsageWarned.delete(ctx.sessionKey);
751
921
  }
752
922
  },
753
923
  });
@@ -769,6 +939,9 @@ function register(api) {
769
939
  const payloadMaxContinuations = ctx.maxTotalContinuations;
770
940
  const payloadWorkspacePath = validateWorkspacePath(ctx.workspacePath);
771
941
  const payloadTokenBudget = typeof ctx.tokenBudget === 'number' && ctx.tokenBudget > 0 ? ctx.tokenBudget : undefined;
942
+ // S1-residual A: per-activate opt-in to execute workspace-sourced validation
943
+ // commands. Undefined → fall back to plugin config; both undefined → false.
944
+ const payloadTrustWorkspace = typeof ctx.trustWorkspace === 'boolean' ? ctx.trustWorkspace : undefined;
772
945
  // Concurrency guard: count sessions with status === 'running'
773
946
  const maxConcurrent = config.maxConcurrentAutopilot ?? 5;
774
947
  const runningCount = Array.from(stateByRun.values()).filter(s => s.status === 'running').length;
@@ -791,21 +964,35 @@ function register(api) {
791
964
  };
792
965
  // GAP-6: Load workflow config from WORKFLOW.md
793
966
  const applyWorkflowConfig = (s) => {
967
+ // S1-residual A: workspace-sourced validation commands (WORKFLOW.md +
968
+ // auto-detected `npm test` / `node …`) are NOT executed unless the operator
969
+ // trusts this workspace. Untrusted → commands empty + warning. This is the
970
+ // root-cause boundary: the binary allowlist cannot stop `npm run <script>` /
971
+ // `node evil.js` when the workspace owns the script.
972
+ const trustWorkspace = payloadTrustWorkspace ?? config.trustWorkspace ?? false;
794
973
  try {
795
974
  // Use payloadWorkspacePath (validated in outer scope by validateWorkspacePath) rather than
796
975
  // re-reading ctx.workspacePath raw — prevents path-traversal via WORKFLOW.md loading.
797
976
  const result = (0, workflow_config_1.loadWorkflowConfig)(process.cwd(), payloadWorkspacePath);
798
- // R-3: Auto-fill validation commands when WORKFLOW.md has none.
799
- // Only auto-detect when an explicit workspace path is provided (not cwd fallback)
800
- // to avoid running project tests in unexpected directories during tests/CI.
801
- const autoCommands = result.config.validation.commands.length === 0 && payloadWorkspacePath
802
- ? (0, project_detector_1.detectValidationCommands)(payloadWorkspacePath)
803
- : result.config.validation.commands;
977
+ let commands = result.config.validation.commands;
978
+ if (!trustWorkspace) {
979
+ commands = [];
980
+ }
981
+ else if (commands.length === 0 && payloadWorkspacePath) {
982
+ // R-3: Auto-fill validation commands when WORKFLOW.md has none.
983
+ // Only auto-detect when an explicit workspace path is provided AND trusted
984
+ // (not cwd fallback) to avoid running project tests in unexpected dirs.
985
+ commands = (0, project_detector_1.detectValidationCommands)(payloadWorkspacePath);
986
+ }
987
+ const warnings = trustWorkspace
988
+ ? result.warnings
989
+ : [...result.warnings, 'untrusted workspace — validation commands disabled (enable via trustWorkspace:true)'];
804
990
  return {
805
991
  ...s,
806
992
  workflow: {
807
993
  ...result.config,
808
- validation: { ...result.config.validation, commands: autoCommands },
994
+ validation: { ...result.config.validation, commands },
995
+ warnings,
809
996
  },
810
997
  maxTotalContinuations: s.maxTotalContinuations,
811
998
  };
@@ -826,13 +1013,23 @@ function register(api) {
826
1013
  // A stuck run would otherwise block every future activation until a
827
1014
  // gateway restart, because the stall handler leaves status='running'.
828
1015
  // Genuinely-active runs (recent activity) still fall through to reject.
829
- const stuckRecovery = (0, autopilot_state_1.isRunStuck)(state, Date.now(), config.tokenBudget ? 300_000 : 600_000);
1016
+ // M1: use the run's own stallTimeoutMs if configured, not just the global default.
1017
+ const stuckStallMs = state.workflow?.stallTimeoutMs
1018
+ ?? defaultStallTimeoutMs(!!config.tokenBudget);
1019
+ const stuckRecovery = (0, autopilot_state_1.isRunStuck)(state, Date.now(), stuckStallMs);
830
1020
  if (state.status === 'idle' || state.status === 'done' || stuckRecovery) {
831
1021
  if (stuckRecovery) {
832
1022
  (0, logger_1.warn)(`[autopilot] activate: recovering stuck session=${sessionKey} (status=${state.status}, orchState=${state.orchestrationState ?? 'none'}) — discarding stale run ${oldRunId}`);
833
1023
  }
834
- // Release audit monitor for the old run before discarding it.
835
- setAuditMode('active');
1024
+ // Release audit monitor refcount ONLY if the old run still holds one.
1025
+ // Per GAP-23 invariant: only status==='running' runs hold an unreleased
1026
+ // refCount (acquire on activate, release on pause/complete/stop). The
1027
+ // stuckRecovery branch is the only one where oldRunId may still be
1028
+ // 'running'. idle/done runs were already released at complete/stop —
1029
+ // releasing again would over-release the shared audit refcount (S8).
1030
+ if (stuckRecovery) {
1031
+ setAuditMode('active');
1032
+ }
836
1033
  stateByRun.delete(oldRunId);
837
1034
  sessionKeyToRunId.delete(sessionKey);
838
1035
  const runId = generateRunId();
@@ -850,7 +1047,7 @@ function register(api) {
850
1047
  });
851
1048
  setState(runId, newState);
852
1049
  sessionKeyToRunId.set(sessionKey, runId);
853
- (0, logger_1.log)(`[autopilot] activate: session=${sessionKey} new run=${runId} (was ${state.status}, goal=${goalForEvent ?? 'none'})`);
1050
+ (0, logger_1.log)(`[autopilot] activate: session=${sessionKey} new run=${runId} (was ${state.status}, goalLen=${goalForEvent?.length ?? 0})`);
854
1051
  }
855
1052
  else {
856
1053
  (0, logger_1.warn)(`[autopilot] activate rejected: session=${sessionKey} status=${state.status}`);
@@ -859,7 +1056,7 @@ function register(api) {
859
1056
  }
860
1057
  }
861
1058
  else {
862
- const runId = `run-${Math.random().toString(36).slice(2, 10)}`;
1059
+ const runId = generateRunId();
863
1060
  let state = (0, autopilot_state_1.activate)((0, types_1.createInitialState)(sessionKey, runId, config));
864
1061
  state = (0, orchestrator_1.orchestratorReducer)(state, { type: 'activate_requested', sessionKey, goal: payloadGoal, now: Date.now() });
865
1062
  state = applyPayload(state);
@@ -897,7 +1094,11 @@ function register(api) {
897
1094
  }
898
1095
  // M2: Dispatch resume_requested through orchestrator reducer
899
1096
  const orchestrated = (0, orchestrator_1.orchestratorReducer)(state, { type: 'resume_requested', runId, now: Date.now() });
900
- setState(runId, (0, autopilot_state_1.resume)(orchestrated));
1097
+ const resumed = (0, autopilot_state_1.resume)(orchestrated);
1098
+ setState(runId, resumed);
1099
+ // LOGIC-4: a programmatic resume has no follow-up user message to trigger
1100
+ // agent_turn_prepare, so kick a cross-turn injection to actually continue.
1101
+ kickResumedTurn(runId, resumed);
901
1102
  (0, logger_1.log)(`[autopilot] resume: session=${sessionKey} paused→running, errors reset`);
902
1103
  // Re-acquire audit monitor mode on resume.
903
1104
  setAuditMode('monitor');
@@ -928,7 +1129,7 @@ function register(api) {
928
1129
  api.registerGatewayMethod('autopilot.status', async ({ params: ctx, respond }) => {
929
1130
  const sessionKey = ctx.sessionKey;
930
1131
  const entry = sessionKey ? findRunBySession(sessionKey) : undefined;
931
- const projection = entry ? (0, projection_1.projectState)(entry[1]) : undefined;
1132
+ const projection = entry ? (0, projection_1.projectState)(entry[1], config) : undefined;
932
1133
  // Also expose raw state fields not in projection (progress, permissionAudit)
933
1134
  const state = entry ? entry[1] : undefined;
934
1135
  respond(true, {
@@ -960,7 +1161,7 @@ function register(api) {
960
1161
  }
961
1162
  const [runId, state] = entry;
962
1163
  setState(runId, (0, autopilot_state_1.setGoal)(state, goal));
963
- (0, logger_1.log)(`[autopilot] setGoal: session=${sessionKey} goal="${goal.substring(0, 80)}"`);
1164
+ (0, logger_1.log)(`[autopilot] setGoal: session=${sessionKey} goalLen=${goal.length}`);
964
1165
  respond(true, { ok: true });
965
1166
  });
966
1167
  // GAP-23: Cleanup action for graceful shutdown
@@ -974,8 +1175,7 @@ function register(api) {
974
1175
  // Periodically check all active runs for stall (no activity for stallTimeoutMs).
975
1176
  // When a stall is detected, dispatch stall_timeout through the orchestrator reducer.
976
1177
  const stallCheckIntervalMs = 60_000; // Check every 60 seconds
977
- const stallTimeoutMs = config.tokenBudget ? 300_000 : 600_000; // 5min or 10min default
978
- // M-7: Clear previous interval before creating new one (HMR / double-register safety)
1178
+ // M-7: Clear previous interval before creating a new one (HMR / double-register safety)
979
1179
  if (stallInterval) {
980
1180
  clearInterval(stallInterval);
981
1181
  stallInterval = null;
@@ -984,8 +1184,13 @@ function register(api) {
984
1184
  const now = Date.now();
985
1185
  const orphanRunIds = [];
986
1186
  for (const [runId, state] of stateByRun.entries()) {
987
- // GAP-4: Stall detection for active runs
988
- if (state.enabled && state.status === 'running' && state.orchestrationState === 'running') {
1187
+ // GAP-4: Stall detection for active runs.
1188
+ // M1 fix: read stallTimeoutMs PER-RUN from the run's workflow config, not
1189
+ // the global default. An operator setting `stall_timeout_ms` in WORKFLOW.md
1190
+ // expects it to take effect; the global fallback was silently ignoring it.
1191
+ const stallTimeoutMs = state.workflow?.stallTimeoutMs
1192
+ ?? defaultStallTimeoutMs(!!config.tokenBudget);
1193
+ if (state.enabled && state.status === 'running' && (state.orchestrationState === 'running' || state.orchestrationState === 'claimed')) {
989
1194
  const stallResult = (0, stall_detector_1.checkStall)({
990
1195
  lastActivityAt: state.lastActivityAt ?? state.startedAt ?? now,
991
1196
  now,
@@ -1011,6 +1216,10 @@ function register(api) {
1011
1216
  now,
1012
1217
  });
1013
1218
  setState(runId, updated);
1219
+ // PROD-7: a claimed run cannot self-start a turn — kick one so a genuinely
1220
+ // dead agent restarts instead of sitting in claimed until the 24h sweep.
1221
+ if (updated.orchestrationState === 'claimed')
1222
+ kickResumedTurn(runId, updated);
1014
1223
  (0, logger_1.log)(`[autopilot] retry_due: session=${state.sessionKey} run=${runId} attempt=${state.retry?.attempt ?? 1}`);
1015
1224
  }
1016
1225
  // GAP-26: Health check — detect orphaned sessions (no activity for 24h)
@@ -1024,11 +1233,18 @@ function register(api) {
1024
1233
  const state = stateByRun.get(runId);
1025
1234
  if (state) {
1026
1235
  (0, logger_1.warn)(`[autopilot] health check: cleaning orphaned session=${state.sessionKey} run=${runId}`);
1236
+ // S8: release audit refCount for orphaned still-running runs before delete.
1237
+ if (state.status === 'running')
1238
+ setAuditMode('active');
1027
1239
  stateByRun.delete(runId);
1028
1240
  sessionKeyToRunId.delete(state.sessionKey);
1029
1241
  canaryFired.delete(state.sessionKey);
1242
+ noUsageWarned.delete(state.sessionKey);
1030
1243
  }
1031
1244
  }
1032
1245
  }, stallCheckIntervalMs);
1246
+ // PROD-6: don't let the stall timer keep the event loop alive if the host
1247
+ // exits without calling cleanup — unref so the process can drain naturally.
1248
+ stallInterval?.unref?.();
1033
1249
  }
1034
1250
  //# sourceMappingURL=index.js.map