@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.
- package/.env.example +30 -4
- package/CHANGELOG.md +21 -0
- package/README.md +87 -66
- package/bin/agent.js +44 -18
- package/bin/cli.js +30 -28
- package/bin/dream.js +5 -11
- package/bin/loopback-client.js +29 -5
- package/bin/schedule.js +19 -5
- package/bin/tool.js +23 -5
- package/bot-agent.js +5 -2350
- package/bot.js +105 -7
- package/channels/telegram/adapter.js +6 -1
- package/channels/voice/adapter.js +1 -0
- package/channels/voice/manage.js +43 -5
- package/config-dir.js +3 -11
- package/core/actions.js +149 -40
- package/core/approvals.js +136 -29
- package/core/auth-flow.js +103 -19
- package/core/config-dir.js +19 -0
- package/core/config.js +127 -69
- package/core/doctor.js +111 -57
- package/core/dream.js +219 -62
- package/core/enforcer.js +0 -0
- package/core/entities.js +2 -1
- package/core/fsutil.js +55 -0
- package/core/handlers.js +503 -217
- package/core/ideas.js +2 -1
- package/core/identity.js +8 -11
- package/core/io.js +24 -3
- package/core/jobs.js +589 -72
- package/core/lessons.js +3 -2
- package/core/loopback.js +45 -8
- package/core/memory-mutation-queue.js +241 -0
- package/core/migration-backup.js +1060 -0
- package/core/pack-review.js +295 -88
- package/core/packs.js +4 -3
- package/core/persona.js +2 -1
- package/core/process-tree.js +19 -2
- package/core/provider-migration.js +39 -0
- package/core/provider-status.js +80 -0
- package/core/providers/claude-events.js +117 -0
- package/core/providers/claude-hook.js +114 -0
- package/core/providers/claude.js +296 -0
- package/core/providers/codex-events.js +121 -0
- package/core/providers/codex-hook.js +280 -0
- package/core/providers/codex.js +286 -0
- package/core/providers/contract.js +153 -0
- package/core/providers/env.js +148 -0
- package/core/providers/events.js +170 -0
- package/core/providers/hook-command.js +22 -0
- package/core/providers/index.js +240 -0
- package/core/providers/utility-policy.js +269 -0
- package/core/recall/classic.js +2 -2
- package/core/recall/discoverer.js +71 -22
- package/core/recall/metrics.js +27 -1
- package/core/recall/tuning.js +4 -1
- package/core/recall/warm-walker.js +151 -108
- package/core/recall-filter.js +86 -61
- package/core/router.js +79 -7
- package/core/run-context.js +185 -0
- package/core/runner.js +1415 -1282
- package/core/scheduler.js +515 -210
- package/core/side-chat.js +346 -0
- package/core/state.js +1096 -95
- package/core/subagent.js +72 -98
- package/core/system-prompt.js +462 -279
- package/core/tool-guard.js +73 -39
- package/core/tools.js +22 -5
- package/core/transcripts.js +30 -1
- package/core/usage-log.js +19 -4
- package/core/utility-agent.js +654 -0
- package/core/web-auth.js +29 -13
- package/core/web-sessions.js +78 -0
- package/docs/CHANNEL_DESIGN.md +181 -0
- package/docs/MULTI_CHANNEL_PLAN.md +254 -0
- package/docs/PROVIDER_MIGRATION.md +67 -0
- package/health.js +50 -39
- package/package.json +51 -4
- package/setup.js +198 -38
- package/soul.md +39 -0
- package/test-ability-extraction.js +5 -0
- package/test-approval-async.js +63 -4
- package/test-cursor-removal.js +334 -0
- package/test-delivery-contract.js +88 -0
- package/test-enforcer.js +70 -27
- package/test-fixtures/current-provider-parity.json +132 -0
- package/test-fixtures/fake-agent-cli.js +477 -0
- package/test-fixtures/migrations/claude-only/jobs.json +3 -0
- package/test-fixtures/migrations/claude-only/sessions.json +5 -0
- package/test-fixtures/migrations/claude-only/state.json +5 -0
- package/test-fixtures/migrations/cursor-selected/crons.json +3 -0
- package/test-fixtures/migrations/cursor-selected/jobs.json +3 -0
- package/test-fixtures/migrations/cursor-selected/sessions.json +5 -0
- package/test-fixtures/migrations/cursor-selected/state.json +6 -0
- package/test-fixtures/migrations/dual-provider/jobs.json +3 -0
- package/test-fixtures/migrations/dual-provider/sessions.json +7 -0
- package/test-fixtures/migrations/dual-provider/state.json +10 -0
- package/test-fixtures/migrations/malformed-partial/jobs.json +1 -0
- package/test-fixtures/migrations/malformed-partial/sessions.json +1 -0
- package/test-fixtures/migrations/malformed-partial/sessions.json.bak +3 -0
- package/test-fixtures/migrations/malformed-partial/state.json +1 -0
- package/test-fixtures/migrations/malformed-partial/state.json.bak +5 -0
- package/test-fixtures/migrations/missing-project/jobs.json +3 -0
- package/test-fixtures/migrations/missing-project/sessions.json +3 -0
- package/test-fixtures/migrations/missing-project/state.json +4 -0
- package/test-fixtures/migrations/multi-user/jobs.json +4 -0
- package/test-fixtures/migrations/multi-user/sessions.json +4 -0
- package/test-fixtures/migrations/multi-user/state.json +6 -0
- package/test-fixtures/provider-event-samples.json +17 -0
- package/test-learning-e2e.js +5 -0
- package/test-memory-mutation-queue.js +191 -0
- package/test-provider-boot-matrix.js +399 -0
- package/test-provider-bootstrap.js +158 -0
- package/test-provider-capabilities.js +232 -0
- package/test-provider-claude.js +206 -0
- package/test-provider-codex.js +228 -0
- package/test-provider-compaction.js +371 -0
- package/test-provider-core-prompt.js +264 -0
- package/test-provider-coupling-audit.js +90 -0
- package/test-provider-dream.js +312 -0
- package/test-provider-enforcer.js +252 -0
- package/test-provider-env-isolation.js +150 -0
- package/test-provider-events.js +141 -0
- package/test-provider-fixture.js +332 -0
- package/test-provider-gateway.js +508 -0
- package/test-provider-language.js +89 -0
- package/test-provider-migration-backup.js +424 -0
- package/test-provider-pack-review.js +436 -0
- package/test-provider-parity-e2e.js +537 -0
- package/test-provider-prompt-parity.js +89 -0
- package/test-provider-recall.js +271 -0
- package/test-provider-registry.js +251 -0
- package/test-provider-resume-parity.js +41 -0
- package/test-provider-run-lifecycle.js +349 -0
- package/test-provider-runner.js +271 -0
- package/test-provider-scheduler.js +689 -0
- package/test-provider-session-commands.js +185 -0
- package/test-provider-session-history.js +205 -0
- package/test-provider-side-chat.js +337 -0
- package/test-provider-state-migration.js +316 -0
- package/test-provider-stream-decoder.js +69 -0
- package/test-provider-subagent.js +228 -0
- package/test-provider-tool-hooks.js +360 -0
- package/test-recall-discoverer.js +1 -0
- package/test-recall-engine.js +3 -3
- package/test-recall-evolution.js +18 -0
- package/test-run-lock.js +63 -0
- package/test-runner-watchdog-static.js +16 -8
- package/test-single-runtime.js +35 -0
- package/test-telegram-poll-recovery.js +56 -0
- package/test-tool-guard.js +56 -0
- package/test-tools.js +19 -0
- package/test-unified-identity.js +3 -1
- package/test-usage-accounting.js +92 -20
- package/test-utility-provider-policy.js +486 -0
- package/test-web-sessions.js +74 -0
- 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,
|
|
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 {
|
|
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
|
|
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
|
-
|
|
37
|
+
admitRunContext, runAgent, compactActiveSession, getActiveSessionId, effectiveCompactThreshold,
|
|
32
38
|
} = require("./runner");
|
|
33
39
|
const {
|
|
34
|
-
getClaudeOAuthToken,
|
|
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,
|
|
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
|
|
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
|
-
|
|
78
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
99
|
-
state.isFirstMessage =
|
|
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
|
|
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
|
|
618
|
-
|
|
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
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
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
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
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
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
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
|
|
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
|
|
682
|
-
|
|
683
|
-
if (
|
|
684
|
-
|
|
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: "[
|
|
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
|
|
696
|
-
|
|
697
|
-
if (
|
|
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
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
}
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
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
|
|
812
|
-
|
|
813
|
-
if (
|
|
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
|
|
816
|
-
|
|
817
|
-
return send(
|
|
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(
|
|
1095
|
+
send(`${provider.label} budget: ${settings.budget ? "$" + settings.budget : "none"}`, {
|
|
820
1096
|
keyboard: { inline_keyboard: [
|
|
821
|
-
[{ text: "$1", callback_data:
|
|
822
|
-
[{ text: "No limit", callback_data:
|
|
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
|
|
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
|
|
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
|
-
|
|
884
|
-
|
|
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
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
const
|
|
960
|
-
send(
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
//
|
|
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("
|
|
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
|
-
|
|
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
|
-
|
|
1058
|
-
const
|
|
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
|
|
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
|
|
1064
|
-
`
|
|
1065
|
-
`Model: ${settings
|
|
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
|
|
1081
|
-
|
|
1082
|
-
|
|
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
|
-
|
|
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
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
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 (!
|
|
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
|
|
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+"(.+)"$/);
|