@calltelemetry/openclaw-linear 0.9.16 → 0.9.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/infra/tmux-runner.ts +157 -531
- package/src/infra/tmux.ts +18 -132
- package/src/tools/planner-tools.ts +1 -0
- package/src/tools/steering-tools.ts +56 -91
- package/src/infra/token-refresh-timer.ts +0 -44
- package/src/pipeline/memory-search.ts +0 -40
- package/src/pipeline/retro.ts +0 -231
package/src/infra/tmux-runner.ts
CHANGED
|
@@ -1,131 +1,54 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
|
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
|
|
11
|
+
export interface TmuxSession {
|
|
49
12
|
sessionName: string;
|
|
50
13
|
backend: string;
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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;
|
|
23
|
+
command: string;
|
|
104
24
|
cwd: string;
|
|
105
25
|
timeoutMs: number;
|
|
106
26
|
watchdogMs: number;
|
|
107
|
-
logPath: string;
|
|
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
|
|
32
|
+
logger: { info: (msg: string) => void; warn: (msg: string) => void; error: (msg: string) => void; debug?: (msg: string) => void };
|
|
113
33
|
onUpdate?: OnProgressUpdate;
|
|
114
|
-
progressHeader
|
|
34
|
+
progressHeader: string;
|
|
115
35
|
}
|
|
116
36
|
|
|
117
|
-
//
|
|
118
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
146
|
-
|
|
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
|
-
//
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
// 4. Register in session map
|
|
163
|
-
const sessionInfo: TmuxSessionInfo = {
|
|
73
|
+
// Register active session
|
|
74
|
+
const session: TmuxSession = {
|
|
164
75
|
sessionName,
|
|
165
|
-
backend:
|
|
166
|
-
issueId,
|
|
76
|
+
backend: sessionName.split("-").slice(-2, -1)[0] ?? "unknown",
|
|
167
77
|
issueIdentifier,
|
|
168
|
-
|
|
78
|
+
issueId,
|
|
169
79
|
steeringMode,
|
|
170
80
|
};
|
|
171
|
-
|
|
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
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
|
101
|
+
let killed = false;
|
|
102
|
+
let killedByWatchdog = false;
|
|
103
|
+
const collectedMessages: string[] = [];
|
|
207
104
|
|
|
208
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
-
|
|
398
|
-
|
|
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
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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
|
-
|
|
492
|
-
|
|
493
|
-
|
|
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
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
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
|
-
}
|