@calltelemetry/openclaw-linear 0.9.19 → 0.9.21
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 +17 -5
- package/index.ts +70 -23
- package/package.json +1 -1
- package/src/agent/agent.ts +15 -6
- package/src/infra/tmux-runner.test.ts +32 -0
- package/src/infra/tmux-runner.ts +44 -4
- 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
|
|
@@ -132,6 +134,14 @@ The end result: you work in Linear. You create issues, assign them, comment in p
|
|
|
132
134
|
|
|
133
135
|
## Quick Start
|
|
134
136
|
|
|
137
|
+
> **Tip:** Claude Code is very good at setting this up for you. Install the plugin, install the [Cloudflare CLI](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/get-started/create-local-tunnel/#1-download-and-install-cloudflared) and authenticate (`cloudflared tunnel login`), then just ask Claude to configure the rest. Or run the guided setup wizard:
|
|
138
|
+
>
|
|
139
|
+
> ```bash
|
|
140
|
+
> openclaw openclaw-linear setup
|
|
141
|
+
> ```
|
|
142
|
+
>
|
|
143
|
+
> It walks through agent profiles, auth, webhook provisioning, and verification in one interactive flow.
|
|
144
|
+
|
|
135
145
|
### 1. Install the plugin
|
|
136
146
|
|
|
137
147
|
```bash
|
|
@@ -1303,7 +1313,7 @@ Every agent session gets these registered tools. They're available as native too
|
|
|
1303
1313
|
|
|
1304
1314
|
### `cli_codex` / `cli_claude` / `cli_gemini` — Coding backend tools
|
|
1305
1315
|
|
|
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`).
|
|
1316
|
+
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
1317
|
|
|
1308
1318
|
### `linear_issues` — Native Linear API
|
|
1309
1319
|
|
|
@@ -1449,9 +1459,11 @@ The `request_work` intent is the only one gated by issue state. When the issue i
|
|
|
1449
1459
|
|
|
1450
1460
|
### Hook Lifecycle
|
|
1451
1461
|
|
|
1452
|
-
The plugin registers
|
|
1462
|
+
The plugin registers completion + lifecycle hooks via `api.on()` in `index.ts`.
|
|
1463
|
+
For completion events it listens to `agent_end`, `task_completed`, and `task_completion`
|
|
1464
|
+
to stay compatible across OpenClaw lifecycle event changes.
|
|
1453
1465
|
|
|
1454
|
-
|
|
1466
|
+
**Completion hooks (`agent_end` / `task_completed` / `task_completion`)** — Dispatch pipeline state machine. When a sub-agent (worker or auditor) finishes:
|
|
1455
1467
|
- Looks up the session key in dispatch state to find the active dispatch
|
|
1456
1468
|
- Validates the attempt number matches (rejects stale events from old retries)
|
|
1457
1469
|
- If the worker finished → triggers the audit phase (`triggerAudit`)
|
package/index.ts
CHANGED
|
@@ -20,6 +20,51 @@ import { createDispatchHistoryTool } from "./src/tools/dispatch-history-tool.js"
|
|
|
20
20
|
import { readDispatchState as readStateForHook, listActiveDispatches as listActiveForHook } from "./src/pipeline/dispatch-state.js";
|
|
21
21
|
import { startTokenRefreshTimer, stopTokenRefreshTimer } from "./src/infra/token-refresh-timer.js";
|
|
22
22
|
|
|
23
|
+
const COMPLETION_HOOK_NAMES = ["agent_end", "task_completed", "task_completion"] as const;
|
|
24
|
+
const SUCCESS_STATUSES = new Set(["ok", "success", "completed", "complete", "done", "pass", "passed"]);
|
|
25
|
+
const FAILURE_STATUSES = new Set(["error", "failed", "failure", "timeout", "timed_out", "cancelled", "canceled", "aborted", "unknown"]);
|
|
26
|
+
|
|
27
|
+
function parseCompletionSuccess(event: any): boolean {
|
|
28
|
+
if (typeof event?.success === "boolean") {
|
|
29
|
+
return event.success;
|
|
30
|
+
}
|
|
31
|
+
const status = typeof event?.status === "string" ? event.status.trim().toLowerCase() : "";
|
|
32
|
+
if (status) {
|
|
33
|
+
if (SUCCESS_STATUSES.has(status)) return true;
|
|
34
|
+
if (FAILURE_STATUSES.has(status)) return false;
|
|
35
|
+
}
|
|
36
|
+
if (typeof event?.error === "string" && event.error.trim().length > 0) {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function extractCompletionOutput(event: any): string {
|
|
43
|
+
if (typeof event?.output === "string" && event.output.trim().length > 0) {
|
|
44
|
+
return event.output;
|
|
45
|
+
}
|
|
46
|
+
if (typeof event?.result === "string" && event.result.trim().length > 0) {
|
|
47
|
+
return event.result;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const assistantBlocks = (event?.messages ?? [])
|
|
51
|
+
.filter((m: any) => m?.role === "assistant")
|
|
52
|
+
.flatMap((m: any) => {
|
|
53
|
+
if (typeof m?.content === "string") {
|
|
54
|
+
return [m.content];
|
|
55
|
+
}
|
|
56
|
+
if (Array.isArray(m?.content)) {
|
|
57
|
+
return m.content
|
|
58
|
+
.filter((b: any) => b?.type === "text" && typeof b?.text === "string")
|
|
59
|
+
.map((b: any) => b.text);
|
|
60
|
+
}
|
|
61
|
+
return [];
|
|
62
|
+
})
|
|
63
|
+
.filter((value: string) => value.trim().length > 0);
|
|
64
|
+
|
|
65
|
+
return assistantBlocks.join("\n");
|
|
66
|
+
}
|
|
67
|
+
|
|
23
68
|
export default function register(api: OpenClawPluginApi) {
|
|
24
69
|
const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
|
|
25
70
|
|
|
@@ -95,17 +140,19 @@ export default function register(api: OpenClawPluginApi) {
|
|
|
95
140
|
}).catch((err) => api.logger.warn(`Planning state hydration failed: ${err}`));
|
|
96
141
|
|
|
97
142
|
// ---------------------------------------------------------------------------
|
|
98
|
-
// Dispatch pipeline v2: notifier +
|
|
143
|
+
// Dispatch pipeline v2: notifier + completion lifecycle hooks
|
|
99
144
|
// ---------------------------------------------------------------------------
|
|
100
145
|
|
|
101
146
|
// Instantiate notifier (Discord, Slack, or both — config-driven)
|
|
102
147
|
const notify: NotifyFn = createNotifierFromConfig(pluginConfig, api.runtime, api);
|
|
103
148
|
|
|
104
|
-
// Register
|
|
105
|
-
// In the current implementation, the worker
|
|
106
|
-
// via spawnWorker() in pipeline.ts.
|
|
107
|
-
// (future upgrade path) and
|
|
108
|
-
api.on(
|
|
149
|
+
// Register completion hooks — safety net for sessions_spawn sub-agents.
|
|
150
|
+
// In the current implementation, the worker->audit->verdict flow runs inline
|
|
151
|
+
// via spawnWorker() in pipeline.ts. These hooks catch sessions_spawn agents
|
|
152
|
+
// (future upgrade path) and serve as a recovery mechanism.
|
|
153
|
+
const onAnyHook = api.on as unknown as (hookName: string, handler: (event: any, ctx: any) => Promise<void> | void) => void;
|
|
154
|
+
|
|
155
|
+
const handleCompletionEvent = async (event: any, ctx: any, hookName: string) => {
|
|
109
156
|
try {
|
|
110
157
|
const sessionKey = ctx?.sessionKey ?? "";
|
|
111
158
|
if (!sessionKey) return;
|
|
@@ -117,14 +164,14 @@ export default function register(api: OpenClawPluginApi) {
|
|
|
117
164
|
|
|
118
165
|
const dispatch = getActiveDispatch(state, mapping.dispatchId);
|
|
119
166
|
if (!dispatch) {
|
|
120
|
-
api.logger.info(
|
|
167
|
+
api.logger.info(`${hookName}: dispatch ${mapping.dispatchId} no longer active`);
|
|
121
168
|
return;
|
|
122
169
|
}
|
|
123
170
|
|
|
124
171
|
// Stale event rejection — only process if attempt matches
|
|
125
172
|
if (dispatch.attempt !== mapping.attempt) {
|
|
126
173
|
api.logger.info(
|
|
127
|
-
|
|
174
|
+
`${hookName}: stale event for ${mapping.dispatchId} ` +
|
|
128
175
|
`(event attempt=${mapping.attempt}, current=${dispatch.attempt})`
|
|
129
176
|
);
|
|
130
177
|
return;
|
|
@@ -133,7 +180,7 @@ export default function register(api: OpenClawPluginApi) {
|
|
|
133
180
|
// Create Linear API for hook context
|
|
134
181
|
const tokenInfo = resolveLinearToken(pluginConfig);
|
|
135
182
|
if (!tokenInfo.accessToken) {
|
|
136
|
-
api.logger.error(
|
|
183
|
+
api.logger.error(`${hookName}: no Linear access token — cannot process dispatch event`);
|
|
137
184
|
return;
|
|
138
185
|
}
|
|
139
186
|
const linearApi = new LinearAgentApi(tokenInfo.accessToken, {
|
|
@@ -149,29 +196,24 @@ export default function register(api: OpenClawPluginApi) {
|
|
|
149
196
|
configPath: statePath,
|
|
150
197
|
};
|
|
151
198
|
|
|
152
|
-
|
|
153
|
-
const
|
|
154
|
-
? event.output
|
|
155
|
-
: (event?.messages ?? [])
|
|
156
|
-
.filter((m: any) => m?.role === "assistant")
|
|
157
|
-
.map((m: any) => typeof m?.content === "string" ? m.content : "")
|
|
158
|
-
.join("\n") || "";
|
|
199
|
+
const output = extractCompletionOutput(event);
|
|
200
|
+
const success = parseCompletionSuccess(event);
|
|
159
201
|
|
|
160
202
|
if (mapping.phase === "worker") {
|
|
161
|
-
api.logger.info(
|
|
203
|
+
api.logger.info(`${hookName}: worker completed for ${mapping.dispatchId} - triggering audit`);
|
|
162
204
|
await triggerAudit(hookCtx, dispatch, {
|
|
163
|
-
success
|
|
205
|
+
success,
|
|
164
206
|
output,
|
|
165
207
|
}, sessionKey);
|
|
166
208
|
} else if (mapping.phase === "audit") {
|
|
167
|
-
api.logger.info(
|
|
209
|
+
api.logger.info(`${hookName}: audit completed for ${mapping.dispatchId} - processing verdict`);
|
|
168
210
|
await processVerdict(hookCtx, dispatch, {
|
|
169
|
-
success
|
|
211
|
+
success,
|
|
170
212
|
output,
|
|
171
213
|
}, sessionKey);
|
|
172
214
|
}
|
|
173
215
|
} catch (err) {
|
|
174
|
-
api.logger.error(
|
|
216
|
+
api.logger.error(`${hookName} hook error: ${err}`);
|
|
175
217
|
// Escalate: mark dispatch as stuck so it's visible
|
|
176
218
|
try {
|
|
177
219
|
const statePath = pluginConfig?.dispatchStatePath as string | undefined;
|
|
@@ -199,10 +241,15 @@ export default function register(api: OpenClawPluginApi) {
|
|
|
199
241
|
}
|
|
200
242
|
}
|
|
201
243
|
} catch (escalateErr) {
|
|
202
|
-
api.logger.error(
|
|
244
|
+
api.logger.error(`${hookName} escalation also failed: ${escalateErr}`);
|
|
203
245
|
}
|
|
204
246
|
}
|
|
205
|
-
}
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
for (const hookName of COMPLETION_HOOK_NAMES) {
|
|
250
|
+
onAnyHook(hookName, (event: any, ctx: any) => handleCompletionEvent(event, ctx, hookName));
|
|
251
|
+
}
|
|
252
|
+
api.logger.info(`Dispatch completion hooks registered: ${COMPLETION_HOOK_NAMES.join(", ")}`);
|
|
206
253
|
|
|
207
254
|
// Inject recent dispatch history as context for worker/audit agents
|
|
208
255
|
api.on("before_agent_start", async (event: any, ctx: any) => {
|
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
|
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { isCompletionEvent } from "./tmux-runner.js";
|
|
3
|
+
|
|
4
|
+
describe("isCompletionEvent", () => {
|
|
5
|
+
it("detects Claude result events", () => {
|
|
6
|
+
expect(isCompletionEvent({ type: "result" })).toBe(true);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("detects Codex session completion events", () => {
|
|
10
|
+
expect(isCompletionEvent({ type: "session.completed" })).toBe(true);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("detects task completion lifecycle variants", () => {
|
|
14
|
+
expect(isCompletionEvent({ type: "task_completed" })).toBe(true);
|
|
15
|
+
expect(isCompletionEvent({ type: "task.completed" })).toBe(true);
|
|
16
|
+
expect(isCompletionEvent({ type: "task_completion" })).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("detects completion when event type is under item.type", () => {
|
|
20
|
+
expect(isCompletionEvent({ item: { type: "task_completed" } })).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("detects completion from session.completed boolean", () => {
|
|
24
|
+
expect(isCompletionEvent({ session: { completed: true } })).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("does not treat non-completion events as complete", () => {
|
|
28
|
+
expect(isCompletionEvent({ type: "assistant" })).toBe(false);
|
|
29
|
+
expect(isCompletionEvent({ type: "message" })).toBe(false);
|
|
30
|
+
expect(isCompletionEvent({ item: { type: "agent_message" } })).toBe(false);
|
|
31
|
+
});
|
|
32
|
+
});
|
package/src/infra/tmux-runner.ts
CHANGED
|
@@ -8,6 +8,14 @@ import { formatActivityLogLine, createProgressEmitter } from "../tools/cli-share
|
|
|
8
8
|
import { InactivityWatchdog } from "../agent/watchdog.js";
|
|
9
9
|
import { shellEscape } from "./tmux.js";
|
|
10
10
|
|
|
11
|
+
const COMPLETION_EVENT_TYPES = new Set([
|
|
12
|
+
"result",
|
|
13
|
+
"session.completed",
|
|
14
|
+
"task_completed",
|
|
15
|
+
"task.completed",
|
|
16
|
+
"task_completion",
|
|
17
|
+
]);
|
|
18
|
+
|
|
11
19
|
export interface TmuxSession {
|
|
12
20
|
sessionName: string;
|
|
13
21
|
backend: string;
|
|
@@ -37,6 +45,24 @@ export interface RunInTmuxOptions {
|
|
|
37
45
|
// Track active tmux sessions by issueId
|
|
38
46
|
const activeSessions = new Map<string, TmuxSession>();
|
|
39
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Completion detector for streamed CLI JSONL events.
|
|
50
|
+
* Supports Claude and Codex event variants across releases.
|
|
51
|
+
*/
|
|
52
|
+
export function isCompletionEvent(event: any): boolean {
|
|
53
|
+
const type = typeof event?.type === "string" ? event.type.trim().toLowerCase() : "";
|
|
54
|
+
if (type && COMPLETION_EVENT_TYPES.has(type)) {
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const itemType = typeof event?.item?.type === "string" ? event.item.type.trim().toLowerCase() : "";
|
|
59
|
+
if (itemType && COMPLETION_EVENT_TYPES.has(itemType)) {
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return event?.session?.completed === true;
|
|
64
|
+
}
|
|
65
|
+
|
|
40
66
|
/**
|
|
41
67
|
* Get the active tmux session for a given issueId, or null if none.
|
|
42
68
|
*/
|
|
@@ -119,6 +145,7 @@ export async function runInTmux(opts: RunInTmuxOptions): Promise<CliResult> {
|
|
|
119
145
|
let killed = false;
|
|
120
146
|
let killedByWatchdog = false;
|
|
121
147
|
let resolved = false;
|
|
148
|
+
let completionEventReceived = false;
|
|
122
149
|
const collectedMessages: string[] = [];
|
|
123
150
|
|
|
124
151
|
const timer = setTimeout(() => {
|
|
@@ -175,8 +202,15 @@ export async function runInTmux(opts: RunInTmuxOptions): Promise<CliResult> {
|
|
|
175
202
|
output: `Agent timed out after ${Math.round(timeoutMs / 1000)}s. Partial output:\n${output}`,
|
|
176
203
|
error: "timeout",
|
|
177
204
|
});
|
|
205
|
+
} else if (reason === "unexpected_exit") {
|
|
206
|
+
logger.warn(`tmux session ${sessionName} exited without completion event`);
|
|
207
|
+
resolve({
|
|
208
|
+
success: false,
|
|
209
|
+
output: `Agent session exited unexpectedly (no completion event received). Partial output:\n${output}`,
|
|
210
|
+
error: "unexpected_exit",
|
|
211
|
+
});
|
|
178
212
|
} else {
|
|
179
|
-
// Normal completion
|
|
213
|
+
// Normal completion — only reached when completionEventReceived is true
|
|
180
214
|
resolve({ success: true, output });
|
|
181
215
|
}
|
|
182
216
|
}
|
|
@@ -223,8 +257,9 @@ export async function runInTmux(opts: RunInTmuxOptions): Promise<CliResult> {
|
|
|
223
257
|
progress.push(formatActivityLogLine(activity));
|
|
224
258
|
}
|
|
225
259
|
|
|
226
|
-
// Detect completion
|
|
227
|
-
if (event
|
|
260
|
+
// Detect completion across known CLI event shapes.
|
|
261
|
+
if (isCompletionEvent(event)) {
|
|
262
|
+
completionEventReceived = true;
|
|
228
263
|
cleanup("done");
|
|
229
264
|
rl.close();
|
|
230
265
|
}
|
|
@@ -233,7 +268,12 @@ export async function runInTmux(opts: RunInTmuxOptions): Promise<CliResult> {
|
|
|
233
268
|
// Handle tail process ending (tmux session exited)
|
|
234
269
|
tail.on("close", () => {
|
|
235
270
|
if (!resolved) {
|
|
236
|
-
|
|
271
|
+
// If we never saw a completion event, the session died unexpectedly
|
|
272
|
+
if (completionEventReceived) {
|
|
273
|
+
cleanup("done");
|
|
274
|
+
} else {
|
|
275
|
+
cleanup("unexpected_exit");
|
|
276
|
+
}
|
|
237
277
|
}
|
|
238
278
|
rl.close();
|
|
239
279
|
});
|
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
|
|