@ob1-sg/horizon 0.1.10 → 0.1.12

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 (157) hide show
  1. package/dist/cli.d.ts +3 -0
  2. package/dist/cli.d.ts.map +1 -0
  3. package/dist/cli.js +43 -0
  4. package/dist/cli.js.map +1 -0
  5. package/dist/config.d.ts +10 -0
  6. package/dist/config.d.ts.map +1 -0
  7. package/dist/config.js +293 -0
  8. package/dist/config.js.map +1 -0
  9. package/dist/index.d.ts +4 -0
  10. package/dist/index.d.ts.map +1 -0
  11. package/dist/index.js +635 -0
  12. package/dist/index.js.map +1 -0
  13. package/dist/lib/__tests__/attachment-downloader.test.d.ts +2 -0
  14. package/dist/lib/__tests__/attachment-downloader.test.d.ts.map +1 -0
  15. package/dist/lib/__tests__/attachment-downloader.test.js +163 -0
  16. package/dist/lib/__tests__/attachment-downloader.test.js.map +1 -0
  17. package/dist/lib/__tests__/cli-detection.test.d.ts +2 -0
  18. package/dist/lib/__tests__/cli-detection.test.d.ts.map +1 -0
  19. package/dist/lib/__tests__/cli-detection.test.js +119 -0
  20. package/dist/lib/__tests__/cli-detection.test.js.map +1 -0
  21. package/dist/lib/__tests__/config.test.d.ts +2 -0
  22. package/dist/lib/__tests__/config.test.d.ts.map +1 -0
  23. package/dist/lib/__tests__/config.test.js +291 -0
  24. package/dist/lib/__tests__/config.test.js.map +1 -0
  25. package/dist/lib/__tests__/gcp.test.d.ts +2 -0
  26. package/dist/lib/__tests__/gcp.test.d.ts.map +1 -0
  27. package/dist/lib/__tests__/gcp.test.js +104 -0
  28. package/dist/lib/__tests__/gcp.test.js.map +1 -0
  29. package/dist/lib/__tests__/git.test.d.ts +2 -0
  30. package/dist/lib/__tests__/git.test.d.ts.map +1 -0
  31. package/dist/lib/__tests__/git.test.js +62 -0
  32. package/dist/lib/__tests__/git.test.js.map +1 -0
  33. package/dist/lib/__tests__/linear-quick-check.test.d.ts +2 -0
  34. package/dist/lib/__tests__/linear-quick-check.test.d.ts.map +1 -0
  35. package/dist/lib/__tests__/linear-quick-check.test.js +152 -0
  36. package/dist/lib/__tests__/linear-quick-check.test.js.map +1 -0
  37. package/dist/lib/__tests__/loop-instance-name.test.d.ts +2 -0
  38. package/dist/lib/__tests__/loop-instance-name.test.d.ts.map +1 -0
  39. package/dist/lib/__tests__/loop-instance-name.test.js +90 -0
  40. package/dist/lib/__tests__/loop-instance-name.test.js.map +1 -0
  41. package/dist/lib/__tests__/output-logger.test.d.ts +2 -0
  42. package/dist/lib/__tests__/output-logger.test.d.ts.map +1 -0
  43. package/dist/lib/__tests__/output-logger.test.js +136 -0
  44. package/dist/lib/__tests__/output-logger.test.js.map +1 -0
  45. package/dist/lib/__tests__/prompts.test.d.ts +2 -0
  46. package/dist/lib/__tests__/prompts.test.d.ts.map +1 -0
  47. package/dist/lib/__tests__/prompts.test.js +70 -0
  48. package/dist/lib/__tests__/prompts.test.js.map +1 -0
  49. package/dist/lib/__tests__/provider.test.d.ts +2 -0
  50. package/dist/lib/__tests__/provider.test.d.ts.map +1 -0
  51. package/dist/lib/__tests__/provider.test.js +89 -0
  52. package/dist/lib/__tests__/provider.test.js.map +1 -0
  53. package/dist/lib/__tests__/rate-limit.test.d.ts +2 -0
  54. package/dist/lib/__tests__/rate-limit.test.d.ts.map +1 -0
  55. package/dist/lib/__tests__/rate-limit.test.js +275 -0
  56. package/dist/lib/__tests__/rate-limit.test.js.map +1 -0
  57. package/dist/lib/__tests__/readline.test.d.ts +2 -0
  58. package/dist/lib/__tests__/readline.test.d.ts.map +1 -0
  59. package/dist/lib/__tests__/readline.test.js +55 -0
  60. package/dist/lib/__tests__/readline.test.js.map +1 -0
  61. package/dist/lib/__tests__/stats-logger.test.d.ts +2 -0
  62. package/dist/lib/__tests__/stats-logger.test.d.ts.map +1 -0
  63. package/dist/lib/__tests__/stats-logger.test.js +297 -0
  64. package/dist/lib/__tests__/stats-logger.test.js.map +1 -0
  65. package/dist/lib/__tests__/update-checker.test.d.ts +2 -0
  66. package/dist/lib/__tests__/update-checker.test.d.ts.map +1 -0
  67. package/dist/lib/__tests__/update-checker.test.js +141 -0
  68. package/dist/lib/__tests__/update-checker.test.js.map +1 -0
  69. package/dist/lib/__tests__/version.test.d.ts +2 -0
  70. package/dist/lib/__tests__/version.test.d.ts.map +1 -0
  71. package/dist/lib/__tests__/version.test.js +51 -0
  72. package/dist/lib/__tests__/version.test.js.map +1 -0
  73. package/dist/lib/attachment-downloader.d.ts +26 -0
  74. package/dist/lib/attachment-downloader.d.ts.map +1 -0
  75. package/dist/lib/attachment-downloader.js +259 -0
  76. package/dist/lib/attachment-downloader.js.map +1 -0
  77. package/dist/lib/claude.d.ts +6 -0
  78. package/dist/lib/claude.d.ts.map +1 -0
  79. package/dist/lib/claude.js +459 -0
  80. package/dist/lib/claude.js.map +1 -0
  81. package/dist/lib/cli-detection.d.ts +25 -0
  82. package/dist/lib/cli-detection.d.ts.map +1 -0
  83. package/dist/lib/cli-detection.js +53 -0
  84. package/dist/lib/cli-detection.js.map +1 -0
  85. package/dist/lib/codex.d.ts +4 -0
  86. package/dist/lib/codex.d.ts.map +1 -0
  87. package/dist/lib/codex.js +320 -0
  88. package/dist/lib/codex.js.map +1 -0
  89. package/dist/lib/gcp.d.ts +21 -0
  90. package/dist/lib/gcp.d.ts.map +1 -0
  91. package/dist/lib/gcp.js +96 -0
  92. package/dist/lib/gcp.js.map +1 -0
  93. package/dist/lib/git.d.ts +3 -0
  94. package/dist/lib/git.d.ts.map +1 -0
  95. package/dist/lib/git.js +24 -0
  96. package/dist/lib/git.js.map +1 -0
  97. package/dist/lib/init-project.d.ts +13 -0
  98. package/dist/lib/init-project.d.ts.map +1 -0
  99. package/dist/lib/init-project.js +420 -0
  100. package/dist/lib/init-project.js.map +1 -0
  101. package/dist/lib/linear-api.d.ts +32 -0
  102. package/dist/lib/linear-api.d.ts.map +1 -0
  103. package/dist/lib/linear-api.js +267 -0
  104. package/dist/lib/linear-api.js.map +1 -0
  105. package/dist/lib/linear-quick-check.d.ts +13 -0
  106. package/dist/lib/linear-quick-check.d.ts.map +1 -0
  107. package/dist/lib/linear-quick-check.js +61 -0
  108. package/dist/lib/linear-quick-check.js.map +1 -0
  109. package/dist/lib/loop-instance-name.d.ts +29 -0
  110. package/dist/lib/loop-instance-name.d.ts.map +1 -0
  111. package/dist/lib/loop-instance-name.js +105 -0
  112. package/dist/lib/loop-instance-name.js.map +1 -0
  113. package/dist/lib/output-logger.d.ts +23 -0
  114. package/dist/lib/output-logger.d.ts.map +1 -0
  115. package/dist/lib/output-logger.js +104 -0
  116. package/dist/lib/output-logger.js.map +1 -0
  117. package/dist/lib/prompts.d.ts +17 -0
  118. package/dist/lib/prompts.d.ts.map +1 -0
  119. package/dist/lib/prompts.js +65 -0
  120. package/dist/lib/prompts.js.map +1 -0
  121. package/dist/lib/provider.d.ts +32 -0
  122. package/dist/lib/provider.d.ts.map +1 -0
  123. package/dist/lib/provider.js +27 -0
  124. package/dist/lib/provider.js.map +1 -0
  125. package/dist/lib/rate-limit.d.ts +14 -0
  126. package/dist/lib/rate-limit.d.ts.map +1 -0
  127. package/dist/lib/rate-limit.js +154 -0
  128. package/dist/lib/rate-limit.js.map +1 -0
  129. package/dist/lib/readline.d.ts +4 -0
  130. package/dist/lib/readline.d.ts.map +1 -0
  131. package/dist/lib/readline.js +39 -0
  132. package/dist/lib/readline.js.map +1 -0
  133. package/dist/lib/setup.d.ts +126 -0
  134. package/dist/lib/setup.d.ts.map +1 -0
  135. package/dist/lib/setup.js +482 -0
  136. package/dist/lib/setup.js.map +1 -0
  137. package/dist/lib/stats-logger.d.ts +92 -0
  138. package/dist/lib/stats-logger.d.ts.map +1 -0
  139. package/dist/lib/stats-logger.js +258 -0
  140. package/dist/lib/stats-logger.js.map +1 -0
  141. package/dist/lib/ui.d.ts +38 -0
  142. package/dist/lib/ui.d.ts.map +1 -0
  143. package/dist/lib/ui.js +69 -0
  144. package/dist/lib/ui.js.map +1 -0
  145. package/dist/lib/update-checker.d.ts +17 -0
  146. package/dist/lib/update-checker.d.ts.map +1 -0
  147. package/dist/lib/update-checker.js +138 -0
  148. package/dist/lib/update-checker.js.map +1 -0
  149. package/dist/lib/version.d.ts +10 -0
  150. package/dist/lib/version.d.ts.map +1 -0
  151. package/dist/lib/version.js +37 -0
  152. package/dist/lib/version.js.map +1 -0
  153. package/dist/types.d.ts +92 -0
  154. package/dist/types.d.ts.map +1 -0
  155. package/dist/types.js +3 -0
  156. package/dist/types.js.map +1 -0
  157. package/package.json +5 -2
