claude-remote 0.2.0 → 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 +154 -14
|
@@ -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,8 +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;
|
|
124
|
+
let pendingSwitchTarget = null;
|
|
125
|
+
let pendingInitialClearTranscript = null; // { sessionId }
|
|
123
126
|
let tailRemainder = Buffer.alloc(0);
|
|
124
127
|
let tailCatchingUp = false; // true while reading historical transcript content
|
|
125
128
|
const isTTY = process.stdin.isTTY && process.stdout.isTTY;
|
|
@@ -196,6 +199,7 @@ function resolveHookTranscript(data) {
|
|
|
196
199
|
function maybeAttachHookSession(data, source) {
|
|
197
200
|
const target = resolveHookTranscript(data);
|
|
198
201
|
if (!target) return;
|
|
202
|
+
let hookSource = null;
|
|
199
203
|
|
|
200
204
|
// Already attached to this exact session — no-op
|
|
201
205
|
if (currentSessionId === target.sessionId && transcriptPath &&
|
|
@@ -206,16 +210,29 @@ function maybeAttachHookSession(data, source) {
|
|
|
206
210
|
const targetHasContent = fileLooksLikeTranscript(target.full);
|
|
207
211
|
|
|
208
212
|
if (source === 'session-start') {
|
|
209
|
-
//
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
//
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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;
|
|
235
|
+
}
|
|
219
236
|
}
|
|
220
237
|
}
|
|
221
238
|
} else if (source === 'pre-tool-use') {
|
|
@@ -235,7 +252,35 @@ function maybeAttachHookSession(data, source) {
|
|
|
235
252
|
}
|
|
236
253
|
|
|
237
254
|
log(`Hook session attached from ${source}: ${target.sessionId}`);
|
|
255
|
+
attachTranscript({
|
|
256
|
+
full: target.full,
|
|
257
|
+
ignoreInitialClearCommand: source === 'session-start' && hookSource === 'clear',
|
|
258
|
+
}, 0);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function maybeAttachPendingSwitchTarget(reason, requireReady = true) {
|
|
262
|
+
if (!pendingSwitchTarget) return false;
|
|
263
|
+
if ((Date.now() - pendingSwitchTarget.seenAt) > 15000) {
|
|
264
|
+
log(`Dropped stale pending switch target: ${pendingSwitchTarget.sessionId}`);
|
|
265
|
+
pendingSwitchTarget = null;
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
if (pendingSwitchTarget.sessionId === currentSessionId) {
|
|
269
|
+
pendingSwitchTarget = null;
|
|
270
|
+
return false;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (requireReady && !fileLooksLikeTranscript(pendingSwitchTarget.full)) {
|
|
274
|
+
return false;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const target = pendingSwitchTarget;
|
|
278
|
+
pendingSwitchTarget = null;
|
|
279
|
+
log(`Attaching pending switch target from ${reason}: ${target.sessionId}`);
|
|
280
|
+
if (tailTimer) { clearInterval(tailTimer); tailTimer = null; }
|
|
281
|
+
if (switchWatcher) { clearInterval(switchWatcher); switchWatcher = null; }
|
|
238
282
|
attachTranscript({ full: target.full }, 0);
|
|
283
|
+
return true;
|
|
239
284
|
}
|
|
240
285
|
|
|
241
286
|
// ============================================================
|
|
@@ -326,7 +371,9 @@ const server = http.createServer((req, res) => {
|
|
|
326
371
|
req.on('data', chunk => (body += chunk));
|
|
327
372
|
req.on('end', () => {
|
|
328
373
|
try {
|
|
329
|
-
|
|
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');
|
|
330
377
|
} catch {}
|
|
331
378
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
332
379
|
res.end('{}');
|
|
@@ -334,6 +381,25 @@ const server = http.createServer((req, res) => {
|
|
|
334
381
|
return;
|
|
335
382
|
}
|
|
336
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
|
+
|
|
337
403
|
// --- API: Stop hook endpoint ---
|
|
338
404
|
if (req.method === 'POST' && url === '/hook/stop') {
|
|
339
405
|
let body = '';
|
|
@@ -872,9 +938,26 @@ function extractSlashCommand(content) {
|
|
|
872
938
|
return inlineMatch ? inlineMatch[1].trim().toLowerCase() : '';
|
|
873
939
|
}
|
|
874
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
|
+
|
|
875
952
|
function attachTranscript(target, startOffset = 0) {
|
|
876
953
|
transcriptPath = target.full;
|
|
877
954
|
currentSessionId = path.basename(transcriptPath, '.jsonl');
|
|
955
|
+
pendingInitialClearTranscript = target.ignoreInitialClearCommand
|
|
956
|
+
? { sessionId: currentSessionId }
|
|
957
|
+
: null;
|
|
958
|
+
if (pendingSwitchTarget && pendingSwitchTarget.sessionId === currentSessionId) {
|
|
959
|
+
pendingSwitchTarget = null;
|
|
960
|
+
}
|
|
878
961
|
transcriptOffset = Math.max(0, startOffset);
|
|
879
962
|
tailRemainder = Buffer.alloc(0);
|
|
880
963
|
eventBuffer = [];
|
|
@@ -885,6 +968,7 @@ function attachTranscript(target, startOffset = 0) {
|
|
|
885
968
|
expectingSwitch = false;
|
|
886
969
|
if (expectingSwitchTimer) { clearTimeout(expectingSwitchTimer); expectingSwitchTimer = null; }
|
|
887
970
|
}
|
|
971
|
+
if (switchWatcherDelayTimer) { clearTimeout(switchWatcherDelayTimer); switchWatcherDelayTimer = null; }
|
|
888
972
|
|
|
889
973
|
// If transcript file already has content, mark as catching up so we don't
|
|
890
974
|
// broadcast working_started for historical user messages.
|
|
@@ -903,7 +987,7 @@ function attachTranscript(target, startOffset = 0) {
|
|
|
903
987
|
lastSeq: 0,
|
|
904
988
|
});
|
|
905
989
|
startTailing();
|
|
906
|
-
|
|
990
|
+
// switchWatcher is now only started as a delayed fallback from markExpectingSwitch()
|
|
907
991
|
}
|
|
908
992
|
|
|
909
993
|
function markExpectingSwitch() {
|
|
@@ -915,6 +999,18 @@ function markExpectingSwitch() {
|
|
|
915
999
|
log('Expecting-switch flag expired (no new transcript found)');
|
|
916
1000
|
}, 15000);
|
|
917
1001
|
log('Expecting session switch (/clear detected)');
|
|
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);
|
|
918
1014
|
}
|
|
919
1015
|
|
|
920
1016
|
function startSwitchWatcher() {
|
|
@@ -952,6 +1048,7 @@ function startSwitchWatcher() {
|
|
|
952
1048
|
function startTailing() {
|
|
953
1049
|
tailRemainder = Buffer.alloc(0);
|
|
954
1050
|
tailTimer = setInterval(() => {
|
|
1051
|
+
if (maybeAttachPendingSwitchTarget('tail_pending_target')) return;
|
|
955
1052
|
if (!transcriptPath) return;
|
|
956
1053
|
try {
|
|
957
1054
|
const stat = fs.statSync(transcriptPath);
|
|
@@ -983,15 +1080,38 @@ function startTailing() {
|
|
|
983
1080
|
if (event.type === 'user' || (event.message && event.message.role === 'user')) {
|
|
984
1081
|
const content = event.message && event.message.content;
|
|
985
1082
|
const slashCommand = extractSlashCommand(content);
|
|
1083
|
+
const isPassiveUserEvent = isNonAiUserEvent(event, content);
|
|
1084
|
+
const ignoreInitialClear = (
|
|
1085
|
+
slashCommand === '/clear' &&
|
|
1086
|
+
pendingInitialClearTranscript &&
|
|
1087
|
+
pendingInitialClearTranscript.sessionId === currentSessionId
|
|
1088
|
+
);
|
|
986
1089
|
// Only broadcast working_started for live (new) user messages,
|
|
987
1090
|
// not for historical events during catch-up, and not for slash
|
|
988
1091
|
// commands (which are CLI commands, not AI turns).
|
|
989
|
-
if (!tailCatchingUp && !slashCommand) {
|
|
1092
|
+
if (!tailCatchingUp && !slashCommand && !isPassiveUserEvent) {
|
|
990
1093
|
broadcast({ type: 'working_started' });
|
|
991
1094
|
}
|
|
992
1095
|
if (slashCommand === '/clear') {
|
|
993
|
-
|
|
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;
|
|
994
1111
|
}
|
|
1112
|
+
} else if (pendingInitialClearTranscript && pendingInitialClearTranscript.sessionId === currentSessionId &&
|
|
1113
|
+
event.type === 'assistant') {
|
|
1114
|
+
pendingInitialClearTranscript = null;
|
|
995
1115
|
}
|
|
996
1116
|
// Enrich Edit tool_use blocks with source file start line
|
|
997
1117
|
enrichEditStartLines(event);
|
|
@@ -1037,8 +1157,11 @@ function enrichEditStartLines(event) {
|
|
|
1037
1157
|
function stopTailing() {
|
|
1038
1158
|
if (tailTimer) { clearInterval(tailTimer); tailTimer = null; }
|
|
1039
1159
|
if (switchWatcher) { clearInterval(switchWatcher); switchWatcher = null; }
|
|
1160
|
+
if (switchWatcherDelayTimer) { clearTimeout(switchWatcherDelayTimer); switchWatcherDelayTimer = null; }
|
|
1040
1161
|
if (expectingSwitchTimer) { clearTimeout(expectingSwitchTimer); expectingSwitchTimer = null; }
|
|
1041
1162
|
expectingSwitch = false;
|
|
1163
|
+
pendingSwitchTarget = null;
|
|
1164
|
+
pendingInitialClearTranscript = null;
|
|
1042
1165
|
tailRemainder = Buffer.alloc(0);
|
|
1043
1166
|
}
|
|
1044
1167
|
|
|
@@ -1194,6 +1317,23 @@ function setupHooks() {
|
|
|
1194
1317
|
}
|
|
1195
1318
|
settings.hooks.SessionStart = existingSessionStart;
|
|
1196
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
|
+
|
|
1197
1337
|
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
|
|
1198
1338
|
log(`Hooks configured: ${settingsPath}`);
|
|
1199
1339
|
}
|