@calltelemetry/openclaw-linear 0.9.20 → 0.9.22

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
 
@@ -57,6 +102,8 @@ export default function register(api: OpenClawPluginApi) {
57
102
  // Register Linear webhook handler on a dedicated route
58
103
  api.registerHttpRoute({
59
104
  path: "/linear/webhook",
105
+ auth: "plugin",
106
+ match: "exact",
60
107
  handler: async (req, res) => {
61
108
  await handleLinearWebhook(api, req, res);
62
109
  },
@@ -65,6 +112,8 @@ export default function register(api: OpenClawPluginApi) {
65
112
  // Back-compat route so existing production webhook URLs keep working.
66
113
  api.registerHttpRoute({
67
114
  path: "/hooks/linear",
115
+ auth: "plugin",
116
+ match: "exact",
68
117
  handler: async (req, res) => {
69
118
  await handleLinearWebhook(api, req, res);
70
119
  },
@@ -73,6 +122,8 @@ export default function register(api: OpenClawPluginApi) {
73
122
  // Register OAuth callback route
74
123
  api.registerHttpRoute({
75
124
  path: "/linear/oauth/callback",
125
+ auth: "plugin",
126
+ match: "exact",
76
127
  handler: async (req, res) => {
77
128
  await handleOAuthCallback(api, req, res);
78
129
  },
@@ -95,17 +146,19 @@ export default function register(api: OpenClawPluginApi) {
95
146
  }).catch((err) => api.logger.warn(`Planning state hydration failed: ${err}`));
96
147
 
97
148
  // ---------------------------------------------------------------------------
98
- // Dispatch pipeline v2: notifier + agent_end lifecycle hook
149
+ // Dispatch pipeline v2: notifier + completion lifecycle hooks
99
150
  // ---------------------------------------------------------------------------
100
151
 
101
152
  // Instantiate notifier (Discord, Slack, or both — config-driven)
102
153
  const notify: NotifyFn = createNotifierFromConfig(pluginConfig, api.runtime, api);
103
154
 
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) => {
155
+ // Register completion hooks — safety net for sessions_spawn sub-agents.
156
+ // In the current implementation, the worker->audit->verdict flow runs inline
157
+ // via spawnWorker() in pipeline.ts. These hooks catch sessions_spawn agents
158
+ // (future upgrade path) and serve as a recovery mechanism.
159
+ const onAnyHook = api.on as unknown as (hookName: string, handler: (event: any, ctx: any) => Promise<void> | void) => void;
160
+
161
+ const handleCompletionEvent = async (event: any, ctx: any, hookName: string) => {
109
162
  try {
110
163
  const sessionKey = ctx?.sessionKey ?? "";
111
164
  if (!sessionKey) return;
@@ -117,14 +170,14 @@ export default function register(api: OpenClawPluginApi) {
117
170
 
118
171
  const dispatch = getActiveDispatch(state, mapping.dispatchId);
119
172
  if (!dispatch) {
120
- api.logger.info(`agent_end: dispatch ${mapping.dispatchId} no longer active`);
173
+ api.logger.info(`${hookName}: dispatch ${mapping.dispatchId} no longer active`);
121
174
  return;
122
175
  }
123
176
 
124
177
  // Stale event rejection — only process if attempt matches
125
178
  if (dispatch.attempt !== mapping.attempt) {
126
179
  api.logger.info(
127
- `agent_end: stale event for ${mapping.dispatchId} ` +
180
+ `${hookName}: stale event for ${mapping.dispatchId} ` +
128
181
  `(event attempt=${mapping.attempt}, current=${dispatch.attempt})`
129
182
  );
130
183
  return;
@@ -133,7 +186,7 @@ export default function register(api: OpenClawPluginApi) {
133
186
  // Create Linear API for hook context
134
187
  const tokenInfo = resolveLinearToken(pluginConfig);
135
188
  if (!tokenInfo.accessToken) {
136
- api.logger.error("agent_end: no Linear access token — cannot process dispatch event");
189
+ api.logger.error(`${hookName}: no Linear access token — cannot process dispatch event`);
137
190
  return;
138
191
  }
139
192
  const linearApi = new LinearAgentApi(tokenInfo.accessToken, {
@@ -149,29 +202,24 @@ export default function register(api: OpenClawPluginApi) {
149
202
  configPath: statePath,
150
203
  };
151
204
 
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") || "";
205
+ const output = extractCompletionOutput(event);
206
+ const success = parseCompletionSuccess(event);
159
207
 
160
208
  if (mapping.phase === "worker") {
161
- api.logger.info(`agent_end: worker completed for ${mapping.dispatchId} triggering audit`);
209
+ api.logger.info(`${hookName}: worker completed for ${mapping.dispatchId} - triggering audit`);
162
210
  await triggerAudit(hookCtx, dispatch, {
163
- success: event?.success ?? true,
211
+ success,
164
212
  output,
165
213
  }, sessionKey);
166
214
  } else if (mapping.phase === "audit") {
167
- api.logger.info(`agent_end: audit completed for ${mapping.dispatchId} processing verdict`);
215
+ api.logger.info(`${hookName}: audit completed for ${mapping.dispatchId} - processing verdict`);
168
216
  await processVerdict(hookCtx, dispatch, {
169
- success: event?.success ?? true,
217
+ success,
170
218
  output,
171
219
  }, sessionKey);
172
220
  }
173
221
  } catch (err) {
174
- api.logger.error(`agent_end hook error: ${err}`);
222
+ api.logger.error(`${hookName} hook error: ${err}`);
175
223
  // Escalate: mark dispatch as stuck so it's visible
176
224
  try {
177
225
  const statePath = pluginConfig?.dispatchStatePath as string | undefined;
@@ -199,10 +247,15 @@ export default function register(api: OpenClawPluginApi) {
199
247
  }
200
248
  }
201
249
  } catch (escalateErr) {
202
- api.logger.error(`agent_end escalation also failed: ${escalateErr}`);
250
+ api.logger.error(`${hookName} escalation also failed: ${escalateErr}`);
203
251
  }
204
252
  }
205
- });
253
+ };
254
+
255
+ for (const hookName of COMPLETION_HOOK_NAMES) {
256
+ onAnyHook(hookName, (event: any, ctx: any) => handleCompletionEvent(event, ctx, hookName));
257
+ }
258
+ api.logger.info(`Dispatch completion hooks registered: ${COMPLETION_HOOK_NAMES.join(", ")}`);
206
259
 
207
260
  // Inject recent dispatch history as context for worker/audit agents
208
261
  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.22",
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;
@@ -25,7 +33,7 @@ export interface RunInTmuxOptions {
25
33
  timeoutMs: number;
26
34
  watchdogMs: number;
27
35
  logPath: string;
28
- mapEvent: (event: any) => ActivityContent | null;
36
+ mapEvent: (event: any) => ActivityContent[];
29
37
  linearApi?: LinearAgentApi;
30
38
  agentSessionId?: string;
31
39
  steeringMode: "stdin-pipe" | "one-shot";
@@ -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
  */
@@ -221,8 +247,8 @@ export async function runInTmux(opts: RunInTmuxOptions): Promise<CliResult> {
221
247
  }
222
248
 
223
249
  // Stream to Linear
224
- const activity = mapEvent(event);
225
- if (activity) {
250
+ const activities = mapEvent(event);
251
+ for (const activity of activities) {
226
252
  if (linearApi && agentSessionId) {
227
253
  linearApi.emitActivity(agentSessionId, activity).catch((err) => {
228
254
  logger.warn(`Failed to emit tmux activity: ${err}`);
@@ -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();
@@ -26,17 +26,18 @@ const CLAUDE_BIN = "claude";
26
26
  * Claude event types:
27
27
  * system(init) → assistant (text|tool_use) → user (tool_result) → result
28
28
  */
29
- function mapClaudeEventToActivity(event: any): ActivityContent | null {
29
+ function mapClaudeEventToActivity(event: any): ActivityContent[] {
30
30
  const type = event?.type;
31
31
 
32
- // Assistant message — text response or tool use
32
+ // Assistant message — text response and/or tool use (emit all blocks)
33
33
  if (type === "assistant") {
34
34
  const content = event.message?.content;
35
- if (!Array.isArray(content)) return null;
35
+ if (!Array.isArray(content)) return [];
36
36
 
37
+ const activities: ActivityContent[] = [];
37
38
  for (const block of content) {
38
39
  if (block.type === "text" && block.text) {
39
- return { type: "thought", body: block.text.slice(0, 1000) };
40
+ activities.push({ type: "thought", body: block.text.slice(0, 1000) });
40
41
  }
41
42
  if (block.type === "tool_use") {
42
43
  const toolName = block.name ?? "tool";
@@ -54,30 +55,31 @@ function mapClaudeEventToActivity(event: any): ActivityContent | null {
54
55
  } else {
55
56
  paramSummary = JSON.stringify(input).slice(0, 500);
56
57
  }
57
- return { type: "action", action: `Running ${toolName}`, parameter: paramSummary };
58
+ activities.push({ type: "action", action: `Running ${toolName}`, parameter: paramSummary });
58
59
  }
59
60
  }
60
- return null;
61
+ return activities;
61
62
  }
62
63
 
63
64
  // Tool result
64
65
  if (type === "user") {
65
66
  const content = event.message?.content;
66
- if (!Array.isArray(content)) return null;
67
+ if (!Array.isArray(content)) return [];
67
68
 
69
+ const activities: ActivityContent[] = [];
68
70
  for (const block of content) {
69
71
  if (block.type === "tool_result") {
70
72
  const output = typeof block.content === "string" ? block.content : "";
71
73
  const truncated = output.length > 1000 ? output.slice(0, 1000) + "..." : output;
72
74
  const isError = block.is_error === true;
73
- return {
75
+ activities.push({
74
76
  type: "action",
75
77
  action: isError ? "Tool error" : "Tool result",
76
78
  parameter: truncated || "(no output)",
77
- };
79
+ });
78
80
  }
79
81
  }
80
- return null;
82
+ return activities;
81
83
  }
82
84
 
83
85
  // Final result
@@ -92,10 +94,10 @@ function mapClaudeEventToActivity(event: any): ActivityContent | null {
92
94
  const output = usage.output_tokens ?? 0;
93
95
  parts.push(`${input} in / ${output} out tokens`);
94
96
  }
95
- return { type: "thought", body: parts.join(" — ") };
97
+ return [{ type: "thought", body: parts.join(" — ") }];
96
98
  }
97
99
 
98
- return null;
100
+ return [];
99
101
  }
100
102
 
101
103
  /**
@@ -299,9 +301,9 @@ export async function runClaude(
299
301
  // (it duplicates the last assistant text message)
300
302
  }
301
303
 
302
- // Stream activity to Linear + session progress
303
- const activity = mapClaudeEventToActivity(event);
304
- if (activity) {
304
+ // Stream activities to Linear + session progress
305
+ const activities = mapClaudeEventToActivity(event);
306
+ for (const activity of activities) {
305
307
  if (linearApi && agentSessionId) {
306
308
  linearApi.emitActivity(agentSessionId, activity).catch((err) => {
307
309
  api.logger.warn(`Failed to emit Claude activity: ${err}`);
@@ -23,13 +23,13 @@ const CODEX_BIN = "codex";
23
23
  /**
24
24
  * Parse a JSONL line from `codex exec --json` and map it to a Linear activity.
25
25
  */
26
- function mapCodexEventToActivity(event: any): ActivityContent | null {
26
+ function mapCodexEventToActivity(event: any): ActivityContent[] {
27
27
  const eventType = event?.type;
28
28
  const item = event?.item;
29
29
 
30
30
  if (item?.type === "reasoning") {
31
31
  const text = item.text ?? "";
32
- return { type: "thought", body: text ? text.slice(0, 500) : "Reasoning..." };
32
+ return [{ type: "thought", body: text ? text.slice(0, 500) : "Reasoning..." }];
33
33
  }
34
34
 
35
35
  if (
@@ -37,8 +37,8 @@ function mapCodexEventToActivity(event: any): ActivityContent | null {
37
37
  (item?.type === "agent_message" || item?.type === "message")
38
38
  ) {
39
39
  const text = item.text ?? item.content ?? "";
40
- if (text) return { type: "thought", body: text.slice(0, 1000) };
41
- return null;
40
+ if (text) return [{ type: "thought", body: text.slice(0, 1000) }];
41
+ return [];
42
42
  }
43
43
 
44
44
  if (eventType === "item.started" && item?.type === "command_execution") {
@@ -46,7 +46,7 @@ function mapCodexEventToActivity(event: any): ActivityContent | null {
46
46
  const cleaned = typeof cmd === "string"
47
47
  ? cmd.replace(/^\/usr\/bin\/\w+ -lc ['"]?/, "").replace(/['"]?$/, "")
48
48
  : JSON.stringify(cmd);
49
- return { type: "action", action: "Running", parameter: cleaned.slice(0, 200) };
49
+ return [{ type: "action", action: "Running", parameter: cleaned.slice(0, 200) }];
50
50
  }
51
51
 
52
52
  if (eventType === "item.completed" && item?.type === "command_execution") {
@@ -57,19 +57,19 @@ function mapCodexEventToActivity(event: any): ActivityContent | null {
57
57
  ? cmd.replace(/^\/usr\/bin\/\w+ -lc ['"]?/, "").replace(/['"]?$/, "")
58
58
  : JSON.stringify(cmd);
59
59
  const truncated = output.length > 1000 ? output.slice(0, 1000) + "..." : output;
60
- return {
60
+ return [{
61
61
  type: "action",
62
62
  action: `${cleaned.slice(0, 150)}`,
63
63
  parameter: `exit ${exitCode}`,
64
64
  result: truncated || undefined,
65
- };
65
+ }];
66
66
  }
67
67
 
68
68
  if (eventType === "item.completed" && item?.type === "file_changes") {
69
69
  const files = item.files ?? [];
70
70
  const fileList = Array.isArray(files) ? files.join(", ") : String(files);
71
71
  const preview = (item.diff ?? item.content ?? "").slice(0, 500) || undefined;
72
- return { type: "action", action: "Modified files", parameter: fileList || "unknown files", result: preview };
72
+ return [{ type: "action", action: "Modified files", parameter: fileList || "unknown files", result: preview }];
73
73
  }
74
74
 
75
75
  if (eventType === "turn.completed") {
@@ -78,12 +78,12 @@ function mapCodexEventToActivity(event: any): ActivityContent | null {
78
78
  const input = usage.input_tokens ?? 0;
79
79
  const cached = usage.cached_input_tokens ?? 0;
80
80
  const output = usage.output_tokens ?? 0;
81
- return { type: "thought", body: `Codex turn complete (${input} in / ${cached} cached / ${output} out tokens)` };
81
+ return [{ type: "thought", body: `Codex turn complete (${input} in / ${cached} cached / ${output} out tokens)` }];
82
82
  }
83
- return { type: "thought", body: "Codex turn complete" };
83
+ return [{ type: "thought", body: "Codex turn complete" }];
84
84
  }
85
85
 
86
- return null;
86
+ return [];
87
87
  }
88
88
 
89
89
  /**
@@ -248,8 +248,8 @@ export async function runCodex(
248
248
  collectedCommands.push(`\`${cleanCmd}\` → exit ${exitCode}${truncOutput ? "\n```\n" + truncOutput + "\n```" : ""}`);
249
249
  }
250
250
 
251
- const activity = mapCodexEventToActivity(event);
252
- if (activity) {
251
+ const activities = mapCodexEventToActivity(event);
252
+ for (const activity of activities) {
253
253
  if (linearApi && agentSessionId) {
254
254
  linearApi.emitActivity(agentSessionId, activity).catch((err) => {
255
255
  api.logger.warn(`Failed to emit Codex activity: ${err}`);
@@ -26,14 +26,14 @@ const GEMINI_BIN = "gemini";
26
26
  * Gemini event types:
27
27
  * init → message(user) → message(assistant) → tool_use → tool_result → result
28
28
  */
29
- function mapGeminiEventToActivity(event: any): ActivityContent | null {
29
+ function mapGeminiEventToActivity(event: any): ActivityContent[] {
30
30
  const type = event?.type;
31
31
 
32
32
  // Assistant message (delta text)
33
33
  if (type === "message" && event.role === "assistant") {
34
34
  const text = event.content;
35
- if (text) return { type: "thought", body: text.slice(0, 1000) };
36
- return null;
35
+ if (text) return [{ type: "thought", body: text.slice(0, 1000) }];
36
+ return [];
37
37
  }
38
38
 
39
39
  // Tool use — running a command or tool
@@ -50,7 +50,7 @@ function mapGeminiEventToActivity(event: any): ActivityContent | null {
50
50
  } else {
51
51
  paramSummary = JSON.stringify(params).slice(0, 500);
52
52
  }
53
- return { type: "action", action: `Running ${toolName}`, parameter: paramSummary };
53
+ return [{ type: "action", action: `Running ${toolName}`, parameter: paramSummary }];
54
54
  }
55
55
 
56
56
  // Tool result
@@ -58,11 +58,11 @@ function mapGeminiEventToActivity(event: any): ActivityContent | null {
58
58
  const status = event.status ?? "unknown";
59
59
  const output = event.output ?? "";
60
60
  const truncated = output.length > 1000 ? output.slice(0, 1000) + "..." : output;
61
- return {
61
+ return [{
62
62
  type: "action",
63
63
  action: `Tool ${status}`,
64
64
  parameter: truncated || "(no output)",
65
- };
65
+ }];
66
66
  }
67
67
 
68
68
  // Final result
@@ -74,10 +74,10 @@ function mapGeminiEventToActivity(event: any): ActivityContent | null {
74
74
  if (stats.total_tokens) parts.push(`${stats.total_tokens} tokens`);
75
75
  if (stats.tool_calls) parts.push(`${stats.tool_calls} tool calls`);
76
76
  }
77
- return { type: "thought", body: parts.join(" — ") };
77
+ return [{ type: "thought", body: parts.join(" — ") }];
78
78
  }
79
79
 
80
- return null;
80
+ return [];
81
81
  }
82
82
 
83
83
  /**
@@ -244,9 +244,9 @@ export async function runGemini(
244
244
  }
245
245
  }
246
246
 
247
- // Stream activity to Linear + session progress
248
- const activity = mapGeminiEventToActivity(event);
249
- if (activity) {
247
+ // Stream activities to Linear + session progress
248
+ const activities = mapGeminiEventToActivity(event);
249
+ for (const activity of activities) {
250
250
  if (linearApi && agentSessionId) {
251
251
  linearApi.emitActivity(agentSessionId, activity).catch((err) => {
252
252
  api.logger.warn(`Failed to emit Gemini activity: ${err}`);