@supaku/agentfactory 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/LICENSE +21 -0
  2. package/dist/src/deployment/deployment-checker.d.ts +110 -0
  3. package/dist/src/deployment/deployment-checker.d.ts.map +1 -0
  4. package/dist/src/deployment/deployment-checker.js +242 -0
  5. package/dist/src/deployment/index.d.ts +3 -0
  6. package/dist/src/deployment/index.d.ts.map +1 -0
  7. package/dist/src/deployment/index.js +2 -0
  8. package/dist/src/index.d.ts +5 -0
  9. package/dist/src/index.d.ts.map +1 -0
  10. package/dist/src/index.js +4 -0
  11. package/dist/src/logger.d.ts +117 -0
  12. package/dist/src/logger.d.ts.map +1 -0
  13. package/dist/src/logger.js +430 -0
  14. package/dist/src/orchestrator/activity-emitter.d.ts +128 -0
  15. package/dist/src/orchestrator/activity-emitter.d.ts.map +1 -0
  16. package/dist/src/orchestrator/activity-emitter.js +406 -0
  17. package/dist/src/orchestrator/api-activity-emitter.d.ts +167 -0
  18. package/dist/src/orchestrator/api-activity-emitter.d.ts.map +1 -0
  19. package/dist/src/orchestrator/api-activity-emitter.js +469 -0
  20. package/dist/src/orchestrator/heartbeat-writer.d.ts +57 -0
  21. package/dist/src/orchestrator/heartbeat-writer.d.ts.map +1 -0
  22. package/dist/src/orchestrator/heartbeat-writer.js +137 -0
  23. package/dist/src/orchestrator/index.d.ts +20 -0
  24. package/dist/src/orchestrator/index.d.ts.map +1 -0
  25. package/dist/src/orchestrator/index.js +22 -0
  26. package/dist/src/orchestrator/log-analyzer.d.ts +160 -0
  27. package/dist/src/orchestrator/log-analyzer.d.ts.map +1 -0
  28. package/dist/src/orchestrator/log-analyzer.js +572 -0
  29. package/dist/src/orchestrator/log-config.d.ts +39 -0
  30. package/dist/src/orchestrator/log-config.d.ts.map +1 -0
  31. package/dist/src/orchestrator/log-config.js +45 -0
  32. package/dist/src/orchestrator/orchestrator.d.ts +246 -0
  33. package/dist/src/orchestrator/orchestrator.d.ts.map +1 -0
  34. package/dist/src/orchestrator/orchestrator.js +2525 -0
  35. package/dist/src/orchestrator/parse-work-result.d.ts +16 -0
  36. package/dist/src/orchestrator/parse-work-result.d.ts.map +1 -0
  37. package/dist/src/orchestrator/parse-work-result.js +73 -0
  38. package/dist/src/orchestrator/progress-logger.d.ts +72 -0
  39. package/dist/src/orchestrator/progress-logger.d.ts.map +1 -0
  40. package/dist/src/orchestrator/progress-logger.js +135 -0
  41. package/dist/src/orchestrator/session-logger.d.ts +159 -0
  42. package/dist/src/orchestrator/session-logger.d.ts.map +1 -0
  43. package/dist/src/orchestrator/session-logger.js +275 -0
  44. package/dist/src/orchestrator/state-recovery.d.ts +96 -0
  45. package/dist/src/orchestrator/state-recovery.d.ts.map +1 -0
  46. package/dist/src/orchestrator/state-recovery.js +301 -0
  47. package/dist/src/orchestrator/state-types.d.ts +165 -0
  48. package/dist/src/orchestrator/state-types.d.ts.map +1 -0
  49. package/dist/src/orchestrator/state-types.js +7 -0
  50. package/dist/src/orchestrator/stream-parser.d.ts +145 -0
  51. package/dist/src/orchestrator/stream-parser.d.ts.map +1 -0
  52. package/dist/src/orchestrator/stream-parser.js +131 -0
  53. package/dist/src/orchestrator/types.d.ts +205 -0
  54. package/dist/src/orchestrator/types.d.ts.map +1 -0
  55. package/dist/src/orchestrator/types.js +4 -0
  56. package/dist/src/providers/amp-provider.d.ts +20 -0
  57. package/dist/src/providers/amp-provider.d.ts.map +1 -0
  58. package/dist/src/providers/amp-provider.js +24 -0
  59. package/dist/src/providers/claude-provider.d.ts +18 -0
  60. package/dist/src/providers/claude-provider.d.ts.map +1 -0
  61. package/dist/src/providers/claude-provider.js +267 -0
  62. package/dist/src/providers/codex-provider.d.ts +21 -0
  63. package/dist/src/providers/codex-provider.d.ts.map +1 -0
  64. package/dist/src/providers/codex-provider.js +25 -0
  65. package/dist/src/providers/index.d.ts +42 -0
  66. package/dist/src/providers/index.d.ts.map +1 -0
  67. package/dist/src/providers/index.js +77 -0
  68. package/dist/src/providers/types.d.ts +147 -0
  69. package/dist/src/providers/types.d.ts.map +1 -0
  70. package/dist/src/providers/types.js +13 -0
  71. package/package.json +63 -0
