kernelbot 1.0.26 → 1.0.28

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.
Files changed (41) hide show
  1. package/README.md +198 -124
  2. package/bin/kernel.js +201 -4
  3. package/package.json +1 -1
  4. package/src/agent.js +397 -222
  5. package/src/automation/automation-manager.js +377 -0
  6. package/src/automation/automation.js +79 -0
  7. package/src/automation/index.js +2 -0
  8. package/src/automation/scheduler.js +141 -0
  9. package/src/bot.js +667 -21
  10. package/src/conversation.js +33 -0
  11. package/src/intents/detector.js +50 -0
  12. package/src/intents/index.js +2 -0
  13. package/src/intents/planner.js +58 -0
  14. package/src/persona.js +68 -0
  15. package/src/prompts/orchestrator.js +76 -0
  16. package/src/prompts/persona.md +21 -0
  17. package/src/prompts/system.js +59 -6
  18. package/src/prompts/workers.js +89 -0
  19. package/src/providers/anthropic.js +23 -16
  20. package/src/providers/base.js +76 -2
  21. package/src/providers/index.js +1 -0
  22. package/src/providers/models.js +2 -1
  23. package/src/providers/openai-compat.js +5 -3
  24. package/src/security/confirm.js +7 -2
  25. package/src/skills/catalog.js +506 -0
  26. package/src/skills/custom.js +128 -0
  27. package/src/swarm/job-manager.js +169 -0
  28. package/src/swarm/job.js +67 -0
  29. package/src/swarm/worker-registry.js +74 -0
  30. package/src/tools/browser.js +458 -335
  31. package/src/tools/categories.js +3 -3
  32. package/src/tools/index.js +3 -0
  33. package/src/tools/orchestrator-tools.js +371 -0
  34. package/src/tools/persona.js +32 -0
  35. package/src/utils/config.js +50 -15
  36. package/src/worker.js +305 -0
  37. package/.agents/skills/interface-design/SKILL.md +0 -391
  38. package/.agents/skills/interface-design/references/critique.md +0 -67
  39. package/.agents/skills/interface-design/references/example.md +0 -86
  40. package/.agents/skills/interface-design/references/principles.md +0 -235
  41. package/.agents/skills/interface-design/references/validation.md +0 -48
@@ -3,7 +3,7 @@
3
3
  */
4
4
 
