clideck 1.30.1 → 1.30.3

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.
@@ -5,16 +5,24 @@ const { join } = require('path');
5
5
  const crypto = require('crypto');
6
6
 
7
7
  const DATA_DIR = join(require('os').homedir(), '.clideck', 'autopilot');
8
+ const GOALS_DIR = join(DATA_DIR, 'goals');
9
+ const GOAL_PREFIX = 'AUTOPILOT GOAL:';
10
+ const FIRST_USER_MESSAGES = 3;
11
+ const FIRST_TURN_SCAN = 50;
12
+ const GOAL_SEARCH_TURNS = 2000;
13
+ const MAX_GOAL_MESSAGE_CHARS = 100000;
8
14
 
9
15
  // --- State ---
10
16
  const projects = new Map(); // projectId → Project
11
17
  const tokenUsage = new Map(); // projectId → { input, output }
12
18
  const menuPending = new Set(); // sessionIds with a menu awaiting auto-approve
19
+ const idleCaptureTimers = new Map(); // sessionId → timeout id for deferred idle capture
13
20
  let api = null;
14
21
  let piAi = null;
15
22
 
16
23
  function enabled() { return api.getSetting('enabled') !== false; }
17
24
  function safeId(id) { return String(id).replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64); }
25
+ function pillId(pid) { return `autopilot-${pid}`; }
18
26
 
