clideck 1.30.2 → 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,6 +5,12 @@ 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
@@ -16,6 +22,7 @@ let piAi = null;
16
22
 
17
23
  function enabled() { return api.getSetting('enabled') !== false; }
18
24
  function safeId(id) { return String(id).replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64); }
25
+ function pillId(pid) { return `autopilot-${pid}`; }
19
26
 
20
27
  function outputId(text) {
21
28
  const normalized = (text || '').trim().replace(/\s+/g, ' ');
@@ -33,6 +40,7 @@ async function ai() {
33
40
  // --- KB (routing history) ---
34
41
 
35
42
  function kbPath(pid) { return join(DATA_DIR, `${safeId(pid)}.jsonl`); }
43
+ function goalPath(pid) { return join(GOALS_DIR, `${safeId(pid)}.json`); }
36
44
 
37
45
  function appendKB(pid, entry) {
38
46
  mkdirSync(DATA_DIR, { recursive: true });
@@ -49,6 +57,21 @@ function readKB(pid, n) {
49
57
  } catch { return []; }
50
58
  }
51
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
+
52
75
  // --- Token usage ---
53
76
 
54
77
  function usagePath() { return join(DATA_DIR, 'usage.json'); }
@@ -82,6 +105,120 @@ function latestAgentOutput(id) {
82
105
  return last?.text?.trim().slice(0, 8000) || null;
83
106
  }
84
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
+
85
222
  // --- Consumed state (persisted per-project: role → boolean) ---
86
223
 
87
224
  function captureIdleOutput(id, pid, proj) {
@@ -96,9 +233,9 @@ function captureIdleOutput(id, pid, proj) {
96
233
  const prev = proj.lastOutput.get(id);
97
234
  const isNew = !prev || prev.outputId !== oid;
98
235
  proj.lastOutput.set(id, { text: out, capturedAt: Date.now(), outputId: oid });
99
- appendKB(pid, { from: w.role, msg: out.slice(0, 4000), outputId: oid });
100
- // Clear waitingOn when the awaited role delivers new output
101
- 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()) {
102
239
  proj.waitingOn = null;
103
240
  proj.staleSince = null;
104
241
  }
@@ -158,51 +295,187 @@ function projectFor(sid) {
158
295
  for (const [pid, proj] of projects) {
159
296
  if (proj.workers.has(sid)) return [pid, proj];
160
297
  }
161
- // Auto-discover: session may belong to a project with active autopilot
162
- if (!projects.size) return [null, null];
163
- const sess = api.getSessions().find(s => s.id === sid);
164
- if (!sess?.projectId || !sess.roleName || !projects.has(sess.projectId)) return [null, null];
165
- const pid = sess.projectId;
166
- const proj = projects.get(pid);
167
- const err = refreshWorkers(pid, proj);
168
- if (err) {
169
- api.sendToFrontend('notify', { projectId: pid, reason: `${err} — autopilot stopped` });
170
- stop(pid);
171
- return [null, null];
172
- }
173
- return proj.workers.has(sid) ? [pid, proj] : [null, null];
298
+ return [null, null];
174
299
  }
175
300
 
176
- function workerByRole(proj, role) {
301
+ function workerByLabel(proj, label) {
177
302
  for (const [sid, w] of proj.workers) {
178
- if (w.role.toLowerCase() === role.toLowerCase()) return sid;
303
+ if (w.label.toLowerCase() === String(label || '').toLowerCase()) return sid;
179
304
  }
180
305
  return null;
181
306
  }
182
307
 
183
308
  function isAutopilotWorkerSession(s) {
184
- 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
+ };
185
407
  }
186
408
 
187
- 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
+
188
420
  const workers = new Map();
189
421
  const status = new Map();
190
- for (const s of api.getSessions().filter(s => s.projectId === pid && isAutopilotWorkerSession(s))) {
191
- workers.set(s.id, { role: s.roleName, name: s.name, presetId: s.presetId });
192
- // Sessions start idle. Status is tracked by notifyStatus() on every
193
- // working/idle transition. If s.working is undefined, no transition was
194
- // ever recorded — the session has been idle since creation.
195
- 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);
196
435
  }
197
436
  return { workers, status };
198
437
  }
199
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
+
200
467
  // Returns null on success, or an error string (caller must stop autopilot).
201
- function refreshWorkers(pid, proj) {
468
+ async function refreshWorkers(pid, proj, model, provider, apiKey) {
469
+ const liveSessions = candidateSessions(pid);
202
470
  const live = new Map();
203
471
  const liveStatus = new Map();
204
- for (const s of api.getSessions().filter(s => s.projectId === pid && isAutopilotWorkerSession(s))) {
205
- 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);
206
479
  liveStatus.set(s.id, s.working === true);
207
480
  }
208
481
  // Remove dead sessions
@@ -215,21 +488,38 @@ function refreshWorkers(pid, proj) {
215
488
  }
216
489
  }
217
490
  // Add new sessions
218
- 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) {
219
493
  if (!proj.workers.has(sid)) {
220
- const roleKey = w.role.toLowerCase();
221
- if ([...proj.workers.values()].some(x => x.role.toLowerCase() === roleKey)) {
222
- return `Duplicate role "${w.role}"`;
223
- }
224
- 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
+ });
225
506
  proj.status.set(sid, liveStatus.get(sid));
