claude-code-remote-pilot 0.5.9 → 0.5.11

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.
@@ -154,7 +154,7 @@ function buildAllSessions(manager) {
154
154
  return [...active, ...offline];
155
155
  }
156
156
 
157
- function renderWatchTable(allSessions, selectedIdx) {
157
+ function renderWatchTable(allSessions, selectedIdx, webServer = null) {
158
158
  const NW = 18, SW = 14, UW = 7, TW = 16;
159
159
  const bar = ' ' + '─'.repeat(NW + SW + UW + TW + 10);
160
160
  const header = ` ${'#'.padEnd(3)}${'SESSION'.padEnd(NW)} ${'STATUS'.padEnd(SW)} ${'UP'.padEnd(UW)} ${'USAGE / RESET'.padEnd(TW)}`;
@@ -181,7 +181,16 @@ function renderWatchTable(allSessions, selectedIdx) {
181
181
  footer = ` [1-${Math.min(allSessions.length, 9)}]: select session w: web ui q: exit watch`;
182
182
  }
183
183
 
184
- return ['\n', ' Claude Code Remote Pilot', bar, header, bar, ...rows, bar, footer, ''].join('\n');
184
+ const lines = ['\n', ' Claude Code Remote Pilot'];
185
+ if (webServer) {
186
+ if (webServer._tunnelUrl) {
187
+ lines.push(` ${C.blue}Tunnel${C.reset}: ${webServer._tunnelUrl} ${C.dim}local: http://127.0.0.1:${webServer.port}${C.reset}`);
188
+ } else {
189
+ lines.push(` ${C.dim}Web UI: http://${webServer.host}:${webServer.port}${C.reset}`);
190
+ }
191
+ }
192
+ lines.push(bar, header, bar, ...rows, bar, footer, '');
193
+ return lines.join('\n');
185
194
  }
186
195
 
187
196
  function startWatch(manager, rl) {
@@ -192,7 +201,7 @@ function startWatch(manager, rl) {
192
201
  function draw() {
193
202
  allSessions = buildAllSessions(manager);
194
203
  process.stdout.write('\x1B[2J\x1B[0f');
195
- process.stdout.write(renderWatchTable(allSessions, selectedIdx));
204
+ process.stdout.write(renderWatchTable(allSessions, selectedIdx, manager._webServer));
196
205
  }
197
206
 
198
207
  function startTimer() {
@@ -214,7 +223,7 @@ function startWatch(manager, rl) {
214
223
 
215
224
  function redraw() {
216
225
  process.stdout.write('\x1B[2J\x1B[0f');
217
- process.stdout.write(renderWatchTable(allSessions, selectedIdx));
226
+ process.stdout.write(renderWatchTable(allSessions, selectedIdx, manager._webServer));
218
227
  }
219
228
 
220
229
  function onKeypress(str, key) {
package/lib/WebServer.js CHANGED
@@ -21,6 +21,7 @@ class WebServer {
21
21
  this._heartbeatInterval = null;
22
22
  this._tunnelProcess = null;
23
23
  this._tunnelUrl = null;
24
+ this._queues = new Map(); // name → { items: [{id,message}], autoFeed, pendingSend }
24
25
  }
25
26
 
26
27
  _buildAllSessions() {
@@ -30,7 +31,17 @@ class WebServer {
30
31
  const offline = history
31
32
  .filter(h => !activeNames.has(h.name))
32
33
  .map(h => ({ name: h.name, path: h.path, status: 'offline', startedAt: h.lastSeen, resumeAt: null }));
33
- return [...active, ...offline].map(s => ({ ...s, id: s.name }));
34
+ return [...active, ...offline].map(s => {
35
+ const q = this._queues.get(s.name);
36
+ return { ...s, id: s.name, queueLength: q ? q.items.length : 0, autoFeed: q ? q.autoFeed : false };
37
+ });
38
+ }
39
+
40
+ _getQueue(name) {
41
+ if (!this._queues.has(name)) {
42
+ this._queues.set(name, { items: [], autoFeed: false, pendingSend: false });
43
+ }
44
+ return this._queues.get(name);
34
45
  }
35
46
 
36
47
  _getOutput(name) {
@@ -147,6 +158,58 @@ class WebServer {
147
158
  });
148
159
  }
149
160
 
161
+ // Queue routes — GET|POST|PATCH /api/sessions/:name/queue
162
+ const queueBaseMatch = pathname.match(/^\/api\/sessions\/([^/]+)\/queue$/);
163
+ if (queueBaseMatch) {
164
+ const name = decodeURIComponent(queueBaseMatch[1]);
165
+ if (req.method === 'GET') {
166
+ const q = this._getQueue(name);
167
+ return this._json(res, 200, { items: q.items, autoFeed: q.autoFeed });
168
+ }
169
+ if (req.method === 'POST') {
170
+ return this._readBody(req, (err, body) => {
171
+ if (err) return this._json(res, 400, { error: err.message });
172
+ if (!body.message) return this._json(res, 400, { error: 'message required' });
173
+ const q = this._getQueue(name);
174
+ const item = { id: crypto.randomBytes(4).toString('hex'), message: body.message };
175
+ q.items.push(item);
176
+ return this._json(res, 201, { item });
177
+ });
178
+ }
179
+ if (req.method === 'PATCH') {
180
+ return this._readBody(req, (err, body) => {
181
+ if (err) return this._json(res, 400, { error: err.message });
182
+ const q = this._getQueue(name);
183
+ if (typeof body.autoFeed === 'boolean') {
184
+ q.autoFeed = body.autoFeed;
185
+ if (!body.autoFeed) q.pendingSend = false;
186
+ }
187
+ return this._json(res, 200, { autoFeed: q.autoFeed });
188
+ });
189
+ }
190
+ }
191
+
192
+ // POST /api/sessions/:name/queue/play — send and dequeue first item
193
+ const queuePlayMatch = pathname.match(/^\/api\/sessions\/([^/]+)\/queue\/play$/);
194
+ if (req.method === 'POST' && queuePlayMatch) {
195
+ const name = decodeURIComponent(queuePlayMatch[1]);
196
+ const q = this._queues.get(name);
197
+ if (!q || !q.items.length) return this._json(res, 400, { error: 'Queue is empty' });
198
+ const item = q.items.shift();
199
+ spawnSync('tmux', ['send-keys', '-t', name, item.message, 'Enter']);
200
+ return this._json(res, 200, { ok: true, item });
201
+ }
202
+
203
+ // DELETE /api/sessions/:name/queue/:id — remove a specific queued item
204
+ const queueItemMatch = pathname.match(/^\/api\/sessions\/([^/]+)\/queue\/([^/]+)$/);
205
+ if (req.method === 'DELETE' && queueItemMatch) {
206
+ const name = decodeURIComponent(queueItemMatch[1]);
207
+ const id = queueItemMatch[2];
208
+ const q = this._queues.get(name);
209
+ if (q) q.items = q.items.filter(it => it.id !== id);
210
+ return this._json(res, 200, { ok: true });
211
+ }
212
+
150
213
  // DELETE /api/sessions/:name
151
214
  const killMatch = pathname.match(/^\/api\/sessions\/([^/]+)$/);
152
215
  if (req.method === 'DELETE' && killMatch) {
@@ -177,6 +240,23 @@ class WebServer {
177
240
  }
178
241
 
179
242
  _broadcast() {
243
+ // Auto-feed: send the next queued message when a session becomes idle/waiting
244
+ for (const session of this.manager.list()) {
245
+ const q = this._queues.get(session.name);
246
+ if (!q || !q.autoFeed || !q.items.length) {
247
+ if (q && session.status !== 'idle' && session.status !== 'needs-response') q.pendingSend = false;
248
+ continue;
249
+ }
250
+ const isWaiting = session.status === 'idle' || session.status === 'needs-response';
251
+ if (isWaiting && !q.pendingSend) {
252
+ q.pendingSend = true;
253
+ const item = q.items.shift();
254
+ spawnSync('tmux', ['send-keys', '-t', session.name, item.message, 'Enter']);
255
+ } else if (!isWaiting) {
256
+ q.pendingSend = false;
257
+ }
258
+ }
259
+
180
260
  if (!this._clients.size) return;
181
261
  const payload = `data: ${JSON.stringify(this._buildAllSessions())}\n\n`;
182
262
  for (const res of this._clients) {
package/lib/ui.html CHANGED
@@ -547,6 +547,11 @@ function DashboardScreen({ onNavigate, sessions, activity, serverStatus }) {
547
547
  <div className="session-card-field"><div className="session-card-field-label">Started</div><div className="session-card-field-value">{relativeTime(s.startedAt)}</div></div>
548
548
  <div className="session-card-field"><div className="session-card-field-label">Tokens</div><div className="session-card-field-value">{formatTokens(s.tokens)}</div></div>
549
549
  </div>
550
+ {(s.queueLength > 0 || s.autoFeed) && (
551
+ <div style={{ marginTop: 8, paddingTop: 8, borderTop: '1px solid var(--border)', fontSize: 11, color: 'var(--muted)', display: 'flex', alignItems: 'center', gap: 4 }}>
552
+ {s.autoFeed ? '⚡' : '⏳'} {s.queueLength} message{s.queueLength !== 1 ? 's' : ''} queued{s.autoFeed ? ' · auto-feed' : ''}
553
+ </div>
554
+ )}
550
555
  </div>
551
556
  ))}
552
557
  </div>
@@ -847,6 +852,8 @@ function SessionDetailScreen({ session, onBack, onKilled, onRespawned }) {
847
852
  </div>
848
853
  </div>
849
854
  </div>
855
+
856
+ <QueuePanel session={session} />
850
857
  </div>
851
858
  );
852
859
  }
@@ -1015,6 +1022,151 @@ function BroadcastBar({ activeSessions }) {
1015
1022
  );
1016
1023
  }
1017
1024
 
1025
+ /* --- Queue Panel --- */
1026
+ function QueuePanel({ session }) {
1027
+ const isOffline = session.status === 'offline';
1028
+ const [items, setItems] = useState([]);
1029
+ const [autoFeed, setAutoFeed] = useState(false);
1030
+ const [input, setInput] = useState('');
1031
+ const [busy, setBusy] = useState(false);
1032
+ const inputRef = useRef(null);
1033
+
1034
+ useEffect(() => {
1035
+ let mounted = true;
1036
+ const load = () => {
1037
+ apiFetch(`/api/sessions/${encodeURIComponent(session.name)}/queue`, { cache: 'no-store' })
1038
+ .then(r => r.json())
1039
+ .then(d => { if (mounted) { setItems(d.items || []); setAutoFeed(!!d.autoFeed); } })
1040
+ .catch(() => {});
1041
+ };
1042
+ load();
1043
+ const t = setInterval(load, 3000);
1044
+ return () => { mounted = false; clearInterval(t); };
1045
+ }, [session.name]);
1046
+
1047
+ const enqueue = async () => {
1048
+ const msg = input.trim();
1049
+ if (!msg || busy) return;
1050
+ setBusy(true);
1051
+ try {
1052
+ const res = await apiFetch(`/api/sessions/${encodeURIComponent(session.name)}/queue`, {
1053
+ method: 'POST',
1054
+ headers: { 'Content-Type': 'application/json' },
1055
+ body: JSON.stringify({ message: msg }),
1056
+ });
1057
+ const data = await res.json();
1058
+ if (res.ok) { setItems(prev => [...prev, data.item]); setInput(''); }
1059
+ } finally {
1060
+ setBusy(false);
1061
+ setTimeout(() => inputRef.current?.focus(), 0);
1062
+ }
1063
+ };
1064
+
1065
+ const play = async () => {
1066
+ if (busy || !items.length || isOffline) return;
1067
+ setBusy(true);
1068
+ try {
1069
+ await apiFetch(`/api/sessions/${encodeURIComponent(session.name)}/queue/play`, { method: 'POST' });
1070
+ setItems(prev => prev.slice(1));
1071
+ } finally { setBusy(false); }
1072
+ };
1073
+
1074
+ const remove = async (id) => {
1075
+ setItems(prev => prev.filter(it => it.id !== id));
1076
+ apiFetch(`/api/sessions/${encodeURIComponent(session.name)}/queue/${id}`, { method: 'DELETE' }).catch(() => {});
1077
+ };
1078
+
1079
+ const toggleAutoFeed = async () => {
1080
+ if (isOffline) return;
1081
+ const next = !autoFeed;
1082
+ setAutoFeed(next);
1083
+ apiFetch(`/api/sessions/${encodeURIComponent(session.name)}/queue`, {
1084
+ method: 'PATCH',
1085
+ headers: { 'Content-Type': 'application/json' },
1086
+ body: JSON.stringify({ autoFeed: next }),
1087
+ }).catch(() => setAutoFeed(!next));
1088
+ };
1089
+
1090
+ return (
1091
+ <div className="card" style={{ padding: '14px 18px', marginTop: 16 }}>
1092
+ <div style={{ display: 'flex', alignItems: 'center', marginBottom: 12 }}>
1093
+ <span style={{ fontSize: 12, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--muted)', display: 'flex', alignItems: 'center', gap: 6 }}>
1094
+ Queue
1095
+ {items.length > 0 && (
1096
+ <span style={{ background: 'var(--accent-soft)', color: 'var(--accent)', fontFamily: 'var(--font-mono)', fontSize: 11, padding: '1px 6px', borderRadius: 8, fontWeight: 700 }}>
1097
+ {items.length}
1098
+ </span>
1099
+ )}
1100
+ </span>
1101
+ <button
1102
+ onClick={toggleAutoFeed}
1103
+ disabled={isOffline}
1104
+ title={isOffline ? 'Start session to enable auto-feed' : autoFeed ? 'Auto-feed: sends next message when session goes idle' : 'Enable auto-feed'}
1105
+ style={{
1106
+ marginLeft: 'auto', fontSize: 11, padding: '3px 8px', borderRadius: 6, cursor: isOffline ? 'not-allowed' : 'pointer', fontWeight: 600,
1107
+ border: `1px solid ${autoFeed && !isOffline ? 'var(--success)' : 'var(--border)'}`,
1108
+ background: autoFeed && !isOffline ? 'var(--success-soft)' : 'var(--bg)',
1109
+ color: autoFeed && !isOffline ? 'var(--success)' : 'var(--muted)',
1110
+ opacity: isOffline ? 0.5 : 1,
1111
+ }}
1112
+ >
1113
+ {autoFeed && !isOffline ? '⚡ Auto-feed on' : 'Auto-feed off'}
1114
+ </button>
1115
+ </div>
1116
+
1117
+ {items.length === 0 && (
1118
+ <div style={{ fontSize: 12, color: 'var(--muted)', textAlign: 'center', padding: '8px 0 12px' }}>No messages queued</div>
1119
+ )}
1120
+
1121
+ {items.length > 0 && (
1122
+ <div style={{ marginBottom: 12, border: '1px solid var(--border)', borderRadius: 'var(--radius-sm)', overflow: 'hidden' }}>
1123
+ {items.map((item, i) => (
1124
+ <div key={item.id} style={{
1125
+ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 10px',
1126
+ background: i === 0 ? 'var(--accent-soft)' : 'transparent',
1127
+ borderBottom: i < items.length - 1 ? '1px solid var(--border)' : 'none',
1128
+ }}>
1129
+ {i === 0 ? (
1130
+ <button
1131
+ onClick={play}
1132
+ disabled={busy || isOffline}
1133
+ title={isOffline ? 'Session offline' : 'Send now'}
1134
+ style={{ background: 'none', border: 'none', cursor: busy || isOffline ? 'not-allowed' : 'pointer', color: isOffline ? 'var(--muted)' : 'var(--success)', fontSize: 13, padding: 0, flexShrink: 0, opacity: isOffline ? 0.4 : 1 }}
1135
+ >▶</button>
1136
+ ) : (
1137
+ <span style={{ width: 16, flexShrink: 0, textAlign: 'center', fontSize: 11, color: 'var(--muted)', fontFamily: 'var(--font-mono)' }}>{i + 1}</span>
1138
+ )}
1139
+ <span style={{ flex: 1, fontSize: 12, fontFamily: 'var(--font-mono)', color: i === 0 ? 'var(--fg)' : 'var(--fg-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
1140
+ {item.message}
1141
+ </span>
1142
+ <button
1143
+ onClick={() => remove(item.id)}
1144
+ title="Remove"
1145
+ style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--muted)', fontSize: 13, padding: 0, flexShrink: 0, lineHeight: 1 }}
1146
+ >✕</button>
1147
+ </div>
1148
+ ))}
1149
+ </div>
1150
+ )}
1151
+
1152
+ <div style={{ display: 'flex', gap: 8 }}>
1153
+ <input
1154
+ ref={inputRef}
1155
+ className="form-input"
1156
+ placeholder="Type a message to queue…"
1157
+ value={input}
1158
+ onChange={e => setInput(e.target.value)}
1159
+ onKeyDown={e => e.key === 'Enter' && enqueue()}
1160
+ style={{ flex: 1, fontSize: 12 }}
1161
+ />
1162
+ <button className="btn btn-sm btn-primary" onClick={enqueue} disabled={busy || !input.trim()}>
1163
+ + Enqueue
1164
+ </button>
1165
+ </div>
1166
+ </div>
1167
+ );
1168
+ }
1169
+
1018
1170
  /* --- App --- */
1019
1171
  function App() {
1020
1172
  const savedTheme = storage.getItem('ccp-theme');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-remote-pilot",
3
- "version": "0.5.9",
3
+ "version": "0.5.11",
4
4
  "description": "Interactive Claude Code supervisor — spawn and monitor multiple Claude sessions from a single terminal.",
5
5
  "type": "commonjs",
6
6
  "repository": {