cyrus-edge-worker 0.2.3 → 0.2.5

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.
@@ -7,6 +7,7 @@ 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 { GeminiRunner } from "cyrus-gemini-runner";
10
11
  import { LinearEventTransport, LinearIssueTrackerService, } from "cyrus-linear-event-transport";
11
12
  import { fileTypeFromBuffer } from "file-type";
12
13
  import { AgentSessionManager } from "./AgentSessionManager.js";
@@ -22,7 +23,7 @@ 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
+ agentSessionManagers = new Map(); // Maps repository ID to AgentSessionManager, which manages agent runners for a repo
26
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
@@ -40,11 +41,13 @@ 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 = {
@@ -122,47 +125,11 @@ export class EdgeWorker extends EventEmitter {
122
125
  return parentId;
123
126
  }, async (parentSessionId, prompt, childSessionId) => {
124
127
  await this.handleResumeParentSession(parentSessionId, prompt, childSessionId, repo, agentSessionManager);
125
- }, async (linearAgentActivitySessionId) => {
126
- console.log(`[Subroutine Transition] Advancing to next subroutine for session ${linearAgentActivitySessionId}`);
127
- // Get the session
128
- const session = agentSessionManager.getSession(linearAgentActivitySessionId);
129
- if (!session) {
130
- console.error(`[Subroutine Transition] Session ${linearAgentActivitySessionId} not found`);
131
- return;
132
- }
133
- // Get next subroutine (advancement already handled by AgentSessionManager)
134
- const nextSubroutine = this.procedureRouter.getCurrentSubroutine(session);
135
- if (!nextSubroutine) {
136
- console.log(`[Subroutine Transition] Procedure complete for session ${linearAgentActivitySessionId}`);
137
- return;
138
- }
139
- console.log(`[Subroutine Transition] Next subroutine: ${nextSubroutine.name}`);
140
- // Load subroutine prompt
141
- let subroutinePrompt;
142
- try {
143
- subroutinePrompt = await this.loadSubroutinePrompt(nextSubroutine, this.config.linearWorkspaceSlug);
144
- if (!subroutinePrompt) {
145
- // Fallback if loadSubroutinePrompt returns null
146
- subroutinePrompt = `Continue with: ${nextSubroutine.description}`;
147
- }
148
- }
149
- catch (error) {
150
- console.error(`[Subroutine Transition] Failed to load subroutine prompt:`, error);
151
- // Fallback to simple prompt
152
- subroutinePrompt = `Continue with: ${nextSubroutine.description}`;
153
- }
154
- // Resume Claude session with subroutine prompt
155
- try {
156
- await this.resumeClaudeSession(session, repo, linearAgentActivitySessionId, agentSessionManager, subroutinePrompt, "", // No attachment manifest
157
- false, // Not a new session
158
- [], // No additional allowed directories
159
- nextSubroutine.maxTurns);
160
- console.log(`[Subroutine Transition] Successfully resumed session for ${nextSubroutine.name} subroutine${nextSubroutine.maxTurns ? ` (maxTurns=${nextSubroutine.maxTurns})` : ""}`);
161
- }
162
- catch (error) {
163
- console.error(`[Subroutine Transition] Failed to resume session for ${nextSubroutine.name} subroutine:`, error);
164
- }
165
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
+ });
166
133
  this.agentSessionManagers.set(repo.id, agentSessionManager);
167
134
  }
168
135
  }
