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.
Files changed (54) hide show
  1. package/README.md +21 -5
  2. package/bin/cli.js +214 -9
  3. package/lib/cli-sessions.js +270 -0
  4. package/lib/config.js +3 -2
  5. package/lib/daemon.js +45 -1
  6. package/lib/pages.js +8 -1
  7. package/lib/project.js +121 -12
  8. package/lib/public/app.js +411 -87
  9. package/lib/public/css/base.css +41 -7
  10. package/lib/public/css/diff.css +6 -6
  11. package/lib/public/css/filebrowser.css +62 -52
  12. package/lib/public/css/highlight.css +144 -0
  13. package/lib/public/css/input.css +11 -9
  14. package/lib/public/css/menus.css +82 -23
  15. package/lib/public/css/messages.css +183 -35
  16. package/lib/public/css/overlays.css +166 -50
  17. package/lib/public/css/rewind.css +17 -17
  18. package/lib/public/css/sidebar.css +210 -137
  19. package/lib/public/index.html +75 -42
  20. package/lib/public/modules/filebrowser.js +2 -1
  21. package/lib/public/modules/markdown.js +10 -10
  22. package/lib/public/modules/notifications.js +38 -1
  23. package/lib/public/modules/sidebar.js +109 -31
  24. package/lib/public/modules/terminal.js +84 -23
  25. package/lib/public/modules/theme.js +622 -0
  26. package/lib/public/modules/tools.js +247 -4
  27. package/lib/public/modules/utils.js +21 -5
  28. package/lib/public/style.css +1 -0
  29. package/lib/sdk-bridge.js +95 -0
  30. package/lib/server.js +45 -3
  31. package/lib/sessions.js +16 -3
  32. package/lib/themes/ayu-light.json +9 -0
  33. package/lib/themes/catppuccin-latte.json +9 -0
  34. package/lib/themes/catppuccin-mocha.json +9 -0
  35. package/lib/themes/claude-light.json +9 -0
  36. package/lib/themes/claude.json +9 -0
  37. package/lib/themes/dracula.json +9 -0
  38. package/lib/themes/everforest-light.json +9 -0
  39. package/lib/themes/everforest.json +9 -0
  40. package/lib/themes/github-light.json +9 -0
  41. package/lib/themes/gruvbox-dark.json +9 -0
  42. package/lib/themes/gruvbox-light.json +9 -0
  43. package/lib/themes/monokai.json +9 -0
  44. package/lib/themes/nord-light.json +9 -0
  45. package/lib/themes/nord.json +9 -0
  46. package/lib/themes/one-dark.json +9 -0
  47. package/lib/themes/one-light.json +9 -0
  48. package/lib/themes/rose-pine-dawn.json +9 -0
  49. package/lib/themes/rose-pine.json +9 -0
  50. package/lib/themes/solarized-dark.json +9 -0
  51. package/lib/themes/solarized-light.json +9 -0
  52. package/lib/themes/tokyo-night-light.json +9 -0
  53. package/lib/themes/tokyo-night.json +9 -0
  54. 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.
@@ -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 -y # Skip interactive prompts (accept defaults)
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 slug = generateSlug(cwd, []);
1194
- var allProjects = [{ path: cwd, slug: slug, addedAt: Date.now() }];
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
- await forkDaemon(pin, false, autoRestorable.length > 0 ? autoRestorable : undefined);
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
+ };