kernelbot 1.0.28 → 1.0.30

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 (56) hide show
  1. package/.env.example +4 -0
  2. package/README.md +0 -0
  3. package/bin/kernel.js +13 -6
  4. package/config.example.yaml +14 -1
  5. package/package.json +1 -1
  6. package/src/agent.js +482 -27
  7. package/src/automation/automation-manager.js +0 -0
  8. package/src/automation/automation.js +0 -0
  9. package/src/automation/index.js +0 -0
  10. package/src/automation/scheduler.js +0 -0
  11. package/src/bot.js +340 -3
  12. package/src/claude-auth.js +93 -0
  13. package/src/coder.js +48 -6
  14. package/src/conversation.js +0 -0
  15. package/src/intents/detector.js +0 -0
  16. package/src/intents/index.js +0 -0
  17. package/src/intents/planner.js +0 -0
  18. package/src/persona.js +0 -0
  19. package/src/prompts/orchestrator.js +53 -5
  20. package/src/prompts/persona.md +0 -0
  21. package/src/prompts/system.js +0 -0
  22. package/src/prompts/workers.js +61 -2
  23. package/src/providers/anthropic.js +0 -0
  24. package/src/providers/base.js +0 -0
  25. package/src/providers/index.js +0 -0
  26. package/src/providers/models.js +0 -0
  27. package/src/providers/openai-compat.js +0 -0
  28. package/src/security/audit.js +0 -0
  29. package/src/security/auth.js +0 -0
  30. package/src/security/confirm.js +0 -0
  31. package/src/self.js +122 -0
  32. package/src/services/stt.js +139 -0
  33. package/src/services/tts.js +124 -0
  34. package/src/skills/catalog.js +0 -0
  35. package/src/skills/custom.js +0 -0
  36. package/src/swarm/job-manager.js +54 -7
  37. package/src/swarm/job.js +19 -1
  38. package/src/swarm/worker-registry.js +5 -0
  39. package/src/tools/browser.js +0 -0
  40. package/src/tools/categories.js +0 -0
  41. package/src/tools/coding.js +5 -0
  42. package/src/tools/docker.js +0 -0
  43. package/src/tools/git.js +0 -0
  44. package/src/tools/github.js +0 -0
  45. package/src/tools/index.js +0 -0
  46. package/src/tools/jira.js +0 -0
  47. package/src/tools/monitor.js +0 -0
  48. package/src/tools/network.js +0 -0
  49. package/src/tools/orchestrator-tools.js +76 -19
  50. package/src/tools/os.js +14 -1
  51. package/src/tools/persona.js +0 -0
  52. package/src/tools/process.js +0 -0
  53. package/src/utils/config.js +105 -2
  54. package/src/utils/display.js +0 -0
  55. package/src/utils/logger.js +0 -0
  56. package/src/worker.js +96 -5
@@ -1,5 +1,6 @@
1
1
  import { EventEmitter } from 'events';
2
2
  import { Job } from './job.js';
3
+ import { WORKER_TYPES } from './worker-registry.js';
3
4
  import { getLogger } from '../utils/logger.js';
4
5
 
5
6
  /**
@@ -10,6 +11,7 @@ import { getLogger } from '../utils/logger.js';
10
11
  * job:completed (job)
11
12
  * job:failed (job)
12
13
  * job:cancelled (job)
14
+ * job:ready (job) — emitted when a queued job's dependencies are all met
13
15
  */