507
+ appendKB(pid, { type: 'worker-role', worker: label, role: inferred.role, summary: inferred.summary, added: true });
226
508
  api.setAutoApproveMenu(sid, true);
227
509
  if (!liveStatus.get(sid)) {
228
510
  const text = latestAgentOutput(sid);
229
511
  if (text) proj.lastOutput.set(sid, { text, capturedAt: Date.now(), outputId: outputId(text) });
230
512
  }
513
+ const pillId = `autopilot-${pid}`;
514
+ api.appendPillLog(pillId, `Role: ${label} → ${inferred.role}`);
231
515
  }
232
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
+ }
233
523
  return null;
234
524
  }
235
525
 
@@ -259,6 +549,72 @@ function filterModels(all) {
259
549
  .map(m => ({ value: m.id, label: `${m.name.replace(/\s*\(latest\)$/, '')} ($${m.cost.input}/M)` }));
260
550
  }
261
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
+
262
618
  async function loadModelsForProvider(provider) {
263
619
  try {
264
620
  const m = await ai();
@@ -289,8 +645,8 @@ function buildTools(Type) {
289
645
  name: 'route',
290
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.',
291
647
  parameters: Type.Object({
292
- from: Type.String({ description: 'Source agent role name (prefer [LATEST] agent)' }),
293
- 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)' }),
294
650
  }),
295
651
  },
296
652
  {
@@ -312,13 +668,10 @@ function buildPrompt(proj, pid) {
312
668
  const p = pList.find(x => x.id === pid);
313
669
  const projectName = p ? `${p.name}${p.path ? ` (${p.path})` : ''}` : pid;
314
670
 
315
- const roles = api.getRoles();
316
671
  const agentProfiles = [];
317
672
  for (const [sid, w] of proj.workers) {
318
673
  const busy = proj.status.get(sid);
319
- const role = roles.find(r => r.name === w.role);
320
- const desc = role?.instructions || '(no role description)';
321
- 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}`);
322
675
  }
323
676
 
324
677
  let prompt = PROMPT_TEMPLATE
@@ -337,7 +690,7 @@ function buildStateContext(proj, pid) {
337
690
  return `${Math.round(sec / 60)}m ago`;
338
691
  };
339
692
 
340
- const lines = ['CURRENT STATE'];
693
+ const lines = ['PROJECT GOAL', ` ${goalText(proj.goal) || 'Goal not set'}`, '', 'CURRENT STATE'];
341
694
 
342
695
  // Per-worker state
343
696
  const lastActionAt = proj.lastAction?.at || 0;
@@ -347,7 +700,7 @@ function buildStateContext(proj, pid) {
347
700
  const capturedAt = stored?.capturedAt || 0;
348
701
  const isNew = capturedAt > lastActionAt;
349
702
  const status = proj.status.get(sid) ? 'WORKING' : 'IDLE';
350
- let line = ` ${w.role}: ${status}`;
703
+ let line = ` ${w.label}: ${status} | role: ${w.role}`;
351
704
  if (oid) line += ` | output #${oid} captured ${fmtAgo(capturedAt)}${isNew ? ' (NEW)' : ''}`;
352
705
  else line += ' | no output';
353
706
  lines.push(line);
@@ -400,34 +753,23 @@ function buildStateContext(proj, pid) {
400
753
 
401
754
  async function consult(pid, proj) {
402
755
  if (proj.pending || proj.paused || !projects.has(pid)) return;
403
- const pillId = `autopilot-${pid}`;
404
- const refreshErr = refreshWorkers(pid, proj);
405
- if (refreshErr) {
406
- api.sendToFrontend('notify', { projectId: pid, reason: `${refreshErr} — autopilot stopped` });
407
- stop(pid);
408
- return;
409
- }
410
- // Re-check all-idle after refresh — new workers may be busy
411
- if (![...proj.workers.keys()].every(sid => !proj.status.get(sid))) return;
412
-
756
+ const runPillId = pillId(pid);
757
+ const provider = api.getSetting('provider') || 'anthropic';
758
+ const modelId = api.getSetting('model') || 'claude-opus-4-6';
413
759
  let m;
414
760
  try { m = await ai(); } catch (e) {
415
761
  api.sendToFrontend('notify', { projectId: pid, reason: `Failed to load AI library: ${e.message}` });
416
762
  stop(pid);
417
763
  return;
418
764
  }
419
-
420
- const provider = api.getSetting('provider') || 'anthropic';
421
- const modelId = api.getSetting('model') || 'claude-opus-4-6';
422
765
  const apiKey = api.getSetting('apiKey') || m.getEnvApiKey(provider) || '';
423
-
424
766
  if (!apiKey) {
425
767
  proj.paused = true;
426
768
  proj.pauseReason = 'config';
427
769
  api.sendToFrontend('error', { msg: `No API key for ${provider}. Set in Autopilot settings or via env var.` });
428
770
  api.sendToFrontend('paused', { projectId: pid, question: 'API key missing — configure in plugin settings' });
429
- api.updateSessionPill(pillId, { working: false, statusText: 'Paused — no API key' });
430
- 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');
431
773
  return;
432
774
  }
433
775
 
@@ -438,6 +780,15 @@ async function consult(pid, proj) {
438
780
  return;
439
781
  }
440
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
+
441
792
  // Build structured state context
442
793
  const stateContext = buildStateContext(proj, pid);
443
794
 
@@ -453,7 +804,7 @@ async function consult(pid, proj) {
453
804
  text = latestAgentOutput(sid);
454
805
  if (text) { capturedAt = capturedAt || Date.now(); oid = outputId(text); }
455
806
  }
456
- entries.push({ sid, role: w.role, text, capturedAt, outputId: oid });
807
+ entries.push({ sid, label: w.label, role: w.role, text, capturedAt, outputId: oid });
457
808
  }
458
809
  entries.sort((a, b) => a.capturedAt - b.capturedAt);
459
810
 
@@ -461,7 +812,7 @@ async function consult(pid, proj) {
461
812
  const isNew = e.capturedAt > lastActionAt;
462
813
  const tag = isNew ? ' — NEW' : '';
463
814
  const idTag = e.outputId ? ` (#${e.outputId})` : '';
464
- 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)'}`;
465
816
  });
466
817
 
467
818
  const sections = [stateContext, ''];
@@ -494,8 +845,8 @@ async function consult(pid, proj) {
494
845
  ctx.messages[0].content,
495
846
  ].join('\n'));
496
847
 
497
- api.updateSessionPill(pillId, { working: true, statusText: 'Consulting router...' });
498
- api.appendPillLog(pillId, `Consulting ${modelId}`);
848
+ api.updateSessionPill(runPillId, { working: true, statusText: 'Consulting router...' });
849
+ api.appendPillLog(runPillId, `Consulting ${modelId}`);
499
850
  // api.log(`[consult] model=${modelId} workers=${[...proj.workers.values()].map(w => w.role).join(',')}`);
500
851
  // api.log(`[consult] hasOutput=${[...proj.workers].filter(([sid]) => proj.lastOutput.has(sid)).map(([,w]) => w.role).join(',') || 'none'}`);
501
852
 
@@ -530,7 +881,7 @@ async function consult(pid, proj) {
530
881
  continue;
531
882
  }
532
883
 
533
- const error = executeAction(pid, proj, tc.name, tc.arguments, pillId);
884
+ const error = executeAction(pid, proj, tc.name, tc.arguments, runPillId);
534
885
  if (error === null) { proj.pending = false; return; }
535
886
 
536
887
  // api.log(`[consult] action error — hinting: ${error}`);
@@ -559,12 +910,13 @@ function triggerConsult(pid, proj) {
559
910
  // --- Action execution (returns error string or null on success) ---
560
911
 
561
912
  function executeAction(pid, proj, action, args, pillId) {
562
- const roles = [...proj.workers.values()].map(w => w.role);
913
+ const labels = [...proj.workers.values()].map(w => w.label);
563
914
  switch (action) {
564
915
  case 'route': {
565
- const src = workerByRole(proj, args.from);
566
- const dst = workerByRole(proj, args.to);
567
- 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(', ')}`;
568
920
  if (src === dst) return 'Cannot route agent to itself';
569
921
  if (proj.status.get(dst)) return `"${args.to}" is currently working — pick an idle agent`;
570
922
  const stored = src ? proj.lastOutput.get(src) : null;
@@ -585,11 +937,12 @@ function executeAction(pid, proj, action, args, pillId) {
585
937
  }
586
938
  }
587
939
 
588
- const dstRole = proj.workers.get(dst)?.role || args.to;
940
+ const dstWorker = proj.workers.get(dst);
589
941
  const header = [
590
942
  `[Autopilot route${oid ? ` | output #${oid}` : ''}]`,
591
- `[Team: ${roles.join(', ')}]`,
592
- `[You are: ${dstRole}]`,
943
+ `[Team: ${labels.join(', ')}]`,
944
+ `[Target session: ${dstWorker?.label || args.to}]`,
945
+ `[Target inferred role: ${dstWorker?.role || 'unknown'}]`,
593
946
  `[From: ${args.from}]`,
594
947
  '[Do not spawn internal agents.]',
595
948
  ].join('\n');
@@ -633,26 +986,26 @@ async function start(pid) {
633
986
  if (!enabled()) return { error: 'Autopilot disabled' };
634
987
 
635
988
  const provider = api.getSetting('provider') || 'anthropic';
636
- const apiKey = api.getSetting('apiKey') || (await ai()).getEnvApiKey(provider) || '';
989
+ const m = await ai();
990
+ const apiKey = api.getSetting('apiKey') || m.getEnvApiKey(provider) || '';
637
991
  if (!apiKey) return { error: 'Set the API key in Autopilot settings (Plugins panel)' };
638
-
639
- const { workers, status } = discoverWorkers(pid);
640
- if (workers.size < 1) return { error: 'No agents with roles in this project' };
641
-
642
- const roles = new Set();
643
- for (const [, w] of workers) {
644
- const key = w.role.toLowerCase();
645
- if (roles.has(key)) return { error: `Duplicate role "${w.role}"` };
646
- roles.add(key);
647
- }
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` }; }
648
995
 
649
996
  resetProjectState(pid);
650
997
  resetDebugLogs(pid);
651
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
+
652
1004
  const proj = {
653
1005
  workers,
654
1006
  status,
655
1007
  lastOutput: new Map(),
1008
+ goal: null,
656
1009
  paused: false,
657
1010
  pauseReason: null,
658
1011
  pending: false,
@@ -662,6 +1015,10 @@ async function start(pid) {
662
1015
  };
663
1016
  projects.set(pid, proj);
664
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
+
665
1022
  // Flag all workers for core menu auto-approve
666
1023
  for (const [sid] of workers) api.setAutoApproveMenu(sid, true);
667
1024
 
@@ -674,9 +1031,75 @@ async function start(pid) {
674
1031
 
675
1032
  // api.log(`Started: ${pid}, ${workers.size} workers`);
676
1033
  api.sendToFrontend('started', { projectId: pid });
677
- const pillId = `autopilot-${pid}`;
678
- api.addSessionPill({ id: pillId, title: 'Autopilot', projectId: pid });
679
- 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' });
680
1103
 
681
1104
  // Only consult if all workers are already idle
682
1105
  const allIdle = [...workers.keys()].every(sid => !status.get(sid));
@@ -685,7 +1108,7 @@ async function start(pid) {
685
1108
  return { ok: true };
686
1109
  }
687
1110
 
688
- function stop(pid, keepPill) {
1111
+ function stop(pid, keepPill, finalLog) {
689
1112
  const proj = projects.get(pid);
690
1113
  if (!proj) return;
691
1114
  for (const [sid] of proj.workers) clearIdleCaptureTimer(sid);
@@ -695,7 +1118,7 @@ function stop(pid, keepPill) {
695
1118
  api.sendToFrontend('stopped', { projectId: pid });
696
1119
  const pillId = `autopilot-${pid}`;
697
1120
  if (keepPill) {
698
- api.appendPillLog(pillId, 'Completed');
1121
+ api.appendPillLog(pillId, finalLog || 'Completed');
699
1122
  } else {
700
1123
  api.appendPillLog(pillId, 'Stopped');
701
1124
  api.removeSessionPill(pillId);
@@ -742,7 +1165,7 @@ module.exports.init = function (pluginApi) {
742
1165
  const [pid, proj] = projectFor(id);
743
1166
  if (!pid) return;
744
1167
  const w = proj.workers.get(id);
745
- const role = w?.role || id.slice(0, 8);
1168
+ const role = w?.label || id.slice(0, 8);
746
1169
 
747
1170
  if (working) {
748
1171
  menuPending.delete(id);