claude-remote 0.5.0 → 0.5.2

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/server.js CHANGED
@@ -1,2257 +1,33 @@
1
- const http = require('http');
2
- const fs = require('fs');
3
- const path = require('path');
4
- const os = require('os');
5
- const pty = require('node-pty');
6
- const { WebSocketServer, WebSocket } = require('ws');
7
- const crypto = require('crypto');
8
- const { execSync, spawn } = require('child_process');
9
-
10
- // --- CLI argument parsing ---
11
- // Separate bridge args (CWD positional) from claude passthrough flags.
12
- // Usage: claude-remote [cwd] [--claude-flags...]
13
- // Example: claude-remote --resume xxx
14
- // claude-remote /path/to/project --resume xxx -c
15
- const BLOCKED_FLAGS = new Set([
16
- '--print', '-p', // non-interactive mode, breaks PTY bridge
17
- '--output-format', // requires --print
18
- '--input-format', // requires --print
19
- '--include-partial-messages', // requires --print
20
- '--json-schema', // requires --print
21
- '--no-session-persistence', // requires --print
22
- '--max-budget-usd', // requires --print
23
- '--max-turns', // requires --print
24
- '--fallback-model', // requires --print
25
- '--permission-prompt-tool', // conflicts with bridge approval hooks
26
- '--version', '-v', // exits immediately
27
- '--help', '-h', // exits immediately
28
- '--init-only', // exits immediately
29
- '--maintenance', // exits immediately
30
- '--token', // bridge-only: auth token
31
- '--no-auth', // bridge-only: disable auth
32
- ]);
33
-
34
- // Flags that consume the next argument as a value
35
- const FLAGS_WITH_VALUE = new Set([
36
- '--resume', '-r', '--session-id', '--from-pr', '--model',
37
- '--system-prompt', '--system-prompt-file',
38
- '--append-system-prompt', '--append-system-prompt-file',
39
- '--permission-mode', '--add-dir', '--worktree', '-w',
40
- '--mcp-config', '--settings', '--setting-sources',
41
- '--agent', '--agents', '--teammate-mode',
42
- '--allowedTools', '--disallowedTools', '--tools',
43
- '--betas', '--debug', '--plugin-dir',
44
- // blocked but still need to consume their values when filtering
45
- '--output-format', '--input-format', '--json-schema',
46
- '--max-budget-usd', '--max-turns', '--fallback-model',
47
- '--permission-prompt-tool',
48
- '--token', // bridge-only: auth token
49
- ]);
50
-
51
- function parseCliArgs(argv) {
52
- const rawArgs = argv.slice(2);
53
- let cwd = null;
54
- const claudeArgs = [];
55
- const blocked = [];
56
- let token = null;
57
- let noAuth = false;
58
-
59
- let i = 0;
60
- while (i < rawArgs.length) {
61
- const arg = rawArgs[i];
62
-
63
- if (arg === '--') {
64
- // Everything after -- is passed to claude
65
- claudeArgs.push(...rawArgs.slice(i + 1));
66
- break;
67
- }
68
-
69
- if (!arg.startsWith('-')) {
70
- // Positional arg → treat first one as CWD (backward compatible)
71
- if (!cwd) {
72
- cwd = arg;
73
- } else {
74
- claudeArgs.push(arg);
75
- }
76
- i++;
77
- continue;
78
- }
79
-
80
- // Handle --flag=value syntax
81
- const eqIdx = arg.indexOf('=');
82
- const flagName = eqIdx > 0 ? arg.substring(0, eqIdx) : arg;
83
-
84
- // Intercept bridge-only flags
85
- if (flagName === '--token') {
86
- if (eqIdx > 0) {
87
- token = arg.substring(eqIdx + 1);
88
- } else if (i + 1 < rawArgs.length && !rawArgs[i + 1].startsWith('-')) {
89
- i++;
90
- token = rawArgs[i];
91
- } else {
92
- token = '';
93
- }
94
- i++;
95
- continue;
96
- }
97
- if (flagName === '--no-auth') {
98
- noAuth = true;
99
- i++;
100
- continue;
101
- }
102
-
103
- if (BLOCKED_FLAGS.has(flagName)) {
104
- blocked.push(flagName);
105
- if (eqIdx > 0) {
106
- // --flag=value, already consumed
107
- } else if (FLAGS_WITH_VALUE.has(flagName) && i + 1 < rawArgs.length) {
108
- i++; // skip the value too
109
- }
110
- i++;
111
- continue;
112
- }
113
-
114
- // Pass through to claude
115
- claudeArgs.push(arg);
116
- // If this flag takes a value and it's not in --flag=value form, grab next arg
117
- if (eqIdx < 0 && FLAGS_WITH_VALUE.has(flagName) && i + 1 < rawArgs.length && !rawArgs[i + 1].startsWith('-')) {
118
- i++;
119
- claudeArgs.push(rawArgs[i]);
120
- }
121
- i++;
122
- }
123
-
124
- return { cwd: cwd || process.cwd(), claudeArgs, blocked, token, noAuth };
125
- }
126
-
127
- const { cwd: _parsedCwd, claudeArgs: CLAUDE_EXTRA_ARGS, blocked: _blockedArgs, token: _cliToken, noAuth: _cliNoAuth } = parseCliArgs(process.argv);
128
-
129
- // --- Auth token resolution ---
130
- const AUTH_TOKEN_ENV_VAR = 'CLAUDE_REMOTE_TOKEN';
131
- const LEGACY_AUTH_TOKEN_ENV_VAR = 'TOKEN';
132
- const AUTH_DISABLED = _cliNoAuth || process.env.NO_AUTH === '1';
133
- const TOKEN_FILE = path.join(os.homedir(), '.claude-remote-token');
134
- const UNUSED_LEGACY_TOKEN_ENV = !!process.env[LEGACY_AUTH_TOKEN_ENV_VAR] && !process.env[AUTH_TOKEN_ENV_VAR];
135
-
136
- function resolveAuthToken() {
137
- if (AUTH_DISABLED) return null;
138
- // 1. CLI --token
139
- if (_cliToken) return _cliToken;
140
- // 2. Namespaced env
141
- if (process.env[AUTH_TOKEN_ENV_VAR]) return process.env[AUTH_TOKEN_ENV_VAR];
142
- // 3. Persisted file
143
- try {
144
- const saved = fs.readFileSync(TOKEN_FILE, 'utf8').trim();
145
- if (saved) return saved;
146
- } catch {}
147
- // 4. Generate and persist
148
- const generated = crypto.randomBytes(24).toString('base64url');
149
- try { fs.writeFileSync(TOKEN_FILE, generated + '\n', { mode: 0o600 }); } catch {}
150
- return generated;
151
- }
152
-
153
- const AUTH_TOKEN = resolveAuthToken();
154
-
155
- // --- Config ---
156
- const PORT = parseInt(process.env.PORT || '3100', 10);
157
- let CWD = _parsedCwd;
158
- const CLAUDE_HOME = path.join(os.homedir(), '.claude');
159
- const CLAUDE_STATE_FILE = path.join(os.homedir(), '.claude.json');
160
- const PROJECTS_DIR = path.join(CLAUDE_HOME, 'projects');
161
- const AUTH_HELLO_TIMEOUT_MS = 5000;
162
- const WS_CLOSE_AUTH_FAILED = 4001;
163
- const WS_CLOSE_AUTH_TIMEOUT = 4002;
164
- const WS_CLOSE_REASON_AUTH_FAILED = 'auth_failed';
165
- const WS_CLOSE_REASON_AUTH_TIMEOUT = 'auth_timeout';
166
- const DEBUG_TTY_INPUT = process.env.CLAUDE_REMOTE_DEBUG_TTY_INPUT === '1';
167
-
168
- // --- State ---
169
- let claudeProc = null;
170
- let transcriptPath = null;
171
- let currentSessionId = null;
172
- let transcriptOffset = 0;
173
- let eventBuffer = [];
174
- let eventSeq = 0;
175
- const EVENT_BUFFER_MAX = 5000;
176
- let nextWsId = 0;
177
- let tailTimer = null;
178
- let switchWatcher = null;
179
- let switchWatcherDelayTimer = null;
180
- let expectingSwitch = false;
181
- let expectingSwitchTimer = null;
182
- let pendingSwitchTarget = null;
183
- let pendingInitialClearTranscript = null; // { sessionId }
184
- let tailRemainder = Buffer.alloc(0);
185
- let tailCatchingUp = false; // true while reading historical transcript content
186
- const isTTY = process.stdin.isTTY && process.stdout.isTTY;
187
- const LEGACY_REPLAY_DELAY_MS = 1500;
188
- const IMAGE_UPLOAD_TTL_MS = 15 * 60 * 1000;
189
- const LINUX_CLIPBOARD_READY_GRACE_MS = 400;
190
- const LINUX_AT_PROMPT_SUBMIT_DELAY_MS = 450;
191
- const LINUX_AT_IMAGE_CLEANUP_DELAY_MS = 10 * 60 * 1000;
192
- let turnStateVersion = 0;
193
- let turnState = {
194
- phase: 'idle',
195
- sessionId: null,
196
- version: 0,
197
- updatedAt: Date.now(),
198
- };
199
- let ttyInputForwarderAttached = false;
200
- let ttyInputHandler = null;
201
- let ttyResizeHandler = null;
202
- let activeLinuxClipboardProc = null;
203
- let linuxImagePasteInFlight = false;
204
-
205
- // --- Permission approval state ---
206
- let approvalSeq = 0;
207
- const pendingApprovals = new Map(); // id → { res, timer }
208
- const pendingImageUploads = new Map();
209
- let approvalMode = 'default'; // 'default' | 'partial' | 'all'
210
- const ALWAYS_AUTO_ALLOW = new Set(['TaskCreate', 'TaskUpdate']);
211
- const PARTIAL_AUTO_ALLOW = new Set(['Read', 'Glob', 'Grep', 'Write', 'Edit']);
212
-
213
- // --- Logging → file only (never pollute the terminal) ---
214
- const LOG_FILE = path.join(os.homedir(), '.claude', 'bridge.log');
215
- fs.writeFileSync(LOG_FILE, `--- Bridge started ${new Date().toISOString()} ---\n`);
216
- function log(msg) {
217
- const line = `[${new Date().toISOString()}] ${msg}\n`;
218
- fs.appendFileSync(LOG_FILE, line);
219
- }
220
-
221
- function formatTtyInputChunk(chunk) {
222
- const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
223
- return `len=${buf.length} hex=${buf.toString('hex')} base64=${buf.toString('base64')} utf8=${JSON.stringify(buf.toString('utf8'))}`;
224
- }
225
-
226
- function clearActiveLinuxClipboardProc(reason = '') {
227
- if (!activeLinuxClipboardProc) return;
228
- const { child, tool } = activeLinuxClipboardProc;
229
- activeLinuxClipboardProc = null;
230
- try {
231
- child.kill('SIGTERM');
232
- log(`Linux clipboard process terminated (${tool}) reason=${reason || 'cleanup'}`);
233
- } catch (err) {
234
- log(`Linux clipboard process terminate error (${tool}): ${err.message}`);
235
- }
236
- }
237
-
238
- function wsLabel(ws) {
239
- const clientId = ws && ws._clientInstanceId ? ` client=${ws._clientInstanceId}` : '';
240
- return `ws#${ws && ws._bridgeId ? ws._bridgeId : '?'}${clientId}`;
241
- }
242
-
243
- function isAuthenticatedClient(ws) {
244
- return !!ws && ws.readyState === WebSocket.OPEN && !!ws._authenticated;
245
- }
246
-
247
- function sendWs(ws, msg, context = '') {
248
- if (!ws || ws.readyState !== WebSocket.OPEN) return false;
249
- ws.send(JSON.stringify(msg));
250
- if (msg.type === 'status' || msg.type === 'transcript_ready' || msg.type === 'replay_done' || msg.type === 'turn_state') {
251
- const extra = [];
252
- if (msg.sessionId !== undefined) extra.push(`session=${msg.sessionId ?? 'null'}`);
253
- if (msg.lastSeq !== undefined) extra.push(`lastSeq=${msg.lastSeq}`);
254
- if (msg.resumed !== undefined) extra.push(`resumed=${msg.resumed}`);
255
- if (msg.phase !== undefined) extra.push(`phase=${msg.phase}`);
256
- if (msg.version !== undefined) extra.push(`version=${msg.version}`);
257
- log(`Send ${msg.type}${context ? ` (${context})` : ''} -> ${wsLabel(ws)}${extra.length ? ` ${extra.join(' ')}` : ''}`);
258
- }
259
- return true;
260
- }
261
-
262
- function getTurnStatePayload() {
263
- return {
264
- type: 'turn_state',
265
- phase: turnState.phase,
266
- sessionId: turnState.sessionId,
267
- version: turnState.version,
268
- updatedAt: turnState.updatedAt,
269
- reason: turnState.reason || '',
270
- };
271
- }
272
-
273
- function sendTurnState(ws, context = '') {
274
- return sendWs(ws, getTurnStatePayload(), context);
275
- }
276
-
277
- function setTurnState(phase, { sessionId = currentSessionId, reason = '', force = false } = {}) {
278
- const normalizedPhase = phase === 'running' ? 'running' : 'idle';
279
- const normalizedSessionId = sessionId || null;
280
- const changed = force ||
281
- turnState.phase !== normalizedPhase ||
282
- turnState.sessionId !== normalizedSessionId;
283
-
284
- if (!changed) return false;
285
-
286
- turnState = {
287
- phase: normalizedPhase,
288
- sessionId: normalizedSessionId,
289
- version: ++turnStateVersion,
290
- updatedAt: Date.now(),
291
- reason,
292
- };
293
-
294
- log(`Turn state -> phase=${turnState.phase} session=${turnState.sessionId ?? 'null'} version=${turnState.version}${reason ? ` reason=${reason}` : ''}`);
295
- broadcast(getTurnStatePayload());
296
- return true;
297
- }
298
-
299
- function emitInterrupt(source) {
300
- const interruptEvent = {
301
- type: 'interrupt',
302
- source,
303
- timestamp: Date.now(),
304
- uuid: crypto.randomUUID(),
305
- };
306
- const record = { seq: ++eventSeq, event: interruptEvent };
307
- eventBuffer.push(record);
308
- if (eventBuffer.length > EVENT_BUFFER_MAX) {
309
- eventBuffer = eventBuffer.slice(-Math.round(EVENT_BUFFER_MAX * 0.8));
310
- }
311
- broadcast({ type: 'log_event', seq: record.seq, event: interruptEvent });
312
- setTurnState('idle', { reason: `${source}_interrupt` });
313
- }
314
-
315
- function attachTtyForwarders() {
316
- if (!isTTY || ttyInputForwarderAttached) return;
317
-
318
- ttyInputHandler = (chunk) => {
319
- if (DEBUG_TTY_INPUT) {
320
- try {
321
- log(`TTY input ${formatTtyInputChunk(chunk)}`);
322
- } catch (err) {
323
- log(`TTY input log error: ${err.message}`);
324
- }
325
- }
326
- if (claudeProc) claudeProc.write(chunk);
327
- // Detect Ctrl+C (0x03) from local terminal and sync state
328
- const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
329
- if (buf.includes(0x03) && turnState.phase === 'running') {
330
- log('Terminal Ctrl+C detected — injecting interrupt event');
331
- emitInterrupt('terminal');
332
- }
333
- };
334
- ttyResizeHandler = () => {
335
- if (claudeProc) claudeProc.resize(process.stdout.columns, process.stdout.rows);
336
- };
337
-
338
- process.stdin.setRawMode(true);
339
- process.stdin.resume();
340
- process.stdin.on('data', ttyInputHandler);
341
- process.stdout.on('resize', ttyResizeHandler);
342
- ttyInputForwarderAttached = true;
343
- }
344
-
345
- function normalizeFsPath(value) {
346
- const resolved = path.resolve(String(value || ''));
347
- return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
348
- }
349
-
350
- function projectTranscriptDir() {
351
- return path.join(PROJECTS_DIR, getProjectSlug(CWD));
352
- }
353
-
354
- function resolveHookTranscript(data) {
355
- if (!data || typeof data !== 'object') return null;
356
-
357
- const hookCwd = data.cwd ? path.resolve(String(data.cwd)) : '';
358
- if (hookCwd && normalizeFsPath(hookCwd) !== normalizeFsPath(CWD)) return null;
359
-
360
- const sessionId = data.session_id ? String(data.session_id) : '';
361
- const expectedDir = projectTranscriptDir();
362
- const transcriptPath = data.transcript_path ? path.resolve(String(data.transcript_path)) : '';
363
-
364
- if (transcriptPath) {
365
- const transcriptDir = path.dirname(transcriptPath);
366
- const transcriptSessionId = path.basename(transcriptPath, '.jsonl');
367
- const dirMatches = normalizeFsPath(transcriptDir) === normalizeFsPath(expectedDir);
368
- const idMatches = !sessionId || transcriptSessionId === sessionId;
369
- if (dirMatches && idMatches) {
370
- return { full: transcriptPath, sessionId: transcriptSessionId };
371
- }
372
- }
373
-
374
- if (!sessionId) return null;
375
- return { full: path.join(expectedDir, `${sessionId}.jsonl`), sessionId };
376
- }
377
-
378
- function maybeAttachHookSession(data, source) {
379
- const target = resolveHookTranscript(data);
380
- if (!target) return;
381
- let hookSource = null;
382
-
383
- // Already attached to this exact session — no-op
384
- if (currentSessionId === target.sessionId && transcriptPath &&
385
- normalizeFsPath(transcriptPath) === normalizeFsPath(target.full)) {
386
- return;
387
- }
388
-
389
- const targetHasContent = fileLooksLikeTranscript(target.full);
390
-
391
- if (source === 'session-start') {
392
- // Check hook stdin's source field for deterministic binding
393
- hookSource = data.source; // "startup" | "resume" | "clear" | "compact"
394
-
395
- // /clear or resume: deterministic bind — skip defensive filtering
396
- if (hookSource === 'clear' || hookSource === 'resume') {
397
- log(`Deterministic session-start (hookSource=${hookSource}): ${target.sessionId}`);
398
- // Fall through to attachTranscript below
399
- } else {
400
- // session-start is unreliable for --resume (fires twice, one is a
401
- // snapshot-only session). Only accept when:
402
- // 1. No session bound yet (first attach), OR
403
- // 2. Expecting a switch (/clear), OR
404
- // 3. Target has conversation content and current doesn't
405
- if (currentSessionId && !expectingSwitch) {
406
- const currentHasContent = transcriptPath && fileLooksLikeTranscript(transcriptPath);
407
- if (!targetHasContent || currentHasContent) {
408
- if (currentSessionId !== target.sessionId) {
409
- pendingSwitchTarget = { ...target, seenAt: Date.now(), source };
410
- log(`Queued pending session-start: ${target.sessionId} (current=${currentSessionId} currentHasContent=${currentHasContent} targetHasContent=${targetHasContent})`);
411
- }
412
- log(`Ignored session-start: ${target.sessionId} (current=${currentSessionId} currentHasContent=${currentHasContent} targetHasContent=${targetHasContent})`);
413
- return;
414
- }
415
- }
416
- }
417
- } else if (source === 'pre-tool-use') {
418
- // pre-tool-use is the authoritative source — comes from the actually
419
- // running Claude process. Always allow it to correct the session,
420
- // as long as the target transcript has conversation content.
421
- if (currentSessionId && currentSessionId !== target.sessionId && !targetHasContent) {
422
- log(`Ignored pre-tool-use: ${target.sessionId} (no conversation content)`);
423
- return;
424
- }
425
- } else {
426
- // Other sources (e.g. stop) — only accept if matching current or no session
427
- if (currentSessionId && currentSessionId !== target.sessionId && !expectingSwitch) {
428
- log(`Ignored hook session from ${source}: ${target.sessionId} (current=${currentSessionId})`);
429
- return;
430
- }
431
- }
432
-
433
- log(`Hook session attached from ${source}: ${target.sessionId}`);
434
- attachTranscript({
435
- full: target.full,
436
- ignoreInitialClearCommand: source === 'session-start' && hookSource === 'clear',
437
- }, 0);
438
- }
439
-
440
- function maybeAttachPendingSwitchTarget(reason, requireReady = true) {
441
- if (!pendingSwitchTarget) return false;
442
- if ((Date.now() - pendingSwitchTarget.seenAt) > 15000) {
443
- log(`Dropped stale pending switch target: ${pendingSwitchTarget.sessionId}`);
444
- pendingSwitchTarget = null;
445
- return false;
446
- }
447
- if (pendingSwitchTarget.sessionId === currentSessionId) {
448
- pendingSwitchTarget = null;
449
- return false;
450
- }
451
-
452
- if (requireReady && !fileLooksLikeTranscript(pendingSwitchTarget.full)) {
453
- return false;
454
- }
455
-
456
- const target = pendingSwitchTarget;
457
- pendingSwitchTarget = null;
458
- log(`Attaching pending switch target from ${reason}: ${target.sessionId}`);
459
- if (tailTimer) { clearInterval(tailTimer); tailTimer = null; }
460
- if (switchWatcher) { clearInterval(switchWatcher); switchWatcher = null; }
461
- attachTranscript({ full: target.full }, 0);
462
- return true;
463
- }
464
-
465
- // ============================================================
466
- // 1. Static file server
467
- // ============================================================
468
- const MIME = {
469
- '.html': 'text/html; charset=utf-8',
470
- '.js': 'text/javascript; charset=utf-8',
471
- '.css': 'text/css; charset=utf-8',
472
- '.json': 'application/json',
473
- '.png': 'image/png',
474
- '.svg': 'image/svg+xml',
475
- };
476
-
477
- const server = http.createServer((req, res) => {
478
- const url = req.url.split('?')[0];
479
-
480
- // --- API: Hook approval endpoint ---
481
- if (req.method === 'POST' && url === '/hook/pre-tool-use') {
482
- let body = '';
483
- req.on('data', chunk => (body += chunk));
484
- req.on('end', () => {
485
- let data;
486
- try { data = JSON.parse(body); } catch {
487
- res.writeHead(400, { 'Content-Type': 'application/json' });
488
- res.end(JSON.stringify({ decision: 'ask' }));
489
- return;
490
- }
491
-
492
- maybeAttachHookSession(data, 'pre-tool-use');
493
-
494
- if (ALWAYS_AUTO_ALLOW.has(data.tool_name)) {
495
- res.writeHead(200, { 'Content-Type': 'application/json' });
496
- res.end(JSON.stringify({ decision: 'allow' }));
497
- log(`Permission auto-allowed (always): ${data.tool_name}`);
498
- return;
499
- }
500
-
501
- // Auto-approve based on approvalMode setting
502
- if (approvalMode === 'all') {
503
- res.writeHead(200, { 'Content-Type': 'application/json' });
504
- res.end(JSON.stringify({ decision: 'allow' }));
505
- log(`Permission auto-allowed (mode=all): ${data.tool_name}`);
506
- return;
507
- }
508
- if (approvalMode === 'partial' && PARTIAL_AUTO_ALLOW.has(data.tool_name)) {
509
- res.writeHead(200, { 'Content-Type': 'application/json' });
510
- res.end(JSON.stringify({ decision: 'allow' }));
511
- log(`Permission auto-allowed (mode=partial): ${data.tool_name}`);
512
- return;
513
- }
514
-
515
- // No WebUI clients → fall back to terminal prompt
516
- const clients = [...wss.clients].filter(isAuthenticatedClient);
517
- if (clients.length === 0) {
518
- res.writeHead(200, { 'Content-Type': 'application/json' });
519
- res.end(JSON.stringify({ decision: 'ask' }));
520
- return;
521
- }
522
-
523
- const id = String(++approvalSeq);
524
- log(`Permission #${id}: ${data.tool_name} → ${clients.length} WebUI client(s)`);
525
-
526
- broadcast({
527
- type: 'permission_request',
528
- id,
529
- toolName: data.tool_name,
530
- toolInput: data.tool_input,
531
- permissionMode: data.permission_mode,
532
- });
533
-
534
- // Hold HTTP response open until WebUI user decides or timeout
535
- const timer = setTimeout(() => {
536
- pendingApprovals.delete(id);
537
- res.writeHead(200, { 'Content-Type': 'application/json' });
538
- res.end(JSON.stringify({ decision: 'ask' }));
539
- log(`Permission #${id}: timeout → ask`);
540
- }, 90000);
541
-
542
- pendingApprovals.set(id, { res, timer });
543
- });
544
- return;
545
- }
546
-
547
- // --- API: Session start hook endpoint ---
548
- if (req.method === 'POST' && url === '/hook/session-start') {
549
- let body = '';
550
- req.on('data', chunk => (body += chunk));
551
- req.on('end', () => {
552
- try {
553
- const data = JSON.parse(body);
554
- log(`/hook/session-start received (source=${data.source || 'unknown'}, session_id=${data.session_id || 'none'})`);
555
- maybeAttachHookSession(data, 'session-start');
556
- } catch {}
557
- res.writeHead(200, { 'Content-Type': 'application/json' });
558
- res.end('{}');
559
- });
560
- return;
561
- }
562
-
563
- // --- API: Session end hook endpoint ---
564
- if (req.method === 'POST' && url === '/hook/session-end') {
565
- let body = '';
566
- req.on('data', chunk => (body += chunk));
567
- req.on('end', () => {
568
- let data = {};
569
- try { data = JSON.parse(body); } catch {}
570
- const reason = data.reason || 'unknown';
571
- log(`/hook/session-end received (reason=${reason})`);
572
- if (reason === 'clear') {
573
- markExpectingSwitch();
574
- }
575
- setTurnState('idle', { reason: `session-end:${reason}` });
576
- broadcast({ type: 'session_end', reason });
577
- res.writeHead(200, { 'Content-Type': 'application/json' });
578
- res.end('{}');
579
- });
580
- return;
581
- }
582
-
583
- // --- API: Stop hook endpoint ---
584
- if (req.method === 'POST' && url === '/hook/stop') {
585
- let body = '';
586
- req.on('data', chunk => (body += chunk));
587
- req.on('end', () => {
588
- log('/hook/stop received — broadcasting turn_complete');
589
- try {
590
- maybeAttachHookSession(JSON.parse(body), 'stop');
591
- } catch {}
592
- setTurnState('idle', { reason: 'stop-hook' });
593
- res.writeHead(200, { 'Content-Type': 'application/json' });
594
- res.end('{}');
595
- });
596
- return;
597
- }
598
-
599
- // --- Static files ---
600
- const filePath = path.join(__dirname, 'web', url === '/' ? 'index.html' : url);
601
- const ext = path.extname(filePath);
602
- fs.readFile(filePath, (err, data) => {
603
- if (err) {
604
- res.writeHead(404);
605
- res.end('Not found');
606
- return;
607
- }
608
- res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream' });
609
- res.end(data);
610
- });
611
- });
612
-
613
- // ============================================================
614
- // 2. WebSocket server
615
- // ============================================================
616
- const wss = new WebSocketServer({ server });
617
-
618
- function broadcast(msg) {
619
- const raw = JSON.stringify(msg);
620
- const recipients = [];
621
- for (const ws of wss.clients) {
622
- if (isAuthenticatedClient(ws)) {
623
- ws.send(raw);
624
- recipients.push(wsLabel(ws));
625
- }
626
- }
627
- if (msg.type === 'status' || msg.type === 'transcript_ready' || msg.type === 'turn_state') {
628
- log(`Broadcast ${msg.type} -> ${recipients.length} client(s)${recipients.length ? ` [${recipients.join(', ')}]` : ''}`);
629
- }
630
- }
631
-
632
- function latestEventSeq() {
633
- return eventBuffer.length > 0 ? eventBuffer[eventBuffer.length - 1].seq : 0;
634
- }
635
-
636
- function sendReplay(ws, lastSeq = null) {
637
- const normalizedLastSeq = Number.isInteger(lastSeq) && lastSeq >= 0 ? lastSeq : null;
638
- const replayFrom = normalizedLastSeq == null ? 0 : normalizedLastSeq;
639
- const records = replayFrom > 0
640
- ? eventBuffer.filter(record => record.seq > replayFrom)
641
- : eventBuffer;
642
-
643
- log(`Replay start -> ${wsLabel(ws)} from=${replayFrom} count=${records.length} currentSession=${currentSessionId ?? 'null'}`);
644
-
645
- for (const record of records) {
646
- ws.send(JSON.stringify({
647
- type: 'log_event',
648
- seq: record.seq,
649
- event: record.event,
650
- }));
651
- }
652
-
653
- sendWs(ws, {
654
- type: 'replay_done',
655
- sessionId: currentSessionId,
656
- lastSeq: latestEventSeq(),
657
- resumed: normalizedLastSeq != null,
658
- }, 'sendReplay');
659
- sendTurnState(ws, 'sendReplay');
660
- }
661
-
662
- function sendUploadStatus(ws, uploadId, status, extra = {}) {
663
- if (!ws || ws.readyState !== WebSocket.OPEN) return;
664
- ws.send(JSON.stringify({
665
- type: 'image_upload_status',
666
- uploadId,
667
- status,
668
- ...extra,
669
- }));
670
- }
671
-
672
- function cleanupImageUpload(uploadId) {
673
- const upload = pendingImageUploads.get(uploadId);
674
- if (!upload) return;
675
- if (upload.tmpFile) {
676
- try { fs.unlinkSync(upload.tmpFile); } catch {}
677
- }
678
- pendingImageUploads.delete(uploadId);
679
- }
680
-
681
- function cleanupClientUploads(ws) {
682
- for (const [uploadId, upload] of pendingImageUploads) {
683
- if (upload.owner === ws && !upload.submitted) cleanupImageUpload(uploadId);
684
- }
685
- }
686
-
687
- function createTempImageFile(buffer, mediaType, uploadId) {
688
- const isLinux = process.platform !== 'win32' && process.platform !== 'darwin';
689
- const tmpDir = isLinux
690
- ? path.join(CWD, 'tmp')
691
- : (process.env.CLAUDE_CODE_TMPDIR || os.tmpdir());
692
- const type = String(mediaType || 'image/png').toLowerCase();
693
- const ext = type.includes('jpeg') || type.includes('jpg') ? '.jpg' : '.png';
694
- fs.mkdirSync(tmpDir, { recursive: true });
695
- const tmpFile = path.join(tmpDir, `bridge_upload_${uploadId}_${Date.now()}${ext}`);
696
- fs.writeFileSync(tmpFile, buffer);
697
- return tmpFile;
698
- }
699
-
700
- function toClaudeAtPath(filePath) {
701
- const normalized = path.normalize(String(filePath || ''));
702
- const rel = path.relative(CWD, normalized);
703
- const inProject = rel && !rel.startsWith('..') && !path.isAbsolute(rel);
704
- const target = inProject ? rel : normalized;
705
- return target.split(path.sep).join('/');
706
- }
707
-
708
- function buildLinuxImagePrompt(text, tmpFile) {
709
- const trimmedText = String(text || '').trim();
710
- const atPath = `@${toClaudeAtPath(tmpFile)}`;
711
- return trimmedText ? `${trimmedText} ${atPath}` : atPath;
712
- }
713
-
714
- function isLinuxClipboardToolInstalled(tool) {
715
- try {
716
- execSync(`command -v ${tool} >/dev/null 2>&1`, {
717
- stdio: 'ignore',
718
- shell: '/bin/sh',
719
- timeout: 2000,
720
- });
721
- return true;
722
- } catch {
723
- return false;
724
- }
725
- }
726
-
727
- function setLinuxImagePasteInFlight(active, reason = '') {
728
- linuxImagePasteInFlight = !!active;
729
- if (reason) log(`Linux image paste lock=${linuxImagePasteInFlight ? 'on' : 'off'} reason=${reason}`);
730
- }
731
-
732
- function normalizeLinuxEnvVar(value) {
733
- const text = String(value || '').trim();
734
- return text || null;
735
- }
736
-
737
- function parseLinuxProcStatusUid(statusText) {
738
- const match = String(statusText || '').match(/^Uid:\s+(\d+)/m);
739
- return match ? Number(match[1]) : null;
740
- }
741
-
742
- function readLinuxProcGuiEnv(pid) {
743
- try {
744
- const statusPath = `/proc/${pid}/status`;
745
- const environPath = `/proc/${pid}/environ`;
746
- const statusText = fs.readFileSync(statusPath, 'utf8');
747
- const currentUid = typeof process.getuid === 'function' ? process.getuid() : null;
748
- if (currentUid != null) {
749
- const procUid = parseLinuxProcStatusUid(statusText);
750
- if (procUid == null || procUid !== currentUid) return null;
751
- }
752
-
753
- const envRaw = fs.readFileSync(environPath, 'utf8');
754
- if (!envRaw) return null;
755
- let waylandDisplay = null;
756
- let display = null;
757
- let runtimeDir = null;
758
- let xAuthority = null;
759
-
760
- for (const entry of envRaw.split('\0')) {
761
- if (!entry) continue;
762
- if (entry.startsWith('WAYLAND_DISPLAY=')) waylandDisplay = normalizeLinuxEnvVar(entry.slice('WAYLAND_DISPLAY='.length));
763
- else if (entry.startsWith('DISPLAY=')) display = normalizeLinuxEnvVar(entry.slice('DISPLAY='.length));
764
- else if (entry.startsWith('XDG_RUNTIME_DIR=')) runtimeDir = normalizeLinuxEnvVar(entry.slice('XDG_RUNTIME_DIR='.length));
765
- else if (entry.startsWith('XAUTHORITY=')) xAuthority = normalizeLinuxEnvVar(entry.slice('XAUTHORITY='.length));
766
- }
767
-
768
- if (!waylandDisplay && !display) return null;
769
- return { waylandDisplay, display, runtimeDir, xAuthority };
770
- } catch {
771
- return null;
772
- }
773
- }
774
-
775
- function discoverLinuxGuiEnvFromProc() {
776
- if (process.platform === 'win32' || process.platform === 'darwin') return null;
777
- let entries = [];
778
- try {
779
- entries = fs.readdirSync('/proc', { withFileTypes: true });
780
- } catch {
781
- return null;
782
- }
783
-
784
- for (const entry of entries) {
785
- if (!entry.isDirectory()) continue;
786
- if (!/^\d+$/.test(entry.name)) continue;
787
- if (Number(entry.name) === process.pid) continue;
788
- const discovered = readLinuxProcGuiEnv(entry.name);
789
- if (discovered) return discovered;
790
- }
791
- return null;
792
- }
793
-
794
- function discoverLinuxGuiEnvFromSocket() {
795
- if (process.platform === 'win32' || process.platform === 'darwin') return null;
796
- const discovered = {
797
- waylandDisplay: null,
798
- display: null,
799
- runtimeDir: null,
800
- xAuthority: null,
801
- };
802
-
803
- const currentUid = typeof process.getuid === 'function' ? process.getuid() : null;
804
- const runtimeDir = currentUid != null ? `/run/user/${currentUid}` : null;
805
- if (runtimeDir && fs.existsSync(runtimeDir)) {
806
- discovered.runtimeDir = runtimeDir;
807
- try {
808
- const entries = fs.readdirSync(runtimeDir);
809
- const waylandSockets = entries.filter(name => /^wayland-\d+$/.test(name)).sort();
810
- if (waylandSockets.length > 0) discovered.waylandDisplay = waylandSockets[0];
811
- } catch {}
812
- }
813
-
814
- try {
815
- const xEntries = fs.readdirSync('/tmp/.X11-unix');
816
- const displaySockets = xEntries
817
- .map(name => {
818
- const match = /^X(\d+)$/.exec(name);
819
- return match ? Number(match[1]) : null;
820
- })
821
- .filter(num => Number.isInteger(num))
822
- .sort((a, b) => a - b);
823
- if (displaySockets.length > 0) discovered.display = `:${displaySockets[0]}`;
824
- } catch {}
825
-
826
- if (!discovered.waylandDisplay && !discovered.display) return null;
827
- return discovered;
828
- }
829
-
830
- function getLinuxClipboardEnv() {
831
- if (process.platform === 'win32' || process.platform === 'darwin') {
832
- return { env: process.env, source: 'not_linux' };
833
- }
834
-
835
- const overlay = {
836
- WAYLAND_DISPLAY: normalizeLinuxEnvVar(process.env.CLAUDE_REMOTE_WAYLAND_DISPLAY) || normalizeLinuxEnvVar(process.env.WAYLAND_DISPLAY),
837
- DISPLAY: normalizeLinuxEnvVar(process.env.CLAUDE_REMOTE_DISPLAY) || normalizeLinuxEnvVar(process.env.DISPLAY),
838
- XDG_RUNTIME_DIR: normalizeLinuxEnvVar(process.env.CLAUDE_REMOTE_XDG_RUNTIME_DIR) || normalizeLinuxEnvVar(process.env.XDG_RUNTIME_DIR),
839
- XAUTHORITY: normalizeLinuxEnvVar(process.env.CLAUDE_REMOTE_XAUTHORITY) || normalizeLinuxEnvVar(process.env.XAUTHORITY),
840
- };
841
-
842
- let source = 'process_env';
843
- const needsSocketDiscovery =
844
- (!overlay.WAYLAND_DISPLAY && !overlay.DISPLAY) ||
845
- (!!overlay.WAYLAND_DISPLAY && !overlay.XDG_RUNTIME_DIR);
846
- if (needsSocketDiscovery) {
847
- const before = {
848
- waylandDisplay: overlay.WAYLAND_DISPLAY,
849
- display: overlay.DISPLAY,
850
- runtimeDir: overlay.XDG_RUNTIME_DIR,
851
- xAuthority: overlay.XAUTHORITY,
852
- };
853
- const fromSocket = discoverLinuxGuiEnvFromSocket();
854
- if (fromSocket) {
855
- if (!overlay.WAYLAND_DISPLAY && fromSocket.waylandDisplay) overlay.WAYLAND_DISPLAY = fromSocket.waylandDisplay;
856
- if (!overlay.DISPLAY && fromSocket.display) overlay.DISPLAY = fromSocket.display;
857
- if (!overlay.XDG_RUNTIME_DIR && fromSocket.runtimeDir) overlay.XDG_RUNTIME_DIR = fromSocket.runtimeDir;
858
- if (!overlay.XAUTHORITY && fromSocket.xAuthority) overlay.XAUTHORITY = fromSocket.xAuthority;
859
- const changed =
860
- before.waylandDisplay !== overlay.WAYLAND_DISPLAY ||
861
- before.display !== overlay.DISPLAY ||
862
- before.runtimeDir !== overlay.XDG_RUNTIME_DIR ||
863
- before.xAuthority !== overlay.XAUTHORITY;
864
- if (changed) source = 'socket_discovery';
865
- }
866
- }
867
-
868
- const needsProcDiscovery =
869
- (!overlay.WAYLAND_DISPLAY && !overlay.DISPLAY) ||
870
- (!!overlay.DISPLAY && !overlay.XAUTHORITY) ||
871
- (!!overlay.WAYLAND_DISPLAY && !overlay.XDG_RUNTIME_DIR);
872
- if (needsProcDiscovery) {
873
- const before = {
874
- waylandDisplay: overlay.WAYLAND_DISPLAY,
875
- display: overlay.DISPLAY,
876
- runtimeDir: overlay.XDG_RUNTIME_DIR,
877
- xAuthority: overlay.XAUTHORITY,
878
- };
879
- const fromProc = discoverLinuxGuiEnvFromProc();
880
- if (fromProc) {
881
- if (!overlay.WAYLAND_DISPLAY && fromProc.waylandDisplay) overlay.WAYLAND_DISPLAY = fromProc.waylandDisplay;
882
- if (!overlay.DISPLAY && fromProc.display) overlay.DISPLAY = fromProc.display;
883
- if (!overlay.XDG_RUNTIME_DIR && fromProc.runtimeDir) overlay.XDG_RUNTIME_DIR = fromProc.runtimeDir;
884
- if (!overlay.XAUTHORITY && fromProc.xAuthority) overlay.XAUTHORITY = fromProc.xAuthority;
885
- const changed =
886
- before.waylandDisplay !== overlay.WAYLAND_DISPLAY ||
887
- before.display !== overlay.DISPLAY ||
888
- before.runtimeDir !== overlay.XDG_RUNTIME_DIR ||
889
- before.xAuthority !== overlay.XAUTHORITY;
890
- if (changed) {
891
- source = source === 'socket_discovery' ? 'socket+proc_discovery' : 'proc_discovery';
892
- }
893
- }
894
- }
895
-
896
- const env = { ...process.env };
897
- if (overlay.WAYLAND_DISPLAY) env.WAYLAND_DISPLAY = overlay.WAYLAND_DISPLAY;
898
- if (overlay.DISPLAY) env.DISPLAY = overlay.DISPLAY;
899
- if (overlay.XDG_RUNTIME_DIR) env.XDG_RUNTIME_DIR = overlay.XDG_RUNTIME_DIR;
900
- if (overlay.XAUTHORITY) env.XAUTHORITY = overlay.XAUTHORITY;
901
-
902
- return {
903
- env,
904
- source,
905
- waylandDisplay: overlay.WAYLAND_DISPLAY || null,
906
- display: overlay.DISPLAY || null,
907
- runtimeDir: overlay.XDG_RUNTIME_DIR || null,
908
- xAuthority: overlay.XAUTHORITY || null,
909
- };
910
- }
911
-
912
- function getLinuxClipboardToolCandidates(clipboardEnv = process.env) {
913
- if (process.platform === 'win32' || process.platform === 'darwin') return [];
914
- const preferred = [];
915
- if (clipboardEnv.WAYLAND_DISPLAY) preferred.push('wl-copy');
916
- if (clipboardEnv.DISPLAY) preferred.push('xclip');
917
- return preferred;
918
- }
919
-
920
- function assertLinuxClipboardAvailable() {
921
- const gui = getLinuxClipboardEnv();
922
- const candidates = getLinuxClipboardToolCandidates(gui.env);
923
- const available = candidates.filter(isLinuxClipboardToolInstalled);
924
- if (available.length > 0) {
925
- return {
926
- tools: available,
927
- env: gui.env,
928
- source: gui.source,
929
- waylandDisplay: gui.waylandDisplay,
930
- display: gui.display,
931
- runtimeDir: gui.runtimeDir,
932
- xAuthority: gui.xAuthority,
933
- };
934
- }
935
- if (!gui.waylandDisplay && !gui.display) {
936
- throw new Error('Linux image paste requires a graphical session. Could not detect WAYLAND_DISPLAY or DISPLAY (common in pm2/systemd). Set CLAUDE_REMOTE_DISPLAY or CLAUDE_REMOTE_WAYLAND_DISPLAY and retry.');
937
- }
938
- throw new Error('Linux image paste requires wl-copy or xclip on the server. Install a matching clipboard tool and try again.');
939
- }
940
-
941
- function formatLinuxClipboardEnvLog(info) {
942
- if (!info) return '';
943
- const parts = [];
944
- if (info.waylandDisplay) parts.push(`WAYLAND_DISPLAY=${info.waylandDisplay}`);
945
- if (info.display) parts.push(`DISPLAY=${info.display}`);
946
- if (info.runtimeDir) parts.push(`XDG_RUNTIME_DIR=${info.runtimeDir}`);
947
- if (info.xAuthority) parts.push(`XAUTHORITY=${info.xAuthority}`);
948
- return parts.length ? ` env[${parts.join(', ')}]` : '';
949
- }
950
-
951
- function spawnLinuxClipboardTool(tool, imageBuffer, type, clipboardEnv) {
952
- return new Promise((resolve, reject) => {
953
- const args = tool === 'xclip'
954
- ? ['-quiet', '-selection', 'clipboard', '-t', type, '-i']
955
- : ['--type', type];
956
- const child = spawn(tool, args, {
957
- detached: true,
958
- stdio: ['pipe', 'ignore', 'pipe'],
959
- env: clipboardEnv || process.env,
960
- });
961
- let settled = false;
962
- let stderr = '';
963
- let readyTimer = null;
964
-
965
- const settleFailure = (message) => {
966
- if (settled) return;
967
- settled = true;
968
- if (readyTimer) clearTimeout(readyTimer);
969
- if (child.exitCode == null && child.signalCode == null) {
970
- try { child.kill('SIGTERM'); } catch {}
971
- }
972
- reject(new Error(message));
973
- };
974
-
975
- const settleSuccess = (trackProcess = true) => {
976
- if (settled) return;
977
- settled = true;
978
- if (readyTimer) clearTimeout(readyTimer);
979
- if (trackProcess && child.exitCode == null && child.signalCode == null) {
980
- activeLinuxClipboardProc = { child, tool };
981
- child.unref();
982
- }
983
- resolve(tool);
984
- };
985
-
986
- child.on('error', (err) => {
987
- log(`Linux clipboard process error (${tool}): ${err.message}`);
988
- settleFailure(`Linux clipboard tool ${tool} failed: ${err.message}`);
989
- });
990
- child.stderr.on('data', (chunk) => {
991
- stderr += chunk.toString('utf8');
992
- if (stderr.length > 2000) stderr = stderr.slice(-2000);
993
- });
994
- child.on('exit', (code, signal) => {
995
- if (activeLinuxClipboardProc && activeLinuxClipboardProc.child === child) activeLinuxClipboardProc = null;
996
- const extra = stderr.trim() ? ` stderr=${JSON.stringify(stderr.trim())}` : '';
997
- log(`Linux clipboard process exited (${tool}) code=${code ?? 'null'} signal=${signal ?? 'null'}${extra}`);
998
- if (!settled) {
999
- if (tool === 'xclip' && code === 0 && !signal && !stderr.trim()) {
1000
- log('Linux clipboard xclip exited cleanly without stderr; treating clipboard arm as successful');
1001
- settleSuccess(false);
1002
- return;
1003
- }
1004
- const detail = stderr.trim() || `exit code ${code ?? 'null'} signal ${signal ?? 'null'}`;
1005
- settleFailure(`Linux clipboard tool ${tool} exited before paste: ${detail}`);
1006
- }
1007
- });
1008
- child.stdin.on('error', (err) => {
1009
- if (err.code === 'EPIPE') {
1010
- settleFailure(`Linux clipboard tool ${tool} closed its input early`);
1011
- return;
1012
- }
1013
- log(`Linux clipboard stdin error (${tool}): ${err.message}`);
1014
- settleFailure(`Linux clipboard tool ${tool} stdin failed: ${err.message}`);
1015
- });
1016
-
1017
- child.stdin.end(imageBuffer);
1018
- log(`Linux clipboard process started (${tool}) pid=${child.pid ?? 'null'} type=${type} bytes=${imageBuffer.length}`);
1019
- readyTimer = setTimeout(() => settleSuccess(), LINUX_CLIPBOARD_READY_GRACE_MS);
1020
- });
1021
- }
1022
-
1023
- async function startLinuxClipboardImage(tmpFile, mediaType, clipboardInfo = null) {
1024
- const type = String(mediaType || 'image/png').toLowerCase();
1025
- const imageBuffer = fs.readFileSync(tmpFile);
1026
- const resolved = clipboardInfo || assertLinuxClipboardAvailable();
1027
- const availableTools = resolved.tools;
1028
- clearActiveLinuxClipboardProc('replace');
1029
-
1030
- let lastErr = null;
1031
- for (const tool of availableTools) {
1032
- try {
1033
- return await spawnLinuxClipboardTool(tool, imageBuffer, type, resolved.env);
1034
- } catch (err) {
1035
- lastErr = err;
1036
- log(`Linux clipboard arm failed (${tool}): ${err.message}`);
1037
- }
1038
- }
1039
-
1040
- throw lastErr || new Error('Linux clipboard could not be initialized');
1041
- }
1042
-
1043
- setInterval(() => {
1044
- const now = Date.now();
1045
- for (const [uploadId, upload] of pendingImageUploads) {
1046
- if ((upload.updatedAt || 0) < (now - IMAGE_UPLOAD_TTL_MS)) {
1047
- cleanupImageUpload(uploadId);
1048
- }
1049
- }
1050
- }, 60 * 1000).unref();
1051
-
1052
- function sendInitialMessages(ws) {
1053
- sendWs(ws, {
1054
- type: 'status',
1055
- status: claudeProc ? 'running' : 'starting',
1056
- hasTranscript: !!transcriptPath,
1057
- cwd: CWD,
1058
- sessionId: currentSessionId,
1059
- lastSeq: latestEventSeq(),
1060
- }, 'initial');
1061
-
1062
- if (currentSessionId) {
1063
- sendWs(ws, {
1064
- type: 'transcript_ready',
1065
- transcript: transcriptPath,
1066
- sessionId: currentSessionId,
1067
- lastSeq: latestEventSeq(),
1068
- }, 'initial');
1069
- }
1070
- }
1071
-
1072
- function sendAuthOk(ws) {
1073
- sendWs(ws, {
1074
- type: 'auth_ok',
1075
- authRequired: !AUTH_DISABLED,
1076
- }, 'auth_ok');
1077
- }
1078
-
1079
- wss.on('connection', (ws, req) => {
1080
- ws._bridgeId = ++nextWsId;
1081
- ws._clientInstanceId = '';
1082
- ws._authenticated = AUTH_DISABLED; // skip auth if disabled
1083
- ws._authTimer = null;
1084
- log(`WS connected: ${wsLabel(ws)} remote=${req.socket.remoteAddress || '?'} ua=${JSON.stringify(req.headers['user-agent'] || '')} authRequired=${!AUTH_DISABLED}`);
1085
-
1086
- // If auth is disabled, send initial messages immediately
1087
- if (AUTH_DISABLED) {
1088
- sendAuthOk(ws);
1089
- sendInitialMessages(ws);
1090
- } else {
1091
- ws._authTimer = setTimeout(() => {
1092
- if (ws.readyState !== WebSocket.OPEN || ws._authenticated) return;
1093
- log(`Auth timeout for ${wsLabel(ws)}`);
1094
- ws.close(WS_CLOSE_AUTH_TIMEOUT, WS_CLOSE_REASON_AUTH_TIMEOUT);
1095
- }, AUTH_HELLO_TIMEOUT_MS);
1096
- }
1097
-
1098
- // New clients should explicitly request a resume window. Keep a delayed
1099
- // full replay fallback so older clients still work.
1100
- ws._resumeHandled = false;
1101
- ws._legacyReplayTimer = null;
1102
- if (AUTH_DISABLED) {
1103
- ws._legacyReplayTimer = setTimeout(() => {
1104
- if (ws.readyState !== WebSocket.OPEN || ws._resumeHandled) return;
1105
- ws._resumeHandled = true;
1106
- sendReplay(ws, null);
1107
- }, LEGACY_REPLAY_DELAY_MS);
1108
- }
1109
-
1110
- ws.on('message', async (raw) => {
1111
- let msg;
1112
- try { msg = JSON.parse(raw); } catch { return; }
1113
-
1114
- // --- Authentication gate ---
1115
- if (!ws._authenticated) {
1116
- if (msg.type !== 'hello') return; // ignore non-hello when not authenticated
1117
- ws._clientInstanceId = String(msg.clientInstanceId || ws._clientInstanceId || '');
1118
- log(`WS hello from ${wsLabel(ws)} page=${JSON.stringify(msg.page || '')} ua=${JSON.stringify(msg.userAgent || '')}`);
1119
-
1120
- const clientToken = String(msg.token || '');
1121
- if (!AUTH_TOKEN || !clientToken) {
1122
- log(`Auth failed for ${wsLabel(ws)}: missing token`);
1123
- ws.close(WS_CLOSE_AUTH_FAILED, WS_CLOSE_REASON_AUTH_FAILED);
1124
- return;
1125
- }
1126
- const a = Buffer.from(AUTH_TOKEN, 'utf8');
1127
- const b = Buffer.from(clientToken, 'utf8');
1128
- if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
1129
- log(`Auth failed for ${wsLabel(ws)}: invalid token`);
1130
- ws.close(WS_CLOSE_AUTH_FAILED, WS_CLOSE_REASON_AUTH_FAILED);
1131
- return;
1132
- }
1133
- ws._authenticated = true;
1134
- if (ws._authTimer) {
1135
- clearTimeout(ws._authTimer);
1136
- ws._authTimer = null;
1137
- }
1138
- log(`Auth OK for ${wsLabel(ws)}`);
1139
-
1140
- sendAuthOk(ws);
1141
- sendInitialMessages(ws);
1142
- ws._legacyReplayTimer = setTimeout(() => {
1143
- if (ws.readyState !== WebSocket.OPEN || ws._resumeHandled) return;
1144
- ws._resumeHandled = true;
1145
- sendReplay(ws, null);
1146
- }, LEGACY_REPLAY_DELAY_MS);
1147
- return;
1148
- }
1149
-
1150
- switch (msg.type) {
1151
- case 'hello':
1152
- ws._clientInstanceId = String(msg.clientInstanceId || ws._clientInstanceId || '');
1153
- log(`WS hello from ${wsLabel(ws)} page=${JSON.stringify(msg.page || '')} ua=${JSON.stringify(msg.userAgent || '')}`);
1154
- break;
1155
- case 'debug_log':
1156
- if (msg.clientInstanceId) ws._clientInstanceId = String(msg.clientInstanceId);
1157
- log(`ClientDebug ${wsLabel(ws)} event=${msg.event || 'unknown'} detail=${JSON.stringify(msg.detail || {})}`);
1158
- break;
1159
- case 'resume': {
1160
- ws._resumeHandled = true;
1161
- if (ws._legacyReplayTimer) {
1162
- clearTimeout(ws._legacyReplayTimer);
1163
- ws._legacyReplayTimer = null;
1164
- }
1165
-
1166
- if (!currentSessionId) {
1167
- ws.send(JSON.stringify({
1168
- type: 'replay_done',
1169
- sessionId: null,
1170
- lastSeq: 0,
1171
- resumed: false,
1172
- }));
1173
- sendTurnState(ws, 'resume-empty');
1174
- break;
1175
- }
1176
-
1177
- const clientServerLastSeq = Number.isInteger(msg.serverLastSeq) && msg.serverLastSeq >= 0
1178
- ? msg.serverLastSeq
1179
- : null;
1180
- const canResume = (
1181
- msg.sessionId &&
1182
- msg.sessionId === currentSessionId &&
1183
- Number.isInteger(msg.lastSeq) &&
1184
- msg.lastSeq >= 0 &&
1185
- msg.lastSeq <= latestEventSeq() &&
1186
- (clientServerLastSeq == null || msg.lastSeq <= clientServerLastSeq)
1187
- );
1188
-
1189
- log(`Resume request from ${wsLabel(ws)} session=${msg.sessionId ?? 'null'} lastSeq=${msg.lastSeq} serverLastSeq=${clientServerLastSeq ?? 'null'} canResume=${canResume}`);
1190
-
1191
- sendReplay(ws, canResume ? msg.lastSeq : null);
1192
- break;
1193
- }
1194
- case 'foreground_probe': {
1195
- const probeId = typeof msg.probeId === 'string' ? msg.probeId : '';
1196
- sendWs(ws, {
1197
- type: 'foreground_probe_ack',
1198
- probeId,
1199
- sessionId: currentSessionId,
1200
- lastSeq: latestEventSeq(),
1201
- cwd: CWD,
1202
- }, 'foreground_probe');
1203
- log(`Foreground probe ack -> ${wsLabel(ws)} probeId=${probeId || 'none'} session=${currentSessionId ?? 'null'} lastSeq=${latestEventSeq()}`);
1204
- break;
1205
- }
1206
- case 'input':
1207
- // Raw terminal keystrokes from xterm.js in WebUI
1208
- if (claudeProc) claudeProc.write(msg.data);
1209
- break;
1210
- case 'interrupt': {
1211
- // User pressed stop button in app — send Ctrl+C to PTY
1212
- if (!claudeProc || turnState.phase !== 'running') break;
1213
- log(`Interrupt from ${wsLabel(ws)} — sending Ctrl+C to PTY`);
1214
- claudeProc.write('\x03');
1215
- emitInterrupt('app');
1216
- break;
1217
- }
1218
- case 'expect_clear':
1219
- // Plan mode option 1 triggers /clear inside Claude Code;
1220
- // client notifies us so we can detect the session switch.
1221
- markExpectingSwitch();
1222
- break;
1223
- case 'chat':
1224
- // Chat message from WebUI → write to PTY as user input
1225
- // Must send text first, then Enter after a delay so Claude's
1226
- // TUI (Ink) has time to process the typed characters
1227
- if (claudeProc) {
1228
- const text = msg.text;
1229
- log(`Chat input → PTY: "${text.substring(0, 80)}"`);
1230
- const slashCommand = extractSlashCommand(text);
1231
- if (slashCommand === '/clear') {
1232
- markExpectingSwitch();
1233
- }
1234
- // Slash commands are internal CLI control flow.
1235
- // commands, not AI turns — the stop hook will never fire, so don't
1236
- // They should never mutate the live turn state into running.
1237
- if (!slashCommand) {
1238
- setTurnState('running', { reason: 'chat' });
1239
- }
1240
- claudeProc.write(text);
1241
- setTimeout(() => {
1242
- if (claudeProc) claudeProc.write('\r');
1243
- }, 150);
1244
- }
1245
- break;
1246
- case 'resize':
1247
- // Only resize if no local TTY is controlling size
1248
- if (claudeProc && msg.cols && msg.rows && !isTTY) {
1249
- claudeProc.resize(msg.cols, msg.rows);
1250
- }
1251
- break;
1252
- case 'permission_response': {
1253
- const approval = pendingApprovals.get(msg.id);
1254
- if (approval) {
1255
- clearTimeout(approval.timer);
1256
- pendingApprovals.delete(msg.id);
1257
- approval.res.writeHead(200, { 'Content-Type': 'application/json' });
1258
- approval.res.end(JSON.stringify({
1259
- decision: msg.decision,
1260
- reason: msg.reason || '',
1261
- }));
1262
- log(`Permission #${msg.id}: ${msg.decision}`);
1263
- }
1264
- break;
1265
- }
1266
- case 'set_approval_mode': {
1267
- const valid = ['default', 'partial', 'all'];
1268
- if (valid.includes(msg.mode)) {
1269
- approvalMode = msg.mode;
1270
- log(`Approval mode changed to: ${approvalMode}`);
1271
- // If switching to 'all' or 'partial', auto-resolve queued permissions
1272
- if (approvalMode === 'all') {
1273
- for (const [id, approval] of pendingApprovals) {
1274
- clearTimeout(approval.timer);
1275
- approval.res.writeHead(200, { 'Content-Type': 'application/json' });
1276
- approval.res.end(JSON.stringify({ decision: 'allow' }));
1277
- log(`Permission #${id}: auto-allowed (mode switched to all)`);
1278
- }
1279
- pendingApprovals.clear();
1280
- broadcast({ type: 'clear_permissions' });
1281
- }
1282
- }
1283
- break;
1284
- }
1285
- case 'image_upload_init': {
1286
- const uploadId = String(msg.uploadId || '');
1287
- if (!uploadId) {
1288
- sendUploadStatus(ws, '', 'error', { message: 'Missing uploadId' });
1289
- break;
1290
- }
1291
- cleanupImageUpload(uploadId);
1292
- pendingImageUploads.set(uploadId, {
1293
- id: uploadId,
1294
- owner: ws,
1295
- mediaType: msg.mediaType || 'image/png',
1296
- name: msg.name || 'image',
1297
- totalBytes: Number.isFinite(msg.totalBytes) ? msg.totalBytes : 0,
1298
- totalChunks: Number.isFinite(msg.totalChunks) ? msg.totalChunks : 0,
1299
- nextChunkIndex: 0,
1300
- receivedBytes: 0,
1301
- chunks: [],
1302
- tmpFile: null,
1303
- updatedAt: Date.now(),
1304
- });
1305
- sendUploadStatus(ws, uploadId, 'ready_for_chunks', { receivedBytes: 0, totalBytes: msg.totalBytes || 0 });
1306
- break;
1307
- }
1308
- case 'image_upload_chunk': {
1309
- const uploadId = String(msg.uploadId || '');
1310
- const upload = pendingImageUploads.get(uploadId);
1311
- if (!upload) {
1312
- sendUploadStatus(ws, uploadId, 'error', { message: 'Upload session not found' });
1313
- break;
1314
- }
1315
- if (upload.owner !== ws) {
1316
- sendUploadStatus(ws, uploadId, 'error', { message: 'Upload owner mismatch' });
1317
- break;
1318
- }
1319
- if (msg.index !== upload.nextChunkIndex) {
1320
- sendUploadStatus(ws, uploadId, 'error', {
1321
- message: `Unexpected chunk index ${msg.index}, expected ${upload.nextChunkIndex}`,
1322
- });
1323
- break;
1324
- }
1325
- if (!msg.base64) {
1326
- sendUploadStatus(ws, uploadId, 'error', { message: 'Missing chunk payload' });
1327
- break;
1328
- }
1329
-
1330
- try {
1331
- const chunk = Buffer.from(msg.base64, 'base64');
1332
- upload.chunks.push(chunk);
1333
- upload.receivedBytes += chunk.length;
1334
- upload.nextChunkIndex += 1;
1335
- upload.updatedAt = Date.now();
1336
- sendUploadStatus(ws, uploadId, 'uploading', {
1337
- chunkIndex: msg.index,
1338
- receivedBytes: upload.receivedBytes,
1339
- totalBytes: upload.totalBytes,
1340
- });
1341
- } catch (err) {
1342
- sendUploadStatus(ws, uploadId, 'error', { message: err.message });
1343
- }
1344
- break;
1345
- }
1346
- case 'image_upload_complete': {
1347
- const uploadId = String(msg.uploadId || '');
1348
- const upload = pendingImageUploads.get(uploadId);
1349
- if (!upload) {
1350
- sendUploadStatus(ws, uploadId, 'error', { message: 'Upload session not found' });
1351
- break;
1352
- }
1353
- if (upload.owner !== ws) {
1354
- sendUploadStatus(ws, uploadId, 'error', { message: 'Upload owner mismatch' });
1355
- break;
1356
- }
1357
- if (upload.nextChunkIndex !== upload.totalChunks) {
1358
- sendUploadStatus(ws, uploadId, 'error', {
1359
- message: `Upload incomplete (${upload.nextChunkIndex}/${upload.totalChunks})`,
1360
- });
1361
- break;
1362
- }
1363
-
1364
- try {
1365
- const buffer = Buffer.concat(upload.chunks);
1366
- upload.tmpFile = createTempImageFile(buffer, upload.mediaType, uploadId);
1367
- upload.chunks = [];
1368
- upload.updatedAt = Date.now();
1369
- log(`Image pre-upload complete: ${upload.tmpFile} (${buffer.length} bytes)`);
1370
- sendUploadStatus(ws, uploadId, 'uploaded', {
1371
- receivedBytes: upload.receivedBytes,
1372
- totalBytes: upload.totalBytes,
1373
- });
1374
- } catch (err) {
1375
- sendUploadStatus(ws, uploadId, 'error', { message: err.message });
1376
- cleanupImageUpload(uploadId);
1377
- }
1378
- break;
1379
- }
1380
- case 'image_upload_abort': {
1381
- const uploadId = String(msg.uploadId || '');
1382
- if (uploadId) cleanupImageUpload(uploadId);
1383
- sendUploadStatus(ws, uploadId, 'aborted');
1384
- break;
1385
- }
1386
- case 'image_submit': {
1387
- const uploadId = String(msg.uploadId || '');
1388
- const upload = pendingImageUploads.get(uploadId);
1389
- if (!upload || !upload.tmpFile) {
1390
- sendUploadStatus(ws, uploadId, 'error', { message: 'Upload not ready' });
1391
- break;
1392
- }
1393
- try {
1394
- await handlePreparedImageUpload({
1395
- tmpFile: upload.tmpFile,
1396
- mediaType: upload.mediaType,
1397
- text: msg.text || '',
1398
- logLabel: upload.name || uploadId,
1399
- onCleanup: () => cleanupImageUpload(uploadId),
1400
- });
1401
- upload.submitted = true;
1402
- upload.updatedAt = Date.now();
1403
- setTurnState('running', { reason: 'image_submit' });
1404
- sendUploadStatus(ws, uploadId, 'submitted');
1405
- } catch (err) {
1406
- sendUploadStatus(ws, uploadId, 'error', { message: err.message });
1407
- cleanupImageUpload(uploadId);
1408
- }
1409
- break;
1410
- }
1411
- case 'image_upload': {
1412
- handleImageUpload(msg);
1413
- break;
1414
- }
1415
- case 'list_sessions': {
1416
- try {
1417
- const sessions = scanSessions(CWD, 20);
1418
- sendWs(ws, { type: 'sessions', sessions });
1419
- } catch (err) {
1420
- log(`scanSessions error: ${err.message}`);
1421
- sendWs(ws, { type: 'sessions', sessions: [], error: err.message });
1422
- }
1423
- break;
1424
- }
1425
- case 'list_dirs': {
1426
- try {
1427
- const browser = listDirectories(msg.cwd || CWD);
1428
- sendWs(ws, { type: 'dir_list', ...browser });
1429
- } catch (err) {
1430
- log(`listDirectories error: ${err.message}`);
1431
- sendWs(ws, {
1432
- type: 'dir_list',
1433
- cwd: path.resolve(String(msg.cwd || CWD || '')),
1434
- parent: null,
1435
- roots: getDirectoryRoots(),
1436
- entries: [],
1437
- error: err.message,
1438
- });
1439
- }
1440
- break;
1441
- }
1442
- case 'switch_session': {
1443
- if (claudeProc && msg.sessionId) {
1444
- log(`Switch session → /resume ${msg.sessionId}`);
1445
- markExpectingSwitch();
1446
- claudeProc.write(`/resume ${msg.sessionId}`);
1447
- setTimeout(() => {
1448
- if (claudeProc) claudeProc.write('\r');
1449
- }, 150);
1450
- }
1451
- break;
1452
- }
1453
- case 'change_cwd': {
1454
- if (msg.cwd) {
1455
- try {
1456
- const targetCwd = assertDirectoryPath(msg.cwd);
1457
- restartClaude(targetCwd);
1458
- } catch (err) {
1459
- sendWs(ws, { type: 'cwd_change_error', cwd: String(msg.cwd), error: err.message });
1460
- }
1461
- }
1462
- break;
1463
- }
1464
- }
1465
- });
1466
-
1467
- ws.on('close', () => {
1468
- if (ws._authTimer) {
1469
- clearTimeout(ws._authTimer);
1470
- ws._authTimer = null;
1471
- }
1472
- if (ws._legacyReplayTimer) {
1473
- clearTimeout(ws._legacyReplayTimer);
1474
- ws._legacyReplayTimer = null;
1475
- }
1476
- log(`WS closed: ${wsLabel(ws)}`);
1477
- cleanupClientUploads(ws);
1478
- });
1479
- });
1480
-
1481
- // ============================================================
1482
- // 4. PTY Manager — local terminal passthrough
1483
- // ============================================================
1484
- function spawnClaude() {
1485
- const isWin = process.platform === 'win32';
1486
- const shell = isWin ? 'powershell.exe' : (process.env.SHELL || '/bin/bash');
1487
- const claudeCmd = CLAUDE_EXTRA_ARGS.length > 0
1488
- ? `claude ${CLAUDE_EXTRA_ARGS.join(' ')}`
1489
- : 'claude';
1490
- const args = isWin
1491
- ? ['-NoLogo', '-NoProfile', '-Command', claudeCmd]
1492
- : ['-c', claudeCmd];
1493
-
1494
- // Use local terminal size if available, otherwise default
1495
- const cols = isTTY ? process.stdout.columns : 120;
1496
- const rows = isTTY ? process.stdout.rows : 40;
1497
-
1498
- const proc = claudeProc = pty.spawn(shell, args, {
1499
- name: 'xterm-256color',
1500
- cols,
1501
- rows,
1502
- cwd: CWD,
1503
- env: { ...process.env, FORCE_COLOR: '1', BRIDGE_PORT: String(PORT) },
1504
- });
1505
-
1506
- log(`Claude spawned (pid ${claudeProc.pid}) — ${cols}x${rows} cmd="${claudeCmd}"`);
1507
- setTurnState('idle', { sessionId: currentSessionId, reason: 'claude_spawned' });
1508
- broadcast({
1509
- type: 'status',
1510
- status: 'running',
1511
- pid: proc.pid,
1512
- cwd: CWD,
1513
- sessionId: currentSessionId,
1514
- lastSeq: latestEventSeq(),
1515
- });
1516
-
1517
- // === PTY output → local terminal + WebSocket + mode detection ===
1518
- proc.onData((data) => {
1519
- if (isTTY) process.stdout.write(data); // show in the terminal you ran the bridge from
1520
- broadcast({ type: 'pty_output', data }); // push to WebUI
1521
- });
1522
-
1523
- // === Local terminal input → PTY ===
1524
- attachTtyForwarders();
1525
-
1526
- // === PTY exit → cleanup ===
1527
- proc.onExit(({ exitCode, signal }) => {
1528
- if (claudeProc !== proc) {
1529
- log(`Ignoring stale Claude exit (pid ${proc.pid}, code=${exitCode}, signal=${signal})`);
1530
- return;
1531
- }
1532
- log(`Claude exited (code=${exitCode}, signal=${signal})`);
1533
- setTurnState('idle', { sessionId: currentSessionId, reason: 'pty_exit' });
1534
- broadcast({ type: 'pty_exit', exitCode, signal });
1535
- claudeProc = null;
1536
-
1537
- // Restore terminal and exit bridge
1538
- if (isTTY) {
1539
- process.stdin.setRawMode(false);
1540
- process.stdin.pause();
1541
- }
1542
- stopTailing();
1543
- log('Bridge shutting down.');
1544
- setTimeout(() => process.exit(exitCode || 0), 300);
1545
- });
1546
- }
1547
-
1548
- // ============================================================
1549
- // 4. Transcript Discovery & Tailing
1550
- // ============================================================
1551
- function getProjectSlug(cwd) {
1552
- return cwd.replace(/[^a-zA-Z0-9]/g, '-');
1553
- }
1554
-
1555
- function hasConversationEvent(evt) {
1556
- if (!evt || typeof evt !== 'object') return false;
1557
- if (evt.type === 'user' || evt.type === 'assistant') return true;
1558
- const role = evt.message && typeof evt.message === 'object' ? evt.message.role : null;
1559
- return role === 'user' || role === 'assistant';
1560
- }
1561
-
1562
- function fileLooksLikeTranscript(filePath) {
1563
- try {
1564
- const stat = fs.statSync(filePath);
1565
- if (stat.size <= 0) return false;
1566
-
1567
- const readSize = Math.min(stat.size, 64 * 1024);
1568
- const fd = fs.openSync(filePath, 'r');
1569
- const buf = Buffer.alloc(readSize);
1570
- fs.readSync(fd, buf, 0, readSize, stat.size - readSize);
1571
- fs.closeSync(fd);
1572
-
1573
- const lines = buf.toString('utf8').split('\n').filter(Boolean);
1574
- for (const line of lines) {
1575
- try {
1576
- const evt = JSON.parse(line);
1577
- if (hasConversationEvent(evt)) return true;
1578
- } catch {
1579
- // ignore malformed lines at file tail
1580
- }
1581
- }
1582
- } catch {}
1583
- return false;
1584
- }
1585
-
1586
- function flattenUserContent(content) {
1587
- if (typeof content === 'string') return content;
1588
- if (!Array.isArray(content)) return '';
1589
- return content.map(block => {
1590
- if (!block || typeof block !== 'object') return '';
1591
- if (typeof block.text === 'string') return block.text;
1592
- if (typeof block.content === 'string') return block.content;
1593
- return '';
1594
- }).filter(Boolean).join('\n');
1595
- }
1596
-
1597
- function extractSlashCommand(content) {
1598
- const text = flattenUserContent(content).trim();
1599
- if (!text) return '';
1600
-
1601
- const commandTagMatch = text.match(/<command-name>\s*(\/[^\s<]+)\s*<\/command-name>/i);
1602
- if (commandTagMatch) return commandTagMatch[1].trim().toLowerCase();
1603
-
1604
- const inlineMatch = text.match(/^(\/\S+)/);
1605
- return inlineMatch ? inlineMatch[1].trim().toLowerCase() : '';
1606
- }
1607
-
1608
- function isUserInterruptEvent(content) {
1609
- const text = flattenUserContent(content)
1610
- .replace(/\x1B\[[0-9;]*m/g, '')
1611
- .trim();
1612
- if (!text) return false;
1613
- return /(?:^|\n)\[Request interrupted by user(?: for tool use)?\](?:\r?\n|$)/i.test(text);
1614
- }
1615
-
1616
- function isNonAiUserEvent(event, content) {
1617
- if (!event || typeof event !== 'object') return false;
1618
- if (event.isMeta === true) return true;
1619
- if (event.isCompactSummary === true) return true;
1620
- if (event.isVisibleInTranscriptOnly === true) return true;
1621
- if (isUserInterruptEvent(content)) return true;
1622
-
1623
- const text = flattenUserContent(content).trim();
1624
- if (!text) return false;
1625
- return /<local-command-(?:stdout|stderr|caveat)>/i.test(text);
1626
- }
1627
-
1628
- function extractSessionPrompt(event) {
1629
- if (!event || event.type !== 'user') return '';
1630
-
1631
- const message = event.message;
1632
- const content = typeof message === 'string'
1633
- ? message
1634
- : (message && typeof message === 'object' ? message.content : '');
1635
- const text = flattenUserContent(content).trim();
1636
- if (!text) return '';
1637
- if (isNonAiUserEvent(event, content)) return '';
1638
- if (extractSlashCommand(content)) return '';
1
+ 'use strict';
1639
2
 
1640
- return text.replace(/\s+/g, ' ').trim().substring(0, 120);
1641
- }
1642
-
1643
- function attachTranscript(target, startOffset = 0) {
1644
- transcriptPath = target.full;
1645
- currentSessionId = path.basename(transcriptPath, '.jsonl');
1646
- setTurnState('idle', { sessionId: currentSessionId, reason: 'transcript_attached' });
1647
- pendingInitialClearTranscript = target.ignoreInitialClearCommand
1648
- ? { sessionId: currentSessionId }
1649
- : null;
1650
- if (pendingSwitchTarget && pendingSwitchTarget.sessionId === currentSessionId) {
1651
- pendingSwitchTarget = null;
1652
- }
1653
- transcriptOffset = Math.max(0, startOffset);
1654
- tailRemainder = Buffer.alloc(0);
1655
- eventBuffer = [];
1656
- eventSeq = 0;
1657
-
1658
- // Clear the expecting-switch state — we've found the new session.
1659
- if (expectingSwitch) {
1660
- expectingSwitch = false;
1661
- if (expectingSwitchTimer) { clearTimeout(expectingSwitchTimer); expectingSwitchTimer = null; }
1662
- }
1663
- if (switchWatcherDelayTimer) { clearTimeout(switchWatcherDelayTimer); switchWatcherDelayTimer = null; }
1664
-
1665
- // If transcript file already has content, mark as catching up so historical
1666
- // transcript replay cannot mutate live turn state.
1667
- try {
1668
- const stat = fs.statSync(transcriptPath);
1669
- tailCatchingUp = stat.size > transcriptOffset;
1670
- } catch {
1671
- tailCatchingUp = false;
1672
- }
1673
-
1674
- log(`Transcript attached: ${currentSessionId} (offset=${transcriptOffset} catchUp=${tailCatchingUp})`);
1675
- broadcast({
1676
- type: 'transcript_ready',
1677
- transcript: transcriptPath,
1678
- sessionId: currentSessionId,
1679
- lastSeq: 0,
1680
- });
1681
- startTailing();
1682
- // switchWatcher is now only started as a delayed fallback from markExpectingSwitch()
1683
- }
1684
-
1685
- function markExpectingSwitch() {
1686
- expectingSwitch = true;
1687
- if (expectingSwitchTimer) clearTimeout(expectingSwitchTimer);
1688
- expectingSwitchTimer = setTimeout(() => {
1689
- expectingSwitch = false;
1690
- expectingSwitchTimer = null;
1691
- log('Expecting-switch flag expired (no new transcript found)');
1692
- }, 15000);
1693
- log('Expecting session switch (/clear detected)');
1694
- if (maybeAttachPendingSwitchTarget('markExpectingSwitch')) return;
1695
-
1696
- // Delay switchWatcher as fallback — give hooks 5s to bind deterministically
1697
- if (switchWatcher) { clearInterval(switchWatcher); switchWatcher = null; }
1698
- if (switchWatcherDelayTimer) { clearTimeout(switchWatcherDelayTimer); switchWatcherDelayTimer = null; }
1699
- switchWatcherDelayTimer = setTimeout(() => {
1700
- switchWatcherDelayTimer = null;
1701
- if (expectingSwitch && !switchWatcher) {
1702
- log('Hook did not bind within 5s, starting switchWatcher fallback');
1703
- startSwitchWatcher();
1704
- }
1705
- }, 5000);
1706
- }
1707
-
1708
- function startSwitchWatcher() {
1709
- if (switchWatcher) { clearInterval(switchWatcher); switchWatcher = null; }
1710
- const slug = getProjectSlug(CWD);
1711
- const projectDir = path.join(PROJECTS_DIR, slug);
1712
-
1713
- switchWatcher = setInterval(() => {
1714
- if (!transcriptPath || !expectingSwitch || !fs.existsSync(projectDir)) return;
1715
- try {
1716
- const currentBasename = path.basename(transcriptPath);
1717
- const candidates = fs.readdirSync(projectDir)
1718
- .filter(f => f.endsWith('.jsonl') && f !== currentBasename)
1719
- .map(f => {
1720
- const full = path.join(projectDir, f);
1721
- const stat = fs.statSync(full);
1722
- return { name: f, full, mtime: stat.mtimeMs, size: stat.size };
1723
- })
1724
- .filter(t => t.mtime > fs.statSync(transcriptPath).mtimeMs)
1725
- .sort((a, b) => b.mtime - a.mtime);
1726
-
1727
- const newer = candidates.find(t => fileLooksLikeTranscript(t.full));
1728
- if (newer) {
1729
- log(`Session switch detected → ${path.basename(newer.full, '.jsonl')}`);
1730
- expectingSwitch = false;
1731
- if (expectingSwitchTimer) { clearTimeout(expectingSwitchTimer); expectingSwitchTimer = null; }
1732
- if (tailTimer) { clearInterval(tailTimer); tailTimer = null; }
1733
- if (switchWatcher) { clearInterval(switchWatcher); switchWatcher = null; }
1734
- attachTranscript(newer, 0);
1735
- }
1736
- } catch {}
1737
- }, 500);
1738
- }
1739
-
1740
- function startTailing() {
1741
- tailRemainder = Buffer.alloc(0);
1742
- tailTimer = setInterval(() => {
1743
- if (maybeAttachPendingSwitchTarget('tail_pending_target')) return;
1744
- if (!transcriptPath) return;
1745
- try {
1746
- const stat = fs.statSync(transcriptPath);
1747
- if (stat.size <= transcriptOffset) {
1748
- // Caught up to file end — initial catch-up phase is over
1749
- if (tailCatchingUp) {
1750
- tailCatchingUp = false;
1751
- log('Tail catch-up complete, live mode');
1752
- }
1753
- return;
1754
- }
1755
-
1756
- const fd = fs.openSync(transcriptPath, 'r');
1757
- const buf = Buffer.alloc(stat.size - transcriptOffset);
1758
- fs.readSync(fd, buf, 0, buf.length, transcriptOffset);
1759
- fs.closeSync(fd);
1760
- transcriptOffset = stat.size;
1761
-
1762
- const data = tailRemainder.length > 0 ? Buffer.concat([tailRemainder, buf]) : buf;
1763
- let start = 0;
1764
- for (let i = 0; i < data.length; i++) {
1765
- if (data[i] !== 0x0A) continue; // '\n'
1766
- const line = data.slice(start, i).toString('utf8').trim();
1767
- start = i + 1;
1768
- if (!line) continue;
1769
- try {
1770
- const event = JSON.parse(line);
1771
- // Detect /clear from JSONL events (covers terminal direct input)
1772
- if (event.type === 'user' || (event.message && event.message.role === 'user')) {
1773
- const content = event.message && event.message.content;
1774
- const slashCommand = extractSlashCommand(content);
1775
- const isInterruptedUserEvent = isUserInterruptEvent(content);
1776
- const isPassiveUserEvent = isNonAiUserEvent(event, content);
1777
- const ignoreInitialClear = (
1778
- slashCommand === '/clear' &&
1779
- pendingInitialClearTranscript &&
1780
- pendingInitialClearTranscript.sessionId === currentSessionId
1781
- );
1782
- if (!tailCatchingUp && isInterruptedUserEvent) {
1783
- setTurnState('idle', { sessionId: currentSessionId, reason: 'transcript_user_interrupt' });
1784
- }
1785
- // Only live, AI-producing user messages can move the turn state
1786
- // into running. Historical replay and slash commands are ignored.
1787
- if (!tailCatchingUp && !slashCommand && !isPassiveUserEvent) {
1788
- setTurnState('running', { sessionId: currentSessionId, reason: 'transcript_user_event' });
1789
- }
1790
- if (slashCommand === '/clear') {
1791
- if (ignoreInitialClear) {
1792
- pendingInitialClearTranscript = null;
1793
- log(`Ignored bootstrap /clear transcript event for session ${currentSessionId}`);
1794
- } else {
1795
- markExpectingSwitch();
1796
- }
1797
- } else if (
1798
- pendingInitialClearTranscript &&
1799
- pendingInitialClearTranscript.sessionId === currentSessionId &&
1800
- !isPassiveUserEvent &&
1801
- !event.isMeta &&
1802
- !event.isCompactSummary &&
1803
- !event.isVisibleInTranscriptOnly
1804
- ) {
1805
- pendingInitialClearTranscript = null;
1806
- }
1807
- } else if (pendingInitialClearTranscript && pendingInitialClearTranscript.sessionId === currentSessionId &&
1808
- event.type === 'assistant') {
1809
- pendingInitialClearTranscript = null;
1810
- }
1811
- // Enrich Edit tool_use blocks with source file start line
1812
- enrichEditStartLines(event);
1813
- const record = { seq: ++eventSeq, event };
1814
- eventBuffer.push(record);
1815
- if (eventBuffer.length > EVENT_BUFFER_MAX) {
1816
- eventBuffer = eventBuffer.slice(-Math.round(EVENT_BUFFER_MAX * 0.8));
1817
- }
1818
- broadcast({ type: 'log_event', seq: record.seq, event });
1819
- } catch {
1820
- // skip malformed lines
1821
- }
1822
- }
1823
- tailRemainder = data.slice(start);
1824
- } catch {
1825
- // file might be temporarily locked
1826
- }
1827
- }, 300);
1828
- }
1829
-
1830
- function enrichEditStartLines(event) {
1831
- const content = event.message && event.message.content;
1832
- if (!Array.isArray(content)) return;
1833
- for (const block of content) {
1834
- if (block.type !== 'tool_use' || block.name !== 'Edit') continue;
1835
- const input = block.input;
1836
- if (!input || !input.file_path || input.old_string === undefined) continue;
1837
- try {
1838
- const filePath = path.resolve(CWD, input.file_path);
1839
- const src = fs.readFileSync(filePath, 'utf8');
1840
- // Search for new_string first (edit likely already applied), fallback to old_string
1841
- const needle = input.new_string || input.old_string;
1842
- const idx = src.indexOf(needle);
1843
- if (idx >= 0) {
1844
- input._startLine = src.substring(0, idx).split('\n').length;
1845
- }
1846
- } catch {
1847
- // file not readable — skip enrichment
1848
- }
1849
- }
1850
- }
1851
-
1852
- // ============================================================
1853
- // Session Scanner — list historical sessions from JSONL files
1854
- // ============================================================
1855
- function scanSessions(cwd, limit = 20) {
1856
- const dir = path.join(PROJECTS_DIR, getProjectSlug(cwd));
1857
- let files;
1858
- try {
1859
- files = fs.readdirSync(dir).filter(f => f.endsWith('.jsonl'));
1860
- } catch {
1861
- return [];
1862
- }
1863
-
1864
- // Stat each file, sort by mtime desc
1865
- const entries = [];
1866
- for (const f of files) {
1867
- const full = path.join(dir, f);
1868
- try {
1869
- const stat = fs.statSync(full);
1870
- entries.push({ file: f, full, mtime: stat.mtimeMs, size: stat.size });
1871
- } catch { /* skip */ }
1872
- }
1873
- entries.sort((a, b) => b.mtime - a.mtime);
1874
- const top = entries.slice(0, limit);
1875
-
1876
- const sessions = [];
1877
- for (const entry of top) {
1878
- const sessionId = path.basename(entry.file, '.jsonl');
1879
- const info = {
1880
- sessionId,
1881
- summary: '',
1882
- firstPrompt: '',
1883
- lastModified: Math.round(entry.mtime),
1884
- fileSize: entry.size,
1885
- cwd: cwd,
1886
- };
1887
-
1888
- // Read first ~64KB to extract the first real user prompt and model.
1889
- try {
1890
- const fd = fs.openSync(entry.full, 'r');
1891
- const buf = Buffer.alloc(Math.min(entry.size, 64 * 1024));
1892
- fs.readSync(fd, buf, 0, buf.length, 0);
1893
- fs.closeSync(fd);
1894
- const lines = buf.toString('utf8').split('\n').filter(Boolean);
1895
- for (const line of lines) {
1896
- try {
1897
- const evt = JSON.parse(line);
1898
- if (!info.firstPrompt) {
1899
- info.firstPrompt = extractSessionPrompt(evt);
1900
- }
1901
- if (!info.model && evt.model) {
1902
- info.model = evt.model;
1903
- }
1904
- } catch { /* skip malformed line */ }
1905
- }
1906
- } catch { /* skip unreadable file */ }
1907
-
1908
- info.summary = info.firstPrompt || 'Untitled';
1909
- sessions.push(info);
1910
- }
1911
- return sessions;
1912
- }
1913
-
1914
- function getDirectoryRoots() {
1915
- if (process.platform === 'win32') {
1916
- const roots = [];
1917
- for (let code = 65; code <= 90; code++) {
1918
- const drive = String.fromCharCode(code) + ':\\';
1919
- try {
1920
- if (fs.existsSync(drive)) roots.push(drive);
1921
- } catch {}
1922
- }
1923
- return roots;
1924
- }
1925
- return ['/'];
1926
- }
1927
-
1928
- function assertDirectoryPath(target) {
1929
- const resolved = path.resolve(String(target || ''));
1930
- let stat;
1931
- try {
1932
- stat = fs.statSync(resolved);
1933
- } catch {
1934
- throw new Error('Directory not found');
1935
- }
1936
- if (!stat.isDirectory()) throw new Error('Path is not a directory');
1937
- return resolved;
1938
- }
1939
-
1940
- function listDirectories(target) {
1941
- const cwd = assertDirectoryPath(target);
1942
- const roots = getDirectoryRoots();
1943
- const parentDir = path.dirname(cwd);
1944
- const parent = normalizeFsPath(parentDir) === normalizeFsPath(cwd) ? null : parentDir;
1945
-
1946
- const entries = fs.readdirSync(cwd, { withFileTypes: true })
1947
- .filter(entry => entry.isDirectory())
1948
- .map(entry => ({
1949
- name: entry.name,
1950
- path: path.join(cwd, entry.name),
1951
- }))
1952
- .sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }));
1953
-
1954
- return { cwd, parent, roots, entries };
1955
- }
1956
-
1957
- function stopTailing() {
1958
- if (tailTimer) { clearInterval(tailTimer); tailTimer = null; }
1959
- if (switchWatcher) { clearInterval(switchWatcher); switchWatcher = null; }
1960
- if (switchWatcherDelayTimer) { clearTimeout(switchWatcherDelayTimer); switchWatcherDelayTimer = null; }
1961
- if (expectingSwitchTimer) { clearTimeout(expectingSwitchTimer); expectingSwitchTimer = null; }
1962
- expectingSwitch = false;
1963
- pendingSwitchTarget = null;
1964
- pendingInitialClearTranscript = null;
1965
- tailRemainder = Buffer.alloc(0);
1966
- }
1967
-
1968
- function trustProjectCwd(cwd) {
1969
- const resolved = path.resolve(String(cwd || ''));
1970
- if (!resolved) return;
1971
-
1972
- let state = {};
1973
- try {
1974
- state = JSON.parse(fs.readFileSync(CLAUDE_STATE_FILE, 'utf8'));
1975
- } catch {}
1976
-
1977
- state.projects = state.projects && typeof state.projects === 'object' ? state.projects : {};
1978
- const keyVariants = process.platform === 'win32'
1979
- ? [resolved, resolved.replace(/\\/g, '/')]
1980
- : [resolved];
1981
-
1982
- for (const projectKey of keyVariants) {
1983
- const existing = state.projects[projectKey];
1984
- state.projects[projectKey] = {
1985
- ...(existing && typeof existing === 'object' ? existing : {}),
1986
- hasTrustDialogAccepted: true,
1987
- projectOnboardingSeenCount: Number.isInteger(existing?.projectOnboardingSeenCount)
1988
- ? existing.projectOnboardingSeenCount
1989
- : 0,
1990
- };
1991
- }
1992
-
1993
- fs.writeFileSync(CLAUDE_STATE_FILE, JSON.stringify(state, null, 2));
1994
- log(`Trusted Claude project cwd: ${resolved}`);
1995
- }
1996
-
1997
- function restartClaude(newCwd) {
1998
- log(`Restarting Claude with new CWD: ${newCwd}`);
1999
- CWD = newCwd;
2000
- try {
2001
- trustProjectCwd(CWD);
2002
- } catch (err) {
2003
- log(`Failed to trust Claude project cwd "${CWD}": ${err.message}`);
2004
- }
2005
-
2006
- // Stop transcript tailing (also clears switch/expect state)
2007
- stopTailing();
2008
-
2009
- // Reset session state
2010
- currentSessionId = null;
2011
- transcriptPath = null;
2012
- transcriptOffset = 0;
2013
- eventBuffer = [];
2014
- eventSeq = 0;
2015
- tailCatchingUp = false;
2016
- setTurnState('idle', { sessionId: null, reason: 'restart_claude' });
2017
-
2018
- // Mark the current PTY as stale before killing it so its exit handler
2019
- // does not shut down the whole bridge during a restart.
2020
- const procToRestart = claudeProc;
2021
- claudeProc = null;
2022
- if (procToRestart) {
2023
- procToRestart.kill();
2024
- }
2025
-
2026
- // Re-setup hooks (CWD changed → project slug may change)
2027
- setupHooks();
2028
-
2029
- // Broadcast cwd change immediately so clients drop the previous session
2030
- // before the replacement Claude process attaches a new transcript.
2031
- broadcast({ type: 'cwd_changed', cwd: CWD, sessionId: null, lastSeq: 0 });
2032
-
2033
- // Respawn Claude in new directory
2034
- spawnClaude();
2035
- }
2036
-
2037
- // ============================================================
2038
- // 5. Image Upload → Clipboard Injection
2039
- // ============================================================
2040
- async function handlePreparedImageUpload({ tmpFile, mediaType, text, logLabel = '', onCleanup = null }) {
2041
- if (!claudeProc) throw new Error('Claude not running');
2042
- if (!tmpFile || !fs.existsSync(tmpFile)) throw new Error('Prepared image file missing');
2043
-
2044
- const isWin = process.platform === 'win32';
2045
- const isMac = process.platform === 'darwin';
2046
- const isLinux = !isWin && !isMac;
2047
- try {
2048
- const stat = fs.statSync(tmpFile);
2049
- log(`Image ready: ${logLabel || path.basename(tmpFile)} (${stat.size} bytes)`);
2050
- if (isLinux) {
2051
- const linuxPrompt = buildLinuxImagePrompt(text, tmpFile);
2052
- await new Promise((resolve, reject) => {
2053
- if (!claudeProc) {
2054
- reject(new Error('Claude stopped before Linux image submit'));
2055
- return;
2056
- }
2057
- claudeProc.write(linuxPrompt);
2058
- setTimeout(() => {
2059
- if (!claudeProc) {
2060
- reject(new Error('Claude stopped before Linux image submit'));
2061
- return;
2062
- }
2063
- claudeProc.write('\r');
2064
- log(`Sent Linux image prompt via @ref: "${linuxPrompt.substring(0, 120)}"`);
2065
- setTimeout(() => {
2066
- if (onCleanup) onCleanup();
2067
- else {
2068
- try { fs.unlinkSync(tmpFile); } catch {}
2069
- }
2070
- }, LINUX_AT_IMAGE_CLEANUP_DELAY_MS);
2071
- resolve();
2072
- }, LINUX_AT_PROMPT_SUBMIT_DELAY_MS);
2073
- });
2074
- return;
2075
- }
2076
-
2077
- if (isWin) {
2078
- const psCmd = `Add-Type -AssemblyName System.Drawing; Add-Type -AssemblyName System.Windows.Forms; $img = [System.Drawing.Image]::FromFile('${tmpFile.replace(/'/g, "''")}'); [System.Windows.Forms.Clipboard]::SetImage($img); $img.Dispose()`;
2079
- execSync(`powershell -NoProfile -STA -Command "${psCmd}"`, { timeout: 10000 });
2080
- } else if (isMac) {
2081
- execSync(`osascript -e 'set the clipboard to (read POSIX file "${tmpFile}" as 芦class PNGf禄)'`, { timeout: 10000 });
2082
- }
2083
- log('Clipboard set with image');
2084
-
2085
- const pasteDelayMs = isWin || isMac ? 0 : 150;
2086
- await new Promise((resolve, reject) => {
2087
- setTimeout(() => {
2088
- if (!claudeProc) {
2089
- reject(new Error('Claude stopped before image paste'));
2090
- return;
2091
- }
2092
- if (isWin) claudeProc.write('\x1bv');
2093
- else claudeProc.write('\x16');
2094
- log('Sent image paste keypress to PTY');
2095
-
2096
- setTimeout(() => {
2097
- if (!claudeProc) {
2098
- reject(new Error('Claude stopped before image prompt'));
2099
- return;
2100
- }
2101
- const trimmedText = (text || '').trim();
2102
- if (trimmedText) claudeProc.write(trimmedText);
2103
-
2104
- setTimeout(() => {
2105
- if (!claudeProc) {
2106
- reject(new Error('Claude stopped before image submit'));
2107
- return;
2108
- }
2109
- claudeProc.write('\r');
2110
- log('Sent Enter after image paste' + (trimmedText ? ` + text: "${trimmedText.substring(0, 60)}"` : ''));
2111
-
2112
- setTimeout(() => {
2113
- if (onCleanup) onCleanup();
2114
- else {
2115
- try { fs.unlinkSync(tmpFile); } catch {}
2116
- }
2117
- }, 5000);
2118
- resolve();
2119
- }, 150);
2120
- }, 1000);
2121
- }, pasteDelayMs);
2122
- });
2123
- } catch (err) {
2124
- log(`Image upload error: ${err.message}`);
2125
- if (onCleanup) onCleanup();
2126
- else {
2127
- try { fs.unlinkSync(tmpFile); } catch {}
2128
- }
2129
- throw err;
2130
- }
2131
- }
2132
-
2133
- function handleImageUpload(msg) {
2134
- if (!claudeProc) {
2135
- log('Image upload ignored: Claude not running');
2136
- return;
2137
- }
2138
- if (!msg.base64) {
2139
- log('Image upload ignored: no base64 data');
2140
- return;
2141
- }
2142
- let tmpFile = null;
2143
-
2144
- try {
2145
- const buf = Buffer.from(msg.base64, 'base64');
2146
- tmpFile = createTempImageFile(buf, msg.mediaType, `legacy_${Date.now()}`);
2147
- log(`Image saved: ${tmpFile} (${buf.length} bytes)`);
2148
- handlePreparedImageUpload({
2149
- tmpFile,
2150
- mediaType: msg.mediaType,
2151
- text: msg.text || '',
2152
- }).then(() => {
2153
- setTurnState('running', { reason: 'legacy_image_upload' });
2154
- }).catch((err) => {
2155
- log(`Image upload error: ${err.message}`);
2156
- });
2157
- } catch (err) {
2158
- log(`Image upload error: ${err.message}`);
2159
- try { fs.unlinkSync(tmpFile); } catch {}
2160
- }
2161
- }
2162
-
2163
- // ============================================================
2164
- // 6. Hook Auto-Setup
2165
-
2166
- // ============================================================
2167
- function setupHooks() {
2168
- const claudeDir = path.join(CWD, '.claude');
2169
- if (!fs.existsSync(claudeDir)) fs.mkdirSync(claudeDir, { recursive: true });
2170
-
2171
- const settingsPath = path.join(claudeDir, 'settings.local.json');
2172
- let settings = {};
2173
- try { settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); } catch {}
2174
-
2175
- const hookScript = path.resolve(__dirname, 'hooks', 'bridge-approval.js').replace(/\\/g, '/');
2176
- const hookCmd = `node "${hookScript}"`;
2177
-
2178
- // Merge bridge hook into PreToolUse (preserve user's other hooks)
2179
- const existing = settings.hooks?.PreToolUse || [];
2180
- const bridgeIdx = existing.findIndex(e =>
2181
- e.hooks?.some(h => h.command?.includes('bridge-approval'))
2182
- );
2183
- const bridgeEntry = {
2184
- matcher: '',
2185
- hooks: [{ type: 'command', command: hookCmd, timeout: 120 }],
2186
- };
2187
-
2188
- if (bridgeIdx >= 0) {
2189
- existing[bridgeIdx] = bridgeEntry;
2190
- } else {
2191
- existing.push(bridgeEntry);
2192
- }
2193
-
2194
- settings.hooks = settings.hooks || {};
2195
- settings.hooks.PreToolUse = existing;
2196
-
2197
- // Merge bridge hook into Stop (notify WebUI when Claude's turn ends)
2198
- const stopScript = path.resolve(__dirname, 'hooks', 'bridge-stop.js').replace(/\\/g, '/');
2199
- const stopCmd = `node "${stopScript}"`;
2200
- const existingStop = settings.hooks.Stop || [];
2201
- const stopBridgeIdx = existingStop.findIndex(e =>
2202
- e.hooks?.some(h => h.command?.includes('bridge-stop'))
2203
- );
2204
- const stopEntry = {
2205
- hooks: [{ type: 'command', command: stopCmd, timeout: 10 }],
2206
- };
2207
- if (stopBridgeIdx >= 0) {
2208
- existingStop[stopBridgeIdx] = stopEntry;
2209
- } else {
2210
- existingStop.push(stopEntry);
2211
- }
2212
- settings.hooks.Stop = existingStop;
2213
-
2214
- const sessionStartScript = path.resolve(__dirname, 'hooks', 'bridge-session-start.js').replace(/\\/g, '/');
2215
- const sessionStartCmd = `node "${sessionStartScript}"`;
2216
- const existingSessionStart = settings.hooks.SessionStart || [];
2217
- const sessionStartBridgeIdx = existingSessionStart.findIndex(e =>
2218
- e.hooks?.some(h => h.command?.includes('bridge-session-start'))
2219
- );
2220
- const sessionStartEntry = {
2221
- hooks: [{ type: 'command', command: sessionStartCmd, timeout: 10 }],
2222
- };
2223
- if (sessionStartBridgeIdx >= 0) {
2224
- existingSessionStart[sessionStartBridgeIdx] = sessionStartEntry;
2225
- } else {
2226
- existingSessionStart.push(sessionStartEntry);
2227
- }
2228
- settings.hooks.SessionStart = existingSessionStart;
2229
-
2230
- // Merge bridge hook into SessionEnd (notify bridge when session ends, e.g. /clear)
2231
- const sessionEndScript = path.resolve(__dirname, 'hooks', 'bridge-session-end.js').replace(/\\/g, '/');
2232
- const sessionEndCmd = `node "${sessionEndScript}"`;
2233
- const existingSessionEnd = settings.hooks.SessionEnd || [];
2234
- const sessionEndBridgeIdx = existingSessionEnd.findIndex(e =>
2235
- e.hooks?.some(h => h.command?.includes('bridge-session-end'))
2236
- );
2237
- const sessionEndEntry = {
2238
- hooks: [{ type: 'command', command: sessionEndCmd, timeout: 10 }],
2239
- };
2240
- if (sessionEndBridgeIdx >= 0) {
2241
- existingSessionEnd[sessionEndBridgeIdx] = sessionEndEntry;
2242
- } else {
2243
- existingSessionEnd.push(sessionEndEntry);
2244
- }
2245
- settings.hooks.SessionEnd = existingSessionEnd;
2246
-
2247
- fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
2248
- log(`Hooks configured: ${settingsPath}`);
2249
- }
2250
-
2251
- // ============================================================
2252
- // 7. Startup
2253
- // ============================================================
2254
- server.listen(PORT, '0.0.0.0', () => {
3
+ const os = require('os');
4
+ const { state, LOG_FILE } = require('./lib/state');
5
+ const { initConfig } = require('./lib/cli');
6
+ const { log } = require('./lib/logger');
7
+ const { createHttpServer } = require('./lib/http-server');
8
+ const { setupWebSocketServer } = require('./lib/ws-server');
9
+ const { spawnClaude } = require('./lib/pty-manager');
10
+ const { setupHooks } = require('./lib/hooks');
11
+ const { startUploadCleanup } = require('./lib/image-upload');
12
+
13
+ // --- Initialize config from CLI args + env ---
14
+ const config = initConfig();
15
+ state.PORT = config.PORT;
16
+ state.CWD = config.CWD;
17
+ state.AUTH_TOKEN = config.AUTH_TOKEN;
18
+ state.AUTH_DISABLED = config.AUTH_DISABLED;
19
+ state.CLAUDE_EXTRA_ARGS = config.CLAUDE_EXTRA_ARGS;
20
+ state.DEBUG_TTY_INPUT = config.DEBUG_TTY_INPUT;
21
+
22
+ // --- Create servers ---
23
+ const server = createHttpServer();
24
+ setupWebSocketServer(server);
25
+
26
+ // --- Start periodic cleanup ---
27
+ startUploadCleanup();
28
+
29
+ // --- Listen ---
30
+ server.listen(state.PORT, '0.0.0.0', () => {
2255
31
  const ifaces = os.networkInterfaces();
2256
32
  let lanIp = 'localhost';
2257
33
  for (const name of Object.keys(ifaces)) {
@@ -2262,31 +38,30 @@ server.listen(PORT, '0.0.0.0', () => {
2262
38
  }
2263
39
  }
2264
40
  }
2265
- const local = `http://localhost:${PORT}`;
2266
- const lan = `http://${lanIp}:${PORT}`;
41
+ const local = `http://localhost:${state.PORT}`;
42
+ const lan = `http://${lanIp}:${state.PORT}`;
2267
43
 
2268
- // Print banner to stdout BEFORE PTY takes over
2269
44
  let banner = `
2270
45
  Claude Remote Control Bridge
2271
46
  ─────────────────────────────
2272
47
  Local: ${local}
2273
48
  LAN: ${lan}
2274
- CWD: ${CWD}
49
+ CWD: ${state.CWD}
2275
50
  Log: ${LOG_FILE}
2276
51
  `;
2277
- if (AUTH_DISABLED) {
52
+ if (config.AUTH_DISABLED) {
2278
53
  banner += ` Auth: DISABLED (no authentication)\n`;
2279
54
  } else {
2280
- banner += ` Token: ${AUTH_TOKEN}\n`;
55
+ banner += ` Token: ${config.AUTH_TOKEN}\n`;
2281
56
  }
2282
- if (UNUSED_LEGACY_TOKEN_ENV) {
2283
- banner += ` Note: Ignoring legacy ${LEGACY_AUTH_TOKEN_ENV_VAR}; use ${AUTH_TOKEN_ENV_VAR} instead\n`;
57
+ if (config.unusedLegacyTokenEnv) {
58
+ banner += ` Note: Ignoring legacy ${config.LEGACY_AUTH_TOKEN_ENV_VAR}; use ${config.AUTH_TOKEN_ENV_VAR} instead\n`;
2284
59
  }
2285
- if (CLAUDE_EXTRA_ARGS.length > 0) {
2286
- banner += ` Args: claude ${CLAUDE_EXTRA_ARGS.join(' ')}\n`;
60
+ if (config.CLAUDE_EXTRA_ARGS.length > 0) {
61
+ banner += ` Args: claude ${config.CLAUDE_EXTRA_ARGS.join(' ')}\n`;
2287
62
  }
2288
- if (_blockedArgs.length > 0) {
2289
- banner += ` Blocked: ${_blockedArgs.join(', ')} (incompatible with bridge)\n`;
63
+ if (config.blockedArgs.length > 0) {
64
+ banner += ` Blocked: ${config.blockedArgs.join(', ')} (incompatible with bridge)\n`;
2290
65
  }
2291
66
  banner += `
2292
67
  Phone: ${lan}