cyrus-edge-worker 0.2.0 → 0.2.2

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.
@@ -11,6 +11,7 @@ import { LinearEventTransport } from "cyrus-linear-event-transport";
11
11
  import { fileTypeFromBuffer } from "file-type";
12
12
  import { AgentSessionManager } from "./AgentSessionManager.js";
13
13
  import { ProcedureRouter, } from "./procedures/index.js";
14
+ import { RepositoryRouter, } from "./RepositoryRouter.js";
14
15
  import { SharedApplicationServer } from "./SharedApplicationServer.js";
15
16
  /**
16
17
  * Unified edge worker that **orchestrates**
@@ -32,6 +33,8 @@ export class EdgeWorker extends EventEmitter {
32
33
  procedureRouter; // Intelligent workflow routing
33
34
  configWatcher; // File watcher for config.json
34
35
  configPath; // Path to config.json file
36
+ /** @internal - Exposed for testing only */
37
+ repositoryRouter; // Repository routing and selection
35
38
  constructor(config) {
36
39
  super();
37
40
  this.config = config;
@@ -43,6 +46,36 @@ export class EdgeWorker extends EventEmitter {
43
46
  model: "haiku",
44
47
  timeoutMs: 10000,
45
48
  });
49
+ // Initialize repository router with dependencies
50
+ const repositoryRouterDeps = {
51
+ fetchIssueLabels: async (issueId, workspaceId) => {
52
+ // Get Linear client for this workspace
53
+ const linearClient = this.getLinearClientForWorkspace(workspaceId);
54
+ if (!linearClient) {
55
+ console.warn(`[EdgeWorker] No Linear client found for workspace ${workspaceId}`);
56
+ return [];
57
+ }
58
+ try {
59
+ const issue = await linearClient.issue(issueId);
60
+ return await this.fetchIssueLabels(issue);
61
+ }
62
+ catch (error) {
63
+ console.error(`[EdgeWorker] Failed to fetch issue labels for ${issueId}:`, error);
64
+ return [];
65
+ }
66
+ },
67
+ hasActiveSession: (issueId, repositoryId) => {
68
+ const sessionManager = this.agentSessionManagers.get(repositoryId);
69
+ if (!sessionManager)
70
+ return false;
71
+ const activeSessions = sessionManager.getActiveSessionsByIssueId(issueId);
72
+ return activeSessions.length > 0;
73
+ },
74
+ getLinearClient: (workspaceId) => {
75
+ return this.getLinearClientForWorkspace(workspaceId);
76
+ },
77
+ };
78
+ this.repositoryRouter = new RepositoryRouter(repositoryRouterDeps);
46
79
  console.log(`[EdgeWorker Constructor] Initializing parent-child session mapping system`);
47
80
  console.log(`[EdgeWorker Constructor] Parent-child mapping initialized with 0 entries`);
48
81
  // Initialize shared application server
@@ -108,16 +141,16 @@ export class EdgeWorker extends EventEmitter {
108
141
  }
109
142
  console.log(`[Subroutine Transition] Next subroutine: ${nextSubroutine.name}`);
110
143
  // Load subroutine prompt
111
- const __filename = fileURLToPath(import.meta.url);
112
- const __dirname = dirname(__filename);
113
- const subroutinePromptPath = join(__dirname, "prompts", nextSubroutine.promptPath);
114
144
  let subroutinePrompt;
115
145
  try {
116
- subroutinePrompt = await readFile(subroutinePromptPath, "utf-8");
117
- console.log(`[Subroutine Transition] Loaded ${nextSubroutine.name} subroutine prompt (${subroutinePrompt.length} characters)`);
146
+ subroutinePrompt = await this.loadSubroutinePrompt(nextSubroutine, this.config.linearWorkspaceSlug);
147
+ if (!subroutinePrompt) {
148
+ // Fallback if loadSubroutinePrompt returns null
149
+ subroutinePrompt = `Continue with: ${nextSubroutine.description}`;
150
+ }
118
151
  }
119
152
  catch (error) {
120
- console.error(`[Subroutine Transition] Failed to load subroutine prompt from ${subroutinePromptPath}:`, error);
153
+ console.error(`[Subroutine Transition] Failed to load subroutine prompt:`, error);
121
154
  // Fallback to simple prompt
122
155
  subroutinePrompt = `Continue with: ${nextSubroutine.description}`;
123
156
  }
