@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.
- package/LICENSE +21 -0
- package/README.md +123 -0
- package/app/api/claude/hidden/route.ts +66 -0
- package/app/api/claude/projects/[name]/sessions/route.ts +71 -0
- package/app/api/claude/projects/route.ts +44 -0
- package/app/api/code-search/available/route.ts +12 -0
- package/app/api/code-search/route.ts +47 -0
- package/app/api/dev-servers/[id]/logs/route.ts +23 -0
- package/app/api/dev-servers/[id]/restart/route.ts +20 -0
- package/app/api/dev-servers/[id]/route.ts +51 -0
- package/app/api/dev-servers/[id]/stop/route.ts +20 -0
- package/app/api/dev-servers/detect/route.ts +39 -0
- package/app/api/dev-servers/route.ts +48 -0
- package/app/api/exec/route.ts +60 -0
- package/app/api/files/content/route.ts +76 -0
- package/app/api/files/route.ts +37 -0
- package/app/api/files/upload-temp/route.ts +41 -0
- package/app/api/git/check/route.ts +54 -0
- package/app/api/git/clone/route.ts +99 -0
- package/app/api/git/commit/route.ts +75 -0
- package/app/api/git/discard/route.ts +38 -0
- package/app/api/git/file-content/route.ts +64 -0
- package/app/api/git/history/[hash]/diff/route.ts +38 -0
- package/app/api/git/history/[hash]/route.ts +34 -0
- package/app/api/git/history/route.ts +27 -0
- package/app/api/git/multi-status/route.ts +46 -0
- package/app/api/git/pr/route.ts +164 -0
- package/app/api/git/push/route.ts +64 -0
- package/app/api/git/stage/route.ts +40 -0
- package/app/api/git/status/route.ts +51 -0
- package/app/api/git/unstage/route.ts +46 -0
- package/app/api/groups/[...path]/route.ts +136 -0
- package/app/api/groups/route.ts +93 -0
- package/app/api/orchestrate/spawn/route.ts +45 -0
- package/app/api/orchestrate/workers/[id]/route.ts +89 -0
- package/app/api/orchestrate/workers/route.ts +31 -0
- package/app/api/projects/[id]/detect/route.ts +27 -0
- package/app/api/projects/[id]/dev-servers/[dsId]/route.ts +66 -0
- package/app/api/projects/[id]/dev-servers/route.ts +51 -0
- package/app/api/projects/[id]/repositories/[repoId]/route.ts +67 -0
- package/app/api/projects/[id]/repositories/route.ts +74 -0
- package/app/api/projects/[id]/route.ts +108 -0
- package/app/api/projects/detect/route.ts +33 -0
- package/app/api/projects/route.ts +59 -0
- package/app/api/sessions/[id]/claude-session/route.ts +42 -0
- package/app/api/sessions/[id]/fork/route.ts +74 -0
- package/app/api/sessions/[id]/mcp-config/route.ts +34 -0
- package/app/api/sessions/[id]/messages/route.ts +60 -0
- package/app/api/sessions/[id]/pr/route.ts +188 -0
- package/app/api/sessions/[id]/preview/route.ts +42 -0
- package/app/api/sessions/[id]/route.ts +229 -0
- package/app/api/sessions/[id]/send-keys/route.ts +119 -0
- package/app/api/sessions/[id]/summarize/route.ts +331 -0
- package/app/api/sessions/init-script/route.ts +84 -0
- package/app/api/sessions/route.ts +209 -0
- package/app/api/sessions/status/route.ts +237 -0
- package/app/api/system/route.ts +9 -0
- package/app/api/tmux/kill-all/route.ts +57 -0
- package/app/api/tmux/rename/route.ts +30 -0
- package/app/globals.css +174 -0
- package/app/icon.svg +11 -0
- package/app/layout.tsx +122 -0
- package/app/page.tsx +629 -0
- package/components/ChatMessage.tsx +65 -0
- package/components/ChatView.tsx +276 -0
- package/components/ClaudeProjects/ClaudeProjectCard.tsx +195 -0
- package/components/ClaudeProjects/ClaudeProjectsSection.tsx +89 -0
- package/components/ClaudeProjects/ClaudeSessionCard.tsx +100 -0
- package/components/ClaudeProjects/index.ts +1 -0
- package/components/CodeSearch/CodeSearchResults.tsx +177 -0
- package/components/ConductorPanel.tsx +256 -0
- package/components/DevServers/DevServerCard.tsx +311 -0
- package/components/DevServers/DevServersSection.tsx +91 -0
- package/components/DevServers/ServerLogsModal.tsx +151 -0
- package/components/DevServers/StartServerDialog.tsx +359 -0
- package/components/DevServers/index.ts +4 -0
- package/components/DiffViewer/DiffModal.tsx +151 -0
- package/components/DiffViewer/UnifiedDiff.tsx +185 -0
- package/components/DiffViewer/index.tsx +2 -0
- package/components/DirectoryPicker.tsx +355 -0
- package/components/FileExplorer/FileEditor.tsx +276 -0
- package/components/FileExplorer/FileTabs.tsx +118 -0
- package/components/FileExplorer/FileTree.tsx +214 -0
- package/components/FileExplorer/HtmlRenderer.tsx +16 -0
- package/components/FileExplorer/MarkdownRenderer.tsx +18 -0
- package/components/FileExplorer/index.tsx +520 -0
- package/components/FilePicker.tsx +339 -0
- package/components/FolderPicker.tsx +201 -0
- package/components/GitDrawer/FileEditDialog.tsx +400 -0
- package/components/GitDrawer/index.tsx +464 -0
- package/components/GitPanel/CommitForm.tsx +205 -0
- package/components/GitPanel/CommitHistory.tsx +174 -0
- package/components/GitPanel/CommitItem.tsx +196 -0
- package/components/GitPanel/FileChanges.tsx +414 -0
- package/components/GitPanel/GitPanelTabs.tsx +39 -0
- package/components/GitPanel/index.tsx +817 -0
- package/components/MessageInput.tsx +82 -0
- package/components/NewClaudeSessionDialog.tsx +166 -0
- package/components/NewSessionDialog/AdvancedSettings.tsx +78 -0
- package/components/NewSessionDialog/AgentSelector.tsx +37 -0
- package/components/NewSessionDialog/CreatingOverlay.tsx +94 -0
- package/components/NewSessionDialog/NewSessionDialog.types.ts +136 -0
- package/components/NewSessionDialog/ProjectSelector.tsx +146 -0
- package/components/NewSessionDialog/WorkingDirectoryInput.tsx +55 -0
- package/components/NewSessionDialog/WorktreeSection.tsx +92 -0
- package/components/NewSessionDialog/hooks/useNewSessionForm.ts +370 -0
- package/components/NewSessionDialog/index.tsx +106 -0
- package/components/NotificationSettings.tsx +127 -0
- package/components/PRCreationModal.tsx +272 -0
- package/components/Pane/DesktopTabBar.tsx +353 -0
- package/components/Pane/MobileTabBar.tsx +210 -0
- package/components/Pane/OpenInVSCode.tsx +69 -0
- package/components/Pane/PaneSkeletons.tsx +57 -0
- package/components/Pane/index.tsx +558 -0
- package/components/PaneLayout.tsx +60 -0
- package/components/Projects/DevServersSection.tsx +140 -0
- package/components/Projects/DirectoryField.tsx +92 -0
- package/components/Projects/NewProjectDialog.tsx +188 -0
- package/components/Projects/NewProjectDialog.types.ts +46 -0
- package/components/Projects/ProjectCard.tsx +276 -0
- package/components/Projects/ProjectSettingsDialog.tsx +811 -0
- package/components/Projects/hooks/useNewProjectForm.ts +249 -0
- package/components/Projects/index.ts +3 -0
- package/components/Providers.tsx +49 -0
- package/components/QuickSwitcher.tsx +306 -0
- package/components/SessionList/KillAllConfirm.tsx +46 -0
- package/components/SessionList/SelectionToolbar.tsx +164 -0
- package/components/SessionList/SessionList.types.ts +37 -0
- package/components/SessionList/SessionListHeader.tsx +71 -0
- package/components/SessionList/hooks/useSessionListMutations.ts +269 -0
- package/components/SessionList/index.tsx +189 -0
- package/components/ShellDrawer/index.tsx +106 -0
- package/components/SidebarFooter.tsx +55 -0
- package/components/Terminal/KeybarToggleButton.tsx +45 -0
- package/components/Terminal/ScrollToBottomButton.tsx +32 -0
- package/components/Terminal/SearchBar.tsx +71 -0
- package/components/Terminal/TerminalToolbar.tsx +551 -0
- package/components/Terminal/VirtualKeyboard.tsx +711 -0
- package/components/Terminal/constants.ts +20 -0
- package/components/Terminal/hooks/index.ts +5 -0
- package/components/Terminal/hooks/resize-handlers.ts +140 -0
- package/components/Terminal/hooks/terminal-init.ts +151 -0
- package/components/Terminal/hooks/touch-scroll.ts +155 -0
- package/components/Terminal/hooks/useTerminalConnection.ts +282 -0
- package/components/Terminal/hooks/useTerminalConnection.types.ts +39 -0
- package/components/Terminal/hooks/useTerminalSearch.ts +103 -0
- package/components/Terminal/hooks/websocket-connection.ts +274 -0
- package/components/Terminal/index.tsx +320 -0
- package/components/ThemeToggle.tsx +168 -0
- package/components/TmuxSessions.tsx +132 -0
- package/components/ToolCallDisplay.tsx +71 -0
- package/components/WorkerCard.tsx +245 -0
- package/components/a/ABadge.tsx +115 -0
- package/components/a/AButton.tsx +163 -0
- package/components/a/ADialog.tsx +93 -0
- package/components/a/ADropdownMenu.tsx +279 -0
- package/components/a/AIconButton.tsx +190 -0
- package/components/a/ASheet.tsx +150 -0
- package/components/a/ATooltip.tsx +77 -0
- package/components/a/index.ts +64 -0
- package/components/mobile/SwipeSidebar.tsx +122 -0
- package/components/ui/badge.tsx +41 -0
- package/components/ui/button.tsx +60 -0
- package/components/ui/context-menu.tsx +197 -0
- package/components/ui/dialog.tsx +143 -0
- package/components/ui/dropdown-menu.tsx +257 -0
- package/components/ui/input.tsx +21 -0
- package/components/ui/scroll-area.tsx +52 -0
- package/components/ui/select.tsx +159 -0
- package/components/ui/skeleton.tsx +111 -0
- package/components/ui/switch.tsx +31 -0
- package/components/ui/textarea.tsx +21 -0
- package/components/ui/tooltip.tsx +32 -0
- package/components/views/DesktopView.tsx +244 -0
- package/components/views/MobileView.tsx +110 -0
- package/components/views/types.ts +75 -0
- package/contexts/PaneContext.tsx +336 -0
- package/data/claude/index.ts +9 -0
- package/data/claude/keys.ts +6 -0
- package/data/claude/queries.ts +120 -0
- package/data/claude/useClaudeUpdates.ts +37 -0
- package/data/code-search/index.ts +2 -0
- package/data/code-search/keys.ts +7 -0
- package/data/code-search/queries.ts +61 -0
- package/data/dev-servers/index.ts +8 -0
- package/data/dev-servers/keys.ts +4 -0
- package/data/dev-servers/queries.ts +104 -0
- package/data/files/index.ts +3 -0
- package/data/files/keys.ts +4 -0
- package/data/files/queries.ts +25 -0
- package/data/git/keys.ts +15 -0
- package/data/git/queries.ts +395 -0
- package/data/groups/index.ts +1 -0
- package/data/groups/mutations.ts +95 -0
- package/data/projects/index.ts +10 -0
- package/data/projects/keys.ts +4 -0
- package/data/projects/queries.ts +193 -0
- package/data/repositories/index.ts +7 -0
- package/data/repositories/keys.ts +5 -0
- package/data/repositories/queries.ts +122 -0
- package/data/sessions/index.ts +12 -0
- package/data/sessions/keys.ts +8 -0
- package/data/sessions/queries.ts +218 -0
- package/data/statuses/index.ts +1 -0
- package/data/statuses/queries.ts +69 -0
- package/hooks/useCopyToClipboard.ts +48 -0
- package/hooks/useDevServersManager.ts +73 -0
- package/hooks/useDirectoryBrowser.ts +90 -0
- package/hooks/useDrawerAnimation.ts +27 -0
- package/hooks/useFileDrop.ts +87 -0
- package/hooks/useFileEditor.ts +184 -0
- package/hooks/useGroups.ts +37 -0
- package/hooks/useHomePath.ts +34 -0
- package/hooks/useKeyRepeat.ts +55 -0
- package/hooks/useKeybarVisibility.ts +42 -0
- package/hooks/useNotifications.ts +257 -0
- package/hooks/useProjects.ts +53 -0
- package/hooks/useSessionStatuses.ts +30 -0
- package/hooks/useSessions.ts +86 -0
- package/hooks/useSpeechRecognition.ts +124 -0
- package/hooks/useViewport.ts +32 -0
- package/hooks/useViewportHeight.ts +50 -0
- package/lib/async-operations.ts +35 -0
- package/lib/banner.ts +81 -0
- package/lib/claude/jsonl-cache.ts +86 -0
- package/lib/claude/jsonl-reader.ts +271 -0
- package/lib/claude/process-manager.ts +278 -0
- package/lib/claude/stream-parser.ts +173 -0
- package/lib/claude/types.ts +154 -0
- package/lib/claude/watcher.ts +71 -0
- package/lib/client/session-registry.ts +111 -0
- package/lib/code-search.ts +121 -0
- package/lib/db/index.ts +48 -0
- package/lib/db/migrations.ts +45 -0
- package/lib/db/queries.ts +460 -0
- package/lib/db/schema.ts +114 -0
- package/lib/db/types.ts +92 -0
- package/lib/db.ts +2 -0
- package/lib/dev-servers.ts +509 -0
- package/lib/diff-parser.ts +221 -0
- package/lib/env-setup.ts +285 -0
- package/lib/file-upload.ts +34 -0
- package/lib/file-utils.ts +50 -0
- package/lib/files.ts +207 -0
- package/lib/git-history.ts +294 -0
- package/lib/git-status.ts +391 -0
- package/lib/git.ts +257 -0
- package/lib/mcp-config.ts +81 -0
- package/lib/multi-repo-git.ts +179 -0
- package/lib/notifications.ts +219 -0
- package/lib/orchestration.ts +448 -0
- package/lib/panes.ts +232 -0
- package/lib/ports.ts +97 -0
- package/lib/pr-generation.ts +307 -0
- package/lib/pr.ts +234 -0
- package/lib/projects.ts +578 -0
- package/lib/providers/registry.ts +70 -0
- package/lib/providers.ts +121 -0
- package/lib/query-client.ts +14 -0
- package/lib/rangeSelectionUtils.ts +65 -0
- package/lib/status-detector.ts +375 -0
- package/lib/terminal-themes.ts +265 -0
- package/lib/theme-config.ts +327 -0
- package/lib/utils.ts +6 -0
- package/lib/worktrees.ts +262 -0
- package/mcp/orchestration-server.ts +438 -0
- package/package.json +139 -0
- package/postcss.config.mjs +7 -0
- package/public/icon.svg +10 -0
- package/public/icons/icon-128x128.png +0 -0
- package/public/icons/icon-144x144.png +0 -0
- package/public/icons/icon-152x152.png +0 -0
- package/public/icons/icon-192x192.png +0 -0
- package/public/icons/icon-384x384.png +0 -0
- package/public/icons/icon-512x512.png +0 -0
- package/public/icons/icon-72x72.png +0 -0
- package/public/icons/icon-96x96.png +0 -0
- package/public/manifest.json +61 -0
- package/public/sw.js +64 -0
- package/scripts/agent-os +91 -0
- package/scripts/install.sh +48 -0
- package/scripts/lib/ai-clis.sh +132 -0
- package/scripts/lib/commands.sh +487 -0
- package/scripts/lib/common.sh +89 -0
- package/scripts/lib/prerequisites.sh +462 -0
- package/scripts/setup.sh +134 -0
- package/server.ts +155 -0
- package/stores/fileOpen.ts +26 -0
- package/stores/index.ts +1 -0
- package/stores/initialPrompt.ts +24 -0
- package/stores/sessionSelection.ts +48 -0
- package/styles/themes.css +603 -0
- 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
|
+
}
|