@inetafrica/open-claudia 2.15.0 → 3.0.1

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 (152) hide show
  1. package/.env.example +30 -4
  2. package/CHANGELOG.md +22 -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 +81 -3
  12. package/channels/telegram/adapter.js +65 -5
  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 +155 -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 +115 -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 +21 -4
  26. package/core/handlers.js +542 -224
  27. package/core/ideas.js +2 -1
  28. package/core/jobs.js +589 -72
  29. package/core/lessons.js +3 -2
  30. package/core/loopback.js +42 -6
  31. package/core/memory-mutation-queue.js +241 -0
  32. package/core/migration-backup.js +1060 -0
  33. package/core/pack-review.js +295 -88
  34. package/core/packs.js +4 -3
  35. package/core/persona.js +2 -1
  36. package/core/process-tree.js +19 -2
  37. package/core/provider-migration.js +39 -0
  38. package/core/provider-status.js +80 -0
  39. package/core/providers/claude-events.js +121 -0
  40. package/core/providers/claude-hook.js +114 -0
  41. package/core/providers/claude.js +296 -0
  42. package/core/providers/codex-events.js +125 -0
  43. package/core/providers/codex-hook.js +280 -0
  44. package/core/providers/codex.js +286 -0
  45. package/core/providers/contract.js +153 -0
  46. package/core/providers/env.js +148 -0
  47. package/core/providers/events.js +171 -0
  48. package/core/providers/hook-command.js +22 -0
  49. package/core/providers/index.js +240 -0
  50. package/core/providers/utility-policy.js +269 -0
  51. package/core/recall/classic.js +2 -2
  52. package/core/recall/discoverer.js +71 -22
  53. package/core/recall/metrics.js +27 -1
  54. package/core/recall/tuning.js +4 -1
  55. package/core/recall/warm-walker.js +151 -108
  56. package/core/recall-filter.js +86 -61
  57. package/core/router.js +79 -7
  58. package/core/run-context.js +185 -0
  59. package/core/runner.js +1562 -1286
  60. package/core/scheduler.js +515 -210
  61. package/core/side-chat.js +346 -0
  62. package/core/single-instance.js +46 -0
  63. package/core/state.js +1084 -97
  64. package/core/subagent.js +72 -98
  65. package/core/system-prompt.js +462 -279
  66. package/core/tool-guard.js +73 -39
  67. package/core/tools.js +22 -5
  68. package/core/transcripts.js +30 -1
  69. package/core/usage-log.js +19 -4
  70. package/core/utility-agent.js +654 -0
  71. package/core/web-auth.js +29 -13
  72. package/docs/CHANNEL_DESIGN.md +181 -0
  73. package/docs/MULTI_CHANNEL_PLAN.md +254 -0
  74. package/docs/PROVIDER_MIGRATION.md +67 -0
  75. package/health.js +50 -39
  76. package/package.json +48 -4
  77. package/setup.js +198 -38
  78. package/soul.md +39 -0
  79. package/test-ability-extraction.js +5 -0
  80. package/test-approval-async.js +63 -4
  81. package/test-cursor-removal.js +337 -0
  82. package/test-enforcer.js +70 -27
  83. package/test-fixtures/current-provider-parity.json +132 -0
  84. package/test-fixtures/fake-agent-cli.js +509 -0
  85. package/test-fixtures/migrations/claude-only/jobs.json +3 -0
  86. package/test-fixtures/migrations/claude-only/sessions.json +5 -0
  87. package/test-fixtures/migrations/claude-only/state.json +5 -0
  88. package/test-fixtures/migrations/cursor-selected/crons.json +3 -0
  89. package/test-fixtures/migrations/cursor-selected/jobs.json +3 -0
  90. package/test-fixtures/migrations/cursor-selected/sessions.json +5 -0
  91. package/test-fixtures/migrations/cursor-selected/state.json +6 -0
  92. package/test-fixtures/migrations/dual-provider/jobs.json +3 -0
  93. package/test-fixtures/migrations/dual-provider/sessions.json +7 -0
  94. package/test-fixtures/migrations/dual-provider/state.json +10 -0
  95. package/test-fixtures/migrations/malformed-partial/jobs.json +1 -0
  96. package/test-fixtures/migrations/malformed-partial/sessions.json +1 -0
  97. package/test-fixtures/migrations/malformed-partial/sessions.json.bak +3 -0
  98. package/test-fixtures/migrations/malformed-partial/state.json +1 -0
  99. package/test-fixtures/migrations/malformed-partial/state.json.bak +5 -0
  100. package/test-fixtures/migrations/missing-project/jobs.json +3 -0
  101. package/test-fixtures/migrations/missing-project/sessions.json +3 -0
  102. package/test-fixtures/migrations/missing-project/state.json +4 -0
  103. package/test-fixtures/migrations/multi-user/jobs.json +4 -0
  104. package/test-fixtures/migrations/multi-user/sessions.json +4 -0
  105. package/test-fixtures/migrations/multi-user/state.json +6 -0
  106. package/test-fixtures/provider-event-samples.json +17 -0
  107. package/test-learning-e2e.js +5 -0
  108. package/test-memory-mutation-queue.js +191 -0
  109. package/test-provider-boot-matrix.js +399 -0
  110. package/test-provider-bootstrap.js +158 -0
  111. package/test-provider-capabilities.js +232 -0
  112. package/test-provider-claude.js +206 -0
  113. package/test-provider-codex.js +228 -0
  114. package/test-provider-compaction.js +371 -0
  115. package/test-provider-core-prompt.js +264 -0
  116. package/test-provider-coupling-audit.js +90 -0
  117. package/test-provider-dream.js +312 -0
  118. package/test-provider-enforcer.js +252 -0
  119. package/test-provider-env-isolation.js +150 -0
  120. package/test-provider-events.js +171 -0
  121. package/test-provider-fixture.js +332 -0
  122. package/test-provider-gateway.js +508 -0
  123. package/test-provider-language.js +89 -0
  124. package/test-provider-migration-backup.js +424 -0
  125. package/test-provider-pack-review.js +436 -0
  126. package/test-provider-parity-e2e.js +537 -0
  127. package/test-provider-prompt-parity.js +89 -0
  128. package/test-provider-recall.js +271 -0
  129. package/test-provider-registry.js +251 -0
  130. package/test-provider-resume-parity.js +41 -0
  131. package/test-provider-run-lifecycle.js +349 -0
  132. package/test-provider-runner.js +271 -0
  133. package/test-provider-scheduler.js +689 -0
  134. package/test-provider-session-commands.js +185 -0
  135. package/test-provider-session-history.js +205 -0
  136. package/test-provider-side-chat.js +337 -0
  137. package/test-provider-state-migration.js +316 -0
  138. package/test-provider-stream-decoder.js +69 -0
  139. package/test-provider-subagent.js +228 -0
  140. package/test-provider-tool-hooks.js +360 -0
  141. package/test-recall-discoverer.js +1 -0
  142. package/test-recall-engine.js +3 -3
  143. package/test-recall-evolution.js +18 -0
  144. package/test-runner-watchdog-static.js +16 -8
  145. package/test-single-runtime.js +35 -0
  146. package/test-telegram-poll-recovery.js +93 -0
  147. package/test-tool-guard.js +56 -0
  148. package/test-tools.js +19 -0
  149. package/test-unified-identity.js +3 -1
  150. package/test-usage-accounting.js +92 -20
  151. package/test-utility-provider-policy.js +486 -0
  152. package/web.js +130 -35
