claude-code-remote-pilot 0.3.1 → 0.4.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.4.0 — 2026-05-06
4
+
5
+ ### Added
6
+ - **Web dashboard**: `web [port]` REPL command starts a browser UI at `http://127.0.0.1:3742` (auto-opens in default browser).
7
+ - Live session list via SSE (Server-Sent Events) — no polling, no WebSocket dependency
8
+ - Terminal output viewer with 2-second auto-refresh
9
+ - Send message to Claude from the browser
10
+ - Create new sessions from the browser (name, path, optional initial prompt)
11
+ - Kill sessions from the session detail view
12
+ - Dark/light mode, responsive layout (mobile + desktop)
13
+ - Status activity log: tracks transitions between running / idle / needs-response / limit / offline
14
+ - All tmux calls use `spawnSync` array args — shell-injection safe; server binds to `127.0.0.1` only
15
+
16
+ ### Fixed
17
+ - Shell injection vulnerability in `Watcher.js`: `tmux send-keys` on auto-resume now uses `spawnSync` with array args instead of shell interpolation.
18
+
19
+ ---
20
+
3
21
  ## 0.3.1 — 2026-05-06
4
22
 
5
23
  ### Changed
package/README.md CHANGED
@@ -104,6 +104,7 @@ sudo apt update && sudo apt install tmux
104
104
  | `spawn <path> [name]` | Start Claude at a path. Name defaults to the directory name. |
105
105
  | `list` | One-shot status of all sessions. |
106
106
  | `watch` | Live dashboard with offline session history. Press a number to select, `q` to exit. |
107
+ | `web [port]` | Start the web dashboard at `http://127.0.0.1:3742` (or given port). Opens browser automatically. |
107
108
  | `attach <name>` | Open a tmux session in the current terminal. |
108
109
  | `kill <name>` | Stop a session. |
109
110
  | `help` | Show command reference. |
@@ -111,6 +112,27 @@ sudo apt update && sudo apt install tmux
111
112
 
112
113
  ---
113
114
 
115
+ ## Web dashboard
116
+
117
+ Type `web` in the REPL to open a browser dashboard:
118
+
119
+ ```
120
+ claude-pilot> web
121
+ ✓ Web dashboard started at http://127.0.0.1:3742
122
+ ```
123
+
124
+ The dashboard shows all sessions (live and offline), lets you:
125
+
126
+ - View terminal output for each session (auto-refreshes every 2 seconds)
127
+ - Send a message to Claude directly from the browser
128
+ - Spawn new sessions with a name, path, and optional initial prompt
129
+ - Kill sessions
130
+ - See a live activity log of status transitions
131
+
132
+ The server binds to `127.0.0.1` only — not reachable from other machines. Use `web <port>` to use a custom port.
133
+
134
+ ---
135
+
114
136
  ## Telegram setup
115
137
 
116
138
  Create a bot via `@BotFather` and get your token.
@@ -173,7 +195,7 @@ Start Claude without `--dangerously-skip-permissions` unless you know what you'r
173
195
  - [x] Telegram notifications
174
196
  - [x] interactive REPL — spawn, watch, attach, kill
175
197
  - [x] multi-session support
176
- - [ ] web dashboard (sessions connect to pilot server)
198
+ - [x] web dashboard `web [port]` command, React SPA, SSE live updates
177
199
  - [x] persistent session history with offline session display
178
200
  - [ ] pluggable notification providers
179
201
  - [ ] safety / policy engine
@@ -6,6 +6,7 @@ const path = require('path');
6
6
  const fs = require('fs');
7
7
  const readline = require('readline');
8
8
  const SessionManager = require('../lib/SessionManager');
9
+ const WebServer = require('../lib/WebServer');
9
10
  const config = require('../lib/config');
10
11
 
11
12
  // ─── dependency checks ────────────────────────────────────────────────────────
@@ -318,6 +319,7 @@ const HELP = `
318
319
  spawn <path> [name] Start Claude at path (name defaults to dir name)
319
320
  list Show all sessions
320
321
  watch Live session monitor (q to exit)
322
+ web [port] Start web dashboard (default port 3742)
321
323
  attach <name> Open tmux session in this terminal
322
324
  kill <name> Stop a session
323
325
  resume [message] Show or set the message sent after a limit resets
@@ -370,9 +372,9 @@ ${HELP}`);
370
372
 
371
373
  const cwd = process.cwd();
372
374
  const defaultName = path.basename(cwd);
373
- const mount = await question(setupRl, `Mount current directory as a session? (${defaultName}) [Y/n] `);
375
+ const mount = await question(setupRl, `Mount current directory as a session? (${defaultName}) [y/N] `);
374
376
 
