claude-code-remote-pilot 0.5.6 → 0.5.9

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/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.7 — 2026-05-06
4
+
5
+ ### Fixed
6
+ - **Running status now takes precedence over stale limit text**: watcher checks recent running markers before limit window matching, preventing false re-entry into `limit` when old limit lines remain in scrollback.
7
+
8
+ ---
9
+
3
10
  ## 0.5.5 — 2026-05-06
4
11
 
5
12
  ### Fixed
@@ -8,6 +8,7 @@ const readline = require('readline');
8
8
  const SessionManager = require('../lib/SessionManager');
9
9
  const WebServer = require('../lib/WebServer');
10
10
  const config = require('../lib/config');
11
+ const notifier = require('../lib/notifier');
11
12
 
12
13
  // ─── dependency checks ────────────────────────────────────────────────────────
13
14
 
@@ -33,6 +34,13 @@ function tmuxInstallCmd() {
33
34
  return 'sudo apt-get install -y tmux';
34
35
  }
35
36
 
37
+ function cloudflaredInstallCmd() {
38
+ const p = detectPlatform();
39
+ if (p === 'macos') return 'brew install cloudflare/cloudflare/cloudflared';
40
+ if (p === 'arch') return 'yay -S cloudflared';
41
+ return 'curl -L https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64 -o /usr/local/bin/cloudflared && chmod +x /usr/local/bin/cloudflared';
42
+ }
43
+
36
44
  function isYes(answer) { return answer === '' || answer === 'y' || answer === 'yes'; }
37
45
  function isNo(answer) { return answer === '' || answer === 'n' || answer === 'no'; }
38
46
 
@@ -328,15 +336,15 @@ async function handleExit(manager, rl) {
328
336
  // ─── REPL ─────────────────────────────────────────────────────────────────────
329
337
 
330
338
  const HELP = `
331
- spawn <path> [name] Start Claude at path (name defaults to dir name)
332
- list Show all sessions
333
- watch Live session monitor (q to exit)
334
- web [port] [host] [password] Start web dashboard (default: 3742 127.0.0.1)
335
- attach <name> Open tmux session in this terminal
336
- kill <name> Stop a session
337
- resume [message] Show or set the message sent after a limit resets
338
- help Show this help
339
- exit Quit pilot (asks whether to kill sessions)
339
+ spawn <path> [name] Start Claude at path (name defaults to dir name)
340
+ list Show all sessions
341
+ watch Live session monitor (q to exit)
342
+ web [port] [host] [password] [--tunnel] Start web dashboard (default: 3742 127.0.0.1)
343
+ attach <name> Open tmux session in this terminal
344
+ kill <name> Stop a session
345
+ resume [message] Show or set the message sent after a limit resets
346
+ help Show this help
347
+ exit Quit pilot (asks whether to kill sessions)
340
348
  `;
341
349
 
342
350
  // ─── main ─────────────────────────────────────────────────────────────────────
@@ -419,13 +427,50 @@ ${HELP}`);
419
427
 
420
428
  const openWeb = await question(setupRl, 'Open web dashboard? (Y/n) ');
421
429
  if (isYes(openWeb)) {
422
- const webServer = new WebServer(manager, 3742, '127.0.0.1');
430
+ let webPassword = null;
431
+ let useTunnel = false;
432
+
433
+ const tunnelAns = await question(setupRl, 'Expose publicly via cloudflared tunnel? (y/N) ');
434
+ useTunnel = tunnelAns === 'y' || tunnelAns === 'yes';
435
+
436
+ if (useTunnel && !has('cloudflared')) {
437
+ console.log(`\n cloudflared not found. Install it:\n ${cloudflaredInstallCmd()}\n Continuing without tunnel.\n`);
438
+ useTunnel = false;
439
+ }
440
+
441
+ if (useTunnel) {
442
+ console.log('\n ⚠ Public tunnel exposes your dashboard to the internet.');
443
+ const pwAns = await questionRaw(setupRl, ' Set a password (strongly recommended, Enter to skip): ');
444
+ if (pwAns) {
445
+ webPassword = pwAns;
446
+ console.log(' Password protection enabled.\n');
447
+ } else {
448
+ console.log(' ⚠ No password set — anyone with the URL can control your sessions!\n');
449
+ }
450
+ }
451
+
452
+ const webServer = new WebServer(manager, 3742, '127.0.0.1', webPassword);
423
453
  manager._webServer = webServer;
424
454
  webServer.start();
425
- const url = 'http://127.0.0.1:3742';
426
- console.log(` ✓ Web dashboard at ${url}`);
455
+ const localUrl = 'http://127.0.0.1:3742';
456
+ console.log(` ✓ Web dashboard at ${localUrl}`);
457
+
458
+ if (useTunnel) {
459
+ console.log(' Starting cloudflared tunnel...');
460
+ webServer.startTunnel().then(publicUrl => {
461
+ console.log(` ✓ Tunnel ready: ${publicUrl}`);
462
+ console.log(' Note: first visit may show a Cloudflare warning — click "Proceed" to open the dashboard.');
463
+ if (!webPassword) console.log(' ⚠ Reminder: no password set. Restart with a password for security.');
464
+ if (telegram.token && telegram.chatId) {
465
+ notifier.send(telegram.token, telegram.chatId,
466
+ `Claude Remote Pilot tunnel ready: ${publicUrl}${webPassword ? ' (password protected)' : ' ⚠ no password set'}`);
467
+ console.log(' ✓ Tunnel URL sent via Telegram.');
468
+ }
469
+ }).catch(e => console.log(` ✗ Tunnel failed: ${e.message}`));
470
+ }
471
+
427
472
  const opener = process.platform === 'darwin' ? 'open' : 'xdg-open';
428
- spawn(opener, [url], { stdio: 'ignore', detached: true }).unref();
473
+ spawn(opener, [localUrl], { stdio: 'ignore', detached: true }).unref();
429
474
  console.log('');
430
475
  }
431
476
 
@@ -479,22 +524,57 @@ ${HELP}`);
479
524
  return;
