claude-code-remote-pilot 0.4.9 → 0.5.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.
- package/CHANGELOG.md +20 -0
- package/README.md +9 -3
- package/lib/WebServer.js +19 -4
- package/lib/ui.html +172 -12
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.1 — 2026-05-06
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
- **Terminal always "Connecting…"**: two root causes patched:
|
|
7
|
+
1. `Cache-Control: no-store` added to all API responses — browsers were heuristic-caching the first (sometimes empty) output response and serving stale data on every subsequent poll.
|
|
8
|
+
2. `ansiToHtml` is now pre-computed before the JSX return with a try-catch — any parsing edge case falls back to ANSI-stripped plain text instead of silently breaking the render.
|
|
9
|
+
- **Poll errors are now logged** to the browser console (`[ccp] output poll error:`) instead of silently swallowed, so future issues are diagnosable.
|
|
10
|
+
- Added `cache: 'no-store'` to the `fetch` call in the output poll (belt-and-suspenders alongside the server header).
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## 0.5.0 — 2026-05-06
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
- **ANSI color rendering**: terminal output in the web dashboard now renders full 24-bit color, bold, dim, italic, and underline — same colors you see in the real tmux terminal. Server-side: `capture-pane` now uses `-e` flag and sends raw ANSI codes. Client-side: inline `ansiToHtml()` parser handles `38;2;R;G;B` / `48;2;R;G;B` (24-bit), `38;5;N` / `48;5;N` (256-color), and all standard SGR attributes.
|
|
18
|
+
- **Browser notifications**: the dashboard requests notification permission on first load. When any session transitions to `needs-response` or `limit`, a desktop notification fires (uses `tag` deduplication so the same session doesn't spam).
|
|
19
|
+
- **Broadcast message**: a "Broadcast" bar appears on the Dashboard when there are active sessions. Type a message and press Enter (or click Send) to send the same text to every active session at once. The server sends it via `POST /api/broadcast`.
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
3
23
|
## 0.4.9 — 2026-05-06
|
|
4
24
|
|
|
5
25
|
### Added
|
package/README.md
CHANGED
|
@@ -123,11 +123,13 @@ claude-pilot> web
|
|
|
123
123
|
|
|
124
124
|
The dashboard shows all sessions (live and offline), lets you:
|
|
125
125
|
|
|
126
|
-
- View terminal output
|
|
127
|
-
- Send a message to Claude directly from the browser
|
|
126
|
+
- View terminal output with **full ANSI color rendering** (24-bit color, bold, dim, italic) — looks like the real terminal
|
|
127
|
+
- Send a message to Claude directly from the browser (or press Esc / ^C / ^D)
|
|
128
|
+
- **Broadcast** a message to all active sessions at once
|
|
128
129
|
- Spawn new sessions with a name, path, and optional initial prompt
|
|
129
130
|
- Kill sessions
|
|
130
131
|
- See a live activity log of status transitions
|
|
132
|
+
- Receive **browser desktop notifications** when any session needs input or hits a usage limit
|
|
131
133
|
|
|
132
134
|
By default the server binds to `127.0.0.1` — local only. To access from other devices on your network:
|
|
133
135
|
|
|
@@ -204,8 +206,12 @@ Start Claude without `--dangerously-skip-permissions` unless you know what you'r
|
|
|
204
206
|
- [x] multi-session support
|
|
205
207
|
- [x] web dashboard — `web [port]` command, React SPA, SSE live updates
|
|
206
208
|
- [x] persistent session history with offline session display
|
|
209
|
+
- [x] ANSI color terminal rendering in browser
|
|
210
|
+
- [x] browser desktop notifications on status changes
|
|
211
|
+
- [x] broadcast message to all sessions
|
|
212
|
+
- [x] auto-discover untracked tmux sessions on startup
|
|
213
|
+
- [ ] auto-yes rules — confirm prompts automatically by pattern
|
|
207
214
|
- [ ] pluggable notification providers
|
|
208
|
-
- [ ] safety / policy engine
|
|
209
215
|
|
|
210
216
|
---
|
|
211
217
|
|
package/lib/WebServer.js
CHANGED
|
@@ -6,7 +6,6 @@ const crypto = require('crypto');
|
|
|
6
6
|
const { spawnSync } = require('child_process');
|
|
7
7
|
const config = require('./config');
|
|
8
8
|
|
|
9
|
-
const STRIP_ANSI = /\x1b\[[0-9;]*[mGKHFABCDJsuhl]|\x1b[()][AB012]/g;
|
|
10
9
|
|
|
11
10
|
class WebServer {
|
|
12
11
|
constructor(manager, port = 3742, host = '127.0.0.1', password = null) {
|
|
@@ -32,12 +31,14 @@ class WebServer {
|
|
|
32
31
|
}
|
|
33
32
|
|
|
34
33
|
_getOutput(name) {
|
|
35
|
-
const result = spawnSync('tmux', ['capture-pane', '-pt', name, '-S', '-500'], { encoding: 'utf8' });
|
|
36
|
-
|
|
34
|
+
const result = spawnSync('tmux', ['capture-pane', '-pt', name, '-e', '-S', '-500'], { encoding: 'utf8' });
|
|
35
|
+
if (!result.stdout) return '';
|
|
36
|
+
// Trim trailing whitespace from each line (tmux pads lines to terminal width)
|
|
37
|
+
return result.stdout.split('\n').map(l => l.trimEnd()).join('\n');
|
|
37
38
|
}
|
|
38
39
|
|
|
39
40
|
_json(res, code, data) {
|
|
40
|
-
res.writeHead(code, { 'Content-Type': 'application/json' });
|
|
41
|
+
res.writeHead(code, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
|
41
42
|
res.end(JSON.stringify(data));
|
|
42
43
|
}
|
|
43
44
|
|
|
@@ -143,6 +144,20 @@ class WebServer {
|
|
|
143
144
|
}
|
|
144
145
|
}
|
|
145
146
|
|
|
147
|
+
// POST /api/broadcast
|
|
148
|
+
if (req.method === 'POST' && pathname === '/api/broadcast') {
|
|
149
|
+
return this._readBody(req, (err, body) => {
|
|
150
|
+
if (err) return this._json(res, 400, { error: err.message });
|
|
151
|
+
const { message } = body;
|
|
152
|
+
if (!message) return this._json(res, 400, { error: 'message required' });
|
|
153
|
+
const sessions = this.manager.list().filter(s => s.status !== 'offline');
|
|
154
|
+
for (const s of sessions) {
|
|
155
|
+
spawnSync('tmux', ['send-keys', '-t', s.name, message, 'Enter'], { stdio: 'ignore' });
|
|
156
|
+
}
|
|
157
|
+
return this._json(res, 200, { ok: true, sent: sessions.length });
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
146
161
|
this._json(res, 404, { error: 'Not found' });
|
|
147
162
|
}
|
|
148
163
|
|
package/lib/ui.html
CHANGED
|
@@ -310,6 +310,85 @@ function statusLabel(s) {
|
|
|
310
310
|
return s === 'needs-response' ? 'needs input' : s;
|
|
311
311
|
}
|
|
312
312
|
|
|
313
|
+
/* --- ANSI → HTML renderer --- */
|
|
314
|
+
function ansiToHtml(raw) {
|
|
315
|
+
// Strip cursor-movement / other non-SGR sequences
|
|
316
|
+
const text = raw
|
|
317
|
+
.replace(/\x1b\[[0-9;]*[ABCDEFGHJKSTfunsu]/g, '')
|
|
318
|
+
.replace(/\x1b[()][AB012]/g, '')
|
|
319
|
+
.replace(/\x1b[=<>]/g, '');
|
|
320
|
+
|
|
321
|
+
const PALETTE = [
|
|
322
|
+
'#1a1a1a','#cc3333','#4e9a06','#c4a000','#3465a4','#75507b','#06989a','#d3d7cf',
|
|
323
|
+
'#7f7f7f','#ef2929','#8ae234','#fce94f','#729fcf','#ad7fa8','#34e2e2','#eeeeec',
|
|
324
|
+
];
|
|
325
|
+
function p256(n) {
|
|
326
|
+
if (n < 16) return PALETTE[n];
|
|
327
|
+
if (n >= 232) { const v = 8 + (n - 232) * 10; return `rgb(${v},${v},${v})`; }
|
|
328
|
+
const i = n - 16, r = Math.floor(i/36)*51, g = Math.floor((i%36)/6)*51, b = (i%6)*51;
|
|
329
|
+
return `rgb(${r},${g},${b})`;
|
|
330
|
+
}
|
|
331
|
+
const esc = s => s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
|
332
|
+
|
|
333
|
+
let result = '', last = 0, attrs = {}, spanOpen = false;
|
|
334
|
+
function styleStr() {
|
|
335
|
+
const p = [];
|
|
336
|
+
if (attrs.fg) p.push(`color:${attrs.fg}`);
|
|
337
|
+
if (attrs.bg) p.push(`background:${attrs.bg}`);
|
|
338
|
+
if (attrs.bold) p.push('font-weight:700');
|
|
339
|
+
if (attrs.dim) p.push('opacity:0.55');
|
|
340
|
+
if (attrs.italic) p.push('font-style:italic');
|
|
341
|
+
if (attrs.ul) p.push('text-decoration:underline');
|
|
342
|
+
return p.join(';');
|
|
343
|
+
}
|
|
344
|
+
function emitText(txt) {
|
|
345
|
+
if (!txt) return;
|
|
346
|
+
const s = styleStr();
|
|
347
|
+
if (s) { if (spanOpen) result += '</span>'; result += `<span style="${s}">`; spanOpen = true; }
|
|
348
|
+
else if (spanOpen) { result += '</span>'; spanOpen = false; }
|
|
349
|
+
result += esc(txt);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const SGR = /\x1b\[([0-9;]*)m/g;
|
|
353
|
+
let m;
|
|
354
|
+
SGR.lastIndex = 0;
|
|
355
|
+
while ((m = SGR.exec(text)) !== null) {
|
|
356
|
+
emitText(text.slice(last, m.index));
|
|
357
|
+
last = m.index + m[0].length;
|
|
358
|
+
const codes = m[1] ? m[1].split(';').map(Number) : [0];
|
|
359
|
+
let i = 0;
|
|
360
|
+
while (i < codes.length) {
|
|
361
|
+
const c = codes[i];
|
|
362
|
+
if (c === 0) { attrs = {}; }
|
|
363
|
+
else if (c === 1) { attrs.bold = true; }
|
|
364
|
+
else if (c === 2) { attrs.dim = true; }
|
|
365
|
+
else if (c === 3) { attrs.italic = true; }
|
|
366
|
+
else if (c === 4) { attrs.ul = true; }
|
|
367
|
+
else if (c === 22) { delete attrs.bold; delete attrs.dim; }
|
|
368
|
+
else if (c === 23) { delete attrs.italic; }
|
|
369
|
+
else if (c === 24) { delete attrs.ul; }
|
|
370
|
+
else if (c >= 30 && c <= 37) { attrs.fg = PALETTE[c-30]; }
|
|
371
|
+
else if (c === 38) {
|
|
372
|
+
if (codes[i+1] === 2 && i+4 < codes.length) { attrs.fg = `rgb(${codes[i+2]},${codes[i+3]},${codes[i+4]})`; i+=4; }
|
|
373
|
+
else if (codes[i+1] === 5 && i+2 < codes.length) { attrs.fg = p256(codes[i+2]); i+=2; }
|
|
374
|
+
}
|
|
375
|
+
else if (c === 39) { delete attrs.fg; }
|
|
376
|
+
else if (c >= 40 && c <= 47) { attrs.bg = PALETTE[c-40]; }
|
|
377
|
+
else if (c === 48) {
|
|
378
|
+
if (codes[i+1] === 2 && i+4 < codes.length) { attrs.bg = `rgb(${codes[i+2]},${codes[i+3]},${codes[i+4]})`; i+=4; }
|
|
379
|
+
else if (codes[i+1] === 5 && i+2 < codes.length) { attrs.bg = p256(codes[i+2]); i+=2; }
|
|
380
|
+
}
|
|
381
|
+
else if (c === 49) { delete attrs.bg; }
|
|
382
|
+
else if (c >= 90 && c <= 97) { attrs.fg = PALETTE[c-82]; }
|
|
383
|
+
else if (c >= 100 && c <= 107) { attrs.bg = PALETTE[c-92]; }
|
|
384
|
+
i++;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
emitText(text.slice(last));
|
|
388
|
+
if (spanOpen) result += '</span>';
|
|
389
|
+
return result;
|
|
390
|
+
}
|
|
391
|
+
|
|
313
392
|
/* --- Icons --- */
|
|
314
393
|
const Icons = {
|
|
315
394
|
dashboard: <svg className="nav-icon" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.5"><rect x="2" y="2" width="6" height="6" rx="1"/><rect x="10" y="2" width="6" height="6" rx="1"/><rect x="2" y="10" width="6" height="6" rx="1"/><rect x="10" y="10" width="6" height="6" rx="1"/></svg>,
|
|
@@ -400,7 +479,7 @@ function Sidebar({ currentScreen, onNavigate, sessionCount, open, onClose, conne
|
|
|
400
479
|
<div className="logo-mark">C</div>
|
|
401
480
|
<span className="logo-text">Code Pilot</span>
|
|
402
481
|
</a>
|
|
403
|
-
<span className="logo-badge">v0.
|
|
482
|
+
<span className="logo-badge">v0.5.0</span>
|
|
404
483
|
<button className="sidebar-close" onClick={onClose}>{Icons.close}</button>
|
|
405
484
|
</div>
|
|
406
485
|
<nav className="sidebar-nav">
|
|
@@ -488,6 +567,8 @@ function DashboardScreen({ onNavigate, sessions, activity, serverStatus }) {
|
|
|
488
567
|
</div>
|
|
489
568
|
</div>
|
|
490
569
|
)}
|
|
570
|
+
|
|
571
|
+
<BroadcastBar activeSessions={active.length} />
|
|
491
572
|
</div>
|
|
492
573
|
);
|
|
493
574
|
}
|
|
@@ -520,10 +601,10 @@ function SessionDetailScreen({ session, onBack, onKilled }) {
|
|
|
520
601
|
useEffect(() => {
|
|
521
602
|
if (isOffline) return;
|
|
522
603
|
const poll = () => {
|
|
523
|
-
apiFetch(`/api/sessions/${encodeURIComponent(session.name)}/output
|
|
604
|
+
apiFetch(`/api/sessions/${encodeURIComponent(session.name)}/output`, { cache: 'no-store' })
|
|
524
605
|
.then(r => r.json())
|
|
525
606
|
.then(d => setOutput(d.output || ''))
|
|
526
|
-
.catch(()
|
|
607
|
+
.catch(e => { if (e && e.message !== 'Unauthorized') console.error('[ccp] output poll error:', e); });
|
|
527
608
|
};
|
|
528
609
|
poll();
|
|
529
610
|
const t = setInterval(poll, 2000);
|
|
@@ -579,6 +660,17 @@ function SessionDetailScreen({ session, onBack, onKilled }) {
|
|
|
579
660
|
}
|
|
580
661
|
};
|
|
581
662
|
|
|
663
|
+
// Render terminal HTML (ANSI → HTML, with plain-text fallback)
|
|
664
|
+
let _termHtml = '';
|
|
665
|
+
if (output) {
|
|
666
|
+
try {
|
|
667
|
+
_termHtml = ansiToHtml(output);
|
|
668
|
+
} catch(e) {
|
|
669
|
+
console.error('[ccp] ansiToHtml error:', e);
|
|
670
|
+
_termHtml = output.replace(/\x1b\[[0-9;]*m/g,'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
582
674
|
// Terminal height: fill viewport minus chrome
|
|
583
675
|
const terminalStyle = {
|
|
584
676
|
height: 'calc(100vh - 210px)',
|
|
@@ -621,14 +713,17 @@ function SessionDetailScreen({ session, onBack, onKilled }) {
|
|
|
621
713
|
<StatusPill status={session.status} />
|
|
622
714
|
</div>
|
|
623
715
|
|
|
624
|
-
<div
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
716
|
+
<div
|
|
717
|
+
className="terminal-body"
|
|
718
|
+
ref={terminalRef}
|
|
719
|
+
dangerouslySetInnerHTML={{ __html:
|
|
720
|
+
isOffline
|
|
721
|
+
? '<span style="color:oklch(50% 0.018 50)">Session is offline — no output available.</span>'
|
|
722
|
+
: _termHtml
|
|
723
|
+
? _termHtml + '<span style="opacity:0.4">▊</span>'
|
|
724
|
+
: '<span style="color:oklch(50% 0.018 50)">Connecting…</span>'
|
|
725
|
+
}}
|
|
726
|
+
/>
|
|
632
727
|
|
|
633
728
|
{!isOffline && (
|
|
634
729
|
<div className="terminal-footer">
|
|
@@ -765,6 +860,56 @@ function SessionsScreen({ sessions, onNavigate }) {
|
|
|
765
860
|
);
|
|
766
861
|
}
|
|
767
862
|
|
|
863
|
+
/* --- Broadcast Bar --- */
|
|
864
|
+
function BroadcastBar({ activeSessions }) {
|
|
865
|
+
const [msg, setMsg] = useState('');
|
|
866
|
+
const [sending, setSending] = useState(false);
|
|
867
|
+
const [result, setResult] = useState(null);
|
|
868
|
+
|
|
869
|
+
const broadcast = async () => {
|
|
870
|
+
if (!msg.trim() || sending) return;
|
|
871
|
+
setSending(true);
|
|
872
|
+
try {
|
|
873
|
+
const res = await apiFetch('/api/broadcast', {
|
|
874
|
+
method: 'POST',
|
|
875
|
+
headers: { 'Content-Type': 'application/json' },
|
|
876
|
+
body: JSON.stringify({ message: msg }),
|
|
877
|
+
});
|
|
878
|
+
const data = await res.json();
|
|
879
|
+
if (res.ok) {
|
|
880
|
+
setResult(`Sent to ${data.sent} session${data.sent !== 1 ? 's' : ''}`);
|
|
881
|
+
setMsg('');
|
|
882
|
+
setTimeout(() => setResult(null), 3000);
|
|
883
|
+
}
|
|
884
|
+
} catch {}
|
|
885
|
+
finally { setSending(false); }
|
|
886
|
+
};
|
|
887
|
+
|
|
888
|
+
if (activeSessions === 0) return null;
|
|
889
|
+
|
|
890
|
+
return (
|
|
891
|
+
<div className="card" style={{ padding: '14px 18px', marginTop: 24 }}>
|
|
892
|
+
<div style={{ fontSize: 12, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--muted)', marginBottom: 10 }}>
|
|
893
|
+
Broadcast to all {activeSessions} active session{activeSessions !== 1 ? 's' : ''}
|
|
894
|
+
</div>
|
|
895
|
+
<div style={{ display: 'flex', gap: 8 }}>
|
|
896
|
+
<input
|
|
897
|
+
className="form-input"
|
|
898
|
+
placeholder="Send the same message to every active session…"
|
|
899
|
+
value={msg}
|
|
900
|
+
onChange={e => setMsg(e.target.value)}
|
|
901
|
+
onKeyDown={e => e.key === 'Enter' && broadcast()}
|
|
902
|
+
style={{ flex: 1 }}
|
|
903
|
+
/>
|
|
904
|
+
<button className="btn btn-primary btn-sm" onClick={broadcast} disabled={sending || !msg.trim()}>
|
|
905
|
+
{sending ? 'Sending…' : 'Send'}
|
|
906
|
+
</button>
|
|
907
|
+
</div>
|
|
908
|
+
{result && <div style={{ fontSize: 12, color: 'var(--success)', marginTop: 8 }}>{result}</div>}
|
|
909
|
+
</div>
|
|
910
|
+
);
|
|
911
|
+
}
|
|
912
|
+
|
|
768
913
|
/* --- App --- */
|
|
769
914
|
function App() {
|
|
770
915
|
const savedTheme = storage.getItem('ccp-theme');
|
|
@@ -791,6 +936,13 @@ function App() {
|
|
|
791
936
|
return () => { _onUnauth = () => {}; };
|
|
792
937
|
}, []);
|
|
793
938
|
|
|
939
|
+
// Request browser notification permission on first load
|
|
940
|
+
useEffect(() => {
|
|
941
|
+
if ('Notification' in window && Notification.permission === 'default') {
|
|
942
|
+
Notification.requestPermission();
|
|
943
|
+
}
|
|
944
|
+
}, []);
|
|
945
|
+
|
|
794
946
|
const connectSSE = useCallback(() => {
|
|
795
947
|
if (esRef.current) esRef.current.close();
|
|
796
948
|
const es = new EventSource(sseUrl());
|
|
@@ -808,8 +960,16 @@ function App() {
|
|
|
808
960
|
const prev = prevStatusRef.current;
|
|
809
961
|
const newEntries = [];
|
|
810
962
|
for (const s of incoming) {
|
|
811
|
-
if (prev[s.name] && prev[s.name] !== s.status)
|
|
963
|
+
if (prev[s.name] && prev[s.name] !== s.status) {
|
|
812
964
|
newEntries.push({ time: now, name: s.name, from: prev[s.name], to: s.status });
|
|
965
|
+
if ((s.status === 'needs-response' || s.status === 'limit') &&
|
|
966
|
+
'Notification' in window && Notification.permission === 'granted') {
|
|
967
|
+
new Notification(`Claude Pilot — ${s.name}`, {
|
|
968
|
+
body: s.status === 'needs-response' ? 'Claude needs your input' : 'Usage limit hit — will auto-resume',
|
|
969
|
+
tag: `ccp-${s.name}-${s.status}`,
|
|
970
|
+
});
|
|
971
|
+
}
|
|
972
|
+
}
|
|
813
973
|
prev[s.name] = s.status;
|
|
814
974
|
}
|
|
815
975
|
if (newEntries.length) setActivity(a => [...newEntries, ...a].slice(0, 20));
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-remote-pilot",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "Interactive Claude Code supervisor — spawn and monitor multiple Claude sessions from a single terminal.",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
8
|
-
"url": "https://github.com/mekku/claude-code-remote-pilot.git"
|
|
8
|
+
"url": "git+https://github.com/mekku/claude-code-remote-pilot.git"
|
|
9
9
|
},
|
|
10
10
|
"homepage": "https://github.com/mekku/claude-code-remote-pilot#readme",
|
|
11
11
|
"bugs": {
|