@calltelemetry/openclaw-linear 0.8.0 → 0.8.1

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 {
@@ -659,7 +660,7 @@ export async function handleLinearWebhook(
659
660
  return true;
660
661
  }
661
662
 
662
- // ── Comment.create — @mention routing to agents ─────────────────
663
+ // ── Comment.create — intent-based routing ────────────────────────
663
664
  if (payload.type === "Comment" && payload.action === "create") {
664
665
  res.statusCode = 200;
665
666
  res.end("ok");
@@ -668,265 +669,211 @@ export async function handleLinearWebhook(
668
669
  const commentBody = comment?.body ?? "";
669
670
  const commentor = comment?.user?.name ?? "Unknown";
670
671
  const issue = comment?.issue ?? payload.issue;
671
-
672
- // ── Planning mode intercept ──────────────────────────────────
673
672
  const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
674
673
 
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
674
  if (!issue?.id) {
765
675
  api.logger.error("Comment webhook: missing issue data");
766
676
  return true;
767
677
  }
768
678
 
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
679
+ // Dedup on comment ID
776
680
  if (comment?.id && wasRecentlyProcessed(`comment:${comment.id}`)) {
777
681
  api.logger.info(`Comment ${comment.id} already processed — skipping`);
778
682
  return true;
779
683
  }
780
684
 
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`);
685
+ const linearApi = createLinearApi(api);
686
+ if (!linearApi) {
687
+ api.logger.error("No Linear access token cannot process comment");
787
688
  return true;
788
689
  }
789
- activeRuns.add(issue.id);
790
690
 
791
- // React with eyes to acknowledge the comment
792
- if (comment?.id) {
793
- linearApi.createReaction(comment.id, "eyes").catch(() => {});
691
+ // Skip bot's own comments
692
+ try {
693
+ const viewerId = await linearApi.getViewerId();
694
+ if (viewerId && comment?.user?.id === viewerId) {
695
+ api.logger.info(`Comment webhook: skipping our own comment on ${issue.identifier ?? issue.id}`);
696
+ return true;
697
+ }
698
+ } catch { /* proceed if viewerId check fails */ }
699
+
700
+ // Load agent profiles
701
+ const profiles = loadAgentProfiles();
702
+ const agentNames = Object.keys(profiles);
703
+
704
+ // ── @mention fast path — skip classifier ────────────────────
705
+ const mentionPattern = buildMentionPattern(profiles);
706
+ const mentionMatches = mentionPattern ? commentBody.match(mentionPattern) : null;
707
+ if (mentionMatches && mentionMatches.length > 0) {
708
+ const alias = mentionMatches[0].replace("@", "");
709
+ const resolved = resolveAgentFromAlias(alias, profiles);
710
+ if (resolved) {
711
+ api.logger.info(`Comment @mention fast path: @${resolved.agentId} on ${issue.identifier ?? issue.id}`);
712
+ void dispatchCommentToAgent(api, linearApi, profiles, resolved.agentId, issue, comment, commentBody, commentor, pluginConfig)
713
+ .catch((err) => api.logger.error(`Comment dispatch error: ${err}`));
714
+ return true;
715
+ }
794
716
  }
795
717
 
796
- // Fetch full issue details from Linear API for richer context
797
- let enrichedIssue = issue;
798
- let recentComments = "";
718
+ // ── Intent classification ─────────────────────────────────────
719
+ // Fetch issue details for context
720
+ let enrichedIssue: any = issue;
799
721
  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`;
722
+ enrichedIssue = await linearApi.getIssueDetails(issue.id);
810
723
  } catch (err) {
811
724
  api.logger.warn(`Could not fetch issue details: ${err}`);
812
725
  }
813
726
 
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;
727
+ const projectId = enrichedIssue?.project?.id;
728
+ const planStatePath = pluginConfig?.planningStatePath as string | undefined;
846
729
 
730
+ // Determine planning state
731
+ let isPlanning = false;
732
+ let planSession: any = null;
733
+ if (projectId) {
847
734
  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`);
735
+ const planState = await readPlanningState(planStatePath);
736
+ isPlanning = isInPlanningMode(planState, projectId);
737
+ if (isPlanning) {
738
+ planSession = getPlanningSession(planState, projectId);
863
739
  }
740
+ } catch { /* proceed without planning context */ }
741
+ }
864
742
 
865
- // 2. Emit thought
866
- if (agentSessionId) {
867
- await linearApi.emitActivity(agentSessionId, {
868
- type: "thought",
869
- body: `Reviewing ${enrichedIssue.identifier ?? issue.id}...`,
870
- }).catch(() => {});
871
- }
743
+ const intentResult = await classifyIntent(api, {
744
+ commentBody,
745
+ issueTitle: enrichedIssue?.title ?? "(untitled)",
746
+ issueStatus: enrichedIssue?.state?.name,
747
+ isPlanning,
748
+ agentNames,
749
+ hasProject: !!projectId,
750
+ }, pluginConfig);
872
751
 
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
- });
752
+ api.logger.info(`Comment intent: ${intentResult.intent}${intentResult.agentId ? ` (agent: ${intentResult.agentId})` : ""} — ${intentResult.reasoning} (fallback: ${intentResult.fromFallback})`);
884
753
 
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.`;
754
+ // ── Route by intent ────────────────────────────────────────────
888
755
 
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;
893
-
894
- try {
895
- if (brandingOpts) {
896
- await linearApi.createComment(issue.id, responseBody, brandingOpts);
897
- } else {
898
- await linearApi.createComment(issue.id, `**[${label}]** ${responseBody}`);
756
+ switch (intentResult.intent) {
757
+ case "plan_start": {
758
+ if (!projectId) {
759
+ api.logger.info("Comment intent plan_start but no project — ignoring");
760
+ break;
761
+ }
762
+ if (isPlanning) {
763
+ api.logger.info("Comment intent plan_start but already planning — treating as plan_continue");
764
+ // Fall through to plan_continue
765
+ if (planSession) {
766
+ void handlePlannerTurn(
767
+ { api, linearApi, pluginConfig },
768
+ planSession,
769
+ { issueId: issue.id, commentBody, commentorName: commentor },
770
+ ).catch((err) => api.logger.error(`Planner turn error: ${err}`));
899
771
  }
900
- } catch (brandErr) {
901
- api.logger.warn(`Branded comment failed, falling back to prefix: ${brandErr}`);
902
- await linearApi.createComment(issue.id, `**[${label}]** ${responseBody}`);
772
+ break;
903
773
  }
774
+ api.logger.info(`Planning: initiation requested on ${issue.identifier ?? issue.id}`);
775
+ void initiatePlanningSession(
776
+ { api, linearApi, pluginConfig },
777
+ projectId,
778
+ { id: issue.id, identifier: enrichedIssue.identifier, title: enrichedIssue.title, team: enrichedIssue.team },
779
+ ).catch((err) => api.logger.error(`Planning initiation error: ${err}`));
780
+ break;
781
+ }
904
782
 
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(() => {});
783
+ case "plan_finalize": {
784
+ if (!isPlanning || !planSession) {
785
+ api.logger.info("Comment intent plan_finalize but not in planning mode — ignoring");
786
+ break;
787
+ }
788
+ if (planSession.status === "plan_review") {
789
+ // Already passed audit + cross-model review — approve directly
790
+ api.logger.info(`Planning: approving plan for ${planSession.projectName} (from plan_review)`);
791
+ void (async () => {
792
+ try {
793
+ await endPlanningSession(planSession.projectId, "approved", planStatePath);
794
+ await linearApi.createComment(
795
+ planSession.rootIssueId,
796
+ `## Plan Approved\n\nPlan for **${planSession.projectName}** has been approved. Dispatching to workers.`,
797
+ );
798
+ // Trigger DAG dispatch
799
+ const notify = createNotifierFromConfig(pluginConfig, api.runtime, api);
800
+ const hookCtx: HookContext = {
801
+ api, linearApi, notify, pluginConfig,
802
+ configPath: pluginConfig?.dispatchStatePath as string | undefined,
803
+ };
804
+ await startProjectDispatch(hookCtx, planSession.projectId);
805
+ } catch (err) {
806
+ api.logger.error(`Plan approval error: ${err}`);
807
+ }
808
+ })();
809
+ } else {
810
+ // Still interviewing — run audit (which transitions to plan_review)
811
+ void runPlanAudit(
812
+ { api, linearApi, pluginConfig },
813
+ planSession,
814
+ ).catch((err) => api.logger.error(`Plan audit error: ${err}`));
914
815
  }
816
+ break;
817
+ }
915
818
 
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(() => {});
819
+ case "plan_abandon": {
820
+ if (!isPlanning || !planSession) {
821
+ api.logger.info("Comment intent plan_abandon but not in planning mode — ignoring");
822
+ break;
924
823
  }
925
- } finally {
926
- clearActiveSession(issue.id);
927
- activeRuns.delete(issue.id);
824
+ void (async () => {
825
+ try {
826
+ await endPlanningSession(planSession.projectId, "abandoned", planStatePath);
827
+ await linearApi.createComment(
828
+ planSession.rootIssueId,
829
+ `Planning mode ended for **${planSession.projectName}**. Session abandoned.`,
830
+ );
831
+ api.logger.info(`Planning: session abandoned for ${planSession.projectName}`);
832
+ } catch (err) {
833
+ api.logger.error(`Plan abandon error: ${err}`);
834
+ }
835
+ })();
836
+ break;
928
837
  }
929
- })();
838
+
839
+ case "plan_continue": {
840
+ if (!isPlanning || !planSession) {
841
+ // Not in planning mode — treat as general
842
+ api.logger.info("Comment intent plan_continue but not in planning mode — dispatching to default agent");
843
+ void dispatchCommentToAgent(api, linearApi, profiles, resolveAgentId(api), issue, comment, commentBody, commentor, pluginConfig)
844
+ .catch((err) => api.logger.error(`Comment dispatch error: ${err}`));
845
+ break;
846
+ }
847
+ void handlePlannerTurn(
848
+ { api, linearApi, pluginConfig },
849
+ planSession,
850
+ { issueId: issue.id, commentBody, commentorName: commentor },
851
+ ).catch((err) => api.logger.error(`Planner turn error: ${err}`));
852
+ break;
853
+ }
854
+
855
+ case "ask_agent": {
856
+ const targetAgent = intentResult.agentId ?? resolveAgentId(api);
857
+ api.logger.info(`Comment intent ask_agent: routing to ${targetAgent}`);
858
+ void dispatchCommentToAgent(api, linearApi, profiles, targetAgent, issue, comment, commentBody, commentor, pluginConfig)
859
+ .catch((err) => api.logger.error(`Comment dispatch error: ${err}`));
860
+ break;
861
+ }
862
+
863
+ case "request_work":
864
+ case "question": {
865
+ const defaultAgent = resolveAgentId(api);
866
+ api.logger.info(`Comment intent ${intentResult.intent}: routing to default agent ${defaultAgent}`);
867
+ void dispatchCommentToAgent(api, linearApi, profiles, defaultAgent, issue, comment, commentBody, commentor, pluginConfig)
868
+ .catch((err) => api.logger.error(`Comment dispatch error: ${err}`));
869
+ break;
870
+ }
871
+
872
+ case "general":
873
+ default:
874
+ api.logger.info(`Comment intent general: no action taken for ${issue.identifier ?? issue.id}`);
875
+ break;
876
+ }
930
877
 
931
878
  return true;
932
879
  }
@@ -1009,6 +956,7 @@ export async function handleLinearWebhook(
1009
956
 
1010
957
  api.logger.info(`Issue.create: ${issue.identifier ?? issue.id} — ${issue.title ?? "(untitled)"}`);
1011
958
 
959
+ const pluginConfig = (api as any).pluginConfig as Record<string, unknown> | undefined;
1012
960
  const linearApi = createLinearApi(api);
1013
961
  if (!linearApi) {
1014
962
  api.logger.error("No Linear access token — cannot triage new issue");
@@ -1237,6 +1185,154 @@ export async function handleLinearWebhook(
1237
1185
  return true;
1238
1186
  }
1239
1187
 
1188
+ // ── Comment dispatch helper ───────────────────────────────────────
1189
+ //
1190
+ // Dispatches a comment to a specific agent. Used by intent-based routing
1191
+ // and @mention fast path.
1192
+
1193
+ async function dispatchCommentToAgent(
1194
+ api: OpenClawPluginApi,
1195
+ linearApi: LinearAgentApi,
1196
+ profiles: Record<string, AgentProfile>,
1197
+ agentId: string,
1198
+ issue: any,
1199
+ comment: any,
1200
+ commentBody: string,
1201
+ commentor: string,
1202
+ pluginConfig?: Record<string, unknown>,
1203
+ ): Promise<void> {
1204
+ const profile = profiles[agentId];
1205
+ const label = profile?.label ?? agentId;
1206
+ const avatarUrl = profile?.avatarUrl;
1207
+
1208
+ // Guard: prevent concurrent runs on same issue
1209
+ if (activeRuns.has(issue.id)) {
1210
+ api.logger.info(`dispatchCommentToAgent: ${issue.identifier ?? issue.id} has active run — skipping`);
1211
+ return;
1212
+ }
1213
+
1214
+ // Fetch full issue details
1215
+ let enrichedIssue: any = issue;
1216
+ try {
1217
+ enrichedIssue = await linearApi.getIssueDetails(issue.id);
1218
+ } catch (err) {
1219
+ api.logger.warn(`Could not fetch issue details: ${err}`);
1220
+ }
1221
+
1222
+ const description = enrichedIssue?.description ?? issue?.description ?? "(no description)";
1223
+ const comments = enrichedIssue?.comments?.nodes ?? [];
1224
+ const commentSummary = comments
1225
+ .slice(-5)
1226
+ .map((c: any) => `**${c.user?.name ?? "Unknown"}**: ${(c.body ?? "").slice(0, 200)}`)
1227
+ .join("\n");
1228
+
1229
+ const issueRef = enrichedIssue?.identifier ?? issue.identifier ?? issue.id;
1230
+ const message = [
1231
+ `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).`,
1232
+ ``,
1233
+ `**Tool access:**`,
1234
+ `- \`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.`,
1235
+ `- \`code_run\`: Dispatch coding work to a worker. Workers return text — they cannot access linearis.`,
1236
+ `- Standard tools: exec, read, edit, write, web_search, etc.`,
1237
+ ``,
1238
+ `**Your role:** Dispatcher. For work requests, use \`code_run\`. You do NOT update issue status — the audit system handles lifecycle.`,
1239
+ ``,
1240
+ `## Issue: ${issueRef} — ${enrichedIssue?.title ?? issue.title ?? "(untitled)"}`,
1241
+ `**Status:** ${enrichedIssue?.state?.name ?? "Unknown"} | **Assignee:** ${enrichedIssue?.assignee?.name ?? "Unassigned"}`,
1242
+ ``,
1243
+ `**Description:**`,
1244
+ description,
1245
+ commentSummary ? `\n**Recent comments:**\n${commentSummary}` : "",
1246
+ `\n**${commentor} says:**\n> ${commentBody}`,
1247
+ ``,
1248
+ `Respond concisely. For work requests, dispatch via \`code_run\` and summarize the result.`,
1249
+ ].filter(Boolean).join("\n");
1250
+
1251
+ // Dispatch with session lifecycle
1252
+ activeRuns.add(issue.id);
1253
+ let agentSessionId: string | null = null;
1254
+
1255
+ try {
1256
+ // Create agent session (non-fatal)
1257
+ const sessionResult = await linearApi.createSessionOnIssue(issue.id);
1258
+ agentSessionId = sessionResult.sessionId;
1259
+ if (agentSessionId) {
1260
+ wasRecentlyProcessed(`session:${agentSessionId}`);
1261
+ setActiveSession({
1262
+ agentSessionId,
1263
+ issueIdentifier: enrichedIssue?.identifier ?? issue.identifier ?? issue.id,
1264
+ issueId: issue.id,
1265
+ agentId,
1266
+ startedAt: Date.now(),
1267
+ });
1268
+ }
1269
+
1270
+ // Emit thought
1271
+ if (agentSessionId) {
1272
+ await linearApi.emitActivity(agentSessionId, {
1273
+ type: "thought",
1274
+ body: `Processing comment on ${issueRef}...`,
1275
+ }).catch(() => {});
1276
+ }
1277
+
1278
+ // Run agent
1279
+ const sessionId = `linear-comment-${agentId}-${Date.now()}`;
1280
+ const { runAgent } = await import("../agent/agent.js");
1281
+ const result = await runAgent({
1282
+ api,
1283
+ agentId,
1284
+ sessionId,
1285
+ message,
1286
+ timeoutMs: 3 * 60_000,
1287
+ streaming: agentSessionId ? { linearApi, agentSessionId } : undefined,
1288
+ });
1289
+
1290
+ const responseBody = result.success
1291
+ ? result.output
1292
+ : `Something went wrong while processing this. The system will retry automatically if possible.`;
1293
+
1294
+ // Post branded comment
1295
+ const brandingOpts = avatarUrl
1296
+ ? { createAsUser: label, displayIconUrl: avatarUrl }
1297
+ : undefined;
1298
+
1299
+ try {
1300
+ if (brandingOpts) {
1301
+ await linearApi.createComment(issue.id, responseBody, brandingOpts);
1302
+ } else {
1303
+ await linearApi.createComment(issue.id, `**[${label}]** ${responseBody}`);
1304
+ }
1305
+ } catch (brandErr) {
1306
+ api.logger.warn(`Branded comment failed, falling back to prefix: ${brandErr}`);
1307
+ await linearApi.createComment(issue.id, `**[${label}]** ${responseBody}`);
1308
+ }
1309
+
1310
+ // Emit response (closes session)
1311
+ if (agentSessionId) {
1312
+ const truncated = responseBody.length > 2000
1313
+ ? responseBody.slice(0, 2000) + "…"
1314
+ : responseBody;
1315
+ await linearApi.emitActivity(agentSessionId, {
1316
+ type: "response",
1317
+ body: truncated,
1318
+ }).catch(() => {});
1319
+ }
1320
+
1321
+ api.logger.info(`Posted ${agentId} response to ${issueRef}`);
1322
+ } catch (err) {
1323
+ api.logger.error(`dispatchCommentToAgent error: ${err}`);
1324
+ if (agentSessionId) {
1325
+ await linearApi.emitActivity(agentSessionId, {
1326
+ type: "error",
1327
+ body: `Failed to process comment: ${String(err).slice(0, 500)}`,
1328
+ }).catch(() => {});
1329
+ }
1330
+ } finally {
1331
+ clearActiveSession(issue.id);
1332
+ activeRuns.delete(issue.id);
1333
+ }
1334
+ }
1335
+
1240
1336
  // ── @dispatch handler ─────────────────────────────────────────────
1241
1337
  //
1242
1338
  // Triggered by `@dispatch` in a Linear comment. Assesses issue complexity,
@@ -150,6 +150,12 @@ export async function runClaude(
150
150
  const env = { ...process.env };
151
151
  delete env.CLAUDECODE;
152
152
 
153
+ // Pass Anthropic API key if configured (plugin config takes precedence over env)
154
+ const claudeApiKey = pluginConfig?.claudeApiKey as string | undefined;
155
+ if (claudeApiKey) {
156
+ env.ANTHROPIC_API_KEY = claudeApiKey;
157
+ }
158
+
153
159
  const child = spawn(CLAUDE_BIN, args, {
154
160
  stdio: ["ignore", "pipe", "pipe"],
155
161
  cwd: workingDir,
@@ -203,8 +203,8 @@ describe("resolveCodingBackend", () => {
203
203
  expect(resolveCodingBackend(config)).toBe("gemini");
204
204
  });
205
205
 
206
- it("falls back to claude when no config provided", () => {
207
- expect(resolveCodingBackend({})).toBe("claude");
208
- expect(resolveCodingBackend({}, "anyAgent")).toBe("claude");
206
+ it("falls back to codex when no config provided", () => {
207
+ expect(resolveCodingBackend({})).toBe("codex");
208
+ expect(resolveCodingBackend({}, "anyAgent")).toBe("codex");
209
209
  });
210
210
  });
@@ -88,7 +88,7 @@ function resolveAlias(aliasMap: Map<string, CodingBackend>, input: string): Codi
88
88
  * Priority:
89
89
  * 1. Per-agent override: config.agentCodingTools[agentId]
90
90
  * 2. Global default: config.codingTool
91
- * 3. Hardcoded fallback: "claude"
91
+ * 3. Hardcoded fallback: "codex"
92
92
  */
93
93
  export function resolveCodingBackend(
94
94
  config: CodingToolsConfig,
@@ -104,7 +104,7 @@ export function resolveCodingBackend(
104
104
  const global = config.codingTool;
105
105
  if (global && global in BACKEND_RUNNERS) return global as CodingBackend;
106
106
 
107
- return "claude";
107
+ return "codex";
108
108
  }
109
109
 
110
110
  /**