sylas-edge-worker 0.2.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. package/README.md +293 -0
  2. package/dist/ActivityPoster.d.ts +15 -0
  3. package/dist/ActivityPoster.d.ts.map +1 -0
  4. package/dist/ActivityPoster.js +194 -0
  5. package/dist/ActivityPoster.js.map +1 -0
  6. package/dist/AgentSessionManager.d.ts +280 -0
  7. package/dist/AgentSessionManager.d.ts.map +1 -0
  8. package/dist/AgentSessionManager.js +1412 -0
  9. package/dist/AgentSessionManager.js.map +1 -0
  10. package/dist/AskUserQuestionHandler.d.ts +97 -0
  11. package/dist/AskUserQuestionHandler.d.ts.map +1 -0
  12. package/dist/AskUserQuestionHandler.js +206 -0
  13. package/dist/AskUserQuestionHandler.js.map +1 -0
  14. package/dist/AttachmentService.d.ts +69 -0
  15. package/dist/AttachmentService.d.ts.map +1 -0
  16. package/dist/AttachmentService.js +369 -0
  17. package/dist/AttachmentService.js.map +1 -0
  18. package/dist/ChatSessionHandler.d.ts +87 -0
  19. package/dist/ChatSessionHandler.d.ts.map +1 -0
  20. package/dist/ChatSessionHandler.js +231 -0
  21. package/dist/ChatSessionHandler.js.map +1 -0
  22. package/dist/ConfigManager.d.ts +91 -0
  23. package/dist/ConfigManager.d.ts.map +1 -0
  24. package/dist/ConfigManager.js +227 -0
  25. package/dist/ConfigManager.js.map +1 -0
  26. package/dist/EdgeWorker.d.ts +670 -0
  27. package/dist/EdgeWorker.d.ts.map +1 -0
  28. package/dist/EdgeWorker.js +3801 -0
  29. package/dist/EdgeWorker.js.map +1 -0
  30. package/dist/GitService.d.ts +39 -0
  31. package/dist/GitService.d.ts.map +1 -0
  32. package/dist/GitService.js +432 -0
  33. package/dist/GitService.js.map +1 -0
  34. package/dist/GlobalSessionRegistry.d.ts +142 -0
  35. package/dist/GlobalSessionRegistry.d.ts.map +1 -0
  36. package/dist/GlobalSessionRegistry.js +254 -0
  37. package/dist/GlobalSessionRegistry.js.map +1 -0
  38. package/dist/PromptBuilder.d.ts +175 -0
  39. package/dist/PromptBuilder.d.ts.map +1 -0
  40. package/dist/PromptBuilder.js +884 -0
  41. package/dist/PromptBuilder.js.map +1 -0
  42. package/dist/RepositoryRouter.d.ts +152 -0
  43. package/dist/RepositoryRouter.d.ts.map +1 -0
  44. package/dist/RepositoryRouter.js +480 -0
  45. package/dist/RepositoryRouter.js.map +1 -0
  46. package/dist/RunnerSelectionService.d.ts +62 -0
  47. package/dist/RunnerSelectionService.d.ts.map +1 -0
  48. package/dist/RunnerSelectionService.js +379 -0
  49. package/dist/RunnerSelectionService.js.map +1 -0
  50. package/dist/SharedApplicationServer.d.ts +107 -0
  51. package/dist/SharedApplicationServer.d.ts.map +1 -0
  52. package/dist/SharedApplicationServer.js +247 -0
  53. package/dist/SharedApplicationServer.js.map +1 -0
  54. package/dist/SharedWebhookServer.d.ts +39 -0
  55. package/dist/SharedWebhookServer.d.ts.map +1 -0
  56. package/dist/SharedWebhookServer.js +150 -0
  57. package/dist/SharedWebhookServer.js.map +1 -0
  58. package/dist/SlackChatAdapter.d.ts +25 -0
  59. package/dist/SlackChatAdapter.d.ts.map +1 -0
  60. package/dist/SlackChatAdapter.js +143 -0
  61. package/dist/SlackChatAdapter.js.map +1 -0
  62. package/dist/UserAccessControl.d.ts +69 -0
  63. package/dist/UserAccessControl.d.ts.map +1 -0
  64. package/dist/UserAccessControl.js +171 -0
  65. package/dist/UserAccessControl.js.map +1 -0
  66. package/dist/WorktreeIncludeService.d.ts +32 -0
  67. package/dist/WorktreeIncludeService.d.ts.map +1 -0
  68. package/dist/WorktreeIncludeService.js +123 -0
  69. package/dist/WorktreeIncludeService.js.map +1 -0
  70. package/dist/index.d.ts +22 -0
  71. package/dist/index.d.ts.map +1 -0
  72. package/dist/index.js +17 -0
  73. package/dist/index.js.map +1 -0
  74. package/dist/label-prompt-template.md +27 -0
  75. package/dist/procedures/ProcedureAnalyzer.d.ts +69 -0
  76. package/dist/procedures/ProcedureAnalyzer.d.ts.map +1 -0
  77. package/dist/procedures/ProcedureAnalyzer.js +271 -0
  78. package/dist/procedures/ProcedureAnalyzer.js.map +1 -0
  79. package/dist/procedures/index.d.ts +7 -0
  80. package/dist/procedures/index.d.ts.map +1 -0
  81. package/dist/procedures/index.js +7 -0
  82. package/dist/procedures/index.js.map +1 -0
  83. package/dist/procedures/registry.d.ts +156 -0
  84. package/dist/procedures/registry.d.ts.map +1 -0
  85. package/dist/procedures/registry.js +240 -0
  86. package/dist/procedures/registry.js.map +1 -0
  87. package/dist/procedures/types.d.ts +103 -0
  88. package/dist/procedures/types.d.ts.map +1 -0
  89. package/dist/procedures/types.js +5 -0
  90. package/dist/procedures/types.js.map +1 -0
  91. package/dist/prompt-assembly/types.d.ts +80 -0
  92. package/dist/prompt-assembly/types.d.ts.map +1 -0
  93. package/dist/prompt-assembly/types.js +8 -0
  94. package/dist/prompt-assembly/types.js.map +1 -0
  95. package/dist/prompts/builder.md +191 -0
  96. package/dist/prompts/debugger.md +128 -0
  97. package/dist/prompts/graphite-orchestrator.md +362 -0
  98. package/dist/prompts/orchestrator.md +290 -0
  99. package/dist/prompts/scoper.md +95 -0
  100. package/dist/prompts/standard-issue-assigned-user-prompt.md +33 -0
  101. package/dist/prompts/subroutines/changelog-update.md +79 -0
  102. package/dist/prompts/subroutines/coding-activity.md +12 -0
  103. package/dist/prompts/subroutines/concise-summary.md +67 -0
  104. package/dist/prompts/subroutines/debugger-fix.md +92 -0
  105. package/dist/prompts/subroutines/debugger-reproduction.md +74 -0
  106. package/dist/prompts/subroutines/full-delegation.md +68 -0
  107. package/dist/prompts/subroutines/get-approval.md +175 -0
  108. package/dist/prompts/subroutines/gh-pr.md +80 -0
  109. package/dist/prompts/subroutines/git-commit.md +37 -0
  110. package/dist/prompts/subroutines/plan-summary.md +21 -0
  111. package/dist/prompts/subroutines/preparation.md +16 -0
  112. package/dist/prompts/subroutines/question-answer.md +8 -0
  113. package/dist/prompts/subroutines/question-investigation.md +8 -0
  114. package/dist/prompts/subroutines/release-execution.md +81 -0
  115. package/dist/prompts/subroutines/release-summary.md +60 -0
  116. package/dist/prompts/subroutines/user-testing-summary.md +87 -0
  117. package/dist/prompts/subroutines/user-testing.md +48 -0
  118. package/dist/prompts/subroutines/validation-fixer.md +56 -0
  119. package/dist/prompts/subroutines/verbose-summary.md +46 -0
  120. package/dist/prompts/subroutines/verifications.md +77 -0
  121. package/dist/prompts/todolist-system-prompt-extension.md +15 -0
  122. package/dist/sinks/IActivitySink.d.ts +60 -0
  123. package/dist/sinks/IActivitySink.d.ts.map +1 -0
  124. package/dist/sinks/IActivitySink.js +2 -0
  125. package/dist/sinks/IActivitySink.js.map +1 -0
  126. package/dist/sinks/LinearActivitySink.d.ts +69 -0
  127. package/dist/sinks/LinearActivitySink.d.ts.map +1 -0
  128. package/dist/sinks/LinearActivitySink.js +111 -0
  129. package/dist/sinks/LinearActivitySink.js.map +1 -0
  130. package/dist/sinks/NoopActivitySink.d.ts +13 -0
  131. package/dist/sinks/NoopActivitySink.d.ts.map +1 -0
  132. package/dist/sinks/NoopActivitySink.js +17 -0
  133. package/dist/sinks/NoopActivitySink.js.map +1 -0
  134. package/dist/sinks/index.d.ts +9 -0
  135. package/dist/sinks/index.d.ts.map +1 -0
  136. package/dist/sinks/index.js +8 -0
  137. package/dist/sinks/index.js.map +1 -0
  138. package/dist/types.d.ts +32 -0
  139. package/dist/types.d.ts.map +1 -0
  140. package/dist/types.js +2 -0
  141. package/dist/types.js.map +1 -0
  142. package/dist/validation/ValidationLoopController.d.ts +54 -0
  143. package/dist/validation/ValidationLoopController.d.ts.map +1 -0
  144. package/dist/validation/ValidationLoopController.js +242 -0
  145. package/dist/validation/ValidationLoopController.js.map +1 -0
  146. package/dist/validation/index.d.ts +7 -0
  147. package/dist/validation/index.d.ts.map +1 -0
  148. package/dist/validation/index.js +7 -0
  149. package/dist/validation/index.js.map +1 -0
  150. package/dist/validation/types.d.ts +82 -0
  151. package/dist/validation/types.d.ts.map +1 -0
  152. package/dist/validation/types.js +29 -0
  153. package/dist/validation/types.js.map +1 -0
  154. package/label-prompt-template.md +27 -0
  155. package/package.json +56 -0
  156. package/prompt-template.md +116 -0
  157. package/prompts/builder.md +191 -0
  158. package/prompts/debugger.md +128 -0
  159. package/prompts/graphite-orchestrator.md +362 -0
  160. package/prompts/orchestrator.md +290 -0
  161. package/prompts/scoper.md +95 -0
  162. package/prompts/standard-issue-assigned-user-prompt.md +33 -0
  163. package/prompts/todolist-system-prompt-extension.md +15 -0
