cyrus-edge-worker 0.2.2 → 0.2.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.
@@ -2,12 +2,13 @@ import { EventEmitter } from "node:events";
2
2
  import { mkdir, readdir, readFile, rename, writeFile } from "node:fs/promises";
3
3
  import { basename, dirname, extname, join, resolve } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
- import { LinearClient, } from "@linear/sdk";
5
+ import { LinearClient } from "@linear/sdk";
6
6
  import { watch as chokidarWatch } from "chokidar";
7
7
  import { AbortError, ClaudeRunner, createCyrusToolsServer, createImageToolsServer, createSoraToolsServer, getAllTools, getCoordinatorTools, getReadOnlyTools, getSafeTools, } from "cyrus-claude-runner";
8
8
  import { ConfigUpdater } from "cyrus-config-updater";
9
9
  import { DEFAULT_PROXY_URL, isAgentSessionCreatedWebhook, isAgentSessionPromptedWebhook, isIssueAssignedWebhook, isIssueCommentMentionWebhook, isIssueNewCommentWebhook, isIssueUnassignedWebhook, PersistenceManager, resolvePath, } from "cyrus-core";
10
- import { LinearEventTransport } from "cyrus-linear-event-transport";
10
+ import { GeminiRunner } from "cyrus-gemini-runner";
11
+ import { LinearEventTransport, LinearIssueTrackerService, } from "cyrus-linear-event-transport";
11
12
  import { fileTypeFromBuffer } from "file-type";
12
13
  import { AgentSessionManager } from "./AgentSessionManager.js";
13
14
  import { ProcedureRouter, } from "./procedures/index.js";
