cyrus-edge-worker 0.0.36 → 0.0.38

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.
Files changed (45) hide show
  1. package/dist/AgentSessionManager.d.ts +26 -1
  2. package/dist/AgentSessionManager.d.ts.map +1 -1
  3. package/dist/AgentSessionManager.js +227 -30
  4. package/dist/AgentSessionManager.js.map +1 -1
  5. package/dist/EdgeWorker.d.ts +66 -3
  6. package/dist/EdgeWorker.d.ts.map +1 -1
  7. package/dist/EdgeWorker.js +660 -55
  8. package/dist/EdgeWorker.js.map +1 -1
  9. package/dist/SharedApplicationServer.d.ts +29 -4
  10. package/dist/SharedApplicationServer.d.ts.map +1 -1
  11. package/dist/SharedApplicationServer.js +262 -0
  12. package/dist/SharedApplicationServer.js.map +1 -1
  13. package/dist/index.d.ts +2 -3
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js.map +1 -1
  16. package/dist/procedures/ProcedureRouter.d.ts +60 -0
  17. package/dist/procedures/ProcedureRouter.d.ts.map +1 -0
  18. package/dist/procedures/ProcedureRouter.js +201 -0
  19. package/dist/procedures/ProcedureRouter.js.map +1 -0
  20. package/dist/procedures/index.d.ts +7 -0
  21. package/dist/procedures/index.d.ts.map +1 -0
  22. package/dist/procedures/index.js +7 -0
  23. package/dist/procedures/index.js.map +1 -0
  24. package/dist/procedures/registry.d.ts +76 -0
  25. package/dist/procedures/registry.d.ts.map +1 -0
  26. package/dist/procedures/registry.js +130 -0
  27. package/dist/procedures/registry.js.map +1 -0
  28. package/dist/procedures/types.d.ts +64 -0
  29. package/dist/procedures/types.d.ts.map +1 -0
  30. package/dist/procedures/types.js +5 -0
  31. package/dist/procedures/types.js.map +1 -0
  32. package/dist/prompts/subroutines/concise-summary.md +53 -0
  33. package/dist/prompts/subroutines/debugger-fix.md +108 -0
  34. package/dist/prompts/subroutines/debugger-reproduction.md +106 -0
  35. package/dist/prompts/subroutines/get-approval.md +175 -0
  36. package/dist/prompts/subroutines/git-gh.md +52 -0
  37. package/dist/prompts/subroutines/verbose-summary.md +46 -0
  38. package/dist/prompts/subroutines/verifications.md +46 -0
  39. package/dist/types.d.ts +0 -97
  40. package/dist/types.d.ts.map +1 -1
  41. package/package.json +8 -5
  42. package/prompt-template-v2.md +3 -19
  43. package/prompts/builder.md +1 -23
  44. package/prompts/debugger.md +11 -174
  45. package/prompts/orchestrator.md +41 -64
@@ -3,14 +3,15 @@ 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
5
  import { LinearClient, } from "@linear/sdk";
6
- import { ClaudeRunner, createCyrusToolsServer, getAllTools, getCoordinatorTools, getReadOnlyTools, getSafeTools, } from "cyrus-claude-runner";
6
+ import { watch as chokidarWatch } from "chokidar";
7
+ import { ClaudeRunner, createCyrusToolsServer, createImageToolsServer, createSoraToolsServer, getAllTools, getCoordinatorTools, getReadOnlyTools, getSafeTools, } from "cyrus-claude-runner";
7
8
  import { isAgentSessionCreatedWebhook, isAgentSessionPromptedWebhook, isIssueAssignedWebhook, isIssueCommentMentionWebhook, isIssueNewCommentWebhook, isIssueUnassignedWebhook, PersistenceManager, } from "cyrus-core";
8
9
  import { LinearWebhookClient } from "cyrus-linear-webhook-client";
9
10
  import { NdjsonClient } from "cyrus-ndjson-client";
10
11
  import { fileTypeFromBuffer } from "file-type";
11
12
  import { AgentSessionManager } from "./AgentSessionManager.js";
13
+ import { ProcedureRouter, } from "./procedures/index.js";
12
14
  import { SharedApplicationServer } from "./SharedApplicationServer.js";