@@ -243,13 +210,13 @@ export class EdgeWorker extends EventEmitter {
243
210
  catch (error) {
244
211
  console.error("❌ Failed to save EdgeWorker state during shutdown:", error);
245
212
  }
246
- // get all claudeRunners
247
- const claudeRunners = [];
213
+ // get all agent runners
214
+ const agentRunners = [];
248
215
  for (const agentSessionManager of this.agentSessionManagers.values()) {
249
- claudeRunners.push(...agentSessionManager.getAllClaudeRunners());
216
+ agentRunners.push(...agentSessionManager.getAllAgentRunners());
250
217
  }
251
- // Kill all Claude processes with null checking
252
- for (const runner of claudeRunners) {
218
+ // Kill all agent processes with null checking
219
+ for (const runner of agentRunners) {
253
220
  if (runner) {
254
221
  try {
255
222
  runner.stop();
@@ -335,6 +302,45 @@ export class EdgeWorker extends EventEmitter {
335
302
  console.error(`[Parent Session Resume] Error context - Parent issue: ${parentSession.issueId}, Repository: ${repo.name}`);
336
303
  }
337
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
+ }
338
344
  /**
339
345
  * Start watching config file for changes
340
346
  */
@@ -510,8 +516,11 @@ export class EdgeWorker extends EventEmitter {
510
516
  return this.childToParentAgentSession.get(childSessionId);
511
517
  }, async (parentSessionId, prompt, childSessionId) => {
512
518
  await this.handleResumeParentSession(parentSessionId, prompt, childSessionId, repo, agentSessionManager);
513
- }, undefined, // No resumeNextSubroutine callback for dynamically added repos
514
- 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
+ });
515
524
  this.agentSessionManagers.set(repo.id, agentSessionManager);
516
525
  console.log(`✅ Repository added successfully: ${repo.name}`);
517
526
  }
@@ -592,10 +601,10 @@ export class EdgeWorker extends EventEmitter {
592
601
  for (const session of activeSessions) {
593
602
  try {
594
603
  console.log(` 🛑 Stopping session for issue ${session.issueId}`);
595
- // Get the Claude runner for this session
596
- const runner = manager?.getClaudeRunner(session.linearAgentActivitySessionId);
604
+ // Get the agent runner for this session
605
+ const runner = manager?.getAgentRunner(session.linearAgentActivitySessionId);
597
606
  if (runner) {
598
- // Stop the Claude process
607
+ // Stop the agent process
599
608
  runner.stop();
600
609
  console.log(` ✅ Stopped Claude runner for session ${session.linearAgentActivitySessionId}`);
601
610
  }
@@ -754,7 +763,10 @@ export class EdgeWorker extends EventEmitter {
754
763
  const attachmentsDir = join(this.cyrusHome, workspaceFolderName, "attachments");
755
764
  await mkdir(attachmentsDir, { recursive: true });
756
765
  // Build allowed directories list - always include attachments directory
757
- const allowedDirectories = [attachmentsDir];
766
+ const allowedDirectories = [
767
+ attachmentsDir,
768
+ repository.repositoryPath,
769
+ ];
758
770
  console.log(`[EdgeWorker] Configured allowed directories for ${fullIssue.identifier}:`, allowedDirectories);
759
771
  // Build allowed tools list with Linear MCP tools
760
772
  const allowedTools = this.buildAllowedTools(repository);
@@ -821,14 +833,14 @@ export class EdgeWorker extends EventEmitter {
821
833
  console.log(`[EdgeWorker] Handling agent session created: ${webhook.agentSession.issue.identifier}`);
822
834
  const { agentSession, guidance } = webhook;
823
835
  const commentBody = agentSession.comment?.body;
824
- // Initialize Claude runner using shared logic
825
- await this.initializeClaudeRunner(agentSession, repository, guidance, commentBody);
836
+ // Initialize agent runner using shared logic
837
+ await this.initializeAgentRunner(agentSession, repository, guidance, commentBody);
826
838
  }
827
839
  /**
828
840
 
829
841
  /**
830
- * Initialize and start Claude runner for an agent session
831
- * 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
832
844
  * handleAgentSessionCreatedWebhook and handleUserPromptedAgentActivity use.
833
845
  *
834
846
  * @param agentSession The Linear agent session
@@ -836,7 +848,7 @@ export class EdgeWorker extends EventEmitter {
836
848
  * @param guidance Optional guidance rules from Linear
837
849
  * @param commentBody Optional comment body (for mentions)
838
850
  */
839
- async initializeClaudeRunner(agentSession, repository, guidance, commentBody) {
851
+ async initializeAgentRunner(agentSession, repository, guidance, commentBody) {
840
852
  const linearAgentActivitySessionId = agentSession.id;
841
853
  const { issue } = agentSession;
842
854
  if (!issue) {
@@ -973,28 +985,47 @@ export class EdgeWorker extends EventEmitter {
973
985
  if (disallowedTools.length > 0) {
974
986
  console.log(`[EdgeWorker] Configured disallowed tools for ${fullIssue.identifier}:`, disallowedTools);
975
987
  }
976
- // Create Claude runner with system prompt from assembly
977
- const runnerConfig = this.buildClaudeRunnerConfig(session, repository, linearAgentActivitySessionId, assembly.systemPrompt, allowedTools, allowedDirectories, disallowedTools, undefined, // resumeSessionId
978
- labels);
979
- 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);
980
1000
  // Store runner by comment ID
981
- agentSessionManager.addClaudeRunner(linearAgentActivitySessionId, runner);
1001
+ agentSessionManager.addAgentRunner(linearAgentActivitySessionId, runner);
982
1002
  // Save state after mapping changes
983
1003
  await this.savePersistedState();
984
1004
  // Emit events using full Linear issue
985
1005
  this.emit("session:started", fullIssue.id, fullIssue, repository.id);
986
1006
  this.config.handlers?.onSessionStart?.(fullIssue.id, fullIssue, repository.id);
987
1007
  // Update runner with version information (if available)
988
- if (systemPromptVersion) {
1008
+ // Note: updatePromptVersions is specific to ClaudeRunner
1009
+ if (systemPromptVersion &&
1010
+ "updatePromptVersions" in runner &&
1011
+ typeof runner.updatePromptVersions === "function") {
989
1012
  runner.updatePromptVersions({
990
1013
  systemPromptVersion,
991
1014
  });
992
1015
  }
993
1016
  // Log metadata for debugging
994
1017
  console.log(`[EdgeWorker] Initial prompt built successfully - components: ${assembly.metadata.components.join(", ")}, type: ${assembly.metadata.promptType}, length: ${assembly.userPrompt.length} characters`);
995
- console.log(`[EdgeWorker] Starting Claude streaming session`);
996
- const sessionInfo = await runner.startStreaming(assembly.userPrompt);
997
- 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
+ }
998
1029
  // Note: AgentSessionManager will be initialized automatically when the first system message
999
1030
  // is received via handleClaudeMessage() callback
1000
1031
  }
@@ -1032,10 +1063,10 @@ export class EdgeWorker extends EventEmitter {
1032
1063
  return;
1033
1064
  }
1034
1065
  // Stop the existing runner if it's active
1035
- const existingRunner = foundSession.claudeRunner;
1066
+ const existingRunner = foundSession.agentRunner;
1036
1067
  if (existingRunner) {
1037
1068
  existingRunner.stop();
1038
- console.log(`[EdgeWorker] Stopped Claude session for agent activity session ${agentSessionId}`);
1069
+ console.log(`[EdgeWorker] Stopped agent session for agent activity session ${agentSessionId}`);
1039
1070
  }
1040
1071
  // Post confirmation
1041
1072
  const issueTitle = issue?.title || "this issue";
@@ -1075,9 +1106,9 @@ export class EdgeWorker extends EventEmitter {
1075
1106
  this.repositoryRouter.getIssueRepositoryCache().set(issueId, repository.id);
1076
1107
  // Post agent activity showing user-selected repository
1077
1108
  await this.postRepositorySelectionActivity(agentSessionId, repository.id, repository.name, "user-selected");
1078
- console.log(`[EdgeWorker] Initializing Claude runner after repository selection: ${agentSession.issue.identifier} -> ${repository.name}`);
1079
- // Initialize Claude runner with the selected repository
1080
- 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);
1081
1112
  }
1082
1113
  /**
1083
1114
  * Handle normal prompted activity (existing session continuation)
@@ -1124,8 +1155,8 @@ export class EdgeWorker extends EventEmitter {
1124
1155
  else {
1125
1156
  console.log(`[EdgeWorker] Found existing session ${linearAgentActivitySessionId} for new user prompt`);
1126
1157
  // Post instant acknowledgment for existing session BEFORE any async work
1127
- // Check streaming status first to determine the message
1128
- 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;
1129
1160
  await this.postInstantPromptedAcknowledgment(linearAgentActivitySessionId, repository.id, isCurrentlyStreaming);
1130
1161
  // Need to fetch full issue for routing context
1131
1162
  const issueTracker = this.issueTrackers.get(repository.id);
@@ -1259,12 +1290,12 @@ export class EdgeWorker extends EventEmitter {
1259
1290
  console.log("No agentSessionManager for unassigned issue, so no sessions to stop");
1260
1291
  return;
1261
1292
  }
1262
- // Get all Claude runners for this specific issue
1263
- const claudeRunners = agentSessionManager.getClaudeRunnersForIssue(issue.id);
1264
- // Stop all Claude runners for this issue
1265
- const activeThreadCount = claudeRunners.length;
1266
- for (const runner of claudeRunners) {
1267
- 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}`);
1268
1299
  runner.stop();
1269
1300
  }
1270
1301
  // Post ONE farewell comment on the issue (not in any thread) if there were active sessions
@@ -1308,6 +1339,86 @@ export class EdgeWorker extends EventEmitter {
1308
1339
  return [];
1309
1340
  }
1310
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
+ }
1311
1422
  /**
1312
1423
  * Determine system prompt based on issue labels and repository configuration
1313
1424
  */
@@ -2378,7 +2489,7 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ""}Please ana
2378
2489
  let childRepo;
2379
2490
  let childAgentSessionManager;
2380
2491
  for (const [repoId, manager] of this.agentSessionManagers) {
2381
- if (manager.hasClaudeRunner(childSessionId)) {
2492
+ if (manager.hasAgentRunner(childSessionId)) {
2382
2493
  childRepo = this.repositories.get(repoId);
2383
2494
  childAgentSessionManager = manager;
2384
2495
  break;
@@ -2762,9 +2873,11 @@ ${input.userComment}
2762
2873
  attachmentManifest, guidance);
2763
2874
  }
2764
2875
  /**
2765
- * 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
2766
2879
  */
2767
- buildClaudeRunnerConfig(session, repository, linearAgentActivitySessionId, systemPrompt, allowedTools, allowedDirectories, disallowedTools, resumeSessionId, labels, maxTurns) {
2880
+ buildAgentRunnerConfig(session, repository, linearAgentActivitySessionId, systemPrompt, allowedTools, allowedDirectories, disallowedTools, resumeSessionId, labels, maxTurns, singleTurn) {
2768
2881
  // Configure PostToolUse hook for playwright screenshots
2769
2882
  const hooks = {
2770
2883
  PostToolUse: [
@@ -2783,38 +2896,30 @@ ${input.userComment}
2783
2896
  },
2784
2897
  ],
2785
2898
  };
2786
- // Check for model override labels (case-insensitive)
2787
- let modelOverride;
2788
- let fallbackModelOverride;
2789
- if (labels && labels.length > 0) {
2790
- const lowercaseLabels = labels.map((label) => label.toLowerCase());
2791
- // Check for model override labels: opus, sonnet, haiku
2792
- if (lowercaseLabels.includes("opus")) {
2793
- modelOverride = "opus";
2794
- console.log(`[EdgeWorker] Model override via label: opus (for session ${linearAgentActivitySessionId})`);
2795
- }
2796
- else if (lowercaseLabels.includes("sonnet")) {
2797
- modelOverride = "sonnet";
2798
- console.log(`[EdgeWorker] Model override via label: sonnet (for session ${linearAgentActivitySessionId})`);
2799
- }
2800
- else if (lowercaseLabels.includes("haiku")) {
2801
- modelOverride = "haiku";
2802
- console.log(`[EdgeWorker] Model override via label: haiku (for session ${linearAgentActivitySessionId})`);
2803
- }
2804
- // If a model override is found, also set a reasonable fallback
2805
- if (modelOverride) {
2806
- // Set fallback to the next lower tier: opus->sonnet, sonnet->haiku, haiku->sonnet
2807
- if (modelOverride === "opus") {
2808
- fallbackModelOverride = "sonnet";
2809
- }
2810
- else if (modelOverride === "sonnet") {
2811
- fallbackModelOverride = "haiku";
2812
- }
2813
- else {
2814
- fallbackModelOverride = "sonnet"; // haiku falls back to sonnet since same model retry doesn't help
2815
- }
2816
- }
2817
- }
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;
2818
2923
  const config = {
2819
2924
  workingDirectory: session.workspace.path,
2820
2925
  allowedTools,
@@ -2826,7 +2931,7 @@ ${input.userComment}
2826
2931
  mcpConfig: this.buildMcpConfig(repository, linearAgentActivitySessionId),
2827
2932
  appendSystemPrompt: systemPrompt || "",
2828
2933
  // Priority order: label override > repository config > global default
2829
- model: modelOverride || repository.model || this.config.defaultModel,
2934
+ model: finalModel,
2830
2935
  fallbackModel: fallbackModelOverride ||
2831
2936
  repository.fallbackModel ||
2832
2937
  this.config.defaultFallbackModel,
@@ -2839,10 +2944,13 @@ ${input.userComment}
2839
2944
  if (resumeSessionId) {
2840
2945
  config.resumeSessionId = resumeSessionId;
2841
2946
  }
2842
- if (maxTurns !== undefined) {
2843
- 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
+ }
2844
2952
  }
2845
- return config;
2953
+ return { config, runnerType };
2846
2954
  }
2847
2955
  /**
2848
2956
  * Build disallowed tools list following the same hierarchy as allowed tools
@@ -3222,19 +3330,21 @@ ${input.userComment}
3222
3330
  * @returns true if message was added to stream, false if session was resumed
3223
3331
  */
3224
3332
  async handlePromptWithStreamingCheck(session, repository, linearAgentActivitySessionId, agentSessionManager, promptBody, attachmentManifest, isNewSession, additionalAllowedDirs, logContext, commentAuthor, commentTimestamp) {
3225
- // Check if runner is actively streaming before routing
3226
- const existingRunner = session.claudeRunner;
3227
- const isStreaming = existingRunner?.isStreaming() || false;
3228
- // Always route procedure for new input, UNLESS actively streaming
3229
- 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) {
3230
3338
  await this.rerouteProcedureForSession(session, linearAgentActivitySessionId, agentSessionManager, promptBody, repository);
3231
3339
  console.log(`[EdgeWorker] Routed procedure for ${logContext}`);
3232
3340
  }
3233
3341
  else {
3234
- 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`);
3235
3343
  }
3236
- // Handle streaming case - add message to existing stream
3237
- if (existingRunner?.isStreaming()) {
3344
+ // Handle running case - add message to existing stream (if supported)
3345
+ if (existingRunner?.isRunning() &&
3346
+ existingRunner.supportsStreamingInput &&
3347
+ existingRunner.addStreamMessage) {
3238
3348
  console.log(`[EdgeWorker] Adding prompt to existing stream for ${linearAgentActivitySessionId} (${logContext})`);
3239
3349
  // Append attachment manifest to the prompt if we have one
3240
3350
  let fullPrompt = promptBody;
@@ -3246,7 +3356,7 @@ ${input.userComment}
3246
3356
  }
3247
3357
  // Not streaming - resume/start session
3248
3358
  console.log(`[EdgeWorker] Resuming Claude session for ${linearAgentActivitySessionId} (${logContext})`);
3249
- 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
3250
3360
  commentAuthor, commentTimestamp);
3251
3361
  return false; // Session was resumed
3252
3362
  }
@@ -3336,7 +3446,7 @@ ${input.userComment}
3336
3446
  }
3337
3447
  }
3338
3448
  /**
3339
- * Resume or create a Claude session with the given prompt
3449
+ * Resume or create an Agent session with the given prompt
3340
3450
  * This is the core logic for handling prompted agent activities
3341
3451
  * @param session The Cyrus agent session
3342
3452
  * @param repository The repository configuration
@@ -3346,11 +3456,13 @@ ${input.userComment}
3346
3456
  * @param attachmentManifest Optional attachment manifest
3347
3457
  * @param isNewSession Whether this is a new session
3348
3458
  */
3349
- 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) {
3350
3460
  // Check for existing runner
3351
- const existingRunner = session.claudeRunner;
3352
- // If there's an existing streaming runner, add to it
3353
- 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) {
3354
3466
  let fullPrompt = promptBody;
3355
3467
  if (attachmentManifest) {
3356
3468
  fullPrompt = `${promptBody}\n\n${attachmentManifest}`;
@@ -3358,20 +3470,23 @@ ${input.userComment}
3358
3470
  existingRunner.addStreamMessage(fullPrompt);
3359
3471
  return;
3360
3472
  }
3361
- // Stop existing runner if it's not streaming
3473
+ // Stop existing runner if it's not running
3362
3474
  if (existingRunner) {
3363
3475
  existingRunner.stop();
3364
3476
  }
3365
- // Determine if we need a new Claude session
3366
- const needsNewClaudeSession = isNewSession || !session.claudeSessionId;
3367
3477
  // Fetch full issue details
3368
3478
  const fullIssue = await this.fetchFullIssueDetails(session.issueId, repository.id);
3369
3479
  if (!fullIssue) {
3370
- 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}`);
3371
3481
  throw new Error(`Failed to fetch full issue details for ${session.issueId}`);
3372
3482
  }
3373
- // Fetch issue labels and determine system prompt
3483
+ // Fetch issue labels early to determine runner type
3374
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
3375
3490
  const systemPromptResult = await this.determineSystemPromptFromLabels(labels, repository);
3376
3491
  const systemPrompt = systemPromptResult?.prompt;
3377
3492
  const promptType = systemPromptResult?.type;
@@ -3386,27 +3501,43 @@ ${input.userComment}
3386
3501
  await mkdir(attachmentsDir, { recursive: true });
3387
3502
  const allowedDirectories = [
3388
3503
  attachmentsDir,
3504
+ repository.repositoryPath,
3389
3505
  ...additionalAllowedDirectories,
3390
3506
  ];
3391
- // Create runner configuration
3392
- const resumeSessionId = needsNewClaudeSession
3507
+ // Get current subroutine to check for singleTurn mode
3508
+ const currentSubroutine = this.procedureRouter.getCurrentSubroutine(session);
3509
+ const resumeSessionId = needsNewSession
3393
3510
  ? undefined
3394
- : session.claudeSessionId;
3395
- const runnerConfig = this.buildClaudeRunnerConfig(session, repository, linearAgentActivitySessionId, systemPrompt, allowedTools, allowedDirectories, disallowedTools, resumeSessionId, labels, // Pass labels for model override
3396
- maxTurns);
3397
- 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);
3398
3524
  // Store runner
3399
- agentSessionManager.addClaudeRunner(linearAgentActivitySessionId, runner);
3525
+ agentSessionManager.addAgentRunner(linearAgentActivitySessionId, runner);
3400
3526
  // Save state
3401
3527
  await this.savePersistedState();
3402
3528
  // Prepare the full prompt
3403
3529
  const fullPrompt = await this.buildSessionPrompt(isNewSession, session, fullIssue, repository, promptBody, attachmentManifest, commentAuthor, commentTimestamp);
3404
- // Start streaming session
3530
+ // Start session - use streaming mode if supported for ability to add messages later
3405
3531
  try {
3406
- await runner.startStreaming(fullPrompt);
3532
+ if (runner.supportsStreamingInput && runner.startStreaming) {
3533
+ await runner.startStreaming(fullPrompt);
3534
+ }
3535
+ else {
3536
+ await runner.start(fullPrompt);
3537
+ }
3407
3538
  }
3408
3539
  catch (error) {
3409
- console.error(`[resumeClaudeSession] Failed to start streaming session for ${linearAgentActivitySessionId}:`, error);
3540
+ console.error(`[resumeAgentSession] Failed to start streaming session for ${linearAgentActivitySessionId}:`, error);
3410
3541
  throw error;
3411
3542
  }
3412
3543
  }