claude-code-remote-pilot 0.4.8 → 0.5.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.
- package/CHANGELOG.md +18 -0
- package/README.md +9 -3
- package/bin/claude-pilot.js +24 -0
- package/lib/Watcher.js +1 -0
- package/lib/WebServer.js +18 -3
- package/lib/ui.html +159 -10
- package/package.json +9 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.0 — 2026-05-06
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- **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.
|
|
7
|
+
- **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).
|
|
8
|
+
- **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`.
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## 0.4.9 — 2026-05-06
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
- **Repo metadata in package.json**: added `repository`, `homepage`, and `bugs` fields pointing to `github.com/mekku/claude-code-remote-pilot`.
|
|
16
|
+
- **Auto-discover untracked tmux sessions**: on startup, pilot lists any tmux sessions not already being watched and offers to adopt them (fetches the pane's current working directory automatically).
|
|
17
|
+
- **Immediate status check on watcher start**: `Watcher.start()` now calls `_check()` immediately instead of waiting for the first 5-second tick — sessions show the correct status from the first second after adopt/spawn.
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
3
21
|
## 0.4.8 — 2026-05-06
|
|
4
22
|
|
|
5
23
|
### 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/bin/claude-pilot.js
CHANGED
|
@@ -382,6 +382,30 @@ ${HELP}`);
|
|
|
382
382
|
}
|
|
383
383
|
}
|
|
384
384
|
|
|
385
|
+
// Auto-discover tmux sessions not already managed
|
|
386
|
+
try {
|
|
387
|
+
const tmuxListRaw = execSync('tmux ls -F "#{session_name}"', { encoding: 'utf8' });
|
|
388
|
+
const allTmux = tmuxListRaw.trim().split('\n').filter(Boolean);
|
|
389
|
+
const managed = new Set(manager.list().map(s => s.name));
|
|
390
|
+
const untracked = allTmux.filter(n => !managed.has(n));
|
|
391
|
+
if (untracked.length) {
|
|
392
|
+
console.log(`\n Found ${untracked.length} untracked tmux session(s):`);
|
|
393
|
+
untracked.forEach(n => console.log(` ${n}`));
|
|
394
|
+
const adoptAns = await question(setupRl, ' Adopt and watch these? (y/N) ');
|
|
395
|
+
if (adoptAns === 'y' || adoptAns === 'yes') {
|
|
396
|
+
for (const sessionName of untracked) {
|
|
397
|
+
try {
|
|
398
|
+
let sessionPath = '';
|
|
399
|
+
try { sessionPath = execSync(`tmux display-message -p -t "${sessionName}" '#{pane_current_path}'`, { encoding: 'utf8' }).trim(); } catch {}
|
|
400
|
+
manager.adopt(sessionName, sessionPath);
|
|
401
|
+
console.log(` ✓ Adopted "${sessionName}"${sessionPath ? ` at ${sessionPath}` : ''}`);
|
|
402
|
+
} catch (e) { console.log(` ✗ ${e.message}`); }
|
|
403
|
+
}
|
|
404
|
+
console.log('');
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
} catch {}
|
|
408
|
+
|
|
385
409
|
const cwd = process.cwd();
|
|
386
410
|
const defaultName = path.basename(cwd);
|
|
387
411
|
const mount = await question(setupRl, `Mount current directory as a session? (${defaultName}) [y/N] `);
|
package/lib/Watcher.js
CHANGED
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,8 +31,10 @@ 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) {
|
|
@@ -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
|
}
|
|
@@ -621,14 +702,17 @@ function SessionDetailScreen({ session, onBack, onKilled }) {
|
|
|
621
702
|
<StatusPill status={session.status} />
|
|
622
703
|
</div>
|
|
623
704
|
|
|
624
|
-
<div
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
705
|
+
<div
|
|
706
|
+
className="terminal-body"
|
|
707
|
+
ref={terminalRef}
|
|
708
|
+
dangerouslySetInnerHTML={{ __html:
|
|
709
|
+
isOffline
|
|
710
|
+
? '<span style="color:oklch(50% 0.018 50)">Session is offline — no output available.</span>'
|
|
711
|
+
: output
|
|
712
|
+
? ansiToHtml(output) + '<span style="opacity:0.4">▊</span>'
|
|
713
|
+
: '<span style="color:oklch(50% 0.018 50)">Connecting…</span>'
|
|
714
|
+
}}
|
|
715
|
+
/>
|
|
632
716
|
|
|
633
717
|
{!isOffline && (
|
|
634
718
|
<div className="terminal-footer">
|
|
@@ -765,6 +849,56 @@ function SessionsScreen({ sessions, onNavigate }) {
|
|
|
765
849
|
);
|
|
766
850
|
}
|
|
767
851
|
|
|
852
|
+
/* --- Broadcast Bar --- */
|
|
853
|
+
function BroadcastBar({ activeSessions }) {
|
|
854
|
+
const [msg, setMsg] = useState('');
|
|
855
|
+
const [sending, setSending] = useState(false);
|
|
856
|
+
const [result, setResult] = useState(null);
|
|
857
|
+
|
|
858
|
+
const broadcast = async () => {
|
|
859
|
+
if (!msg.trim() || sending) return;
|
|
860
|
+
setSending(true);
|
|
861
|
+
try {
|
|
862
|
+
const res = await apiFetch('/api/broadcast', {
|
|
863
|
+
method: 'POST',
|
|
864
|
+
headers: { 'Content-Type': 'application/json' },
|
|
865
|
+
body: JSON.stringify({ message: msg }),
|
|
866
|
+
});
|
|
867
|
+
const data = await res.json();
|
|
868
|
+
if (res.ok) {
|
|
869
|
+
setResult(`Sent to ${data.sent} session${data.sent !== 1 ? 's' : ''}`);
|
|
870
|
+
setMsg('');
|
|
871
|
+
setTimeout(() => setResult(null), 3000);
|
|
872
|
+
}
|
|
873
|
+
} catch {}
|
|
874
|
+
finally { setSending(false); }
|
|
875
|
+
};
|
|
876
|
+
|
|
877
|
+
if (activeSessions === 0) return null;
|
|
878
|
+
|
|
879
|
+
return (
|
|
880
|
+
<div className="card" style={{ padding: '14px 18px', marginTop: 24 }}>
|
|
881
|
+
<div style={{ fontSize: 12, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--muted)', marginBottom: 10 }}>
|
|
882
|
+
Broadcast to all {activeSessions} active session{activeSessions !== 1 ? 's' : ''}
|
|
883
|
+
</div>
|
|
884
|
+
<div style={{ display: 'flex', gap: 8 }}>
|
|
885
|
+
<input
|
|
886
|
+
className="form-input"
|
|
887
|
+
placeholder="Send the same message to every active session…"
|
|
888
|
+
value={msg}
|
|
889
|
+
onChange={e => setMsg(e.target.value)}
|
|
890
|
+
onKeyDown={e => e.key === 'Enter' && broadcast()}
|
|
891
|
+
style={{ flex: 1 }}
|
|
892
|
+
/>
|
|
893
|
+
<button className="btn btn-primary btn-sm" onClick={broadcast} disabled={sending || !msg.trim()}>
|
|
894
|
+
{sending ? 'Sending…' : 'Send'}
|
|
895
|
+
</button>
|
|
896
|
+
</div>
|
|
897
|
+
{result && <div style={{ fontSize: 12, color: 'var(--success)', marginTop: 8 }}>{result}</div>}
|
|
898
|
+
</div>
|
|
899
|
+
);
|
|
900
|
+
}
|
|
901
|
+
|
|
768
902
|
/* --- App --- */
|
|
769
903
|
function App() {
|
|
770
904
|
const savedTheme = storage.getItem('ccp-theme');
|
|
@@ -791,6 +925,13 @@ function App() {
|
|
|
791
925
|
return () => { _onUnauth = () => {}; };
|
|
792
926
|
}, []);
|
|
793
927
|
|
|
928
|
+
// Request browser notification permission on first load
|
|
929
|
+
useEffect(() => {
|
|
930
|
+
if ('Notification' in window && Notification.permission === 'default') {
|
|
931
|
+
Notification.requestPermission();
|
|
932
|
+
}
|
|
933
|
+
}, []);
|
|
934
|
+
|
|
794
935
|
const connectSSE = useCallback(() => {
|
|
795
936
|
if (esRef.current) esRef.current.close();
|
|
796
937
|
const es = new EventSource(sseUrl());
|
|
@@ -808,8 +949,16 @@ function App() {
|
|
|
808
949
|
const prev = prevStatusRef.current;
|
|
809
950
|
const newEntries = [];
|
|
810
951
|
for (const s of incoming) {
|
|
811
|
-
if (prev[s.name] && prev[s.name] !== s.status)
|
|
952
|
+
if (prev[s.name] && prev[s.name] !== s.status) {
|
|
812
953
|
newEntries.push({ time: now, name: s.name, from: prev[s.name], to: s.status });
|
|
954
|
+
if ((s.status === 'needs-response' || s.status === 'limit') &&
|
|
955
|
+
'Notification' in window && Notification.permission === 'granted') {
|
|
956
|
+
new Notification(`Claude Pilot — ${s.name}`, {
|
|
957
|
+
body: s.status === 'needs-response' ? 'Claude needs your input' : 'Usage limit hit — will auto-resume',
|
|
958
|
+
tag: `ccp-${s.name}-${s.status}`,
|
|
959
|
+
});
|
|
960
|
+
}
|
|
961
|
+
}
|
|
813
962
|
prev[s.name] = s.status;
|
|
814
963
|
}
|
|
815
964
|
if (newEntries.length) setActivity(a => [...newEntries, ...a].slice(0, 20));
|
package/package.json
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-remote-pilot",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Interactive Claude Code supervisor — spawn and monitor multiple Claude sessions from a single terminal.",
|
|
5
5
|
"type": "commonjs",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/mekku/claude-code-remote-pilot.git"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://github.com/mekku/claude-code-remote-pilot#readme",
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/mekku/claude-code-remote-pilot/issues"
|
|
13
|
+
},
|
|
6
14
|
"bin": {
|
|
7
15
|
"claude-remote-pilot": "bin/claude-pilot.js"
|
|
8
16
|
},
|