@calltelemetry/openclaw-linear 0.9.16 → 0.9.18

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.
@@ -1,131 +1,54 @@
1
- /**
2
- * tmux-runner.ts Shared tmux runner with pipe-pane JSONL streaming
3
- * and in-memory session registry.
4
- *
5
- * Wraps CLI processes (Claude, Codex, Gemini) in tmux sessions, captures
6
- * JSONL output via pipe-pane log files, and streams parsed events as Linear
7
- * activities. Provides a session registry for steering (Phase 2) and
8
- * orphan recovery on gateway restart.
9
- *
10
- * Flow:
11
- * 1. Create tmux session + pipe-pane → JSONL log file
12
- * 2. Send CLI command via sendKeys
13
- * 3. Tail log file with fs.watch() + manual offset tracking
14
- * 4. Parse JSONL lines → tick watchdog → emit activities → collect output
15
- * 5. Detect completion (exit marker, session death, timeout, or watchdog kill)
16
- * 6. Clean up and return CliResult
17
- */
18
- import {
19
- createSession,
20
- setupPipePane,
21
- sendKeys,
22
- killSession,
23
- sessionExists,
24
- listSessions,
25
- } from "./tmux.js";
26
- import { InactivityWatchdog } from "../agent/watchdog.js";
27
- import type { ActivityContent } from "../api/linear-api.js";
28
- import type { LinearAgentApi } from "../api/linear-api.js";
1
+ import { execSync, spawn } from "node:child_process";
2
+ import { createInterface } from "node:readline";
3
+ import { mkdirSync, createWriteStream } from "node:fs";
4
+ import { dirname } from "node:path";
5
+ import type { ActivityContent, LinearAgentApi } from "../api/linear-api.js";
29
6
  import type { CliResult, OnProgressUpdate } from "../tools/cli-shared.js";
30
7
  import { formatActivityLogLine, createProgressEmitter } from "../tools/cli-shared.js";
31
- import type { DispatchState } from "../pipeline/dispatch-state.js";
32
- import {
33
- writeFileSync,
34
- mkdirSync,
35
- openSync,
36
- readSync,
37
- closeSync,
38
- statSync,
39
- watch,
40
- type FSWatcher,
41
- } from "node:fs";
42
- import { dirname } from "node:path";
43
-
44
- // ---------------------------------------------------------------------------
45
- // Session Registry
46
- // ---------------------------------------------------------------------------
8
+ import { InactivityWatchdog } from "../agent/watchdog.js";
9
+ import { shellEscape } from "./tmux.js";
47
10
 
