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.
@@ -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
+ };