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.
- package/hooks/bridge-session-end.js +33 -0
- package/package.json +1 -1
- package/server.js +398 -38
|
@@ -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
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
|
-
|
|
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
|
-
//
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
//
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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({
|
|
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
|
-
|
|
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({
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|