cyrus-edge-worker 0.2.5 → 0.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/dist/AgentSessionManager.d.ts +42 -5
  2. package/dist/AgentSessionManager.d.ts.map +1 -1
  3. package/dist/AgentSessionManager.js +143 -16
  4. package/dist/AgentSessionManager.js.map +1 -1
  5. package/dist/AskUserQuestionHandler.d.ts +96 -0
  6. package/dist/AskUserQuestionHandler.d.ts.map +1 -0
  7. package/dist/AskUserQuestionHandler.js +203 -0
  8. package/dist/AskUserQuestionHandler.js.map +1 -0
  9. package/dist/EdgeWorker.d.ts +72 -11
  10. package/dist/EdgeWorker.d.ts.map +1 -1
  11. package/dist/EdgeWorker.js +469 -124
  12. package/dist/EdgeWorker.js.map +1 -1
  13. package/dist/GitService.d.ts +34 -0
  14. package/dist/GitService.d.ts.map +1 -0
  15. package/dist/GitService.js +347 -0
  16. package/dist/GitService.js.map +1 -0
  17. package/dist/SharedApplicationServer.d.ts +2 -1
  18. package/dist/SharedApplicationServer.d.ts.map +1 -1
  19. package/dist/SharedApplicationServer.js +5 -3
  20. package/dist/SharedApplicationServer.js.map +1 -1
  21. package/dist/index.d.ts +5 -0
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +4 -0
  24. package/dist/index.js.map +1 -1
  25. package/dist/procedures/{ProcedureRouter.d.ts → ProcedureAnalyzer.d.ts} +11 -11
  26. package/dist/procedures/ProcedureAnalyzer.d.ts.map +1 -0
  27. package/dist/procedures/{ProcedureRouter.js → ProcedureAnalyzer.js} +21 -14
  28. package/dist/procedures/ProcedureAnalyzer.js.map +1 -0
  29. package/dist/procedures/index.d.ts +2 -2
  30. package/dist/procedures/index.d.ts.map +1 -1
  31. package/dist/procedures/index.js +2 -2
  32. package/dist/procedures/index.js.map +1 -1
  33. package/dist/procedures/registry.d.ts +20 -1
  34. package/dist/procedures/registry.d.ts.map +1 -1
  35. package/dist/procedures/registry.js +26 -1
  36. package/dist/procedures/registry.js.map +1 -1
  37. package/dist/procedures/types.d.ts +29 -5
  38. package/dist/procedures/types.d.ts.map +1 -1
  39. package/dist/procedures/types.js +1 -1
  40. package/dist/prompts/subroutines/user-testing-summary.md +87 -0
  41. package/dist/prompts/subroutines/user-testing.md +48 -0
  42. package/dist/prompts/subroutines/validation-fixer.md +56 -0
  43. package/dist/prompts/subroutines/verifications.md +51 -24
  44. package/dist/validation/ValidationLoopController.d.ts +54 -0
  45. package/dist/validation/ValidationLoopController.d.ts.map +1 -0
  46. package/dist/validation/ValidationLoopController.js +242 -0
  47. package/dist/validation/ValidationLoopController.js.map +1 -0
  48. package/dist/validation/index.d.ts +7 -0
  49. package/dist/validation/index.d.ts.map +1 -0
  50. package/dist/validation/index.js +7 -0
  51. package/dist/validation/index.js.map +1 -0
  52. package/dist/validation/types.d.ts +90 -0
  53. package/dist/validation/types.d.ts.map +1 -0
  54. package/dist/validation/types.js +33 -0
  55. package/dist/validation/types.js.map +1 -0
  56. package/package.json +10 -8
  57. package/prompts/graphite-orchestrator.md +360 -0
  58. package/dist/procedures/ProcedureRouter.d.ts.map +0 -1
  59. package/dist/procedures/ProcedureRouter.js.map +0 -1
@@ -4,14 +4,16 @@ import { basename, dirname, extname, join, resolve } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { LinearClient } from "@linear/sdk";
6
6
  import { watch as chokidarWatch } from "chokidar";
7
- import { AbortError, ClaudeRunner, createCyrusToolsServer, createImageToolsServer, createSoraToolsServer, getAllTools, getCoordinatorTools, getReadOnlyTools, getSafeTools, } from "cyrus-claude-runner";
7
+ import { ClaudeRunner, createCyrusToolsServer, createImageToolsServer, createSoraToolsServer, getAllTools, getCoordinatorTools, getReadOnlyTools, getSafeTools, } from "cyrus-claude-runner";
8
8
  import { ConfigUpdater } from "cyrus-config-updater";
9
- import { DEFAULT_PROXY_URL, isAgentSessionCreatedWebhook, isAgentSessionPromptedWebhook, isIssueAssignedWebhook, isIssueCommentMentionWebhook, isIssueNewCommentWebhook, isIssueUnassignedWebhook, PersistenceManager, resolvePath, } from "cyrus-core";
9
+ import { CLIIssueTrackerService, CLIRPCServer, DEFAULT_PROXY_URL, isAgentSessionCreatedWebhook, isAgentSessionPromptedWebhook, isIssueAssignedWebhook, isIssueCommentMentionWebhook, isIssueNewCommentWebhook, isIssueUnassignedWebhook, PersistenceManager, resolvePath, } from "cyrus-core";
10
10
  import { GeminiRunner } from "cyrus-gemini-runner";
11
11
  import { LinearEventTransport, LinearIssueTrackerService, } from "cyrus-linear-event-transport";
12
12
  import { fileTypeFromBuffer } from "file-type";
13
13
  import { AgentSessionManager } from "./AgentSessionManager.js";
14
- import { ProcedureRouter, } from "./procedures/index.js";
14
+ import { AskUserQuestionHandler } from "./AskUserQuestionHandler.js";
15
+ import { GitService } from "./GitService.js";
16
+ import { ProcedureAnalyzer, } from "./procedures/index.js";
15
17
  import { RepositoryRouter, } from "./RepositoryRouter.js";
16
18
  import { SharedApplicationServer } from "./SharedApplicationServer.js";
17
19
  /**
@@ -26,16 +28,21 @@ export class EdgeWorker extends EventEmitter {
26
28
  agentSessionManagers = new Map(); // Maps repository ID to AgentSessionManager, which manages agent runners for a repo
27
29
  issueTrackers = new Map(); // one issue tracker per 'repository'
28
30
  linearEventTransport = null; // Single event transport for webhook delivery
31
+ cliRPCServer = null; // CLI RPC server for CLI platform mode
29
32
  configUpdater = null; // Single config updater for configuration updates
30
33
  persistenceManager;
31
34
  sharedApplicationServer;
32
35
  cyrusHome;
33
36
  childToParentAgentSession = new Map(); // Maps child agentSessionId to parent agentSessionId
34
- procedureRouter; // Intelligent workflow routing
37
+ procedureAnalyzer; // Intelligent workflow routing
35
38
  configWatcher; // File watcher for config.json
36
39
  configPath; // Path to config.json file
37
40
  /** @internal - Exposed for testing only */
38
41
  repositoryRouter; // Repository routing and selection