13
- const LAST_MESSAGE_MARKER = "\n\nIMPORTANT: When providing your final summary response, include the special marker ___LAST_MESSAGE_MARKER___ at the very beginning of your message. This marker will be automatically removed before posting.";
14
15
  /**
15
16
  * Unified edge worker that **orchestrates**
16
17
  * capturing Linear webhooks,
@@ -27,11 +28,21 @@ export class EdgeWorker extends EventEmitter {
27
28
  sharedApplicationServer;
28
29
  cyrusHome;
29
30
  childToParentAgentSession = new Map(); // Maps child agentSessionId to parent agentSessionId
31
+ procedureRouter; // Intelligent workflow routing
32
+ configWatcher; // File watcher for config.json
33
+ configPath; // Path to config.json file
34
+ tokenToRepoIds = new Map(); // Maps Linear token to repository IDs using that token
30
35
  constructor(config) {
31
36
  super();
32
37
  this.config = config;
33
38
  this.cyrusHome = config.cyrusHome;
34
39
  this.persistenceManager = new PersistenceManager(join(this.cyrusHome, "state"));
40
+ // Initialize procedure router with haiku model for fast classification
41
+ this.procedureRouter = new ProcedureRouter({
42
+ cyrusHome: this.cyrusHome,
43
+ model: "haiku",
44
+ timeoutMs: 10000,
45
+ });
35
46
  console.log(`[EdgeWorker Constructor] Initializing parent-child session mapping system`);
36
47
  console.log(`[EdgeWorker Constructor] Parent-child mapping initialized with 0 entries`);
37
48
  // Initialize shared application server
@@ -67,40 +78,48 @@ export class EdgeWorker extends EventEmitter {
67
78
  console.log(`[Parent-Child Lookup] Child ${childSessionId} -> Parent ${parentId || "not found"}`);
68
79
  return parentId;
69
80
  }, async (parentSessionId, prompt, childSessionId) => {
70
- console.log(`[Parent Session Resume] Child session completed, resuming parent session ${parentSessionId}`);
71
- // Get the parent session and repository
72
- // This works because by the time this callback runs, agentSessionManager is fully initialized
73
- console.log(`[Parent Session Resume] Retrieving parent session ${parentSessionId} from agent session manager`);
74
- const parentSession = agentSessionManager.getSession(parentSessionId);
75
- if (!parentSession) {
76
- console.error(`[Parent Session Resume] Parent session ${parentSessionId} not found in agent session manager`);
81
+ await this.handleResumeParentSession(parentSessionId, prompt, childSessionId, repo, agentSessionManager);
82
+ }, async (linearAgentActivitySessionId) => {
83
+ console.log(`[Subroutine Transition] Advancing to next subroutine for session ${linearAgentActivitySessionId}`);
84
+ // Get the session
85
+ const session = agentSessionManager.getSession(linearAgentActivitySessionId);
86
+ if (!session) {
87
+ console.error(`[Subroutine Transition] Session ${linearAgentActivitySessionId} not found`);
77
88
  return;
78
89
  }
79
- console.log(`[Parent Session Resume] Found parent session - Issue: ${parentSession.issueId}, Workspace: ${parentSession.workspace.path}`);
80
- // Get the child session to access its workspace path
81
- const childSession = agentSessionManager.getSession(childSessionId);
82
- const childWorkspaceDirs = [];
83
- if (childSession) {
84
- childWorkspaceDirs.push(childSession.workspace.path);
85
- console.log(`[Parent Session Resume] Adding child workspace to parent allowed directories: ${childSession.workspace.path}`);
90
+ // Get next subroutine (advancement already handled by AgentSessionManager)
91
+ const nextSubroutine = this.procedureRouter.getCurrentSubroutine(session);
92
+ if (!nextSubroutine) {
93
+ console.log(`[Subroutine Transition] Procedure complete for session ${linearAgentActivitySessionId}`);
94
+ return;
86
95
  }
87
- else {
88
- console.warn(`[Parent Session Resume] Could not find child session ${childSessionId} to add workspace to parent allowed directories`);
96
+ console.log(`[Subroutine Transition] Next subroutine: ${nextSubroutine.name}`);
97
+ // Load subroutine prompt
98
+ const __filename = fileURLToPath(import.meta.url);
99
+ const __dirname = dirname(__filename);
100
+ const subroutinePromptPath = join(__dirname, "prompts", nextSubroutine.promptPath);
101
+ let subroutinePrompt;
102
+ try {
103
+ subroutinePrompt = await readFile(subroutinePromptPath, "utf-8");
104
+ console.log(`[Subroutine Transition] Loaded ${nextSubroutine.name} subroutine prompt (${subroutinePrompt.length} characters)`);
105
+ }
106
+ catch (error) {
107
+ console.error(`[Subroutine Transition] Failed to load subroutine prompt from ${subroutinePromptPath}:`, error);
108
+ // Fallback to simple prompt
109
+ subroutinePrompt = `Continue with: ${nextSubroutine.description}`;
89
110
  }
90
- await this.postParentResumeAcknowledgment(parentSessionId, repo.id);
91
- // Resume the parent session with the child's result
92
- console.log(`[Parent Session Resume] Resuming parent Claude session with child results`);
111
+ // Resume Claude session with subroutine prompt
93
112
  try {
94
- await this.resumeClaudeSession(parentSession, repo, parentSessionId, agentSessionManager, prompt, "", // No attachment manifest for child results
113
+ await this.resumeClaudeSession(session, repo, linearAgentActivitySessionId, agentSessionManager, subroutinePrompt, "", // No attachment manifest
95
114
  false, // Not a new session
96
- childWorkspaceDirs);
97
- console.log(`[Parent Session Resume] Successfully resumed parent session ${parentSessionId} with child results`);
115
+ [], // No additional allowed directories
116
+ nextSubroutine.maxTurns);
117
+ console.log(`[Subroutine Transition] Successfully resumed session for ${nextSubroutine.name} subroutine${nextSubroutine.maxTurns ? ` (maxTurns=${nextSubroutine.maxTurns})` : ""}`);
98
118
  }
99
119
  catch (error) {
100
- console.error(`[Parent Session Resume] Failed to resume parent session ${parentSessionId}:`, error);
101
- console.error(`[Parent Session Resume] Error context - Parent issue: ${parentSession.issueId}, Repository: ${repo.name}`);
120
+ console.error(`[Subroutine Transition] Failed to resume session for ${nextSubroutine.name} subroutine:`, error);
102
121
  }
103
- });
122
+ }, this.procedureRouter, this.sharedApplicationServer);
104
123
  this.agentSessionManagers.set(repo.id, agentSessionManager);
105
124
  }
106
125
  }
@@ -110,6 +129,12 @@ export class EdgeWorker extends EventEmitter {
110
129
  const repos = tokenToRepos.get(repo.linearToken) || [];
111
130
  repos.push(repo);
112
131
  tokenToRepos.set(repo.linearToken, repos);
132
+ // Track token-to-repo-id mapping for dynamic config updates
133
+ const repoIds = this.tokenToRepoIds.get(repo.linearToken) || [];
134
+ if (!repoIds.includes(repo.id)) {
135
+ repoIds.push(repo.id);
136
+ }
137
+ this.tokenToRepoIds.set(repo.linearToken, repoIds);
113
138
  }
114
139
  // Create one NDJSON client per unique token using shared application server
115
140
  for (const [token, repos] of tokenToRepos) {
@@ -144,12 +169,20 @@ export class EdgeWorker extends EventEmitter {
144
169
  const ndjsonClient = useLinearDirectWebhooks
145
170
  ? new LinearWebhookClient({
146
171
  ...clientConfig,
147
- onWebhook: (payload) => this.handleWebhook(payload, repos),
172
+ onWebhook: (payload) => {
173
+ // Get fresh repositories for this token to avoid stale closures
174
+ const freshRepos = this.getRepositoriesForToken(token);
175
+ this.handleWebhook(payload, freshRepos);
176
+ },
148
177
  })
149
178
  : new NdjsonClient(clientConfig);
150
179
  // Set up webhook handler for NdjsonClient (LinearWebhookClient uses onWebhook in constructor)
151
180
  if (!useLinearDirectWebhooks) {
152
- ndjsonClient.on("webhook", (data) => this.handleWebhook(data, repos));
181
+ ndjsonClient.on("webhook", (data) => {
182
+ // Get fresh repositories for this token to avoid stale closures
183
+ const freshRepos = this.getRepositoriesForToken(token);
184
+ this.handleWebhook(data, freshRepos);
185
+ });
153
186
  }
154
187
  // Optional heartbeat logging (only for NdjsonClient)
155
188
  if (process.env.DEBUG_EDGE === "true" && !useLinearDirectWebhooks) {
@@ -168,6 +201,10 @@ export class EdgeWorker extends EventEmitter {
168
201
  async start() {
169
202
  // Load persisted state for each repository
170
203
  await this.loadPersistedState();
204
+ // Start config file watcher if configPath is provided
205
+ if (this.configPath) {
206
+ this.startConfigWatcher();
207
+ }
171
208
  // Start shared application server first
172
209
  await this.sharedApplicationServer.start();
173
210
  // Connect all NDJSON clients
@@ -219,6 +256,12 @@ export class EdgeWorker extends EventEmitter {
219
256
  * Stop the edge worker
220
257
  */
