@atercates/claude-deck 0.2.1

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 (293) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +123 -0
  3. package/app/api/claude/hidden/route.ts +66 -0
  4. package/app/api/claude/projects/[name]/sessions/route.ts +71 -0
  5. package/app/api/claude/projects/route.ts +44 -0
  6. package/app/api/code-search/available/route.ts +12 -0
  7. package/app/api/code-search/route.ts +47 -0
  8. package/app/api/dev-servers/[id]/logs/route.ts +23 -0
  9. package/app/api/dev-servers/[id]/restart/route.ts +20 -0
  10. package/app/api/dev-servers/[id]/route.ts +51 -0
  11. package/app/api/dev-servers/[id]/stop/route.ts +20 -0
  12. package/app/api/dev-servers/detect/route.ts +39 -0
  13. package/app/api/dev-servers/route.ts +48 -0
  14. package/app/api/exec/route.ts +60 -0
  15. package/app/api/files/content/route.ts +76 -0
  16. package/app/api/files/route.ts +37 -0
  17. package/app/api/files/upload-temp/route.ts +41 -0
  18. package/app/api/git/check/route.ts +54 -0
  19. package/app/api/git/clone/route.ts +99 -0
  20. package/app/api/git/commit/route.ts +75 -0
  21. package/app/api/git/discard/route.ts +38 -0
  22. package/app/api/git/file-content/route.ts +64 -0
  23. package/app/api/git/history/[hash]/diff/route.ts +38 -0
  24. package/app/api/git/history/[hash]/route.ts +34 -0
  25. package/app/api/git/history/route.ts +27 -0
  26. package/app/api/git/multi-status/route.ts +46 -0
  27. package/app/api/git/pr/route.ts +164 -0
  28. package/app/api/git/push/route.ts +64 -0
  29. package/app/api/git/stage/route.ts +40 -0
  30. package/app/api/git/status/route.ts +51 -0
  31. package/app/api/git/unstage/route.ts +46 -0
  32. package/app/api/groups/[...path]/route.ts +136 -0
  33. package/app/api/groups/route.ts +93 -0
  34. package/app/api/orchestrate/spawn/route.ts +45 -0
  35. package/app/api/orchestrate/workers/[id]/route.ts +89 -0
  36. package/app/api/orchestrate/workers/route.ts +31 -0
  37. package/app/api/projects/[id]/detect/route.ts +27 -0
  38. package/app/api/projects/[id]/dev-servers/[dsId]/route.ts +66 -0
  39. package/app/api/projects/[id]/dev-servers/route.ts +51 -0
  40. package/app/api/projects/[id]/repositories/[repoId]/route.ts +67 -0
  41. package/app/api/projects/[id]/repositories/route.ts +74 -0
  42. package/app/api/projects/[id]/route.ts +108 -0
  43. package/app/api/projects/detect/route.ts +33 -0
  44. package/app/api/projects/route.ts +59 -0
  45. package/app/api/sessions/[id]/claude-session/route.ts +42 -0
  46. package/app/api/sessions/[id]/fork/route.ts +74 -0
  47. package/app/api/sessions/[id]/mcp-config/route.ts +34 -0
  48. package/app/api/sessions/[id]/messages/route.ts +60 -0
  49. package/app/api/sessions/[id]/pr/route.ts +188 -0
  50. package/app/api/sessions/[id]/preview/route.ts +42 -0
  51. package/app/api/sessions/[id]/route.ts +229 -0
  52. package/app/api/sessions/[id]/send-keys/route.ts +119 -0
  53. package/app/api/sessions/[id]/summarize/route.ts +331 -0
  54. package/app/api/sessions/init-script/route.ts +84 -0
  55. package/app/api/sessions/route.ts +209 -0
  56. package/app/api/sessions/status/route.ts +237 -0
  57. package/app/api/system/route.ts +9 -0
  58. package/app/api/tmux/kill-all/route.ts +57 -0
  59. package/app/api/tmux/rename/route.ts +30 -0
  60. package/app/globals.css +174 -0
  61. package/app/icon.svg +11 -0
  62. package/app/layout.tsx +122 -0
  63. package/app/page.tsx +629 -0
  64. package/components/ChatMessage.tsx +65 -0
  65. package/components/ChatView.tsx +276 -0
  66. package/components/ClaudeProjects/ClaudeProjectCard.tsx +195 -0
  67. package/components/ClaudeProjects/ClaudeProjectsSection.tsx +89 -0
  68. package/components/ClaudeProjects/ClaudeSessionCard.tsx +100 -0
  69. package/components/ClaudeProjects/index.ts +1 -0
  70. package/components/CodeSearch/CodeSearchResults.tsx +177 -0
  71. package/components/ConductorPanel.tsx +256 -0
  72. package/components/DevServers/DevServerCard.tsx +311 -0
  73. package/components/DevServers/DevServersSection.tsx +91 -0
  74. package/components/DevServers/ServerLogsModal.tsx +151 -0
  75. package/components/DevServers/StartServerDialog.tsx +359 -0
  76. package/components/DevServers/index.ts +4 -0
  77. package/components/DiffViewer/DiffModal.tsx +151 -0
  78. package/components/DiffViewer/UnifiedDiff.tsx +185 -0
  79. package/components/DiffViewer/index.tsx +2 -0
  80. package/components/DirectoryPicker.tsx +355 -0
  81. package/components/FileExplorer/FileEditor.tsx +276 -0
  82. package/components/FileExplorer/FileTabs.tsx +118 -0
  83. package/components/FileExplorer/FileTree.tsx +214 -0
  84. package/components/FileExplorer/HtmlRenderer.tsx +16 -0
  85. package/components/FileExplorer/MarkdownRenderer.tsx +18 -0
  86. package/components/FileExplorer/index.tsx +520 -0
  87. package/components/FilePicker.tsx +339 -0
  88. package/components/FolderPicker.tsx +201 -0
  89. package/components/GitDrawer/FileEditDialog.tsx +400 -0
  90. package/components/GitDrawer/index.tsx +464 -0
  91. package/components/GitPanel/CommitForm.tsx +205 -0
  92. package/components/GitPanel/CommitHistory.tsx +174 -0
  93. package/components/GitPanel/CommitItem.tsx +196 -0
  94. package/components/GitPanel/FileChanges.tsx +414 -0
  95. package/components/GitPanel/GitPanelTabs.tsx +39 -0
  96. package/components/GitPanel/index.tsx +817 -0
  97. package/components/MessageInput.tsx +82 -0
  98. package/components/NewClaudeSessionDialog.tsx +166 -0
  99. package/components/NewSessionDialog/AdvancedSettings.tsx +78 -0
  100. package/components/NewSessionDialog/AgentSelector.tsx +37 -0
  101. package/components/NewSessionDialog/CreatingOverlay.tsx +94 -0
  102. package/components/NewSessionDialog/NewSessionDialog.types.ts +136 -0
  103. package/components/NewSessionDialog/ProjectSelector.tsx +146 -0
  104. package/components/NewSessionDialog/WorkingDirectoryInput.tsx +55 -0
  105. package/components/NewSessionDialog/WorktreeSection.tsx +92 -0
  106. package/components/NewSessionDialog/hooks/useNewSessionForm.ts +370 -0
  107. package/components/NewSessionDialog/index.tsx +106 -0
  108. package/components/NotificationSettings.tsx +127 -0
  109. package/components/PRCreationModal.tsx +272 -0
  110. package/components/Pane/DesktopTabBar.tsx +353 -0
  111. package/components/Pane/MobileTabBar.tsx +210 -0
  112. package/components/Pane/OpenInVSCode.tsx +69 -0
  113. package/components/Pane/PaneSkeletons.tsx +57 -0
  114. package/components/Pane/index.tsx +558 -0
  115. package/components/PaneLayout.tsx +60 -0
  116. package/components/Projects/DevServersSection.tsx +140 -0
  117. package/components/Projects/DirectoryField.tsx +92 -0
  118. package/components/Projects/NewProjectDialog.tsx +188 -0
  119. package/components/Projects/NewProjectDialog.types.ts +46 -0
  120. package/components/Projects/ProjectCard.tsx +276 -0
  121. package/components/Projects/ProjectSettingsDialog.tsx +811 -0
  122. package/components/Projects/hooks/useNewProjectForm.ts +249 -0
  123. package/components/Projects/index.ts +3 -0
  124. package/components/Providers.tsx +49 -0
  125. package/components/QuickSwitcher.tsx +306 -0
  126. package/components/SessionList/KillAllConfirm.tsx +46 -0
  127. package/components/SessionList/SelectionToolbar.tsx +164 -0
  128. package/components/SessionList/SessionList.types.ts +37 -0
  129. package/components/SessionList/SessionListHeader.tsx +71 -0
  130. package/components/SessionList/hooks/useSessionListMutations.ts +269 -0
  131. package/components/SessionList/index.tsx +189 -0
  132. package/components/ShellDrawer/index.tsx +106 -0
  133. package/components/SidebarFooter.tsx +55 -0
  134. package/components/Terminal/KeybarToggleButton.tsx +45 -0
  135. package/components/Terminal/ScrollToBottomButton.tsx +32 -0
  136. package/components/Terminal/SearchBar.tsx +71 -0
  137. package/components/Terminal/TerminalToolbar.tsx +551 -0
  138. package/components/Terminal/VirtualKeyboard.tsx +711 -0
  139. package/components/Terminal/constants.ts +20 -0
  140. package/components/Terminal/hooks/index.ts +5 -0
  141. package/components/Terminal/hooks/resize-handlers.ts +140 -0
  142. package/components/Terminal/hooks/terminal-init.ts +151 -0
  143. package/components/Terminal/hooks/touch-scroll.ts +155 -0
  144. package/components/Terminal/hooks/useTerminalConnection.ts +282 -0
  145. package/components/Terminal/hooks/useTerminalConnection.types.ts +39 -0
  146. package/components/Terminal/hooks/useTerminalSearch.ts +103 -0
  147. package/components/Terminal/hooks/websocket-connection.ts +274 -0
  148. package/components/Terminal/index.tsx +320 -0
  149. package/components/ThemeToggle.tsx +168 -0
  150. package/components/TmuxSessions.tsx +132 -0
  151. package/components/ToolCallDisplay.tsx +71 -0
  152. package/components/WorkerCard.tsx +245 -0
  153. package/components/a/ABadge.tsx +115 -0
  154. package/components/a/AButton.tsx +163 -0
  155. package/components/a/ADialog.tsx +93 -0
  156. package/components/a/ADropdownMenu.tsx +279 -0
  157. package/components/a/AIconButton.tsx +190 -0
  158. package/components/a/ASheet.tsx +150 -0
  159. package/components/a/ATooltip.tsx +77 -0
  160. package/components/a/index.ts +64 -0
  161. package/components/mobile/SwipeSidebar.tsx +122 -0
  162. package/components/ui/badge.tsx +41 -0
  163. package/components/ui/button.tsx +60 -0
  164. package/components/ui/context-menu.tsx +197 -0
  165. package/components/ui/dialog.tsx +143 -0
  166. package/components/ui/dropdown-menu.tsx +257 -0
  167. package/components/ui/input.tsx +21 -0
  168. package/components/ui/scroll-area.tsx +52 -0
  169. package/components/ui/select.tsx +159 -0
  170. package/components/ui/skeleton.tsx +111 -0
  171. package/components/ui/switch.tsx +31 -0
  172. package/components/ui/textarea.tsx +21 -0
  173. package/components/ui/tooltip.tsx +32 -0
  174. package/components/views/DesktopView.tsx +244 -0
  175. package/components/views/MobileView.tsx +110 -0
  176. package/components/views/types.ts +75 -0
  177. package/contexts/PaneContext.tsx +336 -0
  178. package/data/claude/index.ts +9 -0
  179. package/data/claude/keys.ts +6 -0
  180. package/data/claude/queries.ts +120 -0
  181. package/data/claude/useClaudeUpdates.ts +37 -0
  182. package/data/code-search/index.ts +2 -0
  183. package/data/code-search/keys.ts +7 -0
  184. package/data/code-search/queries.ts +61 -0
  185. package/data/dev-servers/index.ts +8 -0
  186. package/data/dev-servers/keys.ts +4 -0
  187. package/data/dev-servers/queries.ts +104 -0
  188. package/data/files/index.ts +3 -0
  189. package/data/files/keys.ts +4 -0
  190. package/data/files/queries.ts +25 -0
  191. package/data/git/keys.ts +15 -0
  192. package/data/git/queries.ts +395 -0
  193. package/data/groups/index.ts +1 -0
  194. package/data/groups/mutations.ts +95 -0
  195. package/data/projects/index.ts +10 -0
  196. package/data/projects/keys.ts +4 -0
  197. package/data/projects/queries.ts +193 -0
  198. package/data/repositories/index.ts +7 -0
  199. package/data/repositories/keys.ts +5 -0
  200. package/data/repositories/queries.ts +122 -0
  201. package/data/sessions/index.ts +12 -0
  202. package/data/sessions/keys.ts +8 -0
  203. package/data/sessions/queries.ts +218 -0
  204. package/data/statuses/index.ts +1 -0
  205. package/data/statuses/queries.ts +69 -0
  206. package/hooks/useCopyToClipboard.ts +48 -0
  207. package/hooks/useDevServersManager.ts +73 -0
  208. package/hooks/useDirectoryBrowser.ts +90 -0
  209. package/hooks/useDrawerAnimation.ts +27 -0
  210. package/hooks/useFileDrop.ts +87 -0
  211. package/hooks/useFileEditor.ts +184 -0
  212. package/hooks/useGroups.ts +37 -0
  213. package/hooks/useHomePath.ts +34 -0
  214. package/hooks/useKeyRepeat.ts +55 -0
  215. package/hooks/useKeybarVisibility.ts +42 -0
  216. package/hooks/useNotifications.ts +257 -0
  217. package/hooks/useProjects.ts +53 -0
  218. package/hooks/useSessionStatuses.ts +30 -0
  219. package/hooks/useSessions.ts +86 -0
  220. package/hooks/useSpeechRecognition.ts +124 -0
  221. package/hooks/useViewport.ts +32 -0
  222. package/hooks/useViewportHeight.ts +50 -0
  223. package/lib/async-operations.ts +35 -0
  224. package/lib/banner.ts +81 -0
  225. package/lib/claude/jsonl-cache.ts +86 -0
  226. package/lib/claude/jsonl-reader.ts +271 -0
  227. package/lib/claude/process-manager.ts +278 -0
  228. package/lib/claude/stream-parser.ts +173 -0
  229. package/lib/claude/types.ts +154 -0
  230. package/lib/claude/watcher.ts +71 -0
  231. package/lib/client/session-registry.ts +111 -0
  232. package/lib/code-search.ts +121 -0
  233. package/lib/db/index.ts +48 -0
  234. package/lib/db/migrations.ts +45 -0
  235. package/lib/db/queries.ts +460 -0
  236. package/lib/db/schema.ts +114 -0
  237. package/lib/db/types.ts +92 -0
  238. package/lib/db.ts +2 -0
  239. package/lib/dev-servers.ts +509 -0
  240. package/lib/diff-parser.ts +221 -0
  241. package/lib/env-setup.ts +285 -0
  242. package/lib/file-upload.ts +34 -0
  243. package/lib/file-utils.ts +50 -0
  244. package/lib/files.ts +207 -0
  245. package/lib/git-history.ts +294 -0
  246. package/lib/git-status.ts +391 -0
  247. package/lib/git.ts +257 -0
  248. package/lib/mcp-config.ts +81 -0
  249. package/lib/multi-repo-git.ts +179 -0
  250. package/lib/notifications.ts +219 -0
  251. package/lib/orchestration.ts +448 -0
  252. package/lib/panes.ts +232 -0
  253. package/lib/ports.ts +97 -0
  254. package/lib/pr-generation.ts +307 -0
  255. package/lib/pr.ts +234 -0
  256. package/lib/projects.ts +578 -0
  257. package/lib/providers/registry.ts +70 -0
  258. package/lib/providers.ts +121 -0
  259. package/lib/query-client.ts +14 -0
  260. package/lib/rangeSelectionUtils.ts +65 -0
  261. package/lib/status-detector.ts +375 -0
  262. package/lib/terminal-themes.ts +265 -0
  263. package/lib/theme-config.ts +327 -0
  264. package/lib/utils.ts +6 -0
  265. package/lib/worktrees.ts +262 -0
  266. package/mcp/orchestration-server.ts +438 -0
  267. package/package.json +139 -0
  268. package/postcss.config.mjs +7 -0
  269. package/public/icon.svg +10 -0
  270. package/public/icons/icon-128x128.png +0 -0
  271. package/public/icons/icon-144x144.png +0 -0
  272. package/public/icons/icon-152x152.png +0 -0
  273. package/public/icons/icon-192x192.png +0 -0
  274. package/public/icons/icon-384x384.png +0 -0
  275. package/public/icons/icon-512x512.png +0 -0
  276. package/public/icons/icon-72x72.png +0 -0
  277. package/public/icons/icon-96x96.png +0 -0
  278. package/public/manifest.json +61 -0
  279. package/public/sw.js +64 -0
  280. package/scripts/agent-os +91 -0
  281. package/scripts/install.sh +48 -0
  282. package/scripts/lib/ai-clis.sh +132 -0
  283. package/scripts/lib/commands.sh +487 -0
  284. package/scripts/lib/common.sh +89 -0
  285. package/scripts/lib/prerequisites.sh +462 -0
  286. package/scripts/setup.sh +134 -0
  287. package/server.ts +155 -0
  288. package/stores/fileOpen.ts +26 -0
  289. package/stores/index.ts +1 -0
  290. package/stores/initialPrompt.ts +24 -0
  291. package/stores/sessionSelection.ts +48 -0
  292. package/styles/themes.css +603 -0
  293. package/tsconfig.json +33 -0
