@askalf/dario 4.8.154 → 5.0.0

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/proxy.js CHANGED
@@ -140,23 +140,27 @@ function extractFirstUserMessage(body) {
140
140
  }
141
141
  return '';
142
142
  }
143
- // Session ID behavior (single-account mode):
143
+ // Session ID behavior:
144
144
  // v3.18 rotated per request — which was itself a fingerprint. Real CC
145
145
  // rotates roughly once per conversation, not per call. A user who has
146
146
  // distinct session-ids for every request looks nothing like a CC user.
147
147
  //
148
148
  // v3.19 keeps the id stable through a conversation window and rotates
149
149
  // only after an idle gap long enough to credibly indicate a new
150
- // conversation. Pool mode still uses the per-account identity.sessionId
151
- // (stable across the account's lifetime).
150
+ // conversation.
152
151
  //
153
152
  // v3.28 generalises the single hardcoded 15-min window into a tunable
154
153
  // registry (see src/session-rotation.ts) with optional jitter, max-age,
155
- // and per-client keying. SESSION_ID below is kept only as a mirror of
156
- // the default single-account session so out-of-band consumers (presence
157
- // ping, diagnostic logs) can read the most recent id without going
158
- // through the registry. It's refreshed after every dispatch-path call
159
- // that assigns a new id.
154
+ // and per-client keying.
155
+ //
156
+ // v5.0 (pool-as-primitive) makes this registry the ONE session-id
157
+ // mechanism: it's keyed by the selected pool account so each account keeps
158
+ // its own idle/jitter/max-age lifecycle, seeded with the account's minted
159
+ // identity.sessionId so an account's first request is byte-identical to the
160
+ // pre-v5 stable id. SESSION_ID below is kept only as a mirror of the most
161
+ // recently assigned id so out-of-band consumers (presence ping, diagnostic
162
+ // logs) can read it without going through the registry. It's refreshed
163
+ // after every dispatch-path call that assigns a new id.
160
164
  let SESSION_ID = randomUUID();
161
165
  const OS_NAME = platform === 'win32' ? 'Windows' : platform === 'darwin' ? 'MacOS' : 'Linux';
162
166
  // Claude Code device identity — required for Max plan billing classification.
@@ -774,19 +778,8 @@ export function upstreamAuthHeaders(upstreamApiKey, accessToken) {
774
778
  : { 'Authorization': `Bearer ${accessToken}` };
775
779
  }
776
780
  /**
777
- * Whether the proxy routes through the account pool. Any entry in
778
- * `~/.dario/accounts/` activates the pool (#618)so a cold
779
- * `dario accounts add` bootstraps a servable proxy with no `dario login`
780
- * step — and admin mode always does (#599), where the pool may start empty
781
- * and be populated over HTTP. Login-only setups (no accounts/ entries) keep
782
- * the single-account credentials.json path. Pure + exported for unit
783
- * testing.
784
- */
785
- export function shouldUsePool(accountCount, adminEnabled) {
786
- return accountCount >= 1 || adminEnabled;
787
- }
788
- /**
789
- * Resolve the single-account startup auth outcome, refreshing an expired-but-
781
+ * Resolve the startup auth outcome when the pool is empty and there's a
782
+ * `dario login` credentials.json to fall back on refreshing an expired-but-
790
783
  * refreshable access token before giving up.
791
784
  *
792
785
  * The classic single-account startup used to read getStatus() once and, on any
@@ -877,7 +870,7 @@ export async function startProxy(opts = {}) {
877
870
  // Bun/BoringSSL shape. `--strict-tls` turns this silent divergence into
878
871
  // a startup refusal. Doctor + the always-on banner below surface the
879
872
  // same information without aborting, for users who know they're fine
880
- // (API-key billing, single-call invocations, shim-mode-elsewhere, etc.).
873
+ // (API-key billing, single-call invocations, etc.).
881
874
  const { detectRuntimeFingerprint } = await import('./runtime-fingerprint.js');
882
875
  const runtimeFp = detectRuntimeFingerprint();
883
876
  if (opts.strictTls && runtimeFp.status !== 'bun-match') {
@@ -995,47 +988,44 @@ export async function startProxy(opts = {}) {
995
988
  if (openaiBackend) {
996
989
  console.log(` OpenAI-compat backend: ${openaiBackend.name} → ${openaiBackend.baseUrl}`);
997
990
  }
998
- // Multi-account pool activated whenever ~/.dario/accounts/ has any entry
999
- // (#618; or admin mode is on, see the #599 block below). Login-only dario
1000
- // (no accounts/ entries) keeps its existing credentials.json code path.
991
+ // Pool-as-primitive (v5.0). The account pool is the one credential model:
992
+ // a plain `dario login` is a pool of one under the reserved `login` alias,
993
+ // and a pool of many is the same path with more members. There is no
994
+ // separate single-account code path.
1001
995
  //
1002
- // Before loading the pool, check whether the back-filled `login` snapshot
1003
- // has gone stale relative to credentials.json (dario#235). The single-
1004
- // account path keeps refreshing credentials.json independently; each
1005
- // refresh invalidates the snapshot's tokens server-side. Re-syncing at
1006
- // startup ensures the pool sees the current canonical tokens.
996
+ // Back-fill the login credentials into accounts/login.json unconditionally
997
+ // (pre-v5 this ran only on the first `dario accounts add` or under admin
998
+ // mode) so the request path is always the pool. No-op when accounts/ already
999
+ // has entries or no login credentials are reachable — see
1000
+ // ensureLoginCredentialsInPool.
1001
+ try {
1002
+ await ensureLoginCredentialsInPool();
1003
+ }
1004
+ catch { /* non-fatal — pool may start empty (admin bootstrap / api-key mode) */ }
1005
+ // Re-sync the back-filled `login` snapshot if credentials.json refreshed out
1006
+ // of band — e.g. a `dario refresh`/`dario status` in another process rotated
1007
+ // the shared token lineage (dario#235). Runs after the back-fill so a freshly
1008
+ // created login.json is seen as in-sync rather than triggering a redundant
1009
+ // rewrite.
1007
1010
  const resyncResult = await resyncLoginFromCredentialsIfStale();
1008
1011
  if (resyncResult === 'resynced') {
1009
1012
  console.log('[dario] re-synced pool `login` account from current credentials.json (was stale; dario#235)');
1010
1013
  }
1011
- // Admin mode (#599) manages the account pool over HTTP, so route through the
1012
- // pool engine even for 0–1 accounts. This is what makes a headless deploy
1013
- // work: an empty pool returns a clean 503 (not the single-account path's
1014
- // generic upstream error), and accounts added via POST /admin/login/* take
1015
- // effect immediately — no proxy restart (see onAccountsChanged below).
1016
- //
1017
- // Back-fill first: an operator who enables admin mode on top of an existing
1018
- // single-account `dario login` setup keeps that account (ensureLogin… is a
1019
- // no-op when accounts/ is already populated or no credentials are reachable).
1014
+ // Admin mode (#599) manages the pool over HTTP: it may legitimately start
1015
+ // with zero accounts and returns a clean 503 until one is added via
1016
+ // POST /admin/login/*, taking effect with no restart (see onAccountsChanged).
1020
1017
  const adminEnabled = process.env.DARIO_ADMIN === '1';