5
5
  export const TOOL_CATEGORIES = {
6
- core: ['execute_command', 'read_file', 'write_file', 'list_directory'],
6
+ core: ['execute_command', 'read_file', 'write_file', 'list_directory', 'update_user_persona'],
7
7
  git: ['git_clone', 'git_checkout', 'git_commit', 'git_push', 'git_diff'],
8
8
  github: ['github_create_pr', 'github_get_pr_diff', 'github_post_review', 'github_create_repo', 'github_list_prs'],
9
9
  coding: ['spawn_claude_code'],
@@ -11,7 +11,7 @@ export const TOOL_CATEGORIES = {
11
11
  process: ['process_list', 'kill_process', 'service_control'],
12
12
  monitor: ['disk_usage', 'memory_usage', 'cpu_usage', 'system_logs'],
13
13
  network: ['check_port', 'curl_url', 'nginx_reload'],
14
- browser: ['browse_website', 'screenshot_website', 'extract_content', 'send_image', 'interact_with_page'],
14
+ browser: ['web_search', 'browse_website', 'screenshot_website', 'extract_content', 'send_image', 'interact_with_page'],
15
15
  jira: ['jira_get_ticket', 'jira_search_tickets', 'jira_list_my_tickets', 'jira_get_project_tickets'],
16
16
  };
17
17
 
@@ -23,7 +23,7 @@ const CATEGORY_KEYWORDS = {
23
23
  process: ['process', 'kill', 'restart', 'service', 'daemon', 'systemctl', 'pid'],
24
24
  monitor: ['disk', 'memory', 'cpu', 'usage', 'monitor', 'logs', 'status', 'health', 'space'],
25
25
  network: ['port', 'curl', 'http', 'nginx', 'network', 'api', 'endpoint', 'request', 'url', 'fetch'],
26
- browser: ['browse', 'screenshot', 'scrape', 'website', 'web page', 'webpage', 'extract content', 'html', 'css selector'],
26
+ browser: ['search', 'find', 'look up', 'browse', 'screenshot', 'scrape', 'website', 'web page', 'webpage', 'extract content', 'html', 'css selector'],
27
27
  jira: ['jira', 'ticket', 'issue', 'sprint', 'backlog', 'story', 'epic'],
28
28
  };
29
29
 
@@ -8,6 +8,7 @@ import { definitions as githubDefinitions, handlers as githubHandlers } from './
8
8
  import { definitions as codingDefinitions, handlers as codingHandlers } from './coding.js';
9
9
  import { definitions as browserDefinitions, handlers as browserHandlers } from './browser.js';
10
10
  import { definitions as jiraDefinitions, handlers as jiraHandlers } from './jira.js';
11
+ import { definitions as personaDefinitions, handlers as personaHandlers } from './persona.js';
11
12
  import { logToolCall } from '../security/audit.js';
12
13
  import { requiresConfirmation } from '../security/confirm.js';
13
14
 
@@ -22,6 +23,7 @@ export const toolDefinitions = [
22
23
  ...codingDefinitions,
23
24
  ...browserDefinitions,
24
25
  ...jiraDefinitions,
26
+ ...personaDefinitions,
25
27
  ];
26
28
 
27
29
  const handlerMap = {
@@ -35,6 +37,7 @@ const handlerMap = {
35
37
  ...codingHandlers,
36
38
  ...browserHandlers,
37
39
  ...jiraHandlers,
40
+ ...personaHandlers,
38
41
  };
39
42
 
40
43
  export function checkConfirmation(name, params, config) {
@@ -0,0 +1,371 @@
1
+ import { WORKER_TYPES, getToolsForWorker, getToolNamesForWorkerType } from '../swarm/worker-registry.js';
2
+ import { getMissingCredential } from '../utils/config.js';
3
+ import { getLogger } from '../utils/logger.js';
4
+
5
+ const workerTypeEnum = Object.keys(WORKER_TYPES);
6
+
7
+ /**
8
+ * Tool definitions for the orchestrator's 3 meta-tools.
9
+ */
10
+ export const orchestratorToolDefinitions = [
11
+ {
12
+ name: 'dispatch_task',
13
+ description: `Dispatch a task to a specialized background worker. Available worker types: ${workerTypeEnum.join(', ')}. The worker runs in the background and you'll be notified when it completes.`,
14
+ input_schema: {
15
+ type: 'object',
16
+ properties: {
17
+ worker_type: {
18
+ type: 'string',
19
+ enum: workerTypeEnum,
20
+ description: 'The type of worker to dispatch the task to.',
21
+ },
22
+ task: {
23
+ type: 'string',
24
+ description: 'A clear, detailed description of what the worker should do.',
25
+ },
26
+ },
27
+ required: ['worker_type', 'task'],
28
+ },
29
+ },
30
+ {
31
+ name: 'list_jobs',
32
+ description: 'List all jobs for the current chat with their status, type, and summary.',
33
+ input_schema: {
34
+ type: 'object',
35
+ properties: {},
36
+ },
37
+ },
38
+ {
39
+ name: 'cancel_job',
40
+ description: 'Cancel a running or queued job by its ID.',
41
+ input_schema: {
42
+ type: 'object',
43
+ properties: {
44
+ job_id: {
45
+ type: 'string',
46
+ description: 'The ID of the job to cancel.',
47
+ },
48
+ },
49
+ required: ['job_id'],
50
+ },
51
+ },
52
+ {
53
+ name: 'create_automation',
54
+ description: 'Create a recurring automation that runs on a schedule. The task description will be executed as a standalone prompt each time it fires.',
55
+ input_schema: {
56
+ type: 'object',
57
+ properties: {
58
+ name: {
59
+ type: 'string',
60
+ description: 'Short name for the automation (e.g. "Server Health Check").',
61
+ },
62
+ description: {
63
+ type: 'string',
64
+ description: 'Detailed task prompt that will be executed each time. Must be standalone and self-contained.',
65
+ },
66
+ schedule_type: {
67
+ type: 'string',
68
+ enum: ['cron', 'interval', 'random'],
69
+ description: 'Schedule type: cron (fixed times), interval (every N minutes), random (human-like random intervals).',
70
+ },
71
+ cron_expression: {
72
+ type: 'string',
73
+ description: 'Cron expression for schedule_type=cron (e.g. "0 9 * * *" for 9am daily). 5 fields: minute hour dayOfMonth month dayOfWeek.',
74
+ },
75
+ interval_minutes: {
76
+ type: 'number',
77
+ description: 'Interval in minutes for schedule_type=interval (minimum 5).',
78
+ },
79
+ min_minutes: {
80
+ type: 'number',
81
+ description: 'Minimum interval in minutes for schedule_type=random.',
82
+ },
83
+ max_minutes: {
84
+ type: 'number',
85
+ description: 'Maximum interval in minutes for schedule_type=random.',
86
+ },
87
+ },
88
+ required: ['name', 'description', 'schedule_type'],
89
+ },
90
+ },
91
+ {
92
+ name: 'list_automations',
93
+ description: 'List all automations for the current chat with their status, schedule, and next run time.',
94
+ input_schema: {
95
+ type: 'object',
96
+ properties: {},
97
+ },
98
+ },
99
+ {
100
+ name: 'update_automation',
101
+ description: 'Update an existing automation — change its name, description, schedule, or enable/disable it.',
102
+ input_schema: {
103
+ type: 'object',
104
+ properties: {
105
+ automation_id: {
106
+ type: 'string',
107
+ description: 'The ID of the automation to update.',
108
+ },
109
+ enabled: {
110
+ type: 'boolean',
111
+ description: 'Enable or disable the automation.',
112
+ },
113
+ name: {
114
+ type: 'string',
115
+ description: 'New name for the automation.',
116
+ },
117
+ description: {
118
+ type: 'string',
119
+ description: 'New task prompt for the automation.',
120
+ },
121
+ schedule_type: {
122
+ type: 'string',
123
+ enum: ['cron', 'interval', 'random'],
124
+ description: 'New schedule type.',
125
+ },
126
+ cron_expression: { type: 'string' },
127
+ interval_minutes: { type: 'number' },
128
+ min_minutes: { type: 'number' },
129
+ max_minutes: { type: 'number' },
130
+ },
131
+ required: ['automation_id'],
132
+ },
133
+ },
134
+ {
135
+ name: 'delete_automation',
136
+ description: 'Permanently delete an automation by its ID.',
137
+ input_schema: {
138
+ type: 'object',
139
+ properties: {
140
+ automation_id: {
141
+ type: 'string',
142
+ description: 'The ID of the automation to delete.',
143
+ },
144
+ },
145
+ required: ['automation_id'],
146
+ },
147
+ },
148
+ ];
149
+
150
+ /**
151
+ * Execute an orchestrator meta-tool.
152
+ * The dispatch_task handler is async and fire-and-forget — it returns immediately.
153
+ *
154
+ * @param {string} name - Tool name
155
+ * @param {object} input - Tool input
156
+ * @param {object} context - { chatId, jobManager, config, spawnWorker }
157
+ * @returns {object} Tool result
158
+ */
159
+ export async function executeOrchestratorTool(name, input, context) {
160
+ const logger = getLogger();
161
+ const { chatId, jobManager, config, spawnWorker, automationManager } = context;
162
+
163
+ switch (name) {
164
+ case 'dispatch_task': {
165
+ const { worker_type, task } = input;
166
+ logger.info(`[dispatch_task] Request: type=${worker_type}, task="${task.slice(0, 120)}"`);
167
+
168
+ // Validate worker type
169
+ if (!WORKER_TYPES[worker_type]) {
170
+ logger.warn(`[dispatch_task] Invalid worker type: ${worker_type}`);
171
+ return { error: `Unknown worker type: ${worker_type}. Valid: ${workerTypeEnum.join(', ')}` };
172
+ }
173
+
174
+ // Check concurrent job limit
175
+ const running = jobManager.getRunningJobsForChat(chatId);
176
+ const maxConcurrent = config.swarm?.max_concurrent_jobs || 3;
177
+ logger.debug(`[dispatch_task] Running jobs: ${running.length}/${maxConcurrent} for chat ${chatId}`);
178
+ if (running.length >= maxConcurrent) {
179
+ logger.warn(`[dispatch_task] Rejected — concurrent limit reached: ${running.length}/${maxConcurrent} jobs for chat ${chatId}`);
180
+ return { error: `Maximum concurrent jobs (${maxConcurrent}) reached. Wait for a job to finish or cancel one.` };
181
+ }
182
+
183
+ // Pre-check credentials for the worker's tools
184
+ const toolNames = getToolNamesForWorkerType(worker_type);
185
+ for (const toolName of toolNames) {
186
+ const missing = getMissingCredential(toolName, config);
187
+ if (missing) {
188
+ logger.warn(`[dispatch_task] Missing credential for ${worker_type}: ${missing.envKey}`);
189
+ return {
190
+ error: `Missing credential for ${worker_type} worker: ${missing.label} (${missing.envKey}). Ask the user to provide it.`,
191
+ };
192
+ }
193
+ }
194
+
195
+ // Create and spawn the job
196
+ const job = jobManager.createJob(chatId, worker_type, task);
197
+ const workerConfig = WORKER_TYPES[worker_type];
198
+
199
+ logger.info(`[dispatch_task] Dispatching job ${job.id} — ${workerConfig.emoji} ${workerConfig.label}: "${task.slice(0, 100)}"`);
200
+
201
+ // Fire and forget — spawnWorker handles lifecycle
202
+ spawnWorker(job).catch((err) => {
203
+ logger.error(`[dispatch_task] Worker spawn error for job ${job.id}: ${err.message}`);
204
+ if (!job.isTerminal) {
205
+ jobManager.failJob(job.id, err.message);
206
+ }
207
+ });
208
+
209
+ return {
210
+ job_id: job.id,
211
+ worker_type,
212
+ status: 'dispatched',
213
+ message: `${workerConfig.emoji} ${workerConfig.label} started.`,
214
+ };
215
+ }
216
+
217
+ case 'list_jobs': {
218
+ const jobs = jobManager.getJobsForChat(chatId);
219
+ logger.info(`[list_jobs] Chat ${chatId} — ${jobs.length} jobs found`);
220
+ if (jobs.length > 0) {
221
+ logger.debug(`[list_jobs] Jobs: ${jobs.slice(0, 5).map(j => `${j.id}[${j.status}]`).join(', ')}${jobs.length > 5 ? '...' : ''}`);
222
+ }
223
+ if (jobs.length === 0) {
224
+ return { message: 'No jobs for this chat.' };
225
+ }
226
+ return {
227
+ jobs: jobs.slice(0, 20).map((j) => ({
228
+ id: j.id,
229
+ worker_type: j.workerType,
230
+ status: j.status,
231
+ task: j.task.slice(0, 100),
232
+ duration: j.duration,
233
+ summary: j.toSummary(),
234
+ })),
235
+ };
236
+ }
237
+
238
+ case 'cancel_job': {
239
+ const { job_id } = input;
240
+ logger.info(`[cancel_job] Request to cancel job ${job_id} in chat ${chatId}`);
241
+ const job = jobManager.cancelJob(job_id);
242
+ if (!job) {
243
+ logger.warn(`[cancel_job] Job ${job_id} not found or already finished`);
244
+ return { error: `Job ${job_id} not found or already finished.` };
245
+ }
246
+ logger.info(`[cancel_job] Successfully cancelled job ${job_id} [${job.workerType}]`);
247
+ return {
248
+ job_id: job.id,
249
+ status: 'cancelled',
250
+ message: `Cancelled ${WORKER_TYPES[job.workerType]?.emoji || ''} ${job.workerType} worker.`,
251
+ };
252
+ }
253
+
254
+ case 'create_automation': {
255
+ if (!automationManager) return { error: 'Automation system not available.' };
256
+
257
+ const { name: autoName, description, schedule_type, cron_expression, interval_minutes, min_minutes, max_minutes } = input;
258
+ logger.info(`[create_automation] Request: name="${autoName}", type=${schedule_type}`);
259
+
260
+ let schedule;
261
+ switch (schedule_type) {
262
+ case 'cron':
263
+ schedule = { type: 'cron', expression: cron_expression };
264
+ break;
265
+ case 'interval':
266
+ schedule = { type: 'interval', minutes: interval_minutes };
267
+ break;
268
+ case 'random':
269
+ schedule = { type: 'random', minMinutes: min_minutes, maxMinutes: max_minutes };
270
+ break;
271
+ default:
272
+ return { error: `Unknown schedule type: ${schedule_type}` };
273
+ }
274
+
275
+ try {
276
+ const auto = automationManager.create(chatId, { name: autoName, description, schedule });
277
+ return {
278
+ automation_id: auto.id,
279
+ name: auto.name,
280
+ schedule: auto.schedule,
281
+ next_run: auto.nextRun ? new Date(auto.nextRun).toLocaleString() : null,
282
+ message: `Automation "${auto.name}" created and armed.`,
283
+ };
284
+ } catch (err) {
285
+ logger.warn(`[create_automation] Failed: ${err.message}`);
286
+ return { error: err.message };
287
+ }
288
+ }
289
+
290
+ case 'list_automations': {
291
+ if (!automationManager) return { error: 'Automation system not available.' };
292
+
293
+ const autos = automationManager.listForChat(chatId);
294
+ logger.info(`[list_automations] Chat ${chatId} — ${autos.length} automation(s)`);
295
+
296
+ if (autos.length === 0) {
297
+ return { message: 'No automations for this chat.' };
298
+ }
299
+
300
+ return {
301
+ automations: autos.map((a) => ({
302
+ id: a.id,
303
+ name: a.name,
304
+ description: a.description.slice(0, 100),
305
+ schedule: a.schedule,
306
+ enabled: a.enabled,
307
+ next_run: a.nextRun ? new Date(a.nextRun).toLocaleString() : null,
308
+ run_count: a.runCount,
309
+ last_error: a.lastError,
310
+ summary: a.toSummary(),
311
+ })),
312
+ };
313
+ }
314
+
315
+ case 'update_automation': {
316
+ if (!automationManager) return { error: 'Automation system not available.' };
317
+
318
+ const { automation_id, enabled, name: newName, description: newDesc, schedule_type: newSchedType, cron_expression: newCron, interval_minutes: newInterval, min_minutes: newMin, max_minutes: newMax } = input;
319
+ logger.info(`[update_automation] Request: id=${automation_id}`);
320
+
321
+ const changes = {};
322
+ if (enabled !== undefined) changes.enabled = enabled;
323
+ if (newName !== undefined) changes.name = newName;
324
+ if (newDesc !== undefined) changes.description = newDesc;
325
+
326
+ if (newSchedType !== undefined) {
327
+ switch (newSchedType) {
328
+ case 'cron':
329
+ changes.schedule = { type: 'cron', expression: newCron };
330
+ break;
331
+ case 'interval':
332
+ changes.schedule = { type: 'interval', minutes: newInterval };
333
+ break;
334
+ case 'random':
335
+ changes.schedule = { type: 'random', minMinutes: newMin, maxMinutes: newMax };
336
+ break;
337
+ }
338
+ }
339
+
340
+ try {
341
+ const auto = automationManager.update(automation_id, changes);
342
+ if (!auto) return { error: `Automation ${automation_id} not found.` };
343
+ return {
344
+ automation_id: auto.id,
345
+ name: auto.name,
346
+ enabled: auto.enabled,
347
+ schedule: auto.schedule,
348
+ next_run: auto.nextRun ? new Date(auto.nextRun).toLocaleString() : null,
349
+ message: `Automation "${auto.name}" updated.`,
350
+ };
351
+ } catch (err) {
352
+ logger.warn(`[update_automation] Failed: ${err.message}`);
353
+ return { error: err.message };
354
+ }
355
+ }
356
+
357
+ case 'delete_automation': {
358
+ if (!automationManager) return { error: 'Automation system not available.' };
359
+
360
+ const { automation_id } = input;
361
+ logger.info(`[delete_automation] Request: id=${automation_id}`);
362
+
363
+ const deleted = automationManager.delete(automation_id);
364
+ if (!deleted) return { error: `Automation ${automation_id} not found.` };
365
+ return { automation_id, status: 'deleted', message: `Automation deleted.` };
366
+ }
367
+
368
+ default:
369
+ return { error: `Unknown orchestrator tool: ${name}` };
370
+ }
371
+ }
@@ -0,0 +1,32 @@
1
+ export const definitions = [
2
+ {
3
+ name: 'update_user_persona',
4
+ description:
5
+ 'Update the stored persona/profile for the current user. ' +
6
+ 'Pass the COMPLETE updated persona document as markdown. ' +
7
+ 'Before calling this, mentally merge any new information into the existing persona — ' +
8
+ 'do not blindly append. Only call when you discover genuinely new, meaningful information ' +
9
+ '(expertise, preferences, projects, communication style).',
10
+ input_schema: {
11
+ type: 'object',
12
+ properties: {
13
+ content: {
14
+ type: 'string',
15
+ description: 'The full updated persona markdown document.',
16
+ },
17
+ },
18
+ required: ['content'],
19
+ },
20
+ },
21
+ ];
22
+
23
+ export const handlers = {
24
+ async update_user_persona({ content }, context) {
25
+ const { personaManager, user } = context;
26
+ if (!personaManager) return { error: 'Persona manager not available.' };
27
+ if (!user?.id) return { error: 'User ID not available.' };
28
+
29
+ personaManager.save(user.id, content);
30
+ return { success: true, message: 'User persona updated.' };
31
+ },
32
+ };
@@ -12,12 +12,22 @@ const DEFAULTS = {
12
12
  name: 'KernelBot',
13
13
  description: 'AI engineering agent with full OS control',
14
14
  },
15
+ orchestrator: {
16
+ model: 'claude-opus-4-6',
17
+ max_tokens: 2048,
18
+ temperature: 0.3,
19
+ max_tool_depth: 5,
20
+ },
15
21
  brain: {
16
22
  provider: 'anthropic',
17
23
  model: 'claude-sonnet-4-20250514',
18
24
  max_tokens: 4096,
19
25
  temperature: 0.3,
20
- max_tool_depth: 12,
26
+ },
27
+ swarm: {
28
+ max_concurrent_jobs: 3,
29
+ job_timeout_seconds: 300,
30
+ cleanup_interval_minutes: 30,
21
31
  },
22
32
  telegram: {
23
33
  allowed_users: [],
@@ -184,30 +194,50 @@ export function saveProviderToYaml(providerKey, modelId) {
184
194
  * Full interactive flow: change brain model + optionally enter API key.
185
195
  */
186
196
  export async function changeBrainModel(config, rl) {
197
+ const { createProvider } = await import('../providers/index.js');
187
198
  const { providerKey, modelId } = await promptProviderSelection(rl);
188
199
 
189
200
  const providerDef = PROVIDERS[providerKey];
201
+
202
+ // Resolve API key
203
+ const envKey = providerDef.envKey;
204
+ let apiKey = process.env[envKey];
205
+ if (!apiKey) {
206
+ const key = await ask(rl, chalk.cyan(`\n ${providerDef.name} API key (${envKey}): `));
207
+ if (!key.trim()) {
208
+ console.log(chalk.yellow('\n No API key provided. Brain not changed.\n'));
209
+ return config;
210
+ }
211
+ apiKey = key.trim();
212
+ }
213
+
214
+ // Validate the new provider before saving anything
215
+ console.log(chalk.dim(`\n Verifying ${providerDef.name} / ${modelId}...`));
216
+ const testConfig = { ...config, brain: { ...config.brain, provider: providerKey, model: modelId, api_key: apiKey } };
217
+ try {
218
+ const testProvider = createProvider(testConfig);
219
+ await testProvider.ping();
220
+ } catch (err) {
221
+ console.log(chalk.red(`\n ✖ Verification failed: ${err.message}`));
222
+ console.log(chalk.yellow(` Brain not changed. Keeping current model.\n`));
223
+ return config;
224
+ }
225
+
226
+ // Validation passed — save everything
190
227
  const savedPath = saveProviderToYaml(providerKey, modelId);
191
- console.log(chalk.dim(`\n Saved to ${savedPath}`));
228
+ console.log(chalk.dim(` Saved to ${savedPath}`));
192
229
 
193
- // Update live config
194
230
  config.brain.provider = providerKey;
195
231
  config.brain.model = modelId;
232
+ config.brain.api_key = apiKey;
196
233
 
197
- // Check if we have the API key for this provider
198
- const envKey = providerDef.envKey;
199
- const currentKey = process.env[envKey];
200
- if (!currentKey) {
201
- const key = await ask(rl, chalk.cyan(`\n ${providerDef.name} API key (${envKey}): `));
202
- if (key.trim()) {
203
- saveCredential(config, envKey, key.trim());
204
- config.brain.api_key = key.trim();
205
- console.log(chalk.dim(' Saved.\n'));
206
- }
207
- } else {
208
- config.brain.api_key = currentKey;
234
+ // Save the key if it was newly entered
235
+ if (!process.env[envKey]) {
236
+ saveCredential(config, envKey, apiKey);
237
+ console.log(chalk.dim(' API key saved.\n'));
209
238
  }
210
239
 
240
+ console.log(chalk.green(` ✔ Brain switched to ${providerDef.name} / ${modelId}\n`));
211
241
  return config;
212
242
  }
213
243
 
@@ -299,6 +329,11 @@ export function loadConfig() {
299
329
 
300
330
  const config = deepMerge(DEFAULTS, fileConfig);
301
331
 
332
+ // Orchestrator always uses Anthropic — resolve its API key
333
+ if (process.env.ANTHROPIC_API_KEY) {
334
+ config.orchestrator.api_key = process.env.ANTHROPIC_API_KEY;
335
+ }
336
+
302
337
  // Overlay env vars for brain API key based on provider
303
338
  const providerDef = PROVIDERS[config.brain.provider];
304
339
  if (providerDef && process.env[providerDef.envKey]) {