@@ -22,8 +23,8 @@ import { SharedApplicationServer } from "./SharedApplicationServer.js";
22
23
  export class EdgeWorker extends EventEmitter {
23
24
  config;
24
25
  repositories = new Map(); // repository 'id' (internal, stored in config.json) mapped to the full repo config
25
- agentSessionManagers = new Map(); // Maps repository ID to AgentSessionManager, which manages ClaudeRunners for a repo
26
- linearClients = new Map(); // one linear client per 'repository'
26
+ agentSessionManagers = new Map(); // Maps repository ID to AgentSessionManager, which manages agent runners for a repo
27
+ issueTrackers = new Map(); // one issue tracker per 'repository'
27
28
  linearEventTransport = null; // Single event transport for webhook delivery
28
29
  configUpdater = null; // Single config updater for configuration updates
29
30
  persistenceManager;
@@ -40,29 +41,27 @@ export class EdgeWorker extends EventEmitter {
40
41
  this.config = config;
41
42
  this.cyrusHome = config.cyrusHome;
42
43
  this.persistenceManager = new PersistenceManager(join(this.cyrusHome, "state"));
43
- // Initialize procedure router with haiku model for fast classification
44
+ // Initialize procedure router with haiku for fast classification
45
+ // Default to claude runner
44
46
  this.procedureRouter = new ProcedureRouter({
45
47
  cyrusHome: this.cyrusHome,
46
48
  model: "haiku",
47
- timeoutMs: 10000,
49
+ timeoutMs: 100000,
50
+ runnerType: "claude", // Use Claude by default
48
51
  });
49
52
  // Initialize repository router with dependencies
50
53
  const repositoryRouterDeps = {
51
54
  fetchIssueLabels: async (issueId, workspaceId) => {
52
- // Get Linear client for this workspace
53
- const linearClient = this.getLinearClientForWorkspace(workspaceId);
54
- if (!linearClient) {
55
- console.warn(`[EdgeWorker] No Linear client found for workspace ${workspaceId}`);
55
+ // Find repository for this workspace
56
+ const repo = Array.from(this.repositories.values()).find((r) => r.linearWorkspaceId === workspaceId);
57
+ if (!repo)
56
58
  return [];
57
- }
58
- try {
59
- const issue = await linearClient.issue(issueId);
60
- return await this.fetchIssueLabels(issue);
61
- }
62
- catch (error) {
63
- console.error(`[EdgeWorker] Failed to fetch issue labels for ${issueId}:`, error);
59
+ // Get issue tracker for this repository
60
+ const issueTracker = this.issueTrackers.get(repo.id);
61
+ if (!issueTracker)
64
62
  return [];
65
- }
63
+ // Use platform-agnostic getIssueLabels method
64
+ return await issueTracker.getIssueLabels(issueId);
66
65
  },
67
66
  hasActiveSession: (issueId, repositoryId) => {
68
67
  const sessionManager = this.agentSessionManagers.get(repositoryId);
@@ -71,8 +70,8 @@ export class EdgeWorker extends EventEmitter {
71
70
  const activeSessions = sessionManager.getActiveSessionsByIssueId(issueId);
72
71
  return activeSessions.length > 0;
73
72
  },
74
- getLinearClient: (workspaceId) => {
75
- return this.getLinearClientForWorkspace(workspaceId);
73
+ getIssueTracker: (workspaceId) => {
74
+ return this.getIssueTrackerForWorkspace(workspaceId);
76
75
  },
77
76
  };
78
77
  this.repositoryRouter = new RepositoryRouter(repositoryRouterDeps);
@@ -103,11 +102,12 @@ export class EdgeWorker extends EventEmitter {
103
102
  : undefined,
104
103
  };
105
104
  this.repositories.set(repo.id, resolvedRepo);
106
- // Create Linear client for this repository's workspace
105
+ // Create issue tracker for this repository's workspace
107
106
  const linearClient = new LinearClient({
108
107
  accessToken: repo.linearToken,
109
108
  });
110
- this.linearClients.set(repo.id, linearClient);
109
+ const issueTracker = new LinearIssueTrackerService(linearClient);
110
+ this.issueTrackers.set(repo.id, issueTracker);
111
111
  // Create AgentSessionManager for this repository with parent session lookup and resume callback
112
112
  //
113
113
  // Note: This pattern works (despite appearing recursive) because:
@@ -118,54 +118,18 @@ export class EdgeWorker extends EventEmitter {
118
118
  //
119
119
  // This allows the AgentSessionManager to call back into itself to access its own sessions,
120
120
  // enabling child sessions to trigger parent session resumption using the same manager instance.
121
- const agentSessionManager = new AgentSessionManager(linearClient, (childSessionId) => {
121
+ const agentSessionManager = new AgentSessionManager(issueTracker, (childSessionId) => {
122
122
  console.log(`[Parent-Child Lookup] Looking up parent session for child ${childSessionId}`);
123
123
  const parentId = this.childToParentAgentSession.get(childSessionId);
124
124
  console.log(`[Parent-Child Lookup] Child ${childSessionId} -> Parent ${parentId || "not found"}`);
125
125
  return parentId;
126
126
  }, async (parentSessionId, prompt, childSessionId) => {
127
127
  await this.handleResumeParentSession(parentSessionId, prompt, childSessionId, repo, agentSessionManager);
128
- }, async (linearAgentActivitySessionId) => {
129
- console.log(`[Subroutine Transition] Advancing to next subroutine for session ${linearAgentActivitySessionId}`);
130
- // Get the session
131
- const session = agentSessionManager.getSession(linearAgentActivitySessionId);
132
- if (!session) {
133
- console.error(`[Subroutine Transition] Session ${linearAgentActivitySessionId} not found`);
134
- return;
135
- }
136
- // Get next subroutine (advancement already handled by AgentSessionManager)
137
- const nextSubroutine = this.procedureRouter.getCurrentSubroutine(session);
138
- if (!nextSubroutine) {
139
- console.log(`[Subroutine Transition] Procedure complete for session ${linearAgentActivitySessionId}`);
140
- return;
141
- }
142
- console.log(`[Subroutine Transition] Next subroutine: ${nextSubroutine.name}`);
143
- // Load subroutine prompt
144
- let subroutinePrompt;
145
- try {
146
- subroutinePrompt = await this.loadSubroutinePrompt(nextSubroutine, this.config.linearWorkspaceSlug);
147
- if (!subroutinePrompt) {
148
- // Fallback if loadSubroutinePrompt returns null
149
- subroutinePrompt = `Continue with: ${nextSubroutine.description}`;
150
- }
151
- }
152
- catch (error) {
153
- console.error(`[Subroutine Transition] Failed to load subroutine prompt:`, error);
154
- // Fallback to simple prompt
155
- subroutinePrompt = `Continue with: ${nextSubroutine.description}`;
156
- }
157
- // Resume Claude session with subroutine prompt
158
- try {
159
- await this.resumeClaudeSession(session, repo, linearAgentActivitySessionId, agentSessionManager, subroutinePrompt, "", // No attachment manifest
160
- false, // Not a new session
161
- [], // No additional allowed directories
162
- nextSubroutine.maxTurns);
163
- console.log(`[Subroutine Transition] Successfully resumed session for ${nextSubroutine.name} subroutine${nextSubroutine.maxTurns ? ` (maxTurns=${nextSubroutine.maxTurns})` : ""}`);
164
- }
165
- catch (error) {
166
- console.error(`[Subroutine Transition] Failed to resume session for ${nextSubroutine.name} subroutine:`, error);
167
- }
168
128
  }, this.procedureRouter, this.sharedApplicationServer);
129
+ // Subscribe to subroutine completion events
130
+ agentSessionManager.on("subroutineComplete", async ({ linearAgentActivitySessionId, session }) => {
131
+ await this.handleSubroutineTransition(linearAgentActivitySessionId, session, repo, agentSessionManager);
132
+ });
169
133
  this.agentSessionManagers.set(repo.id, agentSessionManager);
170
134
  }
171
135
  }
@@ -208,10 +172,10 @@ export class EdgeWorker extends EventEmitter {
208
172
  secret,
209
173
  });
210
174
  // Listen for webhook events
211
- this.linearEventTransport.on("webhook", (payload) => {
175
+ this.linearEventTransport.on("event", (event) => {
212
176
  // Get all active repositories for webhook handling
213
177
  const repos = Array.from(this.repositories.values());
214
- this.handleWebhook(payload, repos);
178
+ this.handleWebhook(event, repos);
215
179
  });
216
180
  // Listen for errors
217
181
  this.linearEventTransport.on("error", (error) => {
@@ -246,13 +210,13 @@ export class EdgeWorker extends EventEmitter {
246
210
  catch (error) {
247
211
  console.error("❌ Failed to save EdgeWorker state during shutdown:", error);
248
212
  }
249
- // get all claudeRunners
250
- const claudeRunners = [];
213
+ // get all agent runners
214
+ const agentRunners = [];
251
215
  for (const agentSessionManager of this.agentSessionManagers.values()) {
252
- claudeRunners.push(...agentSessionManager.getAllClaudeRunners());
216
+ agentRunners.push(...agentSessionManager.getAllAgentRunners());
253
217
  }
254
- // Kill all Claude processes with null checking
255
- for (const runner of claudeRunners) {
218
+ // Kill all agent processes with null checking
219
+ for (const runner of agentRunners) {
256
220
  if (runner) {
257
221
  try {
258
222
  runner.stop();
@@ -301,12 +265,12 @@ export class EdgeWorker extends EventEmitter {
301
265
  }
302
266
  await this.postParentResumeAcknowledgment(parentSessionId, repo.id);
303
267
  // Post thought to Linear showing child result receipt
304
- const linearClient = this.linearClients.get(repo.id);
305
- if (linearClient && childSession) {
268
+ const issueTracker = this.issueTrackers.get(repo.id);
269
+ if (issueTracker && childSession) {
306
270
  const childIssueIdentifier = childSession.issue?.identifier || childSession.issueId;
307
271
  const resultThought = `Received result from sub-issue ${childIssueIdentifier}:\n\n---\n\n${prompt}\n\n---`;
308
272
  try {
309
- const result = await linearClient.createAgentActivity({
273
+ const result = await issueTracker.createAgentActivity({
310
274
  agentSessionId: parentSessionId,
311
275
  content: {
312
276
  type: "thought",
@@ -338,6 +302,45 @@ export class EdgeWorker extends EventEmitter {
338
302
  console.error(`[Parent Session Resume] Error context - Parent issue: ${parentSession.issueId}, Repository: ${repo.name}`);
339
303
  }
340
304
  }
305
+ /**
306
+ * Handle subroutine transition when a subroutine completes
307
+ * This is triggered by the AgentSessionManager's 'subroutineComplete' event
308
+ */
309
+ async handleSubroutineTransition(linearAgentActivitySessionId, session, repo, agentSessionManager) {
310
+ console.log(`[Subroutine Transition] Handling subroutine completion for session ${linearAgentActivitySessionId}`);
311
+ // Get next subroutine (advancement already handled by AgentSessionManager)
312
+ const nextSubroutine = this.procedureRouter.getCurrentSubroutine(session);
313
+ if (!nextSubroutine) {
314
+ console.log(`[Subroutine Transition] Procedure complete for session ${linearAgentActivitySessionId}`);
315
+ return;
316
+ }
317
+ console.log(`[Subroutine Transition] Next subroutine: ${nextSubroutine.name}`);
318
+ // Load subroutine prompt
319
+ let subroutinePrompt;
320
+ try {
321
+ subroutinePrompt = await this.loadSubroutinePrompt(nextSubroutine, this.config.linearWorkspaceSlug);
322
+ if (!subroutinePrompt) {
323
+ // Fallback if loadSubroutinePrompt returns null
324
+ subroutinePrompt = `Continue with: ${nextSubroutine.description}`;
325
+ }
326
+ }
327
+ catch (error) {
328
+ console.error(`[Subroutine Transition] Failed to load subroutine prompt:`, error);
329
+ // Fallback to simple prompt
330
+ subroutinePrompt = `Continue with: ${nextSubroutine.description}`;
331
+ }
332
+ // Resume Claude session with subroutine prompt
333
+ try {
334
+ await this.resumeAgentSession(session, repo, linearAgentActivitySessionId, agentSessionManager, subroutinePrompt, "", // No attachment manifest
335
+ false, // Not a new session
336
+ [], // No additional allowed directories
337
+ nextSubroutine?.singleTurn ? 1 : undefined);
338
+ console.log(`[Subroutine Transition] Successfully resumed session for ${nextSubroutine.name} subroutine${nextSubroutine.singleTurn ? " (singleTurn)" : ""}`);
339
+ }
340
+ catch (error) {
341
+ console.error(`[Subroutine Transition] Failed to resume session for ${nextSubroutine.name} subroutine:`, error);
342
+ }
343
+ }
341
344
  /**
342
345
  * Start watching config file for changes
343
346
  */
@@ -502,18 +505,22 @@ export class EdgeWorker extends EventEmitter {
502
505
  };
503
506
  // Add to internal map
504
507
  this.repositories.set(repo.id, resolvedRepo);
505
- // Create Linear client
508
+ // Create issue tracker
506
509
  const linearClient = new LinearClient({
507
510
  accessToken: repo.linearToken,
508
511
  });
509
- this.linearClients.set(repo.id, linearClient);
512
+ const issueTracker = new LinearIssueTrackerService(linearClient);
513
+ this.issueTrackers.set(repo.id, issueTracker);
510
514
  // Create AgentSessionManager with same pattern as constructor
511
- const agentSessionManager = new AgentSessionManager(linearClient, (childSessionId) => {
515
+ const agentSessionManager = new AgentSessionManager(issueTracker, (childSessionId) => {
512
516
  return this.childToParentAgentSession.get(childSessionId);
513
517
  }, async (parentSessionId, prompt, childSessionId) => {
514
518
  await this.handleResumeParentSession(parentSessionId, prompt, childSessionId, repo, agentSessionManager);
515
- }, undefined, // No resumeNextSubroutine callback for dynamically added repos
516
- this.procedureRouter, this.sharedApplicationServer);
519
+ }, this.procedureRouter, this.sharedApplicationServer);
520
+ // Subscribe to subroutine completion events
521
+ agentSessionManager.on("subroutineComplete", async ({ linearAgentActivitySessionId, session }) => {
522
+ await this.handleSubroutineTransition(linearAgentActivitySessionId, session, repo, agentSessionManager);
523
+ });
517
524
  this.agentSessionManagers.set(repo.id, agentSessionManager);
518
525
  console.log(`✅ Repository added successfully: ${repo.name}`);
519
526
  }
@@ -553,13 +560,14 @@ export class EdgeWorker extends EventEmitter {
553
560
  };
554
561
  // Update stored config
555
562
  this.repositories.set(repo.id, resolvedRepo);
556
- // If token changed, recreate Linear client
563
+ // If token changed, recreate issue tracker
557
564
  if (oldRepo.linearToken !== repo.linearToken) {
558
- console.log(` 🔑 Token changed, recreating Linear client`);
565
+ console.log(` 🔑 Token changed, recreating issue tracker`);
559
566
  const linearClient = new LinearClient({
560
567
  accessToken: repo.linearToken,
561
568
  });
562
- this.linearClients.set(repo.id, linearClient);
569
+ const issueTracker = new LinearIssueTrackerService(linearClient);
570
+ this.issueTrackers.set(repo.id, issueTracker);
563
571
  }
564
572
  // If active status changed
565
573
  if (oldRepo.isActive !== repo.isActive) {
@@ -593,17 +601,17 @@ export class EdgeWorker extends EventEmitter {
593
601
  for (const session of activeSessions) {
594
602
  try {
595
603
  console.log(` 🛑 Stopping session for issue ${session.issueId}`);
596
- // Get the Claude runner for this session
597
- const runner = manager?.getClaudeRunner(session.linearAgentActivitySessionId);
604
+ // Get the agent runner for this session
605
+ const runner = manager?.getAgentRunner(session.linearAgentActivitySessionId);
598
606
  if (runner) {
599
- // Stop the Claude process
607
+ // Stop the agent process
600
608
  runner.stop();
601
609
  console.log(` ✅ Stopped Claude runner for session ${session.linearAgentActivitySessionId}`);
602
610
  }
603
611
  // Post cancellation message to Linear
604
- const linearClient = this.linearClients.get(repo.id);
605
- if (linearClient) {
606
- await linearClient.createAgentActivity({
612
+ const issueTracker = this.issueTrackers.get(repo.id);
613
+ if (issueTracker) {
614
+ await issueTracker.createAgentActivity({
607
615
  agentSessionId: session.linearAgentActivitySessionId,
608
616
  content: {
609
617
  type: "response",
@@ -620,7 +628,7 @@ export class EdgeWorker extends EventEmitter {
620
628
  }
621
629
  // Remove repository from all maps
622
630
  this.repositories.delete(repo.id);
623
- this.linearClients.delete(repo.id);
631
+ this.issueTrackers.delete(repo.id);
624
632
  this.agentSessionManagers.delete(repo.id);
625
633
  console.log(`✅ Repository removed successfully: ${repo.name}`);
626
634
  }
@@ -688,6 +696,10 @@ export class EdgeWorker extends EventEmitter {
688
696
  * Handle issue unassignment webhook
689
697
  */
690
698
  async handleIssueUnassignedWebhook(webhook) {
699
+ if (!webhook.notification.issue) {
700
+ console.warn("[EdgeWorker] Received issue unassignment webhook without issue");
701
+ return;
702
+ }
691
703
  const issueId = webhook.notification.issue.id;
692
704
  // Get cached repository (unassignment should only happen on issues with active sessions)
693
705
  const repository = this.getCachedRepository(issueId);
@@ -703,12 +715,12 @@ export class EdgeWorker extends EventEmitter {
703
715
  await this.handleIssueUnassigned(webhook.notification.issue, repository);
704
716
  }
705
717
  /**
706
- * Get Linear client for a workspace by finding first repository with that workspace ID
718
+ * Get issue tracker for a workspace by finding first repository with that workspace ID
707
719
  */
708
- getLinearClientForWorkspace(workspaceId) {
720
+ getIssueTrackerForWorkspace(workspaceId) {
709
721
  for (const [repoId, repo] of this.repositories) {
710
722
  if (repo.linearWorkspaceId === workspaceId) {
711
- return this.linearClients.get(repoId);
723
+ return this.issueTrackers.get(repoId);
712
724
  }
713
725
  }
714
726
  return undefined;
@@ -751,7 +763,10 @@ export class EdgeWorker extends EventEmitter {
751
763
  const attachmentsDir = join(this.cyrusHome, workspaceFolderName, "attachments");
752
764
  await mkdir(attachmentsDir, { recursive: true });
753
765
  // Build allowed directories list - always include attachments directory
754
- const allowedDirectories = [attachmentsDir];
766
+ const allowedDirectories = [
767
+ attachmentsDir,
768
+ repository.repositoryPath,
769
+ ];
755
770
  console.log(`[EdgeWorker] Configured allowed directories for ${fullIssue.identifier}:`, allowedDirectories);
756
771
  // Build allowed tools list with Linear MCP tools
757
772
  const allowedTools = this.buildAllowedTools(repository);
@@ -811,17 +826,21 @@ export class EdgeWorker extends EventEmitter {
811
826
  // Post agent activity showing auto-matched routing
812
827
  await this.postRepositorySelectionActivity(webhook.agentSession.id, repository.id, repository.name, routingMethod);
813
828
  }
829
+ if (!webhook.agentSession.issue) {
830
+ console.warn("[EdgeWorker] Agent session created webhook missing issue");
831
+ return;
832
+ }
814
833
  console.log(`[EdgeWorker] Handling agent session created: ${webhook.agentSession.issue.identifier}`);
815
834
  const { agentSession, guidance } = webhook;
816
835
  const commentBody = agentSession.comment?.body;
817
- // Initialize Claude runner using shared logic
818
- await this.initializeClaudeRunner(agentSession, repository, guidance, commentBody);
836
+ // Initialize agent runner using shared logic
837
+ await this.initializeAgentRunner(agentSession, repository, guidance, commentBody);
819
838
  }
820
839
  /**
821
840
 
822
841
  /**
823
- * Initialize and start Claude runner for an agent session
824
- * This method contains the shared logic for creating a Claude runner that both
842
+ * Initialize and start agent runner for an agent session
843
+ * This method contains the shared logic for creating an agent runner that both
825
844
  * handleAgentSessionCreatedWebhook and handleUserPromptedAgentActivity use.
826
845
  *
827
846
  * @param agentSession The Linear agent session
@@ -829,9 +848,13 @@ export class EdgeWorker extends EventEmitter {
829
848
  * @param guidance Optional guidance rules from Linear
830
849
  * @param commentBody Optional comment body (for mentions)
831
850
  */
832
- async initializeClaudeRunner(agentSession, repository, guidance, commentBody) {
851
+ async initializeAgentRunner(agentSession, repository, guidance, commentBody) {
833
852
  const linearAgentActivitySessionId = agentSession.id;
834
853
  const { issue } = agentSession;
854
+ if (!issue) {
855
+ console.warn("[EdgeWorker] Cannot initialize Claude runner without issue");
856
+ return;
857
+ }
835
858
  // Log guidance if present
836
859
  if (guidance && guidance.length > 0) {
837
860
  console.log(`[EdgeWorker] Agent guidance received: ${guidance.length} rule(s)`);
@@ -931,7 +954,7 @@ export class EdgeWorker extends EventEmitter {
931
954
  repository,
932
955
  userComment: commentBody || "", // Empty for delegation, present for mentions
933
956
  attachmentManifest: attachmentResult.manifest,
934
- guidance,
957
+ guidance: guidance || undefined,
935
958
  agentSession,
936
959
  labels,
937
960
  isNewSession: true,
@@ -962,28 +985,47 @@ export class EdgeWorker extends EventEmitter {
962
985
  if (disallowedTools.length > 0) {
963
986
  console.log(`[EdgeWorker] Configured disallowed tools for ${fullIssue.identifier}:`, disallowedTools);
964
987
  }
965
- // Create Claude runner with system prompt from assembly
966
- const runnerConfig = this.buildClaudeRunnerConfig(session, repository, linearAgentActivitySessionId, assembly.systemPrompt, allowedTools, allowedDirectories, disallowedTools, undefined, // resumeSessionId
967
- labels);
968
- const runner = new ClaudeRunner(runnerConfig);
988
+ // Get current subroutine to check for singleTurn mode
989
+ const currentSubroutine = this.procedureRouter.getCurrentSubroutine(session);
990
+ // Create agent runner with system prompt from assembly
991
+ // buildAgentRunnerConfig now determines runner type from labels internally
992
+ const { config: runnerConfig, runnerType } = this.buildAgentRunnerConfig(session, repository, linearAgentActivitySessionId, assembly.systemPrompt, allowedTools, allowedDirectories, disallowedTools, undefined, // resumeSessionId
993
+ labels, // Pass labels for runner selection and model override
994
+ undefined, // maxTurns
995
+ currentSubroutine?.singleTurn);
996
+ console.log(`[EdgeWorker] Label-based runner selection for new session: ${runnerType} (session ${linearAgentActivitySessionId})`);
997
+ const runner = runnerType === "claude"
998
+ ? new ClaudeRunner(runnerConfig)
999
+ : new GeminiRunner(runnerConfig);
969
1000
  // Store runner by comment ID
970
- agentSessionManager.addClaudeRunner(linearAgentActivitySessionId, runner);
1001
+ agentSessionManager.addAgentRunner(linearAgentActivitySessionId, runner);
971
1002
  // Save state after mapping changes
972
1003
  await this.savePersistedState();
973
1004
  // Emit events using full Linear issue
974
1005
  this.emit("session:started", fullIssue.id, fullIssue, repository.id);
975
1006
  this.config.handlers?.onSessionStart?.(fullIssue.id, fullIssue, repository.id);
976
1007
  // Update runner with version information (if available)
977
- if (systemPromptVersion) {
1008
+ // Note: updatePromptVersions is specific to ClaudeRunner
1009
+ if (systemPromptVersion &&
1010
+ "updatePromptVersions" in runner &&
1011
+ typeof runner.updatePromptVersions === "function") {
978
1012
  runner.updatePromptVersions({
979
1013
  systemPromptVersion,
980
1014
  });
981
1015
  }
982
1016
  // Log metadata for debugging
983
1017
  console.log(`[EdgeWorker] Initial prompt built successfully - components: ${assembly.metadata.components.join(", ")}, type: ${assembly.metadata.promptType}, length: ${assembly.userPrompt.length} characters`);
984
- console.log(`[EdgeWorker] Starting Claude streaming session`);
985
- const sessionInfo = await runner.startStreaming(assembly.userPrompt);
986
- console.log(`[EdgeWorker] Claude streaming session started: ${sessionInfo.sessionId}`);
1018
+ // Start session - use streaming mode if supported for ability to add messages later
1019
+ if (runner.supportsStreamingInput && runner.startStreaming) {
1020
+ console.log(`[EdgeWorker] Starting streaming session`);
1021
+ const sessionInfo = await runner.startStreaming(assembly.userPrompt);
1022
+ console.log(`[EdgeWorker] Streaming session started: ${sessionInfo.sessionId}`);
1023
+ }
1024
+ else {
1025
+ console.log(`[EdgeWorker] Starting non-streaming session`);
1026
+ const sessionInfo = await runner.start(assembly.userPrompt);
1027
+ console.log(`[EdgeWorker] Non-streaming session started: ${sessionInfo.sessionId}`);
1028
+ }
987
1029
  // Note: AgentSessionManager will be initialized automatically when the first system message
988
1030
  // is received via handleClaudeMessage() callback
989
1031
  }
@@ -1021,13 +1063,13 @@ export class EdgeWorker extends EventEmitter {
1021
1063
  return;
1022
1064
  }
1023
1065
  // Stop the existing runner if it's active
1024
- const existingRunner = foundSession.claudeRunner;
1066
+ const existingRunner = foundSession.agentRunner;
1025
1067
  if (existingRunner) {
1026
1068
  existingRunner.stop();
1027
- console.log(`[EdgeWorker] Stopped Claude session for agent activity session ${agentSessionId}`);
1069
+ console.log(`[EdgeWorker] Stopped agent session for agent activity session ${agentSessionId}`);
1028
1070
  }
1029
1071
  // Post confirmation
1030
- const issueTitle = issue.title || "this issue";
1072
+ const issueTitle = issue?.title || "this issue";
1031
1073
  const stopConfirmation = `I've stopped working on ${issueTitle} as requested.\n\n**Stop Signal:** Received from ${webhook.agentSession.creator?.name || "user"}\n**Action Taken:** All ongoing work has been halted`;
1032
1074
  await foundManager.createResponseActivity(agentSessionId, stopConfirmation);
1033
1075
  }
@@ -1043,6 +1085,14 @@ export class EdgeWorker extends EventEmitter {
1043
1085
  const { agentSession, agentActivity, guidance } = webhook;
1044
1086
  const commentBody = agentSession.comment?.body;
1045
1087
  const agentSessionId = agentSession.id;
1088
+ if (!agentActivity) {
1089
+ console.warn("[EdgeWorker] Cannot handle repository selection without agentActivity");
1090
+ return;
1091
+ }
1092
+ if (!agentSession.issue) {
1093
+ console.warn("[EdgeWorker] Cannot handle repository selection without issue");
1094
+ return;
1095
+ }
1046
1096
  const userMessage = agentActivity.content.body;
1047
1097
  console.log(`[EdgeWorker] Processing repository selection response: "${userMessage}"`);
1048
1098
  // Get the selected repository (or fallback)
@@ -1056,9 +1106,9 @@ export class EdgeWorker extends EventEmitter {
1056
1106
  this.repositoryRouter.getIssueRepositoryCache().set(issueId, repository.id);
1057
1107
  // Post agent activity showing user-selected repository
1058
1108
  await this.postRepositorySelectionActivity(agentSessionId, repository.id, repository.name, "user-selected");
1059
- console.log(`[EdgeWorker] Initializing Claude runner after repository selection: ${agentSession.issue.identifier} -> ${repository.name}`);
1060
- // Initialize Claude runner with the selected repository
1061
- await this.initializeClaudeRunner(agentSession, repository, guidance, commentBody);
1109
+ console.log(`[EdgeWorker] Initializing agent runner after repository selection: ${agentSession.issue.identifier} -> ${repository.name}`);
1110
+ // Initialize agent runner with the selected repository
1111
+ await this.initializeAgentRunner(agentSession, repository, guidance, commentBody);
1062
1112
  }
1063
1113
  /**
1064
1114
  * Handle normal prompted activity (existing session continuation)
@@ -1068,6 +1118,14 @@ export class EdgeWorker extends EventEmitter {
1068
1118
  const { agentSession } = webhook;
1069
1119
  const linearAgentActivitySessionId = agentSession.id;
1070
1120
  const { issue } = agentSession;
1121
+ if (!issue) {
1122
+ console.warn("[EdgeWorker] Cannot handle prompted activity without issue");
1123
+ return;
1124
+ }
1125
+ if (!webhook.agentActivity) {
1126
+ console.warn("[EdgeWorker] Cannot handle prompted activity without agentActivity");
1127
+ return;
1128
+ }
1071
1129
  const commentId = webhook.agentActivity.sourceCommentId;
1072
1130
  // Initialize the agent session in AgentSessionManager
1073
1131
  const agentSessionManager = this.agentSessionManagers.get(repository.id);
@@ -1097,14 +1155,14 @@ export class EdgeWorker extends EventEmitter {
1097
1155
  else {
1098
1156
  console.log(`[EdgeWorker] Found existing session ${linearAgentActivitySessionId} for new user prompt`);
1099
1157
  // Post instant acknowledgment for existing session BEFORE any async work
1100
- // Check streaming status first to determine the message
1101
- const isCurrentlyStreaming = session?.claudeRunner?.isStreaming() || false;
1158
+ // Check if runner is currently running (streaming is Claude-specific, use isRunning for both)
1159
+ const isCurrentlyStreaming = session?.agentRunner?.isRunning() || false;
1102
1160
  await this.postInstantPromptedAcknowledgment(linearAgentActivitySessionId, repository.id, isCurrentlyStreaming);
1103
1161
  // Need to fetch full issue for routing context
1104
- const linearClient = this.linearClients.get(repository.id);
1105
- if (linearClient) {
1162
+ const issueTracker = this.issueTrackers.get(repository.id);
1163
+ if (issueTracker) {
1106
1164
  try {
1107
- fullIssue = await linearClient.issue(issue.id);
1165
+ fullIssue = await issueTracker.fetchIssue(issue.id);
1108
1166
  }
1109
1167
  catch (error) {
1110
1168
  console.warn(`[EdgeWorker] Failed to fetch full issue for routing: ${issue.id}`, error);
@@ -1120,10 +1178,10 @@ export class EdgeWorker extends EventEmitter {
1120
1178
  }
1121
1179
  // Acknowledgment already posted above for both new and existing sessions
1122
1180
  // (before any async routing work to ensure instant user feedback)
1123
- // Get Linear client for this repository
1124
- const linearClient = this.linearClients.get(repository.id);
1125
- if (!linearClient) {
1126
- console.error("Unexpected: There was no LinearClient for the repository with id", repository.id);
1181
+ // Get issue tracker for this repository
1182
+ const issueTracker = this.issueTrackers.get(repository.id);
1183
+ if (!issueTracker) {
1184
+ console.error("Unexpected: There was no IssueTrackerService for the repository with id", repository.id);
1127
1185
  return;
1128
1186
  }
1129
1187
  // Always set up attachments directory, even if no attachments in current comment
@@ -1134,37 +1192,34 @@ export class EdgeWorker extends EventEmitter {
1134
1192
  let attachmentManifest = "";
1135
1193
  let commentAuthor;
1136
1194
  let commentTimestamp;
1195
+ if (!commentId) {
1196
+ console.warn("[EdgeWorker] No comment ID provided for attachment handling");
1197
+ }
1137
1198
  try {
1138
- const result = await linearClient.client.rawRequest(`
1139
- query GetComment($id: String!) {
1140
- comment(id: $id) {
1141
- id
1142
- body
1143
- createdAt
1144
- updatedAt
1145
- user {
1146
- name
1147
- displayName
1148
- email
1149
- id
1150
- }
1151
- }
1152
- }
1153
- `, { id: commentId });
1154
- // Extract comment data
1155
- const comment = result.data.comment;
1199
+ const comment = commentId
1200
+ ? await issueTracker.fetchComment(commentId)
1201
+ : null;
1156
1202
  // Extract comment metadata for multi-player context
1157
1203
  if (comment) {
1158
- const user = comment.user;
1204
+ const user = await comment.user;
1159
1205
  commentAuthor =
1160
1206
  user?.displayName || user?.name || user?.email || "Unknown";
1161
- commentTimestamp = comment.createdAt || new Date().toISOString();
1207
+ commentTimestamp = comment.createdAt
1208
+ ? comment.createdAt.toISOString()
1209
+ : new Date().toISOString();
1162
1210
  }
1163
1211
  // Count existing attachments
1164
1212
  const existingFiles = await readdir(attachmentsDir).catch(() => []);
1165
1213
  const existingAttachmentCount = existingFiles.filter((file) => file.startsWith("attachment_") || file.startsWith("image_")).length;
1166
1214
  // Download new attachments from the comment
1167
- const downloadResult = await this.downloadCommentAttachments(comment.body, attachmentsDir, repository.linearToken, existingAttachmentCount);
1215
+ const downloadResult = comment
1216
+ ? await this.downloadCommentAttachments(comment.body, attachmentsDir, repository.linearToken, existingAttachmentCount)
1217
+ : {
1218
+ totalNewAttachments: 0,
1219
+ newAttachmentMap: {},
1220
+ newImageMap: {},
1221
+ failedCount: 0,
1222
+ };
1168
1223
  if (downloadResult.totalNewAttachments > 0) {
1169
1224
  attachmentManifest = this.generateNewAttachmentManifest(downloadResult);
1170
1225
  }
@@ -1196,7 +1251,7 @@ export class EdgeWorker extends EventEmitter {
1196
1251
  // Branch 1: Handle stop signal (checked FIRST, before any routing work)
1197
1252
  // Per CLAUDE.md: "an agentSession MUST already exist" for stop signals
1198
1253
  // IMPORTANT: Stop signals do NOT require repository lookup
1199
- if (webhook.agentActivity.signal === "stop") {
1254
+ if (webhook.agentActivity?.signal === "stop") {
1200
1255
  await this.handleStopSignal(webhook);
1201
1256
  return;
1202
1257
  }
@@ -1235,12 +1290,12 @@ export class EdgeWorker extends EventEmitter {
1235
1290
  console.log("No agentSessionManager for unassigned issue, so no sessions to stop");
1236
1291
  return;
1237
1292
  }
1238
- // Get all Claude runners for this specific issue
1239
- const claudeRunners = agentSessionManager.getClaudeRunnersForIssue(issue.id);
1240
- // Stop all Claude runners for this issue
1241
- const activeThreadCount = claudeRunners.length;
1242
- for (const runner of claudeRunners) {
1243
- console.log(`[EdgeWorker] Stopping Claude runner for issue ${issue.identifier}`);
1293
+ // Get all agent runners for this specific issue
1294
+ const agentRunners = agentSessionManager.getAgentRunnersForIssue(issue.id);
1295
+ // Stop all agent runners for this issue
1296
+ const activeThreadCount = agentRunners.length;
1297
+ for (const runner of agentRunners) {
1298
+ console.log(`[EdgeWorker] Stopping agent runner for issue ${issue.identifier}`);
1244
1299
  runner.stop();
1245
1300
  }
1246
1301
  // Post ONE farewell comment on the issue (not in any thread) if there were active sessions
@@ -1284,6 +1339,86 @@ export class EdgeWorker extends EventEmitter {
1284
1339
  return [];
1285
1340
  }
1286
1341
  }
1342
+ /**
1343
+ * Determine runner type and model from issue labels.
1344
+ * Returns the runner type ("claude" or "gemini"), optional model override, and fallback model.
1345
+ *
1346
+ * Label priority (case-insensitive):
1347
+ * - Gemini labels: gemini, gemini-2.5-pro, gemini-2.5-flash, gemini-2.5-flash-lite, gemini-3-pro, gemini-3-pro-preview
1348
+ * - Claude labels: claude, sonnet, opus
1349
+ *
1350
+ * If no runner label is found, defaults to claude.
1351
+ */
1352
+ determineRunnerFromLabels(labels) {
1353
+ if (!labels || labels.length === 0) {
1354
+ return {
1355
+ runnerType: "claude",
1356
+ modelOverride: "sonnet",
1357
+ fallbackModelOverride: "haiku",
1358
+ };
1359
+ }
1360
+ const lowercaseLabels = labels.map((label) => label.toLowerCase());
1361
+ // Check for Gemini labels first
1362
+ if (lowercaseLabels.includes("gemini-2.5-pro") ||
1363
+ lowercaseLabels.includes("gemini-2.5")) {
1364
+ return {
1365
+ runnerType: "gemini",
1366
+ modelOverride: "gemini-2.5-pro",
1367
+ fallbackModelOverride: "gemini-2.5-flash",
1368
+ };
1369
+ }
1370
+ if (lowercaseLabels.includes("gemini-2.5-flash")) {
1371
+ return {
1372
+ runnerType: "gemini",
1373
+ modelOverride: "gemini-2.5-flash",
1374
+ fallbackModelOverride: "gemini-2.5-flash-lite",
1375
+ };
1376
+ }
1377
+ if (lowercaseLabels.includes("gemini-2.5-flash-lite")) {
1378
+ return {
1379
+ runnerType: "gemini",
1380
+ modelOverride: "gemini-2.5-flash-lite",
1381
+ fallbackModelOverride: "gemini-2.5-flash-lite",
1382
+ };
1383
+ }
1384
+ if (lowercaseLabels.includes("gemini-3") ||
1385
+ lowercaseLabels.includes("gemini-3-pro") ||
1386
+ lowercaseLabels.includes("gemini-3-pro-preview")) {
1387
+ return {
1388
+ runnerType: "gemini",
1389
+ modelOverride: "gemini-3-pro-preview",
1390
+ fallbackModelOverride: "gemini-2.5-pro",
1391
+ };
1392
+ }
1393
+ if (lowercaseLabels.includes("gemini")) {
1394
+ return {
1395
+ runnerType: "gemini",
1396
+ modelOverride: "gemini-2.5-pro",
1397
+ fallbackModelOverride: "gemini-2.5-flash",
1398
+ };
1399
+ }
1400
+ // Check for Claude labels
1401
+ if (lowercaseLabels.includes("opus")) {
1402
+ return {
1403
+ runnerType: "claude",
1404
+ modelOverride: "opus",
1405
+ fallbackModelOverride: "sonnet",
1406
+ };
1407
+ }
1408
+ if (lowercaseLabels.includes("sonnet")) {
1409
+ return {
1410
+ runnerType: "claude",
1411
+ modelOverride: "sonnet",
1412
+ fallbackModelOverride: "haiku",
1413
+ };
1414
+ }
1415
+ // Default to claude if no runner labels found
1416
+ return {
1417
+ runnerType: "claude",
1418
+ modelOverride: "sonnet",
1419
+ fallbackModelOverride: "haiku",
1420
+ };
1421
+ }
1287
1422
  /**
1288
1423
  * Determine system prompt based on issue labels and repository configuration
1289
1424
  */
@@ -1373,10 +1508,10 @@ export class EdgeWorker extends EventEmitter {
1373
1508
  console.warn(`[EdgeWorker] Failed to fetch assignee details:`, error);
1374
1509
  }
1375
1510
  // Get LinearClient for this repository
1376
- const linearClient = this.linearClients.get(repository.id);
1377
- if (!linearClient) {
1378
- console.error(`No LinearClient found for repository ${repository.id}`);
1379
- throw new Error(`No LinearClient found for repository ${repository.id}`);
1511
+ const issueTracker = this.issueTrackers.get(repository.id);
1512
+ if (!issueTracker) {
1513
+ console.error(`No IssueTrackerService found for repository ${repository.id}`);
1514
+ throw new Error(`No IssueTrackerService found for repository ${repository.id}`);
1380
1515
  }
1381
1516
  // Fetch workspace teams and labels
1382
1517
  let workspaceTeams = "";
@@ -1384,7 +1519,7 @@ export class EdgeWorker extends EventEmitter {
1384
1519
  try {
1385
1520
  console.log(`[EdgeWorker] Fetching workspace teams and labels for repository ${repository.id}`);
1386
1521
  // Fetch teams
1387
- const teamsConnection = await linearClient.teams();
1522
+ const teamsConnection = await issueTracker.fetchTeams();
1388
1523
  const teamsArray = [];
1389
1524
  for (const team of teamsConnection.nodes) {
1390
1525
  teamsArray.push({
@@ -1399,7 +1534,7 @@ export class EdgeWorker extends EventEmitter {
1399
1534
  .map((team) => `- ${team.name} (${team.key}): ${team.id}${team.description ? ` - ${team.description}` : ""}`)
1400
1535
  .join("\n");
1401
1536
  // Fetch labels
1402
- const labelsConnection = await linearClient.issueLabels();
1537
+ const labelsConnection = await issueTracker.fetchLabels();
1403
1538
  const labelsArray = [];
1404
1539
  for (const label of labelsConnection.nodes) {
1405
1540
  labelsArray.push({
@@ -1721,14 +1856,12 @@ ${reply.body}
1721
1856
  // Determine the base branch considering parent issues
1722
1857
  const baseBranch = await this.determineBaseBranch(issue, repository);
1723
1858
  // Get formatted comment threads
1724
- const linearClient = this.linearClients.get(repository.id);
1859
+ const issueTracker = this.issueTrackers.get(repository.id);
1725
1860
  let commentThreads = "No comments yet.";
1726
- if (linearClient && issue.id) {
1861
+ if (issueTracker && issue.id) {
1727
1862
  try {
1728
1863
  console.log(`[EdgeWorker] Fetching comments for issue ${issue.identifier}`);
1729
- const comments = await linearClient.comments({
1730
- filter: { issue: { id: { eq: issue.id } } },
1731
- });
1864
+ const comments = await issueTracker.fetchComments(issue.id);
1732
1865
  const commentNodes = comments.nodes;
1733
1866
  if (commentNodes.length > 0) {
1734
1867
  commentThreads = await this.formatCommentThreads(commentNodes);
@@ -1771,11 +1904,9 @@ IMPORTANT: Focus specifically on addressing the new comment above. This is a new
1771
1904
  // Now replace the new comment variables
1772
1905
  // We'll need to fetch the comment author
1773
1906
  let authorName = "Unknown";
1774
- if (linearClient) {
1907
+ if (issueTracker) {
1775
1908
  try {
1776
- const fullComment = await linearClient.comment({
1777
- id: newComment.id,
1778
- });
1909
+ const fullComment = await issueTracker.fetchComment(newComment.id);
1779
1910
  const user = await fullComment.user;
1780
1911
  authorName =
1781
1912
  user?.displayName || user?.name || user?.email || "Unknown";
@@ -1876,13 +2007,13 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ""}Please ana
1876
2007
  /**
1877
2008
  * Move issue to started state when assigned
1878
2009
  * @param issue Full Linear issue object from Linear SDK
1879
- * @param repositoryId Repository ID for Linear client lookup
2010
+ * @param repositoryId Repository ID for issue tracker lookup
1880
2011
  */
1881
2012
  async moveIssueToStartedState(issue, repositoryId) {
1882
2013
  try {
1883
- const linearClient = this.linearClients.get(repositoryId);
1884
- if (!linearClient) {
1885
- console.warn(`No Linear client found for repository ${repositoryId}, skipping state update`);
2014
+ const issueTracker = this.issueTrackers.get(repositoryId);
2015
+ if (!issueTracker) {
2016
+ console.warn(`No issue tracker found for repository ${repositoryId}, skipping state update`);
1886
2017
  return;
1887
2018
  }
1888
2019
  // Check if issue is already in a started state
@@ -1898,9 +2029,7 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ""}Please ana
1898
2029
  return;
1899
2030
  }
1900
2031
  // Get available workflow states for the issue's team
1901
- const teamStates = await linearClient.workflowStates({
1902
- filter: { team: { id: { eq: team.id } } },
1903
- });
2032
+ const teamStates = await issueTracker.fetchWorkflowStates(team.id);
1904
2033
  const states = teamStates;
1905
2034
  // Find all states with type "started" and pick the one with lowest position
1906
2035
  // This ensures we pick "In Progress" over "In Review" when both have type "started"
@@ -1916,7 +2045,7 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ""}Please ana
1916
2045
  console.warn(`Issue ${issue.identifier} has no ID, skipping state update`);
1917
2046
  return;
1918
2047
  }
1919
- await linearClient.updateIssue(issue.id, {
2048
+ await issueTracker.updateIssue(issue.id, {
1920
2049
  stateId: startedState.id,
1921
2050
  });
1922
2051
  console.log(`✅ Successfully moved issue ${issue.identifier} to ${startedState.name} state`);
@@ -1931,35 +2060,33 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ""}Please ana
1931
2060
  */
1932
2061
  // private async postInitialComment(issueId: string, repositoryId: string): Promise<void> {
1933
2062
  // const body = "I'm getting started right away."
1934
- // // Get the Linear client for this repository
1935
- // const linearClient = this.linearClients.get(repositoryId)
1936
- // if (!linearClient) {
1937
- // throw new Error(`No Linear client found for repository ${repositoryId}`)
2063
+ // // Get the issue tracker for this repository
2064
+ // const issueTracker = this.issueTrackers.get(repositoryId)
2065
+ // if (!issueTracker) {
2066
+ // throw new Error(`No issue tracker found for repository ${repositoryId}`)
1938
2067
  // }
1939
2068
  // const commentData = {
1940
- // issueId,
1941
2069
  // body
1942
2070
  // }
1943
- // await linearClient.createComment(commentData)
2071
+ // await issueTracker.createComment(commentData)
1944
2072
  // }
1945
2073
  /**
1946
2074
  * Post a comment to Linear
1947
2075
  */
1948
2076
  async postComment(issueId, body, repositoryId, parentId) {
1949
- // Get the Linear client for this repository
1950
- const linearClient = this.linearClients.get(repositoryId);
1951
- if (!linearClient) {
1952
- throw new Error(`No Linear client found for repository ${repositoryId}`);
2077
+ // Get the issue tracker for this repository
2078
+ const issueTracker = this.issueTrackers.get(repositoryId);
2079
+ if (!issueTracker) {
2080
+ throw new Error(`No issue tracker found for repository ${repositoryId}`);
1953
2081
  }
1954
- const commentData = {
1955
- issueId,
2082
+ const commentInput = {
1956
2083
  body,
1957
2084
  };
1958
2085
  // Add parent ID if provided (for reply)
1959
2086
  if (parentId) {
1960
- commentData.parentId = parentId;
2087
+ commentInput.parentId = parentId;
1961
2088
  }
1962
- await linearClient.createComment(commentData);
2089
+ await issueTracker.createComment(issueId, commentInput);
1963
2090
  }
1964
2091
  /**
1965
2092
  * Format todos as Linear checklist markdown
@@ -2008,10 +2135,10 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ""}Please ana
2008
2135
  const descriptionUrls = this.extractAttachmentUrls(issue.description || "");
2009
2136
  // Extract URLs from comments if available
2010
2137
  const commentUrls = [];
2011
- const linearClient = this.linearClients.get(repository.id);
2138
+ const issueTracker = this.issueTrackers.get(repository.id);
2012
2139
  // Fetch native Linear attachments (e.g., Sentry links)
2013
2140
  const nativeAttachments = [];
2014
- if (linearClient && issue.id) {
2141
+ if (issueTracker && issue.id) {
2015
2142
  try {
2016
2143
  // Fetch native attachments using Linear SDK
2017
2144
  console.log(`[EdgeWorker] Fetching native attachments for issue ${issue.identifier}`);
@@ -2030,9 +2157,7 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ""}Please ana
2030
2157
  console.error("Failed to fetch native attachments:", error);
2031
2158
  }
2032
2159
  try {
2033
- const comments = await linearClient.comments({
2034
- filter: { issue: { id: { eq: issue.id } } },
2035
- });
2160
+ const comments = await issueTracker.fetchComments(issue.id);
2036
2161
  const commentNodes = comments.nodes;
2037
2162
  for (const comment of commentNodes) {
2038
2163
  const urls = this.extractAttachmentUrls(comment.body);
@@ -2364,7 +2489,7 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ""}Please ana
2364
2489
  let childRepo;
2365
2490
  let childAgentSessionManager;
2366
2491
  for (const [repoId, manager] of this.agentSessionManagers) {
2367
- if (manager.hasClaudeRunner(childSessionId)) {
2492
+ if (manager.hasAgentRunner(childSessionId)) {
2368
2493
  childRepo = this.repositories.get(repoId);
2369
2494
  childAgentSessionManager = manager;
2370
2495
  break;
@@ -2395,13 +2520,13 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ""}Please ana
2395
2520
  }
2396
2521
  }
2397
2522
  // Post thought to Linear showing feedback receipt
2398
- const linearClient = this.linearClients.get(childRepo.id);
2399
- if (linearClient) {
2523
+ const issueTracker = this.issueTrackers.get(childRepo.id);
2524
+ if (issueTracker) {
2400
2525
  const feedbackThought = parentIssueId
2401
2526
  ? `Received feedback from orchestrator (${parentIssueId}):\n\n---\n\n${message}\n\n---`
2402
2527
  : `Received feedback from orchestrator:\n\n---\n\n${message}\n\n---`;
2403
2528
  try {
2404
- const result = await linearClient.createAgentActivity({
2529
+ const result = await issueTracker.createAgentActivity({
2405
2530
  agentSessionId: childSessionId,
2406
2531
  content: {
2407
2532
  type: "thought",
@@ -2748,9 +2873,11 @@ ${input.userComment}
2748
2873
  attachmentManifest, guidance);
2749
2874
  }
2750
2875
  /**
2751
- * Build Claude runner configuration with common settings
2876
+ * Build agent runner configuration with common settings.
2877
+ * Also determines which runner type to use based on labels.
2878
+ * @returns Object containing the runner config and runner type to use
2752
2879
  */
2753
- buildClaudeRunnerConfig(session, repository, linearAgentActivitySessionId, systemPrompt, allowedTools, allowedDirectories, disallowedTools, resumeSessionId, labels, maxTurns) {
2880
+ buildAgentRunnerConfig(session, repository, linearAgentActivitySessionId, systemPrompt, allowedTools, allowedDirectories, disallowedTools, resumeSessionId, labels, maxTurns, singleTurn) {
2754
2881
  // Configure PostToolUse hook for playwright screenshots
2755
2882
  const hooks = {
2756
2883
  PostToolUse: [
@@ -2769,38 +2896,30 @@ ${input.userComment}
2769
2896
  },
2770
2897
  ],
2771
2898
  };
2772
- // Check for model override labels (case-insensitive)
2773
- let modelOverride;
2774
- let fallbackModelOverride;
2775
- if (labels && labels.length > 0) {
2776
- const lowercaseLabels = labels.map((label) => label.toLowerCase());
2777
- // Check for model override labels: opus, sonnet, haiku
2778
- if (lowercaseLabels.includes("opus")) {
2779
- modelOverride = "opus";
2780
- console.log(`[EdgeWorker] Model override via label: opus (for session ${linearAgentActivitySessionId})`);
2781
- }
2782
- else if (lowercaseLabels.includes("sonnet")) {
2783
- modelOverride = "sonnet";
2784
- console.log(`[EdgeWorker] Model override via label: sonnet (for session ${linearAgentActivitySessionId})`);
2785
- }
2786
- else if (lowercaseLabels.includes("haiku")) {
2787
- modelOverride = "haiku";
2788
- console.log(`[EdgeWorker] Model override via label: haiku (for session ${linearAgentActivitySessionId})`);
2789
- }
2790
- // If a model override is found, also set a reasonable fallback
2791
- if (modelOverride) {
2792
- // Set fallback to the next lower tier: opus->sonnet, sonnet->haiku, haiku->sonnet
2793
- if (modelOverride === "opus") {
2794
- fallbackModelOverride = "sonnet";
2795
- }
2796
- else if (modelOverride === "sonnet") {
2797
- fallbackModelOverride = "haiku";
2798
- }
2799
- else {
2800
- fallbackModelOverride = "sonnet"; // haiku falls back to sonnet since same model retry doesn't help
2801
- }
2802
- }
2803
- }
2899
+ // Determine runner type and model override from labels
2900
+ const runnerSelection = this.determineRunnerFromLabels(labels || []);
2901
+ let runnerType = runnerSelection.runnerType;
2902
+ let modelOverride = runnerSelection.modelOverride;
2903
+ let fallbackModelOverride = runnerSelection.fallbackModelOverride;
2904
+ // If the labels have changed, and we are resuming a session. Use the existing runner for the session.
2905
+ if (session.claudeSessionId && runnerType !== "claude") {
2906
+ runnerType = "claude";
2907
+ modelOverride = "sonnet";
2908
+ fallbackModelOverride = "haiku";
2909
+ }
2910
+ else if (session.geminiSessionId && runnerType !== "gemini") {
2911
+ runnerType = "gemini";
2912
+ modelOverride = "gemini-2.5-pro";
2913
+ fallbackModelOverride = "gemini-2.5-flash";
2914
+ }
2915
+ // Log model override if found
2916
+ if (modelOverride) {
2917
+ console.log(`[EdgeWorker] Model override via label: ${modelOverride} (for session ${linearAgentActivitySessionId})`);
2918
+ }
2919
+ // Convert singleTurn flag to effective maxTurns value
2920
+ const effectiveMaxTurns = singleTurn ? 1 : maxTurns;
2921
+ // Determine final model name with singleTurn suffix for Gemini
2922
+ const finalModel = modelOverride || repository.model || this.config.defaultModel;
2804
2923
  const config = {
2805
2924
  workingDirectory: session.workspace.path,
2806
2925
  allowedTools,
@@ -2812,7 +2931,7 @@ ${input.userComment}
2812
2931
  mcpConfig: this.buildMcpConfig(repository, linearAgentActivitySessionId),
2813
2932
  appendSystemPrompt: systemPrompt || "",
2814
2933
  // Priority order: label override > repository config > global default
2815
- model: modelOverride || repository.model || this.config.defaultModel,
2934
+ model: finalModel,
2816
2935
  fallbackModel: fallbackModelOverride ||
2817
2936
  repository.fallbackModel ||
2818
2937
  this.config.defaultFallbackModel,
@@ -2825,10 +2944,13 @@ ${input.userComment}
2825
2944
  if (resumeSessionId) {
2826
2945
  config.resumeSessionId = resumeSessionId;
2827
2946
  }
2828
- if (maxTurns !== undefined) {
2829
- config.maxTurns = maxTurns;
2947
+ if (effectiveMaxTurns !== undefined) {
2948
+ config.maxTurns = effectiveMaxTurns;
2949
+ if (singleTurn) {
2950
+ console.log(`[EdgeWorker] Applied singleTurn maxTurns=1 (for session ${linearAgentActivitySessionId})`);
2951
+ }
2830
2952
  }
2831
- return config;
2953
+ return { config, runnerType };
2832
2954
  }
2833
2955
  /**
2834
2956
  * Build disallowed tools list following the same hierarchy as allowed tools
@@ -3024,9 +3146,9 @@ ${input.userComment}
3024
3146
  */
3025
3147
  async postInstantAcknowledgment(linearAgentActivitySessionId, repositoryId) {
3026
3148
  try {
3027
- const linearClient = this.linearClients.get(repositoryId);
3028
- if (!linearClient) {
3029
- console.warn(`[EdgeWorker] No Linear client found for repository ${repositoryId}`);
3149
+ const issueTracker = this.issueTrackers.get(repositoryId);
3150
+ if (!issueTracker) {
3151
+ console.warn(`[EdgeWorker] No issue tracker found for repository ${repositoryId}`);
3030
3152
  return;
3031
3153
  }
3032
3154
  const activityInput = {
@@ -3036,7 +3158,7 @@ ${input.userComment}
3036
3158
  body: "I've received your request and I'm starting to work on it. Let me analyze the issue and prepare my approach.",
3037
3159
  },
3038
3160
  };
3039
- const result = await linearClient.createAgentActivity(activityInput);
3161
+ const result = await issueTracker.createAgentActivity(activityInput);
3040
3162
  if (result.success) {
3041
3163
  console.log(`[EdgeWorker] Posted instant acknowledgment thought for session ${linearAgentActivitySessionId}`);
3042
3164
  }
@@ -3053,9 +3175,9 @@ ${input.userComment}
3053
3175
  */
3054
3176
  async postParentResumeAcknowledgment(linearAgentActivitySessionId, repositoryId) {
3055
3177
  try {
3056
- const linearClient = this.linearClients.get(repositoryId);
3057
- if (!linearClient) {
3058
- console.warn(`[EdgeWorker] No Linear client found for repository ${repositoryId}`);
3178
+ const issueTracker = this.issueTrackers.get(repositoryId);
3179
+ if (!issueTracker) {
3180
+ console.warn(`[EdgeWorker] No issue tracker found for repository ${repositoryId}`);
3059
3181
  return;
3060
3182
  }
3061
3183
  const activityInput = {
@@ -3065,7 +3187,7 @@ ${input.userComment}
3065
3187
  body: "Resuming from child session",
3066
3188
  },
3067
3189
  };
3068
- const result = await linearClient.createAgentActivity(activityInput);
3190
+ const result = await issueTracker.createAgentActivity(activityInput);
3069
3191
  if (result.success) {
3070
3192
  console.log(`[EdgeWorker] Posted parent resumption acknowledgment thought for session ${linearAgentActivitySessionId}`);
3071
3193
  }
@@ -3083,9 +3205,9 @@ ${input.userComment}
3083
3205
  */
3084
3206
  async postRepositorySelectionActivity(linearAgentActivitySessionId, repositoryId, repositoryName, selectionMethod) {
3085
3207
  try {
3086
- const linearClient = this.linearClients.get(repositoryId);
3087
- if (!linearClient) {
3088
- console.warn(`[EdgeWorker] No Linear client found for repository ${repositoryId}`);
3208
+ const issueTracker = this.issueTrackers.get(repositoryId);
3209
+ if (!issueTracker) {
3210
+ console.warn(`[EdgeWorker] No issue tracker found for repository ${repositoryId}`);
3089
3211
  return;
3090
3212
  }
3091
3213
  let methodDisplay;
@@ -3117,7 +3239,7 @@ ${input.userComment}
3117
3239
  body: `Repository "${repositoryName}" has been ${methodDisplay}.`,
3118
3240
  },
3119
3241
  };
3120
- const result = await linearClient.createAgentActivity(activityInput);
3242
+ const result = await issueTracker.createAgentActivity(activityInput);
3121
3243
  if (result.success) {
3122
3244
  console.log(`[EdgeWorker] Posted repository selection activity for session ${linearAgentActivitySessionId} (${selectionMethod})`);
3123
3245
  }
@@ -3141,11 +3263,11 @@ ${input.userComment}
3141
3263
  // Post ephemeral "Routing..." thought
3142
3264
  await agentSessionManager.postRoutingThought(linearAgentActivitySessionId);
3143
3265
  // Fetch full issue and labels to check for Orchestrator label override
3144
- const linearClient = this.linearClients.get(repository.id);
3266
+ const issueTracker = this.issueTrackers.get(repository.id);
3145
3267
  let hasOrchestratorLabel = false;
3146
- if (linearClient) {
3268
+ if (issueTracker) {
3147
3269
  try {
3148
- const fullIssue = await linearClient.issue(session.issueId);
3270
+ const fullIssue = await issueTracker.fetchIssue(session.issueId);
3149
3271
  const labels = await this.fetchIssueLabels(fullIssue);
3150
3272
  // Check for Orchestrator label (same logic as initial routing)
3151
3273
  const orchestratorConfig = repository.labelPrompts?.orchestrator;
@@ -3208,19 +3330,21 @@ ${input.userComment}
3208
3330
  * @returns true if message was added to stream, false if session was resumed
3209
3331
  */
3210
3332
  async handlePromptWithStreamingCheck(session, repository, linearAgentActivitySessionId, agentSessionManager, promptBody, attachmentManifest, isNewSession, additionalAllowedDirs, logContext, commentAuthor, commentTimestamp) {
3211
- // Check if runner is actively streaming before routing
3212
- const existingRunner = session.claudeRunner;
3213
- const isStreaming = existingRunner?.isStreaming() || false;
3214
- // Always route procedure for new input, UNLESS actively streaming
3215
- if (!isStreaming) {
3333
+ // Check if runner is actively running before routing
3334
+ const existingRunner = session.agentRunner;
3335
+ const isRunning = existingRunner?.isRunning() || false;
3336
+ // Always route procedure for new input, UNLESS actively running
3337
+ if (!isRunning) {
3216
3338
  await this.rerouteProcedureForSession(session, linearAgentActivitySessionId, agentSessionManager, promptBody, repository);
3217
3339
  console.log(`[EdgeWorker] Routed procedure for ${logContext}`);
3218
3340
  }
3219
3341
  else {
3220
- console.log(`[EdgeWorker] Skipping routing for ${linearAgentActivitySessionId} (${logContext}) - runner is actively streaming`);
3342
+ console.log(`[EdgeWorker] Skipping routing for ${linearAgentActivitySessionId} (${logContext}) - runner is actively running`);
3221
3343
  }
3222
- // Handle streaming case - add message to existing stream
3223
- if (existingRunner?.isStreaming()) {
3344
+ // Handle running case - add message to existing stream (if supported)
3345
+ if (existingRunner?.isRunning() &&
3346
+ existingRunner.supportsStreamingInput &&
3347
+ existingRunner.addStreamMessage) {
3224
3348
  console.log(`[EdgeWorker] Adding prompt to existing stream for ${linearAgentActivitySessionId} (${logContext})`);
3225
3349
  // Append attachment manifest to the prompt if we have one
3226
3350
  let fullPrompt = promptBody;
@@ -3232,7 +3356,7 @@ ${input.userComment}
3232
3356
  }
3233
3357
  // Not streaming - resume/start session
3234
3358
  console.log(`[EdgeWorker] Resuming Claude session for ${linearAgentActivitySessionId} (${logContext})`);
3235
- await this.resumeClaudeSession(session, repository, linearAgentActivitySessionId, agentSessionManager, promptBody, attachmentManifest, isNewSession, additionalAllowedDirs, undefined, // maxTurns
3359
+ await this.resumeAgentSession(session, repository, linearAgentActivitySessionId, agentSessionManager, promptBody, attachmentManifest, isNewSession, additionalAllowedDirs, undefined, // maxTurns
3236
3360
  commentAuthor, commentTimestamp);
3237
3361
  return false; // Session was resumed
3238
3362
  }
@@ -3241,9 +3365,9 @@ ${input.userComment}
3241
3365
  */
3242
3366
  async postSystemPromptSelectionThought(linearAgentActivitySessionId, labels, repositoryId) {
3243
3367
  try {
3244
- const linearClient = this.linearClients.get(repositoryId);
3245
- if (!linearClient) {
3246
- console.warn(`[EdgeWorker] No Linear client found for repository ${repositoryId}`);
3368
+ const issueTracker = this.issueTrackers.get(repositoryId);
3369
+ if (!issueTracker) {
3370
+ console.warn(`[EdgeWorker] No issue tracker found for repository ${repositoryId}`);
3247
3371
  return;
3248
3372
  }
3249
3373
  // Determine which prompt type was selected and which label triggered it
@@ -3309,7 +3433,7 @@ ${input.userComment}
3309
3433
  body: `Entering '${selectedPromptType}' mode because of the '${triggerLabel}' label. I'll follow the ${selectedPromptType} process...`,
3310
3434
  },
3311
3435
  };
3312
- const result = await linearClient.createAgentActivity(activityInput);
3436
+ const result = await issueTracker.createAgentActivity(activityInput);
3313
3437
  if (result.success) {
3314
3438
  console.log(`[EdgeWorker] Posted system prompt selection thought for session ${linearAgentActivitySessionId} (${selectedPromptType} mode)`);
3315
3439
  }
@@ -3322,7 +3446,7 @@ ${input.userComment}
3322
3446
  }
3323
3447
  }
3324
3448
  /**
3325
- * Resume or create a Claude session with the given prompt
3449
+ * Resume or create an Agent session with the given prompt
3326
3450
  * This is the core logic for handling prompted agent activities
3327
3451
  * @param session The Cyrus agent session
3328
3452
  * @param repository The repository configuration
@@ -3332,11 +3456,13 @@ ${input.userComment}
3332
3456
  * @param attachmentManifest Optional attachment manifest
3333
3457
  * @param isNewSession Whether this is a new session
3334
3458
  */
3335
- async resumeClaudeSession(session, repository, linearAgentActivitySessionId, agentSessionManager, promptBody, attachmentManifest = "", isNewSession = false, additionalAllowedDirectories = [], maxTurns, commentAuthor, commentTimestamp) {
3459
+ async resumeAgentSession(session, repository, linearAgentActivitySessionId, agentSessionManager, promptBody, attachmentManifest = "", isNewSession = false, additionalAllowedDirectories = [], maxTurns, commentAuthor, commentTimestamp) {
3336
3460
  // Check for existing runner
3337
- const existingRunner = session.claudeRunner;
3338
- // If there's an existing streaming runner, add to it
3339
- if (existingRunner?.isStreaming()) {
3461
+ const existingRunner = session.agentRunner;
3462
+ // If there's an existing running runner that supports streaming, add to it
3463
+ if (existingRunner?.isRunning() &&
3464
+ existingRunner.supportsStreamingInput &&
3465
+ existingRunner.addStreamMessage) {
3340
3466
  let fullPrompt = promptBody;
3341
3467
  if (attachmentManifest) {
3342
3468
  fullPrompt = `${promptBody}\n\n${attachmentManifest}`;
@@ -3344,20 +3470,23 @@ ${input.userComment}
3344
3470
  existingRunner.addStreamMessage(fullPrompt);
3345
3471
  return;
3346
3472
  }
3347
- // Stop existing runner if it's not streaming
3473
+ // Stop existing runner if it's not running
3348
3474
  if (existingRunner) {
3349
3475
  existingRunner.stop();
3350
3476
  }
3351
- // Determine if we need a new Claude session
3352
- const needsNewClaudeSession = isNewSession || !session.claudeSessionId;
3353
3477
  // Fetch full issue details
3354
3478
  const fullIssue = await this.fetchFullIssueDetails(session.issueId, repository.id);
3355
3479
  if (!fullIssue) {
3356
- console.error(`[resumeClaudeSession] Failed to fetch full issue details for ${session.issueId}`);
3480
+ console.error(`[resumeAgentSession] Failed to fetch full issue details for ${session.issueId}`);
3357
3481
  throw new Error(`Failed to fetch full issue details for ${session.issueId}`);
3358
3482
  }
3359
- // Fetch issue labels and determine system prompt
3483
+ // Fetch issue labels early to determine runner type
3360
3484
  const labels = await this.fetchIssueLabels(fullIssue);
3485
+ // Determine which runner to use based on existing session IDs
3486
+ const hasClaudeSession = !isNewSession && Boolean(session.claudeSessionId);
3487
+ const hasGeminiSession = !isNewSession && Boolean(session.geminiSessionId);
3488
+ const needsNewSession = isNewSession || (!hasClaudeSession && !hasGeminiSession);
3489
+ // Fetch system prompt based on labels
3361
3490
  const systemPromptResult = await this.determineSystemPromptFromLabels(labels, repository);
3362
3491
  const systemPrompt = systemPromptResult?.prompt;
3363
3492
  const promptType = systemPromptResult?.type;
@@ -3372,27 +3501,43 @@ ${input.userComment}
3372
3501
  await mkdir(attachmentsDir, { recursive: true });
3373
3502
  const allowedDirectories = [
3374
3503
  attachmentsDir,
3504
+ repository.repositoryPath,
3375
3505
  ...additionalAllowedDirectories,
3376
3506
  ];
3377
- // Create runner configuration
3378
- const resumeSessionId = needsNewClaudeSession
3507
+ // Get current subroutine to check for singleTurn mode
3508
+ const currentSubroutine = this.procedureRouter.getCurrentSubroutine(session);
3509
+ const resumeSessionId = needsNewSession
3379
3510
  ? undefined
3380
- : session.claudeSessionId;
3381
- const runnerConfig = this.buildClaudeRunnerConfig(session, repository, linearAgentActivitySessionId, systemPrompt, allowedTools, allowedDirectories, disallowedTools, resumeSessionId, labels, // Pass labels for model override
3382
- maxTurns);
3383
- const runner = new ClaudeRunner(runnerConfig);
3511
+ : session.claudeSessionId
3512
+ ? session.claudeSessionId
3513
+ : session.geminiSessionId;
3514
+ // Create runner configuration
3515
+ // buildAgentRunnerConfig determines runner type from labels for new sessions
3516
+ // For existing sessions, we still need labels for model override but ignore runner type
3517
+ const { config: runnerConfig, runnerType } = this.buildAgentRunnerConfig(session, repository, linearAgentActivitySessionId, systemPrompt, allowedTools, allowedDirectories, disallowedTools, resumeSessionId, labels, // Always pass labels to preserve model override
3518
+ maxTurns, // Pass maxTurns if specified
3519
+ currentSubroutine?.singleTurn);
3520
+ // Create the appropriate runner based on session state
3521
+ const runner = runnerType === "claude"
3522
+ ? new ClaudeRunner(runnerConfig)
3523
+ : new GeminiRunner(runnerConfig);
3384
3524
  // Store runner
3385
- agentSessionManager.addClaudeRunner(linearAgentActivitySessionId, runner);
3525
+ agentSessionManager.addAgentRunner(linearAgentActivitySessionId, runner);
3386
3526
  // Save state
3387
3527
  await this.savePersistedState();
3388
3528
  // Prepare the full prompt
3389
3529
  const fullPrompt = await this.buildSessionPrompt(isNewSession, session, fullIssue, repository, promptBody, attachmentManifest, commentAuthor, commentTimestamp);
3390
- // Start streaming session
3530
+ // Start session - use streaming mode if supported for ability to add messages later
3391
3531
  try {
3392
- await runner.startStreaming(fullPrompt);
3532
+ if (runner.supportsStreamingInput && runner.startStreaming) {
3533
+ await runner.startStreaming(fullPrompt);
3534
+ }
3535
+ else {
3536
+ await runner.start(fullPrompt);
3537
+ }
3393
3538
  }
3394
3539
  catch (error) {
3395
- console.error(`[resumeClaudeSession] Failed to start streaming session for ${linearAgentActivitySessionId}:`, error);
3540
+ console.error(`[resumeAgentSession] Failed to start streaming session for ${linearAgentActivitySessionId}:`, error);
3396
3541
  throw error;
3397
3542
  }
3398
3543
  }
@@ -3401,9 +3546,9 @@ ${input.userComment}
3401
3546
  */
3402
3547
  async postInstantPromptedAcknowledgment(linearAgentActivitySessionId, repositoryId, isStreaming) {
3403
3548
  try {
3404
- const linearClient = this.linearClients.get(repositoryId);
3405
- if (!linearClient) {
3406
- console.warn(`[EdgeWorker] No Linear client found for repository ${repositoryId}`);
3549
+ const issueTracker = this.issueTrackers.get(repositoryId);
3550
+ if (!issueTracker) {
3551
+ console.warn(`[EdgeWorker] No issue tracker found for repository ${repositoryId}`);
3407
3552
  return;
3408
3553
  }
3409
3554
  const message = isStreaming
@@ -3416,7 +3561,7 @@ ${input.userComment}
3416
3561
  body: message,
3417
3562
  },
3418
3563
  };
3419
- const result = await linearClient.createAgentActivity(activityInput);
3564
+ const result = await issueTracker.createAgentActivity(activityInput);
3420
3565
  if (result.success) {
3421
3566
  console.log(`[EdgeWorker] Posted instant prompted acknowledgment thought for session ${linearAgentActivitySessionId} (streaming: ${isStreaming})`);
3422
3567
  }
@@ -3432,14 +3577,14 @@ ${input.userComment}
3432
3577
  * Fetch complete issue details from Linear API
3433
3578
  */
3434
3579
  async fetchFullIssueDetails(issueId, repositoryId) {
3435
- const linearClient = this.linearClients.get(repositoryId);
3436
- if (!linearClient) {
3437
- console.warn(`[EdgeWorker] No Linear client found for repository ${repositoryId}`);
3580
+ const issueTracker = this.issueTrackers.get(repositoryId);
3581
+ if (!issueTracker) {
3582
+ console.warn(`[EdgeWorker] No issue tracker found for repository ${repositoryId}`);
3438
3583
  return null;
3439
3584
  }
3440
3585
  try {
3441
3586
  console.log(`[EdgeWorker] Fetching full issue details for ${issueId}`);
3442
- const fullIssue = await linearClient.issue(issueId);
3587
+ const fullIssue = await issueTracker.fetchIssue(issueId);
3443
3588
  console.log(`[EdgeWorker] Successfully fetched issue details for ${issueId}`);
3444
3589
  // Check if issue has a parent
3445
3590
  try {