1021
- if (adminEnabled) {
1022
- try {
1023
- await ensureLoginCredentialsInPool();
1024
- }
1025
- catch { /* non-fatal — pool just starts empty */ }
1026
- }
1027
1018
  const accountsList = await loadAllAccounts();
1028
- const pool = shouldUsePool(accountsList.length, adminEnabled) ? new AccountPool() : null;
1019
+ const pool = new AccountPool();
1029
1020
  // Per-model rate-limit bucket families seen during this proxy run. First-
1030
1021
  // sight is logged once when verbose so a new Anthropic bucket (e.g. an
1031
1022
  // eventual `7d_opus`) doesn't slip past unnoticed. Pure observability —
1032
1023
  // routing already handles unknown families generically.
1033
1024
  const seenPerModelBuckets = new Set();
1034
1025
  // v4 promotion: analytics is always-on so the TUI's Analytics + Hits
1035
- // tabs work in both pool and single-account mode. Pre-v4 this was
1036
- // `pool ? new Analytics() : null` — that gated the /analytics
1037
- // endpoint, but burn-rate / per-request visibility is useful for
1038
- // single-account users too.
1026
+ // tabs work for every install. Pre-v4 this was `pool ? new Analytics()
1027
+ // : null` — that gated the /analytics endpoint, but burn-rate /
1028
+ // per-request visibility is useful for a pool of one too.
1039
1029
  const analytics = new Analytics();
1040
1030
  // Overage-guard (v4.1, dario#288). Resolved from opts with built-in
1041
1031
  // defaults (enabled=true, behavior='halt', cooldown=30min, notifyOs=true)
@@ -1070,67 +1060,87 @@ export async function startProxy(opts = {}) {
1070
1060
  console.error(`[dario] overage-guard resumed (${info.reason}). Normal request handling restored.`);
1071
1061
  });
1072
1062
  let status;
