@vaporsoft/orc 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 (223) hide show
  1. package/LICENSE +6 -0
  2. package/README.md +178 -0
  3. package/dist/bin/orc.d.ts +3 -0
  4. package/dist/bin/orc.d.ts.map +1 -0
  5. package/dist/bin/orc.js +4 -0
  6. package/dist/bin/orc.js.map +1 -0
  7. package/dist/src/cli.d.ts +6 -0
  8. package/dist/src/cli.d.ts.map +1 -0
  9. package/dist/src/cli.js +28 -0
  10. package/dist/src/cli.js.map +1 -0
  11. package/dist/src/commands/init.d.ts +6 -0
  12. package/dist/src/commands/init.d.ts.map +1 -0
  13. package/dist/src/commands/init.js +58 -0
  14. package/dist/src/commands/init.js.map +1 -0
  15. package/dist/src/commands/start.d.ts +17 -0
  16. package/dist/src/commands/start.d.ts.map +1 -0
  17. package/dist/src/commands/start.js +146 -0
  18. package/dist/src/commands/start.js.map +1 -0
  19. package/dist/src/constants.d.ts +18 -0
  20. package/dist/src/constants.d.ts.map +1 -0
  21. package/dist/src/constants.js +42 -0
  22. package/dist/src/constants.js.map +1 -0
  23. package/dist/src/core/comment-categorizer.d.ts +20 -0
  24. package/dist/src/core/comment-categorizer.d.ts.map +1 -0
  25. package/dist/src/core/comment-categorizer.js +208 -0
  26. package/dist/src/core/comment-categorizer.js.map +1 -0
  27. package/dist/src/core/comment-fetcher.d.ts +37 -0
  28. package/dist/src/core/comment-fetcher.d.ts.map +1 -0
  29. package/dist/src/core/comment-fetcher.js +138 -0
  30. package/dist/src/core/comment-fetcher.js.map +1 -0
  31. package/dist/src/core/daemon.d.ts +92 -0
  32. package/dist/src/core/daemon.d.ts.map +1 -0
  33. package/dist/src/core/daemon.js +896 -0
  34. package/dist/src/core/daemon.js.map +1 -0
  35. package/dist/src/core/fix-executor.d.ts +50 -0
  36. package/dist/src/core/fix-executor.d.ts.map +1 -0
  37. package/dist/src/core/fix-executor.js +374 -0
  38. package/dist/src/core/fix-executor.js.map +1 -0
  39. package/dist/src/core/git-manager.d.ts +44 -0
  40. package/dist/src/core/git-manager.d.ts.map +1 -0
  41. package/dist/src/core/git-manager.js +230 -0
  42. package/dist/src/core/git-manager.js.map +1 -0
  43. package/dist/src/core/pilot-config.d.ts +18 -0
  44. package/dist/src/core/pilot-config.d.ts.map +1 -0
  45. package/dist/src/core/pilot-config.js +76 -0
  46. package/dist/src/core/pilot-config.js.map +1 -0
  47. package/dist/src/core/progress-store.d.ts +32 -0
  48. package/dist/src/core/progress-store.d.ts.map +1 -0
  49. package/dist/src/core/progress-store.js +106 -0
  50. package/dist/src/core/progress-store.js.map +1 -0
  51. package/dist/src/core/repo-config.d.ts +11 -0
  52. package/dist/src/core/repo-config.d.ts.map +1 -0
  53. package/dist/src/core/repo-config.js +168 -0
  54. package/dist/src/core/repo-config.js.map +1 -0
  55. package/dist/src/core/session-controller.d.ts +61 -0
  56. package/dist/src/core/session-controller.d.ts.map +1 -0
  57. package/dist/src/core/session-controller.js +926 -0
  58. package/dist/src/core/session-controller.js.map +1 -0
  59. package/dist/src/core/thread-responder.d.ts +28 -0
  60. package/dist/src/core/thread-responder.d.ts.map +1 -0
  61. package/dist/src/core/thread-responder.js +193 -0
  62. package/dist/src/core/thread-responder.js.map +1 -0
  63. package/dist/src/core/worktree-manager.d.ts +26 -0
  64. package/dist/src/core/worktree-manager.d.ts.map +1 -0
  65. package/dist/src/core/worktree-manager.js +189 -0
  66. package/dist/src/core/worktree-manager.js.map +1 -0
  67. package/dist/src/github/gh-client.d.ts +57 -0
  68. package/dist/src/github/gh-client.d.ts.map +1 -0
  69. package/dist/src/github/gh-client.js +236 -0
  70. package/dist/src/github/gh-client.js.map +1 -0
  71. package/dist/src/github/queries.d.ts +9 -0
  72. package/dist/src/github/queries.d.ts.map +1 -0
  73. package/dist/src/github/queries.js +152 -0
  74. package/dist/src/github/queries.js.map +1 -0
  75. package/dist/src/github/types.d.ts +114 -0
  76. package/dist/src/github/types.d.ts.map +1 -0
  77. package/dist/src/github/types.js +3 -0
  78. package/dist/src/github/types.js.map +1 -0
  79. package/dist/src/tui/App.d.ts +8 -0
  80. package/dist/src/tui/App.d.ts.map +1 -0
  81. package/dist/src/tui/App.js +407 -0
  82. package/dist/src/tui/App.js.map +1 -0
  83. package/dist/src/tui/components/ActivityPane.d.ts +7 -0
  84. package/dist/src/tui/components/ActivityPane.d.ts.map +1 -0
  85. package/dist/src/tui/components/ActivityPane.js +10 -0
  86. package/dist/src/tui/components/ActivityPane.js.map +1 -0
  87. package/dist/src/tui/components/DetailPanel.d.ts +15 -0
  88. package/dist/src/tui/components/DetailPanel.d.ts.map +1 -0
  89. package/dist/src/tui/components/DetailPanel.js +137 -0
  90. package/dist/src/tui/components/DetailPanel.js.map +1 -0
  91. package/dist/src/tui/components/DrillInOverlay.d.ts +13 -0
  92. package/dist/src/tui/components/DrillInOverlay.d.ts.map +1 -0
  93. package/dist/src/tui/components/DrillInOverlay.js +85 -0
  94. package/dist/src/tui/components/DrillInOverlay.js.map +1 -0
  95. package/dist/src/tui/components/ExpandedContent.d.ts +10 -0
  96. package/dist/src/tui/components/ExpandedContent.d.ts.map +1 -0
  97. package/dist/src/tui/components/ExpandedContent.js +99 -0
  98. package/dist/src/tui/components/ExpandedContent.js.map +1 -0
  99. package/dist/src/tui/components/Header.d.ts +12 -0
  100. package/dist/src/tui/components/Header.d.ts.map +1 -0
  101. package/dist/src/tui/components/Header.js +35 -0
  102. package/dist/src/tui/components/Header.js.map +1 -0
  103. package/dist/src/tui/components/HelpBar.d.ts +2 -0
  104. package/dist/src/tui/components/HelpBar.d.ts.map +1 -0
  105. package/dist/src/tui/components/HelpBar.js +11 -0
  106. package/dist/src/tui/components/HelpBar.js.map +1 -0
  107. package/dist/src/tui/components/KeybindLegend.d.ts +7 -0
  108. package/dist/src/tui/components/KeybindLegend.d.ts.map +1 -0
  109. package/dist/src/tui/components/KeybindLegend.js +53 -0
  110. package/dist/src/tui/components/KeybindLegend.js.map +1 -0
  111. package/dist/src/tui/components/LogPane.d.ts +11 -0
  112. package/dist/src/tui/components/LogPane.d.ts.map +1 -0
  113. package/dist/src/tui/components/LogPane.js +31 -0
  114. package/dist/src/tui/components/LogPane.js.map +1 -0
  115. package/dist/src/tui/components/SessionList.d.ts +14 -0
  116. package/dist/src/tui/components/SessionList.d.ts.map +1 -0
  117. package/dist/src/tui/components/SessionList.js +31 -0
  118. package/dist/src/tui/components/SessionList.js.map +1 -0
  119. package/dist/src/tui/components/SessionRow.d.ts +10 -0
  120. package/dist/src/tui/components/SessionRow.d.ts.map +1 -0
  121. package/dist/src/tui/components/SessionRow.js +52 -0
  122. package/dist/src/tui/components/SessionRow.js.map +1 -0
  123. package/dist/src/tui/components/SettingsPanel.d.ts +8 -0
  124. package/dist/src/tui/components/SettingsPanel.d.ts.map +1 -0
  125. package/dist/src/tui/components/SettingsPanel.js +191 -0
  126. package/dist/src/tui/components/SettingsPanel.js.map +1 -0
  127. package/dist/src/tui/components/StatusBadge.d.ts +9 -0
  128. package/dist/src/tui/components/StatusBadge.d.ts.map +1 -0
  129. package/dist/src/tui/components/StatusBadge.js +52 -0
  130. package/dist/src/tui/components/StatusBadge.js.map +1 -0
  131. package/dist/src/tui/components/Toolbar.d.ts +5 -0
  132. package/dist/src/tui/components/Toolbar.d.ts.map +1 -0
  133. package/dist/src/tui/components/Toolbar.js +2 -0
  134. package/dist/src/tui/components/Toolbar.js.map +1 -0
  135. package/dist/src/tui/components/comment-constants.d.ts +4 -0
  136. package/dist/src/tui/components/comment-constants.d.ts.map +1 -0
  137. package/dist/src/tui/components/comment-constants.js +15 -0
  138. package/dist/src/tui/components/comment-constants.js.map +1 -0
  139. package/dist/src/tui/hooks/logFlushUtils.d.ts +15 -0
  140. package/dist/src/tui/hooks/logFlushUtils.d.ts.map +1 -0
  141. package/dist/src/tui/hooks/logFlushUtils.js +33 -0
  142. package/dist/src/tui/hooks/logFlushUtils.js.map +1 -0
  143. package/dist/src/tui/hooks/useBranchLogs.d.ts +7 -0
  144. package/dist/src/tui/hooks/useBranchLogs.d.ts.map +1 -0
  145. package/dist/src/tui/hooks/useBranchLogs.js +58 -0
  146. package/dist/src/tui/hooks/useBranchLogs.js.map +1 -0
  147. package/dist/src/tui/hooks/useDaemonState.d.ts +19 -0
  148. package/dist/src/tui/hooks/useDaemonState.d.ts.map +1 -0
  149. package/dist/src/tui/hooks/useDaemonState.js +152 -0
  150. package/dist/src/tui/hooks/useDaemonState.js.map +1 -0
  151. package/dist/src/tui/hooks/useInitialDiscovery.d.ts +8 -0
  152. package/dist/src/tui/hooks/useInitialDiscovery.d.ts.map +1 -0
  153. package/dist/src/tui/hooks/useInitialDiscovery.js +31 -0
  154. package/dist/src/tui/hooks/useInitialDiscovery.js.map +1 -0
  155. package/dist/src/tui/hooks/useLogBuffer.d.ts +7 -0
  156. package/dist/src/tui/hooks/useLogBuffer.d.ts.map +1 -0
  157. package/dist/src/tui/hooks/useLogBuffer.js +52 -0
  158. package/dist/src/tui/hooks/useLogBuffer.js.map +1 -0
  159. package/dist/src/tui/hooks/useNextCheckCountdown.d.ts +7 -0
  160. package/dist/src/tui/hooks/useNextCheckCountdown.d.ts.map +1 -0
  161. package/dist/src/tui/hooks/useNextCheckCountdown.js +42 -0
  162. package/dist/src/tui/hooks/useNextCheckCountdown.js.map +1 -0
  163. package/dist/src/tui/hooks/useTerminalFocus.d.ts +9 -0
  164. package/dist/src/tui/hooks/useTerminalFocus.d.ts.map +1 -0
  165. package/dist/src/tui/hooks/useTerminalFocus.js +43 -0
  166. package/dist/src/tui/hooks/useTerminalFocus.js.map +1 -0
  167. package/dist/src/tui/theme.d.ts +32 -0
  168. package/dist/src/tui/theme.d.ts.map +1 -0
  169. package/dist/src/tui/theme.js +61 -0
  170. package/dist/src/tui/theme.js.map +1 -0
  171. package/dist/src/types/config.d.ts +35 -0
  172. package/dist/src/types/config.d.ts.map +1 -0
  173. package/dist/src/types/config.js +14 -0
  174. package/dist/src/types/config.js.map +1 -0
  175. package/dist/src/types/index.d.ts +107 -0
  176. package/dist/src/types/index.d.ts.map +1 -0
  177. package/dist/src/types/index.js +3 -0
  178. package/dist/src/types/index.js.map +1 -0
  179. package/dist/src/utils/concurrency.d.ts +7 -0
  180. package/dist/src/utils/concurrency.d.ts.map +1 -0
  181. package/dist/src/utils/concurrency.js +26 -0
  182. package/dist/src/utils/concurrency.js.map +1 -0
  183. package/dist/src/utils/format.d.ts +2 -0
  184. package/dist/src/utils/format.d.ts.map +1 -0
  185. package/dist/src/utils/format.js +8 -0
  186. package/dist/src/utils/format.js.map +1 -0
  187. package/dist/src/utils/logger.d.ts +39 -0
  188. package/dist/src/utils/logger.d.ts.map +1 -0
  189. package/dist/src/utils/logger.js +120 -0
  190. package/dist/src/utils/logger.js.map +1 -0
  191. package/dist/src/utils/notify.d.ts +6 -0
  192. package/dist/src/utils/notify.d.ts.map +1 -0
  193. package/dist/src/utils/notify.js +15 -0
  194. package/dist/src/utils/notify.js.map +1 -0
  195. package/dist/src/utils/open-terminal.d.ts +12 -0
  196. package/dist/src/utils/open-terminal.d.ts.map +1 -0
  197. package/dist/src/utils/open-terminal.js +93 -0
  198. package/dist/src/utils/open-terminal.js.map +1 -0
  199. package/dist/src/utils/process.d.ts +14 -0
  200. package/dist/src/utils/process.d.ts.map +1 -0
  201. package/dist/src/utils/process.js +36 -0
  202. package/dist/src/utils/process.js.map +1 -0
  203. package/dist/src/utils/project-detector.d.ts +12 -0
  204. package/dist/src/utils/project-detector.d.ts.map +1 -0
  205. package/dist/src/utils/project-detector.js +123 -0
  206. package/dist/src/utils/project-detector.js.map +1 -0
  207. package/dist/src/utils/quoting.d.ts +15 -0
  208. package/dist/src/utils/quoting.d.ts.map +1 -0
  209. package/dist/src/utils/quoting.js +39 -0
  210. package/dist/src/utils/quoting.js.map +1 -0
  211. package/dist/src/utils/retry.d.ts +14 -0
  212. package/dist/src/utils/retry.d.ts.map +1 -0
  213. package/dist/src/utils/retry.js +41 -0
  214. package/dist/src/utils/retry.js.map +1 -0
  215. package/dist/src/utils/settings.d.ts +14 -0
  216. package/dist/src/utils/settings.d.ts.map +1 -0
  217. package/dist/src/utils/settings.js +21 -0
  218. package/dist/src/utils/settings.js.map +1 -0
  219. package/dist/src/utils/time.d.ts +2 -0
  220. package/dist/src/utils/time.d.ts.map +1 -0
  221. package/dist/src/utils/time.js +5 -0
  222. package/dist/src/utils/time.js.map +1 -0
  223. package/package.json +73 -0
