cyrus-edge-worker 0.0.27 → 0.0.28

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.
@@ -1,10 +1,9 @@
1
1
  import { EventEmitter } from "node:events";
2
2
  import { mkdir, readdir, readFile, rename, writeFile } from "node:fs/promises";
3
- import { homedir } from "node:os";
4
3
  import { basename, dirname, extname, join, resolve } from "node:path";
5
4
  import { fileURLToPath } from "node:url";
6
5
  import { LinearClient, } from "@linear/sdk";
7
- import { ClaudeRunner, getAllTools, getReadOnlyTools, getSafeTools, } from "cyrus-claude-runner";
6
+ import { ClaudeRunner, getAllTools, getCoordinatorTools, getReadOnlyTools, getSafeTools, } from "cyrus-claude-runner";
8
7
  import { isAgentSessionCreatedWebhook, isAgentSessionPromptedWebhook, isIssueAssignedWebhook, isIssueCommentMentionWebhook, isIssueNewCommentWebhook, isIssueUnassignedWebhook, PersistenceManager, } from "cyrus-core";
9
8
  import { NdjsonClient } from "cyrus-ndjson-client";
10
9
  import { fileTypeFromBuffer } from "file-type";
@@ -25,10 +24,15 @@ export class EdgeWorker extends EventEmitter {
25
24
  ndjsonClients = new Map(); // listeners for webhook events, one per linear token
26
25
  persistenceManager;
27
26
  sharedApplicationServer;
27
+ cyrusHome;
28
+ childToParentAgentSession = new Map(); // Maps child agentSessionId to parent agentSessionId
28
29
  constructor(config) {
29
30
  super();
30
31
  this.config = config;
31
- this.persistenceManager = new PersistenceManager();
32
+ this.cyrusHome = config.cyrusHome;
33
+ this.persistenceManager = new PersistenceManager(join(this.cyrusHome, "state"));
34
+ console.log(`[EdgeWorker Constructor] Initializing parent-child session mapping system`);
35
+ console.log(`[EdgeWorker Constructor] Parent-child mapping initialized with 0 entries`);
32
36
  // Initialize shared application server
33
37
  const serverPort = config.serverPort || config.webhookPort || 3456;
34
38
  const serverHost = config.serverHost || "localhost";
@@ -46,8 +50,46 @@ export class EdgeWorker extends EventEmitter {
46
50
  accessToken: repo.linearToken,
47
51
  });
48
52
  this.linearClients.set(repo.id, linearClient);
49
- // Create AgentSessionManager for this repository
50
- this.agentSessionManagers.set(repo.id, new AgentSessionManager(linearClient));
53
+ // Create AgentSessionManager for this repository with parent session lookup and resume callback
54
+ //
55
+ // Note: This pattern works (despite appearing recursive) because:
56
+ // 1. The agentSessionManager variable is captured by the closure after it's assigned
57
+ // 2. JavaScript's variable hoisting means 'agentSessionManager' exists (but is undefined) when the arrow function is created
58
+ // 3. By the time the callback is actually invoked (when a child session completes), agentSessionManager is fully initialized
59
+ // 4. The callback only executes asynchronously, well after the constructor has completed and agentSessionManager is assigned
60
+ //
61
+ // This allows the AgentSessionManager to call back into itself to access its own sessions,
62
+ // enabling child sessions to trigger parent session resumption using the same manager instance.
63
+ const agentSessionManager = new AgentSessionManager(linearClient, (childSessionId) => {
64
+ console.log(`[Parent-Child Lookup] Looking up parent session for child ${childSessionId}`);
65
+ const parentId = this.childToParentAgentSession.get(childSessionId);
66
+ console.log(`[Parent-Child Lookup] Child ${childSessionId} -> Parent ${parentId || "not found"}`);
67
+ return parentId;
68
+ }, async (parentSessionId, prompt) => {
69
+ console.log(`[Parent Session Resume] Child session completed, resuming parent session ${parentSessionId}`);
70
+ // Get the parent session and repository
71
+ // This works because by the time this callback runs, agentSessionManager is fully initialized
72
+ console.log(`[Parent Session Resume] Retrieving parent session ${parentSessionId} from agent session manager`);
73
+ const parentSession = agentSessionManager.getSession(parentSessionId);
74
+ if (!parentSession) {
75
+ console.error(`[Parent Session Resume] Parent session ${parentSessionId} not found in agent session manager`);
76
+ return;
77
+ }
78
+ console.log(`[Parent Session Resume] Found parent session - Issue: ${parentSession.issueId}, Workspace: ${parentSession.workspace.path}`);
79
+ await this.postParentResumeAcknowledgment(parentSessionId, repo.id);
80
+ // Resume the parent session with the child's result
81
+ console.log(`[Parent Session Resume] Resuming parent Claude session with child results`);
82
+ try {
83
+ await this.resumeClaudeSession(parentSession, repo, parentSessionId, agentSessionManager, prompt, "", // No attachment manifest for child results
84
+ false);
85
+ console.log(`[Parent Session Resume] Successfully resumed parent session ${parentSessionId} with child results`);
86
+ }
87
+ catch (error) {
88
+ console.error(`[Parent Session Resume] Failed to resume parent session ${parentSessionId}:`, error);
89
+ console.error(`[Parent Session Resume] Error context - Parent issue: ${parentSession.issueId}, Repository: ${repo.name}`);
90
+ }
91
+ });
92
+ this.agentSessionManagers.set(repo.id, agentSessionManager);
51
93
  }
