@calltelemetry/openclaw-linear 0.9.22 → 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 +178 -93
- package/package.json +2 -2
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);
|
|
@@ -152,110 +151,196 @@ export default function register(api: OpenClawPluginApi) {
|
|
|
152
151
|
// Instantiate notifier (Discord, Slack, or both — config-driven)
|
|
153
152
|
const notify: NotifyFn = createNotifierFromConfig(pluginConfig, api.runtime, api);
|
|
154
153
|
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
|
|
159
|
-
|
|
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
|
+
});
|
|
160
193
|
|
|
161
|
-
|
|
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) => {
|
|
162
212
|
try {
|
|
163
|
-
const
|
|
164
|
-
|
|
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
|
+
});
|
|
165
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 {
|
|
166
274
|
const statePath = pluginConfig?.dispatchStatePath as string | undefined;
|
|
167
275
|
const state = await readDispatchState(statePath);
|
|
168
276
|
const mapping = lookupSessionMapping(state, sessionKey);
|
|
169
|
-
if (
|
|
170
|
-
|
|
171
|
-
const dispatch = getActiveDispatch(state, mapping.dispatchId);
|
|
172
|
-
if (!dispatch) {
|
|
173
|
-
api.logger.info(`${hookName}: dispatch ${mapping.dispatchId} no longer active`);
|
|
174
|
-
return;
|
|
277
|
+
if (mapping) {
|
|
278
|
+
api.logger.info(`session_start: dispatch ${mapping.dispatchId} phase=${mapping.phase} session started`);
|
|
175
279
|
}
|
|
280
|
+
} catch {
|
|
281
|
+
// Never block session start for telemetry
|
|
282
|
+
}
|
|
283
|
+
});
|
|
176
284
|
|
|
177
|
-
|
|
178
|
-
|
|
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) : "?";
|
|
179
295
|
api.logger.info(
|
|
180
|
-
|
|
181
|
-
`
|
|
296
|
+
`session_end: dispatch ${mapping.dispatchId} phase=${mapping.phase} ` +
|
|
297
|
+
`messages=${event.messageCount} duration=${durationSec}s`
|
|
182
298
|
);
|
|
183
|
-
return;
|
|
184
299
|
}
|
|
300
|
+
} catch {
|
|
301
|
+
// Never block session end for telemetry
|
|
302
|
+
}
|
|
303
|
+
});
|
|
185
304
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
+
);
|
|
191
318
|
}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
const hookCtx: HookContext = {
|
|
198
|
-
api,
|
|
199
|
-
linearApi,
|
|
200
|
-
notify,
|
|
201
|
-
pluginConfig,
|
|
202
|
-
configPath: statePath,
|
|
203
|
-
};
|
|
204
|
-
|
|
205
|
-
const output = extractCompletionOutput(event);
|
|
206
|
-
const success = parseCompletionSuccess(event);
|
|
319
|
+
} catch {
|
|
320
|
+
// Never block compaction pipeline
|
|
321
|
+
}
|
|
322
|
+
});
|
|
207
323
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
} catch (err) {
|
|
222
|
-
api.logger.error(`${hookName} hook error: ${err}`);
|
|
223
|
-
// Escalate: mark dispatch as stuck so it's visible
|
|
224
|
-
try {
|
|
225
|
-
const statePath = pluginConfig?.dispatchStatePath as string | undefined;
|
|
226
|
-
const state = await readDispatchState(statePath);
|
|
227
|
-
const sessionKey = ctx?.sessionKey ?? "";
|
|
228
|
-
const mapping = sessionKey ? lookupSessionMapping(state, sessionKey) : null;
|
|
229
|
-
if (mapping) {
|
|
230
|
-
const dispatch = getActiveDispatch(state, mapping.dispatchId);
|
|
231
|
-
if (dispatch && dispatch.status !== "done" && dispatch.status !== "stuck" && dispatch.status !== "failed") {
|
|
232
|
-
const stuckReason = `Hook error: ${err instanceof Error ? err.message : String(err)}`.slice(0, 500);
|
|
233
|
-
await transitionDispatch(
|
|
234
|
-
mapping.dispatchId,
|
|
235
|
-
dispatch.status as DispatchStatus,
|
|
236
|
-
"stuck",
|
|
237
|
-
{ stuckReason },
|
|
238
|
-
statePath,
|
|
239
|
-
);
|
|
240
|
-
// Notify if possible
|
|
241
|
-
await notify("escalation", {
|
|
242
|
-
identifier: dispatch.issueIdentifier,
|
|
243
|
-
title: dispatch.issueTitle ?? "Unknown",
|
|
244
|
-
status: "stuck",
|
|
245
|
-
reason: `Dispatch failed in ${mapping.phase} phase: ${stuckReason}`,
|
|
246
|
-
}).catch(() => {}); // Don't fail on notification failure
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
} catch (escalateErr) {
|
|
250
|
-
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
|
+
);
|
|
251
337
|
}
|
|
338
|
+
} catch {
|
|
339
|
+
// Never block reset
|
|
252
340
|
}
|
|
253
|
-
};
|
|
341
|
+
});
|
|
254
342
|
|
|
255
|
-
|
|
256
|
-
onAnyHook(hookName, (event: any, ctx: any) => handleCompletionEvent(event, ctx, hookName));
|
|
257
|
-
}
|
|
258
|
-
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");
|
|
259
344
|
|
|
260
345
|
// Inject recent dispatch history as context for worker/audit agents
|
|
261
346
|
api.on("before_agent_start", async (event: any, ctx: any) => {
|
|
@@ -342,11 +427,11 @@ export default function register(api: OpenClawPluginApi) {
|
|
|
342
427
|
];
|
|
343
428
|
const MAX_SHORT_RESPONSE = 250;
|
|
344
429
|
|
|
345
|
-
api.on("message_sending", (event
|
|
430
|
+
api.on("message_sending", (event) => {
|
|
346
431
|
const text = event?.content ?? "";
|
|
347
|
-
if (!text || text.length > MAX_SHORT_RESPONSE) return
|
|
432
|
+
if (!text || text.length > MAX_SHORT_RESPONSE) return;
|
|
348
433
|
const isNarration = NARRATION_PATTERNS.some((p) => p.test(text));
|
|
349
|
-
if (!isNarration) return
|
|
434
|
+
if (!isNarration) return;
|
|
350
435
|
api.logger.warn(`Narration guard triggered: "${text.slice(0, 80)}..."`);
|
|
351
436
|
return {
|
|
352
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
|
},
|