@@ -0,0 +1,219 @@
1
+ // Notification utilities for ClaudeDeck
2
+
3
+ export type NotificationEvent = "waiting" | "error" | "completed";
4
+
5
+ export interface NotificationSettings {
6
+ enabled: boolean;
7
+ browserNotifications: boolean;
8
+ sound: boolean;
9
+ events: {
10
+ waiting: boolean;
11
+ error: boolean;
12
+ completed: boolean;
13
+ };
14
+ }
15
+
16
+ export const defaultSettings: NotificationSettings = {
17
+ enabled: true,
18
+ browserNotifications: true,
19
+ sound: true,
20
+ events: {
21
+ waiting: true,
22
+ error: true,
23
+ completed: false, // Off by default - can be noisy
24
+ },
25
+ };
26
+
27
+ const SETTINGS_KEY = "agentosNotificationSettings";
28
+
29
+ export function loadSettings(): NotificationSettings {
30
+ if (typeof window === "undefined") return defaultSettings;
31
+ try {
32
+ const stored = localStorage.getItem(SETTINGS_KEY);
33
+ if (stored) {
34
+ return { ...defaultSettings, ...JSON.parse(stored) };
35
+ }
36
+ } catch {
37
+ // Ignore parse errors
38
+ }
39
+ return defaultSettings;
40
+ }
41
+
42
+ export function saveSettings(settings: NotificationSettings): void {
43
+ if (typeof window === "undefined") return;
44
+ localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
45
+ }
46
+
47
+ export async function requestNotificationPermission(): Promise<boolean> {
48
+ if (typeof window === "undefined" || !("Notification" in window)) {
49
+ return false;
50
+ }
51
+
52
+ if (Notification.permission === "granted") {
53
+ return true;
54
+ }
55
+
56
+ if (Notification.permission === "denied") {
57
+ return false;
58
+ }
59
+
60
+ const permission = await Notification.requestPermission();
61
+ return permission === "granted";
62
+ }
63
+
64
+ export function canSendBrowserNotification(): boolean {
65
+ if (typeof window === "undefined" || !("Notification" in window)) {
66
+ return false;
67
+ }
68
+ return Notification.permission === "granted";
69
+ }
70
+
71
+ export function sendBrowserNotification(
72
+ title: string,
73
+ options?: NotificationOptions,
74
+ onClick?: () => void
75
+ ): Notification | null {
76
+ if (!canSendBrowserNotification()) return null;
77
+
78
+ // Only send if page is not focused
79
+ if (document.hasFocus()) return null;
80
+
81
+ const notification = new Notification(title, {
82
+ icon: "/favicon.ico",
83
+ badge: "/favicon.ico",
84
+ ...options,
85
+ });
86
+
87
+ // Auto-close after 5 seconds
88
+ setTimeout(() => notification.close(), 5000);
89
+
90
+ // Focus window and trigger callback when clicked
91
+ notification.onclick = () => {
92
+ window.focus();
93
+ notification.close();
94
+ onClick?.();
95
+ };
96
+
97
+ return notification;
98
+ }
99
+
100
+ // Audio notification
101
+ let audioContext: AudioContext | null = null;
102
+
103
+ export function playNotificationSound(
104
+ type: NotificationEvent = "waiting"
105
+ ): void {
106
+ if (typeof window === "undefined") return;
107
+
108
+ try {
109
+ if (!audioContext) {
110
+ audioContext = new AudioContext();
111
+ }
112
+
113
+ const oscillator = audioContext.createOscillator();
114
+ const gainNode = audioContext.createGain();
115
+
116
+ oscillator.connect(gainNode);
117
+ gainNode.connect(audioContext.destination);
118
+
119
+ // Different tones for different events
120
+ const frequencies: Record<NotificationEvent, number[]> = {
121
+ waiting: [800, 600], // Two-tone descending (needs attention)
122
+ error: [300, 200], // Low tones (error)
123
+ completed: [600, 800], // Two-tone ascending (success)
124
+ };
125
+
126
+ const freqs = frequencies[type];
127
+ const duration = 0.1;
128
+
129
+ gainNode.gain.setValueAtTime(0.1, audioContext.currentTime);
130
+
131
+ freqs.forEach((freq, i) => {
132
+ const startTime = audioContext!.currentTime + i * duration;
133
+ oscillator.frequency.setValueAtTime(freq, startTime);
134
+ });
135
+
136
+ gainNode.gain.setValueAtTime(0.1, audioContext.currentTime);
137
+ gainNode.gain.exponentialRampToValueAtTime(
138
+ 0.01,
139
+ audioContext.currentTime + freqs.length * duration
140
+ );
141
+
142
+ oscillator.start(audioContext.currentTime);
143
+ oscillator.stop(audioContext.currentTime + freqs.length * duration);
144
+ } catch {
145
+ // Audio not available
146
+ }
147
+ }
148
+
149
+ // Tab title/badge management
150
+ let originalTitle = "";
151
+ let notificationCount = 0;
152
+ let titleInterval: NodeJS.Timeout | null = null;
153
+
154
+ export function setTabNotificationCount(count: number): void {
155
+ if (typeof window === "undefined") return;
156
+
157
+ if (!originalTitle) {
158
+ originalTitle = document.title.replace(/^\(\d+\)\s*/, "");
159
+ }
160
+
161
+ notificationCount = count;
162
+
163
+ if (count > 0) {
164
+ document.title = `(${count}) ${originalTitle}`;
165
+ } else {
166
+ document.title = originalTitle;
167
+ }
168
+ }
169
+
170
+ export function flashTabTitle(message: string): void {
171
+ if (typeof window === "undefined") return;
172
+
173
+ if (!originalTitle) {
174
+ originalTitle = document.title.replace(/^\(\d+\)\s*/, "");
175
+ }
176
+
177
+ // Clear existing flash
178
+ if (titleInterval) {
179
+ clearInterval(titleInterval);
180
+ }
181
+
182
+ let showMessage = true;
183
+ titleInterval = setInterval(() => {
184
+ if (document.hasFocus()) {
185
+ // Stop flashing when focused
186
+ if (titleInterval) clearInterval(titleInterval);
187
+ document.title =
188
+ notificationCount > 0
189
+ ? `(${notificationCount}) ${originalTitle}`
190
+ : originalTitle;
191
+ return;
192
+ }
193
+
194
+ document.title = showMessage ? message : originalTitle;
195
+ showMessage = !showMessage;
196
+ }, 1000);
197
+
198
+ // Stop after 30 seconds
199
+ setTimeout(() => {
200
+ if (titleInterval) {
201
+ clearInterval(titleInterval);
202
+ document.title =
203
+ notificationCount > 0
204
+ ? `(${notificationCount}) ${originalTitle}`
205
+ : originalTitle;
206
+ }
207
+ }, 30000);
208
+ }
209
+
210
+ export function clearTabNotifications(): void {
211
+ if (titleInterval) {
212
+ clearInterval(titleInterval);
213
+ titleInterval = null;
214
+ }
215
+ notificationCount = 0;
216
+ if (originalTitle) {
217
+ document.title = originalTitle;
218
+ }
219
+ }
@@ -0,0 +1,448 @@
1
+ /**
2
+ * Orchestration System
3
+ *
4
+ * Allows a "conductor" session to spawn and manage worker sessions.
5
+ * Each worker gets its own git worktree for isolation.
6
+ */
7
+
8
+ import { randomUUID } from "crypto";
9
+ import { exec } from "child_process";
10
+ import { promisify } from "util";
11
+ import { queries, type Session } from "./db";
12
+ import { createWorktree, deleteWorktree } from "./worktrees";
13
+ import { setupWorktree } from "./env-setup";
14
+ import { type AgentType, getProvider } from "./providers";
15
+ import { statusDetector } from "./status-detector";
16
+ import { wrapWithBanner } from "./banner";
17
+ import { runInBackground } from "./async-operations";
18
+
19
+ const execAsync = promisify(exec);
20
+
21
+ export interface SpawnWorkerOptions {
22
+ conductorSessionId: string;
23
+ task: string;
24
+ workingDirectory: string;
25
+ branchName?: string;
26
+ useWorktree?: boolean;
27
+ model?: string;
28
+ agentType?: AgentType;
29
+ }
30
+
31
+ export interface WorkerInfo {
32
+ id: string;
33
+ name: string;
34
+ task: string;
35
+ status:
36
+ | "pending"
37
+ | "running"
38
+ | "waiting"
39
+ | "idle"
40
+ | "completed"
41
+ | "failed"
42
+ | "dead";
43
+ worktreePath: string | null;
44
+ branchName: string | null;
45
+ createdAt: string;
46
+ }
47
+
48
+ /**
49
+ * Generate a unique branch name from a task description
50
+ */
51
+ function taskToBranchName(task: string): string {
52
+ const base =
53
+ task
54
+ .toLowerCase()
55
+ .replace(/[^a-z0-9\s]/g, "")
56
+ .split(/\s+/)
57
+ .slice(0, 4)
58
+ .join("-")
59
+ .slice(0, 30) || "worker";
60
+
61
+ // Add short unique suffix to avoid conflicts
62
+ const suffix = Date.now().toString(36).slice(-4);
63
+ return `${base}-${suffix}`;
64
+ }
65
+
66
+ /**
67
+ * Generate a short session name from a task description
68
+ */
69
+ function taskToSessionName(task: string): string {
70
+ // Take first 50 chars, trim to last complete word
71
+ const truncated = task.slice(0, 50);
72
+ const lastSpace = truncated.lastIndexOf(" ");
73
+ return lastSpace > 20 ? truncated.slice(0, lastSpace) : truncated;
74
+ }
75
+
76
+ /**
77
+ * Spawn a new worker session
78
+ */
79
+ export async function spawnWorker(
80
+ options: SpawnWorkerOptions
81
+ ): Promise<Session> {
82
+ const {
83
+ conductorSessionId,
84
+ task,
85
+ workingDirectory: rawWorkingDir,
86
+ branchName = taskToBranchName(task),
87
+ useWorktree = true,
88
+ model = "sonnet",
89
+ agentType = "claude",
90
+ } = options;
91
+
92
+ // Expand ~ to home directory
93
+ const workingDirectory = rawWorkingDir.replace(/^~/, process.env.HOME || "");
94
+
95
+ const sessionId = randomUUID();
96
+ const sessionName = taskToSessionName(task);
97
+ const provider = getProvider(agentType);
98
+
99
+ let worktreePath: string | null = null;
100
+ let actualWorkingDir = workingDirectory;
101
+
102
+ // Create worktree if requested
103
+ if (useWorktree) {
104
+ try {
105
+ const worktreeResult = await createWorktree({
106
+ projectPath: workingDirectory,
107
+ featureName: branchName,
108
+ });
109
+ worktreePath = worktreeResult.worktreePath;
110
+ actualWorkingDir = worktreePath;
111
+
112
+ // Set up environment in background (copy .env files, install deps)
113
+ const capturedWorktreePath = worktreePath;
114
+ const capturedSourcePath = workingDirectory;
115
+ runInBackground(async () => {
116
+ const result = await setupWorktree({
117
+ worktreePath: capturedWorktreePath,
118
+ sourcePath: capturedSourcePath,
119
+ });
120
+ console.log("Worker worktree setup completed:", {
121
+ worktreePath: capturedWorktreePath,
122
+ envFilesCopied: result.envFilesCopied,
123
+ stepsRun: result.steps.length,
124
+ success: result.success,
125
+ });
126
+ }, `setup-worker-worktree-${sessionId}`);
127
+ } catch (error) {
128
+ console.error("Failed to create worktree:", error);
129
+ // Fall back to same directory (no isolation)
130
+ }
131
+ }
132
+
133
+ // Create session in database
134
+ const tmuxName = `${provider.id}-${sessionId}`;
135
+ await queries.createWorkerSession(
136
+ sessionId,
137
+ sessionName,
138
+ tmuxName,
139
+ actualWorkingDir,
140
+ conductorSessionId,
141
+ task,
142
+ model,
143
+ "sessions", // group_path
144
+ agentType,
145
+ "uncategorized" // project_id
146
+ );
147
+
148
+ // Update worktree info if created
149
+ if (worktreePath) {
150
+ await queries.updateSessionWorktree(
151
+ worktreePath,
152
+ branchName,
153
+ "main", // base_branch
154
+ null, // dev_server_port
155
+ sessionId
156
+ );
157
+ }
158
+
159
+ // Create tmux session and start the agent
160
+ const tmuxSessionName = `${provider.id}-${sessionId}`;
161
+ const cwd = actualWorkingDir.replace("~", "$HOME");
162
+
163
+ // Build the initial prompt command (workers use auto-approve by default for automation)
164
+ const flags = provider.buildFlags({ model, autoApprove: true });
165
+ const flagsStr = flags.join(" ");
166
+
167
+ // Create tmux session with the agent and banner
168
+ const agentCmd = `${provider.command} ${flagsStr}`;
169
+ const newSessionCmd = wrapWithBanner(agentCmd);
170
+ const createCmd = `tmux set -g mouse on 2>/dev/null; tmux new-session -d -s "${tmuxSessionName}" -c "${cwd}" "${newSessionCmd}"`;
171
+
172
+ try {
173
+ await execAsync(createCmd);
174
+
175
+ // Wait for Claude to be ready by checking for the input prompt
176
+ // Poll every 2 seconds for up to 30 seconds
177
+ const maxWaitMs = 30000;
178
+ const pollIntervalMs = 2000;
179
+ let waited = 0;
180
+ let ready = false;
181
+
182
+ console.log(
183
+ `[orchestration] Waiting for Claude to initialize in ${tmuxSessionName}...`
184
+ );
185
+
186
+ while (waited < maxWaitMs && !ready) {
187
+ await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
188
+ waited += pollIntervalMs;
189
+
190
+ try {
191
+ const { stdout } = await execAsync(
192
+ `tmux capture-pane -t '${tmuxSessionName}' -p -S -10 2>/dev/null`
193
+ );
194
+ const content = stdout.toLowerCase();
195
+
196
+ // Check for trust/permissions prompt and auto-accept
197
+ // Claude shows "Ready to code here?" with "Yes, continue" option - just press Enter
198
+ if (
199
+ content.includes("ready to code here") ||
200
+ content.includes("yes, continue") ||
201
+ content.includes("need permission to work")
202
+ ) {
203
+ console.log(
204
+ `[orchestration] Trust prompt detected, pressing Enter to accept`
205
+ );
206
+ await execAsync(`tmux send-keys -t '${tmuxSessionName}' Enter`);
207
+ continue; // Keep waiting for the real prompt
208
+ }
209
+
210
+ // Look for Claude's ready state - the "? for shortcuts" line indicates fully loaded
211
+ const lines = stdout.trim().split("\n");
212
+ const lastFewLines = lines.slice(-3).join("\n");
213
+ if (
214
+ lastFewLines.includes("? for shortcuts") ||
215
+ lastFewLines.includes("?>")
216
+ ) {
217
+ ready = true;
218
+ console.log(`[orchestration] Claude ready after ${waited}ms`);
219
+ }
220
+ } catch {
221
+ // Session might not be ready yet
222
+ }
223
+ }
224
+
225
+ if (!ready) {
226
+ console.log(
227
+ `[orchestration] Timed out waiting for Claude, sending task anyway after ${waited}ms`
228
+ );
229
+ }
230
+
231
+ // Send the task as input, then press Enter
232
+ const escapedTask = task.replace(/'/g, "'\\''"); // Escape single quotes for shell
233
+ console.log(
234
+ `[orchestration] Sending task to ${tmuxSessionName}: "${task}"`
235
+ );
236
+ try {
237
+ await execAsync(
238
+ `tmux send-keys -t '${tmuxSessionName}' -l '${escapedTask}'`
239
+ );
240
+ await execAsync(`tmux send-keys -t '${tmuxSessionName}' Enter`);
241
+ console.log(
242
+ `[orchestration] Task sent successfully to ${tmuxSessionName}`
243
+ );
244
+ } catch (sendError) {
245
+ console.error(
246
+ `[orchestration] Failed to send task to ${tmuxSessionName}:`,
247
+ sendError
248
+ );
249
+ }
250
+
251
+ // Update worker status to running
252
+ await queries.updateWorkerStatus("running", sessionId);
253
+ } catch (error) {
254
+ console.error("Failed to start worker session:", error);
255
+ await queries.updateWorkerStatus("failed", sessionId);
256
+ }
257
+
258
+ return (await queries.getSession(sessionId))!;
259
+ }
260
+
261
+ /**
262
+ * Get all workers for a conductor session
263
+ */
264
+ export async function getWorkers(
265
+ conductorSessionId: string
266
+ ): Promise<WorkerInfo[]> {
267
+ const workers = await queries.getWorkersByConductor(conductorSessionId);
268
+
269
+ // Get live status for each worker
270
+ const workerInfos: WorkerInfo[] = [];
271
+
272
+ for (const worker of workers) {
273
+ const provider = getProvider(worker.agent_type || "claude");
274
+ const tmuxSessionName = worker.tmux_name || `${provider.id}-${worker.id}`;
275
+
276
+ // Get live status from tmux
277
+ let liveStatus: string;
278
+ try {
279
+ liveStatus = await statusDetector.getStatus(tmuxSessionName);
280
+ } catch {
281
+ liveStatus = "dead";
282
+ }
283
+
284
+ // Combine DB status with live status
285
+ let status: WorkerInfo["status"];
286
+ if (
287
+ worker.worker_status === "completed" ||
288
+ worker.worker_status === "failed"
289
+ ) {
290
+ status = worker.worker_status;
291
+ } else if (liveStatus === "dead") {
292
+ status = "dead";
293
+ } else {
294
+ status = liveStatus as WorkerInfo["status"];
295
+ }
296
+
297
+ workerInfos.push({
298
+ id: worker.id,
299
+ name: worker.name,
300
+ task: worker.worker_task || "",
301
+ status,
302
+ worktreePath: worker.worktree_path,
303
+ branchName: worker.branch_name,
304
+ createdAt: worker.created_at,
305
+ });
306
+ }
307
+
308
+ return workerInfos;
309
+ }
310
+
311
+ /**
312
+ * Get recent output from a worker's terminal
313
+ */
314
+ export async function getWorkerOutput(
315
+ workerId: string,
316
+ lines: number = 50
317
+ ): Promise<string> {
318
+ const session = await queries.getSession(workerId);
319
+ if (!session) {
320
+ throw new Error(`Worker ${workerId} not found`);
321
+ }
322
+
323
+ const provider = getProvider(session.agent_type || "claude");
324
+ const tmuxSessionName = session.tmux_name || `${provider.id}-${workerId}`;
325
+
326
+ try {
327
+ const { stdout } = await execAsync(
328
+ `tmux capture-pane -t "${tmuxSessionName}" -p -S -${lines} 2>/dev/null || echo ""`
329
+ );
330
+ return stdout.trim();
331
+ } catch {
332
+ return "";
333
+ }
334
+ }
335
+
336
+ /**
337
+ * Send a message/command to a worker
338
+ */
339
+ export async function sendToWorker(
340
+ workerId: string,
341
+ message: string
342
+ ): Promise<boolean> {
343
+ const session = await queries.getSession(workerId);
344
+ if (!session) {
345
+ throw new Error(`Worker ${workerId} not found`);
346
+ }
347
+
348
+ const provider = getProvider(session.agent_type || "claude");
349
+ const tmuxSessionName = session.tmux_name || `${provider.id}-${workerId}`;
350
+
351
+ try {
352
+ const escapedMessage = message.replace(/"/g, '\\"').replace(/\$/g, "\\$");
353
+ await execAsync(
354
+ `tmux send-keys -t "${tmuxSessionName}" "${escapedMessage}" Enter`
355
+ );
356
+ return true;
357
+ } catch {
358
+ return false;
359
+ }
360
+ }
361
+
362
+ /**
363
+ * Mark a worker as completed
364
+ */
365
+ export async function completeWorker(workerId: string): Promise<void> {
366
+ await queries.updateWorkerStatus("completed", workerId);
367
+ }
368
+
369
+ /**
370
+ * Mark a worker as failed
371
+ */
372
+ export async function failWorker(workerId: string): Promise<void> {
373
+ await queries.updateWorkerStatus("failed", workerId);
374
+ }
375
+
376
+ /**
377
+ * Kill a worker session and optionally clean up its worktree
378
+ */
379
+ export async function killWorker(
380
+ workerId: string,
381
+ cleanupWorktree: boolean = false
382
+ ): Promise<void> {
383
+ const session = await queries.getSession(workerId);
384
+ if (!session) {
385
+ return;
386
+ }
387
+
388
+ const provider = getProvider(session.agent_type || "claude");
389
+ const tmuxSessionName = session.tmux_name || `${provider.id}-${workerId}`;
390
+
391
+ // Kill tmux session
392
+ try {
393
+ await execAsync(
394
+ `tmux kill-session -t "${tmuxSessionName}" 2>/dev/null || true`
395
+ );
396
+ } catch {
397
+ // Ignore errors
398
+ }
399
+
400
+ // Clean up worktree if requested
401
+ // Note: This requires knowing the original project path, which we derive from git
402
+ if (cleanupWorktree && session.worktree_path) {
403
+ try {
404
+ // Get the main worktree (original project) from git
405
+ const { stdout } = await execAsync(
406
+ `git -C "${session.worktree_path}" worktree list --porcelain | head -1 | sed 's/worktree //'`
407
+ );
408
+ const projectPath = stdout.trim();
409
+ if (projectPath && projectPath !== session.worktree_path) {
410
+ await deleteWorktree(session.worktree_path, projectPath, true);
411
+ }
412
+ } catch (error) {
413
+ console.error("Failed to delete worktree:", error);
414
+ // Fallback: just remove the directory
415
+ try {
416
+ await execAsync(`rm -rf "${session.worktree_path}"`);
417
+ } catch {
418
+ // Ignore cleanup errors
419
+ }
420
+ }
421
+ }
422
+
423
+ await queries.updateWorkerStatus("failed", workerId);
424
+ }
425
+
426
+ /**
427
+ * Get a summary of all workers' statuses
428
+ */
429
+ export async function getWorkersSummary(conductorSessionId: string): Promise<{
430
+ total: number;
431
+ pending: number;
432
+ running: number;
433
+ waiting: number;
434
+ completed: number;
435
+ failed: number;
436
+ }> {
437
+ const workers = await getWorkers(conductorSessionId);
438
+
439
+ return {
440
+ total: workers.length,
441
+ pending: workers.filter((w) => w.status === "pending").length,
442
+ running: workers.filter((w) => w.status === "running").length,
443
+ waiting: workers.filter((w) => w.status === "waiting").length,
444
+ completed: workers.filter((w) => w.status === "completed").length,
445
+ failed: workers.filter((w) => w.status === "failed" || w.status === "dead")
446
+ .length,
447
+ };
448
+ }