cyrus-edge-worker 0.0.27 → 0.0.29

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,11 +1,11 @@
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, createCyrusToolsServer, getAllTools, getCoordinatorTools, getReadOnlyTools, getSafeTools, } from "cyrus-claude-runner";
8
7
  import { isAgentSessionCreatedWebhook, isAgentSessionPromptedWebhook, isIssueAssignedWebhook, isIssueCommentMentionWebhook, isIssueNewCommentWebhook, isIssueUnassignedWebhook, PersistenceManager, } from "cyrus-core";
8
+ import { LinearWebhookClient } from "cyrus-linear-webhook-client";
9
9
  import { NdjsonClient } from "cyrus-ndjson-client";
10
10
  import { fileTypeFromBuffer } from "file-type";
11
11
  import { AgentSessionManager } from "./AgentSessionManager.js";
@@ -25,10 +25,15 @@ export class EdgeWorker extends EventEmitter {
25
25
  ndjsonClients = new Map(); // listeners for webhook events, one per linear token
26
26
  persistenceManager;
27
27
  sharedApplicationServer;
28
+ cyrusHome;
29
+ childToParentAgentSession = new Map(); // Maps child agentSessionId to parent agentSessionId
28
30
  constructor(config) {
29
31
  super();
30
32
  this.config = config;
31
- this.persistenceManager = new PersistenceManager();
33
+ this.cyrusHome = config.cyrusHome;
34
+ this.persistenceManager = new PersistenceManager(join(this.cyrusHome, "state"));
35
+ console.log(`[EdgeWorker Constructor] Initializing parent-child session mapping system`);
36
+ console.log(`[EdgeWorker Constructor] Parent-child mapping initialized with 0 entries`);
32
37
  // Initialize shared application server
33
38
  const serverPort = config.serverPort || config.webhookPort || 3456;
34
39
  const serverHost = config.serverHost || "localhost";
@@ -46,8 +51,57 @@ export class EdgeWorker extends EventEmitter {
46
51
  accessToken: repo.linearToken,
47
52
  });
48
53
  this.linearClients.set(repo.id, linearClient);
49
- // Create AgentSessionManager for this repository
50
- this.agentSessionManagers.set(repo.id, new AgentSessionManager(linearClient));
54
+ // Create AgentSessionManager for this repository with parent session lookup and resume callback
55
+ //
56
+ // Note: This pattern works (despite appearing recursive) because:
57
+ // 1. The agentSessionManager variable is captured by the closure after it's assigned
58
+ // 2. JavaScript's variable hoisting means 'agentSessionManager' exists (but is undefined) when the arrow function is created
59
+ // 3. By the time the callback is actually invoked (when a child session completes), agentSessionManager is fully initialized
60
+ // 4. The callback only executes asynchronously, well after the constructor has completed and agentSessionManager is assigned
61
+ //
62
+ // This allows the AgentSessionManager to call back into itself to access its own sessions,
63
+ // enabling child sessions to trigger parent session resumption using the same manager instance.
64
+ const agentSessionManager = new AgentSessionManager(linearClient, (childSessionId) => {
65
+ console.log(`[Parent-Child Lookup] Looking up parent session for child ${childSessionId}`);
66
+ const parentId = this.childToParentAgentSession.get(childSessionId);
67
+ console.log(`[Parent-Child Lookup] Child ${childSessionId} -> Parent ${parentId || "not found"}`);
68
+ return parentId;
69
+ }, 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`);
77
+ return;
78
+ }
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}`);
86
+ }
87
+ else {
88
+ console.warn(`[Parent Session Resume] Could not find child session ${childSessionId} to add workspace to parent allowed directories`);
89
+ }
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`);
93
+ try {
94
+ await this.resumeClaudeSession(parentSession, repo, parentSessionId, agentSessionManager, prompt, "", // No attachment manifest for child results
95
+ false, // Not a new session
96
+ childWorkspaceDirs);
97
+ console.log(`[Parent Session Resume] Successfully resumed parent session ${parentSessionId} with child results`);
98
+ }
99
+ 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}`);
102
+ }
103
+ });
104
+ this.agentSessionManagers.set(repo.id, agentSessionManager);
51
105
  }
52
106
  }
53
107
  // Group repositories by token to minimize NDJSON connections