@@ -0,0 +1,896 @@
1
+ /**
2
+ * Always-on daemon that discovers open PRs authored by the current user.
3
+ * PRs are discovered but not auto-started — the TUI controls which to run.
4
+ * Also fetches unresolved comment counts for the TUI badge.
5
+ */
6
+ import { EventEmitter } from "node:events";
7
+ import { SessionController } from "./session-controller.js";
8
+ import { CommentFetcher } from "./comment-fetcher.js";
9
+ import { GitManager } from "./git-manager.js";
10
+ import { WorktreeManager } from "./worktree-manager.js";
11
+ import { ProgressStore } from "./progress-store.js";
12
+ import { GHClient } from "../github/gh-client.js";
13
+ import { logger } from "../utils/logger.js";
14
+ import { exec } from "../utils/process.js";
15
+ import { mapWithConcurrency } from "../utils/concurrency.js";
16
+ import { RateLimitError } from "../utils/retry.js";
17
+ import { loadSettings } from "../utils/settings.js";
18
+ import { notify } from "../utils/notify.js";
19
+ import { loadRepoConfig } from "./repo-config.js";
20
+ export class Daemon extends EventEmitter {
21
+ config;
22
+ cwd;
23
+ ghClient;
24
+ worktreeManager;
25
+ progressStore;
26
+ sessions = new Map();
27
+ discoveredPRs = new Map();
28
+ commentCounts = new Map();
29
+ commentThreads = new Map();
30
+ threadCounts = new Map();
31
+ lastStates = new Map();
32
+ mergedPRs = new Map();
33
+ ciStatuses = new Map();
34
+ ciFailedChecks = new Map();
35
+ conflictStatuses = new Map();
36
+ running = false;
37
+ abortController = new AbortController();
38
+ botLogin = null;
39
+ cachedNotificationSettings = null;
40
+ isInitialDiscovery = true;
41
+ nextCheckAt = null;
42
+ skipNextSleep = false;
43
+ constructor(config, cwd) {
44
+ super();
45
+ this.config = config;
46
+ this.cwd = cwd;
47
+ this.ghClient = new GHClient(cwd);
48
+ this.worktreeManager = new WorktreeManager(cwd);
49
+ this.progressStore = new ProgressStore(cwd);
50
+ }
51
+ getProgressStore() {
52
+ return this.progressStore;
53
+ }
54
+ getSessions() {
55
+ const result = new Map();
56
+ for (const [branch, session] of this.sessions) {
57
+ if (session.controller) {
58
+ result.set(branch, session.controller);
59
+ }
60
+ }
61
+ return result;
62
+ }
63
+ getDiscoveredPRs() {
64
+ return new Map(this.discoveredPRs);
65
+ }
66
+ getCommentCounts() {
67
+ return new Map(this.commentCounts);
68
+ }
69
+ getCommentThreads() {
70
+ return new Map(this.commentThreads);
71
+ }
72
+ getThreadCounts() {
73
+ return new Map(this.threadCounts);
74
+ }
75
+ getLastStates() {
76
+ return new Map(this.lastStates);
77
+ }
78
+ getMergedPRs() {
79
+ return new Map(this.mergedPRs);
80
+ }
81
+ clearMergedPRs() {
82
+ this.mergedPRs.clear();
83
+ this.emit("prUpdate", "__merged__");
84
+ }
85
+ hasCompletedInitialDiscovery() {
86
+ return !this.isInitialDiscovery;
87
+ }
88
+ getNextCheckAt() {
89
+ return this.nextCheckAt;
90
+ }
91
+ getConfig() {
92
+ return { ...this.config };
93
+ }
94
+ updateConfig(partial) {
95
+ this.config = { ...this.config, ...partial };
96
+ logger.info(`Config updated: ${JSON.stringify(partial)}`);
97
+ // Propagate config updates to existing sessions
98
+ for (const [, session] of this.sessions) {
99
+ if (session.controller) {
100
+ session.controller.updateConfig(this.config);
101
+ }
102
+ }
103
+ this.emit("configUpdate", this.config);
104
+ }
105
+ getCIStatuses() {
106
+ return new Map(this.ciStatuses);
107
+ }
108
+ getCIFailedChecks() {
109
+ return new Map(this.ciFailedChecks);
110
+ }
111
+ getConflictStatuses() {
112
+ return new Map(this.conflictStatuses);
113
+ }
114
+ maybeNotify(title, message) {
115
+ if (this.cachedNotificationSettings === null) {
116
+ const settings = loadSettings();
117
+ this.cachedNotificationSettings = settings?.notifications ?? true;
118
+ }
119
+ if (this.cachedNotificationSettings) {
120
+ notify(title, message);
121
+ }
122
+ }
123
+ refreshNotificationSettings() {
124
+ this.cachedNotificationSettings = null;
125
+ }
126
+ isRunning(branch) {
127
+ return this.sessions.has(branch);
128
+ }
129
+ async run() {
130
+ this.running = true;
131
+ await this.progressStore.load();
132
+ await this.worktreeManager.purgeStale();
133
+ await this.ghClient.validateAuth();
134
+ const user = await this.ghClient.getCurrentUser();
135
+ this.botLogin = user;
136
+ const { owner, repo } = await this.ghClient.getRepoInfo();
137
+ logger.info(`Watching ${owner}/${repo} for open PRs by ${user}`);
138
+ while (this.running) {
139
+ try {
140
+ await this.discover();
141
+ }
142
+ catch (err) {
143
+ logger.error(`Discovery failed: ${err}`);
144
+ }
145
+ if (!this.running)
146
+ break;
147
+ // Skip sleep if refreshNow() was called (during or before discover)
148
+ if (this.skipNextSleep) {
149
+ this.skipNextSleep = false;
150
+ }
151
+ else {
152
+ this.nextCheckAt = Date.now() + this.config.pollInterval * 1000;
153
+ this.emit("discoveryComplete");
154
+ await this.cancellableSleep(this.config.pollInterval * 1000);
155
+ }
156
+ }
157
+ }
158
+ async refreshNow() {
159
+ logger.info("Manual refresh triggered");
160
+ // Don't call discover() here — just wake the main loop and let it call discover().
161
+ // This avoids redundant API calls when refreshNow() is called during sleep.
162
+ this.skipNextSleep = true;
163
+ this.abortController.abort();
164
+ this.abortController = new AbortController();
165
+ }
166
+ async startBranch(branch, mode = "once") {
167
+ if (this.sessions.has(branch))
168
+ return;
169
+ const pr = this.discoveredPRs.get(branch);
170
+ if (!pr)
171
+ return;
172
+ // Check concurrent session limit
173
+ const settings = loadSettings();
174
+ const maxConcurrentSessions = settings?.maxConcurrentSessions ?? 4;
175
+ const activeSessions = Array.from(this.sessions.values()).filter(s => s.controller !== null).length;
176
+ if (activeSessions >= maxConcurrentSessions) {
177
+ logger.warn(`Cannot start session — max concurrent sessions (${maxConcurrentSessions}) reached`, branch);
178
+ const lifetime = this.progressStore.getLifetimeStats(branch);
179
+ const totalCostUsd = lifetime.cycleHistory.reduce((sum, cycle) => sum + cycle.costUsd, 0);
180
+ const totalInputTokens = lifetime.cycleHistory.reduce((sum, cycle) => sum + (cycle.inputTokens ?? 0), 0);
181
+ const totalOutputTokens = lifetime.cycleHistory.reduce((sum, cycle) => sum + (cycle.outputTokens ?? 0), 0);
182
+ this.lastStates.set(branch, {
183
+ branch,
184
+ prNumber: pr.number,
185
+ prUrl: pr.url,
186
+ status: "error",
187
+ mode,
188
+ commentsAddressed: 0,
189
+ totalCostUsd,
190
+ error: `Cannot start session — max concurrent sessions (${maxConcurrentSessions}) reached`,
191
+ unresolvedCount: 0,
192
+ commentSummary: null,
193
+ lastPushAt: null,
194
+ claudeActivity: [],
195
+ lastSessionId: null,
196
+ workDir: null,
197
+ sessionExpiresAt: null,
198
+ ...lifetime,
199
+ totalInputTokens,
200
+ totalOutputTokens,
201
+ ciStatus: "unknown",
202
+ failedChecks: [],
203
+ ciFixAttempts: 0,
204
+ conflicted: [],
205
+ hasFixupCommits: false,
206
+ });
207
+ this.emit("prUpdate", branch);
208
+ return;
209
+ }
210
+ this.setOptimisticStatus(branch, "initializing", mode);
211
+ // Refuse to run on the branch that's currently checked out
212
+ const currentBranch = await this.getCurrentBranch();
213
+ if (currentBranch === branch) {
214
+ logger.warn("Cannot start session — branch is checked out locally. Switch to main first.", branch);
215
+ const lifetime = this.progressStore.getLifetimeStats(branch);
216
+ const totalCostUsd = lifetime.cycleHistory.reduce((sum, cycle) => sum + cycle.costUsd, 0);
217
+ const totalInputTokens = lifetime.cycleHistory.reduce((sum, cycle) => sum + (cycle.inputTokens ?? 0), 0);
218
+ const totalOutputTokens = lifetime.cycleHistory.reduce((sum, cycle) => sum + (cycle.outputTokens ?? 0), 0);
219
+ this.lastStates.set(branch, {
220
+ branch,
221
+ prNumber: pr.number,
222
+ prUrl: pr.url,
223
+ status: "error",
224
+ mode,
225
+ commentsAddressed: 0,
226
+ totalCostUsd,
227
+ totalInputTokens,
228
+ totalOutputTokens,
229
+ error: "Branch is checked out locally — switch to main first",
230
+ unresolvedCount: 0,
231
+ commentSummary: null,
232
+ lastPushAt: null,
233
+ claudeActivity: [],
234
+ lastSessionId: null,
235
+ workDir: null,
236
+ sessionExpiresAt: null,
237
+ ...lifetime,
238
+ ciStatus: "unknown",
239
+ failedChecks: [],
240
+ ciFixAttempts: 0,
241
+ conflicted: [],
242
+ hasFixupCommits: false,
243
+ });
244
+ this.emit("prUpdate", branch);
245
+ return;
246
+ }
247
+ // Clean up any worktree left from a previous errored session
248
+ await this.worktreeManager.remove(branch);
249
+ this.lastStates.delete(branch);
250
+ await this.launchSession(pr, mode);
251
+ }
252
+ async stopBranch(branch) {
253
+ this.setOptimisticStatus(branch, "stopped");
254
+ await this.teardownSession(branch);
255
+ // Cleanup saves the controller's final state (often "error" from abort),
256
+ // so force it back to "stopped"
257
+ const lastState = this.lastStates.get(branch);
258
+ if (lastState && lastState.status !== "stopped") {
259
+ lastState.status = "stopped";
260
+ lastState.error = null;
261
+ }
262
+ if (this.discoveredPRs.has(branch)) {
263
+ this.emit("prUpdate", branch);
264
+ }
265
+ }
266
+ async startAll(mode = "once") {
267
+ for (const [branch] of this.discoveredPRs) {
268
+ if (!this.sessions.has(branch)) {
269
+ await this.startBranch(branch, mode);
270
+ }
271
+ }
272
+ }
273
+ async watchBranch(branch) {
274
+ await this.startBranch(branch, "watch");
275
+ }
276
+ async watchAll() {
277
+ await this.startAll("watch");
278
+ }
279
+ async rebaseBranch(branch) {
280
+ if (this.sessions.has(branch))
281
+ return;
282
+ const pr = this.discoveredPRs.get(branch);
283
+ if (!pr)
284
+ return;
285
+ this.setOptimisticStatus(branch, "initializing");
286
+ const currentBranch = await this.getCurrentBranch();
287
+ if (currentBranch === branch) {
288
+ logger.warn("Cannot rebase — branch is checked out locally. Switch to main first.", branch);
289
+ this.lastStates.set(branch, this.makeErrorState(branch, pr, "Cannot rebase — branch is checked out locally. Switch to main first.", "once"));
290
+ this.emit("prUpdate", branch);
291
+ return;
292
+ }
293
+ await this.worktreeManager.remove(branch);
294
+ this.lastStates.delete(branch);
295
+ const repoConfig = await loadRepoConfig(this.cwd);
296
+ let workDir;
297
+ try {
298
+ workDir = await this.worktreeManager.create(branch, repoConfig.setupCommands);
299
+ }
300
+ catch (err) {
301
+ logger.error(`Failed to create worktree for ${branch}: ${err}`);
302
+ this.lastStates.set(branch, this.makeErrorState(branch, pr, `Failed to create worktree: ${err}`, "once"));
303
+ this.emit("prUpdate", branch);
304
+ return;
305
+ }
306
+ const controller = new SessionController(branch, this.config, workDir, "once", this.progressStore);
307
+ controller.on("statusChange", (b, status) => {
308
+ logger.info(`Status: ${status}`, b);
309
+ this.emit("sessionUpdate", b, controller.getState());
310
+ });
311
+ controller.on("sessionUpdate", (b, state) => {
312
+ this.emit("sessionUpdate", b, state);
313
+ });
314
+ controller.on("pushed", (b) => {
315
+ this.syncMainRepo(b).catch((err) => {
316
+ logger.debug(`Main repo sync failed for ${b}: ${err}`);
317
+ });
318
+ });
319
+ controller.on("ready", (b) => {
320
+ logger.info("Rebase session finished.", b);
321
+ const state = controller.getState();
322
+ if (state.status === "error") {
323
+ // Keep the worktree alive for manual intervention
324
+ this.lastStates.set(b, state);
325
+ this.sessions.delete(b);
326
+ this.emit("prUpdate", b);
327
+ }
328
+ else {
329
+ this.cleanupSession(b).catch((err) => {
330
+ logger.warn(`Cleanup failed for ${b}: ${err}`);
331
+ });
332
+ }
333
+ });
334
+ const promise = controller.startRebase();
335
+ this.sessions.set(branch, { controller, promise });
336
+ this.emit("sessionUpdate", branch, controller.getState());
337
+ }
338
+ /** Plain git rebase — no Claude. Reports conflicts without resolving them. */
339
+ async rebaseBranchPlain(branch) {
340
+ if (this.sessions.has(branch))
341
+ return;
342
+ const pr = this.discoveredPRs.get(branch);
343
+ if (!pr)
344
+ return;
345
+ this.setOptimisticStatus(branch, "initializing");
346
+ const currentBranch = await this.getCurrentBranch();
347
+ if (currentBranch === branch) {
348
+ logger.warn("Cannot rebase — branch is checked out locally. Switch to main first.", branch);
349
+ this.lastStates.set(branch, this.makeErrorState(branch, pr, "Cannot rebase — branch is checked out locally. Switch to main first.", "once"));
350
+ this.emit("prUpdate", branch);
351
+ return;
352
+ }
353
+ await this.worktreeManager.remove(branch);
354
+ const repoConfig = await loadRepoConfig(this.cwd);
355
+ let workDir;
356
+ try {
357
+ workDir = await this.worktreeManager.create(branch, repoConfig.setupCommands);
358
+ }
359
+ catch (err) {
360
+ logger.error(`Failed to create worktree for ${branch}: ${err}`);
361
+ this.lastStates.set(branch, this.makeErrorState(branch, pr, `Failed to create worktree: ${err}`, "once"));
362
+ this.emit("prUpdate", branch);
363
+ return;
364
+ }
365
+ // Register a placeholder session to prevent concurrent operations
366
+ const placeholderPromise = (async () => {
367
+ try {
368
+ const gitManager = new GitManager(workDir, branch);
369
+ logger.info(`Plain rebase of ${branch} onto ${pr.baseRefName}`, branch);
370
+ const ok = await gitManager.pullRebase(pr.baseRefName);
371
+ if (!ok) {
372
+ logger.warn("Rebase has conflicts — use R to rebase with Claude", branch);
373
+ // Refresh conflict status so TUI shows them
374
+ await this.updateConflictStatuses([pr]);
375
+ return;
376
+ }
377
+ const pushed = await gitManager.forcePushWithLease();
378
+ if (pushed) {
379
+ logger.info("Rebase complete, pushed", branch);
380
+ this.syncMainRepo(branch).catch(() => { });
381
+ }
382
+ else {
383
+ logger.error("Push failed after rebase", branch);
384
+ }
385
+ }
386
+ catch (err) {
387
+ logger.error(`Plain rebase failed: ${err}`, branch);
388
+ }
389
+ finally {
390
+ await this.worktreeManager.remove(branch).catch(() => { });
391
+ this.setOptimisticStatus(branch, "stopped");
392
+ this.sessions.delete(branch);
393
+ }
394
+ })();
395
+ this.sessions.set(branch, { controller: null, promise: placeholderPromise });
396
+ await placeholderPromise;
397
+ }
398
+ resolveConflicts(branch, always) {
399
+ const session = this.sessions.get(branch);
400
+ if (session) {
401
+ session.controller?.acceptConflictResolution(always);
402
+ }
403
+ }
404
+ dismissConflictResolution(branch) {
405
+ const session = this.sessions.get(branch);
406
+ if (session) {
407
+ session.controller?.dismissConflictResolution();
408
+ }
409
+ }
410
+ async stopAll() {
411
+ for (const branch of [...this.sessions.keys()]) {
412
+ await this.stopBranch(branch);
413
+ }
414
+ }
415
+ cancellableSleep(ms) {
416
+ return new Promise((resolve) => {
417
+ const signal = this.abortController.signal;
418
+ const onAbort = () => {
419
+ clearTimeout(timer);
420
+ resolve();
421
+ };
422
+ const timer = setTimeout(() => {
423
+ signal.removeEventListener("abort", onAbort);
424
+ resolve();
425
+ }, ms);
426
+ signal.addEventListener("abort", onAbort, { once: true });
427
+ });
428
+ }
429
+ async stop() {
430
+ this.running = false;
431
+ this.abortController.abort();
432
+ logger.info("Shutting down daemon...");
433
+ const branches = [...this.sessions.keys()];
434
+ for (const branch of branches) {
435
+ await this.teardownSession(branch);
436
+ }
437
+ await this.worktreeManager.cleanup();
438
+ }
439
+ async discover() {
440
+ let prs;
441
+ try {
442
+ prs = await this.ghClient.getMyOpenPRs();
443
+ }
444
+ catch (err) {
445
+ if (err instanceof RateLimitError) {
446
+ logger.warn("GitHub rate limit hit during discovery, skipping this cycle");
447
+ }
448
+ else {
449
+ logger.warn(`Failed to discover PRs: ${err}`);
450
+ }
451
+ return;
452
+ }
453
+ const activeBranches = new Set(prs.map((pr) => pr.headRefName));
454
+ if (prs.length === 0 && this.discoveredPRs.size === 0) {
455
+ logger.info("No open PRs found, waiting...");
456
+ }
457
+ for (const pr of prs) {
458
+ if (!this.discoveredPRs.has(pr.headRefName)) {
459
+ logger.info(`Discovered PR #${pr.number}: ${pr.title}`, pr.headRefName);
460
+ this.discoveredPRs.set(pr.headRefName, pr);
461
+ // Remove any stale merged entry for this branch to prevent conflicts
462
+ this.mergedPRs.delete(pr.headRefName);
463
+ this.emit("prDiscovered", pr.headRefName, pr);
464
+ // Only notify for newly discovered PRs after initial discovery to avoid flooding
465
+ if (!this.isInitialDiscovery) {
466
+ this.maybeNotify("New PR Discovered", `Found pull request #${pr.number}: ${pr.title}`);
467
+ }
468
+ }
469
+ }
470
+ // Mark that initial discovery is complete
471
+ if (this.isInitialDiscovery) {
472
+ this.isInitialDiscovery = false;
473
+ this.emit("initialDiscoveryComplete");
474
+ }
475
+ // Extract CI statuses from PR data (no extra API calls)
476
+ this.updateCIStatusesFromPRs(prs);
477
+ // Fetch comment counts and conflict statuses in parallel
478
+ await Promise.all([
479
+ this.updateCommentCounts(prs),
480
+ this.updateConflictStatuses(prs),
481
+ ]);
482
+ // Handle PRs that are no longer open (closed or merged) before checking ready statuses,
483
+ // so merged PRs are removed from discoveredPRs and don't briefly flicker to "ready"
484
+ for (const branch of [...this.discoveredPRs.keys()]) {
485
+ if (!activeBranches.has(branch)) {
486
+ const pr = this.discoveredPRs.get(branch);
487
+ let wasMerged = false;
488
+ try {
489
+ wasMerged = await this.ghClient.isPRMerged(pr.number);
490
+ }
491
+ catch {
492
+ // If we can't determine, treat as closed
493
+ }
494
+ if (wasMerged) {
495
+ logger.info(`PR #${pr.number} merged`, branch);
496
+ this.mergedPRs.set(branch, { pr, mergedAt: Date.now() });
497
+ this.maybeNotify("PR Merged", `Pull request #${pr.number} (${pr.title}) has been merged!`);
498
+ }
499
+ // Now remove from discovered PRs and clean up
500
+ this.discoveredPRs.delete(branch);
501
+ this.commentCounts.delete(branch);
502
+ this.commentThreads.delete(branch);
503
+ this.threadCounts.delete(branch);
504
+ this.ciStatuses.delete(branch);
505
+ this.ciFailedChecks.delete(branch);
506
+ this.conflictStatuses.delete(branch);
507
+ try {
508
+ await this.teardownSession(branch);
509
+ }
510
+ catch (err) {
511
+ logger.warn(`Failed to teardown session for ${branch}: ${err}`);
512
+ }
513
+ // Emit appropriate event
514
+ if (wasMerged) {
515
+ this.emit("prMerged", branch);
516
+ }
517
+ else {
518
+ logger.info(`PR closed, removing`, branch);
519
+ this.emit("prRemoved", branch);
520
+ }
521
+ }
522
+ }
523
+ // Check ready statuses after merged PRs have been removed from discoveredPRs
524
+ this.updateReadyStatuses();
525
+ }
526
+ async updateCommentCounts(prs) {
527
+ if (!this.botLogin)
528
+ return;
529
+ await mapWithConcurrency(prs, 3, async (pr) => {
530
+ try {
531
+ const fetcher = new CommentFetcher(this.ghClient, pr.number, this.botLogin, pr.headRefName);
532
+ const { comments: fetched, threadCounts } = await fetcher.fetchWithCounts();
533
+ const count = fetched.length;
534
+ const prev = this.commentCounts.get(pr.headRefName) ?? -1;
535
+ this.commentCounts.set(pr.headRefName, count);
536
+ this.threadCounts.set(pr.headRefName, threadCounts);
537
+ this.commentThreads.set(pr.headRefName, fetched.map((f) => f.thread));
538
+ if (count !== prev) {
539
+ this.emit("commentCountUpdate", pr.headRefName, count);
540
+ }
541
+ }
542
+ catch (err) {
543
+ if (err instanceof RateLimitError) {
544
+ logger.warn("GitHub rate limit hit while fetching comments, stopping comment updates");
545
+ throw err;
546
+ }
547
+ logger.debug(`Failed to fetch comments for ${pr.headRefName}: ${err}`);
548
+ }
549
+ }).catch((err) => {
550
+ if (err instanceof RateLimitError)
551
+ return;
552
+ throw err;
553
+ });
554
+ }
555
+ async launchSession(pr, mode = "once") {
556
+ const branch = pr.headRefName;
557
+ if (this.config.dryRun) {
558
+ logger.info(`[DRY RUN] Would start watching PR #${pr.number}`, branch);
559
+ return;
560
+ }
561
+ // Load repo config early so we can pass setup commands to worktree creation
562
+ const repoConfig = await loadRepoConfig(this.cwd);
563
+ let workDir;
564
+ try {
565
+ workDir = await this.worktreeManager.create(branch, repoConfig.setupCommands);
566
+ }
567
+ catch (err) {
568
+ logger.error(`Failed to create worktree for ${branch}: ${err}`);
569
+ const lifetime = this.progressStore.getLifetimeStats(branch);
570
+ const totalCostUsd = lifetime.cycleHistory.reduce((sum, cycle) => sum + cycle.costUsd, 0);
571
+ const totalInputTokens = lifetime.cycleHistory.reduce((sum, cycle) => sum + (cycle.inputTokens ?? 0), 0);
572
+ const totalOutputTokens = lifetime.cycleHistory.reduce((sum, cycle) => sum + (cycle.outputTokens ?? 0), 0);
573
+ this.lastStates.set(branch, {
574
+ branch,
575
+ prNumber: pr.number,
576
+ prUrl: pr.url,
577
+ status: "error",
578
+ mode,
579
+ commentsAddressed: 0,
580
+ totalCostUsd,
581
+ totalInputTokens,
582
+ totalOutputTokens,
583
+ error: `Failed to create worktree: ${err}`,
584
+ unresolvedCount: 0,
585
+ commentSummary: null,
586
+ lastPushAt: null,
587
+ claudeActivity: [],
588
+ lastSessionId: null,
589
+ workDir: null,
590
+ sessionExpiresAt: null,
591
+ ...lifetime,
592
+ ciStatus: "unknown",
593
+ failedChecks: [],
594
+ ciFixAttempts: 0,
595
+ conflicted: [],
596
+ hasFixupCommits: false,
597
+ });
598
+ this.emit("prUpdate", branch);
599
+ return;
600
+ }
601
+ const controller = new SessionController(branch, this.config, workDir, mode, this.progressStore);
602
+ controller.on("statusChange", (b, status) => {
603
+ logger.info(`Status: ${status}`, b);
604
+ this.emit("sessionUpdate", b, controller.getState());
605
+ });
606
+ controller.on("sessionUpdate", (b, state) => {
607
+ this.emit("sessionUpdate", b, state);
608
+ });
609
+ controller.on("pushed", (b) => {
610
+ this.syncMainRepo(b).catch((err) => {
611
+ logger.debug(`Main repo sync failed for ${b}: ${err}`);
612
+ });
613
+ });
614
+ controller.on("ready", (b) => {
615
+ logger.info("Session finished.", b);
616
+ const state = controller.getState();
617
+ if (state.status === "error") {
618
+ // Keep the worktree alive for manual intervention
619
+ this.lastStates.set(b, state);
620
+ this.sessions.delete(b);
621
+ this.emit("prUpdate", b);
622
+ this.maybeNotify("Session Failed", `Session for branch ${b} failed: ${state.error || "Unknown error"}`);
623
+ }
624
+ else {
625
+ // Notify on successful completion
626
+ const commentsAddressed = state.commentsAddressed || 0;
627
+ if (commentsAddressed > 0) {
628
+ this.maybeNotify("Session Complete", `Successfully addressed ${commentsAddressed} comment(s) on ${b}`);
629
+ }
630
+ else {
631
+ this.maybeNotify("Session Complete", `Session completed for ${b}`);
632
+ }
633
+ this.cleanupSession(b).catch((err) => {
634
+ logger.warn(`Cleanup failed for ${b}: ${err}`);
635
+ });
636
+ }
637
+ });
638
+ const promise = controller.start();
639
+ this.sessions.set(branch, { controller, promise });
640
+ this.emit("sessionUpdate", branch, controller.getState());
641
+ }
642
+ async teardownSession(branch) {
643
+ const session = this.sessions.get(branch);
644
+ if (!session)
645
+ return;
646
+ session.controller?.stop();
647
+ await session.promise.catch(() => { });
648
+ await this.cleanupSession(branch);
649
+ }
650
+ async cleanupSession(branch) {
651
+ const session = this.sessions.get(branch);
652
+ if (session) {
653
+ if (session.controller) {
654
+ this.lastStates.set(branch, session.controller.getState());
655
+ }
656
+ }
657
+ this.sessions.delete(branch);
658
+ await this.worktreeManager.remove(branch);
659
+ // Refresh CI status for this branch since it was skipped during active session
660
+ const pr = this.discoveredPRs.get(branch);
661
+ if (pr) {
662
+ this.updateCIStatusesFromPRs([pr]);
663
+ }
664
+ // Check if this branch is now ready to merge
665
+ this.updateReadyStatuses();
666
+ if (this.discoveredPRs.has(branch)) {
667
+ this.emit("prUpdate", branch);
668
+ }
669
+ else {
670
+ this.emit("prRemoved", branch);
671
+ }
672
+ }
673
+ setOptimisticStatus(branch, status, mode = "once") {
674
+ const pr = this.discoveredPRs.get(branch);
675
+ if (!pr)
676
+ return;
677
+ const lifetime = this.progressStore.getLifetimeStats(branch);
678
+ const totalCostUsd = lifetime.cycleHistory.reduce((sum, c) => sum + c.costUsd, 0);
679
+ const totalInputTokens = lifetime.cycleHistory.reduce((sum, c) => sum + (c.inputTokens ?? 0), 0);
680
+ const totalOutputTokens = lifetime.cycleHistory.reduce((sum, c) => sum + (c.outputTokens ?? 0), 0);
681
+ const prev = this.lastStates.get(branch);
682
+ this.lastStates.set(branch, {
683
+ branch,
684
+ prNumber: pr.number,
685
+ prUrl: pr.url,
686
+ status,
687
+ mode,
688
+ commentsAddressed: prev?.commentsAddressed ?? 0,
689
+ totalCostUsd,
690
+ totalInputTokens,
691
+ totalOutputTokens,
692
+ error: null,
693
+ unresolvedCount: prev?.unresolvedCount ?? 0,
694
+ commentSummary: prev?.commentSummary ?? null,
695
+ lastPushAt: prev?.lastPushAt ?? null,
696
+ claudeActivity: [],
697
+ lastSessionId: prev?.lastSessionId ?? null,
698
+ workDir: prev?.workDir ?? null,
699
+ ...lifetime,
700
+ ciStatus: prev?.ciStatus ?? "unknown",
701
+ failedChecks: prev?.failedChecks ?? [],
702
+ ciFixAttempts: prev?.ciFixAttempts ?? 0,
703
+ conflicted: prev?.conflicted ?? [],
704
+ hasFixupCommits: prev?.hasFixupCommits ?? false,
705
+ sessionExpiresAt: prev?.sessionExpiresAt ?? null,
706
+ });
707
+ this.emit("prUpdate", branch);
708
+ }
709
+ makeErrorState(branch, pr, error, mode = "once") {
710
+ const lifetime = this.progressStore.getLifetimeStats(branch);
711
+ const totalCostUsd = lifetime.cycleHistory.reduce((sum, c) => sum + c.costUsd, 0);
712
+ const totalInputTokens = lifetime.cycleHistory.reduce((sum, c) => sum + (c.inputTokens ?? 0), 0);
713
+ const totalOutputTokens = lifetime.cycleHistory.reduce((sum, c) => sum + (c.outputTokens ?? 0), 0);
714
+ return {
715
+ branch,
716
+ prNumber: pr.number,
717
+ prUrl: pr.url,
718
+ status: "error",
719
+ mode,
720
+ commentsAddressed: 0,
721
+ totalCostUsd,
722
+ totalInputTokens,
723
+ totalOutputTokens,
724
+ error,
725
+ unresolvedCount: 0,
726
+ commentSummary: null,
727
+ lastPushAt: null,
728
+ claudeActivity: [],
729
+ lastSessionId: null,
730
+ workDir: this.cwd,
731
+ ...lifetime,
732
+ ciStatus: "unknown",
733
+ failedChecks: [],
734
+ ciFixAttempts: 0,
735
+ conflicted: [],
736
+ hasFixupCommits: false,
737
+ sessionExpiresAt: null,
738
+ };
739
+ }
740
+ async getCurrentBranch() {
741
+ try {
742
+ const { stdout } = await exec("git", ["branch", "--show-current"], { cwd: this.cwd });
743
+ return stdout.trim() || null;
744
+ }
745
+ catch {
746
+ return null;
747
+ }
748
+ }
749
+ /** Extract CI check statuses from PR data (embedded in the discovery query). */
750
+ updateCIStatusesFromPRs(prs) {
751
+ for (const pr of prs) {
752
+ const commitNode = pr.commits?.nodes?.[0];
753
+ const rollup = commitNode?.commit?.statusCheckRollup;
754
+ if (!rollup) {
755
+ this.updateCIStatus(pr.headRefName, "unknown", []);
756
+ continue;
757
+ }
758
+ const checks = rollup.contexts.nodes.filter((n) => n.name);
759
+ if (checks.length === 0) {
760
+ this.updateCIStatus(pr.headRefName, "unknown", []);
761
+ continue;
762
+ }
763
+ const allCompleted = checks.every((c) => c.status?.toUpperCase() === "COMPLETED");
764
+ if (!allCompleted) {
765
+ this.updateCIStatus(pr.headRefName, "pending", []);
766
+ continue;
767
+ }
768
+ const passing = new Set(["SUCCESS", "NEUTRAL", "SKIPPED"]);
769
+ const failed = checks.filter((c) => !passing.has(c.conclusion?.toUpperCase() ?? ""));
770
+ if (failed.length === 0) {
771
+ this.updateCIStatus(pr.headRefName, "passing", []);
772
+ }
773
+ else {
774
+ const failedChecks = failed.map((c) => ({
775
+ id: c.databaseId ?? 0,
776
+ name: c.name,
777
+ htmlUrl: c.detailsUrl ?? "",
778
+ logSnippet: null,
779
+ }));
780
+ this.updateCIStatus(pr.headRefName, "failing", failedChecks);
781
+ }
782
+ }
783
+ }
784
+ updateCIStatus(branch, status, failedChecks) {
785
+ const prev = this.ciStatuses.get(branch);
786
+ this.ciStatuses.set(branch, status);
787
+ this.ciFailedChecks.set(branch, failedChecks);
788
+ if (status !== prev) {
789
+ this.emit("ciStatusUpdate", branch, status, failedChecks);
790
+ }
791
+ }
792
+ /** Set status to "ready" for branches with CI passing and 0 unresolved comments. */
793
+ updateReadyStatuses() {
794
+ for (const [branch] of this.discoveredPRs) {
795
+ if (this.sessions.has(branch))
796
+ continue;
797
+ const ci = this.ciStatuses.get(branch);
798
+ if (ci !== "passing")
799
+ continue;
800
+ const unresolvedCount = this.commentCounts.get(branch) ?? 0;
801
+ if (unresolvedCount > 0)
802
+ continue;
803
+ const lastState = this.lastStates.get(branch);
804
+ const currentStatus = lastState?.status;
805
+ if (currentStatus !== "stopped" && currentStatus !== "watching")
806
+ continue;
807
+ lastState.status = "ready";
808
+ this.emit("prUpdate", branch);
809
+ }
810
+ }
811
+ /** Detect merge conflicts with base branch for all discovered PRs not actively running. */
812
+ async updateConflictStatuses(prs) {
813
+ if (prs.length === 0)
814
+ return;
815
+ // Batch unique refs to fetch only once to prevent git lock contention
816
+ const allRefs = new Set();
817
+ for (const pr of prs) {
818
+ allRefs.add(pr.baseRefName);
819
+ allRefs.add(pr.headRefName);
820
+ }
821
+ // Perform a single fetch operation for all refs
822
+ try {
823
+ await exec("git", ["fetch", "origin", ...Array.from(allRefs)], {
824
+ cwd: this.cwd,
825
+ allowFailure: true,
826
+ });
827
+ }
828
+ catch (err) {
829
+ logger.debug(`Failed to fetch refs: ${err}`);
830
+ }
831
+ // Check conflicts for each PR
832
+ await Promise.all(prs.map(async (pr) => {
833
+ try {
834
+ const { stdout, exitCode } = await exec("git", [
835
+ "merge-tree", "--write-tree", `origin/${pr.headRefName}`, `origin/${pr.baseRefName}`,
836
+ ], { cwd: this.cwd, allowFailure: true });
837
+ const conflictPaths = [];
838
+ if (exitCode !== 0) {
839
+ for (const line of stdout.split("\n")) {
840
+ const match = line.match(/^CONFLICT \([^)]+\): Merge conflict in (.+)$/);
841
+ if (match) {
842
+ conflictPaths.push(match[1]);
843
+ }
844
+ }
845
+ if (conflictPaths.length === 0) {
846
+ conflictPaths.push("(unknown)");
847
+ }
848
+ }
849
+ const prev = this.conflictStatuses.get(pr.headRefName);
850
+ this.conflictStatuses.set(pr.headRefName, conflictPaths);
851
+ const changed = !prev || prev.length !== conflictPaths.length ||
852
+ prev.some((p, i) => p !== conflictPaths[i]);
853
+ // Propagate to active sessions in passive states (watching/stopped)
854
+ // so the TUI picks up daemon-polled conflict data.
855
+ // This must happen even when `changed` is false, since conflicts may have
856
+ // been detected while the session was in an active state (fixing/pushing).
857
+ const session = this.sessions.get(pr.headRefName);
858
+ if (session?.controller) {
859
+ const state = session.controller.getState();
860
+ if (state.status === "watching" || state.status === "stopped") {
861
+ const controllerConflicts = state.conflicted;
862
+ const needsSync = controllerConflicts.length !== conflictPaths.length ||
863
+ controllerConflicts.some((p, i) => p !== conflictPaths[i]);
864
+ if (needsSync) {
865
+ session.controller.setConflicted(conflictPaths);
866
+ }
867
+ }
868
+ }
869
+ if (changed) {
870
+ this.emit("conflictStatusUpdate", pr.headRefName, conflictPaths);
871
+ }
872
+ }
873
+ catch (err) {
874
+ logger.debug(`Failed to check conflicts for ${pr.headRefName}: ${err}`);
875
+ }
876
+ }));
877
+ }
878
+ /** Fetch the pushed branch and update the local ref so git log stays current. */
879
+ async syncMainRepo(branch) {
880
+ await exec("git", ["fetch", "origin", branch], { cwd: this.cwd });
881
+ // Update the local branch ref to match the remote
882
+ // (safe because we block starting sessions for the checked-out branch)
883
+ await exec("git", ["branch", "-f", branch, `origin/${branch}`], {
884
+ cwd: this.cwd,
885
+ allowFailure: true,
886
+ });
887
+ logger.info("Synced local branch ref with remote", branch);
888
+ // Push implies conflicts are resolved — clear stale status immediately
889
+ const prev = this.conflictStatuses.get(branch);
890
+ if (prev && prev.length > 0) {
891
+ this.conflictStatuses.set(branch, []);
892
+ this.emit("conflictStatusUpdate", branch, []);
893
+ }
894
+ }
895
+ }
896
+ //# sourceMappingURL=daemon.js.map