package/dist/index.js ADDED
@@ -0,0 +1,635 @@
1
+ import { getConfig, isGitRepository } from './config.js';
2
+ import { sleep, executeWithRateLimitRetry } from './lib/rate-limit.js';
3
+ import { loadPrompt } from './lib/prompts.js';
4
+ import { getCurrentBranch } from './lib/git.js';
5
+ import { generatePodName } from './lib/loop-instance-name.js';
6
+ import { initLoopLogger, getCurrentOutputDir } from './lib/output-logger.js';
7
+ import { initLoopStats, logAgentStats, finalizeLoopStats } from './lib/stats-logger.js';
8
+ import { createProvider } from './lib/provider.js';
9
+ import { extractLinearUrls, downloadIssueAttachments } from './lib/attachment-downloader.js';
10
+ import { createLinearClientWithSignedUrls, getIssueDescription } from './lib/linear-api.js';
11
+ import { isRunningOnGcp, stopGcpInstance } from './lib/gcp.js';
12
+ import { checkForUncompletedTickets } from './lib/linear-quick-check.js';
13
+ import { getVersion } from './lib/version.js';
14
+ import { checkForUpdates, displayUpdateNotification } from './lib/update-checker.js';
15
+ import { box, BOLD, CYAN, YELLOW, RESET } from './lib/ui.js';
16
+ import { ensureHorizonDir, ensureHorizonDocsDir, ensureGitignore, loadExistingConfig, saveEnvConfig, saveMcpConfig, copyPromptsToProject, checkAndDisplayCliAvailability, autoSelectProvider, validateLinearKey, fetchLinearTeams, checkLinearStatuses, createLinearStatuses, checkCodexLinearMcp, createPromptInterface, promptSecret, promptSelect, } from './lib/setup.js';
17
+ // Import providers to register them
18
+ import './lib/claude.js';
19
+ import './lib/codex.js';
20
+ /**
21
+ * Extracts the issue identifier (e.g., RSK-39) from Agent 1's output.
22
+ * Agent 1 outputs this in the format "**Identifier**: RSK-39" or similar.
23
+ */
24
+ function extractIssueIdentifier(agent1Output) {
25
+ // Try various patterns Agent 1 might use
26
+ const patterns = [
27
+ /\*\*Identifier\*\*:\s*([A-Z]+-\d+)/i,
28
+ /\*\*Issue Identifier\*\*:\s*([A-Z]+-\d+)/i,
29
+ /Issue ID[^:]*:\s*[^\n]*\n[^*]*\*\*Identifier\*\*:\s*([A-Z]+-\d+)/i,
30
+ /Identifier:\s*([A-Z]+-\d+)/i,
31
+ /Branch:\s*horizon\/([A-Z]+-\d+)/i,
32
+ /\b([A-Z]+-\d+)\b/, // Fallback: any ticket-like pattern
33
+ ];
34
+ for (const pattern of patterns) {
35
+ const match = agent1Output.match(pattern);
36
+ if (match) {
37
+ return match[1];
38
+ }
39
+ }
40
+ return null;
41
+ }
42
+ async function runLoop(podName, iteration) {
43
+ const config = getConfig();
44
+ // Initialize output logger for this loop iteration
45
+ // Pod name persists across all loops in this Horizon session
46
+ initLoopLogger(podName, iteration);
47
+ // Initialize stats tracking for this loop iteration
48
+ initLoopStats(podName, iteration);
49
+ console.log('\n' + box([` Log Directory: ${getCurrentOutputDir()} `], { title: `Loop ${iteration}`, color: CYAN, width: 60 }));
50
+ const loopStart = Date.now();
51
+ // ═══════════════════════════════════════════════════════════════════════════
52
+ // AGENT 1: Linear Reader (uses configured provider)
53
+ // ═══════════════════════════════════════════════════════════════════════════
54
+ console.log(`\n${BOLD}▸ Agent 1: Linear Reader${RESET}`);
55
+ const agent1Provider = createProvider(config.provider);
56
+ const agent1BasePrompt = await loadPrompt('agent1-linear-reader');
57
+ const agent1Prompt = `## Agent Instance
58
+
59
+ You are part of pod: **${podName}** / Loop ${iteration} / Agent 1 (Linear Reader)
60
+
61
+ This identifier format is: Pod Name / Loop Number / Agent Number (Role).
62
+ - **Pod Name**: ${podName} - persists for this entire Horizon session
63
+ - **Loop Number**: ${iteration} - increments each time Horizon processes a new ticket
64
+ - **Agent**: Agent 1 (Linear Reader) - your role in this loop
65
+
66
+ ## Linear Team Configuration
67
+
68
+ **Team Key**: ${config.linearTeamId}
69
+
70
+ Use this team key for all Linear MCP tool calls (list_issues, list_issue_statuses, update_issue, create_comment, etc.). Do NOT use any other team key.
71
+
72
+ ---
73
+
74
+ ${agent1BasePrompt}`;
75
+ // Select model based on provider
76
+ const agent1Model = config.provider === 'codex' ? config.codexModel : 'opus';
77
+ // Rate limit retry config - uses configured max retries
78
+ const retryConfig = { maxRetries: config.rateLimitMaxRetries };
79
+ // Agent 1 with rate limit retry
80
+ const agent1Result = await executeWithRateLimitRetry(() => agent1Provider.spawn({
81
+ prompt: agent1Prompt,
82
+ model: agent1Model,
83
+ allowedTools: ['mcp__linear__*'],
84
+ reasoningEffort: config.provider === 'codex' ? config.codexAgentReasoning.agent1 : undefined,
85
+ }, 1), retryConfig, 'Agent 1 (Linear Reader)');
86
+ // If still rate limited after max retries, skip this iteration
87
+ if (agent1Result.rateLimited) {
88
+ console.log('Agent 1 still rate limited after max retries. Skipping iteration.');
89
+ return;
90
+ }
91
+ // Log Agent 1 stats
92
+ await logAgentStats(1, config.provider, agent1Model, {
93
+ tokenUsage: agent1Result.tokenUsage,
94
+ cost: agent1Result.cost,
95
+ costEstimated: agent1Result.costEstimated,
96
+ duration: agent1Result.duration,
97
+ exitCode: agent1Result.exitCode,
98
+ rateLimited: agent1Result.rateLimited,
99
+ output: agent1Result.output,
100
+ });
101
+ // Extract the text output from agent 1
102
+ const agent1Output = agent1Result.finalOutput;
103
+ // Check if there's no work (look for the signal in the output)
104
+ if (agent1Output.includes('no_work: true') || agent1Output.includes('NO_WORK')) {
105
+ // Extract reason from agent output (format: "NO_WORK\n\nReason: ...")
106
+ const reasonMatch = agent1Output.match(/Reason:\s*([\s\S]*)/i);
107
+ const reason = reasonMatch?.[1]?.trim() || 'No actionable issues found.';
108
+ // Wrap reason text to fit box width, then display
109
+ const boxWidth = 64;
110
+ const innerWidth = boxWidth - 4; // borders + padding
111
+ const words = reason.split(/\s+/);
112
+ const wrappedLines = [];
113
+ let currentLine = '';
114
+ for (const word of words) {
115
+ if (currentLine && (currentLine.length + 1 + word.length) > innerWidth) {
116
+ wrappedLines.push(` ${currentLine}`);
117
+ currentLine = word;
118
+ }
119
+ else {
120
+ currentLine = currentLine ? `${currentLine} ${word}` : word;
121
+ }
122
+ }
123
+ if (currentLine)
124
+ wrappedLines.push(` ${currentLine}`);
125
+ console.log('\n' + box([` ${YELLOW}Create tickets in Linear to give Horizon work.${RESET}`, '', ...wrappedLines], { title: 'No Work', color: YELLOW, width: boxWidth }));
126
+ // If GCP auto-stop is enabled, check if we're on GCP and stop the instance
127
+ if (config.gcpAutoStop) {
128
+ console.log('GCP auto-stop is enabled. Checking if running on GCP...');
129
+ const onGcp = await isRunningOnGcp();
130
+ if (onGcp) {
131
+ console.log('Running on GCP VM. Initiating instance stop...');
132
+ const stopped = await stopGcpInstance();
133
+ if (stopped) {
134
+ console.log('Instance stop command issued. VM will shut down shortly.');
135
+ // Give the stop command time to take effect
136
+ await sleep(10000);
137
+ process.exit(0);
138
+ }
139
+ else {
140
+ console.log('Failed to stop GCP instance. Falling back to quick check.');
141
+ }
142
+ }
143
+ else {
144
+ console.log('Not running on GCP VM. Falling back to pulse check.');
145
+ }
146
+ }
147
+ // Two-tier polling: pulse check loop with fallback
148
+ const fullCheckIntervalMs = config.fullCheckIntervalMinutes * 60 * 1000;
149
+ const pulseCheckIntervalMs = config.quickCheckIntervalMinutes * 60 * 1000;
150
+ let lastFullCheck = Date.now(); // Agent 1 just ran
151
+ while (true) {
152
+ console.log(`\n[Pulse Check] Sleeping ${config.quickCheckIntervalMinutes} minutes...`);
153
+ await sleep(pulseCheckIntervalMs);
154
+ // Check if fallback is due
155
+ const timeSinceFullCheck = Date.now() - lastFullCheck;
156
+ const fallbackDue = timeSinceFullCheck >= fullCheckIntervalMs;
157
+ if (fallbackDue) {
158
+ console.log('[Pulse Check] Full check interval reached. Running full Agent 1 check.');
159
+ return; // Exit to let main loop run Agent 1
160
+ }
161
+ // Perform pulse check
162
+ if (!config.linearApiKey || !config.linearTeamId) {
163
+ console.log('[Pulse Check] Missing Linear credentials. Falling back to Agent 1.');
164
+ return;
165
+ }
166
+ console.log('[Pulse Check] Checking for ready-to-work tickets...');
167
+ const result = await checkForUncompletedTickets(config.linearApiKey, config.linearTeamId);
168
+ if (result.error) {
169
+ console.log(`[Pulse Check] Error: ${result.error}. Falling back to Agent 1.`);
170
+ return; // Error - let Agent 1 handle it
171
+ }
172
+ // Display status category stats
173
+ const { statusCounts } = result;
174
+ const statsLine = `[Pulse Check] Status: ${statusCounts.completed} done, ${statusCounts.started} in progress, ${statusCounts.unstarted} todo, ${statusCounts.backlog} backlog, ${statusCounts.canceled} canceled`;
175
+ console.log(statsLine);
176
+ if (result.hasWork) {
177
+ console.log(`[Pulse Check] Found ${result.ticketCount} ready-to-work ticket(s). Initiating next loop.`);
178
+ return; // Work found - run Agent 1
179
+ }
180
+ // No work found - continue pulse check loop
181
+ const minutesUntilFallback = Math.round((fullCheckIntervalMs - timeSinceFullCheck) / 60000);
182
+ console.log(`[Pulse Check] No ready-to-work tickets. Checking again in ${config.quickCheckIntervalMinutes} minutes (full check in ${minutesUntilFallback} minutes).`);
183
+ }
184
+ }
185
+ // ═══════════════════════════════════════════════════════════════════════════
186
+ // ATTACHMENT DOWNLOAD: Download any attached images/files from the Linear issue
187
+ // ═══════════════════════════════════════════════════════════════════════════
188
+ let attachmentPaths = [];
189
+ const issueIdentifier = extractIssueIdentifier(agent1Output);
190
+ if (issueIdentifier && config.linearApiKey) {
191
+ console.log('\nDownloading attachments from Linear...');
192
+ try {
193
+ // Fetch fresh description from Linear API with signed URLs
194
+ // The public-file-urls-expire-in header makes Linear return pre-signed URLs (1 hour expiry)
195
+ const linearClient = createLinearClientWithSignedUrls(config.linearApiKey, 3600);
196
+ const freshDescription = await getIssueDescription(linearClient, issueIdentifier);
197
+ if (freshDescription) {
198
+ // Extract URLs from the fresh description (has valid signatures)
199
+ const attachments = extractLinearUrls(freshDescription);
200
+ if (attachments.length > 0) {
201
+ const result = await downloadIssueAttachments(config.linearApiKey, issueIdentifier, attachments);
202
+ attachmentPaths = result.attachments.map(a => a.localPath);
203
+ }
204
+ }
205
+ if (attachmentPaths.length > 0) {
206
+ console.log(`Downloaded ${attachmentPaths.length} attachment(s)`);
207
+ }
208
+ else {
209
+ console.log('No attachments found in issue');
210
+ }
211
+ }
212
+ catch (error) {
213
+ console.log('Failed to download attachments (continuing without them):', error);
214
+ }
215
+ }
216
+ // ═══════════════════════════════════════════════════════════════════════════
217
+ // AGENT 2: Worker (uses configured provider - Claude or Codex)
218
+ // ═══════════════════════════════════════════════════════════════════════════
219
+ console.log(`\n${BOLD}▸ Agent 2: Worker${RESET}`);
220
+ // Use configured provider for Agent 2
221
+ const agent2Provider = createProvider(config.provider);
222
+ // Build attachment context section if we have downloaded files
223
+ const attachmentSection = attachmentPaths.length > 0
224
+ ? `
225
+
226
+ ## Downloaded Attachments
227
+
228
+ The following files have been downloaded locally from the Linear issue. You can read/view these files using the Read tool:
229
+
230
+ ${attachmentPaths.map(p => `- ${p}`).join('\n')}
231
+
232
+ `
233
+ : '';
234
+ // Build worker prompt by combining the base prompt with agent 1's output
235
+ const workerBasePrompt = await loadPrompt('agent2-worker');
236
+ const workerPrompt = `## Agent Instance
237
+
238
+ You are part of pod: **${podName}** / Loop ${iteration} / Agent 2 (Worker)
239
+
240
+ This identifier format is: Pod Name / Loop Number / Agent Number (Role).
241
+ - **Pod Name**: ${podName} - persists for this entire Horizon session
242
+ - **Loop Number**: ${iteration} - increments each time Horizon processes a new ticket
243
+ - **Agent**: Agent 2 (Worker) - your role in this loop
244
+
245
+ ## Linear Team Configuration
246
+
247
+ **Team Key**: ${config.linearTeamId}
248
+
249
+ This team key is used for all Linear MCP tool calls. While you don't make Linear calls directly, this context may be passed to other agents.
250
+
251
+ ---
252
+
253
+ ## Context from Linear (gathered by Agent 1)
254
+
255
+ ${agent1Output}
256
+ ${attachmentSection}
257
+ ---
258
+
259
+ ${workerBasePrompt}`;
260
+ // Agent 2 with rate limit retry
261
+ let agent2Result;
262
+ if (config.provider === 'codex') {
263
+ agent2Result = await executeWithRateLimitRetry(() => agent2Provider.spawn({
264
+ prompt: workerPrompt,
265
+ model: config.codexModel,
266
+ reasoningEffort: config.codexReasoningEffort,
267
+ }, 2), retryConfig, 'Agent 2 (Worker)');
268
+ }
269
+ else {
270
+ agent2Result = await executeWithRateLimitRetry(() => agent2Provider.spawn({
271
+ prompt: workerPrompt,
272
+ model: config.claudeModel,
273
+ }, 2), retryConfig, 'Agent 2 (Worker)');
274
+ }
275
+ // Note: Agent 2 rate limits are logged but we continue to Agent 3 regardless
276
+ if (agent2Result.rateLimited) {
277
+ console.log('Agent 2 still rate limited after max retries. Continuing to Agent 3 to log status.');
278
+ }
279
+ // Log Agent 2 stats
280
+ const agent2Model = config.provider === 'codex' ? config.codexModel : config.claudeModel;
281
+ await logAgentStats(2, config.provider, agent2Model, {
282
+ tokenUsage: agent2Result.tokenUsage,
283
+ cost: agent2Result.cost,
284
+ costEstimated: agent2Result.costEstimated,
285
+ duration: agent2Result.duration,
286
+ exitCode: agent2Result.exitCode,
287
+ rateLimited: agent2Result.rateLimited,
288
+ output: agent2Result.output,
289
+ });
290
+ const agent2Output = agent2Result.finalOutput;
291
+ // ═══════════════════════════════════════════════════════════════════════════
292
+ // AGENT 3: Linear Writer (uses configured provider)
293
+ // ═══════════════════════════════════════════════════════════════════════════
294
+ console.log(`\n${BOLD}▸ Agent 3: Linear Writer${RESET}`);
295
+ const agent3Provider = createProvider(config.provider);
296
+ const writerBasePrompt = await loadPrompt('agent3-linear-writer');
297
+ // Format cost strings with estimated marker for Codex
298
+ const agent1CostStr = agent1Result.costEstimated
299
+ ? `~$${agent1Result.cost.toFixed(4)} (estimated)`
300
+ : `$${agent1Result.cost.toFixed(4)}`;
301
+ const agent2CostStr = agent2Result.costEstimated
302
+ ? `~$${agent2Result.cost.toFixed(4)} (estimated)`
303
+ : `$${agent2Result.cost.toFixed(4)}`;
304
+ const writerPrompt = `## Agent Instance
305
+
306
+ You are part of pod: **${podName}** / Loop ${iteration} / Agent 3 (Linear Writer)
307
+
308
+ This identifier format is: Pod Name / Loop Number / Agent Number (Role).
309
+ - **Pod Name**: ${podName} - persists for this entire Horizon session
310
+ - **Loop Number**: ${iteration} - increments each time Horizon processes a new ticket
311
+ - **Agent**: Agent 3 (Linear Writer) - your role in this loop
312
+
313
+ **IMPORTANT**: Include the pod name (${podName}) in all comments you post to Linear so that when multiple pods work in parallel, we can identify which one made which comment.
314
+
315
+ ## Linear Team Configuration
316
+
317
+ **Team Key**: ${config.linearTeamId}
318
+
319
+ Use this team key for all Linear MCP tool calls (update_issue, create_comment, create_issue for sub-issues, etc.). Do NOT use any other team key.
320
+
321
+ ---
322
+
323
+ ## Context from Agent 1 (Linear issue details)
324
+
325
+ ${agent1Output}
326
+
327
+ ---
328
+
329
+ ## Results from Agent 2 (Work performed)
330
+
331
+ ${agent2Output}
332
+
333
+ ---
334
+
335
+ ## Session Stats
336
+
337
+ - Pod: ${podName}
338
+ - Loop: ${iteration}
339
+
340
+ ### Agent 1 (Linear Reader)
341
+ - Provider: ${config.provider}
342
+ - Model: ${agent1Model}
343
+ - Cost: ${agent1CostStr}
344
+ - Duration: ${Math.round(agent1Result.duration / 1000)}s
345
+ - Tokens: in=${agent1Result.tokenUsage.input.toLocaleString()} out=${agent1Result.tokenUsage.output.toLocaleString()} cached=${agent1Result.tokenUsage.cached.toLocaleString()}
346
+
347
+ ### Agent 2 (Worker)
348
+ - Provider: ${config.provider}
349
+ - Model: ${agent2Model}
350
+ - Cost: ${agent2CostStr}
351
+ - Duration: ${Math.round(agent2Result.duration / 1000)}s
352
+ - Tokens: in=${agent2Result.tokenUsage.input.toLocaleString()} out=${agent2Result.tokenUsage.output.toLocaleString()} cached=${agent2Result.tokenUsage.cached.toLocaleString()}
353
+ - Exit code: ${agent2Result.exitCode}
354
+ - Rate limited: ${agent2Result.rateLimited}
355
+
356
+ ### Loop Totals (Agent 1 + Agent 2)
357
+ - Total Cost: $${(agent1Result.cost + agent2Result.cost).toFixed(4)}${(agent1Result.costEstimated || agent2Result.costEstimated) ? ' (includes estimate)' : ''}
358
+ - Total Duration: ${Math.round((agent1Result.duration + agent2Result.duration) / 1000)}s
359
+ - Total Tokens: in=${(agent1Result.tokenUsage.input + agent2Result.tokenUsage.input).toLocaleString()} out=${(agent1Result.tokenUsage.output + agent2Result.tokenUsage.output).toLocaleString()} cached=${(agent1Result.tokenUsage.cached + agent2Result.tokenUsage.cached).toLocaleString()}
360
+
361
+ ---
362
+
363
+ ${writerBasePrompt}`;
364
+ // Agent 3 with rate limit retry
365
+ const agent3Model = config.provider === 'codex' ? config.codexModel : 'sonnet';
366
+ const agent3Result = await executeWithRateLimitRetry(() => agent3Provider.spawn({
367
+ prompt: writerPrompt,
368
+ model: agent3Model,
369
+ allowedTools: ['mcp__linear__*'],
370
+ reasoningEffort: config.provider === 'codex' ? config.codexAgentReasoning.agent3 : undefined,
371
+ }, 3), retryConfig, 'Agent 3 (Linear Writer)');
372
+ if (agent3Result.rateLimited) {
373
+ console.log('Agent 3 still rate limited after max retries.');
374
+ }
375
+ // Log Agent 3 stats
376
+ await logAgentStats(3, config.provider, agent3Model, {
377
+ tokenUsage: agent3Result.tokenUsage,
378
+ cost: agent3Result.cost,
379
+ costEstimated: agent3Result.costEstimated,
380
+ duration: agent3Result.duration,
381
+ exitCode: agent3Result.exitCode,
382
+ rateLimited: agent3Result.rateLimited,
383
+ output: agent3Result.output,
384
+ });
385
+ // Finalize loop stats
386
+ await finalizeLoopStats();
387
+ // Loop stats
388
+ const duration = Math.round((Date.now() - loopStart) / 1000);
389
+ const minutes = Math.floor(duration / 60);
390
+ const seconds = duration % 60;
391
+ console.log(`\nLoop ${iteration} complete in ${minutes}m ${seconds}s`);
392
+ }
393
+ /**
394
+ * Displays the Horizon startup banner with ASCII art.
395
+ * Futuristic/sci-fi themed, ~10 lines.
396
+ */
397
+ function displayBanner() {
398
+ const version = getVersion();
399
+ const banner = `
400
+ ██╗ ██╗ ██████╗ ██████╗ ██╗███████╗ ██████╗ ███╗ ██╗
401
+ ██║ ██║██╔═══██╗██╔══██╗██║╚══███╔╝██╔═══██╗████╗ ██║
402
+ ███████║██║ ██║██████╔╝██║ ███╔╝ ██║ ██║██╔██╗ ██║
403
+ ██╔══██║██║ ██║██╔══██╗██║ ███╔╝ ██║ ██║██║╚██╗██║
404
+ ██║ ██║╚██████╔╝██║ ██║██║███████╗╚██████╔╝██║ ╚████║
405
+ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚═╝╚══════╝ ╚═════╝ ╚═╝ ╚═══╝
406
+
407
+ ⚡ Complete Linear Tickets with Long-Running Agents ⚡ v${version}
408
+ ════════════════════════════════════════════════════════════════`;
409
+ console.log(banner);
410
+ }
411
+ /**
412
+ * Displays a safety warning about AI coding agents.
413
+ */
414
+ function displaySafetyWarning() {
415
+ console.log('\n' + box([
416
+ ' Horizon uses AI coding agents (Claude Code, Codex) that are ',
417
+ ' granted permissions to read, write, and execute code. ',
418
+ ' ',
419
+ ' * Coding agents may make mistakes ',
420
+ ' * They may take actions that could be harmful to your system ',
421
+ ' * Always review changes before merging to production ',
422
+ ' ',
423
+ ' We recommend running Horizon in a sandboxed environment or ',
424
+ ' virtual machine for added safety. ',
425
+ ], { title: 'Safety Warning', width: 65 }) + '\n');
426
+ }
427
+ /**
428
+ * Runs minimal first-run setup if credentials are missing.
429
+ * Prompts only for essentials (API key, team), uses defaults for everything else.
430
+ * Returns true if setup completed successfully, false if user cancelled.
431
+ */
432
+ async function runMinimalSetup(cliAvailability) {
433
+ console.log('\n⚠️ Linear credentials not configured.\n');
434
+ const rl = createPromptInterface();
435
+ try {
436
+ // Load any existing config
437
+ const existingConfig = loadExistingConfig();
438
+ // ─── API Key ───
439
+ console.log('To get your API key:');
440
+ console.log(' 1. Go to: https://linear.app/settings/account/security/api-keys/new');
441
+ console.log(' 2. Enter a label (e.g., "Horizon")');
442
+ console.log(' 3. Click "Create key"');
443
+ console.log('');
444
+ let apiKey = await promptSecret(rl, 'Enter your Linear API key');
445
+ if (!apiKey) {
446
+ console.log('\nLinear API key is required.');
447
+ return false;
448
+ }
449
+ // Validate API key
450
+ console.log('Validating...');
451
+ const isValid = await validateLinearKey(apiKey);
452
+ if (!isValid) {
453
+ console.log('Invalid API key. Please check and try again.');
454
+ return false;
455
+ }
456
+ console.log('✓ Valid\n');
457
+ // ─── Team Selection ───
458
+ console.log('Fetching teams...');
459
+ const teams = await fetchLinearTeams(apiKey);
460
+ if (teams.length === 0) {
461
+ console.log('No teams found in your Linear workspace.');
462
+ return false;
463
+ }
464
+ let teamKey;
465
+ if (teams.length === 1) {
466
+ teamKey = teams[0].key;
467
+ console.log(`Found team: ${teams[0].name} (${teams[0].key})`);
468
+ console.log('Auto-selecting as it\'s the only team.\n');
469
+ }
470
+ else {
471
+ console.log('Select team:');
472
+ const teamOptions = teams.map((team) => ({
473
+ value: team.key,
474
+ label: `${team.name} (${team.key})`,
475
+ }));
476
+ teamKey = await promptSelect(rl, teamOptions, 0);
477
+ const selectedTeam = teams.find((t) => t.key === teamKey);
478
+ console.log(`Selected: ${selectedTeam.name}\n`);
479
+ }
480
+ // Auto-select provider based on CLI availability
481
+ const provider = autoSelectProvider(cliAvailability);
482
+ console.log(`Using provider: ${provider}\n`);
483
+ // ─── Merge Mode ───
484
+ console.log('When work completes:');
485
+ const mergeModeOptions = [
486
+ { value: 'auto', label: 'auto', description: 'Agent decides: merge directly or create PR (recommended)' },
487
+ { value: 'merge', label: 'merge', description: 'Merge directly to main' },
488
+ { value: 'pr', label: 'pr', description: 'Create PR for review' },
489
+ ];
490
+ const mergeMode = await promptSelect(rl, mergeModeOptions, 0);
491
+ // Save configuration
492
+ const newConfig = {
493
+ linearApiKey: apiKey,
494
+ linearTeamKey: teamKey,
495
+ provider,
496
+ claudeModel: existingConfig.claudeModel || 'opus',
497
+ codexModel: existingConfig.codexModel || 'gpt-5.2',
498
+ codexReasoningEffort: existingConfig.codexReasoningEffort || 'high',
499
+ maxIterations: existingConfig.maxIterations ?? 0,
500
+ mergeMode,
501
+ };
502
+ saveEnvConfig(newConfig);
503
+ saveMcpConfig(apiKey);
504
+ ensureGitignore();
505
+ // Update process.env so config picks up new values
506
+ process.env.LINEAR_API_KEY = apiKey;
507
+ process.env.LINEAR_TEAM_KEY = teamKey;
508
+ process.env.HORIZON_PROVIDER = provider;
509
+ process.env.HORIZON_MERGE_MODE = mergeMode;
510
+ console.log('\n✓ Configuration saved!\n');
511
+ return true;
512
+ }
513
+ finally {
514
+ rl.close();
515
+ }
516
+ }
517
+ export async function main() {
518
+ displayBanner();
519
+ displaySafetyWarning();
520
+ // Check for updates (non-blocking, cached for 24 hours)
521
+ const updateResult = await checkForUpdates();
522
+ displayUpdateNotification(updateResult);
523
+ // Check for git repository first - Horizon requires git
524
+ if (!isGitRepository()) {
525
+ console.log('\n❌ Error: Not a git repository');
526
+ console.log('');
527
+ console.log(' Horizon requires a git repository to operate. It uses git for:');
528
+ console.log(' - Creating feature branches');
529
+ console.log(' - Committing changes');
530
+ console.log(' - Creating pull requests');
531
+ console.log('');
532
+ console.log(' To initialize a git repository, run:');
533
+ console.log(' git init');
534
+ console.log('');
535
+ process.exit(1);
536
+ }
537
+ // Ensure directories exist (silent)
538
+ ensureHorizonDir();
539
+ ensureHorizonDocsDir();
540
+ ensureGitignore();
541
+ // Check for coding CLI availability - at least one must be installed
542
+ const cliAvailability = checkAndDisplayCliAvailability();
543
+ if (!cliAvailability) {
544
+ // Error message already displayed by checkAndDisplayCliAvailability
545
+ process.exit(1);
546
+ }
547
+ // Sync prompts from package to .horizon/prompts/ (ensures they're always up-to-date)
548
+ const promptSync = copyPromptsToProject();
549
+ let config = getConfig();
550
+ // Check if Linear credentials are configured
551
+ if (!config.linearApiKey || !config.linearTeamId) {
552
+ const setupSuccess = await runMinimalSetup(cliAvailability);
553
+ if (!setupSuccess) {
554
+ console.log('\nCannot start Horizon without Linear configuration.');
555
+ console.log('Run `horizon config` for full configuration options.\n');
556
+ process.exit(1);
557
+ }
558
+ // Reload config after setup (rebuild from updated process.env)
559
+ config = getConfig(true);
560
+ }
561
+ // Check if ∞ statuses exist in Linear, create if needed
562
+ const statusesExist = await checkLinearStatuses(config.linearApiKey, config.linearTeamId);
563
+ if (!statusesExist) {
564
+ const result = await createLinearStatuses(config.linearApiKey, config.linearTeamId);
565
+ if (!result.success) {
566
+ console.log('\n❌ Failed to create ∞ statuses. Please check Linear permissions.');
567
+ if (result.errors.length > 0) {
568
+ result.errors.forEach((err) => console.log(` - ${err}`));
569
+ }
570
+ process.exit(1);
571
+ }
572
+ }
573
+ // Check Codex Linear MCP if using Codex provider
574
+ if (config.provider === 'codex') {
575
+ const hasLinearMcp = checkCodexLinearMcp();
576
+ if (!hasLinearMcp) {
577
+ console.log('\n⚠️ Linear MCP not configured for Codex.');
578
+ console.log(' Run: horizon config');
579
+ process.exit(1);
580
+ }
581
+ }
582
+ // Generate pod name once at startup - persists for entire Horizon session
583
+ const podName = generatePodName();
584
+ // Build provider display string
585
+ const providerDisplay = config.provider === 'codex'
586
+ ? `Codex (${config.codexModel})`
587
+ : `Claude (${config.claudeModel.charAt(0).toUpperCase() + config.claudeModel.slice(1)})`;
588
+ // Build CLI availability display
589
+ const cliParts = [];
590
+ if (cliAvailability.claude)
591
+ cliParts.push('Claude ✓');
592
+ if (cliAvailability.codex)
593
+ cliParts.push('Codex ✓');
594
+ // Build init box lines
595
+ const detectionLines = [
596
+ ` CLIs Available ${cliParts.join(' ')} `,
597
+ ` Agent Prompts ${promptSync.copied} files synced `,
598
+ ];
599
+ const configLines = [
600
+ ` Working Directory ${config.workingDirectory} `,
601
+ ` Agent Branch ${getCurrentBranch()} `,
602
+ ` Provider ${providerDisplay} `,
603
+ ` Merge Mode ${promptSync.mergeMode} `,
604
+ ` Linear Team ${config.linearTeamId} ∞ prefixed statuses `,
605
+ ` Current Pod ${podName} `,
606
+ ];
607
+ if (config.maxIterations > 0) {
608
+ configLines.push(` Max Iterations ${config.maxIterations} `);
609
+ }
610
+ if (config.gcpAutoStop) {
611
+ configLines.push(` GCP Auto-Stop enabled `);
612
+ }
613
+ console.log('\n' + box([...detectionLines, ...configLines], { title: 'Horizon', width: 60, dividerAfter: [detectionLines.length - 1] }));
614
+ let iteration = 0;
615
+ while (config.maxIterations === 0 || iteration < config.maxIterations) {
616
+ try {
617
+ await runLoop(podName, iteration);
618
+ iteration++;
619
+ }
620
+ catch (error) {
621
+ console.error(`\nLoop ${iteration} error:`, error);
622
+ console.log(`Sleeping ${config.errorSleepMinutes} minute(s) before retry...`);
623
+ await sleep(config.errorSleepMinutes * 60 * 1000);
624
+ }
625
+ }
626
+ if (config.maxIterations > 0) {
627
+ console.log(`\nReached max iterations: ${config.maxIterations}`);
628
+ }
629
+ }
630
+ // Run main when executed directly
631
+ // When used as CLI via cli.ts, main() is imported and called there
632
+ if (import.meta.url === `file://${process.argv[1]}`) {
633
+ main().catch(console.error);
634
+ }
635
+ //# sourceMappingURL=index.js.map