claude-remote 0.1.2 → 0.1.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 (3) hide show
  1. package/README.md +26 -0
  2. package/package.json +1 -1
  3. package/server.js +907 -801
package/server.js CHANGED
@@ -1,21 +1,116 @@
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 { execSync } = require('child_process');
8
-
9
- // --- Config ---
10
- const PORT = parseInt(process.env.PORT || '3100', 10);
11
- const CWD = process.argv[2] || process.cwd();
12
- const CLAUDE_HOME = path.join(os.homedir(), '.claude');
13
- const PROJECTS_DIR = path.join(CLAUDE_HOME, 'projects');
14
-
15
- // --- State ---
16
- let claudeProc = null;
17
- let transcriptPath = null;
18
- let currentSessionId = null;
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 { execSync } = require('child_process');
8
+
9
+ // --- CLI argument parsing ---
10
+ // Separate bridge args (CWD positional) from claude passthrough flags.
11
+ // Usage: claude-remote [cwd] [--claude-flags...]
12
+ // Example: claude-remote --resume xxx
13
+ // claude-remote /path/to/project --resume xxx -c
14
+ const BLOCKED_FLAGS = new Set([
15
+ '--print', '-p', // non-interactive mode, breaks PTY bridge
16
+ '--output-format', // requires --print
17
+ '--input-format', // requires --print
18
+ '--include-partial-messages', // requires --print
19
+ '--json-schema', // requires --print
20
+ '--no-session-persistence', // requires --print
21
+ '--max-budget-usd', // requires --print
22
+ '--max-turns', // requires --print
23
+ '--fallback-model', // requires --print
24
+ '--permission-prompt-tool', // conflicts with bridge approval hooks
25
+ '--version', '-v', // exits immediately
26
+ '--help', '-h', // exits immediately
27
+ '--init-only', // exits immediately
28
+ '--maintenance', // exits immediately
29
+ ]);
30
+
31
+ // Flags that consume the next argument as a value
32
+ const FLAGS_WITH_VALUE = new Set([
33
+ '--resume', '-r', '--session-id', '--from-pr', '--model',
34
+ '--system-prompt', '--system-prompt-file',
35
+ '--append-system-prompt', '--append-system-prompt-file',
36
+ '--permission-mode', '--add-dir', '--worktree', '-w',
37
+ '--mcp-config', '--settings', '--setting-sources',
38
+ '--agent', '--agents', '--teammate-mode',
39
+ '--allowedTools', '--disallowedTools', '--tools',
40
+ '--betas', '--debug', '--plugin-dir',
41
+ // blocked but still need to consume their values when filtering
42
+ '--output-format', '--input-format', '--json-schema',
43
+ '--max-budget-usd', '--max-turns', '--fallback-model',
44
+ '--permission-prompt-tool',
45
+ ]);
46
+
47
+ function parseCliArgs(argv) {
48
+ const rawArgs = argv.slice(2);
49
+ let cwd = null;
50
+ const claudeArgs = [];
51
+ const blocked = [];
52
+
53
+ let i = 0;
54
+ while (i < rawArgs.length) {
55
+ const arg = rawArgs[i];
56
+
57
+ if (arg === '--') {
58
+ // Everything after -- is passed to claude
59
+ claudeArgs.push(...rawArgs.slice(i + 1));
60
+ break;
61
+ }
62
+
63
+ if (!arg.startsWith('-')) {
64
+ // Positional arg → treat first one as CWD (backward compatible)
65
+ if (!cwd) {
66
+ cwd = arg;
67
+ } else {
68
+ claudeArgs.push(arg);
69
+ }
70
+ i++;
71
+ continue;
72
+ }
73
+
74
+ // Handle --flag=value syntax
75
+ const eqIdx = arg.indexOf('=');
76
+ const flagName = eqIdx > 0 ? arg.substring(0, eqIdx) : arg;
77
+
78
+ if (BLOCKED_FLAGS.has(flagName)) {
79
+ blocked.push(flagName);
80
+ if (eqIdx > 0) {
81
+ // --flag=value, already consumed
82
+ } else if (FLAGS_WITH_VALUE.has(flagName) && i + 1 < rawArgs.length) {
83
+ i++; // skip the value too
84
+ }
85
+ i++;
86
+ continue;
87
+ }
88
+
89
+ // Pass through to claude
90
+ claudeArgs.push(arg);
91
+ // If this flag takes a value and it's not in --flag=value form, grab next arg
92
+ if (eqIdx < 0 && FLAGS_WITH_VALUE.has(flagName) && i + 1 < rawArgs.length && !rawArgs[i + 1].startsWith('-')) {
93
+ i++;
94
+ claudeArgs.push(rawArgs[i]);
95
+ }
96
+ i++;
97
+ }
98
+
99
+ return { cwd: cwd || process.cwd(), claudeArgs, blocked };
100
+ }
101
+
102
+ const { cwd: _parsedCwd, claudeArgs: CLAUDE_EXTRA_ARGS, blocked: _blockedArgs } = parseCliArgs(process.argv);
103
+
104
+ // --- Config ---
105
+ const PORT = parseInt(process.env.PORT || '3100', 10);
106
+ const CWD = _parsedCwd;
107
+ const CLAUDE_HOME = path.join(os.homedir(), '.claude');
108
+ const PROJECTS_DIR = path.join(CLAUDE_HOME, 'projects');
109
+
110
+ // --- State ---
111
+ let claudeProc = null;
112
+ let transcriptPath = null;
113
+ let currentSessionId = null;
19
114
  let transcriptOffset = 0;
20
115
  let eventBuffer = [];
21
116
  let eventSeq = 0;
@@ -26,21 +121,21 @@ let switchWatcher = null;
26
121
  let expectingSwitch = false;
27
122
  let expectingSwitchTimer = null;
28
123
  let tailRemainder = Buffer.alloc(0);
