@inetafrica/open-claudia 2.14.8 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. package/.env.example +30 -4
  2. package/CHANGELOG.md +21 -0
  3. package/README.md +87 -66
  4. package/bin/agent.js +44 -18
  5. package/bin/cli.js +30 -28
  6. package/bin/dream.js +5 -11
  7. package/bin/loopback-client.js +29 -5
  8. package/bin/schedule.js +19 -5
  9. package/bin/tool.js +23 -5
  10. package/bot-agent.js +5 -2350
  11. package/bot.js +105 -7
  12. package/channels/telegram/adapter.js +6 -1
  13. package/channels/voice/adapter.js +1 -0
  14. package/channels/voice/manage.js +43 -5
  15. package/config-dir.js +3 -11
  16. package/core/actions.js +149 -40
  17. package/core/approvals.js +136 -29
  18. package/core/auth-flow.js +103 -19
  19. package/core/config-dir.js +19 -0
  20. package/core/config.js +127 -69
  21. package/core/doctor.js +111 -57
  22. package/core/dream.js +219 -62
  23. package/core/enforcer.js +0 -0
  24. package/core/entities.js +2 -1
  25. package/core/fsutil.js +55 -0
  26. package/core/handlers.js +503 -217
  27. package/core/ideas.js +2 -1
  28. package/core/identity.js +8 -11
  29. package/core/io.js +24 -3
  30. package/core/jobs.js +589 -72
  31. package/core/lessons.js +3 -2
  32. package/core/loopback.js +45 -8
  33. package/core/memory-mutation-queue.js +241 -0
  34. package/core/migration-backup.js +1060 -0
  35. package/core/pack-review.js +295 -88
  36. package/core/packs.js +4 -3
  37. package/core/persona.js +2 -1
  38. package/core/process-tree.js +19 -2
  39. package/core/provider-migration.js +39 -0
  40. package/core/provider-status.js +80 -0
  41. package/core/providers/claude-events.js +117 -0
  42. package/core/providers/claude-hook.js +114 -0
  43. package/core/providers/claude.js +296 -0
  44. package/core/providers/codex-events.js +121 -0
  45. package/core/providers/codex-hook.js +280 -0
  46. package/core/providers/codex.js +286 -0
  47. package/core/providers/contract.js +153 -0
  48. package/core/providers/env.js +148 -0
  49. package/core/providers/events.js +170 -0
  50. package/core/providers/hook-command.js +22 -0
  51. package/core/providers/index.js +240 -0
  52. package/core/providers/utility-policy.js +269 -0
  53. package/core/recall/classic.js +2 -2
  54. package/core/recall/discoverer.js +71 -22
  55. package/core/recall/metrics.js +27 -1
  56. package/core/recall/tuning.js +4 -1
  57. package/core/recall/warm-walker.js +151 -108
  58. package/core/recall-filter.js +86 -61
  59. package/core/router.js +79 -7
  60. package/core/run-context.js +185 -0
  61. package/core/runner.js +1415 -1282
  62. package/core/scheduler.js +515 -210
  63. package/core/side-chat.js +346 -0
  64. package/core/state.js +1096 -95
  65. package/core/subagent.js +72 -98
  66. package/core/system-prompt.js +462 -279
  67. package/core/tool-guard.js +73 -39
  68. package/core/tools.js +22 -5
  69. package/core/transcripts.js +30 -1
  70. package/core/usage-log.js +19 -4
  71. package/core/utility-agent.js +654 -0
  72. package/core/web-auth.js +29 -13
  73. package/core/web-sessions.js +78 -0
  74. package/docs/CHANNEL_DESIGN.md +181 -0
  75. package/docs/MULTI_CHANNEL_PLAN.md +254 -0
  76. package/docs/PROVIDER_MIGRATION.md +67 -0
  77. package/health.js +50 -39
  78. package/package.json +51 -4
  79. package/setup.js +198 -38
  80. package/soul.md +39 -0
  81. package/test-ability-extraction.js +5 -0
  82. package/test-approval-async.js +63 -4
  83. package/test-cursor-removal.js +334 -0
  84. package/test-delivery-contract.js +88 -0
  85. package/test-enforcer.js +70 -27
  86. package/test-fixtures/current-provider-parity.json +132 -0
  87. package/test-fixtures/fake-agent-cli.js +477 -0
  88. package/test-fixtures/migrations/claude-only/jobs.json +3 -0
  89. package/test-fixtures/migrations/claude-only/sessions.json +5 -0
  90. package/test-fixtures/migrations/claude-only/state.json +5 -0
  91. package/test-fixtures/migrations/cursor-selected/crons.json +3 -0
  92. package/test-fixtures/migrations/cursor-selected/jobs.json +3 -0
  93. package/test-fixtures/migrations/cursor-selected/sessions.json +5 -0
  94. package/test-fixtures/migrations/cursor-selected/state.json +6 -0
  95. package/test-fixtures/migrations/dual-provider/jobs.json +3 -0
  96. package/test-fixtures/migrations/dual-provider/sessions.json +7 -0
  97. package/test-fixtures/migrations/dual-provider/state.json +10 -0
  98. package/test-fixtures/migrations/malformed-partial/jobs.json +1 -0
  99. package/test-fixtures/migrations/malformed-partial/sessions.json +1 -0
  100. package/test-fixtures/migrations/malformed-partial/sessions.json.bak +3 -0
  101. package/test-fixtures/migrations/malformed-partial/state.json +1 -0
  102. package/test-fixtures/migrations/malformed-partial/state.json.bak +5 -0
  103. package/test-fixtures/migrations/missing-project/jobs.json +3 -0
  104. package/test-fixtures/migrations/missing-project/sessions.json +3 -0
  105. package/test-fixtures/migrations/missing-project/state.json +4 -0
  106. package/test-fixtures/migrations/multi-user/jobs.json +4 -0
  107. package/test-fixtures/migrations/multi-user/sessions.json +4 -0
  108. package/test-fixtures/migrations/multi-user/state.json +6 -0
  109. package/test-fixtures/provider-event-samples.json +17 -0
  110. package/test-learning-e2e.js +5 -0
  111. package/test-memory-mutation-queue.js +191 -0
  112. package/test-provider-boot-matrix.js +399 -0
  113. package/test-provider-bootstrap.js +158 -0
  114. package/test-provider-capabilities.js +232 -0
  115. package/test-provider-claude.js +206 -0
  116. package/test-provider-codex.js +228 -0
  117. package/test-provider-compaction.js +371 -0
  118. package/test-provider-core-prompt.js +264 -0
  119. package/test-provider-coupling-audit.js +90 -0
  120. package/test-provider-dream.js +312 -0
  121. package/test-provider-enforcer.js +252 -0
  122. package/test-provider-env-isolation.js +150 -0
  123. package/test-provider-events.js +141 -0
  124. package/test-provider-fixture.js +332 -0
  125. package/test-provider-gateway.js +508 -0
  126. package/test-provider-language.js +89 -0
  127. package/test-provider-migration-backup.js +424 -0
  128. package/test-provider-pack-review.js +436 -0
  129. package/test-provider-parity-e2e.js +537 -0
  130. package/test-provider-prompt-parity.js +89 -0
  131. package/test-provider-recall.js +271 -0
  132. package/test-provider-registry.js +251 -0
  133. package/test-provider-resume-parity.js +41 -0
  134. package/test-provider-run-lifecycle.js +349 -0
  135. package/test-provider-runner.js +271 -0
  136. package/test-provider-scheduler.js +689 -0
  137. package/test-provider-session-commands.js +185 -0
  138. package/test-provider-session-history.js +205 -0
  139. package/test-provider-side-chat.js +337 -0
  140. package/test-provider-state-migration.js +316 -0
  141. package/test-provider-stream-decoder.js +69 -0
  142. package/test-provider-subagent.js +228 -0
  143. package/test-provider-tool-hooks.js +360 -0
  144. package/test-recall-discoverer.js +1 -0
  145. package/test-recall-engine.js +3 -3
  146. package/test-recall-evolution.js +18 -0
  147. package/test-run-lock.js +63 -0
  148. package/test-runner-watchdog-static.js +16 -8
  149. package/test-single-runtime.js +35 -0
  150. package/test-telegram-poll-recovery.js +56 -0
  151. package/test-tool-guard.js +56 -0
  152. package/test-tools.js +19 -0
  153. package/test-unified-identity.js +3 -1
  154. package/test-usage-accounting.js +92 -20
  155. package/test-utility-provider-policy.js +486 -0
  156. package/test-web-sessions.js +74 -0
  157. package/web.js +159 -45
