@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/core/handlers.js CHANGED
@@ -9,15 +9,21 @@ const { execSync } = require("child_process");
9
9
  const {
10
10
  WORKSPACE, FULL_PATH, AUTO_COMPACT_TOKENS, CONFIG_DIR,
11
11
  config, saveEnvKey,
12
- CHAT_ID, DEFAULT_CLAUDE_MODEL, resolvedCursorPath, resolvedCodexPath, SOUL_FILE,
12
+ CHAT_ID, discoverProviderExecutable, SOUL_FILE,
13
13
  } = require("./config");
14
14
  const { register } = require("./commands");
15
15
  const { send, deleteMessage } = require("./io");
16
- const { currentState, resetSettings, resetSessionUsage, saveState, getProjectSessions, getLastProjectSession, recordSession, linkIdentity } = require("./state");
16
+ const {
17
+ currentState, saveState,
18
+ freshUsage, getActiveProvider, getProviderSession, setProviderSession, clearProviderSession,
19
+ getProviderSettings,
20
+ getProjectSessions, getLastProjectSession, userOwnsProviderSession,
21
+ createSessionCallbackToken, linkIdentity,
22
+ } = require("./state");
17
23
  const { canonicalForChannel, canonicalForTelegram, normalizeCanonicalUserId, channelKey, identities, isConfiguredOwnerChannel, removeIdentityMapping } = require("./identity");
18
24
  const { isChatAuthorized, isChatOwner, recordPendingAuthRequest, authRequestLabel, hasOwner, bootstrapOwner } = require("./access");
19
25
  const { isOnboarded, startOnboarding } = require("./onboarding");
20
- const { listProjects, findProject, projectKeyboard, workspacePath } = require("./projects");
26
+ const { listProjects, findProject, projectKeyboard } = require("./projects");
21
27
  const { vault } = require("./vault-store");
22
28
  const keyring = require("./keyring");
23
29
  const { redactSensitive, registerSecrets } = require("./redact");
@@ -28,19 +34,21 @@ const scheduler = require("./scheduler");
28
34
  const skillsLib = require("./skills");
29
35
  const packsLib = require("./packs");
30
36
  const {
31
- runClaude, compactActiveSession, getActiveSessionId, effectiveCompactThreshold,
37
+ admitRunContext, runAgent, compactActiveSession, getActiveSessionId, effectiveCompactThreshold,
32
38
  } = require("./runner");
33
39
  const {
34
- getClaudeOAuthToken, claudeSubprocessEnv, saveClaudeOAuthToken,
40
+ getClaudeOAuthToken, saveClaudeOAuthToken,
35
41
  clearClaudeOAuthToken, looksLikeClaudeToken, looksLikeClaudeAuthReply,
36
42
  looksLikeOpenAIKey,
37
43
  isClaudeAuthErrorText,
38
44
  summarizeClaudeAuthStatus,
45
+ runClaudeAuthStatusDiagnostic,
39
46
  sendClaudeAuthStatusSummary,
40
47
  runClaudeAuthCommand, clearPendingClaudeAuth,
41
48
  sendCodexAuthStatusSummary, runCodexDeviceLogin, clearPendingCodexAuth,
42
- saveCodexApiKeyWithCli, providerAuthStatus,
49
+ saveCodexApiKeyWithCli,
43
50
  } = require("./auth-flow");
51
+ const providerRegistry = require("./providers");
44
52
 
45
53
  const CURRENT_VERSION = require(path.join(__dirname, "..", "package.json")).version;
46
54
 
@@ -60,8 +68,236 @@ function requireSession() {
60
68
  return true;
61
69
  }
62
70
 