52
94
  }
53
95
  // Group repositories by token to minimize NDJSON connections
@@ -212,17 +254,16 @@ export class EdgeWorker extends EventEmitter {
212
254
  * Handle webhook events from proxy - now accepts native webhook payloads
213
255
  */
214
256
  async handleWebhook(webhook, repos) {
215
- console.log(`[EdgeWorker] Processing webhook: ${webhook.type}`);
216
257
  // Log verbose webhook info if enabled
217
258
  if (process.env.CYRUS_WEBHOOK_DEBUG === "true") {
218
- console.log(`[EdgeWorker] Webhook payload:`, JSON.stringify(webhook, null, 2));
259
+ console.log(`[handleWebhook] Full webhook payload:`, JSON.stringify(webhook, null, 2));
219
260
  }
220
261
  // Find the appropriate repository for this webhook
221
262
  const repository = await this.findRepositoryForWebhook(webhook, repos);
222
263
  if (!repository) {
223
- console.log("No repository configured for webhook from workspace", webhook.organizationId);
224
264
  if (process.env.CYRUS_WEBHOOK_DEBUG === "true") {
225
- console.log("Available repositories:", repos.map((r) => ({
265
+ console.log(`[handleWebhook] No repository configured for webhook from workspace ${webhook.organizationId}`);
266
+ console.log(`[handleWebhook] Available repositories:`, repos.map((r) => ({
226
267
  name: r.name,
227
268
  workspaceId: r.linearWorkspaceId,
228
269
  teamKeys: r.teamKeys,
@@ -231,20 +272,16 @@ export class EdgeWorker extends EventEmitter {
231
272
  }
232
273
  return;
233
274
  }
234
- console.log(`[EdgeWorker] Webhook matched to repository: ${repository.name}`);
235
275
  try {
236
276
  // Handle specific webhook types with proper typing
237
277
  // NOTE: Traditional webhooks (assigned, comment) are disabled in favor of agent session events
238
278
  if (isIssueAssignedWebhook(webhook)) {
239
- console.log(`[EdgeWorker] Ignoring traditional issue assigned webhook - using agent session events instead`);
240
279
  return;
241
280
  }
242
281
  else if (isIssueCommentMentionWebhook(webhook)) {
243
- console.log(`[EdgeWorker] Ignoring traditional comment mention webhook - using agent session events instead`);
244
282
  return;
245
283
  }
246
284
  else if (isIssueNewCommentWebhook(webhook)) {
247
- console.log(`[EdgeWorker] Ignoring traditional new comment webhook - using agent session events instead`);
248
285
  return;
249
286
  }
250
287
  else if (isIssueUnassignedWebhook(webhook)) {
@@ -258,11 +295,13 @@ export class EdgeWorker extends EventEmitter {
258
295
  await this.handleUserPostedAgentActivity(webhook, repository);
259
296
  }
260
297
  else {
261
- console.log(`Unhandled webhook type: ${webhook.action}`);
298
+ if (process.env.CYRUS_WEBHOOK_DEBUG === "true") {
299
+ console.log(`[handleWebhook] Unhandled webhook type: ${webhook.action} for repository ${repository.name}`);
300
+ }
262
301
  }
263
302
  }
264
303
  catch (error) {
265
- console.error(`[EdgeWorker] Failed to process webhook: ${webhook.action}`, error);
304
+ console.error(`[handleWebhook] Failed to process webhook: ${webhook.action} for repository ${repository.name}`, error);
266
305
  // Don't re-throw webhook processing errors to prevent application crashes
267
306
  // The error has been logged and individual webhook failures shouldn't crash the entire system
268
307
  }
@@ -437,7 +476,7 @@ export class EdgeWorker extends EventEmitter {
437
476
  const attachmentResult = await this.downloadIssueAttachments(fullIssue, repository, workspace.path);
438
477
  // Pre-create attachments directory even if no attachments exist yet
439
478
  const workspaceFolderName = basename(workspace.path);
440
- const attachmentsDir = join(homedir(), ".cyrus", workspaceFolderName, "attachments");
479
+ const attachmentsDir = join(this.cyrusHome, workspaceFolderName, "attachments");
441
480
  await mkdir(attachmentsDir, { recursive: true });
442
481
  // Build allowed directories list - always include attachments directory
443
482
  const allowedDirectories = [attachmentsDir];
@@ -481,13 +520,14 @@ export class EdgeWorker extends EventEmitter {
481
520
  const sessionData = await this.createLinearAgentSession(linearAgentActivitySessionId, issue, repository, agentSessionManager);
482
521
  // Destructure the session data (excluding allowedTools which we'll build with promptType)
483
522
  const { session, fullIssue, workspace: _workspace, attachmentResult, attachmentsDir: _attachmentsDir, allowedDirectories, } = sessionData;
484
- // Only fetch labels and determine system prompt for delegation (not mentions)
523
+ // Fetch labels (needed for both model selection and system prompt determination)
524
+ const labels = await this.fetchIssueLabels(fullIssue);
525
+ // Only determine system prompt for delegation (not mentions)
485
526
  let systemPrompt;
486
527
  let systemPromptVersion;
487
528
  let promptType;
488
529
  if (!isMentionTriggered) {
489
- // Fetch issue labels and determine system prompt (delegation case)
490
- const labels = await this.fetchIssueLabels(fullIssue);
530
+ // Determine system prompt based on labels (delegation case)
491
531
  const systemPromptResult = await this.determineSystemPromptFromLabels(labels, repository);
492
532
  systemPrompt = systemPromptResult?.prompt;
493
533
  systemPromptVersion = systemPromptResult?.version;
@@ -504,7 +544,9 @@ export class EdgeWorker extends EventEmitter {
504
544
  const allowedTools = this.buildAllowedTools(repository, promptType);
505
545
  console.log(`[EdgeWorker] Configured allowed tools for ${fullIssue.identifier}:`, allowedTools);
506
546
  // Create Claude runner with attachment directory access and optional system prompt
507
- const runnerConfig = this.buildClaudeRunnerConfig(session, repository, linearAgentActivitySessionId, systemPrompt, allowedTools, allowedDirectories);
547
+ const runnerConfig = this.buildClaudeRunnerConfig(session, repository, linearAgentActivitySessionId, agentSessionManager, systemPrompt, allowedTools, allowedDirectories, undefined, // resumeSessionId
548
+ linearAgentActivitySessionId, // Pass current session ID as parent context
549
+ labels);
508
550
  const runner = new ClaudeRunner(runnerConfig);
509
551
  // Store runner by comment ID
510
552
  agentSessionManager.addClaudeRunner(linearAgentActivitySessionId, runner);
@@ -600,7 +642,7 @@ export class EdgeWorker extends EventEmitter {
600
642
  }
601
643
  // Always set up attachments directory, even if no attachments in current comment
602
644
  const workspaceFolderName = basename(session.workspace.path);
603
- const attachmentsDir = join(homedir(), ".cyrus", workspaceFolderName, "attachments");
645
+ const attachmentsDir = join(this.cyrusHome, workspaceFolderName, "attachments");
604
646
  // Ensure directory exists
605
647
  await mkdir(attachmentsDir, { recursive: true });
606
648
  let attachmentManifest = "";
@@ -659,44 +701,9 @@ export class EdgeWorker extends EventEmitter {
659
701
  existingRunner.addStreamMessage(fullPrompt);
660
702
  return; // Exit early - comment has been added to stream
661
703
  }
662
- // Stop existing runner if it's not streaming or stream addition failed
663
- if (existingRunner) {
664
- existingRunner.stop();
665
- }
666
- // For newly created sessions or sessions without claudeSessionId, create a fresh Claude session
667
- const needsNewClaudeSession = isNewSession || !session.claudeSessionId;
704
+ // Use the new resumeClaudeSession function
668
705
  try {
669
- // Fetch full issue details to get labels (needed for both new and existing sessions)
670
- const fullIssue = await this.fetchFullIssueDetails(issue.id, repository.id);
671
- if (!fullIssue) {
672
- throw new Error(`Failed to fetch full issue details for ${issue.id}`);
673
- }
674
- // Fetch issue labels and determine system prompt (same as in handleAgentSessionCreatedWebhook)
675
- const labels = await this.fetchIssueLabels(fullIssue);
676
- const systemPromptResult = await this.determineSystemPromptFromLabels(labels, repository);
677
- const systemPrompt = systemPromptResult?.prompt;
678
- const promptType = systemPromptResult?.type;
679
- // Build allowed tools list with Linear MCP tools (now with prompt type context)
680
- const allowedTools = this.buildAllowedTools(repository, promptType);
681
- console.log(`[EdgeWorker] Configured allowed tools for ${issue.identifier} (continued session):`, allowedTools);
682
- const allowedDirectories = [attachmentsDir];
683
- // Create runner - resume existing session or start new one
684
- const runnerConfig = this.buildClaudeRunnerConfig(session, repository, linearAgentActivitySessionId, systemPrompt, allowedTools, allowedDirectories, needsNewClaudeSession ? undefined : session.claudeSessionId);
685
- const runner = new ClaudeRunner(runnerConfig);
686
- // Store new runner by comment thread root
687
- // Store runner by comment ID
688
- agentSessionManager.addClaudeRunner(linearAgentActivitySessionId, runner);
689
- // Save state after mapping changes
690
- await this.savePersistedState();
691
- // Prepare the prompt - different logic for new vs existing sessions
692
- const fullPrompt = await this.buildSessionPrompt(isNewSession, fullIssue, repository, promptBody, attachmentManifest);
693
- if (isNewSession) {
694
- console.log(`[EdgeWorker] Building initial prompt for new session for issue ${fullIssue.identifier}`);
695
- }
696
- // Start streaming session
697
- const sessionType = needsNewClaudeSession ? "new" : "resumed";
698
- console.log(`[EdgeWorker] Starting ${sessionType} streaming session for issue ${issue.identifier}`);
699
- await runner.startStreaming(fullPrompt);
706
+ await this.resumeClaudeSession(session, repository, linearAgentActivitySessionId, agentSessionManager, promptBody, attachmentManifest, isNewSession);
700
707
  }
701
708
  catch (error) {
702
709
  console.error("Failed to continue conversation:", error);
@@ -776,7 +783,12 @@ export class EdgeWorker extends EventEmitter {
776
783
  return undefined;
777
784
  }
778
785
  // Check each prompt type for matching labels
779
- const promptTypes = ["debugger", "builder", "scoper"];
786
+ const promptTypes = [
787
+ "debugger",
788
+ "builder",
789
+ "scoper",
790
+ "orchestrator",
791
+ ];
780
792
  for (const promptType of promptTypes) {
781
793
  const promptConfig = repository.labelPrompts[promptType];
782
794
  // Handle both old array format and new object format for backward compatibility
@@ -1255,6 +1267,26 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ""}Please ana
1255
1267
  * @param issue Full Linear issue object from Linear SDK
1256
1268
  * @param repositoryId Repository ID for Linear client lookup
1257
1269
  */
1270
+ /**
1271
+ * Get the repository configuration for a given session
1272
+ */
1273
+ getRepositoryForSession(session) {
1274
+ // Find the repository that matches the session's workspace path
1275
+ for (const repo of this.config.repositories) {
1276
+ if (session.workspace.path.includes(repo.workspaceBaseDir)) {
1277
+ return repo;
1278
+ }
1279
+ }
1280
+ // Fallback: try to find by issue ID in Linear client
1281
+ for (const [repoId, _] of this.linearClients) {
1282
+ const repo = this.config.repositories.find((r) => r.id === repoId);
1283
+ if (repo) {
1284
+ // Additional checks could be added here if needed
1285
+ return repo;
1286
+ }
1287
+ }
1288
+ return undefined;
1289
+ }
1258
1290
  async moveIssueToStartedState(issue, repositoryId) {
1259
1291
  try {
1260
1292
  const linearClient = this.linearClients.get(repositoryId);
@@ -1369,7 +1401,7 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ""}Please ana
1369
1401
  async downloadIssueAttachments(issue, repository, workspacePath) {
1370
1402
  // Create attachments directory in home directory
1371
1403
  const workspaceFolderName = basename(workspacePath);
1372
- const attachmentsDir = join(homedir(), ".cyrus", workspaceFolderName, "attachments");
1404
+ const attachmentsDir = join(this.cyrusHome, workspaceFolderName, "attachments");
1373
1405
  try {
1374
1406
  const attachmentMap = {};
1375
1407
  const imageMap = {};
@@ -1713,7 +1745,7 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ""}Please ana
1713
1745
  * Build MCP configuration with automatic Linear server injection
1714
1746
  */
1715
1747
  buildMcpConfig(repository) {
1716
- // Always inject the Linear MCP server with the repository's token
1748
+ // Always inject the Linear MCP servers with the repository's token
1717
1749
  const mcpConfig = {
1718
1750
  linear: {
1719
1751
  type: "stdio",
@@ -1723,6 +1755,14 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ""}Please ana
1723
1755
  LINEAR_API_TOKEN: repository.linearToken,
1724
1756
  },
1725
1757
  },
1758
+ "cyrus-mcp-tools": {
1759
+ type: "stdio",
1760
+ command: "npx",
1761
+ args: ["-y", "cyrus-mcp-tools"],
1762
+ env: {
1763
+ LINEAR_API_TOKEN: repository.linearToken,
1764
+ },
1765
+ },
1726
1766
  };
1727
1767
  return mcpConfig;
1728
1768
  }
@@ -1740,6 +1780,8 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ""}Please ana
1740
1780
  return getSafeTools();
1741
1781
  case "all":
1742
1782
  return getAllTools();
1783
+ case "coordinator":
1784
+ return getCoordinatorTools();
1743
1785
  default:
1744
1786
  // If it's a string but not a preset, treat it as a single tool
1745
1787
  return [preset];
@@ -1766,18 +1808,137 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ""}Please ana
1766
1808
  /**
1767
1809
  * Build Claude runner configuration with common settings
1768
1810
  */
1769
- buildClaudeRunnerConfig(session, repository, linearAgentActivitySessionId, systemPrompt, allowedTools, allowedDirectories, resumeSessionId) {
1811
+ buildClaudeRunnerConfig(session, repository, linearAgentActivitySessionId, agentSessionManager, systemPrompt, allowedTools, allowedDirectories, resumeSessionId, parentAgentSessionId, labels) {
1812
+ // Build hooks configuration
1813
+ const hooks = {
1814
+ PostToolUse: [
1815
+ {
1816
+ matcher: "mcp__cyrus-mcp-tools__linear_agent_session_create",
1817
+ hooks: [
1818
+ async (input, _toolUseID, _options) => {
1819
+ // Check if this is the linear_agent_session_create tool
1820
+ if (input.tool_name ===
1821
+ "mcp__cyrus-mcp-tools__linear_agent_session_create") {
1822
+ const toolResponse = input.tool_response;
1823
+ // The response is an array with a single object containing type and text fields
1824
+ // Parse the JSON from the text field to get the agentSessionId
1825
+ if (Array.isArray(toolResponse) &&
1826
+ toolResponse.length > 0 &&
1827
+ toolResponse[0].type === "text" &&
1828
+ toolResponse[0].text) {
1829
+ try {
1830
+ const responseData = JSON.parse(toolResponse[0].text);
1831
+ const childAgentSessionId = responseData.agentSessionId;
1832
+ // If there's a parent session, create the mapping
1833
+ if (parentAgentSessionId && childAgentSessionId) {
1834
+ console.log(`[Parent-Child Mapping] Creating: child ${childAgentSessionId} -> parent ${parentAgentSessionId}`);
1835
+ this.childToParentAgentSession.set(childAgentSessionId, parentAgentSessionId);
1836
+ console.log(`[Parent-Child Mapping] Successfully created. Total mappings: ${this.childToParentAgentSession.size}`);
1837
+ // Save state after adding new mapping
1838
+ this.savePersistedState().catch((error) => {
1839
+ console.error(`[Parent-Child Mapping] Failed to save state after creating mapping:`, error);
1840
+ });
1841
+ }
1842
+ }
1843
+ catch (error) {
1844
+ console.error(`[Parent-Child Mapping] Failed to parse agentSessionId from tool response:`, error);
1845
+ }
1846
+ }
1847
+ }
1848
+ return { continue: true };
1849
+ },
1850
+ ],
1851
+ },
1852
+ {
1853
+ matcher: "mcp__cyrus-mcp-tools__linear_agent_give_feedback",
1854
+ hooks: [
1855
+ async (input, _toolUseID, _options) => {
1856
+ // Check if this is the give_feedback tool
1857
+ if (input.tool_name ===
1858
+ "mcp__cyrus-mcp-tools__linear_agent_give_feedback") {
1859
+ const toolInput = input.tool_input;
1860
+ const childAgentSessionId = toolInput?.agentSessionId;
1861
+ const feedbackMessage = toolInput?.message;
1862
+ if (childAgentSessionId && feedbackMessage) {
1863
+ console.log(`[Give Feedback] Triggering child session resumption: ${childAgentSessionId}`);
1864
+ // Get the child session
1865
+ const childSession = agentSessionManager.getSession(childAgentSessionId);
1866
+ if (!childSession) {
1867
+ console.error(`[Give Feedback] Child session not found: ${childAgentSessionId}`);
1868
+ return { continue: true };
1869
+ }
1870
+ // Find the repository for this session
1871
+ const repo = this.getRepositoryForSession(childSession);
1872
+ if (!repo) {
1873
+ console.error(`[Give Feedback] Repository not found for child session: ${childAgentSessionId}`);
1874
+ return { continue: true };
1875
+ }
1876
+ // Prepare the prompt with the feedback message and child session ID
1877
+ const prompt = `Feedback from parent session regarding child agent session ${childAgentSessionId}:\n\n${feedbackMessage}`;
1878
+ // Resume the child session with the feedback
1879
+ try {
1880
+ await this.resumeClaudeSession(childSession, repo, childAgentSessionId, agentSessionManager, prompt, "", // No attachment manifest for feedback
1881
+ false);
1882
+ console.log(`[Give Feedback] Successfully resumed child session ${childAgentSessionId} with feedback`);
1883
+ }
1884
+ catch (error) {
1885
+ console.error(`[Give Feedback] Failed to resume child session ${childAgentSessionId}:`, error);
1886
+ }
1887
+ }
1888
+ }
1889
+ return { continue: true };
1890
+ },
1891
+ ],
1892
+ },
1893
+ ],
1894
+ };
1895
+ // Check for model override labels (case-insensitive)
1896
+ let modelOverride;
1897
+ let fallbackModelOverride;
1898
+ if (labels && labels.length > 0) {
1899
+ const lowercaseLabels = labels.map((label) => label.toLowerCase());
1900
+ // Check for model override labels: opus, sonnet, haiku
1901
+ if (lowercaseLabels.includes("opus")) {
1902
+ modelOverride = "opus";
1903
+ console.log(`[EdgeWorker] Model override via label: opus (for session ${linearAgentActivitySessionId})`);
1904
+ }
1905
+ else if (lowercaseLabels.includes("sonnet")) {
1906
+ modelOverride = "sonnet";
1907
+ console.log(`[EdgeWorker] Model override via label: sonnet (for session ${linearAgentActivitySessionId})`);
1908
+ }
1909
+ else if (lowercaseLabels.includes("haiku")) {
1910
+ modelOverride = "haiku";
1911
+ console.log(`[EdgeWorker] Model override via label: haiku (for session ${linearAgentActivitySessionId})`);
1912
+ }
1913
+ // If a model override is found, also set a reasonable fallback
1914
+ if (modelOverride) {
1915
+ // Set fallback to the next lower tier: opus->sonnet, sonnet->haiku, haiku->haiku
1916
+ if (modelOverride === "opus") {
1917
+ fallbackModelOverride = "sonnet";
1918
+ }
1919
+ else if (modelOverride === "sonnet") {
1920
+ fallbackModelOverride = "haiku";
1921
+ }
1922
+ else {
1923
+ fallbackModelOverride = "haiku";
1924
+ }
1925
+ }
1926
+ }
1770
1927
  const config = {
1771
1928
  workingDirectory: session.workspace.path,
1772
1929
  allowedTools,
1773
1930
  allowedDirectories,
1774
1931
  workspaceName: session.issue?.identifier || session.issueId,
1932
+ cyrusHome: this.cyrusHome,
1775
1933
  mcpConfigPath: repository.mcpConfigPath,
1776
1934
  mcpConfig: this.buildMcpConfig(repository),
1777
1935
  appendSystemPrompt: (systemPrompt || "") + LAST_MESSAGE_MARKER,
1778
- // Use repository-specific model or fall back to global default
1779
- model: repository.model || this.config.defaultModel,
1780
- fallbackModel: repository.fallbackModel || this.config.defaultFallbackModel,
1936
+ // Priority order: label override > repository config > global default
1937
+ model: modelOverride || repository.model || this.config.defaultModel,
1938
+ fallbackModel: fallbackModelOverride ||
1939
+ repository.fallbackModel ||
1940
+ this.config.defaultFallbackModel,
1941
+ hooks,
1781
1942
  onMessage: (message) => {
1782
1943
  this.handleClaudeMessage(linearAgentActivitySessionId, message, repository.id);
1783
1944
  },
@@ -1823,7 +1984,7 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ""}Please ana
1823
1984
  }
1824
1985
  // Linear MCP tools that should always be available
1825
1986
  // See: https://docs.anthropic.com/en/docs/claude-code/iam#tool-specific-permission-rules
1826
- const linearMcpTools = ["mcp__linear"];
1987
+ const linearMcpTools = ["mcp__linear", "mcp__cyrus-mcp-tools"];
1827
1988
  // Combine and deduplicate
1828
1989
  const allTools = [...new Set([...baseTools, ...linearMcpTools])];
1829
1990
  console.log(`[EdgeWorker] Tool selection for ${repository.name}: ${allTools.length} tools from ${toolSource}`);
@@ -1879,9 +2040,12 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ""}Please ana
1879
2040
  agentSessions[repositoryId] = serializedState.sessions;
1880
2041
  agentSessionEntries[repositoryId] = serializedState.entries;
1881
2042
  }
2043
+ // Serialize child to parent agent session mapping
2044
+ const childToParentAgentSession = Object.fromEntries(this.childToParentAgentSession.entries());
1882
2045
  return {
1883
2046
  agentSessions,
1884
2047
  agentSessionEntries,
2048
+ childToParentAgentSession,
1885
2049
  };
1886
2050
  }
1887
2051
  /**
@@ -1900,6 +2064,11 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ""}Please ana
1900
2064
  }
1901
2065
  }
1902
2066
  }
2067
+ // Restore child to parent agent session mapping
2068
+ if (state.childToParentAgentSession) {
2069
+ this.childToParentAgentSession = new Map(Object.entries(state.childToParentAgentSession));
2070
+ console.log(`[EdgeWorker] Restored ${this.childToParentAgentSession.size} child-to-parent agent session mappings`);
2071
+ }
1903
2072
  }
1904
2073
  /**
1905
2074
  * Post instant acknowledgment thought when agent session is created
@@ -1930,6 +2099,35 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ""}Please ana
1930
2099
  console.error(`[EdgeWorker] Error posting instant acknowledgment:`, error);
1931
2100
  }
1932
2101
  }
2102
+ /**
2103
+ * Post parent resume acknowledgment thought when parent session is resumed from child
2104
+ */
2105
+ async postParentResumeAcknowledgment(linearAgentActivitySessionId, repositoryId) {
2106
+ try {
2107
+ const linearClient = this.linearClients.get(repositoryId);
2108
+ if (!linearClient) {
2109
+ console.warn(`[EdgeWorker] No Linear client found for repository ${repositoryId}`);
2110
+ return;
2111
+ }
2112
+ const activityInput = {
2113
+ agentSessionId: linearAgentActivitySessionId,
2114
+ content: {
2115
+ type: "thought",
2116
+ body: "Resuming from child session",
2117
+ },
2118
+ };
2119
+ const result = await linearClient.createAgentActivity(activityInput);
2120
+ if (result.success) {
2121
+ console.log(`[EdgeWorker] Posted parent resumption acknowledgment thought for session ${linearAgentActivitySessionId}`);
2122
+ }
2123
+ else {
2124
+ console.error(`[EdgeWorker] Failed to post parent resumption acknowledgment:`, result);
2125
+ }
2126
+ }
2127
+ catch (error) {
2128
+ console.error(`[EdgeWorker] Error posting parent resumption acknowledgment:`, error);
2129
+ }
2130
+ }
1933
2131
  /**
1934
2132
  * Post thought about system prompt selection based on labels
1935
2133
  */
@@ -1977,6 +2175,18 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ""}Please ana
1977
2175
  selectedPromptType = "scoper";
1978
2176
  triggerLabel = scoperLabel;
1979
2177
  }
2178
+ else {
2179
+ // Check orchestrator labels
2180
+ const orchestratorConfig = repository.labelPrompts.orchestrator;
2181
+ const orchestratorLabels = Array.isArray(orchestratorConfig)
2182
+ ? orchestratorConfig
2183
+ : orchestratorConfig?.labels;
2184
+ const orchestratorLabel = orchestratorLabels?.find((label) => labels.includes(label));
2185
+ if (orchestratorLabel) {
2186
+ selectedPromptType = "orchestrator";
2187
+ triggerLabel = orchestratorLabel;
2188
+ }
2189
+ }
1980
2190
  }
1981
2191
  }
1982
2192
  }
@@ -2003,6 +2213,72 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ""}Please ana
2003
2213
  console.error(`[EdgeWorker] Error posting system prompt selection thought:`, error);
2004
2214
  }
2005
2215
  }
2216
+ /**
2217
+ * Resume or create a Claude session with the given prompt
2218
+ * This is the core logic for handling prompted agent activities
2219
+ * @param session The Cyrus agent session
2220
+ * @param repository The repository configuration
2221
+ * @param linearAgentActivitySessionId The Linear agent session ID
2222
+ * @param agentSessionManager The agent session manager
2223
+ * @param promptBody The prompt text to send
2224
+ * @param attachmentManifest Optional attachment manifest
2225
+ * @param isNewSession Whether this is a new session
2226
+ */
2227
+ async resumeClaudeSession(session, repository, linearAgentActivitySessionId, agentSessionManager, promptBody, attachmentManifest = "", isNewSession = false) {
2228
+ // Check for existing runner
2229
+ const existingRunner = session.claudeRunner;
2230
+ // If there's an existing streaming runner, add to it
2231
+ if (existingRunner?.isStreaming()) {
2232
+ let fullPrompt = promptBody;
2233
+ if (attachmentManifest) {
2234
+ fullPrompt = `${promptBody}\n\n${attachmentManifest}`;
2235
+ }
2236
+ fullPrompt = `${fullPrompt}${LAST_MESSAGE_MARKER}`;
2237
+ existingRunner.addStreamMessage(fullPrompt);
2238
+ return;
2239
+ }
2240
+ // Stop existing runner if it's not streaming
2241
+ if (existingRunner) {
2242
+ existingRunner.stop();
2243
+ }
2244
+ // Determine if we need a new Claude session
2245
+ const needsNewClaudeSession = isNewSession || !session.claudeSessionId;
2246
+ // Fetch full issue details
2247
+ const fullIssue = await this.fetchFullIssueDetails(session.issueId, repository.id);
2248
+ if (!fullIssue) {
2249
+ console.error(`[resumeClaudeSession] Failed to fetch full issue details for ${session.issueId}`);
2250
+ throw new Error(`Failed to fetch full issue details for ${session.issueId}`);
2251
+ }
2252
+ // Fetch issue labels and determine system prompt
2253
+ const labels = await this.fetchIssueLabels(fullIssue);
2254
+ const systemPromptResult = await this.determineSystemPromptFromLabels(labels, repository);
2255
+ const systemPrompt = systemPromptResult?.prompt;
2256
+ const promptType = systemPromptResult?.type;
2257
+ // Build allowed tools list
2258
+ const allowedTools = this.buildAllowedTools(repository, promptType);
2259
+ // Set up attachments directory
2260
+ const workspaceFolderName = basename(session.workspace.path);
2261
+ const attachmentsDir = join(this.cyrusHome, workspaceFolderName, "attachments");
2262
+ await mkdir(attachmentsDir, { recursive: true });
2263
+ const allowedDirectories = [attachmentsDir];
2264
+ // Create runner configuration
2265
+ const runnerConfig = this.buildClaudeRunnerConfig(session, repository, linearAgentActivitySessionId, agentSessionManager, systemPrompt, allowedTools, allowedDirectories, needsNewClaudeSession ? undefined : session.claudeSessionId, linearAgentActivitySessionId, labels);
2266
+ const runner = new ClaudeRunner(runnerConfig);
2267
+ // Store runner
2268
+ agentSessionManager.addClaudeRunner(linearAgentActivitySessionId, runner);
2269
+ // Save state
2270
+ await this.savePersistedState();
2271
+ // Prepare the full prompt
2272
+ const fullPrompt = await this.buildSessionPrompt(isNewSession, fullIssue, repository, promptBody, attachmentManifest);
2273
+ // Start streaming session
2274
+ try {
2275
+ await runner.startStreaming(fullPrompt);
2276
+ }
2277
+ catch (error) {
2278
+ console.error(`[resumeClaudeSession] Failed to start streaming session for ${linearAgentActivitySessionId}:`, error);
2279
+ throw error;
2280
+ }
2281
+ }
2006
2282
  /**
2007
2283
  * Post instant acknowledgment thought when receiving prompted webhook
2008
2284
  */