cyrus-edge-worker 0.0.28 → 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.
@@ -3,8 +3,9 @@ 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, getAllTools, getCoordinatorTools, getReadOnlyTools, getSafeTools, } from "cyrus-claude-runner";
6
+ import { ClaudeRunner, createCyrusToolsServer, getAllTools, getCoordinatorTools, getReadOnlyTools, getSafeTools, } from "cyrus-claude-runner";
7
7
  import { isAgentSessionCreatedWebhook, isAgentSessionPromptedWebhook, isIssueAssignedWebhook, isIssueCommentMentionWebhook, isIssueNewCommentWebhook, isIssueUnassignedWebhook, PersistenceManager, } from "cyrus-core";
8
+ import { LinearWebhookClient } from "cyrus-linear-webhook-client";
8
9
  import { NdjsonClient } from "cyrus-ndjson-client";
9
10
  import { fileTypeFromBuffer } from "file-type";
10
11
  import { AgentSessionManager } from "./AgentSessionManager.js";
@@ -65,7 +66,7 @@ export class EdgeWorker extends EventEmitter {
65
66
  const parentId = this.childToParentAgentSession.get(childSessionId);
66
67
  console.log(`[Parent-Child Lookup] Child ${childSessionId} -> Parent ${parentId || "not found"}`);
67
68
  return parentId;
68
- }, async (parentSessionId, prompt) => {
69
+ }, async (parentSessionId, prompt, childSessionId) => {
69
70
  console.log(`[Parent Session Resume] Child session completed, resuming parent session ${parentSessionId}`);
70
71
  // Get the parent session and repository
71
72
  // This works because by the time this callback runs, agentSessionManager is fully initialized
@@ -76,12 +77,23 @@ export class EdgeWorker extends EventEmitter {
76
77
  return;
77
78
  }
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
+ }
79
90
  await this.postParentResumeAcknowledgment(parentSessionId, repo.id);
80
91
  // Resume the parent session with the child's result
81
92
  console.log(`[Parent Session Resume] Resuming parent Claude session with child results`);
82
93
  try {
83
94
  await this.resumeClaudeSession(parentSession, repo, parentSessionId, agentSessionManager, prompt, "", // No attachment manifest for child results
84
- false);
95
+ false, // Not a new session
96
+ childWorkspaceDirs);
85
97
  console.log(`[Parent Session Resume] Successfully resumed parent session ${parentSessionId} with child results`);
86
98
  }
