@inetafrica/open-claudia 2.14.8 → 3.0.0

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 (157) hide show
  1. package/.env.example +30 -4
  2. package/CHANGELOG.md +21 -0
  3. package/README.md +87 -66
  4. package/bin/agent.js +44 -18
  5. package/bin/cli.js +30 -28
  6. package/bin/dream.js +5 -11
  7. package/bin/loopback-client.js +29 -5
  8. package/bin/schedule.js +19 -5
  9. package/bin/tool.js +23 -5
  10. package/bot-agent.js +5 -2350
  11. package/bot.js +105 -7
  12. package/channels/telegram/adapter.js +6 -1
  13. package/channels/voice/adapter.js +1 -0
  14. package/channels/voice/manage.js +43 -5
  15. package/config-dir.js +3 -11
  16. package/core/actions.js +149 -40
  17. package/core/approvals.js +136 -29
  18. package/core/auth-flow.js +103 -19
  19. package/core/config-dir.js +19 -0
  20. package/core/config.js +127 -69
  21. package/core/doctor.js +111 -57
  22. package/core/dream.js +219 -62
  23. package/core/enforcer.js +0 -0
  24. package/core/entities.js +2 -1
  25. package/core/fsutil.js +55 -0
  26. package/core/handlers.js +503 -217
  27. package/core/ideas.js +2 -1
  28. package/core/identity.js +8 -11
  29. package/core/io.js +24 -3
  30. package/core/jobs.js +589 -72
  31. package/core/lessons.js +3 -2
  32. package/core/loopback.js +45 -8
  33. package/core/memory-mutation-queue.js +241 -0
  34. package/core/migration-backup.js +1060 -0
  35. package/core/pack-review.js +295 -88
  36. package/core/packs.js +4 -3
  37. package/core/persona.js +2 -1
  38. package/core/process-tree.js +19 -2
  39. package/core/provider-migration.js +39 -0
  40. package/core/provider-status.js +80 -0
  41. package/core/providers/claude-events.js +117 -0
  42. package/core/providers/claude-hook.js +114 -0
  43. package/core/providers/claude.js +296 -0
  44. package/core/providers/codex-events.js +121 -0
  45. package/core/providers/codex-hook.js +280 -0
  46. package/core/providers/codex.js +286 -0
  47. package/core/providers/contract.js +153 -0
  48. package/core/providers/env.js +148 -0
  49. package/core/providers/events.js +170 -0
  50. package/core/providers/hook-command.js +22 -0
  51. package/core/providers/index.js +240 -0
  52. package/core/providers/utility-policy.js +269 -0
  53. package/core/recall/classic.js +2 -2
  54. package/core/recall/discoverer.js +71 -22
  55. package/core/recall/metrics.js +27 -1
  56. package/core/recall/tuning.js +4 -1
  57. package/core/recall/warm-walker.js +151 -108
  58. package/core/recall-filter.js +86 -61
  59. package/core/router.js +79 -7
  60. package/core/run-context.js +185 -0
  61. package/core/runner.js +1415 -1282
  62. package/core/scheduler.js +515 -210
  63. package/core/side-chat.js +346 -0
  64. package/core/state.js +1096 -95
  65. package/core/subagent.js +72 -98
  66. package/core/system-prompt.js +462 -279
  67. package/core/tool-guard.js +73 -39
  68. package/core/tools.js +22 -5
  69. package/core/transcripts.js +30 -1
  70. package/core/usage-log.js +19 -4
  71. package/core/utility-agent.js +654 -0
  72. package/core/web-auth.js +29 -13
  73. package/core/web-sessions.js +78 -0
  74. package/docs/CHANNEL_DESIGN.md +181 -0
  75. package/docs/MULTI_CHANNEL_PLAN.md +254 -0
  76. package/docs/PROVIDER_MIGRATION.md +67 -0
  77. package/health.js +50 -39
  78. package/package.json +51 -4
  79. package/setup.js +198 -38
  80. package/soul.md +39 -0
  81. package/test-ability-extraction.js +5 -0
  82. package/test-approval-async.js +63 -4
  83. package/test-cursor-removal.js +334 -0
  84. package/test-delivery-contract.js +88 -0
  85. package/test-enforcer.js +70 -27
  86. package/test-fixtures/current-provider-parity.json +132 -0
  87. package/test-fixtures/fake-agent-cli.js +477 -0
  88. package/test-fixtures/migrations/claude-only/jobs.json +3 -0
  89. package/test-fixtures/migrations/claude-only/sessions.json +5 -0
  90. package/test-fixtures/migrations/claude-only/state.json +5 -0
  91. package/test-fixtures/migrations/cursor-selected/crons.json +3 -0
  92. package/test-fixtures/migrations/cursor-selected/jobs.json +3 -0
  93. package/test-fixtures/migrations/cursor-selected/sessions.json +5 -0
  94. package/test-fixtures/migrations/cursor-selected/state.json +6 -0
  95. package/test-fixtures/migrations/dual-provider/jobs.json +3 -0
  96. package/test-fixtures/migrations/dual-provider/sessions.json +7 -0
  97. package/test-fixtures/migrations/dual-provider/state.json +10 -0
  98. package/test-fixtures/migrations/malformed-partial/jobs.json +1 -0
  99. package/test-fixtures/migrations/malformed-partial/sessions.json +1 -0
  100. package/test-fixtures/migrations/malformed-partial/sessions.json.bak +3 -0
  101. package/test-fixtures/migrations/malformed-partial/state.json +1 -0
  102. package/test-fixtures/migrations/malformed-partial/state.json.bak +5 -0
  103. package/test-fixtures/migrations/missing-project/jobs.json +3 -0
  104. package/test-fixtures/migrations/missing-project/sessions.json +3 -0
  105. package/test-fixtures/migrations/missing-project/state.json +4 -0
  106. package/test-fixtures/migrations/multi-user/jobs.json +4 -0
  107. package/test-fixtures/migrations/multi-user/sessions.json +4 -0
  108. package/test-fixtures/migrations/multi-user/state.json +6 -0
  109. package/test-fixtures/provider-event-samples.json +17 -0
  110. package/test-learning-e2e.js +5 -0
  111. package/test-memory-mutation-queue.js +191 -0
  112. package/test-provider-boot-matrix.js +399 -0
  113. package/test-provider-bootstrap.js +158 -0
  114. package/test-provider-capabilities.js +232 -0
  115. package/test-provider-claude.js +206 -0
  116. package/test-provider-codex.js +228 -0
  117. package/test-provider-compaction.js +371 -0
  118. package/test-provider-core-prompt.js +264 -0
  119. package/test-provider-coupling-audit.js +90 -0
  120. package/test-provider-dream.js +312 -0
  121. package/test-provider-enforcer.js +252 -0
  122. package/test-provider-env-isolation.js +150 -0
  123. package/test-provider-events.js +141 -0
  124. package/test-provider-fixture.js +332 -0
  125. package/test-provider-gateway.js +508 -0
  126. package/test-provider-language.js +89 -0
  127. package/test-provider-migration-backup.js +424 -0
  128. package/test-provider-pack-review.js +436 -0
  129. package/test-provider-parity-e2e.js +537 -0
  130. package/test-provider-prompt-parity.js +89 -0
  131. package/test-provider-recall.js +271 -0
  132. package/test-provider-registry.js +251 -0
  133. package/test-provider-resume-parity.js +41 -0
  134. package/test-provider-run-lifecycle.js +349 -0
  135. package/test-provider-runner.js +271 -0
  136. package/test-provider-scheduler.js +689 -0
  137. package/test-provider-session-commands.js +185 -0
  138. package/test-provider-session-history.js +205 -0
  139. package/test-provider-side-chat.js +337 -0
  140. package/test-provider-state-migration.js +316 -0
  141. package/test-provider-stream-decoder.js +69 -0
  142. package/test-provider-subagent.js +228 -0
  143. package/test-provider-tool-hooks.js +360 -0
  144. package/test-recall-discoverer.js +1 -0
  145. package/test-recall-engine.js +3 -3
  146. package/test-recall-evolution.js +18 -0
  147. package/test-run-lock.js +63 -0
  148. package/test-runner-watchdog-static.js +16 -8
  149. package/test-single-runtime.js +35 -0
  150. package/test-telegram-poll-recovery.js +56 -0
  151. package/test-tool-guard.js +56 -0
  152. package/test-tools.js +19 -0
  153. package/test-unified-identity.js +3 -1
  154. package/test-usage-accounting.js +92 -20
  155. package/test-utility-provider-policy.js +486 -0
  156. package/test-web-sessions.js +74 -0
  157. package/web.js +159 -45
