@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.js CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  __resetProviderProxyForTests,
12
12
  getProviderProxy
13
13
  } from "@omnicross/core/provider-proxy";
14
+ import { PricingEngine, UsageRecorder } from "@omnicross/core/usage";
14
15
  import {
15
16
  setSubscriptionAccountService,
16
17
  setSubscriptionProviderRegistry,
@@ -203,23 +204,23 @@ function decodeEnvKey(raw) {
203
204
  }
204
205
  return buf;
205
206
  }
206
- function readKeyFile(path) {
207
- const raw = readFileSync(path);
207
+ function readKeyFile(path2) {
208
+ const raw = readFileSync(path2);
208
209
  if (raw.length === KEY_BYTES2) return raw;
209
210
  const text = raw.toString("utf8").trim();
210
211
  if (/^[0-9a-fA-F]{64}$/.test(text)) return Buffer.from(text, "hex");
211
212
  const b64 = Buffer.from(text, "base64");
212
213
  if (b64.length === KEY_BYTES2) return b64;
213
214
  throw new Error(
214
- `master key file '${path}' is invalid: expected 32 raw bytes, 64 hex chars, or 32-byte base64`
215
+ `master key file '${path2}' is invalid: expected 32 raw bytes, 64 hex chars, or 32-byte base64`
215
216
  );
216
217
  }
217
- function generateKeyFile(path) {
218
+ function generateKeyFile(path2) {
218
219
  const key = randomBytes2(KEY_BYTES2);
219
- mkdirSync(dirname(path), { recursive: true });
220
- writeFileSync(path, key, { mode: 384 });
220
+ mkdirSync(dirname(path2), { recursive: true });
221
+ writeFileSync(path2, key, { mode: 384 });
221
222
  try {
222
- chmodSync(path, 384);
223
+ chmodSync(path2, 384);
223
224
  } catch {
224
225
  }
225
226
  return key;
@@ -598,25 +599,25 @@ var secretBox = null;
598
599
  function setSecretBox(box) {
599
600
  secretBox = box;
600
601
  }
601
- function loadConfig(path) {
602
+ function loadConfig(path2) {
602
603
  let raw;
603
604
  try {
604
- raw = readFileSync2(path, "utf8");
605
+ raw = readFileSync2(path2, "utf8");
605
606
  } catch {
606
- throw new Error(`config: cannot read file at '${path}'`);
607
+ throw new Error(`config: cannot read file at '${path2}'`);
607
608
  }
608
609
  let parsed;
609
610
  try {
610
611
  parsed = JSON.parse(raw);
611
612
  } catch {
612
- throw new Error(`config: '${path}' is not valid JSON`);
613
+ throw new Error(`config: '${path2}' is not valid JSON`);
613
614
  }
614
615
  const validated = validateConfig(parsed);
615
616
  return secretBox ? decryptConfigSecrets(validated, secretBox) : validated;
616
617
  }
617
- function saveConfig(path, cfg) {
618
+ function saveConfig(path2, cfg) {
618
619
  const toWrite = secretBox ? encryptConfigSecrets(cfg, secretBox) : cfg;
619
- writeFileSync2(path, JSON.stringify(toWrite, null, 2) + "\n", "utf8");
620
+ writeFileSync2(path2, JSON.stringify(toWrite, null, 2) + "\n", "utf8");
620
621
  }
621
622
 
622
623
  // src/pool/resolveEnvKey.ts
@@ -862,7 +863,8 @@ async function handleOAuthComplete(providerId, body, deps) {
862
863
  const reason = exchangeError instanceof Error ? exchangeError.message : "token exchange failed";
863
864
  return err2(502, `oauth token exchange failed for '${providerId}': ${reason}`);
864
865
  }
865
- await deps.subscriptionAccountAppender.appendProviderAccount(providerId, block);
866
+ const label = typeof body["label"] === "string" && body["label"].trim() ? body["label"].trim() : void 0;
867
+ await deps.subscriptionAccountAppender.appendProviderAccount(providerId, block, label);
866
868
  const status = await statusEntryFor(deps.subscriptionAccounts, providerId);
867
869
  return { status: 200, body: status ? { account: status } : { ok: true } };
868
870
  }
@@ -895,8 +897,207 @@ async function exchangeGemini(code, codeVerifier, exchangeFetch) {
895
897
  };
896
898
  }
897
899
 
898
- // src/ports/account-multi.ts
900
+ // src/admin/cliLaunch.ts
901
+ import { exec, spawn } from "child_process";
899
902
  import { randomUUID } from "crypto";
903
+ import { existsSync as existsSync2 } from "fs";
904
+ import { delimiter, join as join2 } from "path";
905
+ import {
906
+ buildChatCliLaunchConfig,
907
+ buildClaudeCliLaunchConfig,
908
+ buildCodexLaunchConfig,
909
+ buildGeminiCliLaunchConfig
910
+ } from "@omnicross/cli-launcher";
911
+ var LAUNCHABLE_CLIS = [
912
+ { id: "claude", displayName: "Claude Code", command: "claude" },
913
+ { id: "codex", displayName: "Codex CLI", command: "codex" },
914
+ { id: "gemini", displayName: "Gemini CLI", command: "gemini" },
915
+ { id: "qwen", displayName: "Qwen Code", command: "qwen" },
916
+ { id: "copilot", displayName: "GitHub Copilot CLI", command: "copilot" },
917
+ { id: "opencode", displayName: "OpenCode", command: "opencode" }
918
+ ];
919
+ var INSTALL_COMMANDS = {
920
+ claude: "npm install -g @anthropic-ai/claude-code",
921
+ codex: "npm install -g @openai/codex",
922
+ gemini: "npm install -g @google/gemini-cli",
923
+ qwen: "npm install -g @qwen-code/qwen-code",
924
+ copilot: "npm install -g @github/copilot",
925
+ opencode: "npm install -g opencode-ai"
926
+ };
927
+ var LAUNCHABLE_IDS = new Set(LAUNCHABLE_CLIS.map((c) => c.id));
928
+ function isLaunchCliId(id) {
929
+ return id !== void 0 && LAUNCHABLE_IDS.has(id);
930
+ }
931
+ function probeDefault(candidate) {
932
+ const segments = (process.env["PATH"] ?? "").split(delimiter).filter(Boolean);
933
+ for (const seg of segments) {
934
+ const full = join2(seg, candidate);
935
+ if (existsSync2(full)) return full;
936
+ }
937
+ return null;
938
+ }
939
+ function isCliInstalled(command, platform = process.platform, probe = probeDefault) {
940
+ if (platform === "win32") {
941
+ return Boolean(probe(`${command}.exe`) || probe(`${command}.cmd`) || probe(`${command}.bat`));
942
+ }
943
+ return Boolean(probe(command));
944
+ }
945
+ function detectClis(platform = process.platform, probe = probeDefault) {
946
+ return LAUNCHABLE_CLIS.map((c) => ({
947
+ id: c.id,
948
+ displayName: c.displayName,
949
+ command: c.command,
950
+ installed: isCliInstalled(c.command, platform, probe),
951
+ installable: Boolean(INSTALL_COMMANDS[c.id])
952
+ }));
953
+ }
954
+ function resolveLaunchTarget(providers, requested) {
955
+ 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));
956
+ if (!pick) {
957
+ throw new Error("no provider with a model is configured \u2014 add one on the Providers page first");
958
+ }
959
+ const model = requested?.model || firstModel(pick);
960
+ if (!model) {
961
+ throw new Error(`provider "${pick.id}" has no models \u2014 add a model on the Providers page first`);
962
+ }
963
+ return { providerId: pick.id, model };
964
+ }
965
+ function firstModel(p) {
966
+ return p.models?.[0] ?? p.modelConfigs?.[0]?.id;
967
+ }
968
+ async function buildLaunchEnv(cli, llmConfig, target) {
969
+ const common = {
970
+ llmConfig,
971
+ providerId: target.providerId,
972
+ model: target.model,
973
+ sessionId: `dashboard:${cli}`
974
+ };
975
+ switch (cli) {
976
+ case "claude":
977
+ return buildClaudeCliLaunchConfig(common);
978
+ case "codex":
979
+ return buildCodexLaunchConfig(common);
980
+ case "gemini":
981
+ return buildGeminiCliLaunchConfig(common);
982
+ case "qwen":
983
+ case "copilot":
984
+ case "opencode":
985
+ return buildChatCliLaunchConfig({ backendId: cli, ...common });
986
+ }
987
+ }
988
+ function shq(s) {
989
+ return `'${s.replace(/'/g, `'\\''`)}'`;
990
+ }
991
+ var defaultTerminalOpener = ({ cli, command, extraArgs, env, cwd, platform }) => {
992
+ const childEnv = { ...process.env, ...env };
993
+ if (platform === "win32") {
994
+ const args = ["/c", "start", `"omnicross ${cli}"`];
995
+ if (cwd) args.push("/D", `"${cwd}"`);
996
+ args.push("cmd", "/k", command, ...extraArgs);
997
+ spawn(process.env["ComSpec"] || "cmd.exe", args, {
998
+ env: childEnv,
999
+ windowsVerbatimArguments: true,
1000
+ detached: true,
1001
+ stdio: "ignore"
1002
+ }).unref();
1003
+ return;
1004
+ }
1005
+ const exportLine = Object.entries(env).map(([k, v]) => `export ${k}=${shq(v)}`).join("; ");
1006
+ const runLine = [command, ...extraArgs].map(shq).join(" ");
1007
+ const script = `${exportLine}; ${cwd ? `cd ${shq(cwd)}; ` : ""}${runLine}`;
1008
+ if (platform === "darwin") {
1009
+ const osa = `tell application "Terminal" to do script ${JSON.stringify(script)}`;
1010
+ spawn("osascript", ["-e", osa], { detached: true, stdio: "ignore" }).unref();
1011
+ return;
1012
+ }
1013
+ spawn("x-terminal-emulator", ["-e", "bash", "-lc", `${script}; exec bash`], {
1014
+ detached: true,
1015
+ stdio: "ignore"
1016
+ }).unref();
1017
+ };
1018
+ var sessions = /* @__PURE__ */ new Map();
1019
+ function errBody(message) {
1020
+ return { error: { type: "admin_api_error", message } };
1021
+ }
1022
+ var defaultCommandRunner = (command) => new Promise((resolve) => {
1023
+ exec(command, { timeout: 18e4 }, (err5, _stdout, stderr) => {
1024
+ if (err5) resolve({ ok: false, error: stderr.trim() || err5.message });
1025
+ else resolve({ ok: true });
1026
+ });
1027
+ });
1028
+ async function handleCliInstall(cli, runner = defaultCommandRunner) {
1029
+ const cmd = INSTALL_COMMANDS[cli];
1030
+ if (!cmd) {
1031
+ return { status: 400, body: errBody(`no install command for cli '${cli}' (manual install only)`) };
1032
+ }
1033
+ const result = await runner(cmd);
1034
+ if (!result.ok) {
1035
+ return { status: 500, body: errBody(result.error || "install failed") };
1036
+ }
1037
+ return { status: 200, body: { ok: true } };
1038
+ }
1039
+ function handleCliList(platform = process.platform, probe = probeDefault) {
1040
+ return { status: 200, body: { clis: detectClis(platform, probe) } };
1041
+ }
1042
+ function handleCliSessions() {
1043
+ const list = [...sessions.values()].map(({ onSessionEnd: _drop, ...rest }) => rest);
1044
+ return { status: 200, body: { sessions: list } };
1045
+ }
1046
+ function handleCliStop(id) {
1047
+ const s = sessions.get(id);
1048
+ if (!s) return { status: 404, body: errBody(`session '${id}' not found`) };
1049
+ try {
1050
+ s.onSessionEnd();
1051
+ } catch {
1052
+ }
1053
+ sessions.delete(id);
1054
+ return { status: 200, body: { ok: true } };
1055
+ }
1056
+ async function handleCliLaunch(cli, body, ctx) {
1057
+ const platform = ctx.platform ?? process.platform;
1058
+ const probe = ctx.probe ?? probeDefault;
1059
+ const meta = LAUNCHABLE_CLIS.find((c) => c.id === cli);
1060
+ if (!meta) return { status: 404, body: errBody(`unknown cli '${cli}'`) };
1061
+ if (!isCliInstalled(meta.command, platform, probe)) {
1062
+ return { status: 400, body: errBody(`"${meta.command}" is not installed (not found on PATH)`) };
1063
+ }
1064
+ let target;
1065
+ try {
1066
+ target = resolveLaunchTarget(ctx.providers, {
1067
+ providerId: typeof body["providerId"] === "string" ? body["providerId"] : void 0,
1068
+ model: typeof body["model"] === "string" ? body["model"] : void 0
1069
+ });
1070
+ } catch (err5) {
1071
+ return { status: 400, body: errBody(err5 instanceof Error ? err5.message : "no launch target") };
1072
+ }
1073
+ let launch;
1074
+ try {
1075
+ launch = await buildLaunchEnv(cli, ctx.llmConfig, target);
1076
+ } catch (err5) {
1077
+ return { status: 400, body: errBody(err5 instanceof Error ? err5.message : "failed to build launch env") };
1078
+ }
1079
+ const cwd = typeof body["cwd"] === "string" && body["cwd"].trim() ? body["cwd"].trim() : void 0;
1080
+ const opener = ctx.opener ?? defaultTerminalOpener;
1081
+ try {
1082
+ opener({ cli, command: meta.command, extraArgs: launch.extraArgs ?? [], env: launch.env, cwd, platform });
1083
+ } catch (err5) {
1084
+ launch.onSessionEnd();
1085
+ return { status: 500, body: errBody(err5 instanceof Error ? err5.message : "failed to open terminal") };
1086
+ }
1087
+ const id = randomUUID();
1088
+ sessions.set(id, {
1089
+ id,
1090
+ cli,
1091
+ providerId: target.providerId,
1092
+ model: target.model,
1093
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
1094
+ onSessionEnd: launch.onSessionEnd
1095
+ });
1096
+ return { status: 200, body: { sessionId: id, providerId: target.providerId, model: target.model } };
1097
+ }
1098
+
1099
+ // src/ports/account-multi.ts
1100
+ import { randomUUID as randomUUID2 } from "crypto";
900
1101
  var PROVIDER_KEYS = {
901
1102
  claude: { block: "claude", accounts: "claudeAccounts", active: "activeClaudeAccountId" },
902
1103
  codex: { block: "codex", accounts: "codexAccounts", active: "activeCodexAccountId" },
@@ -962,7 +1163,7 @@ function migrateLazily(config) {
962
1163
  }
963
1164
  function addAccount(config, p, tokens, label) {
964
1165
  const accounts = [...getAccounts(config, p)];
965
- const id = randomUUID();
1166
+ const id = randomUUID2();
966
1167
  accounts.push({
967
1168
  id,
968
1169
  label: label ?? `Account ${accounts.length + 1}`,
@@ -999,6 +1200,13 @@ function setActiveAccount(config, p, id) {
999
1200
  deriveMirror(config, p);
1000
1201
  return { ok: true };
1001
1202
  }
1203
+ function listAccounts(config, p) {
1204
+ return getAccounts(config, p);
1205
+ }
1206
+ function getAccountById(config, p, id) {
1207
+ const account = getAccounts(config, p).find((a) => a.id === id);
1208
+ return account ? { id: account.id, tokens: account.tokens } : void 0;
1209
+ }
1002
1210
  function getActiveAccount(config, p) {
1003
1211
  const active = getAccounts(config, p).find((a) => a.id === getActiveId(config, p));
1004
1212
  return active ? { id: active.id, tokens: active.tokens } : void 0;
@@ -1032,12 +1240,27 @@ function sanitizeAccounts(config, p) {
1032
1240
  id: a.id,
1033
1241
  label: a.label,
1034
1242
  status: t.status ?? "unconfigured",
1243
+ authMethod: t.authMethod,
1244
+ subscriptionLevel: t.subscriptionLevel,
1035
1245
  expiresAt: t.expiresAt,
1246
+ lastRefreshedAt: t.lastRefreshedAt,
1247
+ isSetupToken: t.isSetupToken,
1036
1248
  hasAccessToken: !!(t.accessToken || t.apiKey),
1037
- isActive: a.id === activeId
1249
+ isActive: a.id === activeId,
1250
+ syncWarning: t.syncWarning
1038
1251
  };
1039
1252
  });
1040
1253
  }
1254
+ function renameAccount(config, p, id, label) {
1255
+ const accounts = getAccounts(config, p);
1256
+ if (!accounts.some((a) => a.id === id)) return { ok: false };
1257
+ setAccounts(
1258
+ config,
1259
+ p,
1260
+ accounts.map((a) => a.id === id ? { ...a, label } : a)
1261
+ );
1262
+ return { ok: true };
1263
+ }
1041
1264
  function clearProvider(config, p) {
1042
1265
  setBlock(config, p, void 0);
1043
1266
  setAccounts(config, p, void 0);
@@ -1284,6 +1507,169 @@ async function handleImport(body, deps) {
1284
1507
  }
1285
1508
  }
1286
1509
 
1510
+ // src/admin/usagePricing.ts
1511
+ var err4 = (status, message) => ({
1512
+ status,
1513
+ body: { error: { type: "admin_api_error", message } }
1514
+ });
1515
+ function parseFiniteInt(raw) {
1516
+ if (raw === null || raw.trim() === "") return null;
1517
+ const n = Number(raw);
1518
+ return Number.isFinite(n) && Number.isInteger(n) ? n : null;
1519
+ }
1520
+ function parseRange(query) {
1521
+ const startTs = parseFiniteInt(query.get("startTs"));
1522
+ const endTs = parseFiniteInt(query.get("endTs"));
1523
+ if (startTs === null || endTs === null) {
1524
+ return err4(400, "startTs and endTs are required finite-integer unix-millis query params");
1525
+ }
1526
+ return { startTs, endTs };
1527
+ }
1528
+ var isRange = (v) => v.startTs !== void 0 && !("status" in v);
1529
+ async function handleUsageGet(view, query, deps) {
1530
+ const range = parseRange(query);
1531
+ if (!isRange(range)) return range;
1532
+ switch (view) {
1533
+ case "totals":
1534
+ return { status: 200, body: await deps.usageRecorder.getTotals(range) };
1535
+ case "by-model":
1536
+ return { status: 200, body: await deps.usageRecorder.getByModel(range) };
1537
+ case "by-api-key": {
1538
+ const rows = await deps.usageRecorder.getByApiKey(range);
1539
+ const labels = poolKeyLabels(loadConfig(deps.configPath));
1540
+ return {
1541
+ status: 200,
1542
+ body: rows.map((r) => {
1543
+ if (r.apiKeyId === null) {
1544
+ return { ...r, label: "unattributed", providerId: null };
1545
+ }
1546
+ const known = labels.get(r.apiKeyId);
1547
+ return known ? { ...r, label: known.label, providerId: known.providerId } : { ...r, label: r.apiKeyId };
1548
+ })
1549
+ };
1550
+ }
1551
+ default:
1552
+ return err4(404, `unknown usage view '${view ?? ""}'`);
1553
+ }
1554
+ }
1555
+ function poolKeyLabels(cfg) {
1556
+ const out = /* @__PURE__ */ new Map();
1557
+ for (const provider of cfg.providers) {
1558
+ for (const key of provider.apiKeys ?? []) {
1559
+ out.set(key.id, {
1560
+ label: key.label && key.label.length > 0 ? key.label : key.id,
1561
+ providerId: provider.id
1562
+ });
1563
+ }
1564
+ }
1565
+ return out;
1566
+ }
1567
+ var INVALID_PRICE = /* @__PURE__ */ Symbol("invalid-price");
1568
+ function parseOptionalPrice(b, key) {
1569
+ if (!(key in b) || b[key] === null || b[key] === void 0) return null;
1570
+ const v = b[key];
1571
+ return typeof v === "number" && Number.isFinite(v) ? v : INVALID_PRICE;
1572
+ }
1573
+ function parsePricingEntryInput(raw) {
1574
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
1575
+ const b = raw;
1576
+ const providerId = typeof b["providerId"] === "string" && b["providerId"].trim() ? b["providerId"].trim() : "";
1577
+ const modelId = typeof b["modelId"] === "string" && b["modelId"].trim() ? b["modelId"].trim() : "";
1578
+ const inputPrice = b["inputPricePer1m"];
1579
+ const outputPrice = b["outputPricePer1m"];
1580
+ if (!providerId || !modelId) return null;
1581
+ if (typeof inputPrice !== "number" || !Number.isFinite(inputPrice)) return null;
1582
+ if (typeof outputPrice !== "number" || !Number.isFinite(outputPrice)) return null;
1583
+ const cacheRead = parseOptionalPrice(b, "cacheReadPricePer1m");
1584
+ const cacheWrite = parseOptionalPrice(b, "cacheWritePricePer1m");
1585
+ if (cacheRead === INVALID_PRICE || cacheWrite === INVALID_PRICE) return null;
1586
+ return {
1587
+ providerId,
1588
+ modelId,
1589
+ inputPricePer1m: inputPrice,
1590
+ outputPricePer1m: outputPrice,
1591
+ cacheReadPricePer1m: cacheRead,
1592
+ cacheWritePricePer1m: cacheWrite
1593
+ };
1594
+ }
1595
+ async function handlePricingList(deps) {
1596
+ return { status: 200, body: { entries: await deps.pricingEngine.getAll() } };
1597
+ }
1598
+ async function handlePricingUpsert(body, deps) {
1599
+ const input = parsePricingEntryInput(body);
1600
+ if (!input) {
1601
+ return err4(400, "invalid pricing entry (providerId, modelId, finite numeric inputPricePer1m/outputPricePer1m required)");
1602
+ }
1603
+ const entry = await deps.pricingEngine.upsertManual(input);
1604
+ return { status: 200, body: { entry } };
1605
+ }
1606
+ async function handlePricingDelete(query, deps) {
1607
+ const providerId = query.get("providerId")?.trim() ?? "";
1608
+ const modelId = query.get("modelId")?.trim() ?? "";
1609
+ if (!providerId || !modelId) {
1610
+ return err4(400, "delete requires providerId and modelId query params");
1611
+ }
1612
+ const deleted = await deps.pricingStore.delete(providerId, modelId);
1613
+ if (deleted) await deps.pricingEngine.invalidateCache();
1614
+ return { status: 200, body: { deleted } };
1615
+ }
1616
+ async function handlePricingFetchLatest(deps) {
1617
+ try {
1618
+ const result = await deps.pricingEngine.fetchLatestFromSource();
1619
+ return {
1620
+ status: 200,
1621
+ body: {
1622
+ appliedCount: result.applied.length,
1623
+ conflicts: result.conflicts,
1624
+ fetchedAt: result.fetchedAt,
1625
+ sourceUrl: result.sourceUrl
1626
+ }
1627
+ };
1628
+ } catch (e) {
1629
+ return err4(502, `pricing-source fetch failed: ${e instanceof Error ? e.message : String(e)}`);
1630
+ }
1631
+ }
1632
+ async function handlePricingResolveConflicts(body, deps) {
1633
+ const raw = body["resolutions"];
1634
+ if (!Array.isArray(raw)) {
1635
+ return err4(400, "resolve-conflicts requires { resolutions: [...] }");
1636
+ }
1637
+ const currentRows = await deps.pricingStore.getAll();
1638
+ const userEditedKeys = new Set(
1639
+ currentRows.filter((r) => r.userEdited).map((r) => `${r.providerId}::${r.modelId}`)
1640
+ );
1641
+ const decisions = [];
1642
+ const pendingIncoming = /* @__PURE__ */ new Map();
1643
+ let staleCount = 0;
1644
+ for (const item of raw) {
1645
+ if (!item || typeof item !== "object") return err4(400, "invalid resolution entry");
1646
+ const r = item;
1647
+ const action = r["action"];
1648
+ if (action !== "overwrite" && action !== "skip") {
1649
+ return err4(400, "resolution action must be 'overwrite' or 'skip'");
1650
+ }
1651
+ const providerId = typeof r["providerId"] === "string" && r["providerId"].trim() ? r["providerId"].trim() : "";
1652
+ const modelId = typeof r["modelId"] === "string" && r["modelId"].trim() ? r["modelId"].trim() : "";
1653
+ if (!providerId || !modelId) {
1654
+ return err4(400, "each resolution requires top-level providerId and modelId");
1655
+ }
1656
+ const incoming = parsePricingEntryInput(r["incoming"]);
1657
+ if (!incoming) return err4(400, "each resolution must echo a valid incoming pricing entry");
1658
+ if (incoming.providerId !== providerId || incoming.modelId !== modelId) {
1659
+ return err4(400, "resolution providerId/modelId must match the echoed incoming entry");
1660
+ }
1661
+ const key = `${providerId}::${modelId}`;
1662
+ if (action === "overwrite" && !userEditedKeys.has(key)) {
1663
+ staleCount += 1;
1664
+ continue;
1665
+ }
1666
+ decisions.push({ providerId, modelId, action });
1667
+ pendingIncoming.set(key, incoming);
1668
+ }
1669
+ const resolution = await deps.pricingEngine.resolveConflicts(decisions, pendingIncoming);
1670
+ return { status: 200, body: { ...resolution, staleCount } };
1671
+ }
1672
+
1287
1673
  // src/admin/adminApi.ts
1288
1674
  function readBody(req) {
1289
1675
  return new Promise((resolve, reject) => {
@@ -1374,9 +1760,9 @@ function toProviderView(row) {
1374
1760
  selectedApiModeId: row.selectedApiModeId
1375
1761
  };
1376
1762
  }
1377
- async function handleAdminApi(req, res, path, deps) {
1763
+ async function handleAdminApi(req, res, path2, deps) {
1378
1764
  const method = (req.method ?? "GET").toUpperCase();
1379
- const sub = path.slice("/admin/api/".length);
1765
+ const sub = path2.slice("/admin/api/".length);
1380
1766
  const [resource, ...rest] = sub.split("/").filter((s) => s.length > 0);
1381
1767
  try {
1382
1768
  switch (resource) {
@@ -1390,6 +1776,8 @@ async function handleAdminApi(req, res, path, deps) {
1390
1776
  return await handleServer(req, res, method, deps);
1391
1777
  case "accounts":
1392
1778
  return await handleAccounts(req, res, method, rest, deps);
1779
+ case "cli":
1780
+ return await handleCli(req, res, method, rest, deps);
1393
1781
  case "status":
1394
1782
  return await handleStatus(res, method, deps);
1395
1783
  case "playground":
@@ -1398,13 +1786,48 @@ async function handleAdminApi(req, res, path, deps) {
1398
1786
  return await handleMigrationExport(req, res, method, deps);
1399
1787
  case "import":
1400
1788
  return await handleMigrationImport(req, res, method, deps);
1789
+ case "usage":
1790
+ return await handleUsage(req, res, method, rest, deps);
1791
+ case "pricing":
1792
+ return await handlePricing(req, res, method, rest, deps);
1401
1793
  default:
1402
1794
  return writeJsonError(res, 404, `unknown admin resource '${resource}'`);
1403
1795
  }
1404
- } catch (err4) {
1405
- writeJsonError(res, 500, err4 instanceof Error ? err4.message : String(err4));
1796
+ } catch (err5) {
1797
+ writeJsonError(res, 500, err5 instanceof Error ? err5.message : String(err5));
1406
1798
  }
1407
1799
  }
1800
+ function requestQuery(req) {
1801
+ const raw = req.url ?? "";
1802
+ const qIdx = raw.indexOf("?");
1803
+ return new URLSearchParams(qIdx >= 0 ? raw.slice(qIdx + 1) : "");
1804
+ }
1805
+ function writeResult(res, result) {
1806
+ writeJson(res, result.status, result.body);
1807
+ }
1808
+ async function handleUsage(req, res, method, rest, deps) {
1809
+ if (method !== "GET") return writeJsonError(res, 405, `method ${method} not allowed on usage`);
1810
+ return writeResult(res, await handleUsageGet(rest[0], requestQuery(req), deps));
1811
+ }
1812
+ async function handlePricing(req, res, method, rest, deps) {
1813
+ if (rest.length === 0) {
1814
+ if (method === "GET") return writeResult(res, await handlePricingList(deps));
1815
+ if (method === "PUT") {
1816
+ return writeResult(res, await handlePricingUpsert(await readJsonBody(req), deps));
1817
+ }
1818
+ if (method === "DELETE") {
1819
+ return writeResult(res, await handlePricingDelete(requestQuery(req), deps));
1820
+ }
1821
+ return writeJsonError(res, 405, `method ${method} not allowed on pricing`);
1822
+ }
1823
+ if (method === "POST" && rest.length === 1 && rest[0] === "fetch-latest") {
1824
+ return writeResult(res, await handlePricingFetchLatest(deps));
1825
+ }
1826
+ if (method === "POST" && rest.length === 1 && rest[0] === "resolve-conflicts") {
1827
+ return writeResult(res, await handlePricingResolveConflicts(await readJsonBody(req), deps));
1828
+ }
1829
+ return writeJsonError(res, 404, `unknown pricing route '${rest.join("/")}'`);
1830
+ }
1408
1831
  function migrationDeps(deps) {
1409
1832
  return {
1410
1833
  configPath: deps.configPath,
@@ -1451,6 +1874,11 @@ async function handleProviders(req, res, method, rest, deps) {
1451
1874
  if (method === "POST" && rest.length === 2 && rest[1] === "test") {
1452
1875
  return await handleTestModel(req, res, rest[0], cfg);
1453
1876
  }
1877
+ if (method === "GET" && rest.length === 2 && rest[1] === "reveal-key") {
1878
+ const row = cfg.providers.find((p) => p.id === rest[0]);
1879
+ if (!row) return writeJsonError(res, 404, `provider '${rest[0]}' not found`);
1880
+ return writeJson(res, 200, { apiKey: row.apiKey ?? "" });
1881
+ }
1454
1882
  if (method === "GET") {
1455
1883
  return writeJson(res, 200, { providers: cfg.providers.map(toProviderView) });
1456
1884
  }
@@ -1546,8 +1974,8 @@ async function handleDiscoverModels(res, id, cfg) {
1546
1974
  const data = await response.json();
1547
1975
  const models = Array.isArray(data?.data) ? data.data.map((m) => typeof m?.id === "string" ? m.id : "").filter((m) => m.length > 0) : [];
1548
1976
  return writeJson(res, 200, { models });
1549
- } catch (err4) {
1550
- const message = err4 instanceof Error ? err4.message : String(err4);
1977
+ } catch (err5) {
1978
+ const message = err5 instanceof Error ? err5.message : String(err5);
1551
1979
  return writeJson(res, 200, { models: [], error: `discovery failed: ${message}` });
1552
1980
  }
1553
1981
  }
@@ -1606,8 +2034,8 @@ async function handleTestModel(req, res, id, cfg) {
1606
2034
  latencyMs,
1607
2035
  sample: extractSampleText(text, row.apiFormat)
1608
2036
  });
1609
- } catch (err4) {
1610
- const message = err4 instanceof Error ? err4.message : String(err4);
2037
+ } catch (err5) {
2038
+ const message = err5 instanceof Error ? err5.message : String(err5);
1611
2039
  return writeJson(res, 200, { ok: false, latencyMs: Date.now() - startedAt, message });
1612
2040
  }
1613
2041
  }
@@ -1956,7 +2384,8 @@ async function handleAccounts(req, res, method, rest, deps) {
1956
2384
  if (method === "GET" && rest.length === 0) {
1957
2385
  const accounts = await deps.subscriptionAccounts.listAll();
1958
2386
  const providerAccounts = await deps.subscriptionTokenWriter.listSanitizedAccounts();
1959
- return writeJson(res, 200, { accounts, providerAccounts });
2387
+ const externalCli = await deps.subscriptionTokenWriter.listExternalCliAvailability();
2388
+ return writeJson(res, 200, { accounts, providerAccounts, externalCli });
1960
2389
  }
1961
2390
  if (method === "GET" && rest[0] === "codex" && rest[1] === "oauth" && rest[3] === "status") {
1962
2391
  const result = handleCodexOAuthStatus(rest[2], deps);
@@ -1976,6 +2405,47 @@ async function handleAccounts(req, res, method, rest, deps) {
1976
2405
  const result = await handleOAuthComplete(providerId, body2, deps);
1977
2406
  return writeJson(res, result.status, result.body);
1978
2407
  }
2408
+ if (method === "POST" && rest[1] === "accounts") {
2409
+ const body2 = await readJsonBody(req);
2410
+ const block = validateTokenBody(providerId, body2);
2411
+ if (!block) {
2412
+ return writeJsonError(res, 400, `malformed token body for provider '${providerId}'`);
2413
+ }
2414
+ const label = typeof body2["label"] === "string" && body2["label"].trim() ? body2["label"].trim() : void 0;
2415
+ await deps.subscriptionAccountAppender.appendProviderAccount(providerId, block, label);
2416
+ const status2 = await statusEntryFor(deps.subscriptionAccounts, providerId);
2417
+ return writeJson(res, 200, status2 ? { account: status2 } : { ok: true });
2418
+ }
2419
+ if (method === "POST" && rest[1] === "import-external") {
2420
+ if (providerId !== "claude" && providerId !== "codex") {
2421
+ return writeJsonError(res, 400, `provider '${providerId}' has no external CLI store`);
2422
+ }
2423
+ const body2 = await readJsonBody(req);
2424
+ const label = typeof body2["label"] === "string" && body2["label"].trim() ? body2["label"].trim() : void 0;
2425
+ const result = await deps.subscriptionTokenWriter.importExternalCliAccount(providerId, label);
2426
+ if (!result.ok) {
2427
+ return writeJsonError(res, 409, `no usable external ${providerId} CLI credential found`);
2428
+ }
2429
+ const status2 = await statusEntryFor(deps.subscriptionAccounts, providerId);
2430
+ return writeJson(res, 200, { ok: true, account: status2 ?? void 0 });
2431
+ }
2432
+ if (method === "POST" && rest[1] === "refresh") {
2433
+ if (providerId === "opencodego") {
2434
+ return writeJsonError(res, 400, "opencodego credentials are not refreshable");
2435
+ }
2436
+ const writer = deps.subscriptionTokenWriter;
2437
+ const ok = providerId === "claude" ? await writer.refreshClaudeToken() : providerId === "codex" ? await writer.refreshCodexToken() : await writer.refreshGeminiToken();
2438
+ const status2 = await statusEntryFor(deps.subscriptionAccounts, providerId);
2439
+ return writeJson(res, 200, { ok, account: status2 ?? void 0 });
2440
+ }
2441
+ if (method === "POST" && rest[2] === "label") {
2442
+ const accountId = rest[1];
2443
+ const body2 = await readJsonBody(req);
2444
+ const label = typeof body2["label"] === "string" ? body2["label"].trim() : "";
2445
+ const result = await deps.subscriptionTokenWriter.renameAccount(providerId, accountId, label);
2446
+ if (!result.ok) return writeJsonError(res, 404, `account '${accountId}' not found`);
2447
+ return writeJson(res, 200, { ok: true });
2448
+ }
1979
2449
  if (method === "PUT" && rest[1] === "active") {
1980
2450
  const body2 = await readJsonBody(req);
1981
2451
  const id = typeof body2["id"] === "string" ? body2["id"] : "";
@@ -2005,6 +2475,44 @@ async function handleAccounts(req, res, method, rest, deps) {
2005
2475
  }
2006
2476
  return writeJsonError(res, 405, `method ${method} not allowed on accounts`);
2007
2477
  }
2478
+ async function handleCli(req, res, method, rest, deps) {
2479
+ if (method === "GET" && rest.length === 0) {
2480
+ const result = handleCliList(process.platform, deps.cliPathProbe);
2481
+ return writeJson(res, result.status, result.body);
2482
+ }
2483
+ if (method === "GET" && rest[0] === "sessions") {
2484
+ const result = handleCliSessions();
2485
+ return writeJson(res, result.status, result.body);
2486
+ }
2487
+ if (method === "DELETE" && rest[0] === "sessions" && rest[1]) {
2488
+ const result = handleCliStop(rest[1]);
2489
+ return writeJson(res, result.status, result.body);
2490
+ }
2491
+ if (method === "POST" && rest[1] === "install") {
2492
+ const cli = rest[0];
2493
+ if (!isLaunchCliId(cli)) {
2494
+ return writeJsonError(res, 400, `unknown cli '${cli ?? ""}'`);
2495
+ }
2496
+ const result = await handleCliInstall(cli, deps.cliCommandRunner);
2497
+ return writeJson(res, result.status, result.body);
2498
+ }
2499
+ if (method === "POST" && rest[1] === "launch") {
2500
+ const cli = rest[0];
2501
+ if (!isLaunchCliId(cli)) {
2502
+ return writeJsonError(res, 400, `unknown cli '${cli ?? ""}'`);
2503
+ }
2504
+ const body = await readJsonBody(req);
2505
+ const providers = loadConfig(deps.configPath).providers ?? [];
2506
+ const result = await handleCliLaunch(cli, body, {
2507
+ llmConfig: deps.llmConfig,
2508
+ providers,
2509
+ opener: deps.cliTerminalOpener,
2510
+ probe: deps.cliPathProbe
2511
+ });
2512
+ return writeJson(res, result.status, result.body);
2513
+ }
2514
+ return writeJsonError(res, 405, `method ${method} not allowed on cli`);
2515
+ }
2008
2516
  async function handleStatus(res, method, deps) {
2009
2517
  if (method !== "GET") return writeJsonError(res, 405, `method ${method} not allowed on status`);
2010
2518
  const status = deps.outboundApiServer.getStatus();
@@ -2040,21 +2548,21 @@ async function handlePlayground(req, res, method, deps) {
2040
2548
  const payload = body["body"];
2041
2549
  const status = deps.outboundApiServer.getStatus();
2042
2550
  if (!status.running || !status.port) return writeJsonError(res, 503, "outbound server not running");
2043
- const path = resolvePlaygroundPath(endpoint, isRecord(payload) ? payload : {});
2044
- if (!path) return writeJsonError(res, 400, `unknown endpoint '${endpoint}'`);
2551
+ const path2 = resolvePlaygroundPath(endpoint, isRecord(payload) ? payload : {});
2552
+ if (!path2) return writeJsonError(res, 400, `unknown endpoint '${endpoint}'`);
2045
2553
  const upstreamBody = typeof payload === "string" ? payload : JSON.stringify(payload ?? {});
2046
- await proxyToOutbound(res, status.port, path, key, upstreamBody);
2554
+ await proxyToOutbound(res, status.port, path2, key, upstreamBody);
2047
2555
  }
2048
2556
  function isRecord(v) {
2049
2557
  return !!v && typeof v === "object" && !Array.isArray(v);
2050
2558
  }
2051
- function proxyToOutbound(res, outboundPort, path, key, body) {
2559
+ function proxyToOutbound(res, outboundPort, path2, key, body) {
2052
2560
  return new Promise((resolve) => {
2053
2561
  const upstream = http.request(
2054
2562
  {
2055
2563
  host: "127.0.0.1",
2056
2564
  port: outboundPort,
2057
- path,
2565
+ path: path2,
2058
2566
  method: "POST",
2059
2567
  headers: {
2060
2568
  "Content-Type": "application/json",
@@ -2074,8 +2582,8 @@ function proxyToOutbound(res, outboundPort, path, key, body) {
2074
2582
  });
2075
2583
  }
2076
2584
  );
2077
- upstream.on("error", (err4) => {
2078
- if (!res.headersSent) writeJsonError(res, 502, `playground proxy failed: ${err4.message}`);
2585
+ upstream.on("error", (err5) => {
2586
+ if (!res.headersSent) writeJsonError(res, 502, `playground proxy failed: ${err5.message}`);
2079
2587
  else res.end();
2080
2588
  resolve();
2081
2589
  });
@@ -2084,463 +2592,105 @@ function proxyToOutbound(res, outboundPort, path, key, body) {
2084
2592
  });
2085
2593
  }
2086
2594
 
2087
- // src/admin/client.ts
2088
- var DASHBOARD_JS = String.raw`
2089
- (function () {
2090
- var $ = function (id) { return document.getElementById(id); };
2091
- var authToken = null; // set if a 401 ever comes back (token-gated deploys)
2092
-
2093
- function headers(extra) {
2094
- var h = extra || {};
2095
- if (authToken) h['Authorization'] = 'Bearer ' + authToken;
2096
- return h;
2097
- }
2098
-
2099
- async function api(method, path, body) {
2100
- var opt = { method: method, headers: headers(body ? { 'Content-Type': 'application/json' } : {}) };
2101
- if (body) opt.body = JSON.stringify(body);
2102
- var res = await fetch('/admin/api/' + path, opt);
2103
- if (res.status === 401 && !authToken) {
2104
- var t = window.prompt('Admin token required');
2105
- if (t) { authToken = t; return api(method, path, body); }
2106
- }
2107
- var text = await res.text();
2108
- var json = null;
2109
- try { json = text ? JSON.parse(text) : null; } catch (e) { json = { raw: text }; }
2110
- return { status: res.status, json: json };
2111
- }
2112
-
2113
- function clear(el) { while (el.firstChild) el.removeChild(el.firstChild); }
2114
- function td(text) { var c = document.createElement('td'); c.textContent = text == null ? '' : String(text); return c; }
2115
- function btn(label, cls, fn) { var b = document.createElement('button'); b.textContent = label; if (cls) b.className = cls; b.onclick = fn; return b; }
2116
-
2117
- // ── Status ────────────────────────────────────────────────────────────────
2118
- async function loadStatus() {
2119
- var r = await api('GET', 'status');
2120
- var s = r.json || {};
2121
- $('statusBadge').textContent = s.running ? ('running :' + s.port) : 'stopped';
2122
- var html = '';
2123
- if (s.running) {
2124
- html += 'Outbound server <span class="pill ok">running</span> on port ' + s.port + '<br/>';
2125
- if (s.formats) {
2126
- html += '<div class="mono muted">' +
2127
- 'chat: ' + s.formats.chat + '<br/>responses: ' + s.formats.responses +
2128
- '<br/>messages: ' + s.formats.messages + '<br/>gemini: ' + s.formats.gemini + '</div>';
2129
- }
2130
- } else {
2131
- html += 'Outbound server <span class="pill bad">stopped</span>';
2132
- }
2133
- $('statusBody').innerHTML = html;
2134
- }
2135
-
2136
- // ── Providers ───────────────────────────────────────────────────────────────
2137
- // Curated presets loaded from GET /admin/api/presets. Selecting one prefills
2138
- // the add-form (format/base/models); the WRITE still goes through the existing
2139
- // POST/PUT /admin/api/providers path (no new write endpoint).
2140
- var presetsById = {};
2141
- var presetModels = []; // models staged by the last preset prefill
2142
-
2143
- async function loadPresets() {
2144
- var r = await api('GET', 'presets');
2145
- var sel = $('pPreset');
2146
- // Keep the placeholder; drop any previously appended options.
2147
- while (sel.options.length > 1) sel.remove(1);
2148
- presetsById = {};
2149
- (r.json && r.json.presets || []).forEach(function (p) {
2150
- presetsById[p.id] = p;
2151
- var opt = document.createElement('option');
2152
- opt.value = p.id;
2153
- opt.textContent = p.name + ' (' + p.apiFormat + ')';
2154
- sel.appendChild(opt);
2155
- });
2156
- }
2157
-
2158
- function onPresetChange() {
2159
- var p = presetsById[$('pPreset').value];
2160
- if (!p) { presetModels = []; return; }
2161
- if (!$('pId').value.trim()) $('pId').value = p.id;
2162
- $('pFormat').value = p.apiFormat;
2163
- $('pBase').value = p.baseUrl;
2164
- presetModels = Array.isArray(p.models) ? p.models.slice() : [];
2165
- }
2166
-
2167
- async function loadProviders() {
2168
- var r = await api('GET', 'providers');
2169
- var body = $('providersTable').querySelector('tbody');
2170
- clear(body);
2171
- (r.json && r.json.providers || []).forEach(function (p) {
2172
- var tr = document.createElement('tr');
2173
- tr.appendChild(td(p.id));
2174
- tr.appendChild(td(p.apiFormat));
2175
- tr.appendChild(td(p.baseUrl));
2176
- tr.appendChild(td(p.hasApiKey ? p.apiKeyMasked : '(none)'));
2177
- var act = document.createElement('td');
2178
- act.appendChild(btn('Edit', 'secondary', function () {
2179
- $('pId').value = p.id; $('pFormat').value = p.apiFormat; $('pBase').value = p.baseUrl; $('pKey').value = '';
2180
- }));
2181
- act.appendChild(btn('Pool', 'secondary', function () { loadProviderKeys(p.id); }));
2182
- act.appendChild(btn('Delete', 'danger', async function () {
2183
- if (window.confirm('Delete provider ' + p.id + '?')) { await api('DELETE', 'providers/' + encodeURIComponent(p.id)); loadProviders(); }
2184
- }));
2185
- tr.appendChild(act);
2186
- body.appendChild(tr);
2187
- });
2188
- }
2189
-
2190
- // ── Pool health (read-only; key-pool change) ──────────────────────────────
2191
- // GET /admin/api/providers/:id/keys → masked pool view. Multi-key is
2192
- // cold-standby + observable in v1 (no outbound failover yet — see panel note).
2193
- async function loadProviderKeys(providerId) {
2194
- var r = await api('GET', 'providers/' + encodeURIComponent(providerId) + '/keys');
2195
- var panel = $('poolPanel');
2196
- var body = $('poolTable').querySelector('tbody');
2197
- clear(body);
2198
- $('poolTitle').textContent = 'API key pool — ' + providerId;
2199
- (r.json && r.json.keys || []).forEach(function (k) {
2200
- var tr = document.createElement('tr');
2201
- tr.appendChild(td(k.id));
2202
- tr.appendChild(td(k.label));
2203
- tr.appendChild(td(k.apiKeyMasked));
2204
- tr.appendChild(td(k.enabled ? 'yes' : 'no'));
2205
- tr.appendChild(td(k.weight));
2206
- var h = '';
2207
- if (k.health && k.health.autoDisabled) h += 'auto-disabled (' + k.health.autoDisabled.status + ') ';
2208
- if (k.health && k.health.cooldown) h += 'cooldown until ' + new Date(k.health.cooldown.until).toLocaleTimeString();
2209
- tr.appendChild(td(h || 'ok'));
2210
- body.appendChild(tr);
2211
- });
2212
- panel.style.display = 'block';
2213
- }
2214
-
2215
- async function saveProvider() {
2216
- $('pErr').textContent = '';
2217
- var id = $('pId').value.trim();
2218
- if (!id) { $('pErr').textContent = 'id required'; return; }
2219
- var payload = { id: id, apiFormat: $('pFormat').value, baseUrl: $('pBase').value.trim(), apiKey: $('pKey').value };
2220
- // Carry the preset-prefilled models (existing parseProviderInput accepts them).
2221
- if (presetModels.length) payload.models = presetModels;
2222
- // Try PUT first (edit, blank key keeps existing); fall back to POST (create).
2223
- var r = await api('PUT', 'providers/' + encodeURIComponent(id), payload);
2224
- if (r.status === 404) r = await api('POST', 'providers', payload);
2225
- if (r.status >= 400) { $('pErr').textContent = (r.json && r.json.error && r.json.error.message) || ('error ' + r.status); return; }
2226
- $('pId').value = ''; $('pBase').value = ''; $('pKey').value = '';
2227
- $('pPreset').value = ''; presetModels = []; // reset the picker after a write
2228
- loadProviders();
2229
- }
2230
-
2231
- // ── Keys ────────────────────────────────────────────────────────────────────
2232
- async function loadKeys() {
2233
- var r = await api('GET', 'keys');
2234
- var body = $('keysTable').querySelector('tbody');
2235
- clear(body);
2236
- (r.json && r.json.keys || []).forEach(function (k) {
2237
- var tr = document.createElement('tr');
2238
- tr.appendChild(td(k.name));
2239
- tr.appendChild(td(k.keyPrefix));
2240
- tr.appendChild(td(k.enabled ? 'yes' : 'no'));
2241
- tr.appendChild(td(k.revoked ? 'yes' : 'no'));
2242
- var act = document.createElement('td');
2243
- if (!k.revoked) {
2244
- act.appendChild(btn(k.enabled ? 'Disable' : 'Enable', 'secondary', async function () {
2245
- await api('POST', 'keys/' + encodeURIComponent(k.id) + '/enabled', { enabled: !k.enabled }); loadKeys();
2246
- }));
2247
- act.appendChild(btn('Revoke', 'danger', async function () {
2248
- if (window.confirm('Revoke ' + k.name + '?')) { await api('POST', 'keys/' + encodeURIComponent(k.id) + '/revoke'); loadKeys(); }
2249
- }));
2250
- }
2251
- tr.appendChild(act);
2252
- body.appendChild(tr);
2253
- });
2254
- }
2255
-
2256
- function showKeyModal(plaintext) {
2257
- $('keyPlaintext').textContent = plaintext;
2258
- $('keyModalBg').classList.add('show');
2259
- $('keyCopy').onclick = function () { navigator.clipboard && navigator.clipboard.writeText(plaintext); };
2260
- $('keyClose').onclick = function () {
2261
- $('keyModalBg').classList.remove('show');
2262
- $('keyPlaintext').textContent = ''; // never persist the plaintext
2263
- };
2264
- }
2265
-
2266
- async function createKey() {
2267
- var name = $('kName').value.trim() || 'key';
2268
- var r = await api('POST', 'keys', { name: name });
2269
- if (r.json && r.json.plaintextOnce) { showKeyModal(r.json.plaintextOnce); $('kName').value = ''; loadKeys(); }
2270
- }
2271
-
2272
- // ── Server config ───────────────────────────────────────────────────────────
2273
- async function loadServer() {
2274
- var r = await api('GET', 'server');
2275
- var s = (r.json && r.json.server) || {};
2276
- $('sEnabled').checked = !!s.enabled;
2277
- $('sLan').checked = !!s.networkBinding;
2278
- $('sPort').value = s.port || '';
2279
- var body = $('endpointsTable').querySelector('tbody');
2280
- clear(body);
2281
- (s.endpoints || []).forEach(function (e) {
2282
- var tr = document.createElement('tr');
2283
- tr.appendChild(td(e.endpoint));
2284
- tr.appendChild(td(e.defaultModel));
2285
- tr.appendChild(td(e.useSubscription ? 'yes' : 'no'));
2286
- body.appendChild(tr);
2287
- });
2595
+ // src/admin/uiStatic.ts
2596
+ import { existsSync as existsSync3, statSync } from "fs";
2597
+ import { readFile } from "fs/promises";
2598
+ import { createRequire } from "module";
2599
+ import path from "path";
2600
+ var CONTENT_TYPES = {
2601
+ ".html": "text/html; charset=utf-8",
2602
+ ".js": "text/javascript; charset=utf-8",
2603
+ ".mjs": "text/javascript; charset=utf-8",
2604
+ ".css": "text/css; charset=utf-8",
2605
+ ".json": "application/json; charset=utf-8",
2606
+ ".svg": "image/svg+xml",
2607
+ ".png": "image/png",
2608
+ ".ico": "image/x-icon",
2609
+ ".webp": "image/webp",
2610
+ ".woff": "font/woff",
2611
+ ".woff2": "font/woff2",
2612
+ ".ttf": "font/ttf",
2613
+ ".map": "application/json; charset=utf-8",
2614
+ ".txt": "text/plain; charset=utf-8"
2615
+ };
2616
+ function resolveUiDist() {
2617
+ const fromEnv = process.env["OMNICROSS_UI_DIST"];
2618
+ if (fromEnv) {
2619
+ return existsSync3(path.join(fromEnv, "index.html")) ? path.resolve(fromEnv) : null;
2288
2620
  }
2289
-
2290
- async function saveServer() {
2291
- var patch = { enabled: $('sEnabled').checked, networkBinding: $('sLan').checked };
2292
- var port = parseInt($('sPort').value, 10);
2293
- if (port) patch.port = port;
2294
- await api('PUT', 'server', patch);
2295
- loadServer(); loadStatus();
2621
+ try {
2622
+ const req = createRequire(typeof __filename !== "undefined" ? __filename : import.meta.url);
2623
+ const pkgJson = req.resolve("@omnicross/ui/package.json");
2624
+ const dist = path.join(path.dirname(pkgJson), "dist");
2625
+ return existsSync3(path.join(dist, "index.html")) ? dist : null;
2626
+ } catch {
2627
+ return null;
2296
2628
  }
2297
-
2298
- // ── Accounts ────────────────────────────────────────────────────────────────
2299
- // The GET stays token-free (status only). The Save/Clear actions WRITE tokens
2300
- // (secret IN); the form never renders an existing/stored token (write-only).
2301
- async function loadAccounts() {
2302
- var r = await api('GET', 'accounts');
2303
- var body = $('accountsTable').querySelector('tbody');
2304
- clear(body);
2305
- (r.json && r.json.accounts || []).forEach(function (a) {
2306
- var tr = document.createElement('tr');
2307
- tr.appendChild(td(a.displayName || a.providerId));
2308
- tr.appendChild(td(a.kind));
2309
- var st = document.createElement('td');
2310
- var ok = a.credentialStatus && a.credentialStatus.ok;
2311
- var pill = document.createElement('span');
2312
- pill.className = 'pill ' + (ok ? 'ok' : 'bad');
2313
- pill.textContent = ok ? 'ok' : ((a.credentialStatus && a.credentialStatus.reason) || 'no credential');
2314
- st.appendChild(pill);
2315
- tr.appendChild(st);
2316
- var act = document.createElement('td');
2317
- act.appendChild(btn('Clear', 'danger', async function () {
2318
- if (window.confirm('Clear ' + a.providerId + ' token?')) {
2319
- await api('DELETE', 'accounts/' + encodeURIComponent(a.providerId));
2320
- loadAccounts();
2321
- }
2322
- }));
2323
- tr.appendChild(act);
2324
- body.appendChild(tr);
2325
- });
2326
- renderProviderAccounts(r.json && r.json.providerAccounts || {});
2629
+ }
2630
+ async function handleUiStatic(req, res, urlPath, uiDist) {
2631
+ if (urlPath !== "/ui" && !urlPath.startsWith("/ui/")) return false;
2632
+ if (req.method !== "GET" && req.method !== "HEAD") {
2633
+ res.writeHead(405, { "Content-Type": "application/json" });
2634
+ res.end(JSON.stringify({ error: { type: "method_not_allowed", message: "GET/HEAD only" } }));
2635
+ return true;
2327
2636
  }
2328
-
2329
- // Per-provider sanitized accounts (multi-account). Secrets IN-never-OUT: this
2330
- // view shows id/label/status/active only — set-active + delete are STATUS-ONLY.
2331
- function renderProviderAccounts(byProvider) {
2332
- var body = $('providerAccountsTable').querySelector('tbody');
2333
- clear(body);
2334
- Object.keys(byProvider).forEach(function (provider) {
2335
- (byProvider[provider] || []).forEach(function (acc) {
2336
- var tr = document.createElement('tr');
2337
- tr.appendChild(td(provider));
2338
- tr.appendChild(td(acc.label || acc.id));
2339
- tr.appendChild(td(acc.status));
2340
- tr.appendChild(td(acc.isActive ? 'yes' : ''));
2341
- var act = document.createElement('td');
2342
- if (!acc.isActive) {
2343
- act.appendChild(btn('Set active', '', async function () {
2344
- await api('PUT', 'accounts/' + encodeURIComponent(provider) + '/active', { id: acc.id });
2345
- loadAccounts();
2346
- }));
2637
+ if (!uiDist) {
2638
+ res.writeHead(404, { "Content-Type": "application/json" });
2639
+ res.end(
2640
+ JSON.stringify({
2641
+ error: {
2642
+ type: "ui_not_installed",
2643
+ message: "Control Panel UI not installed (@omnicross/ui has no built dist). Install/build @omnicross/ui or set OMNICROSS_UI_DIST."
2347
2644
  }
2348
- act.appendChild(btn('Delete', 'danger', async function () {
2349
- if (window.confirm('Delete account ' + (acc.label || acc.id) + '?')) {
2350
- await api('DELETE', 'accounts/' + encodeURIComponent(provider) + '/' + encodeURIComponent(acc.id));
2351
- loadAccounts();
2352
- }
2353
- }));
2354
- tr.appendChild(act);
2355
- body.appendChild(tr);
2356
- });
2357
- });
2358
- }
2359
-
2360
- async function saveAccount() {
2361
- $('acErr').textContent = '';
2362
- var provider = $('acProvider').value;
2363
- var raw = $('acBody').value.trim();
2364
- if (!raw) { $('acErr').textContent = 'paste a token JSON'; return; }
2365
- var payload; try { payload = JSON.parse(raw); } catch (e) { $('acErr').textContent = 'invalid JSON'; return; }
2366
- var r = await api('PUT', 'accounts/' + encodeURIComponent(provider), payload);
2367
- if (r.status >= 400) { $('acErr').textContent = (r.json && r.json.error && r.json.error.message) || ('error ' + r.status); return; }
2368
- $('acBody').value = ''; // never persist/echo the just-saved token
2369
- loadAccounts();
2370
- }
2371
-
2372
- // ── Playground ──────────────────────────────────────────────────────────────
2373
- async function sendPlayground() {
2374
- var pre = $('plResponse');
2375
- pre.classList.remove('muted');
2376
- pre.textContent = 'sending…';
2377
- var bodyText = $('plBody').value;
2378
- var parsed; try { parsed = JSON.parse(bodyText); } catch (e) { parsed = bodyText; }
2379
- var r = await fetch('/admin/api/playground', {
2380
- method: 'POST',
2381
- headers: headers({ 'Content-Type': 'application/json' }),
2382
- body: JSON.stringify({ endpoint: $('plEndpoint').value, key: $('plKey').value, body: parsed }),
2383
- });
2384
- var text = await r.text();
2385
- pre.textContent = '[' + r.status + ']\n' + text;
2645
+ })
2646
+ );
2647
+ return true;
2386
2648
  }
2387
-
2388
- function wire() {
2389
- $('pSave').onclick = saveProvider;
2390
- $('pPreset').onchange = onPresetChange;
2391
- $('kCreate').onclick = createKey;
2392
- $('sSave').onclick = saveServer;
2393
- $('acSave').onclick = saveAccount;
2394
- $('plSend').onclick = sendPlayground;
2395
- refresh();
2649
+ if (urlPath === "/ui") {
2650
+ res.writeHead(302, { Location: "/ui/" });
2651
+ res.end();
2652
+ return true;
2396
2653
  }
2397
-
2398
- function refresh() {
2399
- loadStatus(); loadProviders(); loadPresets(); loadKeys(); loadServer(); loadAccounts();
2654
+ let rel;
2655
+ try {
2656
+ rel = decodeURIComponent(urlPath.slice("/ui/".length));
2657
+ } catch {
2658
+ res.writeHead(400, { "Content-Type": "application/json" });
2659
+ res.end(JSON.stringify({ error: { type: "bad_request", message: "malformed path" } }));
2660
+ return true;
2661
+ }
2662
+ if (rel.includes("\\") || rel.includes("\0")) {
2663
+ res.writeHead(400, { "Content-Type": "application/json" });
2664
+ res.end(JSON.stringify({ error: { type: "bad_request", message: "invalid path" } }));
2665
+ return true;
2666
+ }
2667
+ const filePath = path.resolve(uiDist, rel === "" ? "index.html" : rel);
2668
+ if (filePath !== uiDist && !filePath.startsWith(uiDist + path.sep)) {
2669
+ res.writeHead(403, { "Content-Type": "application/json" });
2670
+ res.end(JSON.stringify({ error: { type: "forbidden", message: "path outside ui root" } }));
2671
+ return true;
2672
+ }
2673
+ let target = filePath;
2674
+ if (!existsSync3(target) || statSync(target).isDirectory()) {
2675
+ if (path.extname(rel) === "") {
2676
+ target = path.join(uiDist, "index.html");
2677
+ } else {
2678
+ res.writeHead(404, { "Content-Type": "application/json" });
2679
+ res.end(JSON.stringify({ error: { type: "not_found", message: "no such ui asset" } }));
2680
+ return true;
2681
+ }
2400
2682
  }
2401
-
2402
- if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', wire);
2403
- else wire();
2404
- })();
2405
- `;
2406
-
2407
- // src/admin/html.ts
2408
- var STYLE = `
2409
- :root { --bg:#0f1115; --panel:#171a21; --line:#272b35; --fg:#e6e8ec; --muted:#8b91a0; --accent:#5b8cff; --danger:#ff5b6e; --ok:#3ecf8e; }
2410
- * { box-sizing: border-box; }
2411
- body { margin:0; font:14px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif; background:var(--bg); color:var(--fg); }
2412
- header { padding:14px 20px; border-bottom:1px solid var(--line); display:flex; align-items:center; gap:12px; }
2413
- header h1 { font-size:16px; margin:0; font-weight:600; }
2414
- header .badge { font-size:12px; color:var(--muted); }
2415
- main { padding:20px; display:grid; gap:20px; max-width:980px; margin:0 auto; }
2416
- section { background:var(--panel); border:1px solid var(--line); border-radius:10px; padding:16px; }
2417
- section h2 { font-size:14px; margin:0 0 12px; font-weight:600; }
2418
- table { width:100%; border-collapse:collapse; font-size:13px; }
2419
- th, td { text-align:left; padding:6px 8px; border-bottom:1px solid var(--line); }
2420
- th { color:var(--muted); font-weight:500; }
2421
- input, select, textarea { background:var(--bg); color:var(--fg); border:1px solid var(--line); border-radius:6px; padding:6px 8px; font:inherit; }
2422
- textarea { width:100%; min-height:90px; resize:vertical; font-family:ui-monospace,Menlo,monospace; }
2423
- button { background:var(--accent); color:#fff; border:0; border-radius:6px; padding:6px 12px; cursor:pointer; font:inherit; }
2424
- button.secondary { background:#2a2f3a; }
2425
- button.danger { background:var(--danger); }
2426
- .row { display:flex; gap:8px; flex-wrap:wrap; align-items:center; margin-top:8px; }
2427
- .muted { color:var(--muted); }
2428
- .mono { font-family:ui-monospace,Menlo,monospace; }
2429
- .pill { padding:1px 8px; border-radius:999px; font-size:12px; }
2430
- .pill.ok { background:rgba(62,207,142,.15); color:var(--ok); }
2431
- .pill.bad { background:rgba(255,91,110,.15); color:var(--danger); }
2432
- pre { background:var(--bg); border:1px solid var(--line); border-radius:6px; padding:10px; overflow:auto; max-height:320px; white-space:pre-wrap; }
2433
- .modal-bg { position:fixed; inset:0; background:rgba(0,0,0,.6); display:none; align-items:center; justify-content:center; }
2434
- .modal-bg.show { display:flex; }
2435
- .modal { background:var(--panel); border:1px solid var(--line); border-radius:10px; padding:20px; max-width:520px; width:90%; }
2436
- .warn { color:var(--danger); font-size:13px; margin:8px 0; }
2437
- .err { color:var(--danger); font-size:13px; }
2438
- `;
2439
- var BODY = `
2440
- <header>
2441
- <h1>omnicross daemon dashboard</h1>
2442
- <span class="badge" id="statusBadge">connecting\u2026</span>
2443
- </header>
2444
- <main>
2445
- <section id="statusSection">
2446
- <h2>Runtime status</h2>
2447
- <div id="statusBody" class="muted">loading\u2026</div>
2448
- </section>
2449
-
2450
- <section>
2451
- <h2>Providers</h2>
2452
- <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>
2453
- <div class="row">
2454
- <select id="pPreset"><option value="">-- \u9009\u62E9\u9884\u7F6E --</option></select>
2455
- <input id="pId" placeholder="id" size="10" />
2456
- <select id="pFormat"><option value="openai">openai</option><option value="anthropic">anthropic</option><option value="gemini">gemini</option></select>
2457
- <input id="pBase" placeholder="base URL" size="26" />
2458
- <input id="pKey" placeholder="apiKey (blank = keep on edit)" size="22" />
2459
- <button id="pSave">Save provider</button>
2460
- </div>
2461
- <div class="err" id="pErr"></div>
2462
- <div id="poolPanel" style="display:none; margin-top:12px;">
2463
- <h2 id="poolTitle" style="font-size:13px;">API key pool</h2>
2464
- <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>
2465
- <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>
2466
- </div>
2467
- </section>
2468
-
2469
- <section>
2470
- <h2>Named keys</h2>
2471
- <table id="keysTable"><thead><tr><th>name</th><th>prefix</th><th>enabled</th><th>revoked</th><th></th></tr></thead><tbody></tbody></table>
2472
- <div class="row">
2473
- <input id="kName" placeholder="key name" size="16" />
2474
- <button id="kCreate">Create key</button>
2475
- </div>
2476
- </section>
2477
-
2478
- <section>
2479
- <h2>Server config</h2>
2480
- <div class="row">
2481
- <label><input type="checkbox" id="sEnabled" /> enabled</label>
2482
- <label><input type="checkbox" id="sLan" /> networkBinding (LAN)</label>
2483
- <label>port <input id="sPort" size="6" /></label>
2484
- <button id="sSave">Apply server config</button>
2485
- </div>
2486
- <table id="endpointsTable"><thead><tr><th>endpoint</th><th>defaultModel</th><th>subscription</th></tr></thead><tbody></tbody></table>
2487
- </section>
2488
-
2489
- <section>
2490
- <h2>Accounts <span class="muted">(subscription tokens)</span></h2>
2491
- <table id="accountsTable"><thead><tr><th>provider</th><th>kind</th><th>status</th><th></th></tr></thead><tbody></tbody></table>
2492
- <h3>Per-provider accounts <span class="muted">(multi-account \u2014 sanitized, no tokens)</span></h3>
2493
- <table id="providerAccountsTable"><thead><tr><th>provider</th><th>label</th><th>status</th><th>active</th><th></th></tr></thead><tbody></tbody></table>
2494
- <div class="row">
2495
- <select id="acProvider"><option value="claude">claude</option><option value="codex">codex</option><option value="gemini">gemini</option><option value="opencodego">opencodego</option></select>
2496
- <button id="acSave">Save token</button>
2497
- </div>
2498
- <textarea id="acBody" placeholder='{"authMethod":"oauth","status":"authorized","accessToken":"\u2026","refreshToken":"\u2026"}'></textarea>
2499
- <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>
2500
- <div class="err" id="acErr"></div>
2501
- </section>
2502
-
2503
- <section>
2504
- <h2>Playground</h2>
2505
- <div class="row">
2506
- <select id="plEndpoint"><option value="chat">chat</option><option value="responses">responses</option><option value="messages">messages</option><option value="gemini">gemini</option></select>
2507
- <input id="plKey" placeholder="named key (sk-omnicross-\u2026)" size="30" />
2508
- <button id="plSend">Send</button>
2509
- </div>
2510
- <textarea id="plBody">{"model":"","messages":[{"role":"user","content":"ping"}]}</textarea>
2511
- <pre id="plResponse" class="muted">response will appear here</pre>
2512
- </section>
2513
- </main>
2514
-
2515
- <div class="modal-bg" id="keyModalBg">
2516
- <div class="modal">
2517
- <h2>Key created</h2>
2518
- <p class="warn">This secret is shown ONCE. Copy it now \u2014 it cannot be retrieved again.</p>
2519
- <pre id="keyPlaintext" class="mono"></pre>
2520
- <div class="row">
2521
- <button id="keyCopy">Copy</button>
2522
- <button class="secondary" id="keyClose">Close</button>
2523
- </div>
2524
- </div>
2525
- </div>
2526
- `;
2527
- var DASHBOARD_HTML = `<!doctype html>
2528
- <html lang="en">
2529
- <head>
2530
- <meta charset="utf-8" />
2531
- <meta name="viewport" content="width=device-width, initial-scale=1" />
2532
- <title>omnicross daemon dashboard</title>
2533
- <style>${STYLE}</style>
2534
- </head>
2535
- <body>
2536
- ${BODY}
2537
- <script>${DASHBOARD_JS}</script>
2538
- </body>
2539
- </html>`;
2683
+ const body = await readFile(target);
2684
+ const type = CONTENT_TYPES[path.extname(target).toLowerCase()] ?? "application/octet-stream";
2685
+ res.writeHead(200, { "Content-Type": type, "Content-Length": body.length });
2686
+ res.end(req.method === "HEAD" ? void 0 : body);
2687
+ return true;
2688
+ }
2540
2689
 
2541
2690
  // src/admin/AdminServer.ts
2542
2691
  var LOOPBACK_ADDR = "127.0.0.1";
2543
2692
  var LAN_ADDR = "0.0.0.0";
2693
+ var DAEMON_VERSION = true ? "0.1.2" : "0.0.0-dev";
2544
2694
  var AdminServer = class {
2545
2695
  constructor(deps) {
2546
2696
  this.deps = deps;
@@ -2549,6 +2699,8 @@ var AdminServer = class {
2549
2699
  server = null;
2550
2700
  boundPort = 0;
2551
2701
  boundAddr = LOOPBACK_ADDR;
2702
+ /** Control Panel dist dir (resolved once at first request; null = no UI). */
2703
+ uiDist;
2552
2704
  /**
2553
2705
  * Start the admin listener honoring the resolved admin config. Returns the
2554
2706
  * actual bound port, or `0` when it refuses/declines to bind (disabled or the
@@ -2577,13 +2729,13 @@ var AdminServer = class {
2577
2729
  const server = http2.createServer((req, res) => {
2578
2730
  this.onRequest(req, res);
2579
2731
  });
2580
- const onError = (err4) => {
2581
- if (err4.code === "EADDRINUSE" && port !== 0) {
2732
+ const onError = (err5) => {
2733
+ if (err5.code === "EADDRINUSE" && port !== 0) {
2582
2734
  server.removeListener("error", onError);
2583
2735
  this.listen(bindAddr, 0).then(resolve, reject);
2584
2736
  return;
2585
2737
  }
2586
- reject(err4);
2738
+ reject(err5);
2587
2739
  };
2588
2740
  server.on("error", onError);
2589
2741
  server.listen(port, bindAddr, () => {
@@ -2601,8 +2753,8 @@ var AdminServer = class {
2601
2753
  }
2602
2754
  /** Per-request handler: auth gate (when a token is set) → routing. */
2603
2755
  onRequest(req, res) {
2604
- void this.dispatch(req, res).catch((err4) => {
2605
- const message = err4 instanceof Error ? err4.message : String(err4);
2756
+ void this.dispatch(req, res).catch((err5) => {
2757
+ const message = err5 instanceof Error ? err5.message : String(err5);
2606
2758
  console.error("[AdminServer] unhandled error:", message);
2607
2759
  if (!res.headersSent) {
2608
2760
  res.writeHead(500, { "Content-Type": "application/json" });
@@ -2612,22 +2764,26 @@ var AdminServer = class {
2612
2764
  }
2613
2765
  async dispatch(req, res) {
2614
2766
  const cfg = this.deps.getAdminConfig();
2767
+ res.setHeader("x-omnicross-daemon", DAEMON_VERSION);
2768
+ res.setHeader("x-omnicross-pid", String(process.pid));
2615
2769
  if (cfg.token && !this.isAuthorized(req, cfg.token)) {
2616
2770
  res.writeHead(401, { "Content-Type": "application/json" });
2617
2771
  res.end(JSON.stringify({ error: { type: "unauthorized", message: "admin token required" } }));
2618
2772
  return;
2619
2773
  }
2620
2774
  const url = req.url ?? "/";
2621
- const path = url.split("?")[0];
2622
- if ((req.method === "GET" || req.method === "HEAD") && (path === "/" || path === "/admin")) {
2623
- res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
2624
- res.end(req.method === "HEAD" ? void 0 : DASHBOARD_HTML);
2775
+ const path2 = url.split("?")[0];
2776
+ if ((req.method === "GET" || req.method === "HEAD") && (path2 === "/" || path2 === "/admin")) {
2777
+ res.writeHead(302, { Location: "/ui/" });
2778
+ res.end();
2625
2779
  return;
2626
2780
  }
2627
- if (path.startsWith("/admin/api/")) {
2628
- await handleAdminApi(req, res, path, this.deps);
2781
+ if (path2.startsWith("/admin/api/")) {
2782
+ await handleAdminApi(req, res, path2, this.deps);
2629
2783
  return;
2630
2784
  }
2785
+ if (this.uiDist === void 0) this.uiDist = resolveUiDist();
2786
+ if (await handleUiStatic(req, res, path2, this.uiDist)) return;
2631
2787
  res.writeHead(404, { "Content-Type": "application/json" });
2632
2788
  res.end(JSON.stringify({ error: { type: "not_found", message: "no such admin route" } }));
2633
2789
  }
@@ -2750,18 +2906,18 @@ function awaitLoopbackCode(expectedState, timeoutMs = DEFAULT_TIMEOUT_MS) {
2750
2906
  res.end(pageHtml("Login complete."));
2751
2907
  finish(server, () => resolve(code));
2752
2908
  });
2753
- server.on("error", (err4) => {
2909
+ server.on("error", (err5) => {
2754
2910
  if (settled) return;
2755
2911
  settled = true;
2756
2912
  clearTimeout(timer);
2757
- if (err4.code === "EADDRINUSE") {
2913
+ if (err5.code === "EADDRINUSE") {
2758
2914
  reject(
2759
2915
  new Error(
2760
2916
  `login: cannot bind ${LOOPBACK_HOST}:${LOOPBACK_PORT} (address in use) \u2014 another codex login or process is holding the port`
2761
2917
  )
2762
2918
  );
2763
2919
  } else {
2764
- reject(err4);
2920
+ reject(err5);
2765
2921
  }
2766
2922
  });
2767
2923
  const timer = setTimeout(() => {
@@ -2836,6 +2992,15 @@ function createPoolKeysLoader(getProviderRow, autoDisabled) {
2836
2992
  };
2837
2993
  }
2838
2994
 
2995
+ // src/commands/paths.ts
2996
+ import { dirname as dirname2, join as join3 } from "path";
2997
+ function defaultPricingPath(configPath) {
2998
+ return join3(dirname2(configPath), "pricing.json");
2999
+ }
3000
+ function defaultUsageEventsPath(configPath) {
3001
+ return join3(dirname2(configPath), "usage-events.jsonl");
3002
+ }
3003
+
2839
3004
  // src/ports/ConfigFileProviderConfigSource.ts
2840
3005
  import {
2841
3006
  registerBuiltinTransformers,
@@ -3048,8 +3213,211 @@ var JsonApiServerSettingsStore = class {
3048
3213
  }
3049
3214
  };
3050
3215
 
3216
+ // src/ports/JsonlUsageEventStore.ts
3217
+ import { randomUUID as randomUUID3 } from "crypto";
3218
+ import { appendFileSync, existsSync as existsSync4, readFileSync as readFileSync4 } from "fs";
3219
+ var JsonlUsageEventStore = class {
3220
+ constructor(eventsPath, isPriced) {
3221
+ this.eventsPath = eventsPath;
3222
+ this.isPriced = isPriced;
3223
+ }
3224
+ eventsPath;
3225
+ isPriced;
3226
+ /** Persist one event: assign `id`, stamp `ts` when absent, append ONE line. */
3227
+ async insert(input) {
3228
+ const row = {
3229
+ ...input,
3230
+ id: randomUUID3(),
3231
+ ts: input.ts ?? Date.now()
3232
+ };
3233
+ appendFileSync(this.eventsPath, JSON.stringify(row) + "\n", "utf8");
3234
+ return row.id;
3235
+ }
3236
+ async getTotals(range) {
3237
+ const totals = {
3238
+ inputTokens: 0,
3239
+ outputTokens: 0,
3240
+ cacheReadTokens: 0,
3241
+ cacheCreationTokens: 0,
3242
+ reasoningTokens: 0,
3243
+ costUsd: 0,
3244
+ costSavedByCacheUsd: 0,
3245
+ eventCount: 0
3246
+ };
3247
+ for (const row of this.readRows(range)) {
3248
+ totals.inputTokens += row.inputTokens;
3249
+ totals.outputTokens += row.outputTokens;
3250
+ totals.cacheReadTokens += row.cacheReadTokens;
3251
+ totals.cacheCreationTokens += row.cacheCreationTokens;
3252
+ totals.reasoningTokens += row.reasoningTokens;
3253
+ totals.costUsd += row.costUsd;
3254
+ totals.costSavedByCacheUsd += row.costSavedByCacheUsd;
3255
+ totals.eventCount += 1;
3256
+ }
3257
+ return totals;
3258
+ }
3259
+ async getByModel(range) {
3260
+ const groups = /* @__PURE__ */ new Map();
3261
+ for (const row of this.readRows(range)) {
3262
+ const key = `${row.providerId}::${row.model}`;
3263
+ let g = groups.get(key);
3264
+ if (!g) {
3265
+ g = {
3266
+ providerId: row.providerId,
3267
+ model: row.model,
3268
+ eventCount: 0,
3269
+ inputTokens: 0,
3270
+ outputTokens: 0,
3271
+ cacheReadTokens: 0,
3272
+ cacheCreationTokens: 0,
3273
+ costUsd: 0,
3274
+ costSavedByCacheUsd: 0,
3275
+ unpriced: false
3276
+ };
3277
+ groups.set(key, g);
3278
+ }
3279
+ g.eventCount += 1;
3280
+ g.inputTokens += row.inputTokens;
3281
+ g.outputTokens += row.outputTokens;
3282
+ g.cacheReadTokens += row.cacheReadTokens;
3283
+ g.cacheCreationTokens += row.cacheCreationTokens;
3284
+ g.costUsd += row.costUsd;
3285
+ g.costSavedByCacheUsd += row.costSavedByCacheUsd;
3286
+ }
3287
+ const rows = Array.from(groups.values());
3288
+ for (const g of rows) {
3289
+ g.unpriced = !await this.isPriced(g.providerId, g.model);
3290
+ }
3291
+ return rows;
3292
+ }
3293
+ /**
3294
+ * Group by RAW apiKeyId (null forms the unattributed sentinel group). Label
3295
+ * here is the raw id fallback — the admin handler resolves display labels
3296
+ * against the configured pool keys (the store stays config-schema-free).
3297
+ */
3298
+ async getByApiKey(range) {
3299
+ const groups = /* @__PURE__ */ new Map();
3300
+ for (const row of this.readRows(range)) {
3301
+ const key = row.apiKeyId;
3302
+ let g = groups.get(key);
3303
+ if (!g) {
3304
+ g = {
3305
+ apiKeyId: key,
3306
+ label: key ?? "unattributed",
3307
+ providerId: key === null ? null : row.providerId,
3308
+ eventCount: 0,
3309
+ inputTokens: 0,
3310
+ outputTokens: 0,
3311
+ costUsd: 0
3312
+ };
3313
+ groups.set(key, g);
3314
+ }
3315
+ g.eventCount += 1;
3316
+ g.inputTokens += row.inputTokens;
3317
+ g.outputTokens += row.outputTokens;
3318
+ g.costUsd += row.costUsd;
3319
+ }
3320
+ return Array.from(groups.values());
3321
+ }
3322
+ async getMessagesForSession(sessionId) {
3323
+ return this.readAllRows().filter((r) => r.sessionId === sessionId).sort((a, b) => a.ts - b.ts).map((r) => ({
3324
+ id: r.id,
3325
+ ts: r.ts,
3326
+ messageId: r.messageId,
3327
+ parentMessageId: r.parentMessageId,
3328
+ sessionId: r.sessionId,
3329
+ providerId: r.providerId,
3330
+ model: r.model,
3331
+ apiKeyId: r.apiKeyId,
3332
+ engineOrigin: r.engineOrigin,
3333
+ inputTokens: r.inputTokens,
3334
+ outputTokens: r.outputTokens,
3335
+ cacheReadTokens: r.cacheReadTokens,
3336
+ cacheCreationTokens: r.cacheCreationTokens,
3337
+ reasoningTokens: r.reasoningTokens,
3338
+ costUsd: r.costUsd,
3339
+ costSavedByCacheUsd: r.costSavedByCacheUsd
3340
+ }));
3341
+ }
3342
+ async getSessionCacheStats(sessionId) {
3343
+ const stats = {
3344
+ sessionId,
3345
+ inputTokens: 0,
3346
+ cacheReadTokens: 0,
3347
+ cacheCreationTokens: 0,
3348
+ outputTokens: 0,
3349
+ eventCount: 0,
3350
+ hitRate: 0
3351
+ };
3352
+ for (const r of this.readAllRows()) {
3353
+ if (r.sessionId !== sessionId) continue;
3354
+ stats.inputTokens += r.inputTokens;
3355
+ stats.cacheReadTokens += r.cacheReadTokens;
3356
+ stats.cacheCreationTokens += r.cacheCreationTokens;
3357
+ stats.outputTokens += r.outputTokens;
3358
+ stats.eventCount += 1;
3359
+ }
3360
+ const promptSide = stats.inputTokens + stats.cacheReadTokens + stats.cacheCreationTokens;
3361
+ stats.hitRate = promptSide > 0 ? stats.cacheReadTokens / promptSide : 0;
3362
+ return stats;
3363
+ }
3364
+ /** Rows inside `startTs <= ts < endTs` (endTs EXCLUSIVE). */
3365
+ readRows(range) {
3366
+ return this.readAllRows().filter((r) => r.ts >= range.startTs && r.ts < range.endTs);
3367
+ }
3368
+ /** Parse every line, skipping malformed/torn lines defensively. */
3369
+ readAllRows() {
3370
+ if (!existsSync4(this.eventsPath)) return [];
3371
+ let raw;
3372
+ try {
3373
+ raw = readFileSync4(this.eventsPath, "utf8");
3374
+ } catch {
3375
+ return [];
3376
+ }
3377
+ const rows = [];
3378
+ for (const line of raw.split("\n")) {
3379
+ const trimmed = line.trim();
3380
+ if (!trimmed) continue;
3381
+ try {
3382
+ const parsed = JSON.parse(trimmed);
3383
+ if (isUsageEventRecord(parsed)) rows.push(parsed);
3384
+ } catch {
3385
+ }
3386
+ }
3387
+ return rows;
3388
+ }
3389
+ };
3390
+ var NUMERIC_FIELDS = [
3391
+ "ts",
3392
+ "inputTokens",
3393
+ "outputTokens",
3394
+ "cacheReadTokens",
3395
+ "cacheCreationTokens",
3396
+ "reasoningTokens",
3397
+ "costUsd",
3398
+ "costSavedByCacheUsd"
3399
+ ];
3400
+ var NULLABLE_STRING_FIELDS = ["messageId", "parentMessageId", "sessionId", "apiKeyId"];
3401
+ var isStringOrNull = (v) => v === null || typeof v === "string";
3402
+ function isUsageEventRecord(parsed) {
3403
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return false;
3404
+ const r = parsed;
3405
+ if (typeof r["id"] !== "string") return false;
3406
+ if (typeof r["providerId"] !== "string") return false;
3407
+ if (typeof r["model"] !== "string") return false;
3408
+ if (typeof r["engineOrigin"] !== "string") return false;
3409
+ for (const f of NULLABLE_STRING_FIELDS) {
3410
+ if (!isStringOrNull(r[f])) return false;
3411
+ }
3412
+ for (const f of NUMERIC_FIELDS) {
3413
+ const v = r[f];
3414
+ if (typeof v !== "number" || !Number.isFinite(v)) return false;
3415
+ }
3416
+ return true;
3417
+ }
3418
+
3051
3419
  // src/ports/JsonOutboundKeyDb.ts
3052
- import { existsSync as existsSync2, readFileSync as readFileSync4, writeFileSync as writeFileSync4 } from "fs";
3420
+ import { existsSync as existsSync5, readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "fs";
3053
3421
  var JsonOutboundKeyDb = class {
3054
3422
  constructor(keysPath) {
3055
3423
  this.keysPath = keysPath;
@@ -3113,9 +3481,9 @@ var JsonOutboundKeyDb = class {
3113
3481
  }
3114
3482
  /** Read the key rows, tolerating a missing/corrupt file (→ empty list). */
3115
3483
  readRows() {
3116
- if (!existsSync2(this.keysPath)) return [];
3484
+ if (!existsSync5(this.keysPath)) return [];
3117
3485
  try {
3118
- const parsed = JSON.parse(readFileSync4(this.keysPath, "utf8"));
3486
+ const parsed = JSON.parse(readFileSync5(this.keysPath, "utf8"));
3119
3487
  return Array.isArray(parsed) ? parsed : [];
3120
3488
  } catch {
3121
3489
  return [];
@@ -3126,14 +3494,357 @@ var JsonOutboundKeyDb = class {
3126
3494
  }
3127
3495
  };
3128
3496
 
3497
+ // src/ports/JsonPricingStore.ts
3498
+ import { existsSync as existsSync6, readFileSync as readFileSync6, writeFileSync as writeFileSync5 } from "fs";
3499
+ var JsonPricingStore = class {
3500
+ constructor(pricingPath) {
3501
+ this.pricingPath = pricingPath;
3502
+ }
3503
+ pricingPath;
3504
+ async getAll() {
3505
+ return this.readRows();
3506
+ }
3507
+ /**
3508
+ * Insert or update one row keyed (providerId, modelId). `asUserEdit` stamps
3509
+ * user provenance (source 'user', userEdited, editedAt now) so the row is
3510
+ * protected from auto-overwrite during source refreshes; a non-user upsert
3511
+ * stamps source 'litellm' and clears nothing it should not (a plain source
3512
+ * upsert through this method overwrites the row wholesale).
3513
+ */
3514
+ async upsert(input, asUserEdit) {
3515
+ const rows = this.readRows();
3516
+ const entry = this.applyUpsert(rows, input, asUserEdit);
3517
+ this.writeRows(rows);
3518
+ return entry;
3519
+ }
3520
+ /**
3521
+ * Apply a batch fetched from a pricing source. Rows whose local copy is
3522
+ * user-edited are NOT applied — they come back as `{ current, incoming }`
3523
+ * conflicts; everything else is upserted (source 'litellm'). ONE file write
3524
+ * for the whole batch.
3525
+ */
3526
+ async bulkApplyFromSource(entries) {
3527
+ const rows = this.readRows();
3528
+ const applied = [];
3529
+ const conflicts = [];
3530
+ for (const incoming of entries) {
3531
+ const current = rows.find(
3532
+ (r) => r.providerId === incoming.providerId && r.modelId === incoming.modelId
3533
+ );
3534
+ if (current && current.userEdited) {
3535
+ conflicts.push({ current, incoming });
3536
+ continue;
3537
+ }
3538
+ applied.push(this.applyUpsert(
3539
+ rows,
3540
+ incoming,
3541
+ /* asUserEdit */
3542
+ false
3543
+ ));
3544
+ }
3545
+ if (applied.length > 0) this.writeRows(rows);
3546
+ return { applied, conflicts };
3547
+ }
3548
+ /**
3549
+ * Apply per-row conflict decisions: 'overwrite' replaces the local row with
3550
+ * the incoming values (clearing the user-edited mark), 'skip' counts only.
3551
+ */
3552
+ async applyResolutions(resolutions) {
3553
+ const rows = this.readRows();
3554
+ let overwrittenCount = 0;
3555
+ let skippedCount = 0;
3556
+ for (const r of resolutions) {
3557
+ if (r.action === "skip") {
3558
+ skippedCount += 1;
3559
+ continue;
3560
+ }
3561
+ this.applyUpsert(
3562
+ rows,
3563
+ r.incoming,
3564
+ /* asUserEdit */
3565
+ false
3566
+ );
3567
+ overwrittenCount += 1;
3568
+ }
3569
+ if (overwrittenCount > 0) this.writeRows(rows);
3570
+ return { overwrittenCount, skippedCount };
3571
+ }
3572
+ /**
3573
+ * STORE-LOCAL (not on the core port): remove one row. Returns whether a row
3574
+ * was actually removed. The admin DELETE handler calls this then invalidates
3575
+ * the engine cache.
3576
+ */
3577
+ async delete(providerId, modelId) {
3578
+ const rows = this.readRows();
3579
+ const idx = rows.findIndex((r) => r.providerId === providerId && r.modelId === modelId);
3580
+ if (idx < 0) return false;
3581
+ rows.splice(idx, 1);
3582
+ this.writeRows(rows);
3583
+ return true;
3584
+ }
3585
+ /** Upsert into `rows` IN PLACE (no write) and return the resulting entry. */
3586
+ applyUpsert(rows, input, asUserEdit) {
3587
+ const now = Date.now();
3588
+ const entry = {
3589
+ providerId: input.providerId,
3590
+ modelId: input.modelId,
3591
+ inputPricePer1m: input.inputPricePer1m,
3592
+ outputPricePer1m: input.outputPricePer1m,
3593
+ cacheReadPricePer1m: input.cacheReadPricePer1m ?? null,
3594
+ cacheWritePricePer1m: input.cacheWritePricePer1m ?? null,
3595
+ source: asUserEdit ? "user" : "litellm",
3596
+ userEdited: asUserEdit,
3597
+ editedAt: asUserEdit ? now : null,
3598
+ updatedAt: now
3599
+ };
3600
+ const idx = rows.findIndex(
3601
+ (r) => r.providerId === input.providerId && r.modelId === input.modelId
3602
+ );
3603
+ if (idx >= 0) rows[idx] = entry;
3604
+ else rows.push(entry);
3605
+ return entry;
3606
+ }
3607
+ /** Read the pricing rows, tolerating a missing/corrupt file (→ empty list). */
3608
+ readRows() {
3609
+ if (!existsSync6(this.pricingPath)) return [];
3610
+ try {
3611
+ const parsed = JSON.parse(readFileSync6(this.pricingPath, "utf8"));
3612
+ return Array.isArray(parsed) ? parsed : [];
3613
+ } catch {
3614
+ return [];
3615
+ }
3616
+ }
3617
+ writeRows(rows) {
3618
+ writeFileSync5(this.pricingPath, JSON.stringify(rows, null, 2) + "\n", "utf8");
3619
+ }
3620
+ };
3621
+
3129
3622
  // src/ports/JsonSubscriptionCredentialStore.ts
3130
- import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync5, writeFileSync as writeFileSync5 } from "fs";
3131
- import { dirname as dirname2 } from "path";
3623
+ import { existsSync as existsSync9, mkdirSync as mkdirSync3, readFileSync as readFileSync9, writeFileSync as writeFileSync7 } from "fs";
3624
+ import { dirname as dirname4 } from "path";
3132
3625
  import {
3133
3626
  claudeOAuth as claudeOAuth2,
3134
3627
  codexOAuth as codexOAuth2,
3135
3628
  geminiOAuth as geminiOAuth2
3136
3629
  } from "@omnicross/subscriptions";
3630
+
3631
+ // src/ports/account-sync.ts
3632
+ var IMPORT_EXPIRY_MARGIN_MS = 6e4;
3633
+ function viewOf(tokens) {
3634
+ return tokens;
3635
+ }
3636
+ function decideExternalImport(captured, external, now = Date.now()) {
3637
+ if (!external?.accessToken) return "no-credential";
3638
+ const capturedRt = viewOf(captured).refreshToken;
3639
+ const hasNewRefresh = Boolean(external.refreshToken && external.refreshToken !== capturedRt);
3640
+ const accessStillValid = external.expiresAt ? Date.parse(external.expiresAt) > now + IMPORT_EXPIRY_MARGIN_MS : true;
3641
+ return hasNewRefresh || accessStillValid ? "import" : "not-rotated";
3642
+ }
3643
+ function buildImportedTokens(captured, external) {
3644
+ const imported = {
3645
+ ...captured,
3646
+ accessToken: external.accessToken,
3647
+ status: "authorized",
3648
+ errorMessage: void 0,
3649
+ syncWarning: void 0,
3650
+ lastRefreshedAt: (/* @__PURE__ */ new Date()).toISOString()
3651
+ };
3652
+ if (external.refreshToken) imported.refreshToken = external.refreshToken;
3653
+ if (external.expiresAt) imported.expiresAt = external.expiresAt;
3654
+ else delete imported.expiresAt;
3655
+ if (external.idToken) imported.idToken = external.idToken;
3656
+ if (external.scopes) imported.scopes = external.scopes;
3657
+ return imported;
3658
+ }
3659
+ function buildTokensFromExternal(provider, external) {
3660
+ const base = {
3661
+ authMethod: "oauth",
3662
+ status: "authorized",
3663
+ accessToken: external.accessToken,
3664
+ lastRefreshedAt: (/* @__PURE__ */ new Date()).toISOString()
3665
+ };
3666
+ if (provider === "claude") {
3667
+ const tokens2 = { ...base };
3668
+ if (external.refreshToken) tokens2.refreshToken = external.refreshToken;
3669
+ if (external.expiresAt) tokens2.expiresAt = external.expiresAt;
3670
+ if (external.scopes) tokens2.scopes = external.scopes;
3671
+ return tokens2;
3672
+ }
3673
+ const tokens = { ...base };
3674
+ if (external.refreshToken) tokens.refreshToken = external.refreshToken;
3675
+ if (external.expiresAt) tokens.expiresAt = external.expiresAt;
3676
+ if (external.idToken) tokens.idToken = external.idToken;
3677
+ return tokens;
3678
+ }
3679
+ function isExternalDivergent(stored, external) {
3680
+ if (!external?.accessToken || !external.refreshToken) return false;
3681
+ const view = viewOf(stored);
3682
+ if (!view.refreshToken || external.refreshToken === view.refreshToken) return false;
3683
+ const storedExp = view.expiresAt ? Date.parse(view.expiresAt) : NaN;
3684
+ const externalExp = external.expiresAt ? Date.parse(external.expiresAt) : Infinity;
3685
+ return !Number.isFinite(storedExp) || externalExp > storedExp;
3686
+ }
3687
+ function findDuplicateCredentialIds(accounts) {
3688
+ const byCredential = /* @__PURE__ */ new Map();
3689
+ for (const account of accounts) {
3690
+ const view = viewOf(account.tokens);
3691
+ const credential = view.refreshToken ?? view.apiKey ?? view.accessToken;
3692
+ if (!credential) continue;
3693
+ const ids = byCredential.get(credential) ?? [];
3694
+ ids.push(account.id);
3695
+ byCredential.set(credential, ids);
3696
+ }
3697
+ const duplicates = /* @__PURE__ */ new Set();
3698
+ for (const ids of byCredential.values()) {
3699
+ if (ids.length > 1) for (const id of ids) duplicates.add(id);
3700
+ }
3701
+ return duplicates;
3702
+ }
3703
+
3704
+ // src/ports/external-cli-credentials.ts
3705
+ import { existsSync as existsSync7, readFileSync as readFileSync7 } from "fs";
3706
+ import { homedir as homedir2 } from "os";
3707
+ import { join as join4 } from "path";
3708
+ function externalStorePath(provider, home = homedir2()) {
3709
+ return provider === "claude" ? join4(home, ".claude", ".credentials.json") : join4(home, ".codex", "auth.json");
3710
+ }
3711
+ function decodeJwtExpiryMs(token) {
3712
+ try {
3713
+ const payload = token.split(".")[1];
3714
+ if (!payload) return void 0;
3715
+ const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
3716
+ if (typeof decoded.exp === "number" && Number.isFinite(decoded.exp)) {
3717
+ return decoded.exp * 1e3;
3718
+ }
3719
+ } catch {
3720
+ }
3721
+ return void 0;
3722
+ }
3723
+ function parseClaudeOAuthEnvelope(raw) {
3724
+ const oauth = raw?.claudeAiOauth;
3725
+ if (!oauth || typeof oauth.accessToken !== "string" || !oauth.accessToken) return null;
3726
+ const parsed = { accessToken: oauth.accessToken };
3727
+ if (typeof oauth.refreshToken === "string" && oauth.refreshToken) {
3728
+ parsed.refreshToken = oauth.refreshToken;
3729
+ }
3730
+ if (typeof oauth.expiresAt === "number" && Number.isFinite(oauth.expiresAt)) {
3731
+ parsed.expiresAt = new Date(oauth.expiresAt).toISOString();
3732
+ }
3733
+ if (Array.isArray(oauth.scopes) && oauth.scopes.every((s) => typeof s === "string")) {
3734
+ parsed.scopes = oauth.scopes;
3735
+ }
3736
+ return parsed;
3737
+ }
3738
+ function parseCodexTokensEnvelope(raw) {
3739
+ const tokens = raw?.tokens;
3740
+ if (!tokens) return null;
3741
+ const accessToken = typeof tokens.access_token === "string" && tokens.access_token ? tokens.access_token : void 0;
3742
+ const idToken = typeof tokens.id_token === "string" && tokens.id_token ? tokens.id_token : void 0;
3743
+ if (!accessToken && !idToken) return null;
3744
+ const parsed = {};
3745
+ if (accessToken) {
3746
+ parsed.accessToken = accessToken;
3747
+ const expMs = decodeJwtExpiryMs(accessToken);
3748
+ if (expMs !== void 0) parsed.expiresAt = new Date(expMs).toISOString();
3749
+ }
3750
+ if (idToken) parsed.idToken = idToken;
3751
+ if (typeof tokens.refresh_token === "string" && tokens.refresh_token) {
3752
+ parsed.refreshToken = tokens.refresh_token;
3753
+ }
3754
+ return parsed;
3755
+ }
3756
+ function readExternalCliCredentials(provider, home = homedir2()) {
3757
+ const path2 = externalStorePath(provider, home);
3758
+ if (!existsSync7(path2)) return null;
3759
+ let raw;
3760
+ try {
3761
+ const parsed = JSON.parse(readFileSync7(path2, "utf8"));
3762
+ raw = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : void 0;
3763
+ } catch {
3764
+ return null;
3765
+ }
3766
+ return provider === "claude" ? parseClaudeOAuthEnvelope(raw) : parseCodexTokensEnvelope(raw);
3767
+ }
3768
+
3769
+ // src/ports/external-cli-store.ts
3770
+ import { copyFileSync, existsSync as existsSync8, mkdirSync as mkdirSync2, readFileSync as readFileSync8, renameSync, writeFileSync as writeFileSync6 } from "fs";
3771
+ import { homedir as homedir3 } from "os";
3772
+ import { dirname as dirname3 } from "path";
3773
+ function markerPath(provider, home) {
3774
+ return `${externalStorePath(provider, home)}.omnicross-managed`;
3775
+ }
3776
+ function backupPath(provider, home) {
3777
+ return `${externalStorePath(provider, home)}.omnicross-backup`;
3778
+ }
3779
+ function buildClaudeOAuthEnvelope(tokens) {
3780
+ if (!tokens.accessToken) return null;
3781
+ const envelope = { accessToken: tokens.accessToken };
3782
+ if (tokens.refreshToken) envelope.refreshToken = tokens.refreshToken;
3783
+ if (tokens.expiresAt) {
3784
+ const ms = Date.parse(tokens.expiresAt);
3785
+ if (Number.isFinite(ms)) envelope.expiresAt = ms;
3786
+ }
3787
+ if (tokens.scopes && tokens.scopes.length > 0) envelope.scopes = tokens.scopes;
3788
+ return envelope;
3789
+ }
3790
+ function buildCodexTokensEnvelope(tokens) {
3791
+ if (!tokens.accessToken && !tokens.idToken) return null;
3792
+ const envelope = { access_token: tokens.accessToken ?? "" };
3793
+ if (tokens.idToken) envelope.id_token = tokens.idToken;
3794
+ if (tokens.refreshToken) envelope.refresh_token = tokens.refreshToken;
3795
+ return envelope;
3796
+ }
3797
+ function readExistingObject(path2) {
3798
+ if (!existsSync8(path2)) return {};
3799
+ try {
3800
+ const parsed = JSON.parse(readFileSync8(path2, "utf8"));
3801
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
3802
+ } catch {
3803
+ return {};
3804
+ }
3805
+ }
3806
+ function writeAtomic(path2, content) {
3807
+ mkdirSync2(dirname3(path2), { recursive: true });
3808
+ const temp = `${path2}.omnicross-tmp`;
3809
+ writeFileSync6(temp, content, "utf8");
3810
+ renameSync(temp, path2);
3811
+ }
3812
+ function createExternalCliStore(home = homedir3()) {
3813
+ return {
3814
+ readMarkerAccountId(provider) {
3815
+ const path2 = markerPath(provider, home);
3816
+ if (!existsSync8(path2)) return void 0;
3817
+ try {
3818
+ const parsed = JSON.parse(readFileSync8(path2, "utf8"));
3819
+ return typeof parsed.accountId === "string" && parsed.accountId ? parsed.accountId : void 0;
3820
+ } catch {
3821
+ return void 0;
3822
+ }
3823
+ },
3824
+ writeMarker(provider, accountId) {
3825
+ writeAtomic(
3826
+ markerPath(provider, home),
3827
+ JSON.stringify({ accountId, at: (/* @__PURE__ */ new Date()).toISOString() }, null, 2) + "\n"
3828
+ );
3829
+ },
3830
+ writeBack(provider, accountId, tokens) {
3831
+ const owner = this.readMarkerAccountId(provider);
3832
+ if (owner !== accountId) return false;
3833
+ const envelope = provider === "claude" ? buildClaudeOAuthEnvelope(tokens) : buildCodexTokensEnvelope(tokens);
3834
+ if (!envelope) return false;
3835
+ const storePath = externalStorePath(provider, home);
3836
+ if (existsSync8(storePath) && !existsSync8(backupPath(provider, home))) {
3837
+ copyFileSync(storePath, backupPath(provider, home));
3838
+ }
3839
+ const existing = readExistingObject(storePath);
3840
+ const merged = provider === "claude" ? { ...existing, claudeAiOauth: envelope } : { ...existing, tokens: envelope };
3841
+ writeAtomic(storePath, JSON.stringify(merged, null, 2) + "\n");
3842
+ return true;
3843
+ }
3844
+ };
3845
+ }
3846
+
3847
+ // src/ports/JsonSubscriptionCredentialStore.ts
3137
3848
  var JsonSubscriptionCredentialStore = class {
3138
3849
  /**
3139
3850
  * @param tokensPath on-disk `tokens.json` location.
@@ -3143,14 +3854,33 @@ var JsonSubscriptionCredentialStore = class {
3143
3854
  * is unchanged; tests inject a mock fetch. NOT used by any
3144
3855
  * read/write path — only by `refresh*Token`.
3145
3856
  */
3146
- constructor(tokensPath, box, fetchImpl = (url, init) => fetch(url, init)) {
3857
+ constructor(tokensPath, box, fetchImpl = (url, init) => fetch(url, init), externalCliReader = readExternalCliCredentials, externalCliStore = createExternalCliStore()) {
3147
3858
  this.tokensPath = tokensPath;
3148
3859
  this.box = box;
3149
3860
  this.fetchImpl = fetchImpl;
3861
+ this.externalCliReader = externalCliReader;
3862
+ this.externalCliStore = externalCliStore;
3150
3863
  }
3151
3864
  tokensPath;
3152
3865
  box;
3153
3866
  fetchImpl;
3867
+ externalCliReader;
3868
+ externalCliStore;
3869
+ /**
3870
+ * In-flight refresh coalescing (external-cli-sync). OAuth refresh tokens are
3871
+ * SINGLE-USE: two concurrent refreshes of one account each spend the same
3872
+ * token and the loser bricks a healthy account. Every refresh entry point
3873
+ * (auth-strategy lazy refresh, 401 retry, background scheduler) funnels
3874
+ * through `coalesce`, so overlapping callers share ONE upstream round-trip.
3875
+ */
3876
+ inFlightRefreshes = /* @__PURE__ */ new Map();
3877
+ coalesce(key, task) {
3878
+ const existing = this.inFlightRefreshes.get(key);
3879
+ if (existing) return existing;
3880
+ const run = task().finally(() => this.inFlightRefreshes.delete(key));
3881
+ this.inFlightRefreshes.set(key, run);
3882
+ return run;
3883
+ }
3154
3884
  /** Full parsed account-tokens config (or a minimal `{ updatedAt }` when the
3155
3885
  * file is absent/corrupt). This is the hot read — the codex / gemini auth
3156
3886
  * strategies pull `accessToken` / `expiresAt` / `status` from it. */
@@ -3178,10 +3908,41 @@ var JsonSubscriptionCredentialStore = class {
3178
3908
  const out = {};
3179
3909
  for (const provider of ["claude", "codex", "gemini", "opencodego"]) {
3180
3910
  const sanitized = sanitizeAccounts(config, provider);
3181
- if (sanitized.length > 0) out[provider] = sanitized;
3911
+ if (sanitized.length > 0) out[provider] = this.attachSyncWarnings(config, provider, sanitized);
3182
3912
  }
3183
3913
  return out;
3184
3914
  }
3915
+ /**
3916
+ * List-time credential-conflict warnings (external-cli-sync). Computed, not
3917
+ * persisted: (a) `duplicate-token` when two accounts of one provider share a
3918
+ * credential, (b) `external-divergent` when the external CLI native store has
3919
+ * rotated PAST the ACTIVE account (claude/codex only). A warning persisted by
3920
+ * a failed refresh (`external-not-rotated`) takes precedence — it is the most
3921
+ * actionable state.
3922
+ */
3923
+ attachSyncWarnings(config, provider, sanitized) {
3924
+ const duplicates = findDuplicateCredentialIds(listAccounts(config, provider));
3925
+ let divergentId;
3926
+ if (provider === "claude" || provider === "codex") {
3927
+ const active = getActiveAccount(config, provider);
3928
+ if (active && isExternalDivergent(active.tokens, this.safeReadExternal(provider))) {
3929
+ divergentId = active.id;
3930
+ }
3931
+ }
3932
+ if (duplicates.size === 0 && !divergentId) return sanitized;
3933
+ return sanitized.map((account) => {
3934
+ const computed = account.id === divergentId ? "external-divergent" : duplicates.has(account.id) ? "duplicate-token" : void 0;
3935
+ return { ...account, syncWarning: account.syncWarning ?? computed };
3936
+ });
3937
+ }
3938
+ /** Read the external CLI store, never letting an fs/parse error escape. */
3939
+ safeReadExternal(provider) {
3940
+ try {
3941
+ return this.externalCliReader(provider);
3942
+ } catch {
3943
+ return null;
3944
+ }
3945
+ }
3185
3946
  /**
3186
3947
  * Refresh the Claude OAuth access token (oauth design D4). HONEST `false` when
3187
3948
  * the block has no refresh_token (setup-token / manual) — no upstream call, the
@@ -3191,30 +3952,44 @@ var JsonSubscriptionCredentialStore = class {
3191
3952
  * errorMessage → `false`.
3192
3953
  */
3193
3954
  async refreshClaudeToken() {
3194
- const config = this.readConfig();
3195
- const active = getActiveAccount(config, "claude");
3196
- const claude = active?.tokens;
3197
- if (!active || !claude?.refreshToken) return false;
3198
- const capturedId = active.id;
3199
- this.materializeMigration(config);
3200
- try {
3201
- const result = await claudeOAuth2.refreshAccessToken(claude.refreshToken, this.fetchImpl);
3202
- const expiresAt = new Date(Date.now() + result.expiresIn * 1e3).toISOString();
3203
- const next = {
3204
- ...claude,
3205
- accessToken: result.accessToken,
3206
- refreshToken: result.refreshToken,
3207
- expiresAt,
3208
- status: "authorized",
3209
- lastRefreshedAt: (/* @__PURE__ */ new Date()).toISOString(),
3210
- errorMessage: void 0
3211
- };
3212
- this.writeBackById("claude", capturedId, next);
3213
- return true;
3214
- } catch (error) {
3215
- this.markExpiredById("claude", capturedId, claude, error);
3216
- return false;
3217
- }
3955
+ return this.coalesce("claude:active", async () => {
3956
+ const config = this.readConfig();
3957
+ const active = getActiveAccount(config, "claude");
3958
+ const claude = active?.tokens;
3959
+ if (!active || !claude?.refreshToken) return false;
3960
+ const capturedId = active.id;
3961
+ this.materializeMigration(config);
3962
+ try {
3963
+ const result = await claudeOAuth2.refreshAccessToken(claude.refreshToken, this.fetchImpl);
3964
+ const expiresAt = new Date(Date.now() + result.expiresIn * 1e3).toISOString();
3965
+ const next = {
3966
+ ...claude,
3967
+ accessToken: result.accessToken,
3968
+ refreshToken: result.refreshToken,
3969
+ expiresAt,
3970
+ status: "authorized",
3971
+ lastRefreshedAt: (/* @__PURE__ */ new Date()).toISOString(),
3972
+ errorMessage: void 0,
3973
+ syncWarning: void 0
3974
+ };
3975
+ this.writeBackById("claude", capturedId, next);
3976
+ this.resyncExternal("claude", capturedId, next);
3977
+ return true;
3978
+ } catch (error) {
3979
+ if (await this.tryExternalImport("claude", capturedId, claude, async (rt) => {
3980
+ const r = await claudeOAuth2.refreshAccessToken(rt, this.fetchImpl);
3981
+ return {
3982
+ accessToken: r.accessToken,
3983
+ refreshToken: r.refreshToken,
3984
+ expiresAt: new Date(Date.now() + r.expiresIn * 1e3).toISOString()
3985
+ };
3986
+ })) {
3987
+ return true;
3988
+ }
3989
+ this.markExpiredById("claude", capturedId, claude, error);
3990
+ return false;
3991
+ }
3992
+ });
3218
3993
  }
3219
3994
  /**
3220
3995
  * Refresh the Codex (ChatGPT) OAuth access token. Same shape
@@ -3222,31 +3997,46 @@ var JsonSubscriptionCredentialStore = class {
3222
3997
  * HONEST `false` when no refresh_token.
3223
3998
  */
3224
3999
  async refreshCodexToken() {
3225
- const config = this.readConfig();
3226
- const active = getActiveAccount(config, "codex");
3227
- const codex = active?.tokens;
3228
- if (!active || !codex?.refreshToken) return false;
3229
- const capturedId = active.id;
3230
- this.materializeMigration(config);
3231
- try {
3232
- const result = await codexOAuth2.refreshAccessToken(codex.refreshToken, this.fetchImpl);
3233
- const expiresAt = new Date(Date.now() + result.expiresIn * 1e3).toISOString();
3234
- const next = {
3235
- ...codex,
3236
- accessToken: result.accessToken,
3237
- refreshToken: result.refreshToken,
3238
- idToken: result.idToken,
3239
- expiresAt,
3240
- status: "authorized",
3241
- lastRefreshedAt: (/* @__PURE__ */ new Date()).toISOString(),
3242
- errorMessage: void 0
3243
- };
3244
- this.writeBackById("codex", capturedId, next);
3245
- return true;
3246
- } catch (error) {
3247
- this.markExpiredById("codex", capturedId, codex, error);
3248
- return false;
3249
- }
4000
+ return this.coalesce("codex:active", async () => {
4001
+ const config = this.readConfig();
4002
+ const active = getActiveAccount(config, "codex");
4003
+ const codex = active?.tokens;
4004
+ if (!active || !codex?.refreshToken) return false;
4005
+ const capturedId = active.id;
4006
+ this.materializeMigration(config);
4007
+ try {
4008
+ const result = await codexOAuth2.refreshAccessToken(codex.refreshToken, this.fetchImpl);
4009
+ const expiresAt = new Date(Date.now() + result.expiresIn * 1e3).toISOString();
4010
+ const next = {
4011
+ ...codex,
4012
+ accessToken: result.accessToken,
4013
+ refreshToken: result.refreshToken,
4014
+ idToken: result.idToken,
4015
+ expiresAt,
4016
+ status: "authorized",
4017
+ lastRefreshedAt: (/* @__PURE__ */ new Date()).toISOString(),
4018
+ errorMessage: void 0,
4019
+ syncWarning: void 0
4020
+ };
4021
+ this.writeBackById("codex", capturedId, next);
4022
+ this.resyncExternal("codex", capturedId, next);
4023
+ return true;
4024
+ } catch (error) {
4025
+ if (await this.tryExternalImport("codex", capturedId, codex, async (rt) => {
4026
+ const r = await codexOAuth2.refreshAccessToken(rt, this.fetchImpl);
4027
+ return {
4028
+ accessToken: r.accessToken,
4029
+ refreshToken: r.refreshToken,
4030
+ idToken: r.idToken,
4031
+ expiresAt: new Date(Date.now() + r.expiresIn * 1e3).toISOString()
4032
+ };
4033
+ })) {
4034
+ return true;
4035
+ }
4036
+ this.markExpiredById("codex", capturedId, codex, error);
4037
+ return false;
4038
+ }
4039
+ });
3250
4040
  }
3251
4041
  /**
3252
4042
  * Refresh the Gemini (Google) OAuth access token. The Google
@@ -3256,30 +4046,175 @@ var JsonSubscriptionCredentialStore = class {
3256
4046
  * destroy the ability to refresh again). HONEST `false` when no refresh_token.
3257
4047
  */
3258
4048
  async refreshGeminiToken() {
3259
- const config = this.readConfig();
3260
- const active = getActiveAccount(config, "gemini");
3261
- const gemini = active?.tokens;
3262
- if (!active || !gemini?.refreshToken) return false;
3263
- const capturedId = active.id;
3264
- this.materializeMigration(config);
3265
- try {
3266
- const result = await geminiOAuth2.refreshAccessToken(gemini.refreshToken, this.fetchImpl);
3267
- const expiresAt = new Date(Date.now() + result.expiresIn * 1e3).toISOString();
3268
- const next = {
3269
- ...gemini,
3270
- // KEEP the existing refreshToken (response omits it).
3271
- accessToken: result.accessToken,
3272
- expiresAt,
3273
- status: "authorized",
3274
- lastRefreshedAt: (/* @__PURE__ */ new Date()).toISOString(),
3275
- errorMessage: void 0
3276
- };
3277
- this.writeBackById("gemini", capturedId, next);
3278
- return true;
3279
- } catch (error) {
3280
- this.markExpiredById("gemini", capturedId, gemini, error);
4049
+ return this.coalesce("gemini:active", async () => {
4050
+ const config = this.readConfig();
4051
+ const active = getActiveAccount(config, "gemini");
4052
+ const gemini = active?.tokens;
4053
+ if (!active || !gemini?.refreshToken) return false;
4054
+ const capturedId = active.id;
4055
+ this.materializeMigration(config);
4056
+ try {
4057
+ const result = await geminiOAuth2.refreshAccessToken(gemini.refreshToken, this.fetchImpl);
4058
+ const expiresAt = new Date(Date.now() + result.expiresIn * 1e3).toISOString();
4059
+ const next = {
4060
+ ...gemini,
4061
+ // KEEP the existing refreshToken (response omits it).
4062
+ accessToken: result.accessToken,
4063
+ expiresAt,
4064
+ status: "authorized",
4065
+ lastRefreshedAt: (/* @__PURE__ */ new Date()).toISOString(),
4066
+ errorMessage: void 0
4067
+ };
4068
+ this.writeBackById("gemini", capturedId, next);
4069
+ return true;
4070
+ } catch (error) {
4071
+ this.markExpiredById("gemini", capturedId, gemini, error);
4072
+ return false;
4073
+ }
4074
+ });
4075
+ }
4076
+ /**
4077
+ * Refresh a SPECIFIC account by id (background scheduler sweep,
4078
+ * external-cli-sync). Unlike the active-account refreshers it does NOT
4079
+ * attempt the external-import fallback — the external CLI file's lineage can
4080
+ * only plausibly match the ACTIVE account. Coalesced per `provider:id`; on
4081
+ * failure flags ONLY that account `expired`.
4082
+ */
4083
+ async refreshAccountById(provider, id) {
4084
+ return this.coalesce(`${provider}:${id}`, async () => {
4085
+ const config = this.readConfig();
4086
+ const account = getAccountById(config, provider, id);
4087
+ const captured = account?.tokens;
4088
+ if (!account || !captured?.refreshToken) return false;
4089
+ this.materializeMigration(config);
4090
+ try {
4091
+ const refreshed = await this.refreshUpstream(provider, captured.refreshToken);
4092
+ const next = {
4093
+ ...captured,
4094
+ accessToken: refreshed.accessToken,
4095
+ // Gemini's refresh response omits a new refresh token — keep the captured.
4096
+ refreshToken: refreshed.refreshToken ?? captured.refreshToken,
4097
+ expiresAt: refreshed.expiresAt,
4098
+ status: "authorized",
4099
+ lastRefreshedAt: (/* @__PURE__ */ new Date()).toISOString(),
4100
+ errorMessage: void 0,
4101
+ syncWarning: void 0
4102
+ };
4103
+ if (refreshed.idToken) next.idToken = refreshed.idToken;
4104
+ this.writeBackById(provider, id, next);
4105
+ if (provider !== "gemini") this.resyncExternal(provider, id, next);
4106
+ return true;
4107
+ } catch (error) {
4108
+ this.markExpiredById(provider, id, captured, error);
4109
+ return false;
4110
+ }
4111
+ });
4112
+ }
4113
+ /** Dispatch one OAuth refresh round-trip to the provider's shared flow. */
4114
+ async refreshUpstream(provider, refreshToken) {
4115
+ const flow = provider === "claude" ? claudeOAuth2 : provider === "codex" ? codexOAuth2 : geminiOAuth2;
4116
+ const r = await flow.refreshAccessToken(refreshToken, this.fetchImpl);
4117
+ return {
4118
+ accessToken: r.accessToken,
4119
+ refreshToken: r.refreshToken,
4120
+ idToken: r.idToken,
4121
+ expiresAt: new Date(Date.now() + r.expiresIn * 1e3).toISOString()
4122
+ };
4123
+ }
4124
+ /**
4125
+ * External-import fallback for a FAILED active-account refresh
4126
+ * (external-cli-sync). Reads the CLI native store; imports when the external
4127
+ * lineage ROTATED (different refresh token) or its access token is still
4128
+ * valid. When the imported access token is already expired it refreshes once
4129
+ * with the rotated refresh token. A `not-rotated` outcome persists the
4130
+ * `external-not-rotated` warning on the (about-to-be-expired) account so the
4131
+ * UI can tell "genuine revocation" apart from a plain refresh failure.
4132
+ */
4133
+ async tryExternalImport(provider, capturedId, captured, refreshWithToken) {
4134
+ const markerOwner = this.safeReadMarker(provider);
4135
+ if (markerOwner && markerOwner !== capturedId) return false;
4136
+ const external = this.safeReadExternal(provider);
4137
+ const decision = decideExternalImport(captured, external);
4138
+ if (decision === "not-rotated") {
4139
+ captured.syncWarning = "external-not-rotated";
3281
4140
  return false;
3282
4141
  }
4142
+ if (decision !== "import" || !external) return false;
4143
+ let imported = buildImportedTokens(
4144
+ captured,
4145
+ external
4146
+ );
4147
+ const accessStillValid = external.expiresAt ? Date.parse(external.expiresAt) > Date.now() + 6e4 : true;
4148
+ if (!accessStillValid) {
4149
+ try {
4150
+ const refreshed = await refreshWithToken(external.refreshToken);
4151
+ imported = {
4152
+ ...imported,
4153
+ accessToken: refreshed.accessToken,
4154
+ refreshToken: refreshed.refreshToken ?? imported.refreshToken,
4155
+ expiresAt: refreshed.expiresAt,
4156
+ lastRefreshedAt: (/* @__PURE__ */ new Date()).toISOString()
4157
+ };
4158
+ if (refreshed.idToken) imported.idToken = refreshed.idToken;
4159
+ } catch {
4160
+ return false;
4161
+ }
4162
+ }
4163
+ this.writeBackById(provider, capturedId, imported);
4164
+ this.resyncExternal(provider, capturedId, imported);
4165
+ return true;
4166
+ }
4167
+ /**
4168
+ * Marker-gated external write-back (external-cli-sync). After a successful
4169
+ * refresh of the account that OWNS the provider's native CLI store (imported
4170
+ * via `importExternalCliAccount`), push the rotated credential back into the
4171
+ * file — otherwise the daemon's refresh invalidates the single-use refresh
4172
+ * token and silently logs the bare CLI out. NON-FATAL: the internal store is
4173
+ * already persisted; a failed external write only leaves the file stale,
4174
+ * which the `external-divergent` warning surfaces.
4175
+ */
4176
+ resyncExternal(provider, accountId, tokens) {
4177
+ try {
4178
+ this.externalCliStore.writeBack(provider, accountId, tokens);
4179
+ } catch {
4180
+ }
4181
+ }
4182
+ /** Read the marker's owning account id, never letting an fs error escape. */
4183
+ safeReadMarker(provider) {
4184
+ try {
4185
+ return this.externalCliStore.readMarkerAccountId(provider);
4186
+ } catch {
4187
+ return void 0;
4188
+ }
4189
+ }
4190
+ /**
4191
+ * DAEMON-ONLY (admin import button): which providers have a usable external
4192
+ * CLI credential on THIS machine. Pure detection — reads the native files,
4193
+ * never mutates anything, never returns a token.
4194
+ */
4195
+ async listExternalCliAvailability() {
4196
+ return {
4197
+ claude: Boolean(this.safeReadExternal("claude")?.accessToken),
4198
+ codex: Boolean(this.safeReadExternal("codex")?.accessToken)
4199
+ };
4200
+ }
4201
+ /**
4202
+ * DAEMON-ONLY (admin import button): import the external CLI's current login
4203
+ * as a NEW account (+ activate), and take MANAGED ownership of the native
4204
+ * store (marker) so subsequent refreshes write back — keeping the bare CLI
4205
+ * and the daemon on the same live credential instead of silently killing one
4206
+ * side's single-use refresh token.
4207
+ */
4208
+ async importExternalCliAccount(provider, label) {
4209
+ const external = this.safeReadExternal(provider);
4210
+ if (!external?.accessToken) return { ok: false, reason: "no-credential" };
4211
+ const tokens = buildTokensFromExternal(provider, external);
4212
+ const result = await this.appendProviderAccount(provider, tokens, label);
4213
+ try {
4214
+ this.externalCliStore.writeMarker(provider, result.id);
4215
+ } catch {
4216
+ }
4217
+ return { ok: true, id: result.id };
3283
4218
  }
3284
4219
  /**
3285
4220
  * Materialize a lazily-synthesized account id to disk (design D3). On a legacy
@@ -3363,6 +4298,18 @@ var JsonSubscriptionCredentialStore = class {
3363
4298
  this.persist({ ...current, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
3364
4299
  return result;
3365
4300
  }
4301
+ /**
4302
+ * DAEMON-ONLY per-account rename (NOT on the port). Update one account's label;
4303
+ * rejects an unknown id. Label-only — no token material is read or written
4304
+ * (the secret-free invariant holds).
4305
+ */
4306
+ async renameAccount(providerId, id, label) {
4307
+ const current = this.readConfig();
4308
+ const result = renameAccount(current, providerId, id, label);
4309
+ if (!result.ok) return result;
4310
+ this.persist({ ...current, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
4311
+ return result;
4312
+ }
3366
4313
  /**
3367
4314
  * DAEMON-ONLY CLEAR (design D1/D3, NOT on the port). Remove a single provider's
3368
4315
  * block from `tokens.json` and re-persist (the strategies already tolerate an
@@ -3379,9 +4326,9 @@ var JsonSubscriptionCredentialStore = class {
3379
4326
  * → `enc:v1:`; already-`enc:`/`$ENV` untouched) before serializing, so any
3380
4327
  * write — incl. child 4's future refresh writes — lands encrypted. */
3381
4328
  persist(config) {
3382
- mkdirSync2(dirname2(this.tokensPath), { recursive: true });
4329
+ mkdirSync3(dirname4(this.tokensPath), { recursive: true });
3383
4330
  const encrypted = encryptTokens(config, this.box);
3384
- writeFileSync5(this.tokensPath, JSON.stringify(encrypted, null, 2) + "\n", "utf8");
4331
+ writeFileSync7(this.tokensPath, JSON.stringify(encrypted, null, 2) + "\n", "utf8");
3385
4332
  }
3386
4333
  /**
3387
4334
  * Read + parse `tokens.json`, tolerating a missing/corrupt file, then DECRYPT
@@ -3397,10 +4344,10 @@ var JsonSubscriptionCredentialStore = class {
3397
4344
  * `config.ts loadConfig`, which decrypts outside its parse try.
3398
4345
  */
3399
4346
  readConfig() {
3400
- if (!existsSync3(this.tokensPath)) return { updatedAt: "" };
4347
+ if (!existsSync9(this.tokensPath)) return { updatedAt: "" };
3401
4348
  let parsed;
3402
4349
  try {
3403
- const raw = JSON.parse(readFileSync5(this.tokensPath, "utf8"));
4350
+ const raw = JSON.parse(readFileSync9(this.tokensPath, "utf8"));
3404
4351
  parsed = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : null;
3405
4352
  } catch {
3406
4353
  parsed = null;
@@ -3411,6 +4358,95 @@ var JsonSubscriptionCredentialStore = class {
3411
4358
  }
3412
4359
  };
3413
4360
 
4361
+ // src/TokenRefreshScheduler.ts
4362
+ var REFRESH_LEAD_MS = 5 * 6e4;
4363
+ var SWEEP_INTERVAL_MS = 6e4;
4364
+ var OAUTH_PROVIDERS = ["claude", "codex", "gemini"];
4365
+ var TokenRefreshScheduler = class {
4366
+ constructor(store, logger, intervalMs = SWEEP_INTERVAL_MS, leadMs = REFRESH_LEAD_MS) {
4367
+ this.store = store;
4368
+ this.logger = logger;
4369
+ this.intervalMs = intervalMs;
4370
+ this.leadMs = leadMs;
4371
+ }
4372
+ store;
4373
+ logger;
4374
+ intervalMs;
4375
+ leadMs;
4376
+ timer = null;
4377
+ sweeping = false;
4378
+ /** Arm the sweep interval. Idempotent. The timer never holds the loop open. */
4379
+ start() {
4380
+ if (this.timer) return;
4381
+ this.timer = setInterval(() => void this.sweep(), this.intervalMs);
4382
+ this.timer.unref?.();
4383
+ }
4384
+ /** Clear the interval (daemon shutdown / test teardown). Idempotent. */
4385
+ dispose() {
4386
+ if (this.timer) {
4387
+ clearInterval(this.timer);
4388
+ this.timer = null;
4389
+ }
4390
+ }
4391
+ /** One sweep over every account of every OAuth provider. Exposed for tests. */
4392
+ async sweep(now = Date.now()) {
4393
+ if (this.sweeping) return;
4394
+ this.sweeping = true;
4395
+ try {
4396
+ const config = await this.store.getFullConfig();
4397
+ for (const provider of OAUTH_PROVIDERS) {
4398
+ const activeId = getActiveAccount(config, provider)?.id;
4399
+ for (const account of listAccounts(config, provider)) {
4400
+ if (!this.needsRefresh(account.tokens, now)) continue;
4401
+ await this.refreshOne(provider, account.id, account.id === activeId);
4402
+ }
4403
+ }
4404
+ } catch (error) {
4405
+ this.logger.warn("token-refresh sweep failed", {
4406
+ error: error instanceof Error ? error.message : String(error)
4407
+ });
4408
+ } finally {
4409
+ this.sweeping = false;
4410
+ }
4411
+ }
4412
+ /** Expiring within the lead window, refreshable, and not already dead. */
4413
+ needsRefresh(tokens, now) {
4414
+ const t = tokens;
4415
+ if (!t.refreshToken || t.status === "expired" || t.status === "error") return false;
4416
+ if (!t.expiresAt) return false;
4417
+ const expiresAt = Date.parse(t.expiresAt);
4418
+ return Number.isFinite(expiresAt) && now >= expiresAt - this.leadMs;
4419
+ }
4420
+ /** Refresh one account; failures are logged, never thrown (the store has
4421
+ * already flagged the account `expired`). */
4422
+ async refreshOne(provider, id, isActive) {
4423
+ try {
4424
+ const ok = isActive ? await this.refreshActive(provider) : await this.store.refreshAccountById(provider, id);
4425
+ if (!ok) {
4426
+ this.logger.warn("background token refresh failed", { provider, accountId: id });
4427
+ } else {
4428
+ this.logger.info("background token refresh succeeded", { provider, accountId: id });
4429
+ }
4430
+ } catch (error) {
4431
+ this.logger.warn("background token refresh threw", {
4432
+ provider,
4433
+ accountId: id,
4434
+ error: error instanceof Error ? error.message : String(error)
4435
+ });
4436
+ }
4437
+ }
4438
+ refreshActive(provider) {
4439
+ switch (provider) {
4440
+ case "claude":
4441
+ return this.store.refreshClaudeToken();
4442
+ case "codex":
4443
+ return this.store.refreshCodexToken();
4444
+ case "gemini":
4445
+ return this.store.refreshGeminiToken();
4446
+ }
4447
+ }
4448
+ };
4449
+
3414
4450
  // src/bootstrap.ts
3415
4451
  function buildDaemon(config, paths) {
3416
4452
  const logger = new ConsoleLogger();
@@ -3443,7 +4479,14 @@ function buildDaemon(config, paths) {
3443
4479
  autoDisableStore.markAutoDisabled(keyId, status, at);
3444
4480
  }
3445
4481
  );
3446
- const providerProxy = getProviderProxy({ llmConfig, apiKeyPool });
4482
+ const pricingStore = new JsonPricingStore(defaultPricingPath(paths.configPath));
4483
+ const pricingEngine = new PricingEngine(pricingStore, logger);
4484
+ const usageEventStore = new JsonlUsageEventStore(
4485
+ defaultUsageEventsPath(paths.configPath),
4486
+ async (providerId, model) => await pricingEngine.getEntry(providerId, model) !== null
4487
+ );
4488
+ const usageRecorder = new UsageRecorder(usageEventStore, pricingEngine, logger);
4489
+ const providerProxy = getProviderProxy({ llmConfig, apiKeyPool, usageRecorder });
3447
4490
  llmConfig.setReloadHook(() => apiKeyPool.invalidateCache());
3448
4491
  const outboundApiServer = getOutboundApiServer({
3449
4492
  db: keyDb,
@@ -3489,10 +4532,23 @@ function buildDaemon(config, paths) {
3489
4532
  // the multi-account append (`appendProviderAccount`, import re-encrypts at-
3490
4533
  // rest). Confined to the export/import handlers; never reached by a GET.
3491
4534
  migrationCredentialStore: credentialStore,
4535
+ // Code CLI launch (dashboard parity): the external-terminal opener + PATH probe
4536
+ // default to the real implementations; tests inject spies so no window spawns.
4537
+ cliTerminalOpener: paths.cliTerminalOpener,
4538
+ cliPathProbe: paths.cliPathProbe,
4539
+ cliCommandRunner: paths.cliCommandRunner,
4540
+ // Usage/pricing admin surface (usage-pricing child): stats queries go
4541
+ // through the recorder facade, pricing mutations through the engine, and
4542
+ // the row DELETE through the concrete store (delete is store-local — the
4543
+ // core port stays frozen). None of these can reach key material.
4544
+ usageRecorder,
4545
+ pricingEngine,
4546
+ pricingStore,
3492
4547
  // Use the DECRYPTED config so `admin.token` (if stored as `enc:`) is the
3493
4548
  // plaintext bearer the AdminServer's constant-time compare expects (D4).
3494
4549
  getAdminConfig: () => resolveAdminConfig(decryptedConfig.admin)
3495
4550
  });
4551
+ const tokenRefreshScheduler = new TokenRefreshScheduler(credentialStore, logger);
3496
4552
  return {
3497
4553
  logger,
3498
4554
  llmConfig,
@@ -3505,7 +4561,11 @@ function buildDaemon(config, paths) {
3505
4561
  credentialStore,
3506
4562
  subscriptionRegistry,
3507
4563
  subscriptionAccounts,
3508
- adminServer
4564
+ pricingStore,
4565
+ pricingEngine,
4566
+ usageRecorder,
4567
+ adminServer,
4568
+ tokenRefreshScheduler
3509
4569
  };
3510
4570
  }
3511
4571
  function resetDaemonSingletonsForTests() {
@@ -3594,7 +4654,6 @@ export {
3594
4654
  AdminServer,
3595
4655
  ConfigFileProviderConfigSource,
3596
4656
  ConsoleLogger,
3597
- DASHBOARD_HTML,
3598
4657
  DEFAULT_ADMIN_PORT,
3599
4658
  JsonApiServerSettingsStore,
3600
4659
  JsonOutboundKeyDb,