@calltelemetry/openclaw-linear 0.9.18 → 0.9.20
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 +5 -3
- package/package.json +1 -1
- package/src/agent/agent.ts +15 -6
- package/src/infra/tmux-runner.ts +66 -16
- package/src/pipeline/webhook.ts +3 -2
package/README.md
CHANGED
|
@@ -41,7 +41,9 @@ Connect Linear to AI agents. Issues get triaged, implemented, and audited — au
|
|
|
41
41
|
- [x] CI + coverage badges (1170+ tests, Codecov integration)
|
|
42
42
|
- [x] Setup wizard (`openclaw openclaw-linear setup`) + `doctor --fix` auto-repair
|
|
43
43
|
- [x] Project context auto-detection (repo, framework, build/test commands → worker/audit prompts)
|
|
44
|
-
- [x] Per-backend CLI tools (`cli_codex`, `cli_claude`, `cli_gemini`) with Linear session activity streaming
|
|
44
|
+
- [x] Per-backend CLI tools (`cli_codex`, `cli_claude`, `cli_gemini`) with `[Codex]`/`[Claude]`/`[Gemini]`-prefixed Linear session activity streaming
|
|
45
|
+
- [x] Immediate thought emission on comment receipt and tool dispatch (visible before long-running tasks complete)
|
|
46
|
+
- [x] Proactive OAuth token refresh timer (runs on startup, then every 6h)
|
|
45
47
|
- [ ] **Worktree → PR merge** — `createPullRequest()` exists but is not wired into the pipeline. After audit pass, commits sit on a `codex/{identifier}` branch. You create the PR manually.
|
|
46
48
|
- [ ] **Sub-agent worktree sharing** — Sub-agents spawned via `spawn_agent`/`ask_agent` do not inherit the parent worktree. They run in their own session without code access.
|
|
47
49
|
- [ ] **Parallel worktree conflict resolution** — DAG dispatch runs up to 3 issues concurrently in separate worktrees, but there's no merge conflict detection across them.
|
|
@@ -118,7 +120,7 @@ The end result: you work in Linear. You create issues, assign them, comment in p
|
|
|
118
120
|
|
|
119
121
|
### Multi-Backend & Multi-Repo
|
|
120
122
|
|
|
121
|
-
- **Three coding backends** — Codex (OpenAI), Claude (Anthropic), Gemini (Google). Configurable globally or per-agent. Each backend registers as a dedicated tool (`cli_codex`, `cli_claude`, `cli_gemini`)
|
|
123
|
+
- **Three coding backends** — Codex (OpenAI), Claude (Anthropic), Gemini (Google). Configurable globally or per-agent. Each backend registers as a dedicated tool (`cli_codex`, `cli_claude`, `cli_gemini`) with `[Codex]`/`[Claude]`/`[Gemini]`-prefixed activity streaming — thoughts, actions, progress, and results all show the backend label in Linear's session UI so you always know which runner is active. Per-agent overrides let you assign different backends to different team members.
|
|
122
124
|
- **Multi-repo dispatch** — Tag an issue with `<!-- repos: api, frontend -->` and the worker gets isolated worktrees for each repo. One issue, multiple codebases, one agent session.
|
|
123
125
|
|
|
124
126
|
### Operations
|
|
@@ -1303,7 +1305,7 @@ Every agent session gets these registered tools. They're available as native too
|
|
|
1303
1305
|
|
|
1304
1306
|
### `cli_codex` / `cli_claude` / `cli_gemini` — Coding backend tools
|
|
1305
1307
|
|
|
1306
|
-
Three per-backend tools that send tasks to their respective coding CLIs. Each agent sees only the tool matching its configured backend (e.g., an agent configured for `codex` gets `cli_codex`).
|
|
1308
|
+
Three per-backend tools that send tasks to their respective coding CLIs. Each agent sees only the tool matching its configured backend (e.g., an agent configured for `codex` gets `cli_codex`). All activity emissions are prefixed with the backend label (`[Codex]`, `[Claude]`, `[Gemini]`) — thoughts, action starts, progress updates, results, and errors — so the Linear session UI always shows which runner is active. When a CLI tool is dispatched, an immediate thought is emitted with the prompt excerpt before the long-running task begins. The agent writes the prompt; the plugin handles worktree setup, session activity streaming, and output capture.
|
|
1307
1309
|
|
|
1308
1310
|
### `linear_issues` — Native Linear API
|
|
1309
1311
|
|
package/package.json
CHANGED
package/src/agent/agent.ts
CHANGED
|
@@ -293,6 +293,8 @@ async function runEmbedded(
|
|
|
293
293
|
|
|
294
294
|
// Track last emitted tool to avoid duplicates
|
|
295
295
|
let lastToolAction = "";
|
|
296
|
+
// Derive a friendly label from cli_ tool names: cli_codex→"Codex", cli_claude→"Claude"
|
|
297
|
+
const cliLabel = (name: string) => name.startsWith("cli_") ? name.slice(4).charAt(0).toUpperCase() + name.slice(5) : name;
|
|
296
298
|
|
|
297
299
|
watchdog.start();
|
|
298
300
|
|
|
@@ -335,7 +337,8 @@ async function runEmbedded(
|
|
|
335
337
|
if (text) {
|
|
336
338
|
// Truncate tool results for activity display
|
|
337
339
|
const truncated = text.length > 300 ? text.slice(0, 300) + "..." : text;
|
|
338
|
-
|
|
340
|
+
const prefix = lastToolAction.startsWith("cli_") ? `[${cliLabel(lastToolAction)}] ` : "";
|
|
341
|
+
emit({ type: "action", action: `${prefix}${lastToolAction || "Tool result"}`, parameter: truncated });
|
|
339
342
|
}
|
|
340
343
|
},
|
|
341
344
|
|
|
@@ -364,11 +367,14 @@ async function runEmbedded(
|
|
|
364
367
|
if (phase === "start") {
|
|
365
368
|
lastToolAction = toolName;
|
|
366
369
|
|
|
367
|
-
// cli_codex / cli_claude / cli_gemini:
|
|
370
|
+
// cli_codex / cli_claude / cli_gemini: emit a thought + action so the
|
|
371
|
+
// user immediately sees what the agent is dispatching and why.
|
|
368
372
|
if (toolName.startsWith("cli_") && inputObj) {
|
|
373
|
+
const tag = cliLabel(toolName);
|
|
369
374
|
const prompt = String(inputObj.prompt ?? "").slice(0, 250);
|
|
370
375
|
const workDir = inputObj.workingDir ? ` in ${inputObj.workingDir}` : "";
|
|
371
|
-
emit({ type: "
|
|
376
|
+
emit({ type: "thought", body: `[${tag}] Starting${workDir}: "${prompt}"\n\n${toolName}\nin progress` });
|
|
377
|
+
emit({ type: "action", action: `[${tag}] Running${workDir}`, parameter: prompt });
|
|
372
378
|
} else {
|
|
373
379
|
const detail = input || meta || toolName;
|
|
374
380
|
emit({ type: "action", action: `Running ${toolName}`, parameter: detail.slice(0, 300) });
|
|
@@ -378,18 +384,21 @@ async function runEmbedded(
|
|
|
378
384
|
// Tool execution update — partial progress (keeps Linear UI alive for long tools)
|
|
379
385
|
if (phase === "update") {
|
|
380
386
|
const detail = meta || input || "in progress";
|
|
381
|
-
|
|
387
|
+
const prefix = toolName.startsWith("cli_") ? `[${cliLabel(toolName)}] ` : "";
|
|
388
|
+
emit({ type: "action", action: `${prefix}${toolName}`, parameter: detail.slice(0, 300) });
|
|
382
389
|
}
|
|
383
390
|
|
|
384
391
|
// Tool execution completed successfully
|
|
385
392
|
if (phase === "result" && !data.isError) {
|
|
386
393
|
const detail = meta ? meta.slice(0, 300) : "completed";
|
|
387
|
-
|
|
394
|
+
const prefix = toolName.startsWith("cli_") ? `[${cliLabel(toolName)}] ` : "";
|
|
395
|
+
emit({ type: "action", action: `${prefix}${toolName} done`, parameter: detail });
|
|
388
396
|
}
|
|
389
397
|
|
|
390
398
|
// Tool execution result with error
|
|
391
399
|
if (phase === "result" && data.isError) {
|
|
392
|
-
|
|
400
|
+
const prefix = toolName.startsWith("cli_") ? `[${cliLabel(toolName)}] ` : "";
|
|
401
|
+
emit({ type: "action", action: `${prefix}${toolName} failed`, parameter: (meta || "error").slice(0, 300) });
|
|
393
402
|
}
|
|
394
403
|
},
|
|
395
404
|
|
package/src/infra/tmux-runner.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { execSync, spawn } from "node:child_process";
|
|
2
2
|
import { createInterface } from "node:readline";
|
|
3
|
-
import { mkdirSync,
|
|
3
|
+
import { mkdirSync, writeFileSync, unlinkSync } from "node:fs";
|
|
4
4
|
import { dirname } from "node:path";
|
|
5
5
|
import type { ActivityContent, LinearAgentApi } from "../api/linear-api.js";
|
|
6
6
|
import type { CliResult, OnProgressUpdate } from "../tools/cli-shared.js";
|
|
@@ -45,8 +45,12 @@ export function getActiveTmuxSession(issueId: string): TmuxSession | null {
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
/**
|
|
48
|
-
* Run a command inside a tmux session with
|
|
48
|
+
* Run a command inside a tmux session with output piped to a JSONL log.
|
|
49
49
|
* Monitors the log file for events and streams them to Linear.
|
|
50
|
+
*
|
|
51
|
+
* The command + tee are wrapped in a shell script so that tee runs INSIDE
|
|
52
|
+
* the tmux session (not in the outer shell). This ensures JSONL output
|
|
53
|
+
* from the CLI subprocess is captured to the log file.
|
|
50
54
|
*/
|
|
51
55
|
export async function runInTmux(opts: RunInTmuxOptions): Promise<CliResult> {
|
|
52
56
|
const {
|
|
@@ -70,6 +74,9 @@ export async function runInTmux(opts: RunInTmuxOptions): Promise<CliResult> {
|
|
|
70
74
|
// Ensure log directory exists
|
|
71
75
|
mkdirSync(dirname(logPath), { recursive: true });
|
|
72
76
|
|
|
77
|
+
// Touch the log file so tail -f can start immediately
|
|
78
|
+
writeFileSync(logPath, "", { flag: "a" });
|
|
79
|
+
|
|
73
80
|
// Register active session
|
|
74
81
|
const session: TmuxSession = {
|
|
75
82
|
sessionName,
|
|
@@ -83,14 +90,25 @@ export async function runInTmux(opts: RunInTmuxOptions): Promise<CliResult> {
|
|
|
83
90
|
const progress = createProgressEmitter({ header: progressHeader, onUpdate });
|
|
84
91
|
progress.emitHeader();
|
|
85
92
|
|
|
93
|
+
// Write a shell wrapper script so the entire pipeline (command | tee)
|
|
94
|
+
// runs inside the tmux session. This avoids quoting hell and ensures
|
|
95
|
+
// tee captures the subprocess output, not tmux's own stdout.
|
|
96
|
+
const scriptPath = `${logPath}.run.sh`;
|
|
97
|
+
|
|
86
98
|
try {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
`
|
|
90
|
-
|
|
91
|
-
].join("
|
|
99
|
+
writeFileSync(scriptPath, [
|
|
100
|
+
"#!/bin/sh",
|
|
101
|
+
`exec ${command} 2>&1 | tee -a ${shellEscape(logPath)}`,
|
|
102
|
+
"",
|
|
103
|
+
].join("\n"), { mode: 0o755 });
|
|
92
104
|
|
|
93
|
-
|
|
105
|
+
// Start tmux session running the wrapper script
|
|
106
|
+
execSync(
|
|
107
|
+
`tmux new-session -d -s ${shellEscape(sessionName)} -c ${shellEscape(cwd)} ${shellEscape(scriptPath)}`,
|
|
108
|
+
{ stdio: "ignore", timeout: 10_000 },
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
logger.info(`tmux session started: ${sessionName} (log: ${logPath})`);
|
|
94
112
|
|
|
95
113
|
// Tail the log file and process JSONL events
|
|
96
114
|
return await new Promise<CliResult>((resolve) => {
|
|
@@ -100,6 +118,8 @@ export async function runInTmux(opts: RunInTmuxOptions): Promise<CliResult> {
|
|
|
100
118
|
|
|
101
119
|
let killed = false;
|
|
102
120
|
let killedByWatchdog = false;
|
|
121
|
+
let resolved = false;
|
|
122
|
+
let completionEventReceived = false;
|
|
103
123
|
const collectedMessages: string[] = [];
|
|
104
124
|
|
|
105
125
|
const timer = setTimeout(() => {
|
|
@@ -120,6 +140,9 @@ export async function runInTmux(opts: RunInTmuxOptions): Promise<CliResult> {
|
|
|
120
140
|
watchdog.start();
|
|
121
141
|
|
|
122
142
|
function cleanup(reason: string) {
|
|
143
|
+
if (resolved) return;
|
|
144
|
+
resolved = true;
|
|
145
|
+
|
|
123
146
|
clearTimeout(timer);
|
|
124
147
|
watchdog.stop();
|
|
125
148
|
tail.kill();
|
|
@@ -132,6 +155,9 @@ export async function runInTmux(opts: RunInTmuxOptions): Promise<CliResult> {
|
|
|
132
155
|
});
|
|
133
156
|
} catch { /* session may already be gone */ }
|
|
134
157
|
|
|
158
|
+
// Clean up wrapper script
|
|
159
|
+
try { unlinkSync(scriptPath); } catch { /* best effort */ }
|
|
160
|
+
|
|
135
161
|
activeSessions.delete(issueId);
|
|
136
162
|
|
|
137
163
|
const output = collectedMessages.join("\n\n") || "(no output)";
|
|
@@ -150,8 +176,15 @@ export async function runInTmux(opts: RunInTmuxOptions): Promise<CliResult> {
|
|
|
150
176
|
output: `Agent timed out after ${Math.round(timeoutMs / 1000)}s. Partial output:\n${output}`,
|
|
151
177
|
error: "timeout",
|
|
152
178
|
});
|
|
179
|
+
} else if (reason === "unexpected_exit") {
|
|
180
|
+
logger.warn(`tmux session ${sessionName} exited without completion event`);
|
|
181
|
+
resolve({
|
|
182
|
+
success: false,
|
|
183
|
+
output: `Agent session exited unexpectedly (no completion event received). Partial output:\n${output}`,
|
|
184
|
+
error: "unexpected_exit",
|
|
185
|
+
});
|
|
153
186
|
} else {
|
|
154
|
-
// Normal completion
|
|
187
|
+
// Normal completion — only reached when completionEventReceived is true
|
|
155
188
|
resolve({ success: true, output });
|
|
156
189
|
}
|
|
157
190
|
}
|
|
@@ -169,8 +202,9 @@ export async function runInTmux(opts: RunInTmuxOptions): Promise<CliResult> {
|
|
|
169
202
|
return;
|
|
170
203
|
}
|
|
171
204
|
|
|
172
|
-
// Collect text for output
|
|
205
|
+
// Collect text for output — handle both Claude and Codex event shapes
|
|
173
206
|
if (event.type === "assistant") {
|
|
207
|
+
// Claude stream-json shape
|
|
174
208
|
const content = event.message?.content;
|
|
175
209
|
if (Array.isArray(content)) {
|
|
176
210
|
for (const block of content) {
|
|
@@ -180,6 +214,11 @@ export async function runInTmux(opts: RunInTmuxOptions): Promise<CliResult> {
|
|
|
180
214
|
}
|
|
181
215
|
}
|
|
182
216
|
}
|
|
217
|
+
if (event.item?.type === "agent_message" || event.item?.type === "message") {
|
|
218
|
+
// Codex --json shape
|
|
219
|
+
const text = event.item.text ?? event.item.content ?? "";
|
|
220
|
+
if (text) collectedMessages.push(text);
|
|
221
|
+
}
|
|
183
222
|
|
|
184
223
|
// Stream to Linear
|
|
185
224
|
const activity = mapEvent(event);
|
|
@@ -192,28 +231,39 @@ export async function runInTmux(opts: RunInTmuxOptions): Promise<CliResult> {
|
|
|
192
231
|
progress.push(formatActivityLogLine(activity));
|
|
193
232
|
}
|
|
194
233
|
|
|
195
|
-
// Detect completion
|
|
196
|
-
if (event.type === "result") {
|
|
234
|
+
// Detect completion — Claude uses "result", Codex uses "session.completed"
|
|
235
|
+
if (event.type === "result" || event.type === "session.completed") {
|
|
236
|
+
completionEventReceived = true;
|
|
197
237
|
cleanup("done");
|
|
198
238
|
rl.close();
|
|
199
239
|
}
|
|
200
240
|
});
|
|
201
241
|
|
|
202
|
-
// Handle tail process ending (tmux session
|
|
242
|
+
// Handle tail process ending (tmux session exited)
|
|
203
243
|
tail.on("close", () => {
|
|
204
|
-
if (!
|
|
205
|
-
|
|
244
|
+
if (!resolved) {
|
|
245
|
+
// If we never saw a completion event, the session died unexpectedly
|
|
246
|
+
if (completionEventReceived) {
|
|
247
|
+
cleanup("done");
|
|
248
|
+
} else {
|
|
249
|
+
cleanup("unexpected_exit");
|
|
250
|
+
}
|
|
206
251
|
}
|
|
207
252
|
rl.close();
|
|
208
253
|
});
|
|
209
254
|
|
|
210
255
|
tail.on("error", (err) => {
|
|
211
256
|
logger.error(`tmux tail error: ${err}`);
|
|
212
|
-
|
|
257
|
+
if (!resolved) {
|
|
258
|
+
cleanup("error");
|
|
259
|
+
}
|
|
213
260
|
rl.close();
|
|
214
261
|
});
|
|
215
262
|
});
|
|
216
263
|
} catch (err) {
|
|
264
|
+
// Clean up wrapper script on failure
|
|
265
|
+
try { unlinkSync(scriptPath); } catch { /* best effort */ }
|
|
266
|
+
|
|
217
267
|
activeSessions.delete(issueId);
|
|
218
268
|
logger.error(`runInTmux failed: ${err}`);
|
|
219
269
|
return {
|
package/src/pipeline/webhook.ts
CHANGED
|
@@ -1731,11 +1731,12 @@ async function dispatchCommentToAgent(
|
|
|
1731
1731
|
});
|
|
1732
1732
|
}
|
|
1733
1733
|
|
|
1734
|
-
// Emit thought
|
|
1734
|
+
// Emit thought — include comment excerpt so the user sees immediate context
|
|
1735
1735
|
if (agentSessionId) {
|
|
1736
|
+
const excerpt = commentBody.length > 200 ? commentBody.slice(0, 200) + "..." : commentBody;
|
|
1736
1737
|
await linearApi.emitActivity(agentSessionId, {
|
|
1737
1738
|
type: "thought",
|
|
1738
|
-
body: `${label}
|
|
1739
|
+
body: `${label} received comment on ${issueRef}: "${excerpt}" — working on it now...`,
|
|
1739
1740
|
}).catch(() => {});
|
|
1740
1741
|
}
|
|
1741
1742
|
|