package/bot.js CHANGED
@@ -10,7 +10,46 @@ const path = require("path");
10
10
  const fs = require("fs");
11
11
  const { execSync } = require("child_process");
12
12
 
13
- const { config, BOT_DIR, CONFIG_DIR, SOUL_FILE, VAULT_FILE, CHAT_ID } = require("./core/config");
13
+ const {
14
+ config, BOT_DIR, CONFIG_DIR, SOUL_FILE, VAULT_FILE, CHAT_ID,
15
+ STATE_FILE, SESSIONS_FILE, JOBS_FILE, CRONS_FILE,
16
+ ensureRuntimeDirectories,
17
+ } = require("./core/config");
18
+ ensureRuntimeDirectories();
19
+
20
+ // Refuse to double-boot: a second process on this CONFIG_DIR shares the same
21
+ // channel tokens and 409-wars the running service (see core/single-instance).
22
+ const { acquireSingleInstanceLock } = require("./core/single-instance");
23
+ const instanceLock = acquireSingleInstanceLock({ configDir: CONFIG_DIR });
24
+ if (!instanceLock.acquired) {
25
+ const h = instanceLock.holder;
26
+ console.error(
27
+ `Another Open Claudia instance already owns ${CONFIG_DIR} ` +
28
+ `(pid ${h.pid}, started ${h.startedAt || "?"}, ${h.entrypoint || "unknown entrypoint"}). Exiting. ` +
29
+ "Stop it first (launchctl stop com.claude-telegram-bot), or give a dev run its own OPEN_CLAUDIA_CONFIG_DIR."
30
+ );
31
+ process.exit(1);
32
+ }
33
+
34
+ const { defaultMigrationSources, ensureMigrationSnapshot } = require("./core/migration-backup");
35
+ const migrationSources = defaultMigrationSources({
36
+ configDir: CONFIG_DIR,
37
+ stateFile: STATE_FILE,
38
+ sessionsFile: SESSIONS_FILE,
39
+ jobsFile: JOBS_FILE,
40
+ cronsFile: CRONS_FILE,
41
+ });
42
+ const migrationSnapshot = ensureMigrationSnapshot({
43
+ configDir: CONFIG_DIR,
44
+ sources: migrationSources,
45
+ });
46
+ require("./core/provider-migration").completeProviderMigration({
47
+ configDir: CONFIG_DIR,
48
+ sources: migrationSources,
49
+ snapshot: migrationSnapshot,
50
+ });
51
+ const { readRuntimeMode, sideChatCoordinator } = require("./core/side-chat");
52
+ const runtimeMode = readRuntimeMode(CONFIG_DIR);
14
53
  const { vault } = require("./core/vault-store");
15
54
  const { isOnboarded } = require("./core/onboarding");
16
55
  const { initScheduler, stopAll: stopScheduler } = require("./core/scheduler");
