@calltelemetry/openclaw-linear 0.9.15 → 0.9.16

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,10 +1,11 @@
1
1
  /**
2
2
  * multi-repo.ts — Multi-repo resolution for dispatches spanning multiple git repos.
3
3
  *
4
- * Three-tier resolution:
4
+ * Four-tier resolution:
5
5
  * 1. Issue body markers: <!-- repos: api, frontend --> or [repos: api, frontend]
6
6
  * 2. Linear labels: repo:api, repo:frontend
7
- * 3. Config default: Falls back to single codexBaseRepo
7
+ * 3. Team mapping: teamMappings[teamKey].repos from plugin config
8
+ * 4. Config default: Falls back to single codexBaseRepo
8
9
  */
9
10
 
10
11
  import { existsSync, statSync } from "node:fs";
@@ -18,7 +19,55 @@ export interface RepoConfig {
18
19
 
19
20
  export interface RepoResolution {
20
21
  repos: RepoConfig[];
21
- source: "issue_body" | "labels" | "config_default";
22
+ source: "issue_body" | "labels" | "team_mapping" | "config_default";
23
+ }
24
+
25
+ /**
26
+ * Enriched repo entry — filesystem path + optional GitHub identity.
27
+ * Supports both plain string paths (backward compat) and objects.
28
+ */
29
+ export interface RepoEntry {
30
+ path: string;
31
+ github?: string; // "owner/repo" format
32
+ hostname?: string; // defaults to "github.com"
33
+ }
34
+
35
+ /**
36
+ * Parse the repos config, normalizing both string and object formats.
37
+ * String values become { path: value }, objects pass through.
38
+ */
39
+ export function getRepoEntries(pluginConfig?: Record<string, unknown>): Record<string, RepoEntry> {
40
+ const repos = pluginConfig?.repos as Record<string, string | Record<string, unknown>> | undefined;
41
+ if (!repos) return {};
42
+ const result: Record<string, RepoEntry> = {};
43
+ for (const [name, value] of Object.entries(repos)) {
44
+ if (typeof value === "string") {
45
+ result[name] = { path: value };
46
+ } else if (value && typeof value === "object") {
47
+ result[name] = {
48
+ path: (value as any).path as string,
49
+ github: (value as any).github as string | undefined,
50
+ hostname: (value as any).hostname as string | undefined,
51
+ };
52
+ }
53
+ }
54
+ return result;
55
+ }
56
+
57
+ /**
58
+ * Build candidate repositories for Linear's issueRepositorySuggestions API.
59
+ * Extracts GitHub identity from enriched repo entries.
60
+ */
61
+ export function buildCandidateRepositories(
62
+ pluginConfig?: Record<string, unknown>,
63
+ ): Array<{ hostname: string; repositoryFullName: string }> {
64
+ const entries = getRepoEntries(pluginConfig);
65
+ return Object.values(entries)
66
+ .filter(e => e.github)
67
+ .map(e => ({
68
+ hostname: e.hostname ?? "github.com",
69
+ repositoryFullName: e.github!,
70
+ }));
22
71
  }
23
72
 
24
73
  /**
@@ -28,6 +77,7 @@ export function resolveRepos(
28
77
  description: string | null | undefined,
29
78
  labels: string[],
30
79
  pluginConfig?: Record<string, unknown>,
80
+ teamKey?: string,
31
81
  ): RepoResolution {
32
82
  // 1. Check issue body for repo markers
33
83
  // Match: <!-- repos: name1, name2 --> or [repos: name1, name2]
@@ -62,7 +112,21 @@ export function resolveRepos(
62
112
  return { repos, source: "labels" };
63
113
  }
64
114
 
65
- // 3. Config default: single repo
115
+ // 3. Team mapping: teamMappings[teamKey].repos
116
+ if (teamKey) {
117
+ const teamMappings = pluginConfig?.teamMappings as Record<string, Record<string, unknown>> | undefined;
118
+ const teamRepoNames = teamMappings?.[teamKey]?.repos as string[] | undefined;
119
+ if (teamRepoNames && teamRepoNames.length > 0) {
120
+ const repoMap = getRepoMap(pluginConfig);
121
+ const repos = teamRepoNames.map(name => ({
122
+ name,
123
+ path: repoMap[name] ?? resolveRepoPath(name, pluginConfig),
124
+ }));
125
+ return { repos, source: "team_mapping" };
126
+ }
127
+ }
128
+
129
+ // 4. Config default: single repo
66
130
  const baseRepo = (pluginConfig?.codexBaseRepo as string) ?? path.join(homedir(), "ai-workspace");
67
131
  return {
68
132
  repos: [{ name: "default", path: baseRepo }],
@@ -71,8 +135,12 @@ export function resolveRepos(
71
135
  }
72
136
 
73
137
  function getRepoMap(pluginConfig?: Record<string, unknown>): Record<string, string> {
74
- const repos = pluginConfig?.repos as Record<string, string> | undefined;
75
- return repos ?? {};
138
+ const entries = getRepoEntries(pluginConfig);
139
+ const result: Record<string, string> = {};
140
+ for (const [name, entry] of Object.entries(entries)) {
141
+ result[name] = entry.path;
142
+ }
143
+ return result;
76
144
  }
77
145
 
78
146
  function resolveRepoPath(name: string, pluginConfig?: Record<string, unknown>): string {
@@ -0,0 +1,599 @@
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";
29
+ import type { CliResult, OnProgressUpdate } from "../tools/cli-shared.js";
30
+ 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
+ // ---------------------------------------------------------------------------
47
+
48
+ export interface TmuxSessionInfo {
49
+ sessionName: string;
50
+ 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());
87
+ }
88
+
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 {
100
+ issueId: string;
101
+ issueIdentifier: string;
102
+ sessionName: string;
103
+ command: string; // Full CLI command string (shell-escaped)
104
+ cwd: string;
105
+ timeoutMs: number;
106
+ watchdogMs: number;
107
+ logPath: string; // pipe-pane JSONL log path
108
+ mapEvent: (event: any) => ActivityContent | null;
109
+ linearApi?: LinearAgentApi;
110
+ agentSessionId?: string;
111
+ steeringMode: "stdin-pipe" | "one-shot";
112
+ logger?: { info: (...a: any[]) => void; warn: (...a: any[]) => void };
113
+ onUpdate?: OnProgressUpdate;
114
+ progressHeader?: string;
115
+ }
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // runInTmux
119
+ // ---------------------------------------------------------------------------
120
+
121
+ /**
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).
127
+ */
128
+ export async function runInTmux(opts: TmuxRunnerOpts): Promise<CliResult> {
129
+ const {
130
+ issueId,
131
+ issueIdentifier,
132
+ sessionName,
133
+ command,
134
+ cwd,
135
+ timeoutMs,
136
+ watchdogMs,
137
+ logPath,
138
+ mapEvent,
139
+ linearApi,
140
+ agentSessionId,
141
+ steeringMode,
142
+ logger,
143
+ } = opts;
144
+
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);
158
+
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 = {
164
+ sessionName,
165
+ backend: extractBackend(sessionName),
166
+ issueId,
167
+ issueIdentifier,
168
+ startedAt: Date.now(),
169
+ steeringMode,
170
+ };
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}`);
178
+
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
+ });
186
+ progress.emitHeader();
187
+
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 = "";
202
+
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;
207
+
208
+ // --- Watchdog ---
209
+ const watchdog = new InactivityWatchdog({
210
+ inactivityMs: watchdogMs,
211
+ label: `tmux:${sessionName}`,
212
+ logger: log,
213
+ onKill: () => {
214
+ killedByWatchdog = true;
215
+ 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
+ }
298
+
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
+ }
304
+
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
+ }
318
+
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}`);
325
+ });
326
+ }
327
+ progress.push(formatActivityLogLine(activity));
328
+ }
329
+ }
330
+
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
+ }
358
+
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
+ }
386
+
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
+ }
396
+
397
+ log.info(`${sessionName}: completed successfully`);
398
+ resolve({ success: true, output });
399
+ }
400
+
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
408
+ });
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
+
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...`);
490
+
491
+ let state: DispatchState;
492
+ try {
493
+ state = await getDispatchState();
494
+ } 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,
529
+ };
530
+
531
+ registerTmuxSession(info);
532
+ log.info(
533
+ `Re-registered tmux session ${sessionName} for dispatch ${parsed.issueIdentifier} ` +
534
+ `(${parsed.backend}, ${steeringMode})`,
535
+ );
536
+ }
537
+ }
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
+ }