@@ -0,0 +1,3801 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { EventEmitter } from "node:events";
3
+ import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
4
+ import { basename, join } from "node:path";
5
+ import { LinearClient } from "@linear/sdk";
6
+ import { Sessions, streamableHttp } from "fastify-mcp";
7
+ import { ClaudeRunner, createImageToolsServer, createSoraToolsServer, } from "sylas-claude-runner";
8
+ import { CodexRunner } from "sylas-codex-runner";
9
+ import { ConfigUpdater } from "sylas-config-updater";
10
+ import { CLIIssueTrackerService, CLIRPCServer, createLogger, DEFAULT_PROXY_URL, isAgentSessionCreatedWebhook, isAgentSessionPromptedWebhook, isContentUpdateMessage, isIssueAssignedWebhook, isIssueCommentMentionWebhook, isIssueNewCommentWebhook, isIssueTitleOrDescriptionUpdateWebhook, isIssueUnassignedWebhook, isSessionStartMessage, isStopSignalMessage, isUnassignMessage, isUserPromptMessage, PersistenceManager, resolvePath, } from "sylas-core";
11
+ import { CursorRunner } from "sylas-cursor-runner";
12
+ import { GeminiRunner } from "sylas-gemini-runner";
13
+ import { extractCommentAuthor, extractCommentBody, extractCommentId, extractCommentUrl, extractPRBranchRef, extractPRNumber, extractPRTitle, extractRepoFullName, extractRepoName, extractRepoOwner, extractSessionKey, GitHubCommentService, GitHubEventTransport, isCommentOnPullRequest, isIssueCommentPayload, isPullRequestReviewCommentPayload, stripMention, } from "sylas-github-event-transport";
14
+ import { LinearEventTransport, LinearIssueTrackerService, } from "sylas-linear-event-transport";
15
+ import { createSylasToolsServer, } from "sylas-mcp-tools";
16
+ import { OpenCodeRunner } from "sylas-opencode-runner";
17
+ import { SlackEventTransport, } from "sylas-slack-event-transport";
18
+ import { ActivityPoster } from "./ActivityPoster.js";
19
+ import { AgentSessionManager } from "./AgentSessionManager.js";
20
+ import { AskUserQuestionHandler } from "./AskUserQuestionHandler.js";
21
+ import { AttachmentService } from "./AttachmentService.js";
22
+ import { ChatSessionHandler } from "./ChatSessionHandler.js";
23
+ import { ConfigManager } from "./ConfigManager.js";
24
+ import { GitService } from "./GitService.js";
25
+ import { GlobalSessionRegistry } from "./GlobalSessionRegistry.js";
26
+ import { PromptBuilder } from "./PromptBuilder.js";
27
+ import { ProcedureAnalyzer, } from "./procedures/index.js";
28
+ import { RepositoryRouter, } from "./RepositoryRouter.js";
29
+ import { RunnerSelectionService } from "./RunnerSelectionService.js";
30
+ import { SharedApplicationServer } from "./SharedApplicationServer.js";
31
+ import { SlackChatAdapter } from "./SlackChatAdapter.js";
32
+ import { LinearActivitySink } from "./sinks/LinearActivitySink.js";
33
+ import { UserAccessControl } from "./UserAccessControl.js";
34
+ /**
35
+ * Unified edge worker that **orchestrates**
36
+ * capturing Linear webhooks,
37
+ * managing Claude Code processes, and
38
+ * processes results through to Linear Agent Activity Sessions
39
+ */
40
+ export class EdgeWorker extends EventEmitter {
41
+ config;
42
+ repositories = new Map(); // repository 'id' (internal, stored in config.json) mapped to the full repo config
43
+ agentSessionManagers = new Map(); // Maps repository ID to AgentSessionManager, which manages agent runners for a repo
44
+ issueTrackers = new Map(); // one issue tracker per 'repository'
45
+ linearEventTransport = null; // Single event transport for webhook delivery
46
+ gitHubEventTransport = null; // GitHub event transport for forwarded GitHub webhooks
47
+ slackEventTransport = null;
48
+ chatSessionHandler = null;
49
+ gitHubCommentService; // Service for posting comments back to GitHub PRs
50
+ cliRPCServer = null; // CLI RPC server for CLI platform mode
51
+ configUpdater = null; // Single config updater for configuration updates
52
+ persistenceManager;
53
+ sharedApplicationServer;
54
+ sylasHome;
55
+ globalSessionRegistry; // Centralized session storage across all repositories
56
+ childToParentAgentSession = new Map(); // Maps child agentSessionId to parent agentSessionId
57
+ procedureAnalyzer; // Intelligent workflow routing
58
+ configPath; // Path to config.json file
59
+ /** @internal - Exposed for testing only */
60
+ repositoryRouter; // Repository routing and selection
61
+ gitService;
62
+ activeWebhookCount = 0; // Track number of webhooks currently being processed
63
+ /** Handler for AskUserQuestion tool invocations via Linear select signal */
64
+ askUserQuestionHandler;
65
+ /** User access control for whitelisting/blacklisting Linear users */
66
+ userAccessControl;
67
+ logger;
68
+ // Extracted service modules
69
+ attachmentService;
70
+ runnerSelectionService;
71
+ activityPoster;
72
+ configManager;
73
+ promptBuilder;
74
+ sylasToolsMcpEndpoint = "/mcp/sylas-tools";
75
+ sylasToolsMcpRegistered = false;
76
+ sylasToolsMcpContexts = new Map();
77
+ sylasToolsMcpRequestContext = new AsyncLocalStorage();
78
+ sylasToolsMcpSessions = new Sessions();
79
+ constructor(config) {
80
+ super();
81
+ this.config = config;
82
+ this.sylasHome = config.sylasHome;
83
+ this.logger = createLogger({ component: "EdgeWorker" });
84
+ this.persistenceManager = new PersistenceManager(join(this.sylasHome, "state"));
85
+ // Initialize GitHub comment service for posting replies to GitHub PRs
86
+ this.gitHubCommentService = new GitHubCommentService();
87
+ // Initialize global session registry (centralized session storage)
88
+ this.globalSessionRegistry = new GlobalSessionRegistry();
89
+ // Initialize procedure router with haiku for fast classification
90
+ // Default to claude runner
91
+ this.procedureAnalyzer = new ProcedureAnalyzer({
92
+ sylasHome: this.sylasHome,
93
+ model: "gemini-2.5-flash-lite",
94
+ timeoutMs: 100000,
95
+ runnerType: "gemini",
96
+ });
97
+ // Initialize repository router with dependencies
98
+ const repositoryRouterDeps = {
99
+ fetchIssueLabels: async (issueId, workspaceId) => {
100
+ // Find repository for this workspace
101
+ const repo = Array.from(this.repositories.values()).find((r) => r.linearWorkspaceId === workspaceId);
102
+ if (!repo)
103
+ return [];
104
+ // Get issue tracker for this repository
105
+ const issueTracker = this.issueTrackers.get(repo.id);
106
+ if (!issueTracker)
107
+ return [];
108
+ // Use platform-agnostic getIssueLabels method
109
+ return await issueTracker.getIssueLabels(issueId);
110
+ },
111
+ fetchIssueDescription: async (issueId, workspaceId) => {
112
+ // Find repository for this workspace
113
+ const repo = Array.from(this.repositories.values()).find((r) => r.linearWorkspaceId === workspaceId);
114
+ if (!repo)
115
+ return undefined;
116
+ // Get issue tracker for this repository
117
+ const issueTracker = this.issueTrackers.get(repo.id);
118
+ if (!issueTracker)
119
+ return undefined;
120
+ // Fetch issue and get description
121
+ try {
122
+ const issue = await issueTracker.fetchIssue(issueId);
123
+ return issue?.description ?? undefined;
124
+ }
125
+ catch (error) {
126
+ this.logger.error(`Failed to fetch issue description for routing:`, error);
127
+ return undefined;
128
+ }
129
+ },
130
+ hasActiveSession: (issueId, repositoryId) => {
131
+ const sessionManager = this.agentSessionManagers.get(repositoryId);
132
+ if (!sessionManager)
133
+ return false;
134
+ const activeSessions = sessionManager.getActiveSessionsByIssueId(issueId);
135
+ return activeSessions.length > 0;
136
+ },
137
+ getIssueTracker: (workspaceId) => {
138
+ return this.getIssueTrackerForWorkspace(workspaceId);
139
+ },
140
+ };
141
+ this.repositoryRouter = new RepositoryRouter(repositoryRouterDeps);
142
+ this.gitService = new GitService();
143
+ // Initialize AskUserQuestion handler for elicitation via Linear select signal
144
+ this.askUserQuestionHandler = new AskUserQuestionHandler({
145
+ getIssueTracker: (workspaceId) => {
146
+ return this.getIssueTrackerForWorkspace(workspaceId) ?? null;
147
+ },
148
+ });
149
+ // Initialize shared application server
150
+ const serverPort = config.serverPort || config.webhookPort || 3456;
151
+ const serverHost = config.serverHost || "localhost";
152
+ const skipTunnel = config.platform === "cli"; // Skip Cloudflare tunnel in CLI mode
153
+ this.sharedApplicationServer = new SharedApplicationServer(serverPort, serverHost, skipTunnel);
154
+ // Initialize repositories with path resolution
155
+ for (const repo of config.repositories) {
156
+ if (repo.isActive !== false) {
157
+ // Resolve paths that may contain tilde (~) prefix
158
+ const resolvedRepo = {
159
+ ...repo,
160
+ repositoryPath: resolvePath(repo.repositoryPath),
161
+ workspaceBaseDir: resolvePath(repo.workspaceBaseDir),
162
+ mcpConfigPath: Array.isArray(repo.mcpConfigPath)
163
+ ? repo.mcpConfigPath.map(resolvePath)
164
+ : repo.mcpConfigPath
165
+ ? resolvePath(repo.mcpConfigPath)
166
+ : undefined,
167
+ promptTemplatePath: repo.promptTemplatePath
168
+ ? resolvePath(repo.promptTemplatePath)
169
+ : undefined,
170
+ openaiOutputDirectory: repo.openaiOutputDirectory
171
+ ? resolvePath(repo.openaiOutputDirectory)
172
+ : undefined,
173
+ };
174
+ this.repositories.set(repo.id, resolvedRepo);
175
+ // Create issue tracker for this repository's workspace
176
+ const issueTracker = this.config.platform === "cli"
177
+ ? (() => {
178
+ const service = new CLIIssueTrackerService();
179
+ service.seedDefaultData();
180
+ return service;
181
+ })()
182
+ : new LinearIssueTrackerService(new LinearClient({
183
+ accessToken: repo.linearToken,
184
+ }), this.buildOAuthConfig(resolvedRepo));
185
+ this.issueTrackers.set(repo.id, issueTracker);
186
+ // Create AgentSessionManager for this repository with parent session lookup and resume callback
187
+ //
188
+ // Note: This pattern works (despite appearing recursive) because:
189
+ // 1. The agentSessionManager variable is captured by the closure after it's assigned
190
+ // 2. JavaScript's variable hoisting means 'agentSessionManager' exists (but is undefined) when the arrow function is created
191
+ // 3. By the time the callback is actually invoked (when a child session completes), agentSessionManager is fully initialized
192
+ // 4. The callback only executes asynchronously, well after the constructor has completed and agentSessionManager is assigned
193
+ //
194
+ // This allows the AgentSessionManager to call back into itself to access its own sessions,
195
+ // enabling child sessions to trigger parent session resumption using the same manager instance.
196
+ const activitySink = new LinearActivitySink(issueTracker, repo.linearWorkspaceId);
197
+ const agentSessionManager = new AgentSessionManager(activitySink, (childSessionId) => {
198
+ this.logger.debug(`Looking up parent session for child ${childSessionId}`);
199
+ const parentId = this.globalSessionRegistry.getParentSessionId(childSessionId);
200
+ this.logger.debug(`Child ${childSessionId} -> Parent ${parentId || "not found"}`);
201
+ return parentId;
202
+ }, async (parentSessionId, prompt, childSessionId) => {
203
+ await this.handleResumeParentSession(parentSessionId, prompt, childSessionId, repo, agentSessionManager);
204
+ }, this.procedureAnalyzer, this.sharedApplicationServer);
205
+ // Subscribe to subroutine completion events
206
+ agentSessionManager.on("subroutineComplete", async ({ sessionId, session }) => {
207
+ await this.handleSubroutineTransition(sessionId, session, repo, agentSessionManager);
208
+ });
209
+ // Subscribe to validation loop events
210
+ agentSessionManager.on("validationLoopIteration", async ({ sessionId, session, fixerPrompt, iteration, maxIterations, }) => {
211
+ this.logger.info(`Validation loop iteration ${iteration}/${maxIterations}, running fixer`);
212
+ await this.handleValidationLoopFixer(sessionId, session, repo, agentSessionManager, fixerPrompt, iteration);
213
+ });
214
+ agentSessionManager.on("validationLoopRerun", async ({ sessionId, session, iteration }) => {
215
+ this.logger.info(`Validation loop re-running verifications (iteration ${iteration})`);
216
+ await this.handleValidationLoopRerun(sessionId, session, repo, agentSessionManager);
217
+ });
218
+ this.agentSessionManagers.set(repo.id, agentSessionManager);
219
+ }
220
+ }
221
+ // Initialize user access control with global and per-repository configs
222
+ const repoAccessConfigs = new Map();
223
+ for (const repo of config.repositories) {
224
+ if (repo.isActive !== false) {
225
+ repoAccessConfigs.set(repo.id, repo.userAccessControl);
226
+ }
227
+ }
228
+ this.userAccessControl = new UserAccessControl(config.userAccessControl, repoAccessConfigs);
229
+ // Initialize extracted service modules
230
+ this.attachmentService = new AttachmentService(this.logger, this.sylasHome);
231
+ this.runnerSelectionService = new RunnerSelectionService(this.config, this.logger);
232
+ this.activityPoster = new ActivityPoster(this.issueTrackers, this.repositories, this.logger);
233
+ this.configManager = new ConfigManager(this.config, this.logger, this.configPath, this.repositories);
234
+ this.promptBuilder = new PromptBuilder({
235
+ logger: this.logger,
236
+ repositories: this.repositories,
237
+ issueTrackers: this.issueTrackers,
238
+ gitService: this.gitService,
239
+ config: this.config,
240
+ });
241
+ // Components will be initialized and registered in start() method before server starts
242
+ }
243
+ /**
244
+ * Start the edge worker
245
+ */
246
+ async start() {
247
+ // Load persisted state for each repository
248
+ await this.loadPersistedState();
249
+ // Start config file watcher via ConfigManager
250
+ this.configManager.on("configChanged", async (changes) => {
251
+ await this.removeDeletedRepositories(changes.removed);
252
+ await this.updateModifiedRepositories(changes.modified);
253
+ await this.addNewRepositories(changes.added);
254
+ this.config = changes.newConfig;
255
+ this.configManager.setConfig(changes.newConfig);
256
+ });
257
+ this.configManager.startConfigWatcher();
258
+ // Initialize and register components BEFORE starting server (routes must be registered before listen())
259
+ await this.initializeComponents();
260
+ // Start shared application server (this also starts Cloudflare tunnel if CLOUDFLARE_TOKEN is set)
261
+ await this.sharedApplicationServer.start();
262
+ }
263
+ /**
264
+ * Initialize and register components (routes) before server starts
265
+ */
266
+ async initializeComponents() {
267
+ // Get the first active repository for configuration
268
+ const firstRepo = Array.from(this.repositories.values())[0];
269
+ if (!firstRepo) {
270
+ throw new Error("No active repositories configured");
271
+ }
272
+ // Platform-specific initialization
273
+ if (this.config.platform === "cli") {
274
+ // CLI mode: Create and register CLIRPCServer
275
+ const firstIssueTracker = this.issueTrackers.get(firstRepo.id);
276
+ if (!firstIssueTracker) {
277
+ throw new Error("Issue tracker not found for first repository");
278
+ }
279
+ // Type guard to ensure it's a CLIIssueTrackerService
280
+ if (!(firstIssueTracker instanceof CLIIssueTrackerService)) {
281
+ throw new Error("CLI platform requires CLIIssueTrackerService but found different implementation");
282
+ }
283
+ this.cliRPCServer = new CLIRPCServer({
284
+ fastifyServer: this.sharedApplicationServer.getFastifyInstance(),
285
+ issueTracker: firstIssueTracker,
286
+ version: "1.0.0",
287
+ });
288
+ // Register the /cli/rpc endpoint
289
+ this.cliRPCServer.register();
290
+ this.logger.info("✅ CLI RPC server registered");
291
+ this.logger.info(" RPC endpoint: /cli/rpc");
292
+ // Create CLI event transport and register listener
293
+ const cliEventTransport = firstIssueTracker.createEventTransport({
294
+ platform: "cli",
295
+ fastifyServer: this.sharedApplicationServer.getFastifyInstance(),
296
+ });
297
+ // Listen for webhook events (same pattern as Linear mode)
298
+ cliEventTransport.on("event", (event) => {
299
+ // Get all active repositories for webhook handling
300
+ const repos = Array.from(this.repositories.values());
301
+ this.handleWebhook(event, repos);
302
+ });
303
+ // Listen for errors
304
+ cliEventTransport.on("error", (error) => {
305
+ this.handleError(error);
306
+ });
307
+ // Register the CLI event transport endpoints
308
+ cliEventTransport.register();
309
+ this.logger.info("✅ CLI event transport registered");
310
+ this.logger.info(" Event listener: listening for AgentSessionCreated events");
311
+ }
312
+ else {
313
+ // Linear mode: Create and register LinearEventTransport
314
+ const useDirectWebhooks = process.env.LINEAR_DIRECT_WEBHOOKS?.toLowerCase() === "true";
315
+ const verificationMode = useDirectWebhooks ? "direct" : "proxy";
316
+ // Get appropriate secret based on mode
317
+ const secret = useDirectWebhooks
318
+ ? process.env.LINEAR_WEBHOOK_SECRET || ""
319
+ : process.env.SYLAS_API_KEY || "";
320
+ this.linearEventTransport = new LinearEventTransport({
321
+ fastifyServer: this.sharedApplicationServer.getFastifyInstance(),
322
+ verificationMode,
323
+ secret,
324
+ });
325
+ // Listen for legacy webhook events (deprecated, kept for backward compatibility)
326
+ this.linearEventTransport.on("event", (event) => {
327
+ // Get all active repositories for webhook handling
328
+ const repos = Array.from(this.repositories.values());
329
+ this.handleWebhook(event, repos);
330
+ });
331
+ // Listen for unified internal messages (new message bus)
332
+ this.linearEventTransport.on("message", (message) => {
333
+ this.handleMessage(message);
334
+ });
335
+ // Listen for errors
336
+ this.linearEventTransport.on("error", (error) => {
337
+ this.handleError(error);
338
+ });
339
+ // Register the /webhook endpoint
340
+ this.linearEventTransport.register();
341
+ this.logger.info(`✅ Linear event transport registered (${verificationMode} mode)`);
342
+ this.logger.info(` Webhook endpoint: ${this.sharedApplicationServer.getWebhookUrl()}`);
343
+ }
344
+ // 2. Register GitHub event transport (for forwarded GitHub webhooks from CYHOST)
345
+ // This is registered regardless of platform mode since GitHub webhooks can come from CYHOST
346
+ this.registerGitHubEventTransport();
347
+ // 2b. Register Slack event transport (for forwarded Slack webhooks from CYHOST)
348
+ this.registerSlackEventTransport();
349
+ // 3. Create and register ConfigUpdater (both platforms)
350
+ this.configUpdater = new ConfigUpdater(this.sharedApplicationServer.getFastifyInstance(), this.sylasHome, process.env.SYLAS_API_KEY || "");
351
+ // Register config update routes
352
+ this.configUpdater.register();
353
+ this.logger.info("✅ Config updater registered");
354
+ this.logger.info(" Routes: /api/update/sylas-config, /api/update/sylas-env,");
355
+ this.logger.info(" /api/update/repository, /api/test-mcp, /api/configure-mcp");
356
+ // 3. Register MCP endpoint for sylas-tools on the same Fastify server/port
357
+ await this.registerSylasToolsMcpEndpoint();
358
+ // 4. Register /status endpoint for process activity monitoring
359
+ this.registerStatusEndpoint();
360
+ // 5. Register /version endpoint for CLI version info
361
+ this.registerVersionEndpoint();
362
+ }
363
+ /**
364
+ * Register the /status endpoint for checking if the process is busy or idle
365
+ * This endpoint is used to determine if the process can be safely restarted
366
+ */
367
+ registerStatusEndpoint() {
368
+ const fastify = this.sharedApplicationServer.getFastifyInstance();
369
+ fastify.get("/status", async (_request, reply) => {
370
+ const status = this.computeStatus();
371
+ return reply.status(200).send({ status });
372
+ });
373
+ this.logger.info("✅ Status endpoint registered");
374
+ this.logger.info(" Route: GET /status");
375
+ }
376
+ /**
377
+ * Register the /version endpoint for CLI version information
378
+ * This endpoint is used by dashboards to display the installed CLI version
379
+ */
380
+ registerVersionEndpoint() {
381
+ const fastify = this.sharedApplicationServer.getFastifyInstance();
382
+ fastify.get("/version", async (_request, reply) => {
383
+ return reply.status(200).send({
384
+ sylas_cli_version: this.config.version ?? null,
385
+ });
386
+ });
387
+ this.logger.info("✅ Version endpoint registered");
388
+ this.logger.info(" Route: GET /version");
389
+ }
390
+ /**
391
+ * Register the GitHub event transport for receiving forwarded GitHub webhooks from CYHOST.
392
+ * This creates a /github-webhook endpoint that handles @sylasagent mentions on GitHub PRs.
393
+ */
394
+ registerGitHubEventTransport() {
395
+ // Use the same verification approach as Linear webhooks
396
+ // In proxy mode: Bearer token (SYLAS_API_KEY)
397
+ // In direct/cloud mode: GitHub HMAC-SHA256 signature
398
+ const useSignatureVerification = process.env.GITHUB_WEBHOOK_SECRET != null &&
399
+ process.env.GITHUB_WEBHOOK_SECRET !== "";
400
+ const verificationMode = useSignatureVerification ? "signature" : "proxy";
401
+ const secret = useSignatureVerification
402
+ ? process.env.GITHUB_WEBHOOK_SECRET
403
+ : process.env.SYLAS_API_KEY || "";
404
+ this.gitHubEventTransport = new GitHubEventTransport({
405
+ fastifyServer: this.sharedApplicationServer.getFastifyInstance(),
406
+ verificationMode,
407
+ secret,
408
+ });
409
+ // Listen for legacy GitHub webhook events (deprecated, kept for backward compatibility)
410
+ this.gitHubEventTransport.on("event", (event) => {
411
+ this.handleGitHubWebhook(event).catch((error) => {
412
+ this.logger.error("Failed to handle GitHub webhook", error instanceof Error ? error : new Error(String(error)));
413
+ });
414
+ });
415
+ // Listen for unified internal messages (new message bus)
416
+ this.gitHubEventTransport.on("message", (message) => {
417
+ this.handleMessage(message);
418
+ });
419
+ // Listen for errors
420
+ this.gitHubEventTransport.on("error", (error) => {
421
+ this.handleError(error);
422
+ });
423
+ // Register the /github-webhook endpoint
424
+ this.gitHubEventTransport.register();
425
+ this.logger.info(`GitHub event transport registered (${verificationMode} mode)`);
426
+ this.logger.info("Webhook endpoint: POST /github-webhook");
427
+ }
428
+ /**
429
+ * Register the Slack event transport for receiving forwarded Slack webhooks from CYHOST.
430
+ * This creates a /slack-webhook endpoint that handles @mention events from Slack.
431
+ */
432
+ registerSlackEventTransport() {
433
+ const slackAdapter = new SlackChatAdapter(this.logger);
434
+ // Build MCP config for Slack sessions using the first repository's Linear token
435
+ const firstRepo = Array.from(this.repositories.values())[0];
436
+ const mcpConfig = firstRepo ? this.buildMcpConfig(firstRepo) : undefined;
437
+ if (!firstRepo) {
438
+ this.logger.warn("No repositories configured — Slack sessions will not have access to Linear MCP tools");
439
+ }
440
+ this.chatSessionHandler = new ChatSessionHandler(slackAdapter, {
441
+ sylasHome: this.sylasHome,
442
+ defaultModel: this.config.defaultModel,
443
+ defaultFallbackModel: this.config.defaultFallbackModel,
444
+ mcpConfig,
445
+ onWebhookStart: () => {
446
+ this.activeWebhookCount++;
447
+ },
448
+ onWebhookEnd: () => {
449
+ this.activeWebhookCount--;
450
+ },
451
+ onStateChange: () => this.savePersistedState(),
452
+ onClaudeError: (error) => this.handleClaudeError(error),
453
+ }, this.logger);
454
+ this.slackEventTransport = new SlackEventTransport({
455
+ fastifyServer: this.sharedApplicationServer.getFastifyInstance(),
456
+ verificationMode: "proxy",
457
+ secret: process.env.SYLAS_API_KEY || "",
458
+ });
459
+ this.slackEventTransport.on("event", (event) => {
460
+ this.chatSessionHandler.handleEvent(event).catch((error) => {
461
+ this.logger.error("Failed to handle Slack webhook", error instanceof Error ? error : new Error(String(error)));
462
+ });
463
+ });
464
+ this.slackEventTransport.on("message", (message) => {
465
+ this.handleMessage(message);
466
+ });
467
+ this.slackEventTransport.on("error", (error) => {
468
+ this.handleError(error);
469
+ });
470
+ this.slackEventTransport.register();
471
+ this.logger.info("Slack event transport registered");
472
+ }
473
+ /**
474
+ * Handle a GitHub webhook event (forwarded from CYHOST).
475
+ *
476
+ * This creates a new session for the GitHub PR comment, checks out the PR branch
477
+ * via git worktree, and processes the comment as a task prompt.
478
+ */
479
+ async handleGitHubWebhook(event) {
480
+ this.activeWebhookCount++;
481
+ try {
482
+ // Only handle comments on pull requests
483
+ if (!isCommentOnPullRequest(event)) {
484
+ this.logger.debug("Ignoring GitHub comment on non-PR issue");
485
+ return;
486
+ }
487
+ const repoFullName = extractRepoFullName(event);
488
+ const prNumber = extractPRNumber(event);
489
+ const commentBody = extractCommentBody(event);
490
+ const commentAuthor = extractCommentAuthor(event);
491
+ const prTitle = extractPRTitle(event);
492
+ const sessionKey = extractSessionKey(event);
493
+ this.logger.info(`Processing GitHub webhook: ${repoFullName}#${prNumber} by @${commentAuthor}`);
494
+ // Add "eyes" reaction to acknowledge receipt
495
+ const reactionToken = event.installationToken || process.env.GITHUB_TOKEN;
496
+ if (reactionToken) {
497
+ const commentId = extractCommentId(event);
498
+ if (commentId) {
499
+ this.gitHubCommentService
500
+ .addReaction({
501
+ token: reactionToken,
502
+ owner: extractRepoOwner(event),
503
+ repo: extractRepoName(event),
504
+ commentId,
505
+ isPullRequestReviewComment: isPullRequestReviewCommentPayload(event.payload),
506
+ content: "eyes",
507
+ })
508
+ .catch((err) => {
509
+ this.logger.warn(`Failed to add reaction: ${err instanceof Error ? err.message : err}`);
510
+ });
511
+ }
512
+ }
513
+ // Find the repository configuration that matches this GitHub repo
514
+ const repository = this.findRepositoryByGitHubUrl(repoFullName);
515
+ if (!repository) {
516
+ this.logger.warn(`No repository configured for GitHub repo: ${repoFullName}`);
517
+ return;
518
+ }
519
+ // Get the agent session manager for this repository
520
+ const agentSessionManager = this.agentSessionManagers.get(repository.id);
521
+ if (!agentSessionManager) {
522
+ this.logger.error(`No AgentSessionManager for repository ${repository.name}`);
523
+ return;
524
+ }
525
+ // Determine the PR branch
526
+ let branchRef = extractPRBranchRef(event);
527
+ // For issue_comment events, the branch ref is not in the payload
528
+ // We need to fetch it from the GitHub API
529
+ if (!branchRef && isIssueCommentPayload(event.payload)) {
530
+ branchRef = await this.fetchPRBranchRef(event, repository);
531
+ }
532
+ if (!branchRef) {
533
+ this.logger.error(`Could not determine branch for ${repoFullName}#${prNumber}`);
534
+ return;
535
+ }
536
+ // Strip the @sylasagent mention to get the task instructions
537
+ const taskInstructions = stripMention(commentBody);
538
+ // Create workspace (git worktree) for the PR branch
539
+ const workspace = await this.createGitHubWorkspace(repository, branchRef, prNumber);
540
+ if (!workspace) {
541
+ this.logger.error(`Failed to create workspace for ${repoFullName}#${prNumber}`);
542
+ return;
543
+ }
544
+ this.logger.info(`GitHub workspace created at: ${workspace.path}`);
545
+ // Check if another active session is already using this branch/workspace
546
+ const existingSessions = agentSessionManager.getActiveSessionsByBranchName(branchRef);
547
+ const firstExisting = existingSessions[0];
548
+ if (firstExisting) {
549
+ this.logger.warn(`Reusing workspace from active session ${firstExisting.id} — concurrent writes possible`);
550
+ }
551
+ // Create a synthetic session for this GitHub PR comment
552
+ const issueMinimal = {
553
+ id: sessionKey,
554
+ identifier: `${extractRepoName(event)}#${prNumber}`,
555
+ title: prTitle || `PR #${prNumber}`,
556
+ branchName: branchRef,
557
+ };
558
+ // Create an internal agent session (no Linear session for GitHub)
559
+ const githubSessionId = `github-${event.deliveryId}`;
560
+ agentSessionManager.createLinearAgentSession(githubSessionId, sessionKey, issueMinimal, workspace, "github");
561
+ const session = agentSessionManager.getSession(githubSessionId);
562
+ if (!session) {
563
+ this.logger.error(`Failed to create session for GitHub webhook ${event.deliveryId}`);
564
+ return;
565
+ }
566
+ // Initialize procedure metadata
567
+ if (!session.metadata) {
568
+ session.metadata = {};
569
+ }
570
+ // Store GitHub-specific metadata for reply posting
571
+ session.metadata.commentId = String(extractCommentId(event));
572
+ // Build the system prompt for this GitHub PR session
573
+ const systemPrompt = this.buildGitHubSystemPrompt(event, branchRef, taskInstructions);
574
+ // Build allowed tools and directories
575
+ const allowedTools = this.buildAllowedTools(repository);
576
+ const disallowedTools = this.buildDisallowedTools(repository);
577
+ const allowedDirectories = [repository.repositoryPath];
578
+ // Create agent runner using the standard config builder
579
+ const { config: runnerConfig } = this.buildAgentRunnerConfig(session, repository, githubSessionId, systemPrompt, allowedTools, allowedDirectories, disallowedTools, undefined, // resumeSessionId
580
+ undefined, // labels
581
+ undefined, // issueDescription
582
+ 200, // maxTurns
583
+ false);
584
+ const runner = new ClaudeRunner(runnerConfig);
585
+ // Store the runner in the session manager
586
+ agentSessionManager.addAgentRunner(githubSessionId, runner);
587
+ // Save persisted state
588
+ await this.savePersistedState();
589
+ this.emit("session:started", sessionKey, issueMinimal, repository.id);
590
+ this.logger.info(`Starting Claude runner for GitHub PR ${repoFullName}#${prNumber}`);
591
+ // Start the session and handle completion
592
+ try {
593
+ const sessionInfo = await runner.start(taskInstructions);
594
+ this.logger.info(`GitHub session started: ${sessionInfo.sessionId}`);
595
+ // When session completes, post the reply back to GitHub
596
+ await this.postGitHubReply(event, runner, repository);
597
+ }
598
+ catch (error) {
599
+ this.logger.error(`GitHub session error for ${repoFullName}#${prNumber}`, error instanceof Error ? error : new Error(String(error)));
600
+ }
601
+ finally {
602
+ await this.savePersistedState();
603
+ }
604
+ }
605
+ catch (error) {
606
+ this.logger.error("Failed to process GitHub webhook", error instanceof Error ? error : new Error(String(error)));
607
+ }
608
+ finally {
609
+ this.activeWebhookCount--;
610
+ }
611
+ }
612
+ /**
613
+ * Find a repository configuration that matches a GitHub repository URL.
614
+ * Matches against the githubUrl field in repository config.
615
+ */
616
+ findRepositoryByGitHubUrl(repoFullName) {
617
+ for (const repo of this.repositories.values()) {
618
+ if (!repo.githubUrl)
619
+ continue;
620
+ // Match against full name (owner/repo) or URL containing it
621
+ if (repo.githubUrl.includes(repoFullName) ||
622
+ repo.githubUrl.endsWith(`/${repoFullName}`)) {
623
+ return repo;
624
+ }
625
+ }
626
+ return null;
627
+ }
628
+ /**
629
+ * Fetch the PR branch ref for an issue_comment webhook.
630
+ * For issue_comment events, the branch ref is not in the payload
631
+ * and must be fetched from the GitHub API.
632
+ */
633
+ async fetchPRBranchRef(event, _repository) {
634
+ if (!isIssueCommentPayload(event.payload))
635
+ return null;
636
+ const prUrl = event.payload.issue.pull_request?.url;
637
+ if (!prUrl)
638
+ return null;
639
+ try {
640
+ const owner = extractRepoOwner(event);
641
+ const repo = extractRepoName(event);
642
+ const prNumber = event.payload.issue.number;
643
+ const headers = {
644
+ Accept: "application/vnd.github+json",
645
+ "X-GitHub-Api-Version": "2022-11-28",
646
+ };
647
+ // Prefer forwarded installation token, fall back to GITHUB_TOKEN
648
+ const token = event.installationToken || process.env.GITHUB_TOKEN;
649
+ if (token) {
650
+ headers.Authorization = `Bearer ${token}`;
651
+ }
652
+ const response = await fetch(`https://api.github.com/repos/${owner}/${repo}/pulls/${prNumber}`, { headers });
653
+ if (!response.ok) {
654
+ this.logger.warn(`Failed to fetch PR details from GitHub API: ${response.status}`);
655
+ return null;
656
+ }
657
+ const prData = (await response.json());
658
+ return prData.head?.ref ?? null;
659
+ }
660
+ catch (error) {
661
+ this.logger.error("Failed to fetch PR branch ref", error instanceof Error ? error : new Error(String(error)));
662
+ return null;
663
+ }
664
+ }
665
+ /**
666
+ * Create a git worktree for a GitHub PR branch.
667
+ * If the worktree already exists for this branch, reuse it.
668
+ */
669
+ async createGitHubWorkspace(repository, branchRef, prNumber) {
670
+ try {
671
+ // Use the GitService to create the worktree
672
+ // Create a synthetic issue-like object for the git service
673
+ const syntheticIssue = {
674
+ id: `github-pr-${prNumber}`,
675
+ identifier: `PR-${prNumber}`,
676
+ title: `PR #${prNumber}`,
677
+ description: null,
678
+ url: "",
679
+ branchName: branchRef,
680
+ assigneeId: null,
681
+ stateId: null,
682
+ teamId: null,
683
+ labelIds: [],
684
+ priority: 0,
685
+ createdAt: new Date(),
686
+ updatedAt: new Date(),
687
+ archivedAt: null,
688
+ state: Promise.resolve(undefined),
689
+ assignee: Promise.resolve(undefined),
690
+ team: Promise.resolve(undefined),
691
+ parent: Promise.resolve(undefined),
692
+ project: Promise.resolve(undefined),
693
+ labels: () => Promise.resolve({ nodes: [] }),
694
+ comments: () => Promise.resolve({ nodes: [] }),
695
+ attachments: () => Promise.resolve({ nodes: [] }),
696
+ children: () => Promise.resolve({ nodes: [] }),
697
+ inverseRelations: () => Promise.resolve({ nodes: [] }),
698
+ update: () => Promise.resolve({
699
+ success: true,
700
+ issue: undefined,
701
+ lastSyncId: 0,
702
+ }),
703
+ };
704
+ return await this.gitService.createGitWorktree(syntheticIssue, repository);
705
+ }
706
+ catch (error) {
707
+ this.logger.error(`Failed to create GitHub workspace for PR #${prNumber}`, error instanceof Error ? error : new Error(String(error)));
708
+ return null;
709
+ }
710
+ }
711
+ /**
712
+ * Build a system prompt for a GitHub PR comment session.
713
+ */
714
+ buildGitHubSystemPrompt(event, branchRef, taskInstructions) {
715
+ const repoFullName = extractRepoFullName(event);
716
+ const prNumber = extractPRNumber(event);
717
+ const prTitle = extractPRTitle(event);
718
+ const commentAuthor = extractCommentAuthor(event);
719
+ const commentUrl = extractCommentUrl(event);
720
+ return `You are working on a GitHub Pull Request.
721
+
722
+ ## Context
723
+ - **Repository**: ${repoFullName}
724
+ - **PR**: #${prNumber} - ${prTitle || "Untitled"}
725
+ - **Branch**: ${branchRef}
726
+ - **Requested by**: @${commentAuthor}
727
+ - **Comment URL**: ${commentUrl}
728
+
729
+ ## Task
730
+ ${taskInstructions}
731
+
732
+ ## Instructions
733
+ - You are already checked out on the PR branch \`${branchRef}\`
734
+ - Make changes directly to the code on this branch
735
+ - After making changes, commit and push them to the branch
736
+ - Be concise in your responses as they will be posted back to the GitHub PR`;
737
+ }
738
+ /**
739
+ * Post a reply back to the GitHub PR comment after the session completes.
740
+ */
741
+ async postGitHubReply(event, runner, _repository) {
742
+ try {
743
+ // Get the last assistant message from the runner as the summary
744
+ const messages = runner.getMessages();
745
+ const lastAssistantMessage = [...messages]
746
+ .reverse()
747
+ .find((m) => m.type === "assistant");
748
+ let summary = "Task completed. Please review the changes on this branch.";
749
+ if (lastAssistantMessage &&
750
+ lastAssistantMessage.type === "assistant" &&
751
+ "message" in lastAssistantMessage) {
752
+ const msg = lastAssistantMessage;
753
+ const textBlock = msg.message.content?.find((block) => block.type === "text" && block.text);
754
+ if (textBlock?.text) {
755
+ summary = textBlock.text;
756
+ }
757
+ }
758
+ const owner = extractRepoOwner(event);
759
+ const repo = extractRepoName(event);
760
+ const prNumber = extractPRNumber(event);
761
+ const commentId = extractCommentId(event);
762
+ if (!prNumber) {
763
+ this.logger.warn("Cannot post GitHub reply: no PR number");
764
+ return;
765
+ }
766
+ // Prefer the forwarded installation token from CYHOST (1-hour expiry)
767
+ // Fall back to process.env.GITHUB_TOKEN if not provided
768
+ const token = event.installationToken || process.env.GITHUB_TOKEN;
769
+ if (!token) {
770
+ this.logger.warn("Cannot post GitHub reply: no installation token or GITHUB_TOKEN configured");
771
+ this.logger.debug(`Would have posted reply to ${owner}/${repo}#${prNumber} (comment ${commentId}): ${summary}`);
772
+ return;
773
+ }
774
+ if (event.eventType === "pull_request_review_comment") {
775
+ // Reply to the specific review comment thread
776
+ await this.gitHubCommentService.postReviewCommentReply({
777
+ token,
778
+ owner,
779
+ repo,
780
+ pullNumber: prNumber,
781
+ commentId,
782
+ body: summary,
783
+ });
784
+ }
785
+ else {
786
+ // Post as a regular issue comment on the PR
787
+ await this.gitHubCommentService.postIssueComment({
788
+ token,
789
+ owner,
790
+ repo,
791
+ issueNumber: prNumber,
792
+ body: summary,
793
+ });
794
+ }
795
+ this.logger.info(`Posted GitHub reply to ${owner}/${repo}#${prNumber}`);
796
+ }
797
+ catch (error) {
798
+ this.logger.error("Failed to post GitHub reply", error instanceof Error ? error : new Error(String(error)));
799
+ }
800
+ }
801
+ /**
802
+ * Compute the current status of the Sylas process
803
+ * @returns "idle" if the process can be safely restarted, "busy" if work is in progress
804
+ */
805
+ computeStatus() {
806
+ // Busy if any webhooks are currently being processed
807
+ if (this.activeWebhookCount > 0) {
808
+ return "busy";
809
+ }
810
+ // Busy if any runner is actively running (repository-tied sessions)
811
+ for (const manager of this.agentSessionManagers.values()) {
812
+ const runners = manager.getAllAgentRunners();
813
+ for (const runner of runners) {
814
+ if (runner.isRunning()) {
815
+ return "busy";
816
+ }
817
+ }
818
+ }
819
+ // Busy if any chat platform runner is actively running
820
+ if (this.chatSessionHandler?.isAnyRunnerBusy()) {
821
+ return "busy";
822
+ }
823
+ return "idle";
824
+ }
825
+ /**
826
+ * Stop the edge worker
827
+ */
828
+ async stop() {
829
+ // Stop config file watcher
830
+ await this.configManager.stop();
831
+ try {
832
+ await this.savePersistedState();
833
+ this.logger.info("✅ EdgeWorker state saved successfully");
834
+ }
835
+ catch (error) {
836
+ this.logger.error("❌ Failed to save EdgeWorker state during shutdown:", error);
837
+ }
838
+ // get all agent runners (including chat platform sessions)
839
+ const agentRunners = [];
840
+ for (const agentSessionManager of this.agentSessionManagers.values()) {
841
+ agentRunners.push(...agentSessionManager.getAllAgentRunners());
842
+ }
843
+ if (this.chatSessionHandler) {
844
+ agentRunners.push(...this.chatSessionHandler.getAllRunners());
845
+ }
846
+ // Kill all agent processes with null checking
847
+ for (const runner of agentRunners) {
848
+ if (runner) {
849
+ try {
850
+ runner.stop();
851
+ }
852
+ catch (error) {
853
+ this.logger.error("Error stopping Claude runner:", error);
854
+ }
855
+ }
856
+ }
857
+ // Clear event transport (no explicit cleanup needed, routes are removed when server stops)
858
+ this.linearEventTransport = null;
859
+ this.configUpdater = null;
860
+ this.sylasToolsMcpContexts.clear();
861
+ this.sylasToolsMcpSessions.removeAllListeners();
862
+ this.sylasToolsMcpRegistered = false;
863
+ // Stop shared application server (this also stops Cloudflare tunnel if running)
864
+ await this.sharedApplicationServer.stop();
865
+ }
866
+ /**
867
+ * Set the config file path for dynamic reloading
868
+ */
869
+ setConfigPath(configPath) {
870
+ this.configPath = configPath;
871
+ this.configManager.setConfigPath(configPath);
872
+ }
873
+ /**
874
+ * Handle resuming a parent session when a child session completes
875
+ * This is the core logic used by the resume parent session callback
876
+ * Extracted to reduce duplication between constructor and addNewRepositories
877
+ */
878
+ async handleResumeParentSession(parentSessionId, prompt, childSessionId, _childRepo, childAgentSessionManager) {
879
+ const log = this.logger.withContext({ sessionId: parentSessionId });
880
+ log.info(`Child session completed, resuming parent session ${parentSessionId}`);
881
+ // Find parent session across all repositories
882
+ // This is critical for cross-repository orchestration where parent and child
883
+ // may be in different repositories with different AgentSessionManagers
884
+ // See also: feedback delivery code at line ~4413 which uses same pattern
885
+ log.debug(`Searching for parent session ${parentSessionId} across all repositories`);
886
+ let parentSession;
887
+ let parentRepo;
888
+ let parentAgentSessionManager;
889
+ for (const [repoId, manager] of this.agentSessionManagers) {
890
+ const candidate = manager.getSession(parentSessionId);
891
+ if (candidate) {
892
+ parentSession = candidate;
893
+ parentRepo = this.repositories.get(repoId);
894
+ parentAgentSessionManager = manager;
895
+ log.debug(`Found parent session in repository: ${parentRepo?.name || repoId}`);
896
+ break;
897
+ }
898
+ }
899
+ if (!parentSession || !parentRepo || !parentAgentSessionManager) {
900
+ log.error(`Parent session ${parentSessionId} not found in any repository's agent session manager`);
901
+ return;
902
+ }
903
+ log.debug(`Found parent session - Issue: ${parentSession.issueId}, Workspace: ${parentSession.workspace.path}`);
904
+ // Get the child session to access its workspace path
905
+ // Child session is in the child's manager (passed in from the callback)
906
+ const childSession = childAgentSessionManager.getSession(childSessionId);
907
+ const childWorkspaceDirs = [];
908
+ if (childSession) {
909
+ childWorkspaceDirs.push(childSession.workspace.path);
910
+ log.debug(`Adding child workspace to parent allowed directories: ${childSession.workspace.path}`);
911
+ }
912
+ else {
913
+ log.warn(`Could not find child session ${childSessionId} to add workspace to parent allowed directories`);
914
+ }
915
+ await this.postParentResumeAcknowledgment(parentSessionId, parentRepo.id);
916
+ // Post thought showing child result receipt
917
+ // Use parent's issue tracker since we're posting to the parent's session
918
+ const issueTracker = this.issueTrackers.get(parentRepo.id);
919
+ if (issueTracker && childSession) {
920
+ const childIssueIdentifier = childSession.issue?.identifier || childSession.issueId;
921
+ const resultThought = `Received result from sub-issue ${childIssueIdentifier}:\n\n---\n\n${prompt}\n\n---`;
922
+ await this.postActivityDirect(issueTracker, {
923
+ agentSessionId: parentSessionId,
924
+ content: { type: "thought", body: resultThought },
925
+ }, "child result receipt");
926
+ }
927
+ // Use centralized streaming check and routing logic
928
+ log.info(`Handling child result for parent session ${parentSessionId}`);
929
+ try {
930
+ await this.handlePromptWithStreamingCheck(parentSession, parentRepo, parentSessionId, parentAgentSessionManager, prompt, "", // No attachment manifest for child results
931
+ false, // Not a new session
932
+ childWorkspaceDirs, // Add child workspace directories to parent's allowed directories
933
+ "parent resume from child");
934
+ log.info(`Successfully handled child result for parent session ${parentSessionId}`);
935
+ }
936
+ catch (error) {
937
+ log.error(`Failed to resume parent session ${parentSessionId}:`, error);
938
+ log.error(`Error context - Parent issue: ${parentSession.issueId}, Repository: ${parentRepo.name}`);
939
+ }
940
+ }
941
+ /**
942
+ * Handle subroutine transition when a subroutine completes
943
+ * This is triggered by the AgentSessionManager's 'subroutineComplete' event
944
+ */
945
+ async handleSubroutineTransition(sessionId, session, repo, agentSessionManager) {
946
+ const log = this.logger.withContext({ sessionId });
947
+ log.info(`Handling subroutine completion for session ${sessionId}`);
948
+ // Get next subroutine (advancement already handled by AgentSessionManager)
949
+ const nextSubroutine = this.procedureAnalyzer.getCurrentSubroutine(session);
950
+ if (!nextSubroutine) {
951
+ log.info(`Procedure complete for session ${sessionId}`);
952
+ return;
953
+ }
954
+ log.info(`Next subroutine: ${nextSubroutine.name}`);
955
+ // Load subroutine prompt
956
+ let subroutinePrompt;
957
+ try {
958
+ subroutinePrompt = await this.loadSubroutinePrompt(nextSubroutine, this.config.linearWorkspaceSlug);
959
+ if (!subroutinePrompt) {
960
+ // Fallback if loadSubroutinePrompt returns null
961
+ subroutinePrompt = `Continue with: ${nextSubroutine.description}`;
962
+ }
963
+ }
964
+ catch (error) {
965
+ log.error(`Failed to load subroutine prompt:`, error);
966
+ // Fallback to simple prompt
967
+ subroutinePrompt = `Continue with: ${nextSubroutine.description}`;
968
+ }
969
+ // Resume Claude session with subroutine prompt
970
+ try {
971
+ await this.resumeAgentSession(session, repo, sessionId, agentSessionManager, subroutinePrompt, "", // No attachment manifest
972
+ false, // Not a new session
973
+ [], // No additional allowed directories
974
+ nextSubroutine?.singleTurn ? 1 : undefined);
975
+ log.info(`Successfully resumed session for ${nextSubroutine.name} subroutine${nextSubroutine.singleTurn ? " (singleTurn)" : ""}`);
976
+ }
977
+ catch (error) {
978
+ log.error(`Failed to resume session for ${nextSubroutine.name} subroutine:`, error);
979
+ }
980
+ }
981
+ /**
982
+ * Handle validation loop fixer - run the fixer prompt
983
+ */
984
+ async handleValidationLoopFixer(sessionId, session, repo, agentSessionManager, fixerPrompt, iteration) {
985
+ this.logger.info(`Running fixer for session ${sessionId}, iteration ${iteration}`);
986
+ try {
987
+ await this.resumeAgentSession(session, repo, sessionId, agentSessionManager, fixerPrompt, "", // No attachment manifest
988
+ false, // Not a new session
989
+ [], // No additional allowed directories
990
+ undefined);
991
+ this.logger.info(`Successfully started fixer for iteration ${iteration}`);
992
+ }
993
+ catch (error) {
994
+ this.logger.error(`Failed to run fixer for iteration ${iteration}:`, error);
995
+ }
996
+ }
997
+ /**
998
+ * Handle validation loop rerun - re-run the verifications subroutine
999
+ */
1000
+ async handleValidationLoopRerun(sessionId, session, repo, agentSessionManager) {
1001
+ this.logger.info(`Re-running verifications for session ${sessionId}`);
1002
+ // Get the verifications subroutine definition
1003
+ const verificationsSubroutine = this.procedureAnalyzer.getCurrentSubroutine(session);
1004
+ if (!verificationsSubroutine ||
1005
+ verificationsSubroutine.name !== "verifications") {
1006
+ this.logger.error(`Expected verifications subroutine, got: ${verificationsSubroutine?.name}`);
1007
+ return;
1008
+ }
1009
+ try {
1010
+ // Load the verifications prompt
1011
+ const subroutinePrompt = await this.loadSubroutinePrompt(verificationsSubroutine, this.config.linearWorkspaceSlug);
1012
+ if (!subroutinePrompt) {
1013
+ this.logger.error(`Failed to load verifications prompt`);
1014
+ return;
1015
+ }
1016
+ await this.resumeAgentSession(session, repo, sessionId, agentSessionManager, subroutinePrompt, "", // No attachment manifest
1017
+ false, // Not a new session
1018
+ [], // No additional allowed directories
1019
+ undefined);
1020
+ this.logger.info(`Successfully re-started verifications`);
1021
+ }
1022
+ catch (error) {
1023
+ this.logger.error(`Failed to re-run verifications:`, error);
1024
+ }
1025
+ }
1026
+ /**
1027
+ * Add new repositories to the running EdgeWorker
1028
+ */
1029
+ async addNewRepositories(repos) {
1030
+ for (const repo of repos) {
1031
+ if (repo.isActive === false) {
1032
+ this.logger.info(`⏭️ Skipping inactive repository: ${repo.name}`);
1033
+ continue;
1034
+ }
1035
+ try {
1036
+ this.logger.info(`➕ Adding repository: ${repo.name} (${repo.id})`);
1037
+ // Resolve paths that may contain tilde (~) prefix
1038
+ const resolvedRepo = {
1039
+ ...repo,
1040
+ repositoryPath: resolvePath(repo.repositoryPath),
1041
+ workspaceBaseDir: resolvePath(repo.workspaceBaseDir),
1042
+ mcpConfigPath: Array.isArray(repo.mcpConfigPath)
1043
+ ? repo.mcpConfigPath.map(resolvePath)
1044
+ : repo.mcpConfigPath
1045
+ ? resolvePath(repo.mcpConfigPath)
1046
+ : undefined,
1047
+ promptTemplatePath: repo.promptTemplatePath
1048
+ ? resolvePath(repo.promptTemplatePath)
1049
+ : undefined,
1050
+ openaiOutputDirectory: repo.openaiOutputDirectory
1051
+ ? resolvePath(repo.openaiOutputDirectory)
1052
+ : undefined,
1053
+ };
1054
+ // Add to internal map
1055
+ this.repositories.set(repo.id, resolvedRepo);
1056
+ // Create issue tracker with OAuth config for token refresh
1057
+ const issueTracker = this.config.platform === "cli"
1058
+ ? (() => {
1059
+ const service = new CLIIssueTrackerService();
1060
+ service.seedDefaultData();
1061
+ return service;
1062
+ })()
1063
+ : new LinearIssueTrackerService(new LinearClient({
1064
+ accessToken: repo.linearToken,
1065
+ }), this.buildOAuthConfig(resolvedRepo));
1066
+ this.issueTrackers.set(repo.id, issueTracker);
1067
+ // Create AgentSessionManager with same pattern as constructor
1068
+ const activitySink = new LinearActivitySink(issueTracker, repo.linearWorkspaceId);
1069
+ const agentSessionManager = new AgentSessionManager(activitySink, (childSessionId) => {
1070
+ return this.globalSessionRegistry.getParentSessionId(childSessionId);
1071
+ }, async (parentSessionId, prompt, childSessionId) => {
1072
+ await this.handleResumeParentSession(parentSessionId, prompt, childSessionId, repo, agentSessionManager);
1073
+ }, this.procedureAnalyzer, this.sharedApplicationServer);
1074
+ // Subscribe to subroutine completion events
1075
+ agentSessionManager.on("subroutineComplete", async ({ sessionId, session }) => {
1076
+ await this.handleSubroutineTransition(sessionId, session, repo, agentSessionManager);
1077
+ });
1078
+ // Subscribe to validation loop events
1079
+ agentSessionManager.on("validationLoopIteration", async ({ sessionId, session, fixerPrompt, iteration, maxIterations, }) => {
1080
+ this.logger.info(`Validation loop iteration ${iteration}/${maxIterations}, running fixer`);
1081
+ await this.handleValidationLoopFixer(sessionId, session, repo, agentSessionManager, fixerPrompt, iteration);
1082
+ });
1083
+ agentSessionManager.on("validationLoopRerun", async ({ sessionId, session, iteration }) => {
1084
+ this.logger.info(`Validation loop re-running verifications (iteration ${iteration})`);
1085
+ await this.handleValidationLoopRerun(sessionId, session, repo, agentSessionManager);
1086
+ });
1087
+ this.agentSessionManagers.set(repo.id, agentSessionManager);
1088
+ this.logger.info(`✅ Repository added successfully: ${repo.name}`);
1089
+ }
1090
+ catch (error) {
1091
+ this.logger.error(`❌ Failed to add repository ${repo.name}:`, error);
1092
+ }
1093
+ }
1094
+ }
1095
+ /**
1096
+ * Update existing repositories
1097
+ */
1098
+ async updateModifiedRepositories(repos) {
1099
+ for (const repo of repos) {
1100
+ try {
1101
+ const oldRepo = this.repositories.get(repo.id);
1102
+ if (!oldRepo) {
1103
+ this.logger.warn(`⚠️ Repository ${repo.id} not found for update, skipping`);
1104
+ continue;
1105
+ }
1106
+ this.logger.info(`🔄 Updating repository: ${repo.name} (${repo.id})`);
1107
+ // Resolve paths that may contain tilde (~) prefix
1108
+ const resolvedRepo = {
1109
+ ...repo,
1110
+ repositoryPath: resolvePath(repo.repositoryPath),
1111
+ workspaceBaseDir: resolvePath(repo.workspaceBaseDir),
1112
+ mcpConfigPath: Array.isArray(repo.mcpConfigPath)
1113
+ ? repo.mcpConfigPath.map(resolvePath)
1114
+ : repo.mcpConfigPath
1115
+ ? resolvePath(repo.mcpConfigPath)
1116
+ : undefined,
1117
+ promptTemplatePath: repo.promptTemplatePath
1118
+ ? resolvePath(repo.promptTemplatePath)
1119
+ : undefined,
1120
+ openaiOutputDirectory: repo.openaiOutputDirectory
1121
+ ? resolvePath(repo.openaiOutputDirectory)
1122
+ : undefined,
1123
+ };
1124
+ // Update stored config
1125
+ this.repositories.set(repo.id, resolvedRepo);
1126
+ // If token changed, update the issue tracker's client
1127
+ if (oldRepo.linearToken !== repo.linearToken) {
1128
+ this.logger.info(` 🔑 Token changed, updating client`);
1129
+ const issueTracker = this.issueTrackers.get(repo.id);
1130
+ if (issueTracker) {
1131
+ issueTracker.setAccessToken(repo.linearToken);
1132
+ }
1133
+ }
1134
+ // If active status changed
1135
+ if (oldRepo.isActive !== repo.isActive) {
1136
+ if (repo.isActive === false) {
1137
+ this.logger.info(` ⏸️ Repository set to inactive - existing sessions will continue`);
1138
+ }
1139
+ else {
1140
+ this.logger.info(` ▶️ Repository reactivated`);
1141
+ }
1142
+ }
1143
+ this.logger.info(`✅ Repository updated successfully: ${repo.name}`);
1144
+ }
1145
+ catch (error) {
1146
+ this.logger.error(`❌ Failed to update repository ${repo.name}:`, error);
1147
+ }
1148
+ }
1149
+ }
1150
+ /**
1151
+ * Remove deleted repositories
1152
+ */
1153
+ async removeDeletedRepositories(repos) {
1154
+ for (const repo of repos) {
1155
+ try {
1156
+ this.logger.info(`🗑️ Removing repository: ${repo.name} (${repo.id})`);
1157
+ // Check for active sessions
1158
+ const manager = this.agentSessionManagers.get(repo.id);
1159
+ const activeSessions = manager?.getActiveSessions() || [];
1160
+ if (activeSessions.length > 0) {
1161
+ this.logger.warn(` ⚠️ Repository has ${activeSessions.length} active sessions - stopping them`);
1162
+ // Stop all active sessions and notify Linear
1163
+ for (const session of activeSessions) {
1164
+ try {
1165
+ this.logger.debug(` 🛑 Stopping session for issue ${session.issueId}`);
1166
+ // Get the agent runner for this session
1167
+ const runner = manager?.getAgentRunner(session.id);
1168
+ if (runner) {
1169
+ // Stop the agent process
1170
+ runner.stop();
1171
+ this.logger.debug(` ✅ Stopped Claude runner for session ${session.id}`);
1172
+ }
1173
+ // Post cancellation message to tracker
1174
+ const issueTracker = this.issueTrackers.get(repo.id);
1175
+ if (issueTracker && session.externalSessionId) {
1176
+ await this.postActivityDirect(issueTracker, {
1177
+ agentSessionId: session.externalSessionId,
1178
+ content: {
1179
+ type: "response",
1180
+ body: `**Repository Removed from Configuration**\n\nThis repository (\`${repo.name}\`) has been removed from the Sylas configuration. All active sessions for this repository have been stopped.\n\nIf you need to continue working on this issue, please contact your administrator to restore the repository configuration.`,
1181
+ },
1182
+ }, "repository removal");
1183
+ }
1184
+ }
1185
+ catch (error) {
1186
+ this.logger.error(` ❌ Failed to stop session ${session.id}:`, error);
1187
+ }
1188
+ }
1189
+ }
1190
+ // Remove repository from all maps
1191
+ this.repositories.delete(repo.id);
1192
+ this.issueTrackers.delete(repo.id);
1193
+ this.agentSessionManagers.delete(repo.id);
1194
+ this.logger.info(`✅ Repository removed successfully: ${repo.name}`);
1195
+ }
1196
+ catch (error) {
1197
+ this.logger.error(`❌ Failed to remove repository ${repo.name}:`, error);
1198
+ }
1199
+ }
1200
+ }
1201
+ /**
1202
+ * Handle errors
1203
+ */
1204
+ handleError(error) {
1205
+ this.emit("error", error);
1206
+ this.config.handlers?.onError?.(error);
1207
+ }
1208
+ /**
1209
+ * Get cached repository for an issue (used by agentSessionPrompted Branch 3)
1210
+ */
1211
+ getCachedRepository(issueId) {
1212
+ return this.repositoryRouter.getCachedRepository(issueId, this.repositories);
1213
+ }
1214
+ /**
1215
+ * Handle webhook events from proxy - main router for all webhooks
1216
+ */
1217
+ async handleWebhook(webhook, repos) {
1218
+ // Track active webhook processing for status endpoint
1219
+ this.activeWebhookCount++;
1220
+ // Log verbose webhook info if enabled
1221
+ if (process.env.SYLAS_WEBHOOK_DEBUG === "true") {
1222
+ this.logger.debug(`Full webhook payload:`, JSON.stringify(webhook, null, 2));
1223
+ }
1224
+ try {
1225
+ // Route to specific webhook handlers based on webhook type
1226
+ // NOTE: Traditional webhooks (assigned, comment) are disabled in favor of agent session events
1227
+ if (isIssueAssignedWebhook(webhook)) {
1228
+ return;
1229
+ }
1230
+ else if (isIssueCommentMentionWebhook(webhook)) {
1231
+ return;
1232
+ }
1233
+ else if (isIssueNewCommentWebhook(webhook)) {
1234
+ return;
1235
+ }
1236
+ else if (isIssueUnassignedWebhook(webhook)) {
1237
+ // Keep unassigned webhook active
1238
+ await this.handleIssueUnassignedWebhook(webhook);
1239
+ }
1240
+ else if (isAgentSessionCreatedWebhook(webhook)) {
1241
+ await this.handleAgentSessionCreatedWebhook(webhook, repos);
1242
+ }
1243
+ else if (isAgentSessionPromptedWebhook(webhook)) {
1244
+ await this.handleUserPromptedAgentActivity(webhook);
1245
+ }
1246
+ else if (isIssueTitleOrDescriptionUpdateWebhook(webhook)) {
1247
+ // Handle issue title/description/attachments updates - feed changes into active session
1248
+ await this.handleIssueContentUpdate(webhook);
1249
+ }
1250
+ else {
1251
+ if (process.env.SYLAS_WEBHOOK_DEBUG === "true") {
1252
+ this.logger.debug(`Unhandled webhook type: ${webhook.action}`);
1253
+ }
1254
+ }
1255
+ }
1256
+ catch (error) {
1257
+ this.logger.error(`Failed to process webhook: ${webhook.action}`, error);
1258
+ // Don't re-throw webhook processing errors to prevent application crashes
1259
+ // The error has been logged and individual webhook failures shouldn't crash the entire system
1260
+ }
1261
+ finally {
1262
+ // Always decrement counter when webhook processing completes
1263
+ this.activeWebhookCount--;
1264
+ }
1265
+ }
1266
+ // ============================================================================
1267
+ // INTERNAL MESSAGE BUS HANDLERS
1268
+ // ============================================================================
1269
+ // These handlers process unified InternalMessage types from the message bus.
1270
+ // They provide a platform-agnostic interface for handling events from
1271
+ // Linear, GitHub, Slack, and other platforms.
1272
+ // ============================================================================
1273
+ /**
1274
+ * Handle unified internal messages from the message bus.
1275
+ * This is the new entry point for processing events from all platforms.
1276
+ *
1277
+ * Note: For now, this runs in parallel with legacy webhook handlers.
1278
+ * Once migration is complete, legacy handlers will be removed.
1279
+ */
1280
+ async handleMessage(message) {
1281
+ // NOTE: activeWebhookCount is NOT tracked here because legacy webhook handlers
1282
+ // already increment/decrement it for every event. Counting here would double-count.
1283
+ // TODO: When legacy handlers are removed, restore activeWebhookCount tracking here.
1284
+ // Log verbose message info if enabled
1285
+ if (process.env.SYLAS_WEBHOOK_DEBUG === "true") {
1286
+ this.logger.debug(`Internal message received: ${message.source}/${message.action}`, JSON.stringify(message, null, 2));
1287
+ }
1288
+ try {
1289
+ // Route to specific message handlers based on action type
1290
+ if (isSessionStartMessage(message)) {
1291
+ await this.handleSessionStartMessage(message);
1292
+ }
1293
+ else if (isUserPromptMessage(message)) {
1294
+ await this.handleUserPromptMessage(message);
1295
+ }
1296
+ else if (isStopSignalMessage(message)) {
1297
+ await this.handleStopSignalMessage(message);
1298
+ }
1299
+ else if (isContentUpdateMessage(message)) {
1300
+ await this.handleContentUpdateMessage(message);
1301
+ }
1302
+ else if (isUnassignMessage(message)) {
1303
+ await this.handleUnassignMessage(message);
1304
+ }
1305
+ else {
1306
+ // This branch should never be reached due to exhaustive type checking
1307
+ // If it is reached, log the unexpected message for debugging
1308
+ if (process.env.SYLAS_WEBHOOK_DEBUG === "true") {
1309
+ const unexpectedMessage = message;
1310
+ this.logger.debug(`Unhandled message action: ${unexpectedMessage.action}`);
1311
+ }
1312
+ }
1313
+ }
1314
+ catch (error) {
1315
+ this.logger.error(`Failed to process message: ${message.source}/${message.action}`, error);
1316
+ // Don't re-throw message processing errors to prevent application crashes
1317
+ }
1318
+ }
1319
+ /**
1320
+ * Handle session start message (unified handler for session creation).
1321
+ *
1322
+ * This is a placeholder that logs the message for now.
1323
+ * TODO: Migrate logic from handleAgentSessionCreatedWebhook and handleGitHubWebhook.
1324
+ */
1325
+ async handleSessionStartMessage(message) {
1326
+ this.logger.debug(`[MessageBus] Session start: ${message.workItemIdentifier} from ${message.source}`);
1327
+ // TODO: Implement unified session start handling
1328
+ // For now, the legacy handlers (handleAgentSessionCreatedWebhook, handleGitHubWebhook)
1329
+ // continue to process the actual session creation via the 'event' emitter.
1330
+ }
1331
+ /**
1332
+ * Handle user prompt message (unified handler for mid-session prompts).
1333
+ *
1334
+ * This is a placeholder that logs the message for now.
1335
+ * TODO: Migrate logic from handleUserPromptedAgentActivity (branch 3).
1336
+ */
1337
+ async handleUserPromptMessage(message) {
1338
+ this.logger.debug(`[MessageBus] User prompt: ${message.workItemIdentifier} from ${message.source}`);
1339
+ // TODO: Implement unified user prompt handling
1340
+ // For now, the legacy handler (handleUserPromptedAgentActivity)
1341
+ // continues to process the actual prompt via the 'event' emitter.
1342
+ }
1343
+ /**
1344
+ * Handle stop signal message (unified handler for session termination).
1345
+ *
1346
+ * This is a placeholder that logs the message for now.
1347
+ * TODO: Migrate logic from handleUserPromptedAgentActivity (branch 1).
1348
+ */
1349
+ async handleStopSignalMessage(message) {
1350
+ this.logger.debug(`[MessageBus] Stop signal: ${message.workItemIdentifier} from ${message.source}`);
1351
+ // TODO: Implement unified stop signal handling
1352
+ // For now, the legacy handler (handleUserPromptedAgentActivity)
1353
+ // continues to process the actual stop via the 'event' emitter.
1354
+ }
1355
+ /**
1356
+ * Handle content update message (unified handler for issue/PR content changes).
1357
+ *
1358
+ * This is a placeholder that logs the message for now.
1359
+ * TODO: Migrate logic from handleIssueContentUpdate.
1360
+ */
1361
+ async handleContentUpdateMessage(message) {
1362
+ this.logger.debug(`[MessageBus] Content update: ${message.workItemIdentifier} from ${message.source}`);
1363
+ // TODO: Implement unified content update handling
1364
+ // For now, the legacy handler (handleIssueContentUpdate)
1365
+ // continues to process the actual update via the 'event' emitter.
1366
+ }
1367
+ /**
1368
+ * Handle unassign message (unified handler for task unassignment).
1369
+ *
1370
+ * This is a placeholder that logs the message for now.
1371
+ * TODO: Migrate logic from handleIssueUnassignedWebhook.
1372
+ */
1373
+ async handleUnassignMessage(message) {
1374
+ this.logger.debug(`[MessageBus] Unassign: ${message.workItemIdentifier} from ${message.source}`);
1375
+ // TODO: Implement unified unassign handling
1376
+ // For now, the legacy handler (handleIssueUnassignedWebhook)
1377
+ // continues to process the actual unassignment via the 'event' emitter.
1378
+ }
1379
+ // ============================================================================
1380
+ // LEGACY WEBHOOK HANDLERS
1381
+ // ============================================================================
1382
+ /**
1383
+ * Handle issue unassignment webhook
1384
+ */
1385
+ async handleIssueUnassignedWebhook(webhook) {
1386
+ if (!webhook.notification.issue) {
1387
+ this.logger.warn("Received issue unassignment webhook without issue");
1388
+ return;
1389
+ }
1390
+ const issueId = webhook.notification.issue.id;
1391
+ // Get cached repository (unassignment should only happen on issues with active sessions)
1392
+ const repository = this.getCachedRepository(issueId);
1393
+ if (!repository) {
1394
+ this.logger.debug(`No cached repository for issue unassignment webhook ${webhook.notification.issue.identifier} (no active sessions to stop)`);
1395
+ return;
1396
+ }
1397
+ this.logger.info(`Handling issue unassignment: ${webhook.notification.issue.identifier}`);
1398
+ // Log the complete webhook payload for TypeScript type definition
1399
+ // console.log('=== ISSUE UNASSIGNMENT WEBHOOK PAYLOAD ===')
1400
+ // console.log(JSON.stringify(webhook, null, 2))
1401
+ // console.log('=== END WEBHOOK PAYLOAD ===')
1402
+ await this.handleIssueUnassigned(webhook.notification.issue, repository);
1403
+ }
1404
+ /**
1405
+ * Handle issue content update webhook (title, description, or attachments).
1406
+ *
1407
+ * When the title, description, or attachments of an issue are updated, this handler feeds
1408
+ * the changes into any active session for that issue, allowing the AI to
1409
+ * compare old vs new values and decide whether to take action.
1410
+ *
1411
+ * The prompt uses XML-style formatting to clearly show what changed:
1412
+ * - <issue_update> wrapper with timestamp and issue identifier
1413
+ * - <title_change> with <old_title> and <new_title> if title changed
1414
+ * - <description_change> with <old_description> and <new_description> if description changed
1415
+ * - <attachments_change> with <old_attachments> and <new_attachments> if attachments changed
1416
+ * - <guidance> section instructing the agent to evaluate whether changes affect its work
1417
+ *
1418
+ * @see https://studio.apollographql.com/public/Linear-Webhooks/variant/current/schema/reference/objects/EntityWebhookPayload
1419
+ * @see https://studio.apollographql.com/public/Linear-Webhooks/variant/current/schema/reference/objects/IssueWebhookPayload
1420
+ * @see https://studio.apollographql.com/public/Linear-Webhooks/variant/current/schema/reference/unions/DataWebhookPayload
1421
+ */
1422
+ async handleIssueContentUpdate(webhook) {
1423
+ // Check if issue update trigger is enabled (defaults to true if not set)
1424
+ if (this.config.issueUpdateTrigger === false) {
1425
+ if (process.env.SYLAS_WEBHOOK_DEBUG === "true") {
1426
+ this.logger.debug("Issue update trigger is disabled, skipping issue content update");
1427
+ }
1428
+ return;
1429
+ }
1430
+ const issueData = webhook.data;
1431
+ const issueId = issueData.id;
1432
+ const issueIdentifier = issueData.identifier;
1433
+ const updatedFrom = webhook.updatedFrom;
1434
+ if (!updatedFrom) {
1435
+ this.logger.warn(`Issue update webhook for ${issueIdentifier} has no updatedFrom data`);
1436
+ return;
1437
+ }
1438
+ // Get cached repository (updates should only be processed for issues with active sessions)
1439
+ const repository = this.getCachedRepository(issueId);
1440
+ if (!repository) {
1441
+ if (process.env.SYLAS_WEBHOOK_DEBUG === "true") {
1442
+ this.logger.debug(`No cached repository for issue update webhook ${issueIdentifier} (no active sessions to notify)`);
1443
+ }
1444
+ return;
1445
+ }
1446
+ // Determine what changed for logging
1447
+ const changedFields = [];
1448
+ if ("title" in updatedFrom)
1449
+ changedFields.push("title");
1450
+ if ("description" in updatedFrom)
1451
+ changedFields.push("description");
1452
+ if ("attachments" in updatedFrom)
1453
+ changedFields.push("attachments");
1454
+ this.logger.info(`Handling issue content update: ${issueIdentifier} (changed: ${changedFields.join(", ")})`);
1455
+ // Get agent session manager for this repository
1456
+ const agentSessionManager = this.agentSessionManagers.get(repository.id);
1457
+ if (!agentSessionManager) {
1458
+ this.logger.debug(`No agent session manager for repository ${repository.id}`);
1459
+ return;
1460
+ }
1461
+ // Find session(s) for this issue (may be running or paused between subroutines)
1462
+ const sessions = agentSessionManager.getSessionsByIssueId(issueId);
1463
+ if (sessions.length === 0) {
1464
+ if (process.env.SYLAS_WEBHOOK_DEBUG === "true") {
1465
+ this.logger.debug(`No sessions found for issue ${issueIdentifier} to receive update`);
1466
+ }
1467
+ return;
1468
+ }
1469
+ // Process attachments from the updated description if description changed
1470
+ let attachmentManifest = "";
1471
+ if ("description" in updatedFrom && issueData.description) {
1472
+ const firstSession = sessions[0];
1473
+ if (!firstSession) {
1474
+ this.logger.debug(`No sessions found for issue ${issueIdentifier}`);
1475
+ return;
1476
+ }
1477
+ const workspaceFolderName = basename(firstSession.workspace.path);
1478
+ const attachmentsDir = join(this.sylasHome, workspaceFolderName, "attachments");
1479
+ try {
1480
+ // Ensure directory exists
1481
+ await mkdir(attachmentsDir, { recursive: true });
1482
+ // Count existing attachments
1483
+ const existingFiles = await readdir(attachmentsDir).catch(() => []);
1484
+ const existingAttachmentCount = existingFiles.filter((file) => file.startsWith("attachment_") || file.startsWith("image_")).length;
1485
+ // Download attachments from the new description
1486
+ const downloadResult = await this.downloadCommentAttachments(issueData.description, attachmentsDir, repository.linearToken, existingAttachmentCount);
1487
+ if (downloadResult.totalNewAttachments > 0) {
1488
+ attachmentManifest =
1489
+ this.generateNewAttachmentManifest(downloadResult);
1490
+ this.logger.debug(`Downloaded ${downloadResult.totalNewAttachments} attachments from updated description`);
1491
+ }
1492
+ }
1493
+ catch (error) {
1494
+ this.logger.error("Failed to process attachments from updated description:", error);
1495
+ }
1496
+ }
1497
+ // Build the XML-formatted prompt showing old vs new values
1498
+ const promptBody = this.buildIssueUpdatePrompt(issueIdentifier, issueData, updatedFrom);
1499
+ // Feed the update into each active session
1500
+ for (const session of sessions) {
1501
+ const linearAgentActivitySessionId = session.id;
1502
+ // Check if runner is actively running and supports streaming input
1503
+ const existingRunner = session.agentRunner;
1504
+ const isRunning = existingRunner?.isRunning() || false;
1505
+ // Combine prompt body with attachment manifest
1506
+ let fullPrompt = promptBody;
1507
+ if (attachmentManifest) {
1508
+ fullPrompt = `${promptBody}\n\n${attachmentManifest}`;
1509
+ }
1510
+ if (isRunning &&
1511
+ existingRunner?.supportsStreamingInput &&
1512
+ existingRunner.addStreamMessage) {
1513
+ // Add to existing stream
1514
+ this.logger.debug(`Adding issue update to existing stream for ${linearAgentActivitySessionId}`);
1515
+ existingRunner.addStreamMessage(fullPrompt);
1516
+ }
1517
+ else if (isRunning) {
1518
+ // Runner is running but doesn't support streaming input - log and skip
1519
+ this.logger.debug(`Session ${linearAgentActivitySessionId} is running but doesn't support streaming input, skipping issue update`);
1520
+ }
1521
+ else {
1522
+ // Session exists but runner is not running - resume with the update
1523
+ this.logger.debug(`Resuming session ${linearAgentActivitySessionId} with issue update`);
1524
+ await this.handlePromptWithStreamingCheck(session, repository, linearAgentActivitySessionId, agentSessionManager, promptBody, attachmentManifest, false, // Not a new session
1525
+ [], // No additional allowed directories
1526
+ "issue content update", undefined, // No comment author
1527
+ undefined);
1528
+ }
1529
+ }
1530
+ }
1531
+ /**
1532
+ * Build an XML-formatted prompt for issue content updates (title, description, attachments).
1533
+ *
1534
+ * The prompt clearly shows what fields changed by comparing old vs new values,
1535
+ * and includes guidance for the agent to evaluate whether these changes affect
1536
+ * its current implementation or action plan.
1537
+ */
1538
+ buildIssueUpdatePrompt(issueIdentifier, issueData, updatedFrom) {
1539
+ return this.promptBuilder.buildIssueUpdatePrompt(issueIdentifier, issueData, updatedFrom);
1540
+ }
1541
+ /**
1542
+ * Get issue tracker for a workspace by finding first repository with that workspace ID
1543
+ */
1544
+ getIssueTrackerForWorkspace(workspaceId) {
1545
+ for (const [repoId, repo] of this.repositories) {
1546
+ if (repo.linearWorkspaceId === workspaceId) {
1547
+ return this.issueTrackers.get(repoId);
1548
+ }
1549
+ }
1550
+ return undefined;
1551
+ }
1552
+ /**
1553
+ * Create a new Linear agent session with all necessary setup
1554
+ * @param sessionId The Linear agent activity session ID
1555
+ * @param issue Linear issue object
1556
+ * @param repository Repository configuration
1557
+ * @param agentSessionManager Agent session manager instance
1558
+ * @returns Object containing session details and setup information
1559
+ */
1560
+ async createLinearAgentSession(sessionId, issue, repository, agentSessionManager) {
1561
+ // Fetch full Linear issue details
1562
+ const fullIssue = await this.fetchFullIssueDetails(issue.id, repository.id);
1563
+ if (!fullIssue) {
1564
+ throw new Error(`Failed to fetch full issue details for ${issue.id}`);
1565
+ }
1566
+ // Move issue to started state automatically, in case it's not already
1567
+ await this.moveIssueToStartedState(fullIssue, repository.id);
1568
+ // Create workspace using full issue data
1569
+ // Use custom handler if provided, otherwise create a git worktree by default
1570
+ const workspace = this.config.handlers?.createWorkspace
1571
+ ? await this.config.handlers.createWorkspace(fullIssue, repository)
1572
+ : await this.gitService.createGitWorktree(fullIssue, repository);
1573
+ this.logger.debug(`Workspace created at: ${workspace.path}`);
1574
+ const issueMinimal = this.convertLinearIssueToCore(fullIssue);
1575
+ agentSessionManager.createLinearAgentSession(sessionId, issue.id, issueMinimal, workspace);
1576
+ // Get the newly created session
1577
+ const session = agentSessionManager.getSession(sessionId);
1578
+ if (!session) {
1579
+ throw new Error(`Failed to create session for agent activity session ${sessionId}`);
1580
+ }
1581
+ // Download attachments before creating Claude runner
1582
+ const attachmentResult = await this.downloadIssueAttachments(fullIssue, repository, workspace.path);
1583
+ // Pre-create attachments directory even if no attachments exist yet
1584
+ const workspaceFolderName = basename(workspace.path);
1585
+ const attachmentsDir = join(this.sylasHome, workspaceFolderName, "attachments");
1586
+ await mkdir(attachmentsDir, { recursive: true });
1587
+ // Build allowed directories list - always include attachments directory
1588
+ const allowedDirectories = [
1589
+ ...new Set([
1590
+ attachmentsDir,
1591
+ repository.repositoryPath,
1592
+ ...this.gitService.getGitMetadataDirectories(workspace.path),
1593
+ ]),
1594
+ ];
1595
+ this.logger.debug(`Configured allowed directories for ${fullIssue.identifier}:`, allowedDirectories);
1596
+ // Build allowed tools list with Linear MCP tools
1597
+ const allowedTools = this.buildAllowedTools(repository);
1598
+ const disallowedTools = this.buildDisallowedTools(repository);
1599
+ return {
1600
+ session,
1601
+ fullIssue,
1602
+ workspace,
1603
+ attachmentResult,
1604
+ attachmentsDir,
1605
+ allowedDirectories,
1606
+ allowedTools,
1607
+ disallowedTools,
1608
+ };
1609
+ }
1610
+ /**
1611
+ * Handle agent session created webhook
1612
+ * Can happen due to being 'delegated' or @ mentioned in a new thread
1613
+ * @param webhook The agent session created webhook
1614
+ * @param repos All available repositories for routing
1615
+ */
1616
+ async handleAgentSessionCreatedWebhook(webhook, repos) {
1617
+ const issueId = webhook.agentSession?.issue?.id;
1618
+ // Check the cache first, as the agentSessionCreated webhook may have been triggered by an @mention
1619
+ // on an issue that already has an agentSession and an associated repository.
1620
+ let repository = null;
1621
+ if (issueId) {
1622
+ repository = this.getCachedRepository(issueId);
1623
+ if (repository) {
1624
+ this.logger.debug(`Using cached repository ${repository.name} for issue ${issueId}`);
1625
+ }
1626
+ }
1627
+ // If not cached, perform routing logic
1628
+ if (!repository) {
1629
+ const routingResult = await this.repositoryRouter.determineRepositoryForWebhook(webhook, repos);
1630
+ if (routingResult.type === "none") {
1631
+ if (process.env.SYLAS_WEBHOOK_DEBUG === "true") {
1632
+ this.logger.info(`No repository configured for webhook from workspace ${webhook.organizationId}`);
1633
+ }
1634
+ return;
1635
+ }
1636
+ // Handle needs_selection case
1637
+ if (routingResult.type === "needs_selection") {
1638
+ await this.repositoryRouter.elicitUserRepositorySelection(webhook, routingResult.workspaceRepos);
1639
+ // Selection in progress - will be handled by handleRepositorySelectionResponse
1640
+ return;
1641
+ }
1642
+ // At this point, routingResult.type === "selected"
1643
+ repository = routingResult.repository;
1644
+ const routingMethod = routingResult.routingMethod;
1645
+ // Cache the repository for this issue
1646
+ if (issueId) {
1647
+ this.repositoryRouter
1648
+ .getIssueRepositoryCache()
1649
+ .set(issueId, repository.id);
1650
+ }
1651
+ // Post agent activity showing auto-matched routing
1652
+ await this.postRepositorySelectionActivity(webhook.agentSession.id, repository.id, repository.name, routingMethod);
1653
+ }
1654
+ if (!webhook.agentSession.issue) {
1655
+ this.logger.warn("Agent session created webhook missing issue");
1656
+ return;
1657
+ }
1658
+ // User access control check
1659
+ const accessResult = this.checkUserAccess(webhook, repository);
1660
+ if (!accessResult.allowed) {
1661
+ this.logger.info(`User ${accessResult.userName} blocked from delegating: ${accessResult.reason}`);
1662
+ await this.handleBlockedUser(webhook, repository, accessResult.reason);
1663
+ return;
1664
+ }
1665
+ const log = this.logger.withContext({
1666
+ sessionId: webhook.agentSession.id,
1667
+ platform: this.getRepositoryPlatform(repository.id),
1668
+ issueIdentifier: webhook.agentSession.issue.identifier,
1669
+ });
1670
+ log.info(`Handling agent session created`);
1671
+ const { agentSession, guidance } = webhook;
1672
+ const commentBody = agentSession.comment?.body;
1673
+ // Initialize agent runner using shared logic
1674
+ await this.initializeAgentRunner(agentSession, repository, guidance, commentBody);
1675
+ }
1676
+ /**
1677
+
1678
+ /**
1679
+ * Initialize and start agent runner for an agent session
1680
+ * This method contains the shared logic for creating an agent runner that both
1681
+ * handleAgentSessionCreatedWebhook and handleUserPromptedAgentActivity use.
1682
+ *
1683
+ * @param agentSession The Linear agent session
1684
+ * @param repository The repository configuration
1685
+ * @param guidance Optional guidance rules from Linear
1686
+ * @param commentBody Optional comment body (for mentions)
1687
+ */
1688
+ async initializeAgentRunner(agentSession, repository, guidance, commentBody) {
1689
+ const sessionId = agentSession.id;
1690
+ const { issue } = agentSession;
1691
+ if (!issue) {
1692
+ this.logger.warn("Cannot initialize Claude runner without issue");
1693
+ return;
1694
+ }
1695
+ const log = this.logger.withContext({
1696
+ sessionId,
1697
+ issueIdentifier: issue.identifier,
1698
+ });
1699
+ // Log guidance if present
1700
+ if (guidance && guidance.length > 0) {
1701
+ log.debug(`Agent guidance received: ${guidance.length} rule(s)`);
1702
+ for (const rule of guidance) {
1703
+ let origin = "Unknown";
1704
+ if (rule.origin) {
1705
+ if (rule.origin.__typename === "TeamOriginWebhookPayload") {
1706
+ origin = `Team: ${rule.origin.team.displayName}`;
1707
+ }
1708
+ else {
1709
+ origin = "Organization";
1710
+ }
1711
+ }
1712
+ log.info(`- ${origin}: ${rule.body.substring(0, 100)}...`);
1713
+ }
1714
+ }
1715
+ // HACK: This is required since the comment body is always populated, thus there is no other way to differentiate between the two trigger events
1716
+ const AGENT_SESSION_MARKER = "This thread is for an agent session";
1717
+ const isMentionTriggered = commentBody && !commentBody.includes(AGENT_SESSION_MARKER);
1718
+ // Check if the comment contains the /label-based-prompt command
1719
+ const isLabelBasedPromptRequested = commentBody?.includes("/label-based-prompt");
1720
+ // Initialize the agent session in AgentSessionManager
1721
+ const agentSessionManager = this.agentSessionManagers.get(repository.id);
1722
+ if (!agentSessionManager) {
1723
+ log.error("There was no agentSessionManage for the repository with id", repository.id);
1724
+ return;
1725
+ }
1726
+ // Post instant acknowledgment thought
1727
+ await this.postInstantAcknowledgment(sessionId, repository.id);
1728
+ // Create the session using the shared method
1729
+ const sessionData = await this.createLinearAgentSession(sessionId, issue, repository, agentSessionManager);
1730
+ // Destructure the session data (excluding allowedTools which we'll build with promptType)
1731
+ const { session, fullIssue, workspace: _workspace, attachmentResult, attachmentsDir: _attachmentsDir, allowedDirectories, } = sessionData;
1732
+ // Initialize procedure metadata using intelligent routing
1733
+ if (!session.metadata) {
1734
+ session.metadata = {};
1735
+ }
1736
+ // Post ephemeral "Routing..." thought
1737
+ await agentSessionManager.postAnalyzingThought(sessionId);
1738
+ // Fetch labels early (needed for label override check)
1739
+ const labels = await this.fetchIssueLabels(fullIssue);
1740
+ // Lowercase labels for case-insensitive comparison
1741
+ const lowercaseLabels = labels.map((label) => label.toLowerCase());
1742
+ // Check for label overrides BEFORE AI routing
1743
+ const debuggerConfig = repository.labelPrompts?.debugger;
1744
+ const debuggerLabels = Array.isArray(debuggerConfig)
1745
+ ? debuggerConfig
1746
+ : debuggerConfig?.labels;
1747
+ const hasDebuggerLabel = debuggerLabels?.some((label) => lowercaseLabels.includes(label.toLowerCase()));
1748
+ // ALWAYS check for 'orchestrator' label (case-insensitive) regardless of EdgeConfig
1749
+ // This is a hardcoded rule: any issue with 'orchestrator'/'Orchestrator' label
1750
+ // goes to orchestrator procedure
1751
+ const hasHardcodedOrchestratorLabel = lowercaseLabels.includes("orchestrator");
1752
+ // Also check any additional orchestrator labels from config
1753
+ const orchestratorConfig = repository.labelPrompts?.orchestrator;
1754
+ const orchestratorLabels = Array.isArray(orchestratorConfig)
1755
+ ? orchestratorConfig
1756
+ : orchestratorConfig?.labels;
1757
+ const hasConfiguredOrchestratorLabel = orchestratorLabels?.some((label) => lowercaseLabels.includes(label.toLowerCase())) ?? false;
1758
+ const hasOrchestratorLabel = hasHardcodedOrchestratorLabel || hasConfiguredOrchestratorLabel;
1759
+ // Check for graphite label (for graphite-orchestrator combination)
1760
+ const graphiteConfig = repository.labelPrompts?.graphite;
1761
+ const graphiteLabels = Array.isArray(graphiteConfig)
1762
+ ? graphiteConfig
1763
+ : (graphiteConfig?.labels ?? ["graphite"]);
1764
+ const hasGraphiteLabel = graphiteLabels?.some((label) => lowercaseLabels.includes(label.toLowerCase()));
1765
+ // Graphite-orchestrator requires BOTH graphite AND orchestrator labels
1766
+ const hasGraphiteOrchestratorLabels = hasGraphiteLabel && hasOrchestratorLabel;
1767
+ let finalProcedure;
1768
+ let finalClassification;
1769
+ // Smart runner override: OpenCode with oh-my-opencode gets full-delegation
1770
+ // (bypasses AI routing and label-based procedure selection entirely)
1771
+ const earlyRunnerSelection = this.determineRunnerSelection(labels, fullIssue.description || undefined);
1772
+ if (earlyRunnerSelection.runnerType === "opencode") {
1773
+ const fullDelegationProcedure = this.procedureAnalyzer.getProcedure("full-delegation");
1774
+ if (!fullDelegationProcedure) {
1775
+ throw new Error("full-delegation procedure not found in registry");
1776
+ }
1777
+ finalProcedure = fullDelegationProcedure;
1778
+ finalClassification = "code"; // Default classification; the runner handles everything
1779
+ log.info(`Using full-delegation procedure for OpenCode runner (bypassing AI routing)`);
1780
+ }
1781
+ else if (hasDebuggerLabel) {
1782
+ const debuggerProcedure = this.procedureAnalyzer.getProcedure("debugger-full");
1783
+ if (!debuggerProcedure) {
1784
+ throw new Error("debugger-full procedure not found in registry");
1785
+ }
1786
+ finalProcedure = debuggerProcedure;
1787
+ finalClassification = "debugger";
1788
+ log.info(`Using debugger-full procedure due to debugger label (skipping AI routing)`);
1789
+ }
1790
+ else if (hasGraphiteOrchestratorLabels) {
1791
+ // Graphite-orchestrator takes precedence over regular orchestrator when both labels present
1792
+ const orchestratorProcedure = this.procedureAnalyzer.getProcedure("orchestrator-full");
1793
+ if (!orchestratorProcedure) {
1794
+ throw new Error("orchestrator-full procedure not found in registry");
1795
+ }
1796
+ finalProcedure = orchestratorProcedure;
1797
+ // Use orchestrator classification but the system prompt will be graphite-orchestrator
1798
+ finalClassification = "orchestrator";
1799
+ log.info(`Using orchestrator-full procedure with graphite-orchestrator prompt (graphite + orchestrator labels)`);
1800
+ }
1801
+ else if (hasOrchestratorLabel) {
1802
+ const orchestratorProcedure = this.procedureAnalyzer.getProcedure("orchestrator-full");
1803
+ if (!orchestratorProcedure) {
1804
+ throw new Error("orchestrator-full procedure not found in registry");
1805
+ }
1806
+ finalProcedure = orchestratorProcedure;
1807
+ finalClassification = "orchestrator";
1808
+ log.info(`Using orchestrator-full procedure due to orchestrator label (skipping AI routing)`);
1809
+ }
1810
+ else {
1811
+ // No label override - use AI routing
1812
+ const issueDescription = `${issue.title}\n\n${fullIssue.description || ""}`.trim();
1813
+ const routingDecision = await this.procedureAnalyzer.determineRoutine(issueDescription);
1814
+ finalProcedure = routingDecision.procedure;
1815
+ finalClassification = routingDecision.classification;
1816
+ // Log AI routing decision
1817
+ log.info(`AI routing decision for ${sessionId}:`);
1818
+ log.info(` Classification: ${routingDecision.classification}`);
1819
+ log.info(` Procedure: ${finalProcedure.name}`);
1820
+ log.info(` Reasoning: ${routingDecision.reasoning}`);
1821
+ }
1822
+ // Initialize procedure metadata in session with final decision
1823
+ this.procedureAnalyzer.initializeProcedureMetadata(session, finalProcedure);
1824
+ // Post single procedure selection result (replaces ephemeral routing thought)
1825
+ await agentSessionManager.postProcedureSelectionThought(sessionId, finalProcedure.name, finalClassification);
1826
+ // Build and start Claude with initial prompt using full issue (streaming mode)
1827
+ log.info(`Building initial prompt for issue ${fullIssue.identifier}`);
1828
+ try {
1829
+ // Create input for unified prompt assembly
1830
+ const input = {
1831
+ session,
1832
+ fullIssue,
1833
+ repository,
1834
+ userComment: commentBody || "", // Empty for delegation, present for mentions
1835
+ attachmentManifest: attachmentResult.manifest,
1836
+ guidance: guidance || undefined,
1837
+ agentSession,
1838
+ labels,
1839
+ isNewSession: true,
1840
+ isStreaming: false, // Not yet streaming
1841
+ isMentionTriggered: isMentionTriggered || false,
1842
+ isLabelBasedPromptRequested: isLabelBasedPromptRequested || false,
1843
+ };
1844
+ // Use unified prompt assembly
1845
+ const assembly = await this.assemblePrompt(input);
1846
+ // Get systemPromptVersion for tracking (TODO: add to PromptAssembly metadata)
1847
+ let systemPromptVersion;
1848
+ let promptType;
1849
+ if (!isMentionTriggered || isLabelBasedPromptRequested) {
1850
+ const systemPromptResult = await this.determineSystemPromptFromLabels(labels, repository);
1851
+ systemPromptVersion = systemPromptResult?.version;
1852
+ promptType = systemPromptResult?.type;
1853
+ // Post thought about system prompt selection
1854
+ if (assembly.systemPrompt) {
1855
+ await this.postSystemPromptSelectionThought(sessionId, labels, repository.id);
1856
+ }
1857
+ }
1858
+ // Get current subroutine to check for singleTurn mode and disallowAllTools
1859
+ const currentSubroutine = this.procedureAnalyzer.getCurrentSubroutine(session);
1860
+ // Build allowed tools list with Linear MCP tools (now with prompt type context)
1861
+ // If subroutine has disallowAllTools: true, use empty array to disable all tools
1862
+ const allowedTools = currentSubroutine?.disallowAllTools
1863
+ ? []
1864
+ : this.buildAllowedTools(repository, promptType);
1865
+ const baseDisallowedTools = this.buildDisallowedTools(repository, promptType);
1866
+ // Merge subroutine-level disallowedTools if applicable
1867
+ const disallowedTools = this.mergeSubroutineDisallowedTools(session, baseDisallowedTools, "EdgeWorker");
1868
+ if (currentSubroutine?.disallowAllTools) {
1869
+ log.debug(`All tools disabled for ${fullIssue.identifier} (subroutine: ${currentSubroutine.name})`);
1870
+ }
1871
+ else {
1872
+ log.debug(`Configured allowed tools for ${fullIssue.identifier}:`, allowedTools);
1873
+ }
1874
+ if (disallowedTools.length > 0) {
1875
+ log.debug(`Configured disallowed tools for ${fullIssue.identifier}:`, disallowedTools);
1876
+ }
1877
+ // Create agent runner with system prompt from assembly
1878
+ // buildAgentRunnerConfig now determines runner type from labels internally
1879
+ const { config: runnerConfig, runnerType } = this.buildAgentRunnerConfig(session, repository, sessionId, assembly.systemPrompt, allowedTools, allowedDirectories, disallowedTools, undefined, // resumeSessionId
1880
+ labels, // Pass labels for runner selection and model override
1881
+ fullIssue.description || undefined, // Description tags can override label selectors
1882
+ undefined, // maxTurns
1883
+ currentSubroutine?.singleTurn, // singleTurn flag
1884
+ currentSubroutine?.disallowAllTools);
1885
+ log.debug(`Label-based runner selection for new session: ${runnerType} (session ${sessionId})`);
1886
+ const runner = runnerType === "opencode"
1887
+ ? new OpenCodeRunner(runnerConfig)
1888
+ : runnerType === "claude"
1889
+ ? new ClaudeRunner(runnerConfig)
1890
+ : runnerType === "gemini"
1891
+ ? new GeminiRunner(runnerConfig)
1892
+ : runnerType === "codex"
1893
+ ? new CodexRunner(runnerConfig)
1894
+ : new CursorRunner(runnerConfig);
1895
+ // Store runner by comment ID
1896
+ agentSessionManager.addAgentRunner(sessionId, runner);
1897
+ // Save state after mapping changes
1898
+ await this.savePersistedState();
1899
+ // Emit events using full issue (core Issue type)
1900
+ this.emit("session:started", fullIssue.id, fullIssue, repository.id);
1901
+ this.config.handlers?.onSessionStart?.(fullIssue.id, fullIssue, repository.id);
1902
+ // Update runner with version information (if available)
1903
+ // Note: updatePromptVersions is specific to ClaudeRunner
1904
+ if (systemPromptVersion &&
1905
+ "updatePromptVersions" in runner &&
1906
+ typeof runner.updatePromptVersions === "function") {
1907
+ runner.updatePromptVersions({
1908
+ systemPromptVersion,
1909
+ });
1910
+ }
1911
+ // Log metadata for debugging
1912
+ log.debug(`Initial prompt built successfully - components: ${assembly.metadata.components.join(", ")}, type: ${assembly.metadata.promptType}, length: ${assembly.userPrompt.length} characters`);
1913
+ // Start session - use streaming mode if supported for ability to add messages later
1914
+ if (runner.supportsStreamingInput && runner.startStreaming) {
1915
+ log.debug(`Starting streaming session`);
1916
+ const sessionInfo = await runner.startStreaming(assembly.userPrompt);
1917
+ log.debug(`Streaming session started: ${sessionInfo.sessionId}`);
1918
+ }
1919
+ else {
1920
+ log.debug(`Starting non-streaming session`);
1921
+ const sessionInfo = await runner.start(assembly.userPrompt);
1922
+ log.debug(`Non-streaming session started: ${sessionInfo.sessionId}`);
1923
+ }
1924
+ // Note: AgentSessionManager will be initialized automatically when the first system message
1925
+ // is received via handleClaudeMessage() callback
1926
+ }
1927
+ catch (error) {
1928
+ log.error(`Error in prompt building/starting:`, error);
1929
+ throw error;
1930
+ }
1931
+ }
1932
+ /**
1933
+ * Handle stop signal from prompted webhook
1934
+ * Branch 1 of agentSessionPrompted (see packages/CLAUDE.md)
1935
+ *
1936
+ * IMPORTANT: Stop signals do NOT require repository lookup.
1937
+ * The session must already exist (per CLAUDE.md), so we search
1938
+ * all agent session managers to find it.
1939
+ */
1940
+ async handleStopSignal(webhook) {
1941
+ const agentSessionId = webhook.agentSession.id;
1942
+ const { issue } = webhook.agentSession;
1943
+ const log = this.logger.withContext({ sessionId: agentSessionId });
1944
+ log.info(`Received stop signal for agent activity session ${agentSessionId}`);
1945
+ // Find the agent session manager that contains this session
1946
+ // We don't need repository lookup - just search all managers
1947
+ let foundManager = null;
1948
+ let foundSession = null;
1949
+ for (const manager of this.agentSessionManagers.values()) {
1950
+ const session = manager.getSession(agentSessionId);
1951
+ if (session) {
1952
+ foundManager = manager;
1953
+ foundSession = session;
1954
+ break;
1955
+ }
1956
+ }
1957
+ if (!foundManager || !foundSession) {
1958
+ log.warn(`No session found for stop signal: ${agentSessionId}`);
1959
+ return;
1960
+ }
1961
+ // Stop the existing runner if it's active
1962
+ const existingRunner = foundSession.agentRunner;
1963
+ foundManager.requestSessionStop(agentSessionId);
1964
+ if (existingRunner) {
1965
+ existingRunner.stop();
1966
+ log.info(`Stopped agent session for agent activity session ${agentSessionId}`);
1967
+ }
1968
+ // Post confirmation
1969
+ const issueTitle = issue?.title || "this issue";
1970
+ 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`;
1971
+ await foundManager.createResponseActivity(agentSessionId, stopConfirmation);
1972
+ }
1973
+ /**
1974
+ * Handle repository selection response from prompted webhook
1975
+ * Branch 2 of agentSessionPrompted (see packages/CLAUDE.md)
1976
+ *
1977
+ * This method extracts the user's repository selection from their response,
1978
+ * or uses the fallback repository if their message doesn't match any option.
1979
+ * In both cases, the selected repository is cached for future use.
1980
+ */
1981
+ async handleRepositorySelectionResponse(webhook) {
1982
+ const { agentSession, agentActivity, guidance } = webhook;
1983
+ const commentBody = agentSession.comment?.body;
1984
+ const agentSessionId = agentSession.id;
1985
+ const log = this.logger.withContext({ sessionId: agentSessionId });
1986
+ if (!agentActivity) {
1987
+ log.warn("Cannot handle repository selection without agentActivity");
1988
+ return;
1989
+ }
1990
+ if (!agentSession.issue) {
1991
+ log.warn("Cannot handle repository selection without issue");
1992
+ return;
1993
+ }
1994
+ const userMessage = agentActivity.content.body;
1995
+ log.debug(`Processing repository selection response: "${userMessage}"`);
1996
+ // Get the selected repository (or fallback)
1997
+ const repository = await this.repositoryRouter.selectRepositoryFromResponse(agentSessionId, userMessage);
1998
+ if (!repository) {
1999
+ log.error(`Failed to select repository for agent session ${agentSessionId}`);
2000
+ return;
2001
+ }
2002
+ // Cache the selected repository for this issue
2003
+ const issueId = agentSession.issue.id;
2004
+ this.repositoryRouter.getIssueRepositoryCache().set(issueId, repository.id);
2005
+ // Post agent activity showing user-selected repository
2006
+ await this.postRepositorySelectionActivity(agentSessionId, repository.id, repository.name, "user-selected");
2007
+ log.debug(`Initializing agent runner after repository selection: ${agentSession.issue.identifier} -> ${repository.name}`);
2008
+ // Initialize agent runner with the selected repository
2009
+ await this.initializeAgentRunner(agentSession, repository, guidance, commentBody);
2010
+ }
2011
+ /**
2012
+ * Handle AskUserQuestion response from prompted webhook
2013
+ * Branch 2.5: User response to a question posed via AskUserQuestion tool
2014
+ *
2015
+ * @param webhook The prompted webhook containing user's response
2016
+ */
2017
+ async handleAskUserQuestionResponse(webhook) {
2018
+ const { agentSession, agentActivity } = webhook;
2019
+ const agentSessionId = agentSession.id;
2020
+ if (!agentActivity) {
2021
+ this.logger.warn("Cannot handle AskUserQuestion response without agentActivity");
2022
+ // Resolve with a denial to unblock the waiting promise
2023
+ this.askUserQuestionHandler.cancelPendingQuestion(agentSessionId, "No agent activity in webhook");
2024
+ return;
2025
+ }
2026
+ // Extract the user's response from the activity body
2027
+ const userResponse = agentActivity.content?.body || "";
2028
+ this.logger.debug(`Processing AskUserQuestion response for session ${agentSessionId}: "${userResponse}"`);
2029
+ // Pass the response to the handler to resolve the waiting promise
2030
+ const handled = this.askUserQuestionHandler.handleUserResponse(agentSessionId, userResponse);
2031
+ if (!handled) {
2032
+ this.logger.warn(`AskUserQuestion response not handled for session ${agentSessionId} (no pending question)`);
2033
+ }
2034
+ else {
2035
+ this.logger.debug(`AskUserQuestion response handled for session ${agentSessionId}`);
2036
+ }
2037
+ }
2038
+ /**
2039
+ * Handle normal prompted activity (existing session continuation)
2040
+ * Branch 3 of agentSessionPrompted (see packages/CLAUDE.md)
2041
+ */
2042
+ async handleNormalPromptedActivity(webhook, repository) {
2043
+ const { agentSession } = webhook;
2044
+ const sessionId = agentSession.id;
2045
+ const { issue } = agentSession;
2046
+ if (!issue) {
2047
+ this.logger.warn("Cannot handle prompted activity without issue");
2048
+ return;
2049
+ }
2050
+ if (!webhook.agentActivity) {
2051
+ this.logger.warn("Cannot handle prompted activity without agentActivity");
2052
+ return;
2053
+ }
2054
+ const commentId = webhook.agentActivity.sourceCommentId;
2055
+ // Initialize the agent session in AgentSessionManager
2056
+ const agentSessionManager = this.agentSessionManagers.get(repository.id);
2057
+ if (!agentSessionManager) {
2058
+ this.logger.error("Unexpected: There was no agentSessionManage for the repository with id", repository.id);
2059
+ return;
2060
+ }
2061
+ let session = agentSessionManager.getSession(sessionId);
2062
+ let isNewSession = false;
2063
+ let fullIssue = null;
2064
+ if (!session) {
2065
+ this.logger.debug(`No existing session found for agent activity session ${sessionId}, creating new session`);
2066
+ isNewSession = true;
2067
+ // Post instant acknowledgment for new session creation
2068
+ await this.postInstantPromptedAcknowledgment(sessionId, repository.id, false);
2069
+ // Create the session using the shared method
2070
+ const sessionData = await this.createLinearAgentSession(sessionId, issue, repository, agentSessionManager);
2071
+ // Destructure session data for new session
2072
+ fullIssue = sessionData.fullIssue;
2073
+ session = sessionData.session;
2074
+ this.logger.debug(`Created new session ${sessionId} (prompted webhook)`);
2075
+ // Save state and emit events for new session
2076
+ await this.savePersistedState();
2077
+ // Emit events using full issue (core Issue type)
2078
+ this.emit("session:started", fullIssue.id, fullIssue, repository.id);
2079
+ this.config.handlers?.onSessionStart?.(fullIssue.id, fullIssue, repository.id);
2080
+ }
2081
+ else {
2082
+ this.logger.debug(`Found existing session ${sessionId} for new user prompt`);
2083
+ // Post instant acknowledgment for existing session BEFORE any async work
2084
+ // Check if runner is currently running (streaming is Claude-specific, use isRunning for both)
2085
+ const isCurrentlyStreaming = session?.agentRunner?.isRunning() || false;
2086
+ await this.postInstantPromptedAcknowledgment(sessionId, repository.id, isCurrentlyStreaming);
2087
+ // Need to fetch full issue for routing context
2088
+ const issueTracker = this.issueTrackers.get(repository.id);
2089
+ if (issueTracker) {
2090
+ try {
2091
+ fullIssue = await issueTracker.fetchIssue(issue.id);
2092
+ }
2093
+ catch (error) {
2094
+ this.logger.warn(`Failed to fetch full issue for routing: ${issue.id}`, error);
2095
+ // Continue with degraded routing context
2096
+ }
2097
+ }
2098
+ }
2099
+ // Note: Routing and streaming check happens later in handlePromptWithStreamingCheck
2100
+ // after attachments are processed
2101
+ // Ensure session is not null after creation/retrieval
2102
+ if (!session) {
2103
+ throw new Error(`Failed to get or create session for agent activity session ${sessionId}`);
2104
+ }
2105
+ // Acknowledgment already posted above for both new and existing sessions
2106
+ // (before any async routing work to ensure instant user feedback)
2107
+ // Get issue tracker for this repository
2108
+ const issueTracker = this.issueTrackers.get(repository.id);
2109
+ if (!issueTracker) {
2110
+ this.logger.error("Unexpected: There was no IssueTrackerService for the repository with id", repository.id);
2111
+ return;
2112
+ }
2113
+ // Always set up attachments directory, even if no attachments in current comment
2114
+ const workspaceFolderName = basename(session.workspace.path);
2115
+ const attachmentsDir = join(this.sylasHome, workspaceFolderName, "attachments");
2116
+ // Ensure directory exists
2117
+ await mkdir(attachmentsDir, { recursive: true });
2118
+ let attachmentManifest = "";
2119
+ let commentAuthor;
2120
+ let commentTimestamp;
2121
+ if (!commentId) {
2122
+ this.logger.warn("No comment ID provided for attachment handling");
2123
+ }
2124
+ try {
2125
+ const comment = commentId
2126
+ ? await issueTracker.fetchComment(commentId)
2127
+ : null;
2128
+ // Extract comment metadata for multi-player context
2129
+ if (comment) {
2130
+ const user = await comment.user;
2131
+ commentAuthor =
2132
+ user?.displayName || user?.name || user?.email || "Unknown";
2133
+ commentTimestamp = comment.createdAt
2134
+ ? comment.createdAt.toISOString()
2135
+ : new Date().toISOString();
2136
+ }
2137
+ // Count existing attachments
2138
+ const existingFiles = await readdir(attachmentsDir).catch(() => []);
2139
+ const existingAttachmentCount = existingFiles.filter((file) => file.startsWith("attachment_") || file.startsWith("image_")).length;
2140
+ // Download new attachments from the comment
2141
+ const downloadResult = comment
2142
+ ? await this.downloadCommentAttachments(comment.body, attachmentsDir, repository.linearToken, existingAttachmentCount)
2143
+ : {
2144
+ totalNewAttachments: 0,
2145
+ newAttachmentMap: {},
2146
+ newImageMap: {},
2147
+ failedCount: 0,
2148
+ };
2149
+ if (downloadResult.totalNewAttachments > 0) {
2150
+ attachmentManifest = this.generateNewAttachmentManifest(downloadResult);
2151
+ }
2152
+ }
2153
+ catch (error) {
2154
+ this.logger.error("Failed to fetch comments for attachments:", error);
2155
+ }
2156
+ const promptBody = webhook.agentActivity.content.body;
2157
+ // Use centralized streaming check and routing logic
2158
+ try {
2159
+ await this.handlePromptWithStreamingCheck(session, repository, sessionId, agentSessionManager, promptBody, attachmentManifest, isNewSession, [], // No additional allowed directories for regular continuation
2160
+ `prompted webhook (${isNewSession ? "new" : "existing"} session)`, commentAuthor, commentTimestamp);
2161
+ }
2162
+ catch (error) {
2163
+ this.logger.error("Failed to handle prompted webhook:", error);
2164
+ }
2165
+ }
2166
+ /**
2167
+ * Handle user-prompted agent activity webhook
2168
+ * Implements three-branch architecture from packages/CLAUDE.md:
2169
+ * 1. Stop signal - terminate existing runner
2170
+ * 2. Repository selection response - initialize Claude runner for first time
2171
+ * 3. Normal prompted activity - continue existing session or create new one
2172
+ *
2173
+ * @param webhook The prompted webhook containing user's message
2174
+ */
2175
+ async handleUserPromptedAgentActivity(webhook) {
2176
+ const agentSessionId = webhook.agentSession.id;
2177
+ const activityBody = webhook.agentActivity?.content?.body || "";
2178
+ const signal = webhook.agentActivity?.signal;
2179
+ const isTextStopRequest = /^\s*stop(\s+session|\s+working)?[\s.!?]*$/i.test(activityBody);
2180
+ // Branch 1: Handle stop signal (checked FIRST, before any routing work)
2181
+ // Per CLAUDE.md: "an agentSession MUST already exist" for stop signals
2182
+ // IMPORTANT: Stop signals do NOT require repository lookup
2183
+ if (signal === "stop" || isTextStopRequest) {
2184
+ await this.handleStopSignal(webhook);
2185
+ return;
2186
+ }
2187
+ // Branch 2: Handle repository selection response
2188
+ // This is the first Claude runner initialization after user selects a repository.
2189
+ // The selection handler extracts the choice from the response (or uses fallback)
2190
+ // and caches the repository for future use.
2191
+ if (this.repositoryRouter.hasPendingSelection(agentSessionId)) {
2192
+ await this.handleRepositorySelectionResponse(webhook);
2193
+ return;
2194
+ }
2195
+ // Branch 2.5: Handle AskUserQuestion response
2196
+ // This handles responses to questions posed via the AskUserQuestion tool.
2197
+ // The response is passed to the pending promise resolver.
2198
+ if (this.askUserQuestionHandler.hasPendingQuestion(agentSessionId)) {
2199
+ await this.handleAskUserQuestionResponse(webhook);
2200
+ return;
2201
+ }
2202
+ // Branch 3: Handle normal prompted activity (existing session continuation)
2203
+ // Per CLAUDE.md: "an agentSession MUST exist and a repository MUST already
2204
+ // be associated with the Linear issue. The repository will be retrieved from
2205
+ // the issue-to-repository cache - no new routing logic is performed."
2206
+ const issueId = webhook.agentSession?.issue?.id;
2207
+ if (!issueId) {
2208
+ this.logger.error(`No issue ID found in prompted webhook ${agentSessionId}`);
2209
+ return;
2210
+ }
2211
+ const repository = this.getCachedRepository(issueId);
2212
+ if (!repository) {
2213
+ this.logger.warn(`No cached repository found for prompted webhook ${agentSessionId}`);
2214
+ return;
2215
+ }
2216
+ // User access control check for mid-session prompts
2217
+ const accessResult = this.checkUserAccess(webhook, repository);
2218
+ if (!accessResult.allowed) {
2219
+ this.logger.info(`User ${accessResult.userName} blocked from prompting: ${accessResult.reason}`);
2220
+ await this.handleBlockedUser(webhook, repository, accessResult.reason);
2221
+ return;
2222
+ }
2223
+ await this.handleNormalPromptedActivity(webhook, repository);
2224
+ }
2225
+ /**
2226
+ * Handle issue unassignment
2227
+ * @param issue Linear issue object from webhook data
2228
+ * @param repository Repository configuration
2229
+ */
2230
+ async handleIssueUnassigned(issue, repository) {
2231
+ const agentSessionManager = this.agentSessionManagers.get(repository.id);
2232
+ if (!agentSessionManager) {
2233
+ this.logger.info("No agentSessionManager for unassigned issue, so no sessions to stop");
2234
+ return;
2235
+ }
2236
+ const sessions = agentSessionManager.getSessionsByIssueId(issue.id);
2237
+ const activeThreadCount = sessions.length;
2238
+ // Stop all agent runners for this issue
2239
+ for (const session of sessions) {
2240
+ this.logger.info(`Stopping agent runner for issue ${issue.identifier}`);
2241
+ agentSessionManager.requestSessionStop(session.id);
2242
+ session.agentRunner?.stop();
2243
+ }
2244
+ // Post ONE farewell comment on the issue (not in any thread) if there were active sessions
2245
+ if (activeThreadCount > 0) {
2246
+ await this.postComment(issue.id, "I've been unassigned and am stopping work now.", repository.id);
2247
+ }
2248
+ // Emit events
2249
+ this.logger.info(`Stopped ${activeThreadCount} sessions for unassigned issue ${issue.identifier}`);
2250
+ }
2251
+ /**
2252
+ * Handle Claude messages
2253
+ */
2254
+ async handleClaudeMessage(sessionId, message, repositoryId) {
2255
+ const agentSessionManager = this.agentSessionManagers.get(repositoryId);
2256
+ // Integrate with AgentSessionManager to capture streaming messages
2257
+ if (agentSessionManager) {
2258
+ await agentSessionManager.handleClaudeMessage(sessionId, message);
2259
+ }
2260
+ }
2261
+ /**
2262
+ * Handle Claude session error
2263
+ * Silently ignores AbortError (user-initiated stop), logs other errors
2264
+ */
2265
+ async handleClaudeError(error) {
2266
+ // AbortError is expected when user stops Claude process, don't log it
2267
+ // Check by name since the SDK's AbortError class may not match our imported definition
2268
+ const isAbortError = error.name === "AbortError" || error.message.includes("aborted by user");
2269
+ // Also check for SIGTERM (exit code 143), which indicates graceful termination
2270
+ const isSigterm = error.message.includes("Claude Code process exited with code 143");
2271
+ if (isAbortError || isSigterm) {
2272
+ return;
2273
+ }
2274
+ this.logger.error("Unhandled claude error:", error);
2275
+ }
2276
+ /**
2277
+ * Fetch issue labels for a given issue
2278
+ */
2279
+ async fetchIssueLabels(issue) {
2280
+ return this.promptBuilder.fetchIssueLabels(issue);
2281
+ }
2282
+ /**
2283
+ * Resolve default model for a given runner from config with sensible built-in defaults.
2284
+ * Supports legacy config keys for backwards compatibility.
2285
+ */
2286
+ getDefaultModelForRunner(runnerType) {
2287
+ return this.runnerSelectionService.getDefaultModelForRunner(runnerType);
2288
+ }
2289
+ /**
2290
+ * Resolve default fallback model for a given runner from config with sensible built-in defaults.
2291
+ * Supports legacy Claude fallback key for backwards compatibility.
2292
+ */
2293
+ getDefaultFallbackModelForRunner(runnerType) {
2294
+ return this.runnerSelectionService.getDefaultFallbackModelForRunner(runnerType);
2295
+ }
2296
+ /**
2297
+ * Determine runner type and model using labels + issue description tags.
2298
+ *
2299
+ * Supported description tags:
2300
+ * - [agent=claude|gemini|codex|cursor]
2301
+ * - [model=<model-name>]
2302
+ *
2303
+ * Precedence:
2304
+ * - Description tags override labels.
2305
+ * - Agent selection and model selection are independent.
2306
+ * - If agent is not explicit, model can infer runner type.
2307
+ */
2308
+ determineRunnerSelection(labels, issueDescription) {
2309
+ return this.runnerSelectionService.determineRunnerSelection(labels, issueDescription);
2310
+ }
2311
+ /**
2312
+ * Determine system prompt based on issue labels and repository configuration
2313
+ */
2314
+ async determineSystemPromptFromLabels(labels, repository) {
2315
+ return this.promptBuilder.determineSystemPromptFromLabels(labels, repository);
2316
+ }
2317
+ /**
2318
+ * Build simplified prompt for label-based workflows
2319
+ * @param issue Full Linear issue
2320
+ * @param repository Repository configuration
2321
+ * @param attachmentManifest Optional attachment manifest
2322
+ * @param guidance Optional agent guidance rules from Linear
2323
+ * @returns Formatted prompt string
2324
+ */
2325
+ async buildLabelBasedPrompt(issue, repository, attachmentManifest = "", guidance) {
2326
+ return this.promptBuilder.buildLabelBasedPrompt(issue, repository, attachmentManifest, guidance);
2327
+ }
2328
+ /**
2329
+ * Build prompt for mention-triggered sessions
2330
+ * @param issue Full Linear issue object
2331
+ * @param repository Repository configuration
2332
+ * @param agentSession The agent session containing the mention
2333
+ * @param attachmentManifest Optional attachment manifest to append
2334
+ * @param guidance Optional agent guidance rules from Linear
2335
+ * @returns The constructed prompt and optional version tag
2336
+ */
2337
+ async buildMentionPrompt(issue, agentSession, attachmentManifest = "", guidance) {
2338
+ return this.promptBuilder.buildMentionPrompt(issue, agentSession, attachmentManifest, guidance);
2339
+ }
2340
+ /**
2341
+ * Convert full Linear SDK issue to CoreIssue interface for Session creation
2342
+ */
2343
+ convertLinearIssueToCore(issue) {
2344
+ return this.promptBuilder.convertLinearIssueToCore(issue);
2345
+ }
2346
+ /**
2347
+ * Build a prompt for Claude using the improved XML-style template
2348
+ * @param issue Full Linear issue
2349
+ * @param repository Repository configuration
2350
+ * @param newComment Optional new comment to focus on (for handleNewRootComment)
2351
+ * @param attachmentManifest Optional attachment manifest
2352
+ * @param guidance Optional agent guidance rules from Linear
2353
+ * @returns Formatted prompt string
2354
+ */
2355
+ async buildIssueContextPrompt(issue, repository, newComment, attachmentManifest = "", guidance) {
2356
+ return this.promptBuilder.buildIssueContextPrompt(issue, repository, newComment, attachmentManifest, guidance);
2357
+ }
2358
+ /**
2359
+ * Get connection status by repository ID
2360
+ */
2361
+ getConnectionStatus() {
2362
+ const status = new Map();
2363
+ // Single event transport is "connected" if it exists
2364
+ if (this.linearEventTransport) {
2365
+ // Mark all repositories as connected since they share the single transport
2366
+ for (const repoId of this.repositories.keys()) {
2367
+ status.set(repoId, true);
2368
+ }
2369
+ }
2370
+ return status;
2371
+ }
2372
+ /**
2373
+ * Get event transport (for testing purposes)
2374
+ * @internal
2375
+ */
2376
+ _getClientByToken(_token) {
2377
+ // Return the single shared event transport
2378
+ return this.linearEventTransport;
2379
+ }
2380
+ /**
2381
+ * Start OAuth flow using the shared application server
2382
+ */
2383
+ async startOAuthFlow(proxyUrl) {
2384
+ const oauthProxyUrl = proxyUrl || this.config.proxyUrl || DEFAULT_PROXY_URL;
2385
+ return this.sharedApplicationServer.startOAuthFlow(oauthProxyUrl);
2386
+ }
2387
+ /**
2388
+ * Get the server port
2389
+ */
2390
+ getServerPort() {
2391
+ return this.config.serverPort || this.config.webhookPort || 3456;
2392
+ }
2393
+ /**
2394
+ * Get the OAuth callback URL
2395
+ */
2396
+ getOAuthCallbackUrl() {
2397
+ return this.sharedApplicationServer.getOAuthCallbackUrl();
2398
+ }
2399
+ /**
2400
+ * Move issue to started state when assigned
2401
+ * @param issue Full Linear issue object from Linear SDK
2402
+ * @param repositoryId Repository ID for issue tracker lookup
2403
+ */
2404
+ async moveIssueToStartedState(issue, repositoryId) {
2405
+ try {
2406
+ const issueTracker = this.issueTrackers.get(repositoryId);
2407
+ if (!issueTracker) {
2408
+ this.logger.warn(`No issue tracker found for repository ${repositoryId}, skipping state update`);
2409
+ return;
2410
+ }
2411
+ // Check if issue is already in a started state
2412
+ const currentState = await issue.state;
2413
+ if (currentState?.type === "started") {
2414
+ this.logger.debug(`Issue ${issue.identifier} is already in started state (${currentState.name})`);
2415
+ return;
2416
+ }
2417
+ // Get team for the issue
2418
+ const team = await issue.team;
2419
+ if (!team) {
2420
+ this.logger.warn(`No team found for issue ${issue.identifier}, skipping state update`);
2421
+ return;
2422
+ }
2423
+ // Get available workflow states for the issue's team
2424
+ const teamStates = await issueTracker.fetchWorkflowStates(team.id);
2425
+ const states = teamStates;
2426
+ // Find all states with type "started" and pick the one with lowest position
2427
+ // This ensures we pick "In Progress" over "In Review" when both have type "started"
2428
+ // Linear uses standardized state types: triage, backlog, unstarted, started, completed, canceled
2429
+ const startedStates = states.nodes.filter((state) => state.type === "started");
2430
+ const startedState = startedStates.sort((a, b) => a.position - b.position)[0];
2431
+ if (!startedState) {
2432
+ throw new Error('Could not find a state with type "started" for this team');
2433
+ }
2434
+ // Update the issue state
2435
+ this.logger.debug(`Moving issue ${issue.identifier} to started state: ${startedState.name}`);
2436
+ if (!issue.id) {
2437
+ this.logger.warn(`Issue ${issue.identifier} has no ID, skipping state update`);
2438
+ return;
2439
+ }
2440
+ await issueTracker.updateIssue(issue.id, {
2441
+ stateId: startedState.id,
2442
+ });
2443
+ this.logger.debug(`✅ Successfully moved issue ${issue.identifier} to ${startedState.name} state`);
2444
+ }
2445
+ catch (error) {
2446
+ this.logger.error(`Failed to move issue ${issue.identifier} to started state:`, error);
2447
+ // Don't throw - we don't want to fail the entire assignment process due to state update failure
2448
+ }
2449
+ }
2450
+ /**
2451
+ * Post initial comment when assigned to issue
2452
+ */
2453
+ // private async postInitialComment(issueId: string, repositoryId: string): Promise<void> {
2454
+ // const body = "I'm getting started right away."
2455
+ // // Get the issue tracker for this repository
2456
+ // const issueTracker = this.issueTrackers.get(repositoryId)
2457
+ // if (!issueTracker) {
2458
+ // throw new Error(`No issue tracker found for repository ${repositoryId}`)
2459
+ // }
2460
+ // const commentData = {
2461
+ // body
2462
+ // }
2463
+ // await issueTracker.createComment(commentData)
2464
+ // }
2465
+ /**
2466
+ * Post a comment to Linear
2467
+ */
2468
+ async postComment(issueId, body, repositoryId, parentId) {
2469
+ return this.activityPoster.postComment(issueId, body, repositoryId, parentId);
2470
+ }
2471
+ /**
2472
+ * Format todos as Linear checklist markdown
2473
+ */
2474
+ // private formatTodosAsChecklist(todos: Array<{id: string, content: string, status: string, priority: string}>): string {
2475
+ // return todos.map(todo => {
2476
+ // const checkbox = todo.status === 'completed' ? '[x]' : '[ ]'
2477
+ // const statusEmoji = todo.status === 'in_progress' ? ' 🔄' : ''
2478
+ // return `- ${checkbox} ${todo.content}${statusEmoji}`
2479
+ // }).join('\n')
2480
+ // }
2481
+ /**
2482
+ * Download attachments from Linear issue
2483
+ * @param issue Linear issue object from webhook data
2484
+ * @param repository Repository configuration
2485
+ * @param workspacePath Path to workspace directory
2486
+ */
2487
+ async downloadIssueAttachments(issue, repository, workspacePath) {
2488
+ const issueTracker = this.issueTrackers.get(repository.id);
2489
+ return this.attachmentService.downloadIssueAttachments(issue, repository, workspacePath, issueTracker);
2490
+ }
2491
+ /**
2492
+ * Download attachments from a specific comment
2493
+ * @param commentBody The body text of the comment
2494
+ * @param attachmentsDir Directory where attachments should be saved
2495
+ * @param linearToken Linear API token
2496
+ * @param existingAttachmentCount Current number of attachments already downloaded
2497
+ */
2498
+ async downloadCommentAttachments(commentBody, attachmentsDir, linearToken, existingAttachmentCount) {
2499
+ return this.attachmentService.downloadCommentAttachments(commentBody, attachmentsDir, linearToken, existingAttachmentCount);
2500
+ }
2501
+ /**
2502
+ * Generate attachment manifest for new comment attachments
2503
+ */
2504
+ generateNewAttachmentManifest(result) {
2505
+ return this.attachmentService.generateNewAttachmentManifest(result);
2506
+ }
2507
+ async registerSylasToolsMcpEndpoint() {
2508
+ if (this.sylasToolsMcpRegistered) {
2509
+ return;
2510
+ }
2511
+ const fastify = this.sharedApplicationServer.getFastifyInstance();
2512
+ if (typeof fastify.register !== "function" ||
2513
+ typeof fastify.addHook !== "function") {
2514
+ console.warn("[EdgeWorker] Skipping sylas-tools MCP endpoint registration: Fastify instance does not support register/addHook");
2515
+ return;
2516
+ }
2517
+ fastify.addHook("onRequest", (request, _reply, done) => {
2518
+ const rawUrl = typeof request?.raw?.url === "string"
2519
+ ? request.raw.url
2520
+ : typeof request?.url === "string"
2521
+ ? request.url
2522
+ : "";
2523
+ const requestPath = rawUrl.split("?")[0];
2524
+ if (requestPath !== this.sylasToolsMcpEndpoint) {
2525
+ done();
2526
+ return;
2527
+ }
2528
+ if (!this.isSylasToolsMcpAuthorizationValid(request.headers?.authorization)) {
2529
+ _reply.code(401).send({
2530
+ error: "Unauthorized sylas-tools MCP request",
2531
+ });
2532
+ done();
2533
+ return;
2534
+ }
2535
+ const rawContextHeader = request.headers?.["x-sylas-mcp-context-id"];
2536
+ const contextId = Array.isArray(rawContextHeader)
2537
+ ? rawContextHeader[0]
2538
+ : rawContextHeader;
2539
+ this.sylasToolsMcpRequestContext.run({ contextId }, () => {
2540
+ done();
2541
+ });
2542
+ });
2543
+ this.sylasToolsMcpSessions.on("connected", (sessionId) => {
2544
+ console.log(`[EdgeWorker] sylas-tools MCP session connected: ${sessionId}`);
2545
+ });
2546
+ this.sylasToolsMcpSessions.on("terminated", (sessionId) => {
2547
+ console.log(`[EdgeWorker] sylas-tools MCP session terminated: ${sessionId}`);
2548
+ });
2549
+ this.sylasToolsMcpSessions.on("error", (error) => {
2550
+ console.error("[EdgeWorker] sylas-tools MCP session error:", error);
2551
+ });
2552
+ await fastify.register(streamableHttp, {
2553
+ stateful: true,
2554
+ mcpEndpoint: this.sylasToolsMcpEndpoint,
2555
+ sessions: this.sylasToolsMcpSessions,
2556
+ createServer: async () => {
2557
+ const contextId = this.sylasToolsMcpRequestContext.getStore()?.contextId;
2558
+ if (!contextId) {
2559
+ throw new Error("Missing x-sylas-mcp-context-id header for sylas-tools MCP request");
2560
+ }
2561
+ const context = this.sylasToolsMcpContexts.get(contextId);
2562
+ if (!context) {
2563
+ throw new Error(`Unknown sylas-tools MCP context '${contextId}'. Build MCP config before connecting.`);
2564
+ }
2565
+ const sdkServer = context.prebuiltServer ||
2566
+ createSylasToolsServer(context.linearToken, this.createSylasToolsOptions(context.parentSessionId));
2567
+ context.prebuiltServer = undefined;
2568
+ return sdkServer.server;
2569
+ },
2570
+ });
2571
+ this.sylasToolsMcpRegistered = true;
2572
+ console.log(`✅ Sylas tools MCP endpoint registered at ${this.sylasToolsMcpEndpoint}`);
2573
+ }
2574
+ createSylasToolsOptions(parentSessionId) {
2575
+ return {
2576
+ parentSessionId,
2577
+ onSessionCreated: (childSessionId, parentId) => {
2578
+ this.handleChildSessionMapping(childSessionId, parentId);
2579
+ },
2580
+ onFeedbackDelivery: async (childSessionId, message) => {
2581
+ return this.handleFeedbackDeliveryToChildSession(childSessionId, message);
2582
+ },
2583
+ };
2584
+ }
2585
+ handleChildSessionMapping(childSessionId, parentSessionId) {
2586
+ console.log(`[EdgeWorker] Agent session created: ${childSessionId}, mapping to parent ${parentSessionId}`);
2587
+ this.childToParentAgentSession.set(childSessionId, parentSessionId);
2588
+ console.log(`[EdgeWorker] Parent-child mapping updated: ${this.childToParentAgentSession.size} mappings`);
2589
+ }
2590
+ async handleFeedbackDeliveryToChildSession(childSessionId, message) {
2591
+ console.log(`[EdgeWorker] Processing feedback delivery to child session ${childSessionId}`);
2592
+ // Find the parent session ID for context
2593
+ const parentSessionId = this.childToParentAgentSession.get(childSessionId);
2594
+ // Find the repository containing the child session
2595
+ let childRepo;
2596
+ let childAgentSessionManager;
2597
+ for (const [repoId, manager] of this.agentSessionManagers) {
2598
+ if (manager.hasAgentRunner(childSessionId)) {
2599
+ childRepo = this.repositories.get(repoId);
2600
+ childAgentSessionManager = manager;
2601
+ break;
2602
+ }
2603
+ }
2604
+ if (!childRepo || !childAgentSessionManager) {
2605
+ console.error(`[EdgeWorker] Child session ${childSessionId} not found in any repository`);
2606
+ return false;
2607
+ }
2608
+ // Get the child session
2609
+ const childSession = childAgentSessionManager.getSession(childSessionId);
2610
+ if (!childSession) {
2611
+ console.error(`[EdgeWorker] Child session ${childSessionId} not found`);
2612
+ return false;
2613
+ }
2614
+ console.log(`[EdgeWorker] Found child session - Issue: ${childSession.issueId}`);
2615
+ // Get parent session info for better context in the thought
2616
+ let parentIssueId;
2617
+ if (parentSessionId) {
2618
+ for (const manager of this.agentSessionManagers.values()) {
2619
+ const parentSession = manager.getSession(parentSessionId);
2620
+ if (parentSession) {
2621
+ parentIssueId =
2622
+ parentSession.issue?.identifier || parentSession.issueId;
2623
+ break;
2624
+ }
2625
+ }
2626
+ }
2627
+ // Post thought to Linear showing feedback receipt
2628
+ const issueTracker = this.issueTrackers.get(childRepo.id);
2629
+ if (issueTracker) {
2630
+ const feedbackThought = parentIssueId
2631
+ ? `Received feedback from orchestrator (${parentIssueId}):\n\n---\n\n${message}\n\n---`
2632
+ : `Received feedback from orchestrator:\n\n---\n\n${message}\n\n---`;
2633
+ try {
2634
+ const result = await issueTracker.createAgentActivity({
2635
+ agentSessionId: childSessionId,
2636
+ content: {
2637
+ type: "thought",
2638
+ body: feedbackThought,
2639
+ },
2640
+ });
2641
+ if (result.success) {
2642
+ console.log(`[EdgeWorker] Posted feedback receipt thought for child session ${childSessionId}`);
2643
+ }
2644
+ else {
2645
+ console.error(`[EdgeWorker] Failed to post feedback receipt thought:`, result);
2646
+ }
2647
+ }
2648
+ catch (error) {
2649
+ console.error(`[EdgeWorker] Error posting feedback receipt thought:`, error);
2650
+ }
2651
+ }
2652
+ const feedbackPrompt = `## Received feedback from orchestrator\n\n---\n\n${message}\n\n---`;
2653
+ console.log(`[EdgeWorker] Handling feedback delivery to child session ${childSessionId}`);
2654
+ this.handlePromptWithStreamingCheck(childSession, childRepo, childSessionId, childAgentSessionManager, feedbackPrompt, "", false, [], "give feedback to child")
2655
+ .then(() => {
2656
+ console.log(`[EdgeWorker] Child session ${childSessionId} completed processing feedback`);
2657
+ })
2658
+ .catch((error) => {
2659
+ console.error(`[EdgeWorker] Failed to process feedback in child session:`, error);
2660
+ });
2661
+ console.log(`[EdgeWorker] Feedback delivered successfully to child session ${childSessionId}`);
2662
+ return true;
2663
+ }
2664
+ buildSylasToolsMcpContextId(repository, parentSessionId) {
2665
+ if (parentSessionId) {
2666
+ return `${repository.id}:${parentSessionId}`;
2667
+ }
2668
+ return `${repository.id}:anon:${Date.now()}:${Math.random().toString(36).slice(2, 8)}`;
2669
+ }
2670
+ getSylasToolsMcpUrl() {
2671
+ const server = this.sharedApplicationServer;
2672
+ const port = typeof server.getPort === "function"
2673
+ ? server.getPort()
2674
+ : this.config.serverPort || this.config.webhookPort || 3456;
2675
+ return `http://127.0.0.1:${port}${this.sylasToolsMcpEndpoint}`;
2676
+ }
2677
+ pruneSylasToolsMcpContexts(maxEntries = 500) {
2678
+ if (this.sylasToolsMcpContexts.size <= maxEntries) {
2679
+ return;
2680
+ }
2681
+ const entriesByAge = Array.from(this.sylasToolsMcpContexts.entries()).sort((a, b) => a[1].createdAt - b[1].createdAt);
2682
+ const pruneCount = this.sylasToolsMcpContexts.size - maxEntries;
2683
+ for (let i = 0; i < pruneCount; i++) {
2684
+ const entry = entriesByAge[i];
2685
+ if (!entry) {
2686
+ break;
2687
+ }
2688
+ const [contextId] = entry;
2689
+ this.sylasToolsMcpContexts.delete(contextId);
2690
+ }
2691
+ }
2692
+ /**
2693
+ * Build MCP configuration with automatic Linear server injection and sylas-tools over Fastify MCP.
2694
+ */
2695
+ buildMcpConfig(repository, parentSessionId) {
2696
+ const contextId = this.buildSylasToolsMcpContextId(repository, parentSessionId);
2697
+ // Prebuild one SDK server for this context so callback wiring remains deterministic.
2698
+ // If the client reconnects and needs another server, the endpoint creates a fresh one.
2699
+ const prebuiltServer = createSylasToolsServer(repository.linearToken, this.createSylasToolsOptions(parentSessionId));
2700
+ this.sylasToolsMcpContexts.set(contextId, {
2701
+ contextId,
2702
+ linearToken: repository.linearToken,
2703
+ parentSessionId,
2704
+ prebuiltServer,
2705
+ createdAt: Date.now(),
2706
+ });
2707
+ this.pruneSylasToolsMcpContexts();
2708
+ const sylasToolsAuthorizationHeader = this.getSylasToolsMcpAuthorizationHeaderValue();
2709
+ // Always inject the Linear MCP servers with the repository's token
2710
+ // https://linear.app/docs/mcp
2711
+ const mcpConfig = {
2712
+ linear: {
2713
+ type: "http",
2714
+ url: "https://mcp.linear.app/mcp",
2715
+ headers: {
2716
+ Authorization: `Bearer ${repository.linearToken}`,
2717
+ },
2718
+ },
2719
+ "sylas-tools": {
2720
+ type: "http",
2721
+ url: this.getSylasToolsMcpUrl(),
2722
+ headers: {
2723
+ "x-sylas-mcp-context-id": contextId,
2724
+ ...(sylasToolsAuthorizationHeader
2725
+ ? {
2726
+ Authorization: sylasToolsAuthorizationHeader,
2727
+ }
2728
+ : {}),
2729
+ },
2730
+ },
2731
+ };
2732
+ // Add OpenAI-based MCP servers if API key is configured
2733
+ if (repository.openaiApiKey) {
2734
+ // Sora video generation tools
2735
+ mcpConfig["sora-tools"] = createSoraToolsServer({
2736
+ apiKey: repository.openaiApiKey,
2737
+ outputDirectory: repository.openaiOutputDirectory,
2738
+ });
2739
+ // GPT Image generation tools
2740
+ mcpConfig["image-tools"] = createImageToolsServer({
2741
+ apiKey: repository.openaiApiKey,
2742
+ outputDirectory: repository.openaiOutputDirectory,
2743
+ });
2744
+ this.logger.debug(`Configured OpenAI MCP servers (Sora + GPT Image) for repository: ${repository.name}`);
2745
+ }
2746
+ return mcpConfig;
2747
+ }
2748
+ getSylasToolsMcpAuthorizationHeaderValue() {
2749
+ const apiKey = process.env.SYLAS_API_KEY?.trim();
2750
+ if (!apiKey) {
2751
+ return undefined;
2752
+ }
2753
+ return `Bearer ${apiKey}`;
2754
+ }
2755
+ isSylasToolsMcpAuthorizationValid(rawAuthorizationHeader) {
2756
+ const expectedHeader = this.getSylasToolsMcpAuthorizationHeaderValue();
2757
+ if (!expectedHeader) {
2758
+ return true;
2759
+ }
2760
+ const authorizationHeader = Array.isArray(rawAuthorizationHeader)
2761
+ ? rawAuthorizationHeader[0]
2762
+ : rawAuthorizationHeader;
2763
+ return authorizationHeader === expectedHeader;
2764
+ }
2765
+ /**
2766
+ * Build the complete prompt for a session - shows full prompt assembly in one place
2767
+ *
2768
+ * New session prompt structure:
2769
+ * 1. Issue context (from buildIssueContextPrompt)
2770
+ * 2. Initial subroutine prompt (if procedure initialized)
2771
+ * 3. User comment
2772
+ *
2773
+ * Existing session prompt structure:
2774
+ * 1. User comment
2775
+ * 2. Attachment manifest (if present)
2776
+ */
2777
+ async buildSessionPrompt(isNewSession, session, fullIssue, repository, promptBody, attachmentManifest, commentAuthor, commentTimestamp) {
2778
+ // Fetch labels for system prompt determination
2779
+ const labels = await this.fetchIssueLabels(fullIssue);
2780
+ // Create input for unified prompt assembly
2781
+ const input = {
2782
+ session,
2783
+ fullIssue,
2784
+ repository,
2785
+ userComment: promptBody,
2786
+ commentAuthor,
2787
+ commentTimestamp,
2788
+ attachmentManifest,
2789
+ isNewSession,
2790
+ isStreaming: false, // This path is only for non-streaming prompts
2791
+ labels,
2792
+ };
2793
+ // Use unified prompt assembly
2794
+ const assembly = await this.assemblePrompt(input);
2795
+ // Log metadata for debugging
2796
+ this.logger.debug(`Built prompt - components: ${assembly.metadata.components.join(", ")}, type: ${assembly.metadata.promptType}`);
2797
+ return assembly.userPrompt;
2798
+ }
2799
+ /**
2800
+ * Assemble a complete prompt - unified entry point for all prompt building
2801
+ * This method contains all prompt assembly logic in one place
2802
+ */
2803
+ async assemblePrompt(input) {
2804
+ // If actively streaming, just pass through the comment
2805
+ if (input.isStreaming) {
2806
+ return this.buildStreamingPrompt(input);
2807
+ }
2808
+ // If new session, build full prompt with all components
2809
+ if (input.isNewSession) {
2810
+ return this.buildNewSessionPrompt(input);
2811
+ }
2812
+ // Existing session continuation - just user comment + attachments
2813
+ return this.buildContinuationPrompt(input);
2814
+ }
2815
+ /**
2816
+ * Build prompt for actively streaming session - pass through user comment as-is
2817
+ */
2818
+ buildStreamingPrompt(input) {
2819
+ const components = ["user-comment"];
2820
+ if (input.attachmentManifest) {
2821
+ components.push("attachment-manifest");
2822
+ }
2823
+ const parts = [input.userComment];
2824
+ if (input.attachmentManifest) {
2825
+ parts.push(input.attachmentManifest);
2826
+ }
2827
+ return {
2828
+ systemPrompt: undefined,
2829
+ userPrompt: parts.join("\n\n"),
2830
+ metadata: {
2831
+ components,
2832
+ promptType: "continuation",
2833
+ isNewSession: false,
2834
+ isStreaming: true,
2835
+ },
2836
+ };
2837
+ }
2838
+ /**
2839
+ * Build prompt for new session - includes issue context, subroutine prompt, and user comment
2840
+ */
2841
+ async buildNewSessionPrompt(input) {
2842
+ const components = [];
2843
+ const parts = [];
2844
+ // 1. Determine system prompt from labels
2845
+ // Only for delegation (not mentions) or when /label-based-prompt is requested
2846
+ let labelBasedSystemPrompt;
2847
+ if (!input.isMentionTriggered || input.isLabelBasedPromptRequested) {
2848
+ labelBasedSystemPrompt = await this.determineSystemPromptForAssembly(input.labels || [], input.repository);
2849
+ }
2850
+ // 2. Determine system prompt based on prompt type
2851
+ // Label-based: Use only the label-based system prompt
2852
+ // Fallback: Use scenarios system prompt (shared instructions)
2853
+ let systemPrompt;
2854
+ if (labelBasedSystemPrompt) {
2855
+ // Use label-based system prompt as-is (no shared instructions)
2856
+ systemPrompt = labelBasedSystemPrompt;
2857
+ }
2858
+ else {
2859
+ // Use scenarios system prompt for fallback cases
2860
+ const sharedInstructions = await this.loadSharedInstructions();
2861
+ systemPrompt = sharedInstructions;
2862
+ }
2863
+ // 3. Build issue context using appropriate builder
2864
+ // Use label-based prompt ONLY if we have a label-based system prompt
2865
+ const promptType = this.determinePromptType(input, !!labelBasedSystemPrompt);
2866
+ const issueContext = await this.buildIssueContextForPromptAssembly(input.fullIssue, input.repository, promptType, input.attachmentManifest, input.guidance, input.agentSession);
2867
+ parts.push(issueContext.prompt);
2868
+ components.push("issue-context");
2869
+ // 4. Load and append initial subroutine prompt
2870
+ const currentSubroutine = this.procedureAnalyzer.getCurrentSubroutine(input.session);
2871
+ let subroutineName;
2872
+ if (currentSubroutine) {
2873
+ const subroutinePrompt = await this.loadSubroutinePrompt(currentSubroutine, this.config.linearWorkspaceSlug);
2874
+ if (subroutinePrompt) {
2875
+ parts.push(subroutinePrompt);
2876
+ components.push("subroutine-prompt");
2877
+ subroutineName = currentSubroutine.name;
2878
+ }
2879
+ }
2880
+ // 5. Add user comment (if present)
2881
+ // Skip for mention-triggered prompts since the comment is already in the mention block
2882
+ if (input.userComment.trim() && !input.isMentionTriggered) {
2883
+ // If we have author/timestamp metadata, include it for multi-player context
2884
+ if (input.commentAuthor || input.commentTimestamp) {
2885
+ const author = input.commentAuthor || "Unknown";
2886
+ const timestamp = input.commentTimestamp || new Date().toISOString();
2887
+ parts.push(`<user_comment>
2888
+ <author>${author}</author>
2889
+ <timestamp>${timestamp}</timestamp>
2890
+ <content>
2891
+ ${input.userComment}
2892
+ </content>
2893
+ </user_comment>`);
2894
+ }
2895
+ else {
2896
+ // Legacy format without metadata
2897
+ parts.push(`<user_comment>\n${input.userComment}\n</user_comment>`);
2898
+ }
2899
+ components.push("user-comment");
2900
+ }
2901
+ // 6. Add guidance rules (if present)
2902
+ if (input.guidance && input.guidance.length > 0) {
2903
+ components.push("guidance-rules");
2904
+ }
2905
+ return {
2906
+ systemPrompt,
2907
+ userPrompt: parts.join("\n\n"),
2908
+ metadata: {
2909
+ components,
2910
+ subroutineName,
2911
+ promptType,
2912
+ isNewSession: true,
2913
+ isStreaming: false,
2914
+ },
2915
+ };
2916
+ }
2917
+ /**
2918
+ * Build prompt for existing session continuation - user comment and attachments only
2919
+ */
2920
+ buildContinuationPrompt(input) {
2921
+ const components = ["user-comment"];
2922
+ if (input.attachmentManifest) {
2923
+ components.push("attachment-manifest");
2924
+ }
2925
+ // Wrap comment in XML with author and timestamp for multi-player context
2926
+ const author = input.commentAuthor || "Unknown";
2927
+ const timestamp = input.commentTimestamp || new Date().toISOString();
2928
+ const commentXml = `<new_comment>
2929
+ <author>${author}</author>
2930
+ <timestamp>${timestamp}</timestamp>
2931
+ <content>
2932
+ ${input.userComment}
2933
+ </content>
2934
+ </new_comment>`;
2935
+ const parts = [commentXml];
2936
+ if (input.attachmentManifest) {
2937
+ parts.push(input.attachmentManifest);
2938
+ }
2939
+ return {
2940
+ systemPrompt: undefined,
2941
+ userPrompt: parts.join("\n\n"),
2942
+ metadata: {
2943
+ components,
2944
+ promptType: "continuation",
2945
+ isNewSession: false,
2946
+ isStreaming: false,
2947
+ },
2948
+ };
2949
+ }
2950
+ /**
2951
+ * Determine the prompt type based on input flags and system prompt availability
2952
+ */
2953
+ determinePromptType(input, hasSystemPrompt) {
2954
+ if (input.isMentionTriggered && input.isLabelBasedPromptRequested) {
2955
+ return "label-based-prompt-command";
2956
+ }
2957
+ if (input.isMentionTriggered) {
2958
+ return "mention";
2959
+ }
2960
+ if (hasSystemPrompt) {
2961
+ return "label-based";
2962
+ }
2963
+ return "fallback";
2964
+ }
2965
+ /**
2966
+ * Load a subroutine prompt file
2967
+ * Extracted helper to make prompt assembly more readable
2968
+ */
2969
+ async loadSubroutinePrompt(subroutine, workspaceSlug) {
2970
+ return this.promptBuilder.loadSubroutinePrompt(subroutine, workspaceSlug);
2971
+ }
2972
+ /**
2973
+ * Load shared instructions that get appended to all system prompts
2974
+ */
2975
+ async loadSharedInstructions() {
2976
+ return this.promptBuilder.loadSharedInstructions();
2977
+ }
2978
+ /**
2979
+ * Adapter method for prompt assembly - extracts just the prompt string
2980
+ */
2981
+ async determineSystemPromptForAssembly(labels, repository) {
2982
+ const result = await this.determineSystemPromptFromLabels(labels, repository);
2983
+ return result?.prompt;
2984
+ }
2985
+ /**
2986
+ * Adapter method for prompt assembly - routes to appropriate issue context builder
2987
+ */
2988
+ async buildIssueContextForPromptAssembly(issue, repository, promptType, attachmentManifest, guidance, agentSession) {
2989
+ // Delegate to appropriate builder based on promptType
2990
+ if (promptType === "mention") {
2991
+ if (!agentSession) {
2992
+ throw new Error("agentSession is required for mention-triggered prompts");
2993
+ }
2994
+ return this.buildMentionPrompt(issue, agentSession, attachmentManifest, guidance);
2995
+ }
2996
+ if (promptType === "label-based" ||
2997
+ promptType === "label-based-prompt-command") {
2998
+ return this.buildLabelBasedPrompt(issue, repository, attachmentManifest, guidance);
2999
+ }
3000
+ // Fallback to standard issue context
3001
+ return this.buildIssueContextPrompt(issue, repository, undefined, // No new comment for initial prompt assembly
3002
+ attachmentManifest, guidance);
3003
+ }
3004
+ /**
3005
+ * Build agent runner configuration with common settings.
3006
+ * Also determines which runner type to use based on labels.
3007
+ * @returns Object containing the runner config and runner type to use
3008
+ */
3009
+ buildAgentRunnerConfig(session, repository, sessionId, systemPrompt, allowedTools, allowedDirectories, disallowedTools, resumeSessionId, labels, issueDescription, maxTurns, singleTurn, disallowAllTools) {
3010
+ const log = this.logger.withContext({
3011
+ sessionId,
3012
+ platform: session.issueContext?.trackerId,
3013
+ issueIdentifier: session.issueContext?.issueIdentifier,
3014
+ });
3015
+ // Configure PostToolUse hooks for screenshot tools to guide Claude to use linear_upload_file
3016
+ // This ensures screenshots can be viewed in Linear comments instead of remaining as local files
3017
+ const hooks = {
3018
+ PostToolUse: [
3019
+ {
3020
+ matcher: "playwright_screenshot",
3021
+ hooks: [
3022
+ async (input, _toolUseID, { signal: _signal }) => {
3023
+ const postToolUseInput = input;
3024
+ log.debug(`Tool ${postToolUseInput.tool_name} completed with response:`, postToolUseInput.tool_response);
3025
+ const response = postToolUseInput.tool_response;
3026
+ const filePath = response?.path || "the screenshot file";
3027
+ return {
3028
+ continue: true,
3029
+ additionalContext: `Screenshot taken successfully. To share this screenshot in Linear comments, use the linear_upload_file tool to upload ${filePath}. This will return an asset URL that can be embedded in markdown. You can also use the Read tool to view the screenshot file to analyze the visual content.`,
3030
+ };
3031
+ },
3032
+ ],
3033
+ },
3034
+ {
3035
+ matcher: "mcp__claude-in-chrome__computer",
3036
+ hooks: [
3037
+ async (input, _toolUseID, { signal: _signal }) => {
3038
+ const postToolUseInput = input;
3039
+ const response = postToolUseInput.tool_response;
3040
+ // Only provide upload guidance for screenshot actions
3041
+ if (response?.action === "screenshot") {
3042
+ const filePath = response?.path || "the screenshot file";
3043
+ return {
3044
+ continue: true,
3045
+ additionalContext: `Screenshot captured. To share this screenshot in Linear comments, use the linear_upload_file tool to upload ${filePath}. This will return an asset URL that can be embedded in markdown.`,
3046
+ };
3047
+ }
3048
+ return { continue: true };
3049
+ },
3050
+ ],
3051
+ },
3052
+ {
3053
+ matcher: "mcp__claude-in-chrome__gif_creator",
3054
+ hooks: [
3055
+ async (input, _toolUseID, { signal: _signal }) => {
3056
+ const postToolUseInput = input;
3057
+ const response = postToolUseInput.tool_response;
3058
+ // Only provide upload guidance for export actions
3059
+ if (response?.action === "export") {
3060
+ const filePath = response?.path || "the exported GIF";
3061
+ return {
3062
+ continue: true,
3063
+ additionalContext: `GIF exported successfully. To share this GIF in Linear comments, use the linear_upload_file tool to upload ${filePath}. This will return an asset URL that can be embedded in markdown.`,
3064
+ };
3065
+ }
3066
+ return { continue: true };
3067
+ },
3068
+ ],
3069
+ },
3070
+ {
3071
+ matcher: "mcp__chrome-devtools__take_screenshot",
3072
+ hooks: [
3073
+ async (input, _toolUseID, { signal: _signal }) => {
3074
+ const postToolUseInput = input;
3075
+ // Extract file path from input (the tool saves to filePath parameter)
3076
+ const toolInput = postToolUseInput.tool_input;
3077
+ const filePath = toolInput?.filePath || "the screenshot file";
3078
+ return {
3079
+ continue: true,
3080
+ additionalContext: `Screenshot saved. To share this screenshot in Linear comments, use the linear_upload_file tool to upload ${filePath}. This will return an asset URL that can be embedded in markdown.`,
3081
+ };
3082
+ },
3083
+ ],
3084
+ },
3085
+ ],
3086
+ };
3087
+ // Determine runner type and model override from selectors
3088
+ const runnerSelection = this.determineRunnerSelection(labels || [], issueDescription);
3089
+ let runnerType = runnerSelection.runnerType;
3090
+ let modelOverride = runnerSelection.modelOverride;
3091
+ let fallbackModelOverride = runnerSelection.fallbackModelOverride;
3092
+ // If the labels have changed, and we are resuming a session. Use the existing runner for the session.
3093
+ if (session.claudeSessionId && runnerType !== "claude") {
3094
+ runnerType = "claude";
3095
+ modelOverride = this.getDefaultModelForRunner("claude");
3096
+ fallbackModelOverride = this.getDefaultFallbackModelForRunner("claude");
3097
+ }
3098
+ else if (session.geminiSessionId && runnerType !== "gemini") {
3099
+ runnerType = "gemini";
3100
+ modelOverride = this.getDefaultModelForRunner("gemini");
3101
+ fallbackModelOverride = this.getDefaultFallbackModelForRunner("gemini");
3102
+ }
3103
+ else if (session.codexSessionId && runnerType !== "codex") {
3104
+ runnerType = "codex";
3105
+ modelOverride = this.getDefaultModelForRunner("codex");
3106
+ fallbackModelOverride = this.getDefaultFallbackModelForRunner("codex");
3107
+ }
3108
+ else if (session.cursorSessionId && runnerType !== "cursor") {
3109
+ runnerType = "cursor";
3110
+ modelOverride = this.getDefaultModelForRunner("cursor");
3111
+ fallbackModelOverride = this.getDefaultFallbackModelForRunner("cursor");
3112
+ }
3113
+ else if (session.openCodeSessionId && runnerType !== "opencode") {
3114
+ runnerType = "opencode";
3115
+ modelOverride = this.getDefaultModelForRunner("opencode");
3116
+ fallbackModelOverride = this.getDefaultFallbackModelForRunner("opencode");
3117
+ }
3118
+ // Log model override if found
3119
+ if (modelOverride) {
3120
+ log.debug(`Model override via selector: ${modelOverride}`);
3121
+ }
3122
+ // Convert singleTurn flag to effective maxTurns value
3123
+ const effectiveMaxTurns = singleTurn ? 1 : maxTurns;
3124
+ // Determine final model from selectors, repository override, then runner-specific defaults
3125
+ const finalModel = modelOverride ||
3126
+ repository.model ||
3127
+ this.getDefaultModelForRunner(runnerType);
3128
+ // When disallowAllTools is true, don't provide any MCP servers to ensure
3129
+ // the agent cannot use any tools (including MCP-provided tools like Linear create_comment)
3130
+ const mcpConfig = disallowAllTools
3131
+ ? undefined
3132
+ : this.buildMcpConfig(repository, sessionId);
3133
+ const mcpConfigPath = disallowAllTools
3134
+ ? undefined
3135
+ : repository.mcpConfigPath;
3136
+ if (disallowAllTools) {
3137
+ log.info(`MCP tools disabled for session ${sessionId} (disallowAllTools=true)`);
3138
+ }
3139
+ const config = {
3140
+ workingDirectory: session.workspace.path,
3141
+ allowedTools,
3142
+ disallowedTools,
3143
+ allowedDirectories,
3144
+ workspaceName: session.issue?.identifier || session.issueId,
3145
+ sylasHome: this.sylasHome,
3146
+ mcpConfigPath,
3147
+ mcpConfig,
3148
+ appendSystemPrompt: systemPrompt || "",
3149
+ // When disallowAllTools is true, remove all built-in tools from model context
3150
+ // so Claude cannot see or attempt tool use (distinct from allowedTools which only controls permissions)
3151
+ ...(disallowAllTools && { tools: [] }),
3152
+ // Priority order: label override > repository config > global default
3153
+ model: finalModel,
3154
+ fallbackModel: fallbackModelOverride ||
3155
+ repository.fallbackModel ||
3156
+ this.getDefaultFallbackModelForRunner(runnerType),
3157
+ logger: log,
3158
+ hooks,
3159
+ // Enable Chrome integration for Claude runner (disabled for other runners)
3160
+ ...(runnerType === "claude" && { extraArgs: { chrome: null } }),
3161
+ // AskUserQuestion callback - only for Claude runner
3162
+ ...(runnerType === "claude" && {
3163
+ onAskUserQuestion: this.createAskUserQuestionCallback(sessionId, repository.linearWorkspaceId),
3164
+ }),
3165
+ onMessage: (message) => {
3166
+ this.handleClaudeMessage(sessionId, message, repository.id);
3167
+ },
3168
+ onError: (error) => this.handleClaudeError(error),
3169
+ };
3170
+ // Cursor runner-specific wiring for offline/headless harness
3171
+ // We pass these as loose fields to avoid widening core runner types.
3172
+ if (runnerType === "cursor") {
3173
+ const approvalPolicy = (process.env.SYLAS_APPROVAL_POLICY || "never");
3174
+ // Cursor CLI binary path (defaults to relying on PATH)
3175
+ config.cursorPath =
3176
+ process.env.CURSOR_AGENT_PATH || process.env.CURSOR_PATH || undefined;
3177
+ // API key for headless auth (optional; CLI may also read CURSOR_API_KEY directly)
3178
+ config.cursorApiKey = process.env.CURSOR_API_KEY || undefined;
3179
+ // Keep headless runs non-interactive by default in F1/CLI environments
3180
+ config.askForApproval = approvalPolicy;
3181
+ config.approveMcps = true;
3182
+ // Default to enabled sandbox for tool execution isolation; set SYLAS_SANDBOX=disabled to disable
3183
+ config.sandbox = (process.env.SYLAS_SANDBOX || "enabled");
3184
+ // Expected cursor-agent version for pre-run validation; mismatch posts error to Linear
3185
+ config.cursorAgentVersion =
3186
+ process.env.SYLAS_CURSOR_AGENT_VERSION || undefined;
3187
+ }
3188
+ // OpenCode runner-specific wiring
3189
+ if (runnerType === "opencode") {
3190
+ config.autoApprove = true;
3191
+ config.opencodeAgent =
3192
+ process.env.SYLAS_OPENCODE_AGENT || undefined;
3193
+ config.opencodeReportedModel =
3194
+ process.env.SYLAS_OPENCODE_REPORTED_MODEL || undefined;
3195
+ config.opencodePlugins = (process.env.SYLAS_OPENCODE_PLUGINS || "")
3196
+ .split(",")
3197
+ .map((value) => value.trim())
3198
+ .filter(Boolean);
3199
+ }
3200
+ if (resumeSessionId) {
3201
+ config.resumeSessionId = resumeSessionId;
3202
+ }
3203
+ if (effectiveMaxTurns !== undefined) {
3204
+ config.maxTurns = effectiveMaxTurns;
3205
+ if (singleTurn) {
3206
+ log.debug(`Applied singleTurn maxTurns=1`);
3207
+ }
3208
+ }
3209
+ return { config, runnerType };
3210
+ }
3211
+ /**
3212
+ * Create an onAskUserQuestion callback for the ClaudeRunner.
3213
+ * This callback delegates to the AskUserQuestionHandler which posts
3214
+ * elicitations to Linear and waits for user responses.
3215
+ *
3216
+ * @param linearAgentSessionId - Linear agent session ID for tracking
3217
+ * @param organizationId - Linear organization/workspace ID
3218
+ */
3219
+ createAskUserQuestionCallback(linearAgentSessionId, organizationId) {
3220
+ return async (input, _sessionId, signal) => {
3221
+ // Note: We use linearAgentSessionId (from closure) instead of the passed sessionId
3222
+ // because the passed sessionId is the Claude session ID, not the Linear agent session ID
3223
+ return this.askUserQuestionHandler.handleAskUserQuestion(input, linearAgentSessionId, organizationId, signal);
3224
+ };
3225
+ }
3226
+ /**
3227
+ * Build disallowed tools list following the same hierarchy as allowed tools
3228
+ */
3229
+ buildDisallowedTools(repository, promptType) {
3230
+ return this.runnerSelectionService.buildDisallowedTools(repository, promptType);
3231
+ }
3232
+ /**
3233
+ * Merge subroutine-level disallowedTools with base disallowedTools
3234
+ * @param session Current agent session
3235
+ * @param baseDisallowedTools Base disallowed tools from repository/global config
3236
+ * @param logContext Context string for logging (e.g., "EdgeWorker", "resumeClaudeSession")
3237
+ * @returns Merged disallowed tools list
3238
+ */
3239
+ mergeSubroutineDisallowedTools(session, baseDisallowedTools, logContext) {
3240
+ return this.runnerSelectionService.mergeSubroutineDisallowedTools(session, baseDisallowedTools, logContext, this.procedureAnalyzer);
3241
+ }
3242
+ /**
3243
+ * Build allowed tools list with Linear MCP tools automatically included
3244
+ */
3245
+ buildAllowedTools(repository, promptType) {
3246
+ return this.runnerSelectionService.buildAllowedTools(repository, promptType);
3247
+ }
3248
+ /**
3249
+ * Get Agent Sessions for an issue
3250
+ */
3251
+ getAgentSessionsForIssue(issueId, repositoryId) {
3252
+ const agentSessionManager = this.agentSessionManagers.get(repositoryId);
3253
+ if (!agentSessionManager) {
3254
+ return [];
3255
+ }
3256
+ return agentSessionManager.getSessionsByIssueId(issueId);
3257
+ }
3258
+ // ========================================================================
3259
+ // User Access Control
3260
+ // ========================================================================
3261
+ /**
3262
+ * Check if the user who triggered the webhook is allowed to interact.
3263
+ * @param webhook The webhook containing user information
3264
+ * @param repository The repository configuration
3265
+ * @returns Access check result with allowed status and user name
3266
+ */
3267
+ checkUserAccess(webhook, repository) {
3268
+ const creator = webhook.agentSession.creator;
3269
+ const userId = creator?.id;
3270
+ const userEmail = creator?.email;
3271
+ const userName = creator?.name || userId || "Unknown";
3272
+ const result = this.userAccessControl.checkAccess(userId, userEmail, repository.id);
3273
+ if (!result.allowed) {
3274
+ return { allowed: false, reason: result.reason, userName };
3275
+ }
3276
+ return { allowed: true };
3277
+ }
3278
+ /**
3279
+ * Handle blocked user according to configured behavior.
3280
+ * Posts a response activity to end the session.
3281
+ * @param webhook The webhook that triggered the blocked access
3282
+ * @param repository The repository configuration
3283
+ * @param _reason The reason for blocking (for logging)
3284
+ */
3285
+ async handleBlockedUser(webhook, repository, _reason) {
3286
+ const issueTracker = this.issueTrackers.get(repository.id);
3287
+ const agentSessionId = webhook.agentSession.id;
3288
+ const behavior = this.userAccessControl.getBlockBehavior(repository.id);
3289
+ if (!issueTracker) {
3290
+ return;
3291
+ }
3292
+ if (behavior === "comment") {
3293
+ // Get user info for templating
3294
+ const creator = webhook.agentSession.creator;
3295
+ const userName = creator?.name || "User";
3296
+ const userId = creator?.id || "";
3297
+ // Get the message template and replace variables
3298
+ // Supported variables:
3299
+ // - {{userName}} - The user's display name
3300
+ // - {{userId}} - The user's Linear ID
3301
+ let message = this.userAccessControl.getBlockMessage(repository.id);
3302
+ message = message
3303
+ .replace(/\{\{userName\}\}/g, userName)
3304
+ .replace(/\{\{userId\}\}/g, userId);
3305
+ await this.postActivityDirect(issueTracker, {
3306
+ agentSessionId,
3307
+ content: { type: "response", body: message },
3308
+ }, "blocked user message");
3309
+ }
3310
+ // For "silent" behavior, we don't post any activity.
3311
+ // The session will remain in "Working" state until manually stopped or timed out.
3312
+ }
3313
+ /**
3314
+ * Load persisted EdgeWorker state for all repositories
3315
+ */
3316
+ async loadPersistedState() {
3317
+ try {
3318
+ const state = await this.persistenceManager.loadEdgeWorkerState();
3319
+ if (state) {
3320
+ this.restoreMappings(state);
3321
+ this.logger.debug(`✅ Loaded persisted EdgeWorker state with ${Object.keys(state.agentSessions || {}).length} repositories`);
3322
+ }
3323
+ }
3324
+ catch (error) {
3325
+ this.logger.error(`Failed to load persisted EdgeWorker state:`, error);
3326
+ }
3327
+ }
3328
+ /**
3329
+ * Save current EdgeWorker state for all repositories
3330
+ */
3331
+ async savePersistedState() {
3332
+ try {
3333
+ const state = this.serializeMappings();
3334
+ await this.persistenceManager.saveEdgeWorkerState(state);
3335
+ this.logger.debug(`✅ Saved EdgeWorker state for ${Object.keys(state.agentSessions || {}).length} repositories`);
3336
+ }
3337
+ catch (error) {
3338
+ this.logger.error(`Failed to save persisted EdgeWorker state:`, error);
3339
+ }
3340
+ }
3341
+ /**
3342
+ * Serialize EdgeWorker mappings to a serializable format
3343
+ */
3344
+ serializeMappings() {
3345
+ // Serialize Agent Session state for all repositories
3346
+ const agentSessions = {};
3347
+ const agentSessionEntries = {};
3348
+ for (const [repositoryId, agentSessionManager,] of this.agentSessionManagers.entries()) {
3349
+ const serializedState = agentSessionManager.serializeState();
3350
+ agentSessions[repositoryId] = serializedState.sessions;
3351
+ agentSessionEntries[repositoryId] = serializedState.entries;
3352
+ }
3353
+ // Serialize child to parent agent session mapping
3354
+ const childToParentAgentSession = Object.fromEntries(this.childToParentAgentSession.entries());
3355
+ // Serialize issue to repository cache from RepositoryRouter
3356
+ const issueRepositoryCache = Object.fromEntries(this.repositoryRouter.getIssueRepositoryCache().entries());
3357
+ return {
3358
+ agentSessions,
3359
+ agentSessionEntries,
3360
+ childToParentAgentSession,
3361
+ issueRepositoryCache,
3362
+ };
3363
+ }
3364
+ /**
3365
+ * Restore EdgeWorker mappings from serialized state
3366
+ */
3367
+ restoreMappings(state) {
3368
+ // Restore Agent Session state for all repositories
3369
+ if (state.agentSessions && state.agentSessionEntries) {
3370
+ for (const [repositoryId, agentSessionManager,] of this.agentSessionManagers.entries()) {
3371
+ const repositorySessions = state.agentSessions[repositoryId] || {};
3372
+ const repositoryEntries = state.agentSessionEntries[repositoryId] || {};
3373
+ if (Object.keys(repositorySessions).length > 0 ||
3374
+ Object.keys(repositoryEntries).length > 0) {
3375
+ agentSessionManager.restoreState(repositorySessions, repositoryEntries);
3376
+ this.logger.debug(`Restored Agent Session state for repository ${repositoryId}`);
3377
+ }
3378
+ }
3379
+ }
3380
+ // Restore child to parent agent session mapping
3381
+ if (state.childToParentAgentSession) {
3382
+ this.childToParentAgentSession = new Map(Object.entries(state.childToParentAgentSession));
3383
+ this.logger.debug(`Restored ${this.childToParentAgentSession.size} child-to-parent agent session mappings`);
3384
+ }
3385
+ // Restore issue to repository cache in RepositoryRouter
3386
+ if (state.issueRepositoryCache) {
3387
+ const cache = new Map(Object.entries(state.issueRepositoryCache));
3388
+ this.repositoryRouter.restoreIssueRepositoryCache(cache);
3389
+ this.logger.debug(`Restored ${cache.size} issue-to-repository cache mappings`);
3390
+ }
3391
+ }
3392
+ /**
3393
+ * Post an activity directly via an issue tracker instance.
3394
+ * Consolidates try/catch and success/error logging for EdgeWorker call sites
3395
+ * that already have the issueTracker and agentSessionId resolved.
3396
+ *
3397
+ * @returns The activity ID when resolved, `null` otherwise.
3398
+ */
3399
+ async postActivityDirect(issueTracker, input, label) {
3400
+ return this.activityPoster.postActivityDirect(issueTracker, input, label);
3401
+ }
3402
+ /**
3403
+ * Post instant acknowledgment thought when agent session is created
3404
+ */
3405
+ async postInstantAcknowledgment(sessionId, repositoryId) {
3406
+ return this.activityPoster.postInstantAcknowledgment(sessionId, repositoryId);
3407
+ }
3408
+ /**
3409
+ * Post parent resume acknowledgment thought when parent session is resumed from child
3410
+ */
3411
+ async postParentResumeAcknowledgment(sessionId, repositoryId) {
3412
+ return this.activityPoster.postParentResumeAcknowledgment(sessionId, repositoryId);
3413
+ }
3414
+ /**
3415
+ * Post repository selection activity
3416
+ * Shows which method was used to select the repository (auto-routing or user selection)
3417
+ */
3418
+ async postRepositorySelectionActivity(sessionId, repositoryId, repositoryName, selectionMethod) {
3419
+ return this.activityPoster.postRepositorySelectionActivity(sessionId, repositoryId, repositoryName, selectionMethod);
3420
+ }
3421
+ /**
3422
+ * Re-route procedure for a session (used when resuming from child or give feedback)
3423
+ * This ensures the currentSubroutine is reset to avoid suppression issues
3424
+ */
3425
+ async rerouteProcedureForSession(session, sessionId, agentSessionManager, promptBody, repository) {
3426
+ // Initialize procedure metadata using intelligent routing
3427
+ if (!session.metadata) {
3428
+ session.metadata = {};
3429
+ }
3430
+ // Post ephemeral "Routing..." thought
3431
+ await agentSessionManager.postAnalyzingThought(sessionId);
3432
+ // Fetch full issue and labels to check for Orchestrator label override
3433
+ const issueTracker = this.issueTrackers.get(repository.id);
3434
+ let hasOrchestratorLabel = false;
3435
+ // Get issueId from issueContext (preferred) or deprecated issueId field
3436
+ const issueId = session.issueContext?.issueId ?? session.issueId;
3437
+ if (issueTracker && issueId) {
3438
+ try {
3439
+ const fullIssue = await issueTracker.fetchIssue(issueId);
3440
+ const labels = await this.fetchIssueLabels(fullIssue);
3441
+ // ALWAYS check for 'orchestrator' label (case-insensitive) regardless of EdgeConfig
3442
+ // This is a hardcoded rule: any issue with 'orchestrator'/'Orchestrator' label
3443
+ // goes to orchestrator procedure
3444
+ const lowercaseLabels = labels.map((label) => label.toLowerCase());
3445
+ const hasHardcodedOrchestratorLabel = lowercaseLabels.includes("orchestrator");
3446
+ // Also check any additional orchestrator labels from config
3447
+ const orchestratorConfig = repository.labelPrompts?.orchestrator;
3448
+ const orchestratorLabels = Array.isArray(orchestratorConfig)
3449
+ ? orchestratorConfig
3450
+ : orchestratorConfig?.labels;
3451
+ const hasConfiguredOrchestratorLabel = orchestratorLabels?.some((label) => lowercaseLabels.includes(label.toLowerCase())) ?? false;
3452
+ hasOrchestratorLabel =
3453
+ hasHardcodedOrchestratorLabel || hasConfiguredOrchestratorLabel;
3454
+ }
3455
+ catch (error) {
3456
+ this.logger.error(`Failed to fetch issue labels for routing:`, error);
3457
+ // Continue with AI routing if label fetch fails
3458
+ }
3459
+ }
3460
+ let selectedProcedure;
3461
+ let finalClassification;
3462
+ // If Orchestrator label is present, ALWAYS use orchestrator-full procedure
3463
+ if (hasOrchestratorLabel) {
3464
+ const orchestratorProcedure = this.procedureAnalyzer.getProcedure("orchestrator-full");
3465
+ if (!orchestratorProcedure) {
3466
+ throw new Error("orchestrator-full procedure not found in registry");
3467
+ }
3468
+ selectedProcedure = orchestratorProcedure;
3469
+ finalClassification = "orchestrator";
3470
+ this.logger.info(`Using orchestrator-full procedure due to Orchestrator label (skipping AI routing)`);
3471
+ }
3472
+ else {
3473
+ // No Orchestrator label - use AI routing based on prompt content
3474
+ const routingDecision = await this.procedureAnalyzer.determineRoutine(promptBody.trim());
3475
+ selectedProcedure = routingDecision.procedure;
3476
+ finalClassification = routingDecision.classification;
3477
+ // Log AI routing decision
3478
+ this.logger.info(`AI routing decision for ${sessionId}:`);
3479
+ this.logger.info(` Classification: ${routingDecision.classification}`);
3480
+ this.logger.info(` Procedure: ${selectedProcedure.name}`);
3481
+ this.logger.info(` Reasoning: ${routingDecision.reasoning}`);
3482
+ }
3483
+ // Initialize procedure metadata in session (resets currentSubroutine)
3484
+ this.procedureAnalyzer.initializeProcedureMetadata(session, selectedProcedure);
3485
+ // Post procedure selection result (replaces ephemeral routing thought)
3486
+ await agentSessionManager.postProcedureSelectionThought(sessionId, selectedProcedure.name, finalClassification);
3487
+ }
3488
+ /**
3489
+ * Handle prompt with streaming check - centralized logic for all input types
3490
+ *
3491
+ * This method implements the unified pattern for handling prompts:
3492
+ * 1. Check if runner is actively streaming
3493
+ * 2. Route procedure if NOT streaming (resets currentSubroutine)
3494
+ * 3. Add to stream if streaming, OR resume session if not
3495
+ *
3496
+ * @param session The Sylas agent session
3497
+ * @param repository Repository configuration
3498
+ * @param sessionId Linear agent activity session ID
3499
+ * @param agentSessionManager Agent session manager instance
3500
+ * @param promptBody The prompt text to send
3501
+ * @param attachmentManifest Optional attachment manifest to append
3502
+ * @param isNewSession Whether this is a new session
3503
+ * @param additionalAllowedDirs Additional directories to allow access to
3504
+ * @param logContext Context string for logging (e.g., "prompted webhook", "parent resume")
3505
+ * @returns true if message was added to stream, false if session was resumed
3506
+ */
3507
+ async handlePromptWithStreamingCheck(session, repository, sessionId, agentSessionManager, promptBody, attachmentManifest, isNewSession, additionalAllowedDirs, logContext, commentAuthor, commentTimestamp) {
3508
+ const log = this.logger.withContext({ sessionId });
3509
+ // Check if runner is actively running before routing
3510
+ const existingRunner = session.agentRunner;
3511
+ const isRunning = existingRunner?.isRunning() || false;
3512
+ // Always route procedure for new input, UNLESS actively running
3513
+ if (!isRunning) {
3514
+ await this.rerouteProcedureForSession(session, sessionId, agentSessionManager, promptBody, repository);
3515
+ log.debug(`Routed procedure for ${logContext}`);
3516
+ }
3517
+ else {
3518
+ log.debug(`Skipping routing for ${sessionId} (${logContext}) - runner is actively running`);
3519
+ }
3520
+ // Handle running case - add message to existing stream (if supported)
3521
+ if (existingRunner?.isRunning() &&
3522
+ existingRunner.supportsStreamingInput &&
3523
+ existingRunner.addStreamMessage) {
3524
+ log.debug(`Adding prompt to existing stream for ${sessionId} (${logContext})`);
3525
+ // Append attachment manifest to the prompt if we have one
3526
+ let fullPrompt = promptBody;
3527
+ if (attachmentManifest) {
3528
+ fullPrompt = `${promptBody}\n\n${attachmentManifest}`;
3529
+ }
3530
+ existingRunner.addStreamMessage(fullPrompt);
3531
+ return true; // Message added to stream
3532
+ }
3533
+ // Not streaming - resume/start session
3534
+ log.debug(`Resuming Claude session for ${sessionId} (${logContext})`);
3535
+ await this.resumeAgentSession(session, repository, sessionId, agentSessionManager, promptBody, attachmentManifest, isNewSession, additionalAllowedDirs, undefined, // maxTurns
3536
+ commentAuthor, commentTimestamp);
3537
+ return false; // Session was resumed
3538
+ }
3539
+ /**
3540
+ * Post thought about system prompt selection based on labels
3541
+ */
3542
+ async postSystemPromptSelectionThought(sessionId, labels, repositoryId) {
3543
+ return this.activityPoster.postSystemPromptSelectionThought(sessionId, labels, repositoryId);
3544
+ }
3545
+ /**
3546
+ * Resume or create an Agent session with the given prompt
3547
+ * This is the core logic for handling prompted agent activities
3548
+ * @param session The Sylas agent session
3549
+ * @param repository The repository configuration
3550
+ * @param sessionId The Linear agent session ID
3551
+ * @param agentSessionManager The agent session manager
3552
+ * @param promptBody The prompt text to send
3553
+ * @param attachmentManifest Optional attachment manifest
3554
+ * @param isNewSession Whether this is a new session
3555
+ */
3556
+ async resumeAgentSession(session, repository, sessionId, agentSessionManager, promptBody, attachmentManifest = "", isNewSession = false, additionalAllowedDirectories = [], maxTurns, commentAuthor, commentTimestamp) {
3557
+ const log = this.logger.withContext({ sessionId });
3558
+ // Check for existing runner
3559
+ const existingRunner = session.agentRunner;
3560
+ // If there's an existing running runner that supports streaming, add to it
3561
+ if (existingRunner?.isRunning() &&
3562
+ existingRunner.supportsStreamingInput &&
3563
+ existingRunner.addStreamMessage) {
3564
+ let fullPrompt = promptBody;
3565
+ if (attachmentManifest) {
3566
+ fullPrompt = `${promptBody}\n\n${attachmentManifest}`;
3567
+ }
3568
+ existingRunner.addStreamMessage(fullPrompt);
3569
+ return;
3570
+ }
3571
+ // Stop existing runner if it's not running
3572
+ if (existingRunner) {
3573
+ existingRunner.stop();
3574
+ }
3575
+ // Get issueId from issueContext (preferred) or deprecated issueId field
3576
+ const issueIdForResume = session.issueContext?.issueId ?? session.issueId;
3577
+ if (!issueIdForResume) {
3578
+ log.error(`No issue ID found for session ${session.id}`);
3579
+ throw new Error(`No issue ID found for session ${session.id}`);
3580
+ }
3581
+ // Fetch full issue details
3582
+ const fullIssue = await this.fetchFullIssueDetails(issueIdForResume, repository.id);
3583
+ if (!fullIssue) {
3584
+ log.error(`Failed to fetch full issue details for ${issueIdForResume}`);
3585
+ throw new Error(`Failed to fetch full issue details for ${issueIdForResume}`);
3586
+ }
3587
+ // Fetch issue labels early to determine runner type
3588
+ const labels = await this.fetchIssueLabels(fullIssue);
3589
+ // Determine which runner to use based on existing session IDs
3590
+ const hasClaudeSession = !isNewSession && Boolean(session.claudeSessionId);
3591
+ const hasGeminiSession = !isNewSession && Boolean(session.geminiSessionId);
3592
+ const hasCodexSession = !isNewSession && Boolean(session.codexSessionId);
3593
+ const hasCursorSession = !isNewSession && Boolean(session.cursorSessionId);
3594
+ const needsNewSession = isNewSession ||
3595
+ (!hasClaudeSession &&
3596
+ !hasGeminiSession &&
3597
+ !hasCodexSession &&
3598
+ !hasCursorSession);
3599
+ // Fetch system prompt based on labels
3600
+ const systemPromptResult = await this.determineSystemPromptFromLabels(labels, repository);
3601
+ const systemPrompt = systemPromptResult?.prompt;
3602
+ const promptType = systemPromptResult?.type;
3603
+ // Get current subroutine to check for singleTurn mode and disallowAllTools
3604
+ const currentSubroutine = this.procedureAnalyzer.getCurrentSubroutine(session);
3605
+ // Build allowed tools list
3606
+ // If subroutine has disallowAllTools: true, use empty array to disable all tools
3607
+ const allowedTools = currentSubroutine?.disallowAllTools
3608
+ ? []
3609
+ : this.buildAllowedTools(repository, promptType);
3610
+ const baseDisallowedTools = this.buildDisallowedTools(repository, promptType);
3611
+ // Merge subroutine-level disallowedTools if applicable
3612
+ const disallowedTools = this.mergeSubroutineDisallowedTools(session, baseDisallowedTools, "resumeClaudeSession");
3613
+ if (currentSubroutine?.disallowAllTools) {
3614
+ log.debug(`All tools disabled for subroutine: ${currentSubroutine.name}`);
3615
+ }
3616
+ // Set up attachments directory
3617
+ const workspaceFolderName = basename(session.workspace.path);
3618
+ const attachmentsDir = join(this.sylasHome, workspaceFolderName, "attachments");
3619
+ await mkdir(attachmentsDir, { recursive: true });
3620
+ const allowedDirectories = [
3621
+ ...new Set([
3622
+ attachmentsDir,
3623
+ repository.repositoryPath,
3624
+ ...additionalAllowedDirectories,
3625
+ ...this.gitService.getGitMetadataDirectories(session.workspace.path),
3626
+ ]),
3627
+ ];
3628
+ const resumeSessionId = needsNewSession
3629
+ ? undefined
3630
+ : session.claudeSessionId
3631
+ ? session.claudeSessionId
3632
+ : session.geminiSessionId
3633
+ ? session.geminiSessionId
3634
+ : session.codexSessionId
3635
+ ? session.codexSessionId
3636
+ : session.cursorSessionId
3637
+ ? session.cursorSessionId
3638
+ : session.openCodeSessionId;
3639
+ console.log(`[resumeAgentSession] needsNewSession=${needsNewSession}, resumeSessionId=${resumeSessionId ?? "none"}`);
3640
+ // Create runner configuration
3641
+ // buildAgentRunnerConfig determines runner type from labels for new sessions
3642
+ // For existing sessions, we still need labels for model override but ignore runner type
3643
+ const { config: runnerConfig, runnerType } = this.buildAgentRunnerConfig(session, repository, sessionId, systemPrompt, allowedTools, allowedDirectories, disallowedTools, resumeSessionId, labels, // Always pass labels to preserve model override
3644
+ fullIssue.description || undefined, // Description tags can override label selectors
3645
+ maxTurns, // Pass maxTurns if specified
3646
+ currentSubroutine?.singleTurn, // singleTurn flag
3647
+ currentSubroutine?.disallowAllTools);
3648
+ // Create the appropriate runner based on session state
3649
+ const runner = runnerType === "opencode"
3650
+ ? new OpenCodeRunner(runnerConfig)
3651
+ : runnerType === "claude"
3652
+ ? new ClaudeRunner(runnerConfig)
3653
+ : runnerType === "gemini"
3654
+ ? new GeminiRunner(runnerConfig)
3655
+ : runnerType === "codex"
3656
+ ? new CodexRunner(runnerConfig)
3657
+ : new CursorRunner(runnerConfig);
3658
+ // Store runner
3659
+ agentSessionManager.addAgentRunner(sessionId, runner);
3660
+ // Save state
3661
+ await this.savePersistedState();
3662
+ // Prepare the full prompt
3663
+ const fullPrompt = await this.buildSessionPrompt(isNewSession, session, fullIssue, repository, promptBody, attachmentManifest, commentAuthor, commentTimestamp);
3664
+ // Start session - use streaming mode if supported for ability to add messages later
3665
+ try {
3666
+ if (runner.supportsStreamingInput && runner.startStreaming) {
3667
+ await runner.startStreaming(fullPrompt);
3668
+ }
3669
+ else {
3670
+ await runner.start(fullPrompt);
3671
+ }
3672
+ }
3673
+ catch (error) {
3674
+ log.error(`Failed to start streaming session for ${sessionId}:`, error);
3675
+ throw error;
3676
+ }
3677
+ }
3678
+ /**
3679
+ * Post instant acknowledgment thought when receiving prompted webhook
3680
+ */
3681
+ async postInstantPromptedAcknowledgment(sessionId, repositoryId, isStreaming) {
3682
+ return this.activityPoster.postInstantPromptedAcknowledgment(sessionId, repositoryId, isStreaming);
3683
+ }
3684
+ /**
3685
+ * Get the platform type for a repository's issue tracker.
3686
+ */
3687
+ getRepositoryPlatform(repositoryId) {
3688
+ try {
3689
+ return this.issueTrackers.get(repositoryId)?.getPlatformType();
3690
+ }
3691
+ catch {
3692
+ return undefined;
3693
+ }
3694
+ }
3695
+ /**
3696
+ * Fetch complete issue details from Linear API
3697
+ */
3698
+ async fetchFullIssueDetails(issueId, repositoryId) {
3699
+ const issueTracker = this.issueTrackers.get(repositoryId);
3700
+ if (!issueTracker) {
3701
+ this.logger.warn(`No issue tracker found for repository ${repositoryId}`);
3702
+ return null;
3703
+ }
3704
+ try {
3705
+ this.logger.debug(`Fetching full issue details for ${issueId}`);
3706
+ const fullIssue = await issueTracker.fetchIssue(issueId);
3707
+ this.logger.debug(`Successfully fetched issue details for ${issueId}`);
3708
+ // Check if issue has a parent
3709
+ try {
3710
+ const parent = await fullIssue.parent;
3711
+ if (parent) {
3712
+ this.logger.debug(`Issue ${issueId} has parent: ${parent.identifier}`);
3713
+ }
3714
+ }
3715
+ catch (_error) {
3716
+ // Parent field might not exist, ignore error
3717
+ }
3718
+ return fullIssue;
3719
+ }
3720
+ catch (error) {
3721
+ this.logger.error(`Failed to fetch issue details for ${issueId}:`, error);
3722
+ return null;
3723
+ }
3724
+ }
3725
+ // ========================================================================
3726
+ // OAuth Token Refresh
3727
+ // ========================================================================
3728
+ /**
3729
+ * Build OAuth config for LinearIssueTrackerService.
3730
+ * Returns undefined if OAuth credentials are not available.
3731
+ */
3732
+ buildOAuthConfig(repo) {
3733
+ const clientId = process.env.LINEAR_CLIENT_ID;
3734
+ const clientSecret = process.env.LINEAR_CLIENT_SECRET;
3735
+ if (!clientId || !clientSecret) {
3736
+ this.logger.warn("LINEAR_CLIENT_ID and LINEAR_CLIENT_SECRET not set, token refresh disabled");
3737
+ return undefined;
3738
+ }
3739
+ if (!repo.linearRefreshToken) {
3740
+ this.logger.warn(`No refresh token for repository ${repo.id}, token refresh disabled`);
3741
+ return undefined;
3742
+ }
3743
+ const workspaceId = repo.linearWorkspaceId;
3744
+ const workspaceName = repo.linearWorkspaceName || workspaceId;
3745
+ return {
3746
+ clientId,
3747
+ clientSecret,
3748
+ refreshToken: repo.linearRefreshToken,
3749
+ workspaceId,
3750
+ onTokenRefresh: async (tokens) => {
3751
+ // Update repository config state (for EdgeWorker's internal tracking)
3752
+ for (const [, repository] of this.repositories) {
3753
+ if (repository.linearWorkspaceId === workspaceId) {
3754
+ repository.linearToken = tokens.accessToken;
3755
+ repository.linearRefreshToken = tokens.refreshToken;
3756
+ }
3757
+ }
3758
+ // Persist tokens to config.json
3759
+ await this.saveOAuthTokens({
3760
+ linearToken: tokens.accessToken,
3761
+ linearRefreshToken: tokens.refreshToken,
3762
+ linearWorkspaceId: workspaceId,
3763
+ linearWorkspaceName: workspaceName,
3764
+ });
3765
+ },
3766
+ };
3767
+ }
3768
+ /**
3769
+ * Save OAuth tokens to config.json
3770
+ */
3771
+ async saveOAuthTokens(tokens) {
3772
+ if (!this.configPath) {
3773
+ this.logger.warn("No config path set, cannot save OAuth tokens");
3774
+ return;
3775
+ }
3776
+ try {
3777
+ const configContent = await readFile(this.configPath, "utf-8");
3778
+ const config = JSON.parse(configContent);
3779
+ // Find and update all repositories with this workspace ID
3780
+ if (config.repositories && Array.isArray(config.repositories)) {
3781
+ for (const repo of config.repositories) {
3782
+ if (repo.linearWorkspaceId === tokens.linearWorkspaceId) {
3783
+ repo.linearToken = tokens.linearToken;
3784
+ if (tokens.linearRefreshToken) {
3785
+ repo.linearRefreshToken = tokens.linearRefreshToken;
3786
+ }
3787
+ if (tokens.linearWorkspaceName) {
3788
+ repo.linearWorkspaceName = tokens.linearWorkspaceName;
3789
+ }
3790
+ }
3791
+ }
3792
+ }
3793
+ await writeFile(this.configPath, JSON.stringify(config, null, "\t"));
3794
+ this.logger.debug(`OAuth tokens saved to config for workspace ${tokens.linearWorkspaceId}`);
3795
+ }
3796
+ catch (error) {
3797
+ this.logger.error("Failed to save OAuth tokens:", error);
3798
+ }
3799
+ }
3800
+ }
3801
+ //# sourceMappingURL=EdgeWorker.js.map