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 +81 -1
- package/lib/ui.html +152 -0
- package/package.json +1 -1
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 =>
|
|
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