clideck 1.26.0 → 1.26.2
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/README.md +17 -4
- package/activity.js +15 -1
- package/config.js +74 -1
- package/handlers.js +14 -3
- package/package.json +2 -1
- package/plugin-loader.js +128 -11
- package/plugins/autopilot/clideck-plugin.json +52 -0
- package/plugins/autopilot/client.js +84 -0
- package/plugins/autopilot/index.js +797 -0
- package/plugins/autopilot/prompt.md +68 -0
- package/public/index.html +11 -3
- package/public/js/app.js +73 -6
- package/public/js/creator.js +72 -6
- package/public/js/nav.js +2 -2
- package/public/js/prompts.js +1 -1
- package/public/js/roles.js +112 -0
- package/public/js/state.js +2 -0
- package/public/js/terminals.js +219 -2
- package/public/js/toast.js +28 -9
- package/public/tailwind.css +1 -1
- package/server.js +7 -4
- package/sessions.js +74 -6
- package/telemetry-receiver.js +75 -41
- package/transcript.js +15 -3
|
@@ -0,0 +1,797 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { existsSync, mkdirSync, appendFileSync, readFileSync, writeFileSync, unlinkSync } = require('fs');
|
|
4
|
+
const { join } = require('path');
|
|
5
|
+
const crypto = require('crypto');
|
|
6
|
+
|
|
7
|
+
const DATA_DIR = join(require('os').homedir(), '.clideck', 'autopilot');
|
|
8
|
+
|
|
9
|
+
// --- State ---
|
|
10
|
+
const projects = new Map(); // projectId → Project
|
|
11
|
+
const tokenUsage = new Map(); // projectId → { input, output }
|
|
12
|
+
const menuPending = new Set(); // sessionIds with a menu awaiting auto-approve
|
|
13
|
+
let api = null;
|
|
14
|
+
let piAi = null;
|
|
15
|
+
|
|
16
|
+
function enabled() { return api.getSetting('enabled') !== false; }
|
|
17
|
+
function safeId(id) { return String(id).replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64); }
|
|
18
|
+
|
|
19
|
+
function outputId(text) {
|
|
20
|
+
const normalized = (text || '').trim().replace(/\s+/g, ' ');
|
|
21
|
+
return crypto.createHash('sha256').update(normalized).digest('hex').slice(0, 12);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// --- pi-ai (lazy, ESM) ---
|
|
25
|
+
|
|
26
|
+
async function ai() {
|
|
27
|
+
if (piAi) return piAi;
|
|
28
|
+
piAi = await import(api.resolve('@mariozechner/pi-ai'));
|
|
29
|
+
return piAi;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// --- KB (routing history) ---
|
|
33
|
+
|
|
34
|
+
function kbPath(pid) { return join(DATA_DIR, `${safeId(pid)}.jsonl`); }
|
|
35
|
+
|
|
36
|
+
function appendKB(pid, entry) {
|
|
37
|
+
mkdirSync(DATA_DIR, { recursive: true });
|
|
38
|
+
appendFileSync(kbPath(pid), JSON.stringify({ ts: Date.now(), ...entry }) + '\n');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function readKB(pid, n) {
|
|
42
|
+
const p = kbPath(pid);
|
|
43
|
+
if (!existsSync(p)) return [];
|
|
44
|
+
try {
|
|
45
|
+
const lines = readFileSync(p, 'utf8').trim().split('\n').filter(Boolean);
|
|
46
|
+
const entries = lines.map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
|
|
47
|
+
return n ? entries.slice(-n) : entries;
|
|
48
|
+
} catch { return []; }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// --- Token usage ---
|
|
52
|
+
|
|
53
|
+
function usagePath() { return join(DATA_DIR, 'usage.json'); }
|
|
54
|
+
|
|
55
|
+
function loadUsage() {
|
|
56
|
+
try {
|
|
57
|
+
for (const [k, v] of Object.entries(JSON.parse(readFileSync(usagePath(), 'utf8')))) {
|
|
58
|
+
tokenUsage.set(k, v);
|
|
59
|
+
}
|
|
60
|
+
} catch {}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function saveUsage() {
|
|
64
|
+
mkdirSync(DATA_DIR, { recursive: true });
|
|
65
|
+
writeFileSync(usagePath(), JSON.stringify(Object.fromEntries(tokenUsage)));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function addTokens(pid, usage) {
|
|
69
|
+
if (!usage) return;
|
|
70
|
+
const u = tokenUsage.get(pid) || { input: 0, output: 0 };
|
|
71
|
+
u.input += usage.input || 0;
|
|
72
|
+
u.output += usage.output || 0;
|
|
73
|
+
tokenUsage.set(pid, u);
|
|
74
|
+
saveUsage();
|
|
75
|
+
api.sendToFrontend('tokens', { projectId: pid, ...u });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// --- Consumed state (persisted per-project: role → boolean) ---
|
|
79
|
+
|
|
80
|
+
function captureIdleOutput(id, pid, proj) {
|
|
81
|
+
if (!projects.has(pid)) return;
|
|
82
|
+
if (proj.status.get(id)) return;
|
|
83
|
+
const w = proj.workers.get(id);
|
|
84
|
+
if (w) {
|
|
85
|
+
const turns = api.getScreenTurns(id, w.presetId, { raw: true });
|
|
86
|
+
if (turns?.length && turns[turns.length - 1].role === 'agent') {
|
|
87
|
+
const out = turns[turns.length - 1].text.trim().slice(0, 8000);
|
|
88
|
+
const oid = outputId(out);
|
|
89
|
+
const prev = proj.lastOutput.get(id);
|
|
90
|
+
const isNew = !prev || prev.outputId !== oid;
|
|
91
|
+
proj.lastOutput.set(id, { text: out, capturedAt: Date.now(), outputId: oid });
|
|
92
|
+
appendKB(pid, { from: w.role, msg: out.slice(0, 4000), outputId: oid });
|
|
93
|
+
// Clear waitingOn when the awaited role delivers new output
|
|
94
|
+
if (isNew && proj.waitingOn?.toLowerCase() === w.role.toLowerCase()) {
|
|
95
|
+
proj.waitingOn = null;
|
|
96
|
+
proj.staleSince = null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (proj.paused) return;
|
|
101
|
+
const allIdle = [...proj.workers.keys()].every(sid => !proj.status.get(sid));
|
|
102
|
+
if (allIdle) {
|
|
103
|
+
// Track staleness: if no new output arrived since last consult, mark stale
|
|
104
|
+
if (proj.lastAction && !proj.staleSince) {
|
|
105
|
+
const anyNew = [...proj.lastOutput.values()].some(o => o.capturedAt > proj.lastAction.at);
|
|
106
|
+
if (!anyNew) proj.staleSince = Date.now();
|
|
107
|
+
}
|
|
108
|
+
triggerConsult(pid, proj);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function resetProjectState(pid) {
|
|
113
|
+
try { unlinkSync(kbPath(pid)); } catch {}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// --- Debug logging ---
|
|
117
|
+
|
|
118
|
+
const LOGS_DIR = join(DATA_DIR, 'logs');
|
|
119
|
+
const debugCounters = new Map(); // pid → turn counter
|
|
120
|
+
|
|
121
|
+
function debugLog(pid, label, content) {
|
|
122
|
+
if (api.getSetting('debugging') !== true) return;
|
|
123
|
+
mkdirSync(LOGS_DIR, { recursive: true });
|
|
124
|
+
const n = (debugCounters.get(pid) || 0) + 1;
|
|
125
|
+
debugCounters.set(pid, n);
|
|
126
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
127
|
+
const file = join(LOGS_DIR, `${safeId(pid)}_${String(n).padStart(3, '0')}_${label}_${ts}.md`);
|
|
128
|
+
writeFileSync(file, content);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function resetDebugLogs(pid) {
|
|
132
|
+
debugCounters.set(pid, 0);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// --- Helpers ---
|
|
136
|
+
|
|
137
|
+
function projectFor(sid) {
|
|
138
|
+
for (const [pid, proj] of projects) {
|
|
139
|
+
if (proj.workers.has(sid)) return [pid, proj];
|
|
140
|
+
}
|
|
141
|
+
// Auto-discover: session may belong to a project with active autopilot
|
|
142
|
+
if (!projects.size) return [null, null];
|
|
143
|
+
const sess = api.getSessions().find(s => s.id === sid);
|
|
144
|
+
if (!sess?.projectId || !sess.roleName || !projects.has(sess.projectId)) return [null, null];
|
|
145
|
+
const pid = sess.projectId;
|
|
146
|
+
const proj = projects.get(pid);
|
|
147
|
+
const err = refreshWorkers(pid, proj);
|
|
148
|
+
if (err) {
|
|
149
|
+
api.sendToFrontend('notify', { projectId: pid, reason: `${err} — autopilot stopped` });
|
|
150
|
+
stop(pid);
|
|
151
|
+
return [null, null];
|
|
152
|
+
}
|
|
153
|
+
return proj.workers.has(sid) ? [pid, proj] : [null, null];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function workerByRole(proj, role) {
|
|
157
|
+
for (const [sid, w] of proj.workers) {
|
|
158
|
+
if (w.role.toLowerCase() === role.toLowerCase()) return sid;
|
|
159
|
+
}
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function discoverWorkers(pid) {
|
|
164
|
+
const workers = new Map();
|
|
165
|
+
const status = new Map();
|
|
166
|
+
for (const s of api.getSessions().filter(s => s.projectId === pid && s.roleName)) {
|
|
167
|
+
workers.set(s.id, { role: s.roleName, name: s.name, presetId: s.presetId });
|
|
168
|
+
// Sessions start idle. Status is tracked by notifyStatus() on every
|
|
169
|
+
// working/idle transition. If s.working is undefined, no transition was
|
|
170
|
+
// ever recorded — the session has been idle since creation.
|
|
171
|
+
status.set(s.id, s.working === true);
|
|
172
|
+
}
|
|
173
|
+
return { workers, status };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Returns null on success, or an error string (caller must stop autopilot).
|
|
177
|
+
function refreshWorkers(pid, proj) {
|
|
178
|
+
const live = new Map();
|
|
179
|
+
const liveStatus = new Map();
|
|
180
|
+
for (const s of api.getSessions().filter(s => s.projectId === pid && s.roleName)) {
|
|
181
|
+
live.set(s.id, { role: s.roleName, name: s.name, presetId: s.presetId });
|
|
182
|
+
liveStatus.set(s.id, s.working === true);
|
|
183
|
+
}
|
|
184
|
+
// Remove dead sessions
|
|
185
|
+
for (const sid of [...proj.workers.keys()]) {
|
|
186
|
+
if (!live.has(sid)) {
|
|
187
|
+
proj.workers.delete(sid);
|
|
188
|
+
proj.status.delete(sid);
|
|
189
|
+
proj.lastOutput.delete(sid);
|
|
190
|
+
api.setAutoApproveMenu(sid, false);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
// Add new sessions
|
|
194
|
+
for (const [sid, w] of live) {
|
|
195
|
+
if (!proj.workers.has(sid)) {
|
|
196
|
+
const roleKey = w.role.toLowerCase();
|
|
197
|
+
if ([...proj.workers.values()].some(x => x.role.toLowerCase() === roleKey)) {
|
|
198
|
+
return `Duplicate role "${w.role}"`;
|
|
199
|
+
}
|
|
200
|
+
proj.workers.set(sid, w);
|
|
201
|
+
proj.status.set(sid, liveStatus.get(sid));
|
|
202
|
+
api.setAutoApproveMenu(sid, true);
|
|
203
|
+
// Seed output for idle new workers from .screen
|
|
204
|
+
if (!liveStatus.get(sid)) {
|
|
205
|
+
const turns = api.getScreenTurns(sid, w.presetId, { raw: true });
|
|
206
|
+
if (turns?.length && turns[turns.length - 1].role === 'agent') {
|
|
207
|
+
const text = turns[turns.length - 1].text.trim().slice(0, 8000);
|
|
208
|
+
proj.lastOutput.set(sid, { text, capturedAt: Date.now(), outputId: outputId(text) });
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// --- Model management ---
|
|
217
|
+
|
|
218
|
+
function filterModels(all) {
|
|
219
|
+
// pi-ai model objects have no alias/canonical metadata — the display name is
|
|
220
|
+
// the only signal. Canonical models get "(latest)" appended, dated snapshots
|
|
221
|
+
// get "(YYYY-MM-DD)". We drop qualified variants when the clean base exists.
|
|
222
|
+
const baseNames = new Set();
|
|
223
|
+
for (const m of all) {
|
|
224
|
+
const base = m.name.replace(/\s*\((?:latest|\d{4}-\d{2}-\d{2})\)$/, '');
|
|
225
|
+
if (base !== m.name) baseNames.add(base);
|
|
226
|
+
}
|
|
227
|
+
const filtered = all.filter(m => {
|
|
228
|
+
// Drop "(latest)" and "(date)" variants when the clean base name exists as another model
|
|
229
|
+
const qualifier = m.name.match(/\s*\((latest|\d{4}-\d{2}-\d{2})\)$/);
|
|
230
|
+
if (!qualifier) return !baseNames.has(m.name) || true; // clean name — always keep
|
|
231
|
+
const base = m.name.replace(qualifier[0], '');
|
|
232
|
+
// Keep (latest) variant only if no clean base exists
|
|
233
|
+
if (qualifier[1] === 'latest') return !all.find(x => x.name === base);
|
|
234
|
+
// Drop dated variants
|
|
235
|
+
return false;
|
|
236
|
+
});
|
|
237
|
+
return filtered
|
|
238
|
+
.sort((a, b) => a.cost.input - b.cost.input)
|
|
239
|
+
.map(m => ({ value: m.id, label: `${m.name.replace(/\s*\(latest\)$/, '')} ($${m.cost.input}/M)` }));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function loadModelsForProvider(provider) {
|
|
243
|
+
try {
|
|
244
|
+
const m = await ai();
|
|
245
|
+
const options = filterModels(m.getModels(provider));
|
|
246
|
+
// Set options first so setSetting can validate against the new list
|
|
247
|
+
api.setSettingOptions('model', options);
|
|
248
|
+
const current = api.getSetting('model');
|
|
249
|
+
if (options.length && !options.find(o => o.value === current)) {
|
|
250
|
+
api.setSetting('model', options[0].value);
|
|
251
|
+
}
|
|
252
|
+
} catch (e) {
|
|
253
|
+
api.log(`Failed to load models for ${provider}: ${e.message}`);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// --- Provider helpers ---
|
|
258
|
+
|
|
259
|
+
function toolChoiceForProvider(provider) {
|
|
260
|
+
// Anthropic, Google, Mistral use "any"; OpenAI-compatible (openai, groq, openrouter, xai, cerebras) use "required"
|
|
261
|
+
return ({ anthropic: 'any', google: 'any', mistral: 'any' })[provider] || 'required';
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// --- LLM tools ---
|
|
265
|
+
|
|
266
|
+
function buildTools(Type) {
|
|
267
|
+
return [
|
|
268
|
+
{
|
|
269
|
+
name: 'route',
|
|
270
|
+
description: 'Forward one agent\'s output to another idle agent. The system copies the output verbatim — you only choose who sends and who receives.',
|
|
271
|
+
parameters: Type.Object({
|
|
272
|
+
from: Type.String({ description: 'Source agent role name (prefer [LATEST] agent)' }),
|
|
273
|
+
to: Type.String({ description: 'Target agent role name (must be IDLE)' }),
|
|
274
|
+
}),
|
|
275
|
+
},
|
|
276
|
+
{
|
|
277
|
+
name: 'notify_user',
|
|
278
|
+
description: 'Notify the user and stop autopilot. Use light markdown. Keep it concise (2-5 sentences). Use when: workflow is complete, human input is needed, or agents are stuck.',
|
|
279
|
+
parameters: Type.Object({
|
|
280
|
+
reason: Type.String({ description: 'Short message to user (2-3 sentences max)' }),
|
|
281
|
+
}),
|
|
282
|
+
},
|
|
283
|
+
];
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// --- System prompt ---
|
|
287
|
+
|
|
288
|
+
const PROMPT_TEMPLATE = readFileSync(join(__dirname, 'prompt.md'), 'utf8');
|
|
289
|
+
|
|
290
|
+
function buildPrompt(proj, pid) {
|
|
291
|
+
const pList = api.getProjects();
|
|
292
|
+
const p = pList.find(x => x.id === pid);
|
|
293
|
+
const projectName = p ? `${p.name}${p.path ? ` (${p.path})` : ''}` : pid;
|
|
294
|
+
|
|
295
|
+
const roles = api.getRoles();
|
|
296
|
+
const agentProfiles = [];
|
|
297
|
+
for (const [sid, w] of proj.workers) {
|
|
298
|
+
const busy = proj.status.get(sid);
|
|
299
|
+
const role = roles.find(r => r.name === w.role);
|
|
300
|
+
const desc = role?.instructions || '(no role description)';
|
|
301
|
+
agentProfiles.push(`${w.role} [${busy ? 'WORKING' : 'IDLE'}]\n Role: ${desc}`);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
let prompt = PROMPT_TEMPLATE
|
|
305
|
+
.replace('{{projectName}}', projectName)
|
|
306
|
+
.replace('{{agents}}', agentProfiles.join('\n\n'));
|
|
307
|
+
|
|
308
|
+
return prompt;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function buildStateContext(proj, pid) {
|
|
312
|
+
const fmtTs = (ts) => ts ? new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }) : '';
|
|
313
|
+
const fmtAgo = (ts) => {
|
|
314
|
+
if (!ts) return '';
|
|
315
|
+
const sec = Math.round((Date.now() - ts) / 1000);
|
|
316
|
+
if (sec < 60) return `${sec}s ago`;
|
|
317
|
+
return `${Math.round(sec / 60)}m ago`;
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
const lines = ['CURRENT STATE'];
|
|
321
|
+
|
|
322
|
+
// Per-worker state
|
|
323
|
+
const lastActionAt = proj.lastAction?.at || 0;
|
|
324
|
+
for (const [sid, w] of proj.workers) {
|
|
325
|
+
const stored = proj.lastOutput.get(sid);
|
|
326
|
+
const oid = stored?.outputId || null;
|
|
327
|
+
const capturedAt = stored?.capturedAt || 0;
|
|
328
|
+
const isNew = capturedAt > lastActionAt;
|
|
329
|
+
const status = proj.status.get(sid) ? 'WORKING' : 'IDLE';
|
|
330
|
+
let line = ` ${w.role}: ${status}`;
|
|
331
|
+
if (oid) line += ` | output #${oid} captured ${fmtAgo(capturedAt)}${isNew ? ' (NEW)' : ''}`;
|
|
332
|
+
else line += ' | no output';
|
|
333
|
+
lines.push(line);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Handoff summary from KB: which outputs were routed where
|
|
337
|
+
const kb = readKB(pid, 30);
|
|
338
|
+
const handoffs = new Map(); // outputId → { from, routedTo: Set }
|
|
339
|
+
for (const e of kb) {
|
|
340
|
+
if (e.from && e.to && e.outputId) {
|
|
341
|
+
if (!handoffs.has(e.outputId)) handoffs.set(e.outputId, { from: e.from, routedTo: new Set() });
|
|
342
|
+
handoffs.get(e.outputId).routedTo.add(e.to);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
if (handoffs.size) {
|
|
346
|
+
lines.push('');
|
|
347
|
+
lines.push('HANDOFF LOG');
|
|
348
|
+
for (const [oid, h] of handoffs) {
|
|
349
|
+
lines.push(` #${oid} from ${h.from} → routed to: ${[...h.routedTo].join(', ')}`);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Project-level routing state
|
|
354
|
+
lines.push('');
|
|
355
|
+
lines.push('ROUTING STATE');
|
|
356
|
+
if (proj.lastAction) {
|
|
357
|
+
lines.push(` Last route: ${proj.lastAction.from} → ${proj.lastAction.to} (output #${proj.lastAction.outputId}, ${fmtAgo(proj.lastAction.at)})`);
|
|
358
|
+
} else {
|
|
359
|
+
lines.push(' No routes yet');
|
|
360
|
+
}
|
|
361
|
+
if (proj.waitingOn) lines.push(` Waiting on: ${proj.waitingOn}`);
|
|
362
|
+
if (proj.staleSince) lines.push(` Stale since: ${fmtAgo(proj.staleSince)} (no new output since last route)`);
|
|
363
|
+
|
|
364
|
+
// Recent routing history (compact, last 15)
|
|
365
|
+
const recent = kb.slice(-15);
|
|
366
|
+
if (recent.length) {
|
|
367
|
+
lines.push('');
|
|
368
|
+
lines.push('RECENT HISTORY');
|
|
369
|
+
for (const e of recent) {
|
|
370
|
+
const t = fmtTs(e.ts);
|
|
371
|
+
if (e.from && e.to) lines.push(` ${t} ${e.from} → ${e.to} (#${e.outputId || '?'})`);
|
|
372
|
+
else if (e.from) lines.push(` ${t} [${e.from}] output captured (#${e.outputId || '?'})`);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return lines.join('\n');
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// --- Core: consult LLM ---
|
|
380
|
+
|
|
381
|
+
async function consult(pid, proj) {
|
|
382
|
+
if (proj.pending || proj.paused || !projects.has(pid)) return;
|
|
383
|
+
const pillId = `autopilot-${pid}`;
|
|
384
|
+
const refreshErr = refreshWorkers(pid, proj);
|
|
385
|
+
if (refreshErr) {
|
|
386
|
+
api.sendToFrontend('notify', { projectId: pid, reason: `${refreshErr} — autopilot stopped` });
|
|
387
|
+
stop(pid);
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
// Re-check all-idle after refresh — new workers may be busy
|
|
391
|
+
if (![...proj.workers.keys()].every(sid => !proj.status.get(sid))) return;
|
|
392
|
+
|
|
393
|
+
let m;
|
|
394
|
+
try { m = await ai(); } catch (e) {
|
|
395
|
+
api.sendToFrontend('notify', { projectId: pid, reason: `Failed to load AI library: ${e.message}` });
|
|
396
|
+
stop(pid);
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const provider = api.getSetting('provider') || 'anthropic';
|
|
401
|
+
const modelId = api.getSetting('model') || 'claude-haiku-4-5';
|
|
402
|
+
const apiKey = api.getSetting('apiKey') || m.getEnvApiKey(provider) || '';
|
|
403
|
+
|
|
404
|
+
if (!apiKey) {
|
|
405
|
+
proj.paused = true;
|
|
406
|
+
proj.pauseReason = 'config';
|
|
407
|
+
api.sendToFrontend('error', { msg: `No API key for ${provider}. Set in Autopilot settings or via env var.` });
|
|
408
|
+
api.sendToFrontend('paused', { projectId: pid, question: 'API key missing — configure in plugin settings' });
|
|
409
|
+
api.updateSessionPill(pillId, { working: false, statusText: 'Paused — no API key' });
|
|
410
|
+
api.appendPillLog(pillId, 'Paused: API key missing');
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
let model;
|
|
415
|
+
try { model = m.getModel(provider, modelId); } catch (e) {
|
|
416
|
+
api.sendToFrontend('notify', { projectId: pid, reason: `Model "${modelId}" not found — autopilot stopped` });
|
|
417
|
+
stop(pid);
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Build structured state context
|
|
422
|
+
const stateContext = buildStateContext(proj, pid);
|
|
423
|
+
|
|
424
|
+
// Build agent output section
|
|
425
|
+
const lastActionAt = proj.lastAction?.at || 0;
|
|
426
|
+
const entries = [];
|
|
427
|
+
for (const [sid, w] of proj.workers) {
|
|
428
|
+
const stored = proj.lastOutput.get(sid);
|
|
429
|
+
let text = stored?.text || null;
|
|
430
|
+
let capturedAt = stored?.capturedAt || 0;
|
|
431
|
+
let oid = stored?.outputId || null;
|
|
432
|
+
if (!text) {
|
|
433
|
+
const turns = api.getScreenTurns(sid, w.presetId, { raw: true });
|
|
434
|
+
if (turns?.length) {
|
|
435
|
+
const last = [...turns].reverse().find(t => t.role === 'agent');
|
|
436
|
+
if (last) { text = last.text.trim().slice(0, 8000); capturedAt = capturedAt || Date.now(); oid = outputId(text); }
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
entries.push({ sid, role: w.role, text, capturedAt, outputId: oid });
|
|
440
|
+
}
|
|
441
|
+
entries.sort((a, b) => a.capturedAt - b.capturedAt);
|
|
442
|
+
|
|
443
|
+
const outputParts = entries.map(e => {
|
|
444
|
+
const isNew = e.capturedAt > lastActionAt;
|
|
445
|
+
const tag = isNew ? ' — NEW' : '';
|
|
446
|
+
const idTag = e.outputId ? ` (#${e.outputId})` : '';
|
|
447
|
+
return `[${e.role}${tag}${idTag}]:\n${e.text ? e.text.slice(0, 2000) : '(no output captured)'}`;
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
const sections = [stateContext, ''];
|
|
451
|
+
if (outputParts.length) {
|
|
452
|
+
sections.push('AGENT OUTPUTS');
|
|
453
|
+
sections.push(outputParts.join('\n\n'));
|
|
454
|
+
} else {
|
|
455
|
+
sections.push('No output from any agent yet.');
|
|
456
|
+
}
|
|
457
|
+
sections.push('\nDecide what to route next.');
|
|
458
|
+
|
|
459
|
+
const ctx = {
|
|
460
|
+
systemPrompt: buildPrompt(proj, pid),
|
|
461
|
+
messages: [{
|
|
462
|
+
role: 'user',
|
|
463
|
+
content: sections.join('\n'),
|
|
464
|
+
timestamp: Date.now(),
|
|
465
|
+
}],
|
|
466
|
+
tools: buildTools(m.Type),
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
debugLog(pid, 'turn', [
|
|
470
|
+
`# Router Turn — ${new Date().toISOString()}`,
|
|
471
|
+
`Model: ${modelId} (${provider})`,
|
|
472
|
+
'',
|
|
473
|
+
'## System Prompt',
|
|
474
|
+
ctx.systemPrompt,
|
|
475
|
+
'',
|
|
476
|
+
'## User Message',
|
|
477
|
+
ctx.messages[0].content,
|
|
478
|
+
].join('\n'));
|
|
479
|
+
|
|
480
|
+
api.updateSessionPill(pillId, { working: true, statusText: 'Consulting router...' });
|
|
481
|
+
api.appendPillLog(pillId, `Consulting ${modelId}`);
|
|
482
|
+
// api.log(`[consult] model=${modelId} workers=${[...proj.workers.values()].map(w => w.role).join(',')}`);
|
|
483
|
+
// api.log(`[consult] hasOutput=${[...proj.workers].filter(([sid]) => proj.lastOutput.has(sid)).map(([,w]) => w.role).join(',') || 'none'}`);
|
|
484
|
+
|
|
485
|
+
const MAX_HINTS = 3;
|
|
486
|
+
const HINT_DELAYS = [0, 3000, 6000];
|
|
487
|
+
const delay = ms => new Promise(r => setTimeout(r, ms));
|
|
488
|
+
|
|
489
|
+
proj.pending = true;
|
|
490
|
+
const opts = { apiKey, reasoning: 'minimal', toolChoice: toolChoiceForProvider(provider) };
|
|
491
|
+
try {
|
|
492
|
+
for (let attempt = 0; attempt < MAX_HINTS; attempt++) {
|
|
493
|
+
if (attempt > 0) await delay(HINT_DELAYS[attempt] || 6000);
|
|
494
|
+
|
|
495
|
+
const res = await m.complete(model, ctx, opts);
|
|
496
|
+
addTokens(pid, res.usage);
|
|
497
|
+
|
|
498
|
+
const tc = res.content?.find(b => b.type === 'toolCall');
|
|
499
|
+
const textContent = res.content?.find(b => b.type === 'text')?.text || '';
|
|
500
|
+
debugLog(pid, `response_${attempt + 1}`, [
|
|
501
|
+
`# Response — attempt ${attempt + 1}/${MAX_HINTS}`,
|
|
502
|
+
`Tokens: in=${res.usage?.input || 0} out=${res.usage?.output || 0}`,
|
|
503
|
+
'',
|
|
504
|
+
tc ? `## Tool Call: ${tc.name}\n\`\`\`json\n${JSON.stringify(tc.arguments, null, 2)}\n\`\`\`` : '## No tool call',
|
|
505
|
+
textContent ? `\n## Text\n${textContent}` : '',
|
|
506
|
+
].join('\n'));
|
|
507
|
+
|
|
508
|
+
if (!tc) {
|
|
509
|
+
ctx.messages.push(
|
|
510
|
+
{ role: 'assistant', content: textContent, timestamp: Date.now() },
|
|
511
|
+
{ role: 'user', content: 'You must call one of the provided tools: route(from, to) or notify_user(reason). Do not reply with text.', timestamp: Date.now() },
|
|
512
|
+
);
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const error = executeAction(pid, proj, tc.name, tc.arguments, pillId);
|
|
517
|
+
if (error === null) { proj.pending = false; return; }
|
|
518
|
+
|
|
519
|
+
// api.log(`[consult] action error — hinting: ${error}`);
|
|
520
|
+
ctx.messages.push(res, {
|
|
521
|
+
role: 'toolResult', toolCallId: tc.id, toolName: tc.name,
|
|
522
|
+
content: [{ type: 'text', text: error }], isError: true, timestamp: Date.now(),
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
proj.pending = false;
|
|
527
|
+
api.sendToFrontend('notify', { projectId: pid, reason: 'Model failed after multiple hints — autopilot stopped' });
|
|
528
|
+
stop(pid);
|
|
529
|
+
} catch (e) {
|
|
530
|
+
proj.pending = false;
|
|
531
|
+
api.log(`LLM error: ${e.message}`);
|
|
532
|
+
api.sendToFrontend('notify', { projectId: pid, reason: `Model error: ${e.message}` });
|
|
533
|
+
stop(pid);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function triggerConsult(pid, proj) {
|
|
538
|
+
if (!projects.has(pid) || proj.paused || proj.pending) return;
|
|
539
|
+
consult(pid, proj);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// --- Action execution (returns error string or null on success) ---
|
|
543
|
+
|
|
544
|
+
function executeAction(pid, proj, action, args, pillId) {
|
|
545
|
+
const roles = [...proj.workers.values()].map(w => w.role);
|
|
546
|
+
switch (action) {
|
|
547
|
+
case 'route': {
|
|
548
|
+
const src = workerByRole(proj, args.from);
|
|
549
|
+
const dst = workerByRole(proj, args.to);
|
|
550
|
+
if (!dst) return `No agent with role "${args.to}". Available roles: ${roles.join(', ')}`;
|
|
551
|
+
if (src === dst) return 'Cannot route agent to itself';
|
|
552
|
+
if (proj.status.get(dst)) return `"${args.to}" is currently working — pick an idle agent`;
|
|
553
|
+
const stored = src ? proj.lastOutput.get(src) : null;
|
|
554
|
+
let out = stored?.text || null;
|
|
555
|
+
let oid = stored?.outputId || null;
|
|
556
|
+
if (!out && src) {
|
|
557
|
+
const w = proj.workers.get(src);
|
|
558
|
+
if (w) {
|
|
559
|
+
const turns = api.getScreenTurns(src, w.presetId, { raw: true });
|
|
560
|
+
if (turns?.length && turns[turns.length - 1].role === 'agent') {
|
|
561
|
+
out = turns[turns.length - 1].text.trim().slice(0, 8000);
|
|
562
|
+
oid = outputId(out);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
if (!out) return `"${args.from}" has no output to route`;
|
|
567
|
+
|
|
568
|
+
// Repeat guard: block same outputId → same target unless new output was captured in between
|
|
569
|
+
const la = proj.lastAction;
|
|
570
|
+
if (la && oid && la.outputId === oid && la.to.toLowerCase() === args.to.toLowerCase()) {
|
|
571
|
+
const anyNewSince = [...proj.lastOutput.values()].some(o => o.capturedAt > la.at);
|
|
572
|
+
if (!anyNewSince) {
|
|
573
|
+
return `Output #${oid} was already routed to "${args.to}" and no new output has been captured since. Pick a different target or call notify_user if stuck.`;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const dstRole = proj.workers.get(dst)?.role || args.to;
|
|
578
|
+
const header = [
|
|
579
|
+
`[Autopilot route${oid ? ` | output #${oid}` : ''}]`,
|
|
580
|
+
`[Team: ${roles.join(', ')}]`,
|
|
581
|
+
`[You are: ${dstRole}]`,
|
|
582
|
+
`[From: ${args.from}]`,
|
|
583
|
+
'[Do not spawn internal agents.]',
|
|
584
|
+
].join('\n');
|
|
585
|
+
const payload = `${header}\n\n${out}`;
|
|
586
|
+
api.inputToSession(dst, payload);
|
|
587
|
+
setTimeout(() => api.inputToSession(dst, '\r'), 150);
|
|
588
|
+
const now = Date.now();
|
|
589
|
+
appendKB(pid, { from: args.from, to: args.to, msg: out.slice(0, 4000), outputId: oid });
|
|
590
|
+
proj.lastAction = { outputId: oid, from: args.from, to: args.to, at: now };
|
|
591
|
+
proj.waitingOn = args.to;
|
|
592
|
+
proj.staleSince = null;
|
|
593
|
+
api.sendToFrontend('routed', { projectId: pid, from: args.from, to: args.to });
|
|
594
|
+
if (pillId) {
|
|
595
|
+
api.updateSessionPill(pillId, { working: false, statusText: `${args.from} → ${args.to}` });
|
|
596
|
+
api.appendPillLog(pillId, `Routed ${args.from} → ${args.to}`);
|
|
597
|
+
}
|
|
598
|
+
return null;
|
|
599
|
+
}
|
|
600
|
+
case 'notify_user': {
|
|
601
|
+
if (pillId) {
|
|
602
|
+
api.appendPillLog(pillId, `Notify: ${args.reason}`);
|
|
603
|
+
api.updateSessionPill(pillId, { working: false, statusText: 'Finished' });
|
|
604
|
+
}
|
|
605
|
+
const pList = api.getProjects();
|
|
606
|
+
const projName = pList.find(x => x.id === pid)?.name || 'Project';
|
|
607
|
+
api.sendToFrontend('notify', { projectId: pid, reason: args.reason, projectName: projName });
|
|
608
|
+
// api.log(`Notify + stop: ${args.reason}`);
|
|
609
|
+
stop(pid, true); // keepPill=true
|
|
610
|
+
return null;
|
|
611
|
+
}
|
|
612
|
+
default:
|
|
613
|
+
return `Unknown tool "${action}". You must use route(from, to) or notify_user(reason).`;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// --- Lifecycle ---
|
|
618
|
+
|
|
619
|
+
function start(pid) {
|
|
620
|
+
if (projects.has(pid)) return { error: 'Already running' };
|
|
621
|
+
if (!enabled()) return { error: 'Autopilot disabled' };
|
|
622
|
+
|
|
623
|
+
const { workers, status } = discoverWorkers(pid);
|
|
624
|
+
if (workers.size < 1) return { error: 'No agents with roles in this project' };
|
|
625
|
+
|
|
626
|
+
const roles = new Set();
|
|
627
|
+
for (const [, w] of workers) {
|
|
628
|
+
const key = w.role.toLowerCase();
|
|
629
|
+
if (roles.has(key)) return { error: `Duplicate role "${w.role}"` };
|
|
630
|
+
roles.add(key);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
resetProjectState(pid);
|
|
634
|
+
resetDebugLogs(pid);
|
|
635
|
+
|
|
636
|
+
const proj = {
|
|
637
|
+
workers,
|
|
638
|
+
status,
|
|
639
|
+
lastOutput: new Map(),
|
|
640
|
+
paused: false,
|
|
641
|
+
pauseReason: null,
|
|
642
|
+
pending: false,
|
|
643
|
+
lastAction: null, // { outputId, from, to, at }
|
|
644
|
+
waitingOn: null, // role name we expect new output from
|
|
645
|
+
staleSince: null, // timestamp when we started waiting without progress
|
|
646
|
+
};
|
|
647
|
+
projects.set(pid, proj);
|
|
648
|
+
|
|
649
|
+
// Flag all workers for core menu auto-approve
|
|
650
|
+
for (const [sid] of workers) api.setAutoApproveMenu(sid, true);
|
|
651
|
+
|
|
652
|
+
// Seed lastOutput from .screen for idle workers
|
|
653
|
+
for (const [sid, w] of workers) {
|
|
654
|
+
if (status.get(sid)) continue;
|
|
655
|
+
const turns = api.getScreenTurns(sid, w.presetId, { raw: true });
|
|
656
|
+
if (!turns?.length || turns[turns.length - 1].role !== 'agent') continue;
|
|
657
|
+
const text = turns[turns.length - 1].text.trim().slice(0, 8000);
|
|
658
|
+
proj.lastOutput.set(sid, { text, capturedAt: Date.now(), outputId: outputId(text) });
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// api.log(`Started: ${pid}, ${workers.size} workers`);
|
|
662
|
+
api.sendToFrontend('started', { projectId: pid });
|
|
663
|
+
const pillId = `autopilot-${pid}`;
|
|
664
|
+
api.addSessionPill({ id: pillId, title: 'Autopilot', projectId: pid });
|
|
665
|
+
api.appendPillLog(pillId, `Started with ${workers.size} workers: ${[...workers.values()].map(w => w.role).join(', ')}`);
|
|
666
|
+
|
|
667
|
+
// Only consult if all workers are already idle
|
|
668
|
+
const allIdle = [...workers.keys()].every(sid => !status.get(sid));
|
|
669
|
+
if (allIdle) setTimeout(() => triggerConsult(pid, proj), 3000);
|
|
670
|
+
|
|
671
|
+
return { ok: true };
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function stop(pid, keepPill) {
|
|
675
|
+
const proj = projects.get(pid);
|
|
676
|
+
if (!proj) return;
|
|
677
|
+
for (const [sid] of proj.workers) api.setAutoApproveMenu(sid, false);
|
|
678
|
+
projects.delete(pid);
|
|
679
|
+
// api.log(`Stopped: ${pid}`);
|
|
680
|
+
api.sendToFrontend('stopped', { projectId: pid });
|
|
681
|
+
const pillId = `autopilot-${pid}`;
|
|
682
|
+
if (keepPill) {
|
|
683
|
+
api.appendPillLog(pillId, 'Completed');
|
|
684
|
+
} else {
|
|
685
|
+
api.appendPillLog(pillId, 'Stopped');
|
|
686
|
+
api.removeSessionPill(pillId);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// --- Plugin init ---
|
|
691
|
+
|
|
692
|
+
module.exports.init = function (pluginApi) {
|
|
693
|
+
api = pluginApi;
|
|
694
|
+
loadUsage();
|
|
695
|
+
|
|
696
|
+
api.addProjectAction({
|
|
697
|
+
id: 'autopilot-toggle',
|
|
698
|
+
title: 'Autopilot',
|
|
699
|
+
icon: '<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>',
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
// Toggle on/off
|
|
703
|
+
api.onFrontendMessage('autopilot-toggle', (msg) => {
|
|
704
|
+
if (projects.has(msg.projectId)) {
|
|
705
|
+
stop(msg.projectId);
|
|
706
|
+
} else {
|
|
707
|
+
// Remove lingering pill from a previous finished run
|
|
708
|
+
api.removeSessionPill(`autopilot-${msg.projectId}`);
|
|
709
|
+
const r = start(msg.projectId);
|
|
710
|
+
if (r.error) api.sendToFrontend('error', { msg: r.error });
|
|
711
|
+
}
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
// Menu detected on an autopilot worker — flag it so idle is suppressed until auto-approve completes
|
|
715
|
+
api.onMenuDetected((id, choices) => {
|
|
716
|
+
if (!choices?.length) return;
|
|
717
|
+
const [pid] = projectFor(id);
|
|
718
|
+
if (pid) menuPending.add(id);
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
// Status change — the main routing trigger (only when ALL workers are idle)
|
|
722
|
+
api.onStatusChange((id, working, source) => {
|
|
723
|
+
if (!enabled()) return;
|
|
724
|
+
const [pid, proj] = projectFor(id);
|
|
725
|
+
if (!pid) return;
|
|
726
|
+
const w = proj.workers.get(id);
|
|
727
|
+
const role = w?.role || id.slice(0, 8);
|
|
728
|
+
|
|
729
|
+
if (working) { menuPending.delete(id); }
|
|
730
|
+
|
|
731
|
+
if (!working && menuPending.has(id)) {
|
|
732
|
+
menuPending.delete(id);
|
|
733
|
+
// api.log(`[status] ${role} → IDLE (menu pending — suppressed)`);
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
proj.status.set(id, working);
|
|
737
|
+
const pillId = `autopilot-${pid}`;
|
|
738
|
+
|
|
739
|
+
if (working) {
|
|
740
|
+
api.appendPillLog(pillId, `${role} → working`);
|
|
741
|
+
api.updateSessionPill(pillId, { working: false, statusText: `Waiting on ${role}` });
|
|
742
|
+
// api.log(`[status] ${role} → WORKING`);
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
if (!proj.workers.has(id)) return;
|
|
746
|
+
|
|
747
|
+
api.appendPillLog(pillId, `${role} → idle`);
|
|
748
|
+
setTimeout(() => captureIdleOutput(id, pid, proj), 5000);
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
// Frontend queries
|
|
752
|
+
api.onFrontendMessage('getStatus', (msg) => {
|
|
753
|
+
const proj = projects.get(msg.projectId);
|
|
754
|
+
const tokens = tokenUsage.get(msg.projectId);
|
|
755
|
+
api.sendToFrontend('status', {
|
|
756
|
+
projectId: msg.projectId,
|
|
757
|
+
active: !!proj,
|
|
758
|
+
paused: proj?.paused || false,
|
|
759
|
+
tokens: tokens || null,
|
|
760
|
+
});
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
api.onFrontendMessage('sync', () => {
|
|
764
|
+
for (const [pid] of projects) {
|
|
765
|
+
api.sendToFrontend('status', { projectId: pid, active: true });
|
|
766
|
+
}
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
api.onFrontendMessage('getTokens', (msg) => {
|
|
770
|
+
const u = tokenUsage.get(msg.projectId);
|
|
771
|
+
api.sendToFrontend('tokens', { projectId: msg.projectId, ...(u || { input: 0, output: 0 }) });
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
// Clear config pauses when user fixes settings; refresh model list on provider change
|
|
775
|
+
api.onSettingsChange((key) => {
|
|
776
|
+
if (key === 'apiKey' || key === 'provider' || key === 'model') {
|
|
777
|
+
for (const [pid, proj] of projects) {
|
|
778
|
+
if (proj.paused && proj.pauseReason === 'config') {
|
|
779
|
+
proj.paused = false;
|
|
780
|
+
proj.pauseReason = null;
|
|
781
|
+
api.sendToFrontend('resumed', { projectId: pid });
|
|
782
|
+
const allIdle = [...proj.workers.keys()].every(sid => !proj.status.get(sid));
|
|
783
|
+
if (allIdle) triggerConsult(pid, proj);
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
if (key === 'provider') loadModelsForProvider(api.getSetting('provider'));
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
// Load model options on startup
|
|
791
|
+
loadModelsForProvider(api.getSetting('provider') || 'anthropic');
|
|
792
|
+
|
|
793
|
+
api.onShutdown(() => {
|
|
794
|
+
for (const [pid] of projects) stop(pid);
|
|
795
|
+
saveUsage();
|
|
796
|
+
});
|
|
797
|
+
};
|