@@ -375,6 +408,7 @@ export class EdgeWorker extends EventEmitter {
375
408
  ...this.config,
376
409
  repositories: parsedConfig.repositories || [],
377
410
  ngrokAuthToken: parsedConfig.ngrokAuthToken || this.config.ngrokAuthToken,
411
+ linearWorkspaceSlug: parsedConfig.linearWorkspaceSlug || this.config.linearWorkspaceSlug,
378
412
  defaultModel: parsedConfig.defaultModel || this.config.defaultModel,
379
413
  defaultFallbackModel: parsedConfig.defaultFallbackModel || this.config.defaultFallbackModel,
380
414
  defaultAllowedTools: parsedConfig.defaultAllowedTools || this.config.defaultAllowedTools,
@@ -603,29 +637,21 @@ export class EdgeWorker extends EventEmitter {
603
637
  this.config.handlers?.onError?.(error);
604
638
  }
605
639
  /**
606
- * Handle webhook events from proxy - now accepts native webhook payloads
640
+ * Get cached repository for an issue (used by agentSessionPrompted Branch 3)
641
+ */
642
+ getCachedRepository(issueId) {
643
+ return this.repositoryRouter.getCachedRepository(issueId, this.repositories);
644
+ }
645
+ /**
646
+ * Handle webhook events from proxy - main router for all webhooks
607
647
  */
608
648
  async handleWebhook(webhook, repos) {
609
649
  // Log verbose webhook info if enabled
610
650
  if (process.env.CYRUS_WEBHOOK_DEBUG === "true") {
611
651
  console.log(`[handleWebhook] Full webhook payload:`, JSON.stringify(webhook, null, 2));
612
652
  }
613
- // Find the appropriate repository for this webhook
614
- const repository = await this.findRepositoryForWebhook(webhook, repos);
615
- if (!repository) {
616
- if (process.env.CYRUS_WEBHOOK_DEBUG === "true") {
617
- console.log(`[handleWebhook] No repository configured for webhook from workspace ${webhook.organizationId}`);
618
- console.log(`[handleWebhook] Available repositories:`, repos.map((r) => ({
619
- name: r.name,
620
- workspaceId: r.linearWorkspaceId,
621
- teamKeys: r.teamKeys,
622
- routingLabels: r.routingLabels,
623
- })));
624
- }
625
- return;
626
- }
627
653
  try {
628
- // Handle specific webhook types with proper typing
654
+ // Route to specific webhook handlers based on webhook type
629
655
  // NOTE: Traditional webhooks (assigned, comment) are disabled in favor of agent session events
630
656
  if (isIssueAssignedWebhook(webhook)) {
631
657
  return;
@@ -638,22 +664,22 @@ export class EdgeWorker extends EventEmitter {
638
664
  }
639
665
  else if (isIssueUnassignedWebhook(webhook)) {
640
666
  // Keep unassigned webhook active
641
- await this.handleIssueUnassignedWebhook(webhook, repository);
667
+ await this.handleIssueUnassignedWebhook(webhook);
642
668
  }
643
669
  else if (isAgentSessionCreatedWebhook(webhook)) {
644
- await this.handleAgentSessionCreatedWebhook(webhook, repository);
670
+ await this.handleAgentSessionCreatedWebhook(webhook, repos);
645
671
  }
646
672
  else if (isAgentSessionPromptedWebhook(webhook)) {
647
- await this.handleUserPostedAgentActivity(webhook, repository);
673
+ await this.handleUserPromptedAgentActivity(webhook);
648
674
  }
649
675
  else {
650
676
  if (process.env.CYRUS_WEBHOOK_DEBUG === "true") {
651
- console.log(`[handleWebhook] Unhandled webhook type: ${webhook.action} for repository ${repository.name}`);
677
+ console.log(`[handleWebhook] Unhandled webhook type: ${webhook.action}`);
652
678
  }
653
679
  }
654
680
  }
655
681
  catch (error) {
656
- console.error(`[handleWebhook] Failed to process webhook: ${webhook.action} for repository ${repository.name}`, error);
682
+ console.error(`[handleWebhook] Failed to process webhook: ${webhook.action}`, error);
657
683
  // Don't re-throw webhook processing errors to prevent application crashes
658
684
  // The error has been logged and individual webhook failures shouldn't crash the entire system
659
685
  }
@@ -661,7 +687,14 @@ export class EdgeWorker extends EventEmitter {
661
687
  /**
662
688
  * Handle issue unassignment webhook
663
689
  */
664
- async handleIssueUnassignedWebhook(webhook, repository) {
690
+ async handleIssueUnassignedWebhook(webhook) {
691
+ const issueId = webhook.notification.issue.id;
692
+ // Get cached repository (unassignment should only happen on issues with active sessions)
693
+ const repository = this.getCachedRepository(issueId);
694
+ if (!repository) {
695
+ console.log(`[EdgeWorker] No cached repository for issue unassignment webhook ${webhook.notification.issue.identifier} (no active sessions to stop)`);
696
+ return;
697
+ }
665
698
  console.log(`[EdgeWorker] Handling issue unassignment: ${webhook.notification.issue.identifier}`);
666
699
  // Log the complete webhook payload for TypeScript type definition
667
700
  // console.log('=== ISSUE UNASSIGNMENT WEBHOOK PAYLOAD ===')
@@ -670,128 +703,15 @@ export class EdgeWorker extends EventEmitter {
670
703
  await this.handleIssueUnassigned(webhook.notification.issue, repository);
671
704
  }
672
705
  /**
673
- * Find the repository configuration for a webhook
674
- * Now supports async operations for label-based and project-based routing
675
- * Priority: routingLabels > projectKeys > teamKeys
676
- */
677
- async findRepositoryForWebhook(webhook, repos) {
678
- const workspaceId = webhook.organizationId;
679
- if (!workspaceId)
680
- return repos[0] || null; // Fallback to first repo if no workspace ID
681
- // Get issue information from webhook
682
- let issueId;
683
- let teamKey;
684
- let issueIdentifier;
685
- // Handle agent session webhooks which have different structure
686
- if (isAgentSessionCreatedWebhook(webhook) ||
687
- isAgentSessionPromptedWebhook(webhook)) {
688
- issueId = webhook.agentSession?.issue?.id;
689
- teamKey = webhook.agentSession?.issue?.team?.key;
690
- issueIdentifier = webhook.agentSession?.issue?.identifier;
691
- }
692
- else {
693
- issueId = webhook.notification?.issue?.id;
694
- teamKey = webhook.notification?.issue?.team?.key;
695
- issueIdentifier = webhook.notification?.issue?.identifier;
696
- }
697
- // Filter repos by workspace first
698
- const workspaceRepos = repos.filter((repo) => repo.linearWorkspaceId === workspaceId);
699
- if (workspaceRepos.length === 0)
700
- return null;
701
- // Priority 1: Check routing labels (highest priority)
702
- const reposWithRoutingLabels = workspaceRepos.filter((repo) => repo.routingLabels && repo.routingLabels.length > 0);
703
- if (reposWithRoutingLabels.length > 0 && issueId && workspaceRepos[0]) {
704
- // We need a Linear client to fetch labels
705
- // Use the first workspace repo's client temporarily
706
- const linearClient = this.linearClients.get(workspaceRepos[0].id);
707
- if (linearClient) {
708
- try {
709
- // Fetch the issue to get labels
710
- const issue = await linearClient.issue(issueId);
711
- const labels = await this.fetchIssueLabels(issue);
712
- // Check each repo with routing labels
713
- for (const repo of reposWithRoutingLabels) {
714
- if (repo.routingLabels?.some((routingLabel) => labels.includes(routingLabel))) {
715
- console.log(`[EdgeWorker] Repository selected: ${repo.name} (label-based routing)`);
716
- return repo;
717
- }
718
- }
719
- }
720
- catch (error) {
721
- console.error(`[EdgeWorker] Failed to fetch labels for routing:`, error);
722
- // Continue to project-based routing
723
- }
724
- }
725
- }
726
- // Priority 2: Check project-based routing
727
- if (issueId) {
728
- const projectBasedRepo = await this.findRepositoryByProject(issueId, workspaceRepos);
729
- if (projectBasedRepo) {
730
- console.log(`[EdgeWorker] Repository selected: ${projectBasedRepo.name} (project-based routing)`);
731
- return projectBasedRepo;
732
- }
733
- }
734
- // Priority 3: Check team-based routing
735
- if (teamKey) {
736
- const repo = workspaceRepos.find((r) => r.teamKeys?.includes(teamKey));
737
- if (repo) {
738
- console.log(`[EdgeWorker] Repository selected: ${repo.name} (team-based routing)`);
739
- return repo;
740
- }
741
- }
742
- // Try parsing issue identifier as fallback for team routing
743
- if (issueIdentifier?.includes("-")) {
744
- const prefix = issueIdentifier.split("-")[0];
745
- if (prefix) {
746
- const repo = workspaceRepos.find((r) => r.teamKeys?.includes(prefix));
747
- if (repo) {
748
- console.log(`[EdgeWorker] Repository selected: ${repo.name} (team prefix routing)`);
749
- return repo;
750
- }
751
- }
752
- }
753
- // Workspace fallback - find first repo without routing configuration
754
- const catchAllRepo = workspaceRepos.find((repo) => (!repo.teamKeys || repo.teamKeys.length === 0) &&
755
- (!repo.routingLabels || repo.routingLabels.length === 0) &&
756
- (!repo.projectKeys || repo.projectKeys.length === 0));
757
- if (catchAllRepo) {
758
- console.log(`[EdgeWorker] Repository selected: ${catchAllRepo.name} (workspace catch-all)`);
759
- return catchAllRepo;
760
- }
761
- // Final fallback to first workspace repo
762
- const fallbackRepo = workspaceRepos[0] || null;
763
- if (fallbackRepo) {
764
- console.log(`[EdgeWorker] Repository selected: ${fallbackRepo.name} (workspace fallback)`);
765
- }
766
- return fallbackRepo;
767
- }
768
- /**
769
- * Helper method to find repository by project name
706
+ * Get Linear client for a workspace by finding first repository with that workspace ID
770
707
  */
771
- async findRepositoryByProject(issueId, repos) {
772
- // Try each repository that has projectKeys configured
773
- for (const repo of repos) {
774
- if (!repo.projectKeys || repo.projectKeys.length === 0)
775
- continue;
776
- try {
777
- const fullIssue = await this.fetchFullIssueDetails(issueId, repo.id);
778
- const project = await fullIssue?.project;
779
- if (!project || !project.name) {
780
- console.warn(`[EdgeWorker] No project name found for issue ${issueId} in repository ${repo.name}`);
781
- continue;
782
- }
783
- const projectName = project.name;
784
- if (repo.projectKeys.includes(projectName)) {
785
- console.log(`[EdgeWorker] Matched issue ${issueId} to repository ${repo.name} via project: ${projectName}`);
786
- return repo;
787
- }
788
- }
789
- catch (error) {
790
- // Continue to next repository if this one fails
791
- console.debug(`[EdgeWorker] Failed to fetch project for issue ${issueId} from repository ${repo.name}:`, error);
708
+ getLinearClientForWorkspace(workspaceId) {
709
+ for (const [repoId, repo] of this.repositories) {
710
+ if (repo.linearWorkspaceId === workspaceId) {
711
+ return this.linearClients.get(repoId);
792
712
  }
793
713
  }
794
- return null;
714
+ return undefined;
795
715
  }
796
716
  /**
797
717
  * Create a new Linear agent session with all necessary setup
@@ -849,13 +769,67 @@ export class EdgeWorker extends EventEmitter {
849
769
  }
850
770
  /**
851
771
  * Handle agent session created webhook
852
- * . Can happen due to being 'delegated' or @ mentioned in a new thread
853
- * @param webhook
854
- * @param repository Repository configuration
855
- */
856
- async handleAgentSessionCreatedWebhook(webhook, repository) {
772
+ * Can happen due to being 'delegated' or @ mentioned in a new thread
773
+ * @param webhook The agent session created webhook
774
+ * @param repos All available repositories for routing
775
+ */
776
+ async handleAgentSessionCreatedWebhook(webhook, repos) {
777
+ const issueId = webhook.agentSession?.issue?.id;
778
+ // Check the cache first, as the agentSessionCreated webhook may have been triggered by an @mention
779
+ // on an issue that already has an agentSession and an associated repository.
780
+ let repository = null;
781
+ if (issueId) {
782
+ repository = this.getCachedRepository(issueId);
783
+ if (repository) {
784
+ console.log(`[EdgeWorker] Using cached repository ${repository.name} for issue ${issueId}`);
785
+ }
786
+ }
787
+ // If not cached, perform routing logic
788
+ if (!repository) {
789
+ const routingResult = await this.repositoryRouter.determineRepositoryForWebhook(webhook, repos);
790
+ if (routingResult.type === "none") {
791
+ if (process.env.CYRUS_WEBHOOK_DEBUG === "true") {
792
+ console.log(`[EdgeWorker] No repository configured for webhook from workspace ${webhook.organizationId}`);
793
+ }
794
+ return;
795
+ }
796
+ // Handle needs_selection case
797
+ if (routingResult.type === "needs_selection") {
798
+ await this.repositoryRouter.elicitUserRepositorySelection(webhook, routingResult.workspaceRepos);
799
+ // Selection in progress - will be handled by handleRepositorySelectionResponse
800
+ return;
801
+ }
802
+ // At this point, routingResult.type === "selected"
803
+ repository = routingResult.repository;
804
+ const routingMethod = routingResult.routingMethod;
805
+ // Cache the repository for this issue
806
+ if (issueId) {
807
+ this.repositoryRouter
808
+ .getIssueRepositoryCache()
809
+ .set(issueId, repository.id);
810
+ }
811
+ // Post agent activity showing auto-matched routing
812
+ await this.postRepositorySelectionActivity(webhook.agentSession.id, repository.id, repository.name, routingMethod);
813
+ }
857
814
  console.log(`[EdgeWorker] Handling agent session created: ${webhook.agentSession.issue.identifier}`);
858
815
  const { agentSession, guidance } = webhook;
816
+ const commentBody = agentSession.comment?.body;
817
+ // Initialize Claude runner using shared logic
818
+ await this.initializeClaudeRunner(agentSession, repository, guidance, commentBody);
819
+ }
820
+ /**
821
+
822
+ /**
823
+ * Initialize and start Claude runner for an agent session
824
+ * This method contains the shared logic for creating a Claude runner that both
825
+ * handleAgentSessionCreatedWebhook and handleUserPromptedAgentActivity use.
826
+ *
827
+ * @param agentSession The Linear agent session
828
+ * @param repository The repository configuration
829
+ * @param guidance Optional guidance rules from Linear
830
+ * @param commentBody Optional comment body (for mentions)
831
+ */
832
+ async initializeClaudeRunner(agentSession, repository, guidance, commentBody) {
859
833
  const linearAgentActivitySessionId = agentSession.id;
860
834
  const { issue } = agentSession;
861
835
  // Log guidance if present
@@ -874,7 +848,6 @@ export class EdgeWorker extends EventEmitter {
874
848
  console.log(`[EdgeWorker] - ${origin}: ${rule.body.substring(0, 100)}...`);
875
849
  }
876
850
  }
877
- const commentBody = agentSession.comment?.body;
878
851
  // HACK: This is required since the comment body is always populated, thus there is no other way to differentiate between the two trigger events
879
852
  const AGENT_SESSION_MARKER = "This thread is for an agent session";
880
853
  const isMentionTriggered = commentBody && !commentBody.includes(AGENT_SESSION_MARKER);
@@ -982,7 +955,9 @@ export class EdgeWorker extends EventEmitter {
982
955
  }
983
956
  // Build allowed tools list with Linear MCP tools (now with prompt type context)
984
957
  const allowedTools = this.buildAllowedTools(repository, promptType);
985
- const disallowedTools = this.buildDisallowedTools(repository, promptType);
958
+ const baseDisallowedTools = this.buildDisallowedTools(repository, promptType);
959
+ // Merge subroutine-level disallowedTools if applicable
960
+ const disallowedTools = this.mergeSubroutineDisallowedTools(session, baseDisallowedTools, "EdgeWorker");
986
961
  console.log(`[EdgeWorker] Configured allowed tools for ${fullIssue.identifier}:`, allowedTools);
987
962
  if (disallowedTools.length > 0) {
988
963
  console.log(`[EdgeWorker] Configured disallowed tools for ${fullIssue.identifier}:`, disallowedTools);
@@ -1018,13 +993,78 @@ export class EdgeWorker extends EventEmitter {
1018
993
  }
1019
994
  }
1020
995
  /**
1021
- * Handle new comment on issue (updated for comment-based sessions)
1022
- * @param issue Linear issue object from webhook data
1023
- * @param comment Linear comment object from webhook data
1024
- * @param repository Repository configuration
996
+ * Handle stop signal from prompted webhook
997
+ * Branch 1 of agentSessionPrompted (see packages/CLAUDE.md)
998
+ *
999
+ * IMPORTANT: Stop signals do NOT require repository lookup.
1000
+ * The session must already exist (per CLAUDE.md), so we search
1001
+ * all agent session managers to find it.
1002
+ */
1003
+ async handleStopSignal(webhook) {
1004
+ const agentSessionId = webhook.agentSession.id;
1005
+ const { issue } = webhook.agentSession;
1006
+ console.log(`[EdgeWorker] Received stop signal for agent activity session ${agentSessionId}`);
1007
+ // Find the agent session manager that contains this session
1008
+ // We don't need repository lookup - just search all managers
1009
+ let foundManager = null;
1010
+ let foundSession = null;
1011
+ for (const manager of this.agentSessionManagers.values()) {
1012
+ const session = manager.getSession(agentSessionId);
1013
+ if (session) {
1014
+ foundManager = manager;
1015
+ foundSession = session;
1016
+ break;
1017
+ }
1018
+ }
1019
+ if (!foundManager || !foundSession) {
1020
+ console.warn(`[EdgeWorker] No session found for stop signal: ${agentSessionId}`);
1021
+ return;
1022
+ }
1023
+ // Stop the existing runner if it's active
1024
+ const existingRunner = foundSession.claudeRunner;
1025
+ if (existingRunner) {
1026
+ existingRunner.stop();
1027
+ console.log(`[EdgeWorker] Stopped Claude session for agent activity session ${agentSessionId}`);
1028
+ }
1029
+ // Post confirmation
1030
+ const issueTitle = issue.title || "this issue";
1031
+ const stopConfirmation = `I've stopped working on ${issueTitle} as requested.\n\n**Stop Signal:** Received from ${webhook.agentSession.creator?.name || "user"}\n**Action Taken:** All ongoing work has been halted`;
1032
+ await foundManager.createResponseActivity(agentSessionId, stopConfirmation);
1033
+ }
1034
+ /**
1035
+ * Handle repository selection response from prompted webhook
1036
+ * Branch 2 of agentSessionPrompted (see packages/CLAUDE.md)
1037
+ *
1038
+ * This method extracts the user's repository selection from their response,
1039
+ * or uses the fallback repository if their message doesn't match any option.
1040
+ * In both cases, the selected repository is cached for future use.
1025
1041
  */
1026
- async handleUserPostedAgentActivity(webhook, repository) {
1027
- // Look for existing session for this comment thread
1042
+ async handleRepositorySelectionResponse(webhook) {
1043
+ const { agentSession, agentActivity, guidance } = webhook;
1044
+ const commentBody = agentSession.comment?.body;
1045
+ const agentSessionId = agentSession.id;
1046
+ const userMessage = agentActivity.content.body;
1047
+ console.log(`[EdgeWorker] Processing repository selection response: "${userMessage}"`);
1048
+ // Get the selected repository (or fallback)
1049
+ const repository = await this.repositoryRouter.selectRepositoryFromResponse(agentSessionId, userMessage);
1050
+ if (!repository) {
1051
+ console.error(`[EdgeWorker] Failed to select repository for agent session ${agentSessionId}`);
1052
+ return;
1053
+ }
1054
+ // Cache the selected repository for this issue
1055
+ const issueId = agentSession.issue.id;
1056
+ this.repositoryRouter.getIssueRepositoryCache().set(issueId, repository.id);
1057
+ // Post agent activity showing user-selected repository
1058
+ await this.postRepositorySelectionActivity(agentSessionId, repository.id, repository.name, "user-selected");
1059
+ console.log(`[EdgeWorker] Initializing Claude runner after repository selection: ${agentSession.issue.identifier} -> ${repository.name}`);
1060
+ // Initialize Claude runner with the selected repository
1061
+ await this.initializeClaudeRunner(agentSession, repository, guidance, commentBody);
1062
+ }
1063
+ /**
1064
+ * Handle normal prompted activity (existing session continuation)
1065
+ * Branch 3 of agentSessionPrompted (see packages/CLAUDE.md)
1066
+ */
1067
+ async handleNormalPromptedActivity(webhook, repository) {
1028
1068
  const { agentSession } = webhook;
1029
1069
  const linearAgentActivitySessionId = agentSession.id;
1030
1070
  const { issue } = agentSession;
@@ -1133,21 +1173,6 @@ export class EdgeWorker extends EventEmitter {
1133
1173
  console.error("Failed to fetch comments for attachments:", error);
1134
1174
  }
1135
1175
  const promptBody = webhook.agentActivity.content.body;
1136
- const stopSignal = webhook.agentActivity.signal === "stop";
1137
- // Handle stop signal
1138
- if (stopSignal) {
1139
- console.log(`[EdgeWorker] Received stop signal for agent activity session ${linearAgentActivitySessionId}`);
1140
- // Stop the existing runner if it's active
1141
- const existingRunner = session.claudeRunner;
1142
- if (existingRunner) {
1143
- existingRunner.stop();
1144
- console.log(`[EdgeWorker] Stopped Claude session for agent activity session ${linearAgentActivitySessionId}`);
1145
- }
1146
- const issueTitle = issue.title || "this issue";
1147
- const stopConfirmation = `I've stopped working on ${issueTitle} as requested.\n\n**Stop Signal:** Received from ${webhook.agentSession.creator?.name || "user"}\n**Action Taken:** All ongoing work has been halted`;
1148
- await agentSessionManager.createResponseActivity(linearAgentActivitySessionId, stopConfirmation);
1149
- return; // Exit early - stop signal handled
1150
- }
1151
1176
  // Use centralized streaming check and routing logic
1152
1177
  try {
1153
1178
  await this.handlePromptWithStreamingCheck(session, repository, linearAgentActivitySessionId, agentSessionManager, promptBody, attachmentManifest, isNewSession, [], // No additional allowed directories for regular continuation
@@ -1157,6 +1182,48 @@ export class EdgeWorker extends EventEmitter {
1157
1182
  console.error("Failed to handle prompted webhook:", error);
1158
1183
  }
1159
1184
  }
1185
+ /**
1186
+ * Handle user-prompted agent activity webhook
1187
+ * Implements three-branch architecture from packages/CLAUDE.md:
1188
+ * 1. Stop signal - terminate existing runner
1189
+ * 2. Repository selection response - initialize Claude runner for first time
1190
+ * 3. Normal prompted activity - continue existing session or create new one
1191
+ *
1192
+ * @param webhook The prompted webhook containing user's message
1193
+ */
1194
+ async handleUserPromptedAgentActivity(webhook) {
1195
+ const agentSessionId = webhook.agentSession.id;
1196
+ // Branch 1: Handle stop signal (checked FIRST, before any routing work)
1197
+ // Per CLAUDE.md: "an agentSession MUST already exist" for stop signals
1198
+ // IMPORTANT: Stop signals do NOT require repository lookup
1199
+ if (webhook.agentActivity.signal === "stop") {
1200
+ await this.handleStopSignal(webhook);
1201
+ return;
1202
+ }
1203
+ // Branch 2: Handle repository selection response
1204
+ // This is the first Claude runner initialization after user selects a repository.
1205
+ // The selection handler extracts the choice from the response (or uses fallback)
1206
+ // and caches the repository for future use.
1207
+ if (this.repositoryRouter.hasPendingSelection(agentSessionId)) {
1208
+ await this.handleRepositorySelectionResponse(webhook);
1209
+ return;
1210
+ }
1211
+ // Branch 3: Handle normal prompted activity (existing session continuation)
1212
+ // Per CLAUDE.md: "an agentSession MUST exist and a repository MUST already
1213
+ // be associated with the Linear issue. The repository will be retrieved from
1214
+ // the issue-to-repository cache - no new routing logic is performed."
1215
+ const issueId = webhook.agentSession?.issue?.id;
1216
+ if (!issueId) {
1217
+ console.error(`[EdgeWorker] No issue ID found in prompted webhook ${agentSessionId}`);
1218
+ return;
1219
+ }
1220
+ const repository = this.getCachedRepository(issueId);
1221
+ if (!repository) {
1222
+ console.warn(`[EdgeWorker] No cached repository found for prompted webhook ${agentSessionId}`);
1223
+ return;
1224
+ }
1225
+ await this.handleNormalPromptedActivity(webhook, repository);
1226
+ }
1160
1227
  /**
1161
1228
  * Handle issue unassignment
1162
1229
  * @param issue Linear issue object from webhook data
@@ -2520,7 +2587,7 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ""}Please ana
2520
2587
  const currentSubroutine = this.procedureRouter.getCurrentSubroutine(input.session);
2521
2588
  let subroutineName;
2522
2589
  if (currentSubroutine) {
2523
- const subroutinePrompt = await this.loadSubroutinePrompt(currentSubroutine);
2590
+ const subroutinePrompt = await this.loadSubroutinePrompt(currentSubroutine, this.config.linearWorkspaceSlug);
2524
2591
  if (subroutinePrompt) {
2525
2592
  parts.push(subroutinePrompt);
2526
2593
  components.push("subroutine-prompt");
@@ -2616,7 +2683,7 @@ ${input.userComment}
2616
2683
  * Load a subroutine prompt file
2617
2684
  * Extracted helper to make prompt assembly more readable
2618
2685
  */
2619
- async loadSubroutinePrompt(subroutine) {
2686
+ async loadSubroutinePrompt(subroutine, workspaceSlug) {
2620
2687
  // Skip loading for "primary" - it's a placeholder that doesn't have a file
2621
2688
  if (subroutine.promptPath === "primary") {
2622
2689
  return null;
@@ -2625,8 +2692,12 @@ ${input.userComment}
2625
2692
  const __dirname = dirname(__filename);
2626
2693
  const subroutinePromptPath = join(__dirname, "prompts", subroutine.promptPath);
2627
2694
  try {
2628
- const prompt = await readFile(subroutinePromptPath, "utf-8");
2695
+ let prompt = await readFile(subroutinePromptPath, "utf-8");
2629
2696
  console.log(`[EdgeWorker] Loaded ${subroutine.name} subroutine prompt (${prompt.length} characters)`);
2697
+ // Perform template substitution if workspace slug is provided
2698
+ if (workspaceSlug) {
2699
+ prompt = prompt.replace(/https:\/\/linear\.app\/linear\/profiles\//g, `https://linear.app/${workspaceSlug}/profiles/`);
2700
+ }
2630
2701
  return prompt;
2631
2702
  }
2632
2703
  catch (error) {
@@ -2797,6 +2868,27 @@ ${input.userComment}
2797
2868
  }
2798
2869
  return disallowedTools;
2799
2870
  }
2871
+ /**
2872
+ * Merge subroutine-level disallowedTools with base disallowedTools
2873
+ * @param session Current agent session
2874
+ * @param baseDisallowedTools Base disallowed tools from repository/global config
2875
+ * @param logContext Context string for logging (e.g., "EdgeWorker", "resumeClaudeSession")
2876
+ * @returns Merged disallowed tools list
2877
+ */
2878
+ mergeSubroutineDisallowedTools(session, baseDisallowedTools, logContext) {
2879
+ const currentSubroutine = this.procedureRouter.getCurrentSubroutine(session);
2880
+ if (currentSubroutine?.disallowedTools) {
2881
+ const mergedTools = [
2882
+ ...new Set([
2883
+ ...baseDisallowedTools,
2884
+ ...currentSubroutine.disallowedTools,
2885
+ ]),
2886
+ ];
2887
+ console.log(`[${logContext}] Merged subroutine-level disallowedTools for ${currentSubroutine.name}:`, currentSubroutine.disallowedTools);
2888
+ return mergedTools;
2889
+ }
2890
+ return baseDisallowedTools;
2891
+ }
2800
2892
  /**
2801
2893
  * Build allowed tools list with Linear MCP tools automatically included
2802
2894
  */
@@ -2890,10 +2982,13 @@ ${input.userComment}
2890
2982
  }
2891
2983
  // Serialize child to parent agent session mapping
2892
2984
  const childToParentAgentSession = Object.fromEntries(this.childToParentAgentSession.entries());
2985
+ // Serialize issue to repository cache from RepositoryRouter
2986
+ const issueRepositoryCache = Object.fromEntries(this.repositoryRouter.getIssueRepositoryCache().entries());
2893
2987
  return {
2894
2988
  agentSessions,
2895
2989
  agentSessionEntries,
2896
2990
  childToParentAgentSession,
2991
+ issueRepositoryCache,
2897
2992
  };
2898
2993
  }
2899
2994
  /**
@@ -2917,6 +3012,12 @@ ${input.userComment}
2917
3012
  this.childToParentAgentSession = new Map(Object.entries(state.childToParentAgentSession));
2918
3013
  console.log(`[EdgeWorker] Restored ${this.childToParentAgentSession.size} child-to-parent agent session mappings`);
2919
3014
  }
3015
+ // Restore issue to repository cache in RepositoryRouter
3016
+ if (state.issueRepositoryCache) {
3017
+ const cache = new Map(Object.entries(state.issueRepositoryCache));
3018
+ this.repositoryRouter.restoreIssueRepositoryCache(cache);
3019
+ console.log(`[EdgeWorker] Restored ${cache.size} issue-to-repository cache mappings`);
3020
+ }
2920
3021
  }
2921
3022
  /**
2922
3023
  * Post instant acknowledgment thought when agent session is created
@@ -2976,6 +3077,58 @@ ${input.userComment}
2976
3077
  console.error(`[EdgeWorker] Error posting parent resumption acknowledgment:`, error);
2977
3078
  }
2978
3079
  }
3080
+ /**
3081
+ * Post repository selection activity to Linear
3082
+ * Shows which method was used to select the repository (auto-routing or user selection)
3083
+ */
3084
+ async postRepositorySelectionActivity(linearAgentActivitySessionId, repositoryId, repositoryName, selectionMethod) {
3085
+ try {
3086
+ const linearClient = this.linearClients.get(repositoryId);
3087
+ if (!linearClient) {
3088
+ console.warn(`[EdgeWorker] No Linear client found for repository ${repositoryId}`);
3089
+ return;
3090
+ }
3091
+ let methodDisplay;
3092
+ if (selectionMethod === "user-selected") {
3093
+ methodDisplay = "selected by user";
3094
+ }
3095
+ else if (selectionMethod === "label-based") {
3096
+ methodDisplay = "matched via label-based routing";
3097
+ }
3098
+ else if (selectionMethod === "project-based") {
3099
+ methodDisplay = "matched via project-based routing";
3100
+ }
3101
+ else if (selectionMethod === "team-based") {
3102
+ methodDisplay = "matched via team-based routing";
3103
+ }
3104
+ else if (selectionMethod === "team-prefix") {
3105
+ methodDisplay = "matched via team prefix routing";
3106
+ }
3107
+ else if (selectionMethod === "catch-all") {
3108
+ methodDisplay = "matched via catch-all routing";
3109
+ }
3110
+ else {
3111
+ methodDisplay = "matched via workspace fallback";
3112
+ }
3113
+ const activityInput = {
3114
+ agentSessionId: linearAgentActivitySessionId,
3115
+ content: {
3116
+ type: "thought",
3117
+ body: `Repository "${repositoryName}" has been ${methodDisplay}.`,
3118
+ },
3119
+ };
3120
+ const result = await linearClient.createAgentActivity(activityInput);
3121
+ if (result.success) {
3122
+ console.log(`[EdgeWorker] Posted repository selection activity for session ${linearAgentActivitySessionId} (${selectionMethod})`);
3123
+ }
3124
+ else {
3125
+ console.error(`[EdgeWorker] Failed to post repository selection activity:`, result);
3126
+ }
3127
+ }
3128
+ catch (error) {
3129
+ console.error(`[EdgeWorker] Error posting repository selection activity:`, error);
3130
+ }
3131
+ }
2979
3132
  /**
2980
3133
  * Re-route procedure for a session (used when resuming from child or give feedback)
2981
3134
  * This ensures the currentSubroutine is reset to avoid suppression issues
@@ -3210,7 +3363,9 @@ ${input.userComment}
3210
3363
  const promptType = systemPromptResult?.type;
3211
3364
  // Build allowed tools list
3212
3365
  const allowedTools = this.buildAllowedTools(repository, promptType);
3213
- const disallowedTools = this.buildDisallowedTools(repository, promptType);
3366
+ const baseDisallowedTools = this.buildDisallowedTools(repository, promptType);
3367
+ // Merge subroutine-level disallowedTools if applicable
3368
+ const disallowedTools = this.mergeSubroutineDisallowedTools(session, baseDisallowedTools, "resumeClaudeSession");
3214
3369
  // Set up attachments directory
3215
3370
  const workspaceFolderName = basename(session.workspace.path);
3216
3371
  const attachmentsDir = join(this.cyrusHome, workspaceFolderName, "attachments");