cyrus-edge-worker 0.0.17 → 0.0.18-alpha.0

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.
@@ -2,40 +2,37 @@ import { EventEmitter } from 'events';
2
2
  import { LinearClient } from '@linear/sdk';
3
3
  import { NdjsonClient } from 'cyrus-ndjson-client';
4
4
  import { ClaudeRunner, getSafeTools } from 'cyrus-claude-runner';
5
- import { SessionManager, Session } from 'cyrus-core';
5
+ import { PersistenceManager } from 'cyrus-core';
6
6
  import { SharedApplicationServer } from './SharedApplicationServer.js';
7
- import { isIssueAssignedWebhook, isIssueCommentMentionWebhook, isIssueNewCommentWebhook, isIssueUnassignedWebhook } from 'cyrus-core';
8
- import { readFile, writeFile, mkdir, rename, readdir } from 'fs/promises';
7
+ import { AgentSessionManager } from './AgentSessionManager.js';
8
+ import { isIssueAssignedWebhook, isIssueCommentMentionWebhook, isIssueNewCommentWebhook, isIssueUnassignedWebhook, isAgentSessionCreatedWebhook, isAgentSessionPromptedWebhook } from 'cyrus-core';
9
+ import { readFile, writeFile, mkdir, rename } from 'fs/promises';
9
10
  import { resolve, dirname, join, basename, extname } from 'path';
10
11
  import { fileURLToPath } from 'url';
11
12
  import { homedir } from 'os';
12
13
  import { fileTypeFromBuffer } from 'file-type';
13
- import { existsSync } from 'fs';
14
14
  /**
15
- * Unified edge worker that orchestrates NDJSON streaming, Claude processing, and Linear integration
15
+ * Unified edge worker that **orchestrates**
16
+ * capturing Linear webhooks,
17
+ * managing Claude Code processes, and
18
+ * processes results through to Linear Agent Activity Sessions
16
19
  */
