ai-agent-session-center 2.0.2 → 2.0.3
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/README.md +484 -429
- package/docs/3D/ADAPTATION_GUIDE.md +592 -0
- package/docs/3D/index.html +754 -0
- package/docs/AGENT_TEAM_TASKS.md +716 -0
- package/docs/CYBERDROME_V2_SPEC.md +531 -0
- package/docs/ERROR_185_ANALYSIS.md +263 -0
- package/docs/PLATFORM_FEATURES_PROMPT.md +296 -0
- package/docs/SESSION_DETAIL_FEATURES.md +98 -0
- package/docs/_3d_multimedia_features.md +1080 -0
- package/docs/_frontend_features.md +1057 -0
- package/docs/_server_features.md +1077 -0
- package/docs/session-duplication-fixes.md +271 -0
- package/docs/session-terminal-linkage.md +412 -0
- package/package.json +63 -5
- package/public/apple-touch-icon.svg +21 -0
- package/public/css/dashboard.css +0 -161
- package/public/css/detail-panel.css +25 -0
- package/public/css/layout.css +18 -1
- package/public/css/modals.css +0 -26
- package/public/css/settings.css +0 -150
- package/public/css/terminal.css +34 -0
- package/public/favicon.svg +18 -0
- package/public/index.html +6 -26
- package/public/js/alarmManager.js +0 -21
- package/public/js/app.js +21 -7
- package/public/js/detailPanel.js +63 -64
- package/public/js/historyPanel.js +61 -55
- package/public/js/quickActions.js +132 -48
- package/public/js/sessionCard.js +5 -20
- package/public/js/sessionControls.js +8 -0
- package/public/js/settingsManager.js +0 -142
- package/server/apiRouter.js +60 -15
- package/server/apiRouter.ts +774 -0
- package/server/approvalDetector.ts +94 -0
- package/server/authManager.ts +144 -0
- package/server/autoIdleManager.ts +110 -0
- package/server/config.ts +121 -0
- package/server/constants.ts +150 -0
- package/server/db.ts +475 -0
- package/server/hookInstaller.d.ts +3 -0
- package/server/hookProcessor.ts +108 -0
- package/server/hookRouter.ts +18 -0
- package/server/hookStats.ts +116 -0
- package/server/index.js +15 -1
- package/server/index.ts +230 -0
- package/server/logger.ts +75 -0
- package/server/mqReader.ts +349 -0
- package/server/portManager.ts +55 -0
- package/server/processMonitor.ts +239 -0
- package/server/serverConfig.ts +29 -0
- package/server/sessionMatcher.js +17 -6
- package/server/sessionMatcher.ts +403 -0
- package/server/sessionStore.js +109 -3
- package/server/sessionStore.ts +1145 -0
- package/server/sshManager.js +167 -24
- package/server/sshManager.ts +671 -0
- package/server/teamManager.ts +289 -0
- package/server/wsManager.ts +200 -0
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
// mqReader.ts — File-based JSONL message queue reader
|
|
2
|
+
// Hooks append JSON lines to a queue file; this module watches it and processes events.
|
|
3
|
+
//
|
|
4
|
+
// Performance: fs.watch() for instant notification + 500ms fallback poll.
|
|
5
|
+
// Atomicity: POSIX guarantees atomic append for writes <= PIPE_BUF (4096 bytes).
|
|
6
|
+
// Our enriched hook JSON is typically 300-800 bytes.
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
existsSync, mkdirSync, writeFileSync,
|
|
10
|
+
openSync, readSync, closeSync, fstatSync, watch,
|
|
11
|
+
} from 'fs';
|
|
12
|
+
import type { FSWatcher } from 'fs';
|
|
13
|
+
import { join } from 'path';
|
|
14
|
+
import { processHookEvent } from './hookProcessor.js';
|
|
15
|
+
import log from './logger.js';
|
|
16
|
+
|
|
17
|
+
// Use /tmp on macOS/Linux (matches the hardcoded path in dashboard-hook.sh).
|
|
18
|
+
// os.tmpdir() on macOS returns /var/folders/... which hooks can't predict.
|
|
19
|
+
// On Windows, hooks use $env:TEMP which matches os.tmpdir().
|
|
20
|
+
const QUEUE_DIR = process.platform === 'win32'
|
|
21
|
+
? join(process.env.TEMP || process.env.TMP || 'C:\\Temp', 'claude-session-center')
|
|
22
|
+
: '/tmp/claude-session-center';
|
|
23
|
+
const QUEUE_FILE = join(QUEUE_DIR, 'queue.jsonl');
|
|
24
|
+
const POLL_INTERVAL_MS = 500;
|
|
25
|
+
const DEBOUNCE_MS = 10;
|
|
26
|
+
const TRUNCATE_THRESHOLD = 1 * 1024 * 1024; // 1 MB
|
|
27
|
+
|
|
28
|
+
// Internal state
|
|
29
|
+
let watcher: FSWatcher | null = null;
|
|
30
|
+
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
|
31
|
+
let healthCheckTimer: ReturnType<typeof setInterval> | null = null;
|
|
32
|
+
let lastByteOffset = 0;
|
|
33
|
+
let partialLine = '';
|
|
34
|
+
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
35
|
+
let running = false;
|
|
36
|
+
let lastWatchEventAt = 0;
|
|
37
|
+
let lastKnownFileSize = 0;
|
|
38
|
+
const HEALTH_CHECK_INTERVAL_MS = 5000;
|
|
39
|
+
|
|
40
|
+
// Stats
|
|
41
|
+
const mqStats = {
|
|
42
|
+
linesProcessed: 0,
|
|
43
|
+
linesErrored: 0,
|
|
44
|
+
truncations: 0,
|
|
45
|
+
lastProcessedAt: null as number | null,
|
|
46
|
+
startedAt: null as number | null,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
interface MqReaderOptions {
|
|
50
|
+
resumeOffset?: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Start the MQ reader. Called once from server startup.
|
|
55
|
+
* Creates queue directory/file and begins watching.
|
|
56
|
+
*/
|
|
57
|
+
export function startMqReader(options?: MqReaderOptions): void {
|
|
58
|
+
if (running) return;
|
|
59
|
+
running = true;
|
|
60
|
+
mqStats.startedAt = Date.now();
|
|
61
|
+
|
|
62
|
+
// Ensure queue directory exists
|
|
63
|
+
mkdirSync(QUEUE_DIR, { recursive: true });
|
|
64
|
+
|
|
65
|
+
// Create queue file if it doesn't exist (but don't truncate existing)
|
|
66
|
+
if (!existsSync(QUEUE_FILE)) {
|
|
67
|
+
writeFileSync(QUEUE_FILE, '');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Resume from snapshot offset or start from current EOF
|
|
71
|
+
if (options?.resumeOffset != null && options.resumeOffset >= 0) {
|
|
72
|
+
// Clamp to file size in case file was truncated externally
|
|
73
|
+
try {
|
|
74
|
+
const fd = openSync(QUEUE_FILE, 'r');
|
|
75
|
+
const stat = fstatSync(fd);
|
|
76
|
+
closeSync(fd);
|
|
77
|
+
lastByteOffset = Math.min(options.resumeOffset, stat.size);
|
|
78
|
+
} catch {
|
|
79
|
+
lastByteOffset = 0;
|
|
80
|
+
}
|
|
81
|
+
log.info('mq', `Resuming from offset ${lastByteOffset} (snapshot)`);
|
|
82
|
+
} else {
|
|
83
|
+
// No snapshot — skip existing data (already stale), start from EOF
|
|
84
|
+
try {
|
|
85
|
+
const fd = openSync(QUEUE_FILE, 'r');
|
|
86
|
+
const stat = fstatSync(fd);
|
|
87
|
+
closeSync(fd);
|
|
88
|
+
lastByteOffset = stat.size;
|
|
89
|
+
} catch {
|
|
90
|
+
lastByteOffset = 0;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
partialLine = '';
|
|
94
|
+
|
|
95
|
+
// Initialize lastKnownFileSize so the health check doesn't false-alarm
|
|
96
|
+
// on the first tick (file already has data from before the reader started).
|
|
97
|
+
try {
|
|
98
|
+
const initFd = openSync(QUEUE_FILE, 'r');
|
|
99
|
+
lastKnownFileSize = fstatSync(initFd).size;
|
|
100
|
+
closeSync(initFd);
|
|
101
|
+
} catch {
|
|
102
|
+
lastKnownFileSize = 0;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
log.info('mq', `Queue reader started: ${QUEUE_FILE}`);
|
|
106
|
+
|
|
107
|
+
// Do an immediate read to process any events written while the server was down
|
|
108
|
+
readNewLines();
|
|
109
|
+
|
|
110
|
+
// Start fs.watch for instant notification
|
|
111
|
+
try {
|
|
112
|
+
watcher = watch(QUEUE_FILE, (eventType) => {
|
|
113
|
+
if (eventType === 'change') {
|
|
114
|
+
lastWatchEventAt = Date.now();
|
|
115
|
+
scheduleRead();
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
watcher.on('error', (err: Error) => {
|
|
119
|
+
log.warn('mq', `fs.watch error: ${err.message}, relying on poll`);
|
|
120
|
+
watcher = null;
|
|
121
|
+
});
|
|
122
|
+
} catch (err: unknown) {
|
|
123
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
124
|
+
log.warn('mq', `fs.watch failed: ${msg}, using poll only`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Fallback poll (catches events fs.watch may miss)
|
|
128
|
+
pollTimer = setInterval(() => {
|
|
129
|
+
readNewLines();
|
|
130
|
+
}, POLL_INTERVAL_MS);
|
|
131
|
+
|
|
132
|
+
// Health check: detect when fs.watch silently stops delivering events
|
|
133
|
+
// If no watch events for HEALTH_CHECK_INTERVAL_MS but the file has grown, trigger a manual read
|
|
134
|
+
lastWatchEventAt = Date.now();
|
|
135
|
+
healthCheckTimer = setInterval(() => {
|
|
136
|
+
if (!watcher) return; // Already relying on poll only
|
|
137
|
+
try {
|
|
138
|
+
const fd = openSync(QUEUE_FILE, 'r');
|
|
139
|
+
const stat = fstatSync(fd);
|
|
140
|
+
closeSync(fd);
|
|
141
|
+
const currentSize = stat.size;
|
|
142
|
+
const timeSinceWatch = Date.now() - lastWatchEventAt;
|
|
143
|
+
if (timeSinceWatch > HEALTH_CHECK_INTERVAL_MS && currentSize > lastKnownFileSize) {
|
|
144
|
+
log.warn('mq', `fs.watch stale (${Math.round(timeSinceWatch / 1000)}s silent, file grew ${currentSize - lastKnownFileSize} bytes), triggering manual read`);
|
|
145
|
+
readNewLines();
|
|
146
|
+
}
|
|
147
|
+
lastKnownFileSize = currentSize;
|
|
148
|
+
} catch {
|
|
149
|
+
// File may not exist yet, ignore
|
|
150
|
+
}
|
|
151
|
+
}, HEALTH_CHECK_INTERVAL_MS);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Debounced read scheduler — coalesces rapid fs.watch events */
|
|
155
|
+
function scheduleRead(): void {
|
|
156
|
+
if (debounceTimer) return;
|
|
157
|
+
debounceTimer = setTimeout(() => {
|
|
158
|
+
debounceTimer = null;
|
|
159
|
+
readNewLines();
|
|
160
|
+
}, DEBOUNCE_MS);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Core read loop: reads from lastByteOffset to current EOF,
|
|
165
|
+
* processes complete JSON lines, retains any partial trailing line.
|
|
166
|
+
*/
|
|
167
|
+
function readNewLines(): void {
|
|
168
|
+
let fd: number | undefined;
|
|
169
|
+
try {
|
|
170
|
+
fd = openSync(QUEUE_FILE, 'r');
|
|
171
|
+
const fileStat = fstatSync(fd);
|
|
172
|
+
const fileSize = fileStat.size;
|
|
173
|
+
|
|
174
|
+
// File was truncated externally or is smaller than our offset
|
|
175
|
+
if (fileSize < lastByteOffset) {
|
|
176
|
+
log.info('mq', 'Detected external truncation, resetting offset');
|
|
177
|
+
lastByteOffset = 0;
|
|
178
|
+
partialLine = '';
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (fileSize <= lastByteOffset) {
|
|
182
|
+
closeSync(fd);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Read the new chunk
|
|
187
|
+
const bytesToRead = fileSize - lastByteOffset;
|
|
188
|
+
const buffer = Buffer.alloc(bytesToRead);
|
|
189
|
+
const bytesRead = readSync(fd, buffer, 0, bytesToRead, lastByteOffset);
|
|
190
|
+
closeSync(fd);
|
|
191
|
+
fd = undefined;
|
|
192
|
+
|
|
193
|
+
if (bytesRead === 0) return;
|
|
194
|
+
|
|
195
|
+
const chunk = buffer.toString('utf-8', 0, bytesRead);
|
|
196
|
+
const combined = partialLine + chunk;
|
|
197
|
+
const lines = combined.split('\n');
|
|
198
|
+
|
|
199
|
+
// Last element is either '' (if chunk ended with \n) or a partial line
|
|
200
|
+
partialLine = lines.pop() || '';
|
|
201
|
+
|
|
202
|
+
// Process each complete line
|
|
203
|
+
for (const line of lines) {
|
|
204
|
+
const trimmed = line.trim();
|
|
205
|
+
if (!trimmed) continue;
|
|
206
|
+
|
|
207
|
+
try {
|
|
208
|
+
const hookData = JSON.parse(trimmed);
|
|
209
|
+
processHookEvent(hookData, 'mq');
|
|
210
|
+
mqStats.linesProcessed++;
|
|
211
|
+
} catch (err: unknown) {
|
|
212
|
+
mqStats.linesErrored++;
|
|
213
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
214
|
+
log.warn('mq', `Parse error: ${msg} — line: ${trimmed.substring(0, 100)}`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Update offset: advance by bytes consumed (exclude held-back partial)
|
|
219
|
+
const partialBytes = Buffer.byteLength(partialLine, 'utf-8');
|
|
220
|
+
lastByteOffset = lastByteOffset + bytesRead - partialBytes;
|
|
221
|
+
mqStats.lastProcessedAt = Date.now();
|
|
222
|
+
|
|
223
|
+
// Truncate if file grew too large and we've fully caught up
|
|
224
|
+
if (lastByteOffset > TRUNCATE_THRESHOLD && partialLine === '') {
|
|
225
|
+
truncateQueue();
|
|
226
|
+
}
|
|
227
|
+
} catch (err: unknown) {
|
|
228
|
+
if (fd != null) {
|
|
229
|
+
try { closeSync(fd); } catch { /* ignore */ }
|
|
230
|
+
}
|
|
231
|
+
const e = err as NodeJS.ErrnoException;
|
|
232
|
+
if (e.code !== 'ENOENT') {
|
|
233
|
+
log.warn('mq', `Read error: ${e.message}`);
|
|
234
|
+
} else {
|
|
235
|
+
// Queue file deleted — recreate it
|
|
236
|
+
try { writeFileSync(QUEUE_FILE, ''); } catch { /* ignore */ }
|
|
237
|
+
lastByteOffset = 0;
|
|
238
|
+
partialLine = '';
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Truncate the queue file after all lines have been processed.
|
|
244
|
+
* Checks if file grew since our last read to avoid losing events
|
|
245
|
+
* written between the read and truncation.
|
|
246
|
+
*/
|
|
247
|
+
function truncateQueue(): void {
|
|
248
|
+
let fd: number | undefined;
|
|
249
|
+
try {
|
|
250
|
+
fd = openSync(QUEUE_FILE, 'r+');
|
|
251
|
+
const stat = fstatSync(fd);
|
|
252
|
+
// If file grew since our last read, read the new data first
|
|
253
|
+
if (stat.size > lastByteOffset) {
|
|
254
|
+
const newBytes = stat.size - lastByteOffset;
|
|
255
|
+
const buffer = Buffer.alloc(newBytes);
|
|
256
|
+
const bytesRead = readSync(fd, buffer, 0, newBytes, lastByteOffset);
|
|
257
|
+
if (bytesRead > 0) {
|
|
258
|
+
const chunk = buffer.toString('utf-8', 0, bytesRead);
|
|
259
|
+
const combined = partialLine + chunk;
|
|
260
|
+
const lines = combined.split('\n');
|
|
261
|
+
partialLine = lines.pop() || '';
|
|
262
|
+
for (const line of lines) {
|
|
263
|
+
const trimmed = line.trim();
|
|
264
|
+
if (!trimmed) continue;
|
|
265
|
+
try {
|
|
266
|
+
const hookData = JSON.parse(trimmed);
|
|
267
|
+
processHookEvent(hookData, 'mq');
|
|
268
|
+
mqStats.linesProcessed++;
|
|
269
|
+
} catch (err: unknown) {
|
|
270
|
+
mqStats.linesErrored++;
|
|
271
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
272
|
+
log.warn('mq', `Parse error during truncation: ${msg}`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
// Now truncate — write remaining partial line (if any) to start of file
|
|
278
|
+
closeSync(fd);
|
|
279
|
+
fd = undefined;
|
|
280
|
+
writeFileSync(QUEUE_FILE, partialLine);
|
|
281
|
+
lastByteOffset = Buffer.byteLength(partialLine, 'utf-8');
|
|
282
|
+
partialLine = '';
|
|
283
|
+
mqStats.truncations++;
|
|
284
|
+
log.info('mq', 'Queue file truncated (all events processed)');
|
|
285
|
+
} catch (err: unknown) {
|
|
286
|
+
if (fd != null) {
|
|
287
|
+
try { closeSync(fd); } catch { /* ignore */ }
|
|
288
|
+
}
|
|
289
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
290
|
+
log.warn('mq', `Truncation error: ${msg}`);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Stop the MQ reader. Called during server shutdown. */
|
|
295
|
+
export function stopMqReader(): void {
|
|
296
|
+
running = false;
|
|
297
|
+
if (watcher) {
|
|
298
|
+
watcher.close();
|
|
299
|
+
watcher = null;
|
|
300
|
+
}
|
|
301
|
+
if (pollTimer) {
|
|
302
|
+
clearInterval(pollTimer);
|
|
303
|
+
pollTimer = null;
|
|
304
|
+
}
|
|
305
|
+
if (healthCheckTimer) {
|
|
306
|
+
clearInterval(healthCheckTimer);
|
|
307
|
+
healthCheckTimer = null;
|
|
308
|
+
}
|
|
309
|
+
if (debounceTimer) {
|
|
310
|
+
clearTimeout(debounceTimer);
|
|
311
|
+
debounceTimer = null;
|
|
312
|
+
}
|
|
313
|
+
// Final read to flush remaining lines
|
|
314
|
+
readNewLines();
|
|
315
|
+
log.info('mq', `Queue reader stopped. Processed: ${mqStats.linesProcessed}, Errors: ${mqStats.linesErrored}`);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export interface MqStatsResult {
|
|
319
|
+
linesProcessed: number;
|
|
320
|
+
linesErrored: number;
|
|
321
|
+
truncations: number;
|
|
322
|
+
lastProcessedAt: number | null;
|
|
323
|
+
startedAt: number | null;
|
|
324
|
+
queueFile: string;
|
|
325
|
+
running: boolean;
|
|
326
|
+
currentOffset: number;
|
|
327
|
+
hasPartialLine: boolean;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Get MQ reader stats for the API. */
|
|
331
|
+
export function getMqStats(): MqStatsResult {
|
|
332
|
+
return {
|
|
333
|
+
...mqStats,
|
|
334
|
+
queueFile: QUEUE_FILE,
|
|
335
|
+
running,
|
|
336
|
+
currentOffset: lastByteOffset,
|
|
337
|
+
hasPartialLine: partialLine.length > 0,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** Get the current byte offset (used by snapshot persistence). */
|
|
342
|
+
export function getMqOffset(): number {
|
|
343
|
+
return lastByteOffset;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** Get the queue file path (used by install-hooks logging). */
|
|
347
|
+
export function getQueueFilePath(): string {
|
|
348
|
+
return QUEUE_FILE;
|
|
349
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module portManager
|
|
3
|
+
* Resolves the server listen port (--port flag > PORT env > config > 3333) and provides
|
|
4
|
+
* killPortProcess() to detect and terminate processes occupying the target port via lsof/netstat.
|
|
5
|
+
*/
|
|
6
|
+
import { execSync } from 'child_process';
|
|
7
|
+
import type { ServerConfig } from '../src/types/settings.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Resolve which port to listen on.
|
|
11
|
+
* Priority: --port flag > PORT env > config file > 3333
|
|
12
|
+
*/
|
|
13
|
+
export function resolvePort(cliArgs: string[], config: ServerConfig): number {
|
|
14
|
+
const portArgIdx = cliArgs.indexOf('--port');
|
|
15
|
+
if (portArgIdx >= 0 && cliArgs[portArgIdx + 1]) {
|
|
16
|
+
const p = parseInt(cliArgs[portArgIdx + 1], 10);
|
|
17
|
+
if (p > 0) return p;
|
|
18
|
+
}
|
|
19
|
+
if (process.env.PORT) {
|
|
20
|
+
const p = parseInt(process.env.PORT, 10);
|
|
21
|
+
if (p > 0) return p;
|
|
22
|
+
}
|
|
23
|
+
return config.port || 3333;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Kill any process currently occupying the given port.
|
|
28
|
+
*/
|
|
29
|
+
export function killPortProcess(port: number): void {
|
|
30
|
+
try {
|
|
31
|
+
if (process.platform === 'win32') {
|
|
32
|
+
const output = execSync(
|
|
33
|
+
`netstat -ano | findstr :${port} | findstr LISTENING`,
|
|
34
|
+
{ encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
|
|
35
|
+
);
|
|
36
|
+
const pids = [...new Set(
|
|
37
|
+
output.trim().split('\n')
|
|
38
|
+
.map(line => line.trim().split(/\s+/).pop())
|
|
39
|
+
.filter(Boolean)
|
|
40
|
+
)];
|
|
41
|
+
for (const pid of pids) {
|
|
42
|
+
try { execSync(`taskkill /F /PID ${pid}`, { stdio: 'pipe' }); } catch { /* already dead */ }
|
|
43
|
+
}
|
|
44
|
+
} else {
|
|
45
|
+
// macOS & Linux
|
|
46
|
+
const output = execSync(`lsof -ti:${port}`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
47
|
+
const pids = output.trim().split('\n').filter(Boolean);
|
|
48
|
+
for (const pid of pids) {
|
|
49
|
+
try { process.kill(Number(pid), 'SIGTERM'); } catch { /* already dead */ }
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
} catch {
|
|
53
|
+
// No process found on port — nothing to kill
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module processMonitor
|
|
3
|
+
* Periodically checks whether session PIDs are still alive via process.kill(pid, 0).
|
|
4
|
+
* Auto-ends sessions whose processes have died (e.g., terminal closed abruptly).
|
|
5
|
+
* Also provides findClaudeProcess() with cached PID, pgrep, and lsof fallbacks.
|
|
6
|
+
*/
|
|
7
|
+
import { execSync } from 'child_process';
|
|
8
|
+
import { getTerminalForSession } from './sshManager.js';
|
|
9
|
+
import { SESSION_STATUS, ANIMATION_STATE, WS_TYPES } from './constants.js';
|
|
10
|
+
import { PROCESS_CHECK_INTERVAL } from './config.js';
|
|
11
|
+
import log from './logger.js';
|
|
12
|
+
import type { Session } from '../src/types/session.js';
|
|
13
|
+
import type { ServerMessage } from '../src/types/websocket.js';
|
|
14
|
+
|
|
15
|
+
// Validate PID as a positive integer. Returns the validated number or null.
|
|
16
|
+
function validatePid(pid: unknown): number | null {
|
|
17
|
+
const n = parseInt(String(pid), 10);
|
|
18
|
+
return Number.isFinite(n) && n > 0 ? n : null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let livenessInterval: ReturnType<typeof setInterval> | null = null;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Start the process liveness monitor.
|
|
25
|
+
* Periodically checks if session PIDs are still alive and auto-ends dead sessions.
|
|
26
|
+
*/
|
|
27
|
+
export function startMonitoring(
|
|
28
|
+
sessions: Map<string, Session>,
|
|
29
|
+
pidToSession: Map<number, string>,
|
|
30
|
+
clearApprovalTimerFn: (sessionId: string, session: Session) => void,
|
|
31
|
+
handleTeamMemberEndFn: (sessionId: string) => void,
|
|
32
|
+
broadcastFn: (data: ServerMessage) => Promise<void>,
|
|
33
|
+
): void {
|
|
34
|
+
if (livenessInterval) return;
|
|
35
|
+
|
|
36
|
+
livenessInterval = setInterval(async () => {
|
|
37
|
+
for (const [id, session] of sessions) {
|
|
38
|
+
if (session.status === SESSION_STATUS.ENDED) continue;
|
|
39
|
+
if (!session.cachedPid) continue;
|
|
40
|
+
const monitorPid = validatePid(session.cachedPid);
|
|
41
|
+
if (!monitorPid) {
|
|
42
|
+
session.cachedPid = null;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Skip sessions with active terminal — the PTY is the source of truth
|
|
47
|
+
if (session.terminalId && getTerminalForSession(id)) continue;
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
process.kill(monitorPid, 0); // signal 0 = liveness check, doesn't kill
|
|
51
|
+
} catch {
|
|
52
|
+
// Process is dead — auto-end this session
|
|
53
|
+
log.info('session', `processMonitor: pid=${session.cachedPid} is dead -> ending session=${id.slice(0, 8)}`);
|
|
54
|
+
|
|
55
|
+
session.status = SESSION_STATUS.ENDED;
|
|
56
|
+
session.animationState = ANIMATION_STATE.DEATH;
|
|
57
|
+
session.lastActivityAt = Date.now();
|
|
58
|
+
session.endedAt = Date.now();
|
|
59
|
+
|
|
60
|
+
session.events.push({
|
|
61
|
+
type: 'SessionEnd',
|
|
62
|
+
timestamp: Date.now(),
|
|
63
|
+
detail: 'Session ended (process exited)',
|
|
64
|
+
});
|
|
65
|
+
if (session.events.length > 50) session.events.shift();
|
|
66
|
+
|
|
67
|
+
// Release PID cache
|
|
68
|
+
pidToSession.delete(session.cachedPid);
|
|
69
|
+
session.cachedPid = null;
|
|
70
|
+
|
|
71
|
+
// Clear any pending tool timer
|
|
72
|
+
clearApprovalTimerFn(id, session);
|
|
73
|
+
|
|
74
|
+
// Team cleanup
|
|
75
|
+
handleTeamMemberEndFn(id);
|
|
76
|
+
|
|
77
|
+
// Broadcast to connected browsers
|
|
78
|
+
try {
|
|
79
|
+
await broadcastFn({ type: WS_TYPES.SESSION_UPDATE, session: { ...session } });
|
|
80
|
+
} catch (e: unknown) {
|
|
81
|
+
log.warn('session', `processMonitor broadcast failed: ${(e as Error).message}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// SSH sessions: keep in memory as historical (disconnected), preserve terminal ref for resume
|
|
85
|
+
if (session.source === 'ssh') {
|
|
86
|
+
session.isHistorical = true;
|
|
87
|
+
session.lastTerminalId = session.terminalId;
|
|
88
|
+
session.terminalId = null;
|
|
89
|
+
} else {
|
|
90
|
+
setTimeout(() => sessions.delete(id), 10000);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}, PROCESS_CHECK_INTERVAL);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Stop the process liveness monitor.
|
|
99
|
+
*/
|
|
100
|
+
export function stopMonitoring(): void {
|
|
101
|
+
if (livenessInterval) {
|
|
102
|
+
clearInterval(livenessInterval);
|
|
103
|
+
livenessInterval = null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Find the Claude process PID for a given session.
|
|
109
|
+
* Uses cached PID first, then falls back to pgrep/lsof.
|
|
110
|
+
*/
|
|
111
|
+
export function findClaudeProcess(
|
|
112
|
+
sessionId: string,
|
|
113
|
+
projectPath: string,
|
|
114
|
+
sessions: Map<string, Session>,
|
|
115
|
+
pidToSession: Map<number, string>,
|
|
116
|
+
): number | null {
|
|
117
|
+
const session = sessionId ? sessions.get(sessionId) : null;
|
|
118
|
+
if (session?.cachedPid) {
|
|
119
|
+
const validCachedPid = validatePid(session.cachedPid);
|
|
120
|
+
if (validCachedPid) {
|
|
121
|
+
try {
|
|
122
|
+
process.kill(validCachedPid, 0); // signal 0 = liveness check
|
|
123
|
+
log.debug('findProcess', `session=${sessionId?.slice(0, 8)} -> cached pid=${validCachedPid}`);
|
|
124
|
+
return validCachedPid;
|
|
125
|
+
} catch {
|
|
126
|
+
log.debug('findProcess', `session=${sessionId?.slice(0, 8)} cached pid=${validCachedPid} is dead, re-scanning`);
|
|
127
|
+
pidToSession.delete(validCachedPid);
|
|
128
|
+
session.cachedPid = null;
|
|
129
|
+
}
|
|
130
|
+
} else {
|
|
131
|
+
session.cachedPid = null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const myPid = process.pid;
|
|
136
|
+
log.debug('findProcess', `session=${sessionId?.slice(0, 8)} projectPath=${projectPath}`);
|
|
137
|
+
|
|
138
|
+
const claimedPids = new Set<number>();
|
|
139
|
+
for (const [pid, sid] of pidToSession) {
|
|
140
|
+
if (sid !== sessionId) claimedPids.add(pid);
|
|
141
|
+
}
|
|
142
|
+
if (claimedPids.size > 0) {
|
|
143
|
+
log.debug('findProcess', `PIDs claimed by other sessions: [${[...claimedPids].join(', ')}]`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
if (process.platform === 'win32') {
|
|
148
|
+
if (!projectPath) return null;
|
|
149
|
+
const psScript = `
|
|
150
|
+
$procs = Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -like '*claude*' -and $_.ProcessId -ne ${myPid} }
|
|
151
|
+
foreach ($p in $procs) {
|
|
152
|
+
try {
|
|
153
|
+
$proc = Get-Process -Id $p.ProcessId -ErrorAction Stop
|
|
154
|
+
if ($proc.Path) {
|
|
155
|
+
$cwd = (Get-Process -Id $p.ProcessId).Path | Split-Path
|
|
156
|
+
}
|
|
157
|
+
} catch {}
|
|
158
|
+
}
|
|
159
|
+
if ($procs.Count -gt 0) { $procs[0].ProcessId }
|
|
160
|
+
`;
|
|
161
|
+
const out = execSync(
|
|
162
|
+
`powershell -NoProfile -Command "${psScript.replace(/"/g, '\\"')}"`,
|
|
163
|
+
{ encoding: 'utf-8', timeout: 5000 }
|
|
164
|
+
);
|
|
165
|
+
const pid = validatePid(out.trim());
|
|
166
|
+
if (pid) cachePid(pid, sessionId, session, pidToSession);
|
|
167
|
+
return pid || null;
|
|
168
|
+
} else {
|
|
169
|
+
const pidsOut = execSync(`pgrep -f claude 2>/dev/null || true`, { encoding: 'utf-8', timeout: 5000 });
|
|
170
|
+
const pids = pidsOut.trim().split('\n')
|
|
171
|
+
.map(p => validatePid(p.trim()))
|
|
172
|
+
.filter((p): p is number => p !== null && p !== myPid);
|
|
173
|
+
|
|
174
|
+
log.debug('findProcess', `pgrep found ${pids.length} claude pids: [${pids.join(', ')}]`);
|
|
175
|
+
|
|
176
|
+
if (pids.length === 0) return null;
|
|
177
|
+
|
|
178
|
+
if (projectPath) {
|
|
179
|
+
for (const pid of pids) {
|
|
180
|
+
if (claimedPids.has(pid)) {
|
|
181
|
+
log.debug('findProcess', `pid=${pid} SKIP (claimed by session ${pidToSession.get(pid)?.slice(0, 8)})`);
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
try {
|
|
185
|
+
let cwd: string;
|
|
186
|
+
if (process.platform === 'darwin') {
|
|
187
|
+
const out = execSync(`lsof -a -d cwd -Fn -p ${pid} 2>/dev/null | grep '^n'`, { encoding: 'utf-8', timeout: 3000 });
|
|
188
|
+
cwd = out.trim().replace(/^n/, '');
|
|
189
|
+
} else {
|
|
190
|
+
cwd = execSync(`readlink /proc/${pid}/cwd 2>/dev/null`, { encoding: 'utf-8', timeout: 3000 }).trim();
|
|
191
|
+
}
|
|
192
|
+
const match = cwd === projectPath;
|
|
193
|
+
log.debug('findProcess', `pid=${pid} cwd="${cwd}" ${match ? 'MATCH' : 'no match'}`);
|
|
194
|
+
if (match) {
|
|
195
|
+
cachePid(pid, sessionId, session, pidToSession);
|
|
196
|
+
return pid;
|
|
197
|
+
}
|
|
198
|
+
} catch (e: unknown) {
|
|
199
|
+
log.debug('findProcess', `pid=${pid} cwd lookup failed: ${(e as Error).message?.split('\n')[0]}`);
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
log.debug('findProcess', `no cwd match found, trying tty fallback`);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
for (const pid of pids) {
|
|
207
|
+
if (claimedPids.has(pid)) continue;
|
|
208
|
+
try {
|
|
209
|
+
const tty = execSync(`ps -o tty= -p ${pid}`, { encoding: 'utf-8', timeout: 3000 }).trim();
|
|
210
|
+
log.debug('findProcess', `fallback pid=${pid} tty=${tty || 'NONE'}`);
|
|
211
|
+
if (tty && tty !== '??' && tty !== '?') {
|
|
212
|
+
log.debug('findProcess', `FALLBACK returning pid=${pid} (first unclaimed with tty)`);
|
|
213
|
+
cachePid(pid, sessionId, session, pidToSession);
|
|
214
|
+
return pid;
|
|
215
|
+
}
|
|
216
|
+
} catch { continue; }
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const unclaimed = pids.find(p => !claimedPids.has(p));
|
|
220
|
+
log.debug('findProcess', `last resort returning pid=${unclaimed || 'null'}`);
|
|
221
|
+
if (unclaimed) cachePid(unclaimed, sessionId, session, pidToSession);
|
|
222
|
+
return unclaimed || null;
|
|
223
|
+
}
|
|
224
|
+
} catch (e: unknown) {
|
|
225
|
+
log.error('findProcess', `ERROR: ${(e as Error).message}`);
|
|
226
|
+
}
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function cachePid(
|
|
231
|
+
pid: number,
|
|
232
|
+
sessionId: string,
|
|
233
|
+
session: Session | null | undefined,
|
|
234
|
+
pidToSession: Map<number, string>,
|
|
235
|
+
): void {
|
|
236
|
+
pidToSession.set(pid, sessionId);
|
|
237
|
+
if (session) session.cachedPid = pid;
|
|
238
|
+
log.debug('findProcess', `CACHED pid=${pid} -> session=${sessionId?.slice(0, 8)}`);
|
|
239
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// serverConfig.ts — Loads user config from data/server-config.json
|
|
2
|
+
// Falls back to defaults if file is missing (first run without wizard)
|
|
3
|
+
|
|
4
|
+
import { readFileSync } from 'fs';
|
|
5
|
+
import { join, dirname } from 'path';
|
|
6
|
+
import { fileURLToPath } from 'url';
|
|
7
|
+
import type { ServerConfig } from '../src/types/settings.js';
|
|
8
|
+
|
|
9
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const CONFIG_PATH = join(__dirname, '..', 'data', 'server-config.json');
|
|
11
|
+
|
|
12
|
+
const DEFAULTS: ServerConfig = {
|
|
13
|
+
port: 3333,
|
|
14
|
+
hookDensity: 'medium',
|
|
15
|
+
debug: false,
|
|
16
|
+
processCheckInterval: 15000,
|
|
17
|
+
sessionHistoryHours: 24,
|
|
18
|
+
enabledClis: ['claude'],
|
|
19
|
+
passwordHash: null,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
let userConfig: Partial<ServerConfig> = {};
|
|
23
|
+
try {
|
|
24
|
+
userConfig = JSON.parse(readFileSync(CONFIG_PATH, 'utf8'));
|
|
25
|
+
} catch {
|
|
26
|
+
// No config file yet — use defaults
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const config: ServerConfig = { ...DEFAULTS, ...userConfig };
|