cyrus-edge-worker 0.0.18 → 0.0.19

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,38 +2,33 @@ 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, PersistenceManager } 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;
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
23
26
  persistenceManager;
24
- claudeRunners = new Map(); // Maps comment ID to ClaudeRunner
25
- commentToRepo = new Map(); // Maps comment ID to repository ID
26
- commentToIssue = new Map(); // Maps comment ID to issue ID
27
- commentToLatestAgentReply = new Map(); // Maps thread root comment ID to latest agent comment
28
- issueToCommentThreads = new Map(); // Maps issue ID to all comment thread IDs
29
- tokenToClientId = new Map(); // Maps token to NDJSON client ID
30
- issueToReplyContext = new Map(); // Maps issue ID to reply context
31
27
  sharedApplicationServer;
32
28
  constructor(config) {
33
29
  super();
34
30
  this.config = config;
35
31
  this.persistenceManager = new PersistenceManager();
36
- this.sessionManager = new SessionManager(this.persistenceManager);
37
32
  // Initialize shared application server
38
33
  const serverPort = config.serverPort || config.webhookPort || 3456;
39
34
  const serverHost = config.serverHost || 'localhost';
@@ -47,9 +42,12 @@ export class EdgeWorker extends EventEmitter {
47
42
  if (repo.isActive !== false) {
48
43
  this.repositories.set(repo.id, repo);
49
44
  // Create Linear client for this repository's workspace
50
- this.linearClients.set(repo.id, new LinearClient({
45
+ const linearClient = new LinearClient({
51
46
  accessToken: repo.linearToken
52
- }));
47
+ });
48
+ this.linearClients.set(repo.id, linearClient);
49
+ // Create AgentSessionManager for this repository
50
+ this.agentSessionManagers.set(repo.id, new AgentSessionManager(linearClient));
53
51
  }
54
52
  }
55
53
  // Group repositories by token to minimize NDJSON connections
@@ -96,8 +94,6 @@ export class EdgeWorker extends EventEmitter {
96
94
  // Store with the first repo's ID as the key (for error messages)
97
95
  // But also store the token mapping for lookup
98
96
  this.ndjsonClients.set(primaryRepoId, ndjsonClient);
99
- // Store token to client mapping for other lookups if needed
100
- this.tokenToClientId.set(token, primaryRepoId);
101
97
  }
102
98
  }
103
99
  /**
@@ -157,9 +153,21 @@ export class EdgeWorker extends EventEmitter {
157
153
  * Stop the edge worker
158
154
  */
159
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
+ }
160
168
  // Kill all Claude processes with null checking
