agent-relay-runner 0.127.5 → 0.127.6

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.
@@ -1,6 +1,5 @@
1
1
  import { hostname } from "node:os";
2
2
  import { mkdirSync, writeFileSync } from "node:fs";
3
- import { readFile } from "node:fs/promises";
4
3
  import { dirname, join } from "node:path";
5
4
  import type { AgentLifecycle, AgentProfile, ContextState, Message, MessageSessionMeta, ProviderState, SendMessageInput, TaskStatusInput, WorkspaceMetadata } from "agent-relay-sdk";
6
5
  import { errMessage, RelayBusClient, RelayHttpClient } from "agent-relay-sdk";
@@ -8,16 +7,14 @@ import { contextStateFromProbeMetrics, quotaStateFromProbeMetrics } from "agent-
8
7
  import { isPidAlive } from "agent-relay-sdk/process-utils";
9
8
  import { providerAttachmentText, providerMessageText } from "./adapter";
10
9
  import { computeLivenessSignal, type LivenessInputs } from "./liveness";
11
- import type { ManagedProcess, ProviderAdapter, ProviderConfig, ProviderPermissionDecision, ProviderPermissionDecisionInput, ProviderSessionEvent, ProviderStatusUpdate, RunnerSpawnConfig, SemanticStatus, TerminalAttachSpec } from "./adapter";
10
+ import type { ManagedProcess, ProviderAdapter, ProviderConfig, ProviderPermissionDecision, ProviderPermissionDecisionInput, ProviderSessionEvent, ProviderSessionEventBatch, ProviderSessionTurnContext, ProviderSessionTurnInput, ProviderStatusUpdate, ProviderUserPromptInput, RunnerSpawnConfig, SemanticStatus, TerminalAttachSpec } from "./adapter";
12
11
  import { messagesWithCachedAttachments } from "./attachment-cache";
13
12
  import { ClaimTracker } from "./claim-tracker";
14
- import { ClaudeQuotaHarvest } from "./adapters/claude-quota-harvest";
13
+ import { ProviderQuotaHarvest } from "./adapters/provider-quota-harvest";
15
14
  import { readRunnerContextProbeState } from "./context-probe-state";
16
15
  import { startControlServer, type ControlServer, type ResolvedPermissionPrompt } from "./control-server";
17
16
  import { ReplyObligationCache, obligationRequiresExplicitReply, responseCaptureReplyRoute } from "./reply-obligation-cache";
18
17
  import { Outbox, type OutboxRecord } from "./outbox";
19
- import { extractLastAssistantTurn, extractLastAssistantTurnAfterEntry, extractAssistantTurnForPrompt, countTranscriptEntries, extractHookAssistantMessage, extractLatestTurnSteps, stepDedupKeys, transcriptHasToolUseAnchor, transcriptLooksComplete } from "./adapters/claude-transcript";
20
- import { IncrementalClaudeTranscriptTail } from "./adapters/claude-transcript-tail";
21
18
  import { getManifest } from "agent-relay-providers";
22
19
  import { profileUsesProviderHostGlobals } from "./profile-home";
23
20
  import { RELAY_MCP_TOKEN_ENV, relayMcpEndpoint, resolveSharedMcpUrl } from "./relay-mcp";
