@inetafrica/open-claudia 2.15.0 → 3.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +30 -4
- package/CHANGELOG.md +22 -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 +81 -3
- package/channels/telegram/adapter.js +65 -5
- package/channels/voice/adapter.js +1 -0
- package/channels/voice/manage.js +43 -5
- package/config-dir.js +3 -11
- package/core/actions.js +155 -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 +115 -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 +21 -4
- package/core/handlers.js +542 -224
- package/core/ideas.js +2 -1
- package/core/jobs.js +589 -72
- package/core/lessons.js +3 -2
- package/core/loopback.js +42 -6
- 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 +121 -0
- package/core/providers/claude-hook.js +114 -0
- package/core/providers/claude.js +296 -0
- package/core/providers/codex-events.js +125 -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 +171 -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 +1562 -1286
- package/core/scheduler.js +515 -210
- package/core/side-chat.js +346 -0
- package/core/single-instance.js +46 -0
- package/core/state.js +1084 -97
- 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/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 +48 -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 +337 -0
- package/test-enforcer.js +70 -27
- package/test-fixtures/current-provider-parity.json +132 -0
- package/test-fixtures/fake-agent-cli.js +509 -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 +171 -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-runner-watchdog-static.js +16 -8
- package/test-single-runtime.js +35 -0
- package/test-telegram-poll-recovery.js +93 -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/web.js +130 -35
package/core/handlers.js
CHANGED
|
@@ -9,15 +9,21 @@ const { execSync } = require("child_process");
|
|
|
9
9
|
const {
|
|
10
10
|
WORKSPACE, FULL_PATH, AUTO_COMPACT_TOKENS, CONFIG_DIR,
|
|
11
11
|
config, saveEnvKey,
|
|
12
|
-
CHAT_ID,
|
|
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
|
|
|
@@ -123,7 +371,7 @@ register({
|
|
|
123
371
|
if (!authorized(env)) return;
|
|
124
372
|
send([
|
|
125
373
|
"Session: /session /sessions /projects /continue /status /stop /end",
|
|
126
|
-
"Settings: /model /effort /budget /plan /compact /compactwindow /worktree /mode /engine /recall /tooltrace /toolmode",
|
|
374
|
+
"Settings: /model /effort /budget /plan /compact /compactwindow /worktree /mode /engine /recall /tooltrace /toolmode /verbose",
|
|
127
375
|
"Identity: /whoami /link",
|
|
128
376
|
"Team: /people /intros /auth (owner)",
|
|
129
377
|
"Automation: /cron /vault /soul /dreamsummary",
|
|
@@ -610,78 +858,100 @@ register({
|
|
|
610
858
|
if (!authorized(env)) return;
|
|
611
859
|
if (!requireSession()) return;
|
|
612
860
|
const state = currentState();
|
|
613
|
-
const
|
|
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
|
-
]);
|
|
658
|
-
}
|
|
659
|
-
if (resolvedCodexPath) {
|
|
660
|
-
rows.push([{ text: `── Codex${mark(auth && auth.codex)} ──`, callback_data: "noop" }]);
|
|
661
|
-
rows.push([
|
|
662
|
-
{ text: "GPT-5.5", callback_data: "mb:codex:gpt-5.5" },
|
|
663
|
-
{ text: "gpt-5", callback_data: "mb:codex:gpt-5" },
|
|
664
|
-
{ text: "gpt-5-codex", callback_data: "mb:codex:gpt-5-codex" },
|
|
665
|
-
]);
|
|
666
|
-
rows.push([
|
|
667
|
-
{ text: "o3", callback_data: "mb:codex:o3" },
|
|
668
|
-
{ text: "o4-mini", callback_data: "mb:codex:o4-mini" },
|
|
669
|
-
]);
|
|
908
|
+
for (const provider of registry.listProviders()) {
|
|
909
|
+
let authMarker = "";
|
|
910
|
+
if (authAware) {
|
|
911
|
+
let connected = false;
|
|
912
|
+
try { connected = provider.authStatus().state === "authenticated"; } catch (_) {}
|
|
913
|
+
authMarker = connected ? " · connected" : " · tap to connect";
|
|
914
|
+
}
|
|
915
|
+
const availableMarker = providerAvailability(provider) ? "" : " · unavailable";
|
|
916
|
+
rows.push([{ text: `── ${provider.label}${availableMarker}${authMarker} ──`, callback_data: "noop" }]);
|
|
917
|
+
const choices = provider.modelChoices();
|
|
918
|
+
for (let index = 0; index < choices.length; index += 3) {
|
|
919
|
+
rows.push(choices.slice(index, index + 3).map((model) => ({
|
|
920
|
+
text: model,
|
|
921
|
+
callback_data: `mb:${provider.id}:${model}`,
|
|
922
|
+
})));
|
|
923
|
+
}
|
|
924
|
+
const defaultModel = provider.defaultModel();
|
|
925
|
+
rows.push([{
|
|
926
|
+
text: `Default${defaultModel ? ` (${defaultModel})` : ""}`,
|
|
927
|
+
callback_data: `mb:${provider.id}:default`,
|
|
928
|
+
}]);
|
|
670
929
|
}
|
|
671
|
-
|
|
672
|
-
const
|
|
673
|
-
return {
|
|
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
|
});
|
|
@@ -805,55 +1078,75 @@ register({
|
|
|
805
1078
|
});
|
|
806
1079
|
|
|
807
1080
|
register({
|
|
808
|
-
name: "
|
|
1081
|
+
name: "verbose", description: "Stream thinking into the live preview (on/off)", args: "[on|off]",
|
|
809
1082
|
handler: async (env, { tail }) => {
|
|
810
1083
|
if (!authorized(env)) return;
|
|
811
1084
|
const { settings } = currentState();
|
|
812
|
-
if (settings.backend === "cursor") return send("Budget limits are not supported on Cursor Agent.\nSwitch to Claude with /claude to use this.");
|
|
813
|
-
if (settings.backend === "codex") return send("Budget limits are not supported on Codex.\nSwitch to Claude with /claude to use this.");
|
|
814
1085
|
if (tail) {
|
|
815
|
-
const v =
|
|
816
|
-
|
|
817
|
-
|
|
1086
|
+
const v = tail.trim().toLowerCase();
|
|
1087
|
+
if (v === "on" || v === "true") settings.showThinking = true;
|
|
1088
|
+
else if (v === "off" || v === "false") settings.showThinking = false;
|
|
1089
|
+
else return send(`Usage: /verbose [on|off]. Currently ${settings.showThinking ? "on" : "off"}.`);
|
|
1090
|
+
saveState();
|
|
1091
|
+
return send(`Verbose thinking: ${settings.showThinking ? "on" : "off"}`);
|
|
818
1092
|
}
|
|
819
|
-
send(
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
1093
|
+
send(
|
|
1094
|
+
`Verbose thinking: ${settings.showThinking ? "on" : "off"}\n\n` +
|
|
1095
|
+
"When on, the live status preview also streams my thinking (reasoning) in italics as it happens. Narration and the clean final answer are unaffected. Off by default.",
|
|
1096
|
+
{ keyboard: { inline_keyboard: [
|
|
1097
|
+
[{ text: "On", callback_data: "vb:on" }, { text: "Off", callback_data: "vb:off" }],
|
|
1098
|
+
] } },
|
|
1099
|
+
);
|
|
825
1100
|
},
|
|
826
1101
|
});
|
|
827
1102
|
|
|
828
1103
|
register({
|
|
829
|
-
name: "
|
|
830
|
-
handler: async (env) => {
|
|
1104
|
+
name: "budget", description: "Set max spend for next task", args: "[$N]",
|
|
1105
|
+
handler: async (env, { tail }) => {
|
|
831
1106
|
if (!authorized(env)) return;
|
|
832
1107
|
const state = currentState();
|
|
833
|
-
const
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
}
|
|
843
|
-
send(
|
|
1108
|
+
const provider = providerFromRegistry(getActiveProvider(state));
|
|
1109
|
+
if (!provider) return send("Choose an available provider with /backend first.");
|
|
1110
|
+
const capability = provider.capabilities.budget;
|
|
1111
|
+
if (capability.support === "unsupported") return send(capability.reason);
|
|
1112
|
+
const settings = getProviderSettings(state, provider.id);
|
|
1113
|
+
if (tail) {
|
|
1114
|
+
const value = tail.trim().toLowerCase() === "none" ? null : tail.replace("$", "");
|
|
1115
|
+
const result = applyProviderSetting(state, provider.id, "budget", value, { requireActive: true, persist: true });
|
|
1116
|
+
return send(result.ok ? `${provider.label} budget: ${result.value ? "$" + result.value : "none"}` : result.message);
|
|
1117
|
+
}
|
|
1118
|
+
send(`${provider.label} budget: ${settings.budget ? "$" + settings.budget : "none"}`, {
|
|
1119
|
+
keyboard: { inline_keyboard: [
|
|
1120
|
+
[{ text: "$1", callback_data: `b:${provider.id}:1` }, { text: "$5", callback_data: `b:${provider.id}:5` }, { text: "$10", callback_data: `b:${provider.id}:10` }, { text: "$25", callback_data: `b:${provider.id}:25` }],
|
|
1121
|
+
[{ text: "No limit", callback_data: `b:${provider.id}:none` }],
|
|
1122
|
+
] },
|
|
1123
|
+
});
|
|
844
1124
|
},
|
|
845
1125
|
});
|
|
846
1126
|
|
|
1127
|
+
async function toggleReadOnlyMode(env, mode) {
|
|
1128
|
+
if (!authorized(env)) return;
|
|
1129
|
+
const state = currentState();
|
|
1130
|
+
const provider = providerFromRegistry(getActiveProvider(state));
|
|
1131
|
+
if (!provider) return send("Choose an available provider with /backend first.");
|
|
1132
|
+
const settings = getProviderSettings(state, provider.id);
|
|
1133
|
+
const next = settings.permissionMode === mode ? null : mode;
|
|
1134
|
+
const result = applyProviderSetting(state, provider.id, "permissionMode", next, { requireActive: true, persist: true });
|
|
1135
|
+
if (!result.ok) return send(result.message);
|
|
1136
|
+
const label = mode === "ask" ? "Read-only Q&A" : "Plan mode";
|
|
1137
|
+
return send(next === null
|
|
1138
|
+
? `${label} off.`
|
|
1139
|
+
: `${label} on for ${provider.label} (${result.support}).`);
|
|
1140
|
+
}
|
|
1141
|
+
|
|
847
1142
|
register({
|
|
848
|
-
name: "
|
|
849
|
-
handler: async (env) =>
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
send(a ? "Ask mode off." : "Ask mode on (read-only Q&A, no edits).");
|
|
856
|
-
},
|
|
1143
|
+
name: "plan", description: "Toggle read-only planning",
|
|
1144
|
+
handler: async (env) => toggleReadOnlyMode(env, "plan"),
|
|
1145
|
+
});
|
|
1146
|
+
|
|
1147
|
+
register({
|
|
1148
|
+
name: "ask", description: "Toggle read-only Q&A",
|
|
1149
|
+
handler: async (env) => toggleReadOnlyMode(env, "ask"),
|
|
857
1150
|
});
|
|
858
1151
|
|
|
859
1152
|
register({
|
|
@@ -875,13 +1168,28 @@ register({
|
|
|
875
1168
|
},
|
|
876
1169
|
});
|
|
877
1170
|
|
|
1171
|
+
register({
|
|
1172
|
+
name: "new", description: "Start a new conversation for this provider",
|
|
1173
|
+
handler: async (env) => {
|
|
1174
|
+
if (!authorized(env)) return;
|
|
1175
|
+
if (!requireSession()) return;
|
|
1176
|
+
const result = startNewConversation();
|
|
1177
|
+
if (!result.ok) return send(`Could not start a new conversation: ${result.error.message}`);
|
|
1178
|
+
return send(`Session: ${result.project}\nNew ${result.provider} conversation\n\nSend text, voice, or images.`);
|
|
1179
|
+
},
|
|
1180
|
+
});
|
|
1181
|
+
|
|
878
1182
|
register({
|
|
879
1183
|
name: "continue", description: "Resume last conversation",
|
|
880
1184
|
handler: async (env) => {
|
|
881
1185
|
if (!authorized(env)) return;
|
|
882
1186
|
if (!requireSession()) return;
|
|
883
|
-
|
|
884
|
-
|
|
1187
|
+
const state = currentState();
|
|
1188
|
+
const continuation = activeContinueOptions(state);
|
|
1189
|
+
if (!continuation) return send("No conversation to continue.");
|
|
1190
|
+
const prompt = "continue where we left off";
|
|
1191
|
+
const runContext = admitRunContext(prompt, state.currentSession.dir, env.messageId, continuation);
|
|
1192
|
+
return runAgent(prompt, runContext.cwd, env.messageId, { runContext, ...continuation });
|
|
885
1193
|
},
|
|
886
1194
|
});
|
|
887
1195
|
|
|
@@ -941,65 +1249,60 @@ register({
|
|
|
941
1249
|
name: "worktree", description: "Toggle isolated git branch",
|
|
942
1250
|
handler: async (env) => {
|
|
943
1251
|
if (!authorized(env)) return;
|
|
944
|
-
const { settings } = currentState();
|
|
945
|
-
settings.worktree = !settings.worktree;
|
|
946
|
-
send(settings.worktree ? "Worktree on." : "Worktree off.");
|
|
947
|
-
},
|
|
948
|
-
});
|
|
949
|
-
|
|
950
|
-
register({
|
|
951
|
-
name: "cursor", description: "Switch to Cursor Agent backend",
|
|
952
|
-
handler: async (env) => {
|
|
953
|
-
if (!authorized(env)) return;
|
|
954
|
-
if (!resolvedCursorPath) return send("Cursor Agent CLI not found.\nSet CURSOR_PATH in .env or install: https://docs.cursor.com/agent");
|
|
955
1252
|
const state = currentState();
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
const
|
|
960
|
-
send(
|
|
1253
|
+
const provider = providerFromRegistry(getActiveProvider(state));
|
|
1254
|
+
if (!provider) return send("Choose an available provider with /backend first.");
|
|
1255
|
+
const settings = getProviderSettings(state, provider.id);
|
|
1256
|
+
const result = applyProviderSetting(state, provider.id, "worktree", !settings.worktree, { requireActive: true, persist: true });
|
|
1257
|
+
send(result.ok ? `${provider.label} worktree ${result.value ? "on" : "off"}.` : result.message);
|
|
961
1258
|
},
|
|
962
1259
|
});
|
|
963
1260
|
|
|
1261
|
+
async function switchProviderCommand(env, providerId) {
|
|
1262
|
+
if (!authorized(env)) return;
|
|
1263
|
+
const provider = providerFromRegistry(providerId);
|
|
1264
|
+
if (!provider) return send(`Unknown provider: ${providerId}`);
|
|
1265
|
+
if (!providerAvailability(provider)) {
|
|
1266
|
+
return send(`${provider.label} is unavailable. Install its CLI or configure its path, then run /doctor.`);
|
|
1267
|
+
}
|
|
1268
|
+
const state = currentState();
|
|
1269
|
+
const switched = switchProviderState(provider.id, { state });
|
|
1270
|
+
if (!switched.ok) return send(`Could not switch provider: ${switched.error.message}`);
|
|
1271
|
+
const session = switched.sessionId ? `\nSession: ${switched.sessionId.slice(0, 8)}...` : "\nNew session.";
|
|
1272
|
+
return send(`Switched to ${provider.label}.${session}`);
|
|
1273
|
+
}
|
|
1274
|
+
|
|
964
1275
|
register({
|
|
965
|
-
name: "claude", description: "Switch to Claude Code
|
|
966
|
-
handler: async (env) =>
|
|
967
|
-
if (!authorized(env)) return;
|
|
968
|
-
const state = currentState();
|
|
969
|
-
state.settings.backend = "claude";
|
|
970
|
-
state.settings.model = null;
|
|
971
|
-
saveState();
|
|
972
|
-
const sid = state.lastSessionId ? `\nSession: ${state.lastSessionId.slice(0, 8)}...` : "\nNew session.";
|
|
973
|
-
send(`Switched to Claude Code.${sid}\n\n/cursor — Cursor · /codex — Codex`);
|
|
974
|
-
},
|
|
1276
|
+
name: "claude", description: "Switch to Claude Code provider",
|
|
1277
|
+
handler: async (env) => switchProviderCommand(env, "claude"),
|
|
975
1278
|
});
|
|
976
1279
|
|
|
977
1280
|
register({
|
|
978
|
-
name: "codex", description: "Switch to OpenAI Codex
|
|
979
|
-
handler: async (env) =>
|
|
980
|
-
if (!authorized(env)) return;
|
|
981
|
-
if (!resolvedCodexPath) return send("Codex CLI not found.\nInstall: npm install -g @openai/codex\nThen run `codex login` to authenticate.");
|
|
982
|
-
const state = currentState();
|
|
983
|
-
state.settings.backend = "codex";
|
|
984
|
-
state.settings.model = null;
|
|
985
|
-
saveState();
|
|
986
|
-
const sid = state.codexSessionId ? `\nSession: ${state.codexSessionId.slice(0, 8)}...` : "\nNew session.";
|
|
987
|
-
send(`Switched to Codex.${sid}\n\n/claude — Claude · /cursor — Cursor`);
|
|
988
|
-
},
|
|
1281
|
+
name: "codex", description: "Switch to OpenAI Codex provider",
|
|
1282
|
+
handler: async (env) => switchProviderCommand(env, "codex"),
|
|
989
1283
|
});
|
|
990
1284
|
|
|
991
1285
|
register({
|
|
992
|
-
name: "backend", description: "Show/switch active
|
|
993
|
-
handler: async (env) => {
|
|
1286
|
+
name: "backend", description: "Show/switch active provider", args: "[claude|codex]",
|
|
1287
|
+
handler: async (env, { tail }) => {
|
|
994
1288
|
if (!authorized(env)) return;
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1289
|
+
if (tail) {
|
|
1290
|
+
const provider = tail.trim().toLowerCase();
|
|
1291
|
+
const descriptor = providerFromRegistry(provider);
|
|
1292
|
+
if (!descriptor) return send(`Usage: /backend [${providerRegistry.listProviders().map((entry) => entry.id).join("|")}]`);
|
|
1293
|
+
if (!providerAvailability(descriptor)) return send(`${descriptor.label} is unavailable. Run /doctor for recovery steps.`);
|
|
1294
|
+
const switched = switchProviderState(descriptor.id);
|
|
1295
|
+
if (!switched.ok) return send(`Could not switch provider: ${switched.error.message}`);
|
|
1296
|
+
return send(`Switched to ${descriptor.label}.${switched.sessionId ? `\nSession: ${switched.sessionId.slice(0, 8)}...` : "\nNew session."}`);
|
|
1297
|
+
}
|
|
1298
|
+
const picker = buildBackendPicker();
|
|
1299
|
+
const active = providerFromRegistry(picker.activeProvider);
|
|
1300
|
+
send([
|
|
1301
|
+
`Provider: ${active?.label || "none"}`,
|
|
1302
|
+
"",
|
|
1303
|
+
...picker.entries.map((entry) => `${entry.label}: ${entry.available ? "available" : "unavailable"}`),
|
|
1304
|
+
].join("\n"), {
|
|
1305
|
+
keyboard: { inline_keyboard: picker.rows },
|
|
1003
1306
|
});
|
|
1004
1307
|
},
|
|
1005
1308
|
});
|
|
@@ -1008,9 +1311,11 @@ register({
|
|
|
1008
1311
|
name: "mode", description: "Bot mode (direct/agent)",
|
|
1009
1312
|
handler: async (env) => {
|
|
1010
1313
|
if (!authorized(env)) return;
|
|
1011
|
-
|
|
1314
|
+
const mode = require("./side-chat").readRuntimeMode(CONFIG_DIR);
|
|
1315
|
+
const target = mode === "agent" ? "direct" : "agent";
|
|
1316
|
+
await send(`Bot mode: ${mode}\n\nBoth modes use the same modular runtime. Agent mode gives one isolated, read-only side response while a heavy main task runs; direct mode queues follow-up messages.`, {
|
|
1012
1317
|
parseMode: "HTML",
|
|
1013
|
-
keyboard: { inline_keyboard: [[{ text:
|
|
1318
|
+
keyboard: { inline_keyboard: [[{ text: `Switch to ${target} mode`, callback_data: `mode:${target}` }]] },
|
|
1014
1319
|
});
|
|
1015
1320
|
},
|
|
1016
1321
|
});
|
|
@@ -1020,24 +1325,37 @@ register({
|
|
|
1020
1325
|
handler: async (env) => {
|
|
1021
1326
|
if (!authorized(env)) return;
|
|
1022
1327
|
const state = currentState();
|
|
1328
|
+
const sideCancellation = require("./side-chat").sideChatCoordinator.cancel(state.userId);
|
|
1329
|
+
// /stop cancels the CURRENT turn only. Messages queued behind it stay
|
|
1330
|
+
// queued — the cancelled run's unwind drains them as usual.
|
|
1331
|
+
const queuedNote = () => {
|
|
1332
|
+
const n = Array.isArray(state.messageQueue) ? state.messageQueue.length : 0;
|
|
1333
|
+
return n ? ` ${n} queued message${n === 1 ? "" : "s"} will run next.` : "";
|
|
1334
|
+
};
|
|
1023
1335
|
if (state.runningProcess) {
|
|
1024
1336
|
const pid = state.runningProcess.pid;
|
|
1025
1337
|
const { killProcessTree } = require("./process-tree");
|
|
1338
|
+
// Quiet unwind: deliverForegroundResult consumes this flag to suppress
|
|
1339
|
+
// the "run failed" diagnostic the SIGTERM exit would otherwise emit.
|
|
1340
|
+
state.cancelRequested = true;
|
|
1026
1341
|
killProcessTree(pid, "SIGTERM");
|
|
1027
1342
|
setTimeout(() => killProcessTree(pid, "SIGKILL"), 3000);
|
|
1028
1343
|
state.runningProcess = null;
|
|
1029
|
-
|
|
1344
|
+
// Stop pending preview flushes but keep statusMessageId/Channel: the
|
|
1345
|
+
// killed run's delivery unwind edits the preview to drop its footer.
|
|
1346
|
+
if (state.streamInterval) { clearTimeout(state.streamInterval); state.streamInterval = null; }
|
|
1030
1347
|
if (state.typingHeartbeat) { clearInterval(state.typingHeartbeat); state.typingHeartbeat = null; }
|
|
1031
|
-
|
|
1032
|
-
await send(
|
|
1348
|
+
await sideCancellation;
|
|
1349
|
+
await send(`Cancelled.${queuedNote()}`);
|
|
1033
1350
|
} else if (state.preparingRun) {
|
|
1034
|
-
// Turn is mid-recall/
|
|
1035
|
-
//
|
|
1351
|
+
// Turn is mid-recall/prompt-build — no process to kill yet. Flag it so
|
|
1352
|
+
// the runner's pre-spawn shouldAbort checkpoint bails before spawning.
|
|
1036
1353
|
state.cancelRequested = true;
|
|
1037
|
-
state.messageQueue = [];
|
|
1038
1354
|
if (state.typingHeartbeat) { clearInterval(state.typingHeartbeat); state.typingHeartbeat = null; }
|
|
1039
|
-
await
|
|
1040
|
-
|
|
1355
|
+
await sideCancellation;
|
|
1356
|
+
await send(`Cancelled.${queuedNote()}`);
|
|
1357
|
+
} else if (await sideCancellation) await send("Cancelled side response.");
|
|
1358
|
+
else await send("Nothing running.");
|
|
1041
1359
|
},
|
|
1042
1360
|
});
|
|
1043
1361
|
|
|
@@ -1045,7 +1363,9 @@ register({
|
|
|
1045
1363
|
name: "doctor", aliases: ["requirements"], description: "Check CLI requirements",
|
|
1046
1364
|
handler: async (env) => {
|
|
1047
1365
|
if (!authorized(env)) return;
|
|
1048
|
-
|
|
1366
|
+
// Includes the live Codex tool-hook probe (a real, short codex exec turn)
|
|
1367
|
+
// — /doctor is the only surface that opts in.
|
|
1368
|
+
await send(formatDoctorReport(runDoctorChecks({ probeCodexHook: true })));
|
|
1049
1369
|
},
|
|
1050
1370
|
});
|
|
1051
1371
|
|
|
@@ -1054,15 +1374,22 @@ register({
|
|
|
1054
1374
|
handler: async (env) => {
|
|
1055
1375
|
if (!authorized(env)) return;
|
|
1056
1376
|
const state = currentState();
|
|
1057
|
-
|
|
1058
|
-
const
|
|
1059
|
-
const backendLabel = settings.backend === "cursor" ? "Cursor Agent" : settings.backend === "codex" ? "Codex" : "Claude Code";
|
|
1377
|
+
const provider = providerFromRegistry(getActiveProvider(state));
|
|
1378
|
+
const settings = provider ? getProviderSettings(state, provider.id) : null;
|
|
1060
1379
|
const cronCount = jobs.listForChannel(env.adapter.id, env.channelId).filter((j) => j.kind === "cron").length;
|
|
1061
|
-
const
|
|
1380
|
+
const commonSettings = state.settings;
|
|
1381
|
+
const activeEngine = require("./recall").activeEngineName(commonSettings) + (commonSettings.recallEngine ? "" : " (default)");
|
|
1382
|
+
const capabilitySummary = provider
|
|
1383
|
+
? ["readOnlyMode", "effort", "budget", "worktree"].map((name) => {
|
|
1384
|
+
const capability = provider.capabilities[name];
|
|
1385
|
+
return `${name === "readOnlyMode" ? "read-only" : name} ${capability.support}`;
|
|
1386
|
+
}).join(" · ")
|
|
1387
|
+
: "none";
|
|
1062
1388
|
send([
|
|
1063
|
-
`Project: ${state.currentSession
|
|
1064
|
-
`
|
|
1065
|
-
`Model: ${settings
|
|
1389
|
+
`Project: ${state.currentSession?.name || "none"}`,
|
|
1390
|
+
`Provider: ${provider?.label || "none"}`,
|
|
1391
|
+
`Model: ${settings?.model || provider?.defaultModel() || "default"} | Effort: ${settings?.effort || "default"}`,
|
|
1392
|
+
`Capabilities: ${capabilitySummary}`,
|
|
1066
1393
|
`Recall engine: ${activeEngine}`,
|
|
1067
1394
|
`Auto-compact: ${formatCompactWindow(state)}`,
|
|
1068
1395
|
`Vault: ${vault.isUnlocked() ? "unlocked" : "locked"} | Crons: ${cronCount}`,
|
|
@@ -1077,14 +1404,9 @@ register({
|
|
|
1077
1404
|
if (!authorized(env)) return;
|
|
1078
1405
|
const state = currentState();
|
|
1079
1406
|
if (state.currentSession) {
|
|
1080
|
-
const
|
|
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" }]] } });
|
|
1407
|
+
const result = endCurrentSession({ state });
|
|
1408
|
+
if (!result.ok) return send(`Could not end session: ${result.error.message}`);
|
|
1409
|
+
send(`Ended: ${result.project}`, { keyboard: { inline_keyboard: [[{ text: "New session", callback_data: "show:projects" }]] } });
|
|
1088
1410
|
} else send("No session.");
|
|
1089
1411
|
},
|
|
1090
1412
|
});
|
|
@@ -1281,7 +1603,9 @@ register({
|
|
|
1281
1603
|
`CRUCIAL — also crystallise the EXECUTABLE part: if the work involved a script, API call, or any repeatable command sequence you wrote inline (a heredoc, a one-off .py/.sh, a curl chain), turn it into a reusable tool instead of leaving it to be re-typed. Write the script to a file (parameterise the bits that vary; read any credential from the env as $NAME rather than hardcoding), then register it with 'open-claudia tool add <path> --pack <dir> --desc "..." [--requires "key1,key2"] [--usage "..."]', linking it to the pack you just touched. Reference creds by their keyring names (see 'open-claudia keyring list'); never write a secret into the tool. The tool is the runnable form of the pack's Procedure — save both. ` +
|
|
1282
1604
|
`No secrets or tokens in packs OR tools. When done, reply with one short line naming the pack you created or updated, any tool you saved, and what it covers. ` +
|
|
1283
1605
|
`If the recent work is genuinely not reusable, say so instead of forcing a skill.`;
|
|
1284
|
-
|
|
1606
|
+
const state = currentState();
|
|
1607
|
+
const runContext = admitRunContext(prompt, state.currentSession.dir, env.messageId, { purpose: "learn" });
|
|
1608
|
+
return runAgent(prompt, runContext.cwd, env.messageId, { runContext, purpose: "learn" });
|
|
1285
1609
|
},
|
|
1286
1610
|
});
|
|
1287
1611
|
|
|
@@ -1291,32 +1615,19 @@ register({
|
|
|
1291
1615
|
name: "auth_status", aliases: ["auth status"], description: "Check Claude Code auth",
|
|
1292
1616
|
handler: async (env) => {
|
|
1293
1617
|
if (!authorized(env)) return;
|
|
1294
|
-
const { spawn } = require("child_process");
|
|
1295
|
-
const { CLAUDE_PATH } = require("./config");
|
|
1296
1618
|
const tokenInfo = getClaudeOAuthToken();
|
|
1297
|
-
const
|
|
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
|
-
});
|
|
1619
|
+
const status = providerRegistry.providerStatus("claude");
|
|
1620
|
+
const output = runClaudeAuthStatusDiagnostic();
|
|
1621
|
+
const code = status.availability.state !== "available" ? 127 : (status.auth.state === "unauthenticated" ? 1 : 0);
|
|
1622
|
+
const clean = redactSensitive(output.trim()) || "(no output)";
|
|
1623
|
+
await send([
|
|
1624
|
+
`Claude auth status: exit ${code}`,
|
|
1625
|
+
...summarizeClaudeAuthStatus(output, code, tokenInfo),
|
|
1626
|
+
`Bot OAuth token: ${tokenInfo.value ? "configured via " + tokenInfo.source : "not configured"}`,
|
|
1627
|
+
`Vault: ${vault.isUnlocked() ? "unlocked" : "locked"}`,
|
|
1628
|
+
"",
|
|
1629
|
+
clean.slice(-2500),
|
|
1630
|
+
].join("\n"));
|
|
1320
1631
|
},
|
|
1321
1632
|
});
|
|
1322
1633
|
|
|
@@ -1432,7 +1743,7 @@ register({
|
|
|
1432
1743
|
name: "codex_setup_token", aliases: ["codex_use_api_key"], description: "Save an OpenAI API key", args: "[<key>]", ownerOnly: true,
|
|
1433
1744
|
handler: async (env, { tail }) => {
|
|
1434
1745
|
if (!ownerEnv(env)) return send("Owner only — Codex auth is shared across users.");
|
|
1435
|
-
if (!
|
|
1746
|
+
if (!discoverProviderExecutable("codex")) return send("Codex CLI not found. Install: npm install -g @openai/codex");
|
|
1436
1747
|
const key = (tail || "").trim();
|
|
1437
1748
|
await deleteMessage(env.messageId);
|
|
1438
1749
|
if (!key) {
|
|
@@ -1606,17 +1917,24 @@ register({
|
|
|
1606
1917
|
handler: async (env, { tail }) => {
|
|
1607
1918
|
if (!authorized(env)) return;
|
|
1608
1919
|
const channelJobs = () => jobs.listForChannel(env.adapter.id, env.channelId).filter((j) => j.kind === "cron");
|
|
1920
|
+
const archivedNote = () => {
|
|
1921
|
+
const archived = jobs.listArchived().filter((j) => !j.channelId || String(j.channelId) === String(env.channelId));
|
|
1922
|
+
if (archived.length === 0) return "";
|
|
1923
|
+
const shown = archived.slice(0, 5).map((j) => `• ${j.label || j.id} — ${j.archiveReason || "archived"}`);
|
|
1924
|
+
const more = archived.length > 5 ? `\n…and ${archived.length - 5} more (open-claudia cron-list shows all).` : "";
|
|
1925
|
+
return `\n\n⚠️ Disabled by provider migration (will not fire):\n${shown.join("\n")}${more}`;
|
|
1926
|
+
};
|
|
1609
1927
|
if (!tail) {
|
|
1610
1928
|
const list = channelJobs();
|
|
1611
1929
|
if (list.length === 0) {
|
|
1612
|
-
return send("No crons
|
|
1930
|
+
return send("No crons." + archivedNote() + "\n\nAdd: /cron add \"<schedule>\" <project> \"<prompt>\"\n\nOr pick a preset:", {
|
|
1613
1931
|
keyboard: { inline_keyboard: [
|
|
1614
1932
|
[{ text: "Standup 9am", callback_data: "cp:standup" }, { text: "Git digest 6pm", callback_data: "cp:git" }],
|
|
1615
1933
|
[{ text: "Dep check Mon", callback_data: "cp:deps" }, { text: "Health 30min", callback_data: "cp:health" }],
|
|
1616
1934
|
] },
|
|
1617
1935
|
});
|
|
1618
1936
|
}
|
|
1619
|
-
return send("Crons:\n\n" + list.map((c, i) => `${i + 1}. ${c.label} (${c.schedule}) — ${c.project || ""}`).join("\n") + "\n\nRemove: /cron remove <#>");
|
|
1937
|
+
return send("Crons:\n\n" + list.map((c, i) => `${i + 1}. ${c.label} (${c.schedule}) — ${c.project || ""}`).join("\n") + archivedNote() + "\n\nRemove: /cron remove <#>");
|
|
1620
1938
|
}
|
|
1621
1939
|
|
|
1622
1940
|
const addMatch = tail.match(/^add\s+"(.+)"\s+(\S+)\s+"(.+)"$/);
|