221
258
  async stop() {
259
+ // Stop config file watcher
260
+ if (this.configWatcher) {
261
+ await this.configWatcher.close();
262
+ this.configWatcher = undefined;
263
+ console.log("✅ Config file watcher stopped");
264
+ }
222
265
  try {
223
266
  await this.savePersistedState();
224
267
  console.log("✅ EdgeWorker state saved successfully");
@@ -249,6 +292,450 @@ export class EdgeWorker extends EventEmitter {
249
292
  // Stop shared application server
250
293
  await this.sharedApplicationServer.stop();
251
294
  }
295
+ /**
296
+ * Set the config file path for dynamic reloading
297
+ */
298
+ setConfigPath(configPath) {
299
+ this.configPath = configPath;
300
+ }
301
+ /**
302
+ * Get fresh list of repositories for a given Linear token
303
+ * This ensures webhook handlers always work with current repository state
304
+ */
305
+ getRepositoriesForToken(token) {
306
+ const repoIds = this.tokenToRepoIds.get(token) || [];
307
+ const repos = [];
308
+ for (const repoId of repoIds) {
309
+ const repo = this.repositories.get(repoId);
310
+ if (repo) {
311
+ repos.push(repo);
312
+ }
313
+ }
314
+ return repos;
315
+ }
316
+ /**
317
+ * Handle resuming a parent session when a child session completes
318
+ * This is the core logic used by the resume parent session callback
319
+ * Extracted to reduce duplication between constructor and addNewRepositories
320
+ */
321
+ async handleResumeParentSession(parentSessionId, prompt, childSessionId, repo, agentSessionManager) {
322
+ console.log(`[Parent Session Resume] Child session completed, resuming parent session ${parentSessionId}`);
323
+ // Get the parent session and repository
324
+ console.log(`[Parent Session Resume] Retrieving parent session ${parentSessionId} from agent session manager`);
325
+ const parentSession = agentSessionManager.getSession(parentSessionId);
326
+ if (!parentSession) {
327
+ console.error(`[Parent Session Resume] Parent session ${parentSessionId} not found in agent session manager`);
328
+ return;
329
+ }
330
+ console.log(`[Parent Session Resume] Found parent session - Issue: ${parentSession.issueId}, Workspace: ${parentSession.workspace.path}`);
331
+ // Get the child session to access its workspace path
332
+ const childSession = agentSessionManager.getSession(childSessionId);
333
+ const childWorkspaceDirs = [];
334
+ if (childSession) {
335
+ childWorkspaceDirs.push(childSession.workspace.path);
336
+ console.log(`[Parent Session Resume] Adding child workspace to parent allowed directories: ${childSession.workspace.path}`);
337
+ }
338
+ else {
339
+ console.warn(`[Parent Session Resume] Could not find child session ${childSessionId} to add workspace to parent allowed directories`);
340
+ }
341
+ await this.postParentResumeAcknowledgment(parentSessionId, repo.id);
342
+ // Resume the parent session with the child's result
343
+ console.log(`[Parent Session Resume] Resuming parent Claude session with child results`);
344
+ try {
345
+ await this.resumeClaudeSession(parentSession, repo, parentSessionId, agentSessionManager, prompt, "", // No attachment manifest for child results
346
+ false, // Not a new session
347
+ childWorkspaceDirs);
348
+ console.log(`[Parent Session Resume] Successfully resumed parent session ${parentSessionId} with child results`);
349
+ }
350
+ catch (error) {
351
+ console.error(`[Parent Session Resume] Failed to resume parent session ${parentSessionId}:`, error);
352
+ console.error(`[Parent Session Resume] Error context - Parent issue: ${parentSession.issueId}, Repository: ${repo.name}`);
353
+ }
354
+ }
355
+ /**
356
+ * Start watching config file for changes
357
+ */
358
+ startConfigWatcher() {
359
+ if (!this.configPath) {
360
+ console.warn("⚠️ No config path set, skipping config file watcher");
361
+ return;
362
+ }
363
+ console.log(`👀 Watching config file for changes: ${this.configPath}`);
364
+ this.configWatcher = chokidarWatch(this.configPath, {
365
+ persistent: true,
366
+ ignoreInitial: true,
367
+ awaitWriteFinish: {
368
+ stabilityThreshold: 500,
369
+ pollInterval: 100,
370
+ },
371
+ });
372
+ this.configWatcher.on("change", async () => {
373
+ console.log("🔄 Config file changed, reloading...");
374
+ await this.handleConfigChange();
375
+ });
376
+ this.configWatcher.on("error", (error) => {
377
+ console.error("❌ Config watcher error:", error);
378
+ });
379
+ }
380
+ /**
381
+ * Handle configuration file changes
382
+ */
383
+ async handleConfigChange() {
384
+ try {
385
+ const newConfig = await this.loadConfigSafely();
386
+ if (!newConfig) {
387
+ return;
388
+ }
389
+ const changes = this.detectRepositoryChanges(newConfig);
390
+ if (changes.added.length === 0 &&
391
+ changes.modified.length === 0 &&
392
+ changes.removed.length === 0) {
393
+ console.log("ℹ️ No repository changes detected");
394
+ return;
395
+ }
396
+ console.log(`📊 Repository changes detected: ${changes.added.length} added, ${changes.modified.length} modified, ${changes.removed.length} removed`);
397
+ // Apply changes incrementally
398
+ await this.removeDeletedRepositories(changes.removed);
399
+ await this.updateModifiedRepositories(changes.modified);
400
+ await this.addNewRepositories(changes.added);
401
+ // Update config reference
402
+ this.config = newConfig;
403
+ console.log("✅ Configuration reloaded successfully");
404
+ }
405
+ catch (error) {
406
+ console.error("❌ Failed to reload configuration:", error);
407
+ }
408
+ }
409
+ /**
410
+ * Safely load configuration from file with validation
411
+ */
412
+ async loadConfigSafely() {
413
+ try {
414
+ if (!this.configPath) {
415
+ console.error("❌ No config path set");
416
+ return null;
417
+ }
418
+ const configContent = await readFile(this.configPath, "utf-8");
419
+ const parsedConfig = JSON.parse(configContent);
420
+ // Merge with current EdgeWorker config structure
421
+ const newConfig = {
422
+ ...this.config,
423
+ repositories: parsedConfig.repositories || [],
424
+ ngrokAuthToken: parsedConfig.ngrokAuthToken || this.config.ngrokAuthToken,
425
+ defaultModel: parsedConfig.defaultModel || this.config.defaultModel,
426
+ defaultFallbackModel: parsedConfig.defaultFallbackModel || this.config.defaultFallbackModel,
427
+ defaultAllowedTools: parsedConfig.defaultAllowedTools || this.config.defaultAllowedTools,
428
+ defaultDisallowedTools: parsedConfig.defaultDisallowedTools ||
429
+ this.config.defaultDisallowedTools,
430
+ };
431
+ // Basic validation
432
+ if (!Array.isArray(newConfig.repositories)) {
433
+ console.error("❌ Invalid config: repositories must be an array");
434
+ return null;
435
+ }
436
+ // Validate each repository has required fields
437
+ for (const repo of newConfig.repositories) {
438
+ if (!repo.id ||
439
+ !repo.name ||
440
+ !repo.repositoryPath ||
441
+ !repo.baseBranch) {
442
+ console.error(`❌ Invalid repository config: missing required fields (id, name, repositoryPath, baseBranch)`, repo);
443
+ return null;
444
+ }
445
+ }
446
+ return newConfig;
447
+ }
448
+ catch (error) {
449
+ console.error("❌ Failed to load config file:", error);
450
+ return null;
451
+ }
452
+ }
453
+ /**
454
+ * Detect changes between current and new repository configurations
455
+ */
456
+ detectRepositoryChanges(newConfig) {
457
+ const currentRepos = new Map(this.repositories);
458
+ const newRepos = new Map(newConfig.repositories.map((r) => [r.id, r]));
459
+ const added = [];
460
+ const modified = [];
461
+ const removed = [];
462
+ // Find added and modified repositories
463
+ for (const [id, repo] of newRepos) {
464
+ if (!currentRepos.has(id)) {
465
+ added.push(repo);
466
+ }
467
+ else {
468
+ const currentRepo = currentRepos.get(id);
469
+ if (currentRepo && !this.deepEqual(currentRepo, repo)) {
470
+ modified.push(repo);
471
+ }
472
+ }
473
+ }
474
+ // Find removed repositories
475
+ for (const [id, repo] of currentRepos) {
476
+ if (!newRepos.has(id)) {
477
+ removed.push(repo);
478
+ }
479
+ }
480
+ return { added, modified, removed };
481
+ }
482
+ /**
483
+ * Deep equality check for repository configs
484
+ */
485
+ deepEqual(obj1, obj2) {
486
+ return JSON.stringify(obj1) === JSON.stringify(obj2);
487
+ }
488
+ /**
489
+ * Add new repositories to the running EdgeWorker
490
+ */
491
+ async addNewRepositories(repos) {
492
+ for (const repo of repos) {
493
+ if (repo.isActive === false) {
494
+ console.log(`⏭️ Skipping inactive repository: ${repo.name}`);
495
+ continue;
496
+ }
497
+ try {
498
+ console.log(`➕ Adding repository: ${repo.name} (${repo.id})`);
499
+ // Add to internal map
500
+ this.repositories.set(repo.id, repo);
501
+ // Create Linear client
502
+ const linearClient = new LinearClient({
503
+ accessToken: repo.linearToken,
504
+ });
505
+ this.linearClients.set(repo.id, linearClient);
506
+ // Create AgentSessionManager with same pattern as constructor
507
+ const agentSessionManager = new AgentSessionManager(linearClient, (childSessionId) => {
508
+ return this.childToParentAgentSession.get(childSessionId);
509
+ }, async (parentSessionId, prompt, childSessionId) => {
510
+ await this.handleResumeParentSession(parentSessionId, prompt, childSessionId, repo, agentSessionManager);
511
+ }, undefined, // No resumeNextSubroutine callback for dynamically added repos
512
+ this.procedureRouter, this.sharedApplicationServer);
513
+ this.agentSessionManagers.set(repo.id, agentSessionManager);
514
+ // Update token-to-repo mapping
515
+ const repoIds = this.tokenToRepoIds.get(repo.linearToken) || [];
516
+ if (!repoIds.includes(repo.id)) {
517
+ repoIds.push(repo.id);
518
+ }
519
+ this.tokenToRepoIds.set(repo.linearToken, repoIds);
520
+ // Set up webhook listener
521
+ await this.setupWebhookListener(repo);
522
+ console.log(`✅ Repository added successfully: ${repo.name}`);
523
+ }
524
+ catch (error) {
525
+ console.error(`❌ Failed to add repository ${repo.name}:`, error);
526
+ }
527
+ }
528
+ }
529
+ /**
530
+ * Update existing repositories
531
+ */
532
+ async updateModifiedRepositories(repos) {
533
+ for (const repo of repos) {
534
+ try {
535
+ const oldRepo = this.repositories.get(repo.id);
536
+ if (!oldRepo) {
537
+ console.warn(`⚠️ Repository ${repo.id} not found for update, skipping`);
538
+ continue;
539
+ }
540
+ console.log(`🔄 Updating repository: ${repo.name} (${repo.id})`);
541
+ // Update stored config
542
+ this.repositories.set(repo.id, repo);
543
+ // If token changed, recreate Linear client
544
+ if (oldRepo.linearToken !== repo.linearToken) {
545
+ console.log(` 🔑 Token changed, recreating Linear client`);
546
+ const linearClient = new LinearClient({
547
+ accessToken: repo.linearToken,
548
+ });
549
+ this.linearClients.set(repo.id, linearClient);
550
+ // Update token mapping
551
+ const oldRepoIds = this.tokenToRepoIds.get(oldRepo.linearToken) || [];
552
+ const filteredOldIds = oldRepoIds.filter((id) => id !== repo.id);
553
+ if (filteredOldIds.length > 0) {
554
+ this.tokenToRepoIds.set(oldRepo.linearToken, filteredOldIds);
555
+ }
556
+ else {
557
+ this.tokenToRepoIds.delete(oldRepo.linearToken);
558
+ }
559
+ const newRepoIds = this.tokenToRepoIds.get(repo.linearToken) || [];
560
+ if (!newRepoIds.includes(repo.id)) {
561
+ newRepoIds.push(repo.id);
562
+ }
563
+ this.tokenToRepoIds.set(repo.linearToken, newRepoIds);
564
+ // Reconnect webhook if needed
565
+ await this.reconnectWebhook(oldRepo, repo);
566
+ }
567
+ // If active status changed
568
+ if (oldRepo.isActive !== repo.isActive) {
569
+ if (repo.isActive === false) {
570
+ console.log(` ⏸️ Repository set to inactive - existing sessions will continue`);
571
+ }
572
+ else {
573
+ console.log(` ▶️ Repository reactivated`);
574
+ await this.setupWebhookListener(repo);
575
+ }
576
+ }
577
+ console.log(`✅ Repository updated successfully: ${repo.name}`);
578
+ }
579
+ catch (error) {
580
+ console.error(`❌ Failed to update repository ${repo.name}:`, error);
581
+ }
582
+ }
583
+ }
584
+ /**
585
+ * Remove deleted repositories
586
+ */
587
+ async removeDeletedRepositories(repos) {
588
+ for (const repo of repos) {
589
+ try {
590
+ console.log(`🗑️ Removing repository: ${repo.name} (${repo.id})`);
591
+ // Check for active sessions
592
+ const manager = this.agentSessionManagers.get(repo.id);
593
+ const activeSessions = manager?.getActiveSessions() || [];
594
+ if (activeSessions.length > 0) {
595
+ console.warn(` ⚠️ Repository has ${activeSessions.length} active sessions - stopping them`);
596
+ // Stop all active sessions and notify Linear
597
+ for (const session of activeSessions) {
598
+ try {
599
+ console.log(` 🛑 Stopping session for issue ${session.issueId}`);
600
+ // Get the Claude runner for this session
601
+ const runner = manager?.getClaudeRunner(session.linearAgentActivitySessionId);
602
+ if (runner) {
603
+ // Stop the Claude process
604
+ runner.stop();
605
+ console.log(` ✅ Stopped Claude runner for session ${session.linearAgentActivitySessionId}`);
606
+ }
607
+ // Post cancellation message to Linear
608
+ const linearClient = this.linearClients.get(repo.id);
609
+ if (linearClient) {
610
+ await linearClient.createAgentActivity({
611
+ agentSessionId: session.linearAgentActivitySessionId,
612
+ content: {
613
+ type: "response",
614
+ body: `**Repository Removed from Configuration**\n\nThis repository (\`${repo.name}\`) has been removed from the Cyrus configuration. All active sessions for this repository have been stopped.\n\nIf you need to continue working on this issue, please contact your administrator to restore the repository configuration.`,
615
+ },
616
+ });
617
+ console.log(` 📤 Posted cancellation message to Linear for issue ${session.issueId}`);
618
+ }
619
+ }
620
+ catch (error) {
621
+ console.error(` ❌ Failed to stop session ${session.linearAgentActivitySessionId}:`, error);
622
+ }
623
+ }
624
+ }
625
+ // Remove repository from all maps
626
+ this.repositories.delete(repo.id);
627
+ this.linearClients.delete(repo.id);
628
+ this.agentSessionManagers.delete(repo.id);
629
+ // Update token mapping
630
+ const repoIds = this.tokenToRepoIds.get(repo.linearToken) || [];
631
+ const filteredIds = repoIds.filter((id) => id !== repo.id);
632
+ if (filteredIds.length > 0) {
633
+ this.tokenToRepoIds.set(repo.linearToken, filteredIds);
634
+ }
635
+ else {
636
+ this.tokenToRepoIds.delete(repo.linearToken);
637
+ }
638
+ // Clean up webhook listener if no other repos use the same token
639
+ await this.cleanupWebhookIfUnused(repo);
640
+ console.log(`✅ Repository removed successfully: ${repo.name}`);
641
+ }
642
+ catch (error) {
643
+ console.error(`❌ Failed to remove repository ${repo.name}:`, error);
644
+ }
645
+ }
646
+ }
647
+ /**
648
+ * Set up webhook listener for a repository
649
+ */
650
+ async setupWebhookListener(repo) {
651
+ // Check if we already have a client for this token
652
+ const existingRepoIds = this.tokenToRepoIds.get(repo.linearToken) || [];
653
+ const existingClient = existingRepoIds.length > 0
654
+ ? this.ndjsonClients.get(existingRepoIds[0] || "")
655
+ : null;
656
+ if (existingClient) {
657
+ console.log(` ℹ️ Reusing existing webhook connection for token ...${repo.linearToken.slice(-4)}`);
658
+ return;
659
+ }
660
+ // Create new NDJSON client for this token
661
+ const serverPort = this.config.serverPort || this.config.webhookPort || 3456;
662
+ const serverHost = this.config.serverHost || "localhost";
663
+ const useLinearDirectWebhooks = process.env.LINEAR_DIRECT_WEBHOOKS?.toLowerCase().trim() === "true";
664
+ const clientConfig = {
665
+ proxyUrl: this.config.proxyUrl,
666
+ token: repo.linearToken,
667
+ name: repo.name,
668
+ transport: "webhook",
669
+ useExternalWebhookServer: true,
670
+ externalWebhookServer: this.sharedApplicationServer,
671
+ webhookPort: serverPort,
672
+ webhookPath: "/webhook",
673
+ webhookHost: serverHost,
674
+ ...(this.config.baseUrl && { webhookBaseUrl: this.config.baseUrl }),
675
+ ...(!this.config.baseUrl &&
676
+ this.config.webhookBaseUrl && {
677
+ webhookBaseUrl: this.config.webhookBaseUrl,
678
+ }),
679
+ onConnect: () => this.handleConnect(repo.id, [repo]),
680
+ onDisconnect: (reason) => this.handleDisconnect(repo.id, [repo], reason),
681
+ onError: (error) => this.handleError(error),
682
+ };
683
+ const ndjsonClient = useLinearDirectWebhooks
684
+ ? new LinearWebhookClient({
685
+ ...clientConfig,
686
+ onWebhook: (payload) => {
687
+ // Get fresh repositories for this token to avoid stale closures
688
+ const freshRepos = this.getRepositoriesForToken(repo.linearToken);
689
+ this.handleWebhook(payload, freshRepos);
690
+ },
691
+ })
692
+ : new NdjsonClient(clientConfig);
693
+ if (!useLinearDirectWebhooks) {
694
+ ndjsonClient.on("webhook", (data) => {
695
+ // Get fresh repositories for this token to avoid stale closures
696
+ const freshRepos = this.getRepositoriesForToken(repo.linearToken);
697
+ this.handleWebhook(data, freshRepos);
698
+ });
699
+ }
700
+ this.ndjsonClients.set(repo.id, ndjsonClient);
701
+ // Connect the client
702
+ try {
703
+ await ndjsonClient.connect();
704
+ console.log(` ✅ Webhook listener connected for ${repo.name}`);
705
+ }
706
+ catch (error) {
707
+ console.error(` ❌ Failed to connect webhook listener:`, error);
708
+ }
709
+ }
710
+ /**
711
+ * Reconnect webhook when token changes
712
+ */
713
+ async reconnectWebhook(oldRepo, newRepo) {
714
+ console.log(` 🔌 Reconnecting webhook due to token change`);
715
+ // Disconnect old client if no other repos use it
716
+ await this.cleanupWebhookIfUnused(oldRepo);
717
+ // Set up new connection
718
+ await this.setupWebhookListener(newRepo);
719
+ }
720
+ /**
721
+ * Clean up webhook listener if no other repositories use the token
722
+ */
723
+ async cleanupWebhookIfUnused(repo) {
724
+ const repoIds = this.tokenToRepoIds.get(repo.linearToken) || [];
725
+ const otherRepos = repoIds.filter((id) => id !== repo.id);
726
+ if (otherRepos.length === 0) {
727
+ // No other repos use this token, safe to disconnect
728
+ const client = this.ndjsonClients.get(repo.id);
729
+ if (client) {
730
+ console.log(` 🔌 Disconnecting webhook for token ...${repo.linearToken.slice(-4)}`);
731
+ client.disconnect();
732
+ this.ndjsonClients.delete(repo.id);
733
+ }
734
+ }
735
+ else {
736
+ console.log(` ℹ️ Token still used by ${otherRepos.length} other repository(ies), keeping connection`);
737
+ }
738
+ }
252
739
  /**
253
740
  * Handle connection established
254
741
  */
@@ -563,8 +1050,62 @@ export class EdgeWorker extends EventEmitter {
563
1050
  const sessionData = await this.createLinearAgentSession(linearAgentActivitySessionId, issue, repository, agentSessionManager);
564
1051
  // Destructure the session data (excluding allowedTools which we'll build with promptType)
565
1052
  const { session, fullIssue, workspace: _workspace, attachmentResult, attachmentsDir: _attachmentsDir, allowedDirectories, } = sessionData;
566
- // Fetch labels (needed for both model selection and system prompt determination)
1053
+ // Initialize procedure metadata using intelligent routing
1054
+ if (!session.metadata) {
1055
+ session.metadata = {};
1056
+ }
1057
+ // Post ephemeral "Routing..." thought
1058
+ await agentSessionManager.postRoutingThought(linearAgentActivitySessionId);
1059
+ // Fetch labels early (needed for label override check)
567
1060
  const labels = await this.fetchIssueLabels(fullIssue);
1061
+ // Check for label overrides BEFORE AI routing
1062
+ const debuggerConfig = repository.labelPrompts?.debugger;
1063
+ const debuggerLabels = Array.isArray(debuggerConfig)
1064
+ ? debuggerConfig
1065
+ : debuggerConfig?.labels;
1066
+ const hasDebuggerLabel = debuggerLabels?.some((label) => labels.includes(label));
1067
+ const orchestratorConfig = repository.labelPrompts?.orchestrator;
1068
+ const orchestratorLabels = Array.isArray(orchestratorConfig)
1069
+ ? orchestratorConfig
1070
+ : orchestratorConfig?.labels;
1071
+ const hasOrchestratorLabel = orchestratorLabels?.some((label) => labels.includes(label));
1072
+ let finalProcedure;
1073
+ let finalClassification;
1074
+ // If labels indicate a specific procedure, use that instead of AI routing
1075
+ if (hasDebuggerLabel) {
1076
+ const debuggerProcedure = this.procedureRouter.getProcedure("debugger-full");
1077
+ if (!debuggerProcedure) {
1078
+ throw new Error("debugger-full procedure not found in registry");
1079
+ }
1080
+ finalProcedure = debuggerProcedure;
1081
+ finalClassification = "debugger";
1082
+ console.log(`[EdgeWorker] Using debugger-full procedure due to debugger label (skipping AI routing)`);
1083
+ }
1084
+ else if (hasOrchestratorLabel) {
1085
+ const orchestratorProcedure = this.procedureRouter.getProcedure("orchestrator-full");
1086
+ if (!orchestratorProcedure) {
1087
+ throw new Error("orchestrator-full procedure not found in registry");
1088
+ }
1089
+ finalProcedure = orchestratorProcedure;
1090
+ finalClassification = "orchestrator";
1091
+ console.log(`[EdgeWorker] Using orchestrator-full procedure due to orchestrator label (skipping AI routing)`);
1092
+ }
1093
+ else {
1094
+ // No label override - use AI routing
1095
+ const issueDescription = `${issue.title}\n\n${fullIssue.description || ""}`.trim();
1096
+ const routingDecision = await this.procedureRouter.determineRoutine(issueDescription);
1097
+ finalProcedure = routingDecision.procedure;
1098
+ finalClassification = routingDecision.classification;
1099
+ // Log AI routing decision
1100
+ console.log(`[EdgeWorker] AI routing decision for ${linearAgentActivitySessionId}:`);
1101
+ console.log(` Classification: ${routingDecision.classification}`);
1102
+ console.log(` Procedure: ${finalProcedure.name}`);
1103
+ console.log(` Reasoning: ${routingDecision.reasoning}`);
1104
+ }
1105
+ // Initialize procedure metadata in session with final decision
1106
+ this.procedureRouter.initializeProcedureMetadata(session, finalProcedure);
1107
+ // Post single procedure selection result (replaces ephemeral routing thought)
1108
+ await agentSessionManager.postProcedureSelectionThought(linearAgentActivitySessionId, finalProcedure.name, finalClassification);
568
1109
  // Only determine system prompt for delegation (not mentions) or when /label-based-prompt is requested
569
1110
  let systemPrompt;
570
1111
  let systemPromptVersion;
@@ -659,6 +1200,7 @@ export class EdgeWorker extends EventEmitter {
659
1200
  }
660
1201
  let session = agentSessionManager.getSession(linearAgentActivitySessionId);
661
1202
  let isNewSession = false;
1203
+ let fullIssue = null;
662
1204
  if (!session) {
663
1205
  console.log(`[EdgeWorker] No existing session found for agent activity session ${linearAgentActivitySessionId}, creating new session`);
664
1206
  isNewSession = true;
@@ -667,23 +1209,70 @@ export class EdgeWorker extends EventEmitter {
667
1209
  // Create the session using the shared method
668
1210
  const sessionData = await this.createLinearAgentSession(linearAgentActivitySessionId, issue, repository, agentSessionManager);
669
1211
  // Destructure session data for new session
670
- const { fullIssue: newFullIssue } = sessionData;
1212
+ fullIssue = sessionData.fullIssue;
671
1213
  session = sessionData.session;
1214
+ console.log(`[EdgeWorker] Created new session ${linearAgentActivitySessionId} (prompted webhook)`);
672
1215
  // Save state and emit events for new session
673
1216
  await this.savePersistedState();
674
- this.emit("session:started", newFullIssue.id, newFullIssue, repository.id);
675
- this.config.handlers?.onSessionStart?.(newFullIssue.id, newFullIssue, repository.id);
1217
+ this.emit("session:started", fullIssue.id, fullIssue, repository.id);
1218
+ this.config.handlers?.onSessionStart?.(fullIssue.id, fullIssue, repository.id);
1219
+ }
1220
+ else {
1221
+ console.log(`[EdgeWorker] Found existing session ${linearAgentActivitySessionId} for new user prompt`);
1222
+ // Post instant acknowledgment for existing session BEFORE any async work
1223
+ // Check streaming status first to determine the message
1224
+ const isCurrentlyStreaming = session?.claudeRunner?.isStreaming() || false;
1225
+ await this.postInstantPromptedAcknowledgment(linearAgentActivitySessionId, repository.id, isCurrentlyStreaming);
1226
+ // Need to fetch full issue for routing context
1227
+ const linearClient = this.linearClients.get(repository.id);
1228
+ if (linearClient) {
1229
+ try {
1230
+ fullIssue = await linearClient.issue(issue.id);
1231
+ }
1232
+ catch (error) {
1233
+ console.warn(`[EdgeWorker] Failed to fetch full issue for routing: ${issue.id}`, error);
1234
+ // Continue with degraded routing context
1235
+ }
1236
+ }
1237
+ }
1238
+ // Check if runner is actively streaming before routing
1239
+ const existingRunner = session?.claudeRunner;
1240
+ const isStreaming = existingRunner?.isStreaming() || false;
1241
+ // Always route procedure for new comments, UNLESS actively streaming
1242
+ if (!isStreaming) {
1243
+ // Initialize procedure metadata using intelligent routing
1244
+ if (!session.metadata) {
1245
+ session.metadata = {};
1246
+ }
1247
+ // Post ephemeral "Routing..." thought
1248
+ await agentSessionManager.postRoutingThought(linearAgentActivitySessionId);
1249
+ // For prompted events, use the actual prompt content from the user
1250
+ // Combine with issue context for better routing
1251
+ if (!fullIssue) {
1252
+ console.warn(`[EdgeWorker] Routing without full issue details for ${linearAgentActivitySessionId}`);
1253
+ }
1254
+ const promptBody = webhook.agentActivity.content.body;
1255
+ const routingDecision = await this.procedureRouter.determineRoutine(promptBody.trim());
1256
+ const selectedProcedure = routingDecision.procedure;
1257
+ // Initialize procedure metadata in session (resets for each new comment)
1258
+ this.procedureRouter.initializeProcedureMetadata(session, selectedProcedure);
1259
+ // Post procedure selection result (replaces ephemeral routing thought)
1260
+ await agentSessionManager.postProcedureSelectionThought(linearAgentActivitySessionId, selectedProcedure.name, routingDecision.classification);
1261
+ // Log routing decision
1262
+ console.log(`[EdgeWorker] Routing decision for ${linearAgentActivitySessionId} (prompted webhook, ${isNewSession ? "new" : "existing"} session):`);
1263
+ console.log(` Classification: ${routingDecision.classification}`);
1264
+ console.log(` Procedure: ${selectedProcedure.name}`);
1265
+ console.log(` Reasoning: ${routingDecision.reasoning}`);
1266
+ }
1267
+ else {
1268
+ console.log(`[EdgeWorker] Skipping routing for ${linearAgentActivitySessionId} - runner is actively streaming`);
676
1269
  }
677
1270
  // Ensure session is not null after creation/retrieval
678
1271
  if (!session) {
679
1272
  throw new Error(`Failed to get or create session for agent activity session ${linearAgentActivitySessionId}`);
680
1273
  }
681
- // Nothing before this should create latency or be async, so that these remain instant and low-latency for user experience
682
- const existingRunner = session.claudeRunner;
683
- if (!isNewSession) {
684
- // Only post acknowledgment for existing sessions (new sessions already handled it above)
685
- await this.postInstantPromptedAcknowledgment(linearAgentActivitySessionId, repository.id, existingRunner?.isStreaming() || false);
686
- }
1274
+ // Acknowledgment already posted above for both new and existing sessions
1275
+ // (before any async routing work to ensure instant user feedback)
687
1276
  // Get Linear client for this repository
688
1277
  const linearClient = this.linearClients.get(repository.id);
689
1278
  if (!linearClient) {
@@ -747,7 +1336,6 @@ export class EdgeWorker extends EventEmitter {
747
1336
  if (attachmentManifest) {
748
1337
  fullPrompt = `${promptBody}\n\n${attachmentManifest}`;
749
1338
  }
750
- fullPrompt = `${fullPrompt}${LAST_MESSAGE_MARKER}`;
751
1339
  existingRunner.addStreamMessage(fullPrompt);
752
1340
  return; // Exit early - comment has been added to stream
753
1341
  }
@@ -977,7 +1565,6 @@ export class EdgeWorker extends EventEmitter {
977
1565
  console.log(`[EdgeWorker] Adding attachment manifest to label-based prompt, length: ${attachmentManifest.length} characters`);
978
1566
  prompt = `${prompt}\n\n${attachmentManifest}`;
979
1567
  }
980
- prompt = `${prompt}${LAST_MESSAGE_MARKER}`;
981
1568
  console.log(`[EdgeWorker] Label-based prompt built successfully, length: ${prompt.length} characters`);
982
1569
  return { prompt, version: templateVersion };
983
1570
  }
@@ -1021,7 +1608,6 @@ IMPORTANT: You were specifically mentioned in the comment above. Focus on addres
1021
1608
  if (attachmentManifest) {
1022
1609
  prompt = `${prompt}\n\n${attachmentManifest}`;
1023
1610
  }
1024
- prompt = `${prompt}${LAST_MESSAGE_MARKER}`;
1025
1611
  return { prompt };
1026
1612
  }
1027
1613
  catch (error) {
@@ -1342,7 +1928,6 @@ IMPORTANT: Focus specifically on addressing the new comment above. This is a new
1342
1928
  console.log(`[EdgeWorker] Adding repository-specific instruction`);
1343
1929
  prompt = `${prompt}\n\n<repository-specific-instruction>\n${repository.appendInstruction}\n</repository-specific-instruction>`;
1344
1930
  }
1345
- prompt = `${prompt}${LAST_MESSAGE_MARKER}`;
1346
1931
  console.log(`[EdgeWorker] Final prompt length: ${prompt.length} characters`);
1347
1932
  return { prompt, version: templateVersion };
1348
1933
  }
@@ -1366,7 +1951,7 @@ Branch: ${issue.branchName}
1366
1951
  Working directory: ${repository.repositoryPath}
1367
1952
  Base branch: ${baseBranch}
1368
1953
 
1369
- ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ""}Please analyze this issue and help implement a solution. ${LAST_MESSAGE_MARKER}`;
1954
+ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ""}Please analyze this issue and help implement a solution.`;
1370
1955
  return { prompt: fallbackPrompt, version: undefined };
1371
1956
  }