161
- for (const [, runner] of this.claudeRunners) {
162
- if (runner && typeof runner.stop === 'function') {
169
+ for (const runner of claudeRunners) {
170
+ if (runner) {
163
171
  try {
164
172
  runner.stop();
165
173
  }
@@ -168,15 +176,6 @@ export class EdgeWorker extends EventEmitter {
168
176
  }
169
177
  }
170
178
  }
171
- this.claudeRunners.clear();
172
- // Clear all sessions
173
- for (const [commentId] of this.sessionManager.getAllSessions()) {
174
- this.sessionManager.removeSession(commentId);
175
- }
176
- this.commentToRepo.clear();
177
- this.commentToIssue.clear();
178
- this.commentToLatestAgentReply.clear();
179
- this.issueToCommentThreads.clear();
180
179
  // Disconnect all NDJSON clients
181
180
  for (const client of this.ndjsonClients.values()) {
182
181
  client.disconnect();
@@ -208,25 +207,6 @@ export class EdgeWorker extends EventEmitter {
208
207
  this.emit('error', error);
209
208
  this.config.handlers?.onError?.(error);
210
209
  }
211
- /**
212
- * Check if Claude logs exist for a workspace
213
- */
214
- async hasExistingLogs(workspaceName) {
215
- try {
216
- const logsDir = join(homedir(), '.cyrus', 'logs', workspaceName);
217
- // Check if directory exists
218
- if (!existsSync(logsDir)) {
219
- return false;
220
- }
221
- // Check if directory has any log files
222
- const files = await readdir(logsDir);
223
- return files.some(file => file.endsWith('.jsonl'));
224
- }
225
- catch (error) {
226
- console.error(`Failed to check logs for workspace ${workspaceName}:`, error);
227
- return false;
228
- }
229
- }
230
210
  /**
231
211
  * Handle webhook events from proxy - now accepts native webhook payloads
232
212
  */
@@ -252,18 +232,29 @@ export class EdgeWorker extends EventEmitter {
252
232
  console.log(`[EdgeWorker] Webhook matched to repository: ${repository.name}`);
253
233
  try {
254
234
  // Handle specific webhook types with proper typing
235
+ // NOTE: Traditional webhooks (assigned, comment) are disabled in favor of agent session events
255
236
  if (isIssueAssignedWebhook(webhook)) {
256
- await this.handleIssueAssignedWebhook(webhook, repository);
237
+ console.log(`[EdgeWorker] Ignoring traditional issue assigned webhook - using agent session events instead`);
238
+ return;
257
239
  }
258
240
  else if (isIssueCommentMentionWebhook(webhook)) {
259
- await this.handleIssueCommentMentionWebhook(webhook, repository);
241
+ console.log(`[EdgeWorker] Ignoring traditional comment mention webhook - using agent session events instead`);
242
+ return;
260
243
  }
261
244
  else if (isIssueNewCommentWebhook(webhook)) {
262
- await this.handleIssueNewCommentWebhook(webhook, repository);
245
+ console.log(`[EdgeWorker] Ignoring traditional new comment webhook - using agent session events instead`);
246
+ return;
263
247
  }
264
248
  else if (isIssueUnassignedWebhook(webhook)) {
249
+ // Keep unassigned webhook active
265
250
  await this.handleIssueUnassignedWebhook(webhook, repository);
266
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
+ }
267
258
  else {
268
259
  console.log(`Unhandled webhook type: ${webhook.action}`);
269
260
  }
@@ -274,32 +265,6 @@ export class EdgeWorker extends EventEmitter {
274
265
  // The error has been logged and individual webhook failures shouldn't crash the entire system
275
266
  }
276
267
  }
277
- /**
278
- * Handle issue assignment webhook
279
- */
280
- async handleIssueAssignedWebhook(webhook, repository) {
281
- console.log(`[EdgeWorker] Handling issue assignment: ${webhook.notification.issue.identifier}`);
282
- await this.handleIssueAssigned(webhook.notification.issue, repository);
283
- }
284
- /**
285
- * Handle issue comment mention webhook
286
- */
287
- async handleIssueCommentMentionWebhook(webhook, repository) {
288
- console.log(`[EdgeWorker] Handling comment mention: ${webhook.notification.issue.identifier}`);
289
- await this.handleNewComment(webhook.notification.issue, webhook.notification.comment, repository);
290
- }
291
- /**
292
- * Handle issue new comment webhook
293
- */
294
- async handleIssueNewCommentWebhook(webhook, repository) {
295
- console.log(`[EdgeWorker] Handling new comment: ${webhook.notification.issue.identifier}`);
296
- // Check if the comment mentions the agent (Cyrus) before proceeding
297
- if (!(await this.isAgentMentionedInComment(webhook.notification.comment, repository))) {
298
- console.log(`[EdgeWorker] Comment does not mention agent, ignoring: ${webhook.notification.issue.identifier}`);
299
- return;
300
- }
301
- await this.handleNewComment(webhook.notification.issue, webhook.notification.comment, repository);
302
- }
303
268
  /**
304
269
  * Handle issue unassignment webhook
305
270
  */
@@ -318,50 +283,73 @@ export class EdgeWorker extends EventEmitter {
318
283
  const workspaceId = webhook.organizationId;
319
284
  if (!workspaceId)
320
285
  return repos[0] || null; // Fallback to first repo if no workspace ID
321
- // Try team-based routing first
322
- const teamKey = webhook.notification?.issue?.team?.key;
323
- if (teamKey) {
324
- const repo = repos.find(r => r.teamKeys && r.teamKeys.includes(teamKey));
325
- if (repo)
326
- return repo;
327
- }
328
- // Try parsing issue identifier as fallback
329
- const issueId = webhook.notification?.issue?.identifier;
330
- if (issueId && issueId.includes('-')) {
331
- const prefix = issueId.split('-')[0];
332
- if (prefix) {
333
- 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));
334
291
  if (repo)
335
292
  return repo;
336
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
+ }
337
323
  }
338
324
  // Original workspace fallback - find first repo without teamKeys or matching workspace
339
325
  return repos.find(repo => repo.linearWorkspaceId === workspaceId && (!repo.teamKeys || repo.teamKeys.length === 0)) || repos.find(repo => repo.linearWorkspaceId === workspaceId) || null;
340
326
  }
341
327
  /**
342
- * Handle issue assignment
343
- * @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
344
331
  * @param repository Repository configuration
345
332
  */
346
- async handleIssueAssigned(issue, repository) {
347
- 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);
348
346
  // Fetch full Linear issue details immediately
349
347
  const fullIssue = await this.fetchFullIssueDetails(issue.id, repository.id);
350
348
  if (!fullIssue) {
351
349
  throw new Error(`Failed to fetch full issue details for ${issue.id}`);
352
350
  }
353
- console.log(`[EdgeWorker] Fetched full issue details for ${issue.identifier}`);
354
- await this.handleIssueAssignedWithFullIssue(fullIssue, repository);
355
- }
356
- async handleIssueAssignedWithFullIssue(fullIssue, repository) {
357
- console.log(`[EdgeWorker] handleIssueAssignedWithFullIssue started for issue ${fullIssue.identifier} (${fullIssue.id})`);
358
- // Move issue to started state automatically
351
+ // Move issue to started state automatically, in case it's not already
359
352
  await this.moveIssueToStartedState(fullIssue, repository.id);
360
- // Post initial comment immediately
361
- const initialComment = await this.postInitialComment(fullIssue.id, repository.id);
362
- if (!initialComment?.id) {
363
- throw new Error(`Failed to create initial comment for issue ${fullIssue.identifier}`);
364
- }
365
353
  // Create workspace using full issue data
366
354
  const workspace = this.config.handlers?.createWorkspace
367
355
  ? await this.config.handlers.createWorkspace(fullIssue, repository)
@@ -370,6 +358,8 @@ export class EdgeWorker extends EventEmitter {
370
358
  isGitWorktree: false
371
359
  };
372
360
  console.log(`[EdgeWorker] Workspace created at: ${workspace.path}`);
361
+ const issueMinimal = this.convertLinearIssueToCore(fullIssue);
362
+ agentSessionManager.createLinearAgentSession(linearAgentActivitySessionId, issue.id, issueMinimal, workspace);
373
363
  // Download attachments before creating Claude runner
374
364
  const attachmentResult = await this.downloadIssueAttachments(fullIssue, repository, workspace.path);
375
365
  // Build allowed directories list
@@ -379,7 +369,18 @@ export class EdgeWorker extends EventEmitter {
379
369
  }
380
370
  // Build allowed tools list with Linear MCP tools
381
371
  const allowedTools = this.buildAllowedTools(repository);
382
- // Create Claude runner with attachment directory access
372
+ // Fetch issue labels and determine system prompt
373
+ const labels = await this.fetchIssueLabels(fullIssue);
374
+ const systemPromptResult = await this.determineSystemPromptFromLabels(labels, repository);
375
+ const systemPrompt = systemPromptResult?.prompt;
376
+ const systemPromptVersion = systemPromptResult?.version;
377
+ // Post thought about system prompt selection
378
+ if (systemPrompt) {
379
+ await this.postSystemPromptSelectionThought(linearAgentActivitySessionId, labels, repository.id);
380
+ }
381
+ // Create Claude runner with attachment directory access and optional system prompt
382
+ // Always append the last message marker to prevent duplication
383
+ const lastMessageMarker = '\n\n___LAST_MESSAGE_MARKER___\nIMPORTANT: When providing your final summary response, include the special marker ___LAST_MESSAGE_MARKER___ at the very beginning of your message. This marker will be automatically removed before posting.';
383
384
  const runner = new ClaudeRunner({
384
385
  workingDirectory: workspace.path,
385
386
  allowedTools,
@@ -387,27 +388,13 @@ export class EdgeWorker extends EventEmitter {
387
388
  workspaceName: fullIssue.identifier,
388
389
  mcpConfigPath: repository.mcpConfigPath,
389
390
  mcpConfig: this.buildMcpConfig(repository),
390
- onMessage: (message) => this.handleClaudeMessage(initialComment.id, message, repository.id),
391
- onComplete: (messages) => this.handleClaudeComplete(initialComment.id, messages, repository.id),
392
- onError: (error) => this.handleClaudeError(initialComment.id, error, repository.id)
391
+ appendSystemPrompt: (systemPrompt || '') + lastMessageMarker,
392
+ onMessage: (message) => this.handleClaudeMessage(linearAgentActivitySessionId, message, repository.id),
393
+ // onComplete: (messages) => this.handleClaudeComplete(initialComment.id, messages, repository.id),
394
+ onError: (error) => this.handleClaudeError(error)
393
395
  });
394
396
  // Store runner by comment ID
395
- this.claudeRunners.set(initialComment.id, runner);
396
- this.commentToRepo.set(initialComment.id, repository.id);
397
- this.commentToIssue.set(initialComment.id, fullIssue.id);
398
- // Create session using full Linear issue (convert LinearIssue to CoreIssue)
399
- const session = new Session({
400
- issue: this.convertLinearIssueToCore(fullIssue),
401
- workspace,
402
- startedAt: new Date(),
403
- agentRootCommentId: initialComment.id
404
- });
405
- // Store session by comment ID
406
- this.sessionManager.addSession(initialComment.id, session);
407
- // Track this thread for the issue
408
- const threads = this.issueToCommentThreads.get(fullIssue.id) || new Set();
409
- threads.add(initialComment.id);
410
- this.issueToCommentThreads.set(fullIssue.id, threads);
397
+ agentSessionManager.addClaudeRunner(linearAgentActivitySessionId, runner);
411
398
  // Save state after mapping changes
412
399
  await this.savePersistedState();
413
400
  // Emit events using full Linear issue
@@ -416,105 +403,24 @@ export class EdgeWorker extends EventEmitter {
416
403
  // Build and start Claude with initial prompt using full issue (streaming mode)
417
404
  console.log(`[EdgeWorker] Building initial prompt for issue ${fullIssue.identifier}`);
418
405
  try {
419
- // Use buildPromptV2 without a new comment for issue assignment
420
- const prompt = await this.buildPromptV2(fullIssue, repository, undefined, attachmentResult.manifest);
421
- console.log(`[EdgeWorker] Initial prompt built successfully, length: ${prompt.length} characters`);
406
+ // Choose the appropriate prompt builder based on system prompt availability
407
+ const promptResult = systemPrompt
408
+ ? await this.buildLabelBasedPrompt(fullIssue, repository, attachmentResult.manifest)
409
+ : await this.buildPromptV2(fullIssue, repository, undefined, attachmentResult.manifest);
410
+ const { prompt, version: userPromptVersion } = promptResult;
411
+ // Update runner with version information
412
+ if (userPromptVersion || systemPromptVersion) {
413
+ runner.updatePromptVersions({
414
+ userPromptVersion,
415
+ systemPromptVersion
416
+ });
417
+ }
418
+ console.log(`[EdgeWorker] Initial prompt built successfully using ${systemPrompt ? 'label-based' : 'fallback'} workflow, length: ${prompt.length} characters`);
422
419
  console.log(`[EdgeWorker] Starting Claude streaming session`);
423
420
  const sessionInfo = await runner.startStreaming(prompt);
424
421
  console.log(`[EdgeWorker] Claude streaming session started: ${sessionInfo.sessionId}`);
425
- }
426
- catch (error) {
427
- console.error(`[EdgeWorker] Error in prompt building/starting:`, error);
428
- throw error;
429
- }
430
- }
431
- /**
432
- * Find the root comment of a comment thread by traversing parent relationships
433
- */
434
- /**
435
- * Handle new root comment - creates a new Claude session for a new comment thread
436
- * @param issue Linear issue object from webhook data
437
- * @param comment Linear comment object from webhook data
438
- * @param repository Repository configuration
439
- */
440
- async handleNewRootComment(issue, comment, repository) {
441
- console.log(`[EdgeWorker] Handling new root comment ${comment.id} on issue ${issue.identifier}`);
442
- // Fetch full Linear issue details
443
- const fullIssue = await this.fetchFullIssueDetails(issue.id, repository.id);
444
- if (!fullIssue) {
445
- throw new Error(`Failed to fetch full issue details for ${issue.id}`);
446
- }
447
- // Post immediate acknowledgment
448
- 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
449
- );
450
- if (!acknowledgment?.id) {
451
- throw new Error(`Failed to create acknowledgment for root comment ${comment.id}`);
452
- }
453
- // Create or get workspace
454
- const workspace = this.config.handlers?.createWorkspace
455
- ? await this.config.handlers.createWorkspace(fullIssue, repository)
456
- : {
457
- path: `${repository.workspaceBaseDir}/${fullIssue.identifier}`,
458
- isGitWorktree: false
459
- };
460
- console.log(`[EdgeWorker] Using workspace at: ${workspace.path}`);
461
- // Download attachments if any
462
- const attachmentResult = await this.downloadIssueAttachments(fullIssue, repository, workspace.path);
463
- // Build allowed directories and tools
464
- const allowedDirectories = [];
465
- if (attachmentResult.attachmentsDir) {
466
- allowedDirectories.push(attachmentResult.attachmentsDir);
467
- }
468
- const allowedTools = this.buildAllowedTools(repository);
469
- // Create Claude runner for this new comment thread
470
- const runner = new ClaudeRunner({
471
- workingDirectory: workspace.path,
472
- allowedTools,
473
- allowedDirectories,
474
- workspaceName: fullIssue.identifier,
475
- mcpConfigPath: repository.mcpConfigPath,
476
- mcpConfig: this.buildMcpConfig(repository),
477
- onMessage: (message) => {
478
- // Update session with Claude session ID when first received
479
- if (!session.claudeSessionId && message.session_id) {
480
- session.claudeSessionId = message.session_id;
481
- console.log(`[EdgeWorker] Claude session ID assigned: ${message.session_id}`);
482
- }
483
- this.handleClaudeMessage(acknowledgment.id, message, repository.id);
484
- },
485
- onComplete: (messages) => this.handleClaudeComplete(acknowledgment.id, messages, repository.id),
486
- onError: (error) => this.handleClaudeError(acknowledgment.id, error, repository.id)
487
- });
488
- // Store runner and mappings
489
- this.claudeRunners.set(comment.id, runner);
490
- this.commentToRepo.set(comment.id, repository.id);
491
- this.commentToIssue.set(comment.id, fullIssue.id);
492
- // Create session for this new comment thread
493
- const session = new Session({
494
- issue: this.convertLinearIssueToCore(fullIssue),
495
- workspace,
496
- startedAt: new Date(),
497
- agentRootCommentId: comment.id
498
- });
499
- this.sessionManager.addSession(comment.id, session);
500
- // Track this new thread for the issue
501
- const threads = this.issueToCommentThreads.get(issue.id) || new Set();
502
- threads.add(comment.id);
503
- this.issueToCommentThreads.set(issue.id, threads);
504
- // Track latest reply
505
- this.commentToLatestAgentReply.set(comment.id, acknowledgment.id);
506
- // Save state after mapping changes
507
- await this.savePersistedState();
508
- // Emit session start event
509
- this.config.handlers?.onSessionStart?.(fullIssue.id, fullIssue, repository.id);
510
- // Build prompt with new comment focus using V2 template
511
- console.log(`[EdgeWorker] Building prompt for new root comment`);
512
- try {
513
- const prompt = await this.buildPromptV2(fullIssue, repository, comment, attachmentResult.manifest);
514
- console.log(`[EdgeWorker] Prompt built successfully, length: ${prompt.length} characters`);
515
- console.log(`[EdgeWorker] Starting Claude streaming session for new comment thread`);
516
- const sessionInfo = await runner.startStreaming(prompt);
517
- console.log(`[EdgeWorker] Claude streaming session started: ${sessionInfo.sessionId}`);
422
+ // Note: AgentSessionManager will be initialized automatically when the first system message
423
+ // is received via handleClaudeMessage() callback
518
424
  }
519
425
  catch (error) {
520
426
  console.error(`[EdgeWorker] Error in prompt building/starting:`, error);
@@ -527,191 +433,93 @@ export class EdgeWorker extends EventEmitter {
527
433
  * @param comment Linear comment object from webhook data
528
434
  * @param repository Repository configuration
529
435
  */
530
- async handleNewComment(issue, comment, repository) {
531
- // Check if continuation is enabled
532
- if (!this.config.features?.enableContinuation) {
533
- console.log('Continuation not enabled, ignoring comment');
436
+ async handleUserPostedAgentActivity(webhook, repository) {
437
+ // Look for existing session for this comment thread
438
+ const { agentSession } = webhook;
439
+ const linearAgentActivitySessionId = agentSession.id;
440
+ const { issue } = agentSession;
441
+ const promptBody = webhook.agentActivity.content.body;
442
+ // Initialize the agent session in AgentSessionManager
443
+ const agentSessionManager = this.agentSessionManagers.get(repository.id);
444
+ if (!agentSessionManager) {
445
+ console.error('Unexpected: There was no agentSessionManage for the repository with id', repository.id);
534
446
  return;
535
447
  }
536
- // Fetch full Linear issue details
537
- const fullIssue = await this.fetchFullIssueDetails(issue.id, repository.id);
538
- if (!fullIssue) {
539
- throw new Error(`Failed to fetch full issue details for ${issue.id}`);
540
- }
541
- // IMPORTANT: Linear has exactly ONE level of comment nesting:
542
- // - Root comments (no parent)
543
- // - Reply comments (have a parent, which must be a root comment)
544
- // There is NO recursion - a reply cannot have replies
545
- // Fetch full comment to determine if this is a root or reply
546
- let parentCommentId = null;
547
- let rootCommentId = comment.id; // Default to this comment being the root
548
- try {
549
- const linearClient = this.linearClients.get(repository.id);
550
- if (linearClient && comment.id) {
551
- const fullComment = await linearClient.comment({ id: comment.id });
552
- // Check if comment has a parent (making it a reply)
553
- if (fullComment.parent) {
554
- const parent = await fullComment.parent;
555
- if (parent?.id) {
556
- parentCommentId = parent.id;
557
- // In Linear's 2-level structure, the parent IS always the root
558
- // No need for recursion - replies can't have replies
559
- rootCommentId = parent.id;
560
- }
561
- }
562
- }
563
- }
564
- catch (error) {
565
- console.error('Failed to fetch full comment data:', error);
566
- }
567
- // Determine comment type based on whether it has a parent
568
- const isRootComment = parentCommentId === null;
569
- const threadRootCommentId = rootCommentId;
570
- console.log(`[EdgeWorker] Comment ${comment.id} - isRoot: ${isRootComment}, threadRoot: ${threadRootCommentId}, parent: ${parentCommentId}`);
571
- // Store reply context for Linear commenting
572
- // parentId will be: the parent comment ID (if this is a reply) OR this comment's ID (if root)
573
- // This ensures our bot's replies appear at the correct nesting level
574
- this.issueToReplyContext.set(issue.id, {
575
- commentId: comment.id,
576
- parentId: parentCommentId || comment.id
577
- });
578
- // Look for existing session for this comment thread
579
- let session = this.sessionManager.getSession(threadRootCommentId);
580
- // If no session exists, we need to create one
448
+ const session = agentSessionManager.getSession(linearAgentActivitySessionId);
581
449
  if (!session) {
582
- console.log(`No active session for issue ${issue.identifier}, checking for existing logs...`);
583
- // Check if we have existing logs for this issue
584
- const hasLogs = await this.hasExistingLogs(issue.identifier);
585
- if (!hasLogs) {
586
- console.log(`No existing logs found for ${issue.identifier}, treating as new assignment`);
587
- // Start fresh - treat it like a new assignment
588
- await this.handleIssueAssigned(issue, repository);
589
- return;
590
- }
591
- console.log(`Found existing logs for ${issue.identifier}, creating session for continuation`);
592
- // Create workspace (or get existing one)
593
- const workspace = this.config.handlers?.createWorkspace
594
- ? await this.config.handlers.createWorkspace(fullIssue, repository)
595
- : {
596
- path: `${repository.workspaceBaseDir}/${fullIssue.identifier}`,
597
- isGitWorktree: false
598
- };
599
- // Create session for this comment thread
600
- session = new Session({
601
- issue: this.convertLinearIssueToCore(fullIssue),
602
- workspace,
603
- process: null,
604
- startedAt: new Date(),
605
- agentRootCommentId: threadRootCommentId
606
- });
607
- this.sessionManager.addSession(threadRootCommentId, session);
608
- this.commentToRepo.set(threadRootCommentId, repository.id);
609
- this.commentToIssue.set(threadRootCommentId, issue.id);
610
- // Track this thread for the issue
611
- const threads = this.issueToCommentThreads.get(issue.id) || new Set();
612
- threads.add(threadRootCommentId);
613
- this.issueToCommentThreads.set(issue.id, threads);
614
- // Save state after mapping changes
615
- await this.savePersistedState();
450
+ console.error(`Unexpected: could not find Cyrus Agent Session for agent activity session: ${linearAgentActivitySessionId}`);
451
+ return;
616
452
  }
617
453
  // Check if there's an existing runner for this comment thread
618
- const existingRunner = this.claudeRunners.get(threadRootCommentId);
454
+ const existingRunner = session.claudeRunner;
619
455
  if (existingRunner && existingRunner.isStreaming()) {
620
- // Post immediate reply for streaming case
621
- // parentId ensures correct nesting: replies to parent if this is a reply, or to comment itself if root
622
- 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
623
- );
456
+ // Post instant acknowledgment for streaming case
457
+ await this.postInstantPromptedAcknowledgment(linearAgentActivitySessionId, repository.id, true);
624
458
  // Add comment to existing stream instead of restarting
625
- console.log(`[EdgeWorker] Adding comment to existing stream for thread ${threadRootCommentId}`);
626
- try {
627
- existingRunner.addStreamMessage(comment.body || '');
628
- return; // Exit early - comment has been added to stream
629
- }
630
- catch (error) {
631
- console.error(`[EdgeWorker] Failed to add comment to stream, will stop the existing session and start a new one: ${error}`);
632
- // Fall through to restart logic below
633
- }
634
- }
635
- // For root comments without existing sessions, call placeholder handler
636
- if (isRootComment && !session) {
637
- console.log(`[EdgeWorker] Detected new root comment ${comment.id}, delegating to handleNewRootComment`);
638
- await this.handleNewRootComment(issue, comment, repository);
639
- return;
459
+ console.log(`[EdgeWorker] Adding comment to existing stream for agent activity session ${linearAgentActivitySessionId}`);
460
+ existingRunner.addStreamMessage(promptBody);
461
+ return; // Exit early - comment has been added to stream
640
462
  }
641
- // Post immediate reply for continuing existing thread
642
- // parentId ensures correct nesting: replies to parent if this is a reply, or to comment itself if root
643
- 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
644
- );
463
+ // Post instant acknowledgment for non-streaming case
464
+ await this.postInstantPromptedAcknowledgment(linearAgentActivitySessionId, repository.id, false);
645
465
  // Stop existing runner if it's not streaming or stream addition failed
646
466
  if (existingRunner) {
647
467
  existingRunner.stop();
648
468
  }
469
+ if (!session.claudeSessionId) {
470
+ console.error(`Unexpected: Handling a 'prompted' webhook but did not find an existing claudeSessionId for the linearAgentActivitySessionId ${linearAgentActivitySessionId}. Not continuing.`);
471
+ return;
472
+ }
649
473
  try {
650
474
  // Build allowed tools list with Linear MCP tools
651
475
  const allowedTools = this.buildAllowedTools(repository);
476
+ // Fetch full issue details to get labels
477
+ const fullIssue = await this.fetchFullIssueDetails(issue.id, repository.id);
478
+ if (!fullIssue) {
479
+ throw new Error(`Failed to fetch full issue details for ${issue.id}`);
480
+ }
481
+ // Fetch issue labels and determine system prompt (same as in handleAgentSessionCreatedWebhook)
482
+ const labels = await this.fetchIssueLabels(fullIssue);
483
+ const systemPromptResult = await this.determineSystemPromptFromLabels(labels, repository);
484
+ const systemPrompt = systemPromptResult?.prompt;
652
485
  // Create new runner with resume mode if we have a Claude session ID
486
+ // Always append the last message marker to prevent duplication
487
+ const lastMessageMarker = '\n\n___LAST_MESSAGE_MARKER___\nIMPORTANT: When providing your final summary response, include the special marker ___LAST_MESSAGE_MARKER___ at the very beginning of your message. This marker will be automatically removed before posting.';
653
488
  const runner = new ClaudeRunner({
654
489
  workingDirectory: session.workspace.path,
655
490
  allowedTools,
656
- resumeSessionId: session.claudeSessionId || undefined,
491
+ resumeSessionId: session.claudeSessionId,
657
492
  workspaceName: issue.identifier,
658
493
  mcpConfigPath: repository.mcpConfigPath,
659
494
  mcpConfig: this.buildMcpConfig(repository),
495
+ appendSystemPrompt: (systemPrompt || '') + lastMessageMarker,
660
496
  onMessage: (message) => {
661
- // Update session with Claude session ID when first received
662
- if (!session.claudeSessionId && message.session_id) {
663
- session.claudeSessionId = message.session_id;
664
- console.log(`[EdgeWorker] Stored Claude session ID ${message.session_id} for comment thread ${threadRootCommentId}`);
665
- }
666
- // Check for continuation errors
667
- if (message.type === 'assistant' && 'message' in message && message.message?.content) {
668
- const content = Array.isArray(message.message.content) ? message.message.content : [message.message.content];
669
- for (const item of content) {
670
- if (item?.type === 'text' && item.text?.includes('tool_use` ids were found without `tool_result` blocks')) {
671
- console.log('Detected corrupted conversation history, will restart fresh');
672
- // Kill this runner
673
- runner.stop();
674
- // Remove from map
675
- this.claudeRunners.delete(threadRootCommentId);
676
- // Start fresh by calling root comment handler
677
- this.handleNewRootComment(issue, comment, repository).catch(error => {
678
- console.error(`[EdgeWorker] Failed to restart fresh session for comment thread ${threadRootCommentId}:`, error);
679
- // Clean up any partial state
680
- this.claudeRunners.delete(threadRootCommentId);
681
- this.commentToRepo.delete(threadRootCommentId);
682
- this.commentToIssue.delete(threadRootCommentId);
683
- // Emit error event to notify handlers
684
- this.emit('session:ended', threadRootCommentId, 1, repository.id);
685
- this.config.handlers?.onSessionEnd?.(threadRootCommentId, 1, repository.id);
686
- });
687
- return;
688
- }
689
- }
690
- }
691
- this.handleClaudeMessage(threadRootCommentId, message, repository.id);
497
+ this.handleClaudeMessage(linearAgentActivitySessionId, message, repository.id);
692
498
  },
693
- onComplete: (messages) => this.handleClaudeComplete(threadRootCommentId, messages, repository.id),
694
- onError: (error) => this.handleClaudeError(threadRootCommentId, error, repository.id)
499
+ // onComplete: (messages) => this.handleClaudeComplete(threadRootCommentId, messages, repository.id),
500
+ onError: (error) => this.handleClaudeError(error)
695
501
  });
696
502
  // Store new runner by comment thread root
697
- this.claudeRunners.set(threadRootCommentId, runner);
503
+ // Store runner by comment ID
504
+ agentSessionManager.addClaudeRunner(linearAgentActivitySessionId, runner);
505
+ // Save state after mapping changes
506
+ await this.savePersistedState();
698
507
  // Start streaming session with the comment as initial prompt
699
508
  console.log(`[EdgeWorker] Starting new streaming session for issue ${issue.identifier}`);
700
- await runner.startStreaming(comment.body || '');
509
+ await runner.startStreaming(promptBody);
701
510
  }
702
511
  catch (error) {
703
- console.error('Failed to continue conversation, starting fresh:', error);
512
+ console.error('Failed to continue conversation:', error);
704
513
  // Remove any partially created session
705
- this.sessionManager.removeSession(threadRootCommentId);
706
- this.commentToRepo.delete(threadRootCommentId);
707
- this.commentToIssue.delete(threadRootCommentId);
708
- // Start fresh for root comments, or fall back to issue assignment
709
- if (isRootComment) {
710
- await this.handleNewRootComment(issue, comment, repository);
711
- }
712
- else {
713
- await this.handleIssueAssigned(issue, repository);
714
- }
514
+ // this.sessionManager.removeSession(threadRootCommentId)
515
+ // this.commentToRepo.delete(threadRootCommentId)
516
+ // this.commentToIssue.delete(threadRootCommentId)
517
+ // // Start fresh for root comments, or fall back to issue assignment
518
+ // if (isRootComment) {
519
+ // await this.handleNewRootComment(issue, comment, repository)
520
+ // } else {
521
+ // await this.handleIssueAssigned(issue, repository)
522
+ // }
715
523
  }
716
524
  }
717
525
  /**
@@ -720,17 +528,18 @@ export class EdgeWorker extends EventEmitter {
720
528
  * @param repository Repository configuration
721
529
  */
722
530
  async handleIssueUnassigned(issue, repository) {
723
- // Get all comment threads for this issue
724
- const threadRootCommentIds = this.issueToCommentThreads.get(issue.id) || new Set();
531
+ const agentSessionManager = this.agentSessionManagers.get(repository.id);
532
+ if (!agentSessionManager) {
533
+ console.log('No agentSessionManager for unassigned issue, so no sessions to stop');
534
+ return;
535
+ }
536
+ // Get all Claude runners for this specific issue
537
+ const claudeRunners = agentSessionManager.getClaudeRunnersForIssue(issue.id);
725
538
  // Stop all Claude runners for this issue
726
- let activeThreadCount = 0;
727
- for (const threadRootCommentId of threadRootCommentIds) {
728
- const runner = this.claudeRunners.get(threadRootCommentId);
729
- if (runner) {
730
- console.log(`[EdgeWorker] Stopping Claude runner for thread ${threadRootCommentId}`);
731
- await runner.stop();
732
- activeThreadCount++;
733
- }
539
+ const activeThreadCount = claudeRunners.length;
540
+ for (const runner of claudeRunners) {
541
+ console.log(`[EdgeWorker] Stopping Claude runner for issue ${issue.identifier}`);
542
+ runner.stop();
734
543
  }
735
544
  // Post ONE farewell comment on the issue (not in any thread) if there were active sessions
736
545
  if (activeThreadCount > 0) {
@@ -738,135 +547,25 @@ export class EdgeWorker extends EventEmitter {
738
547
  // No parentId - post as a new comment on the issue
739
548
  );
740
549
  }
741
- // Clean up thread mappings for each stopped thread
742
- for (const threadRootCommentId of threadRootCommentIds) {
743
- // Remove from runners map
744
- this.claudeRunners.delete(threadRootCommentId);
745
- // Clean up comment mappings
746
- this.commentToRepo.delete(threadRootCommentId);
747
- this.commentToIssue.delete(threadRootCommentId);
748
- this.commentToLatestAgentReply.delete(threadRootCommentId);
749
- // Remove session
750
- this.sessionManager.removeSession(threadRootCommentId);
751
- }
752
- // Clean up issue-level mappings
753
- this.issueToCommentThreads.delete(issue.id);
754
- this.issueToReplyContext.delete(issue.id);
755
- // Save state after mapping changes
756
- await this.savePersistedState();
757
550
  // Emit events
758
551
  console.log(`[EdgeWorker] Stopped ${activeThreadCount} sessions for unassigned issue ${issue.identifier}`);
759
- this.emit('session:ended', issue.id, null, repository.id);
760
- this.config.handlers?.onSessionEnd?.(issue.id, null, repository.id);
761
552
  }
762
553
  /**
763
554
  * Handle Claude messages
764
555
  */
765
- async handleClaudeMessage(commentId, message, repositoryId) {
766
- // Get issue ID from comment mapping
767
- const issueId = this.commentToIssue.get(commentId);
768
- if (!issueId) {
769
- console.error(`[EdgeWorker] No issue mapping found for comment ${commentId}`);
770
- return;
771
- }
772
- // Emit generic message event
773
- this.emit('claude:message', issueId, message, repositoryId);
774
- this.config.handlers?.onClaudeMessage?.(issueId, message, repositoryId);
775
- // Handle specific messages
776
- if (message.type === 'assistant') {
777
- const content = this.extractTextContent(message);
778
- if (content) {
779
- this.emit('claude:response', issueId, content, repositoryId);
780
- // Don't post assistant messages anymore - wait for result
781
- }
782
- // Also check for tool use in assistant messages
783
- if ('message' in message && message.message && 'content' in message.message) {
784
- const messageContent = Array.isArray(message.message.content) ? message.message.content : [message.message.content];
785
- for (const item of messageContent) {
786
- if (item && typeof item === 'object' && 'type' in item && item.type === 'tool_use') {
787
- this.emit('claude:tool-use', issueId, item.name, item.input, repositoryId);
788
- // Handle TodoWrite tool specifically
789
- if ('name' in item && item.name === 'TodoWrite' && 'input' in item && item.input?.todos) {
790
- console.log(`[EdgeWorker] Detected TodoWrite tool use with ${item.input.todos.length} todos`);
791
- await this.updateCommentWithTodos(item.input.todos, repositoryId, commentId);
792
- }
793
- }
794
- }
795
- }
796
- }
797
- else if (message.type === 'result') {
798
- if (message.subtype === 'success' && 'result' in message && message.result) {
799
- // Post the successful result to Linear
800
- // For comment-based sessions, reply to the root comment of this thread
801
- await this.postComment(issueId, message.result, repositoryId, commentId);
802
- }
803
- else if (message.subtype === 'error_max_turns' || message.subtype === 'error_during_execution') {
804
- // Handle error results
805
- const errorMessage = message.subtype === 'error_max_turns'
806
- ? 'Maximum turns reached'
807
- : 'Error during execution';
808
- this.handleError(new Error(`Claude error: ${errorMessage}`));
809
- // Handle token limit specifically for max turns error
810
- if (this.config.features?.enableTokenLimitHandling && message.subtype === 'error_max_turns') {
811
- await this.handleTokenLimit(commentId, repositoryId);
812
- }
813
- }
814
- }
815
- }
816
- /**
817
- * Handle Claude session completion (successful)
818
- */
819
- async handleClaudeComplete(commentId, messages, repositoryId) {
820
- const issueId = this.commentToIssue.get(commentId);
821
- console.log(`[EdgeWorker] Claude session completed for comment thread ${commentId} (issue ${issueId}) with ${messages.length} messages`);
822
- this.claudeRunners.delete(commentId);
823
- if (issueId) {
824
- this.emit('session:ended', issueId, 0, repositoryId); // 0 indicates success
825
- this.config.handlers?.onSessionEnd?.(issueId, 0, repositoryId);
556
+ async handleClaudeMessage(linearAgentActivitySessionId, message, repositoryId) {
557
+ const agentSessionManager = this.agentSessionManagers.get(repositoryId);
558
+ // Integrate with AgentSessionManager to capture streaming messages
559
+ if (agentSessionManager) {
560
+ await agentSessionManager.handleClaudeMessage(linearAgentActivitySessionId, message);
826
561
  }
827
562
  }
828
563
  /**
829
564
  * Handle Claude session error
565
+ * TODO: improve this
830
566
  */
831
- async handleClaudeError(commentId, error, repositoryId) {
832
- const issueId = this.commentToIssue.get(commentId);
833
- console.error(`[EdgeWorker] Claude session error for comment thread ${commentId} (issue ${issueId}):`, error.message);
834
- console.error(`[EdgeWorker] Error type: ${error.constructor.name}`);
835
- if (error.stack) {
836
- console.error(`[EdgeWorker] Stack trace:`, error.stack);
837
- }
838
- // Clean up resources
839
- this.claudeRunners.delete(commentId);
840
- if (issueId) {
841
- // Emit events for external handlers
842
- this.emit('session:ended', issueId, 1, repositoryId); // 1 indicates error
843
- this.config.handlers?.onSessionEnd?.(issueId, 1, repositoryId);
844
- }
845
- console.log(`[EdgeWorker] Cleaned up resources for failed session ${commentId}`);
846
- }
847
- /**
848
- * Handle token limit by restarting session
849
- */
850
- async handleTokenLimit(commentId, repositoryId) {
851
- const session = this.sessionManager.getSession(commentId);
852
- if (!session)
853
- return;
854
- const repository = this.repositories.get(repositoryId);
855
- if (!repository)
856
- return;
857
- const issueId = this.commentToIssue.get(commentId);
858
- if (!issueId)
859
- return;
860
- // Post warning to Linear
861
- await this.postComment(issueId, '[System] Token limit reached. Starting fresh session with issue context.', repositoryId, commentId);
862
- // Fetch fresh LinearIssue data and restart session for this comment thread
863
- const linearIssue = await this.fetchFullIssueDetails(issueId, repositoryId);
864
- if (!linearIssue) {
865
- throw new Error(`Failed to fetch full issue details for ${issueId}`);
866
- }
867
- // For now, fall back to creating a new root comment handler
868
- // TODO: Implement proper comment thread restart
869
- await this.handleIssueAssignedWithFullIssue(linearIssue, repository);
567
+ async handleClaudeError(error) {
568
+ console.error('Unhandled claude error:', error);
870
569
  }
871
570
  /**
872
571
  * Fetch complete issue details from Linear API
@@ -881,6 +580,16 @@ export class EdgeWorker extends EventEmitter {
881
580
  console.log(`[EdgeWorker] Fetching full issue details for ${issueId}`);
882
581
  const fullIssue = await linearClient.issue(issueId);
883
582
  console.log(`[EdgeWorker] Successfully fetched issue details for ${issueId}`);
583
+ // Check if issue has a parent
584
+ try {
585
+ const parent = await fullIssue.parent;
586
+ if (parent) {
587
+ console.log(`[EdgeWorker] Issue ${issueId} has parent: ${parent.identifier}`);
588
+ }
589
+ }
590
+ catch (error) {
591
+ // Parent field might not exist, ignore error
592
+ }
884
593
  return fullIssue;
885
594
  }
886
595
  catch (error) {
@@ -888,6 +597,107 @@ export class EdgeWorker extends EventEmitter {
888
597
  return null;
889
598
  }
890
599
  }
600
+ /**
601
+ * Fetch issue labels for a given issue
602
+ */
603
+ async fetchIssueLabels(issue) {
604
+ try {
605
+ const labels = await issue.labels();
606
+ return labels.nodes.map(label => label.name);
607
+ }
608
+ catch (error) {
609
+ console.error(`[EdgeWorker] Failed to fetch labels for issue ${issue.id}:`, error);
610
+ return [];
611
+ }
612
+ }
613
+ /**
614
+ * Determine system prompt based on issue labels and repository configuration
615
+ */
616
+ async determineSystemPromptFromLabels(labels, repository) {
617
+ if (!repository.labelPrompts || labels.length === 0) {
618
+ return undefined;
619
+ }
620
+ // Check each prompt type for matching labels
621
+ const promptTypes = ['debugger', 'builder', 'scoper'];
622
+ for (const promptType of promptTypes) {
623
+ const configuredLabels = repository.labelPrompts[promptType];
624
+ if (configuredLabels && configuredLabels.some(label => labels.includes(label))) {
625
+ try {
626
+ // Load the prompt template from file
627
+ const __filename = fileURLToPath(import.meta.url);
628
+ const __dirname = dirname(__filename);
629
+ const promptPath = join(__dirname, '..', 'prompts', `${promptType}.md`);
630
+ const promptContent = await readFile(promptPath, 'utf-8');
631
+ console.log(`[EdgeWorker] Using ${promptType} system prompt for labels: ${labels.join(', ')}`);
632
+ // Extract and log version tag if present
633
+ const promptVersion = this.extractVersionTag(promptContent);
634
+ if (promptVersion) {
635
+ console.log(`[EdgeWorker] ${promptType} system prompt version: ${promptVersion}`);
636
+ }
637
+ return { prompt: promptContent, version: promptVersion };
638
+ }
639
+ catch (error) {
640
+ console.error(`[EdgeWorker] Failed to load ${promptType} prompt template:`, error);
641
+ return undefined;
642
+ }
643
+ }
644
+ }
645
+ return undefined;
646
+ }
647
+ /**
648
+ * Build simplified prompt for label-based workflows
649
+ * @param issue Full Linear issue
650
+ * @param repository Repository configuration
651
+ * @returns Formatted prompt string
652
+ */
653
+ async buildLabelBasedPrompt(issue, repository, attachmentManifest = '') {
654
+ console.log(`[EdgeWorker] buildLabelBasedPrompt called for issue ${issue.identifier}`);
655
+ try {
656
+ // Load the label-based prompt template
657
+ const __filename = fileURLToPath(import.meta.url);
658
+ const __dirname = dirname(__filename);
659
+ const templatePath = resolve(__dirname, '../label-prompt-template.md');
660
+ console.log(`[EdgeWorker] Loading label prompt template from: ${templatePath}`);
661
+ const template = await readFile(templatePath, 'utf-8');
662
+ console.log(`[EdgeWorker] Template loaded, length: ${template.length} characters`);
663
+ // Extract and log version tag if present
664
+ const templateVersion = this.extractVersionTag(template);
665
+ if (templateVersion) {
666
+ console.log(`[EdgeWorker] Label prompt template version: ${templateVersion}`);
667
+ }
668
+ // Build the simplified prompt with only essential variables
669
+ let prompt = template
670
+ .replace(/{{repository_name}}/g, repository.name)
671
+ .replace(/{{base_branch}}/g, repository.baseBranch)
672
+ .replace(/{{issue_id}}/g, issue.id || '')
673
+ .replace(/{{issue_identifier}}/g, issue.identifier || '')
674
+ .replace(/{{issue_title}}/g, issue.title || '')
675
+ .replace(/{{issue_description}}/g, issue.description || 'No description provided')
676
+ .replace(/{{issue_url}}/g, issue.url || '');
677
+ if (attachmentManifest) {
678
+ console.log(`[EdgeWorker] Adding attachment manifest to label-based prompt, length: ${attachmentManifest.length} characters`);
679
+ prompt = prompt + '\n\n' + attachmentManifest;
680
+ }
681
+ console.log(`[EdgeWorker] Label-based prompt built successfully, length: ${prompt.length} characters`);
682
+ return { prompt, version: templateVersion };
683
+ }
684
+ catch (error) {
685
+ console.error(`[EdgeWorker] Error building label-based prompt:`, error);
686
+ throw error;
687
+ }
688
+ }
689
+ /**
690
+ * Extract version tag from template content
691
+ * @param templateContent The template content to parse
692
+ * @returns The version value if found, undefined otherwise
693
+ */
694
+ extractVersionTag(templateContent) {
695
+ // Match the version tag pattern: <version-tag value="..." />
696
+ const versionTagMatch = templateContent.match(/<version-tag\s+value="([^"]*)"\s*\/>/i);
697
+ const version = versionTagMatch ? versionTagMatch[1] : undefined;
698
+ // Return undefined for empty strings
699
+ return version && version.trim() ? version : undefined;
700
+ }
891
701
  /**
892
702
  * Convert full Linear SDK issue to CoreIssue interface for Session creation
893
703
  */
@@ -897,9 +707,7 @@ export class EdgeWorker extends EventEmitter {
897
707
  identifier: issue.identifier,
898
708
  title: issue.title || '',
899
709
  description: issue.description || undefined,
900
- getBranchName() {
901
- return issue.branchName; // Use the real branchName property!
902
- }
710
+ branchName: issue.branchName // Use the real branchName property!
903
711
  };
904
712
  }
905
713
  /**
@@ -1003,6 +811,11 @@ ${reply.body}
1003
811
  console.log(`[EdgeWorker] Loading prompt template from: ${templatePath}`);
1004
812
  const template = await readFile(templatePath, 'utf-8');
1005
813
  console.log(`[EdgeWorker] Template loaded, length: ${template.length} characters`);
814
+ // Extract and log version tag if present
815
+ const templateVersion = this.extractVersionTag(template);
816
+ if (templateVersion) {
817
+ console.log(`[EdgeWorker] Prompt template version: ${templateVersion}`);
818
+ }
1006
819
  // Get state name from Linear API
1007
820
  const state = await issue.state;
1008
821
  const stateName = state?.name || 'Unknown';
@@ -1015,7 +828,7 @@ ${reply.body}
1015
828
  const comments = await linearClient.comments({
1016
829
  filter: { issue: { id: { eq: issue.id } } }
1017
830
  });
1018
- const commentNodes = await comments.nodes;
831
+ const commentNodes = comments.nodes;
1019
832
  if (commentNodes.length > 0) {
1020
833
  commentThreads = await this.formatCommentThreads(commentNodes);
1021
834
  console.log(`[EdgeWorker] Formatted ${commentNodes.length} comments into threads`);
@@ -1080,15 +893,20 @@ IMPORTANT: Focus specifically on addressing the new comment above. This is a new
1080
893
  console.log(`[EdgeWorker] Adding attachment manifest, length: ${attachmentManifest.length} characters`);
1081
894
  prompt = prompt + '\n\n' + attachmentManifest;
1082
895
  }
896
+ // Append repository-specific instruction if provided
897
+ if (repository.appendInstruction) {
898
+ console.log(`[EdgeWorker] Adding repository-specific instruction`);
899
+ prompt = prompt + '\n\n<repository-specific-instruction>\n' + repository.appendInstruction + '\n</repository-specific-instruction>';
900
+ }
1083
901
  console.log(`[EdgeWorker] Final prompt length: ${prompt.length} characters`);
1084
- return prompt;
902
+ return { prompt, version: templateVersion };
1085
903
  }
1086
904
  catch (error) {
1087
905
  console.error('[EdgeWorker] Failed to load prompt template:', error);
1088
906
  // Fallback to simple prompt
1089
907
  const state = await issue.state;
1090
908
  const stateName = state?.name || 'Unknown';
1091
- return `Please help me with the following Linear issue:
909
+ const fallbackPrompt = `Please help me with the following Linear issue:
1092
910
 
1093
911
  Repository: ${repository.name}
1094
912
  Issue: ${issue.identifier}
@@ -1102,31 +920,9 @@ Working directory: ${repository.repositoryPath}
1102
920
  Base branch: ${repository.baseBranch}
1103
921
 
1104
922
  ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ''}Please analyze this issue and help implement a solution.`;
923
+ return { prompt: fallbackPrompt, version: undefined };
1105
924
  }
1106
925
  }
1107
- /**
1108
- * Extract text content from Claude message
1109
- */
1110
- extractTextContent(sdkMessage) {
1111
- if (sdkMessage.type !== 'assistant')
1112
- return null;
1113
- const message = sdkMessage.message;
1114
- if (!message?.content)
1115
- return null;
1116
- if (typeof message.content === 'string') {
1117
- return message.content;
1118
- }
1119
- if (Array.isArray(message.content)) {
1120
- const textBlocks = [];
1121
- for (const block of message.content) {
1122
- if (typeof block === 'object' && block !== null && 'type' in block && block.type === 'text' && 'text' in block) {
1123
- textBlocks.push(block.text);
1124
- }
1125
- }
1126
- return textBlocks.join('');
1127
- }
1128
- return null;
1129
- }
1130
926
  /**
1131
927
  * Get connection status by repository ID
1132
928
  */
@@ -1137,12 +933,6 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ''}Please ana
1137
933
  }
1138
934
  return status;
1139
935
  }
1140
- /**
1141
- * Get active sessions
1142
- */
1143
- getActiveSessions() {
1144
- return Array.from(this.sessionManager.getAllSessions().keys());
1145
- }
1146
936
  /**
1147
937
  * Get NDJSON client by token (for testing purposes)
1148
938
  * @internal
@@ -1231,125 +1021,48 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ''}Please ana
1231
1021
  /**
1232
1022
  * Post initial comment when assigned to issue
1233
1023
  */
1234
- async postInitialComment(issueId, repositoryId) {
1235
- try {
1236
- const body = "I've been assigned to this issue and am getting started right away. I'll update this comment with my plan shortly.";
1237
- // Get the Linear client for this repository
1238
- const linearClient = this.linearClients.get(repositoryId);
1239
- if (!linearClient) {
1240
- throw new Error(`No Linear client found for repository ${repositoryId}`);
1241
- }
1242
- const commentData = {
1243
- issueId,
1244
- body
1245
- };
1246
- const response = await linearClient.createComment(commentData);
1247
- // Linear SDK returns CommentPayload with structure: { comment, success, lastSyncId }
1248
- if (response && response.comment) {
1249
- const comment = await response.comment;
1250
- console.log(`✅ Posted initial comment on issue ${issueId} (ID: ${comment.id})`);
1251
- // Track this as the latest agent reply for the thread (initial comment is its own root)
1252
- if (comment.id) {
1253
- this.commentToLatestAgentReply.set(comment.id, comment.id);
1254
- // Save state after successful comment creation and mapping update
1255
- await this.savePersistedState();
1256
- }
1257
- return comment;
1258
- }
1259
- else {
1260
- throw new Error('Initial comment creation failed');
1261
- }
1262
- }
1263
- catch (error) {
1264
- console.error(`Failed to create initial comment on issue ${issueId}:`, error);
1265
- return null;
1266
- }
1267
- }
1024
+ // private async postInitialComment(issueId: string, repositoryId: string): Promise<void> {
1025
+ // const body = "I'm getting started right away."
1026
+ // // Get the Linear client for this repository
1027
+ // const linearClient = this.linearClients.get(repositoryId)
1028
+ // if (!linearClient) {
1029
+ // throw new Error(`No Linear client found for repository ${repositoryId}`)
1030
+ // }
1031
+ // const commentData = {
1032
+ // issueId,
1033
+ // body
1034
+ // }
1035
+ // await linearClient.createComment(commentData)
1036
+ // }
1268
1037
  /**
1269
1038
  * Post a comment to Linear
1270
1039
  */
1271
1040
  async postComment(issueId, body, repositoryId, parentId) {
1272
- try {
1273
- // Get the Linear client for this repository
1274
- const linearClient = this.linearClients.get(repositoryId);
1275
- if (!linearClient) {
1276
- throw new Error(`No Linear client found for repository ${repositoryId}`);
1277
- }
1278
- const commentData = {
1279
- issueId,
1280
- body
1281
- };
1282
- // Add parent ID if provided (for reply)
1283
- if (parentId) {
1284
- commentData.parentId = parentId;
1285
- }
1286
- const response = await linearClient.createComment(commentData);
1287
- // Linear SDK returns CommentPayload with structure: { comment, success, lastSyncId }
1288
- if (response && response.comment) {
1289
- console.log(`✅ Successfully created comment on issue ${issueId}`);
1290
- const comment = await response.comment;
1291
- if (comment?.id) {
1292
- console.log(`Comment ID: ${comment.id}`);
1293
- // Track this as the latest agent reply for the thread
1294
- // If parentId exists, that's the thread root; otherwise this comment IS the root
1295
- const threadRootCommentId = parentId || comment.id;
1296
- this.commentToLatestAgentReply.set(threadRootCommentId, comment.id);
1297
- // Save state after successful comment creation and mapping update
1298
- await this.savePersistedState();
1299
- return comment;
1300
- }
1301
- return null;
1302
- }
1303
- else {
1304
- throw new Error('Comment creation failed');
1305
- }
1306
- }
1307
- catch (error) {
1308
- console.error(`Failed to create comment on issue ${issueId}:`, error);
1309
- // Don't re-throw - just log the error so the edge worker doesn't crash
1310
- // TODO: Implement retry logic or token refresh
1311
- return null;
1312
- }
1313
- }
1314
- /**
1315
- * Update initial comment with TODO checklist
1316
- */
1317
- async updateCommentWithTodos(todos, repositoryId, threadRootCommentId) {
1318
- try {
1319
- // Get the latest agent comment in this thread
1320
- const commentId = this.commentToLatestAgentReply.get(threadRootCommentId) || threadRootCommentId;
1321
- if (!commentId) {
1322
- console.log('No comment ID found for thread, cannot update with todos');
1323
- return;
1324
- }
1325
- // Convert todos to Linear checklist format
1326
- const checklist = this.formatTodosAsChecklist(todos);
1327
- const body = `I've been assigned to this issue and am getting started right away. Here's my plan:\n\n${checklist}`;
1328
- // Get the Linear client
1329
- const linearClient = this.linearClients.get(repositoryId);
1330
- if (!linearClient) {
1331
- throw new Error(`No Linear client found for repository ${repositoryId}`);
1332
- }
1333
- // Update the comment
1334
- const response = await linearClient.updateComment(commentId, { body });
1335
- if (response) {
1336
- console.log(`✅ Updated comment ${commentId} with ${todos.length} todos`);
1337
- }
1041
+ // Get the Linear client for this repository
1042
+ const linearClient = this.linearClients.get(repositoryId);
1043
+ if (!linearClient) {
1044
+ throw new Error(`No Linear client found for repository ${repositoryId}`);
1338
1045
  }
1339
- catch (error) {
1340
- console.error(`Failed to update comment with todos:`, error);
1046
+ const commentData = {
1047
+ issueId,
1048
+ body
1049
+ };
1050
+ // Add parent ID if provided (for reply)
1051
+ if (parentId) {
1052
+ commentData.parentId = parentId;
1341
1053
  }
1054
+ await linearClient.createComment(commentData);
1342
1055
  }
1343
1056
  /**
1344
1057
  * Format todos as Linear checklist markdown
1345
1058
  */
1346
- formatTodosAsChecklist(todos) {
1347
- return todos.map(todo => {
1348
- const checkbox = todo.status === 'completed' ? '[x]' : '[ ]';
1349
- const statusEmoji = todo.status === 'in_progress' ? ' 🔄' : '';
1350
- return `- ${checkbox} ${todo.content}${statusEmoji}`;
1351
- }).join('\n');
1352
- }
1059
+ // private formatTodosAsChecklist(todos: Array<{id: string, content: string, status: string, priority: string}>): string {
1060
+ // return todos.map(todo => {
1061
+ // const checkbox = todo.status === 'completed' ? '[x]' : '[ ]'
1062
+ // const statusEmoji = todo.status === 'in_progress' ? ' 🔄' : ''
1063
+ // return `- ${checkbox} ${todo.content}${statusEmoji}`
1064
+ // }).join('\n')
1065
+ // }
1353
1066
  /**
1354
1067
  * Extract attachment URLs from text (issue description or comment)
1355
1068
  */
@@ -1392,7 +1105,7 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ''}Please ana
1392
1105
  const comments = await linearClient.comments({
1393
1106
  filter: { issue: { id: { eq: issue.id } } }
1394
1107
  });
1395
- const commentNodes = await comments.nodes;
1108
+ const commentNodes = comments.nodes;
1396
1109
  for (const comment of commentNodes) {
1397
1110
  const urls = this.extractAttachmentUrls(comment.body);
1398
1111
  commentUrls.push(...urls);
@@ -1557,62 +1270,61 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ''}Please ana
1557
1270
  }
1558
1271
  return manifest;
1559
1272
  }
1560
- /**
1561
- * Check if the agent (Cyrus) is mentioned in a comment
1562
- * @param comment Linear comment object from webhook data
1563
- * @param repository Repository configuration
1564
- * @returns true if the agent is mentioned, false otherwise
1565
- */
1566
- async isAgentMentionedInComment(comment, repository) {
1567
- try {
1568
- const linearClient = this.linearClients.get(repository.id);
1569
- if (!linearClient) {
1570
- console.warn(`No Linear client found for repository ${repository.id}`);
1571
- return false;
1572
- }
1573
- // Get the current user (agent) information
1574
- const viewer = await linearClient.viewer;
1575
- if (!viewer) {
1576
- console.warn('Unable to fetch viewer information');
1577
- return false;
1578
- }
1579
- // Check for mentions in the comment body
1580
- // Linear mentions can be in formats like:
1581
- // @username, @"Display Name", or @userId
1582
- const commentBody = comment.body;
1583
- // Check for mention by user ID (most reliable)
1584
- if (commentBody.includes(`@${viewer.id}`)) {
1585
- return true;
1586
- }
1587
- // Check for mention by name (case-insensitive)
1588
- if (viewer.name) {
1589
- const namePattern = new RegExp(`@"?${viewer.name}"?`, 'i');
1590
- if (namePattern.test(commentBody)) {
1591
- return true;
1592
- }
1593
- }
1594
- // Check for mention by display name (case-insensitive)
1595
- if (viewer.displayName && viewer.displayName !== viewer.name) {
1596
- const displayNamePattern = new RegExp(`@"?${viewer.displayName}"?`, 'i');
1597
- if (displayNamePattern.test(commentBody)) {
1598
- return true;
1599
- }
1600
- }
1601
- // Check for mention by email (less common but possible)
1602
- if (viewer.email) {
1603
- const emailPattern = new RegExp(`@"?${viewer.email}"?`, 'i');
1604
- if (emailPattern.test(commentBody)) {
1605
- return true;
1606
- }
1607
- }
1608
- return false;
1609
- }
1610
- catch (error) {
1611
- console.error('Failed to check if agent is mentioned in comment:', error);
1612
- // If we can't determine, err on the side of caution and allow the trigger
1613
- return true;
1614
- }
1615
- }
1273
+ // /**
1274
+ // * Check if the agent (Cyrus) is mentioned in a comment
1275
+ // * @param comment Linear comment object from webhook data
1276
+ // * @param repository Repository configuration
1277
+ // * @returns true if the agent is mentioned, false otherwise
1278
+ // */
1279
+ // private async isAgentMentionedInComment(comment: LinearWebhookComment, repository: RepositoryConfig): Promise<boolean> {
1280
+ // try {
1281
+ // const linearClient = this.linearClients.get(repository.id)
1282
+ // if (!linearClient) {
1283
+ // console.warn(`No Linear client found for repository ${repository.id}`)
1284
+ // return false
1285
+ // }
1286
+ // // Get the current user (agent) information
1287
+ // const viewer = await linearClient.viewer
1288
+ // if (!viewer) {
1289
+ // console.warn('Unable to fetch viewer information')
1290
+ // return false
1291
+ // }
1292
+ // // Check for mentions in the comment body
1293
+ // // Linear mentions can be in formats like:
1294
+ // // @username, @"Display Name", or @userId
1295
+ // const commentBody = comment.body
1296
+ // // Check for mention by user ID (most reliable)
1297
+ // if (commentBody.includes(`@${viewer.id}`)) {
1298
+ // return true
1299
+ // }
1300
+ // // Check for mention by name (case-insensitive)
1301
+ // if (viewer.name) {
1302
+ // const namePattern = new RegExp(`@"?${viewer.name}"?`, 'i')
1303
+ // if (namePattern.test(commentBody)) {
1304
+ // return true
1305
+ // }
1306
+ // }
1307
+ // // Check for mention by display name (case-insensitive)
1308
+ // if (viewer.displayName && viewer.displayName !== viewer.name) {
1309
+ // const displayNamePattern = new RegExp(`@"?${viewer.displayName}"?`, 'i')
1310
+ // if (displayNamePattern.test(commentBody)) {
1311
+ // return true
1312
+ // }
1313
+ // }
1314
+ // // Check for mention by email (less common but possible)
1315
+ // if (viewer.email) {
1316
+ // const emailPattern = new RegExp(`@"?${viewer.email}"?`, 'i')
1317
+ // if (emailPattern.test(commentBody)) {
1318
+ // return true
1319
+ // }
1320
+ // }
1321
+ // return false
1322
+ // } catch (error) {
1323
+ // console.error('Failed to check if agent is mentioned in comment:', error)
1324
+ // // If we can't determine, err on the side of caution and allow the trigger
1325
+ // return true
1326
+ // }
1327
+ // }
1616
1328
  /**
1617
1329
  * Build MCP configuration with automatic Linear server injection
1618
1330
  */
@@ -1647,104 +1359,197 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ''}Please ana
1647
1359
  const allTools = [...new Set([...baseToolsArray, ...linearMcpTools])];
1648
1360
  return allTools;
1649
1361
  }
1362
+ /**
1363
+ * Get Agent Sessions for an issue
1364
+ */
1365
+ getAgentSessionsForIssue(issueId, repositoryId) {
1366
+ const agentSessionManager = this.agentSessionManagers.get(repositoryId);
1367
+ if (!agentSessionManager) {
1368
+ return [];
1369
+ }
1370
+ return agentSessionManager.getSessionsByIssueId(issueId);
1371
+ }
1650
1372
  /**
1651
1373
  * Load persisted EdgeWorker state for all repositories
1652
1374
  */
1653
1375
  async loadPersistedState() {
1654
- for (const repo of this.repositories.values()) {
1655
- try {
1656
- const state = await this.persistenceManager.loadEdgeWorkerState(repo.id);
1657
- if (state) {
1658
- this.restoreMappings(state);
1659
- console.log(`✅ Loaded persisted state for repository: ${repo.name}`);
1660
- }
1661
- }
1662
- catch (error) {
1663
- console.error(`Failed to load persisted state for repository ${repo.name}:`, error);
1376
+ try {
1377
+ const state = await this.persistenceManager.loadEdgeWorkerState();
1378
+ if (state) {
1379
+ this.restoreMappings(state);
1380
+ console.log(`✅ Loaded persisted EdgeWorker state with ${Object.keys(state.agentSessions || {}).length} repositories`);
1664
1381
  }
1665
1382
  }
1383
+ catch (error) {
1384
+ console.error(`Failed to load persisted EdgeWorker state:`, error);
1385
+ }
1666
1386
  }
1667
1387
  /**
1668
1388
  * Save current EdgeWorker state for all repositories
1669
1389
  */
1670
1390
  async savePersistedState() {
1671
- for (const repo of this.repositories.values()) {
1672
- try {
1673
- const state = this.serializeMappings();
1674
- await this.persistenceManager.saveEdgeWorkerState(repo.id, state);
1675
- }
1676
- catch (error) {
1677
- console.error(`Failed to save persisted state for repository ${repo.name}:`, error);
1678
- }
1391
+ try {
1392
+ const state = this.serializeMappings();
1393
+ await this.persistenceManager.saveEdgeWorkerState(state);
1394
+ console.log(`✅ Saved EdgeWorker state for ${Object.keys(state.agentSessions || {}).length} repositories`);
1395
+ }
1396
+ catch (error) {
1397
+ console.error(`Failed to save persisted EdgeWorker state:`, error);
1679
1398
  }
1680
1399
  }
1681
1400
  /**
1682
1401
  * Serialize EdgeWorker mappings to a serializable format
1683
1402
  */
1684
1403
  serializeMappings() {
1685
- // Convert issueToCommentThreads Map<string, Set<string>> to Record<string, string[]>
1686
- const issueToCommentThreads = {};
1687
- for (const [issueId, threadSet] of this.issueToCommentThreads.entries()) {
1688
- issueToCommentThreads[issueId] = Array.from(threadSet);
1404
+ // Serialize Agent Session state for all repositories
1405
+ const agentSessions = {};
1406
+ const agentSessionEntries = {};
1407
+ for (const [repositoryId, agentSessionManager] of this.agentSessionManagers.entries()) {
1408
+ const serializedState = agentSessionManager.serializeState();
1409
+ agentSessions[repositoryId] = serializedState.sessions;
1410
+ agentSessionEntries[repositoryId] = serializedState.entries;
1689
1411
  }
1690
- // Serialize session manager state
1691
- const sessionManagerState = this.sessionManager.serializeSessions();
1692
1412
  return {
1693
- commentToRepo: PersistenceManager.mapToRecord(this.commentToRepo),
1694
- commentToIssue: PersistenceManager.mapToRecord(this.commentToIssue),
1695
- commentToLatestAgentReply: PersistenceManager.mapToRecord(this.commentToLatestAgentReply),
1696
- issueToCommentThreads,
1697
- issueToReplyContext: PersistenceManager.mapToRecord(this.issueToReplyContext),
1698
- sessionsByCommentId: sessionManagerState.sessionsByCommentId,
1699
- sessionsByIssueId: sessionManagerState.sessionsByIssueId
1413
+ agentSessions,
1414
+ agentSessionEntries,
1700
1415
  };
1701
1416
  }
1702
1417
  /**
1703
1418
  * Restore EdgeWorker mappings from serialized state
1704
1419
  */
1705
1420
  restoreMappings(state) {
1706
- // Restore basic mappings
1707
- this.commentToRepo = PersistenceManager.recordToMap(state.commentToRepo);
1708
- this.commentToIssue = PersistenceManager.recordToMap(state.commentToIssue);
1709
- this.commentToLatestAgentReply = PersistenceManager.recordToMap(state.commentToLatestAgentReply);
1710
- this.issueToReplyContext = PersistenceManager.recordToMap(state.issueToReplyContext);
1711
- // Restore issueToCommentThreads Record<string, string[]> to Map<string, Set<string>>
1712
- this.issueToCommentThreads.clear();
1713
- for (const [issueId, threadArray] of Object.entries(state.issueToCommentThreads)) {
1714
- this.issueToCommentThreads.set(issueId, new Set(threadArray));
1715
- }
1716
- // Restore session manager state
1717
- this.sessionManager.deserializeSessions({
1718
- sessionsByCommentId: state.sessionsByCommentId,
1719
- sessionsByIssueId: state.sessionsByIssueId
1720
- });
1421
+ // Restore Agent Session state for all repositories
1422
+ if (state.agentSessions && state.agentSessionEntries) {
1423
+ for (const [repositoryId, agentSessionManager] of this.agentSessionManagers.entries()) {
1424
+ const repositorySessions = state.agentSessions[repositoryId] || {};
1425
+ const repositoryEntries = state.agentSessionEntries[repositoryId] || {};
1426
+ if (Object.keys(repositorySessions).length > 0 || Object.keys(repositoryEntries).length > 0) {
1427
+ agentSessionManager.restoreState(repositorySessions, repositoryEntries);
1428
+ console.log(`[EdgeWorker] Restored Agent Session state for repository ${repositoryId}`);
1429
+ }
1430
+ }
1431
+ }
1721
1432
  }
1722
1433
  /**
1723
- * Save state and cleanup on shutdown
1434
+ * Post instant acknowledgment thought when agent session is created
1724
1435
  */
1725
- async shutdown() {
1436
+ async postInstantAcknowledgment(linearAgentActivitySessionId, repositoryId) {
1726
1437
  try {
1727
- await this.savePersistedState();
1728
- console.log('✅ EdgeWorker state saved successfully');
1438
+ const linearClient = this.linearClients.get(repositoryId);
1439
+ if (!linearClient) {
1440
+ console.warn(`[EdgeWorker] No Linear client found for repository ${repositoryId}`);
1441
+ return;
1442
+ }
1443
+ const activityInput = {
1444
+ agentSessionId: linearAgentActivitySessionId,
1445
+ content: {
1446
+ type: 'thought',
1447
+ body: 'I\'ve received your request and I\'m starting to work on it. Let me analyze the issue and prepare my approach.'
1448
+ }
1449
+ };
1450
+ const result = await linearClient.createAgentActivity(activityInput);
1451
+ if (result.success) {
1452
+ console.log(`[EdgeWorker] Posted instant acknowledgment thought for session ${linearAgentActivitySessionId}`);
1453
+ }
1454
+ else {
1455
+ console.error(`[EdgeWorker] Failed to post instant acknowledgment:`, result);
1456
+ }
1729
1457
  }
1730
1458
  catch (error) {
1731
- console.error('❌ Failed to save EdgeWorker state during shutdown:', error);
1459
+ console.error(`[EdgeWorker] Error posting instant acknowledgment:`, error);
1732
1460
  }
1733
- // Stop all Claude runners
1734
- for (const [commentId, runner] of this.claudeRunners.entries()) {
1735
- try {
1736
- await runner.stop();
1461
+ }
1462
+ /**
1463
+ * Post thought about system prompt selection based on labels
1464
+ */
1465
+ async postSystemPromptSelectionThought(linearAgentActivitySessionId, labels, repositoryId) {
1466
+ try {
1467
+ const linearClient = this.linearClients.get(repositoryId);
1468
+ if (!linearClient) {
1469
+ console.warn(`[EdgeWorker] No Linear client found for repository ${repositoryId}`);
1470
+ return;
1737
1471
  }
1738
- catch (error) {
1739
- console.error(`Failed to stop Claude runner for comment ${commentId}:`, error);
1472
+ // Determine which prompt type was selected and which label triggered it
1473
+ let selectedPromptType = null;
1474
+ let triggerLabel = null;
1475
+ const repository = Array.from(this.repositories.values()).find(r => r.id === repositoryId);
1476
+ if (repository?.labelPrompts) {
1477
+ // Check debugger labels
1478
+ const debuggerLabel = repository.labelPrompts.debugger?.find(label => labels.includes(label));
1479
+ if (debuggerLabel) {
1480
+ selectedPromptType = 'debugger';
1481
+ triggerLabel = debuggerLabel;
1482
+ }
1483
+ else {
1484
+ // Check builder labels
1485
+ const builderLabel = repository.labelPrompts.builder?.find(label => labels.includes(label));
1486
+ if (builderLabel) {
1487
+ selectedPromptType = 'builder';
1488
+ triggerLabel = builderLabel;
1489
+ }
1490
+ else {
1491
+ // Check scoper labels
1492
+ const scoperLabel = repository.labelPrompts.scoper?.find(label => labels.includes(label));
1493
+ if (scoperLabel) {
1494
+ selectedPromptType = 'scoper';
1495
+ triggerLabel = scoperLabel;
1496
+ }
1497
+ }
1498
+ }
1499
+ }
1500
+ // Only post if a role was actually triggered
1501
+ if (!selectedPromptType || !triggerLabel) {
1502
+ return;
1503
+ }
1504
+ const activityInput = {
1505
+ agentSessionId: linearAgentActivitySessionId,
1506
+ content: {
1507
+ type: 'thought',
1508
+ body: `Entering '${selectedPromptType}' mode because of the '${triggerLabel}' label. I'll follow the ${selectedPromptType} process...`
1509
+ }
1510
+ };
1511
+ const result = await linearClient.createAgentActivity(activityInput);
1512
+ if (result.success) {
1513
+ console.log(`[EdgeWorker] Posted system prompt selection thought for session ${linearAgentActivitySessionId} (${selectedPromptType} mode)`);
1514
+ }
1515
+ else {
1516
+ console.error(`[EdgeWorker] Failed to post system prompt selection thought:`, result);
1740
1517
  }
1741
1518
  }
1742
- // Stop shared application server
1519
+ catch (error) {
1520
+ console.error(`[EdgeWorker] Error posting system prompt selection thought:`, error);
1521
+ }
1522
+ }
1523
+ /**
1524
+ * Post instant acknowledgment thought when receiving prompted webhook
1525
+ */
1526
+ async postInstantPromptedAcknowledgment(linearAgentActivitySessionId, repositoryId, isStreaming) {
1743
1527
  try {
1744
- await this.sharedApplicationServer.stop();
1528
+ const linearClient = this.linearClients.get(repositoryId);
1529
+ if (!linearClient) {
1530
+ console.warn(`[EdgeWorker] No Linear client found for repository ${repositoryId}`);
1531
+ return;
1532
+ }
1533
+ const message = isStreaming
1534
+ ? "I've queued up your message as guidance"
1535
+ : "Getting started on that...";
1536
+ const activityInput = {
1537
+ agentSessionId: linearAgentActivitySessionId,
1538
+ content: {
1539
+ type: 'thought',
1540
+ body: message
1541
+ }
1542
+ };
1543
+ const result = await linearClient.createAgentActivity(activityInput);
1544
+ if (result.success) {
1545
+ console.log(`[EdgeWorker] Posted instant prompted acknowledgment thought for session ${linearAgentActivitySessionId} (streaming: ${isStreaming})`);
1546
+ }
1547
+ else {
1548
+ console.error(`[EdgeWorker] Failed to post instant prompted acknowledgment:`, result);
1549
+ }
1745
1550
  }
1746
1551
  catch (error) {
1747
- console.error('Failed to stop shared application server:', error);
1552
+ console.error(`[EdgeWorker] Error posting instant prompted acknowledgment:`, error);
1748
1553
  }
1749
1554
  }
1750
1555
  }