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.
@@ -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 "";
@@ -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 { claudeProbeActivity, readManagedClaudeStatus, resolveClaudePid, type ClaudeProbeActivity } from "./claude-session-probe";
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, claudeConnectionRetryStatus, claudeModelUnavailableStatus } from "./claude-status-detectors";
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 { CLAUDE_TMUX_READY_TIMEOUT_MS, captureTmuxPane, confirmTmuxSessionGone, createLauncherGate, createOrReuseTmuxSession, releaseLauncherGate, submitTextToTmux, tmuxEnvKeys, tmuxSessionName, tmuxSocketName, waitForClaudeInputReady, writeLauncherScript } from "./claude-tmux";
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 modelUnavailableReported = false;
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
- return { method: "tmux-inject", command: "/clear" };
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
- const probe = managed?.reading.available ? claudeProbeActivity(managed.reading.status) : undefined;
144
- if (probe === "busy") return "busy";
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, consumed ONLY as a veto on the busy
151
- // backstop's force-clear (BusyReconciler) a probe-`idle` must never clear a live
152
- // turn while the pane spinner still reads busy (a connection-retry/529 can write
153
- // status:idle mid-turn without tripping the rate-limit pane detector — the #769
154
- // gap). probeActivity stays probe-primary; this is a clear-time veto-toward-busy
155
- // only. false on no session / capture failure (no veto).
156
- async probePaneBusy(process: ManagedProcess): Promise<boolean> {
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 false;
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 false;
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
- this.statusCb({ status: "idle", reason: "provider-turn", id: CLAUDE_TURN_WATCH_ID });
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 sessionName = config.tmuxSession || tmuxSessionName(config.providerConfig.headless.tmuxPrefix, config.instanceId, config.label);
332
- const socketName = tmuxSocketName(sessionName);
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
- this.modelUnavailableReported = false;
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 status = claudeModelUnavailableStatus(pane, sessionName);
423
- if (status) {
424
- this.modelUnavailableReported = true;
425
- this.statusCb(status);
426
- return;
427
- }
428
- // #769: connection-retry banner detection. Emits a transient blocked hold while the
429
- // pane shows the retry footer; clears to idle when the banner disappears (API recovered).
430
- const connRetryStatus = claudeConnectionRetryStatus(pane, sessionName);
431
- if (connRetryStatus) {
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.statusCb(connRetryStatus);
541
+ this.paneObservationCb(connRetry);
435
542
  }
436
543
  } else if (this.connectionRetryActive) {
437
544
  this.connectionRetryActive = false;
438
- this.statusCb("idle");
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.action) {
450
- this.sendPromptGateKeystroke(sessionName, promptGate.action.keystroke, socketName);
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
- // Best-effort: a failed auto-dismiss must not wedge the pane watcher.
458
- private sendPromptGateKeystroke(sessionName: string, keystroke: string, socketName?: string): void {
459
- try {
460
- Bun.spawnSync(tmuxCommand(socketName, "send-keys", "-t", sessionName, keystroke), { stdin: "ignore", stdout: "ignore", stderr: "ignore" });
461
- } catch {
462
- // ignore; the timeline/status still publishes, and the next watcher tick can observe whether the pane moved.
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
- if (opts.graceful && tmuxHasSession(sessionName, socketName)) {
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
- export const CLAUDE_EXIT_COMMAND = "/exit";
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, claudeConnectionRetryStatus, claudeModelUnavailableStatus } from "./claude-status-detectors";
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 };