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