@calltelemetry/openclaw-linear 0.8.1 → 0.8.2

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.
@@ -2,7 +2,7 @@
2
2
  "id": "openclaw-linear",
3
3
  "name": "Linear Agent",
4
4
  "description": "Linear integration with OAuth support, agent pipeline, and webhook-driven AI agent lifecycle",
5
- "version": "0.8.1",
5
+ "version": "0.8.2",
6
6
  "configSchema": {
7
7
  "type": "object",
8
8
  "additionalProperties": false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@calltelemetry/openclaw-linear",
3
- "version": "0.8.1",
3
+ "version": "0.8.2",
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",
@@ -155,158 +155,14 @@ export async function handleLinearWebhook(
155
155
  emitDiagnostic(api, { event: "webhook_received", webhookType: payload.type, webhookAction: payload.action });
156
156
 
157
157
 
158
- // ── AppUserNotification — OAuth app webhook for agent mentions/assignments
158
+ // ── AppUserNotification — IGNORED ─────────────────────────────────
159
+ // AppUserNotification duplicates events already handled by the workspace
160
+ // webhook (Comment.create for mentions, Issue.update for assignments).
161
+ // Processing both causes double agent runs. Ack and discard.
159
162
  if (payload.type === "AppUserNotification") {
163
+ api.logger.info(`AppUserNotification ignored (duplicate of workspace webhook): ${payload.notification?.type} appUserId=${payload.appUserId}`);
160
164
  res.statusCode = 200;
161
165
  res.end("ok");
162
-
163
- const notification = payload.notification;
164
- const notifType = notification?.type;
165
- api.logger.info(`AppUserNotification: ${notifType} appUserId=${payload.appUserId}`);
166
-
167
- const issue = notification?.issue;
168
- const comment = notification?.comment ?? notification?.parentComment;
169
-
170
- if (!issue?.id) {
171
- api.logger.error("AppUserNotification missing issue data");
172
- return true;
173
- }
174
-
175
- const linearApi = createLinearApi(api);
176
- if (!linearApi) {
177
- api.logger.error("No Linear access token — cannot process agent notification");
178
- return true;
179
- }
180
-
181
- const agentId = resolveAgentId(api);
182
-
183
- // Fetch full issue details
184
- let enrichedIssue: any = issue;
185
- try {
186
- enrichedIssue = await linearApi.getIssueDetails(issue.id);
187
- } catch (err) {
188
- api.logger.warn(`Could not fetch issue details: ${err}`);
189
- }
190
-
191
- const description = enrichedIssue?.description ?? issue?.description ?? "(no description)";
192
- const comments = enrichedIssue?.comments?.nodes ?? [];
193
- const commentSummary = comments
194
- .slice(-5)
195
- .map((c: any) => ` - **${c.user?.name ?? "Unknown"}**: ${c.body?.slice(0, 200)}`)
196
- .join("\n");
197
-
198
- const notifIssueRef = enrichedIssue?.identifier ?? issue.id;
199
- const message = [
200
- `You are an orchestrator responding to a Linear issue notification. Your text output will be automatically posted as a comment on the issue (do NOT post a comment yourself — the handler does it).`,
201
- ``,
202
- `**Tool access:**`,
203
- `- \`linearis\` CLI: READ ONLY. You can read issues (\`linearis issues read ${notifIssueRef}\`), list, and search. Do NOT use linearis to update, close, comment, or modify issues.`,
204
- `- \`code_run\`: Dispatch coding work to a worker. Workers return text — they cannot access linearis.`,
205
- `- Standard tools: exec, read, edit, write, web_search, etc.`,
206
- ``,
207
- `**Your role:** Dispatcher. For work requests, use \`code_run\`. You do NOT update issue status — the audit system handles lifecycle.`,
208
- ``,
209
- `## Issue: ${notifIssueRef} — ${enrichedIssue?.title ?? issue.title ?? "(untitled)"}`,
210
- `**Status:** ${enrichedIssue?.state?.name ?? "Unknown"} | **Assignee:** ${enrichedIssue?.assignee?.name ?? "Unassigned"}`,
211
- ``,
212
- `**Description:**`,
213
- description,
214
- commentSummary ? `\n**Recent comments:**\n${commentSummary}` : "",
215
- comment?.body ? `\n**Triggering comment:**\n> ${comment.body}` : "",
216
- ``,
217
- `Respond concisely. For work requests, dispatch via \`code_run\` and summarize the result.`,
218
- ].filter(Boolean).join("\n");
219
-
220
- // Dispatch agent with session lifecycle (non-blocking)
221
- void (async () => {
222
- const profiles = loadAgentProfiles();
223
- const defaultProfile = Object.entries(profiles).find(([, p]) => p.isDefault);
224
- const label = defaultProfile?.[1]?.label ?? profiles[agentId]?.label ?? agentId;
225
- const avatarUrl = defaultProfile?.[1]?.avatarUrl ?? profiles[agentId]?.avatarUrl;
226
- let agentSessionId: string | null = null;
227
-
228
- try {
229
- // 1. Create agent session (non-fatal)
230
- const sessionResult = await linearApi.createSessionOnIssue(issue.id);
231
- agentSessionId = sessionResult.sessionId;
232
- if (agentSessionId) {
233
- api.logger.info(`Created agent session ${agentSessionId} for notification`);
234
- setActiveSession({
235
- agentSessionId,
236
- issueIdentifier: enrichedIssue?.identifier ?? issue.id,
237
- issueId: issue.id,
238
- agentId,
239
- startedAt: Date.now(),
240
- });
241
- } else {
242
- api.logger.warn(`Could not create agent session for notification: ${sessionResult.error ?? "unknown"}`);
243
- }
244
-
245
- // 2. Emit thought
246
- if (agentSessionId) {
247
- await linearApi.emitActivity(agentSessionId, {
248
- type: "thought",
249
- body: `Reviewing ${enrichedIssue?.identifier ?? issue.id}...`,
250
- }).catch(() => {});
251
- }
252
-
253
- // 3. Run agent with streaming
254
- const sessionId = `linear-notif-${notification?.type ?? "unknown"}-${Date.now()}`;
255
- const { runAgent } = await import("../agent/agent.js");
256
- const result = await runAgent({
257
- api,
258
- agentId,
259
- sessionId,
260
- message,
261
- timeoutMs: 3 * 60_000,
262
- streaming: agentSessionId ? { linearApi, agentSessionId } : undefined,
263
- });
264
-
265
- const responseBody = result.success
266
- ? result.output
267
- : `Something went wrong while processing this. The system will retry automatically if possible. If this keeps happening, run \`openclaw openclaw-linear doctor\` to check for issues.`;
268
-
269
- // 5. Post branded comment (fallback to prefix)
270
- const brandingOpts = avatarUrl
271
- ? { createAsUser: label, displayIconUrl: avatarUrl }
272
- : undefined;
273
-
274
- try {
275
- if (brandingOpts) {
276
- await linearApi.createComment(issue.id, responseBody, brandingOpts);
277
- } else {
278
- await linearApi.createComment(issue.id, `**[${label}]** ${responseBody}`);
279
- }
280
- } catch (brandErr) {
281
- api.logger.warn(`Branded comment failed, falling back to prefix: ${brandErr}`);
282
- await linearApi.createComment(issue.id, `**[${label}]** ${responseBody}`);
283
- }
284
-
285
- // 6. Emit response (closes session)
286
- if (agentSessionId) {
287
- const truncated = responseBody.length > 2000
288
- ? responseBody.slice(0, 2000) + "…"
289
- : responseBody;
290
- await linearApi.emitActivity(agentSessionId, {
291
- type: "response",
292
- body: truncated,
293
- }).catch(() => {});
294
- }
295
-
296
- api.logger.info(`Posted agent response to ${enrichedIssue?.identifier ?? issue.id}`);
297
- } catch (err) {
298
- api.logger.error(`AppUserNotification handler error: ${err}`);
299
- if (agentSessionId) {
300
- await linearApi.emitActivity(agentSessionId, {
301
- type: "error",
302
- body: `Failed to process notification: ${String(err).slice(0, 500)}`,
303
- }).catch(() => {});
304
- }
305
- } finally {
306
- clearActiveSession(issue.id);
307
- }
308
- })();
309
-
310
166
  return true;
311
167
  }
312
168
 
@@ -965,6 +821,14 @@ export async function handleLinearWebhook(
965
821
 
966
822
  const agentId = resolveAgentId(api);
967
823
 
824
+ // Guard: prevent duplicate runs on same issue (also blocks AgentSessionEvent
825
+ // webhooks that arrive from sessions we create during triage)
826
+ if (activeRuns.has(issue.id)) {
827
+ api.logger.info(`Issue.create: ${issue.identifier ?? issue.id} already has active run — skipping triage`);
828
+ return true;
829
+ }
830
+ activeRuns.add(issue.id);
831
+
968
832
  // Dispatch triage (non-blocking)
969
833
  void (async () => {
970
834
  const profiles = loadAgentProfiles();
@@ -1172,6 +1036,7 @@ export async function handleLinearWebhook(
1172
1036
  }
1173
1037
  } finally {
1174
1038
  clearActiveSession(issue.id);
1039
+ activeRuns.delete(issue.id);
1175
1040
  }
1176
1041
  })();
1177
1042
 
@@ -1481,6 +1346,9 @@ async function handleDispatch(
1481
1346
  }
1482
1347
 
1483
1348
  // 6. Create agent session on Linear
1349
+ // Mark active BEFORE session creation so that any AgentSessionEvent.created
1350
+ // webhook arriving from this call is blocked by the activeRuns guard.
1351
+ activeRuns.add(issue.id);
1484
1352
  let agentSessionId: string | undefined;
1485
1353
  try {
1486
1354
  const sessionResult = await linearApi.createSessionOnIssue(issue.id);
@@ -1583,7 +1451,7 @@ async function handleDispatch(
1583
1451
  }
1584
1452
 
1585
1453
  // 11. Run v2 pipeline: worker → audit → verdict (non-blocking)
1586
- activeRuns.add(issue.id);
1454
+ // (activeRuns already set in step 6 above)
1587
1455
 
1588
1456
  // Instantiate notifier (Discord, Slack, or both — config-driven)
1589
1457
  const notify: NotifyFn = createNotifierFromConfig(pluginConfig, api.runtime, api);