package/web.js CHANGED
@@ -4,12 +4,16 @@ const path = require("path");
4
4
  const crypto = require("crypto");
5
5
  const https = require("https");
6
6
  const CONFIG_DIR = require("./config-dir");
7
+ const { ensureConfigDir } = require("./core/config-dir");
8
+ const { atomicWriteFileSync } = require("./core/fsutil");
7
9
  const Vault = require("./vault");
8
10
  const {
9
11
  loadUsageHistory,
10
12
  usageAlertPolicy,
11
13
  usageTrend,
12
14
  } = require("./core/usage-log");
15
+ const webSessions = require("./core/web-sessions");
16
+ const { inspectProviderConfiguration } = require("./core/provider-status");
13
17
 
14
18
  const ENV_FILE = path.join(CONFIG_DIR, ".env");
15
19
  const AUTH_FILE = path.join(CONFIG_DIR, "auth.json");
@@ -28,7 +32,7 @@ function getPassword() {
28
32
  }
29
33
  // Use env-provided password (from K8s secret) or generate random
30
34
  const initial = process.env.WEB_PASSWORD || crypto.randomBytes(16).toString("hex");
31
- fs.writeFileSync(WEB_PASSWORD_FILE, initial);
35
+ atomicWriteFileSync(WEB_PASSWORD_FILE, initial, { mode: 0o600 });
32
36
  if (!process.env.WEB_PASSWORD) {
33
37
  console.log(`\n Web UI initial password: ${initial}`);
34
38
  console.log(` Change it in Settings after first login.\n`);
@@ -56,8 +60,8 @@ function validatePasswordComplexity(pw) {
56
60
  }
57
61
 
58
62
  function setPassword(newPassword) {
59
- fs.writeFileSync(WEB_PASSWORD_FILE, newPassword);
60
- fs.writeFileSync(PASSWORD_CHANGED_FILE, new Date().toISOString());
63
+ atomicWriteFileSync(WEB_PASSWORD_FILE, newPassword, { backup: true, mode: 0o600 });
64
+ atomicWriteFileSync(PASSWORD_CHANGED_FILE, new Date().toISOString(), { mode: 0o600 });
61
65
  notifyAgentSpacePasswordChanged().then((r) => {
62
66
  if (r && r.ok) {
63
67
  try { fs.writeFileSync(PASSWORD_CHANGED_NOTIFIED_FILE, new Date().toISOString()); } catch (e) {}
@@ -136,12 +140,7 @@ function checkAuth(req) {
136
140
  const cookie = (req.headers.cookie || "").split(";").find((c) => c.trim().startsWith("oc_session="));
137
141
  if (!cookie) return false;
138
142
  const token = cookie.split("=")[1]?.trim();
139
- const expected = crypto.createHash("sha256").update(getPassword()).digest("hex");
140
- return token === expected;
141
- }
142
-
143
- function authToken() {
144
- return crypto.createHash("sha256").update(getPassword()).digest("hex");
143
+ return webSessions.valid(token);
145
144
  }
146
145
 
147
146
  // ── Config helpers ─────────────────────────────────────────────────
@@ -158,7 +157,27 @@ function loadEnv() {
158
157
 
159
158
  function saveEnv(env) {
160
159
  const content = Object.entries(env).map(([k, v]) => `${k}=${v}`).join("\n");
161
- fs.writeFileSync(ENV_FILE, content);
160
+ atomicWriteFileSync(ENV_FILE, content, { backup: true, mode: 0o600 });
161
+ }
162
+
163
+ // GET /api/config used to return the raw .env — every bot token, model API key
164
+ // and control-plane secret in plaintext to anyone with a dashboard session. The
165
+ // config UI only ever edits the non-secret SAFE_KEYS, so mask any secret-looking
166
+ // value before it leaves the server (keeping a 4-char tail so the owner can still
167
+ // tell a value is set / which one it is). Masked values can never round-trip back
168
+ // in: the POST handler rejects every key outside SAFE_KEYS.
169
+ const SECRET_KEY_RE = /(TOKEN|SECRET|PASSWORD|API_?KEY|_KEY|CREDENTIAL|WEBHOOK|SESSION|PRIVATE)/i;
170
+ function maskEnv(env) {
171
+ const out = {};
172
+ for (const [k, v] of Object.entries(env)) {
173
+ if (v && SECRET_KEY_RE.test(k)) {
174
+ const s = String(v);
175
+ out[k] = s.length <= 8 ? "••••••••" : `••••${s.slice(-4)}`;
176
+ } else {
177
+ out[k] = v;
178
+ }
179
+ }
180
+ return out;
162
181
  }
163
182
 
164
183
  function loadAuth() {
@@ -166,7 +185,7 @@ function loadAuth() {
166
185
  }
167
186
 
168
187
  function saveAuth(auth) {
169
- fs.writeFileSync(AUTH_FILE, JSON.stringify(auth, null, 2));
188
+ atomicWriteFileSync(AUTH_FILE, JSON.stringify(auth, null, 2), { backup: true, mode: 0o600 });
170
189
  }
171
190
 
172
191
  function loadSoul() {
@@ -180,12 +199,27 @@ function loadCrons() {
180
199
  try {
181
200
  const jobs = JSON.parse(fs.readFileSync(JOBS_FILE, "utf-8"));
182
201
  if (Array.isArray(jobs)) return jobs.filter((j) => j && j.kind === "cron");
202
+ if (jobs && Array.isArray(jobs.jobs)) return jobs.jobs.filter((j) => j && j.kind === "cron" && !j.disabled);
183
203
  } catch (e) { /* fall through to legacy */ }
184
204
  try { return JSON.parse(fs.readFileSync(CRONS_FILE, "utf-8")); } catch (e) { return []; }
185
205
  }
186
206
 
187
207
  function loadSessions() {
188
- try { return JSON.parse(fs.readFileSync(SESSIONS_FILE, "utf-8")); } catch (e) { return {}; }
208
+ let document;
209
+ try { document = JSON.parse(fs.readFileSync(SESSIONS_FILE, "utf-8")); } catch (e) { return []; }
210
+ const rows = [];
211
+ if (document && document.users && typeof document.users === "object") {
212
+ for (const [userId, projects] of Object.entries(document.users)) {
213
+ for (const [project, sessions] of Object.entries(projects || {})) {
214
+ for (const session of Array.isArray(sessions) ? sessions : []) rows.push({ userId, project, ...session });
215
+ }
216
+ }
217
+ return rows;
218
+ }
219
+ for (const [project, sessions] of Object.entries(document || {})) {
220
+ for (const session of Array.isArray(sessions) ? sessions : []) rows.push({ userId: null, project, ...session });
221
+ }
222
+ return rows;
189
223
  }
190
224
 
191
225
  function loadLogs(lines = 100) {
@@ -200,6 +234,17 @@ function isConfigured() {
200
234
  return fs.existsSync(ENV_FILE);
201
235
  }
202
236
 
237
+ function providerConfigurationStatus(options = {}) {
238
+ const env = loadEnv();
239
+ return inspectProviderConfiguration({
240
+ ...options,
241
+ env: { ...process.env, ...env, ...(options.env || {}) },
242
+ defaultProvider: options.defaultProvider === undefined
243
+ ? (env.DEFAULT_PROVIDER || null)
244
+ : options.defaultProvider,
245
+ });
246
+ }
247
+
203
248
  // Group usage records by a key (version / model) and return per-group
204
249
  // averages so the dashboard can compare tokenomics across versions.
205
250
  function aggregateUsage(records, keyName) {
@@ -246,7 +291,7 @@ function telegramGet(token, method) {
246
291
 
247
292
  // ── API routes ─────────────────────────────────────────────────────
248
293
 
249
- async function handleAPI(req, res, body) {
294
+ async function handleAPI(req, res, body, dependencies = {}) {
250
295
  const url = req.url;
251
296
 
252
297
  // Login — no auth required
@@ -255,7 +300,7 @@ async function handleAPI(req, res, body) {
255
300
  if (password === getPassword()) {
256
301
  res.writeHead(200, {
257
302
  "Content-Type": "application/json",
258
- "Set-Cookie": `oc_session=${authToken()}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`,
303
+ "Set-Cookie": `oc_session=${webSessions.issue()}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`,
259
304
  });
260
305
  return res.end(JSON.stringify({ ok: true, mustChangePassword: !isPasswordChanged() }));
261
306
  }
@@ -291,14 +336,16 @@ async function handleAPI(req, res, body) {
291
336
  if (url === "/api/status") {
292
337
  const env = loadEnv();
293
338
  const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "package.json"), "utf-8"));
339
+ const providerStatus = providerConfigurationStatus();
294
340
  res.writeHead(200, { "Content-Type": "application/json" });
295
341
  return res.end(JSON.stringify({
296
342
  configured: isConfigured(),
297
343
  version: pkg.version,
298
344
  workspace: env.WORKSPACE || "",
299
- botToken: env.TELEGRAM_BOT_TOKEN ? "***" + env.TELEGRAM_BOT_TOKEN.slice(-8) : "",
345
+ botToken: env.TELEGRAM_BOT_TOKEN ? "***" + env.TELEGRAM_BOT_TOKEN.slice(-4) : "",
300
346
  chatId: env.TELEGRAM_CHAT_ID || "",
301
- claudePath: env.CLAUDE_PATH || "",
347
+ providers: providerStatus.providers,
348
+ defaultProvider: providerStatus.defaultProvider,
302
349
  onboarded: env.ONBOARDED === "true",
303
350
  }));
304
351
  }
@@ -308,28 +355,44 @@ async function handleAPI(req, res, body) {
308
355
  const data = JSON.parse(body);
309
356
 
310
357
  // Verify token
311
- const botInfo = await telegramGet(data.botToken, "getMe");
358
+ const telegramProbe = dependencies.telegramGet || telegramGet;
359
+ const botInfo = await telegramProbe(data.botToken, "getMe");
312
360
  if (!botInfo.ok) {
313
361
  res.writeHead(400, { "Content-Type": "application/json" });
314
362
  return res.end(JSON.stringify({ error: "Invalid Telegram bot token" }));
315
363
  }
316
364
 
365
+ const existingEnv = loadEnv();
366
+ const requestedDefault = String(data.defaultProvider || "").trim().toLowerCase();
367
+ const requestedUtility = String(data.utilityProvider || "").trim().toLowerCase();
317
368
  const env = {
369
+ ...existingEnv,
318
370
  TELEGRAM_BOT_TOKEN: data.botToken,
319
371
  TELEGRAM_CHAT_ID: data.chatId,
320
372
  WORKSPACE: data.workspace || path.join(CONFIG_DIR, "Workspace"),
321
- CLAUDE_PATH: data.claudePath || "claude",
322
- ANTHROPIC_API_KEY: data.apiKey || "",
323
- WHISPER_CLI: "",
324
- WHISPER_MODEL: "",
325
- FFMPEG: "",
373
+ CLAUDE_PATH: String(data.claudePath || "").trim() || existingEnv.CLAUDE_PATH || "",
374
+ CODEX_PATH: String(data.codexPath || "").trim() || existingEnv.CODEX_PATH || "",
375
+ DEFAULT_PROVIDER: new Set(["claude", "codex"]).has(requestedDefault)
376
+ ? requestedDefault
377
+ : (existingEnv.DEFAULT_PROVIDER || ""),
378
+ UTILITY_PROVIDER: new Set(["active", "claude", "codex"]).has(requestedUtility)
379
+ ? requestedUtility
380
+ : (existingEnv.UTILITY_PROVIDER || "active"),
381
+ PROVIDER_FALLBACKS: String(data.providerFallbacks || "").trim() || existingEnv.PROVIDER_FALLBACKS || "",
382
+ WHISPER_CLI: existingEnv.WHISPER_CLI || "",
383
+ WHISPER_MODEL: existingEnv.WHISPER_MODEL || "",
384
+ FFMPEG: existingEnv.FFMPEG || "",
326
385
  VAULT_FILE: path.join(CONFIG_DIR, "vault.enc"),
327
386
  SOUL_FILE: SOUL_FILE,
328
387
  CRONS_FILE: CRONS_FILE,
329
388
  AUTH_FILE: AUTH_FILE,
330
389
  ONBOARDED: "false",
331
390
  };
391
+ if (data.apiKey) env.ANTHROPIC_API_KEY = data.apiKey;
332
392
  saveEnv(env);
393
+ const runtimeConfig = require("./core/config");
394
+ Object.assign(runtimeConfig.config, env);
395
+ require("./core/providers").registry.defaultProvider = env.DEFAULT_PROVIDER || null;
333
396
 
334
397
  // Create workspace dir
335
398
  const wsDir = env.WORKSPACE;
@@ -357,16 +420,25 @@ async function handleAPI(req, res, body) {
357
420
  }
358
421
 
359
422
  // Get config
360
- if (url === "/api/config") {
423
+ if (url === "/api/config" && req.method === "GET") {
361
424
  const env = loadEnv();
362
425
  res.writeHead(200, { "Content-Type": "application/json" });
363
- return res.end(JSON.stringify(env));
426
+ return res.end(JSON.stringify(maskEnv(env)));
364
427
  }
365
428
 
366
429
  // Update config (whitelist safe keys only)
367
430
  if (url === "/api/config" && req.method === "POST") {
368
431
  const SAFE_KEYS = new Set([
369
- "WORKSPACE", "CLAUDE_PATH", "WHISPER_CLI", "FFMPEG", "WHISPER_MODEL",
432
+ "WORKSPACE", "CLAUDE_PATH", "CODEX_PATH", "DEFAULT_PROVIDER",
433
+ "UTILITY_PROVIDER", "PROVIDER_FALLBACKS",
434
+ "DREAM_PROVIDER", "MEMORY_PROVIDER", "RECALL_PROVIDER", "ENFORCER_PROVIDER", "SUBAGENT_PROVIDER",
435
+ "CLAUDE_MODEL", "CODEX_MODEL",
436
+ "DREAM_MODEL_CLAUDE", "DREAM_MODEL_CODEX",
437
+ "PACK_REVIEW_MODEL_CLAUDE", "PACK_REVIEW_MODEL_CODEX",
438
+ "RECALL_MODEL_CLAUDE", "RECALL_MODEL_CODEX",
439
+ "ENFORCER_MODEL_CLAUDE", "ENFORCER_MODEL_CODEX",
440
+ "SUBAGENT_MODEL_CLAUDE", "SUBAGENT_MODEL_CODEX",
441
+ "WHISPER_CLI", "FFMPEG", "WHISPER_MODEL",
370
442
  "MEMORY_RECALL_MAX_CHARS",
371
443
  "USAGE_ALERT_CONTEXT_TOKENS", "USAGE_ALERT_RATE_MULTIPLIER",
372
444
  "USAGE_ALERT_BASELINE_TURNS", "USAGE_ALERT_MIN_BASELINE_TURNS",
@@ -379,6 +451,20 @@ async function handleAPI(req, res, body) {
379
451
  res.writeHead(403, { "Content-Type": "application/json" });
380
452
  return res.end(JSON.stringify({ error: `Cannot update key: ${key}` }));
381
453
  }
454
+ const normalized = String(value == null ? "" : value).trim().toLowerCase();
455
+ if (key === "DEFAULT_PROVIDER" && normalized && !new Set(["claude", "codex"]).has(normalized)) {
456
+ res.writeHead(400, { "Content-Type": "application/json" });
457
+ return res.end(JSON.stringify({ error: "DEFAULT_PROVIDER must be claude, codex, or blank" }));
458
+ }
459
+ if (["UTILITY_PROVIDER", "DREAM_PROVIDER", "MEMORY_PROVIDER", "RECALL_PROVIDER", "ENFORCER_PROVIDER", "SUBAGENT_PROVIDER"].includes(key)
460
+ && normalized && !new Set(["active", "claude", "codex"]).has(normalized)) {
461
+ res.writeHead(400, { "Content-Type": "application/json" });
462
+ return res.end(JSON.stringify({ error: `${key} must be active, claude, codex, or blank` }));
463
+ }
464
+ if (key === "PROVIDER_FALLBACKS" && normalized.split(",").filter(Boolean).some((id) => !new Set(["claude", "codex"]).has(id.trim()))) {
465
+ res.writeHead(400, { "Content-Type": "application/json" });
466
+ return res.end(JSON.stringify({ error: "PROVIDER_FALLBACKS accepts only claude and codex" }));
467
+ }
382
468
  env[key] = value;
383
469
  }
384
470
  saveEnv(env);
@@ -431,7 +517,7 @@ async function handleAPI(req, res, body) {
431
517
  }
432
518
 
433
519
  // Soul
434
- if (url === "/api/soul") {
520
+ if (url === "/api/soul" && req.method === "GET") {
435
521
  res.writeHead(200, { "Content-Type": "application/json" });
436
522
  return res.end(JSON.stringify({ content: loadSoul() }));
437
523
  }
@@ -502,9 +588,12 @@ async function handleAPI(req, res, body) {
502
588
  return res.end(JSON.stringify({ error: complexityError }));
503
589
  }
504
590
  setPassword(newPassword);
591
+ // Changing the password invalidates every outstanding session, then we
592
+ // re-issue one so the caller who just changed it stays logged in.
593
+ webSessions.revokeAll();
505
594
  res.writeHead(200, {
506
595
  "Content-Type": "application/json",
507
- "Set-Cookie": `oc_session=${authToken()}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`,
596
+ "Set-Cookie": `oc_session=${webSessions.issue()}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`,
508
597
  });
509
598
  return res.end(JSON.stringify({ ok: true }));
510
599
  }
@@ -705,7 +794,8 @@ async function loadTab() {
705
794
  <div class="list-item"><span>Workspace</span><span>\${status.workspace}</span></div>
706
795
  <div class="list-item"><span>Bot Token</span><span>\${status.botToken}</span></div>
707
796
  <div class="list-item"><span>Chat ID</span><span>\${status.chatId}</span></div>
708
- <div class="list-item"><span>Claude</span><span>\${status.claudePath}</span></div>
797
+ <div class="list-item"><span>Default provider</span><span>\${status.defaultProvider?.label || "None (model turns unavailable)"}</span></div>
798
+ \${(status.providers || []).map(p => '<div class="list-item"><span>' + p.label + '</span><span>' + p.availability.state + ' · ' + p.compatibility.state + ' · auth ' + p.auth.state + '</span></div>').join("")}
709
799
  </div>\`;
710
800
  }
711
801
  else if (currentTab === "usage") {
@@ -770,7 +860,7 @@ async function loadTab() {
770
860
  }
771
861
  else if (currentTab === "credentials") {
772
862
  el.innerHTML = \`
773
- <div class="card" id="cred-status"><h2>Credentials</h2><p class="subtitle">Loading status…</p></div>
863
+ <div class="card" id="cred-status"><h2>Provider credentials</h2><p class="subtitle">Authenticate the coding-agent providers you use. Each provider keeps its own credentials.</p></div>
774
864
  <div class="card">
775
865
  <h2>Claude</h2>
776
866
  <button onclick="startClaudeAuth()">Authenticate via browser</button>
@@ -824,18 +914,14 @@ async function loadTab() {
824
914
  else if (currentTab === "sessions") {
825
915
  const sessions = await api("/api/sessions");
826
916
  if (!sessions) return;
827
- const projects = Object.keys(sessions);
828
917
  el.innerHTML = \`
829
918
  <div class="card">
830
919
  <h2>Conversation History</h2>
831
- \${projects.map(p => \`
832
- <h2 style="font-size:14px;color:#6366f1;margin:16px 0 8px;">\${p}</h2>
833
- \${sessions[p].slice().reverse().slice(0,5).map(s => \`
834
- <div class="list-item">
835
- <span>\${s.title}</span>
836
- <span style="color:#666;font-size:12px;">\${new Date(s.lastUsed).toLocaleDateString()}</span>
837
- </div>\`).join("")}
838
- \`).join("") || "<p>No sessions yet.</p>"}
920
+ \${sessions.slice().reverse().slice(0,25).map(s => \`
921
+ <div class="list-item">
922
+ <span>\${s.title || s.id} · \${s.provider || "legacy"} · \${s.project}</span>
923
+ <span style="color:#666;font-size:12px;">\${s.lastUsed ? new Date(s.lastUsed).toLocaleDateString() : "unknown"}</span>
924
+ </div>\`).join("") || "<p>No sessions yet.</p>"}
839
925
  </div>\`;
840
926
  }
841
927
  else if (currentTab === "logs") {
@@ -871,7 +957,18 @@ async function loadTab() {
871
957
  const groups = [
872
958
  { title: "General", fields: [
873
959
  { k: "WORKSPACE", label: "Workspace folder", desc: "Root directory where the bot reads and writes project files.", ph: "/data/Workspace" },
960
+ ]},
961
+ { title: "Providers", fields: [
874
962
  { k: "CLAUDE_PATH", label: "Claude CLI path", desc: "Path or command name for the Claude Code CLI.", ph: "claude" },
963
+ { k: "CODEX_PATH", label: "Codex CLI path", desc: "Path or command name for the OpenAI Codex CLI.", ph: "codex" },
964
+ { k: "DEFAULT_PROVIDER", label: "Default provider", desc: "claude or codex; blank chooses the first compatible provider deterministically.", ph: "claude" },
965
+ { k: "UTILITY_PROVIDER", label: "Utility provider", desc: "active, claude, or codex for background intelligence.", ph: "active" },
966
+ { k: "PROVIDER_FALLBACKS", label: "Utility fallbacks", desc: "Ordered comma-separated fallback providers. Foreground turns never use this list.", ph: "codex,claude" },
967
+ { k: "DREAM_PROVIDER", label: "Dream provider", desc: "Optional per-purpose override.", ph: "codex" },
968
+ { k: "MEMORY_PROVIDER", label: "Memory-review provider", desc: "Optional per-purpose override.", ph: "active" },
969
+ { k: "RECALL_PROVIDER", label: "Recall provider", desc: "Optional per-purpose override.", ph: "active" },
970
+ { k: "ENFORCER_PROVIDER", label: "Guard provider", desc: "Optional fail-closed relationship-guard provider.", ph: "claude" },
971
+ { k: "SUBAGENT_PROVIDER", label: "Sub-agent provider", desc: "Optional /agent provider override.", ph: "active" },
875
972
  ]},
876
973
  { title: "Voice transcription", fields: [
877
974
  { k: "WHISPER_CLI", label: "Whisper CLI", desc: "Path to the whisper binary for transcribing voice notes. Leave blank to disable voice.", ph: "whisper" },
@@ -975,7 +1072,7 @@ async function loadCredStatus() {
975
1072
  const s = await api("/api/auth/status");
976
1073
  if (!s) return;
977
1074
  const yn = (b) => b ? "<span style='color:#22c55e'>yes</span>" : "<span style='color:#dc2626'>no</span>";
978
- $("#cred-status").innerHTML = \`<h2>Credentials</h2>
1075
+ $("#cred-status").innerHTML = \`<h2>Provider credentials</h2>
979
1076
  <div class="list-item"><span>Claude OAuth token saved</span><span>\${yn(s.claude.configured)}</span></div>
980
1077
  <div class="list-item"><span>Claude logged in</span><span>\${yn(s.claude.loggedIn)}</span></div>
981
1078
  <div class="list-item"><span>Codex CLI present</span><span>\${yn(s.codex.cliFound)}</span></div>
@@ -1076,16 +1173,25 @@ async function saveConfig() {
1076
1173
  }
1077
1174
 
1078
1175
  function renderSetup() {
1176
+ const providerRows = (status.providers || []).map(p =>
1177
+ '<div class="list-item"><span>' + p.label + '</span><span>' + p.availability.state + ' · ' + p.compatibility.state + ' · auth ' + p.auth.state + '</span></div>' +
1178
+ (p.recovery ? '<div style="color:#888;font-size:11px;margin:-8px 0 10px;">' + p.recovery.split("\\n")[0] + '</div>' : "")
1179
+ ).join("");
1079
1180
  $("#app").innerHTML = \`
1080
1181
  <div class="container">
1081
1182
  <h1>${botName} Setup</h1>
1082
- <p class="subtitle">Configure your AI coding assistant</p>
1183
+ <p class="subtitle">Configure channels and coding-agent providers</p>
1083
1184
  <div id="setup-msg"></div>
1185
+ <div class="card"><h2>Provider status</h2>\${providerRows || "<p>No provider detected. You can still complete setup and configure one later.</p>"}</div>
1084
1186
  <div class="card">
1085
1187
  <div class="form-group"><label>Telegram Bot Token (from @BotFather)</label><input id="s-token" placeholder="123456:ABC-DEF..."></div>
1086
1188
  <div class="form-group"><label>Your Telegram Chat ID</label><input id="s-chatid" placeholder="e.g. 6251055967"></div>
1087
1189
  <div class="form-group"><label>Anthropic API Key (or leave blank if Claude CLI is authed)</label><input id="s-apikey" type="password" placeholder="sk-ant-..."></div>
1088
- <div class="form-group"><label>Claude CLI Path (auto-detected if on PATH)</label><input id="s-claude" placeholder="claude" value="claude"></div>
1190
+ <div class="form-group"><label>Claude CLI Path (optional; blank uses PATH discovery)</label><input id="s-claude" placeholder="claude"></div>
1191
+ <div class="form-group"><label>Codex CLI Path (optional; blank uses PATH discovery)</label><input id="s-codex" placeholder="codex"></div>
1192
+ <div class="form-group"><label>Default Provider (optional)</label><select id="s-default"><option value="">Auto-detect</option><option value="claude">Claude Code</option><option value="codex">OpenAI Codex</option></select></div>
1193
+ <div class="form-group"><label>Utility Provider</label><select id="s-utility"><option value="active">Active provider</option><option value="claude">Claude Code</option><option value="codex">OpenAI Codex</option></select></div>
1194
+ <div class="form-group"><label>Utility Fallbacks (optional)</label><input id="s-fallbacks" placeholder="codex,claude"></div>
1089
1195
  <div class="form-group"><label>Workspace Path</label><input id="s-workspace" placeholder="Leave blank for default"></div>
1090
1196
  <button onclick="doSetup()" style="width:100%">Complete Setup</button>
1091
1197
  </div>
@@ -1098,6 +1204,10 @@ async function doSetup() {
1098
1204
  chatId: $("#s-chatid").value,
1099
1205
  apiKey: $("#s-apikey").value,
1100
1206
  claudePath: $("#s-claude").value,
1207
+ codexPath: $("#s-codex").value,
1208
+ defaultProvider: $("#s-default").value,
1209
+ utilityProvider: $("#s-utility").value,
1210
+ providerFallbacks: $("#s-fallbacks").value,
1101
1211
  workspace: $("#s-workspace").value,
1102
1212
  }});
1103
1213
  if (r?.ok) {
@@ -1119,8 +1229,9 @@ init();
1119
1229
 
1120
1230
  let webServer = null;
1121
1231
 
1122
- function startWebServer() {
1232
+ function startWebServer(options = {}) {
1123
1233
  if (webServer) return webServer;
1234
+ ensureConfigDir(CONFIG_DIR);
1124
1235
 
1125
1236
  const server = http.createServer(async (req, res) => {
1126
1237
  // CORS for local dev
@@ -1137,7 +1248,7 @@ function startWebServer() {
1137
1248
  if (consume(token)) {
1138
1249
  res.writeHead(302, {
1139
1250
  "Location": "/",
1140
- "Set-Cookie": `oc_session=${authToken()}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`,
1251
+ "Set-Cookie": `oc_session=${webSessions.issue()}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`,
1141
1252
  });
1142
1253
  return res.end();
1143
1254
  }
@@ -1148,7 +1259,7 @@ function startWebServer() {
1148
1259
  if (req.url.startsWith("/api/")) {
1149
1260
  let body = "";
1150
1261
  req.on("data", (d) => { body += d; });
1151
- req.on("end", () => handleAPI(req, res, body));
1262
+ req.on("end", () => handleAPI(req, res, body, options));
1152
1263
  return;
1153
1264
  }
1154
1265
 
@@ -1162,9 +1273,12 @@ function startWebServer() {
1162
1273
  console.log("Admin password configured.");
1163
1274
  reconcileGrandfatheredPasswordChange();
1164
1275
  });
1276
+ server.once("close", () => {
1277
+ if (webServer === server) webServer = null;
1278
+ });
1165
1279
 
1166
1280
  webServer = server;
1167
1281
  return server;
1168
1282
  }
1169
1283
 
1170
- module.exports = { startWebServer, isConfigured };
1284
+ module.exports = { startWebServer, isConfigured, providerConfigurationStatus, handleAPI };