42
+ gitService;
43
+ activeWebhookCount = 0; // Track number of webhooks currently being processed
44
+ /** Handler for AskUserQuestion tool invocations via Linear select signal */
45
+ askUserQuestionHandler;
39
46
  constructor(config) {
40
47
  super();
41
48
  this.config = config;
@@ -43,7 +50,7 @@ export class EdgeWorker extends EventEmitter {
43
50
  this.persistenceManager = new PersistenceManager(join(this.cyrusHome, "state"));
44
51
  // Initialize procedure router with haiku for fast classification
45
52
  // Default to claude runner
46
- this.procedureRouter = new ProcedureRouter({
53
+ this.procedureAnalyzer = new ProcedureAnalyzer({
47
54
  cyrusHome: this.cyrusHome,
48
55
  model: "haiku",
49
56
  timeoutMs: 100000,
@@ -75,12 +82,20 @@ export class EdgeWorker extends EventEmitter {
75
82
  },
76
83
  };
77
84
  this.repositoryRouter = new RepositoryRouter(repositoryRouterDeps);
85
+ this.gitService = new GitService();
86
+ // Initialize AskUserQuestion handler for elicitation via Linear select signal
87
+ this.askUserQuestionHandler = new AskUserQuestionHandler({
88
+ getIssueTracker: (workspaceId) => {
89
+ return this.getIssueTrackerForWorkspace(workspaceId) ?? null;
90
+ },
91
+ });
78
92
  console.log(`[EdgeWorker Constructor] Initializing parent-child session mapping system`);
79
93
  console.log(`[EdgeWorker Constructor] Parent-child mapping initialized with 0 entries`);
80
94
  // Initialize shared application server
81
95
  const serverPort = config.serverPort || config.webhookPort || 3456;
82
96
  const serverHost = config.serverHost || "localhost";
83
- this.sharedApplicationServer = new SharedApplicationServer(serverPort, serverHost);
97
+ const skipTunnel = config.platform === "cli"; // Skip Cloudflare tunnel in CLI mode
98
+ this.sharedApplicationServer = new SharedApplicationServer(serverPort, serverHost, skipTunnel);
84
99
  // Initialize repositories with path resolution
85
100
  for (const repo of config.repositories) {
86
101
  if (repo.isActive !== false) {
@@ -103,10 +118,15 @@ export class EdgeWorker extends EventEmitter {
103
118
  };
104
119
  this.repositories.set(repo.id, resolvedRepo);
105
120
  // Create issue tracker for this repository's workspace
106
- const linearClient = new LinearClient({
107
- accessToken: repo.linearToken,
108
- });
109
- const issueTracker = new LinearIssueTrackerService(linearClient);
121
+ const issueTracker = this.config.platform === "cli"
122
+ ? (() => {
123
+ const service = new CLIIssueTrackerService();
124
+ service.seedDefaultData();
125
+ return service;
126
+ })()
127
+ : new LinearIssueTrackerService(new LinearClient({
128
+ accessToken: repo.linearToken,
129
+ }));
110
130
  this.issueTrackers.set(repo.id, issueTracker);
111
131
  // Create AgentSessionManager for this repository with parent session lookup and resume callback
112
132
  //
@@ -125,11 +145,20 @@ export class EdgeWorker extends EventEmitter {
125
145
  return parentId;
126
146
  }, async (parentSessionId, prompt, childSessionId) => {
127
147
  await this.handleResumeParentSession(parentSessionId, prompt, childSessionId, repo, agentSessionManager);
128
- }, this.procedureRouter, this.sharedApplicationServer);
148
+ }, this.procedureAnalyzer, this.sharedApplicationServer);
129
149
  // Subscribe to subroutine completion events
130
150
  agentSessionManager.on("subroutineComplete", async ({ linearAgentActivitySessionId, session }) => {
131
151
  await this.handleSubroutineTransition(linearAgentActivitySessionId, session, repo, agentSessionManager);
132
152
  });
153
+ // Subscribe to validation loop events
154
+ agentSessionManager.on("validationLoopIteration", async ({ linearAgentActivitySessionId, session, fixerPrompt, iteration, maxIterations, }) => {
155
+ console.log(`[EdgeWorker] Validation loop iteration ${iteration}/${maxIterations}, running fixer`);
156
+ await this.handleValidationLoopFixer(linearAgentActivitySessionId, session, repo, agentSessionManager, fixerPrompt, iteration);
157
+ });
158
+ agentSessionManager.on("validationLoopRerun", async ({ linearAgentActivitySessionId, session, iteration }) => {
159
+ console.log(`[EdgeWorker] Validation loop re-running verifications (iteration ${iteration})`);
160
+ await this.handleValidationLoopRerun(linearAgentActivitySessionId, session, repo, agentSessionManager);
161
+ });
133
162
  this.agentSessionManagers.set(repo.id, agentSessionManager);
134
163
  }
135
164
  }
@@ -159,39 +188,116 @@ export class EdgeWorker extends EventEmitter {
159
188
  if (!firstRepo) {
160
189
  throw new Error("No active repositories configured");
161
190
  }
162
- // 1. Create and register LinearEventTransport
163
- const useDirectWebhooks = process.env.LINEAR_DIRECT_WEBHOOKS?.toLowerCase() === "true";
164
- const verificationMode = useDirectWebhooks ? "direct" : "proxy";
165
- // Get appropriate secret based on mode
166
- const secret = useDirectWebhooks
167
- ? process.env.LINEAR_WEBHOOK_SECRET || ""
168
- : process.env.CYRUS_API_KEY || "";
169
- this.linearEventTransport = new LinearEventTransport({
170
- fastifyServer: this.sharedApplicationServer.getFastifyInstance(),
171
- verificationMode,
172
- secret,
173
- });
174
- // Listen for webhook events
175
- this.linearEventTransport.on("event", (event) => {
176
- // Get all active repositories for webhook handling
177
- const repos = Array.from(this.repositories.values());
178
- this.handleWebhook(event, repos);
179
- });
180
- // Listen for errors
181
- this.linearEventTransport.on("error", (error) => {
182
- this.handleError(error);
183
- });
184
- // Register the /webhook endpoint
185
- this.linearEventTransport.register();
186
- console.log(`✅ Linear event transport registered (${verificationMode} mode)`);
187
- console.log(` Webhook endpoint: ${this.sharedApplicationServer.getWebhookUrl()}`);
188
- // 2. Create and register ConfigUpdater
191
+ // Platform-specific initialization
192
+ if (this.config.platform === "cli") {
193
+ // CLI mode: Create and register CLIRPCServer
194
+ const firstIssueTracker = this.issueTrackers.get(firstRepo.id);
195
+ if (!firstIssueTracker) {
196
+ throw new Error("Issue tracker not found for first repository");
197
+ }
198
+ // Type guard to ensure it's a CLIIssueTrackerService
199
+ if (!(firstIssueTracker instanceof CLIIssueTrackerService)) {
200
+ throw new Error("CLI platform requires CLIIssueTrackerService but found different implementation");
201
+ }
202
+ this.cliRPCServer = new CLIRPCServer({
203
+ fastifyServer: this.sharedApplicationServer.getFastifyInstance(),
204
+ issueTracker: firstIssueTracker,
205
+ version: "1.0.0",
206
+ });
207
+ // Register the /cli/rpc endpoint
208
+ this.cliRPCServer.register();
209
+ console.log("✅ CLI RPC server registered");
210
+ console.log(" RPC endpoint: /cli/rpc");
211
+ // Create CLI event transport and register listener
212
+ const cliEventTransport = firstIssueTracker.createEventTransport({
213
+ platform: "cli",
214
+ fastifyServer: this.sharedApplicationServer.getFastifyInstance(),
215
+ });
216
+ // Listen for webhook events (same pattern as Linear mode)
217
+ cliEventTransport.on("event", (event) => {
218
+ // Get all active repositories for webhook handling
219
+ const repos = Array.from(this.repositories.values());
220
+ this.handleWebhook(event, repos);
221
+ });
222
+ // Listen for errors
223
+ cliEventTransport.on("error", (error) => {
224
+ this.handleError(error);
225
+ });
226
+ // Register the CLI event transport endpoints
227
+ cliEventTransport.register();
228
+ console.log("✅ CLI event transport registered");
229
+ console.log(" Event listener: listening for AgentSessionCreated events");
230
+ }
231
+ else {
232
+ // Linear mode: Create and register LinearEventTransport
233
+ const useDirectWebhooks = process.env.LINEAR_DIRECT_WEBHOOKS?.toLowerCase() === "true";
234
+ const verificationMode = useDirectWebhooks ? "direct" : "proxy";
235
+ // Get appropriate secret based on mode
236
+ const secret = useDirectWebhooks
237
+ ? process.env.LINEAR_WEBHOOK_SECRET || ""
238
+ : process.env.CYRUS_API_KEY || "";
239
+ this.linearEventTransport = new LinearEventTransport({
240
+ fastifyServer: this.sharedApplicationServer.getFastifyInstance(),
241
+ verificationMode,
242
+ secret,
243
+ });
244
+ // Listen for webhook events
245
+ this.linearEventTransport.on("event", (event) => {
246
+ // Get all active repositories for webhook handling
247
+ const repos = Array.from(this.repositories.values());
248
+ this.handleWebhook(event, repos);
249
+ });
250
+ // Listen for errors
251
+ this.linearEventTransport.on("error", (error) => {
252
+ this.handleError(error);
253
+ });
254
+ // Register the /webhook endpoint
255
+ this.linearEventTransport.register();
256
+ console.log(`✅ Linear event transport registered (${verificationMode} mode)`);
257
+ console.log(` Webhook endpoint: ${this.sharedApplicationServer.getWebhookUrl()}`);
258
+ }
259
+ // 2. Create and register ConfigUpdater (both platforms)
189
260
  this.configUpdater = new ConfigUpdater(this.sharedApplicationServer.getFastifyInstance(), this.cyrusHome, process.env.CYRUS_API_KEY || "");
190
261
  // Register config update routes
191
262
  this.configUpdater.register();
192
263
  console.log("✅ Config updater registered");
193
264
  console.log(" Routes: /api/update/cyrus-config, /api/update/cyrus-env,");
194
265
  console.log(" /api/update/repository, /api/test-mcp, /api/configure-mcp");
266
+ // 3. Register /status endpoint for process activity monitoring
267
+ this.registerStatusEndpoint();
268
+ }
269
+ /**
270
+ * Register the /status endpoint for checking if the process is busy or idle
271
+ * This endpoint is used to determine if the process can be safely restarted
272
+ */
273
+ registerStatusEndpoint() {
274
+ const fastify = this.sharedApplicationServer.getFastifyInstance();
275
+ fastify.get("/status", async (_request, reply) => {
276
+ const status = this.computeStatus();
277
+ return reply.status(200).send({ status });
278
+ });
279
+ console.log("✅ Status endpoint registered");
280
+ console.log(" Route: GET /status");
281
+ }
282
+ /**
283
+ * Compute the current status of the Cyrus process
284
+ * @returns "idle" if the process can be safely restarted, "busy" if work is in progress
285
+ */
286
+ computeStatus() {
287
+ // Busy if any webhooks are currently being processed
288
+ if (this.activeWebhookCount > 0) {
289
+ return "busy";
290
+ }
291
+ // Busy if any runner is actively running
292
+ for (const manager of this.agentSessionManagers.values()) {
293
+ const runners = manager.getAllAgentRunners();
294
+ for (const runner of runners) {
295
+ if (runner.isRunning()) {
296
+ return "busy";
297
+ }
298
+ }
299
+ }
300
+ return "idle";
195
301
  }
196
302
  /**
197
303
  * Stop the edge worker
@@ -309,7 +415,7 @@ export class EdgeWorker extends EventEmitter {
309
415
  async handleSubroutineTransition(linearAgentActivitySessionId, session, repo, agentSessionManager) {
310
416
  console.log(`[Subroutine Transition] Handling subroutine completion for session ${linearAgentActivitySessionId}`);
311
417
  // Get next subroutine (advancement already handled by AgentSessionManager)
312
- const nextSubroutine = this.procedureRouter.getCurrentSubroutine(session);
418
+ const nextSubroutine = this.procedureAnalyzer.getCurrentSubroutine(session);
313
419
  if (!nextSubroutine) {
314
420
  console.log(`[Subroutine Transition] Procedure complete for session ${linearAgentActivitySessionId}`);
315
421
  return;
@@ -341,6 +447,51 @@ export class EdgeWorker extends EventEmitter {
341
447
  console.error(`[Subroutine Transition] Failed to resume session for ${nextSubroutine.name} subroutine:`, error);
342
448
  }
343
449
  }
450
+ /**
451
+ * Handle validation loop fixer - run the fixer prompt
452
+ */
453
+ async handleValidationLoopFixer(linearAgentActivitySessionId, session, repo, agentSessionManager, fixerPrompt, iteration) {
454
+ console.log(`[Validation Loop] Running fixer for session ${linearAgentActivitySessionId}, iteration ${iteration}`);
455
+ try {
456
+ await this.resumeAgentSession(session, repo, linearAgentActivitySessionId, agentSessionManager, fixerPrompt, "", // No attachment manifest
457
+ false, // Not a new session
458
+ [], // No additional allowed directories
459
+ undefined);
460
+ console.log(`[Validation Loop] Successfully started fixer for iteration ${iteration}`);
461
+ }
462
+ catch (error) {
463
+ console.error(`[Validation Loop] Failed to run fixer for iteration ${iteration}:`, error);
464
+ }
465
+ }
466
+ /**
467
+ * Handle validation loop rerun - re-run the verifications subroutine
468
+ */
469
+ async handleValidationLoopRerun(linearAgentActivitySessionId, session, repo, agentSessionManager) {
470
+ console.log(`[Validation Loop] Re-running verifications for session ${linearAgentActivitySessionId}`);
471
+ // Get the verifications subroutine definition
472
+ const verificationsSubroutine = this.procedureAnalyzer.getCurrentSubroutine(session);
473
+ if (!verificationsSubroutine ||
474
+ verificationsSubroutine.name !== "verifications") {
475
+ console.error(`[Validation Loop] Expected verifications subroutine, got: ${verificationsSubroutine?.name}`);
476
+ return;
477
+ }
478
+ try {
479
+ // Load the verifications prompt
480
+ const subroutinePrompt = await this.loadSubroutinePrompt(verificationsSubroutine, this.config.linearWorkspaceSlug);
481
+ if (!subroutinePrompt) {
482
+ console.error(`[Validation Loop] Failed to load verifications prompt`);
483
+ return;
484
+ }
485
+ await this.resumeAgentSession(session, repo, linearAgentActivitySessionId, agentSessionManager, subroutinePrompt, "", // No attachment manifest
486
+ false, // Not a new session
487
+ [], // No additional allowed directories
488
+ undefined);
489
+ console.log(`[Validation Loop] Successfully re-started verifications`);
490
+ }
491
+ catch (error) {
492
+ console.error(`[Validation Loop] Failed to re-run verifications:`, error);
493
+ }
494
+ }
344
495
  /**
345
496
  * Start watching config file for changes
346
497
  */
@@ -506,21 +657,35 @@ export class EdgeWorker extends EventEmitter {
506
657
  // Add to internal map
507
658
  this.repositories.set(repo.id, resolvedRepo);
508
659
  // Create issue tracker
509
- const linearClient = new LinearClient({
510
- accessToken: repo.linearToken,
511
- });
512
- const issueTracker = new LinearIssueTrackerService(linearClient);
660
+ const issueTracker = this.config.platform === "cli"
661
+ ? (() => {
662
+ const service = new CLIIssueTrackerService();
663
+ service.seedDefaultData();
664
+ return service;
665
+ })()
666
+ : new LinearIssueTrackerService(new LinearClient({
667
+ accessToken: repo.linearToken,
668
+ }));
513
669
  this.issueTrackers.set(repo.id, issueTracker);
514
670
  // Create AgentSessionManager with same pattern as constructor
515
671
  const agentSessionManager = new AgentSessionManager(issueTracker, (childSessionId) => {
516
672
  return this.childToParentAgentSession.get(childSessionId);
517
673
  }, async (parentSessionId, prompt, childSessionId) => {
518
674
  await this.handleResumeParentSession(parentSessionId, prompt, childSessionId, repo, agentSessionManager);
519
- }, this.procedureRouter, this.sharedApplicationServer);
675
+ }, this.procedureAnalyzer, this.sharedApplicationServer);
520
676
  // Subscribe to subroutine completion events
521
677
  agentSessionManager.on("subroutineComplete", async ({ linearAgentActivitySessionId, session }) => {
522
678
  await this.handleSubroutineTransition(linearAgentActivitySessionId, session, repo, agentSessionManager);
523
679
  });
680
+ // Subscribe to validation loop events
681
+ agentSessionManager.on("validationLoopIteration", async ({ linearAgentActivitySessionId, session, fixerPrompt, iteration, maxIterations, }) => {
682
+ console.log(`[EdgeWorker] Validation loop iteration ${iteration}/${maxIterations}, running fixer`);
683
+ await this.handleValidationLoopFixer(linearAgentActivitySessionId, session, repo, agentSessionManager, fixerPrompt, iteration);
684
+ });
685
+ agentSessionManager.on("validationLoopRerun", async ({ linearAgentActivitySessionId, session, iteration }) => {
686
+ console.log(`[EdgeWorker] Validation loop re-running verifications (iteration ${iteration})`);
687
+ await this.handleValidationLoopRerun(linearAgentActivitySessionId, session, repo, agentSessionManager);
688
+ });
524
689
  this.agentSessionManagers.set(repo.id, agentSessionManager);
525
690
  console.log(`✅ Repository added successfully: ${repo.name}`);
526
691
  }
