@inetafrica/open-claudia 2.6.16 → 2.6.22

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/CHANGELOG.md CHANGED
@@ -1,5 +1,8 @@
1
1
  # Changelog
2
2
 
3
+ ## v2.6.20
4
+ - **AgentSpace pod durability.** `/upgrade` and password-change callbacks now preserve the `/api` base path expected by AgentSpace, CLI paths are resolved to concrete executables at startup, and runs repair a stale/missing session workspace before spawning Claude, Cursor, or Codex. This prevents misleading `spawn ... ENOENT` failures when the saved session cwd no longer exists.
5
+
3
6
  ## v2.6.15
4
7
  - **Tokenomics guardrails.** The Usage dashboard now shows the latest context-token count, recent baseline, current rate multiplier, and active alert policy above the existing per-version/token trend charts. Completed turns already logged to `~/.open-claudia/usage-history.jsonl`; the dashboard now uses that history to make regressions visible immediately after `/upgrade`.
5
8
  - **Hard memory recall budget.** Auto-recalled packs/entities now share a total `MEMORY_RECALL_MAX_CHARS` budget (default `9000`) after relevance filtering. Matched memory beyond the budget is omitted with a short pointer to inspect packs/entities manually, so long-term memory remains available without silently bloating every turn.
package/bot-agent.js CHANGED
@@ -150,6 +150,7 @@ bot.on("polling_error", (err) => {
150
150
 
151
151
  // ── Update checker (every 5 mins) ──────────────────────────────────
152
152
  const CURRENT_VERSION = require(path.join(__dirname, "package.json")).version;
153
+ const { isNewerVersion } = require("./core/version");
153
154
  let lastNotifiedVersion = null;
154
155
 
155
156
  function checkForUpdates() {
@@ -159,7 +160,7 @@ function checkForUpdates() {
159
160
  res.on("end", () => {
160
161
  try {
161
162
  const latest = JSON.parse(data).version;
162
- if (latest && latest !== CURRENT_VERSION && latest !== lastNotifiedVersion) {
163
+ if (isNewerVersion(latest, CURRENT_VERSION) && latest !== lastNotifiedVersion) {
163
164
  lastNotifiedVersion = latest;
164
165
  bot.sendMessage(CHAT_ID, `Hey! A new version is available (v${latest}). You're on v${CURRENT_VERSION}.\n\nSend /upgrade to update — I'll be back in a few seconds.`);
165
166
  }
@@ -1585,12 +1586,13 @@ bot.onText(/\/upgrade$/, async (msg) => {
1585
1586
  if (!isOwner(msg)) return;
1586
1587
  try { process.chdir(process.env.HOME || require("os").homedir()); } catch (e) { /* already gone */ }
1587
1588
  // Check if there's actually a newer version
1589
+ let latest = null;
1588
1590
  try {
1589
- const latest = execSync("npm view @inetafrica/open-claudia version", {
1591
+ latest = execSync("npm view @inetafrica/open-claudia version", {
1590
1592
  encoding: "utf-8", timeout: 15000,
1591
1593
  env: { ...process.env, PATH: FULL_PATH, HOME: process.env.HOME || require("os").homedir() },
1592
1594
  }).trim();
1593
- if (latest === CURRENT_VERSION) {
1595
+ if (!isNewerVersion(latest, CURRENT_VERSION)) {
1594
1596
  await send(`Already on the latest version (v${CURRENT_VERSION}).`);
1595
1597
  return;
1596
1598
  }
package/bot.js CHANGED
@@ -16,6 +16,7 @@ const { isOnboarded } = require("./core/onboarding");
16
16
  const { initScheduler, stopAll: stopScheduler } = require("./core/scheduler");
17
17
  const { onMessage, onAction } = require("./core/router");
18
18
  const { publicCommands } = require("./core/commands");
19
+ const { isNewerVersion } = require("./core/version");
19
20
  const registry = require("./core/adapter-registry");
20
21
  const loopback = require("./core/loopback");
21
22
  require("./core/handlers"); // side-effect: register slash commands
@@ -102,7 +103,7 @@ function checkForUpdates() {
102
103
  res.on("end", () => {
103
104
  try {
104
105
  const latest = JSON.parse(data).version;
105
- if (latest && latest !== CURRENT_VERSION && latest !== lastNotifiedVersion) {
106
+ if (isNewerVersion(latest, CURRENT_VERSION) && latest !== lastNotifiedVersion) {
106
107
  lastNotifiedVersion = latest;
107
108
  // Notify owner via the first telegram adapter (the historical home).
108
109
  const tg = adapters.find((a) => a.type === "telegram");
package/core/config.js CHANGED
@@ -3,7 +3,7 @@
3
3
 
4
4
  const fs = require("fs");
5
5
  const path = require("path");
6
- const { execSync } = require("child_process");
6
+ const { execFileSync } = require("child_process");
7
7
  const CONFIG_DIR = require("../config-dir");
8
8
  const { truthy: configTruthy } = require("../project-transcripts");
9
9
 
@@ -12,11 +12,12 @@ const ENV_PATH = path.join(CONFIG_DIR, ".env");
12
12
 
13
13
  function loadEnv() {
14
14
  if (!fs.existsSync(ENV_PATH)) {
15
+ if (process.env.OPEN_CLAUDIA_TEST === "1") return { ...process.env };
15
16
  console.error("No .env file found. Run: node setup.js");
16
17
  process.exit(1);
17
18
  }
18
19
  const lines = fs.readFileSync(ENV_PATH, "utf-8").split("\n");
19
- const env = {};
20
+ const env = { ...process.env };
20
21
  for (const line of lines) {
21
22
  const idx = line.indexOf("=");
22
23
  if (idx > 0) env[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
@@ -38,13 +39,55 @@ function saveEnvKey(key, value) {
38
39
 
39
40
  const config = loadEnv();
40
41
 
42
+ function hasPathSeparator(value) {
43
+ return value.includes("/") || value.includes("\\");
44
+ }
45
+
46
+ function which(command) {
47
+ const lookup = process.platform === "win32" ? "where" : "which";
48
+ return execFileSync(lookup, [command], { encoding: "utf-8" })
49
+ .split(/\r?\n/)
50
+ .map((line) => line.trim())
51
+ .filter(Boolean)[0] || null;
52
+ }
53
+
54
+ function resolveExecutablePath(configured, fallbackCommand, label, opts = {}) {
55
+ const raw = (configured || fallbackCommand || "").trim();
56
+ if (!raw) {
57
+ if (opts.required) {
58
+ console.error(`${label} not set`);
59
+ process.exit(1);
60
+ }
61
+ return null;
62
+ }
63
+
64
+ if (path.isAbsolute(raw) || hasPathSeparator(raw)) {
65
+ if (fs.existsSync(raw)) return raw;
66
+ if (opts.required) {
67
+ console.error(`${label} not found at: ${raw}`);
68
+ process.exit(1);
69
+ }
70
+ return null;
71
+ }
72
+
73
+ try {
74
+ return which(raw);
75
+ } catch (e) {
76
+ if (opts.required) {
77
+ console.error(`${label} not found on PATH: ${raw}`);
78
+ process.exit(1);
79
+ }
80
+ return null;
81
+ }
82
+ }
83
+
41
84
  // Telegram (kept for backwards compat — channels list defaults to telegram)
42
85
  const TOKEN = config.TELEGRAM_BOT_TOKEN;
43
86
  const CHAT_IDS = (config.TELEGRAM_CHAT_ID || "").split(",").map((s) => s.trim()).filter(Boolean);
44
87
  const CHAT_ID = CHAT_IDS[0];
45
88
 
46
89
  const WORKSPACE = config.WORKSPACE;
47
- const CLAUDE_PATH = config.CLAUDE_PATH;
90
+ const CLAUDE_PATH = resolveExecutablePath(config.CLAUDE_PATH, null, "Claude CLI", { required: true });
48
91
  const CURSOR_PATH = config.CURSOR_PATH || null;
49
92
  const CODEX_PATH = config.CODEX_PATH || null;
50
93
  const DEFAULT_CLAUDE_MODEL = config.CLAUDE_MODEL || process.env.CLAUDE_MODEL || "claude-fable-5";
@@ -78,7 +121,6 @@ const MAX_PROCESS_TIMEOUT = 360 * 60 * 1000;
78
121
  const COMPACT_SUMMARY_TIMEOUT = 10 * 60 * 1000;
79
122
 
80
123
  if (!WORKSPACE) { console.error("WORKSPACE not set"); process.exit(1); }
81
- if (!CLAUDE_PATH) { console.error("CLAUDE_PATH not set"); process.exit(1); }
82
124
 
83
125
  if (!fs.existsSync(WORKSPACE)) {
84
126
  try {
@@ -90,27 +132,10 @@ if (!fs.existsSync(WORKSPACE)) {
90
132
  }
91
133
  }
92
134
 
93
- if (!fs.existsSync(CLAUDE_PATH)) {
94
- try {
95
- execSync(`which "${CLAUDE_PATH}" 2>/dev/null || where "${CLAUDE_PATH}" 2>nul`, { encoding: "utf-8" });
96
- } catch (e) {
97
- console.error(`Claude CLI not found at: ${CLAUDE_PATH}`);
98
- process.exit(1);
99
- }
100
- }
101
-
102
- let resolvedCursorPath = CURSOR_PATH;
103
- if (!resolvedCursorPath) {
104
- try { resolvedCursorPath = execSync("which agent 2>/dev/null", { encoding: "utf-8" }).trim() || null; }
105
- catch (e) { resolvedCursorPath = null; }
106
- }
135
+ let resolvedCursorPath = resolveExecutablePath(CURSOR_PATH, "agent", "Cursor Agent CLI");
107
136
  if (resolvedCursorPath) console.error(`Cursor Agent CLI: ${resolvedCursorPath}`);
108
137
 
109
- let resolvedCodexPath = CODEX_PATH;
110
- if (!resolvedCodexPath) {
111
- try { resolvedCodexPath = execSync("which codex 2>/dev/null", { encoding: "utf-8" }).trim() || null; }
112
- catch (e) { resolvedCodexPath = null; }
113
- }
138
+ let resolvedCodexPath = resolveExecutablePath(CODEX_PATH, "codex", "Codex CLI");
114
139
  if (resolvedCodexPath) console.error(`Codex CLI: ${resolvedCodexPath}`);
115
140
 
116
141
  if (!fs.existsSync(TEMP_DIR)) fs.mkdirSync(TEMP_DIR, { recursive: true });
package/core/handlers.js CHANGED
@@ -21,9 +21,11 @@ const { listProjects, findProject, projectKeyboard, workspacePath } = require(".
21
21
  const { vault } = require("./vault-store");
22
22
  const { redactSensitive } = require("./redact");
23
23
  const { runDoctorChecks, formatDoctorReport } = require("./doctor");
24
+ const { isNewerVersion } = require("./version");
24
25
  const jobs = require("./jobs");
25
26
  const scheduler = require("./scheduler");
26
27
  const skillsLib = require("./skills");
28
+ const packsLib = require("./packs");
27
29
  const {
28
30
  runClaude, compactActiveSession, getActiveSessionId, effectiveCompactThreshold,
29
31
  } = require("./runner");
@@ -338,7 +340,10 @@ async function requestAgentSpaceUpgrade() {
338
340
  const apiUrl = process.env.AGENTSPACE_API_URL;
339
341
  const token = process.env.AGENTSPACE_POD_TOKEN;
340
342
  if (!apiUrl || !token) return null;
341
- const u = new URL("/pods/self/upgrade", apiUrl);
343
+ const base = new URL(apiUrl);
344
+ if (base.pathname === "/") base.pathname = "/api/";
345
+ if (!base.pathname.endsWith("/")) base.pathname += "/";
346
+ const u = new URL("pods/self/upgrade", base);
342
347
  const lib = u.protocol === "https:" ? require("https") : require("http");
343
348
  return new Promise((resolve) => {
344
349
  const req = lib.request({
@@ -434,7 +439,7 @@ register({
434
439
  await send(`Upgrade failed: could not query npm registry (${(e.message || String(e)).slice(0, 200)}).`);
435
440
  return;
436
441
  }
437
- if (latest === CURRENT_VERSION) {
442
+ if (!isNewerVersion(latest, CURRENT_VERSION)) {
438
443
  await send(`Already on the latest version (v${CURRENT_VERSION}).`);
439
444
  return;
440
445
  }
@@ -474,7 +479,7 @@ register({
474
479
  encoding: "utf-8", timeout: 15000,
475
480
  env: { ...process.env, PATH: FULL_PATH, HOME: process.env.HOME || require("os").homedir() },
476
481
  }).trim();
477
- if (latest === CURRENT_VERSION) {
482
+ if (!isNewerVersion(latest, CURRENT_VERSION)) {
478
483
  await send(`Already on the latest version (v${CURRENT_VERSION}).`);
479
484
  return;
480
485
  }
@@ -942,7 +947,10 @@ register({
942
947
  },
943
948
  });
944
949
 
945
- // ── Learned skills ──────────────────────────────────────────────────
950
+ // ── Learned skills (now backed by context packs) ────────────────────
951
+ // Skills migrated into context packs (~/.open-claudia/packs). /skills is a
952
+ // thin window onto that store; any stragglers still in the legacy
953
+ // ~/.claude/skills dir get surfaced as a one-line migration nudge.
946
954
 
947
955
  register({
948
956
  name: "skills", description: "List, show, or remove learned skills", args: "[show|remove <name>]",
@@ -952,26 +960,35 @@ register({
952
960
  const name = rest.join(" ");
953
961
 
954
962
  if (sub === "show" && name) {
955
- const skill = skillsLib.findSkill(name);
956
- if (!skill) return send(`No skill named "${name}". /skills to list.`);
963
+ const pack = packsLib.findPack(name);
964
+ if (!pack) return send(`No skill named "${name}". /skills to list.`);
965
+ const file = path.join(packsLib.PACKS_DIR, pack.dir, "PACK.md");
957
966
  let content;
958
- try { content = fs.readFileSync(skill.file, "utf-8"); } catch (e) { return send(`Could not read ${skill.file}: ${e.message}`); }
967
+ try { content = fs.readFileSync(file, "utf-8"); } catch (e) { return send(`Could not read ${file}: ${e.message}`); }
959
968
  const preview = content.length > 3500 ? content.slice(0, 3500) + "\n…(truncated)" : content;
960
969
  await send(preview);
961
- return send(`File: ${skill.file}`);
970
+ return send(`File: ${file}`);
962
971
  }
963
972
 
964
973
  if (sub === "remove" && name) {
965
- const skill = skillsLib.findSkill(name);
966
- if (!skill) return send(`No skill named "${name}". /skills to list.`);
967
- skillsLib.removeSkill(name);
968
- return send(`Removed skill: ${skill.name} (${skill.file})`);
974
+ const pack = packsLib.findPack(name);
975
+ if (!pack) return send(`No skill named "${name}". /skills to list.`);
976
+ packsLib.removePack(pack.dir);
977
+ return send(`Removed skill pack: ${pack.name} (${pack.dir})`);
969
978
  }
970
979
 
971
- const skills = skillsLib.listSkills();
972
- if (skills.length === 0) return send("No learned skills yet. I save them automatically after complex tasks, or use /learn to capture the last piece of work.");
973
- const lines = skills.map((s) => `• ${s.dir} — ${s.description || "(no description)"}`);
974
- return send(`Learned skills (${skills.length}):\n\n${lines.join("\n")}\n\n/skills show <name> · /skills remove <name> · /learn [hint]`);
980
+ const packs = packsLib.listPacks();
981
+ const legacy = skillsLib.listSkills();
982
+ if (packs.length === 0 && legacy.length === 0) {
983
+ return send("No skills yet. I save them automatically as context packs after complex tasks, or use /learn to capture the last piece of work.");
984
+ }
985
+ const lines = packs.map((p) => `• ${p.dir} — ${p.description || p.name || "(no description)"}`);
986
+ let msg = `Skills / context packs (${packs.length}):\n\n${lines.join("\n")}`;
987
+ if (legacy.length) {
988
+ msg += `\n\n${legacy.length} legacy skill(s) still in ~/.claude/skills — run open-claudia pack migrate to fold them in.`;
989
+ }
990
+ msg += `\n\n/skills show <name> · /skills remove <name> · /learn [hint]`;
991
+ return send(msg);
975
992
  },
976
993
  });
977
994
 
@@ -984,9 +1001,10 @@ register({
984
1001
  const hint = tail ? ` The user's hint about what to capture: "${tail}".` : "";
985
1002
  const prompt =
986
1003
  `The user typed /learn: capture the most recent substantial piece of work in this conversation as a reusable skill.${hint} ` +
987
- `Follow the Skill learning rules in your system prompt: check ~/.claude/skills/ first and patch an existing skill instead of duplicating; ` +
988
- `otherwise write ~/.claude/skills/<kebab-name>/SKILL.md with name + a when-to-use description in the frontmatter, then prerequisites, exact commands in order, and pitfalls. ` +
989
- `No secrets or tokens. When done, reply with one short line saying which skill you created or updated and what it covers. ` +
1004
+ `Skills live in context packs (~/.open-claudia/packs/<dir>/PACK.md), NOT the legacy ~/.claude/skills dir. ` +
1005
+ `First check the already-injected packs and 'open-claudia pack list' for a pack this work belongs to; if one fits, fold the reusable how-to into that pack's Procedure section (exact commands in order, prerequisites, pitfalls) and append a dated Journal line — edit the PACK.md directly. ` +
1006
+ `Only if no pack fits, create a new one: 'open-claudia pack' has no create subcommand, so write ~/.open-claudia/packs/<kebab-name>/PACK.md following the four-section format (Stance, Procedure, State, Journal) with name + a when-to-use description in the frontmatter. ` +
1007
+ `No secrets or tokens. When done, reply with one short line naming the pack you created or updated and what it covers. ` +
990
1008
  `If the recent work is genuinely not reusable, say so instead of forcing a skill.`;
991
1009
  await runClaude(prompt, currentState().currentSession.dir, env.messageId);
992
1010
  },
package/core/runner.js CHANGED
@@ -9,9 +9,9 @@ const { spawn, execFileSync } = require("child_process");
9
9
  const {
10
10
  CLAUDE_PATH, DEFAULT_CLAUDE_MODEL, resolvedCursorPath, resolvedCodexPath,
11
11
  AUTO_COMPACT_TOKENS, MIN_COMPACT_INTERVAL_MS, MAX_PROCESS_TIMEOUT, COMPACT_SUMMARY_TIMEOUT, botSubprocessEnv,
12
- CONFIG_DIR, config,
12
+ CONFIG_DIR, WORKSPACE, config,
13
13
  } = require("./config");
14
- const { currentState, saveState, recordSession, userOwnsClaudeSession } = require("./state");
14
+ const { currentState, saveState, recordSession, userOwnsClaudeSession, resetSessionUsage } = require("./state");
15
15
  const { chatContext, currentChannelId, currentAdapter } = require("./context");
16
16
  const { buildSystemPrompt, promptWithDynamicContext } = require("./system-prompt");
17
17
  const { redactSensitive } = require("./redact");
@@ -20,7 +20,7 @@ const { textToVoice, TTS_CMD } = require("./media");
20
20
  const { killProcessTree } = require("./process-tree");
21
21
  const {
22
22
  appendProjectTranscript, transcriptProjectInfo,
23
- promptWithTranscriptPointer, stripTranscriptPointerForStorage,
23
+ stripTranscriptPointerForStorage,
24
24
  } = require("./transcripts");
25
25
  const { getClaudeOAuthToken, claudeAuthRecoveryMessage, isClaudeAuthErrorText, claudeUsageLimitMessage, isClaudeUsageLimitText, runClaudeAuthStatusDiagnostic, claudeSubprocessEnv } = require("./auth-flow");
26
26
  const loopback = require("./loopback");
@@ -45,13 +45,91 @@ function fmtTokens(n) {
45
45
  return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
46
46
  }
47
47
 
48
- function logTurnUsage(state, usage, costUsd, announce) {
48
+ function usageNumber(usage, ...keys) {
49
+ for (const key of keys) {
50
+ const value = usage?.[key];
51
+ if (Number.isFinite(value)) return value;
52
+ }
53
+ return 0;
54
+ }
55
+
56
+ function usageParts(usage, backend = "claude") {
57
+ const input = usageNumber(usage, "input_tokens");
58
+ const cacheRead = backend === "codex"
59
+ ? usageNumber(usage, "cached_input_tokens", "cache_read_input_tokens")
60
+ : usageNumber(usage, "cache_read_input_tokens", "cached_input_tokens");
61
+ const cacheCreation = usageNumber(usage, "cache_creation_input_tokens");
62
+ const output = usageNumber(usage, "output_tokens");
63
+ return {
64
+ input,
65
+ cacheRead,
66
+ cacheCreation,
67
+ output,
68
+ // Codex/OpenAI reports cached input as part of input_tokens. Keep the
69
+ // cache-read field for attribution, but do not add it again to context.
70
+ context: backend === "codex" ? input : input + cacheRead + cacheCreation,
71
+ };
72
+ }
73
+
74
+ function clampUsageDelta(current, previous) {
75
+ if (!previous) return current;
76
+ return {
77
+ input_tokens: Math.max(0, usageNumber(current, "input_tokens") - usageNumber(previous, "input_tokens")),
78
+ cached_input_tokens: Math.max(0, usageNumber(current, "cached_input_tokens") - usageNumber(previous, "cached_input_tokens")),
79
+ cache_read_input_tokens: Math.max(0, usageNumber(current, "cache_read_input_tokens") - usageNumber(previous, "cache_read_input_tokens")),
80
+ cache_creation_input_tokens: Math.max(0, usageNumber(current, "cache_creation_input_tokens") - usageNumber(previous, "cache_creation_input_tokens")),
81
+ output_tokens: Math.max(0, usageNumber(current, "output_tokens") - usageNumber(previous, "output_tokens")),
82
+ };
83
+ }
84
+
85
+ function snapshotCodexUsage(state, sessionId, usage) {
86
+ if (!state || !sessionId || !usage) return null;
87
+ const snapshots = state.codexUsageSnapshots && typeof state.codexUsageSnapshots === "object"
88
+ ? state.codexUsageSnapshots
89
+ : {};
90
+ const previous = snapshots[sessionId] || null;
91
+ snapshots[sessionId] = {
92
+ input_tokens: usageNumber(usage, "input_tokens"),
93
+ cached_input_tokens: usageNumber(usage, "cached_input_tokens"),
94
+ output_tokens: usageNumber(usage, "output_tokens"),
95
+ };
96
+ const keys = Object.keys(snapshots);
97
+ if (keys.length > 20) {
98
+ for (const key of keys.slice(0, keys.length - 20)) delete snapshots[key];
99
+ }
100
+ state.codexUsageSnapshots = snapshots;
101
+ return previous;
102
+ }
103
+
104
+ function normalizeCodexUsage(state, sessionId, usage) {
105
+ const previous = snapshotCodexUsage(state, sessionId, usage);
106
+ if (!previous) return { usage, usageScope: "session_total", rawUsage: usage };
107
+ return { usage: clampUsageDelta(usage, previous), usageScope: "turn_delta", rawUsage: usage };
108
+ }
109
+
110
+ function applyUsageToState(state, usage, costUsd, opts = {}) {
111
+ if (!usage) return null;
112
+ const backend = state.settings?.backend || "claude";
113
+ const parts = usageParts(usage, backend);
114
+ const u = state.sessionUsage;
115
+ u.turns += 1;
116
+ u.inputTokens += parts.input;
117
+ u.outputTokens += parts.output;
118
+ u.cacheReadTokens += parts.cacheRead;
119
+ u.cacheCreationTokens += parts.cacheCreation;
120
+ u.lastInputTokens = parts.context;
121
+ u.lastUsageScope = opts.usageScope || "turn_delta";
122
+ if (Number.isFinite(opts.liveContextTokens)) u.liveContextTokens = opts.liveContextTokens;
123
+ else u.liveContextTokens = parts.context;
124
+ if (typeof costUsd === "number") u.costUsd += costUsd;
125
+ return parts;
126
+ }
127
+
128
+ function logTurnUsage(state, usage, costUsd, announce, opts = {}) {
49
129
  if (!usage) return null;
50
130
  const backend = state.settings?.backend || "claude";
51
- const input = usage.input_tokens || 0;
52
- const cacheRead = usage.cache_read_input_tokens || usage.cached_input_tokens || 0;
53
- const cacheCreation = usage.cache_creation_input_tokens || 0;
54
- const output = usage.output_tokens || 0;
131
+ const parts = usageParts(usage, backend);
132
+ const usageScope = opts.usageScope || "turn_delta";
55
133
  const record = {
56
134
  ts: new Date().toISOString(),
57
135
  version: PKG_VERSION,
@@ -59,13 +137,21 @@ function logTurnUsage(state, usage, costUsd, announce) {
59
137
  model: state.settings?.model || (backend === "claude" ? DEFAULT_CLAUDE_MODEL : null),
60
138
  userId: state.userId || null,
61
139
  sessionId: state[getActiveSessionKey(state)] || null,
62
- inputTokens: input,
63
- outputTokens: output,
64
- cacheReadTokens: cacheRead,
65
- cacheCreationTokens: cacheCreation,
66
- contextTokens: input + cacheRead + cacheCreation,
140
+ usageScope,
141
+ inputTokens: parts.input,
142
+ outputTokens: parts.output,
143
+ cacheReadTokens: parts.cacheRead,
144
+ cacheCreationTokens: parts.cacheCreation,
145
+ contextTokens: parts.context,
67
146
  costUsd: typeof costUsd === "number" ? costUsd : 0,
68
147
  };
148
+ if (opts.rawUsage && opts.rawUsage !== usage) {
149
+ const raw = usageParts(opts.rawUsage, backend);
150
+ record.rawInputTokens = raw.input;
151
+ record.rawOutputTokens = raw.output;
152
+ record.rawCacheReadTokens = raw.cacheRead;
153
+ record.rawContextTokens = raw.context;
154
+ }
69
155
  const previousRecords = loadUsageHistory();
70
156
  appendUsageRecord(record);
71
157
 
@@ -77,9 +163,10 @@ function logTurnUsage(state, usage, costUsd, announce) {
77
163
  if (announce) {
78
164
  const baseline = result.trend?.baselineAvgContextTokens || 0;
79
165
  const rate = result.trend?.latestRate || 0;
166
+ const contextLabel = usageScope === "turn_delta" ? "Context this turn" : "Session context total";
80
167
  announce([
81
168
  "Token usage alert:",
82
- `Context this turn: ${fmtTokens(record.contextTokens)}`,
169
+ `${contextLabel}: ${fmtTokens(record.contextTokens)}`,
83
170
  baseline ? `Recent baseline: ${fmtTokens(baseline)} (${rate.toFixed(2)}x)` : null,
84
171
  `Version/model: v${record.version} / ${record.model || record.backend}`,
85
172
  `Reason: ${result.reasons.join("; ")}`,
@@ -154,6 +241,26 @@ function getActiveSessionKey(state = currentState()) {
154
241
  return "lastSessionId";
155
242
  }
156
243
 
244
+ function resolveRunCwd(cwd) {
245
+ const state = currentState();
246
+ const requested = cwd || state.currentSession?.dir || WORKSPACE || process.cwd();
247
+ if (requested && fs.existsSync(requested)) return requested;
248
+
249
+ const fallback = WORKSPACE && fs.existsSync(WORKSPACE) ? WORKSPACE : process.cwd();
250
+ if (state.currentSession && state.currentSession.dir === requested) {
251
+ console.warn(`[session-guard] repairing missing session dir ${requested} -> ${fallback}`);
252
+ state.currentSession = { ...state.currentSession, dir: fallback };
253
+ state.lastSessionId = null;
254
+ state.cursorSessionId = null;
255
+ state.codexSessionId = null;
256
+ resetSessionUsage(state);
257
+ saveState();
258
+ } else {
259
+ console.warn(`[session-guard] missing run cwd ${requested}; using ${fallback}`);
260
+ }
261
+ return fallback;
262
+ }
263
+
157
264
  function effectiveCompactThreshold(state = currentState()) {
158
265
  const override = state.settings?.compactWindow;
159
266
  if (override === 0) return 0;
@@ -166,9 +273,11 @@ function shouldAutoCompact(state = currentState(), opts = {}) {
166
273
  if (!state[getActiveSessionKey(state)]) return false;
167
274
  const threshold = effectiveCompactThreshold(state);
168
275
  if (threshold === 0) return false;
169
- if ((state.sessionUsage?.lastInputTokens || 0) < threshold) return false;
276
+ const contextTokens = state.sessionUsage?.liveContextTokens || state.sessionUsage?.lastInputTokens || 0;
277
+ if (contextTokens < threshold) return false;
170
278
  const minInterval = Number.isFinite(MIN_COMPACT_INTERVAL_MS) ? MIN_COMPACT_INTERVAL_MS : 1800000;
171
- if (state.lastCompactedAt && (Date.now() - state.lastCompactedAt) < minInterval) return false;
279
+ const farOverThreshold = contextTokens >= threshold * 2;
280
+ if (!farOverThreshold && state.lastCompactedAt && (Date.now() - state.lastCompactedAt) < minInterval) return false;
172
281
  return true;
173
282
  }
174
283
 
@@ -219,7 +328,7 @@ function buildCursorArgs(prompt, opts = {}) {
219
328
  return args;
220
329
  }
221
330
 
222
- function buildCodexArgs(prompt, opts = {}) {
331
+ async function buildCodexArgs(prompt, opts = {}) {
223
332
  const state = currentState();
224
333
  const { settings, codexSessionId } = state;
225
334
  const args = [];
@@ -231,7 +340,7 @@ function buildCodexArgs(prompt, opts = {}) {
231
340
  if (settings.permissionMode === "plan") args.push("--sandbox", "read-only");
232
341
  else args.push("--dangerously-bypass-approvals-and-sandbox");
233
342
  if (settings.model) args.push("--model", settings.model);
234
- args.push(promptWithTranscriptPointer(prompt, state));
343
+ args.push(await promptWithDynamicContext(prompt, { includeTranscriptPointer: true }));
235
344
  return args;
236
345
  }
237
346
 
@@ -504,6 +613,7 @@ async function runClaudeCapture(prompt, cwd, opts = {}) {
504
613
  if (state.runningProcess) throw new Error("Another task is already running.");
505
614
  const authPreflight = preflightClaudeAuthMessage();
506
615
  if (authPreflight) throw new Error(authPreflight);
616
+ cwd = resolveRunCwd(cwd);
507
617
 
508
618
  const args = await buildClaudeArgs(prompt, { ...opts, skipAutoCompact: true });
509
619
  return new Promise((resolve, reject) => {
@@ -556,15 +666,7 @@ async function runClaudeCapture(prompt, cwd, opts = {}) {
556
666
  else state.lastSessionId = evt.session_id;
557
667
  sessionId = evt.session_id;
558
668
  if (evt.usage) {
559
- const u = state.sessionUsage;
560
- u.turns += 1;
561
- u.inputTokens += evt.usage.input_tokens || 0;
562
- u.outputTokens += evt.usage.output_tokens || 0;
563
- u.cacheReadTokens += evt.usage.cache_read_input_tokens || 0;
564
- u.cacheCreationTokens += evt.usage.cache_creation_input_tokens || 0;
565
- u.lastInputTokens = (evt.usage.input_tokens || 0) +
566
- (evt.usage.cache_read_input_tokens || 0) +
567
- (evt.usage.cache_creation_input_tokens || 0);
669
+ applyUsageToState(state, evt.usage, 0, { usageScope: "turn_delta" });
568
670
  }
569
671
  if (typeof evt.total_cost_usd === "number") state.sessionUsage.costUsd += evt.total_cost_usd;
570
672
  saveState();
@@ -630,7 +732,8 @@ async function compactActiveSession(cwd, opts = {}) {
630
732
  state[sessionKey] = newSessionId;
631
733
  state.isFirstMessage = false;
632
734
  state.lastCompactedAt = Date.now();
633
- state.sessionUsage = { turns: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0, costUsd: 0, lastInputTokens: 0 };
735
+ state.sessionUsage = { turns: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0, costUsd: 0, lastInputTokens: 0, liveContextTokens: 0, lastUsageScope: "turn_delta" };
736
+ state.codexUsageSnapshots = {};
634
737
  saveState();
635
738
 
636
739
  if (newSessionId && state.currentSession) {
@@ -646,6 +749,7 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
646
749
  const channelId = currentChannelId();
647
750
  const adapter = currentAdapter();
648
751
  const { settings } = state;
752
+ cwd = resolveRunCwd(cwd);
649
753
 
650
754
  if (state.runningProcess) {
651
755
  state.messageQueue.push({ prompt, replyToMsgId, opts, queuedAt: Date.now() });
@@ -659,6 +763,14 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
659
763
  return;
660
764
  }
661
765
 
766
+ if (state.currentSession && shouldAutoCompact(state, opts)) {
767
+ try {
768
+ await compactActiveSession(state.currentSession.dir, { notify: true, message: "Context is getting large, compacting before I handle this…" });
769
+ } catch (e) {
770
+ console.warn(`[preflight-compact] failed: ${redactSensitive(e.message)}`);
771
+ }
772
+ }
773
+
662
774
  appendProjectTranscript("user", prompt, {
663
775
  sourceMessageId: replyToMsgId || null,
664
776
  fresh: !!opts.fresh,
@@ -868,37 +980,26 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
868
980
  }
869
981
  }
870
982
  if (evt.type === "turn.completed" && evt.usage && settings.backend === "codex") {
871
- const u = state.sessionUsage;
872
- u.turns += 1;
873
- const input = evt.usage.input_tokens || 0;
874
- const cached = evt.usage.cached_input_tokens || 0;
875
- const output = evt.usage.output_tokens || 0;
876
- u.inputTokens += input;
877
- u.outputTokens += output;
878
- u.cacheReadTokens += cached;
879
- u.lastInputTokens = input + cached;
880
- logTurnUsage(state, evt.usage, 0, (text) => chatContext.run(store, () => send(text)));
983
+ const session = state.codexSessionId || evt.thread_id || getActiveSessionId();
984
+ const normalized = normalizeCodexUsage(state, session, evt.usage);
985
+ const rawParts = usageParts(evt.usage, "codex");
986
+ applyUsageToState(state, normalized.usage, 0, {
987
+ usageScope: normalized.usageScope,
988
+ liveContextTokens: rawParts.context,
989
+ });
990
+ logTurnUsage(state, normalized.usage, 0, (text) => chatContext.run(store, () => send(text)), normalized);
881
991
  saveState();
882
992
  }
883
993
  if (evt.type === "result" && evt.session_id) {
884
994
  if (settings.backend === "cursor") { state.cursorSessionId = evt.session_id; }
885
995
  else { state.lastSessionId = evt.session_id; }
886
- if (evt.usage) {
887
- const u = state.sessionUsage;
888
- u.turns += 1;
889
- u.inputTokens += evt.usage.input_tokens || 0;
890
- u.outputTokens += evt.usage.output_tokens || 0;
891
- u.cacheReadTokens += evt.usage.cache_read_input_tokens || 0;
892
- u.cacheCreationTokens += evt.usage.cache_creation_input_tokens || 0;
893
- u.lastInputTokens = (evt.usage.input_tokens || 0) +
894
- (evt.usage.cache_read_input_tokens || 0) +
895
- (evt.usage.cache_creation_input_tokens || 0);
896
- // Codex already logged this turn at turn.completed — don't double-count.
897
- if (settings.backend !== "codex") {
898
- logTurnUsage(state, evt.usage, typeof evt.total_cost_usd === "number" ? evt.total_cost_usd : 0, (text) => chatContext.run(store, () => send(text)));
899
- }
996
+ // Codex already reports usage at turn.completed. A result usage block,
997
+ // if present, is the same cumulative total and must not be counted again.
998
+ if (evt.usage && settings.backend !== "codex") {
999
+ applyUsageToState(state, evt.usage, typeof evt.total_cost_usd === "number" ? evt.total_cost_usd : 0, { usageScope: "turn_delta" });
1000
+ logTurnUsage(state, evt.usage, typeof evt.total_cost_usd === "number" ? evt.total_cost_usd : 0, (text) => chatContext.run(store, () => send(text)), { usageScope: "turn_delta" });
900
1001
  }
901
- if (typeof evt.total_cost_usd === "number") state.sessionUsage.costUsd += evt.total_cost_usd;
1002
+ if (typeof evt.total_cost_usd === "number" && settings.backend === "codex") state.sessionUsage.costUsd += evt.total_cost_usd;
902
1003
  saveState();
903
1004
  }
904
1005
  // Fallback only: evt.result is just the final text segment, and assigning
@@ -1068,6 +1169,10 @@ module.exports = {
1068
1169
  getActiveSessionKey,
1069
1170
  shouldAutoCompact,
1070
1171
  effectiveCompactThreshold,
1172
+ usageParts,
1173
+ clampUsageDelta,
1174
+ normalizeCodexUsage,
1175
+ applyUsageToState,
1071
1176
  compactSeedPrompt,
1072
1177
  splitCompactionBrief,
1073
1178
  archiveCompactionBrief,
package/core/state.js CHANGED
@@ -47,7 +47,7 @@ function freshSettings() {
47
47
  }
48
48
 
49
49
  function freshUsage() {
50
- return { turns: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0, costUsd: 0, lastInputTokens: 0 };
50
+ return { turns: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0, costUsd: 0, lastInputTokens: 0, liveContextTokens: 0, lastUsageScope: "turn_delta" };
51
51
  }
52
52
 
53
53
  function createUserState(userId) {
@@ -65,6 +65,7 @@ function createUserState(userId) {
65
65
  lastSessionId: saved.lastSessionId || null,
66
66
  cursorSessionId: saved.cursorSessionId || null,
67
67
  codexSessionId: saved.codexSessionId || null,
68
+ codexUsageSnapshots: saved.codexUsageSnapshots || {},
68
69
  messageQueue: [],
69
70
  isFirstMessage: !saved.lastSessionId,
70
71
  lastInputWasVoice: false,
@@ -97,6 +98,7 @@ function currentState() {
97
98
 
98
99
  function resetSessionUsage(state = currentState()) {
99
100
  state.sessionUsage = freshUsage();
101
+ state.codexUsageSnapshots = {};
100
102
  }
101
103
 
102
104
  function resetSettings(state = currentState()) {
@@ -111,6 +113,7 @@ function saveState() {
111
113
  lastSessionId: s.lastSessionId,
112
114
  cursorSessionId: s.cursorSessionId,
113
115
  codexSessionId: s.codexSessionId,
116
+ codexUsageSnapshots: s.codexUsageSnapshots || {},
114
117
  settings: s.settings,
115
118
  sessionUsage: s.sessionUsage,
116
119
  lastCompactedAt: s.lastCompactedAt || 0,
@@ -118,6 +118,11 @@ ${buildPersonaBlock()}
118
118
  - Voice notes: ${hasVoice ? "enabled" : "disabled"}
119
119
  - Vault status, session state, and any pending tasks arrive in a "Runtime state" block at the top of each user message, not here.
120
120
 
121
+ ## Open Claudia Skills
122
+ Open Claudia learned skills are stored as context packs under ${path.join(CONFIG_DIR, "packs")}. Older \`~/.claude/skills/<name>/SKILL.md\` skills may have been migrated into packs; their reusable instructions live in the pack's Procedure section.
123
+
124
+ If the user asks for a skill by name, do not rely only on the backend harness's native "Available skills" list. First use any Active context pack injected into the current request as the requested Open Claudia skill. If no matching pack was injected, inspect with \`open-claudia pack list\` / \`open-claudia pack show <dir>\` and legacy \`/skills\` paths before saying the skill does not exist.
125
+
121
126
  ## Stable Local Paths
122
127
  - Bot code: ${path.join(BOT_DIR, "bot.js")}
123
128
  - Soul file (identity + hard rules): ${SOUL_FILE}
@@ -379,7 +384,7 @@ function buildPackBlock(matches, budget) {
379
384
  }
380
385
  if (used.length > 0) setImmediate(() => { try { packsLib.touchUsed(used); } catch (e) {} });
381
386
  if (blocks.length === 0) return "";
382
- return `\n\n## Active context packs\nLong-term topic context auto-matched to this message. Source files live under ${packsLib.PACKS_DIR}/<dir>/PACK.md — read or edit them directly when deeper context or a correction is needed.\n\n${blocks.join("\n\n---\n\n")}`;
387
+ return `\n\n## Active Open Claudia skills / context packs\nLong-term topic context auto-matched to this message. If the user asked for a skill by name, treat the matching pack's Procedure section as that Open Claudia skill. Source files live under ${packsLib.PACKS_DIR}/<dir>/PACK.md — read or edit them directly when deeper context or a correction is needed.\n\n${blocks.join("\n\n---\n\n")}`;
383
388
  } catch (e) {
384
389
  return "";
385
390
  }
@@ -518,7 +523,7 @@ function logRecall(msg, candPacks, candEntities, keptPacks, keptEntities) {
518
523
  } catch (e) {}
519
524
  }
520
525
 
521
- async function promptWithDynamicContext(prompt) {
526
+ async function promptWithDynamicContext(prompt, opts = {}) {
522
527
  lastInjected = { packs: [], entities: [] };
523
528
  try {
524
529
  const { userText, contextText } = recallMatchParts(prompt);
@@ -557,7 +562,8 @@ async function promptWithDynamicContext(prompt) {
557
562
  const budgetNote = budget.omitted > 0
558
563
  ? `\n\n## Memory budget\n${budget.omitted} matched memory item${budget.omitted === 1 ? " was" : "s were"} omitted to keep this turn under the recall budget (${budget.maxChars} chars). Use \`open-claudia pack show <dir>\`, \`entity show <slug>\`, or transcript search if deeper context is needed.`
559
564
  : "";
560
- return `${buildDynamicContextBlock()}${packBlock}${entityBlock}${budgetNote}\n\nCurrent user request:\n${prompt}`;
565
+ const transcriptPointer = opts.includeTranscriptPointer ? `${transcriptPointerNote(state)}\n\n` : "";
566
+ return `${transcriptPointer}${buildDynamicContextBlock()}${packBlock}${entityBlock}${budgetNote}\n\nCurrent user request:\n${prompt}`;
561
567
  } catch (e) {
562
568
  return prompt;
563
569
  }
@@ -0,0 +1,22 @@
1
+ function parseVersion(version) {
2
+ const match = String(version || "").trim().match(/^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/);
3
+ if (!match) return null;
4
+ return match.slice(1).map((part) => Number.parseInt(part, 10));
5
+ }
6
+
7
+ function compareVersions(left, right) {
8
+ const a = parseVersion(left);
9
+ const b = parseVersion(right);
10
+ if (!a || !b) return 0;
11
+ for (let i = 0; i < 3; i += 1) {
12
+ if (a[i] > b[i]) return 1;
13
+ if (a[i] < b[i]) return -1;
14
+ }
15
+ return 0;
16
+ }
17
+
18
+ function isNewerVersion(candidate, current) {
19
+ return compareVersions(candidate, current) > 0;
20
+ }
21
+
22
+ module.exports = { compareVersions, isNewerVersion };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inetafrica/open-claudia",
3
- "version": "2.6.16",
3
+ "version": "2.6.22",
4
4
  "description": "Your always-on AI coding assistant — Claude Code, Cursor Agent, and OpenAI Codex via Telegram or Kazee Chat",
5
5
  "main": "bot.js",
6
6
  "bin": {
@@ -9,7 +9,7 @@
9
9
  "scripts": {
10
10
  "setup": "node setup.js",
11
11
  "start": "node bot.js",
12
- "test": "node -e \"require('./vault'); console.log('OK')\""
12
+ "test": "OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node -e \"require('./vault'); console.log('OK')\" && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-usage-accounting.js"
13
13
  },
14
14
  "files": [
15
15
  "bot.js",
@@ -29,7 +29,8 @@
29
29
  ".dockerignore",
30
30
  ".env.example",
31
31
  "README.md",
32
- "CHANGELOG.md"
32
+ "CHANGELOG.md",
33
+ "test-usage-accounting.js"
33
34
  ],
34
35
  "keywords": [
35
36
  "claude",
@@ -0,0 +1,84 @@
1
+ const assert = require("assert");
2
+ const {
3
+ usageParts,
4
+ normalizeCodexUsage,
5
+ applyUsageToState,
6
+ shouldAutoCompact,
7
+ } = require("./core/runner");
8
+
9
+ function state(overrides = {}) {
10
+ return {
11
+ settings: { backend: "codex", compactWindow: 100 },
12
+ codexSessionId: "codex-session-1",
13
+ codexUsageSnapshots: {},
14
+ sessionUsage: {
15
+ turns: 0,
16
+ inputTokens: 0,
17
+ outputTokens: 0,
18
+ cacheReadTokens: 0,
19
+ cacheCreationTokens: 0,
20
+ costUsd: 0,
21
+ lastInputTokens: 0,
22
+ liveContextTokens: 0,
23
+ lastUsageScope: "turn_delta",
24
+ },
25
+ ...overrides,
26
+ };
27
+ }
28
+
29
+ assert.deepStrictEqual(
30
+ usageParts({ input_tokens: 100, cached_input_tokens: 60, output_tokens: 10 }, "codex"),
31
+ { input: 100, cacheRead: 60, cacheCreation: 0, output: 10, context: 100 },
32
+ );
33
+
34
+ assert.deepStrictEqual(
35
+ usageParts({ input_tokens: 100, cache_read_input_tokens: 60, cache_creation_input_tokens: 20, output_tokens: 10 }, "claude"),
36
+ { input: 100, cacheRead: 60, cacheCreation: 20, output: 10, context: 180 },
37
+ );
38
+
39
+ const s = state();
40
+ const first = normalizeCodexUsage(s, "codex-session-1", {
41
+ input_tokens: 1000,
42
+ cached_input_tokens: 700,
43
+ output_tokens: 50,
44
+ });
45
+ assert.strictEqual(first.usageScope, "session_total");
46
+ assert.strictEqual(usageParts(first.usage, "codex").context, 1000);
47
+
48
+ const second = normalizeCodexUsage(s, "codex-session-1", {
49
+ input_tokens: 1250,
50
+ cached_input_tokens: 800,
51
+ output_tokens: 80,
52
+ });
53
+ assert.strictEqual(second.usageScope, "turn_delta");
54
+ assert.deepStrictEqual(second.usage, {
55
+ input_tokens: 250,
56
+ cached_input_tokens: 100,
57
+ cache_read_input_tokens: 0,
58
+ cache_creation_input_tokens: 0,
59
+ output_tokens: 30,
60
+ });
61
+
62
+ applyUsageToState(s, second.usage, 0, {
63
+ usageScope: second.usageScope,
64
+ liveContextTokens: usageParts(second.rawUsage, "codex").context,
65
+ });
66
+ assert.strictEqual(s.sessionUsage.turns, 1);
67
+ assert.strictEqual(s.sessionUsage.inputTokens, 250);
68
+ assert.strictEqual(s.sessionUsage.cacheReadTokens, 100);
69
+ assert.strictEqual(s.sessionUsage.lastInputTokens, 250);
70
+ assert.strictEqual(s.sessionUsage.liveContextTokens, 1250);
71
+
72
+ assert.strictEqual(shouldAutoCompact(state({
73
+ sessionUsage: { liveContextTokens: 99, lastInputTokens: 99 },
74
+ })), false);
75
+ assert.strictEqual(shouldAutoCompact(state({
76
+ sessionUsage: { liveContextTokens: 150, lastInputTokens: 150 },
77
+ lastCompactedAt: Date.now(),
78
+ })), false);
79
+ assert.strictEqual(shouldAutoCompact(state({
80
+ sessionUsage: { liveContextTokens: 250, lastInputTokens: 250 },
81
+ lastCompactedAt: Date.now(),
82
+ })), true);
83
+
84
+ console.log("usage accounting OK");
package/web.js CHANGED
@@ -70,7 +70,14 @@ function notifyAgentSpacePasswordChanged() {
70
70
  const token = process.env.AGENTSPACE_POD_TOKEN;
71
71
  if (!apiUrl || !token) return resolve({ ok: false, reason: "missing-env" });
72
72
  let u;
73
- try { u = new URL("/pods/self/password-changed", apiUrl); } catch (e) { return resolve({ ok: false, reason: "bad-url" }); }
73
+ try {
74
+ const base = new URL(apiUrl);
75
+ if (base.pathname === "/") base.pathname = "/api/";
76
+ if (!base.pathname.endsWith("/")) base.pathname += "/";
77
+ u = new URL("pods/self/password-changed", base);
78
+ } catch (e) {
79
+ return resolve({ ok: false, reason: "bad-url" });
80
+ }
74
81
  const lib = u.protocol === "https:" ? require("https") : require("http");
75
82
  const req = lib.request({
76
83
  method: "POST",
@@ -935,7 +942,11 @@ init();
935
942
 
936
943
  // ── Server ─────────────────────────────────────────────────────────
937
944
 
945
+ let webServer = null;
946
+
938
947
  function startWebServer() {
948
+ if (webServer) return webServer;
949
+
939
950
  const server = http.createServer(async (req, res) => {
940
951
  // CORS for local dev
941
952
  res.setHeader("Access-Control-Allow-Origin", "*");
@@ -977,6 +988,7 @@ function startWebServer() {
977
988
  reconcileGrandfatheredPasswordChange();
978
989
  });
979
990
 
991
+ webServer = server;
980
992
  return server;
981
993
  }
982
994