17
20
  export class EdgeWorker extends EventEmitter {
18
21
  config;
19
- repositories = new Map();
20
- linearClients = new Map();
21
- ndjsonClients = new Map();
22
- sessionManager;
23
- claudeRunners = new Map(); // Maps comment ID to ClaudeRunner
24
- commentToRepo = new Map(); // Maps comment ID to repository ID
25
- commentToIssue = new Map(); // Maps comment ID to issue ID
26
- commentToLatestAgentReply = new Map(); // Maps thread root comment ID to latest agent comment
27
- issueToCommentThreads = new Map(); // Maps issue ID to all comment thread IDs
28
- tokenToClientId = new Map(); // Maps token to NDJSON client ID
29
- issueToReplyContext = new Map(); // Maps issue ID to reply context
22
+ repositories = new Map(); // repository 'id' (internal, stored in config.json) mapped to the full repo config
23
+ agentSessionManagers = new Map(); // Maps repository ID to AgentSessionManager, which manages ClaudeRunners for a repo
24
+ linearClients = new Map(); // one linear client per 'repository'
25
+ ndjsonClients = new Map(); // listeners for webhook events, one per linear token
26
+ persistenceManager;
30
27
  sharedApplicationServer;
31
28
  constructor(config) {
32
29
  super();
33
30
  this.config = config;
34
- this.sessionManager = new SessionManager();
31
+ this.persistenceManager = new PersistenceManager();
35
32
  // Initialize shared application server
36
33
  const serverPort = config.serverPort || config.webhookPort || 3456;
37
34
  const serverHost = config.serverHost || 'localhost';
38
- this.sharedApplicationServer = new SharedApplicationServer(serverPort, serverHost, config.ngrokAuthToken);
35
+ this.sharedApplicationServer = new SharedApplicationServer(serverPort, serverHost, config.ngrokAuthToken, config.proxyUrl);
39
36
  // Register OAuth callback handler if provided
40
37
  if (config.handlers?.onOAuthCallback) {
41
38
  this.sharedApplicationServer.registerOAuthCallbackHandler(config.handlers.onOAuthCallback);
@@ -45,9 +42,12 @@ export class EdgeWorker extends EventEmitter {
45
42
  if (repo.isActive !== false) {
46
43
  this.repositories.set(repo.id, repo);
47
44
  // Create Linear client for this repository's workspace
48
- this.linearClients.set(repo.id, new LinearClient({
45
+ const linearClient = new LinearClient({
49
46
  accessToken: repo.linearToken
50
- }));
47
+ });
48
+ this.linearClients.set(repo.id, linearClient);
49
+ // Create AgentSessionManager for this repository
50
+ this.agentSessionManagers.set(repo.id, new AgentSessionManager(linearClient));
51
51
  }
52
52
  }
53
53
  // Group repositories by token to minimize NDJSON connections
@@ -94,14 +94,14 @@ export class EdgeWorker extends EventEmitter {
94
94
  // Store with the first repo's ID as the key (for error messages)
95
95
  // But also store the token mapping for lookup
96
96
  this.ndjsonClients.set(primaryRepoId, ndjsonClient);
97
- // Store token to client mapping for other lookups if needed
98
- this.tokenToClientId.set(token, primaryRepoId);
99
97
  }
100
98
  }
101
99
  /**
102
100
  * Start the edge worker
103
101
  */
104
102
  async start() {
103
+ // Load persisted state for each repository
104
+ await this.loadPersistedState();
105
105
  // Start shared application server first
106
106
  await this.sharedApplicationServer.start();
107
107
  // Connect all NDJSON clients
@@ -153,9 +153,21 @@ export class EdgeWorker extends EventEmitter {
153
153
  * Stop the edge worker
154
154
  */
155
155
  async stop() {
156
+ try {
157
+ await this.savePersistedState();
158
+ console.log('✅ EdgeWorker state saved successfully');
159
+ }
160
+ catch (error) {
161
+ console.error('❌ Failed to save EdgeWorker state during shutdown:', error);
162
+ }
163
+ // get all claudeRunners
164
+ const claudeRunners = [];
165
+ for (const agentSessionManager of this.agentSessionManagers.values()) {
166
+ claudeRunners.push(...agentSessionManager.getAllClaudeRunners());
167
+ }
156
168
  // Kill all Claude processes with null checking
157
- for (const [, runner] of this.claudeRunners) {
158
- if (runner && typeof runner.stop === 'function') {
169
+ for (const runner of claudeRunners) {
170
+ if (runner) {
159
171
  try {
160
172
  runner.stop();
161
173
  }
@@ -164,15 +176,6 @@ export class EdgeWorker extends EventEmitter {
164
176
  }
165
177
  }
166
178
  }
167
- this.claudeRunners.clear();
168
- // Clear all sessions
169
- for (const [commentId] of this.sessionManager.getAllSessions()) {
170
- this.sessionManager.removeSession(commentId);
171
- }
172
- this.commentToRepo.clear();
173
- this.commentToIssue.clear();
174
- this.commentToLatestAgentReply.clear();
175
- this.issueToCommentThreads.clear();
176
179
  // Disconnect all NDJSON clients
177
180
  for (const client of this.ndjsonClients.values()) {
178
181
  client.disconnect();
@@ -204,25 +207,6 @@ export class EdgeWorker extends EventEmitter {
204
207
  this.emit('error', error);
205
208
  this.config.handlers?.onError?.(error);
206
209
  }
207
- /**
208
- * Check if Claude logs exist for a workspace
209
- */
210
- async hasExistingLogs(workspaceName) {
211
- try {
212
- const logsDir = join(homedir(), '.cyrus', 'logs', workspaceName);
213
- // Check if directory exists
214
- if (!existsSync(logsDir)) {
215
- return false;
216
- }
217
- // Check if directory has any log files
218
- const files = await readdir(logsDir);
219
- return files.some(file => file.endsWith('.jsonl'));
220
- }
221
- catch (error) {
222
- console.error(`Failed to check logs for workspace ${workspaceName}:`, error);
223
- return false;
224
- }
225
- }
226
210
  /**
227
211
  * Handle webhook events from proxy - now accepts native webhook payloads
228
212
  */
@@ -248,18 +232,29 @@ export class EdgeWorker extends EventEmitter {
248
232
  console.log(`[EdgeWorker] Webhook matched to repository: ${repository.name}`);
249
233
  try {
250
234
  // Handle specific webhook types with proper typing
235
+ // NOTE: Traditional webhooks (assigned, comment) are disabled in favor of agent session events
251
236
  if (isIssueAssignedWebhook(webhook)) {
252
- await this.handleIssueAssignedWebhook(webhook, repository);
237
+ console.log(`[EdgeWorker] Ignoring traditional issue assigned webhook - using agent session events instead`);
238
+ return;
253
239
  }
254
240
  else if (isIssueCommentMentionWebhook(webhook)) {
255
- await this.handleIssueCommentMentionWebhook(webhook, repository);
241
+ console.log(`[EdgeWorker] Ignoring traditional comment mention webhook - using agent session events instead`);
242
+ return;
256
243
  }
257
244
  else if (isIssueNewCommentWebhook(webhook)) {
258
- await this.handleIssueNewCommentWebhook(webhook, repository);
245
+ console.log(`[EdgeWorker] Ignoring traditional new comment webhook - using agent session events instead`);
246
+ return;
259
247
  }
260
248
  else if (isIssueUnassignedWebhook(webhook)) {
249
+ // Keep unassigned webhook active
261
250
  await this.handleIssueUnassignedWebhook(webhook, repository);
262
251
  }
252
+ else if (isAgentSessionCreatedWebhook(webhook)) {
253
+ await this.handleAgentSessionCreatedWebhook(webhook, repository);
254
+ }
255
+ else if (isAgentSessionPromptedWebhook(webhook)) {
256
+ await this.handleUserPostedAgentActivity(webhook, repository);
257
+ }
263
258
  else {
264
259
  console.log(`Unhandled webhook type: ${webhook.action}`);
265
260
  }
@@ -270,32 +265,6 @@ export class EdgeWorker extends EventEmitter {
270
265
  // The error has been logged and individual webhook failures shouldn't crash the entire system
271
266
  }
272
267
  }
273
- /**
274
- * Handle issue assignment webhook
275
- */
276
- async handleIssueAssignedWebhook(webhook, repository) {
277
- console.log(`[EdgeWorker] Handling issue assignment: ${webhook.notification.issue.identifier}`);
278
- await this.handleIssueAssigned(webhook.notification.issue, repository);
279
- }
280
- /**
281
- * Handle issue comment mention webhook
282
- */
283
- async handleIssueCommentMentionWebhook(webhook, repository) {
284
- console.log(`[EdgeWorker] Handling comment mention: ${webhook.notification.issue.identifier}`);
285
- await this.handleNewComment(webhook.notification.issue, webhook.notification.comment, repository);
286
- }
287
- /**
288
- * Handle issue new comment webhook
289
- */
290
- async handleIssueNewCommentWebhook(webhook, repository) {
291
- console.log(`[EdgeWorker] Handling new comment: ${webhook.notification.issue.identifier}`);
292
- // Check if the comment mentions the agent (Cyrus) before proceeding
293
- if (!(await this.isAgentMentionedInComment(webhook.notification.comment, repository))) {
294
- console.log(`[EdgeWorker] Comment does not mention agent, ignoring: ${webhook.notification.issue.identifier}`);
295
- return;
296
- }
297
- await this.handleNewComment(webhook.notification.issue, webhook.notification.comment, repository);
298
- }
299
268
  /**
300
269
  * Handle issue unassignment webhook
301
270
  */
@@ -314,50 +283,73 @@ export class EdgeWorker extends EventEmitter {
314
283
  const workspaceId = webhook.organizationId;
315
284
  if (!workspaceId)
316
285
  return repos[0] || null; // Fallback to first repo if no workspace ID
317
- // Try team-based routing first
318
- const teamKey = webhook.notification?.issue?.team?.key;
319
- if (teamKey) {
320
- const repo = repos.find(r => r.teamKeys && r.teamKeys.includes(teamKey));
321
- if (repo)
322
- return repo;
323
- }
324
- // Try parsing issue identifier as fallback
325
- const issueId = webhook.notification?.issue?.identifier;
326
- if (issueId && issueId.includes('-')) {
327
- const prefix = issueId.split('-')[0];
328
- if (prefix) {
329
- const repo = repos.find(r => r.teamKeys && r.teamKeys.includes(prefix));
286
+ // Handle agent session webhooks which have different structure
287
+ if (isAgentSessionCreatedWebhook(webhook) || isAgentSessionPromptedWebhook(webhook)) {
288
+ const teamKey = webhook.agentSession?.issue?.team?.key;
289
+ if (teamKey) {
290
+ const repo = repos.find(r => r.teamKeys && r.teamKeys.includes(teamKey));
330
291
  if (repo)
331
292
  return repo;
332
293
  }
294
+ // Try parsing issue identifier as fallback
295
+ const issueId = webhook.agentSession?.issue?.identifier;
296
+ if (issueId && issueId.includes('-')) {
297
+ const prefix = issueId.split('-')[0];
298
+ if (prefix) {
299
+ const repo = repos.find(r => r.teamKeys && r.teamKeys.includes(prefix));
300
+ if (repo)
301
+ return repo;
302
+ }
303
+ }
304
+ }
305
+ else {
306
+ // Original logic for other webhook types
307
+ const teamKey = webhook.notification?.issue?.team?.key;
308
+ if (teamKey) {
309
+ const repo = repos.find(r => r.teamKeys && r.teamKeys.includes(teamKey));
310
+ if (repo)
311
+ return repo;
312
+ }
313
+ // Try parsing issue identifier as fallback
314
+ const issueId = webhook.notification?.issue?.identifier;
315
+ if (issueId && issueId.includes('-')) {
316
+ const prefix = issueId.split('-')[0];
317
+ if (prefix) {
318
+ const repo = repos.find(r => r.teamKeys && r.teamKeys.includes(prefix));
319
+ if (repo)
320
+ return repo;
321
+ }
322
+ }
333
323
  }
334
324
  // Original workspace fallback - find first repo without teamKeys or matching workspace
335
325
  return repos.find(repo => repo.linearWorkspaceId === workspaceId && (!repo.teamKeys || repo.teamKeys.length === 0)) || repos.find(repo => repo.linearWorkspaceId === workspaceId) || null;
336
326
  }
337
327
  /**
338
- * Handle issue assignment
339
- * @param issue Linear issue object from webhook data (contains full Linear SDK properties)
328
+ * Handle agent session created webhook
329
+ * . Can happen due to being 'delegated' or @ mentioned in a new thread
330
+ * @param webhook
340
331
  * @param repository Repository configuration
341
332
  */
342
- async handleIssueAssigned(issue, repository) {
343
- console.log(`[EdgeWorker] handleIssueAssigned started for issue ${issue.identifier} (${issue.id})`);
333
+ async handleAgentSessionCreatedWebhook(webhook, repository) {
334
+ console.log(`[EdgeWorker] Handling agent session created: ${webhook.agentSession.issue.identifier}`);
335
+ const { agentSession } = webhook;
336
+ const linearAgentActivitySessionId = agentSession.id;
337
+ const { issue } = agentSession;
338
+ // Initialize the agent session in AgentSessionManager
339
+ const agentSessionManager = this.agentSessionManagers.get(repository.id);
340
+ if (!agentSessionManager) {
341
+ console.error('There was no agentSessionManage for the repository with id', repository.id);
342
+ return;
343
+ }
344
+ // Post instant acknowledgment thought
345
+ await this.postInstantAcknowledgment(linearAgentActivitySessionId, repository.id);
344
346
  // Fetch full Linear issue details immediately
345
347
  const fullIssue = await this.fetchFullIssueDetails(issue.id, repository.id);
346
348
  if (!fullIssue) {
347
349
  throw new Error(`Failed to fetch full issue details for ${issue.id}`);
348
350
  }
349
- console.log(`[EdgeWorker] Fetched full issue details for ${issue.identifier}`);
350
- await this.handleIssueAssignedWithFullIssue(fullIssue, repository);
351
- }
352
- async handleIssueAssignedWithFullIssue(fullIssue, repository) {
353
- console.log(`[EdgeWorker] handleIssueAssignedWithFullIssue started for issue ${fullIssue.identifier} (${fullIssue.id})`);
354
- // Move issue to started state automatically
351
+ // Move issue to started state automatically, in case it's not already
355
352
  await this.moveIssueToStartedState(fullIssue, repository.id);
356
- // Post initial comment immediately
357
- const initialComment = await this.postInitialComment(fullIssue.id, repository.id);
358
- if (!initialComment?.id) {
359
- throw new Error(`Failed to create initial comment for issue ${fullIssue.identifier}`);
360
- }
361
353
  // Create workspace using full issue data
362
354
  const workspace = this.config.handlers?.createWorkspace
363
355
  ? await this.config.handlers.createWorkspace(fullIssue, repository)
@@ -366,6 +358,8 @@ export class EdgeWorker extends EventEmitter {
366
358
  isGitWorktree: false
367
359
  };
368
360
  console.log(`[EdgeWorker] Workspace created at: ${workspace.path}`);
361
+ const issueMinimal = this.convertLinearIssueToCore(fullIssue);
362
+ agentSessionManager.createLinearAgentSession(linearAgentActivitySessionId, issue.id, issueMinimal, workspace);
369
363
  // Download attachments before creating Claude runner
370
364
  const attachmentResult = await this.downloadIssueAttachments(fullIssue, repository, workspace.path);
371
365
  // Build allowed directories list
@@ -375,7 +369,14 @@ export class EdgeWorker extends EventEmitter {
375
369
  }
376
370
  // Build allowed tools list with Linear MCP tools
377
371
  const allowedTools = this.buildAllowedTools(repository);
378
- // Create Claude runner with attachment directory access
372
+ // Fetch issue labels and determine system prompt
373
+ const labels = await this.fetchIssueLabels(fullIssue);
374
+ const systemPrompt = await this.determineSystemPromptFromLabels(labels, repository);
375
+ // Post thought about system prompt selection
376
+ if (systemPrompt) {
377
+ await this.postSystemPromptSelectionThought(linearAgentActivitySessionId, labels, repository.id);
378
+ }
379
+ // Create Claude runner with attachment directory access and optional system prompt
379
380
  const runner = new ClaudeRunner({
380
381
  workingDirectory: workspace.path,
381
382
  allowedTools,
@@ -383,130 +384,31 @@ export class EdgeWorker extends EventEmitter {
383
384
  workspaceName: fullIssue.identifier,
384
385
  mcpConfigPath: repository.mcpConfigPath,
385
386
  mcpConfig: this.buildMcpConfig(repository),
386
- onMessage: (message) => this.handleClaudeMessage(initialComment.id, message, repository.id),
387
- onComplete: (messages) => this.handleClaudeComplete(initialComment.id, messages, repository.id),
388
- onError: (error) => this.handleClaudeError(initialComment.id, error, repository.id)
387
+ ...(systemPrompt && { appendSystemPrompt: systemPrompt }),
388
+ onMessage: (message) => this.handleClaudeMessage(linearAgentActivitySessionId, message, repository.id),
389
+ // onComplete: (messages) => this.handleClaudeComplete(initialComment.id, messages, repository.id),
390
+ onError: (error) => this.handleClaudeError(error)
389
391
  });
390
392
  // Store runner by comment ID
391
- this.claudeRunners.set(initialComment.id, runner);
392
- this.commentToRepo.set(initialComment.id, repository.id);
393
- this.commentToIssue.set(initialComment.id, fullIssue.id);
394
- // Create session using full Linear issue (convert LinearIssue to CoreIssue)
395
- const session = new Session({
396
- issue: this.convertLinearIssueToCore(fullIssue),
397
- workspace,
398
- startedAt: new Date(),
399
- agentRootCommentId: initialComment.id
400
- });
401
- // Store session by comment ID
402
- this.sessionManager.addSession(initialComment.id, session);
403
- // Track this thread for the issue
404
- const threads = this.issueToCommentThreads.get(fullIssue.id) || new Set();
405
- threads.add(initialComment.id);
406
- this.issueToCommentThreads.set(fullIssue.id, threads);
393
+ agentSessionManager.addClaudeRunner(linearAgentActivitySessionId, runner);
394
+ // Save state after mapping changes
395
+ await this.savePersistedState();
407
396
  // Emit events using full Linear issue
408
397
  this.emit('session:started', fullIssue.id, fullIssue, repository.id);
409
398
  this.config.handlers?.onSessionStart?.(fullIssue.id, fullIssue, repository.id);
410
399
  // Build and start Claude with initial prompt using full issue (streaming mode)
411
400
  console.log(`[EdgeWorker] Building initial prompt for issue ${fullIssue.identifier}`);
412
401
  try {
413
- // Use buildPromptV2 without a new comment for issue assignment
414
- const prompt = await this.buildPromptV2(fullIssue, repository, undefined, attachmentResult.manifest);
415
- console.log(`[EdgeWorker] Initial prompt built successfully, length: ${prompt.length} characters`);
402
+ // Choose the appropriate prompt builder based on system prompt availability
403
+ const prompt = systemPrompt
404
+ ? await this.buildLabelBasedPrompt(fullIssue, repository, attachmentResult.manifest)
405
+ : await this.buildPromptV2(fullIssue, repository, undefined, attachmentResult.manifest);
406
+ console.log(`[EdgeWorker] Initial prompt built successfully using ${systemPrompt ? 'label-based' : 'fallback'} workflow, length: ${prompt.length} characters`);
416
407
  console.log(`[EdgeWorker] Starting Claude streaming session`);
417
408
  const sessionInfo = await runner.startStreaming(prompt);
418
409
  console.log(`[EdgeWorker] Claude streaming session started: ${sessionInfo.sessionId}`);
419
- }
420
- catch (error) {
421
- console.error(`[EdgeWorker] Error in prompt building/starting:`, error);
422
- throw error;
423
- }
424
- }
425
- /**
426
- * Find the root comment of a comment thread by traversing parent relationships
427
- */
428
- /**
429
- * Handle new root comment - creates a new Claude session for a new comment thread
430
- * @param issue Linear issue object from webhook data
431
- * @param comment Linear comment object from webhook data
432
- * @param repository Repository configuration
433
- */
434
- async handleNewRootComment(issue, comment, repository) {
435
- console.log(`[EdgeWorker] Handling new root comment ${comment.id} on issue ${issue.identifier}`);
436
- // Fetch full Linear issue details
437
- const fullIssue = await this.fetchFullIssueDetails(issue.id, repository.id);
438
- if (!fullIssue) {
439
- throw new Error(`Failed to fetch full issue details for ${issue.id}`);
440
- }
441
- // Post immediate acknowledgment
442
- const acknowledgment = await this.postComment(issue.id, "I'm getting started on that right away. I'll update this comment with my plan as I work through it.", repository.id, comment.id // Reply to the new root comment
443
- );
444
- if (!acknowledgment?.id) {
445
- throw new Error(`Failed to create acknowledgment for root comment ${comment.id}`);
446
- }
447
- // Create or get workspace
448
- const workspace = this.config.handlers?.createWorkspace
449
- ? await this.config.handlers.createWorkspace(fullIssue, repository)
450
- : {
451
- path: `${repository.workspaceBaseDir}/${fullIssue.identifier}`,
452
- isGitWorktree: false
453
- };
454
- console.log(`[EdgeWorker] Using workspace at: ${workspace.path}`);
455
- // Download attachments if any
456
- const attachmentResult = await this.downloadIssueAttachments(fullIssue, repository, workspace.path);
457
- // Build allowed directories and tools
458
- const allowedDirectories = [];
459
- if (attachmentResult.attachmentsDir) {
460
- allowedDirectories.push(attachmentResult.attachmentsDir);
461
- }
462
- const allowedTools = this.buildAllowedTools(repository);
463
- // Create Claude runner for this new comment thread
464
- const runner = new ClaudeRunner({
465
- workingDirectory: workspace.path,
466
- allowedTools,
467
- allowedDirectories,
468
- workspaceName: fullIssue.identifier,
469
- mcpConfigPath: repository.mcpConfigPath,
470
- mcpConfig: this.buildMcpConfig(repository),
471
- onMessage: (message) => {
472
- // Update session with Claude session ID when first received
473
- if (!session.claudeSessionId && message.session_id) {
474
- session.claudeSessionId = message.session_id;
475
- console.log(`[EdgeWorker] Claude session ID assigned: ${message.session_id}`);
476
- }
477
- this.handleClaudeMessage(acknowledgment.id, message, repository.id);
478
- },
479
- onComplete: (messages) => this.handleClaudeComplete(acknowledgment.id, messages, repository.id),
480
- onError: (error) => this.handleClaudeError(acknowledgment.id, error, repository.id)
481
- });
482
- // Store runner and mappings
483
- this.claudeRunners.set(comment.id, runner);
484
- this.commentToRepo.set(comment.id, repository.id);
485
- this.commentToIssue.set(comment.id, fullIssue.id);
486
- // Create session for this new comment thread
487
- const session = new Session({
488
- issue: this.convertLinearIssueToCore(fullIssue),
489
- workspace,
490
- startedAt: new Date(),
491
- agentRootCommentId: comment.id
492
- });
493
- this.sessionManager.addSession(comment.id, session);
494
- // Track this new thread for the issue
495
- const threads = this.issueToCommentThreads.get(issue.id) || new Set();
496
- threads.add(comment.id);
497
- this.issueToCommentThreads.set(issue.id, threads);
498
- // Track latest reply
499
- this.commentToLatestAgentReply.set(comment.id, acknowledgment.id);
500
- // Emit session start event
501
- this.config.handlers?.onSessionStart?.(fullIssue.id, fullIssue, repository.id);
502
- // Build prompt with new comment focus using V2 template
503
- console.log(`[EdgeWorker] Building prompt for new root comment`);
504
- try {
505
- const prompt = await this.buildPromptV2(fullIssue, repository, comment, attachmentResult.manifest);
506
- console.log(`[EdgeWorker] Prompt built successfully, length: ${prompt.length} characters`);
507
- console.log(`[EdgeWorker] Starting Claude streaming session for new comment thread`);
508
- const sessionInfo = await runner.startStreaming(prompt);
509
- console.log(`[EdgeWorker] Claude streaming session started: ${sessionInfo.sessionId}`);
410
+ // Note: AgentSessionManager will be initialized automatically when the first system message
411
+ // is received via handleClaudeMessage() callback
510
412
  }
511
413
  catch (error) {
512
414
  console.error(`[EdgeWorker] Error in prompt building/starting:`, error);
@@ -519,123 +421,48 @@ export class EdgeWorker extends EventEmitter {
519
421
  * @param comment Linear comment object from webhook data
520
422
  * @param repository Repository configuration
521
423
  */
522
- async handleNewComment(issue, comment, repository) {
523
- // Check if continuation is enabled
524
- if (!this.config.features?.enableContinuation) {
525
- console.log('Continuation not enabled, ignoring comment');
424
+ async handleUserPostedAgentActivity(webhook, repository) {
425
+ // Look for existing session for this comment thread
426
+ const { agentSession } = webhook;
427
+ const linearAgentActivitySessionId = agentSession.id;
428
+ const { issue } = agentSession;
429
+ const promptBody = webhook.agentActivity.content.body;
430
+ // Initialize the agent session in AgentSessionManager
431
+ const agentSessionManager = this.agentSessionManagers.get(repository.id);
432
+ if (!agentSessionManager) {
433
+ console.error('Unexpected: There was no agentSessionManage for the repository with id', repository.id);
526
434
  return;
527
435
  }
528
- // Fetch full Linear issue details
529
- const fullIssue = await this.fetchFullIssueDetails(issue.id, repository.id);
530
- if (!fullIssue) {
531
- throw new Error(`Failed to fetch full issue details for ${issue.id}`);
532
- }
533
- // IMPORTANT: Linear has exactly ONE level of comment nesting:
534
- // - Root comments (no parent)
535
- // - Reply comments (have a parent, which must be a root comment)
536
- // There is NO recursion - a reply cannot have replies
537
- // Fetch full comment to determine if this is a root or reply
538
- let parentCommentId = null;
539
- let rootCommentId = comment.id; // Default to this comment being the root
540
- try {
541
- const linearClient = this.linearClients.get(repository.id);
542
- if (linearClient && comment.id) {
543
- const fullComment = await linearClient.comment({ id: comment.id });
544
- // Check if comment has a parent (making it a reply)
545
- if (fullComment.parent) {
546
- const parent = await fullComment.parent;
547
- if (parent?.id) {
548
- parentCommentId = parent.id;
549
- // In Linear's 2-level structure, the parent IS always the root
550
- // No need for recursion - replies can't have replies
551
- rootCommentId = parent.id;
552
- }
553
- }
554
- }
555
- }
556
- catch (error) {
557
- console.error('Failed to fetch full comment data:', error);
558
- }
559
- // Determine comment type based on whether it has a parent
560
- const isRootComment = parentCommentId === null;
561
- const threadRootCommentId = rootCommentId;
562
- console.log(`[EdgeWorker] Comment ${comment.id} - isRoot: ${isRootComment}, threadRoot: ${threadRootCommentId}, parent: ${parentCommentId}`);
563
- // Store reply context for Linear commenting
564
- // parentId will be: the parent comment ID (if this is a reply) OR this comment's ID (if root)
565
- // This ensures our bot's replies appear at the correct nesting level
566
- this.issueToReplyContext.set(issue.id, {
567
- commentId: comment.id,
568
- parentId: parentCommentId || comment.id
569
- });
570
- // Look for existing session for this comment thread
571
- let session = this.sessionManager.getSession(threadRootCommentId);
572
- // If no session exists, we need to create one
436
+ const session = agentSessionManager.getSession(linearAgentActivitySessionId);
573
437
  if (!session) {
574
- console.log(`No active session for issue ${issue.identifier}, checking for existing logs...`);
575
- // Check if we have existing logs for this issue
576
- const hasLogs = await this.hasExistingLogs(issue.identifier);
577
- if (!hasLogs) {
578
- console.log(`No existing logs found for ${issue.identifier}, treating as new assignment`);
579
- // Start fresh - treat it like a new assignment
580
- await this.handleIssueAssigned(issue, repository);
581
- return;
582
- }
583
- console.log(`Found existing logs for ${issue.identifier}, creating session for continuation`);
584
- // Create workspace (or get existing one)
585
- const workspace = this.config.handlers?.createWorkspace
586
- ? await this.config.handlers.createWorkspace(fullIssue, repository)
587
- : {
588
- path: `${repository.workspaceBaseDir}/${fullIssue.identifier}`,
589
- isGitWorktree: false
590
- };
591
- // Create session for this comment thread
592
- session = new Session({
593
- issue: this.convertLinearIssueToCore(fullIssue),
594
- workspace,
595
- process: null,
596
- startedAt: new Date(),
597
- agentRootCommentId: threadRootCommentId
598
- });
599
- this.sessionManager.addSession(threadRootCommentId, session);
600
- this.commentToRepo.set(threadRootCommentId, repository.id);
601
- this.commentToIssue.set(threadRootCommentId, issue.id);
602
- // Track this thread for the issue
603
- const threads = this.issueToCommentThreads.get(issue.id) || new Set();
604
- threads.add(threadRootCommentId);
605
- this.issueToCommentThreads.set(issue.id, threads);
438
+ console.error(`Unexpected: could not find Cyrus Agent Session for agent activity session: ${linearAgentActivitySessionId}`);
439
+ return;
606
440
  }
607
441
  // Check if there's an existing runner for this comment thread
608
- const existingRunner = this.claudeRunners.get(threadRootCommentId);
442
+ const existingRunner = session.claudeRunner;
609
443
  if (existingRunner && existingRunner.isStreaming()) {
610
444
  // Post immediate reply for streaming case
611
445
  // parentId ensures correct nesting: replies to parent if this is a reply, or to comment itself if root
612
- await this.postComment(issue.id, "I've queued up your message to address it right after I resolve my current focus.", repository.id, parentCommentId || comment.id // Same nesting level as the triggering comment
613
- );
446
+ // TODO____ REACT
447
+ // await this.postComment(
448
+ // issue.id,
449
+ // "I've queued up your message to address it right after I resolve my current focus.",
450
+ // repository.id,
451
+ // parentCommentId || comment.id // Same nesting level as the triggering comment
452
+ // )
614
453
  // Add comment to existing stream instead of restarting
615
- console.log(`[EdgeWorker] Adding comment to existing stream for thread ${threadRootCommentId}`);
616
- try {
617
- existingRunner.addStreamMessage(comment.body || '');
618
- return; // Exit early - comment has been added to stream
619
- }
620
- catch (error) {
621
- console.error(`[EdgeWorker] Failed to add comment to stream, will stop the existing session and start a new one: ${error}`);
622
- // Fall through to restart logic below
623
- }
454
+ console.log(`[EdgeWorker] Adding comment to existing stream for agent activity session ${linearAgentActivitySessionId}`);
455
+ existingRunner.addStreamMessage(promptBody);
456
+ return; // Exit early - comment has been added to stream
624
457
  }
625
- // For root comments without existing sessions, call placeholder handler
626
- if (isRootComment && !session) {
627
- console.log(`[EdgeWorker] Detected new root comment ${comment.id}, delegating to handleNewRootComment`);
628
- await this.handleNewRootComment(issue, comment, repository);
629
- return;
630
- }
631
- // Post immediate reply for continuing existing thread
632
- // parentId ensures correct nesting: replies to parent if this is a reply, or to comment itself if root
633
- await this.postComment(issue.id, "I'm getting started on that right away. I'll update this comment with my plan as I work through it.", repository.id, parentCommentId || comment.id // Same nesting level as the triggering comment
634
- );
635
458
  // Stop existing runner if it's not streaming or stream addition failed
636
459
  if (existingRunner) {
637
460
  existingRunner.stop();
638
461
  }
462
+ if (!session.claudeSessionId) {
463
+ console.error(`Unexpected: Handling a 'prompted' webhook but did not find an existing claudeSessionId for the linearAgentActivitySessionId ${linearAgentActivitySessionId}. Not continuing.`);
464
+ return;
465
+ }
639
466
  try {
640
467
  // Build allowed tools list with Linear MCP tools
641
468
  const allowedTools = this.buildAllowedTools(repository);
@@ -643,65 +470,37 @@ export class EdgeWorker extends EventEmitter {
643
470
  const runner = new ClaudeRunner({
644
471
  workingDirectory: session.workspace.path,
645
472
  allowedTools,
646
- resumeSessionId: session.claudeSessionId || undefined,
473
+ resumeSessionId: session.claudeSessionId,
647
474
  workspaceName: issue.identifier,
648
475
  mcpConfigPath: repository.mcpConfigPath,
649
476
  mcpConfig: this.buildMcpConfig(repository),
650
477
  onMessage: (message) => {
651
- // Update session with Claude session ID when first received
652
- if (!session.claudeSessionId && message.session_id) {
653
- session.claudeSessionId = message.session_id;
654
- console.log(`[EdgeWorker] Stored Claude session ID ${message.session_id} for comment thread ${threadRootCommentId}`);
655
- }
656
- // Check for continuation errors
657
- if (message.type === 'assistant' && 'message' in message && message.message?.content) {
658
- const content = Array.isArray(message.message.content) ? message.message.content : [message.message.content];
659
- for (const item of content) {
660
- if (item?.type === 'text' && item.text?.includes('tool_use` ids were found without `tool_result` blocks')) {
661
- console.log('Detected corrupted conversation history, will restart fresh');
662
- // Kill this runner
663
- runner.stop();
664
- // Remove from map
665
- this.claudeRunners.delete(threadRootCommentId);
666
- // Start fresh by calling root comment handler
667
- this.handleNewRootComment(issue, comment, repository).catch(error => {
668
- console.error(`[EdgeWorker] Failed to restart fresh session for comment thread ${threadRootCommentId}:`, error);
669
- // Clean up any partial state
670
- this.claudeRunners.delete(threadRootCommentId);
671
- this.commentToRepo.delete(threadRootCommentId);
672
- this.commentToIssue.delete(threadRootCommentId);
673
- // Emit error event to notify handlers
674
- this.emit('session:ended', threadRootCommentId, 1, repository.id);
675
- this.config.handlers?.onSessionEnd?.(threadRootCommentId, 1, repository.id);
676
- });
677
- return;
678
- }
679
- }
680
- }
681
- this.handleClaudeMessage(threadRootCommentId, message, repository.id);
478
+ this.handleClaudeMessage(linearAgentActivitySessionId, message, repository.id);
682
479
  },
683
- onComplete: (messages) => this.handleClaudeComplete(threadRootCommentId, messages, repository.id),
684
- onError: (error) => this.handleClaudeError(threadRootCommentId, error, repository.id)
480
+ // onComplete: (messages) => this.handleClaudeComplete(threadRootCommentId, messages, repository.id),
481
+ onError: (error) => this.handleClaudeError(error)
685
482
  });
686
483
  // Store new runner by comment thread root
687
- this.claudeRunners.set(threadRootCommentId, runner);
484
+ // Store runner by comment ID
485
+ agentSessionManager.addClaudeRunner(linearAgentActivitySessionId, runner);
486
+ // Save state after mapping changes
487
+ await this.savePersistedState();
688
488
  // Start streaming session with the comment as initial prompt
689
489
  console.log(`[EdgeWorker] Starting new streaming session for issue ${issue.identifier}`);
690
- await runner.startStreaming(comment.body || '');
490
+ await runner.startStreaming(promptBody);
691
491
  }
692
492
  catch (error) {
693
- console.error('Failed to continue conversation, starting fresh:', error);
493
+ console.error('Failed to continue conversation:', error);
694
494
  // Remove any partially created session
695
- this.sessionManager.removeSession(threadRootCommentId);
696
- this.commentToRepo.delete(threadRootCommentId);
697
- this.commentToIssue.delete(threadRootCommentId);
698
- // Start fresh for root comments, or fall back to issue assignment
699
- if (isRootComment) {
700
- await this.handleNewRootComment(issue, comment, repository);
701
- }
702
- else {
703
- await this.handleIssueAssigned(issue, repository);
704
- }
495
+ // this.sessionManager.removeSession(threadRootCommentId)
496
+ // this.commentToRepo.delete(threadRootCommentId)
497
+ // this.commentToIssue.delete(threadRootCommentId)
498
+ // // Start fresh for root comments, or fall back to issue assignment
499
+ // if (isRootComment) {
500
+ // await this.handleNewRootComment(issue, comment, repository)
501
+ // } else {
502
+ // await this.handleIssueAssigned(issue, repository)
503
+ // }
705
504
  }
706
505
  }
707
506
  /**
@@ -710,17 +509,18 @@ export class EdgeWorker extends EventEmitter {
710
509
  * @param repository Repository configuration
711
510
  */
712
511
  async handleIssueUnassigned(issue, repository) {
713
- // Get all comment threads for this issue
714
- const threadRootCommentIds = this.issueToCommentThreads.get(issue.id) || new Set();
512
+ const agentSessionManager = this.agentSessionManagers.get(repository.id);
513
+ if (!agentSessionManager) {
514
+ console.log('No agentSessionManager for unassigned issue, so no sessions to stop');
515
+ return;
516
+ }
517
+ // Get all Claude runners for this specific issue
518
+ const claudeRunners = agentSessionManager.getClaudeRunnersForIssue(issue.id);
715
519
  // Stop all Claude runners for this issue
716
- let activeThreadCount = 0;
717
- for (const threadRootCommentId of threadRootCommentIds) {
718
- const runner = this.claudeRunners.get(threadRootCommentId);
719
- if (runner) {
720
- console.log(`[EdgeWorker] Stopping Claude runner for thread ${threadRootCommentId}`);
721
- await runner.stop();
722
- activeThreadCount++;
723
- }
520
+ const activeThreadCount = claudeRunners.length;
521
+ for (const runner of claudeRunners) {
522
+ console.log(`[EdgeWorker] Stopping Claude runner for issue ${issue.identifier}`);
523
+ runner.stop();
724
524
  }
725
525
  // Post ONE farewell comment on the issue (not in any thread) if there were active sessions
726
526
  if (activeThreadCount > 0) {
@@ -728,137 +528,25 @@ export class EdgeWorker extends EventEmitter {
728
528
  // No parentId - post as a new comment on the issue
729
529
  );
730
530
  }
731
- // Clean up thread mappings for each stopped thread
732
- for (const threadRootCommentId of threadRootCommentIds) {
733
- // Remove from runners map
734
- this.claudeRunners.delete(threadRootCommentId);
735
- // Clean up comment mappings
736
- this.commentToRepo.delete(threadRootCommentId);
737
- this.commentToIssue.delete(threadRootCommentId);
738
- this.commentToLatestAgentReply.delete(threadRootCommentId);
739
- // Remove session
740
- this.sessionManager.removeSession(threadRootCommentId);
741
- }
742
- // Clean up issue-level mappings
743
- this.issueToCommentThreads.delete(issue.id);
744
- this.issueToReplyContext.delete(issue.id);
745
531
  // Emit events
746
532
  console.log(`[EdgeWorker] Stopped ${activeThreadCount} sessions for unassigned issue ${issue.identifier}`);
747
- this.emit('session:ended', issue.id, null, repository.id);
748
- this.config.handlers?.onSessionEnd?.(issue.id, null, repository.id);
749
533
  }
750
534
  /**
751
535
  * Handle Claude messages
752
536
  */
753
- async handleClaudeMessage(commentId, message, repositoryId) {
754
- // Get issue ID from comment mapping
755
- const issueId = this.commentToIssue.get(commentId);
756
- if (!issueId) {
757
- console.error(`[EdgeWorker] No issue mapping found for comment ${commentId}`);
758
- return;
759
- }
760
- // Emit generic message event
761
- this.emit('claude:message', issueId, message, repositoryId);
762
- this.config.handlers?.onClaudeMessage?.(issueId, message, repositoryId);
763
- // Handle specific messages
764
- if (message.type === 'assistant') {
765
- const content = this.extractTextContent(message);
766
- if (content) {
767
- this.emit('claude:response', issueId, content, repositoryId);
768
- // Don't post assistant messages anymore - wait for result
769
- }
770
- // Also check for tool use in assistant messages
771
- if ('message' in message && message.message && 'content' in message.message) {
772
- const messageContent = Array.isArray(message.message.content) ? message.message.content : [message.message.content];
773
- for (const item of messageContent) {
774
- if (item && typeof item === 'object' && 'type' in item && item.type === 'tool_use') {
775
- this.emit('claude:tool-use', issueId, item.name, item.input, repositoryId);
776
- // Handle TodoWrite tool specifically
777
- if ('name' in item && item.name === 'TodoWrite' && 'input' in item && item.input?.todos) {
778
- console.log(`[EdgeWorker] Detected TodoWrite tool use with ${item.input.todos.length} todos`);
779
- await this.updateCommentWithTodos(item.input.todos, repositoryId, commentId);
780
- }
781
- }
782
- }
783
- }
784
- }
785
- else if (message.type === 'result') {
786
- if (message.subtype === 'success' && 'result' in message && message.result) {
787
- // Post the successful result to Linear
788
- // For comment-based sessions, reply to the root comment of this thread
789
- await this.postComment(issueId, message.result, repositoryId, commentId);
790
- }
791
- else if (message.subtype === 'error_max_turns' || message.subtype === 'error_during_execution') {
792
- // Handle error results
793
- const errorMessage = message.subtype === 'error_max_turns'
794
- ? 'Maximum turns reached'
795
- : 'Error during execution';
796
- this.handleError(new Error(`Claude error: ${errorMessage}`));
797
- // Handle token limit specifically for max turns error
798
- if (this.config.features?.enableTokenLimitHandling && message.subtype === 'error_max_turns') {
799
- await this.handleTokenLimit(commentId, repositoryId);
800
- }
801
- }
802
- }
803
- }
804
- /**
805
- * Handle Claude session completion (successful)
806
- */
807
- handleClaudeComplete(commentId, messages, repositoryId) {
808
- const issueId = this.commentToIssue.get(commentId);
809
- console.log(`[EdgeWorker] Claude session completed for comment thread ${commentId} (issue ${issueId}) with ${messages.length} messages`);
810
- this.claudeRunners.delete(commentId);
811
- this.commentToRepo.delete(commentId);
812
- if (issueId) {
813
- this.commentToIssue.delete(commentId);
814
- this.emit('session:ended', issueId, 0, repositoryId); // 0 indicates success
815
- this.config.handlers?.onSessionEnd?.(issueId, 0, repositoryId);
537
+ async handleClaudeMessage(linearAgentActivitySessionId, message, repositoryId) {
538
+ const agentSessionManager = this.agentSessionManagers.get(repositoryId);
539
+ // Integrate with AgentSessionManager to capture streaming messages
540
+ if (agentSessionManager) {
541
+ await agentSessionManager.handleClaudeMessage(linearAgentActivitySessionId, message);
816
542
  }
817
543
  }
818
544
  /**
819
545
  * Handle Claude session error
546
+ * TODO: improve this
820
547
  */
821
- handleClaudeError(commentId, error, repositoryId) {
822
- const issueId = this.commentToIssue.get(commentId);
823
- console.error(`[EdgeWorker] Claude session error for comment thread ${commentId} (issue ${issueId}):`, error.message);
824
- console.error(`[EdgeWorker] Error type: ${error.constructor.name}`);
825
- if (error.stack) {
826
- console.error(`[EdgeWorker] Stack trace:`, error.stack);
827
- }
828
- // Clean up resources
829
- this.claudeRunners.delete(commentId);
830
- this.commentToRepo.delete(commentId);
831
- if (issueId) {
832
- this.commentToIssue.delete(commentId);
833
- // Emit events for external handlers
834
- this.emit('session:ended', issueId, 1, repositoryId); // 1 indicates error
835
- this.config.handlers?.onSessionEnd?.(issueId, 1, repositoryId);
836
- }
837
- console.log(`[EdgeWorker] Cleaned up resources for failed session ${commentId}`);
838
- }
839
- /**
840
- * Handle token limit by restarting session
841
- */
842
- async handleTokenLimit(commentId, repositoryId) {
843
- const session = this.sessionManager.getSession(commentId);
844
- if (!session)
845
- return;
846
- const repository = this.repositories.get(repositoryId);
847
- if (!repository)
848
- return;
849
- const issueId = this.commentToIssue.get(commentId);
850
- if (!issueId)
851
- return;
852
- // Post warning to Linear
853
- await this.postComment(issueId, '[System] Token limit reached. Starting fresh session with issue context.', repositoryId, commentId);
854
- // Fetch fresh LinearIssue data and restart session for this comment thread
855
- const linearIssue = await this.fetchFullIssueDetails(issueId, repositoryId);
856
- if (!linearIssue) {
857
- throw new Error(`Failed to fetch full issue details for ${issueId}`);
858
- }
859
- // For now, fall back to creating a new root comment handler
860
- // TODO: Implement proper comment thread restart
861
- await this.handleIssueAssignedWithFullIssue(linearIssue, repository);
548
+ async handleClaudeError(error) {
549
+ console.error('Unhandled claude error:', error);
862
550
  }
863
551
  /**
864
552
  * Fetch complete issue details from Linear API
@@ -880,6 +568,85 @@ export class EdgeWorker extends EventEmitter {
880
568
  return null;
881
569
  }
882
570
  }
571
+ /**
572
+ * Fetch issue labels for a given issue
573
+ */
574
+ async fetchIssueLabels(issue) {
575
+ try {
576
+ const labels = await issue.labels();
577
+ return labels.nodes.map(label => label.name);
578
+ }
579
+ catch (error) {
580
+ console.error(`[EdgeWorker] Failed to fetch labels for issue ${issue.id}:`, error);
581
+ return [];
582
+ }
583
+ }
584
+ /**
585
+ * Determine system prompt based on issue labels and repository configuration
586
+ */
587
+ async determineSystemPromptFromLabels(labels, repository) {
588
+ if (!repository.labelPrompts || labels.length === 0) {
589
+ return undefined;
590
+ }
591
+ // Check each prompt type for matching labels
592
+ const promptTypes = ['debugger', 'builder', 'scoper'];
593
+ for (const promptType of promptTypes) {
594
+ const configuredLabels = repository.labelPrompts[promptType];
595
+ if (configuredLabels && configuredLabels.some(label => labels.includes(label))) {
596
+ try {
597
+ // Load the prompt template from file
598
+ const __filename = fileURLToPath(import.meta.url);
599
+ const __dirname = dirname(__filename);
600
+ const promptPath = join(__dirname, '..', 'prompts', `${promptType}.md`);
601
+ const promptContent = await readFile(promptPath, 'utf-8');
602
+ console.log(`[EdgeWorker] Using ${promptType} system prompt for labels: ${labels.join(', ')}`);
603
+ return promptContent;
604
+ }
605
+ catch (error) {
606
+ console.error(`[EdgeWorker] Failed to load ${promptType} prompt template:`, error);
607
+ return undefined;
608
+ }
609
+ }
610
+ }
611
+ return undefined;
612
+ }
613
+ /**
614
+ * Build simplified prompt for label-based workflows
615
+ * @param issue Full Linear issue
616
+ * @param repository Repository configuration
617
+ * @returns Formatted prompt string
618
+ */
619
+ async buildLabelBasedPrompt(issue, repository, attachmentManifest = '') {
620
+ console.log(`[EdgeWorker] buildLabelBasedPrompt called for issue ${issue.identifier}`);
621
+ try {
622
+ // Load the label-based prompt template
623
+ const __filename = fileURLToPath(import.meta.url);
624
+ const __dirname = dirname(__filename);
625
+ const templatePath = resolve(__dirname, '../label-prompt-template.md');
626
+ console.log(`[EdgeWorker] Loading label prompt template from: ${templatePath}`);
627
+ const template = await readFile(templatePath, 'utf-8');
628
+ console.log(`[EdgeWorker] Template loaded, length: ${template.length} characters`);
629
+ // Build the simplified prompt with only essential variables
630
+ let prompt = template
631
+ .replace(/{{repository_name}}/g, repository.name)
632
+ .replace(/{{base_branch}}/g, repository.baseBranch)
633
+ .replace(/{{issue_id}}/g, issue.id || '')
634
+ .replace(/{{issue_identifier}}/g, issue.identifier || '')
635
+ .replace(/{{issue_title}}/g, issue.title || '')
636
+ .replace(/{{issue_description}}/g, issue.description || 'No description provided')
637
+ .replace(/{{issue_url}}/g, issue.url || '');
638
+ if (attachmentManifest) {
639
+ console.log(`[EdgeWorker] Adding attachment manifest to label-based prompt, length: ${attachmentManifest.length} characters`);
640
+ prompt = prompt + '\n\n' + attachmentManifest;
641
+ }
642
+ console.log(`[EdgeWorker] Label-based prompt built successfully, length: ${prompt.length} characters`);
643
+ return prompt;
644
+ }
645
+ catch (error) {
646
+ console.error(`[EdgeWorker] Error building label-based prompt:`, error);
647
+ throw error;
648
+ }
649
+ }
883
650
  /**
884
651
  * Convert full Linear SDK issue to CoreIssue interface for Session creation
885
652
  */
@@ -889,9 +656,7 @@ export class EdgeWorker extends EventEmitter {
889
656
  identifier: issue.identifier,
890
657
  title: issue.title || '',
891
658
  description: issue.description || undefined,
892
- getBranchName() {
893
- return issue.branchName; // Use the real branchName property!
894
- }
659
+ branchName: issue.branchName // Use the real branchName property!
895
660
  };
896
661
  }
897
662
  /**
@@ -1007,7 +772,7 @@ ${reply.body}
1007
772
  const comments = await linearClient.comments({
1008
773
  filter: { issue: { id: { eq: issue.id } } }
1009
774
  });
1010
- const commentNodes = await comments.nodes;
775
+ const commentNodes = comments.nodes;
1011
776
  if (commentNodes.length > 0) {
1012
777
  commentThreads = await this.formatCommentThreads(commentNodes);
1013
778
  console.log(`[EdgeWorker] Formatted ${commentNodes.length} comments into threads`);
@@ -1072,6 +837,11 @@ IMPORTANT: Focus specifically on addressing the new comment above. This is a new
1072
837
  console.log(`[EdgeWorker] Adding attachment manifest, length: ${attachmentManifest.length} characters`);
1073
838
  prompt = prompt + '\n\n' + attachmentManifest;
1074
839
  }
840
+ // Append repository-specific instruction if provided
841
+ if (repository.appendInstruction) {
842
+ console.log(`[EdgeWorker] Adding repository-specific instruction`);
843
+ prompt = prompt + '\n\n<repository-specific-instruction>\n' + repository.appendInstruction + '\n</repository-specific-instruction>';
844
+ }
1075
845
  console.log(`[EdgeWorker] Final prompt length: ${prompt.length} characters`);
1076
846
  return prompt;
1077
847
  }
@@ -1096,29 +866,6 @@ Base branch: ${repository.baseBranch}
1096
866
  ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ''}Please analyze this issue and help implement a solution.`;
1097
867
  }
1098
868
  }
1099
- /**
1100
- * Extract text content from Claude message
1101
- */
1102
- extractTextContent(sdkMessage) {
1103
- if (sdkMessage.type !== 'assistant')
1104
- return null;
1105
- const message = sdkMessage.message;
1106
- if (!message?.content)
1107
- return null;
1108
- if (typeof message.content === 'string') {
1109
- return message.content;
1110
- }
1111
- if (Array.isArray(message.content)) {
1112
- const textBlocks = [];
1113
- for (const block of message.content) {
1114
- if (typeof block === 'object' && block !== null && 'type' in block && block.type === 'text' && 'text' in block) {
1115
- textBlocks.push(block.text);
1116
- }
1117
- }
1118
- return textBlocks.join('');
1119
- }
1120
- return null;
1121
- }
1122
869
  /**
1123
870
  * Get connection status by repository ID
1124
871
  */
@@ -1129,12 +876,6 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ''}Please ana
1129
876
  }
1130
877
  return status;
1131
878
  }
1132
- /**
1133
- * Get active sessions
1134
- */
1135
- getActiveSessions() {
1136
- return Array.from(this.sessionManager.getAllSessions().keys());
1137
- }
1138
879
  /**
1139
880
  * Get NDJSON client by token (for testing purposes)
1140
881
  * @internal
@@ -1223,121 +964,48 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ''}Please ana
1223
964
  /**
1224
965
  * Post initial comment when assigned to issue
1225
966
  */
1226
- async postInitialComment(issueId, repositoryId) {
1227
- try {
1228
- const body = "I've been assigned to this issue and am getting started right away. I'll update this comment with my plan shortly.";
1229
- // Get the Linear client for this repository
1230
- const linearClient = this.linearClients.get(repositoryId);
1231
- if (!linearClient) {
1232
- throw new Error(`No Linear client found for repository ${repositoryId}`);
1233
- }
1234
- const commentData = {
1235
- issueId,
1236
- body
1237
- };
1238
- const response = await linearClient.createComment(commentData);
1239
- // Linear SDK returns CommentPayload with structure: { comment, success, lastSyncId }
1240
- if (response && response.comment) {
1241
- const comment = await response.comment;
1242
- console.log(`✅ Posted initial comment on issue ${issueId} (ID: ${comment.id})`);
1243
- // Track this as the latest agent reply for the thread (initial comment is its own root)
1244
- if (comment.id) {
1245
- this.commentToLatestAgentReply.set(comment.id, comment.id);
1246
- }
1247
- return comment;
1248
- }
1249
- else {
1250
- throw new Error('Initial comment creation failed');
1251
- }
1252
- }
1253
- catch (error) {
1254
- console.error(`Failed to create initial comment on issue ${issueId}:`, error);
1255
- return null;
1256
- }
1257
- }
967
+ // private async postInitialComment(issueId: string, repositoryId: string): Promise<void> {
968
+ // const body = "I'm getting started right away."
969
+ // // Get the Linear client for this repository
970
+ // const linearClient = this.linearClients.get(repositoryId)
971
+ // if (!linearClient) {
972
+ // throw new Error(`No Linear client found for repository ${repositoryId}`)
973
+ // }
974
+ // const commentData = {
975
+ // issueId,
976
+ // body
977
+ // }
978
+ // await linearClient.createComment(commentData)
979
+ // }
1258
980
  /**
1259
981
  * Post a comment to Linear
1260
982
  */
1261
983
  async postComment(issueId, body, repositoryId, parentId) {
1262
- try {
1263
- // Get the Linear client for this repository
1264
- const linearClient = this.linearClients.get(repositoryId);
1265
- if (!linearClient) {
1266
- throw new Error(`No Linear client found for repository ${repositoryId}`);
1267
- }
1268
- const commentData = {
1269
- issueId,
1270
- body
1271
- };
1272
- // Add parent ID if provided (for reply)
1273
- if (parentId) {
1274
- commentData.parentId = parentId;
1275
- }
1276
- const response = await linearClient.createComment(commentData);
1277
- // Linear SDK returns CommentPayload with structure: { comment, success, lastSyncId }
1278
- if (response && response.comment) {
1279
- console.log(`✅ Successfully created comment on issue ${issueId}`);
1280
- const comment = await response.comment;
1281
- if (comment?.id) {
1282
- console.log(`Comment ID: ${comment.id}`);
1283
- // Track this as the latest agent reply for the thread
1284
- // If parentId exists, that's the thread root; otherwise this comment IS the root
1285
- const threadRootCommentId = parentId || comment.id;
1286
- this.commentToLatestAgentReply.set(threadRootCommentId, comment.id);
1287
- return comment;
1288
- }
1289
- return null;
1290
- }
1291
- else {
1292
- throw new Error('Comment creation failed');
1293
- }
1294
- }
1295
- catch (error) {
1296
- console.error(`Failed to create comment on issue ${issueId}:`, error);
1297
- // Don't re-throw - just log the error so the edge worker doesn't crash
1298
- // TODO: Implement retry logic or token refresh
1299
- return null;
1300
- }
1301
- }
1302
- /**
1303
- * Update initial comment with TODO checklist
1304
- */
1305
- async updateCommentWithTodos(todos, repositoryId, threadRootCommentId) {
1306
- try {
1307
- // Get the latest agent comment in this thread
1308
- const commentId = this.commentToLatestAgentReply.get(threadRootCommentId) || threadRootCommentId;
1309
- if (!commentId) {
1310
- console.log('No comment ID found for thread, cannot update with todos');
1311
- return;
1312
- }
1313
- // Convert todos to Linear checklist format
1314
- const checklist = this.formatTodosAsChecklist(todos);
1315
- const body = `I've been assigned to this issue and am getting started right away. Here's my plan:\n\n${checklist}`;
1316
- // Get the Linear client
1317
- const linearClient = this.linearClients.get(repositoryId);
1318
- if (!linearClient) {
1319
- throw new Error(`No Linear client found for repository ${repositoryId}`);
1320
- }
1321
- // Update the comment
1322
- const response = await linearClient.updateComment(commentId, { body });
1323
- if (response) {
1324
- console.log(`✅ Updated comment ${commentId} with ${todos.length} todos`);
1325
- }
984
+ // Get the Linear client for this repository
985
+ const linearClient = this.linearClients.get(repositoryId);
986
+ if (!linearClient) {
987
+ throw new Error(`No Linear client found for repository ${repositoryId}`);
1326
988
  }
1327
- catch (error) {
1328
- console.error(`Failed to update comment with todos:`, error);
989
+ const commentData = {
990
+ issueId,
991
+ body
992
+ };
993
+ // Add parent ID if provided (for reply)
994
+ if (parentId) {
995
+ commentData.parentId = parentId;
1329
996
  }
997
+ await linearClient.createComment(commentData);
1330
998
  }
1331
999
  /**
1332
1000
  * Format todos as Linear checklist markdown
1333
1001
  */
1334
- formatTodosAsChecklist(todos) {
1335
- return todos.map(todo => {
1336
- const checkbox = todo.status === 'completed' ? '[x]' : '[ ]';
1337
- const statusEmoji = todo.status === 'in_progress' ? ' 🔄' : '';
1338
- return `- ${checkbox} ${todo.content}${statusEmoji}`;
1339
- }).join('\n');
1340
- }
1002
+ // private formatTodosAsChecklist(todos: Array<{id: string, content: string, status: string, priority: string}>): string {
1003
+ // return todos.map(todo => {
1004
+ // const checkbox = todo.status === 'completed' ? '[x]' : '[ ]'
1005
+ // const statusEmoji = todo.status === 'in_progress' ? ' 🔄' : ''
1006
+ // return `- ${checkbox} ${todo.content}${statusEmoji}`
1007
+ // }).join('\n')
1008
+ // }
1341
1009
  /**
1342
1010
  * Extract attachment URLs from text (issue description or comment)
1343
1011
  */
@@ -1380,7 +1048,7 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ''}Please ana
1380
1048
  const comments = await linearClient.comments({
1381
1049
  filter: { issue: { id: { eq: issue.id } } }
1382
1050
  });
1383
- const commentNodes = await comments.nodes;
1051
+ const commentNodes = comments.nodes;
1384
1052
  for (const comment of commentNodes) {
1385
1053
  const urls = this.extractAttachmentUrls(comment.body);
1386
1054
  commentUrls.push(...urls);
@@ -1545,62 +1213,61 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ''}Please ana
1545
1213
  }
1546
1214
  return manifest;
1547
1215
  }
1548
- /**
1549
- * Check if the agent (Cyrus) is mentioned in a comment
1550
- * @param comment Linear comment object from webhook data
1551
- * @param repository Repository configuration
1552
- * @returns true if the agent is mentioned, false otherwise
1553
- */
1554
- async isAgentMentionedInComment(comment, repository) {
1555
- try {
1556
- const linearClient = this.linearClients.get(repository.id);
1557
- if (!linearClient) {
1558
- console.warn(`No Linear client found for repository ${repository.id}`);
1559
- return false;
1560
- }
1561
- // Get the current user (agent) information
1562
- const viewer = await linearClient.viewer;
1563
- if (!viewer) {
1564
- console.warn('Unable to fetch viewer information');
1565
- return false;
1566
- }
1567
- // Check for mentions in the comment body
1568
- // Linear mentions can be in formats like:
1569
- // @username, @"Display Name", or @userId
1570
- const commentBody = comment.body;
1571
- // Check for mention by user ID (most reliable)
1572
- if (commentBody.includes(`@${viewer.id}`)) {
1573
- return true;
1574
- }
1575
- // Check for mention by name (case-insensitive)
1576
- if (viewer.name) {
1577
- const namePattern = new RegExp(`@"?${viewer.name}"?`, 'i');
1578
- if (namePattern.test(commentBody)) {
1579
- return true;
1580
- }
1581
- }
1582
- // Check for mention by display name (case-insensitive)
1583
- if (viewer.displayName && viewer.displayName !== viewer.name) {
1584
- const displayNamePattern = new RegExp(`@"?${viewer.displayName}"?`, 'i');
1585
- if (displayNamePattern.test(commentBody)) {
1586
- return true;
1587
- }
1588
- }
1589
- // Check for mention by email (less common but possible)
1590
- if (viewer.email) {
1591
- const emailPattern = new RegExp(`@"?${viewer.email}"?`, 'i');
1592
- if (emailPattern.test(commentBody)) {
1593
- return true;
1594
- }
1595
- }
1596
- return false;
1597
- }
1598
- catch (error) {
1599
- console.error('Failed to check if agent is mentioned in comment:', error);
1600
- // If we can't determine, err on the side of caution and allow the trigger
1601
- return true;
1602
- }
1603
- }
1216
+ // /**
1217
+ // * Check if the agent (Cyrus) is mentioned in a comment
1218
+ // * @param comment Linear comment object from webhook data
1219
+ // * @param repository Repository configuration
1220
+ // * @returns true if the agent is mentioned, false otherwise
1221
+ // */
1222
+ // private async isAgentMentionedInComment(comment: LinearWebhookComment, repository: RepositoryConfig): Promise<boolean> {
1223
+ // try {
1224
+ // const linearClient = this.linearClients.get(repository.id)
1225
+ // if (!linearClient) {
1226
+ // console.warn(`No Linear client found for repository ${repository.id}`)
1227
+ // return false
1228
+ // }
1229
+ // // Get the current user (agent) information
1230
+ // const viewer = await linearClient.viewer
1231
+ // if (!viewer) {
1232
+ // console.warn('Unable to fetch viewer information')
1233
+ // return false
1234
+ // }
1235
+ // // Check for mentions in the comment body
1236
+ // // Linear mentions can be in formats like:
1237
+ // // @username, @"Display Name", or @userId
1238
+ // const commentBody = comment.body
1239
+ // // Check for mention by user ID (most reliable)
1240
+ // if (commentBody.includes(`@${viewer.id}`)) {
1241
+ // return true
1242
+ // }
1243
+ // // Check for mention by name (case-insensitive)
1244
+ // if (viewer.name) {
1245
+ // const namePattern = new RegExp(`@"?${viewer.name}"?`, 'i')
1246
+ // if (namePattern.test(commentBody)) {
1247
+ // return true
1248
+ // }
1249
+ // }
1250
+ // // Check for mention by display name (case-insensitive)
1251
+ // if (viewer.displayName && viewer.displayName !== viewer.name) {
1252
+ // const displayNamePattern = new RegExp(`@"?${viewer.displayName}"?`, 'i')
1253
+ // if (displayNamePattern.test(commentBody)) {
1254
+ // return true
1255
+ // }
1256
+ // }
1257
+ // // Check for mention by email (less common but possible)
1258
+ // if (viewer.email) {
1259
+ // const emailPattern = new RegExp(`@"?${viewer.email}"?`, 'i')
1260
+ // if (emailPattern.test(commentBody)) {
1261
+ // return true
1262
+ // }
1263
+ // }
1264
+ // return false
1265
+ // } catch (error) {
1266
+ // console.error('Failed to check if agent is mentioned in comment:', error)
1267
+ // // If we can't determine, err on the side of caution and allow the trigger
1268
+ // return true
1269
+ // }
1270
+ // }
1604
1271
  /**
1605
1272
  * Build MCP configuration with automatic Linear server injection
1606
1273
  */
@@ -1635,5 +1302,166 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ''}Please ana
1635
1302
  const allTools = [...new Set([...baseToolsArray, ...linearMcpTools])];
1636
1303
  return allTools;
1637
1304
  }
1305
+ /**
1306
+ * Get Agent Sessions for an issue
1307
+ */
1308
+ getAgentSessionsForIssue(issueId, repositoryId) {
1309
+ const agentSessionManager = this.agentSessionManagers.get(repositoryId);
1310
+ if (!agentSessionManager) {
1311
+ return [];
1312
+ }
1313
+ return agentSessionManager.getSessionsByIssueId(issueId);
1314
+ }
1315
+ /**
1316
+ * Load persisted EdgeWorker state for all repositories
1317
+ */
1318
+ async loadPersistedState() {
1319
+ try {
1320
+ const state = await this.persistenceManager.loadEdgeWorkerState();
1321
+ if (state) {
1322
+ this.restoreMappings(state);
1323
+ console.log(`✅ Loaded persisted EdgeWorker state with ${Object.keys(state.agentSessions || {}).length} repositories`);
1324
+ }
1325
+ }
1326
+ catch (error) {
1327
+ console.error(`Failed to load persisted EdgeWorker state:`, error);
1328
+ }
1329
+ }
1330
+ /**
1331
+ * Save current EdgeWorker state for all repositories
1332
+ */
1333
+ async savePersistedState() {
1334
+ try {
1335
+ const state = this.serializeMappings();
1336
+ await this.persistenceManager.saveEdgeWorkerState(state);
1337
+ console.log(`✅ Saved EdgeWorker state for ${Object.keys(state.agentSessions || {}).length} repositories`);
1338
+ }
1339
+ catch (error) {
1340
+ console.error(`Failed to save persisted EdgeWorker state:`, error);
1341
+ }
1342
+ }
1343
+ /**
1344
+ * Serialize EdgeWorker mappings to a serializable format
1345
+ */
1346
+ serializeMappings() {
1347
+ // Serialize Agent Session state for all repositories
1348
+ const agentSessions = {};
1349
+ const agentSessionEntries = {};
1350
+ for (const [repositoryId, agentSessionManager] of this.agentSessionManagers.entries()) {
1351
+ const serializedState = agentSessionManager.serializeState();
1352
+ agentSessions[repositoryId] = serializedState.sessions;
1353
+ agentSessionEntries[repositoryId] = serializedState.entries;
1354
+ }
1355
+ return {
1356
+ agentSessions,
1357
+ agentSessionEntries,
1358
+ };
1359
+ }
1360
+ /**
1361
+ * Restore EdgeWorker mappings from serialized state
1362
+ */
1363
+ restoreMappings(state) {
1364
+ // Restore Agent Session state for all repositories
1365
+ if (state.agentSessions && state.agentSessionEntries) {
1366
+ for (const [repositoryId, agentSessionManager] of this.agentSessionManagers.entries()) {
1367
+ const repositorySessions = state.agentSessions[repositoryId] || {};
1368
+ const repositoryEntries = state.agentSessionEntries[repositoryId] || {};
1369
+ if (Object.keys(repositorySessions).length > 0 || Object.keys(repositoryEntries).length > 0) {
1370
+ agentSessionManager.restoreState(repositorySessions, repositoryEntries);
1371
+ console.log(`[EdgeWorker] Restored Agent Session state for repository ${repositoryId}`);
1372
+ }
1373
+ }
1374
+ }
1375
+ }
1376
+ /**
1377
+ * Post instant acknowledgment thought when agent session is created
1378
+ */
1379
+ async postInstantAcknowledgment(linearAgentActivitySessionId, repositoryId) {
1380
+ try {
1381
+ const linearClient = this.linearClients.get(repositoryId);
1382
+ if (!linearClient) {
1383
+ console.warn(`[EdgeWorker] No Linear client found for repository ${repositoryId}`);
1384
+ return;
1385
+ }
1386
+ const activityInput = {
1387
+ agentSessionId: linearAgentActivitySessionId,
1388
+ content: {
1389
+ type: 'thought',
1390
+ body: 'I\'ve received your request and I\'m starting to work on it. Let me analyze the issue and prepare my approach.'
1391
+ }
1392
+ };
1393
+ const result = await linearClient.createAgentActivity(activityInput);
1394
+ if (result.success) {
1395
+ console.log(`[EdgeWorker] Posted instant acknowledgment thought for session ${linearAgentActivitySessionId}`);
1396
+ }
1397
+ else {
1398
+ console.error(`[EdgeWorker] Failed to post instant acknowledgment:`, result);
1399
+ }
1400
+ }
1401
+ catch (error) {
1402
+ console.error(`[EdgeWorker] Error posting instant acknowledgment:`, error);
1403
+ }
1404
+ }
1405
+ /**
1406
+ * Post thought about system prompt selection based on labels
1407
+ */
1408
+ async postSystemPromptSelectionThought(linearAgentActivitySessionId, labels, repositoryId) {
1409
+ try {
1410
+ const linearClient = this.linearClients.get(repositoryId);
1411
+ if (!linearClient) {
1412
+ console.warn(`[EdgeWorker] No Linear client found for repository ${repositoryId}`);
1413
+ return;
1414
+ }
1415
+ // Determine which prompt type was selected and which label triggered it
1416
+ let selectedPromptType = null;
1417
+ let triggerLabel = null;
1418
+ const repository = Array.from(this.repositories.values()).find(r => r.id === repositoryId);
1419
+ if (repository?.labelPrompts) {
1420
+ // Check debugger labels
1421
+ const debuggerLabel = repository.labelPrompts.debugger?.find(label => labels.includes(label));
1422
+ if (debuggerLabel) {
1423
+ selectedPromptType = 'debugger';
1424
+ triggerLabel = debuggerLabel;
1425
+ }
1426
+ else {
1427
+ // Check builder labels
1428
+ const builderLabel = repository.labelPrompts.builder?.find(label => labels.includes(label));
1429
+ if (builderLabel) {
1430
+ selectedPromptType = 'builder';
1431
+ triggerLabel = builderLabel;
1432
+ }
1433
+ else {
1434
+ // Check scoper labels
1435
+ const scoperLabel = repository.labelPrompts.scoper?.find(label => labels.includes(label));
1436
+ if (scoperLabel) {
1437
+ selectedPromptType = 'scoper';
1438
+ triggerLabel = scoperLabel;
1439
+ }
1440
+ }
1441
+ }
1442
+ }
1443
+ // Only post if a role was actually triggered
1444
+ if (!selectedPromptType || !triggerLabel) {
1445
+ return;
1446
+ }
1447
+ const activityInput = {
1448
+ agentSessionId: linearAgentActivitySessionId,
1449
+ content: {
1450
+ type: 'thought',
1451
+ body: `Entering '${selectedPromptType}' mode because of the '${triggerLabel}' label. I'll follow the ${selectedPromptType} process...`
1452
+ }
1453
+ };
1454
+ const result = await linearClient.createAgentActivity(activityInput);
1455
+ if (result.success) {
1456
+ console.log(`[EdgeWorker] Posted system prompt selection thought for session ${linearAgentActivitySessionId} (${selectedPromptType} mode)`);
1457
+ }
1458
+ else {
1459
+ console.error(`[EdgeWorker] Failed to post system prompt selection thought:`, result);
1460
+ }
1461
+ }
1462
+ catch (error) {
1463
+ console.error(`[EdgeWorker] Error posting system prompt selection thought:`, error);
1464
+ }
1465
+ }
1638
1466
  }
1639
1467
  //# sourceMappingURL=EdgeWorker.js.map