@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 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`) so agents and Linear session UI show exactly which backend is running. Per-agent overrides let you assign different backends to different team members.
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`). The tool name is visible in Linear's agent session UI, so you always know which backend is running. The agent writes the prompt; the plugin handles worktree setup, session activity streaming, and output capture.
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 four lifecycle hooks via `api.on()` in `index.ts`:
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
- **`agent_end`** — Dispatch pipeline state machine. When a sub-agent (worker or auditor) finishes:
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 + agent_end lifecycle hook
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 agent_end hook — safety net for sessions_spawn sub-agents.
105
- // In the current implementation, the workerauditverdict flow runs inline
106
- // via spawnWorker() in pipeline.ts. This hook catches sessions_spawn agents
107
- // (future upgrade path) and serves as a recovery mechanism.
108
- api.on("agent_end", async (event: any, ctx: any) => {
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(`agent_end: dispatch ${mapping.dispatchId} no longer active`);
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
- `agent_end: stale event for ${mapping.dispatchId} ` +
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("agent_end: no Linear access token — cannot process dispatch event");
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
- // Extract output from event
153
- const output = typeof event?.output === "string"
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(`agent_end: worker completed for ${mapping.dispatchId} triggering audit`);
203
+ api.logger.info(`${hookName}: worker completed for ${mapping.dispatchId} - triggering audit`);
162
204
  await triggerAudit(hookCtx, dispatch, {
163
- success: event?.success ?? true,
205
+ success,
164
206
  output,
165
207
  }, sessionKey);
166
208
  } else if (mapping.phase === "audit") {
167
- api.logger.info(`agent_end: audit completed for ${mapping.dispatchId} processing verdict`);
209
+ api.logger.info(`${hookName}: audit completed for ${mapping.dispatchId} - processing verdict`);
168
210
  await processVerdict(hookCtx, dispatch, {
169
- success: event?.success ?? true,
211
+ success,
170
212
  output,
171
213
  }, sessionKey);
172
214
  }
173
215
  } catch (err) {
174
- api.logger.error(`agent_end hook error: ${err}`);
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(`agent_end escalation also failed: ${escalateErr}`);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@calltelemetry/openclaw-linear",
3
- "version": "0.9.19",
3
+ "version": "0.9.21",
4
4
  "description": "Linear Agent plugin for OpenClaw — webhook-driven AI pipeline with OAuth, multi-agent routing, and issue triage",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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
- emit({ type: "action", action: lastToolAction || "Tool result", parameter: truncated });
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: show working dir and prompt excerpt
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: "action", action: `Running ${toolName}${workDir}`, parameter: prompt });
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
- emit({ type: "action", action: `${toolName}`, parameter: detail.slice(0, 300) });
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
- emit({ type: "action", action: `${toolName} done`, parameter: detail });
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
- emit({ type: "action", action: `${toolName} failed`, parameter: (meta || "error").slice(0, 300) });
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
+ });
@@ -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 Claude uses "result", Codex uses "session.completed"
227
- if (event.type === "result" || event.type === "session.completed") {
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
- cleanup("done");
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
  });
@@ -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} is processing comment on ${issueRef}...`,
1739
+ body: `${label} received comment on ${issueRef}: "${excerpt}" — working on it now...`,
1739
1740
  }).catch(() => {});
1740
1741
  }
1741
1742