@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.
- package/README.md +104 -48
- package/index.ts +7 -0
- package/openclaw.plugin.json +44 -2
- package/package.json +1 -1
- package/prompts.yaml +3 -1
- package/src/__test__/fixtures/recorded-sub-issue-flow.ts +37 -50
- package/src/agent/agent.test.ts +1 -1
- package/src/agent/agent.ts +39 -6
- package/src/api/linear-api.test.ts +188 -1
- package/src/api/linear-api.ts +114 -5
- package/src/infra/multi-repo.test.ts +127 -1
- package/src/infra/multi-repo.ts +74 -6
- package/src/infra/tmux-runner.ts +599 -0
- package/src/infra/tmux.ts +158 -0
- package/src/infra/token-refresh-timer.ts +44 -0
- package/src/pipeline/active-session.ts +19 -1
- package/src/pipeline/artifacts.ts +42 -0
- package/src/pipeline/dispatch-state.ts +3 -0
- package/src/pipeline/guidance.test.ts +53 -0
- package/src/pipeline/guidance.ts +38 -0
- package/src/pipeline/memory-search.ts +40 -0
- package/src/pipeline/pipeline.ts +184 -17
- package/src/pipeline/retro.ts +231 -0
- package/src/pipeline/webhook.test.ts +1 -1
- package/src/pipeline/webhook.ts +271 -30
- package/src/tools/claude-tool.ts +68 -10
- package/src/tools/cli-shared.ts +50 -2
- package/src/tools/code-tool.ts +230 -150
- package/src/tools/codex-tool.ts +61 -9
- package/src/tools/gemini-tool.ts +61 -10
- package/src/tools/steering-tools.ts +176 -0
- package/src/tools/tools.test.ts +47 -15
- package/src/tools/tools.ts +17 -4
- package/src/__test__/smoke-linear-api.test.ts +0 -847
package/src/infra/multi-repo.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* multi-repo.ts — Multi-repo resolution for dispatches spanning multiple git repos.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
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.
|
|
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.
|
|
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
|
|
75
|
-
|
|
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
|
+
}
|