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.
- package/hooks/bridge-session-start.js +32 -0
- package/package.json +1 -1
- package/server.js +113 -107
|
@@ -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
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
|
|
26
|
-
let
|
|
27
|
-
let
|
|
28
|
-
let
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
673
|
-
|
|
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 (
|
|
842
|
-
if (
|
|
843
|
-
|
|
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
|
-
|
|
985
|
-
|
|
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
|
-
|
|
1021
|
-
});
|
|
1024
|
+
`);
|
|
1025
|
+
setupHooks();
|
|
1026
|
+
spawnClaude();
|
|
1027
|
+
});
|