claude-relay 2.3.1 → 2.4.1

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.
Files changed (53) hide show
  1. package/README.md +20 -5
  2. package/bin/cli.js +261 -20
  3. package/lib/cli-sessions.js +270 -0
  4. package/lib/daemon.js +146 -37
  5. package/lib/project.js +129 -1
  6. package/lib/public/app.js +403 -76
  7. package/lib/public/css/base.css +41 -7
  8. package/lib/public/css/diff.css +6 -6
  9. package/lib/public/css/filebrowser.css +41 -56
  10. package/lib/public/css/highlight.css +144 -0
  11. package/lib/public/css/input.css +9 -9
  12. package/lib/public/css/menus.css +82 -23
  13. package/lib/public/css/messages.css +178 -34
  14. package/lib/public/css/overlays.css +184 -51
  15. package/lib/public/css/rewind.css +17 -17
  16. package/lib/public/css/sidebar.css +225 -145
  17. package/lib/public/index.html +75 -43
  18. package/lib/public/modules/filebrowser.js +30 -12
  19. package/lib/public/modules/fileicons.js +172 -0
  20. package/lib/public/modules/markdown.js +10 -10
  21. package/lib/public/modules/notifications.js +52 -2
  22. package/lib/public/modules/sidebar.js +109 -31
  23. package/lib/public/modules/terminal.js +11 -23
  24. package/lib/public/modules/theme.js +622 -0
  25. package/lib/public/modules/tools.js +245 -4
  26. package/lib/public/modules/utils.js +21 -5
  27. package/lib/public/style.css +1 -0
  28. package/lib/sdk-bridge.js +95 -0
  29. package/lib/server.js +42 -1
  30. package/lib/sessions.js +57 -5
  31. package/lib/themes/ayu-light.json +9 -0
  32. package/lib/themes/catppuccin-latte.json +9 -0
  33. package/lib/themes/catppuccin-mocha.json +9 -0
  34. package/lib/themes/claude-light.json +9 -0
  35. package/lib/themes/claude.json +9 -0
  36. package/lib/themes/dracula.json +9 -0
  37. package/lib/themes/everforest-light.json +9 -0
  38. package/lib/themes/everforest.json +9 -0
  39. package/lib/themes/github-light.json +9 -0
  40. package/lib/themes/gruvbox-dark.json +9 -0
  41. package/lib/themes/gruvbox-light.json +9 -0
  42. package/lib/themes/monokai.json +9 -0
  43. package/lib/themes/nord-light.json +9 -0
  44. package/lib/themes/nord.json +9 -0
  45. package/lib/themes/one-dark.json +9 -0
  46. package/lib/themes/one-light.json +9 -0
  47. package/lib/themes/rose-pine-dawn.json +9 -0
  48. package/lib/themes/rose-pine.json +9 -0
  49. package/lib/themes/solarized-dark.json +9 -0
  50. package/lib/themes/solarized-light.json +9 -0
  51. package/lib/themes/tokyo-night-light.json +9 -0
  52. package/lib/themes/tokyo-night.json +9 -0
  53. 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
