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,1190 @@
1
+ import { existsSync, readdirSync, rmSync } from 'node:fs';
2
+ import { basename, dirname, join, resolve } from 'node:path';
3
+ import { TmuxService } from '../services/tmux.js';
4
+ import { GitHubClient } from '../services/github.js';
5
+ import { createWorktree, ensureClone, removeWorktree } from '../git/worktree.js';
6
+ import { fetchOrigin, fetchBranchRef, findUnresolvedMergeMarkers, getConflictedFiles, getGitRoot, getHeadSha, getCommitCount, hasDiffBetweenRefs, hasUncommittedChanges, isGitWorktree, pushBranch, getMainBranch, resolveBranchRef, } from '../git/utils.js';
7
+ import { launchAgentInPane, writeContextFiles, resolveAgent, buildAgentPromptCommand } from './launcher.js';
8
+ import { generateRunId } from '../utils/slug.js';
9
+ import { runSafe } from '../utils/shell.js';
10
+ import { allowsAutomaticConflictResolution, allowsAutomaticMerge } from '../runModes.js';
11
+ const SHELL_PROMPT_PATTERNS = [
12
+ /^\[[^\]]+\]\s*[$#%]\s*$/,
13
+ /^[^>\n]*[$#%]\s*$/,
14
+ ];
15
+ const AGENT_PROMPT_PATTERNS = [
16
+ /^\s*>\s*\S?/,
17
+ /^\s*❯\s*\S?/,
18
+ /^\s*›\s*\S?/,
19
+ /^\s*│\s*>\s*\S?/,
20
+ ];
21
+ const NEEDS_ATTENTION_PATTERNS = [
22
+ /approval/i,
23
+ /approve/i,
24
+ /review command/i,
25
+ /allow this command/i,
26
+ /permission/i,
27
+ /press enter to continue/i,
28
+ /continue\?/i,
29
+ /waiting for input/i,
30
+ ];
31
+ const WORKING_PATTERNS = [
32
+ /thinking/i,
33
+ /planning/i,
34
+ /analyzing/i,
35
+ /building/i,
36
+ /staging/i,
37
+ /committing/i,
38
+ /finalizing/i,
39
+ /testing/i,
40
+ /running/i,
41
+ /searching/i,
42
+ /reviewing/i,
43
+ /writing/i,
44
+ /reading/i,
45
+ /editing/i,
46
+ /patching/i,
47
+ /generating/i,
48
+ /refactoring/i,
49
+ /fixing/i,
50
+ /checking/i,
51
+ ];
52
+ const ACTIVE_PROMPT_TAIL_PATTERNS = [
53
+ /esc to interrupt/i,
54
+ /\(\d+[smh].*interrupt/i,
55
+ /considering/i,
56
+ /inspecting/i,
57
+ /implementing/i,
58
+ /verifying/i,
59
+ /staging/i,
60
+ /committing/i,
61
+ /finalizing/i,
62
+ ];
63
+ const GIT_AUTOMATION_TIMEOUT_MS = 60_000;
64
+ /**
65
+ * Execute a full agent run for an issue.
66
+ * Ported from symphony Python's runner.py + daemon.py.
67
+ */
68
+ export class AgentRunner {
69
+ runtime;
70
+ github;
71
+ tmux;
72
+ settings;
73
+ onProgress;
74
+ constructor(settings, runtime, onProgress) {
75
+ this.settings = settings;
76
+ this.runtime = runtime;
77
+ this.github = new GitHubClient();
78
+ this.tmux = TmuxService.getInstance();
79
+ this.onProgress = onProgress;
80
+ }
81
+ /**
82
+ * Set up and launch an agent run, returning the pane info.
83
+ */
84
+ async startRun(repo, issue, comments, sessionName, mode) {
85
+ // Resolve agent
86
+ const agent = resolveAgent(issue, comments, repo, this.settings);
87
+ // Generate run ID
88
+ const runId = generateRunId(repo, issue.number);
89
+ // Create worktree
90
+ const worktree = await createWorktree(repo, issue.number, issue.title, this.settings.clone_root, this.settings.worktree_root);
91
+ // Write context files
92
+ writeContextFiles(worktree.path, issue, comments, repo);
93
+ const baseSha = await getHeadSha(worktree.path);
94
+ // Create run directory and manifest
95
+ const runDir = this.runtime.createRun({
96
+ run_id: runId,
97
+ repo,
98
+ issue_number: issue.number,
99
+ issue_title: issue.title,
100
+ issue_url: issue.html_url,
101
+ agent_name: agent.name,
102
+ agent_provider: agent.provider,
103
+ model: agent.model,
104
+ mode,
105
+ branch: worktree.branch,
106
+ worktree_path: worktree.path,
107
+ base_sha: baseSha,
108
+ started_at: new Date().toISOString(),
109
+ status: 'running',
110
+ });
111
+ // Create tmux pane (in a background window)
112
+ const paneId = await this.tmux.createWindow(sessionName, `${issue.number}-${agent.name}`, worktree.path);
113
+ this.runtime.updateManifest(runId, {
114
+ tmux_session: sessionName,
115
+ tmux_pane_id: paneId,
116
+ });
117
+ // Build run request
118
+ const request = {
119
+ repo,
120
+ issue,
121
+ agent,
122
+ mode,
123
+ worktree_path: worktree.path,
124
+ branch: worktree.branch,
125
+ run_id: runId,
126
+ run_dir: runDir,
127
+ };
128
+ // Launch agent in the pane
129
+ await launchAgentInPane(paneId, request, { swarmEnabled: this.settings.swarm?.enabled });
130
+ // Record event
131
+ this.runtime.appendEvent(runId, {
132
+ type: 'started',
133
+ agent: agent.name,
134
+ provider: agent.provider,
135
+ session_name: sessionName,
136
+ pane_id: paneId,
137
+ });
138
+ // Create pane info for TUI
139
+ const pane = {
140
+ id: runId,
141
+ slug: worktree.branch,
142
+ type: 'issue',
143
+ repo,
144
+ issue_number: issue.number,
145
+ issue_title: issue.title,
146
+ issue_url: issue.html_url,
147
+ agent_name: agent.name,
148
+ agent_provider: agent.provider,
149
+ run_id: runId,
150
+ mode,
151
+ status: 'running',
152
+ tmux_pane_id: paneId,
153
+ worktree_path: worktree.path,
154
+ branch: worktree.branch,
155
+ base_sha: baseSha,
156
+ started_at: new Date().toISOString(),
157
+ };
158
+ return { pane, runId };
159
+ }
160
+ /**
161
+ * Inspect a run and classify whether it is still working, needs review,
162
+ * needs attention, or has fully completed.
163
+ */
164
+ async checkRunStatus(pane) {
165
+ const worktreeState = pane.worktree_path
166
+ ? await this.inspectWorktreeState(pane.worktree_path, pane.branch || '', pane.base_sha)
167
+ : { commits: 0, has_uncommitted: false, head_sha: undefined, has_repo_changes: false };
168
+ const hasMeaningfulResult = worktreeState.has_repo_changes || worktreeState.has_uncommitted;
169
+ if (!pane.tmux_pane_id) {
170
+ if (hasMeaningfulResult) {
171
+ return {
172
+ status: 'success',
173
+ detail: 'Recovered completed worktree without a live tmux pane',
174
+ result: this.buildSuccessResult(worktreeState),
175
+ };
176
+ }
177
+ return {
178
+ status: 'failed',
179
+ detail: 'Missing tmux pane id',
180
+ result: {
181
+ success: false,
182
+ exit_code: 1,
183
+ error_summary: 'Missing tmux pane id',
184
+ commits: 0,
185
+ has_uncommitted: false,
186
+ has_repo_changes: false,
187
+ },
188
+ };
189
+ }
190
+ // Check if the pane still exists
191
+ const exists = await this.tmux.paneExists(pane.tmux_pane_id);
192
+ if (!exists) {
193
+ if (hasMeaningfulResult) {
194
+ return {
195
+ status: 'success',
196
+ detail: 'Agent pane exited after producing repository changes',
197
+ result: this.buildSuccessResult(worktreeState),
198
+ };
199
+ }
200
+ return {
201
+ status: 'failed',
202
+ detail: 'Agent pane terminated unexpectedly',
203
+ result: {
204
+ success: false,
205
+ exit_code: 1,
206
+ error_summary: 'Agent pane terminated unexpectedly',
207
+ commits: 0,
208
+ has_uncommitted: false,
209
+ has_repo_changes: false,
210
+ },
211
+ };
212
+ }
213
+ // Check pane content for completion indicators
214
+ const content = await this.tmux.getPaneContent(pane.tmux_pane_id, 30);
215
+ const lines = content.split('\n').map(l => l.trim()).filter(Boolean);
216
+ const recentLines = lines.slice(-8);
217
+ const recentContent = recentLines.join('\n');
218
+ // Check for shell prompt (agent has exited, back to shell)
219
+ const lastLine = lines[lines.length - 1] || '';
220
+ const isAtShellPrompt = SHELL_PROMPT_PATTERNS.some(pattern => pattern.test(lastLine));
221
+ if (isAtShellPrompt) {
222
+ return {
223
+ status: 'success',
224
+ result: this.buildSuccessResult(worktreeState),
225
+ };
226
+ }
227
+ const needsAttention = NEEDS_ATTENTION_PATTERNS.some(pattern => pattern.test(recentContent));
228
+ if (needsAttention) {
229
+ return {
230
+ status: 'needs_attention',
231
+ detail: 'Agent is waiting for approval or input',
232
+ };
233
+ }
234
+ let agentPromptIndex = -1;
235
+ for (let idx = recentLines.length - 1; idx >= 0; idx--) {
236
+ if (AGENT_PROMPT_PATTERNS.some(pattern => pattern.test(recentLines[idx] || ''))) {
237
+ agentPromptIndex = idx;
238
+ break;
239
+ }
240
+ }
241
+ if (hasMeaningfulResult && agentPromptIndex !== -1) {
242
+ const promptTail = recentLines
243
+ .slice(Math.max(0, agentPromptIndex - 3), agentPromptIndex)
244
+ .join('\n');
245
+ const promptStillBusy = ACTIVE_PROMPT_TAIL_PATTERNS.some(pattern => pattern.test(promptTail));
246
+ if (promptStillBusy) {
247
+ return {
248
+ status: 'running',
249
+ detail: 'Agent produced changes; waiting for terminal to settle',
250
+ };
251
+ }
252
+ return {
253
+ status: 'success',
254
+ detail: 'Agent finished work',
255
+ result: this.buildSuccessResult(worktreeState),
256
+ };
257
+ }
258
+ const stillWorking = WORKING_PATTERNS.some(pattern => pattern.test(recentContent));
259
+ if (stillWorking) {
260
+ return { status: 'running' };
261
+ }
262
+ if (hasMeaningfulResult) {
263
+ return {
264
+ status: 'success',
265
+ detail: 'Agent finished work',
266
+ result: this.buildSuccessResult(worktreeState),
267
+ };
268
+ }
269
+ return { status: 'running' };
270
+ }
271
+ async requestAgentExit(pane) {
272
+ if (!pane.tmux_pane_id)
273
+ return;
274
+ await this.tmux.sendKeys(pane.tmux_pane_id, 'C-d');
275
+ }
276
+ /**
277
+ * Handle post-run actions (push, PR, labels, comments).
278
+ */
279
+ async handleRunCompletion(pane, result) {
280
+ if (!pane.run_id || !pane.repo)
281
+ return;
282
+ const mode = pane.mode || this.runtime.getManifest(pane.run_id)?.mode || this.settings.mode;
283
+ const agent = this.getResolvedAgentForPane(pane);
284
+ let finalStatus = result.success ? 'success' : 'failed';
285
+ let finalDetail = result.success
286
+ ? (result.has_repo_changes || result.has_uncommitted
287
+ ? describeWorktreeState(result.commits, result.has_uncommitted)
288
+ : 'Completed without effective repository changes')
289
+ : (result.error_summary || 'Run failed');
290
+ let shouldCleanupArtifacts = false;
291
+ let shouldClosePane = false;
292
+ if (result.success) {
293
+ if (agent) {
294
+ this.recordAutomationProgress(pane, allowsAutomaticMerge(mode)
295
+ ? 'Agent finished; finalizing merge automation'
296
+ : 'Agent finished; finalizing PR automation');
297
+ const automation = await this.handleAutomaticCompletion(pane, result, mode, agent);
298
+ finalStatus = automation.status;
299
+ finalDetail = automation.detail;
300
+ shouldCleanupArtifacts = automation.cleanupArtifacts;
301
+ shouldClosePane = automation.closePane;
302
+ if (automation.mergedPr && pane.repo) {
303
+ const syncResult = await this.syncLocalCheckouts(pane.repo);
304
+ if (syncResult.detail) {
305
+ finalDetail = `${finalDetail} (${syncResult.detail})`;
306
+ }
307
+ }
308
+ }
309
+ else if (!result.has_repo_changes && !result.has_uncommitted) {
310
+ shouldCleanupArtifacts = true;
311
+ shouldClosePane = true;
312
+ }
313
+ }
314
+ if (shouldCleanupArtifacts) {
315
+ const cleanupResult = await this.cleanupRunArtifacts(pane);
316
+ if (!cleanupResult.cleaned) {
317
+ finalDetail = `${finalDetail} (cleanup failed: ${cleanupResult.reason})`;
318
+ }
319
+ }
320
+ if (shouldClosePane && pane.tmux_pane_id) {
321
+ try {
322
+ await this.tmux.killPane(pane.tmux_pane_id);
323
+ }
324
+ catch {
325
+ // Ignore pane cleanup failures.
326
+ }
327
+ pane.tmux_pane_id = undefined;
328
+ }
329
+ pane.status = finalStatus;
330
+ pane.status_detail = finalDetail;
331
+ this.runtime.updateManifest(pane.run_id, {
332
+ finished_at: new Date().toISOString(),
333
+ status: finalStatus,
334
+ exit_code: result.exit_code,
335
+ error_summary: result.error_summary,
336
+ status_detail: finalDetail,
337
+ commits: result.commits,
338
+ head_sha: result.head_sha,
339
+ tmux_pane_id: pane.tmux_pane_id,
340
+ worktree_path: pane.worktree_path,
341
+ branch: pane.branch,
342
+ base_sha: pane.base_sha,
343
+ });
344
+ this.runtime.appendEvent(pane.run_id, {
345
+ type: 'finished',
346
+ status: finalStatus,
347
+ commits: result.commits,
348
+ detail: finalDetail,
349
+ });
350
+ }
351
+ async inspectWorktreeState(worktreePath, branch, baseSha) {
352
+ const commits = baseSha
353
+ ? await getCommitCount(baseSha, 'HEAD', worktreePath)
354
+ : (branch
355
+ ? await getCommitCount(await getMainBranch(worktreePath), branch, worktreePath)
356
+ : 0);
357
+ const has_uncommitted = await hasUncommittedChanges(worktreePath);
358
+ const head_sha = commits > 0 ? await getHeadSha(worktreePath) : undefined;
359
+ const compareRef = baseSha || (branch ? await getMainBranch(worktreePath) : '');
360
+ const has_repo_changes = has_uncommitted
361
+ || (compareRef ? await hasDiffBetweenRefs(compareRef, 'HEAD', worktreePath) : commits > 0);
362
+ return { commits, has_uncommitted, head_sha, has_repo_changes };
363
+ }
364
+ buildSuccessResult(worktreeState) {
365
+ return {
366
+ success: true,
367
+ exit_code: 0,
368
+ commits: worktreeState.commits,
369
+ head_sha: worktreeState.head_sha,
370
+ has_uncommitted: worktreeState.has_uncommitted,
371
+ has_repo_changes: worktreeState.has_repo_changes,
372
+ };
373
+ }
374
+ recordAutomationProgress(pane, detail) {
375
+ pane.status = 'running';
376
+ pane.status_detail = detail;
377
+ if (!pane.run_id)
378
+ return;
379
+ this.runtime.updateManifest(pane.run_id, {
380
+ status: 'running',
381
+ status_detail: detail,
382
+ tmux_pane_id: pane.tmux_pane_id,
383
+ worktree_path: pane.worktree_path,
384
+ branch: pane.branch,
385
+ });
386
+ this.runtime.appendEvent(pane.run_id, {
387
+ type: 'status_updated',
388
+ status: 'running',
389
+ detail,
390
+ });
391
+ try {
392
+ void this.onProgress?.({ ...pane });
393
+ }
394
+ catch {
395
+ // Ignore reporting callback failures.
396
+ }
397
+ }
398
+ async handleAutomaticCompletion(pane, result, mode, agent) {
399
+ if (!pane.branch || !pane.worktree_path) {
400
+ return {
401
+ status: 'awaiting_review',
402
+ detail: 'Changes exist but branch metadata is missing; manual review required',
403
+ cleanupArtifacts: false,
404
+ closePane: false,
405
+ mergedPr: false,
406
+ };
407
+ }
408
+ if (result.has_uncommitted) {
409
+ const finalized = await this.finalizePendingChanges(pane);
410
+ if (!finalized.committed) {
411
+ return {
412
+ status: 'awaiting_review',
413
+ detail: finalized.detail,
414
+ cleanupArtifacts: false,
415
+ closePane: false,
416
+ mergedPr: false,
417
+ };
418
+ }
419
+ const updatedState = await this.inspectWorktreeState(pane.worktree_path, pane.branch, pane.base_sha);
420
+ result.commits = updatedState.commits;
421
+ result.has_uncommitted = updatedState.has_uncommitted;
422
+ result.head_sha = updatedState.head_sha;
423
+ result.has_repo_changes = updatedState.has_repo_changes;
424
+ }
425
+ if (!result.has_repo_changes) {
426
+ return {
427
+ status: 'awaiting_review',
428
+ detail: result.commits > 0
429
+ ? 'Agent produced commits, but the branch has no effective repository diff; refusing to auto-merge a no-op result'
430
+ : 'Agent finished without repository changes; manual verification required',
431
+ cleanupArtifacts: false,
432
+ closePane: false,
433
+ mergedPr: false,
434
+ };
435
+ }
436
+ const markerValidation = await this.validateNoMergeMarkers(pane.worktree_path, `Branch ${pane.branch} still contains unresolved merge markers`);
437
+ if (!markerValidation.ok) {
438
+ return {
439
+ status: 'awaiting_review',
440
+ detail: markerValidation.detail,
441
+ cleanupArtifacts: false,
442
+ closePane: false,
443
+ mergedPr: false,
444
+ };
445
+ }
446
+ if (!agent.push) {
447
+ return {
448
+ status: 'awaiting_review',
449
+ detail: `Changes ready on ${pane.branch}; push is disabled, manual review required`,
450
+ cleanupArtifacts: false,
451
+ closePane: false,
452
+ mergedPr: false,
453
+ };
454
+ }
455
+ this.recordAutomationProgress(pane, `Pushing ${pane.branch} to origin`);
456
+ const pushed = await pushBranch(pane.branch, pane.worktree_path);
457
+ if (!pushed.ok) {
458
+ return {
459
+ status: 'awaiting_review',
460
+ detail: `Changes ready on ${pane.branch}; failed to push branch for review${pushed.detail ? `: ${pushed.detail}` : ''}`,
461
+ cleanupArtifacts: false,
462
+ closePane: false,
463
+ mergedPr: false,
464
+ };
465
+ }
466
+ let prNumber;
467
+ let prUrl;
468
+ let prFailureDetail = '';
469
+ if (agent.create_pr !== 'none') {
470
+ const mainBranch = await getMainBranch(pane.worktree_path);
471
+ this.recordAutomationProgress(pane, `Creating a pull request for ${pane.branch}`);
472
+ const createdPr = await this.github.createPr(pane.repo, {
473
+ title: `[Symphony] ${pane.issue_title || `Issue #${pane.issue_number}`}`,
474
+ body: [
475
+ `Closes #${pane.issue_number}`,
476
+ '',
477
+ `Automated by Symphony agent \`${pane.agent_name}\`.`,
478
+ ].join('\n'),
479
+ head: pane.branch,
480
+ base: mainBranch,
481
+ draft: agent.create_pr === 'draft',
482
+ }, pane.worktree_path);
483
+ const foundPr = createdPr.pr
484
+ ? { pr: createdPr.pr, detail: createdPr.detail }
485
+ : await this.github.findOpenPrForBranch(pane.repo, pane.branch, pane.worktree_path);
486
+ const pr = createdPr.pr || foundPr.pr;
487
+ prFailureDetail = createdPr.detail || foundPr.detail || '';
488
+ if (pr) {
489
+ prNumber = pr.number;
490
+ prUrl = pr.html_url;
491
+ this.runtime.updateManifest(pane.run_id, {
492
+ pr_url: pr.html_url,
493
+ pr_number: pr.number,
494
+ });
495
+ if (allowsAutomaticMerge(mode)) {
496
+ return this.attemptAutomaticMerge(pane, agent, pr, mode);
497
+ }
498
+ return {
499
+ status: 'awaiting_review',
500
+ detail: `PR #${pr.number} is ready for manual merge${prUrl ? `: ${prUrl}` : ''}`,
501
+ cleanupArtifacts: false,
502
+ closePane: false,
503
+ mergedPr: false,
504
+ };
505
+ }
506
+ }
507
+ if (allowsAutomaticMerge(mode)) {
508
+ return {
509
+ status: 'awaiting_review',
510
+ detail: `Branch ${pane.branch} was pushed, but no PR is available for automatic merging${prFailureDetail ? `: ${prFailureDetail}` : ''}`,
511
+ cleanupArtifacts: false,
512
+ closePane: false,
513
+ mergedPr: false,
514
+ };
515
+ }
516
+ return {
517
+ status: 'awaiting_review',
518
+ detail: `Branch ${pane.branch} was pushed and is waiting for manual merge${prFailureDetail ? `: ${prFailureDetail}` : ''}`,
519
+ cleanupArtifacts: false,
520
+ closePane: false,
521
+ mergedPr: false,
522
+ };
523
+ }
524
+ async attemptAutomaticMerge(pane, agent, pr, mode) {
525
+ const maxAutomaticMergePasses = 3;
526
+ this.recordAutomationProgress(pane, `Inspecting PR #${pr.number} for automatic merge`);
527
+ let currentPr = await this.waitForPrState(pane.repo, pr.number, 10, pane.worktree_path) || await this.github.getPr(pane.repo, pr.number, pane.worktree_path) || pr;
528
+ if (currentPr.is_draft) {
529
+ this.recordAutomationProgress(pane, `Marking PR #${pr.number} ready for automatic merge`);
530
+ const readied = await this.github.readyPr(pane.repo, pr.number, pane.worktree_path);
531
+ if (!readied.ok) {
532
+ return {
533
+ status: 'awaiting_review',
534
+ detail: `PR #${pr.number} exists as a draft and could not be marked ready for merge${readied.detail ? `: ${readied.detail}` : ''}`,
535
+ cleanupArtifacts: false,
536
+ closePane: false,
537
+ mergedPr: false,
538
+ };
539
+ }
540
+ currentPr = await this.waitForPrState(pane.repo, pr.number, 5, pane.worktree_path) || currentPr;
541
+ }
542
+ for (let pass = 1; pass <= maxAutomaticMergePasses; pass++) {
543
+ currentPr = await this.waitForPrState(pane.repo, pr.number, 8, pane.worktree_path) || currentPr;
544
+ if (currentPr.state && currentPr.state !== 'OPEN') {
545
+ if (currentPr.merged_at) {
546
+ return {
547
+ status: 'success',
548
+ detail: `Merged PR #${pr.number} and cleaned up local artifacts`,
549
+ cleanupArtifacts: true,
550
+ closePane: true,
551
+ mergedPr: true,
552
+ };
553
+ }
554
+ return {
555
+ status: 'awaiting_review',
556
+ detail: `PR #${pr.number} is no longer open, but GitHub does not report it as merged`,
557
+ cleanupArtifacts: false,
558
+ closePane: false,
559
+ mergedPr: false,
560
+ };
561
+ }
562
+ if (currentPr.mergeable === 'CONFLICTING') {
563
+ if (!allowsAutomaticConflictResolution(mode)) {
564
+ return {
565
+ status: 'awaiting_review',
566
+ detail: `PR #${pr.number} has merge conflicts and is waiting for manual conflict resolution`,
567
+ cleanupArtifacts: false,
568
+ closePane: false,
569
+ mergedPr: false,
570
+ };
571
+ }
572
+ this.recordAutomationProgress(pane, `Resolving merge conflicts for PR #${pr.number} (pass ${pass})`);
573
+ const resolved = await this.resolveAutomergeConflicts(pane, agent, pr.number);
574
+ if (!resolved.resolved) {
575
+ return {
576
+ status: 'awaiting_review',
577
+ detail: resolved.detail,
578
+ cleanupArtifacts: false,
579
+ closePane: false,
580
+ mergedPr: false,
581
+ };
582
+ }
583
+ currentPr = await this.waitForPrState(pane.repo, pr.number, 12, pane.worktree_path) || currentPr;
584
+ continue;
585
+ }
586
+ this.recordAutomationProgress(pane, `Attempting automatic merge for PR #${pr.number} (pass ${pass})`);
587
+ const mergeResult = await this.github.mergePr(pane.repo, pr.number, pane.worktree_path);
588
+ this.runtime.appendEvent(pane.run_id, {
589
+ type: mergeResult.status === 'merged' ? 'pr_merged' : 'pr_merge_pending',
590
+ pr_number: pr.number,
591
+ });
592
+ if (mergeResult.status === 'merged') {
593
+ return {
594
+ status: 'success',
595
+ detail: `Merged PR #${pr.number} and cleaned up local artifacts`,
596
+ cleanupArtifacts: true,
597
+ closePane: true,
598
+ mergedPr: true,
599
+ };
600
+ }
601
+ const settledPr = await this.waitForPrResolution(pane.repo, pr.number, mergeResult.status === 'queued' ? 40 : 10, pane.worktree_path) || currentPr;
602
+ currentPr = settledPr;
603
+ if (currentPr.state && currentPr.state !== 'OPEN') {
604
+ if (currentPr.merged_at) {
605
+ return {
606
+ status: 'success',
607
+ detail: `Merged PR #${pr.number} and cleaned up local artifacts`,
608
+ cleanupArtifacts: true,
609
+ closePane: true,
610
+ mergedPr: true,
611
+ };
612
+ }
613
+ return {
614
+ status: 'awaiting_review',
615
+ detail: `PR #${pr.number} is no longer open, but GitHub does not report it as merged`,
616
+ cleanupArtifacts: false,
617
+ closePane: false,
618
+ mergedPr: false,
619
+ };
620
+ }
621
+ if (currentPr.mergeable === 'CONFLICTING') {
622
+ continue;
623
+ }
624
+ if (mergeResult.status === 'queued') {
625
+ return {
626
+ status: 'awaiting_review',
627
+ detail: `Automatic merge stayed queued for PR #${pr.number} after waiting for GitHub to settle`,
628
+ cleanupArtifacts: false,
629
+ closePane: false,
630
+ mergedPr: false,
631
+ };
632
+ }
633
+ if (mergeResult.status === 'failed' && currentPr.mergeable === 'UNKNOWN' && pass < maxAutomaticMergePasses) {
634
+ continue;
635
+ }
636
+ return {
637
+ status: 'awaiting_review',
638
+ detail: mergeResult.detail || `PR #${pr.number} is ready, but auto-merge could not be completed automatically`,
639
+ cleanupArtifacts: false,
640
+ closePane: false,
641
+ mergedPr: false,
642
+ };
643
+ }
644
+ return {
645
+ status: 'awaiting_review',
646
+ detail: `PR #${pr.number} still has merge conflicts after the automatic resolution attempts`,
647
+ cleanupArtifacts: false,
648
+ closePane: false,
649
+ mergedPr: false,
650
+ };
651
+ }
652
+ async resolveAutomergeConflicts(pane, agent, prNumber) {
653
+ if (!pane.repo || !pane.worktree_path || !pane.branch) {
654
+ return {
655
+ resolved: false,
656
+ detail: `PR #${prNumber} conflicts, but the local worktree metadata is missing`,
657
+ };
658
+ }
659
+ const mainBranch = await getMainBranch(pane.worktree_path);
660
+ this.removeSymphonyArtifacts(pane.worktree_path);
661
+ const existingConflicts = await getConflictedFiles(pane.worktree_path);
662
+ if (existingConflicts.length > 0) {
663
+ await runSafe('git', ['merge', '--abort'], { cwd: pane.worktree_path });
664
+ }
665
+ if (await hasUncommittedChanges(pane.worktree_path)) {
666
+ const finalized = await this.finalizePendingChanges(pane);
667
+ if (!finalized.committed) {
668
+ return {
669
+ resolved: false,
670
+ detail: finalized.detail,
671
+ };
672
+ }
673
+ }
674
+ const fetchResult = await fetchOrigin(pane.worktree_path);
675
+ if (!fetchResult.ok) {
676
+ return {
677
+ resolved: false,
678
+ detail: `PR #${prNumber} could not fetch the latest ${mainBranch} before conflict resolution: ${fetchResult.detail || 'git fetch failed'}`,
679
+ };
680
+ }
681
+ await runSafe('git', ['merge', '--abort'], { cwd: pane.worktree_path });
682
+ const mergeSource = await fetchBranchRef(pane.worktree_path, 'origin', mainBranch);
683
+ const mergeRef = mergeSource.ref || await resolveBranchRef(pane.worktree_path, mainBranch);
684
+ if (!mergeRef) {
685
+ return {
686
+ resolved: false,
687
+ detail: `PR #${prNumber} could not locate a merge source for ${mainBranch}${mergeSource.detail ? `: ${mergeSource.detail}` : ''}`,
688
+ };
689
+ }
690
+ const mergeResult = await runSafe('git', ['merge', mergeRef, '--no-edit'], {
691
+ cwd: pane.worktree_path,
692
+ timeout: GIT_AUTOMATION_TIMEOUT_MS,
693
+ });
694
+ let conflictedFiles = await getConflictedFiles(pane.worktree_path);
695
+ if (mergeResult.exitCode !== 0 && conflictedFiles.length === 0) {
696
+ return {
697
+ resolved: false,
698
+ detail: `PR #${prNumber} could not merge ${mainBranch} into ${pane.branch}: ${mergeResult.stderr || mergeResult.stdout || 'git merge failed'}`,
699
+ };
700
+ }
701
+ if (conflictedFiles.length > 0) {
702
+ for (let attempt = 1; attempt <= 2; attempt++) {
703
+ const statusSnapshot = await runSafe('git', ['status', '--short', '--branch'], {
704
+ cwd: pane.worktree_path,
705
+ });
706
+ const prompt = this.buildConflictResolutionPrompt(pane, mainBranch, mergeRef, conflictedFiles, statusSnapshot.stdout, attempt);
707
+ const agentResult = await this.runAgentPromptInWorktree(pane.worktree_path, agent, prompt, pane);
708
+ this.recordAutomationProgress(pane, `Attempted automatic merge-conflict resolution for PR #${prNumber} (pass ${attempt})`);
709
+ conflictedFiles = await getConflictedFiles(pane.worktree_path);
710
+ if (conflictedFiles.length === 0) {
711
+ const mergeHead = await runSafe('git', ['rev-parse', '--verify', 'MERGE_HEAD'], {
712
+ cwd: pane.worktree_path,
713
+ });
714
+ if (mergeHead.exitCode === 0) {
715
+ await runSafe('git', ['add', '-A'], { cwd: pane.worktree_path });
716
+ const commitResult = await runSafe('git', ['commit', '--no-edit'], { cwd: pane.worktree_path });
717
+ if (commitResult.exitCode !== 0) {
718
+ return {
719
+ resolved: false,
720
+ detail: `Conflicts were edited for PR #${prNumber}, but the merge commit could not be completed automatically`,
721
+ };
722
+ }
723
+ }
724
+ break;
725
+ }
726
+ if (attempt === 2 && agentResult.exitCode !== 0) {
727
+ return {
728
+ resolved: false,
729
+ detail: `Automatic conflict resolution for PR #${prNumber} failed: ${agentResult.stderr || agentResult.stdout || 'agent exited non-zero'}`,
730
+ };
731
+ }
732
+ }
733
+ conflictedFiles = await getConflictedFiles(pane.worktree_path);
734
+ if (conflictedFiles.length > 0) {
735
+ return {
736
+ resolved: false,
737
+ detail: `PR #${prNumber} still has merge conflicts after the automatic resolution attempt`,
738
+ };
739
+ }
740
+ const markerValidation = await this.validateNoMergeMarkers(pane.worktree_path, `PR #${prNumber} still contains unresolved merge markers after the automatic resolution attempt`);
741
+ if (!markerValidation.ok) {
742
+ return {
743
+ resolved: false,
744
+ detail: markerValidation.detail,
745
+ };
746
+ }
747
+ }
748
+ this.recordAutomationProgress(pane, `Pushing conflict-resolved branch ${pane.branch}`);
749
+ const pushed = await pushBranch(pane.branch, pane.worktree_path);
750
+ if (!pushed.ok) {
751
+ return {
752
+ resolved: false,
753
+ detail: `Conflicts were resolved locally for PR #${prNumber}, but pushing ${pane.branch} failed${pushed.detail ? `: ${pushed.detail}` : ''}`,
754
+ };
755
+ }
756
+ return {
757
+ resolved: true,
758
+ detail: `Resolved merge conflicts for PR #${prNumber} and pushed the updated branch`,
759
+ };
760
+ }
761
+ async runAgentPromptInWorktree(worktreePath, agent, prompt, pane) {
762
+ const timeoutMs = agent.timeout_min * 60 * 1000;
763
+ const env = this.buildAgentEnvironment(pane, agent);
764
+ const options = {
765
+ cwd: worktreePath,
766
+ env,
767
+ timeout: timeoutMs,
768
+ };
769
+ if (agent.provider === 'codex') {
770
+ // Run conflict-resolution prompts through a fresh non-interactive Codex invocation.
771
+ // Reusing the live pane is brittle because prompt-completion detection can get stuck
772
+ // on the agent's terminal transcript even after the merge has been resolved.
773
+ return runSafe('sh', ['-lc', buildCodexExecCommand(prompt, agent.permission_mode)], options);
774
+ }
775
+ const automationPaneId = await this.createAutomationPane(pane, worktreePath, agent.name);
776
+ const command = buildAgentPromptCommand(agent, prompt);
777
+ if (automationPaneId) {
778
+ try {
779
+ await this.tmux.sendShellCommand(automationPaneId, `cd "${worktreePath}"`);
780
+ await sleep(200);
781
+ for (const [key, value] of Object.entries(env)) {
782
+ await this.tmux.sendShellCommand(automationPaneId, `export ${key}="${value.replace(/"/g, '\\"')}"`);
783
+ }
784
+ await sleep(200);
785
+ await this.tmux.sendShellCommand(automationPaneId, command);
786
+ return this.waitForAgentPromptCompletion(automationPaneId, timeoutMs);
787
+ }
788
+ finally {
789
+ try {
790
+ await this.tmux.killPane(automationPaneId);
791
+ }
792
+ catch {
793
+ // Ignore cleanup failures for automation panes.
794
+ }
795
+ }
796
+ }
797
+ return runSafe('sh', ['-lc', command], options);
798
+ }
799
+ async runPromptInExistingPane(paneId, prompt, timeoutMs) {
800
+ const initialContent = await this.tmux.getPaneContent(paneId, 120);
801
+ await this.tmux.pasteText(paneId, prompt);
802
+ await sleep(200);
803
+ await this.tmux.sendKeys(paneId, 'Enter');
804
+ return this.waitForExistingAgentPromptTurn(paneId, initialContent, timeoutMs);
805
+ }
806
+ async createAutomationPane(pane, worktreePath, agentName) {
807
+ const sessionName = pane.run_id
808
+ ? this.runtime.getManifest(pane.run_id)?.tmux_session
809
+ : undefined;
810
+ if (!sessionName || !(await this.tmux.sessionExists(sessionName))) {
811
+ return undefined;
812
+ }
813
+ const windowName = `${pane.issue_number || 'merge'}-${agentName}-resolve`.slice(0, 24);
814
+ return this.tmux.createWindow(sessionName, windowName, worktreePath);
815
+ }
816
+ async waitForAgentPromptCompletion(paneId, timeoutMs) {
817
+ const startedAt = Date.now();
818
+ let lastContent = '';
819
+ let previousContent = '';
820
+ let stablePromptPolls = 0;
821
+ while (Date.now() - startedAt < timeoutMs) {
822
+ if (!(await this.tmux.paneExists(paneId))) {
823
+ return {
824
+ exitCode: 1,
825
+ stdout: lastContent,
826
+ stderr: 'Temporary automation pane terminated unexpectedly',
827
+ };
828
+ }
829
+ const content = await this.tmux.getPaneContent(paneId, 120);
830
+ lastContent = content || lastContent;
831
+ const lines = content.split('\n').map(line => line.trim()).filter(Boolean);
832
+ const recentLines = lines.slice(-12);
833
+ const recentContent = recentLines.join('\n');
834
+ const lastLine = recentLines[recentLines.length - 1] || '';
835
+ if (NEEDS_ATTENTION_PATTERNS.some(pattern => pattern.test(recentContent))) {
836
+ return {
837
+ exitCode: 1,
838
+ stdout: content,
839
+ stderr: 'Conflict-resolution agent requested approval or interactive input',
840
+ };
841
+ }
842
+ if (SHELL_PROMPT_PATTERNS.some(pattern => pattern.test(lastLine))) {
843
+ return { exitCode: 0, stdout: content, stderr: '' };
844
+ }
845
+ const promptVisible = recentLines.some(line => AGENT_PROMPT_PATTERNS.some(pattern => pattern.test(line)));
846
+ if (promptVisible) {
847
+ stablePromptPolls = content === previousContent ? stablePromptPolls + 1 : 1;
848
+ if (stablePromptPolls >= 2) {
849
+ return { exitCode: 0, stdout: content, stderr: '' };
850
+ }
851
+ }
852
+ else {
853
+ stablePromptPolls = 0;
854
+ }
855
+ previousContent = content;
856
+ await sleep(2000);
857
+ }
858
+ return {
859
+ exitCode: 124,
860
+ stdout: lastContent,
861
+ stderr: 'Timed out waiting for the conflict-resolution agent to finish',
862
+ };
863
+ }
864
+ async waitForExistingAgentPromptTurn(paneId, initialContent, timeoutMs) {
865
+ const startedAt = Date.now();
866
+ let lastContent = initialContent;
867
+ let previousContent = '';
868
+ let stablePromptPolls = 0;
869
+ let sawContentChange = false;
870
+ while (Date.now() - startedAt < timeoutMs) {
871
+ if (!(await this.tmux.paneExists(paneId))) {
872
+ return {
873
+ exitCode: 1,
874
+ stdout: lastContent,
875
+ stderr: 'Existing agent pane terminated unexpectedly',
876
+ };
877
+ }
878
+ const content = await this.tmux.getPaneContent(paneId, 120);
879
+ lastContent = content || lastContent;
880
+ const lines = content.split('\n').map(line => line.trim()).filter(Boolean);
881
+ const recentLines = lines.slice(-12);
882
+ const recentContent = recentLines.join('\n');
883
+ const lastLine = recentLines[recentLines.length - 1] || '';
884
+ const promptVisible = recentLines.some(line => AGENT_PROMPT_PATTERNS.some(pattern => pattern.test(line)));
885
+ const shellPromptVisible = SHELL_PROMPT_PATTERNS.some(pattern => pattern.test(lastLine));
886
+ if (NEEDS_ATTENTION_PATTERNS.some(pattern => pattern.test(recentContent))) {
887
+ return {
888
+ exitCode: 1,
889
+ stdout: content,
890
+ stderr: 'Conflict-resolution agent requested approval or interactive input',
891
+ };
892
+ }
893
+ if (content !== initialContent) {
894
+ sawContentChange = true;
895
+ }
896
+ const activelyWorking = ACTIVE_PROMPT_TAIL_PATTERNS.some(pattern => pattern.test(recentContent));
897
+ if (sawContentChange && (promptVisible || shellPromptVisible) && !activelyWorking) {
898
+ stablePromptPolls = content === previousContent ? stablePromptPolls + 1 : 1;
899
+ if (stablePromptPolls >= 2) {
900
+ return { exitCode: 0, stdout: content, stderr: '' };
901
+ }
902
+ }
903
+ else {
904
+ stablePromptPolls = 0;
905
+ }
906
+ previousContent = content;
907
+ await sleep(2000);
908
+ }
909
+ return {
910
+ exitCode: 124,
911
+ stdout: lastContent,
912
+ stderr: 'Timed out waiting for the conflict-resolution agent to finish in the existing pane',
913
+ };
914
+ }
915
+ buildConflictResolutionPrompt(pane, mainBranch, mergeRef, conflictedFiles, statusSnapshot, attempt) {
916
+ return [
917
+ `You are resolving merge conflicts for GitHub issue #${pane.issue_number} in ${pane.repo}.`,
918
+ `Issue title: ${pane.issue_title || '(unknown title)'}`,
919
+ `The branch ${pane.branch} is being prepared for automatic merge and conflicts with ${mergeRef}.`,
920
+ '',
921
+ `A merge of ${mergeRef} (${mainBranch}) into the current branch has already been started in this worktree.`,
922
+ `This is automatic conflict-resolution pass ${attempt}. Do not work on unrelated feature changes.`,
923
+ 'Read `.symphony/context.md` before editing so you preserve the current issue intent while resolving the conflict.',
924
+ 'Run `git status` to inspect the merge state, resolve every conflict marker while preserving the current issue outcome, run targeted verification if needed, and complete the merge commit.',
925
+ 'If the current issue requests exact replacement text, use that exact text instead of blending or paraphrasing conflicting wording.',
926
+ 'Do not abandon the merge, reset the branch, or leave the repository mid-merge. Finish with a committed merge result on the current branch.',
927
+ 'When the merge is complete, exit immediately. Do not produce a long summary, walkthrough, or code explanation.',
928
+ '',
929
+ 'Current git status:',
930
+ statusSnapshot || '(status unavailable)',
931
+ '',
932
+ 'Conflicted files:',
933
+ ...conflictedFiles.map(file => `- ${file}`),
934
+ ].join('\n');
935
+ }
936
+ hasAgentPrompt(content) {
937
+ const lines = content.split('\n').map(line => line.trim()).filter(Boolean);
938
+ return lines.slice(-12).some(line => AGENT_PROMPT_PATTERNS.some(pattern => pattern.test(line)));
939
+ }
940
+ buildAgentEnvironment(pane, agent) {
941
+ return {
942
+ ...agent.env,
943
+ SYMPHONY_REPO: pane.repo || '',
944
+ SYMPHONY_ISSUE_NUMBER: String(pane.issue_number || ''),
945
+ SYMPHONY_ISSUE_URL: pane.issue_url || '',
946
+ SYMPHONY_WORKTREE: pane.worktree_path || '',
947
+ SYMPHONY_BRANCH: pane.branch || '',
948
+ SYMPHONY_RUN_ID: pane.run_id || '',
949
+ SYMPHONY_AGENT: agent.name,
950
+ SYMPHONY_AGENT_PROVIDER: agent.provider,
951
+ };
952
+ }
953
+ async finalizePendingChanges(pane) {
954
+ if (!pane.worktree_path) {
955
+ return {
956
+ committed: false,
957
+ detail: 'Agent left uncommitted changes, but the worktree path is missing',
958
+ };
959
+ }
960
+ this.removeSymphonyArtifacts(pane.worktree_path);
961
+ await runSafe('git', ['add', '-A'], { cwd: pane.worktree_path });
962
+ const commitResult = await runSafe('git', [
963
+ 'commit',
964
+ '-m',
965
+ `chore: finalize Symphony changes for issue #${pane.issue_number ?? ''}`.trim(),
966
+ ], {
967
+ cwd: pane.worktree_path,
968
+ });
969
+ if (commitResult.exitCode !== 0) {
970
+ return {
971
+ committed: false,
972
+ detail: `Agent left uncommitted changes and Symphony could not commit them automatically: ${commitResult.stderr || commitResult.stdout || 'git commit failed'}`,
973
+ };
974
+ }
975
+ this.recordAutomationProgress(pane, 'Committed leftover worktree changes before automatic merge handling');
976
+ return {
977
+ committed: true,
978
+ detail: 'Committed leftover worktree changes before automatic merge handling',
979
+ };
980
+ }
981
+ getResolvedAgentForPane(pane) {
982
+ if (!pane.agent_name)
983
+ return null;
984
+ const agentConfig = this.settings.agents[pane.agent_name];
985
+ if (!agentConfig)
986
+ return null;
987
+ return {
988
+ name: pane.agent_name,
989
+ provider: pane.agent_provider || agentConfig.provider,
990
+ model: agentConfig.model,
991
+ thinking: agentConfig.thinking,
992
+ create_pr: agentConfig.create_pr,
993
+ push: agentConfig.push,
994
+ timeout_min: agentConfig.timeout_min,
995
+ env: agentConfig.env || {},
996
+ command: agentConfig.command,
997
+ permission_mode: agentConfig.permission_mode || '',
998
+ };
999
+ }
1000
+ async waitForPrState(repo, prNumber, attempts, cwd) {
1001
+ let latest = null;
1002
+ for (let idx = 0; idx < attempts; idx++) {
1003
+ latest = await this.github.getPr(repo, prNumber, cwd);
1004
+ if (latest && latest.mergeable && latest.mergeable !== 'UNKNOWN') {
1005
+ return latest;
1006
+ }
1007
+ await sleep(1500);
1008
+ }
1009
+ return latest;
1010
+ }
1011
+ async waitForPrResolution(repo, prNumber, attempts, cwd) {
1012
+ let latest = null;
1013
+ for (let idx = 0; idx < attempts; idx++) {
1014
+ latest = await this.github.getPr(repo, prNumber, cwd);
1015
+ if (latest?.state && latest.state !== 'OPEN') {
1016
+ return latest;
1017
+ }
1018
+ if (latest?.mergeable === 'CONFLICTING') {
1019
+ return latest;
1020
+ }
1021
+ await sleep(1500);
1022
+ }
1023
+ return latest;
1024
+ }
1025
+ async validateNoMergeMarkers(repoPath, prefix) {
1026
+ const markers = await findUnresolvedMergeMarkers(repoPath);
1027
+ if (markers.length === 0) {
1028
+ return { ok: true, detail: '' };
1029
+ }
1030
+ return {
1031
+ ok: false,
1032
+ detail: `${prefix}: ${markers.slice(0, 3).join('; ')}${markers.length > 3 ? ' ...' : ''}`,
1033
+ };
1034
+ }
1035
+ removeSymphonyArtifacts(worktreePath) {
1036
+ const symphonyDir = `${worktreePath}/.symphony`;
1037
+ if (existsSync(symphonyDir)) {
1038
+ rmSync(symphonyDir, { recursive: true, force: true });
1039
+ }
1040
+ }
1041
+ async syncLocalCheckouts(repo) {
1042
+ const syncSetting = (process.env.SYMPHONY_SYNC_LOCAL_CHECKOUTS || '').trim().toLowerCase();
1043
+ if (['0', 'false', 'off', 'no'].includes(syncSetting)) {
1044
+ return { synced: 0, detail: 'local checkout sync disabled via SYMPHONY_SYNC_LOCAL_CHECKOUTS' };
1045
+ }
1046
+ const candidates = await this.findLocalCheckoutCandidates(repo);
1047
+ if (candidates.length === 0) {
1048
+ return { synced: 0, detail: 'no matching local checkout found to sync' };
1049
+ }
1050
+ let synced = 0;
1051
+ const notes = [];
1052
+ for (const candidate of candidates) {
1053
+ const result = await this.syncCheckout(candidate);
1054
+ if (result.synced) {
1055
+ synced += 1;
1056
+ }
1057
+ else if (result.detail) {
1058
+ notes.push(`${basename(candidate)}: ${result.detail}`);
1059
+ }
1060
+ }
1061
+ if (synced > 0) {
1062
+ return {
1063
+ synced,
1064
+ detail: notes.length > 0
1065
+ ? `synced ${synced} local checkout(s); ${notes.join(' | ')}`
1066
+ : `synced ${synced} local checkout(s)`,
1067
+ };
1068
+ }
1069
+ return {
1070
+ synced: 0,
1071
+ detail: notes.join(' | ') || 'found local checkout(s), but could not sync them',
1072
+ };
1073
+ }
1074
+ async findLocalCheckoutCandidates(repo) {
1075
+ const repoName = repo.split('/').at(-1) || repo;
1076
+ const cwd = process.cwd();
1077
+ const home = process.env.HOME || '';
1078
+ const seedPaths = [
1079
+ cwd,
1080
+ dirname(cwd),
1081
+ dirname(dirname(cwd)),
1082
+ join(dirname(cwd), repoName),
1083
+ join(dirname(dirname(cwd)), repoName),
1084
+ ...(home ? [join(home, 'Symphony_Repositories'), join(home, 'code')] : []),
1085
+ ];
1086
+ const candidates = new Set();
1087
+ for (const seed of seedPaths) {
1088
+ const absoluteSeed = resolve(seed);
1089
+ if (!existsSync(absoluteSeed))
1090
+ continue;
1091
+ if (basename(absoluteSeed) === repoName) {
1092
+ candidates.add(absoluteSeed);
1093
+ }
1094
+ try {
1095
+ for (const entry of readdirSync(absoluteSeed, { withFileTypes: true })) {
1096
+ if (entry.isDirectory() && entry.name === repoName) {
1097
+ candidates.add(join(absoluteSeed, entry.name));
1098
+ }
1099
+ }
1100
+ }
1101
+ catch {
1102
+ // Ignore unreadable directories while discovering sibling repos.
1103
+ }
1104
+ }
1105
+ const matches = [];
1106
+ for (const candidate of candidates) {
1107
+ try {
1108
+ const gitRoot = await getGitRoot(candidate);
1109
+ if (resolve(gitRoot) !== resolve(candidate))
1110
+ continue;
1111
+ if (await isGitWorktree(candidate))
1112
+ continue;
1113
+ const remote = await runSafe('git', ['remote', 'get-url', 'origin'], { cwd: candidate });
1114
+ if (remote.exitCode !== 0)
1115
+ continue;
1116
+ const normalized = remote.stdout.trim();
1117
+ if (normalized === `https://github.com/${repo}.git`
1118
+ || normalized === `git@github.com:${repo}.git`
1119
+ || normalized === `https://github.com/${repo}`
1120
+ || normalized === `git@github.com:${repo}`) {
1121
+ matches.push(candidate);
1122
+ }
1123
+ }
1124
+ catch {
1125
+ // Ignore non-git directories.
1126
+ }
1127
+ }
1128
+ return matches;
1129
+ }
1130
+ async syncCheckout(repoPath) {
1131
+ await runSafe('git', ['fetch', '--prune', 'origin'], { cwd: repoPath });
1132
+ const mainBranch = await getMainBranch(repoPath);
1133
+ const currentBranch = await runSafe('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: repoPath });
1134
+ if (currentBranch.exitCode !== 0) {
1135
+ return { synced: false, detail: 'could not detect current branch' };
1136
+ }
1137
+ if (currentBranch.stdout.trim() !== mainBranch) {
1138
+ return { synced: false, detail: `skipped pull because checkout is on ${currentBranch.stdout.trim()}` };
1139
+ }
1140
+ if (await hasUncommittedChanges(repoPath)) {
1141
+ return { synced: false, detail: 'skipped pull because checkout has local changes' };
1142
+ }
1143
+ const pullResult = await runSafe('git', ['pull', '--ff-only', 'origin', mainBranch], { cwd: repoPath });
1144
+ if (pullResult.exitCode !== 0) {
1145
+ return { synced: false, detail: `pull failed: ${pullResult.stderr || pullResult.stdout || 'unknown error'}` };
1146
+ }
1147
+ return { synced: true };
1148
+ }
1149
+ async cleanupRunArtifacts(pane) {
1150
+ if (!pane.repo || !pane.worktree_path) {
1151
+ return { cleaned: true };
1152
+ }
1153
+ try {
1154
+ const clonePath = await ensureClone(pane.repo, this.settings.clone_root);
1155
+ await removeWorktree(pane.worktree_path, clonePath, true, pane.branch);
1156
+ pane.worktree_path = undefined;
1157
+ return { cleaned: true };
1158
+ }
1159
+ catch (err) {
1160
+ return {
1161
+ cleaned: false,
1162
+ reason: err instanceof Error ? err.message : String(err),
1163
+ };
1164
+ }
1165
+ }
1166
+ }
1167
+ function describeWorktreeState(commits, hasUncommitted) {
1168
+ const parts = [`${commits} commit(s)`];
1169
+ if (hasUncommitted) {
1170
+ parts.push('uncommitted changes');
1171
+ }
1172
+ return `Ready for review: ${parts.join(', ')}`;
1173
+ }
1174
+ function buildCodexExecCommand(prompt, permissionMode) {
1175
+ const escapedPrompt = prompt
1176
+ .replace(/\\/g, '\\\\')
1177
+ .replace(/"/g, '\\"')
1178
+ .replace(/`/g, '\\`')
1179
+ .replace(/\$/g, '\\$');
1180
+ const flags = permissionMode === 'bypassPermissions'
1181
+ ? ' --dangerously-bypass-approvals-and-sandbox'
1182
+ : permissionMode === 'acceptEdits'
1183
+ ? ' --full-auto'
1184
+ : '';
1185
+ return `printf '%s\\n' "${escapedPrompt}" | codex exec${flags} -`;
1186
+ }
1187
+ function sleep(ms) {
1188
+ return new Promise(resolve => setTimeout(resolve, ms));
1189
+ }
1190
+ //# sourceMappingURL=runner.js.map