@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.
- package/app/api/sessions/[id]/fork/route.ts +0 -1
- package/app/api/sessions/[id]/route.ts +0 -5
- package/app/api/sessions/[id]/summarize/route.ts +2 -3
- package/app/api/sessions/route.ts +2 -11
- package/app/api/sessions/status/acknowledge/route.ts +8 -0
- package/app/api/sessions/status/route.ts +2 -233
- package/app/page.tsx +6 -13
- package/components/ClaudeProjects/ClaudeProjectCard.tsx +19 -31
- package/components/ClaudeProjects/ClaudeSessionCard.tsx +20 -31
- package/components/NewSessionDialog/AdvancedSettings.tsx +3 -12
- package/components/NewSessionDialog/NewSessionDialog.types.ts +0 -10
- package/components/NewSessionDialog/ProjectSelector.tsx +2 -7
- package/components/NewSessionDialog/hooks/useNewSessionForm.ts +3 -36
- package/components/NewSessionDialog/index.tsx +0 -7
- package/components/Pane/DesktopTabBar.tsx +62 -28
- package/components/Pane/index.tsx +5 -0
- package/components/Projects/index.ts +0 -1
- package/components/QuickSwitcher.tsx +63 -11
- package/components/SessionList/ActiveSessionsSection.tsx +116 -0
- package/components/SessionList/hooks/useSessionListMutations.ts +0 -35
- package/components/SessionList/index.tsx +9 -1
- package/components/SessionStatusBar.tsx +155 -0
- package/components/WaitingBanner.tsx +122 -0
- package/components/views/DesktopView.tsx +27 -8
- package/components/views/MobileView.tsx +6 -1
- package/components/views/types.ts +2 -0
- package/data/sessions/index.ts +0 -1
- package/data/sessions/queries.ts +1 -27
- package/data/statuses/queries.ts +68 -34
- package/hooks/useSessions.ts +0 -12
- package/lib/claude/watcher.ts +28 -5
- package/lib/db/queries.ts +4 -64
- package/lib/db/types.ts +0 -8
- package/lib/hooks/reporter.ts +116 -0
- package/lib/hooks/setup.ts +164 -0
- package/lib/orchestration.ts +16 -23
- package/lib/providers/registry.ts +3 -57
- package/lib/providers.ts +19 -100
- package/lib/status-monitor.ts +303 -0
- package/package.json +1 -1
- package/server.ts +5 -1
- package/app/api/groups/[...path]/route.ts +0 -136
- package/app/api/groups/route.ts +0 -93
- package/components/NewSessionDialog/AgentSelector.tsx +0 -37
- package/components/Projects/ProjectCard.tsx +0 -276
- package/components/TmuxSessions.tsx +0 -132
- package/data/groups/index.ts +0 -1
- package/data/groups/mutations.ts +0 -95
- package/hooks/useGroups.ts +0 -37
- package/hooks/useKeybarVisibility.ts +0 -42
- package/lib/claude/process-manager.ts +0 -278
- 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
|
-
}
|
package/lib/status-detector.ts
DELETED
|
@@ -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();
|