@inetafrica/open-claudia 3.0.44 → 3.1.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.
@@ -0,0 +1,213 @@
1
+ // Memory-substrate health checks for /doctor. These are the tripwires that
2
+ // would have caught the node-20 pod outage: node:sqlite silently absent, so
3
+ // recall ran 542 turns over 16 days with zero seeds and 87% of reviewer runs
4
+ // lost their mutations — and nothing surfaced it. Every check is synchronous,
5
+ // fast, never throws, and never spawns a model.
6
+
7
+ const fs = require("fs");
8
+ const path = require("path");
9
+ const CONFIG_DIR = require("../config-dir");
10
+
11
+ const DAY_MS = 24 * 60 * 60 * 1000;
12
+ const METRICS_TAIL_BYTES = 256 * 1024;
13
+ const LOCK_HELD_WARN_MS = 5 * 60 * 1000;
14
+ const DISK_WARN_BYTES = 1024 * 1024 * 1024;
15
+ const DISK_FAIL_BYTES = 200 * 1024 * 1024;
16
+
17
+ function sqliteAvailable() {
18
+ try { require("node:sqlite"); return true; } catch (_) { return false; }
19
+ }
20
+
21
+ // Last ~N bytes of a JSONL file as parsed objects (partial first line dropped).
22
+ function tailJsonl(file, maxBytes = METRICS_TAIL_BYTES) {
23
+ let fd = null;
24
+ try {
25
+ const stat = fs.statSync(file);
26
+ fd = fs.openSync(file, "r");
27
+ const size = Math.min(stat.size, maxBytes);
28
+ const buf = Buffer.alloc(size);
29
+ fs.readSync(fd, buf, 0, size, stat.size - size);
30
+ const lines = buf.toString("utf-8").split("\n").filter(Boolean);
31
+ if (stat.size > maxBytes) lines.shift();
32
+ return lines.map((l) => { try { return JSON.parse(l); } catch (_) { return null; } }).filter(Boolean);
33
+ } catch (_) {
34
+ return [];
35
+ } finally {
36
+ if (fd !== null) { try { fs.closeSync(fd); } catch (_) {} }
37
+ }
38
+ }
39
+
40
+ function substrateCheck() {
41
+ if (sqliteAvailable()) {
42
+ return { ok: true, label: "Memory substrate (node:sqlite)", detail: "available — FTS matching, recall graph, episodic index active" };
43
+ }
44
+ return {
45
+ ok: false,
46
+ label: "Memory substrate (node:sqlite)",
47
+ detail: `missing on Node ${process.version} — recall graph, FTS and episodic memory DISABLED; lexical keyword fallback only`,
48
+ action: "Run on Node 24+ (Dockerfile.base must build FROM node:24-slim). Memory stays degraded until then.",
49
+ };
50
+ }
51
+
52
+ function graphCheck() {
53
+ if (!sqliteAvailable()) return null; // substrate check already reports the cause
54
+ try {
55
+ const graph = require("./recall/graph");
56
+ if (!graph.available()) {
57
+ return { ok: true, warn: true, label: "Recall graph", detail: "unavailable", action: "Graph module failed to open its database — check RECALL_GRAPH_DB path/permissions." };
58
+ }
59
+ const s = graph.stats();
60
+ return { ok: true, label: "Recall graph", detail: `${s.nodes} nodes, ${s.edges} edges` };
61
+ } catch (e) {
62
+ return { ok: true, warn: true, label: "Recall graph", detail: `check failed: ${e.message}` };
63
+ }
64
+ }
65
+
66
+ function recallPipelineCheck(now = Date.now()) {
67
+ const label = "Recall pipeline (24h)";
68
+ try {
69
+ const metrics = require("./recall/metrics");
70
+ const cutoff = now - DAY_MS;
71
+ const lines = tailJsonl(metrics.LOG_FILE);
72
+ const turns = lines.filter((r) => r.engine && Date.parse(r.ts || "") >= cutoff);
73
+ const live = turns.filter((r) => !r.gated);
74
+ if (!live.length) {
75
+ return { ok: true, warn: true, label, detail: "no recall turns in the last 24h (idle, or classic engine)" };
76
+ }
77
+ const seeded = live.filter((r) => (r.seeds || []).length > 0).length;
78
+ const injected = live.filter((r) => (r.kept || []).length > 0).length;
79
+ const walkerFailed = live.filter((r) => r.walker === "failed").length;
80
+ const detail = `${live.length} turns, ${seeded} seeded, ${injected} injected memories, ${walkerFailed} walker failures`;
81
+ if (live.length >= 10 && seeded === 0) {
82
+ return { ok: false, label, detail: `${detail} — matching is returning NOTHING`, action: "Recall is running blind. Check the substrate above and the packs/entities indexes." };
83
+ }
84
+ if (live.length >= 10 && injected === 0) {
85
+ return { ok: true, warn: true, label, detail: `${detail} — nothing injected all day`, action: "Seeds exist but nothing survives to injection — check walker health and the relevance gate." };
86
+ }
87
+ return { ok: true, label, detail };
88
+ } catch (e) {
89
+ return { ok: true, warn: true, label, detail: `check failed: ${e.message}` };
90
+ }
91
+ }
92
+
93
+ function reviewerCheck(now = Date.now()) {
94
+ const label = "Memory reviewer (24h)";
95
+ try {
96
+ const metrics = require("./recall/metrics");
97
+ const cutoff = now - DAY_MS;
98
+ const runs = tailJsonl(metrics.LOG_FILE).filter((r) => r.reviewer && Date.parse(r.ts || "") >= cutoff);
99
+ if (!runs.length) {
100
+ return { ok: true, warn: true, label, detail: "no reviewer runs in the last 24h" };
101
+ }
102
+ const failed = runs.filter((r) => r.reviewer.status === "failed");
103
+ const deferred = runs.filter((r) => r.reviewer.status === "deferred").length;
104
+ const lastFail = failed.length ? failed[failed.length - 1].reviewer.code || "" : "";
105
+ const okRate = (runs.length - failed.length) / runs.length;
106
+ const detail = `${runs.length} runs, ${failed.length} failed${deferred ? `, ${deferred} deferred` : ""}${lastFail ? ` (last error: ${lastFail})` : ""}`;
107
+ if (okRate < 0.5) {
108
+ return { ok: false, label, detail, action: "Most memory captures are being lost — check the mutation lock and pending review mutations below." };
109
+ }
110
+ if (okRate < 0.9) {
111
+ return { ok: true, warn: true, label, detail, action: "Reviewer failure rate is elevated — check the mutation lock below." };
112
+ }
113
+ return { ok: true, label, detail };
114
+ } catch (e) {
115
+ return { ok: true, warn: true, label, detail: `check failed: ${e.message}` };
116
+ }
117
+ }
118
+
119
+ function mutationLockCheck() {
120
+ const label = "Memory mutation lock";
121
+ try {
122
+ const { inspectLock } = require("./memory-mutation-queue");
123
+ const lock = inspectLock();
124
+ if (!lock.held) return { ok: true, label, detail: "free" };
125
+ const mins = Math.round(lock.ageMs / 60000);
126
+ const holder = lock.owner ? `pid ${lock.owner.pid}${lock.owner.id ? ` (${String(lock.owner.id).slice(0, 8)})` : ""}` : "unknown owner";
127
+ if (lock.stale) {
128
+ return { ok: true, warn: true, label, detail: `stale — holder gone (${holder}, ${mins}m old); breaks on next mutation` };
129
+ }
130
+ if (lock.ageMs > LOCK_HELD_WARN_MS) {
131
+ return { ok: true, warn: true, label, detail: `held ${mins}m by live ${holder}`, action: "A dream/review may be wedged holding the lock — if this persists, investigate that process." };
132
+ }
133
+ return { ok: true, label, detail: `held by ${holder} (active mutation)` };
134
+ } catch (e) {
135
+ return { ok: true, warn: true, label, detail: `check failed: ${e.message}` };
136
+ }
137
+ }
138
+
139
+ function deferredMutationsCheck() {
140
+ const label = "Pending review mutations";
141
+ try {
142
+ const { listDeferredDecisions } = require("./pack-review");
143
+ const pending = listDeferredDecisions();
144
+ if (!pending.length) return { ok: true, label, detail: "none" };
145
+ return {
146
+ ok: true, warn: true, label,
147
+ detail: `${pending.length} computed decision(s) never applied (lock timeouts)`,
148
+ action: `Inspect ${path.join(CONFIG_DIR, "pending-review-mutations.jsonl")} — re-apply what still matters, then clear it.`,
149
+ };
150
+ } catch (e) {
151
+ return { ok: true, warn: true, label, detail: `check failed: ${e.message}` };
152
+ }
153
+ }
154
+
155
+ function corpusCheck() {
156
+ const label = "Memory corpus";
157
+ try {
158
+ const packs = require("./packs").listPacks().length;
159
+ const entities = require("./entities").listEntities().length;
160
+ const lessonsLib = require("./lessons");
161
+ const lessonCount = lessonsLib.listLessons().length;
162
+ const overCap = lessonCount > lessonsLib.MAX_LESSONS;
163
+ return {
164
+ ok: true,
165
+ warn: overCap,
166
+ label,
167
+ detail: `${packs} packs, ${entities} entities, ${lessonCount}/${lessonsLib.MAX_LESSONS} lessons${overCap ? " (over cap)" : ""}`,
168
+ action: overCap ? "Lessons exceed the always-loaded cap — run `open-claudia dream` to consolidate." : "",
169
+ };
170
+ } catch (e) {
171
+ return { ok: true, warn: true, label, detail: `check failed: ${e.message}` };
172
+ }
173
+ }
174
+
175
+ function diskCheck() {
176
+ const label = "Disk space (config dir)";
177
+ if (typeof fs.statfsSync !== "function") return null;
178
+ try {
179
+ const s = fs.statfsSync(CONFIG_DIR);
180
+ const free = Number(s.bsize) * Number(s.bavail);
181
+ const gb = (free / (1024 * 1024 * 1024)).toFixed(1);
182
+ if (free < DISK_FAIL_BYTES) {
183
+ return { ok: false, label, detail: `${gb} GB free`, action: "Nearly out of disk — memory writes will start failing. Free space now." };
184
+ }
185
+ if (free < DISK_WARN_BYTES) {
186
+ return { ok: true, warn: true, label, detail: `${gb} GB free`, action: "Low disk — consider clearing old transcripts/artifacts." };
187
+ }
188
+ return { ok: true, label, detail: `${gb} GB free` };
189
+ } catch (_) {
190
+ return null;
191
+ }
192
+ }
193
+
194
+ // The full memory section, in report order. Never throws.
195
+ function memoryChecks(options = {}) {
196
+ const now = options.now || Date.now();
197
+ const checks = [];
198
+ for (const check of [
199
+ substrateCheck(),
200
+ graphCheck(),
201
+ recallPipelineCheck(now),
202
+ reviewerCheck(now),
203
+ mutationLockCheck(),
204
+ deferredMutationsCheck(),
205
+ corpusCheck(),
206
+ diskCheck(),
207
+ ]) {
208
+ if (check) checks.push(check);
209
+ }
210
+ return checks;
211
+ }
212
+
213
+ module.exports = { memoryChecks, sqliteAvailable, tailJsonl };
package/core/doctor.js CHANGED
@@ -160,6 +160,11 @@ function runDoctorChecks(options = {}) {
160
160
 
161
161
  checks.push(checkWritableDir(WORKSPACE, "Workspace writable"));
162
162
  checks.push(checkWritableDir(CONFIG_DIR, "Config dir writable"));
163
+ try {
164
+ checks.push(...require("./doctor-memory").memoryChecks());
165
+ } catch (e) {
166
+ checks.push({ ok: true, warn: true, label: "Memory health", detail: `checks failed to run: ${redactSensitive(e.message)}` });
167
+ }
163
168
  return checks;
164
169
  }
