@calltelemetry/openclaw-linear 0.8.2 → 0.8.4

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.
@@ -29,10 +29,20 @@ interface AgentProfile {
29
29
 
30
30
  const PROFILES_PATH = join(process.env.HOME ?? "/home/claw", ".openclaw", "agent-profiles.json");
31
31
 
32
+ // ── Cached profile loader (5s TTL) ─────────────────────────────────
33
+ let profilesCache: { data: Record<string, AgentProfile>; loadedAt: number } | null = null;
34
+ const PROFILES_CACHE_TTL_MS = 5_000;
35
+
32
36
  function loadAgentProfiles(): Record<string, AgentProfile> {
37
+ const now = Date.now();
38
+ if (profilesCache && now - profilesCache.loadedAt < PROFILES_CACHE_TTL_MS) {
39
+ return profilesCache.data;
40
+ }
33
41
  try {
34
42
  const raw = readFileSync(PROFILES_PATH, "utf8");
35
- return JSON.parse(raw).agents ?? {};
43
+ const data = JSON.parse(raw).agents ?? {};
44
+ profilesCache = { data, loadedAt: now };
45
+ return data;
36
46
  } catch {
37
47
  return {};
38
48
  }
@@ -64,19 +74,45 @@ function resolveAgentFromAlias(alias: string, profiles: Record<string, AgentProf
64
74
  // Track issues with active agent runs to prevent concurrent duplicate runs.
65
75
  const activeRuns = new Set<string>();
66
76
 
67
- // Dedup: track recently processed keys to avoid double-handling
77
+ // Dedup: track recently processed keys to avoid double-handling.
78
+ // Periodic sweep (every 10s) instead of O(n) scan on every call.
68
79
  const recentlyProcessed = new Map<string, number>();
80
+ const DEDUP_TTL_MS = 60_000;
81
+ const SWEEP_INTERVAL_MS = 10_000;
82
+ let lastSweep = Date.now();
83
+
69
84
  function wasRecentlyProcessed(key: string): boolean {
70
85
  const now = Date.now();
71
- // Clean old entries
72
- for (const [k, ts] of recentlyProcessed) {
73
- if (now - ts > 60_000) recentlyProcessed.delete(k);
86
+ if (now - lastSweep > SWEEP_INTERVAL_MS) {
87
+ for (const [k, ts] of recentlyProcessed) {
88
+ if (now - ts > DEDUP_TTL_MS) recentlyProcessed.delete(k);
89
+ }
90
+ lastSweep = now;
74
91
  }
75
92
  if (recentlyProcessed.has(key)) return true;
76
93
  recentlyProcessed.set(key, now);
77
94
  return false;
78
95
  }
79
96
 
97
+ /** @internal — test-only; clears all in-memory dedup state. */
98
+ export function _resetForTesting(): void {
99
+ activeRuns.clear();
100
+ recentlyProcessed.clear();
101
+ profilesCache = null;
102
+ linearApiCache = null;
103
+ lastSweep = Date.now();
104
+ }
105
+
106
+ /** @internal — test-only; add an issue ID to the activeRuns set. */
107
+ export function _addActiveRunForTesting(issueId: string): void {
108
+ activeRuns.add(issueId);
109
+ }
110
+
111
+ /** @internal — test-only; pre-registers a key as recently processed. */
112
+ export function _markAsProcessedForTesting(key: string): void {
113
+ wasRecentlyProcessed(key);
114
+ }
115
+
80
116
  async function readJsonBody(req: IncomingMessage, maxBytes: number) {
81
117
  const chunks: Buffer[] = [];
82
118
  let total = 0;
@@ -101,7 +137,16 @@ async function readJsonBody(req: IncomingMessage, maxBytes: number) {
101
137
  });
102
138
  }
103
139
 
140
+ // ── Cached LinearApi instance (30s TTL) ────────────────────────────
141
+ let linearApiCache: { instance: LinearAgentApi; createdAt: number } | null = null;
142
+ const LINEAR_API_CACHE_TTL_MS = 30_000;
143
+
104
144
  function createLinearApi(api: OpenClawPluginApi): LinearAgentApi | null {
145
+ const now = Date.now();
146
+ if (linearApiCache && now - linearApiCache.createdAt < LINEAR_API_CACHE_TTL_MS) {
147
+ return linearApiCache.instance;
148
+ }
149
+
105
150
  const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
106
151
  const resolved = resolveLinearToken(pluginConfig);
107
152
 
@@ -110,12 +155,54 @@ function createLinearApi(api: OpenClawPluginApi): LinearAgentApi | null {
110
155
  const clientId = (pluginConfig?.clientId as string) ?? process.env.LINEAR_CLIENT_ID;
111
156
  const clientSecret = (pluginConfig?.clientSecret as string) ?? process.env.LINEAR_CLIENT_SECRET;
112
157
 
113
- return new LinearAgentApi(resolved.accessToken, {
158
+ const instance = new LinearAgentApi(resolved.accessToken, {
114
159
  refreshToken: resolved.refreshToken,
115
160
  expiresAt: resolved.expiresAt,
116
161
  clientId: clientId ?? undefined,
117
162
  clientSecret: clientSecret ?? undefined,
118
163
  });
164
+ linearApiCache = { instance, createdAt: now };
165
+ return instance;
166
+ }
167
+
168
+ // ── Comment wrapper that pre-registers comment ID for dedup ────────
169
+ // When we create a comment, Linear fires Comment.create webhook back to us.
170
+ // Register the comment ID immediately so the webhook handler skips it.
171
+ // The `opts` parameter posts as a named OpenClaw agent identity (e.g.
172
+ // createAsUser: "Mal" with avatar) — requires OAuth actor=app scope.
173
+ async function createCommentWithDedup(
174
+ linearApi: LinearAgentApi,
175
+ issueId: string,
176
+ body: string,
177
+ opts?: { createAsUser?: string; displayIconUrl?: string },
178
+ ): Promise<string> {
179
+ const commentId = await linearApi.createComment(issueId, body, opts);
180
+ wasRecentlyProcessed(`comment:${commentId}`);
181
+ return commentId;
182
+ }
183
+
184
+ /**
185
+ * Post a comment as agent identity with prefix fallback.
186
+ * With gql() partial-success fix, the catch only fires for real failures.
187
+ */
188
+ async function postAgentComment(
189
+ api: OpenClawPluginApi,
190
+ linearApi: LinearAgentApi,
191
+ issueId: string,
192
+ body: string,
193
+ label: string,
194
+ agentOpts?: { createAsUser: string; displayIconUrl: string },
195
+ ): Promise<void> {
196
+ if (!agentOpts) {
197
+ await createCommentWithDedup(linearApi, issueId, `**[${label}]** ${body}`);
198
+ return;
199
+ }
200
+ try {
201
+ await createCommentWithDedup(linearApi, issueId, body, agentOpts);
202
+ } catch (identityErr) {
203
+ api.logger.warn(`Agent identity comment failed: ${identityErr}`);
204
+ await createCommentWithDedup(linearApi, issueId, `**[${label}]** ${body}`);
205
+ }
119
206
  }
120
207
 
121
208
  function resolveAgentId(api: OpenClawPluginApi): string {
@@ -186,7 +273,17 @@ export async function handleLinearWebhook(
186
273
  return true;
187
274
  }
188
275
 
189
- // Dedup: skip if we already handled this session
276
+ // Guard: check activeRuns FIRST (O(1), no side effects).
277
+ // This catches sessions created by our own handlers (Comment dispatch,
278
+ // Issue triage, handleDispatch) which all set activeRuns BEFORE calling
279
+ // createSessionOnIssue(). Checking this first prevents the race condition
280
+ // where the webhook arrives before wasRecentlyProcessed is registered.
281
+ if (activeRuns.has(issue.id)) {
282
+ api.logger.info(`Agent already running for ${issue?.identifier ?? issue?.id} — skipping session ${session.id}`);
283
+ return true;
284
+ }
285
+
286
+ // Secondary dedup: skip if we already handled this exact session ID
190
287
  if (wasRecentlyProcessed(`session:${session.id}`)) {
191
288
  api.logger.info(`AgentSession ${session.id} already handled — skipping`);
192
289
  return true;
@@ -202,13 +299,7 @@ export async function handleLinearWebhook(
202
299
  const previousComments = payload.previousComments ?? [];
203
300
  const guidance = payload.guidance;
204
301
 
205
- api.logger.info(`AgentSession created: ${session.id} for issue ${issue?.identifier ?? issue?.id} (comments: ${previousComments.length}, guidance: ${guidance ? "yes" : "no"})`);
206
-
207
- // Guard: skip if an agent run is already active for this issue
208
- if (activeRuns.has(issue.id)) {
209
- api.logger.info(`Agent already running for ${issue?.identifier ?? issue?.id} — skipping session ${session.id}`);
210
- return true;
211
- }
302
+ api.logger.info(`AgentSession created: ${session.id} for issue ${issue?.identifier ?? issue?.id} (comments: ${previousComments.length}, guidance: ${guidance ? "yes" : "no"})`)
212
303
 
213
304
  // Extract the user's latest message from previousComments
214
305
  // The last comment is the most recent user message
@@ -298,31 +389,21 @@ export async function handleLinearWebhook(
298
389
  ? result.output
299
390
  : `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.`;
300
391
 
301
- // Post as comment
302
- const avatarUrl = defaultProfile?.[1]?.avatarUrl ?? profiles[agentId]?.avatarUrl;
303
- const brandingOpts = avatarUrl
304
- ? { createAsUser: label, displayIconUrl: avatarUrl }
305
- : undefined;
306
-
307
- try {
308
- if (brandingOpts) {
309
- await linearApi.createComment(issue.id, responseBody, brandingOpts);
310
- } else {
311
- await linearApi.createComment(issue.id, `**[${label}]** ${responseBody}`);
312
- }
313
- } catch (brandErr) {
314
- api.logger.warn(`Branded comment failed: ${brandErr}`);
315
- await linearApi.createComment(issue.id, `**[${label}]** ${responseBody}`);
316
- }
317
-
318
- // Emit response (closes session)
319
- const truncated = responseBody.length > 2000
320
- ? responseBody.slice(0, 2000) + "\u2026"
321
- : responseBody;
322
- await linearApi.emitActivity(session.id, {
392
+ // Emit response via session (preferred — avoids duplicate comment).
393
+ // Fall back to a regular comment only if emitActivity fails.
394
+ const labeledResponse = `**[${label}]** ${responseBody}`;
395
+ const emitted = await linearApi.emitActivity(session.id, {
323
396
  type: "response",
324
- body: truncated,
325
- }).catch(() => {});
397
+ body: labeledResponse,
398
+ }).then(() => true).catch(() => false);
399
+
400
+ if (!emitted) {
401
+ const avatarUrl = defaultProfile?.[1]?.avatarUrl ?? profiles[agentId]?.avatarUrl;
402
+ const agentOpts = avatarUrl
403
+ ? { createAsUser: label, displayIconUrl: avatarUrl }
404
+ : undefined;
405
+ await postAgentComment(api, linearApi, issue.id, responseBody, label, agentOpts);
406
+ }
326
407
 
327
408
  api.logger.info(`Posted agent response to ${enrichedIssue?.identifier ?? issue.id} (session ${session.id})`);
328
409
  } catch (err) {
@@ -476,29 +557,20 @@ export async function handleLinearWebhook(
476
557
  ? result.output
477
558
  : `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.`;
478
559
 
479
- const avatarUrl = defaultProfile?.[1]?.avatarUrl ?? profiles[agentId]?.avatarUrl;
480
- const brandingOpts = avatarUrl
481
- ? { createAsUser: label, displayIconUrl: avatarUrl }
482
- : undefined;
483
-
484
- try {
485
- if (brandingOpts) {
486
- await linearApi.createComment(issue.id, responseBody, brandingOpts);
487
- } else {
488
- await linearApi.createComment(issue.id, `**[${label}]** ${responseBody}`);
489
- }
490
- } catch (brandErr) {
491
- api.logger.warn(`Branded comment failed: ${brandErr}`);
492
- await linearApi.createComment(issue.id, `**[${label}]** ${responseBody}`);
493
- }
494
-
495
- const truncated = responseBody.length > 2000
496
- ? responseBody.slice(0, 2000) + "\u2026"
497
- : responseBody;
498
- await linearApi.emitActivity(session.id, {
560
+ // Emit response via session (preferred). Fall back to comment if it fails.
561
+ const labeledResponse = `**[${label}]** ${responseBody}`;
562
+ const emitted = await linearApi.emitActivity(session.id, {
499
563
  type: "response",
500
- body: truncated,
501
- }).catch(() => {});
564
+ body: labeledResponse,
565
+ }).then(() => true).catch(() => false);
566
+
567
+ if (!emitted) {
568
+ const avatarUrl = defaultProfile?.[1]?.avatarUrl ?? profiles[agentId]?.avatarUrl;
569
+ const agentOpts = avatarUrl
570
+ ? { createAsUser: label, displayIconUrl: avatarUrl }
571
+ : undefined;
572
+ await postAgentComment(api, linearApi, issue.id, responseBody, label, agentOpts);
573
+ }
502
574
 
503
575
  api.logger.info(`Posted follow-up response to ${enrichedIssue?.identifier ?? issue.id} (session ${session.id})`);
504
576
  } catch (err) {
@@ -553,6 +625,14 @@ export async function handleLinearWebhook(
553
625
  }
554
626
  } catch { /* proceed if viewerId check fails */ }
555
627
 
628
+ // Early guard: skip if an agent run is already active for this issue.
629
+ // Avoids wasted LLM intent classification (~2-5s) when result would
630
+ // be discarded anyway by activeRuns check in dispatchCommentToAgent().
631
+ if (activeRuns.has(issue.id)) {
632
+ api.logger.info(`Comment on ${issue.identifier ?? issue.id}: active run — skipping`);
633
+ return true;
634
+ }
635
+
556
636
  // Load agent profiles
557
637
  const profiles = loadAgentProfiles();
558
638
  const agentNames = Object.keys(profiles);
@@ -647,7 +727,7 @@ export async function handleLinearWebhook(
647
727
  void (async () => {
648
728
  try {
649
729
  await endPlanningSession(planSession.projectId, "approved", planStatePath);
650
- await linearApi.createComment(
730
+ await createCommentWithDedup(linearApi,
651
731
  planSession.rootIssueId,
652
732
  `## Plan Approved\n\nPlan for **${planSession.projectName}** has been approved. Dispatching to workers.`,
653
733
  );
@@ -680,7 +760,7 @@ export async function handleLinearWebhook(
680
760
  void (async () => {
681
761
  try {
682
762
  await endPlanningSession(planSession.projectId, "abandoned", planStatePath);
683
- await linearApi.createComment(
763
+ await createCommentWithDedup(linearApi,
684
764
  planSession.rootIssueId,
685
765
  `Planning mode ended for **${planSession.projectName}**. Session abandoned.`,
686
766
  );
@@ -725,6 +805,14 @@ export async function handleLinearWebhook(
725
805
  break;
726
806
  }
727
807
 
808
+ case "close_issue": {
809
+ const closeAgent = resolveAgentId(api);
810
+ api.logger.info(`Comment intent close_issue: closing ${issue.identifier ?? issue.id} via ${closeAgent}`);
811
+ void handleCloseIssue(api, linearApi, profiles, closeAgent, issue, comment, commentBody, commentor, pluginConfig)
812
+ .catch((err) => api.logger.error(`Close issue error: ${err}`));
813
+ break;
814
+ }
815
+
728
816
  case "general":
729
817
  default:
730
818
  api.logger.info(`Comment intent general: no action taken for ${issue.identifier ?? issue.id}`);
@@ -740,6 +828,16 @@ export async function handleLinearWebhook(
740
828
  res.end("ok");
741
829
 
742
830
  const issue = payload.data;
831
+
832
+ // Guard: check activeRuns FIRST (synchronous, O(1)) before any async work.
833
+ // Linear can send duplicate Issue.update webhooks <20ms apart for the same
834
+ // assignment change. Without this sync guard, both pass through the async
835
+ // getViewerId() call before either registers with wasRecentlyProcessed().
836
+ if (activeRuns.has(issue?.id)) {
837
+ api.logger.info(`Issue.update ${issue?.identifier ?? issue?.id}: active run — skipping`);
838
+ return true;
839
+ }
840
+
743
841
  const updatedFrom = payload.updatedFrom ?? {};
744
842
 
745
843
  // Check both assigneeId and delegateId — Linear uses delegateId for agent delegation
@@ -777,7 +875,8 @@ export async function handleLinearWebhook(
777
875
  const trigger = isDelegatedToUs ? "delegated" : "assigned";
778
876
  api.logger.info(`Issue ${trigger} to our app user (${viewerId}), executing pipeline`);
779
877
 
780
- // Dedup on assignment/delegation
878
+ // Secondary dedup: catch duplicate webhooks that both passed the activeRuns
879
+ // check before either could register (belt-and-suspenders with the sync guard).
781
880
  const dedupKey = `${trigger}:${issue.id}:${viewerId}`;
782
881
  if (wasRecentlyProcessed(dedupKey)) {
783
882
  api.logger.info(`${trigger} ${issue.id} -> ${viewerId} already processed — skipping`);
@@ -895,7 +994,7 @@ export async function handleLinearWebhook(
895
994
  if (agentSessionId) {
896
995
  await linearApi.emitActivity(agentSessionId, {
897
996
  type: "thought",
898
- body: `Triaging new issue ${enrichedIssue?.identifier ?? issue.id}...`,
997
+ body: `${label} is triaging new issue ${enrichedIssue?.identifier ?? issue.id}...`,
899
998
  }).catch(() => {});
900
999
  }
901
1000
 
@@ -951,6 +1050,10 @@ export async function handleLinearWebhook(
951
1050
  message,
952
1051
  timeoutMs: 3 * 60_000,
953
1052
  streaming: agentSessionId ? { linearApi, agentSessionId } : undefined,
1053
+ // Triage is strictly read-only: the agent can read/search the
1054
+ // codebase but all write-capable tools are denied via config
1055
+ // policy. The only artifacts are a Linear comment + issue updates.
1056
+ readOnly: true,
954
1057
  });
955
1058
 
956
1059
  const responseBody = result.success
@@ -999,30 +1102,26 @@ export async function handleLinearWebhook(
999
1102
  }
1000
1103
  }
1001
1104
 
1002
- // Post branded triage comment
1003
- const brandingOpts = avatarUrl
1004
- ? { createAsUser: label, displayIconUrl: avatarUrl }
1005
- : undefined;
1006
-
1007
- try {
1008
- if (brandingOpts) {
1009
- await linearApi.createComment(issue.id, commentBody, brandingOpts);
1010
- } else {
1011
- await linearApi.createComment(issue.id, `**[${label}]** ${commentBody}`);
1012
- }
1013
- } catch (brandErr) {
1014
- api.logger.warn(`Branded comment failed, falling back to prefix: ${brandErr}`);
1015
- await linearApi.createComment(issue.id, `**[${label}]** ${commentBody}`);
1016
- }
1017
-
1105
+ // When a session exists, prefer emitActivity (avoids duplicate comment).
1106
+ // Otherwise, post as a regular comment.
1018
1107
  if (agentSessionId) {
1019
- const truncated = commentBody.length > 2000
1020
- ? commentBody.slice(0, 2000) + "…"
1021
- : commentBody;
1022
- await linearApi.emitActivity(agentSessionId, {
1108
+ const labeledComment = `**[${label}]** ${commentBody}`;
1109
+ const emitted = await linearApi.emitActivity(agentSessionId, {
1023
1110
  type: "response",
1024
- body: truncated,
1025
- }).catch(() => {});
1111
+ body: labeledComment,
1112
+ }).then(() => true).catch(() => false);
1113
+
1114
+ if (!emitted) {
1115
+ const agentOpts = avatarUrl
1116
+ ? { createAsUser: label, displayIconUrl: avatarUrl }
1117
+ : undefined;
1118
+ await postAgentComment(api, linearApi, issue.id, commentBody, label, agentOpts);
1119
+ }
1120
+ } else {
1121
+ const agentOpts = avatarUrl
1122
+ ? { createAsUser: label, displayIconUrl: avatarUrl }
1123
+ : undefined;
1124
+ await postAgentComment(api, linearApi, issue.id, commentBody, label, agentOpts);
1026
1125
  }
1027
1126
 
1028
1127
  api.logger.info(`Triage complete for ${enrichedIssue?.identifier ?? issue.id}`);
@@ -1136,7 +1235,7 @@ async function dispatchCommentToAgent(
1136
1235
  if (agentSessionId) {
1137
1236
  await linearApi.emitActivity(agentSessionId, {
1138
1237
  type: "thought",
1139
- body: `Processing comment on ${issueRef}...`,
1238
+ body: `${label} is processing comment on ${issueRef}...`,
1140
1239
  }).catch(() => {});
1141
1240
  }
1142
1241
 
@@ -1156,40 +1255,201 @@ async function dispatchCommentToAgent(
1156
1255
  ? result.output
1157
1256
  : `Something went wrong while processing this. The system will retry automatically if possible.`;
1158
1257
 
1159
- // Post branded comment
1160
- const brandingOpts = avatarUrl
1161
- ? { createAsUser: label, displayIconUrl: avatarUrl }
1162
- : undefined;
1258
+ // When a session exists, prefer emitActivity (avoids duplicate comment).
1259
+ // Otherwise, post as a regular comment.
1260
+ if (agentSessionId) {
1261
+ const labeledResponse = `**[${label}]** ${responseBody}`;
1262
+ const emitted = await linearApi.emitActivity(agentSessionId, {
1263
+ type: "response",
1264
+ body: labeledResponse,
1265
+ }).then(() => true).catch(() => false);
1163
1266
 
1164
- try {
1165
- if (brandingOpts) {
1166
- await linearApi.createComment(issue.id, responseBody, brandingOpts);
1167
- } else {
1168
- await linearApi.createComment(issue.id, `**[${label}]** ${responseBody}`);
1267
+ if (!emitted) {
1268
+ const agentOpts = avatarUrl
1269
+ ? { createAsUser: label, displayIconUrl: avatarUrl }
1270
+ : undefined;
1271
+ await postAgentComment(api, linearApi, issue.id, responseBody, label, agentOpts);
1169
1272
  }
1170
- } catch (brandErr) {
1171
- api.logger.warn(`Branded comment failed, falling back to prefix: ${brandErr}`);
1172
- await linearApi.createComment(issue.id, `**[${label}]** ${responseBody}`);
1273
+ } else {
1274
+ const agentOpts = avatarUrl
1275
+ ? { createAsUser: label, displayIconUrl: avatarUrl }
1276
+ : undefined;
1277
+ await postAgentComment(api, linearApi, issue.id, responseBody, label, agentOpts);
1173
1278
  }
1174
1279
 
1175
- // Emit response (closes session)
1280
+ api.logger.info(`Posted ${agentId} response to ${issueRef}`);
1281
+ } catch (err) {
1282
+ api.logger.error(`dispatchCommentToAgent error: ${err}`);
1176
1283
  if (agentSessionId) {
1177
- const truncated = responseBody.length > 2000
1178
- ? responseBody.slice(0, 2000) + "…"
1179
- : responseBody;
1180
1284
  await linearApi.emitActivity(agentSessionId, {
1181
- type: "response",
1182
- body: truncated,
1285
+ type: "error",
1286
+ body: `Failed to process comment: ${String(err).slice(0, 500)}`,
1183
1287
  }).catch(() => {});
1184
1288
  }
1289
+ } finally {
1290
+ clearActiveSession(issue.id);
1291
+ activeRuns.delete(issue.id);
1292
+ }
1293
+ }
1185
1294
 
1186
- api.logger.info(`Posted ${agentId} response to ${issueRef}`);
1295
+ // ── Close issue handler ──────────────────────────────────────────
1296
+ //
1297
+ // Triggered by close_issue intent. Generates a closure report via agent,
1298
+ // transitions issue to completed state, and posts the report.
1299
+
1300
+ async function handleCloseIssue(
1301
+ api: OpenClawPluginApi,
1302
+ linearApi: LinearAgentApi,
1303
+ profiles: Record<string, AgentProfile>,
1304
+ agentId: string,
1305
+ issue: any,
1306
+ comment: any,
1307
+ commentBody: string,
1308
+ commentor: string,
1309
+ pluginConfig?: Record<string, unknown>,
1310
+ ): Promise<void> {
1311
+ const profile = profiles[agentId];
1312
+ const label = profile?.label ?? agentId;
1313
+ const avatarUrl = profile?.avatarUrl;
1314
+
1315
+ if (activeRuns.has(issue.id)) {
1316
+ api.logger.info(`handleCloseIssue: ${issue.identifier ?? issue.id} has active run — skipping`);
1317
+ return;
1318
+ }
1319
+
1320
+ // Fetch full issue details
1321
+ let enrichedIssue: any = issue;
1322
+ try {
1323
+ enrichedIssue = await linearApi.getIssueDetails(issue.id);
1187
1324
  } catch (err) {
1188
- api.logger.error(`dispatchCommentToAgent error: ${err}`);
1325
+ api.logger.warn(`Could not fetch issue details for close: ${err}`);
1326
+ }
1327
+
1328
+ const issueRef = enrichedIssue?.identifier ?? issue.identifier ?? issue.id;
1329
+ const teamId = enrichedIssue?.team?.id ?? issue.team?.id;
1330
+
1331
+ // Find completed state
1332
+ let completedStateId: string | null = null;
1333
+ if (teamId) {
1334
+ try {
1335
+ const states = await linearApi.getTeamStates(teamId);
1336
+ const completedState = states.find((s: any) => s.type === "completed");
1337
+ if (completedState) completedStateId = completedState.id;
1338
+ } catch (err) {
1339
+ api.logger.warn(`Could not fetch team states for close: ${err}`);
1340
+ }
1341
+ }
1342
+
1343
+ // Build closure report prompt
1344
+ const description = enrichedIssue?.description ?? issue?.description ?? "(no description)";
1345
+ const comments = enrichedIssue?.comments?.nodes ?? [];
1346
+ const commentSummary = comments
1347
+ .slice(-10)
1348
+ .map((c: any) => `**${c.user?.name ?? "Unknown"}**: ${(c.body ?? "").slice(0, 300)}`)
1349
+ .join("\n");
1350
+
1351
+ const message = [
1352
+ `You are writing a closure report for a Linear issue that is being marked as done.`,
1353
+ `Your text output will be posted as the closing comment on the issue.`,
1354
+ ``,
1355
+ `## Issue: ${issueRef} — ${enrichedIssue?.title ?? issue.title ?? "(untitled)"}`,
1356
+ `**Status:** ${enrichedIssue?.state?.name ?? "Unknown"} | **Assignee:** ${enrichedIssue?.assignee?.name ?? "Unassigned"}`,
1357
+ ``,
1358
+ `**Description:**`,
1359
+ description,
1360
+ commentSummary ? `\n**Comment history:**\n${commentSummary}` : "",
1361
+ `\n**${commentor} says (closure request):**\n> ${commentBody}`,
1362
+ ``,
1363
+ `Write a concise closure report with:`,
1364
+ `- **Summary**: What was done (1-2 sentences)`,
1365
+ `- **Resolution**: How it was resolved`,
1366
+ `- **Notes**: Any follow-up items or caveats (if applicable)`,
1367
+ ``,
1368
+ `Keep it brief and factual. Use markdown formatting.`,
1369
+ ].filter(Boolean).join("\n");
1370
+
1371
+ // Execute with session lifecycle
1372
+ activeRuns.add(issue.id);
1373
+ let agentSessionId: string | null = null;
1374
+
1375
+ try {
1376
+ const sessionResult = await linearApi.createSessionOnIssue(issue.id);
1377
+ agentSessionId = sessionResult.sessionId;
1378
+ if (agentSessionId) {
1379
+ wasRecentlyProcessed(`session:${agentSessionId}`);
1380
+ setActiveSession({
1381
+ agentSessionId,
1382
+ issueIdentifier: issueRef,
1383
+ issueId: issue.id,
1384
+ agentId,
1385
+ startedAt: Date.now(),
1386
+ });
1387
+ }
1388
+
1389
+ if (agentSessionId) {
1390
+ await linearApi.emitActivity(agentSessionId, {
1391
+ type: "thought",
1392
+ body: `${label} is preparing closure report for ${issueRef}...`,
1393
+ }).catch(() => {});
1394
+ }
1395
+
1396
+ // Run agent for closure report
1397
+ const { runAgent } = await import("../agent/agent.js");
1398
+ const result = await runAgent({
1399
+ api,
1400
+ agentId,
1401
+ sessionId: `linear-close-${agentId}-${Date.now()}`,
1402
+ message,
1403
+ timeoutMs: 2 * 60_000,
1404
+ readOnly: true,
1405
+ });
1406
+
1407
+ const closureReport = result.success
1408
+ ? result.output
1409
+ : "Issue closed. (Closure report generation failed.)";
1410
+
1411
+ const fullReport = `## Closure Report\n\n${closureReport}`;
1412
+
1413
+ // Transition issue to completed state
1414
+ if (completedStateId) {
1415
+ try {
1416
+ await linearApi.updateIssue(issue.id, { stateId: completedStateId });
1417
+ api.logger.info(`Closed issue ${issueRef} (state → completed)`);
1418
+ } catch (err) {
1419
+ api.logger.error(`Failed to transition issue ${issueRef} to completed: ${err}`);
1420
+ }
1421
+ } else {
1422
+ api.logger.warn(`No completed state found for ${issueRef} — posting report without state change`);
1423
+ }
1424
+
1425
+ // Post closure report via emitActivity-first pattern
1426
+ if (agentSessionId) {
1427
+ const labeledReport = `**[${label}]** ${fullReport}`;
1428
+ const emitted = await linearApi.emitActivity(agentSessionId, {
1429
+ type: "response",
1430
+ body: labeledReport,
1431
+ }).then(() => true).catch(() => false);
1432
+
1433
+ if (!emitted) {
1434
+ const agentOpts = avatarUrl
1435
+ ? { createAsUser: label, displayIconUrl: avatarUrl }
1436
+ : undefined;
1437
+ await postAgentComment(api, linearApi, issue.id, fullReport, label, agentOpts);
1438
+ }
1439
+ } else {
1440
+ const agentOpts = avatarUrl
1441
+ ? { createAsUser: label, displayIconUrl: avatarUrl }
1442
+ : undefined;
1443
+ await postAgentComment(api, linearApi, issue.id, fullReport, label, agentOpts);
1444
+ }
1445
+
1446
+ api.logger.info(`Posted closure report for ${issueRef}`);
1447
+ } catch (err) {
1448
+ api.logger.error(`handleCloseIssue error: ${err}`);
1189
1449
  if (agentSessionId) {
1190
1450
  await linearApi.emitActivity(agentSessionId, {
1191
1451
  type: "error",
1192
- body: `Failed to process comment: ${String(err).slice(0, 500)}`,
1452
+ body: `Failed to close issue: ${String(err).slice(0, 500)}`,
1193
1453
  }).catch(() => {});
1194
1454
  }
1195
1455
  } finally {
@@ -1226,7 +1486,7 @@ async function handleDispatch(
1226
1486
  const planState = await readPlanningState(planStatePath);
1227
1487
  if (isInPlanningMode(planState, planProjectId)) {
1228
1488
  api.logger.info(`dispatch: ${identifier} is in planning-mode project — skipping`);
1229
- await linearApi.createComment(
1489
+ await createCommentWithDedup(linearApi,
1230
1490
  issue.id,
1231
1491
  `**Can't dispatch yet** — this project is in planning mode.\n\n**To continue:** Comment on the planning issue with your requirements, then say **"finalize plan"** when ready.\n\n**To cancel planning:** Comment **"abandon"** on the planning issue.`,
1232
1492
  );
@@ -1249,7 +1509,7 @@ async function handleDispatch(
1249
1509
  if (!isStale && inMemory) {
1250
1510
  // Truly still running in this gateway process
1251
1511
  api.logger.info(`dispatch: ${identifier} actively running (status: ${existing.status}, age: ${Math.round(ageMs / 1000)}s) — skipping`);
1252
- await linearApi.createComment(
1512
+ await createCommentWithDedup(linearApi,
1253
1513
  issue.id,
1254
1514
  `**Already running** as **${existing.tier}** — status: **${existing.status}**, started ${Math.round(ageMs / 60_000)}m ago.\n\nWorktree: \`${existing.worktreePath}\`\n\n**Options:**\n- Check progress: \`/dispatch status ${identifier}\`\n- Force restart: \`/dispatch retry ${identifier}\` (only works when stuck)\n- Escalate: \`/dispatch escalate ${identifier} "reason"\``,
1255
1515
  );
@@ -1321,7 +1581,7 @@ async function handleDispatch(
1321
1581
  }
1322
1582
  } catch (err) {
1323
1583
  api.logger.error(`@dispatch: worktree creation failed: ${err}`);
1324
- await linearApi.createComment(
1584
+ await createCommentWithDedup(linearApi,
1325
1585
  issue.id,
1326
1586
  `**Dispatch failed** — couldn't create the worktree.\n\n> ${String(err).slice(0, 200)}\n\n**What to try:**\n- Check that the base repo exists\n- Re-assign this issue to try again\n- Check logs: \`journalctl --user -u openclaw-gateway --since "5 min ago"\``,
1327
1587
  );
@@ -1425,7 +1685,7 @@ async function handleDispatch(
1425
1685
  `- All dispatches: \`/dispatch list\``,
1426
1686
  ].join("\n");
1427
1687
 
1428
- await linearApi.createComment(issue.id, statusComment);
1688
+ await createCommentWithDedup(linearApi, issue.id, statusComment);
1429
1689
 
1430
1690
  if (agentSessionId) {
1431
1691
  await linearApi.emitActivity(agentSessionId, {