14
16
  export class JobManager extends EventEmitter {
15
17
  constructor({ jobTimeoutSeconds = 300, cleanupIntervalMinutes = 30 } = {}) {
@@ -22,9 +24,12 @@ export class JobManager extends EventEmitter {
22
24
  /** Create a new job (status: queued). */
23
25
  createJob(chatId, workerType, task) {
24
26
  const job = new Job({ chatId, workerType, task });
27
+ // Set per-job timeout from worker type config, fall back to global
28
+ const workerConfig = WORKER_TYPES[workerType];
29
+ job.timeoutMs = workerConfig?.timeout ? workerConfig.timeout * 1000 : this.jobTimeoutMs;
25
30
  this.jobs.set(job.id, job);
26
31
  const logger = getLogger();
27
- logger.info(`[JobManager] Job created: ${job.id} [${workerType}] for chat ${chatId} — "${task.slice(0, 100)}"`);
32
+ logger.info(`[JobManager] Job created: ${job.id} [${workerType}] for chat ${chatId} — timeout: ${job.timeoutMs / 1000}s — "${task.slice(0, 100)}"`);
28
33
  logger.debug(`[JobManager] Total jobs tracked: ${this.jobs.size}`);
29
34
  return job;
30
35
  }
@@ -36,13 +41,17 @@ export class JobManager extends EventEmitter {
36
41
  getLogger().info(`[JobManager] Job ${job.id} [${job.workerType}] → running`);
37
42
  }
38
43
 
39
- /** Move a job to completed with a result. */
40
- completeJob(jobId, result) {
44
+ /** Move a job to completed with a result and optional structured data. */
45
+ completeJob(jobId, result, structuredResult = null) {
41
46
  const job = this._get(jobId);
42
47
  job.transition('completed');
43
48
  job.result = result;
44
- getLogger().info(`[JobManager] Job ${job.id} [${job.workerType}] → completed (${job.duration}s) — result: ${(result || '').length} chars`);
49
+ if (structuredResult) {
50
+ job.structuredResult = structuredResult;
51
+ }
52
+ getLogger().info(`[JobManager] Job ${job.id} [${job.workerType}] → completed (${job.duration}s) — result: ${(result || '').length} chars, structured: ${!!structuredResult}`);
45
53
  this.emit('job:completed', job);
54
+ this._checkDependents(job);
46
55
  }
47
56
 
48
57
  /** Move a job to failed with an error message. */
@@ -52,6 +61,7 @@ export class JobManager extends EventEmitter {
52
61
  job.error = typeof error === 'string' ? error : error?.message || String(error);
53
62
  getLogger().error(`[JobManager] Job ${job.id} [${job.workerType}] → failed (${job.duration}s) — ${job.error}`);
54
63
  this.emit('job:failed', job);
64
+ this._failDependents(job);
55
65
  }
56
66
 
57
67
  /** Cancel a specific job. Returns the job or null if not found / already terminal. */
@@ -145,13 +155,14 @@ export class JobManager extends EventEmitter {
145
155
  if (job.status === 'running' && job.startedAt) {
146
156
  checkedCount++;
147
157
  const elapsed = now - job.startedAt;
148
- if (elapsed > this.jobTimeoutMs) {
149
- getLogger().warn(`[JobManager] Job ${job.id} [${job.workerType}] timed out — elapsed: ${Math.round(elapsed / 1000)}s, limit: ${this.jobTimeoutMs / 1000}s`);
158
+ const timeoutMs = job.timeoutMs || this.jobTimeoutMs;
159
+ if (elapsed > timeoutMs) {
160
+ getLogger().warn(`[JobManager] Job ${job.id} [${job.workerType}] timed out — elapsed: ${Math.round(elapsed / 1000)}s, limit: ${timeoutMs / 1000}s`);
150
161
  // Cancel the worker first so it stops executing & frees resources
151
162
  if (job.worker && typeof job.worker.cancel === 'function') {
152
163
  job.worker.cancel();
153
164
  }
154
- this.failJob(job.id, `Timed out after ${this.jobTimeoutMs / 1000}s`);
165
+ this.failJob(job.id, `Timed out after ${timeoutMs / 1000}s`);
155
166
  }
156
167
  }
157
168
  }
@@ -160,6 +171,42 @@ export class JobManager extends EventEmitter {
160
171
  }
161
172
  }
162
173
 
174
+ /** Check if any queued jobs have all dependencies met after a job completes. */
175
+ _checkDependents(completedJob) {
176
+ const logger = getLogger();
177
+ for (const job of this.jobs.values()) {
178
+ if (job.status !== 'queued' || job.dependsOn.length === 0) continue;
179
+ if (!job.dependsOn.includes(completedJob.id)) continue;
180
+
181
+ // Check if ALL dependencies are completed
182
+ const allMet = job.dependsOn.every(depId => {
183
+ const dep = this.jobs.get(depId);
184
+ return dep && dep.status === 'completed';
185
+ });
186
+
187
+ if (allMet) {
188
+ logger.info(`[JobManager] Job ${job.id} [${job.workerType}] — all dependencies met, emitting job:ready`);
189
+ this.emit('job:ready', job);
190
+ }
191
+ }
192
+ }
193
+
194
+ /** Cascade failure to dependent jobs when a job fails. */
195
+ _failDependents(failedJob) {
196
+ const logger = getLogger();
197
+ for (const job of this.jobs.values()) {
198
+ if (job.status !== 'queued' || job.dependsOn.length === 0) continue;
199
+ if (!job.dependsOn.includes(failedJob.id)) continue;
200
+
201
+ logger.warn(`[JobManager] Job ${job.id} [${job.workerType}] — dependency ${failedJob.id} failed, cascading failure`);
202
+ job.transition('failed');
203
+ job.error = `Dependency job ${failedJob.id} failed: ${failedJob.error || 'unknown error'}`;
204
+ this.emit('job:failed', job);
205
+ // Recursively cascade to jobs depending on this one
206
+ this._failDependents(job);
207
+ }
208
+ }
209
+
163
210
  /** Internal: get job or throw. */
164
211
  _get(jobId) {
165
212
  const job = this.jobs.get(jobId);
package/src/swarm/job.js CHANGED
@@ -20,12 +20,19 @@ export class Job {
20
20
  this.task = task;
21
21
  this.status = 'queued';
22
22
  this.result = null;
23
+ this.structuredResult = null; // WorkerResult: { summary, status, details, artifacts, followUp, toolsUsed, errors }
24
+ this.context = null; // Orchestrator-provided context string
25
+ this.dependsOn = []; // Job IDs this job depends on
26
+ this.userId = null; // Telegram user ID for persona loading
23
27
  this.error = null;
24
28
  this.worker = null; // WorkerAgent ref
25
29
  this.statusMessageId = null; // Telegram message for progress edits
26
30
  this.createdAt = Date.now();
27
31
  this.startedAt = null;
28
32
  this.completedAt = null;
33
+ this.timeoutMs = null; // Per-job timeout (set from worker type config)
34
+ this.progress = []; // Recent activity entries
35
+ this.lastActivity = null; // Timestamp of last activity
29
36
  }
30
37
 
31
38
  /** Transition to a new status. Throws if the transition is invalid. */
@@ -46,6 +53,13 @@ export class Job {
46
53
  return Math.round((end - this.startedAt) / 1000);
47
54
  }
48
55
 
56
+ /** Record a progress heartbeat from the worker. Caps at 20 entries. */
57
+ addProgress(text) {
58
+ this.progress.push(text);
59
+ if (this.progress.length > 20) this.progress.shift();
60
+ this.lastActivity = Date.now();
61
+ }
62
+
49
63
  /** Whether this job is in a terminal state. */
50
64
  get isTerminal() {
51
65
  return ['completed', 'failed', 'cancelled'].includes(this.status);
@@ -62,6 +76,10 @@ export class Job {
62
76
  };
63
77
  const emoji = statusEmoji[this.status] || '❓';
64
78
  const dur = this.duration != null ? ` (${this.duration}s)` : '';
65
- return `${emoji} \`${this.id}\` [${this.workerType}] ${this.task.slice(0, 60)}${this.task.length > 60 ? '...' : ''}${dur}`;
79
+ const summaryText = this.structuredResult?.summary || null;
80
+ const lastAct = !summaryText && this.progress.length > 0 ? ` | ${this.progress[this.progress.length - 1]}` : '';
81
+ const resultSnippet = summaryText ? ` | ${summaryText.slice(0, 80)}` : '';
82
+ const deps = this.dependsOn.length > 0 ? ` [deps: ${this.dependsOn.join(',')}]` : '';
83
+ return `${emoji} \`${this.id}\` [${this.workerType}] ${this.task.slice(0, 60)}${this.task.length > 60 ? '...' : ''}${dur}${resultSnippet}${lastAct}${deps}`;
66
84
  }
67
85
  }
@@ -10,30 +10,35 @@ export const WORKER_TYPES = {
10
10
  emoji: '💻',
11
11
  categories: ['core', 'coding', 'git', 'github'],
12
12
  description: 'Write code, fix bugs, create PRs',
13
+ timeout: 86400, // 24 hours — Claude Code can legitimately run for hours
13
14
  },
14
15
  browser: {
15
16
  label: 'Browser Worker',
16
17
  emoji: '🌐',
17
18
  categories: ['browser'],
18
19
  description: 'Web search, scraping, screenshots',
20
+ timeout: 300, // 5 minutes
19
21
  },
20
22
  system: {
21
23
  label: 'System Worker',
22
24
  emoji: '🖥️',
23
25
  categories: ['core', 'process', 'monitor', 'network'],
24
26
  description: 'OS operations, monitoring, network',
27
+ timeout: 600, // 10 minutes
25
28
  },
26
29
  devops: {
27
30
  label: 'DevOps Worker',
28
31
  emoji: '🚀',
29
32
  categories: ['core', 'docker', 'process', 'monitor', 'network', 'git'],
30
33
  description: 'Docker, deploy, infrastructure',
34
+ timeout: 3600, // 1 hour
31
35
  },
32
36
  research: {
33
37
  label: 'Research Worker',
34
38
  emoji: '🔍',
35
39
  categories: ['browser', 'core'],
36
40
  description: 'Deep web research and analysis',
41
+ timeout: 600, // 10 minutes
37
42
  },
38
43
  };
39
44
 
File without changes
File without changes
@@ -10,6 +10,10 @@ function getSpawner(config) {
10
10
  return spawner;
11
11
  }
12
12
 
13
+ export function resetClaudeCodeSpawner() {
14
+ spawner = null;
15
+ }
16
+
13
17
  export const definitions = [
14
18
  {
15
19
  name: 'spawn_claude_code',
@@ -57,6 +61,7 @@ export const handlers = {
57
61
  prompt: params.prompt,
58
62
  maxTurns: params.max_turns,
59
63
  onOutput: onUpdate,
64
+ signal: context.signal || null,
60
65
  });
61
66
 
62
67
  // Show stderr if any
File without changes
package/src/tools/git.js CHANGED
File without changes
File without changes
File without changes
package/src/tools/jira.js CHANGED
File without changes
File without changes
File without changes
@@ -23,6 +23,15 @@ export const orchestratorToolDefinitions = [
23
23
  type: 'string',
24
24
  description: 'A clear, detailed description of what the worker should do.',
25
25
  },
26
+ context: {
27
+ type: 'string',
28
+ description: 'Optional background context for the worker — relevant conversation details, goals, constraints. The worker cannot see the chat history, so include anything important here.',
29
+ },
30
+ depends_on: {
31
+ type: 'array',
32
+ items: { type: 'string' },
33
+ description: 'Optional array of job IDs that must complete before this worker starts. The worker will receive dependency results as context.',
34
+ },
26
35
  },
27
36
  required: ['worker_type', 'task'],
28
37
  },
@@ -158,12 +167,12 @@ export const orchestratorToolDefinitions = [
158
167
  */
159
168
  export async function executeOrchestratorTool(name, input, context) {
160
169
  const logger = getLogger();
161
- const { chatId, jobManager, config, spawnWorker, automationManager } = context;
170
+ const { chatId, jobManager, config, spawnWorker, automationManager, user } = context;
162
171
 
163
172
  switch (name) {
164
173
  case 'dispatch_task': {
165
- const { worker_type, task } = input;
166
- logger.info(`[dispatch_task] Request: type=${worker_type}, task="${task.slice(0, 120)}"`);
174
+ const { worker_type, task, context: taskContext, depends_on } = input;
175
+ logger.info(`[dispatch_task] Request: type=${worker_type}, task="${task.slice(0, 120)}", deps=${depends_on?.length || 0}`);
167
176
 
168
177
  // Validate worker type
169
178
  if (!WORKER_TYPES[worker_type]) {
@@ -171,13 +180,34 @@ export async function executeOrchestratorTool(name, input, context) {
171
180
  return { error: `Unknown worker type: ${worker_type}. Valid: ${workerTypeEnum.join(', ')}` };
172
181
  }
173
182
 
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.` };
183
+ // Validate dependency IDs
184
+ const depIds = Array.isArray(depends_on) ? depends_on : [];
185
+ for (const depId of depIds) {
186
+ const depJob = jobManager.getJob(depId);
187
+ if (!depJob) {
188
+ logger.warn(`[dispatch_task] Unknown dependency job: ${depId}`);
189
+ return { error: `Dependency job not found: ${depId}` };
190
+ }
191
+ if (depJob.status === 'failed' || depJob.status === 'cancelled') {
192
+ logger.warn(`[dispatch_task] Dependency job ${depId} already ${depJob.status}`);
193
+ return { error: `Dependency job ${depId} already ${depJob.status}. Cannot dispatch.` };
194
+ }
195
+ }
196
+
197
+ // Check concurrent job limit (only if we'll spawn immediately)
198
+ const hasUnmetDeps = depIds.some(id => {
199
+ const dep = jobManager.getJob(id);
200
+ return dep && dep.status !== 'completed';
201
+ });
202
+
203
+ if (!hasUnmetDeps) {
204
+ const running = jobManager.getRunningJobsForChat(chatId);
205
+ const maxConcurrent = config.swarm?.max_concurrent_jobs || 3;
206
+ logger.debug(`[dispatch_task] Running jobs: ${running.length}/${maxConcurrent} for chat ${chatId}`);
207
+ if (running.length >= maxConcurrent) {
208
+ logger.warn(`[dispatch_task] Rejected — concurrent limit reached: ${running.length}/${maxConcurrent} jobs for chat ${chatId}`);
209
+ return { error: `Maximum concurrent jobs (${maxConcurrent}) reached. Wait for a job to finish or cancel one.` };
210
+ }
181
211
  }
182
212
 
183
213
  // Pre-check credentials for the worker's tools
@@ -192,10 +222,25 @@ export async function executeOrchestratorTool(name, input, context) {
192
222
  }
193
223
  }
194
224
 
195
- // Create and spawn the job
225
+ // Create the job with context and dependencies
196
226
  const job = jobManager.createJob(chatId, worker_type, task);
227
+ job.context = taskContext || null;
228
+ job.dependsOn = depIds;
229
+ job.userId = user?.id || null;
197
230
  const workerConfig = WORKER_TYPES[worker_type];
198
231
 
232
+ // If dependencies are not all met, leave job queued (job:ready will spawn it later)
233
+ if (hasUnmetDeps) {
234
+ logger.info(`[dispatch_task] Job ${job.id} queued — waiting for dependencies: ${depIds.join(', ')}`);
235
+ return {
236
+ job_id: job.id,
237
+ worker_type,
238
+ status: 'queued_waiting',
239
+ depends_on: depIds,
240
+ message: `${workerConfig.emoji} ${workerConfig.label} queued — waiting for ${depIds.length} dependency job(s) to complete.`,
241
+ };
242
+ }
243
+
199
244
  logger.info(`[dispatch_task] Dispatching job ${job.id} — ${workerConfig.emoji} ${workerConfig.label}: "${task.slice(0, 100)}"`);
200
245
 
201
246
  // Fire and forget — spawnWorker handles lifecycle
@@ -224,14 +269,26 @@ export async function executeOrchestratorTool(name, input, context) {
224
269
  return { message: 'No jobs for this chat.' };
225
270
  }
226
271
  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
- })),
272
+ jobs: jobs.slice(0, 20).map((j) => {
273
+ const entry = {
274
+ id: j.id,
275
+ worker_type: j.workerType,
276
+ status: j.status,
277
+ task: j.task.slice(0, 100),
278
+ duration: j.duration,
279
+ recent_activity: j.progress.slice(-5),
280
+ last_activity_seconds_ago: j.lastActivity ? Math.round((Date.now() - j.lastActivity) / 1000) : null,
281
+ summary: j.toSummary(),
282
+ };
283
+ if (j.dependsOn.length > 0) entry.depends_on = j.dependsOn;
284
+ if (j.structuredResult) {
285
+ entry.result_summary = j.structuredResult.summary;
286
+ entry.result_status = j.structuredResult.status;
287
+ if (j.structuredResult.artifacts?.length > 0) entry.artifacts = j.structuredResult.artifacts;
288
+ if (j.structuredResult.followUp) entry.follow_up = j.structuredResult.followUp;
289
+ }
290
+ return entry;
291
+ }),
235
292
  };
236
293
  }
237
294
 
package/src/tools/os.js CHANGED
@@ -122,12 +122,15 @@ export const handlers = {
122
122
  }
123
123
 
124
124
  return new Promise((res) => {
125
+ let abortHandler = null;
126
+
125
127
  const child = exec(
126
128
  command,
127
129
  { timeout: timeout_seconds * 1000, maxBuffer: 10 * 1024 * 1024 },
128
130
  (error, stdout, stderr) => {
131
+ if (abortHandler && context.signal) context.signal.removeEventListener('abort', abortHandler);
129
132
  if (error && error.killed) {
130
- return res({ error: `Command timed out after ${timeout_seconds}s` });
133
+ return res({ error: `Command timed out or was cancelled after ${timeout_seconds}s` });
131
134
  }
132
135
  const result = {
133
136
  stdout: stdout || '',
@@ -147,6 +150,16 @@ export const handlers = {
147
150
  res(result);
148
151
  },
149
152
  );
153
+
154
+ // Wire abort signal to kill the child process
155
+ if (context.signal) {
156
+ if (context.signal.aborted) {
157
+ child.kill('SIGTERM');
158
+ } else {
159
+ abortHandler = () => child.kill('SIGTERM');
160
+ context.signal.addEventListener('abort', abortHandler, { once: true });
161
+ }
162
+ }
150
163
  });
151
164
  },
152
165
 
File without changes
File without changes
@@ -13,6 +13,7 @@ const DEFAULTS = {
13
13
  description: 'AI engineering agent with full OS control',
14
14
  },
15
15
  orchestrator: {
16
+ provider: 'anthropic',
16
17
  model: 'claude-opus-4-6',
17
18
  max_tokens: 2048,
18
19
  temperature: 0.3,
@@ -38,6 +39,7 @@ const DEFAULTS = {
38
39
  max_turns: 50,
39
40
  timeout_seconds: 600,
40
41
  workspace_dir: null, // defaults to ~/.kernelbot/workspaces
42
+ auth_mode: 'system', // system | api_key | oauth_token
41
43
  },
42
44
  github: {
43
45
  default_branch: 'main',
@@ -190,6 +192,84 @@ export function saveProviderToYaml(providerKey, modelId) {
190
192
  return configPath;
191
193
  }
192
194
 
195
+ /**
196
+ * Save orchestrator provider and model to config.yaml.
197
+ */
198
+ export function saveOrchestratorToYaml(providerKey, modelId) {
199
+ const configDir = getConfigDir();
200
+ mkdirSync(configDir, { recursive: true });
201
+ const configPath = join(configDir, 'config.yaml');
202
+
203
+ let existing = {};
204
+ if (existsSync(configPath)) {
205
+ existing = yaml.load(readFileSync(configPath, 'utf-8')) || {};
206
+ }
207
+
208
+ existing.orchestrator = {
209
+ ...(existing.orchestrator || {}),
210
+ provider: providerKey,
211
+ model: modelId,
212
+ };
213
+
214
+ writeFileSync(configPath, yaml.dump(existing, { lineWidth: -1 }));
215
+ return configPath;
216
+ }
217
+
218
+ /**
219
+ * Save Claude Code model to config.yaml.
220
+ */
221
+ export function saveClaudeCodeModelToYaml(modelId) {
222
+ const configDir = getConfigDir();
223
+ mkdirSync(configDir, { recursive: true });
224
+ const configPath = join(configDir, 'config.yaml');
225
+
226
+ let existing = {};
227
+ if (existsSync(configPath)) {
228
+ existing = yaml.load(readFileSync(configPath, 'utf-8')) || {};
229
+ }
230
+
231
+ existing.claude_code = {
232
+ ...(existing.claude_code || {}),
233
+ model: modelId,
234
+ };
235
+
236
+ writeFileSync(configPath, yaml.dump(existing, { lineWidth: -1 }));
237
+ return configPath;
238
+ }
239
+
240
+ /**
241
+ * Save Claude Code auth mode + credential to config.yaml and .env.
242
+ */
243
+ export function saveClaudeCodeAuth(config, mode, value) {
244
+ const configDir = getConfigDir();
245
+ mkdirSync(configDir, { recursive: true });
246
+ const configPath = join(configDir, 'config.yaml');
247
+
248
+ let existing = {};
249
+ if (existsSync(configPath)) {
250
+ existing = yaml.load(readFileSync(configPath, 'utf-8')) || {};
251
+ }
252
+
253
+ existing.claude_code = {
254
+ ...(existing.claude_code || {}),
255
+ auth_mode: mode,
256
+ };
257
+
258
+ writeFileSync(configPath, yaml.dump(existing, { lineWidth: -1 }));
259
+
260
+ // Update live config
261
+ config.claude_code.auth_mode = mode;
262
+
263
+ if (mode === 'api_key' && value) {
264
+ saveCredential(config, 'CLAUDE_CODE_API_KEY', value);
265
+ config.claude_code.api_key = value;
266
+ } else if (mode === 'oauth_token' && value) {
267
+ saveCredential(config, 'CLAUDE_CODE_OAUTH_TOKEN', value);
268
+ config.claude_code.oauth_token = value;
269
+ }
270
+ // mode === 'system' — no credentials to save
271
+ }
272
+
193
273
  /**
194
274
  * Full interactive flow: change brain model + optionally enter API key.
195
275
  */
@@ -329,8 +409,13 @@ export function loadConfig() {
329
409
 
330
410
  const config = deepMerge(DEFAULTS, fileConfig);
331
411
 
332
- // Orchestrator always uses Anthropic resolve its API key
333
- if (process.env.ANTHROPIC_API_KEY) {
412
+ // Orchestrator resolve API key based on configured provider
413
+ const orchProvider = PROVIDERS[config.orchestrator.provider];
414
+ if (orchProvider && process.env[orchProvider.envKey]) {
415
+ config.orchestrator.api_key = process.env[orchProvider.envKey];
416
+ }
417
+ // Legacy fallback: ANTHROPIC_API_KEY for anthropic orchestrator
418
+ if (config.orchestrator.provider === 'anthropic' && !config.orchestrator.api_key && process.env.ANTHROPIC_API_KEY) {
334
419
  config.orchestrator.api_key = process.env.ANTHROPIC_API_KEY;
335
420
  }
336
421
 
@@ -351,6 +436,16 @@ export function loadConfig() {
351
436
  if (!config.github) config.github = {};
352
437
  config.github.token = process.env.GITHUB_TOKEN;
353
438
  }
439
+ // ElevenLabs voice credentials
440
+ if (process.env.ELEVENLABS_API_KEY) {
441
+ if (!config.elevenlabs) config.elevenlabs = {};
442
+ config.elevenlabs.api_key = process.env.ELEVENLABS_API_KEY;
443
+ }
444
+ if (process.env.ELEVENLABS_VOICE_ID) {
445
+ if (!config.elevenlabs) config.elevenlabs = {};
446
+ config.elevenlabs.voice_id = process.env.ELEVENLABS_VOICE_ID;
447
+ }
448
+
354
449
  if (process.env.JIRA_BASE_URL || process.env.JIRA_EMAIL || process.env.JIRA_API_TOKEN) {
355
450
  if (!config.jira) config.jira = {};
356
451
  if (process.env.JIRA_BASE_URL) config.jira.base_url = process.env.JIRA_BASE_URL;
@@ -358,6 +453,14 @@ export function loadConfig() {
358
453
  if (process.env.JIRA_API_TOKEN) config.jira.api_token = process.env.JIRA_API_TOKEN;
359
454
  }
360
455
 
456
+ // Claude Code auth credentials from env
457
+ if (process.env.CLAUDE_CODE_API_KEY) {
458
+ config.claude_code.api_key = process.env.CLAUDE_CODE_API_KEY;
459
+ }
460
+ if (process.env.CLAUDE_CODE_OAUTH_TOKEN) {
461
+ config.claude_code.oauth_token = process.env.CLAUDE_CODE_OAUTH_TOKEN;
462
+ }
463
+
361
464
  return config;
362
465
  }
363
466
 
File without changes
File without changes