87
99
  catch (error) {
@@ -107,7 +119,9 @@ export class EdgeWorker extends EventEmitter {
107
119
  if (!firstRepo)
108
120
  continue;
109
121
  const primaryRepoId = firstRepo.id;
110
- 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 = {
111
125
  proxyUrl: config.proxyUrl,
112
126
  token: token,
113
127
  name: repos.map((r) => r.name).join(", "), // Pass repository names
@@ -125,11 +139,20 @@ export class EdgeWorker extends EventEmitter {
125
139
  onConnect: () => this.handleConnect(primaryRepoId, repos),
126
140
  onDisconnect: (reason) => this.handleDisconnect(primaryRepoId, repos, reason),
127
141
  onError: (error) => this.handleError(error),
128
- });
129
- // Set up webhook handler - data should be the native webhook payload
130
- ndjsonClient.on("webhook", (data) => this.handleWebhook(data, repos));
131
- // Optional heartbeat logging
132
- 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) {
133
156
  ndjsonClient.on("heartbeat", () => {
134
157
  console.log(`❤️ Heartbeat received for token ending in ...${token.slice(-4)}`);
135
158
  });
@@ -483,6 +506,7 @@ export class EdgeWorker extends EventEmitter {
483
506
  console.log(`[EdgeWorker] Configured allowed directories for ${fullIssue.identifier}:`, allowedDirectories);
484
507
  // Build allowed tools list with Linear MCP tools
485
508
  const allowedTools = this.buildAllowedTools(repository);
509
+ const disallowedTools = this.buildDisallowedTools(repository);
486
510
  return {
487
511
  session,
488
512
  fullIssue,
@@ -491,6 +515,7 @@ export class EdgeWorker extends EventEmitter {
491
515
  attachmentsDir,
492
516
  allowedDirectories,
493
517
  allowedTools,
518
+ disallowedTools,
494
519
  };
495
520
  }
496
521
  /**
@@ -508,6 +533,8 @@ export class EdgeWorker extends EventEmitter {
508
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
509
534
  const AGENT_SESSION_MARKER = "This thread is for an agent session";
510
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");
511
538
  // Initialize the agent session in AgentSessionManager
512
539
  const agentSessionManager = this.agentSessionManagers.get(repository.id);
513
540
  if (!agentSessionManager) {
@@ -522,12 +549,12 @@ export class EdgeWorker extends EventEmitter {
522
549
  const { session, fullIssue, workspace: _workspace, attachmentResult, attachmentsDir: _attachmentsDir, allowedDirectories, } = sessionData;
523
550
  // Fetch labels (needed for both model selection and system prompt determination)
524
551
  const labels = await this.fetchIssueLabels(fullIssue);
525
- // Only determine system prompt for delegation (not mentions)
552
+ // Only determine system prompt for delegation (not mentions) or when /label-based-prompt is requested
526
553
  let systemPrompt;
527
554
  let systemPromptVersion;
528
555
  let promptType;
529
- if (!isMentionTriggered) {
530
- // Determine system prompt based on labels (delegation case)
556
+ if (!isMentionTriggered || isLabelBasedPromptRequested) {
557
+ // Determine system prompt based on labels (delegation case or /label-based-prompt command)
531
558
  const systemPromptResult = await this.determineSystemPromptFromLabels(labels, repository);
532
559
  systemPrompt = systemPromptResult?.prompt;
533
560
  systemPromptVersion = systemPromptResult?.version;
@@ -542,10 +569,13 @@ export class EdgeWorker extends EventEmitter {
542
569
  }
543
570
  // Build allowed tools list with Linear MCP tools (now with prompt type context)
544
571
  const allowedTools = this.buildAllowedTools(repository, promptType);
572
+ const disallowedTools = this.buildDisallowedTools(repository, promptType);
545
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
+ }
546
577
  // Create Claude runner with attachment directory access and optional system prompt
547
- const runnerConfig = this.buildClaudeRunnerConfig(session, repository, linearAgentActivitySessionId, agentSessionManager, systemPrompt, allowedTools, allowedDirectories, undefined, // resumeSessionId
548
- linearAgentActivitySessionId, // Pass current session ID as parent context
578
+ const runnerConfig = this.buildClaudeRunnerConfig(session, repository, linearAgentActivitySessionId, systemPrompt, allowedTools, allowedDirectories, disallowedTools, undefined, // resumeSessionId
549
579
  labels);
550
580
  const runner = new ClaudeRunner(runnerConfig);
551
581
  // Store runner by comment ID
@@ -559,11 +589,13 @@ export class EdgeWorker extends EventEmitter {
559
589
  console.log(`[EdgeWorker] Building initial prompt for issue ${fullIssue.identifier}`);
560
590
  try {
561
591
  // Choose the appropriate prompt builder based on trigger type and system prompt
562
- const promptResult = isMentionTriggered
563
- ? await this.buildMentionPrompt(fullIssue, agentSession, attachmentResult.manifest)
564
- : systemPrompt
565
- ? await this.buildLabelBasedPrompt(fullIssue, repository, attachmentResult.manifest)
566
- : 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);
567
599
  const { prompt, version: userPromptVersion } = promptResult;
568
600
  // Update runner with version information
569
601
  if (userPromptVersion || systemPromptVersion) {
@@ -572,11 +604,13 @@ export class EdgeWorker extends EventEmitter {
572
604
  systemPromptVersion,
573
605
  });
574
606
  }
575
- const promptType = isMentionTriggered
576
- ? "mention"
577
- : systemPrompt
578
- ? "label-based"
579
- : "fallback";
607
+ const promptType = isMentionTriggered && isLabelBasedPromptRequested
608
+ ? "label-based-prompt-command"
609
+ : isMentionTriggered
610
+ ? "mention"
611
+ : systemPrompt
612
+ ? "label-based"
613
+ : "fallback";
580
614
  console.log(`[EdgeWorker] Initial prompt built successfully using ${promptType} workflow, length: ${prompt.length} characters`);
581
615
  console.log(`[EdgeWorker] Starting Claude streaming session`);
582
616
  const sessionInfo = await runner.startStreaming(prompt);
@@ -703,7 +737,7 @@ export class EdgeWorker extends EventEmitter {
703
737
  }
704
738
  // Use the new resumeClaudeSession function
705
739
  try {
706
- await this.resumeClaudeSession(session, repository, linearAgentActivitySessionId, agentSessionManager, promptBody, attachmentManifest, isNewSession);
740
+ await this.resumeClaudeSession(session, repository, linearAgentActivitySessionId, agentSessionManager, promptBody, attachmentManifest, isNewSession, []);
707
741
  }
708
742
  catch (error) {
709
743
  console.error("Failed to continue conversation:", error);
@@ -845,6 +879,67 @@ export class EdgeWorker extends EventEmitter {
845
879
  }
846
880
  // Determine the base branch considering parent issues
847
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
+ }
848
943
  // Build the simplified prompt with only essential variables
849
944
  let prompt = template
850
945
  .replace(/{{repository_name}}/g, repository.name)
@@ -853,7 +948,11 @@ export class EdgeWorker extends EventEmitter {
853
948
  .replace(/{{issue_identifier}}/g, issue.identifier || "")
854
949
  .replace(/{{issue_title}}/g, issue.title || "")
855
950
  .replace(/{{issue_description}}/g, issue.description || "No description provided")
856
- .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);
857
956
  if (attachmentManifest) {
858
957
  console.log(`[EdgeWorker] Adding attachment manifest to label-based prompt, length: ${attachmentManifest.length} characters`);
859
958
  prompt = `${prompt}\n\n${attachmentManifest}`;
@@ -1267,26 +1366,6 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ""}Please ana
1267
1366
  * @param issue Full Linear issue object from Linear SDK
1268
1367
  * @param repositoryId Repository ID for Linear client lookup
1269
1368
  */
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
- }
1290
1369
  async moveIssueToStartedState(issue, repositoryId) {
1291
1370
  try {
1292
1371
  const linearClient = this.linearClients.get(repositoryId);
@@ -1387,7 +1466,8 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ""}Please ana
1387
1466
  if (!text)
1388
1467
  return [];
1389
1468
  // Match URLs that start with https://uploads.linear.app
1390
- 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;
1391
1471
  const matches = text.match(regex) || [];
1392
1472
  // Remove duplicates
1393
1473
  return [...new Set(matches)];
@@ -1409,7 +1489,7 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ""}Please ana
1409
1489
  let imageCount = 0;
1410
1490
  let skippedCount = 0;
1411
1491
  let failedCount = 0;
1412
- const maxAttachments = 10;
1492
+ const maxAttachments = 20;
1413
1493
  // Ensure directory exists
1414
1494
  await mkdir(attachmentsDir, { recursive: true });
1415
1495
  // Extract URLs from issue description
@@ -1574,7 +1654,7 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ""}Please ana
1574
1654
  let newAttachmentCount = 0;
1575
1655
  let newImageCount = 0;
1576
1656
  let failedCount = 0;
1577
- const maxAttachments = 10;
1657
+ const maxAttachments = 20;
1578
1658
  // Extract URLs from the comment
1579
1659
  const urls = this.extractAttachmentUrls(commentBody);
1580
1660
  if (urls.length === 0) {
@@ -1742,9 +1822,9 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ""}Please ana
1742
1822
  return manifest;
1743
1823
  }