@@ -563,10 +728,15 @@ export class EdgeWorker extends EventEmitter {
563
728
  // If token changed, recreate issue tracker
564
729
  if (oldRepo.linearToken !== repo.linearToken) {
565
730
  console.log(` 🔑 Token changed, recreating issue tracker`);
566
- const linearClient = new LinearClient({
567
- accessToken: repo.linearToken,
568
- });
569
- const issueTracker = new LinearIssueTrackerService(linearClient);
731
+ const issueTracker = this.config.platform === "cli"
732
+ ? (() => {
733
+ const service = new CLIIssueTrackerService();
734
+ service.seedDefaultData();
735
+ return service;
736
+ })()
737
+ : new LinearIssueTrackerService(new LinearClient({
738
+ accessToken: repo.linearToken,
739
+ }));
570
740
  this.issueTrackers.set(repo.id, issueTracker);
571
741
  }
572
742
  // If active status changed
@@ -654,6 +824,8 @@ export class EdgeWorker extends EventEmitter {
654
824
  * Handle webhook events from proxy - main router for all webhooks
655
825
  */
656
826
  async handleWebhook(webhook, repos) {
827
+ // Track active webhook processing for status endpoint
828
+ this.activeWebhookCount++;
657
829
  // Log verbose webhook info if enabled
658
830
  if (process.env.CYRUS_WEBHOOK_DEBUG === "true") {
659
831
  console.log(`[handleWebhook] Full webhook payload:`, JSON.stringify(webhook, null, 2));
@@ -691,6 +863,10 @@ export class EdgeWorker extends EventEmitter {
691
863
  // Don't re-throw webhook processing errors to prevent application crashes
692
864
  // The error has been logged and individual webhook failures shouldn't crash the entire system
693
865
  }
866
+ finally {
867
+ // Always decrement counter when webhook processing completes
868
+ this.activeWebhookCount--;
869
+ }
694
870
  }
695
871
  /**
696
872
  * Handle issue unassignment webhook
@@ -742,12 +918,10 @@ export class EdgeWorker extends EventEmitter {
742
918
  // Move issue to started state automatically, in case it's not already
743
919
  await this.moveIssueToStartedState(fullIssue, repository.id);
744
920
  // Create workspace using full issue data
921
+ // Use custom handler if provided, otherwise create a git worktree by default
745
922
  const workspace = this.config.handlers?.createWorkspace
746
923
  ? await this.config.handlers.createWorkspace(fullIssue, repository)
747
- : {
748
- path: `${repository.workspaceBaseDir}/${fullIssue.identifier}`,
749
- isGitWorktree: false,
750
- };
924
+ : await this.gitService.createGitWorktree(fullIssue, repository);
751
925
  console.log(`[EdgeWorker] Workspace created at: ${workspace.path}`);
752
926
  const issueMinimal = this.convertLinearIssueToCore(fullIssue);
753
927
  agentSessionManager.createLinearAgentSession(linearAgentActivitySessionId, issue.id, issueMinimal, workspace);
@@ -893,7 +1067,7 @@ export class EdgeWorker extends EventEmitter {
893
1067
  session.metadata = {};
894
1068
  }
895
1069
  // Post ephemeral "Routing..." thought
896
- await agentSessionManager.postRoutingThought(linearAgentActivitySessionId);
1070
+ await agentSessionManager.postAnalyzingThought(linearAgentActivitySessionId);
897
1071
  // Fetch labels early (needed for label override check)
898
1072
  const labels = await this.fetchIssueLabels(fullIssue);
899
1073
  // Check for label overrides BEFORE AI routing
@@ -905,13 +1079,19 @@ export class EdgeWorker extends EventEmitter {
905
1079
  const orchestratorConfig = repository.labelPrompts?.orchestrator;
906
1080
  const orchestratorLabels = Array.isArray(orchestratorConfig)
907
1081
  ? orchestratorConfig
908
- : orchestratorConfig?.labels;
1082
+ : (orchestratorConfig?.labels ?? ["orchestrator"]);
909
1083
  const hasOrchestratorLabel = orchestratorLabels?.some((label) => labels.includes(label));
1084
+ // Check for graphite label (for graphite-orchestrator combination)
1085
+ const graphiteConfig = repository.labelPrompts?.graphite;
1086
+ const graphiteLabels = graphiteConfig?.labels ?? ["graphite"];
1087
+ const hasGraphiteLabel = graphiteLabels?.some((label) => labels.includes(label));
1088
+ // Graphite-orchestrator requires BOTH graphite AND orchestrator labels
1089
+ const hasGraphiteOrchestratorLabels = hasGraphiteLabel && hasOrchestratorLabel;
910
1090
  let finalProcedure;
911
1091
  let finalClassification;
912
1092
  // If labels indicate a specific procedure, use that instead of AI routing
913
1093
  if (hasDebuggerLabel) {
914
- const debuggerProcedure = this.procedureRouter.getProcedure("debugger-full");
1094
+ const debuggerProcedure = this.procedureAnalyzer.getProcedure("debugger-full");
915
1095
  if (!debuggerProcedure) {
916
1096
  throw new Error("debugger-full procedure not found in registry");
917
1097
  }
@@ -919,8 +1099,19 @@ export class EdgeWorker extends EventEmitter {
919
1099
  finalClassification = "debugger";
920
1100
  console.log(`[EdgeWorker] Using debugger-full procedure due to debugger label (skipping AI routing)`);
921
1101
  }
1102
+ else if (hasGraphiteOrchestratorLabels) {
1103
+ // Graphite-orchestrator takes precedence over regular orchestrator when both labels present
1104
+ const orchestratorProcedure = this.procedureAnalyzer.getProcedure("orchestrator-full");
1105
+ if (!orchestratorProcedure) {
1106
+ throw new Error("orchestrator-full procedure not found in registry");
1107
+ }
1108
+ finalProcedure = orchestratorProcedure;
1109
+ // Use orchestrator classification but the system prompt will be graphite-orchestrator
1110
+ finalClassification = "orchestrator";
1111
+ console.log(`[EdgeWorker] Using orchestrator-full procedure with graphite-orchestrator prompt (graphite + orchestrator labels)`);
1112
+ }
922
1113
  else if (hasOrchestratorLabel) {
923
- const orchestratorProcedure = this.procedureRouter.getProcedure("orchestrator-full");
1114
+ const orchestratorProcedure = this.procedureAnalyzer.getProcedure("orchestrator-full");
924
1115
  if (!orchestratorProcedure) {
925
1116
  throw new Error("orchestrator-full procedure not found in registry");
926
1117
  }
@@ -931,7 +1122,7 @@ export class EdgeWorker extends EventEmitter {
931
1122
  else {
932
1123
  // No label override - use AI routing
933
1124
  const issueDescription = `${issue.title}\n\n${fullIssue.description || ""}`.trim();
934
- const routingDecision = await this.procedureRouter.determineRoutine(issueDescription);
1125
+ const routingDecision = await this.procedureAnalyzer.determineRoutine(issueDescription);
935
1126
  finalProcedure = routingDecision.procedure;
936
1127
  finalClassification = routingDecision.classification;
937
1128
  // Log AI routing decision
@@ -941,7 +1132,7 @@ export class EdgeWorker extends EventEmitter {
941
1132
  console.log(` Reasoning: ${routingDecision.reasoning}`);
942
1133
  }
943
1134
  // Initialize procedure metadata in session with final decision
944
- this.procedureRouter.initializeProcedureMetadata(session, finalProcedure);
1135
+ this.procedureAnalyzer.initializeProcedureMetadata(session, finalProcedure);
945
1136
  // Post single procedure selection result (replaces ephemeral routing thought)
946
1137
  await agentSessionManager.postProcedureSelectionThought(linearAgentActivitySessionId, finalProcedure.name, finalClassification);
947
1138
  // Build and start Claude with initial prompt using full issue (streaming mode)
@@ -986,7 +1177,7 @@ export class EdgeWorker extends EventEmitter {
986
1177
  console.log(`[EdgeWorker] Configured disallowed tools for ${fullIssue.identifier}:`, disallowedTools);
987
1178
  }
988
1179
  // Get current subroutine to check for singleTurn mode
989
- const currentSubroutine = this.procedureRouter.getCurrentSubroutine(session);
1180
+ const currentSubroutine = this.procedureAnalyzer.getCurrentSubroutine(session);
990
1181
  // Create agent runner with system prompt from assembly
991
1182
  // buildAgentRunnerConfig now determines runner type from labels internally
992
1183
  const { config: runnerConfig, runnerType } = this.buildAgentRunnerConfig(session, repository, linearAgentActivitySessionId, assembly.systemPrompt, allowedTools, allowedDirectories, disallowedTools, undefined, // resumeSessionId
@@ -1001,7 +1192,7 @@ export class EdgeWorker extends EventEmitter {
1001
1192
  agentSessionManager.addAgentRunner(linearAgentActivitySessionId, runner);
1002
1193
  // Save state after mapping changes
1003
1194
  await this.savePersistedState();
1004
- // Emit events using full Linear issue
1195
+ // Emit events using full issue (core Issue type)
1005
1196
  this.emit("session:started", fullIssue.id, fullIssue, repository.id);
1006
1197
  this.config.handlers?.onSessionStart?.(fullIssue.id, fullIssue, repository.id);
1007
1198
  // Update runner with version information (if available)
@@ -1110,6 +1301,33 @@ export class EdgeWorker extends EventEmitter {
1110
1301
  // Initialize agent runner with the selected repository
1111
1302
  await this.initializeAgentRunner(agentSession, repository, guidance, commentBody);
1112
1303
  }
1304
+ /**
1305
+ * Handle AskUserQuestion response from prompted webhook
1306
+ * Branch 2.5: User response to a question posed via AskUserQuestion tool
1307
+ *
1308
+ * @param webhook The prompted webhook containing user's response
1309
+ */
1310
+ async handleAskUserQuestionResponse(webhook) {
1311
+ const { agentSession, agentActivity } = webhook;
1312
+ const agentSessionId = agentSession.id;
1313
+ if (!agentActivity) {
1314
+ console.warn("[EdgeWorker] Cannot handle AskUserQuestion response without agentActivity");
1315
+ // Resolve with a denial to unblock the waiting promise
1316
+ this.askUserQuestionHandler.cancelPendingQuestion(agentSessionId, "No agent activity in webhook");
1317
+ return;
1318
+ }
1319
+ // Extract the user's response from the activity body
1320
+ const userResponse = agentActivity.content?.body || "";
1321
+ console.log(`[EdgeWorker] Processing AskUserQuestion response for session ${agentSessionId}: "${userResponse}"`);
1322
+ // Pass the response to the handler to resolve the waiting promise
1323
+ const handled = this.askUserQuestionHandler.handleUserResponse(agentSessionId, userResponse);
1324
+ if (!handled) {
1325
+ console.warn(`[EdgeWorker] AskUserQuestion response not handled for session ${agentSessionId} (no pending question)`);
1326
+ }
1327
+ else {
1328
+ console.log(`[EdgeWorker] AskUserQuestion response handled for session ${agentSessionId}`);
1329
+ }
1330
+ }
1113
1331
  /**
1114
1332
  * Handle normal prompted activity (existing session continuation)
1115
1333
  * Branch 3 of agentSessionPrompted (see packages/CLAUDE.md)
@@ -1149,6 +1367,7 @@ export class EdgeWorker extends EventEmitter {
1149
1367
  console.log(`[EdgeWorker] Created new session ${linearAgentActivitySessionId} (prompted webhook)`);
1150
1368
  // Save state and emit events for new session
1151
1369
  await this.savePersistedState();
1370
+ // Emit events using full issue (core Issue type)
1152
1371
  this.emit("session:started", fullIssue.id, fullIssue, repository.id);
1153
1372
  this.config.handlers?.onSessionStart?.(fullIssue.id, fullIssue, repository.id);
1154
1373
  }
@@ -1263,6 +1482,13 @@ export class EdgeWorker extends EventEmitter {
1263
1482
  await this.handleRepositorySelectionResponse(webhook);
1264
1483
  return;
1265
1484
  }
1485
+ // Branch 2.5: Handle AskUserQuestion response
1486
+ // This handles responses to questions posed via the AskUserQuestion tool.
1487
+ // The response is passed to the pending promise resolver.
1488
+ if (this.askUserQuestionHandler.hasPendingQuestion(agentSessionId)) {
1489
+ await this.handleAskUserQuestionResponse(webhook);
1490
+ return;
1491
+ }
1266
1492
  // Branch 3: Handle normal prompted activity (existing session continuation)
1267
1493
  // Per CLAUDE.md: "an agentSession MUST exist and a repository MUST already
1268
1494
  // be associated with the Linear issue. The repository will be retrieved from
@@ -1321,7 +1547,11 @@ export class EdgeWorker extends EventEmitter {
1321
1547
  */
1322
1548
  async handleClaudeError(error) {
1323
1549
  // AbortError is expected when user stops Claude process, don't log it
1324
- if (error instanceof AbortError) {
1550
+ // Check by name since the SDK's AbortError class may not match our imported definition
1551
+ const isAbortError = error.name === "AbortError" || error.message.includes("aborted by user");
1552
+ // Also check for SIGTERM (exit code 143), which indicates graceful termination
1553
+ const isSigterm = error.message.includes("Claude Code process exited with code 143");
1554
+ if (isAbortError || isSigterm) {
1325
1555
  return;
1326
1556
  }
1327
1557
  console.error("Unhandled claude error:", error);
@@ -1353,8 +1583,8 @@ export class EdgeWorker extends EventEmitter {
1353
1583
  if (!labels || labels.length === 0) {
1354
1584
  return {
1355
1585
  runnerType: "claude",
1356
- modelOverride: "sonnet",
1357
- fallbackModelOverride: "haiku",
1586
+ modelOverride: "opus",
1587
+ fallbackModelOverride: "sonnet",
1358
1588
  };
1359
1589
  }
1360
1590
  const lowercaseLabels = labels.map((label) => label.toLowerCase());
@@ -1415,8 +1645,8 @@ export class EdgeWorker extends EventEmitter {
1415
1645
  // Default to claude if no runner labels found
1416
1646
  return {
1417
1647
  runnerType: "claude",
1418
- modelOverride: "sonnet",
1419
- fallbackModelOverride: "haiku",
1648
+ modelOverride: "opus",
1649
+ fallbackModelOverride: "sonnet",
1420
1650
  };
1421
1651
  }
1422
1652
  /**
@@ -1426,6 +1656,38 @@ export class EdgeWorker extends EventEmitter {
1426
1656
  if (!repository.labelPrompts || labels.length === 0) {
1427
1657
  return undefined;
1428
1658
  }
1659
+ // Check for graphite-orchestrator first (requires BOTH graphite AND orchestrator labels)
1660
+ const graphiteConfig = repository.labelPrompts.graphite;
1661
+ const graphiteLabels = graphiteConfig?.labels ?? ["graphite"];
1662
+ const hasGraphiteLabel = graphiteLabels?.some((label) => labels.includes(label));
1663
+ const orchestratorConfig = repository.labelPrompts.orchestrator;
1664
+ const orchestratorLabels = Array.isArray(orchestratorConfig)
1665
+ ? orchestratorConfig
1666
+ : (orchestratorConfig?.labels ?? ["orchestrator"]);
1667
+ const hasOrchestratorLabel = orchestratorLabels?.some((label) => labels.includes(label));
1668
+ // If both graphite AND orchestrator labels are present, use graphite-orchestrator prompt
1669
+ if (hasGraphiteLabel && hasOrchestratorLabel) {
1670
+ try {
1671
+ const __filename = fileURLToPath(import.meta.url);
1672
+ const __dirname = dirname(__filename);
1673
+ const promptPath = join(__dirname, "..", "prompts", "graphite-orchestrator.md");
1674
+ const promptContent = await readFile(promptPath, "utf-8");
1675
+ console.log(`[EdgeWorker] Using graphite-orchestrator system prompt for labels: ${labels.join(", ")}`);
1676
+ const promptVersion = this.extractVersionTag(promptContent);
1677
+ if (promptVersion) {
1678
+ console.log(`[EdgeWorker] graphite-orchestrator system prompt version: ${promptVersion}`);
1679
+ }
1680
+ return {
1681
+ prompt: promptContent,
1682
+ version: promptVersion,
1683
+ type: "graphite-orchestrator",
1684
+ };
1685
+ }
1686
+ catch (error) {
1687
+ console.error(`[EdgeWorker] Failed to load graphite-orchestrator prompt template:`, error);
1688
+ // Fall through to regular orchestrator if graphite-orchestrator prompt fails
1689
+ }
1690
+ }
1429
1691
  // Check each prompt type for matching labels
1430
1692
  const promptTypes = [
1431
1693
  "debugger",
@@ -1665,40 +1927,44 @@ Focus on addressing the specific request in the mention. You can use the Linear
1665
1927
  return formatted;
1666
1928
  }
1667
1929
  /**
1668
- * Check if a branch exists locally or remotely
1669
- */
1670
- async branchExists(branchName, repoPath) {
1671
- const { execSync } = await import("node:child_process");
1672
- try {
1673
- // Check if branch exists locally
1674
- execSync(`git rev-parse --verify "${branchName}"`, {
1675
- cwd: repoPath,
1676
- stdio: "pipe",
1677
- });
1678
- return true;
1679
- }
1680
- catch {
1681
- // Branch doesn't exist locally, check remote
1682
- try {
1683
- execSync(`git ls-remote --heads origin "${branchName}"`, {
1684
- cwd: repoPath,
1685
- stdio: "pipe",
1686
- });
1687
- return true;
1688
- }
1689
- catch {
1690
- // Branch doesn't exist remotely either
1691
- return false;
1692
- }
1693
- }
1694
- }
1695
- /**
1696
- * Determine the base branch for an issue, considering parent issues
1930
+ * Determine the base branch for an issue, considering parent issues and blocked-by relationships
1931
+ *
1932
+ * Priority order:
1933
+ * 1. If issue has graphite label AND has a "blocked by" relationship, use the blocking issue's branch
1934
+ * (This enables Graphite stacking where each sub-issue branches off the previous)
1935
+ * 2. If issue has a parent, use the parent's branch
1936
+ * 3. Fall back to repository's default base branch
1697
1937
  */
1698
1938
  async determineBaseBranch(issue, repository) {
1699
1939
  // Start with the repository's default base branch
1700
1940
  let baseBranch = repository.baseBranch;
1701
- // Check if issue has a parent
1941
+ // Check if this issue has the graphite label - if so, blocked-by relationship takes priority
1942
+ const isGraphiteIssue = await this.hasGraphiteLabel(issue, repository);
1943
+ if (isGraphiteIssue) {
1944
+ // For Graphite stacking: use the blocking issue's branch as base
1945
+ const blockingIssues = await this.fetchBlockingIssues(issue);
1946
+ if (blockingIssues.length > 0) {
1947
+ // Use the first blocking issue's branch (typically there's only one in a stack)
1948
+ const blockingIssue = blockingIssues[0];
1949
+ console.log(`[EdgeWorker] Issue ${issue.identifier} has graphite label and is blocked by ${blockingIssue.identifier}`);
1950
+ // Get blocking issue's branch name
1951
+ const blockingRawBranchName = blockingIssue.branchName ||
1952
+ `${blockingIssue.identifier}-${(blockingIssue.title ?? "")
1953
+ .toLowerCase()
1954
+ .replace(/\s+/g, "-")
1955
+ .substring(0, 30)}`;
1956
+ const blockingBranchName = this.gitService.sanitizeBranchName(blockingRawBranchName);
1957
+ // Check if blocking issue's branch exists
1958
+ const blockingBranchExists = await this.gitService.branchExists(blockingBranchName, repository.repositoryPath);
1959
+ if (blockingBranchExists) {
1960
+ baseBranch = blockingBranchName;
1961
+ console.log(`[EdgeWorker] Using blocking issue branch '${blockingBranchName}' as base for Graphite-stacked issue ${issue.identifier}`);
1962
+ return baseBranch;
1963
+ }
1964
+ console.log(`[EdgeWorker] Blocking issue branch '${blockingBranchName}' not found, falling back to parent/default`);
1965
+ }
1966
+ }
1967
+ // Check if issue has a parent (standard sub-issue behavior)
1702
1968
  try {
1703
1969
  const parent = await issue.parent;
1704
1970
  if (parent) {
@@ -1709,9 +1975,9 @@ Focus on addressing the specific request in the mention. You can use the Linear
1709
1975
  ?.toLowerCase()
1710
1976
  .replace(/\s+/g, "-")
1711
1977
  .substring(0, 30)}`;
1712
- const parentBranchName = this.sanitizeBranchName(parentRawBranchName);
1978
+ const parentBranchName = this.gitService.sanitizeBranchName(parentRawBranchName);
1713
1979
  // Check if parent branch exists
1714
- const parentBranchExists = await this.branchExists(parentBranchName, repository.repositoryPath);
1980
+ const parentBranchExists = await this.gitService.branchExists(parentBranchName, repository.repositoryPath);
1715
1981
  if (parentBranchExists) {
1716
1982
  baseBranch = parentBranchName;
1717
1983
  console.log(`[EdgeWorker] Using parent issue branch '${parentBranchName}' as base for sub-issue ${issue.identifier}`);
@@ -1740,10 +2006,60 @@ Focus on addressing the specific request in the mention. You can use the Linear
1740
2006
  };
1741
2007
  }
1742
2008
  /**
1743
- * Sanitize branch name by removing backticks to prevent command injection
2009
+ * Fetch issues that block this issue (i.e., issues this one is "blocked by")
2010
+ * Uses the inverseRelations field with type "blocks"
2011
+ *
2012
+ * Linear relations work like this:
2013
+ * - When Issue A "blocks" Issue B, a relation is created with:
2014
+ * - issue = A (the blocker)
2015
+ * - relatedIssue = B (the blocked one)
2016
+ * - type = "blocks"
2017
+ *
2018
+ * So to find "who blocks Issue B", we need inverseRelations (where B is the relatedIssue)
2019
+ * and look for type === "blocks", then get the `issue` field (the blocker).
2020
+ *
2021
+ * @param issue The issue to fetch blocking issues for
2022
+ * @returns Array of issues that block this one, or empty array if none
2023
+ */
2024
+ async fetchBlockingIssues(issue) {
2025
+ try {
2026
+ // inverseRelations contains relations where THIS issue is the relatedIssue
2027
+ // When type is "blocks", it means the `issue` field blocks THIS issue
2028
+ const inverseRelations = await issue.inverseRelations();
2029
+ if (!inverseRelations?.nodes) {
2030
+ return [];
2031
+ }
2032
+ const blockingIssues = [];
2033
+ for (const relation of inverseRelations.nodes) {
2034
+ // "blocks" type in inverseRelations means the `issue` blocks this one
2035
+ if (relation.type === "blocks") {
2036
+ // The `issue` field is the one that blocks THIS issue
2037
+ const blockingIssue = await relation.issue;
2038
+ if (blockingIssue) {
2039
+ blockingIssues.push(blockingIssue);
2040
+ }
2041
+ }
2042
+ }
2043
+ console.log(`[EdgeWorker] Issue ${issue.identifier} is blocked by ${blockingIssues.length} issue(s): ${blockingIssues.map((i) => i.identifier).join(", ") || "none"}`);
2044
+ return blockingIssues;
2045
+ }
2046
+ catch (error) {
2047
+ console.error(`[EdgeWorker] Failed to fetch blocking issues for ${issue.identifier}:`, error);
2048
+ return [];
2049
+ }
2050
+ }
2051
+ /**
2052
+ * Check if an issue has the graphite label
2053
+ *
2054
+ * @param issue The issue to check
2055
+ * @param repository The repository configuration
2056
+ * @returns True if the issue has the graphite label
1744
2057
  */
1745
- sanitizeBranchName(name) {
1746
- return name ? name.replace(/`/g, "") : name;
2058
+ async hasGraphiteLabel(issue, repository) {
2059
+ const graphiteConfig = repository.labelPrompts?.graphite;
2060
+ const graphiteLabels = graphiteConfig?.labels ?? ["graphite"];
2061
+ const issueLabels = await this.fetchIssueLabels(issue);
2062
+ return graphiteLabels.some((label) => issueLabels.includes(label));
1747
2063
  }
1748
2064
  /**
1749
2065
  * Format Linear comments into a threaded structure that mirrors the Linear UI
@@ -1887,7 +2203,7 @@ ${reply.body}
1887
2203
  ? "Will be created based on issue"
1888
2204
  : repository.repositoryPath)
1889
2205
  .replace(/{{base_branch}}/g, baseBranch)
1890
- .replace(/{{branch_name}}/g, this.sanitizeBranchName(issue.branchName));
2206
+ .replace(/{{branch_name}}/g, this.gitService.sanitizeBranchName(issue.branchName));
1891
2207
  // Handle the optional new comment section
1892
2208
  if (newComment) {
1893
2209
  // Replace the conditional block
@@ -2709,7 +3025,7 @@ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ""}Please ana
2709
3025
  parts.push(issueContext.prompt);
2710
3026
  components.push("issue-context");
2711
3027
  // 4. Load and append initial subroutine prompt
2712
- const currentSubroutine = this.procedureRouter.getCurrentSubroutine(input.session);
3028
+ const currentSubroutine = this.procedureAnalyzer.getCurrentSubroutine(input.session);
2713
3029
  let subroutineName;
2714
3030
  if (currentSubroutine) {
2715
3031
  const subroutinePrompt = await this.loadSubroutinePrompt(currentSubroutine, this.config.linearWorkspaceSlug);
@@ -2936,6 +3252,12 @@ ${input.userComment}
2936
3252
  repository.fallbackModel ||
2937
3253
  this.config.defaultFallbackModel,
2938
3254
  hooks,
3255
+ // Enable Chrome integration for Claude runner (disabled for other runners)
3256
+ ...(runnerType === "claude" && { extraArgs: { chrome: null } }),
3257
+ // AskUserQuestion callback - only for Claude runner
3258
+ ...(runnerType === "claude" && {
3259
+ onAskUserQuestion: this.createAskUserQuestionCallback(linearAgentActivitySessionId, repository.linearWorkspaceId),
3260
+ }),
2939
3261
  onMessage: (message) => {
2940
3262
  this.handleClaudeMessage(linearAgentActivitySessionId, message, repository.id);
2941
3263
  },
@@ -2952,23 +3274,43 @@ ${input.userComment}
2952
3274
  }
2953
3275
  return { config, runnerType };
2954
3276
  }
3277
+ /**
3278
+ * Create an onAskUserQuestion callback for the ClaudeRunner.
3279
+ * This callback delegates to the AskUserQuestionHandler which posts
3280
+ * elicitations to Linear and waits for user responses.
3281
+ *
3282
+ * @param linearAgentSessionId - Linear agent session ID for tracking
3283
+ * @param organizationId - Linear organization/workspace ID
3284
+ */
3285
+ createAskUserQuestionCallback(linearAgentSessionId, organizationId) {
3286
+ return async (input, _sessionId, signal) => {
3287
+ // Note: We use linearAgentSessionId (from closure) instead of the passed sessionId
3288
+ // because the passed sessionId is the Claude session ID, not the Linear agent session ID
3289
+ return this.askUserQuestionHandler.handleAskUserQuestion(input, linearAgentSessionId, organizationId, signal);
3290
+ };
3291
+ }
2955
3292
  /**
2956
3293
  * Build disallowed tools list following the same hierarchy as allowed tools
2957
3294
  */
2958
3295
  buildDisallowedTools(repository, promptType) {
3296
+ // graphite-orchestrator uses the same tool config as orchestrator
3297
+ const effectivePromptType = promptType === "graphite-orchestrator" ? "orchestrator" : promptType;
2959
3298
  let disallowedTools = [];
2960
3299
  let toolSource = "";
2961
3300
  // Priority order (same as allowedTools):
2962
3301
  // 1. Repository-specific prompt type configuration
2963
- if (promptType && repository.labelPrompts?.[promptType]?.disallowedTools) {
2964
- disallowedTools = repository.labelPrompts[promptType].disallowedTools;
2965
- toolSource = `repository label prompt (${promptType})`;
3302
+ if (effectivePromptType &&
3303
+ repository.labelPrompts?.[effectivePromptType]?.disallowedTools) {
3304
+ disallowedTools =
3305
+ repository.labelPrompts[effectivePromptType].disallowedTools;
3306
+ toolSource = `repository label prompt (${effectivePromptType})`;
2966
3307
  }
2967
3308
  // 2. Global prompt type defaults
2968
- else if (promptType &&
2969
- this.config.promptDefaults?.[promptType]?.disallowedTools) {
2970
- disallowedTools = this.config.promptDefaults[promptType].disallowedTools;
2971
- toolSource = `global prompt defaults (${promptType})`;
3309
+ else if (effectivePromptType &&
3310
+ this.config.promptDefaults?.[effectivePromptType]?.disallowedTools) {
3311
+ disallowedTools =
3312
+ this.config.promptDefaults[effectivePromptType].disallowedTools;
3313
+ toolSource = `global prompt defaults (${effectivePromptType})`;
2972
3314
  }
2973
3315
  // 3. Repository-level disallowed tools
2974
3316
  else if (repository.disallowedTools) {
@@ -2998,7 +3340,7 @@ ${input.userComment}
2998
3340
  * @returns Merged disallowed tools list
2999
3341
  */
3000
3342
  mergeSubroutineDisallowedTools(session, baseDisallowedTools, logContext) {
3001
- const currentSubroutine = this.procedureRouter.getCurrentSubroutine(session);
3343
+ const currentSubroutine = this.procedureAnalyzer.getCurrentSubroutine(session);
3002
3344
  if (currentSubroutine?.disallowedTools) {
3003
3345
  const mergedTools = [
3004
3346
  ...new Set([
@@ -3015,19 +3357,22 @@ ${input.userComment}
3015
3357
  * Build allowed tools list with Linear MCP tools automatically included
3016
3358
  */
3017
3359
  buildAllowedTools(repository, promptType) {
3360
+ // graphite-orchestrator uses the same tool config as orchestrator
3361
+ const effectivePromptType = promptType === "graphite-orchestrator" ? "orchestrator" : promptType;
3018
3362
  let baseTools = [];
3019
3363
  let toolSource = "";
3020
3364
  // Priority order:
3021
3365
  // 1. Repository-specific prompt type configuration
3022
- if (promptType && repository.labelPrompts?.[promptType]?.allowedTools) {
3023
- baseTools = this.resolveToolPreset(repository.labelPrompts[promptType].allowedTools);
3024
- toolSource = `repository label prompt (${promptType})`;
3366
+ if (effectivePromptType &&
3367
+ repository.labelPrompts?.[effectivePromptType]?.allowedTools) {
3368
+ baseTools = this.resolveToolPreset(repository.labelPrompts[effectivePromptType].allowedTools);
3369
+ toolSource = `repository label prompt (${effectivePromptType})`;
3025
3370
  }
3026
3371
  // 2. Global prompt type defaults
3027
- else if (promptType &&
3028
- this.config.promptDefaults?.[promptType]?.allowedTools) {
3029
- baseTools = this.resolveToolPreset(this.config.promptDefaults[promptType].allowedTools);
3030
- toolSource = `global prompt defaults (${promptType})`;
3372
+ else if (effectivePromptType &&
3373
+ this.config.promptDefaults?.[effectivePromptType]?.allowedTools) {
3374
+ baseTools = this.resolveToolPreset(this.config.promptDefaults[effectivePromptType].allowedTools);
3375
+ toolSource = `global prompt defaults (${effectivePromptType})`;
3031
3376
  }
3032
3377
  // 3. Repository-level allowed tools
3033
3378
  else if (repository.allowedTools) {
@@ -3261,7 +3606,7 @@ ${input.userComment}
3261
3606
  session.metadata = {};
3262
3607
  }
3263
3608
  // Post ephemeral "Routing..." thought
3264
- await agentSessionManager.postRoutingThought(linearAgentActivitySessionId);
3609
+ await agentSessionManager.postAnalyzingThought(linearAgentActivitySessionId);
3265
3610
  // Fetch full issue and labels to check for Orchestrator label override
3266
3611
  const issueTracker = this.issueTrackers.get(repository.id);
3267
3612
  let hasOrchestratorLabel = false;
@@ -3273,7 +3618,7 @@ ${input.userComment}
3273
3618
  const orchestratorConfig = repository.labelPrompts?.orchestrator;
3274
3619
  const orchestratorLabels = Array.isArray(orchestratorConfig)
3275
3620
  ? orchestratorConfig
3276
- : orchestratorConfig?.labels;
3621
+ : (orchestratorConfig?.labels ?? ["orchestrator"]);
3277
3622
  hasOrchestratorLabel =
3278
3623
  orchestratorLabels?.some((label) => labels.includes(label)) || false;
3279
3624
  }
@@ -3286,7 +3631,7 @@ ${input.userComment}
3286
3631
  let finalClassification;
3287
3632
  // If Orchestrator label is present, ALWAYS use orchestrator-full procedure
3288
3633
  if (hasOrchestratorLabel) {
3289
- const orchestratorProcedure = this.procedureRouter.getProcedure("orchestrator-full");
3634
+ const orchestratorProcedure = this.procedureAnalyzer.getProcedure("orchestrator-full");
3290
3635
  if (!orchestratorProcedure) {
3291
3636
  throw new Error("orchestrator-full procedure not found in registry");
3292
3637
  }
@@ -3296,7 +3641,7 @@ ${input.userComment}
3296
3641
  }
3297
3642
  else {
3298
3643
  // No Orchestrator label - use AI routing based on prompt content
3299
- const routingDecision = await this.procedureRouter.determineRoutine(promptBody.trim());
3644
+ const routingDecision = await this.procedureAnalyzer.determineRoutine(promptBody.trim());
3300
3645
  selectedProcedure = routingDecision.procedure;
3301
3646
  finalClassification = routingDecision.classification;
3302
3647
  // Log AI routing decision
@@ -3306,7 +3651,7 @@ ${input.userComment}
3306
3651
  console.log(` Reasoning: ${routingDecision.reasoning}`);
3307
3652
  }
3308
3653
  // Initialize procedure metadata in session (resets currentSubroutine)
3309
- this.procedureRouter.initializeProcedureMetadata(session, selectedProcedure);
3654
+ this.procedureAnalyzer.initializeProcedureMetadata(session, selectedProcedure);
3310
3655
  // Post procedure selection result (replaces ephemeral routing thought)
3311
3656
  await agentSessionManager.postProcedureSelectionThought(linearAgentActivitySessionId, selectedProcedure.name, finalClassification);
3312
3657
  }
@@ -3412,7 +3757,7 @@ ${input.userComment}
3412
3757
  const orchestratorConfig = repository.labelPrompts.orchestrator;
3413
3758
  const orchestratorLabels = Array.isArray(orchestratorConfig)
3414
3759
  ? orchestratorConfig
3415
- : orchestratorConfig?.labels;
3760
+ : (orchestratorConfig?.labels ?? ["orchestrator"]);
3416
3761
  const orchestratorLabel = orchestratorLabels?.find((label) => labels.includes(label));
3417
3762
  if (orchestratorLabel) {
3418
3763
  selectedPromptType = "orchestrator";
@@ -3505,7 +3850,7 @@ ${input.userComment}
3505
3850
  ...additionalAllowedDirectories,
3506
3851
  ];
3507
3852
  // Get current subroutine to check for singleTurn mode
3508
- const currentSubroutine = this.procedureRouter.getCurrentSubroutine(session);
3853
+ const currentSubroutine = this.procedureAnalyzer.getCurrentSubroutine(session);
3509
3854
  const resumeSessionId = needsNewSession
3510
3855
  ? undefined
3511
3856
  : session.claudeSessionId