@omnicross/daemon 0.1.0 → 0.1.2

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/dist/index.cjs CHANGED
@@ -33,7 +33,6 @@ __export(src_exports, {
33
33
  AdminServer: () => AdminServer,
34
34
  ConfigFileProviderConfigSource: () => ConfigFileProviderConfigSource,
35
35
  ConsoleLogger: () => ConsoleLogger,
36
- DASHBOARD_HTML: () => DASHBOARD_HTML,
37
36
  DEFAULT_ADMIN_PORT: () => DEFAULT_ADMIN_PORT,
38
37
  JsonApiServerSettingsStore: () => JsonApiServerSettingsStore,
39
38
  JsonOutboundKeyDb: () => JsonOutboundKeyDb,
@@ -58,6 +57,7 @@ var import_outbound_api3 = require("@omnicross/core/outbound-api");
58
57
  var import_subscriptionRegistryPort = require("@omnicross/core/outbound-api/subscriptionRegistryPort");
59
58
  var import_gemini_code_assist_resolver = require("@omnicross/core/ports/gemini-code-assist-resolver");
60
59
  var import_provider_proxy = require("@omnicross/core/provider-proxy");
60
+ var import_usage = require("@omnicross/core/usage");
61
61
  var import_subscriptions4 = require("@omnicross/subscriptions");
62
62
 
63
63
  // src/admin/accountsCodexOAuth.ts
@@ -152,7 +152,7 @@ function handleCodexOAuthStatus(sessionId, deps) {
152
152
  }
153
153
 
154
154
  // src/admin/AdminServer.ts
155
- var import_node_crypto6 = require("crypto");
155
+ var import_node_crypto7 = require("crypto");
156
156
  var import_node_http2 = __toESM(require("http"), 1);
157
157
 
158
158
  // src/admin/adminApi.ts
@@ -240,23 +240,23 @@ function decodeEnvKey(raw) {
240
240
  }
241
241
  return buf;
242
242
  }
243
- function readKeyFile(path) {
244
- const raw = (0, import_node_fs.readFileSync)(path);
243
+ function readKeyFile(path2) {
244
+ const raw = (0, import_node_fs.readFileSync)(path2);
245
245
  if (raw.length === KEY_BYTES2) return raw;
246
246
  const text = raw.toString("utf8").trim();
247
247
  if (/^[0-9a-fA-F]{64}$/.test(text)) return Buffer.from(text, "hex");
248
248
  const b64 = Buffer.from(text, "base64");
249
249
  if (b64.length === KEY_BYTES2) return b64;
250
250
  throw new Error(
251
- `master key file '${path}' is invalid: expected 32 raw bytes, 64 hex chars, or 32-byte base64`
251
+ `master key file '${path2}' is invalid: expected 32 raw bytes, 64 hex chars, or 32-byte base64`
252
252
  );
253
253
  }
254
- function generateKeyFile(path) {
254
+ function generateKeyFile(path2) {
255
255
  const key = (0, import_node_crypto3.randomBytes)(KEY_BYTES2);
256
- (0, import_node_fs.mkdirSync)((0, import_node_path.dirname)(path), { recursive: true });
257
- (0, import_node_fs.writeFileSync)(path, key, { mode: 384 });
256
+ (0, import_node_fs.mkdirSync)((0, import_node_path.dirname)(path2), { recursive: true });
257
+ (0, import_node_fs.writeFileSync)(path2, key, { mode: 384 });
258
258
  try {
259
- (0, import_node_fs.chmodSync)(path, 384);
259
+ (0, import_node_fs.chmodSync)(path2, 384);
260
260
  } catch {
261
261
  }
262
262
  return key;
@@ -635,25 +635,25 @@ var secretBox = null;
635
635
  function setSecretBox(box) {
636
636
  secretBox = box;
637
637
  }
638
- function loadConfig(path) {
638
+ function loadConfig(path2) {
639
639
  let raw;
640
640
  try {
641
- raw = (0, import_node_fs2.readFileSync)(path, "utf8");
641
+ raw = (0, import_node_fs2.readFileSync)(path2, "utf8");
642
642
  } catch {
643
- throw new Error(`config: cannot read file at '${path}'`);
643
+ throw new Error(`config: cannot read file at '${path2}'`);
644
644
  }
645
645
  let parsed;
646
646
  try {
647
647
  parsed = JSON.parse(raw);
648
648
  } catch {
649
- throw new Error(`config: '${path}' is not valid JSON`);
649
+ throw new Error(`config: '${path2}' is not valid JSON`);
650
650
  }
651
651
  const validated = validateConfig(parsed);
652
652
  return secretBox ? decryptConfigSecrets(validated, secretBox) : validated;
653
653
  }
654
- function saveConfig(path, cfg) {
654
+ function saveConfig(path2, cfg) {
655
655
  const toWrite = secretBox ? encryptConfigSecrets(cfg, secretBox) : cfg;
656
- (0, import_node_fs2.writeFileSync)(path, JSON.stringify(toWrite, null, 2) + "\n", "utf8");
656
+ (0, import_node_fs2.writeFileSync)(path2, JSON.stringify(toWrite, null, 2) + "\n", "utf8");
657
657
  }
658
658
 
659
659
  // src/pool/resolveEnvKey.ts
@@ -899,7 +899,8 @@ async function handleOAuthComplete(providerId, body, deps) {
899
899
  const reason = exchangeError instanceof Error ? exchangeError.message : "token exchange failed";
900
900
  return err2(502, `oauth token exchange failed for '${providerId}': ${reason}`);
901
901
  }
902
- await deps.subscriptionAccountAppender.appendProviderAccount(providerId, block);
902
+ const label = typeof body["label"] === "string" && body["label"].trim() ? body["label"].trim() : void 0;
903
+ await deps.subscriptionAccountAppender.appendProviderAccount(providerId, block, label);
903
904
  const status = await statusEntryFor(deps.subscriptionAccounts, providerId);
904
905
  return { status: 200, body: status ? { account: status } : { ok: true } };
905
906
  }
@@ -932,8 +933,202 @@ async function exchangeGemini(code, codeVerifier, exchangeFetch) {
932
933
  };
933
934
  }
934
935
 
935
- // src/ports/account-multi.ts
936
+ // src/admin/cliLaunch.ts
937
+ var import_node_child_process = require("child_process");
936
938
  var import_node_crypto4 = require("crypto");
939
+ var import_node_fs3 = require("fs");
940
+ var import_node_path2 = require("path");
941
+ var import_cli_launcher = require("@omnicross/cli-launcher");
942
+ var LAUNCHABLE_CLIS = [
943
+ { id: "claude", displayName: "Claude Code", command: "claude" },
944
+ { id: "codex", displayName: "Codex CLI", command: "codex" },
945
+ { id: "gemini", displayName: "Gemini CLI", command: "gemini" },
946
+ { id: "qwen", displayName: "Qwen Code", command: "qwen" },
947
+ { id: "copilot", displayName: "GitHub Copilot CLI", command: "copilot" },
948
+ { id: "opencode", displayName: "OpenCode", command: "opencode" }
949
+ ];
950
+ var INSTALL_COMMANDS = {
951
+ claude: "npm install -g @anthropic-ai/claude-code",
952
+ codex: "npm install -g @openai/codex",
953
+ gemini: "npm install -g @google/gemini-cli",
954
+ qwen: "npm install -g @qwen-code/qwen-code",
955
+ copilot: "npm install -g @github/copilot",
956
+ opencode: "npm install -g opencode-ai"
957
+ };
958
+ var LAUNCHABLE_IDS = new Set(LAUNCHABLE_CLIS.map((c) => c.id));
959
+ function isLaunchCliId(id) {
960
+ return id !== void 0 && LAUNCHABLE_IDS.has(id);
961
+ }
962
+ function probeDefault(candidate) {
963
+ const segments = (process.env["PATH"] ?? "").split(import_node_path2.delimiter).filter(Boolean);
964
+ for (const seg of segments) {
965
+ const full = (0, import_node_path2.join)(seg, candidate);
966
+ if ((0, import_node_fs3.existsSync)(full)) return full;
967
+ }
968
+ return null;
969
+ }
970
+ function isCliInstalled(command, platform = process.platform, probe = probeDefault) {
971
+ if (platform === "win32") {
972
+ return Boolean(probe(`${command}.exe`) || probe(`${command}.cmd`) || probe(`${command}.bat`));
973
+ }
974
+ return Boolean(probe(command));
975
+ }
976
+ function detectClis(platform = process.platform, probe = probeDefault) {
977
+ return LAUNCHABLE_CLIS.map((c) => ({
978
+ id: c.id,
979
+ displayName: c.displayName,
980
+ command: c.command,
981
+ installed: isCliInstalled(c.command, platform, probe),
982
+ installable: Boolean(INSTALL_COMMANDS[c.id])
983
+ }));
984
+ }
985
+ function resolveLaunchTarget(providers, requested) {
986
+ const pick = (requested?.providerId ? providers.find((p) => p.id === requested.providerId) : void 0) ?? providers.find((p) => p.enabled !== false && firstModel(p)) ?? providers.find((p) => firstModel(p));
987
+ if (!pick) {
988
+ throw new Error("no provider with a model is configured \u2014 add one on the Providers page first");
989
+ }
990
+ const model = requested?.model || firstModel(pick);
991
+ if (!model) {
992
+ throw new Error(`provider "${pick.id}" has no models \u2014 add a model on the Providers page first`);
993
+ }
994
+ return { providerId: pick.id, model };
995
+ }
996
+ function firstModel(p) {
997
+ return p.models?.[0] ?? p.modelConfigs?.[0]?.id;
998
+ }
999
+ async function buildLaunchEnv(cli, llmConfig, target) {
1000
+ const common = {
1001
+ llmConfig,
1002
+ providerId: target.providerId,
1003
+ model: target.model,
1004
+ sessionId: `dashboard:${cli}`
1005
+ };
1006
+ switch (cli) {
1007
+ case "claude":
1008
+ return (0, import_cli_launcher.buildClaudeCliLaunchConfig)(common);
1009
+ case "codex":
1010
+ return (0, import_cli_launcher.buildCodexLaunchConfig)(common);
1011
+ case "gemini":
1012
+ return (0, import_cli_launcher.buildGeminiCliLaunchConfig)(common);
1013
+ case "qwen":
1014
+ case "copilot":
1015
+ case "opencode":
1016
+ return (0, import_cli_launcher.buildChatCliLaunchConfig)({ backendId: cli, ...common });
1017
+ }
1018
+ }
1019
+ function shq(s) {
1020
+ return `'${s.replace(/'/g, `'\\''`)}'`;
1021
+ }
1022
+ var defaultTerminalOpener = ({ cli, command, extraArgs, env, cwd, platform }) => {
1023
+ const childEnv = { ...process.env, ...env };
1024
+ if (platform === "win32") {
1025
+ const args = ["/c", "start", `"omnicross ${cli}"`];
1026
+ if (cwd) args.push("/D", `"${cwd}"`);
1027
+ args.push("cmd", "/k", command, ...extraArgs);
1028
+ (0, import_node_child_process.spawn)(process.env["ComSpec"] || "cmd.exe", args, {
1029
+ env: childEnv,
1030
+ windowsVerbatimArguments: true,
1031
+ detached: true,
1032
+ stdio: "ignore"
1033
+ }).unref();
1034
+ return;
1035
+ }
1036
+ const exportLine = Object.entries(env).map(([k, v]) => `export ${k}=${shq(v)}`).join("; ");
1037
+ const runLine = [command, ...extraArgs].map(shq).join(" ");
1038
+ const script = `${exportLine}; ${cwd ? `cd ${shq(cwd)}; ` : ""}${runLine}`;
1039
+ if (platform === "darwin") {
1040
+ const osa = `tell application "Terminal" to do script ${JSON.stringify(script)}`;
1041
+ (0, import_node_child_process.spawn)("osascript", ["-e", osa], { detached: true, stdio: "ignore" }).unref();
1042
+ return;
1043
+ }
1044
+ (0, import_node_child_process.spawn)("x-terminal-emulator", ["-e", "bash", "-lc", `${script}; exec bash`], {
1045
+ detached: true,
1046
+ stdio: "ignore"
1047
+ }).unref();
1048
+ };
1049
+ var sessions = /* @__PURE__ */ new Map();
1050
+ function errBody(message) {
1051
+ return { error: { type: "admin_api_error", message } };
1052
+ }
1053
+ var defaultCommandRunner = (command) => new Promise((resolve) => {
1054
+ (0, import_node_child_process.exec)(command, { timeout: 18e4 }, (err5, _stdout, stderr) => {
1055
+ if (err5) resolve({ ok: false, error: stderr.trim() || err5.message });
1056
+ else resolve({ ok: true });
1057
+ });
1058
+ });
1059
+ async function handleCliInstall(cli, runner = defaultCommandRunner) {
1060
+ const cmd = INSTALL_COMMANDS[cli];
1061
+ if (!cmd) {
1062
+ return { status: 400, body: errBody(`no install command for cli '${cli}' (manual install only)`) };
1063
+ }
1064
+ const result = await runner(cmd);
1065
+ if (!result.ok) {
1066
+ return { status: 500, body: errBody(result.error || "install failed") };
1067
+ }
1068
+ return { status: 200, body: { ok: true } };
1069
+ }
1070
+ function handleCliList(platform = process.platform, probe = probeDefault) {
1071
+ return { status: 200, body: { clis: detectClis(platform, probe) } };
1072
+ }
1073
+ function handleCliSessions() {
1074
+ const list = [...sessions.values()].map(({ onSessionEnd: _drop, ...rest }) => rest);
1075
+ return { status: 200, body: { sessions: list } };
1076
+ }
1077
+ function handleCliStop(id) {
1078
+ const s = sessions.get(id);
1079
+ if (!s) return { status: 404, body: errBody(`session '${id}' not found`) };
1080
+ try {
1081
+ s.onSessionEnd();
1082
+ } catch {
1083
+ }
1084
+ sessions.delete(id);
1085
+ return { status: 200, body: { ok: true } };
1086
+ }
1087
+ async function handleCliLaunch(cli, body, ctx) {
1088
+ const platform = ctx.platform ?? process.platform;
1089
+ const probe = ctx.probe ?? probeDefault;
1090
+ const meta = LAUNCHABLE_CLIS.find((c) => c.id === cli);
1091
+ if (!meta) return { status: 404, body: errBody(`unknown cli '${cli}'`) };
1092
+ if (!isCliInstalled(meta.command, platform, probe)) {
1093
+ return { status: 400, body: errBody(`"${meta.command}" is not installed (not found on PATH)`) };
1094
+ }
1095
+ let target;
1096
+ try {
1097
+ target = resolveLaunchTarget(ctx.providers, {
1098
+ providerId: typeof body["providerId"] === "string" ? body["providerId"] : void 0,
1099
+ model: typeof body["model"] === "string" ? body["model"] : void 0
1100
+ });
1101
+ } catch (err5) {
1102
+ return { status: 400, body: errBody(err5 instanceof Error ? err5.message : "no launch target") };
1103
+ }
1104
+ let launch;
1105
+ try {
1106
+ launch = await buildLaunchEnv(cli, ctx.llmConfig, target);
1107
+ } catch (err5) {
1108
+ return { status: 400, body: errBody(err5 instanceof Error ? err5.message : "failed to build launch env") };
1109
+ }
1110
+ const cwd = typeof body["cwd"] === "string" && body["cwd"].trim() ? body["cwd"].trim() : void 0;
1111
+ const opener = ctx.opener ?? defaultTerminalOpener;
1112
+ try {
1113
+ opener({ cli, command: meta.command, extraArgs: launch.extraArgs ?? [], env: launch.env, cwd, platform });
1114
+ } catch (err5) {
1115
+ launch.onSessionEnd();
1116
+ return { status: 500, body: errBody(err5 instanceof Error ? err5.message : "failed to open terminal") };
1117
+ }
1118
+ const id = (0, import_node_crypto4.randomUUID)();
1119
+ sessions.set(id, {
1120
+ id,
1121
+ cli,
1122
+ providerId: target.providerId,
1123
+ model: target.model,
1124
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
1125
+ onSessionEnd: launch.onSessionEnd
1126
+ });
1127
+ return { status: 200, body: { sessionId: id, providerId: target.providerId, model: target.model } };
1128
+ }
1129
+
1130
+ // src/ports/account-multi.ts
1131
+ var import_node_crypto5 = require("crypto");
937
1132
  var PROVIDER_KEYS = {
938
1133
  claude: { block: "claude", accounts: "claudeAccounts", active: "activeClaudeAccountId" },
939
1134
  codex: { block: "codex", accounts: "codexAccounts", active: "activeCodexAccountId" },
@@ -999,7 +1194,7 @@ function migrateLazily(config) {
999
1194
  }
1000
1195
  function addAccount(config, p, tokens, label) {
1001
1196
  const accounts = [...getAccounts(config, p)];
1002
- const id = (0, import_node_crypto4.randomUUID)();
1197
+ const id = (0, import_node_crypto5.randomUUID)();
1003
1198
  accounts.push({
1004
1199
  id,
1005
1200
  label: label ?? `Account ${accounts.length + 1}`,
@@ -1036,6 +1231,13 @@ function setActiveAccount(config, p, id) {
1036
1231
  deriveMirror(config, p);
1037
1232
  return { ok: true };
1038
1233
  }
1234
+ function listAccounts(config, p) {
1235
+ return getAccounts(config, p);
1236
+ }
1237
+ function getAccountById(config, p, id) {
1238
+ const account = getAccounts(config, p).find((a) => a.id === id);
1239
+ return account ? { id: account.id, tokens: account.tokens } : void 0;
1240
+ }
1039
1241
  function getActiveAccount(config, p) {
1040
1242
  const active = getAccounts(config, p).find((a) => a.id === getActiveId(config, p));
1041
1243
  return active ? { id: active.id, tokens: active.tokens } : void 0;
@@ -1069,12 +1271,27 @@ function sanitizeAccounts(config, p) {
1069
1271
  id: a.id,
1070
1272
  label: a.label,
1071
1273
  status: t.status ?? "unconfigured",
1274
+ authMethod: t.authMethod,
1275
+ subscriptionLevel: t.subscriptionLevel,
1072
1276
  expiresAt: t.expiresAt,
1277
+ lastRefreshedAt: t.lastRefreshedAt,
1278
+ isSetupToken: t.isSetupToken,
1073
1279
  hasAccessToken: !!(t.accessToken || t.apiKey),
1074
- isActive: a.id === activeId
1280
+ isActive: a.id === activeId,
1281
+ syncWarning: t.syncWarning
1075
1282
  };
1076
1283
  });
1077
1284
  }
1285
+ function renameAccount(config, p, id, label) {
1286
+ const accounts = getAccounts(config, p);
1287
+ if (!accounts.some((a) => a.id === id)) return { ok: false };
1288
+ setAccounts(
1289
+ config,
1290
+ p,
1291
+ accounts.map((a) => a.id === id ? { ...a, label } : a)
1292
+ );
1293
+ return { ok: true };
1294
+ }
1078
1295
  function clearProvider(config, p) {
1079
1296
  setBlock(config, p, void 0);
1080
1297
  setAccounts(config, p, void 0);
@@ -1083,7 +1300,7 @@ function clearProvider(config, p) {
1083
1300
  var DAEMON_PROVIDER_KEYS = PROVIDER_KEYS;
1084
1301
 
1085
1302
  // src/migration/packCodec.ts
1086
- var import_node_crypto5 = require("crypto");
1303
+ var import_node_crypto6 = require("crypto");
1087
1304
  var PACK_MAGIC = "OMCXPACK";
1088
1305
  var PACK_VERSION = 1;
1089
1306
  var KDF_ALGORITHM = "scrypt";
@@ -1121,17 +1338,17 @@ function fromB64Url(s) {
1121
1338
  return Buffer.from(s, "base64url").toString("utf8");
1122
1339
  }
1123
1340
  function deriveKey(passphrase, salt, N, r, p) {
1124
- return (0, import_node_crypto5.scryptSync)(passphrase, salt, KEY_BYTES3, { N, r, p, maxmem: SCRYPT_MAXMEM });
1341
+ return (0, import_node_crypto6.scryptSync)(passphrase, salt, KEY_BYTES3, { N, r, p, maxmem: SCRYPT_MAXMEM });
1125
1342
  }
1126
1343
  function aadFor(magic, version, kdf) {
1127
1344
  return Buffer.from(`${magic}|${version}|${kdf}`, "utf8");
1128
1345
  }
1129
1346
  function sealPack(bundleJson, passphrase) {
1130
1347
  assertPassphraseStrength(passphrase);
1131
- const salt = (0, import_node_crypto5.randomBytes)(SCRYPT_SALT_BYTES);
1132
- const iv = (0, import_node_crypto5.randomBytes)(IV_BYTES2);
1348
+ const salt = (0, import_node_crypto6.randomBytes)(SCRYPT_SALT_BYTES);
1349
+ const iv = (0, import_node_crypto6.randomBytes)(IV_BYTES2);
1133
1350
  const key = deriveKey(passphrase, salt, SCRYPT_N, SCRYPT_R, SCRYPT_P);
1134
- const cipher = (0, import_node_crypto5.createCipheriv)("aes-256-gcm", key, iv);
1351
+ const cipher = (0, import_node_crypto6.createCipheriv)("aes-256-gcm", key, iv);
1135
1352
  cipher.setAAD(aadFor(PACK_MAGIC, PACK_VERSION, KDF_ALGORITHM));
1136
1353
  const ciphertext = Buffer.concat([cipher.update(bundleJson, "utf8"), cipher.final()]);
1137
1354
  const tag = cipher.getAuthTag();
@@ -1179,7 +1396,7 @@ function openPack(packString, passphrase) {
1179
1396
  throw new PackAuthError("migration pack is malformed (invalid iv/tag length)");
1180
1397
  }
1181
1398
  const key = deriveKey(passphrase, salt, header.N, header.r, header.p);
1182
- const decipher = (0, import_node_crypto5.createDecipheriv)("aes-256-gcm", key, iv);
1399
+ const decipher = (0, import_node_crypto6.createDecipheriv)("aes-256-gcm", key, iv);
1183
1400
  decipher.setAAD(aadFor(PACK_MAGIC, PACK_VERSION, KDF_ALGORITHM));
1184
1401
  decipher.setAuthTag(tag);
1185
1402
  try {
@@ -1321,6 +1538,169 @@ async function handleImport(body, deps) {
1321
1538
  }
1322
1539
  }
1323
1540
 
1541
+ // src/admin/usagePricing.ts
1542
+ var err4 = (status, message) => ({
1543
+ status,
1544
+ body: { error: { type: "admin_api_error", message } }
1545
+ });
1546
+ function parseFiniteInt(raw) {
1547
+ if (raw === null || raw.trim() === "") return null;
1548
+ const n = Number(raw);
1549
+ return Number.isFinite(n) && Number.isInteger(n) ? n : null;
1550
+ }
1551
+ function parseRange(query) {
1552
+ const startTs = parseFiniteInt(query.get("startTs"));
1553
+ const endTs = parseFiniteInt(query.get("endTs"));
1554
+ if (startTs === null || endTs === null) {
1555
+ return err4(400, "startTs and endTs are required finite-integer unix-millis query params");
1556
+ }
1557
+ return { startTs, endTs };
1558
+ }
1559
+ var isRange = (v) => v.startTs !== void 0 && !("status" in v);
1560
+ async function handleUsageGet(view, query, deps) {
1561
+ const range = parseRange(query);
1562
+ if (!isRange(range)) return range;
1563
+ switch (view) {
1564
+ case "totals":
1565
+ return { status: 200, body: await deps.usageRecorder.getTotals(range) };
1566
+ case "by-model":
1567
+ return { status: 200, body: await deps.usageRecorder.getByModel(range) };
1568
+ case "by-api-key": {
1569
+ const rows = await deps.usageRecorder.getByApiKey(range);
1570
+ const labels = poolKeyLabels(loadConfig(deps.configPath));
1571
+ return {
1572
+ status: 200,
1573
+ body: rows.map((r) => {
1574
+ if (r.apiKeyId === null) {
1575
+ return { ...r, label: "unattributed", providerId: null };
1576
+ }
1577
+ const known = labels.get(r.apiKeyId);
1578
+ return known ? { ...r, label: known.label, providerId: known.providerId } : { ...r, label: r.apiKeyId };
1579
+ })
1580
+ };
1581
+ }
1582
+ default:
1583
+ return err4(404, `unknown usage view '${view ?? ""}'`);
1584
+ }
1585
+ }
1586
+ function poolKeyLabels(cfg) {
1587
+ const out = /* @__PURE__ */ new Map();
1588
+ for (const provider of cfg.providers) {
1589
+ for (const key of provider.apiKeys ?? []) {
1590
+ out.set(key.id, {
1591
+ label: key.label && key.label.length > 0 ? key.label : key.id,
1592
+ providerId: provider.id
1593
+ });
1594
+ }
1595
+ }
1596
+ return out;
1597
+ }
1598
+ var INVALID_PRICE = /* @__PURE__ */ Symbol("invalid-price");
1599
+ function parseOptionalPrice(b, key) {
1600
+ if (!(key in b) || b[key] === null || b[key] === void 0) return null;
1601
+ const v = b[key];
1602
+ return typeof v === "number" && Number.isFinite(v) ? v : INVALID_PRICE;
1603
+ }
1604
+ function parsePricingEntryInput(raw) {
1605
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
1606
+ const b = raw;
1607
+ const providerId = typeof b["providerId"] === "string" && b["providerId"].trim() ? b["providerId"].trim() : "";
1608
+ const modelId = typeof b["modelId"] === "string" && b["modelId"].trim() ? b["modelId"].trim() : "";
1609
+ const inputPrice = b["inputPricePer1m"];
1610
+ const outputPrice = b["outputPricePer1m"];
1611
+ if (!providerId || !modelId) return null;
1612
+ if (typeof inputPrice !== "number" || !Number.isFinite(inputPrice)) return null;
1613
+ if (typeof outputPrice !== "number" || !Number.isFinite(outputPrice)) return null;
1614
+ const cacheRead = parseOptionalPrice(b, "cacheReadPricePer1m");
1615
+ const cacheWrite = parseOptionalPrice(b, "cacheWritePricePer1m");
1616
+ if (cacheRead === INVALID_PRICE || cacheWrite === INVALID_PRICE) return null;
1617
+ return {
1618
+ providerId,
1619
+ modelId,
1620
+ inputPricePer1m: inputPrice,
1621
+ outputPricePer1m: outputPrice,
1622
+ cacheReadPricePer1m: cacheRead,
1623
+ cacheWritePricePer1m: cacheWrite
1624
+ };
1625
+ }
1626
+ async function handlePricingList(deps) {
1627
+ return { status: 200, body: { entries: await deps.pricingEngine.getAll() } };
1628
+ }
1629
+ async function handlePricingUpsert(body, deps) {
1630
+ const input = parsePricingEntryInput(body);
1631
+ if (!input) {
1632
+ return err4(400, "invalid pricing entry (providerId, modelId, finite numeric inputPricePer1m/outputPricePer1m required)");
1633
+ }
1634
+ const entry = await deps.pricingEngine.upsertManual(input);
1635
+ return { status: 200, body: { entry } };
1636
+ }
1637
+ async function handlePricingDelete(query, deps) {
1638
+ const providerId = query.get("providerId")?.trim() ?? "";
1639
+ const modelId = query.get("modelId")?.trim() ?? "";
1640
+ if (!providerId || !modelId) {
1641
+ return err4(400, "delete requires providerId and modelId query params");
1642
+ }
1643
+ const deleted = await deps.pricingStore.delete(providerId, modelId);
1644
+ if (deleted) await deps.pricingEngine.invalidateCache();
1645
+ return { status: 200, body: { deleted } };
1646
+ }
1647
+ async function handlePricingFetchLatest(deps) {
1648
+ try {
1649
+ const result = await deps.pricingEngine.fetchLatestFromSource();
1650
+ return {
1651
+ status: 200,
1652
+ body: {
1653
+ appliedCount: result.applied.length,
1654
+ conflicts: result.conflicts,
1655
+ fetchedAt: result.fetchedAt,
1656
+ sourceUrl: result.sourceUrl
1657
+ }
1658
+ };
1659
+ } catch (e) {
1660
+ return err4(502, `pricing-source fetch failed: ${e instanceof Error ? e.message : String(e)}`);
1661
+ }
1662
+ }
1663
+ async function handlePricingResolveConflicts(body, deps) {
1664
+ const raw = body["resolutions"];
1665
+ if (!Array.isArray(raw)) {
1666
+ return err4(400, "resolve-conflicts requires { resolutions: [...] }");
1667
+ }
1668
+ const currentRows = await deps.pricingStore.getAll();
1669
+ const userEditedKeys = new Set(
1670
+ currentRows.filter((r) => r.userEdited).map((r) => `${r.providerId}::${r.modelId}`)
1671
+ );
1672
+ const decisions = [];
1673
+ const pendingIncoming = /* @__PURE__ */ new Map();
1674
+ let staleCount = 0;
1675
+ for (const item of raw) {
1676
+ if (!item || typeof item !== "object") return err4(400, "invalid resolution entry");
1677
+ const r = item;
1678
+ const action = r["action"];
1679
+ if (action !== "overwrite" && action !== "skip") {
1680
+ return err4(400, "resolution action must be 'overwrite' or 'skip'");
1681
+ }
1682
+ const providerId = typeof r["providerId"] === "string" && r["providerId"].trim() ? r["providerId"].trim() : "";
1683
+ const modelId = typeof r["modelId"] === "string" && r["modelId"].trim() ? r["modelId"].trim() : "";
1684
+ if (!providerId || !modelId) {
1685
+ return err4(400, "each resolution requires top-level providerId and modelId");
1686
+ }
1687
+ const incoming = parsePricingEntryInput(r["incoming"]);
1688
+ if (!incoming) return err4(400, "each resolution must echo a valid incoming pricing entry");
1689
+ if (incoming.providerId !== providerId || incoming.modelId !== modelId) {
1690
+ return err4(400, "resolution providerId/modelId must match the echoed incoming entry");
1691
+ }
1692
+ const key = `${providerId}::${modelId}`;
1693
+ if (action === "overwrite" && !userEditedKeys.has(key)) {
1694
+ staleCount += 1;
1695
+ continue;
1696
+ }
1697
+ decisions.push({ providerId, modelId, action });
1698
+ pendingIncoming.set(key, incoming);
1699
+ }
1700
+ const resolution = await deps.pricingEngine.resolveConflicts(decisions, pendingIncoming);
1701
+ return { status: 200, body: { ...resolution, staleCount } };
1702
+ }
1703
+
1324
1704
  // src/admin/adminApi.ts
1325
1705
  function readBody(req) {
1326
1706
  return new Promise((resolve, reject) => {
@@ -1411,9 +1791,9 @@ function toProviderView(row) {
1411
1791
  selectedApiModeId: row.selectedApiModeId
1412
1792
  };
1413
1793
  }
1414
- async function handleAdminApi(req, res, path, deps) {
1794
+ async function handleAdminApi(req, res, path2, deps) {
1415
1795
  const method = (req.method ?? "GET").toUpperCase();
1416
- const sub = path.slice("/admin/api/".length);
1796
+ const sub = path2.slice("/admin/api/".length);
1417
1797
  const [resource, ...rest] = sub.split("/").filter((s) => s.length > 0);
1418
1798
  try {
1419
1799
  switch (resource) {
@@ -1427,6 +1807,8 @@ async function handleAdminApi(req, res, path, deps) {
1427
1807
  return await handleServer(req, res, method, deps);
1428
1808
  case "accounts":
1429
1809
  return await handleAccounts(req, res, method, rest, deps);
1810
+ case "cli":
1811
+ return await handleCli(req, res, method, rest, deps);
1430
1812
  case "status":
1431
1813
  return await handleStatus(res, method, deps);
1432
1814
  case "playground":
@@ -1435,12 +1817,47 @@ async function handleAdminApi(req, res, path, deps) {
1435
1817
  return await handleMigrationExport(req, res, method, deps);
1436
1818
  case "import":
1437
1819
  return await handleMigrationImport(req, res, method, deps);
1820
+ case "usage":
1821
+ return await handleUsage(req, res, method, rest, deps);
1822
+ case "pricing":
1823
+ return await handlePricing(req, res, method, rest, deps);
1438
1824
  default:
1439
1825
  return writeJsonError(res, 404, `unknown admin resource '${resource}'`);
1440
1826
  }
1441
- } catch (err4) {
1442
- writeJsonError(res, 500, err4 instanceof Error ? err4.message : String(err4));
1827
+ } catch (err5) {
1828
+ writeJsonError(res, 500, err5 instanceof Error ? err5.message : String(err5));
1829
+ }
1830
+ }
1831
+ function requestQuery(req) {
1832
+ const raw = req.url ?? "";
1833
+ const qIdx = raw.indexOf("?");
1834
+ return new URLSearchParams(qIdx >= 0 ? raw.slice(qIdx + 1) : "");
1835
+ }
1836
+ function writeResult(res, result) {
1837
+ writeJson(res, result.status, result.body);
1838
+ }
1839
+ async function handleUsage(req, res, method, rest, deps) {
1840
+ if (method !== "GET") return writeJsonError(res, 405, `method ${method} not allowed on usage`);
1841
+ return writeResult(res, await handleUsageGet(rest[0], requestQuery(req), deps));
1842
+ }
1843
+ async function handlePricing(req, res, method, rest, deps) {
1844
+ if (rest.length === 0) {
1845
+ if (method === "GET") return writeResult(res, await handlePricingList(deps));
1846
+ if (method === "PUT") {
1847
+ return writeResult(res, await handlePricingUpsert(await readJsonBody(req), deps));
1848
+ }
1849
+ if (method === "DELETE") {
1850
+ return writeResult(res, await handlePricingDelete(requestQuery(req), deps));
1851
+ }
1852
+ return writeJsonError(res, 405, `method ${method} not allowed on pricing`);
1853
+ }
1854
+ if (method === "POST" && rest.length === 1 && rest[0] === "fetch-latest") {
1855
+ return writeResult(res, await handlePricingFetchLatest(deps));
1443
1856
  }
1857
+ if (method === "POST" && rest.length === 1 && rest[0] === "resolve-conflicts") {
1858
+ return writeResult(res, await handlePricingResolveConflicts(await readJsonBody(req), deps));
1859
+ }
1860
+ return writeJsonError(res, 404, `unknown pricing route '${rest.join("/")}'`);
1444
1861
  }
1445
1862
  function migrationDeps(deps) {
1446
1863
  return {
@@ -1488,6 +1905,11 @@ async function handleProviders(req, res, method, rest, deps) {
1488
1905
  if (method === "POST" && rest.length === 2 && rest[1] === "test") {
1489
1906
  return await handleTestModel(req, res, rest[0], cfg);
1490
1907
  }
1908
+ if (method === "GET" && rest.length === 2 && rest[1] === "reveal-key") {
1909
+ const row = cfg.providers.find((p) => p.id === rest[0]);
1910
+ if (!row) return writeJsonError(res, 404, `provider '${rest[0]}' not found`);
1911
+ return writeJson(res, 200, { apiKey: row.apiKey ?? "" });
1912
+ }
1491
1913
  if (method === "GET") {
1492
1914
  return writeJson(res, 200, { providers: cfg.providers.map(toProviderView) });
1493
1915
  }
@@ -1583,8 +2005,8 @@ async function handleDiscoverModels(res, id, cfg) {
1583
2005
  const data = await response.json();
1584
2006
  const models = Array.isArray(data?.data) ? data.data.map((m) => typeof m?.id === "string" ? m.id : "").filter((m) => m.length > 0) : [];
1585
2007
  return writeJson(res, 200, { models });
1586
- } catch (err4) {
1587
- const message = err4 instanceof Error ? err4.message : String(err4);
2008
+ } catch (err5) {
2009
+ const message = err5 instanceof Error ? err5.message : String(err5);
1588
2010
  return writeJson(res, 200, { models: [], error: `discovery failed: ${message}` });
1589
2011
  }
1590
2012
  }
@@ -1643,8 +2065,8 @@ async function handleTestModel(req, res, id, cfg) {
1643
2065
  latencyMs,
1644
2066
  sample: extractSampleText(text, row.apiFormat)
1645
2067
  });
1646
- } catch (err4) {
1647
- const message = err4 instanceof Error ? err4.message : String(err4);
2068
+ } catch (err5) {
2069
+ const message = err5 instanceof Error ? err5.message : String(err5);
1648
2070
  return writeJson(res, 200, { ok: false, latencyMs: Date.now() - startedAt, message });
1649
2071
  }
1650
2072
  }
@@ -1993,7 +2415,8 @@ async function handleAccounts(req, res, method, rest, deps) {
1993
2415
  if (method === "GET" && rest.length === 0) {
1994
2416
  const accounts = await deps.subscriptionAccounts.listAll();
1995
2417
  const providerAccounts = await deps.subscriptionTokenWriter.listSanitizedAccounts();
1996
- return writeJson(res, 200, { accounts, providerAccounts });
2418
+ const externalCli = await deps.subscriptionTokenWriter.listExternalCliAvailability();
2419
+ return writeJson(res, 200, { accounts, providerAccounts, externalCli });
1997
2420
  }
1998
2421
  if (method === "GET" && rest[0] === "codex" && rest[1] === "oauth" && rest[3] === "status") {
1999
2422
  const result = handleCodexOAuthStatus(rest[2], deps);
@@ -2013,6 +2436,47 @@ async function handleAccounts(req, res, method, rest, deps) {
2013
2436
  const result = await handleOAuthComplete(providerId, body2, deps);
2014
2437
  return writeJson(res, result.status, result.body);
2015
2438
  }
2439
+ if (method === "POST" && rest[1] === "accounts") {
2440
+ const body2 = await readJsonBody(req);
2441
+ const block = validateTokenBody(providerId, body2);
2442
+ if (!block) {
2443
+ return writeJsonError(res, 400, `malformed token body for provider '${providerId}'`);
2444
+ }
2445
+ const label = typeof body2["label"] === "string" && body2["label"].trim() ? body2["label"].trim() : void 0;
2446
+ await deps.subscriptionAccountAppender.appendProviderAccount(providerId, block, label);
2447
+ const status2 = await statusEntryFor(deps.subscriptionAccounts, providerId);
2448
+ return writeJson(res, 200, status2 ? { account: status2 } : { ok: true });
2449
+ }
2450
+ if (method === "POST" && rest[1] === "import-external") {
2451
+ if (providerId !== "claude" && providerId !== "codex") {
2452
+ return writeJsonError(res, 400, `provider '${providerId}' has no external CLI store`);
2453
+ }
2454
+ const body2 = await readJsonBody(req);
2455
+ const label = typeof body2["label"] === "string" && body2["label"].trim() ? body2["label"].trim() : void 0;
2456
+ const result = await deps.subscriptionTokenWriter.importExternalCliAccount(providerId, label);
2457
+ if (!result.ok) {
2458
+ return writeJsonError(res, 409, `no usable external ${providerId} CLI credential found`);
2459
+ }
2460
+ const status2 = await statusEntryFor(deps.subscriptionAccounts, providerId);
2461
+ return writeJson(res, 200, { ok: true, account: status2 ?? void 0 });
2462
+ }
2463
+ if (method === "POST" && rest[1] === "refresh") {
2464
+ if (providerId === "opencodego") {
2465
+ return writeJsonError(res, 400, "opencodego credentials are not refreshable");
2466
+ }
2467
+ const writer = deps.subscriptionTokenWriter;
2468
+ const ok = providerId === "claude" ? await writer.refreshClaudeToken() : providerId === "codex" ? await writer.refreshCodexToken() : await writer.refreshGeminiToken();
2469
+ const status2 = await statusEntryFor(deps.subscriptionAccounts, providerId);
2470
+ return writeJson(res, 200, { ok, account: status2 ?? void 0 });
2471
+ }
2472
+ if (method === "POST" && rest[2] === "label") {
2473
+ const accountId = rest[1];
2474
+ const body2 = await readJsonBody(req);
2475
+ const label = typeof body2["label"] === "string" ? body2["label"].trim() : "";
2476
+ const result = await deps.subscriptionTokenWriter.renameAccount(providerId, accountId, label);
2477
+ if (!result.ok) return writeJsonError(res, 404, `account '${accountId}' not found`);
2478
+ return writeJson(res, 200, { ok: true });
2479
+ }
2016
2480
  if (method === "PUT" && rest[1] === "active") {
2017
2481
  const body2 = await readJsonBody(req);
2018
2482
  const id = typeof body2["id"] === "string" ? body2["id"] : "";
@@ -2042,6 +2506,44 @@ async function handleAccounts(req, res, method, rest, deps) {
2042
2506
  }
2043
2507
  return writeJsonError(res, 405, `method ${method} not allowed on accounts`);
2044
2508
  }
2509
+ async function handleCli(req, res, method, rest, deps) {
2510
+ if (method === "GET" && rest.length === 0) {
2511
+ const result = handleCliList(process.platform, deps.cliPathProbe);
2512
+ return writeJson(res, result.status, result.body);
2513
+ }
2514
+ if (method === "GET" && rest[0] === "sessions") {
2515
+ const result = handleCliSessions();
2516
+ return writeJson(res, result.status, result.body);
2517
+ }
2518
+ if (method === "DELETE" && rest[0] === "sessions" && rest[1]) {
2519
+ const result = handleCliStop(rest[1]);
2520
+ return writeJson(res, result.status, result.body);
2521
+ }
2522
+ if (method === "POST" && rest[1] === "install") {
2523
+ const cli = rest[0];
2524
+ if (!isLaunchCliId(cli)) {
2525
+ return writeJsonError(res, 400, `unknown cli '${cli ?? ""}'`);
2526
+ }
2527
+ const result = await handleCliInstall(cli, deps.cliCommandRunner);
2528
+ return writeJson(res, result.status, result.body);
2529
+ }
2530
+ if (method === "POST" && rest[1] === "launch") {
2531
+ const cli = rest[0];
2532
+ if (!isLaunchCliId(cli)) {
2533
+ return writeJsonError(res, 400, `unknown cli '${cli ?? ""}'`);
2534
+ }
2535
+ const body = await readJsonBody(req);
2536
+ const providers = loadConfig(deps.configPath).providers ?? [];
2537
+ const result = await handleCliLaunch(cli, body, {
2538
+ llmConfig: deps.llmConfig,
2539
+ providers,
2540
+ opener: deps.cliTerminalOpener,
2541
+ probe: deps.cliPathProbe
2542
+ });
2543
+ return writeJson(res, result.status, result.body);
2544
+ }
2545
+ return writeJsonError(res, 405, `method ${method} not allowed on cli`);
2546
+ }
2045
2547
  async function handleStatus(res, method, deps) {
2046
2548
  if (method !== "GET") return writeJsonError(res, 405, `method ${method} not allowed on status`);
2047
2549
  const status = deps.outboundApiServer.getStatus();
@@ -2077,21 +2579,21 @@ async function handlePlayground(req, res, method, deps) {
2077
2579
  const payload = body["body"];
2078
2580
  const status = deps.outboundApiServer.getStatus();
2079
2581
  if (!status.running || !status.port) return writeJsonError(res, 503, "outbound server not running");
2080
- const path = resolvePlaygroundPath(endpoint, isRecord(payload) ? payload : {});
2081
- if (!path) return writeJsonError(res, 400, `unknown endpoint '${endpoint}'`);
2582
+ const path2 = resolvePlaygroundPath(endpoint, isRecord(payload) ? payload : {});
2583
+ if (!path2) return writeJsonError(res, 400, `unknown endpoint '${endpoint}'`);
2082
2584
  const upstreamBody = typeof payload === "string" ? payload : JSON.stringify(payload ?? {});
2083
- await proxyToOutbound(res, status.port, path, key, upstreamBody);
2585
+ await proxyToOutbound(res, status.port, path2, key, upstreamBody);
2084
2586
  }
2085
2587
  function isRecord(v) {
2086
2588
  return !!v && typeof v === "object" && !Array.isArray(v);
2087
2589
  }
2088
- function proxyToOutbound(res, outboundPort, path, key, body) {
2590
+ function proxyToOutbound(res, outboundPort, path2, key, body) {
2089
2591
  return new Promise((resolve) => {
2090
2592
  const upstream = import_node_http.default.request(
2091
2593
  {
2092
2594
  host: "127.0.0.1",
2093
2595
  port: outboundPort,
2094
- path,
2596
+ path: path2,
2095
2597
  method: "POST",
2096
2598
  headers: {
2097
2599
  "Content-Type": "application/json",
@@ -2111,8 +2613,8 @@ function proxyToOutbound(res, outboundPort, path, key, body) {
2111
2613
  });
2112
2614
  }
2113
2615
  );
2114
- upstream.on("error", (err4) => {
2115
- if (!res.headersSent) writeJsonError(res, 502, `playground proxy failed: ${err4.message}`);
2616
+ upstream.on("error", (err5) => {
2617
+ if (!res.headersSent) writeJsonError(res, 502, `playground proxy failed: ${err5.message}`);
2116
2618
  else res.end();
2117
2619
  resolve();
2118
2620
  });
@@ -2121,463 +2623,106 @@ function proxyToOutbound(res, outboundPort, path, key, body) {
2121
2623
  });
2122
2624
  }
2123
2625
 
2124
- // src/admin/client.ts
2125
- var DASHBOARD_JS = String.raw`
2126
- (function () {
2127
- var $ = function (id) { return document.getElementById(id); };
2128
- var authToken = null; // set if a 401 ever comes back (token-gated deploys)
2129
-
2130
- function headers(extra) {
2131
- var h = extra || {};
2132
- if (authToken) h['Authorization'] = 'Bearer ' + authToken;
2133
- return h;
2134
- }
2135
-
2136
- async function api(method, path, body) {
2137
- var opt = { method: method, headers: headers(body ? { 'Content-Type': 'application/json' } : {}) };
2138
- if (body) opt.body = JSON.stringify(body);
2139
- var res = await fetch('/admin/api/' + path, opt);
2140
- if (res.status === 401 && !authToken) {
2141
- var t = window.prompt('Admin token required');
2142
- if (t) { authToken = t; return api(method, path, body); }
2143
- }
2144
- var text = await res.text();
2145
- var json = null;
2146
- try { json = text ? JSON.parse(text) : null; } catch (e) { json = { raw: text }; }
2147
- return { status: res.status, json: json };
2148
- }
2149
-
2150
- function clear(el) { while (el.firstChild) el.removeChild(el.firstChild); }
2151
- function td(text) { var c = document.createElement('td'); c.textContent = text == null ? '' : String(text); return c; }
2152
- function btn(label, cls, fn) { var b = document.createElement('button'); b.textContent = label; if (cls) b.className = cls; b.onclick = fn; return b; }
2153
-
2154
- // ── Status ────────────────────────────────────────────────────────────────
2155
- async function loadStatus() {
2156
- var r = await api('GET', 'status');
2157
- var s = r.json || {};
2158
- $('statusBadge').textContent = s.running ? ('running :' + s.port) : 'stopped';
2159
- var html = '';
2160
- if (s.running) {
2161
- html += 'Outbound server <span class="pill ok">running</span> on port ' + s.port + '<br/>';
2162
- if (s.formats) {
2163
- html += '<div class="mono muted">' +
2164
- 'chat: ' + s.formats.chat + '<br/>responses: ' + s.formats.responses +
2165
- '<br/>messages: ' + s.formats.messages + '<br/>gemini: ' + s.formats.gemini + '</div>';
2166
- }
2167
- } else {
2168
- html += 'Outbound server <span class="pill bad">stopped</span>';
2169
- }
2170
- $('statusBody').innerHTML = html;
2171
- }
2172
-
2173
- // ── Providers ───────────────────────────────────────────────────────────────
2174
- // Curated presets loaded from GET /admin/api/presets. Selecting one prefills
2175
- // the add-form (format/base/models); the WRITE still goes through the existing
2176
- // POST/PUT /admin/api/providers path (no new write endpoint).
2177
- var presetsById = {};
2178
- var presetModels = []; // models staged by the last preset prefill
2179
-
2180
- async function loadPresets() {
2181
- var r = await api('GET', 'presets');
2182
- var sel = $('pPreset');
2183
- // Keep the placeholder; drop any previously appended options.
2184
- while (sel.options.length > 1) sel.remove(1);
2185
- presetsById = {};
2186
- (r.json && r.json.presets || []).forEach(function (p) {
2187
- presetsById[p.id] = p;
2188
- var opt = document.createElement('option');
2189
- opt.value = p.id;
2190
- opt.textContent = p.name + ' (' + p.apiFormat + ')';
2191
- sel.appendChild(opt);
2192
- });
2193
- }
2194
-
2195
- function onPresetChange() {
2196
- var p = presetsById[$('pPreset').value];
2197
- if (!p) { presetModels = []; return; }
2198
- if (!$('pId').value.trim()) $('pId').value = p.id;
2199
- $('pFormat').value = p.apiFormat;
2200
- $('pBase').value = p.baseUrl;
2201
- presetModels = Array.isArray(p.models) ? p.models.slice() : [];
2202
- }
2203
-
2204
- async function loadProviders() {
2205
- var r = await api('GET', 'providers');
2206
- var body = $('providersTable').querySelector('tbody');
2207
- clear(body);
2208
- (r.json && r.json.providers || []).forEach(function (p) {
2209
- var tr = document.createElement('tr');
2210
- tr.appendChild(td(p.id));
2211
- tr.appendChild(td(p.apiFormat));
2212
- tr.appendChild(td(p.baseUrl));
2213
- tr.appendChild(td(p.hasApiKey ? p.apiKeyMasked : '(none)'));
2214
- var act = document.createElement('td');
2215
- act.appendChild(btn('Edit', 'secondary', function () {
2216
- $('pId').value = p.id; $('pFormat').value = p.apiFormat; $('pBase').value = p.baseUrl; $('pKey').value = '';
2217
- }));
2218
- act.appendChild(btn('Pool', 'secondary', function () { loadProviderKeys(p.id); }));
2219
- act.appendChild(btn('Delete', 'danger', async function () {
2220
- if (window.confirm('Delete provider ' + p.id + '?')) { await api('DELETE', 'providers/' + encodeURIComponent(p.id)); loadProviders(); }
2221
- }));
2222
- tr.appendChild(act);
2223
- body.appendChild(tr);
2224
- });
2225
- }
2226
-
2227
- // ── Pool health (read-only; key-pool change) ──────────────────────────────
2228
- // GET /admin/api/providers/:id/keys → masked pool view. Multi-key is
2229
- // cold-standby + observable in v1 (no outbound failover yet — see panel note).
2230
- async function loadProviderKeys(providerId) {
2231
- var r = await api('GET', 'providers/' + encodeURIComponent(providerId) + '/keys');
2232
- var panel = $('poolPanel');
2233
- var body = $('poolTable').querySelector('tbody');
2234
- clear(body);
2235
- $('poolTitle').textContent = 'API key pool — ' + providerId;
2236
- (r.json && r.json.keys || []).forEach(function (k) {
2237
- var tr = document.createElement('tr');
2238
- tr.appendChild(td(k.id));
2239
- tr.appendChild(td(k.label));
2240
- tr.appendChild(td(k.apiKeyMasked));
2241
- tr.appendChild(td(k.enabled ? 'yes' : 'no'));
2242
- tr.appendChild(td(k.weight));
2243
- var h = '';
2244
- if (k.health && k.health.autoDisabled) h += 'auto-disabled (' + k.health.autoDisabled.status + ') ';
2245
- if (k.health && k.health.cooldown) h += 'cooldown until ' + new Date(k.health.cooldown.until).toLocaleTimeString();
2246
- tr.appendChild(td(h || 'ok'));
2247
- body.appendChild(tr);
2248
- });
2249
- panel.style.display = 'block';
2250
- }
2251
-
2252
- async function saveProvider() {
2253
- $('pErr').textContent = '';
2254
- var id = $('pId').value.trim();
2255
- if (!id) { $('pErr').textContent = 'id required'; return; }
2256
- var payload = { id: id, apiFormat: $('pFormat').value, baseUrl: $('pBase').value.trim(), apiKey: $('pKey').value };
2257
- // Carry the preset-prefilled models (existing parseProviderInput accepts them).
2258
- if (presetModels.length) payload.models = presetModels;
2259
- // Try PUT first (edit, blank key keeps existing); fall back to POST (create).
2260
- var r = await api('PUT', 'providers/' + encodeURIComponent(id), payload);
2261
- if (r.status === 404) r = await api('POST', 'providers', payload);
2262
- if (r.status >= 400) { $('pErr').textContent = (r.json && r.json.error && r.json.error.message) || ('error ' + r.status); return; }
2263
- $('pId').value = ''; $('pBase').value = ''; $('pKey').value = '';
2264
- $('pPreset').value = ''; presetModels = []; // reset the picker after a write
2265
- loadProviders();
2266
- }
2267
-
2268
- // ── Keys ────────────────────────────────────────────────────────────────────
2269
- async function loadKeys() {
2270
- var r = await api('GET', 'keys');
2271
- var body = $('keysTable').querySelector('tbody');
2272
- clear(body);
2273
- (r.json && r.json.keys || []).forEach(function (k) {
2274
- var tr = document.createElement('tr');
2275
- tr.appendChild(td(k.name));
2276
- tr.appendChild(td(k.keyPrefix));
2277
- tr.appendChild(td(k.enabled ? 'yes' : 'no'));
2278
- tr.appendChild(td(k.revoked ? 'yes' : 'no'));
2279
- var act = document.createElement('td');
2280
- if (!k.revoked) {
2281
- act.appendChild(btn(k.enabled ? 'Disable' : 'Enable', 'secondary', async function () {
2282
- await api('POST', 'keys/' + encodeURIComponent(k.id) + '/enabled', { enabled: !k.enabled }); loadKeys();
2283
- }));
2284
- act.appendChild(btn('Revoke', 'danger', async function () {
2285
- if (window.confirm('Revoke ' + k.name + '?')) { await api('POST', 'keys/' + encodeURIComponent(k.id) + '/revoke'); loadKeys(); }
2286
- }));
2287
- }
2288
- tr.appendChild(act);
2289
- body.appendChild(tr);
2290
- });
2291
- }
2292
-
2293
- function showKeyModal(plaintext) {
2294
- $('keyPlaintext').textContent = plaintext;
2295
- $('keyModalBg').classList.add('show');
2296
- $('keyCopy').onclick = function () { navigator.clipboard && navigator.clipboard.writeText(plaintext); };
2297
- $('keyClose').onclick = function () {
2298
- $('keyModalBg').classList.remove('show');
2299
- $('keyPlaintext').textContent = ''; // never persist the plaintext
2300
- };
2301
- }
2302
-
2303
- async function createKey() {
2304
- var name = $('kName').value.trim() || 'key';
2305
- var r = await api('POST', 'keys', { name: name });
2306
- if (r.json && r.json.plaintextOnce) { showKeyModal(r.json.plaintextOnce); $('kName').value = ''; loadKeys(); }
2307
- }
2308
-
2309
- // ── Server config ───────────────────────────────────────────────────────────
2310
- async function loadServer() {
2311
- var r = await api('GET', 'server');
2312
- var s = (r.json && r.json.server) || {};
2313
- $('sEnabled').checked = !!s.enabled;
2314
- $('sLan').checked = !!s.networkBinding;
2315
- $('sPort').value = s.port || '';
2316
- var body = $('endpointsTable').querySelector('tbody');
2317
- clear(body);
2318
- (s.endpoints || []).forEach(function (e) {
2319
- var tr = document.createElement('tr');
2320
- tr.appendChild(td(e.endpoint));
2321
- tr.appendChild(td(e.defaultModel));
2322
- tr.appendChild(td(e.useSubscription ? 'yes' : 'no'));
2323
- body.appendChild(tr);
2324
- });
2626
+ // src/admin/uiStatic.ts
2627
+ var import_node_fs4 = require("fs");
2628
+ var import_promises = require("fs/promises");
2629
+ var import_node_module = require("module");
2630
+ var import_node_path3 = __toESM(require("path"), 1);
2631
+ var import_meta = {};
2632
+ var CONTENT_TYPES = {
2633
+ ".html": "text/html; charset=utf-8",
2634
+ ".js": "text/javascript; charset=utf-8",
2635
+ ".mjs": "text/javascript; charset=utf-8",
2636
+ ".css": "text/css; charset=utf-8",
2637
+ ".json": "application/json; charset=utf-8",
2638
+ ".svg": "image/svg+xml",
2639
+ ".png": "image/png",
2640
+ ".ico": "image/x-icon",
2641
+ ".webp": "image/webp",
2642
+ ".woff": "font/woff",
2643
+ ".woff2": "font/woff2",
2644
+ ".ttf": "font/ttf",
2645
+ ".map": "application/json; charset=utf-8",
2646
+ ".txt": "text/plain; charset=utf-8"
2647
+ };
2648
+ function resolveUiDist() {
2649
+ const fromEnv = process.env["OMNICROSS_UI_DIST"];
2650
+ if (fromEnv) {
2651
+ return (0, import_node_fs4.existsSync)(import_node_path3.default.join(fromEnv, "index.html")) ? import_node_path3.default.resolve(fromEnv) : null;
2325
2652
  }
2326
-
2327
- async function saveServer() {
2328
- var patch = { enabled: $('sEnabled').checked, networkBinding: $('sLan').checked };
2329
- var port = parseInt($('sPort').value, 10);
2330
- if (port) patch.port = port;
2331
- await api('PUT', 'server', patch);
2332
- loadServer(); loadStatus();
2653
+ try {
2654
+ const req = (0, import_node_module.createRequire)(typeof __filename !== "undefined" ? __filename : import_meta.url);
2655
+ const pkgJson = req.resolve("@omnicross/ui/package.json");
2656
+ const dist = import_node_path3.default.join(import_node_path3.default.dirname(pkgJson), "dist");
2657
+ return (0, import_node_fs4.existsSync)(import_node_path3.default.join(dist, "index.html")) ? dist : null;
2658
+ } catch {
2659
+ return null;
2333
2660
  }
2334
-
2335
- // ── Accounts ────────────────────────────────────────────────────────────────
2336
- // The GET stays token-free (status only). The Save/Clear actions WRITE tokens
2337
- // (secret IN); the form never renders an existing/stored token (write-only).
2338
- async function loadAccounts() {
2339
- var r = await api('GET', 'accounts');
2340
- var body = $('accountsTable').querySelector('tbody');
2341
- clear(body);
2342
- (r.json && r.json.accounts || []).forEach(function (a) {
2343
- var tr = document.createElement('tr');
2344
- tr.appendChild(td(a.displayName || a.providerId));
2345
- tr.appendChild(td(a.kind));
2346
- var st = document.createElement('td');
2347
- var ok = a.credentialStatus && a.credentialStatus.ok;
2348
- var pill = document.createElement('span');
2349
- pill.className = 'pill ' + (ok ? 'ok' : 'bad');
2350
- pill.textContent = ok ? 'ok' : ((a.credentialStatus && a.credentialStatus.reason) || 'no credential');
2351
- st.appendChild(pill);
2352
- tr.appendChild(st);
2353
- var act = document.createElement('td');
2354
- act.appendChild(btn('Clear', 'danger', async function () {
2355
- if (window.confirm('Clear ' + a.providerId + ' token?')) {
2356
- await api('DELETE', 'accounts/' + encodeURIComponent(a.providerId));
2357
- loadAccounts();
2358
- }
2359
- }));
2360
- tr.appendChild(act);
2361
- body.appendChild(tr);
2362
- });
2363
- renderProviderAccounts(r.json && r.json.providerAccounts || {});
2661
+ }
2662
+ async function handleUiStatic(req, res, urlPath, uiDist) {
2663
+ if (urlPath !== "/ui" && !urlPath.startsWith("/ui/")) return false;
2664
+ if (req.method !== "GET" && req.method !== "HEAD") {
2665
+ res.writeHead(405, { "Content-Type": "application/json" });
2666
+ res.end(JSON.stringify({ error: { type: "method_not_allowed", message: "GET/HEAD only" } }));
2667
+ return true;
2364
2668
  }
2365
-
2366
- // Per-provider sanitized accounts (multi-account). Secrets IN-never-OUT: this
2367
- // view shows id/label/status/active only — set-active + delete are STATUS-ONLY.
2368
- function renderProviderAccounts(byProvider) {
2369
- var body = $('providerAccountsTable').querySelector('tbody');
2370
- clear(body);
2371
- Object.keys(byProvider).forEach(function (provider) {
2372
- (byProvider[provider] || []).forEach(function (acc) {
2373
- var tr = document.createElement('tr');
2374
- tr.appendChild(td(provider));
2375
- tr.appendChild(td(acc.label || acc.id));
2376
- tr.appendChild(td(acc.status));
2377
- tr.appendChild(td(acc.isActive ? 'yes' : ''));
2378
- var act = document.createElement('td');
2379
- if (!acc.isActive) {
2380
- act.appendChild(btn('Set active', '', async function () {
2381
- await api('PUT', 'accounts/' + encodeURIComponent(provider) + '/active', { id: acc.id });
2382
- loadAccounts();
2383
- }));
2669
+ if (!uiDist) {
2670
+ res.writeHead(404, { "Content-Type": "application/json" });
2671
+ res.end(
2672
+ JSON.stringify({
2673
+ error: {
2674
+ type: "ui_not_installed",
2675
+ message: "Control Panel UI not installed (@omnicross/ui has no built dist). Install/build @omnicross/ui or set OMNICROSS_UI_DIST."
2384
2676
  }
2385
- act.appendChild(btn('Delete', 'danger', async function () {
2386
- if (window.confirm('Delete account ' + (acc.label || acc.id) + '?')) {
2387
- await api('DELETE', 'accounts/' + encodeURIComponent(provider) + '/' + encodeURIComponent(acc.id));
2388
- loadAccounts();
2389
- }
2390
- }));
2391
- tr.appendChild(act);
2392
- body.appendChild(tr);
2393
- });
2394
- });
2395
- }
2396
-
2397
- async function saveAccount() {
2398
- $('acErr').textContent = '';
2399
- var provider = $('acProvider').value;
2400
- var raw = $('acBody').value.trim();
2401
- if (!raw) { $('acErr').textContent = 'paste a token JSON'; return; }
2402
- var payload; try { payload = JSON.parse(raw); } catch (e) { $('acErr').textContent = 'invalid JSON'; return; }
2403
- var r = await api('PUT', 'accounts/' + encodeURIComponent(provider), payload);
2404
- if (r.status >= 400) { $('acErr').textContent = (r.json && r.json.error && r.json.error.message) || ('error ' + r.status); return; }
2405
- $('acBody').value = ''; // never persist/echo the just-saved token
2406
- loadAccounts();
2407
- }
2408
-
2409
- // ── Playground ──────────────────────────────────────────────────────────────
2410
- async function sendPlayground() {
2411
- var pre = $('plResponse');
2412
- pre.classList.remove('muted');
2413
- pre.textContent = 'sending…';
2414
- var bodyText = $('plBody').value;
2415
- var parsed; try { parsed = JSON.parse(bodyText); } catch (e) { parsed = bodyText; }
2416
- var r = await fetch('/admin/api/playground', {
2417
- method: 'POST',
2418
- headers: headers({ 'Content-Type': 'application/json' }),
2419
- body: JSON.stringify({ endpoint: $('plEndpoint').value, key: $('plKey').value, body: parsed }),
2420
- });
2421
- var text = await r.text();
2422
- pre.textContent = '[' + r.status + ']\n' + text;
2677
+ })
2678
+ );
2679
+ return true;
2423
2680
  }
2424
-
2425
- function wire() {
2426
- $('pSave').onclick = saveProvider;
2427
- $('pPreset').onchange = onPresetChange;
2428
- $('kCreate').onclick = createKey;
2429
- $('sSave').onclick = saveServer;
2430
- $('acSave').onclick = saveAccount;
2431
- $('plSend').onclick = sendPlayground;
2432
- refresh();
2681
+ if (urlPath === "/ui") {
2682
+ res.writeHead(302, { Location: "/ui/" });
2683
+ res.end();
2684
+ return true;
2433
2685
  }
2434
-
2435
- function refresh() {
2436
- loadStatus(); loadProviders(); loadPresets(); loadKeys(); loadServer(); loadAccounts();
2686
+ let rel;
2687
+ try {
2688
+ rel = decodeURIComponent(urlPath.slice("/ui/".length));
2689
+ } catch {
2690
+ res.writeHead(400, { "Content-Type": "application/json" });
2691
+ res.end(JSON.stringify({ error: { type: "bad_request", message: "malformed path" } }));
2692
+ return true;
2693
+ }
2694
+ if (rel.includes("\\") || rel.includes("\0")) {
2695
+ res.writeHead(400, { "Content-Type": "application/json" });
2696
+ res.end(JSON.stringify({ error: { type: "bad_request", message: "invalid path" } }));
2697
+ return true;
2698
+ }
2699
+ const filePath = import_node_path3.default.resolve(uiDist, rel === "" ? "index.html" : rel);
2700
+ if (filePath !== uiDist && !filePath.startsWith(uiDist + import_node_path3.default.sep)) {
2701
+ res.writeHead(403, { "Content-Type": "application/json" });
2702
+ res.end(JSON.stringify({ error: { type: "forbidden", message: "path outside ui root" } }));
2703
+ return true;
2704
+ }
2705
+ let target = filePath;
2706
+ if (!(0, import_node_fs4.existsSync)(target) || (0, import_node_fs4.statSync)(target).isDirectory()) {
2707
+ if (import_node_path3.default.extname(rel) === "") {
2708
+ target = import_node_path3.default.join(uiDist, "index.html");
2709
+ } else {
2710
+ res.writeHead(404, { "Content-Type": "application/json" });
2711
+ res.end(JSON.stringify({ error: { type: "not_found", message: "no such ui asset" } }));
2712
+ return true;
2713
+ }
2437
2714
  }
2438
-
2439
- if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', wire);
2440
- else wire();
2441
- })();
2442
- `;
2443
-
2444
- // src/admin/html.ts
2445
- var STYLE = `
2446
- :root { --bg:#0f1115; --panel:#171a21; --line:#272b35; --fg:#e6e8ec; --muted:#8b91a0; --accent:#5b8cff; --danger:#ff5b6e; --ok:#3ecf8e; }
2447
- * { box-sizing: border-box; }
2448
- body { margin:0; font:14px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif; background:var(--bg); color:var(--fg); }
2449
- header { padding:14px 20px; border-bottom:1px solid var(--line); display:flex; align-items:center; gap:12px; }
2450
- header h1 { font-size:16px; margin:0; font-weight:600; }
2451
- header .badge { font-size:12px; color:var(--muted); }
2452
- main { padding:20px; display:grid; gap:20px; max-width:980px; margin:0 auto; }
2453
- section { background:var(--panel); border:1px solid var(--line); border-radius:10px; padding:16px; }
2454
- section h2 { font-size:14px; margin:0 0 12px; font-weight:600; }
2455
- table { width:100%; border-collapse:collapse; font-size:13px; }
2456
- th, td { text-align:left; padding:6px 8px; border-bottom:1px solid var(--line); }
2457
- th { color:var(--muted); font-weight:500; }
2458
- input, select, textarea { background:var(--bg); color:var(--fg); border:1px solid var(--line); border-radius:6px; padding:6px 8px; font:inherit; }
2459
- textarea { width:100%; min-height:90px; resize:vertical; font-family:ui-monospace,Menlo,monospace; }
2460
- button { background:var(--accent); color:#fff; border:0; border-radius:6px; padding:6px 12px; cursor:pointer; font:inherit; }
2461
- button.secondary { background:#2a2f3a; }
2462
- button.danger { background:var(--danger); }
2463
- .row { display:flex; gap:8px; flex-wrap:wrap; align-items:center; margin-top:8px; }
2464
- .muted { color:var(--muted); }
2465
- .mono { font-family:ui-monospace,Menlo,monospace; }
2466
- .pill { padding:1px 8px; border-radius:999px; font-size:12px; }
2467
- .pill.ok { background:rgba(62,207,142,.15); color:var(--ok); }
2468
- .pill.bad { background:rgba(255,91,110,.15); color:var(--danger); }
2469
- pre { background:var(--bg); border:1px solid var(--line); border-radius:6px; padding:10px; overflow:auto; max-height:320px; white-space:pre-wrap; }
2470
- .modal-bg { position:fixed; inset:0; background:rgba(0,0,0,.6); display:none; align-items:center; justify-content:center; }
2471
- .modal-bg.show { display:flex; }
2472
- .modal { background:var(--panel); border:1px solid var(--line); border-radius:10px; padding:20px; max-width:520px; width:90%; }
2473
- .warn { color:var(--danger); font-size:13px; margin:8px 0; }
2474
- .err { color:var(--danger); font-size:13px; }
2475
- `;
2476
- var BODY = `
2477
- <header>
2478
- <h1>omnicross daemon dashboard</h1>
2479
- <span class="badge" id="statusBadge">connecting\u2026</span>
2480
- </header>
2481
- <main>
2482
- <section id="statusSection">
2483
- <h2>Runtime status</h2>
2484
- <div id="statusBody" class="muted">loading\u2026</div>
2485
- </section>
2486
-
2487
- <section>
2488
- <h2>Providers</h2>
2489
- <table id="providersTable"><thead><tr><th>id</th><th>format</th><th>base URL</th><th>key</th><th></th></tr></thead><tbody></tbody></table>
2490
- <div class="row">
2491
- <select id="pPreset"><option value="">-- \u9009\u62E9\u9884\u7F6E --</option></select>
2492
- <input id="pId" placeholder="id" size="10" />
2493
- <select id="pFormat"><option value="openai">openai</option><option value="anthropic">anthropic</option><option value="gemini">gemini</option></select>
2494
- <input id="pBase" placeholder="base URL" size="26" />
2495
- <input id="pKey" placeholder="apiKey (blank = keep on edit)" size="22" />
2496
- <button id="pSave">Save provider</button>
2497
- </div>
2498
- <div class="err" id="pErr"></div>
2499
- <div id="poolPanel" style="display:none; margin-top:12px;">
2500
- <h2 id="poolTitle" style="font-size:13px;">API key pool</h2>
2501
- <p class="muted" style="margin:0 0 8px;">Read-only. Multi-key is <b>cold-standby + observable</b> in v1: outbound failover does not yet rotate keys (null-session boundary \u2014 pending the core seam). Keys are masked; edit the pool via the provider's <span class="mono">apiKeys</span>.</p>
2502
- <table id="poolTable"><thead><tr><th>id</th><th>label</th><th>key</th><th>enabled</th><th>weight</th><th>health</th></tr></thead><tbody></tbody></table>
2503
- </div>
2504
- </section>
2505
-
2506
- <section>
2507
- <h2>Named keys</h2>
2508
- <table id="keysTable"><thead><tr><th>name</th><th>prefix</th><th>enabled</th><th>revoked</th><th></th></tr></thead><tbody></tbody></table>
2509
- <div class="row">
2510
- <input id="kName" placeholder="key name" size="16" />
2511
- <button id="kCreate">Create key</button>
2512
- </div>
2513
- </section>
2514
-
2515
- <section>
2516
- <h2>Server config</h2>
2517
- <div class="row">
2518
- <label><input type="checkbox" id="sEnabled" /> enabled</label>
2519
- <label><input type="checkbox" id="sLan" /> networkBinding (LAN)</label>
2520
- <label>port <input id="sPort" size="6" /></label>
2521
- <button id="sSave">Apply server config</button>
2522
- </div>
2523
- <table id="endpointsTable"><thead><tr><th>endpoint</th><th>defaultModel</th><th>subscription</th></tr></thead><tbody></tbody></table>
2524
- </section>
2525
-
2526
- <section>
2527
- <h2>Accounts <span class="muted">(subscription tokens)</span></h2>
2528
- <table id="accountsTable"><thead><tr><th>provider</th><th>kind</th><th>status</th><th></th></tr></thead><tbody></tbody></table>
2529
- <h3>Per-provider accounts <span class="muted">(multi-account \u2014 sanitized, no tokens)</span></h3>
2530
- <table id="providerAccountsTable"><thead><tr><th>provider</th><th>label</th><th>status</th><th>active</th><th></th></tr></thead><tbody></tbody></table>
2531
- <div class="row">
2532
- <select id="acProvider"><option value="claude">claude</option><option value="codex">codex</option><option value="gemini">gemini</option><option value="opencodego">opencodego</option></select>
2533
- <button id="acSave">Save token</button>
2534
- </div>
2535
- <textarea id="acBody" placeholder='{"authMethod":"oauth","status":"authorized","accessToken":"\u2026","refreshToken":"\u2026"}'></textarea>
2536
- <p class="muted">Write-only: paste a token JSON to authorize this provider. The token is shown only on entry \u2014 it is never read back or displayed. Stored as plain JSON in <span class="mono">tokens.json</span>.</p>
2537
- <div class="err" id="acErr"></div>
2538
- </section>
2539
-
2540
- <section>
2541
- <h2>Playground</h2>
2542
- <div class="row">
2543
- <select id="plEndpoint"><option value="chat">chat</option><option value="responses">responses</option><option value="messages">messages</option><option value="gemini">gemini</option></select>
2544
- <input id="plKey" placeholder="named key (sk-omnicross-\u2026)" size="30" />
2545
- <button id="plSend">Send</button>
2546
- </div>
2547
- <textarea id="plBody">{"model":"","messages":[{"role":"user","content":"ping"}]}</textarea>
2548
- <pre id="plResponse" class="muted">response will appear here</pre>
2549
- </section>
2550
- </main>
2551
-
2552
- <div class="modal-bg" id="keyModalBg">
2553
- <div class="modal">
2554
- <h2>Key created</h2>
2555
- <p class="warn">This secret is shown ONCE. Copy it now \u2014 it cannot be retrieved again.</p>
2556
- <pre id="keyPlaintext" class="mono"></pre>
2557
- <div class="row">
2558
- <button id="keyCopy">Copy</button>
2559
- <button class="secondary" id="keyClose">Close</button>
2560
- </div>
2561
- </div>
2562
- </div>
2563
- `;
2564
- var DASHBOARD_HTML = `<!doctype html>
2565
- <html lang="en">
2566
- <head>
2567
- <meta charset="utf-8" />
2568
- <meta name="viewport" content="width=device-width, initial-scale=1" />
2569
- <title>omnicross daemon dashboard</title>
2570
- <style>${STYLE}</style>
2571
- </head>
2572
- <body>
2573
- ${BODY}
2574
- <script>${DASHBOARD_JS}</script>
2575
- </body>
2576
- </html>`;
2715
+ const body = await (0, import_promises.readFile)(target);
2716
+ const type = CONTENT_TYPES[import_node_path3.default.extname(target).toLowerCase()] ?? "application/octet-stream";
2717
+ res.writeHead(200, { "Content-Type": type, "Content-Length": body.length });
2718
+ res.end(req.method === "HEAD" ? void 0 : body);
2719
+ return true;
2720
+ }
2577
2721
 
2578
2722
  // src/admin/AdminServer.ts
2579
2723
  var LOOPBACK_ADDR = "127.0.0.1";
2580
2724
  var LAN_ADDR = "0.0.0.0";
2725
+ var DAEMON_VERSION = true ? "0.1.2" : "0.0.0-dev";
2581
2726
  var AdminServer = class {
2582
2727
  constructor(deps) {
2583
2728
  this.deps = deps;
@@ -2586,6 +2731,8 @@ var AdminServer = class {
2586
2731
  server = null;
2587
2732
  boundPort = 0;
2588
2733
  boundAddr = LOOPBACK_ADDR;
2734
+ /** Control Panel dist dir (resolved once at first request; null = no UI). */
2735
+ uiDist;
2589
2736
  /**
2590
2737
  * Start the admin listener honoring the resolved admin config. Returns the
2591
2738
  * actual bound port, or `0` when it refuses/declines to bind (disabled or the
@@ -2614,13 +2761,13 @@ var AdminServer = class {
2614
2761
  const server = import_node_http2.default.createServer((req, res) => {
2615
2762
  this.onRequest(req, res);
2616
2763
  });
2617
- const onError = (err4) => {
2618
- if (err4.code === "EADDRINUSE" && port !== 0) {
2764
+ const onError = (err5) => {
2765
+ if (err5.code === "EADDRINUSE" && port !== 0) {
2619
2766
  server.removeListener("error", onError);
2620
2767
  this.listen(bindAddr, 0).then(resolve, reject);
2621
2768
  return;
2622
2769
  }
2623
- reject(err4);
2770
+ reject(err5);
2624
2771
  };
2625
2772
  server.on("error", onError);
2626
2773
  server.listen(port, bindAddr, () => {
@@ -2638,8 +2785,8 @@ var AdminServer = class {
2638
2785
  }
2639
2786
  /** Per-request handler: auth gate (when a token is set) → routing. */
2640
2787
  onRequest(req, res) {
2641
- void this.dispatch(req, res).catch((err4) => {
2642
- const message = err4 instanceof Error ? err4.message : String(err4);
2788
+ void this.dispatch(req, res).catch((err5) => {
2789
+ const message = err5 instanceof Error ? err5.message : String(err5);
2643
2790
  console.error("[AdminServer] unhandled error:", message);
2644
2791
  if (!res.headersSent) {
2645
2792
  res.writeHead(500, { "Content-Type": "application/json" });
@@ -2649,22 +2796,26 @@ var AdminServer = class {
2649
2796
  }
2650
2797
  async dispatch(req, res) {
2651
2798
  const cfg = this.deps.getAdminConfig();
2799
+ res.setHeader("x-omnicross-daemon", DAEMON_VERSION);
2800
+ res.setHeader("x-omnicross-pid", String(process.pid));
2652
2801
  if (cfg.token && !this.isAuthorized(req, cfg.token)) {
2653
2802
  res.writeHead(401, { "Content-Type": "application/json" });
2654
2803
  res.end(JSON.stringify({ error: { type: "unauthorized", message: "admin token required" } }));
2655
2804
  return;
2656
2805
  }
2657
2806
  const url = req.url ?? "/";
2658
- const path = url.split("?")[0];
2659
- if ((req.method === "GET" || req.method === "HEAD") && (path === "/" || path === "/admin")) {
2660
- res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
2661
- res.end(req.method === "HEAD" ? void 0 : DASHBOARD_HTML);
2807
+ const path2 = url.split("?")[0];
2808
+ if ((req.method === "GET" || req.method === "HEAD") && (path2 === "/" || path2 === "/admin")) {
2809
+ res.writeHead(302, { Location: "/ui/" });
2810
+ res.end();
2662
2811
  return;
2663
2812
  }
2664
- if (path.startsWith("/admin/api/")) {
2665
- await handleAdminApi(req, res, path, this.deps);
2813
+ if (path2.startsWith("/admin/api/")) {
2814
+ await handleAdminApi(req, res, path2, this.deps);
2666
2815
  return;
2667
2816
  }
2817
+ if (this.uiDist === void 0) this.uiDist = resolveUiDist();
2818
+ if (await handleUiStatic(req, res, path2, this.uiDist)) return;
2668
2819
  res.writeHead(404, { "Content-Type": "application/json" });
2669
2820
  res.end(JSON.stringify({ error: { type: "not_found", message: "no such admin route" } }));
2670
2821
  }
@@ -2699,11 +2850,11 @@ function constantTimeEquals(a, b) {
2699
2850
  const bufA = Buffer.from(a, "utf8");
2700
2851
  const bufB = Buffer.from(b, "utf8");
2701
2852
  if (bufA.length !== bufB.length) return false;
2702
- return (0, import_node_crypto6.timingSafeEqual)(bufA, bufB);
2853
+ return (0, import_node_crypto7.timingSafeEqual)(bufA, bufB);
2703
2854
  }
2704
2855
 
2705
2856
  // src/admin/oauthSessions.ts
2706
- var import_node_crypto7 = __toESM(require("crypto"), 1);
2857
+ var import_node_crypto8 = __toESM(require("crypto"), 1);
2707
2858
  var DEFAULT_OAUTH_SESSION_TTL_MS = 10 * 60 * 1e3;
2708
2859
  var OAuthSessionStore = class {
2709
2860
  constructor(ttlMs = DEFAULT_OAUTH_SESSION_TTL_MS) {
@@ -2717,7 +2868,7 @@ var OAuthSessionStore = class {
2717
2868
  */
2718
2869
  put(session) {
2719
2870
  this.sweep();
2720
- const sessionId = import_node_crypto7.default.randomBytes(24).toString("base64url");
2871
+ const sessionId = import_node_crypto8.default.randomBytes(24).toString("base64url");
2721
2872
  this.sessions.set(sessionId, { ...session, createdAt: Date.now() });
2722
2873
  return sessionId;
2723
2874
  }
@@ -2787,18 +2938,18 @@ function awaitLoopbackCode(expectedState, timeoutMs = DEFAULT_TIMEOUT_MS) {
2787
2938
  res.end(pageHtml("Login complete."));
2788
2939
  finish(server, () => resolve(code));
2789
2940
  });
2790
- server.on("error", (err4) => {
2941
+ server.on("error", (err5) => {
2791
2942
  if (settled) return;
2792
2943
  settled = true;
2793
2944
  clearTimeout(timer);
2794
- if (err4.code === "EADDRINUSE") {
2945
+ if (err5.code === "EADDRINUSE") {
2795
2946
  reject(
2796
2947
  new Error(
2797
2948
  `login: cannot bind ${LOOPBACK_HOST}:${LOOPBACK_PORT} (address in use) \u2014 another codex login or process is holding the port`
2798
2949
  )
2799
2950
  );
2800
2951
  } else {
2801
- reject(err4);
2952
+ reject(err5);
2802
2953
  }
2803
2954
  });
2804
2955
  const timer = setTimeout(() => {
@@ -2873,6 +3024,15 @@ function createPoolKeysLoader(getProviderRow, autoDisabled) {
2873
3024
  };
2874
3025
  }
2875
3026
 
3027
+ // src/commands/paths.ts
3028
+ var import_node_path4 = require("path");
3029
+ function defaultPricingPath(configPath) {
3030
+ return (0, import_node_path4.join)((0, import_node_path4.dirname)(configPath), "pricing.json");
3031
+ }
3032
+ function defaultUsageEventsPath(configPath) {
3033
+ return (0, import_node_path4.join)((0, import_node_path4.dirname)(configPath), "usage-events.jsonl");
3034
+ }
3035
+
2876
3036
  // src/ports/ConfigFileProviderConfigSource.ts
2877
3037
  var import_core = require("@omnicross/core");
2878
3038
  var EMPTY_CHAIN = {
@@ -3052,7 +3212,7 @@ var ConsoleLogger = class {
3052
3212
  };
3053
3213
 
3054
3214
  // src/ports/JsonApiServerSettingsStore.ts
3055
- var import_node_fs3 = require("fs");
3215
+ var import_node_fs5 = require("fs");
3056
3216
  var import_outbound_api2 = require("@omnicross/core/outbound-api");
3057
3217
  var JsonApiServerSettingsStore = class {
3058
3218
  constructor(configPath) {
@@ -3068,12 +3228,12 @@ var JsonApiServerSettingsStore = class {
3068
3228
  if (key !== import_outbound_api2.OUTBOUND_API_SERVER_CONFIG_KEY) return;
3069
3229
  const file = this.readFile();
3070
3230
  file.server = value;
3071
- (0, import_node_fs3.writeFileSync)(this.configPath, JSON.stringify(file, null, 2) + "\n", "utf8");
3231
+ (0, import_node_fs5.writeFileSync)(this.configPath, JSON.stringify(file, null, 2) + "\n", "utf8");
3072
3232
  }
3073
3233
  /** Read the config.json, tolerating a missing/corrupt file (→ empty shape). */
3074
3234
  readFile() {
3075
3235
  try {
3076
- const raw = (0, import_node_fs3.readFileSync)(this.configPath, "utf8");
3236
+ const raw = (0, import_node_fs5.readFileSync)(this.configPath, "utf8");
3077
3237
  const parsed = JSON.parse(raw);
3078
3238
  if (parsed && typeof parsed === "object") return parsed;
3079
3239
  } catch {
@@ -3082,8 +3242,211 @@ var JsonApiServerSettingsStore = class {
3082
3242
  }
3083
3243
  };
3084
3244
 
3245
+ // src/ports/JsonlUsageEventStore.ts
3246
+ var import_node_crypto9 = require("crypto");
3247
+ var import_node_fs6 = require("fs");
3248
+ var JsonlUsageEventStore = class {
3249
+ constructor(eventsPath, isPriced) {
3250
+ this.eventsPath = eventsPath;
3251
+ this.isPriced = isPriced;
3252
+ }
3253
+ eventsPath;
3254
+ isPriced;
3255
+ /** Persist one event: assign `id`, stamp `ts` when absent, append ONE line. */
3256
+ async insert(input) {
3257
+ const row = {
3258
+ ...input,
3259
+ id: (0, import_node_crypto9.randomUUID)(),
3260
+ ts: input.ts ?? Date.now()
3261
+ };
3262
+ (0, import_node_fs6.appendFileSync)(this.eventsPath, JSON.stringify(row) + "\n", "utf8");
3263
+ return row.id;
3264
+ }
3265
+ async getTotals(range) {
3266
+ const totals = {
3267
+ inputTokens: 0,
3268
+ outputTokens: 0,
3269
+ cacheReadTokens: 0,
3270
+ cacheCreationTokens: 0,
3271
+ reasoningTokens: 0,
3272
+ costUsd: 0,
3273
+ costSavedByCacheUsd: 0,
3274
+ eventCount: 0
3275
+ };
3276
+ for (const row of this.readRows(range)) {
3277
+ totals.inputTokens += row.inputTokens;
3278
+ totals.outputTokens += row.outputTokens;
3279
+ totals.cacheReadTokens += row.cacheReadTokens;
3280
+ totals.cacheCreationTokens += row.cacheCreationTokens;
3281
+ totals.reasoningTokens += row.reasoningTokens;
3282
+ totals.costUsd += row.costUsd;
3283
+ totals.costSavedByCacheUsd += row.costSavedByCacheUsd;
3284
+ totals.eventCount += 1;
3285
+ }
3286
+ return totals;
3287
+ }
3288
+ async getByModel(range) {
3289
+ const groups = /* @__PURE__ */ new Map();
3290
+ for (const row of this.readRows(range)) {
3291
+ const key = `${row.providerId}::${row.model}`;
3292
+ let g = groups.get(key);
3293
+ if (!g) {
3294
+ g = {
3295
+ providerId: row.providerId,
3296
+ model: row.model,
3297
+ eventCount: 0,
3298
+ inputTokens: 0,
3299
+ outputTokens: 0,
3300
+ cacheReadTokens: 0,
3301
+ cacheCreationTokens: 0,
3302
+ costUsd: 0,
3303
+ costSavedByCacheUsd: 0,
3304
+ unpriced: false
3305
+ };
3306
+ groups.set(key, g);
3307
+ }
3308
+ g.eventCount += 1;
3309
+ g.inputTokens += row.inputTokens;
3310
+ g.outputTokens += row.outputTokens;
3311
+ g.cacheReadTokens += row.cacheReadTokens;
3312
+ g.cacheCreationTokens += row.cacheCreationTokens;
3313
+ g.costUsd += row.costUsd;
3314
+ g.costSavedByCacheUsd += row.costSavedByCacheUsd;
3315
+ }
3316
+ const rows = Array.from(groups.values());
3317
+ for (const g of rows) {
3318
+ g.unpriced = !await this.isPriced(g.providerId, g.model);
3319
+ }
3320
+ return rows;
3321
+ }
3322
+ /**
3323
+ * Group by RAW apiKeyId (null forms the unattributed sentinel group). Label
3324
+ * here is the raw id fallback — the admin handler resolves display labels
3325
+ * against the configured pool keys (the store stays config-schema-free).
3326
+ */
3327
+ async getByApiKey(range) {
3328
+ const groups = /* @__PURE__ */ new Map();
3329
+ for (const row of this.readRows(range)) {
3330
+ const key = row.apiKeyId;
3331
+ let g = groups.get(key);
3332
+ if (!g) {
3333
+ g = {
3334
+ apiKeyId: key,
3335
+ label: key ?? "unattributed",
3336
+ providerId: key === null ? null : row.providerId,
3337
+ eventCount: 0,
3338
+ inputTokens: 0,
3339
+ outputTokens: 0,
3340
+ costUsd: 0
3341
+ };
3342
+ groups.set(key, g);
3343
+ }
3344
+ g.eventCount += 1;
3345
+ g.inputTokens += row.inputTokens;
3346
+ g.outputTokens += row.outputTokens;
3347
+ g.costUsd += row.costUsd;
3348
+ }
3349
+ return Array.from(groups.values());
3350
+ }
3351
+ async getMessagesForSession(sessionId) {
3352
+ return this.readAllRows().filter((r) => r.sessionId === sessionId).sort((a, b) => a.ts - b.ts).map((r) => ({
3353
+ id: r.id,
3354
+ ts: r.ts,
3355
+ messageId: r.messageId,
3356
+ parentMessageId: r.parentMessageId,
3357
+ sessionId: r.sessionId,
3358
+ providerId: r.providerId,
3359
+ model: r.model,
3360
+ apiKeyId: r.apiKeyId,
3361
+ engineOrigin: r.engineOrigin,
3362
+ inputTokens: r.inputTokens,
3363
+ outputTokens: r.outputTokens,
3364
+ cacheReadTokens: r.cacheReadTokens,
3365
+ cacheCreationTokens: r.cacheCreationTokens,
3366
+ reasoningTokens: r.reasoningTokens,
3367
+ costUsd: r.costUsd,
3368
+ costSavedByCacheUsd: r.costSavedByCacheUsd
3369
+ }));
3370
+ }
3371
+ async getSessionCacheStats(sessionId) {
3372
+ const stats = {
3373
+ sessionId,
3374
+ inputTokens: 0,
3375
+ cacheReadTokens: 0,
3376
+ cacheCreationTokens: 0,
3377
+ outputTokens: 0,
3378
+ eventCount: 0,
3379
+ hitRate: 0
3380
+ };
3381
+ for (const r of this.readAllRows()) {
3382
+ if (r.sessionId !== sessionId) continue;
3383
+ stats.inputTokens += r.inputTokens;
3384
+ stats.cacheReadTokens += r.cacheReadTokens;
3385
+ stats.cacheCreationTokens += r.cacheCreationTokens;
3386
+ stats.outputTokens += r.outputTokens;
3387
+ stats.eventCount += 1;
3388
+ }
3389
+ const promptSide = stats.inputTokens + stats.cacheReadTokens + stats.cacheCreationTokens;
3390
+ stats.hitRate = promptSide > 0 ? stats.cacheReadTokens / promptSide : 0;
3391
+ return stats;
3392
+ }
3393
+ /** Rows inside `startTs <= ts < endTs` (endTs EXCLUSIVE). */
3394
+ readRows(range) {
3395
+ return this.readAllRows().filter((r) => r.ts >= range.startTs && r.ts < range.endTs);
3396
+ }
3397
+ /** Parse every line, skipping malformed/torn lines defensively. */
3398
+ readAllRows() {
3399
+ if (!(0, import_node_fs6.existsSync)(this.eventsPath)) return [];
3400
+ let raw;
3401
+ try {
3402
+ raw = (0, import_node_fs6.readFileSync)(this.eventsPath, "utf8");
3403
+ } catch {
3404
+ return [];
3405
+ }
3406
+ const rows = [];
3407
+ for (const line of raw.split("\n")) {
3408
+ const trimmed = line.trim();
3409
+ if (!trimmed) continue;
3410
+ try {
3411
+ const parsed = JSON.parse(trimmed);
3412
+ if (isUsageEventRecord(parsed)) rows.push(parsed);
3413
+ } catch {
3414
+ }
3415
+ }
3416
+ return rows;
3417
+ }
3418
+ };
3419
+ var NUMERIC_FIELDS = [
3420
+ "ts",
3421
+ "inputTokens",
3422
+ "outputTokens",
3423
+ "cacheReadTokens",
3424
+ "cacheCreationTokens",
3425
+ "reasoningTokens",
3426
+ "costUsd",
3427
+ "costSavedByCacheUsd"
3428
+ ];
3429
+ var NULLABLE_STRING_FIELDS = ["messageId", "parentMessageId", "sessionId", "apiKeyId"];
3430
+ var isStringOrNull = (v) => v === null || typeof v === "string";
3431
+ function isUsageEventRecord(parsed) {
3432
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return false;
3433
+ const r = parsed;
3434
+ if (typeof r["id"] !== "string") return false;
3435
+ if (typeof r["providerId"] !== "string") return false;
3436
+ if (typeof r["model"] !== "string") return false;
3437
+ if (typeof r["engineOrigin"] !== "string") return false;
3438
+ for (const f of NULLABLE_STRING_FIELDS) {
3439
+ if (!isStringOrNull(r[f])) return false;
3440
+ }
3441
+ for (const f of NUMERIC_FIELDS) {
3442
+ const v = r[f];
3443
+ if (typeof v !== "number" || !Number.isFinite(v)) return false;
3444
+ }
3445
+ return true;
3446
+ }
3447
+
3085
3448
  // src/ports/JsonOutboundKeyDb.ts
3086
- var import_node_fs4 = require("fs");
3449
+ var import_node_fs7 = require("fs");
3087
3450
  var JsonOutboundKeyDb = class {
3088
3451
  constructor(keysPath) {
3089
3452
  this.keysPath = keysPath;
@@ -3147,23 +3510,366 @@ var JsonOutboundKeyDb = class {
3147
3510
  }
3148
3511
  /** Read the key rows, tolerating a missing/corrupt file (→ empty list). */
3149
3512
  readRows() {
3150
- if (!(0, import_node_fs4.existsSync)(this.keysPath)) return [];
3513
+ if (!(0, import_node_fs7.existsSync)(this.keysPath)) return [];
3514
+ try {
3515
+ const parsed = JSON.parse((0, import_node_fs7.readFileSync)(this.keysPath, "utf8"));
3516
+ return Array.isArray(parsed) ? parsed : [];
3517
+ } catch {
3518
+ return [];
3519
+ }
3520
+ }
3521
+ writeRows(rows) {
3522
+ (0, import_node_fs7.writeFileSync)(this.keysPath, JSON.stringify(rows, null, 2) + "\n", "utf8");
3523
+ }
3524
+ };
3525
+
3526
+ // src/ports/JsonPricingStore.ts
3527
+ var import_node_fs8 = require("fs");
3528
+ var JsonPricingStore = class {
3529
+ constructor(pricingPath) {
3530
+ this.pricingPath = pricingPath;
3531
+ }
3532
+ pricingPath;
3533
+ async getAll() {
3534
+ return this.readRows();
3535
+ }
3536
+ /**
3537
+ * Insert or update one row keyed (providerId, modelId). `asUserEdit` stamps
3538
+ * user provenance (source 'user', userEdited, editedAt now) so the row is
3539
+ * protected from auto-overwrite during source refreshes; a non-user upsert
3540
+ * stamps source 'litellm' and clears nothing it should not (a plain source
3541
+ * upsert through this method overwrites the row wholesale).
3542
+ */
3543
+ async upsert(input, asUserEdit) {
3544
+ const rows = this.readRows();
3545
+ const entry = this.applyUpsert(rows, input, asUserEdit);
3546
+ this.writeRows(rows);
3547
+ return entry;
3548
+ }
3549
+ /**
3550
+ * Apply a batch fetched from a pricing source. Rows whose local copy is
3551
+ * user-edited are NOT applied — they come back as `{ current, incoming }`
3552
+ * conflicts; everything else is upserted (source 'litellm'). ONE file write
3553
+ * for the whole batch.
3554
+ */
3555
+ async bulkApplyFromSource(entries) {
3556
+ const rows = this.readRows();
3557
+ const applied = [];
3558
+ const conflicts = [];
3559
+ for (const incoming of entries) {
3560
+ const current = rows.find(
3561
+ (r) => r.providerId === incoming.providerId && r.modelId === incoming.modelId
3562
+ );
3563
+ if (current && current.userEdited) {
3564
+ conflicts.push({ current, incoming });
3565
+ continue;
3566
+ }
3567
+ applied.push(this.applyUpsert(
3568
+ rows,
3569
+ incoming,
3570
+ /* asUserEdit */
3571
+ false
3572
+ ));
3573
+ }
3574
+ if (applied.length > 0) this.writeRows(rows);
3575
+ return { applied, conflicts };
3576
+ }
3577
+ /**
3578
+ * Apply per-row conflict decisions: 'overwrite' replaces the local row with
3579
+ * the incoming values (clearing the user-edited mark), 'skip' counts only.
3580
+ */
3581
+ async applyResolutions(resolutions) {
3582
+ const rows = this.readRows();
3583
+ let overwrittenCount = 0;
3584
+ let skippedCount = 0;
3585
+ for (const r of resolutions) {
3586
+ if (r.action === "skip") {
3587
+ skippedCount += 1;
3588
+ continue;
3589
+ }
3590
+ this.applyUpsert(
3591
+ rows,
3592
+ r.incoming,
3593
+ /* asUserEdit */
3594
+ false
3595
+ );
3596
+ overwrittenCount += 1;
3597
+ }
3598
+ if (overwrittenCount > 0) this.writeRows(rows);
3599
+ return { overwrittenCount, skippedCount };
3600
+ }
3601
+ /**
3602
+ * STORE-LOCAL (not on the core port): remove one row. Returns whether a row
3603
+ * was actually removed. The admin DELETE handler calls this then invalidates
3604
+ * the engine cache.
3605
+ */
3606
+ async delete(providerId, modelId) {
3607
+ const rows = this.readRows();
3608
+ const idx = rows.findIndex((r) => r.providerId === providerId && r.modelId === modelId);
3609
+ if (idx < 0) return false;
3610
+ rows.splice(idx, 1);
3611
+ this.writeRows(rows);
3612
+ return true;
3613
+ }
3614
+ /** Upsert into `rows` IN PLACE (no write) and return the resulting entry. */
3615
+ applyUpsert(rows, input, asUserEdit) {
3616
+ const now = Date.now();
3617
+ const entry = {
3618
+ providerId: input.providerId,
3619
+ modelId: input.modelId,
3620
+ inputPricePer1m: input.inputPricePer1m,
3621
+ outputPricePer1m: input.outputPricePer1m,
3622
+ cacheReadPricePer1m: input.cacheReadPricePer1m ?? null,
3623
+ cacheWritePricePer1m: input.cacheWritePricePer1m ?? null,
3624
+ source: asUserEdit ? "user" : "litellm",
3625
+ userEdited: asUserEdit,
3626
+ editedAt: asUserEdit ? now : null,
3627
+ updatedAt: now
3628
+ };
3629
+ const idx = rows.findIndex(
3630
+ (r) => r.providerId === input.providerId && r.modelId === input.modelId
3631
+ );
3632
+ if (idx >= 0) rows[idx] = entry;
3633
+ else rows.push(entry);
3634
+ return entry;
3635
+ }
3636
+ /** Read the pricing rows, tolerating a missing/corrupt file (→ empty list). */
3637
+ readRows() {
3638
+ if (!(0, import_node_fs8.existsSync)(this.pricingPath)) return [];
3151
3639
  try {
3152
- const parsed = JSON.parse((0, import_node_fs4.readFileSync)(this.keysPath, "utf8"));
3640
+ const parsed = JSON.parse((0, import_node_fs8.readFileSync)(this.pricingPath, "utf8"));
3153
3641
  return Array.isArray(parsed) ? parsed : [];
3154
3642
  } catch {
3155
3643
  return [];
3156
3644
  }
3157
3645
  }
3158
3646
  writeRows(rows) {
3159
- (0, import_node_fs4.writeFileSync)(this.keysPath, JSON.stringify(rows, null, 2) + "\n", "utf8");
3647
+ (0, import_node_fs8.writeFileSync)(this.pricingPath, JSON.stringify(rows, null, 2) + "\n", "utf8");
3160
3648
  }
3161
3649
  };
3162
3650
 
3163
3651
  // src/ports/JsonSubscriptionCredentialStore.ts
3164
- var import_node_fs5 = require("fs");
3165
- var import_node_path2 = require("path");
3652
+ var import_node_fs11 = require("fs");
3653
+ var import_node_path7 = require("path");
3166
3654
  var import_subscriptions3 = require("@omnicross/subscriptions");
3655
+
3656
+ // src/ports/account-sync.ts
3657
+ var IMPORT_EXPIRY_MARGIN_MS = 6e4;
3658
+ function viewOf(tokens) {
3659
+ return tokens;
3660
+ }
3661
+ function decideExternalImport(captured, external, now = Date.now()) {
3662
+ if (!external?.accessToken) return "no-credential";
3663
+ const capturedRt = viewOf(captured).refreshToken;
3664
+ const hasNewRefresh = Boolean(external.refreshToken && external.refreshToken !== capturedRt);
3665
+ const accessStillValid = external.expiresAt ? Date.parse(external.expiresAt) > now + IMPORT_EXPIRY_MARGIN_MS : true;
3666
+ return hasNewRefresh || accessStillValid ? "import" : "not-rotated";
3667
+ }
3668
+ function buildImportedTokens(captured, external) {
3669
+ const imported = {
3670
+ ...captured,
3671
+ accessToken: external.accessToken,
3672
+ status: "authorized",
3673
+ errorMessage: void 0,
3674
+ syncWarning: void 0,
3675
+ lastRefreshedAt: (/* @__PURE__ */ new Date()).toISOString()
3676
+ };
3677
+ if (external.refreshToken) imported.refreshToken = external.refreshToken;
3678
+ if (external.expiresAt) imported.expiresAt = external.expiresAt;
3679
+ else delete imported.expiresAt;
3680
+ if (external.idToken) imported.idToken = external.idToken;
3681
+ if (external.scopes) imported.scopes = external.scopes;
3682
+ return imported;
3683
+ }
3684
+ function buildTokensFromExternal(provider, external) {
3685
+ const base = {
3686
+ authMethod: "oauth",
3687
+ status: "authorized",
3688
+ accessToken: external.accessToken,
3689
+ lastRefreshedAt: (/* @__PURE__ */ new Date()).toISOString()
3690
+ };
3691
+ if (provider === "claude") {
3692
+ const tokens2 = { ...base };
3693
+ if (external.refreshToken) tokens2.refreshToken = external.refreshToken;
3694
+ if (external.expiresAt) tokens2.expiresAt = external.expiresAt;
3695
+ if (external.scopes) tokens2.scopes = external.scopes;
3696
+ return tokens2;
3697
+ }
3698
+ const tokens = { ...base };
3699
+ if (external.refreshToken) tokens.refreshToken = external.refreshToken;
3700
+ if (external.expiresAt) tokens.expiresAt = external.expiresAt;
3701
+ if (external.idToken) tokens.idToken = external.idToken;
3702
+ return tokens;
3703
+ }
3704
+ function isExternalDivergent(stored, external) {
3705
+ if (!external?.accessToken || !external.refreshToken) return false;
3706
+ const view = viewOf(stored);
3707
+ if (!view.refreshToken || external.refreshToken === view.refreshToken) return false;
3708
+ const storedExp = view.expiresAt ? Date.parse(view.expiresAt) : NaN;
3709
+ const externalExp = external.expiresAt ? Date.parse(external.expiresAt) : Infinity;
3710
+ return !Number.isFinite(storedExp) || externalExp > storedExp;
3711
+ }
3712
+ function findDuplicateCredentialIds(accounts) {
3713
+ const byCredential = /* @__PURE__ */ new Map();
3714
+ for (const account of accounts) {
3715
+ const view = viewOf(account.tokens);
3716
+ const credential = view.refreshToken ?? view.apiKey ?? view.accessToken;
3717
+ if (!credential) continue;
3718
+ const ids = byCredential.get(credential) ?? [];
3719
+ ids.push(account.id);
3720
+ byCredential.set(credential, ids);
3721
+ }
3722
+ const duplicates = /* @__PURE__ */ new Set();
3723
+ for (const ids of byCredential.values()) {
3724
+ if (ids.length > 1) for (const id of ids) duplicates.add(id);
3725
+ }
3726
+ return duplicates;
3727
+ }
3728
+
3729
+ // src/ports/external-cli-credentials.ts
3730
+ var import_node_fs9 = require("fs");
3731
+ var import_node_os2 = require("os");
3732
+ var import_node_path5 = require("path");
3733
+ function externalStorePath(provider, home = (0, import_node_os2.homedir)()) {
3734
+ return provider === "claude" ? (0, import_node_path5.join)(home, ".claude", ".credentials.json") : (0, import_node_path5.join)(home, ".codex", "auth.json");
3735
+ }
3736
+ function decodeJwtExpiryMs(token) {
3737
+ try {
3738
+ const payload = token.split(".")[1];
3739
+ if (!payload) return void 0;
3740
+ const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
3741
+ if (typeof decoded.exp === "number" && Number.isFinite(decoded.exp)) {
3742
+ return decoded.exp * 1e3;
3743
+ }
3744
+ } catch {
3745
+ }
3746
+ return void 0;
3747
+ }
3748
+ function parseClaudeOAuthEnvelope(raw) {
3749
+ const oauth = raw?.claudeAiOauth;
3750
+ if (!oauth || typeof oauth.accessToken !== "string" || !oauth.accessToken) return null;
3751
+ const parsed = { accessToken: oauth.accessToken };
3752
+ if (typeof oauth.refreshToken === "string" && oauth.refreshToken) {
3753
+ parsed.refreshToken = oauth.refreshToken;
3754
+ }
3755
+ if (typeof oauth.expiresAt === "number" && Number.isFinite(oauth.expiresAt)) {
3756
+ parsed.expiresAt = new Date(oauth.expiresAt).toISOString();
3757
+ }
3758
+ if (Array.isArray(oauth.scopes) && oauth.scopes.every((s) => typeof s === "string")) {
3759
+ parsed.scopes = oauth.scopes;
3760
+ }
3761
+ return parsed;
3762
+ }
3763
+ function parseCodexTokensEnvelope(raw) {
3764
+ const tokens = raw?.tokens;
3765
+ if (!tokens) return null;
3766
+ const accessToken = typeof tokens.access_token === "string" && tokens.access_token ? tokens.access_token : void 0;
3767
+ const idToken = typeof tokens.id_token === "string" && tokens.id_token ? tokens.id_token : void 0;
3768
+ if (!accessToken && !idToken) return null;
3769
+ const parsed = {};
3770
+ if (accessToken) {
3771
+ parsed.accessToken = accessToken;
3772
+ const expMs = decodeJwtExpiryMs(accessToken);
3773
+ if (expMs !== void 0) parsed.expiresAt = new Date(expMs).toISOString();
3774
+ }
3775
+ if (idToken) parsed.idToken = idToken;
3776
+ if (typeof tokens.refresh_token === "string" && tokens.refresh_token) {
3777
+ parsed.refreshToken = tokens.refresh_token;
3778
+ }
3779
+ return parsed;
3780
+ }
3781
+ function readExternalCliCredentials(provider, home = (0, import_node_os2.homedir)()) {
3782
+ const path2 = externalStorePath(provider, home);
3783
+ if (!(0, import_node_fs9.existsSync)(path2)) return null;
3784
+ let raw;
3785
+ try {
3786
+ const parsed = JSON.parse((0, import_node_fs9.readFileSync)(path2, "utf8"));
3787
+ raw = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : void 0;
3788
+ } catch {
3789
+ return null;
3790
+ }
3791
+ return provider === "claude" ? parseClaudeOAuthEnvelope(raw) : parseCodexTokensEnvelope(raw);
3792
+ }
3793
+
3794
+ // src/ports/external-cli-store.ts
3795
+ var import_node_fs10 = require("fs");
3796
+ var import_node_os3 = require("os");
3797
+ var import_node_path6 = require("path");
3798
+ function markerPath(provider, home) {
3799
+ return `${externalStorePath(provider, home)}.omnicross-managed`;
3800
+ }
3801
+ function backupPath(provider, home) {
3802
+ return `${externalStorePath(provider, home)}.omnicross-backup`;
3803
+ }
3804
+ function buildClaudeOAuthEnvelope(tokens) {
3805
+ if (!tokens.accessToken) return null;
3806
+ const envelope = { accessToken: tokens.accessToken };
3807
+ if (tokens.refreshToken) envelope.refreshToken = tokens.refreshToken;
3808
+ if (tokens.expiresAt) {
3809
+ const ms = Date.parse(tokens.expiresAt);
3810
+ if (Number.isFinite(ms)) envelope.expiresAt = ms;
3811
+ }
3812
+ if (tokens.scopes && tokens.scopes.length > 0) envelope.scopes = tokens.scopes;
3813
+ return envelope;
3814
+ }
3815
+ function buildCodexTokensEnvelope(tokens) {
3816
+ if (!tokens.accessToken && !tokens.idToken) return null;
3817
+ const envelope = { access_token: tokens.accessToken ?? "" };
3818
+ if (tokens.idToken) envelope.id_token = tokens.idToken;
3819
+ if (tokens.refreshToken) envelope.refresh_token = tokens.refreshToken;
3820
+ return envelope;
3821
+ }
3822
+ function readExistingObject(path2) {
3823
+ if (!(0, import_node_fs10.existsSync)(path2)) return {};
3824
+ try {
3825
+ const parsed = JSON.parse((0, import_node_fs10.readFileSync)(path2, "utf8"));
3826
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
3827
+ } catch {
3828
+ return {};
3829
+ }
3830
+ }
3831
+ function writeAtomic(path2, content) {
3832
+ (0, import_node_fs10.mkdirSync)((0, import_node_path6.dirname)(path2), { recursive: true });
3833
+ const temp = `${path2}.omnicross-tmp`;
3834
+ (0, import_node_fs10.writeFileSync)(temp, content, "utf8");
3835
+ (0, import_node_fs10.renameSync)(temp, path2);
3836
+ }
3837
+ function createExternalCliStore(home = (0, import_node_os3.homedir)()) {
3838
+ return {
3839
+ readMarkerAccountId(provider) {
3840
+ const path2 = markerPath(provider, home);
3841
+ if (!(0, import_node_fs10.existsSync)(path2)) return void 0;
3842
+ try {
3843
+ const parsed = JSON.parse((0, import_node_fs10.readFileSync)(path2, "utf8"));
3844
+ return typeof parsed.accountId === "string" && parsed.accountId ? parsed.accountId : void 0;
3845
+ } catch {
3846
+ return void 0;
3847
+ }
3848
+ },
3849
+ writeMarker(provider, accountId) {
3850
+ writeAtomic(
3851
+ markerPath(provider, home),
3852
+ JSON.stringify({ accountId, at: (/* @__PURE__ */ new Date()).toISOString() }, null, 2) + "\n"
3853
+ );
3854
+ },
3855
+ writeBack(provider, accountId, tokens) {
3856
+ const owner = this.readMarkerAccountId(provider);
3857
+ if (owner !== accountId) return false;
3858
+ const envelope = provider === "claude" ? buildClaudeOAuthEnvelope(tokens) : buildCodexTokensEnvelope(tokens);
3859
+ if (!envelope) return false;
3860
+ const storePath = externalStorePath(provider, home);
3861
+ if ((0, import_node_fs10.existsSync)(storePath) && !(0, import_node_fs10.existsSync)(backupPath(provider, home))) {
3862
+ (0, import_node_fs10.copyFileSync)(storePath, backupPath(provider, home));
3863
+ }
3864
+ const existing = readExistingObject(storePath);
3865
+ const merged = provider === "claude" ? { ...existing, claudeAiOauth: envelope } : { ...existing, tokens: envelope };
3866
+ writeAtomic(storePath, JSON.stringify(merged, null, 2) + "\n");
3867
+ return true;
3868
+ }
3869
+ };
3870
+ }
3871
+
3872
+ // src/ports/JsonSubscriptionCredentialStore.ts
3167
3873
  var JsonSubscriptionCredentialStore = class {
3168
3874
  /**
3169
3875
  * @param tokensPath on-disk `tokens.json` location.
@@ -3173,14 +3879,33 @@ var JsonSubscriptionCredentialStore = class {
3173
3879
  * is unchanged; tests inject a mock fetch. NOT used by any
3174
3880
  * read/write path — only by `refresh*Token`.
3175
3881
  */
3176
- constructor(tokensPath, box, fetchImpl = (url, init) => fetch(url, init)) {
3882
+ constructor(tokensPath, box, fetchImpl = (url, init) => fetch(url, init), externalCliReader = readExternalCliCredentials, externalCliStore = createExternalCliStore()) {
3177
3883
  this.tokensPath = tokensPath;
3178
3884
  this.box = box;
3179
3885
  this.fetchImpl = fetchImpl;
3886
+ this.externalCliReader = externalCliReader;
3887
+ this.externalCliStore = externalCliStore;
3180
3888
  }
3181
3889
  tokensPath;
3182
3890
  box;
3183
3891
  fetchImpl;
3892
+ externalCliReader;
3893
+ externalCliStore;
3894
+ /**
3895
+ * In-flight refresh coalescing (external-cli-sync). OAuth refresh tokens are
3896
+ * SINGLE-USE: two concurrent refreshes of one account each spend the same
3897
+ * token and the loser bricks a healthy account. Every refresh entry point
3898
+ * (auth-strategy lazy refresh, 401 retry, background scheduler) funnels
3899
+ * through `coalesce`, so overlapping callers share ONE upstream round-trip.
3900
+ */
3901
+ inFlightRefreshes = /* @__PURE__ */ new Map();
3902
+ coalesce(key, task) {
3903
+ const existing = this.inFlightRefreshes.get(key);
3904
+ if (existing) return existing;
3905
+ const run = task().finally(() => this.inFlightRefreshes.delete(key));
3906
+ this.inFlightRefreshes.set(key, run);
3907
+ return run;
3908
+ }
3184
3909
  /** Full parsed account-tokens config (or a minimal `{ updatedAt }` when the
3185
3910
  * file is absent/corrupt). This is the hot read — the codex / gemini auth
3186
3911
  * strategies pull `accessToken` / `expiresAt` / `status` from it. */
@@ -3208,10 +3933,41 @@ var JsonSubscriptionCredentialStore = class {
3208
3933
  const out = {};
3209
3934
  for (const provider of ["claude", "codex", "gemini", "opencodego"]) {
3210
3935
  const sanitized = sanitizeAccounts(config, provider);
3211
- if (sanitized.length > 0) out[provider] = sanitized;
3936
+ if (sanitized.length > 0) out[provider] = this.attachSyncWarnings(config, provider, sanitized);
3212
3937
  }
3213
3938
  return out;
3214
3939
  }
3940
+ /**
3941
+ * List-time credential-conflict warnings (external-cli-sync). Computed, not
3942
+ * persisted: (a) `duplicate-token` when two accounts of one provider share a
3943
+ * credential, (b) `external-divergent` when the external CLI native store has
3944
+ * rotated PAST the ACTIVE account (claude/codex only). A warning persisted by
3945
+ * a failed refresh (`external-not-rotated`) takes precedence — it is the most
3946
+ * actionable state.
3947
+ */
3948
+ attachSyncWarnings(config, provider, sanitized) {
3949
+ const duplicates = findDuplicateCredentialIds(listAccounts(config, provider));
3950
+ let divergentId;
3951
+ if (provider === "claude" || provider === "codex") {
3952
+ const active = getActiveAccount(config, provider);
3953
+ if (active && isExternalDivergent(active.tokens, this.safeReadExternal(provider))) {
3954
+ divergentId = active.id;
3955
+ }
3956
+ }
3957
+ if (duplicates.size === 0 && !divergentId) return sanitized;
3958
+ return sanitized.map((account) => {
3959
+ const computed = account.id === divergentId ? "external-divergent" : duplicates.has(account.id) ? "duplicate-token" : void 0;
3960
+ return { ...account, syncWarning: account.syncWarning ?? computed };
3961
+ });
3962
+ }
3963
+ /** Read the external CLI store, never letting an fs/parse error escape. */
3964
+ safeReadExternal(provider) {
3965
+ try {
3966
+ return this.externalCliReader(provider);
3967
+ } catch {
3968
+ return null;
3969
+ }
3970
+ }
3215
3971
  /**
3216
3972
  * Refresh the Claude OAuth access token (oauth design D4). HONEST `false` when
3217
3973
  * the block has no refresh_token (setup-token / manual) — no upstream call, the
@@ -3221,30 +3977,44 @@ var JsonSubscriptionCredentialStore = class {
3221
3977
  * errorMessage → `false`.
3222
3978
  */
3223
3979
  async refreshClaudeToken() {
3224
- const config = this.readConfig();
3225
- const active = getActiveAccount(config, "claude");
3226
- const claude = active?.tokens;
3227
- if (!active || !claude?.refreshToken) return false;
3228
- const capturedId = active.id;
3229
- this.materializeMigration(config);
3230
- try {
3231
- const result = await import_subscriptions3.claudeOAuth.refreshAccessToken(claude.refreshToken, this.fetchImpl);
3232
- const expiresAt = new Date(Date.now() + result.expiresIn * 1e3).toISOString();
3233
- const next = {
3234
- ...claude,
3235
- accessToken: result.accessToken,
3236
- refreshToken: result.refreshToken,
3237
- expiresAt,
3238
- status: "authorized",
3239
- lastRefreshedAt: (/* @__PURE__ */ new Date()).toISOString(),
3240
- errorMessage: void 0
3241
- };
3242
- this.writeBackById("claude", capturedId, next);
3243
- return true;
3244
- } catch (error) {
3245
- this.markExpiredById("claude", capturedId, claude, error);
3246
- return false;
3247
- }
3980
+ return this.coalesce("claude:active", async () => {
3981
+ const config = this.readConfig();
3982
+ const active = getActiveAccount(config, "claude");
3983
+ const claude = active?.tokens;
3984
+ if (!active || !claude?.refreshToken) return false;
3985
+ const capturedId = active.id;
3986
+ this.materializeMigration(config);
3987
+ try {
3988
+ const result = await import_subscriptions3.claudeOAuth.refreshAccessToken(claude.refreshToken, this.fetchImpl);
3989
+ const expiresAt = new Date(Date.now() + result.expiresIn * 1e3).toISOString();
3990
+ const next = {
3991
+ ...claude,
3992
+ accessToken: result.accessToken,
3993
+ refreshToken: result.refreshToken,
3994
+ expiresAt,
3995
+ status: "authorized",
3996
+ lastRefreshedAt: (/* @__PURE__ */ new Date()).toISOString(),
3997
+ errorMessage: void 0,
3998
+ syncWarning: void 0
3999
+ };
4000
+ this.writeBackById("claude", capturedId, next);
4001
+ this.resyncExternal("claude", capturedId, next);
4002
+ return true;
4003
+ } catch (error) {
4004
+ if (await this.tryExternalImport("claude", capturedId, claude, async (rt) => {
4005
+ const r = await import_subscriptions3.claudeOAuth.refreshAccessToken(rt, this.fetchImpl);
4006
+ return {
4007
+ accessToken: r.accessToken,
4008
+ refreshToken: r.refreshToken,
4009
+ expiresAt: new Date(Date.now() + r.expiresIn * 1e3).toISOString()
4010
+ };
4011
+ })) {
4012
+ return true;
4013
+ }
4014
+ this.markExpiredById("claude", capturedId, claude, error);
4015
+ return false;
4016
+ }
4017
+ });
3248
4018
  }
3249
4019
  /**
3250
4020
  * Refresh the Codex (ChatGPT) OAuth access token. Same shape
@@ -3252,31 +4022,46 @@ var JsonSubscriptionCredentialStore = class {
3252
4022
  * HONEST `false` when no refresh_token.
3253
4023
  */
3254
4024
  async refreshCodexToken() {
3255
- const config = this.readConfig();
3256
- const active = getActiveAccount(config, "codex");
3257
- const codex = active?.tokens;
3258
- if (!active || !codex?.refreshToken) return false;
3259
- const capturedId = active.id;
3260
- this.materializeMigration(config);
3261
- try {
3262
- const result = await import_subscriptions3.codexOAuth.refreshAccessToken(codex.refreshToken, this.fetchImpl);
3263
- const expiresAt = new Date(Date.now() + result.expiresIn * 1e3).toISOString();
3264
- const next = {
3265
- ...codex,
3266
- accessToken: result.accessToken,
3267
- refreshToken: result.refreshToken,
3268
- idToken: result.idToken,
3269
- expiresAt,
3270
- status: "authorized",
3271
- lastRefreshedAt: (/* @__PURE__ */ new Date()).toISOString(),
3272
- errorMessage: void 0
3273
- };
3274
- this.writeBackById("codex", capturedId, next);
3275
- return true;
3276
- } catch (error) {
3277
- this.markExpiredById("codex", capturedId, codex, error);
3278
- return false;
3279
- }
4025
+ return this.coalesce("codex:active", async () => {
4026
+ const config = this.readConfig();
4027
+ const active = getActiveAccount(config, "codex");
4028
+ const codex = active?.tokens;
4029
+ if (!active || !codex?.refreshToken) return false;
4030
+ const capturedId = active.id;
4031
+ this.materializeMigration(config);
4032
+ try {
4033
+ const result = await import_subscriptions3.codexOAuth.refreshAccessToken(codex.refreshToken, this.fetchImpl);
4034
+ const expiresAt = new Date(Date.now() + result.expiresIn * 1e3).toISOString();
4035
+ const next = {
4036
+ ...codex,
4037
+ accessToken: result.accessToken,
4038
+ refreshToken: result.refreshToken,
4039
+ idToken: result.idToken,
4040
+ expiresAt,
4041
+ status: "authorized",
4042
+ lastRefreshedAt: (/* @__PURE__ */ new Date()).toISOString(),
4043
+ errorMessage: void 0,
4044
+ syncWarning: void 0
4045
+ };
4046
+ this.writeBackById("codex", capturedId, next);
4047
+ this.resyncExternal("codex", capturedId, next);
4048
+ return true;
4049
+ } catch (error) {
4050
+ if (await this.tryExternalImport("codex", capturedId, codex, async (rt) => {
4051
+ const r = await import_subscriptions3.codexOAuth.refreshAccessToken(rt, this.fetchImpl);
4052
+ return {
4053
+ accessToken: r.accessToken,
4054
+ refreshToken: r.refreshToken,
4055
+ idToken: r.idToken,
4056
+ expiresAt: new Date(Date.now() + r.expiresIn * 1e3).toISOString()
4057
+ };
4058
+ })) {
4059
+ return true;
4060
+ }
4061
+ this.markExpiredById("codex", capturedId, codex, error);
4062
+ return false;
4063
+ }
4064
+ });
3280
4065
  }
3281
4066
  /**
3282
4067
  * Refresh the Gemini (Google) OAuth access token. The Google
@@ -3286,30 +4071,175 @@ var JsonSubscriptionCredentialStore = class {
3286
4071
  * destroy the ability to refresh again). HONEST `false` when no refresh_token.
3287
4072
  */
3288
4073
  async refreshGeminiToken() {
3289
- const config = this.readConfig();
3290
- const active = getActiveAccount(config, "gemini");
3291
- const gemini = active?.tokens;
3292
- if (!active || !gemini?.refreshToken) return false;
3293
- const capturedId = active.id;
3294
- this.materializeMigration(config);
3295
- try {
3296
- const result = await import_subscriptions3.geminiOAuth.refreshAccessToken(gemini.refreshToken, this.fetchImpl);
3297
- const expiresAt = new Date(Date.now() + result.expiresIn * 1e3).toISOString();
3298
- const next = {
3299
- ...gemini,
3300
- // KEEP the existing refreshToken (response omits it).
3301
- accessToken: result.accessToken,
3302
- expiresAt,
3303
- status: "authorized",
3304
- lastRefreshedAt: (/* @__PURE__ */ new Date()).toISOString(),
3305
- errorMessage: void 0
3306
- };
3307
- this.writeBackById("gemini", capturedId, next);
3308
- return true;
3309
- } catch (error) {
3310
- this.markExpiredById("gemini", capturedId, gemini, error);
4074
+ return this.coalesce("gemini:active", async () => {
4075
+ const config = this.readConfig();
4076
+ const active = getActiveAccount(config, "gemini");
4077
+ const gemini = active?.tokens;
4078
+ if (!active || !gemini?.refreshToken) return false;
4079
+ const capturedId = active.id;
4080
+ this.materializeMigration(config);
4081
+ try {
4082
+ const result = await import_subscriptions3.geminiOAuth.refreshAccessToken(gemini.refreshToken, this.fetchImpl);
4083
+ const expiresAt = new Date(Date.now() + result.expiresIn * 1e3).toISOString();
4084
+ const next = {
4085
+ ...gemini,
4086
+ // KEEP the existing refreshToken (response omits it).
4087
+ accessToken: result.accessToken,
4088
+ expiresAt,
4089
+ status: "authorized",
4090
+ lastRefreshedAt: (/* @__PURE__ */ new Date()).toISOString(),
4091
+ errorMessage: void 0
4092
+ };
4093
+ this.writeBackById("gemini", capturedId, next);
4094
+ return true;
4095
+ } catch (error) {
4096
+ this.markExpiredById("gemini", capturedId, gemini, error);
4097
+ return false;
4098
+ }
4099
+ });
4100
+ }
4101
+ /**
4102
+ * Refresh a SPECIFIC account by id (background scheduler sweep,
4103
+ * external-cli-sync). Unlike the active-account refreshers it does NOT
4104
+ * attempt the external-import fallback — the external CLI file's lineage can
4105
+ * only plausibly match the ACTIVE account. Coalesced per `provider:id`; on
4106
+ * failure flags ONLY that account `expired`.
4107
+ */
4108
+ async refreshAccountById(provider, id) {
4109
+ return this.coalesce(`${provider}:${id}`, async () => {
4110
+ const config = this.readConfig();
4111
+ const account = getAccountById(config, provider, id);
4112
+ const captured = account?.tokens;
4113
+ if (!account || !captured?.refreshToken) return false;
4114
+ this.materializeMigration(config);
4115
+ try {
4116
+ const refreshed = await this.refreshUpstream(provider, captured.refreshToken);
4117
+ const next = {
4118
+ ...captured,
4119
+ accessToken: refreshed.accessToken,
4120
+ // Gemini's refresh response omits a new refresh token — keep the captured.
4121
+ refreshToken: refreshed.refreshToken ?? captured.refreshToken,
4122
+ expiresAt: refreshed.expiresAt,
4123
+ status: "authorized",
4124
+ lastRefreshedAt: (/* @__PURE__ */ new Date()).toISOString(),
4125
+ errorMessage: void 0,
4126
+ syncWarning: void 0
4127
+ };
4128
+ if (refreshed.idToken) next.idToken = refreshed.idToken;
4129
+ this.writeBackById(provider, id, next);
4130
+ if (provider !== "gemini") this.resyncExternal(provider, id, next);
4131
+ return true;
4132
+ } catch (error) {
4133
+ this.markExpiredById(provider, id, captured, error);
4134
+ return false;
4135
+ }
4136
+ });
4137
+ }
4138
+ /** Dispatch one OAuth refresh round-trip to the provider's shared flow. */
4139
+ async refreshUpstream(provider, refreshToken) {
4140
+ const flow = provider === "claude" ? import_subscriptions3.claudeOAuth : provider === "codex" ? import_subscriptions3.codexOAuth : import_subscriptions3.geminiOAuth;
4141
+ const r = await flow.refreshAccessToken(refreshToken, this.fetchImpl);
4142
+ return {
4143
+ accessToken: r.accessToken,
4144
+ refreshToken: r.refreshToken,
4145
+ idToken: r.idToken,
4146
+ expiresAt: new Date(Date.now() + r.expiresIn * 1e3).toISOString()
4147
+ };
4148
+ }
4149
+ /**
4150
+ * External-import fallback for a FAILED active-account refresh
4151
+ * (external-cli-sync). Reads the CLI native store; imports when the external
4152
+ * lineage ROTATED (different refresh token) or its access token is still
4153
+ * valid. When the imported access token is already expired it refreshes once
4154
+ * with the rotated refresh token. A `not-rotated` outcome persists the
4155
+ * `external-not-rotated` warning on the (about-to-be-expired) account so the
4156
+ * UI can tell "genuine revocation" apart from a plain refresh failure.
4157
+ */
4158
+ async tryExternalImport(provider, capturedId, captured, refreshWithToken) {
4159
+ const markerOwner = this.safeReadMarker(provider);
4160
+ if (markerOwner && markerOwner !== capturedId) return false;
4161
+ const external = this.safeReadExternal(provider);
4162
+ const decision = decideExternalImport(captured, external);
4163
+ if (decision === "not-rotated") {
4164
+ captured.syncWarning = "external-not-rotated";
3311
4165
  return false;
3312
4166
  }
4167
+ if (decision !== "import" || !external) return false;
4168
+ let imported = buildImportedTokens(
4169
+ captured,
4170
+ external
4171
+ );
4172
+ const accessStillValid = external.expiresAt ? Date.parse(external.expiresAt) > Date.now() + 6e4 : true;
4173
+ if (!accessStillValid) {
4174
+ try {
4175
+ const refreshed = await refreshWithToken(external.refreshToken);
4176
+ imported = {
4177
+ ...imported,
4178
+ accessToken: refreshed.accessToken,
4179
+ refreshToken: refreshed.refreshToken ?? imported.refreshToken,
4180
+ expiresAt: refreshed.expiresAt,
4181
+ lastRefreshedAt: (/* @__PURE__ */ new Date()).toISOString()
4182
+ };
4183
+ if (refreshed.idToken) imported.idToken = refreshed.idToken;
4184
+ } catch {
4185
+ return false;
4186
+ }
4187
+ }
4188
+ this.writeBackById(provider, capturedId, imported);
4189
+ this.resyncExternal(provider, capturedId, imported);
4190
+ return true;
4191
+ }
4192
+ /**
4193
+ * Marker-gated external write-back (external-cli-sync). After a successful
4194
+ * refresh of the account that OWNS the provider's native CLI store (imported
4195
+ * via `importExternalCliAccount`), push the rotated credential back into the
4196
+ * file — otherwise the daemon's refresh invalidates the single-use refresh
4197
+ * token and silently logs the bare CLI out. NON-FATAL: the internal store is
4198
+ * already persisted; a failed external write only leaves the file stale,
4199
+ * which the `external-divergent` warning surfaces.
4200
+ */
4201
+ resyncExternal(provider, accountId, tokens) {
4202
+ try {
4203
+ this.externalCliStore.writeBack(provider, accountId, tokens);
4204
+ } catch {
4205
+ }
4206
+ }
4207
+ /** Read the marker's owning account id, never letting an fs error escape. */
4208
+ safeReadMarker(provider) {
4209
+ try {
4210
+ return this.externalCliStore.readMarkerAccountId(provider);
4211
+ } catch {
4212
+ return void 0;
4213
+ }
4214
+ }
4215
+ /**
4216
+ * DAEMON-ONLY (admin import button): which providers have a usable external
4217
+ * CLI credential on THIS machine. Pure detection — reads the native files,
4218
+ * never mutates anything, never returns a token.
4219
+ */
4220
+ async listExternalCliAvailability() {
4221
+ return {
4222
+ claude: Boolean(this.safeReadExternal("claude")?.accessToken),
4223
+ codex: Boolean(this.safeReadExternal("codex")?.accessToken)
4224
+ };
4225
+ }
4226
+ /**
4227
+ * DAEMON-ONLY (admin import button): import the external CLI's current login
4228
+ * as a NEW account (+ activate), and take MANAGED ownership of the native
4229
+ * store (marker) so subsequent refreshes write back — keeping the bare CLI
4230
+ * and the daemon on the same live credential instead of silently killing one
4231
+ * side's single-use refresh token.
4232
+ */
4233
+ async importExternalCliAccount(provider, label) {
4234
+ const external = this.safeReadExternal(provider);
4235
+ if (!external?.accessToken) return { ok: false, reason: "no-credential" };
4236
+ const tokens = buildTokensFromExternal(provider, external);
4237
+ const result = await this.appendProviderAccount(provider, tokens, label);
4238
+ try {
4239
+ this.externalCliStore.writeMarker(provider, result.id);
4240
+ } catch {
4241
+ }
4242
+ return { ok: true, id: result.id };
3313
4243
  }
3314
4244
  /**
3315
4245
  * Materialize a lazily-synthesized account id to disk (design D3). On a legacy
@@ -3393,6 +4323,18 @@ var JsonSubscriptionCredentialStore = class {
3393
4323
  this.persist({ ...current, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
3394
4324
  return result;
3395
4325
  }
4326
+ /**
4327
+ * DAEMON-ONLY per-account rename (NOT on the port). Update one account's label;
4328
+ * rejects an unknown id. Label-only — no token material is read or written
4329
+ * (the secret-free invariant holds).
4330
+ */
4331
+ async renameAccount(providerId, id, label) {
4332
+ const current = this.readConfig();
4333
+ const result = renameAccount(current, providerId, id, label);
4334
+ if (!result.ok) return result;
4335
+ this.persist({ ...current, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
4336
+ return result;
4337
+ }
3396
4338
  /**
3397
4339
  * DAEMON-ONLY CLEAR (design D1/D3, NOT on the port). Remove a single provider's
3398
4340
  * block from `tokens.json` and re-persist (the strategies already tolerate an
@@ -3409,9 +4351,9 @@ var JsonSubscriptionCredentialStore = class {
3409
4351
  * → `enc:v1:`; already-`enc:`/`$ENV` untouched) before serializing, so any
3410
4352
  * write — incl. child 4's future refresh writes — lands encrypted. */
3411
4353
  persist(config) {
3412
- (0, import_node_fs5.mkdirSync)((0, import_node_path2.dirname)(this.tokensPath), { recursive: true });
4354
+ (0, import_node_fs11.mkdirSync)((0, import_node_path7.dirname)(this.tokensPath), { recursive: true });
3413
4355
  const encrypted = encryptTokens(config, this.box);
3414
- (0, import_node_fs5.writeFileSync)(this.tokensPath, JSON.stringify(encrypted, null, 2) + "\n", "utf8");
4356
+ (0, import_node_fs11.writeFileSync)(this.tokensPath, JSON.stringify(encrypted, null, 2) + "\n", "utf8");
3415
4357
  }
3416
4358
  /**
3417
4359
  * Read + parse `tokens.json`, tolerating a missing/corrupt file, then DECRYPT
@@ -3427,10 +4369,10 @@ var JsonSubscriptionCredentialStore = class {
3427
4369
  * `config.ts loadConfig`, which decrypts outside its parse try.
3428
4370
  */
3429
4371
  readConfig() {
3430
- if (!(0, import_node_fs5.existsSync)(this.tokensPath)) return { updatedAt: "" };
4372
+ if (!(0, import_node_fs11.existsSync)(this.tokensPath)) return { updatedAt: "" };
3431
4373
  let parsed;
3432
4374
  try {
3433
- const raw = JSON.parse((0, import_node_fs5.readFileSync)(this.tokensPath, "utf8"));
4375
+ const raw = JSON.parse((0, import_node_fs11.readFileSync)(this.tokensPath, "utf8"));
3434
4376
  parsed = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : null;
3435
4377
  } catch {
3436
4378
  parsed = null;
@@ -3441,6 +4383,95 @@ var JsonSubscriptionCredentialStore = class {
3441
4383
  }
3442
4384
  };
3443
4385
 
4386
+ // src/TokenRefreshScheduler.ts
4387
+ var REFRESH_LEAD_MS = 5 * 6e4;
4388
+ var SWEEP_INTERVAL_MS = 6e4;
4389
+ var OAUTH_PROVIDERS = ["claude", "codex", "gemini"];
4390
+ var TokenRefreshScheduler = class {
4391
+ constructor(store, logger, intervalMs = SWEEP_INTERVAL_MS, leadMs = REFRESH_LEAD_MS) {
4392
+ this.store = store;
4393
+ this.logger = logger;
4394
+ this.intervalMs = intervalMs;
4395
+ this.leadMs = leadMs;
4396
+ }
4397
+ store;
4398
+ logger;
4399
+ intervalMs;
4400
+ leadMs;
4401
+ timer = null;
4402
+ sweeping = false;
4403
+ /** Arm the sweep interval. Idempotent. The timer never holds the loop open. */
4404
+ start() {
4405
+ if (this.timer) return;
4406
+ this.timer = setInterval(() => void this.sweep(), this.intervalMs);
4407
+ this.timer.unref?.();
4408
+ }
4409
+ /** Clear the interval (daemon shutdown / test teardown). Idempotent. */
4410
+ dispose() {
4411
+ if (this.timer) {
4412
+ clearInterval(this.timer);
4413
+ this.timer = null;
4414
+ }
4415
+ }
4416
+ /** One sweep over every account of every OAuth provider. Exposed for tests. */
4417
+ async sweep(now = Date.now()) {
4418
+ if (this.sweeping) return;
4419
+ this.sweeping = true;
4420
+ try {
4421
+ const config = await this.store.getFullConfig();
4422
+ for (const provider of OAUTH_PROVIDERS) {
4423
+ const activeId = getActiveAccount(config, provider)?.id;
4424
+ for (const account of listAccounts(config, provider)) {
4425
+ if (!this.needsRefresh(account.tokens, now)) continue;
4426
+ await this.refreshOne(provider, account.id, account.id === activeId);
4427
+ }
4428
+ }
4429
+ } catch (error) {
4430
+ this.logger.warn("token-refresh sweep failed", {
4431
+ error: error instanceof Error ? error.message : String(error)
4432
+ });
4433
+ } finally {
4434
+ this.sweeping = false;
4435
+ }
4436
+ }
4437
+ /** Expiring within the lead window, refreshable, and not already dead. */
4438
+ needsRefresh(tokens, now) {
4439
+ const t = tokens;
4440
+ if (!t.refreshToken || t.status === "expired" || t.status === "error") return false;
4441
+ if (!t.expiresAt) return false;
4442
+ const expiresAt = Date.parse(t.expiresAt);
4443
+ return Number.isFinite(expiresAt) && now >= expiresAt - this.leadMs;
4444
+ }
4445
+ /** Refresh one account; failures are logged, never thrown (the store has
4446
+ * already flagged the account `expired`). */
4447
+ async refreshOne(provider, id, isActive) {
4448
+ try {
4449
+ const ok = isActive ? await this.refreshActive(provider) : await this.store.refreshAccountById(provider, id);
4450
+ if (!ok) {
4451
+ this.logger.warn("background token refresh failed", { provider, accountId: id });
4452
+ } else {
4453
+ this.logger.info("background token refresh succeeded", { provider, accountId: id });
4454
+ }
4455
+ } catch (error) {
4456
+ this.logger.warn("background token refresh threw", {
4457
+ provider,
4458
+ accountId: id,
4459
+ error: error instanceof Error ? error.message : String(error)
4460
+ });
4461
+ }
4462
+ }
4463
+ refreshActive(provider) {
4464
+ switch (provider) {
4465
+ case "claude":
4466
+ return this.store.refreshClaudeToken();
4467
+ case "codex":
4468
+ return this.store.refreshCodexToken();
4469
+ case "gemini":
4470
+ return this.store.refreshGeminiToken();
4471
+ }
4472
+ }
4473
+ };
4474
+
3444
4475
  // src/bootstrap.ts
3445
4476
  function buildDaemon(config, paths) {
3446
4477
  const logger = new ConsoleLogger();
@@ -3473,7 +4504,14 @@ function buildDaemon(config, paths) {
3473
4504
  autoDisableStore.markAutoDisabled(keyId, status, at);
3474
4505
  }
3475
4506
  );
3476
- const providerProxy = (0, import_provider_proxy.getProviderProxy)({ llmConfig, apiKeyPool });
4507
+ const pricingStore = new JsonPricingStore(defaultPricingPath(paths.configPath));
4508
+ const pricingEngine = new import_usage.PricingEngine(pricingStore, logger);
4509
+ const usageEventStore = new JsonlUsageEventStore(
4510
+ defaultUsageEventsPath(paths.configPath),
4511
+ async (providerId, model) => await pricingEngine.getEntry(providerId, model) !== null
4512
+ );
4513
+ const usageRecorder = new import_usage.UsageRecorder(usageEventStore, pricingEngine, logger);
4514
+ const providerProxy = (0, import_provider_proxy.getProviderProxy)({ llmConfig, apiKeyPool, usageRecorder });
3477
4515
  llmConfig.setReloadHook(() => apiKeyPool.invalidateCache());
3478
4516
  const outboundApiServer = (0, import_outbound_api3.getOutboundApiServer)({
3479
4517
  db: keyDb,
@@ -3519,10 +4557,23 @@ function buildDaemon(config, paths) {
3519
4557
  // the multi-account append (`appendProviderAccount`, import re-encrypts at-
3520
4558
  // rest). Confined to the export/import handlers; never reached by a GET.
3521
4559
  migrationCredentialStore: credentialStore,
4560
+ // Code CLI launch (dashboard parity): the external-terminal opener + PATH probe
4561
+ // default to the real implementations; tests inject spies so no window spawns.
4562
+ cliTerminalOpener: paths.cliTerminalOpener,
4563
+ cliPathProbe: paths.cliPathProbe,
4564
+ cliCommandRunner: paths.cliCommandRunner,
4565
+ // Usage/pricing admin surface (usage-pricing child): stats queries go
4566
+ // through the recorder facade, pricing mutations through the engine, and
4567
+ // the row DELETE through the concrete store (delete is store-local — the
4568
+ // core port stays frozen). None of these can reach key material.
4569
+ usageRecorder,
4570
+ pricingEngine,
4571
+ pricingStore,
3522
4572
  // Use the DECRYPTED config so `admin.token` (if stored as `enc:`) is the
3523
4573
  // plaintext bearer the AdminServer's constant-time compare expects (D4).
3524
4574
  getAdminConfig: () => resolveAdminConfig(decryptedConfig.admin)
3525
4575
  });
4576
+ const tokenRefreshScheduler = new TokenRefreshScheduler(credentialStore, logger);
3526
4577
  return {
3527
4578
  logger,
3528
4579
  llmConfig,
@@ -3535,7 +4586,11 @@ function buildDaemon(config, paths) {
3535
4586
  credentialStore,
3536
4587
  subscriptionRegistry,
3537
4588
  subscriptionAccounts,
3538
- adminServer
4589
+ pricingStore,
4590
+ pricingEngine,
4591
+ usageRecorder,
4592
+ adminServer,
4593
+ tokenRefreshScheduler
3539
4594
  };
3540
4595
  }
3541
4596
  function resetDaemonSingletonsForTests() {
@@ -3625,7 +4680,6 @@ function mapCcrToOmnicross(ccr) {
3625
4680
  AdminServer,
3626
4681
  ConfigFileProviderConfigSource,
3627
4682
  ConsoleLogger,
3628
- DASHBOARD_HTML,
3629
4683
  DEFAULT_ADMIN_PORT,
3630
4684
  JsonApiServerSettingsStore,
3631
4685
  JsonOutboundKeyDb,