agent-relay-runner 0.127.7 → 0.127.9

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-runner",
3
- "version": "0.127.7",
3
+ "version": "0.127.9",
4
4
  "description": "Unified provider lifecycle runner for Agent Relay",
5
5
  "type": "module",
6
6
  "bin": {
@@ -23,7 +23,7 @@
23
23
  },
24
24
  "dependencies": {
25
25
  "agent-relay-providers": "0.104.4",
26
- "agent-relay-sdk": "0.2.121",
26
+ "agent-relay-sdk": "0.2.122",
27
27
  "callmux": "0.23.0"
28
28
  },
29
29
  "devDependencies": {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agent-relay-runner",
3
3
  "description": "Thin Agent Relay runner bridge for Claude Code",
4
- "version": "0.127.7",
4
+ "version": "0.127.9",
5
5
  "agentRelayContracts": {
6
6
  "providerPluginProtocol": 1
7
7
  }
@@ -56,8 +56,8 @@ var RUNNER_PROMPT_TEMPLATE_DEFAULTS = [
56
56
  category: "runner",
57
57
  title: "Runner Workspace Lifecycle Note",
58
58
  description: "Lifecycle note injected by the runner for isolated workspaces.",
59
- template: lines("[agent-relay] Isolated workspace: you are in a git worktree on branch {{branch}}, based on {{baseRef}} \u2014 NOT the main checkout. Other agents may work in parallel and land to {{baseRef}}, so {{baseRef}} will move under you. That is expected; don't fight it.", "Do NOT push this branch yourself \u2014 not with `git push`, not with `tl push` or any other push wrapper, and do not manually rebase or merge. A steward may be auto-rebasing this branch in the background; pushing concurrently races it and can leave the worktree mid-rebase. Just commit your work here. When the task is done, call `relay_task_deliver` with disposition `complete`, a summary, commits, and passing `typecheck` + `test` gates. Relay records the typed delivery on the Task, then rebases onto the latest {{baseRef}}, lands your work, and pushes for you.", "After `relay_task_deliver` accepts a complete delivery, the workspace status becomes `ready` \u2014 this is the NORMAL, healthy hand-off state, NOT an escalation or a stall. Relay auto-merges clean rebases roughly every 2 minutes; a steward agent is spawned (after a short delay) ONLY if it can't land deterministically, so seeing no steward means it's working, not stuck. Stop your turn after delivery and go idle; do NOT wait or poll the steward queue. Relay wakes you with `landed-success` when your branch lands and refreshes, or `landed-failure` if landing fatally fails. On success you'll be on a fresh rebased branch whose name gains a `--N` suffix \u2014 expected, keep working there. Never `cd` into the main checkout, and never merge/push/resolve conflicts yourself \u2014 Relay and the steward own all of that. `agent-relay workspace status` anytime shows your current state and the exact next step."),
60
- defaultTemplate: lines("[agent-relay] Isolated workspace: you are in a git worktree on branch {{branch}}, based on {{baseRef}} \u2014 NOT the main checkout. Other agents may work in parallel and land to {{baseRef}}, so {{baseRef}} will move under you. That is expected; don't fight it.", "Do NOT push this branch yourself \u2014 not with `git push`, not with `tl push` or any other push wrapper, and do not manually rebase or merge. A steward may be auto-rebasing this branch in the background; pushing concurrently races it and can leave the worktree mid-rebase. Just commit your work here. When the task is done, call `relay_task_deliver` with disposition `complete`, a summary, commits, and passing `typecheck` + `test` gates. Relay records the typed delivery on the Task, then rebases onto the latest {{baseRef}}, lands your work, and pushes for you.", "After `relay_task_deliver` accepts a complete delivery, the workspace status becomes `ready` \u2014 this is the NORMAL, healthy hand-off state, NOT an escalation or a stall. Relay auto-merges clean rebases roughly every 2 minutes; a steward agent is spawned (after a short delay) ONLY if it can't land deterministically, so seeing no steward means it's working, not stuck. Stop your turn after delivery and go idle; do NOT wait or poll the steward queue. Relay wakes you with `landed-success` when your branch lands and refreshes, or `landed-failure` if landing fatally fails. On success you'll be on a fresh rebased branch whose name gains a `--N` suffix \u2014 expected, keep working there. Never `cd` into the main checkout, and never merge/push/resolve conflicts yourself \u2014 Relay and the steward own all of that. `agent-relay workspace status` anytime shows your current state and the exact next step."),
59
+ template: lines("[agent-relay] Isolated workspace: you are in a git worktree on branch {{branch}}, based on {{baseRef}} \u2014 NOT the main checkout. Other agents may work in parallel and land to {{baseRef}}, so {{baseRef}} will move under you. That is expected; don't fight it.", "Do NOT push this branch yourself \u2014 not with `git push`, not with `tl push` or any other push wrapper, and do not manually rebase or merge. A steward may be auto-rebasing this branch in the background; pushing concurrently races it and can leave the worktree mid-rebase. Just commit your work here. When the task is done, call `relay_task_deliver` with disposition `complete`, a summary, commits, and passing `typecheck` + `test` gates when the diff touches code/test/config paths. Relay records the typed delivery on the Task, skips code gates for empty/docs-only diffs, then rebases onto the latest {{baseRef}}, lands your work, and pushes for you.", "After `relay_task_deliver` accepts a complete delivery, the workspace status becomes `ready` \u2014 this is the NORMAL, healthy hand-off state, NOT an escalation or a stall. Relay auto-merges clean rebases roughly every 2 minutes; a steward agent is spawned (after a short delay) ONLY if it can't land deterministically, so seeing no steward means it's working, not stuck. Stop your turn after delivery and go idle; do NOT wait or poll the steward queue. Relay wakes you with `landed-success` when your branch lands and refreshes, or `landed-failure` if landing fatally fails. On success you'll be on a fresh rebased branch whose name gains a `--N` suffix \u2014 expected, keep working there. Never `cd` into the main checkout, and never merge/push/resolve conflicts yourself \u2014 Relay and the steward own all of that. `agent-relay workspace status` anytime shows your current state and the exact next step."),
60
+ defaultTemplate: lines("[agent-relay] Isolated workspace: you are in a git worktree on branch {{branch}}, based on {{baseRef}} \u2014 NOT the main checkout. Other agents may work in parallel and land to {{baseRef}}, so {{baseRef}} will move under you. That is expected; don't fight it.", "Do NOT push this branch yourself \u2014 not with `git push`, not with `tl push` or any other push wrapper, and do not manually rebase or merge. A steward may be auto-rebasing this branch in the background; pushing concurrently races it and can leave the worktree mid-rebase. Just commit your work here. When the task is done, call `relay_task_deliver` with disposition `complete`, a summary, commits, and passing `typecheck` + `test` gates when the diff touches code/test/config paths. Relay records the typed delivery on the Task, skips code gates for empty/docs-only diffs, then rebases onto the latest {{baseRef}}, lands your work, and pushes for you.", "After `relay_task_deliver` accepts a complete delivery, the workspace status becomes `ready` \u2014 this is the NORMAL, healthy hand-off state, NOT an escalation or a stall. Relay auto-merges clean rebases roughly every 2 minutes; a steward agent is spawned (after a short delay) ONLY if it can't land deterministically, so seeing no steward means it's working, not stuck. Stop your turn after delivery and go idle; do NOT wait or poll the steward queue. Relay wakes you with `landed-success` when your branch lands and refreshes, or `landed-failure` if landing fatally fails. On success you'll be on a fresh rebased branch whose name gains a `--N` suffix \u2014 expected, keep working there. Never `cd` into the main checkout, and never merge/push/resolve conflicts yourself \u2014 Relay and the steward own all of that. `agent-relay workspace status` anytime shows your current state and the exact next step."),
61
61
  variables: ["branch", "baseRef"]
62
62
  },
63
63
  {
package/src/adapter.ts CHANGED
@@ -92,6 +92,11 @@ export interface ProviderSessionTurnContext {
92
92
 
93
93
  export interface ProviderSessionTurnResult {
94
94
  reasoningSettled?: boolean;
95
+ // The transcript-embedded event time of the exact tool_use anchor that triggered this
96
+ // pre-flush harvest (when resolvable) — a more accurate stamp for a resolved permission
97
+ // prompt's display ordering than the hook's own wall-clock receipt time, which a provider
98
+ // can flush/timestamp its transcript entry after.
99
+ toolUseOccurredAt?: number;
95
100
  }
96
101
 
97
102
  export interface ProviderUserPromptInput {
@@ -167,9 +167,9 @@ function claudeAnswerInjectionPrompt(view: InteractivePrompt, decision: Provider
167
167
  const rows = resolvedQuestionRows(questions, decision.answers, "(no answer given)");
168
168
  const qa = rows.map((row) => `Q: ${row.question}\nA: ${row.answer}`).join("\n\n");
169
169
  return [
170
- "You previously called AskUserQuestion with the following question(s); it was intercepted so the user could answer directly via Agent Relay instead of through the tool call:",
170
+ "This was answered by the user via the Agent Relay dashboard — the AskUserQuestion tool call itself was intercepted and will not resolve; it will not return a result. Here is their answer:",
171
171
  qa,
172
- "The user has now answered above. Continue the task using their answer(s) — do not call AskUserQuestion again for the same question(s).",
172
+ "Continue the task using their answer(s) above — do not call AskUserQuestion again for the same question(s), and do not wait for the original tool call to return.",
173
173
  ].join("\n\n");
174
174
  }
175
175
 
@@ -13,6 +13,7 @@ import {
13
13
  stepDedupKeys,
14
14
  transcriptHasToolUseAnchor,
15
15
  transcriptLooksComplete,
16
+ transcriptToolUseOccurredAt,
16
17
  } from "./claude-transcript";
17
18
  import { IncrementalClaudeTranscriptTail } from "./claude-transcript-tail";
18
19
 
@@ -23,10 +24,22 @@ interface ReasoningTailState {
23
24
  }
24
25
 
25
26
  const REASONING_POLL_MS = 1_200;
27
+ // Bounded the same way as control-server.ts's resolvedApprovals so a long session's dedup
28
+ // guard (see emittedStepKeys) can't grow without limit.
29
+ const EMITTED_STEP_KEY_LIMIT = 2048;
26
30
 
27
31
  export class ClaudeSessionCapture {
28
32
  private narrativeFlushEntryCount = 0;
29
33
  private reasoningTail?: ReasoningTailState;
34
+ // Step signatures already published to chat — by the deny-then-hold pre-flush harvest or a
35
+ // prior reasoning-tail instance — kept for the life of this capture (not reset when a new
36
+ // tail starts). A fresh tail is created on every new prompt (see startReasoningTail),
37
+ // including the runner's own injected AskUserQuestion answer; it re-reads the transcript
38
+ // from byte 0 and recomputes "latest turn" steps from scratch, so if that first poll lands
39
+ // before the injected prompt's own transcript entry has advanced the "latest turn" window,
40
+ // it can re-derive the exact same step signatures the earlier tail already emitted. Seeding
41
+ // each new tail's `seen` set from this closes that gap regardless of the exact timing.
42
+ private emittedStepKeys = new Set<string>();
30
43
  private sessionEventCb: (event: ProviderSessionEvent) => void = () => {};
31
44
  private sessionEventsCb: (events: ProviderSessionEventBatch) => void = (events) => { for (const event of events) this.sessionEventCb(event); };
32
45
 
@@ -55,7 +68,8 @@ export class ClaudeSessionCapture {
55
68
  (ok) => { if (!ok) ctx.log(`pre-flush session outbox incomplete before blocking control (${ctx.pendingSessionEventCount()} pending)`); },
56
69
  (error) => ctx.log(`pre-flush session outbox failed before blocking control: ${errMessage(error)}`),
57
70
  );
58
- return { reasoningSettled: settled.settled };
71
+ const toolUseOccurredAt = input.toolName ? transcriptToolUseOccurredAt(jsonl, input.toolName, input.toolInput) : undefined;
72
+ return { reasoningSettled: settled.settled, ...(toolUseOccurredAt ? { toolUseOccurredAt } : {}) };
59
73
  }
60
74
 
61
75
  const turnId = input.promptId ?? ctx.turnId;
@@ -129,7 +143,7 @@ export class ClaudeSessionCapture {
129
143
  private startReasoningTail(transcriptPath: string, ctx: ProviderSessionTurnContext): void {
130
144
  if (ctx.reasoningCapture === false) return;
131
145
  this.stopSessionTrace();
132
- const seen = new Set<string>();
146
+ const seen = new Set<string>(this.emittedStepKeys);
133
147
  const emittedNarrationKeys = new Set<string>();
134
148
  const transcriptTail = new IncrementalClaudeTranscriptTail();
135
149
  const turnIdAtStart = ctx.turnId;
@@ -153,6 +167,7 @@ export class ClaudeSessionCapture {
153
167
  for (const { sig, step } of keyed) {
154
168
  if (seen.has(sig)) continue;
155
169
  seen.add(sig);
170
+ this.rememberEmittedStepKey(sig);
156
171
  emitted += 1;
157
172
  events.push({
158
173
  type: step.type,
@@ -177,6 +192,16 @@ export class ClaudeSessionCapture {
177
192
  void poll();
178
193
  }
179
194
 
195
+ private rememberEmittedStepKey(sig: string): void {
196
+ this.emittedStepKeys.delete(sig);
197
+ this.emittedStepKeys.add(sig);
198
+ while (this.emittedStepKeys.size > EMITTED_STEP_KEY_LIMIT) {
199
+ const oldest = this.emittedStepKeys.values().next().value;
200
+ if (oldest === undefined) break;
201
+ this.emittedStepKeys.delete(oldest);
202
+ }
203
+ }
204
+
180
205
  private preFlushBodyAlreadyMirroredByReasoningTail(jsonl: string, body: string): boolean {
181
206
  const tail = this.reasoningTail;
182
207
  if (!tail || !tail.emittedNarrationKeys.size) return false;
@@ -0,0 +1,133 @@
1
+ // Claude tmux transport plumbing (extracted from ./claude for the file-size
2
+ // ratchet, #300/#291): session/socket naming, pane capture, input-ready gating,
3
+ // paste/submit injection, launcher-script materialization, and env-key selection.
4
+ // The adapter (./claude) keeps the lifecycle decisions; this module owns the
5
+ // mechanical tmux interactions they ride on. Public symbols are re-exported from
6
+ // ./claude so existing import paths (including claude.test.ts) keep resolving.
7
+ import { chmodSync, mkdirSync, writeFileSync } from "node:fs";
8
+ import { dirname, join } from "node:path";
9
+ import { sanitizeFsName } from "agent-relay-sdk/fs-name";
10
+ import { shellEscape as shellQuote } from "agent-relay-sdk/shell-utils";
11
+ import { tmuxCommand, tmuxHasSession } from "agent-relay-sdk/tmux-utils";
12
+ import { claudePaneLooksReady } from "./claude-status-detectors";
13
+ import { launcherScriptPathForSession } from "../session-scratch";
14
+
15
+ export const CLAUDE_TMUX_SUBMIT_DELAY_MS = 250;
16
+ export const CLAUDE_TMUX_SUBMIT_KEY = "C-m";
17
+ export const CLAUDE_TMUX_READY_TIMEOUT_MS = 10_000;
18
+
19
+ export function tmuxSessionName(prefix: string, instanceId: string, label?: string): string {
20
+ if (label) return `${prefix}-${sanitizeFsName(label, { replacement: "-", collapse: false, lowercase: true })}`;
21
+ return `${prefix}-${instanceId.slice(0, 8)}`;
22
+ }
23
+
24
+ export function tmuxSocketName(sessionName: string): string {
25
+ const socketName = sanitizeFsName(`agent-relay-${sessionName}`, { replacement: "-", collapse: false, lowercase: true });
26
+ assertTmuxSocketPathFits(socketName);
27
+ return socketName;
28
+ }
29
+
30
+ export function tmuxSocketPath(socketName: string, socketDir = defaultTmuxSocketDir()): string {
31
+ return join(socketDir, socketName);
32
+ }
33
+
34
+ export function assertTmuxSocketPathFits(socketName: string, socketDir = defaultTmuxSocketDir()): void {
35
+ const path = tmuxSocketPath(socketName, socketDir);
36
+ const bytes = Buffer.byteLength(path);
37
+ if (bytes < 108) return;
38
+ throw new Error(`derived name too long for tmux socket; pass a short \`label\` (socket path is ${bytes} bytes; must be < 108)`);
39
+ }
40
+
41
+ function defaultTmuxSocketDir(): string {
42
+ const base = process.env.TMUX_TMPDIR || "/tmp";
43
+ const uid = typeof process.getuid === "function" ? process.getuid() : "user";
44
+ return join(base, `tmux-${uid}`);
45
+ }
46
+
47
+ export function captureTmuxPane(sessionName: string, socketName?: string): string {
48
+ const result = Bun.spawnSync(tmuxCommand(socketName, "capture-pane", "-p", "-t", sessionName, "-S", "-80"), {
49
+ stdin: "ignore", stdout: "pipe", stderr: "pipe",
50
+ });
51
+ if (result.exitCode !== 0) {
52
+ const stderr = result.stderr.toString().trim();
53
+ throw new Error(`tmux capture-pane failed: ${stderr || `exit code ${result.exitCode}`}`);
54
+ }
55
+ return result.stdout.toString();
56
+ }
57
+
58
+ export async function waitForClaudeInputReady(sessionName: string, timeoutMs = CLAUDE_TMUX_READY_TIMEOUT_MS, socketName?: string): Promise<void> {
59
+ const deadline = Date.now() + timeoutMs;
60
+ while (Date.now() < deadline) {
61
+ if (!tmuxHasSession(sessionName, socketName)) throw new Error("tmux session exited before Claude became ready");
62
+ if (claudePaneLooksReady(captureTmuxPane(sessionName, socketName))) return;
63
+ await Bun.sleep(200);
64
+ }
65
+ throw new Error(`Claude input was not ready within ${timeoutMs}ms`);
66
+ }
67
+
68
+ function pasteTextIntoTmux(sessionName: string, text: string, socketName?: string): void {
69
+ const bufferName = `agent-relay-${crypto.randomUUID()}`;
70
+ const setResult = Bun.spawnSync(tmuxCommand(socketName, "set-buffer", "-b", bufferName, text), {
71
+ stdin: "ignore", stdout: "ignore", stderr: "pipe",
72
+ });
73
+ if (setResult.exitCode !== 0) {
74
+ const stderr = setResult.stderr.toString().trim();
75
+ throw new Error(`tmux set-buffer failed: ${stderr || `exit code ${setResult.exitCode}`}`);
76
+ }
77
+ try {
78
+ const pasteResult = Bun.spawnSync(tmuxCommand(socketName, "paste-buffer", "-b", bufferName, "-t", sessionName), {
79
+ stdin: "ignore", stdout: "ignore", stderr: "pipe",
80
+ });
81
+ if (pasteResult.exitCode !== 0) {
82
+ const stderr = pasteResult.stderr.toString().trim();
83
+ throw new Error(`tmux paste-buffer failed: ${stderr || `exit code ${pasteResult.exitCode}`}`);
84
+ }
85
+ } finally {
86
+ Bun.spawnSync(tmuxCommand(socketName, "delete-buffer", "-b", bufferName), {
87
+ stdin: "ignore", stdout: "ignore", stderr: "ignore",
88
+ });
89
+ }
90
+ }
91
+
92
+ export async function submitTextToTmux(sessionName: string, text: string, socketName?: string): Promise<void> {
93
+ pasteTextIntoTmux(sessionName, text, socketName);
94
+ await Bun.sleep(CLAUDE_TMUX_SUBMIT_DELAY_MS);
95
+ const result = Bun.spawnSync(tmuxCommand(socketName, "send-keys", "-t", sessionName, CLAUDE_TMUX_SUBMIT_KEY), {
96
+ stdin: "ignore", stdout: "ignore", stderr: "pipe",
97
+ });
98
+ if (result.exitCode !== 0) {
99
+ const stderr = result.stderr.toString().trim();
100
+ throw new Error(`tmux submit failed: ${stderr || `exit code ${result.exitCode}`}`);
101
+ }
102
+ }
103
+
104
+ // tmux new-session has a hard command-length limit that includes every argv item,
105
+ // including `-e KEY=value`. The launcher script keeps tmux argv bounded by carrying
106
+ // both env payloads (profile JSON, tokens, etc.) and the provider command/prompt.
107
+ export function writeLauncherScript(sessionName: string, env: Record<string, string>, shellCmd: string): string {
108
+ const scriptPath = launcherScriptPathForSession(sessionName);
109
+ const launcherDir = dirname(scriptPath); mkdirSync(launcherDir, { recursive: true, mode: 0o700 }); chmodSync(launcherDir, 0o700);
110
+ const exports = Object.entries(env)
111
+ .map(([key, value]) => `export ${shellEnvKey(key)}=${shellQuote(value)}`)
112
+ .join("\n");
113
+ writeFileSync(scriptPath, `#!/usr/bin/env bash\n${exports ? `${exports}\n` : ""}exec ${shellCmd}\n`, { mode: 0o700 });
114
+ return scriptPath;
115
+ }
116
+
117
+ function shellEnvKey(key: string): string {
118
+ if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) return key;
119
+ throw new Error(`invalid environment variable name for Claude launcher: ${key}`);
120
+ }
121
+
122
+ export function tmuxEnvKeys(env: Record<string, string>, providerEnv: Record<string, string>): string[] {
123
+ const keys = new Set<string>();
124
+ for (const key of Object.keys(env)) {
125
+ if (key.startsWith("AGENT_RELAY_") || key.startsWith("CLAUDE_")) {
126
+ keys.add(key);
127
+ }
128
+ }
129
+ for (const key of Object.keys(providerEnv)) {
130
+ keys.add(key);
131
+ }
132
+ return [...keys].sort();
133
+ }
@@ -267,6 +267,35 @@ export function transcriptHasToolUseAnchor(jsonl: string, toolName: string, tool
267
267
  return false;
268
268
  }
269
269
 
270
+ /**
271
+ * The transcript-embedded `timestamp` of the specific assistant entry carrying the exact
272
+ * tool_use block that triggered a PreToolUse hook (same anchor match as
273
+ * transcriptHasToolUseAnchor above). Used to stamp a resolved permission prompt with the
274
+ * tool call's real event time — a hook's own wall-clock receipt time can end up earlier than
275
+ * the entry the provider eventually flushes for that same call, which otherwise sorts the
276
+ * resolved prompt ahead of the narration that led up to it (#1185).
277
+ */
278
+ export function transcriptToolUseOccurredAt(jsonl: string, toolName: string, toolInput: unknown): number | undefined {
279
+ const wantInput = canonicalJson(toolInput ?? {});
280
+ for (const line of jsonl.split("\n")) {
281
+ const trimmed = line.trim();
282
+ if (!trimmed) continue;
283
+ let entry: TranscriptEntry;
284
+ try {
285
+ entry = JSON.parse(trimmed) as TranscriptEntry;
286
+ } catch {
287
+ continue;
288
+ }
289
+ if (entry.type !== "assistant" || isSidechainEntry(entry)) continue;
290
+ for (const block of blocks(entry.message)) {
291
+ if (block.type === "tool_use" && block.name === toolName && canonicalJson(block.input ?? {}) === wantInput) {
292
+ return transcriptEntryOccurredAt(entry);
293
+ }
294
+ }
295
+ }
296
+ return undefined;
297
+ }
298
+
270
299
  /**
271
300
  * Extract the ordered narration, reasoning, and tool steps for the most recent
272
301
  * turn (since the last real user prompt). Used by the reasoning tailer to stream
@@ -1,8 +1,7 @@
1
- import { chmodSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
1
+ import { existsSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
- import { dirname, join, resolve } from "node:path";
3
+ import { join, resolve } from "node:path";
4
4
  import { type Message } from "agent-relay-sdk";
5
- import { sanitizeFsName } from "agent-relay-sdk/fs-name";
6
5
  import { shellEscape as shellQuote } from "agent-relay-sdk/shell-utils";
7
6
  import { tmuxCommand, tmuxHasSession } from "agent-relay-sdk/tmux-utils";
8
7
  import { profileAllowsRelayFeature, type ManagedProcess, type ProviderAdapter, type ProviderConfig, type ProviderStatusUpdate, type RunnerSpawnConfig, type SemanticStatus, type SpawnArgs } from "../adapter";
@@ -14,8 +13,8 @@ import { claudeProviderMessageText } from "./claude-delivery";
14
13
  import { claudeProbeActivity, readManagedClaudeStatus, resolveClaudePid, type ClaudeProbeActivity } from "./claude-session-probe";
15
14
  import { evaluateClaudePromptGatePane, initialClaudePromptGatePaneState, type ClaudePromptGatePaneState } from "./claude-prompt-gates";
16
15
  import { claudePaneLooksReady, claudePaneIsBusy, claudeRateLimitStatus, claudeConnectionRetryStatus, claudeModelUnavailableStatus } from "./claude-status-detectors";
17
- import { launcherScriptPathForSession } from "../session-scratch";
18
16
  import { ClaudeSessionCapture, collectClaudeTranscriptArchiveSegment, collectClaudeTranscriptSessionEvents } from "./claude-session-capture";
17
+ import { CLAUDE_TMUX_READY_TIMEOUT_MS, captureTmuxPane, submitTextToTmux, tmuxEnvKeys, tmuxSessionName, tmuxSocketName, waitForClaudeInputReady, writeLauncherScript } from "./claude-tmux";
19
18
  import { claudePermissionPromptHandler } from "./claude-permission";
20
19
 
21
20
  export class ClaudeAdapter implements ProviderAdapter {
@@ -468,9 +467,9 @@ export class ClaudeAdapter implements ProviderAdapter {
468
467
  }
469
468
 
470
469
  export const CLAUDE_EXIT_COMMAND = "/exit";
471
- export const CLAUDE_TMUX_SUBMIT_DELAY_MS = 250;
472
- export const CLAUDE_TMUX_SUBMIT_KEY = "C-m";
473
- export const CLAUDE_TMUX_READY_TIMEOUT_MS = 10_000;
470
+ // tmux transport plumbing moved to ./claude-tmux (#300 ratchet); re-exported so
471
+ // existing `./claude` consumers + tests keep their import paths.
472
+ 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";
474
473
  // Monitorless turn detection (Fix: isolated agents reach task "done" like Codex).
475
474
  const CLAUDE_TURN_WATCH_ID = "tmux-turn";
476
475
  const CLAUDE_TURN_POLL_MS = 1500;
@@ -528,107 +527,13 @@ function stripClaudePermissionArgs(args: string[], stripToolArgs = false): strin
528
527
  }
529
528
 
530
529
 
531
- export function tmuxSessionName(prefix: string, instanceId: string, label?: string): string {
532
- if (label) return `${prefix}-${sanitizeFsName(label, { replacement: "-", collapse: false, lowercase: true })}`;
533
- return `${prefix}-${instanceId.slice(0, 8)}`;
534
- }
535
-
536
- export function tmuxSocketName(sessionName: string): string {
537
- return sanitizeFsName(`agent-relay-${sessionName}`, { replacement: "-", collapse: false, lowercase: true });
538
- }
539
-
540
530
  // Shared tmux helpers; tmuxHasSession re-exported for ./claude consumers + tests.
541
531
  export { tmuxHasSession };
542
532
 
543
- function captureTmuxPane(sessionName: string, socketName?: string): string {
544
- const result = Bun.spawnSync(tmuxCommand(socketName, "capture-pane", "-p", "-t", sessionName, "-S", "-80"), {
545
- stdin: "ignore", stdout: "pipe", stderr: "pipe",
546
- });
547
- if (result.exitCode !== 0) {
548
- const stderr = result.stderr.toString().trim();
549
- throw new Error(`tmux capture-pane failed: ${stderr || `exit code ${result.exitCode}`}`);
550
- }
551
- return result.stdout.toString();
552
- }
553
-
554
533
  // Re-export pane-status detectors (moved to ./claude-status-detectors) so existing
555
534
  // import paths (including claude.test.ts) continue to resolve via this module.
556
535
  export { claudePaneLooksReady, claudePaneIsBusy, claudeRateLimitStatus, claudeConnectionRetryStatus, claudeModelUnavailableStatus } from "./claude-status-detectors";
557
536
 
558
- async function waitForClaudeInputReady(sessionName: string, timeoutMs = CLAUDE_TMUX_READY_TIMEOUT_MS, socketName?: string): Promise<void> {
559
- const deadline = Date.now() + timeoutMs;
560
- while (Date.now() < deadline) {
561
- if (!tmuxHasSession(sessionName, socketName)) throw new Error("tmux session exited before Claude became ready");
562
- if (claudePaneLooksReady(captureTmuxPane(sessionName, socketName))) return;
563
- await Bun.sleep(200);
564
- }
565
- throw new Error(`Claude input was not ready within ${timeoutMs}ms`);
566
- }
567
-
568
- function pasteTextIntoTmux(sessionName: string, text: string, socketName?: string): void {
569
- const bufferName = `agent-relay-${crypto.randomUUID()}`;
570
- const setResult = Bun.spawnSync(tmuxCommand(socketName, "set-buffer", "-b", bufferName, text), {
571
- stdin: "ignore", stdout: "ignore", stderr: "pipe",
572
- });
573
- if (setResult.exitCode !== 0) {
574
- const stderr = setResult.stderr.toString().trim();
575
- throw new Error(`tmux set-buffer failed: ${stderr || `exit code ${setResult.exitCode}`}`);
576
- }
577
- try {
578
- const pasteResult = Bun.spawnSync(tmuxCommand(socketName, "paste-buffer", "-b", bufferName, "-t", sessionName), {
579
- stdin: "ignore", stdout: "ignore", stderr: "pipe",
580
- });
581
- if (pasteResult.exitCode !== 0) {
582
- const stderr = pasteResult.stderr.toString().trim();
583
- throw new Error(`tmux paste-buffer failed: ${stderr || `exit code ${pasteResult.exitCode}`}`);
584
- }
585
- } finally {
586
- Bun.spawnSync(tmuxCommand(socketName, "delete-buffer", "-b", bufferName), {
587
- stdin: "ignore", stdout: "ignore", stderr: "ignore",
588
- });
589
- }
590
- }
591
-
592
- async function submitTextToTmux(sessionName: string, text: string, socketName?: string): Promise<void> {
593
- pasteTextIntoTmux(sessionName, text, socketName);
594
- await Bun.sleep(CLAUDE_TMUX_SUBMIT_DELAY_MS);
595
- const result = Bun.spawnSync(tmuxCommand(socketName, "send-keys", "-t", sessionName, CLAUDE_TMUX_SUBMIT_KEY), {
596
- stdin: "ignore", stdout: "ignore", stderr: "pipe",
597
- });
598
- if (result.exitCode !== 0) {
599
- const stderr = result.stderr.toString().trim();
600
- throw new Error(`tmux submit failed: ${stderr || `exit code ${result.exitCode}`}`);
601
- }
602
- }
603
-
604
- function writeLauncherScript(sessionName: string, env: Record<string, string>, shellCmd: string): string {
605
- const scriptPath = launcherScriptPathForSession(sessionName);
606
- const launcherDir = dirname(scriptPath); mkdirSync(launcherDir, { recursive: true, mode: 0o700 }); chmodSync(launcherDir, 0o700);
607
- const exports = Object.entries(env)
608
- .map(([key, value]) => `export ${shellEnvKey(key)}=${shellQuote(value)}`)
609
- .join("\n");
610
- writeFileSync(scriptPath, `#!/usr/bin/env bash\n${exports ? `${exports}\n` : ""}exec ${shellCmd}\n`, { mode: 0o700 });
611
- return scriptPath;
612
- }
613
-
614
- function shellEnvKey(key: string): string {
615
- if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) return key;
616
- throw new Error(`invalid environment variable name for Claude launcher: ${key}`);
617
- }
618
-
619
- export function tmuxEnvKeys(env: Record<string, string>, providerEnv: Record<string, string>): string[] {
620
- const keys = new Set<string>();
621
- for (const key of Object.keys(env)) {
622
- if (key.startsWith("AGENT_RELAY_") || key.startsWith("CLAUDE_")) {
623
- keys.add(key);
624
- }
625
- }
626
- for (const key of Object.keys(providerEnv)) {
627
- keys.add(key);
628
- }
629
- return [...keys].sort();
630
- }
631
-
632
537
  // Shared shell-quoting; re-exported so `./claude` consumers + tests resolve it.
633
538
  export { shellQuote };
634
539
 
@@ -60,7 +60,7 @@ interface ControlServerOptions {
60
60
  // Provider session-final and pre-flush hooks hand over their capture source so
61
61
  // the runner can surface the provider turn in dashboard chat without the agent
62
62
  // re-emitting it via /reply.
63
- onSessionTurn?(input: { transcriptPath: string; lastAssistantMessage?: unknown; promptId?: string; isPreFlush?: boolean; toolName?: string; toolInput?: unknown }): Promise<{ reasoningSettled: boolean } | void>;
63
+ onSessionTurn?(input: { transcriptPath: string; lastAssistantMessage?: unknown; promptId?: string; isPreFlush?: boolean; toolName?: string; toolInput?: unknown }): Promise<{ reasoningSettled: boolean; toolUseOccurredAt?: number } | void>;
64
64
  onPermissionResolved?(input: ResolvedPermissionPrompt): Promise<void> | void;
65
65
  onPermissionAbandoned?(input: PendingPermissionRequestRecord & { reason: string; decisionReason: ProviderPermissionDecisionReason }): Promise<void> | void;
66
66
  // #1173: a "questions" (AskUserQuestion) prompt's own PreToolUse hook is answered with `deny`
@@ -385,9 +385,15 @@ async function awaitPermissionDecision(params: {
385
385
  // chat. The adapter owns how to settle its capture source; the transport only
386
386
  // forwards the hook payload as an anchor and stamps the returned truth value.
387
387
  let reasoningSettled = true;
388
+ // Defaults to the hook's own wall-clock receipt time; refined below to the transcript's own
389
+ // tool_use event time when the pre-flush harvest can resolve it (#1185) — the hook can receive
390
+ // before the provider flushes/timestamps that entry, which otherwise sorts the resolved prompt
391
+ // ahead of the narration that led up to it.
392
+ let resolvedOccurredAt = occurredAt;
388
393
  if (transcriptPath && options.onSessionTurn) {
389
394
  const result = await options.onSessionTurn({ transcriptPath, isPreFlush: true, toolName, toolInput }).catch(() => undefined);
390
395
  if (result && typeof result.reasoningSettled === "boolean") reasoningSettled = result.reasoningSettled;
396
+ if (result && typeof result.toolUseOccurredAt === "number") resolvedOccurredAt = result.toolUseOccurredAt;
391
397
  }
392
398
  const view = handler.buildView(approvalId, body, reasoningSettled);
393
399
  const waitMs = options.permissionWaitMs ?? PERMISSION_WAIT_MS;
@@ -423,7 +429,7 @@ async function awaitPermissionDecision(params: {
423
429
  });
424
430
 
425
431
  if (decision.decisionReason?.kind !== "timeout" && options.onPermissionResolved) {
426
- await Promise.resolve(options.onPermissionResolved(handler.buildResolvedPrompt(view, decision, body, occurredAt))).catch(() => {});
432
+ await Promise.resolve(options.onPermissionResolved(handler.buildResolvedPrompt(view, decision, body, resolvedOccurredAt))).catch(() => {});
427
433
  }
428
434
 
429
435
  return { decision, view };