@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/bot.js
CHANGED
|
@@ -10,7 +10,46 @@ const path = require("path");
|
|
|
10
10
|
const fs = require("fs");
|
|
11
11
|
const { execSync } = require("child_process");
|
|
12
12
|
|
|
13
|
-
const {
|
|
13
|
+
const {
|
|
14
|
+
config, BOT_DIR, CONFIG_DIR, SOUL_FILE, VAULT_FILE, CHAT_ID,
|
|
15
|
+
STATE_FILE, SESSIONS_FILE, JOBS_FILE, CRONS_FILE,
|
|
16
|
+
ensureRuntimeDirectories,
|
|
17
|
+
} = require("./core/config");
|
|
18
|
+
ensureRuntimeDirectories();
|
|
19
|
+
|
|
20
|
+
// Refuse to double-boot: a second process on this CONFIG_DIR shares the same
|
|
21
|
+
// channel tokens and 409-wars the running service (see core/single-instance).
|
|
22
|
+
const { acquireSingleInstanceLock } = require("./core/single-instance");
|
|
23
|
+
const instanceLock = acquireSingleInstanceLock({ configDir: CONFIG_DIR });
|
|
24
|
+
if (!instanceLock.acquired) {
|
|
25
|
+
const h = instanceLock.holder;
|
|
26
|
+
console.error(
|
|
27
|
+
`Another Open Claudia instance already owns ${CONFIG_DIR} ` +
|
|
28
|
+
`(pid ${h.pid}, started ${h.startedAt || "?"}, ${h.entrypoint || "unknown entrypoint"}). Exiting. ` +
|
|
29
|
+
"Stop it first (launchctl stop com.claude-telegram-bot), or give a dev run its own OPEN_CLAUDIA_CONFIG_DIR."
|
|
30
|
+
);
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const { defaultMigrationSources, ensureMigrationSnapshot } = require("./core/migration-backup");
|
|
35
|
+
const migrationSources = defaultMigrationSources({
|
|
36
|
+
configDir: CONFIG_DIR,
|
|
37
|
+
stateFile: STATE_FILE,
|
|
38
|
+
sessionsFile: SESSIONS_FILE,
|
|
39
|
+
jobsFile: JOBS_FILE,
|
|
40
|
+
cronsFile: CRONS_FILE,
|
|
41
|
+
});
|
|
42
|
+
const migrationSnapshot = ensureMigrationSnapshot({
|
|
43
|
+
configDir: CONFIG_DIR,
|
|
44
|
+
sources: migrationSources,
|
|
45
|
+
});
|
|
46
|
+
require("./core/provider-migration").completeProviderMigration({
|
|
47
|
+
configDir: CONFIG_DIR,
|
|
48
|
+
sources: migrationSources,
|
|
49
|
+
snapshot: migrationSnapshot,
|
|
50
|
+
});
|
|
51
|
+
const { readRuntimeMode, sideChatCoordinator } = require("./core/side-chat");
|
|
52
|
+
const runtimeMode = readRuntimeMode(CONFIG_DIR);
|
|
14
53
|
const { vault } = require("./core/vault-store");
|
|
15
54
|
const { isOnboarded } = require("./core/onboarding");
|
|
16
55
|
const { initScheduler, stopAll: stopScheduler } = require("./core/scheduler");
|
|
@@ -58,14 +97,22 @@ function persist() {
|
|
|
58
97
|
|
|
59
98
|
async function gracefulShutdown(signal) {
|
|
60
99
|
console.log(`Received ${signal}, shutting down gracefully...`);
|
|
100
|
+
try { stopScheduler(); } catch (e) {}
|
|
101
|
+
try { loopback.stop(); } catch (e) {}
|
|
102
|
+
const sideCleanup = sideChatCoordinator.cancelAll().catch(() => 0);
|
|
103
|
+
const memoryCleanup = require("./core/pack-review")
|
|
104
|
+
.drainReviews({ timeoutMs: 5000, cancelOnTimeout: true })
|
|
105
|
+
.catch((error) => {
|
|
106
|
+
console.error("memory shutdown drain failed:", error?.message);
|
|
107
|
+
return { drained: false };
|
|
108
|
+
});
|
|
61
109
|
for (const state of userStates.values()) {
|
|
62
110
|
if (state.runningProcess) {
|
|
63
111
|
try { killProcessTree(state.runningProcess.pid, "SIGTERM"); } catch (e) {}
|
|
64
112
|
}
|
|
65
113
|
}
|
|
114
|
+
await Promise.all([sideCleanup, memoryCleanup]);
|
|
66
115
|
for (const a of adapters) { try { await a.stop(); } catch (e) {} }
|
|
67
|
-
try { stopScheduler(); } catch (e) {}
|
|
68
|
-
try { loopback.stop(); } catch (e) {}
|
|
69
116
|
try {
|
|
70
117
|
const mediaDir = path.join(CONFIG_DIR, "media");
|
|
71
118
|
if (fs.existsSync(mediaDir)) {
|
|
@@ -234,6 +281,7 @@ setInterval(checkForUpdates, 5 * 60 * 1000);
|
|
|
234
281
|
}
|
|
235
282
|
|
|
236
283
|
console.log(`Open Claudia v${CURRENT_VERSION} running on: ${adapters.map((a) => a.id).join(", ")}`);
|
|
284
|
+
console.log(`Runtime mode: ${runtimeMode} (single modular runtime)`);
|
|
237
285
|
console.log(`Workspace: ${require("./core/config").WORKSPACE}`);
|
|
238
286
|
console.log(`Soul: ${SOUL_FILE}`);
|
|
239
287
|
console.log(`Vault: ${VAULT_FILE} (${vault.exists() ? "exists" : "not created"})`);
|
|
@@ -251,6 +299,36 @@ setInterval(checkForUpdates, 5 * 60 * 1000);
|
|
|
251
299
|
}
|
|
252
300
|
} catch (e) {}
|
|
253
301
|
}
|
|
302
|
+
|
|
303
|
+
// Surface jobs the provider migration disabled. Announce once per job:
|
|
304
|
+
// marked only after a successful send, so a down adapter retries next boot.
|
|
305
|
+
try {
|
|
306
|
+
const jobsStore = require("./core/jobs");
|
|
307
|
+
const unannounced = jobsStore.listArchived().filter((job) => !job.announcedAt);
|
|
308
|
+
if (unannounced.length) {
|
|
309
|
+
const lines = unannounced.slice(0, 15).map((job) => {
|
|
310
|
+
const label = job.label || (job.prompt ? String(job.prompt).slice(0, 50) : job.id);
|
|
311
|
+
return `• [${job.kind || "job"}] ${label}\n ↳ ${job.archiveReason}`;
|
|
312
|
+
});
|
|
313
|
+
const message = [
|
|
314
|
+
`⚠️ ${unannounced.length} scheduled job(s) were disabled during the provider migration and will NOT fire:`,
|
|
315
|
+
"",
|
|
316
|
+
...lines,
|
|
317
|
+
unannounced.length > 15 ? `…and ${unannounced.length - 15} more.` : "",
|
|
318
|
+
"",
|
|
319
|
+
"Full list: open-claudia cron-list. Re-create the ones you still need with /cron add or schedule-wakeup.",
|
|
320
|
+
].filter(Boolean).join("\n");
|
|
321
|
+
const tg = adapters.find((a) => a.type === "telegram");
|
|
322
|
+
if (tg && CHAT_ID) {
|
|
323
|
+
await tg.send(CHAT_ID, message);
|
|
324
|
+
jobsStore.markArchivedAnnounced(unannounced.map((job) => job.id));
|
|
325
|
+
} else {
|
|
326
|
+
console.warn(message);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
} catch (e) {
|
|
330
|
+
console.error("archived-jobs notice failed:", e.message);
|
|
331
|
+
}
|
|
254
332
|
})().catch((e) => {
|
|
255
333
|
console.error("Boot failure:", e);
|
|
256
334
|
process.exit(1);
|
|
@@ -36,6 +36,11 @@ class TelegramAdapter {
|
|
|
36
36
|
this._wedgedSince = 0; // start of the current unrecovered error streak
|
|
37
37
|
this._lastHiccupLog = 0; // throttle the transient-error log (1/min)
|
|
38
38
|
this._healBackoff = 0; // grows per consecutive heal, capped
|
|
39
|
+
this._pollWatchdogTimer = null; // detects a silent first/active getUpdates stall
|
|
40
|
+
this._pollStartedAt = 0;
|
|
41
|
+
this._conflictSince = 0; // start of the current 409 streak
|
|
42
|
+
this._conflictTimer = null; // pending paused retry after a 409
|
|
43
|
+
this._conflictClearTimer = null; // quiet after a retry ⇒ streak over
|
|
39
44
|
this._wireInbound();
|
|
40
45
|
}
|
|
41
46
|
|
|
@@ -53,12 +58,33 @@ class TelegramAdapter {
|
|
|
53
58
|
}
|
|
54
59
|
|
|
55
60
|
async start() {
|
|
56
|
-
|
|
61
|
+
// node-telegram-bot-api resolves startPolling() only after the current
|
|
62
|
+
// getUpdates request settles. Awaiting it can therefore pin the entire bot
|
|
63
|
+
// boot sequence behind a half-open first poll. Start the loop, return so the
|
|
64
|
+
// remaining adapters can boot, and let the independent watchdog recover a
|
|
65
|
+
// poll that never completes and emits no polling_error.
|
|
66
|
+
this._pollStartedAt = Date.now();
|
|
67
|
+
Promise.resolve(this.bot.startPolling()).catch((error) => {
|
|
68
|
+
this._onPollingHiccup(error?.message || "Telegram polling failed to start");
|
|
69
|
+
});
|
|
70
|
+
if (!this._pollWatchdogTimer) {
|
|
71
|
+
this._pollWatchdogTimer = setInterval(() => this._checkPollingHealth(), 15000);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
_checkPollingHealth() {
|
|
76
|
+
if (this._conflictTimer || this._conflictSince) return; // 409 backoff owns recovery
|
|
77
|
+
const lastCompletedPoll = this.bot?._polling?._lastUpdate || this._pollStartedAt;
|
|
78
|
+
if (!lastCompletedPoll || Date.now() - lastCompletedPoll <= 75000) return;
|
|
79
|
+
this._onPollingHiccup("poll watchdog: no completed getUpdates request in 75s");
|
|
57
80
|
}
|
|
58
81
|
|
|
59
82
|
async stop() {
|
|
83
|
+
if (this._pollWatchdogTimer) { clearInterval(this._pollWatchdogTimer); this._pollWatchdogTimer = null; }
|
|
60
84
|
if (this._healTimer) { clearTimeout(this._healTimer); this._healTimer = null; }
|
|
61
85
|
if (this._healthyTimer) { clearTimeout(this._healthyTimer); this._healthyTimer = null; }
|
|
86
|
+
if (this._conflictTimer) { clearTimeout(this._conflictTimer); this._conflictTimer = null; }
|
|
87
|
+
if (this._conflictClearTimer) { clearTimeout(this._conflictClearTimer); this._conflictClearTimer = null; }
|
|
62
88
|
try { await this.bot.stopPolling(); } catch (e) {}
|
|
63
89
|
}
|
|
64
90
|
|
|
@@ -94,7 +120,12 @@ class TelegramAdapter {
|
|
|
94
120
|
process.exit(1);
|
|
95
121
|
}
|
|
96
122
|
try {
|
|
97
|
-
try {
|
|
123
|
+
try {
|
|
124
|
+
// `stopPolling()` without cancellation waits for the active getUpdates
|
|
125
|
+
// request to settle. A half-open socket may never settle, which wedges
|
|
126
|
+
// this recovery routine itself. Abort the request before restarting.
|
|
127
|
+
await this.bot.stopPolling({ cancel: true, reason: "stale Telegram long-poll recovery" });
|
|
128
|
+
} catch (e) {}
|
|
98
129
|
try { this._agent.destroy(); } catch (e) {} // drop any lingering pooled socket
|
|
99
130
|
await new Promise((r) => setTimeout(r, 500));
|
|
100
131
|
await this.bot.startPolling();
|
|
@@ -111,13 +142,42 @@ class TelegramAdapter {
|
|
|
111
142
|
}, 30000);
|
|
112
143
|
}
|
|
113
144
|
|
|
145
|
+
// Telegram 409s a getUpdates call while ANOTHER one holds the token. Two
|
|
146
|
+
// causes: a ghost long-poll left by an uncleanly-killed predecessor (clears
|
|
147
|
+
// within its ≤30s poll timeout), or a genuinely running second instance.
|
|
148
|
+
// Exiting on the first 409 turned the ghost case into a KeepAlive respawn
|
|
149
|
+
// storm (7 boot→409→die cycles on 2026-07-12). So: pause, retry inside a
|
|
150
|
+
// grace window, and exit only if the conflict outlives it — that really is
|
|
151
|
+
// a second instance, and the supervisor's throttled respawns pace the rest.
|
|
152
|
+
_on409Conflict() {
|
|
153
|
+
const now = Date.now();
|
|
154
|
+
if (!this._conflictSince) this._conflictSince = now;
|
|
155
|
+
if (this._conflictClearTimer) { clearTimeout(this._conflictClearTimer); this._conflictClearTimer = null; }
|
|
156
|
+
if (now - this._conflictSince > 120000) {
|
|
157
|
+
console.error("Another instance is polling (409 persisted >2min). Exiting.");
|
|
158
|
+
process.exit(1);
|
|
159
|
+
}
|
|
160
|
+
if (this._conflictTimer) return; // a paused retry is already queued
|
|
161
|
+
console.error("409 Conflict: another poller holds this token — pausing 15s (ghost polls clear in <1min; exiting only if this persists >2min).");
|
|
162
|
+
// Stop hammering now; the timer below dials back in.
|
|
163
|
+
Promise.resolve(this.bot.stopPolling({ cancel: true, reason: "409 conflict backoff" })).catch(() => {});
|
|
164
|
+
this._conflictTimer = setTimeout(async () => {
|
|
165
|
+
this._conflictTimer = null;
|
|
166
|
+
try { await this.bot.startPolling(); } catch (e) {}
|
|
167
|
+
// No further 409 for 45s ⇒ the other poller is gone; end the streak.
|
|
168
|
+
this._conflictClearTimer = setTimeout(() => {
|
|
169
|
+
this._conflictClearTimer = null;
|
|
170
|
+
this._conflictSince = 0;
|
|
171
|
+
}, 45000);
|
|
172
|
+
}, 15000);
|
|
173
|
+
}
|
|
174
|
+
|
|
114
175
|
_wireInbound() {
|
|
115
176
|
this.bot.on("polling_error", (err) => {
|
|
116
177
|
const msg = err.message || "";
|
|
117
178
|
if (msg.includes("409 Conflict")) {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
process.exit(1);
|
|
179
|
+
this._on409Conflict();
|
|
180
|
+
return;
|
|
121
181
|
}
|
|
122
182
|
if (/ETIMEDOUT|ECONNRESET|ENOTFOUND|ENETUNREACH|EAI_AGAIN|EFATAL|socket hang up/i.test(msg)) {
|
|
123
183
|
this._onPollingHiccup(msg); // transient — heal quietly, never exit
|
|
@@ -132,6 +132,7 @@ class VoiceAdapter {
|
|
|
132
132
|
// frontend, not just a voice channel.
|
|
133
133
|
if (pathname.startsWith("/v1/manage/")) {
|
|
134
134
|
return require("./manage").handle(req, res, url, {
|
|
135
|
+
canonicalUserId: canonicalForChannel("voice", this.channelId),
|
|
135
136
|
json: (code, payload) => this._json(res, code, payload),
|
|
136
137
|
readBody: (cb) => this._readBody(req, res, cb),
|
|
137
138
|
});
|
package/channels/voice/manage.js
CHANGED
|
@@ -24,7 +24,9 @@ const entities = require("../../core/entities");
|
|
|
24
24
|
const people = require("../../core/people");
|
|
25
25
|
const lessons = require("../../core/lessons");
|
|
26
26
|
const ideas = require("../../core/ideas");
|
|
27
|
-
const { TASKS_DIR,
|
|
27
|
+
const { TASKS_DIR, DEFAULT_PROVIDER } = require("../../core/config");
|
|
28
|
+
const { registry: providerRegistry } = require("../../core/providers");
|
|
29
|
+
const { getActiveProvider, getUserState } = require("../../core/state");
|
|
28
30
|
|
|
29
31
|
let PKG_VERSION = "unknown";
|
|
30
32
|
try { PKG_VERSION = require("../../package.json").version || "unknown"; } catch (e) {}
|
|
@@ -56,7 +58,25 @@ function isOpen(t) { return t.status === "pending" || t.status === "in_progress"
|
|
|
56
58
|
|
|
57
59
|
// ── responses ──────────────────────────────────────────────────────
|
|
58
60
|
|
|
59
|
-
function
|
|
61
|
+
function providerMetadata(providerId, source, reason = null) {
|
|
62
|
+
if (!providerId) return { providerId: null, label: null, model: null, defaultModel: null, source, reason };
|
|
63
|
+
try {
|
|
64
|
+
const provider = providerRegistry.getProvider(providerId);
|
|
65
|
+
const defaultModel = provider.defaultModel() || null;
|
|
66
|
+
return {
|
|
67
|
+
providerId: provider.id,
|
|
68
|
+
label: provider.label,
|
|
69
|
+
model: defaultModel,
|
|
70
|
+
defaultModel,
|
|
71
|
+
source,
|
|
72
|
+
reason,
|
|
73
|
+
};
|
|
74
|
+
} catch (error) {
|
|
75
|
+
return { providerId, label: providerId, model: null, defaultModel: null, source, reason: error.message };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function overview(canonicalUserId = null) {
|
|
60
80
|
const keys = taskFileKeys();
|
|
61
81
|
let openTasks = 0, inProgress = 0;
|
|
62
82
|
for (const key of keys) {
|
|
@@ -67,9 +87,27 @@ function overview() {
|
|
|
67
87
|
}
|
|
68
88
|
}
|
|
69
89
|
const allJobs = jobs.listAll();
|
|
90
|
+
const selectedDefault = providerRegistry.selectDefaultProvider(DEFAULT_PROVIDER);
|
|
91
|
+
const defaultProvider = providerMetadata(
|
|
92
|
+
selectedDefault.providerId,
|
|
93
|
+
selectedDefault.source,
|
|
94
|
+
selectedDefault.reason,
|
|
95
|
+
);
|
|
96
|
+
const activeState = canonicalUserId ? getUserState(canonicalUserId) : null;
|
|
97
|
+
const activeProviderId = getActiveProvider(activeState) || defaultProvider.providerId;
|
|
98
|
+
const activeProvider = providerMetadata(
|
|
99
|
+
activeProviderId,
|
|
100
|
+
getActiveProvider(activeState) ? "active" : defaultProvider.source,
|
|
101
|
+
activeProviderId ? null : defaultProvider.reason,
|
|
102
|
+
);
|
|
103
|
+
const activeModel = activeState?.providerSettings?.[activeProviderId]?.model || activeProvider.defaultModel;
|
|
104
|
+
activeProvider.model = activeModel || null;
|
|
70
105
|
return {
|
|
71
106
|
version: PKG_VERSION,
|
|
72
|
-
|
|
107
|
+
activeProvider,
|
|
108
|
+
defaultProvider,
|
|
109
|
+
// Compatibility for companion clients predating provider metadata.
|
|
110
|
+
model: activeProvider.model,
|
|
73
111
|
counts: {
|
|
74
112
|
tasksOpen: openTasks,
|
|
75
113
|
tasksInProgress: inProgress,
|
|
@@ -184,7 +222,7 @@ function ideasView() {
|
|
|
184
222
|
// we don't reach into its privates.
|
|
185
223
|
|
|
186
224
|
function handle(req, res, url, helpers) {
|
|
187
|
-
const { json, readBody } = helpers;
|
|
225
|
+
const { canonicalUserId, json, readBody } = helpers;
|
|
188
226
|
const method = req.method;
|
|
189
227
|
const sub = url.pathname.slice("/v1/manage/".length).replace(/\/+$/, "");
|
|
190
228
|
const parts = sub.split("/").filter(Boolean);
|
|
@@ -192,7 +230,7 @@ function handle(req, res, url, helpers) {
|
|
|
192
230
|
try {
|
|
193
231
|
// GET reads
|
|
194
232
|
if (method === "GET") {
|
|
195
|
-
if (sub === "overview") return json(200, { ok: true, ...overview() });
|
|
233
|
+
if (sub === "overview") return json(200, { ok: true, ...overview(canonicalUserId) });
|
|
196
234
|
if (sub === "tasks") return json(200, { ok: true, ...allTasks() });
|
|
197
235
|
if (sub === "jobs") return json(200, { ok: true, ...allJobsView() });
|
|
198
236
|
if (sub === "packs") return json(200, { ok: true, ...packSummaries() });
|
package/config-dir.js
CHANGED
|
@@ -1,11 +1,3 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
const CONFIG_DIR = path.join(process.env.HOME || require("os").homedir(), ".open-claudia");
|
|
5
|
-
|
|
6
|
-
// Ensure directory exists
|
|
7
|
-
if (!fs.existsSync(CONFIG_DIR)) {
|
|
8
|
-
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
module.exports = CONFIG_DIR;
|
|
1
|
+
// Compatibility export for modules that need only the path. Resolving a path
|
|
2
|
+
// is intentionally pure: setup/runtime entrypoints create it explicitly.
|
|
3
|
+
module.exports = require("./core/config-dir").resolveConfigDir();
|
package/core/actions.js
CHANGED
|
@@ -4,11 +4,15 @@
|
|
|
4
4
|
|
|
5
5
|
const fs = require("fs");
|
|
6
6
|
const path = require("path");
|
|
7
|
-
const { CONFIG_DIR, WORKSPACE, CHAT_ID
|
|
7
|
+
const { CONFIG_DIR, WORKSPACE, CHAT_ID } = require("./config");
|
|
8
8
|
const { send } = require("./io");
|
|
9
|
-
const {
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
const {
|
|
10
|
+
currentState, saveState,
|
|
11
|
+
getActiveProvider, getProjectKey, consumeSessionCallbackToken,
|
|
12
|
+
userOwnsProviderSession,
|
|
13
|
+
} = require("./state");
|
|
14
|
+
const { admitRunContext, runAgent } = require("./runner");
|
|
15
|
+
const { listProjects, projectKeyboard } = require("./projects");
|
|
12
16
|
const { isChatOwner, approveAuthRequest, denyAuthRequest, authRequestLabel } = require("./access");
|
|
13
17
|
const accessStore = require("./access");
|
|
14
18
|
const peopleStore = require("./people");
|
|
@@ -16,13 +20,47 @@ const { vault } = require("./vault-store");
|
|
|
16
20
|
const intros = require("./intros");
|
|
17
21
|
const introFlow = require("./intro-flow");
|
|
18
22
|
const { finishOnboarding } = require("./onboarding");
|
|
19
|
-
const {
|
|
23
|
+
const {
|
|
24
|
+
startSession, switchProviderState, startNewConversation,
|
|
25
|
+
endCurrentSession, activeContinueOptions, applyProviderSetting,
|
|
26
|
+
} = require("./handlers");
|
|
27
|
+
const providerRegistry = require("./providers");
|
|
20
28
|
const jobs = require("./jobs");
|
|
21
29
|
const scheduler = require("./scheduler");
|
|
30
|
+
const { sideChatCoordinator } = require("./side-chat");
|
|
22
31
|
const {
|
|
23
32
|
getClaudeOAuthToken, runClaudeAuthCommand, runCodexLoginStatus, runCodexDeviceLogin,
|
|
24
33
|
} = require("./auth-flow");
|
|
25
34
|
|
|
35
|
+
const REMOVED_PROVIDER_CALLBACK_MESSAGE = "This button is from an older Open Claudia release. Cursor Agent support has been removed. Open /backend to choose Claude Code or OpenAI Codex.";
|
|
36
|
+
|
|
37
|
+
function isRemovedProviderCallback(payload) {
|
|
38
|
+
const value = String(payload || "");
|
|
39
|
+
return value === "be:cursor" || value === "mb:cursor" || value.startsWith("mb:cursor:");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function handleRemovedProviderCallback(payload, deliver = send) {
|
|
43
|
+
if (!isRemovedProviderCallback(payload)) return false;
|
|
44
|
+
await deliver(REMOVED_PROVIDER_CALLBACK_MESSAGE);
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function resolveSessionCallbackAction(token, state, now = Date.now()) {
|
|
49
|
+
const expected = {
|
|
50
|
+
userId: state?.userId,
|
|
51
|
+
project: getProjectKey(state),
|
|
52
|
+
provider: getActiveProvider(state),
|
|
53
|
+
now,
|
|
54
|
+
};
|
|
55
|
+
const consumed = consumeSessionCallbackToken(token, expected);
|
|
56
|
+
if (consumed.status !== "ok") return consumed;
|
|
57
|
+
const value = consumed.value;
|
|
58
|
+
if (!userOwnsProviderSession(value.userId, value.project, value.provider, value.sessionId)) {
|
|
59
|
+
return { status: "stale", value: null };
|
|
60
|
+
}
|
|
61
|
+
return { status: "ok", value };
|
|
62
|
+
}
|
|
63
|
+
|
|
26
64
|
// Append an owner "always-allow" grant to a person's persona-pack Mandate so
|
|
27
65
|
// the guardrail won't escalate the same thing again. Wholesale-writes the
|
|
28
66
|
// combined mandate (upsert replaces the section); relationship is preserved.
|
|
@@ -46,11 +84,24 @@ function appendMandate(rec, line) {
|
|
|
46
84
|
async function handleAction(envelope) {
|
|
47
85
|
const adapter = envelope.adapter;
|
|
48
86
|
const d = envelope.action?.payload || envelope.action?.buttonId || "";
|
|
87
|
+
const state = currentState();
|
|
88
|
+
const continuePrompt = "continue";
|
|
89
|
+
const continuation = d === "a:continue" && state.currentSession ? activeContinueOptions(state) : null;
|
|
90
|
+
const continueRunContext = continuation
|
|
91
|
+
? admitRunContext(continuePrompt, state.currentSession.dir, null, continuation)
|
|
92
|
+
: null;
|
|
49
93
|
// Telegram needs us to ack the callback so the client stops spinning.
|
|
50
94
|
if (adapter && adapter.type === "telegram" && envelope.action?.callbackId) {
|
|
51
95
|
try { await adapter.bot.answerCallbackQuery(envelope.action.callbackId); } catch (e) {}
|
|
52
96
|
}
|
|
53
|
-
|
|
97
|
+
|
|
98
|
+
if (d.startsWith("sc:q:")) {
|
|
99
|
+
const outcome = await sideChatCoordinator.enqueue(d.slice("sc:q:".length), state.userId);
|
|
100
|
+
if (outcome.status === "queued") return send("Added to the main task queue.");
|
|
101
|
+
if (outcome.status === "forbidden") return send("That side-chat action belongs to another user.");
|
|
102
|
+
if (outcome.status === "expired") return send("That side-chat action expired. Send the message again if it still matters.");
|
|
103
|
+
return send(`Could not add that message to the main task: ${outcome.error?.message || "unknown error"}`);
|
|
104
|
+
}
|
|
54
105
|
|
|
55
106
|
if (d.startsWith("auth:")) {
|
|
56
107
|
if (!isChatOwner(envelope.channelId)) return send("Owner only — auth controls are restricted.");
|
|
@@ -233,47 +284,60 @@ async function handleAction(envelope) {
|
|
|
233
284
|
|
|
234
285
|
if (d === "show:projects") { await send("Pick:", { keyboard: projectKeyboard() }); return; }
|
|
235
286
|
if (d.startsWith("s:")) { startSession(d.slice(2)); return; }
|
|
236
|
-
if (d.startsWith("ss:")) {
|
|
287
|
+
if (d.startsWith("ss:")) {
|
|
288
|
+
const resolved = resolveSessionCallbackAction(d.slice(3), state);
|
|
289
|
+
if (resolved.status !== "ok") {
|
|
290
|
+
const message = resolved.status === "forbidden"
|
|
291
|
+
? "That conversation button belongs to another user, project, or provider."
|
|
292
|
+
: "That conversation button is stale or expired. Open /sessions again.";
|
|
293
|
+
return send(message);
|
|
294
|
+
}
|
|
295
|
+
return startSession(resolved.value.project, resolved.value.sessionId, { provider: resolved.value.provider });
|
|
296
|
+
}
|
|
237
297
|
if (d.startsWith("new:")) {
|
|
238
298
|
const proj = d.slice(4);
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
state.messageQueue = [];
|
|
243
|
-
resetSettings(state);
|
|
244
|
-
resetSessionUsage(state);
|
|
245
|
-
saveState();
|
|
299
|
+
const expected = proj === "__workspace__" ? "Workspace" : proj;
|
|
300
|
+
const result = startNewConversation({ state, projectName: expected });
|
|
301
|
+
if (!result.ok) return send("That new-conversation button is stale. Open /sessions again.");
|
|
246
302
|
await send(`Session: ${state.currentSession.name}\nNew conversation\n\nSend text, voice, or images.\n\n/sessions — switch conversation\n/session — switch project`);
|
|
247
303
|
return;
|
|
248
304
|
}
|
|
249
|
-
if (d === "a:continue") {
|
|
305
|
+
if (d === "a:continue") {
|
|
306
|
+
if (!continueRunContext) return send("No session to continue.");
|
|
307
|
+
return runAgent(continuePrompt, continueRunContext.cwd, null, {
|
|
308
|
+
runContext: continueRunContext,
|
|
309
|
+
...continuation,
|
|
310
|
+
});
|
|
311
|
+
}
|
|
250
312
|
if (d === "a:end") {
|
|
251
313
|
if (state.currentSession) {
|
|
252
|
-
const
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
state.messageQueue = [];
|
|
256
|
-
resetSettings(state);
|
|
257
|
-
resetSessionUsage(state);
|
|
258
|
-
saveState();
|
|
259
|
-
await send(`Ended: ${n}`, { keyboard: { inline_keyboard: [[{ text: "New", callback_data: "show:projects" }]] } });
|
|
314
|
+
const result = endCurrentSession({ state });
|
|
315
|
+
if (!result.ok) return send(`Could not end session: ${result.error.message}`);
|
|
316
|
+
await send(`Ended: ${result.project}`, { keyboard: { inline_keyboard: [[{ text: "New", callback_data: "show:projects" }]] } });
|
|
260
317
|
}
|
|
261
318
|
return;
|
|
262
319
|
}
|
|
263
320
|
if (d === "noop") return;
|
|
321
|
+
if (await handleRemovedProviderCallback(d)) return;
|
|
264
322
|
if (d.startsWith("mb:")) {
|
|
265
323
|
const rest = d.slice(3);
|
|
266
324
|
const colon = rest.indexOf(":");
|
|
267
325
|
if (colon < 0) return;
|
|
268
326
|
const be = rest.slice(0, colon);
|
|
269
327
|
const model = rest.slice(colon + 1);
|
|
270
|
-
|
|
271
|
-
|
|
328
|
+
let provider;
|
|
329
|
+
try { provider = providerRegistry.getProvider(be); }
|
|
330
|
+
catch (_) { return send("That provider is no longer available. Open /model again."); }
|
|
331
|
+
let available = false;
|
|
332
|
+
try { available = provider.isAvailable() === true; } catch (_) {}
|
|
333
|
+
if (!available) return send(`${provider.label} is unavailable. Run /doctor for recovery steps.`);
|
|
334
|
+
if (model !== "default" && !provider.modelChoices().includes(model)) {
|
|
335
|
+
return send("That model button is stale. Open /model again.");
|
|
336
|
+
}
|
|
272
337
|
const switched = state.settings.backend !== be;
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
const beLabel = be === "cursor" ? "Cursor Agent" : be === "codex" ? "Codex" : "Claude Code";
|
|
338
|
+
const changed = switchProviderState(be, { state, model: model === "default" ? null : model });
|
|
339
|
+
if (!changed.ok) return send(`Could not switch provider: ${changed.error.message}`);
|
|
340
|
+
const beLabel = provider.label;
|
|
277
341
|
// Auth-aware: if the chosen provider isn't connected, start its login flow
|
|
278
342
|
// now. The model is already set, so once auth completes the user is on it.
|
|
279
343
|
if (be === "claude" && !getClaudeOAuthToken().value) {
|
|
@@ -291,9 +355,27 @@ async function handleAction(envelope) {
|
|
|
291
355
|
await send(switched ? `Switched to ${beLabel}.\nModel: ${model}` : `Model: ${model}`);
|
|
292
356
|
return;
|
|
293
357
|
}
|
|
294
|
-
if (d.startsWith("m:")) {
|
|
295
|
-
|
|
296
|
-
|
|
358
|
+
if (d.startsWith("m:")) {
|
|
359
|
+
state.settings.model = d.slice(2) === "default" ? null : d.slice(2);
|
|
360
|
+
await send(`Model: ${state.settings.model || "default"}`);
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
if (d.startsWith("e:")) {
|
|
364
|
+
const parts = d.split(":");
|
|
365
|
+
const providerId = parts.length >= 3 ? parts[1] : getActiveProvider(state);
|
|
366
|
+
const effort = parts.length >= 3 ? parts.slice(2).join(":") : parts[1];
|
|
367
|
+
const result = applyProviderSetting(state, providerId, "effort", effort, { requireActive: true, persist: true });
|
|
368
|
+
await send(result.ok ? `${result.providerLabel} effort: ${result.value || "default"}` : result.message);
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
if (d.startsWith("b:")) {
|
|
372
|
+
const parts = d.split(":");
|
|
373
|
+
const providerId = parts.length >= 3 ? parts[1] : getActiveProvider(state);
|
|
374
|
+
const budget = parts.length >= 3 ? parts.slice(2).join(":") : parts[1];
|
|
375
|
+
const result = applyProviderSetting(state, providerId, "budget", budget, { requireActive: true, persist: true });
|
|
376
|
+
await send(result.ok ? `${result.providerLabel} budget: ${result.value ? "$" + result.value : "none"}` : result.message);
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
297
379
|
if (d.startsWith("eng:")) {
|
|
298
380
|
const v = d.slice(4);
|
|
299
381
|
const recall = require("./recall");
|
|
@@ -386,6 +468,9 @@ async function handleAction(envelope) {
|
|
|
386
468
|
: `[Tool approval ${rec.id}] The owner DENIED the held run${isExternalAction ? ` requested by ${rec.personName || "an external contact"}` : ""}:\n${rec.command}\n` +
|
|
387
469
|
`Do NOT run it. ${isExternalAction ? "Let them know you can't do that one and move on." : "Acknowledge the denial to the user and adjust course."}`;
|
|
388
470
|
try {
|
|
471
|
+
if (!rec.provider || !rec.project || !rec.canonicalUserId) {
|
|
472
|
+
throw new Error("the original provider/project context predates safe scheduled-run pinning");
|
|
473
|
+
}
|
|
389
474
|
scheduler.addJob({
|
|
390
475
|
kind: "wakeup",
|
|
391
476
|
// External escalations carry the origin as an adapter TYPE string
|
|
@@ -393,6 +478,20 @@ async function handleAction(envelope) {
|
|
|
393
478
|
adapter: isExternalAction ? "" : (rec.adapter || (adapter && adapter.id) || ""),
|
|
394
479
|
adapterType: isExternalAction ? (rec.adapter || (adapter && adapter.type)) : (adapter && adapter.type),
|
|
395
480
|
channelId: String(rec.channelId || envelope.channelId),
|
|
481
|
+
canonicalUserId: rec.canonicalUserId,
|
|
482
|
+
userId: rec.userId || null,
|
|
483
|
+
provider: rec.provider,
|
|
484
|
+
providerSettings: rec.providerSettings || {},
|
|
485
|
+
project: rec.project,
|
|
486
|
+
projectDir: rec.projectDir || null,
|
|
487
|
+
sessionId: rec.sessionId || null,
|
|
488
|
+
previousSessionId: rec.previousSessionId || null,
|
|
489
|
+
originRunId: rec.originRunId || null,
|
|
490
|
+
sessionLineage: {
|
|
491
|
+
originRunId: rec.originRunId || null,
|
|
492
|
+
originSessionId: rec.sessionId || null,
|
|
493
|
+
previousSessionId: rec.previousSessionId || null,
|
|
494
|
+
},
|
|
396
495
|
prompt,
|
|
397
496
|
label: `Tool approval ${rec.status}: ${label}`.slice(0, 60),
|
|
398
497
|
source: "approval",
|
|
@@ -417,6 +516,12 @@ async function handleAction(envelope) {
|
|
|
417
516
|
await send(`Tool trace: ${state.settings.showToolTrace ? "on" : "off"}`);
|
|
418
517
|
return;
|
|
419
518
|
}
|
|
519
|
+
if (d.startsWith("vb:")) {
|
|
520
|
+
state.settings.showThinking = d.slice(3) === "on";
|
|
521
|
+
saveState();
|
|
522
|
+
await send(`Verbose thinking: ${state.settings.showThinking ? "on" : "off"}`);
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
420
525
|
if (d.startsWith("cw:")) {
|
|
421
526
|
const v = d.slice(3);
|
|
422
527
|
if (v === "default") state.settings.compactWindow = null;
|
|
@@ -432,22 +537,26 @@ async function handleAction(envelope) {
|
|
|
432
537
|
}
|
|
433
538
|
if (d.startsWith("be:")) {
|
|
434
539
|
const be = d.slice(3);
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
540
|
+
let provider;
|
|
541
|
+
try { provider = providerRegistry.getProvider(be); }
|
|
542
|
+
catch (_) { return send("That provider is no longer available. Open /backend again."); }
|
|
543
|
+
let available = false;
|
|
544
|
+
try { available = provider.isAvailable() === true; } catch (_) {}
|
|
545
|
+
if (!available) return send(`${provider.label} is unavailable. Run /doctor for recovery steps.`);
|
|
546
|
+
const switched = switchProviderState(be, { state });
|
|
547
|
+
if (!switched.ok) return send(`Could not switch provider: ${switched.error.message}`);
|
|
548
|
+
await send(`Switched to ${provider.label}.`);
|
|
442
549
|
return;
|
|
443
550
|
}
|
|
444
551
|
|
|
445
552
|
if (d.startsWith("mode:")) {
|
|
446
553
|
if (!isChatOwner(envelope.channelId)) { await send("Owner only — mode switch restarts the bot."); return; }
|
|
447
554
|
const newMode = d.slice(5);
|
|
555
|
+
if (!new Set(["direct", "agent"]).has(newMode)) return send("Unknown runtime mode.");
|
|
448
556
|
const modeFile = path.join(CONFIG_DIR, ".bot-mode");
|
|
449
557
|
fs.writeFileSync(modeFile, newMode);
|
|
450
558
|
await send(`Switching to ${newMode} mode... restarting.`);
|
|
559
|
+
await sideChatCoordinator.cancelAll();
|
|
451
560
|
setTimeout(() => process.exit(0), 500);
|
|
452
561
|
return;
|
|
453
562
|
}
|
|
@@ -499,4 +608,10 @@ async function handleAction(envelope) {
|
|
|
499
608
|
}
|
|
500
609
|
}
|
|
501
610
|
|
|
502
|
-
module.exports = {
|
|
611
|
+
module.exports = {
|
|
612
|
+
REMOVED_PROVIDER_CALLBACK_MESSAGE,
|
|
613
|
+
handleAction,
|
|
614
|
+
handleRemovedProviderCallback,
|
|
615
|
+
isRemovedProviderCallback,
|
|
616
|
+
resolveSessionCallbackAction,
|
|
617
|
+
};
|