165
170
 
package/core/dream.js CHANGED
@@ -1221,13 +1221,14 @@ async function runDreamMutation({ trigger = "manual", provider = null, runUtilit
1221
1221
  }
1222
1222
  }
1223
1223
 
1224
- // Recall-graph maintenance: refresh structural edges, decay reinforced
1225
- // weights, prune orphans. Deterministic + safe, runs every dream.
1224
+ // Recall-graph maintenance: refresh structural edges, prune orphans.
1225
+ // Weight decay is read-time (graph.effectiveWeight) — nothing to rewrite
1226
+ // nightly. Deterministic + safe, runs every dream.
1226
1227
  let graphNote = "";
1227
1228
  try {
1228
- const g = recallGraphLib.tend(packs, entities, { halfLifeDays: recallTuning.get("decayHalfLifeDays") });
1229
- if (g.synced || g.decayed || g.pruned) {
1230
- graphNote = `🕸 Recall graph: ${g.edges} edges / ${g.nodes} nodes (synced ${g.synced}, decayed ${g.decayed}, pruned ${g.pruned}).`;
1229
+ const g = recallGraphLib.tend(packs, entities);
1230
+ if (g.synced || g.pruned) {
1231
+ graphNote = `🕸 Recall graph: ${g.edges} edges / ${g.nodes} nodes (synced ${g.synced}, pruned ${g.pruned}).`;
1231
1232
  }
1232
1233
  } catch (e) { /* graph is best-effort */ }
1233
1234
 
@@ -1241,8 +1242,9 @@ async function runDreamMutation({ trigger = "manual", provider = null, runUtilit
1241
1242
  }
1242
1243
  } catch (e) { /* episodic index is best-effort */ }
1243
1244
 
1244
- // Tool hygiene: tend the directed tool-graph (decay reinforced follows-edges,
1245
- // prune faded/orphaned ones) and flag tools whose --pack link now dangles.
1245
+ // Tool hygiene: tend the directed tool-graph (prune faded/orphaned
1246
+ // follows-edges — decay itself is read-time) and flag tools whose --pack
1247
+ // link now dangles.
1246
1248
  // Read-only toward the tools themselves — a tool is never auto-deleted (that's
1247
1249
  // destructive); we just surface the breakage for the user to re-link or remove.
1248
1250
  let toolNote = "";
@@ -1258,8 +1260,8 @@ async function runDreamMutation({ trigger = "manual", provider = null, runUtilit
1258
1260
  const capList = (items, sep = ", ", max = 5) => (items.length <= max
1259
1261
  ? items.join(sep)
1260
1262
  : `${items.slice(0, max).join(sep)} …and ${items.length - max} more (full list in the dream log)`);
1261
- if (tg.edges || tg.decayed || tg.pruned) {
1262
- push(`🔧 Tool graph: ${tg.edges} edges / ${tg.nodes} nodes (decayed ${tg.decayed}, pruned ${tg.pruned}).`);
1263
+ if (tg.edges || tg.pruned) {
1264
+ push(`🔧 Tool graph: ${tg.edges} edges / ${tg.nodes} nodes (pruned ${tg.pruned}).`);
1263
1265
  }
1264
1266
  const allTools = toolsLib.listTools();
1265
1267
  const dangling = allTools.filter((t) => t.pack && !packs.readPack(t.pack));
package/core/entities.js CHANGED
@@ -264,7 +264,7 @@ let _db = null;
264
264
  let _indexedAt = 0;
265
265
  let _dirty = true;
266
266
 
267
- function markIndexDirty() { _dirty = true; }
267
+ function markIndexDirty() { _dirty = true; _lexicalDocsAt = -1; }
268
268
 
269
269
  function openDb() {
270
270
  if (!DatabaseSync) return null;
@@ -350,16 +350,60 @@ function queryTerms(text) {
350
350
  return terms;
351
351
  }
352
352
 
353
+ // Pure-JS floor under matchEntities for substrates without node:sqlite —
354
+ // same rationale and scoring as the packs floor. Strong is name+aliases
355
+ // only (description scores as body, mirroring the FTS strong filter).
356
+ function termVariants(t) {
357
+ for (const suffix of ["ing", "ed", "es", "s"]) {
358
+ if (t.length > suffix.length + 2 && t.endsWith(suffix)) return [t, t.slice(0, -suffix.length)];
359
+ }
360
+ return [t];
361
+ }
362
+
363
+ let _lexicalDocs = null;
364
+ let _lexicalDocsAt = -1;
365
+
366
+ function lexicalDocs() {
367
+ const mtime = latestMtime();
368
+ if (_lexicalDocs && _lexicalDocsAt === mtime) return _lexicalDocs;
369
+ _lexicalDocs = listEntities().map((e) => ({
370
+ slug: e.slug,
371
+ name: e.name,
372
+ strong: [e.name, e.aliases.join(" ")].join("\n").toLowerCase(),
373
+ body: [e.description, e.sections.Notes, e.sections.Log, e.sections.Role, e.sections.Style, e.sections.Knows]
374
+ .filter(Boolean).join("\n").toLowerCase(),
375
+ }));
376
+ _lexicalDocsAt = mtime;
377
+ return _lexicalDocs;
378
+ }
379
+
380
+ function matchEntitiesLexical(text, { limit = 4, threshold = null } = {}) {
381
+ const terms = queryTerms(text);
382
+ if (terms.length === 0) return [];
383
+ const minScore = threshold ?? Number(process.env.ENTITY_MATCH_THRESHOLD || 2);
384
+ const scored = [];
385
+ for (const doc of lexicalDocs()) {
386
+ let score = 0;
387
+ for (const t of terms) {
388
+ const variants = termVariants(t);
389
+ if (variants.some((v) => doc.strong.includes(v))) score += 2;
390
+ else if (variants.some((v) => doc.body.includes(v))) score += 1;
391
+ }
392
+ if (score >= minScore) scored.push({ slug: doc.slug, name: doc.name, score });
393
+ }
394
+ return scored.sort((a, b) => b.score - a.score).slice(0, Math.max(1, Math.min(10, limit)));
395
+ }
396
+
353
397
  function matchEntities(text, { limit = 4, threshold = null } = {}) {
354
398
  const db = openDb();
355
- if (!db) return [];
399
+ if (!db) return matchEntitiesLexical(text, { limit, threshold });
356
400
  ensureIndex();
357
401
  const terms = queryTerms(text);
358
402
  if (terms.length === 0) return [];
359
403
  const minScore = threshold ?? Number(process.env.ENTITY_MATCH_THRESHOLD || 2);
360
404
  const scores = new Map();
361
405
  let stmt;
362
- try { stmt = db.prepare("SELECT slug FROM entities WHERE entities MATCH ?"); } catch (e) { return []; }
406
+ try { stmt = db.prepare("SELECT slug FROM entities WHERE entities MATCH ?"); } catch (e) { return matchEntitiesLexical(text, { limit, threshold }); }
363
407
  for (const t of terms) {
364
408
  const quoted = `"${t.replace(/"/g, '""')}"`;
365
409
  let strong = [];
package/core/handlers.js CHANGED
@@ -48,6 +48,7 @@ const {
48
48
  runClaudeAuthCommand, clearPendingClaudeAuth,
49
49
  sendCodexAuthStatusSummary, runCodexDeviceLogin, clearPendingCodexAuth,
50
50
  saveCodexApiKeyWithCli,
51
+ canLogout, providerLogout, providerAccountSummary,
51
52
  } = require("./auth-flow");
52
53
  const providerRegistry = require("./providers");
53
54
 
@@ -375,7 +376,7 @@ register({
375
376
  "Connect: /login (pick model + sign in)",
376
377
  "Claude auth: /auth_status /setup_token /use_oauth_token /clear_oauth_token",
377
378
  "Codex auth: /codex_auth_status /codex_login /codex_setup_token",
378
- "System: /doctor /requirements /restart /upgrade",
379
+ "System: /doctor /requirements /restart /upgrade /downgrade /safemode",
379
380
  "",
380
381
  "Send text, voice, photos, or files.",
381
382
  "Reply to any message for context.",
@@ -693,6 +694,8 @@ register({
693
694
  // can be SIGTERM'd at any moment, and an unsent ack dies with us.
694
695
  await send("Upgrade requested — AgentSpace will pull latest image and restart, see you in a minute.");
695
696
  recordUpgradeRequested();
697
+ try { require("./boot-health").recordPendingDoctor({ reason: "AgentSpace image rollout", from: CURRENT_VERSION }); } catch (e) {}
698
+ try { require("./safe-mode").clearCrashLoop(); } catch (e) {}
696
699
  try {
697
700
  const result = await requestAgentSpaceUpgrade();
698
701
  if (result && (result.status === 202 || result.status === 200)) {
@@ -762,6 +765,8 @@ register({
762
765
  const tailNote = "Source refreshed in /app. If this release also changed the Dockerfile (apt packages, env, base image), the host still needs a docker pull + recreate for those.";
763
766
  const msg = `Installed v${newVersion}.${whatsNew ? `\n\nWhat's new:\n${whatsNew}` : ""}\n\n${tailNote}\n\nRestarting...`;
764
767
  await send(msg.length > 3900 ? msg.slice(0, 3900) : msg);
768
+ try { require("./boot-health").recordPendingDoctor({ reason: "source overlay upgrade", from: CURRENT_VERSION, to: newVersion }); } catch (e) {}
769
+ try { require("./safe-mode").clearCrashLoop(); } catch (e) {}
765
770
  try { require("./restart-reason").recordExitReason(`User /upgrade → v${newVersion} (source refresh)`, { code: 0 }); } catch (e) {}
766
771
  setTimeout(() => process.exit(0), 2000);
767
772
  return;
@@ -810,6 +815,8 @@ register({
810
815
  const doctorReport = formatDoctorReport(runDoctorChecks());
811
816
  const msg = `Installed v${newPkg.version}.${whatsNew ? `\n\nWhat's new:\n${whatsNew}` : ""}\n\nPost-upgrade requirements check:\n${doctorReport}\n\nGoing offline to restart...`;
812
817
  await send(msg.length > 3900 ? msg.slice(0, 3900) : msg);
818
+ try { require("./boot-health").recordPendingDoctor({ reason: "npm upgrade", from: CURRENT_VERSION, to: newPkg.version }); } catch (e) {}
819
+ try { require("./safe-mode").clearCrashLoop(); } catch (e) {}
813
820
  try { require("./restart-reason").recordExitReason(`User /upgrade → v${newPkg.version}`, { code: 0 }); } catch (e) {}
814
821
  } catch (e) {
815
822
  const errOutput = (e.stdout || e.stderr || e.message || "").slice(-500);
@@ -820,6 +827,115 @@ register({
820
827
  },
821
828
  });
822
829
 
830
+ // Roll back to an earlier release. The rescue half of /upgrade: a bad release
831
+ // that crash-loops lands in safe mode, and this is the chat-side way back to
832
+ // a version that worked. No arg ⇒ the previously-running version from boot
833
+ // history. Deliberately skips the AgentSpace control-plane path (it can only
834
+ // pull :latest): on pods this overlays /app in place, which survives restarts
835
+ // but not a pod recreate — the permanent fix there is rolling the image.
836
+ register({
837
+ name: "downgrade", description: "Roll back to a previous version", args: "[version]", ownerOnly: true,
838
+ handler: async (env, { args }) => {
839
+ if (!ownerEnv(env)) return;
840
+ const safeModeLib = require("./safe-mode");
841
+ let target = (args[0] || "").trim().replace(/^v/i, "");
842
+ if (!target) {
843
+ target = safeModeLib.previousVersion(CURRENT_VERSION);
844
+ if (!target) {
845
+ await send("No earlier version in my boot history — tell me one explicitly, e.g. /downgrade 3.0.44");
846
+ return;
847
+ }
848
+ }
849
+ // The version lands in a shell command: accept strict semver only.
850
+ if (!/^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/.test(target)) {
851
+ await send(`That doesn't look like a version: "${target}". Use e.g. /downgrade 3.0.44`);
852
+ return;
853
+ }
854
+ if (target === CURRENT_VERSION) {
855
+ await send(`I'm already on v${CURRENT_VERSION}.`);
856
+ return;
857
+ }
858
+ const execEnv = { ...process.env, PATH: FULL_PATH, HOME: process.env.HOME || require("os").homedir() };
859
+ let resolved = null;
860
+ try {
861
+ resolved = execSync(`npm view @inetafrica/open-claudia@${target} version`, {
862
+ encoding: "utf-8", timeout: 15000, env: execEnv,
863
+ }).trim().split("\n").filter(Boolean).pop() || null;
864
+ } catch (e) {
865
+ await send(`Could not verify v${target} on npm (${(e.message || String(e)).slice(0, 200)}).`);
866
+ return;
867
+ }
868
+ if (!resolved) {
869
+ await send(`v${target} does not exist on the npm registry.`);
870
+ return;
871
+ }
872
+ const direction = isNewerVersion(resolved, CURRENT_VERSION) ? "Moving" : "Rolling back";
873
+ if (isDockerAppLayout()) {
874
+ try { process.chdir("/tmp"); } catch (e) {}
875
+ const podNote = process.env.AGENTSPACE_POD_TOKEN
876
+ ? "\nNote: this is an in-place overlay — a pod recreate returns to the deployment image version. Roll the image for a permanent downgrade."
877
+ : "";
878
+ await send(`${direction} v${CURRENT_VERSION} → v${resolved} (in-place tarball overlay into /app)...${podNote}`);
879
+ try {
880
+ inPlaceTarballUpgrade(resolved);
881
+ } catch (e) {
882
+ const errOutput = (e.stdout || e.stderr || e.message || String(e)).slice(-700);
883
+ await send(`Downgrade failed:\n${errOutput}`);
884
+ return;
885
+ }
886
+ await send(`Installed v${resolved}. Restarting...`);
887
+ try { require("./boot-health").recordPendingDoctor({ reason: "downgrade overlay", from: CURRENT_VERSION, to: resolved }); } catch (e) {}
888
+ try { safeModeLib.clearCrashLoop(); } catch (e) {}
889
+ try { require("./restart-reason").recordExitReason(`User /downgrade → v${resolved} (source refresh)`, { code: 0 }); } catch (e) {}
890
+ setTimeout(() => process.exit(0), 2000);
891
+ return;
892
+ }
893
+ try { process.chdir(process.env.HOME || require("os").homedir()); } catch (e) {}
894
+ await send(`${direction} v${CURRENT_VERSION} → v${resolved}...`);
895
+ try {
896
+ execSync(`npm install -g @inetafrica/open-claudia@${resolved} 2>&1`, {
897
+ encoding: "utf-8", timeout: 120000,
898
+ cwd: process.env.HOME || require("os").homedir(),
899
+ env: execEnv,
900
+ });
901
+ await send(`Installed v${resolved}. Going offline to restart...`);
902
+ try { require("./boot-health").recordPendingDoctor({ reason: "downgrade", from: CURRENT_VERSION, to: resolved }); } catch (e) {}
903
+ try { safeModeLib.clearCrashLoop(); } catch (e) {}
904
+ try { require("./restart-reason").recordExitReason(`User /downgrade → v${resolved}`, { code: 0 }); } catch (e) {}
905
+ } catch (e) {
906
+ const errOutput = (e.stdout || e.stderr || e.message || "").slice(-500);
907
+ await send(`Downgrade failed:\n${errOutput}`);
908
+ return;
909
+ }
910
+ setTimeout(() => process.exit(0), 2000);
911
+ },
912
+ });
913
+
914
+ register({
915
+ name: "safemode", description: "Safe-mode status, or continue past it", args: "[continue]", ownerOnly: true,
916
+ handler: async (env, { args }) => {
917
+ if (!ownerEnv(env)) return;
918
+ const safeModeLib = require("./safe-mode");
919
+ const sub = (args[0] || "").toLowerCase();
920
+ if (sub === "continue") {
921
+ if (!safeModeLib.isSafeMode()) {
922
+ await send(`Running normally (v${CURRENT_VERSION}) — there's no safe mode to continue past.`);
923
+ return;
924
+ }
925
+ safeModeLib.requestContinue();
926
+ await send("Override armed: the next boot runs normally even if the substrate is missing (keyword-only memory) and the crash-loop counter resets. Restarting now...");
927
+ try { require("./restart-reason").recordExitReason("User /safemode continue override", { code: 0 }); } catch (e) {}
928
+ setTimeout(() => process.exit(0), 1500);
929
+ return;
930
+ }
931
+ if (safeModeLib.isSafeMode()) {
932
+ await send(safeModeLib.formatSafeModeAlert({ version: CURRENT_VERSION }));
933
+ } else {
934
+ await send(`Running normally (v${CURRENT_VERSION}) — safe mode not active.`);
935
+ }
936
+ },
937
+ });
938
+
823
939
  register({
824
940
  name: "spaces", description: "Control Kazee Spaces auto-replies (status/pause/resume/mode)",
825
941
  args: "<status | pause | resume | mode advisory|autonomous|off>", ownerOnly: true,
@@ -971,13 +1087,16 @@ function buildModelPicker({ authAware = false, state = currentState(), registry
971
1087
  const rows = [];
972
1088
  for (const provider of registry.listProviders()) {
973
1089
  let authMarker = "";
1090
+ let connected = false;
974
1091
  if (authAware) {
975
- let connected = false;
976
1092
  try { connected = provider.authStatus().state === "authenticated"; } catch (_) {}
977
1093
  authMarker = connected ? " · connected" : " · tap to connect";
978
1094
  }
979
1095
  const availableMarker = providerAvailability(provider) ? "" : " · unavailable";
980
1096
  rows.push([{ text: `── ${provider.label}${availableMarker}${authMarker} ──`, callback_data: "noop" }]);
1097
+ if (authAware && connected && canLogout(provider.id)) {
1098
+ rows.push([{ text: "🔁 Switch account", callback_data: `sw:${provider.id}` }]);
1099
+ }
981
1100
  const choices = provider.modelChoices();
982
1101
  for (let index = 0; index < choices.length; index += 3) {
983
1102
  rows.push(choices.slice(index, index + 3).map((model) => ({
@@ -1488,7 +1607,9 @@ register({
1488
1607
  return `${name === "readOnlyMode" ? "read-only" : name} ${capability.support}`;
1489
1608
  }).join(" · ")
1490
1609
  : "none";
1610
+ const safeModeLib = require("./safe-mode");
1491
1611
  send([
1612
+ ...(safeModeLib.isSafeMode() ? [`⛔ SAFE MODE (${safeModeLib.bootInfo().reasons.join(", ")}) — /safemode for details`] : []),
1492
1613
  `Project: ${state.currentSession?.name || "none"}`,
1493
1614
  `Provider: ${provider?.label || "none"}`,
1494
1615
  `Model: ${settings?.model || provider?.defaultModel() || "default"} | Effort: ${settings?.effort || "default"}`,
@@ -1811,6 +1932,43 @@ register({
1811
1932
  },
1812
1933
  });
1813
1934
 
1935
+ async function startProviderLogin(providerId) {
1936
+ if (providerId === "claude") return runClaudeAuthCommand(["auth", "login", "--claudeai"], "Claude login");
1937
+ if (providerId === "codex") return runCodexDeviceLogin();
1938
+ return send(`No login flow for provider "${providerId}".`);
1939
+ }
1940
+
1941
+ async function performProviderLogout(providerId, { thenLogin = false } = {}) {
1942
+ const provider = providerFromRegistry(providerId);
1943
+ const label = provider ? provider.label : providerId;
1944
+ const result = providerLogout(providerId);
1945
+ await send(`${label} sign-out:\n${result.steps.map((s) => `• ${s}`).join("\n")}`);
1946
+ if (thenLogin) return startProviderLogin(providerId);
1947
+ await send(`Sign in with a different account when ready — tap below or use /login.`, {
1948
+ keyboard: { inline_keyboard: [[{ text: `Sign in ${label}`, callback_data: `li:${providerId}` }]] },
1949
+ });
1950
+ }
1951
+
1952
+ module.exports.startProviderLogin = startProviderLogin;
1953
+ module.exports.performProviderLogout = performProviderLogout;
1954
+
1955
+ register({
1956
+ name: "logout", description: "Sign out of a provider (full: CLI store + bot token)", args: "[claude|codex]", ownerOnly: true,
1957
+ handler: async (env, { tail }) => {
1958
+ if (!ownerEnv(env)) return send("Owner only — provider auth is shared across users.");
1959
+ const raw = (tail || "").trim().toLowerCase();
1960
+ const target = ({ gpt: "codex", openai: "codex", chatgpt: "codex", anthropic: "claude" })[raw] || raw;
1961
+ if (canLogout(target)) return performProviderLogout(target);
1962
+ if (target) return send(`Unknown provider "${target}". Use /logout claude or /logout codex.`);
1963
+ return send("Sign out of which provider? This clears the CLI credential store (and Claude's bot token) so /login offers a fresh sign-in.", {
1964
+ keyboard: { inline_keyboard: [[
1965
+ { text: "Claude", callback_data: "lo:claude" },
1966
+ { text: "Codex", callback_data: "lo:codex" },
1967
+ ]] },
1968
+ });
1969
+ },
1970
+ });
1971
+
1814
1972
  register({
1815
1973
  name: "setup_token", description: "Create Claude OAuth token", ownerOnly: true,
1816
1974
  handler: async (env) => {
package/core/keyring.js CHANGED
@@ -21,21 +21,41 @@
21
21
  const fs = require("fs");
22
22
  const path = require("path");
23
23
  const CONFIG_DIR = require("../config-dir");
24
+ const { atomicWriteFileSync, readJsonWithFallback } = require("./fsutil");
24
25
 
25
26
  const KEYRING_FILE = process.env.KEYRING_FILE || path.join(CONFIG_DIR, "keyring.json");
26
27
 
28
+ let warnedCorrupt = false;
29
+
27
30
  function load() {
31
+ let raw;
28
32
  try {
29
- const data = JSON.parse(fs.readFileSync(KEYRING_FILE, "utf-8"));
33
+ raw = fs.readFileSync(KEYRING_FILE, "utf-8");
34
+ } catch (e) {
35
+ return {}; // no keyring yet — fine
36
+ }
37
+ try {
38
+ const data = JSON.parse(raw);
30
39
  return data && typeof data === "object" ? data : {};
31
40
  } catch (e) {
32
- return {}; // missing or corrupt → empty keyring
41
+ // File exists but is unparseable: fall back to the last-good .bak snapshot
42
+ // rather than silently running credential-less, and say so loudly — stderr
43
+ // reaches the bot log, and from `tool run` it lands in the agent's
44
+ // transcript so the failure surfaces to the owner instead of vanishing.
45
+ const data = readJsonWithFallback(KEYRING_FILE, null);
46
+ if (!warnedCorrupt) {
47
+ warnedCorrupt = true;
48
+ console.error(`[keyring] ${KEYRING_FILE} is corrupt (${e.message}); ${
49
+ data ? "using the last-good .bak snapshot" : "NO usable backup — tools run WITHOUT credentials until it is restored"}.`);
50
+ }
51
+ return data && typeof data === "object" ? data : {};
33
52
  }
34
53
  }
35
54
 
36
55
  function save(data) {
37
- fs.writeFileSync(KEYRING_FILE, JSON.stringify(data, null, 2) + "\n");
38
- try { fs.chmodSync(KEYRING_FILE, 0o600); } catch (e) { /* best effort on odd filesystems */ }
56
+ // Atomic, 0600 from birth (no torn-file window, no chmod-after-write gap),
57
+ // with a last-good .bak snapshot that load() falls back to on corruption.
58
+ atomicWriteFileSync(KEYRING_FILE, JSON.stringify(data, null, 2) + "\n", { backup: true, mode: 0o600 });
39
59
  }
40
60
 
41
61
  // Full {key: value} map — used to inject creds into subprocess env.