claude-remote 0.2.1 → 0.2.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.
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env node
2
+ // Bridge session-end hook — notifies WebUI that a Claude session has ended.
3
+ // Fire-and-forget: POST stdin JSON to bridge server, don't wait for response.
4
+
5
+ const http = require('http');
6
+
7
+ if (!process.env.BRIDGE_PORT) process.exit(0);
8
+
9
+ const PORT = process.env.BRIDGE_PORT;
10
+
11
+ let input = '';
12
+ process.stdin.setEncoding('utf8');
13
+ process.stdin.on('data', chunk => (input += chunk));
14
+ process.stdin.on('end', () => {
15
+ const body = input || '{}';
16
+ const req = http.request({
17
+ hostname: '127.0.0.1',
18
+ port: PORT,
19
+ path: '/hook/session-end',
20
+ method: 'POST',
21
+ headers: {
22
+ 'Content-Type': 'application/json',
23
+ 'Content-Length': Buffer.byteLength(body),
24
+ },
25
+ }, () => {
26
+ process.exit(0);
27
+ });
28
+
29
+ req.on('error', () => process.exit(0));
30
+ req.setTimeout(10000, () => { req.destroy(); process.exit(0); });
31
+ req.write(body);
32
+ req.end();
33
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-remote",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Remote control bridge for Claude Code REPL - drive from phone/WebUI",
5
5
  "main": "server.js",
6
6
  "bin": {
package/server.js CHANGED
@@ -103,8 +103,9 @@ const { cwd: _parsedCwd, claudeArgs: CLAUDE_EXTRA_ARGS, blocked: _blockedArgs }
103
103
 
104
104
  // --- Config ---
105
105
  const PORT = parseInt(process.env.PORT || '3100', 10);
106
- const CWD = _parsedCwd;
106
+ let CWD = _parsedCwd;
107
107
  const CLAUDE_HOME = path.join(os.homedir(), '.claude');
108
+ const CLAUDE_STATE_FILE = path.join(os.homedir(), '.claude.json');
108
109
  const PROJECTS_DIR = path.join(CLAUDE_HOME, 'projects');
109
110
 
110
111
  // --- State ---
@@ -118,14 +119,19 @@ const EVENT_BUFFER_MAX = 5000;
118
119
  let nextWsId = 0;
119
120
  let tailTimer = null;
120
121
  let switchWatcher = null;
122
+ let switchWatcherDelayTimer = null;
121
123
  let expectingSwitch = false;
122
124
  let expectingSwitchTimer = null;
123
125
  let pendingSwitchTarget = null;
126
+ let pendingInitialClearTranscript = null; // { sessionId }
124
127
  let tailRemainder = Buffer.alloc(0);
125
128
  let tailCatchingUp = false; // true while reading historical transcript content
126
129
  const isTTY = process.stdin.isTTY && process.stdout.isTTY;
127
130
  const LEGACY_REPLAY_DELAY_MS = 1500;
128
131
  const IMAGE_UPLOAD_TTL_MS = 15 * 60 * 1000;
132
+ let ttyInputForwarderAttached = false;
133
+ let ttyInputHandler = null;
134
+ let ttyResizeHandler = null;
129
135
 
130
136
  // --- Permission approval state ---
131
137
  let approvalSeq = 0;
@@ -161,6 +167,23 @@ function sendWs(ws, msg, context = '') {
161
167
  return true;
162
168
  }
163
169
 
170
+ function attachTtyForwarders() {
171
+ if (!isTTY || ttyInputForwarderAttached) return;
172
+
173
+ ttyInputHandler = (chunk) => {
174
+ if (claudeProc) claudeProc.write(chunk);
175
+ };
176
+ ttyResizeHandler = () => {
177
+ if (claudeProc) claudeProc.resize(process.stdout.columns, process.stdout.rows);
178
+ };
179
+
180
+ process.stdin.setRawMode(true);
181
+ process.stdin.resume();
182
+ process.stdin.on('data', ttyInputHandler);
183
+ process.stdout.on('resize', ttyResizeHandler);
184
+ ttyInputForwarderAttached = true;
185
+ }
186
+
164
187
  function normalizeFsPath(value) {
165
188
  const resolved = path.resolve(String(value || ''));
166
189
  return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
@@ -197,6 +220,7 @@ function resolveHookTranscript(data) {
197
220
  function maybeAttachHookSession(data, source) {
198
221
  const target = resolveHookTranscript(data);
199
222
  if (!target) return;
223
+ let hookSource = null;
200
224
 
201
225
  // Already attached to this exact session — no-op
202
226
  if (currentSessionId === target.sessionId && transcriptPath &&
@@ -207,20 +231,29 @@ function maybeAttachHookSession(data, source) {
207
231
  const targetHasContent = fileLooksLikeTranscript(target.full);
208
232
 
209
233
  if (source === 'session-start') {
210
- // session-start is unreliable for --resume (fires twice, one is a
211
- // snapshot-only session). Only accept when:
212
- // 1. No session bound yet (first attach), OR
213
- // 2. Expecting a switch (/clear), OR
214
- // 3. Target has conversation content and current doesn't
215
- if (currentSessionId && !expectingSwitch) {
216
- const currentHasContent = transcriptPath && fileLooksLikeTranscript(transcriptPath);
217
- if (!targetHasContent || currentHasContent) {
218
- if (currentSessionId !== target.sessionId) {
219
- pendingSwitchTarget = { ...target, seenAt: Date.now(), source };
220
- log(`Queued pending session-start: ${target.sessionId} (current=${currentSessionId} currentHasContent=${currentHasContent} targetHasContent=${targetHasContent})`);
234
+ // Check hook stdin's source field for deterministic binding
235
+ hookSource = data.source; // "startup" | "resume" | "clear" | "compact"
236
+
237
+ // /clear or resume: deterministic bind — skip defensive filtering
238
+ if (hookSource === 'clear' || hookSource === 'resume') {
239
+ log(`Deterministic session-start (hookSource=${hookSource}): ${target.sessionId}`);
240
+ // Fall through to attachTranscript below
241
+ } else {
242
+ // session-start is unreliable for --resume (fires twice, one is a
243
+ // snapshot-only session). Only accept when:
244
+ // 1. No session bound yet (first attach), OR
245
+ // 2. Expecting a switch (/clear), OR
246
+ // 3. Target has conversation content and current doesn't
247
+ if (currentSessionId && !expectingSwitch) {
248
+ const currentHasContent = transcriptPath && fileLooksLikeTranscript(transcriptPath);
249
+ if (!targetHasContent || currentHasContent) {
250
+ if (currentSessionId !== target.sessionId) {
251
+ pendingSwitchTarget = { ...target, seenAt: Date.now(), source };
252
+ log(`Queued pending session-start: ${target.sessionId} (current=${currentSessionId} currentHasContent=${currentHasContent} targetHasContent=${targetHasContent})`);
253
+ }
254
+ log(`Ignored session-start: ${target.sessionId} (current=${currentSessionId} currentHasContent=${currentHasContent} targetHasContent=${targetHasContent})`);
255
+ return;
221
256
  }
222
- log(`Ignored session-start: ${target.sessionId} (current=${currentSessionId} currentHasContent=${currentHasContent} targetHasContent=${targetHasContent})`);
223
- return;
224
257
  }
225
258
  }
226
259
  } else if (source === 'pre-tool-use') {
@@ -240,7 +273,10 @@ function maybeAttachHookSession(data, source) {
240
273
  }
241
274
 
242
275
  log(`Hook session attached from ${source}: ${target.sessionId}`);
243
- attachTranscript({ full: target.full }, 0);
276
+ attachTranscript({
277
+ full: target.full,
278
+ ignoreInitialClearCommand: source === 'session-start' && hookSource === 'clear',
279
+ }, 0);
244
280
  }
245
281
 
246
282
  function maybeAttachPendingSwitchTarget(reason, requireReady = true) {
@@ -356,7 +392,9 @@ const server = http.createServer((req, res) => {
356
392
  req.on('data', chunk => (body += chunk));
357
393
  req.on('end', () => {
358
394
  try {
359
- maybeAttachHookSession(JSON.parse(body), 'session-start');
395
+ const data = JSON.parse(body);
396
+ log(`/hook/session-start received (source=${data.source || 'unknown'}, session_id=${data.session_id || 'none'})`);
397
+ maybeAttachHookSession(data, 'session-start');
360
398
  } catch {}
361
399
  res.writeHead(200, { 'Content-Type': 'application/json' });
362
400
  res.end('{}');
@@ -364,6 +402,25 @@ const server = http.createServer((req, res) => {
364
402
  return;
365
403
  }
366
404
 
405
+ // --- API: Session end hook endpoint ---
406
+ if (req.method === 'POST' && url === '/hook/session-end') {
407
+ let body = '';
408
+ req.on('data', chunk => (body += chunk));
409
+ req.on('end', () => {
410
+ let data = {};
411
+ try { data = JSON.parse(body); } catch {}
412
+ const reason = data.reason || 'unknown';
413
+ log(`/hook/session-end received (reason=${reason})`);
414
+ if (reason === 'clear') {
415
+ markExpectingSwitch();
416
+ }
417
+ broadcast({ type: 'session_end', reason });
418
+ res.writeHead(200, { 'Content-Type': 'application/json' });
419
+ res.end('{}');
420
+ });
421
+ return;
422
+ }
423
+
367
424
  // --- API: Stop hook endpoint ---
368
425
  if (req.method === 'POST' && url === '/hook/stop') {
369
426
  let body = '';
@@ -517,7 +574,7 @@ wss.on('connection', (ws, req) => {
517
574
  sendReplay(ws, null);
518
575
  }, LEGACY_REPLAY_DELAY_MS);
519
576
 
520
- ws.on('message', (raw) => {
577
+ ws.on('message', async (raw) => {
521
578
  let msg;
522
579
  try { msg = JSON.parse(raw); } catch { return; }
523
580
 
@@ -762,6 +819,55 @@ wss.on('connection', (ws, req) => {
762
819
  handleImageUpload(msg);
763
820
  break;
764
821
  }
822
+ case 'list_sessions': {
823
+ try {
824
+ const sessions = scanSessions(CWD, 20);
825
+ sendWs(ws, { type: 'sessions', sessions });
826
+ } catch (err) {
827
+ log(`scanSessions error: ${err.message}`);
828
+ sendWs(ws, { type: 'sessions', sessions: [], error: err.message });
829
+ }
830
+ break;
831
+ }
832
+ case 'list_dirs': {
833
+ try {
834
+ const browser = listDirectories(msg.cwd || CWD);
835
+ sendWs(ws, { type: 'dir_list', ...browser });
836
+ } catch (err) {
837
+ log(`listDirectories error: ${err.message}`);
838
+ sendWs(ws, {
839
+ type: 'dir_list',
840
+ cwd: path.resolve(String(msg.cwd || CWD || '')),
841
+ parent: null,
842
+ roots: getDirectoryRoots(),
843
+ entries: [],
844
+ error: err.message,
845
+ });
846
+ }
847
+ break;
848
+ }
849
+ case 'switch_session': {
850
+ if (claudeProc && msg.sessionId) {
851
+ log(`Switch session → /resume ${msg.sessionId}`);
852
+ markExpectingSwitch();
853
+ claudeProc.write(`/resume ${msg.sessionId}`);
854
+ setTimeout(() => {
855
+ if (claudeProc) claudeProc.write('\r');
856
+ }, 150);
857
+ }
858
+ break;
859
+ }
860
+ case 'change_cwd': {
861
+ if (msg.cwd) {
862
+ try {
863
+ const targetCwd = assertDirectoryPath(msg.cwd);
864
+ restartClaude(targetCwd);
865
+ } catch (err) {
866
+ sendWs(ws, { type: 'cwd_change_error', cwd: String(msg.cwd), error: err.message });
867
+ }
868
+ }
869
+ break;
870
+ }
765
871
  }
766
872
  });
767
873
 
@@ -792,7 +898,7 @@ function spawnClaude() {
792
898
  const cols = isTTY ? process.stdout.columns : 120;
793
899
  const rows = isTTY ? process.stdout.rows : 40;
794
900
 
795
- claudeProc = pty.spawn(shell, args, {
901
+ const proc = claudeProc = pty.spawn(shell, args, {
796
902
  name: 'xterm-256color',
797
903
  cols,
798
904
  rows,
@@ -801,32 +907,30 @@ function spawnClaude() {
801
907
  });
802
908
 
803
909
  log(`Claude spawned (pid ${claudeProc.pid}) — ${cols}x${rows} cmd="${claudeCmd}"`);
804
- broadcast({ type: 'status', status: 'running', pid: claudeProc.pid });
910
+ broadcast({
911
+ type: 'status',
912
+ status: 'running',
913
+ pid: proc.pid,
914
+ cwd: CWD,
915
+ sessionId: currentSessionId,
916
+ lastSeq: latestEventSeq(),
917
+ });
805
918
 
806
919
  // === PTY output → local terminal + WebSocket + mode detection ===
807
- claudeProc.onData((data) => {
920
+ proc.onData((data) => {
808
921
  if (isTTY) process.stdout.write(data); // show in the terminal you ran the bridge from
809
922
  broadcast({ type: 'pty_output', data }); // push to WebUI
810
923
  });
811
924
 
812
925
  // === Local terminal input → PTY ===
813
- if (isTTY) {
814
- process.stdin.setRawMode(true);
815
- process.stdin.resume();
816
- process.stdin.on('data', (chunk) => {
817
- if (claudeProc) claudeProc.write(chunk);
818
- });
819
-
820
- // Resize PTY when local terminal resizes
821
- process.stdout.on('resize', () => {
822
- if (claudeProc) {
823
- claudeProc.resize(process.stdout.columns, process.stdout.rows);
824
- }
825
- });
826
- }
926
+ attachTtyForwarders();
827
927
 
828
928
  // === PTY exit → cleanup ===
829
- claudeProc.onExit(({ exitCode, signal }) => {
929
+ proc.onExit(({ exitCode, signal }) => {
930
+ if (claudeProc !== proc) {
931
+ log(`Ignoring stale Claude exit (pid ${proc.pid}, code=${exitCode}, signal=${signal})`);
932
+ return;
933
+ }
830
934
  log(`Claude exited (code=${exitCode}, signal=${signal})`);
831
935
  broadcast({ type: 'pty_exit', exitCode, signal });
832
936
  claudeProc = null;
@@ -902,9 +1006,38 @@ function extractSlashCommand(content) {
902
1006
  return inlineMatch ? inlineMatch[1].trim().toLowerCase() : '';
903
1007
  }
904
1008
 
1009
+ function isNonAiUserEvent(event, content) {
1010
+ if (!event || typeof event !== 'object') return false;
1011
+ if (event.isMeta === true) return true;
1012
+ if (event.isCompactSummary === true) return true;
1013
+ if (event.isVisibleInTranscriptOnly === true) return true;
1014
+
1015
+ const text = flattenUserContent(content).trim();
1016
+ if (!text) return false;
1017
+ return /<local-command-(?:stdout|stderr|caveat)>/i.test(text);
1018
+ }
1019
+
1020
+ function extractSessionPrompt(event) {
1021
+ if (!event || event.type !== 'user') return '';
1022
+
1023
+ const message = event.message;
1024
+ const content = typeof message === 'string'
1025
+ ? message
1026
+ : (message && typeof message === 'object' ? message.content : '');
1027
+ const text = flattenUserContent(content).trim();
1028
+ if (!text) return '';
1029
+ if (isNonAiUserEvent(event, content)) return '';
1030
+ if (extractSlashCommand(content)) return '';
1031
+
1032
+ return text.replace(/\s+/g, ' ').trim().substring(0, 120);
1033
+ }
1034
+
905
1035
  function attachTranscript(target, startOffset = 0) {
906
1036
  transcriptPath = target.full;
907
1037
  currentSessionId = path.basename(transcriptPath, '.jsonl');
1038
+ pendingInitialClearTranscript = target.ignoreInitialClearCommand
1039
+ ? { sessionId: currentSessionId }
1040
+ : null;
908
1041
  if (pendingSwitchTarget && pendingSwitchTarget.sessionId === currentSessionId) {
909
1042
  pendingSwitchTarget = null;
910
1043
  }
@@ -918,6 +1051,7 @@ function attachTranscript(target, startOffset = 0) {
918
1051
  expectingSwitch = false;
919
1052
  if (expectingSwitchTimer) { clearTimeout(expectingSwitchTimer); expectingSwitchTimer = null; }
920
1053
  }
1054
+ if (switchWatcherDelayTimer) { clearTimeout(switchWatcherDelayTimer); switchWatcherDelayTimer = null; }
921
1055
 
922
1056
  // If transcript file already has content, mark as catching up so we don't
923
1057
  // broadcast working_started for historical user messages.
@@ -936,7 +1070,7 @@ function attachTranscript(target, startOffset = 0) {
936
1070
  lastSeq: 0,
937
1071
  });
938
1072
  startTailing();
939
- startSwitchWatcher();
1073
+ // switchWatcher is now only started as a delayed fallback from markExpectingSwitch()
940
1074
  }
941
1075
 
942
1076
  function markExpectingSwitch() {
@@ -949,6 +1083,17 @@ function markExpectingSwitch() {
949
1083
  }, 15000);
950
1084
  log('Expecting session switch (/clear detected)');
951
1085
  if (maybeAttachPendingSwitchTarget('markExpectingSwitch')) return;
1086
+
1087
+ // Delay switchWatcher as fallback — give hooks 5s to bind deterministically
1088
+ if (switchWatcher) { clearInterval(switchWatcher); switchWatcher = null; }
1089
+ if (switchWatcherDelayTimer) { clearTimeout(switchWatcherDelayTimer); switchWatcherDelayTimer = null; }
1090
+ switchWatcherDelayTimer = setTimeout(() => {
1091
+ switchWatcherDelayTimer = null;
1092
+ if (expectingSwitch && !switchWatcher) {
1093
+ log('Hook did not bind within 5s, starting switchWatcher fallback');
1094
+ startSwitchWatcher();
1095
+ }
1096
+ }, 5000);
952
1097
  }
953
1098
 
954
1099
  function startSwitchWatcher() {
@@ -1018,15 +1163,38 @@ function startTailing() {
1018
1163
  if (event.type === 'user' || (event.message && event.message.role === 'user')) {
1019
1164
  const content = event.message && event.message.content;
1020
1165
  const slashCommand = extractSlashCommand(content);
1166
+ const isPassiveUserEvent = isNonAiUserEvent(event, content);
1167
+ const ignoreInitialClear = (
1168
+ slashCommand === '/clear' &&
1169
+ pendingInitialClearTranscript &&
1170
+ pendingInitialClearTranscript.sessionId === currentSessionId
1171
+ );
1021
1172
  // Only broadcast working_started for live (new) user messages,
1022
1173
  // not for historical events during catch-up, and not for slash
1023
1174
  // commands (which are CLI commands, not AI turns).
1024
- if (!tailCatchingUp && !slashCommand) {
1175
+ if (!tailCatchingUp && !slashCommand && !isPassiveUserEvent) {
1025
1176
  broadcast({ type: 'working_started' });
1026
1177
  }
1027
1178
  if (slashCommand === '/clear') {
1028
- markExpectingSwitch();
1179
+ if (ignoreInitialClear) {
1180
+ pendingInitialClearTranscript = null;
1181
+ log(`Ignored bootstrap /clear transcript event for session ${currentSessionId}`);
1182
+ } else {
1183
+ markExpectingSwitch();
1184
+ }
1185
+ } else if (
1186
+ pendingInitialClearTranscript &&
1187
+ pendingInitialClearTranscript.sessionId === currentSessionId &&
1188
+ !isPassiveUserEvent &&
1189
+ !event.isMeta &&
1190
+ !event.isCompactSummary &&
1191
+ !event.isVisibleInTranscriptOnly
1192
+ ) {
1193
+ pendingInitialClearTranscript = null;
1029
1194
  }
1195
+ } else if (pendingInitialClearTranscript && pendingInitialClearTranscript.sessionId === currentSessionId &&
1196
+ event.type === 'assistant') {
1197
+ pendingInitialClearTranscript = null;
1030
1198
  }
1031
1199
  // Enrich Edit tool_use blocks with source file start line
1032
1200
  enrichEditStartLines(event);
@@ -1069,15 +1237,190 @@ function enrichEditStartLines(event) {
1069
1237
  }
1070
1238
  }
1071
1239
 
1240
+ // ============================================================
1241
+ // Session Scanner — list historical sessions from JSONL files
1242
+ // ============================================================
1243
+ function scanSessions(cwd, limit = 20) {
1244
+ const dir = path.join(PROJECTS_DIR, getProjectSlug(cwd));
1245
+ let files;
1246
+ try {
1247
+ files = fs.readdirSync(dir).filter(f => f.endsWith('.jsonl'));
1248
+ } catch {
1249
+ return [];
1250
+ }
1251
+
1252
+ // Stat each file, sort by mtime desc
1253
+ const entries = [];
1254
+ for (const f of files) {
1255
+ const full = path.join(dir, f);
1256
+ try {
1257
+ const stat = fs.statSync(full);
1258
+ entries.push({ file: f, full, mtime: stat.mtimeMs, size: stat.size });
1259
+ } catch { /* skip */ }
1260
+ }
1261
+ entries.sort((a, b) => b.mtime - a.mtime);
1262
+ const top = entries.slice(0, limit);
1263
+
1264
+ const sessions = [];
1265
+ for (const entry of top) {
1266
+ const sessionId = path.basename(entry.file, '.jsonl');
1267
+ const info = {
1268
+ sessionId,
1269
+ summary: '',
1270
+ firstPrompt: '',
1271
+ lastModified: Math.round(entry.mtime),
1272
+ fileSize: entry.size,
1273
+ cwd: cwd,
1274
+ };
1275
+
1276
+ // Read first ~64KB to extract the first real user prompt and model.
1277
+ try {
1278
+ const fd = fs.openSync(entry.full, 'r');
1279
+ const buf = Buffer.alloc(Math.min(entry.size, 64 * 1024));
1280
+ fs.readSync(fd, buf, 0, buf.length, 0);
1281
+ fs.closeSync(fd);
1282
+ const lines = buf.toString('utf8').split('\n').filter(Boolean);
1283
+ for (const line of lines) {
1284
+ try {
1285
+ const evt = JSON.parse(line);
1286
+ if (!info.firstPrompt) {
1287
+ info.firstPrompt = extractSessionPrompt(evt);
1288
+ }
1289
+ if (!info.model && evt.model) {
1290
+ info.model = evt.model;
1291
+ }
1292
+ } catch { /* skip malformed line */ }
1293
+ }
1294
+ } catch { /* skip unreadable file */ }
1295
+
1296
+ info.summary = info.firstPrompt || 'Untitled';
1297
+ sessions.push(info);
1298
+ }
1299
+ return sessions;
1300
+ }
1301
+
1302
+ function getDirectoryRoots() {
1303
+ if (process.platform === 'win32') {
1304
+ const roots = [];
1305
+ for (let code = 65; code <= 90; code++) {
1306
+ const drive = String.fromCharCode(code) + ':\\';
1307
+ try {
1308
+ if (fs.existsSync(drive)) roots.push(drive);
1309
+ } catch {}
1310
+ }
1311
+ return roots;
1312
+ }
1313
+ return ['/'];
1314
+ }
1315
+
1316
+ function assertDirectoryPath(target) {
1317
+ const resolved = path.resolve(String(target || ''));
1318
+ let stat;
1319
+ try {
1320
+ stat = fs.statSync(resolved);
1321
+ } catch {
1322
+ throw new Error('Directory not found');
1323
+ }
1324
+ if (!stat.isDirectory()) throw new Error('Path is not a directory');
1325
+ return resolved;
1326
+ }
1327
+
1328
+ function listDirectories(target) {
1329
+ const cwd = assertDirectoryPath(target);
1330
+ const roots = getDirectoryRoots();
1331
+ const parentDir = path.dirname(cwd);
1332
+ const parent = normalizeFsPath(parentDir) === normalizeFsPath(cwd) ? null : parentDir;
1333
+
1334
+ const entries = fs.readdirSync(cwd, { withFileTypes: true })
1335
+ .filter(entry => entry.isDirectory())
1336
+ .map(entry => ({
1337
+ name: entry.name,
1338
+ path: path.join(cwd, entry.name),
1339
+ }))
1340
+ .sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }));
1341
+
1342
+ return { cwd, parent, roots, entries };
1343
+ }
1344
+
1072
1345
  function stopTailing() {
1073
1346
  if (tailTimer) { clearInterval(tailTimer); tailTimer = null; }
1074
1347
  if (switchWatcher) { clearInterval(switchWatcher); switchWatcher = null; }
1348
+ if (switchWatcherDelayTimer) { clearTimeout(switchWatcherDelayTimer); switchWatcherDelayTimer = null; }
1075
1349
  if (expectingSwitchTimer) { clearTimeout(expectingSwitchTimer); expectingSwitchTimer = null; }
1076
1350
  expectingSwitch = false;
1077
1351
  pendingSwitchTarget = null;
1352
+ pendingInitialClearTranscript = null;
1078
1353
  tailRemainder = Buffer.alloc(0);
1079
1354
  }
1080
1355
 
1356
+ function trustProjectCwd(cwd) {
1357
+ const resolved = path.resolve(String(cwd || ''));
1358
+ if (!resolved) return;
1359
+
1360
+ let state = {};
1361
+ try {
1362
+ state = JSON.parse(fs.readFileSync(CLAUDE_STATE_FILE, 'utf8'));
1363
+ } catch {}
1364
+
1365
+ state.projects = state.projects && typeof state.projects === 'object' ? state.projects : {};
1366
+ const keyVariants = process.platform === 'win32'
1367
+ ? [resolved, resolved.replace(/\\/g, '/')]
1368
+ : [resolved];
1369
+
1370
+ for (const projectKey of keyVariants) {
1371
+ const existing = state.projects[projectKey];
1372
+ state.projects[projectKey] = {
1373
+ ...(existing && typeof existing === 'object' ? existing : {}),
1374
+ hasTrustDialogAccepted: true,
1375
+ projectOnboardingSeenCount: Number.isInteger(existing?.projectOnboardingSeenCount)
1376
+ ? existing.projectOnboardingSeenCount
1377
+ : 0,
1378
+ };
1379
+ }
1380
+
1381
+ fs.writeFileSync(CLAUDE_STATE_FILE, JSON.stringify(state, null, 2));
1382
+ log(`Trusted Claude project cwd: ${resolved}`);
1383
+ }
1384
+
1385
+ function restartClaude(newCwd) {
1386
+ log(`Restarting Claude with new CWD: ${newCwd}`);
1387
+ CWD = newCwd;
1388
+ try {
1389
+ trustProjectCwd(CWD);
1390
+ } catch (err) {
1391
+ log(`Failed to trust Claude project cwd "${CWD}": ${err.message}`);
1392
+ }
1393
+
1394
+ // Stop transcript tailing (also clears switch/expect state)
1395
+ stopTailing();
1396
+
1397
+ // Reset session state
1398
+ currentSessionId = null;
1399
+ transcriptPath = null;
1400
+ transcriptOffset = 0;
1401
+ eventBuffer = [];
1402
+ eventSeq = 0;
1403
+ tailCatchingUp = false;
1404
+
1405
+ // Mark the current PTY as stale before killing it so its exit handler
1406
+ // does not shut down the whole bridge during a restart.
1407
+ const procToRestart = claudeProc;
1408
+ claudeProc = null;
1409
+ if (procToRestart) {
1410
+ procToRestart.kill();
1411
+ }
1412
+
1413
+ // Re-setup hooks (CWD changed → project slug may change)
1414
+ setupHooks();
1415
+
1416
+ // Broadcast cwd change immediately so clients drop the previous session
1417
+ // before the replacement Claude process attaches a new transcript.
1418
+ broadcast({ type: 'cwd_changed', cwd: CWD, sessionId: null, lastSeq: 0 });
1419
+
1420
+ // Respawn Claude in new directory
1421
+ spawnClaude();
1422
+ }
1423
+
1081
1424
  // ============================================================
1082
1425
  // 5. Image Upload → Clipboard Injection
1083
1426
  // ============================================================
@@ -1230,6 +1573,23 @@ function setupHooks() {
1230
1573
  }
1231
1574
  settings.hooks.SessionStart = existingSessionStart;
1232
1575
 
1576
+ // Merge bridge hook into SessionEnd (notify bridge when session ends, e.g. /clear)
1577
+ const sessionEndScript = path.resolve(__dirname, 'hooks', 'bridge-session-end.js').replace(/\\/g, '/');
1578
+ const sessionEndCmd = `node "${sessionEndScript}"`;
1579
+ const existingSessionEnd = settings.hooks.SessionEnd || [];
1580
+ const sessionEndBridgeIdx = existingSessionEnd.findIndex(e =>
1581
+ e.hooks?.some(h => h.command?.includes('bridge-session-end'))
1582
+ );
1583
+ const sessionEndEntry = {
1584
+ hooks: [{ type: 'command', command: sessionEndCmd, timeout: 10 }],
1585
+ };
1586
+ if (sessionEndBridgeIdx >= 0) {
1587
+ existingSessionEnd[sessionEndBridgeIdx] = sessionEndEntry;
1588
+ } else {
1589
+ existingSessionEnd.push(sessionEndEntry);
1590
+ }
1591
+ settings.hooks.SessionEnd = existingSessionEnd;
1592
+
1233
1593
  fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
1234
1594
  log(`Hooks configured: ${settingsPath}`);
1235
1595
  }