@@ -58,14 +97,22 @@ function persist() {
58
97
 
59
98
  async function gracefulShutdown(signal) {
60
99
  console.log(`Received ${signal}, shutting down gracefully...`);
100
+ try { stopScheduler(); } catch (e) {}
101
+ try { loopback.stop(); } catch (e) {}
102
+ const sideCleanup = sideChatCoordinator.cancelAll().catch(() => 0);
103
+ const memoryCleanup = require("./core/pack-review")
104
+ .drainReviews({ timeoutMs: 5000, cancelOnTimeout: true })
105
+ .catch((error) => {
106
+ console.error("memory shutdown drain failed:", error?.message);
107
+ return { drained: false };
108
+ });
61
109
  for (const state of userStates.values()) {
62
110
  if (state.runningProcess) {
63
111
  try { killProcessTree(state.runningProcess.pid, "SIGTERM"); } catch (e) {}
64
112
  }
65
113
  }
114
+ await Promise.all([sideCleanup, memoryCleanup]);
66
115
  for (const a of adapters) { try { await a.stop(); } catch (e) {} }
67
- try { stopScheduler(); } catch (e) {}
68
- try { loopback.stop(); } catch (e) {}
69
116
  try {
70
117
  const mediaDir = path.join(CONFIG_DIR, "media");
71
118
  if (fs.existsSync(mediaDir)) {
@@ -234,6 +281,7 @@ setInterval(checkForUpdates, 5 * 60 * 1000);
234
281
  }
235
282
 
236
283
  console.log(`Open Claudia v${CURRENT_VERSION} running on: ${adapters.map((a) => a.id).join(", ")}`);
284
+ console.log(`Runtime mode: ${runtimeMode} (single modular runtime)`);
237
285
  console.log(`Workspace: ${require("./core/config").WORKSPACE}`);
238
286
  console.log(`Soul: ${SOUL_FILE}`);
239
287
  console.log(`Vault: ${VAULT_FILE} (${vault.exists() ? "exists" : "not created"})`);
@@ -251,6 +299,36 @@ setInterval(checkForUpdates, 5 * 60 * 1000);
251
299
  }
252
300
  } catch (e) {}
253
301
  }
