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