claude-relay 2.3.0 → 2.4.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/README.md +21 -5
- package/bin/cli.js +214 -9
- package/lib/cli-sessions.js +270 -0
- package/lib/config.js +3 -2
- package/lib/daemon.js +45 -1
- package/lib/pages.js +8 -1
- package/lib/project.js +121 -12
- package/lib/public/app.js +411 -87
- package/lib/public/css/base.css +41 -7
- package/lib/public/css/diff.css +6 -6
- package/lib/public/css/filebrowser.css +62 -52
- package/lib/public/css/highlight.css +144 -0
- package/lib/public/css/input.css +11 -9
- package/lib/public/css/menus.css +82 -23
- package/lib/public/css/messages.css +183 -35
- package/lib/public/css/overlays.css +166 -50
- package/lib/public/css/rewind.css +17 -17
- package/lib/public/css/sidebar.css +210 -137
- package/lib/public/index.html +75 -42
- package/lib/public/modules/filebrowser.js +2 -1
- package/lib/public/modules/markdown.js +10 -10
- package/lib/public/modules/notifications.js +38 -1
- package/lib/public/modules/sidebar.js +109 -31
- package/lib/public/modules/terminal.js +84 -23
- package/lib/public/modules/theme.js +622 -0
- package/lib/public/modules/tools.js +247 -4
- package/lib/public/modules/utils.js +21 -5
- package/lib/public/style.css +1 -0
- package/lib/sdk-bridge.js +95 -0
- package/lib/server.js +45 -3
- package/lib/sessions.js +16 -3
- package/lib/themes/ayu-light.json +9 -0
- package/lib/themes/catppuccin-latte.json +9 -0
- package/lib/themes/catppuccin-mocha.json +9 -0
- package/lib/themes/claude-light.json +9 -0
- package/lib/themes/claude.json +9 -0
- package/lib/themes/dracula.json +9 -0
- package/lib/themes/everforest-light.json +9 -0
- package/lib/themes/everforest.json +9 -0
- package/lib/themes/github-light.json +9 -0
- package/lib/themes/gruvbox-dark.json +9 -0
- package/lib/themes/gruvbox-light.json +9 -0
- package/lib/themes/monokai.json +9 -0
- package/lib/themes/nord-light.json +9 -0
- package/lib/themes/nord.json +9 -0
- package/lib/themes/one-dark.json +9 -0
- package/lib/themes/one-light.json +9 -0
- package/lib/themes/rose-pine-dawn.json +9 -0
- package/lib/themes/rose-pine.json +9 -0
- package/lib/themes/solarized-dark.json +9 -0
- package/lib/themes/solarized-light.json +9 -0
- package/lib/themes/tokyo-night-light.json +9 -0
- package/lib/themes/tokyo-night.json +9 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
<h3 align="center">Web UI for Claude Code. Any device. Push notifications.</h3>
|
|
8
8
|
|
|
9
|
-
[](https://www.npmjs.com/package/claude-relay) [](https://www.npmjs.com/package/claude-relay) [](https://github.com/chadbyte/claude-relay)
|
|
9
|
+
[](https://www.npmjs.com/package/claude-relay) [](https://www.npmjs.com/package/claude-relay) [](https://github.com/chadbyte/claude-relay) [](https://github.com/chadbyte/claude-relay/blob/main/LICENSE)
|
|
10
10
|
|
|
11
11
|
Claude Code. Anywhere.
|
|
12
12
|
Same session. Same files. Same machine.
|
|
@@ -86,7 +86,7 @@ Scan the QR code with your phone to connect instantly, or open the URL displayed
|
|
|
86
86
|
## Key Features
|
|
87
87
|
|
|
88
88
|
* **Push Approvals** - Approve or reject from your phone while away, so Claude Code does not get stuck waiting.
|
|
89
|
-
* **Multi Project Daemon** - Manage all projects via a single port.
|
|
89
|
+
* **Multi Project Daemon** - Manage all projects via a single port. Add and remove projects from the browser.
|
|
90
90
|
* **Usage and Model Switching** - View token usage, rate limit bars, and switch models from the browser.
|
|
91
91
|
* **Session Search** - Full-text search across all session messages with hit timeline.
|
|
92
92
|
* **Auto Session Logs (JSONL)** - Conversations and execution history are always saved locally. No data loss on crashes or restarts. Location: `./.claude-relay/sessions/`
|
|
@@ -105,10 +105,10 @@ Scan the QR code with your phone to connect instantly, or open the URL displayed
|
|
|
105
105
|
|
|
106
106
|
**Projects and Sessions**
|
|
107
107
|
|
|
108
|
-
* **Multi Project** - Single port management for all projects.
|
|
108
|
+
* **Multi Project** - Single port management for all projects. Add and remove projects from the browser with path autocomplete.
|
|
109
109
|
* **Project Names** - Custom names make it easy to distinguish tabs.
|
|
110
110
|
* **Session Persistence** - Sessions survive server restarts, browser crashes, and network drops.
|
|
111
|
-
* **Session Handoff** - Start in the terminal, continue on your phone, pass back to desktop.
|
|
111
|
+
* **Session Handoff** - Start in the terminal, continue on your phone, pass back to desktop. Browse and resume CLI sessions directly from the web UI.
|
|
112
112
|
* **Session Search** - Full-text search across all session content with highlighted results and a rewind-style hit timeline.
|
|
113
113
|
* **Rewind (Native Claude Code)** - Accessible directly from the browser UI.
|
|
114
114
|
* **Draft Persistence** - Unsent messages are saved per session and restored when you switch back.
|
|
@@ -133,6 +133,7 @@ Scan the QR code with your phone to connect instantly, or open the URL displayed
|
|
|
133
133
|
* **Send While Processing** - Queue messages without waiting for the current response to finish.
|
|
134
134
|
* **Sticky Todo Overlay** - TodoWrite tasks float as a progress bar while you scroll through the conversation.
|
|
135
135
|
* **Scroll Position Hold** - Reading earlier messages will not get interrupted by new content arriving.
|
|
136
|
+
* **RTL Text Support** - Automatic bidirectional text rendering for Arabic, Hebrew, and other RTL languages.
|
|
136
137
|
|
|
137
138
|
**Server and Security**
|
|
138
139
|
|
|
@@ -182,7 +183,7 @@ If push registration fails: check whether your browser trusts HTTPS and whether
|
|
|
182
183
|
```bash
|
|
183
184
|
npx claude-relay # Default (port 2633)
|
|
184
185
|
npx claude-relay -p 8080 # Specify port
|
|
185
|
-
npx claude-relay
|
|
186
|
+
npx claude-relay --yes # Skip interactive prompts (accept defaults)
|
|
186
187
|
npx claude-relay -y --pin 123456
|
|
187
188
|
# Non-interactive with PIN (for scripts/CI)
|
|
188
189
|
npx claude-relay --no-https # Disable HTTPS
|
|
@@ -195,6 +196,7 @@ npx claude-relay --list # List registered projects
|
|
|
195
196
|
npx claude-relay --shutdown # Stop the running daemon
|
|
196
197
|
npx claude-relay --dangerously-skip-permissions
|
|
197
198
|
# Bypass all permission prompts (PIN required during setup)
|
|
199
|
+
npx claude-relay --dev # Development mode (foreground, auto-restart on lib/ changes, port 2635)
|
|
198
200
|
```
|
|
199
201
|
|
|
200
202
|
## Requirements
|
|
@@ -204,6 +206,20 @@ npx claude-relay --dangerously-skip-permissions
|
|
|
204
206
|
* [mkcert](https://github.com/FiloSottile/mkcert) - For push notifications (optional)
|
|
205
207
|
* [Tailscale](https://tailscale.com) - For remote access (optional)
|
|
206
208
|
|
|
209
|
+
## Why claude-relay?
|
|
210
|
+
|
|
211
|
+
**Why not just use tmux + Termius?**
|
|
212
|
+
You can monitor the terminal remotely, but there are no push notifications and no way to approve permission requests without switching back to the terminal. On a phone, navigating a raw terminal session is clunky. You end up checking manually instead of getting notified, and the experience never feels native.
|
|
213
|
+
|
|
214
|
+
**Why not just add hooks to send notifications?**
|
|
215
|
+
Hooks with ntfy or Pushover can get you push notifications, but you still need shell scripts, config files, and third-party accounts just to get alerts. And once you get the notification, there is no UI to approve or reject from. You are back to opening a terminal. claude-relay gives you notifications, a one-tap approval UI, and a full web interface with a single command.
|
|
216
|
+
|
|
217
|
+
**Why not use Claude Code Desktop?**
|
|
218
|
+
The desktop app works great on your computer, but there is no mobile version. To access sessions from your phone, you need remote sessions on Anthropic's cloud, which requires pushing your code to GitHub first. claude-relay runs entirely on your machine and lets you connect from any device on your network.
|
|
219
|
+
|
|
220
|
+
**Why not use Happy Coder?**
|
|
221
|
+
Happy Coder requires installing a native app and routes through its own relay server with end-to-end encryption. claude-relay *is* the relay server, running on your machine. Open a URL and you are in. No app install, no signup, nothing leaves your network.
|
|
222
|
+
|
|
207
223
|
## Architecture
|
|
208
224
|
|
|
209
225
|
claude-relay is not a wrapper that intercepts standard input/output.
|
package/bin/cli.js
CHANGED
|
@@ -6,6 +6,13 @@ var path = require("path");
|
|
|
6
6
|
var { execSync, execFileSync, spawn } = require("child_process");
|
|
7
7
|
var qrcode = require("qrcode-terminal");
|
|
8
8
|
var net = require("net");
|
|
9
|
+
|
|
10
|
+
// Detect dev mode before loading config (env must be set before require)
|
|
11
|
+
var _isDev = (process.argv[1] && path.basename(process.argv[1]) === "claude-relay-dev") || process.argv.includes("--dev");
|
|
12
|
+
if (_isDev) {
|
|
13
|
+
process.env.CLAUDE_RELAY_HOME = path.join(os.homedir(), ".claude-relay-dev");
|
|
14
|
+
}
|
|
15
|
+
|
|
9
16
|
var { loadConfig, saveConfig, configPath, socketPath, logPath, ensureConfigDir, isDaemonAlive, isDaemonAliveAsync, generateSlug, clearStaleConfig, loadClayrc, saveClayrc, readCrashInfo } = require("../lib/config");
|
|
10
17
|
var { sendIPCCommand } = require("../lib/ipc");
|
|
11
18
|
var { generateAuthToken } = require("../lib/server");
|
|
@@ -22,7 +29,7 @@ function openUrl(url) {
|
|
|
22
29
|
}
|
|
23
30
|
|
|
24
31
|
var args = process.argv.slice(2);
|
|
25
|
-
var port = 2633;
|
|
32
|
+
var port = _isDev ? 2635 : 2633;
|
|
26
33
|
var useHttps = true;
|
|
27
34
|
var skipUpdate = false;
|
|
28
35
|
var debugMode = false;
|
|
@@ -33,6 +40,7 @@ var addPath = null;
|
|
|
33
40
|
var removePath = null;
|
|
34
41
|
var listMode = false;
|
|
35
42
|
var dangerouslySkipPermissions = false;
|
|
43
|
+
var headlessMode = false;
|
|
36
44
|
|
|
37
45
|
for (var i = 0; i < args.length; i++) {
|
|
38
46
|
if (args[i] === "-p" || args[i] === "--port") {
|
|
@@ -46,6 +54,8 @@ for (var i = 0; i < args.length; i++) {
|
|
|
46
54
|
useHttps = false;
|
|
47
55
|
} else if (args[i] === "--no-update" || args[i] === "--skip-update") {
|
|
48
56
|
skipUpdate = true;
|
|
57
|
+
} else if (args[i] === "--dev") {
|
|
58
|
+
// Already handled above for CLAUDE_RELAY_HOME, just skip
|
|
49
59
|
} else if (args[i] === "--debug") {
|
|
50
60
|
debugMode = true;
|
|
51
61
|
} else if (args[i] === "-y" || args[i] === "--yes") {
|
|
@@ -63,6 +73,9 @@ for (var i = 0; i < args.length; i++) {
|
|
|
63
73
|
i++;
|
|
64
74
|
} else if (args[i] === "--list") {
|
|
65
75
|
listMode = true;
|
|
76
|
+
} else if (args[i] === "--headless") {
|
|
77
|
+
headlessMode = true;
|
|
78
|
+
autoYes = true;
|
|
66
79
|
} else if (args[i] === "--dangerously-skip-permissions") {
|
|
67
80
|
dangerouslySkipPermissions = true;
|
|
68
81
|
} else if (args[i] === "-h" || args[i] === "--help") {
|
|
@@ -82,12 +95,19 @@ for (var i = 0; i < args.length; i++) {
|
|
|
82
95
|
console.log(" --add <path> Add a project directory (use '.' for current)");
|
|
83
96
|
console.log(" --remove <path> Remove a project directory");
|
|
84
97
|
console.log(" --list List all registered projects");
|
|
98
|
+
console.log(" --headless Start daemon and exit immediately (implies --yes)");
|
|
85
99
|
console.log(" --dangerously-skip-permissions");
|
|
86
100
|
console.log(" Bypass all permission prompts (requires --pin)");
|
|
87
101
|
process.exit(0);
|
|
88
102
|
}
|
|
89
103
|
}
|
|
90
104
|
|
|
105
|
+
// Dev mode implies debug + skip update
|
|
106
|
+
if (_isDev) {
|
|
107
|
+
debugMode = true;
|
|
108
|
+
skipUpdate = true;
|
|
109
|
+
}
|
|
110
|
+
|
|
91
111
|
// --- Handle --shutdown before anything else ---
|
|
92
112
|
if (shutdownMode) {
|
|
93
113
|
var shutdownConfig = loadConfig();
|
|
@@ -467,7 +487,7 @@ function getAllIPs() {
|
|
|
467
487
|
|
|
468
488
|
function ensureCerts(ip) {
|
|
469
489
|
var homeDir = os.homedir();
|
|
470
|
-
var certDir = path.join(homeDir, ".claude-relay", "certs");
|
|
490
|
+
var certDir = path.join(process.env.CLAUDE_RELAY_HOME || path.join(homeDir, ".claude-relay"), "certs");
|
|
471
491
|
var keyPath = path.join(certDir, "key.pem");
|
|
472
492
|
var certPath = path.join(certDir, "cert.pem");
|
|
473
493
|
|
|
@@ -1168,7 +1188,7 @@ function setup(callback) {
|
|
|
1168
1188
|
// ==============================
|
|
1169
1189
|
// Fork the daemon process
|
|
1170
1190
|
// ==============================
|
|
1171
|
-
async function forkDaemon(pin, keepAwake, extraProjects) {
|
|
1191
|
+
async function forkDaemon(pin, keepAwake, extraProjects, addCwd) {
|
|
1172
1192
|
var ip = getLocalIP();
|
|
1173
1193
|
var hasTls = false;
|
|
1174
1194
|
|
|
@@ -1190,12 +1210,18 @@ async function forkDaemon(pin, keepAwake, extraProjects) {
|
|
|
1190
1210
|
return;
|
|
1191
1211
|
}
|
|
1192
1212
|
|
|
1193
|
-
var
|
|
1194
|
-
var
|
|
1213
|
+
var allProjects = [];
|
|
1214
|
+
var usedSlugs = [];
|
|
1215
|
+
|
|
1216
|
+
// Only include cwd if explicitly requested
|
|
1217
|
+
if (addCwd) {
|
|
1218
|
+
var slug = generateSlug(cwd, []);
|
|
1219
|
+
allProjects.push({ path: cwd, slug: slug, addedAt: Date.now() });
|
|
1220
|
+
usedSlugs.push(slug);
|
|
1221
|
+
}
|
|
1195
1222
|
|
|
1196
1223
|
// Add restored projects (from ~/.clayrc)
|
|
1197
1224
|
if (extraProjects && extraProjects.length > 0) {
|
|
1198
|
-
var usedSlugs = [slug];
|
|
1199
1225
|
for (var ep = 0; ep < extraProjects.length; ep++) {
|
|
1200
1226
|
var rp = extraProjects[ep];
|
|
1201
1227
|
if (rp.path === cwd) continue; // skip if same as cwd
|
|
@@ -1253,10 +1279,165 @@ async function forkDaemon(pin, keepAwake, extraProjects) {
|
|
|
1253
1279
|
return;
|
|
1254
1280
|
}
|
|
1255
1281
|
|
|
1282
|
+
// Headless mode — print status and exit immediately
|
|
1283
|
+
if (headlessMode) {
|
|
1284
|
+
var protocol = config.tls ? "https" : "http";
|
|
1285
|
+
var url = protocol + "://" + ip + ":" + config.port;
|
|
1286
|
+
console.log(" " + sym.done + " Daemon started (PID " + config.pid + ")");
|
|
1287
|
+
console.log(" " + sym.done + " " + url);
|
|
1288
|
+
console.log(" " + sym.done + " Headless mode — exiting CLI");
|
|
1289
|
+
process.exit(0);
|
|
1290
|
+
return;
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1256
1293
|
// Show success + QR
|
|
1257
1294
|
showServerStarted(config, ip);
|
|
1258
1295
|
}
|
|
1259
1296
|
|
|
1297
|
+
// ==============================
|
|
1298
|
+
// Dev mode — foreground daemon with file watching
|
|
1299
|
+
// ==============================
|
|
1300
|
+
async function devMode(pin, keepAwake, existingPinHash) {
|
|
1301
|
+
var ip = getLocalIP();
|
|
1302
|
+
var hasTls = false;
|
|
1303
|
+
|
|
1304
|
+
if (useHttps) {
|
|
1305
|
+
var certPaths = ensureCerts(ip);
|
|
1306
|
+
if (certPaths) hasTls = true;
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
var portFree = await isPortFree(port);
|
|
1310
|
+
if (!portFree) {
|
|
1311
|
+
console.log("\x1b[31m[dev] Port " + port + " is already in use.\x1b[0m");
|
|
1312
|
+
process.exit(1);
|
|
1313
|
+
return;
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
var slug = generateSlug(cwd, []);
|
|
1317
|
+
var allProjects = [{ path: cwd, slug: slug, addedAt: Date.now() }];
|
|
1318
|
+
|
|
1319
|
+
// Restore previous projects
|
|
1320
|
+
var rc = loadClayrc();
|
|
1321
|
+
var restorable = (rc.recentProjects || []).filter(function (p) {
|
|
1322
|
+
return p.path !== cwd && fs.existsSync(p.path);
|
|
1323
|
+
});
|
|
1324
|
+
var usedSlugs = [slug];
|
|
1325
|
+
for (var ri = 0; ri < restorable.length; ri++) {
|
|
1326
|
+
var rp = restorable[ri];
|
|
1327
|
+
var rpSlug = generateSlug(rp.path, usedSlugs);
|
|
1328
|
+
usedSlugs.push(rpSlug);
|
|
1329
|
+
allProjects.push({ path: rp.path, slug: rpSlug, title: rp.title || undefined, addedAt: rp.addedAt || Date.now() });
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
var config = {
|
|
1333
|
+
pid: null,
|
|
1334
|
+
port: port,
|
|
1335
|
+
pinHash: existingPinHash || (pin ? generateAuthToken(pin) : null),
|
|
1336
|
+
tls: hasTls,
|
|
1337
|
+
debug: true,
|
|
1338
|
+
keepAwake: keepAwake || false,
|
|
1339
|
+
dangerouslySkipPermissions: dangerouslySkipPermissions,
|
|
1340
|
+
projects: allProjects,
|
|
1341
|
+
};
|
|
1342
|
+
|
|
1343
|
+
ensureConfigDir();
|
|
1344
|
+
saveConfig(config);
|
|
1345
|
+
|
|
1346
|
+
var daemonScript = path.join(__dirname, "..", "lib", "daemon.js");
|
|
1347
|
+
var libDir = path.join(__dirname, "..", "lib");
|
|
1348
|
+
var child = null;
|
|
1349
|
+
var intentionalKill = false;
|
|
1350
|
+
var debounceTimer = null;
|
|
1351
|
+
|
|
1352
|
+
function spawnDaemon() {
|
|
1353
|
+
child = spawn(process.execPath, [daemonScript], {
|
|
1354
|
+
stdio: ["ignore", "inherit", "inherit"],
|
|
1355
|
+
env: Object.assign({}, process.env, {
|
|
1356
|
+
CLAUDE_RELAY_CONFIG: configPath(),
|
|
1357
|
+
}),
|
|
1358
|
+
});
|
|
1359
|
+
|
|
1360
|
+
child.on("exit", function (code) {
|
|
1361
|
+
child = null;
|
|
1362
|
+
if (intentionalKill) {
|
|
1363
|
+
intentionalKill = false;
|
|
1364
|
+
return;
|
|
1365
|
+
}
|
|
1366
|
+
// Unexpected exit — auto restart
|
|
1367
|
+
console.log("\x1b[33m[dev] Daemon exited (code " + code + "), restarting...\x1b[0m");
|
|
1368
|
+
setTimeout(spawnDaemon, 500);
|
|
1369
|
+
});
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
function restartDaemon() {
|
|
1373
|
+
intentionalKill = true;
|
|
1374
|
+
if (child) {
|
|
1375
|
+
child.kill("SIGTERM");
|
|
1376
|
+
// Give it a moment to shut down, then spawn
|
|
1377
|
+
setTimeout(spawnDaemon, 300);
|
|
1378
|
+
} else {
|
|
1379
|
+
intentionalKill = false;
|
|
1380
|
+
spawnDaemon();
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
console.log("\x1b[36m[dev]\x1b[0m Starting relay on port " + port + "...");
|
|
1385
|
+
console.log("\x1b[36m[dev]\x1b[0m Watching lib/ for changes (excluding lib/public/)");
|
|
1386
|
+
console.log("");
|
|
1387
|
+
|
|
1388
|
+
spawnDaemon();
|
|
1389
|
+
|
|
1390
|
+
// Wait for daemon to be ready, then show CLI menu
|
|
1391
|
+
await new Promise(function (resolve) { setTimeout(resolve, 1000); });
|
|
1392
|
+
config.pid = child ? child.pid : null;
|
|
1393
|
+
saveConfig(config);
|
|
1394
|
+
|
|
1395
|
+
var daemonReady = await isDaemonAliveAsync(config);
|
|
1396
|
+
if (daemonReady) {
|
|
1397
|
+
showServerStarted(config, ip);
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
// Watch lib/ for server-side file changes
|
|
1401
|
+
var watcher = fs.watch(libDir, { recursive: true }, function (eventType, filename) {
|
|
1402
|
+
if (!filename) return;
|
|
1403
|
+
// Skip client-side files — they're served from disk
|
|
1404
|
+
if (filename.startsWith("public" + path.sep) || filename.startsWith("public/")) return;
|
|
1405
|
+
// Skip non-JS files
|
|
1406
|
+
if (!filename.endsWith(".js")) return;
|
|
1407
|
+
|
|
1408
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
1409
|
+
debounceTimer = setTimeout(function () {
|
|
1410
|
+
console.log("\x1b[36m[dev]\x1b[0m File changed: lib/" + filename);
|
|
1411
|
+
console.log("\x1b[36m[dev]\x1b[0m Restarting...");
|
|
1412
|
+
console.log("");
|
|
1413
|
+
restartDaemon();
|
|
1414
|
+
}, 300);
|
|
1415
|
+
});
|
|
1416
|
+
|
|
1417
|
+
// Clean exit on Ctrl+C
|
|
1418
|
+
var shuttingDown = false;
|
|
1419
|
+
process.on("SIGINT", function () {
|
|
1420
|
+
if (shuttingDown) return;
|
|
1421
|
+
shuttingDown = true;
|
|
1422
|
+
console.log("\n\x1b[36m[dev]\x1b[0m Shutting down...");
|
|
1423
|
+
watcher.close();
|
|
1424
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
1425
|
+
intentionalKill = true;
|
|
1426
|
+
if (child) {
|
|
1427
|
+
child.kill("SIGTERM");
|
|
1428
|
+
child.on("exit", function () {
|
|
1429
|
+
clearStaleConfig();
|
|
1430
|
+
process.exit(0);
|
|
1431
|
+
});
|
|
1432
|
+
// Force kill after 3s
|
|
1433
|
+
setTimeout(function () { process.exit(0); }, 3000);
|
|
1434
|
+
} else {
|
|
1435
|
+
clearStaleConfig();
|
|
1436
|
+
process.exit(0);
|
|
1437
|
+
}
|
|
1438
|
+
});
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1260
1441
|
// ==============================
|
|
1261
1442
|
// Restart daemon with TLS enabled
|
|
1262
1443
|
// ==============================
|
|
@@ -1991,6 +2172,28 @@ var currentVersion = require("../package.json").version;
|
|
|
1991
2172
|
var updated = await checkAndUpdate(currentVersion, skipUpdate);
|
|
1992
2173
|
if (updated) return;
|
|
1993
2174
|
|
|
2175
|
+
// Dev mode — foreground daemon with file watching
|
|
2176
|
+
if (_isDev) {
|
|
2177
|
+
var devConfig = loadConfig();
|
|
2178
|
+
var devAlive = devConfig ? await isDaemonAliveAsync(devConfig) : false;
|
|
2179
|
+
if (devAlive) {
|
|
2180
|
+
console.log("\x1b[36m[dev]\x1b[0m Shutting down existing daemon...");
|
|
2181
|
+
await sendIPCCommand(socketPath(), { cmd: "shutdown" });
|
|
2182
|
+
clearStaleConfig();
|
|
2183
|
+
await new Promise(function (resolve) { setTimeout(resolve, 500); });
|
|
2184
|
+
}
|
|
2185
|
+
// First run — go through setup (disclaimer, port, PIN, etc.)
|
|
2186
|
+
if (!devConfig) {
|
|
2187
|
+
setup(function (pin, keepAwake) {
|
|
2188
|
+
devMode(pin, keepAwake, null);
|
|
2189
|
+
});
|
|
2190
|
+
} else {
|
|
2191
|
+
// Reuse existing PIN hash from previous config
|
|
2192
|
+
await devMode(cliPin || null, devConfig.keepAwake || false, devConfig.pinHash || null);
|
|
2193
|
+
}
|
|
2194
|
+
return;
|
|
2195
|
+
}
|
|
2196
|
+
|
|
1994
2197
|
var config = loadConfig();
|
|
1995
2198
|
var alive = config ? await isDaemonAliveAsync(config) : false;
|
|
1996
2199
|
|
|
@@ -2072,7 +2275,8 @@ var currentVersion = require("../package.json").version;
|
|
|
2072
2275
|
if (autoRestorable.length > 0) {
|
|
2073
2276
|
console.log(" " + sym.done + " Restoring " + autoRestorable.length + " previous project(s)");
|
|
2074
2277
|
}
|
|
2075
|
-
|
|
2278
|
+
var hasRestorable = autoRestorable.length > 0;
|
|
2279
|
+
await forkDaemon(pin, false, hasRestorable ? autoRestorable : undefined, !hasRestorable);
|
|
2076
2280
|
} else {
|
|
2077
2281
|
setup(function (pin, keepAwake) {
|
|
2078
2282
|
if (dangerouslySkipPermissions && !pin) {
|
|
@@ -2081,6 +2285,7 @@ var currentVersion = require("../package.json").version;
|
|
|
2081
2285
|
process.exit(1);
|
|
2082
2286
|
return;
|
|
2083
2287
|
}
|
|
2288
|
+
|
|
2084
2289
|
// Check ~/.clayrc for previous projects to restore
|
|
2085
2290
|
var rc = loadClayrc();
|
|
2086
2291
|
var restorable = (rc.recentProjects || []).filter(function (p) {
|
|
@@ -2089,13 +2294,13 @@ var currentVersion = require("../package.json").version;
|
|
|
2089
2294
|
|
|
2090
2295
|
if (restorable.length > 0) {
|
|
2091
2296
|
promptRestoreProjects(restorable, function (selected) {
|
|
2092
|
-
forkDaemon(pin, keepAwake, selected);
|
|
2297
|
+
forkDaemon(pin, keepAwake, selected, false);
|
|
2093
2298
|
});
|
|
2094
2299
|
} else {
|
|
2095
2300
|
log(sym.bar);
|
|
2096
2301
|
log(sym.end + " " + a.dim + "Starting relay..." + a.reset);
|
|
2097
2302
|
log("");
|
|
2098
|
-
forkDaemon(pin, keepAwake);
|
|
2303
|
+
forkDaemon(pin, keepAwake, undefined, true);
|
|
2099
2304
|
}
|
|
2100
2305
|
});
|
|
2101
2306
|
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
var fs = require("fs");
|
|
2
|
+
var path = require("path");
|
|
3
|
+
var os = require("os");
|
|
4
|
+
var readline = require("readline");
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Compute the encoded project directory name used by the Claude CLI.
|
|
8
|
+
* Replaces all "/" with "-", e.g. "/Users/foo/project" -> "-Users-foo-project"
|
|
9
|
+
*/
|
|
10
|
+
function encodeCwd(cwd) {
|
|
11
|
+
return cwd.replace(/\//g, "-");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Parse the first ~20 lines of a CLI session JSONL file to extract metadata.
|
|
16
|
+
* Returns null if the file can't be parsed or has no user messages.
|
|
17
|
+
*/
|
|
18
|
+
function parseSessionFile(filePath, maxLines) {
|
|
19
|
+
if (maxLines == null) maxLines = 20;
|
|
20
|
+
return new Promise(function (resolve) {
|
|
21
|
+
var sessionId = path.basename(filePath, ".jsonl");
|
|
22
|
+
var result = {
|
|
23
|
+
sessionId: sessionId,
|
|
24
|
+
firstPrompt: "",
|
|
25
|
+
model: null,
|
|
26
|
+
gitBranch: null,
|
|
27
|
+
startTime: null,
|
|
28
|
+
lastActivity: null,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
var lineCount = 0;
|
|
32
|
+
var foundUser = false;
|
|
33
|
+
var stream;
|
|
34
|
+
try {
|
|
35
|
+
stream = fs.createReadStream(filePath, { encoding: "utf8" });
|
|
36
|
+
} catch (e) {
|
|
37
|
+
return resolve(null);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
var rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
41
|
+
|
|
42
|
+
rl.on("line", function (line) {
|
|
43
|
+
lineCount++;
|
|
44
|
+
if (lineCount > maxLines) {
|
|
45
|
+
rl.close();
|
|
46
|
+
stream.destroy();
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
var obj;
|
|
51
|
+
try { obj = JSON.parse(line); } catch (e) { return; }
|
|
52
|
+
|
|
53
|
+
// Skip file-history-snapshot, queue-operation, and other non-message records
|
|
54
|
+
if (obj.type === "user" && obj.message && obj.message.role === "user") {
|
|
55
|
+
if (!foundUser) {
|
|
56
|
+
foundUser = true;
|
|
57
|
+
result.sessionId = obj.sessionId || sessionId;
|
|
58
|
+
result.gitBranch = obj.gitBranch || null;
|
|
59
|
+
if (obj.timestamp) result.startTime = obj.timestamp;
|
|
60
|
+
var content = obj.message.content || "";
|
|
61
|
+
if (typeof content === "string") {
|
|
62
|
+
result.firstPrompt = content.substring(0, 100);
|
|
63
|
+
} else if (Array.isArray(content)) {
|
|
64
|
+
for (var i = 0; i < content.length; i++) {
|
|
65
|
+
if (content[i].type === "text" && content[i].text) {
|
|
66
|
+
result.firstPrompt = content[i].text.substring(0, 100);
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// Track latest user timestamp for lastActivity
|
|
73
|
+
if (obj.timestamp) result.lastActivity = obj.timestamp;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Extract model from first assistant message
|
|
77
|
+
if (!result.model && obj.message && obj.message.role === "assistant" && obj.message.model) {
|
|
78
|
+
result.model = obj.message.model;
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
rl.on("close", function () {
|
|
83
|
+
if (!foundUser) return resolve(null);
|
|
84
|
+
|
|
85
|
+
// Use file mtime as fallback for lastActivity, or as a better proxy
|
|
86
|
+
// since we only read the first ~20 lines
|
|
87
|
+
try {
|
|
88
|
+
var stat = fs.statSync(filePath);
|
|
89
|
+
var mtime = stat.mtime.toISOString();
|
|
90
|
+
// File mtime is always more accurate for "last activity" since we
|
|
91
|
+
// don't read the entire file
|
|
92
|
+
result.lastActivity = mtime;
|
|
93
|
+
} catch (e) {}
|
|
94
|
+
|
|
95
|
+
resolve(result);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
rl.on("error", function () {
|
|
99
|
+
resolve(null);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
stream.on("error", function () {
|
|
103
|
+
rl.close();
|
|
104
|
+
resolve(null);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* List CLI sessions for a given project directory.
|
|
111
|
+
* Reads ~/.claude/projects/{encoded-cwd}/ and parses JSONL metadata.
|
|
112
|
+
* Returns array sorted by lastActivity descending (most recent first).
|
|
113
|
+
*/
|
|
114
|
+
function listCliSessions(cwd) {
|
|
115
|
+
var encoded = encodeCwd(cwd);
|
|
116
|
+
var projectDir = path.join(os.homedir(), ".claude", "projects", encoded);
|
|
117
|
+
|
|
118
|
+
return new Promise(function (resolve) {
|
|
119
|
+
fs.readdir(projectDir, { withFileTypes: true }, function (err, entries) {
|
|
120
|
+
if (err) return resolve([]);
|
|
121
|
+
|
|
122
|
+
var jsonlFiles = [];
|
|
123
|
+
for (var i = 0; i < entries.length; i++) {
|
|
124
|
+
if (entries[i].isFile() && entries[i].name.endsWith(".jsonl")) {
|
|
125
|
+
jsonlFiles.push(path.join(projectDir, entries[i].name));
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (jsonlFiles.length === 0) return resolve([]);
|
|
130
|
+
|
|
131
|
+
var pending = jsonlFiles.length;
|
|
132
|
+
var results = [];
|
|
133
|
+
|
|
134
|
+
for (var j = 0; j < jsonlFiles.length; j++) {
|
|
135
|
+
parseSessionFile(jsonlFiles[j]).then(function (session) {
|
|
136
|
+
if (session) results.push(session);
|
|
137
|
+
pending--;
|
|
138
|
+
if (pending === 0) {
|
|
139
|
+
results.sort(function (a, b) {
|
|
140
|
+
var ta = a.lastActivity || "";
|
|
141
|
+
var tb = b.lastActivity || "";
|
|
142
|
+
return ta < tb ? 1 : ta > tb ? -1 : 0;
|
|
143
|
+
});
|
|
144
|
+
resolve(results);
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Get the most recent CLI session for a given project directory.
|
|
154
|
+
* Returns the session object or null if none found.
|
|
155
|
+
*/
|
|
156
|
+
function getMostRecentCliSession(cwd) {
|
|
157
|
+
return listCliSessions(cwd).then(function (sessions) {
|
|
158
|
+
return sessions.length > 0 ? sessions[0] : null;
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Extract user message text from a CLI JSONL content field.
|
|
164
|
+
* Content can be a string or an array of content blocks.
|
|
165
|
+
*/
|
|
166
|
+
function extractText(content) {
|
|
167
|
+
if (typeof content === "string") return content;
|
|
168
|
+
if (!Array.isArray(content)) return "";
|
|
169
|
+
var parts = [];
|
|
170
|
+
for (var i = 0; i < content.length; i++) {
|
|
171
|
+
if (content[i].type === "text" && content[i].text) {
|
|
172
|
+
parts.push(content[i].text);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return parts.join("");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Read a full CLI session JSONL file and convert it to relay-compatible
|
|
180
|
+
* history entries (user_message, delta, tool_start, tool_executing, tool_result).
|
|
181
|
+
* Returns a Promise that resolves to an array of history entries.
|
|
182
|
+
*/
|
|
183
|
+
function readCliSessionHistory(cwd, sessionId) {
|
|
184
|
+
var encoded = encodeCwd(cwd);
|
|
185
|
+
var filePath = path.join(os.homedir(), ".claude", "projects", encoded, sessionId + ".jsonl");
|
|
186
|
+
|
|
187
|
+
return new Promise(function (resolve) {
|
|
188
|
+
var history = [];
|
|
189
|
+
var stream;
|
|
190
|
+
try {
|
|
191
|
+
stream = fs.createReadStream(filePath, { encoding: "utf8" });
|
|
192
|
+
} catch (e) {
|
|
193
|
+
return resolve([]);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
var rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
197
|
+
var toolCounter = 0;
|
|
198
|
+
|
|
199
|
+
rl.on("line", function (line) {
|
|
200
|
+
var obj;
|
|
201
|
+
try { obj = JSON.parse(line); } catch (e) { return; }
|
|
202
|
+
|
|
203
|
+
if (!obj.message) return;
|
|
204
|
+
|
|
205
|
+
// User prompt
|
|
206
|
+
if (obj.type === "user" && obj.message.role === "user") {
|
|
207
|
+
// Skip tool_result records (they have type "user" but content is tool results)
|
|
208
|
+
var content = obj.message.content;
|
|
209
|
+
if (Array.isArray(content) && content.length > 0 && content[0].type === "tool_result") {
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
var text = extractText(content);
|
|
213
|
+
if (text) {
|
|
214
|
+
history.push({ type: "user_message", text: text });
|
|
215
|
+
}
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Assistant message
|
|
220
|
+
if (obj.message.role === "assistant" && Array.isArray(obj.message.content)) {
|
|
221
|
+
for (var i = 0; i < obj.message.content.length; i++) {
|
|
222
|
+
var block = obj.message.content[i];
|
|
223
|
+
|
|
224
|
+
if (block.type === "text" && block.text) {
|
|
225
|
+
history.push({ type: "delta", text: block.text });
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (block.type === "tool_use") {
|
|
229
|
+
var toolId = "cli-tool-" + (++toolCounter);
|
|
230
|
+
var toolName = block.name || "Tool";
|
|
231
|
+
history.push({ type: "tool_start", id: toolId, name: toolName });
|
|
232
|
+
history.push({
|
|
233
|
+
type: "tool_executing",
|
|
234
|
+
id: toolId,
|
|
235
|
+
name: toolName,
|
|
236
|
+
input: block.input || {},
|
|
237
|
+
});
|
|
238
|
+
// Emit ask_user_answered so the client re-enables input after replaying AskUserQuestion
|
|
239
|
+
if (toolName === "AskUserQuestion") {
|
|
240
|
+
history.push({ type: "ask_user_answered", toolId: toolId });
|
|
241
|
+
}
|
|
242
|
+
history.push({ type: "tool_result", id: toolId, content: "" });
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
rl.on("close", function () {
|
|
249
|
+
resolve(history);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
rl.on("error", function () {
|
|
253
|
+
resolve([]);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
stream.on("error", function () {
|
|
257
|
+
rl.close();
|
|
258
|
+
resolve([]);
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
module.exports = {
|
|
264
|
+
listCliSessions: listCliSessions,
|
|
265
|
+
getMostRecentCliSession: getMostRecentCliSession,
|
|
266
|
+
readCliSessionHistory: readCliSessionHistory,
|
|
267
|
+
parseSessionFile: parseSessionFile,
|
|
268
|
+
encodeCwd: encodeCwd,
|
|
269
|
+
extractText: extractText,
|
|
270
|
+
};
|