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.
Files changed (58) hide show
  1. package/README.md +484 -429
  2. package/docs/3D/ADAPTATION_GUIDE.md +592 -0
  3. package/docs/3D/index.html +754 -0
  4. package/docs/AGENT_TEAM_TASKS.md +716 -0
  5. package/docs/CYBERDROME_V2_SPEC.md +531 -0
  6. package/docs/ERROR_185_ANALYSIS.md +263 -0
  7. package/docs/PLATFORM_FEATURES_PROMPT.md +296 -0
  8. package/docs/SESSION_DETAIL_FEATURES.md +98 -0
  9. package/docs/_3d_multimedia_features.md +1080 -0
  10. package/docs/_frontend_features.md +1057 -0
  11. package/docs/_server_features.md +1077 -0
  12. package/docs/session-duplication-fixes.md +271 -0
  13. package/docs/session-terminal-linkage.md +412 -0
  14. package/package.json +63 -5
  15. package/public/apple-touch-icon.svg +21 -0
  16. package/public/css/dashboard.css +0 -161
  17. package/public/css/detail-panel.css +25 -0
  18. package/public/css/layout.css +18 -1
  19. package/public/css/modals.css +0 -26
  20. package/public/css/settings.css +0 -150
  21. package/public/css/terminal.css +34 -0
  22. package/public/favicon.svg +18 -0
  23. package/public/index.html +6 -26
  24. package/public/js/alarmManager.js +0 -21
  25. package/public/js/app.js +21 -7
  26. package/public/js/detailPanel.js +63 -64
  27. package/public/js/historyPanel.js +61 -55
  28. package/public/js/quickActions.js +132 -48
  29. package/public/js/sessionCard.js +5 -20
  30. package/public/js/sessionControls.js +8 -0
  31. package/public/js/settingsManager.js +0 -142
  32. package/server/apiRouter.js +60 -15
  33. package/server/apiRouter.ts +774 -0
  34. package/server/approvalDetector.ts +94 -0
  35. package/server/authManager.ts +144 -0
  36. package/server/autoIdleManager.ts +110 -0
  37. package/server/config.ts +121 -0
  38. package/server/constants.ts +150 -0
  39. package/server/db.ts +475 -0
  40. package/server/hookInstaller.d.ts +3 -0
  41. package/server/hookProcessor.ts +108 -0
  42. package/server/hookRouter.ts +18 -0
  43. package/server/hookStats.ts +116 -0
  44. package/server/index.js +15 -1
  45. package/server/index.ts +230 -0
  46. package/server/logger.ts +75 -0
  47. package/server/mqReader.ts +349 -0
  48. package/server/portManager.ts +55 -0
  49. package/server/processMonitor.ts +239 -0
  50. package/server/serverConfig.ts +29 -0
  51. package/server/sessionMatcher.js +17 -6
  52. package/server/sessionMatcher.ts +403 -0
  53. package/server/sessionStore.js +109 -3
  54. package/server/sessionStore.ts +1145 -0
  55. package/server/sshManager.js +167 -24
  56. package/server/sshManager.ts +671 -0
  57. package/server/teamManager.ts +289 -0
  58. 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 };