375
- if (isYes(mount)) {
377
+ if (mount === 'y' || mount === 'yes') {
376
378
  const rawName = await questionRaw(setupRl, `Session name [${defaultName}]: `);
377
379
  const session = manager.spawn(cwd, rawName || defaultName);
378
380
  console.log(` ✓ "${session.name}" started at ${session.path}`);
@@ -428,6 +430,22 @@ ${HELP}`);
428
430
  startWatch(manager, replRl);
429
431
  return;
430
432
  }
433
+ case 'web': {
434
+ const port = parseInt(args[0]) || 3742;
435
+ let webServer = manager._webServer;
436
+ if (webServer) {
437
+ console.log(` Web dashboard already running at http://127.0.0.1:${webServer.port}`);
438
+ break;
439
+ }
440
+ webServer = new WebServer(manager, port);
441
+ manager._webServer = webServer;
442
+ webServer.start();
443
+ const url = `http://127.0.0.1:${port}`;
444
+ console.log(` ✓ Web dashboard started at ${url}`);
445
+ const opener = process.platform === 'darwin' ? 'open' : 'xdg-open';
446
+ spawn(opener, [url], { stdio: 'ignore', detached: true }).unref();
447
+ break;
448
+ }
431
449
  case 'attach': {
432
450
  if (!args[0]) { console.log(' Usage: attach <name>'); break; }
433
451
  replRl.pause();
package/lib/Watcher.js CHANGED
@@ -1,5 +1,5 @@
1
1
  'use strict';
2
- const { execSync } = require('child_process');
2
+ const { execSync, spawnSync } = require('child_process');
3
3
  const crypto = require('crypto');
4
4
  const notifier = require('./notifier');
5
5
 
@@ -146,7 +146,7 @@ class Watcher {
146
146
 
147
147
  await new Promise(r => setTimeout(r, wait * 1000));
148
148
 
149
- try { execSync(`tmux send-keys -t "${this.session.name}" "${this.resumeCommand}" Enter`, { stdio: 'ignore' }); }
149
+ try { spawnSync('tmux', ['send-keys', '-t', this.session.name, this.resumeCommand, 'Enter'], { stdio: 'ignore' }); }
150
150
  catch {}
151
151
 
152
152
  this.lastResumeAt = Date.now() / 1000;
@@ -0,0 +1,171 @@
1
+ 'use strict';
2
+ const http = require('http');
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { spawnSync } = require('child_process');
6
+ const config = require('./config');
7
+
8
+ const STRIP_ANSI = /\x1b\[[0-9;]*[mGKHFABCDJsuhl]|\x1b[()][AB012]/g;
9
+
10
+ class WebServer {
11
+ constructor(manager, port = 3742) {
12
+ this.manager = manager;
13
+ this.port = port;
14
+ this.startedAt = new Date();
15
+ this.server = null;
16
+ this._clients = new Set();
17
+ this._broadcastInterval = null;
18
+ }
19
+
20
+ _buildAllSessions() {
21
+ const active = this.manager.list();
22
+ const activeNames = new Set(active.map(s => s.name));
23
+ const history = config.getHistory();
24
+ const offline = history
25
+ .filter(h => !activeNames.has(h.name))
26
+ .map(h => ({ name: h.name, path: h.path, status: 'offline', startedAt: h.lastSeen, resumeAt: null }));
27
+ return [...active, ...offline].map(s => ({ ...s, id: s.name }));
28
+ }
29
+
30
+ _getOutput(name) {
31
+ const result = spawnSync('tmux', ['capture-pane', '-pt', name, '-S', '-500'], { encoding: 'utf8' });
32
+ return result.stdout ? result.stdout.replace(STRIP_ANSI, '') : '';
33
+ }
34
+
35
+ _json(res, code, data) {
36
+ res.writeHead(code, { 'Content-Type': 'application/json' });
37
+ res.end(JSON.stringify(data));
38
+ }
39
+
40
+ _readBody(req, cb) {
41
+ let raw = '';
42
+ req.on('data', d => raw += d);
43
+ req.on('end', () => {
44
+ try { cb(null, JSON.parse(raw || '{}')); }
45
+ catch { cb(new Error('Invalid JSON')); }
46
+ });
47
+ }
48
+
49
+ _handleApi(req, res, pathname) {
50
+ // GET /api/sessions
51
+ if (req.method === 'GET' && pathname === '/api/sessions') {
52
+ return this._json(res, 200, this._buildAllSessions());
53
+ }
54
+
55
+ // GET /api/status
56
+ if (req.method === 'GET' && pathname === '/api/status') {
57
+ return this._json(res, 200, {
58
+ startedAt: this.startedAt,
59
+ port: this.port,
60
+ activeSessions: this.manager.list().length,
61
+ });
62
+ }
63
+
64
+ // POST /api/sessions — spawn
65
+ if (req.method === 'POST' && pathname === '/api/sessions') {
66
+ return this._readBody(req, (err, body) => {
67
+ if (err) return this._json(res, 400, { error: err.message });
68
+ const { name, path: dirPath, prompt: initialPrompt } = body;
69
+ try {
70
+ const session = this.manager.spawn(dirPath, name);
71
+ if (initialPrompt) {
72
+ setTimeout(() => {
73
+ spawnSync('tmux', ['send-keys', '-t', session.name, initialPrompt, 'Enter']);
74
+ }, 2000);
75
+ }
76
+ this._json(res, 201, { ...session, id: session.name });
77
+ } catch (e) {
78
+ this._json(res, 400, { error: e.message });
79
+ }
80
+ });
81
+ }
82
+
83
+ // GET /api/sessions/:name/output
84
+ const outputMatch = pathname.match(/^\/api\/sessions\/([^/]+)\/output$/);
85
+ if (req.method === 'GET' && outputMatch) {
86
+ const name = decodeURIComponent(outputMatch[1]);
87
+ return this._json(res, 200, { output: this._getOutput(name) });
88
+ }
89
+
90
+ // POST /api/sessions/:name/send
91
+ const sendMatch = pathname.match(/^\/api\/sessions\/([^/]+)\/send$/);
92
+ if (req.method === 'POST' && sendMatch) {
93
+ const name = decodeURIComponent(sendMatch[1]);
94
+ return this._readBody(req, (err, body) => {
95
+ if (err) return this._json(res, 400, { error: err.message });
96
+ const { message } = body;
97
+ if (!message) return this._json(res, 400, { error: 'message required' });
98
+ spawnSync('tmux', ['send-keys', '-t', name, message, 'Enter']);
99
+ this._json(res, 200, { ok: true });
100
+ });
101
+ }
102
+
103
+ // DELETE /api/sessions/:name
104
+ const killMatch = pathname.match(/^\/api\/sessions\/([^/]+)$/);
105
+ if (req.method === 'DELETE' && killMatch) {
106
+ const name = decodeURIComponent(killMatch[1]);
107
+ try {
108
+ this.manager.kill(name);
109
+ return this._json(res, 200, { ok: true });
110
+ } catch (e) {
111
+ return this._json(res, 400, { error: e.message });
112
+ }
113
+ }
114
+
115
+ this._json(res, 404, { error: 'Not found' });
116
+ }
117
+
118
+ _broadcast() {
119
+ if (!this._clients.size) return;
120
+ const payload = `data: ${JSON.stringify(this._buildAllSessions())}\n\n`;
121
+ for (const res of this._clients) {
122
+ try { res.write(payload); } catch { this._clients.delete(res); }
123
+ }
124
+ }
125
+
126
+ start() {
127
+ const uiHtml = fs.readFileSync(path.join(__dirname, 'ui.html'), 'utf8');
128
+
129
+ this.server = http.createServer((req, res) => {
130
+ const url = new URL(req.url, `http://127.0.0.1:${this.port}`);
131
+ const pathname = url.pathname;
132
+
133
+ if (pathname === '/' || pathname === '/index.html') {
134
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
135
+ return res.end(uiHtml);
136
+ }
137
+
138
+ if (pathname === '/events') {
139
+ res.writeHead(200, {
140
+ 'Content-Type': 'text/event-stream',
141
+ 'Cache-Control': 'no-cache',
142
+ 'Connection': 'keep-alive',
143
+ });
144
+ this._clients.add(res);
145
+ res.write(`data: ${JSON.stringify(this._buildAllSessions())}\n\n`);
146
+ req.on('close', () => this._clients.delete(res));
147
+ return;
148
+ }
149
+
150
+ if (pathname.startsWith('/api/')) {
151
+ return this._handleApi(req, res, pathname);
152
+ }
153
+
154
+ res.writeHead(404);
155
+ res.end('Not found');
156
+ });
157
+
158
+ this._broadcastInterval = setInterval(() => this._broadcast(), 3000);
159
+ this.server.listen(this.port, '127.0.0.1');
160
+ return this.port;
161
+ }
162
+
163
+ stop() {
164
+ clearInterval(this._broadcastInterval);
165
+ for (const res of this._clients) { try { res.end(); } catch {} }
166
+ this._clients.clear();
167
+ if (this.server) this.server.close();
168
+ }
169
+ }
170
+
171
+ module.exports = WebServer;
package/lib/ui.html ADDED
@@ -0,0 +1,858 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head><script>(function(){
4
+ function makeStore(){
5
+ var data={};
6
+ var api={getItem:function(k){return Object.prototype.hasOwnProperty.call(data,k)?data[k]:null;},setItem:function(k,v){data[k]=String(v);},removeItem:function(k){delete data[k];},clear:function(){data={};},key:function(i){return Object.keys(data)[i]||null;}};
7
+ Object.defineProperty(api,'length',{get:function(){return Object.keys(data).length;}});
8
+ return api;
9
+ }
10
+ function tryShim(name){var works=false;try{works=!!window[name]&&typeof window[name].getItem==='function';void window[name].length;}catch(_){works=false;}if(works)return;try{Object.defineProperty(window,name,{configurable:true,value:makeStore()});}catch(_){try{window[name]=makeStore();}catch(__){}}};
11
+ tryShim('localStorage');tryShim('sessionStorage');
12
+ })();</script>
13
+ <meta charset="utf-8">
14
+ <meta name="viewport" content="width=device-width, initial-scale=1">
15
+ <title>Claude Code Pilot — Dashboard</title>
16
+ <script src="https://unpkg.com/react@18.3.1/umd/react.development.js" crossorigin="anonymous"></script>
17
+ <script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" crossorigin="anonymous"></script>
18
+ <script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" crossorigin="anonymous"></script>
19
+ <style>
20
+ *,*::before,*::after{box-sizing:border-box;margin:0;padding:0;}
21
+
22
+ :root {
23
+ --bg: oklch(97% 0.018 70);
24
+ --surface: oklch(99% 0.008 70);
25
+ --surface-hover: oklch(96% 0.016 70);
26
+ --fg: oklch(22% 0.02 50);
27
+ --fg-secondary: oklch(35% 0.022 50);
28
+ --muted: oklch(50% 0.018 50);
29
+ --border: oklch(90% 0.014 70);
30
+ --accent: oklch(64% 0.13 28);
31
+ --accent-soft: oklch(64% 0.13 28 / 0.08);
32
+ --accent-medium: oklch(64% 0.13 28 / 0.15);
33
+ --success: oklch(62% 0.14 145);
34
+ --success-soft: oklch(62% 0.14 145 / 0.1);
35
+ --warning: oklch(68% 0.16 75);
36
+ --warning-soft: oklch(68% 0.16 75 / 0.1);
37
+ --error: oklch(58% 0.20 25);
38
+ --error-soft: oklch(58% 0.20 25 / 0.1);
39
+ --idle: oklch(55% 0.018 250);
40
+ --idle-soft: oklch(55% 0.018 250 / 0.1);
41
+ --font-display: 'Newsreader','Iowan Old Style',Georgia,serif;
42
+ --font-body: -apple-system,BlinkMacSystemFont,'SF Pro Text',system-ui,sans-serif;
43
+ --font-mono: 'SF Mono','JetBrains Mono',ui-monospace,Menlo,monospace;
44
+ --radius-sm: 6px;
45
+ --radius-md: 10px;
46
+ --radius-lg: 14px;
47
+ --shadow-sm: 0 1px 2px oklch(22% 0.02 50 / 0.04);
48
+ --shadow-md: 0 2px 8px oklch(22% 0.02 50 / 0.06);
49
+ --shadow-inner: inset 0 1px 3px oklch(22% 0.02 50 / 0.04);
50
+ --sidebar-w: 240px;
51
+ --header-h: 56px;
52
+ }
53
+
54
+ [data-theme="dark"] {
55
+ --bg: oklch(18% 0.018 50);
56
+ --surface: oklch(22% 0.02 50);
57
+ --surface-hover: oklch(26% 0.022 50);
58
+ --fg: oklch(95% 0.008 70);
59
+ --fg-secondary: oklch(75% 0.012 70);
60
+ --muted: oklch(55% 0.018 50);
61
+ --border: oklch(32% 0.02 50);
62
+ --accent: oklch(70% 0.14 32);
63
+ --accent-soft: oklch(70% 0.14 32 / 0.12);
64
+ --accent-medium: oklch(70% 0.14 32 / 0.2);
65
+ --success: oklch(68% 0.15 150);
66
+ --success-soft: oklch(68% 0.15 150 / 0.12);
67
+ --warning: oklch(72% 0.16 78);
68
+ --warning-soft: oklch(72% 0.16 78 / 0.12);
69
+ --error: oklch(64% 0.20 28);
70
+ --error-soft: oklch(64% 0.20 28 / 0.12);
71
+ --idle: oklch(60% 0.018 250);
72
+ --idle-soft: oklch(60% 0.018 250 / 0.12);
73
+ --shadow-sm: 0 1px 2px oklch(0% 0 0 / 0.2);
74
+ --shadow-md: 0 2px 8px oklch(0% 0 0 / 0.25);
75
+ --shadow-inner: inset 0 1px 3px oklch(0% 0 0 / 0.15);
76
+ }
77
+
78
+ body { font-family:var(--font-body);background:var(--bg);color:var(--fg);line-height:1.5;font-size:14px;-webkit-font-smoothing:antialiased; }
79
+
80
+ #root { height:100vh;display:flex;flex-direction:column; }
81
+ .app-shell { display:flex;flex-direction:column;height:100vh;overflow:hidden; }
82
+
83
+ .sidebar { position:fixed;top:0;left:0;width:100%;height:auto;max-height:85vh;background:var(--surface);border-bottom:1px solid var(--border);z-index:100;transform:translateY(-100%);transition:transform 0.25s ease;border-radius:0 0 var(--radius-lg) var(--radius-lg);overflow-y:auto; }
84
+ .sidebar.open { transform:translateY(0); }
85
+ .sidebar-overlay { position:fixed;inset:0;background:oklch(0% 0 0 / 0.3);z-index:99;opacity:0;pointer-events:none;transition:opacity 0.25s ease; }
86
+ .sidebar-overlay.open { opacity:1;pointer-events:auto; }
87
+ .sidebar-header { padding:14px 16px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:12px; }
88
+ .sidebar-close { margin-left:auto;background:none;border:none;cursor:pointer;color:var(--muted);padding:4px;display:flex; }
89
+ .logo { display:flex;align-items:center;gap:10px;text-decoration:none;color:var(--fg); }
90
+ .logo-mark { width:28px;height:28px;background:var(--accent);border-radius:var(--radius-sm);display:flex;align-items:center;justify-content:center;color:var(--surface);font-weight:700;font-size:13px; }
91
+ .logo-text { font-family:var(--font-display);font-size:16px;font-weight:600;letter-spacing:-0.01em; }
92
+ .logo-badge { font-size:10px;font-family:var(--font-mono);color:var(--muted);background:var(--accent-soft);padding:2px 6px;border-radius:4px; }
93
+ .sidebar-nav { padding:12px 10px; }
94
+ .nav-section-label { font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.06em;color:var(--muted);padding:8px 8px 4px;margin-top:8px; }
95
+ .nav-item { display:flex;align-items:center;gap:10px;padding:10px;border-radius:var(--radius-sm);color:var(--fg-secondary);cursor:pointer;font-size:14px;font-weight:500;transition:background 0.15s,color 0.15s;user-select:none;border:none;background:none;width:100%;text-align:left; }
96
+ .nav-item:hover { background:var(--surface-hover);color:var(--fg); }
97
+ .nav-item.active { background:var(--accent-soft);color:var(--fg);font-weight:600; }
98
+ .nav-icon { width:18px;height:18px;opacity:0.7;flex-shrink:0; }
99
+ .nav-item.active .nav-icon { opacity:1; }
100
+ .nav-count { margin-left:auto;font-size:11px;font-family:var(--font-mono);color:var(--muted);background:var(--surface-hover);padding:1px 6px;border-radius:10px;min-width:20px;text-align:center; }
101
+ .sidebar-footer { padding:12px 10px;border-top:1px solid var(--border);margin-top:auto; }
102
+
103
+ .main { flex:1;display:flex;flex-direction:column;overflow:hidden; }
104
+ .mobile-header { height:var(--header-h);border-bottom:1px solid var(--border);background:var(--surface);display:flex;align-items:center;padding:0 16px;gap:12px;flex-shrink:0; }
105
+ .menu-btn { background:none;border:none;cursor:pointer;color:var(--fg);padding:6px;display:flex; }
106
+ .header { height:var(--header-h);border-bottom:1px solid var(--border);background:var(--surface);display:none;align-items:center;padding:0 24px;gap:16px;flex-shrink:0; }
107
+ .mobile-title { font-family:var(--font-display);font-size:15px;font-weight:600;flex:1; }
108
+ .header-title { font-family:var(--font-display);font-size:15px;font-weight:600; }
109
+ .header-spacer { flex:1; }
110
+ .theme-toggle { display:flex;align-items:center;gap:8px;padding:6px 10px;border-radius:var(--radius-sm);border:1px solid var(--border);background:var(--bg);color:var(--fg-secondary);cursor:pointer;font-size:12px;font-family:var(--font-body);transition:border-color 0.15s; }
111
+ .theme-toggle:hover { border-color:var(--muted); }
112
+
113
+ .content { flex:1;overflow-y:auto;padding:16px; }
114
+
115
+ .session-cards { display:grid;grid-template-columns:1fr;gap:12px; }
116
+ .session-card { background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-md);padding:16px;cursor:pointer;transition:border-color 0.15s,box-shadow 0.15s; }
117
+ .session-card:hover { border-color:var(--muted);box-shadow:var(--shadow-md); }
118
+ .session-card.offline { opacity:0.6; }
119
+ .session-card-header { display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin-bottom:10px; }
120
+ .session-card-name { font-weight:600;font-size:14px;color:var(--fg);line-height:1.3; }
121
+ .session-card-meta { font-size:12px;color:var(--muted);margin-top:4px; }
122
+ .session-card-body { display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:12px;padding-top:12px;border-top:1px solid var(--border); }
123
+ .session-card-field { font-size:11px; }
124
+ .session-card-field-label { color:var(--muted);font-weight:500;text-transform:uppercase;letter-spacing:0.04em;margin-bottom:2px; }
125
+ .session-card-field-value { font-family:var(--font-mono);font-size:12px;color:var(--fg-secondary); }
126
+
127
+ .status-pill { display:inline-flex;align-items:center;gap:5px;font-size:11px;font-weight:600;padding:3px 8px;border-radius:10px;font-family:var(--font-mono);letter-spacing:0.02em; }
128
+ .status-dot { width:6px;height:6px;border-radius:50%; }
129
+ .status-running { background:var(--success-soft);color:var(--success); }
130
+ .status-running .status-dot { background:var(--success); }
131
+ .status-idle { background:var(--idle-soft);color:var(--idle); }
132
+ .status-idle .status-dot { background:var(--idle); }
133
+ .status-error { background:var(--error-soft);color:var(--error); }
134
+ .status-error .status-dot { background:var(--error); }
135
+ .status-warn { background:var(--warning-soft);color:var(--warning); }
136
+ .status-warn .status-dot { background:var(--warning); }
137
+
138
+ .card { background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-md);box-shadow:var(--shadow-sm); }
139
+
140
+ .btn { display:inline-flex;align-items:center;gap:6px;padding:7px 14px;border-radius:var(--radius-sm);font-size:13px;font-weight:500;font-family:var(--font-body);cursor:pointer;border:1px solid var(--border);background:var(--surface);color:var(--fg);transition:background 0.15s,border-color 0.15s;white-space:nowrap; }
141
+ .btn:hover { background:var(--surface-hover); }
142
+ .btn:disabled { opacity:0.5;cursor:not-allowed; }
143
+ .btn-primary { background:var(--accent);color:oklch(99% 0.008 70);border-color:var(--accent); }
144
+ .btn-primary:hover { filter:brightness(1.05); }
145
+ .btn-sm { padding:4px 10px;font-size:12px; }
146
+
147
+ .terminal { background:oklch(14% 0.015 50);color:oklch(82% 0.015 70);font-family:var(--font-mono);font-size:13px;line-height:1.6;border-radius:var(--radius-md);overflow:hidden;border:1px solid oklch(28% 0.02 50); }
148
+ .terminal-header { display:flex;align-items:center;gap:8px;padding:10px 14px;background:oklch(18% 0.018 50);border-bottom:1px solid oklch(28% 0.02 50); }
149
+ .terminal-dot { width:10px;height:10px;border-radius:50%; }
150
+ .terminal-title { font-size:12px;color:oklch(65% 0.018 50);margin-left:8px; }
151
+ .terminal-body { padding:16px;min-height:320px;max-height:420px;overflow-y:auto;white-space:pre-wrap;word-break:break-word;font-family:var(--font-mono);font-size:12px;line-height:1.5; }
152
+
153
+ .form-group { margin-bottom:18px; }
154
+ .form-label { display:block;font-size:12px;font-weight:600;color:var(--fg);margin-bottom:6px; }
155
+ .form-hint { font-size:11px;color:var(--muted);margin-top:4px; }
156
+ .form-input,.form-select,.form-textarea { width:100%;padding:8px 12px;border:1px solid var(--border);border-radius:var(--radius-sm);background:var(--bg);color:var(--fg);font-size:13px;font-family:var(--font-body);transition:border-color 0.15s; }
157
+ .form-input:focus,.form-select:focus,.form-textarea:focus { outline:none;border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft); }
158
+ .form-textarea { resize:vertical;min-height:80px; }
159
+ .form-actions { display:flex;gap:10px;justify-content:flex-end;padding-top:20px;border-top:1px solid var(--border);margin-top:8px; }
160
+
161
+ .stat-row { display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:20px; }
162
+ .stat-card { padding:14px;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-md);box-shadow:var(--shadow-inner); }
163
+ .stat-label { font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;color:var(--muted);margin-bottom:6px; }
164
+ .stat-value { font-size:22px;font-weight:700;font-family:var(--font-display);color:var(--fg);line-height:1.2; }
165
+ .stat-sub { font-size:11px;color:var(--muted);margin-top:4px; }
166
+
167
+ .activity-list { list-style:none; }
168
+ .activity-item { display:flex;align-items:flex-start;gap:12px;padding:10px 0;border-bottom:1px solid var(--border);font-size:13px; }
169
+ .activity-item:last-child { border-bottom:none; }
170
+ .activity-time { font-size:11px;color:var(--muted);font-family:var(--font-mono);white-space:nowrap;margin-top:2px; }
171
+ .activity-text { color:var(--fg-secondary); }
172
+ .activity-text strong { color:var(--fg);font-weight:600; }
173
+
174
+ .section-header { display:flex;align-items:center;justify-content:space-between;margin-bottom:16px; }
175
+ .section-title { font-family:var(--font-display);font-size:15px;font-weight:600; }
176
+
177
+ .back-link { display:inline-flex;align-items:center;gap:6px;font-size:13px;color:var(--muted);cursor:pointer;margin-bottom:16px;font-weight:500;border:none;background:none;padding:0;font-family:var(--font-body); }
178
+ .back-link:hover { color:var(--fg); }
179
+
180
+ .detail-header { display:flex;flex-direction:column;align-items:flex-start;gap:12px;margin-bottom:20px; }
181
+ .detail-title { font-family:var(--font-display);font-size:18px;font-weight:600;line-height:1.3; }
182
+ .detail-meta { font-size:12px;color:var(--muted);margin-top:4px; }
183
+ .detail-actions { display:flex;gap:8px;margin-left:0;flex-wrap:wrap; }
184
+
185
+ .detail-grid { display:grid;grid-template-columns:1fr;gap:16px; }
186
+ .detail-sidebar .card { padding:16px; }
187
+ .detail-sidebar h3 { font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;color:var(--muted);margin-bottom:12px; }
188
+
189
+ .info-row { display:flex;justify-content:space-between;padding:6px 0;font-size:12px; }
190
+ .info-label { color:var(--muted); }
191
+ .info-value { font-weight:600;font-family:var(--font-mono); }
192
+
193
+ .empty-state { text-align:center;padding:48px 24px;color:var(--muted); }
194
+ .empty-state-icon { font-size:32px;margin-bottom:12px;opacity:0.5; }
195
+ .empty-state-title { font-family:var(--font-display);font-size:15px;font-weight:600;color:var(--fg-secondary);margin-bottom:6px; }
196
+ .empty-state-desc { font-size:13px; }
197
+
198
+ .create-layout { max-width:640px; }
199
+
200
+ .content::-webkit-scrollbar { width:6px; }
201
+ .content::-webkit-scrollbar-track { background:transparent; }
202
+ .content::-webkit-scrollbar-thumb { background:var(--border);border-radius:3px; }
203
+ .terminal-body::-webkit-scrollbar { width:6px; }
204
+ .terminal-body::-webkit-scrollbar-track { background:transparent; }
205
+ .terminal-body::-webkit-scrollbar-thumb { background:oklch(30% 0.02 50);border-radius:3px; }
206
+
207
+ .conn-dot { width:8px;height:8px;border-radius:50%;background:var(--success);display:inline-block; }
208
+ .conn-dot.disconnected { background:var(--error); }
209
+
210
+ @media (min-width:768px) {
211
+ .app-shell { flex-direction:row; }
212
+ .sidebar { position:static;width:var(--sidebar-w);height:100vh;max-height:none;transform:none;border-bottom:none;border-right:1px solid var(--border);border-radius:0;display:flex;flex-direction:column; }
213
+ .sidebar-close,.sidebar-overlay { display:none; }
214
+ .mobile-header { display:none; }
215
+ .header { display:flex; }
216
+ .content { padding:24px; }
217
+ .session-cards { grid-template-columns:repeat(auto-fill,minmax(300px,1fr)); }
218
+ .stat-row { grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:24px; }
219
+ .detail-grid { grid-template-columns:1fr 300px;gap:20px; }
220
+ .detail-header { flex-direction:row;align-items:flex-start;gap:16px;margin-bottom:24px; }
221
+ .detail-title { font-size:20px; }
222
+ .detail-actions { margin-left:auto; }
223
+ }
224
+
225
+ @media (min-width:1200px) {
226
+ .session-cards { grid-template-columns:repeat(auto-fill,minmax(340px,1fr)); }
227
+ }
228
+ </style>
229
+ </head>
230
+ <body>
231
+ <div id="root"></div>
232
+
233
+ <script type="text/babel">
234
+ const { useState, useEffect, useRef, useCallback } = React;
235
+
236
+ const memStore = new Map();
237
+ const storage = {
238
+ getItem(key) { try { return localStorage.getItem(key); } catch { return memStore.has(key) ? memStore.get(key) : null; } },
239
+ setItem(key, val) { try { localStorage.setItem(key, val); } catch { memStore.set(key, val); } },
240
+ };
241
+
242
+ function relativeTime(dateStr) {
243
+ if (!dateStr) return '—';
244
+ const secs = Math.max(0, Math.floor((Date.now() - new Date(dateStr)) / 1000));
245
+ if (secs < 60) return `${secs}s ago`;
246
+ if (secs < 3600) return `${Math.floor(secs / 60)}m ago`;
247
+ if (secs < 86400) return `${Math.floor(secs / 3600)}h ${Math.floor((secs % 3600) / 60)}m ago`;
248
+ return `${Math.floor(secs / 86400)}d ago`;
249
+ }
250
+
251
+ function formatUptime(startedAt) {
252
+ if (!startedAt) return '—';
253
+ const secs = Math.max(0, Math.floor((Date.now() - new Date(startedAt)) / 1000));
254
+ const h = Math.floor(secs / 3600);
255
+ const m = Math.floor((secs % 3600) / 60);
256
+ if (h > 0) return `${h}h ${m}m`;
257
+ return `${m}m`;
258
+ }
259
+
260
+ function formatTokens(tokens) {
261
+ if (!tokens) return '—';
262
+ return `↑${tokens.sent} ↓${tokens.received}`;
263
+ }
264
+
265
+ function statusCls(status) {
266
+ switch (status) {
267
+ case 'running': return 'status-running';
268
+ case 'needs-response': return 'status-warn';
269
+ case 'limit': return 'status-warn';
270
+ case 'idle': return 'status-idle';
271
+ default: return 'status-idle';
272
+ }
273
+ }
274
+
275
+ function statusLabel(status) {
276
+ if (status === 'needs-response') return 'needs input';
277
+ return status;
278
+ }
279
+
280
+ /* --- Icons --- */
281
+ const Icons = {
282
+ 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>,
283
+ sessions: <svg className="nav-icon" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.5"><rect x="2" y="3" width="14" height="12" rx="1.5"/><line x1="2" y1="7" x2="16" y2="7"/><line x1="5" y1="11" x2="9" y2="11"/></svg>,
284
+ plus: <svg className="nav-icon" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.5"><line x1="9" y1="3" x2="9" y2="15"/><line x1="3" y1="9" x2="15" y2="9"/></svg>,
285
+ settings: <svg className="nav-icon" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.5"><circle cx="9" cy="9" r="3"/><path d="M9 1v2M9 15v2M1 9h2M15 9h2M3.3 3.3l1.4 1.4M13.3 13.3l1.4 1.4M3.3 14.7l1.4-1.4M13.3 4.7l1.4-1.4"/></svg>,
286
+ sun: <svg className="nav-icon" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.5"><circle cx="9" cy="9" r="4"/><line x1="9" y1="1" x2="9" y2="3"/><line x1="9" y1="15" x2="9" y2="17"/><line x1="1" y1="9" x2="3" y2="9"/><line x1="15" y1="9" x2="17" y2="9"/></svg>,
287
+ moon: <svg className="nav-icon" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M15 10.5a6.5 6.5 0 0 1-8-8A6.5 6.5 0 1 0 15 10.5Z"/></svg>,
288
+ arrow: <svg className="nav-icon" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.5"><polyline points="11,4 6,9 11,14"/></svg>,
289
+ terminal: <svg className="nav-icon" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.5"><polyline points="4,5 8,9 4,13"/><line x1="10" y1="13" x2="14" y2="13"/></svg>,
290
+ trash: <svg className="nav-icon" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M3 5h12M7 5V3.5A1.5 1.5 0 0 1 8.5 2h1A1.5 1.5 0 0 1 11 3.5V5M6 8v5M9 8v5M12 8v5"/><path d="M4.5 5l.7 9.8a1.5 1.5 0 0 0 1.5 1.2h6.6a1.5 1.5 0 0 0 1.5-1.2L13.5 5"/></svg>,
291
+ refresh: <svg className="nav-icon" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M3 9a6 6 0 0 1 10.9-2.5M15 9a6 6 0 0 1-10.9 2.5"/><polyline points="3,4 3,9 8,9"/><polyline points="15,14 15,9 10,9"/></svg>,
292
+ menu: <svg className="nav-icon" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.5"><line x1="3" y1="5" x2="15" y2="5"/><line x1="3" y1="9" x2="15" y2="9"/><line x1="3" y1="13" x2="15" y2="13"/></svg>,
293
+ close: <svg className="nav-icon" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.5"><line x1="4" y1="4" x2="14" y2="14"/><line x1="14" y1="4" x2="4" y2="14"/></svg>,
294
+ send: <svg className="nav-icon" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.5"><line x1="2" y1="9" x2="16" y2="9"/><polyline points="10,3 16,9 10,15"/></svg>,
295
+ };
296
+
297
+ /* --- Status Pill --- */
298
+ function StatusPill({ status }) {
299
+ const cls = statusCls(status);
300
+ const label = statusLabel(status);
301
+ const dim = status === 'offline';
302
+ return (
303
+ <span className={`status-pill ${cls}`} style={dim ? { opacity: 0.55 } : {}}>
304
+ <span className="status-dot" />
305
+ {label}
306
+ </span>
307
+ );
308
+ }
309
+
310
+ /* --- Sidebar --- */
311
+ function Sidebar({ currentScreen, onNavigate, sessionCount, open, onClose, connected }) {
312
+ return (
313
+ <>
314
+ <div className={`sidebar-overlay ${open ? 'open' : ''}`} onClick={onClose} />
315
+ <aside className={`sidebar ${open ? 'open' : ''}`}>
316
+ <div className="sidebar-header">
317
+ <a className="logo" href="#">
318
+ <div className="logo-mark">C</div>
319
+ <span className="logo-text">Code Pilot</span>
320
+ </a>
321
+ <span className="logo-badge">v0.4.0</span>
322
+ <button className="sidebar-close" onClick={onClose}>{Icons.close}</button>
323
+ </div>
324
+ <nav className="sidebar-nav">
325
+ <div className="nav-section-label">Workspace</div>
326
+ <button className={`nav-item ${currentScreen === 'dashboard' ? 'active' : ''}`} onClick={() => { onNavigate('dashboard'); onClose(); }}>
327
+ {Icons.dashboard} Dashboard
328
+ </button>
329
+ <button className={`nav-item ${currentScreen === 'sessions' ? 'active' : ''}`} onClick={() => { onNavigate('sessions'); onClose(); }}>
330
+ {Icons.sessions} Sessions <span className="nav-count">{sessionCount}</span>
331
+ </button>
332
+ <button className={`nav-item ${currentScreen === 'create' ? 'active' : ''}`} onClick={() => { onNavigate('create'); onClose(); }}>
333
+ {Icons.plus} New Session
334
+ </button>
335
+ </nav>
336
+ <div className="sidebar-footer">
337
+ <div style={{ padding: '4px 8px', fontSize: '11px', color: 'var(--muted)', fontFamily: 'var(--font-mono)', display: 'flex', alignItems: 'center', gap: 6 }}>
338
+ <span className={`conn-dot ${connected ? '' : 'disconnected'}`} />
339
+ {connected ? 'Connected' : 'Disconnected'}
340
+ </div>
341
+ </div>
342
+ </aside>
343
+ </>
344
+ );
345
+ }
346
+
347
+ /* --- Dashboard Screen --- */
348
+ function DashboardScreen({ onNavigate, sessions, activity, serverStatus }) {
349
+ const active = sessions.filter(s => s.status !== 'offline');
350
+ const running = sessions.filter(s => s.status === 'running');
351
+ const uptime = serverStatus ? formatUptime(serverStatus.startedAt) : '—';
352
+ const port = serverStatus ? serverStatus.port : 3742;
353
+
354
+ return (
355
+ <div>
356
+ <div className="stat-row">
357
+ <div className="stat-card">
358
+ <div className="stat-label">Running</div>
359
+ <div className="stat-value">{running.length}</div>
360
+ <div className="stat-sub">of {sessions.length} total</div>
361
+ </div>
362
+ <div className="stat-card">
363
+ <div className="stat-label">Active</div>
364
+ <div className="stat-value">{active.length}</div>
365
+ <div className="stat-sub">tmux sessions</div>
366
+ </div>
367
+ <div className="stat-card">
368
+ <div className="stat-label">Supervisor</div>
369
+ <div className="stat-value" style={{ color: 'var(--success)', fontSize: 16 }}>Online</div>
370
+ <div className="stat-sub">:{port}</div>
371
+ </div>
372
+ <div className="stat-card">
373
+ <div className="stat-label">Uptime</div>
374
+ <div className="stat-value" style={{ fontSize: 16 }}>{uptime}</div>
375
+ <div className="stat-sub">since last start</div>
376
+ </div>
377
+ </div>
378
+
379
+ <div className="section-header">
380
+ <h2 className="section-title">Sessions</h2>
381
+ <button className="btn btn-sm" onClick={() => onNavigate('create')}>
382
+ {Icons.plus} New
383
+ </button>
384
+ </div>
385
+
386
+ {sessions.length === 0 ? (
387
+ <div className="empty-state">
388
+ <div className="empty-state-icon">⌘</div>
389
+ <div className="empty-state-title">No sessions yet</div>
390
+ <div className="empty-state-desc">Create a session to get started.</div>
391
+ </div>
392
+ ) : (
393
+ <div className="session-cards">
394
+ {sessions.map(s => (
395
+ <div key={s.id} className={`session-card ${s.status === 'offline' ? 'offline' : ''}`} onClick={() => onNavigate('detail', s)}>
396
+ <div className="session-card-header">
397
+ <div>
398
+ <div className="session-card-name">{s.name}</div>
399
+ <div className="session-card-meta" style={{ maxWidth: 220, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{s.path}</div>
400
+ </div>
401
+ <StatusPill status={s.status} />
402
+ </div>
403
+ <div className="session-card-body">
404
+ <div className="session-card-field">
405
+ <div className="session-card-field-label">Started</div>
406
+ <div className="session-card-field-value">{relativeTime(s.startedAt)}</div>
407
+ </div>
408
+ <div className="session-card-field">
409
+ <div className="session-card-field-label">Tokens</div>
410
+ <div className="session-card-field-value">{formatTokens(s.tokens)}</div>
411
+ </div>
412
+ </div>
413
+ </div>
414
+ ))}
415
+ </div>
416
+ )}
417
+
418
+ {activity.length > 0 && (
419
+ <div style={{ marginTop: 24 }}>
420
+ <div className="section-header">
421
+ <h2 className="section-title">Recent Activity</h2>
422
+ </div>
423
+ <div className="card" style={{ padding: '14px 18px' }}>
424
+ <ul className="activity-list">
425
+ {activity.map((a, i) => (
426
+ <li key={i} className="activity-item">
427
+ <span className="activity-time">{relativeTime(a.time)}</span>
428
+ <span className="activity-text">
429
+ <strong>{a.name}</strong> — {a.from} → {a.to}
430
+ </span>
431
+ </li>
432
+ ))}
433
+ </ul>
434
+ </div>
435
+ </div>
436
+ )}
437
+ </div>
438
+ );
439
+ }
440
+
441
+ /* --- Session Detail Screen --- */
442
+ function SessionDetailScreen({ session, onBack, onKilled }) {
443
+ const [output, setOutput] = useState('');
444
+ const [msg, setMsg] = useState('');
445
+ const [sending, setSending] = useState(false);
446
+ const [killing, setKilling] = useState(false);
447
+ const terminalRef = useRef(null);
448
+
449
+ useEffect(() => {
450
+ if (session.status === 'offline') return;
451
+ const poll = () => {
452
+ fetch(`/api/sessions/${encodeURIComponent(session.name)}/output`)
453
+ .then(r => r.json())
454
+ .then(d => setOutput(d.output || ''))
455
+ .catch(() => {});
456
+ };
457
+ poll();
458
+ const t = setInterval(poll, 2000);
459
+ return () => clearInterval(t);
460
+ }, [session.name, session.status]);
461
+
462
+ useEffect(() => {
463
+ if (terminalRef.current) {
464
+ terminalRef.current.scrollTop = terminalRef.current.scrollHeight;
465
+ }
466
+ }, [output]);
467
+
468
+ const sendMessage = async () => {
469
+ if (!msg.trim() || sending || session.status === 'offline') return;
470
+ setSending(true);
471
+ try {
472
+ await fetch(`/api/sessions/${encodeURIComponent(session.name)}/send`, {
473
+ method: 'POST',
474
+ headers: { 'Content-Type': 'application/json' },
475
+ body: JSON.stringify({ message: msg }),
476
+ });
477
+ setMsg('');
478
+ } catch {
479
+ } finally {
480
+ setSending(false);
481
+ }
482
+ };
483
+
484
+ const handleKeyDown = (e) => {
485
+ if (e.key === 'Enter' && !e.shiftKey) {
486
+ e.preventDefault();
487
+ sendMessage();
488
+ }
489
+ };
490
+
491
+ const handleKill = async () => {
492
+ if (!confirm(`End session "${session.name}"?`)) return;
493
+ setKilling(true);
494
+ try {
495
+ await fetch(`/api/sessions/${encodeURIComponent(session.name)}`, { method: 'DELETE' });
496
+ onKilled();
497
+ } catch {
498
+ setKilling(false);
499
+ }
500
+ };
501
+
502
+ const isOffline = session.status === 'offline';
503
+
504
+ return (
505
+ <div>
506
+ <button className="back-link" onClick={onBack}>
507
+ {Icons.arrow} Back
508
+ </button>
509
+
510
+ <div className="detail-header">
511
+ <div>
512
+ <h2 className="detail-title">{session.name}</h2>
513
+ <div className="detail-meta" style={{ maxWidth: 480, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{session.path}</div>
514
+ </div>
515
+ <div className="detail-actions">
516
+ {!isOffline && (
517
+ <button className="btn btn-sm" style={{ color: 'var(--error)' }} onClick={handleKill} disabled={killing}>
518
+ {Icons.trash} {killing ? 'Ending…' : 'End Session'}
519
+ </button>
520
+ )}
521
+ </div>
522
+ </div>
523
+
524
+ <div className="detail-grid">
525
+ <div>
526
+ <div className="terminal">
527
+ <div className="terminal-header">
528
+ <span className="terminal-dot" style={{ background: '#ff5f57' }} />
529
+ <span className="terminal-dot" style={{ background: '#febc2e' }} />
530
+ <span className="terminal-dot" style={{ background: '#28c840' }} />
531
+ <span className="terminal-title">{session.name}</span>
532
+ </div>
533
+ <div className="terminal-body" ref={terminalRef}>
534
+ {isOffline
535
+ ? <span style={{ color: 'oklch(50% 0.018 50)' }}>Session is offline — no output available.</span>
536
+ : (output || <span style={{ color: 'oklch(50% 0.018 50)' }}>Loading…</span>)
537
+ }
538
+ {!isOffline && output && <span style={{ opacity: 0.5 }}>▊</span>}
539
+ </div>
540
+ </div>
541
+
542
+ {!isOffline && (
543
+ <div style={{ marginTop: 16 }}>
544
+ <div className="form-group" style={{ marginBottom: 0 }}>
545
+ <label className="form-label">Send message to Claude</label>
546
+ <div style={{ display: 'flex', gap: 8 }}>
547
+ <input
548
+ className="form-input"
549
+ placeholder="Type a message…"
550
+ value={msg}
551
+ onChange={e => setMsg(e.target.value)}
552
+ onKeyDown={handleKeyDown}
553
+ style={{ flex: 1 }}
554
+ disabled={sending}
555
+ />
556
+ <button className="btn btn-primary" onClick={sendMessage} disabled={sending || !msg.trim()}>
557
+ {Icons.send} {sending ? 'Sending…' : 'Send'}
558
+ </button>
559
+ </div>
560
+ </div>
561
+ </div>
562
+ )}
563
+ </div>
564
+
565
+ <div className="detail-sidebar">
566
+ <div className="card">
567
+ <h3>Session Info</h3>
568
+ <div className="info-row">
569
+ <span className="info-label">Status</span>
570
+ <StatusPill status={session.status} />
571
+ </div>
572
+ <div className="info-row">
573
+ <span className="info-label">Started</span>
574
+ <span className="info-value" style={{ fontSize: 11 }}>{relativeTime(session.startedAt)}</span>
575
+ </div>
576
+ <div className="info-row">
577
+ <span className="info-label">Tokens</span>
578
+ <span className="info-value" style={{ fontSize: 11 }}>{formatTokens(session.tokens)}</span>
579
+ </div>
580
+ {session.status === 'limit' && session.resumeAt && (
581
+ <div className="info-row">
582
+ <span className="info-label">Resumes</span>
583
+ <span className="info-value" style={{ fontSize: 11, color: 'var(--warning)' }}>
584
+ {relativeTime(session.resumeAt)}
585
+ </span>
586
+ </div>
587
+ )}
588
+ <div className="info-row">
589
+ <span className="info-label">tmux</span>
590
+ <span className="info-value" style={{ fontSize: 11 }}>{session.name}</span>
591
+ </div>
592
+ </div>
593
+ </div>
594
+ </div>
595
+ </div>
596
+ );
597
+ }
598
+
599
+ /* --- Create Session Screen --- */
600
+ function CreateSessionScreen({ onBack, onCreated }) {
601
+ const [name, setName] = useState('');
602
+ const [path, setPath] = useState('');
603
+ const [prompt, setPrompt] = useState('');
604
+ const [error, setError] = useState('');
605
+ const [loading, setLoading] = useState(false);
606
+
607
+ const handleCreate = async () => {
608
+ if (!name.trim() || !path.trim()) {
609
+ setError('Session name and working directory are required.');
610
+ return;
611
+ }
612
+ setError('');
613
+ setLoading(true);
614
+ try {
615
+ const res = await fetch('/api/sessions', {
616
+ method: 'POST',
617
+ headers: { 'Content-Type': 'application/json' },
618
+ body: JSON.stringify({ name: name.trim(), path: path.trim(), prompt: prompt.trim() || undefined }),
619
+ });
620
+ const data = await res.json();
621
+ if (!res.ok) { setError(data.error || 'Failed to create session.'); return; }
622
+ onCreated(data);
623
+ } catch {
624
+ setError('Network error. Is the supervisor running?');
625
+ } finally {
626
+ setLoading(false);
627
+ }
628
+ };
629
+
630
+ return (
631
+ <div className="create-layout">
632
+ <button className="back-link" onClick={onBack}>
633
+ {Icons.arrow} Back
634
+ </button>
635
+
636
+ <h2 className="section-title" style={{ marginBottom: 20, fontSize: 18 }}>New Session</h2>
637
+
638
+ <div className="form-group">
639
+ <label className="form-label">Session name</label>
640
+ <input className="form-input" placeholder="e.g. refactor-auth-flow" value={name} onChange={e => setName(e.target.value)} />
641
+ <div className="form-hint">Used as the tmux session name. Lowercase with hyphens.</div>
642
+ </div>
643
+
644
+ <div className="form-group">
645
+ <label className="form-label">Working directory</label>
646
+ <input className="form-input" placeholder="~/projects/app" value={path} onChange={e => setPath(e.target.value)} />
647
+ <div className="form-hint">Absolute path or ~ home-relative path where Claude will run.</div>
648
+ </div>
649
+
650
+ <div className="form-group">
651
+ <label className="form-label">Initial prompt <span style={{ fontWeight: 400, color: 'var(--muted)' }}>(optional)</span></label>
652
+ <textarea className="form-textarea" placeholder="What should Claude work on?" value={prompt} onChange={e => setPrompt(e.target.value)} rows={4} />
653
+ <div className="form-hint">Sent to Claude 2 seconds after the session starts.</div>
654
+ </div>
655
+
656
+ {error && (
657
+ <div style={{ marginBottom: 16, padding: '10px 14px', background: 'var(--error-soft)', color: 'var(--error)', borderRadius: 'var(--radius-sm)', fontSize: 13 }}>
658
+ {error}
659
+ </div>
660
+ )}
661
+
662
+ <div className="form-actions">
663
+ <button className="btn" onClick={onBack}>Cancel</button>
664
+ <button className="btn btn-primary" onClick={handleCreate} disabled={loading}>
665
+ {loading ? 'Creating…' : 'Create & Start Session'}
666
+ </button>
667
+ </div>
668
+ </div>
669
+ );
670
+ }
671
+
672
+ /* --- Sessions List Screen --- */
673
+ function SessionsScreen({ sessions, onNavigate }) {
674
+ return (
675
+ <div>
676
+ <div className="section-header">
677
+ <h2 className="section-title">All Sessions</h2>
678
+ <button className="btn btn-sm" onClick={() => onNavigate('create')}>{Icons.plus} New</button>
679
+ </div>
680
+
681
+ {sessions.length === 0 ? (
682
+ <div className="empty-state">
683
+ <div className="empty-state-icon">⌘</div>
684
+ <div className="empty-state-title">No sessions</div>
685
+ <div className="empty-state-desc">Spawn a session to get started.</div>
686
+ </div>
687
+ ) : (
688
+ <div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--radius-md)' }}>
689
+ <table style={{ width: '100%', borderCollapse: 'collapse' }}>
690
+ <thead>
691
+ <tr>
692
+ {['Name', 'Path', 'Status', 'Started', 'Tokens'].map(h => (
693
+ <th key={h} style={{ textAlign: 'left', padding: '10px 14px', fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--muted)', borderBottom: '1px solid var(--border)' }}>{h}</th>
694
+ ))}
695
+ </tr>
696
+ </thead>
697
+ <tbody>
698
+ {sessions.map(s => (
699
+ <tr key={s.id} style={{ cursor: 'pointer', opacity: s.status === 'offline' ? 0.6 : 1, transition: 'background 0.12s' }}
700
+ onClick={() => onNavigate('detail', s)}
701
+ onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-hover)'}
702
+ onMouseLeave={e => e.currentTarget.style.background = ''}>
703
+ <td style={{ padding: '12px 14px', fontWeight: 600, fontSize: 13, borderBottom: '1px solid var(--border)' }}>{s.name}</td>
704
+ <td style={{ padding: '12px 14px', fontSize: 12, color: 'var(--muted)', borderBottom: '1px solid var(--border)', maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{s.path}</td>
705
+ <td style={{ padding: '12px 14px', borderBottom: '1px solid var(--border)' }}><StatusPill status={s.status} /></td>
706
+ <td style={{ padding: '12px 14px', fontSize: 12, color: 'var(--muted)', fontFamily: 'var(--font-mono)', borderBottom: '1px solid var(--border)' }}>{relativeTime(s.startedAt)}</td>
707
+ <td style={{ padding: '12px 14px', fontSize: 12, fontFamily: 'var(--font-mono)', color: 'var(--fg-secondary)', borderBottom: '1px solid var(--border)' }}>{formatTokens(s.tokens)}</td>
708
+ </tr>
709
+ ))}
710
+ </tbody>
711
+ </table>
712
+ </div>
713
+ )}
714
+ </div>
715
+ );
716
+ }
717
+
718
+ /* --- App --- */
719
+ function App() {
720
+ const savedTheme = storage.getItem('ccp-theme');
721
+ const [dark, setDark] = useState(savedTheme === 'dark');
722
+ const [screen, setScreen] = useState('dashboard');
723
+ const [selectedSession, setSelectedSession] = useState(null);
724
+ const [sidebarOpen, setSidebarOpen] = useState(false);
725
+ const [sessions, setSessions] = useState([]);
726
+ const [serverStatus, setServerStatus] = useState(null);
727
+ const [activity, setActivity] = useState([]);
728
+ const [connected, setConnected] = useState(false);
729
+ const prevStatusRef = useRef({});
730
+
731
+ useEffect(() => {
732
+ document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
733
+ storage.setItem('ccp-theme', dark ? 'dark' : 'light');
734
+ }, [dark]);
735
+
736
+ // SSE for live sessions
737
+ useEffect(() => {
738
+ let es;
739
+ let retryTimer;
740
+ const connect = () => {
741
+ es = new EventSource('/events');
742
+ es.onopen = () => setConnected(true);
743
+ es.onerror = () => {
744
+ setConnected(false);
745
+ es.close();
746
+ retryTimer = setTimeout(connect, 5000);
747
+ };
748
+ es.onmessage = (e) => {
749
+ const incoming = JSON.parse(e.data);
750
+ setSessions(incoming);
751
+
752
+ const now = new Date();
753
+ const prev = prevStatusRef.current;
754
+ const newEntries = [];
755
+ for (const s of incoming) {
756
+ if (prev[s.name] && prev[s.name] !== s.status) {
757
+ newEntries.push({ time: now, name: s.name, from: prev[s.name], to: s.status });
758
+ }
759
+ prev[s.name] = s.status;
760
+ }
761
+ if (newEntries.length) {
762
+ setActivity(a => [...newEntries, ...a].slice(0, 20));
763
+ }
764
+
765
+ // Update selected session data if viewing detail
766
+ setSelectedSession(sel => {
767
+ if (!sel) return sel;
768
+ const updated = incoming.find(s => s.name === sel.name);
769
+ return updated || sel;
770
+ });
771
+ };
772
+ };
773
+ connect();
774
+ return () => { clearTimeout(retryTimer); if (es) es.close(); };
775
+ }, []);
776
+
777
+ // Server status
778
+ useEffect(() => {
779
+ fetch('/api/status').then(r => r.json()).then(setServerStatus).catch(() => {});
780
+ }, []);
781
+
782
+ const navigate = useCallback((target, session) => {
783
+ setScreen(target);
784
+ if (session) setSelectedSession(session);
785
+ setSidebarOpen(false);
786
+ }, []);
787
+
788
+ const screenTitle = {
789
+ dashboard: 'Dashboard',
790
+ sessions: 'Sessions',
791
+ create: 'New Session',
792
+ detail: selectedSession ? selectedSession.name : 'Session',
793
+ }[screen] || 'Dashboard';
794
+
795
+ const renderScreen = () => {
796
+ switch (screen) {
797
+ case 'dashboard':
798
+ return <DashboardScreen onNavigate={navigate} sessions={sessions} activity={activity} serverStatus={serverStatus} />;
799
+ case 'sessions':
800
+ return <SessionsScreen sessions={sessions} onNavigate={navigate} />;
801
+ case 'create':
802
+ return (
803
+ <CreateSessionScreen
804
+ onBack={() => navigate('dashboard')}
805
+ onCreated={(s) => navigate('detail', s)}
806
+ />
807
+ );
808
+ case 'detail':
809
+ return (
810
+ <SessionDetailScreen
811
+ session={selectedSession}
812
+ onBack={() => navigate('dashboard')}
813
+ onKilled={() => navigate('sessions')}
814
+ />
815
+ );
816
+ default:
817
+ return <DashboardScreen onNavigate={navigate} sessions={sessions} activity={activity} serverStatus={serverStatus} />;
818
+ }
819
+ };
820
+
821
+ return (
822
+ <div className="app-shell">
823
+ <Sidebar
824
+ currentScreen={screen}
825
+ onNavigate={navigate}
826
+ sessionCount={sessions.length}
827
+ open={sidebarOpen}
828
+ onClose={() => setSidebarOpen(false)}
829
+ connected={connected}
830
+ />
831
+ <div className="main">
832
+ <div className="mobile-header">
833
+ <button className="menu-btn" onClick={() => setSidebarOpen(true)}>{Icons.menu}</button>
834
+ <span className="mobile-title">{screenTitle}</span>
835
+ <button className="theme-toggle" onClick={() => setDark(d => !d)}>
836
+ {dark ? Icons.sun : Icons.moon}
837
+ </button>
838
+ </div>
839
+ <header className="header">
840
+ <h1 className="header-title">{screenTitle}</h1>
841
+ <div className="header-spacer" />
842
+ <button className="theme-toggle" onClick={() => setDark(d => !d)}>
843
+ {dark ? Icons.sun : Icons.moon}
844
+ {dark ? 'Light' : 'Dark'}
845
+ </button>
846
+ </header>
847
+ <div className="content">
848
+ {renderScreen()}
849
+ </div>
850
+ </div>
851
+ </div>
852
+ );
853
+ }
854
+
855
+ ReactDOM.createRoot(document.getElementById('root')).render(<App />);
856
+ </script>
857
+ </body>
858
+ </html>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-remote-pilot",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Interactive Claude Code supervisor — spawn and monitor multiple Claude sessions from a single terminal.",
5
5
  "type": "commonjs",
6
6
  "bin": {