agent-relay-runner 0.62.3 → 0.63.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.
@@ -0,0 +1,2224 @@
1
+ import { hostname } from "node:os";
2
+ import { mkdirSync, writeFileSync } from "node:fs";
3
+ import { readFile } from "node:fs/promises";
4
+ import { dirname, join } from "node:path";
5
+ import type { AgentLifecycle, AgentProfile, ContextState, Message, MessageSessionMeta, SendMessageInput, TaskStatusInput, WorkspaceMetadata } from "agent-relay-sdk";
6
+ import { errMessage, RelayBusClient, RelayHttpClient } from "agent-relay-sdk";
7
+ import { contextStateFromProbeMetrics, readContextProbeState } from "agent-relay-sdk/context-probe";
8
+ import { providerAttachmentText, providerMessageText } from "./adapter";
9
+ import type { ManagedProcess, ProviderAdapter, ProviderConfig, ProviderPermissionDecision, ProviderPermissionDecisionInput, ProviderSessionEvent, ProviderStatusUpdate, RunnerSpawnConfig, SemanticStatus, TerminalAttachSpec } from "./adapter";
10
+ import { messagesWithCachedAttachments } from "./attachment-cache";
11
+ import { ClaimTracker } from "./claim-tracker";
12
+ import { startControlServer, type ControlServer } from "./control-server";
13
+ import { ReplyObligationCache, obligationRequiresExplicitReply } from "./reply-obligation-cache";
14
+ import { Outbox, type OutboxRecord } from "./outbox";
15
+ import { extractLastAssistantTurn, extractLastAssistantTurnAfterEntry, countTranscriptEntries, extractFinalAssistantMessage, extractHookAssistantMessage, extractLatestTurnSteps, stepDedupKeys, transcriptLooksComplete } from "./adapters/claude-transcript";
16
+ import { profileUsesHostProviderGlobals } from "./profile-home";
17
+ import { RELAY_MCP_TOKEN_ENV, relayMcpEndpoint } from "./relay-mcp";
18
+ import { RelayMcpProxy } from "./relay-mcp-proxy";
19
+ import { runtimeMetadata } from "./version";
20
+ import { logger, parseLogLevel } from "./logger";
21
+ import { ensureSessionScratch, reapSessionScratch, sweepStaleSessions, type SessionScratchLayout } from "./session-scratch";
22
+ import { deliverBufferedMcpCall as deliverBufferedMcpOutboxCall, deliverRunnerOutboxEvent } from "./mcp-outbox";
23
+ import { RunnerInsights } from "./runner-insights";
24
+ import { BusyReconciler } from "./busy-reconciler";
25
+ import {
26
+ appliedAgentProfileMetadata,
27
+ commandTimeoutMs,
28
+ csvTags,
29
+ httpErrorKey,
30
+ isContextState,
31
+ isHttpAuthError,
32
+ isHttpStatusError,
33
+ latestClaudeResumeIdFromLogFile,
34
+ lifecycleCapabilities,
35
+ providerStateFromActiveWork,
36
+ relayBusUrl,
37
+ runnerAgentStatus,
38
+ runnerBusErrorAction,
39
+ runnerMessageMatches,
40
+ runnerShouldResolveProviderExit,
41
+ runnerShouldRestartUnexpectedProviderExit,
42
+ runtimeProviderCapabilities,
43
+ runtimeTokenRenewDelayMs,
44
+ runtimeTokenRenewTimerSchedule,
45
+ shouldLogDeliveryFailure,
46
+ stripRunnerClaimedGuidance,
47
+ taskIdFromMessage,
48
+ type ProbeModelInfo,
49
+ } from "./runner-helpers";
50
+
51
+ // A destructive session transition. The runner runs end-of-session work (Insights
52
+ // capture, #183/#184) before the invasive operation and, during that window, presents a
53
+ // distinct non-addressable lifecycle state. Bus commands and provider hooks (Claude
54
+ // PreCompact / SessionEnd) both normalize to one of these.
55
+ type SessionDestroyReason = "compact" | "clear" | "restart" | "shutdown" | "kill" | "crash";
56
+
57
+ // Reasons after which the runner process won't survive to drain the durable outbox (and the
58
+ // per-agent outbox is never reopened once the agent is gone). For these, pre-destroy must
59
+ // block on delivery of the just-captured Insights datapoint, not just enqueue it (#183).
60
+ // `restart` (bus command) deliberately excluded: the runner stays alive and drains normally.
61
+ function reasonExitsRunner(reason: SessionDestroyReason): boolean {
62
+ return reason === "shutdown" || reason === "kill" || reason === "crash";
63
+ }
64
+
65
+ // `finalizing-<reason>` is the transient pre-destroy window; the others are the executing
66
+ // teardown states the dashboard already renders.
67
+ type LifecycleAction =
68
+ | "shutting-down" | "killing" | "restarting"
69
+ | `finalizing-${SessionDestroyReason}`;
70
+
71
+ // Pre-destroy work is best-effort and must never hang teardown. Capping it keeps a slow
72
+ // transcript read or a wedged provider from stalling a shutdown the operator asked for.
73
+ const PRE_DESTROY_TIMEOUT_MS = 4_000;
74
+
75
+ // Bounded window to deliver the durable outbox before an exit-bound teardown (#183). Kept
76
+ // short so a wedged/down server can't stall an operator-requested shutdown for long; a
77
+ // row that still can't land is logged, not silently dropped.
78
+ const OUTBOX_FLUSH_TIMEOUT_MS = 3_000;
79
+
80
+ // Map a lifecycle bus command to its destructive boundary reason, or undefined for
81
+ // non-destructive commands (interrupt, inject, reconnect, permission decisions).
82
+ function boundaryReasonForCommand(type: string): SessionDestroyReason | undefined {
83
+ switch (type) {
84
+ case "agent.compact": return "compact";
85
+ case "agent.clearContext": return "clear";
86
+ case "agent.restart": return "restart";
87
+ case "agent.shutdown": return "shutdown";
88
+ case "agent.kill": return "kill";
89
+ default: return undefined;
90
+ }
91
+ }
92
+
93
+ interface RunnerOptions {
94
+ provider: string;
95
+ model?: string;
96
+ effort?: string;
97
+ runnerId: string;
98
+ instanceId: string;
99
+ agentId?: string;
100
+ relayUrl: string;
101
+ token?: string;
102
+ tokenJti?: string;
103
+ tokenProfileId?: string;
104
+ tokenExpiresAt?: number;
105
+ rootTokenFallback?: boolean;
106
+ cwd: string;
107
+ headless: boolean;
108
+ approvalMode: string;
109
+ label?: string;
110
+ rig?: string;
111
+ profile?: string;
112
+ agentProfile?: AgentProfile;
113
+ prompt?: string;
114
+ systemPromptAppend?: string;
115
+ tags: string[];
116
+ capabilities: string[];
117
+ providerArgs: string[];
118
+ policyName?: string;
119
+ spawnRequestId?: string;
120
+ automationId?: string; automationRunId?: string;
121
+ workspace?: WorkspaceMetadata; lifecycle?: AgentLifecycle;
122
+ tmuxSession?: string;
123
+ startedAt: number;
124
+ providerConfig: ProviderConfig;
125
+ adapter: ProviderAdapter;
126
+ onProviderExit?: (code: number | null) => void;
127
+ exitProcessOnShutdown?: boolean;
128
+ }
129
+
130
+ interface ActiveTaskClaim {
131
+ messageId: number;
132
+ taskId: number;
133
+ observedProviderBusy: boolean;
134
+ }
135
+
136
+ const CLAIM_RENEW_INTERVAL_MS = 5 * 60 * 1000;
137
+ const HTTP_LIVENESS_INTERVAL_MS = 20_000;
138
+ const HTTP_LIVENESS_LOG_INTERVAL_MS = 5 * 60 * 1000;
139
+ const TOKEN_RENEW_RETRY_MS = 60_000;
140
+ // Debounce reactive token recovery so a burst of 401-ing calls in the same window
141
+ // triggers a single re-mint attempt, not one per failing request.
142
+ const REACTIVE_TOKEN_RECOVERY_DEBOUNCE_MS = 10_000;
143
+ const UNEXPECTED_EXIT_WINDOW_MS = 2 * 60 * 1000;
144
+ const RAPID_EXIT_MS = 30 * 1000;
145
+ const MAX_RAPID_UNEXPECTED_EXITS = 3;
146
+ // A UserPromptSubmit echo matching a runner-injected prompt within this window is
147
+ // the same prompt arriving back from the provider — drop it to avoid a duplicate.
148
+ const PROMPT_ECHO_DEDUP_MS = 30_000;
149
+ // Relay-injected content (delivered messages, memory context) is wrapped with
150
+ // these markers; a UserPromptSubmit echo starting with one is a runner injection,
151
+ // not a human typing into the terminal, so it must not be mirrored as a prompt.
152
+ // `<task-notification>` is the harness wrapper for async monitor/task wake-ups
153
+ // (which embed a `[relay message #…]` deeper inside, defeating a plain prefix
154
+ // match) — it is emitted only by the harness, never typed by a human, so any
155
+ // echo starting with it is a system injection, not a user prompt (#289).
156
+ const RELAY_INJECTION_MARKERS = ["[relay message #", "[agent-relay", "<task-notification>"];
157
+ // #329: a cold isolated profile (first-run trust/onboarding/install gates) can take far longer
158
+ // than the warm-restart window to render an input-ready TUI, so the first prompt delivery gets a
159
+ // generous timeout. If it still misses, the prompt is re-attempted on the next provider ready
160
+ // signal (short timeout, since it just went idle), up to a bounded number of attempts.
161
+ const INITIAL_PROMPT_FIRST_READY_TIMEOUT_MS = 30_000;
162
+ const INITIAL_PROMPT_RETRY_READY_TIMEOUT_MS = 10_000;
163
+ const MAX_INITIAL_PROMPT_ATTEMPTS = 6;
164
+ // Reasoning tailer poll cadence (item 5). Coarse on purpose — reasoning is a
165
+ // discreet progress signal, not a token stream, so ~1.2s keeps it light.
166
+ const REASONING_POLL_MS = 1_200;
167
+ interface RunnerTimelineEvent {
168
+ status: string;
169
+ id?: string;
170
+ timestamp: number;
171
+ title?: string;
172
+ body?: string;
173
+ icon?: string;
174
+ metadata?: Record<string, unknown>;
175
+ }
176
+
177
+ interface ReasoningTailState {
178
+ timer: ReturnType<typeof setInterval>;
179
+ seen: Set<string>;
180
+ emittedNarrationKeys: Set<string>;
181
+ poll(): Promise<void>;
182
+ }
183
+
184
+ export class AgentRunner {
185
+ private readonly agentId: string;
186
+ private readonly claims = new ClaimTracker();
187
+ private readonly http: RelayHttpClient;
188
+ private readonly bus: RelayBusClient;
189
+ // Phase 2 (#196): the Stop hook reads reply obligations from this local snapshot, never
190
+ // from the server — so a slow server can no longer wedge a turn (the crux fix).
191
+ private readonly obligationCache: ReplyObligationCache;
192
+ // Phase 2 (#196): Runner→server append-log events (session turns, reasoning, prompts,
193
+ // insights, hook-fatal) go through this durable, disk-backed, timestamped queue instead of
194
+ // direct fire-and-forget HTTP — so nothing is lost across a server/Runner restart.
195
+ private readonly outbox: Outbox;
196
+ // #332: a fast-lane outbox for display-only session-mirror trace events. Routing them through
197
+ // their own FIFO queue stops one transient trace failure from head-of-line blocking real-message
198
+ // delivery (and vice versa); its backoff is capped low since a stale live-mirror frame is cheap.
199
+ private readonly sessionOutbox: Outbox;
200
+ private readonly insights: RunnerInsights;
201
+ private readonly busyReconciler: BusyReconciler;
202
+ private currentToken?: string;
203
+ private currentTokenJti?: string;
204
+ private currentTokenProfileId?: string;
205
+ private currentTokenExpiresAt?: number;
206
+ private control?: ControlServer;
207
+ // Stage 2 (#215): the local MCP endpoint the agent connects to, fronting the relay so the
208
+ // Runner owns reconnect/backoff + a durable buffer. Disabled via AGENT_RELAY_MCP_PROXY=0
209
+ // (then the agent connects to the relay directly, Stage-1 behaviour). The proxy secret is the
210
+ // bearer the agent presents to the localhost proxy — it decouples the agent from the rotating
211
+ // relay token (the proxy injects the live token relay-side).
212
+ private proxy?: RelayMcpProxy;
213
+ private mcpProxyEndpoint?: string;
214
+ private readonly mcpProxyEnabled: boolean;
215
+ private readonly mcpProxySecret: string;
216
+ private process?: ManagedProcess;
217
+ private stopped = false;
218
+ private exitCommandInProgress = false;
219
+ private restartInProgress = false;
220
+ // Set for the whole unexpected-exit restart, including the pre-restart backoff
221
+ // sleep, so a second exit during that window can't launch a parallel restart.
222
+ private restartPending = false;
223
+ private delivering = false;
224
+ private drainTimer?: Timer;
225
+ private claimRenewTimer?: Timer;
226
+ private httpLivenessTimer?: Timer;
227
+ private httpLivenessInFlight = false;
228
+ private httpLivenessAuthFailed = false;
229
+ private httpLivenessLastLog?: { key: string; at: number };
230
+ private tokenRenewTimer?: Timer;
231
+ private tokenRenewInFlight = false;
232
+ private tokenRenewLastLog?: { key: string; at: number };
233
+ private reactiveTokenRecoveryAt?: number;
234
+ private processStartedAt = 0;
235
+ private providerSessionId = crypto.randomUUID();
236
+ // Last transcript path seen this session — used by end-of-session Insights (#184)
237
+ // when the SessionEnd hook payload omits it.
238
+ private lastTranscriptPath?: string;
239
+ private lifecycleAction?: LifecycleAction;
240
+ // Coalesces concurrent pre-session-destroy runs (e.g. the shutdown bus command and the
241
+ // SessionEnd hook both fire for the same teardown) so the cursor isn't raced.
242
+ private preDestroyPromise?: Promise<void>;
243
+ private readonly unexpectedExitTimes: number[] = [];
244
+ private readonly pendingMessages = new Map<number, Message>();
245
+ private readonly activeTaskClaims = new Map<number, ActiveTaskClaim>();
246
+ private pendingTimelineEvent?: RunnerTimelineEvent;
247
+ private pendingPromptMessageId?: number;
248
+ // #329: a spawn-time initial prompt whose first delivery timed out before the TUI was
249
+ // ready. Held (not dropped) for a ready-signal-driven re-attempt; cleared on success.
250
+ private pendingInitialPrompt?: string;
251
+ private deliveringInitialPrompt = false;
252
+ private initialPromptAttempts = 0;
253
+ // Session-mirror: a synthesized id grouping a turn's reasoning/tool steps and
254
+ // its final response. Set when a provider-turn starts, cleared when it ends.
255
+ private currentTurnId?: string;
256
+ private currentTurnStartedAt?: number;
257
+ // #435: high-water-mark for the pre-turn narrative flush. Counts how many
258
+ // valid JSONL entries were in the transcript at last pre-flush time so the
259
+ // Stop-hook capture (full mode) can skip what was already emitted.
260
+ private narrativeFlushEntryCount = 0;
261
+ // True while a turn that was already in flight is being compacted. Claude's PostCompact
262
+ // hook posts a single `idle`, but a mid-turn compaction RESUMES the turn afterward — so
263
+ // treating that idle as a turn end (flip idle, kill the reasoning tail) makes chat go dark
264
+ // until the next prompt. Set when `compacting` arrives with a turn running; cleared on the
265
+ // genuine end (a plain idle with no compaction timeline).
266
+ private compactionMidTurn = false;
267
+ // Prompt-echo dedup: a short, time-bounded queue of prompts the runner itself
268
+ // injected (chat box or initial prompt) that are still awaiting their matching
269
+ // UserPromptSubmit echo. A single slot dropped earlier entries when several prompts
270
+ // were injected before their echoes returned (rapid sends while the provider is busy
271
+ // and queues them) — the evicted ones then double-posted. Match consumes one entry.
272
+ private injectedPrompts: Array<{ text: string; at: number }> = [];
273
+ // Verbose session-mirror diagnostics (turn lifecycle, reconciler probes, tail
274
+ // emits) → a dedicated clean log. Always on for key transitions; AGENT_RELAY_SESSION_DEBUG=1 adds the high-frequency probe/emit lines.
275
+ private readonly sessionDebugVerbose = process.env.AGENT_RELAY_SESSION_DEBUG === "1";
276
+ // Tracks whether the provider is in a legitimate blocked/approval state, so the
277
+ // busy reconciler doesn't mistake a permission prompt for a stuck-busy turn.
278
+ private providerBlocked = false;
279
+ private terminalFailure?: { reason: string; message: string; providerState?: Record<string, unknown> };
280
+ // #286: a usage/rate-limit hold. The turn truly ended (idle), but the agent is
281
+ // held until the limit resets, so this can't ride an active-work claim like the
282
+ // permission-blocked state does. publishStatus surfaces it as the agent's
283
+ // providerState; it clears when a new turn starts (the relay's resume message
284
+ // wakes one) so the blocked badge lifts on its own.
285
+ private rateLimitHold?: Record<string, unknown>;
286
+ // Reasoning tailer (item 5): streams the in-flight turn's reasoning/tool steps
287
+ // from the Claude transcript into chat as discreet session events.
288
+ private reasoningTail?: ReasoningTailState;
289
+ private scratch?: SessionScratchLayout;
290
+ // #417: for non-claude providers, tracks how many times each spawner obligation has
291
+ // been re-injected so a non-responsive agent can't spin in a re-injection loop.
292
+ private readonly pendingObligationInjections = new Map<number, number>();
293
+
294
+ constructor(private readonly options: RunnerOptions) {
295
+ this.agentId = options.agentId ?? options.runnerId;
296
+ // Bind the process-global logger to this agent. AGENT_RELAY_SESSION_DEBUG=1 is
297
+ // kept as a back-compat alias for the verbose probe/emit lines, now expressed
298
+ // as log level "debug" (AGENT_RELAY_LOG_LEVEL still wins when both are set).
299
+ logger.configure({
300
+ agentId: this.agentId,
301
+ headless: options.headless,
302
+ ...(this.sessionDebugVerbose && !parseLogLevel(process.env.AGENT_RELAY_LOG_LEVEL) ? { level: "debug" as const } : {}),
303
+ });
304
+ this.currentToken = options.token;
305
+ this.currentTokenJti = options.tokenJti;
306
+ this.currentTokenProfileId = options.tokenProfileId;
307
+ this.currentTokenExpiresAt = options.tokenExpiresAt;
308
+ this.mcpProxyEnabled = !["0", "false", "off"].includes((process.env.AGENT_RELAY_MCP_PROXY ?? "").trim().toLowerCase());
309
+ this.mcpProxySecret = crypto.randomUUID();
310
+ const runtime = runtimeMetadata(options.provider);
311
+ this.http = new RelayHttpClient({ baseUrl: options.relayUrl, token: this.currentToken });
312
+ this.obligationCache = new ReplyObligationCache({ fetch: () => this.http.listReplyObligations(this.agentId) });
313
+ // Co-locate the durable outbox with the runner's runtime state (survives reboot) when the
314
+ // orchestrator told us where that is; otherwise the Outbox falls back to a temp dir.
315
+ const outboxDir = process.env.AGENT_RELAY_RUNNER_OUTBOX_DIR
316
+ ?? (process.env.AGENT_RELAY_RUNNER_INFO_FILE ? join(dirname(process.env.AGENT_RELAY_RUNNER_INFO_FILE), "outbox") : undefined);
317
+ this.outbox = new Outbox({ agentId: this.agentId, dir: outboxDir, send: (record) => this.deliverOutboxEvent(record) });
318
+ this.sessionOutbox = new Outbox({
319
+ agentId: `${this.agentId}-session`,
320
+ dir: outboxDir,
321
+ send: (record) => this.deliverOutboxEvent(record),
322
+ // Ephemeral trace frames: cap the per-row stall low and poison sooner than the main queue.
323
+ maxBackoffMs: 5_000,
324
+ maxAttempts: 6,
325
+ });
326
+ this.insights = new RunnerInsights({
327
+ agentId: this.agentId,
328
+ cwd: options.cwd,
329
+ workspace: options.workspace,
330
+ getOutbox: () => this.outbox,
331
+ getProviderSessionId: () => this.providerSessionId,
332
+ getProcess: () => this.process,
333
+ getAdapter: () => this.options.adapter,
334
+ getLastTranscriptPath: () => this.lastTranscriptPath,
335
+ sessionLog: (message) => this.sessionLog(message),
336
+ });
337
+ this.busyReconciler = new BusyReconciler({
338
+ isStopped: () => this.stopped,
339
+ hasProcess: () => Boolean(this.process),
340
+ currentStatus: () => this.claims.currentStatus(),
341
+ isProviderBlocked: () => this.providerBlocked,
342
+ hasProviderTurn: () => this.claims.activeWork().some((w) => w.kind === "provider-turn"),
343
+ canProbeActivity: () => Boolean(this.options.adapter.probeActivity),
344
+ probeActivity: () => this.process && this.options.adapter.probeActivity
345
+ ? this.options.adapter.probeActivity(this.process)
346
+ : undefined,
347
+ clearProviderTurn: (reason) => this.forceClearProviderTurn(reason),
348
+ sessionDebug: (message) => this.sessionDebug(message),
349
+ });
350
+ this.bus = new RelayBusClient({
351
+ url: relayBusUrl(options.relayUrl),
352
+ role: "provider",
353
+ componentId: options.runnerId,
354
+ agentId: this.agentId,
355
+ instanceId: options.instanceId,
356
+ token: this.currentToken,
357
+ machine: hostname(),
358
+ capabilities: [
359
+ ...new Set([
360
+ ...options.providerConfig.defaultCapabilities,
361
+ ...options.capabilities,
362
+ ...csvTags(process.env.AGENT_RELAY_CAPS),
363
+ "lifecycle.shutdown.hard",
364
+ "lifecycle.restart.hard",
365
+ "lifecycle.status.semantic",
366
+ "lifecycle.heartbeat",
367
+ "lifecycle.reconnect.transport",
368
+ "commands.lifecycle",
369
+ "capabilities.report",
370
+ ]),
371
+ ],
372
+ tags: [...new Set([options.provider, ...csvTags(process.env.AGENT_RELAY_TAGS), ...options.tags, ...options.providerConfig.defaultTags, ...(options.headless ? ["headless"] : [])])],
373
+ meta: {
374
+ ...runtime,
375
+ version: runtime.package.version,
376
+ provider: options.provider,
377
+ model: options.model ?? null,
378
+ effort: options.effort ?? null,
379
+ profile: options.profile ?? null,
380
+ agentProfile: options.agentProfile ? appliedAgentProfileMetadata(options.provider, options.agentProfile) : null,
381
+ runnerId: options.runnerId,
382
+ startedAt: options.startedAt,
383
+ tmuxSession: this.providerTerminalSession() ?? options.tmuxSession ?? null,
384
+ tmuxSocket: this.providerTerminalSocket() ?? null,
385
+ policyName: options.policyName ?? null,
386
+ spawnRequestId: options.spawnRequestId ?? null,
387
+ automationId: options.automationId ?? null,
388
+ automationRunId: options.automationRunId ?? null,
389
+ workspace: options.workspace ?? null,
390
+ lifecycle: options.lifecycle ?? "persistent",
391
+ workspaceMode: options.workspace?.requestedMode ?? options.workspace?.mode ?? process.env.AGENT_RELAY_WORKSPACE_MODE ?? null,
392
+ runnerManaged: true,
393
+ cwd: options.cwd,
394
+ approvalMode: options.approvalMode,
395
+ label: options.label ?? null,
396
+ auth: {
397
+ profileId: this.currentTokenProfileId ?? null,
398
+ jti: this.currentTokenJti ?? null,
399
+ expiresAt: this.currentTokenExpiresAt ?? null,
400
+ rootTokenFallback: options.rootTokenFallback === true,
401
+ },
402
+ lifecycleCapabilities: lifecycleCapabilities(),
403
+ providerCapabilities: runtimeProviderCapabilities(options),
404
+ },
405
+ heartbeatMeta: () => this.heartbeatRuntimeMeta(),
406
+ heartbeatIntervalMs: 30_000,
407
+ initialStatus: "idle",
408
+ });
409
+ }
410
+
411
+ get id(): string {
412
+ return this.agentId;
413
+ }
414
+
415
+ async run(): Promise<void> {
416
+ this.control = startControlServer({
417
+ onStatus: (status) => this.setProviderStatus(status),
418
+ onTerminalAttachSpec: () => this.terminalAttachSpec(),
419
+ // Hot-path-safe: answered instantly from the local snapshot, never a server
420
+ // round-trip. The snapshot is kept warm by the background refresh below (#196).
421
+ onReplyObligations: () => Promise.resolve(this.obligationCache.get()),
422
+ onSessionTurn: (input) => this.publishSessionTurn(input),
423
+ onUserPrompt: (input) => this.handleUserPrompt(input),
424
+ onSessionBoundary: (input) => this.handleSessionBoundary(input),
425
+ onHookFatal: (report) => this.reportHookFatal(report),
426
+ });
427
+ this.startMcpProxy();
428
+ this.writeRunnerInfoFile();
429
+ this.options.adapter.onStatusChange((status) => {
430
+ if (this.restartInProgress || this.restartPending) return;
431
+ const semanticStatus = typeof status === "string" ? status : status.status;
432
+ if (this.shouldRestartUnexpectedProviderExit(semanticStatus)) {
433
+ void this.restartUnexpectedProviderExit(semanticStatus);
434
+ return;
435
+ }
436
+ this.setProviderStatus(status);
437
+ if (runnerShouldResolveProviderExit(semanticStatus, this.exitCommandInProgress)) this.options.onProviderExit?.(semanticStatus === "offline" ? 0 : 1);
438
+ });
439
+ this.options.adapter.onSessionEvent?.((event) => { void this.publishProviderSessionEvent(event); });
440
+ this.bus.on("message.new", (message) => {
441
+ // A delivered message may create a new reply obligation — warm the snapshot so the
442
+ // next turn-end sees it without a hot-path server read.
443
+ this.obligationCache.markDirty();
444
+ this.enqueueMessage(message as Message);
445
+ });
446
+ this.bus.on("command", (type, params, commandId, command) => {
447
+ void this.handleCommand(type, params, commandId, command);
448
+ });
449
+ this.bus.on("error", (code, message) => this.handleBusError(String(code), String(message)));
450
+ await this.bus.connect();
451
+ this.obligationCache.start();
452
+ this.outbox.start();
453
+ this.sessionOutbox.start();
454
+ this.ensureScratch();
455
+ void this.sweepStaleScratch();
456
+ this.process = await this.spawnProvider();
457
+ this.writeRunnerInfoFile();
458
+ this.processStartedAt = Date.now();
459
+ this.publishStatus();
460
+ await this.deliverInitialPrompt();
461
+ await this.bootstrapUnreadMessages();
462
+ this.startClaimRenewer();
463
+ this.startHttpLiveness();
464
+ this.scheduleRuntimeTokenRenewal();
465
+ this.scheduleDrain();
466
+ }
467
+
468
+ async stop(): Promise<void> {
469
+ const alreadyStopped = this.stopped;
470
+ this.stopped = true;
471
+ reapSessionScratch({
472
+ agentId: this.agentId,
473
+ cwd: this.options.cwd,
474
+ fallbackBaseDir: process.env.AGENT_RELAY_ORCHESTRATOR_BASE_DIR,
475
+ });
476
+ if (!alreadyStopped) await this.bus.statusAsync({ agentStatus: "offline", ready: false });
477
+ if (this.process && !alreadyStopped) {
478
+ await this.options.adapter.shutdown(this.process, {
479
+ graceful: true,
480
+ timeoutMs: this.options.providerConfig.headless.shutdownTimeoutMs,
481
+ });
482
+ }
483
+ if (this.drainTimer) clearTimeout(this.drainTimer);
484
+ this.drainTimer = undefined;
485
+ if (this.claimRenewTimer) clearInterval(this.claimRenewTimer);
486
+ this.claimRenewTimer = undefined;
487
+ if (this.httpLivenessTimer) clearInterval(this.httpLivenessTimer);
488
+ this.httpLivenessTimer = undefined;
489
+ if (this.tokenRenewTimer) clearTimeout(this.tokenRenewTimer);
490
+ this.tokenRenewTimer = undefined;
491
+ this.busyReconciler.disarm();
492
+ this.stopReasoningTail();
493
+ this.obligationCache.stop();
494
+ this.outbox.close();
495
+ this.sessionOutbox.close();
496
+ this.proxy?.stop();
497
+ this.control?.stop();
498
+ await this.bus.close();
499
+ }
500
+
501
+ // Start the local MCP proxy the agent connects to (Stage 2, #215). Forwards tool calls to the
502
+ // relay with the runner's LIVE token, buffers bufferable writes durably during a relay outage,
503
+ // and narrows the tool list to this agent's workspace context. Best-effort: if it can't bind,
504
+ // we fall back to a direct relay MCP connection (the agent env still works, no resilience).
505
+ private startMcpProxy(): void {
506
+ if (!this.mcpProxyEnabled) return;
507
+ try {
508
+ this.proxy = new RelayMcpProxy({
509
+ relayMcpEndpoint: relayMcpEndpoint(this.options.relayUrl),
510
+ getToken: () => this.currentToken,
511
+ authSecret: this.mcpProxySecret,
512
+ enqueueBuffered: (call) => {
513
+ this.outbox.enqueue({
514
+ kind: "mcp-tool-call",
515
+ payload: { tool: call.tool, arguments: call.arguments },
516
+ idempotencyKey: call.idempotencyKey,
517
+ });
518
+ },
519
+ initialContext: { isolatedWorktree: this.ownsIsolatedWorktree() },
520
+ });
521
+ this.mcpProxyEndpoint = this.proxy.start().url;
522
+ logger.info("mcp-proxy", `runner MCP proxy listening at ${this.mcpProxyEndpoint} (worktree=${this.ownsIsolatedWorktree()})`);
523
+ } catch (error) {
524
+ this.proxy = undefined;
525
+ this.mcpProxyEndpoint = undefined;
526
+ logger.warn("mcp-proxy", `failed to start MCP proxy; agent will connect to the relay directly: ${errMessage(error)}`);
527
+ }
528
+ }
529
+
530
+ private ownsIsolatedWorktree(): boolean {
531
+ const mode = this.options.workspace?.requestedMode ?? this.options.workspace?.mode ?? process.env.AGENT_RELAY_WORKSPACE_MODE;
532
+ return mode === "isolated";
533
+ }
534
+
535
+ private async spawnProvider(): Promise<ManagedProcess> {
536
+ this.providerSessionId = crypto.randomUUID();
537
+ this.lastTranscriptPath = undefined;
538
+ const includeProviderGlobals = profileUsesHostProviderGlobals(this.options);
539
+ const env = {
540
+ ...process.env as Record<string, string>,
541
+ ...(includeProviderGlobals ? this.options.providerConfig.env : {}),
542
+ AGENT_RELAY_RUNNER_PORT: String(this.control!.port),
543
+ AGENT_RELAY_RUNNER_ID: this.options.runnerId,
544
+ AGENT_RELAY_PROVIDER_SESSION_ID: this.providerSessionId,
545
+ AGENT_RELAY_ID: this.agentId,
546
+ // The repo-name project id the runner uses for insight observations. Exposed so
547
+ // agent-side signals (e.g. /introspect) tag the SAME project and aggregate together,
548
+ // instead of the agent's worktree cwd splitting one repo into many "projects".
549
+ AGENT_RELAY_PROJECT: this.insightProject(),
550
+ ...(this.scratch ? { AGENT_RELAY_SCRATCH_DIR: this.scratch.tmpDir } : {}),
551
+ AGENT_RELAY_URL: this.options.relayUrl,
552
+ AGENT_RELAY_APPROVAL: this.options.approvalMode,
553
+ ...(this.currentToken ? { AGENT_RELAY_TOKEN: this.currentToken } : {}),
554
+ // Dedicated, un-clobberable credential for the injected MCP endpoint. A rig's
555
+ // settings.json `env.AGENT_RELAY_TOKEN` would override the scoped token above at
556
+ // MCP-parse time → server-actor auth, no identity (#233). The MCP config references
557
+ // ${AGENT_RELAY_SESSION_TOKEN}, which rigs never set. See runner/src/relay-mcp.ts.
558
+ //
559
+ // Stage 2 (#215): when the proxy is active the agent connects to the LOCAL proxy, so this
560
+ // holds the per-session PROXY SECRET (not the relay token). The proxy injects the live
561
+ // relay token itself — the agent never holds it, and token rotation is invisible. With the
562
+ // proxy disabled this stays the scoped relay token (Stage-1 direct connection).
563
+ ...(this.proxy
564
+ ? { [RELAY_MCP_TOKEN_ENV]: this.mcpProxySecret }
565
+ : (this.currentToken ? { [RELAY_MCP_TOKEN_ENV]: this.currentToken } : {})),
566
+ ...(this.currentTokenJti ? { AGENT_RELAY_TOKEN_JTI: this.currentTokenJti } : {}),
567
+ ...(this.currentTokenProfileId ? { AGENT_RELAY_TOKEN_PROFILE: this.currentTokenProfileId } : {}),
568
+ ...(this.currentTokenExpiresAt ? { AGENT_RELAY_TOKEN_EXPIRES_AT: String(this.currentTokenExpiresAt) } : {}),
569
+ };
570
+ const config: RunnerSpawnConfig = {
571
+ provider: this.options.provider,
572
+ model: this.options.model,
573
+ effort: this.options.effort,
574
+ runnerId: this.options.runnerId,
575
+ instanceId: this.options.instanceId,
576
+ agentId: this.agentId,
577
+ relayUrl: this.options.relayUrl,
578
+ cwd: this.options.cwd,
579
+ headless: this.options.headless,
580
+ approvalMode: this.options.approvalMode,
581
+ ...(this.options.label ? { label: this.options.label } : {}),
582
+ ...(this.options.rig ? { rig: this.options.rig } : {}),
583
+ ...(this.options.profile ? { profile: this.options.profile } : {}),
584
+ ...(this.options.agentProfile ? { agentProfile: this.options.agentProfile } : {}),
585
+ ...(this.options.prompt ? { prompt: this.options.prompt } : {}),
586
+ ...(this.options.systemPromptAppend ? { systemPromptAppend: this.options.systemPromptAppend } : {}),
587
+ ...(this.options.tmuxSession ? { tmuxSession: this.options.tmuxSession } : {}),
588
+ providerArgs: this.options.providerArgs,
589
+ providerConfig: this.options.providerConfig,
590
+ env,
591
+ controlPort: this.control!.port,
592
+ // Stage 2 (#215): the MCP endpoint the agent's client should target — the runner-local
593
+ // proxy when active, undefined when disabled (adapters fall back to the direct relay URL).
594
+ ...(this.mcpProxyEndpoint ? { relayMcpEndpoint: this.mcpProxyEndpoint } : {}),
595
+ monitor: {
596
+ deliver: (messages) => this.control!.deliverToMonitor(messages),
597
+ },
598
+ };
599
+ const launchSeededPrompt = this.options.adapter.seedsInitialPromptAtLaunch ? this.options.prompt?.trim() : ""; if (launchSeededPrompt) this.recordInjectedPrompt(launchSeededPrompt);
600
+ return this.options.adapter.spawn(config);
601
+ }
602
+
603
+ private async terminalAttachSpec(): Promise<TerminalAttachSpec> {
604
+ if (!this.process) throw new Error("provider process is unavailable");
605
+ if (!this.options.adapter.terminalAttachSpec) throw new Error("provider does not support terminal attach");
606
+ return this.options.adapter.terminalAttachSpec(this.process);
607
+ }
608
+
609
+ private writeRunnerInfoFile(): void {
610
+ const file = process.env.AGENT_RELAY_RUNNER_INFO_FILE;
611
+ if (!file || !this.control) return;
612
+ try {
613
+ mkdirSync(dirname(file), { recursive: true });
614
+ writeFileSync(file, JSON.stringify({
615
+ agentId: this.agentId,
616
+ runnerId: this.options.runnerId,
617
+ provider: this.options.provider,
618
+ controlUrl: this.control.url,
619
+ pid: process.pid,
620
+ ...(this.providerTerminalSession() ? { tmuxSession: this.providerTerminalSession() } : {}),
621
+ ...(this.providerTerminalSocket() ? { tmuxSocket: this.providerTerminalSocket() } : {}),
622
+ startedAt: this.options.startedAt,
623
+ }, null, 2) + "\n", { mode: 0o600 });
624
+ } catch (error) {
625
+ logger.error("runner", `failed to write runner info file: ${error}`);
626
+ }
627
+ }
628
+
629
+ private enqueueMessage(message: Message): void {
630
+ if (!this.matchesMessage(message)) return;
631
+ if (message.kind === "session") return;
632
+ this.pendingMessages.set(message.id, message);
633
+ this.scheduleDrain();
634
+ }
635
+
636
+ private async bootstrapUnreadMessages(): Promise<void> {
637
+ try {
638
+ const messages = await this.http.pollMessages({ for: this.agentId, unread: true, limit: 100 });
639
+ for (const message of messages) this.enqueueMessage(message);
640
+ } catch (error) {
641
+ logger.error("runner", `inbox bootstrap failed: ${error}`);
642
+ }
643
+ }
644
+
645
+ private async deliverInitialPrompt(): Promise<void> {
646
+ const prompt = this.options.prompt?.trim();
647
+ if (!prompt || this.options.adapter.seedsInitialPromptAtLaunch) return;
648
+ await this.attemptInitialPromptDelivery(prompt, INITIAL_PROMPT_FIRST_READY_TIMEOUT_MS);
649
+ }
650
+
651
+ // Deliver the spawn-time first prompt, surviving a cold-start TUI that isn't input-ready yet
652
+ // (#329). Timeout stashes it for ready-signal retry and surfaces the stall on the timeline.
653
+ private async attemptInitialPromptDelivery(prompt: string, readyTimeoutMs: number): Promise<void> {
654
+ if (this.deliveringInitialPrompt || this.stopped || !this.process || !this.options.adapter.deliverInitialPrompt) return;
655
+ this.deliveringInitialPrompt = true;
656
+ const attempt = (this.initialPromptAttempts += 1);
657
+ try {
658
+ this.recordInjectedPrompt(prompt); await this.options.adapter.deliverInitialPrompt(this.process, prompt, { readyTimeoutMs });
659
+ this.pendingInitialPrompt = undefined;
660
+ } catch (error) {
661
+ const giveUp = attempt >= MAX_INITIAL_PROMPT_ATTEMPTS;
662
+ this.pendingInitialPrompt = giveUp ? undefined : prompt;
663
+ logger.error("runner", `initial prompt attempt ${attempt} failed${giveUp ? " (giving up)" : "; will retry on next ready signal"}: ${errMessage(error)}`);
664
+ this.publishRunnerTimelineEvent({ status: "prompt_failed", timestamp: Date.now(), title: "Initial prompt not delivered", body: errMessage(error), metadata: { attempts: attempt, terminal: giveUp } });
665
+ } finally {
666
+ this.deliveringInitialPrompt = false;
667
+ }
668
+ }
669
+
670
+ private scheduleDrain(delayMs = 0): void {
671
+ if (this.stopped || this.drainTimer) return;
672
+ this.drainTimer = setTimeout(() => {
673
+ this.drainTimer = undefined;
674
+ void this.drainMessages();
675
+ }, delayMs);
676
+ }
677
+
678
+ private async drainMessages(): Promise<void> {
679
+ if (this.stopped || this.delivering || this.pendingMessages.size === 0) return;
680
+ if (!this.process) {
681
+ this.scheduleDrain(500);
682
+ return;
683
+ }
684
+ this.delivering = true;
685
+ const messages = [...this.pendingMessages.values()].sort((a, b) => a.id - b.id);
686
+ this.pendingMessages.clear();
687
+ const deliverable: Message[] = [];
688
+ const providerAlreadyBusy = this.claims.reasons().includes("provider-turn");
689
+ for (const message of messages) {
690
+ let toDeliver = message;
691
+ if (message.claimable) {
692
+ const claimed = await this.http.claimMessageResult(message.id, this.agentId).catch(() => ({ ok: false, claimExpiresAt: undefined }));
693
+ if (!claimed.ok) continue;
694
+ this.claims.startClaim("message", String(message.id), claimed.claimExpiresAt);
695
+ const taskId = taskIdFromMessage(message);
696
+ if (taskId) {
697
+ this.activeTaskClaims.set(message.id, { messageId: message.id, taskId, observedProviderBusy: providerAlreadyBusy });
698
+ this.claims.startClaim("task", String(taskId), claimed.claimExpiresAt);
699
+ await this.updateTaskStatus(taskId, {
700
+ status: "in_progress",
701
+ agentId: this.agentId,
702
+ metadata: { messageId: message.id, completedBy: "runner" },
703
+ }).catch((error) => logger.error("task", `task ${taskId} in_progress update failed: ${error}`));
704
+ // Runner owns claim + status here; drop the server's self-claim instruction
705
+ // so the agent doesn't improvise a stray claim send (see stripRunnerClaimedGuidance).
706
+ toDeliver = { ...message, body: stripRunnerClaimedGuidance(message.body) };
707
+ }
708
+ }
709
+ deliverable.push(toDeliver);
710
+ }
711
+ if (deliverable.length === 0) {
712
+ this.delivering = false;
713
+ this.publishStatus();
714
+ return;
715
+ }
716
+ this.publishStatus();
717
+ let failed = false;
718
+ try {
719
+ const prepared = await messagesWithCachedAttachments(deliverable, this.http, {
720
+ agentId: this.agentId,
721
+ onError: (message) => logger.error("runner", message),
722
+ });
723
+ await this.options.adapter.deliver(this.process, prepared);
724
+ for (const message of deliverable) {
725
+ await this.http.markRead(message.id, this.agentId).catch(() => {});
726
+ if (!taskIdFromMessage(message)) this.claims.finishClaim("message", String(message.id));
727
+ }
728
+ } catch (error) {
729
+ failed = true;
730
+ if (shouldLogDeliveryFailure(error)) logger.warn("delivery", `message delivery failed: ${error}`);
731
+ for (const message of deliverable) {
732
+ this.clearActiveClaim(message);
733
+ this.pendingMessages.set(message.id, message);
734
+ }
735
+ } finally {
736
+ this.delivering = false;
737
+ this.publishStatus();
738
+ if (this.pendingMessages.size > 0) this.scheduleDrain(failed ? 1000 : 0);
739
+ }
740
+ }
741
+
742
+ private async handleCommand(type: string, params: Record<string, unknown>, commandId: string, command?: Record<string, unknown>): Promise<void> {
743
+ const target = typeof command?.target === "string" ? command.target : this.agentId;
744
+ if (target !== this.agentId && target !== this.options.runnerId) return;
745
+ if (type !== "agent.shutdown" && type !== "agent.restart" && type !== "agent.reconnect" && type !== "agent.kill" && type !== "agent.compact" && type !== "agent.clearContext" && type !== "agent.injectContext" && type !== "agent.permissionDecision" && type !== "agent.interrupt" && type !== "prompt.inject") return;
746
+
747
+ const exitAfterCommand = type === "agent.shutdown" || type === "agent.kill";
748
+ if (exitAfterCommand) this.exitCommandInProgress = true;
749
+ this.claims.startClaim("command", commandId);
750
+ try {
751
+ await this.updateCommand(commandId, "accepted");
752
+ await this.updateCommand(commandId, "running");
753
+ // Pre-session-destroy seam (#183): for destructive transitions, run end-of-session
754
+ // work (Insights capture, #184) BEFORE the invasive operation, surfaced as a
755
+ // non-addressable "finalizing" state so the agent isn't mistaken for merely busy.
756
+ const destroyReason = boundaryReasonForCommand(type);
757
+ if (destroyReason) await this.runPreSessionDestroy(destroyReason);
758
+ // Move from the transient finalizing window to the executing teardown state (or drop
759
+ // it entirely for compact/clear, which complete promptly once capture is done).
760
+ if (exitAfterCommand) this.lifecycleAction = type === "agent.kill" ? "killing" : "shutting-down";
761
+ else if (type === "agent.restart") this.lifecycleAction = "restarting";
762
+ else this.lifecycleAction = undefined;
763
+ this.publishStatus();
764
+ let providerResult: Record<string, unknown> | void = undefined;
765
+ if (type === "agent.restart") await this.restartProvider();
766
+ else if (type === "agent.reconnect") this.publishStatus();
767
+ else if (type === "agent.compact") {
768
+ if (!this.options.adapter.compact || !this.process) throw new Error("provider does not support compact");
769
+ providerResult = await this.options.adapter.compact(this.process, {
770
+ instructions: typeof params.instructions === "string" ? params.instructions : undefined,
771
+ });
772
+ } else if (type === "agent.clearContext") {
773
+ if (!this.options.adapter.clearContext || !this.process) throw new Error("provider does not support clearContext");
774
+ providerResult = await this.options.adapter.clearContext(this.process);
775
+ } else if (type === "agent.interrupt") {
776
+ if (!this.options.adapter.interrupt || !this.process) throw new Error("provider does not support interrupt");
777
+ this.sessionLog("interrupt requested from dashboard");
778
+ providerResult = await this.options.adapter.interrupt(this.process);
779
+ this.busyReconciler.scheduleInterruptReconcile();
780
+ } else if (type === "agent.injectContext") {
781
+ if (!this.process) throw new Error("provider process is unavailable");
782
+ providerResult = await this.injectContext(params);
783
+ } else if (type === "agent.permissionDecision") {
784
+ providerResult = await this.respondToPermissionDecision(params);
785
+ } else if (type === "prompt.inject") {
786
+ providerResult = await this.injectPrompt(params);
787
+ } else await this.shutdownProvider(type === "agent.kill", commandTimeoutMs(params));
788
+ await this.updateCommand(commandId, "succeeded", {
789
+ action: type,
790
+ agentId: this.agentId,
791
+ runnerId: this.options.runnerId,
792
+ policyName: this.options.policyName,
793
+ spawnRequestId: this.options.spawnRequestId,
794
+ reason: typeof params.reason === "string" ? params.reason : undefined,
795
+ ...(providerResult ? { providerResult } : {}),
796
+ });
797
+ } catch (error) {
798
+ await this.updateCommand(commandId, "failed", undefined, errMessage(error)).catch(() => {});
799
+ } finally {
800
+ this.claims.finishClaim("command", commandId);
801
+ if (exitAfterCommand) {
802
+ if (params.preserveRegistration === true) {
803
+ await this.http.setStatus(this.agentId, "offline", this.options.instanceId).catch(() => {});
804
+ } else {
805
+ await this.http.deleteAgent(this.agentId).catch(() => {});
806
+ }
807
+ if (this.options.exitProcessOnShutdown !== false) {
808
+ setTimeout(() => void this.stop().catch((error) => {
809
+ logger.error("lifecycle", `stop after command failed: ${error}`);
810
+ }).finally(() => process.exit(0)), 10);
811
+ }
812
+ } else if (!this.stopped) {
813
+ this.lifecycleAction = undefined;
814
+ this.publishStatus();
815
+ }
816
+ }
817
+ }
818
+
819
+ private async injectContext(params: Record<string, unknown>): Promise<Record<string, unknown>> {
820
+ const content = typeof params.content === "string" ? params.content.trim() : "";
821
+ if (!content) throw new Error("content required");
822
+ const memoryIds = Array.isArray(params.memoryIds) ? params.memoryIds.filter((id): id is string => typeof id === "string") : [];
823
+ await this.options.adapter.deliver(this.process!, [{
824
+ id: 0,
825
+ from: "system",
826
+ to: this.agentId,
827
+ kind: "system",
828
+ subject: "Agent Relay memory context",
829
+ body: content,
830
+ payload: {
831
+ memoryInjection: true,
832
+ reason: typeof params.reason === "string" ? params.reason : undefined,
833
+ memoryIds,
834
+ },
835
+ readBy: [],
836
+ createdAt: Date.now(),
837
+ }]);
838
+ return { memoryIds, injectedMemoryCount: memoryIds.length };
839
+ }
840
+
841
+ private async respondToPermissionDecision(params: Record<string, unknown>): Promise<Record<string, unknown>> {
842
+ const approvalId = typeof params.approvalId === "string" ? params.approvalId : "";
843
+ const decision = typeof params.decision === "string" ? params.decision : "";
844
+ if (!approvalId) throw new Error("approvalId required");
845
+ if (decision !== "approve" && decision !== "approve-session" && decision !== "deny" && decision !== "abort" && decision !== "answer") {
846
+ throw new Error("decision must be approve, approve-session, deny, abort, or answer");
847
+ }
848
+ const answersRaw = params.answers;
849
+ const answers = answersRaw && typeof answersRaw === "object" && !Array.isArray(answersRaw)
850
+ ? Object.fromEntries(
851
+ Object.entries(answersRaw as Record<string, unknown>).filter(([, v]) => typeof v === "string") as [string, string][],
852
+ )
853
+ : undefined;
854
+ if (decision === "answer" && (!answers || Object.keys(answers).length === 0)) {
855
+ throw new Error("answers required for answer decision");
856
+ }
857
+ const input: ProviderPermissionDecisionInput = {
858
+ approvalId,
859
+ decision: decision as ProviderPermissionDecision,
860
+ ...(typeof params.reason === "string" ? { reason: params.reason } : {}),
861
+ ...(answers ? { answers } : {}),
862
+ };
863
+ if (this.control?.resolvePermissionDecision(input)) {
864
+ return { approvalId, decision, provider: "claude" };
865
+ }
866
+ if (!this.process) throw new Error("provider process is unavailable");
867
+ if (!this.options.adapter.respondToPermissionDecision) throw new Error("provider does not support permission decisions");
868
+ const result = await this.options.adapter.respondToPermissionDecision(this.process, input);
869
+ return { approvalId, decision, ...(result ? { providerResult: result } : {}) };
870
+ }
871
+
872
+ private async injectPrompt(params: Record<string, unknown>): Promise<Record<string, unknown>> {
873
+ const body = typeof params.body === "string" ? params.body : "";
874
+ if (!body) throw new Error("body required");
875
+ if (!this.process) throw new Error("provider process is unavailable");
876
+ if (!this.options.adapter.deliverInitialPrompt) throw new Error("provider does not support prompt injection");
877
+ const messageId = typeof params.messageId === "number" ? params.messageId : undefined;
878
+ const attachments = Array.isArray(params.attachments)
879
+ ? params.attachments.filter((item): item is Record<string, unknown> => item !== null && typeof item === "object" && !Array.isArray(item))
880
+ : [];
881
+ if (messageId) this.pendingPromptMessageId = messageId;
882
+ const prompt = await this.promptInjectionText(body, messageId, attachments);
883
+ // Mark so the matching UserPromptSubmit echo isn't double-posted: a chat-box
884
+ // prompt already created its own session message shown in the dashboard.
885
+ this.recordInjectedPrompt(prompt.trim());
886
+ await this.options.adapter.deliverInitialPrompt(this.process, prompt);
887
+ return { injected: true, messageId, attachmentCount: attachments.length };
888
+ }
889
+
890
+ private async promptInjectionText(body: string, messageId: number | undefined, attachments: Record<string, unknown>[]): Promise<string> {
891
+ if (!attachments.length) return body;
892
+ const messages = await messagesWithCachedAttachments([{
893
+ id: messageId ?? 0,
894
+ from: "user",
895
+ to: this.agentId,
896
+ kind: "session",
897
+ body,
898
+ payload: { attachments },
899
+ readBy: [],
900
+ createdAt: Date.now(),
901
+ }], this.http, {
902
+ agentId: this.agentId,
903
+ onError: (message) => logger.warn("attachments", message),
904
+ });
905
+ const attachmentText = providerAttachmentText(messages[0]!);
906
+ return attachmentText ? `${body}\n\n${attachmentText}` : body;
907
+ }
908
+
909
+ private async restartProvider(): Promise<void> {
910
+ this.restartInProgress = true;
911
+ try {
912
+ if (this.process) {
913
+ await this.options.adapter.shutdown(this.process, {
914
+ graceful: true,
915
+ timeoutMs: this.options.providerConfig.headless.shutdownTimeoutMs,
916
+ });
917
+ }
918
+ this.claims.clearTerminalStatus();
919
+ this.claims.clearWorkKind("provider-turn");
920
+ this.claims.clearWorkKind("subagent");
921
+ if (this.stopped) return;
922
+ this.process = await this.spawnProvider();
923
+ this.processStartedAt = Date.now();
924
+ } finally {
925
+ this.restartInProgress = false;
926
+ }
927
+ }
928
+
929
+ private shouldRestartUnexpectedProviderExit(status: SemanticStatus): boolean {
930
+ return runnerShouldRestartUnexpectedProviderExit(status, {
931
+ exitCommandInProgress: this.exitCommandInProgress,
932
+ stopped: this.stopped,
933
+ restartInProgress: this.restartInProgress,
934
+ provider: this.options.provider,
935
+ headless: this.options.headless,
936
+ hasTerminalSession: typeof this.process?.meta?.tmuxSession === "string"
937
+ || (this.options.adapter.supportsUnexpectedExitRestart?.() ?? false),
938
+ });
939
+ }
940
+
941
+ private async restartUnexpectedProviderExit(status: SemanticStatus): Promise<void> {
942
+ if (this.restartPending) return;
943
+ this.restartPending = true;
944
+ try {
945
+ // Best-effort Insights capture for the segment that just ended in a crash (#183). This
946
+ // path has no controlled teardown, so without it crashed sessions silently drop their
947
+ // context-ratio datapoint. The process handle is still set (cleared later), so the
948
+ // Claude transcript is readable; the runner stays alive here (restart or offline), so the
949
+ // durable outbox drains normally — no flush needed.
950
+ await Promise.race([
951
+ this.captureContextRatio("crash"),
952
+ new Promise<void>((resolve) => setTimeout(resolve, PRE_DESTROY_TIMEOUT_MS)),
953
+ ]).catch((error) => this.sessionLog(`insights: crash capture failed: ${errMessage(error)}`));
954
+
955
+ const now = Date.now();
956
+ const runtimeMs = this.processStartedAt ? now - this.processStartedAt : Number.POSITIVE_INFINITY;
957
+ const recent = this.unexpectedExitTimes.filter((time) => now - time <= UNEXPECTED_EXIT_WINDOW_MS);
958
+ recent.push(now);
959
+ this.unexpectedExitTimes.splice(0, this.unexpectedExitTimes.length, ...recent);
960
+ const diagnostics = this.providerExitDiagnostics(status, runtimeMs);
961
+
962
+ this.publishRunnerTimelineEvent({
963
+ status: "provider.exit_detected",
964
+ id: `provider-exit-${this.providerSessionId}-${now}`,
965
+ timestamp: now,
966
+ title: "Provider exited",
967
+ body: `${this.options.provider} reported ${status} after ${Math.round(runtimeMs / 1000)}s`,
968
+ icon: "ti-plug-off",
969
+ metadata: {
970
+ eventType: "provider.exit_detected",
971
+ ...diagnostics,
972
+ },
973
+ });
974
+
975
+ if (this.shouldStopUnexpectedProviderExit(diagnostics)) {
976
+ const hasResumeId = typeof diagnostics.claudeResumeId === "string" && diagnostics.claudeResumeId.length > 0;
977
+ logger.warn("lifecycle", `${this.options.provider} exited; leaving agent offline for manual recovery`);
978
+ this.publishRunnerTimelineEvent({
979
+ status: "provider.restart_decision",
980
+ id: `provider-restart-decision-${this.providerSessionId}-${now}`,
981
+ timestamp: Date.now(),
982
+ title: "Provider restart skipped",
983
+ body: hasResumeId
984
+ ? "Claude exited; runner will not auto-resume. Resume id captured for manual recovery."
985
+ : "Claude exited; runner will not restart automatically.",
986
+ icon: "ti-player-stop",
987
+ metadata: {
988
+ eventType: "provider.restart_decision",
989
+ decision: "stop-surface",
990
+ reason: hasResumeId ? "claude-exit-manual-resume-available" : "claude-exit-manual-intervention-required",
991
+ ...diagnostics,
992
+ },
993
+ });
994
+ this.process = undefined;
995
+ this.setProviderStatus({
996
+ status,
997
+ reason: "provider-turn",
998
+ id: `provider-exit-${this.providerSessionId}`,
999
+ clear: ["provider-turn", "subagent"],
1000
+ });
1001
+ return;
1002
+ }
1003
+
1004
+ if (runtimeMs < RAPID_EXIT_MS && recent.length > MAX_RAPID_UNEXPECTED_EXITS) {
1005
+ logger.error("lifecycle", `provider session exited ${recent.length} times within ${Math.round(UNEXPECTED_EXIT_WINDOW_MS / 1000)}s; giving up`);
1006
+ this.publishRunnerTimelineEvent({
1007
+ status: "provider.restart_decision",
1008
+ id: `provider-restart-decision-${this.providerSessionId}-${now}`,
1009
+ timestamp: Date.now(),
1010
+ title: "Provider restart skipped",
1011
+ body: `rapid unexpected exits exceeded ${MAX_RAPID_UNEXPECTED_EXITS}`,
1012
+ icon: "ti-alert-triangle",
1013
+ metadata: {
1014
+ eventType: "provider.restart_decision",
1015
+ decision: "give-up",
1016
+ reason: "rapid-unexpected-provider-exits",
1017
+ rapidExitCount: recent.length,
1018
+ rapidExitWindowMs: UNEXPECTED_EXIT_WINDOW_MS,
1019
+ maxRapidUnexpectedExits: MAX_RAPID_UNEXPECTED_EXITS,
1020
+ ...diagnostics,
1021
+ },
1022
+ });
1023
+ this.setProviderStatus(status);
1024
+ this.options.onProviderExit?.(0);
1025
+ return;
1026
+ }
1027
+
1028
+ const delayMs = Math.min(10_000, Math.max(500, 500 * recent.length));
1029
+ logger.warn("lifecycle", `provider session exited unexpectedly after ${Math.round(runtimeMs / 1000)}s; restarting in ${delayMs}ms`);
1030
+ this.publishRunnerTimelineEvent({
1031
+ status: "provider.restart_decision",
1032
+ id: `provider-restart-decision-${this.providerSessionId}-${now}`,
1033
+ timestamp: Date.now(),
1034
+ title: "Provider restart scheduled",
1035
+ body: `runner will start a fresh ${this.options.provider} provider in ${delayMs}ms`,
1036
+ icon: "ti-refresh",
1037
+ metadata: {
1038
+ eventType: "provider.restart_decision",
1039
+ decision: "restart-fresh",
1040
+ reason: "unexpected-headless-terminal-exit",
1041
+ delayMs,
1042
+ rapidExitCount: recent.length,
1043
+ rapidExitWindowMs: UNEXPECTED_EXIT_WINDOW_MS,
1044
+ ...diagnostics,
1045
+ },
1046
+ });
1047
+ await Bun.sleep(delayMs);
1048
+ if (this.stopped || this.exitCommandInProgress) return;
1049
+ try {
1050
+ await this.restartProvider();
1051
+ this.publishStatus();
1052
+ this.scheduleDrain();
1053
+ } catch (error) {
1054
+ logger.error("lifecycle", `provider restart after unexpected exit failed: ${error}`);
1055
+ this.setProviderStatus("error");
1056
+ this.options.onProviderExit?.(1);
1057
+ }
1058
+ } finally {
1059
+ this.restartPending = false;
1060
+ }
1061
+ }
1062
+
1063
+ private shouldStopUnexpectedProviderExit(diagnostics: Record<string, unknown>): boolean {
1064
+ return this.options.provider === "claude" && diagnostics.exitCommandInProgress !== true;
1065
+ }
1066
+
1067
+ private async shutdownProvider(hard: boolean, timeoutMs = this.options.providerConfig.headless.shutdownTimeoutMs): Promise<void> {
1068
+ this.lifecycleAction = hard ? "killing" : "shutting-down";
1069
+ this.publishStatus();
1070
+ await this.bus.statusAsync({ agentStatus: "idle", ready: false, meta: { lifecycleAction: this.lifecycleAction, lifecycleActionAt: Date.now() } });
1071
+ if (this.process) {
1072
+ await this.options.adapter.shutdown(this.process, {
1073
+ graceful: !hard,
1074
+ timeoutMs,
1075
+ });
1076
+ }
1077
+ this.claims.setTerminalStatus("offline");
1078
+ this.publishStatus();
1079
+ this.stopped = true;
1080
+ }
1081
+
1082
+ private publishRunnerTimelineEvent(event: RunnerTimelineEvent): void {
1083
+ this.pendingTimelineEvent = {
1084
+ ...event,
1085
+ metadata: {
1086
+ source: "runner",
1087
+ provider: this.options.provider,
1088
+ runnerId: this.options.runnerId,
1089
+ agentId: this.agentId,
1090
+ policyName: this.options.policyName ?? null,
1091
+ spawnRequestId: this.options.spawnRequestId ?? null,
1092
+ label: this.options.label ?? null,
1093
+ providerSessionId: this.providerSessionId,
1094
+ ...(event.metadata ?? {}),
1095
+ },
1096
+ };
1097
+ this.publishStatus();
1098
+ }
1099
+
1100
+ private providerExitDiagnostics(status: SemanticStatus, runtimeMs: number): Record<string, unknown> {
1101
+ const tmuxSession = typeof this.process?.meta?.tmuxSession === "string" ? this.process.meta.tmuxSession : undefined;
1102
+ const tmuxSocket = typeof this.process?.meta?.tmuxSocket === "string" ? this.process.meta.tmuxSocket : undefined;
1103
+ const exitSource = tmuxSession ? "tmux-session-ended" : this.process?.process ? "process-exit" : "provider-status";
1104
+ const logFile = typeof process.env.AGENT_RELAY_LOG_FILE === "string" ? process.env.AGENT_RELAY_LOG_FILE : undefined;
1105
+ const claudeResumeId = this.options.provider === "claude" && logFile ? latestClaudeResumeIdFromLogFile(logFile) : undefined;
1106
+ return {
1107
+ status,
1108
+ runtimeMs: Number.isFinite(runtimeMs) ? runtimeMs : null,
1109
+ exitSource,
1110
+ exitCommandInProgress: this.exitCommandInProgress,
1111
+ stopped: this.stopped,
1112
+ restartInProgress: this.restartInProgress,
1113
+ restartPending: this.restartPending,
1114
+ headless: this.options.headless,
1115
+ hasTerminalSession: Boolean(tmuxSession),
1116
+ tmuxSession: tmuxSession ?? null,
1117
+ tmuxSocket: tmuxSocket ?? null,
1118
+ claudeResumeId: claudeResumeId ?? null,
1119
+ };
1120
+ }
1121
+
1122
+ private async updateCommand(commandId: string, status: string, result?: Record<string, unknown>, error?: string): Promise<void> {
1123
+ await this.bus.updateCommand(commandId, { status, ...(result ? { result } : {}), ...(error ? { error } : {}) });
1124
+ }
1125
+
1126
+ private handleBusError(code: string, message: string): void {
1127
+ const action = runnerBusErrorAction(code, this.stopped);
1128
+ if (action === "ignore") return;
1129
+ logger.error("bus", `bus error ${code}: ${message}`);
1130
+ if (action === "stop") {
1131
+ void this.stop().catch((error) => {
1132
+ logger.error("bus", `stop after bus error failed: ${error}`);
1133
+ }).finally(() => process.exit(0));
1134
+ }
1135
+ }
1136
+
1137
+ private setProviderStatus(update: ProviderStatusUpdate): void {
1138
+ const status = typeof update === "string" ? update : update.status;
1139
+ const providerSessionId = typeof update === "string" ? undefined : update.providerSessionId;
1140
+ if (providerSessionId && providerSessionId !== this.providerSessionId) return;
1141
+ const reason = typeof update === "string" ? "provider-turn" : update.reason ?? "provider-turn";
1142
+ const id = typeof update === "string" ? reason : update.id ?? reason;
1143
+ if (typeof update !== "string" && update.timeline) {
1144
+ this.pendingTimelineEvent = {
1145
+ status: update.timeline.status,
1146
+ ...(update.timeline.id ? { id: update.timeline.id } : {}),
1147
+ timestamp: update.timeline.timestamp ?? Date.now(),
1148
+ ...(update.timeline.title ? { title: update.timeline.title } : {}),
1149
+ ...(update.timeline.body ? { body: update.timeline.body } : {}),
1150
+ ...(update.timeline.icon ? { icon: update.timeline.icon } : {}),
1151
+ ...(update.timeline.metadata ? { metadata: update.timeline.metadata } : {}),
1152
+ };
1153
+ }
1154
+ if (typeof update !== "string" && update.providerState) {
1155
+ const ps = update.providerState as { state?: unknown; reason?: unknown };
1156
+ this.providerBlocked = ps.state === "blocked";
1157
+ // A rate-limit hold persists across the idle the turn ends on; any other
1158
+ // providerState supersedes it (clears a stale hold).
1159
+ if (ps.state === "blocked" && ps.reason === "rate_limit") {
1160
+ const fresh = !this.rateLimitHold;
1161
+ this.rateLimitHold = update.providerState;
1162
+ if (fresh) this.publishRateLimitNotice(update.providerState);
1163
+ } else {
1164
+ this.rateLimitHold = undefined;
1165
+ }
1166
+ } else if (status === "idle") {
1167
+ this.providerBlocked = false;
1168
+ }
1169
+ // Forward progress (a real turn) lifts the hold so the blocked badge clears.
1170
+ if (status === "busy") this.rateLimitHold = undefined;
1171
+ if (typeof update !== "string" && status === "error") {
1172
+ const terminalReason = typeof update.metadata?.terminalFailureReason === "string"
1173
+ ? update.metadata.terminalFailureReason
1174
+ : typeof update.providerState?.reason === "string"
1175
+ ? update.providerState.reason
1176
+ : "provider-error";
1177
+ const terminalMessage = typeof update.metadata?.terminalFailureMessage === "string"
1178
+ ? update.metadata.terminalFailureMessage
1179
+ : typeof update.providerState?.message === "string"
1180
+ ? update.providerState.message
1181
+ : "Provider reported an unrecoverable error.";
1182
+ this.terminalFailure = {
1183
+ reason: terminalReason,
1184
+ message: terminalMessage,
1185
+ ...(update.providerState ? { providerState: update.providerState } : {}),
1186
+ };
1187
+ } else if (status === "idle" || status === "busy") {
1188
+ this.terminalFailure = undefined;
1189
+ }
1190
+ // Compaction lifecycle (Claude PreCompact→`compacting` busy / PostCompact→`compacted`
1191
+ // idle). A `compacted` idle for a turn that predated compaction is a hook artifact — the
1192
+ // turn resumes — so it must NOT end the turn. Discriminate on whether a turn was running,
1193
+ // captured BEFORE the busy logic below mints a currentTurnId (it does for /compact at idle).
1194
+ const timelineStatus = typeof update !== "string" ? update.timeline?.status : undefined;
1195
+ if (timelineStatus === "compacting") {
1196
+ this.compactionMidTurn = this.currentTurnId !== undefined;
1197
+ } else if (timelineStatus === "compacted") {
1198
+ this.publishCompactionNotice();
1199
+ if (this.compactionMidTurn) {
1200
+ // Keep the turn (and its live reasoning tail) alive; the genuine Stop hook ends it.
1201
+ // pendingTimelineEvent (the marker) was set above and is consumed by publishStatus.
1202
+ this.sessionLog(`compaction completed mid-turn (turn ${this.currentTurnId ?? "?"}) — staying busy`);
1203
+ this.publishStatus();
1204
+ return;
1205
+ }
1206
+ }
1207
+ if (status === "busy" && reason === "provider-turn") {
1208
+ if (!this.currentTurnId) {
1209
+ this.currentTurnId = typeof update !== "string" && update.id ? update.id : crypto.randomUUID();
1210
+ this.currentTurnStartedAt = Date.now();
1211
+ this.compactionMidTurn = false;
1212
+ this.sessionLog(`turn started (turn ${this.currentTurnId})`);
1213
+ }
1214
+ this.busyReconciler.arm();
1215
+ } else if (status === "idle" && reason === "provider-turn") {
1216
+ if (this.currentTurnId) this.sessionLog(`turn ended via provider idle (turn ${this.currentTurnId})`);
1217
+ this.currentTurnId = undefined;
1218
+ this.currentTurnStartedAt = undefined;
1219
+ this.compactionMidTurn = false;
1220
+ this.busyReconciler.disarm();
1221
+ this.stopReasoningTail();
1222
+ // #417: claude enforces obligations via the stop.sh hook; for every other provider
1223
+ // re-inject any pending spawner obligation so it surfaces the /reply command.
1224
+ if (this.options.provider !== "claude") void this.reInjectPendingObligation();
1225
+ }
1226
+ if (status === "busy") {
1227
+ this.claims.clearTerminalStatus();
1228
+ this.claims.startWork(reason, id, typeof update === "string" ? {} : {
1229
+ label: update.label,
1230
+ role: update.role,
1231
+ parentId: update.parentId,
1232
+ metadata: {
1233
+ ...(update.metadata ?? {}),
1234
+ ...(update.providerState ? { providerState: update.providerState } : {}),
1235
+ },
1236
+ });
1237
+ if (reason === "provider-turn") {
1238
+ for (const claim of this.activeTaskClaims.values()) claim.observedProviderBusy = true;
1239
+ }
1240
+ } else if (status === "idle") {
1241
+ this.claims.clearTerminalStatus();
1242
+ this.claims.finishWork(reason, id);
1243
+ if (reason === "provider-turn") void this.completeObservedTaskClaims();
1244
+ }
1245
+ else if (status === "offline" || status === "error") this.claims.setTerminalStatus(status);
1246
+ if (typeof update !== "string") {
1247
+ for (const kind of update.clear ?? []) this.claims.clearWorkKind(kind);
1248
+ }
1249
+ // #329: the provider just went idle (ready). If a first initial-prompt delivery timed out on
1250
+ // a cold-start TUI, re-attempt now — the ready wait should resolve almost immediately.
1251
+ if (status === "idle" && this.pendingInitialPrompt && !this.deliveringInitialPrompt) {
1252
+ void this.attemptInitialPromptDelivery(this.pendingInitialPrompt, INITIAL_PROMPT_RETRY_READY_TIMEOUT_MS);
1253
+ }
1254
+ this.publishStatus();
1255
+ }
1256
+
1257
+ // #417: when a non-claude provider finishes a turn with a pending spawner obligation
1258
+ // still unresolved, re-inject the obligation as a synthetic message so providerMessageText
1259
+ // surfaces the specific `agent-relay /reply <id>` command. Claude handles this via the
1260
+ // stop.sh hook; every other provider needs this in-process nudge.
1261
+ private async reInjectPendingObligation(): Promise<void> {
1262
+ if (!this.process) return;
1263
+ // Filter to spawner obligations only — user obligations go through the existing codex
1264
+ // prompt-capture path (publishProviderSessionEvent) and the session mirror (#418), never
1265
+ // an explicit /reply. Shared predicate so this rule stays in lockstep with the Claude
1266
+ // Stop-hook nag exemption (control-server.replyObligationStopDecision).
1267
+ const obligations = this.obligationCache.get().filter(obligationRequiresExplicitReply);
1268
+ if (!obligations.length) return;
1269
+ const MAX_REINJECTIONS = 3;
1270
+ for (const obligation of obligations) {
1271
+ const count = this.pendingObligationInjections.get(obligation.messageId) ?? 0;
1272
+ if (count >= MAX_REINJECTIONS) continue;
1273
+ this.pendingObligationInjections.set(obligation.messageId, count + 1);
1274
+ this.sessionLog(`re-injecting pending reply obligation #${obligation.messageId} from ${obligation.from} (attempt ${count + 1}/${MAX_REINJECTIONS})`);
1275
+ const replayMessage = {
1276
+ id: obligation.messageId,
1277
+ from: obligation.from,
1278
+ to: this.agentId,
1279
+ kind: obligation.kind,
1280
+ ...(obligation.subject ? { subject: obligation.subject } : {}),
1281
+ body: obligation.bodyPreview,
1282
+ payload: {},
1283
+ readBy: [] as string[],
1284
+ createdAt: obligation.createdAt,
1285
+ };
1286
+ // Suppress the userMessage echo that providers (e.g. Codex) reflect back when
1287
+ // a turn is started with relay-formatted content — the echo body is exactly what
1288
+ // providerMessageText produces, which starts with "[relay message #" and is caught
1289
+ // by the RELAY_INJECTION_MARKERS prefix check, but record it explicitly as
1290
+ // defense-in-depth for adapters that might echo only the unformatted body text.
1291
+ this.recordInjectedPrompt(providerMessageText([replayMessage]));
1292
+ await this.options.adapter.deliver(this.process, [replayMessage]);
1293
+ // Kick off a cache refresh so a cleared obligation (agent replied) is detected
1294
+ // before the next idle fires — without this, the stale snapshot re-injects up to
1295
+ // MAX_REINJECTIONS times even after the agent has already /reply-ed.
1296
+ this.obligationCache.markDirty();
1297
+ // One obligation at a time — let the provider reply before nudging again.
1298
+ break;
1299
+ }
1300
+ }
1301
+
1302
+ // Session-mirror lane: capture the assistant turn from the Claude transcript and
1303
+ // post it as a "session" message so it shows in the dashboard chat with zero
1304
+ // agent tokens. Capture is UNCONDITIONAL — it no longer depends on a triggering
1305
+ // relay message existing, so turns started from the web terminal (which create
1306
+ // no relay message) are mirrored too. A reply obligation, when present, is still
1307
+ // used as replyTo so the Stop hook stops nagging the agent to /reply.
1308
+ //
1309
+ // isPreFlush: true (#435) — mid-turn call from a PreToolUse hook (AskUserQuestion /
1310
+ // ExitPlanMode / permission). Emits any un-mirrored narrative from the transcript
1311
+ // tail immediately, before the interactive form appears in chat, then updates the
1312
+ // entry-count cursor so the eventual Stop-hook call (full mode) skips it.
1313
+ private async publishSessionTurn(input: { transcriptPath: string; lastAssistantMessage?: unknown; isPreFlush?: boolean }): Promise<void> {
1314
+ if (input.transcriptPath) this.lastTranscriptPath = input.transcriptPath;
1315
+
1316
+ if (input.isPreFlush) {
1317
+ if (!input.transcriptPath) return;
1318
+ let jsonl: string | undefined;
1319
+ try { jsonl = await readFile(input.transcriptPath, "utf8"); } catch { return; }
1320
+ if (!jsonl) return;
1321
+ const body = extractLastAssistantTurnAfterEntry(jsonl, this.narrativeFlushEntryCount);
1322
+ await this.drainReasoningTail();
1323
+ this.narrativeFlushEntryCount = countTranscriptEntries(jsonl);
1324
+ if (!body || this.preFlushBodyAlreadyMirroredByReasoningTail(jsonl, body)) return;
1325
+ const turnId = this.currentTurnId;
1326
+ this.sessionLog(`pre-flush narrative for turn ${turnId ?? "?"} (${body.length} chars)`);
1327
+ await this.publishSessionEvent({
1328
+ from: this.agentId,
1329
+ to: "user",
1330
+ body,
1331
+ session: { type: "response", origin: "provider", ...(turnId ? { turnId } : {}) },
1332
+ });
1333
+ return;
1334
+ }
1335
+
1336
+ const turnId = this.currentTurnId;
1337
+ this.stopReasoningTail();
1338
+ // Optional correlation for threading + obligation clearing — never a capture gate.
1339
+ let replyToMessageId: number | undefined;
1340
+ const pendingPrompt = this.pendingPromptMessageId;
1341
+ if (pendingPrompt) {
1342
+ replyToMessageId = pendingPrompt;
1343
+ this.pendingPromptMessageId = undefined;
1344
+ } else {
1345
+ // Correlation-only (threading + obligation clearing) — the local snapshot is fresh
1346
+ // enough and never blocks the response-capture path (#196).
1347
+ const obligation = [...this.obligationCache.get()].reverse().find((o) => o.from === "user");
1348
+ replyToMessageId = obligation?.messageId;
1349
+ }
1350
+
1351
+ // Grab and reset the pre-flush cursor before extraction: the cursor may be
1352
+ // non-zero when a PreToolUse hook flushed narrative earlier this turn (#435).
1353
+ // In "full" mode, skip those already-emitted entries so they don't duplicate.
1354
+ const flushCount = this.narrativeFlushEntryCount;
1355
+ this.narrativeFlushEntryCount = 0;
1356
+
1357
+ // The Stop hook can fire before the final assistant entry is flushed to disk.
1358
+ // Claude writes thinking and text as separate entries (both with end_turn), so
1359
+ // the transcript can "look complete" while the text entry is still pending.
1360
+ // Retry until both the transcript has an end_turn AND extraction yields text.
1361
+ let body = "";
1362
+ let jsonl: string | undefined;
1363
+ try { jsonl = await readFile(input.transcriptPath, "utf8"); } catch { jsonl = undefined; }
1364
+ if (jsonl !== undefined) {
1365
+ for (let attempt = 0; attempt < 5; attempt++) {
1366
+ if (attempt > 0) {
1367
+ await new Promise((r) => setTimeout(r, 100));
1368
+ try { jsonl = await readFile(input.transcriptPath, "utf8"); } catch { break; }
1369
+ }
1370
+ if (!transcriptLooksComplete(jsonl)) continue;
1371
+ // Full mode: if a pre-flush already emitted entries 1..flushCount, only
1372
+ // collect text from entries after that mark to avoid double-emit (#435).
1373
+ const extract = this.options.providerConfig.chatCaptureMode === "full"
1374
+ ? (j: string) => (flushCount > 0 ? extractLastAssistantTurnAfterEntry(j, flushCount) : extractLastAssistantTurn(j))
1375
+ : extractFinalAssistantMessage;
1376
+ body = extract(jsonl);
1377
+ if (body) break;
1378
+ }
1379
+ }
1380
+ // Fallback: last_assistant_message from the Stop hook payload, which bypasses
1381
+ // the transcript file race entirely.
1382
+ if (!body && input.lastAssistantMessage) {
1383
+ body = extractHookAssistantMessage(input.lastAssistantMessage);
1384
+ }
1385
+ // A pure tool-use turn with no closing text is fine to skip — its reasoning and
1386
+ // tool steps already carried the visibility into chat.
1387
+ if (!body) {
1388
+ this.sessionLog(`response capture: no closing text for turn ${turnId ?? "?"} (skipped)`);
1389
+ return;
1390
+ }
1391
+
1392
+ this.sessionLog(`response captured for turn ${turnId ?? "?"} (${body.length} chars${replyToMessageId ? `, replyTo #${replyToMessageId}` : ", no replyTo"})`);
1393
+ await this.publishSessionEvent({
1394
+ from: this.agentId,
1395
+ to: "user",
1396
+ body,
1397
+ ...(replyToMessageId ? { replyTo: replyToMessageId } : {}),
1398
+ session: { type: "response", origin: "provider", ...(turnId ? { turnId } : {}) },
1399
+ });
1400
+ // The agent's reply may have cleared an obligation — refresh the snapshot so the next
1401
+ // turn-end doesn't re-prompt for a message already answered (#196).
1402
+ if (replyToMessageId) this.obligationCache.markDirty();
1403
+ }
1404
+
1405
+ // Post one session-mirror event (prompt echo, assistant response, reasoning or
1406
+ // tool step) as a `kind: "session"` relay message tagged with payload.session so
1407
+ // the dashboard can render the live provider session faithfully. Display-only:
1408
+ // session messages are never delivered back into a provider.
1409
+ private publishSessionEvent(input: {
1410
+ from: string;
1411
+ to: string;
1412
+ body: string;
1413
+ session: MessageSessionMeta;
1414
+ replyTo?: number;
1415
+ }): void {
1416
+ // Durable, ordered, timestamped (#196): the actual POST happens in deliverOutboxEvent,
1417
+ // retried until it lands. occurredAt is stamped now so a queued event reports when it
1418
+ // truly happened, not when the server finally accepted it. Routed through the fast-lane
1419
+ // sessionOutbox (#332) so a transient trace failure can't head-of-line block real messages.
1420
+ // A stepId-bearing step (Codex tool running→completed, streamed reasoning/response) uses a
1421
+ // STABLE idempotency key so the server upserts the row in place instead of appending a dup.
1422
+ const stepId = input.session.stepId;
1423
+ this.sessionOutbox.enqueue({
1424
+ kind: "session-message",
1425
+ ...(stepId ? { idempotencyKey: `session-step:${input.from}:${input.session.turnId ?? ""}:${stepId}` } : {}),
1426
+ payload: {
1427
+ from: input.from,
1428
+ to: input.to,
1429
+ ...(input.replyTo ? { replyTo: input.replyTo } : {}),
1430
+ kind: "session",
1431
+ body: input.body,
1432
+ payload: { session: { provider: this.options.provider, ...input.session } },
1433
+ } satisfies SendMessageInput,
1434
+ });
1435
+ }
1436
+
1437
+ // Map queued records to HTTP calls. Throw to retry, return to ack/delete.
1438
+ private async deliverOutboxEvent(record: OutboxRecord): Promise<void> {
1439
+ await deliverRunnerOutboxEvent({
1440
+ record,
1441
+ http: this.http,
1442
+ relayUrl: this.options.relayUrl,
1443
+ token: this.currentToken,
1444
+ updatePayload: (seq, payload) => this.outbox.updatePayload(seq, payload),
1445
+ sessionLog: (message) => this.sessionLog(message),
1446
+ recoverRuntimeTokenAfterAuthFailure: (source) => this.recoverRuntimeTokenAfterAuthFailure(source),
1447
+ });
1448
+ }
1449
+
1450
+ // Replay a buffered MCP tool call (Stage 2, #215) that the proxy queued while the relay was
1451
+ // unreachable. Kept as a method wrapper for tests; the concern lives in mcp-outbox.ts.
1452
+ private async deliverBufferedMcpCall(record: OutboxRecord): Promise<void> {
1453
+ await deliverBufferedMcpOutboxCall({
1454
+ record,
1455
+ relayUrl: this.options.relayUrl,
1456
+ token: this.currentToken,
1457
+ recoverRuntimeTokenAfterAuthFailure: (source) => this.recoverRuntimeTokenAfterAuthFailure(source),
1458
+ });
1459
+ }
1460
+
1461
+ // A hook reported an unhandled failure (#198 seam). Already logged FATAL by the control
1462
+ // server; here we additionally surface it durably to the server as a generic insight so
1463
+ // it shows up in observability rather than only in the per-agent log (#196).
1464
+ private reportHookFatal(report: { hook: string; error: string }): void {
1465
+ try {
1466
+ this.insights.queueHookFatal(report);
1467
+ } catch (error) {
1468
+ logger.error("outbox", `failed to queue hook-fatal report: ${errMessage(error)}`);
1469
+ }
1470
+ }
1471
+
1472
+ // A human typed a prompt directly into the provider (web terminal / TUI). Mirror
1473
+ // it into the dashboard chat so both surfaces stay in sync, and kick off reasoning
1474
+ // tailing for the turn. Skips prompts the runner itself injected (chat box, relay
1475
+ // deliveries) so those aren't double-posted.
1476
+ private async handleUserPrompt(input: { prompt: string; transcriptPath?: string }): Promise<void> {
1477
+ if (input.transcriptPath) this.lastTranscriptPath = input.transcriptPath;
1478
+ if (!this.currentTurnId) {
1479
+ this.currentTurnId = crypto.randomUUID();
1480
+ this.currentTurnStartedAt = Date.now();
1481
+ }
1482
+ const text = input.prompt.trim();
1483
+ if (text && !this.isRunnerInjectedPrompt(text)) {
1484
+ this.sessionLog(`prompt echoed from terminal (${text.length} chars)`);
1485
+ await this.publishSessionEvent({
1486
+ from: "user",
1487
+ to: this.agentId,
1488
+ body: text,
1489
+ session: { type: "prompt", origin: "terminal", turnId: this.currentTurnId },
1490
+ });
1491
+ } else if (text) {
1492
+ this.sessionDebug("user-prompt hook: skipped echo (runner-injected)");
1493
+ }
1494
+ if (input.transcriptPath) this.startReasoningTail(input.transcriptPath);
1495
+ }
1496
+
1497
+ // A provider lifecycle hook reported a session boundary (Claude PreCompact / SessionEnd
1498
+ // → control server). Normalize the raw provider reason to a SessionDestroyReason and run
1499
+ // the same pre-destroy seam the bus commands use. `clear`/`compact` continue the session;
1500
+ // anything else (logout, prompt_input_exit, other) is a real termination.
1501
+ private async handleSessionBoundary(input: { reason?: string; transcriptPath?: string }): Promise<void> {
1502
+ // Reason mapping is fail-safe-toward-termination: only the two known session-
1503
+ // CONTINUING reasons are special-cased; everything else (logout, prompt_input_exit,
1504
+ // other, AND any future reason) maps to "shutdown" → full pre-destroy capture.
1505
+ // ⚠ If Claude Code adds a new BENIGN/continuing boundary reason, add it here — until
1506
+ // then it will trigger a (harmless but wasteful) full context capture on a session
1507
+ // that isn't actually ending.
1508
+ const reason = input.reason === "compact" ? "compact"
1509
+ : input.reason === "clear" ? "clear"
1510
+ : "shutdown";
1511
+ await this.runPreSessionDestroy(reason, { transcriptPath: input.transcriptPath });
1512
+ // clear/compact CONTINUE the session — the finalizing window is transient. The bus-command
1513
+ // path (handleCommand) restores the addressable status in its finally; the hook path has no
1514
+ // such teardown, so without this the dashboard stays stuck on "wrapping up — messaging
1515
+ // paused" with the composer disabled forever. Only restore if we're still in the exact
1516
+ // finalizing state we published — a concurrent bus command (kill/restart/shutdown) may have
1517
+ // transitioned lifecycleAction since, and that must win. "shutdown" reasons end the session,
1518
+ // so their finalizing state is superseded by going offline; leave it be.
1519
+ if ((reason === "clear" || reason === "compact") && this.lifecycleAction === `finalizing-${reason}` && !this.stopped) {
1520
+ this.lifecycleAction = undefined;
1521
+ this.publishStatus();
1522
+ }
1523
+ }
1524
+
1525
+ // The pre-session-destroy seam (#183): the single place end-of-session work runs before
1526
+ // an invasive transition (compact/clear/restart/shutdown/kill). Best-effort and
1527
+ // time-boxed so it never hangs teardown; concurrent calls for the same teardown coalesce
1528
+ // (a shutdown bus command and the SessionEnd hook can both fire). During the window the
1529
+ // agent is published non-addressable so the operator sees "wrapping up", not "busy".
1530
+ private runPreSessionDestroy(reason: SessionDestroyReason, opts?: { transcriptPath?: string }): Promise<void> {
1531
+ if (this.preDestroyPromise) return this.preDestroyPromise;
1532
+ const run = (async () => {
1533
+ this.publishFinalizing(reason);
1534
+ try {
1535
+ await Promise.race([
1536
+ Promise.all([
1537
+ this.captureContextRatio(reason, opts),
1538
+ this.captureContinuationArchive(reason, opts),
1539
+ ]).then(() => undefined),
1540
+ new Promise<void>((resolve) => setTimeout(resolve, PRE_DESTROY_TIMEOUT_MS)),
1541
+ ]);
1542
+ } catch (error) {
1543
+ this.sessionLog(`pre-destroy capture failed: ${errMessage(error)}`);
1544
+ }
1545
+ // For exit-bound transitions the runner won't be alive afterward to drain the durable
1546
+ // outbox, so block (bounded) on delivering what capture just enqueued. This runs before
1547
+ // handleCommand's finally deletes the agent, so the runtime token is still valid here.
1548
+ if (reasonExitsRunner(reason)) {
1549
+ try {
1550
+ const delivered = await this.outbox.flush(OUTBOX_FLUSH_TIMEOUT_MS);
1551
+ if (!delivered) this.sessionLog(`insights: outbox flush incomplete before ${reason} (${this.outbox.pendingCount()} pending)`);
1552
+ } catch (error) {
1553
+ this.sessionLog(`insights: outbox flush failed: ${errMessage(error)}`);
1554
+ }
1555
+ }
1556
+ })();
1557
+ this.preDestroyPromise = run;
1558
+ void run.finally(() => { this.preDestroyPromise = undefined; });
1559
+ return run;
1560
+ }
1561
+
1562
+ // Publish the transient pre-destroy state: a non-offline status with ready:false (so the
1563
+ // agent drops out of isAgentOnline fan-out targeting without going "offline") plus a
1564
+ // finalizing-<reason> lifecycleAction the dashboard renders as "wrapping up" with the
1565
+ // composer disabled.
1566
+ private publishFinalizing(reason: SessionDestroyReason): void {
1567
+ this.lifecycleAction = `finalizing-${reason}`;
1568
+ void this.bus.statusAsync({ agentStatus: "busy", ready: false, meta: { lifecycleAction: this.lifecycleAction, lifecycleActionAt: Date.now() } });
1569
+ }
1570
+
1571
+ // Repo-name project id for insight observations, resolved once by the insights collaborator.
1572
+ private insightProject(): string {
1573
+ return this.insights.project();
1574
+ }
1575
+
1576
+ private async captureContinuationArchive(reason: SessionDestroyReason, opts?: { transcriptPath?: string }): Promise<void> {
1577
+ await this.insights.captureContinuationArchive(reason, opts);
1578
+ }
1579
+
1580
+ private async captureContextRatio(reason: SessionDestroyReason, opts?: { transcriptPath?: string }): Promise<void> {
1581
+ await this.insights.captureContextRatio(reason, opts);
1582
+ }
1583
+
1584
+ // Route a provider-emitted session event (Codex app-server) into the chat mirror.
1585
+ // Mirrors the same semantics as the Claude lane: prompts are echoed with dedup,
1586
+ // and a response is only auto-captured when the agent won't separately reply to a
1587
+ // relay obligation (so relay-triggered turns aren't double-posted).
1588
+ private async publishProviderSessionEvent(event: ProviderSessionEvent): Promise<void> {
1589
+ const body = event.body.trim();
1590
+ if (!body) return;
1591
+ const turnId = event.turnId ?? this.currentTurnId;
1592
+ if (event.type === "prompt") {
1593
+ if (this.isRunnerInjectedPrompt(body)) return;
1594
+ await this.publishSessionEvent({
1595
+ from: "user",
1596
+ to: this.agentId,
1597
+ body,
1598
+ session: { type: "prompt", origin: event.origin ?? "terminal", ...(turnId ? { turnId } : {}) },
1599
+ });
1600
+ return;
1601
+ }
1602
+ if (event.type === "response") {
1603
+ // Dashboard prompt injection is already answered by this captured App Server
1604
+ // response. Other Relay inbox obligations still belong to the agent's explicit
1605
+ // reply path, so those keep suppressing auto-capture to avoid duplicates.
1606
+ let replyToMessageId: number | undefined;
1607
+ const pendingPrompt = this.pendingPromptMessageId;
1608
+ if (pendingPrompt) {
1609
+ replyToMessageId = pendingPrompt;
1610
+ this.pendingPromptMessageId = undefined;
1611
+ } else if (this.obligationCache.get().some((o) => o.from === "user" && this.obligationPredatesCurrentTurn(o))) {
1612
+ // The agent will answer the relay obligation itself — don't double-post (#196).
1613
+ return;
1614
+ }
1615
+ await this.publishSessionEvent({
1616
+ from: this.agentId,
1617
+ to: "user",
1618
+ body,
1619
+ ...(replyToMessageId ? { replyTo: replyToMessageId } : {}),
1620
+ session: { type: "response", origin: event.origin ?? "provider", ...(turnId ? { turnId } : {}) },
1621
+ });
1622
+ if (replyToMessageId) this.obligationCache.markDirty();
1623
+ return;
1624
+ }
1625
+ if (this.options.providerConfig.reasoningCapture === false) return;
1626
+ await this.publishSessionEvent({
1627
+ from: this.agentId,
1628
+ to: "user",
1629
+ body,
1630
+ session: {
1631
+ type: event.type,
1632
+ origin: event.origin ?? "provider",
1633
+ ...(turnId ? { turnId } : {}),
1634
+ ...(event.label ? { label: event.label } : {}),
1635
+ ...(event.status ? { status: event.status } : {}),
1636
+ ...(event.streaming !== undefined ? { streaming: event.streaming } : {}),
1637
+ ...(event.stepId ? { stepId: event.stepId } : {}),
1638
+ },
1639
+ });
1640
+ }
1641
+
1642
+ // Remember an injected prompt so its UserPromptSubmit echo can be suppressed. Prunes
1643
+ // expired entries first; a defensive length cap guards against echoes that never
1644
+ // arrive (e.g. the provider drops a queued prompt) so the queue can't grow unbounded.
1645
+ private recordInjectedPrompt(text: string): void {
1646
+ const now = Date.now();
1647
+ this.injectedPrompts = this.injectedPrompts.filter((p) => now - p.at < PROMPT_ECHO_DEDUP_MS);
1648
+ this.injectedPrompts.push({ text, at: now });
1649
+ if (this.injectedPrompts.length > 50) this.injectedPrompts.shift();
1650
+ }
1651
+
1652
+ private isRunnerInjectedPrompt(text: string): boolean {
1653
+ if (RELAY_INJECTION_MARKERS.some((marker) => text.startsWith(marker))) return true;
1654
+ const now = Date.now();
1655
+ this.injectedPrompts = this.injectedPrompts.filter((p) => now - p.at < PROMPT_ECHO_DEDUP_MS);
1656
+ const idx = this.injectedPrompts.findIndex((p) => p.text === text);
1657
+ if (idx !== -1) {
1658
+ this.injectedPrompts.splice(idx, 1); // consume one — identical repeats each match once
1659
+ return true;
1660
+ }
1661
+ return false;
1662
+ }
1663
+
1664
+ private obligationPredatesCurrentTurn(obligation: { createdAt: number }): boolean {
1665
+ return this.currentTurnStartedAt === undefined || obligation.createdAt <= this.currentTurnStartedAt;
1666
+ }
1667
+
1668
+ // Force-clear a stuck provider-turn claim directly. Unlike the idle status path
1669
+ // it does NOT depend on a matching claim id (the Stop hook keys busy as
1670
+ // provider-turn:provider-turn, but reconciliation has no specific id), and it
1671
+ // deliberately leaves the reasoning tail alone so a late clear can't truncate
1672
+ // a turn's activity stream.
1673
+ private forceClearProviderTurn(reason: string): void {
1674
+ if (!this.claims.activeWork().some((w) => w.kind === "provider-turn")) return;
1675
+ this.sessionLog(`force-clearing stuck provider-turn (${reason})`);
1676
+ this.claims.clearWorkKind("provider-turn");
1677
+ this.currentTurnId = undefined;
1678
+ this.compactionMidTurn = false;
1679
+ this.publishStatus();
1680
+ }
1681
+
1682
+ // --- Turn-step tailer (item 5) ------------------------------------------------------
1683
+ // Tail the in-flight turn's Claude transcript and surface new narration/reasoning/tool
1684
+ // steps as session events, in transcript order. `narration` (the agent's intermediate
1685
+ // text) is the primary visible content; reasoning visibility is a client-side toggle.
1686
+ // Coalesced and coarse; the final response still comes through publishSessionTurn.
1687
+ // `reasoningCapture: false` disables the whole live trace (server-side kill switch).
1688
+ private startReasoningTail(transcriptPath: string): void {
1689
+ if (this.options.providerConfig.reasoningCapture === false) return;
1690
+ this.stopReasoningTail();
1691
+ // Track emitted steps by content signature, not by index/count: the "latest
1692
+ // turn" window in the transcript can shrink/reset (a tool_result entry, a
1693
+ // mid-turn user line), and an index cursor would then either re-emit or stall
1694
+ // and drop the rest of the turn. A seen-set is idempotent under any reshuffle.
1695
+ // The signature is salted with each step's occurrence-within-window (stepDedupKeys)
1696
+ // so two identical steps in one turn — same tool, same input — both surface (#265).
1697
+ const seen = new Set<string>();
1698
+ const emittedNarrationKeys = new Set<string>();
1699
+ const turnIdAtStart = this.currentTurnId;
1700
+ // On the first poll the new prompt usually hasn't landed in the transcript yet,
1701
+ // so extractLatestTurnSteps still returns the PRIOR (completed) turn. Seed those
1702
+ // signatures as already-seen so we don't replay last turn's reasoning/tools as
1703
+ // this turn's activity. Once our prompt lands the window resets at the new user
1704
+ // boundary and genuinely-new steps emit normally. Only seed when the transcript
1705
+ // is complete (last entry is an end_turn assistant) — otherwise we're already
1706
+ // inside the new turn and those steps are legitimately ours.
1707
+ let seeded = false;
1708
+ let pendingPoll = Promise.resolve();
1709
+ const pollBody = async (): Promise<void> => {
1710
+ let jsonl: string;
1711
+ try { jsonl = await readFile(transcriptPath, "utf8"); } catch { return; }
1712
+ let steps: ReturnType<typeof extractLatestTurnSteps>;
1713
+ try { steps = extractLatestTurnSteps(jsonl); } catch { return; }
1714
+ const keyed = stepDedupKeys(steps).map((sig, i) => ({ sig, step: steps[i]! }));
1715
+ if (!seeded) {
1716
+ seeded = true;
1717
+ if (transcriptLooksComplete(jsonl)) {
1718
+ for (const { sig } of keyed) seen.add(sig);
1719
+ }
1720
+ }
1721
+ const turnId = this.currentTurnId ?? turnIdAtStart;
1722
+ let emitted = 0;
1723
+ for (const { sig, step } of keyed) {
1724
+ if (seen.has(sig)) continue;
1725
+ seen.add(sig);
1726
+ emitted += 1;
1727
+ this.publishSessionEvent({
1728
+ from: this.agentId,
1729
+ to: "user",
1730
+ body: step.text,
1731
+ session: { type: step.type, origin: "provider", ...(turnId ? { turnId } : {}), ...(step.label ? { label: step.label } : {}) },
1732
+ });
1733
+ if (step.type === "narration") emittedNarrationKeys.add(sig);
1734
+ }
1735
+ if (emitted) this.sessionDebug(`reasoning tail emitted ${emitted} step(s) (turn ${turnId ?? "?"}, ${seen.size} total)`);
1736
+ };
1737
+ const poll = (): Promise<void> => {
1738
+ const next = pendingPoll.then(pollBody, pollBody);
1739
+ pendingPoll = next.catch(() => {});
1740
+ return next;
1741
+ };
1742
+ this.reasoningTail = { seen, emittedNarrationKeys, poll, timer: setInterval(() => { void poll(); }, REASONING_POLL_MS) };
1743
+ this.sessionLog(`reasoning tail started (turn ${turnIdAtStart ?? "?"})`);
1744
+ void poll();
1745
+ }
1746
+
1747
+ private async drainReasoningTail(): Promise<void> {
1748
+ if (this.options.providerConfig.reasoningCapture === false) return;
1749
+ await this.reasoningTail?.poll();
1750
+ }
1751
+
1752
+ private preFlushBodyAlreadyMirroredByReasoningTail(jsonl: string, body: string): boolean {
1753
+ const tail = this.reasoningTail;
1754
+ if (!tail || !tail.emittedNarrationKeys.size) return false;
1755
+ const steps = extractLatestTurnSteps(jsonl);
1756
+ const keys = stepDedupKeys(steps);
1757
+ const mirrored = steps
1758
+ .map((step, index) => ({ step, key: keys[index]! }))
1759
+ .filter(({ step, key }) => step.type === "narration" && tail.emittedNarrationKeys.has(key))
1760
+ .map(({ step }) => step.text)
1761
+ .join("\n\n")
1762
+ .trim();
1763
+ const trimmed = body.trim();
1764
+ return mirrored === trimmed || mirrored.endsWith(`\n\n${trimmed}`);
1765
+ }
1766
+
1767
+ private stopReasoningTail(): void {
1768
+ if (this.reasoningTail) clearInterval(this.reasoningTail.timer);
1769
+ this.reasoningTail = undefined;
1770
+ }
1771
+
1772
+ // Mirror a discreet, durable "context compacted" marker into chat via the existing
1773
+ // session-mirror lane (not a parallel channel). `notice` renders as an inline timeline
1774
+ // marker (never a bubble) and survives reload, unlike the ephemeral timeline-status one.
1775
+ private publishCompactionNotice(): void {
1776
+ this.publishSessionEvent({
1777
+ from: this.agentId,
1778
+ to: "user",
1779
+ body: "🗜 Context compacted",
1780
+ session: { type: "notice", origin: "provider", label: "compacted", ...(this.currentTurnId ? { turnId: this.currentTurnId } : {}) },
1781
+ });
1782
+ }
1783
+
1784
+ // #286: a discreet, durable chat marker when a usage/rate-limit hold begins, via
1785
+ // the same session-mirror lane as the compaction notice. Outbound session event
1786
+ // (NOT an inbound message) so it shows in the dashboard chat WITHOUT waking a turn
1787
+ // — waking a still-limited agent would just re-fail. The relay sends the waking
1788
+ // resume message later, once the window has reset.
1789
+ private publishRateLimitNotice(providerState: Record<string, unknown>): void {
1790
+ const label = typeof providerState.label === "string" && providerState.label ? providerState.label : "usage limit reached";
1791
+ this.publishSessionEvent({
1792
+ from: this.agentId,
1793
+ to: "user",
1794
+ body: `⏳ ${label} — holding; agent-relay will auto-resume at reset.`,
1795
+ session: { type: "notice", origin: "provider", label: "rate-limit", ...(this.currentTurnId ? { turnId: this.currentTurnId } : {}) },
1796
+ });
1797
+ }
1798
+
1799
+ private publishStatus(): void {
1800
+ this.claims.expire();
1801
+ const status = this.claims.currentStatus();
1802
+ const agentStatus = runnerAgentStatus(status);
1803
+ const activeWork = this.claims.activeWork();
1804
+ const activeSubagents = activeWork.filter((item) => item.kind === "subagent");
1805
+ const terminalFailure = this.terminalFailure;
1806
+ const providerState = terminalFailure?.providerState ?? this.rateLimitHold ?? providerStateFromActiveWork(activeWork);
1807
+ this.bus.setSemanticStatus(status === "offline" || status === "error" ? "idle" : status);
1808
+ const timelineEvent = this.pendingTimelineEvent;
1809
+ this.pendingTimelineEvent = undefined;
1810
+ this.bus.status({
1811
+ agentStatus,
1812
+ ready: agentStatus !== "offline" && !this.stopped,
1813
+ meta: {
1814
+ runnerId: this.options.runnerId,
1815
+ startedAt: this.options.startedAt,
1816
+ tmuxSession: this.providerTerminalSession() ?? this.options.tmuxSession ?? null,
1817
+ tmuxSocket: this.providerTerminalSocket() ?? null,
1818
+ policyName: this.options.policyName ?? null,
1819
+ spawnRequestId: this.options.spawnRequestId ?? null,
1820
+ automationId: this.options.automationId ?? null,
1821
+ automationRunId: this.options.automationRunId ?? null,
1822
+ workspace: this.options.workspace ?? null,
1823
+ lifecycle: this.options.lifecycle ?? "persistent", workspaceMode: this.options.workspace?.requestedMode ?? this.options.workspace?.mode ?? process.env.AGENT_RELAY_WORKSPACE_MODE ?? null,
1824
+ lifecycleAction: this.lifecycleAction ?? null,
1825
+ profile: this.options.profile ?? null,
1826
+ ...(status === "error" ? { terminalStatus: "error" } : {}),
1827
+ ...(terminalFailure ? {
1828
+ lastError: terminalFailure.message,
1829
+ terminalFailureReason: terminalFailure.reason,
1830
+ terminalFailureMessage: terminalFailure.message,
1831
+ } : {}),
1832
+ busyReasons: this.claims.reasons(),
1833
+ activeWork,
1834
+ activeSubagents,
1835
+ activeSubagentCount: activeSubagents.length,
1836
+ providerState,
1837
+ auth: {
1838
+ profileId: this.currentTokenProfileId ?? null,
1839
+ jti: this.currentTokenJti ?? null,
1840
+ expiresAt: this.currentTokenExpiresAt ?? null,
1841
+ rootTokenFallback: this.options.rootTokenFallback === true,
1842
+ },
1843
+ ...(timelineEvent ? { timelineEvent } : {}),
1844
+ transport: this.bus.transportState,
1845
+ },
1846
+ });
1847
+ }
1848
+
1849
+ private startHttpLiveness(): void {
1850
+ if (this.httpLivenessTimer) clearInterval(this.httpLivenessTimer);
1851
+ void this.publishHttpLiveness();
1852
+ this.httpLivenessTimer = setInterval(() => {
1853
+ void this.publishHttpLiveness();
1854
+ }, HTTP_LIVENESS_INTERVAL_MS);
1855
+ }
1856
+
1857
+ private async publishHttpLiveness(): Promise<void> {
1858
+ if (this.stopped || this.httpLivenessInFlight || this.httpLivenessAuthFailed) return;
1859
+ this.httpLivenessInFlight = true;
1860
+ const status = this.claims.currentStatus();
1861
+ const agentStatus = runnerAgentStatus(status);
1862
+ try {
1863
+ await this.http.setStatus(this.agentId, agentStatus, this.options.instanceId);
1864
+ await this.http.setReady(this.agentId, agentStatus !== "offline", this.options.instanceId);
1865
+ await this.http.heartbeat(this.agentId, this.options.instanceId);
1866
+ await this.bootstrapUnreadMessages();
1867
+ } catch (error) {
1868
+ if (!this.stopped) this.handleHttpLivenessFailure(error);
1869
+ } finally {
1870
+ this.httpLivenessInFlight = false;
1871
+ }
1872
+ }
1873
+
1874
+ private handleHttpLivenessFailure(error: unknown): void {
1875
+ const authFailed = isHttpAuthError(error);
1876
+ this.logHttpLivenessFailure(error, authFailed);
1877
+ if (!authFailed) return;
1878
+ this.httpLivenessAuthFailed = true;
1879
+ if (this.httpLivenessTimer) clearInterval(this.httpLivenessTimer);
1880
+ this.httpLivenessTimer = undefined;
1881
+ // A 401/403 here is the only timely signal that the token died — stopping the
1882
+ // liveness timer means there is no second chance, so recover from THIS failure.
1883
+ this.recoverRuntimeTokenAfterAuthFailure("http-liveness");
1884
+ }
1885
+
1886
+ // A definitive relay auth failure (401/403) means the runtime token is dead right
1887
+ // now — expired, or (the common case) revoked when the relay marked this agent
1888
+ // stale across its own restart/reconnect. The proactive renew timer is keyed to
1889
+ // TTL and structurally cannot catch a revocation, so the auth failure itself must
1890
+ // drive recovery. renewRuntimeToken() prefers an orchestrator re-mint, which heals
1891
+ // an expired token AND a token revoked-as-"stale" (the restart strand, #484) —
1892
+ // but NOT a hard kill (admin revoke / rotation / agent removal), which stays dead
1893
+ // by design. Debounced so a burst of failing calls re-mints once.
1894
+ private recoverRuntimeTokenAfterAuthFailure(source: string): void {
1895
+ if (this.stopped || this.tokenRenewInFlight) return;
1896
+ if (!this.isRuntimeTokenRenewable() && !this.canRemintViaOrchestrator()) return;
1897
+ const now = Date.now();
1898
+ if (this.reactiveTokenRecoveryAt && now - this.reactiveTokenRecoveryAt < REACTIVE_TOKEN_RECOVERY_DEBOUNCE_MS) return;
1899
+ this.reactiveTokenRecoveryAt = now;
1900
+ this.logRunnerDiagnostic(`[runner] relay auth failure on ${source}; recovering runtime token`);
1901
+ void this.renewRuntimeToken();
1902
+ }
1903
+
1904
+ private logHttpLivenessFailure(error: unknown, authFailed: boolean): void {
1905
+ const key = httpErrorKey(error);
1906
+ const now = Date.now();
1907
+ if (
1908
+ this.httpLivenessLastLog?.key === key &&
1909
+ now - this.httpLivenessLastLog.at < HTTP_LIVENESS_LOG_INTERVAL_MS
1910
+ ) return;
1911
+ this.httpLivenessLastLog = { key, at: now };
1912
+ const suffix = authFailed ? "auth failed; stopping HTTP liveness retries until restart" : String(error);
1913
+ this.logRunnerDiagnostic(`[runner] HTTP liveness update failed: ${suffix}`);
1914
+ }
1915
+
1916
+ // Runner operational diagnostics (HTTP liveness, token renewal failures). Routed
1917
+ // through the leveled logger at warn — see logger.ts. Kept as a thin wrapper so
1918
+ // the existing call sites and their `[runner]` framing stay put.
1919
+ private logRunnerDiagnostic(message: string): void {
1920
+ logger.warn("runner", message.replace(/^\[runner\]\s*/, ""));
1921
+ }
1922
+
1923
+ // Session-mirror diagnostics → the leveled logger (component "mirror"), written
1924
+ // to the dashboard-surfaced session-mirror-<agent>.log. Key transitions log at
1925
+ // info; the single place to look when chat/terminal sync misbehaves.
1926
+ private sessionLog(message: string): void {
1927
+ logger.info("mirror", message);
1928
+ }
1929
+
1930
+ // Verbose, high-frequency lines (per-probe, per-emit) — surfaced only at log
1931
+ // level "debug" (AGENT_RELAY_LOG_LEVEL=debug, or flip live via /log-level).
1932
+ private sessionDebug(message: string): void {
1933
+ logger.debug("mirror", message);
1934
+ }
1935
+
1936
+ private ensureScratch(): void {
1937
+ try {
1938
+ this.scratch = ensureSessionScratch({
1939
+ agentId: this.agentId,
1940
+ cwd: this.options.cwd,
1941
+ fallbackBaseDir: process.env.AGENT_RELAY_ORCHESTRATOR_BASE_DIR,
1942
+ });
1943
+ } catch (error) {
1944
+ this.logRunnerDiagnostic(`session scratch setup failed: ${errMessage(error)}`);
1945
+ }
1946
+ }
1947
+
1948
+ private async sweepStaleScratch(): Promise<void> {
1949
+ try {
1950
+ const agents = await this.http.listAgents().catch(() => [] as Awaited<ReturnType<RelayHttpClient["listAgents"]>>);
1951
+ const keep = new Set(agents.map((a) => a.id));
1952
+ keep.add(this.agentId);
1953
+ const removed = sweepStaleSessions({
1954
+ cwd: this.options.cwd,
1955
+ fallbackBaseDir: process.env.AGENT_RELAY_ORCHESTRATOR_BASE_DIR,
1956
+ keepAgentIds: keep,
1957
+ now: Date.now(),
1958
+ });
1959
+ if (removed.length) this.logRunnerDiagnostic(`swept ${removed.length} stale session scratch dir(s)`);
1960
+ } catch {}
1961
+ }
1962
+
1963
+ private scheduleRuntimeTokenRenewal(delayMs?: number): void {
1964
+ if (this.tokenRenewTimer) clearTimeout(this.tokenRenewTimer);
1965
+ this.tokenRenewTimer = undefined;
1966
+ if (this.stopped) return;
1967
+ const canSelfRenew = this.isRuntimeTokenRenewable();
1968
+ const canRemint = this.canRemintViaOrchestrator();
1969
+ // Keep the renewal clock ticking as long as the session can recover its token
1970
+ // by EITHER path. Without the re-mint fallback an expired token would stop the
1971
+ // timer forever (the old deadlock that stranded live agents off the bus).
1972
+ if (!canSelfRenew && !canRemint) return;
1973
+ let computedDelay = delayMs;
1974
+ if (computedDelay === undefined) {
1975
+ computedDelay = canSelfRenew
1976
+ ? runtimeTokenRenewDelayMs(this.currentTokenExpiresAt!, Date.now())
1977
+ : TOKEN_RENEW_RETRY_MS; // expired but re-mintable → retry via orchestrator soon
1978
+ if (computedDelay === undefined) computedDelay = TOKEN_RENEW_RETRY_MS;
1979
+ }
1980
+ const schedule = runtimeTokenRenewTimerSchedule(computedDelay);
1981
+ if (!schedule) return;
1982
+ this.tokenRenewTimer = setTimeout(() => {
1983
+ this.tokenRenewTimer = undefined;
1984
+ if (!schedule.renew) {
1985
+ this.scheduleRuntimeTokenRenewal();
1986
+ return;
1987
+ }
1988
+ void this.renewRuntimeToken();
1989
+ }, schedule.delayMs);
1990
+ }
1991
+
1992
+ // Can the runner self-renew right now? Requires a non-expired runner-profile token
1993
+ // (the relay rejects renewal of an expired token).
1994
+ private isRuntimeTokenRenewable(): boolean {
1995
+ return Boolean(
1996
+ this.currentToken &&
1997
+ this.currentTokenExpiresAt &&
1998
+ this.currentTokenExpiresAt * 1000 > Date.now() &&
1999
+ (this.currentTokenProfileId === "provider-agent" || this.currentTokenProfileId === "provider-interactive"),
2000
+ );
2001
+ }
2002
+
2003
+ // Can the runner recover its token via the orchestrator? Works even when the token
2004
+ // is already expired — the orchestrator's standing credential is the authority.
2005
+ private canRemintViaOrchestrator(): boolean {
2006
+ return Boolean(
2007
+ process.env.AGENT_RELAY_ORCHESTRATOR_URL &&
2008
+ this.currentToken &&
2009
+ (this.currentTokenProfileId === "provider-agent" || this.currentTokenProfileId === "provider-interactive"),
2010
+ );
2011
+ }
2012
+
2013
+ private async renewRuntimeToken(): Promise<void> {
2014
+ if (this.stopped || this.tokenRenewInFlight || !this.currentToken) return;
2015
+ this.tokenRenewInFlight = true;
2016
+ try {
2017
+ // Preferred path: self-renew directly against the relay while the token is
2018
+ // still valid. Cheapest and needs no orchestrator round-trip.
2019
+ if (this.isRuntimeTokenRenewable()) {
2020
+ try {
2021
+ const renewed = await this.http.renewRuntimeToken();
2022
+ this.applyRenewedToken(renewed.token, renewed.record, "runtime-token-renewed");
2023
+ return;
2024
+ } catch (error) {
2025
+ this.logRuntimeTokenRenewalFailure(error);
2026
+ // Relay unreachable or token rejected — fall through to orchestrator re-mint.
2027
+ }
2028
+ }
2029
+ // Recovery path: token expired, or self-renew failed. Ask the orchestrator —
2030
+ // it holds a long-lived credential and can mint a fresh runner token, so a
2031
+ // live session heals instead of being stranded off the bus.
2032
+ if (this.canRemintViaOrchestrator() && await this.remintViaOrchestrator()) return;
2033
+ this.pendingTimelineEvent = {
2034
+ status: "runtime-token-renewal-failed",
2035
+ timestamp: Date.now(),
2036
+ };
2037
+ this.publishStatus();
2038
+ this.scheduleRuntimeTokenRenewal(TOKEN_RENEW_RETRY_MS);
2039
+ } finally {
2040
+ this.tokenRenewInFlight = false;
2041
+ }
2042
+ }
2043
+
2044
+ // Apply a freshly issued token across every live surface — runner state, the
2045
+ // RunnerOptions bag (re-injected into the provider on respawn), the HTTP client,
2046
+ // the bus client — then force a bus handshake with the new token and reschedule.
2047
+ private applyRenewedToken(
2048
+ token: string,
2049
+ record: { jti: string; profileId?: string; expiresAt?: number },
2050
+ status: "runtime-token-renewed" | "runtime-token-reminted",
2051
+ ): void {
2052
+ this.currentToken = token;
2053
+ this.currentTokenJti = record.jti;
2054
+ this.currentTokenProfileId = record.profileId ?? this.currentTokenProfileId;
2055
+ this.currentTokenExpiresAt = record.expiresAt;
2056
+ this.options.token = token;
2057
+ this.options.tokenJti = record.jti;
2058
+ this.options.tokenProfileId = this.currentTokenProfileId;
2059
+ this.options.tokenExpiresAt = this.currentTokenExpiresAt;
2060
+ this.http.setToken(token);
2061
+ this.bus.setToken(token);
2062
+ // The proxy reads the token live via getToken(), so forwarding already uses the new one.
2063
+ // A re-mint can change scope (e.g. a profile change), so refresh the relay tool list and
2064
+ // emit tools/list_changed if the visible set changed (#215 — token-scope transition).
2065
+ void this.proxy?.refreshTools().catch(() => {});
2066
+ this.httpLivenessAuthFailed = false;
2067
+ this.reactiveTokenRecoveryAt = undefined;
2068
+ // An earlier auth failure may have stopped the liveness loop; restart it so the
2069
+ // agent reports live again on the fresh token. startHttpLiveness clears any
2070
+ // existing timer first, so this is safe on the normal (proactive) renew path too.
2071
+ this.startHttpLiveness();
2072
+ this.pendingTimelineEvent = { status, id: record.jti, timestamp: Date.now() };
2073
+ this.bus.reconnectTransport(status === "runtime-token-reminted" ? "runtime token re-minted" : "runtime token renewed");
2074
+ this.publishStatus();
2075
+ this.scheduleRuntimeTokenRenewal();
2076
+ }
2077
+
2078
+ // Recover the runtime token through the orchestrator. The runner proxies its own
2079
+ // (possibly expired) token; the orchestrator re-mints it via the relay using its
2080
+ // standing credential. Returns true on success.
2081
+ private async remintViaOrchestrator(): Promise<boolean> {
2082
+ const orchUrl = process.env.AGENT_RELAY_ORCHESTRATOR_URL;
2083
+ if (!orchUrl || !this.currentToken) return false;
2084
+ try {
2085
+ const res = await fetch(`${orchUrl.replace(/\/+$/, "")}/api/runtime-tokens/runner-renew`, {
2086
+ method: "POST",
2087
+ headers: { "Content-Type": "application/json" },
2088
+ body: JSON.stringify({ token: this.currentToken }),
2089
+ signal: AbortSignal.timeout(10_000),
2090
+ });
2091
+ if (!res.ok) return false;
2092
+ const renewed = await res.json() as { token?: string; record?: { jti: string; profileId?: string; expiresAt?: number } };
2093
+ if (!renewed?.token || !renewed.record) return false;
2094
+ this.applyRenewedToken(renewed.token, renewed.record, "runtime-token-reminted");
2095
+ this.logRunnerDiagnostic(`[runner] runtime token re-minted via orchestrator (jti ${renewed.record.jti})`);
2096
+ return true;
2097
+ } catch (error) {
2098
+ this.logRuntimeTokenRenewalFailure(error);
2099
+ return false;
2100
+ }
2101
+ }
2102
+
2103
+ private logRuntimeTokenRenewalFailure(error: unknown): void {
2104
+ const key = httpErrorKey(error);
2105
+ const now = Date.now();
2106
+ if (
2107
+ this.tokenRenewLastLog?.key === key &&
2108
+ now - this.tokenRenewLastLog.at < HTTP_LIVENESS_LOG_INTERVAL_MS
2109
+ ) return;
2110
+ this.tokenRenewLastLog = { key, at: now };
2111
+ this.logRunnerDiagnostic(`[runner] runtime token renewal failed: ${String(error)}`);
2112
+ }
2113
+
2114
+ private heartbeatRuntimeMeta(): Record<string, unknown> | undefined {
2115
+ const processContext = this.latestProcessContext();
2116
+ const probeMetrics = this.latestProbeMetrics();
2117
+ const probeContext = probeMetrics ? contextStateFromProbeMetrics(probeMetrics) : undefined;
2118
+ const context = processContext ?? probeContext;
2119
+ const terminalSession = this.providerTerminalSession();
2120
+ const terminalSocket = this.providerTerminalSocket();
2121
+ const probeModel: ProbeModelInfo | undefined = probeMetrics?.model || probeMetrics?.effort
2122
+ ? { model: probeMetrics.model, effort: probeMetrics.effort }
2123
+ : undefined;
2124
+ const meta: Record<string, unknown> = {
2125
+ providerCapabilities: runtimeProviderCapabilities(
2126
+ this.options,
2127
+ context,
2128
+ probeModel,
2129
+ ),
2130
+ ...(terminalSession ? { tmuxSession: terminalSession } : {}),
2131
+ ...(terminalSocket ? { tmuxSocket: terminalSocket } : {}),
2132
+ };
2133
+ if (context) meta.context = context;
2134
+ return meta;
2135
+ }
2136
+
2137
+ private providerTerminalSession(): string | undefined {
2138
+ return typeof this.process?.meta?.tmuxSession === "string" ? this.process.meta.tmuxSession : undefined;
2139
+ }
2140
+
2141
+ private providerTerminalSocket(): string | undefined {
2142
+ return typeof this.process?.meta?.tmuxSocket === "string" ? this.process.meta.tmuxSocket : undefined;
2143
+ }
2144
+
2145
+ private latestProcessContext(): ContextState | undefined {
2146
+ const getContext = this.process?.meta?.getContext;
2147
+ if (typeof getContext === "function") {
2148
+ const context = getContext();
2149
+ if (isContextState(context)) return context;
2150
+ }
2151
+ const context = this.process?.meta?.context;
2152
+ return isContextState(context) ? context : undefined;
2153
+ }
2154
+
2155
+ private latestProbeMetrics() {
2156
+ const stateDir = process.env.AGENT_RELAY_CONTEXT_STATE_DIR;
2157
+ return readContextProbeState(this.agentId, stateDir);
2158
+ }
2159
+
2160
+ private matchesMessage(message: Message): boolean {
2161
+ return runnerMessageMatches(message, {
2162
+ agentId: this.agentId,
2163
+ label: this.options.label,
2164
+ provider: this.options.provider,
2165
+ tags: this.options.tags,
2166
+ capabilities: this.options.capabilities,
2167
+ defaultTags: this.options.providerConfig.defaultTags,
2168
+ defaultCapabilities: this.options.providerConfig.defaultCapabilities,
2169
+ allowBroadTargets: this.options.agentProfile?.relay?.context !== false,
2170
+ });
2171
+ }
2172
+
2173
+ private startClaimRenewer(): void {
2174
+ if (this.claimRenewTimer) return;
2175
+ this.claimRenewTimer = setInterval(() => {
2176
+ void this.renewActiveTaskClaims();
2177
+ }, CLAIM_RENEW_INTERVAL_MS);
2178
+ }
2179
+
2180
+ private async renewActiveTaskClaims(): Promise<void> {
2181
+ for (const claim of this.activeTaskClaims.values()) {
2182
+ const renewed = await this.http.renewMessageClaim(claim.messageId, this.agentId).catch(() => ({ ok: false, claimExpiresAt: undefined }));
2183
+ if (renewed.ok) {
2184
+ this.claims.startClaim("message", String(claim.messageId), renewed.claimExpiresAt);
2185
+ this.claims.startClaim("task", String(claim.taskId), renewed.claimExpiresAt);
2186
+ }
2187
+ }
2188
+ this.publishStatus();
2189
+ }
2190
+
2191
+ private async completeObservedTaskClaims(): Promise<void> {
2192
+ const completed = [...this.activeTaskClaims.values()].filter((claim) => claim.observedProviderBusy);
2193
+ for (const claim of completed) {
2194
+ const ok = await this.updateTaskStatus(claim.taskId, {
2195
+ status: "done",
2196
+ agentId: this.agentId,
2197
+ body: `Runner observed provider completion for message #${claim.messageId}`,
2198
+ metadata: { messageId: claim.messageId, completedBy: "runner" },
2199
+ })
2200
+ .then(() => true)
2201
+ .catch((error) => {
2202
+ logger.error("task", `task ${claim.taskId} completion update failed: ${error}`);
2203
+ return false;
2204
+ });
2205
+ if (!ok) continue;
2206
+ this.activeTaskClaims.delete(claim.messageId);
2207
+ this.claims.finishClaim("message", String(claim.messageId));
2208
+ this.claims.finishClaim("task", String(claim.taskId));
2209
+ }
2210
+ if (completed.length > 0) this.publishStatus();
2211
+ }
2212
+
2213
+ private async updateTaskStatus(taskId: number, input: TaskStatusInput): Promise<void> {
2214
+ await this.http.updateTaskStatus(taskId, input);
2215
+ }
2216
+
2217
+ private clearActiveClaim(message: Message): void {
2218
+ const taskId = taskIdFromMessage(message);
2219
+ this.claims.finishClaim("message", String(message.id));
2220
+ if (!taskId) return;
2221
+ this.activeTaskClaims.delete(message.id);
2222
+ this.claims.finishClaim("task", String(taskId));
2223
+ }
2224
+ }