@inetafrica/open-claudia 2.15.0 → 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 (151) hide show
  1. package/.env.example +30 -4
  2. package/CHANGELOG.md +12 -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 +66 -3
  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 +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 +503 -217
  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 +117 -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 +121 -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 +170 -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 +1414 -1290
  60. package/core/scheduler.js +515 -210
  61. package/core/side-chat.js +346 -0
  62. package/core/state.js +1084 -97
  63. package/core/subagent.js +72 -98
  64. package/core/system-prompt.js +462 -279
  65. package/core/tool-guard.js +73 -39
  66. package/core/tools.js +22 -5
  67. package/core/transcripts.js +30 -1
  68. package/core/usage-log.js +19 -4
  69. package/core/utility-agent.js +654 -0
  70. package/core/web-auth.js +29 -13
  71. package/docs/CHANNEL_DESIGN.md +181 -0
  72. package/docs/MULTI_CHANNEL_PLAN.md +254 -0
  73. package/docs/PROVIDER_MIGRATION.md +67 -0
  74. package/health.js +50 -39
  75. package/package.json +48 -4
  76. package/setup.js +198 -38
  77. package/soul.md +39 -0
  78. package/test-ability-extraction.js +5 -0
  79. package/test-approval-async.js +63 -4
  80. package/test-cursor-removal.js +334 -0
  81. package/test-enforcer.js +70 -27
  82. package/test-fixtures/current-provider-parity.json +132 -0
  83. package/test-fixtures/fake-agent-cli.js +477 -0
  84. package/test-fixtures/migrations/claude-only/jobs.json +3 -0
  85. package/test-fixtures/migrations/claude-only/sessions.json +5 -0
  86. package/test-fixtures/migrations/claude-only/state.json +5 -0
  87. package/test-fixtures/migrations/cursor-selected/crons.json +3 -0
  88. package/test-fixtures/migrations/cursor-selected/jobs.json +3 -0
  89. package/test-fixtures/migrations/cursor-selected/sessions.json +5 -0
  90. package/test-fixtures/migrations/cursor-selected/state.json +6 -0
  91. package/test-fixtures/migrations/dual-provider/jobs.json +3 -0
  92. package/test-fixtures/migrations/dual-provider/sessions.json +7 -0
  93. package/test-fixtures/migrations/dual-provider/state.json +10 -0
  94. package/test-fixtures/migrations/malformed-partial/jobs.json +1 -0
  95. package/test-fixtures/migrations/malformed-partial/sessions.json +1 -0
  96. package/test-fixtures/migrations/malformed-partial/sessions.json.bak +3 -0
  97. package/test-fixtures/migrations/malformed-partial/state.json +1 -0
  98. package/test-fixtures/migrations/malformed-partial/state.json.bak +5 -0
  99. package/test-fixtures/migrations/missing-project/jobs.json +3 -0
  100. package/test-fixtures/migrations/missing-project/sessions.json +3 -0
  101. package/test-fixtures/migrations/missing-project/state.json +4 -0
  102. package/test-fixtures/migrations/multi-user/jobs.json +4 -0
  103. package/test-fixtures/migrations/multi-user/sessions.json +4 -0
  104. package/test-fixtures/migrations/multi-user/state.json +6 -0
  105. package/test-fixtures/provider-event-samples.json +17 -0
  106. package/test-learning-e2e.js +5 -0
  107. package/test-memory-mutation-queue.js +191 -0
  108. package/test-provider-boot-matrix.js +399 -0
  109. package/test-provider-bootstrap.js +158 -0
  110. package/test-provider-capabilities.js +232 -0
  111. package/test-provider-claude.js +206 -0
  112. package/test-provider-codex.js +228 -0
  113. package/test-provider-compaction.js +371 -0
  114. package/test-provider-core-prompt.js +264 -0
  115. package/test-provider-coupling-audit.js +90 -0
  116. package/test-provider-dream.js +312 -0
  117. package/test-provider-enforcer.js +252 -0
  118. package/test-provider-env-isolation.js +150 -0
  119. package/test-provider-events.js +141 -0
  120. package/test-provider-fixture.js +332 -0
  121. package/test-provider-gateway.js +508 -0
  122. package/test-provider-language.js +89 -0
  123. package/test-provider-migration-backup.js +424 -0
  124. package/test-provider-pack-review.js +436 -0
  125. package/test-provider-parity-e2e.js +537 -0
  126. package/test-provider-prompt-parity.js +89 -0
  127. package/test-provider-recall.js +271 -0
  128. package/test-provider-registry.js +251 -0
  129. package/test-provider-resume-parity.js +41 -0
  130. package/test-provider-run-lifecycle.js +349 -0
  131. package/test-provider-runner.js +271 -0
  132. package/test-provider-scheduler.js +689 -0
  133. package/test-provider-session-commands.js +185 -0
  134. package/test-provider-session-history.js +205 -0
  135. package/test-provider-side-chat.js +337 -0
  136. package/test-provider-state-migration.js +316 -0
  137. package/test-provider-stream-decoder.js +69 -0
  138. package/test-provider-subagent.js +228 -0
  139. package/test-provider-tool-hooks.js +360 -0
  140. package/test-recall-discoverer.js +1 -0
  141. package/test-recall-engine.js +3 -3
  142. package/test-recall-evolution.js +18 -0
  143. package/test-runner-watchdog-static.js +16 -8
  144. package/test-single-runtime.js +35 -0
  145. package/test-telegram-poll-recovery.js +56 -0
  146. package/test-tool-guard.js +56 -0
  147. package/test-tools.js +19 -0
  148. package/test-unified-identity.js +3 -1
  149. package/test-usage-accounting.js +92 -20
  150. package/test-utility-provider-policy.js +486 -0
  151. package/web.js +130 -35
