claude-remote 0.1.1 → 0.1.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,32 @@
1
+ #!/usr/bin/env node
2
+ // Bridge session-start hook — binds the spawned Claude session to its transcript.
3
+
4
+ const http = require('http');
5
+
6
+ if (!process.env.BRIDGE_PORT) process.exit(0);
7
+
8
+ const PORT = process.env.BRIDGE_PORT;
9
+
10
+ let input = '';
11
+ process.stdin.setEncoding('utf8');
12
+ process.stdin.on('data', chunk => (input += chunk));
13
+ process.stdin.on('end', () => {
14
+ const body = input || '{}';
15
+ const req = http.request({
16
+ hostname: '127.0.0.1',
17
+ port: PORT,
18
+ path: '/hook/session-start',
19
+ method: 'POST',
20
+ headers: {
21
+ 'Content-Type': 'application/json',
22
+ 'Content-Length': Buffer.byteLength(body),
23
+ },
24
+ }, () => {
25
+ process.exit(0);
26
+ });
27
+
28
+ req.on('error', () => process.exit(0));
29
+ req.setTimeout(10000, () => { req.destroy(); process.exit(0); });
30
+ req.write(body);
31
+ req.end();
32
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-remote",
3
- "version": "0.1.1",
3
+ "version": "0.1.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
@@ -22,13 +22,10 @@ let eventSeq = 0;
22
22
  const EVENT_BUFFER_MAX = 5000;
23
23
  let nextWsId = 0;
24
24
  let tailTimer = null;
25
- let discoveryTimer = null;
26
- let switchWatcher = null;
27
- let expectingSwitch = false;
28
- let expectingSwitchTimer = null;
29
- let preExistingFiles = new Set();
30
- let preExistingFileSizes = new Map();
31
- let tailRemainder = Buffer.alloc(0);
25
+ let switchWatcher = null;
26
+ let expectingSwitch = false;
27
+ let expectingSwitchTimer = null;
28
+ let tailRemainder = Buffer.alloc(0);
32
29
  const isTTY = process.stdin.isTTY && process.stdout.isTTY;
33
30
  const LEGACY_REPLAY_DELAY_MS = 1500;
34
31
  const IMAGE_UPLOAD_TTL_MS = 15 * 60 * 1000;
@@ -66,6 +63,57 @@ function sendWs(ws, msg, context = '') {
66
63
  }
67
64
  return true;
68
65
  }
66
+
67
+ function normalizeFsPath(value) {
68
+ const resolved = path.resolve(String(value || ''));
69
+ return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
70
+ }
71
+
72
+ function projectTranscriptDir() {
73
+ return path.join(PROJECTS_DIR, getProjectSlug(CWD));
74
+ }
75
+
76
+ function resolveHookTranscript(data) {
77
+ if (!data || typeof data !== 'object') return null;
78
+
79
+ const hookCwd = data.cwd ? path.resolve(String(data.cwd)) : '';
80
+ if (hookCwd && normalizeFsPath(hookCwd) !== normalizeFsPath(CWD)) return null;
81
+
82
+ const sessionId = data.session_id ? String(data.session_id) : '';
83
+ const expectedDir = projectTranscriptDir();
84
+ const transcriptPath = data.transcript_path ? path.resolve(String(data.transcript_path)) : '';
85
+
86
+ if (transcriptPath) {
87
+ const transcriptDir = path.dirname(transcriptPath);
88
+ const transcriptSessionId = path.basename(transcriptPath, '.jsonl');
89
+ const dirMatches = normalizeFsPath(transcriptDir) === normalizeFsPath(expectedDir);
90
+ const idMatches = !sessionId || transcriptSessionId === sessionId;
91
+ if (dirMatches && idMatches) {
92
+ return { full: transcriptPath, sessionId: transcriptSessionId };
93
+ }
94
+ }
95
+
96
+ if (!sessionId) return null;
97
+ return { full: path.join(expectedDir, `${sessionId}.jsonl`), sessionId };
98
+ }
99
+
100
+ function maybeAttachHookSession(data, source) {
101
+ const target = resolveHookTranscript(data);
102
+ if (!target) return;
103
+
104
+ if (currentSessionId === target.sessionId && transcriptPath &&
105
+ normalizeFsPath(transcriptPath) === normalizeFsPath(target.full)) {
106
+ return;
107
+ }
108
+
109
+ if (currentSessionId && currentSessionId !== target.sessionId && !expectingSwitch) {
110
+ log(`Ignored hook session from ${source}: ${target.sessionId} (current=${currentSessionId})`);
111
+ return;
112
+ }
113
+
114
+ log(`Hook session attached from ${source}: ${target.sessionId}`);
115
+ attachTranscript({ full: target.full }, 0);
116
+ }
69
117
 
70
118
  // ============================================================
71
119
  // 1. Static file server
@@ -94,7 +142,9 @@ const server = http.createServer((req, res) => {
94
142
  return;
95
143
  }
96
144
 
97
- if (ALWAYS_AUTO_ALLOW.has(data.tool_name)) {
145
+ maybeAttachHookSession(data, 'pre-tool-use');
146
+
147
+ if (ALWAYS_AUTO_ALLOW.has(data.tool_name)) {
98
148
  res.writeHead(200, { 'Content-Type': 'application/json' });
99
149
  res.end(JSON.stringify({ decision: 'allow' }));
100
150
  log(`Permission auto-allowed (always): ${data.tool_name}`);
@@ -147,13 +197,30 @@ const server = http.createServer((req, res) => {
147
197
  return;
148
198
  }
149
199
 
150
- // --- API: Stop hook endpoint ---
200
+ // --- API: Session start hook endpoint ---
201
+ if (req.method === 'POST' && url === '/hook/session-start') {
202
+ let body = '';
203
+ req.on('data', chunk => (body += chunk));
204
+ req.on('end', () => {
205
+ try {
206
+ maybeAttachHookSession(JSON.parse(body), 'session-start');
207
+ } catch {}
208
+ res.writeHead(200, { 'Content-Type': 'application/json' });
209
+ res.end('{}');
210
+ });
211
+ return;
212
+ }
213
+
214
+ // --- API: Stop hook endpoint ---
151
215
  if (req.method === 'POST' && url === '/hook/stop') {
152
216
  let body = '';
153
217
  req.on('data', chunk => (body += chunk));
154
218
  req.on('end', () => {
155
219
  log('/hook/stop received — broadcasting turn_complete');
156
- broadcast({ type: 'turn_complete' });
220
+ try {
221
+ maybeAttachHookSession(JSON.parse(body), 'stop');
222
+ } catch {}
223
+ broadcast({ type: 'turn_complete' });
157
224
  res.writeHead(200, { 'Content-Type': 'application/json' });
158
225
  res.end('{}');
159
226
  });
@@ -552,10 +619,8 @@ wss.on('connection', (ws, req) => {
552
619
  // ============================================================
553
620
  // 4. PTY Manager — local terminal passthrough
554
621
  // ============================================================
555
- function spawnClaude() {
556
- snapshotExistingFiles();
557
-
558
- const isWin = process.platform === 'win32';
622
+ function spawnClaude() {
623
+ const isWin = process.platform === 'win32';
559
624
  const shell = isWin ? 'powershell.exe' : (process.env.SHELL || '/bin/bash');
560
625
  const args = isWin
561
626
  ? ['-NoLogo', '-NoProfile', '-Command', 'claude']
@@ -668,82 +733,9 @@ function attachTranscript(target, startOffset = 0) {
668
733
  sessionId: currentSessionId,
669
734
  lastSeq: 0,
670
735
  });
671
-
672
- if (discoveryTimer) {
673
- clearInterval(discoveryTimer);
674
- discoveryTimer = null;
675
- }
676
- startTailing();
677
- startSwitchWatcher();
678
- }
679
-
680
- function snapshotExistingFiles() {
681
- const slug = getProjectSlug(CWD);
682
- const projectDir = path.join(PROJECTS_DIR, slug);
683
- preExistingFiles.clear();
684
- preExistingFileSizes.clear();
685
- try {
686
- if (fs.existsSync(projectDir)) {
687
- for (const f of fs.readdirSync(projectDir)) {
688
- if (!f.endsWith('.jsonl')) continue;
689
- const full = path.join(projectDir, f);
690
- const stat = fs.statSync(full);
691
- preExistingFiles.add(f);
692
- preExistingFileSizes.set(f, stat.size);
693
- }
694
- }
695
- } catch {}
696
- log(`Pre-existing transcripts: ${preExistingFiles.size} files`);
697
- }
698
-
699
- function startDiscovery() {
700
- const slug = getProjectSlug(CWD);
701
- const projectDir = path.join(PROJECTS_DIR, slug);
702
- log(`Watching for NEW transcript in: ${projectDir}`);
703
-
704
- discoveryTimer = setInterval(() => {
705
- if (!fs.existsSync(projectDir)) return;
706
-
707
- try {
708
- const targets = fs.readdirSync(projectDir)
709
- .filter(f => f.endsWith('.jsonl'))
710
- .map(f => {
711
- const full = path.join(projectDir, f);
712
- const stat = fs.statSync(full);
713
- return {
714
- name: f,
715
- full,
716
- mtime: stat.mtimeMs,
717
- size: stat.size,
718
- };
719
- })
720
- .sort((a, b) => b.mtime - a.mtime);
721
-
722
- const newTargets = targets.filter(t => !preExistingFiles.has(t.name));
723
- const newTranscript = newTargets.find(t => fileLooksLikeTranscript(t.full));
724
- if (newTranscript) {
725
- log(`NEW transcript found: ${path.basename(newTranscript.full, '.jsonl')}`);
726
- attachTranscript(newTranscript, 0);
727
- return;
728
- }
729
-
730
- for (const t of newTargets) {
731
- preExistingFiles.add(t.name);
732
- preExistingFileSizes.set(t.name, t.size);
733
- }
734
-
735
- // Fallback: reuse a pre-existing transcript if it keeps growing.
736
- const grownTargets = targets.filter(t => t.size > (preExistingFileSizes.get(t.name) || 0));
737
- const grownTranscript = grownTargets.find(t => fileLooksLikeTranscript(t.full));
738
- if (grownTranscript) {
739
- const baseOffset = preExistingFileSizes.get(grownTranscript.name) || 0;
740
- log(`Reusing growing transcript: ${path.basename(grownTranscript.full, '.jsonl')} (from offset ${baseOffset})`);
741
- attachTranscript(grownTranscript, baseOffset);
742
- return;
743
- }
744
- } catch {}
745
- }, 500);
746
- }
736
+ startTailing();
737
+ startSwitchWatcher();
738
+ }
747
739
 
748
740
  function markExpectingSwitch() {
749
741
  expectingSwitch = true;
@@ -836,12 +828,11 @@ function startTailing() {
836
828
  }, 300);
837
829
  }
838
830
 
839
- function stopTailing() {
840
- if (tailTimer) { clearInterval(tailTimer); tailTimer = null; }
841
- if (discoveryTimer) { clearInterval(discoveryTimer); discoveryTimer = null; }
842
- if (switchWatcher) { clearInterval(switchWatcher); switchWatcher = null; }
843
- if (expectingSwitchTimer) { clearTimeout(expectingSwitchTimer); expectingSwitchTimer = null; }
844
- expectingSwitch = false;
831
+ function stopTailing() {
832
+ if (tailTimer) { clearInterval(tailTimer); tailTimer = null; }
833
+ if (switchWatcher) { clearInterval(switchWatcher); switchWatcher = null; }
834
+ if (expectingSwitchTimer) { clearTimeout(expectingSwitchTimer); expectingSwitchTimer = null; }
835
+ expectingSwitch = false;
845
836
  tailRemainder = Buffer.alloc(0);
846
837
  }
847
838
 
@@ -978,12 +969,28 @@ function setupHooks() {
978
969
  existingStop[stopBridgeIdx] = stopEntry;
979
970
  } else {
980
971
  existingStop.push(stopEntry);
981
- }
982
- settings.hooks.Stop = existingStop;
983
-
984
- fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
985
- log(`Hooks configured: ${settingsPath}`);
986
- }
972
+ }
973
+ settings.hooks.Stop = existingStop;
974
+
975
+ const sessionStartScript = path.resolve(__dirname, 'hooks', 'bridge-session-start.js').replace(/\\/g, '/');
976
+ const sessionStartCmd = `node "${sessionStartScript}"`;
977
+ const existingSessionStart = settings.hooks.SessionStart || [];
978
+ const sessionStartBridgeIdx = existingSessionStart.findIndex(e =>
979
+ e.hooks?.some(h => h.command?.includes('bridge-session-start'))
980
+ );
981
+ const sessionStartEntry = {
982
+ hooks: [{ type: 'command', command: sessionStartCmd, timeout: 10 }],
983
+ };
984
+ if (sessionStartBridgeIdx >= 0) {
985
+ existingSessionStart[sessionStartBridgeIdx] = sessionStartEntry;
986
+ } else {
987
+ existingSessionStart.push(sessionStartEntry);
988
+ }
989
+ settings.hooks.SessionStart = existingSessionStart;
990
+
991
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
992
+ log(`Hooks configured: ${settingsPath}`);
993
+ }
987
994
 
988
995
  // ============================================================
989
996
  // 7. Startup
@@ -1014,8 +1021,7 @@ server.listen(PORT, '0.0.0.0', () => {
1014
1021
  Phone: ${lan}
1015
1022
  ─────────────────────────────
1016
1023
 
1017
- `);
1018
- setupHooks();
1019
- spawnClaude();
1020
- startDiscovery();
1021
- });
1024
+ `);
1025
+ setupHooks();
1026
+ spawnClaude();
1027
+ });