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.
@@ -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.2",
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
@@ -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
- // 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})`);
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({ full: target.full }, 0);
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
- maybeAttachHookSession(JSON.parse(body), 'session-start');
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
- startSwitchWatcher();
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
- markExpectingSwitch();
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
  }