package/bot.js CHANGED
@@ -10,7 +10,31 @@ 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
+ const { defaultMigrationSources, ensureMigrationSnapshot } = require("./core/migration-backup");
20
+ const migrationSources = defaultMigrationSources({
21
+ configDir: CONFIG_DIR,
22
+ stateFile: STATE_FILE,
23
+ sessionsFile: SESSIONS_FILE,
24
+ jobsFile: JOBS_FILE,
25
+ cronsFile: CRONS_FILE,
26
+ });
27
+ const migrationSnapshot = ensureMigrationSnapshot({
28
+ configDir: CONFIG_DIR,
29
+ sources: migrationSources,
30
+ });
31
+ require("./core/provider-migration").completeProviderMigration({
32
+ configDir: CONFIG_DIR,
33
+ sources: migrationSources,
34
+ snapshot: migrationSnapshot,
35
+ });
36
+ const { readRuntimeMode, sideChatCoordinator } = require("./core/side-chat");
37
+ const runtimeMode = readRuntimeMode(CONFIG_DIR);
14
38
  const { vault } = require("./core/vault-store");
15
39
  const { isOnboarded } = require("./core/onboarding");
16
40
  const { initScheduler, stopAll: stopScheduler } = require("./core/scheduler");
@@ -58,14 +82,22 @@ function persist() {
58
82
 
59
83
  async function gracefulShutdown(signal) {
60
84
  console.log(`Received ${signal}, shutting down gracefully...`);
85
+ try { stopScheduler(); } catch (e) {}
86
+ try { loopback.stop(); } catch (e) {}
87
+ const sideCleanup = sideChatCoordinator.cancelAll().catch(() => 0);
88
+ const memoryCleanup = require("./core/pack-review")
89
+ .drainReviews({ timeoutMs: 5000, cancelOnTimeout: true })
90
+ .catch((error) => {
91
+ console.error("memory shutdown drain failed:", error?.message);
92
+ return { drained: false };
93
+ });
61
94
  for (const state of userStates.values()) {
62
95
  if (state.runningProcess) {
63
96
  try { killProcessTree(state.runningProcess.pid, "SIGTERM"); } catch (e) {}
64
97
  }
65
98
  }
99
+ await Promise.all([sideCleanup, memoryCleanup]);
66
100
  for (const a of adapters) { try { await a.stop(); } catch (e) {} }
67
- try { stopScheduler(); } catch (e) {}
68
- try { loopback.stop(); } catch (e) {}
69
101
  try {
70
102
  const mediaDir = path.join(CONFIG_DIR, "media");
71
103
  if (fs.existsSync(mediaDir)) {
@@ -234,6 +266,7 @@ setInterval(checkForUpdates, 5 * 60 * 1000);
234
266
  }
235
267
 
236
268
  console.log(`Open Claudia v${CURRENT_VERSION} running on: ${adapters.map((a) => a.id).join(", ")}`);
269
+ console.log(`Runtime mode: ${runtimeMode} (single modular runtime)`);
237
270
  console.log(`Workspace: ${require("./core/config").WORKSPACE}`);
238
271
  console.log(`Soul: ${SOUL_FILE}`);
239
272
  console.log(`Vault: ${VAULT_FILE} (${vault.exists() ? "exists" : "not created"})`);
@@ -251,6 +284,36 @@ setInterval(checkForUpdates, 5 * 60 * 1000);
251
284
  }
252
285
  } catch (e) {}
253
286
  }