29
- const isTTY = process.stdin.isTTY && process.stdout.isTTY;
30
- const LEGACY_REPLAY_DELAY_MS = 1500;
31
- const IMAGE_UPLOAD_TTL_MS = 15 * 60 * 1000;
32
-
33
- // --- Permission approval state ---
34
- let approvalSeq = 0;
35
- const pendingApprovals = new Map(); // id → { res, timer }
36
- const pendingImageUploads = new Map();
37
- let approvalMode = 'default'; // 'default' | 'partial' | 'all'
38
- const ALWAYS_AUTO_ALLOW = new Set(['TaskCreate', 'TaskUpdate']);
39
- const PARTIAL_AUTO_ALLOW = new Set(['Read', 'Glob', 'Grep', 'Write', 'Edit']);
40
-
41
- // --- Logging → file only (never pollute the terminal) ---
42
- const LOG_FILE = path.join(os.homedir(), '.claude', 'bridge.log');
43
- fs.writeFileSync(LOG_FILE, `--- Bridge started ${new Date().toISOString()} ---\n`);
124
+ const isTTY = process.stdin.isTTY && process.stdout.isTTY;
125
+ const LEGACY_REPLAY_DELAY_MS = 1500;
126
+ const IMAGE_UPLOAD_TTL_MS = 15 * 60 * 1000;
127
+
128
+ // --- Permission approval state ---
129
+ let approvalSeq = 0;
130
+ const pendingApprovals = new Map(); // id → { res, timer }
131
+ const pendingImageUploads = new Map();
132
+ let approvalMode = 'default'; // 'default' | 'partial' | 'all'
133
+ const ALWAYS_AUTO_ALLOW = new Set(['TaskCreate', 'TaskUpdate']);
134
+ const PARTIAL_AUTO_ALLOW = new Set(['Read', 'Glob', 'Grep', 'Write', 'Edit']);
135
+
136
+ // --- Logging → file only (never pollute the terminal) ---
137
+ const LOG_FILE = path.join(os.homedir(), '.claude', 'bridge.log');
138
+ fs.writeFileSync(LOG_FILE, `--- Bridge started ${new Date().toISOString()} ---\n`);
44
139
  function log(msg) {
45
140
  const line = `[${new Date().toISOString()}] ${msg}\n`;
46
141
  fs.appendFileSync(LOG_FILE, line);
@@ -114,89 +209,89 @@ function maybeAttachHookSession(data, source) {
114
209
  log(`Hook session attached from ${source}: ${target.sessionId}`);
115
210
  attachTranscript({ full: target.full }, 0);
116
211
  }
117
-
118
- // ============================================================
119
- // 1. Static file server
120
- // ============================================================
121
- const MIME = {
122
- '.html': 'text/html; charset=utf-8',
123
- '.js': 'text/javascript; charset=utf-8',
124
- '.css': 'text/css; charset=utf-8',
125
- '.json': 'application/json',
126
- '.png': 'image/png',
127
- '.svg': 'image/svg+xml',
128
- };
129
-
130
- const server = http.createServer((req, res) => {
131
- const url = req.url.split('?')[0];
132
-
133
- // --- API: Hook approval endpoint ---
134
- if (req.method === 'POST' && url === '/hook/pre-tool-use') {
135
- let body = '';
136
- req.on('data', chunk => (body += chunk));
137
- req.on('end', () => {
138
- let data;
139
- try { data = JSON.parse(body); } catch {
140
- res.writeHead(400, { 'Content-Type': 'application/json' });
141
- res.end(JSON.stringify({ decision: 'ask' }));
142
- return;
143
- }
144
-
212
+
213
+ // ============================================================
214
+ // 1. Static file server
215
+ // ============================================================
216
+ const MIME = {
217
+ '.html': 'text/html; charset=utf-8',
218
+ '.js': 'text/javascript; charset=utf-8',
219
+ '.css': 'text/css; charset=utf-8',
220
+ '.json': 'application/json',
221
+ '.png': 'image/png',
222
+ '.svg': 'image/svg+xml',
223
+ };
224
+
225
+ const server = http.createServer((req, res) => {
226
+ const url = req.url.split('?')[0];
227
+
228
+ // --- API: Hook approval endpoint ---
229
+ if (req.method === 'POST' && url === '/hook/pre-tool-use') {
230
+ let body = '';
231
+ req.on('data', chunk => (body += chunk));
232
+ req.on('end', () => {
233
+ let data;
234
+ try { data = JSON.parse(body); } catch {
235
+ res.writeHead(400, { 'Content-Type': 'application/json' });
236
+ res.end(JSON.stringify({ decision: 'ask' }));
237
+ return;
238
+ }
239
+
145
240
  maybeAttachHookSession(data, 'pre-tool-use');
146
241
 
147
242
  if (ALWAYS_AUTO_ALLOW.has(data.tool_name)) {
148
- res.writeHead(200, { 'Content-Type': 'application/json' });
149
- res.end(JSON.stringify({ decision: 'allow' }));
150
- log(`Permission auto-allowed (always): ${data.tool_name}`);
151
- return;
152
- }
153
-
154
- // Auto-approve based on approvalMode setting
155
- if (approvalMode === 'all') {
156
- res.writeHead(200, { 'Content-Type': 'application/json' });
157
- res.end(JSON.stringify({ decision: 'allow' }));
158
- log(`Permission auto-allowed (mode=all): ${data.tool_name}`);
159
- return;
160
- }
161
- if (approvalMode === 'partial' && PARTIAL_AUTO_ALLOW.has(data.tool_name)) {
162
- res.writeHead(200, { 'Content-Type': 'application/json' });
163
- res.end(JSON.stringify({ decision: 'allow' }));
164
- log(`Permission auto-allowed (mode=partial): ${data.tool_name}`);
165
- return;
166
- }
167
-
168
- // No WebUI clients → fall back to terminal prompt
169
- const clients = [...wss.clients].filter(c => c.readyState === WebSocket.OPEN);
170
- if (clients.length === 0) {
171
- res.writeHead(200, { 'Content-Type': 'application/json' });
172
- res.end(JSON.stringify({ decision: 'ask' }));
173
- return;
174
- }
175
-
176
- const id = String(++approvalSeq);
177
- log(`Permission #${id}: ${data.tool_name} → ${clients.length} WebUI client(s)`);
178
-
179
- broadcast({
180
- type: 'permission_request',
181
- id,
182
- toolName: data.tool_name,
183
- toolInput: data.tool_input,
184
- permissionMode: data.permission_mode,
185
- });
186
-
187
- // Hold HTTP response open until WebUI user decides or timeout
188
- const timer = setTimeout(() => {
189
- pendingApprovals.delete(id);
190
- res.writeHead(200, { 'Content-Type': 'application/json' });
191
- res.end(JSON.stringify({ decision: 'ask' }));
192
- log(`Permission #${id}: timeout → ask`);
193
- }, 90000);
194
-
195
- pendingApprovals.set(id, { res, timer });
196
- });
197
- return;
198
- }
199
-
243
+ res.writeHead(200, { 'Content-Type': 'application/json' });
244
+ res.end(JSON.stringify({ decision: 'allow' }));
245
+ log(`Permission auto-allowed (always): ${data.tool_name}`);
246
+ return;
247
+ }
248
+
249
+ // Auto-approve based on approvalMode setting
250
+ if (approvalMode === 'all') {
251
+ res.writeHead(200, { 'Content-Type': 'application/json' });
252
+ res.end(JSON.stringify({ decision: 'allow' }));
253
+ log(`Permission auto-allowed (mode=all): ${data.tool_name}`);
254
+ return;
255
+ }
256
+ if (approvalMode === 'partial' && PARTIAL_AUTO_ALLOW.has(data.tool_name)) {
257
+ res.writeHead(200, { 'Content-Type': 'application/json' });
258
+ res.end(JSON.stringify({ decision: 'allow' }));
259
+ log(`Permission auto-allowed (mode=partial): ${data.tool_name}`);
260
+ return;
261
+ }
262
+
263
+ // No WebUI clients → fall back to terminal prompt
264
+ const clients = [...wss.clients].filter(c => c.readyState === WebSocket.OPEN);
265
+ if (clients.length === 0) {
266
+ res.writeHead(200, { 'Content-Type': 'application/json' });
267
+ res.end(JSON.stringify({ decision: 'ask' }));
268
+ return;
269
+ }
270
+
271
+ const id = String(++approvalSeq);
272
+ log(`Permission #${id}: ${data.tool_name} → ${clients.length} WebUI client(s)`);
273
+
274
+ broadcast({
275
+ type: 'permission_request',
276
+ id,
277
+ toolName: data.tool_name,
278
+ toolInput: data.tool_input,
279
+ permissionMode: data.permission_mode,
280
+ });
281
+
282
+ // Hold HTTP response open until WebUI user decides or timeout
283
+ const timer = setTimeout(() => {
284
+ pendingApprovals.delete(id);
285
+ res.writeHead(200, { 'Content-Type': 'application/json' });
286
+ res.end(JSON.stringify({ decision: 'ask' }));
287
+ log(`Permission #${id}: timeout → ask`);
288
+ }, 90000);
289
+
290
+ pendingApprovals.set(id, { res, timer });
291
+ });
292
+ return;
293
+ }
294
+
200
295
  // --- API: Session start hook endpoint ---
201
296
  if (req.method === 'POST' && url === '/hook/session-start') {
202
297
  let body = '';
@@ -212,40 +307,40 @@ const server = http.createServer((req, res) => {
212
307
  }
213
308
 
214
309
  // --- API: Stop hook endpoint ---
215
- if (req.method === 'POST' && url === '/hook/stop') {
216
- let body = '';
217
- req.on('data', chunk => (body += chunk));
218
- req.on('end', () => {
219
- log('/hook/stop received — broadcasting turn_complete');
310
+ if (req.method === 'POST' && url === '/hook/stop') {
311
+ let body = '';
312
+ req.on('data', chunk => (body += chunk));
313
+ req.on('end', () => {
314
+ log('/hook/stop received — broadcasting turn_complete');
220
315
  try {
221
316
  maybeAttachHookSession(JSON.parse(body), 'stop');
222
317
  } catch {}
223
318
  broadcast({ type: 'turn_complete' });
224
- res.writeHead(200, { 'Content-Type': 'application/json' });
225
- res.end('{}');
226
- });
227
- return;
228
- }
229
-
230
- // --- Static files ---
231
- const filePath = path.join(__dirname, 'web', url === '/' ? 'index.html' : url);
232
- const ext = path.extname(filePath);
233
- fs.readFile(filePath, (err, data) => {
234
- if (err) {
235
- res.writeHead(404);
236
- res.end('Not found');
237
- return;
238
- }
239
- res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream' });
240
- res.end(data);
241
- });
242
- });
243
-
244
- // ============================================================
245
- // 2. WebSocket server
246
- // ============================================================
247
- const wss = new WebSocketServer({ server });
248
-
319
+ res.writeHead(200, { 'Content-Type': 'application/json' });
320
+ res.end('{}');
321
+ });
322
+ return;
323
+ }
324
+
325
+ // --- Static files ---
326
+ const filePath = path.join(__dirname, 'web', url === '/' ? 'index.html' : url);
327
+ const ext = path.extname(filePath);
328
+ fs.readFile(filePath, (err, data) => {
329
+ if (err) {
330
+ res.writeHead(404);
331
+ res.end('Not found');
332
+ return;
333
+ }
334
+ res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream' });
335
+ res.end(data);
336
+ });
337
+ });
338
+
339
+ // ============================================================
340
+ // 2. WebSocket server
341
+ // ============================================================
342
+ const wss = new WebSocketServer({ server });
343
+
249
344
  function broadcast(msg) {
250
345
  const raw = JSON.stringify(msg);
251
346
  const recipients = [];
@@ -259,11 +354,11 @@ function broadcast(msg) {
259
354
  log(`Broadcast ${msg.type} -> ${recipients.length} client(s)${recipients.length ? ` [${recipients.join(', ')}]` : ''}`);
260
355
  }
261
356
  }
262
-
263
- function latestEventSeq() {
264
- return eventBuffer.length > 0 ? eventBuffer[eventBuffer.length - 1].seq : 0;
265
- }
266
-
357
+
358
+ function latestEventSeq() {
359
+ return eventBuffer.length > 0 ? eventBuffer[eventBuffer.length - 1].seq : 0;
360
+ }
361
+
267
362
  function sendReplay(ws, lastSeq = null) {
268
363
  const normalizedLastSeq = Number.isInteger(lastSeq) && lastSeq >= 0 ? lastSeq : null;
269
364
  const replayFrom = normalizedLastSeq == null ? 0 : normalizedLastSeq;
@@ -272,15 +367,15 @@ function sendReplay(ws, lastSeq = null) {
272
367
  : eventBuffer;
273
368
 
274
369
  log(`Replay start -> ${wsLabel(ws)} from=${replayFrom} count=${records.length} currentSession=${currentSessionId ?? 'null'}`);
275
-
276
- for (const record of records) {
277
- ws.send(JSON.stringify({
278
- type: 'log_event',
279
- seq: record.seq,
280
- event: record.event,
281
- }));
282
- }
283
-
370
+
371
+ for (const record of records) {
372
+ ws.send(JSON.stringify({
373
+ type: 'log_event',
374
+ seq: record.seq,
375
+ event: record.event,
376
+ }));
377
+ }
378
+
284
379
  sendWs(ws, {
285
380
  type: 'replay_done',
286
381
  sessionId: currentSessionId,
@@ -288,50 +383,50 @@ function sendReplay(ws, lastSeq = null) {
288
383
  resumed: normalizedLastSeq != null,
289
384
  }, 'sendReplay');
290
385
  }
291
-
292
- function sendUploadStatus(ws, uploadId, status, extra = {}) {
293
- if (!ws || ws.readyState !== WebSocket.OPEN) return;
294
- ws.send(JSON.stringify({
295
- type: 'image_upload_status',
296
- uploadId,
297
- status,
298
- ...extra,
299
- }));
300
- }
301
-
302
- function cleanupImageUpload(uploadId) {
303
- const upload = pendingImageUploads.get(uploadId);
304
- if (!upload) return;
305
- if (upload.tmpFile) {
306
- try { fs.unlinkSync(upload.tmpFile); } catch {}
307
- }
308
- pendingImageUploads.delete(uploadId);
309
- }
310
-
311
- function cleanupClientUploads(ws) {
312
- for (const [uploadId, upload] of pendingImageUploads) {
313
- if (upload.owner === ws) cleanupImageUpload(uploadId);
314
- }
315
- }
316
-
317
- function createTempImageFile(buffer, mediaType, uploadId) {
318
- const tmpDir = process.env.CLAUDE_CODE_TMPDIR || os.tmpdir();
319
- const type = String(mediaType || 'image/png').toLowerCase();
320
- const ext = type.includes('jpeg') || type.includes('jpg') ? '.jpg' : '.png';
321
- const tmpFile = path.join(tmpDir, `bridge_upload_${uploadId}_${Date.now()}${ext}`);
322
- fs.writeFileSync(tmpFile, buffer);
323
- return tmpFile;
324
- }
325
-
326
- setInterval(() => {
327
- const now = Date.now();
328
- for (const [uploadId, upload] of pendingImageUploads) {
329
- if ((upload.updatedAt || 0) < (now - IMAGE_UPLOAD_TTL_MS)) {
330
- cleanupImageUpload(uploadId);
331
- }
332
- }
333
- }, 60 * 1000).unref();
334
-
386
+
387
+ function sendUploadStatus(ws, uploadId, status, extra = {}) {
388
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
389
+ ws.send(JSON.stringify({
390
+ type: 'image_upload_status',
391
+ uploadId,
392
+ status,
393
+ ...extra,
394
+ }));
395
+ }
396
+
397
+ function cleanupImageUpload(uploadId) {
398
+ const upload = pendingImageUploads.get(uploadId);
399
+ if (!upload) return;
400
+ if (upload.tmpFile) {
401
+ try { fs.unlinkSync(upload.tmpFile); } catch {}
402
+ }
403
+ pendingImageUploads.delete(uploadId);
404
+ }
405
+
406
+ function cleanupClientUploads(ws) {
407
+ for (const [uploadId, upload] of pendingImageUploads) {
408
+ if (upload.owner === ws) cleanupImageUpload(uploadId);
409
+ }
410
+ }
411
+
412
+ function createTempImageFile(buffer, mediaType, uploadId) {
413
+ const tmpDir = process.env.CLAUDE_CODE_TMPDIR || os.tmpdir();
414
+ const type = String(mediaType || 'image/png').toLowerCase();
415
+ const ext = type.includes('jpeg') || type.includes('jpg') ? '.jpg' : '.png';
416
+ const tmpFile = path.join(tmpDir, `bridge_upload_${uploadId}_${Date.now()}${ext}`);
417
+ fs.writeFileSync(tmpFile, buffer);
418
+ return tmpFile;
419
+ }
420
+
421
+ setInterval(() => {
422
+ const now = Date.now();
423
+ for (const [uploadId, upload] of pendingImageUploads) {
424
+ if ((upload.updatedAt || 0) < (now - IMAGE_UPLOAD_TTL_MS)) {
425
+ cleanupImageUpload(uploadId);
426
+ }
427
+ }
428
+ }, 60 * 1000).unref();
429
+
335
430
  wss.on('connection', (ws, req) => {
336
431
  ws._bridgeId = ++nextWsId;
337
432
  ws._clientInstanceId = '';
@@ -354,20 +449,20 @@ wss.on('connection', (ws, req) => {
354
449
  lastSeq: latestEventSeq(),
355
450
  }, 'initial');
356
451
  }
357
-
358
- // New clients should explicitly request a resume window. Keep a delayed
359
- // full replay fallback so older clients still work.
360
- ws._resumeHandled = false;
361
- ws._legacyReplayTimer = setTimeout(() => {
362
- if (ws.readyState !== WebSocket.OPEN || ws._resumeHandled) return;
363
- ws._resumeHandled = true;
364
- sendReplay(ws, null);
365
- }, LEGACY_REPLAY_DELAY_MS);
366
-
367
- ws.on('message', (raw) => {
368
- let msg;
369
- try { msg = JSON.parse(raw); } catch { return; }
370
-
452
+
453
+ // New clients should explicitly request a resume window. Keep a delayed
454
+ // full replay fallback so older clients still work.
455
+ ws._resumeHandled = false;
456
+ ws._legacyReplayTimer = setTimeout(() => {
457
+ if (ws.readyState !== WebSocket.OPEN || ws._resumeHandled) return;
458
+ ws._resumeHandled = true;
459
+ sendReplay(ws, null);
460
+ }, LEGACY_REPLAY_DELAY_MS);
461
+
462
+ ws.on('message', (raw) => {
463
+ let msg;
464
+ try { msg = JSON.parse(raw); } catch { return; }
465
+
371
466
  switch (msg.type) {
372
467
  case 'hello':
373
468
  ws._clientInstanceId = String(msg.clientInstanceId || ws._clientInstanceId || '');
@@ -382,21 +477,21 @@ wss.on('connection', (ws, req) => {
382
477
  if (ws._legacyReplayTimer) {
383
478
  clearTimeout(ws._legacyReplayTimer);
384
479
  ws._legacyReplayTimer = null;
385
- }
386
-
387
- if (!currentSessionId) {
388
- ws.send(JSON.stringify({
389
- type: 'replay_done',
390
- sessionId: null,
391
- lastSeq: 0,
392
- resumed: false,
393
- }));
394
- break;
395
- }
396
-
397
- const clientServerLastSeq = Number.isInteger(msg.serverLastSeq) && msg.serverLastSeq >= 0
398
- ? msg.serverLastSeq
399
- : null;
480
+ }
481
+
482
+ if (!currentSessionId) {
483
+ ws.send(JSON.stringify({
484
+ type: 'replay_done',
485
+ sessionId: null,
486
+ lastSeq: 0,
487
+ resumed: false,
488
+ }));
489
+ break;
490
+ }
491
+
492
+ const clientServerLastSeq = Number.isInteger(msg.serverLastSeq) && msg.serverLastSeq >= 0
493
+ ? msg.serverLastSeq
494
+ : null;
400
495
  const canResume = (
401
496
  msg.sessionId &&
402
497
  msg.sessionId === currentSessionId &&
@@ -411,201 +506,201 @@ wss.on('connection', (ws, req) => {
411
506
  sendReplay(ws, canResume ? msg.lastSeq : null);
412
507
  break;
413
508
  }
414
- case 'input':
415
- // Raw terminal keystrokes from xterm.js in WebUI
416
- if (claudeProc) claudeProc.write(msg.data);
417
- break;
418
- case 'expect_clear':
419
- // Plan mode option 1 triggers /clear inside Claude Code;
420
- // client notifies us so we can detect the session switch.
421
- markExpectingSwitch();
422
- break;
423
- case 'chat':
424
- // Chat message from WebUI → write to PTY as user input
425
- // Must send text first, then Enter after a delay so Claude's
426
- // TUI (Ink) has time to process the typed characters
427
- if (claudeProc) {
428
- const text = msg.text;
429
- log(`Chat input → PTY: "${text.substring(0, 80)}"`);
430
- if (/^\/clear\s*$/i.test(text.trim())) {
431
- markExpectingSwitch();
432
- }
433
- broadcast({ type: 'working_started' });
434
- claudeProc.write(text);
435
- setTimeout(() => {
436
- if (claudeProc) claudeProc.write('\r');
437
- }, 150);
438
- }
439
- break;
440
- case 'resize':
441
- // Only resize if no local TTY is controlling size
442
- if (claudeProc && msg.cols && msg.rows && !isTTY) {
443
- claudeProc.resize(msg.cols, msg.rows);
444
- }
445
- break;
446
- case 'permission_response': {
447
- const approval = pendingApprovals.get(msg.id);
448
- if (approval) {
449
- clearTimeout(approval.timer);
450
- pendingApprovals.delete(msg.id);
451
- approval.res.writeHead(200, { 'Content-Type': 'application/json' });
452
- approval.res.end(JSON.stringify({
453
- decision: msg.decision,
454
- reason: msg.reason || '',
455
- }));
456
- log(`Permission #${msg.id}: ${msg.decision}`);
457
- }
458
- break;
459
- }
460
- case 'set_approval_mode': {
461
- const valid = ['default', 'partial', 'all'];
462
- if (valid.includes(msg.mode)) {
463
- approvalMode = msg.mode;
464
- log(`Approval mode changed to: ${approvalMode}`);
465
- // If switching to 'all' or 'partial', auto-resolve queued permissions
466
- if (approvalMode === 'all') {
467
- for (const [id, approval] of pendingApprovals) {
468
- clearTimeout(approval.timer);
469
- approval.res.writeHead(200, { 'Content-Type': 'application/json' });
470
- approval.res.end(JSON.stringify({ decision: 'allow' }));
471
- log(`Permission #${id}: auto-allowed (mode switched to all)`);
472
- }
473
- pendingApprovals.clear();
474
- broadcast({ type: 'clear_permissions' });
475
- }
476
- }
477
- break;
478
- }
479
- case 'image_upload_init': {
480
- const uploadId = String(msg.uploadId || '');
481
- if (!uploadId) {
482
- sendUploadStatus(ws, '', 'error', { message: 'Missing uploadId' });
483
- break;
484
- }
485
- cleanupImageUpload(uploadId);
486
- pendingImageUploads.set(uploadId, {
487
- id: uploadId,
488
- owner: ws,
489
- mediaType: msg.mediaType || 'image/png',
490
- name: msg.name || 'image',
491
- totalBytes: Number.isFinite(msg.totalBytes) ? msg.totalBytes : 0,
492
- totalChunks: Number.isFinite(msg.totalChunks) ? msg.totalChunks : 0,
493
- nextChunkIndex: 0,
494
- receivedBytes: 0,
495
- chunks: [],
496
- tmpFile: null,
497
- updatedAt: Date.now(),
498
- });
499
- sendUploadStatus(ws, uploadId, 'ready_for_chunks', { receivedBytes: 0, totalBytes: msg.totalBytes || 0 });
500
- break;
501
- }
502
- case 'image_upload_chunk': {
503
- const uploadId = String(msg.uploadId || '');
504
- const upload = pendingImageUploads.get(uploadId);
505
- if (!upload) {
506
- sendUploadStatus(ws, uploadId, 'error', { message: 'Upload session not found' });
507
- break;
508
- }
509
- if (upload.owner !== ws) {
510
- sendUploadStatus(ws, uploadId, 'error', { message: 'Upload owner mismatch' });
511
- break;
512
- }
513
- if (msg.index !== upload.nextChunkIndex) {
514
- sendUploadStatus(ws, uploadId, 'error', {
515
- message: `Unexpected chunk index ${msg.index}, expected ${upload.nextChunkIndex}`,
516
- });
517
- break;
518
- }
519
- if (!msg.base64) {
520
- sendUploadStatus(ws, uploadId, 'error', { message: 'Missing chunk payload' });
521
- break;
522
- }
523
-
524
- try {
525
- const chunk = Buffer.from(msg.base64, 'base64');
526
- upload.chunks.push(chunk);
527
- upload.receivedBytes += chunk.length;
528
- upload.nextChunkIndex += 1;
529
- upload.updatedAt = Date.now();
530
- sendUploadStatus(ws, uploadId, 'uploading', {
531
- chunkIndex: msg.index,
532
- receivedBytes: upload.receivedBytes,
533
- totalBytes: upload.totalBytes,
534
- });
535
- } catch (err) {
536
- sendUploadStatus(ws, uploadId, 'error', { message: err.message });
537
- }
538
- break;
539
- }
540
- case 'image_upload_complete': {
541
- const uploadId = String(msg.uploadId || '');
542
- const upload = pendingImageUploads.get(uploadId);
543
- if (!upload) {
544
- sendUploadStatus(ws, uploadId, 'error', { message: 'Upload session not found' });
545
- break;
546
- }
547
- if (upload.owner !== ws) {
548
- sendUploadStatus(ws, uploadId, 'error', { message: 'Upload owner mismatch' });
549
- break;
550
- }
551
- if (upload.nextChunkIndex !== upload.totalChunks) {
552
- sendUploadStatus(ws, uploadId, 'error', {
553
- message: `Upload incomplete (${upload.nextChunkIndex}/${upload.totalChunks})`,
554
- });
555
- break;
556
- }
557
-
558
- try {
559
- const buffer = Buffer.concat(upload.chunks);
560
- upload.tmpFile = createTempImageFile(buffer, upload.mediaType, uploadId);
561
- upload.chunks = [];
562
- upload.updatedAt = Date.now();
563
- log(`Image pre-upload complete: ${upload.tmpFile} (${buffer.length} bytes)`);
564
- sendUploadStatus(ws, uploadId, 'uploaded', {
565
- receivedBytes: upload.receivedBytes,
566
- totalBytes: upload.totalBytes,
567
- });
568
- } catch (err) {
569
- sendUploadStatus(ws, uploadId, 'error', { message: err.message });
570
- cleanupImageUpload(uploadId);
571
- }
572
- break;
573
- }
574
- case 'image_upload_abort': {
575
- const uploadId = String(msg.uploadId || '');
576
- if (uploadId) cleanupImageUpload(uploadId);
577
- sendUploadStatus(ws, uploadId, 'aborted');
578
- break;
579
- }
580
- case 'image_submit': {
581
- const uploadId = String(msg.uploadId || '');
582
- const upload = pendingImageUploads.get(uploadId);
583
- if (!upload || !upload.tmpFile) {
584
- sendUploadStatus(ws, uploadId, 'error', { message: 'Upload not ready' });
585
- break;
586
- }
587
- try {
588
- handlePreparedImageUpload({
589
- tmpFile: upload.tmpFile,
590
- mediaType: upload.mediaType,
591
- text: msg.text || '',
592
- logLabel: upload.name || uploadId,
593
- onCleanup: () => cleanupImageUpload(uploadId),
594
- });
595
- sendUploadStatus(ws, uploadId, 'submitted');
596
- } catch (err) {
597
- sendUploadStatus(ws, uploadId, 'error', { message: err.message });
598
- cleanupImageUpload(uploadId);
599
- }
600
- break;
601
- }
602
- case 'image_upload': {
603
- handleImageUpload(msg);
604
- break;
605
- }
606
- }
607
- });
608
-
509
+ case 'input':
510
+ // Raw terminal keystrokes from xterm.js in WebUI
511
+ if (claudeProc) claudeProc.write(msg.data);
512
+ break;
513
+ case 'expect_clear':
514
+ // Plan mode option 1 triggers /clear inside Claude Code;
515
+ // client notifies us so we can detect the session switch.
516
+ markExpectingSwitch();
517
+ break;
518
+ case 'chat':
519
+ // Chat message from WebUI → write to PTY as user input
520
+ // Must send text first, then Enter after a delay so Claude's
521
+ // TUI (Ink) has time to process the typed characters
522
+ if (claudeProc) {
523
+ const text = msg.text;
524
+ log(`Chat input → PTY: "${text.substring(0, 80)}"`);
525
+ if (/^\/clear\s*$/i.test(text.trim())) {
526
+ markExpectingSwitch();
527
+ }
528
+ broadcast({ type: 'working_started' });
529
+ claudeProc.write(text);
530
+ setTimeout(() => {
531
+ if (claudeProc) claudeProc.write('\r');
532
+ }, 150);
533
+ }
534
+ break;
535
+ case 'resize':
536
+ // Only resize if no local TTY is controlling size
537
+ if (claudeProc && msg.cols && msg.rows && !isTTY) {
538
+ claudeProc.resize(msg.cols, msg.rows);
539
+ }
540
+ break;
541
+ case 'permission_response': {
542
+ const approval = pendingApprovals.get(msg.id);
543
+ if (approval) {
544
+ clearTimeout(approval.timer);
545
+ pendingApprovals.delete(msg.id);
546
+ approval.res.writeHead(200, { 'Content-Type': 'application/json' });
547
+ approval.res.end(JSON.stringify({
548
+ decision: msg.decision,
549
+ reason: msg.reason || '',
550
+ }));
551
+ log(`Permission #${msg.id}: ${msg.decision}`);
552
+ }
553
+ break;
554
+ }
555
+ case 'set_approval_mode': {
556
+ const valid = ['default', 'partial', 'all'];
557
+ if (valid.includes(msg.mode)) {
558
+ approvalMode = msg.mode;
559
+ log(`Approval mode changed to: ${approvalMode}`);
560
+ // If switching to 'all' or 'partial', auto-resolve queued permissions
561
+ if (approvalMode === 'all') {
562
+ for (const [id, approval] of pendingApprovals) {
563
+ clearTimeout(approval.timer);
564
+ approval.res.writeHead(200, { 'Content-Type': 'application/json' });
565
+ approval.res.end(JSON.stringify({ decision: 'allow' }));
566
+ log(`Permission #${id}: auto-allowed (mode switched to all)`);
567
+ }
568
+ pendingApprovals.clear();
569
+ broadcast({ type: 'clear_permissions' });
570
+ }
571
+ }
572
+ break;
573
+ }
574
+ case 'image_upload_init': {
575
+ const uploadId = String(msg.uploadId || '');
576
+ if (!uploadId) {
577
+ sendUploadStatus(ws, '', 'error', { message: 'Missing uploadId' });
578
+ break;
579
+ }
580
+ cleanupImageUpload(uploadId);
581
+ pendingImageUploads.set(uploadId, {
582
+ id: uploadId,
583
+ owner: ws,
584
+ mediaType: msg.mediaType || 'image/png',
585
+ name: msg.name || 'image',
586
+ totalBytes: Number.isFinite(msg.totalBytes) ? msg.totalBytes : 0,
587
+ totalChunks: Number.isFinite(msg.totalChunks) ? msg.totalChunks : 0,
588
+ nextChunkIndex: 0,
589
+ receivedBytes: 0,
590
+ chunks: [],
591
+ tmpFile: null,
592
+ updatedAt: Date.now(),
593
+ });
594
+ sendUploadStatus(ws, uploadId, 'ready_for_chunks', { receivedBytes: 0, totalBytes: msg.totalBytes || 0 });
595
+ break;
596
+ }
597
+ case 'image_upload_chunk': {
598
+ const uploadId = String(msg.uploadId || '');
599
+ const upload = pendingImageUploads.get(uploadId);
600
+ if (!upload) {
601
+ sendUploadStatus(ws, uploadId, 'error', { message: 'Upload session not found' });
602
+ break;
603
+ }
604
+ if (upload.owner !== ws) {
605
+ sendUploadStatus(ws, uploadId, 'error', { message: 'Upload owner mismatch' });
606
+ break;
607
+ }
608
+ if (msg.index !== upload.nextChunkIndex) {
609
+ sendUploadStatus(ws, uploadId, 'error', {
610
+ message: `Unexpected chunk index ${msg.index}, expected ${upload.nextChunkIndex}`,
611
+ });
612
+ break;
613
+ }
614
+ if (!msg.base64) {
615
+ sendUploadStatus(ws, uploadId, 'error', { message: 'Missing chunk payload' });
616
+ break;
617
+ }
618
+
619
+ try {
620
+ const chunk = Buffer.from(msg.base64, 'base64');
621
+ upload.chunks.push(chunk);
622
+ upload.receivedBytes += chunk.length;
623
+ upload.nextChunkIndex += 1;
624
+ upload.updatedAt = Date.now();
625
+ sendUploadStatus(ws, uploadId, 'uploading', {
626
+ chunkIndex: msg.index,
627
+ receivedBytes: upload.receivedBytes,
628
+ totalBytes: upload.totalBytes,
629
+ });
630
+ } catch (err) {
631
+ sendUploadStatus(ws, uploadId, 'error', { message: err.message });
632
+ }
633
+ break;
634
+ }
635
+ case 'image_upload_complete': {
636
+ const uploadId = String(msg.uploadId || '');
637
+ const upload = pendingImageUploads.get(uploadId);
638
+ if (!upload) {
639
+ sendUploadStatus(ws, uploadId, 'error', { message: 'Upload session not found' });
640
+ break;
641
+ }
642
+ if (upload.owner !== ws) {
643
+ sendUploadStatus(ws, uploadId, 'error', { message: 'Upload owner mismatch' });
644
+ break;
645
+ }
646
+ if (upload.nextChunkIndex !== upload.totalChunks) {
647
+ sendUploadStatus(ws, uploadId, 'error', {
648
+ message: `Upload incomplete (${upload.nextChunkIndex}/${upload.totalChunks})`,
649
+ });
650
+ break;
651
+ }
652
+
653
+ try {
654
+ const buffer = Buffer.concat(upload.chunks);
655
+ upload.tmpFile = createTempImageFile(buffer, upload.mediaType, uploadId);
656
+ upload.chunks = [];
657
+ upload.updatedAt = Date.now();
658
+ log(`Image pre-upload complete: ${upload.tmpFile} (${buffer.length} bytes)`);
659
+ sendUploadStatus(ws, uploadId, 'uploaded', {
660
+ receivedBytes: upload.receivedBytes,
661
+ totalBytes: upload.totalBytes,
662
+ });
663
+ } catch (err) {
664
+ sendUploadStatus(ws, uploadId, 'error', { message: err.message });
665
+ cleanupImageUpload(uploadId);
666
+ }
667
+ break;
668
+ }
669
+ case 'image_upload_abort': {
670
+ const uploadId = String(msg.uploadId || '');
671
+ if (uploadId) cleanupImageUpload(uploadId);
672
+ sendUploadStatus(ws, uploadId, 'aborted');
673
+ break;
674
+ }
675
+ case 'image_submit': {
676
+ const uploadId = String(msg.uploadId || '');
677
+ const upload = pendingImageUploads.get(uploadId);
678
+ if (!upload || !upload.tmpFile) {
679
+ sendUploadStatus(ws, uploadId, 'error', { message: 'Upload not ready' });
680
+ break;
681
+ }
682
+ try {
683
+ handlePreparedImageUpload({
684
+ tmpFile: upload.tmpFile,
685
+ mediaType: upload.mediaType,
686
+ text: msg.text || '',
687
+ logLabel: upload.name || uploadId,
688
+ onCleanup: () => cleanupImageUpload(uploadId),
689
+ });
690
+ sendUploadStatus(ws, uploadId, 'submitted');
691
+ } catch (err) {
692
+ sendUploadStatus(ws, uploadId, 'error', { message: err.message });
693
+ cleanupImageUpload(uploadId);
694
+ }
695
+ break;
696
+ }
697
+ case 'image_upload': {
698
+ handleImageUpload(msg);
699
+ break;
700
+ }
701
+ }
702
+ });
703
+
609
704
  ws.on('close', () => {
610
705
  if (ws._legacyReplayTimer) {
611
706
  clearTimeout(ws._legacyReplayTimer);
@@ -614,361 +709,364 @@ wss.on('connection', (ws, req) => {
614
709
  log(`WS closed: ${wsLabel(ws)}`);
615
710
  cleanupClientUploads(ws);
616
711
  });
617
- });
618
-
619
- // ============================================================
620
- // 4. PTY Manager — local terminal passthrough
621
- // ============================================================
712
+ });
713
+
714
+ // ============================================================
715
+ // 4. PTY Manager — local terminal passthrough
716
+ // ============================================================
622
717
  function spawnClaude() {
623
718
  const isWin = process.platform === 'win32';
624
- const shell = isWin ? 'powershell.exe' : (process.env.SHELL || '/bin/bash');
625
- const args = isWin
626
- ? ['-NoLogo', '-NoProfile', '-Command', 'claude']
627
- : ['-c', 'claude'];
628
-
629
- // Use local terminal size if available, otherwise default
630
- const cols = isTTY ? process.stdout.columns : 120;
631
- const rows = isTTY ? process.stdout.rows : 40;
632
-
633
- claudeProc = pty.spawn(shell, args, {
634
- name: 'xterm-256color',
635
- cols,
636
- rows,
637
- cwd: CWD,
638
- env: { ...process.env, FORCE_COLOR: '1', BRIDGE_PORT: String(PORT) },
639
- });
640
-
641
- log(`Claude spawned (pid ${claudeProc.pid}) — ${cols}x${rows}`);
642
- broadcast({ type: 'status', status: 'running', pid: claudeProc.pid });
643
-
644
- // === PTY output local terminal + WebSocket + mode detection ===
645
- claudeProc.onData((data) => {
646
- if (isTTY) process.stdout.write(data); // show in the terminal you ran the bridge from
647
- broadcast({ type: 'pty_output', data }); // push to WebUI
648
- });
649
-
650
- // === Local terminal input PTY ===
651
- if (isTTY) {
652
- process.stdin.setRawMode(true);
653
- process.stdin.resume();
654
- process.stdin.on('data', (chunk) => {
655
- if (claudeProc) claudeProc.write(chunk);
656
- });
657
-
658
- // Resize PTY when local terminal resizes
659
- process.stdout.on('resize', () => {
660
- if (claudeProc) {
661
- claudeProc.resize(process.stdout.columns, process.stdout.rows);
662
- }
663
- });
664
- }
665
-
666
- // === PTY exit → cleanup ===
667
- claudeProc.onExit(({ exitCode, signal }) => {
668
- log(`Claude exited (code=${exitCode}, signal=${signal})`);
669
- broadcast({ type: 'pty_exit', exitCode, signal });
670
- claudeProc = null;
671
-
672
- // Restore terminal and exit bridge
673
- if (isTTY) {
674
- process.stdin.setRawMode(false);
675
- process.stdin.pause();
676
- }
677
- stopTailing();
678
- log('Bridge shutting down.');
679
- setTimeout(() => process.exit(exitCode || 0), 300);
680
- });
681
- }
682
-
683
- // ============================================================
684
- // 4. Transcript Discovery & Tailing
685
- // ============================================================
686
- function getProjectSlug(cwd) {
687
- return cwd.replace(/[^a-zA-Z0-9]/g, '-');
688
- }
689
-
690
- function hasConversationEvent(evt) {
691
- if (!evt || typeof evt !== 'object') return false;
692
- if (evt.type === 'user' || evt.type === 'assistant') return true;
693
- const role = evt.message && typeof evt.message === 'object' ? evt.message.role : null;
694
- return role === 'user' || role === 'assistant';
695
- }
696
-
697
- function fileLooksLikeTranscript(filePath) {
698
- try {
699
- const stat = fs.statSync(filePath);
700
- if (stat.size <= 0) return false;
701
-
702
- const readSize = Math.min(stat.size, 64 * 1024);
703
- const fd = fs.openSync(filePath, 'r');
704
- const buf = Buffer.alloc(readSize);
705
- fs.readSync(fd, buf, 0, readSize, stat.size - readSize);
706
- fs.closeSync(fd);
707
-
708
- const lines = buf.toString('utf8').split('\n').filter(Boolean);
709
- for (const line of lines) {
710
- try {
711
- const evt = JSON.parse(line);
712
- if (hasConversationEvent(evt)) return true;
713
- } catch {
714
- // ignore malformed lines at file tail
715
- }
716
- }
717
- } catch {}
718
- return false;
719
- }
720
-
721
- function attachTranscript(target, startOffset = 0) {
722
- transcriptPath = target.full;
723
- currentSessionId = path.basename(transcriptPath, '.jsonl');
724
- transcriptOffset = Math.max(0, startOffset);
725
- tailRemainder = Buffer.alloc(0);
726
- eventBuffer = [];
727
- eventSeq = 0;
728
-
729
- log(`Transcript attached: ${currentSessionId} (offset=${transcriptOffset})`);
730
- broadcast({
731
- type: 'transcript_ready',
732
- transcript: transcriptPath,
733
- sessionId: currentSessionId,
734
- lastSeq: 0,
735
- });
719
+ const shell = isWin ? 'powershell.exe' : (process.env.SHELL || '/bin/bash');
720
+ const claudeCmd = CLAUDE_EXTRA_ARGS.length > 0
721
+ ? `claude ${CLAUDE_EXTRA_ARGS.join(' ')}`
722
+ : 'claude';
723
+ const args = isWin
724
+ ? ['-NoLogo', '-NoProfile', '-Command', claudeCmd]
725
+ : ['-c', claudeCmd];
726
+
727
+ // Use local terminal size if available, otherwise default
728
+ const cols = isTTY ? process.stdout.columns : 120;
729
+ const rows = isTTY ? process.stdout.rows : 40;
730
+
731
+ claudeProc = pty.spawn(shell, args, {
732
+ name: 'xterm-256color',
733
+ cols,
734
+ rows,
735
+ cwd: CWD,
736
+ env: { ...process.env, FORCE_COLOR: '1', BRIDGE_PORT: String(PORT) },
737
+ });
738
+
739
+ log(`Claude spawned (pid ${claudeProc.pid}) ${cols}x${rows} cmd="${claudeCmd}"`);
740
+ broadcast({ type: 'status', status: 'running', pid: claudeProc.pid });
741
+
742
+ // === PTY output local terminal + WebSocket + mode detection ===
743
+ claudeProc.onData((data) => {
744
+ if (isTTY) process.stdout.write(data); // show in the terminal you ran the bridge from
745
+ broadcast({ type: 'pty_output', data }); // push to WebUI
746
+ });
747
+
748
+ // === Local terminal input → PTY ===
749
+ if (isTTY) {
750
+ process.stdin.setRawMode(true);
751
+ process.stdin.resume();
752
+ process.stdin.on('data', (chunk) => {
753
+ if (claudeProc) claudeProc.write(chunk);
754
+ });
755
+
756
+ // Resize PTY when local terminal resizes
757
+ process.stdout.on('resize', () => {
758
+ if (claudeProc) {
759
+ claudeProc.resize(process.stdout.columns, process.stdout.rows);
760
+ }
761
+ });
762
+ }
763
+
764
+ // === PTY exit cleanup ===
765
+ claudeProc.onExit(({ exitCode, signal }) => {
766
+ log(`Claude exited (code=${exitCode}, signal=${signal})`);
767
+ broadcast({ type: 'pty_exit', exitCode, signal });
768
+ claudeProc = null;
769
+
770
+ // Restore terminal and exit bridge
771
+ if (isTTY) {
772
+ process.stdin.setRawMode(false);
773
+ process.stdin.pause();
774
+ }
775
+ stopTailing();
776
+ log('Bridge shutting down.');
777
+ setTimeout(() => process.exit(exitCode || 0), 300);
778
+ });
779
+ }
780
+
781
+ // ============================================================
782
+ // 4. Transcript Discovery & Tailing
783
+ // ============================================================
784
+ function getProjectSlug(cwd) {
785
+ return cwd.replace(/[^a-zA-Z0-9]/g, '-');
786
+ }
787
+
788
+ function hasConversationEvent(evt) {
789
+ if (!evt || typeof evt !== 'object') return false;
790
+ if (evt.type === 'user' || evt.type === 'assistant') return true;
791
+ const role = evt.message && typeof evt.message === 'object' ? evt.message.role : null;
792
+ return role === 'user' || role === 'assistant';
793
+ }
794
+
795
+ function fileLooksLikeTranscript(filePath) {
796
+ try {
797
+ const stat = fs.statSync(filePath);
798
+ if (stat.size <= 0) return false;
799
+
800
+ const readSize = Math.min(stat.size, 64 * 1024);
801
+ const fd = fs.openSync(filePath, 'r');
802
+ const buf = Buffer.alloc(readSize);
803
+ fs.readSync(fd, buf, 0, readSize, stat.size - readSize);
804
+ fs.closeSync(fd);
805
+
806
+ const lines = buf.toString('utf8').split('\n').filter(Boolean);
807
+ for (const line of lines) {
808
+ try {
809
+ const evt = JSON.parse(line);
810
+ if (hasConversationEvent(evt)) return true;
811
+ } catch {
812
+ // ignore malformed lines at file tail
813
+ }
814
+ }
815
+ } catch {}
816
+ return false;
817
+ }
818
+
819
+ function attachTranscript(target, startOffset = 0) {
820
+ transcriptPath = target.full;
821
+ currentSessionId = path.basename(transcriptPath, '.jsonl');
822
+ transcriptOffset = Math.max(0, startOffset);
823
+ tailRemainder = Buffer.alloc(0);
824
+ eventBuffer = [];
825
+ eventSeq = 0;
826
+
827
+ log(`Transcript attached: ${currentSessionId} (offset=${transcriptOffset})`);
828
+ broadcast({
829
+ type: 'transcript_ready',
830
+ transcript: transcriptPath,
831
+ sessionId: currentSessionId,
832
+ lastSeq: 0,
833
+ });
736
834
  startTailing();
737
835
  startSwitchWatcher();
738
836
  }
739
-
740
- function markExpectingSwitch() {
741
- expectingSwitch = true;
742
- if (expectingSwitchTimer) clearTimeout(expectingSwitchTimer);
743
- expectingSwitchTimer = setTimeout(() => {
744
- expectingSwitch = false;
745
- expectingSwitchTimer = null;
746
- log('Expecting-switch flag expired (no new transcript found)');
747
- }, 15000);
748
- log('Expecting session switch (/clear detected)');
749
- }
750
-
751
- function startSwitchWatcher() {
752
- if (switchWatcher) { clearInterval(switchWatcher); switchWatcher = null; }
753
- const slug = getProjectSlug(CWD);
754
- const projectDir = path.join(PROJECTS_DIR, slug);
755
-
756
- switchWatcher = setInterval(() => {
757
- if (!transcriptPath || !expectingSwitch || !fs.existsSync(projectDir)) return;
758
- try {
759
- const currentBasename = path.basename(transcriptPath);
760
- const candidates = fs.readdirSync(projectDir)
761
- .filter(f => f.endsWith('.jsonl') && f !== currentBasename)
762
- .map(f => {
763
- const full = path.join(projectDir, f);
764
- const stat = fs.statSync(full);
765
- return { name: f, full, mtime: stat.mtimeMs, size: stat.size };
766
- })
767
- .filter(t => t.mtime > fs.statSync(transcriptPath).mtimeMs)
768
- .sort((a, b) => b.mtime - a.mtime);
769
-
770
- const newer = candidates.find(t => fileLooksLikeTranscript(t.full));
771
- if (newer) {
772
- log(`Session switch detected → ${path.basename(newer.full, '.jsonl')}`);
773
- expectingSwitch = false;
774
- if (expectingSwitchTimer) { clearTimeout(expectingSwitchTimer); expectingSwitchTimer = null; }
775
- if (tailTimer) { clearInterval(tailTimer); tailTimer = null; }
776
- if (switchWatcher) { clearInterval(switchWatcher); switchWatcher = null; }
777
- attachTranscript(newer, 0);
778
- }
779
- } catch {}
780
- }, 500);
781
- }
782
-
783
- function startTailing() {
784
- tailRemainder = Buffer.alloc(0);
785
- tailTimer = setInterval(() => {
786
- if (!transcriptPath) return;
787
- try {
788
- const stat = fs.statSync(transcriptPath);
789
- if (stat.size <= transcriptOffset) return;
790
-
791
- const fd = fs.openSync(transcriptPath, 'r');
792
- const buf = Buffer.alloc(stat.size - transcriptOffset);
793
- fs.readSync(fd, buf, 0, buf.length, transcriptOffset);
794
- fs.closeSync(fd);
795
- transcriptOffset = stat.size;
796
-
797
- const data = tailRemainder.length > 0 ? Buffer.concat([tailRemainder, buf]) : buf;
798
- let start = 0;
799
- for (let i = 0; i < data.length; i++) {
800
- if (data[i] !== 0x0A) continue; // '\n'
801
- const line = data.slice(start, i).toString('utf8').trim();
802
- start = i + 1;
803
- if (!line) continue;
804
- try {
805
- const event = JSON.parse(line);
806
- // Detect /clear from JSONL events (covers terminal direct input)
807
- if (event.type === 'user' || (event.message && event.message.role === 'user')) {
808
- broadcast({ type: 'working_started' });
809
- const content = event.message && event.message.content;
810
- if (typeof content === 'string' && /^\/clear\s*$/i.test(content.trim())) {
811
- markExpectingSwitch();
812
- }
813
- }
814
- const record = { seq: ++eventSeq, event };
815
- eventBuffer.push(record);
816
- if (eventBuffer.length > EVENT_BUFFER_MAX) {
817
- eventBuffer = eventBuffer.slice(-Math.round(EVENT_BUFFER_MAX * 0.8));
818
- }
819
- broadcast({ type: 'log_event', seq: record.seq, event });
820
- } catch {
821
- // skip malformed lines
822
- }
823
- }
824
- tailRemainder = data.slice(start);
825
- } catch {
826
- // file might be temporarily locked
827
- }
828
- }, 300);
829
- }
830
-
837
+
838
+ function markExpectingSwitch() {
839
+ expectingSwitch = true;
840
+ if (expectingSwitchTimer) clearTimeout(expectingSwitchTimer);
841
+ expectingSwitchTimer = setTimeout(() => {
842
+ expectingSwitch = false;
843
+ expectingSwitchTimer = null;
844
+ log('Expecting-switch flag expired (no new transcript found)');
845
+ }, 15000);
846
+ log('Expecting session switch (/clear detected)');
847
+ }
848
+
849
+ function startSwitchWatcher() {
850
+ if (switchWatcher) { clearInterval(switchWatcher); switchWatcher = null; }
851
+ const slug = getProjectSlug(CWD);
852
+ const projectDir = path.join(PROJECTS_DIR, slug);
853
+
854
+ switchWatcher = setInterval(() => {
855
+ if (!transcriptPath || !expectingSwitch || !fs.existsSync(projectDir)) return;
856
+ try {
857
+ const currentBasename = path.basename(transcriptPath);
858
+ const candidates = fs.readdirSync(projectDir)
859
+ .filter(f => f.endsWith('.jsonl') && f !== currentBasename)
860
+ .map(f => {
861
+ const full = path.join(projectDir, f);
862
+ const stat = fs.statSync(full);
863
+ return { name: f, full, mtime: stat.mtimeMs, size: stat.size };
864
+ })
865
+ .filter(t => t.mtime > fs.statSync(transcriptPath).mtimeMs)
866
+ .sort((a, b) => b.mtime - a.mtime);
867
+
868
+ const newer = candidates.find(t => fileLooksLikeTranscript(t.full));
869
+ if (newer) {
870
+ log(`Session switch detected → ${path.basename(newer.full, '.jsonl')}`);
871
+ expectingSwitch = false;
872
+ if (expectingSwitchTimer) { clearTimeout(expectingSwitchTimer); expectingSwitchTimer = null; }
873
+ if (tailTimer) { clearInterval(tailTimer); tailTimer = null; }
874
+ if (switchWatcher) { clearInterval(switchWatcher); switchWatcher = null; }
875
+ attachTranscript(newer, 0);
876
+ }
877
+ } catch {}
878
+ }, 500);
879
+ }
880
+
881
+ function startTailing() {
882
+ tailRemainder = Buffer.alloc(0);
883
+ tailTimer = setInterval(() => {
884
+ if (!transcriptPath) return;
885
+ try {
886
+ const stat = fs.statSync(transcriptPath);
887
+ if (stat.size <= transcriptOffset) return;
888
+
889
+ const fd = fs.openSync(transcriptPath, 'r');
890
+ const buf = Buffer.alloc(stat.size - transcriptOffset);
891
+ fs.readSync(fd, buf, 0, buf.length, transcriptOffset);
892
+ fs.closeSync(fd);
893
+ transcriptOffset = stat.size;
894
+
895
+ const data = tailRemainder.length > 0 ? Buffer.concat([tailRemainder, buf]) : buf;
896
+ let start = 0;
897
+ for (let i = 0; i < data.length; i++) {
898
+ if (data[i] !== 0x0A) continue; // '\n'
899
+ const line = data.slice(start, i).toString('utf8').trim();
900
+ start = i + 1;
901
+ if (!line) continue;
902
+ try {
903
+ const event = JSON.parse(line);
904
+ // Detect /clear from JSONL events (covers terminal direct input)
905
+ if (event.type === 'user' || (event.message && event.message.role === 'user')) {
906
+ broadcast({ type: 'working_started' });
907
+ const content = event.message && event.message.content;
908
+ if (typeof content === 'string' && /^\/clear\s*$/i.test(content.trim())) {
909
+ markExpectingSwitch();
910
+ }
911
+ }
912
+ const record = { seq: ++eventSeq, event };
913
+ eventBuffer.push(record);
914
+ if (eventBuffer.length > EVENT_BUFFER_MAX) {
915
+ eventBuffer = eventBuffer.slice(-Math.round(EVENT_BUFFER_MAX * 0.8));
916
+ }
917
+ broadcast({ type: 'log_event', seq: record.seq, event });
918
+ } catch {
919
+ // skip malformed lines
920
+ }
921
+ }
922
+ tailRemainder = data.slice(start);
923
+ } catch {
924
+ // file might be temporarily locked
925
+ }
926
+ }, 300);
927
+ }
928
+
831
929
  function stopTailing() {
832
930
  if (tailTimer) { clearInterval(tailTimer); tailTimer = null; }
833
931
  if (switchWatcher) { clearInterval(switchWatcher); switchWatcher = null; }
834
932
  if (expectingSwitchTimer) { clearTimeout(expectingSwitchTimer); expectingSwitchTimer = null; }
835
933
  expectingSwitch = false;
836
- tailRemainder = Buffer.alloc(0);
837
- }
838
-
839
- // ============================================================
840
- // 5. Image Upload → Clipboard Injection
841
- // ============================================================
842
- function handlePreparedImageUpload({ tmpFile, mediaType, text, logLabel = '', onCleanup = null }) {
843
- if (!claudeProc) throw new Error('Claude not running');
844
- if (!tmpFile || !fs.existsSync(tmpFile)) throw new Error('Prepared image file missing');
845
-
846
- const isWin = process.platform === 'win32';
847
- const isMac = process.platform === 'darwin';
848
-
849
- try {
850
- const stat = fs.statSync(tmpFile);
851
- log(`Image ready: ${logLabel || path.basename(tmpFile)} (${stat.size} bytes)`);
852
-
853
- if (isWin) {
854
- 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()`;
855
- execSync(`powershell -NoProfile -STA -Command "${psCmd}"`, { timeout: 10000 });
856
- } else if (isMac) {
857
- execSync(`osascript -e 'set the clipboard to (read POSIX file "${tmpFile}" as 芦class PNGf禄)'`, { timeout: 10000 });
858
- } else {
859
- try {
860
- execSync(`xclip -selection clipboard -t image/png -i < "${tmpFile}"`, { timeout: 10000, shell: true });
861
- } catch {
862
- execSync(`wl-copy --type image/png < "${tmpFile}"`, { timeout: 10000, shell: true });
863
- }
864
- }
865
- log('Clipboard set with image');
866
-
867
- if (isWin) claudeProc.write('\x1bv');
868
- else claudeProc.write('\x16');
869
- log('Sent image paste keypress to PTY');
870
-
871
- setTimeout(() => {
872
- if (!claudeProc) return;
873
- const trimmedText = (text || '').trim();
874
- if (trimmedText) claudeProc.write(trimmedText);
875
-
876
- setTimeout(() => {
877
- if (claudeProc) claudeProc.write('\r');
878
- log('Sent Enter after image paste' + (trimmedText ? ` + text: "${trimmedText.substring(0, 60)}"` : ''));
879
-
880
- setTimeout(() => {
881
- if (onCleanup) onCleanup();
882
- else {
883
- try { fs.unlinkSync(tmpFile); } catch {}
884
- }
885
- }, 5000);
886
- }, 150);
887
- }, 1000);
888
- } catch (err) {
889
- log(`Image upload error: ${err.message}`);
890
- if (onCleanup) onCleanup();
891
- else {
892
- try { fs.unlinkSync(tmpFile); } catch {}
893
- }
894
- throw err;
895
- }
896
- }
897
-
898
- function handleImageUpload(msg) {
899
- if (!claudeProc) {
900
- log('Image upload ignored: Claude not running');
901
- return;
902
- }
903
- if (!msg.base64) {
904
- log('Image upload ignored: no base64 data');
905
- return;
906
- }
907
-
908
- const buf = Buffer.from(msg.base64, 'base64');
909
- const tmpFile = createTempImageFile(buf, msg.mediaType, `legacy_${Date.now()}`);
910
-
911
- try {
912
- log(`Image saved: ${tmpFile} (${buf.length} bytes)`);
913
- handlePreparedImageUpload({
914
- tmpFile,
915
- mediaType: msg.mediaType,
916
- text: msg.text || '',
917
- });
918
- } catch (err) {
919
- log(`Image upload error: ${err.message}`);
920
- try { fs.unlinkSync(tmpFile); } catch {}
921
- }
922
- }
923
-
924
- // ============================================================
925
- // 6. Hook Auto-Setup
926
-
927
- // ============================================================
928
- function setupHooks() {
929
- const claudeDir = path.join(CWD, '.claude');
930
- if (!fs.existsSync(claudeDir)) fs.mkdirSync(claudeDir, { recursive: true });
931
-
932
- const settingsPath = path.join(claudeDir, 'settings.local.json');
933
- let settings = {};
934
- try { settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); } catch {}
935
-
936
- const hookScript = path.resolve(__dirname, 'hooks', 'bridge-approval.js').replace(/\\/g, '/');
937
- const hookCmd = `node "${hookScript}"`;
938
-
939
- // Merge bridge hook into PreToolUse (preserve user's other hooks)
940
- const existing = settings.hooks?.PreToolUse || [];
941
- const bridgeIdx = existing.findIndex(e =>
942
- e.hooks?.some(h => h.command?.includes('bridge-approval'))
943
- );
944
- const bridgeEntry = {
945
- matcher: '',
946
- hooks: [{ type: 'command', command: hookCmd, timeout: 120 }],
947
- };
948
-
949
- if (bridgeIdx >= 0) {
950
- existing[bridgeIdx] = bridgeEntry;
951
- } else {
952
- existing.push(bridgeEntry);
953
- }
954
-
955
- settings.hooks = settings.hooks || {};
956
- settings.hooks.PreToolUse = existing;
957
-
958
- // Merge bridge hook into Stop (notify WebUI when Claude's turn ends)
959
- const stopScript = path.resolve(__dirname, 'hooks', 'bridge-stop.js').replace(/\\/g, '/');
960
- const stopCmd = `node "${stopScript}"`;
961
- const existingStop = settings.hooks.Stop || [];
962
- const stopBridgeIdx = existingStop.findIndex(e =>
963
- e.hooks?.some(h => h.command?.includes('bridge-stop'))
964
- );
965
- const stopEntry = {
966
- hooks: [{ type: 'command', command: stopCmd, timeout: 10 }],
967
- };
968
- if (stopBridgeIdx >= 0) {
969
- existingStop[stopBridgeIdx] = stopEntry;
970
- } else {
971
- existingStop.push(stopEntry);
934
+ tailRemainder = Buffer.alloc(0);
935
+ }
936
+
937
+ // ============================================================
938
+ // 5. Image Upload → Clipboard Injection
939
+ // ============================================================
940
+ function handlePreparedImageUpload({ tmpFile, mediaType, text, logLabel = '', onCleanup = null }) {
941
+ if (!claudeProc) throw new Error('Claude not running');
942
+ if (!tmpFile || !fs.existsSync(tmpFile)) throw new Error('Prepared image file missing');
943
+
944
+ const isWin = process.platform === 'win32';
945
+ const isMac = process.platform === 'darwin';
946
+
947
+ try {
948
+ const stat = fs.statSync(tmpFile);
949
+ log(`Image ready: ${logLabel || path.basename(tmpFile)} (${stat.size} bytes)`);
950
+
951
+ if (isWin) {
952
+ 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()`;
953
+ execSync(`powershell -NoProfile -STA -Command "${psCmd}"`, { timeout: 10000 });
954
+ } else if (isMac) {
955
+ execSync(`osascript -e 'set the clipboard to (read POSIX file "${tmpFile}" as 芦class PNGf禄)'`, { timeout: 10000 });
956
+ } else {
957
+ try {
958
+ execSync(`xclip -selection clipboard -t image/png -i < "${tmpFile}"`, { timeout: 10000, shell: true });
959
+ } catch {
960
+ execSync(`wl-copy --type image/png < "${tmpFile}"`, { timeout: 10000, shell: true });
961
+ }
962
+ }
963
+ log('Clipboard set with image');
964
+
965
+ if (isWin) claudeProc.write('\x1bv');
966
+ else claudeProc.write('\x16');
967
+ log('Sent image paste keypress to PTY');
968
+
969
+ setTimeout(() => {
970
+ if (!claudeProc) return;
971
+ const trimmedText = (text || '').trim();
972
+ if (trimmedText) claudeProc.write(trimmedText);
973
+
974
+ setTimeout(() => {
975
+ if (claudeProc) claudeProc.write('\r');
976
+ log('Sent Enter after image paste' + (trimmedText ? ` + text: "${trimmedText.substring(0, 60)}"` : ''));
977
+
978
+ setTimeout(() => {
979
+ if (onCleanup) onCleanup();
980
+ else {
981
+ try { fs.unlinkSync(tmpFile); } catch {}
982
+ }
983
+ }, 5000);
984
+ }, 150);
985
+ }, 1000);
986
+ } catch (err) {
987
+ log(`Image upload error: ${err.message}`);
988
+ if (onCleanup) onCleanup();
989
+ else {
990
+ try { fs.unlinkSync(tmpFile); } catch {}
991
+ }
992
+ throw err;
993
+ }
994
+ }
995
+
996
+ function handleImageUpload(msg) {
997
+ if (!claudeProc) {
998
+ log('Image upload ignored: Claude not running');
999
+ return;
1000
+ }
1001
+ if (!msg.base64) {
1002
+ log('Image upload ignored: no base64 data');
1003
+ return;
1004
+ }
1005
+
1006
+ const buf = Buffer.from(msg.base64, 'base64');
1007
+ const tmpFile = createTempImageFile(buf, msg.mediaType, `legacy_${Date.now()}`);
1008
+
1009
+ try {
1010
+ log(`Image saved: ${tmpFile} (${buf.length} bytes)`);
1011
+ handlePreparedImageUpload({
1012
+ tmpFile,
1013
+ mediaType: msg.mediaType,
1014
+ text: msg.text || '',
1015
+ });
1016
+ } catch (err) {
1017
+ log(`Image upload error: ${err.message}`);
1018
+ try { fs.unlinkSync(tmpFile); } catch {}
1019
+ }
1020
+ }
1021
+
1022
+ // ============================================================
1023
+ // 6. Hook Auto-Setup
1024
+
1025
+ // ============================================================
1026
+ function setupHooks() {
1027
+ const claudeDir = path.join(CWD, '.claude');
1028
+ if (!fs.existsSync(claudeDir)) fs.mkdirSync(claudeDir, { recursive: true });
1029
+
1030
+ const settingsPath = path.join(claudeDir, 'settings.local.json');
1031
+ let settings = {};
1032
+ try { settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); } catch {}
1033
+
1034
+ const hookScript = path.resolve(__dirname, 'hooks', 'bridge-approval.js').replace(/\\/g, '/');
1035
+ const hookCmd = `node "${hookScript}"`;
1036
+
1037
+ // Merge bridge hook into PreToolUse (preserve user's other hooks)
1038
+ const existing = settings.hooks?.PreToolUse || [];
1039
+ const bridgeIdx = existing.findIndex(e =>
1040
+ e.hooks?.some(h => h.command?.includes('bridge-approval'))
1041
+ );
1042
+ const bridgeEntry = {
1043
+ matcher: '',
1044
+ hooks: [{ type: 'command', command: hookCmd, timeout: 120 }],
1045
+ };
1046
+
1047
+ if (bridgeIdx >= 0) {
1048
+ existing[bridgeIdx] = bridgeEntry;
1049
+ } else {
1050
+ existing.push(bridgeEntry);
1051
+ }
1052
+
1053
+ settings.hooks = settings.hooks || {};
1054
+ settings.hooks.PreToolUse = existing;
1055
+
1056
+ // Merge bridge hook into Stop (notify WebUI when Claude's turn ends)
1057
+ const stopScript = path.resolve(__dirname, 'hooks', 'bridge-stop.js').replace(/\\/g, '/');
1058
+ const stopCmd = `node "${stopScript}"`;
1059
+ const existingStop = settings.hooks.Stop || [];
1060
+ const stopBridgeIdx = existingStop.findIndex(e =>
1061
+ e.hooks?.some(h => h.command?.includes('bridge-stop'))
1062
+ );
1063
+ const stopEntry = {
1064
+ hooks: [{ type: 'command', command: stopCmd, timeout: 10 }],
1065
+ };
1066
+ if (stopBridgeIdx >= 0) {
1067
+ existingStop[stopBridgeIdx] = stopEntry;
1068
+ } else {
1069
+ existingStop.push(stopEntry);
972
1070
  }
973
1071
  settings.hooks.Stop = existingStop;
974
1072
 
@@ -991,37 +1089,45 @@ function setupHooks() {
991
1089
  fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
992
1090
  log(`Hooks configured: ${settingsPath}`);
993
1091
  }
994
-
995
- // ============================================================
996
- // 7. Startup
997
- // ============================================================
998
- server.listen(PORT, '0.0.0.0', () => {
999
- const ifaces = os.networkInterfaces();
1000
- let lanIp = 'localhost';
1001
- for (const name of Object.keys(ifaces)) {
1002
- for (const iface of ifaces[name]) {
1003
- if (iface.family === 'IPv4' && !iface.internal) {
1004
- lanIp = iface.address;
1005
- break;
1006
- }
1007
- }
1008
- }
1009
- const local = `http://localhost:${PORT}`;
1010
- const lan = `http://${lanIp}:${PORT}`;
1011
-
1012
- // Print banner to stdout BEFORE PTY takes over
1013
- process.stdout.write(`
1014
- Claude Remote Control Bridge
1015
- ─────────────────────────────
1016
- Local: ${local}
1017
- LAN: ${lan}
1018
- CWD: ${CWD}
1019
- Log: ${LOG_FILE}
1020
-
1021
- Phone: ${lan}
1022
- ─────────────────────────────
1023
-
1024
- `);
1092
+
1093
+ // ============================================================
1094
+ // 7. Startup
1095
+ // ============================================================
1096
+ server.listen(PORT, '0.0.0.0', () => {
1097
+ const ifaces = os.networkInterfaces();
1098
+ let lanIp = 'localhost';
1099
+ for (const name of Object.keys(ifaces)) {
1100
+ for (const iface of ifaces[name]) {
1101
+ if (iface.family === 'IPv4' && !iface.internal) {
1102
+ lanIp = iface.address;
1103
+ break;
1104
+ }
1105
+ }
1106
+ }
1107
+ const local = `http://localhost:${PORT}`;
1108
+ const lan = `http://${lanIp}:${PORT}`;
1109
+
1110
+ // Print banner to stdout BEFORE PTY takes over
1111
+ let banner = `
1112
+ Claude Remote Control Bridge
1113
+ ─────────────────────────────
1114
+ Local: ${local}
1115
+ LAN: ${lan}
1116
+ CWD: ${CWD}
1117
+ Log: ${LOG_FILE}
1118
+ `;
1119
+ if (CLAUDE_EXTRA_ARGS.length > 0) {
1120
+ banner += ` Args: claude ${CLAUDE_EXTRA_ARGS.join(' ')}\n`;
1121
+ }
1122
+ if (_blockedArgs.length > 0) {
1123
+ banner += ` Blocked: ${_blockedArgs.join(', ')} (incompatible with bridge)\n`;
1124
+ }
1125
+ banner += `
1126
+ Phone: ${lan}
1127
+ ─────────────────────────────
1128
+
1129
+ `;
1130
+ process.stdout.write(banner);
1025
1131
  setupHooks();
1026
1132
  spawnClaude();
1027
1133
  });