@@ -45,7 +42,12 @@ import {
45
42
  isHttpAuthError,
46
43
  isHttpStatusError,
47
44
  lifecycleCapabilities,
45
+ providerExitLastError,
48
46
  providerExitDiagnostics,
47
+ providerExitResumeId,
48
+ providerManualRecoveryMessage,
49
+ providerManualRecoveryReason,
50
+ providerMonitorUnavailableMessage,
49
51
  providerStateFromActiveWork,
50
52
  registerWithinDeadline,
51
53
  relayBusUrl,
@@ -149,16 +151,9 @@ const RELAY_INJECTION_MARKERS = ["[relay message #", "[agent-relay", "<task-noti
149
151
  const INITIAL_PROMPT_FIRST_READY_TIMEOUT_MS = 30_000;
150
152
  const INITIAL_PROMPT_RETRY_READY_TIMEOUT_MS = 10_000;
151
153
  const MAX_INITIAL_PROMPT_ATTEMPTS = 6;
152
- // Reasoning tailer poll cadence (item 5). Coarse on purpose — reasoning is a
153
- // discreet progress signal, not a token stream; each tick reads only appended
154
- // transcript bytes.
155
- const REASONING_POLL_MS = 1_200;
156
154
  const SESSION_MIRROR_BATCH_MAX = 100;
157
- // #1116: bounded settle-poll for the PreToolUse pre-flush anchor. Claude's JSONL flush is
158
- // async/bursty and can lag the hook firing by a long margin, so a one-shot read (the old
159
- // #435/#499 behavior) often finds nothing. Poll at this cadence until the exact triggering
160
- // tool_use entry lands on disk, but never past the timeout — the question must never block
161
- // the user more than a few seconds waiting on a transcript write that might not be coming.
155
+ // #1116: bounded settle-poll for provider pre-flush anchors. The adapter owns the
156
+ // source-specific read; the runner owns the shared timeout knobs.
162
157
  const SETTLE_POLL_INTERVAL_MS = 250;
163
158
  const SETTLE_POLL_TIMEOUT_MS = 4_000;
164
159
  interface RunnerTimelineEvent {
@@ -171,11 +166,6 @@ interface RunnerTimelineEvent {
171
166
  metadata?: Record<string, unknown>;
172
167
  }
173
168
 
174
- interface ReasoningTailState {
175
- timer: ReturnType<typeof setInterval>; emittedNarrationKeys: Set<string>;
176
- poll(): Promise<void>;
177
- }
178
-
179
169
  export class AgentRunner {
180
170
  private readonly agentId: string;
181
171
  private readonly claims = new ClaimTracker();
@@ -196,7 +186,7 @@ export class AgentRunner {
196
186
  private sessionBatchSeq = 0;
197
187
  private readonly insights: RunnerInsights;
198
188
  private readonly busyReconciler: BusyReconciler;
199
- private readonly claudeQuotaHarvest: ClaudeQuotaHarvest;
189
+ private readonly providerQuotaHarvest: ProviderQuotaHarvest;
200
190
  private currentToken?: string;
201
191
  private currentTokenJti?: string;
202
192
  private currentTokenProfileId?: string;
@@ -213,7 +203,7 @@ export class AgentRunner {
213
203
  private readonly mcpProxySecret: string;
214
204
  private process?: ManagedProcess;
215
205
  private stopped = false;
216
- // #633 — set when the provider terminally exits but the runner stays alive (Claude native
206
+ // #633 — set when the provider terminally exits but the runner stays alive (native
217
207
  // auto-compaction / manual-resume hold). Rides on the offline status frame so the server fires a
218
208
  // terminal `agent.exited` event with a #636 diagnosis instead of the death going silent. Cleared
219
209
  // on any genuine restart back to busy/idle.
@@ -261,12 +251,8 @@ export class AgentRunner {
261
251
  private currentTurnId?: string;
262
252
  private currentTurnStartedAt?: number;
263
253
  private completedProviderTurns = 0;
264
- // #435: high-water-mark for the pre-turn narrative flush. Counts how many
265
- // valid JSONL entries were in the transcript at last pre-flush time so the
266
- // Stop-hook capture (full mode) can skip what was already emitted.
267
- private narrativeFlushEntryCount = 0;
268
- // True while a turn that was already in flight is being compacted. Claude's PostCompact
269
- // hook posts a single `idle`, but a mid-turn compaction RESUMES the turn afterward — so
254
+ // True while a turn that was already in flight is being compacted. Some providers
255
+ // post a transient idle after compaction even though the turn resumes afterward, so
270
256
  // treating that idle as a turn end (flip idle, kill the reasoning tail) makes chat go dark
271
257
  // until the next prompt. Set when `compacting` arrives with a turn running; cleared on the
272
258
  // genuine end (a plain idle with no compaction timeline).
@@ -291,16 +277,13 @@ export class AgentRunner {
291
277
  // wakes one) so the blocked badge lifts on its own.
292
278
  private rateLimitHold?: ProviderState;
293
279
  private pendingRateLimitHold?: ProviderState;
294
- // Reasoning tailer (item 5): streams the in-flight turn's reasoning/tool steps
295
- // from the Claude transcript into chat as discreet session events.
296
- private reasoningTail?: ReasoningTailState;
297
280
  // Settle-poll cadence for the pre-flush anchor (#1116) — instance fields (not just the
298
281
  // module constants) so tests can shrink them without waiting out the real bound.
299
282
  private settlePollIntervalMs = SETTLE_POLL_INTERVAL_MS;
300
283
  private settlePollTimeoutMs = SETTLE_POLL_TIMEOUT_MS;
301
284
  private scratch?: SessionScratchLayout;
302
- // #417: for non-claude providers, tracks how many times each spawner obligation has
303
- // been re-injected so a non-responsive agent can't spin in a re-injection loop.
285
+ // #417: tracks how many times each spawner obligation has been re-injected so a
286
+ // non-responsive provider can't spin in a re-injection loop.
304
287
  private readonly pendingObligationInjections = new Map<number, number>();
305
288
 
306
289
  constructor(private readonly options: RunnerOptions) {
@@ -321,7 +304,7 @@ export class AgentRunner {
321
304
  this.mcpProxySecret = crypto.randomUUID();
322
305
  const runtime = runtimeMetadata(options.provider);
323
306
  this.http = new RelayHttpClient({ baseUrl: options.relayUrl, token: this.currentToken });
324
- this.claudeQuotaHarvest = new ClaudeQuotaHarvest({ agentId: this.agentId, provider: options.provider, http: () => this.http });
307
+ this.providerQuotaHarvest = new ProviderQuotaHarvest({ agentId: this.agentId, provider: options.provider, http: () => this.http });
325
308
  this.obligationCache = new ReplyObligationCache({ fetch: () => this.http.listReplyObligations(this.agentId) });
326
309
  // Co-locate the durable outbox with the runner's runtime state (survives reboot) when the
327
310
  // orchestrator told us where that is; otherwise the Outbox falls back to a temp dir.
@@ -347,6 +330,7 @@ export class AgentRunner {
347
330
  getLastTranscriptPath: () => this.lastTranscriptPath,
348
331
  sessionLog: (message) => this.sessionLog(message),
349
332
  });
333
+ this.registerProviderSessionRoutes();
350
334
  this.busyReconciler = new BusyReconciler({
351
335
  isStopped: () => this.stopped,
352
336
  hasProcess: () => Boolean(this.process),
@@ -431,6 +415,11 @@ export class AgentRunner {
431
415
  return this.agentId;
432
416
  }
433
417
 
418
+ private registerProviderSessionRoutes(): void {
419
+ this.options.adapter.onSessionEvent?.((event) => { void this.publishProviderSessionEvent(event); });
420
+ this.options.adapter.onSessionEvents?.((events) => { void this.publishProviderSessionEvents(events); });
421
+ }
422
+
434
423
  async run(): Promise<void> {
435
424
  this.control = startControlServer({
436
425
  onStatus: (status) => this.setProviderStatus(status),
@@ -445,6 +434,10 @@ export class AgentRunner {
445
434
  onHookFatal: (report) => this.reportHookFatal(report),
446
435
  instanceId: this.options.instanceId,
447
436
  pendingPermissionStore: this.pendingPermissionStore,
437
+ permissionPromptHandler: this.options.adapter.permissionPromptHandler,
438
+ providerSourceId: this.options.adapter.statusSourceId ?? this.options.provider,
439
+ stopObligationPath: this.options.adapter.stopObligationPath,
440
+ monitorUnavailableMessage: providerMonitorUnavailableMessage(this.options.provider),
448
441
  });
449
442
  this.startMcpProxy();
450
443
  this.writeRunnerInfoFile();
@@ -458,7 +451,7 @@ export class AgentRunner {
458
451
  this.setProviderStatus(status);
459
452
  if (runnerShouldResolveProviderExit(semanticStatus, this.exitCommandInProgress)) this.options.onProviderExit?.(semanticStatus === "offline" ? 0 : 1);
460
453
  });
461
- this.options.adapter.onSessionEvent?.((event) => { void this.publishProviderSessionEvent(event); });
454
+ this.registerProviderSessionRoutes();
462
455
  this.bus.on("message.new", (message) => {
463
456
  // A delivered message may create a new reply obligation — warm the snapshot so the
464
457
  // next turn-end sees it without a hot-path server read.
@@ -992,7 +985,7 @@ export class AgentRunner {
992
985
  // Best-effort Insights capture for the segment that just ended in a crash (#183). This
993
986
  // path has no controlled teardown, so without it crashed sessions silently drop their
994
987
  // context-ratio datapoint. The process handle is still set (cleared later), so the
995
- // Claude transcript is readable; the runner stays alive here (restart or offline), so the
988
+ // provider capture source is readable; the runner stays alive here (restart or offline), so the
996
989
  // durable outbox drains normally — no flush needed.
997
990
  await Promise.race([
998
991
  this.captureContextRatio("crash"),
@@ -1026,7 +1019,7 @@ export class AgentRunner {
1026
1019
  }
1027
1020
 
1028
1021
  if (this.shouldStopUnexpectedProviderExit(diagnostics)) {
1029
- const hasResumeId = typeof diagnostics.claudeResumeId === "string" && diagnostics.claudeResumeId.length > 0;
1022
+ const hasResumeId = providerExitResumeId(diagnostics) !== undefined;
1030
1023
  if (!hasResumeId) {
1031
1024
  const recoveredStatus = await this.recoveredStatusAfterUndeterminedProviderExit();
1032
1025
  if (recoveredStatus) {
@@ -1060,14 +1053,12 @@ export class AgentRunner {
1060
1053
  id: `provider-restart-decision-${this.providerSessionId}-${now}`,
1061
1054
  timestamp: Date.now(),
1062
1055
  title: "Provider restart skipped",
1063
- body: hasResumeId
1064
- ? "Claude exited; runner will not auto-resume. Resume id captured for manual recovery."
1065
- : "Claude exited; runner will not restart automatically.",
1056
+ body: providerManualRecoveryMessage(this.options.provider, diagnostics),
1066
1057
  icon: "ti-player-stop",
1067
1058
  metadata: {
1068
1059
  eventType: "provider.restart_decision",
1069
1060
  decision: "stop-surface",
1070
- reason: hasResumeId ? "claude-exit-manual-resume-available" : "claude-exit-manual-intervention-required",
1061
+ reason: providerManualRecoveryReason(diagnostics),
1071
1062
  ...diagnostics,
1072
1063
  },
1073
1064
  });
@@ -1077,7 +1068,7 @@ export class AgentRunner {
1077
1068
  // status frame (published by setProviderStatus below) carries it; the server's bus status
1078
1069
  // handler turns that edge into a terminal `agent.exited` event with a #636 diagnosis.
1079
1070
  this.terminalProviderExit = {
1080
- reason: hasResumeId ? "claude-exit-manual-resume-available" : "claude-exit-manual-intervention-required",
1071
+ reason: providerManualRecoveryReason(diagnostics),
1081
1072
  ...diagnostics,
1082
1073
  };
1083
1074
  this.setProviderStatus({
@@ -1247,7 +1238,7 @@ export class AgentRunner {
1247
1238
  // A rate-limit hold persists across the idle the turn ends on; any other
1248
1239
  // providerState supersedes it (clears a stale hold).
1249
1240
  if (ps.state === "blocked" && ps.reason === "rate_limit") {
1250
- const paneRateLimitHold = typeof ps.source === "string" && ps.source.startsWith("claude-pane");
1241
+ const paneRateLimitHold = typeof ps.source === "string" && ps.source.startsWith(`${this.options.adapter.statusSourceId ?? this.options.provider}-pane`);
1251
1242
  if (status === "idle" && reason === "provider-turn" && paneRateLimitHold && this.claims.activeWork().some((work) => work.kind === "provider-turn")) {
1252
1243
  this.pendingRateLimitHold = update.providerState;
1253
1244
  this.providerBlocked = false;
@@ -1296,7 +1287,7 @@ export class AgentRunner {
1296
1287
  } else if (status === "idle" || status === "busy") {
1297
1288
  this.terminalFailure = undefined;
1298
1289
  }
1299
- // Compaction lifecycle (Claude PreCompact→`compacting` busy / PostCompact→`compacted`
1290
+ // Compaction lifecycle (`compacting` busy / `compacted` idle timeline) is separate
1300
1291
  // idle). A `compacted` idle for a turn that predated compaction is a hook artifact — the
1301
1292
  // turn resumes — so it must NOT end the turn. Discriminate on whether a turn was running,
1302
1293
  // captured BEFORE the busy logic below mints a currentTurnId (it does for /compact at idle).
@@ -1316,10 +1307,8 @@ export class AgentRunner {
1316
1307
  }
1317
1308
  if (status === "busy" && reason === "provider-turn") {
1318
1309
  if (!this.currentTurnId) {
1319
- // For Claude, `update.id` carries the native prompt_id the UserPromptSubmit
1320
- // hook reported (relay-status.sh /status) the same id the Stop hook and
1321
- // transcript use for this turn. Falls back to a random mint only when no
1322
- // native id was supplied (e.g. a provider/path that doesn't report one).
1310
+ // `update.id` carries a provider-native turn id when available. Falls back
1311
+ // to a random mint only when no native id was supplied.
1323
1312
  this.currentTurnId = typeof update !== "string" && update.id ? update.id : crypto.randomUUID();
1324
1313
  this.currentTurnStartedAt = Date.now();
1325
1314
  this.compactionMidTurn = false;
@@ -1400,16 +1389,15 @@ export class AgentRunner {
1400
1389
  this.publishStatus();
1401
1390
  }
1402
1391
 
1403
- // #417: when a non-claude provider finishes a turn with a pending spawner obligation
1404
- // still unresolved, re-inject the obligation as a synthetic message so providerMessageText
1405
- // surfaces the specific `agent-relay /reply <id>` command. Claude handles this via the
1406
- // stop.sh hook; every other provider needs this in-process nudge.
1392
+ // #417: when a provider finishes a turn with a pending spawner obligation still
1393
+ // unresolved, re-inject the obligation as a synthetic message so providerMessageText
1394
+ // surfaces the specific `agent-relay /reply <id>` command.
1407
1395
  private async reInjectPendingObligation(): Promise<void> {
1408
1396
  if (!this.process) return;
1409
- // Filter to spawner obligations only — user obligations go through the existing codex
1397
+ // Filter to spawner obligations only — user obligations go through the existing
1410
1398
  // prompt-capture path (publishProviderSessionEvent) and the session mirror (#418), never
1411
- // an explicit /reply. Shared predicate so this rule stays in lockstep with the Claude
1412
- // Stop-hook nag exemption (control-server.replyObligationStopDecision).
1399
+ // an explicit /reply. Shared predicate so this rule stays in lockstep with
1400
+ // the stop-hook nag exemption (control-server.replyObligationStopDecision).
1413
1401
  const obligations = this.obligationCache.get().filter(obligationRequiresExplicitReply);
1414
1402
  if (!obligations.length) return;
1415
1403
  const MAX_REINJECTIONS = 3;
@@ -1429,7 +1417,7 @@ export class AgentRunner {
1429
1417
  readBy: [] as string[],
1430
1418
  createdAt: obligation.createdAt,
1431
1419
  };
1432
- // Suppress the userMessage echo that providers (e.g. Codex) reflect back when
1420
+ // Suppress the userMessage echo that providers can reflect back when
1433
1421
  // a turn is started with relay-formatted content — the echo body is exactly what
1434
1422
  // providerMessageText produces, which starts with "[relay message #" and is caught
1435
1423
  // by the RELAY_INJECTION_MARKERS prefix check, but record it explicitly as
@@ -1445,131 +1433,25 @@ export class AgentRunner {
1445
1433
  }
1446
1434
  }
1447
1435
 
1448
- // Session-mirror lane: capture the assistant turn from the Claude transcript and
1449
- // post it as a "session" message so it shows in the dashboard chat with zero
1450
- // agent tokens. Capture is UNCONDITIONAL — it no longer depends on a triggering
1451
- // relay message existing, so turns started from the web terminal (which create
1452
- // no relay message) are mirrored too. A reply obligation, when present, is still
1453
- // used as replyTo so the Stop hook stops nagging the agent to /reply.
1454
- //
1455
- // isPreFlush: true (#435/#499/#1116) — mid-turn PreToolUse boundary. Settle-polls the
1456
- // transcript for the exact tool_use entry that triggered the hook (toolName/toolInput,
1457
- // the hook payload's own anchor), then drains the live tail and force-flushes session
1458
- // messages before showing the blocking control; the entry cursor lets Stop capture skip
1459
- // already-emitted text. Returns whether the settle-poll actually found the anchor
1460
- // (reasoningSettled) — an honest `false` on timeout rather than a stamped `true` on a
1461
- // premise nothing here confirmed (#1116; see InteractivePrompt.reasoningSettled).
1462
- private async publishSessionTurn(input: { transcriptPath: string; lastAssistantMessage?: unknown; promptId?: string; isPreFlush?: boolean; toolName?: string; toolInput?: unknown }): Promise<{ reasoningSettled: boolean } | void> {
1436
+ private async publishSessionTurn(input: ProviderSessionTurnInput): Promise<{ reasoningSettled: boolean } | void> {
1463
1437
  if (input.transcriptPath) this.lastTranscriptPath = input.transcriptPath;
1464
-
1465
- if (input.isPreFlush) {
1466
- if (!input.transcriptPath) return { reasoningSettled: true };
1467
- const settled = await this.settlePollForToolUse(input.transcriptPath, input.toolName, input.toolInput);
1468
- const jsonl = settled.jsonl;
1469
- if (jsonl === undefined) return { reasoningSettled: false };
1470
- const body = extractLastAssistantTurnAfterEntry(jsonl, this.narrativeFlushEntryCount);
1471
- await this.drainReasoningTail();
1472
- this.narrativeFlushEntryCount = countTranscriptEntries(jsonl);
1473
- if (body && !this.preFlushBodyAlreadyMirroredByReasoningTail(jsonl, body)) {
1474
- const turnId = this.currentTurnId;
1475
- this.sessionLog(`pre-flush narrative for turn ${turnId ?? "?"} (${body.length} chars)`);
1476
- await this.publishSessionEvent({ from: this.agentId, to: "user", body, session: { type: "response", origin: "provider", ...(turnId ? { turnId } : {}) } });
1477
- }
1478
- await this.sessionOutbox.flush(2_000).then((ok) => { if (!ok) this.sessionLog(`pre-flush session outbox incomplete before blocking control (${this.sessionOutbox.pendingCount()} pending)`); }, (error) => this.sessionLog(`pre-flush session outbox failed before blocking control: ${errMessage(error)}`));
1479
- return { reasoningSettled: settled.settled };
1480
- }
1481
-
1482
- // Claude's Stop hook reports its own native `prompt_id` — the same UUID the
1483
- // transcript stamps on every entry of this turn (extractAssistantTurnForPrompt /
1484
- // resolveEntryPromptId in claude-transcript.ts). It's the authoritative turn
1485
- // identifier; currentTurnId should already equal it (seeded from the same
1486
- // prompt_id at turn start — see setProviderStatus / handleUserPrompt), but this
1487
- // prefers the freshest source so finalization is correct even if the two drift.
1488
- const turnId = input.promptId ?? this.currentTurnId;
1489
- // #1116: drain whatever the tail hasn't polled yet BEFORE stopping it — stopping first
1490
- // (the old order) clears the poll/timer state, so any steps Claude flushed in the last
1491
- // window between the tail's previous tick and this Stop hook were silently dropped from
1492
- // the step lane instead of reaching chat.
1493
- await this.drainReasoningTail();
1494
- this.stopReasoningTail();
1495
- // Optional correlation for threading + obligation clearing — never a capture gate.
1496
- let replyToMessageId: number | undefined, replyTarget = "user";
1497
- const pendingPrompt = this.pendingPromptMessageId;
1498
- if (pendingPrompt) {
1499
- replyToMessageId = pendingPrompt;
1500
- this.pendingPromptMessageId = undefined;
1501
- } else {
1502
- // Correlation-only (threading + obligation clearing) — the local snapshot is fresh
1503
- // enough and never blocks the response-capture path (#196).
1504
- const route = responseCaptureReplyRoute(this.obligationCache.get(), this.currentTurnStartedAt);
1505
- replyToMessageId = route?.replyToMessageId; replyTarget = route?.to ?? "user";
1506
- }
1507
-
1508
- // Grab and reset the pre-flush cursor before extraction: the cursor may be
1509
- // non-zero when a PreToolUse hook flushed narrative earlier this turn (#435).
1510
- // In "full" mode, skip those already-emitted entries so they don't duplicate.
1511
- const flushCount = this.narrativeFlushEntryCount;
1512
- this.narrativeFlushEntryCount = 0;
1513
-
1514
- // PRIMARY capture source: the Stop hook's own last_assistant_message. Claude
1515
- // reports it synchronously in the hook payload itself, so there is no
1516
- // transcript file-read race to retry against — this text is authoritative and
1517
- // complete the moment the hook fires. The transcript is read at most ONCE
1518
- // (never retried/looped) and only to enrich "full" mode with the turn's
1519
- // intermediate narration; if that single read comes up empty (the file simply
1520
- // hasn't caught up yet) the hook payload text still carries the turn.
1521
- let body = extractHookAssistantMessage(input.lastAssistantMessage);
1522
- if (this.options.providerConfig.chatCaptureMode === "full" && input.transcriptPath) {
1523
- let jsonl: string | undefined;
1524
- try { jsonl = await readFile(input.transcriptPath, "utf8"); } catch { jsonl = undefined; }
1525
- if (jsonl !== undefined && transcriptLooksComplete(jsonl)) {
1526
- // Deterministic slicing via prompt_id→parentUuid chaining when the native id is
1527
- // known; only fall back to the legacy "last real user prompt" heuristic when that
1528
- // deterministic slice comes up EMPTY, not merely when turnId is falsy — an old
1529
- // Claude (<2.1.196) mints a truthy random turnId that never resolves via promptId
1530
- // chaining, so branching on truthiness alone left this fallback unreachable and
1531
- // silently dropped intermediate narration in full mode (#1086 review, Finding F1).
1532
- let full = turnId ? extractAssistantTurnForPrompt(jsonl, turnId, flushCount) : "";
1533
- if (!full) full = flushCount > 0 ? extractLastAssistantTurnAfterEntry(jsonl, flushCount) : extractLastAssistantTurn(jsonl);
1534
- if (full) body = full;
1535
- }
1536
- }
1537
- // Last-resort safety net for the default "final" mode (#1086 review, Finding F2):
1538
- // last_assistant_message is an undocumented Claude hook field. If a future Claude
1539
- // version renames or drops it, do ONE (never retried — same one-shot discipline as the
1540
- // "full" mode enrichment above) transcript read rather than silently losing every
1541
- // capture in the mode nearly every provider config runs in.
1542
- if (!body && this.options.providerConfig.chatCaptureMode === "final" && input.transcriptPath) {
1543
- let jsonl: string | undefined;
1544
- try { jsonl = await readFile(input.transcriptPath, "utf8"); } catch { jsonl = undefined; }
1545
- if (jsonl !== undefined) {
1546
- const fallback = turnId ? extractAssistantTurnForPrompt(jsonl, turnId, flushCount) : "";
1547
- body = fallback || (flushCount > 0 ? extractLastAssistantTurnAfterEntry(jsonl, flushCount) : extractLastAssistantTurn(jsonl));
1548
- }
1549
- }
1550
- // A pure tool-use turn with no closing text is fine to skip — its reasoning and
1551
- // tool steps already carried the visibility into chat.
1552
- if (!body) {
1553
- this.sessionLog(`response capture: no closing text for turn ${turnId ?? "?"} (skipped)`);
1554
- return;
1555
- }
1556
-
1557
- this.sessionLog(`response captured for turn ${turnId ?? "?"} (${body.length} chars${replyToMessageId ? `, replyTo #${replyToMessageId}` : ", no replyTo"})`);
1558
- await publishCapturedResponse({
1559
- publishSessionEvent: (event) => this.publishSessionEvent(event),
1560
- outbox: this.sessionOutbox,
1561
- provider: this.options.provider,
1562
- from: this.agentId,
1563
- to: replyTarget,
1564
- body,
1565
- replyTo: replyToMessageId,
1566
- // A Stop-hook-driven capture is by construction a definitive, provider-native
1567
- // turn completion — Claude's own lifecycle hook fired, not a reconstructed guess.
1568
- session: { type: "response", origin: "provider", final: true, confidence: "definitive", endedBy: "completed", ...(turnId ? { turnId } : {}) },
1569
- });
1570
- // The agent's reply may have cleared an obligation — refresh the snapshot so the next
1571
- // turn-end doesn't re-prompt for a message already answered (#196).
1572
- if (replyToMessageId) this.obligationCache.markDirty();
1438
+ const result = await this.options.adapter.captureSessionTurn?.(input, this.sessionTurnContext());
1439
+ return typeof result?.reasoningSettled === "boolean" ? { reasoningSettled: result.reasoningSettled } : undefined;
1440
+ }
1441
+
1442
+ private sessionTurnContext(): ProviderSessionTurnContext {
1443
+ return {
1444
+ turnId: this.currentTurnId,
1445
+ turnStartedAt: this.currentTurnStartedAt,
1446
+ chatCaptureMode: this.options.providerConfig.chatCaptureMode,
1447
+ reasoningCapture: this.options.providerConfig.reasoningCapture,
1448
+ settlePollIntervalMs: this.settlePollIntervalMs,
1449
+ settlePollTimeoutMs: this.settlePollTimeoutMs,
1450
+ flushSessionEvents: (timeoutMs) => this.sessionOutbox.flush(timeoutMs),
1451
+ pendingSessionEventCount: () => this.sessionOutbox.pendingCount(),
1452
+ log: (message) => this.sessionLog(message),
1453
+ debug: (message) => this.sessionDebug(message),
1454
+ };
1573
1455
  }
1574
1456
 
1575
1457
  // Post one session-mirror event (prompt echo, assistant response, reasoning or
@@ -1582,7 +1464,7 @@ export class AgentRunner {
1582
1464
  // provider-native event time; otherwise the outbox stamps enqueue time. Routed through
1583
1465
  // the fast-lane sessionOutbox (#332) so a transient trace failure can't head-of-line
1584
1466
  // block real messages.
1585
- // A stepId-bearing step (Codex tool runningcompleted, streamed reasoning/response) uses a
1467
+ // A stepId-bearing step (tool running to completed, streamed reasoning/response) uses a
1586
1468
  // STABLE idempotency key so the server upserts the row in place instead of appending a dup.
1587
1469
  const stepId = input.session.stepId;
1588
1470
  this.sessionOutbox.enqueue({
@@ -1663,18 +1545,9 @@ export class AgentRunner {
1663
1545
  }
1664
1546
  }
1665
1547
 
1666
- // A human typed a prompt directly into the provider (web terminal / TUI). Mirror
1667
- // it into the dashboard chat so both surfaces stay in sync, and kick off reasoning
1668
- // tailing for the turn. Skips prompts the runner itself injected (chat box, relay
1669
- // deliveries) so those aren't double-posted.
1670
- private async handleUserPrompt(input: { prompt: string; transcriptPath?: string; promptId?: string }): Promise<void> {
1548
+ private async handleUserPrompt(input: ProviderUserPromptInput): Promise<void> {
1671
1549
  if (input.transcriptPath) this.lastTranscriptPath = input.transcriptPath;
1672
1550
  if (!this.currentTurnId) {
1673
- // Claude's own prompt_id (from the UserPromptSubmit hook payload) is the
1674
- // native, stable turn identifier — the same id the Stop hook reports and the
1675
- // transcript stamps on every entry of this turn. Falls back to a random mint
1676
- // only for the rare case prompt_id wasn't available (see the ZS base schema
1677
- // note: absent until the very first user input of the process lifetime).
1678
1551
  this.currentTurnId = input.promptId ?? crypto.randomUUID();
1679
1552
  this.currentTurnStartedAt = Date.now();
1680
1553
  }
@@ -1690,10 +1563,10 @@ export class AgentRunner {
1690
1563
  } else if (text) {
1691
1564
  this.sessionDebug("user-prompt hook: skipped echo (runner-injected)");
1692
1565
  }
1693
- if (input.transcriptPath) this.startReasoningTail(input.transcriptPath);
1566
+ await this.options.adapter.handleUserPrompt?.(input, this.sessionTurnContext());
1694
1567
  }
1695
1568
 
1696
- // A provider lifecycle hook reported a session boundary (Claude PreCompact / SessionEnd
1569
+ // A provider lifecycle hook reported a session boundary
1697
1570
  // → control server). Normalize the raw provider reason to a SessionDestroyReason and run
1698
1571
  // the same pre-destroy seam the bus commands use. `clear`/`compact` continue the session;
1699
1572
  // anything else (logout, prompt_input_exit, other) is a real termination.
@@ -1701,8 +1574,8 @@ export class AgentRunner {
1701
1574
  // Reason mapping is fail-safe-toward-termination: only the two known session-
1702
1575
  // CONTINUING reasons are special-cased; everything else (logout, prompt_input_exit,
1703
1576
  // other, AND any future reason) maps to "shutdown" → full pre-destroy capture.
1704
- // If Claude Code adds a new BENIGN/continuing boundary reason, add it here — until
1705
- // then it will trigger a (harmless but wasteful) full context capture on a session
1577
+ // If a provider adds a new benign/continuing boundary reason, add it here. Until
1578
+ // then it will trigger a harmless but wasteful full context capture on a session
1706
1579
  // that isn't actually ending.
1707
1580
  const reason = input.reason === "compact" ? "compact"
1708
1581
  : input.reason === "clear" ? "clear"
@@ -1780,10 +1653,9 @@ export class AgentRunner {
1780
1653
  await this.insights.captureContextRatio(reason, opts);
1781
1654
  }
1782
1655
 
1783
- // Route a provider-emitted session event (Codex app-server) into the chat mirror.
1784
- // Mirrors the same semantics as the Claude lane: prompts are echoed with dedup,
1785
- // and a response is only auto-captured when the agent won't separately reply to a
1786
- // relay obligation (so relay-triggered turns aren't double-posted).
1656
+ // Route a provider-emitted session event into the chat mirror.
1657
+ // Mirrors provider session events into chat. Prompts are echoed with dedup, and
1658
+ // response routing clears only the obligations the adapter says its capture can satisfy.
1787
1659
  private async publishProviderSessionEvent(event: ProviderSessionEvent): Promise<void> {
1788
1660
  const body = event.body.trim();
1789
1661
  if (!body) return;
@@ -1795,6 +1667,17 @@ export class AgentRunner {
1795
1667
  to: this.agentId,
1796
1668
  body,
1797
1669
  session: { type: "prompt", origin: event.origin ?? "terminal", ...(turnId ? { turnId } : {}) },
1670
+ ...(event.occurredAt ? { occurredAt: event.occurredAt } : {}),
1671
+ });
1672
+ return;
1673
+ }
1674
+ if (event.type === "response" && event.final === false) {
1675
+ await this.publishSessionEvent({
1676
+ from: this.agentId,
1677
+ to: "user",
1678
+ body,
1679
+ ...(event.occurredAt ? { occurredAt: event.occurredAt } : {}),
1680
+ session: { type: "response", origin: event.origin ?? "provider", ...(turnId ? { turnId } : {}) },
1798
1681
  });
1799
1682
  return;
1800
1683
  }
@@ -1803,7 +1686,7 @@ export class AgentRunner {
1803
1686
  // response. User obligations still belong to the session mirror; spawner
1804
1687
  // obligations (#413) route to the spawn-parent with replyTo to clear the debt.
1805
1688
  // #1083: a turn-final capture is ALWAYS mirrored, regardless of reply route — an
1806
- // autonomous Codex turn with no pending prompt and no eligible obligation used to
1689
+ // autonomous provider turn with no pending prompt and no eligible obligation used to
1807
1690
  // hit `if (!route) return` here and vanish before ever reaching publishCapturedResponse,
1808
1691
  // so it was neither shown in the dashboard nor eligible for report-up. The route only
1809
1692
  // decides whether an explicit reply/obligation-clear also happens; when there's none,
@@ -1816,18 +1699,38 @@ export class AgentRunner {
1816
1699
  replyToMessageId = pendingPrompt;
1817
1700
  this.pendingPromptMessageId = undefined;
1818
1701
  } else {
1819
- const route = responseCaptureReplyRoute(this.obligationCache.get(), this.currentTurnStartedAt, { suppressUser: true });
1702
+ const route = responseCaptureReplyRoute(this.obligationCache.get(), this.currentTurnStartedAt, { suppressUser: event.replyToUserObligation !== true });
1820
1703
  if (route) { replyToMessageId = route.replyToMessageId; replyTarget = route.to; }
1821
1704
  }
1822
- await publishCapturedResponse({ publishSessionEvent: (sessionEvent) => this.publishSessionEvent(sessionEvent), outbox: this.sessionOutbox, provider: this.options.provider, from: this.agentId, to: replyTarget, body, replyTo: replyToMessageId, session: { type: "response", origin: event.origin ?? "provider", ...(turnId ? { turnId } : {}), ...(event.confidence ? { confidence: event.confidence } : {}), ...(event.endedBy ? { endedBy: event.endedBy } : {}) } });
1705
+ await publishCapturedResponse({ publishSessionEvent: (sessionEvent) => this.publishSessionEvent({ ...sessionEvent, ...(event.occurredAt ? { occurredAt: event.occurredAt } : {}) }), outbox: this.sessionOutbox, provider: this.options.provider, from: this.agentId, to: replyTarget, body, replyTo: replyToMessageId, session: { type: "response", origin: event.origin ?? "provider", ...(turnId ? { turnId } : {}), ...(event.confidence ? { confidence: event.confidence } : {}), ...(event.endedBy ? { endedBy: event.endedBy } : {}) } });
1823
1706
  if (replyToMessageId) this.obligationCache.markDirty();
1824
1707
  return;
1825
1708
  }
1826
1709
  if (this.options.providerConfig.reasoningCapture === false) return;
1827
- await this.publishSessionEvent({
1710
+ this.publishSessionEvent(this.providerStepSessionInput(event, body, turnId));
1711
+ }
1712
+
1713
+ private async publishProviderSessionEvents(events: ProviderSessionEventBatch): Promise<void> {
1714
+ const inputs: Array<{ from: string; to: string; body: string; session: MessageSessionMeta; replyTo?: number; occurredAt?: number }> = [];
1715
+ for (const event of events) {
1716
+ const body = event.body.trim();
1717
+ if (!body) continue;
1718
+ if (event.type === "prompt" || event.type === "response") {
1719
+ await this.publishProviderSessionEvent(event);
1720
+ continue;
1721
+ }
1722
+ if (this.options.providerConfig.reasoningCapture === false) continue;
1723
+ inputs.push(this.providerStepSessionInput(event, body, event.turnId ?? this.currentTurnId));
1724
+ }
1725
+ if (inputs.length) this.publishSessionEventsBatch(inputs);
1726
+ }
1727
+
1728
+ private providerStepSessionInput(event: ProviderSessionEvent, body: string, turnId?: string): { from: string; to: string; body: string; session: MessageSessionMeta; occurredAt?: number } {
1729
+ return {
1828
1730
  from: this.agentId,
1829
1731
  to: "user",
1830
1732
  body,
1733
+ ...(event.occurredAt ? { occurredAt: event.occurredAt } : {}),
1831
1734
  session: {
1832
1735
  type: event.type,
1833
1736
  origin: event.origin ?? "provider",
@@ -1838,7 +1741,7 @@ export class AgentRunner {
1838
1741
  ...(event.stepId ? { stepId: event.stepId } : {}),
1839
1742
  ...(event.final ? { final: true } : {}),
1840
1743
  },
1841
- });
1744
+ };
1842
1745
  }
1843
1746
 
1844
1747
  // Remember an injected prompt so its UserPromptSubmit echo can be suppressed. Prunes
@@ -1880,7 +1783,7 @@ export class AgentRunner {
1880
1783
  // #650: reconcile pid-bearing busy work against actual process liveness. A tracked
1881
1784
  // command/shell that dies out-of-band (killed by the operator, crashes, orphaned)
1882
1785
  // sends no exit event, so its busy marker would otherwise never clear and the agent
1883
- // shows `busy` forever — blocking transient auto-reap and (for Claude) holding quota.
1786
+ // shows `busy` forever — blocking transient auto-reap and holding provider quota.
1884
1787
  // The timer self-disarms once no pid-bearing work remains; it is armed lazily when a
1885
1788
  // busy report carrying pids arrives, so idle/non-tracked agents pay nothing.
1886
1789
  private armBusyLivenessReconcile(): void {
@@ -1915,120 +1818,8 @@ export class AgentRunner {
1915
1818
  if (!this.claims.hasLivenessTrackedWork()) this.disarmBusyLivenessReconcile();
1916
1819
  }
1917
1820
 
1918
- // --- Turn-step tailer (item 5) ------------------------------------------------------
1919
- // Tail the in-flight turn's Claude transcript and surface new narration/reasoning/tool
1920
- // steps as session events, in transcript order. `narration` (the agent's intermediate
1921
- // text) is the primary visible content; reasoning visibility is a client-side toggle.
1922
- // Coalesced and coarse; the final response still comes through publishSessionTurn.
1923
- // `reasoningCapture: false` disables the whole live trace (server-side kill switch).
1924
- private startReasoningTail(transcriptPath: string): void {
1925
- if (this.options.providerConfig.reasoningCapture === false) return;
1926
- this.stopReasoningTail();
1927
- // Track emitted steps by content signature, not by index/count: the "latest
1928
- // turn" window in the transcript can shrink/reset (a tool_result entry, a
1929
- // mid-turn user line), and an index cursor would then either re-emit or stall
1930
- // and drop the rest of the turn. A seen-set is idempotent under any reshuffle.
1931
- // The signature is salted with each step's occurrence-within-window (stepDedupKeys)
1932
- // so two identical steps in one turn — same tool, same input — both surface (#265).
1933
- const seen = new Set<string>();
1934
- const emittedNarrationKeys = new Set<string>();
1935
- const transcriptTail = new IncrementalClaudeTranscriptTail();
1936
- const turnIdAtStart = this.currentTurnId;
1937
- // On the first poll the new prompt usually hasn't landed in the transcript yet,
1938
- // so extractLatestTurnSteps still returns the PRIOR (completed) turn. Seed those
1939
- // signatures as already-seen so we don't replay last turn's reasoning/tools as
1940
- // this turn's activity. Once our prompt lands the window resets at the new user
1941
- // boundary and genuinely-new steps emit normally. Only seed when the transcript
1942
- // is complete (last entry is an end_turn assistant) — otherwise we're already
1943
- // inside the new turn and those steps are legitimately ours.
1944
- let seeded = false;
1945
- let pendingPoll = Promise.resolve();
1946
- const pollBody = async (): Promise<void> => {
1947
- const result = await transcriptTail.poll(transcriptPath);
1948
- if (!result.available) return;
1949
- if (result.reset) { seen.clear(); emittedNarrationKeys.clear(); seeded = false; }
1950
- if (!result.changed && (seeded || (!result.steps.length && !result.transcriptLooksComplete))) return;
1951
- const keyed = stepDedupKeys(result.steps).map((sig, i) => ({ sig, step: result.steps[i]! }));
1952
- if (!seeded) {
1953
- seeded = true;
1954
- if (result.transcriptLooksComplete) {
1955
- for (const { sig } of keyed) seen.add(sig);
1956
- }
1957
- }
1958
- const turnId = this.currentTurnId ?? turnIdAtStart;
1959
- let emitted = 0;
1960
- const batch: Array<{ from: string; to: string; body: string; session: MessageSessionMeta; occurredAt?: number }> = [];
1961
- for (const { sig, step } of keyed) {
1962
- if (seen.has(sig)) continue;
1963
- seen.add(sig);
1964
- emitted += 1;
1965
- batch.push({
1966
- from: this.agentId,
1967
- to: "user",
1968
- body: step.text,
1969
- ...(step.occurredAt ? { occurredAt: step.occurredAt } : {}),
1970
- session: { type: step.type, origin: "provider", ...(turnId ? { turnId } : {}), ...(step.label ? { label: step.label } : {}) },
1971
- });
1972
- if (step.type === "narration") emittedNarrationKeys.add(sig);
1973
- }
1974
- this.publishSessionEventsBatch(batch);
1975
- if (emitted) this.sessionDebug(`reasoning tail emitted ${emitted} step(s) (turn ${turnId ?? "?"}, ${seen.size} total)`);
1976
- };
1977
- const poll = (): Promise<void> => {
1978
- const next = pendingPoll.then(pollBody, pollBody);
1979
- pendingPoll = next.catch(() => {});
1980
- return next;
1981
- };
1982
- this.reasoningTail = { emittedNarrationKeys, poll, timer: setInterval(() => { void poll(); }, REASONING_POLL_MS) };
1983
- this.sessionLog(`reasoning tail started (turn ${turnIdAtStart ?? "?"})`);
1984
- void poll();
1985
- }
1986
-
1987
- private async drainReasoningTail(): Promise<void> {
1988
- if (this.options.providerConfig.reasoningCapture === false) return;
1989
- await this.reasoningTail?.poll();
1990
- }
1991
-
1992
- // #1116: bounded settle-poll for the PreToolUse pre-flush anchor. The old pre-flush read
1993
- // the transcript exactly once and, on the false premise that Claude's JSONL write is
1994
- // synchronous with the hook firing, treated whatever it found (often nothing) as complete.
1995
- // Claude's flush is actually async/bursty — sometimes gated on the hook's own resolution —
1996
- // so this polls at settlePollIntervalMs until the transcript contains the EXACT tool_use
1997
- // entry that triggered the hook (toolName/toolInput from the hook payload is the precise
1998
- // anchor), then returns the freshest read. Never polls past settlePollTimeoutMs — the
1999
- // question must ship regardless, just honestly flagged as `settled: false` when it does.
2000
- // No toolName (a caller that can't identify an anchor) skips straight to a single read,
2001
- // matching the old one-shot behavior.
2002
- private async settlePollForToolUse(transcriptPath: string, toolName?: string, toolInput?: unknown): Promise<{ settled: boolean; jsonl?: string }> {
2003
- const deadline = Date.now() + this.settlePollTimeoutMs;
2004
- for (;;) {
2005
- let jsonl: string | undefined;
2006
- try { jsonl = await readFile(transcriptPath, "utf8"); } catch { jsonl = undefined; }
2007
- if (!toolName) return { settled: Boolean(jsonl), jsonl };
2008
- if (jsonl !== undefined && transcriptHasToolUseAnchor(jsonl, toolName, toolInput)) return { settled: true, jsonl };
2009
- if (Date.now() >= deadline) return { settled: false, jsonl };
2010
- await Bun.sleep(this.settlePollIntervalMs);
2011
- }
2012
- }
2013
-
2014
- private preFlushBodyAlreadyMirroredByReasoningTail(jsonl: string, body: string): boolean {
2015
- const tail = this.reasoningTail;
2016
- if (!tail || !tail.emittedNarrationKeys.size) return false;
2017
- const steps = extractLatestTurnSteps(jsonl);
2018
- const keys = stepDedupKeys(steps);
2019
- const mirrored = steps
2020
- .map((step, index) => ({ step, key: keys[index]! }))
2021
- .filter(({ step, key }) => step.type === "narration" && tail.emittedNarrationKeys.has(key))
2022
- .map(({ step }) => step.text)
2023
- .join("\n\n")
2024
- .trim();
2025
- const trimmed = body.trim();
2026
- return mirrored === trimmed || mirrored.endsWith(`\n\n${trimmed}`);
2027
- }
2028
-
2029
1821
  private stopReasoningTail(): void {
2030
- if (this.reasoningTail) clearInterval(this.reasoningTail.timer);
2031
- this.reasoningTail = undefined;
1822
+ this.options.adapter.stopSessionTrace?.();
2032
1823
  }
2033
1824
 
2034
1825
  // Mirror a discreet, durable "context compacted" marker into chat via the existing
@@ -2142,9 +1933,7 @@ export class AgentRunner {
2142
1933
  // silent-death terminal event + #636 post-mortem.
2143
1934
  ...(this.terminalProviderExit ? {
2144
1935
  terminalProviderExit: this.terminalProviderExit,
2145
- ...(typeof this.terminalProviderExit.claudeResumeId === "string"
2146
- ? { lastError: `Claude provider exited; resume id ${this.terminalProviderExit.claudeResumeId} captured for manual recovery` }
2147
- : { lastError: "Claude provider exited; manual intervention required" }),
1936
+ lastError: providerExitLastError(this.options.provider, this.terminalProviderExit),
2148
1937
  } : {}),
2149
1938
  busyReasons: staleProviderTurnBusy ? busyReasons.filter((reason) => reason !== "provider-turn") : busyReasons,
2150
1939
  completedProviderTurns: this.completedProviderTurns,
@@ -2185,7 +1974,7 @@ export class AgentRunner {
2185
1974
  await this.http.setStatus(this.agentId, agentStatus, this.options.instanceId);
2186
1975
  await this.http.setReady(this.agentId, agentStatus !== "offline", this.options.instanceId);
2187
1976
  await this.http.heartbeat(this.agentId, this.options.instanceId);
2188
- await this.claudeQuotaHarvest.publish().catch((error) => this.logRunnerDiagnostic(`passive Claude quota publish failed: ${errMessage(error)}`));
1977
+ await this.providerQuotaHarvest.publish().catch((error) => this.logRunnerDiagnostic(`passive provider quota publish failed: ${errMessage(error)}`));
2189
1978
  await this.bootstrapUnreadMessages();
2190
1979
  } catch (error) {
2191
1980
  if (!this.stopped) this.handleHttpLivenessFailure(error);