@calltelemetry/openclaw-linear 0.9.20 → 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
@@ -134,6 +134,14 @@ The end result: you work in Linear. You create issues, assign them, comment in p
134
134
 
135
135
  ## Quick Start
136
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
+
137
145
  ### 1. Install the plugin
138
146
 
139
147
  ```bash
@@ -1451,9 +1459,11 @@ The `request_work` intent is the only one gated by issue state. When the issue i
1451
1459
 
1452
1460
  ### Hook Lifecycle
1453
1461
 
1454
- 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.
1455
1465
 
1456
- **`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:
1457
1467
  - Looks up the session key in dispatch state to find the active dispatch
1458
1468
  - Validates the attempt number matches (rejects stale events from old retries)
1459
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.20",
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",
@@ -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
  */
@@ -231,8 +257,8 @@ export async function runInTmux(opts: RunInTmuxOptions): Promise<CliResult> {
231
257
  progress.push(formatActivityLogLine(activity));
232
258
  }
233
259
 
234
- // Detect completion Claude uses "result", Codex uses "session.completed"
235
- if (event.type === "result" || event.type === "session.completed") {
260
+ // Detect completion across known CLI event shapes.
261
+ if (isCompletionEvent(event)) {
236
262
  completionEventReceived = true;
237
263
  cleanup("done");
238
264
  rl.close();