@atercates/claude-deck 0.2.3 → 0.2.5

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 (52) hide show
  1. package/app/api/sessions/[id]/fork/route.ts +0 -1
  2. package/app/api/sessions/[id]/route.ts +0 -5
  3. package/app/api/sessions/[id]/summarize/route.ts +2 -3
  4. package/app/api/sessions/route.ts +2 -11
  5. package/app/api/sessions/status/acknowledge/route.ts +8 -0
  6. package/app/api/sessions/status/route.ts +2 -233
  7. package/app/page.tsx +6 -13
  8. package/components/ClaudeProjects/ClaudeProjectCard.tsx +19 -31
  9. package/components/ClaudeProjects/ClaudeSessionCard.tsx +20 -31
  10. package/components/NewSessionDialog/AdvancedSettings.tsx +3 -12
  11. package/components/NewSessionDialog/NewSessionDialog.types.ts +0 -10
  12. package/components/NewSessionDialog/ProjectSelector.tsx +2 -7
  13. package/components/NewSessionDialog/hooks/useNewSessionForm.ts +3 -36
  14. package/components/NewSessionDialog/index.tsx +0 -7
  15. package/components/Pane/DesktopTabBar.tsx +62 -28
  16. package/components/Pane/index.tsx +5 -0
  17. package/components/Projects/index.ts +0 -1
  18. package/components/QuickSwitcher.tsx +63 -11
  19. package/components/SessionList/ActiveSessionsSection.tsx +116 -0
  20. package/components/SessionList/hooks/useSessionListMutations.ts +0 -35
  21. package/components/SessionList/index.tsx +9 -1
  22. package/components/SessionStatusBar.tsx +155 -0
  23. package/components/WaitingBanner.tsx +122 -0
  24. package/components/views/DesktopView.tsx +27 -8
  25. package/components/views/MobileView.tsx +6 -1
  26. package/components/views/types.ts +2 -0
  27. package/data/sessions/index.ts +0 -1
  28. package/data/sessions/queries.ts +1 -27
  29. package/data/statuses/queries.ts +68 -34
  30. package/hooks/useSessions.ts +0 -12
  31. package/lib/claude/watcher.ts +28 -5
  32. package/lib/db/queries.ts +4 -64
  33. package/lib/db/types.ts +0 -8
  34. package/lib/hooks/reporter.ts +116 -0
  35. package/lib/hooks/setup.ts +164 -0
  36. package/lib/orchestration.ts +16 -23
  37. package/lib/providers/registry.ts +3 -57
  38. package/lib/providers.ts +19 -100
  39. package/lib/status-monitor.ts +303 -0
  40. package/package.json +1 -1
  41. package/server.ts +5 -1
  42. package/app/api/groups/[...path]/route.ts +0 -136
  43. package/app/api/groups/route.ts +0 -93
  44. package/components/NewSessionDialog/AgentSelector.tsx +0 -37
  45. package/components/Projects/ProjectCard.tsx +0 -276
  46. package/components/TmuxSessions.tsx +0 -132
  47. package/data/groups/index.ts +0 -1
  48. package/data/groups/mutations.ts +0 -95
  49. package/hooks/useGroups.ts +0 -37
  50. package/hooks/useKeybarVisibility.ts +0 -42
  51. package/lib/claude/process-manager.ts +0 -278
  52. package/lib/status-detector.ts +0 -375
