@timo972/cc-router 0.12.4 → 0.13.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -791,6 +791,12 @@ function LiveDashboard({ data, port, baseUrl, lastUpdate, api, modelsApi, onInte
791
791
  ? Math.max(0, orderedAccounts.findIndex(a => a.id === selectedAccountId))
792
792
  : 0;
793
793
  const selectedAccount = orderedAccounts[selectedAccountIndex] ?? null;
794
+ // Identity for the selected row: health never carries it, so it comes from
795
+ // the authenticated /cc-router/accounts poll above. The detail line and the
796
+ // re-auth intent must agree on which email they mean, so both read this.
797
+ const selectedAccountInfo = selectedAccount
798
+ ? accountInfo[`${selectedAccount.provider ?? "anthropic_subscription"}:${selectedAccount.id}`]
799
+ : undefined;
794
800
  // Same follow-scroll for the accounts window: when the fitting controller
795
801
  // shrinks the list below the fleet size, the selected account must stay on
796
802
  // screen — account actions (caps, toggle, delete confirmation) target the
@@ -955,6 +961,42 @@ function LiveDashboard({ data, port, baseUrl, lastUpdate, api, modelsApi, onInte
955
961
  refreshAllInFlightRef.current = false;
956
962
  }
957
963
  }, [api, modelsApi, modelsStatus, onRefreshAll, refreshCliRouting, showBanner]);
964
+ // ── Reload one account ───────────────────────────────────────────────────
965
+ // R with ACCOUNTS focused narrows the whole-pool reload to the selection, so
966
+ // a single stale account can be refreshed without paying for every provider.
967
+ // Its own in-flight guard: the pool reload and this one are separate calls.
968
+ const refreshOneInFlightRef = useRef(false);
969
+ const doRefreshSelected = useCallback(async () => {
970
+ if (!selectedAccount)
971
+ return;
972
+ if (refreshOneInFlightRef.current) {
973
+ showBanner("Refresh already running…", "gray");
974
+ return;
975
+ }
976
+ refreshOneInFlightRef.current = true;
977
+ showBanner(`Refreshing ${selectedAccount.id}…`, "cyan", REFRESH_ALL_BANNER_MS);
978
+ try {
979
+ // Grok has no server-side account record to refresh — its snapshot is
980
+ // read locally from ~/.grok, so re-read that instead of POSTing.
981
+ if (isXaiAccount(selectedAccount)) {
982
+ await onRefreshAll?.();
983
+ showBanner(`Refreshed ${selectedAccount.id}`, "green");
984
+ return;
985
+ }
986
+ const result = await api.refreshAccount(selectedAccount.id);
987
+ const problems = [
988
+ result.usageRefreshed ? "" : "usage fetch failed",
989
+ result.tokenRefreshed === false ? "token refresh failed" : "",
990
+ ].filter(Boolean);
991
+ showBanner(`Refreshed ${result.id} — ${problems.length ? problems.join(", ") : "usage fresh"}`, problems.length ? "yellow" : "green");
992
+ }
993
+ catch (err) {
994
+ showBanner(`Refresh error: ${errMsg(err)}`, "red");
995
+ }
996
+ finally {
997
+ refreshOneInFlightRef.current = false;
998
+ }
999
+ }, [api, onRefreshAll, selectedAccount, showBanner]);
958
1000
  const doToggleCli = useCallback((target) => {
959
1001
  const current = target === "claude" ? cliRouting.claude.enabled : cliRouting.codex.enabled;
960
1002
  const result = target === "claude"
@@ -1080,7 +1122,7 @@ function LiveDashboard({ data, port, baseUrl, lastUpdate, api, modelsApi, onInte
1080
1122
  // Always call exit() so Ink fully unmounts and releases stdin.
1081
1123
  // The outer dashboardLoop reads `pendingIntent` after waitUntilExit().
1082
1124
  if (input === "q") {
1083
- onIntent?.("quit");
1125
+ onIntent?.({ kind: "quit" });
1084
1126
  exit();
1085
1127
  return;
1086
1128
  }
@@ -1089,7 +1131,7 @@ function LiveDashboard({ data, port, baseUrl, lastUpdate, api, modelsApi, onInte
1089
1131
  setFocus("logs");
1090
1132
  return;
1091
1133
  }
1092
- onIntent?.("quit");
1134
+ onIntent?.({ kind: "quit" });
1093
1135
  exit();
1094
1136
  return;
1095
1137
  }
@@ -1115,7 +1157,10 @@ function LiveDashboard({ data, port, baseUrl, lastUpdate, api, modelsApi, onInte
1115
1157
  return;
1116
1158
  }
1117
1159
  if (input === "R") {
1118
- void doRefreshAll();
1160
+ if (focus === "accounts" && selectedAccount)
1161
+ void doRefreshSelected();
1162
+ else
1163
+ void doRefreshAll();
1119
1164
  return;
1120
1165
  }
1121
1166
  if (key.tab) {
@@ -1199,6 +1244,27 @@ function LiveDashboard({ data, port, baseUrl, lastUpdate, api, modelsApi, onInte
1199
1244
  setMode("confirmDelete");
1200
1245
  return;
1201
1246
  }
1247
+ // l = re-authenticate the selection. Like `n`, the sign-in flow runs in
1248
+ // the outer loop after Ink unmounts, so without a handler this is a no-op.
1249
+ if (input === "l") {
1250
+ if (!selectedAccount)
1251
+ return;
1252
+ if (isXaiAccount(selectedAccount)) {
1253
+ showBanner("Grok credentials live in ~/.grok — run grok login, then cc-router accounts add grok", "yellow");
1254
+ return;
1255
+ }
1256
+ if (!onIntent)
1257
+ return;
1258
+ const provider = selectedAccount.provider === "openai_subscription" ? "openai_subscription" : "anthropic_subscription";
1259
+ onIntent({
1260
+ kind: "reauth",
1261
+ id: selectedAccount.id,
1262
+ provider,
1263
+ ...(selectedAccountInfo?.email ? { email: selectedAccountInfo.email } : {}),
1264
+ });
1265
+ exit();
1266
+ return;
1267
+ }
1202
1268
  }
1203
1269
  if (focus === "models") {
1204
1270
  if (key.upArrow) {
@@ -1251,7 +1317,7 @@ function LiveDashboard({ data, port, baseUrl, lastUpdate, api, modelsApi, onInte
1251
1317
  // flow after Ink unmounts; if none is wired, this key is a no-op.
1252
1318
  if (input === "n") {
1253
1319
  if (onIntent) {
1254
- onIntent("addAccount");
1320
+ onIntent({ kind: "addAccount" });
1255
1321
  exit();
1256
1322
  }
1257
1323
  return;
@@ -1264,10 +1330,10 @@ function LiveDashboard({ data, port, baseUrl, lastUpdate, api, modelsApi, onInte
1264
1330
  : "daemon version unreported (older build)", ` · dashboard v${DASHBOARD_VERSION}`] }), LOCAL_TARGET_RE.test(baseUrl) ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: "gray", children: " \u2014 restart: " }), _jsx(Text, { color: "cyan", children: "cc-router stop --keep-config && cc-router start" })] })) : (
1265
1331
  // A remote router can only be restarted where it runs; printing a
1266
1332
  // local restart command here would never clear the banner.
1267
- _jsxs(Text, { color: "gray", children: [" \u2014 update and restart the daemon on ", baseUrl] }))] })), _jsx(Box, { marginTop: 1 }), data.operational && (_jsxs(_Fragment, { children: [_jsx(OperationsPanel, { operational: data.operational, baseUrl: baseUrl, focus: focus, cliRouting: cliRouting }), _jsx(Box, { marginTop: 1 })] })), (focus === "models" || modelsStatus) && (_jsxs(_Fragment, { children: [_jsx(ModelsPanel, { status: modelsStatus, selectedIndex: selectedModelIndex, focused: focus === "models", visibleRows: modelsVisible, rowsRef: modelRowsRef }), _jsx(Box, { marginTop: 1 })] })), _jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsxs(Text, { bold: true, children: [" ACCOUNTS ", _jsxs(Text, { color: healthyCount === orderedAccounts.length ? "green" : "yellow", children: [healthyCount, "/", orderedAccounts.length, " healthy"] }), weeklyFullCount > 0 && _jsx(Text, { color: "red", children: ` · ${weeklyFullCount} 7d full` })] }), shownAccounts < orderedAccounts.length && (_jsxs(Text, { color: "gray", children: [" · showing ", accountWindowTop + 1, "\u2013", accountWindowTop + shownAccounts] })), compact && _jsx(Text, { color: "cyan", children: " · compact" })] }), _jsx(Box, { marginTop: 1, flexDirection: "column", ref: accountRowsRef, children: _jsx(AccountGroups, { visible: orderedAccounts.slice(accountWindowTop, accountWindowTop + shownAccounts), fleet: orderedAccounts, windowTop: accountWindowTop, selectedIndex: selectedAccountIndex, focused: focus === "accounts" }) }), focus === "accounts" && selectedAccount && (_jsxs(Text, { color: "gray", wrap: "truncate-end", children: [" ", formatAccountInfo(accountInfo[`${selectedAccount.provider ?? "anthropic_subscription"}:${selectedAccount.id}`])] }))] }), banner && (_jsx(Box, { marginTop: 1, paddingLeft: 2, children: _jsxs(Text, { color: banner.color, children: [" ", banner.text] }) })), !compact && (_jsxs(_Fragment, { children: [_jsx(Box, { marginTop: 1 }), _jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: " TOTALS " }), _jsx(Text, { children: "requests " }), _jsx(Text, { color: "cyan", children: data.totalRequests }), _jsx(Text, { color: "gray", children: " \u00B7 " }), _jsx(Text, { children: "errors " }), _jsx(Text, { color: data.totalErrors > 0 ? "red" : "green", children: data.totalErrors }), _jsx(Text, { color: "gray", children: " \u00B7 " }), _jsx(Text, { children: "refreshes " }), _jsx(Text, { color: "yellow", children: data.totalRefreshes }), _jsx(CacheHealthBadge, { read: data.totalCacheReadTokens, created: data.totalCacheCreationTokens, input: data.totalInputTokens })] }), _jsx(TokenSummary, { cacheRead: data.totalCacheReadTokens, cacheCreated: data.totalCacheCreationTokens, uncached: data.totalInputTokens, output: data.totalOutputTokens ?? 0 })] }), _jsx(Box, { marginTop: 1 }), _jsx(Text, { bold: true, children: " RECENT ACTIVITY" }), _jsx(Box, { marginTop: 1 })] }))] }), !compact && (_jsx(Box, { flexDirection: "column", ref: logRowsRef, children: visibleLogs.length === 0
1333
+ _jsxs(Text, { color: "gray", children: [" \u2014 update and restart the daemon on ", baseUrl] }))] })), _jsx(Box, { marginTop: 1 }), data.operational && (_jsxs(_Fragment, { children: [_jsx(OperationsPanel, { operational: data.operational, baseUrl: baseUrl, focus: focus, cliRouting: cliRouting }), _jsx(Box, { marginTop: 1 })] })), (focus === "models" || modelsStatus) && (_jsxs(_Fragment, { children: [_jsx(ModelsPanel, { status: modelsStatus, selectedIndex: selectedModelIndex, focused: focus === "models", visibleRows: modelsVisible, rowsRef: modelRowsRef }), _jsx(Box, { marginTop: 1 })] })), _jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsxs(Text, { bold: true, children: [" ACCOUNTS ", _jsxs(Text, { color: healthyCount === orderedAccounts.length ? "green" : "yellow", children: [healthyCount, "/", orderedAccounts.length, " healthy"] }), weeklyFullCount > 0 && _jsx(Text, { color: "red", children: ` · ${weeklyFullCount} 7d full` })] }), shownAccounts < orderedAccounts.length && (_jsxs(Text, { color: "gray", children: [" · showing ", accountWindowTop + 1, "\u2013", accountWindowTop + shownAccounts] })), compact && _jsx(Text, { color: "cyan", children: " · compact" })] }), _jsx(Box, { marginTop: 1, flexDirection: "column", ref: accountRowsRef, children: _jsx(AccountGroups, { visible: orderedAccounts.slice(accountWindowTop, accountWindowTop + shownAccounts), fleet: orderedAccounts, windowTop: accountWindowTop, selectedIndex: selectedAccountIndex, focused: focus === "accounts" }) }), focus === "accounts" && selectedAccount && (_jsxs(Text, { color: "gray", wrap: "truncate-end", children: [" ", [selectedAccount.tokenOnly ? "token-only" : "", formatAccountInfo(selectedAccountInfo)].filter(Boolean).join(" · ")] }))] }), banner && (_jsx(Box, { marginTop: 1, paddingLeft: 2, children: _jsxs(Text, { color: banner.color, children: [" ", banner.text] }) })), !compact && (_jsxs(_Fragment, { children: [_jsx(Box, { marginTop: 1 }), _jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: " TOTALS " }), _jsx(Text, { children: "requests " }), _jsx(Text, { color: "cyan", children: data.totalRequests }), _jsx(Text, { color: "gray", children: " \u00B7 " }), _jsx(Text, { children: "errors " }), _jsx(Text, { color: data.totalErrors > 0 ? "red" : "green", children: data.totalErrors }), _jsx(Text, { color: "gray", children: " \u00B7 " }), _jsx(Text, { children: "refreshes " }), _jsx(Text, { color: "yellow", children: data.totalRefreshes }), _jsx(CacheHealthBadge, { read: data.totalCacheReadTokens, created: data.totalCacheCreationTokens, input: data.totalInputTokens })] }), _jsx(TokenSummary, { cacheRead: data.totalCacheReadTokens, cacheCreated: data.totalCacheCreationTokens, uncached: data.totalInputTokens, output: data.totalOutputTokens ?? 0 })] }), _jsx(Box, { marginTop: 1 }), _jsx(Text, { bold: true, children: " RECENT ACTIVITY" }), _jsx(Box, { marginTop: 1 })] }))] }), !compact && (_jsx(Box, { flexDirection: "column", ref: logRowsRef, children: visibleLogs.length === 0
1268
1334
  ? _jsx(Text, { color: "gray", children: " No activity yet" })
1269
1335
  : visibleLogs.map((log, i) => (_jsx(LogRow, { log: log, selected: focus === "logs" && logWindowTop + i === selectedLogIndex }, `${log.ts}-${i}`))) })), !compact && focus === "logs" && selectedLog && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginTop: 1 }), _jsx(DetailPanel, { log: selectedLog })] })), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "gray", children: focus === "accounts"
