claude-relay 1.0.0

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/lib/server.js ADDED
@@ -0,0 +1,557 @@
1
+ const http = require("http");
2
+ const fs = require("fs");
3
+ const path = require("path");
4
+ const { spawn } = require("child_process");
5
+ const { WebSocketServer } = require("ws");
6
+
7
+ const publicDir = path.join(__dirname, "public");
8
+
9
+ const MIME_TYPES = {
10
+ ".html": "text/html",
11
+ ".css": "text/css",
12
+ ".js": "application/javascript",
13
+ ".json": "application/json",
14
+ ".png": "image/png",
15
+ ".svg": "image/svg+xml",
16
+ ".ico": "image/x-icon",
17
+ };
18
+
19
+ function serveStatic(req, res) {
20
+ var urlPath = req.url.split("?")[0];
21
+ if (urlPath === "/") urlPath = "/index.html";
22
+
23
+ var filePath = path.join(publicDir, urlPath);
24
+
25
+ // Prevent path traversal
26
+ if (!filePath.startsWith(publicDir)) {
27
+ res.writeHead(403);
28
+ res.end("Forbidden");
29
+ return true;
30
+ }
31
+
32
+ try {
33
+ var content = fs.readFileSync(filePath);
34
+ var ext = path.extname(filePath);
35
+ var mime = MIME_TYPES[ext] || "application/octet-stream";
36
+ res.writeHead(200, { "Content-Type": mime + "; charset=utf-8" });
37
+ res.end(content);
38
+ return true;
39
+ } catch {
40
+ return false;
41
+ }
42
+ }
43
+
44
+ function createServer(cwd) {
45
+ const project = path.basename(cwd);
46
+
47
+ // --- Multi-session state ---
48
+ let nextLocalId = 1;
49
+ let sessions = new Map(); // localId -> session object
50
+ let activeSessionId = null; // currently active local ID
51
+ let slashCommands = null; // shared across sessions
52
+ let activeWs = null;
53
+
54
+ // --- Session persistence ---
55
+ var sessionsDir = path.join(cwd, ".claude-relay", "sessions");
56
+ fs.mkdirSync(sessionsDir, { recursive: true });
57
+
58
+ function sessionFilePath(cliSessionId) {
59
+ return path.join(sessionsDir, cliSessionId + ".jsonl");
60
+ }
61
+
62
+ function saveSessionFile(session) {
63
+ if (!session.cliSessionId) return;
64
+ var meta = JSON.stringify({
65
+ type: "meta",
66
+ localId: session.localId,
67
+ cliSessionId: session.cliSessionId,
68
+ title: session.title,
69
+ createdAt: session.createdAt,
70
+ });
71
+ var lines = [meta];
72
+ for (var i = 0; i < session.history.length; i++) {
73
+ lines.push(JSON.stringify(session.history[i]));
74
+ }
75
+ fs.writeFileSync(sessionFilePath(session.cliSessionId), lines.join("\n") + "\n");
76
+ }
77
+
78
+ function appendToSessionFile(session, obj) {
79
+ if (!session.cliSessionId) return;
80
+ fs.appendFileSync(sessionFilePath(session.cliSessionId), JSON.stringify(obj) + "\n");
81
+ }
82
+
83
+ function loadSessions() {
84
+ var files;
85
+ try { files = fs.readdirSync(sessionsDir); } catch { return; }
86
+
87
+ var loaded = [];
88
+ for (var i = 0; i < files.length; i++) {
89
+ if (!files[i].endsWith(".jsonl")) continue;
90
+ var content;
91
+ try { content = fs.readFileSync(path.join(sessionsDir, files[i]), "utf8"); } catch { continue; }
92
+ var lines = content.trim().split("\n");
93
+ if (lines.length === 0) continue;
94
+
95
+ var meta;
96
+ try { meta = JSON.parse(lines[0]); } catch { continue; }
97
+ if (meta.type !== "meta" || !meta.cliSessionId) continue;
98
+
99
+ var history = [];
100
+ for (var j = 1; j < lines.length; j++) {
101
+ try { history.push(JSON.parse(lines[j])); } catch {}
102
+ }
103
+
104
+ loaded.push({ meta: meta, history: history });
105
+ }
106
+
107
+ loaded.sort(function(a, b) { return a.meta.createdAt - b.meta.createdAt; });
108
+
109
+ for (var i = 0; i < loaded.length; i++) {
110
+ var m = loaded[i].meta;
111
+ var localId = nextLocalId++;
112
+ var session = {
113
+ localId: localId,
114
+ proc: null,
115
+ cliSessionId: m.cliSessionId,
116
+ buffer: "",
117
+ blocks: {},
118
+ sentToolResults: {},
119
+ isProcessing: false,
120
+ title: m.title || "",
121
+ createdAt: m.createdAt || Date.now(),
122
+ history: loaded[i].history,
123
+ };
124
+ sessions.set(localId, session);
125
+ }
126
+ }
127
+
128
+ // Load persisted sessions from disk
129
+ loadSessions();
130
+
131
+ function send(obj) {
132
+ if (activeWs && activeWs.readyState === 1) {
133
+ activeWs.send(JSON.stringify(obj));
134
+ }
135
+ }
136
+
137
+ // Send a message and record it in session history for replay on reconnect
138
+ function sendAndRecord(session, obj) {
139
+ session.history.push(obj);
140
+ appendToSessionFile(session, obj);
141
+ if (session.localId === activeSessionId) {
142
+ send(obj);
143
+ }
144
+ }
145
+
146
+ function getActiveSession() {
147
+ return sessions.get(activeSessionId) || null;
148
+ }
149
+
150
+ function broadcastSessionList() {
151
+ send({
152
+ type: "session_list",
153
+ sessions: [...sessions.values()].map(function(s) {
154
+ return {
155
+ id: s.localId,
156
+ title: s.title || "New Session",
157
+ active: s.localId === activeSessionId,
158
+ isProcessing: s.isProcessing,
159
+ };
160
+ }),
161
+ });
162
+ }
163
+
164
+ function createSession() {
165
+ var localId = nextLocalId++;
166
+ var session = {
167
+ localId: localId,
168
+ proc: null,
169
+ cliSessionId: null,
170
+ buffer: "",
171
+ blocks: {},
172
+ sentToolResults: {},
173
+ isProcessing: false,
174
+ title: "",
175
+ createdAt: Date.now(),
176
+ history: [],
177
+ };
178
+ sessions.set(localId, session);
179
+ spawnProcess(session);
180
+ switchSession(localId);
181
+ return session;
182
+ }
183
+
184
+ function replayHistory(session) {
185
+ for (var i = 0; i < session.history.length; i++) {
186
+ send(session.history[i]);
187
+ }
188
+ }
189
+
190
+ function switchSession(localId) {
191
+ var session = sessions.get(localId);
192
+ if (!session) return;
193
+
194
+ activeSessionId = localId;
195
+ send({ type: "session_switched", id: localId });
196
+ broadcastSessionList();
197
+ replayHistory(session);
198
+
199
+ if (session.isProcessing) {
200
+ send({ type: "status", status: "processing" });
201
+ }
202
+ }
203
+
204
+ function processLine(session, line) {
205
+ if (!line.trim()) return;
206
+
207
+ var parsed;
208
+ try {
209
+ parsed = JSON.parse(line);
210
+ } catch {
211
+ return;
212
+ }
213
+
214
+ if (parsed.session_id && !session.cliSessionId) {
215
+ session.cliSessionId = parsed.session_id;
216
+ saveSessionFile(session);
217
+ } else if (parsed.session_id) {
218
+ session.cliSessionId = parsed.session_id;
219
+ }
220
+
221
+ // Cache slash_commands from CLI init message
222
+ if (parsed.type === "system" && parsed.subtype === "init" && parsed.slash_commands) {
223
+ slashCommands = parsed.slash_commands;
224
+ send({ type: "slash_commands", commands: slashCommands });
225
+ }
226
+
227
+ if (parsed.type === "stream_event" && parsed.event) {
228
+ var evt = parsed.event;
229
+
230
+ if (evt.type === "content_block_start") {
231
+ var block = evt.content_block;
232
+ var idx = evt.index;
233
+
234
+ if (block.type === "tool_use") {
235
+ session.blocks[idx] = { type: "tool_use", id: block.id, name: block.name, inputJson: "" };
236
+ sendAndRecord(session, { type: "tool_start", id: block.id, name: block.name });
237
+ } else if (block.type === "thinking") {
238
+ session.blocks[idx] = { type: "thinking", thinkingText: "" };
239
+ sendAndRecord(session, { type: "thinking_start" });
240
+ } else if (block.type === "text") {
241
+ session.blocks[idx] = { type: "text" };
242
+ }
243
+ }
244
+
245
+ if (evt.type === "content_block_delta" && evt.delta) {
246
+ var idx = evt.index;
247
+
248
+ if (evt.delta.type === "text_delta" && typeof evt.delta.text === "string") {
249
+ sendAndRecord(session, { type: "delta", text: evt.delta.text });
250
+ } else if (evt.delta.type === "input_json_delta" && session.blocks[idx]) {
251
+ session.blocks[idx].inputJson += evt.delta.partial_json;
252
+ } else if (evt.delta.type === "thinking_delta" && session.blocks[idx]) {
253
+ session.blocks[idx].thinkingText += evt.delta.thinking;
254
+ sendAndRecord(session, { type: "thinking_delta", text: evt.delta.thinking });
255
+ }
256
+ }
257
+
258
+ if (evt.type === "content_block_stop") {
259
+ var idx = evt.index;
260
+ var block = session.blocks[idx];
261
+
262
+ if (block && block.type === "tool_use") {
263
+ var input = {};
264
+ try { input = JSON.parse(block.inputJson); } catch {}
265
+ sendAndRecord(session, { type: "tool_executing", id: block.id, name: block.name, input: input });
266
+ } else if (block && block.type === "thinking") {
267
+ sendAndRecord(session, { type: "thinking_stop" });
268
+ }
269
+
270
+ delete session.blocks[idx];
271
+ }
272
+
273
+ } else if ((parsed.type === "assistant" || parsed.type === "user") && parsed.message && parsed.message.content) {
274
+ var content = parsed.message.content;
275
+ for (var i = 0; i < content.length; i++) {
276
+ var block = content[i];
277
+ if (block.type === "tool_result" && !session.sentToolResults[block.tool_use_id]) {
278
+ var resultText = "";
279
+ if (typeof block.content === "string") {
280
+ resultText = block.content;
281
+ } else if (Array.isArray(block.content)) {
282
+ resultText = block.content
283
+ .filter(function(c) { return c.type === "text"; })
284
+ .map(function(c) { return c.text; })
285
+ .join("\n");
286
+ }
287
+ session.sentToolResults[block.tool_use_id] = true;
288
+ sendAndRecord(session, {
289
+ type: "tool_result",
290
+ id: block.tool_use_id,
291
+ content: resultText,
292
+ is_error: block.is_error || false,
293
+ });
294
+ }
295
+ }
296
+
297
+ } else if (parsed.type === "result") {
298
+ session.blocks = {};
299
+ session.sentToolResults = {};
300
+ session.isProcessing = false;
301
+ sendAndRecord(session, {
302
+ type: "result",
303
+ cost: parsed.total_cost_usd,
304
+ duration: parsed.duration_ms,
305
+ sessionId: parsed.session_id,
306
+ });
307
+ sendAndRecord(session, { type: "done", code: 0 });
308
+ broadcastSessionList();
309
+ }
310
+ }
311
+
312
+ function spawnProcess(session) {
313
+ var args = [
314
+ "-p",
315
+ "--verbose",
316
+ "--output-format", "stream-json",
317
+ "--input-format", "stream-json",
318
+ "--include-partial-messages",
319
+ ];
320
+
321
+ if (session.cliSessionId) {
322
+ args.push("--resume", session.cliSessionId);
323
+ }
324
+
325
+ session.buffer = "";
326
+ session.blocks = {};
327
+ session.sentToolResults = {};
328
+
329
+ session.proc = spawn("claude", args, {
330
+ cwd: cwd,
331
+ env: Object.assign({}, process.env),
332
+ stdio: ["pipe", "pipe", "pipe"],
333
+ });
334
+
335
+ session.proc.stdout.on("data", function(chunk) {
336
+ session.buffer += chunk.toString();
337
+ var lines = session.buffer.split("\n");
338
+ session.buffer = lines.pop();
339
+
340
+ for (var i = 0; i < lines.length; i++) {
341
+ processLine(session, lines[i]);
342
+ }
343
+ });
344
+
345
+ session.proc.stderr.on("data", function(chunk) {
346
+ var errText = chunk.toString();
347
+ if (errText.includes("Error") || errText.includes("error")) {
348
+ if (session.localId === activeSessionId) {
349
+ send({ type: "stderr", text: errText });
350
+ }
351
+ }
352
+ });
353
+
354
+ session.proc.on("close", function(code) {
355
+ if (session.buffer.trim()) {
356
+ processLine(session, session.buffer);
357
+ session.buffer = "";
358
+ }
359
+
360
+ session.proc = null;
361
+ session.blocks = {};
362
+
363
+ if (session.isProcessing) {
364
+ session.isProcessing = false;
365
+ sendAndRecord(session, { type: "error", text: "Claude process exited unexpectedly (code " + code + ")" });
366
+ sendAndRecord(session, { type: "done", code: code || 1 });
367
+ broadcastSessionList();
368
+ }
369
+ });
370
+
371
+ session.proc.on("error", function(err) {
372
+ session.proc = null;
373
+ session.isProcessing = false;
374
+ sendAndRecord(session, { type: "error", text: "Failed to spawn claude: " + err.message });
375
+ sendAndRecord(session, { type: "done", code: 1 });
376
+ broadcastSessionList();
377
+ });
378
+ }
379
+
380
+ function writeMessage(session, text, images) {
381
+ var content = [];
382
+
383
+ if (images && images.length > 0) {
384
+ for (var i = 0; i < images.length; i++) {
385
+ content.push({
386
+ type: "image",
387
+ source: {
388
+ type: "base64",
389
+ media_type: images[i].mediaType,
390
+ data: images[i].data,
391
+ },
392
+ });
393
+ }
394
+ }
395
+
396
+ if (text) {
397
+ content.push({ type: "text", text: text });
398
+ }
399
+
400
+ var msg = {
401
+ type: "user",
402
+ session_id: "",
403
+ parent_tool_use_id: null,
404
+ message: {
405
+ role: "user",
406
+ content: content,
407
+ },
408
+ };
409
+ session.proc.stdin.write(JSON.stringify(msg) + "\n");
410
+ }
411
+
412
+ function writeToolResult(session, toolUseId, resultText) {
413
+ var msg = {
414
+ type: "user",
415
+ session_id: "",
416
+ parent_tool_use_id: null,
417
+ message: {
418
+ role: "user",
419
+ content: [{
420
+ type: "tool_result",
421
+ tool_use_id: toolUseId,
422
+ content: resultText,
423
+ }],
424
+ },
425
+ };
426
+ session.proc.stdin.write(JSON.stringify(msg) + "\n");
427
+ }
428
+
429
+ // --- Spawn initial session only if no persisted sessions ---
430
+ if (sessions.size === 0) {
431
+ createSession();
432
+ } else {
433
+ // Activate the most recent session
434
+ var lastSession = [...sessions.values()].pop();
435
+ activeSessionId = lastSession.localId;
436
+ }
437
+
438
+ // --- HTTP server ---
439
+ var server = http.createServer(function(req, res) {
440
+ if (req.method === "GET" && req.url === "/info") {
441
+ res.writeHead(200, { "Content-Type": "application/json" });
442
+ res.end(JSON.stringify({ cwd: cwd, project: project }));
443
+ return;
444
+ }
445
+
446
+ if (req.method === "GET") {
447
+ if (serveStatic(req, res)) return;
448
+ }
449
+
450
+ res.writeHead(404);
451
+ res.end("Not found");
452
+ });
453
+
454
+ // --- WebSocket ---
455
+ var wss = new WebSocketServer({ server: server });
456
+
457
+ wss.on("connection", function(ws) {
458
+ activeWs = ws;
459
+
460
+ // Send cached state
461
+ send({ type: "info", cwd: cwd, project: project });
462
+ if (slashCommands) {
463
+ send({ type: "slash_commands", commands: slashCommands });
464
+ }
465
+ broadcastSessionList();
466
+
467
+ // Restore active session
468
+ var active = getActiveSession();
469
+ if (active) {
470
+ send({ type: "session_switched", id: active.localId });
471
+ replayHistory(active);
472
+ if (active.isProcessing) {
473
+ send({ type: "status", status: "processing" });
474
+ }
475
+ }
476
+
477
+ ws.on("message", function(raw) {
478
+ var msg;
479
+ try {
480
+ msg = JSON.parse(raw.toString());
481
+ } catch {
482
+ return;
483
+ }
484
+
485
+ if (msg.type === "new_session") {
486
+ createSession();
487
+ return;
488
+ }
489
+
490
+ if (msg.type === "switch_session") {
491
+ if (msg.id && sessions.has(msg.id)) {
492
+ switchSession(msg.id);
493
+ }
494
+ return;
495
+ }
496
+
497
+ if (msg.type === "ask_user_response") {
498
+ var session = getActiveSession();
499
+ if (!session || !session.proc) return;
500
+
501
+ var toolId = msg.toolId;
502
+ var answers = msg.answers || {};
503
+ var resultText = JSON.stringify({ answers: answers });
504
+
505
+ writeToolResult(session, toolId, resultText);
506
+ return;
507
+ }
508
+
509
+ if (msg.type !== "message") return;
510
+ if (!msg.text && (!msg.images || msg.images.length === 0)) return;
511
+
512
+ var session = getActiveSession();
513
+ if (!session) return;
514
+
515
+ if (session.isProcessing) {
516
+ send({ type: "error", text: "Still processing previous message. Please wait." });
517
+ return;
518
+ }
519
+
520
+ session.isProcessing = true;
521
+ session.sentToolResults = {};
522
+
523
+ // Record user message in history for replay (without base64 data to save space)
524
+ var userMsg = { type: "user_message", text: msg.text || "" };
525
+ if (msg.images && msg.images.length > 0) {
526
+ userMsg.imageCount = msg.images.length;
527
+ }
528
+ session.history.push(userMsg);
529
+ appendToSessionFile(session, userMsg);
530
+ send({ type: "status", status: "processing" });
531
+
532
+ // Set title from first user message
533
+ if (!session.title) {
534
+ session.title = (msg.text || "Image").substring(0, 50);
535
+ saveSessionFile(session);
536
+ broadcastSessionList();
537
+ }
538
+
539
+ // Respawn if process died
540
+ if (!session.proc) {
541
+ spawnProcess(session);
542
+ }
543
+
544
+ writeMessage(session, msg.text || "", msg.images);
545
+ broadcastSessionList();
546
+ });
547
+
548
+ ws.on("close", function() {
549
+ if (activeWs === ws) activeWs = null;
550
+ // Don't kill procs — they persist across reconnects
551
+ });
552
+ });
553
+
554
+ return server;
555
+ }
556
+
557
+ module.exports = { createServer };
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "claude-relay",
3
+ "version": "1.0.0",
4
+ "description": "Access Claude Code on your machine, from anywhere. One command, no config.",
5
+ "bin": {
6
+ "claude-relay": "./bin/cli.js"
7
+ },
8
+ "files": [
9
+ "bin/",
10
+ "lib/"
11
+ ],
12
+ "keywords": [
13
+ "claude",
14
+ "claude-code",
15
+ "cli",
16
+ "mobile",
17
+ "remote",
18
+ "relay",
19
+ "web-ui",
20
+ "tailscale"
21
+ ],
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/chadbyte/claude-relay.git"
25
+ },
26
+ "homepage": "https://github.com/chadbyte/claude-relay#readme",
27
+ "author": "Chad",
28
+ "dependencies": {
29
+ "ws": "^8.18.0"
30
+ },
31
+ "license": "MIT",
32
+ "engines": {
33
+ "node": ">=18.0.0"
34
+ }
35
+ }