1372
1957
  }
@@ -1877,13 +2462,13 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ""}Please ana
1877
2462
  */
1878
2463
  buildMcpConfig(repository, parentSessionId) {
1879
2464
  // Always inject the Linear MCP servers with the repository's token
2465
+ // https://linear.app/docs/mcp
1880
2466
  const mcpConfig = {
1881
2467
  linear: {
1882
- type: "stdio",
1883
- command: "npx",
1884
- args: ["-y", "@tacticlaunch/mcp-linear"],
1885
- env: {
1886
- LINEAR_API_TOKEN: repository.linearToken,
2468
+ type: "http",
2469
+ url: "https://mcp.linear.app/mcp",
2470
+ headers: {
2471
+ Authorization: `Bearer ${repository.linearToken}`,
1887
2472
  },
1888
2473
  },
1889
2474
  "cyrus-tools": createCyrusToolsServer(repository.linearToken, {
@@ -1939,6 +2524,20 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ""}Please ana
1939
2524
  },
1940
2525
  }),
1941
2526
  };
2527
+ // Add OpenAI-based MCP servers if API key is configured
2528
+ if (repository.openaiApiKey) {
2529
+ // Sora video generation tools
2530
+ mcpConfig["sora-tools"] = createSoraToolsServer({
2531
+ apiKey: repository.openaiApiKey,
2532
+ outputDirectory: repository.openaiOutputDirectory,
2533
+ });
2534
+ // GPT Image generation tools
2535
+ mcpConfig["image-tools"] = createImageToolsServer({
2536
+ apiKey: repository.openaiApiKey,
2537
+ outputDirectory: repository.openaiOutputDirectory,
2538
+ });
2539
+ console.log(`[EdgeWorker] Configured OpenAI MCP servers (Sora + GPT Image) for repository: ${repository.name}`);
2540
+ }
1942
2541
  return mcpConfig;