1270
- ? " [Tab] [e] toggle [a]/[o]/[g] provider [n] add [d] delete [w] 7d [s] 5h [Ctrl+R] reset [R] reload [z] compact [q]"
1336
+ ? " [Tab] [e] toggle [a]/[o]/[g] provider [n] add [l] re-auth [d] delete [w] 7d [s] 5h [Ctrl+R] reset [R] reload [z] compact [q]"
1271
1337
  : focus === "models"
1272
1338
  ? " [Tab] [m/r] refresh [c]/[o] default [R] reload all [Esc] logs [z] compact [q]"
1273
1339
  : " [Tab] [m] models [R] reload [z] compact [q] quit" }) })] }) }));
@@ -12,6 +12,10 @@ const REQUEST_TIMEOUT_MS = 3_000;
12
12
  // 10s each. The server single-flights the pass, so a client that does give
13
13
  // up and presses again joins the running one rather than stacking another.
14
14
  const REFRESH_ALL_TIMEOUT_MS = 120_000;
15
+ // One account only: a token refresh, one usage fetch and the identity fetch.
16
+ // Far short of the whole-pool budget, but long enough that a slow upstream
17
+ // still lands rather than the dashboard reporting a timeout it caused.
18
+ const REFRESH_ONE_TIMEOUT_MS = 30_000;
15
19
  const MAX_PUBLIC_ROWS = 12;
