agent-relay-runner 0.127.7 → 0.127.8
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 +1 -1
- package/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/src/adapter.ts +5 -0
- package/src/adapters/claude-permission.ts +2 -2
- package/src/adapters/claude-session-capture.ts +27 -2
- package/src/adapters/claude-tmux.ts +133 -0
- package/src/adapters/claude-transcript.ts +29 -0
- package/src/adapters/claude.ts +6 -101
- package/src/control-server.ts +8 -2
package/package.json
CHANGED
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
package/src/adapters/claude.ts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
|
-
import {
|
|
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
|
-
|
|
472
|
-
|
|
473
|
-
export
|
|
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
|
|
package/src/control-server.ts
CHANGED
|
@@ -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,
|
|
432
|
+
await Promise.resolve(options.onPermissionResolved(handler.buildResolvedPrompt(view, decision, body, resolvedOccurredAt))).catch(() => {});
|
|
427
433
|
}
|
|
428
434
|
|
|
429
435
|
return { decision, view };
|