480
525
  }
481
526
  case 'web': {
482
- const port = parseInt(args[0]) || 3742;
483
- const host = args[1] || '127.0.0.1';
484
- const password = args[2] || null;
527
+ const flags = args.filter(a => a.startsWith('--'));
528
+ const positional = args.filter(a => !a.startsWith('--'));
529
+ const port = parseInt(positional[0]) || 3742;
530
+ const host = positional[1] || '127.0.0.1';
531
+ const password = positional[2] || null;
532
+ const useTunnel = flags.includes('--tunnel');
485
533
  let webServer = manager._webServer;
486
534
  if (webServer) {
487
535
  console.log(` Web dashboard already running at http://${webServer.host}:${webServer.port}`);
536
+ if (useTunnel && !webServer._tunnelProcess) {
537
+ if (!has('cloudflared')) { console.log(` cloudflared not found. Install: ${cloudflaredInstallCmd()}`); break; }
538
+ if (!webServer.password) console.log(' ⚠ No password set — anyone with the URL can control your sessions!');
539
+ console.log(' Starting cloudflared tunnel...');
540
+ webServer.startTunnel().then(publicUrl => {
541
+ console.log(` ✓ Tunnel ready: ${publicUrl}`);
542
+ console.log(' Note: first visit may show a Cloudflare warning — click "Proceed" to open the dashboard.');
543
+ if (telegram.token && telegram.chatId) {
544
+ notifier.send(telegram.token, telegram.chatId,
545
+ `Claude Remote Pilot tunnel ready: ${publicUrl}${webServer.password ? ' (password protected)' : ' ⚠ no password set'}`);
546
+ console.log(' ✓ Tunnel URL sent via Telegram.');
547
+ }
548
+ }).catch(e => console.log(` ✗ Tunnel failed: ${e.message}`));
549
+ }
550
+ break;
551
+ }
552
+ if (useTunnel && !has('cloudflared')) {
553
+ console.log(` cloudflared not found. Install:\n ${cloudflaredInstallCmd()}`);
488
554
  break;
489
555
  }
556
+ if (useTunnel && !password) console.log(' ⚠ No password set — anyone with the URL can control your sessions!');
490
557
  webServer = new WebServer(manager, port, host, password);
491
558
  manager._webServer = webServer;
492
559
  webServer.start();
493
560
  const url = `http://${host}:${port}`;
494
561
  console.log(` ✓ Web dashboard started at ${url}`);
495
- if (password) console.log(` Password protected. Enter password in the browser.`);
562
+ if (password) console.log(' Password protection enabled.');
496
563
  const opener = process.platform === 'darwin' ? 'open' : 'xdg-open';
497
564
  spawn(opener, [url], { stdio: 'ignore', detached: true }).unref();
565
+ if (useTunnel) {
566
+ console.log(' Starting cloudflared tunnel...');
567
+ webServer.startTunnel().then(publicUrl => {
568
+ console.log(` ✓ Tunnel ready: ${publicUrl}`);
569
+ console.log(' Note: first visit may show a Cloudflare warning — click "Proceed" to open the dashboard.');
570
+ if (!password) console.log(' ⚠ Reminder: add a password with: web <port> <host> <password> --tunnel');
571
+ if (telegram.token && telegram.chatId) {
572
+ notifier.send(telegram.token, telegram.chatId,
573
+ `Claude Remote Pilot tunnel ready: ${publicUrl}${password ? ' (password protected)' : ' ⚠ no password set'}`);
574
+ console.log(' ✓ Tunnel URL sent via Telegram.');
575
+ }
576
+ }).catch(e => console.log(` ✗ Tunnel failed: ${e.message}`));
577
+ }
498
578
  break;
499
579
  }
