claude-code-remote-pilot 0.5.5 → 0.5.8
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 +8 -0
- package/bin/claude-pilot.js +94 -17
- package/lib/Watcher.js +8 -8
- package/lib/WebServer.js +39 -1
- package/lib/ui.html +17 -13
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,11 +1,19 @@
|
|
|
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
|
|
6
13
|
- **Respawn loading no longer appears stuck**: web respawn now has a request timeout and surfaces a clear timeout error when the API call hangs, so the button always returns to clickable state.
|
|
7
14
|
- **Terminal output now feels realtime**: session detail terminal polling runs at a faster cadence and triggers immediate output refresh after sending input/keys, improving perceived connect speed and responsiveness.
|
|
8
15
|
- **"Connecting…" no longer gets stuck**: terminal detail view now clears connecting state on poll failures, shows a retrying error hint, and fixes session state initialization order for stable render behavior.
|
|
16
|
+
- **Respawn/End web actions stabilized**: removed false-positive client respawn timeout and added explicit inline error handling for End action responses, so both controls fail visibly instead of appearing stuck.
|
|
9
17
|
|
|
10
18
|
## 0.5.4 — 2026-05-06
|
|
11
19
|
|
package/bin/claude-pilot.js
CHANGED
|
@@ -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]
|
|
332
|
-
list
|
|
333
|
-
watch
|
|
334
|
-
web [port] [host] [password]
|
|
335
|
-
attach <name>
|
|
336
|
-
kill <name>
|
|
337
|
-
resume [message]
|
|
338
|
-
help
|
|
339
|
-
exit
|
|
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,49 @@ ${HELP}`);
|
|
|
419
427
|
|
|
420
428
|
const openWeb = await question(setupRl, 'Open web dashboard? (Y/n) ');
|
|
421
429
|
if (isYes(openWeb)) {
|
|
422
|
-
|
|
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
|
|
426
|
-
console.log(` ✓ Web dashboard at ${
|
|
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
|
+
if (!webPassword) console.log(' ⚠ Reminder: no password set. Restart with a password for security.');
|
|
463
|
+
if (telegram.token && telegram.chatId) {
|
|
464
|
+
notifier.send(telegram.token, telegram.chatId,
|
|
465
|
+
`Claude Remote Pilot tunnel ready: ${publicUrl}${webPassword ? ' (password protected)' : ' ⚠ no password set'}`);
|
|
466
|
+
console.log(' ✓ Tunnel URL sent via Telegram.');
|
|
467
|
+
}
|
|
468
|
+
}).catch(e => console.log(` ✗ Tunnel failed: ${e.message}`));
|
|
469
|
+
}
|
|
470
|
+
|
|
427
471
|
const opener = process.platform === 'darwin' ? 'open' : 'xdg-open';
|
|
428
|
-
spawn(opener, [
|
|
472
|
+
spawn(opener, [localUrl], { stdio: 'ignore', detached: true }).unref();
|
|
429
473
|
console.log('');
|
|
430
474
|
}
|
|
431
475
|
|
|
@@ -479,22 +523,55 @@ ${HELP}`);
|
|
|
479
523
|
return;
|
|
480
524
|
}
|
|
481
525
|
case 'web': {
|
|
482
|
-
const
|
|
483
|
-
const
|
|
484
|
-
const
|
|
526
|
+
const flags = args.filter(a => a.startsWith('--'));
|
|
527
|
+
const positional = args.filter(a => !a.startsWith('--'));
|
|
528
|
+
const port = parseInt(positional[0]) || 3742;
|
|
529
|
+
const host = positional[1] || '127.0.0.1';
|
|
530
|
+
const password = positional[2] || null;
|
|
531
|
+
const useTunnel = flags.includes('--tunnel');
|
|
485
532
|
let webServer = manager._webServer;
|
|
486
533
|
if (webServer) {
|
|
487
534
|
console.log(` Web dashboard already running at http://${webServer.host}:${webServer.port}`);
|
|
535
|
+
if (useTunnel && !webServer._tunnelProcess) {
|
|
536
|
+
if (!has('cloudflared')) { console.log(` cloudflared not found. Install: ${cloudflaredInstallCmd()}`); break; }
|
|
537
|
+
if (!webServer.password) console.log(' ⚠ No password set — anyone with the URL can control your sessions!');
|
|
538
|
+
console.log(' Starting cloudflared tunnel...');
|
|
539
|
+
webServer.startTunnel().then(publicUrl => {
|
|
540
|
+
console.log(` ✓ Tunnel ready: ${publicUrl}`);
|
|
541
|
+
if (telegram.token && telegram.chatId) {
|
|
542
|
+
notifier.send(telegram.token, telegram.chatId,
|
|
543
|
+
`Claude Remote Pilot tunnel ready: ${publicUrl}${webServer.password ? ' (password protected)' : ' ⚠ no password set'}`);
|
|
544
|
+
console.log(' ✓ Tunnel URL sent via Telegram.');
|
|
545
|
+
}
|
|
546
|
+
}).catch(e => console.log(` ✗ Tunnel failed: ${e.message}`));
|
|
547
|
+
}
|
|
548
|
+
break;
|
|
549
|
+
}
|
|
550
|
+
if (useTunnel && !has('cloudflared')) {
|
|
551
|
+
console.log(` cloudflared not found. Install:\n ${cloudflaredInstallCmd()}`);
|
|
488
552
|
break;
|
|
489
553
|
}
|
|
554
|
+
if (useTunnel && !password) console.log(' ⚠ No password set — anyone with the URL can control your sessions!');
|
|
490
555
|
webServer = new WebServer(manager, port, host, password);
|
|
491
556
|
manager._webServer = webServer;
|
|
492
557
|
webServer.start();
|
|
493
558
|
const url = `http://${host}:${port}`;
|
|
494
559
|
console.log(` ✓ Web dashboard started at ${url}`);
|
|
495
|
-
if (password) console.log(
|
|
560
|
+
if (password) console.log(' Password protection enabled.');
|
|
496
561
|
const opener = process.platform === 'darwin' ? 'open' : 'xdg-open';
|
|
497
562
|
spawn(opener, [url], { stdio: 'ignore', detached: true }).unref();
|
|
563
|
+
if (useTunnel) {
|
|
564
|
+
console.log(' Starting cloudflared tunnel...');
|
|
565
|
+
webServer.startTunnel().then(publicUrl => {
|
|
566
|
+
console.log(` ✓ Tunnel ready: ${publicUrl}`);
|
|
567
|
+
if (!password) console.log(' ⚠ Reminder: add a password with: web <port> <host> <password> --tunnel');
|
|
568
|
+
if (telegram.token && telegram.chatId) {
|
|
569
|
+
notifier.send(telegram.token, telegram.chatId,
|
|
570
|
+
`Claude Remote Pilot tunnel ready: ${publicUrl}${password ? ' (password protected)' : ' ⚠ no password set'}`);
|
|
571
|
+
console.log(' ✓ Tunnel URL sent via Telegram.');
|
|
572
|
+
}
|
|
573
|
+
}).catch(e => console.log(` ✗ Tunnel failed: ${e.message}`));
|
|
574
|
+
}
|
|
498
575
|
break;
|
|
499
576
|
}
|
|
500
577
|
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 (
|
|
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,8 @@ class WebServer {
|
|
|
18
18
|
this.server = null;
|
|
19
19
|
this._clients = new Set();
|
|
20
20
|
this._broadcastInterval = null;
|
|
21
|
+
this._tunnelProcess = null;
|
|
22
|
+
this._tunnelUrl = null;
|
|
21
23
|
}
|
|
22
24
|
|
|
23
25
|
_buildAllSessions() {
|
|
@@ -219,7 +221,43 @@ class WebServer {
|
|
|
219
221
|
return this.port;
|
|
220
222
|
}
|
|
221
223
|
|
|
224
|
+
startTunnel() {
|
|
225
|
+
return new Promise((resolve, reject) => {
|
|
226
|
+
const cf = spawn('cloudflared', ['tunnel', '--url', `http://localhost:${this.port}`], {
|
|
227
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
228
|
+
});
|
|
229
|
+
this._tunnelProcess = cf;
|
|
230
|
+
let resolved = false;
|
|
231
|
+
const onData = (chunk) => {
|
|
232
|
+
const text = chunk.toString();
|
|
233
|
+
const match = text.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/);
|
|
234
|
+
if (match && !resolved) {
|
|
235
|
+
resolved = true;
|
|
236
|
+
this._tunnelUrl = match[0];
|
|
237
|
+
resolve(match[0]);
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
cf.stdout.on('data', onData);
|
|
241
|
+
cf.stderr.on('data', onData);
|
|
242
|
+
cf.on('exit', (code) => {
|
|
243
|
+
if (!resolved) reject(new Error(`cloudflared exited (code ${code})`));
|
|
244
|
+
});
|
|
245
|
+
setTimeout(() => {
|
|
246
|
+
if (!resolved) reject(new Error('Timed out waiting for tunnel URL (30s)'));
|
|
247
|
+
}, 30000);
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
stopTunnel() {
|
|
252
|
+
if (this._tunnelProcess) {
|
|
253
|
+
try { this._tunnelProcess.kill(); } catch {}
|
|
254
|
+
this._tunnelProcess = null;
|
|
255
|
+
this._tunnelUrl = null;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
222
259
|
stop() {
|
|
260
|
+
this.stopTunnel();
|
|
223
261
|
clearInterval(this._broadcastInterval);
|
|
224
262
|
for (const res of this._clients) { try { res.end(); } catch {} }
|
|
225
263
|
this._clients.clear();
|
package/lib/ui.html
CHANGED
|
@@ -580,6 +580,7 @@ function SessionDetailScreen({ session, onBack, onKilled, onRespawned }) {
|
|
|
580
580
|
const [msg, setMsg] = useState('');
|
|
581
581
|
const [sending, setSending] = useState(false);
|
|
582
582
|
const [killing, setKilling] = useState(false);
|
|
583
|
+
const [killError, setKillError] = useState('');
|
|
583
584
|
const [respawning, setRespawning] = useState(false);
|
|
584
585
|
const [respawnError, setRespawnError] = useState('');
|
|
585
586
|
const [copyOk, setCopyOk] = useState(false);
|
|
@@ -688,10 +689,18 @@ function SessionDetailScreen({ session, onBack, onKilled, onRespawned }) {
|
|
|
688
689
|
const handleKill = async () => {
|
|
689
690
|
if (!confirm(`End session "${session.name}"?`)) return;
|
|
690
691
|
setKilling(true);
|
|
692
|
+
setKillError('');
|
|
691
693
|
try {
|
|
692
|
-
await apiFetch(`/api/sessions/${encodeURIComponent(session.name)}`, { method: 'DELETE' });
|
|
694
|
+
const res = await apiFetch(`/api/sessions/${encodeURIComponent(session.name)}`, { method: 'DELETE' });
|
|
695
|
+
if (!res.ok) {
|
|
696
|
+
const d = await res.json().catch(() => ({}));
|
|
697
|
+
setKillError(d.error || 'Failed to end session');
|
|
698
|
+
setKilling(false);
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
693
701
|
onKilled();
|
|
694
|
-
} catch {
|
|
702
|
+
} catch (e) {
|
|
703
|
+
if (e && e.message !== 'Unauthorized') setKillError('Network error');
|
|
695
704
|
setKilling(false);
|
|
696
705
|
}
|
|
697
706
|
};
|
|
@@ -700,13 +709,8 @@ function SessionDetailScreen({ session, onBack, onKilled, onRespawned }) {
|
|
|
700
709
|
if (respawning) return;
|
|
701
710
|
setRespawning(true);
|
|
702
711
|
setRespawnError('');
|
|
703
|
-
const controller = new AbortController();
|
|
704
|
-
const timeoutId = setTimeout(() => controller.abort(), 12000);
|
|
705
712
|
try {
|
|
706
|
-
const res = await apiFetch(`/api/sessions/${encodeURIComponent(session.name)}/respawn`, {
|
|
707
|
-
method: 'POST',
|
|
708
|
-
signal: controller.signal,
|
|
709
|
-
});
|
|
713
|
+
const res = await apiFetch(`/api/sessions/${encodeURIComponent(session.name)}/respawn`, { method: 'POST' });
|
|
710
714
|
const data = await res.json();
|
|
711
715
|
if (!res.ok) {
|
|
712
716
|
setRespawnError(data.error || 'Failed to respawn');
|
|
@@ -714,13 +718,8 @@ function SessionDetailScreen({ session, onBack, onKilled, onRespawned }) {
|
|
|
714
718
|
}
|
|
715
719
|
onRespawned(data);
|
|
716
720
|
} catch (e) {
|
|
717
|
-
if (e && e.name === 'AbortError') {
|
|
718
|
-
setRespawnError('Respawn timeout. Please try again.');
|
|
719
|
-
return;
|
|
720
|
-
}
|
|
721
721
|
if (e.message !== 'Unauthorized') setRespawnError('Network error');
|
|
722
722
|
} finally {
|
|
723
|
-
clearTimeout(timeoutId);
|
|
724
723
|
setRespawning(false);
|
|
725
724
|
}
|
|
726
725
|
};
|
|
@@ -774,6 +773,11 @@ function SessionDetailScreen({ session, onBack, onKilled, onRespawned }) {
|
|
|
774
773
|
{Icons.trash} {killing ? 'Ending…' : 'End'}
|
|
775
774
|
</button>
|
|
776
775
|
)}
|
|
776
|
+
{!isOffline && killError && (
|
|
777
|
+
<span style={{ color: 'var(--error)', fontSize: 12, alignSelf: 'center' }}>
|
|
778
|
+
{killError}
|
|
779
|
+
</span>
|
|
780
|
+
)}
|
|
777
781
|
</div>
|
|
778
782
|
</div>
|
|
779
783
|
|
package/package.json
CHANGED