@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.
Files changed (2) hide show
  1. package/index.ts +178 -93
  2. 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 = (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);
@@ -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
- // 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;
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
- 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) => {
162
212
  try {
163
- const sessionKey = ctx?.sessionKey ?? "";
164
- 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
+ });
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 (!mapping) return; // Not a dispatch sub-agent
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
- // Stale event rejection only process if attempt matches
178
- 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) : "?";
179
295
  api.logger.info(
180
- `${hookName}: stale event for ${mapping.dispatchId} ` +
181
- `(event attempt=${mapping.attempt}, current=${dispatch.attempt})`
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
- // Create Linear API for hook context
187
- const tokenInfo = resolveLinearToken(pluginConfig);
188
- if (!tokenInfo.accessToken) {
189
- api.logger.error(`${hookName}: no Linear access token — cannot process dispatch event`);
190
- 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
+ );
191
318
  }
192
- const linearApi = new LinearAgentApi(tokenInfo.accessToken, {
193
- refreshToken: tokenInfo.refreshToken,
194
- expiresAt: tokenInfo.expiresAt,
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
- if (mapping.phase === "worker") {
209
- api.logger.info(`${hookName}: worker completed for ${mapping.dispatchId} - triggering audit`);
210
- await triggerAudit(hookCtx, dispatch, {
211
- success,
212
- output,
213
- }, sessionKey);
214
- } else if (mapping.phase === "audit") {
215
- api.logger.info(`${hookName}: audit completed for ${mapping.dispatchId} - processing verdict`);
216
- await processVerdict(hookCtx, dispatch, {
217
- success,
218
- output,
219
- }, sessionKey);
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
- 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(", ")}`);
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: { content?: string }) => {
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.22",
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
  },