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.
@@ -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.0",
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,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
- // session-start is unreliable for --resume (fires twice, one is a
210
- // snapshot-only session). Only accept when:
211
- // 1. No session bound yet (first attach), OR
212
- // 2. Expecting a switch (/clear), OR
213
- // 3. Target has conversation content and current doesn't
214
- if (currentSessionId && !expectingSwitch) {
215
- const currentHasContent = transcriptPath && fileLooksLikeTranscript(transcriptPath);
216
- if (!targetHasContent || currentHasContent) {
217
- log(`Ignored session-start: ${target.sessionId} (current=${currentSessionId} currentHasContent=${currentHasContent} targetHasContent=${targetHasContent})`);
218
- return;
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
- 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');
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
- startSwitchWatcher();
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
- 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;
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
  }