claude-code-remote-pilot 0.5.10 → 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.
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.10",
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": {