@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/cli.js CHANGED
@@ -157,23 +157,23 @@ function decodeEnvKey(raw) {
157
157
  }
158
158
  return buf;
159
159
  }
160
- function readKeyFile(path) {
161
- const raw = readFileSync(path);
160
+ function readKeyFile(path2) {
161
+ const raw = readFileSync(path2);
162
162
  if (raw.length === KEY_BYTES2) return raw;
163
163
  const text = raw.toString("utf8").trim();
164
164
  if (/^[0-9a-fA-F]{64}$/.test(text)) return Buffer.from(text, "hex");
165
165
  const b64 = Buffer.from(text, "base64");
166
166
  if (b64.length === KEY_BYTES2) return b64;
167
167
  throw new Error(
168
- `master key file '${path}' is invalid: expected 32 raw bytes, 64 hex chars, or 32-byte base64`
168
+ `master key file '${path2}' is invalid: expected 32 raw bytes, 64 hex chars, or 32-byte base64`
169
169
  );
170
170
  }
171
- function generateKeyFile(path) {
171
+ function generateKeyFile(path2) {
172
172
  const key = randomBytes2(KEY_BYTES2);
173
- mkdirSync(dirname(path), { recursive: true });
174
- writeFileSync(path, key, { mode: 384 });
173
+ mkdirSync(dirname(path2), { recursive: true });
174
+ writeFileSync(path2, key, { mode: 384 });
175
175
  try {
176
- chmodSync(path, 384);
176
+ chmodSync(path2, 384);
177
177
  } catch {
178
178
  }
179
179
  return key;
@@ -552,25 +552,25 @@ var secretBox = null;
552
552
  function setSecretBox(box) {
553
553
  secretBox = box;
554
554
  }
555
- function loadConfig(path) {
555
+ function loadConfig(path2) {
556
556
  let raw;
557
557
  try {
558
- raw = readFileSync2(path, "utf8");
558
+ raw = readFileSync2(path2, "utf8");
559
559
  } catch {
560
- throw new Error(`config: cannot read file at '${path}'`);
560
+ throw new Error(`config: cannot read file at '${path2}'`);
561
561
  }
562
562
  let parsed;
563
563
  try {
564
564
  parsed = JSON.parse(raw);
565
565
  } catch {
566
- throw new Error(`config: '${path}' is not valid JSON`);
566
+ throw new Error(`config: '${path2}' is not valid JSON`);
567
567
  }
568
568
  const validated = validateConfig(parsed);
569
569
  return secretBox ? decryptConfigSecrets(validated, secretBox) : validated;
570
570
  }
571
- function saveConfig(path, cfg) {
571
+ function saveConfig(path2, cfg) {
572
572
  const toWrite = secretBox ? encryptConfigSecrets(cfg, secretBox) : cfg;
573
- writeFileSync2(path, JSON.stringify(toWrite, null, 2) + "\n", "utf8");
573
+ writeFileSync2(path2, JSON.stringify(toWrite, null, 2) + "\n", "utf8");
574
574
  }
575
575
 
576
576
  // src/commands/paths.ts
@@ -581,6 +581,12 @@ function defaultKeysPath(configPath) {
581
581
  function defaultTokensPath(configPath) {
582
582
  return join2(dirname2(configPath), "tokens.json");
583
583
  }
584
+ function defaultPricingPath(configPath) {
585
+ return join2(dirname2(configPath), "pricing.json");
586
+ }
587
+ function defaultUsageEventsPath(configPath) {
588
+ return join2(dirname2(configPath), "usage-events.jsonl");
589
+ }
584
590
  function resolveSecretBox(masterKeyFilePath) {
585
591
  return new SecretBox(() => resolveMasterKey({ keyFilePath: masterKeyFilePath }));
586
592
  }
@@ -758,15 +764,15 @@ async function keysRevoke(db, id) {
758
764
  }
759
765
 
760
766
  // src/commands/launch.ts
761
- import { spawn } from "child_process";
762
- import { existsSync as existsSync4 } from "fs";
763
- import { delimiter, join as join3 } from "path";
767
+ import { spawn as spawn2 } from "child_process";
768
+ import { existsSync as existsSync10 } from "fs";
769
+ import { delimiter as delimiter2, join as join5 } from "path";
764
770
  import { parseArgs as parseArgs3 } from "util";
765
771
  import {
766
- buildChatCliLaunchConfig,
767
- buildClaudeCliLaunchConfig,
768
- buildCodexLaunchConfig,
769
- buildGeminiCliLaunchConfig
772
+ buildChatCliLaunchConfig as buildChatCliLaunchConfig2,
773
+ buildClaudeCliLaunchConfig as buildClaudeCliLaunchConfig2,
774
+ buildCodexLaunchConfig as buildCodexLaunchConfig2,
775
+ buildGeminiCliLaunchConfig as buildGeminiCliLaunchConfig2
770
776
  } from "@omnicross/cli-launcher";
771
777
 
772
778
  // src/bootstrap.ts
@@ -782,6 +788,7 @@ import {
782
788
  __resetProviderProxyForTests,
783
789
  getProviderProxy
784
790
  } from "@omnicross/core/provider-proxy";
791
+ import { PricingEngine, UsageRecorder } from "@omnicross/core/usage";
785
792
  import {
786
793
  setSubscriptionAccountService,
787
794
  setSubscriptionProviderRegistry,
@@ -1156,7 +1163,8 @@ async function handleOAuthComplete(providerId, body, deps) {
1156
1163
  const reason = exchangeError instanceof Error ? exchangeError.message : "token exchange failed";
1157
1164
  return err2(502, `oauth token exchange failed for '${providerId}': ${reason}`);
1158
1165
  }
1159
- await deps.subscriptionAccountAppender.appendProviderAccount(providerId, block);
1166
+ const label = typeof body["label"] === "string" && body["label"].trim() ? body["label"].trim() : void 0;
1167
+ await deps.subscriptionAccountAppender.appendProviderAccount(providerId, block, label);
1160
1168
  const status = await statusEntryFor(deps.subscriptionAccounts, providerId);
1161
1169
  return { status: 200, body: status ? { account: status } : { ok: true } };
1162
1170
  }
@@ -1189,8 +1197,207 @@ async function exchangeGemini(code, codeVerifier, exchangeFetch) {
1189
1197
  };
1190
1198
  }
1191
1199
 
1192
- // src/ports/account-multi.ts
1200
+ // src/admin/cliLaunch.ts
1201
+ import { exec, spawn } from "child_process";
1193
1202
  import { randomUUID } from "crypto";
1203
+ import { existsSync as existsSync3 } from "fs";
1204
+ import { delimiter, join as join3 } from "path";
1205
+ import {
1206
+ buildChatCliLaunchConfig,
1207
+ buildClaudeCliLaunchConfig,
1208
+ buildCodexLaunchConfig,
1209
+ buildGeminiCliLaunchConfig
1210
+ } from "@omnicross/cli-launcher";
1211
+ var LAUNCHABLE_CLIS = [
1212
+ { id: "claude", displayName: "Claude Code", command: "claude" },
1213
+ { id: "codex", displayName: "Codex CLI", command: "codex" },
1214
+ { id: "gemini", displayName: "Gemini CLI", command: "gemini" },
1215
+ { id: "qwen", displayName: "Qwen Code", command: "qwen" },
1216
+ { id: "copilot", displayName: "GitHub Copilot CLI", command: "copilot" },
1217
+ { id: "opencode", displayName: "OpenCode", command: "opencode" }
1218
+ ];
1219
+ var INSTALL_COMMANDS = {
1220
+ claude: "npm install -g @anthropic-ai/claude-code",
1221
+ codex: "npm install -g @openai/codex",
1222
+ gemini: "npm install -g @google/gemini-cli",
1223
+ qwen: "npm install -g @qwen-code/qwen-code",
1224
+ copilot: "npm install -g @github/copilot",
1225
+ opencode: "npm install -g opencode-ai"
1226
+ };
1227
+ var LAUNCHABLE_IDS = new Set(LAUNCHABLE_CLIS.map((c) => c.id));
1228
+ function isLaunchCliId(id) {
1229
+ return id !== void 0 && LAUNCHABLE_IDS.has(id);
1230
+ }
1231
+ function probeDefault(candidate) {
1232
+ const segments = (process.env["PATH"] ?? "").split(delimiter).filter(Boolean);
1233
+ for (const seg of segments) {
1234
+ const full = join3(seg, candidate);
1235
+ if (existsSync3(full)) return full;
1236
+ }
1237
+ return null;
1238
+ }
1239
+ function isCliInstalled(command, platform = process.platform, probe = probeDefault) {
1240
+ if (platform === "win32") {
1241
+ return Boolean(probe(`${command}.exe`) || probe(`${command}.cmd`) || probe(`${command}.bat`));
1242
+ }
1243
+ return Boolean(probe(command));
1244
+ }
1245
+ function detectClis(platform = process.platform, probe = probeDefault) {
1246
+ return LAUNCHABLE_CLIS.map((c) => ({
1247
+ id: c.id,
1248
+ displayName: c.displayName,
1249
+ command: c.command,
1250
+ installed: isCliInstalled(c.command, platform, probe),
1251
+ installable: Boolean(INSTALL_COMMANDS[c.id])
1252
+ }));
1253
+ }
1254
+ function resolveLaunchTarget(providers, requested) {
1255
+ 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));
1256
+ if (!pick) {
1257
+ throw new Error("no provider with a model is configured \u2014 add one on the Providers page first");
1258
+ }
1259
+ const model = requested?.model || firstModel(pick);
1260
+ if (!model) {
1261
+ throw new Error(`provider "${pick.id}" has no models \u2014 add a model on the Providers page first`);
1262
+ }
1263
+ return { providerId: pick.id, model };
1264
+ }
1265
+ function firstModel(p) {
1266
+ return p.models?.[0] ?? p.modelConfigs?.[0]?.id;
1267
+ }
1268
+ async function buildLaunchEnv(cli, llmConfig, target) {
1269
+ const common = {
1270
+ llmConfig,
1271
+ providerId: target.providerId,
1272
+ model: target.model,
1273
+ sessionId: `dashboard:${cli}`
1274
+ };
1275
+ switch (cli) {
1276
+ case "claude":
1277
+ return buildClaudeCliLaunchConfig(common);
1278
+ case "codex":
1279
+ return buildCodexLaunchConfig(common);
1280
+ case "gemini":
1281
+ return buildGeminiCliLaunchConfig(common);
1282
+ case "qwen":
1283
+ case "copilot":
1284
+ case "opencode":
1285
+ return buildChatCliLaunchConfig({ backendId: cli, ...common });
1286
+ }
1287
+ }
1288
+ function shq(s) {
1289
+ return `'${s.replace(/'/g, `'\\''`)}'`;
1290
+ }
1291
+ var defaultTerminalOpener = ({ cli, command, extraArgs, env, cwd, platform }) => {
1292
+ const childEnv = { ...process.env, ...env };
1293
+ if (platform === "win32") {
1294
+ const args = ["/c", "start", `"omnicross ${cli}"`];
1295
+ if (cwd) args.push("/D", `"${cwd}"`);
1296
+ args.push("cmd", "/k", command, ...extraArgs);
1297
+ spawn(process.env["ComSpec"] || "cmd.exe", args, {
1298
+ env: childEnv,
1299
+ windowsVerbatimArguments: true,
1300
+ detached: true,
1301
+ stdio: "ignore"
1302
+ }).unref();
1303
+ return;
1304
+ }
1305
+ const exportLine = Object.entries(env).map(([k, v]) => `export ${k}=${shq(v)}`).join("; ");
1306
+ const runLine = [command, ...extraArgs].map(shq).join(" ");
1307
+ const script = `${exportLine}; ${cwd ? `cd ${shq(cwd)}; ` : ""}${runLine}`;
1308
+ if (platform === "darwin") {
1309
+ const osa = `tell application "Terminal" to do script ${JSON.stringify(script)}`;
1310
+ spawn("osascript", ["-e", osa], { detached: true, stdio: "ignore" }).unref();
1311
+ return;
1312
+ }
1313
+ spawn("x-terminal-emulator", ["-e", "bash", "-lc", `${script}; exec bash`], {
1314
+ detached: true,
1315
+ stdio: "ignore"
1316
+ }).unref();
1317
+ };
1318
+ var sessions = /* @__PURE__ */ new Map();
1319
+ function errBody(message) {
1320
+ return { error: { type: "admin_api_error", message } };
1321
+ }
1322
+ var defaultCommandRunner = (command) => new Promise((resolve) => {
1323
+ exec(command, { timeout: 18e4 }, (err5, _stdout, stderr) => {
1324
+ if (err5) resolve({ ok: false, error: stderr.trim() || err5.message });
1325
+ else resolve({ ok: true });
1326
+ });
1327
+ });
1328
+ async function handleCliInstall(cli, runner = defaultCommandRunner) {
1329
+ const cmd = INSTALL_COMMANDS[cli];
1330
+ if (!cmd) {
1331
+ return { status: 400, body: errBody(`no install command for cli '${cli}' (manual install only)`) };
1332
+ }
1333
+ const result = await runner(cmd);
1334
+ if (!result.ok) {
1335
+ return { status: 500, body: errBody(result.error || "install failed") };
1336
+ }
1337
+ return { status: 200, body: { ok: true } };
1338
+ }
1339
+ function handleCliList(platform = process.platform, probe = probeDefault) {
1340
+ return { status: 200, body: { clis: detectClis(platform, probe) } };
1341
+ }
1342
+ function handleCliSessions() {
1343
+ const list = [...sessions.values()].map(({ onSessionEnd: _drop, ...rest }) => rest);
1344
+ return { status: 200, body: { sessions: list } };
1345
+ }
1346
+ function handleCliStop(id) {
1347
+ const s = sessions.get(id);
1348
+ if (!s) return { status: 404, body: errBody(`session '${id}' not found`) };
1349
+ try {
1350
+ s.onSessionEnd();
1351
+ } catch {
1352
+ }
1353
+ sessions.delete(id);
1354
+ return { status: 200, body: { ok: true } };
1355
+ }
1356
+ async function handleCliLaunch(cli, body, ctx) {
1357
+ const platform = ctx.platform ?? process.platform;
1358
+ const probe = ctx.probe ?? probeDefault;
1359
+ const meta = LAUNCHABLE_CLIS.find((c) => c.id === cli);
1360
+ if (!meta) return { status: 404, body: errBody(`unknown cli '${cli}'`) };
1361
+ if (!isCliInstalled(meta.command, platform, probe)) {
1362
+ return { status: 400, body: errBody(`"${meta.command}" is not installed (not found on PATH)`) };
1363
+ }
1364
+ let target;
1365
+ try {
1366
+ target = resolveLaunchTarget(ctx.providers, {
1367
+ providerId: typeof body["providerId"] === "string" ? body["providerId"] : void 0,
1368
+ model: typeof body["model"] === "string" ? body["model"] : void 0
1369
+ });
1370
+ } catch (err5) {
1371
+ return { status: 400, body: errBody(err5 instanceof Error ? err5.message : "no launch target") };
1372
+ }
1373
+ let launch;
1374
+ try {
1375
+ launch = await buildLaunchEnv(cli, ctx.llmConfig, target);
1376
+ } catch (err5) {
1377
+ return { status: 400, body: errBody(err5 instanceof Error ? err5.message : "failed to build launch env") };
1378
+ }
1379
+ const cwd = typeof body["cwd"] === "string" && body["cwd"].trim() ? body["cwd"].trim() : void 0;
1380
+ const opener = ctx.opener ?? defaultTerminalOpener;
1381
+ try {
1382
+ opener({ cli, command: meta.command, extraArgs: launch.extraArgs ?? [], env: launch.env, cwd, platform });
1383
+ } catch (err5) {
1384
+ launch.onSessionEnd();
1385
+ return { status: 500, body: errBody(err5 instanceof Error ? err5.message : "failed to open terminal") };
1386
+ }
1387
+ const id = randomUUID();
1388
+ sessions.set(id, {
1389
+ id,
1390
+ cli,
1391
+ providerId: target.providerId,
1392
+ model: target.model,
1393
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
1394
+ onSessionEnd: launch.onSessionEnd
1395
+ });
1396
+ return { status: 200, body: { sessionId: id, providerId: target.providerId, model: target.model } };
1397
+ }
1398
+
1399
+ // src/ports/account-multi.ts
1400
+ import { randomUUID as randomUUID2 } from "crypto";
1194
1401
  var PROVIDER_KEYS = {
1195
1402
  claude: { block: "claude", accounts: "claudeAccounts", active: "activeClaudeAccountId" },
1196
1403
  codex: { block: "codex", accounts: "codexAccounts", active: "activeCodexAccountId" },
@@ -1256,7 +1463,7 @@ function migrateLazily(config) {
1256
1463
  }
1257
1464
  function addAccount(config, p, tokens, label) {
1258
1465
  const accounts = [...getAccounts(config, p)];
1259
- const id = randomUUID();
1466
+ const id = randomUUID2();
1260
1467
  accounts.push({
1261
1468
  id,
1262
1469
  label: label ?? `Account ${accounts.length + 1}`,
@@ -1293,6 +1500,13 @@ function setActiveAccount(config, p, id) {
1293
1500
  deriveMirror(config, p);
1294
1501
  return { ok: true };
1295
1502
  }
1503
+ function listAccounts(config, p) {
1504
+ return getAccounts(config, p);
1505
+ }
1506
+ function getAccountById(config, p, id) {
1507
+ const account = getAccounts(config, p).find((a) => a.id === id);
1508
+ return account ? { id: account.id, tokens: account.tokens } : void 0;
1509
+ }
1296
1510
  function getActiveAccount(config, p) {
1297
1511
  const active = getAccounts(config, p).find((a) => a.id === getActiveId(config, p));
1298
1512
  return active ? { id: active.id, tokens: active.tokens } : void 0;
@@ -1326,12 +1540,27 @@ function sanitizeAccounts(config, p) {
1326
1540
  id: a.id,
1327
1541
  label: a.label,
1328
1542
  status: t.status ?? "unconfigured",
1543
+ authMethod: t.authMethod,
1544
+ subscriptionLevel: t.subscriptionLevel,
1329
1545
  expiresAt: t.expiresAt,
1546
+ lastRefreshedAt: t.lastRefreshedAt,
1547
+ isSetupToken: t.isSetupToken,
1330
1548
  hasAccessToken: !!(t.accessToken || t.apiKey),
1331
- isActive: a.id === activeId
1549
+ isActive: a.id === activeId,
1550
+ syncWarning: t.syncWarning
1332
1551
  };
1333
1552
  });
1334
1553
  }
1554
+ function renameAccount(config, p, id, label) {
1555
+ const accounts = getAccounts(config, p);
1556
+ if (!accounts.some((a) => a.id === id)) return { ok: false };
1557
+ setAccounts(
1558
+ config,
1559
+ p,
1560
+ accounts.map((a) => a.id === id ? { ...a, label } : a)
1561
+ );
1562
+ return { ok: true };
1563
+ }
1335
1564
  function clearProvider(config, p) {
1336
1565
  setBlock(config, p, void 0);
1337
1566
  setAccounts(config, p, void 0);
@@ -1578,6 +1807,169 @@ async function handleImport(body, deps) {
1578
1807
  }
1579
1808
  }
1580
1809
 
1810
+ // src/admin/usagePricing.ts
1811
+ var err4 = (status, message) => ({
1812
+ status,
1813
+ body: { error: { type: "admin_api_error", message } }
1814
+ });
1815
+ function parseFiniteInt(raw) {
1816
+ if (raw === null || raw.trim() === "") return null;
1817
+ const n = Number(raw);
1818
+ return Number.isFinite(n) && Number.isInteger(n) ? n : null;
1819
+ }
1820
+ function parseRange(query) {
1821
+ const startTs = parseFiniteInt(query.get("startTs"));
1822
+ const endTs = parseFiniteInt(query.get("endTs"));
1823
+ if (startTs === null || endTs === null) {
1824
+ return err4(400, "startTs and endTs are required finite-integer unix-millis query params");
1825
+ }
1826
+ return { startTs, endTs };
1827
+ }
1828
+ var isRange = (v) => v.startTs !== void 0 && !("status" in v);
1829
+ async function handleUsageGet(view, query, deps) {
1830
+ const range = parseRange(query);
1831
+ if (!isRange(range)) return range;
1832
+ switch (view) {
1833
+ case "totals":
1834
+ return { status: 200, body: await deps.usageRecorder.getTotals(range) };
1835
+ case "by-model":
1836
+ return { status: 200, body: await deps.usageRecorder.getByModel(range) };
1837
+ case "by-api-key": {
1838
+ const rows = await deps.usageRecorder.getByApiKey(range);
1839
+ const labels = poolKeyLabels(loadConfig(deps.configPath));
1840
+ return {
1841
+ status: 200,
1842
+ body: rows.map((r) => {
1843
+ if (r.apiKeyId === null) {
1844
+ return { ...r, label: "unattributed", providerId: null };
1845
+ }
1846
+ const known = labels.get(r.apiKeyId);
1847
+ return known ? { ...r, label: known.label, providerId: known.providerId } : { ...r, label: r.apiKeyId };
1848
+ })
1849
+ };
1850
+ }
1851
+ default:
1852
+ return err4(404, `unknown usage view '${view ?? ""}'`);
1853
+ }
1854
+ }
1855
+ function poolKeyLabels(cfg) {
1856
+ const out = /* @__PURE__ */ new Map();
1857
+ for (const provider of cfg.providers) {
1858
+ for (const key of provider.apiKeys ?? []) {
1859
+ out.set(key.id, {
1860
+ label: key.label && key.label.length > 0 ? key.label : key.id,
1861
+ providerId: provider.id
1862
+ });
1863
+ }
1864
+ }
1865
+ return out;
1866
+ }
1867
+ var INVALID_PRICE = /* @__PURE__ */ Symbol("invalid-price");
1868
+ function parseOptionalPrice(b, key) {
1869
+ if (!(key in b) || b[key] === null || b[key] === void 0) return null;
1870
+ const v = b[key];
1871
+ return typeof v === "number" && Number.isFinite(v) ? v : INVALID_PRICE;
1872
+ }
1873
+ function parsePricingEntryInput(raw) {
1874
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
1875
+ const b = raw;
1876
+ const providerId = typeof b["providerId"] === "string" && b["providerId"].trim() ? b["providerId"].trim() : "";
1877
+ const modelId = typeof b["modelId"] === "string" && b["modelId"].trim() ? b["modelId"].trim() : "";
1878
+ const inputPrice = b["inputPricePer1m"];
1879
+ const outputPrice = b["outputPricePer1m"];
1880
+ if (!providerId || !modelId) return null;
1881
+ if (typeof inputPrice !== "number" || !Number.isFinite(inputPrice)) return null;
1882
+ if (typeof outputPrice !== "number" || !Number.isFinite(outputPrice)) return null;
1883
+ const cacheRead = parseOptionalPrice(b, "cacheReadPricePer1m");
1884
+ const cacheWrite = parseOptionalPrice(b, "cacheWritePricePer1m");
1885
+ if (cacheRead === INVALID_PRICE || cacheWrite === INVALID_PRICE) return null;
1886
+ return {
1887
+ providerId,
1888
+ modelId,
1889
+ inputPricePer1m: inputPrice,
1890
+ outputPricePer1m: outputPrice,
1891
+ cacheReadPricePer1m: cacheRead,
1892
+ cacheWritePricePer1m: cacheWrite
1893
+ };
1894
+ }
1895
+ async function handlePricingList(deps) {
1896
+ return { status: 200, body: { entries: await deps.pricingEngine.getAll() } };
1897
+ }
1898
+ async function handlePricingUpsert(body, deps) {
1899
+ const input = parsePricingEntryInput(body);
1900
+ if (!input) {
1901
+ return err4(400, "invalid pricing entry (providerId, modelId, finite numeric inputPricePer1m/outputPricePer1m required)");
1902
+ }
1903
+ const entry = await deps.pricingEngine.upsertManual(input);
1904
+ return { status: 200, body: { entry } };
1905
+ }
1906
+ async function handlePricingDelete(query, deps) {
1907
+ const providerId = query.get("providerId")?.trim() ?? "";
1908
+ const modelId = query.get("modelId")?.trim() ?? "";
1909
+ if (!providerId || !modelId) {
1910
+ return err4(400, "delete requires providerId and modelId query params");
1911
+ }
1912
+ const deleted = await deps.pricingStore.delete(providerId, modelId);
1913
+ if (deleted) await deps.pricingEngine.invalidateCache();
1914
+ return { status: 200, body: { deleted } };
1915
+ }
1916
+ async function handlePricingFetchLatest(deps) {
1917
+ try {
1918
+ const result = await deps.pricingEngine.fetchLatestFromSource();
1919
+ return {
1920
+ status: 200,
1921
+ body: {
1922
+ appliedCount: result.applied.length,
1923
+ conflicts: result.conflicts,
1924
+ fetchedAt: result.fetchedAt,
1925
+ sourceUrl: result.sourceUrl
1926
+ }
1927
+ };
1928
+ } catch (e) {
1929
+ return err4(502, `pricing-source fetch failed: ${e instanceof Error ? e.message : String(e)}`);
1930
+ }
1931
+ }
1932
+ async function handlePricingResolveConflicts(body, deps) {
1933
+ const raw = body["resolutions"];
1934
+ if (!Array.isArray(raw)) {
1935
+ return err4(400, "resolve-conflicts requires { resolutions: [...] }");
1936
+ }
1937
+ const currentRows = await deps.pricingStore.getAll();
1938
+ const userEditedKeys = new Set(
1939
+ currentRows.filter((r) => r.userEdited).map((r) => `${r.providerId}::${r.modelId}`)
1940
+ );
1941
+ const decisions = [];
1942
+ const pendingIncoming = /* @__PURE__ */ new Map();
1943
+ let staleCount = 0;
1944
+ for (const item of raw) {
1945
+ if (!item || typeof item !== "object") return err4(400, "invalid resolution entry");
1946
+ const r = item;
1947
+ const action = r["action"];
1948
+ if (action !== "overwrite" && action !== "skip") {
1949
+ return err4(400, "resolution action must be 'overwrite' or 'skip'");
1950
+ }
1951
+ const providerId = typeof r["providerId"] === "string" && r["providerId"].trim() ? r["providerId"].trim() : "";
1952
+ const modelId = typeof r["modelId"] === "string" && r["modelId"].trim() ? r["modelId"].trim() : "";
1953
+ if (!providerId || !modelId) {
1954
+ return err4(400, "each resolution requires top-level providerId and modelId");
1955
+ }
1956
+ const incoming = parsePricingEntryInput(r["incoming"]);
1957
+ if (!incoming) return err4(400, "each resolution must echo a valid incoming pricing entry");
1958
+ if (incoming.providerId !== providerId || incoming.modelId !== modelId) {
1959
+ return err4(400, "resolution providerId/modelId must match the echoed incoming entry");
1960
+ }
1961
+ const key = `${providerId}::${modelId}`;
1962
+ if (action === "overwrite" && !userEditedKeys.has(key)) {
1963
+ staleCount += 1;
1964
+ continue;
1965
+ }
1966
+ decisions.push({ providerId, modelId, action });
1967
+ pendingIncoming.set(key, incoming);
1968
+ }
1969
+ const resolution = await deps.pricingEngine.resolveConflicts(decisions, pendingIncoming);
1970
+ return { status: 200, body: { ...resolution, staleCount } };
1971
+ }
1972
+
1581
1973
  // src/admin/adminApi.ts
1582
1974
  function readBody(req) {
1583
1975
  return new Promise((resolve, reject) => {
@@ -1668,9 +2060,9 @@ function toProviderView(row) {
1668
2060
  selectedApiModeId: row.selectedApiModeId
1669
2061
  };
1670
2062
  }
1671
- async function handleAdminApi(req, res, path, deps) {
2063
+ async function handleAdminApi(req, res, path2, deps) {
1672
2064
  const method = (req.method ?? "GET").toUpperCase();
1673
- const sub = path.slice("/admin/api/".length);
2065
+ const sub = path2.slice("/admin/api/".length);
1674
2066
  const [resource, ...rest] = sub.split("/").filter((s) => s.length > 0);
1675
2067
  try {
1676
2068
  switch (resource) {
@@ -1684,6 +2076,8 @@ async function handleAdminApi(req, res, path, deps) {
1684
2076
  return await handleServer(req, res, method, deps);
1685
2077
  case "accounts":
1686
2078
  return await handleAccounts(req, res, method, rest, deps);
2079
+ case "cli":
2080
+ return await handleCli(req, res, method, rest, deps);
1687
2081
  case "status":
1688
2082
  return await handleStatus(res, method, deps);
1689
2083
  case "playground":
@@ -1692,12 +2086,47 @@ async function handleAdminApi(req, res, path, deps) {
1692
2086
  return await handleMigrationExport(req, res, method, deps);
1693
2087
  case "import":
1694
2088
  return await handleMigrationImport(req, res, method, deps);
2089
+ case "usage":
2090
+ return await handleUsage(req, res, method, rest, deps);
2091
+ case "pricing":
2092
+ return await handlePricing(req, res, method, rest, deps);
1695
2093
  default:
1696
2094
  return writeJsonError(res, 404, `unknown admin resource '${resource}'`);
1697
2095
  }
1698
- } catch (err4) {
1699
- writeJsonError(res, 500, err4 instanceof Error ? err4.message : String(err4));
2096
+ } catch (err5) {
2097
+ writeJsonError(res, 500, err5 instanceof Error ? err5.message : String(err5));
2098
+ }
2099
+ }
2100
+ function requestQuery(req) {
2101
+ const raw = req.url ?? "";
2102
+ const qIdx = raw.indexOf("?");
2103
+ return new URLSearchParams(qIdx >= 0 ? raw.slice(qIdx + 1) : "");
2104
+ }
2105
+ function writeResult(res, result) {
2106
+ writeJson(res, result.status, result.body);
2107
+ }
2108
+ async function handleUsage(req, res, method, rest, deps) {
2109
+ if (method !== "GET") return writeJsonError(res, 405, `method ${method} not allowed on usage`);
2110
+ return writeResult(res, await handleUsageGet(rest[0], requestQuery(req), deps));
2111
+ }
2112
+ async function handlePricing(req, res, method, rest, deps) {
2113
+ if (rest.length === 0) {
2114
+ if (method === "GET") return writeResult(res, await handlePricingList(deps));
2115
+ if (method === "PUT") {
2116
+ return writeResult(res, await handlePricingUpsert(await readJsonBody(req), deps));
2117
+ }
2118
+ if (method === "DELETE") {
2119
+ return writeResult(res, await handlePricingDelete(requestQuery(req), deps));
2120
+ }
2121
+ return writeJsonError(res, 405, `method ${method} not allowed on pricing`);
2122
+ }
2123
+ if (method === "POST" && rest.length === 1 && rest[0] === "fetch-latest") {
2124
+ return writeResult(res, await handlePricingFetchLatest(deps));
1700
2125
  }
2126
+ if (method === "POST" && rest.length === 1 && rest[0] === "resolve-conflicts") {
2127
+ return writeResult(res, await handlePricingResolveConflicts(await readJsonBody(req), deps));
2128
+ }
2129
+ return writeJsonError(res, 404, `unknown pricing route '${rest.join("/")}'`);
1701
2130
  }
1702
2131
  function migrationDeps(deps) {
1703
2132
  return {
@@ -1745,6 +2174,11 @@ async function handleProviders(req, res, method, rest, deps) {
1745
2174
  if (method === "POST" && rest.length === 2 && rest[1] === "test") {
1746
2175
  return await handleTestModel(req, res, rest[0], cfg);
1747
2176
  }
2177
+ if (method === "GET" && rest.length === 2 && rest[1] === "reveal-key") {
2178
+ const row = cfg.providers.find((p) => p.id === rest[0]);
2179
+ if (!row) return writeJsonError(res, 404, `provider '${rest[0]}' not found`);
2180
+ return writeJson(res, 200, { apiKey: row.apiKey ?? "" });
2181
+ }
1748
2182
  if (method === "GET") {
1749
2183
  return writeJson(res, 200, { providers: cfg.providers.map(toProviderView) });
1750
2184
  }
@@ -1840,8 +2274,8 @@ async function handleDiscoverModels(res, id, cfg) {
1840
2274
  const data = await response.json();
1841
2275
  const models = Array.isArray(data?.data) ? data.data.map((m) => typeof m?.id === "string" ? m.id : "").filter((m) => m.length > 0) : [];
1842
2276
  return writeJson(res, 200, { models });
1843
- } catch (err4) {
1844
- const message = err4 instanceof Error ? err4.message : String(err4);
2277
+ } catch (err5) {
2278
+ const message = err5 instanceof Error ? err5.message : String(err5);
1845
2279
  return writeJson(res, 200, { models: [], error: `discovery failed: ${message}` });
1846
2280
  }
1847
2281
  }
@@ -1900,8 +2334,8 @@ async function handleTestModel(req, res, id, cfg) {
1900
2334
  latencyMs,
1901
2335
  sample: extractSampleText(text, row.apiFormat)
1902
2336
  });
1903
- } catch (err4) {
1904
- const message = err4 instanceof Error ? err4.message : String(err4);
2337
+ } catch (err5) {
2338
+ const message = err5 instanceof Error ? err5.message : String(err5);
1905
2339
  return writeJson(res, 200, { ok: false, latencyMs: Date.now() - startedAt, message });
1906
2340
  }
1907
2341
  }
@@ -2250,7 +2684,8 @@ async function handleAccounts(req, res, method, rest, deps) {
2250
2684
  if (method === "GET" && rest.length === 0) {
2251
2685
  const accounts = await deps.subscriptionAccounts.listAll();
2252
2686
  const providerAccounts = await deps.subscriptionTokenWriter.listSanitizedAccounts();
2253
- return writeJson(res, 200, { accounts, providerAccounts });
2687
+ const externalCli = await deps.subscriptionTokenWriter.listExternalCliAvailability();
2688
+ return writeJson(res, 200, { accounts, providerAccounts, externalCli });
2254
2689
  }
2255
2690
  if (method === "GET" && rest[0] === "codex" && rest[1] === "oauth" && rest[3] === "status") {
2256
2691
  const result = handleCodexOAuthStatus(rest[2], deps);
@@ -2270,6 +2705,47 @@ async function handleAccounts(req, res, method, rest, deps) {
2270
2705
  const result = await handleOAuthComplete(providerId, body2, deps);
2271
2706
  return writeJson(res, result.status, result.body);
2272
2707
  }
2708
+ if (method === "POST" && rest[1] === "accounts") {
2709
+ const body2 = await readJsonBody(req);
2710
+ const block = validateTokenBody(providerId, body2);
2711
+ if (!block) {
2712
+ return writeJsonError(res, 400, `malformed token body for provider '${providerId}'`);
2713
+ }
2714
+ const label = typeof body2["label"] === "string" && body2["label"].trim() ? body2["label"].trim() : void 0;
2715
+ await deps.subscriptionAccountAppender.appendProviderAccount(providerId, block, label);
2716
+ const status2 = await statusEntryFor(deps.subscriptionAccounts, providerId);
2717
+ return writeJson(res, 200, status2 ? { account: status2 } : { ok: true });
2718
+ }
2719
+ if (method === "POST" && rest[1] === "import-external") {
2720
+ if (providerId !== "claude" && providerId !== "codex") {
2721
+ return writeJsonError(res, 400, `provider '${providerId}' has no external CLI store`);
2722
+ }
2723
+ const body2 = await readJsonBody(req);
2724
+ const label = typeof body2["label"] === "string" && body2["label"].trim() ? body2["label"].trim() : void 0;
2725
+ const result = await deps.subscriptionTokenWriter.importExternalCliAccount(providerId, label);
2726
+ if (!result.ok) {
2727
+ return writeJsonError(res, 409, `no usable external ${providerId} CLI credential found`);
2728
+ }
2729
+ const status2 = await statusEntryFor(deps.subscriptionAccounts, providerId);
2730
+ return writeJson(res, 200, { ok: true, account: status2 ?? void 0 });
2731
+ }
2732
+ if (method === "POST" && rest[1] === "refresh") {
2733
+ if (providerId === "opencodego") {
2734
+ return writeJsonError(res, 400, "opencodego credentials are not refreshable");
2735
+ }
2736
+ const writer = deps.subscriptionTokenWriter;
2737
+ const ok = providerId === "claude" ? await writer.refreshClaudeToken() : providerId === "codex" ? await writer.refreshCodexToken() : await writer.refreshGeminiToken();
2738
+ const status2 = await statusEntryFor(deps.subscriptionAccounts, providerId);
2739
+ return writeJson(res, 200, { ok, account: status2 ?? void 0 });
2740
+ }
2741
+ if (method === "POST" && rest[2] === "label") {
2742
+ const accountId = rest[1];
2743
+ const body2 = await readJsonBody(req);
2744
+ const label = typeof body2["label"] === "string" ? body2["label"].trim() : "";
2745
+ const result = await deps.subscriptionTokenWriter.renameAccount(providerId, accountId, label);
2746
+ if (!result.ok) return writeJsonError(res, 404, `account '${accountId}' not found`);
2747
+ return writeJson(res, 200, { ok: true });
2748
+ }
2273
2749
  if (method === "PUT" && rest[1] === "active") {
2274
2750
  const body2 = await readJsonBody(req);
2275
2751
  const id = typeof body2["id"] === "string" ? body2["id"] : "";
@@ -2299,6 +2775,44 @@ async function handleAccounts(req, res, method, rest, deps) {
2299
2775
  }
2300
2776
  return writeJsonError(res, 405, `method ${method} not allowed on accounts`);
2301
2777
  }
2778
+ async function handleCli(req, res, method, rest, deps) {
2779
+ if (method === "GET" && rest.length === 0) {
2780
+ const result = handleCliList(process.platform, deps.cliPathProbe);
2781
+ return writeJson(res, result.status, result.body);
2782
+ }
2783
+ if (method === "GET" && rest[0] === "sessions") {
2784
+ const result = handleCliSessions();
2785
+ return writeJson(res, result.status, result.body);
2786
+ }
2787
+ if (method === "DELETE" && rest[0] === "sessions" && rest[1]) {
2788
+ const result = handleCliStop(rest[1]);
2789
+ return writeJson(res, result.status, result.body);
2790
+ }
2791
+ if (method === "POST" && rest[1] === "install") {
2792
+ const cli = rest[0];
2793
+ if (!isLaunchCliId(cli)) {
2794
+ return writeJsonError(res, 400, `unknown cli '${cli ?? ""}'`);
2795
+ }
2796
+ const result = await handleCliInstall(cli, deps.cliCommandRunner);
2797
+ return writeJson(res, result.status, result.body);
2798
+ }
2799
+ if (method === "POST" && rest[1] === "launch") {
2800
+ const cli = rest[0];
2801
+ if (!isLaunchCliId(cli)) {
2802
+ return writeJsonError(res, 400, `unknown cli '${cli ?? ""}'`);
2803
+ }
2804
+ const body = await readJsonBody(req);
2805
+ const providers = loadConfig(deps.configPath).providers ?? [];
2806
+ const result = await handleCliLaunch(cli, body, {
2807
+ llmConfig: deps.llmConfig,
2808
+ providers,
2809
+ opener: deps.cliTerminalOpener,
2810
+ probe: deps.cliPathProbe
2811
+ });
2812
+ return writeJson(res, result.status, result.body);
2813
+ }
2814
+ return writeJsonError(res, 405, `method ${method} not allowed on cli`);
2815
+ }
2302
2816
  async function handleStatus(res, method, deps) {
2303
2817
  if (method !== "GET") return writeJsonError(res, 405, `method ${method} not allowed on status`);
2304
2818
  const status = deps.outboundApiServer.getStatus();
@@ -2334,21 +2848,21 @@ async function handlePlayground(req, res, method, deps) {
2334
2848
  const payload = body["body"];
2335
2849
  const status = deps.outboundApiServer.getStatus();
2336
2850
  if (!status.running || !status.port) return writeJsonError(res, 503, "outbound server not running");
2337
- const path = resolvePlaygroundPath(endpoint, isRecord(payload) ? payload : {});
2338
- if (!path) return writeJsonError(res, 400, `unknown endpoint '${endpoint}'`);
2851
+ const path2 = resolvePlaygroundPath(endpoint, isRecord(payload) ? payload : {});
2852
+ if (!path2) return writeJsonError(res, 400, `unknown endpoint '${endpoint}'`);
2339
2853
  const upstreamBody = typeof payload === "string" ? payload : JSON.stringify(payload ?? {});
2340
- await proxyToOutbound(res, status.port, path, key, upstreamBody);
2854
+ await proxyToOutbound(res, status.port, path2, key, upstreamBody);
2341
2855
  }
2342
2856
  function isRecord(v) {
2343
2857
  return !!v && typeof v === "object" && !Array.isArray(v);
2344
2858
  }
2345
- function proxyToOutbound(res, outboundPort, path, key, body) {
2859
+ function proxyToOutbound(res, outboundPort, path2, key, body) {
2346
2860
  return new Promise((resolve) => {
2347
2861
  const upstream = http.request(
2348
2862
  {
2349
2863
  host: "127.0.0.1",
2350
2864
  port: outboundPort,
2351
- path,
2865
+ path: path2,
2352
2866
  method: "POST",
2353
2867
  headers: {
2354
2868
  "Content-Type": "application/json",
@@ -2368,8 +2882,8 @@ function proxyToOutbound(res, outboundPort, path, key, body) {
2368
2882
  });
2369
2883
  }
2370
2884
  );
2371
- upstream.on("error", (err4) => {
2372
- if (!res.headersSent) writeJsonError(res, 502, `playground proxy failed: ${err4.message}`);
2885
+ upstream.on("error", (err5) => {
2886
+ if (!res.headersSent) writeJsonError(res, 502, `playground proxy failed: ${err5.message}`);
2373
2887
  else res.end();
2374
2888
  resolve();
2375
2889
  });
@@ -2378,463 +2892,105 @@ function proxyToOutbound(res, outboundPort, path, key, body) {
2378
2892
  });
2379
2893
  }
2380
2894
 
2381
- // src/admin/client.ts
2382
- var DASHBOARD_JS = String.raw`
2383
- (function () {
2384
- var $ = function (id) { return document.getElementById(id); };
2385
- var authToken = null; // set if a 401 ever comes back (token-gated deploys)
2386
-
2387
- function headers(extra) {
2388
- var h = extra || {};
2389
- if (authToken) h['Authorization'] = 'Bearer ' + authToken;
2390
- return h;
2391
- }
2392
-
2393
- async function api(method, path, body) {
2394
- var opt = { method: method, headers: headers(body ? { 'Content-Type': 'application/json' } : {}) };
2395
- if (body) opt.body = JSON.stringify(body);
2396
- var res = await fetch('/admin/api/' + path, opt);
2397
- if (res.status === 401 && !authToken) {
2398
- var t = window.prompt('Admin token required');
2399
- if (t) { authToken = t; return api(method, path, body); }
2400
- }
2401
- var text = await res.text();
2402
- var json = null;
2403
- try { json = text ? JSON.parse(text) : null; } catch (e) { json = { raw: text }; }
2404
- return { status: res.status, json: json };
2405
- }
2406
-
2407
- function clear(el) { while (el.firstChild) el.removeChild(el.firstChild); }
2408
- function td(text) { var c = document.createElement('td'); c.textContent = text == null ? '' : String(text); return c; }
2409
- function btn(label, cls, fn) { var b = document.createElement('button'); b.textContent = label; if (cls) b.className = cls; b.onclick = fn; return b; }
2410
-
2411
- // ── Status ────────────────────────────────────────────────────────────────
2412
- async function loadStatus() {
2413
- var r = await api('GET', 'status');
2414
- var s = r.json || {};
2415
- $('statusBadge').textContent = s.running ? ('running :' + s.port) : 'stopped';
2416
- var html = '';
2417
- if (s.running) {
2418
- html += 'Outbound server <span class="pill ok">running</span> on port ' + s.port + '<br/>';
2419
- if (s.formats) {
2420
- html += '<div class="mono muted">' +
2421
- 'chat: ' + s.formats.chat + '<br/>responses: ' + s.formats.responses +
2422
- '<br/>messages: ' + s.formats.messages + '<br/>gemini: ' + s.formats.gemini + '</div>';
2423
- }
2424
- } else {
2425
- html += 'Outbound server <span class="pill bad">stopped</span>';
2426
- }
2427
- $('statusBody').innerHTML = html;
2428
- }
2429
-
2430
- // ── Providers ───────────────────────────────────────────────────────────────
2431
- // Curated presets loaded from GET /admin/api/presets. Selecting one prefills
2432
- // the add-form (format/base/models); the WRITE still goes through the existing
2433
- // POST/PUT /admin/api/providers path (no new write endpoint).
2434
- var presetsById = {};
2435
- var presetModels = []; // models staged by the last preset prefill
2436
-
2437
- async function loadPresets() {
2438
- var r = await api('GET', 'presets');
2439
- var sel = $('pPreset');
2440
- // Keep the placeholder; drop any previously appended options.
2441
- while (sel.options.length > 1) sel.remove(1);
2442
- presetsById = {};
2443
- (r.json && r.json.presets || []).forEach(function (p) {
2444
- presetsById[p.id] = p;
2445
- var opt = document.createElement('option');
2446
- opt.value = p.id;
2447
- opt.textContent = p.name + ' (' + p.apiFormat + ')';
2448
- sel.appendChild(opt);
2449
- });
2450
- }
2451
-
2452
- function onPresetChange() {
2453
- var p = presetsById[$('pPreset').value];
2454
- if (!p) { presetModels = []; return; }
2455
- if (!$('pId').value.trim()) $('pId').value = p.id;
2456
- $('pFormat').value = p.apiFormat;
2457
- $('pBase').value = p.baseUrl;
2458
- presetModels = Array.isArray(p.models) ? p.models.slice() : [];
2459
- }
2460
-
2461
- async function loadProviders() {
2462
- var r = await api('GET', 'providers');
2463
- var body = $('providersTable').querySelector('tbody');
2464
- clear(body);
2465
- (r.json && r.json.providers || []).forEach(function (p) {
2466
- var tr = document.createElement('tr');
2467
- tr.appendChild(td(p.id));
2468
- tr.appendChild(td(p.apiFormat));
2469
- tr.appendChild(td(p.baseUrl));
2470
- tr.appendChild(td(p.hasApiKey ? p.apiKeyMasked : '(none)'));
2471
- var act = document.createElement('td');
2472
- act.appendChild(btn('Edit', 'secondary', function () {
2473
- $('pId').value = p.id; $('pFormat').value = p.apiFormat; $('pBase').value = p.baseUrl; $('pKey').value = '';
2474
- }));
2475
- act.appendChild(btn('Pool', 'secondary', function () { loadProviderKeys(p.id); }));
2476
- act.appendChild(btn('Delete', 'danger', async function () {
2477
- if (window.confirm('Delete provider ' + p.id + '?')) { await api('DELETE', 'providers/' + encodeURIComponent(p.id)); loadProviders(); }
2478
- }));
2479
- tr.appendChild(act);
2480
- body.appendChild(tr);
2481
- });
2482
- }
2483
-
2484
- // ── Pool health (read-only; key-pool change) ──────────────────────────────
2485
- // GET /admin/api/providers/:id/keys → masked pool view. Multi-key is
2486
- // cold-standby + observable in v1 (no outbound failover yet — see panel note).
2487
- async function loadProviderKeys(providerId) {
2488
- var r = await api('GET', 'providers/' + encodeURIComponent(providerId) + '/keys');
2489
- var panel = $('poolPanel');
2490
- var body = $('poolTable').querySelector('tbody');
2491
- clear(body);
2492
- $('poolTitle').textContent = 'API key pool — ' + providerId;
2493
- (r.json && r.json.keys || []).forEach(function (k) {
2494
- var tr = document.createElement('tr');
2495
- tr.appendChild(td(k.id));
2496
- tr.appendChild(td(k.label));
2497
- tr.appendChild(td(k.apiKeyMasked));
2498
- tr.appendChild(td(k.enabled ? 'yes' : 'no'));
2499
- tr.appendChild(td(k.weight));
2500
- var h = '';
2501
- if (k.health && k.health.autoDisabled) h += 'auto-disabled (' + k.health.autoDisabled.status + ') ';
2502
- if (k.health && k.health.cooldown) h += 'cooldown until ' + new Date(k.health.cooldown.until).toLocaleTimeString();
2503
- tr.appendChild(td(h || 'ok'));
2504
- body.appendChild(tr);
2505
- });
2506
- panel.style.display = 'block';
2507
- }
2508
-
2509
- async function saveProvider() {
2510
- $('pErr').textContent = '';
2511
- var id = $('pId').value.trim();
2512
- if (!id) { $('pErr').textContent = 'id required'; return; }
2513
- var payload = { id: id, apiFormat: $('pFormat').value, baseUrl: $('pBase').value.trim(), apiKey: $('pKey').value };
2514
- // Carry the preset-prefilled models (existing parseProviderInput accepts them).
2515
- if (presetModels.length) payload.models = presetModels;
2516
- // Try PUT first (edit, blank key keeps existing); fall back to POST (create).
2517
- var r = await api('PUT', 'providers/' + encodeURIComponent(id), payload);
2518
- if (r.status === 404) r = await api('POST', 'providers', payload);
2519
- if (r.status >= 400) { $('pErr').textContent = (r.json && r.json.error && r.json.error.message) || ('error ' + r.status); return; }
2520
- $('pId').value = ''; $('pBase').value = ''; $('pKey').value = '';
2521
- $('pPreset').value = ''; presetModels = []; // reset the picker after a write
2522
- loadProviders();
2523
- }
2524
-
2525
- // ── Keys ────────────────────────────────────────────────────────────────────
2526
- async function loadKeys() {
2527
- var r = await api('GET', 'keys');
2528
- var body = $('keysTable').querySelector('tbody');
2529
- clear(body);
2530
- (r.json && r.json.keys || []).forEach(function (k) {
2531
- var tr = document.createElement('tr');
2532
- tr.appendChild(td(k.name));
2533
- tr.appendChild(td(k.keyPrefix));
2534
- tr.appendChild(td(k.enabled ? 'yes' : 'no'));
2535
- tr.appendChild(td(k.revoked ? 'yes' : 'no'));
2536
- var act = document.createElement('td');
2537
- if (!k.revoked) {
2538
- act.appendChild(btn(k.enabled ? 'Disable' : 'Enable', 'secondary', async function () {
2539
- await api('POST', 'keys/' + encodeURIComponent(k.id) + '/enabled', { enabled: !k.enabled }); loadKeys();
2540
- }));
2541
- act.appendChild(btn('Revoke', 'danger', async function () {
2542
- if (window.confirm('Revoke ' + k.name + '?')) { await api('POST', 'keys/' + encodeURIComponent(k.id) + '/revoke'); loadKeys(); }
2543
- }));
2544
- }
2545
- tr.appendChild(act);
2546
- body.appendChild(tr);
2547
- });
2548
- }
2549
-
2550
- function showKeyModal(plaintext) {
2551
- $('keyPlaintext').textContent = plaintext;
2552
- $('keyModalBg').classList.add('show');
2553
- $('keyCopy').onclick = function () { navigator.clipboard && navigator.clipboard.writeText(plaintext); };
2554
- $('keyClose').onclick = function () {
2555
- $('keyModalBg').classList.remove('show');
2556
- $('keyPlaintext').textContent = ''; // never persist the plaintext
2557
- };
2558
- }
2559
-
2560
- async function createKey() {
2561
- var name = $('kName').value.trim() || 'key';
2562
- var r = await api('POST', 'keys', { name: name });
2563
- if (r.json && r.json.plaintextOnce) { showKeyModal(r.json.plaintextOnce); $('kName').value = ''; loadKeys(); }
2564
- }
2565
-
2566
- // ── Server config ───────────────────────────────────────────────────────────
2567
- async function loadServer() {
2568
- var r = await api('GET', 'server');
2569
- var s = (r.json && r.json.server) || {};
2570
- $('sEnabled').checked = !!s.enabled;
2571
- $('sLan').checked = !!s.networkBinding;
2572
- $('sPort').value = s.port || '';
2573
- var body = $('endpointsTable').querySelector('tbody');
2574
- clear(body);
2575
- (s.endpoints || []).forEach(function (e) {
2576
- var tr = document.createElement('tr');
2577
- tr.appendChild(td(e.endpoint));
2578
- tr.appendChild(td(e.defaultModel));
2579
- tr.appendChild(td(e.useSubscription ? 'yes' : 'no'));
2580
- body.appendChild(tr);
2581
- });
2895
+ // src/admin/uiStatic.ts
2896
+ import { existsSync as existsSync4, statSync } from "fs";
2897
+ import { readFile } from "fs/promises";
2898
+ import { createRequire } from "module";
2899
+ import path from "path";
2900
+ var CONTENT_TYPES = {
2901
+ ".html": "text/html; charset=utf-8",
2902
+ ".js": "text/javascript; charset=utf-8",
2903
+ ".mjs": "text/javascript; charset=utf-8",
2904
+ ".css": "text/css; charset=utf-8",
2905
+ ".json": "application/json; charset=utf-8",
2906
+ ".svg": "image/svg+xml",
2907
+ ".png": "image/png",
2908
+ ".ico": "image/x-icon",
2909
+ ".webp": "image/webp",
2910
+ ".woff": "font/woff",
2911
+ ".woff2": "font/woff2",
2912
+ ".ttf": "font/ttf",
2913
+ ".map": "application/json; charset=utf-8",
2914
+ ".txt": "text/plain; charset=utf-8"
2915
+ };
2916
+ function resolveUiDist() {
2917
+ const fromEnv = process.env["OMNICROSS_UI_DIST"];
2918
+ if (fromEnv) {
2919
+ return existsSync4(path.join(fromEnv, "index.html")) ? path.resolve(fromEnv) : null;
2582
2920
  }
2583
-
2584
- async function saveServer() {
2585
- var patch = { enabled: $('sEnabled').checked, networkBinding: $('sLan').checked };
2586
- var port = parseInt($('sPort').value, 10);
2587
- if (port) patch.port = port;
2588
- await api('PUT', 'server', patch);
2589
- loadServer(); loadStatus();
2921
+ try {
2922
+ const req = createRequire(typeof __filename !== "undefined" ? __filename : import.meta.url);
2923
+ const pkgJson = req.resolve("@omnicross/ui/package.json");
2924
+ const dist = path.join(path.dirname(pkgJson), "dist");
2925
+ return existsSync4(path.join(dist, "index.html")) ? dist : null;
2926
+ } catch {
2927
+ return null;
2590
2928
  }
2591
-
2592
- // ── Accounts ────────────────────────────────────────────────────────────────
2593
- // The GET stays token-free (status only). The Save/Clear actions WRITE tokens
2594
- // (secret IN); the form never renders an existing/stored token (write-only).
2595
- async function loadAccounts() {
2596
- var r = await api('GET', 'accounts');
2597
- var body = $('accountsTable').querySelector('tbody');
2598
- clear(body);
2599
- (r.json && r.json.accounts || []).forEach(function (a) {
2600
- var tr = document.createElement('tr');
2601
- tr.appendChild(td(a.displayName || a.providerId));
2602
- tr.appendChild(td(a.kind));
2603
- var st = document.createElement('td');
2604
- var ok = a.credentialStatus && a.credentialStatus.ok;
2605
- var pill = document.createElement('span');
2606
- pill.className = 'pill ' + (ok ? 'ok' : 'bad');
2607
- pill.textContent = ok ? 'ok' : ((a.credentialStatus && a.credentialStatus.reason) || 'no credential');
2608
- st.appendChild(pill);
2609
- tr.appendChild(st);
2610
- var act = document.createElement('td');
2611
- act.appendChild(btn('Clear', 'danger', async function () {
2612
- if (window.confirm('Clear ' + a.providerId + ' token?')) {
2613
- await api('DELETE', 'accounts/' + encodeURIComponent(a.providerId));
2614
- loadAccounts();
2615
- }
2616
- }));
2617
- tr.appendChild(act);
2618
- body.appendChild(tr);
2619
- });
2620
- renderProviderAccounts(r.json && r.json.providerAccounts || {});
2929
+ }
2930
+ async function handleUiStatic(req, res, urlPath, uiDist) {
2931
+ if (urlPath !== "/ui" && !urlPath.startsWith("/ui/")) return false;
2932
+ if (req.method !== "GET" && req.method !== "HEAD") {
2933
+ res.writeHead(405, { "Content-Type": "application/json" });
2934
+ res.end(JSON.stringify({ error: { type: "method_not_allowed", message: "GET/HEAD only" } }));
2935
+ return true;
2621
2936
  }
2622
-
2623
- // Per-provider sanitized accounts (multi-account). Secrets IN-never-OUT: this
2624
- // view shows id/label/status/active only — set-active + delete are STATUS-ONLY.
2625
- function renderProviderAccounts(byProvider) {
2626
- var body = $('providerAccountsTable').querySelector('tbody');
2627
- clear(body);
2628
- Object.keys(byProvider).forEach(function (provider) {
2629
- (byProvider[provider] || []).forEach(function (acc) {
2630
- var tr = document.createElement('tr');
2631
- tr.appendChild(td(provider));
2632
- tr.appendChild(td(acc.label || acc.id));
2633
- tr.appendChild(td(acc.status));
2634
- tr.appendChild(td(acc.isActive ? 'yes' : ''));
2635
- var act = document.createElement('td');
2636
- if (!acc.isActive) {
2637
- act.appendChild(btn('Set active', '', async function () {
2638
- await api('PUT', 'accounts/' + encodeURIComponent(provider) + '/active', { id: acc.id });
2639
- loadAccounts();
2640
- }));
2937
+ if (!uiDist) {
2938
+ res.writeHead(404, { "Content-Type": "application/json" });
2939
+ res.end(
2940
+ JSON.stringify({
2941
+ error: {
2942
+ type: "ui_not_installed",
2943
+ message: "Control Panel UI not installed (@omnicross/ui has no built dist). Install/build @omnicross/ui or set OMNICROSS_UI_DIST."
2641
2944
  }
2642
- act.appendChild(btn('Delete', 'danger', async function () {
2643
- if (window.confirm('Delete account ' + (acc.label || acc.id) + '?')) {
2644
- await api('DELETE', 'accounts/' + encodeURIComponent(provider) + '/' + encodeURIComponent(acc.id));
2645
- loadAccounts();
2646
- }
2647
- }));
2648
- tr.appendChild(act);
2649
- body.appendChild(tr);
2650
- });
2651
- });
2652
- }
2653
-
2654
- async function saveAccount() {
2655
- $('acErr').textContent = '';
2656
- var provider = $('acProvider').value;
2657
- var raw = $('acBody').value.trim();
2658
- if (!raw) { $('acErr').textContent = 'paste a token JSON'; return; }
2659
- var payload; try { payload = JSON.parse(raw); } catch (e) { $('acErr').textContent = 'invalid JSON'; return; }
2660
- var r = await api('PUT', 'accounts/' + encodeURIComponent(provider), payload);
2661
- if (r.status >= 400) { $('acErr').textContent = (r.json && r.json.error && r.json.error.message) || ('error ' + r.status); return; }
2662
- $('acBody').value = ''; // never persist/echo the just-saved token
2663
- loadAccounts();
2664
- }
2665
-
2666
- // ── Playground ──────────────────────────────────────────────────────────────
2667
- async function sendPlayground() {
2668
- var pre = $('plResponse');
2669
- pre.classList.remove('muted');
2670
- pre.textContent = 'sending…';
2671
- var bodyText = $('plBody').value;
2672
- var parsed; try { parsed = JSON.parse(bodyText); } catch (e) { parsed = bodyText; }
2673
- var r = await fetch('/admin/api/playground', {
2674
- method: 'POST',
2675
- headers: headers({ 'Content-Type': 'application/json' }),
2676
- body: JSON.stringify({ endpoint: $('plEndpoint').value, key: $('plKey').value, body: parsed }),
2677
- });
2678
- var text = await r.text();
2679
- pre.textContent = '[' + r.status + ']\n' + text;
2945
+ })
2946
+ );
2947
+ return true;
2680
2948
  }
2681
-
2682
- function wire() {
2683
- $('pSave').onclick = saveProvider;
2684
- $('pPreset').onchange = onPresetChange;
2685
- $('kCreate').onclick = createKey;
2686
- $('sSave').onclick = saveServer;
2687
- $('acSave').onclick = saveAccount;
2688
- $('plSend').onclick = sendPlayground;
2689
- refresh();
2949
+ if (urlPath === "/ui") {
2950
+ res.writeHead(302, { Location: "/ui/" });
2951
+ res.end();
2952
+ return true;
2690
2953
  }
2691
-
2692
- function refresh() {
2693
- loadStatus(); loadProviders(); loadPresets(); loadKeys(); loadServer(); loadAccounts();
2954
+ let rel;
2955
+ try {
2956
+ rel = decodeURIComponent(urlPath.slice("/ui/".length));
2957
+ } catch {
2958
+ res.writeHead(400, { "Content-Type": "application/json" });
2959
+ res.end(JSON.stringify({ error: { type: "bad_request", message: "malformed path" } }));
2960
+ return true;
2961
+ }
2962
+ if (rel.includes("\\") || rel.includes("\0")) {
2963
+ res.writeHead(400, { "Content-Type": "application/json" });
2964
+ res.end(JSON.stringify({ error: { type: "bad_request", message: "invalid path" } }));
2965
+ return true;
2966
+ }
2967
+ const filePath = path.resolve(uiDist, rel === "" ? "index.html" : rel);
2968
+ if (filePath !== uiDist && !filePath.startsWith(uiDist + path.sep)) {
2969
+ res.writeHead(403, { "Content-Type": "application/json" });
2970
+ res.end(JSON.stringify({ error: { type: "forbidden", message: "path outside ui root" } }));
2971
+ return true;
2972
+ }
2973
+ let target = filePath;
2974
+ if (!existsSync4(target) || statSync(target).isDirectory()) {
2975
+ if (path.extname(rel) === "") {
2976
+ target = path.join(uiDist, "index.html");
2977
+ } else {
2978
+ res.writeHead(404, { "Content-Type": "application/json" });
2979
+ res.end(JSON.stringify({ error: { type: "not_found", message: "no such ui asset" } }));
2980
+ return true;
2981
+ }
2694
2982
  }
2695
-
2696
- if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', wire);
2697
- else wire();
2698
- })();
2699
- `;
2700
-
2701
- // src/admin/html.ts
2702
- var STYLE = `
2703
- :root { --bg:#0f1115; --panel:#171a21; --line:#272b35; --fg:#e6e8ec; --muted:#8b91a0; --accent:#5b8cff; --danger:#ff5b6e; --ok:#3ecf8e; }
2704
- * { box-sizing: border-box; }
2705
- body { margin:0; font:14px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif; background:var(--bg); color:var(--fg); }
2706
- header { padding:14px 20px; border-bottom:1px solid var(--line); display:flex; align-items:center; gap:12px; }
2707
- header h1 { font-size:16px; margin:0; font-weight:600; }
2708
- header .badge { font-size:12px; color:var(--muted); }
2709
- main { padding:20px; display:grid; gap:20px; max-width:980px; margin:0 auto; }
2710
- section { background:var(--panel); border:1px solid var(--line); border-radius:10px; padding:16px; }
2711
- section h2 { font-size:14px; margin:0 0 12px; font-weight:600; }
2712
- table { width:100%; border-collapse:collapse; font-size:13px; }
2713
- th, td { text-align:left; padding:6px 8px; border-bottom:1px solid var(--line); }
2714
- th { color:var(--muted); font-weight:500; }
2715
- input, select, textarea { background:var(--bg); color:var(--fg); border:1px solid var(--line); border-radius:6px; padding:6px 8px; font:inherit; }
2716
- textarea { width:100%; min-height:90px; resize:vertical; font-family:ui-monospace,Menlo,monospace; }
2717
- button { background:var(--accent); color:#fff; border:0; border-radius:6px; padding:6px 12px; cursor:pointer; font:inherit; }
2718
- button.secondary { background:#2a2f3a; }
2719
- button.danger { background:var(--danger); }
2720
- .row { display:flex; gap:8px; flex-wrap:wrap; align-items:center; margin-top:8px; }
2721
- .muted { color:var(--muted); }
2722
- .mono { font-family:ui-monospace,Menlo,monospace; }
2723
- .pill { padding:1px 8px; border-radius:999px; font-size:12px; }
2724
- .pill.ok { background:rgba(62,207,142,.15); color:var(--ok); }
2725
- .pill.bad { background:rgba(255,91,110,.15); color:var(--danger); }
2726
- pre { background:var(--bg); border:1px solid var(--line); border-radius:6px; padding:10px; overflow:auto; max-height:320px; white-space:pre-wrap; }
2727
- .modal-bg { position:fixed; inset:0; background:rgba(0,0,0,.6); display:none; align-items:center; justify-content:center; }
2728
- .modal-bg.show { display:flex; }
2729
- .modal { background:var(--panel); border:1px solid var(--line); border-radius:10px; padding:20px; max-width:520px; width:90%; }
2730
- .warn { color:var(--danger); font-size:13px; margin:8px 0; }
2731
- .err { color:var(--danger); font-size:13px; }
2732
- `;
2733
- var BODY = `
2734
- <header>
2735
- <h1>omnicross daemon dashboard</h1>
2736
- <span class="badge" id="statusBadge">connecting\u2026</span>
2737
- </header>
2738
- <main>
2739
- <section id="statusSection">
2740
- <h2>Runtime status</h2>
2741
- <div id="statusBody" class="muted">loading\u2026</div>
2742
- </section>
2743
-
2744
- <section>
2745
- <h2>Providers</h2>
2746
- <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>
2747
- <div class="row">
2748
- <select id="pPreset"><option value="">-- \u9009\u62E9\u9884\u7F6E --</option></select>
2749
- <input id="pId" placeholder="id" size="10" />
2750
- <select id="pFormat"><option value="openai">openai</option><option value="anthropic">anthropic</option><option value="gemini">gemini</option></select>
2751
- <input id="pBase" placeholder="base URL" size="26" />
2752
- <input id="pKey" placeholder="apiKey (blank = keep on edit)" size="22" />
2753
- <button id="pSave">Save provider</button>
2754
- </div>
2755
- <div class="err" id="pErr"></div>
2756
- <div id="poolPanel" style="display:none; margin-top:12px;">
2757
- <h2 id="poolTitle" style="font-size:13px;">API key pool</h2>
2758
- <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>
2759
- <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>
2760
- </div>
2761
- </section>
2762
-
2763
- <section>
2764
- <h2>Named keys</h2>
2765
- <table id="keysTable"><thead><tr><th>name</th><th>prefix</th><th>enabled</th><th>revoked</th><th></th></tr></thead><tbody></tbody></table>
2766
- <div class="row">
2767
- <input id="kName" placeholder="key name" size="16" />
2768
- <button id="kCreate">Create key</button>
2769
- </div>
2770
- </section>
2771
-
2772
- <section>
2773
- <h2>Server config</h2>
2774
- <div class="row">
2775
- <label><input type="checkbox" id="sEnabled" /> enabled</label>
2776
- <label><input type="checkbox" id="sLan" /> networkBinding (LAN)</label>
2777
- <label>port <input id="sPort" size="6" /></label>
2778
- <button id="sSave">Apply server config</button>
2779
- </div>
2780
- <table id="endpointsTable"><thead><tr><th>endpoint</th><th>defaultModel</th><th>subscription</th></tr></thead><tbody></tbody></table>
2781
- </section>
2782
-
2783
- <section>
2784
- <h2>Accounts <span class="muted">(subscription tokens)</span></h2>
2785
- <table id="accountsTable"><thead><tr><th>provider</th><th>kind</th><th>status</th><th></th></tr></thead><tbody></tbody></table>
2786
- <h3>Per-provider accounts <span class="muted">(multi-account \u2014 sanitized, no tokens)</span></h3>
2787
- <table id="providerAccountsTable"><thead><tr><th>provider</th><th>label</th><th>status</th><th>active</th><th></th></tr></thead><tbody></tbody></table>
2788
- <div class="row">
2789
- <select id="acProvider"><option value="claude">claude</option><option value="codex">codex</option><option value="gemini">gemini</option><option value="opencodego">opencodego</option></select>
2790
- <button id="acSave">Save token</button>
2791
- </div>
2792
- <textarea id="acBody" placeholder='{"authMethod":"oauth","status":"authorized","accessToken":"\u2026","refreshToken":"\u2026"}'></textarea>
2793
- <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>
2794
- <div class="err" id="acErr"></div>
2795
- </section>
2796
-
2797
- <section>
2798
- <h2>Playground</h2>
2799
- <div class="row">
2800
- <select id="plEndpoint"><option value="chat">chat</option><option value="responses">responses</option><option value="messages">messages</option><option value="gemini">gemini</option></select>
2801
- <input id="plKey" placeholder="named key (sk-omnicross-\u2026)" size="30" />
2802
- <button id="plSend">Send</button>
2803
- </div>
2804
- <textarea id="plBody">{"model":"","messages":[{"role":"user","content":"ping"}]}</textarea>
2805
- <pre id="plResponse" class="muted">response will appear here</pre>
2806
- </section>
2807
- </main>
2808
-
2809
- <div class="modal-bg" id="keyModalBg">
2810
- <div class="modal">
2811
- <h2>Key created</h2>
2812
- <p class="warn">This secret is shown ONCE. Copy it now \u2014 it cannot be retrieved again.</p>
2813
- <pre id="keyPlaintext" class="mono"></pre>
2814
- <div class="row">
2815
- <button id="keyCopy">Copy</button>
2816
- <button class="secondary" id="keyClose">Close</button>
2817
- </div>
2818
- </div>
2819
- </div>
2820
- `;
2821
- var DASHBOARD_HTML = `<!doctype html>
2822
- <html lang="en">
2823
- <head>
2824
- <meta charset="utf-8" />
2825
- <meta name="viewport" content="width=device-width, initial-scale=1" />
2826
- <title>omnicross daemon dashboard</title>
2827
- <style>${STYLE}</style>
2828
- </head>
2829
- <body>
2830
- ${BODY}
2831
- <script>${DASHBOARD_JS}</script>
2832
- </body>
2833
- </html>`;
2983
+ const body = await readFile(target);
2984
+ const type = CONTENT_TYPES[path.extname(target).toLowerCase()] ?? "application/octet-stream";
2985
+ res.writeHead(200, { "Content-Type": type, "Content-Length": body.length });
2986
+ res.end(req.method === "HEAD" ? void 0 : body);
2987
+ return true;
2988
+ }
2834
2989
 
2835
2990
  // src/admin/AdminServer.ts
2836
2991
  var LOOPBACK_ADDR = "127.0.0.1";
2837
2992
  var LAN_ADDR = "0.0.0.0";
2993
+ var DAEMON_VERSION = true ? "0.1.2" : "0.0.0-dev";
2838
2994
  var AdminServer = class {
2839
2995
  constructor(deps) {
2840
2996
  this.deps = deps;
@@ -2843,6 +2999,8 @@ var AdminServer = class {
2843
2999
  server = null;
2844
3000
  boundPort = 0;
2845
3001
  boundAddr = LOOPBACK_ADDR;
3002
+ /** Control Panel dist dir (resolved once at first request; null = no UI). */
3003
+ uiDist;
2846
3004
  /**
2847
3005
  * Start the admin listener honoring the resolved admin config. Returns the
2848
3006
  * actual bound port, or `0` when it refuses/declines to bind (disabled or the
@@ -2871,13 +3029,13 @@ var AdminServer = class {
2871
3029
  const server = http2.createServer((req, res) => {
2872
3030
  this.onRequest(req, res);
2873
3031
  });
2874
- const onError = (err4) => {
2875
- if (err4.code === "EADDRINUSE" && port !== 0) {
3032
+ const onError = (err5) => {
3033
+ if (err5.code === "EADDRINUSE" && port !== 0) {
2876
3034
  server.removeListener("error", onError);
2877
3035
  this.listen(bindAddr, 0).then(resolve, reject);
2878
3036
  return;
2879
3037
  }
2880
- reject(err4);
3038
+ reject(err5);
2881
3039
  };
2882
3040
  server.on("error", onError);
2883
3041
  server.listen(port, bindAddr, () => {
@@ -2895,8 +3053,8 @@ var AdminServer = class {
2895
3053
  }
2896
3054
  /** Per-request handler: auth gate (when a token is set) → routing. */
2897
3055
  onRequest(req, res) {
2898
- void this.dispatch(req, res).catch((err4) => {
2899
- const message = err4 instanceof Error ? err4.message : String(err4);
3056
+ void this.dispatch(req, res).catch((err5) => {
3057
+ const message = err5 instanceof Error ? err5.message : String(err5);
2900
3058
  console.error("[AdminServer] unhandled error:", message);
2901
3059
  if (!res.headersSent) {
2902
3060
  res.writeHead(500, { "Content-Type": "application/json" });
@@ -2906,22 +3064,26 @@ var AdminServer = class {
2906
3064
  }
2907
3065
  async dispatch(req, res) {
2908
3066
  const cfg = this.deps.getAdminConfig();
3067
+ res.setHeader("x-omnicross-daemon", DAEMON_VERSION);
3068
+ res.setHeader("x-omnicross-pid", String(process.pid));
2909
3069
  if (cfg.token && !this.isAuthorized(req, cfg.token)) {
2910
3070
  res.writeHead(401, { "Content-Type": "application/json" });
2911
3071
  res.end(JSON.stringify({ error: { type: "unauthorized", message: "admin token required" } }));
2912
3072
  return;
2913
3073
  }
2914
3074
  const url = req.url ?? "/";
2915
- const path = url.split("?")[0];
2916
- if ((req.method === "GET" || req.method === "HEAD") && (path === "/" || path === "/admin")) {
2917
- res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
2918
- res.end(req.method === "HEAD" ? void 0 : DASHBOARD_HTML);
3075
+ const path2 = url.split("?")[0];
3076
+ if ((req.method === "GET" || req.method === "HEAD") && (path2 === "/" || path2 === "/admin")) {
3077
+ res.writeHead(302, { Location: "/ui/" });
3078
+ res.end();
2919
3079
  return;
2920
3080
  }
2921
- if (path.startsWith("/admin/api/")) {
2922
- await handleAdminApi(req, res, path, this.deps);
3081
+ if (path2.startsWith("/admin/api/")) {
3082
+ await handleAdminApi(req, res, path2, this.deps);
2923
3083
  return;
2924
3084
  }
3085
+ if (this.uiDist === void 0) this.uiDist = resolveUiDist();
3086
+ if (await handleUiStatic(req, res, path2, this.uiDist)) return;
2925
3087
  res.writeHead(404, { "Content-Type": "application/json" });
2926
3088
  res.end(JSON.stringify({ error: { type: "not_found", message: "no such admin route" } }));
2927
3089
  }
@@ -3044,18 +3206,18 @@ function awaitLoopbackCode(expectedState, timeoutMs = DEFAULT_TIMEOUT_MS) {
3044
3206
  res.end(pageHtml("Login complete."));
3045
3207
  finish(server, () => resolve(code));
3046
3208
  });
3047
- server.on("error", (err4) => {
3209
+ server.on("error", (err5) => {
3048
3210
  if (settled) return;
3049
3211
  settled = true;
3050
3212
  clearTimeout(timer);
3051
- if (err4.code === "EADDRINUSE") {
3213
+ if (err5.code === "EADDRINUSE") {
3052
3214
  reject(
3053
3215
  new Error(
3054
3216
  `login: cannot bind ${LOOPBACK_HOST}:${LOOPBACK_PORT} (address in use) \u2014 another codex login or process is holding the port`
3055
3217
  )
3056
3218
  );
3057
3219
  } else {
3058
- reject(err4);
3220
+ reject(err5);
3059
3221
  }
3060
3222
  });
3061
3223
  const timer = setTimeout(() => {
@@ -3342,14 +3504,560 @@ var JsonApiServerSettingsStore = class {
3342
3504
  }
3343
3505
  };
3344
3506
 
3507
+ // src/ports/JsonlUsageEventStore.ts
3508
+ import { randomUUID as randomUUID3 } from "crypto";
3509
+ import { appendFileSync, existsSync as existsSync5, readFileSync as readFileSync6 } from "fs";
3510
+ var JsonlUsageEventStore = class {
3511
+ constructor(eventsPath, isPriced) {
3512
+ this.eventsPath = eventsPath;
3513
+ this.isPriced = isPriced;
3514
+ }
3515
+ eventsPath;
3516
+ isPriced;
3517
+ /** Persist one event: assign `id`, stamp `ts` when absent, append ONE line. */
3518
+ async insert(input) {
3519
+ const row = {
3520
+ ...input,
3521
+ id: randomUUID3(),
3522
+ ts: input.ts ?? Date.now()
3523
+ };
3524
+ appendFileSync(this.eventsPath, JSON.stringify(row) + "\n", "utf8");
3525
+ return row.id;
3526
+ }
3527
+ async getTotals(range) {
3528
+ const totals = {
3529
+ inputTokens: 0,
3530
+ outputTokens: 0,
3531
+ cacheReadTokens: 0,
3532
+ cacheCreationTokens: 0,
3533
+ reasoningTokens: 0,
3534
+ costUsd: 0,
3535
+ costSavedByCacheUsd: 0,
3536
+ eventCount: 0
3537
+ };
3538
+ for (const row of this.readRows(range)) {
3539
+ totals.inputTokens += row.inputTokens;
3540
+ totals.outputTokens += row.outputTokens;
3541
+ totals.cacheReadTokens += row.cacheReadTokens;
3542
+ totals.cacheCreationTokens += row.cacheCreationTokens;
3543
+ totals.reasoningTokens += row.reasoningTokens;
3544
+ totals.costUsd += row.costUsd;
3545
+ totals.costSavedByCacheUsd += row.costSavedByCacheUsd;
3546
+ totals.eventCount += 1;
3547
+ }
3548
+ return totals;
3549
+ }
3550
+ async getByModel(range) {
3551
+ const groups = /* @__PURE__ */ new Map();
3552
+ for (const row of this.readRows(range)) {
3553
+ const key = `${row.providerId}::${row.model}`;
3554
+ let g = groups.get(key);
3555
+ if (!g) {
3556
+ g = {
3557
+ providerId: row.providerId,
3558
+ model: row.model,
3559
+ eventCount: 0,
3560
+ inputTokens: 0,
3561
+ outputTokens: 0,
3562
+ cacheReadTokens: 0,
3563
+ cacheCreationTokens: 0,
3564
+ costUsd: 0,
3565
+ costSavedByCacheUsd: 0,
3566
+ unpriced: false
3567
+ };
3568
+ groups.set(key, g);
3569
+ }
3570
+ g.eventCount += 1;
3571
+ g.inputTokens += row.inputTokens;
3572
+ g.outputTokens += row.outputTokens;
3573
+ g.cacheReadTokens += row.cacheReadTokens;
3574
+ g.cacheCreationTokens += row.cacheCreationTokens;
3575
+ g.costUsd += row.costUsd;
3576
+ g.costSavedByCacheUsd += row.costSavedByCacheUsd;
3577
+ }
3578
+ const rows = Array.from(groups.values());
3579
+ for (const g of rows) {
3580
+ g.unpriced = !await this.isPriced(g.providerId, g.model);
3581
+ }
3582
+ return rows;
3583
+ }
3584
+ /**
3585
+ * Group by RAW apiKeyId (null forms the unattributed sentinel group). Label
3586
+ * here is the raw id fallback — the admin handler resolves display labels
3587
+ * against the configured pool keys (the store stays config-schema-free).
3588
+ */
3589
+ async getByApiKey(range) {
3590
+ const groups = /* @__PURE__ */ new Map();
3591
+ for (const row of this.readRows(range)) {
3592
+ const key = row.apiKeyId;
3593
+ let g = groups.get(key);
3594
+ if (!g) {
3595
+ g = {
3596
+ apiKeyId: key,
3597
+ label: key ?? "unattributed",
3598
+ providerId: key === null ? null : row.providerId,
3599
+ eventCount: 0,
3600
+ inputTokens: 0,
3601
+ outputTokens: 0,
3602
+ costUsd: 0
3603
+ };
3604
+ groups.set(key, g);
3605
+ }
3606
+ g.eventCount += 1;
3607
+ g.inputTokens += row.inputTokens;
3608
+ g.outputTokens += row.outputTokens;
3609
+ g.costUsd += row.costUsd;
3610
+ }
3611
+ return Array.from(groups.values());
3612
+ }
3613
+ async getMessagesForSession(sessionId) {
3614
+ return this.readAllRows().filter((r) => r.sessionId === sessionId).sort((a, b) => a.ts - b.ts).map((r) => ({
3615
+ id: r.id,
3616
+ ts: r.ts,
3617
+ messageId: r.messageId,
3618
+ parentMessageId: r.parentMessageId,
3619
+ sessionId: r.sessionId,
3620
+ providerId: r.providerId,
3621
+ model: r.model,
3622
+ apiKeyId: r.apiKeyId,
3623
+ engineOrigin: r.engineOrigin,
3624
+ inputTokens: r.inputTokens,
3625
+ outputTokens: r.outputTokens,
3626
+ cacheReadTokens: r.cacheReadTokens,
3627
+ cacheCreationTokens: r.cacheCreationTokens,
3628
+ reasoningTokens: r.reasoningTokens,
3629
+ costUsd: r.costUsd,
3630
+ costSavedByCacheUsd: r.costSavedByCacheUsd
3631
+ }));
3632
+ }
3633
+ async getSessionCacheStats(sessionId) {
3634
+ const stats = {
3635
+ sessionId,
3636
+ inputTokens: 0,
3637
+ cacheReadTokens: 0,
3638
+ cacheCreationTokens: 0,
3639
+ outputTokens: 0,
3640
+ eventCount: 0,
3641
+ hitRate: 0
3642
+ };
3643
+ for (const r of this.readAllRows()) {
3644
+ if (r.sessionId !== sessionId) continue;
3645
+ stats.inputTokens += r.inputTokens;
3646
+ stats.cacheReadTokens += r.cacheReadTokens;
3647
+ stats.cacheCreationTokens += r.cacheCreationTokens;
3648
+ stats.outputTokens += r.outputTokens;
3649
+ stats.eventCount += 1;
3650
+ }
3651
+ const promptSide = stats.inputTokens + stats.cacheReadTokens + stats.cacheCreationTokens;
3652
+ stats.hitRate = promptSide > 0 ? stats.cacheReadTokens / promptSide : 0;
3653
+ return stats;
3654
+ }
3655
+ /** Rows inside `startTs <= ts < endTs` (endTs EXCLUSIVE). */
3656
+ readRows(range) {
3657
+ return this.readAllRows().filter((r) => r.ts >= range.startTs && r.ts < range.endTs);
3658
+ }
3659
+ /** Parse every line, skipping malformed/torn lines defensively. */
3660
+ readAllRows() {
3661
+ if (!existsSync5(this.eventsPath)) return [];
3662
+ let raw;
3663
+ try {
3664
+ raw = readFileSync6(this.eventsPath, "utf8");
3665
+ } catch {
3666
+ return [];
3667
+ }
3668
+ const rows = [];
3669
+ for (const line of raw.split("\n")) {
3670
+ const trimmed = line.trim();
3671
+ if (!trimmed) continue;
3672
+ try {
3673
+ const parsed = JSON.parse(trimmed);
3674
+ if (isUsageEventRecord(parsed)) rows.push(parsed);
3675
+ } catch {
3676
+ }
3677
+ }
3678
+ return rows;
3679
+ }
3680
+ };
3681
+ var NUMERIC_FIELDS = [
3682
+ "ts",
3683
+ "inputTokens",
3684
+ "outputTokens",
3685
+ "cacheReadTokens",
3686
+ "cacheCreationTokens",
3687
+ "reasoningTokens",
3688
+ "costUsd",
3689
+ "costSavedByCacheUsd"
3690
+ ];
3691
+ var NULLABLE_STRING_FIELDS = ["messageId", "parentMessageId", "sessionId", "apiKeyId"];
3692
+ var isStringOrNull = (v) => v === null || typeof v === "string";
3693
+ function isUsageEventRecord(parsed) {
3694
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return false;
3695
+ const r = parsed;
3696
+ if (typeof r["id"] !== "string") return false;
3697
+ if (typeof r["providerId"] !== "string") return false;
3698
+ if (typeof r["model"] !== "string") return false;
3699
+ if (typeof r["engineOrigin"] !== "string") return false;
3700
+ for (const f of NULLABLE_STRING_FIELDS) {
3701
+ if (!isStringOrNull(r[f])) return false;
3702
+ }
3703
+ for (const f of NUMERIC_FIELDS) {
3704
+ const v = r[f];
3705
+ if (typeof v !== "number" || !Number.isFinite(v)) return false;
3706
+ }
3707
+ return true;
3708
+ }
3709
+
3710
+ // src/ports/JsonPricingStore.ts
3711
+ import { existsSync as existsSync6, readFileSync as readFileSync7, writeFileSync as writeFileSync5 } from "fs";
3712
+ var JsonPricingStore = class {
3713
+ constructor(pricingPath) {
3714
+ this.pricingPath = pricingPath;
3715
+ }
3716
+ pricingPath;
3717
+ async getAll() {
3718
+ return this.readRows();
3719
+ }
3720
+ /**
3721
+ * Insert or update one row keyed (providerId, modelId). `asUserEdit` stamps
3722
+ * user provenance (source 'user', userEdited, editedAt now) so the row is
3723
+ * protected from auto-overwrite during source refreshes; a non-user upsert
3724
+ * stamps source 'litellm' and clears nothing it should not (a plain source
3725
+ * upsert through this method overwrites the row wholesale).
3726
+ */
3727
+ async upsert(input, asUserEdit) {
3728
+ const rows = this.readRows();
3729
+ const entry = this.applyUpsert(rows, input, asUserEdit);
3730
+ this.writeRows(rows);
3731
+ return entry;
3732
+ }
3733
+ /**
3734
+ * Apply a batch fetched from a pricing source. Rows whose local copy is
3735
+ * user-edited are NOT applied — they come back as `{ current, incoming }`
3736
+ * conflicts; everything else is upserted (source 'litellm'). ONE file write
3737
+ * for the whole batch.
3738
+ */
3739
+ async bulkApplyFromSource(entries) {
3740
+ const rows = this.readRows();
3741
+ const applied = [];
3742
+ const conflicts = [];
3743
+ for (const incoming of entries) {
3744
+ const current = rows.find(
3745
+ (r) => r.providerId === incoming.providerId && r.modelId === incoming.modelId
3746
+ );
3747
+ if (current && current.userEdited) {
3748
+ conflicts.push({ current, incoming });
3749
+ continue;
3750
+ }
3751
+ applied.push(this.applyUpsert(
3752
+ rows,
3753
+ incoming,
3754
+ /* asUserEdit */
3755
+ false
3756
+ ));
3757
+ }
3758
+ if (applied.length > 0) this.writeRows(rows);
3759
+ return { applied, conflicts };
3760
+ }
3761
+ /**
3762
+ * Apply per-row conflict decisions: 'overwrite' replaces the local row with
3763
+ * the incoming values (clearing the user-edited mark), 'skip' counts only.
3764
+ */
3765
+ async applyResolutions(resolutions) {
3766
+ const rows = this.readRows();
3767
+ let overwrittenCount = 0;
3768
+ let skippedCount = 0;
3769
+ for (const r of resolutions) {
3770
+ if (r.action === "skip") {
3771
+ skippedCount += 1;
3772
+ continue;
3773
+ }
3774
+ this.applyUpsert(
3775
+ rows,
3776
+ r.incoming,
3777
+ /* asUserEdit */
3778
+ false
3779
+ );
3780
+ overwrittenCount += 1;
3781
+ }
3782
+ if (overwrittenCount > 0) this.writeRows(rows);
3783
+ return { overwrittenCount, skippedCount };
3784
+ }
3785
+ /**
3786
+ * STORE-LOCAL (not on the core port): remove one row. Returns whether a row
3787
+ * was actually removed. The admin DELETE handler calls this then invalidates
3788
+ * the engine cache.
3789
+ */
3790
+ async delete(providerId, modelId) {
3791
+ const rows = this.readRows();
3792
+ const idx = rows.findIndex((r) => r.providerId === providerId && r.modelId === modelId);
3793
+ if (idx < 0) return false;
3794
+ rows.splice(idx, 1);
3795
+ this.writeRows(rows);
3796
+ return true;
3797
+ }
3798
+ /** Upsert into `rows` IN PLACE (no write) and return the resulting entry. */
3799
+ applyUpsert(rows, input, asUserEdit) {
3800
+ const now = Date.now();
3801
+ const entry = {
3802
+ providerId: input.providerId,
3803
+ modelId: input.modelId,
3804
+ inputPricePer1m: input.inputPricePer1m,
3805
+ outputPricePer1m: input.outputPricePer1m,
3806
+ cacheReadPricePer1m: input.cacheReadPricePer1m ?? null,
3807
+ cacheWritePricePer1m: input.cacheWritePricePer1m ?? null,
3808
+ source: asUserEdit ? "user" : "litellm",
3809
+ userEdited: asUserEdit,
3810
+ editedAt: asUserEdit ? now : null,
3811
+ updatedAt: now
3812
+ };
3813
+ const idx = rows.findIndex(
3814
+ (r) => r.providerId === input.providerId && r.modelId === input.modelId
3815
+ );
3816
+ if (idx >= 0) rows[idx] = entry;
3817
+ else rows.push(entry);
3818
+ return entry;
3819
+ }
3820
+ /** Read the pricing rows, tolerating a missing/corrupt file (→ empty list). */
3821
+ readRows() {
3822
+ if (!existsSync6(this.pricingPath)) return [];
3823
+ try {
3824
+ const parsed = JSON.parse(readFileSync7(this.pricingPath, "utf8"));
3825
+ return Array.isArray(parsed) ? parsed : [];
3826
+ } catch {
3827
+ return [];
3828
+ }
3829
+ }
3830
+ writeRows(rows) {
3831
+ writeFileSync5(this.pricingPath, JSON.stringify(rows, null, 2) + "\n", "utf8");
3832
+ }
3833
+ };
3834
+
3345
3835
  // src/ports/JsonSubscriptionCredentialStore.ts
3346
- import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync6, writeFileSync as writeFileSync5 } from "fs";
3347
- import { dirname as dirname3 } from "path";
3836
+ import { existsSync as existsSync9, mkdirSync as mkdirSync3, readFileSync as readFileSync10, writeFileSync as writeFileSync7 } from "fs";
3837
+ import { dirname as dirname4 } from "path";
3348
3838
  import {
3349
3839
  claudeOAuth as claudeOAuth2,
3350
3840
  codexOAuth as codexOAuth2,
3351
3841
  geminiOAuth as geminiOAuth2
3352
3842
  } from "@omnicross/subscriptions";
3843
+
3844
+ // src/ports/account-sync.ts
3845
+ var IMPORT_EXPIRY_MARGIN_MS = 6e4;
3846
+ function viewOf(tokens) {
3847
+ return tokens;
3848
+ }
3849
+ function decideExternalImport(captured, external, now = Date.now()) {
3850
+ if (!external?.accessToken) return "no-credential";
3851
+ const capturedRt = viewOf(captured).refreshToken;
3852
+ const hasNewRefresh = Boolean(external.refreshToken && external.refreshToken !== capturedRt);
3853
+ const accessStillValid = external.expiresAt ? Date.parse(external.expiresAt) > now + IMPORT_EXPIRY_MARGIN_MS : true;
3854
+ return hasNewRefresh || accessStillValid ? "import" : "not-rotated";
3855
+ }
3856
+ function buildImportedTokens(captured, external) {
3857
+ const imported = {
3858
+ ...captured,
3859
+ accessToken: external.accessToken,
3860
+ status: "authorized",
3861
+ errorMessage: void 0,
3862
+ syncWarning: void 0,
3863
+ lastRefreshedAt: (/* @__PURE__ */ new Date()).toISOString()
3864
+ };
3865
+ if (external.refreshToken) imported.refreshToken = external.refreshToken;
3866
+ if (external.expiresAt) imported.expiresAt = external.expiresAt;
3867
+ else delete imported.expiresAt;
3868
+ if (external.idToken) imported.idToken = external.idToken;
3869
+ if (external.scopes) imported.scopes = external.scopes;
3870
+ return imported;
3871
+ }
3872
+ function buildTokensFromExternal(provider, external) {
3873
+ const base = {
3874
+ authMethod: "oauth",
3875
+ status: "authorized",
3876
+ accessToken: external.accessToken,
3877
+ lastRefreshedAt: (/* @__PURE__ */ new Date()).toISOString()
3878
+ };
3879
+ if (provider === "claude") {
3880
+ const tokens2 = { ...base };
3881
+ if (external.refreshToken) tokens2.refreshToken = external.refreshToken;
3882
+ if (external.expiresAt) tokens2.expiresAt = external.expiresAt;
3883
+ if (external.scopes) tokens2.scopes = external.scopes;
3884
+ return tokens2;
3885
+ }
3886
+ const tokens = { ...base };
3887
+ if (external.refreshToken) tokens.refreshToken = external.refreshToken;
3888
+ if (external.expiresAt) tokens.expiresAt = external.expiresAt;
3889
+ if (external.idToken) tokens.idToken = external.idToken;
3890
+ return tokens;
3891
+ }
3892
+ function isExternalDivergent(stored, external) {
3893
+ if (!external?.accessToken || !external.refreshToken) return false;
3894
+ const view = viewOf(stored);
3895
+ if (!view.refreshToken || external.refreshToken === view.refreshToken) return false;
3896
+ const storedExp = view.expiresAt ? Date.parse(view.expiresAt) : NaN;
3897
+ const externalExp = external.expiresAt ? Date.parse(external.expiresAt) : Infinity;
3898
+ return !Number.isFinite(storedExp) || externalExp > storedExp;
3899
+ }
3900
+ function findDuplicateCredentialIds(accounts) {
3901
+ const byCredential = /* @__PURE__ */ new Map();
3902
+ for (const account of accounts) {
3903
+ const view = viewOf(account.tokens);
3904
+ const credential = view.refreshToken ?? view.apiKey ?? view.accessToken;
3905
+ if (!credential) continue;
3906
+ const ids = byCredential.get(credential) ?? [];
3907
+ ids.push(account.id);
3908
+ byCredential.set(credential, ids);
3909
+ }
3910
+ const duplicates = /* @__PURE__ */ new Set();
3911
+ for (const ids of byCredential.values()) {
3912
+ if (ids.length > 1) for (const id of ids) duplicates.add(id);
3913
+ }
3914
+ return duplicates;
3915
+ }
3916
+
3917
+ // src/ports/external-cli-credentials.ts
3918
+ import { existsSync as existsSync7, readFileSync as readFileSync8 } from "fs";
3919
+ import { homedir as homedir2 } from "os";
3920
+ import { join as join4 } from "path";
3921
+ function externalStorePath(provider, home = homedir2()) {
3922
+ return provider === "claude" ? join4(home, ".claude", ".credentials.json") : join4(home, ".codex", "auth.json");
3923
+ }
3924
+ function decodeJwtExpiryMs(token) {
3925
+ try {
3926
+ const payload = token.split(".")[1];
3927
+ if (!payload) return void 0;
3928
+ const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
3929
+ if (typeof decoded.exp === "number" && Number.isFinite(decoded.exp)) {
3930
+ return decoded.exp * 1e3;
3931
+ }
3932
+ } catch {
3933
+ }
3934
+ return void 0;
3935
+ }
3936
+ function parseClaudeOAuthEnvelope(raw) {
3937
+ const oauth = raw?.claudeAiOauth;
3938
+ if (!oauth || typeof oauth.accessToken !== "string" || !oauth.accessToken) return null;
3939
+ const parsed = { accessToken: oauth.accessToken };
3940
+ if (typeof oauth.refreshToken === "string" && oauth.refreshToken) {
3941
+ parsed.refreshToken = oauth.refreshToken;
3942
+ }
3943
+ if (typeof oauth.expiresAt === "number" && Number.isFinite(oauth.expiresAt)) {
3944
+ parsed.expiresAt = new Date(oauth.expiresAt).toISOString();
3945
+ }
3946
+ if (Array.isArray(oauth.scopes) && oauth.scopes.every((s) => typeof s === "string")) {
3947
+ parsed.scopes = oauth.scopes;
3948
+ }
3949
+ return parsed;
3950
+ }
3951
+ function parseCodexTokensEnvelope(raw) {
3952
+ const tokens = raw?.tokens;
3953
+ if (!tokens) return null;
3954
+ const accessToken = typeof tokens.access_token === "string" && tokens.access_token ? tokens.access_token : void 0;
3955
+ const idToken = typeof tokens.id_token === "string" && tokens.id_token ? tokens.id_token : void 0;
3956
+ if (!accessToken && !idToken) return null;
3957
+ const parsed = {};
3958
+ if (accessToken) {
3959
+ parsed.accessToken = accessToken;
3960
+ const expMs = decodeJwtExpiryMs(accessToken);
3961
+ if (expMs !== void 0) parsed.expiresAt = new Date(expMs).toISOString();
3962
+ }
3963
+ if (idToken) parsed.idToken = idToken;
3964
+ if (typeof tokens.refresh_token === "string" && tokens.refresh_token) {
3965
+ parsed.refreshToken = tokens.refresh_token;
3966
+ }
3967
+ return parsed;
3968
+ }
3969
+ function readExternalCliCredentials(provider, home = homedir2()) {
3970
+ const path2 = externalStorePath(provider, home);
3971
+ if (!existsSync7(path2)) return null;
3972
+ let raw;
3973
+ try {
3974
+ const parsed = JSON.parse(readFileSync8(path2, "utf8"));
3975
+ raw = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : void 0;
3976
+ } catch {
3977
+ return null;
3978
+ }
3979
+ return provider === "claude" ? parseClaudeOAuthEnvelope(raw) : parseCodexTokensEnvelope(raw);
3980
+ }
3981
+
3982
+ // src/ports/external-cli-store.ts
3983
+ import { copyFileSync, existsSync as existsSync8, mkdirSync as mkdirSync2, readFileSync as readFileSync9, renameSync, writeFileSync as writeFileSync6 } from "fs";
3984
+ import { homedir as homedir3 } from "os";
3985
+ import { dirname as dirname3 } from "path";
3986
+ function markerPath(provider, home) {
3987
+ return `${externalStorePath(provider, home)}.omnicross-managed`;
3988
+ }
3989
+ function backupPath(provider, home) {
3990
+ return `${externalStorePath(provider, home)}.omnicross-backup`;
3991
+ }
3992
+ function buildClaudeOAuthEnvelope(tokens) {
3993
+ if (!tokens.accessToken) return null;
3994
+ const envelope = { accessToken: tokens.accessToken };
3995
+ if (tokens.refreshToken) envelope.refreshToken = tokens.refreshToken;
3996
+ if (tokens.expiresAt) {
3997
+ const ms = Date.parse(tokens.expiresAt);
3998
+ if (Number.isFinite(ms)) envelope.expiresAt = ms;
3999
+ }
4000
+ if (tokens.scopes && tokens.scopes.length > 0) envelope.scopes = tokens.scopes;
4001
+ return envelope;
4002
+ }
4003
+ function buildCodexTokensEnvelope(tokens) {
4004
+ if (!tokens.accessToken && !tokens.idToken) return null;
4005
+ const envelope = { access_token: tokens.accessToken ?? "" };
4006
+ if (tokens.idToken) envelope.id_token = tokens.idToken;
4007
+ if (tokens.refreshToken) envelope.refresh_token = tokens.refreshToken;
4008
+ return envelope;
4009
+ }
4010
+ function readExistingObject(path2) {
4011
+ if (!existsSync8(path2)) return {};
4012
+ try {
4013
+ const parsed = JSON.parse(readFileSync9(path2, "utf8"));
4014
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
4015
+ } catch {
4016
+ return {};
4017
+ }
4018
+ }
4019
+ function writeAtomic(path2, content) {
4020
+ mkdirSync2(dirname3(path2), { recursive: true });
4021
+ const temp = `${path2}.omnicross-tmp`;
4022
+ writeFileSync6(temp, content, "utf8");
4023
+ renameSync(temp, path2);
4024
+ }
4025
+ function createExternalCliStore(home = homedir3()) {
4026
+ return {
4027
+ readMarkerAccountId(provider) {
4028
+ const path2 = markerPath(provider, home);
4029
+ if (!existsSync8(path2)) return void 0;
4030
+ try {
4031
+ const parsed = JSON.parse(readFileSync9(path2, "utf8"));
4032
+ return typeof parsed.accountId === "string" && parsed.accountId ? parsed.accountId : void 0;
4033
+ } catch {
4034
+ return void 0;
4035
+ }
4036
+ },
4037
+ writeMarker(provider, accountId) {
4038
+ writeAtomic(
4039
+ markerPath(provider, home),
4040
+ JSON.stringify({ accountId, at: (/* @__PURE__ */ new Date()).toISOString() }, null, 2) + "\n"
4041
+ );
4042
+ },
4043
+ writeBack(provider, accountId, tokens) {
4044
+ const owner = this.readMarkerAccountId(provider);
4045
+ if (owner !== accountId) return false;
4046
+ const envelope = provider === "claude" ? buildClaudeOAuthEnvelope(tokens) : buildCodexTokensEnvelope(tokens);
4047
+ if (!envelope) return false;
4048
+ const storePath = externalStorePath(provider, home);
4049
+ if (existsSync8(storePath) && !existsSync8(backupPath(provider, home))) {
4050
+ copyFileSync(storePath, backupPath(provider, home));
4051
+ }
4052
+ const existing = readExistingObject(storePath);
4053
+ const merged = provider === "claude" ? { ...existing, claudeAiOauth: envelope } : { ...existing, tokens: envelope };
4054
+ writeAtomic(storePath, JSON.stringify(merged, null, 2) + "\n");
4055
+ return true;
4056
+ }
4057
+ };
4058
+ }
4059
+
4060
+ // src/ports/JsonSubscriptionCredentialStore.ts
3353
4061
  var JsonSubscriptionCredentialStore = class {
3354
4062
  /**
3355
4063
  * @param tokensPath on-disk `tokens.json` location.
@@ -3359,14 +4067,33 @@ var JsonSubscriptionCredentialStore = class {
3359
4067
  * is unchanged; tests inject a mock fetch. NOT used by any
3360
4068
  * read/write path — only by `refresh*Token`.
3361
4069
  */
3362
- constructor(tokensPath, box, fetchImpl = (url, init) => fetch(url, init)) {
4070
+ constructor(tokensPath, box, fetchImpl = (url, init) => fetch(url, init), externalCliReader = readExternalCliCredentials, externalCliStore = createExternalCliStore()) {
3363
4071
  this.tokensPath = tokensPath;
3364
4072
  this.box = box;
3365
4073
  this.fetchImpl = fetchImpl;
4074
+ this.externalCliReader = externalCliReader;
4075
+ this.externalCliStore = externalCliStore;
3366
4076
  }
3367
4077
  tokensPath;
3368
4078
  box;
3369
4079
  fetchImpl;
4080
+ externalCliReader;
4081
+ externalCliStore;
4082
+ /**
4083
+ * In-flight refresh coalescing (external-cli-sync). OAuth refresh tokens are
4084
+ * SINGLE-USE: two concurrent refreshes of one account each spend the same
4085
+ * token and the loser bricks a healthy account. Every refresh entry point
4086
+ * (auth-strategy lazy refresh, 401 retry, background scheduler) funnels
4087
+ * through `coalesce`, so overlapping callers share ONE upstream round-trip.
4088
+ */
4089
+ inFlightRefreshes = /* @__PURE__ */ new Map();
4090
+ coalesce(key, task) {
4091
+ const existing = this.inFlightRefreshes.get(key);
4092
+ if (existing) return existing;
4093
+ const run = task().finally(() => this.inFlightRefreshes.delete(key));
4094
+ this.inFlightRefreshes.set(key, run);
4095
+ return run;
4096
+ }
3370
4097
  /** Full parsed account-tokens config (or a minimal `{ updatedAt }` when the
3371
4098
  * file is absent/corrupt). This is the hot read — the codex / gemini auth
3372
4099
  * strategies pull `accessToken` / `expiresAt` / `status` from it. */
@@ -3394,10 +4121,41 @@ var JsonSubscriptionCredentialStore = class {
3394
4121
  const out = {};
3395
4122
  for (const provider of ["claude", "codex", "gemini", "opencodego"]) {
3396
4123
  const sanitized = sanitizeAccounts(config, provider);
3397
- if (sanitized.length > 0) out[provider] = sanitized;
4124
+ if (sanitized.length > 0) out[provider] = this.attachSyncWarnings(config, provider, sanitized);
3398
4125
  }
3399
4126
  return out;
3400
4127
  }
4128
+ /**
4129
+ * List-time credential-conflict warnings (external-cli-sync). Computed, not
4130
+ * persisted: (a) `duplicate-token` when two accounts of one provider share a
4131
+ * credential, (b) `external-divergent` when the external CLI native store has
4132
+ * rotated PAST the ACTIVE account (claude/codex only). A warning persisted by
4133
+ * a failed refresh (`external-not-rotated`) takes precedence — it is the most
4134
+ * actionable state.
4135
+ */
4136
+ attachSyncWarnings(config, provider, sanitized) {
4137
+ const duplicates = findDuplicateCredentialIds(listAccounts(config, provider));
4138
+ let divergentId;
4139
+ if (provider === "claude" || provider === "codex") {
4140
+ const active = getActiveAccount(config, provider);
4141
+ if (active && isExternalDivergent(active.tokens, this.safeReadExternal(provider))) {
4142
+ divergentId = active.id;
4143
+ }
4144
+ }
4145
+ if (duplicates.size === 0 && !divergentId) return sanitized;
4146
+ return sanitized.map((account) => {
4147
+ const computed = account.id === divergentId ? "external-divergent" : duplicates.has(account.id) ? "duplicate-token" : void 0;
4148
+ return { ...account, syncWarning: account.syncWarning ?? computed };
4149
+ });
4150
+ }
4151
+ /** Read the external CLI store, never letting an fs/parse error escape. */
4152
+ safeReadExternal(provider) {
4153
+ try {
4154
+ return this.externalCliReader(provider);
4155
+ } catch {
4156
+ return null;
4157
+ }
4158
+ }
3401
4159
  /**
3402
4160
  * Refresh the Claude OAuth access token (oauth design D4). HONEST `false` when
3403
4161
  * the block has no refresh_token (setup-token / manual) — no upstream call, the
@@ -3407,30 +4165,44 @@ var JsonSubscriptionCredentialStore = class {
3407
4165
  * errorMessage → `false`.
3408
4166
  */
3409
4167
  async refreshClaudeToken() {
3410
- const config = this.readConfig();
3411
- const active = getActiveAccount(config, "claude");
3412
- const claude = active?.tokens;
3413
- if (!active || !claude?.refreshToken) return false;
3414
- const capturedId = active.id;
3415
- this.materializeMigration(config);
3416
- try {
3417
- const result = await claudeOAuth2.refreshAccessToken(claude.refreshToken, this.fetchImpl);
3418
- const expiresAt = new Date(Date.now() + result.expiresIn * 1e3).toISOString();
3419
- const next = {
3420
- ...claude,
3421
- accessToken: result.accessToken,
3422
- refreshToken: result.refreshToken,
3423
- expiresAt,
3424
- status: "authorized",
3425
- lastRefreshedAt: (/* @__PURE__ */ new Date()).toISOString(),
3426
- errorMessage: void 0
3427
- };
3428
- this.writeBackById("claude", capturedId, next);
3429
- return true;
3430
- } catch (error) {
3431
- this.markExpiredById("claude", capturedId, claude, error);
3432
- return false;
3433
- }
4168
+ return this.coalesce("claude:active", async () => {
4169
+ const config = this.readConfig();
4170
+ const active = getActiveAccount(config, "claude");
4171
+ const claude = active?.tokens;
4172
+ if (!active || !claude?.refreshToken) return false;
4173
+ const capturedId = active.id;
4174
+ this.materializeMigration(config);
4175
+ try {
4176
+ const result = await claudeOAuth2.refreshAccessToken(claude.refreshToken, this.fetchImpl);
4177
+ const expiresAt = new Date(Date.now() + result.expiresIn * 1e3).toISOString();
4178
+ const next = {
4179
+ ...claude,
4180
+ accessToken: result.accessToken,
4181
+ refreshToken: result.refreshToken,
4182
+ expiresAt,
4183
+ status: "authorized",
4184
+ lastRefreshedAt: (/* @__PURE__ */ new Date()).toISOString(),
4185
+ errorMessage: void 0,
4186
+ syncWarning: void 0
4187
+ };
4188
+ this.writeBackById("claude", capturedId, next);
4189
+ this.resyncExternal("claude", capturedId, next);
4190
+ return true;
4191
+ } catch (error) {
4192
+ if (await this.tryExternalImport("claude", capturedId, claude, async (rt) => {
4193
+ const r = await claudeOAuth2.refreshAccessToken(rt, this.fetchImpl);
4194
+ return {
4195
+ accessToken: r.accessToken,
4196
+ refreshToken: r.refreshToken,
4197
+ expiresAt: new Date(Date.now() + r.expiresIn * 1e3).toISOString()
4198
+ };
4199
+ })) {
4200
+ return true;
4201
+ }
4202
+ this.markExpiredById("claude", capturedId, claude, error);
4203
+ return false;
4204
+ }
4205
+ });
3434
4206
  }
3435
4207
  /**
3436
4208
  * Refresh the Codex (ChatGPT) OAuth access token. Same shape
@@ -3438,31 +4210,46 @@ var JsonSubscriptionCredentialStore = class {
3438
4210
  * HONEST `false` when no refresh_token.
3439
4211
  */
3440
4212
  async refreshCodexToken() {
3441
- const config = this.readConfig();
3442
- const active = getActiveAccount(config, "codex");
3443
- const codex = active?.tokens;
3444
- if (!active || !codex?.refreshToken) return false;
3445
- const capturedId = active.id;
3446
- this.materializeMigration(config);
3447
- try {
3448
- const result = await codexOAuth2.refreshAccessToken(codex.refreshToken, this.fetchImpl);
3449
- const expiresAt = new Date(Date.now() + result.expiresIn * 1e3).toISOString();
3450
- const next = {
3451
- ...codex,
3452
- accessToken: result.accessToken,
3453
- refreshToken: result.refreshToken,
3454
- idToken: result.idToken,
3455
- expiresAt,
3456
- status: "authorized",
3457
- lastRefreshedAt: (/* @__PURE__ */ new Date()).toISOString(),
3458
- errorMessage: void 0
3459
- };
3460
- this.writeBackById("codex", capturedId, next);
3461
- return true;
3462
- } catch (error) {
3463
- this.markExpiredById("codex", capturedId, codex, error);
3464
- return false;
3465
- }
4213
+ return this.coalesce("codex:active", async () => {
4214
+ const config = this.readConfig();
4215
+ const active = getActiveAccount(config, "codex");
4216
+ const codex = active?.tokens;
4217
+ if (!active || !codex?.refreshToken) return false;
4218
+ const capturedId = active.id;
4219
+ this.materializeMigration(config);
4220
+ try {
4221
+ const result = await codexOAuth2.refreshAccessToken(codex.refreshToken, this.fetchImpl);
4222
+ const expiresAt = new Date(Date.now() + result.expiresIn * 1e3).toISOString();
4223
+ const next = {
4224
+ ...codex,
4225
+ accessToken: result.accessToken,
4226
+ refreshToken: result.refreshToken,
4227
+ idToken: result.idToken,
4228
+ expiresAt,
4229
+ status: "authorized",
4230
+ lastRefreshedAt: (/* @__PURE__ */ new Date()).toISOString(),
4231
+ errorMessage: void 0,
4232
+ syncWarning: void 0
4233
+ };
4234
+ this.writeBackById("codex", capturedId, next);
4235
+ this.resyncExternal("codex", capturedId, next);
4236
+ return true;
4237
+ } catch (error) {
4238
+ if (await this.tryExternalImport("codex", capturedId, codex, async (rt) => {
4239
+ const r = await codexOAuth2.refreshAccessToken(rt, this.fetchImpl);
4240
+ return {
4241
+ accessToken: r.accessToken,
4242
+ refreshToken: r.refreshToken,
4243
+ idToken: r.idToken,
4244
+ expiresAt: new Date(Date.now() + r.expiresIn * 1e3).toISOString()
4245
+ };
4246
+ })) {
4247
+ return true;
4248
+ }
4249
+ this.markExpiredById("codex", capturedId, codex, error);
4250
+ return false;
4251
+ }
4252
+ });
3466
4253
  }
3467
4254
  /**
3468
4255
  * Refresh the Gemini (Google) OAuth access token. The Google
@@ -3472,30 +4259,175 @@ var JsonSubscriptionCredentialStore = class {
3472
4259
  * destroy the ability to refresh again). HONEST `false` when no refresh_token.
3473
4260
  */
3474
4261
  async refreshGeminiToken() {
3475
- const config = this.readConfig();
3476
- const active = getActiveAccount(config, "gemini");
3477
- const gemini = active?.tokens;
3478
- if (!active || !gemini?.refreshToken) return false;
3479
- const capturedId = active.id;
3480
- this.materializeMigration(config);
3481
- try {
3482
- const result = await geminiOAuth2.refreshAccessToken(gemini.refreshToken, this.fetchImpl);
3483
- const expiresAt = new Date(Date.now() + result.expiresIn * 1e3).toISOString();
3484
- const next = {
3485
- ...gemini,
3486
- // KEEP the existing refreshToken (response omits it).
3487
- accessToken: result.accessToken,
3488
- expiresAt,
3489
- status: "authorized",
3490
- lastRefreshedAt: (/* @__PURE__ */ new Date()).toISOString(),
3491
- errorMessage: void 0
3492
- };
3493
- this.writeBackById("gemini", capturedId, next);
3494
- return true;
3495
- } catch (error) {
3496
- this.markExpiredById("gemini", capturedId, gemini, error);
4262
+ return this.coalesce("gemini:active", async () => {
4263
+ const config = this.readConfig();
4264
+ const active = getActiveAccount(config, "gemini");
4265
+ const gemini = active?.tokens;
4266
+ if (!active || !gemini?.refreshToken) return false;
4267
+ const capturedId = active.id;
4268
+ this.materializeMigration(config);
4269
+ try {
4270
+ const result = await geminiOAuth2.refreshAccessToken(gemini.refreshToken, this.fetchImpl);
4271
+ const expiresAt = new Date(Date.now() + result.expiresIn * 1e3).toISOString();
4272
+ const next = {
4273
+ ...gemini,
4274
+ // KEEP the existing refreshToken (response omits it).
4275
+ accessToken: result.accessToken,
4276
+ expiresAt,
4277
+ status: "authorized",
4278
+ lastRefreshedAt: (/* @__PURE__ */ new Date()).toISOString(),
4279
+ errorMessage: void 0
4280
+ };
4281
+ this.writeBackById("gemini", capturedId, next);
4282
+ return true;
4283
+ } catch (error) {
4284
+ this.markExpiredById("gemini", capturedId, gemini, error);
4285
+ return false;
4286
+ }
4287
+ });
4288
+ }
4289
+ /**
4290
+ * Refresh a SPECIFIC account by id (background scheduler sweep,
4291
+ * external-cli-sync). Unlike the active-account refreshers it does NOT
4292
+ * attempt the external-import fallback — the external CLI file's lineage can
4293
+ * only plausibly match the ACTIVE account. Coalesced per `provider:id`; on
4294
+ * failure flags ONLY that account `expired`.
4295
+ */
4296
+ async refreshAccountById(provider, id) {
4297
+ return this.coalesce(`${provider}:${id}`, async () => {
4298
+ const config = this.readConfig();
4299
+ const account = getAccountById(config, provider, id);
4300
+ const captured = account?.tokens;
4301
+ if (!account || !captured?.refreshToken) return false;
4302
+ this.materializeMigration(config);
4303
+ try {
4304
+ const refreshed = await this.refreshUpstream(provider, captured.refreshToken);
4305
+ const next = {
4306
+ ...captured,
4307
+ accessToken: refreshed.accessToken,
4308
+ // Gemini's refresh response omits a new refresh token — keep the captured.
4309
+ refreshToken: refreshed.refreshToken ?? captured.refreshToken,
4310
+ expiresAt: refreshed.expiresAt,
4311
+ status: "authorized",
4312
+ lastRefreshedAt: (/* @__PURE__ */ new Date()).toISOString(),
4313
+ errorMessage: void 0,
4314
+ syncWarning: void 0
4315
+ };
4316
+ if (refreshed.idToken) next.idToken = refreshed.idToken;
4317
+ this.writeBackById(provider, id, next);
4318
+ if (provider !== "gemini") this.resyncExternal(provider, id, next);
4319
+ return true;
4320
+ } catch (error) {
4321
+ this.markExpiredById(provider, id, captured, error);
4322
+ return false;
4323
+ }
4324
+ });
4325
+ }
4326
+ /** Dispatch one OAuth refresh round-trip to the provider's shared flow. */
4327
+ async refreshUpstream(provider, refreshToken) {
4328
+ const flow = provider === "claude" ? claudeOAuth2 : provider === "codex" ? codexOAuth2 : geminiOAuth2;
4329
+ const r = await flow.refreshAccessToken(refreshToken, this.fetchImpl);
4330
+ return {
4331
+ accessToken: r.accessToken,
4332
+ refreshToken: r.refreshToken,
4333
+ idToken: r.idToken,
4334
+ expiresAt: new Date(Date.now() + r.expiresIn * 1e3).toISOString()
4335
+ };
4336
+ }
4337
+ /**
4338
+ * External-import fallback for a FAILED active-account refresh
4339
+ * (external-cli-sync). Reads the CLI native store; imports when the external
4340
+ * lineage ROTATED (different refresh token) or its access token is still
4341
+ * valid. When the imported access token is already expired it refreshes once
4342
+ * with the rotated refresh token. A `not-rotated` outcome persists the
4343
+ * `external-not-rotated` warning on the (about-to-be-expired) account so the
4344
+ * UI can tell "genuine revocation" apart from a plain refresh failure.
4345
+ */
4346
+ async tryExternalImport(provider, capturedId, captured, refreshWithToken) {
4347
+ const markerOwner = this.safeReadMarker(provider);
4348
+ if (markerOwner && markerOwner !== capturedId) return false;
4349
+ const external = this.safeReadExternal(provider);
4350
+ const decision = decideExternalImport(captured, external);
4351
+ if (decision === "not-rotated") {
4352
+ captured.syncWarning = "external-not-rotated";
3497
4353
  return false;
3498
4354
  }
4355
+ if (decision !== "import" || !external) return false;
4356
+ let imported = buildImportedTokens(
4357
+ captured,
4358
+ external
4359
+ );
4360
+ const accessStillValid = external.expiresAt ? Date.parse(external.expiresAt) > Date.now() + 6e4 : true;
4361
+ if (!accessStillValid) {
4362
+ try {
4363
+ const refreshed = await refreshWithToken(external.refreshToken);
4364
+ imported = {
4365
+ ...imported,
4366
+ accessToken: refreshed.accessToken,
4367
+ refreshToken: refreshed.refreshToken ?? imported.refreshToken,
4368
+ expiresAt: refreshed.expiresAt,
4369
+ lastRefreshedAt: (/* @__PURE__ */ new Date()).toISOString()
4370
+ };
4371
+ if (refreshed.idToken) imported.idToken = refreshed.idToken;
4372
+ } catch {
4373
+ return false;
4374
+ }
4375
+ }
4376
+ this.writeBackById(provider, capturedId, imported);
4377
+ this.resyncExternal(provider, capturedId, imported);
4378
+ return true;
4379
+ }
4380
+ /**
4381
+ * Marker-gated external write-back (external-cli-sync). After a successful
4382
+ * refresh of the account that OWNS the provider's native CLI store (imported
4383
+ * via `importExternalCliAccount`), push the rotated credential back into the
4384
+ * file — otherwise the daemon's refresh invalidates the single-use refresh
4385
+ * token and silently logs the bare CLI out. NON-FATAL: the internal store is
4386
+ * already persisted; a failed external write only leaves the file stale,
4387
+ * which the `external-divergent` warning surfaces.
4388
+ */
4389
+ resyncExternal(provider, accountId, tokens) {
4390
+ try {
4391
+ this.externalCliStore.writeBack(provider, accountId, tokens);
4392
+ } catch {
4393
+ }
4394
+ }
4395
+ /** Read the marker's owning account id, never letting an fs error escape. */
4396
+ safeReadMarker(provider) {
4397
+ try {
4398
+ return this.externalCliStore.readMarkerAccountId(provider);
4399
+ } catch {
4400
+ return void 0;
4401
+ }
4402
+ }
4403
+ /**
4404
+ * DAEMON-ONLY (admin import button): which providers have a usable external
4405
+ * CLI credential on THIS machine. Pure detection — reads the native files,
4406
+ * never mutates anything, never returns a token.
4407
+ */
4408
+ async listExternalCliAvailability() {
4409
+ return {
4410
+ claude: Boolean(this.safeReadExternal("claude")?.accessToken),
4411
+ codex: Boolean(this.safeReadExternal("codex")?.accessToken)
4412
+ };
4413
+ }
4414
+ /**
4415
+ * DAEMON-ONLY (admin import button): import the external CLI's current login
4416
+ * as a NEW account (+ activate), and take MANAGED ownership of the native
4417
+ * store (marker) so subsequent refreshes write back — keeping the bare CLI
4418
+ * and the daemon on the same live credential instead of silently killing one
4419
+ * side's single-use refresh token.
4420
+ */
4421
+ async importExternalCliAccount(provider, label) {
4422
+ const external = this.safeReadExternal(provider);
4423
+ if (!external?.accessToken) return { ok: false, reason: "no-credential" };
4424
+ const tokens = buildTokensFromExternal(provider, external);
4425
+ const result = await this.appendProviderAccount(provider, tokens, label);
4426
+ try {
4427
+ this.externalCliStore.writeMarker(provider, result.id);
4428
+ } catch {
4429
+ }
4430
+ return { ok: true, id: result.id };
3499
4431
  }
3500
4432
  /**
3501
4433
  * Materialize a lazily-synthesized account id to disk (design D3). On a legacy
@@ -3579,6 +4511,18 @@ var JsonSubscriptionCredentialStore = class {
3579
4511
  this.persist({ ...current, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
3580
4512
  return result;
3581
4513
  }
4514
+ /**
4515
+ * DAEMON-ONLY per-account rename (NOT on the port). Update one account's label;
4516
+ * rejects an unknown id. Label-only — no token material is read or written
4517
+ * (the secret-free invariant holds).
4518
+ */
4519
+ async renameAccount(providerId, id, label) {
4520
+ const current = this.readConfig();
4521
+ const result = renameAccount(current, providerId, id, label);
4522
+ if (!result.ok) return result;
4523
+ this.persist({ ...current, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
4524
+ return result;
4525
+ }
3582
4526
  /**
3583
4527
  * DAEMON-ONLY CLEAR (design D1/D3, NOT on the port). Remove a single provider's
3584
4528
  * block from `tokens.json` and re-persist (the strategies already tolerate an
@@ -3595,9 +4539,9 @@ var JsonSubscriptionCredentialStore = class {
3595
4539
  * → `enc:v1:`; already-`enc:`/`$ENV` untouched) before serializing, so any
3596
4540
  * write — incl. child 4's future refresh writes — lands encrypted. */
3597
4541
  persist(config) {
3598
- mkdirSync2(dirname3(this.tokensPath), { recursive: true });
4542
+ mkdirSync3(dirname4(this.tokensPath), { recursive: true });
3599
4543
  const encrypted = encryptTokens(config, this.box);
3600
- writeFileSync5(this.tokensPath, JSON.stringify(encrypted, null, 2) + "\n", "utf8");
4544
+ writeFileSync7(this.tokensPath, JSON.stringify(encrypted, null, 2) + "\n", "utf8");
3601
4545
  }
3602
4546
  /**
3603
4547
  * Read + parse `tokens.json`, tolerating a missing/corrupt file, then DECRYPT
@@ -3613,10 +4557,10 @@ var JsonSubscriptionCredentialStore = class {
3613
4557
  * `config.ts loadConfig`, which decrypts outside its parse try.
3614
4558
  */
3615
4559
  readConfig() {
3616
- if (!existsSync3(this.tokensPath)) return { updatedAt: "" };
4560
+ if (!existsSync9(this.tokensPath)) return { updatedAt: "" };
3617
4561
  let parsed;
3618
4562
  try {
3619
- const raw = JSON.parse(readFileSync6(this.tokensPath, "utf8"));
4563
+ const raw = JSON.parse(readFileSync10(this.tokensPath, "utf8"));
3620
4564
  parsed = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : null;
3621
4565
  } catch {
3622
4566
  parsed = null;
@@ -3627,6 +4571,95 @@ var JsonSubscriptionCredentialStore = class {
3627
4571
  }
3628
4572
  };
3629
4573
 
4574
+ // src/TokenRefreshScheduler.ts
4575
+ var REFRESH_LEAD_MS = 5 * 6e4;
4576
+ var SWEEP_INTERVAL_MS = 6e4;
4577
+ var OAUTH_PROVIDERS = ["claude", "codex", "gemini"];
4578
+ var TokenRefreshScheduler = class {
4579
+ constructor(store, logger, intervalMs = SWEEP_INTERVAL_MS, leadMs = REFRESH_LEAD_MS) {
4580
+ this.store = store;
4581
+ this.logger = logger;
4582
+ this.intervalMs = intervalMs;
4583
+ this.leadMs = leadMs;
4584
+ }
4585
+ store;
4586
+ logger;
4587
+ intervalMs;
4588
+ leadMs;
4589
+ timer = null;
4590
+ sweeping = false;
4591
+ /** Arm the sweep interval. Idempotent. The timer never holds the loop open. */
4592
+ start() {
4593
+ if (this.timer) return;
4594
+ this.timer = setInterval(() => void this.sweep(), this.intervalMs);
4595
+ this.timer.unref?.();
4596
+ }
4597
+ /** Clear the interval (daemon shutdown / test teardown). Idempotent. */
4598
+ dispose() {
4599
+ if (this.timer) {
4600
+ clearInterval(this.timer);
4601
+ this.timer = null;
4602
+ }
4603
+ }
4604
+ /** One sweep over every account of every OAuth provider. Exposed for tests. */
4605
+ async sweep(now = Date.now()) {
4606
+ if (this.sweeping) return;
4607
+ this.sweeping = true;
4608
+ try {
4609
+ const config = await this.store.getFullConfig();
4610
+ for (const provider of OAUTH_PROVIDERS) {
4611
+ const activeId = getActiveAccount(config, provider)?.id;
4612
+ for (const account of listAccounts(config, provider)) {
4613
+ if (!this.needsRefresh(account.tokens, now)) continue;
4614
+ await this.refreshOne(provider, account.id, account.id === activeId);
4615
+ }
4616
+ }
4617
+ } catch (error) {
4618
+ this.logger.warn("token-refresh sweep failed", {
4619
+ error: error instanceof Error ? error.message : String(error)
4620
+ });
4621
+ } finally {
4622
+ this.sweeping = false;
4623
+ }
4624
+ }
4625
+ /** Expiring within the lead window, refreshable, and not already dead. */
4626
+ needsRefresh(tokens, now) {
4627
+ const t = tokens;
4628
+ if (!t.refreshToken || t.status === "expired" || t.status === "error") return false;
4629
+ if (!t.expiresAt) return false;
4630
+ const expiresAt = Date.parse(t.expiresAt);
4631
+ return Number.isFinite(expiresAt) && now >= expiresAt - this.leadMs;
4632
+ }
4633
+ /** Refresh one account; failures are logged, never thrown (the store has
4634
+ * already flagged the account `expired`). */
4635
+ async refreshOne(provider, id, isActive) {
4636
+ try {
4637
+ const ok = isActive ? await this.refreshActive(provider) : await this.store.refreshAccountById(provider, id);
4638
+ if (!ok) {
4639
+ this.logger.warn("background token refresh failed", { provider, accountId: id });
4640
+ } else {
4641
+ this.logger.info("background token refresh succeeded", { provider, accountId: id });
4642
+ }
4643
+ } catch (error) {
4644
+ this.logger.warn("background token refresh threw", {
4645
+ provider,
4646
+ accountId: id,
4647
+ error: error instanceof Error ? error.message : String(error)
4648
+ });
4649
+ }
4650
+ }
4651
+ refreshActive(provider) {
4652
+ switch (provider) {
4653
+ case "claude":
4654
+ return this.store.refreshClaudeToken();
4655
+ case "codex":
4656
+ return this.store.refreshCodexToken();
4657
+ case "gemini":
4658
+ return this.store.refreshGeminiToken();
4659
+ }
4660
+ }
4661
+ };
4662
+
3630
4663
  // src/bootstrap.ts
3631
4664
  function buildDaemon(config, paths) {
3632
4665
  const logger = new ConsoleLogger();
@@ -3659,7 +4692,14 @@ function buildDaemon(config, paths) {
3659
4692
  autoDisableStore.markAutoDisabled(keyId, status, at);
3660
4693
  }
3661
4694
  );
3662
- const providerProxy = getProviderProxy({ llmConfig, apiKeyPool });
4695
+ const pricingStore = new JsonPricingStore(defaultPricingPath(paths.configPath));
4696
+ const pricingEngine = new PricingEngine(pricingStore, logger);
4697
+ const usageEventStore = new JsonlUsageEventStore(
4698
+ defaultUsageEventsPath(paths.configPath),
4699
+ async (providerId, model) => await pricingEngine.getEntry(providerId, model) !== null
4700
+ );
4701
+ const usageRecorder = new UsageRecorder(usageEventStore, pricingEngine, logger);
4702
+ const providerProxy = getProviderProxy({ llmConfig, apiKeyPool, usageRecorder });
3663
4703
  llmConfig.setReloadHook(() => apiKeyPool.invalidateCache());
3664
4704
  const outboundApiServer = getOutboundApiServer({
3665
4705
  db: keyDb,
@@ -3705,10 +4745,23 @@ function buildDaemon(config, paths) {
3705
4745
  // the multi-account append (`appendProviderAccount`, import re-encrypts at-
3706
4746
  // rest). Confined to the export/import handlers; never reached by a GET.
3707
4747
  migrationCredentialStore: credentialStore,
4748
+ // Code CLI launch (dashboard parity): the external-terminal opener + PATH probe
4749
+ // default to the real implementations; tests inject spies so no window spawns.
4750
+ cliTerminalOpener: paths.cliTerminalOpener,
4751
+ cliPathProbe: paths.cliPathProbe,
4752
+ cliCommandRunner: paths.cliCommandRunner,
4753
+ // Usage/pricing admin surface (usage-pricing child): stats queries go
4754
+ // through the recorder facade, pricing mutations through the engine, and
4755
+ // the row DELETE through the concrete store (delete is store-local — the
4756
+ // core port stays frozen). None of these can reach key material.
4757
+ usageRecorder,
4758
+ pricingEngine,
4759
+ pricingStore,
3708
4760
  // Use the DECRYPTED config so `admin.token` (if stored as `enc:`) is the
3709
4761
  // plaintext bearer the AdminServer's constant-time compare expects (D4).
3710
4762
  getAdminConfig: () => resolveAdminConfig(decryptedConfig.admin)
3711
4763
  });
4764
+ const tokenRefreshScheduler = new TokenRefreshScheduler(credentialStore, logger);
3712
4765
  return {
3713
4766
  logger,
3714
4767
  llmConfig,
@@ -3721,7 +4774,11 @@ function buildDaemon(config, paths) {
3721
4774
  credentialStore,
3722
4775
  subscriptionRegistry,
3723
4776
  subscriptionAccounts,
3724
- adminServer
4777
+ pricingStore,
4778
+ pricingEngine,
4779
+ usageRecorder,
4780
+ adminServer,
4781
+ tokenRefreshScheduler
3725
4782
  };
3726
4783
  }
3727
4784
 
@@ -3763,10 +4820,10 @@ function buildCliSpawnPlan(opts) {
3763
4820
  };
3764
4821
  }
3765
4822
  function resolveInPathDefault(candidate) {
3766
- const segments = (process.env["PATH"] ?? "").split(delimiter).filter(Boolean);
4823
+ const segments = (process.env["PATH"] ?? "").split(delimiter2).filter(Boolean);
3767
4824
  for (const seg of segments) {
3768
- const full = join3(seg, candidate);
3769
- if (existsSync4(full)) return full;
4825
+ const full = join5(seg, candidate);
4826
+ if (existsSync10(full)) return full;
3770
4827
  }
3771
4828
  return null;
3772
4829
  }
@@ -3806,9 +4863,10 @@ async function runLaunch(argv, deps) {
3806
4863
  try {
3807
4864
  await daemon.llmConfig.ready();
3808
4865
  await daemon.providerProxy.start();
3809
- } catch (err4) {
4866
+ } catch (err5) {
3810
4867
  daemon.apiKeyPool.dispose();
3811
- throw err4;
4868
+ daemon.tokenRefreshScheduler.dispose();
4869
+ throw err5;
3812
4870
  }
3813
4871
  let launch;
3814
4872
  try {
@@ -3816,10 +4874,11 @@ async function runLaunch(argv, deps) {
3816
4874
  providerId: values.provider,
3817
4875
  model: values.model
3818
4876
  });
3819
- } catch (err4) {
4877
+ } catch (err5) {
3820
4878
  await daemon.providerProxy.stop();
3821
4879
  daemon.apiKeyPool.dispose();
3822
- throw err4;
4880
+ daemon.tokenRefreshScheduler.dispose();
4881
+ throw err5;
3823
4882
  }
3824
4883
  try {
3825
4884
  const plan = buildCliSpawnPlan({
@@ -3840,6 +4899,7 @@ async function runLaunch(argv, deps) {
3840
4899
  launch.onSessionEnd();
3841
4900
  await daemon.providerProxy.stop();
3842
4901
  daemon.apiKeyPool.dispose();
4902
+ daemon.tokenRefreshScheduler.dispose();
3843
4903
  }
3844
4904
  }
3845
4905
  async function buildLaunchConfig(cli, llmConfig, opts) {
@@ -3853,15 +4913,15 @@ async function buildLaunchConfig(cli, llmConfig, opts) {
3853
4913
  };
3854
4914
  switch (cli) {
3855
4915
  case "claude":
3856
- return buildClaudeCliLaunchConfig(common);
4916
+ return buildClaudeCliLaunchConfig2(common);
3857
4917
  case "codex":
3858
- return buildCodexLaunchConfig(common);
4918
+ return buildCodexLaunchConfig2(common);
3859
4919
  case "gemini":
3860
- return buildGeminiCliLaunchConfig(common);
4920
+ return buildGeminiCliLaunchConfig2(common);
3861
4921
  case "qwen":
3862
4922
  case "copilot":
3863
4923
  case "opencode":
3864
- return buildChatCliLaunchConfig({ backendId: cli, ...common });
4924
+ return buildChatCliLaunchConfig2({ backendId: cli, ...common });
3865
4925
  default: {
3866
4926
  const _exhaustive = cli;
3867
4927
  throw new Error(`Unsupported launch CLI: ${String(_exhaustive)}`);
@@ -3870,7 +4930,7 @@ async function buildLaunchConfig(cli, llmConfig, opts) {
3870
4930
  }
3871
4931
  function spawnCliInherit(plan) {
3872
4932
  return new Promise((resolve, reject) => {
3873
- const child = spawn(plan.command, plan.args, {
4933
+ const child = spawn2(plan.command, plan.args, {
3874
4934
  stdio: "inherit",
3875
4935
  env: plan.env,
3876
4936
  cwd: plan.cwd,
@@ -3889,9 +4949,9 @@ function spawnCliInherit(plan) {
3889
4949
  process.removeListener("SIGINT", onSignal);
3890
4950
  process.removeListener("SIGTERM", onSignal);
3891
4951
  };
3892
- child.on("error", (err4) => {
4952
+ child.on("error", (err5) => {
3893
4953
  detach();
3894
- if (err4.code === "ENOENT") {
4954
+ if (err5.code === "ENOENT") {
3895
4955
  reject(
3896
4956
  new Error(
3897
4957
  `launch: "${plan.command}" not found on PATH \u2014 install the CLI first.`
@@ -3899,7 +4959,7 @@ function spawnCliInherit(plan) {
3899
4959
  );
3900
4960
  return;
3901
4961
  }
3902
- reject(err4);
4962
+ reject(err5);
3903
4963
  });
3904
4964
  child.on("exit", (code, signal) => {
3905
4965
  detach();
@@ -3909,7 +4969,7 @@ function spawnCliInherit(plan) {
3909
4969
  }
3910
4970
 
3911
4971
  // src/commands/login.ts
3912
- import { spawn as spawn2 } from "child_process";
4972
+ import { spawn as spawn3 } from "child_process";
3913
4973
  import { createInterface } from "readline";
3914
4974
  import { parseArgs as parseArgs4 } from "util";
3915
4975
  import { claudeOAuth as claudeOAuth3, codexOAuth as codexOAuth3, geminiOAuth as geminiOAuth3 } from "@omnicross/subscriptions";
@@ -4060,7 +5120,7 @@ function openBrowser(url) {
4060
5120
  return new Promise((resolve) => {
4061
5121
  try {
4062
5122
  const { command, args } = buildOpenBrowserCommand(process.platform, url);
4063
- const child = spawn2(command, args, { stdio: "ignore", detached: true });
5123
+ const child = spawn3(command, args, { stdio: "ignore", detached: true });
4064
5124
  child.on("error", () => resolve(false));
4065
5125
  child.unref();
4066
5126
  resolve(true);
@@ -4080,7 +5140,7 @@ function promptPaste(prompt) {
4080
5140
  }
4081
5141
 
4082
5142
  // src/commands/providers.ts
4083
- import { randomUUID as randomUUID2 } from "crypto";
5143
+ import { randomUUID as randomUUID4 } from "crypto";
4084
5144
  import { parseArgs as parseArgs5 } from "util";
4085
5145
  async function runProviders(argv) {
4086
5146
  const { values, positionals } = parseArgs5({
@@ -4202,7 +5262,7 @@ function providersAddKey(configPath, providerId, opts) {
4202
5262
  const cfg = loadConfig(configPath);
4203
5263
  const row = cfg.providers.find((p) => p.id === providerId);
4204
5264
  if (!row) throw new Error(`providers add-key: unknown provider '${providerId}'`);
4205
- const entry = { id: randomUUID2(), apiKey: opts.key };
5265
+ const entry = { id: randomUUID4(), apiKey: opts.key };
4206
5266
  if (opts.label) entry.label = opts.label;
4207
5267
  if (opts.weight !== void 0) {
4208
5268
  const w = Number(opts.weight);
@@ -4230,7 +5290,7 @@ function providersRmKey(configPath, providerId, keyId) {
4230
5290
  }
4231
5291
 
4232
5292
  // src/commands/secrets.ts
4233
- import { existsSync as existsSync5, readFileSync as readFileSync7, writeFileSync as writeFileSync6 } from "fs";
5293
+ import { existsSync as existsSync11, readFileSync as readFileSync11, writeFileSync as writeFileSync8 } from "fs";
4234
5294
  import { parseArgs as parseArgs6 } from "util";
4235
5295
  async function runSecrets(argv) {
4236
5296
  const { values, positionals } = parseArgs6({
@@ -4302,7 +5362,7 @@ function secretsStatus(args) {
4302
5362
  reportField("admin.token", cfg.admin.token);
4303
5363
  }
4304
5364
  const tokensPath = defaultTokensPath(args.config);
4305
- if (existsSync5(tokensPath)) {
5365
+ if (existsSync11(tokensPath)) {
4306
5366
  console.info(`Secret status for ${tokensPath}:`);
4307
5367
  reportTokenFields(tokensPath);
4308
5368
  }
@@ -4342,7 +5402,7 @@ function secretsRotate(args) {
4342
5402
  const tokensPath = defaultTokensPath(args.config);
4343
5403
  try {
4344
5404
  cfg = loadConfig(args.config);
4345
- if (existsSync5(tokensPath)) tokensPlain = decryptTokensFile(tokensPath, oldBox);
5405
+ if (existsSync11(tokensPath)) tokensPlain = decryptTokensFile(tokensPath, oldBox);
4346
5406
  } finally {
4347
5407
  setSecretBox(null);
4348
5408
  }
@@ -4371,28 +5431,28 @@ function secretsDecrypt(args) {
4371
5431
  let tokensPlain = null;
4372
5432
  try {
4373
5433
  cfg = loadConfig(args.config);
4374
- if (existsSync5(tokensPath)) tokensPlain = decryptTokensFile(tokensPath, box);
5434
+ if (existsSync11(tokensPath)) tokensPlain = decryptTokensFile(tokensPath, box);
4375
5435
  } finally {
4376
5436
  setSecretBox(null);
4377
5437
  }
4378
5438
  saveConfig(args.config, cfg);
4379
5439
  if (tokensPlain) {
4380
- writeFileSync6(tokensPath, JSON.stringify(tokensPlain, null, 2) + "\n", "utf8");
5440
+ writeFileSync8(tokensPath, JSON.stringify(tokensPlain, null, 2) + "\n", "utf8");
4381
5441
  }
4382
5442
  console.info(`Decrypted secrets to plaintext in ${args.config}` + tokensSuffix(args.config));
4383
5443
  }
4384
- function readRawConfig(path) {
5444
+ function readRawConfig(path2) {
4385
5445
  let parsed;
4386
5446
  try {
4387
- parsed = JSON.parse(readFileSync7(path, "utf8"));
5447
+ parsed = JSON.parse(readFileSync11(path2, "utf8"));
4388
5448
  } catch {
4389
- throw new Error(`secrets: cannot read or parse '${path}'`);
5449
+ throw new Error(`secrets: cannot read or parse '${path2}'`);
4390
5450
  }
4391
5451
  return validateConfig(parsed);
4392
5452
  }
4393
- function readRawJson(path) {
5453
+ function readRawJson(path2) {
4394
5454
  try {
4395
- const parsed = JSON.parse(readFileSync7(path, "utf8"));
5455
+ const parsed = JSON.parse(readFileSync11(path2, "utf8"));
4396
5456
  if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
4397
5457
  return parsed;
4398
5458
  }
@@ -4402,7 +5462,7 @@ function readRawJson(path) {
4402
5462
  }
4403
5463
  function encryptTokensFileInPlace(configPath, box) {
4404
5464
  const tokensPath = defaultTokensPath(configPath);
4405
- if (!existsSync5(tokensPath)) return;
5465
+ if (!existsSync11(tokensPath)) return;
4406
5466
  const plain = decryptTokensFile(tokensPath, box);
4407
5467
  writeTokensEncrypted(tokensPath, plain, box);
4408
5468
  }
@@ -4415,7 +5475,7 @@ function writeTokensEncrypted(tokensPath, plain, box) {
4415
5475
  { updatedAt: "", ...plain },
4416
5476
  box
4417
5477
  );
4418
- writeFileSync6(tokensPath, JSON.stringify(encrypted, null, 2) + "\n", "utf8");
5478
+ writeFileSync8(tokensPath, JSON.stringify(encrypted, null, 2) + "\n", "utf8");
4419
5479
  }
4420
5480
  var TOKEN_FIELDS2 = {
4421
5481
  claude: ["accessToken", "refreshToken"],
@@ -4438,7 +5498,7 @@ function walkTokens(raw, fn) {
4438
5498
  return next;
4439
5499
  }
4440
5500
  function tokensSuffix(configPath) {
4441
- return existsSync5(defaultTokensPath(configPath)) ? " (+ tokens.json)" : "";
5501
+ return existsSync11(defaultTokensPath(configPath)) ? " (+ tokens.json)" : "";
4442
5502
  }
4443
5503
 
4444
5504
  // src/commands/start.ts
@@ -4480,6 +5540,7 @@ async function runStart(argv) {
4480
5540
  await daemon.adminServer.start();
4481
5541
  dashboardUrl = daemon.adminServer.getStatus().url;
4482
5542
  }
5543
+ daemon.tokenRefreshScheduler.start();
4483
5544
  const status = daemon.outboundApiServer.getStatus();
4484
5545
  console.info("omnicross daemon is running.");
4485
5546
  if (dashboardUrl) console.info(` dashboard : ${dashboardUrl}`);
@@ -4498,6 +5559,32 @@ async function runStart(argv) {
4498
5559
  console.info(" dashboard: localhost-default; --no-dashboard to disable; admin.token for a bearer gate");
4499
5560
  }
4500
5561
  console.info("Press Ctrl+C to stop.");
5562
+ return { dashboardUrl };
5563
+ }
5564
+
5565
+ // src/commands/ui.ts
5566
+ async function runUi(argv, deps) {
5567
+ const start = deps?.start ?? runStart;
5568
+ const open = deps?.openBrowser ?? openBrowser;
5569
+ const noOpen = argv.includes("--no-open");
5570
+ const startArgv = argv.filter((a) => a !== "--no-open");
5571
+ if (!resolveUiDist()) {
5572
+ console.warn(
5573
+ "omnicross ui: Control Panel assets not found (@omnicross/ui has no built dist). The daemon will still start, but /ui will 404. Install @omnicross/ui (or build it: npm run build -w @omnicross/ui), or set OMNICROSS_UI_DIST."
5574
+ );
5575
+ }
5576
+ const { dashboardUrl } = await start(startArgv);
5577
+ if (!dashboardUrl) {
5578
+ console.warn("omnicross ui: the admin dashboard is disabled \u2014 no UI to open.");
5579
+ return;
5580
+ }
5581
+ const uiUrl = `${dashboardUrl}/ui/`;
5582
+ console.info(`Control Panel: ${uiUrl}`);
5583
+ if (noOpen) return;
5584
+ const launched = await open(uiUrl).catch(() => false);
5585
+ if (!launched) {
5586
+ console.info("(Could not open a browser automatically \u2014 open the URL above manually.)");
5587
+ }
4501
5588
  }
4502
5589
 
4503
5590
  // src/cli.ts
@@ -4505,6 +5592,8 @@ var USAGE = `omnicross \u2014 standalone @omnicross/core daemon
4505
5592
 
4506
5593
  Usage:
4507
5594
  omnicross start --config <path> Boot the daemon (BYO-key serving).
5595
+ omnicross ui --config <path> [--no-open] Boot the daemon + open the Control Panel
5596
+ (the web UI at <dashboard>/ui/) in a browser.
4508
5597
  omnicross keys add <name> --config <p> Mint a named API key (shown once).
4509
5598
  omnicross keys list --config <p> List stored keys (no secrets).
4510
5599
  omnicross keys revoke <id> --config <p> Revoke a key.
@@ -4530,6 +5619,9 @@ async function main() {
4530
5619
  case "start":
4531
5620
  await runStart(rest);
4532
5621
  return;
5622
+ case "ui":
5623
+ await runUi(rest);
5624
+ return;
4533
5625
  case "keys":
4534
5626
  await runKeys(rest);
4535
5627
  return;
@@ -4561,7 +5653,7 @@ async function main() {
4561
5653
  process.exitCode = 1;
4562
5654
  }
4563
5655
  }
4564
- main().catch((err4) => {
4565
- console.error(err4 instanceof Error ? err4.message : String(err4));
5656
+ main().catch((err5) => {
5657
+ console.error(err5 instanceof Error ? err5.message : String(err5));
4566
5658
  process.exitCode = 1;
4567
5659
  });