19
27
  function outputId(text) {
20
28
  const normalized = (text || '').trim().replace(/\s+/g, ' ');
@@ -32,6 +40,7 @@ async function ai() {
32
40
  // --- KB (routing history) ---
33
41
 
34
42
  function kbPath(pid) { return join(DATA_DIR, `${safeId(pid)}.jsonl`); }
43
+ function goalPath(pid) { return join(GOALS_DIR, `${safeId(pid)}.json`); }
35
44
 
36
45
  function appendKB(pid, entry) {
37
46
  mkdirSync(DATA_DIR, { recursive: true });
@@ -48,6 +57,21 @@ function readKB(pid, n) {
48
57
  } catch { return []; }
49
58
  }
50
59
 
60
+ function loadGoal(pid) {
61
+ const p = goalPath(pid);
62
+ if (!existsSync(p)) return null;
63
+ try {
64
+ const goal = JSON.parse(readFileSync(p, 'utf8'));
65
+ return goal?.text ? goal : null;
66
+ } catch { return null; }
67
+ }
68
+
69
+ function saveGoal(pid, goal) {
70
+ if (!goal?.text) return;
71
+ mkdirSync(GOALS_DIR, { recursive: true });
72
+ writeFileSync(goalPath(pid), JSON.stringify(goal, null, 2));
73
+ }
74
+
51
75
  // --- Token usage ---
52
76
 
53
77
  function usagePath() { return join(DATA_DIR, 'usage.json'); }
@@ -81,9 +105,124 @@ function latestAgentOutput(id) {
81
105
  return last?.text?.trim().slice(0, 8000) || null;
82
106
  }
83
107
 
108
+ function usageTotals(usage) {
109
+ const input = usage?.input || 0;
110
+ const output = usage?.output || 0;
111
+ return { input, output, total: input + output };
112
+ }
113
+
114
+ function formatTokenLog(label, usage, source) {
115
+ const t = usageTotals(usage);
116
+ return `${label} tokens (${source}): in ${t.input}, out ${t.output}, total ${t.total}`;
117
+ }
118
+
119
+ function firstUserMessages(id, n) {
120
+ return api.getTranscript(id, FIRST_TURN_SCAN, 'start')
121
+ .filter(t => t.role === 'user')
122
+ .slice(0, n)
123
+ .map(t => String(t.text || '').trim())
124
+ .filter(Boolean);
125
+ }
126
+
127
+ function extractExplicitGoal(text) {
128
+ const lines = String(text || '').split('\n');
129
+ for (let i = 0; i < lines.length; i++) {
130
+ const trimmed = lines[i].trim();
131
+ if (!trimmed.toUpperCase().startsWith(GOAL_PREFIX)) continue;
132
+ const first = trimmed.slice(GOAL_PREFIX.length).trim();
133
+ const rest = lines.slice(i + 1).join('\n').trim();
134
+ return [first, rest].filter(Boolean).join('\n').replace(/\s+/g, ' ').trim();
135
+ }
136
+ return '';
137
+ }
138
+
139
+ function isUnclearGoal(text) {
140
+ const normalized = String(text || '').trim().replace(/^["'\s]+|["'\s]+$/g, '').toLowerCase();
141
+ return normalized === 'unclear_goal';
142
+ }
143
+
144
+ function findExplicitGoal(proj) {
145
+ const found = [];
146
+ for (const [sid, w] of proj.workers) {
147
+ const turns = api.getTranscript(sid, GOAL_SEARCH_TURNS, 'start');
148
+ for (const turn of turns) {
149
+ if (turn.role !== 'user') continue;
150
+ const goal = extractExplicitGoal(turn.text);
151
+ if (!goal) continue;
152
+ found.push({ goal, worker: w.label, sessionId: sid });
153
+ }
154
+ }
155
+ const unique = [...new Set(found.map(x => x.goal))];
156
+ if (!unique.length) return { goal: null };
157
+ if (unique.length > 1) {
158
+ return {
159
+ error: `Found multiple explicit project goals. Keep one \`${GOAL_PREFIX}\` message in an existing worker session and restart Autopilot.`,
160
+ };
161
+ }
162
+ return {
163
+ goal: {
164
+ text: unique[0],
165
+ builtAt: new Date().toISOString(),
166
+ source: 'explicit-message',
167
+ },
168
+ };
169
+ }
170
+
171
+ function goalSourceContext(proj) {
172
+ const sections = [];
173
+ for (const [sid, w] of proj.workers) {
174
+ const messages = firstUserMessages(sid, FIRST_USER_MESSAGES);
175
+ if (!messages.length) continue;
176
+ const body = messages.map((text, idx) => {
177
+ const clipped = text.length > MAX_GOAL_MESSAGE_CHARS ? text.slice(0, MAX_GOAL_MESSAGE_CHARS) : text;
178
+ return `Message ${idx + 1}:\n${clipped}`;
179
+ }).join('\n\n');
180
+ sections.push(`${w.name}: ${(w.role || 'Session')} first user messages:\n${body}`);
181
+ }
182
+ return sections.join('\n\n');
183
+ }
184
+
185
+ function candidateSessions(pid) {
186
+ return api.getSessions().filter(s => s.projectId === pid && s.presetId !== 'shell');
187
+ }
188
+
189
+ function hasUserMessage(id) {
190
+ return firstUserMessages(id, 1).length > 0;
191
+ }
192
+
193
+ function sessionDisplayName(session) {
194
+ return String(session?.name || '').trim() || `Session ${String(session?.id || '').slice(0, 6)}`;
195
+ }
196
+
197
+ function buildRouteLabel(session, taken) {
198
+ const base = sessionDisplayName(session);
199
+ if (!taken.has(base.toLowerCase())) return base;
200
+ return `${base} [${String(session.id).slice(0, 6)}]`;
201
+ }
202
+
203
+ function unpromptedSessionsMessage(sessions) {
204
+ const names = (sessions || []).map(sessionDisplayName).filter(Boolean);
205
+ const suffix = names.length ? ` Missing a first prompt: ${names.join(', ')}.` : '';
206
+ return `Autopilot only steers sessions after you have already told each one what to do. Give every session in this project an initial user prompt first, then start Autopilot again.${suffix}`;
207
+ }
208
+
209
+ function parseJsonObject(text) {
210
+ const trimmed = String(text || '').trim();
211
+ if (!trimmed) return null;
212
+ const cleaned = trimmed.replace(/^```json\s*|^```\s*|\s*```$/g, '').trim();
213
+ try { return JSON.parse(cleaned); } catch {}
214
+ const start = cleaned.indexOf('{');
215
+ const end = cleaned.lastIndexOf('}');
216
+ if (start >= 0 && end > start) {
217
+ try { return JSON.parse(cleaned.slice(start, end + 1)); } catch {}
218
+ }
219
+ return null;
220
+ }
221
+
84
222
  // --- Consumed state (persisted per-project: role → boolean) ---
85
223
 
86
224
  function captureIdleOutput(id, pid, proj) {
225
+ idleCaptureTimers.delete(id);
87
226
  if (!projects.has(pid)) return;
88
227
  if (proj.status.get(id)) return;
89
228
  const w = proj.workers.get(id);
@@ -94,9 +233,9 @@ function captureIdleOutput(id, pid, proj) {
94
233
  const prev = proj.lastOutput.get(id);
95
234
  const isNew = !prev || prev.outputId !== oid;
96
235
  proj.lastOutput.set(id, { text: out, capturedAt: Date.now(), outputId: oid });
97
- appendKB(pid, { from: w.role, msg: out.slice(0, 4000), outputId: oid });
98
- // Clear waitingOn when the awaited role delivers new output
99
- if (isNew && proj.waitingOn?.toLowerCase() === w.role.toLowerCase()) {
236
+ appendKB(pid, { from: w.label, msg: out.slice(0, 4000), outputId: oid });
237
+ // Clear waitingOn when the awaited worker delivers new output
238
+ if (isNew && proj.waitingOn?.toLowerCase() === w.label.toLowerCase()) {
100
239
  proj.waitingOn = null;
101
240
  proj.staleSince = null;
102
241
  }
@@ -114,6 +253,19 @@ function captureIdleOutput(id, pid, proj) {
114
253
  }
115
254
  }
116
255
 
256
+ function clearIdleCaptureTimer(id) {
257
+ const timer = idleCaptureTimers.get(id);
258
+ if (timer) {
259
+ clearTimeout(timer);
260
+ idleCaptureTimers.delete(id);
261
+ }
262
+ }
263
+
264
+ function scheduleIdleCapture(id, pid, proj) {
265
+ clearIdleCaptureTimer(id);
266
+ idleCaptureTimers.set(id, setTimeout(() => captureIdleOutput(id, pid, proj), 5000));
267
+ }
268
+
117
269
  function resetProjectState(pid) {
118
270
  try { unlinkSync(kbPath(pid)); } catch {}
119
271
  }
@@ -143,51 +295,187 @@ function projectFor(sid) {
143
295
  for (const [pid, proj] of projects) {
144
296
  if (proj.workers.has(sid)) return [pid, proj];
145
297
  }
146
- // Auto-discover: session may belong to a project with active autopilot
147
- if (!projects.size) return [null, null];
148
- const sess = api.getSessions().find(s => s.id === sid);
149
- if (!sess?.projectId || !sess.roleName || !projects.has(sess.projectId)) return [null, null];
150
- const pid = sess.projectId;
151
- const proj = projects.get(pid);
152
- const err = refreshWorkers(pid, proj);
153
- if (err) {
154
- api.sendToFrontend('notify', { projectId: pid, reason: `${err} — autopilot stopped` });
155
- stop(pid);
156
- return [null, null];
157
- }
158
- return proj.workers.has(sid) ? [pid, proj] : [null, null];
298
+ return [null, null];
159
299
  }
160
300
 
161
- function workerByRole(proj, role) {
301
+ function workerByLabel(proj, label) {
162
302
  for (const [sid, w] of proj.workers) {
163
- if (w.role.toLowerCase() === role.toLowerCase()) return sid;
303
+ if (w.label.toLowerCase() === String(label || '').toLowerCase()) return sid;
164
304
  }
165
305
  return null;
166
306
  }
167
307
 
168
308
  function isAutopilotWorkerSession(s) {
169
- return s.projectId && s.roleName && s.presetId !== 'shell';
309
+ return s.projectId && s.presetId !== 'shell';
310
+ }
311
+
312
+ async function inferWorkerRole(pid, session, label, goal, model, provider, apiKey) {
313
+ const messages = firstUserMessages(session.id, FIRST_USER_MESSAGES);
314
+ if (!messages.length) {
315
+ return {
316
+ error: unpromptedSessionsMessage([session]),
317
+ };
318
+ }
319
+
320
+ const context = messages.map((text, idx) => {
321
+ const clipped = text.length > MAX_GOAL_MESSAGE_CHARS ? text.slice(0, MAX_GOAL_MESSAGE_CHARS) : text;
322
+ return `Message ${idx + 1}:\n${clipped}`;
323
+ }).join('\n\n');
324
+
325
+ const prompt = [
326
+ 'Hi,',
327
+ 'Your task is to infer a role name and a concise summary of that role\'s responsibilities based on what the user has said so far to the agent. This is a crucial step for Autopilot to understand what this agent session is about and route work correctly.',
328
+ '',
329
+ 'This is the project goal:',
330
+ '<project_goal>',
331
+ goalText(goal) || 'No project goal available.',
332
+ '</project_goal>',
333
+ '',
334
+ 'Read the project goal and the user\'s messages carefully and understand the context of the project and the agent\'s role in it.',
335
+ 'This role will be used by Autopilot to understand what this session is for and route work correctly.',
336
+ '',
337
+ 'ROLE SCOPE RULES',
338
+ '- Do not write a plan. Do not explain your reasoning.',
339
+ '- Infer what this agent session is mainly responsible for in the project, its main focus, responsibilities, and what it should do or not do.',
340
+ '- The project can be anything: software, research, writing, design, operations, brainstorming, etc.',
341
+ '- If the role is unclear from the user\'s messages, return {"role":"unclear_role","summary":"unclear_role"} only.',
342
+ '',
343
+ 'FORMAT RULES',
344
+ '- Return JSON only',
345
+ '- Use exactly this shape: {"role":"...","summary":"..."}',
346
+ '- "role" is short, like a job title or a one- or two-word label for the agent\'s main focus. e.g. "backend developer", "UX researcher", "marketing strategist", "data analyst", "project manager", "content writer", etc.',
347
+ '- "summary" must be a short paragraph, usually 2-5 sentences',
348
+ '- "summary" must explain what the agent is responsible for, what it should do, and what it should not do in this project, based on the user\'s messages and the project goal.',
349
+ '',
350
+ 'EXAMPLES',
351
+ 'Example 1 input:',
352
+ 'You are the docs writer for this project.',
353
+ 'Your job is to write the documentation the project needs, such as the README, setup guides, usage docs, and other project documents.',
354
+ 'First, review the codebase, structure, and existing docs so your writing matches the real project. Do not invent behavior or features.',
355
+ 'Write clear, accurate, concise, well-structured docs with a professional and consistent tone and style.',
356
+ 'You do not write code unless documentation examples are needed.',
357
+ '',
358
+ 'Example 1 output:',
359
+ '{"role":"Documentation writer","summary":"This agent is the Documentation Writer. Its role is to create the project documentation needed to help users and contributors understand, use, and work with the project clearly and correctly. It should review the codebase, structure, and existing docs before writing so the documentation matches the real project state, and it should produce clear, accurate, concise, and well-structured documents in the requested tone and style. It should not invent features or behavior, and it should not write code except when documentation examples are necessary."}',
360
+ '',
361
+ 'Example 2 input:',
362
+ 'You are the Product Manager for this project.',
363
+ 'Your job is to understand the product deeply: what it does, why it exists, who it serves, and what the best user experience should be.',
364
+ 'You focus on product quality above all else. Technical limitations, implementation preferences, and engineering convenience are secondary.',
365
+ 'You never write code.',
366
+ 'First, review the existing project if there is one. Read the codebase, documentation, and README to understand what the product is, why it was built, and what good execution should look like.',
367
+ '',
368
+ 'Example 2 output:',
369
+ '{"role":"Product manager","summary":"This agent is the Product Manager. Its role is to understand the product end to end, define what best-in-class user experience looks like, and guide the team toward polished, high-quality outcomes with strong UI/UX standards. It should review the existing project to understand the product, its purpose, and its users, and it should evaluate decisions through the lens of user value, clarity, trust, and quality. It should not write code or optimize for engineering convenience over product quality."}',
370
+ '',
371
+ 'SESSION',
372
+ `Label: ${label}`,
373
+ `Name: ${sessionDisplayName(session)}`,
374
+ '',
375
+ 'SOURCE CONTEXT',
376
+ context,
377
+ ].join('\n');
378
+
379
+ const res = await (await ai()).complete(model, {
380
+ systemPrompt: 'You infer concise neutral worker roles for workflow routing. Output JSON only.',
381
+ messages: [{ role: 'user', content: prompt, timestamp: Date.now() }],
382
+ }, { apiKey, reasoning: 'minimal' });
383
+
384
+ addTokens(pid, res.usage);
385
+ const raw = res.content?.find(b => b.type === 'text')?.text?.trim() || '';
386
+ const parsed = parseJsonObject(raw);
387
+ const role = String(parsed?.role || '').replace(/\s+/g, ' ').trim();
388
+ const summary = String(parsed?.summary || '').replace(/\s+/g, ' ').trim();
389
+ const unclear = role.toLowerCase() === 'unclear_role' || summary.toLowerCase() === 'unclear_role';
390
+ if (!parsed || unclear || !role || !summary) {
391
+ return {
392
+ unclear: true,
393
+ usage: res.usage,
394
+ notifyReason: `Autopilot could not infer a clear role for session "${label}". Add one or more user messages that make its mission clear, then start Autopilot again.`,
395
+ raw,
396
+ context,
397
+ };
398
+ }
399
+
400
+ return {
401
+ role,
402
+ summary,
403
+ usage: res.usage,
404
+ raw,
405
+ context,
406
+ };
170
407
  }
171
408
 
172
- function discoverWorkers(pid) {
409
+ function bootstrapWorkers(pid) {
410
+ const sessions = candidateSessions(pid);
411
+ if (!sessions.length) return { error: 'No project sessions found for Autopilot' };
412
+
413
+ const empty = sessions.filter(s => !hasUserMessage(s.id));
414
+ if (empty.length) {
415
+ return {
416
+ error: unpromptedSessionsMessage(empty),
417
+ };
418
+ }
419
+
173
420
  const workers = new Map();
174
421
  const status = new Map();
175
- for (const s of api.getSessions().filter(s => s.projectId === pid && isAutopilotWorkerSession(s))) {
176
- workers.set(s.id, { role: s.roleName, name: s.name, presetId: s.presetId });
177
- // Sessions start idle. Status is tracked by notifyStatus() on every
178
- // working/idle transition. If s.working is undefined, no transition was
179
- // ever recorded — the session has been idle since creation.
180
- status.set(s.id, s.working === true);
422
+ const takenLabels = new Set();
423
+
424
+ for (const session of sessions) {
425
+ const label = buildRouteLabel(session, takenLabels);
426
+ takenLabels.add(label.toLowerCase());
427
+ workers.set(session.id, {
428
+ name: sessionDisplayName(session),
429
+ label,
430
+ role: null,
431
+ summary: null,
432
+ presetId: session.presetId,
433
+ });
434
+ status.set(session.id, session.working === true);
181
435
  }
182
436
  return { workers, status };
183
437
  }
184
438
 
439
+ async function inferRolesForWorkers(pid, proj, model, provider, apiKey) {
440
+ const roleUsage = { input: 0, output: 0 };
441
+ for (const [sid, worker] of proj.workers) {
442
+ const session = api.getSessions().find(s => s.id === sid);
443
+ if (!session) continue;
444
+ const inferred = await inferWorkerRole(pid, session, worker.label, proj.goal, model, provider, apiKey);
445
+ roleUsage.input += inferred.usage?.input || 0;
446
+ roleUsage.output += inferred.usage?.output || 0;
447
+ if (inferred.error) return { error: inferred.error, usage: roleUsage };
448
+ if (inferred.unclear) return { unclear: true, notifyReason: inferred.notifyReason, usage: roleUsage };
449
+ worker.role = inferred.role;
450
+ worker.summary = inferred.summary;
451
+ appendKB(pid, { type: 'worker-role', worker: worker.label, role: inferred.role, summary: inferred.summary });
452
+ api.appendPillLog(pillId(pid), `Role: ${worker.label} → ${inferred.role}`);
453
+ }
454
+
455
+ debugLog(pid, 'roles', [
456
+ `# Worker Roles — ${new Date().toISOString()}`,
457
+ `Provider: ${provider}`,
458
+ `Project goal: ${goalText(proj.goal) || '(none)'}`,
459
+ `Tokens: in=${roleUsage.input} out=${roleUsage.output} total=${roleUsage.input + roleUsage.output}`,
460
+ '',
461
+ ...[...proj.workers.values()].map(w => `- ${w.label}: ${w.role} — ${w.summary}`),
462
+ ].join('\n'));
463
+
464
+ return { usage: roleUsage };
465
+ }
466
+
185
467
  // Returns null on success, or an error string (caller must stop autopilot).
186
- function refreshWorkers(pid, proj) {
468
+ async function refreshWorkers(pid, proj, model, provider, apiKey) {
469
+ const liveSessions = candidateSessions(pid);
187
470
  const live = new Map();
188
471
  const liveStatus = new Map();
189
- for (const s of api.getSessions().filter(s => s.projectId === pid && isAutopilotWorkerSession(s))) {
190
- live.set(s.id, { role: s.roleName, name: s.name, presetId: s.presetId });
472
+ const empty = liveSessions.filter(s => !hasUserMessage(s.id));
473
+ if (empty.length) {
474
+ return unpromptedSessionsMessage(empty);
475
+ }
476
+
477
+ for (const s of liveSessions) {
478
+ live.set(s.id, s);
191
479
  liveStatus.set(s.id, s.working === true);
192
480
  }
193
481
  // Remove dead sessions
@@ -200,21 +488,38 @@ function refreshWorkers(pid, proj) {
200
488
  }
201
489
  }
202
490
  // Add new sessions
203
- for (const [sid, w] of live) {
491
+ const takenLabels = new Set([...proj.workers.values()].map(w => w.label.toLowerCase()));
492
+ for (const [sid, session] of live) {
204
493
  if (!proj.workers.has(sid)) {
205
- const roleKey = w.role.toLowerCase();
206
- if ([...proj.workers.values()].some(x => x.role.toLowerCase() === roleKey)) {
207
- return `Duplicate role "${w.role}"`;
208
- }
209
- proj.workers.set(sid, w);
494
+ const label = buildRouteLabel(session, takenLabels);
495
+ takenLabels.add(label.toLowerCase());
496
+ const inferred = await inferWorkerRole(pid, session, label, proj.goal, model, provider, apiKey);
497
+ if (inferred.error) return inferred.error;
498
+ if (inferred.unclear) return inferred.notifyReason;
499
+ proj.workers.set(sid, {
500
+ name: sessionDisplayName(session),
501
+ label,
502
+ role: inferred.role,
503
+ summary: inferred.summary,
504
+ presetId: session.presetId,
505
+ });
210
506
  proj.status.set(sid, liveStatus.get(sid));
507
+ appendKB(pid, { type: 'worker-role', worker: label, role: inferred.role, summary: inferred.summary, added: true });
211
508
  api.setAutoApproveMenu(sid, true);
212
509
  if (!liveStatus.get(sid)) {
213
510
  const text = latestAgentOutput(sid);
214
511
  if (text) proj.lastOutput.set(sid, { text, capturedAt: Date.now(), outputId: outputId(text) });
215
512
  }
513
+ const pillId = `autopilot-${pid}`;
514
+ api.appendPillLog(pillId, `Role: ${label} → ${inferred.role}`);
216
515
  }
217
516
  }
517
+ for (const [sid, session] of live) {
518
+ const w = proj.workers.get(sid);
519
+ if (!w) continue;
520
+ w.name = sessionDisplayName(session);
521
+ proj.status.set(sid, liveStatus.get(sid));
522
+ }
218
523
  return null;
219
524
  }
220
525
 
@@ -244,6 +549,72 @@ function filterModels(all) {
244
549
  .map(m => ({ value: m.id, label: `${m.name.replace(/\s*\(latest\)$/, '')} ($${m.cost.input}/M)` }));
245
550
  }
246
551
 
552
+ function goalText(goal) {
553
+ return String(goal?.text || '').trim();
554
+ }
555
+
556
+ async function buildGoal(pid, proj, model, provider, apiKey) {
557
+ const sourceContext = goalSourceContext(proj);
558
+ if (!sourceContext) return { error: 'No early user messages found to build project goal' };
559
+
560
+ const prompt = [
561
+ 'Hi, You are writing a concise project goal based on early user-side messages.',
562
+ 'This goal will be used to guide the project and keep it focused on the main objectives.',
563
+ '',
564
+ 'PROJECT GOAL SCOPE RULES',
565
+ '- Use only the early user-side messages below.',
566
+ '- Do not write a plan. Do not write steps. Do not explain your reasoning.',
567
+ '- Write one short paragraph that captures the project mission/task/goal.',
568
+ '- The project can be anything: a software project, a research project, a writing project, etc. Do not assume it\'s coding work.',
569
+ '- If the goal is unclear, return "unclear_goal" only.',
570
+ '',
571
+ 'FORMAT RULES',
572
+ '- One paragraph only',
573
+ '- Plain text only',
574
+ '- Keep it concise and easy to scan later',
575
+ '- Generic wording: do not assume this is coding work',
576
+ '',
577
+ 'SOURCE CONTEXT',
578
+ sourceContext,
579
+ ].join('\n');
580
+
581
+ const res = await (await ai()).complete(model, {
582
+ systemPrompt: 'You write concise, neutral project-goal summaries for workflow routing.',
583
+ messages: [{ role: 'user', content: prompt, timestamp: Date.now() }],
584
+ }, { apiKey, reasoning: 'minimal' });
585
+
586
+ addTokens(pid, res.usage);
587
+ const text = res.content?.find(b => b.type === 'text')?.text?.trim() || '';
588
+ if (!text) return { error: 'Model returned an empty project goal' };
589
+ if (isUnclearGoal(text)) {
590
+ return {
591
+ unclear: true,
592
+ usage: res.usage,
593
+ notifyReason: `Autopilot could not infer a clear project goal. Add a message starting with \`${GOAL_PREFIX}\` in any existing project worker session, then start Autopilot again.`,
594
+ };
595
+ }
596
+
597
+ const goal = {
598
+ text: text.replace(/\s+/g, ' ').trim(),
599
+ builtAt: new Date().toISOString(),
600
+ source: 'model',
601
+ };
602
+ saveGoal(pid, goal);
603
+ appendKB(pid, { type: 'goal', goal: goal.text, source: goal.source, usage: usageTotals(res.usage) });
604
+ debugLog(pid, 'goal', [
605
+ `# Project Goal — ${goal.builtAt}`,
606
+ `Provider: ${provider}`,
607
+ `Tokens: in=${res.usage?.input || 0} out=${res.usage?.output || 0} total=${(res.usage?.input || 0) + (res.usage?.output || 0)}`,
608
+ '',
609
+ '## Source Context',
610
+ sourceContext,
611
+ '',
612
+ '## Goal',
613
+ goal.text,
614
+ ].join('\n'));
615
+ return { goal, usage: res.usage };
616
+ }
617
+
247
618
  async function loadModelsForProvider(provider) {
248
619
  try {
249
620
  const m = await ai();
@@ -274,8 +645,8 @@ function buildTools(Type) {
274
645
  name: 'route',
275
646
  description: 'Forward one agent\'s output to another idle agent. The system copies the output verbatim — you only choose who sends and who receives.',
276
647
  parameters: Type.Object({
277
- from: Type.String({ description: 'Source agent role name (prefer [LATEST] agent)' }),
278
- to: Type.String({ description: 'Target agent role name (must be IDLE)' }),
648
+ from: Type.String({ description: 'Source agent label (prefer [LATEST] agent)' }),
649
+ to: Type.String({ description: 'Target agent label (must be IDLE)' }),
279
650
  }),
280
651
  },
281
652
  {
@@ -297,13 +668,10 @@ function buildPrompt(proj, pid) {
297
668
  const p = pList.find(x => x.id === pid);
298
669
  const projectName = p ? `${p.name}${p.path ? ` (${p.path})` : ''}` : pid;
299
670
 
300
- const roles = api.getRoles();
301
671
  const agentProfiles = [];
302
672
  for (const [sid, w] of proj.workers) {
303
673
  const busy = proj.status.get(sid);
304
- const role = roles.find(r => r.name === w.role);
305
- const desc = role?.instructions || '(no role description)';
306
- agentProfiles.push(`${w.role} [${busy ? 'WORKING' : 'IDLE'}]\n Role: ${desc}`);
674
+ agentProfiles.push(`${w.label} [${busy ? 'WORKING' : 'IDLE'}]\n Inferred role: ${w.role}\n Summary: ${w.summary}`);
307
675
  }
308
676
 
309
677
  let prompt = PROMPT_TEMPLATE
@@ -322,7 +690,7 @@ function buildStateContext(proj, pid) {
322
690
  return `${Math.round(sec / 60)}m ago`;
323
691
  };
324
692
 
325
- const lines = ['CURRENT STATE'];
693
+ const lines = ['PROJECT GOAL', ` ${goalText(proj.goal) || 'Goal not set'}`, '', 'CURRENT STATE'];
326
694
 
327
695
  // Per-worker state
328
696
  const lastActionAt = proj.lastAction?.at || 0;
@@ -332,7 +700,7 @@ function buildStateContext(proj, pid) {
332
700
  const capturedAt = stored?.capturedAt || 0;
333
701
  const isNew = capturedAt > lastActionAt;
334
702
  const status = proj.status.get(sid) ? 'WORKING' : 'IDLE';
335
- let line = ` ${w.role}: ${status}`;
703
+ let line = ` ${w.label}: ${status} | role: ${w.role}`;
336
704
  if (oid) line += ` | output #${oid} captured ${fmtAgo(capturedAt)}${isNew ? ' (NEW)' : ''}`;
337
705
  else line += ' | no output';
338
706
  lines.push(line);
@@ -385,34 +753,23 @@ function buildStateContext(proj, pid) {
385
753
 
386
754
  async function consult(pid, proj) {
387
755
  if (proj.pending || proj.paused || !projects.has(pid)) return;
388
- const pillId = `autopilot-${pid}`;
389
- const refreshErr = refreshWorkers(pid, proj);
390
- if (refreshErr) {
391
- api.sendToFrontend('notify', { projectId: pid, reason: `${refreshErr} — autopilot stopped` });
392
- stop(pid);
393
- return;
394
- }
395
- // Re-check all-idle after refresh — new workers may be busy
396
- if (![...proj.workers.keys()].every(sid => !proj.status.get(sid))) return;
397
-
756
+ const runPillId = pillId(pid);
757
+ const provider = api.getSetting('provider') || 'anthropic';
758
+ const modelId = api.getSetting('model') || 'claude-opus-4-6';
398
759
  let m;
399
760
  try { m = await ai(); } catch (e) {
400
761
  api.sendToFrontend('notify', { projectId: pid, reason: `Failed to load AI library: ${e.message}` });
401
762
  stop(pid);
402
763
  return;
403
764
  }
404
-
405
- const provider = api.getSetting('provider') || 'anthropic';
406
- const modelId = api.getSetting('model') || 'claude-opus-4-6';
407
765
  const apiKey = api.getSetting('apiKey') || m.getEnvApiKey(provider) || '';
408
-
409
766
  if (!apiKey) {
410
767
  proj.paused = true;
411
768
  proj.pauseReason = 'config';
412
769
  api.sendToFrontend('error', { msg: `No API key for ${provider}. Set in Autopilot settings or via env var.` });
413
770
  api.sendToFrontend('paused', { projectId: pid, question: 'API key missing — configure in plugin settings' });
414
- api.updateSessionPill(pillId, { working: false, statusText: 'Paused — no API key' });
415
- api.appendPillLog(pillId, 'Paused: API key missing');
771
+ api.updateSessionPill(runPillId, { working: false, statusText: 'Paused — no API key' });
772
+ api.appendPillLog(runPillId, 'Paused: API key missing');
416
773
  return;
417
774
  }
418
775
 
@@ -423,6 +780,15 @@ async function consult(pid, proj) {
423
780
  return;
424
781
  }
425
782
 
783
+ const refreshErr = await refreshWorkers(pid, proj, model, provider, apiKey);
784
+ if (refreshErr) {
785
+ api.sendToFrontend('notify', { projectId: pid, reason: `${refreshErr} — autopilot stopped` });
786
+ stop(pid);
787
+ return;
788
+ }
789
+ // Re-check all-idle after refresh — new workers may be busy
790
+ if (![...proj.workers.keys()].every(sid => !proj.status.get(sid))) return;
791
+
426
792
  // Build structured state context
427
793
  const stateContext = buildStateContext(proj, pid);
428
794
 
@@ -438,7 +804,7 @@ async function consult(pid, proj) {
438
804
  text = latestAgentOutput(sid);
439
805
  if (text) { capturedAt = capturedAt || Date.now(); oid = outputId(text); }
440
806
  }
441
- entries.push({ sid, role: w.role, text, capturedAt, outputId: oid });
807
+ entries.push({ sid, label: w.label, role: w.role, text, capturedAt, outputId: oid });
442
808
  }
443
809
  entries.sort((a, b) => a.capturedAt - b.capturedAt);
444
810
 
@@ -446,7 +812,7 @@ async function consult(pid, proj) {
446
812
  const isNew = e.capturedAt > lastActionAt;
447
813
  const tag = isNew ? ' — NEW' : '';
448
814
  const idTag = e.outputId ? ` (#${e.outputId})` : '';
449
- return `[${e.role}${tag}${idTag}]:\n${e.text ? e.text.slice(0, 2000) : '(no output captured)'}`;
815
+ return `[${e.label}${tag}${idTag} | ${e.role}]:\n${e.text ? e.text.slice(0, 2000) : '(no output captured)'}`;
450
816
  });
451
817
 
452
818
  const sections = [stateContext, ''];
@@ -479,8 +845,8 @@ async function consult(pid, proj) {
479
845
  ctx.messages[0].content,
480
846
  ].join('\n'));
481
847
 
482
- api.updateSessionPill(pillId, { working: true, statusText: 'Consulting router...' });
483
- api.appendPillLog(pillId, `Consulting ${modelId}`);
848
+ api.updateSessionPill(runPillId, { working: true, statusText: 'Consulting router...' });
849
+ api.appendPillLog(runPillId, `Consulting ${modelId}`);
484
850
  // api.log(`[consult] model=${modelId} workers=${[...proj.workers.values()].map(w => w.role).join(',')}`);
485
851
  // api.log(`[consult] hasOutput=${[...proj.workers].filter(([sid]) => proj.lastOutput.has(sid)).map(([,w]) => w.role).join(',') || 'none'}`);
486
852
 
@@ -515,7 +881,7 @@ async function consult(pid, proj) {
515
881
  continue;
516
882
  }
517
883
 
518
- const error = executeAction(pid, proj, tc.name, tc.arguments, pillId);
884
+ const error = executeAction(pid, proj, tc.name, tc.arguments, runPillId);
519
885
  if (error === null) { proj.pending = false; return; }
520
886
 
521
887
  // api.log(`[consult] action error — hinting: ${error}`);
@@ -544,12 +910,13 @@ function triggerConsult(pid, proj) {
544
910
  // --- Action execution (returns error string or null on success) ---
545
911
 
546
912
  function executeAction(pid, proj, action, args, pillId) {
547
- const roles = [...proj.workers.values()].map(w => w.role);
913
+ const labels = [...proj.workers.values()].map(w => w.label);
548
914
  switch (action) {
549
915
  case 'route': {
550
- const src = workerByRole(proj, args.from);
551
- const dst = workerByRole(proj, args.to);
552
- if (!dst) return `No agent with role "${args.to}". Available roles: ${roles.join(', ')}`;
916
+ const src = workerByLabel(proj, args.from);
917
+ const dst = workerByLabel(proj, args.to);
918
+ if (!src) return `No agent with label "${args.from}". Available agents: ${labels.join(', ')}`;
919
+ if (!dst) return `No agent with label "${args.to}". Available agents: ${labels.join(', ')}`;
553
920
  if (src === dst) return 'Cannot route agent to itself';
554
921
  if (proj.status.get(dst)) return `"${args.to}" is currently working — pick an idle agent`;
555
922
  const stored = src ? proj.lastOutput.get(src) : null;
@@ -570,11 +937,12 @@ function executeAction(pid, proj, action, args, pillId) {
570
937
  }
571
938
  }
572
939
 
573
- const dstRole = proj.workers.get(dst)?.role || args.to;
940
+ const dstWorker = proj.workers.get(dst);
574
941
  const header = [
575
942
  `[Autopilot route${oid ? ` | output #${oid}` : ''}]`,
576
- `[Team: ${roles.join(', ')}]`,
577
- `[You are: ${dstRole}]`,
943
+ `[Team: ${labels.join(', ')}]`,
944
+ `[Target session: ${dstWorker?.label || args.to}]`,
945
+ `[Target inferred role: ${dstWorker?.role || 'unknown'}]`,
578
946
  `[From: ${args.from}]`,
579
947
  '[Do not spawn internal agents.]',
580
948
  ].join('\n');
@@ -618,26 +986,26 @@ async function start(pid) {
618
986
  if (!enabled()) return { error: 'Autopilot disabled' };
619
987
 
620
988
  const provider = api.getSetting('provider') || 'anthropic';
621
- const apiKey = api.getSetting('apiKey') || (await ai()).getEnvApiKey(provider) || '';
989
+ const m = await ai();
990
+ const apiKey = api.getSetting('apiKey') || m.getEnvApiKey(provider) || '';
622
991
  if (!apiKey) return { error: 'Set the API key in Autopilot settings (Plugins panel)' };
623
-
624
- const { workers, status } = discoverWorkers(pid);
625
- if (workers.size < 1) return { error: 'No agents with roles in this project' };
626
-
627
- const roles = new Set();
628
- for (const [, w] of workers) {
629
- const key = w.role.toLowerCase();
630
- if (roles.has(key)) return { error: `Duplicate role "${w.role}"` };
631
- roles.add(key);
632
- }
992
+ const modelId = api.getSetting('model') || 'claude-opus-4-6';
993
+ let model;
994
+ try { model = m.getModel(provider, modelId); } catch { return { error: `Model "${modelId}" not found` }; }
633
995
 
634
996
  resetProjectState(pid);
635
997
  resetDebugLogs(pid);
636
998
 
999
+ const seeded = bootstrapWorkers(pid);
1000
+ if (seeded.error) return { error: seeded.error };
1001
+ const { workers, status } = seeded;
1002
+ if (workers.size < 1) return { error: 'No project sessions found for Autopilot' };
1003
+
637
1004
  const proj = {
638
1005
  workers,
639
1006
  status,
640
1007
  lastOutput: new Map(),
1008
+ goal: null,
641
1009
  paused: false,
642
1010
  pauseReason: null,
643
1011
  pending: false,
@@ -647,6 +1015,10 @@ async function start(pid) {
647
1015
  };
648
1016
  projects.set(pid, proj);
649
1017
 
1018
+ const runPillId = pillId(pid);
1019
+ api.addSessionPill({ id: runPillId, title: 'Autopilot', projectId: pid });
1020
+ api.appendPillLog(runPillId, `Started with ${workers.size} workers: ${[...workers.values()].map(w => w.label).join(', ')}`);
1021
+
650
1022
  // Flag all workers for core menu auto-approve
651
1023
  for (const [sid] of workers) api.setAutoApproveMenu(sid, true);
652
1024
 
@@ -659,9 +1031,75 @@ async function start(pid) {
659
1031
 
660
1032
  // api.log(`Started: ${pid}, ${workers.size} workers`);
661
1033
  api.sendToFrontend('started', { projectId: pid });
662
- const pillId = `autopilot-${pid}`;
663
- api.addSessionPill({ id: pillId, title: 'Autopilot', projectId: pid });
664
- api.appendPillLog(pillId, `Started with ${workers.size} workers: ${[...workers.values()].map(w => w.role).join(', ')}`);
1034
+
1035
+ const explicit = findExplicitGoal(proj);
1036
+ if (explicit.error) {
1037
+ api.updateSessionPill(runPillId, { working: false, statusText: 'Goal conflict' });
1038
+ api.appendPillLog(runPillId, `Notify: ${explicit.error}`);
1039
+ api.sendToFrontend('notify', { projectId: pid, reason: explicit.error });
1040
+ stop(pid, true, 'Stopped');
1041
+ return { error: explicit.error };
1042
+ }
1043
+
1044
+ if (explicit.goal) {
1045
+ proj.goal = explicit.goal;
1046
+ saveGoal(pid, explicit.goal);
1047
+ appendKB(pid, { type: 'goal', goal: explicit.goal.text, source: explicit.goal.source });
1048
+ api.updateSessionPill(runPillId, { working: false, statusText: 'Project goal ready' });
1049
+ api.appendPillLog(runPillId, `Goal: ${explicit.goal.text}`);
1050
+ api.appendPillLog(runPillId, formatTokenLog('Goal', null, 'explicit message'));
1051
+ } else {
1052
+ const savedGoal = loadGoal(pid);
1053
+ if (savedGoal) {
1054
+ proj.goal = savedGoal;
1055
+ appendKB(pid, { type: 'goal', goal: savedGoal.text, source: savedGoal.source || 'saved', loaded: true });
1056
+ api.updateSessionPill(runPillId, { working: false, statusText: 'Project goal ready' });
1057
+ api.appendPillLog(runPillId, `Goal: ${savedGoal.text}`);
1058
+ } else {
1059
+ proj.pending = true;
1060
+ api.updateSessionPill(runPillId, { working: true, statusText: 'Building project goal...' });
1061
+ api.appendPillLog(runPillId, 'Building project goal');
1062
+ const built = await buildGoal(pid, proj, model, provider, apiKey);
1063
+ proj.pending = false;
1064
+ if (built.error) {
1065
+ api.sendToFrontend('notify', { projectId: pid, reason: `${built.error} — autopilot stopped` });
1066
+ stop(pid, true, 'Stopped');
1067
+ return { error: built.error };
1068
+ }
1069
+ if (built.unclear) {
1070
+ api.updateSessionPill(runPillId, { working: false, statusText: 'Goal required' });
1071
+ api.appendPillLog(runPillId, 'Goal: unclear_goal');
1072
+ api.appendPillLog(runPillId, formatTokenLog('Goal', built.usage, 'goal build'));
1073
+ api.appendPillLog(runPillId, `Notify: ${built.notifyReason}`);
1074
+ api.sendToFrontend('notify', { projectId: pid, reason: built.notifyReason });
1075
+ stop(pid, true, 'Goal required');
1076
+ return { error: 'unclear_goal' };
1077
+ }
1078
+ proj.goal = built.goal;
1079
+ api.updateSessionPill(runPillId, { working: false, statusText: 'Project goal ready' });
1080
+ api.appendPillLog(runPillId, `Goal: ${built.goal.text}`);
1081
+ api.appendPillLog(runPillId, formatTokenLog('Goal', built.usage, 'goal build'));
1082
+ }
1083
+ }
1084
+
1085
+ api.updateSessionPill(runPillId, { working: true, statusText: 'Inferring agent roles...' });
1086
+ api.appendPillLog(runPillId, 'Inferring agent roles');
1087
+ const roleResult = await inferRolesForWorkers(pid, proj, model, provider, apiKey);
1088
+ if (roleResult.error) {
1089
+ api.sendToFrontend('notify', { projectId: pid, reason: `${roleResult.error} — autopilot stopped` });
1090
+ stop(pid, true, 'Stopped');
1091
+ return { error: roleResult.error };
1092
+ }
1093
+ if (roleResult.unclear) {
1094
+ api.updateSessionPill(runPillId, { working: false, statusText: 'Role required' });
1095
+ api.appendPillLog(runPillId, `Notify: ${roleResult.notifyReason}`);
1096
+ api.appendPillLog(runPillId, formatTokenLog('Role', roleResult.usage, 'role inference'));
1097
+ api.sendToFrontend('notify', { projectId: pid, reason: roleResult.notifyReason });
1098
+ stop(pid, true, 'Role required');
1099
+ return { error: 'unclear_role' };
1100
+ }
1101
+ api.appendPillLog(runPillId, formatTokenLog('Role', roleResult.usage, 'role inference'));
1102
+ api.updateSessionPill(runPillId, { working: false, statusText: 'Ready' });
665
1103
 
666
1104
  // Only consult if all workers are already idle
667
1105
  const allIdle = [...workers.keys()].every(sid => !status.get(sid));
@@ -670,16 +1108,17 @@ async function start(pid) {
670
1108
  return { ok: true };
671
1109
  }
672
1110
 
673
- function stop(pid, keepPill) {
1111
+ function stop(pid, keepPill, finalLog) {
674
1112
  const proj = projects.get(pid);
675
1113
  if (!proj) return;
1114
+ for (const [sid] of proj.workers) clearIdleCaptureTimer(sid);
676
1115
  for (const [sid] of proj.workers) api.setAutoApproveMenu(sid, false);
677
1116
  projects.delete(pid);
678
1117
  // api.log(`Stopped: ${pid}`);
679
1118
  api.sendToFrontend('stopped', { projectId: pid });
680
1119
  const pillId = `autopilot-${pid}`;
681
1120
  if (keepPill) {
682
- api.appendPillLog(pillId, 'Completed');
1121
+ api.appendPillLog(pillId, finalLog || 'Completed');
683
1122
  } else {
684
1123
  api.appendPillLog(pillId, 'Stopped');
685
1124
  api.removeSessionPill(pillId);
@@ -714,7 +1153,10 @@ module.exports.init = function (pluginApi) {
714
1153
  api.onMenuDetected((id, choices) => {
715
1154
  if (!choices?.length) return;
716
1155
  const [pid] = projectFor(id);
717
- if (pid) menuPending.add(id);
1156
+ if (pid) {
1157
+ menuPending.add(id);
1158
+ clearIdleCaptureTimer(id);
1159
+ }
718
1160
  });
719
1161
 
720
1162
  // Status change — the main routing trigger (only when ALL workers are idle)
@@ -723,11 +1165,15 @@ module.exports.init = function (pluginApi) {
723
1165
  const [pid, proj] = projectFor(id);
724
1166
  if (!pid) return;
725
1167
  const w = proj.workers.get(id);
726
- const role = w?.role || id.slice(0, 8);
1168
+ const role = w?.label || id.slice(0, 8);
727
1169
 
728
- if (working) { menuPending.delete(id); }
1170
+ if (working) {
1171
+ menuPending.delete(id);
1172
+ clearIdleCaptureTimer(id);
1173
+ }
729
1174
 
730
1175
  if (!working && menuPending.has(id)) {
1176
+ clearIdleCaptureTimer(id);
731
1177
  menuPending.delete(id);
732
1178
  // api.log(`[status] ${role} → IDLE (menu pending — suppressed)`);
733
1179
  return;
@@ -744,7 +1190,7 @@ module.exports.init = function (pluginApi) {
744
1190
  if (!proj.workers.has(id)) return;
745
1191
 
746
1192
  api.appendPillLog(pillId, `${role} → idle`);
747
- setTimeout(() => captureIdleOutput(id, pid, proj), 5000);
1193
+ scheduleIdleCapture(id, pid, proj);
748
1194
  });
749
1195
 
750
1196
  // Frontend queries