1073
- if (pool) {
1074
- for (const acc of accountsList) {
1075
- pool.add(acc.alias, {
1076
- accessToken: acc.accessToken,
1077
- refreshToken: acc.refreshToken,
1078
- expiresAt: acc.expiresAt,
1079
- deviceId: acc.deviceId,
1080
- accountUuid: acc.accountUuid,
1081
- });
1082
- }
1083
- // Background refresh keep every account's token fresh without blocking requests
1084
- const refreshInterval = setInterval(async () => {
1085
- for (const acc of pool.all()) {
1086
- if (acc.expiresAt < Date.now() + 45 * 60 * 1000) {
1087
- try {
1088
- const saved = await loadAccount(acc.alias);
1089
- if (!saved)
1090
- continue;
1091
- const refreshed = await refreshAccountToken(saved);
1092
- pool.updateTokens(acc.alias, refreshed.accessToken, refreshed.refreshToken, refreshed.expiresAt);
1093
- }
1094
- catch (err) {
1095
- console.error(`[dario] Background refresh failed for ${acc.alias}: ${err instanceof Error ? err.message : err}`);
1096
- }
1063
+ for (const acc of accountsList) {
1064
+ pool.add(acc.alias, {
1065
+ accessToken: acc.accessToken,
1066
+ refreshToken: acc.refreshToken,
1067
+ expiresAt: acc.expiresAt,
1068
+ deviceId: acc.deviceId,
1069
+ accountUuid: acc.accountUuid,
1070
+ });
1071
+ }
1072
+ // Background refresh — keep every account's token fresh without blocking requests
1073
+ const refreshInterval = setInterval(async () => {
1074
+ for (const acc of pool.all()) {
1075
+ if (acc.expiresAt < Date.now() + 45 * 60 * 1000) {
1076
+ try {
1077
+ const saved = await loadAccount(acc.alias);
1078
+ if (!saved)
1079
+ continue;
1080
+ const refreshed = await refreshAccountToken(saved);
1081
+ pool.updateTokens(acc.alias, refreshed.accessToken, refreshed.refreshToken, refreshed.expiresAt);
1082
+ }
1083
+ catch (err) {
1084
+ console.error(`[dario] Background refresh failed for ${acc.alias}: ${err instanceof Error ? err.message : err}`);
1097
1085
  }
1098
1086
  }
1099
- }, 15 * 60 * 1000);
1100
- refreshInterval.unref();
1101
- // Pool mode doesn't check single-account status — compute a placeholder
1102
- // for the startup banner using the pool's earliest expiry. An admin-mode
1103
- // pool can legitimately start empty (#599): report that plainly instead of
1104
- // Math.min([]) === Infinity, and tell the operator how to bootstrap.
1105
- if (pool.size === 0) {
1106
- console.warn('[dario] Starting with no accounts (admin mode). Add one via POST /admin/login/start; LLM requests return 503 until then.');
1107
- status = { authenticated: false, status: 'none', expiresAt: 0, expiresIn: 'no accounts yet' };
1108
1087
  }
1109
- else {
1110
- const earliest = Math.min(...pool.all().map(a => a.expiresAt));
1111
- const msLeft = Math.max(0, earliest - Date.now());
1112
- status = {
1113
- authenticated: true,
1114
- status: 'healthy',
1115
- expiresAt: earliest,
1116
- expiresIn: `${Math.floor(msLeft / 3600000)}h ${Math.floor((msLeft % 3600000) / 60000)}m`,
1117
- };
1118
- }
1119
- }
1120
- else {
1121
- // Single-account mode the classic `dario login` path, reached only when
1122
- // ~/.dario/accounts/ is empty. Any accounts/ entry routes through the pool
1123
- // above (#618), and admin mode always does (#599) — it starts even with
1124
- // zero accounts and returns a clean 503 until one is added.
1125
- // An expired-but-refreshable access token here is self-healing: attempt a
1126
- // refresh before giving up so a container (re)started shortly after a normal
1127
- // token expiry recovers instead of crash-looping on exit(1) (gap #1). Only a
1128
- // dead refresh token or no credentials at all still exits.
1129
- status = await resolveSingleAccountStartupStatus();
1130
- if (!status.authenticated) {
1088
+ }, 15 * 60 * 1000);
1089
+ refreshInterval.unref();
1090
+ // Auth gate. The pool is the one credential model, so "authenticated" means
1091
+ // the pool has at least one account. An empty pool is legitimate only for:
1092
+ // - admin bootstrap (#599): starts empty, returns a clean 503 until an
1093
+ // account is added over the admin API;
1094
+ // - upstream-api-key mode: OAuth + pool are bypassed, requests carry
1095
+ // x-api-key, so an empty pool is expected.
1096
+ // Otherwise an empty pool means the login back-fill found no credentials —
1097
+ // the user hasn't logged in. Preserve the single-account self-heal there: a
1098
+ // dead-but-refreshable token (a container restarted right after a normal
1099
+ // expiry, gap #1) is refreshed, then back-filled so the recovered login
1100
+ // becomes the pool-of-one it should be rather than crash-looping on exit(1).
1101
+ if (pool.size === 0 && !adminEnabled && !upstreamApiKey) {
1102
+ const single = await resolveSingleAccountStartupStatus();
1103
+ if (!single.authenticated) {
1131
1104
  console.error('[dario] Not authenticated. Run `dario login` first.');
1132
1105
  process.exit(1);
1133
1106
  }
1107
+ const recovered = await ensureLoginCredentialsInPool();
1108
+ if (recovered) {
1109
+ const acc = await loadAccount(recovered);
1110
+ if (acc) {
1111
+ pool.add(acc.alias, {
1112
+ accessToken: acc.accessToken,
1113
+ refreshToken: acc.refreshToken,
1114
+ expiresAt: acc.expiresAt,
1115
+ deviceId: acc.deviceId,
1116
+ accountUuid: acc.accountUuid,
1117
+ });
1118
+ }
1119
+ }
1120
+ }
1121
+ if (pool.size === 0) {
1122
+ // Reached only under admin bootstrap or api-key mode (the not-authenticated
1123
+ // case exited above). Report the empty pool plainly instead of
1124
+ // Math.min([]) === Infinity.
1125
+ if (adminEnabled) {
1126
+ console.warn('[dario] Starting with no accounts (admin mode). Add one via POST /admin/login/start; LLM requests return 503 until then.');
1127
+ }
1128
+ status = {
1129
+ authenticated: false,
1130
+ status: 'none',
1131
+ expiresAt: 0,
1132
+ expiresIn: upstreamApiKey ? 'api-key mode' : 'no accounts yet',
1133
+ };
1134
+ }
1135
+ else {
1136
+ const earliest = Math.min(...pool.all().map(a => a.expiresAt));
1137
+ const msLeft = Math.max(0, earliest - Date.now());
1138
+ status = {
1139
+ authenticated: true,
1140
+ status: 'healthy',
1141
+ expiresAt: earliest,
1142
+ expiresIn: `${Math.floor(msLeft / 3600000)}h ${Math.floor((msLeft % 3600000) / 60000)}m`,
1143
+ };
1134
1144
  }
1135
1145
  const cliVersion = detectCliVersion();
1136
1146
  // Parse --model once at startup. Supports `<provider>:<model>` to force
@@ -1194,12 +1204,12 @@ export async function startProxy(opts = {}) {
1194
1204
  maxQueued: opts.maxQueued ?? DEFAULT_MAX_QUEUED,
1195
1205
  queueTimeoutMs: opts.queueTimeoutMs ?? DEFAULT_QUEUE_TIMEOUT_MS,
1196
1206
  });
1197
- // Cache context-1m beta availability. Set false once per account (or process
1198
- // in single-account mode) after the first "long context" rejection, so we
1199
- // skip sending context-1m on every subsequent request instead of paying the
1200
- // round-trip + retry cost each time. Keyed by account alias; `__default__`
1201
- // is the single-account slot. Reported by @boeingchoco in dario#36 — the
1202
- // retry loop was firing on every POST with hybrid-tools + OC.
1207
+ // Cache context-1m beta availability. Set false once per account after the
1208
+ // first "long context" rejection, so we skip sending context-1m on every
1209
+ // subsequent request instead of paying the round-trip + retry cost each time.
1210
+ // Keyed by pool-account alias; `ACCOUNT_KEY_APIKEY` is the slot used in
1211
+ // upstream-api-key mode (no pool account). Reported by @boeingchoco in
1212
+ // dario#36 — the retry loop was firing on every POST with hybrid-tools + OC.
1203
1213
  const context1mUnavailable = new Set();
1204
1214
  // Per-account cache of anthropic-beta flags the upstream has rejected as
1205
1215
  // "Unexpected value(s)". The live-captured template lifts whatever CC emits
@@ -1207,9 +1217,13 @@ export async function startProxy(opts = {}) {
1207
1217
  // `afk-mode-2026-01-31` is rejected on Max 5x as of 2026-04-17). On the
1208
1218
  // first rejection we parse the flag out of the error message, strip it,
1209
1219
  // retry once, and cache it so subsequent requests on the same account don't
1210
- // re-pay the 400 round-trip. Keyed by account alias (pool) or `__default__`.
1220
+ // re-pay the 400 round-trip. Keyed by pool-account alias.
1211
1221
  const unavailableBetas = new Map();
1212
- const ACCOUNT_KEY_SINGLE = '__default__';
1222
+ // Synthetic account key for upstream-api-key mode — the one request path with
1223
+ // no pool account (v5.0: every OAuth request has a pool account, so this is
1224
+ // the sole `poolAccount?.alias ?? …` fallback that survives). Keys the
1225
+ // per-account caches and the analytics `account` field for api-key traffic.
1226
+ const ACCOUNT_KEY_APIKEY = '__apikey__';
1213
1227
  // Per-model effort capability cache — same pay-the-round-trip-once pattern
1214
1228
  // as context1mUnavailable, but keyed by WIRE MODEL id: effort support is a
1215
1229
  // model property, not an account property. Populated from upstream's
@@ -1223,10 +1237,9 @@ export async function startProxy(opts = {}) {
1223
1237
  // re-pay the rejection.
1224
1238
  const maxTokensCapByModel = new Map();
1225
1239
  // Beta flag set — sourced from the live template when the capture recorded
1226
- // one (schema v2+), else falls back to the v2.1.104 bundled default. Same
1227
- // fallback string shim/runtime.cjs uses (kept in sync so proxy and shim
1228
- // never diverge on the wire). Computed once per proxy because it's a
1229
- // function of the loaded template, not of the request.
1240
+ // one (schema v2+), else falls back to the v2.1.104 bundled default.
1241
+ // Computed once per proxy because it's a function of the loaded template,
1242
+ // not of the request.
1230
1243
  const BETA_FALLBACK = 'claude-code-20250219,oauth-2025-04-20,context-1m-2025-08-07,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05,advisor-tool-2026-03-01,effort-2025-11-24';
1231
1244
  let betaBase = CC_TEMPLATE.anthropic_beta || BETA_FALLBACK;
1232
1245
  // `oauth-2025-04-20` is CC's OAuth-enablement beta flag. It is NOT present in
@@ -1314,6 +1327,18 @@ export async function startProxy(opts = {}) {
1314
1327
  const maxAge = sessionCfg.maxAgeMs !== undefined ? `${sessionCfg.maxAgeMs}ms` : 'off';
1315
1328
  console.log(`[dario] session: idle=${sessionCfg.idleRotateMs}ms jitter=${sessionCfg.jitterMs}ms maxAge=${maxAge} perClient=${sessionCfg.perClient}`);
1316
1329
  }
1330
+ // The one session-id mechanism (v5.0). Pre-v5 the proxy forked: single-account
1331
+ // rotated its outbound session id via this registry, while pool accounts each
1332
+ // carried a stable identity.sessionId for the proxy's lifetime. Now that a
1333
+ // plain `dario login` is a pool of one, both collapse here: the session id is
1334
+ // resolved through the registry keyed by the selected account's alias (so each
1335
+ // account keeps its own idle/jitter/max-age lifecycle), seeded with that
1336
+ // account's minted identity.sessionId. The seed makes an account's FIRST
1337
+ // request byte-identical to pre-v5 (the minted id), and only idle/age rotation
1338
+ // mints a fresh one — so a pool of one rotates exactly as the old single path
1339
+ // did, and busy multi-account pools keep their per-account ids until they idle.
1340
+ // `account === null` is upstream-api-key mode; it keys on ACCOUNT_KEY_APIKEY.
1341
+ const resolveOutboundSession = (account, clientKey) => sessionRegistry.getOrCreate(account?.alias ?? ACCOUNT_KEY_APIKEY, clientKey, Date.now(), account?.identity.sessionId);
1317
1342
  // Optional proxy authentication — pre-encode key buffer for performance
1318
1343
  const apiKey = process.env.DARIO_API_KEY;
1319
1344
  const apiKeyBuf = apiKey ? Buffer.from(apiKey) : null;
@@ -1368,14 +1393,13 @@ export async function startProxy(opts = {}) {
1368
1393
  ...SECURITY_HEADERS,
1369
1394
  };
1370
1395
  const JSON_HEADERS = { 'Content-Type': 'application/json', ...SECURITY_HEADERS };
1371
- // Pool-aware status for /status + /health (#636). In pool mode the legacy
1372
- // single-account getStatus() reads credentials.json, which a login-less
1373
- // pool setup (#618 pool-at-1, #599 admin bootstrap) legitimately doesn't
1374
- // have those endpoints reported none/503 while the pool served fine,
1375
- // breaking docker healthchecks and making the TUI claim the proxy was down.
1396
+ // Pool-derived status for /status + /health (#636). The pool is the one
1397
+ // credential model, so status is computed from its accounts' expiry +
1398
+ // cool-down a login-less pool (admin bootstrap / api-key mode) reports
1399
+ // truthfully instead of the pre-v5 single-account getStatus() reading a
1400
+ // credentials.json that may not exist (which broke docker healthchecks and
1401
+ // made the TUI claim the proxy was down).
1376
1402
  async function currentStatus() {
1377
- if (!pool)
1378
- return getStatus();
1379
1403
  const now = Date.now();
1380
1404
  return derivePoolStatus(pool.all().map((a) => ({ expiresAt: a.expiresAt, inAuthCooldown: isInAuthCooldown(a, now) })), now, adminEnabled);
1381
1405
  }
@@ -1388,22 +1412,20 @@ export async function startProxy(opts = {}) {
1388
1412
  // getModelCatalog falls back to the baked list, so the route always 200s.
1389
1413
  const catalogDeps = {
1390
1414
  upstreamApiKey: upstreamApiKey || undefined,
1391
- getToken: pool
1392
- ? async () => {
1393
- const now = Date.now();
1394
- const accounts = pool.all();
1395
- if (accounts.length === 0) {
1396
- throw new Error(adminEnabled
1397
- ? 'pool has no accounts yet — add one via POST /admin/login/start'
1398
- : 'pool has no accounts yet — run `dario accounts add <alias>`');
1399
- }
1400
- const usable = accounts.filter((a) => !isInAuthCooldown(a, now) && a.expiresAt > now);
1401
- return (usable[0] ?? accounts[0]).accessToken;
1415
+ getToken: async () => {
1416
+ const now = Date.now();
1417
+ const accounts = pool.all();
1418
+ if (accounts.length === 0) {
1419
+ throw new Error(adminEnabled
1420
+ ? 'pool has no accounts yet — add one via POST /admin/login/start'
1421
+ : 'pool has no accounts yet — run `dario login` (or `dario accounts add <alias>`)');
1402
1422
  }
1403
- : getAccessToken,
1423
+ const usable = accounts.filter((a) => !isInAuthCooldown(a, now) && a.expiresAt > now);
1424
+ return (usable[0] ?? accounts[0]).accessToken;
1425
+ },
1404
1426
  log: verbose ? (m) => console.log(m) : undefined,
1405
1427
  };
1406
- if (pool && pool.size === 0) {
1428
+ if (pool.size === 0) {
1407
1429
  // Empty admin pool: skip the prewarm instead of logging a guaranteed
1408
1430
  // failure at startup. The catalog refetches when the first account is
1409
1431
  // hot-added (onAccountsChanged below).
@@ -1463,8 +1485,6 @@ export async function startProxy(opts = {}) {
1463
1485
  // the admin API take effect immediately — no proxy restart (#599).
1464
1486
  // Awaited by handleAdminRequest before it responds, so the account is
1465
1487
  // already routable by the time the client sees the 200.
1466
- if (!pool)
1467
- return;
1468
1488
  try {
1469
1489
  const size = reconcilePoolAccounts(pool, await loadAllAccounts());
1470
1490
  if (verbose)
@@ -1483,8 +1503,6 @@ export async function startProxy(opts = {}) {
1483
1503
  // Live per-account status so GET /admin/accounts reports headroom, not
1484
1504
  // just persisted metadata — the same snapshot GET /accounts exposes.
1485
1505
  poolStatus: () => {
1486
- if (!pool)
1487
- return null;
1488
1506
  const snapNow = Date.now();
1489
1507
  const snap = new Map();
1490
1508
  for (const a of pool.all()) {
@@ -1559,11 +1577,6 @@ export async function startProxy(opts = {}) {
1559
1577
  // account that would be selected next. Read-only; mutation flows through
1560
1578
  // the `dario accounts` CLI, not HTTP.
1561
1579
  if (urlPath === '/accounts' && req.method === 'GET') {
1562
- if (!pool) {
1563
- res.writeHead(200, JSON_HEADERS);
1564
- res.end(JSON.stringify({ mode: 'single-account', accounts: 0 }));
1565
- return;
1566
- }
1567
1580
  const now = Date.now();
1568
1581
  const accounts = pool.all().map(a => {
1569
1582
  const inCooldown = isInAuthCooldown(a, now);
@@ -1608,9 +1621,9 @@ export async function startProxy(opts = {}) {
1608
1621
  // backlog of the most-recent 50 records on connect so a freshly-
1609
1622
  // attached subscriber sees state immediately, then live-tails.
1610
1623
  //
1611
- // Auth: same as /analytics — no auth in single-account default mode;
1612
- // the proxy listens on loopback by default. DARIO_API_KEY users
1613
- // get rejected by the earlier auth gate up the handler chain.
1624
+ // Auth: same as /analytics — no auth by default; the proxy listens on
1625
+ // loopback. DARIO_API_KEY users get rejected by the earlier auth gate
1626
+ // up the handler chain.
1614
1627
  //
1615
1628
  // Disconnect handling: the 'close' event on `req` removes our
1616
1629
  // listener from the Analytics EventEmitter so we don't leak.
@@ -1818,21 +1831,22 @@ export async function startProxy(opts = {}) {
1818
1831
  let detectedClientForLog;
1819
1832
  let preserveToolsEffective = Boolean(opts.preserveTools);
1820
1833
  try {
1821
- // Pool mode: select an account by headroom. Single-account mode:
1822
- // fall through to getAccessToken() exactly as before. Request-path
1823
- // 429 failover (retry with the next-best account before returning a
1824
- // rate-limit error to the client) lands in v3.5.1 — this release
1825
- // ships the pool scaffolding and headroom-aware selection across
1826
- // requests, not within a single 429 retry.
1834
+ // Select an account by headroom (v5.0: the pool is the one credential
1835
+ // model, so every OAuth request selects from it — a plain `dario login`
1836
+ // is a pool of one). Upstream-api-key mode is the sole path with no pool
1837
+ // account. Inside-request 429/auth failover retries the next-best account
1838
+ // before surfacing an error to the client (see the dispatch loop below).
1827
1839
  let poolAccount = null;
1828
1840
  let accessToken;
1829
1841
  if (upstreamApiKey) {
1830
- // Per-token API-key mode: no OAuth, no pool. `poolAccount` stays null,
1831
- // so every pool-failover retry below is skipped; the x-api-key is set
1832
- // on the outbound headers instead of an Authorization bearer.
1842
+ // Per-token API-key mode: no OAuth, no pool selection. `poolAccount`
1843
+ // stays null, so every pool-failover retry below is skipped; the
1844
+ // x-api-key is set on the outbound headers instead of a Bearer.
1833
1845
  accessToken = '';
1834
1846
  }
1835
- else if (pool) {
1847
+ else {
1848
+ // Pool is the one credential model (v5.0): a plain `dario login` is a
1849
+ // pool of one, so every OAuth request selects from the pool.
1836
1850
  poolAccount = pool.select();
1837
1851
  if (!poolAccount) {
1838
1852
  // Two distinct empty-selection cases (#599): the pool has no accounts
@@ -1856,9 +1870,11 @@ export async function startProxy(opts = {}) {
1856
1870
  }
1857
1871
  accessToken = poolAccount.accessToken;
1858
1872
  }
1859
- else {
1860
- accessToken = await getAccessToken();
1861
- }
1873
+ // Client-side session key (constant per request) for the rotation registry
1874
+ // consulted at body-build, at the outbound header, and on each mid-request
1875
+ // failover rewrite so all three agree on the selected account's session.
1876
+ const clientSessionKey = req.headers['x-session-id']
1877
+ ?? req.headers['x-client-session-id'];
1862
1878
  // Read request body with size limit and timeout (prevents slow-loris)
1863
1879
  const chunks = [];
1864
1880
  let totalBytes = 0;
@@ -2065,7 +2081,7 @@ export async function startProxy(opts = {}) {
2065
2081
  // that already has the Anthropic prompt cache warmed for it.
2066
2082
  // Rotating off mid-session costs cache-create on every turn.
2067
2083
  stickyKey = computeStickyKey(userMsg);
2068
- if (pool && stickyKey) {
2084
+ if (stickyKey) {
2069
2085
  const preferred = pool.selectSticky(stickyKey, modelFamily(requestModel));
2070
2086
  if (preferred && preferred.alias !== poolAccount?.alias) {
2071
2087
  poolAccount = preferred;
@@ -2080,23 +2096,21 @@ export async function startProxy(opts = {}) {
2080
2096
  // header both use the same value. v3.27 consulted SESSION_ID twice
2081
2097
  // with rotation between the reads, so on rotation events body and
2082
2098
  // header disagreed — harmless for plain operation but a fingerprint
2083
- // in its own right.
2084
- if (poolAccount) {
2085
- preBodySessionId = poolAccount.identity.sessionId;
2086
- }
2087
- else {
2088
- const clientKey = req.headers['x-session-id']
2089
- ?? req.headers['x-client-session-id'];
2090
- const assigned = sessionRegistry.getOrCreate(clientKey, Date.now());
2099
+ // in its own right. One mechanism now (v5.0): the rotation registry
2100
+ // keyed by the selected account (see resolveOutboundSession).
2101
+ {
2102
+ const assigned = resolveOutboundSession(poolAccount, clientSessionKey);
2091
2103
  preBodySessionId = assigned.sessionId;
2092
2104
  SESSION_ID = assigned.sessionId;
2093
2105
  if (verbose && assigned.rotated && assigned.reason !== 'rotate-new') {
2094
- console.log(`[dario] #${requestCount} session: rotate (${assigned.reason})`);
2106
+ console.log(`[dario] #${requestCount} session: rotate (${assigned.reason})${poolAccount ? ` [${poolAccount.alias}]` : ''}`);
2095
2107
  }
2096
2108
  }
2097
- const bodyIdentity = poolAccount
2098
- ? poolAccount.identity
2099
- : { deviceId: identity.deviceId, accountUuid: identity.accountUuid, sessionId: preBodySessionId };
2109
+ // deviceId/accountUuid come from the selected account (or the local
2110
+ // Claude identity in api-key mode); sessionId is the registry value
2111
+ // resolved just above so body and header agree.
2112
+ const idSource = poolAccount ? poolAccount.identity : identity;
2113
+ const bodyIdentity = { deviceId: idSource.deviceId, accountUuid: idSource.accountUuid, sessionId: preBodySessionId };
2100
2114
  const { body: ccBody, toolMap, detectedClient, unmappedTools, genuineCC } = buildCCRequest(r, billingTag, CACHE_EPHEMERAL, bodyIdentity, {
2101
2115
  preserveTools: opts.preserveTools ?? false,
2102
2116
  hybridTools: opts.hybridTools ?? false,
@@ -2280,11 +2294,11 @@ export async function startProxy(opts = {}) {
2280
2294
  else {
2281
2295
  // Beta set sourced from the live template (schema v2). Bundled
2282
2296
  // snapshots predating v3.19 leave anthropic_beta undefined, so fall
2283
- // back to the v2.1.104 flag set — matches shim/runtime.cjs's fallback.
2297
+ // back to the v2.1.104 flag set.
2284
2298
  // context-1m requires Extra Usage — if it 400s, we auto-retry without
2285
2299
  // it, and cache the rejection so subsequent requests on this account
2286
2300
  // skip context-1m entirely (dario#36).
2287
- const acctKey = poolAccount?.alias ?? ACCOUNT_KEY_SINGLE;
2301
+ const acctKey = poolAccount?.alias ?? ACCOUNT_KEY_APIKEY;
2288
2302
  const skipContext1m = context1mUnavailable.has(acctKey);
2289
2303
  // Model-conditional betas (fable fallback-credit; [1m] context-1m),
2290
2304
  // mirroring real CC — see betaForModel. betaWithoutContext1m still
@@ -2345,29 +2359,23 @@ export async function startProxy(opts = {}) {
2345
2359
  await new Promise(r => setTimeout(r, totalDelay));
2346
2360
  }
2347
2361
  lastRequestTime = Date.now();
2348
- // Session ID: pool mode uses the per-account identity.sessionId (stable
2349
- // per account). Single-account mode delegates to the session registry
2350
- // (src/session-rotation.ts) which applies the configured idle / jitter /
2351
- // max-age / per-client policy. Resolution happens earlier, at body-build
2352
- // time, so the CC body's metadata.session_id and the outbound
2353
- // x-claude-code-session-id header always agree. preBodySessionId holds
2354
- // the template-build value; in passthrough mode (no template build)
2362
+ // Session ID: resolved through the rotation registry keyed by the selected
2363
+ // account (src/session-rotation.ts), applying the configured idle / jitter
2364
+ // / max-age / per-client policy. The template-build path resolves it
2365
+ // earlier so the CC body's metadata.session_id and this outbound
2366
+ // x-claude-code-session-id header agree; preBodySessionId holds that value.
2367
+ // In passthrough mode (no template build) preBodySessionId is unset, so
2355
2368
  // the registry is consulted here instead.
2356
2369
  let outboundSessionId;
2357
- if (poolAccount) {
2358
- outboundSessionId = poolAccount.identity.sessionId;
2359
- }
2360
- else if (preBodySessionId !== undefined) {
2370
+ if (preBodySessionId !== undefined) {
2361
2371
  outboundSessionId = preBodySessionId;
2362
2372
  }
2363
2373
  else {
2364
- const clientKey = req.headers['x-session-id']
2365
- ?? req.headers['x-client-session-id'];
2366
- const assigned = sessionRegistry.getOrCreate(clientKey, Date.now());
2374
+ const assigned = resolveOutboundSession(poolAccount, clientSessionKey);
2367
2375
  outboundSessionId = assigned.sessionId;
2368
2376
  SESSION_ID = assigned.sessionId;
2369
2377
  if (verbose && assigned.rotated && assigned.reason !== 'rotate-new') {
2370
- console.log(`[dario] #${requestCount} session: rotate (${assigned.reason})`);
2378
+ console.log(`[dario] #${requestCount} session: rotate (${assigned.reason})${poolAccount ? ` [${poolAccount.alias}]` : ''}`);
2371
2379
  }
2372
2380
  }
2373
2381
  const headers = {
@@ -2437,7 +2445,7 @@ export async function startProxy(opts = {}) {
2437
2445
  // Pool mode: capture rate-limit snapshot from the response. parseRateLimits
2438
2446
  // returns status='rejected' on 429, which makes the next `select()` call
2439
2447
  // route traffic away from this account until it resets.
2440
- if (pool && poolAccount) {
2448
+ if (poolAccount) {
2441
2449
  const snapshot = parseRateLimits(upstream.headers);
2442
2450
  if (upstream.status === 429) {
2443
2451
  pool.markRejected(poolAccount.alias, snapshot);
@@ -2487,7 +2495,7 @@ export async function startProxy(opts = {}) {
2487
2495
  }
2488
2496
  }
2489
2497
  if (betaRejectedFlags.length > 0) {
2490
- const acctKey = poolAccount?.alias ?? ACCOUNT_KEY_SINGLE;
2498
+ const acctKey = poolAccount?.alias ?? ACCOUNT_KEY_APIKEY;
2491
2499
  let set = unavailableBetas.get(acctKey);
2492
2500
  if (!set) {
2493
2501
  set = new Set();
@@ -2512,7 +2520,7 @@ export async function startProxy(opts = {}) {
2512
2520
  });
2513
2521
  upstream = retry;
2514
2522
  peekedBody = null;
2515
- if (pool && poolAccount) {
2523
+ if (poolAccount) {
2516
2524
  const retrySnapshot = parseRateLimits(upstream.headers);
2517
2525
  if (upstream.status === 429) {
2518
2526
  pool.markRejected(poolAccount.alias, retrySnapshot);
@@ -2552,7 +2560,7 @@ export async function startProxy(opts = {}) {
2552
2560
  upstream = retry;
2553
2561
  peekedBody = null;
2554
2562
  retried = true;
2555
- if (pool && poolAccount) {
2563
+ if (poolAccount) {
2556
2564
  const retrySnapshot = parseRateLimits(upstream.headers);
2557
2565
  if (upstream.status === 429) {
2558
2566
  pool.markRejected(poolAccount.alias, retrySnapshot);
@@ -2614,7 +2622,7 @@ export async function startProxy(opts = {}) {
2614
2622
  upstream = retry;
2615
2623
  peekedBody = null;
2616
2624
  retried = true;
2617
- if (pool && poolAccount) {
2625
+ if (poolAccount) {
2618
2626
  const retrySnapshot = parseRateLimits(upstream.headers);
2619
2627
  if (upstream.status === 429) {
2620
2628
  pool.markRejected(poolAccount.alias, retrySnapshot);
@@ -2670,7 +2678,7 @@ export async function startProxy(opts = {}) {
2670
2678
  upstream = retry;
2671
2679
  peekedBody = null;
2672
2680
  retried = true;
2673
- if (pool && poolAccount) {
2681
+ if (poolAccount) {
2674
2682
  const retrySnapshot = parseRateLimits(upstream.headers);
2675
2683
  if (upstream.status === 429) {
2676
2684
  pool.markRejected(poolAccount.alias, retrySnapshot);
@@ -2702,7 +2710,7 @@ export async function startProxy(opts = {}) {
2702
2710
  else if (isLongContextError) {
2703
2711
  // Cache the rejection so future requests on this account skip
2704
2712
  // context-1m up front instead of re-paying the 400/429 round-trip.
2705
- const acctKey = poolAccount?.alias ?? ACCOUNT_KEY_SINGLE;
2713
+ const acctKey = poolAccount?.alias ?? ACCOUNT_KEY_APIKEY;
2706
2714
  const firstRejection = !context1mUnavailable.has(acctKey);
2707
2715
  context1mUnavailable.add(acctKey);
2708
2716
  if (verbose && firstRejection)
@@ -2723,7 +2731,7 @@ export async function startProxy(opts = {}) {
2723
2731
  upstream = retry;
2724
2732
  peekedBody = null;
2725
2733
  // Pool mode: re-capture after the context-1m retry as the snapshot may have changed.
2726
- if (pool && poolAccount) {
2734
+ if (poolAccount) {
2727
2735
  const retrySnapshot = parseRateLimits(upstream.headers);
2728
2736
  if (upstream.status === 429) {
2729
2737
  pool.markRejected(poolAccount.alias, retrySnapshot);
@@ -2735,14 +2743,14 @@ export async function startProxy(opts = {}) {
2735
2743
  }
2736
2744
  else if (upstream.status === 429) {
2737
2745
  // Not a context-1m issue — try pool failover before surfacing to client
2738
- if (pool && poolAccount) {
2746
+ if (poolAccount) {
2739
2747
  const nextAccount = pool.selectExcluding(triedAliases, modelFamily(requestModel));
2740
2748
  if (nextAccount) {
2741
2749
  triedAliases.add(nextAccount.alias);
2742
2750
  poolAccount = nextAccount;
2743
2751
  accessToken = nextAccount.accessToken;
2744
2752
  headers['Authorization'] = `Bearer ${accessToken}`;
2745
- headers['x-claude-code-session-id'] = nextAccount.identity.sessionId;
2753
+ headers['x-claude-code-session-id'] = resolveOutboundSession(nextAccount, clientSessionKey).sessionId;
2746
2754
  pool.rebindSticky(stickyKey, nextAccount.alias);
2747
2755
  peekedBody = null;
2748
2756
  continue dispatchLoop;
@@ -2760,16 +2768,16 @@ export async function startProxy(opts = {}) {
2760
2768
  }
2761
2769
  }
2762
2770
  requestCount++;
2763
- // v4: analytics is always-on. Pool mode supplies the rate-limit
2771
+ // v4: analytics is always-on. A pool account supplies the rate-limit
2764
2772
  // snapshot from `poolAccount.rateLimit` (already authoritative);
2765
- // single-account mode parses it from the upstream response
2773
+ // api-key mode (no pool account) parses it from the upstream response
2766
2774
  // headers on the spot so the TUI's Hits feed shows the same
2767
- // bucket / utilization fields in both modes.
2775
+ // bucket / utilization fields either way.
2768
2776
  {
2769
2777
  const rl = poolAccount?.rateLimit ?? parseRateLimits(upstream.headers);
2770
2778
  analytics.record({
2771
2779
  timestamp: Date.now(),
2772
- account: poolAccount?.alias ?? ACCOUNT_KEY_SINGLE,
2780
+ account: poolAccount?.alias ?? ACCOUNT_KEY_APIKEY,
2773
2781
  model: requestModel,
2774
2782
  inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreateTokens: 0, thinkingTokens: 0,
2775
2783
  claim: rl.claim, util5h: rl.util5h, util7d: rl.util7d, overageUtil: rl.overageUtil,
@@ -2805,7 +2813,7 @@ export async function startProxy(opts = {}) {
2805
2813
  // headers, so headroom math sees a healthy idle account. Mark the
2806
2814
  // cool-down here, try the next-best account, fall through to the
2807
2815
  // normal forwarding only if no peer is available.
2808
- if (pool && poolAccount && (upstream.status === 401 || upstream.status === 403)) {
2816
+ if (poolAccount && (upstream.status === 401 || upstream.status === 403)) {
2809
2817
  pool.markAuthFailure(poolAccount.alias);
2810
2818
  if (verbose) {
2811
2819
  console.error(`[dario] auth failure (${upstream.status}) on account "${poolAccount.alias}" — placing in cool-down and attempting failover`);
@@ -2816,7 +2824,7 @@ export async function startProxy(opts = {}) {
2816
2824
  poolAccount = nextAccount;
2817
2825
  accessToken = nextAccount.accessToken;
2818
2826
  headers['Authorization'] = `Bearer ${accessToken}`;
2819
- headers['x-claude-code-session-id'] = nextAccount.identity.sessionId;
2827
+ headers['x-claude-code-session-id'] = resolveOutboundSession(nextAccount, clientSessionKey).sessionId;
2820
2828
  pool.rebindSticky(stickyKey, nextAccount.alias);
2821
2829
  continue dispatchLoop;
2822
2830
  }
@@ -2826,14 +2834,14 @@ export async function startProxy(opts = {}) {
2826
2834
  // Enrich 429 errors with rate limit details from headers (Anthropic only returns "Error")
2827
2835
  if (upstream.status === 429) {
2828
2836
  // Try pool failover before surfacing to client
2829
- if (pool && poolAccount) {
2837
+ if (poolAccount) {
2830
2838
  const nextAccount = pool.selectExcluding(triedAliases, modelFamily(requestModel));
2831
2839
  if (nextAccount) {
2832
2840
  triedAliases.add(nextAccount.alias);
2833
2841
  poolAccount = nextAccount;
2834
2842
  accessToken = nextAccount.accessToken;
2835
2843
  headers['Authorization'] = `Bearer ${accessToken}`;
2836
- headers['x-claude-code-session-id'] = nextAccount.identity.sessionId;
2844
+ headers['x-claude-code-session-id'] = resolveOutboundSession(nextAccount, clientSessionKey).sessionId;
2837
2845
  pool.rebindSticky(stickyKey, nextAccount.alias);
2838
2846
  continue dispatchLoop;
2839
2847
  }
@@ -2855,7 +2863,7 @@ export async function startProxy(opts = {}) {
2855
2863
  const rl = poolAccount?.rateLimit ?? parseRateLimits(upstream.headers);
2856
2864
  analytics.record({
2857
2865
  timestamp: Date.now(),
2858
- account: poolAccount?.alias ?? ACCOUNT_KEY_SINGLE,
2866
+ account: poolAccount?.alias ?? ACCOUNT_KEY_APIKEY,
2859
2867
  model: requestModel,
2860
2868
  inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreateTokens: 0, thinkingTokens: 0,
2861
2869
  claim: rl.claim, util5h: rl.util5h, util7d: rl.util7d, overageUtil: rl.overageUtil,
@@ -2870,7 +2878,7 @@ export async function startProxy(opts = {}) {
2870
2878
  // Clear the auth-failure cool-down on the responding account if
2871
2879
  // the upstream returned a 2xx — this account is healthy again,
2872
2880
  // so its consecutive-failure counter resets. dario#234.
2873
- if (pool && poolAccount && upstream.status >= 200 && upstream.status < 300) {
2881
+ if (poolAccount && upstream.status >= 200 && upstream.status < 300) {
2874
2882
  pool.clearAuthFailure(poolAccount.alias);
2875
2883
  }
2876
2884
  break;
@@ -2936,12 +2944,11 @@ export async function startProxy(opts = {}) {
2936
2944
  if (isStream && upstream.body) {
2937
2945
  // Analytics accumulators for streaming responses — filled by parsing
2938
2946
  // message_start / message_delta / content_block_delta SSE events as
2939
- // they flow through. Token capture must run regardless of pool mode:
2940
- // gating on `poolAccount` (non-null only in multi-account installs)
2941
- // skipped the parser entirely on single-account setups, so the
2942
- // analytics.record() call below persisted zeros for input/output
2943
- // tokens. SDK streaming clients on single-account installs had their
2944
- // token usage invisible in /analytics until this fix.
2947
+ // they flow through. Token capture must run regardless of whether a
2948
+ // pool account is present: gating on `poolAccount` (null in api-key
2949
+ // mode) once skipped the parser entirely, so the analytics.record()
2950
+ // call below persisted zeros for input/output tokens. SDK streaming
2951
+ // clients had their token usage invisible in /analytics until the fix.
2945
2952
  let streamInputTokens = 0;
2946
2953
  let streamOutputTokens = 0;
2947
2954
  let streamCacheReadTokens = 0;
@@ -3112,7 +3119,7 @@ export async function startProxy(opts = {}) {
3112
3119
  const rl = poolAccount?.rateLimit ?? parseRateLimits(upstream.headers);
3113
3120
  analytics.record({
3114
3121
  timestamp: Date.now(),
3115
- account: poolAccount?.alias ?? ACCOUNT_KEY_SINGLE,
3122
+ account: poolAccount?.alias ?? ACCOUNT_KEY_APIKEY,
3116
3123
  model: requestModel,
3117
3124
  inputTokens: streamInputTokens, outputTokens: streamOutputTokens,
3118
3125
  cacheReadTokens: streamCacheReadTokens, cacheCreateTokens: streamCacheCreateTokens,
@@ -3173,7 +3180,7 @@ export async function startProxy(opts = {}) {
3173
3180
  const rl = poolAccount?.rateLimit ?? parseRateLimits(upstream.headers);
3174
3181
  analytics.record({
3175
3182
  timestamp: Date.now(),
3176
- account: poolAccount?.alias ?? ACCOUNT_KEY_SINGLE,
3183
+ account: poolAccount?.alias ?? ACCOUNT_KEY_APIKEY,
3177
3184
  model: bufferedUsage.model || requestModel,
3178
3185
  inputTokens: bufferedUsage.inputTokens, outputTokens: bufferedUsage.outputTokens,
3179
3186
  cacheReadTokens: bufferedUsage.cacheReadTokens, cacheCreateTokens: bufferedUsage.cacheCreateTokens,
@@ -3358,14 +3365,14 @@ export async function startProxy(opts = {}) {
3358
3365
  ? 'Mode: passthrough (OAuth swap only, no injection)'
3359
3366
  : `OAuth: ${status.status} (expires in ${status.expiresIn})`;
3360
3367
  const modelLine = modelOverride ? `Model: ${modelOverride} (all requests)` : 'Model: passthrough (client decides)';
3361
- // Pool line surfaces the multi-account state on every startup so the
3362
- // feature is visible to single-account users (was previously only
3363
- // logged when pool mode was active).
3364
- const poolLine = pool
3365
- ? accountsList.length === 1
3366
- ? 'Pool: 1 account loaded — add more with `dario accounts add <alias>` to load-balance'
3367
- : `Pool: ${accountsList.length} accounts loadedheadroom-routed, sticky for multi-turn`
3368
- : 'Pool: single-account (run `dario accounts add <alias>` to pool multiple subscriptions)';
3368
+ // Pool line surfaces the account state on every startup. The pool is the
3369
+ // one credential model now (v5.0): a plain `dario login` shows as a pool of
3370
+ // one. An empty pool is only reachable in admin-bootstrap / api-key mode.
3371
+ const poolLine = pool.size === 0
3372
+ ? 'Pool: empty (admin bootstrap / api-key mode) — no accounts loaded'
3373
+ : pool.size === 1
3374
+ ? 'Pool: 1 account (a pool of one) add more with `dario accounts add <alias>` to load-balance'
3375
+ : `Pool: ${pool.size} accounts headroom-routed, sticky for multi-turn`;
3369
3376
  // Display URL uses `localhost` for loopback binds and the literal host
3370
3377
  // for exposed binds, so the printed URL is the one a client would
3371
3378
  // actually use to reach the proxy.
@@ -3407,15 +3414,13 @@ export async function startProxy(opts = {}) {
3407
3414
  return;
3408
3415
  lastPresencePulse = now;
3409
3416
  try {
3410
- // In pool mode the pool refresh loop (above) is the SOLE refresher of
3411
- // every account's token lineage. credentials.json shares its refresh-
3412
- // token family with accounts/login.json after a login->pool migration
3413
- // (ensureLoginCredentialsInPool), so refreshing credentials.json here
3414
- // via getAccessToken() races the pool refreshing login.json and trips
3415
- // Anthropic's refresh-token reuse-detection (the 2026-06-23 fleet
3416
- // outage, dario#641-audit). Pulse with a token the pool already keeps
3417
- // fresh; never refresh from this side in pool mode.
3418
- const token = pool ? (pool.all()[0]?.accessToken ?? '') : await getAccessToken();
3417
+ // The pool refresh loop (above) is the SOLE refresher of every account's
3418
+ // token lineage. Pulse with a token the pool already keeps fresh; never
3419
+ // refresh from this side doing so would race the pool refreshing the
3420
+ // same lineage and trip Anthropic's refresh-token reuse-detection (the
3421
+ // 2026-06-23 fleet outage, dario#641-audit). Empty in api-key mode (no
3422
+ // pool account); presence is an OAuth-session concept and is skipped there.
3423
+ const token = pool.all()[0]?.accessToken ?? '';
3419
3424
  if (!token)
3420
3425
  return;
3421
3426
  const presenceUrl = `${ANTHROPIC_API}/v1/code/sessions/${SESSION_ID}/client/presence`;
@@ -3433,25 +3438,11 @@ export async function startProxy(opts = {}) {
3433
3438
  }
3434
3439
  catch { /* presence is best-effort */ }
3435
3440
  }, 5000);
3436
- // Periodic token refresh (every 15 minutes)
3437
- const refreshInterval = setInterval(async () => {
3438
- // Pool mode: the pool's own 15-min refresh loop (above) owns token refresh
3439
- // for every account. Refreshing credentials.json here too would double-
3440
- // refresh a shared token lineage (see the presence-loop note) -> reuse-
3441
- // detection. Skip; the pool is the sole refresher.
3442
- if (pool)
3443
- return;
3444
- try {
3445
- const s = await getStatus();
3446
- if (s.status === 'expiring' || s.status === 'expired') {
3447
- console.log('[dario] Token expiring, refreshing...');
3448
- await getAccessToken(); // triggers refresh
3449
- }
3450
- }
3451
- catch (err) {
3452
- console.error('[dario] Background refresh error:', err instanceof Error ? err.message : err);
3453
- }
3454
- }, 15 * 60 * 1000);
3441
+ // Token refresh is owned entirely by the pool's 15-min refresh loop (above),
3442
+ // the sole refresher of every account's token lineage. The pre-v5 single-
3443
+ // account periodic refresh that lived here is gone with the single-account
3444
+ // path refreshing credentials.json alongside the pool would double-refresh
3445
+ // a shared lineage and trip reuse-detection (see the presence-loop note).
3455
3446
  // Graceful shutdown
3456
3447
  const shutdown = () => {
3457
3448
  console.log('\n[dario] Shutting down...');