@calltelemetry/openclaw-linear 0.9.21 → 0.9.23

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/index.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
- import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
4
+ import type { OpenClawPluginApi, PluginHookAgentEndEvent, PluginHookAgentContext, PluginHookSubagentEndedEvent, PluginHookSubagentContext, PluginHookSessionStartEvent, PluginHookSessionEndEvent, PluginHookSessionContext, PluginHookAfterCompactionEvent, PluginHookBeforeResetEvent } from "openclaw/plugin-sdk";
5
5
  import { registerLinearProvider } from "./src/api/auth.js";
6
6
  import { registerCli } from "./src/infra/cli.js";
7
7
  import { createLinearTools } from "./src/tools/tools.js";
@@ -20,7 +20,6 @@ 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
23
  const SUCCESS_STATUSES = new Set(["ok", "success", "completed", "complete", "done", "pass", "passed"]);
25
24
  const FAILURE_STATUSES = new Set(["error", "failed", "failure", "timeout", "timed_out", "cancelled", "canceled", "aborted", "unknown"]);
26
25
 
@@ -66,7 +65,7 @@ function extractCompletionOutput(event: any): string {
66
65
  }
67
66
 
68
67
  export default function register(api: OpenClawPluginApi) {
69
- const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
68
+ const pluginConfig = api.pluginConfig;
70
69
 
71
70
  // Check token availability (config → env → auth profile store)
72
71
  const tokenInfo = resolveLinearToken(pluginConfig);
@@ -102,6 +101,8 @@ export default function register(api: OpenClawPluginApi) {
102
101
  // Register Linear webhook handler on a dedicated route
103
102
  api.registerHttpRoute({
104
103
  path: "/linear/webhook",
104
+ auth: "plugin",
105
+ match: "exact",
105
106
  handler: async (req, res) => {
106
107
  await handleLinearWebhook(api, req, res);
107
108
  },
@@ -110,6 +111,8 @@ export default function register(api: OpenClawPluginApi) {
110
111
  // Back-compat route so existing production webhook URLs keep working.
111
112
  api.registerHttpRoute({
112
113
  path: "/hooks/linear",
114
+ auth: "plugin",
115
+ match: "exact",
113
116
  handler: async (req, res) => {
114
117
  await handleLinearWebhook(api, req, res);
115
118
  },
@@ -118,6 +121,8 @@ export default function register(api: OpenClawPluginApi) {
118
121
  // Register OAuth callback route
119
122
  api.registerHttpRoute({
120
123
  path: "/linear/oauth/callback",
124
+ auth: "plugin",
125
+ match: "exact",
121
126
  handler: async (req, res) => {
122
127
  await handleOAuthCallback(api, req, res);
123
128
  },
@@ -146,110 +151,196 @@ export default function register(api: OpenClawPluginApi) {
146
151
  // Instantiate notifier (Discord, Slack, or both — config-driven)
147
152
  const notify: NotifyFn = createNotifierFromConfig(pluginConfig, api.runtime, api);
148
153
 
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
+ // Typed dispatch completion handler (shared by agent_end + subagent_ended)
156
+ // ---------------------------------------------------------------------------
157
+ const handleDispatchCompletion = async (
158
+ sessionKey: string,
159
+ success: boolean,
160
+ output: string,
161
+ hookName: string,
162
+ ) => {
163
+ const statePath = pluginConfig?.dispatchStatePath as string | undefined;
164
+ const state = await readDispatchState(statePath);
165
+ const mapping = lookupSessionMapping(state, sessionKey);
166
+ if (!mapping) return; // Not a dispatch sub-agent
167
+
168
+ const dispatch = getActiveDispatch(state, mapping.dispatchId);
169
+ if (!dispatch) {
170
+ api.logger.info(`${hookName}: dispatch ${mapping.dispatchId} no longer active`);
171
+ return;
172
+ }
173
+
174
+ // Stale event rejection — only process if attempt matches
175
+ if (dispatch.attempt !== mapping.attempt) {
176
+ api.logger.info(
177
+ `${hookName}: stale event for ${mapping.dispatchId} ` +
178
+ `(event attempt=${mapping.attempt}, current=${dispatch.attempt})`
179
+ );
180
+ return;
181
+ }
182
+
183
+ // Create Linear API for hook context
184
+ const tokenInfo = resolveLinearToken(pluginConfig);
185
+ if (!tokenInfo.accessToken) {
186
+ api.logger.error(`${hookName}: no Linear access token — cannot process dispatch event`);
187
+ return;
188
+ }
189
+ const linearApi = new LinearAgentApi(tokenInfo.accessToken, {
190
+ refreshToken: tokenInfo.refreshToken,
191
+ expiresAt: tokenInfo.expiresAt,
192
+ });
154
193
 
155
- const handleCompletionEvent = async (event: any, ctx: any, hookName: string) => {
194
+ const hookCtx: HookContext = {
195
+ api,
196
+ linearApi,
197
+ notify,
198
+ pluginConfig,
199
+ configPath: statePath,
200
+ };
201
+
202
+ if (mapping.phase === "worker") {
203
+ api.logger.info(`${hookName}: worker completed for ${mapping.dispatchId} - triggering audit`);
204
+ await triggerAudit(hookCtx, dispatch, { success, output }, sessionKey);
205
+ } else if (mapping.phase === "audit") {
206
+ api.logger.info(`${hookName}: audit completed for ${mapping.dispatchId} - processing verdict`);
207
+ await processVerdict(hookCtx, dispatch, { success, output }, sessionKey);
208
+ }
209
+ };
210
+
211
+ const escalateDispatchError = async (sessionKey: string, err: unknown, hookName: string) => {
156
212
  try {
157
- const sessionKey = ctx?.sessionKey ?? "";
158
- if (!sessionKey) return;
213
+ const statePath = pluginConfig?.dispatchStatePath as string | undefined;
214
+ const state = await readDispatchState(statePath);
215
+ const mapping = sessionKey ? lookupSessionMapping(state, sessionKey) : null;
216
+ if (mapping) {
217
+ const dispatch = getActiveDispatch(state, mapping.dispatchId);
218
+ if (dispatch && dispatch.status !== "done" && dispatch.status !== "stuck" && dispatch.status !== "failed") {
219
+ const stuckReason = `Hook error: ${err instanceof Error ? err.message : String(err)}`.slice(0, 500);
220
+ await transitionDispatch(
221
+ mapping.dispatchId,
222
+ dispatch.status as DispatchStatus,
223
+ "stuck",
224
+ { stuckReason },
225
+ statePath,
226
+ );
227
+ await notify("escalation", {
228
+ identifier: dispatch.issueIdentifier,
229
+ title: dispatch.issueTitle ?? "Unknown",
230
+ status: "stuck",
231
+ reason: `Dispatch failed in ${mapping.phase} phase: ${stuckReason}`,
232
+ }).catch(() => {});
233
+ }
234
+ }
235
+ } catch (escalateErr) {
236
+ api.logger.error(`${hookName} escalation also failed: ${escalateErr}`);
237
+ }
238
+ };
239
+
240
+ // agent_end — fires when an agent run completes (primary dispatch handler)
241
+ api.on("agent_end", async (event: PluginHookAgentEndEvent, ctx: PluginHookAgentContext) => {
242
+ const sessionKey = ctx?.sessionKey ?? "";
243
+ if (!sessionKey) return;
244
+ try {
245
+ const output = extractCompletionOutput(event);
246
+ const success = parseCompletionSuccess(event);
247
+ await handleDispatchCompletion(sessionKey, success, output, "agent_end");
248
+ } catch (err) {
249
+ api.logger.error(`agent_end hook error: ${err}`);
250
+ await escalateDispatchError(sessionKey, err, "agent_end");
251
+ }
252
+ });
159
253
 
254
+ // subagent_ended — fires when a subagent session ends (proper lifecycle hook, new in 3.7)
255
+ // This catches sessions_spawn sub-agents with structured outcome data.
256
+ api.on("subagent_ended", async (event: PluginHookSubagentEndedEvent, ctx: PluginHookSubagentContext) => {
257
+ const sessionKey = event.targetSessionKey ?? ctx?.childSessionKey ?? "";
258
+ if (!sessionKey) return;
259
+ try {
260
+ const success = event.outcome === "ok";
261
+ const output = event.error ?? event.reason ?? "";
262
+ await handleDispatchCompletion(sessionKey, success, output, "subagent_ended");
263
+ } catch (err) {
264
+ api.logger.error(`subagent_ended hook error: ${err}`);
265
+ await escalateDispatchError(sessionKey, err, "subagent_ended");
266
+ }
267
+ });
268
+
269
+ // session_start — track dispatch session lifecycle
270
+ api.on("session_start", async (event: PluginHookSessionStartEvent, ctx: PluginHookSessionContext) => {
271
+ const sessionKey = ctx?.sessionKey ?? event?.sessionKey ?? "";
272
+ if (!sessionKey) return;
273
+ try {
160
274
  const statePath = pluginConfig?.dispatchStatePath as string | undefined;
161
275
  const state = await readDispatchState(statePath);
162
276
  const mapping = lookupSessionMapping(state, sessionKey);
163
- if (!mapping) return; // Not a dispatch sub-agent
164
-
165
- const dispatch = getActiveDispatch(state, mapping.dispatchId);
166
- if (!dispatch) {
167
- api.logger.info(`${hookName}: dispatch ${mapping.dispatchId} no longer active`);
168
- return;
277
+ if (mapping) {
278
+ api.logger.info(`session_start: dispatch ${mapping.dispatchId} phase=${mapping.phase} session started`);
169
279
  }
280
+ } catch {
281
+ // Never block session start for telemetry
282
+ }
283
+ });
170
284
 
171
- // Stale event rejection only process if attempt matches
172
- if (dispatch.attempt !== mapping.attempt) {
285
+ // session_end log dispatch session duration for observability
286
+ api.on("session_end", async (event: PluginHookSessionEndEvent, ctx: PluginHookSessionContext) => {
287
+ const sessionKey = ctx?.sessionKey ?? event?.sessionKey ?? "";
288
+ if (!sessionKey) return;
289
+ try {
290
+ const statePath = pluginConfig?.dispatchStatePath as string | undefined;
291
+ const state = await readDispatchState(statePath);
292
+ const mapping = lookupSessionMapping(state, sessionKey);
293
+ if (mapping) {
294
+ const durationSec = event.durationMs ? Math.round(event.durationMs / 1000) : "?";
173
295
  api.logger.info(
174
- `${hookName}: stale event for ${mapping.dispatchId} ` +
175
- `(event attempt=${mapping.attempt}, current=${dispatch.attempt})`
296
+ `session_end: dispatch ${mapping.dispatchId} phase=${mapping.phase} ` +
297
+ `messages=${event.messageCount} duration=${durationSec}s`
176
298
  );
177
- return;
178
299
  }
300
+ } catch {
301
+ // Never block session end for telemetry
302
+ }
303
+ });
179
304
 
180
- // Create Linear API for hook context
181
- const tokenInfo = resolveLinearToken(pluginConfig);
182
- if (!tokenInfo.accessToken) {
183
- api.logger.error(`${hookName}: no Linear access token — cannot process dispatch event`);
184
- return;
305
+ // after_compaction log when dispatch sessions compact (visibility into context pressure)
306
+ api.on("after_compaction", async (event: PluginHookAfterCompactionEvent, ctx: PluginHookAgentContext) => {
307
+ const sessionKey = ctx?.sessionKey ?? "";
308
+ if (!sessionKey) return;
309
+ try {
310
+ const statePath = pluginConfig?.dispatchStatePath as string | undefined;
311
+ const state = await readDispatchState(statePath);
312
+ const mapping = lookupSessionMapping(state, sessionKey);
313
+ if (mapping) {
314
+ api.logger.warn(
315
+ `after_compaction: dispatch ${mapping.dispatchId} phase=${mapping.phase} ` +
316
+ `compacted ${event.compactedCount} messages (${event.messageCount} remaining)`
317
+ );
185
318
  }
186
- const linearApi = new LinearAgentApi(tokenInfo.accessToken, {
187
- refreshToken: tokenInfo.refreshToken,
188
- expiresAt: tokenInfo.expiresAt,
189
- });
190
-
191
- const hookCtx: HookContext = {
192
- api,
193
- linearApi,
194
- notify,
195
- pluginConfig,
196
- configPath: statePath,
197
- };
198
-
199
- const output = extractCompletionOutput(event);
200
- const success = parseCompletionSuccess(event);
319
+ } catch {
320
+ // Never block compaction pipeline
321
+ }
322
+ });
201
323
 
202
- if (mapping.phase === "worker") {
203
- api.logger.info(`${hookName}: worker completed for ${mapping.dispatchId} - triggering audit`);
204
- await triggerAudit(hookCtx, dispatch, {
205
- success,
206
- output,
207
- }, sessionKey);
208
- } else if (mapping.phase === "audit") {
209
- api.logger.info(`${hookName}: audit completed for ${mapping.dispatchId} - processing verdict`);
210
- await processVerdict(hookCtx, dispatch, {
211
- success,
212
- output,
213
- }, sessionKey);
214
- }
215
- } catch (err) {
216
- api.logger.error(`${hookName} hook error: ${err}`);
217
- // Escalate: mark dispatch as stuck so it's visible
218
- try {
219
- const statePath = pluginConfig?.dispatchStatePath as string | undefined;
220
- const state = await readDispatchState(statePath);
221
- const sessionKey = ctx?.sessionKey ?? "";
222
- const mapping = sessionKey ? lookupSessionMapping(state, sessionKey) : null;
223
- if (mapping) {
224
- const dispatch = getActiveDispatch(state, mapping.dispatchId);
225
- if (dispatch && dispatch.status !== "done" && dispatch.status !== "stuck" && dispatch.status !== "failed") {
226
- const stuckReason = `Hook error: ${err instanceof Error ? err.message : String(err)}`.slice(0, 500);
227
- await transitionDispatch(
228
- mapping.dispatchId,
229
- dispatch.status as DispatchStatus,
230
- "stuck",
231
- { stuckReason },
232
- statePath,
233
- );
234
- // Notify if possible
235
- await notify("escalation", {
236
- identifier: dispatch.issueIdentifier,
237
- title: dispatch.issueTitle ?? "Unknown",
238
- status: "stuck",
239
- reason: `Dispatch failed in ${mapping.phase} phase: ${stuckReason}`,
240
- }).catch(() => {}); // Don't fail on notification failure
241
- }
242
- }
243
- } catch (escalateErr) {
244
- api.logger.error(`${hookName} escalation also failed: ${escalateErr}`);
324
+ // before_reset clean up dispatch tracking when a session is reset
325
+ api.on("before_reset", async (event: PluginHookBeforeResetEvent, ctx: PluginHookAgentContext) => {
326
+ const sessionKey = ctx?.sessionKey ?? "";
327
+ if (!sessionKey) return;
328
+ try {
329
+ const statePath = pluginConfig?.dispatchStatePath as string | undefined;
330
+ const state = await readDispatchState(statePath);
331
+ const mapping = lookupSessionMapping(state, sessionKey);
332
+ if (mapping) {
333
+ api.logger.warn(
334
+ `before_reset: dispatch ${mapping.dispatchId} phase=${mapping.phase} session reset ` +
335
+ `(reason: ${event.reason ?? "unknown"})`
336
+ );
245
337
  }
338
+ } catch {
339
+ // Never block reset
246
340
  }
247
- };
341
+ });
248
342
 
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(", ")}`);
343
+ api.logger.info("Dispatch lifecycle hooks registered: agent_end, subagent_ended, session_start, session_end, after_compaction, before_reset");
253
344
 
254
345
  // Inject recent dispatch history as context for worker/audit agents
255
346
  api.on("before_agent_start", async (event: any, ctx: any) => {
@@ -336,11 +427,11 @@ export default function register(api: OpenClawPluginApi) {
336
427
  ];
337
428
  const MAX_SHORT_RESPONSE = 250;
338
429
 
339
- api.on("message_sending", (event: { content?: string }) => {
430
+ api.on("message_sending", (event) => {
340
431
  const text = event?.content ?? "";
341
- if (!text || text.length > MAX_SHORT_RESPONSE) return {};
432
+ if (!text || text.length > MAX_SHORT_RESPONSE) return;
342
433
  const isNarration = NARRATION_PATTERNS.some((p) => p.test(text));
343
- if (!isNarration) return {};
434
+ if (!isNarration) return;
344
435
  api.logger.warn(`Narration guard triggered: "${text.slice(0, 80)}..."`);
345
436
  return {
346
437
  content:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@calltelemetry/openclaw-linear",
3
- "version": "0.9.21",
3
+ "version": "0.9.23",
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",
@@ -35,7 +35,7 @@
35
35
  },
36
36
  "devDependencies": {
37
37
  "@vitest/coverage-v8": "^4.0.18",
38
- "openclaw": "^2026.2.13",
38
+ "openclaw": "^2026.3.7",
39
39
  "typescript": "^5.9.3",
40
40
  "vitest": "^4.0.18"
41
41
  },
@@ -33,7 +33,7 @@ export interface RunInTmuxOptions {
33
33
  timeoutMs: number;
34
34
  watchdogMs: number;
35
35
  logPath: string;
36
- mapEvent: (event: any) => ActivityContent | null;
36
+ mapEvent: (event: any) => ActivityContent[];
37
37
  linearApi?: LinearAgentApi;
38
38
  agentSessionId?: string;
39
39
  steeringMode: "stdin-pipe" | "one-shot";
@@ -247,8 +247,8 @@ export async function runInTmux(opts: RunInTmuxOptions): Promise<CliResult> {
247
247
  }
248
248
 
249
249
  // Stream to Linear
250
- const activity = mapEvent(event);
251
- if (activity) {
250
+ const activities = mapEvent(event);
251
+ for (const activity of activities) {
252
252
  if (linearApi && agentSessionId) {
253
253
  linearApi.emitActivity(agentSessionId, activity).catch((err) => {
254
254
  logger.warn(`Failed to emit tmux activity: ${err}`);
@@ -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}`);