claude-remote 0.1.2 → 0.1.4

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