@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 +184 -93
- package/package.json +2 -2
- package/src/infra/tmux-runner.ts +3 -3
- package/src/tools/claude-tool.ts +17 -15
- package/src/tools/codex-tool.ts +13 -13
- package/src/tools/gemini-tool.ts +11 -11
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 =
|
|
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
|
-
//
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
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
|
|
158
|
-
|
|
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 (
|
|
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
|
-
|
|
172
|
-
|
|
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
|
-
|
|
175
|
-
`
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
38
|
+
"openclaw": "^2026.3.7",
|
|
39
39
|
"typescript": "^5.9.3",
|
|
40
40
|
"vitest": "^4.0.18"
|
|
41
41
|
},
|
package/src/infra/tmux-runner.ts
CHANGED
|
@@ -33,7 +33,7 @@ export interface RunInTmuxOptions {
|
|
|
33
33
|
timeoutMs: number;
|
|
34
34
|
watchdogMs: number;
|
|
35
35
|
logPath: string;
|
|
36
|
-
mapEvent: (event: any) => ActivityContent
|
|
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
|
|
251
|
-
|
|
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}`);
|
package/src/tools/claude-tool.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
58
|
+
activities.push({ type: "action", action: `Running ${toolName}`, parameter: paramSummary });
|
|
58
59
|
}
|
|
59
60
|
}
|
|
60
|
-
return
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
303
|
-
const
|
|
304
|
-
|
|
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}`);
|
package/src/tools/codex-tool.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
252
|
-
|
|
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}`);
|
package/src/tools/gemini-tool.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
80
|
+
return [];
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
/**
|
|
@@ -244,9 +244,9 @@ export async function runGemini(
|
|
|
244
244
|
}
|
|
245
245
|
}
|
|
246
246
|
|
|
247
|
-
// Stream
|
|
248
|
-
const
|
|
249
|
-
|
|
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}`);
|