48
- export interface TmuxSessionInfo {
11
+ export interface TmuxSession {
49
12
  sessionName: string;
50
13
  backend: string;
51
- issueId: string; // Linear UUID — matches activeRuns key
52
- issueIdentifier: string; // Human-friendly key (UAT-123)
53
- startedAt: number;
54
- steeringMode: "stdin-pipe" | "one-shot";
55
- }
56
-
57
- const activeTmuxSessions = new Map<string, TmuxSessionInfo>();
58
-
59
- /**
60
- * Look up an active tmux session by Linear issue UUID.
61
- * Returns null if no session is registered for this issue.
62
- */
63
- export function getActiveTmuxSession(issueId: string): TmuxSessionInfo | null {
64
- return activeTmuxSessions.get(issueId) ?? null;
65
- }
66
-
67
- /**
68
- * Register a tmux session in the in-memory map.
69
- * Keyed by issueId (Linear UUID) to match the activeRuns set.
70
- */
71
- export function registerTmuxSession(info: TmuxSessionInfo): void {
72
- activeTmuxSessions.set(info.issueId, info);
73
- }
74
-
75
- /**
76
- * Remove a tmux session from the registry.
77
- */
78
- export function unregisterTmuxSession(issueId: string): void {
79
- activeTmuxSessions.delete(issueId);
80
- }
81
-
82
- /**
83
- * List all registered tmux sessions (for diagnostics).
84
- */
85
- export function listRegisteredSessions(): TmuxSessionInfo[] {
86
- return Array.from(activeTmuxSessions.values());
14
+ issueIdentifier: string;
15
+ issueId: string;
16
+ steeringMode: string;
87
17
  }
88
18
 
89
- // ---------------------------------------------------------------------------
90
- // Exit marker — appended after the CLI command so we can detect completion
91
- // ---------------------------------------------------------------------------
92
-
93
- const EXIT_MARKER = "::TMUX_EXIT::";
94
-
95
- // ---------------------------------------------------------------------------
96
- // TmuxRunnerOpts
97
- // ---------------------------------------------------------------------------
98
-
99
- export interface TmuxRunnerOpts {
19
+ export interface RunInTmuxOptions {
100
20
  issueId: string;
101
21
  issueIdentifier: string;
102
22
  sessionName: string;
103
- command: string; // Full CLI command string (shell-escaped)
23
+ command: string;
104
24
  cwd: string;
105
25
  timeoutMs: number;
106
26
  watchdogMs: number;
107
- logPath: string; // pipe-pane JSONL log path
27
+ logPath: string;
108
28
  mapEvent: (event: any) => ActivityContent | null;
109
29
  linearApi?: LinearAgentApi;
110
30
  agentSessionId?: string;
111
31
  steeringMode: "stdin-pipe" | "one-shot";
112
- logger?: { info: (...a: any[]) => void; warn: (...a: any[]) => void };
32
+ logger: { info: (msg: string) => void; warn: (msg: string) => void; error: (msg: string) => void; debug?: (msg: string) => void };
113
33
  onUpdate?: OnProgressUpdate;
114
- progressHeader?: string;
34
+ progressHeader: string;
115
35
  }
116
36
 
117
- // ---------------------------------------------------------------------------
118
- // runInTmux
119
- // ---------------------------------------------------------------------------
37
+ // Track active tmux sessions by issueId
38
+ const activeSessions = new Map<string, TmuxSession>();
39
+
40
+ /**
41
+ * Get the active tmux session for a given issueId, or null if none.
42
+ */
43
+ export function getActiveTmuxSession(issueId: string): TmuxSession | null {
44
+ return activeSessions.get(issueId) ?? null;
45
+ }
120
46
 
121
47
  /**
122
- * Run a CLI command inside a tmux session with pipe-pane JSONL streaming.
123
- *
124
- * Creates the tmux session, pipes output to a JSONL log file, tails the
125
- * log with fs.watch(), parses events, streams activities to Linear, and
126
- * returns a CliResult when the process completes (or is killed).
48
+ * Run a command inside a tmux session with pipe-pane streaming to a JSONL log.
49
+ * Monitors the log file for events and streams them to Linear.
127
50
  */
128
- export async function runInTmux(opts: TmuxRunnerOpts): Promise<CliResult> {
51
+ export async function runInTmux(opts: RunInTmuxOptions): Promise<CliResult> {
129
52
  const {
130
53
  issueId,
131
54
  issueIdentifier,
@@ -140,460 +63,163 @@ export async function runInTmux(opts: TmuxRunnerOpts): Promise<CliResult> {
140
63
  agentSessionId,
141
64
  steeringMode,
142
65
  logger,
66
+ onUpdate,
67
+ progressHeader,
143
68
  } = opts;
144
69
 
145
- const log = logger ?? {
146
- info: (...a: any[]) => console.log("[tmux-runner]", ...a),
147
- warn: (...a: any[]) => console.warn("[tmux-runner]", ...a),
148
- };
149
-
150
- // 1. Ensure log directory and file exist
151
- const logDir = dirname(logPath);
152
- mkdirSync(logDir, { recursive: true });
153
- writeFileSync(logPath, "", { flag: "w" });
154
-
155
- // 2. Create tmux session
156
- log.info(`Creating tmux session: ${sessionName} in ${cwd}`);
157
- createSession(sessionName, cwd);
70
+ // Ensure log directory exists
71
+ mkdirSync(dirname(logPath), { recursive: true });
158
72
 
159
- // 3. Set up pipe-pane to stream JSONL to the log file
160
- setupPipePane(sessionName, logPath);
161
-
162
- // 4. Register in session map
163
- const sessionInfo: TmuxSessionInfo = {
73
+ // Register active session
74
+ const session: TmuxSession = {
164
75
  sessionName,
165
- backend: extractBackend(sessionName),
166
- issueId,
76
+ backend: sessionName.split("-").slice(-2, -1)[0] ?? "unknown",
167
77
  issueIdentifier,
168
- startedAt: Date.now(),
78
+ issueId,
169
79
  steeringMode,
170
80
  };
171
- registerTmuxSession(sessionInfo);
172
-
173
- // 5. Send the CLI command, chained with exit marker echo
174
- // Use ; (not &&) so the marker fires even if the command fails.
175
- // The echo writes a JSON object to stdout which pipe-pane captures.
176
- const exitEcho = `echo '{"type":"::TMUX_EXIT::","exitCode":'$?'}'`;
177
- sendKeys(sessionName, `${command} ; ${exitEcho}`);
81
+ activeSessions.set(issueId, session);
178
82
 
179
- log.info(`Command sent to ${sessionName}: ${command.slice(0, 200)}...`);
180
-
181
- // 5b. Set up session progress emitter
182
- const progress = createProgressEmitter({
183
- header: opts.progressHeader ?? `[${extractBackend(sessionName)}] ${cwd}\n$ ${command}`,
184
- onUpdate: opts.onUpdate,
185
- });
83
+ const progress = createProgressEmitter({ header: progressHeader, onUpdate });
186
84
  progress.emitHeader();
187
85
 
188
- // 6. Start tailing the log file
189
- return new Promise<CliResult>((resolve) => {
190
- let resolved = false;
191
- let killed = false;
192
- let killedByWatchdog = false;
193
- let exitCode: number | null = null;
194
-
195
- // Collected output for CliResult
196
- const collectedMessages: string[] = [];
197
- const collectedCommands: string[] = [];
198
-
199
- // File read offset tracking
200
- let bytesRead = 0;
201
- let lineBuffer = "";
86
+ try {
87
+ // Create tmux session running the command, piping output to logPath
88
+ const tmuxCmd = [
89
+ `tmux new-session -d -s ${shellEscape(sessionName)} -c ${shellEscape(cwd)}`,
90
+ `${shellEscape(command)} 2>&1 | tee ${shellEscape(logPath)}`,
91
+ ].join(" ");
92
+
93
+ execSync(tmuxCmd, { stdio: "ignore", timeout: 10_000 });
94
+
95
+ // Tail the log file and process JSONL events
96
+ return await new Promise<CliResult>((resolve) => {
97
+ const tail = spawn("tail", ["-f", "-n", "+1", logPath], {
98
+ stdio: ["ignore", "pipe", "ignore"],
99
+ });
202
100
 
203
- // Watcher and timers
204
- let watcher: FSWatcher | null = null;
205
- let hardTimer: ReturnType<typeof setTimeout> | null = null;
206
- let pollTimer: ReturnType<typeof setInterval> | null = null;
101
+ let killed = false;
102
+ let killedByWatchdog = false;
103
+ const collectedMessages: string[] = [];
207
104
 
208
- // --- Watchdog ---
209
- const watchdog = new InactivityWatchdog({
210
- inactivityMs: watchdogMs,
211
- label: `tmux:${sessionName}`,
212
- logger: log,
213
- onKill: () => {
214
- killedByWatchdog = true;
105
+ const timer = setTimeout(() => {
215
106
  killed = true;
216
- log.warn(`Watchdog killed tmux session: ${sessionName}`);
217
- killSession(sessionName);
218
- finish();
219
- },
220
- });
221
-
222
- // --- Process new bytes from the log file ---
223
- function readNewBytes(): void {
224
- let fd: number | null = null;
225
- try {
226
- // Get current file size
227
- const stats = statSync(logPath);
228
- const fileSize = stats.size;
229
- if (fileSize <= bytesRead) return;
230
-
231
- fd = openSync(logPath, "r");
232
- const toRead = fileSize - bytesRead;
233
- const buf = Buffer.alloc(toRead);
234
- const nread = readSync(fd, buf, 0, toRead, bytesRead);
235
- closeSync(fd);
236
- fd = null;
237
-
238
- if (nread <= 0) return;
239
- bytesRead += nread;
240
-
241
- // Combine with leftover from previous read
242
- const chunk = lineBuffer + buf.toString("utf8", 0, nread);
243
- const lines = chunk.split("\n");
244
-
245
- // Last element is either empty (line ended with \n) or a partial line
246
- lineBuffer = lines.pop() ?? "";
247
-
248
- for (const line of lines) {
249
- const trimmed = line.trim();
250
- if (!trimmed) continue;
251
- processLine(trimmed);
252
- }
253
- } catch (err: any) {
254
- // File may have been deleted or is inaccessible during cleanup
255
- if (err.code !== "ENOENT") {
256
- log.warn(`Error reading log file: ${err.message}`);
257
- }
258
- } finally {
259
- if (fd !== null) {
260
- try { closeSync(fd); } catch { /* already closed */ }
261
- }
262
- }
263
- }
264
-
265
- // --- Process a single JSONL line ---
266
- function processLine(line: string): void {
267
- watchdog.tick();
268
-
269
- let event: any;
270
- try {
271
- event = JSON.parse(line);
272
- } catch {
273
- // Non-JSON line that made it through the grep filter — collect as raw output
274
- collectedMessages.push(line);
275
- return;
276
- }
277
-
278
- // Check for our exit marker
279
- if (event?.type === EXIT_MARKER) {
280
- exitCode = typeof event.exitCode === "number" ? event.exitCode : null;
281
- // Don't finish yet — let the session poll detect death
282
- // (there may be trailing events still being written)
283
- return;
284
- }
285
-
286
- // Collect structured output (same pattern as codex-tool.ts)
287
- const item = event?.item;
288
- const eventType = event?.type;
289
-
290
- // Collect agent messages
291
- if (
292
- (eventType === "item.completed" || eventType === "item.started") &&
293
- (item?.type === "agent_message" || item?.type === "message")
294
- ) {
295
- const text = item.text ?? item.content ?? "";
296
- if (text) collectedMessages.push(text);
297
- }
107
+ cleanup("timeout");
108
+ }, timeoutMs);
109
+
110
+ const watchdog = new InactivityWatchdog({
111
+ inactivityMs: watchdogMs,
112
+ label: `tmux:${sessionName}`,
113
+ logger,
114
+ onKill: () => {
115
+ killedByWatchdog = true;
116
+ killed = true;
117
+ cleanup("inactivity_timeout");
118
+ },
119
+ });
120
+ watchdog.start();
121
+
122
+ function cleanup(reason: string) {
123
+ clearTimeout(timer);
124
+ watchdog.stop();
125
+ tail.kill();
126
+
127
+ // Kill the tmux session
128
+ try {
129
+ execSync(`tmux kill-session -t ${shellEscape(sessionName)}`, {
130
+ stdio: "ignore",
131
+ timeout: 5_000,
132
+ });
133
+ } catch { /* session may already be gone */ }
298
134
 
299
- // Collect assistant text blocks (Claude format)
300
- if (eventType === "assistant" || eventType === "result") {
301
- const text = event?.text ?? event?.result ?? "";
302
- if (text) collectedMessages.push(text);
303
- }
135
+ activeSessions.delete(issueId);
304
136
 
305
- // Collect completed commands
306
- if (eventType === "item.completed" && item?.type === "command_execution") {
307
- const cmd = item.command ?? "unknown";
308
- const code = item.exit_code ?? "?";
309
- const output = item.aggregated_output ?? item.output ?? "";
310
- const cleanCmd = typeof cmd === "string"
311
- ? cmd.replace(/^\/usr\/bin\/\w+ -lc ['"]?/, "").replace(/['"]?$/, "")
312
- : String(cmd);
313
- const truncOutput = output.length > 500 ? output.slice(0, 500) + "..." : output;
314
- collectedCommands.push(
315
- `\`${cleanCmd}\` -> exit ${code}${truncOutput ? "\n```\n" + truncOutput + "\n```" : ""}`,
316
- );
317
- }
137
+ const output = collectedMessages.join("\n\n") || "(no output)";
318
138
 
319
- // Map event to activity and emit to Linear + session progress
320
- const activity = mapEvent(event);
321
- if (activity) {
322
- if (linearApi && agentSessionId) {
323
- linearApi.emitActivity(agentSessionId, activity).catch((err) => {
324
- log.warn(`Failed to emit activity: ${err}`);
139
+ if (reason === "inactivity_timeout") {
140
+ logger.warn(`tmux session ${sessionName} killed by inactivity watchdog`);
141
+ resolve({
142
+ success: false,
143
+ output: `Agent killed by inactivity watchdog (no I/O for ${Math.round(watchdogMs / 1000)}s). Partial output:\n${output}`,
144
+ error: "inactivity_timeout",
145
+ });
146
+ } else if (reason === "timeout") {
147
+ logger.warn(`tmux session ${sessionName} timed out after ${Math.round(timeoutMs / 1000)}s`);
148
+ resolve({
149
+ success: false,
150
+ output: `Agent timed out after ${Math.round(timeoutMs / 1000)}s. Partial output:\n${output}`,
151
+ error: "timeout",
325
152
  });
153
+ } else {
154
+ // Normal completion
155
+ resolve({ success: true, output });
326
156
  }
327
- progress.push(formatActivityLogLine(activity));
328
157
  }
329
- }
330
158
 
331
- // --- Finish: resolve the promise ---
332
- function finish(): void {
333
- if (resolved) return;
334
- resolved = true;
335
-
336
- // Stop all watchers and timers
337
- if (watcher) {
338
- try { watcher.close(); } catch { /* ignore */ }
339
- watcher = null;
340
- }
341
- if (hardTimer) {
342
- clearTimeout(hardTimer);
343
- hardTimer = null;
344
- }
345
- if (pollTimer) {
346
- clearInterval(pollTimer);
347
- pollTimer = null;
348
- }
349
- watchdog.stop();
350
-
351
- // Final read to catch any trailing output
352
- readNewBytes();
353
- // Process any remaining partial line
354
- if (lineBuffer.trim()) {
355
- processLine(lineBuffer.trim());
356
- lineBuffer = "";
357
- }
159
+ const rl = createInterface({ input: tail.stdout! });
160
+ rl.on("line", (line) => {
161
+ if (!line.trim()) return;
162
+ watchdog.tick();
358
163
 
359
- // Unregister session
360
- unregisterTmuxSession(issueId);
361
-
362
- // Kill the session if it's still alive (defensive cleanup)
363
- if (sessionExists(sessionName)) {
364
- killSession(sessionName);
365
- }
366
-
367
- // Build result
368
- const parts: string[] = [];
369
- if (collectedMessages.length > 0) parts.push(collectedMessages.join("\n\n"));
370
- if (collectedCommands.length > 0) parts.push(collectedCommands.join("\n\n"));
371
- const output = parts.join("\n\n") || "(no output)";
372
-
373
- if (killed) {
374
- const errorType = killedByWatchdog ? "inactivity_timeout" : "timeout";
375
- const reason = killedByWatchdog
376
- ? `Killed by inactivity watchdog (no I/O for ${Math.round(watchdogMs / 1000)}s)`
377
- : `Hard timeout after ${Math.round(timeoutMs / 1000)}s`;
378
- log.warn(`${sessionName}: ${reason}`);
379
- resolve({
380
- success: false,
381
- output: `${reason}. Partial output:\n${output}`,
382
- error: errorType,
383
- });
384
- return;
385
- }
164
+ let event: any;
165
+ try {
166
+ event = JSON.parse(line);
167
+ } catch {
168
+ collectedMessages.push(line);
169
+ return;
170
+ }
386
171
 
387
- if (exitCode !== null && exitCode !== 0) {
388
- log.warn(`${sessionName}: exited with code ${exitCode}`);
389
- resolve({
390
- success: false,
391
- output: `CLI failed (exit ${exitCode}):\n${output}`,
392
- error: `exit ${exitCode}`,
393
- });
394
- return;
395
- }
172
+ // Collect text for output
173
+ if (event.type === "assistant") {
174
+ const content = event.message?.content;
175
+ if (Array.isArray(content)) {
176
+ for (const block of content) {
177
+ if (block.type === "text" && block.text) {
178
+ collectedMessages.push(block.text);
179
+ }
180
+ }
181
+ }
182
+ }
396
183
 
397
- log.info(`${sessionName}: completed successfully`);
398
- resolve({ success: true, output });
399
- }
184
+ // Stream to Linear
185
+ const activity = mapEvent(event);
186
+ if (activity) {
187
+ if (linearApi && agentSessionId) {
188
+ linearApi.emitActivity(agentSessionId, activity).catch((err) => {
189
+ logger.warn(`Failed to emit tmux activity: ${err}`);
190
+ });
191
+ }
192
+ progress.push(formatActivityLogLine(activity));
193
+ }
400
194
 
401
- // --- Start watching the log file ---
402
- try {
403
- watcher = watch(logPath, () => {
404
- readNewBytes();
405
- });
406
- watcher.on("error", () => {
407
- // Watcher errors are non-fatal — we still have the poll fallback
195
+ // Detect completion
196
+ if (event.type === "result") {
197
+ cleanup("done");
198
+ rl.close();
199
+ }
408
200
  });
409
- } catch {
410
- // fs.watch() may not be available — poll-only mode
411
- log.warn(`fs.watch() unavailable for ${logPath}, using poll-only mode`);
412
- }
413
-
414
- // --- Poll for session death + read any new bytes ---
415
- // fs.watch() can miss events on some filesystems, so we also poll.
416
- // Check every 2 seconds: read new bytes + check if session is still alive.
417
- pollTimer = setInterval(() => {
418
- readNewBytes();
419
-
420
- // Check if the tmux session has died
421
- if (!sessionExists(sessionName)) {
422
- // Give a short grace period for final pipe-pane flush
423
- setTimeout(() => {
424
- readNewBytes();
425
- finish();
426
- }, 500);
427
- return;
428
- }
429
-
430
- // If we already saw the exit marker, check if the session has exited
431
- if (exitCode !== null) {
432
- // The CLI command finished — wait briefly for session cleanup
433
- setTimeout(() => {
434
- readNewBytes();
435
- finish();
436
- }, 1000);
437
- }
438
- }, 2000);
439
-
440
- // --- Hard timeout ---
441
- hardTimer = setTimeout(() => {
442
- if (resolved) return;
443
- killed = true;
444
- log.warn(`${sessionName}: hard timeout (${Math.round(timeoutMs / 1000)}s)`);
445
- killSession(sessionName);
446
- // Small delay for final flush
447
- setTimeout(finish, 500);
448
- }, timeoutMs);
449
-
450
- // --- Start watchdog ---
451
- watchdog.start();
452
-
453
- // Initial read (in case the file already has content from session setup)
454
- readNewBytes();
455
- });
456
- }
457
-
458
- // ---------------------------------------------------------------------------
459
- // recoverOrphanedSessions
460
- // ---------------------------------------------------------------------------
461
-
462
- /**
463
- * Recover or clean up orphaned tmux sessions after a gateway restart.
464
- *
465
- * On restart, the in-memory session registry is empty but tmux sessions
466
- * survive. This function lists all `lnr-*` sessions, checks dispatch
467
- * state, and either re-registers them or kills stale ones.
468
- *
469
- * Call this during plugin onLoad().
470
- *
471
- * @param getDispatchState - async function returning current DispatchState
472
- * @param logger - optional logger
473
- */
474
- export async function recoverOrphanedSessions(
475
- getDispatchState: () => Promise<DispatchState>,
476
- logger?: { info: (...a: any[]) => void; warn: (...a: any[]) => void },
477
- ): Promise<void> {
478
- const log = logger ?? {
479
- info: (...a: any[]) => console.log("[tmux-recovery]", ...a),
480
- warn: (...a: any[]) => console.warn("[tmux-recovery]", ...a),
481
- };
482
201
 
483
- const sessions = listSessions("lnr-");
484
- if (sessions.length === 0) {
485
- log.info("No orphaned tmux sessions found");
486
- return;
487
- }
488
-
489
- log.info(`Found ${sessions.length} lnr-* tmux session(s), checking dispatch state...`);
202
+ // Handle tail process ending (tmux session completed)
203
+ tail.on("close", () => {
204
+ if (!killed) {
205
+ cleanup("done");
206
+ }
207
+ rl.close();
208
+ });
490
209
 
491
- let state: DispatchState;
492
- try {
493
- state = await getDispatchState();
210
+ tail.on("error", (err) => {
211
+ logger.error(`tmux tail error: ${err}`);
212
+ cleanup("error");
213
+ rl.close();
214
+ });
215
+ });
494
216
  } catch (err) {
495
- log.warn(`Failed to read dispatch state for recovery: ${err}`);
496
- return;
497
- }
498
-
499
- const activeDispatches = state.dispatches.active;
500
-
501
- for (const sessionName of sessions) {
502
- // Parse session name: lnr-{identifier}-{backend}-{attempt}
503
- const parsed = parseSessionName(sessionName);
504
- if (!parsed) {
505
- log.warn(`Cannot parse tmux session name: ${sessionName} — killing`);
506
- killSession(sessionName);
507
- continue;
508
- }
509
-
510
- // Find a matching active dispatch by issueIdentifier
511
- const dispatch = activeDispatches[parsed.issueIdentifier];
512
- if (!dispatch) {
513
- log.warn(
514
- `No active dispatch for ${parsed.issueIdentifier} — killing tmux session ${sessionName}`,
515
- );
516
- killSession(sessionName);
517
- continue;
518
- }
519
-
520
- // Dispatch exists — re-register the session so steering tools can find it
521
- const steeringMode = inferSteeringMode(parsed.backend);
522
- const info: TmuxSessionInfo = {
523
- sessionName,
524
- backend: parsed.backend,
525
- issueId: dispatch.issueId,
526
- issueIdentifier: parsed.issueIdentifier,
527
- startedAt: new Date(dispatch.dispatchedAt).getTime(),
528
- steeringMode,
217
+ activeSessions.delete(issueId);
218
+ logger.error(`runInTmux failed: ${err}`);
219
+ return {
220
+ success: false,
221
+ output: `Failed to start tmux session: ${err}`,
222
+ error: String(err),
529
223
  };
530
-
531
- registerTmuxSession(info);
532
- log.info(
533
- `Re-registered tmux session ${sessionName} for dispatch ${parsed.issueIdentifier} ` +
534
- `(${parsed.backend}, ${steeringMode})`,
535
- );
536
224
  }
537
225
  }
538
-
539
- // ---------------------------------------------------------------------------
540
- // Helpers
541
- // ---------------------------------------------------------------------------
542
-
543
- /**
544
- * Parse a tmux session name created by buildSessionName().
545
- * Format: lnr-{identifier}-{backend}-{attempt}
546
- * Example: lnr-UAT-123-claude-0
547
- *
548
- * The identifier itself may contain dashes (e.g., UAT-123), so we parse
549
- * from the right: the last segment is attempt, second-to-last is backend.
550
- */
551
- function parseSessionName(
552
- name: string,
553
- ): { issueIdentifier: string; backend: string; attempt: number } | null {
554
- if (!name.startsWith("lnr-")) return null;
555
-
556
- const rest = name.slice(4); // Remove "lnr-" prefix
557
- const parts = rest.split("-");
558
-
559
- // Need at least 3 parts: identifier(1+), backend(1), attempt(1)
560
- if (parts.length < 3) return null;
561
-
562
- const attemptStr = parts[parts.length - 1];
563
- const attempt = parseInt(attemptStr, 10);
564
- if (isNaN(attempt)) return null;
565
-
566
- const backend = parts[parts.length - 2];
567
- if (!backend) return null;
568
-
569
- // Everything before backend-attempt is the identifier
570
- const identifierParts = parts.slice(0, parts.length - 2);
571
- const issueIdentifier = identifierParts.join("-");
572
- if (!issueIdentifier) return null;
573
-
574
- return { issueIdentifier, backend, attempt };
575
- }
576
-
577
- /**
578
- * Infer steering mode from the backend name.
579
- * Claude and Gemini support stdin-pipe steering; Codex is one-shot.
580
- */
581
- function inferSteeringMode(backend: string): "stdin-pipe" | "one-shot" {
582
- switch (backend.toLowerCase()) {
583
- case "claude":
584
- case "gemini":
585
- return "stdin-pipe";
586
- case "codex":
587
- default:
588
- return "one-shot";
589
- }
590
- }
591
-
592
- /**
593
- * Extract backend name from a session name.
594
- * Falls back to "unknown" if parsing fails.
595
- */
596
- function extractBackend(sessionName: string): string {
597
- const parsed = parseSessionName(sessionName);
598
- return parsed?.backend ?? "unknown";
599
- }