500
580
  case 'attach': {
package/lib/Watcher.js CHANGED
@@ -15,7 +15,7 @@ class Watcher {
15
15
  constructor(session, opts = {}) {
16
16
  this.session = session;
17
17
  this.telegram = opts.telegram || {};
18
- this.onEnded = opts.onEnded || (() => {});
18
+ this.onEnded = opts.onEnded || (() => { });
19
19
  this.checkInterval = opts.checkInterval || 5000;
20
20
  this.fallbackWait = opts.fallbackWait || 300;
21
21
  this.cooldown = opts.cooldown || 180;
@@ -134,7 +134,12 @@ class Watcher {
134
134
  this.session.tokens = { sent: tokenMatch[1], received: tokenMatch[2] };
135
135
  }
136
136
 
137
- if (LIMIT_RE.test(limitWindow)) {
137
+ if (RUNNING_RE.test(recentLines)) {
138
+ if (this.session.status !== 'running') {
139
+ this.session.status = 'running';
140
+ this.session.resumeAt = null;
141
+ }
142
+ } else if (LIMIT_RE.test(limitWindow)) {
138
143
  await this._handleLimit(limitWindow);
139
144
  } else if (RESPONSE_RE.test(recentLines)) {
140
145
  if (this.session.status !== 'needs-response') {
@@ -142,11 +147,6 @@ class Watcher {
142
147
  notifier.send(this.telegram.token, this.telegram.chatId,
143
148
  `Pilot: "${this.session.name}" needs your response.`);
144
149
  }
145
- } else if (RUNNING_RE.test(recentLines)) {
146
- if (this.session.status !== 'running') {
147
- this.session.status = 'running';
148
- this.session.resumeAt = null;
149
- }
150
150
  } else {
151
151
  // No "esc to interrupt" visible — Claude is not actively processing
152
152
  if (this.session.status !== 'idle') {
@@ -181,7 +181,7 @@ class Watcher {
181
181
  await new Promise(r => setTimeout(r, effectiveWaitSeconds * 1000));
182
182
 
183
183
  try { spawnSync('tmux', ['send-keys', '-t', this.session.name, this.resumeCommand, 'Enter'], { stdio: 'ignore' }); }
184
- catch {}
184
+ catch { }
185
185
 
186
186
  this.lastResumeAt = Date.now() / 1000;
187
187
  this.session.status = 'running';
package/lib/WebServer.js CHANGED
@@ -3,7 +3,7 @@ const http = require('http');
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
5
  const crypto = require('crypto');
6
- const { spawnSync } = require('child_process');
6
+ const { spawnSync, spawn } = require('child_process');
7
7
  const config = require('./config');
8
8
 
9
9
 
@@ -18,6 +18,9 @@ class WebServer {
18
18
  this.server = null;
19
19
  this._clients = new Set();
20
20
  this._broadcastInterval = null;
21
+ this._heartbeatInterval = null;
22
+ this._tunnelProcess = null;
23
+ this._tunnelUrl = null;
21
24
  }
22
25
 
23
26
  _buildAllSessions() {
@@ -199,6 +202,7 @@ class WebServer {
199
202
  'Content-Type': 'text/event-stream',
200
203
  'Cache-Control': 'no-cache',
201
204
  'Connection': 'keep-alive',
205
+ 'X-Accel-Buffering': 'no', // prevent Cloudflare/nginx from buffering the stream
202
206
  });
203
207
  this._clients.add(res);
204
208
  res.write(`data: ${JSON.stringify(this._buildAllSessions())}\n\n`);
@@ -215,12 +219,55 @@ class WebServer {
215
219
  });
216
220
 
217
221
  this._broadcastInterval = setInterval(() => this._broadcast(), 3000);
222
+ // Keep SSE connections alive through proxies (Cloudflare timeout is ~100s)
223
+ this._heartbeatInterval = setInterval(() => {
224
+ for (const res of this._clients) {
225
+ try { res.write(': heartbeat\n\n'); } catch { this._clients.delete(res); }
226
+ }
227
+ }, 20000);
218
228
  this.server.listen(this.port, this.host);
219
229
  return this.port;
220
230
  }
221
231
 
232
+ startTunnel() {
233
+ return new Promise((resolve, reject) => {
234
+ const cf = spawn('cloudflared', ['tunnel', '--url', `http://localhost:${this.port}`], {
235
+ stdio: ['ignore', 'pipe', 'pipe'],
236
+ });
237
+ this._tunnelProcess = cf;
238
+ let resolved = false;
239
+ const onData = (chunk) => {
240
+ const text = chunk.toString();
241
+ const match = text.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/);
242
+ if (match && !resolved) {
243
+ resolved = true;
244
+ this._tunnelUrl = match[0];
245
+ resolve(match[0]);
246
+ }
247
+ };
248
+ cf.stdout.on('data', onData);
249
+ cf.stderr.on('data', onData);
250
+ cf.on('exit', (code) => {
251
+ if (!resolved) reject(new Error(`cloudflared exited (code ${code})`));
252
+ });
253
+ setTimeout(() => {
254
+ if (!resolved) reject(new Error('Timed out waiting for tunnel URL (30s)'));
255
+ }, 30000);
256
+ });
257
+ }
258
+
259
+ stopTunnel() {
260
+ if (this._tunnelProcess) {
261
+ try { this._tunnelProcess.kill(); } catch {}
262
+ this._tunnelProcess = null;
263
+ this._tunnelUrl = null;
264
+ }
265
+ }
266
+
222
267
  stop() {
268
+ this.stopTunnel();
223
269
  clearInterval(this._broadcastInterval);
270
+ clearInterval(this._heartbeatInterval);
224
271
  for (const res of this._clients) { try { res.end(); } catch {} }
225
272
  this._clients.clear();
226
273
  if (this.server) this.server.close();
package/lib/ui.html CHANGED
@@ -1094,6 +1094,21 @@ function App() {
1094
1094
  return () => { if (esRef.current) esRef.current.close(); };
1095
1095
  }, []);
1096
1096
 
1097
+ // Fallback polling — loads sessions immediately and keeps them fresh when
1098
+ // SSE is blocked (e.g. through a Cloudflare tunnel that buffers streams).
1099
+ useEffect(() => {
1100
+ let mounted = true;
1101
+ const poll = () => {
1102
+ apiFetch('/api/sessions', { cache: 'no-store' })
1103
+ .then(r => r.json())
1104
+ .then(data => { if (mounted) setSessions(data); })
1105
+ .catch(() => {});
1106
+ };
1107
+ poll();
1108
+ const t = setInterval(poll, 5000);
1109
+ return () => { mounted = false; clearInterval(t); };
1110
+ }, []);
1111
+
1097
1112
  const handleLogin = useCallback(() => {
1098
1113
  setNeedsLogin(false);
1099
1114
  apiFetch('/api/status').then(r => r.json()).then(setServerStatus).catch(() => {});
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-remote-pilot",
3
- "version": "0.5.6",
3
+ "version": "0.5.9",
4
4
  "description": "Interactive Claude Code supervisor — spawn and monitor multiple Claude sessions from a single terminal.",
5
5
  "type": "commonjs",
6
6
  "repository": {