symphony-github 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 (166) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +341 -0
  3. package/config.example.yaml +101 -0
  4. package/dist/agents/launcher.d.ts +24 -0
  5. package/dist/agents/launcher.d.ts.map +1 -0
  6. package/dist/agents/launcher.js +152 -0
  7. package/dist/agents/launcher.js.map +1 -0
  8. package/dist/agents/registry.d.ts +10 -0
  9. package/dist/agents/registry.d.ts.map +1 -0
  10. package/dist/agents/registry.js +324 -0
  11. package/dist/agents/registry.js.map +1 -0
  12. package/dist/agents/runner.d.ts +58 -0
  13. package/dist/agents/runner.d.ts.map +1 -0
  14. package/dist/agents/runner.js +1190 -0
  15. package/dist/agents/runner.js.map +1 -0
  16. package/dist/app.d.ts +11 -0
  17. package/dist/app.d.ts.map +1 -0
  18. package/dist/app.js +829 -0
  19. package/dist/app.js.map +1 -0
  20. package/dist/components/ActivityView.d.ts +9 -0
  21. package/dist/components/ActivityView.d.ts.map +1 -0
  22. package/dist/components/ActivityView.js +73 -0
  23. package/dist/components/ActivityView.js.map +1 -0
  24. package/dist/components/Header.d.ts +12 -0
  25. package/dist/components/Header.d.ts.map +1 -0
  26. package/dist/components/Header.js +44 -0
  27. package/dist/components/Header.js.map +1 -0
  28. package/dist/components/IssueList.d.ts +10 -0
  29. package/dist/components/IssueList.d.ts.map +1 -0
  30. package/dist/components/IssueList.js +119 -0
  31. package/dist/components/IssueList.js.map +1 -0
  32. package/dist/components/Onboarding.d.ts +26 -0
  33. package/dist/components/Onboarding.d.ts.map +1 -0
  34. package/dist/components/Onboarding.js +948 -0
  35. package/dist/components/Onboarding.js.map +1 -0
  36. package/dist/components/PaneView.d.ts +9 -0
  37. package/dist/components/PaneView.d.ts.map +1 -0
  38. package/dist/components/PaneView.js +74 -0
  39. package/dist/components/PaneView.js.map +1 -0
  40. package/dist/components/StartupRecoveryView.d.ts +13 -0
  41. package/dist/components/StartupRecoveryView.d.ts.map +1 -0
  42. package/dist/components/StartupRecoveryView.js +85 -0
  43. package/dist/components/StartupRecoveryView.js.map +1 -0
  44. package/dist/components/StatusBar.d.ts +9 -0
  45. package/dist/components/StatusBar.d.ts.map +1 -0
  46. package/dist/components/StatusBar.js +70 -0
  47. package/dist/components/StatusBar.js.map +1 -0
  48. package/dist/components/TableView.d.ts +8 -0
  49. package/dist/components/TableView.d.ts.map +1 -0
  50. package/dist/components/TableView.js +87 -0
  51. package/dist/components/TableView.js.map +1 -0
  52. package/dist/config/index.d.ts +18 -0
  53. package/dist/config/index.d.ts.map +1 -0
  54. package/dist/config/index.js +357 -0
  55. package/dist/config/index.js.map +1 -0
  56. package/dist/git/merge.d.ts +23 -0
  57. package/dist/git/merge.d.ts.map +1 -0
  58. package/dist/git/merge.js +131 -0
  59. package/dist/git/merge.js.map +1 -0
  60. package/dist/git/utils.d.ts +34 -0
  61. package/dist/git/utils.d.ts.map +1 -0
  62. package/dist/git/utils.js +214 -0
  63. package/dist/git/utils.js.map +1 -0
  64. package/dist/git/worktree.d.ts +23 -0
  65. package/dist/git/worktree.d.ts.map +1 -0
  66. package/dist/git/worktree.js +116 -0
  67. package/dist/git/worktree.js.map +1 -0
  68. package/dist/index.d.ts +3 -0
  69. package/dist/index.d.ts.map +1 -0
  70. package/dist/index.js +225 -0
  71. package/dist/index.js.map +1 -0
  72. package/dist/paths.d.ts +21 -0
  73. package/dist/paths.d.ts.map +1 -0
  74. package/dist/paths.js +59 -0
  75. package/dist/paths.js.map +1 -0
  76. package/dist/runModes.d.ts +7 -0
  77. package/dist/runModes.d.ts.map +1 -0
  78. package/dist/runModes.js +36 -0
  79. package/dist/runModes.js.map +1 -0
  80. package/dist/services/daemon.d.ts +85 -0
  81. package/dist/services/daemon.d.ts.map +1 -0
  82. package/dist/services/daemon.js +836 -0
  83. package/dist/services/daemon.js.map +1 -0
  84. package/dist/services/github.d.ts +101 -0
  85. package/dist/services/github.d.ts.map +1 -0
  86. package/dist/services/github.js +367 -0
  87. package/dist/services/github.js.map +1 -0
  88. package/dist/services/githubProgressReporter.d.ts +33 -0
  89. package/dist/services/githubProgressReporter.d.ts.map +1 -0
  90. package/dist/services/githubProgressReporter.js +272 -0
  91. package/dist/services/githubProgressReporter.js.map +1 -0
  92. package/dist/services/runtime.d.ts +43 -0
  93. package/dist/services/runtime.d.ts.map +1 -0
  94. package/dist/services/runtime.js +126 -0
  95. package/dist/services/runtime.js.map +1 -0
  96. package/dist/services/state.d.ts +43 -0
  97. package/dist/services/state.d.ts.map +1 -0
  98. package/dist/services/state.js +176 -0
  99. package/dist/services/state.js.map +1 -0
  100. package/dist/services/tmux.d.ts +50 -0
  101. package/dist/services/tmux.d.ts.map +1 -0
  102. package/dist/services/tmux.js +157 -0
  103. package/dist/services/tmux.js.map +1 -0
  104. package/dist/swarm/backlog.d.ts +25 -0
  105. package/dist/swarm/backlog.d.ts.map +1 -0
  106. package/dist/swarm/backlog.js +83 -0
  107. package/dist/swarm/backlog.js.map +1 -0
  108. package/dist/swarm/config.d.ts +14 -0
  109. package/dist/swarm/config.d.ts.map +1 -0
  110. package/dist/swarm/config.js +112 -0
  111. package/dist/swarm/config.js.map +1 -0
  112. package/dist/swarm/dependencies.d.ts +36 -0
  113. package/dist/swarm/dependencies.d.ts.map +1 -0
  114. package/dist/swarm/dependencies.js +141 -0
  115. package/dist/swarm/dependencies.js.map +1 -0
  116. package/dist/swarm/director.d.ts +67 -0
  117. package/dist/swarm/director.d.ts.map +1 -0
  118. package/dist/swarm/director.js +358 -0
  119. package/dist/swarm/director.js.map +1 -0
  120. package/dist/swarm/directorPrompt.d.ts +15 -0
  121. package/dist/swarm/directorPrompt.d.ts.map +1 -0
  122. package/dist/swarm/directorPrompt.js +60 -0
  123. package/dist/swarm/directorPrompt.js.map +1 -0
  124. package/dist/swarm/index.d.ts +7 -0
  125. package/dist/swarm/index.d.ts.map +1 -0
  126. package/dist/swarm/index.js +6 -0
  127. package/dist/swarm/index.js.map +1 -0
  128. package/dist/swarm/proposals.d.ts +29 -0
  129. package/dist/swarm/proposals.d.ts.map +1 -0
  130. package/dist/swarm/proposals.js +141 -0
  131. package/dist/swarm/proposals.js.map +1 -0
  132. package/dist/swarm/types.d.ts +65 -0
  133. package/dist/swarm/types.d.ts.map +1 -0
  134. package/dist/swarm/types.js +3 -0
  135. package/dist/swarm/types.js.map +1 -0
  136. package/dist/theme.d.ts +64 -0
  137. package/dist/theme.d.ts.map +1 -0
  138. package/dist/theme.js +161 -0
  139. package/dist/theme.js.map +1 -0
  140. package/dist/triggers/index.d.ts +17 -0
  141. package/dist/triggers/index.d.ts.map +1 -0
  142. package/dist/triggers/index.js +124 -0
  143. package/dist/triggers/index.js.map +1 -0
  144. package/dist/types.d.ts +327 -0
  145. package/dist/types.d.ts.map +1 -0
  146. package/dist/types.js +6 -0
  147. package/dist/types.js.map +1 -0
  148. package/dist/utils/duplicateDetection.d.ts +14 -0
  149. package/dist/utils/duplicateDetection.d.ts.map +1 -0
  150. package/dist/utils/duplicateDetection.js +45 -0
  151. package/dist/utils/duplicateDetection.js.map +1 -0
  152. package/dist/utils/shell.d.ts +46 -0
  153. package/dist/utils/shell.d.ts.map +1 -0
  154. package/dist/utils/shell.js +79 -0
  155. package/dist/utils/shell.js.map +1 -0
  156. package/dist/utils/slug.d.ts +13 -0
  157. package/dist/utils/slug.d.ts.map +1 -0
  158. package/dist/utils/slug.js +32 -0
  159. package/dist/utils/slug.js.map +1 -0
  160. package/dist/version.d.ts +28 -0
  161. package/dist/version.d.ts.map +1 -0
  162. package/dist/version.js +105 -0
  163. package/dist/version.js.map +1 -0
  164. package/examples/run-claude.example.sh +11 -0
  165. package/examples/run-codex.example.sh +11 -0
  166. package/package.json +68 -0