@@ -1,278 +0,0 @@
1
- import { spawn, ChildProcess } from "child_process";
2
- import { WebSocket } from "ws";
3
- import { StreamParser } from "./stream-parser";
4
- import { queries } from "../db";
5
- import type { ClaudeSessionOptions, ClientEvent } from "./types";
6
-
7
- interface ManagedSession {
8
- process: ChildProcess | null;
9
- parser: StreamParser;
10
- clients: Set<WebSocket>;
11
- status: "idle" | "running" | "waiting" | "error";
12
- }
13
-
14
- export class ClaudeProcessManager {
15
- private sessions: Map<string, ManagedSession> = new Map();
16
-
17
- // Register a WebSocket client for a session
18
- registerClient(sessionId: string, ws: WebSocket): void {
19
- let session = this.sessions.get(sessionId);
20
-
21
- if (!session) {
22
- session = {
23
- process: null,
24
- parser: new StreamParser(sessionId),
25
- clients: new Set(),
26
- status: "idle",
27
- };
28
-
29
- // Set up parser event handlers
30
- session.parser.on("event", (event: ClientEvent) => {
31
- this.broadcastToSession(sessionId, event);
32
- this.handleEvent(sessionId, event);
33
- });
34
-
35
- session.parser.on("parse_error", (error) => {
36
- this.broadcastToSession(sessionId, {
37
- type: "error",
38
- sessionId,
39
- timestamp: new Date().toISOString(),
40
- data: { error: `Parse error: ${error.error}` },
41
- });
42
- });
43
-
44
- this.sessions.set(sessionId, session);
45
- }
46
-
47
- session.clients.add(ws);
48
-
49
- // Send current status
50
- ws.send(
51
- JSON.stringify({
52
- type: "status",
53
- sessionId,
54
- timestamp: new Date().toISOString(),
55
- data: { status: session.status },
56
- })
57
- );
58
- }
59
-
60
- // Unregister a WebSocket client
61
- unregisterClient(sessionId: string, ws: WebSocket): void {
62
- const session = this.sessions.get(sessionId);
63
- if (session) {
64
- session.clients.delete(ws);
65
-
66
- // Clean up if no clients remain and process not running
67
- if (session.clients.size === 0 && !session.process) {
68
- this.sessions.delete(sessionId);
69
- }
70
- }
71
- }
72
-
73
- // Send a prompt to Claude
74
- async sendPrompt(
75
- sessionId: string,
76
- prompt: string,
77
- options: ClaudeSessionOptions = {}
78
- ): Promise<void> {
79
- const session = this.sessions.get(sessionId);
80
- if (!session) {
81
- throw new Error(`Session ${sessionId} not found`);
82
- }
83
-
84
- if (session.process) {
85
- throw new Error(`Session ${sessionId} already has a running process`);
86
- }
87
-
88
- // Build Claude CLI command
89
- const args = ["-p", "--output-format", "stream-json", "--verbose"];
90
-
91
- // Add model if specified
92
- if (options.model) {
93
- args.push("--model", options.model);
94
- }
95
-
96
- // Handle session continuity
97
- const dbSession = await queries.getSession(sessionId);
98
-
99
- if (dbSession?.claude_session_id) {
100
- // Resume existing Claude session
101
- args.push("--resume", dbSession.claude_session_id);
102
- }
103
-
104
- // Add system prompt if specified
105
- if (options.systemPrompt) {
106
- args.push("--system-prompt", options.systemPrompt);
107
- }
108
-
109
- // Add the prompt
110
- args.push(prompt);
111
-
112
- // Spawn Claude process
113
- const cwd =
114
- options.workingDirectory ||
115
- dbSession?.working_directory?.replace("~", process.env.HOME || "") ||
116
- process.env.HOME ||
117
- "/";
118
-
119
- console.log(`Spawning Claude for session ${sessionId}:`, args.join(" "));
120
- console.log(`CWD: ${cwd}`);
121
-
122
- // Reset parser for new conversation turn
123
- session.parser = new StreamParser(sessionId);
124
- session.parser.on("event", (event: ClientEvent) => {
125
- console.log(
126
- `Parser event [${sessionId}]:`,
127
- event.type,
128
- JSON.stringify(event.data).substring(0, 100)
129
- );
130
- this.broadcastToSession(sessionId, event);
131
- this.handleEvent(sessionId, event);
132
- });
133
-
134
- // Find claude binary path
135
- const claudePath =
136
- process.env.HOME + "/.nvm/versions/node/v20.19.0/bin/claude";
137
-
138
- const claudeProcess = spawn(claudePath, args, {
139
- cwd,
140
- env: {
141
- ...process.env,
142
- PATH: `/usr/local/bin:/opt/homebrew/bin:${process.env.PATH}`,
143
- },
144
- stdio: ["ignore", "pipe", "pipe"],
145
- });
146
-
147
- session.process = claudeProcess;
148
- session.status = "running";
149
- this.updateDbStatus(sessionId, "running");
150
-
151
- this.broadcastToSession(sessionId, {
152
- type: "status",
153
- sessionId,
154
- timestamp: new Date().toISOString(),
155
- data: { status: "running" },
156
- });
157
-
158
- // Handle stdout (stream-json output)
159
- claudeProcess.stdout?.on("data", (data: Buffer) => {
160
- const text = data.toString();
161
- console.log(`Claude stdout [${sessionId}]:`, text.substring(0, 200));
162
- session.parser.write(text);
163
- });
164
-
165
- // Handle stderr (errors and other output)
166
- claudeProcess.stderr?.on("data", (data: Buffer) => {
167
- const text = data.toString();
168
- console.error(`Claude stderr [${sessionId}]:`, text);
169
- });
170
-
171
- claudeProcess.on("error", (err) => {
172
- console.error(`Claude spawn error [${sessionId}]:`, err);
173
- });
174
-
175
- // Handle process exit
176
- claudeProcess.on("close", (code) => {
177
- console.log(
178
- `Claude process exited for session ${sessionId} with code ${code}`
179
- );
180
-
181
- session.parser.end();
182
- session.process = null;
183
- session.status = code === 0 ? "idle" : "error";
184
-
185
- this.updateDbStatus(sessionId, session.status);
186
-
187
- this.broadcastToSession(sessionId, {
188
- type: "status",
189
- sessionId,
190
- timestamp: new Date().toISOString(),
191
- data: { status: session.status, exitCode: code || 0 },
192
- });
193
- });
194
-
195
- claudeProcess.on("error", (err) => {
196
- console.error(`Claude process error for session ${sessionId}:`, err);
197
-
198
- session.process = null;
199
- session.status = "error";
200
-
201
- this.updateDbStatus(sessionId, "error");
202
-
203
- this.broadcastToSession(sessionId, {
204
- type: "error",
205
- sessionId,
206
- timestamp: new Date().toISOString(),
207
- data: { error: err.message },
208
- });
209
- });
210
- }
211
-
212
- // Cancel a running Claude process
213
- cancelSession(sessionId: string): void {
214
- const session = this.sessions.get(sessionId);
215
- if (session?.process) {
216
- session.process.kill("SIGTERM");
217
- }
218
- }
219
-
220
- // Get session status
221
- getSessionStatus(
222
- sessionId: string
223
- ): "idle" | "running" | "waiting" | "error" | null {
224
- return this.sessions.get(sessionId)?.status ?? null;
225
- }
226
-
227
- // Broadcast event to all clients of a session
228
- private broadcastToSession(sessionId: string, event: ClientEvent): void {
229
- const session = this.sessions.get(sessionId);
230
- if (!session) {
231
- console.log(`No session found for broadcast: ${sessionId}`);
232
- return;
233
- }
234
-
235
- console.log(
236
- `Broadcasting to ${session.clients.size} clients for session ${sessionId}`
237
- );
238
- const message = JSON.stringify(event);
239
- for (const client of session.clients) {
240
- if (client.readyState === WebSocket.OPEN) {
241
- client.send(message);
242
- console.log(`Sent message to client`);
243
- } else {
244
- console.log(`Client not open, state: ${client.readyState}`);
245
- }
246
- }
247
- }
248
-
249
- // Handle events for persistence
250
- private handleEvent(sessionId: string, event: ClientEvent): void {
251
- switch (event.type) {
252
- case "init": {
253
- // Store Claude's session ID for future --resume
254
- const claudeSessionId = event.data.claudeSessionId;
255
- if (claudeSessionId) {
256
- queries.updateSessionClaudeId(claudeSessionId, sessionId);
257
- }
258
- break;
259
- }
260
-
261
- case "complete": {
262
- // Update session timestamp
263
- queries.updateSessionStatus("idle", sessionId);
264
- break;
265
- }
266
-
267
- case "error": {
268
- queries.updateSessionStatus("error", sessionId);
269
- break;
270
- }
271
- }
272
- }
273
-
274
- // Update session status in database
275
- private updateDbStatus(sessionId: string, status: string): void {
276
- queries.updateSessionStatus(status, sessionId);
277
- }
278
- }
@@ -1,375 +0,0 @@
1
- /**
2
- * Session Status Detection System
3
- *
4
- * States:
5
- * - "running" (GREEN): Sustained activity within cooldown period
6
- * - "waiting" (YELLOW): Cooldown expired, NOT acknowledged (needs attention)
7
- * - "idle" (GRAY): Cooldown expired, acknowledged (user saw it)
8
- * - "dead": Session doesn't exist
9
- *
10
- * Detection Strategy:
11
- * 1. Busy indicators + recent activity (highest priority - actively working)
12
- * 2. Waiting patterns - user input needed
13
- * 3. Spike detection - activity timestamp changes (2+ in 1s = sustained)
14
- * 4. Cooldown - 2s grace period after activity stops
15
- */
16
-
17
- import { exec } from "child_process";
18
- import { promisify } from "util";
19
-
20
- const execAsync = promisify(exec);
21
-
22
- // Configuration constants
23
- const CONFIG = {
24
- ACTIVITY_COOLDOWN_MS: 2000, // Grace period after activity
25
- SPIKE_WINDOW_MS: 1000, // Window to detect sustained activity
26
- SUSTAINED_THRESHOLD: 2, // Changes needed to confirm activity
27
- CACHE_VALIDITY_MS: 2000, // How long tmux cache is valid
28
- RECENT_ACTIVITY_MS: 120000, // Window for "recent" activity (2 min, tmux updates slowly)
29
- } as const;
30
-
31
- // Detection patterns
32
- const BUSY_INDICATORS = [
33
- "esc to interrupt",
34
- "(esc to interrupt)",
35
- "· esc to interrupt",
36
- ];
37
-
38
- const SPINNER_CHARS = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
39
-
40
- const WHIMSICAL_WORDS = [
41
- "accomplishing",
42
- "actioning",
43
- "actualizing",
44
- "baking",
45
- "booping",
46
- "brewing",
47
- "calculating",
48
- "cerebrating",
49
- "channelling",
50
- "churning",
51
- "clauding",
52
- "coalescing",
53
- "cogitating",
54
- "combobulating",
55
- "computing",
56
- "concocting",
57
- "conjuring",
58
- "considering",
59
- "contemplating",
60
- "cooking",
61
- "crafting",
62
- "creating",
63
- "crunching",
64
- "deciphering",
65
- "deliberating",
66
- "determining",
67
- "discombobulating",
68
- "divining",
69
- "doing",
70
- "effecting",
71
- "elucidating",
72
- "enchanting",
73
- "envisioning",
74
- "finagling",
75
- "flibbertigibbeting",
76
- "forging",
77
- "forming",
78
- "frolicking",
79
- "generating",
80
- "germinating",
81
- "hatching",
82
- "herding",
83
- "honking",
84
- "hustling",
85
- "ideating",
86
- "imagining",
87
- "incubating",
88
- "inferring",
89
- "jiving",
90
- "manifesting",
91
- "marinating",
92
- "meandering",
93
- "moseying",
94
- "mulling",
95
- "mustering",
96
- "musing",
97
- "noodling",
98
- "percolating",
99
- "perusing",
100
- "philosophising",
101
- "pondering",
102
- "pontificating",
103
- "processing",
104
- "puttering",
105
- "puzzling",
106
- "reticulating",
107
- "ruminating",
108
- "scheming",
109
- "schlepping",
110
- "shimmying",
111
- "shucking",
112
- "simmering",
113
- "smooshing",
114
- "spelunking",
115
- "spinning",
116
- "stewing",
117
- "sussing",
118
- "synthesizing",
119
- "thinking",
120
- "tinkering",
121
- "transmuting",
122
- "unfurling",
123
- "unravelling",
124
- "vibing",
125
- "wandering",
126
- "whirring",
127
- "wibbling",
128
- "wizarding",
129
- "working",
130
- "wrangling",
131
- ];
132
-
133
- const WAITING_PATTERNS = [
134
- /\[Y\/n\]/i,
135
- /\[y\/N\]/i,
136
- /Allow\?/i,
137
- /Approve\?/i,
138
- /Continue\?/i,
139
- /Press Enter to/i,
140
- /waiting for input/i,
141
- /\(yes\/no\)/i,
142
- /Do you want to/i,
143
- /Enter to confirm.*Esc to cancel/i,
144
- />\s*1\.\s*Yes/,
145
- /Yes, allow all/i,
146
- /allow all edits/i,
147
- /allow all commands/i,
148
- ];
149
-
150
- export type SessionStatus = "running" | "waiting" | "idle" | "dead";
151
-
152
- interface StateTracker {
153
- lastChangeTime: number;
154
- acknowledged: boolean;
155
- lastActivityTimestamp: number;
156
- spikeWindowStart: number | null;
157
- spikeChangeCount: number;
158
- }
159
-
160
- interface SessionCache {
161
- data: Map<string, number>;
162
- updatedAt: number;
163
- }
164
-
165
- // Content analysis helpers
166
- function checkBusyIndicators(content: string): boolean {
167
- const lines = content.split("\n");
168
- // Focus on last 10 lines to avoid old scrollback false positives
169
- const recentContent = lines.slice(-10).join("\n").toLowerCase();
170
-
171
- // Check text indicators in recent lines
172
- if (BUSY_INDICATORS.some((ind) => recentContent.includes(ind))) return true;
173
-
174
- // Check whimsical words + "tokens" pattern in recent lines
175
- if (
176
- recentContent.includes("tokens") &&
177
- WHIMSICAL_WORDS.some((w) => recentContent.includes(w))
178
- )
179
- return true;
180
-
181
- // Check spinners in last 5 lines
182
- const last5 = lines.slice(-5).join("");
183
- if (SPINNER_CHARS.some((s) => last5.includes(s))) return true;
184
-
185
- return false;
186
- }
187
-
188
- function checkWaitingPatterns(content: string): boolean {
189
- const recentLines = content.split("\n").slice(-5).join("\n");
190
- return WAITING_PATTERNS.some((p) => p.test(recentLines));
191
- }
192
-
193
- class SessionStatusDetector {
194
- private trackers = new Map<string, StateTracker>();
195
- private cache: SessionCache = { data: new Map(), updatedAt: 0 };
196
-
197
- // Cache management
198
- async refreshCache(): Promise<void> {
199
- if (Date.now() - this.cache.updatedAt < CONFIG.CACHE_VALIDITY_MS) return;
200
-
201
- try {
202
- const { stdout } = await execAsync(
203
- `tmux list-sessions -F '#{session_name}\t#{session_activity}' 2>/dev/null || echo ""`
204
- );
205
-
206
- const newData = new Map<string, number>();
207
- for (const line of stdout.trim().split("\n")) {
208
- if (!line) continue;
209
- const [name, activity] = line.split("\t");
210
- if (name && activity) newData.set(name, parseInt(activity, 10) || 0);
211
- }
212
-
213
- this.cache = { data: newData, updatedAt: Date.now() };
214
- } catch {
215
- // Keep existing cache on error
216
- }
217
- }
218
-
219
- sessionExists(name: string): boolean {
220
- return this.cache.data.has(name);
221
- }
222
-
223
- getTimestamp(name: string): number {
224
- return this.cache.data.get(name) || 0;
225
- }
226
-
227
- async capturePane(name: string): Promise<string> {
228
- try {
229
- const { stdout } = await execAsync(
230
- `tmux capture-pane -t "${name}" -p 2>/dev/null || echo ""`
231
- );
232
- return stdout.trim();
233
- } catch {
234
- return "";
235
- }
236
- }
237
-
238
- private getTracker(name: string, timestamp: number): StateTracker {
239
- let tracker = this.trackers.get(name);
240
- if (!tracker) {
241
- tracker = {
242
- lastChangeTime: Date.now() - CONFIG.ACTIVITY_COOLDOWN_MS,
243
- acknowledged: true,
244
- lastActivityTimestamp: timestamp,
245
- spikeWindowStart: null,
246
- spikeChangeCount: 0,
247
- };
248
- this.trackers.set(name, tracker);
249
- }
250
- return tracker;
251
- }
252
-
253
- // Spike detection: filters single activity spikes from sustained activity
254
- private processSpikeDetection(
255
- tracker: StateTracker,
256
- currentTimestamp: number
257
- ): "running" | null {
258
- const now = Date.now();
259
- const timestampChanged = tracker.lastActivityTimestamp !== currentTimestamp;
260
-
261
- if (timestampChanged) {
262
- tracker.lastActivityTimestamp = currentTimestamp;
263
-
264
- const windowExpired =
265
- tracker.spikeWindowStart === null ||
266
- now - tracker.spikeWindowStart > CONFIG.SPIKE_WINDOW_MS;
267
-
268
- if (windowExpired) {
269
- // Start new detection window
270
- tracker.spikeWindowStart = now;
271
- tracker.spikeChangeCount = 1;
272
- } else {
273
- // Within window - count change
274
- tracker.spikeChangeCount++;
275
- if (tracker.spikeChangeCount >= CONFIG.SUSTAINED_THRESHOLD) {
276
- // Sustained activity confirmed
277
- tracker.lastChangeTime = now;
278
- tracker.acknowledged = false;
279
- tracker.spikeWindowStart = null;
280
- tracker.spikeChangeCount = 0;
281
- return "running";
282
- }
283
- }
284
- } else if (
285
- tracker.spikeChangeCount === 1 &&
286
- tracker.spikeWindowStart !== null
287
- ) {
288
- // Check if single spike should be filtered
289
- if (now - tracker.spikeWindowStart > CONFIG.SPIKE_WINDOW_MS) {
290
- tracker.spikeWindowStart = null;
291
- tracker.spikeChangeCount = 0;
292
- }
293
- }
294
-
295
- return null;
296
- }
297
-
298
- private isInSpikeWindow(tracker: StateTracker): boolean {
299
- return (
300
- tracker.spikeWindowStart !== null &&
301
- Date.now() - tracker.spikeWindowStart < CONFIG.SPIKE_WINDOW_MS
302
- );
303
- }
304
-
305
- private isInCooldown(tracker: StateTracker): boolean {
306
- return Date.now() - tracker.lastChangeTime < CONFIG.ACTIVITY_COOLDOWN_MS;
307
- }
308
-
309
- private getIdleOrWaiting(tracker: StateTracker): SessionStatus {
310
- return tracker.acknowledged ? "idle" : "waiting";
311
- }
312
-
313
- async getStatus(sessionName: string): Promise<SessionStatus> {
314
- await this.refreshCache();
315
-
316
- // Dead check
317
- if (!this.sessionExists(sessionName)) {
318
- this.trackers.delete(sessionName);
319
- return "dead";
320
- }
321
-
322
- const timestamp = this.getTimestamp(sessionName);
323
- const tracker = this.getTracker(sessionName, timestamp);
324
- const content = await this.capturePane(sessionName);
325
-
326
- // 1. Busy indicators in last 10 lines (highest priority - Claude is actively working)
327
- // No activity timestamp check needed since we only look at recent terminal lines
328
- if (checkBusyIndicators(content)) {
329
- tracker.lastChangeTime = Date.now();
330
- tracker.acknowledged = false;
331
- return "running";
332
- }
333
-
334
- // 2. Waiting patterns (only if not actively running)
335
- if (checkWaitingPatterns(content)) return "waiting";
336
-
337
- // 3. Spike detection
338
- const spikeResult = this.processSpikeDetection(tracker, timestamp);
339
- if (spikeResult) return spikeResult;
340
-
341
- // 4. During spike window, maintain stable status
342
- if (this.isInSpikeWindow(tracker)) {
343
- return this.isInCooldown(tracker)
344
- ? "running"
345
- : this.getIdleOrWaiting(tracker);
346
- }
347
-
348
- // 5. Cooldown check
349
- if (this.isInCooldown(tracker)) return "running";
350
-
351
- // 6. Cooldown expired
352
- return this.getIdleOrWaiting(tracker);
353
- }
354
-
355
- acknowledge(sessionName: string): void {
356
- const tracker = this.trackers.get(sessionName);
357
- if (tracker) tracker.acknowledged = true;
358
- }
359
-
360
- async getAllStatuses(names: string[]): Promise<Map<string, SessionStatus>> {
361
- await this.refreshCache();
362
- const results = await Promise.all(
363
- names.map(async (name) => ({ name, status: await this.getStatus(name) }))
364
- );
365
- return new Map(results.map((r) => [r.name, r.status]));
366
- }
367
-
368
- cleanup(): void {
369
- for (const [name] of this.trackers) {
370
- if (!this.sessionExists(name)) this.trackers.delete(name);
371
- }
372
- }
373
- }
374
-
375
- export const statusDetector = new SessionStatusDetector();