@@ -65,7 +119,9 @@ export class EdgeWorker extends EventEmitter {
65
119
  if (!firstRepo)
66
120
  continue;
67
121
  const primaryRepoId = firstRepo.id;
68
- const ndjsonClient = new NdjsonClient({
122
+ // Determine which client to use based on environment variable
123
+ const useLinearDirectWebhooks = process.env.LINEAR_DIRECT_WEBHOOKS === "true";
124
+ const clientConfig = {
69
125
  proxyUrl: config.proxyUrl,
70
126
  token: token,
71
127
  name: repos.map((r) => r.name).join(", "), // Pass repository names
@@ -83,11 +139,20 @@ export class EdgeWorker extends EventEmitter {
83
139
  onConnect: () => this.handleConnect(primaryRepoId, repos),
84
140
  onDisconnect: (reason) => this.handleDisconnect(primaryRepoId, repos, reason),
85
141
  onError: (error) => this.handleError(error),
86
- });
87
- // Set up webhook handler - data should be the native webhook payload
88
- ndjsonClient.on("webhook", (data) => this.handleWebhook(data, repos));
89
- // Optional heartbeat logging
90
- if (process.env.DEBUG_EDGE === "true") {
142
+ };
143
+ // Create the appropriate client based on configuration
144
+ const ndjsonClient = useLinearDirectWebhooks
145
+ ? new LinearWebhookClient({
146
+ ...clientConfig,
147
+ onWebhook: (payload) => this.handleWebhook(payload, repos),
148
+ })
149
+ : new NdjsonClient(clientConfig);
150
+ // Set up webhook handler for NdjsonClient (LinearWebhookClient uses onWebhook in constructor)
151
+ if (!useLinearDirectWebhooks) {
152
+ ndjsonClient.on("webhook", (data) => this.handleWebhook(data, repos));
153
+ }
154
+ // Optional heartbeat logging (only for NdjsonClient)
155
+ if (process.env.DEBUG_EDGE === "true" && !useLinearDirectWebhooks) {
91
156
  ndjsonClient.on("heartbeat", () => {
92
157
  console.log(`❤️ Heartbeat received for token ending in ...${token.slice(-4)}`);
93
158
  });
@@ -212,17 +277,16 @@ export class EdgeWorker extends EventEmitter {
212
277
  * Handle webhook events from proxy - now accepts native webhook payloads
213
278
  */
214
279
  async handleWebhook(webhook, repos) {
215
- console.log(`[EdgeWorker] Processing webhook: ${webhook.type}`);
216
280
  // Log verbose webhook info if enabled
217
281
  if (process.env.CYRUS_WEBHOOK_DEBUG === "true") {
218
- console.log(`[EdgeWorker] Webhook payload:`, JSON.stringify(webhook, null, 2));
282
+ console.log(`[handleWebhook] Full webhook payload:`, JSON.stringify(webhook, null, 2));
219
283
  }
220
284
  // Find the appropriate repository for this webhook
221
285
  const repository = await this.findRepositoryForWebhook(webhook, repos);
222
286
  if (!repository) {
223
- console.log("No repository configured for webhook from workspace", webhook.organizationId);
224
287
  if (process.env.CYRUS_WEBHOOK_DEBUG === "true") {
225
- console.log("Available repositories:", repos.map((r) => ({
288
+ console.log(`[handleWebhook] No repository configured for webhook from workspace ${webhook.organizationId}`);
289
+ console.log(`[handleWebhook] Available repositories:`, repos.map((r) => ({
226
290
  name: r.name,
227
291
  workspaceId: r.linearWorkspaceId,
228
292
  teamKeys: r.teamKeys,
@@ -231,20 +295,16 @@ export class EdgeWorker extends EventEmitter {
231
295
  }
232
296
  return;
233
297
  }
234
- console.log(`[EdgeWorker] Webhook matched to repository: ${repository.name}`);
235
298
  try {
236
299
  // Handle specific webhook types with proper typing
237
300
  // NOTE: Traditional webhooks (assigned, comment) are disabled in favor of agent session events
238
301
  if (isIssueAssignedWebhook(webhook)) {
239
- console.log(`[EdgeWorker] Ignoring traditional issue assigned webhook - using agent session events instead`);
240
302
  return;
241
303
  }
242
304
  else if (isIssueCommentMentionWebhook(webhook)) {
243
- console.log(`[EdgeWorker] Ignoring traditional comment mention webhook - using agent session events instead`);
244
305
  return;
245
306
  }
246
307
  else if (isIssueNewCommentWebhook(webhook)) {
247
- console.log(`[EdgeWorker] Ignoring traditional new comment webhook - using agent session events instead`);
248
308
  return;
249
309
  }
250
310
  else if (isIssueUnassignedWebhook(webhook)) {
@@ -258,11 +318,13 @@ export class EdgeWorker extends EventEmitter {
258
318
  await this.handleUserPostedAgentActivity(webhook, repository);
259
319
  }
260
320
  else {
261
- console.log(`Unhandled webhook type: ${webhook.action}`);
321
+ if (process.env.CYRUS_WEBHOOK_DEBUG === "true") {
322
+ console.log(`[handleWebhook] Unhandled webhook type: ${webhook.action} for repository ${repository.name}`);
323
+ }
262
324
  }
263
325
  }
264
326
  catch (error) {
265
- console.error(`[EdgeWorker] Failed to process webhook: ${webhook.action}`, error);
327
+ console.error(`[handleWebhook] Failed to process webhook: ${webhook.action} for repository ${repository.name}`, error);
266
328
  // Don't re-throw webhook processing errors to prevent application crashes
267
329
  // The error has been logged and individual webhook failures shouldn't crash the entire system
268
330
  }
@@ -437,13 +499,14 @@ export class EdgeWorker extends EventEmitter {
437
499
  const attachmentResult = await this.downloadIssueAttachments(fullIssue, repository, workspace.path);
438
500
  // Pre-create attachments directory even if no attachments exist yet
439
501
  const workspaceFolderName = basename(workspace.path);
440
- const attachmentsDir = join(homedir(), ".cyrus", workspaceFolderName, "attachments");
502
+ const attachmentsDir = join(this.cyrusHome, workspaceFolderName, "attachments");
441
503
  await mkdir(attachmentsDir, { recursive: true });
442
504
  // Build allowed directories list - always include attachments directory
443
505
  const allowedDirectories = [attachmentsDir];
444
506
  console.log(`[EdgeWorker] Configured allowed directories for ${fullIssue.identifier}:`, allowedDirectories);
445
507
  // Build allowed tools list with Linear MCP tools
446
508
  const allowedTools = this.buildAllowedTools(repository);
509
+ const disallowedTools = this.buildDisallowedTools(repository);
447
510
  return {
448
511
  session,
449
512
  fullIssue,
@@ -452,6 +515,7 @@ export class EdgeWorker extends EventEmitter {
452
515
  attachmentsDir,
453
516
  allowedDirectories,
454
517
  allowedTools,
518
+ disallowedTools,
455
519
  };
456
520
  }
457
521
  /**
@@ -469,6 +533,8 @@ export class EdgeWorker extends EventEmitter {
469
533
  // HACK: This is required since the comment body is always populated, thus there is no other way to differentiate between the two trigger events
470
534
  const AGENT_SESSION_MARKER = "This thread is for an agent session";
471
535
  const isMentionTriggered = commentBody && !commentBody.includes(AGENT_SESSION_MARKER);
536
+ // Check if the comment contains the /label-based-prompt command
537
+ const isLabelBasedPromptRequested = commentBody?.includes("/label-based-prompt");
472
538
  // Initialize the agent session in AgentSessionManager
473
539
  const agentSessionManager = this.agentSessionManagers.get(repository.id);
474
540
  if (!agentSessionManager) {
@@ -481,13 +547,14 @@ export class EdgeWorker extends EventEmitter {
481
547
  const sessionData = await this.createLinearAgentSession(linearAgentActivitySessionId, issue, repository, agentSessionManager);
482
548
  // Destructure the session data (excluding allowedTools which we'll build with promptType)
483
549
  const { session, fullIssue, workspace: _workspace, attachmentResult, attachmentsDir: _attachmentsDir, allowedDirectories, } = sessionData;
484
- // Only fetch labels and determine system prompt for delegation (not mentions)
550
+ // Fetch labels (needed for both model selection and system prompt determination)
551
+ const labels = await this.fetchIssueLabels(fullIssue);
552
+ // Only determine system prompt for delegation (not mentions) or when /label-based-prompt is requested
485
553
  let systemPrompt;
486
554
  let systemPromptVersion;
487
555
  let promptType;
488
- if (!isMentionTriggered) {
489
- // Fetch issue labels and determine system prompt (delegation case)
490
- const labels = await this.fetchIssueLabels(fullIssue);
556
+ if (!isMentionTriggered || isLabelBasedPromptRequested) {
557
+ // Determine system prompt based on labels (delegation case or /label-based-prompt command)
491
558
  const systemPromptResult = await this.determineSystemPromptFromLabels(labels, repository);
492
559
  systemPrompt = systemPromptResult?.prompt;
493
560
  systemPromptVersion = systemPromptResult?.version;
@@ -502,9 +569,14 @@ export class EdgeWorker extends EventEmitter {
502
569
  }
503
570
  // Build allowed tools list with Linear MCP tools (now with prompt type context)
504
571
  const allowedTools = this.buildAllowedTools(repository, promptType);
572
+ const disallowedTools = this.buildDisallowedTools(repository, promptType);
505
573
  console.log(`[EdgeWorker] Configured allowed tools for ${fullIssue.identifier}:`, allowedTools);
574
+ if (disallowedTools.length > 0) {
575
+ console.log(`[EdgeWorker] Configured disallowed tools for ${fullIssue.identifier}:`, disallowedTools);
576
+ }
506
577
  // Create Claude runner with attachment directory access and optional system prompt
507
- const runnerConfig = this.buildClaudeRunnerConfig(session, repository, linearAgentActivitySessionId, systemPrompt, allowedTools, allowedDirectories);
578
+ const runnerConfig = this.buildClaudeRunnerConfig(session, repository, linearAgentActivitySessionId, systemPrompt, allowedTools, allowedDirectories, disallowedTools, undefined, // resumeSessionId
579
+ labels);
508
580
  const runner = new ClaudeRunner(runnerConfig);
509
581
  // Store runner by comment ID
510
582
  agentSessionManager.addClaudeRunner(linearAgentActivitySessionId, runner);
@@ -517,11 +589,13 @@ export class EdgeWorker extends EventEmitter {
517
589
  console.log(`[EdgeWorker] Building initial prompt for issue ${fullIssue.identifier}`);
518
590
  try {
519
591
  // Choose the appropriate prompt builder based on trigger type and system prompt
520
- const promptResult = isMentionTriggered
521
- ? await this.buildMentionPrompt(fullIssue, agentSession, attachmentResult.manifest)
522
- : systemPrompt
523
- ? await this.buildLabelBasedPrompt(fullIssue, repository, attachmentResult.manifest)
524
- : await this.buildPromptV2(fullIssue, repository, undefined, attachmentResult.manifest);
592
+ const promptResult = isMentionTriggered && isLabelBasedPromptRequested
593
+ ? await this.buildLabelBasedPrompt(fullIssue, repository, attachmentResult.manifest)
594
+ : isMentionTriggered
595
+ ? await this.buildMentionPrompt(fullIssue, agentSession, attachmentResult.manifest)
596
+ : systemPrompt
597
+ ? await this.buildLabelBasedPrompt(fullIssue, repository, attachmentResult.manifest)
598
+ : await this.buildPromptV2(fullIssue, repository, undefined, attachmentResult.manifest);
525
599
  const { prompt, version: userPromptVersion } = promptResult;
526
600
  // Update runner with version information
527
601
  if (userPromptVersion || systemPromptVersion) {
@@ -530,11 +604,13 @@ export class EdgeWorker extends EventEmitter {
530
604
  systemPromptVersion,
531
605
  });
532
606
  }
533
- const promptType = isMentionTriggered
534
- ? "mention"
535
- : systemPrompt
536
- ? "label-based"
537
- : "fallback";
607
+ const promptType = isMentionTriggered && isLabelBasedPromptRequested
608
+ ? "label-based-prompt-command"
609
+ : isMentionTriggered
610
+ ? "mention"
611
+ : systemPrompt
612
+ ? "label-based"
613
+ : "fallback";
538
614
  console.log(`[EdgeWorker] Initial prompt built successfully using ${promptType} workflow, length: ${prompt.length} characters`);
539
615
  console.log(`[EdgeWorker] Starting Claude streaming session`);
540
616
  const sessionInfo = await runner.startStreaming(prompt);
@@ -600,7 +676,7 @@ export class EdgeWorker extends EventEmitter {
600
676
  }
601
677
  // Always set up attachments directory, even if no attachments in current comment
602
678
  const workspaceFolderName = basename(session.workspace.path);
603
- const attachmentsDir = join(homedir(), ".cyrus", workspaceFolderName, "attachments");
679
+ const attachmentsDir = join(this.cyrusHome, workspaceFolderName, "attachments");
604
680
  // Ensure directory exists
605
681
  await mkdir(attachmentsDir, { recursive: true });
606
682
  let attachmentManifest = "";
@@ -659,44 +735,9 @@ export class EdgeWorker extends EventEmitter {
659
735
  existingRunner.addStreamMessage(fullPrompt);
660
736
  return; // Exit early - comment has been added to stream
661
737
  }
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;
738
+ // Use the new resumeClaudeSession function
668
739
  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);
740
+ await this.resumeClaudeSession(session, repository, linearAgentActivitySessionId, agentSessionManager, promptBody, attachmentManifest, isNewSession, []);
700
741
  }
701
742
  catch (error) {
702
743
  console.error("Failed to continue conversation:", error);
@@ -776,7 +817,12 @@ export class EdgeWorker extends EventEmitter {
776
817
  return undefined;
777
818
  }
778
819
  // Check each prompt type for matching labels
779
- const promptTypes = ["debugger", "builder", "scoper"];
820
+ const promptTypes = [
821
+ "debugger",
822
+ "builder",
823
+ "scoper",
824
+ "orchestrator",
825
+ ];
780
826
  for (const promptType of promptTypes) {
781
827
  const promptConfig = repository.labelPrompts[promptType];
782
828
  // Handle both old array format and new object format for backward compatibility
@@ -833,6 +879,67 @@ export class EdgeWorker extends EventEmitter {
833
879
  }
834
880
  // Determine the base branch considering parent issues
835
881
  const baseBranch = await this.determineBaseBranch(issue, repository);
882
+ // Fetch assignee information
883
+ let assigneeId = "";
884
+ let assigneeName = "";
885
+ try {
886
+ if (issue.assigneeId) {
887
+ assigneeId = issue.assigneeId;
888
+ // Fetch the full assignee object to get the name
889
+ const assignee = await issue.assignee;
890
+ if (assignee) {
891
+ assigneeName = assignee.displayName || assignee.name || "";
892
+ }
893
+ }
894
+ }
895
+ catch (error) {
896
+ console.warn(`[EdgeWorker] Failed to fetch assignee details:`, error);
897
+ }
898
+ // Get LinearClient for this repository
899
+ const linearClient = this.linearClients.get(repository.id);
900
+ if (!linearClient) {
901
+ console.error(`No LinearClient found for repository ${repository.id}`);
902
+ throw new Error(`No LinearClient found for repository ${repository.id}`);
903
+ }
904
+ // Fetch workspace teams and labels
905
+ let workspaceTeams = "";
906
+ let workspaceLabels = "";
907
+ try {
908
+ console.log(`[EdgeWorker] Fetching workspace teams and labels for repository ${repository.id}`);
909
+ // Fetch teams
910
+ const teamsConnection = await linearClient.teams();
911
+ const teamsArray = [];
912
+ for (const team of teamsConnection.nodes) {
913
+ teamsArray.push({
914
+ id: team.id,
915
+ name: team.name,
916
+ key: team.key,
917
+ description: team.description || "",
918
+ color: team.color,
919
+ });
920
+ }
921
+ workspaceTeams = teamsArray
922
+ .map((team) => `- ${team.name} (${team.key}): ${team.id}${team.description ? ` - ${team.description}` : ""}`)
923
+ .join("\n");
924
+ // Fetch labels
925
+ const labelsConnection = await linearClient.issueLabels();
926
+ const labelsArray = [];
927
+ for (const label of labelsConnection.nodes) {
928
+ labelsArray.push({
929
+ id: label.id,
930
+ name: label.name,
931
+ description: label.description || "",
932
+ color: label.color,
933
+ });
934
+ }
935
+ workspaceLabels = labelsArray
936
+ .map((label) => `- ${label.name}: ${label.id}${label.description ? ` - ${label.description}` : ""}`)
937
+ .join("\n");
938
+ console.log(`[EdgeWorker] Fetched ${teamsArray.length} teams and ${labelsArray.length} labels`);
939
+ }
940
+ catch (error) {
941
+ console.warn(`[EdgeWorker] Failed to fetch workspace teams and labels:`, error);
942
+ }
836
943
  // Build the simplified prompt with only essential variables
837
944
  let prompt = template
838
945
  .replace(/{{repository_name}}/g, repository.name)
@@ -841,7 +948,11 @@ export class EdgeWorker extends EventEmitter {
841
948
  .replace(/{{issue_identifier}}/g, issue.identifier || "")
842
949
  .replace(/{{issue_title}}/g, issue.title || "")
843
950
  .replace(/{{issue_description}}/g, issue.description || "No description provided")
844
- .replace(/{{issue_url}}/g, issue.url || "");
951
+ .replace(/{{issue_url}}/g, issue.url || "")
952
+ .replace(/{{assignee_id}}/g, assigneeId)
953
+ .replace(/{{assignee_name}}/g, assigneeName)
954
+ .replace(/{{workspace_teams}}/g, workspaceTeams)
955
+ .replace(/{{workspace_labels}}/g, workspaceLabels);
845
956
  if (attachmentManifest) {
846
957
  console.log(`[EdgeWorker] Adding attachment manifest to label-based prompt, length: ${attachmentManifest.length} characters`);
847
958
  prompt = `${prompt}\n\n${attachmentManifest}`;
@@ -1355,7 +1466,8 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ""}Please ana
1355
1466
  if (!text)
1356
1467
  return [];
1357
1468
  // Match URLs that start with https://uploads.linear.app
1358
- const regex = /https:\/\/uploads\.linear\.app\/[^\s<>"')]+/gi;
1469
+ // Exclude brackets and parentheses to avoid capturing malformed markdown link syntax
1470
+ const regex = /https:\/\/uploads\.linear\.app\/[a-zA-Z0-9/_.-]+/gi;
1359
1471
  const matches = text.match(regex) || [];
1360
1472
  // Remove duplicates
1361
1473
  return [...new Set(matches)];
@@ -1369,7 +1481,7 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ""}Please ana
1369
1481
  async downloadIssueAttachments(issue, repository, workspacePath) {
1370
1482
  // Create attachments directory in home directory
1371
1483
  const workspaceFolderName = basename(workspacePath);
1372
- const attachmentsDir = join(homedir(), ".cyrus", workspaceFolderName, "attachments");
1484
+ const attachmentsDir = join(this.cyrusHome, workspaceFolderName, "attachments");
1373
1485
  try {
1374
1486
  const attachmentMap = {};
1375
1487
  const imageMap = {};
@@ -1377,7 +1489,7 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ""}Please ana
1377
1489
  let imageCount = 0;
1378
1490
  let skippedCount = 0;
1379
1491
  let failedCount = 0;
1380
- const maxAttachments = 10;
1492
+ const maxAttachments = 20;
1381
1493
  // Ensure directory exists
1382
1494
  await mkdir(attachmentsDir, { recursive: true });
1383
1495
  // Extract URLs from issue description
@@ -1542,7 +1654,7 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ""}Please ana
1542
1654
  let newAttachmentCount = 0;
1543
1655
  let newImageCount = 0;
1544
1656
  let failedCount = 0;
1545
- const maxAttachments = 10;
1657
+ const maxAttachments = 20;
1546
1658
  // Extract URLs from the comment
1547
1659
  const urls = this.extractAttachmentUrls(commentBody);
1548
1660
  if (urls.length === 0) {
@@ -1710,10 +1822,10 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ""}Please ana
1710
1822
  return manifest;
1711
1823
  }
1712
1824
  /**
1713
- * Build MCP configuration with automatic Linear server injection
1825
+ * Build MCP configuration with automatic Linear server injection and inline cyrus tools
1714
1826
  */
1715
- buildMcpConfig(repository) {
1716
- // Always inject the Linear MCP server with the repository's token
1827
+ buildMcpConfig(repository, parentSessionId) {
1828
+ // Always inject the Linear MCP servers with the repository's token
1717
1829
  const mcpConfig = {
1718
1830
  linear: {
1719
1831
  type: "stdio",
@@ -1723,6 +1835,58 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ""}Please ana
1723
1835
  LINEAR_API_TOKEN: repository.linearToken,
1724
1836
  },
1725
1837
  },
1838
+ "cyrus-tools": createCyrusToolsServer(repository.linearToken, {
1839
+ parentSessionId,
1840
+ onSessionCreated: (childSessionId, parentId) => {
1841
+ console.log(`[EdgeWorker] Agent session created: ${childSessionId}, mapping to parent ${parentId}`);
1842
+ // Map child to parent session
1843
+ this.childToParentAgentSession.set(childSessionId, parentId);
1844
+ console.log(`[EdgeWorker] Parent-child mapping updated: ${this.childToParentAgentSession.size} mappings`);
1845
+ },
1846
+ onFeedbackDelivery: async (childSessionId, message) => {
1847
+ console.log(`[EdgeWorker] Processing feedback delivery to child session ${childSessionId}`);
1848
+ // Find the repository containing the child session
1849
+ // We need to search all repositories for this child session
1850
+ let childRepo;
1851
+ let childAgentSessionManager;
1852
+ for (const [repoId, manager] of this.agentSessionManagers) {
1853
+ if (manager.hasClaudeRunner(childSessionId)) {
1854
+ childRepo = this.repositories.get(repoId);
1855
+ childAgentSessionManager = manager;
1856
+ break;
1857
+ }
1858
+ }
1859
+ if (!childRepo || !childAgentSessionManager) {
1860
+ console.error(`[EdgeWorker] Child session ${childSessionId} not found in any repository`);
1861
+ return false;
1862
+ }
1863
+ // Get the child session
1864
+ const childSession = childAgentSessionManager.getSession(childSessionId);
1865
+ if (!childSession) {
1866
+ console.error(`[EdgeWorker] Child session ${childSessionId} not found`);
1867
+ return false;
1868
+ }
1869
+ console.log(`[EdgeWorker] Found child session - Issue: ${childSession.issueId}`);
1870
+ // Format the feedback as a prompt for the child session with enhanced markdown formatting
1871
+ const feedbackPrompt = `## Received feedback from orchestrator\n\n---\n\n${message}\n\n---`;
1872
+ // Resume the CHILD session with the feedback from the parent
1873
+ // Important: We don't await the full session completion to avoid timeouts.
1874
+ // The feedback is delivered immediately when the session starts, so we can
1875
+ // return success right away while the session continues in the background.
1876
+ this.resumeClaudeSession(childSession, childRepo, childSessionId, childAgentSessionManager, feedbackPrompt, "", // No attachment manifest for feedback
1877
+ false, // Not a new session
1878
+ [])
1879
+ .then(() => {
1880
+ console.log(`[EdgeWorker] Child session ${childSessionId} completed processing feedback`);
1881
+ })
1882
+ .catch((error) => {
1883
+ console.error(`[EdgeWorker] Failed to complete child session with feedback:`, error);
1884
+ });
1885
+ // Return success immediately after initiating the session
1886
+ console.log(`[EdgeWorker] Feedback delivered successfully to child session ${childSessionId}`);
1887
+ return true;
1888
+ },
1889
+ }),
1726
1890
  };
1727
1891
  return mcpConfig;
1728
1892
  }
@@ -1740,6 +1904,8 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ""}Please ana
1740
1904
  return getSafeTools();
1741
1905
  case "all":
1742
1906
  return getAllTools();
1907
+ case "coordinator":
1908
+ return getCoordinatorTools();
1743
1909
  default:
1744
1910
  // If it's a string but not a preset, treat it as a single tool
1745
1911
  return [preset];
@@ -1766,18 +1932,73 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ""}Please ana
1766
1932
  /**
1767
1933
  * Build Claude runner configuration with common settings
1768
1934
  */
1769
- buildClaudeRunnerConfig(session, repository, linearAgentActivitySessionId, systemPrompt, allowedTools, allowedDirectories, resumeSessionId) {
1935
+ buildClaudeRunnerConfig(session, repository, linearAgentActivitySessionId, systemPrompt, allowedTools, allowedDirectories, disallowedTools, resumeSessionId, labels) {
1936
+ // Configure PostToolUse hook for playwright screenshots
1937
+ const hooks = {
1938
+ PostToolUse: [
1939
+ {
1940
+ matcher: "playwright_screenshot",
1941
+ hooks: [
1942
+ async (input, _toolUseID, { signal: _signal }) => {
1943
+ const postToolUseInput = input;
1944
+ console.log(`Tool ${postToolUseInput.tool_name} completed with response:`, postToolUseInput.tool_response);
1945
+ return {
1946
+ continue: true,
1947
+ additionalContext: "Screenshot taken successfully. You should use the Read tool to view the screenshot file to analyze the visual content.",
1948
+ };
1949
+ },
1950
+ ],
1951
+ },
1952
+ ],
1953
+ };
1954
+ // Check for model override labels (case-insensitive)
1955
+ let modelOverride;
1956
+ let fallbackModelOverride;
1957
+ if (labels && labels.length > 0) {
1958
+ const lowercaseLabels = labels.map((label) => label.toLowerCase());
1959
+ // Check for model override labels: opus, sonnet, haiku
1960
+ if (lowercaseLabels.includes("opus")) {
1961
+ modelOverride = "opus";
1962
+ console.log(`[EdgeWorker] Model override via label: opus (for session ${linearAgentActivitySessionId})`);
1963
+ }
1964
+ else if (lowercaseLabels.includes("sonnet")) {
1965
+ modelOverride = "sonnet";
1966
+ console.log(`[EdgeWorker] Model override via label: sonnet (for session ${linearAgentActivitySessionId})`);
1967
+ }
1968
+ else if (lowercaseLabels.includes("haiku")) {
1969
+ modelOverride = "haiku";
1970
+ console.log(`[EdgeWorker] Model override via label: haiku (for session ${linearAgentActivitySessionId})`);
1971
+ }
1972
+ // If a model override is found, also set a reasonable fallback
1973
+ if (modelOverride) {
1974
+ // Set fallback to the next lower tier: opus->sonnet, sonnet->haiku, haiku->sonnet
1975
+ if (modelOverride === "opus") {
1976
+ fallbackModelOverride = "sonnet";
1977
+ }
1978
+ else if (modelOverride === "sonnet") {
1979
+ fallbackModelOverride = "haiku";
1980
+ }
1981
+ else {
1982
+ fallbackModelOverride = "sonnet"; // haiku falls back to sonnet since same model retry doesn't help
1983
+ }
1984
+ }
1985
+ }
1770
1986
  const config = {
1771
1987
  workingDirectory: session.workspace.path,
1772
1988
  allowedTools,
1989
+ disallowedTools,
1773
1990
  allowedDirectories,
1774
1991
  workspaceName: session.issue?.identifier || session.issueId,
1992
+ cyrusHome: this.cyrusHome,
1775
1993
  mcpConfigPath: repository.mcpConfigPath,
1776
- mcpConfig: this.buildMcpConfig(repository),
1994
+ mcpConfig: this.buildMcpConfig(repository, linearAgentActivitySessionId),
1777
1995
  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,
1996
+ // Priority order: label override > repository config > global default
1997
+ model: modelOverride || repository.model || this.config.defaultModel,
1998
+ fallbackModel: fallbackModelOverride ||
1999
+ repository.fallbackModel ||
2000
+ this.config.defaultFallbackModel,
2001
+ hooks,
1781
2002
  onMessage: (message) => {
1782
2003
  this.handleClaudeMessage(linearAgentActivitySessionId, message, repository.id);
1783
2004
  },
@@ -1788,6 +2009,44 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ""}Please ana
1788
2009
  }
1789
2010
  return config;
1790
2011
  }
2012
+ /**
2013
+ * Build disallowed tools list following the same hierarchy as allowed tools
2014
+ */
2015
+ buildDisallowedTools(repository, promptType) {
2016
+ let disallowedTools = [];
2017
+ let toolSource = "";
2018
+ // Priority order (same as allowedTools):
2019
+ // 1. Repository-specific prompt type configuration
2020
+ if (promptType && repository.labelPrompts?.[promptType]?.disallowedTools) {
2021
+ disallowedTools = repository.labelPrompts[promptType].disallowedTools;
2022
+ toolSource = `repository label prompt (${promptType})`;
2023
+ }
2024
+ // 2. Global prompt type defaults
2025
+ else if (promptType &&
2026
+ this.config.promptDefaults?.[promptType]?.disallowedTools) {
2027
+ disallowedTools = this.config.promptDefaults[promptType].disallowedTools;
2028
+ toolSource = `global prompt defaults (${promptType})`;
2029
+ }
2030
+ // 3. Repository-level disallowed tools
2031
+ else if (repository.disallowedTools) {
2032
+ disallowedTools = repository.disallowedTools;
2033
+ toolSource = "repository configuration";
2034
+ }
2035
+ // 4. Global default disallowed tools
2036
+ else if (this.config.defaultDisallowedTools) {
2037
+ disallowedTools = this.config.defaultDisallowedTools;
2038
+ toolSource = "global defaults";
2039
+ }
2040
+ // 5. No defaults for disallowedTools (as per requirements)
2041
+ else {
2042
+ disallowedTools = [];
2043
+ toolSource = "none (no defaults)";
2044
+ }
2045
+ if (disallowedTools.length > 0) {
2046
+ console.log(`[EdgeWorker] Disallowed tools for ${repository.name}: ${disallowedTools.length} tools from ${toolSource}`);
2047
+ }
2048
+ return disallowedTools;
2049
+ }
1791
2050
  /**
1792
2051
  * Build allowed tools list with Linear MCP tools automatically included
1793
2052
  */
@@ -1823,7 +2082,7 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ""}Please ana
1823
2082
  }
1824
2083
  // Linear MCP tools that should always be available
1825
2084
  // See: https://docs.anthropic.com/en/docs/claude-code/iam#tool-specific-permission-rules
1826
- const linearMcpTools = ["mcp__linear"];
2085
+ const linearMcpTools = ["mcp__linear", "mcp__cyrus-tools"];
1827
2086
  // Combine and deduplicate
1828
2087
  const allTools = [...new Set([...baseTools, ...linearMcpTools])];
1829
2088
  console.log(`[EdgeWorker] Tool selection for ${repository.name}: ${allTools.length} tools from ${toolSource}`);
@@ -1879,9 +2138,12 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ""}Please ana
1879
2138
  agentSessions[repositoryId] = serializedState.sessions;
1880
2139
  agentSessionEntries[repositoryId] = serializedState.entries;
1881
2140
  }
2141
+ // Serialize child to parent agent session mapping
2142
+ const childToParentAgentSession = Object.fromEntries(this.childToParentAgentSession.entries());
1882
2143
  return {
1883
2144
  agentSessions,
1884
2145
  agentSessionEntries,
2146
+ childToParentAgentSession,
1885
2147
  };
1886
2148
  }
1887
2149
  /**
@@ -1900,6 +2162,11 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ""}Please ana
1900
2162
  }
1901
2163
  }
1902
2164
  }
2165
+ // Restore child to parent agent session mapping
2166
+ if (state.childToParentAgentSession) {
2167
+ this.childToParentAgentSession = new Map(Object.entries(state.childToParentAgentSession));
2168
+ console.log(`[EdgeWorker] Restored ${this.childToParentAgentSession.size} child-to-parent agent session mappings`);
2169
+ }
1903
2170
  }
1904
2171
  /**
1905
2172
  * Post instant acknowledgment thought when agent session is created
@@ -1930,6 +2197,35 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ""}Please ana
1930
2197
  console.error(`[EdgeWorker] Error posting instant acknowledgment:`, error);
1931
2198
  }
1932
2199
  }
2200
+ /**
2201
+ * Post parent resume acknowledgment thought when parent session is resumed from child
2202
+ */
2203
+ async postParentResumeAcknowledgment(linearAgentActivitySessionId, repositoryId) {
2204
+ try {
2205
+ const linearClient = this.linearClients.get(repositoryId);
2206
+ if (!linearClient) {
2207
+ console.warn(`[EdgeWorker] No Linear client found for repository ${repositoryId}`);
2208
+ return;
2209
+ }
2210
+ const activityInput = {
2211
+ agentSessionId: linearAgentActivitySessionId,
2212
+ content: {
2213
+ type: "thought",
2214
+ body: "Resuming from child session",
2215
+ },
2216
+ };
2217
+ const result = await linearClient.createAgentActivity(activityInput);
2218
+ if (result.success) {
2219
+ console.log(`[EdgeWorker] Posted parent resumption acknowledgment thought for session ${linearAgentActivitySessionId}`);
2220
+ }
2221
+ else {
2222
+ console.error(`[EdgeWorker] Failed to post parent resumption acknowledgment:`, result);
2223
+ }
2224
+ }
2225
+ catch (error) {
2226
+ console.error(`[EdgeWorker] Error posting parent resumption acknowledgment:`, error);
2227
+ }
2228
+ }
1933
2229
  /**
1934
2230
  * Post thought about system prompt selection based on labels
1935
2231
  */
@@ -1977,6 +2273,18 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ""}Please ana
1977
2273
  selectedPromptType = "scoper";
1978
2274
  triggerLabel = scoperLabel;
1979
2275
  }
2276
+ else {
2277
+ // Check orchestrator labels
2278
+ const orchestratorConfig = repository.labelPrompts.orchestrator;
2279
+ const orchestratorLabels = Array.isArray(orchestratorConfig)
2280
+ ? orchestratorConfig
2281
+ : orchestratorConfig?.labels;
2282
+ const orchestratorLabel = orchestratorLabels?.find((label) => labels.includes(label));
2283
+ if (orchestratorLabel) {
2284
+ selectedPromptType = "orchestrator";
2285
+ triggerLabel = orchestratorLabel;
2286
+ }
2287
+ }
1980
2288
  }
1981
2289
  }
1982
2290
  }
@@ -2003,6 +2311,76 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ""}Please ana
2003
2311
  console.error(`[EdgeWorker] Error posting system prompt selection thought:`, error);
2004
2312
  }
2005
2313
  }
2314
+ /**
2315
+ * Resume or create a Claude session with the given prompt
2316
+ * This is the core logic for handling prompted agent activities
2317
+ * @param session The Cyrus agent session
2318
+ * @param repository The repository configuration
2319
+ * @param linearAgentActivitySessionId The Linear agent session ID
2320
+ * @param agentSessionManager The agent session manager
2321
+ * @param promptBody The prompt text to send
2322
+ * @param attachmentManifest Optional attachment manifest
2323
+ * @param isNewSession Whether this is a new session
2324
+ */
2325
+ async resumeClaudeSession(session, repository, linearAgentActivitySessionId, agentSessionManager, promptBody, attachmentManifest = "", isNewSession = false, additionalAllowedDirectories = []) {
2326
+ // Check for existing runner
2327
+ const existingRunner = session.claudeRunner;
2328
+ // If there's an existing streaming runner, add to it
2329
+ if (existingRunner?.isStreaming()) {
2330
+ let fullPrompt = promptBody;
2331
+ if (attachmentManifest) {
2332
+ fullPrompt = `${promptBody}\n\n${attachmentManifest}`;
2333
+ }
2334
+ fullPrompt = `${fullPrompt}${LAST_MESSAGE_MARKER}`;
2335
+ existingRunner.addStreamMessage(fullPrompt);
2336
+ return;
2337
+ }
2338
+ // Stop existing runner if it's not streaming
2339
+ if (existingRunner) {
2340
+ existingRunner.stop();
2341
+ }
2342
+ // Determine if we need a new Claude session
2343
+ const needsNewClaudeSession = isNewSession || !session.claudeSessionId;
2344
+ // Fetch full issue details
2345
+ const fullIssue = await this.fetchFullIssueDetails(session.issueId, repository.id);
2346
+ if (!fullIssue) {
2347
+ console.error(`[resumeClaudeSession] Failed to fetch full issue details for ${session.issueId}`);
2348
+ throw new Error(`Failed to fetch full issue details for ${session.issueId}`);
2349
+ }
2350
+ // Fetch issue labels and determine system prompt
2351
+ const labels = await this.fetchIssueLabels(fullIssue);
2352
+ const systemPromptResult = await this.determineSystemPromptFromLabels(labels, repository);
2353
+ const systemPrompt = systemPromptResult?.prompt;
2354
+ const promptType = systemPromptResult?.type;
2355
+ // Build allowed tools list
2356
+ const allowedTools = this.buildAllowedTools(repository, promptType);
2357
+ const disallowedTools = this.buildDisallowedTools(repository, promptType);
2358
+ // Set up attachments directory
2359
+ const workspaceFolderName = basename(session.workspace.path);
2360
+ const attachmentsDir = join(this.cyrusHome, workspaceFolderName, "attachments");
2361
+ await mkdir(attachmentsDir, { recursive: true });
2362
+ const allowedDirectories = [
2363
+ attachmentsDir,
2364
+ ...additionalAllowedDirectories,
2365
+ ];
2366
+ // Create runner configuration
2367
+ const runnerConfig = this.buildClaudeRunnerConfig(session, repository, linearAgentActivitySessionId, systemPrompt, allowedTools, allowedDirectories, disallowedTools, needsNewClaudeSession ? undefined : session.claudeSessionId, labels);
2368
+ const runner = new ClaudeRunner(runnerConfig);
2369
+ // Store runner
2370
+ agentSessionManager.addClaudeRunner(linearAgentActivitySessionId, runner);
2371
+ // Save state
2372
+ await this.savePersistedState();
2373
+ // Prepare the full prompt
2374
+ const fullPrompt = await this.buildSessionPrompt(isNewSession, fullIssue, repository, promptBody, attachmentManifest);
2375
+ // Start streaming session
2376
+ try {
2377
+ await runner.startStreaming(fullPrompt);
2378
+ }
2379
+ catch (error) {
2380
+ console.error(`[resumeClaudeSession] Failed to start streaming session for ${linearAgentActivitySessionId}:`, error);
2381
+ throw error;
2382
+ }
2383
+ }
2006
2384
  /**
2007
2385
  * Post instant acknowledgment thought when receiving prompted webhook
2008
2386
  */