@calltelemetry/openclaw-linear 0.8.0 → 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.
@@ -11,10 +11,11 @@ import { assessTier } from "./tier-assess.js";
11
11
  import { createWorktree, createMultiWorktree, prepareWorkspace } from "../infra/codex-worktree.js";
12
12
  import { resolveRepos, isMultiRepo } from "../infra/multi-repo.js";
13
13
  import { ensureClawDir, writeManifest, writeDispatchMemory, resolveOrchestratorWorkspace } from "./artifacts.js";
14
- import { readPlanningState, isInPlanningMode, getPlanningSession } from "./planning-state.js";
15
- import { initiatePlanningSession, handlePlannerTurn } from "./planner.js";
14
+ import { readPlanningState, isInPlanningMode, getPlanningSession, endPlanningSession } from "./planning-state.js";
15
+ import { initiatePlanningSession, handlePlannerTurn, runPlanAudit } from "./planner.js";
16
16
  import { startProjectDispatch } from "./dag-dispatch.js";
17
17
  import { emitDiagnostic } from "../infra/observability.js";
18
+ import { classifyIntent } from "./intent-classify.js";
18
19
 
19
20
  // ── Agent profiles (loaded from config, no hardcoded names) ───────
20
21
  interface AgentProfile {
@@ -154,158 +155,14 @@ export async function handleLinearWebhook(
154
155
  emitDiagnostic(api, { event: "webhook_received", webhookType: payload.type, webhookAction: payload.action });
155
156
 
156
157
 
157
- // ── 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.
158
162
  if (payload.type === "AppUserNotification") {
163
+ api.logger.info(`AppUserNotification ignored (duplicate of workspace webhook): ${payload.notification?.type} appUserId=${payload.appUserId}`);
159
164
  res.statusCode = 200;
160
165
  res.end("ok");
161
-
162
- const notification = payload.notification;
163
- const notifType = notification?.type;
164
- api.logger.info(`AppUserNotification: ${notifType} appUserId=${payload.appUserId}`);
165
-
166
- const issue = notification?.issue;
167
- const comment = notification?.comment ?? notification?.parentComment;
168
-
169
- if (!issue?.id) {
170
- api.logger.error("AppUserNotification missing issue data");
171
- return true;
172
- }
173
-
174
- const linearApi = createLinearApi(api);
175
- if (!linearApi) {
176
- api.logger.error("No Linear access token — cannot process agent notification");
177
- return true;
178
- }
179
-
180
- const agentId = resolveAgentId(api);
181
-
182
- // Fetch full issue details
183
- let enrichedIssue: any = issue;
184
- try {
185
- enrichedIssue = await linearApi.getIssueDetails(issue.id);
186
- } catch (err) {
187
- api.logger.warn(`Could not fetch issue details: ${err}`);
188
- }
189
-
190
- const description = enrichedIssue?.description ?? issue?.description ?? "(no description)";
191
- const comments = enrichedIssue?.comments?.nodes ?? [];
192
- const commentSummary = comments
193
- .slice(-5)
194
- .map((c: any) => ` - **${c.user?.name ?? "Unknown"}**: ${c.body?.slice(0, 200)}`)
195
- .join("\n");
196
-
197
- const notifIssueRef = enrichedIssue?.identifier ?? issue.id;
198
- const message = [
199
- `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).`,
200
- ``,
201
- `**Tool access:**`,
202
- `- \`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.`,
203
- `- \`code_run\`: Dispatch coding work to a worker. Workers return text — they cannot access linearis.`,
204
- `- Standard tools: exec, read, edit, write, web_search, etc.`,
205
- ``,
206
- `**Your role:** Dispatcher. For work requests, use \`code_run\`. You do NOT update issue status — the audit system handles lifecycle.`,
207
- ``,
208
- `## Issue: ${notifIssueRef} — ${enrichedIssue?.title ?? issue.title ?? "(untitled)"}`,
209
- `**Status:** ${enrichedIssue?.state?.name ?? "Unknown"} | **Assignee:** ${enrichedIssue?.assignee?.name ?? "Unassigned"}`,
210
- ``,
211
- `**Description:**`,
212
- description,
213
- commentSummary ? `\n**Recent comments:**\n${commentSummary}` : "",
214
- comment?.body ? `\n**Triggering comment:**\n> ${comment.body}` : "",
215
- ``,
216
- `Respond concisely. For work requests, dispatch via \`code_run\` and summarize the result.`,
217
- ].filter(Boolean).join("\n");
218
-
219
- // Dispatch agent with session lifecycle (non-blocking)
220
- void (async () => {
221
- const profiles = loadAgentProfiles();
222
- const defaultProfile = Object.entries(profiles).find(([, p]) => p.isDefault);
223
- const label = defaultProfile?.[1]?.label ?? profiles[agentId]?.label ?? agentId;
224
- const avatarUrl = defaultProfile?.[1]?.avatarUrl ?? profiles[agentId]?.avatarUrl;
225
- let agentSessionId: string | null = null;
226
-
227
- try {
228
- // 1. Create agent session (non-fatal)
229
- const sessionResult = await linearApi.createSessionOnIssue(issue.id);
230
- agentSessionId = sessionResult.sessionId;
231
- if (agentSessionId) {
232
- api.logger.info(`Created agent session ${agentSessionId} for notification`);
233
- setActiveSession({
234
- agentSessionId,
235
- issueIdentifier: enrichedIssue?.identifier ?? issue.id,
236
- issueId: issue.id,
237
- agentId,
238
- startedAt: Date.now(),
239
- });
240
- } else {
241
- api.logger.warn(`Could not create agent session for notification: ${sessionResult.error ?? "unknown"}`);
242
- }
243
-
244
- // 2. Emit thought
245
- if (agentSessionId) {
246
- await linearApi.emitActivity(agentSessionId, {
247
- type: "thought",
248
- body: `Reviewing ${enrichedIssue?.identifier ?? issue.id}...`,
249
- }).catch(() => {});
250
- }
251
-
252
- // 3. Run agent with streaming
253
- const sessionId = `linear-notif-${notification?.type ?? "unknown"}-${Date.now()}`;
254
- const { runAgent } = await import("../agent/agent.js");
255
- const result = await runAgent({
256
- api,
257
- agentId,
258
- sessionId,
259
- message,
260
- timeoutMs: 3 * 60_000,
261
- streaming: agentSessionId ? { linearApi, agentSessionId } : undefined,
262
- });
263
-
264
- const responseBody = result.success
265
- ? result.output
266
- : `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.`;
267
-
268
- // 5. Post branded comment (fallback to prefix)
269
- const brandingOpts = avatarUrl
270
- ? { createAsUser: label, displayIconUrl: avatarUrl }
271
- : undefined;
272
-
273
- try {
274
- if (brandingOpts) {
275
- await linearApi.createComment(issue.id, responseBody, brandingOpts);
276
- } else {
277
- await linearApi.createComment(issue.id, `**[${label}]** ${responseBody}`);
278
- }
279
- } catch (brandErr) {
280
- api.logger.warn(`Branded comment failed, falling back to prefix: ${brandErr}`);
281
- await linearApi.createComment(issue.id, `**[${label}]** ${responseBody}`);
282
- }
283
-
284
- // 6. Emit response (closes session)
285
- if (agentSessionId) {
286
- const truncated = responseBody.length > 2000
287
- ? responseBody.slice(0, 2000) + "…"
288
- : responseBody;
289
- await linearApi.emitActivity(agentSessionId, {
290
- type: "response",
291
- body: truncated,
292
- }).catch(() => {});
293
- }
294
-
295
- api.logger.info(`Posted agent response to ${enrichedIssue?.identifier ?? issue.id}`);
296
- } catch (err) {
297
- api.logger.error(`AppUserNotification handler error: ${err}`);
298
- if (agentSessionId) {
299
- await linearApi.emitActivity(agentSessionId, {
300
- type: "error",
301
- body: `Failed to process notification: ${String(err).slice(0, 500)}`,
302
- }).catch(() => {});
303
- }
304
- } finally {
305
- clearActiveSession(issue.id);
306
- }
307
- })();
308
-
309
166
  return true;
310
167
  }
311
168
 
@@ -659,7 +516,7 @@ export async function handleLinearWebhook(
659
516
  return true;
660
517
  }
661
518
 
662
- // ── Comment.create — @mention routing to agents ─────────────────
519
+ // ── Comment.create — intent-based routing ────────────────────────
663
520
  if (payload.type === "Comment" && payload.action === "create") {
664
521
  res.statusCode = 200;
665
522
  res.end("ok");
@@ -668,265 +525,211 @@ export async function handleLinearWebhook(
668
525
  const commentBody = comment?.body ?? "";
669
526
  const commentor = comment?.user?.name ?? "Unknown";
670
527
  const issue = comment?.issue ?? payload.issue;
671
-
672
- // ── Planning mode intercept ──────────────────────────────────
673
528
  const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
674
529
 
675
- if (issue?.id) {
676
- const linearApiForPlanning = createLinearApi(api);
677
- if (linearApiForPlanning) {
678
- try {
679
- const enriched = await linearApiForPlanning.getIssueDetails(issue.id);
680
- const projectId = enriched?.project?.id;
681
- const planStatePath = pluginConfig?.planningStatePath as string | undefined;
682
-
683
- if (projectId) {
684
- const planState = await readPlanningState(planStatePath);
685
-
686
- // Check if this is a plan initiation request
687
- const isPlanRequest = /\b(plan|planning)\s+(this\s+)(project|out)\b/i.test(commentBody) || /\bplan\s+this\s+out\b/i.test(commentBody);
688
- if (isPlanRequest && !isInPlanningMode(planState, projectId)) {
689
- api.logger.info(`Planning: initiation requested on ${issue.identifier ?? issue.id}`);
690
- void initiatePlanningSession(
691
- { api, linearApi: linearApiForPlanning, pluginConfig },
692
- projectId,
693
- { id: issue.id, identifier: enriched.identifier, title: enriched.title, team: enriched.team },
694
- ).catch((err) => api.logger.error(`Planning initiation error: ${err}`));
695
- return true;
696
- }
697
-
698
- // Route to planner if project is in planning mode
699
- if (isInPlanningMode(planState, projectId)) {
700
- const session = getPlanningSession(planState, projectId);
701
- if (!session) {
702
- api.logger.error(`Planning: project ${projectId} in planning mode but no session found — state may be corrupted`);
703
- await linearApiForPlanning.createComment(
704
- issue.id,
705
- `**Planning mode is active** for this project, but the session state appears corrupted.\n\n**To fix:** Say **"abandon planning"** to exit planning mode, then start fresh with **"plan this project"**.`,
706
- ).catch(() => {});
707
- return true;
708
- }
709
- if (comment?.id && !wasRecentlyProcessed(`plan-comment:${comment.id}`)) {
710
- api.logger.info(`Planning: routing comment to planner for ${session.projectName}`);
711
- void handlePlannerTurn(
712
- { api, linearApi: linearApiForPlanning, pluginConfig },
713
- session,
714
- { issueId: issue.id, commentBody, commentorName: commentor },
715
- {
716
- onApproved: (approvedProjectId) => {
717
- const notify = createNotifierFromConfig(pluginConfig, api.runtime, api);
718
- const hookCtx: HookContext = {
719
- api,
720
- linearApi: linearApiForPlanning,
721
- notify,
722
- pluginConfig,
723
- configPath: pluginConfig?.dispatchStatePath as string | undefined,
724
- };
725
- void startProjectDispatch(hookCtx, approvedProjectId)
726
- .catch((err) => api.logger.error(`Project dispatch start error: ${err}`));
727
- },
728
- },
729
- ).catch((err) => api.logger.error(`Planner turn error: ${err}`));
730
- }
731
- return true;
732
- }
733
- }
734
- } catch (err) {
735
- api.logger.warn(`Planning mode check failed: ${err}`);
736
- }
737
- }
738
- }
739
-
740
- // Load agent profiles and build mention pattern dynamically.
741
- // Default agent (app mentions) is handled by AgentSessionEvent — never here.
742
- const profiles = loadAgentProfiles();
743
- const mentionPattern = buildMentionPattern(profiles);
744
- if (!mentionPattern) {
745
- api.logger.info("Comment webhook: no sub-agent profiles configured, ignoring");
746
- return true;
747
- }
748
-
749
- const matches = commentBody.match(mentionPattern);
750
- if (!matches || matches.length === 0) {
751
- api.logger.info("Comment webhook: no sub-agent mentions found, ignoring");
752
- return true;
753
- }
754
-
755
- const alias = matches[0].replace("@", "");
756
- const resolved = resolveAgentFromAlias(alias, profiles);
757
- if (!resolved) {
758
- api.logger.info(`Comment webhook: alias "${alias}" not found in profiles, ignoring`);
759
- return true;
760
- }
761
-
762
- const mentionedAgent = resolved.agentId;
763
-
764
530
  if (!issue?.id) {
765
531
  api.logger.error("Comment webhook: missing issue data");
766
532
  return true;
767
533
  }
768
534
 
769
- const linearApi = createLinearApi(api);
770
- if (!linearApi) {
771
- api.logger.error("No Linear access token — cannot process comment mention");
772
- return true;
773
- }
774
-
775
- // Dedup on comment ID — prevent processing same comment twice
535
+ // Dedup on comment ID
776
536
  if (comment?.id && wasRecentlyProcessed(`comment:${comment.id}`)) {
777
537
  api.logger.info(`Comment ${comment.id} already processed — skipping`);
778
538
  return true;
779
539
  }
780
540
 
781
- api.logger.info(`Comment mention: @${mentionedAgent} on ${issue.identifier ?? issue.id} by ${commentor}`);
782
-
783
- // Guard: skip if an agent run is already active for this issue
784
- // (prevents dual-dispatch when both Comment.create and AgentSessionEvent fire)
785
- if (activeRuns.has(issue.id)) {
786
- api.logger.info(`Comment mention: agent already running for ${issue.identifier ?? issue.id} — skipping`);
541
+ const linearApi = createLinearApi(api);
542
+ if (!linearApi) {
543
+ api.logger.error("No Linear access token cannot process comment");
787
544
  return true;
788
545
  }
789
- activeRuns.add(issue.id);
790
546
 
791
- // React with eyes to acknowledge the comment
792
- if (comment?.id) {
793
- linearApi.createReaction(comment.id, "eyes").catch(() => {});
547
+ // Skip bot's own comments
548
+ try {
549
+ const viewerId = await linearApi.getViewerId();
550
+ if (viewerId && comment?.user?.id === viewerId) {
551
+ api.logger.info(`Comment webhook: skipping our own comment on ${issue.identifier ?? issue.id}`);
552
+ return true;
553
+ }
554
+ } catch { /* proceed if viewerId check fails */ }
555
+
556
+ // Load agent profiles
557
+ const profiles = loadAgentProfiles();
558
+ const agentNames = Object.keys(profiles);
559
+
560
+ // ── @mention fast path — skip classifier ────────────────────
561
+ const mentionPattern = buildMentionPattern(profiles);
562
+ const mentionMatches = mentionPattern ? commentBody.match(mentionPattern) : null;
563
+ if (mentionMatches && mentionMatches.length > 0) {
564
+ const alias = mentionMatches[0].replace("@", "");
565
+ const resolved = resolveAgentFromAlias(alias, profiles);
566
+ if (resolved) {
567
+ api.logger.info(`Comment @mention fast path: @${resolved.agentId} on ${issue.identifier ?? issue.id}`);
568
+ void dispatchCommentToAgent(api, linearApi, profiles, resolved.agentId, issue, comment, commentBody, commentor, pluginConfig)
569
+ .catch((err) => api.logger.error(`Comment dispatch error: ${err}`));
570
+ return true;
571
+ }
794
572
  }
795
573
 
796
- // Fetch full issue details from Linear API for richer context
797
- let enrichedIssue = issue;
798
- let recentComments = "";
574
+ // ── Intent classification ─────────────────────────────────────
575
+ // Fetch issue details for context
576
+ let enrichedIssue: any = issue;
799
577
  try {
800
- const full = await linearApi.getIssueDetails(issue.id);
801
- enrichedIssue = { ...issue, ...full };
802
- // Include last few comments for context (excluding the triggering comment)
803
- const comments = full.comments?.nodes ?? [];
804
- const relevant = comments
805
- .filter((c: any) => c.body !== commentBody)
806
- .slice(-3)
807
- .map((c: any) => ` - **${c.user?.name ?? "Unknown"}**: ${c.body.slice(0, 200)}`)
808
- .join("\n");
809
- if (relevant) recentComments = `\n**Recent Comments:**\n${relevant}\n`;
578
+ enrichedIssue = await linearApi.getIssueDetails(issue.id);
810
579
  } catch (err) {
811
580
  api.logger.warn(`Could not fetch issue details: ${err}`);
812
581
  }
813
582
 
814
- const priority = ["No Priority", "Urgent (P1)", "High (P2)", "Medium (P3)", "Low (P4)"][enrichedIssue.priority] ?? "Unknown";
815
- const labels = enrichedIssue.labels?.nodes?.map((l: any) => l.name).join(", ") || "None";
816
- const state = enrichedIssue.state?.name ?? "Unknown";
817
- const assignee = enrichedIssue.assignee?.name ?? "Unassigned";
818
-
819
- const taskMessage = [
820
- `You are an orchestrator responding to a Linear issue comment. Your text output will be automatically posted as a comment on the issue (do NOT post a comment yourself — the handler does it).`,
821
- ``,
822
- `**Tool access:**`,
823
- `- \`linearis\` CLI: READ ONLY. You can read issues (\`linearis issues read ${enrichedIssue.identifier ?? "API-XXX"}\`), list issues (\`linearis issues list\`), and search (\`linearis issues search "..."\`). Do NOT use linearis to update, close, comment, or modify issues.`,
824
- `- \`code_run\`: Dispatch coding work to a worker. Workers return text — they cannot access linearis.`,
825
- `- Standard tools: exec, read, edit, write, web_search, etc.`,
826
- ``,
827
- `**Your role:** You are the dispatcher. For any coding or implementation work, use \`code_run\` to dispatch it. Workers return text output. You summarize results. You do NOT update issue status or post linearis comments — the audit system handles lifecycle transitions.`,
828
- ``,
829
- `**Issue:** ${enrichedIssue.identifier ?? enrichedIssue.id} — ${enrichedIssue.title ?? "(untitled)"}`,
830
- `**Status:** ${state} | **Priority:** ${priority} | **Assignee:** ${assignee} | **Labels:** ${labels}`,
831
- `**URL:** ${enrichedIssue.url ?? "N/A"}`,
832
- ``,
833
- enrichedIssue.description ? `**Description:**\n${enrichedIssue.description}\n` : "",
834
- recentComments,
835
- `**${commentor} wrote:**`,
836
- `> ${commentBody}`,
837
- ``,
838
- `Respond to their message. Be concise and direct. For work requests, dispatch via \`code_run\` and summarize the result.`,
839
- ].filter(Boolean).join("\n");
840
-
841
- // Dispatch to agent with full session lifecycle (non-blocking)
842
- void (async () => {
843
- const label = resolved.label;
844
- const profile = profiles[mentionedAgent];
845
- let agentSessionId: string | null = null;
583
+ const projectId = enrichedIssue?.project?.id;
584
+ const planStatePath = pluginConfig?.planningStatePath as string | undefined;
846
585
 
586
+ // Determine planning state
587
+ let isPlanning = false;
588
+ let planSession: any = null;
589
+ if (projectId) {
847
590
  try {
848
- // 1. Create agent session (non-fatal if fails)
849
- const sessionResult = await linearApi.createSessionOnIssue(issue.id);
850
- agentSessionId = sessionResult.sessionId;
851
- if (agentSessionId) {
852
- api.logger.info(`Created agent session ${agentSessionId} for @${mentionedAgent}`);
853
- // Register active session so code_run can resolve it automatically
854
- setActiveSession({
855
- agentSessionId,
856
- issueIdentifier: enrichedIssue.identifier ?? issue.id,
857
- issueId: issue.id,
858
- agentId: mentionedAgent,
859
- startedAt: Date.now(),
860
- });
861
- } else {
862
- api.logger.warn(`Could not create agent session for @${mentionedAgent}: ${sessionResult.error ?? "unknown"} — falling back to flat comment`);
863
- }
864
-
865
- // 2. Emit thought
866
- if (agentSessionId) {
867
- await linearApi.emitActivity(agentSessionId, {
868
- type: "thought",
869
- body: `Reviewing ${enrichedIssue.identifier ?? issue.id}...`,
870
- }).catch(() => {});
591
+ const planState = await readPlanningState(planStatePath);
592
+ isPlanning = isInPlanningMode(planState, projectId);
593
+ if (isPlanning) {
594
+ planSession = getPlanningSession(planState, projectId);
871
595
  }
596
+ } catch { /* proceed without planning context */ }
597
+ }
872
598
 
873
- // 3. Run agent subprocess with streaming
874
- const sessionId = `linear-comment-${comment.id ?? Date.now()}`;
875
- const { runAgent } = await import("../agent/agent.js");
876
- const result = await runAgent({
877
- api,
878
- agentId: mentionedAgent,
879
- sessionId,
880
- message: taskMessage,
881
- timeoutMs: 3 * 60_000,
882
- streaming: agentSessionId ? { linearApi, agentSessionId } : undefined,
883
- });
599
+ const intentResult = await classifyIntent(api, {
600
+ commentBody,
601
+ issueTitle: enrichedIssue?.title ?? "(untitled)",
602
+ issueStatus: enrichedIssue?.state?.name,
603
+ isPlanning,
604
+ agentNames,
605
+ hasProject: !!projectId,
606
+ }, pluginConfig);
884
607
 
885
- const responseBody = result.success
886
- ? result.output
887
- : `Something went wrong while processing this. If this keeps happening, run \`openclaw openclaw-linear doctor\` to check for issues.`;
608
+ api.logger.info(`Comment intent: ${intentResult.intent}${intentResult.agentId ? ` (agent: ${intentResult.agentId})` : ""} — ${intentResult.reasoning} (fallback: ${intentResult.fromFallback})`);
888
609
 
889
- // 5. Post branded comment (fall back to [Label] prefix if branding fails)
890
- const brandingOpts = profile?.avatarUrl
891
- ? { createAsUser: label, displayIconUrl: profile.avatarUrl }
892
- : undefined;
610
+ // ── Route by intent ────────────────────────────────────────────
893
611
 
894
- try {
895
- if (brandingOpts) {
896
- await linearApi.createComment(issue.id, responseBody, brandingOpts);
897
- } else {
898
- await linearApi.createComment(issue.id, `**[${label}]** ${responseBody}`);
612
+ switch (intentResult.intent) {
613
+ case "plan_start": {
614
+ if (!projectId) {
615
+ api.logger.info("Comment intent plan_start but no project — ignoring");
616
+ break;
617
+ }
618
+ if (isPlanning) {
619
+ api.logger.info("Comment intent plan_start but already planning — treating as plan_continue");
620
+ // Fall through to plan_continue
621
+ if (planSession) {
622
+ void handlePlannerTurn(
623
+ { api, linearApi, pluginConfig },
624
+ planSession,
625
+ { issueId: issue.id, commentBody, commentorName: commentor },
626
+ ).catch((err) => api.logger.error(`Planner turn error: ${err}`));
899
627
  }
900
- } catch (brandErr) {
901
- api.logger.warn(`Branded comment failed, falling back to prefix: ${brandErr}`);
902
- await linearApi.createComment(issue.id, `**[${label}]** ${responseBody}`);
628
+ break;
903
629
  }
630
+ api.logger.info(`Planning: initiation requested on ${issue.identifier ?? issue.id}`);
631
+ void initiatePlanningSession(
632
+ { api, linearApi, pluginConfig },
633
+ projectId,
634
+ { id: issue.id, identifier: enrichedIssue.identifier, title: enrichedIssue.title, team: enrichedIssue.team },
635
+ ).catch((err) => api.logger.error(`Planning initiation error: ${err}`));
636
+ break;
637
+ }
904
638
 
905
- // 6. Emit response activity (closes the session)
906
- if (agentSessionId) {
907
- const truncated = responseBody.length > 2000
908
- ? responseBody.slice(0, 2000) + "\u2026"
909
- : responseBody;
910
- await linearApi.emitActivity(agentSessionId, {
911
- type: "response",
912
- body: truncated,
913
- }).catch(() => {});
639
+ case "plan_finalize": {
640
+ if (!isPlanning || !planSession) {
641
+ api.logger.info("Comment intent plan_finalize but not in planning mode — ignoring");
642
+ break;
914
643
  }
644
+ if (planSession.status === "plan_review") {
645
+ // Already passed audit + cross-model review — approve directly
646
+ api.logger.info(`Planning: approving plan for ${planSession.projectName} (from plan_review)`);
647
+ void (async () => {
648
+ try {
649
+ await endPlanningSession(planSession.projectId, "approved", planStatePath);
650
+ await linearApi.createComment(
651
+ planSession.rootIssueId,
652
+ `## Plan Approved\n\nPlan for **${planSession.projectName}** has been approved. Dispatching to workers.`,
653
+ );
654
+ // Trigger DAG dispatch
655
+ const notify = createNotifierFromConfig(pluginConfig, api.runtime, api);
656
+ const hookCtx: HookContext = {
657
+ api, linearApi, notify, pluginConfig,
658
+ configPath: pluginConfig?.dispatchStatePath as string | undefined,
659
+ };
660
+ await startProjectDispatch(hookCtx, planSession.projectId);
661
+ } catch (err) {
662
+ api.logger.error(`Plan approval error: ${err}`);
663
+ }
664
+ })();
665
+ } else {
666
+ // Still interviewing — run audit (which transitions to plan_review)
667
+ void runPlanAudit(
668
+ { api, linearApi, pluginConfig },
669
+ planSession,
670
+ ).catch((err) => api.logger.error(`Plan audit error: ${err}`));
671
+ }
672
+ break;
673
+ }
915
674
 
916
- api.logger.info(`Posted @${mentionedAgent} response to ${issue.identifier ?? issue.id}`);
917
- } catch (err) {
918
- api.logger.error(`Comment mention handler error: ${err}`);
919
- if (agentSessionId) {
920
- await linearApi.emitActivity(agentSessionId, {
921
- type: "error",
922
- body: `Failed to process mention: ${String(err).slice(0, 500)}`,
923
- }).catch(() => {});
675
+ case "plan_abandon": {
676
+ if (!isPlanning || !planSession) {
677
+ api.logger.info("Comment intent plan_abandon but not in planning mode — ignoring");
678
+ break;
924
679
  }
925
- } finally {
926
- clearActiveSession(issue.id);
927
- activeRuns.delete(issue.id);
680
+ void (async () => {
681
+ try {
682
+ await endPlanningSession(planSession.projectId, "abandoned", planStatePath);
683
+ await linearApi.createComment(
684
+ planSession.rootIssueId,
685
+ `Planning mode ended for **${planSession.projectName}**. Session abandoned.`,
686
+ );
687
+ api.logger.info(`Planning: session abandoned for ${planSession.projectName}`);
688
+ } catch (err) {
689
+ api.logger.error(`Plan abandon error: ${err}`);
690
+ }
691
+ })();
692
+ break;
693
+ }
694
+
695
+ case "plan_continue": {
696
+ if (!isPlanning || !planSession) {
697
+ // Not in planning mode — treat as general
698
+ api.logger.info("Comment intent plan_continue but not in planning mode — dispatching to default agent");
699
+ void dispatchCommentToAgent(api, linearApi, profiles, resolveAgentId(api), issue, comment, commentBody, commentor, pluginConfig)
700
+ .catch((err) => api.logger.error(`Comment dispatch error: ${err}`));
701
+ break;
702
+ }
703
+ void handlePlannerTurn(
704
+ { api, linearApi, pluginConfig },
705
+ planSession,
706
+ { issueId: issue.id, commentBody, commentorName: commentor },
707
+ ).catch((err) => api.logger.error(`Planner turn error: ${err}`));
708
+ break;
928
709
  }
929
- })();
710
+
711
+ case "ask_agent": {
712
+ const targetAgent = intentResult.agentId ?? resolveAgentId(api);
713
+ api.logger.info(`Comment intent ask_agent: routing to ${targetAgent}`);
714
+ void dispatchCommentToAgent(api, linearApi, profiles, targetAgent, issue, comment, commentBody, commentor, pluginConfig)
715
+ .catch((err) => api.logger.error(`Comment dispatch error: ${err}`));
716
+ break;
717
+ }
718
+
719
+ case "request_work":
720
+ case "question": {
721
+ const defaultAgent = resolveAgentId(api);
722
+ api.logger.info(`Comment intent ${intentResult.intent}: routing to default agent ${defaultAgent}`);
723
+ void dispatchCommentToAgent(api, linearApi, profiles, defaultAgent, issue, comment, commentBody, commentor, pluginConfig)
724
+ .catch((err) => api.logger.error(`Comment dispatch error: ${err}`));
725
+ break;
726
+ }
727
+
728
+ case "general":
729
+ default:
730
+ api.logger.info(`Comment intent general: no action taken for ${issue.identifier ?? issue.id}`);
731
+ break;
732
+ }
930
733
 
931
734
  return true;
932
735
  }
@@ -1009,6 +812,7 @@ export async function handleLinearWebhook(
1009
812
 
1010
813
  api.logger.info(`Issue.create: ${issue.identifier ?? issue.id} — ${issue.title ?? "(untitled)"}`);
1011
814
 
815
+ const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
1012
816
  const linearApi = createLinearApi(api);
1013
817
  if (!linearApi) {
1014
818
  api.logger.error("No Linear access token — cannot triage new issue");
@@ -1017,6 +821,14 @@ export async function handleLinearWebhook(
1017
821
 
1018
822
  const agentId = resolveAgentId(api);
1019
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
+
1020
832
  // Dispatch triage (non-blocking)
1021
833
  void (async () => {
1022
834
  const profiles = loadAgentProfiles();
@@ -1224,6 +1036,7 @@ export async function handleLinearWebhook(
1224
1036
  }
1225
1037
  } finally {
1226
1038
  clearActiveSession(issue.id);
1039
+ activeRuns.delete(issue.id);
1227
1040
  }
1228
1041
  })();
1229
1042
 
@@ -1237,6 +1050,154 @@ export async function handleLinearWebhook(
1237
1050
  return true;
1238
1051
  }
1239
1052
 
1053
+ // ── Comment dispatch helper ───────────────────────────────────────
1054
+ //
1055
+ // Dispatches a comment to a specific agent. Used by intent-based routing
1056
+ // and @mention fast path.
1057
+
1058
+ async function dispatchCommentToAgent(
1059
+ api: OpenClawPluginApi,
1060
+ linearApi: LinearAgentApi,
1061
+ profiles: Record<string, AgentProfile>,
1062
+ agentId: string,
1063
+ issue: any,
1064
+ comment: any,
1065
+ commentBody: string,
1066
+ commentor: string,
1067
+ pluginConfig?: Record<string, unknown>,
1068
+ ): Promise<void> {
1069
+ const profile = profiles[agentId];
1070
+ const label = profile?.label ?? agentId;
1071
+ const avatarUrl = profile?.avatarUrl;
1072
+
1073
+ // Guard: prevent concurrent runs on same issue
1074
+ if (activeRuns.has(issue.id)) {
1075
+ api.logger.info(`dispatchCommentToAgent: ${issue.identifier ?? issue.id} has active run — skipping`);
1076
+ return;
1077
+ }
1078
+
1079
+ // Fetch full issue details
1080
+ let enrichedIssue: any = issue;
1081
+ try {
1082
+ enrichedIssue = await linearApi.getIssueDetails(issue.id);
1083
+ } catch (err) {
1084
+ api.logger.warn(`Could not fetch issue details: ${err}`);
1085
+ }
1086
+
1087
+ const description = enrichedIssue?.description ?? issue?.description ?? "(no description)";
1088
+ const comments = enrichedIssue?.comments?.nodes ?? [];
1089
+ const commentSummary = comments
1090
+ .slice(-5)
1091
+ .map((c: any) => `**${c.user?.name ?? "Unknown"}**: ${(c.body ?? "").slice(0, 200)}`)
1092
+ .join("\n");
1093
+
1094
+ const issueRef = enrichedIssue?.identifier ?? issue.identifier ?? issue.id;
1095
+ const message = [
1096
+ `You are an orchestrator responding to a Linear comment. Your text output will be automatically posted as a comment on the issue (do NOT post a comment yourself — the handler does it).`,
1097
+ ``,
1098
+ `**Tool access:**`,
1099
+ `- \`linearis\` CLI: READ ONLY. You can read issues (\`linearis issues read ${issueRef}\`), list, and search. Do NOT use linearis to update, close, comment, or modify issues.`,
1100
+ `- \`code_run\`: Dispatch coding work to a worker. Workers return text — they cannot access linearis.`,
1101
+ `- Standard tools: exec, read, edit, write, web_search, etc.`,
1102
+ ``,
1103
+ `**Your role:** Dispatcher. For work requests, use \`code_run\`. You do NOT update issue status — the audit system handles lifecycle.`,
1104
+ ``,
1105
+ `## Issue: ${issueRef} — ${enrichedIssue?.title ?? issue.title ?? "(untitled)"}`,
1106
+ `**Status:** ${enrichedIssue?.state?.name ?? "Unknown"} | **Assignee:** ${enrichedIssue?.assignee?.name ?? "Unassigned"}`,
1107
+ ``,
1108
+ `**Description:**`,
1109
+ description,
1110
+ commentSummary ? `\n**Recent comments:**\n${commentSummary}` : "",
1111
+ `\n**${commentor} says:**\n> ${commentBody}`,
1112
+ ``,
1113
+ `Respond concisely. For work requests, dispatch via \`code_run\` and summarize the result.`,
1114
+ ].filter(Boolean).join("\n");
1115
+
1116
+ // Dispatch with session lifecycle
1117
+ activeRuns.add(issue.id);
1118
+ let agentSessionId: string | null = null;
1119
+
1120
+ try {
1121
+ // Create agent session (non-fatal)
1122
+ const sessionResult = await linearApi.createSessionOnIssue(issue.id);
1123
+ agentSessionId = sessionResult.sessionId;
1124
+ if (agentSessionId) {
1125
+ wasRecentlyProcessed(`session:${agentSessionId}`);
1126
+ setActiveSession({
1127
+ agentSessionId,
1128
+ issueIdentifier: enrichedIssue?.identifier ?? issue.identifier ?? issue.id,
1129
+ issueId: issue.id,
1130
+ agentId,
1131
+ startedAt: Date.now(),
1132
+ });
1133
+ }
1134
+
1135
+ // Emit thought
1136
+ if (agentSessionId) {
1137
+ await linearApi.emitActivity(agentSessionId, {
1138
+ type: "thought",
1139
+ body: `Processing comment on ${issueRef}...`,
1140
+ }).catch(() => {});
1141
+ }
1142
+
1143
+ // Run agent
1144
+ const sessionId = `linear-comment-${agentId}-${Date.now()}`;
1145
+ const { runAgent } = await import("../agent/agent.js");
1146
+ const result = await runAgent({
1147
+ api,
1148
+ agentId,
1149
+ sessionId,
1150
+ message,
1151
+ timeoutMs: 3 * 60_000,
1152
+ streaming: agentSessionId ? { linearApi, agentSessionId } : undefined,
1153
+ });
1154
+
1155
+ const responseBody = result.success
1156
+ ? result.output
1157
+ : `Something went wrong while processing this. The system will retry automatically if possible.`;
1158
+
1159
+ // Post branded comment
1160
+ const brandingOpts = avatarUrl
1161
+ ? { createAsUser: label, displayIconUrl: avatarUrl }
1162
+ : undefined;
1163
+
1164
+ try {
1165
+ if (brandingOpts) {
1166
+ await linearApi.createComment(issue.id, responseBody, brandingOpts);
1167
+ } else {
1168
+ await linearApi.createComment(issue.id, `**[${label}]** ${responseBody}`);
1169
+ }
1170
+ } catch (brandErr) {
1171
+ api.logger.warn(`Branded comment failed, falling back to prefix: ${brandErr}`);
1172
+ await linearApi.createComment(issue.id, `**[${label}]** ${responseBody}`);
1173
+ }
1174
+
1175
+ // Emit response (closes session)
1176
+ if (agentSessionId) {
1177
+ const truncated = responseBody.length > 2000
1178
+ ? responseBody.slice(0, 2000) + "…"
1179
+ : responseBody;
1180
+ await linearApi.emitActivity(agentSessionId, {
1181
+ type: "response",
1182
+ body: truncated,
1183
+ }).catch(() => {});
1184
+ }
1185
+
1186
+ api.logger.info(`Posted ${agentId} response to ${issueRef}`);
1187
+ } catch (err) {
1188
+ api.logger.error(`dispatchCommentToAgent error: ${err}`);
1189
+ if (agentSessionId) {
1190
+ await linearApi.emitActivity(agentSessionId, {
1191
+ type: "error",
1192
+ body: `Failed to process comment: ${String(err).slice(0, 500)}`,
1193
+ }).catch(() => {});
1194
+ }
1195
+ } finally {
1196
+ clearActiveSession(issue.id);
1197
+ activeRuns.delete(issue.id);
1198
+ }
1199
+ }
1200
+
1240
1201
  // ── @dispatch handler ─────────────────────────────────────────────
1241
1202
  //
1242
1203
  // Triggered by `@dispatch` in a Linear comment. Assesses issue complexity,
@@ -1385,6 +1346,9 @@ async function handleDispatch(
1385
1346
  }
1386
1347
 
1387
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);
1388
1352
  let agentSessionId: string | undefined;
1389
1353
  try {
1390
1354
  const sessionResult = await linearApi.createSessionOnIssue(issue.id);
@@ -1487,7 +1451,7 @@ async function handleDispatch(
1487
1451
  }
1488
1452
 
1489
1453
  // 11. Run v2 pipeline: worker → audit → verdict (non-blocking)
1490
- activeRuns.add(issue.id);
1454
+ // (activeRuns already set in step 6 above)
1491
1455
 
1492
1456
  // Instantiate notifier (Discord, Slack, or both — config-driven)
1493
1457
  const notify: NotifyFn = createNotifierFromConfig(pluginConfig, api.runtime, api);