1943
2542
  }
1944
2543
  /**
@@ -1977,13 +2576,13 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ""}Please ana
1977
2576
  const manifestSuffix = attachmentManifest
1978
2577
  ? `\n\n${attachmentManifest}`
1979
2578
  : "";
1980
- return `${promptBody}${manifestSuffix}${LAST_MESSAGE_MARKER}`;
2579
+ return `${promptBody}${manifestSuffix}`;
1981
2580
  }
1982
2581
  }
1983
2582
  /**
1984
2583
  * Build Claude runner configuration with common settings
1985
2584
  */
1986
- buildClaudeRunnerConfig(session, repository, linearAgentActivitySessionId, systemPrompt, allowedTools, allowedDirectories, disallowedTools, resumeSessionId, labels) {
2585
+ buildClaudeRunnerConfig(session, repository, linearAgentActivitySessionId, systemPrompt, allowedTools, allowedDirectories, disallowedTools, resumeSessionId, labels, maxTurns) {
1987
2586
  // Configure PostToolUse hook for playwright screenshots
1988
2587
  const hooks = {
1989
2588
  PostToolUse: [
@@ -2043,7 +2642,7 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ""}Please ana
2043
2642
  cyrusHome: this.cyrusHome,
2044
2643
  mcpConfigPath: repository.mcpConfigPath,
2045
2644
  mcpConfig: this.buildMcpConfig(repository, linearAgentActivitySessionId),
2046
- appendSystemPrompt: (systemPrompt || "") + LAST_MESSAGE_MARKER,
2645
+ appendSystemPrompt: systemPrompt || "",
2047
2646
  // Priority order: label override > repository config > global default
2048
2647
  model: modelOverride || repository.model || this.config.defaultModel,
2049
2648
  fallbackModel: fallbackModelOverride ||
@@ -2058,6 +2657,9 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ""}Please ana
2058
2657
  if (resumeSessionId) {
2059
2658
  config.resumeSessionId = resumeSessionId;
2060
2659
  }
2660
+ if (maxTurns !== undefined) {
2661
+ config.maxTurns = maxTurns;
2662
+ }
2061
2663
  return config;
2062
2664
  }
2063
2665
  /**
@@ -2373,7 +2975,7 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ""}Please ana
2373
2975
  * @param attachmentManifest Optional attachment manifest
2374
2976
  * @param isNewSession Whether this is a new session
2375
2977
  */
2376
- async resumeClaudeSession(session, repository, linearAgentActivitySessionId, agentSessionManager, promptBody, attachmentManifest = "", isNewSession = false, additionalAllowedDirectories = []) {
2978
+ async resumeClaudeSession(session, repository, linearAgentActivitySessionId, agentSessionManager, promptBody, attachmentManifest = "", isNewSession = false, additionalAllowedDirectories = [], maxTurns) {
2377
2979
  // Check for existing runner
2378
2980
  const existingRunner = session.claudeRunner;
2379
2981
  // If there's an existing streaming runner, add to it
@@ -2382,7 +2984,6 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ""}Please ana
2382
2984
  if (attachmentManifest) {
2383
2985
  fullPrompt = `${promptBody}\n\n${attachmentManifest}`;
2384
2986
  }
2385
- fullPrompt = `${fullPrompt}${LAST_MESSAGE_MARKER}`;
2386
2987
  existingRunner.addStreamMessage(fullPrompt);
2387
2988
  return;
2388
2989
  }
@@ -2415,7 +3016,11 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ""}Please ana
2415
3016
  ...additionalAllowedDirectories,
2416
3017
  ];
2417
3018
  // Create runner configuration
2418
- const runnerConfig = this.buildClaudeRunnerConfig(session, repository, linearAgentActivitySessionId, systemPrompt, allowedTools, allowedDirectories, disallowedTools, needsNewClaudeSession ? undefined : session.claudeSessionId, labels);
3019
+ const resumeSessionId = needsNewClaudeSession
3020
+ ? undefined
3021
+ : session.claudeSessionId;
3022
+ const runnerConfig = this.buildClaudeRunnerConfig(session, repository, linearAgentActivitySessionId, systemPrompt, allowedTools, allowedDirectories, disallowedTools, resumeSessionId, labels, // Pass labels for model override
3023
+ maxTurns);
2419
3024
  const runner = new ClaudeRunner(runnerConfig);
2420
3025
  // Store runner
2421
3026
  agentSessionManager.addClaudeRunner(linearAgentActivitySessionId, runner);