pi-claude-supervisor 0.2.2 → 0.3.0

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.
@@ -0,0 +1,1017 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { spawn } from "node:child_process";
3
+ import { mkdir, open, readFile, rm, stat, truncate, writeFile } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import type {
7
+ WorkerAdapter,
8
+ WorkerCapabilities,
9
+ WorkerEventListener,
10
+ WorkerHandle,
11
+ WorkerOutputChunk,
12
+ WorkerStartInput,
13
+ WorkerStatus,
14
+ } from "../types.ts";
15
+ import { assertSafeWorkerCommand } from "../policy.ts";
16
+ import { redactSensitive } from "../redaction.ts";
17
+ import { workerEnvironment } from "./environment.ts";
18
+
19
+ export interface TmuxWorkerAdapterOptions {
20
+ /** Directory for launcher and output state. */
21
+ stateDir?: string;
22
+ /** tmux executable. */
23
+ tmuxBinary?: string;
24
+ /** Time allowed for Claude's interactive prompt to become ready. */
25
+ startupTimeoutMs?: number;
26
+ /** Poll interval used to detect prompt completion and pane exit. */
27
+ pollIntervalMs?: number;
28
+ /** Time allowed for an interactive /exit before killing the owned server. */
29
+ terminationGraceMs?: number;
30
+ }
31
+
32
+ interface TmuxRecord {
33
+ handle: WorkerHandle;
34
+ sessionName: string;
35
+ socketPath?: string;
36
+ target: string;
37
+ logPath: string;
38
+ runtimeDir: string;
39
+ owned: boolean;
40
+ pipeAttached: boolean;
41
+ outputOffset: number;
42
+ output: WorkerOutputChunk[];
43
+ outputBytes: number;
44
+ outputTruncated: boolean;
45
+ lastOutputAt?: string;
46
+ lastInputAt?: string;
47
+ activeRequests: number;
48
+ turnSequence: number;
49
+ readyStreak: number;
50
+ turnObservedOutput: boolean;
51
+ inputAt?: number;
52
+ sentKeys: Set<string>;
53
+ inputTail: Promise<void>;
54
+ outputTail: Promise<void>;
55
+ listeners: Set<WorkerEventListener>;
56
+ monitor?: NodeJS.Timeout;
57
+ monitorInFlight: boolean;
58
+ exitCode?: number | null;
59
+ signal?: NodeJS.Signals;
60
+ stopping: boolean;
61
+ starting: boolean;
62
+ abortRequested: boolean;
63
+ released: boolean;
64
+ cleanupComplete: boolean;
65
+ sessionCreated?: boolean;
66
+ serverKilled?: boolean;
67
+ cleanupError?: Error;
68
+ panePid?: number;
69
+ paneStartTime?: string;
70
+ paneCommand?: string;
71
+ replacementPaneStartTime?: string;
72
+ replacementPaneCommand?: string;
73
+ paneDead?: boolean;
74
+ }
75
+
76
+ interface TmuxPaneStatus {
77
+ dead: boolean;
78
+ exitCode?: number;
79
+ pid?: number;
80
+ }
81
+
82
+ /**
83
+ * Interactive Claude Code transport backed by a private tmux server and PTY.
84
+ *
85
+ * The adapter owns sessions it starts, while an explicitly adopted session is
86
+ * never killed by stop/release. Tmux is a transport boundary only: policy,
87
+ * watchdog, Decision Worker and verification remain in Supervisor.
88
+ */
89
+ export class TmuxWorkerAdapter implements WorkerAdapter {
90
+ readonly #records = new Map<string, TmuxRecord>();
91
+ readonly #stateDir: string;
92
+ readonly #tmuxBinary: string;
93
+ readonly #startupTimeoutMs: number;
94
+ readonly #pollIntervalMs: number;
95
+ readonly #terminationGraceMs: number;
96
+ readonly #maxOutputBytes = 8 * 1024 * 1024;
97
+ readonly #maxLogBytes = 16 * 1024 * 1024;
98
+ readonly #commandTimeoutMs = 10_000;
99
+
100
+ constructor(options: TmuxWorkerAdapterOptions = {}) {
101
+ this.#stateDir = options.stateDir ?? join(tmpdir(), "pi-claude-supervisor");
102
+ this.#tmuxBinary = options.tmuxBinary ?? "tmux";
103
+ this.#startupTimeoutMs = boundedDelay(options.startupTimeoutMs ?? 60_000);
104
+ this.#pollIntervalMs = boundedDelay(options.pollIntervalMs ?? 500);
105
+ this.#terminationGraceMs = boundedDelay(options.terminationGraceMs ?? 2_000);
106
+ }
107
+
108
+ capabilities(): WorkerCapabilities {
109
+ return {
110
+ transport: "tmux",
111
+ interactiveInput: true,
112
+ pause: true,
113
+ resumeSession: false,
114
+ processGroupControl: false,
115
+ persistentSession: true,
116
+ };
117
+ }
118
+
119
+ async start(input: WorkerStartInput): Promise<WorkerHandle> {
120
+ const id = randomUUID();
121
+ const owned = !input.tmuxSession;
122
+ const sessionName = input.tmuxSession ?? `pi-supervisor-${id}`;
123
+ if (!/^[A-Za-z0-9_.-]+$/u.test(sessionName)) throw new Error("tmux session names must contain only letters, numbers, dot, underscore or hyphen");
124
+ const socketPath = input.tmuxSession ? input.tmuxSocket : join(tmpdir(), `pi-cs-${id}.sock`);
125
+ // Resolve the active window dynamically; users may configure base-index=1.
126
+ const target = sessionName;
127
+ const runtimeDir = join(this.#stateDir, "tmux", id);
128
+ const logPath = join(runtimeDir, `worker-${id}.log`);
129
+ const handle: WorkerHandle = {
130
+ id,
131
+ startedAt: new Date().toISOString(),
132
+ cwd: input.cwd,
133
+ sessionName,
134
+ tmuxSocket: socketPath,
135
+ ownership: owned ? "owned" : "adopted",
136
+ };
137
+ const record: TmuxRecord = {
138
+ handle,
139
+ sessionName,
140
+ socketPath,
141
+ target,
142
+ logPath,
143
+ runtimeDir,
144
+ owned,
145
+ pipeAttached: false,
146
+ outputOffset: 0,
147
+ output: [],
148
+ outputBytes: 0,
149
+ outputTruncated: false,
150
+ activeRequests: 0,
151
+ turnSequence: 0,
152
+ readyStreak: 0,
153
+ turnObservedOutput: false,
154
+ sentKeys: new Set(),
155
+ inputTail: Promise.resolve(),
156
+ outputTail: Promise.resolve(),
157
+ listeners: new Set(input.eventListener ? [input.eventListener] : []),
158
+ monitorInFlight: false,
159
+ stopping: false,
160
+ starting: true,
161
+ abortRequested: false,
162
+ released: false,
163
+ cleanupComplete: false,
164
+ };
165
+ this.#records.set(id, record);
166
+
167
+ try {
168
+ await mkdir(runtimeDir, { recursive: true, mode: 0o700 });
169
+ await writeFile(logPath, "", { mode: 0o600 });
170
+ if (owned) {
171
+ assertSafeWorkerCommand(input.command, input.args ?? [], input.approval);
172
+ assertNoCredentialArguments(input.command, input.args ?? []);
173
+ const launcherPath = join(runtimeDir!, "launcher.mjs");
174
+ await writeFile(launcherPath, launcherSource({ command: input.command, args: input.args ?? [], cwd: input.cwd }), { mode: 0o600 });
175
+ const env = workerEnvironment(process.env, input.env);
176
+ // Create the window with its shell first so remain-on-exit is set
177
+ // before the launcher can finish instantly.
178
+ await this.#run(record, ["new-session", "-d", "-s", sessionName, "-x", "140", "-y", "40", "-c", input.cwd], undefined, env);
179
+ record.sessionCreated = true;
180
+ await this.#run(record, ["set-window-option", "-t", sessionName, "remain-on-exit", "on"]);
181
+ await this.#run(record, ["respawn-pane", "-k", "-t", target, "--", process.execPath, launcherPath]);
182
+ await this.#pinTarget(record);
183
+ const ownedPane = await this.#paneStatus(record);
184
+ record.paneDead = ownedPane.dead;
185
+ if (!ownedPane.dead) {
186
+ record.panePid = ownedPane.pid;
187
+ record.handle.pid = ownedPane.pid;
188
+ await this.#rememberPaneIdentity(record, ownedPane.pid);
189
+ }
190
+ } else {
191
+ await this.#assertExistingSession(record, input.cwd, input.approval);
192
+ const pipe = await this.#run(record, ["display-message", "-p", "-t", record.target, "#{pane_pipe}"]);
193
+ if (pipe.stdout.trim() === "1") throw new Error("cannot adopt a tmux pane that already has an output pipe");
194
+ }
195
+ await this.#run(record, ["pipe-pane", "-o", "-t", record.target, `cat >> ${shellQuote(logPath)}`]);
196
+ record.pipeAttached = true;
197
+ const attachedPipe = await this.#run(record, ["display-message", "-p", "-t", record.target, "#{pane_pipe}"]);
198
+ if (attachedPipe.stdout.trim() !== "1") throw new Error("tmux output pipe could not be attached to the pinned pane");
199
+ if (input.sendInitialInput !== false || !input.tmuxSession) await this.#waitForReady(record);
200
+ if (input.sendInitialInput !== false) {
201
+ await this.#send(record, input.task, `${id}:initial`);
202
+ } else if (input.tmuxSession) {
203
+ const screen = await this.#capture(record);
204
+ if (!isReadyScreen(screen)) {
205
+ record.activeRequests = 1;
206
+ record.lastInputAt = new Date().toISOString();
207
+ record.inputAt = Date.now();
208
+ // A non-prompt screen indicates that adoption is observing an
209
+ // already-running turn; an idle prompt stays inactive.
210
+ record.turnObservedOutput = true;
211
+ }
212
+ } else {
213
+ // Explicit idle startup is used by recovery; do not create a blank
214
+ // Claude turn and do not manufacture a completion event.
215
+ record.activeRequests = 0;
216
+ }
217
+ this.#assertNotAborted(record);
218
+ this.#startMonitor(record);
219
+ record.starting = false;
220
+ return handle;
221
+ } catch (error) {
222
+ let cleanupFailure: unknown;
223
+ try { await this.#cleanup(record, true); }
224
+ catch (cleanupError) { cleanupFailure = cleanupError; }
225
+ record.starting = false;
226
+ const startupError = error instanceof Error ? error : new Error(String(error));
227
+ const cleanupMessage = record.cleanupError?.message ?? (cleanupFailure instanceof Error ? cleanupFailure.message : undefined);
228
+ if (cleanupMessage) startupError.message = `${startupError.message}; startup cleanup failed: ${cleanupMessage}`;
229
+ Object.defineProperty(startupError, "workerHandle", { value: handle, enumerable: false });
230
+ throw startupError;
231
+ }
232
+ }
233
+
234
+ async abortStart(_reason: string): Promise<void> {
235
+ const starts = [...this.#records.values()].filter((record) => record.starting);
236
+ for (const record of starts) {
237
+ record.abortRequested = true;
238
+ record.stopping = true;
239
+ if (record.owned) await this.#run(record, ["kill-server"], undefined, undefined, true).catch(() => {});
240
+ else await this.#detachPipe(record);
241
+ }
242
+ const deadline = Date.now() + 25_000;
243
+ while ([...this.#records.values()].some((record) => record.starting) && Date.now() < deadline) await delay(25);
244
+ }
245
+
246
+ async getStatus(handle: WorkerHandle): Promise<WorkerStatus> {
247
+ const record = this.#record(handle);
248
+ if (record.released) {
249
+ try {
250
+ const pane = await this.#paneStatus(record);
251
+ record.paneDead = pane.dead;
252
+ record.panePid = pane.pid;
253
+ if (!pane.dead) await this.#rememberPaneIdentity(record, pane.pid);
254
+ } catch (error) {
255
+ if (isMissingSession(error) || isPaneIdentityError(error)) record.paneDead = true;
256
+ else record.cleanupError = asError(error);
257
+ }
258
+ return this.#status(record, !record.paneDead);
259
+ }
260
+ try {
261
+ const pane = await this.#paneStatus(record);
262
+ record.paneDead = pane.dead;
263
+ record.panePid = pane.pid;
264
+ if (!pane.dead) {
265
+ record.handle.pid = pane.pid;
266
+ await this.#rememberPaneIdentity(record, pane.pid);
267
+ }
268
+ if (pane.exitCode !== undefined) record.exitCode = pane.exitCode;
269
+ if (pane.dead && !record.cleanupComplete) await this.#cleanup(record, false);
270
+ } catch (error) {
271
+ if (isMissingSession(error)) {
272
+ record.paneDead = true;
273
+ if (!record.cleanupComplete && record.owned) await this.#cleanup(record, false);
274
+ } else {
275
+ record.cleanupError = asError(error);
276
+ if (isPaneIdentityError(error)) record.paneDead = true;
277
+ }
278
+ }
279
+ return this.#status(record, !record.paneDead && !record.released);
280
+ }
281
+
282
+ async readOutput(handle: WorkerHandle): Promise<WorkerOutputChunk[]> {
283
+ const record = this.#record(handle);
284
+ return this.#withOutputLock(record, async () => {
285
+ await this.#collectOutputUnlocked(record);
286
+ const output = record.output.splice(0);
287
+ record.outputBytes = 0;
288
+ return output;
289
+ });
290
+ }
291
+
292
+ subscribe(handle: WorkerHandle, listener: WorkerEventListener): () => void {
293
+ const record = this.#record(handle);
294
+ record.listeners.add(listener);
295
+ return () => record.listeners.delete(listener);
296
+ }
297
+
298
+ async restoreOutput(handle: WorkerHandle, chunks: WorkerOutputChunk[]): Promise<void> {
299
+ const record = this.#record(handle);
300
+ await this.#withOutputLock(record, async () => {
301
+ record.output.unshift(...chunks);
302
+ record.outputBytes += chunks.reduce((total, chunk) => total + Buffer.byteLength(chunk.text, "utf8"), 0);
303
+ });
304
+ }
305
+
306
+ async send(handle: WorkerHandle, message: string, idempotencyKey: string): Promise<void> {
307
+ const record = this.#record(handle);
308
+ if (record.sentKeys.has(idempotencyKey)) return;
309
+ if (record.released) throw new Error("tmux worker is no longer supervised");
310
+ if (record.activeRequests > 0) throw new Error("tmux worker has an active turn; wait for its prompt before sending another turn");
311
+ await this.#send(record, message, idempotencyKey);
312
+ }
313
+
314
+ async pause(handle: WorkerHandle): Promise<void> {
315
+ const record = this.#record(handle);
316
+ if (record.released) throw new Error("tmux worker is no longer supervised");
317
+ const pane = await this.#paneStatus(record);
318
+ if (pane.dead) throw new Error("cannot pause a dead tmux pane");
319
+ await this.#rememberPaneIdentity(record, pane.pid);
320
+ if (!pane.pid) throw new Error("tmux worker pane pid is unavailable");
321
+ signalProcessGroup(pane.pid, "SIGSTOP");
322
+ }
323
+
324
+ async resume(handle: WorkerHandle): Promise<void> {
325
+ const record = this.#record(handle);
326
+ if (record.released) throw new Error("tmux worker is no longer supervised");
327
+ const pane = await this.#paneStatus(record);
328
+ if (pane.dead) throw new Error("cannot resume a dead tmux pane");
329
+ await this.#rememberPaneIdentity(record, pane.pid);
330
+ if (!pane.pid) throw new Error("tmux worker pane pid is unavailable");
331
+ signalProcessGroup(pane.pid, "SIGCONT");
332
+ }
333
+
334
+ async stop(handle: WorkerHandle, _reason: string): Promise<void> {
335
+ const record = this.#record(handle);
336
+ if (record.released || !record.owned) {
337
+ await this.release(handle, "adopted tmux session is not owned by Supervisor");
338
+ return;
339
+ }
340
+ record.stopping = true;
341
+ record.cleanupError = undefined;
342
+ try { await this.#waitForInput(record); }
343
+ catch (error) { record.cleanupError = asError(error); }
344
+ try {
345
+ await this.#collectOutput(record);
346
+ if (!record.paneDead) {
347
+ try {
348
+ await this.#sendRaw(record, "/exit");
349
+ await this.#waitForPaneExit(record, this.#terminationGraceMs);
350
+ } catch {
351
+ // The private tmux server is still the authoritative cleanup boundary.
352
+ }
353
+ await this.#collectOutput(record);
354
+ }
355
+ } catch (error) {
356
+ record.cleanupError ??= asError(error);
357
+ } finally {
358
+ await this.#detachPipe(record);
359
+ try { await this.#flushOutput(record); }
360
+ catch (error) { record.cleanupError ??= asError(error); }
361
+ const operationError = record.cleanupError;
362
+ record.cleanupError = undefined;
363
+ try { await this.#cleanup(record, true); }
364
+ catch (error) { record.cleanupError ??= asError(error); }
365
+ record.cleanupError ??= operationError;
366
+ }
367
+ if (record.cleanupError || !record.cleanupComplete) throw new Error(`tmux worker cleanup failed: ${record.cleanupError?.message ?? "cleanup was not confirmed"}`);
368
+ }
369
+
370
+ async release(handle: WorkerHandle, _reason: string): Promise<void> {
371
+ const record = this.#record(handle);
372
+ record.stopping = true;
373
+ record.cleanupError = undefined;
374
+ record.released = true;
375
+ try { await this.#waitForInput(record); }
376
+ catch (error) { record.cleanupError = asError(error); }
377
+ if (record.monitor) clearInterval(record.monitor);
378
+ record.monitor = undefined;
379
+ record.listeners.clear();
380
+ try { await this.#collectOutput(record); }
381
+ catch (error) { record.cleanupError ??= asError(error); }
382
+ finally {
383
+ await this.#detachPipe(record);
384
+ try { await this.#flushOutput(record); }
385
+ catch (error) { record.cleanupError ??= asError(error); }
386
+ }
387
+ if (!record.owned) {
388
+ try { await rm(record.runtimeDir, { recursive: true, force: true }); }
389
+ catch (error) { record.cleanupError ??= asError(error); }
390
+ }
391
+ if (record.cleanupError) throw new Error(`tmux supervision release failed: ${record.cleanupError.message}`);
392
+ // A released tmux worker is intentionally left running. It can be adopted
393
+ // again explicitly after Pi restarts, and the user's attached window stays open.
394
+ }
395
+
396
+ async killProcessGroup(handle: WorkerHandle, _reason: string): Promise<void> {
397
+ const record = this.#record(handle);
398
+ if (!record.owned) throw new Error("cannot kill an adopted tmux session without explicit ownership");
399
+ await this.#cleanup(record, true);
400
+ }
401
+
402
+ async resumeSession(_sessionId: string): Promise<WorkerHandle> {
403
+ throw new Error("tmux session recovery requires an explicit tmux session name");
404
+ }
405
+
406
+ async #waitForInput(record: TmuxRecord): Promise<void> {
407
+ await new Promise<void>((resolve, reject) => {
408
+ const timer = setTimeout(() => reject(new Error("timed out waiting for tmux input serialization")), this.#commandTimeoutMs);
409
+ timer.unref();
410
+ record.inputTail.then(() => { clearTimeout(timer); resolve(); }, (error) => { clearTimeout(timer); reject(error); });
411
+ });
412
+ }
413
+
414
+ async #detachPipe(record: TmuxRecord): Promise<void> {
415
+ if (!record.pipeAttached) return;
416
+ try { await this.#run(record, ["pipe-pane", "-t", record.target], undefined, undefined, true); }
417
+ catch (error) { if (!isMissingSession(error)) record.cleanupError = asError(error); }
418
+ record.pipeAttached = false;
419
+ }
420
+
421
+ async #send(record: TmuxRecord, message: string, idempotencyKey: string): Promise<void> {
422
+ let release!: () => void;
423
+ const gate = new Promise<void>((resolve) => { release = resolve; });
424
+ const previous = record.inputTail;
425
+ record.inputTail = previous.then(() => gate);
426
+ await previous;
427
+ try {
428
+ if (record.stopping || record.released) throw new Error("tmux worker is stopping or released");
429
+ const pane = await this.#paneStatus(record);
430
+ if (pane.dead) throw new Error("tmux worker is not running");
431
+ record.handle.pid = pane.pid;
432
+ await this.#rememberPaneIdentity(record, pane.pid);
433
+ await this.#collectOutput(record);
434
+ const finalPane = await this.#paneStatus(record);
435
+ if (finalPane.dead) throw new Error("tmux worker exited before input reservation");
436
+ await this.#rememberPaneIdentity(record, finalPane.pid);
437
+ // This is the final reservation check immediately before paste. A
438
+ // human typing after this point is inherently outside tmux's control;
439
+ // takeover mode is the explicit exclusion mechanism for automation.
440
+ const screen = await this.#capture(record);
441
+ if (!isReadyScreen(screen)) throw new Error("tmux worker prompt is not stable; refusing to race interactive input");
442
+ record.activeRequests = 1;
443
+ record.readyStreak = 0;
444
+ record.turnObservedOutput = false;
445
+ record.inputAt = Date.now();
446
+ try {
447
+ await this.#sendRaw(record, message);
448
+ record.sentKeys.add(idempotencyKey);
449
+ record.lastInputAt = new Date().toISOString();
450
+ } catch (error) {
451
+ record.activeRequests = 0;
452
+ record.readyStreak = 0;
453
+ throw error;
454
+ }
455
+ } finally {
456
+ release();
457
+ }
458
+ }
459
+
460
+ async #sendRaw(record: TmuxRecord, message: string): Promise<void> {
461
+ const safeMessage = safeTmuxMessage(message);
462
+ const bufferName = `pi-cs-${record.handle.id}`;
463
+ // Bracketed paste keeps newlines and ordinary text from being interpreted
464
+ // as individual terminal key presses by Claude's TUI.
465
+ const pasted = `\u001b[200~${safeMessage}\u001b[201~`;
466
+ await this.#run(record, ["load-buffer", "-b", bufferName, "-"], pasted);
467
+ await this.#run(record, ["paste-buffer", "-d", "-b", bufferName, "-t", record.target]);
468
+ await this.#run(record, ["send-keys", "-t", record.target, "Enter"]);
469
+ }
470
+
471
+ #startMonitor(record: TmuxRecord): void {
472
+ record.monitor = setInterval(() => {
473
+ void this.#monitor(record).catch((error) => {
474
+ if (isPaneIdentityError(error)) record.paneDead = true;
475
+ if (!record.cleanupComplete && !isMissingSession(error)) record.cleanupError = asError(error);
476
+ });
477
+ }, this.#pollIntervalMs);
478
+ record.monitor.unref();
479
+ }
480
+
481
+ async #monitor(record: TmuxRecord): Promise<void> {
482
+ if (record.monitorInFlight || record.released) return;
483
+ record.monitorInFlight = true;
484
+ try {
485
+ const outputBeforeInput = record.lastOutputAt;
486
+ await this.#collectOutput(record);
487
+ if (record.inputAt && record.lastOutputAt && Date.parse(record.lastOutputAt) >= record.inputAt && record.lastOutputAt !== outputBeforeInput) record.turnObservedOutput = true;
488
+ const pane = await this.#paneStatus(record);
489
+ record.paneDead = pane.dead;
490
+ record.panePid = pane.pid;
491
+ if (!pane.dead) {
492
+ record.handle.pid = pane.pid;
493
+ await this.#rememberPaneIdentity(record, pane.pid);
494
+ }
495
+ if (pane.exitCode !== undefined) record.exitCode = pane.exitCode;
496
+ if (pane.dead) {
497
+ await this.#cleanup(record, false);
498
+ this.#emit(record, { type: "exited", handle: record.handle, exitCode: record.exitCode, signal: record.signal });
499
+ return;
500
+ }
501
+ const screen = await this.#capture(record);
502
+ if (record.activeRequests === 0 && hasPromptInput(screen)) {
503
+ // A human may have typed directly into the attached PTY. Treat that
504
+ // input as an active turn so automatic sends cannot race it.
505
+ record.activeRequests = 1;
506
+ record.turnObservedOutput = false;
507
+ record.inputAt = Date.now();
508
+ record.lastInputAt = new Date().toISOString();
509
+ record.readyStreak = 0;
510
+ }
511
+ if (record.activeRequests > 0) {
512
+ const ready = isReadyScreen(screen);
513
+ if (ready && record.turnObservedOutput && Date.now() - (record.inputAt ?? Date.now()) >= 500) record.readyStreak += 1;
514
+ else if (!ready) record.readyStreak = 0;
515
+ if (record.readyStreak >= 2) {
516
+ record.activeRequests = 0;
517
+ record.readyStreak = 0;
518
+ record.turnSequence += 1;
519
+ this.#emit(record, {
520
+ type: "turn_completed",
521
+ handle: record.handle,
522
+ sequence: record.turnSequence,
523
+ result: {
524
+ type: "result",
525
+ terminal_reason: "completed",
526
+ transport: "tmux",
527
+ session_name: record.sessionName,
528
+ screen_tail: boundText(stripAnsi(screen), 12_000),
529
+ },
530
+ });
531
+ }
532
+ }
533
+ } finally {
534
+ record.monitorInFlight = false;
535
+ }
536
+ }
537
+
538
+ async #waitForReady(record: TmuxRecord): Promise<void> {
539
+ const deadline = Date.now() + this.#startupTimeoutMs;
540
+ while (Date.now() < deadline) {
541
+ this.#assertNotAborted(record);
542
+ const screen = await this.#capture(record);
543
+ if (isReadyScreen(screen)) return;
544
+ await delay(Math.min(this.#pollIntervalMs, Math.max(1, deadline - Date.now())));
545
+ }
546
+ this.#assertNotAborted(record);
547
+ throw new Error(`tmux Claude session did not reach an input prompt before startup timeout; attach with ${attachCommand(record)}`);
548
+ }
549
+
550
+ async #waitForPaneExit(record: TmuxRecord, timeoutMs: number): Promise<void> {
551
+ const deadline = Date.now() + timeoutMs;
552
+ while (Date.now() < deadline) {
553
+ const pane = await this.#paneStatus(record).catch(() => ({ dead: true } as TmuxPaneStatus));
554
+ if (pane.dead) {
555
+ record.paneDead = true;
556
+ return;
557
+ }
558
+ await delay(50);
559
+ }
560
+ }
561
+
562
+ async #assertExistingSession(record: TmuxRecord, cwd: string, approval?: { actor: "human"; reason: string }): Promise<void> {
563
+ await this.#pinTarget(record);
564
+ const pane = await this.#paneStatus(record);
565
+ if (pane.dead) throw new Error("cannot adopt a dead tmux pane");
566
+ record.handle.pid = pane.pid;
567
+ const currentPath = await this.#run(record, ["display-message", "-p", "-t", record.target, "#{pane_current_path}"]);
568
+ if (currentPath.stdout.trim() !== cwd) throw new Error(`tmux session cwd mismatch: expected ${cwd}, got ${currentPath.stdout.trim()}`);
569
+ const command = (await this.#run(record, ["display-message", "-p", "-t", record.target, "#{pane_current_command}"])).stdout.trim();
570
+ let processArgs: { stdout: string; stderr: string };
571
+ if (!pane.pid) throw new Error("tmux pane pid is unavailable; refusing to adopt without command inspection");
572
+ try {
573
+ processArgs = await runCommand("ps", ["-o", "args=", "-p", String(pane.pid)], undefined, workerEnvironment(process.env), this.#commandTimeoutMs);
574
+ } catch (error) {
575
+ throw new Error(`cannot inspect tmux pane command: ${error instanceof Error ? error.message : String(error)}`);
576
+ }
577
+ const commandName = command.split(/[\\/]/u).at(-1) ?? command;
578
+ const argsText = processArgs.stdout.trim();
579
+ const processExecutable = argsText.split(/\s+/u)[0] ?? "";
580
+ const processExecutableName = processExecutable.split(/[\\/]/u).at(-1) ?? processExecutable;
581
+ const commandLine = `${command} ${argsText}`.trim();
582
+ if (commandName !== "claude" || !argsText || processExecutableName !== "claude") {
583
+ throw new Error(`tmux pane is not a Claude Code executable: ${redactSensitiveText(commandLine || "unknown")}`);
584
+ }
585
+ assertSafeWorkerCommand(command, [argsText], approval);
586
+ await this.#rememberPaneIdentity(record, pane.pid);
587
+ }
588
+
589
+ async #pinTarget(record: TmuxRecord): Promise<void> {
590
+ const pane = await this.#run(record, ["display-message", "-p", "-t", record.target, "#{pane_id}"]);
591
+ const paneId = pane.stdout.trim();
592
+ if (!/^%[0-9]+$/u.test(paneId)) throw new Error("tmux did not return a stable pane id");
593
+ record.target = paneId;
594
+ }
595
+
596
+ async #capture(record: TmuxRecord): Promise<string> {
597
+ const result = await this.#run(record, ["capture-pane", "-p", "-J", "-t", record.target, "-S", "-120"]);
598
+ return result.stdout;
599
+ }
600
+
601
+ async #paneStatus(record: TmuxRecord): Promise<TmuxPaneStatus> {
602
+ const result = await this.#run(record, ["display-message", "-p", "-t", record.target, "#{pane_dead}:#{pane_exit_status}:#{pane_pid}"]);
603
+ const [dead, exitCode, pid] = result.stdout.trim().split(":");
604
+ return {
605
+ dead: dead === "1",
606
+ exitCode: exitCode && exitCode !== "-1" ? Number(exitCode) : undefined,
607
+ pid: pid ? Number(pid) : undefined,
608
+ };
609
+ }
610
+
611
+ async #rememberPaneIdentity(record: TmuxRecord, pid: number | undefined): Promise<void> {
612
+ if (!pid || !Number.isInteger(pid) || pid <= 0) throw new Error("tmux pane pid is unavailable; refusing unverified control");
613
+ try {
614
+ const statText = await readFile(`/proc/${pid}/stat`, "utf8");
615
+ const closeParen = statText.lastIndexOf(")");
616
+ const fields = closeParen >= 0 ? statText.slice(closeParen + 2).trim().split(/\s+/u) : [];
617
+ const startTime = fields[19];
618
+ const command = (await readFile(`/proc/${pid}/comm`, "utf8")).trim();
619
+ if (!startTime) throw new Error(`cannot identify tmux pane pid ${pid}`);
620
+ if (record.paneStartTime && (record.paneStartTime !== startTime || record.paneCommand !== command)) {
621
+ throw new Error("tmux pane identity changed; refusing to control a replacement process");
622
+ }
623
+ record.paneStartTime = startTime;
624
+ record.paneCommand = command;
625
+ } catch (error) {
626
+ if (error instanceof Error && /tmux pane identity changed/u.test(error.message)) throw error;
627
+ throw new Error(`tmux pane identity unavailable for pid ${pid}`);
628
+ }
629
+ }
630
+
631
+ async #cleanup(record: TmuxRecord, _force: boolean): Promise<void> {
632
+ if (record.cleanupComplete) return;
633
+ // A later cleanup call is a retry, so do not let a transient prior error
634
+ // permanently poison a successful retry. The caller preserves errors from
635
+ // the current stop attempt around this boundary.
636
+ record.cleanupError = undefined;
637
+ if (!record.owned) {
638
+ if (record.monitor) clearInterval(record.monitor);
639
+ record.monitor = undefined;
640
+ await this.#detachPipe(record);
641
+ try { await this.#flushOutput(record); }
642
+ catch (error) { record.cleanupError ??= asError(error); }
643
+ try { await rm(record.runtimeDir, { recursive: true, force: true }); }
644
+ catch (error) { record.cleanupError ??= asError(error); }
645
+ record.cleanupComplete = !record.cleanupError;
646
+ return;
647
+ }
648
+ if (record.monitor) clearInterval(record.monitor);
649
+ record.monitor = undefined;
650
+ await this.#detachPipe(record);
651
+ try { await this.#flushOutput(record); }
652
+ catch (error) { record.cleanupError ??= asError(error); }
653
+ record.monitor = undefined;
654
+ try {
655
+ await this.#run(record, ["kill-server"], undefined, undefined, true);
656
+ record.serverKilled = true;
657
+ } catch (error) {
658
+ if (isMissingSession(error)) record.serverKilled = true;
659
+ else record.cleanupError = asError(error);
660
+ }
661
+ await this.#ensurePaneGone(record);
662
+ try { await rm(record.runtimeDir, { recursive: true, force: true }); }
663
+ catch (error) { record.cleanupError ??= asError(error); }
664
+ record.cleanupComplete = !record.cleanupError && Boolean(record.paneStartTime || (record.serverKilled && (!record.sessionCreated || record.paneDead)));
665
+ }
666
+
667
+ async #ensurePaneGone(record: TmuxRecord): Promise<void> {
668
+ const pid = record.panePid ?? record.handle.pid;
669
+ if (!pid || !record.paneStartTime) {
670
+ if (record.serverKilled && (!record.sessionCreated || record.paneDead)) return;
671
+ record.cleanupError = new Error("owned tmux cleanup lacks a verifiable pane identity");
672
+ return;
673
+ }
674
+ if (!isPidAlive(pid)) return;
675
+ if (!(await sameProcess(record, pid))) {
676
+ await this.#markReplacement(record, pid);
677
+ return;
678
+ }
679
+ signalProcessGroup(pid, "SIGTERM");
680
+ for (let attempt = 0; attempt < 10 && isPidAlive(pid); attempt += 1) await delay(50);
681
+ if (isPidAlive(pid)) {
682
+ if (!(await sameProcess(record, pid))) {
683
+ await this.#markReplacement(record, pid);
684
+ return;
685
+ }
686
+ signalProcessGroup(pid, "SIGKILL");
687
+ }
688
+ for (let attempt = 0; attempt < 10 && isPidAlive(pid); attempt += 1) await delay(50);
689
+ if (isPidAlive(pid)) {
690
+ if (await sameProcess(record, pid)) record.cleanupError = new Error(`tmux pane process did not exit: ${pid}`);
691
+ else await this.#markReplacement(record, pid);
692
+ }
693
+ }
694
+
695
+ async #markReplacement(record: TmuxRecord, pid: number): Promise<void> {
696
+ const identity = await processIdentity(pid);
697
+ if (identity) {
698
+ record.replacementPaneStartTime = identity.startTime;
699
+ record.replacementPaneCommand = identity.command;
700
+ record.cleanupError = new Error(`owned tmux cleanup refused replacement pane process pid=${pid} start=${identity.startTime} command=${identity.command}`);
701
+ } else {
702
+ record.cleanupError = new Error(`owned tmux cleanup refused an unverified replacement pane process pid=${pid}`);
703
+ }
704
+ }
705
+
706
+ #assertNotAborted(record: TmuxRecord): void {
707
+ if (record.abortRequested) throw new Error("tmux worker startup was aborted");
708
+ }
709
+
710
+ async #run(record: TmuxRecord, args: string[], input?: string, env = workerEnvironment(process.env), ignoreAbort = false): Promise<{ stdout: string; stderr: string }> {
711
+ if (record.abortRequested && !ignoreAbort) throw new Error("tmux worker startup was aborted");
712
+ const tmuxArgs = record.socketPath ? ["-S", record.socketPath, ...args] : args;
713
+ const result = await runCommand(this.#tmuxBinary, tmuxArgs, input, env, this.#commandTimeoutMs);
714
+ if (record.abortRequested && !ignoreAbort) throw new Error("tmux worker startup was aborted");
715
+ return result;
716
+ }
717
+
718
+ async #collectOutput(record: TmuxRecord): Promise<WorkerOutputChunk[]> {
719
+ return this.#withOutputLock(record, async () => this.#collectOutputUnlocked(record));
720
+ }
721
+
722
+ async #flushOutput(record: TmuxRecord): Promise<void> {
723
+ let previousSize = -1;
724
+ let stableReads = 0;
725
+ for (let attempt = 0; attempt < 20 && stableReads < 2; attempt += 1) {
726
+ await this.#collectOutput(record);
727
+ try {
728
+ const size = (await stat(record.logPath)).size;
729
+ stableReads = size === previousSize ? stableReads + 1 : 0;
730
+ previousSize = size;
731
+ } catch (error) {
732
+ if (!isMissingFile(error)) throw error;
733
+ return;
734
+ }
735
+ if (stableReads < 2) await delay(25);
736
+ }
737
+ }
738
+
739
+ async #collectOutputUnlocked(record: TmuxRecord): Promise<WorkerOutputChunk[]> {
740
+ try {
741
+ const initial = await stat(record.logPath);
742
+ if (initial.size > this.#maxLogBytes) {
743
+ await truncate(record.logPath, 0);
744
+ record.outputOffset = 0;
745
+ record.outputTruncated = true;
746
+ }
747
+ const snapshot = await stat(record.logPath);
748
+ if (record.outputOffset > snapshot.size) record.outputOffset = 0;
749
+ const start = Math.max(record.outputOffset, snapshot.size - this.#maxLogBytes);
750
+ if (start > record.outputOffset) {
751
+ record.outputOffset = start;
752
+ record.outputTruncated = true;
753
+ }
754
+ const length = snapshot.size - start;
755
+ if (length > 0) {
756
+ const file = await open(record.logPath, "r");
757
+ try {
758
+ const buffer = Buffer.alloc(length);
759
+ const { bytesRead } = await file.read(buffer, 0, length, start);
760
+ if (bytesRead > 0) {
761
+ const text = buffer.subarray(0, bytesRead).toString("utf8");
762
+ record.outputOffset = start + bytesRead;
763
+ record.lastOutputAt = new Date().toISOString();
764
+ this.#appendOutput(record, { stream: "stdout", text, at: record.lastOutputAt });
765
+ }
766
+ } finally {
767
+ await file.close();
768
+ }
769
+ }
770
+ const after = await stat(record.logPath);
771
+ if (after.size > this.#maxLogBytes) {
772
+ await truncate(record.logPath, 0);
773
+ record.outputOffset = 0;
774
+ record.outputTruncated = true;
775
+ }
776
+ } catch (error) {
777
+ if (!isMissingFile(error)) throw error;
778
+ }
779
+ return [...record.output];
780
+ }
781
+
782
+ async #withOutputLock<T>(record: TmuxRecord, operation: () => Promise<T>): Promise<T> {
783
+ let release!: () => void;
784
+ const gate = new Promise<void>((resolve) => { release = resolve; });
785
+ const previous = record.outputTail;
786
+ record.outputTail = previous.then(() => gate);
787
+ await previous;
788
+ try { return await operation(); }
789
+ finally { release(); }
790
+ }
791
+
792
+ #appendOutput(record: TmuxRecord, chunk: WorkerOutputChunk): void {
793
+ let text = chunk.text;
794
+ if (Buffer.byteLength(text, "utf8") > this.#maxOutputBytes) {
795
+ text = Buffer.from(text, "utf8").subarray(-this.#maxOutputBytes).toString("utf8");
796
+ record.outputTruncated = true;
797
+ }
798
+ record.output.push({ ...chunk, text });
799
+ record.outputBytes += Buffer.byteLength(text, "utf8");
800
+ while (record.outputBytes > this.#maxOutputBytes) {
801
+ const removed = record.output.shift();
802
+ if (!removed) break;
803
+ record.outputBytes -= Buffer.byteLength(removed.text, "utf8");
804
+ record.outputTruncated = true;
805
+ }
806
+ }
807
+
808
+ #status(record: TmuxRecord, running: boolean): WorkerStatus {
809
+ return {
810
+ handle: record.handle,
811
+ running,
812
+ exitCode: record.exitCode,
813
+ signal: record.signal,
814
+ lastOutputAt: record.lastOutputAt,
815
+ lastInputAt: record.lastInputAt,
816
+ activeRequests: record.activeRequests,
817
+ exitReason: running ? undefined : record.stopping ? "stopped" : record.exitCode === 0 ? "completed" : "failed",
818
+ processGroupCleaned: record.cleanupComplete,
819
+ cleanupError: record.cleanupError?.message,
820
+ outputTruncated: record.outputTruncated,
821
+ };
822
+ }
823
+
824
+ #record(handle: WorkerHandle): TmuxRecord {
825
+ const record = this.#records.get(handle.id);
826
+ if (!record) throw new Error(`unknown tmux worker handle: ${handle.id}`);
827
+ return record;
828
+ }
829
+
830
+ #emit(record: TmuxRecord, event: Parameters<WorkerEventListener>[0]): void {
831
+ for (const listener of record.listeners) {
832
+ try {
833
+ const result = listener(event);
834
+ if (result && typeof (result as Promise<void>).catch === "function") void (result as Promise<void>).catch(() => {});
835
+ } catch {
836
+ // Lifecycle observers must not break the PTY transport.
837
+ }
838
+ }
839
+ }
840
+ }
841
+
842
+ export function attachCommand(handle: Pick<WorkerHandle, "tmuxSocket" | "sessionName">): string {
843
+ const target = shellQuote(handle.sessionName ?? "");
844
+ return handle.tmuxSocket ? `tmux -S ${shellQuote(handle.tmuxSocket)} attach -t ${target}` : `tmux attach -t ${target}`;
845
+ }
846
+
847
+ function launcherSource(spec: { command: string; args: string[]; cwd: string }): string {
848
+ return `import { spawn } from "node:child_process";\nconst spec = ${JSON.stringify(spec)};\nconst child = spawn(spec.command, spec.args, { cwd: spec.cwd, env: process.env, stdio: "inherit" });\nchild.once("error", (error) => { console.error(error.message); process.exitCode = 127; });\nchild.once("exit", (code, signal) => { if (signal) process.kill(process.pid, signal); else process.exitCode = code ?? 1; });\n`;
849
+ }
850
+
851
+ function runCommand(command: string, args: string[], input: string | undefined, env: NodeJS.ProcessEnv, timeoutMs: number): Promise<{ stdout: string; stderr: string }> {
852
+ return new Promise((resolve, reject) => {
853
+ const child = spawn(command, args, { env, stdio: ["pipe", "pipe", "pipe"] });
854
+ let stdout = "";
855
+ let stderr = "";
856
+ let settled = false;
857
+ const timer = setTimeout(() => {
858
+ if (settled) return;
859
+ settled = true;
860
+ child.kill("SIGKILL");
861
+ reject(new Error(`tmux command timed out after ${timeoutMs}ms: ${args.join(" ")}`));
862
+ }, timeoutMs);
863
+ timer.unref();
864
+ child.stdout.setEncoding("utf8");
865
+ child.stderr.setEncoding("utf8");
866
+ child.stdout.on("data", (chunk) => { stdout += String(chunk); });
867
+ child.stderr.on("data", (chunk) => { stderr += String(chunk); });
868
+ child.once("error", (error) => {
869
+ if (settled) return;
870
+ settled = true;
871
+ clearTimeout(timer);
872
+ reject(error);
873
+ });
874
+ child.once("close", (code, signal) => {
875
+ if (settled) return;
876
+ settled = true;
877
+ clearTimeout(timer);
878
+ if (code === 0) resolve({ stdout, stderr });
879
+ else reject(new Error(`tmux command failed (${code ?? signal ?? "unknown"}): ${stderr.trim() || args.join(" ")}`));
880
+ });
881
+ child.stdin.end(input);
882
+ });
883
+ }
884
+
885
+ function isReadyScreen(screen: string): boolean {
886
+ const normalized = stripAnsi(screen).replaceAll("\u00a0", " ");
887
+ // Claude keeps an empty input line visible while it is thinking. The status
888
+ // bar's interrupt affordance is stronger evidence than that prompt glyph.
889
+ if (/esc to interrupt/iu.test(normalized.slice(-800))) return false;
890
+ return latestPrompt(normalized) === "ready";
891
+ }
892
+
893
+ function hasPromptInput(screen: string): boolean {
894
+ // `>` is retained as a fixture-compatible ready marker, but is too common in
895
+ // arbitrary command output to identify human typing. Claude's TUI uses ❯/›.
896
+ return latestPrompt(stripAnsi(screen).replaceAll("\u00a0", " "), false) === "input";
897
+ }
898
+
899
+ function latestPrompt(screen: string, allowAsciiMarker = true): "ready" | "input" | "none" {
900
+ const lines = screen.split(/\r?\n/u).map((line) => line.trim()).filter(Boolean).slice(-24);
901
+ const marker = allowAsciiMarker ? "❯|›|>" : "❯|›";
902
+ const promptLines = lines.filter((line) => new RegExp(`^(?:${marker})(?:\\s.*)?$`, "u").test(line));
903
+ const latest = promptLines.at(-1);
904
+ if (latest === undefined) return "none";
905
+ if (latest === ">") return "ready";
906
+ if (/^(?:❯|›)$/u.test(latest)) {
907
+ const promptIndex = lines.lastIndexOf(latest);
908
+ const hasInputSeparator = lines.slice(promptIndex + 1).some((line) => /^[-─]{20,}$/u.test(line));
909
+ if (!hasInputSeparator) return "none";
910
+ }
911
+ return /^(?:❯|›)$/u.test(latest) ? "ready" : "input";
912
+ }
913
+
914
+ function stripAnsi(value: string): string {
915
+ return value.replace(/[\u001B\u009B][[\]()#;?]*(?:(?:[a-zA-Z\d]*(?:;[-a-zA-Z\d/#&.:=?%@~_]+)*)?\u0007|(?:(?:\d{1,4}(?:[;:][\d]{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]))/gu, "");
916
+ }
917
+
918
+ function assertNoCredentialArguments(command: string, args: string[]): void {
919
+ const values = [command, ...args];
920
+ if (values.some((value) => /(?:sk-ant-|(?:api[-_]?key|token|secret|password|authorization)(?:=|$)|(?:ANTHROPIC|OPENAI|AWS)_[A-Z0-9_]*(?:KEY|TOKEN|SECRET)=)/iu.test(value))) {
921
+ throw new Error("tmux launcher refuses credential-shaped command arguments; pass credentials through the explicit worker environment");
922
+ }
923
+ }
924
+
925
+ function redactSensitiveText(value: string): string {
926
+ return String(redactSensitive(value));
927
+ }
928
+
929
+ function safeTmuxMessage(value: string): string {
930
+ const normalized = value.replaceAll(String.fromCharCode(13, 10), "\n");
931
+ for (const character of normalized) {
932
+ const code = character.codePointAt(0) ?? 0;
933
+ if (code <= 8 || code === 11 || code === 12 || (code >= 13 && code <= 31) || code === 127 || (code >= 128 && code <= 159)) {
934
+ throw new Error("tmux input contains terminal control bytes; refusing to send it");
935
+ }
936
+ }
937
+ return normalized;
938
+ }
939
+
940
+ function boundText(value: string, maxBytes: number): string {
941
+ const bytes = Buffer.from(value, "utf8");
942
+ return bytes.byteLength <= maxBytes ? value : bytes.subarray(-maxBytes).toString("utf8");
943
+ }
944
+
945
+ function shellQuote(value: string): string {
946
+ return `'${value.replaceAll("'", "'\\''")}'`;
947
+ }
948
+
949
+ function signalProcessGroup(pid: number, signal: NodeJS.Signals): void {
950
+ try { process.kill(-pid, signal); }
951
+ catch (error) {
952
+ if (error instanceof Error && /ESRCH/u.test(error.message)) {
953
+ try { process.kill(pid, signal); } catch (fallback) {
954
+ if (!(fallback instanceof Error) || !/ESRCH/u.test(fallback.message)) throw fallback;
955
+ }
956
+ return;
957
+ }
958
+ throw error;
959
+ }
960
+ }
961
+
962
+ interface ProcessIdentity {
963
+ startTime: string;
964
+ command: string;
965
+ }
966
+
967
+ async function processIdentity(pid: number): Promise<ProcessIdentity | undefined> {
968
+ try {
969
+ const statText = await readFile(`/proc/${pid}/stat`, "utf8");
970
+ const closeParen = statText.lastIndexOf(")");
971
+ const fields = closeParen >= 0 ? statText.slice(closeParen + 2).trim().split(/\s+/u) : [];
972
+ const startTime = fields[19];
973
+ const command = (await readFile(`/proc/${pid}/comm`, "utf8")).trim();
974
+ return startTime && command ? { startTime, command } : undefined;
975
+ } catch {
976
+ return undefined;
977
+ }
978
+ }
979
+
980
+ async function sameProcess(record: TmuxRecord, pid: number): Promise<boolean> {
981
+ const identity = await processIdentity(pid);
982
+ return Boolean(identity && identity.startTime === record.paneStartTime && (!record.paneCommand || identity.command === record.paneCommand));
983
+ }
984
+
985
+ function isPidAlive(pid: number): boolean {
986
+ try {
987
+ process.kill(pid, 0);
988
+ return true;
989
+ } catch (error) {
990
+ return error instanceof Error && /EPERM/u.test(error.message);
991
+ }
992
+ }
993
+
994
+ function boundedDelay(value: number): number {
995
+ if (!Number.isFinite(value) || value < 1) throw new Error("tmux adapter delays must be positive finite numbers");
996
+ return value;
997
+ }
998
+
999
+ function asError(error: unknown): Error {
1000
+ return error instanceof Error ? error : new Error(String(error));
1001
+ }
1002
+
1003
+ function isPaneIdentityError(error: unknown): boolean {
1004
+ return error instanceof Error && /tmux pane identity changed|pane identity unavailable|cannot identify tmux pane pid/iu.test(error.message);
1005
+ }
1006
+
1007
+ function isMissingSession(error: unknown): boolean {
1008
+ return error instanceof Error && /(can't find session|no server running|session not found|failed to connect)/iu.test(error.message);
1009
+ }
1010
+
1011
+ function isMissingFile(error: unknown): boolean {
1012
+ return error instanceof Error && /ENOENT/u.test(error.message);
1013
+ }
1014
+
1015
+ function delay(ms: number): Promise<void> {
1016
+ return new Promise((resolve) => setTimeout(resolve, ms));
1017
+ }