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 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 for each session (auto-refreshes every 2 seconds)
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
- return result.stdout ? result.stdout.replace(STRIP_ANSI, '') : '';
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
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.4.5</span>
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
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 className="terminal-body" ref={terminalRef}>
625
- {isOffline
626
- ? <span style={{ color: 'oklch(50% 0.018 50)' }}>Session is offline — no output available.</span>
627
- : output
628
- ? <>{output}<span style={{ opacity: 0.4 }}>▊</span></>
629
- : <span style={{ color: 'oklch(50% 0.018 50)' }}>Connecting…</span>
630
- }
631
- </div>
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.4.9",
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": {