- [![npm version](https://img.shields.io/npm/v/claude-relay)](https://www.npmjs.com/package/claude-relay) [![npm downloads](https://img.shields.io/npm/dw/claude-relay)](https://www.npmjs.com/package/claude-relay) [![GitHub stars](https://img.shields.io/github/stars/chadbyte/claude-relay)](https://github.com/chadbyte/claude-relay)
9
+ [![npm version](https://img.shields.io/npm/v/claude-relay)](https://www.npmjs.com/package/claude-relay) [![npm downloads](https://img.shields.io/npm/dw/claude-relay)](https://www.npmjs.com/package/claude-relay) [![GitHub stars](https://img.shields.io/github/stars/chadbyte/claude-relay)](https://github.com/chadbyte/claude-relay) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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 -y # Skip interactive prompts (accept defaults)
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,8 @@ var addPath = null;
40
40
  var removePath = null;
41
41
  var listMode = false;
42
42
  var dangerouslySkipPermissions = false;
43
+ var headlessMode = false;
44
+ var watchMode = false;
43
45
 
44
46
  for (var i = 0; i < args.length; i++) {
45
47
  if (args[i] === "-p" || args[i] === "--port") {
@@ -53,6 +55,10 @@ for (var i = 0; i < args.length; i++) {
53
55
  useHttps = false;
54
56
  } else if (args[i] === "--no-update" || args[i] === "--skip-update") {
55
57
  skipUpdate = true;
58
+ } else if (args[i] === "--dev") {
59
+ // Already handled above for CLAUDE_RELAY_HOME, just skip
60
+ } else if (args[i] === "--watch" || args[i] === "-w") {
61
+ watchMode = true;
56
62
  } else if (args[i] === "--debug") {
57
63
  debugMode = true;
58
64
  } else if (args[i] === "-y" || args[i] === "--yes") {
@@ -70,6 +76,9 @@ for (var i = 0; i < args.length; i++) {
70
76
  i++;
71
77
  } else if (args[i] === "--list") {
72
78
  listMode = true;
79
+ } else if (args[i] === "--headless") {
80
+ headlessMode = true;
81
+ autoYes = true;
73
82
  } else if (args[i] === "--dangerously-skip-permissions") {
74
83
  dangerouslySkipPermissions = true;
75
84
  } else if (args[i] === "-h" || args[i] === "--help") {
@@ -89,12 +98,19 @@ for (var i = 0; i < args.length; i++) {
89
98
  console.log(" --add <path> Add a project directory (use '.' for current)");
90
99
  console.log(" --remove <path> Remove a project directory");
91
100
  console.log(" --list List all registered projects");
101
+ console.log(" --headless Start daemon and exit immediately (implies --yes)");
92
102
  console.log(" --dangerously-skip-permissions");
93
103
  console.log(" Bypass all permission prompts (requires --pin)");
94
104
  process.exit(0);
95
105
  }
96
106
  }
97
107
 
108
+ // Dev mode implies debug + skip update
109
+ if (_isDev) {
110
+ debugMode = true;
111
+ skipUpdate = true;
112
+ }
113
+
98
114
  // --- Handle --shutdown before anything else ---
99
115
  if (shutdownMode) {
100
116
  var shutdownConfig = loadConfig();
@@ -393,9 +409,13 @@ async function restartDaemonFromConfig() {
393
409
  newConfig.pid = child.pid;
394
410
  saveConfig(newConfig);
395
411
 
396
- // Wait and verify
397
- await new Promise(function (resolve) { setTimeout(resolve, 1200); });
398
- var alive = await isDaemonAliveAsync(newConfig);
412
+ // Wait and verify (retry up to 5 seconds)
413
+ var alive = false;
414
+ for (var rc = 0; rc < 10; rc++) {
415
+ await new Promise(function (resolve) { setTimeout(resolve, 500); });
416
+ alive = await isDaemonAliveAsync(newConfig);
417
+ if (alive) break;
418
+ }
399
419
  if (!alive) {
400
420
  log(a.red + " Restart failed. Check logs: " + a.reset + logFile);
401
421
  process.exit(1);
@@ -1175,7 +1195,7 @@ function setup(callback) {
1175
1195
  // ==============================
1176
1196
  // Fork the daemon process
1177
1197
  // ==============================
1178
- async function forkDaemon(pin, keepAwake, extraProjects) {
1198
+ async function forkDaemon(pin, keepAwake, extraProjects, addCwd) {
1179
1199
  var ip = getLocalIP();
1180
1200
  var hasTls = false;
1181
1201
 
@@ -1197,12 +1217,18 @@ async function forkDaemon(pin, keepAwake, extraProjects) {
1197
1217
  return;
1198
1218
  }
1199
1219
 
1200
- var slug = generateSlug(cwd, []);
1201
- var allProjects = [{ path: cwd, slug: slug, addedAt: Date.now() }];
1220
+ var allProjects = [];
1221
+ var usedSlugs = [];
1222
+
1223
+ // Only include cwd if explicitly requested
1224
+ if (addCwd) {
1225
+ var slug = generateSlug(cwd, []);
1226
+ allProjects.push({ path: cwd, slug: slug, addedAt: Date.now() });
1227
+ usedSlugs.push(slug);
1228
+ }
1202
1229
 
1203
1230
  // Add restored projects (from ~/.clayrc)
1204
1231
  if (extraProjects && extraProjects.length > 0) {
1205
- var usedSlugs = [slug];
1206
1232
  for (var ep = 0; ep < extraProjects.length; ep++) {
1207
1233
  var rp = extraProjects[ep];
1208
1234
  if (rp.path === cwd) continue; // skip if same as cwd
@@ -1247,11 +1273,13 @@ async function forkDaemon(pin, keepAwake, extraProjects) {
1247
1273
  config.pid = child.pid;
1248
1274
  saveConfig(config);
1249
1275
 
1250
- // Wait for daemon to start
1251
- await new Promise(function (resolve) { setTimeout(resolve, 800); });
1252
-
1253
- // Verify daemon is alive
1254
- var alive = await isDaemonAliveAsync(config);
1276
+ // Wait for daemon to start (retry up to 5 seconds)
1277
+ var alive = false;
1278
+ for (var attempt = 0; attempt < 10; attempt++) {
1279
+ await new Promise(function (resolve) { setTimeout(resolve, 500); });
1280
+ alive = await isDaemonAliveAsync(config);
1281
+ if (alive) break;
1282
+ }
1255
1283
  if (!alive) {
1256
1284
  log(a.red + "Failed to start daemon. Check logs:" + a.reset);
1257
1285
  log(a.dim + logFile + a.reset);
@@ -1260,10 +1288,181 @@ async function forkDaemon(pin, keepAwake, extraProjects) {
1260
1288
  return;
1261
1289
  }
1262
1290
 
1291
+ // Headless mode — print status and exit immediately
1292
+ if (headlessMode) {
1293
+ var protocol = config.tls ? "https" : "http";
1294
+ var url = protocol + "://" + ip + ":" + config.port;
1295
+ console.log(" " + sym.done + " Daemon started (PID " + config.pid + ")");
1296
+ console.log(" " + sym.done + " " + url);
1297
+ console.log(" " + sym.done + " Headless mode — exiting CLI");
1298
+ process.exit(0);
1299
+ return;
1300
+ }
1301
+
1263
1302
  // Show success + QR
1264
1303
  showServerStarted(config, ip);
1265
1304
  }
1266
1305
 
1306
+ // ==============================
1307
+ // Dev mode — foreground daemon with file watching
1308
+ // ==============================
1309
+ async function devMode(pin, keepAwake, existingPinHash) {
1310
+ var ip = getLocalIP();
1311
+ var hasTls = false;
1312
+
1313
+ if (useHttps) {
1314
+ var certPaths = ensureCerts(ip);
1315
+ if (certPaths) hasTls = true;
1316
+ }
1317
+
1318
+ var portFree = await isPortFree(port);
1319
+ if (!portFree) {
1320
+ console.log("\x1b[31m[dev] Port " + port + " is already in use.\x1b[0m");
1321
+ process.exit(1);
1322
+ return;
1323
+ }
1324
+
1325
+ var slug = generateSlug(cwd, []);
1326
+ var allProjects = [{ path: cwd, slug: slug, addedAt: Date.now() }];
1327
+
1328
+ // Restore previous projects
1329
+ var rc = loadClayrc();
1330
+ var restorable = (rc.recentProjects || []).filter(function (p) {
1331
+ return p.path !== cwd && fs.existsSync(p.path);
1332
+ });
1333
+ var usedSlugs = [slug];
1334
+ for (var ri = 0; ri < restorable.length; ri++) {
1335
+ var rp = restorable[ri];
1336
+ var rpSlug = generateSlug(rp.path, usedSlugs);
1337
+ usedSlugs.push(rpSlug);
1338
+ allProjects.push({ path: rp.path, slug: rpSlug, title: rp.title || undefined, addedAt: rp.addedAt || Date.now() });
1339
+ }
1340
+
1341
+ var config = {
1342
+ pid: null,
1343
+ port: port,
1344
+ pinHash: existingPinHash || (pin ? generateAuthToken(pin) : null),
1345
+ tls: hasTls,
1346
+ debug: true,
1347
+ keepAwake: keepAwake || false,
1348
+ dangerouslySkipPermissions: dangerouslySkipPermissions,
1349
+ projects: allProjects,
1350
+ };
1351
+
1352
+ ensureConfigDir();
1353
+ saveConfig(config);
1354
+
1355
+ var daemonScript = path.join(__dirname, "..", "lib", "daemon.js");
1356
+ var libDir = path.join(__dirname, "..", "lib");
1357
+ var child = null;
1358
+ var intentionalKill = false;
1359
+ var debounceTimer = null;
1360
+
1361
+ function spawnDaemon() {
1362
+ child = spawn(process.execPath, [daemonScript], {
1363
+ stdio: ["ignore", "inherit", "inherit"],
1364
+ env: Object.assign({}, process.env, {
1365
+ CLAUDE_RELAY_CONFIG: configPath(),
1366
+ }),
1367
+ });
1368
+
1369
+ child.on("exit", function (code) {
1370
+ child = null;
1371
+ if (intentionalKill) {
1372
+ intentionalKill = false;
1373
+ return;
1374
+ }
1375
+ // Exit code 120 = update restart — respawn daemon with current dev code
1376
+ if (code === 120) {
1377
+ console.log("\x1b[36m[dev]\x1b[0m Update restart — respawning daemon...");
1378
+ console.log("");
1379
+ setTimeout(spawnDaemon, 500);
1380
+ return;
1381
+ }
1382
+ // Unexpected exit — auto restart
1383
+ console.log("\x1b[33m[dev] Daemon exited (code " + code + "), restarting...\x1b[0m");
1384
+ setTimeout(spawnDaemon, 500);
1385
+ });
1386
+ }
1387
+
1388
+ function restartDaemon() {
1389
+ intentionalKill = true;
1390
+ if (child) {
1391
+ child.kill("SIGTERM");
1392
+ // Give it a moment to shut down, then spawn
1393
+ setTimeout(spawnDaemon, 300);
1394
+ } else {
1395
+ intentionalKill = false;
1396
+ spawnDaemon();
1397
+ }
1398
+ }
1399
+
1400
+ console.log("\x1b[36m[dev]\x1b[0m Starting relay on port " + port + "...");
1401
+ if (watchMode) {
1402
+ console.log("\x1b[36m[dev]\x1b[0m Watching lib/ for changes (excluding lib/public/)");
1403
+ }
1404
+ console.log("");
1405
+
1406
+ spawnDaemon();
1407
+
1408
+ // Wait for daemon to be ready, then show CLI menu
1409
+ config.pid = child ? child.pid : null;
1410
+ saveConfig(config);
1411
+
1412
+ var daemonReady = false;
1413
+ for (var da = 0; da < 10; da++) {
1414
+ await new Promise(function (resolve) { setTimeout(resolve, 500); });
1415
+ daemonReady = await isDaemonAliveAsync(config);
1416
+ if (daemonReady) break;
1417
+ }
1418
+ if (daemonReady) {
1419
+ showServerStarted(config, ip);
1420
+ }
1421
+
1422
+ // Watch lib/ for server-side file changes (only with --watch)
1423
+ var watcher = null;
1424
+ if (watchMode) {
1425
+ watcher = fs.watch(libDir, { recursive: true }, function (eventType, filename) {
1426
+ if (!filename) return;
1427
+ // Skip client-side files — they're served from disk
1428
+ if (filename.startsWith("public" + path.sep) || filename.startsWith("public/")) return;
1429
+ // Skip non-JS files
1430
+ if (!filename.endsWith(".js")) return;
1431
+
1432
+ if (debounceTimer) clearTimeout(debounceTimer);
1433
+ debounceTimer = setTimeout(function () {
1434
+ console.log("\x1b[36m[dev]\x1b[0m File changed: lib/" + filename);
1435
+ console.log("\x1b[36m[dev]\x1b[0m Restarting...");
1436
+ console.log("");
1437
+ restartDaemon();
1438
+ }, 300);
1439
+ });
1440
+ }
1441
+
1442
+ // Clean exit on Ctrl+C
1443
+ var shuttingDown = false;
1444
+ process.on("SIGINT", function () {
1445
+ if (shuttingDown) return;
1446
+ shuttingDown = true;
1447
+ console.log("\n\x1b[36m[dev]\x1b[0m Shutting down...");
1448
+ if (watcher) watcher.close();
1449
+ if (debounceTimer) clearTimeout(debounceTimer);
1450
+ intentionalKill = true;
1451
+ if (child) {
1452
+ child.kill("SIGTERM");
1453
+ child.on("exit", function () {
1454
+ clearStaleConfig();
1455
+ process.exit(0);
1456
+ });
1457
+ // Force kill after 3s
1458
+ setTimeout(function () { process.exit(0); }, 3000);
1459
+ } else {
1460
+ clearStaleConfig();
1461
+ process.exit(0);
1462
+ }
1463
+ });
1464
+ }
1465
+
1267
1466
  // ==============================
1268
1467
  // Restart daemon with TLS enabled
1269
1468
  // ==============================
@@ -1324,9 +1523,12 @@ async function restartDaemonWithTLS(config, callback) {
1324
1523
  newConfig.pid = child.pid;
1325
1524
  saveConfig(newConfig);
1326
1525
 
1327
- await new Promise(function (resolve) { setTimeout(resolve, 800); });
1328
-
1329
- var alive = await isDaemonAliveAsync(newConfig);
1526
+ var alive = false;
1527
+ for (var ra = 0; ra < 10; ra++) {
1528
+ await new Promise(function (resolve) { setTimeout(resolve, 500); });
1529
+ alive = await isDaemonAliveAsync(newConfig);
1530
+ if (alive) break;
1531
+ }
1330
1532
  if (!alive) {
1331
1533
  log(sym.warn + " " + a.yellow + "Failed to restart with HTTPS, falling back to HTTP..." + a.reset);
1332
1534
  // Re-fork without TLS so the server is at least running
@@ -1345,7 +1547,11 @@ async function restartDaemonWithTLS(config, callback) {
1345
1547
  fs.closeSync(logFd2);
1346
1548
  newConfig.pid = child2.pid;
1347
1549
  saveConfig(newConfig);
1348
- await new Promise(function (resolve) { setTimeout(resolve, 800); });
1550
+ for (var rb = 0; rb < 10; rb++) {
1551
+ await new Promise(function (resolve) { setTimeout(resolve, 500); });
1552
+ var retryAlive = await isDaemonAliveAsync(newConfig);
1553
+ if (retryAlive) break;
1554
+ }
1349
1555
  startDaemonWatcher();
1350
1556
  callback(newConfig);
1351
1557
  return;
@@ -1998,6 +2204,28 @@ var currentVersion = require("../package.json").version;
1998
2204
  var updated = await checkAndUpdate(currentVersion, skipUpdate);
1999
2205
  if (updated) return;
2000
2206
 
2207
+ // Dev mode — foreground daemon with file watching
2208
+ if (_isDev) {
2209
+ var devConfig = loadConfig();
2210
+ var devAlive = devConfig ? await isDaemonAliveAsync(devConfig) : false;
2211
+ if (devAlive) {
2212
+ console.log("\x1b[36m[dev]\x1b[0m Shutting down existing daemon...");
2213
+ await sendIPCCommand(socketPath(), { cmd: "shutdown" });
2214
+ clearStaleConfig();
2215
+ await new Promise(function (resolve) { setTimeout(resolve, 500); });
2216
+ }
2217
+ // First run — go through setup (disclaimer, port, PIN, etc.)
2218
+ if (!devConfig) {
2219
+ setup(function (pin, keepAwake) {
2220
+ devMode(pin, keepAwake, null);
2221
+ });
2222
+ } else {
2223
+ // Reuse existing PIN hash from previous config
2224
+ await devMode(cliPin || null, devConfig.keepAwake || false, devConfig.pinHash || null);
2225
+ }
2226
+ return;
2227
+ }
2228
+
2001
2229
  var config = loadConfig();
2002
2230
  var alive = config ? await isDaemonAliveAsync(config) : false;
2003
2231
 
@@ -2008,6 +2236,17 @@ var currentVersion = require("../package.json").version;
2008
2236
  }
2009
2237
 
2010
2238
  if (alive) {
2239
+ // Headless mode — daemon already running, just report and exit
2240
+ if (headlessMode) {
2241
+ var protocol = config.tls ? "https" : "http";
2242
+ var ip = getLocalIP();
2243
+ var url = protocol + "://" + ip + ":" + config.port;
2244
+ console.log(" " + sym.done + " Daemon already running (PID " + config.pid + ")");
2245
+ console.log(" " + sym.done + " " + url);
2246
+ process.exit(0);
2247
+ return;
2248
+ }
2249
+
2011
2250
  // Daemon is running — auto-add cwd if needed, then show menu
2012
2251
  var ip = getLocalIP();
2013
2252
 
@@ -2079,7 +2318,8 @@ var currentVersion = require("../package.json").version;
2079
2318
  if (autoRestorable.length > 0) {
2080
2319
  console.log(" " + sym.done + " Restoring " + autoRestorable.length + " previous project(s)");
2081
2320
  }
2082
- await forkDaemon(pin, false, autoRestorable.length > 0 ? autoRestorable : undefined);
2321
+ var hasRestorable = autoRestorable.length > 0;
2322
+ await forkDaemon(pin, false, hasRestorable ? autoRestorable : undefined, !hasRestorable);
2083
2323
  } else {
2084
2324
  setup(function (pin, keepAwake) {
2085
2325
  if (dangerouslySkipPermissions && !pin) {
@@ -2088,6 +2328,7 @@ var currentVersion = require("../package.json").version;
2088
2328
  process.exit(1);
2089
2329
  return;
2090
2330
  }
2331
+
2091
2332
  // Check ~/.clayrc for previous projects to restore
2092
2333
  var rc = loadClayrc();
2093
2334
  var restorable = (rc.recentProjects || []).filter(function (p) {
@@ -2096,13 +2337,13 @@ var currentVersion = require("../package.json").version;
2096
2337
 
2097
2338
  if (restorable.length > 0) {
2098
2339
  promptRestoreProjects(restorable, function (selected) {
2099
- forkDaemon(pin, keepAwake, selected);
2340
+ forkDaemon(pin, keepAwake, selected, false);
2100
2341
  });
2101
2342
  } else {
2102
2343
  log(sym.bar);
2103
2344
  log(sym.end + " " + a.dim + "Starting relay..." + a.reset);
2104
2345
  log("");
2105
- forkDaemon(pin, keepAwake);
2346
+ forkDaemon(pin, keepAwake, undefined, true);
2106
2347
  }
2107
2348
  });
2108
2349
  }