1744
1824
  /**
1745
- * Build MCP configuration with automatic Linear server injection
1825
+ * Build MCP configuration with automatic Linear server injection and inline cyrus tools
1746
1826
  */
1747
- buildMcpConfig(repository) {
1827
+ buildMcpConfig(repository, parentSessionId) {
1748
1828
  // Always inject the Linear MCP servers with the repository's token
1749
1829
  const mcpConfig = {
1750
1830
  linear: {
@@ -1755,14 +1835,58 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ""}Please ana
1755
1835
  LINEAR_API_TOKEN: repository.linearToken,
1756
1836
  },
1757
1837
  },
1758
- "cyrus-mcp-tools": {
1759
- type: "stdio",
1760
- command: "npx",
1761
- args: ["-y", "cyrus-mcp-tools"],
1762
- env: {
1763
- LINEAR_API_TOKEN: repository.linearToken,
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`);
1764
1845
  },
1765
- },
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
+ }),
1766
1890
  };
1767
1891
  return mcpConfig;
1768
1892
  }
@@ -1808,85 +1932,20 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ""}Please ana
1808
1932
  /**
1809
1933
  * Build Claude runner configuration with common settings
1810
1934
  */
1811
- buildClaudeRunnerConfig(session, repository, linearAgentActivitySessionId, agentSessionManager, systemPrompt, allowedTools, allowedDirectories, resumeSessionId, parentAgentSessionId, labels) {
1812
- // Build hooks configuration
1935
+ buildClaudeRunnerConfig(session, repository, linearAgentActivitySessionId, systemPrompt, allowedTools, allowedDirectories, disallowedTools, resumeSessionId, labels) {
1936
+ // Configure PostToolUse hook for playwright screenshots
1813
1937
  const hooks = {
1814
1938
  PostToolUse: [
1815
1939
  {
1816
- matcher: "mcp__cyrus-mcp-tools__linear_agent_session_create",
1940
+ matcher: "playwright_screenshot",
1817
1941
  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 };
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
+ };
1890
1949
  },
1891
1950
  ],
1892
1951
  },
@@ -1912,7 +1971,7 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ""}Please ana
1912
1971
  }
1913
1972
  // If a model override is found, also set a reasonable fallback
1914
1973
  if (modelOverride) {
1915
- // Set fallback to the next lower tier: opus->sonnet, sonnet->haiku, haiku->haiku
1974
+ // Set fallback to the next lower tier: opus->sonnet, sonnet->haiku, haiku->sonnet
1916
1975
  if (modelOverride === "opus") {
1917
1976
  fallbackModelOverride = "sonnet";
1918
1977
  }
@@ -1920,18 +1979,19 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ""}Please ana
1920
1979
  fallbackModelOverride = "haiku";
1921
1980
  }
1922
1981
  else {
1923
- fallbackModelOverride = "haiku";
1982
+ fallbackModelOverride = "sonnet"; // haiku falls back to sonnet since same model retry doesn't help
1924
1983
  }
1925
1984
  }
1926
1985
  }
1927
1986
  const config = {
1928
1987
  workingDirectory: session.workspace.path,
1929
1988
  allowedTools,
1989
+ disallowedTools,
1930
1990
  allowedDirectories,
1931
1991
  workspaceName: session.issue?.identifier || session.issueId,
1932
1992
  cyrusHome: this.cyrusHome,
1933
1993
  mcpConfigPath: repository.mcpConfigPath,
1934
- mcpConfig: this.buildMcpConfig(repository),
1994
+ mcpConfig: this.buildMcpConfig(repository, linearAgentActivitySessionId),
1935
1995
  appendSystemPrompt: (systemPrompt || "") + LAST_MESSAGE_MARKER,
1936
1996
  // Priority order: label override > repository config > global default
1937
1997
  model: modelOverride || repository.model || this.config.defaultModel,
@@ -1949,6 +2009,44 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ""}Please ana
1949
2009
  }
1950
2010
  return config;
1951
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
+ }
1952
2050
  /**
1953
2051
  * Build allowed tools list with Linear MCP tools automatically included
1954
2052
  */
@@ -1984,7 +2082,7 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ""}Please ana
1984
2082
  }
1985
2083
  // Linear MCP tools that should always be available
1986
2084
  // See: https://docs.anthropic.com/en/docs/claude-code/iam#tool-specific-permission-rules
1987
- const linearMcpTools = ["mcp__linear", "mcp__cyrus-mcp-tools"];
2085
+ const linearMcpTools = ["mcp__linear", "mcp__cyrus-tools"];
1988
2086
  // Combine and deduplicate
1989
2087
  const allTools = [...new Set([...baseTools, ...linearMcpTools])];
1990
2088
  console.log(`[EdgeWorker] Tool selection for ${repository.name}: ${allTools.length} tools from ${toolSource}`);
@@ -2224,7 +2322,7 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ""}Please ana
2224
2322
  * @param attachmentManifest Optional attachment manifest
2225
2323
  * @param isNewSession Whether this is a new session
2226
2324
  */
2227
- async resumeClaudeSession(session, repository, linearAgentActivitySessionId, agentSessionManager, promptBody, attachmentManifest = "", isNewSession = false) {
2325
+ async resumeClaudeSession(session, repository, linearAgentActivitySessionId, agentSessionManager, promptBody, attachmentManifest = "", isNewSession = false, additionalAllowedDirectories = []) {
2228
2326
  // Check for existing runner
2229
2327
  const existingRunner = session.claudeRunner;
2230
2328
  // If there's an existing streaming runner, add to it
@@ -2256,13 +2354,17 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ""}Please ana
2256
2354
  const promptType = systemPromptResult?.type;
2257
2355
  // Build allowed tools list
2258
2356
  const allowedTools = this.buildAllowedTools(repository, promptType);
2357
+ const disallowedTools = this.buildDisallowedTools(repository, promptType);
2259
2358
  // Set up attachments directory
2260
2359
  const workspaceFolderName = basename(session.workspace.path);
2261
2360
  const attachmentsDir = join(this.cyrusHome, workspaceFolderName, "attachments");
2262
2361
  await mkdir(attachmentsDir, { recursive: true });
2263
- const allowedDirectories = [attachmentsDir];
2362
+ const allowedDirectories = [
2363
+ attachmentsDir,
2364
+ ...additionalAllowedDirectories,
2365
+ ];
2264
2366
  // Create runner configuration
2265
- const runnerConfig = this.buildClaudeRunnerConfig(session, repository, linearAgentActivitySessionId, agentSessionManager, systemPrompt, allowedTools, allowedDirectories, needsNewClaudeSession ? undefined : session.claudeSessionId, linearAgentActivitySessionId, labels);
2367
+ const runnerConfig = this.buildClaudeRunnerConfig(session, repository, linearAgentActivitySessionId, systemPrompt, allowedTools, allowedDirectories, disallowedTools, needsNewClaudeSession ? undefined : session.claudeSessionId, labels);
2266
2368
  const runner = new ClaudeRunner(runnerConfig);
2267
2369
  // Store runner
2268
2370
  agentSessionManager.addClaudeRunner(linearAgentActivitySessionId, runner);