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 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 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
 
@@ -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
@@ -32,6 +32,7 @@ class Watcher {
32
32
  }
33
33
 
34
34
  start() {
35
+ this._check(); // run immediately so status is correct from the first second
35
36
  this._timer = setInterval(() => this._check(), this.checkInterval);
36
37
  }
37
38
 
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
- 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) {
@@ -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
  }
@@ -621,14 +702,17 @@ function SessionDetailScreen({ session, onBack, onKilled }) {
621
702
  <StatusPill status={session.status} />
622
703
  </div>
623
704
 
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>
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.4.8",
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
  },