agent-relay-runner 0.129.11 → 0.129.13
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 +3 -3
- package/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/plugins/claude/monitors/relay-monitor.provisioned.mjs +8 -8
- package/src/adapter.ts +113 -11
- package/src/adapters/claude-delivery.ts +1 -7
- package/src/adapters/claude-exit.ts +289 -0
- package/src/adapters/claude-prompt-gates.ts +59 -79
- package/src/adapters/claude-session-capture.ts +13 -2
- package/src/adapters/claude-session-probe.ts +98 -7
- package/src/adapters/claude-shutdown.ts +149 -0
- package/src/adapters/claude-status-detectors.ts +201 -69
- package/src/adapters/claude-tmux.ts +94 -5
- package/src/adapters/claude-transcript-tail.ts +22 -3
- package/src/adapters/claude-transcript.ts +95 -1
- package/src/adapters/claude.ts +179 -76
- package/src/adapters/codex.ts +46 -8
- package/src/busy-reconciler.ts +67 -29
- package/src/config.ts +16 -0
- package/src/launch-assembly.ts +251 -82
- package/src/native-memory-lever.ts +308 -0
- package/src/outbox.ts +22 -4
- package/src/profile-projection.ts +45 -1
- package/src/provider-input-queue.ts +100 -0
- package/src/rate-limit.ts +106 -36
- package/src/relay-injection-events.ts +24 -0
- package/src/relay-instructions.ts +9 -0
- package/src/relay-mcp-proxy.ts +79 -8
- package/src/relay-mcp.ts +217 -8
- package/src/runner-core.ts +323 -47
- package/src/runner-helpers.ts +10 -0
- package/src/transcript-context.ts +83 -0
- package/src/turn-reconcile-event.ts +48 -0
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
// entry carrying text, not just tool_result blocks). We collect the assistant
|
|
11
11
|
// `text` blocks from that turn — thinking and tool_use are dropped.
|
|
12
12
|
|
|
13
|
+
import type { ToolCallCategory } from "agent-relay-sdk";
|
|
13
14
|
import { computeContextRatio, type SessionAnalysis, type SessionEvent } from "../session-insights";
|
|
14
15
|
|
|
15
16
|
interface TranscriptBlock {
|
|
@@ -25,6 +26,7 @@ export interface TurnStep {
|
|
|
25
26
|
type: "narration" | "reasoning" | "tool";
|
|
26
27
|
text: string;
|
|
27
28
|
label?: string;
|
|
29
|
+
category?: ToolCallCategory;
|
|
28
30
|
occurredAt?: number;
|
|
29
31
|
}
|
|
30
32
|
|
|
@@ -32,6 +34,28 @@ interface TranscriptMessage {
|
|
|
32
34
|
role?: string;
|
|
33
35
|
content?: string | TranscriptBlock[];
|
|
34
36
|
stop_reason?: string;
|
|
37
|
+
// Assistant entries carry the model id and the API-reported token usage for
|
|
38
|
+
// the turn. `usage.{input,cache_creation_input,cache_read_input}_tokens` sum to
|
|
39
|
+
// the same context-size figure the status-line probe reports — read here so the
|
|
40
|
+
// runner can keep context stats advancing every turn without the probe (#1512).
|
|
41
|
+
model?: string;
|
|
42
|
+
usage?: TranscriptUsage;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface TranscriptUsage {
|
|
46
|
+
input_tokens?: number;
|
|
47
|
+
cache_creation_input_tokens?: number;
|
|
48
|
+
cache_read_input_tokens?: number;
|
|
49
|
+
output_tokens?: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Fresh, per-turn context usage read straight from the transcript (#1512). */
|
|
53
|
+
export interface TranscriptContextUsage {
|
|
54
|
+
/** input + cache-creation + cache-read tokens — the context size sent this turn. */
|
|
55
|
+
tokensUsed: number;
|
|
56
|
+
model?: string;
|
|
57
|
+
/** Wall-clock ms of the assistant entry, when the transcript stamps one. */
|
|
58
|
+
occurredAt?: number;
|
|
35
59
|
}
|
|
36
60
|
|
|
37
61
|
interface TranscriptEntry {
|
|
@@ -361,7 +385,7 @@ export class LatestTurnStepAccumulator {
|
|
|
361
385
|
} else if (b.type === "thinking" && typeof b.thinking === "string" && b.thinking.trim()) {
|
|
362
386
|
this.latestSteps.push({ type: "reasoning", text: b.thinking.trim(), ...(occurredAt ? { occurredAt } : {}) });
|
|
363
387
|
} else if (b.type === "tool_use" && typeof b.name === "string" && b.name) {
|
|
364
|
-
this.latestSteps.push({ type: "tool", label: b.name, text: summarizeToolUse(b.name, b.input), ...(occurredAt ? { occurredAt } : {}) });
|
|
388
|
+
this.latestSteps.push({ type: "tool", label: b.name, text: summarizeToolUse(b.name, b.input), category: claudeToolCategory(b.name), ...(occurredAt ? { occurredAt } : {}) });
|
|
365
389
|
}
|
|
366
390
|
}
|
|
367
391
|
}
|
|
@@ -392,6 +416,28 @@ export function stepDedupKeys(steps: TurnStep[]): string[] {
|
|
|
392
416
|
});
|
|
393
417
|
}
|
|
394
418
|
|
|
419
|
+
// #1498: Claude's native tool names → the provider-independent category taxonomy. Tools with
|
|
420
|
+
// no dedicated category (Grep, Glob, LS, WebFetch, WebSearch, ...) fall to "generic" rather than
|
|
421
|
+
// being force-fit into a category that doesn't really describe them — the fallback exists
|
|
422
|
+
// precisely so nothing is hidden from the dashboard's activity trace.
|
|
423
|
+
const CLAUDE_TOOL_CATEGORIES: Record<string, ToolCallCategory> = {
|
|
424
|
+
Bash: "cli-bash",
|
|
425
|
+
Read: "read",
|
|
426
|
+
Edit: "edit",
|
|
427
|
+
Write: "edit",
|
|
428
|
+
NotebookEdit: "edit",
|
|
429
|
+
AskUserQuestion: "ask-user-question",
|
|
430
|
+
ExitPlanMode: "plan",
|
|
431
|
+
Task: "agent-spawn",
|
|
432
|
+
ToolSearch: "mcp-tool-search",
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
/** Map a Claude tool_use name onto the provider-independent tool-call category (#1498). */
|
|
436
|
+
export function claudeToolCategory(name: string): ToolCallCategory {
|
|
437
|
+
if (name.startsWith("mcp__")) return "mcp-tool-call";
|
|
438
|
+
return CLAUDE_TOOL_CATEGORIES[name] ?? "generic";
|
|
439
|
+
}
|
|
440
|
+
|
|
395
441
|
/** Compact one-line summary of a tool invocation for the discreet activity row. */
|
|
396
442
|
export function summarizeToolUse(name: string, input: Record<string, unknown> | undefined): string {
|
|
397
443
|
const str = (key: string): string | undefined => (input && typeof input[key] === "string" ? (input[key] as string) : undefined);
|
|
@@ -475,6 +521,54 @@ export function countSubstantiveTurns(jsonl: string): number {
|
|
|
475
521
|
return turns;
|
|
476
522
|
}
|
|
477
523
|
|
|
524
|
+
// Walk the transcript from the end and return the most recent root-session
|
|
525
|
+
// assistant turn's token usage (#1512). This is the SAME input+cache token sum
|
|
526
|
+
// the Claude status-line probe reports, but read directly from the transcript —
|
|
527
|
+
// which the Stop hook hands the runner every turn — so context stats keep
|
|
528
|
+
// advancing across compact/clear/native-resume even when the status-line probe
|
|
529
|
+
// goes silent. Sidechain (subagent) entries are skipped so a Task's usage never
|
|
530
|
+
// masquerades as the root session's context size.
|
|
531
|
+
export function extractLatestContextUsage(jsonl: string): TranscriptContextUsage | undefined {
|
|
532
|
+
const lines = jsonl.split("\n");
|
|
533
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
534
|
+
const trimmed = lines[i]?.trim();
|
|
535
|
+
if (!trimmed) continue;
|
|
536
|
+
let entry: TranscriptEntry;
|
|
537
|
+
try {
|
|
538
|
+
entry = JSON.parse(trimmed) as TranscriptEntry;
|
|
539
|
+
} catch {
|
|
540
|
+
continue;
|
|
541
|
+
}
|
|
542
|
+
if (entry.type !== "assistant" || isSidechainEntry(entry)) continue;
|
|
543
|
+
const usage = entry.message?.usage;
|
|
544
|
+
if (!usage) continue;
|
|
545
|
+
const tokensUsed = sumUsageTokens(usage);
|
|
546
|
+
if (tokensUsed === undefined) continue;
|
|
547
|
+
const occurredAt = typeof entry.timestamp === "string" ? Date.parse(entry.timestamp) : NaN;
|
|
548
|
+
return {
|
|
549
|
+
tokensUsed,
|
|
550
|
+
...(typeof entry.message?.model === "string" ? { model: entry.message.model } : {}),
|
|
551
|
+
...(Number.isFinite(occurredAt) ? { occurredAt } : {}),
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
return undefined;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// Context size == prompt input tokens for the turn: fresh input + newly-cached
|
|
558
|
+
// input + cache-read input. Matches the status-line probe's sum (output tokens
|
|
559
|
+
// are excluded — they are not part of the context window carried forward).
|
|
560
|
+
function sumUsageTokens(usage: TranscriptUsage): number | undefined {
|
|
561
|
+
let total = 0;
|
|
562
|
+
let seen = false;
|
|
563
|
+
for (const value of [usage.input_tokens, usage.cache_creation_input_tokens, usage.cache_read_input_tokens]) {
|
|
564
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
565
|
+
total += value;
|
|
566
|
+
seen = true;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
return seen ? total : undefined;
|
|
570
|
+
}
|
|
571
|
+
|
|
478
572
|
export function extractHookAssistantMessage(content: unknown): string {
|
|
479
573
|
if (typeof content === "string") return content.trim();
|
|
480
574
|
if (!Array.isArray(content)) return "";
|
package/src/adapters/claude.ts
CHANGED
|
@@ -5,17 +5,19 @@ import { type Message } from "agent-relay-sdk";
|
|
|
5
5
|
import { shellEscape as shellQuote } from "agent-relay-sdk/shell-utils";
|
|
6
6
|
import { tmuxCommand, tmuxHasSession } from "agent-relay-sdk/tmux-utils";
|
|
7
7
|
import { runtimePath } from "agent-relay-sdk/runtime-prefix";
|
|
8
|
-
import { profileAllowsRelayFeature, type ManagedProcess, type ProviderAdapter, type ProviderConfig, type ProviderStatusUpdate, type RunnerSpawnConfig, type SemanticStatus, type SpawnArgs } from "../adapter";
|
|
8
|
+
import { profileAllowsRelayFeature, type ManagedProcess, type PaneBusyReading, type PaneObservation, type ProviderAdapter, type ProviderConfig, type ProviderStatusUpdate, type RunnerSpawnConfig, type SemanticStatus, type SpawnArgs } from "../adapter";
|
|
9
9
|
import { computeLivenessSignal, type LivenessInputs } from "../liveness";
|
|
10
10
|
import type { LivenessSignal } from "agent-relay-sdk";
|
|
11
11
|
import { prepareClaudeProfileHome, profileUsesClaudeHostProviderGlobals } from "../profile-home";
|
|
12
12
|
import { assembleLaunch, materializeLaunchAssembly, sessionStatusLineSettingsArgs, stripSettingsArgs } from "../launch-assembly";
|
|
13
13
|
import { claudeProviderMessageText } from "./claude-delivery";
|
|
14
|
-
import {
|
|
14
|
+
import { claudeProbeVerdict, readManagedClaudeStatus, resolveClaudePid, resolveManagedClaudeTranscriptPath, type ClaudeProbeActivity } from "./claude-session-probe";
|
|
15
15
|
import { evaluateClaudePromptGatePane, initialClaudePromptGatePaneState, type ClaudePromptGatePaneState } from "./claude-prompt-gates";
|
|
16
|
-
import { claudeActivityFromProbeAndPane, claudePaneLooksReady, claudePaneIsBusy, claudeRateLimitStatus,
|
|
16
|
+
import { claudeActivityFromProbeAndPane, claudePaneLooksReady, claudePaneIsBusy, claudeRateLimitStatus, claudeConnectionRetryObservation, claudeConnectionRetryClearedObservation, claudeModelUnavailableObservation, debounceClaudeModelUnavailableObservation, initialClaudeModelUnavailableObservationState } from "./claude-status-detectors";
|
|
17
17
|
import { ClaudeSessionCapture, collectClaudeTranscriptArchiveSegment, collectClaudeTranscriptSessionEvents } from "./claude-session-capture";
|
|
18
|
-
import {
|
|
18
|
+
import { extractLatestContextUsage } from "./claude-transcript";
|
|
19
|
+
import { CLAUDE_TMUX_READY_TIMEOUT_MS, captureTmuxPane, confirmTmuxSessionGone, createLauncherGate, createOrReuseTmuxSession, releaseLauncherGate, submitTextToTmux, tmuxEnvKeys, tmuxSessionName, tmuxSessionTargetName, tmuxSocketName, waitForClaudeClearCommitted, waitForClaudeInputReady, writeLauncherScript } from "./claude-tmux";
|
|
20
|
+
import { claudeShutdownGraceMs, shutdownClaudeTmux, type ClaudeShutdownDeps } from "./claude-shutdown";
|
|
19
21
|
import { claudePermissionPromptHandler } from "./claude-permission";
|
|
20
22
|
import { launcherGatePathForSession } from "../session-scratch";
|
|
21
23
|
|
|
@@ -26,15 +28,34 @@ export class ClaudeAdapter implements ProviderAdapter {
|
|
|
26
28
|
readonly stopObligationPath = "claude-stop";
|
|
27
29
|
readonly permissionPromptHandler = claudePermissionPromptHandler;
|
|
28
30
|
readonly compactSupportsInstructions = true;
|
|
31
|
+
// #1588 — Claude's clearContext is a fire-and-forget tmux `/clear` keystroke with no structured
|
|
32
|
+
// completion barrier, so the self-resume continuation MUST be delivered in-process coupled to the
|
|
33
|
+
// clear command (after waitForClaudeInputReady settles it) instead of via a separate inject that
|
|
34
|
+
// would race the still-processing async clear. Codex omits this (atomic awaited threadStart).
|
|
35
|
+
readonly couplesClearInjectContinuation = true;
|
|
29
36
|
// #352: initial prompt is seeded as Claude's positional launch arg (buildSpawnArgs) — reliable,
|
|
30
37
|
// no send-keys/onboarding race; tells the runner to skip the redundant post-launch delivery.
|
|
31
38
|
readonly seedsInitialPromptAtLaunch = true;
|
|
32
39
|
private statusCb: (status: ProviderStatusUpdate) => void = () => {};
|
|
40
|
+
// #1651 — the pane watcher's ONLY outbound channel for anything it inferred from pane text.
|
|
41
|
+
// A PaneObservation cannot carry a status, a clear, or a providerState, so routing a
|
|
42
|
+
// detector here is what makes "advisory-only" structural rather than a promise in a comment.
|
|
43
|
+
private paneObservationCb: (observation: PaneObservation) => void = () => {};
|
|
33
44
|
private tmuxWatcher?: Timer;
|
|
34
45
|
private turnWatcher?: Timer;
|
|
35
|
-
private
|
|
46
|
+
private modelUnavailableObservationState = initialClaudeModelUnavailableObservationState();
|
|
36
47
|
private connectionRetryActive = false;
|
|
37
48
|
private promptGatePaneState: ClaudePromptGatePaneState = initialClaudePromptGatePaneState();
|
|
49
|
+
// #1621 — when relay LAST typed a turn-cancelling keystroke into the managed pane itself. A
|
|
50
|
+
// probe whose last write predates this cannot be describing the state after the cancellation,
|
|
51
|
+
// so its busy verdict stops being authoritative at once. #1651 removed the prompt gate's
|
|
52
|
+
// auto-Escape, so the sole remaining writer is the explicit `interrupt()` command — a
|
|
53
|
+
// deliberate, operator/runner-initiated cancel, not something inferred from pane text.
|
|
54
|
+
private paneInterruptAt?: number;
|
|
55
|
+
// #1647 — provider epoch: bumped on every spawn, stamped into the ManagedProcess and
|
|
56
|
+
// into any shutdown intent minted for it. A shutdown request that outlives a restart
|
|
57
|
+
// therefore cannot authorize typing `/exit` into the replacement session.
|
|
58
|
+
private providerEpoch = 0;
|
|
38
59
|
private readonly sessionCapture = new ClaudeSessionCapture();
|
|
39
60
|
readonly onSessionEvent = this.sessionCapture.onSessionEvent.bind(this.sessionCapture);
|
|
40
61
|
readonly onSessionEvents = this.sessionCapture.onSessionEvents.bind(this.sessionCapture);
|
|
@@ -50,6 +71,10 @@ export class ClaudeAdapter implements ProviderAdapter {
|
|
|
50
71
|
this.statusCb = cb;
|
|
51
72
|
}
|
|
52
73
|
|
|
74
|
+
onPaneObservation(cb: (observation: PaneObservation) => void): void {
|
|
75
|
+
this.paneObservationCb = cb;
|
|
76
|
+
}
|
|
77
|
+
|
|
53
78
|
// §4.1 contract verdict (#746). Claude reference (#713): Claude is token-silent during
|
|
54
79
|
// native compaction, but the runner publishes a `compacting` timeline transition while it
|
|
55
80
|
// runs — so `timelineEvent.timestamp` (folded into lastProgressAt) keeps a compacting Opus
|
|
@@ -74,7 +99,7 @@ export class ClaudeAdapter implements ProviderAdapter {
|
|
|
74
99
|
stdout: "inherit",
|
|
75
100
|
stderr: "inherit",
|
|
76
101
|
});
|
|
77
|
-
void proc.exited.then((code) => this.statusCb(code === 0 ? "offline" : "error"));
|
|
102
|
+
void proc.exited.then((code) => this.statusCb(code === 0 ? "offline" : "error")).catch(() => this.statusCb("error")).catch(() => {});
|
|
78
103
|
return { pid: proc.pid, process: proc, meta: { monitor: config.monitor, env: args.env } };
|
|
79
104
|
}
|
|
80
105
|
|
|
@@ -82,7 +107,7 @@ export class ClaudeAdapter implements ProviderAdapter {
|
|
|
82
107
|
const tmuxSession = process.meta?.tmuxSession as string | undefined;
|
|
83
108
|
const tmuxSocket = process.meta?.tmuxSocket as string | undefined;
|
|
84
109
|
if (tmuxSession) {
|
|
85
|
-
await this.shutdownTmux(tmuxSession, opts, tmuxSocket);
|
|
110
|
+
await this.shutdownTmux(process, tmuxSession, opts, tmuxSocket);
|
|
86
111
|
return;
|
|
87
112
|
}
|
|
88
113
|
await terminateSingleProcess(process, opts);
|
|
@@ -101,16 +126,44 @@ export class ClaudeAdapter implements ProviderAdapter {
|
|
|
101
126
|
const session = process.meta?.tmuxSession as string | undefined;
|
|
102
127
|
const socket = process.meta?.tmuxSocket as string | undefined;
|
|
103
128
|
if (!session || !tmuxHasSession(session, socket)) throw new Error("no active tmux session for clear");
|
|
129
|
+
// #1588 — snapshot the pane BEFORE submitting `/clear` so the clear-aware barrier can detect
|
|
130
|
+
// the loaded conversation body disappearing (positive clear-commit evidence), not just the
|
|
131
|
+
// ever-present footer. Best-effort: a capture failure just yields no anchor → clearConfirmed
|
|
132
|
+
// falls to false → backstop covers.
|
|
133
|
+
let preClearPane = "";
|
|
134
|
+
try { preClearPane = captureTmuxPane(session, socket); } catch {}
|
|
104
135
|
await submitTextToTmux(session, "/clear", socket);
|
|
105
|
-
|
|
136
|
+
// #1588 — `/clear` is async in the Claude TUI: submitTextToTmux returns as soon as the keys
|
|
137
|
+
// are typed, before the conversation is actually cleared. The self-resume continuation used to
|
|
138
|
+
// race this still-processing clear (a separate command, ~8ms later, on a different transport)
|
|
139
|
+
// and get swallowed — a silent mind-wipe. The OLD barrier (waitForClaudeInputReady) accepted
|
|
140
|
+
// the first persistent-footer match, which is present on the STILL-UNCLEARED pane too — so it
|
|
141
|
+
// could return before the clear committed. Use the CLEAR-AWARE barrier instead: it requires
|
|
142
|
+
// positive evidence the clear committed (pane idle + input-ready + the pre-clear conversation
|
|
143
|
+
// anchor gone). `clearConfirmed` is surfaced to the coupled deliver: only a genuine confirmation
|
|
144
|
+
// permits continuationDelivered:true; a timeout/uncertain settle → false → the relay's #1023
|
|
145
|
+
// backstop re-injects. Never throws (the clear already ran); uncertainty resolves to false.
|
|
146
|
+
const clearConfirmed = await waitForClaudeClearCommitted(session, preClearPane, CLAUDE_TMUX_READY_TIMEOUT_MS, socket);
|
|
147
|
+
return { method: "tmux-inject", command: "/clear", clearConfirmed };
|
|
106
148
|
}
|
|
107
149
|
|
|
150
|
+
// #1512: Claude reports context only through its out-of-band status-line probe,
|
|
151
|
+
// which freezes after an in-process compact/clear/native-resume. The transcript
|
|
152
|
+
// (handed to the runner every turn by the Stop hook) carries the same per-turn
|
|
153
|
+
// token usage, so the runner reads it here as a probe-independent fallback that
|
|
154
|
+
// keeps context stats advancing across those in-process generation bumps.
|
|
155
|
+
readonly contextUsageFromTranscript = extractLatestContextUsage;
|
|
156
|
+
|
|
108
157
|
async interrupt(process: ManagedProcess): Promise<Record<string, unknown>> {
|
|
109
158
|
const session = process.meta?.tmuxSession as string | undefined;
|
|
110
159
|
const socket = process.meta?.tmuxSocket as string | undefined;
|
|
111
160
|
if (!session || !tmuxHasSession(session, socket)) throw new Error("no active tmux session to interrupt");
|
|
112
161
|
// The same ESC the web terminal's aux key sends: cancels the in-flight turn
|
|
113
|
-
// and drops Claude back to its input box without ending the session.
|
|
162
|
+
// and drops Claude back to its input box without ending the session. #1621: mark the probe's
|
|
163
|
+
// pre-cancel busy verdict non-authoritative so the runner's post-interrupt reconcile can
|
|
164
|
+
// actually see the idle pane (a cancel writes no probe transition, so without this the
|
|
165
|
+
// reconcile read the frozen `busy`/`shell` and left the claim standing).
|
|
166
|
+
this.paneInterruptAt = Date.now();
|
|
114
167
|
const result = Bun.spawnSync(tmuxCommand(socket, "send-keys", "-t", session, "Escape"), {
|
|
115
168
|
stdin: "ignore", stdout: "ignore", stderr: "pipe",
|
|
116
169
|
});
|
|
@@ -128,6 +181,19 @@ export class ClaudeAdapter implements ProviderAdapter {
|
|
|
128
181
|
return this.activityFromProbeOrPane(process, session, socket);
|
|
129
182
|
}
|
|
130
183
|
|
|
184
|
+
// #1138: a tmux-launched Claude session's ManagedProcess carries no direct OS pid
|
|
185
|
+
// (process.pid is undefined — only the tmux session/socket), so the runner's #650
|
|
186
|
+
// pid-liveness fallback had nothing to check for an ordinary hook-driven turn. Resolve
|
|
187
|
+
// the tmux pane's real pid so a long, quiet (no subagents, no compaction) busy turn can
|
|
188
|
+
// prove it is still alive instead of being force-healed to idle by the runner's
|
|
189
|
+
// stale-progress backstop purely because 10 minutes passed with no interim signal.
|
|
190
|
+
resolveBusyPid(process: ManagedProcess): number | undefined {
|
|
191
|
+
const session = process.meta?.tmuxSession as string | undefined;
|
|
192
|
+
if (!session) return undefined;
|
|
193
|
+
const socket = process.meta?.tmuxSocket as string | undefined;
|
|
194
|
+
return resolveClaudePid(process.pid, session, socket);
|
|
195
|
+
}
|
|
196
|
+
|
|
131
197
|
// Shared probe-primary / pane-fallback activity read (#766/#768), used by both the
|
|
132
198
|
// reconciler-facing probeActivity and the monitorless turn-watch. Primary: Claude
|
|
133
199
|
// Code's intrinsic session probe (busy/shell→busy — the idle-while-working gap the
|
|
@@ -140,27 +206,33 @@ export class ClaudeAdapter implements ProviderAdapter {
|
|
|
140
206
|
tmuxSocket: socket,
|
|
141
207
|
configHomeEnv: (process.meta?.env as Record<string, string | undefined> | undefined)?.CLAUDE_CONFIG_DIR,
|
|
142
208
|
});
|
|
143
|
-
|
|
144
|
-
|
|
209
|
+
// #1621: a busy probe that hasn't been rewritten inside its trust window may be describing a
|
|
210
|
+
// turn that was already cancelled out of band (no transition → no rewrite). It keeps the fast
|
|
211
|
+
// path (no pane capture) only while fresh; once stale it has to face the pane.
|
|
212
|
+
const { activity: probe, busyStale } = claudeProbeVerdict(managed?.reading, Date.now(), { paneInterruptAt: this.paneInterruptAt });
|
|
213
|
+
if (probe === "busy" && !busyStale) return "busy";
|
|
145
214
|
let pane: string | undefined;
|
|
146
215
|
try { pane = captureTmuxPane(session, socket); } catch { return probe ?? "unknown"; }
|
|
147
|
-
return claudeActivityFromProbeAndPane(probe, pane);
|
|
216
|
+
return claudeActivityFromProbeAndPane(probe, pane, { probeBusyStale: busyStale });
|
|
148
217
|
}
|
|
149
218
|
|
|
150
|
-
// #769 P1-A: an independent pane-busy read
|
|
151
|
-
//
|
|
152
|
-
// turn
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
|
|
219
|
+
// #769 P1-A: an independent pane-busy read consumed by the busy backstop — a probe-`idle`
|
|
220
|
+
// must never clear a live turn while the pane spinner still reads busy (a connection-retry/529
|
|
221
|
+
// can write status:idle mid-turn without tripping the rate-limit pane detector — the #769 gap).
|
|
222
|
+
// probeActivity stays probe-primary; this is a corroboration channel only.
|
|
223
|
+
//
|
|
224
|
+
// #1656: this used to answer `false` for all three of "captured, no spinner", "no tmux session"
|
|
225
|
+
// and "capture threw", and the backstop read that single `false` as permission to clear a live
|
|
226
|
+
// turn. A read that failed is a fact about the read, not about the provider — it is reported as
|
|
227
|
+
// such now, and the backstop refuses to act on it.
|
|
228
|
+
async probePaneBusy(process: ManagedProcess): Promise<PaneBusyReading> {
|
|
157
229
|
const session = process.meta?.tmuxSession as string | undefined;
|
|
158
230
|
const socket = process.meta?.tmuxSocket as string | undefined;
|
|
159
|
-
if (!session || !tmuxHasSession(session, socket)) return
|
|
231
|
+
if (!session || !tmuxHasSession(session, socket)) return "no-session";
|
|
160
232
|
try {
|
|
161
|
-
return claudePaneIsBusy(captureTmuxPane(session, socket));
|
|
233
|
+
return claudePaneIsBusy(captureTmuxPane(session, socket)) ? "busy" : "not-busy";
|
|
162
234
|
} catch {
|
|
163
|
-
return
|
|
235
|
+
return "unreadable";
|
|
164
236
|
}
|
|
165
237
|
}
|
|
166
238
|
|
|
@@ -175,6 +247,20 @@ export class ClaudeAdapter implements ProviderAdapter {
|
|
|
175
247
|
// transient startup races) to avoid double-delivering against a late monitor.
|
|
176
248
|
const monitorless = process.meta?.monitorless === true;
|
|
177
249
|
if (!monitorless && monitor?.deliver) {
|
|
250
|
+
// #1588 F2 edge 2 — the monitor deliver path had NO settle re-check (unlike the tmux
|
|
251
|
+
// fallback below), so a self-resume continuation delivered via the monitor (the victim
|
|
252
|
+
// coordinator's exact profile) could land into a not-yet-settled post-clear context. For
|
|
253
|
+
// self-resume continuation deliveries, wait for the TUI to be input-ready first. The coupled
|
|
254
|
+
// clearContext clear-aware barrier is the authoritative clear-commit gate; this belt-and-
|
|
255
|
+
// suspenders settle also protects the SEPARATE relay-backstop inject (which does NOT run
|
|
256
|
+
// through clearContext) so it too lands into a ready pane. Best-effort: a settle timeout must
|
|
257
|
+
// not fail the delivery — the backstop path already IS the recovery, and the coupled path's
|
|
258
|
+
// continuationDelivered truthfulness is decided by the clearContext barrier, not here.
|
|
259
|
+
const session = process.meta?.tmuxSession as string | undefined;
|
|
260
|
+
const socket = process.meta?.tmuxSocket as string | undefined;
|
|
261
|
+
if (session && messagesAreSelfResumeContinuation(messages) && tmuxHasSession(session, socket)) {
|
|
262
|
+
await waitForClaudeInputReady(session, CLAUDE_TMUX_READY_TIMEOUT_MS, socket).catch(() => {});
|
|
263
|
+
}
|
|
178
264
|
await monitor.deliver(messages);
|
|
179
265
|
return;
|
|
180
266
|
}
|
|
@@ -208,10 +294,21 @@ export class ClaudeAdapter implements ProviderAdapter {
|
|
|
208
294
|
// process itself isn't tracked here — pid is undefined for tmux-managed claude).
|
|
209
295
|
// A dead pid means the turn is definitively over, so this can't falsely clear.
|
|
210
296
|
const livenessPid = resolveClaudePid(process.pid, sessionName, socketName);
|
|
297
|
+
// #1235 — monitorless workers have no Stop/UserPromptSubmit hooks, so the transcript
|
|
298
|
+
// reasoning tail is never hook-triggered and chat stays empty. Resolve the live transcript
|
|
299
|
+
// path from the intrinsic session probe (sessionId + cwd) and hand it to the runner on the
|
|
300
|
+
// turn edges so it can drive the SAME reasoning tail natively. undefined on an old/absent
|
|
301
|
+
// probe → runner skips native capture (graceful degrade to the prior no-chat behavior).
|
|
302
|
+
const transcriptPath = resolveManagedClaudeTranscriptPath({
|
|
303
|
+
pid: process.pid,
|
|
304
|
+
tmuxSession: sessionName,
|
|
305
|
+
tmuxSocket: socketName,
|
|
306
|
+
configHomeEnv: (process.meta?.env as Record<string, string | undefined> | undefined)?.CLAUDE_CONFIG_DIR,
|
|
307
|
+
});
|
|
211
308
|
// We just submitted a prompt, so a turn is starting. Emit a synthetic
|
|
212
309
|
// provider-turn busy now so the runner arms task-claim completion; the claim
|
|
213
310
|
// for this delivery is already registered before deliver() runs.
|
|
214
|
-
this.statusCb({ status: "busy", reason: "provider-turn", id: CLAUDE_TURN_WATCH_ID, ...(livenessPid ? { pids: [livenessPid] } : {}) });
|
|
311
|
+
this.statusCb({ status: "busy", reason: "provider-turn", id: CLAUDE_TURN_WATCH_ID, ...(livenessPid ? { pids: [livenessPid] } : {}), ...(transcriptPath ? { transcriptPath } : {}) });
|
|
215
312
|
let ticks = 0;
|
|
216
313
|
let idleStreak = 0;
|
|
217
314
|
let sawBusy = false;
|
|
@@ -239,7 +336,9 @@ export class ClaudeAdapter implements ProviderAdapter {
|
|
|
239
336
|
const turnObserved = sawBusy || ticks >= CLAUDE_TURN_QUICK_GRACE_TICKS;
|
|
240
337
|
if (confirmedIdle && turnObserved) {
|
|
241
338
|
this.stopTurnWatch();
|
|
242
|
-
|
|
339
|
+
// Carry the transcript path on the idle edge too (#1235) so the runner can capture the
|
|
340
|
+
// turn's final assistant response natively before it tears down the reasoning tail.
|
|
341
|
+
this.statusCb({ status: "idle", reason: "provider-turn", id: CLAUDE_TURN_WATCH_ID, ...(transcriptPath ? { transcriptPath } : {}) });
|
|
243
342
|
}
|
|
244
343
|
}, CLAUDE_TURN_POLL_MS);
|
|
245
344
|
}
|
|
@@ -328,8 +427,12 @@ export class ClaudeAdapter implements ProviderAdapter {
|
|
|
328
427
|
}
|
|
329
428
|
|
|
330
429
|
buildTmuxArgs(config: RunnerSpawnConfig, spawnArgs: SpawnArgs): { sessionName: string; socketName: string; args: string[]; launcherScript: string; gateFifo?: string } {
|
|
331
|
-
const
|
|
332
|
-
|
|
430
|
+
const requestedSessionName = config.tmuxSession || tmuxSessionName(config.providerConfig.headless.tmuxPrefix, config.instanceId, config.label);
|
|
431
|
+
// Keep the socket derived from the orchestrator's stable identity, but carry
|
|
432
|
+
// tmux's rendered session name everywhere we later target the pane. Without
|
|
433
|
+
// this, `opus-4.8` is created as `opus-4_8` and every watcher/restart lookup
|
|
434
|
+
// immediately misses it.
|
|
435
|
+
const socketName = tmuxSocketName(requestedSessionName); const sessionName = tmuxSessionTargetName(requestedSessionName);
|
|
333
436
|
const shellCmd = [spawnArgs.command, ...spawnArgs.args].map(shellQuote).join(" ");
|
|
334
437
|
const tmuxArgs = ["new-session", "-d", "-s", sessionName, "-x", "200", "-y", "50"];
|
|
335
438
|
|
|
@@ -352,7 +455,8 @@ export class ClaudeAdapter implements ProviderAdapter {
|
|
|
352
455
|
|
|
353
456
|
private async spawnHeadless(config: RunnerSpawnConfig, spawnArgs: SpawnArgs): Promise<ManagedProcess> {
|
|
354
457
|
const { sessionName, socketName, args: tmuxArgs, launcherScript, gateFifo } = this.buildTmuxArgs(config, spawnArgs);
|
|
355
|
-
|
|
458
|
+
const providerEpoch = ++this.providerEpoch;
|
|
459
|
+
this.modelUnavailableObservationState = initialClaudeModelUnavailableObservationState();
|
|
356
460
|
this.connectionRetryActive = false;
|
|
357
461
|
this.promptGatePaneState = initialClaudePromptGatePaneState();
|
|
358
462
|
|
|
@@ -389,6 +493,7 @@ export class ClaudeAdapter implements ProviderAdapter {
|
|
|
389
493
|
tmuxSession: sessionName,
|
|
390
494
|
tmuxSocket: socketName,
|
|
391
495
|
launcherScript,
|
|
496
|
+
providerEpoch,
|
|
392
497
|
},
|
|
393
498
|
};
|
|
394
499
|
}
|
|
@@ -409,86 +514,89 @@ export class ClaudeAdapter implements ProviderAdapter {
|
|
|
409
514
|
clearInterval(watcher);
|
|
410
515
|
this.tmuxWatcher = undefined;
|
|
411
516
|
this.statusCb("offline");
|
|
412
|
-
});
|
|
517
|
+
}).catch(() => { goneConfirmationPending = false; });
|
|
413
518
|
return;
|
|
414
519
|
}
|
|
415
|
-
if (this.modelUnavailableReported) return;
|
|
416
520
|
let pane = "";
|
|
417
521
|
try {
|
|
418
522
|
pane = captureTmuxPane(sessionName, socketName);
|
|
419
523
|
} catch {
|
|
420
524
|
return;
|
|
421
525
|
}
|
|
422
|
-
const
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
//
|
|
429
|
-
//
|
|
430
|
-
|
|
431
|
-
|
|
526
|
+
const modelUnavailable = debounceClaudeModelUnavailableObservation(
|
|
527
|
+
claudeModelUnavailableObservation(pane, sessionName), this.modelUnavailableObservationState,
|
|
528
|
+
);
|
|
529
|
+
this.modelUnavailableObservationState = modelUnavailable.state;
|
|
530
|
+
if (modelUnavailable.observation) this.paneObservationCb(modelUnavailable.observation);
|
|
531
|
+
// #769/#1651: connection-retry banner detection, advisory. BOTH edges are diagnostics.
|
|
532
|
+
// Appearance used to publish an idle + blocked hold, which the runner reads as the
|
|
533
|
+
// provider turn ENDING — completing the task claims of a turn that was still running.
|
|
534
|
+
// Disappearance used to publish a bare `idle`, an unconditional turn-end with no evidence
|
|
535
|
+
// behind it at all. Neither edge touches statusCb now; the flag exists only to keep the
|
|
536
|
+
// diagnostic edge-triggered rather than repeating every 2s tick.
|
|
537
|
+
const connRetry = claudeConnectionRetryObservation(pane, sessionName);
|
|
538
|
+
if (connRetry) {
|
|
432
539
|
if (!this.connectionRetryActive) {
|
|
433
540
|
this.connectionRetryActive = true;
|
|
434
|
-
this.
|
|
541
|
+
this.paneObservationCb(connRetry);
|
|
435
542
|
}
|
|
436
543
|
} else if (this.connectionRetryActive) {
|
|
437
544
|
this.connectionRetryActive = false;
|
|
438
|
-
this.
|
|
545
|
+
this.paneObservationCb(claudeConnectionRetryClearedObservation(sessionName));
|
|
439
546
|
}
|
|
440
547
|
// #663: prompt-gate fallback. Known gates should be prevented before launch;
|
|
441
548
|
// this catches missed/new blocking panes with the same #655 debounce + busy
|
|
442
549
|
// guard used for rate-limit holds.
|
|
550
|
+
// #1651: it NOTICES, it does not act. The five startup/modal gates are advisory and
|
|
551
|
+
// return no status at all, so the only thing this branch can do for them is publish a
|
|
552
|
+
// diagnostic. `status` is present solely for the usage-limit gate.
|
|
443
553
|
const promptGate = evaluateClaudePromptGatePane(pane, this.promptGatePaneState, {
|
|
444
554
|
sessionName,
|
|
445
555
|
busy: claudePaneIsBusy(pane),
|
|
446
556
|
sourceId: this.paneSourceId,
|
|
447
557
|
});
|
|
448
558
|
this.promptGatePaneState = promptGate.state;
|
|
449
|
-
if (promptGate.
|
|
450
|
-
|
|
451
|
-
if (promptGate.status) this.statusCb(promptGate.status);
|
|
452
|
-
}
|
|
559
|
+
if (promptGate.observation) this.paneObservationCb(promptGate.observation);
|
|
560
|
+
if (promptGate.status) this.statusCb(promptGate.status);
|
|
453
561
|
}, 2000);
|
|
454
562
|
this.tmuxWatcher = watcher;
|
|
455
563
|
}
|
|
456
564
|
|
|
457
|
-
//
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
private async shutdownTmux(sessionName: string, opts: { graceful: boolean; timeoutMs: number }, socketName?: string): Promise<void> {
|
|
565
|
+
// #1647 — the shutdown sequence (observe → correlate → maybe /exit) lives in
|
|
566
|
+
// ./claude-shutdown. Only the watcher teardown is the adapter's own business.
|
|
567
|
+
async shutdownTmux(
|
|
568
|
+
process: ManagedProcess,
|
|
569
|
+
sessionName: string,
|
|
570
|
+
opts: { graceful: boolean; timeoutMs: number },
|
|
571
|
+
socketName?: string,
|
|
572
|
+
deps: ClaudeShutdownDeps = {},
|
|
573
|
+
): Promise<void> {
|
|
467
574
|
this.stopTurnWatch();
|
|
468
575
|
if (this.tmuxWatcher) {
|
|
469
576
|
clearInterval(this.tmuxWatcher);
|
|
470
577
|
this.tmuxWatcher = undefined;
|
|
471
578
|
}
|
|
472
|
-
|
|
473
|
-
await submitTextToTmux(sessionName, CLAUDE_EXIT_COMMAND, socketName);
|
|
474
|
-
|
|
475
|
-
const deadline = Date.now() + claudeShutdownGraceMs(opts.timeoutMs);
|
|
476
|
-
while (Date.now() < deadline) {
|
|
477
|
-
if (!tmuxHasSession(sessionName, socketName)) return;
|
|
478
|
-
await Bun.sleep(200);
|
|
479
|
-
}
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
Bun.spawnSync(tmuxCommand(socketName, "kill-session", "-t", sessionName), {
|
|
483
|
-
stdin: "ignore", stdout: "ignore", stderr: "ignore",
|
|
484
|
-
});
|
|
579
|
+
await shutdownClaudeTmux(process, sessionName, opts, socketName, this.providerEpoch, deps);
|
|
485
580
|
}
|
|
486
581
|
}
|
|
487
582
|
|
|
488
|
-
|
|
583
|
+
// #1588 — a self-resume continuation delivery carries the resumeInjection/selfResume markers the
|
|
584
|
+
// runner stamps in injectContext. Used to scope the monitor-path settle re-check to ONLY those
|
|
585
|
+
// deliveries so ordinary message delivery is unchanged.
|
|
586
|
+
function messagesAreSelfResumeContinuation(messages: Message[]): boolean {
|
|
587
|
+
return messages.some((message) => {
|
|
588
|
+
const payload = message.payload as Record<string, unknown> | undefined;
|
|
589
|
+
return payload?.resumeInjection === true || Boolean(payload?.selfResume);
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// #1647 — the exit command and its two writers now live in ./claude-exit, behind the
|
|
594
|
+
// one-shot grant. Re-exported so existing `./claude` consumers + tests keep their path.
|
|
595
|
+
export { CLAUDE_EXIT_COMMAND } from "./claude-exit";
|
|
596
|
+
export { claudeShutdownGraceMs, shutdownClaudeTmux, observeClaudeExitState, type ClaudeShutdownDeps } from "./claude-shutdown";
|
|
489
597
|
// tmux transport plumbing moved to ./claude-tmux (#300 ratchet); re-exported so
|
|
490
598
|
// existing `./claude` consumers + tests keep their import paths.
|
|
491
|
-
export { CLAUDE_TMUX_SUBMIT_DELAY_MS, CLAUDE_TMUX_SUBMIT_KEY, CLAUDE_TMUX_READY_TIMEOUT_MS, tmuxSessionName, tmuxSocketName, tmuxSocketPath, assertTmuxSocketPathFits, tmuxEnvKeys, waitForClaudeInputReady, submitTextToTmux } from "./claude-tmux";
|
|
599
|
+
export { CLAUDE_TMUX_SUBMIT_DELAY_MS, CLAUDE_TMUX_SUBMIT_KEY, CLAUDE_TMUX_READY_TIMEOUT_MS, CLAUDE_CLEAR_COMMIT_POLL_MS, tmuxSessionName, tmuxSocketName, tmuxSocketPath, assertTmuxSocketPathFits, tmuxEnvKeys, waitForClaudeInputReady, waitForClaudeClearCommitted, submitTextToTmux } from "./claude-tmux";
|
|
492
600
|
// Monitorless turn detection (Fix: isolated agents reach task "done" like Codex).
|
|
493
601
|
const CLAUDE_TURN_WATCH_ID = "tmux-turn";
|
|
494
602
|
const CLAUDE_TURN_POLL_MS = 1500;
|
|
@@ -496,11 +604,6 @@ const CLAUDE_TURN_IDLE_CONFIRM_TICKS = 3; // ~4.5s of stable idle before declar
|
|
|
496
604
|
const CLAUDE_TURN_QUICK_GRACE_TICKS = 8; // ~12s fallback when the spinner was never caught
|
|
497
605
|
const CLAUDE_TURN_MAX_TICKS = 3600; // ~90min safety cap so the timer never leaks
|
|
498
606
|
|
|
499
|
-
export function claudeShutdownGraceMs(timeoutMs: number): number {
|
|
500
|
-
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return 10_000;
|
|
501
|
-
return Math.min(timeoutMs, 10_000);
|
|
502
|
-
}
|
|
503
|
-
|
|
504
607
|
// #557 — sessionStatusLineSettingsArgs now lives in ../launch-assembly (the single
|
|
505
608
|
// launch-arg home); re-exported so existing ./claude consumers + tests keep their path.
|
|
506
609
|
export { sessionStatusLineSettingsArgs };
|
|
@@ -551,7 +654,7 @@ export { tmuxHasSession };
|
|
|
551
654
|
|
|
552
655
|
// Re-export pane-status detectors (moved to ./claude-status-detectors) so existing
|
|
553
656
|
// import paths (including claude.test.ts) continue to resolve via this module.
|
|
554
|
-
export { claudeActivityFromProbeAndPane, claudePaneLooksReady, claudePaneIsBusy, claudeRateLimitStatus,
|
|
657
|
+
export { claudeActivityFromProbeAndPane, claudePaneLooksReady, claudePaneIsBusy, claudeConversationAnchor, claudeClearCommitted, claudeRateLimitStatus, claudeConnectionRetryObservation, claudeConnectionRetryClearedObservation, claudeModelUnavailableObservation, debounceClaudeModelUnavailableObservation, initialClaudeModelUnavailableObservationState } from "./claude-status-detectors";
|
|
555
658
|
|
|
556
659
|
// Shared shell-quoting; re-exported so `./claude` consumers + tests resolve it.
|
|
557
660
|
export { shellQuote };
|