63
- function startSession(name, resumeSessionId) {
71
+ function cancelledQueueResult(item, reason) {
72
+ return {
73
+ ok: false,
74
+ status: "cancelled",
75
+ runId: item?.runContext?.runId || null,
76
+ provider: item?.runContext?.provider || null,
77
+ project: item?.runContext?.project || null,
78
+ error: { code: "SESSION_CONTEXT_CLEARED", message: reason },
79
+ };
80
+ }
81
+
82
+ function clearTransientQueue(state, reason = "Session context changed before the queued run started") {
83
+ const queued = Array.isArray(state?.messageQueue) ? state.messageQueue.splice(0) : [];
84
+ for (const item of queued) {
85
+ try { item?.deferred?.resolve(cancelledQueueResult(item, reason)); } catch (_) {}
86
+ }
87
+ return queued.length;
88
+ }
89
+
90
+ function persistSessionControl(state, options, rollback) {
91
+ if (options.persist === false) return { ok: true };
92
+ try {
93
+ saveState({ strict: true });
94
+ return { ok: true };
95
+ } catch (error) {
96
+ if (typeof rollback === "function") rollback();
97
+ return { ok: false, error };
98
+ }
99
+ }
100
+
101
+ function providerFromRegistry(providerId, registry = providerRegistry) {
102
+ try { return registry.getProvider(String(providerId || "").trim().toLowerCase()); }
103
+ catch (error) { return null; }
104
+ }
105
+
106
+ function providerAvailability(provider) {
107
+ try { return provider.isAvailable() === true; }
108
+ catch (_) { return false; }
109
+ }
110
+
111
+ const SETTING_CAPABILITY = Object.freeze({
112
+ effort: "effort",
113
+ budget: "budget",
114
+ worktree: "worktree",
115
+ permissionMode: "readOnlyMode",
116
+ });
117
+
118
+ function applyProviderSetting(state, providerId, setting, value, options = {}) {
119
+ const registry = options.registry || providerRegistry;
120
+ const provider = providerFromRegistry(providerId, registry);
121
+ if (!provider) {
122
+ return { ok: false, code: "UNKNOWN_PROVIDER", message: `Unknown provider: ${providerId}` };
123
+ }
124
+ if (options.requireActive && getActiveProvider(state) !== provider.id) {
125
+ return {
126
+ ok: false,
127
+ code: "STALE_PROVIDER_CONTROL",
128
+ message: `That ${provider.label} control is stale because another provider is active. Open the command again.`,
129
+ };
130
+ }
131
+ const capabilityName = SETTING_CAPABILITY[setting];
132
+ if (!capabilityName) {
133
+ return { ok: false, code: "UNKNOWN_PROVIDER_SETTING", message: `Unknown provider setting: ${setting}` };
134
+ }
135
+ const capability = provider.capabilities[capabilityName];
136
+ if (!capability || capability.support === "unsupported") {
137
+ return {
138
+ ok: false,
139
+ code: "UNSUPPORTED_PROVIDER_CAPABILITY",
140
+ message: capability?.reason || `${provider.label} does not support ${capabilityName}`,
141
+ providerId: provider.id,
142
+ capability: capabilityName,
143
+ };
144
+ }
145
+
146
+ let normalized = value;
147
+ if (setting === "effort") {
148
+ normalized = value == null || value === "default" ? null : String(value).trim().toLowerCase();
149
+ if (normalized && !capability.values.includes(normalized)) {
150
+ return {
151
+ ok: false,
152
+ code: "INVALID_PROVIDER_SETTING",
153
+ message: `${provider.label} effort must be one of: ${capability.values.join(", ")}`,
154
+ };
155
+ }
156
+ } else if (setting === "budget") {
157
+ if (value == null || value === "none" || value === "default") normalized = null;
158
+ else {
159
+ normalized = Number(value);
160
+ if (!Number.isFinite(normalized) || normalized <= 0) {
161
+ return { ok: false, code: "INVALID_PROVIDER_SETTING", message: "Budget must be a positive number or none." };
162
+ }
163
+ }
164
+ } else if (setting === "worktree") {
165
+ normalized = !!value;
166
+ } else if (setting === "permissionMode") {
167
+ normalized = value == null ? null : String(value).trim().toLowerCase();
168
+ if (normalized !== null && !new Set(["plan", "ask"]).has(normalized)) {
169
+ return { ok: false, code: "INVALID_PROVIDER_SETTING", message: "Read-only mode must be plan, ask, or off." };
170
+ }
171
+ }
172
+
173
+ const settings = state.providerSettings?.[provider.id] || getProviderSettings(state, provider.id);
174
+ const previous = settings[setting];
175
+ settings[setting] = normalized;
176
+ if (options.persist) {
177
+ try { saveState({ strict: true }); }
178
+ catch (error) {
179
+ settings[setting] = previous;
180
+ return { ok: false, code: "PROVIDER_SETTING_PERSIST_FAILED", message: error.message };
181
+ }
182
+ }
183
+ return {
184
+ ok: true,
185
+ code: null,
186
+ providerId: provider.id,
187
+ providerLabel: provider.label,
188
+ capability: capabilityName,
189
+ support: capability.support,
190
+ value: normalized,
191
+ };
192
+ }
193
+
194
+ function switchProviderState(providerId, options = {}) {
195
+ const state = options.state || currentState();
196
+ const provider = String(providerId || "").trim().toLowerCase();
197
+ if (!providerFromRegistry(provider, options.registry || providerRegistry)) {
198
+ return { ok: false, error: Object.assign(new Error(`Unknown provider: ${providerId}`), { code: "UNKNOWN_PROVIDER" }) };
199
+ }
200
+ const previous = {
201
+ provider: getActiveProvider(state),
202
+ activeSessions: structuredClone(state.activeSessions || {}),
203
+ providerSettings: structuredClone(state.providerSettings || {}),
204
+ isFirstMessage: state.isFirstMessage,
205
+ };
206
+ state.settings.backend = provider;
207
+ if (Object.prototype.hasOwnProperty.call(options, "model")) state.settings.model = options.model;
208
+ const project = state.currentSession?.name || null;
209
+ if (project && !getProviderSession(state, provider, project)) {
210
+ const last = getLastProjectSession(state.userId, project, provider);
211
+ if (last) setProviderSession(state, provider, project, last.id);
212
+ }
213
+ state.isFirstMessage = !getProviderSession(state, provider, project);
214
+ const persisted = persistSessionControl(state, options, () => {
215
+ state.activeSessions = previous.activeSessions;
216
+ state.providerSettings = previous.providerSettings;
217
+ state.settings.backend = previous.provider;
218
+ state.isFirstMessage = previous.isFirstMessage;
219
+ });
220
+ return persisted.ok
221
+ ? { ok: true, provider, sessionId: getProviderSession(state, provider, project), settings: state.providerSettings[provider] }
222
+ : persisted;
223
+ }
224
+
225
+ function selectProjectState(project, options = {}) {
226
+ const state = options.state || currentState();
227
+ if (!project || typeof project.name !== "string" || !project.name.trim()) {
228
+ return { ok: false, error: new TypeError("project name is required") };
229
+ }
230
+ const previous = {
231
+ currentSession: state.currentSession,
232
+ activeSessions: structuredClone(state.activeSessions || {}),
233
+ isFirstMessage: state.isFirstMessage,
234
+ };
235
+ clearTransientQueue(state, "Project changed before the queued run started");
236
+ state.currentSession = { name: project.name.trim(), dir: project.dir || null };
237
+ const provider = getActiveProvider(state);
238
+ const last = provider ? getLastProjectSession(state.userId, state.currentSession.name, provider) : null;
239
+ if (last) setProviderSession(state, provider, state.currentSession.name, last.id);
240
+ const sessionId = getProviderSession(state, provider, state.currentSession.name);
241
+ state.isFirstMessage = !sessionId;
242
+ const persisted = persistSessionControl(state, options, () => {
243
+ state.currentSession = previous.currentSession;
244
+ state.activeSessions = previous.activeSessions;
245
+ state.isFirstMessage = previous.isFirstMessage;
246
+ });
247
+ return persisted.ok ? { ok: true, provider, sessionId } : persisted;
248
+ }
249
+
250
+ function startNewConversation(options = {}) {
251
+ const state = options.state || currentState();
252
+ const project = state.currentSession?.name || null;
253
+ if (!project || (options.projectName && options.projectName !== project)) {
254
+ return { ok: false, error: Object.assign(new Error("The selected project changed"), { code: "STALE_PROJECT" }) };
255
+ }
256
+ const provider = getActiveProvider(state);
257
+ if (!provider) return { ok: false, error: Object.assign(new Error("Choose a provider with /backend first"), { code: "NO_ACTIVE_PROVIDER" }) };
258
+ const previous = {
259
+ activeSessions: structuredClone(state.activeSessions || {}),
260
+ usageBySession: structuredClone(state.usageBySession || {}),
261
+ isFirstMessage: state.isFirstMessage,
262
+ };
263
+ clearTransientQueue(state, "A new conversation started before the queued run began");
264
+ clearProviderSession(state, provider, project);
265
+ state.sessionUsage = freshUsage();
266
+ state.isFirstMessage = true;
267
+ const persisted = persistSessionControl(state, options, () => {
268
+ state.activeSessions = previous.activeSessions;
269
+ state.usageBySession = previous.usageBySession;
270
+ state.isFirstMessage = previous.isFirstMessage;
271
+ });
272
+ return persisted.ok ? { ok: true, provider, project } : persisted;
273
+ }
274
+
275
+ function endCurrentSession(options = {}) {
276
+ const state = options.state || currentState();
277
+ if (!state.currentSession) return { ok: false, error: Object.assign(new Error("No session"), { code: "NO_SESSION" }) };
278
+ const previous = { currentSession: state.currentSession, isFirstMessage: state.isFirstMessage };
279
+ const project = state.currentSession.name;
280
+ clearTransientQueue(state, "The project session ended before the queued run started");
281
+ state.currentSession = null;
282
+ state.isFirstMessage = true;
283
+ state.cancelRequested = false;
284
+ const persisted = persistSessionControl(state, options, () => {
285
+ state.currentSession = previous.currentSession;
286
+ state.isFirstMessage = previous.isFirstMessage;
287
+ });
288
+ return persisted.ok ? { ok: true, project } : persisted;
289
+ }
290
+
291
+ function activeContinueOptions(state = currentState()) {
292
+ const provider = getActiveProvider(state);
293
+ if (!provider) return null;
294
+ const sessionId = getProviderSession(state, provider, state.currentSession?.name);
295
+ return sessionId ? { provider, resumeSessionId: sessionId } : null;
296
+ }
297
+
298
+ function startSession(name, resumeSessionId, options = {}) {
64
299
  const state = currentState();
300
+ const provider = options.provider || getActiveProvider(state);
65
301
  let projectName, projectDir;
66
302
  if (name === "__workspace__") {
67
303
  projectName = "Workspace";
@@ -74,37 +310,49 @@ function startSession(name, resumeSessionId) {
74
310
  projectDir = path.join(WORKSPACE, result);
75
311
  }
76
312
 
77
- state.currentSession = { name: projectName, dir: projectDir };
78
- state.messageQueue = []; resetSettings(state);
313
+ if (!provider) return send("Choose Claude Code or OpenAI Codex with /backend before opening a project session.");
314
+
315
+ if (resumeSessionId && !userOwnsProviderSession(state.userId, projectName, provider, resumeSessionId)) {
316
+ return send("That conversation is unavailable for this project and provider. Open /sessions again.");
317
+ }
318
+
319
+ const selected = selectProjectState({ name: projectName, dir: projectDir }, { state, persist: false });
320
+ if (!selected.ok) return send(`Could not select project: ${selected.error.message}`);
79
321
 
80
322
  if (resumeSessionId) {
81
- state.lastSessionId = resumeSessionId;
82
- const sessions = getProjectSessions(state.userId, projectName);
83
- const s = sessions.find((x) => x.id === resumeSessionId);
323
+ setProviderSession(state, provider, projectName, resumeSessionId);
324
+ const sessions = getProjectSessions(state.userId, projectName, provider);
325
+ const s = sessions.find((x) => x.id === resumeSessionId && x.provider === provider);
84
326
  const title = s ? s.title : "";
85
327
  state.isFirstMessage = false;
86
328
  saveState();
87
329
  send(`Session: ${projectName}\nResumed: ${title || resumeSessionId.slice(0, 8)}\n\nSend text, voice, or images.\n\n/sessions — switch conversation\n/session — switch project`);
88
330
  } else {
89
- const last = getLastProjectSession(state.userId, projectName);
331
+ const last = getLastProjectSession(state.userId, projectName, provider);
90
332
  if (last) {
91
- state.lastSessionId = last.id;
333
+ setProviderSession(state, provider, projectName, last.id);
92
334
  state.isFirstMessage = false;
93
335
  saveState();
94
336
  send(`Session: ${projectName}\nResumed: ${last.title}\n\nSend text, voice, or images.\n\n/sessions — switch conversation\n/session — switch project`, {
95
337
  keyboard: { inline_keyboard: [[{ text: "New conversation", callback_data: `new:${projectName}` }]] },
96
338
  });
97
339
  } else {
98
- state.lastSessionId = null;
99
- state.isFirstMessage = true;
340
+ const existing = getProviderSession(state, provider, projectName);
341
+ state.isFirstMessage = !existing;
100
342
  saveState();
101
- send(`Session: ${projectName}\n\nSend text, voice, or images.\n\n/sessions — switch conversation\n/session — switch project`);
343
+ send(`Session: ${projectName}${existing ? `\nResumed: ${existing.slice(0, 8)}` : ""}\n\nSend text, voice, or images.\n\n/sessions — switch conversation\n/session — switch project`);
102
344
  }
103
345
  }
104
346
  }
105
347
 
106
348
  // Make startSession reachable for the action router.
107
349
  module.exports.startSession = startSession;
350
+ module.exports.clearTransientQueue = clearTransientQueue;
351
+ module.exports.switchProviderState = switchProviderState;
352
+ module.exports.selectProjectState = selectProjectState;
353
+ module.exports.startNewConversation = startNewConversation;
354
+ module.exports.endCurrentSession = endCurrentSession;
355
+ module.exports.activeContinueOptions = activeContinueOptions;
108
356
 
109
357
  // ── Registry ────────────────────────────────────────────────────────
110
358
 
@@ -123,7 +371,7 @@ register({
123
371
  if (!authorized(env)) return;
124
372
  send([
125
373
  "Session: /session /sessions /projects /continue /status /stop /end",
126
- "Settings: /model /effort /budget /plan /compact /compactwindow /worktree /mode /engine /recall /tooltrace /toolmode",
374
+ "Settings: /model /effort /budget /plan /compact /compactwindow /worktree /mode /engine /recall /tooltrace /toolmode /verbose",
127
375
  "Identity: /whoami /link",
128
376
  "Team: /people /intros /auth (owner)",
129
377
  "Automation: /cron /vault /soul /dreamsummary",
@@ -610,78 +858,100 @@ register({
610
858
  if (!authorized(env)) return;
611
859
  if (!requireSession()) return;
612
860
  const state = currentState();
613
- const sessions = getProjectSessions(state.userId, state.currentSession.name);
861
+ const provider = getActiveProvider(state);
862
+ if (!provider) return send("Choose Claude Code or OpenAI Codex with /backend first.");
863
+ const project = state.currentSession.name;
864
+ const sessions = getProjectSessions(state.userId, project, provider)
865
+ .filter((session) => session.selectable !== false);
614
866
  if (sessions.length === 0) return send("No past conversations for this project.");
615
867
  const rows = sessions.slice(0, 10).map((s) => {
616
868
  const date = new Date(s.lastUsed).toLocaleDateString();
617
- const active = state.lastSessionId === s.id ? " (active)" : "";
618
- return [{ text: `${s.title}${active} — ${date}`, callback_data: `ss:${s.id}` }];
869
+ const active = getProviderSession(state, provider, project) === s.id ? " (active)" : "";
870
+ const token = createSessionCallbackToken({
871
+ userId: state.userId,
872
+ project,
873
+ provider: s.provider,
874
+ sessionId: s.id,
875
+ });
876
+ return [{
877
+ text: `${s.title}${active} · ${s.provider} · ${project} — ${date}`,
878
+ callback_data: `ss:${token}`,
879
+ }];
619
880
  });
620
881
  rows.push([{ text: "New conversation", callback_data: `new:${state.currentSession.name}` }]);
621
882
  send(`Conversations in ${state.currentSession.name}:`, { keyboard: { inline_keyboard: rows } });
622
883
  },
623
884
  });
624
885
 
625
- // Shared model picker used by /model and the unified /login. When authAware is
626
- // set, the provider section headers show connection status so the user knows a
627
- // tap on an unconnected provider will start its auth flow (handled in the mb:
628
- // callback in actions.js).
629
- function buildModelPicker({ authAware = false } = {}) {
630
- const { settings } = currentState();
631
- const auth = authAware ? providerAuthStatus() : null;
632
- const mark = (ok) => authAware ? (ok ? " · connected" : " · tap to connect") : "";
886
+ function buildBackendPicker({ state = currentState(), registry = providerRegistry } = {}) {
887
+ const activeProvider = getActiveProvider(state);
888
+ const entries = registry.listProviders().map((provider) => ({
889
+ id: provider.id,
890
+ label: provider.label,
891
+ available: providerAvailability(provider),
892
+ active: provider.id === activeProvider,
893
+ }));
894
+ return {
895
+ entries,
896
+ activeProvider,
897
+ rows: [entries.map((entry) => ({
898
+ text: `${entry.active ? "✓ " : ""}${entry.label}${entry.available ? "" : " · unavailable"}`,
899
+ callback_data: `be:${entry.id}`,
900
+ }))],
901
+ };
902
+ }
903
+
904
+ // Shared model picker used by /model and the unified /login. Provider names,
905
+ // models, defaults, and auth markers all come from the registry contract.
906
+ function buildModelPicker({ authAware = false, state = currentState(), registry = providerRegistry } = {}) {
633
907
  const rows = [];
634
- rows.push([{ text: `── Claude${mark(auth && auth.claude)} ──`, callback_data: "noop" }]);
635
- rows.push([
636
- { text: "Fable 5", callback_data: "mb:claude:claude-fable-5" },
637
- { text: "Opus 4.8", callback_data: "mb:claude:claude-opus-4-8" },
638
- ]);
639
- rows.push([
640
- { text: "Opus 4.7", callback_data: "mb:claude:claude-opus-4-7" },
641
- { text: "Opus 4.6", callback_data: "mb:claude:claude-opus-4-6" },
642
- ]);
643
- rows.push([
644
- { text: "Sonnet 4.6", callback_data: "mb:claude:claude-sonnet-4-6" },
645
- { text: "Haiku", callback_data: "mb:claude:claude-haiku-4-5-20251001" },
646
- ]);
647
- if (resolvedCursorPath) {
648
- rows.push([{ text: "── Cursor ──", callback_data: "noop" }]);
649
- rows.push([
650
- { text: "Composer 2", callback_data: "mb:cursor:composer-2" },
651
- { text: "Composer 2 Fast", callback_data: "mb:cursor:composer-2-fast" },
652
- { text: "Auto", callback_data: "mb:cursor:auto" },
653
- ]);
654
- rows.push([
655
- { text: "Opus 4.6 Thinking", callback_data: "mb:cursor:claude-4.6-opus-high-thinking" },
656
- { text: "GPT-5.4", callback_data: "mb:cursor:gpt-5.4-medium" },
657
- ]);
658
- }
659
- if (resolvedCodexPath) {
660
- rows.push([{ text: `── Codex${mark(auth && auth.codex)} ──`, callback_data: "noop" }]);
661
- rows.push([
662
- { text: "GPT-5.5", callback_data: "mb:codex:gpt-5.5" },
663
- { text: "gpt-5", callback_data: "mb:codex:gpt-5" },
664
- { text: "gpt-5-codex", callback_data: "mb:codex:gpt-5-codex" },
665
- ]);
666
- rows.push([
667
- { text: "o3", callback_data: "mb:codex:o3" },
668
- { text: "o4-mini", callback_data: "mb:codex:o4-mini" },
669
- ]);
908
+ for (const provider of registry.listProviders()) {
909
+ let authMarker = "";
910
+ if (authAware) {
911
+ let connected = false;
912
+ try { connected = provider.authStatus().state === "authenticated"; } catch (_) {}
913
+ authMarker = connected ? " · connected" : " · tap to connect";
914
+ }
915
+ const availableMarker = providerAvailability(provider) ? "" : " · unavailable";
916
+ rows.push([{ text: `── ${provider.label}${availableMarker}${authMarker} ──`, callback_data: "noop" }]);
917
+ const choices = provider.modelChoices();
918
+ for (let index = 0; index < choices.length; index += 3) {
919
+ rows.push(choices.slice(index, index + 3).map((model) => ({
920
+ text: model,
921
+ callback_data: `mb:${provider.id}:${model}`,
922
+ })));
923
+ }
924
+ const defaultModel = provider.defaultModel();
925
+ rows.push([{
926
+ text: `Default${defaultModel ? ` (${defaultModel})` : ""}`,
927
+ callback_data: `mb:${provider.id}:default`,
928
+ }]);
670
929
  }
671
- rows.push([{ text: `Default (Claude: ${DEFAULT_CLAUDE_MODEL})`, callback_data: "m:default" }]);
672
- const beLabel = settings.backend === "cursor" ? "Cursor" : settings.backend === "codex" ? "Codex" : "Claude";
673
- return { rows, beLabel, model: settings.model };
930
+ const active = providerFromRegistry(getActiveProvider(state), registry);
931
+ const settings = active ? getProviderSettings(state, active.id) : { model: null };
932
+ return {
933
+ rows,
934
+ beLabel: active?.label || "No provider selected",
935
+ model: settings.model,
936
+ };
674
937
  }
675
938
 
939
+ module.exports.buildBackendPicker = buildBackendPicker;
940
+ module.exports.buildModelPicker = buildModelPicker;
941
+ module.exports.applyProviderSetting = applyProviderSetting;
942
+
676
943
  register({
677
- name: "model", description: "Switch model (opus/sonnet/haiku)", args: "[<model>]",
944
+ name: "model", description: "Switch model for the active provider", args: "[<model>]",
678
945
  handler: async (env, { tail }) => {
679
946
  if (!authorized(env)) return;
680
947
  if (tail) {
681
- const { settings } = currentState();
682
- settings.model = tail.toLowerCase();
683
- if (settings.model === "default") settings.model = null;
684
- return send(`Model: ${settings.model || "default"}`);
948
+ const state = currentState();
949
+ const provider = providerFromRegistry(getActiveProvider(state));
950
+ if (!provider) return send("Choose an available provider with /backend first.");
951
+ const settings = getProviderSettings(state, provider.id);
952
+ settings.model = tail.trim().toLowerCase() === "default" ? null : tail.trim();
953
+ saveState();
954
+ return send(`${provider.label} model: ${settings.model || provider.defaultModel() || "provider default"}`);
685
955
  }
686
956
  const { rows, beLabel, model } = buildModelPicker();
687
957
  send(`Current: ${beLabel} · ${model || "default"}\n\nPick a model — backend switches automatically.\nOr type /model <name>.`, { keyboard: { inline_keyboard: rows } });
@@ -689,22 +959,25 @@ register({
689
959
  });
690
960
 
691
961
  register({
692
- name: "effort", description: "Set effort level", args: "[low|medium|high|max]",
962
+ name: "effort", description: "Set provider effort level", args: "[<level>|default]",
693
963
  handler: async (env, { tail }) => {
694
964
  if (!authorized(env)) return;
695
- const { settings } = currentState();
696
- if (settings.backend === "cursor") return send("Effort levels are not supported on Cursor Agent.\nSwitch to Claude with /claude to use this.");
697
- if (settings.backend === "codex") return send("Effort levels are not exposed as a flag on Codex.\nSet `model_reasoning_effort` in ~/.codex/config.toml, or switch to /claude.");
965
+ const state = currentState();
966
+ const provider = providerFromRegistry(getActiveProvider(state));
967
+ if (!provider) return send("Choose an available provider with /backend first.");
968
+ const settings = getProviderSettings(state, provider.id);
969
+ const capability = provider.capabilities.effort;
970
+ if (capability.support === "unsupported") return send(capability.reason);
698
971
  if (tail) {
699
- const e = tail.toLowerCase();
700
- settings.effort = ["low", "medium", "high", "max"].includes(e) ? e : null;
701
- return send(`Effort: ${settings.effort || "default"}`);
702
- }
703
- send(`Effort: ${settings.effort || "default"}`, {
704
- keyboard: { inline_keyboard: [
705
- [{ text: "Low", callback_data: "e:low" }, { text: "Med", callback_data: "e:medium" }, { text: "High", callback_data: "e:high" }, { text: "Max", callback_data: "e:max" }],
706
- [{ text: "Default", callback_data: "e:default" }],
707
- ] },
972
+ const result = applyProviderSetting(state, provider.id, "effort", tail, { requireActive: true, persist: true });
973
+ return send(result.ok ? `${provider.label} effort: ${result.value || "default"}` : result.message);
974
+ }
975
+ const buttons = capability.values.map((value) => ({ text: value, callback_data: `e:${provider.id}:${value}` }));
976
+ const rows = [];
977
+ for (let index = 0; index < buttons.length; index += 4) rows.push(buttons.slice(index, index + 4));
978
+ rows.push([{ text: "Default", callback_data: `e:${provider.id}:default` }]);
979
+ send(`${provider.label} effort: ${settings.effort || "default"}`, {
980
+ keyboard: { inline_keyboard: rows },
708
981
  });
709
982
  },
710
983
  });
@@ -805,55 +1078,75 @@ register({
805
1078
  });
806
1079
 
807
1080
  register({
808
- name: "budget", description: "Set max spend for next task", args: "[$N]",
1081
+ name: "verbose", description: "Stream thinking into the live preview (on/off)", args: "[on|off]",
809
1082
  handler: async (env, { tail }) => {
810
1083
  if (!authorized(env)) return;
811
1084
  const { settings } = currentState();
812
- if (settings.backend === "cursor") return send("Budget limits are not supported on Cursor Agent.\nSwitch to Claude with /claude to use this.");
813
- if (settings.backend === "codex") return send("Budget limits are not supported on Codex.\nSwitch to Claude with /claude to use this.");
814
1085
  if (tail) {
815
- const v = parseFloat(tail.replace("$", ""));
816
- settings.budget = v > 0 ? v : null;
817
- return send(`Budget: ${settings.budget ? "$" + settings.budget : "none"}`);
1086
+ const v = tail.trim().toLowerCase();
1087
+ if (v === "on" || v === "true") settings.showThinking = true;
1088
+ else if (v === "off" || v === "false") settings.showThinking = false;
1089
+ else return send(`Usage: /verbose [on|off]. Currently ${settings.showThinking ? "on" : "off"}.`);
1090
+ saveState();
1091
+ return send(`Verbose thinking: ${settings.showThinking ? "on" : "off"}`);
818
1092
  }
819
- send(`Budget: ${settings.budget ? "$" + settings.budget : "none"}`, {
820
- keyboard: { inline_keyboard: [
821
- [{ text: "$1", callback_data: "b:1" }, { text: "$5", callback_data: "b:5" }, { text: "$10", callback_data: "b:10" }, { text: "$25", callback_data: "b:25" }],
822
- [{ text: "No limit", callback_data: "b:none" }],
823
- ] },
824
- });
1093
+ send(
1094
+ `Verbose thinking: ${settings.showThinking ? "on" : "off"}\n\n` +
1095
+ "When on, the live status preview also streams my thinking (reasoning) in italics as it happens. Narration and the clean final answer are unaffected. Off by default.",
1096
+ { keyboard: { inline_keyboard: [
1097
+ [{ text: "On", callback_data: "vb:on" }, { text: "Off", callback_data: "vb:off" }],
1098
+ ] } },
1099
+ );
825
1100
  },
826
1101
  });
827
1102
 
828
1103
  register({
829
- name: "plan", description: "Toggle plan mode",
830
- handler: async (env) => {
1104
+ name: "budget", description: "Set max spend for next task", args: "[$N]",
1105
+ handler: async (env, { tail }) => {
831
1106
  if (!authorized(env)) return;
832
1107
  const state = currentState();
833
- const { settings } = state;
834
- const p = settings.permissionMode === "plan";
835
- settings.permissionMode = p ? null : "plan";
836
- const label = settings.backend === "cursor" ? "read-only planning, no edits"
837
- : settings.backend === "codex" ? "read-only sandbox, no edits"
838
- : "plan permission mode";
839
- if (p) {
840
- if (settings.backend === "cursor") state.cursorSessionId = null;
841
- else if (settings.backend === "codex") state.codexSessionId = null;
842
- }
843
- send(p ? "Plan mode off (session reset)." : `Plan mode on (${label}).`);
1108
+ const provider = providerFromRegistry(getActiveProvider(state));
1109
+ if (!provider) return send("Choose an available provider with /backend first.");
1110
+ const capability = provider.capabilities.budget;
1111
+ if (capability.support === "unsupported") return send(capability.reason);
1112
+ const settings = getProviderSettings(state, provider.id);
1113
+ if (tail) {
1114
+ const value = tail.trim().toLowerCase() === "none" ? null : tail.replace("$", "");
1115
+ const result = applyProviderSetting(state, provider.id, "budget", value, { requireActive: true, persist: true });
1116
+ return send(result.ok ? `${provider.label} budget: ${result.value ? "$" + result.value : "none"}` : result.message);
1117
+ }
1118
+ send(`${provider.label} budget: ${settings.budget ? "$" + settings.budget : "none"}`, {
1119
+ keyboard: { inline_keyboard: [
1120
+ [{ text: "$1", callback_data: `b:${provider.id}:1` }, { text: "$5", callback_data: `b:${provider.id}:5` }, { text: "$10", callback_data: `b:${provider.id}:10` }, { text: "$25", callback_data: `b:${provider.id}:25` }],
1121
+ [{ text: "No limit", callback_data: `b:${provider.id}:none` }],
1122
+ ] },
1123
+ });
844
1124
  },
845
1125
  });
846
1126
 
1127
+ async function toggleReadOnlyMode(env, mode) {
1128
+ if (!authorized(env)) return;
1129
+ const state = currentState();
1130
+ const provider = providerFromRegistry(getActiveProvider(state));
1131
+ if (!provider) return send("Choose an available provider with /backend first.");
1132
+ const settings = getProviderSettings(state, provider.id);
1133
+ const next = settings.permissionMode === mode ? null : mode;
1134
+ const result = applyProviderSetting(state, provider.id, "permissionMode", next, { requireActive: true, persist: true });
1135
+ if (!result.ok) return send(result.message);
1136
+ const label = mode === "ask" ? "Read-only Q&A" : "Plan mode";
1137
+ return send(next === null
1138
+ ? `${label} off.`
1139
+ : `${label} on for ${provider.label} (${result.support}).`);
1140
+ }
1141
+
847
1142
  register({
848
- name: "ask", description: "Toggle ask mode (Cursor only)",
849
- handler: async (env) => {
850
- if (!authorized(env)) return;
851
- const { settings } = currentState();
852
- if (settings.backend !== "cursor") return send("Ask mode is only available on Cursor Agent.\nUse /cursor to switch.");
853
- const a = settings.permissionMode === "ask";
854
- settings.permissionMode = a ? null : "ask";
855
- send(a ? "Ask mode off." : "Ask mode on (read-only Q&A, no edits).");
856
- },
1143
+ name: "plan", description: "Toggle read-only planning",
1144
+ handler: async (env) => toggleReadOnlyMode(env, "plan"),
1145
+ });
1146
+
1147
+ register({
1148
+ name: "ask", description: "Toggle read-only Q&A",
1149
+ handler: async (env) => toggleReadOnlyMode(env, "ask"),
857
1150
  });
858
1151
 
859
1152
  register({
@@ -875,13 +1168,28 @@ register({
875
1168
  },
876
1169
  });
877
1170
 
1171
+ register({
1172
+ name: "new", description: "Start a new conversation for this provider",
1173
+ handler: async (env) => {
1174
+ if (!authorized(env)) return;
1175
+ if (!requireSession()) return;
1176
+ const result = startNewConversation();
1177
+ if (!result.ok) return send(`Could not start a new conversation: ${result.error.message}`);
1178
+ return send(`Session: ${result.project}\nNew ${result.provider} conversation\n\nSend text, voice, or images.`);
1179
+ },
1180
+ });
1181
+
878
1182
  register({
879
1183
  name: "continue", description: "Resume last conversation",
880
1184
  handler: async (env) => {
881
1185
  if (!authorized(env)) return;
882
1186
  if (!requireSession()) return;
883
- if (!getActiveSessionId()) return send("No conversation to continue.");
884
- await runClaude("continue where we left off", currentState().currentSession.dir, env.messageId);
1187
+ const state = currentState();
1188
+ const continuation = activeContinueOptions(state);
1189
+ if (!continuation) return send("No conversation to continue.");
1190
+ const prompt = "continue where we left off";
1191
+ const runContext = admitRunContext(prompt, state.currentSession.dir, env.messageId, continuation);
1192
+ return runAgent(prompt, runContext.cwd, env.messageId, { runContext, ...continuation });
885
1193
  },
886
1194
  });
887
1195
 
@@ -941,65 +1249,60 @@ register({
941
1249
  name: "worktree", description: "Toggle isolated git branch",
942
1250
  handler: async (env) => {
943
1251
  if (!authorized(env)) return;
944
- const { settings } = currentState();
945
- settings.worktree = !settings.worktree;
946
- send(settings.worktree ? "Worktree on." : "Worktree off.");
947
- },
948
- });
949
-
950
- register({
951
- name: "cursor", description: "Switch to Cursor Agent backend",
952
- handler: async (env) => {
953
- if (!authorized(env)) return;
954
- if (!resolvedCursorPath) return send("Cursor Agent CLI not found.\nSet CURSOR_PATH in .env or install: https://docs.cursor.com/agent");
955
1252
  const state = currentState();
956
- state.settings.backend = "cursor";
957
- state.settings.model = null;
958
- saveState();
959
- const sid = state.cursorSessionId ? `\nSession: ${state.cursorSessionId.slice(0, 8)}...` : "\nNew session.";
960
- send(`Switched to Cursor Agent.${sid}\n\n/claude switch back · /codex Codex`);
1253
+ const provider = providerFromRegistry(getActiveProvider(state));
1254
+ if (!provider) return send("Choose an available provider with /backend first.");
1255
+ const settings = getProviderSettings(state, provider.id);
1256
+ const result = applyProviderSetting(state, provider.id, "worktree", !settings.worktree, { requireActive: true, persist: true });
1257
+ send(result.ok ? `${provider.label} worktree ${result.value ? "on" : "off"}.` : result.message);
961
1258
  },
962
1259
  });
963
1260
 
1261
+ async function switchProviderCommand(env, providerId) {
1262
+ if (!authorized(env)) return;
1263
+ const provider = providerFromRegistry(providerId);
1264
+ if (!provider) return send(`Unknown provider: ${providerId}`);
1265
+ if (!providerAvailability(provider)) {
1266
+ return send(`${provider.label} is unavailable. Install its CLI or configure its path, then run /doctor.`);
1267
+ }
1268
+ const state = currentState();
1269
+ const switched = switchProviderState(provider.id, { state });
1270
+ if (!switched.ok) return send(`Could not switch provider: ${switched.error.message}`);
1271
+ const session = switched.sessionId ? `\nSession: ${switched.sessionId.slice(0, 8)}...` : "\nNew session.";
1272
+ return send(`Switched to ${provider.label}.${session}`);
1273
+ }
1274
+
964
1275
  register({
965
- name: "claude", description: "Switch to Claude Code backend",
966
- handler: async (env) => {
967
- if (!authorized(env)) return;
968
- const state = currentState();
969
- state.settings.backend = "claude";
970
- state.settings.model = null;
971
- saveState();
972
- const sid = state.lastSessionId ? `\nSession: ${state.lastSessionId.slice(0, 8)}...` : "\nNew session.";
973
- send(`Switched to Claude Code.${sid}\n\n/cursor — Cursor · /codex — Codex`);
974
- },
1276
+ name: "claude", description: "Switch to Claude Code provider",
1277
+ handler: async (env) => switchProviderCommand(env, "claude"),
975
1278
  });
976
1279
 
977
1280
  register({
978
- name: "codex", description: "Switch to OpenAI Codex backend",
979
- handler: async (env) => {
980
- if (!authorized(env)) return;
981
- if (!resolvedCodexPath) return send("Codex CLI not found.\nInstall: npm install -g @openai/codex\nThen run `codex login` to authenticate.");
982
- const state = currentState();
983
- state.settings.backend = "codex";
984
- state.settings.model = null;
985
- saveState();
986
- const sid = state.codexSessionId ? `\nSession: ${state.codexSessionId.slice(0, 8)}...` : "\nNew session.";
987
- send(`Switched to Codex.${sid}\n\n/claude — Claude · /cursor — Cursor`);
988
- },
1281
+ name: "codex", description: "Switch to OpenAI Codex provider",
1282
+ handler: async (env) => switchProviderCommand(env, "codex"),
989
1283
  });
990
1284
 
991
1285
  register({
992
- name: "backend", description: "Show/switch active backend",
993
- handler: async (env) => {
1286
+ name: "backend", description: "Show/switch active provider", args: "[claude|codex]",
1287
+ handler: async (env, { tail }) => {
994
1288
  if (!authorized(env)) return;
995
- const { settings } = currentState();
996
- const label = settings.backend === "cursor" ? "Cursor Agent" : settings.backend === "codex" ? "Codex" : "Claude Code";
997
- const cursorAvail = resolvedCursorPath ? "available" : "not found";
998
- const codexAvail = resolvedCodexPath ? "available" : "not found";
999
- send(`Backend: ${label}\n\nClaude Code: available\nCursor Agent: ${cursorAvail}\nCodex: ${codexAvail}`, {
1000
- keyboard: { inline_keyboard: [
1001
- [{ text: "Claude Code", callback_data: "be:claude" }, { text: "Cursor Agent", callback_data: "be:cursor" }, { text: "Codex", callback_data: "be:codex" }],
1002
- ] },
1289
+ if (tail) {
1290
+ const provider = tail.trim().toLowerCase();
1291
+ const descriptor = providerFromRegistry(provider);
1292
+ if (!descriptor) return send(`Usage: /backend [${providerRegistry.listProviders().map((entry) => entry.id).join("|")}]`);
1293
+ if (!providerAvailability(descriptor)) return send(`${descriptor.label} is unavailable. Run /doctor for recovery steps.`);
1294
+ const switched = switchProviderState(descriptor.id);
1295
+ if (!switched.ok) return send(`Could not switch provider: ${switched.error.message}`);
1296
+ return send(`Switched to ${descriptor.label}.${switched.sessionId ? `\nSession: ${switched.sessionId.slice(0, 8)}...` : "\nNew session."}`);
1297
+ }
1298
+ const picker = buildBackendPicker();
1299
+ const active = providerFromRegistry(picker.activeProvider);
1300
+ send([
1301
+ `Provider: ${active?.label || "none"}`,
1302
+ "",
1303
+ ...picker.entries.map((entry) => `${entry.label}: ${entry.available ? "available" : "unavailable"}`),
1304
+ ].join("\n"), {
1305
+ keyboard: { inline_keyboard: picker.rows },
1003
1306
  });
1004
1307
  },
1005
1308
  });
@@ -1008,9 +1311,11 @@ register({
1008
1311
  name: "mode", description: "Bot mode (direct/agent)",
1009
1312
  handler: async (env) => {
1010
1313
  if (!authorized(env)) return;
1011
- await send("Bot mode: *direct* (default)\n\nSwitch to agent mode for non-blocking execution.\nIn agent mode, heavy tasks run in the background and you can keep chatting.", {
1314
+ const mode = require("./side-chat").readRuntimeMode(CONFIG_DIR);
1315
+ const target = mode === "agent" ? "direct" : "agent";
1316
+ await send(`Bot mode: ${mode}\n\nBoth modes use the same modular runtime. Agent mode gives one isolated, read-only side response while a heavy main task runs; direct mode queues follow-up messages.`, {
1012
1317
  parseMode: "HTML",
1013
- keyboard: { inline_keyboard: [[{ text: "Switch to Agent Mode", callback_data: "mode:agent" }]] },
1318
+ keyboard: { inline_keyboard: [[{ text: `Switch to ${target} mode`, callback_data: `mode:${target}` }]] },
1014
1319
  });
1015
1320
  },
1016
1321
  });
@@ -1020,24 +1325,37 @@ register({
1020
1325
  handler: async (env) => {
1021
1326
  if (!authorized(env)) return;
1022
1327
  const state = currentState();
1328
+ const sideCancellation = require("./side-chat").sideChatCoordinator.cancel(state.userId);
1329
+ // /stop cancels the CURRENT turn only. Messages queued behind it stay
1330
+ // queued — the cancelled run's unwind drains them as usual.
1331
+ const queuedNote = () => {
1332
+ const n = Array.isArray(state.messageQueue) ? state.messageQueue.length : 0;
1333
+ return n ? ` ${n} queued message${n === 1 ? "" : "s"} will run next.` : "";
1334
+ };
1023
1335
  if (state.runningProcess) {
1024
1336
  const pid = state.runningProcess.pid;
1025
1337
  const { killProcessTree } = require("./process-tree");
1338
+ // Quiet unwind: deliverForegroundResult consumes this flag to suppress
1339
+ // the "run failed" diagnostic the SIGTERM exit would otherwise emit.
1340
+ state.cancelRequested = true;
1026
1341
  killProcessTree(pid, "SIGTERM");
1027
1342
  setTimeout(() => killProcessTree(pid, "SIGKILL"), 3000);
1028
1343
  state.runningProcess = null;
1029
- if (state.streamInterval) clearTimeout(state.streamInterval);
1344
+ // Stop pending preview flushes but keep statusMessageId/Channel: the
1345
+ // killed run's delivery unwind edits the preview to drop its footer.
1346
+ if (state.streamInterval) { clearTimeout(state.streamInterval); state.streamInterval = null; }
1030
1347
  if (state.typingHeartbeat) { clearInterval(state.typingHeartbeat); state.typingHeartbeat = null; }
1031
- state.messageQueue = [];
1032
- await send("Cancelled.");
1348
+ await sideCancellation;
1349
+ await send(`Cancelled.${queuedNote()}`);
1033
1350
  } else if (state.preparingRun) {
1034
- // Turn is mid-recall/compaction — no process to kill yet. Flag it so
1035
- // runClaude bails at its pre-spawn checkpoint, and stop typing now.
1351
+ // Turn is mid-recall/prompt-build — no process to kill yet. Flag it so
1352
+ // the runner's pre-spawn shouldAbort checkpoint bails before spawning.
1036
1353
  state.cancelRequested = true;
1037
- state.messageQueue = [];
1038
1354
  if (state.typingHeartbeat) { clearInterval(state.typingHeartbeat); state.typingHeartbeat = null; }
1039
- await send("Cancelled.");
1040
- } else await send("Nothing running.");
1355
+ await sideCancellation;
1356
+ await send(`Cancelled.${queuedNote()}`);
1357
+ } else if (await sideCancellation) await send("Cancelled side response.");
1358
+ else await send("Nothing running.");
1041
1359
  },
1042
1360
  });
1043
1361
 
@@ -1045,7 +1363,9 @@ register({
1045
1363
  name: "doctor", aliases: ["requirements"], description: "Check CLI requirements",
1046
1364
  handler: async (env) => {
1047
1365
  if (!authorized(env)) return;
1048
- await send(formatDoctorReport(runDoctorChecks()));
1366
+ // Includes the live Codex tool-hook probe (a real, short codex exec turn)
1367
+ // — /doctor is the only surface that opts in.
1368
+ await send(formatDoctorReport(runDoctorChecks({ probeCodexHook: true })));
1049
1369
  },
1050
1370
  });
1051
1371
 
@@ -1054,15 +1374,22 @@ register({
1054
1374
  handler: async (env) => {
1055
1375
  if (!authorized(env)) return;
1056
1376
  const state = currentState();
1057
- if (!state.currentSession) return send("No session.", { keyboard: { inline_keyboard: [[{ text: "Pick", callback_data: "show:projects" }]] } });
1058
- const { settings } = state;
1059
- const backendLabel = settings.backend === "cursor" ? "Cursor Agent" : settings.backend === "codex" ? "Codex" : "Claude Code";
1377
+ const provider = providerFromRegistry(getActiveProvider(state));
1378
+ const settings = provider ? getProviderSettings(state, provider.id) : null;
1060
1379
  const cronCount = jobs.listForChannel(env.adapter.id, env.channelId).filter((j) => j.kind === "cron").length;
1061
- const activeEngine = require("./recall").activeEngineName(settings) + (settings.recallEngine ? "" : " (default)");
1380
+ const commonSettings = state.settings;
1381
+ const activeEngine = require("./recall").activeEngineName(commonSettings) + (commonSettings.recallEngine ? "" : " (default)");
1382
+ const capabilitySummary = provider
1383
+ ? ["readOnlyMode", "effort", "budget", "worktree"].map((name) => {
1384
+ const capability = provider.capabilities[name];
1385
+ return `${name === "readOnlyMode" ? "read-only" : name} ${capability.support}`;
1386
+ }).join(" · ")
1387
+ : "none";
1062
1388
  send([
1063
- `Project: ${state.currentSession.name}`,
1064
- `Backend: ${backendLabel}`,
1065
- `Model: ${settings.model || "default"} | Effort: ${settings.effort || "default"}`,
1389
+ `Project: ${state.currentSession?.name || "none"}`,
1390
+ `Provider: ${provider?.label || "none"}`,
1391
+ `Model: ${settings?.model || provider?.defaultModel() || "default"} | Effort: ${settings?.effort || "default"}`,
1392
+ `Capabilities: ${capabilitySummary}`,
1066
1393
  `Recall engine: ${activeEngine}`,
1067
1394
  `Auto-compact: ${formatCompactWindow(state)}`,
1068
1395
  `Vault: ${vault.isUnlocked() ? "unlocked" : "locked"} | Crons: ${cronCount}`,
@@ -1077,14 +1404,9 @@ register({
1077
1404
  if (!authorized(env)) return;
1078
1405
  const state = currentState();
1079
1406
  if (state.currentSession) {
1080
- const n = state.currentSession.name;
1081
- state.currentSession = null;
1082
- state.lastSessionId = null;
1083
- state.messageQueue = [];
1084
- resetSettings(state);
1085
- resetSessionUsage(state);
1086
- saveState();
1087
- send(`Ended: ${n}`, { keyboard: { inline_keyboard: [[{ text: "New session", callback_data: "show:projects" }]] } });
1407
+ const result = endCurrentSession({ state });
1408
+ if (!result.ok) return send(`Could not end session: ${result.error.message}`);
1409
+ send(`Ended: ${result.project}`, { keyboard: { inline_keyboard: [[{ text: "New session", callback_data: "show:projects" }]] } });
1088
1410
  } else send("No session.");
1089
1411
  },
1090
1412
  });
@@ -1281,7 +1603,9 @@ register({
1281
1603
  `CRUCIAL — also crystallise the EXECUTABLE part: if the work involved a script, API call, or any repeatable command sequence you wrote inline (a heredoc, a one-off .py/.sh, a curl chain), turn it into a reusable tool instead of leaving it to be re-typed. Write the script to a file (parameterise the bits that vary; read any credential from the env as $NAME rather than hardcoding), then register it with 'open-claudia tool add <path> --pack <dir> --desc "..." [--requires "key1,key2"] [--usage "..."]', linking it to the pack you just touched. Reference creds by their keyring names (see 'open-claudia keyring list'); never write a secret into the tool. The tool is the runnable form of the pack's Procedure — save both. ` +
1282
1604
  `No secrets or tokens in packs OR tools. When done, reply with one short line naming the pack you created or updated, any tool you saved, and what it covers. ` +
1283
1605
  `If the recent work is genuinely not reusable, say so instead of forcing a skill.`;
1284
- await runClaude(prompt, currentState().currentSession.dir, env.messageId);
1606
+ const state = currentState();
1607
+ const runContext = admitRunContext(prompt, state.currentSession.dir, env.messageId, { purpose: "learn" });
1608
+ return runAgent(prompt, runContext.cwd, env.messageId, { runContext, purpose: "learn" });
1285
1609
  },
1286
1610
  });
1287
1611
 
@@ -1291,32 +1615,19 @@ register({
1291
1615
  name: "auth_status", aliases: ["auth status"], description: "Check Claude Code auth",
1292
1616
  handler: async (env) => {
1293
1617
  if (!authorized(env)) return;
1294
- const { spawn } = require("child_process");
1295
- const { CLAUDE_PATH } = require("./config");
1296
1618
  const tokenInfo = getClaudeOAuthToken();
1297
- const proc = spawn(CLAUDE_PATH, ["auth", "status"], {
1298
- cwd: process.env.HOME || require("os").homedir(),
1299
- env: claudeSubprocessEnv(),
1300
- stdio: ["ignore", "pipe", "pipe"],
1301
- });
1302
- let output = "";
1303
- proc.stdout.on("data", (d) => { output += d.toString(); });
1304
- proc.stderr.on("data", (d) => { output += d.toString(); });
1305
- await new Promise((resolve) => {
1306
- proc.on("close", async (code) => {
1307
- const clean = redactSensitive(output.trim()) || "(no output)";
1308
- await send([
1309
- `Claude auth status: exit ${code}`,
1310
- ...summarizeClaudeAuthStatus(output, code, tokenInfo),
1311
- `Bot OAuth token: ${tokenInfo.value ? "configured via " + tokenInfo.source : "not configured"}`,
1312
- `Vault: ${vault.isUnlocked() ? "unlocked" : "locked"}`,
1313
- "",
1314
- clean.slice(-2500),
1315
- ].join("\n"));
1316
- resolve();
1317
- });
1318
- proc.on("error", async (err) => { await send(`Claude auth status failed: ${redactSensitive(err.message)}`); resolve(); });
1319
- });
1619
+ const status = providerRegistry.providerStatus("claude");
1620
+ const output = runClaudeAuthStatusDiagnostic();
1621
+ const code = status.availability.state !== "available" ? 127 : (status.auth.state === "unauthenticated" ? 1 : 0);
1622
+ const clean = redactSensitive(output.trim()) || "(no output)";
1623
+ await send([
1624
+ `Claude auth status: exit ${code}`,
1625
+ ...summarizeClaudeAuthStatus(output, code, tokenInfo),
1626
+ `Bot OAuth token: ${tokenInfo.value ? "configured via " + tokenInfo.source : "not configured"}`,
1627
+ `Vault: ${vault.isUnlocked() ? "unlocked" : "locked"}`,
1628
+ "",
1629
+ clean.slice(-2500),
1630
+ ].join("\n"));
1320
1631
  },
1321
1632
  });
1322
1633
 
@@ -1432,7 +1743,7 @@ register({
1432
1743
  name: "codex_setup_token", aliases: ["codex_use_api_key"], description: "Save an OpenAI API key", args: "[<key>]", ownerOnly: true,
1433
1744
  handler: async (env, { tail }) => {
1434
1745
  if (!ownerEnv(env)) return send("Owner only — Codex auth is shared across users.");
1435
- if (!resolvedCodexPath) return send("Codex CLI not found. Install: npm install -g @openai/codex");
1746
+ if (!discoverProviderExecutable("codex")) return send("Codex CLI not found. Install: npm install -g @openai/codex");
1436
1747
  const key = (tail || "").trim();
1437
1748
  await deleteMessage(env.messageId);
1438
1749
  if (!key) {
@@ -1606,17 +1917,24 @@ register({
1606
1917
  handler: async (env, { tail }) => {
1607
1918
  if (!authorized(env)) return;
1608
1919
  const channelJobs = () => jobs.listForChannel(env.adapter.id, env.channelId).filter((j) => j.kind === "cron");
1920
+ const archivedNote = () => {
1921
+ const archived = jobs.listArchived().filter((j) => !j.channelId || String(j.channelId) === String(env.channelId));
1922
+ if (archived.length === 0) return "";
1923
+ const shown = archived.slice(0, 5).map((j) => `• ${j.label || j.id} — ${j.archiveReason || "archived"}`);
1924
+ const more = archived.length > 5 ? `\n…and ${archived.length - 5} more (open-claudia cron-list shows all).` : "";
1925
+ return `\n\n⚠️ Disabled by provider migration (will not fire):\n${shown.join("\n")}${more}`;
1926
+ };
1609
1927
  if (!tail) {
1610
1928
  const list = channelJobs();
1611
1929
  if (list.length === 0) {
1612
- return send("No crons.\n\nAdd: /cron add \"<schedule>\" <project> \"<prompt>\"\n\nOr pick a preset:", {
1930
+ return send("No crons." + archivedNote() + "\n\nAdd: /cron add \"<schedule>\" <project> \"<prompt>\"\n\nOr pick a preset:", {
1613
1931
  keyboard: { inline_keyboard: [
1614
1932
  [{ text: "Standup 9am", callback_data: "cp:standup" }, { text: "Git digest 6pm", callback_data: "cp:git" }],
1615
1933
  [{ text: "Dep check Mon", callback_data: "cp:deps" }, { text: "Health 30min", callback_data: "cp:health" }],
1616
1934
  ] },
1617
1935
  });
1618
1936
  }
1619
- return send("Crons:\n\n" + list.map((c, i) => `${i + 1}. ${c.label} (${c.schedule}) — ${c.project || ""}`).join("\n") + "\n\nRemove: /cron remove <#>");
1937
+ return send("Crons:\n\n" + list.map((c, i) => `${i + 1}. ${c.label} (${c.schedule}) — ${c.project || ""}`).join("\n") + archivedNote() + "\n\nRemove: /cron remove <#>");
1620
1938
  }
1621
1939
 
1622
1940
  const addMatch = tail.match(/^add\s+"(.+)"\s+(\S+)\s+"(.+)"$/);