16
20
  export function createAccountsApi(baseUrl, authToken) {
17
21
  const root = baseUrl.replace(/\/+$/, "");
@@ -58,6 +62,23 @@ export function createAccountsApi(baseUrl, authToken) {
58
62
  return {
59
63
  list,
60
64
  refreshAll,
65
+ async refreshAccount(id) {
66
+ const res = await fetch(`${base}/${encodeURIComponent(id)}/refresh`, {
67
+ method: "POST",
68
+ headers: authHeaders,
69
+ signal: AbortSignal.timeout(REFRESH_ONE_TIMEOUT_MS),
70
+ });
71
+ if (!res.ok)
72
+ throw new Error(`HTTP ${res.status}`);
73
+ const payload = await res.json();
74
+ const r = isRecord(payload.refresh) ? payload.refresh : {};
75
+ return {
76
+ id: publicText(r.id, 128, id),
77
+ tokenRefreshed: r.tokenRefreshed === true ? true : r.tokenRefreshed === false ? false : null,
78
+ usageRefreshed: r.usageRefreshed === true,
79
+ durationMs: publicInteger(r.durationMs),
80
+ };
81
+ },
61
82
  async resetUsage(id, redeemRequestId) {
62
83
  const response = await fetch(`${base}/${encodeURIComponent(id)}/reset-usage`, {
63
84
  method: "POST",
@@ -0,0 +1,36 @@
1
+ import { spawn as nodeSpawn } from "node:child_process";
2
+ export function browserCommandFor(platform, url) {
3
+ if (platform === "darwin")
4
+ return { command: "open", args: [url] };
5
+ if (platform === "win32")
6
+ return { command: "cmd", args: ["/c", "start", "", url] };
7
+ return { command: "xdg-open", args: [url] };
8
+ }
9
+ /**
10
+ * Best-effort: a headless box, a missing opener or an odd desktop must never
11
+ * fail a sign-in. The caller always prints the URL as well.
12
+ *
13
+ * Resolves as soon as the opener has been launched, never when it exits:
14
+ * on some Linux desktops `xdg-open` blocks until the browser it started
15
+ * closes, and a sign-in that awaited that would never reach its polling
16
+ * loop while the operator was approving it in that very browser.
17
+ */
18
+ export function openInBrowser(url, deps = {}) {
19
+ const env = deps.env ?? process.env;
20
+ if (env.CC_ROUTER_NO_BROWSER === "1")
21
+ return Promise.resolve(false);
22
+ const { command, args } = browserCommandFor(deps.platform ?? process.platform, url);
23
+ const spawn = deps.spawn ?? nodeSpawn;
24
+ return new Promise(resolve => {
25
+ try {
26
+ const child = spawn(command, args, { detached: true, stdio: "ignore" });
27
+ child.once("spawn", () => resolve(true));
28
+ child.once("error", () => resolve(false));
29
+ // Let the process exit without waiting on the opener.
30
+ child.unref();
31
+ }
32
+ catch {
33
+ resolve(false);
34
+ }
35
+ });
36
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Parse an operator-typed expiry: a Unix timestamp in milliseconds or an ISO
3
+ * date. Returns null when the input is neither.
4
+ *
5
+ * `new Date("1790000000000")` is `NaN` — the Date constructor only parses
6
+ * date strings — so a pasted millisecond timestamp has to go through
7
+ * `Number()` first, or the saved account carries an invalid expiry.
8
+ */
9
+ export function parseExpiryInput(raw) {
10
+ const value = raw.trim();
11
+ if (!value)
12
+ return null;
13
+ // Any integer-looking input is a timestamp, negative ones included: V8's
14
+ // Date.parse would otherwise read "-5" as a year.
15
+ if (/^-?\d+$/.test(value)) {
16
+ const ms = Number(value);
17
+ return Number.isFinite(ms) && ms > 0 ? ms : null;
18
+ }
19
+ const parsed = Date.parse(value);
20
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
21
+ }
@@ -123,11 +123,10 @@ function parseCredentialJson(raw) {
123
123
  const accessToken = obj["accessToken"];
124
124
  const refreshToken = obj["refreshToken"];
125
125
  const expiresAt = obj["expiresAt"];
126
- if (typeof accessToken !== "string" ||
127
- typeof refreshToken !== "string" ||
128
- !accessToken.startsWith("sk-ant-")) {
126
+ if (typeof accessToken !== "string" || !accessToken.startsWith("sk-ant-"))
127
+ return null;
128
+ if (refreshToken !== undefined && typeof refreshToken !== "string")
129
129
  return null;
130
- }
131
130
  const scopes = Array.isArray(obj["scopes"])
132
131
  ? obj["scopes"]
133
132
  : ["user:inference", "user:profile"];
@@ -142,7 +141,12 @@ function parseCredentialJson(raw) {
142
141
  // No expiry info — assume 8h from now (standard OAuth token lifetime)
143
142
  expiresAtMs = Date.now() + 8 * 60 * 60 * 1000;
144
143
  }
145
- return { accessToken, refreshToken, expiresAt: expiresAtMs, scopes };
144
+ return {
145
+ accessToken,
146
+ refreshToken: typeof refreshToken === "string" ? refreshToken : undefined,
147
+ expiresAt: expiresAtMs,
148
+ scopes,
149
+ };
146
150
  }
147
151
  catch {
148
152
  return null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timo972/cc-router",
3
- "version": "0.12.4",
3
+ "version": "0.13.0-rc.1",
4
4
  "description": "Cache-aware session router for Claude Max OAuth tokens — use multiple Claude Max accounts with Claude Code",
5
5
  "type": "module",
6
6
  "bin": {