package/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
 
@@ -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
- ]);
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
+ }]);
658
929
  }
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
- ]);
670
- }
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
  });
@@ -808,52 +1081,49 @@ register({
808
1081
  name: "budget", description: "Set max spend for next task", args: "[$N]",
809
1082
  handler: async (env, { tail }) => {
810
1083
  if (!authorized(env)) return;
811
- 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.");
1084
+ const state = currentState();
1085
+ const provider = providerFromRegistry(getActiveProvider(state));
1086
+ if (!provider) return send("Choose an available provider with /backend first.");
1087
+ const capability = provider.capabilities.budget;
1088
+ if (capability.support === "unsupported") return send(capability.reason);
1089
+ const settings = getProviderSettings(state, provider.id);
814
1090
  if (tail) {
815
- const v = parseFloat(tail.replace("$", ""));
816
- settings.budget = v > 0 ? v : null;
817
- return send(`Budget: ${settings.budget ? "$" + settings.budget : "none"}`);
1091
+ const value = tail.trim().toLowerCase() === "none" ? null : tail.replace("$", "");
1092
+ const result = applyProviderSetting(state, provider.id, "budget", value, { requireActive: true, persist: true });
1093
+ return send(result.ok ? `${provider.label} budget: ${result.value ? "$" + result.value : "none"}` : result.message);
818
1094
  }
819
- send(`Budget: ${settings.budget ? "$" + settings.budget : "none"}`, {
1095
+ send(`${provider.label} budget: ${settings.budget ? "$" + settings.budget : "none"}`, {
820
1096
  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" }],
1097
+ [{ 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` }],
1098
+ [{ text: "No limit", callback_data: `b:${provider.id}:none` }],
823
1099
  ] },
824
1100
  });
825
1101
  },
826
1102
  });
827
1103
 
1104
+ async function toggleReadOnlyMode(env, mode) {
1105
+ if (!authorized(env)) return;
1106
+ const state = currentState();
1107
+ const provider = providerFromRegistry(getActiveProvider(state));
1108
+ if (!provider) return send("Choose an available provider with /backend first.");
1109
+ const settings = getProviderSettings(state, provider.id);
1110
+ const next = settings.permissionMode === mode ? null : mode;
1111
+ const result = applyProviderSetting(state, provider.id, "permissionMode", next, { requireActive: true, persist: true });
1112
+ if (!result.ok) return send(result.message);
1113
+ const label = mode === "ask" ? "Read-only Q&A" : "Plan mode";
1114
+ return send(next === null
1115
+ ? `${label} off.`
1116
+ : `${label} on for ${provider.label} (${result.support}).`);
1117
+ }
1118
+
828
1119
  register({
829
- name: "plan", description: "Toggle plan mode",
830
- handler: async (env) => {
831
- if (!authorized(env)) return;
832
- 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}).`);
844
- },
1120
+ name: "plan", description: "Toggle read-only planning",
1121
+ handler: async (env) => toggleReadOnlyMode(env, "plan"),
845
1122
  });
846
1123
 
847
1124
  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
- },
1125
+ name: "ask", description: "Toggle read-only Q&A",
1126
+ handler: async (env) => toggleReadOnlyMode(env, "ask"),
857
1127
  });
858
1128
 
859
1129
  register({
@@ -875,13 +1145,28 @@ register({
875
1145
  },
876
1146
  });
877
1147
 
1148
+ register({
1149
+ name: "new", description: "Start a new conversation for this provider",
1150
+ handler: async (env) => {
1151
+ if (!authorized(env)) return;
1152
+ if (!requireSession()) return;
1153
+ const result = startNewConversation();
1154
+ if (!result.ok) return send(`Could not start a new conversation: ${result.error.message}`);
1155
+ return send(`Session: ${result.project}\nNew ${result.provider} conversation\n\nSend text, voice, or images.`);
1156
+ },
1157
+ });
1158
+
878
1159
  register({
879
1160
  name: "continue", description: "Resume last conversation",
880
1161
  handler: async (env) => {
881
1162
  if (!authorized(env)) return;
882
1163
  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);
1164
+ const state = currentState();
1165
+ const continuation = activeContinueOptions(state);
1166
+ if (!continuation) return send("No conversation to continue.");
1167
+ const prompt = "continue where we left off";
1168
+ const runContext = admitRunContext(prompt, state.currentSession.dir, env.messageId, continuation);
1169
+ return runAgent(prompt, runContext.cwd, env.messageId, { runContext, ...continuation });
885
1170
  },
886
1171
  });
887
1172
 
@@ -941,65 +1226,60 @@ register({
941
1226
  name: "worktree", description: "Toggle isolated git branch",
942
1227
  handler: async (env) => {
943
1228
  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
1229
  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`);
1230
+ const provider = providerFromRegistry(getActiveProvider(state));
1231
+ if (!provider) return send("Choose an available provider with /backend first.");
1232
+ const settings = getProviderSettings(state, provider.id);
1233
+ const result = applyProviderSetting(state, provider.id, "worktree", !settings.worktree, { requireActive: true, persist: true });
1234
+ send(result.ok ? `${provider.label} worktree ${result.value ? "on" : "off"}.` : result.message);
961
1235
  },
962
1236
  });
963
1237
 
1238
+ async function switchProviderCommand(env, providerId) {
1239
+ if (!authorized(env)) return;
1240
+ const provider = providerFromRegistry(providerId);
1241
+ if (!provider) return send(`Unknown provider: ${providerId}`);
1242
+ if (!providerAvailability(provider)) {
1243
+ return send(`${provider.label} is unavailable. Install its CLI or configure its path, then run /doctor.`);
1244
+ }
1245
+ const state = currentState();
1246
+ const switched = switchProviderState(provider.id, { state });
1247
+ if (!switched.ok) return send(`Could not switch provider: ${switched.error.message}`);
1248
+ const session = switched.sessionId ? `\nSession: ${switched.sessionId.slice(0, 8)}...` : "\nNew session.";
1249
+ return send(`Switched to ${provider.label}.${session}`);
1250
+ }
1251
+
964
1252
  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
- },
1253
+ name: "claude", description: "Switch to Claude Code provider",
1254
+ handler: async (env) => switchProviderCommand(env, "claude"),
975
1255
  });
976
1256
 
977
1257
  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
- },
1258
+ name: "codex", description: "Switch to OpenAI Codex provider",
1259
+ handler: async (env) => switchProviderCommand(env, "codex"),
989
1260
  });
990
1261
 
991
1262
  register({
992
- name: "backend", description: "Show/switch active backend",
993
- handler: async (env) => {
1263
+ name: "backend", description: "Show/switch active provider", args: "[claude|codex]",
1264
+ handler: async (env, { tail }) => {
994
1265
  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
- ] },
1266
+ if (tail) {
1267
+ const provider = tail.trim().toLowerCase();
1268
+ const descriptor = providerFromRegistry(provider);
1269
+ if (!descriptor) return send(`Usage: /backend [${providerRegistry.listProviders().map((entry) => entry.id).join("|")}]`);
1270
+ if (!providerAvailability(descriptor)) return send(`${descriptor.label} is unavailable. Run /doctor for recovery steps.`);
1271
+ const switched = switchProviderState(descriptor.id);
1272
+ if (!switched.ok) return send(`Could not switch provider: ${switched.error.message}`);
1273
+ return send(`Switched to ${descriptor.label}.${switched.sessionId ? `\nSession: ${switched.sessionId.slice(0, 8)}...` : "\nNew session."}`);
1274
+ }
1275
+ const picker = buildBackendPicker();
1276
+ const active = providerFromRegistry(picker.activeProvider);
1277
+ send([
1278
+ `Provider: ${active?.label || "none"}`,
1279
+ "",
1280
+ ...picker.entries.map((entry) => `${entry.label}: ${entry.available ? "available" : "unavailable"}`),
1281
+ ].join("\n"), {
1282
+ keyboard: { inline_keyboard: picker.rows },
1003
1283
  });
1004
1284
  },
1005
1285
  });
@@ -1008,9 +1288,11 @@ register({
1008
1288
  name: "mode", description: "Bot mode (direct/agent)",
1009
1289
  handler: async (env) => {
1010
1290
  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.", {
1291
+ const mode = require("./side-chat").readRuntimeMode(CONFIG_DIR);
1292
+ const target = mode === "agent" ? "direct" : "agent";
1293
+ 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
1294
  parseMode: "HTML",
1013
- keyboard: { inline_keyboard: [[{ text: "Switch to Agent Mode", callback_data: "mode:agent" }]] },
1295
+ keyboard: { inline_keyboard: [[{ text: `Switch to ${target} mode`, callback_data: `mode:${target}` }]] },
1014
1296
  });
1015
1297
  },
1016
1298
  });
@@ -1020,6 +1302,7 @@ register({
1020
1302
  handler: async (env) => {
1021
1303
  if (!authorized(env)) return;
1022
1304
  const state = currentState();
1305
+ const sideCancellation = require("./side-chat").sideChatCoordinator.cancel(state.userId);
1023
1306
  if (state.runningProcess) {
1024
1307
  const pid = state.runningProcess.pid;
1025
1308
  const { killProcessTree } = require("./process-tree");
@@ -1029,15 +1312,18 @@ register({
1029
1312
  if (state.streamInterval) clearTimeout(state.streamInterval);
1030
1313
  if (state.typingHeartbeat) { clearInterval(state.typingHeartbeat); state.typingHeartbeat = null; }
1031
1314
  state.messageQueue = [];
1315
+ await sideCancellation;
1032
1316
  await send("Cancelled.");
1033
1317
  } else if (state.preparingRun) {
1034
1318
  // 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.
1319
+ // runAgent bails at its pre-spawn checkpoint, and stop typing now.
1036
1320
  state.cancelRequested = true;
1037
1321
  state.messageQueue = [];
1038
1322
  if (state.typingHeartbeat) { clearInterval(state.typingHeartbeat); state.typingHeartbeat = null; }
1323
+ await sideCancellation;
1039
1324
  await send("Cancelled.");
1040
- } else await send("Nothing running.");
1325
+ } else if (await sideCancellation) await send("Cancelled side response.");
1326
+ else await send("Nothing running.");
1041
1327
  },
1042
1328
  });
1043
1329
 
@@ -1045,7 +1331,9 @@ register({
1045
1331
  name: "doctor", aliases: ["requirements"], description: "Check CLI requirements",
1046
1332
  handler: async (env) => {
1047
1333
  if (!authorized(env)) return;
1048
- await send(formatDoctorReport(runDoctorChecks()));
1334
+ // Includes the live Codex tool-hook probe (a real, short codex exec turn)
1335
+ // — /doctor is the only surface that opts in.
1336
+ await send(formatDoctorReport(runDoctorChecks({ probeCodexHook: true })));
1049
1337
  },
1050
1338
  });
1051
1339
 
@@ -1054,15 +1342,22 @@ register({
1054
1342
  handler: async (env) => {
1055
1343
  if (!authorized(env)) return;
1056
1344
  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";
1345
+ const provider = providerFromRegistry(getActiveProvider(state));
1346
+ const settings = provider ? getProviderSettings(state, provider.id) : null;
1060
1347
  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)");
1348
+ const commonSettings = state.settings;
1349
+ const activeEngine = require("./recall").activeEngineName(commonSettings) + (commonSettings.recallEngine ? "" : " (default)");
1350
+ const capabilitySummary = provider
1351
+ ? ["readOnlyMode", "effort", "budget", "worktree"].map((name) => {
1352
+ const capability = provider.capabilities[name];
1353
+ return `${name === "readOnlyMode" ? "read-only" : name} ${capability.support}`;
1354
+ }).join(" · ")
1355
+ : "none";
1062
1356
  send([
1063
- `Project: ${state.currentSession.name}`,
1064
- `Backend: ${backendLabel}`,
1065
- `Model: ${settings.model || "default"} | Effort: ${settings.effort || "default"}`,
1357
+ `Project: ${state.currentSession?.name || "none"}`,
1358
+ `Provider: ${provider?.label || "none"}`,
1359
+ `Model: ${settings?.model || provider?.defaultModel() || "default"} | Effort: ${settings?.effort || "default"}`,
1360
+ `Capabilities: ${capabilitySummary}`,
1066
1361
  `Recall engine: ${activeEngine}`,
1067
1362
  `Auto-compact: ${formatCompactWindow(state)}`,
1068
1363
  `Vault: ${vault.isUnlocked() ? "unlocked" : "locked"} | Crons: ${cronCount}`,
@@ -1077,14 +1372,9 @@ register({
1077
1372
  if (!authorized(env)) return;
1078
1373
  const state = currentState();
1079
1374
  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" }]] } });
1375
+ const result = endCurrentSession({ state });
1376
+ if (!result.ok) return send(`Could not end session: ${result.error.message}`);
1377
+ send(`Ended: ${result.project}`, { keyboard: { inline_keyboard: [[{ text: "New session", callback_data: "show:projects" }]] } });
1088
1378
  } else send("No session.");
1089
1379
  },
1090
1380
  });
@@ -1281,7 +1571,9 @@ register({
1281
1571
  `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
1572
  `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
1573
  `If the recent work is genuinely not reusable, say so instead of forcing a skill.`;
1284
- await runClaude(prompt, currentState().currentSession.dir, env.messageId);
1574
+ const state = currentState();
1575
+ const runContext = admitRunContext(prompt, state.currentSession.dir, env.messageId, { purpose: "learn" });
1576
+ return runAgent(prompt, runContext.cwd, env.messageId, { runContext, purpose: "learn" });
1285
1577
  },
1286
1578
  });
1287
1579
 
@@ -1291,32 +1583,19 @@ register({
1291
1583
  name: "auth_status", aliases: ["auth status"], description: "Check Claude Code auth",
1292
1584
  handler: async (env) => {
1293
1585
  if (!authorized(env)) return;
1294
- const { spawn } = require("child_process");
1295
- const { CLAUDE_PATH } = require("./config");
1296
1586
  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
- });
1587
+ const status = providerRegistry.providerStatus("claude");
1588
+ const output = runClaudeAuthStatusDiagnostic();
1589
+ const code = status.availability.state !== "available" ? 127 : (status.auth.state === "unauthenticated" ? 1 : 0);
1590
+ const clean = redactSensitive(output.trim()) || "(no output)";
1591
+ await send([
1592
+ `Claude auth status: exit ${code}`,
1593
+ ...summarizeClaudeAuthStatus(output, code, tokenInfo),
1594
+ `Bot OAuth token: ${tokenInfo.value ? "configured via " + tokenInfo.source : "not configured"}`,
1595
+ `Vault: ${vault.isUnlocked() ? "unlocked" : "locked"}`,
1596
+ "",
1597
+ clean.slice(-2500),
1598
+ ].join("\n"));
1320
1599
  },
1321
1600
  });
1322
1601
 
@@ -1432,7 +1711,7 @@ register({
1432
1711
  name: "codex_setup_token", aliases: ["codex_use_api_key"], description: "Save an OpenAI API key", args: "[<key>]", ownerOnly: true,
1433
1712
  handler: async (env, { tail }) => {
1434
1713
  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");
1714
+ if (!discoverProviderExecutable("codex")) return send("Codex CLI not found. Install: npm install -g @openai/codex");
1436
1715
  const key = (tail || "").trim();
1437
1716
  await deleteMessage(env.messageId);
1438
1717
  if (!key) {
@@ -1606,17 +1885,24 @@ register({
1606
1885
  handler: async (env, { tail }) => {
1607
1886
  if (!authorized(env)) return;
1608
1887
  const channelJobs = () => jobs.listForChannel(env.adapter.id, env.channelId).filter((j) => j.kind === "cron");
1888
+ const archivedNote = () => {
1889
+ const archived = jobs.listArchived().filter((j) => !j.channelId || String(j.channelId) === String(env.channelId));
1890
+ if (archived.length === 0) return "";
1891
+ const shown = archived.slice(0, 5).map((j) => `• ${j.label || j.id} — ${j.archiveReason || "archived"}`);
1892
+ const more = archived.length > 5 ? `\n…and ${archived.length - 5} more (open-claudia cron-list shows all).` : "";
1893
+ return `\n\n⚠️ Disabled by provider migration (will not fire):\n${shown.join("\n")}${more}`;
1894
+ };
1609
1895
  if (!tail) {
1610
1896
  const list = channelJobs();
1611
1897
  if (list.length === 0) {
1612
- return send("No crons.\n\nAdd: /cron add \"<schedule>\" <project> \"<prompt>\"\n\nOr pick a preset:", {
1898
+ return send("No crons." + archivedNote() + "\n\nAdd: /cron add \"<schedule>\" <project> \"<prompt>\"\n\nOr pick a preset:", {
1613
1899
  keyboard: { inline_keyboard: [
1614
1900
  [{ text: "Standup 9am", callback_data: "cp:standup" }, { text: "Git digest 6pm", callback_data: "cp:git" }],
1615
1901
  [{ text: "Dep check Mon", callback_data: "cp:deps" }, { text: "Health 30min", callback_data: "cp:health" }],
1616
1902
  ] },
1617
1903
  });
1618
1904
  }
1619
- return send("Crons:\n\n" + list.map((c, i) => `${i + 1}. ${c.label} (${c.schedule}) — ${c.project || ""}`).join("\n") + "\n\nRemove: /cron remove <#>");
1905
+ 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
1906
  }
1621
1907
 
1622
1908
  const addMatch = tail.match(/^add\s+"(.+)"\s+(\S+)\s+"(.+)"$/);