@@ -0,0 +1,836 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import { GitHubClient } from './github.js';
3
+ import { StateStore } from './state.js';
4
+ import { RuntimeStore } from './runtime.js';
5
+ import { TmuxService } from './tmux.js';
6
+ import { GitHubProgressReporter } from './githubProgressReporter.js';
7
+ import { AgentRunner } from '../agents/runner.js';
8
+ import { resolveAgent } from '../agents/launcher.js';
9
+ import { evaluateTrigger } from '../triggers/index.js';
10
+ import { getTriggerForRepo, getModeForRepo } from '../config/index.js';
11
+ import { allowsAutomaticMerge } from '../runModes.js';
12
+ import { DirectorService } from '../swarm/director.js';
13
+ import { describeDuplicateMatch, findDuplicateMatch } from '../utils/duplicateDetection.js';
14
+ /**
15
+ * GitHub issue polling daemon.
16
+ * Ported from symphony Python's daemon.py.
17
+ */
18
+ export class Daemon extends EventEmitter {
19
+ settings;
20
+ github;
21
+ state;
22
+ runtime;
23
+ tmux;
24
+ runner;
25
+ reporter;
26
+ director = null;
27
+ sessionName;
28
+ running = false;
29
+ activeRuns = new Map();
30
+ pollTimer = null;
31
+ suppressIntermediateUpdatesUntil = new Map();
32
+ startupCandidates = new Map();
33
+ startupReposScanned = new Set();
34
+ launchQueue = new Map();
35
+ constructor(settings, sessionName) {
36
+ super();
37
+ this.settings = settings;
38
+ this.sessionName = sessionName;
39
+ this.github = new GitHubClient();
40
+ this.state = new StateStore(settings.state_file);
41
+ this.runtime = new RuntimeStore(settings.runtime_root);
42
+ this.tmux = TmuxService.getInstance();
43
+ this.reporter = new GitHubProgressReporter(settings, this.github, this.runtime, this.tmux);
44
+ this.runner = new AgentRunner(settings, this.runtime, pane => this.handleRunnerProgress(pane));
45
+ if (settings.swarm?.enabled) {
46
+ this.director = new DirectorService(settings, this.runtime);
47
+ }
48
+ }
49
+ getActiveRuns() {
50
+ return [
51
+ ...Array.from(this.launchQueue.values()).map(candidate => this.candidateToPendingPane(candidate)),
52
+ ...Array.from(this.activeRuns.values()),
53
+ ];
54
+ }
55
+ getRecentRuns(limit = 20) {
56
+ return this.runtime.listRuns(limit);
57
+ }
58
+ isRunning() {
59
+ return this.running;
60
+ }
61
+ async finalizeRun(runId) {
62
+ const pane = this.activeRuns.get(runId);
63
+ if (!pane)
64
+ return false;
65
+ await this.runner.requestAgentExit(pane);
66
+ this.setPaneStatus(pane, 'running', 'Finalizing agent session');
67
+ this.suppressIntermediateUpdatesUntil.set(runId, Date.now() + 5000);
68
+ return true;
69
+ }
70
+ async resumeRun(runId) {
71
+ const pane = this.activeRuns.get(runId);
72
+ if (!pane)
73
+ return false;
74
+ this.setPaneStatus(pane, 'running', 'Review resumed in pane');
75
+ this.suppressIntermediateUpdatesUntil.set(runId, Date.now() + 10000);
76
+ return true;
77
+ }
78
+ async pauseRun(runId) {
79
+ const pane = this.activeRuns.get(runId);
80
+ if (!pane?.tmux_pane_id)
81
+ return false;
82
+ await this.tmux.sendKeys(pane.tmux_pane_id, 'C-c');
83
+ this.setPaneStatus(pane, 'needs_attention', 'Agent interrupted by user');
84
+ this.suppressIntermediateUpdatesUntil.set(runId, Date.now() + 10000);
85
+ return true;
86
+ }
87
+ async stopAndLabelRun(runId) {
88
+ const pane = this.activeRuns.get(runId);
89
+ if (!pane?.repo || !pane.issue_number)
90
+ return false;
91
+ const trigger = getTriggerForRepo(this.settings, pane.repo);
92
+ if (trigger.claim_label) {
93
+ try {
94
+ await this.github.removeLabel(pane.repo, pane.issue_number, trigger.claim_label);
95
+ }
96
+ catch { /* ignore */ }
97
+ }
98
+ if (trigger.ignore_labels?.length > 0) {
99
+ try {
100
+ await this.github.addLabels(pane.repo, pane.issue_number, [trigger.ignore_labels[0]]);
101
+ }
102
+ catch { /* ignore */ }
103
+ }
104
+ this.state.markHandled(pane.repo, pane.issue_number, runId);
105
+ if (pane.tmux_pane_id) {
106
+ try {
107
+ await this.tmux.killPane(pane.tmux_pane_id);
108
+ }
109
+ catch { /* ignore */ }
110
+ }
111
+ this.state.removeActiveRun(pane.repo, runId);
112
+ this.activeRuns.delete(runId);
113
+ this.state.save();
114
+ this.emit('run_completed', { ...pane, status: 'failed', status_detail: 'Stopped and labeled by user' });
115
+ return true;
116
+ }
117
+ async prepareStartupRecovery() {
118
+ this.startupCandidates.clear();
119
+ this.startupReposScanned = new Set(this.settings.repos.map(repoConfig => repoConfig.repo));
120
+ const recovered = await this.restoreTrackedRuns();
121
+ const recoveredIssueKeys = new Set(recovered
122
+ .filter(pane => pane.repo && pane.issue_number)
123
+ .map(pane => `${pane.repo}#${pane.issue_number}`));
124
+ const pending = [];
125
+ for (const repoConfig of this.settings.repos) {
126
+ const repo = repoConfig.repo;
127
+ const trigger = getTriggerForRepo(this.settings, repo);
128
+ const mode = getModeForRepo(this.settings, repo);
129
+ if (!trigger.include_existing_open_issues) {
130
+ continue;
131
+ }
132
+ let issues;
133
+ try {
134
+ issues = await this.listStartupCandidateIssues(repo);
135
+ }
136
+ catch (err) {
137
+ this.emit('error', new Error(`Failed to inspect open issues for ${repo}: ${err}`));
138
+ continue;
139
+ }
140
+ for (const issue of issues) {
141
+ const issueKey = `${repo}#${issue.number}`;
142
+ if (recoveredIssueKeys.has(issueKey))
143
+ continue;
144
+ if (trigger.done_label && hasLabel(issue, trigger.done_label))
145
+ continue;
146
+ let comments;
147
+ try {
148
+ comments = await this.github.listIssueComments(repo, issue.number);
149
+ }
150
+ catch {
151
+ comments = [];
152
+ }
153
+ const decision = evaluateTrigger(issue, comments, trigger, {
154
+ isHandled: this.state.isHandled(repo, issue.number),
155
+ hasActiveRun: false,
156
+ hasOpenPr: false,
157
+ isClaimed: false,
158
+ processedCommentIds: new Set(this.state.getProcessedCommentIds(repo)),
159
+ });
160
+ const isClaimed = Boolean(trigger.claim_label && hasLabel(issue, trigger.claim_label));
161
+ if (!decision.should_run && !isClaimed) {
162
+ continue;
163
+ }
164
+ const agentName = this.resolveAgentName(repo, issue, comments);
165
+ const reason = isClaimed && !decision.should_run
166
+ ? `Claimed issue found without a live tmux pane (${trigger.claim_label})`
167
+ : decision.reason;
168
+ const candidate = {
169
+ id: issueKey,
170
+ repo,
171
+ issue,
172
+ comments,
173
+ mode,
174
+ reason,
175
+ matched_by: decision.matched_by,
176
+ is_claimed: isClaimed,
177
+ agent_name: agentName,
178
+ };
179
+ this.startupCandidates.set(candidate.id, candidate);
180
+ pending.push({
181
+ id: candidate.id,
182
+ repo,
183
+ issue_number: issue.number,
184
+ issue_title: issue.title,
185
+ issue_url: issue.html_url,
186
+ reason: candidate.reason,
187
+ matched_by: candidate.matched_by,
188
+ is_claimed: candidate.is_claimed,
189
+ agent_name: candidate.agent_name,
190
+ });
191
+ }
192
+ }
193
+ pending.sort((left, right) => {
194
+ if (left.repo !== right.repo)
195
+ return left.repo.localeCompare(right.repo);
196
+ return left.issue_number - right.issue_number;
197
+ });
198
+ return { recovered, pending };
199
+ }
200
+ async listStartupCandidateIssues(repo) {
201
+ return this.github.listOpenIssues(repo);
202
+ }
203
+ async launchStartupIssues(selectedIds) {
204
+ for (const id of selectedIds) {
205
+ const candidate = this.startupCandidates.get(id);
206
+ if (!candidate)
207
+ continue;
208
+ this.enqueueCandidate(candidate);
209
+ }
210
+ await this.drainLaunchQueue();
211
+ }
212
+ completeStartupRecovery() {
213
+ const now = new Date().toISOString();
214
+ for (const repo of this.startupReposScanned) {
215
+ this.state.setIssueCursor(repo, now);
216
+ this.state.setCommentCursor(repo, now);
217
+ }
218
+ this.startupCandidates.clear();
219
+ this.startupReposScanned.clear();
220
+ this.state.save();
221
+ }
222
+ /**
223
+ * Start the polling loop. Launches director if swarm mode is enabled.
224
+ */
225
+ async start() {
226
+ if (this.running)
227
+ return;
228
+ this.running = true;
229
+ this.emit('status', 'Daemon started');
230
+ // Launch director pane(s) if swarm mode is enabled
231
+ if (this.director) {
232
+ try {
233
+ const directorPanes = await this.director.start(this.sessionName);
234
+ for (const pane of directorPanes) {
235
+ this.emit('run_started', pane);
236
+ }
237
+ this.emit('status', `Swarm director launched (${directorPanes.length} pane(s))`);
238
+ }
239
+ catch (err) {
240
+ this.emit('error', new Error(`Failed to launch director: ${err}`));
241
+ }
242
+ }
243
+ this.poll();
244
+ }
245
+ /**
246
+ * Stop the polling loop.
247
+ */
248
+ stop() {
249
+ this.running = false;
250
+ if (this.pollTimer) {
251
+ clearTimeout(this.pollTimer);
252
+ this.pollTimer = null;
253
+ }
254
+ this.state.save();
255
+ this.emit('status', 'Daemon stopped');
256
+ }
257
+ /**
258
+ * Run a single poll cycle, then schedule the next one.
259
+ */
260
+ async poll() {
261
+ if (!this.running)
262
+ return;
263
+ try {
264
+ this.emit('poll_start');
265
+ // Check active runs for completion
266
+ await this.reconcileActiveRuns();
267
+ await this.drainLaunchQueue();
268
+ // Swarm mode: process proposals and dependencies
269
+ if (this.director) {
270
+ try {
271
+ await this.director.autoApproveProposals();
272
+ await this.director.processApprovedProposals();
273
+ await this.director.reconcileDependencies();
274
+ await this.director.updateDirectorContext(this.getActiveRuns());
275
+ if (this.director.shouldRunEvolution()) {
276
+ const started = await this.director.runEvolutionCycle();
277
+ if (started > 0) {
278
+ this.emit('status', `Swarm director started ${started} evolution cycle(s)`);
279
+ }
280
+ }
281
+ }
282
+ catch (err) {
283
+ this.emit('error', new Error(`Swarm director error: ${err}`));
284
+ }
285
+ }
286
+ // Discover and process new candidates
287
+ let totalDiscovered = 0;
288
+ let totalLaunched = 0;
289
+ for (const repoConfig of this.settings.repos) {
290
+ const repo = repoConfig.repo;
291
+ try {
292
+ const { discovered, launched } = await this.pollRepo(repo);
293
+ totalDiscovered += discovered;
294
+ totalLaunched += launched;
295
+ }
296
+ catch (err) {
297
+ this.emit('error', new Error(`Error polling ${repo}: ${err}`));
298
+ }
299
+ }
300
+ this.state.save();
301
+ this.emit('poll_end', { discovered: totalDiscovered, launched: totalLaunched });
302
+ }
303
+ catch (err) {
304
+ this.emit('error', err instanceof Error ? err : new Error(String(err)));
305
+ }
306
+ // Schedule next poll
307
+ if (this.running) {
308
+ this.pollTimer = setTimeout(() => this.poll(), this.settings.poll_interval_sec * 1000);
309
+ }
310
+ }
311
+ /**
312
+ * Poll a single repository for new issues.
313
+ */
314
+ async pollRepo(repo) {
315
+ const trigger = getTriggerForRepo(this.settings, repo);
316
+ const mode = getModeForRepo(this.settings, repo);
317
+ let discovered = 0;
318
+ let launched = 0;
319
+ let duplicateReferencesPromise = null;
320
+ // Get updated issues
321
+ const issueCursor = this.state.getIssueCursor(repo);
322
+ if (!issueCursor && !trigger.include_existing_open_issues) {
323
+ const now = new Date().toISOString();
324
+ this.state.setIssueCursor(repo, now);
325
+ this.state.setCommentCursor(repo, now);
326
+ return { discovered: 0, launched: 0 };
327
+ }
328
+ const issues = await this.github.listUpdatedIssues(repo, issueCursor);
329
+ // Update cursor to the latest issue's updated_at
330
+ if (issues.length > 0) {
331
+ const latestUpdate = issues.reduce((latest, issue) => issue.updated_at > latest ? issue.updated_at : latest, issues[0].updated_at);
332
+ this.state.setIssueCursor(repo, latestUpdate);
333
+ }
334
+ // Get updated comments
335
+ const commentCursor = this.state.getCommentCursor(repo);
336
+ const recentComments = await this.github.listUpdatedComments(repo, commentCursor);
337
+ if (recentComments.length > 0) {
338
+ const latestCommentUpdate = recentComments.reduce((latest, c) => c.updated_at > latest ? c.updated_at : latest, recentComments[0].updated_at);
339
+ this.state.setCommentCursor(repo, latestCommentUpdate);
340
+ }
341
+ // Also fetch issues mentioned in new comments
342
+ const commentIssueNumbers = new Set();
343
+ for (const comment of recentComments) {
344
+ if (comment.issue_url) {
345
+ const match = comment.issue_url.match(/\/issues\/(\d+)$/);
346
+ if (match) {
347
+ const issueNumber = parseInt(match[1], 10);
348
+ commentIssueNumbers.add(issueNumber);
349
+ if (!isSymphonyManagedComment(comment.body)) {
350
+ this.state.clearSuppressedIssue(repo, issueNumber);
351
+ }
352
+ }
353
+ }
354
+ }
355
+ const deferredIssueNumbers = new Set(this.state.getDeferredIssueNumbers(repo));
356
+ for (const deferredIssueNumber of deferredIssueNumbers) {
357
+ commentIssueNumbers.add(deferredIssueNumber);
358
+ }
359
+ // Merge issues from comments that aren't already in our list
360
+ const existingNumbers = new Set(issues.map(i => i.number));
361
+ for (const num of commentIssueNumbers) {
362
+ if (!existingNumbers.has(num)) {
363
+ try {
364
+ const issue = await this.github.getIssue(repo, num);
365
+ if (issue.state === 'open' && !issue.pull_request) {
366
+ issues.push(issue);
367
+ }
368
+ else {
369
+ this.state.clearDeferredIssue(repo, num);
370
+ }
371
+ }
372
+ catch {
373
+ this.state.clearDeferredIssue(repo, num);
374
+ // Ignore - issue might be closed or deleted
375
+ }
376
+ }
377
+ }
378
+ discovered = issues.length;
379
+ const suppressedIssueNumbers = new Set(this.state.getSuppressedIssueNumbers(repo));
380
+ // Evaluate each issue
381
+ for (const issue of issues) {
382
+ // Get issue comments for trigger evaluation
383
+ let issueComments;
384
+ try {
385
+ issueComments = await this.github.listIssueComments(repo, issue.number);
386
+ }
387
+ catch {
388
+ issueComments = [];
389
+ }
390
+ // Build context for trigger evaluation
391
+ const processedIds = new Set(this.state.getProcessedCommentIds(repo));
392
+ const context = {
393
+ isHandled: this.state.isHandled(repo, issue.number),
394
+ hasActiveRun: this.state.hasActiveRun(repo, issue.number),
395
+ hasOpenPr: false, // Will check below
396
+ isClaimed: issue.labels.some(l => l.name === trigger.claim_label),
397
+ processedCommentIds: processedIds,
398
+ };
399
+ // Check for open PR
400
+ if (trigger.skip_if_open_pr) {
401
+ // Simple heuristic: check if any issue label suggests a PR exists
402
+ // For a more thorough check, we'd search PRs, but that's expensive
403
+ context.hasOpenPr = false; // TODO: implement PR check if needed
404
+ }
405
+ const decision = evaluateTrigger(issue, issueComments, trigger, context);
406
+ if (suppressedIssueNumbers.has(issue.number)) {
407
+ continue;
408
+ }
409
+ if (!decision.should_run) {
410
+ this.state.clearDeferredIssue(repo, issue.number);
411
+ continue;
412
+ }
413
+ const duplicateReferences = duplicateReferencesPromise ??= this.buildDuplicateReferences(repo, trigger.claim_label);
414
+ const duplicate = findDuplicateMatch(issue.title, (await duplicateReferences).filter(ref => ref.issueNumber !== issue.number));
415
+ if (duplicate) {
416
+ this.state.deferIssue(repo, issue.number);
417
+ this.emit('status', `Skipping ${repo}#${issue.number}: ${describeDuplicateMatch(duplicate)}`);
418
+ continue;
419
+ }
420
+ this.state.clearDeferredIssue(repo, issue.number);
421
+ const candidate = {
422
+ id: `${repo}#${issue.number}`,
423
+ repo,
424
+ issue,
425
+ comments: issueComments,
426
+ mode,
427
+ reason: decision.reason,
428
+ matched_by: decision.matched_by,
429
+ is_claimed: context.isClaimed,
430
+ agent_name: this.resolveAgentName(repo, issue, issueComments),
431
+ };
432
+ if (this.activeRuns.size >= this.settings.max_concurrent_runs) {
433
+ this.enqueueCandidate(candidate);
434
+ this.emit('status', `Queued ${repo}#${issue.number}: ${decision.reason}`);
435
+ }
436
+ else {
437
+ this.emit('status', `Triggering run for ${repo}#${issue.number}: ${decision.reason}`);
438
+ try {
439
+ await this.startIssueRun(repo, issue, issueComments, mode, trigger.claim_label);
440
+ if (duplicateReferencesPromise) {
441
+ (await duplicateReferencesPromise).push({
442
+ kind: 'active_run',
443
+ title: issue.title,
444
+ issueNumber: issue.number,
445
+ });
446
+ }
447
+ this.state.clearDeferredIssue(repo, issue.number);
448
+ launched++;
449
+ }
450
+ catch (err) {
451
+ this.state.deferIssue(repo, issue.number);
452
+ this.emit('error', new Error(`Failed to start run for ${repo}#${issue.number}: ${err}`));
453
+ }
454
+ }
455
+ }
456
+ return { discovered, launched };
457
+ }
458
+ async restoreTrackedRuns() {
459
+ const recovered = [];
460
+ for (const { repo, record } of this.state.getAllActiveRuns()) {
461
+ const manifest = this.runtime.getManifest(record.run_id);
462
+ if (!manifest) {
463
+ this.state.removeActiveRun(repo, record.run_id);
464
+ continue;
465
+ }
466
+ const paneId = record.tmux_pane_id || manifest.tmux_pane_id || this.runtime.getRecordedPaneId(record.run_id);
467
+ const paneExists = Boolean(paneId && await this.tmux.paneExists(paneId));
468
+ if (paneExists && (record.tmux_pane_id !== paneId
469
+ || record.tmux_session !== manifest.tmux_session)) {
470
+ this.state.addActiveRun(repo, {
471
+ ...record,
472
+ tmux_session: manifest.tmux_session || record.tmux_session || this.sessionName,
473
+ tmux_pane_id: paneId,
474
+ });
475
+ }
476
+ const pane = this.manifestToPane(manifest, paneExists ? paneId : undefined);
477
+ try {
478
+ const check = await this.runner.checkRunStatus(pane);
479
+ if (check.result) {
480
+ await this.completeRun(record.run_id, pane, check.result, check.detail);
481
+ }
482
+ else {
483
+ this.activeRuns.set(record.run_id, pane);
484
+ pane.status = check.status;
485
+ pane.status_detail = check.detail;
486
+ }
487
+ }
488
+ catch (err) {
489
+ this.emit('error', new Error(`Failed to recover ${repo}#${record.issue_number}: ${err}`));
490
+ }
491
+ recovered.push({ ...pane });
492
+ }
493
+ return recovered;
494
+ }
495
+ manifestToPane(manifest, paneId) {
496
+ return {
497
+ id: manifest.run_id,
498
+ slug: manifest.branch,
499
+ type: 'issue',
500
+ repo: manifest.repo,
501
+ issue_number: manifest.issue_number,
502
+ issue_title: manifest.issue_title,
503
+ issue_url: manifest.issue_url,
504
+ agent_name: manifest.agent_name,
505
+ agent_provider: manifest.agent_provider,
506
+ run_id: manifest.run_id,
507
+ mode: manifest.mode,
508
+ status: manifest.status,
509
+ status_detail: manifest.status_detail,
510
+ tmux_pane_id: paneId,
511
+ worktree_path: manifest.worktree_path,
512
+ branch: manifest.branch,
513
+ base_sha: manifest.base_sha,
514
+ started_at: manifest.started_at,
515
+ };
516
+ }
517
+ resolveAgentName(repo, issue, comments) {
518
+ try {
519
+ return resolveAgent(issue, comments, repo, this.settings).name;
520
+ }
521
+ catch {
522
+ return undefined;
523
+ }
524
+ }
525
+ candidateToPendingPane(candidate) {
526
+ return {
527
+ id: `queued:${candidate.id}`,
528
+ slug: candidate.id,
529
+ type: 'issue',
530
+ repo: candidate.repo,
531
+ issue_number: candidate.issue.number,
532
+ issue_title: candidate.issue.title,
533
+ issue_url: candidate.issue.html_url,
534
+ agent_name: candidate.agent_name,
535
+ run_id: undefined,
536
+ mode: candidate.mode,
537
+ status: 'pending',
538
+ status_detail: 'Queued: waiting for an open run slot',
539
+ started_at: undefined,
540
+ };
541
+ }
542
+ enqueueCandidate(candidate) {
543
+ const issueKey = `${candidate.repo}#${candidate.issue.number}`;
544
+ if (this.launchQueue.has(issueKey) || this.state.hasActiveRun(candidate.repo, candidate.issue.number)) {
545
+ return;
546
+ }
547
+ if (Array.from(this.activeRuns.values()).some(pane => pane.repo === candidate.repo && pane.issue_number === candidate.issue.number)) {
548
+ return;
549
+ }
550
+ this.launchQueue.set(issueKey, candidate);
551
+ this.emit('run_started', this.candidateToPendingPane(candidate));
552
+ }
553
+ async drainLaunchQueue() {
554
+ while (this.launchQueue.size > 0 && this.activeRuns.size < this.settings.max_concurrent_runs) {
555
+ const nextEntry = this.launchQueue.entries().next().value;
556
+ if (!nextEntry)
557
+ break;
558
+ const [issueKey, candidate] = nextEntry;
559
+ this.launchQueue.delete(issueKey);
560
+ try {
561
+ const trigger = getTriggerForRepo(this.settings, candidate.repo);
562
+ await this.startIssueRun(candidate.repo, candidate.issue, candidate.comments, candidate.mode, trigger.claim_label);
563
+ }
564
+ catch (err) {
565
+ this.emit('error', new Error(`Failed to start recovery run for ${candidate.id}: ${err}`));
566
+ }
567
+ }
568
+ }
569
+ async startIssueRun(repo, issue, issueComments, mode, claimLabel) {
570
+ const alreadyClaimed = Boolean(claimLabel && hasLabel(issue, claimLabel));
571
+ let claimApplied = false;
572
+ if (claimLabel && !alreadyClaimed) {
573
+ try {
574
+ await this.github.addLabels(repo, issue.number, [claimLabel]);
575
+ claimApplied = true;
576
+ }
577
+ catch {
578
+ // Ignore label failures; the run can still proceed.
579
+ }
580
+ }
581
+ try {
582
+ const { pane, runId } = await this.runner.startRun(repo, issue, issueComments, this.sessionName, mode);
583
+ this.activeRuns.set(runId, pane);
584
+ this.state.addActiveRun(repo, {
585
+ run_id: runId,
586
+ repo,
587
+ issue_number: issue.number,
588
+ started_at: new Date().toISOString(),
589
+ tmux_session: this.sessionName,
590
+ tmux_pane_id: pane.tmux_pane_id,
591
+ });
592
+ for (const comment of issueComments) {
593
+ this.state.markCommentProcessed(repo, comment.id);
594
+ }
595
+ this.emit('run_started', pane);
596
+ await this.reporter.publishRunStarted({ ...pane });
597
+ }
598
+ catch (err) {
599
+ if (claimApplied && claimLabel) {
600
+ try {
601
+ await this.github.removeLabel(repo, issue.number, claimLabel);
602
+ }
603
+ catch {
604
+ // Ignore rollback failures.
605
+ }
606
+ }
607
+ throw err;
608
+ }
609
+ }
610
+ async buildDuplicateReferences(repo, claimLabel) {
611
+ const references = [];
612
+ for (const pane of this.activeRuns.values()) {
613
+ if (pane.repo !== repo || !pane.issue_title)
614
+ continue;
615
+ references.push({
616
+ kind: 'active_run',
617
+ title: pane.issue_title,
618
+ issueNumber: pane.issue_number,
619
+ });
620
+ }
621
+ for (const candidate of this.launchQueue.values()) {
622
+ if (candidate.repo !== repo)
623
+ continue;
624
+ references.push({
625
+ kind: 'active_run',
626
+ title: candidate.issue.title,
627
+ issueNumber: candidate.issue.number,
628
+ });
629
+ }
630
+ if (claimLabel) {
631
+ try {
632
+ const claimedIssues = await this.github.listOpenIssues(repo, [claimLabel]);
633
+ for (const issue of claimedIssues) {
634
+ references.push({
635
+ kind: 'claimed_issue',
636
+ title: issue.title,
637
+ issueNumber: issue.number,
638
+ });
639
+ }
640
+ }
641
+ catch {
642
+ // Ignore duplicate-check issue lookup failures
643
+ }
644
+ }
645
+ try {
646
+ const openPullRequests = await this.github.listOpenPullRequests(repo);
647
+ for (const pr of openPullRequests) {
648
+ references.push({
649
+ kind: 'open_pr',
650
+ title: pr.title,
651
+ prNumber: pr.number,
652
+ });
653
+ }
654
+ }
655
+ catch {
656
+ // Ignore duplicate-check PR lookup failures
657
+ }
658
+ return references;
659
+ }
660
+ /**
661
+ * Check active runs for completion.
662
+ */
663
+ async reconcileActiveRuns() {
664
+ const now = Date.now();
665
+ for (const [runId, pane] of this.activeRuns) {
666
+ // Give recently started runs time for the agent to initialize
667
+ if (pane.started_at) {
668
+ const elapsed = now - new Date(pane.started_at).getTime();
669
+ if (elapsed < 10000)
670
+ continue;
671
+ }
672
+ try {
673
+ const check = await this.runner.checkRunStatus(pane);
674
+ if (check.result) {
675
+ await this.completeRun(runId, pane, check.result, check.detail);
676
+ continue;
677
+ }
678
+ const suppressUntil = this.suppressIntermediateUpdatesUntil.get(runId) || 0;
679
+ const suppressIntermediateUpdate = suppressUntil > Date.now()
680
+ && (check.status === 'awaiting_review' || check.status === 'needs_attention');
681
+ if (!suppressIntermediateUpdate) {
682
+ this.suppressIntermediateUpdatesUntil.delete(runId);
683
+ }
684
+ if (!suppressIntermediateUpdate && (pane.status !== check.status || pane.status_detail !== check.detail)) {
685
+ this.setPaneStatus(pane, check.status, check.detail);
686
+ }
687
+ else if (!suppressIntermediateUpdate && check.status === 'running') {
688
+ void this.reporter.publishLiveTrace({ ...pane });
689
+ }
690
+ }
691
+ catch (err) {
692
+ this.emit('error', new Error(`Error checking run ${runId}: ${err}`));
693
+ }
694
+ }
695
+ }
696
+ setPaneStatus(pane, status, detail) {
697
+ pane.status = status;
698
+ pane.status_detail = detail;
699
+ if (pane.run_id) {
700
+ this.runtime.updateManifest(pane.run_id, {
701
+ status: status === 'pending' ? 'running' : status,
702
+ status_detail: detail,
703
+ });
704
+ this.runtime.appendEvent(pane.run_id, {
705
+ type: 'status_updated',
706
+ status,
707
+ detail,
708
+ });
709
+ }
710
+ const snapshot = { ...pane };
711
+ this.emit('run_updated', snapshot);
712
+ void this.reporter.publishRunUpdated(snapshot);
713
+ }
714
+ async completeRun(runId, pane, result, detail) {
715
+ const finalSnapshot = await this.capturePaneSnapshot(pane);
716
+ const effectiveMode = pane.mode || this.runtime.getManifest(runId)?.mode || this.settings.mode;
717
+ pane.status = result.success ? 'success' : 'failed';
718
+ pane.status_detail = detail || result.error_summary;
719
+ if (result.success) {
720
+ this.setPaneStatus(pane, 'running', allowsAutomaticMerge(effectiveMode)
721
+ ? 'Agent finished; finalizing merge automation'
722
+ : 'Agent finished; finalizing PR automation');
723
+ }
724
+ try {
725
+ await this.runner.handleRunCompletion(pane, result);
726
+ }
727
+ catch (err) {
728
+ const message = err instanceof Error ? err.message : String(err);
729
+ this.setPaneStatus(pane, 'needs_attention', `Run finished, but Symphony failed during finalization: ${message}`);
730
+ if (pane.run_id) {
731
+ this.runtime.updateManifest(pane.run_id, {
732
+ finished_at: new Date().toISOString(),
733
+ status: 'needs_attention',
734
+ status_detail: pane.status_detail,
735
+ exit_code: result.exit_code,
736
+ error_summary: pane.status_detail,
737
+ commits: result.commits,
738
+ head_sha: result.head_sha,
739
+ tmux_pane_id: pane.tmux_pane_id,
740
+ worktree_path: pane.worktree_path,
741
+ branch: pane.branch,
742
+ });
743
+ this.runtime.appendEvent(pane.run_id, {
744
+ type: 'finished',
745
+ status: 'needs_attention',
746
+ commits: result.commits,
747
+ detail: pane.status_detail,
748
+ });
749
+ }
750
+ }
751
+ const finalStatus = pane.status;
752
+ await this.reporter.publishRunCompleted({ ...pane }, { snapshot: finalSnapshot });
753
+ if (pane.repo) {
754
+ this.state.removeActiveRun(pane.repo, runId);
755
+ const shouldMarkHandled = finalStatus === 'success'
756
+ || (finalStatus === 'awaiting_review' && effectiveMode !== 'auto');
757
+ if (shouldMarkHandled) {
758
+ this.state.markHandled(pane.repo, pane.issue_number, runId);
759
+ }
760
+ else {
761
+ this.state.unmarkHandled(pane.repo, pane.issue_number);
762
+ }
763
+ const trigger = getTriggerForRepo(this.settings, pane.repo);
764
+ if (finalStatus === 'success') {
765
+ this.state.clearSuppressedIssue(pane.repo, pane.issue_number);
766
+ }
767
+ else if (effectiveMode === 'auto') {
768
+ this.state.suppressIssue(pane.repo, pane.issue_number);
769
+ }
770
+ if (trigger.claim_label) {
771
+ try {
772
+ await this.github.removeLabel(pane.repo, pane.issue_number, trigger.claim_label);
773
+ }
774
+ catch {
775
+ // Ignore label cleanup failures.
776
+ }
777
+ }
778
+ for (const obsoleteLabel of [trigger.done_label, trigger.failed_label]) {
779
+ if (!obsoleteLabel)
780
+ continue;
781
+ try {
782
+ await this.github.removeLabel(pane.repo, pane.issue_number, obsoleteLabel);
783
+ }
784
+ catch {
785
+ // Ignore status-label cleanup failures.
786
+ }
787
+ }
788
+ const statusLabel = finalStatus === 'success'
789
+ ? trigger.done_label
790
+ : (finalStatus === 'failed' ? trigger.failed_label : '');
791
+ if (statusLabel) {
792
+ try {
793
+ await this.github.addLabels(pane.repo, pane.issue_number, [statusLabel]);
794
+ }
795
+ catch {
796
+ // Ignore final label failures.
797
+ }
798
+ }
799
+ }
800
+ this.suppressIntermediateUpdatesUntil.delete(runId);
801
+ this.activeRuns.delete(runId);
802
+ // Notify director of completion (for dependency resolution and backlog)
803
+ if (this.director) {
804
+ this.director.recordCompletion({ ...pane });
805
+ }
806
+ this.emit('run_completed', { ...pane });
807
+ }
808
+ async handleRunnerProgress(pane) {
809
+ const snapshot = { ...pane };
810
+ this.emit('run_updated', snapshot);
811
+ await this.reporter.publishRunUpdated(snapshot, { force: true });
812
+ }
813
+ async capturePaneSnapshot(pane) {
814
+ if (!pane.tmux_pane_id)
815
+ return undefined;
816
+ if (!(await this.tmux.paneExists(pane.tmux_pane_id)))
817
+ return undefined;
818
+ const content = await this.tmux.getPaneContent(pane.tmux_pane_id, 30);
819
+ return content.trim() || undefined;
820
+ }
821
+ /**
822
+ * Run a single poll cycle (for testing / manual use).
823
+ */
824
+ async runOnce() {
825
+ this.running = true;
826
+ await this.poll();
827
+ this.running = false;
828
+ }
829
+ }
830
+ function isSymphonyManagedComment(body) {
831
+ return typeof body === 'string' && body.includes('<!-- symphony-progress:');
832
+ }
833
+ function hasLabel(issue, label) {
834
+ return issue.labels.some(issueLabel => issueLabel.name === label);
835
+ }
836
+ //# sourceMappingURL=daemon.js.map