@@ -0,0 +1,2525 @@
1
+ /**
2
+ * Agent Orchestrator
3
+ * Spawns concurrent Claude agents to work on Linear backlog issues
4
+ * Uses the Claude Agent SDK for programmatic control
5
+ */
6
+ import { randomUUID } from 'crypto';
7
+ import { execSync } from 'child_process';
8
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync } from 'fs';
9
+ import { resolve, dirname } from 'path';
10
+ import { config as loadDotenv } from 'dotenv';
11
+ import { createProvider, resolveProviderName, } from '../providers';
12
+ import { initializeAgentDir, writeState, updateState, writeTodos, createInitialState, checkRecovery, buildRecoveryPrompt, getHeartbeatTimeoutFromEnv, getMaxRecoveryAttemptsFromEnv, } from './state-recovery';
13
+ import { createHeartbeatWriter, getHeartbeatIntervalFromEnv } from './heartbeat-writer';
14
+ import { createProgressLogger } from './progress-logger';
15
+ import { createSessionLogger } from './session-logger';
16
+ import { isSessionLoggingEnabled, getLogAnalysisConfig } from './log-config';
17
+ import { createLinearAgentClient, createAgentSession, buildCompletionComments, STATUS_WORK_TYPE_MAP, WORK_TYPE_START_STATUS, WORK_TYPE_COMPLETE_STATUS, WORK_TYPE_FAIL_STATUS, } from '@supaku/agentfactory-linear';
18
+ import { parseWorkResult } from './parse-work-result';
19
+ import { createActivityEmitter } from './activity-emitter';
20
+ import { createApiActivityEmitter } from './api-activity-emitter';
21
+ import { createLogger } from '../logger';
22
+ // Default inactivity timeout: 5 minutes
23
+ const DEFAULT_INACTIVITY_TIMEOUT_MS = 300000;
24
+ // Default max session timeout: unlimited (undefined)
25
+ const DEFAULT_MAX_SESSION_TIMEOUT_MS = undefined;
26
+ const DEFAULT_CONFIG = {
27
+ maxConcurrent: 3,
28
+ worktreePath: '.worktrees',
29
+ autoTransition: true,
30
+ // Preserve worktree when PR creation fails to prevent data loss
31
+ preserveWorkOnPrFailure: true,
32
+ // Sandbox disabled by default due to known bugs:
33
+ // - https://github.com/anthropics/claude-code/issues/14162
34
+ // - https://github.com/anthropics/claude-code/issues/12150
35
+ sandboxEnabled: false,
36
+ streamConfig: {
37
+ minInterval: 500,
38
+ maxOutputLength: 2000,
39
+ includeTimestamps: false,
40
+ },
41
+ // Inactivity timeout: agent is stopped if no activity for this duration
42
+ inactivityTimeoutMs: DEFAULT_INACTIVITY_TIMEOUT_MS,
43
+ // Max session timeout: hard cap on runtime (unlimited by default)
44
+ maxSessionTimeoutMs: DEFAULT_MAX_SESSION_TIMEOUT_MS,
45
+ };
46
+ /**
47
+ * Load environment variables from .claude/settings.local.json
48
+ */
49
+ function loadSettingsEnv(workDir, log) {
50
+ // Walk up from workDir to find .claude/settings.local.json
51
+ let currentDir = workDir;
52
+ let prevDir = '';
53
+ // Keep walking up until we reach the filesystem root
54
+ while (currentDir !== prevDir) {
55
+ const settingsPath = resolve(currentDir, '.claude', 'settings.local.json');
56
+ const exists = existsSync(settingsPath);
57
+ if (exists) {
58
+ try {
59
+ const content = readFileSync(settingsPath, 'utf-8');
60
+ const settings = JSON.parse(content);
61
+ if (settings.env && typeof settings.env === 'object') {
62
+ // Filter to only string values
63
+ const env = {};
64
+ for (const [key, value] of Object.entries(settings.env)) {
65
+ if (typeof value === 'string') {
66
+ env[key] = value;
67
+ }
68
+ }
69
+ log?.debug('Loaded settings.local.json', { envVars: Object.keys(env).length });
70
+ return env;
71
+ }
72
+ }
73
+ catch (error) {
74
+ log?.warn('Failed to load settings.local.json', {
75
+ error: error instanceof Error ? error.message : String(error),
76
+ });
77
+ }
78
+ break;
79
+ }
80
+ prevDir = currentDir;
81
+ currentDir = dirname(currentDir);
82
+ }
83
+ log?.warn('settings.local.json not found', { startDir: workDir });
84
+ return {};
85
+ }
86
+ /**
87
+ * Find the repository root by walking up from a directory
88
+ * The repo root is identified by having a .git directory (not file, which worktrees have)
89
+ */
90
+ function findRepoRoot(startDir) {
91
+ let currentDir = startDir;
92
+ let prevDir = '';
93
+ while (currentDir !== prevDir) {
94
+ const gitPath = resolve(currentDir, '.git');
95
+ if (existsSync(gitPath)) {
96
+ // Check if it's a directory (main repo) not a file (worktree)
97
+ try {
98
+ const content = readFileSync(gitPath, 'utf-8');
99
+ // If it starts with "gitdir:", it's a worktree reference
100
+ if (!content.startsWith('gitdir:')) {
101
+ return currentDir;
102
+ }
103
+ }
104
+ catch {
105
+ // If we can't read it as a file, it's a directory (main repo)
106
+ return currentDir;
107
+ }
108
+ }
109
+ prevDir = currentDir;
110
+ currentDir = dirname(currentDir);
111
+ }
112
+ return null;
113
+ }
114
+ /**
115
+ * Load environment variables from app .env files based on work type
116
+ *
117
+ * - Development work: loads .env.local from all apps
118
+ * - QA/Acceptance work: loads .env.test.local from all apps
119
+ *
120
+ * This ensures agents running in worktrees have access to database config
121
+ * and other environment variables that are gitignored.
122
+ */
123
+ function loadAppEnvFiles(workDir, workType, log) {
124
+ // Find the repo root (worktrees are inside .worktrees/ which is in the repo)
125
+ const repoRoot = findRepoRoot(workDir);
126
+ if (!repoRoot) {
127
+ log?.warn('Could not find repo root for env file loading', { startDir: workDir });
128
+ return {};
129
+ }
130
+ const appsDir = resolve(repoRoot, 'apps');
131
+ if (!existsSync(appsDir)) {
132
+ log?.warn('Apps directory not found', { appsDir });
133
+ return {};
134
+ }
135
+ // Determine which env file to load based on work type
136
+ const isTestWork = workType === 'qa' || workType === 'acceptance' || workType === 'qa-coordination' || workType === 'acceptance-coordination';
137
+ const envFileName = isTestWork ? '.env.test.local' : '.env.local';
138
+ const env = {};
139
+ let loadedCount = 0;
140
+ try {
141
+ const appDirs = readdirSync(appsDir, { withFileTypes: true })
142
+ .filter(dirent => dirent.isDirectory())
143
+ .map(dirent => dirent.name);
144
+ for (const appName of appDirs) {
145
+ const envPath = resolve(appsDir, appName, envFileName);
146
+ if (existsSync(envPath)) {
147
+ // Use dotenv to parse the file
148
+ const result = loadDotenv({ path: envPath });
149
+ if (result.parsed) {
150
+ // Merge into our env object
151
+ // dotenv.parsed is Record<string, string>
152
+ Object.assign(env, result.parsed);
153
+ loadedCount++;
154
+ log?.debug(`Loaded ${envFileName} from ${appName}`, {
155
+ vars: Object.keys(result.parsed).length,
156
+ });
157
+ }
158
+ }
159
+ }
160
+ if (loadedCount > 0) {
161
+ log?.info(`Loaded ${envFileName} from ${loadedCount} app(s)`, {
162
+ workType,
163
+ totalVars: Object.keys(env).length,
164
+ });
165
+ }
166
+ else {
167
+ log?.warn(`No ${envFileName} files found in apps/`, { workType });
168
+ }
169
+ }
170
+ catch (error) {
171
+ log?.warn('Failed to load app env files', {
172
+ error: error instanceof Error ? error.message : String(error),
173
+ });
174
+ }
175
+ return env;
176
+ }
177
+ /**
178
+ * Patterns that indicate tool-related errors (not API or resource limit errors)
179
+ */
180
+ const TOOL_ERROR_PATTERNS = [
181
+ // Sandbox violations
182
+ /sandbox/i,
183
+ /not allowed/i,
184
+ /operation not permitted/i,
185
+ // Permission errors
186
+ /permission denied/i,
187
+ /EACCES/,
188
+ /access denied/i,
189
+ // File system errors
190
+ /ENOENT/,
191
+ /no such file or directory/i,
192
+ /file not found/i,
193
+ // Network errors
194
+ /ECONNREFUSED/,
195
+ /ETIMEDOUT/,
196
+ /ENOTFOUND/,
197
+ /connection refused/i,
198
+ /network error/i,
199
+ // Command/tool failures
200
+ /command failed/i,
201
+ /exited with code/i,
202
+ /tool.*error/i,
203
+ /tool.*failed/i,
204
+ // General error indicators from tools
205
+ /is_error.*true/i,
206
+ ];
207
+ /**
208
+ * Check if an error message is related to tool execution
209
+ * (vs API errors, resource limits, etc.)
210
+ */
211
+ function isToolRelatedError(error) {
212
+ return TOOL_ERROR_PATTERNS.some((pattern) => pattern.test(error));
213
+ }
214
+ /**
215
+ * Extract tool name from an error message if present
216
+ */
217
+ function extractToolNameFromError(error) {
218
+ // Try to extract tool name from common patterns
219
+ const patterns = [
220
+ /Tool\s+["']?(\w+)["']?/i,
221
+ /(\w+)\s+tool.*(?:error|failed)/i,
222
+ /Failed to (?:run|execute|call)\s+["']?(\w+)["']?/i,
223
+ ];
224
+ for (const pattern of patterns) {
225
+ const match = error.match(pattern);
226
+ if (match && match[1]) {
227
+ return match[1];
228
+ }
229
+ }
230
+ return 'unknown';
231
+ }
232
+ /**
233
+ * Check if a worktree has uncommitted changes or unpushed commits
234
+ *
235
+ * @param worktreePath - Path to the git worktree
236
+ * @returns Check result with reason if incomplete work is found
237
+ */
238
+ function checkForIncompleteWork(worktreePath) {
239
+ try {
240
+ // Check for uncommitted changes (staged or unstaged)
241
+ const statusOutput = execSync('git status --porcelain', {
242
+ cwd: worktreePath,
243
+ encoding: 'utf-8',
244
+ timeout: 10000,
245
+ }).trim();
246
+ if (statusOutput.length > 0) {
247
+ const changedFiles = statusOutput.split('\n').length;
248
+ return {
249
+ hasIncompleteWork: true,
250
+ reason: 'uncommitted_changes',
251
+ details: `${changedFiles} file(s) with uncommitted changes`,
252
+ };
253
+ }
254
+ // Check for unpushed commits
255
+ // First, check if we have an upstream branch
256
+ try {
257
+ const trackingBranch = execSync('git rev-parse --abbrev-ref @{u}', {
258
+ cwd: worktreePath,
259
+ encoding: 'utf-8',
260
+ timeout: 10000,
261
+ }).trim();
262
+ // Count commits ahead of upstream
263
+ const unpushedOutput = execSync(`git rev-list --count ${trackingBranch}..HEAD`, {
264
+ cwd: worktreePath,
265
+ encoding: 'utf-8',
266
+ timeout: 10000,
267
+ }).trim();
268
+ const unpushedCount = parseInt(unpushedOutput, 10);
269
+ if (unpushedCount > 0) {
270
+ return {
271
+ hasIncompleteWork: true,
272
+ reason: 'unpushed_commits',
273
+ details: `${unpushedCount} commit(s) not pushed to ${trackingBranch}`,
274
+ };
275
+ }
276
+ }
277
+ catch {
278
+ // No upstream branch set - check if we have any local commits
279
+ // This happens when branch was created but never pushed
280
+ try {
281
+ const logOutput = execSync('git log --oneline -1', {
282
+ cwd: worktreePath,
283
+ encoding: 'utf-8',
284
+ timeout: 10000,
285
+ }).trim();
286
+ if (logOutput.length > 0) {
287
+ // Check if remote branch exists
288
+ const currentBranch = execSync('git branch --show-current', {
289
+ cwd: worktreePath,
290
+ encoding: 'utf-8',
291
+ timeout: 10000,
292
+ }).trim();
293
+ try {
294
+ execSync(`git ls-remote --heads origin ${currentBranch}`, {
295
+ cwd: worktreePath,
296
+ encoding: 'utf-8',
297
+ timeout: 10000,
298
+ });
299
+ // Remote branch exists, no issue
300
+ }
301
+ catch {
302
+ // Remote branch doesn't exist - branch never pushed
303
+ return {
304
+ hasIncompleteWork: true,
305
+ reason: 'unpushed_commits',
306
+ details: `Branch '${currentBranch}' has not been pushed to remote`,
307
+ };
308
+ }
309
+ }
310
+ }
311
+ catch {
312
+ // Empty repo or other issue - assume safe to clean
313
+ }
314
+ }
315
+ return { hasIncompleteWork: false };
316
+ }
317
+ catch (error) {
318
+ // If git commands fail, err on the side of caution and report incomplete
319
+ return {
320
+ hasIncompleteWork: true,
321
+ reason: 'uncommitted_changes',
322
+ details: `Failed to check git status: ${error instanceof Error ? error.message : String(error)}`,
323
+ };
324
+ }
325
+ }
326
+ /**
327
+ * Generate a prompt for the agent based on work type
328
+ *
329
+ * @param identifier - Issue identifier (e.g., SUP-123)
330
+ * @param workType - Type of work being performed
331
+ * @param options - Optional configuration
332
+ * @param options.parentContext - Pre-built enriched prompt for parent issues with sub-issues.
333
+ * When provided for 'qa' or 'acceptance' work types, this overrides the default prompt
334
+ * to include sub-issue context and holistic validation instructions.
335
+ * @returns The appropriate prompt for the work type
336
+ */
337
+ function generatePromptForWorkType(identifier, workType, options) {
338
+ // Use enriched parent context for QA/acceptance if provided
339
+ if (options?.parentContext && (workType === 'qa' || workType === 'acceptance')) {
340
+ return options.parentContext;
341
+ }
342
+ let basePrompt;
343
+ switch (workType) {
344
+ case 'research':
345
+ basePrompt = `Research and flesh out story ${identifier}.
346
+ Analyze requirements, identify technical approach, estimate complexity,
347
+ and update the story description with detailed acceptance criteria.
348
+ Do NOT implement code. Focus on story refinement only.`;
349
+ break;
350
+ case 'backlog-creation':
351
+ basePrompt = `Create backlog issues from the researched story ${identifier}.
352
+ Read the issue description, identify distinct work items, classify each as bug/feature/chore,
353
+ and create appropriately scoped Linear issues in Backlog status.
354
+ Choose the correct issue structure based on the work:
355
+ - Sub-issues (--parentId): When work is a single concern with sequential/parallel phases sharing context and dependencies. Move source to Backlog as parent. Add blocking relations (--type blocks) between sub-issues to define execution order for the coordinator.
356
+ - Independent issues (--type related): When items are unrelated work in different codebase areas with no shared context. Source stays in Icebox.
357
+ - Single issue rewrite: When scope is atomic (single concern, \u22643 files, no phases). Rewrite source in-place and move to Backlog.
358
+ IMPORTANT: When creating multiple issues (sub-issues or independent), always add "related" links between them AND blocking relations where one step depends on another. This informs sub-agents and the coordinator of execution order.
359
+ Do NOT wait for user approval - create issues automatically.`;
360
+ break;
361
+ case 'development':
362
+ basePrompt = `Start work on ${identifier}.
363
+ Implement the feature/fix as specified in the issue description.
364
+
365
+ DEPENDENCY INSTALLATION:
366
+ Dependencies are pre-installed by the orchestrator. Do NOT run pnpm install unless you
367
+ encounter a specific missing module error. If you must run it, run it SYNCHRONOUSLY
368
+ (never with run_in_background). Never use sleep or polling loops to wait for commands.
369
+
370
+ IMPORTANT: If you encounter "exceeds maximum allowed tokens" error when reading files:
371
+ - Use Grep to search for specific code patterns instead of reading entire files
372
+ - Use Read with offset/limit parameters to paginate through large files
373
+ - Avoid reading auto-generated files like payload-types.ts (use Grep instead)
374
+ See the "Working with Large Files" section in CLAUDE.md for details.`;
375
+ break;
376
+ case 'inflight':
377
+ basePrompt = `Continue work on ${identifier}.
378
+ Resume where you left off. Check the issue for any new comments or feedback.
379
+
380
+ DEPENDENCY INSTALLATION:
381
+ Dependencies are pre-installed by the orchestrator. Do NOT run pnpm install unless you
382
+ encounter a specific missing module error. If you must run it, run it SYNCHRONOUSLY
383
+ (never with run_in_background). Never use sleep or polling loops to wait for commands.
384
+
385
+ IMPORTANT: If you encounter "exceeds maximum allowed tokens" error when reading files:
386
+ - Use Grep to search for specific code patterns instead of reading entire files
387
+ - Use Read with offset/limit parameters to paginate through large files
388
+ - Avoid reading auto-generated files like payload-types.ts (use Grep instead)
389
+ See the "Working with Large Files" section in CLAUDE.md for details.`;
390
+ break;
391
+ case 'qa':
392
+ basePrompt = `QA ${identifier}.
393
+ Validate the implementation against acceptance criteria.
394
+ Run tests, check for regressions, verify the PR meets requirements.
395
+
396
+ DEPENDENCY INSTALLATION:
397
+ Dependencies are pre-installed by the orchestrator. Do NOT run pnpm install unless you
398
+ encounter a specific missing module error. If you must run it, run it SYNCHRONOUSLY
399
+ (never with run_in_background). Never use sleep or polling loops to wait for commands.
400
+
401
+ IMPORTANT: If you encounter "exceeds maximum allowed tokens" error when reading files:
402
+ - Use Grep to search for specific code patterns instead of reading entire files
403
+ - Use Read with offset/limit parameters to paginate through large files
404
+ - Avoid reading auto-generated files like payload-types.ts (use Grep instead)
405
+ See the "Working with Large Files" section in CLAUDE.md for details.`;
406
+ break;
407
+ case 'acceptance':
408
+ basePrompt = `Process acceptance for ${identifier}.
409
+ Validate development and QA work is complete.
410
+ Verify PR is ready to merge (CI passing, no conflicts).
411
+ Merge the PR using: gh pr merge <PR_NUMBER> --squash
412
+ After merge succeeds, delete the remote branch: git push origin --delete <BRANCH_NAME>`;
413
+ break;
414
+ case 'refinement':
415
+ basePrompt = `Refine ${identifier} based on rejection feedback.
416
+ Read the rejection comments, identify required changes,
417
+ update the issue description with refined requirements,
418
+ then return to Backlog for re-implementation.`;
419
+ break;
420
+ case 'coordination':
421
+ basePrompt = `Coordinate sub-issue execution for parent issue ${identifier}.
422
+ Fetch sub-issues with dependency graph, create Claude Code Tasks mapping to each sub-issue,
423
+ spawn sub-agents for unblocked sub-issues in parallel, monitor completion,
424
+ and create a single PR with all changes when done.
425
+
426
+ SUB-ISSUE STATUS MANAGEMENT:
427
+ You MUST update sub-issue statuses in Linear as work progresses:
428
+ - When starting work on a sub-issue: pnpm linear update-sub-issue <id> --state Started
429
+ - When a sub-agent completes a sub-issue: pnpm linear update-sub-issue <id> --state Finished --comment "Completed by coordinator agent"
430
+ - If a sub-agent fails on a sub-issue: pnpm linear create-comment <sub-issue-id> --body "Sub-agent failed: <reason>"
431
+
432
+ COMPLETION VERIFICATION:
433
+ Before marking the parent issue as complete, verify ALL sub-issues are in Finished status:
434
+ pnpm linear list-sub-issue-statuses ${identifier}
435
+ If any sub-issue is not Finished, report the failure and do not mark the parent as complete.
436
+
437
+ SUB-AGENT SAFETY RULES (CRITICAL):
438
+ This is a SHARED WORKTREE. Multiple sub-agents run concurrently in this directory.
439
+ Every sub-agent prompt you construct MUST include these rules:
440
+
441
+ 1. NEVER run: git worktree remove, git worktree prune
442
+ 2. NEVER run: git checkout, git switch (to a different branch)
443
+ 3. NEVER run: git reset --hard, git clean -fd, git restore .
444
+ 4. NEVER delete or modify the .git file in the worktree root
445
+ 5. Only the orchestrator manages worktree lifecycle
446
+ 6. Work only on files relevant to your sub-issue to minimize conflicts
447
+ 7. Commit changes with descriptive messages before reporting completion
448
+
449
+ Prefix every sub-agent prompt with: "SHARED WORKTREE \u2014 DO NOT MODIFY GIT STATE"
450
+
451
+ DEPENDENCY INSTALLATION:
452
+ Dependencies are pre-installed by the orchestrator. Do NOT run pnpm install unless you
453
+ encounter a specific missing module error. If you must run it, run it SYNCHRONOUSLY
454
+ (never with run_in_background). Never use sleep or polling loops to wait for commands.
455
+
456
+ IMPORTANT: If you encounter "exceeds maximum allowed tokens" error when reading files:
457
+ - Use Grep to search for specific code patterns instead of reading entire files
458
+ - Use Read with offset/limit parameters to paginate through large files
459
+ - Avoid reading auto-generated files like payload-types.ts (use Grep instead)
460
+ See the "Working with Large Files" section in CLAUDE.md for details.`;
461
+ break;
462
+ case 'qa-coordination':
463
+ basePrompt = `Coordinate QA across sub-issues for parent issue ${identifier}.
464
+
465
+ WORKFLOW:
466
+ 1. Fetch sub-issues: pnpm linear list-sub-issues ${identifier}
467
+ 2. Create Claude Code Tasks for each sub-issue's QA verification
468
+ 3. Spawn qa-reviewer sub-agents in parallel \u2014 no dependency graph needed, all sub-issues are already Finished
469
+ 4. Each sub-agent: reads sub-issue requirements, runs scoped tests, validates implementation, emits pass/fail
470
+ 5. Collect results \u2014 ALL sub-issues must pass QA for the parent to pass
471
+
472
+ RESULT HANDLING:
473
+ - If ALL pass: Mark parent as complete (transitions to Delivered). Update each sub-issue to Delivered.
474
+ - If ANY fail: Post rollup comment listing per-sub-issue results. Parent stays in Finished status.
475
+
476
+ IMPORTANT CONSTRAINTS:
477
+ - This is READ-ONLY validation \u2014 do NOT create PRs or make git commits
478
+ - The PR already exists from the development coordination phase
479
+ - Run pnpm test, pnpm typecheck, and pnpm build as part of validation
480
+ - Verify each sub-issue's acceptance criteria against the actual code changes
481
+
482
+ SUB-AGENT SAFETY RULES (CRITICAL):
483
+ This is a SHARED WORKTREE. Multiple sub-agents run concurrently in this directory.
484
+ Every sub-agent prompt you construct MUST include these rules:
485
+ 1. NEVER run: git worktree remove, git worktree prune
486
+ 2. NEVER run: git checkout, git switch (to a different branch)
487
+ 3. NEVER run: git reset --hard, git clean -fd, git restore .
488
+ 4. NEVER delete or modify the .git file in the worktree root
489
+ 5. Work only on files relevant to your sub-issue to minimize conflicts
490
+ Prefix every sub-agent prompt with: "SHARED WORKTREE \u2014 DO NOT MODIFY GIT STATE"
491
+
492
+ DEPENDENCY INSTALLATION:
493
+ Dependencies are pre-installed by the orchestrator. Do NOT run pnpm install unless you
494
+ encounter a specific missing module error. If you must run it, run it SYNCHRONOUSLY
495
+ (never with run_in_background). Never use sleep or polling loops to wait for commands.
496
+
497
+ IMPORTANT: If you encounter "exceeds maximum allowed tokens" error when reading files:
498
+ - Use Grep to search for specific code patterns instead of reading entire files
499
+ - Use Read with offset/limit parameters to paginate through large files
500
+ - Avoid reading auto-generated files like payload-types.ts (use Grep instead)
501
+ See the "Working with Large Files" section in CLAUDE.md for details.`;
502
+ break;
503
+ case 'acceptance-coordination':
504
+ basePrompt = `Coordinate acceptance across sub-issues for parent issue ${identifier}.
505
+
506
+ WORKFLOW:
507
+ 1. Verify all sub-issues are in Delivered status: pnpm linear list-sub-issue-statuses ${identifier}
508
+ 2. If any sub-issue is NOT Delivered, report which sub-issues need attention and fail
509
+ 3. Validate the PR:
510
+ - CI checks are passing
511
+ - No merge conflicts
512
+ - Preview deployment succeeded (if applicable)
513
+ 4. Merge the PR: gh pr merge <PR_NUMBER> --squash
514
+ 5. After merge succeeds, delete the remote branch: git push origin --delete <BRANCH_NAME>
515
+ 6. Bulk-update all sub-issues to Accepted: for each sub-issue, run pnpm linear update-sub-issue <id> --state Accepted
516
+ 7. Mark parent as complete (transitions to Accepted)
517
+
518
+ IMPORTANT CONSTRAINTS:
519
+ - ALL sub-issues must be in Delivered status before proceeding
520
+ - The PR must pass CI and have no conflicts
521
+ - If merge fails, report the error and do not mark as Accepted
522
+
523
+ IMPORTANT: If you encounter "exceeds maximum allowed tokens" error when reading files:
524
+ - Use Grep to search for specific code patterns instead of reading entire files
525
+ - Use Read with offset/limit parameters to paginate through large files
526
+ - Avoid reading auto-generated files like payload-types.ts (use Grep instead)
527
+ See the "Working with Large Files" section in CLAUDE.md for details.`;
528
+ break;
529
+ }
530
+ if (options?.mentionContext) {
531
+ return `${basePrompt}\n\nAdditional context from the user's mention:\n${options.mentionContext}`;
532
+ }
533
+ return basePrompt;
534
+ }
535
+ /**
536
+ * Map work type to worktree identifier suffix
537
+ * This prevents different work types from using the same worktree directory
538
+ */
539
+ const WORK_TYPE_SUFFIX = {
540
+ research: 'RES',
541
+ 'backlog-creation': 'BC',
542
+ development: 'DEV',
543
+ inflight: 'INF',
544
+ coordination: 'COORD',
545
+ qa: 'QA',
546
+ acceptance: 'AC',
547
+ refinement: 'REF',
548
+ 'qa-coordination': 'QA-COORD',
549
+ 'acceptance-coordination': 'AC-COORD',
550
+ };
551
+ /**
552
+ * Generate a worktree identifier that includes the work type suffix
553
+ *
554
+ * @param issueIdentifier - Issue identifier (e.g., "SUP-294")
555
+ * @param workType - Type of work being performed
556
+ * @returns Worktree identifier with suffix (e.g., "SUP-294-QA")
557
+ */
558
+ export function getWorktreeIdentifier(issueIdentifier, workType) {
559
+ const suffix = WORK_TYPE_SUFFIX[workType];
560
+ return `${issueIdentifier}-${suffix}`;
561
+ }
562
+ export class AgentOrchestrator {
563
+ config;
564
+ client;
565
+ events;
566
+ activeAgents = new Map();
567
+ agentHandles = new Map();
568
+ provider;
569
+ agentSessions = new Map();
570
+ activityEmitters = new Map();
571
+ // Track session ID to issue ID mapping for stop signal handling
572
+ sessionToIssue = new Map();
573
+ // Track AbortControllers for stopping agents
574
+ abortControllers = new Map();
575
+ // Loggers per agent for structured output
576
+ agentLoggers = new Map();
577
+ // Heartbeat writers per agent for crash detection
578
+ heartbeatWriters = new Map();
579
+ // Progress loggers per agent for debugging
580
+ progressLoggers = new Map();
581
+ // Session loggers per agent for verbose analysis logging
582
+ sessionLoggers = new Map();
583
+ constructor(config = {}, events = {}) {
584
+ const apiKey = config.linearApiKey ?? process.env.LINEAR_API_KEY;
585
+ if (!apiKey) {
586
+ throw new Error('LINEAR_API_KEY is required');
587
+ }
588
+ // Parse timeout config from environment variables (can be overridden by config)
589
+ const envInactivityTimeout = process.env.AGENT_INACTIVITY_TIMEOUT_MS
590
+ ? parseInt(process.env.AGENT_INACTIVITY_TIMEOUT_MS, 10)
591
+ : undefined;
592
+ const envMaxSessionTimeout = process.env.AGENT_MAX_SESSION_TIMEOUT_MS
593
+ ? parseInt(process.env.AGENT_MAX_SESSION_TIMEOUT_MS, 10)
594
+ : undefined;
595
+ this.config = {
596
+ ...DEFAULT_CONFIG,
597
+ ...config,
598
+ linearApiKey: apiKey,
599
+ streamConfig: {
600
+ ...DEFAULT_CONFIG.streamConfig,
601
+ ...config.streamConfig,
602
+ },
603
+ apiActivityConfig: config.apiActivityConfig,
604
+ workTypeTimeouts: config.workTypeTimeouts,
605
+ // Config takes precedence over env vars, which take precedence over defaults
606
+ inactivityTimeoutMs: config.inactivityTimeoutMs ?? envInactivityTimeout ?? DEFAULT_CONFIG.inactivityTimeoutMs,
607
+ maxSessionTimeoutMs: config.maxSessionTimeoutMs ?? envMaxSessionTimeout ?? DEFAULT_CONFIG.maxSessionTimeoutMs,
608
+ };
609
+ this.client = createLinearAgentClient({ apiKey });
610
+ this.events = events;
611
+ // Initialize agent provider — defaults to Claude, configurable via env
612
+ const providerName = resolveProviderName({ project: config.project });
613
+ this.provider = config.provider ?? createProvider(providerName);
614
+ }
615
+ /**
616
+ * Update the last activity timestamp for an agent (for inactivity timeout tracking)
617
+ * @param issueId - The issue ID of the agent
618
+ * @param activityType - Optional description of the activity type
619
+ */
620
+ updateLastActivity(issueId, activityType = 'activity') {
621
+ const agent = this.activeAgents.get(issueId);
622
+ if (agent) {
623
+ agent.lastActivityAt = new Date();
624
+ this.events.onActivityEmitted?.(agent, activityType);
625
+ }
626
+ }
627
+ /**
628
+ * Get timeout configuration for a specific work type
629
+ * @param workType - The work type to get timeout config for
630
+ * @returns Timeout configuration with inactivity and max session values
631
+ */
632
+ getTimeoutConfig(workType) {
633
+ const baseConfig = {
634
+ inactivityTimeoutMs: this.config.inactivityTimeoutMs,
635
+ maxSessionTimeoutMs: this.config.maxSessionTimeoutMs,
636
+ };
637
+ // Apply work-type-specific overrides if configured
638
+ if (workType && this.config.workTypeTimeouts?.[workType]) {
639
+ const override = this.config.workTypeTimeouts[workType];
640
+ return {
641
+ inactivityTimeoutMs: override?.inactivityTimeoutMs ?? baseConfig.inactivityTimeoutMs,
642
+ maxSessionTimeoutMs: override?.maxSessionTimeoutMs ?? baseConfig.maxSessionTimeoutMs,
643
+ };
644
+ }
645
+ return baseConfig;
646
+ }
647
+ /**
648
+ * Get backlog issues for the configured project
649
+ */
650
+ async getBacklogIssues(limit) {
651
+ const maxIssues = limit ?? this.config.maxConcurrent;
652
+ // Build filter based on project
653
+ const filter = {
654
+ state: { name: { eqIgnoreCase: 'Backlog' } },
655
+ };
656
+ if (this.config.project) {
657
+ const projects = await this.client.linearClient.projects({
658
+ filter: { name: { eqIgnoreCase: this.config.project } },
659
+ });
660
+ if (projects.nodes.length > 0) {
661
+ filter.project = { id: { eq: projects.nodes[0].id } };
662
+ }
663
+ }
664
+ const issues = await this.client.linearClient.issues({
665
+ filter,
666
+ first: maxIssues * 2, // Fetch extra to account for filtering
667
+ });
668
+ const results = [];
669
+ for (const issue of issues.nodes) {
670
+ if (results.length >= maxIssues)
671
+ break;
672
+ const labels = await issue.labels();
673
+ results.push({
674
+ id: issue.id,
675
+ identifier: issue.identifier,
676
+ title: issue.title,
677
+ description: issue.description ?? undefined,
678
+ url: issue.url,
679
+ priority: issue.priority,
680
+ labels: labels.nodes.map((l) => l.name),
681
+ });
682
+ }
683
+ // Sort by priority (lower number = higher priority, 0 means no priority -> goes last)
684
+ return results.sort((a, b) => {
685
+ const aPriority = a.priority || 5;
686
+ const bPriority = b.priority || 5;
687
+ return aPriority - bPriority;
688
+ });
689
+ }
690
+ /**
691
+ * Validate that a path is a valid git worktree
692
+ */
693
+ validateWorktree(worktreePath) {
694
+ if (!existsSync(worktreePath)) {
695
+ return { valid: false, reason: 'Directory does not exist' };
696
+ }
697
+ const gitPath = resolve(worktreePath, '.git');
698
+ if (!existsSync(gitPath)) {
699
+ return { valid: false, reason: 'Missing .git file' };
700
+ }
701
+ // Verify .git is a worktree reference file (not a directory)
702
+ try {
703
+ const stat = statSync(gitPath);
704
+ if (stat.isDirectory()) {
705
+ return { valid: false, reason: '.git is a directory, not a worktree reference' };
706
+ }
707
+ const content = readFileSync(gitPath, 'utf-8');
708
+ if (!content.includes('gitdir:')) {
709
+ return { valid: false, reason: '.git file missing gitdir reference' };
710
+ }
711
+ }
712
+ catch {
713
+ return { valid: false, reason: 'Cannot read .git file' };
714
+ }
715
+ return { valid: true };
716
+ }
717
+ /**
718
+ * Extract the full error message from an execSync error.
719
+ *
720
+ * Node's execSync throws an Error where .message only contains
721
+ * "Command failed: <command>", but the actual git error output
722
+ * is in .stderr. This helper combines both for reliable pattern matching.
723
+ */
724
+ getExecSyncErrorMessage(error) {
725
+ if (error && typeof error === 'object') {
726
+ const parts = [];
727
+ if ('message' in error && typeof error.message === 'string') {
728
+ parts.push(error.message);
729
+ }
730
+ if ('stderr' in error && typeof error.stderr === 'string') {
731
+ parts.push(error.stderr);
732
+ }
733
+ if ('stdout' in error && typeof error.stdout === 'string') {
734
+ parts.push(error.stdout);
735
+ }
736
+ return parts.join('\n');
737
+ }
738
+ return String(error);
739
+ }
740
+ /**
741
+ * Check if a git error indicates a branch/worktree conflict.
742
+ *
743
+ * Git uses different error messages depending on the situation:
744
+ * - "is already checked out at '/path'" - branch checked out in another worktree
745
+ * - "is already used by worktree at '/path'" - branch associated with another worktree
746
+ *
747
+ * Both mean the same thing: the branch is occupied by another worktree.
748
+ */
749
+ isBranchConflictError(errorMsg) {
750
+ return errorMsg.includes('is already checked out at') ||
751
+ errorMsg.includes('is already used by worktree at');
752
+ }
753
+ /**
754
+ * Extract the conflicting worktree path from a git branch conflict error.
755
+ *
756
+ * Parses paths like:
757
+ * - "fatal: 'SUP-402' is already checked out at '/path/to/.worktrees/SUP-402-DEV'"
758
+ * - "fatal: 'SUP-402' is already used by worktree at '/path/to/.worktrees/SUP-402-DEV'"
759
+ */
760
+ parseConflictingWorktreePath(errorMsg) {
761
+ // Match either "checked out at" or "used by worktree at" followed by a quoted path
762
+ const match = errorMsg.match(/(?:already checked out at|already used by worktree at)\s+'([^']+)'/);
763
+ return match?.[1] ?? null;
764
+ }
765
+ /**
766
+ * Attempt to clean up a stale worktree that is blocking branch creation.
767
+ *
768
+ * During dev\u2192qa\u2192acceptance handoffs, the prior work type's worktree may still
769
+ * exist after its agent has finished (the orchestrator cleans up externally,
770
+ * but there's a race window). This method checks if the blocking worktree's
771
+ * agent is still alive via heartbeat. If not, it removes the stale worktree
772
+ * so the new work type can proceed.
773
+ *
774
+ * @returns true if the conflicting worktree was cleaned up
775
+ */
776
+ tryCleanupConflictingWorktree(conflictPath, branchName) {
777
+ if (!existsSync(conflictPath)) {
778
+ // Directory doesn't exist - just prune git's worktree list
779
+ try {
780
+ execSync('git worktree prune', { stdio: 'pipe', encoding: 'utf-8' });
781
+ console.log(`Pruned stale worktree reference for branch ${branchName}`);
782
+ return true;
783
+ }
784
+ catch {
785
+ return false;
786
+ }
787
+ }
788
+ // Check if the agent in the conflicting worktree is still alive
789
+ const recoveryInfo = checkRecovery(conflictPath, {
790
+ heartbeatTimeoutMs: getHeartbeatTimeoutFromEnv(),
791
+ maxRecoveryAttempts: 0, // We don't want to recover, just check liveness
792
+ });
793
+ if (recoveryInfo.agentAlive) {
794
+ console.log(`Branch ${branchName} is held by a running agent at ${conflictPath} - cannot clean up`);
795
+ return false;
796
+ }
797
+ // Agent is not alive - safe to clean up
798
+ console.log(`Cleaning up stale worktree at ${conflictPath} (agent no longer running) ` +
799
+ `to unblock branch ${branchName}`);
800
+ try {
801
+ execSync(`git worktree remove "${conflictPath}" --force`, {
802
+ stdio: 'pipe',
803
+ encoding: 'utf-8',
804
+ });
805
+ console.log(`Removed stale worktree: ${conflictPath}`);
806
+ return true;
807
+ }
808
+ catch (removeError) {
809
+ console.warn(`Failed to remove stale worktree ${conflictPath}:`, removeError instanceof Error ? removeError.message : String(removeError));
810
+ // Try harder: rm -rf + prune
811
+ try {
812
+ execSync(`rm -rf "${conflictPath}"`, { stdio: 'pipe', encoding: 'utf-8' });
813
+ execSync('git worktree prune', { stdio: 'pipe', encoding: 'utf-8' });
814
+ console.log(`Force-removed stale worktree: ${conflictPath}`);
815
+ return true;
816
+ }
817
+ catch {
818
+ return false;
819
+ }
820
+ }
821
+ }
822
+ /**
823
+ * Handle a branch conflict error by attempting to clean up the stale worktree
824
+ * and retrying, or throwing a retriable error for the worker's retry loop.
825
+ */
826
+ handleBranchConflict(errorMsg, branchName) {
827
+ const conflictPath = this.parseConflictingWorktreePath(errorMsg);
828
+ if (conflictPath) {
829
+ const cleaned = this.tryCleanupConflictingWorktree(conflictPath, branchName);
830
+ if (cleaned) {
831
+ // Return without throwing - the caller should retry the git command
832
+ return;
833
+ }
834
+ }
835
+ // Could not clean up - throw retriable error for worker's retry loop
836
+ throw new Error(`Branch '${branchName}' is already checked out in another worktree. ` +
837
+ `This may indicate another agent is still working on this issue.`);
838
+ }
839
+ /**
840
+ * Create a git worktree for an issue with work type suffix
841
+ *
842
+ * @param issueIdentifier - Issue identifier (e.g., "SUP-294")
843
+ * @param workType - Type of work being performed
844
+ * @returns Object containing worktreePath and worktreeIdentifier
845
+ */
846
+ createWorktree(issueIdentifier, workType) {
847
+ const worktreeIdentifier = getWorktreeIdentifier(issueIdentifier, workType);
848
+ const worktreePath = resolve(this.config.worktreePath, worktreeIdentifier);
849
+ // Use issue identifier for branch name (shared across work types)
850
+ const branchName = issueIdentifier;
851
+ // Ensure parent directory exists
852
+ const parentDir = resolve(this.config.worktreePath);
853
+ if (!existsSync(parentDir)) {
854
+ mkdirSync(parentDir, { recursive: true });
855
+ }
856
+ // Prune any stale worktrees first (handles deleted directories)
857
+ try {
858
+ execSync('git worktree prune', { stdio: 'pipe', encoding: 'utf-8' });
859
+ }
860
+ catch {
861
+ // Ignore prune errors
862
+ }
863
+ // Check if worktree already exists AND is valid
864
+ // A valid worktree has a .git file (not directory) pointing to parent repo with gitdir reference
865
+ if (existsSync(worktreePath)) {
866
+ const validation = this.validateWorktree(worktreePath);
867
+ if (validation.valid) {
868
+ console.log(`Worktree already exists: ${worktreePath}`);
869
+ return { worktreePath, worktreeIdentifier };
870
+ }
871
+ // Invalid/incomplete worktree - must clean up
872
+ console.log(`Removing invalid worktree: ${worktreePath} (${validation.reason})`);
873
+ try {
874
+ execSync(`rm -rf "${worktreePath}"`, { stdio: 'pipe', encoding: 'utf-8' });
875
+ }
876
+ catch (cleanupError) {
877
+ throw new Error(`Failed to clean up invalid worktree at ${worktreePath}: ` +
878
+ `${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`);
879
+ }
880
+ // Verify cleanup worked
881
+ if (existsSync(worktreePath)) {
882
+ throw new Error(`Failed to remove invalid worktree directory at ${worktreePath}`);
883
+ }
884
+ }
885
+ console.log(`Creating worktree: ${worktreePath} (branch: ${branchName})`);
886
+ // Determine the base branch for new worktrees
887
+ // Always base new feature branches on 'main' to avoid HEAD resolution issues
888
+ // when running from worktrees with deleted branches (e.g., after PR merge in acceptance)
889
+ const baseBranch = 'main';
890
+ // Try to create worktree with new branch
891
+ // Uses a two-attempt strategy: if a branch conflict is detected and the
892
+ // conflicting worktree's agent is no longer alive, clean it up and retry once.
893
+ const MAX_CONFLICT_RETRIES = 1;
894
+ let conflictRetries = 0;
895
+ const attemptCreateWorktree = () => {
896
+ try {
897
+ execSync(`git worktree add "${worktreePath}" -b ${branchName} ${baseBranch}`, {
898
+ stdio: 'pipe',
899
+ encoding: 'utf-8',
900
+ });
901
+ }
902
+ catch (error) {
903
+ // Branch might already exist or be checked out elsewhere
904
+ // Note: execSync errors have the git message in .stderr, not just .message
905
+ const errorMsg = this.getExecSyncErrorMessage(error);
906
+ // If branch is in use by another worktree, try to clean up the stale worktree
907
+ if (this.isBranchConflictError(errorMsg)) {
908
+ if (conflictRetries < MAX_CONFLICT_RETRIES) {
909
+ conflictRetries++;
910
+ // handleBranchConflict returns if cleanup succeeded, throws if not
911
+ this.handleBranchConflict(errorMsg, branchName);
912
+ // Cleanup succeeded - retry
913
+ console.log(`Retrying worktree creation after cleaning up stale worktree`);
914
+ attemptCreateWorktree();
915
+ return;
916
+ }
917
+ throw new Error(`Branch '${branchName}' is already checked out in another worktree. ` +
918
+ `This may indicate another agent is still working on this issue.`);
919
+ }
920
+ if (errorMsg.includes('already exists')) {
921
+ // Branch exists, try without -b flag
922
+ try {
923
+ execSync(`git worktree add "${worktreePath}" ${branchName}`, {
924
+ stdio: 'pipe',
925
+ encoding: 'utf-8',
926
+ });
927
+ }
928
+ catch (innerError) {
929
+ const innerMsg = this.getExecSyncErrorMessage(innerError);
930
+ // If branch is in use by another worktree, try to clean up
931
+ if (this.isBranchConflictError(innerMsg)) {
932
+ if (conflictRetries < MAX_CONFLICT_RETRIES) {
933
+ conflictRetries++;
934
+ this.handleBranchConflict(innerMsg, branchName);
935
+ console.log(`Retrying worktree creation after cleaning up stale worktree`);
936
+ attemptCreateWorktree();
937
+ return;
938
+ }
939
+ throw new Error(`Branch '${branchName}' is already checked out in another worktree. ` +
940
+ `This may indicate another agent is still working on this issue.`);
941
+ }
942
+ // For any other error, propagate it
943
+ throw innerError;
944
+ }
945
+ }
946
+ else {
947
+ throw error;
948
+ }
949
+ }
950
+ };
951
+ attemptCreateWorktree();
952
+ // Validate worktree was created correctly
953
+ const validation = this.validateWorktree(worktreePath);
954
+ if (!validation.valid) {
955
+ // Clean up partial state
956
+ try {
957
+ if (existsSync(worktreePath)) {
958
+ execSync(`rm -rf "${worktreePath}"`, { stdio: 'pipe', encoding: 'utf-8' });
959
+ }
960
+ execSync('git worktree prune', { stdio: 'pipe', encoding: 'utf-8' });
961
+ }
962
+ catch {
963
+ // Ignore cleanup errors
964
+ }
965
+ throw new Error(`Failed to create valid worktree at ${worktreePath}: ${validation.reason}. ` +
966
+ `This may indicate a race condition with another agent.`);
967
+ }
968
+ console.log(`Worktree created successfully: ${worktreePath}`);
969
+ // Initialize .agent/ directory for state persistence
970
+ try {
971
+ initializeAgentDir(worktreePath);
972
+ }
973
+ catch (initError) {
974
+ // Log but don't fail - state persistence is optional
975
+ console.warn(`Failed to initialize .agent/ directory: ${initError instanceof Error ? initError.message : String(initError)}`);
976
+ }
977
+ return { worktreePath, worktreeIdentifier };
978
+ }
979
+ /**
980
+ * Clean up a git worktree
981
+ *
982
+ * @param worktreeIdentifier - Worktree identifier with work type suffix (e.g., "SUP-294-QA")
983
+ */
984
+ removeWorktree(worktreeIdentifier) {
985
+ const worktreePath = resolve(this.config.worktreePath, worktreeIdentifier);
986
+ if (existsSync(worktreePath)) {
987
+ try {
988
+ execSync(`git worktree remove "${worktreePath}" --force`, {
989
+ stdio: 'pipe',
990
+ encoding: 'utf-8',
991
+ });
992
+ }
993
+ catch (error) {
994
+ console.warn(`Failed to remove worktree via git, trying fallback:`, error);
995
+ try {
996
+ execSync(`rm -rf "${worktreePath}"`, { stdio: 'pipe', encoding: 'utf-8' });
997
+ execSync('git worktree prune', { stdio: 'pipe', encoding: 'utf-8' });
998
+ }
999
+ catch (fallbackError) {
1000
+ console.warn(`Fallback worktree removal also failed:`, fallbackError);
1001
+ }
1002
+ }
1003
+ }
1004
+ else {
1005
+ // Directory gone but git may still track it
1006
+ try {
1007
+ execSync('git worktree prune', { stdio: 'pipe', encoding: 'utf-8' });
1008
+ }
1009
+ catch {
1010
+ // Ignore
1011
+ }
1012
+ }
1013
+ }
1014
+ /**
1015
+ * Pre-install dependencies in a worktree before spawning an agent.
1016
+ * This prevents agents from wasting time/tokens running pnpm install themselves,
1017
+ * and avoids pathological polling loops when pnpm install is run as a background task.
1018
+ *
1019
+ * @param worktreePath - Absolute path to the worktree
1020
+ * @param identifier - Issue identifier for logging (e.g., "SUP-123")
1021
+ */
1022
+ preInstallDependencies(worktreePath, identifier) {
1023
+ console.log(`[${identifier}] Pre-installing dependencies in worktree...`);
1024
+ try {
1025
+ execSync('pnpm install --frozen-lockfile 2>&1', {
1026
+ cwd: worktreePath,
1027
+ stdio: 'pipe',
1028
+ encoding: 'utf-8',
1029
+ timeout: 120_000, // 2 minute timeout
1030
+ });
1031
+ console.log(`[${identifier}] Dependencies installed successfully`);
1032
+ }
1033
+ catch (error) {
1034
+ // Retry without --frozen-lockfile in case lockfile is out of date
1035
+ console.warn(`[${identifier}] Frozen lockfile install failed, retrying without --frozen-lockfile`);
1036
+ try {
1037
+ execSync('pnpm install 2>&1', {
1038
+ cwd: worktreePath,
1039
+ stdio: 'pipe',
1040
+ encoding: 'utf-8',
1041
+ timeout: 120_000,
1042
+ });
1043
+ console.log(`[${identifier}] Dependencies installed successfully (without frozen lockfile)`);
1044
+ }
1045
+ catch (retryError) {
1046
+ // Log but don't fail - the agent can still attempt to install
1047
+ console.warn(`[${identifier}] Pre-install failed (agent will retry):`, retryError instanceof Error ? retryError.message : String(retryError));
1048
+ }
1049
+ }
1050
+ }
1051
+ /**
1052
+ * Spawn a Claude agent for a specific issue using the Agent SDK
1053
+ */
1054
+ spawnAgent(options) {
1055
+ const { issueId, identifier, worktreeIdentifier, sessionId, worktreePath, streamActivities, workType = 'development', prompt: customPrompt, } = options;
1056
+ // Generate prompt based on work type, or use custom prompt if provided
1057
+ const prompt = customPrompt ?? generatePromptForWorkType(identifier, workType);
1058
+ // Create logger for this agent
1059
+ const log = createLogger({ issueIdentifier: identifier });
1060
+ this.agentLoggers.set(issueId, log);
1061
+ const now = new Date();
1062
+ const agent = {
1063
+ issueId,
1064
+ identifier,
1065
+ worktreeIdentifier,
1066
+ sessionId,
1067
+ worktreePath,
1068
+ pid: undefined,
1069
+ status: 'starting',
1070
+ startedAt: now,
1071
+ lastActivityAt: now, // Initialize for inactivity tracking
1072
+ workType,
1073
+ };
1074
+ this.activeAgents.set(issueId, agent);
1075
+ // Track session to issue mapping for stop signal handling
1076
+ if (sessionId) {
1077
+ this.sessionToIssue.set(sessionId, issueId);
1078
+ }
1079
+ // Initialize state persistence and monitoring
1080
+ try {
1081
+ // Write initial state
1082
+ const initialState = createInitialState({
1083
+ issueId,
1084
+ issueIdentifier: identifier,
1085
+ linearSessionId: sessionId ?? null,
1086
+ workType,
1087
+ prompt,
1088
+ workerId: this.config.apiActivityConfig?.workerId ?? null,
1089
+ pid: null, // Will be updated when process spawns
1090
+ });
1091
+ writeState(worktreePath, initialState);
1092
+ // Start heartbeat writer for crash detection
1093
+ const heartbeatWriter = createHeartbeatWriter({
1094
+ agentDir: resolve(worktreePath, '.agent'),
1095
+ pid: process.pid, // Will be updated to child PID after spawn
1096
+ intervalMs: getHeartbeatIntervalFromEnv(),
1097
+ startTime: now.getTime(),
1098
+ });
1099
+ heartbeatWriter.start();
1100
+ this.heartbeatWriters.set(issueId, heartbeatWriter);
1101
+ // Start progress logger for debugging
1102
+ const progressLogger = createProgressLogger({
1103
+ agentDir: resolve(worktreePath, '.agent'),
1104
+ });
1105
+ progressLogger.logStart({ issueId, workType, prompt: prompt.substring(0, 200) });
1106
+ this.progressLoggers.set(issueId, progressLogger);
1107
+ // Start session logger for verbose analysis if enabled
1108
+ if (isSessionLoggingEnabled()) {
1109
+ const logConfig = getLogAnalysisConfig();
1110
+ const sessionLogger = createSessionLogger({
1111
+ sessionId: sessionId ?? issueId,
1112
+ issueId,
1113
+ issueIdentifier: identifier,
1114
+ workType,
1115
+ prompt,
1116
+ logsDir: logConfig.logsDir,
1117
+ workerId: this.config.apiActivityConfig?.workerId,
1118
+ });
1119
+ this.sessionLoggers.set(issueId, sessionLogger);
1120
+ log.debug('Session logging initialized', { logsDir: logConfig.logsDir });
1121
+ }
1122
+ log.debug('State persistence initialized', { agentDir: resolve(worktreePath, '.agent') });
1123
+ }
1124
+ catch (stateError) {
1125
+ // Log but don't fail - state persistence is optional
1126
+ log.warn('Failed to initialize state persistence', {
1127
+ error: stateError instanceof Error ? stateError.message : String(stateError),
1128
+ });
1129
+ }
1130
+ this.events.onAgentStart?.(agent);
1131
+ // Set up activity streaming if sessionId is provided
1132
+ const shouldStream = streamActivities ?? !!sessionId;
1133
+ let emitter = null;
1134
+ if (shouldStream && sessionId) {
1135
+ // Check if we should use API-based activity emitter (for remote workers)
1136
+ // This proxies activities through the agent app which has OAuth tokens
1137
+ if (this.config.apiActivityConfig) {
1138
+ const { baseUrl, apiKey, workerId } = this.config.apiActivityConfig;
1139
+ log.debug('Using API activity emitter', { baseUrl });
1140
+ emitter = createApiActivityEmitter({
1141
+ sessionId,
1142
+ workerId,
1143
+ apiBaseUrl: baseUrl,
1144
+ apiKey,
1145
+ minInterval: this.config.streamConfig.minInterval,
1146
+ maxOutputLength: this.config.streamConfig.maxOutputLength,
1147
+ includeTimestamps: this.config.streamConfig.includeTimestamps,
1148
+ onActivityEmitted: (type, content) => {
1149
+ log.activity(type, content);
1150
+ },
1151
+ onActivityError: (type, error) => {
1152
+ log.error(`Activity error (${type})`, { error: error.message });
1153
+ },
1154
+ });
1155
+ }
1156
+ else {
1157
+ // Direct Linear API - only works with OAuth tokens (not API keys)
1158
+ // This will fail for createAgentActivity calls but works for comments
1159
+ const session = createAgentSession({
1160
+ client: this.client.linearClient,
1161
+ issueId,
1162
+ sessionId,
1163
+ autoTransition: false, // Orchestrator handles transitions
1164
+ });
1165
+ this.agentSessions.set(issueId, session);
1166
+ // Create ActivityEmitter with rate limiting
1167
+ emitter = createActivityEmitter({
1168
+ session,
1169
+ minInterval: this.config.streamConfig.minInterval,
1170
+ maxOutputLength: this.config.streamConfig.maxOutputLength,
1171
+ includeTimestamps: this.config.streamConfig.includeTimestamps,
1172
+ onActivityEmitted: (type, content) => {
1173
+ log.activity(type, content);
1174
+ },
1175
+ });
1176
+ }
1177
+ this.activityEmitters.set(issueId, emitter);
1178
+ }
1179
+ // Create AbortController for cancellation
1180
+ const abortController = new AbortController();
1181
+ this.abortControllers.set(issueId, abortController);
1182
+ // Load environment from settings.local.json
1183
+ const settingsEnv = loadSettingsEnv(worktreePath, log);
1184
+ // Load app-specific env files based on work type
1185
+ // Development work loads .env.local, QA/acceptance loads .env.test.local
1186
+ const appEnv = loadAppEnvFiles(worktreePath, workType, log);
1187
+ // Build environment variables - inherit ALL from process.env (required for node to be found)
1188
+ // Then overlay app env vars, settings.local.json env vars, then our specific vars
1189
+ const processEnvFiltered = {};
1190
+ for (const [key, value] of Object.entries(process.env)) {
1191
+ if (typeof value === 'string') {
1192
+ processEnvFiltered[key] = value;
1193
+ }
1194
+ }
1195
+ const env = {
1196
+ ...processEnvFiltered, // Include all parent env vars (PATH, NODE_PATH, etc.)
1197
+ ...appEnv, // Include app env vars (.env.local or .env.test.local based on work type)
1198
+ ...settingsEnv, // Include all env vars from settings.local.json (highest priority)
1199
+ LINEAR_ISSUE_ID: issueId,
1200
+ // Disable user .npmrc to prevent picking up expired auth tokens from ~/.npmrc
1201
+ // Point to a non-existent file so npm/pnpm won't try to use stale credentials
1202
+ NPM_CONFIG_USERCONFIG: '/dev/null',
1203
+ npm_config_userconfig: '/dev/null',
1204
+ };
1205
+ if (sessionId) {
1206
+ env.LINEAR_SESSION_ID = sessionId;
1207
+ }
1208
+ // Set work type so agent knows what kind of work it's doing
1209
+ env.LINEAR_WORK_TYPE = workType;
1210
+ // Flag shared worktree for coordination mode so sub-agents know not to modify git state
1211
+ if (workType === 'coordination' || workType === 'qa-coordination' || workType === 'acceptance-coordination') {
1212
+ env.SHARED_WORKTREE = 'true';
1213
+ }
1214
+ // Set Claude Code Task List ID for intra-issue task coordination
1215
+ // This enables Tasks to persist across crashes and be shared between subagents
1216
+ // Format: {issueIdentifier}-{WORKTYPE} (e.g., "SUP-123-DEV")
1217
+ env.CLAUDE_CODE_TASK_LIST_ID = worktreeIdentifier;
1218
+ log.info('Starting agent via provider', { provider: this.provider.name, worktreePath, workType, promptPreview: prompt.substring(0, 50) });
1219
+ // Spawn agent via provider interface
1220
+ const spawnConfig = {
1221
+ prompt,
1222
+ cwd: worktreePath,
1223
+ env,
1224
+ abortController,
1225
+ autonomous: true,
1226
+ sandboxEnabled: this.config.sandboxEnabled,
1227
+ onProcessSpawned: (pid) => {
1228
+ agent.pid = pid;
1229
+ log.info('Agent process spawned', { pid });
1230
+ },
1231
+ };
1232
+ const handle = this.provider.spawn(spawnConfig);
1233
+ this.agentHandles.set(issueId, handle);
1234
+ agent.status = 'running';
1235
+ // Process the event stream in the background
1236
+ this.processEventStream(issueId, identifier, sessionId, handle, emitter, agent);
1237
+ return agent;
1238
+ }
1239
+ /**
1240
+ * Process the provider event stream and emit activities
1241
+ */
1242
+ async processEventStream(issueId, identifier, sessionId, handle, emitter, agent) {
1243
+ const log = this.agentLoggers.get(issueId);
1244
+ try {
1245
+ for await (const event of handle.stream) {
1246
+ await this.handleAgentEvent(issueId, sessionId, event, emitter, agent, handle);
1247
+ }
1248
+ // Query completed successfully
1249
+ if (agent.status !== 'stopped') {
1250
+ agent.status = 'completed';
1251
+ }
1252
+ agent.completedAt = new Date();
1253
+ // Update state file to completed
1254
+ try {
1255
+ updateState(agent.worktreePath, {
1256
+ status: agent.status === 'stopped' ? 'stopped' : 'completed',
1257
+ pullRequestUrl: agent.pullRequestUrl ?? undefined,
1258
+ });
1259
+ }
1260
+ catch {
1261
+ // Ignore state update errors
1262
+ }
1263
+ // Flush remaining activities
1264
+ if (emitter) {
1265
+ await emitter.flush();
1266
+ }
1267
+ // Update Linear status based on work type if auto-transition is enabled
1268
+ if (agent.status === 'completed' && this.config.autoTransition) {
1269
+ const workType = agent.workType ?? 'development';
1270
+ const isResultSensitive = workType === 'qa' || workType === 'acceptance' || workType === 'qa-coordination' || workType === 'acceptance-coordination';
1271
+ let targetStatus = null;
1272
+ if (isResultSensitive) {
1273
+ // For QA/acceptance: parse result to decide promote vs reject
1274
+ const workResult = parseWorkResult(agent.resultMessage, workType);
1275
+ agent.workResult = workResult;
1276
+ if (workResult === 'passed') {
1277
+ targetStatus = WORK_TYPE_COMPLETE_STATUS[workType];
1278
+ log?.info('Work result: passed, promoting', { workType, targetStatus });
1279
+ }
1280
+ else if (workResult === 'failed') {
1281
+ targetStatus = WORK_TYPE_FAIL_STATUS[workType];
1282
+ log?.info('Work result: failed, transitioning to fail status', { workType, targetStatus });
1283
+ }
1284
+ else {
1285
+ // unknown — safe default: don't transition
1286
+ log?.warn('Work result: unknown, skipping auto-transition', {
1287
+ workType,
1288
+ hasResultMessage: !!agent.resultMessage,
1289
+ });
1290
+ }
1291
+ }
1292
+ else {
1293
+ // Non-QA/acceptance: unchanged behavior — always promote on completion
1294
+ targetStatus = WORK_TYPE_COMPLETE_STATUS[workType];
1295
+ }
1296
+ if (targetStatus) {
1297
+ try {
1298
+ await this.client.updateIssueStatus(issueId, targetStatus);
1299
+ log?.info('Issue status updated', { from: workType, to: targetStatus });
1300
+ }
1301
+ catch (error) {
1302
+ log?.error('Failed to update status', {
1303
+ targetStatus,
1304
+ error: error instanceof Error ? error.message : String(error),
1305
+ });
1306
+ }
1307
+ }
1308
+ else if (!isResultSensitive) {
1309
+ log?.info('No auto-transition configured for work type', { workType });
1310
+ }
1311
+ // Unassign agent from issue for clean handoff visibility
1312
+ // This enables automated QA pickup via webhook
1313
+ // Skip unassignment for research work (user should decide when to move to backlog)
1314
+ if (workType !== 'research') {
1315
+ try {
1316
+ await this.client.unassignIssue(issueId);
1317
+ log?.info('Agent unassigned from issue');
1318
+ }
1319
+ catch (error) {
1320
+ log?.warn('Failed to unassign agent from issue', {
1321
+ error: error instanceof Error ? error.message : String(error),
1322
+ });
1323
+ }
1324
+ }
1325
+ }
1326
+ // Post completion comment with full result (not truncated)
1327
+ // This uses multi-comment splitting for long messages
1328
+ if (agent.status === 'completed' && agent.resultMessage) {
1329
+ await this.postCompletionComment(issueId, sessionId, agent.resultMessage, log);
1330
+ }
1331
+ // Clean up worktree for completed agents
1332
+ // NOTE: This must happen AFTER the agent exits to avoid breaking its shell session
1333
+ // Agents should NEVER clean up their own worktree - this is the orchestrator's job
1334
+ if (agent.status === 'completed' && agent.worktreePath) {
1335
+ const shouldPreserve = this.config.preserveWorkOnPrFailure ?? DEFAULT_CONFIG.preserveWorkOnPrFailure;
1336
+ const isDevelopmentWork = agent.workType === 'development' || agent.workType === 'inflight';
1337
+ let shouldCleanup = true;
1338
+ // For development work, validate that PR was created or work was fully pushed
1339
+ if (shouldPreserve && isDevelopmentWork) {
1340
+ if (!agent.pullRequestUrl) {
1341
+ // No PR detected - check for uncommitted/unpushed work
1342
+ const incompleteCheck = checkForIncompleteWork(agent.worktreePath);
1343
+ if (incompleteCheck.hasIncompleteWork) {
1344
+ // Mark as incomplete and preserve worktree
1345
+ agent.status = 'incomplete';
1346
+ agent.incompleteReason = incompleteCheck.reason;
1347
+ shouldCleanup = false;
1348
+ log?.warn('Work incomplete - preserving worktree', {
1349
+ reason: incompleteCheck.reason,
1350
+ details: incompleteCheck.details,
1351
+ worktreePath: agent.worktreePath,
1352
+ });
1353
+ }
1354
+ else {
1355
+ // No PR but also no local changes - agent may not have made any changes
1356
+ log?.warn('No PR created but worktree is clean - proceeding with cleanup', {
1357
+ worktreePath: agent.worktreePath,
1358
+ });
1359
+ }
1360
+ }
1361
+ }
1362
+ if (shouldCleanup) {
1363
+ try {
1364
+ this.removeWorktree(agent.worktreeIdentifier);
1365
+ log?.info('Worktree cleaned up', { worktreePath: agent.worktreePath });
1366
+ }
1367
+ catch (error) {
1368
+ log?.warn('Failed to clean up worktree', {
1369
+ worktreePath: agent.worktreePath,
1370
+ error: error instanceof Error ? error.message : String(error),
1371
+ });
1372
+ }
1373
+ }
1374
+ }
1375
+ // Finalize session logger before cleanup
1376
+ const finalStatus = agent.status === 'completed' ? 'completed' : (agent.status === 'stopped' ? 'stopped' : 'completed');
1377
+ this.finalizeSessionLogger(issueId, finalStatus, {
1378
+ pullRequestUrl: agent.pullRequestUrl,
1379
+ });
1380
+ // Clean up in-memory resources
1381
+ this.cleanupAgent(issueId, sessionId);
1382
+ if (agent.status === 'completed') {
1383
+ this.events.onAgentComplete?.(agent);
1384
+ }
1385
+ else if (agent.status === 'incomplete') {
1386
+ this.events.onAgentIncomplete?.(agent);
1387
+ }
1388
+ else if (agent.status === 'stopped') {
1389
+ this.events.onAgentStopped?.(agent);
1390
+ }
1391
+ }
1392
+ catch (error) {
1393
+ // Handle abort/cancellation
1394
+ if (error instanceof Error && error.name === 'AbortError') {
1395
+ agent.status = 'stopped';
1396
+ agent.completedAt = new Date();
1397
+ this.finalizeSessionLogger(issueId, 'stopped');
1398
+ this.cleanupAgent(issueId, sessionId);
1399
+ this.events.onAgentStopped?.(agent);
1400
+ return;
1401
+ }
1402
+ // Handle other errors
1403
+ log?.error('Agent error', { error: error instanceof Error ? error.message : String(error) });
1404
+ agent.status = 'failed';
1405
+ agent.completedAt = new Date();
1406
+ agent.error = error instanceof Error ? error : new Error(String(error));
1407
+ // Flush remaining activities
1408
+ if (emitter) {
1409
+ await emitter.flush();
1410
+ }
1411
+ // Clean up worktree for failed agents (but preserve if there's work)
1412
+ if (agent.worktreePath) {
1413
+ const shouldPreserve = this.config.preserveWorkOnPrFailure ?? DEFAULT_CONFIG.preserveWorkOnPrFailure;
1414
+ let shouldCleanup = true;
1415
+ // Check for any uncommitted/unpushed work before cleaning up
1416
+ if (shouldPreserve) {
1417
+ const incompleteCheck = checkForIncompleteWork(agent.worktreePath);
1418
+ if (incompleteCheck.hasIncompleteWork) {
1419
+ // Preserve worktree - there's work that could be recovered
1420
+ shouldCleanup = false;
1421
+ agent.incompleteReason = incompleteCheck.reason;
1422
+ log?.warn('Agent failed but has uncommitted work - preserving worktree', {
1423
+ reason: incompleteCheck.reason,
1424
+ details: incompleteCheck.details,
1425
+ worktreePath: agent.worktreePath,
1426
+ });
1427
+ }
1428
+ }
1429
+ if (shouldCleanup) {
1430
+ try {
1431
+ this.removeWorktree(agent.worktreeIdentifier);
1432
+ log?.info('Worktree cleaned up after failure', { worktreePath: agent.worktreePath });
1433
+ }
1434
+ catch (cleanupError) {
1435
+ log?.warn('Failed to clean up worktree after failure', {
1436
+ worktreePath: agent.worktreePath,
1437
+ error: cleanupError instanceof Error ? cleanupError.message : String(cleanupError),
1438
+ });
1439
+ }
1440
+ }
1441
+ }
1442
+ // Finalize session logger with error
1443
+ this.finalizeSessionLogger(issueId, 'failed', {
1444
+ errorMessage: agent.error?.message,
1445
+ });
1446
+ this.cleanupAgent(issueId, sessionId);
1447
+ this.events.onAgentError?.(agent, agent.error);
1448
+ }
1449
+ }
1450
+ /**
1451
+ * Handle a single normalized agent event from any provider
1452
+ */
1453
+ async handleAgentEvent(issueId, sessionId, event, emitter, agent, handle) {
1454
+ const log = this.agentLoggers.get(issueId);
1455
+ // Get heartbeat writer and progress logger for state updates
1456
+ const heartbeatWriter = this.heartbeatWriters.get(issueId);
1457
+ const progressLogger = this.progressLoggers.get(issueId);
1458
+ const sessionLogger = this.sessionLoggers.get(issueId);
1459
+ switch (event.type) {
1460
+ case 'init':
1461
+ log?.success('Agent initialized', { session: event.sessionId.substring(0, 12) });
1462
+ agent.claudeSessionId = event.sessionId;
1463
+ this.updateLastActivity(issueId, 'init');
1464
+ // Update state with provider session ID
1465
+ try {
1466
+ updateState(agent.worktreePath, {
1467
+ claudeSessionId: event.sessionId,
1468
+ status: 'running',
1469
+ pid: agent.pid ?? null,
1470
+ });
1471
+ }
1472
+ catch {
1473
+ // Ignore state update errors
1474
+ }
1475
+ // Notify via callback for external persistence
1476
+ if (sessionId) {
1477
+ await this.events.onClaudeSessionId?.(sessionId, event.sessionId);
1478
+ }
1479
+ break;
1480
+ case 'system':
1481
+ // System-level events (status changes, compaction, auth, etc.)
1482
+ if (event.subtype === 'status') {
1483
+ log?.debug('Status change', { status: event.message });
1484
+ }
1485
+ else if (event.subtype === 'compact_boundary') {
1486
+ log?.debug('Context compacted');
1487
+ }
1488
+ else if (event.subtype === 'hook_response') {
1489
+ // Provider-specific hook handling — access raw event for details
1490
+ const raw = event.raw;
1491
+ if (raw.exit_code !== undefined && raw.exit_code !== 0) {
1492
+ log?.warn('Hook failed', { hook: raw.hook_name, exitCode: raw.exit_code });
1493
+ }
1494
+ }
1495
+ else if (event.subtype === 'auth_status') {
1496
+ if (event.message?.includes('error') || event.message?.includes('Error')) {
1497
+ log?.error('Auth error', { error: event.message });
1498
+ }
1499
+ }
1500
+ else {
1501
+ log?.debug('System event', { subtype: event.subtype, message: event.message });
1502
+ }
1503
+ break;
1504
+ case 'tool_result':
1505
+ // Tool results — track activity and detect PR URLs
1506
+ this.updateLastActivity(issueId, 'tool_result');
1507
+ sessionLogger?.logToolResult(event.toolUseId ?? 'unknown', event.content, event.isError);
1508
+ // Detect GitHub PR URLs in tool output (from gh pr create)
1509
+ if (sessionId) {
1510
+ const prUrl = this.extractPullRequestUrl(event.content);
1511
+ if (prUrl) {
1512
+ log?.info('Pull request detected', { prUrl });
1513
+ agent.pullRequestUrl = prUrl;
1514
+ await this.updateSessionPullRequest(sessionId, prUrl, agent);
1515
+ }
1516
+ }
1517
+ break;
1518
+ case 'assistant_text':
1519
+ // Assistant text output
1520
+ this.updateLastActivity(issueId, 'assistant');
1521
+ heartbeatWriter?.recordThinking();
1522
+ sessionLogger?.logAssistant(event.text);
1523
+ if (emitter) {
1524
+ await emitter.emitThought(event.text.substring(0, 200));
1525
+ }
1526
+ break;
1527
+ case 'tool_use':
1528
+ // Tool invocation
1529
+ this.updateLastActivity(issueId, 'assistant');
1530
+ log?.toolCall(event.toolName, event.input);
1531
+ heartbeatWriter?.recordToolCall(event.toolName);
1532
+ progressLogger?.logTool(event.toolName, event.input);
1533
+ sessionLogger?.logToolUse(event.toolName, event.input);
1534
+ // Intercept TodoWrite tool calls to persist todos
1535
+ if (event.toolName === 'TodoWrite') {
1536
+ try {
1537
+ const input = event.input;
1538
+ if (input.todos && Array.isArray(input.todos)) {
1539
+ const todosState = {
1540
+ updatedAt: Date.now(),
1541
+ items: input.todos,
1542
+ };
1543
+ writeTodos(agent.worktreePath, todosState);
1544
+ log?.debug('Todos persisted', { count: input.todos.length });
1545
+ }
1546
+ }
1547
+ catch {
1548
+ // Ignore todos persistence errors
1549
+ }
1550
+ }
1551
+ if (emitter) {
1552
+ await emitter.emitToolUse(event.toolName, event.input);
1553
+ }
1554
+ break;
1555
+ case 'tool_progress':
1556
+ // Tool execution progress — track activity for long-running tools
1557
+ this.updateLastActivity(issueId, `tool_progress:${event.toolName}`);
1558
+ log?.debug('Tool progress', { tool: event.toolName, elapsed: `${event.elapsedSeconds}s` });
1559
+ break;
1560
+ case 'result':
1561
+ if (event.success) {
1562
+ log?.success('Agent completed', {
1563
+ cost: event.cost?.totalCostUsd ? `$${event.cost.totalCostUsd.toFixed(4)}` : 'N/A',
1564
+ turns: event.cost?.numTurns,
1565
+ });
1566
+ // Track cost data on the agent
1567
+ if (event.cost) {
1568
+ agent.totalCostUsd = event.cost.totalCostUsd;
1569
+ agent.inputTokens = event.cost.inputTokens;
1570
+ agent.outputTokens = event.cost.outputTokens;
1571
+ }
1572
+ // Store full result for completion comment posting later
1573
+ if (event.message) {
1574
+ agent.resultMessage = event.message;
1575
+ }
1576
+ // Update state to completing/completed
1577
+ try {
1578
+ updateState(agent.worktreePath, {
1579
+ status: 'completing',
1580
+ currentPhase: 'Finalizing work',
1581
+ });
1582
+ progressLogger?.logComplete({ message: event.message?.substring(0, 200) });
1583
+ }
1584
+ catch {
1585
+ // Ignore state update errors
1586
+ }
1587
+ // Check cost limit
1588
+ const maxCostUsd = parseFloat(process.env.AGENT_MAX_COST_USD ?? '0');
1589
+ if (maxCostUsd > 0 && event.cost?.totalCostUsd && event.cost.totalCostUsd > maxCostUsd) {
1590
+ log?.warn('Agent exceeded cost limit', {
1591
+ totalCost: event.cost.totalCostUsd,
1592
+ limit: maxCostUsd,
1593
+ });
1594
+ }
1595
+ // Emit truncated preview to activity feed (ephemeral)
1596
+ if (emitter && event.message) {
1597
+ await emitter.emitThought(`Completed: ${event.message.substring(0, 200)}...`, true);
1598
+ }
1599
+ }
1600
+ else {
1601
+ // Error result
1602
+ log?.error('Agent error result', { subtype: event.errorSubtype });
1603
+ // Update state to failed
1604
+ const errorMessage = event.errors && event.errors.length > 0
1605
+ ? event.errors[0]
1606
+ : `Agent error: ${event.errorSubtype}`;
1607
+ try {
1608
+ updateState(agent.worktreePath, {
1609
+ status: 'failed',
1610
+ errorMessage,
1611
+ });
1612
+ progressLogger?.logError('Agent error result', new Error(errorMessage));
1613
+ sessionLogger?.logError('Agent error result', new Error(errorMessage), { subtype: event.errorSubtype });
1614
+ }
1615
+ catch {
1616
+ // Ignore state update errors
1617
+ }
1618
+ // Report tool errors as Linear issues for tracking
1619
+ // Only report for 'error_during_execution' subtype (tool/execution errors)
1620
+ if (event.errorSubtype === 'error_during_execution' &&
1621
+ event.errors &&
1622
+ emitter) {
1623
+ for (const err of event.errors) {
1624
+ log?.error('Error detail', { error: err });
1625
+ if (isToolRelatedError(err)) {
1626
+ const toolName = extractToolNameFromError(err);
1627
+ try {
1628
+ const issue = await emitter.reportToolError(toolName, err, {
1629
+ issueIdentifier: agent.identifier,
1630
+ additionalContext: {
1631
+ agentStatus: agent.status,
1632
+ workType: agent.workType,
1633
+ subtype: event.errorSubtype,
1634
+ },
1635
+ });
1636
+ if (issue) {
1637
+ log?.info('Tool error reported to Linear', {
1638
+ issue: issue.identifier,
1639
+ toolName,
1640
+ });
1641
+ }
1642
+ }
1643
+ catch (reportError) {
1644
+ log?.warn('Failed to report tool error', {
1645
+ error: reportError instanceof Error
1646
+ ? reportError.message
1647
+ : String(reportError),
1648
+ });
1649
+ }
1650
+ }
1651
+ }
1652
+ }
1653
+ else if (event.errors) {
1654
+ for (const err of event.errors) {
1655
+ log?.error('Error detail', { error: err });
1656
+ }
1657
+ }
1658
+ }
1659
+ break;
1660
+ case 'error':
1661
+ log?.error('Agent error', { message: event.message, code: event.code });
1662
+ break;
1663
+ default:
1664
+ log?.debug('Unhandled event type', { type: event.type });
1665
+ }
1666
+ }
1667
+ /**
1668
+ * Extract GitHub PR URL from text (typically from gh pr create output)
1669
+ */
1670
+ extractPullRequestUrl(text) {
1671
+ // GitHub PR URL pattern: https://github.com/owner/repo/pull/123
1672
+ const prUrlPattern = /https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/\d+/g;
1673
+ const matches = text.match(prUrlPattern);
1674
+ return matches ? matches[0] : null;
1675
+ }
1676
+ /**
1677
+ * Update the Linear session with the PR URL
1678
+ */
1679
+ async updateSessionPullRequest(sessionId, prUrl, agent) {
1680
+ const log = this.agentLoggers.get(agent.issueId);
1681
+ // If using API activity config, call the API endpoint
1682
+ if (this.config.apiActivityConfig) {
1683
+ const { baseUrl, apiKey } = this.config.apiActivityConfig;
1684
+ try {
1685
+ const response = await fetch(`${baseUrl}/api/sessions/${sessionId}/external-urls`, {
1686
+ method: 'POST',
1687
+ headers: {
1688
+ 'Content-Type': 'application/json',
1689
+ Authorization: `Bearer ${apiKey}`,
1690
+ },
1691
+ body: JSON.stringify({
1692
+ externalUrls: [{ label: 'Pull Request', url: prUrl }],
1693
+ }),
1694
+ });
1695
+ if (!response.ok) {
1696
+ const error = await response.text();
1697
+ log?.warn('Failed to update session PR URL via API', { status: response.status, error });
1698
+ }
1699
+ else {
1700
+ log?.info('Session PR URL updated via API');
1701
+ }
1702
+ }
1703
+ catch (error) {
1704
+ log?.warn('Failed to update session PR URL via API', {
1705
+ error: error instanceof Error ? error.message : String(error),
1706
+ });
1707
+ }
1708
+ }
1709
+ else {
1710
+ // Direct Linear API - use AgentSession if available
1711
+ const session = this.agentSessions.get(agent.issueId);
1712
+ if (session) {
1713
+ try {
1714
+ await session.setPullRequestUrl(prUrl);
1715
+ log?.info('Session PR URL updated via Linear API');
1716
+ }
1717
+ catch (error) {
1718
+ log?.warn('Failed to update session PR URL via Linear API', {
1719
+ error: error instanceof Error ? error.message : String(error),
1720
+ });
1721
+ }
1722
+ }
1723
+ }
1724
+ }
1725
+ /**
1726
+ * Post completion comment with full result message
1727
+ * Uses multi-comment splitting for long messages (up to 10 comments, 10k chars each)
1728
+ */
1729
+ async postCompletionComment(issueId, sessionId, resultMessage, log) {
1730
+ // Build completion comments with multi-part splitting
1731
+ const comments = buildCompletionComments(resultMessage, [], // No plan items to include (already shown via activities)
1732
+ sessionId ?? null);
1733
+ log?.info('Posting completion comment', {
1734
+ parts: comments.length,
1735
+ totalLength: resultMessage.length,
1736
+ });
1737
+ // If using API activity config, call the API endpoint
1738
+ if (this.config.apiActivityConfig) {
1739
+ const { baseUrl, apiKey } = this.config.apiActivityConfig;
1740
+ try {
1741
+ const response = await fetch(`${baseUrl}/api/sessions/${sessionId}/completion`, {
1742
+ method: 'POST',
1743
+ headers: {
1744
+ 'Content-Type': 'application/json',
1745
+ Authorization: `Bearer ${apiKey}`,
1746
+ },
1747
+ body: JSON.stringify({
1748
+ summary: resultMessage,
1749
+ }),
1750
+ });
1751
+ if (!response.ok) {
1752
+ const error = await response.text();
1753
+ log?.warn('Failed to post completion comment via API', { status: response.status, error });
1754
+ }
1755
+ else {
1756
+ log?.info('Completion comment posted via API');
1757
+ }
1758
+ }
1759
+ catch (error) {
1760
+ log?.warn('Failed to post completion comment via API', {
1761
+ error: error instanceof Error ? error.message : String(error),
1762
+ });
1763
+ }
1764
+ }
1765
+ else {
1766
+ // Direct Linear API - post comments sequentially
1767
+ for (const chunk of comments) {
1768
+ try {
1769
+ await this.client.createComment(issueId, chunk.body);
1770
+ log?.info(`Posted completion comment part ${chunk.partNumber}/${chunk.totalParts}`);
1771
+ // Small delay between comments to ensure ordering
1772
+ if (chunk.partNumber < chunk.totalParts) {
1773
+ await new Promise(resolve => setTimeout(resolve, 100));
1774
+ }
1775
+ }
1776
+ catch (error) {
1777
+ log?.error(`Failed to post completion comment part ${chunk.partNumber}`, {
1778
+ error: error instanceof Error ? error.message : String(error),
1779
+ });
1780
+ }
1781
+ }
1782
+ }
1783
+ }
1784
+ /**
1785
+ * Clean up agent resources
1786
+ */
1787
+ cleanupAgent(issueId, sessionId) {
1788
+ this.agentHandles.delete(issueId);
1789
+ this.agentSessions.delete(issueId);
1790
+ this.activityEmitters.delete(issueId);
1791
+ this.abortControllers.delete(issueId);
1792
+ this.agentLoggers.delete(issueId);
1793
+ // Stop heartbeat writer
1794
+ const heartbeatWriter = this.heartbeatWriters.get(issueId);
1795
+ if (heartbeatWriter) {
1796
+ heartbeatWriter.stop();
1797
+ this.heartbeatWriters.delete(issueId);
1798
+ }
1799
+ // Stop progress logger
1800
+ const progressLogger = this.progressLoggers.get(issueId);
1801
+ if (progressLogger) {
1802
+ progressLogger.stop();
1803
+ this.progressLoggers.delete(issueId);
1804
+ }
1805
+ // Session logger is cleaned up separately (in finalizeSessionLogger)
1806
+ // to ensure the final status is captured before cleanup
1807
+ this.sessionLoggers.delete(issueId);
1808
+ if (sessionId) {
1809
+ this.sessionToIssue.delete(sessionId);
1810
+ }
1811
+ }
1812
+ /**
1813
+ * Finalize the session logger with final status
1814
+ */
1815
+ finalizeSessionLogger(issueId, status, options) {
1816
+ const sessionLogger = this.sessionLoggers.get(issueId);
1817
+ if (sessionLogger) {
1818
+ sessionLogger.finalize(status, options);
1819
+ }
1820
+ }
1821
+ /**
1822
+ * Run the orchestrator - spawn agents for backlog issues
1823
+ */
1824
+ async run() {
1825
+ const issues = await this.getBacklogIssues();
1826
+ const result = {
1827
+ success: true,
1828
+ agents: [],
1829
+ errors: [],
1830
+ };
1831
+ if (issues.length === 0) {
1832
+ console.log('No backlog issues found');
1833
+ return result;
1834
+ }
1835
+ console.log(`Found ${issues.length} backlog issue(s)`);
1836
+ for (const issue of issues) {
1837
+ this.events.onIssueSelected?.(issue);
1838
+ console.log(`Processing: ${issue.identifier} - ${issue.title}`);
1839
+ try {
1840
+ // Backlog issues are always development work
1841
+ const workType = 'development';
1842
+ // Create worktree with work type suffix
1843
+ const { worktreePath, worktreeIdentifier } = this.createWorktree(issue.identifier, workType);
1844
+ // Pre-install dependencies so the agent doesn't waste time on pnpm install
1845
+ this.preInstallDependencies(worktreePath, issue.identifier);
1846
+ const startStatus = WORK_TYPE_START_STATUS[workType];
1847
+ // Update issue status based on work type if auto-transition is enabled
1848
+ if (this.config.autoTransition && startStatus) {
1849
+ await this.client.updateIssueStatus(issue.id, startStatus);
1850
+ console.log(`Updated ${issue.identifier} status to ${startStatus}`);
1851
+ }
1852
+ // Spawn agent with generated session ID for autonomous mode
1853
+ const agent = this.spawnAgent({
1854
+ issueId: issue.id,
1855
+ identifier: issue.identifier,
1856
+ worktreeIdentifier,
1857
+ sessionId: randomUUID(),
1858
+ worktreePath,
1859
+ workType,
1860
+ });
1861
+ result.agents.push(agent);
1862
+ }
1863
+ catch (error) {
1864
+ const err = error instanceof Error ? error : new Error(String(error));
1865
+ result.errors.push({ issueId: issue.id, error: err });
1866
+ console.error(`Failed to process ${issue.identifier}:`, err.message);
1867
+ }
1868
+ }
1869
+ result.success = result.errors.length === 0;
1870
+ return result;
1871
+ }
1872
+ /**
1873
+ * Spawn agent for a single issue (webhook-triggered or CLI)
1874
+ * Generates a session ID if not provided to enable autonomous mode
1875
+ *
1876
+ * This method includes crash recovery support:
1877
+ * - If a worktree exists with valid state and stale heartbeat, triggers recovery
1878
+ * - If a worktree exists with fresh heartbeat (agent alive), throws error to prevent duplicates
1879
+ *
1880
+ * @param issueIdOrIdentifier - Issue ID or identifier (e.g., SUP-123)
1881
+ * @param sessionId - Optional Linear session ID
1882
+ * @param workType - Optional work type (auto-detected from issue status if not provided)
1883
+ * @param prompt - Optional custom prompt override
1884
+ */
1885
+ async spawnAgentForIssue(issueIdOrIdentifier, sessionId, workType, prompt) {
1886
+ console.log(`Fetching issue:`, issueIdOrIdentifier);
1887
+ const issue = await this.client.getIssue(issueIdOrIdentifier);
1888
+ const identifier = issue.identifier;
1889
+ const issueId = issue.id; // Use the actual UUID
1890
+ console.log(`Processing single issue: ${identifier} (${issueId}) - ${issue.title}`);
1891
+ // Auto-detect work type from issue status if not provided
1892
+ // This must happen BEFORE creating worktree since path includes work type suffix
1893
+ let effectiveWorkType = workType;
1894
+ if (!effectiveWorkType) {
1895
+ const state = await issue.state;
1896
+ const statusName = state?.name ?? 'Backlog';
1897
+ effectiveWorkType = STATUS_WORK_TYPE_MAP[statusName] ?? 'development';
1898
+ console.log(`Auto-detected work type: ${effectiveWorkType} (from status: ${statusName})`);
1899
+ }
1900
+ // Create worktree with work type suffix (e.g., SUP-294-QA)
1901
+ const { worktreePath, worktreeIdentifier } = this.createWorktree(identifier, effectiveWorkType);
1902
+ // Pre-install dependencies so the agent doesn't waste time on pnpm install
1903
+ this.preInstallDependencies(worktreePath, identifier);
1904
+ // Check for existing state and potential recovery
1905
+ const recoveryCheck = checkRecovery(worktreePath, {
1906
+ heartbeatTimeoutMs: getHeartbeatTimeoutFromEnv(),
1907
+ maxRecoveryAttempts: getMaxRecoveryAttemptsFromEnv(),
1908
+ });
1909
+ if (recoveryCheck.agentAlive) {
1910
+ // Agent is still running - prevent duplicate
1911
+ throw new Error(`Agent already running for ${identifier}: ${recoveryCheck.message}. ` +
1912
+ `Stop the existing agent before spawning a new one.`);
1913
+ }
1914
+ if (recoveryCheck.canRecover && recoveryCheck.state) {
1915
+ // Crashed agent detected - trigger recovery
1916
+ console.log(`Recovery detected for ${identifier}: ${recoveryCheck.message}`);
1917
+ // Increment recovery attempts in state
1918
+ const updatedState = updateState(worktreePath, {
1919
+ recoveryAttempts: (recoveryCheck.state.recoveryAttempts ?? 0) + 1,
1920
+ });
1921
+ // Build recovery prompt
1922
+ const recoveryPrompt = prompt ?? buildRecoveryPrompt(recoveryCheck.state, recoveryCheck.todos);
1923
+ // Use existing Claude session ID for resume if available
1924
+ const claudeSessionId = recoveryCheck.state.claudeSessionId ?? undefined;
1925
+ // Inherit work type from previous state if not provided
1926
+ const recoveryWorkType = workType ?? recoveryCheck.state.workType ?? effectiveWorkType;
1927
+ const effectiveSessionId = sessionId ?? recoveryCheck.state.linearSessionId ?? randomUUID();
1928
+ console.log(`Resuming work on ${identifier} (recovery attempt ${updatedState?.recoveryAttempts ?? 1})`);
1929
+ // Update status based on work type if auto-transition is enabled
1930
+ const startStatus = WORK_TYPE_START_STATUS[recoveryWorkType];
1931
+ if (this.config.autoTransition && startStatus) {
1932
+ await this.client.updateIssueStatus(issueId, startStatus);
1933
+ console.log(`Updated ${identifier} status to ${startStatus}`);
1934
+ }
1935
+ // Spawn with resume capability
1936
+ return this.spawnAgentWithResume({
1937
+ issueId,
1938
+ identifier,
1939
+ worktreeIdentifier,
1940
+ sessionId: effectiveSessionId,
1941
+ worktreePath,
1942
+ prompt: recoveryPrompt,
1943
+ claudeSessionId,
1944
+ workType: recoveryWorkType,
1945
+ });
1946
+ }
1947
+ // No recovery needed - proceed with fresh spawn
1948
+ // Update status based on work type if auto-transition is enabled
1949
+ const startStatus = WORK_TYPE_START_STATUS[effectiveWorkType];
1950
+ if (this.config.autoTransition && startStatus) {
1951
+ await this.client.updateIssueStatus(issueId, startStatus);
1952
+ console.log(`Updated ${identifier} status to ${startStatus}`);
1953
+ }
1954
+ // Generate session ID if not provided to enable autonomous mode
1955
+ // This ensures LINEAR_SESSION_ID is always set, triggering headless operation
1956
+ const effectiveSessionId = sessionId ?? randomUUID();
1957
+ // Spawn agent with work type and optional custom prompt
1958
+ return this.spawnAgent({
1959
+ issueId,
1960
+ identifier,
1961
+ worktreeIdentifier,
1962
+ sessionId: effectiveSessionId,
1963
+ worktreePath,
1964
+ workType: effectiveWorkType,
1965
+ prompt,
1966
+ });
1967
+ }
1968
+ /**
1969
+ * Get all active agents
1970
+ */
1971
+ getActiveAgents() {
1972
+ return Array.from(this.activeAgents.values()).filter((a) => a.status === 'running' || a.status === 'starting');
1973
+ }
1974
+ /**
1975
+ * Stop a running agent by issue ID
1976
+ * @param issueId - The Linear issue ID
1977
+ * @param cleanupWorktree - Whether to remove the git worktree
1978
+ * @param stopReason - Why the agent is being stopped: 'user_request' or 'timeout'
1979
+ */
1980
+ async stopAgent(issueId, cleanupWorktree = false, stopReason = 'user_request') {
1981
+ const agent = this.activeAgents.get(issueId);
1982
+ if (!agent) {
1983
+ return { stopped: false, reason: 'not_found' };
1984
+ }
1985
+ if (agent.status !== 'running' && agent.status !== 'starting') {
1986
+ return { stopped: false, reason: 'already_stopped', agent };
1987
+ }
1988
+ const abortController = this.abortControllers.get(issueId);
1989
+ if (!abortController) {
1990
+ return { stopped: false, reason: 'not_found', agent };
1991
+ }
1992
+ const log = this.agentLoggers.get(issueId);
1993
+ try {
1994
+ // Emit final activity before stopping
1995
+ const emitter = this.activityEmitters.get(issueId);
1996
+ if (emitter) {
1997
+ try {
1998
+ const message = stopReason === 'user_request'
1999
+ ? 'Agent stopped by user request.'
2000
+ : 'Agent stopped due to timeout.';
2001
+ await emitter.emitResponse(message);
2002
+ await emitter.flush();
2003
+ }
2004
+ catch (emitError) {
2005
+ log?.warn('Failed to emit stop activity', {
2006
+ error: emitError instanceof Error ? emitError.message : String(emitError),
2007
+ });
2008
+ }
2009
+ }
2010
+ // Mark as stopped with reason before aborting
2011
+ agent.status = 'stopped';
2012
+ agent.stopReason = stopReason;
2013
+ agent.completedAt = new Date();
2014
+ // Abort the query
2015
+ abortController.abort();
2016
+ // Clean up worktree if requested
2017
+ if (cleanupWorktree) {
2018
+ this.removeWorktree(agent.worktreeIdentifier);
2019
+ }
2020
+ const logMessage = stopReason === 'user_request'
2021
+ ? 'Agent stopped by user request'
2022
+ : 'Agent stopped due to timeout';
2023
+ log?.status('stopped', logMessage);
2024
+ return { stopped: true, agent };
2025
+ }
2026
+ catch (error) {
2027
+ log?.warn('Failed to stop agent', {
2028
+ error: error instanceof Error ? error.message : String(error),
2029
+ });
2030
+ return { stopped: false, reason: 'signal_failed', agent };
2031
+ }
2032
+ }
2033
+ /**
2034
+ * Stop a running agent by session ID
2035
+ */
2036
+ async stopAgentBySession(sessionId, cleanupWorktree = false) {
2037
+ const issueId = this.sessionToIssue.get(sessionId);
2038
+ if (!issueId) {
2039
+ return { stopped: false, reason: 'not_found' };
2040
+ }
2041
+ return this.stopAgent(issueId, cleanupWorktree);
2042
+ }
2043
+ /**
2044
+ * Get agent by session ID
2045
+ */
2046
+ getAgentBySession(sessionId) {
2047
+ const issueId = this.sessionToIssue.get(sessionId);
2048
+ if (!issueId)
2049
+ return undefined;
2050
+ return this.activeAgents.get(issueId);
2051
+ }
2052
+ /**
2053
+ * Update the worker ID for all active activity emitters.
2054
+ * Called after worker re-registration to ensure activities are attributed
2055
+ * to the new worker ID and pass ownership checks.
2056
+ *
2057
+ * @param newWorkerId - The new worker ID after re-registration
2058
+ */
2059
+ updateWorkerId(newWorkerId) {
2060
+ // Update the config for any future emitters
2061
+ if (this.config.apiActivityConfig) {
2062
+ this.config.apiActivityConfig.workerId = newWorkerId;
2063
+ }
2064
+ // Update all existing activity emitters
2065
+ for (const [issueId, emitter] of this.activityEmitters.entries()) {
2066
+ // Only ApiActivityEmitter has updateWorkerId method
2067
+ if ('updateWorkerId' in emitter && typeof emitter.updateWorkerId === 'function') {
2068
+ emitter.updateWorkerId(newWorkerId);
2069
+ console.log(`[Orchestrator] Updated worker ID for emitter ${issueId}`);
2070
+ }
2071
+ }
2072
+ }
2073
+ /**
2074
+ * Forward a follow-up prompt to an existing or new agent
2075
+ *
2076
+ * If the agent is running, attempts to inject the message into the running session
2077
+ * without stopping it. If injection fails or agent isn't running, it will be
2078
+ * stopped gracefully and resumed with the new prompt.
2079
+ *
2080
+ * @param workType - Optional work type. If not provided, inherits from existing agent or defaults to 'development'.
2081
+ */
2082
+ async forwardPrompt(issueId, sessionId, prompt, claudeSessionId, workType) {
2083
+ const existingAgent = this.activeAgents.get(issueId);
2084
+ // If agent is running, try to inject the message without stopping
2085
+ if (existingAgent && (existingAgent.status === 'running' || existingAgent.status === 'starting')) {
2086
+ const injectResult = await this.injectMessage(issueId, sessionId, prompt);
2087
+ if (injectResult.injected) {
2088
+ console.log(`Message injected into running agent for ${existingAgent.identifier}`);
2089
+ return {
2090
+ forwarded: true,
2091
+ resumed: false,
2092
+ injected: true,
2093
+ agent: existingAgent,
2094
+ };
2095
+ }
2096
+ // Injection failed - fall back to stop and respawn
2097
+ console.log(`Message injection failed for ${existingAgent.identifier}: ${injectResult.reason} - stopping and respawning`);
2098
+ await this.stopAgent(issueId, false); // Don't cleanup worktree
2099
+ }
2100
+ // Get worktree path from existing agent or create new one
2101
+ let worktreePath;
2102
+ let worktreeIdentifier;
2103
+ let identifier;
2104
+ if (existingAgent) {
2105
+ worktreePath = existingAgent.worktreePath;
2106
+ worktreeIdentifier = existingAgent.worktreeIdentifier;
2107
+ identifier = existingAgent.identifier;
2108
+ // Use existing Claude session ID if not provided
2109
+ claudeSessionId = claudeSessionId ?? existingAgent.claudeSessionId;
2110
+ // Inherit work type from existing agent if not provided
2111
+ workType = workType ?? existingAgent.workType;
2112
+ }
2113
+ else {
2114
+ // Need to fetch issue to get identifier
2115
+ try {
2116
+ const issue = await this.client.getIssue(issueId);
2117
+ identifier = issue.identifier;
2118
+ // Auto-detect work type from issue status if not provided
2119
+ // This prevents defaulting to 'development' which would cause
2120
+ // incorrect status transitions (e.g., Delivered \u2192 Started for acceptance work)
2121
+ if (!workType) {
2122
+ const state = await issue.state;
2123
+ const statusName = state?.name ?? 'Backlog';
2124
+ workType = STATUS_WORK_TYPE_MAP[statusName] ?? 'development';
2125
+ }
2126
+ const result = this.createWorktree(identifier, workType);
2127
+ worktreePath = result.worktreePath;
2128
+ worktreeIdentifier = result.worktreeIdentifier;
2129
+ // Pre-install dependencies so the agent doesn't waste time on pnpm install
2130
+ this.preInstallDependencies(worktreePath, identifier);
2131
+ }
2132
+ catch (error) {
2133
+ return {
2134
+ forwarded: false,
2135
+ resumed: false,
2136
+ reason: 'not_found',
2137
+ error: error instanceof Error ? error : new Error(String(error)),
2138
+ };
2139
+ }
2140
+ }
2141
+ // Check if worktree exists
2142
+ if (!existsSync(worktreePath)) {
2143
+ try {
2144
+ const result = this.createWorktree(identifier, workType ?? 'development');
2145
+ worktreePath = result.worktreePath;
2146
+ worktreeIdentifier = result.worktreeIdentifier;
2147
+ // Pre-install dependencies so the agent doesn't waste time on pnpm install
2148
+ this.preInstallDependencies(worktreePath, identifier);
2149
+ }
2150
+ catch (error) {
2151
+ return {
2152
+ forwarded: false,
2153
+ resumed: false,
2154
+ reason: 'no_worktree',
2155
+ error: error instanceof Error ? error : new Error(String(error)),
2156
+ };
2157
+ }
2158
+ }
2159
+ // Spawn agent with resume if we have a Claude session ID
2160
+ try {
2161
+ const agent = await this.spawnAgentWithResume({
2162
+ issueId,
2163
+ identifier,
2164
+ worktreeIdentifier,
2165
+ sessionId,
2166
+ worktreePath,
2167
+ prompt,
2168
+ claudeSessionId,
2169
+ workType,
2170
+ });
2171
+ return {
2172
+ forwarded: true,
2173
+ resumed: !!claudeSessionId,
2174
+ agent,
2175
+ };
2176
+ }
2177
+ catch (error) {
2178
+ return {
2179
+ forwarded: false,
2180
+ resumed: false,
2181
+ reason: 'spawn_failed',
2182
+ error: error instanceof Error ? error : new Error(String(error)),
2183
+ };
2184
+ }
2185
+ }
2186
+ /**
2187
+ * Inject a user message into a running agent session without stopping it.
2188
+ *
2189
+ * Uses the SDK's streamInput() method to send follow-up messages to a running session.
2190
+ * This is the preferred method for user follow-ups as it doesn't interrupt agent work.
2191
+ *
2192
+ * @param issueId - The issue ID the agent is working on
2193
+ * @param sessionId - The Linear session ID
2194
+ * @param message - The user message to inject
2195
+ * @returns Result indicating if injection was successful
2196
+ */
2197
+ async injectMessage(issueId, sessionId, message) {
2198
+ const log = this.agentLoggers.get(issueId);
2199
+ const agent = this.activeAgents.get(issueId);
2200
+ const handle = this.agentHandles.get(issueId);
2201
+ // Check if agent is running
2202
+ if (!agent || (agent.status !== 'running' && agent.status !== 'starting')) {
2203
+ return {
2204
+ injected: false,
2205
+ reason: 'not_running',
2206
+ };
2207
+ }
2208
+ // Check if we have the handle
2209
+ if (!handle) {
2210
+ log?.warn('No AgentHandle found for running agent', { issueId, sessionId });
2211
+ return {
2212
+ injected: false,
2213
+ reason: 'no_query',
2214
+ };
2215
+ }
2216
+ try {
2217
+ // Inject the message into the running session via provider handle
2218
+ log?.info('Injecting user message into running session', {
2219
+ issueId,
2220
+ sessionId,
2221
+ messageLength: message.length,
2222
+ });
2223
+ await handle.injectMessage(message);
2224
+ // Update activity timestamp since we just interacted with the agent
2225
+ agent.lastActivityAt = new Date();
2226
+ log?.success('Message injected successfully');
2227
+ return {
2228
+ injected: true,
2229
+ };
2230
+ }
2231
+ catch (error) {
2232
+ log?.error('Failed to inject message', {
2233
+ error: error instanceof Error ? error.message : String(error),
2234
+ });
2235
+ return {
2236
+ injected: false,
2237
+ reason: 'injection_failed',
2238
+ error: error instanceof Error ? error : new Error(String(error)),
2239
+ };
2240
+ }
2241
+ }
2242
+ /**
2243
+ * Spawn an agent with resume capability for continuing a previous session
2244
+ * If autoTransition is enabled, also transitions the issue status to the appropriate working state
2245
+ */
2246
+ async spawnAgentWithResume(options) {
2247
+ const { issueId, identifier, worktreeIdentifier, sessionId, worktreePath, prompt, claudeSessionId, workType } = options;
2248
+ // Create logger for this agent
2249
+ const log = createLogger({ issueIdentifier: identifier });
2250
+ this.agentLoggers.set(issueId, log);
2251
+ // Use the work type to determine if we need to transition on start
2252
+ // Only certain work types trigger a start transition
2253
+ const effectiveWorkType = workType ?? 'development';
2254
+ const startStatus = WORK_TYPE_START_STATUS[effectiveWorkType];
2255
+ if (this.config.autoTransition && startStatus) {
2256
+ try {
2257
+ await this.client.updateIssueStatus(issueId, startStatus);
2258
+ log.info('Transitioned issue status on resume', { workType: effectiveWorkType, to: startStatus });
2259
+ }
2260
+ catch (error) {
2261
+ // Log but don't fail - status might already be in a working state
2262
+ log.warn('Failed to transition issue status', {
2263
+ error: error instanceof Error ? error.message : String(error),
2264
+ });
2265
+ }
2266
+ }
2267
+ const now = new Date();
2268
+ const agent = {
2269
+ issueId,
2270
+ identifier,
2271
+ worktreeIdentifier,
2272
+ sessionId,
2273
+ claudeSessionId,
2274
+ worktreePath,
2275
+ pid: undefined,
2276
+ status: 'starting',
2277
+ startedAt: now,
2278
+ lastActivityAt: now, // Initialize for inactivity tracking
2279
+ workType,
2280
+ };
2281
+ this.activeAgents.set(issueId, agent);
2282
+ // Track session to issue mapping for stop signal handling
2283
+ this.sessionToIssue.set(sessionId, issueId);
2284
+ // Initialize state persistence and monitoring (for resumed sessions)
2285
+ try {
2286
+ // Write/update state with resume info
2287
+ const initialState = createInitialState({
2288
+ issueId,
2289
+ issueIdentifier: identifier,
2290
+ linearSessionId: sessionId,
2291
+ workType: effectiveWorkType,
2292
+ prompt,
2293
+ workerId: this.config.apiActivityConfig?.workerId ?? null,
2294
+ pid: null, // Will be updated when process spawns
2295
+ });
2296
+ // Preserve Claude session ID if resuming
2297
+ if (claudeSessionId) {
2298
+ initialState.claudeSessionId = claudeSessionId;
2299
+ }
2300
+ writeState(worktreePath, initialState);
2301
+ // Start heartbeat writer for crash detection
2302
+ const heartbeatWriter = createHeartbeatWriter({
2303
+ agentDir: resolve(worktreePath, '.agent'),
2304
+ pid: process.pid, // Will be updated to child PID after spawn
2305
+ intervalMs: getHeartbeatIntervalFromEnv(),
2306
+ startTime: now.getTime(),
2307
+ });
2308
+ heartbeatWriter.start();
2309
+ this.heartbeatWriters.set(issueId, heartbeatWriter);
2310
+ // Start progress logger for debugging
2311
+ const progressLogger = createProgressLogger({
2312
+ agentDir: resolve(worktreePath, '.agent'),
2313
+ });
2314
+ progressLogger.logStart({ issueId, workType: effectiveWorkType, prompt: prompt.substring(0, 200) });
2315
+ this.progressLoggers.set(issueId, progressLogger);
2316
+ // Start session logger for verbose analysis if enabled
2317
+ if (isSessionLoggingEnabled()) {
2318
+ const logConfig = getLogAnalysisConfig();
2319
+ const sessionLogger = createSessionLogger({
2320
+ sessionId,
2321
+ issueId,
2322
+ issueIdentifier: identifier,
2323
+ workType: effectiveWorkType,
2324
+ prompt,
2325
+ logsDir: logConfig.logsDir,
2326
+ workerId: this.config.apiActivityConfig?.workerId,
2327
+ });
2328
+ this.sessionLoggers.set(issueId, sessionLogger);
2329
+ log.debug('Session logging initialized', { logsDir: logConfig.logsDir });
2330
+ }
2331
+ log.debug('State persistence initialized', { agentDir: resolve(worktreePath, '.agent') });
2332
+ }
2333
+ catch (stateError) {
2334
+ // Log but don't fail - state persistence is optional
2335
+ log.warn('Failed to initialize state persistence', {
2336
+ error: stateError instanceof Error ? stateError.message : String(stateError),
2337
+ });
2338
+ }
2339
+ this.events.onAgentStart?.(agent);
2340
+ // Set up activity streaming
2341
+ let emitter;
2342
+ // Check if we should use API-based activity emitter (for remote workers)
2343
+ if (this.config.apiActivityConfig) {
2344
+ const { baseUrl, apiKey, workerId } = this.config.apiActivityConfig;
2345
+ log.debug('Using API activity emitter', { baseUrl });
2346
+ emitter = createApiActivityEmitter({
2347
+ sessionId,
2348
+ workerId,
2349
+ apiBaseUrl: baseUrl,
2350
+ apiKey,
2351
+ minInterval: this.config.streamConfig.minInterval,
2352
+ maxOutputLength: this.config.streamConfig.maxOutputLength,
2353
+ includeTimestamps: this.config.streamConfig.includeTimestamps,
2354
+ onActivityEmitted: (type, content) => {
2355
+ log.activity(type, content);
2356
+ },
2357
+ onActivityError: (type, error) => {
2358
+ log.error(`Activity error (${type})`, { error: error.message });
2359
+ },
2360
+ });
2361
+ }
2362
+ else {
2363
+ // Direct Linear API
2364
+ const session = createAgentSession({
2365
+ client: this.client.linearClient,
2366
+ issueId,
2367
+ sessionId,
2368
+ autoTransition: false,
2369
+ });
2370
+ this.agentSessions.set(issueId, session);
2371
+ emitter = createActivityEmitter({
2372
+ session,
2373
+ minInterval: this.config.streamConfig.minInterval,
2374
+ maxOutputLength: this.config.streamConfig.maxOutputLength,
2375
+ includeTimestamps: this.config.streamConfig.includeTimestamps,
2376
+ onActivityEmitted: (type, content) => {
2377
+ log.activity(type, content);
2378
+ },
2379
+ });
2380
+ }
2381
+ this.activityEmitters.set(issueId, emitter);
2382
+ // Create AbortController for cancellation
2383
+ const abortController = new AbortController();
2384
+ this.abortControllers.set(issueId, abortController);
2385
+ // Load environment from settings.local.json
2386
+ const settingsEnv = loadSettingsEnv(worktreePath, log);
2387
+ // Load app-specific env files based on work type
2388
+ // Development work loads .env.local, QA/acceptance loads .env.test.local
2389
+ const effectiveWorkTypeForEnv = workType ?? 'development';
2390
+ const appEnv = loadAppEnvFiles(worktreePath, effectiveWorkTypeForEnv, log);
2391
+ // Build environment variables - inherit ALL from process.env (required for node to be found)
2392
+ // Then overlay app env vars, settings.local.json env vars, then our specific vars
2393
+ const processEnvFiltered = {};
2394
+ for (const [key, value] of Object.entries(process.env)) {
2395
+ if (typeof value === 'string') {
2396
+ processEnvFiltered[key] = value;
2397
+ }
2398
+ }
2399
+ const env = {
2400
+ ...processEnvFiltered, // Include all parent env vars (PATH, NODE_PATH, etc.)
2401
+ ...appEnv, // Include app env vars (.env.local or .env.test.local based on work type)
2402
+ ...settingsEnv, // Include all env vars from settings.local.json (highest priority)
2403
+ LINEAR_ISSUE_ID: issueId,
2404
+ LINEAR_SESSION_ID: sessionId,
2405
+ // Set work type so agent knows if it's doing QA or development work
2406
+ ...(workType && { LINEAR_WORK_TYPE: workType }),
2407
+ };
2408
+ log.info('Starting agent via provider', {
2409
+ provider: this.provider.name,
2410
+ worktreePath,
2411
+ resuming: !!claudeSessionId,
2412
+ workType: workType ?? 'development',
2413
+ });
2414
+ // Spawn agent via provider interface (with resume if session ID available)
2415
+ const spawnConfig = {
2416
+ prompt,
2417
+ cwd: worktreePath,
2418
+ env,
2419
+ abortController,
2420
+ autonomous: true,
2421
+ sandboxEnabled: this.config.sandboxEnabled,
2422
+ onProcessSpawned: (pid) => {
2423
+ agent.pid = pid;
2424
+ log.info('Agent process spawned', { pid });
2425
+ },
2426
+ };
2427
+ const handle = claudeSessionId
2428
+ ? this.provider.resume(claudeSessionId, spawnConfig)
2429
+ : this.provider.spawn(spawnConfig);
2430
+ this.agentHandles.set(issueId, handle);
2431
+ agent.status = 'running';
2432
+ // Process the event stream in the background
2433
+ this.processEventStream(issueId, identifier, sessionId, handle, emitter, agent);
2434
+ return agent;
2435
+ }
2436
+ /**
2437
+ * Stop all running agents
2438
+ */
2439
+ stopAll() {
2440
+ for (const [issueId] of this.abortControllers) {
2441
+ try {
2442
+ const agent = this.activeAgents.get(issueId);
2443
+ if (agent) {
2444
+ agent.status = 'stopped';
2445
+ agent.completedAt = new Date();
2446
+ }
2447
+ const abortController = this.abortControllers.get(issueId);
2448
+ abortController?.abort();
2449
+ }
2450
+ catch (error) {
2451
+ console.warn(`Failed to stop agent for ${issueId}:`, error);
2452
+ }
2453
+ }
2454
+ this.abortControllers.clear();
2455
+ this.sessionToIssue.clear();
2456
+ }
2457
+ /**
2458
+ * Wait for all agents to complete with inactivity-based timeout
2459
+ *
2460
+ * Unlike a simple session timeout, this method monitors each agent's activity
2461
+ * and only stops agents that have been inactive for longer than the inactivity
2462
+ * timeout. Active agents are allowed to run indefinitely (unless maxSessionTimeoutMs
2463
+ * is set as a hard cap).
2464
+ *
2465
+ * @param inactivityTimeoutMsOverride - Override inactivity timeout from config (for backwards compatibility)
2466
+ */
2467
+ async waitForAll(inactivityTimeoutMsOverride) {
2468
+ const activeAgents = this.getActiveAgents();
2469
+ if (activeAgents.length === 0) {
2470
+ return Array.from(this.activeAgents.values());
2471
+ }
2472
+ return new Promise((resolve) => {
2473
+ const checkInterval = setInterval(async () => {
2474
+ const stillActive = this.getActiveAgents();
2475
+ if (stillActive.length === 0) {
2476
+ clearInterval(checkInterval);
2477
+ resolve(Array.from(this.activeAgents.values()));
2478
+ return;
2479
+ }
2480
+ const now = Date.now();
2481
+ // Check each agent for inactivity timeout and max session timeout
2482
+ for (const agent of stillActive) {
2483
+ // Get timeout config for this agent's work type
2484
+ const timeoutConfig = this.getTimeoutConfig(agent.workType);
2485
+ // Use override if provided (for backwards compatibility), otherwise use config
2486
+ const inactivityTimeout = inactivityTimeoutMsOverride ?? timeoutConfig.inactivityTimeoutMs;
2487
+ const maxSessionTimeout = timeoutConfig.maxSessionTimeoutMs;
2488
+ const log = this.agentLoggers.get(agent.issueId);
2489
+ const timeSinceLastActivity = now - agent.lastActivityAt.getTime();
2490
+ const totalRuntime = now - agent.startedAt.getTime();
2491
+ // Check max session timeout (hard cap regardless of activity)
2492
+ if (maxSessionTimeout && totalRuntime > maxSessionTimeout) {
2493
+ log?.warn('Agent reached max session timeout', {
2494
+ totalRuntime: `${Math.floor(totalRuntime / 1000)}s`,
2495
+ maxSessionTimeout: `${Math.floor(maxSessionTimeout / 1000)}s`,
2496
+ });
2497
+ await this.stopAgent(agent.issueId, false, 'timeout');
2498
+ continue;
2499
+ }
2500
+ // Check inactivity timeout (agent is "hung" only if no activity)
2501
+ if (timeSinceLastActivity > inactivityTimeout) {
2502
+ log?.warn('Agent timed out due to inactivity', {
2503
+ timeSinceLastActivity: `${Math.floor(timeSinceLastActivity / 1000)}s`,
2504
+ inactivityTimeout: `${Math.floor(inactivityTimeout / 1000)}s`,
2505
+ lastActivityAt: agent.lastActivityAt.toISOString(),
2506
+ });
2507
+ await this.stopAgent(agent.issueId, false, 'timeout');
2508
+ }
2509
+ }
2510
+ // Check again if all agents are done after potential stops
2511
+ const remaining = this.getActiveAgents();
2512
+ if (remaining.length === 0) {
2513
+ clearInterval(checkInterval);
2514
+ resolve(Array.from(this.activeAgents.values()));
2515
+ }
2516
+ }, 1000);
2517
+ });
2518
+ }
2519
+ }
2520
+ /**
2521
+ * Create an orchestrator instance
2522
+ */
2523
+ export function createOrchestrator(config, events) {
2524
+ return new AgentOrchestrator(config, events);
2525
+ }