302
+
303
+ // Surface jobs the provider migration disabled. Announce once per job:
304
+ // marked only after a successful send, so a down adapter retries next boot.
305
+ try {
306
+ const jobsStore = require("./core/jobs");
307
+ const unannounced = jobsStore.listArchived().filter((job) => !job.announcedAt);
308
+ if (unannounced.length) {
309
+ const lines = unannounced.slice(0, 15).map((job) => {
310
+ const label = job.label || (job.prompt ? String(job.prompt).slice(0, 50) : job.id);
311
+ return `• [${job.kind || "job"}] ${label}\n ↳ ${job.archiveReason}`;
312
+ });
313
+ const message = [
314
+ `⚠️ ${unannounced.length} scheduled job(s) were disabled during the provider migration and will NOT fire:`,
315
+ "",
316
+ ...lines,
317
+ unannounced.length > 15 ? `…and ${unannounced.length - 15} more.` : "",
318
+ "",
319
+ "Full list: open-claudia cron-list. Re-create the ones you still need with /cron add or schedule-wakeup.",
320
+ ].filter(Boolean).join("\n");
321
+ const tg = adapters.find((a) => a.type === "telegram");
322
+ if (tg && CHAT_ID) {
323
+ await tg.send(CHAT_ID, message);
324
+ jobsStore.markArchivedAnnounced(unannounced.map((job) => job.id));
325
+ } else {
326
+ console.warn(message);
327
+ }
328
+ }
329
+ } catch (e) {
330
+ console.error("archived-jobs notice failed:", e.message);
331
+ }
254
332
  })().catch((e) => {
255
333
  console.error("Boot failure:", e);
256
334
  process.exit(1);
@@ -36,6 +36,11 @@ class TelegramAdapter {
36
36
  this._wedgedSince = 0; // start of the current unrecovered error streak
37
37
  this._lastHiccupLog = 0; // throttle the transient-error log (1/min)
38
38
  this._healBackoff = 0; // grows per consecutive heal, capped
39
+ this._pollWatchdogTimer = null; // detects a silent first/active getUpdates stall
40
+ this._pollStartedAt = 0;
41
+ this._conflictSince = 0; // start of the current 409 streak
42
+ this._conflictTimer = null; // pending paused retry after a 409
43
+ this._conflictClearTimer = null; // quiet after a retry ⇒ streak over
39
44
  this._wireInbound();
40
45
  }
41
46
 
@@ -53,12 +58,33 @@ class TelegramAdapter {
53
58
  }
54
59
 
55
60
  async start() {
56
- await this.bot.startPolling();
61
+ // node-telegram-bot-api resolves startPolling() only after the current
62
+ // getUpdates request settles. Awaiting it can therefore pin the entire bot
63
+ // boot sequence behind a half-open first poll. Start the loop, return so the
64
+ // remaining adapters can boot, and let the independent watchdog recover a
65
+ // poll that never completes and emits no polling_error.
66
+ this._pollStartedAt = Date.now();
67
+ Promise.resolve(this.bot.startPolling()).catch((error) => {
68
+ this._onPollingHiccup(error?.message || "Telegram polling failed to start");
69
+ });
70
+ if (!this._pollWatchdogTimer) {
71
+ this._pollWatchdogTimer = setInterval(() => this._checkPollingHealth(), 15000);
72
+ }
73
+ }
74
+
75
+ _checkPollingHealth() {
76
+ if (this._conflictTimer || this._conflictSince) return; // 409 backoff owns recovery
77
+ const lastCompletedPoll = this.bot?._polling?._lastUpdate || this._pollStartedAt;
78
+ if (!lastCompletedPoll || Date.now() - lastCompletedPoll <= 75000) return;
79
+ this._onPollingHiccup("poll watchdog: no completed getUpdates request in 75s");
57
80
  }
58
81
 
59
82
  async stop() {
83
+ if (this._pollWatchdogTimer) { clearInterval(this._pollWatchdogTimer); this._pollWatchdogTimer = null; }
60
84
  if (this._healTimer) { clearTimeout(this._healTimer); this._healTimer = null; }
61
85
  if (this._healthyTimer) { clearTimeout(this._healthyTimer); this._healthyTimer = null; }
86
+ if (this._conflictTimer) { clearTimeout(this._conflictTimer); this._conflictTimer = null; }
87
+ if (this._conflictClearTimer) { clearTimeout(this._conflictClearTimer); this._conflictClearTimer = null; }
62
88
  try { await this.bot.stopPolling(); } catch (e) {}
63
89
  }
64
90
 
@@ -94,7 +120,12 @@ class TelegramAdapter {
94
120
  process.exit(1);
95
121
  }
96
122
  try {
97
- try { await this.bot.stopPolling(); } catch (e) {}
123
+ try {
124
+ // `stopPolling()` without cancellation waits for the active getUpdates
125
+ // request to settle. A half-open socket may never settle, which wedges
126
+ // this recovery routine itself. Abort the request before restarting.
127
+ await this.bot.stopPolling({ cancel: true, reason: "stale Telegram long-poll recovery" });
128
+ } catch (e) {}
98
129
  try { this._agent.destroy(); } catch (e) {} // drop any lingering pooled socket
99
130
  await new Promise((r) => setTimeout(r, 500));
100
131
  await this.bot.startPolling();
@@ -111,13 +142,42 @@ class TelegramAdapter {
111
142
  }, 30000);
112
143
  }
113
144
 
145
+ // Telegram 409s a getUpdates call while ANOTHER one holds the token. Two
146
+ // causes: a ghost long-poll left by an uncleanly-killed predecessor (clears
147
+ // within its ≤30s poll timeout), or a genuinely running second instance.
148
+ // Exiting on the first 409 turned the ghost case into a KeepAlive respawn
149
+ // storm (7 boot→409→die cycles on 2026-07-12). So: pause, retry inside a
150
+ // grace window, and exit only if the conflict outlives it — that really is
151
+ // a second instance, and the supervisor's throttled respawns pace the rest.
152
+ _on409Conflict() {
153
+ const now = Date.now();
154
+ if (!this._conflictSince) this._conflictSince = now;
155
+ if (this._conflictClearTimer) { clearTimeout(this._conflictClearTimer); this._conflictClearTimer = null; }
156
+ if (now - this._conflictSince > 120000) {
157
+ console.error("Another instance is polling (409 persisted >2min). Exiting.");
158
+ process.exit(1);
159
+ }
160
+ if (this._conflictTimer) return; // a paused retry is already queued
161
+ console.error("409 Conflict: another poller holds this token — pausing 15s (ghost polls clear in <1min; exiting only if this persists >2min).");
162
+ // Stop hammering now; the timer below dials back in.
163
+ Promise.resolve(this.bot.stopPolling({ cancel: true, reason: "409 conflict backoff" })).catch(() => {});
164
+ this._conflictTimer = setTimeout(async () => {
165
+ this._conflictTimer = null;
166
+ try { await this.bot.startPolling(); } catch (e) {}
167
+ // No further 409 for 45s ⇒ the other poller is gone; end the streak.
168
+ this._conflictClearTimer = setTimeout(() => {
169
+ this._conflictClearTimer = null;
170
+ this._conflictSince = 0;
171
+ }, 45000);
172
+ }, 15000);
173
+ }
174
+
114
175
  _wireInbound() {
115
176
  this.bot.on("polling_error", (err) => {
116
177
  const msg = err.message || "";
117
178
  if (msg.includes("409 Conflict")) {
118
- // Two pollers can't share one token — this one must bow out.
119
- console.error("Another instance is polling. Exiting.");
120
- process.exit(1);
179
+ this._on409Conflict();
180
+ return;
121
181
  }
122
182
  if (/ETIMEDOUT|ECONNRESET|ENOTFOUND|ENETUNREACH|EAI_AGAIN|EFATAL|socket hang up/i.test(msg)) {
123
183
  this._onPollingHiccup(msg); // transient — heal quietly, never exit
@@ -132,6 +132,7 @@ class VoiceAdapter {
132
132
  // frontend, not just a voice channel.
133
133
  if (pathname.startsWith("/v1/manage/")) {
134
134
  return require("./manage").handle(req, res, url, {
135
+ canonicalUserId: canonicalForChannel("voice", this.channelId),
135
136
  json: (code, payload) => this._json(res, code, payload),
136
137
  readBody: (cb) => this._readBody(req, res, cb),
137
138
  });
@@ -24,7 +24,9 @@ const entities = require("../../core/entities");
24
24
  const people = require("../../core/people");
25
25
  const lessons = require("../../core/lessons");
26
26
  const ideas = require("../../core/ideas");
27
- const { TASKS_DIR, DEFAULT_CLAUDE_MODEL } = require("../../core/config");
27
+ const { TASKS_DIR, DEFAULT_PROVIDER } = require("../../core/config");
28
+ const { registry: providerRegistry } = require("../../core/providers");
29
+ const { getActiveProvider, getUserState } = require("../../core/state");
28
30
 
29
31
  let PKG_VERSION = "unknown";
30
32
  try { PKG_VERSION = require("../../package.json").version || "unknown"; } catch (e) {}
@@ -56,7 +58,25 @@ function isOpen(t) { return t.status === "pending" || t.status === "in_progress"
56
58
 
57
59
  // ── responses ──────────────────────────────────────────────────────
58
60
 
59
- function overview() {
61
+ function providerMetadata(providerId, source, reason = null) {
62
+ if (!providerId) return { providerId: null, label: null, model: null, defaultModel: null, source, reason };
63
+ try {
64
+ const provider = providerRegistry.getProvider(providerId);
65
+ const defaultModel = provider.defaultModel() || null;
66
+ return {
67
+ providerId: provider.id,
68
+ label: provider.label,
69
+ model: defaultModel,
70
+ defaultModel,
71
+ source,
72
+ reason,
73
+ };
74
+ } catch (error) {
75
+ return { providerId, label: providerId, model: null, defaultModel: null, source, reason: error.message };
76
+ }
77
+ }
78
+
79
+ function overview(canonicalUserId = null) {
60
80
  const keys = taskFileKeys();
61
81
  let openTasks = 0, inProgress = 0;
62
82
  for (const key of keys) {
@@ -67,9 +87,27 @@ function overview() {
67
87
  }
68
88
  }
69
89
  const allJobs = jobs.listAll();
90
+ const selectedDefault = providerRegistry.selectDefaultProvider(DEFAULT_PROVIDER);
91
+ const defaultProvider = providerMetadata(
92
+ selectedDefault.providerId,
93
+ selectedDefault.source,
94
+ selectedDefault.reason,
95
+ );
96
+ const activeState = canonicalUserId ? getUserState(canonicalUserId) : null;
97
+ const activeProviderId = getActiveProvider(activeState) || defaultProvider.providerId;
98
+ const activeProvider = providerMetadata(
99
+ activeProviderId,
100
+ getActiveProvider(activeState) ? "active" : defaultProvider.source,
101
+ activeProviderId ? null : defaultProvider.reason,
102
+ );
103
+ const activeModel = activeState?.providerSettings?.[activeProviderId]?.model || activeProvider.defaultModel;
104
+ activeProvider.model = activeModel || null;
70
105
  return {
71
106
  version: PKG_VERSION,
72
- model: DEFAULT_CLAUDE_MODEL,
107
+ activeProvider,
108
+ defaultProvider,
109
+ // Compatibility for companion clients predating provider metadata.
110
+ model: activeProvider.model,
73
111
  counts: {
74
112
  tasksOpen: openTasks,
75
113
  tasksInProgress: inProgress,
@@ -184,7 +222,7 @@ function ideasView() {
184
222
  // we don't reach into its privates.
185
223
 
186
224
  function handle(req, res, url, helpers) {
187
- const { json, readBody } = helpers;
225
+ const { canonicalUserId, json, readBody } = helpers;
188
226
  const method = req.method;
189
227
  const sub = url.pathname.slice("/v1/manage/".length).replace(/\/+$/, "");
190
228
  const parts = sub.split("/").filter(Boolean);
@@ -192,7 +230,7 @@ function handle(req, res, url, helpers) {
192
230
  try {
193
231
  // GET reads
194
232
  if (method === "GET") {
195
- if (sub === "overview") return json(200, { ok: true, ...overview() });
233
+ if (sub === "overview") return json(200, { ok: true, ...overview(canonicalUserId) });
196
234
  if (sub === "tasks") return json(200, { ok: true, ...allTasks() });
197
235
  if (sub === "jobs") return json(200, { ok: true, ...allJobsView() });
198
236
  if (sub === "packs") return json(200, { ok: true, ...packSummaries() });
package/config-dir.js CHANGED
@@ -1,11 +1,3 @@
1
- const path = require("path");
2
- const fs = require("fs");
3
-
4
- const CONFIG_DIR = path.join(process.env.HOME || require("os").homedir(), ".open-claudia");
5
-
6
- // Ensure directory exists
7
- if (!fs.existsSync(CONFIG_DIR)) {
8
- fs.mkdirSync(CONFIG_DIR, { recursive: true });
9
- }
10
-
11
- module.exports = CONFIG_DIR;
1
+ // Compatibility export for modules that need only the path. Resolving a path
2
+ // is intentionally pure: setup/runtime entrypoints create it explicitly.
3
+ module.exports = require("./core/config-dir").resolveConfigDir();
package/core/actions.js CHANGED
@@ -4,11 +4,15 @@
4
4
 
5
5
  const fs = require("fs");
6
6
  const path = require("path");
7
- const { CONFIG_DIR, WORKSPACE, CHAT_ID, resolvedCursorPath, resolvedCodexPath } = require("./config");
7
+ const { CONFIG_DIR, WORKSPACE, CHAT_ID } = require("./config");
8
8
  const { send } = require("./io");
9
- const { currentState, resetSettings, resetSessionUsage, saveState } = require("./state");
10
- const { runClaude, getActiveSessionId } = require("./runner");
11
- const { listProjects, projectKeyboard, workspacePath } = require("./projects");
9
+ const {
10
+ currentState, saveState,
11
+ getActiveProvider, getProjectKey, consumeSessionCallbackToken,
12
+ userOwnsProviderSession,
13
+ } = require("./state");
14
+ const { admitRunContext, runAgent } = require("./runner");
15
+ const { listProjects, projectKeyboard } = require("./projects");
12
16
  const { isChatOwner, approveAuthRequest, denyAuthRequest, authRequestLabel } = require("./access");
13
17
  const accessStore = require("./access");
14
18
  const peopleStore = require("./people");
@@ -16,13 +20,47 @@ const { vault } = require("./vault-store");
16
20
  const intros = require("./intros");
17
21
  const introFlow = require("./intro-flow");
18
22
  const { finishOnboarding } = require("./onboarding");
19
- const { startSession } = require("./handlers");
23
+ const {
24
+ startSession, switchProviderState, startNewConversation,
25
+ endCurrentSession, activeContinueOptions, applyProviderSetting,
26
+ } = require("./handlers");
27
+ const providerRegistry = require("./providers");
20
28
  const jobs = require("./jobs");
21
29
  const scheduler = require("./scheduler");
30
+ const { sideChatCoordinator } = require("./side-chat");
22
31
  const {
23
32
  getClaudeOAuthToken, runClaudeAuthCommand, runCodexLoginStatus, runCodexDeviceLogin,
24
33
  } = require("./auth-flow");
25
34
 
35
+ const REMOVED_PROVIDER_CALLBACK_MESSAGE = "This button is from an older Open Claudia release. Cursor Agent support has been removed. Open /backend to choose Claude Code or OpenAI Codex.";
36
+
37
+ function isRemovedProviderCallback(payload) {
38
+ const value = String(payload || "");
39
+ return value === "be:cursor" || value === "mb:cursor" || value.startsWith("mb:cursor:");
40
+ }
41
+
42
+ async function handleRemovedProviderCallback(payload, deliver = send) {
43
+ if (!isRemovedProviderCallback(payload)) return false;
44
+ await deliver(REMOVED_PROVIDER_CALLBACK_MESSAGE);
45
+ return true;
46
+ }
47
+
48
+ function resolveSessionCallbackAction(token, state, now = Date.now()) {
49
+ const expected = {
50
+ userId: state?.userId,
51
+ project: getProjectKey(state),
52
+ provider: getActiveProvider(state),
53
+ now,
54
+ };
55
+ const consumed = consumeSessionCallbackToken(token, expected);
56
+ if (consumed.status !== "ok") return consumed;
57
+ const value = consumed.value;
58
+ if (!userOwnsProviderSession(value.userId, value.project, value.provider, value.sessionId)) {
59
+ return { status: "stale", value: null };
60
+ }
61
+ return { status: "ok", value };
62
+ }
63
+
26
64
  // Append an owner "always-allow" grant to a person's persona-pack Mandate so
27
65
  // the guardrail won't escalate the same thing again. Wholesale-writes the
28
66
  // combined mandate (upsert replaces the section); relationship is preserved.
@@ -46,11 +84,24 @@ function appendMandate(rec, line) {
46
84
  async function handleAction(envelope) {
47
85
  const adapter = envelope.adapter;
48
86
  const d = envelope.action?.payload || envelope.action?.buttonId || "";
87
+ const state = currentState();
88
+ const continuePrompt = "continue";
89
+ const continuation = d === "a:continue" && state.currentSession ? activeContinueOptions(state) : null;
90
+ const continueRunContext = continuation
91
+ ? admitRunContext(continuePrompt, state.currentSession.dir, null, continuation)
92
+ : null;
49
93
  // Telegram needs us to ack the callback so the client stops spinning.
50
94
  if (adapter && adapter.type === "telegram" && envelope.action?.callbackId) {
51
95
  try { await adapter.bot.answerCallbackQuery(envelope.action.callbackId); } catch (e) {}
52
96
  }
53
- const state = currentState();
97
+
98
+ if (d.startsWith("sc:q:")) {
99
+ const outcome = await sideChatCoordinator.enqueue(d.slice("sc:q:".length), state.userId);
100
+ if (outcome.status === "queued") return send("Added to the main task queue.");
101
+ if (outcome.status === "forbidden") return send("That side-chat action belongs to another user.");
102
+ if (outcome.status === "expired") return send("That side-chat action expired. Send the message again if it still matters.");
103
+ return send(`Could not add that message to the main task: ${outcome.error?.message || "unknown error"}`);
104
+ }
54
105
 
55
106
  if (d.startsWith("auth:")) {
56
107
  if (!isChatOwner(envelope.channelId)) return send("Owner only — auth controls are restricted.");
@@ -233,47 +284,60 @@ async function handleAction(envelope) {
233
284
 
234
285
  if (d === "show:projects") { await send("Pick:", { keyboard: projectKeyboard() }); return; }
235
286
  if (d.startsWith("s:")) { startSession(d.slice(2)); return; }
236
- if (d.startsWith("ss:")) { if (state.currentSession) startSession(state.currentSession.name, d.slice(3)); return; }
287
+ if (d.startsWith("ss:")) {
288
+ const resolved = resolveSessionCallbackAction(d.slice(3), state);
289
+ if (resolved.status !== "ok") {
290
+ const message = resolved.status === "forbidden"
291
+ ? "That conversation button belongs to another user, project, or provider."
292
+ : "That conversation button is stale or expired. Open /sessions again.";
293
+ return send(message);
294
+ }
295
+ return startSession(resolved.value.project, resolved.value.sessionId, { provider: resolved.value.provider });
296
+ }
237
297
  if (d.startsWith("new:")) {
238
298
  const proj = d.slice(4);
239
- state.currentSession = { name: proj === "__workspace__" ? "Workspace" : proj, dir: workspacePath(proj) };
240
- state.lastSessionId = null;
241
- state.isFirstMessage = true;
242
- state.messageQueue = [];
243
- resetSettings(state);
244
- resetSessionUsage(state);
245
- saveState();
299
+ const expected = proj === "__workspace__" ? "Workspace" : proj;
300
+ const result = startNewConversation({ state, projectName: expected });
301
+ if (!result.ok) return send("That new-conversation button is stale. Open /sessions again.");
246
302
  await send(`Session: ${state.currentSession.name}\nNew conversation\n\nSend text, voice, or images.\n\n/sessions — switch conversation\n/session — switch project`);
247
303
  return;
248
304
  }
249
- if (d === "a:continue") { if (state.currentSession && getActiveSessionId()) await runClaude("continue", state.currentSession.dir); else send("No session to continue."); return; }
305
+ if (d === "a:continue") {
306
+ if (!continueRunContext) return send("No session to continue.");
307
+ return runAgent(continuePrompt, continueRunContext.cwd, null, {
308
+ runContext: continueRunContext,
309
+ ...continuation,
310
+ });
311
+ }
250
312
  if (d === "a:end") {
251
313
  if (state.currentSession) {
252
- const n = state.currentSession.name;
253
- state.currentSession = null;
254
- state.lastSessionId = null;
255
- state.messageQueue = [];
256
- resetSettings(state);
257
- resetSessionUsage(state);
258
- saveState();
259
- await send(`Ended: ${n}`, { keyboard: { inline_keyboard: [[{ text: "New", callback_data: "show:projects" }]] } });
314
+ const result = endCurrentSession({ state });
315
+ if (!result.ok) return send(`Could not end session: ${result.error.message}`);
316
+ await send(`Ended: ${result.project}`, { keyboard: { inline_keyboard: [[{ text: "New", callback_data: "show:projects" }]] } });
260
317
  }
261
318
  return;
262
319
  }
263
320
  if (d === "noop") return;
321
+ if (await handleRemovedProviderCallback(d)) return;
264
322
  if (d.startsWith("mb:")) {
265
323
  const rest = d.slice(3);
266
324
  const colon = rest.indexOf(":");
267
325
  if (colon < 0) return;
268
326
  const be = rest.slice(0, colon);
269
327
  const model = rest.slice(colon + 1);
270
- if (be === "cursor" && !resolvedCursorPath) { await send("Cursor Agent CLI not found."); return; }
271
- if (be === "codex" && !resolvedCodexPath) { await send("Codex CLI not found. Install: npm install -g @openai/codex"); return; }
328
+ let provider;
329
+ try { provider = providerRegistry.getProvider(be); }
330
+ catch (_) { return send("That provider is no longer available. Open /model again."); }
331
+ let available = false;
332
+ try { available = provider.isAvailable() === true; } catch (_) {}
333
+ if (!available) return send(`${provider.label} is unavailable. Run /doctor for recovery steps.`);
334
+ if (model !== "default" && !provider.modelChoices().includes(model)) {
335
+ return send("That model button is stale. Open /model again.");
336
+ }
272
337
  const switched = state.settings.backend !== be;
273
- state.settings.backend = be;
274
- state.settings.model = model;
275
- saveState();
276
- const beLabel = be === "cursor" ? "Cursor Agent" : be === "codex" ? "Codex" : "Claude Code";
338
+ const changed = switchProviderState(be, { state, model: model === "default" ? null : model });
339
+ if (!changed.ok) return send(`Could not switch provider: ${changed.error.message}`);
340
+ const beLabel = provider.label;
277
341
  // Auth-aware: if the chosen provider isn't connected, start its login flow
278
342
  // now. The model is already set, so once auth completes the user is on it.
279
343
  if (be === "claude" && !getClaudeOAuthToken().value) {
@@ -291,9 +355,27 @@ async function handleAction(envelope) {
291
355
  await send(switched ? `Switched to ${beLabel}.\nModel: ${model}` : `Model: ${model}`);
292
356
  return;
293
357
  }
294
- if (d.startsWith("m:")) { state.settings.model = d.slice(2) === "default" ? null : d.slice(2); await send(`Model: ${state.settings.model || "default"}`); return; }
295
- if (d.startsWith("e:")) { const e = d.slice(2); state.settings.effort = e === "default" ? null : e; await send(`Effort: ${state.settings.effort || "default"}`); return; }
296
- if (d.startsWith("b:")) { const b = d.slice(2); state.settings.budget = b === "none" ? null : parseFloat(b); await send(`Budget: ${state.settings.budget ? "$" + state.settings.budget : "none"}`); return; }
358
+ if (d.startsWith("m:")) {
359
+ state.settings.model = d.slice(2) === "default" ? null : d.slice(2);
360
+ await send(`Model: ${state.settings.model || "default"}`);
361
+ return;
362
+ }
363
+ if (d.startsWith("e:")) {
364
+ const parts = d.split(":");
365
+ const providerId = parts.length >= 3 ? parts[1] : getActiveProvider(state);
366
+ const effort = parts.length >= 3 ? parts.slice(2).join(":") : parts[1];
367
+ const result = applyProviderSetting(state, providerId, "effort", effort, { requireActive: true, persist: true });
368
+ await send(result.ok ? `${result.providerLabel} effort: ${result.value || "default"}` : result.message);
369
+ return;
370
+ }
371
+ if (d.startsWith("b:")) {
372
+ const parts = d.split(":");
373
+ const providerId = parts.length >= 3 ? parts[1] : getActiveProvider(state);
374
+ const budget = parts.length >= 3 ? parts.slice(2).join(":") : parts[1];
375
+ const result = applyProviderSetting(state, providerId, "budget", budget, { requireActive: true, persist: true });
376
+ await send(result.ok ? `${result.providerLabel} budget: ${result.value ? "$" + result.value : "none"}` : result.message);
377
+ return;
378
+ }
297
379
  if (d.startsWith("eng:")) {
298
380
  const v = d.slice(4);
299
381
  const recall = require("./recall");
@@ -386,6 +468,9 @@ async function handleAction(envelope) {
386
468
  : `[Tool approval ${rec.id}] The owner DENIED the held run${isExternalAction ? ` requested by ${rec.personName || "an external contact"}` : ""}:\n${rec.command}\n` +
387
469
  `Do NOT run it. ${isExternalAction ? "Let them know you can't do that one and move on." : "Acknowledge the denial to the user and adjust course."}`;
388
470
  try {
471
+ if (!rec.provider || !rec.project || !rec.canonicalUserId) {
472
+ throw new Error("the original provider/project context predates safe scheduled-run pinning");
473
+ }
389
474
  scheduler.addJob({
390
475
  kind: "wakeup",
391
476
  // External escalations carry the origin as an adapter TYPE string
@@ -393,6 +478,20 @@ async function handleAction(envelope) {
393
478
  adapter: isExternalAction ? "" : (rec.adapter || (adapter && adapter.id) || ""),
394
479
  adapterType: isExternalAction ? (rec.adapter || (adapter && adapter.type)) : (adapter && adapter.type),
395
480
  channelId: String(rec.channelId || envelope.channelId),
481
+ canonicalUserId: rec.canonicalUserId,
482
+ userId: rec.userId || null,
483
+ provider: rec.provider,
484
+ providerSettings: rec.providerSettings || {},
485
+ project: rec.project,
486
+ projectDir: rec.projectDir || null,
487
+ sessionId: rec.sessionId || null,
488
+ previousSessionId: rec.previousSessionId || null,
489
+ originRunId: rec.originRunId || null,
490
+ sessionLineage: {
491
+ originRunId: rec.originRunId || null,
492
+ originSessionId: rec.sessionId || null,
493
+ previousSessionId: rec.previousSessionId || null,
494
+ },
396
495
  prompt,
397
496
  label: `Tool approval ${rec.status}: ${label}`.slice(0, 60),
398
497
  source: "approval",
@@ -417,6 +516,12 @@ async function handleAction(envelope) {
417
516
  await send(`Tool trace: ${state.settings.showToolTrace ? "on" : "off"}`);
418
517
  return;
419
518
  }
519
+ if (d.startsWith("vb:")) {
520
+ state.settings.showThinking = d.slice(3) === "on";
521
+ saveState();
522
+ await send(`Verbose thinking: ${state.settings.showThinking ? "on" : "off"}`);
523
+ return;
524
+ }
420
525
  if (d.startsWith("cw:")) {
421
526
  const v = d.slice(3);
422
527
  if (v === "default") state.settings.compactWindow = null;
@@ -432,22 +537,26 @@ async function handleAction(envelope) {
432
537
  }
433
538
  if (d.startsWith("be:")) {
434
539
  const be = d.slice(3);
435
- if (be === "cursor" && !resolvedCursorPath) { await send("Cursor Agent CLI not found."); return; }
436
- if (be === "codex" && !resolvedCodexPath) { await send("Codex CLI not found. Install: npm install -g @openai/codex"); return; }
437
- state.settings.backend = be;
438
- state.settings.model = null;
439
- saveState();
440
- const label = be === "cursor" ? "Cursor Agent" : be === "codex" ? "Codex" : "Claude Code";
441
- await send(`Switched to ${label}.`);
540
+ let provider;
541
+ try { provider = providerRegistry.getProvider(be); }
542
+ catch (_) { return send("That provider is no longer available. Open /backend again."); }
543
+ let available = false;
544
+ try { available = provider.isAvailable() === true; } catch (_) {}
545
+ if (!available) return send(`${provider.label} is unavailable. Run /doctor for recovery steps.`);
546
+ const switched = switchProviderState(be, { state });
547
+ if (!switched.ok) return send(`Could not switch provider: ${switched.error.message}`);
548
+ await send(`Switched to ${provider.label}.`);
442
549
  return;
443
550
  }
444
551
 
445
552
  if (d.startsWith("mode:")) {
446
553
  if (!isChatOwner(envelope.channelId)) { await send("Owner only — mode switch restarts the bot."); return; }
447
554
  const newMode = d.slice(5);
555
+ if (!new Set(["direct", "agent"]).has(newMode)) return send("Unknown runtime mode.");
448
556
  const modeFile = path.join(CONFIG_DIR, ".bot-mode");
449
557
  fs.writeFileSync(modeFile, newMode);
450
558
  await send(`Switching to ${newMode} mode... restarting.`);
559
+ await sideChatCoordinator.cancelAll();
451
560
  setTimeout(() => process.exit(0), 500);
452
561
  return;
453
562
  }
@@ -499,4 +608,10 @@ async function handleAction(envelope) {
499
608
  }
500
609
  }
501
610
 
502
- module.exports = { handleAction };
611
+ module.exports = {
612
+ REMOVED_PROVIDER_CALLBACK_MESSAGE,
613
+ handleAction,
614
+ handleRemovedProviderCallback,
615
+ isRemovedProviderCallback,
616
+ resolveSessionCallbackAction,
617
+ };