287
+
288
+ // Surface jobs the provider migration disabled. Announce once per job:
289
+ // marked only after a successful send, so a down adapter retries next boot.
290
+ try {
291
+ const jobsStore = require("./core/jobs");
292
+ const unannounced = jobsStore.listArchived().filter((job) => !job.announcedAt);
293
+ if (unannounced.length) {
294
+ const lines = unannounced.slice(0, 15).map((job) => {
295
+ const label = job.label || (job.prompt ? String(job.prompt).slice(0, 50) : job.id);
296
+ return `• [${job.kind || "job"}] ${label}\n ↳ ${job.archiveReason}`;
297
+ });
298
+ const message = [
299
+ `⚠️ ${unannounced.length} scheduled job(s) were disabled during the provider migration and will NOT fire:`,
300
+ "",
301
+ ...lines,
302
+ unannounced.length > 15 ? `…and ${unannounced.length - 15} more.` : "",
303
+ "",
304
+ "Full list: open-claudia cron-list. Re-create the ones you still need with /cron add or schedule-wakeup.",
305
+ ].filter(Boolean).join("\n");
306
+ const tg = adapters.find((a) => a.type === "telegram");
307
+ if (tg && CHAT_ID) {
308
+ await tg.send(CHAT_ID, message);
309
+ jobsStore.markArchivedAnnounced(unannounced.map((job) => job.id));
310
+ } else {
311
+ console.warn(message);
312
+ }
313
+ }
314
+ } catch (e) {
315
+ console.error("archived-jobs notice failed:", e.message);
316
+ }
254
317
  })().catch((e) => {
255
318
  console.error("Boot failure:", e);
256
319
  process.exit(1);
@@ -94,7 +94,12 @@ class TelegramAdapter {
94
94
  process.exit(1);
95
95
  }
96
96
  try {
97
- try { await this.bot.stopPolling(); } catch (e) {}
97
+ try {
98
+ // `stopPolling()` without cancellation waits for the active getUpdates
99
+ // request to settle. A half-open socket may never settle, which wedges
100
+ // this recovery routine itself. Abort the request before restarting.
101
+ await this.bot.stopPolling({ cancel: true, reason: "stale Telegram long-poll recovery" });
102
+ } catch (e) {}
98
103
  try { this._agent.destroy(); } catch (e) {} // drop any lingering pooled socket
99
104
  await new Promise((r) => setTimeout(r, 500));
100
105
  await this.bot.startPolling();
@@ -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",
@@ -432,22 +531,26 @@ async function handleAction(envelope) {
432
531
  }
433
532
  if (d.startsWith("be:")) {
434
533
  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}.`);
534
+ let provider;
535
+ try { provider = providerRegistry.getProvider(be); }
536
+ catch (_) { return send("That provider is no longer available. Open /backend again."); }
537
+ let available = false;
538
+ try { available = provider.isAvailable() === true; } catch (_) {}
539
+ if (!available) return send(`${provider.label} is unavailable. Run /doctor for recovery steps.`);
540
+ const switched = switchProviderState(be, { state });
541
+ if (!switched.ok) return send(`Could not switch provider: ${switched.error.message}`);
542
+ await send(`Switched to ${provider.label}.`);
442
543
  return;
443
544
  }
444
545
 
445
546
  if (d.startsWith("mode:")) {
446
547
  if (!isChatOwner(envelope.channelId)) { await send("Owner only — mode switch restarts the bot."); return; }
447
548
  const newMode = d.slice(5);
549
+ if (!new Set(["direct", "agent"]).has(newMode)) return send("Unknown runtime mode.");
448
550
  const modeFile = path.join(CONFIG_DIR, ".bot-mode");
449
551
  fs.writeFileSync(modeFile, newMode);
450
552
  await send(`Switching to ${newMode} mode... restarting.`);
553
+ await sideChatCoordinator.cancelAll();
451
554
  setTimeout(() => process.exit(0), 500);
452
555
  return;
453
556
  }
@@ -499,4 +602,10 @@ async function handleAction(envelope) {
499
602
  }
500
603
  }
501
604
 
502
- module.exports = { handleAction };
605
+ module.exports = {
606
+ REMOVED_PROVIDER_CALLBACK_MESSAGE,
607
+ handleAction,
608
+ handleRemovedProviderCallback,
609
+ isRemovedProviderCallback,
610
+ resolveSessionCallbackAction,
611
+ };