claude-remote 0.2.1 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/hooks/bridge-session-end.js +33 -0
- package/package.json +1 -1
- package/server.js +122 -18
|
@@ -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
|
@@ -118,9 +118,11 @@ const EVENT_BUFFER_MAX = 5000;
|
|
|
118
118
|
let nextWsId = 0;
|
|
119
119
|
let tailTimer = null;
|
|
120
120
|
let switchWatcher = null;
|
|
121
|
+
let switchWatcherDelayTimer = null;
|
|
121
122
|
let expectingSwitch = false;
|
|
122
123
|
let expectingSwitchTimer = null;
|
|
123
124
|
let pendingSwitchTarget = null;
|
|
125
|
+
let pendingInitialClearTranscript = null; // { sessionId }
|
|
124
126
|
let tailRemainder = Buffer.alloc(0);
|
|
125
127
|
let tailCatchingUp = false; // true while reading historical transcript content
|
|
126
128
|
const isTTY = process.stdin.isTTY && process.stdout.isTTY;
|
|
@@ -197,6 +199,7 @@ function resolveHookTranscript(data) {
|
|
|
197
199
|
function maybeAttachHookSession(data, source) {
|
|
198
200
|
const target = resolveHookTranscript(data);
|
|
199
201
|
if (!target) return;
|
|
202
|
+
let hookSource = null;
|
|
200
203
|
|
|
201
204
|
// Already attached to this exact session — no-op
|
|
202
205
|
if (currentSessionId === target.sessionId && transcriptPath &&
|
|
@@ -207,20 +210,29 @@ function maybeAttachHookSession(data, source) {
|
|
|
207
210
|
const targetHasContent = fileLooksLikeTranscript(target.full);
|
|
208
211
|
|
|
209
212
|
if (source === 'session-start') {
|
|
210
|
-
//
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
//
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
213
|
+
// Check hook stdin's source field for deterministic binding
|
|
214
|
+
hookSource = data.source; // "startup" | "resume" | "clear" | "compact"
|
|
215
|
+
|
|
216
|
+
// /clear or resume: deterministic bind — skip defensive filtering
|
|
217
|
+
if (hookSource === 'clear' || hookSource === 'resume') {
|
|
218
|
+
log(`Deterministic session-start (hookSource=${hookSource}): ${target.sessionId}`);
|
|
219
|
+
// Fall through to attachTranscript below
|
|
220
|
+
} else {
|
|
221
|
+
// session-start is unreliable for --resume (fires twice, one is a
|
|
222
|
+
// snapshot-only session). Only accept when:
|
|
223
|
+
// 1. No session bound yet (first attach), OR
|
|
224
|
+
// 2. Expecting a switch (/clear), OR
|
|
225
|
+
// 3. Target has conversation content and current doesn't
|
|
226
|
+
if (currentSessionId && !expectingSwitch) {
|
|
227
|
+
const currentHasContent = transcriptPath && fileLooksLikeTranscript(transcriptPath);
|
|
228
|
+
if (!targetHasContent || currentHasContent) {
|
|
229
|
+
if (currentSessionId !== target.sessionId) {
|
|
230
|
+
pendingSwitchTarget = { ...target, seenAt: Date.now(), source };
|
|
231
|
+
log(`Queued pending session-start: ${target.sessionId} (current=${currentSessionId} currentHasContent=${currentHasContent} targetHasContent=${targetHasContent})`);
|
|
232
|
+
}
|
|
233
|
+
log(`Ignored session-start: ${target.sessionId} (current=${currentSessionId} currentHasContent=${currentHasContent} targetHasContent=${targetHasContent})`);
|
|
234
|
+
return;
|
|
221
235
|
}
|
|
222
|
-
log(`Ignored session-start: ${target.sessionId} (current=${currentSessionId} currentHasContent=${currentHasContent} targetHasContent=${targetHasContent})`);
|
|
223
|
-
return;
|
|
224
236
|
}
|
|
225
237
|
}
|
|
226
238
|
} else if (source === 'pre-tool-use') {
|
|
@@ -240,7 +252,10 @@ function maybeAttachHookSession(data, source) {
|
|
|
240
252
|
}
|
|
241
253
|
|
|
242
254
|
log(`Hook session attached from ${source}: ${target.sessionId}`);
|
|
243
|
-
attachTranscript({
|
|
255
|
+
attachTranscript({
|
|
256
|
+
full: target.full,
|
|
257
|
+
ignoreInitialClearCommand: source === 'session-start' && hookSource === 'clear',
|
|
258
|
+
}, 0);
|
|
244
259
|
}
|
|
245
260
|
|
|
246
261
|
function maybeAttachPendingSwitchTarget(reason, requireReady = true) {
|
|
@@ -356,7 +371,9 @@ const server = http.createServer((req, res) => {
|
|
|
356
371
|
req.on('data', chunk => (body += chunk));
|
|
357
372
|
req.on('end', () => {
|
|
358
373
|
try {
|
|
359
|
-
|
|
374
|
+
const data = JSON.parse(body);
|
|
375
|
+
log(`/hook/session-start received (source=${data.source || 'unknown'}, session_id=${data.session_id || 'none'})`);
|
|
376
|
+
maybeAttachHookSession(data, 'session-start');
|
|
360
377
|
} catch {}
|
|
361
378
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
362
379
|
res.end('{}');
|
|
@@ -364,6 +381,25 @@ const server = http.createServer((req, res) => {
|
|
|
364
381
|
return;
|
|
365
382
|
}
|
|
366
383
|
|
|
384
|
+
// --- API: Session end hook endpoint ---
|
|
385
|
+
if (req.method === 'POST' && url === '/hook/session-end') {
|
|
386
|
+
let body = '';
|
|
387
|
+
req.on('data', chunk => (body += chunk));
|
|
388
|
+
req.on('end', () => {
|
|
389
|
+
let data = {};
|
|
390
|
+
try { data = JSON.parse(body); } catch {}
|
|
391
|
+
const reason = data.reason || 'unknown';
|
|
392
|
+
log(`/hook/session-end received (reason=${reason})`);
|
|
393
|
+
if (reason === 'clear') {
|
|
394
|
+
markExpectingSwitch();
|
|
395
|
+
}
|
|
396
|
+
broadcast({ type: 'session_end', reason });
|
|
397
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
398
|
+
res.end('{}');
|
|
399
|
+
});
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
|
|
367
403
|
// --- API: Stop hook endpoint ---
|
|
368
404
|
if (req.method === 'POST' && url === '/hook/stop') {
|
|
369
405
|
let body = '';
|
|
@@ -902,9 +938,23 @@ function extractSlashCommand(content) {
|
|
|
902
938
|
return inlineMatch ? inlineMatch[1].trim().toLowerCase() : '';
|
|
903
939
|
}
|
|
904
940
|
|
|
941
|
+
function isNonAiUserEvent(event, content) {
|
|
942
|
+
if (!event || typeof event !== 'object') return false;
|
|
943
|
+
if (event.isMeta === true) return true;
|
|
944
|
+
if (event.isCompactSummary === true) return true;
|
|
945
|
+
if (event.isVisibleInTranscriptOnly === true) return true;
|
|
946
|
+
|
|
947
|
+
const text = flattenUserContent(content).trim();
|
|
948
|
+
if (!text) return false;
|
|
949
|
+
return /<local-command-(?:stdout|stderr|caveat)>/i.test(text);
|
|
950
|
+
}
|
|
951
|
+
|
|
905
952
|
function attachTranscript(target, startOffset = 0) {
|
|
906
953
|
transcriptPath = target.full;
|
|
907
954
|
currentSessionId = path.basename(transcriptPath, '.jsonl');
|
|
955
|
+
pendingInitialClearTranscript = target.ignoreInitialClearCommand
|
|
956
|
+
? { sessionId: currentSessionId }
|
|
957
|
+
: null;
|
|
908
958
|
if (pendingSwitchTarget && pendingSwitchTarget.sessionId === currentSessionId) {
|
|
909
959
|
pendingSwitchTarget = null;
|
|
910
960
|
}
|
|
@@ -918,6 +968,7 @@ function attachTranscript(target, startOffset = 0) {
|
|
|
918
968
|
expectingSwitch = false;
|
|
919
969
|
if (expectingSwitchTimer) { clearTimeout(expectingSwitchTimer); expectingSwitchTimer = null; }
|
|
920
970
|
}
|
|
971
|
+
if (switchWatcherDelayTimer) { clearTimeout(switchWatcherDelayTimer); switchWatcherDelayTimer = null; }
|
|
921
972
|
|
|
922
973
|
// If transcript file already has content, mark as catching up so we don't
|
|
923
974
|
// broadcast working_started for historical user messages.
|
|
@@ -936,7 +987,7 @@ function attachTranscript(target, startOffset = 0) {
|
|
|
936
987
|
lastSeq: 0,
|
|
937
988
|
});
|
|
938
989
|
startTailing();
|
|
939
|
-
|
|
990
|
+
// switchWatcher is now only started as a delayed fallback from markExpectingSwitch()
|
|
940
991
|
}
|
|
941
992
|
|
|
942
993
|
function markExpectingSwitch() {
|
|
@@ -949,6 +1000,17 @@ function markExpectingSwitch() {
|
|
|
949
1000
|
}, 15000);
|
|
950
1001
|
log('Expecting session switch (/clear detected)');
|
|
951
1002
|
if (maybeAttachPendingSwitchTarget('markExpectingSwitch')) return;
|
|
1003
|
+
|
|
1004
|
+
// Delay switchWatcher as fallback — give hooks 5s to bind deterministically
|
|
1005
|
+
if (switchWatcher) { clearInterval(switchWatcher); switchWatcher = null; }
|
|
1006
|
+
if (switchWatcherDelayTimer) { clearTimeout(switchWatcherDelayTimer); switchWatcherDelayTimer = null; }
|
|
1007
|
+
switchWatcherDelayTimer = setTimeout(() => {
|
|
1008
|
+
switchWatcherDelayTimer = null;
|
|
1009
|
+
if (expectingSwitch && !switchWatcher) {
|
|
1010
|
+
log('Hook did not bind within 5s, starting switchWatcher fallback');
|
|
1011
|
+
startSwitchWatcher();
|
|
1012
|
+
}
|
|
1013
|
+
}, 5000);
|
|
952
1014
|
}
|
|
953
1015
|
|
|
954
1016
|
function startSwitchWatcher() {
|
|
@@ -1018,15 +1080,38 @@ function startTailing() {
|
|
|
1018
1080
|
if (event.type === 'user' || (event.message && event.message.role === 'user')) {
|
|
1019
1081
|
const content = event.message && event.message.content;
|
|
1020
1082
|
const slashCommand = extractSlashCommand(content);
|
|
1083
|
+
const isPassiveUserEvent = isNonAiUserEvent(event, content);
|
|
1084
|
+
const ignoreInitialClear = (
|
|
1085
|
+
slashCommand === '/clear' &&
|
|
1086
|
+
pendingInitialClearTranscript &&
|
|
1087
|
+
pendingInitialClearTranscript.sessionId === currentSessionId
|
|
1088
|
+
);
|
|
1021
1089
|
// Only broadcast working_started for live (new) user messages,
|
|
1022
1090
|
// not for historical events during catch-up, and not for slash
|
|
1023
1091
|
// commands (which are CLI commands, not AI turns).
|
|
1024
|
-
if (!tailCatchingUp && !slashCommand) {
|
|
1092
|
+
if (!tailCatchingUp && !slashCommand && !isPassiveUserEvent) {
|
|
1025
1093
|
broadcast({ type: 'working_started' });
|
|
1026
1094
|
}
|
|
1027
1095
|
if (slashCommand === '/clear') {
|
|
1028
|
-
|
|
1096
|
+
if (ignoreInitialClear) {
|
|
1097
|
+
pendingInitialClearTranscript = null;
|
|
1098
|
+
log(`Ignored bootstrap /clear transcript event for session ${currentSessionId}`);
|
|
1099
|
+
} else {
|
|
1100
|
+
markExpectingSwitch();
|
|
1101
|
+
}
|
|
1102
|
+
} else if (
|
|
1103
|
+
pendingInitialClearTranscript &&
|
|
1104
|
+
pendingInitialClearTranscript.sessionId === currentSessionId &&
|
|
1105
|
+
!isPassiveUserEvent &&
|
|
1106
|
+
!event.isMeta &&
|
|
1107
|
+
!event.isCompactSummary &&
|
|
1108
|
+
!event.isVisibleInTranscriptOnly
|
|
1109
|
+
) {
|
|
1110
|
+
pendingInitialClearTranscript = null;
|
|
1029
1111
|
}
|
|
1112
|
+
} else if (pendingInitialClearTranscript && pendingInitialClearTranscript.sessionId === currentSessionId &&
|
|
1113
|
+
event.type === 'assistant') {
|
|
1114
|
+
pendingInitialClearTranscript = null;
|
|
1030
1115
|
}
|
|
1031
1116
|
// Enrich Edit tool_use blocks with source file start line
|
|
1032
1117
|
enrichEditStartLines(event);
|
|
@@ -1072,9 +1157,11 @@ function enrichEditStartLines(event) {
|
|
|
1072
1157
|
function stopTailing() {
|
|
1073
1158
|
if (tailTimer) { clearInterval(tailTimer); tailTimer = null; }
|
|
1074
1159
|
if (switchWatcher) { clearInterval(switchWatcher); switchWatcher = null; }
|
|
1160
|
+
if (switchWatcherDelayTimer) { clearTimeout(switchWatcherDelayTimer); switchWatcherDelayTimer = null; }
|
|
1075
1161
|
if (expectingSwitchTimer) { clearTimeout(expectingSwitchTimer); expectingSwitchTimer = null; }
|
|
1076
1162
|
expectingSwitch = false;
|
|
1077
1163
|
pendingSwitchTarget = null;
|
|
1164
|
+
pendingInitialClearTranscript = null;
|
|
1078
1165
|
tailRemainder = Buffer.alloc(0);
|
|
1079
1166
|
}
|
|
1080
1167
|
|
|
@@ -1230,6 +1317,23 @@ function setupHooks() {
|
|
|
1230
1317
|
}
|
|
1231
1318
|
settings.hooks.SessionStart = existingSessionStart;
|
|
1232
1319
|
|
|
1320
|
+
// Merge bridge hook into SessionEnd (notify bridge when session ends, e.g. /clear)
|
|
1321
|
+
const sessionEndScript = path.resolve(__dirname, 'hooks', 'bridge-session-end.js').replace(/\\/g, '/');
|
|
1322
|
+
const sessionEndCmd = `node "${sessionEndScript}"`;
|
|
1323
|
+
const existingSessionEnd = settings.hooks.SessionEnd || [];
|
|
1324
|
+
const sessionEndBridgeIdx = existingSessionEnd.findIndex(e =>
|
|
1325
|
+
e.hooks?.some(h => h.command?.includes('bridge-session-end'))
|
|
1326
|
+
);
|
|
1327
|
+
const sessionEndEntry = {
|
|
1328
|
+
hooks: [{ type: 'command', command: sessionEndCmd, timeout: 10 }],
|
|
1329
|
+
};
|
|
1330
|
+
if (sessionEndBridgeIdx >= 0) {
|
|
1331
|
+
existingSessionEnd[sessionEndBridgeIdx] = sessionEndEntry;
|
|
1332
|
+
} else {
|
|
1333
|
+
existingSessionEnd.push(sessionEndEntry);
|
|
1334
|
+
}
|
|
1335
|
+
settings.hooks.SessionEnd = existingSessionEnd;
|
|
1336
|
+
|
|
1233
1337
|
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
|
|
1234
1338
|
log(`Hooks configured: ${settingsPath}`);
|
|
1235
1339
|
}
|