claude-relay 2.3.1 → 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 +20 -5
- package/bin/cli.js +206 -8
- package/lib/cli-sessions.js +270 -0
- package/lib/daemon.js +40 -0
- package/lib/project.js +121 -1
- package/lib/public/app.js +385 -76
- package/lib/public/css/base.css +41 -7
- package/lib/public/css/diff.css +6 -6
- package/lib/public/css/filebrowser.css +34 -54
- package/lib/public/css/highlight.css +144 -0
- package/lib/public/css/input.css +9 -9
- package/lib/public/css/menus.css +82 -23
- package/lib/public/css/messages.css +178 -34
- 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 +73 -40
- 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 +11 -23
- package/lib/public/modules/theme.js +622 -0
- package/lib/public/modules/tools.js +245 -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 +41 -0
- 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.
|
|
@@ -183,7 +183,7 @@ If push registration fails: check whether your browser trusts HTTPS and whether
|
|
|
183
183
|
```bash
|
|
184
184
|
npx claude-relay # Default (port 2633)
|
|
185
185
|
npx claude-relay -p 8080 # Specify port
|
|
186
|
-
npx claude-relay
|
|
186
|
+
npx claude-relay --yes # Skip interactive prompts (accept defaults)
|
|
187
187
|
npx claude-relay -y --pin 123456
|
|
188
188
|
# Non-interactive with PIN (for scripts/CI)
|
|
189
189
|
npx claude-relay --no-https # Disable HTTPS
|
|
@@ -196,6 +196,7 @@ npx claude-relay --list # List registered projects
|
|
|
196
196
|
npx claude-relay --shutdown # Stop the running daemon
|
|
197
197
|
npx claude-relay --dangerously-skip-permissions
|
|
198
198
|
# Bypass all permission prompts (PIN required during setup)
|
|
199
|
+
npx claude-relay --dev # Development mode (foreground, auto-restart on lib/ changes, port 2635)
|
|
199
200
|
```
|
|
200
201
|
|
|
201
202
|
## Requirements
|
|
@@ -205,6 +206,20 @@ npx claude-relay --dangerously-skip-permissions
|
|
|
205
206
|
* [mkcert](https://github.com/FiloSottile/mkcert) - For push notifications (optional)
|
|
206
207
|
* [Tailscale](https://tailscale.com) - For remote access (optional)
|
|
207
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
|
+
|
|
208
223
|
## Architecture
|
|
209
224
|
|
|
210
225
|
claude-relay is not a wrapper that intercepts standard input/output.
|
package/bin/cli.js
CHANGED
|
@@ -8,7 +8,7 @@ var qrcode = require("qrcode-terminal");
|
|
|
8
8
|
var net = require("net");
|
|
9
9
|
|
|
10
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";
|
|
11
|
+
var _isDev = (process.argv[1] && path.basename(process.argv[1]) === "claude-relay-dev") || process.argv.includes("--dev");
|
|
12
12
|
if (_isDev) {
|
|
13
13
|
process.env.CLAUDE_RELAY_HOME = path.join(os.homedir(), ".claude-relay-dev");
|
|
14
14
|
}
|
|
@@ -40,6 +40,7 @@ var addPath = null;
|
|
|
40
40
|
var removePath = null;
|
|
41
41
|
var listMode = false;
|
|
42
42
|
var dangerouslySkipPermissions = false;
|
|
43
|
+
var headlessMode = false;
|
|
43
44
|
|
|
44
45
|
for (var i = 0; i < args.length; i++) {
|
|
45
46
|
if (args[i] === "-p" || args[i] === "--port") {
|
|
@@ -53,6 +54,8 @@ for (var i = 0; i < args.length; i++) {
|
|
|
53
54
|
useHttps = false;
|
|
54
55
|
} else if (args[i] === "--no-update" || args[i] === "--skip-update") {
|
|
55
56
|
skipUpdate = true;
|
|
57
|
+
} else if (args[i] === "--dev") {
|
|
58
|
+
// Already handled above for CLAUDE_RELAY_HOME, just skip
|
|
56
59
|
} else if (args[i] === "--debug") {
|
|
57
60
|
debugMode = true;
|
|
58
61
|
} else if (args[i] === "-y" || args[i] === "--yes") {
|
|
@@ -70,6 +73,9 @@ for (var i = 0; i < args.length; i++) {
|
|
|
70
73
|
i++;
|
|
71
74
|
} else if (args[i] === "--list") {
|
|
72
75
|
listMode = true;
|
|
76
|
+
} else if (args[i] === "--headless") {
|
|
77
|
+
headlessMode = true;
|
|
78
|
+
autoYes = true;
|
|
73
79
|
} else if (args[i] === "--dangerously-skip-permissions") {
|
|
74
80
|
dangerouslySkipPermissions = true;
|
|
75
81
|
} else if (args[i] === "-h" || args[i] === "--help") {
|
|
@@ -89,12 +95,19 @@ for (var i = 0; i < args.length; i++) {
|
|
|
89
95
|
console.log(" --add <path> Add a project directory (use '.' for current)");
|
|
90
96
|
console.log(" --remove <path> Remove a project directory");
|
|
91
97
|
console.log(" --list List all registered projects");
|
|
98
|
+
console.log(" --headless Start daemon and exit immediately (implies --yes)");
|
|
92
99
|
console.log(" --dangerously-skip-permissions");
|
|
93
100
|
console.log(" Bypass all permission prompts (requires --pin)");
|
|
94
101
|
process.exit(0);
|
|
95
102
|
}
|
|
96
103
|
}
|
|
97
104
|
|
|
105
|
+
// Dev mode implies debug + skip update
|
|
106
|
+
if (_isDev) {
|
|
107
|
+
debugMode = true;
|
|
108
|
+
skipUpdate = true;
|
|
109
|
+
}
|
|
110
|
+
|
|
98
111
|
// --- Handle --shutdown before anything else ---
|
|
99
112
|
if (shutdownMode) {
|
|
100
113
|
var shutdownConfig = loadConfig();
|
|
@@ -1175,7 +1188,7 @@ function setup(callback) {
|
|
|
1175
1188
|
// ==============================
|
|
1176
1189
|
// Fork the daemon process
|
|
1177
1190
|
// ==============================
|
|
1178
|
-
async function forkDaemon(pin, keepAwake, extraProjects) {
|
|
1191
|
+
async function forkDaemon(pin, keepAwake, extraProjects, addCwd) {
|
|
1179
1192
|
var ip = getLocalIP();
|
|
1180
1193
|
var hasTls = false;
|
|
1181
1194
|
|
|
@@ -1197,12 +1210,18 @@ async function forkDaemon(pin, keepAwake, extraProjects) {
|
|
|
1197
1210
|
return;
|
|
1198
1211
|
}
|
|
1199
1212
|
|
|
1200
|
-
var
|
|
1201
|
-
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
|
+
}
|
|
1202
1222
|
|
|
1203
1223
|
// Add restored projects (from ~/.clayrc)
|
|
1204
1224
|
if (extraProjects && extraProjects.length > 0) {
|
|
1205
|
-
var usedSlugs = [slug];
|
|
1206
1225
|
for (var ep = 0; ep < extraProjects.length; ep++) {
|
|
1207
1226
|
var rp = extraProjects[ep];
|
|
1208
1227
|
if (rp.path === cwd) continue; // skip if same as cwd
|
|
@@ -1260,10 +1279,165 @@ async function forkDaemon(pin, keepAwake, extraProjects) {
|
|
|
1260
1279
|
return;
|
|
1261
1280
|
}
|
|
1262
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
|
+
|
|
1263
1293
|
// Show success + QR
|
|
1264
1294
|
showServerStarted(config, ip);
|
|
1265
1295
|
}
|
|
1266
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
|
+
|
|
1267
1441
|
// ==============================
|
|
1268
1442
|
// Restart daemon with TLS enabled
|
|
1269
1443
|
// ==============================
|
|
@@ -1998,6 +2172,28 @@ var currentVersion = require("../package.json").version;
|
|
|
1998
2172
|
var updated = await checkAndUpdate(currentVersion, skipUpdate);
|
|
1999
2173
|
if (updated) return;
|
|
2000
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
|
+
|
|
2001
2197
|
var config = loadConfig();
|
|
2002
2198
|
var alive = config ? await isDaemonAliveAsync(config) : false;
|
|
2003
2199
|
|
|
@@ -2079,7 +2275,8 @@ var currentVersion = require("../package.json").version;
|
|
|
2079
2275
|
if (autoRestorable.length > 0) {
|
|
2080
2276
|
console.log(" " + sym.done + " Restoring " + autoRestorable.length + " previous project(s)");
|
|
2081
2277
|
}
|
|
2082
|
-
|
|
2278
|
+
var hasRestorable = autoRestorable.length > 0;
|
|
2279
|
+
await forkDaemon(pin, false, hasRestorable ? autoRestorable : undefined, !hasRestorable);
|
|
2083
2280
|
} else {
|
|
2084
2281
|
setup(function (pin, keepAwake) {
|
|
2085
2282
|
if (dangerouslySkipPermissions && !pin) {
|
|
@@ -2088,6 +2285,7 @@ var currentVersion = require("../package.json").version;
|
|
|
2088
2285
|
process.exit(1);
|
|
2089
2286
|
return;
|
|
2090
2287
|
}
|
|
2288
|
+
|
|
2091
2289
|
// Check ~/.clayrc for previous projects to restore
|
|
2092
2290
|
var rc = loadClayrc();
|
|
2093
2291
|
var restorable = (rc.recentProjects || []).filter(function (p) {
|
|
@@ -2096,13 +2294,13 @@ var currentVersion = require("../package.json").version;
|
|
|
2096
2294
|
|
|
2097
2295
|
if (restorable.length > 0) {
|
|
2098
2296
|
promptRestoreProjects(restorable, function (selected) {
|
|
2099
|
-
forkDaemon(pin, keepAwake, selected);
|
|
2297
|
+
forkDaemon(pin, keepAwake, selected, false);
|
|
2100
2298
|
});
|
|
2101
2299
|
} else {
|
|
2102
2300
|
log(sym.bar);
|
|
2103
2301
|
log(sym.end + " " + a.dim + "Starting relay..." + a.reset);
|
|
2104
2302
|
log("");
|
|
2105
|
-
forkDaemon(pin, keepAwake);
|
|
2303
|
+
forkDaemon(pin, keepAwake, undefined, true);
|
|
2106
2304
|
}
|
|
2107
2305
|
});
|
|
2108
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
|
+
};
|
package/lib/daemon.js
CHANGED
|
@@ -73,6 +73,46 @@ var relay = createServer({
|
|
|
73
73
|
debug: config.debug || false,
|
|
74
74
|
dangerouslySkipPermissions: config.dangerouslySkipPermissions || false,
|
|
75
75
|
lanHost: lanIp ? lanIp + ":" + config.port : null,
|
|
76
|
+
onAddProject: function (absPath) {
|
|
77
|
+
// Check if already registered
|
|
78
|
+
for (var j = 0; j < config.projects.length; j++) {
|
|
79
|
+
if (config.projects[j].path === absPath) {
|
|
80
|
+
return { ok: true, slug: config.projects[j].slug, existing: true };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
var slugs = config.projects.map(function (p) { return p.slug; });
|
|
84
|
+
var slug = generateSlug(absPath, slugs);
|
|
85
|
+
relay.addProject(absPath, slug);
|
|
86
|
+
config.projects.push({ path: absPath, slug: slug, addedAt: Date.now() });
|
|
87
|
+
saveConfig(config);
|
|
88
|
+
try { syncClayrc(config.projects); } catch (e) {}
|
|
89
|
+
console.log("[daemon] Added project (web):", slug, "→", absPath);
|
|
90
|
+
// Broadcast updated project list to all clients
|
|
91
|
+
relay.broadcastAll({
|
|
92
|
+
type: "projects_updated",
|
|
93
|
+
projects: relay.getProjects(),
|
|
94
|
+
projectCount: config.projects.length,
|
|
95
|
+
});
|
|
96
|
+
return { ok: true, slug: slug };
|
|
97
|
+
},
|
|
98
|
+
onRemoveProject: function (slug) {
|
|
99
|
+
var found = false;
|
|
100
|
+
for (var j = 0; j < config.projects.length; j++) {
|
|
101
|
+
if (config.projects[j].slug === slug) { found = true; break; }
|
|
102
|
+
}
|
|
103
|
+
if (!found) return { ok: false, error: "Project not found" };
|
|
104
|
+
relay.removeProject(slug);
|
|
105
|
+
config.projects = config.projects.filter(function (p) { return p.slug !== slug; });
|
|
106
|
+
saveConfig(config);
|
|
107
|
+
try { syncClayrc(config.projects); } catch (e) {}
|
|
108
|
+
console.log("[daemon] Removed project (web):", slug);
|
|
109
|
+
relay.broadcastAll({
|
|
110
|
+
type: "projects_updated",
|
|
111
|
+
projects: relay.getProjects(),
|
|
112
|
+
projectCount: config.projects.length,
|
|
113
|
+
});
|
|
114
|
+
return { ok: true };
|
|
115
|
+
},
|
|
76
116
|
});
|
|
77
117
|
|
|
78
118
|
// --- Register projects ---
|