@askalf/dario 4.8.116 → 4.8.118

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/README.md CHANGED
@@ -18,8 +18,6 @@
18
18
 
19
19
  > 🗓️ **2026-06-15 — Anthropic splits Claude billing.** Agent-SDK and `claude -p` (headless) traffic stops counting against your subscription pool and moves to a small separate monthly credit ($20 / $100 / $200 by plan), then metered per-token API rates. Most proxies forward your requests in exactly the shape that gets reclassified into that bucket. dario rewrites every request into interactive Claude Code wire-shape before it leaves your machine, so your traffic stays in the subscription pool you already pay for — same install, no config change for the cliff. **[What changes, and how to verify it on your own machine →](#the-deadline-2026-06-15)**
20
20
 
21
- > ⛔ **Claude Fable 5 / Mythos 5 — temporarily suspended for all Anthropic customers.** On 2026-06-12 a US-government legal directive disabled Fable 5 and Mythos 5 for **every** Anthropic plan and tier — `api.anthropic.com` now returns `not_found` for them, and no account or proxy can route around it ([details](https://www.anthropic.com/news/fable-mythos-access)). As of **v4.8.71**, dario filters both families out of `/v1/models` and rejects any spelling (`fable`, `fable1m`, `claude-fable-5`, `claude-fable-5[1m]`, `claude:fable`) up front with a clean `404` pointing at `claude-opus-4-8` / `claude-sonnet-5` — instead of forwarding into a confusing upstream error. Reversible without a code change once access is restored: set `DARIO_SUSPENDED_MODELS=` (empty) on the instance. `npm install -g @askalf/dario@latest`
22
- >
23
21
  > ⚠️ Still on a version **before 4.8.39**? Upgrade now — those could silently corrupt code/structured content routed through the proxy (the identifier scrub stripped tokens like the JS `continue` keyword). **[Details →](https://github.com/askalf/dario/issues/457)**
24
22
 
25
23
  ---
@@ -194,7 +192,7 @@ You point every tool at one URL. dario reads each request, decides which backend
194
192
 
195
193
  The tool doesn't know. The backend doesn't know. dario is the seam.
196
194
 
197
- **The full Claude lineup, autodetected.** Opus 4.8, Sonnet 5, and Haiku 4.5 — plus `[1m]` long-context variants, generated by one rule for every family — by full id (`claude-opus-4-8`) or shortcut (`opus` / `sonnet` / `haiku`, append `1m` for the long-context form). `GET /v1/models` asks Anthropic's live catalog (TTL-cached, baked fallback when offline), and the family shortcuts track it — a new model shows up and resolves the day it lands, no dario release needed; the model-specific wire shape (effort level, beta set, thinking config) is applied automatically. **Globally-suspended families are filtered out** of both the live catalog and the baked fallback, so `/v1/models` never advertises a model that 404s upstream and suspended ids are rejected locally with an actionable error currently Claude Fable 5 and Mythos 5 (US-government directive, 2026-06-12); governed by `DARIO_SUSPENDED_MODELS` (default `fable`), set it empty to re-enable when access is restored.
195
+ **The full Claude lineup, autodetected.** Opus 4.8, Sonnet 5, and Haiku 4.5 — plus `[1m]` long-context variants, generated by one rule for every family — by full id (`claude-opus-4-8`) or shortcut (`opus` / `sonnet` / `haiku`, append `1m` for the long-context form). `GET /v1/models` asks Anthropic's live catalog (TTL-cached, baked fallback when offline), and the family shortcuts track it — a new model shows up and resolves the day it lands, no dario release needed; the model-specific wire shape (effort level, beta set, thinking config) is applied automatically. **Suspended families are filtered out** of both the live catalog and the baked fallback, so `/v1/models` never advertises a model that 404s upstream and suspended ids are rejected locally with an actionable error. Nothing is suspended by default; the mechanism is retained via `DARIO_SUSPENDED_MODELS` (comma-separated families, every spelling caught) for if a family is ever pulled upstream again.
198
196
 
199
197
  ---
200
198
 
@@ -224,6 +222,7 @@ Tune via `~/.dario/config.json` → `overageGuard`, or CLI flags: `--overage-beh
224
222
  ## Capabilities
225
223
 
226
224
  - **Multi-account pool.** Drop Claude accounts in `~/.dario/accounts/` and pool mode auto-activates — one is enough to serve, so `dario accounts add` alone bootstraps a proxy with no `dario login` step. With more, every request routes to the account with the most headroom, multi-turn sessions pin to one account so the prompt cache survives, in-flight 429s fail over to a peer before your client sees an error. → [`docs/multi-account-pool.md`](./docs/multi-account-pool.md)
225
+ - **Headless admin API (`DARIO_ADMIN=1`).** Provision and manage pool accounts entirely over HTTP — start with zero accounts, `POST /admin/login/start`, paste the code back, and the account is routable the moment the `200` lands (live pool hot-reload, no restart). Token-gated even on loopback, audit-logged, rate-limited; `GET /admin/accounts` reports live per-account headroom. Built for Docker / k8s / Raspberry-Pi deployments where a console is the awkward part. → [`docs/admin-api.md`](./docs/admin-api.md)
227
226
  - **Behavioral stealth (`--stealth`).** Static wire fidelity covers *what* the request looks like; `--stealth` adds *when* it arrives — response-length-correlated think time and 1.2–4.2s session-start latency, the inter-arrival pattern real interactive sessions have and agent loops don't. → [`docs/wire-fidelity.md`](./docs/wire-fidelity.md)
228
227
  - **Runs any non-Claude-Code agent.** A 64-entry schema-verified `TOOL_MAP` pre-maps Cline, Roo, Kilo, Cursor, Windsurf, Continue, Copilot, OpenHands, OpenClaw, Hermes, [hands](https://github.com/askalf/hands) tool names to CC's native set. No flag, no validator errors. → [`docs/integrations/agent-compat.md`](./docs/integrations/agent-compat.md)
229
228
  - **Recover output capability.** `dario proxy --system-prompt=partial` strips CC's tone/verbosity/no-comments constraints for 1.2–2.8× more output on open-ended work — empirically without flipping billing (the classifier doesn't read that slot). [Discussion #183](https://github.com/askalf/dario/discussions/183) has the per-prompt receipts. → [`docs/system-prompt.md`](./docs/system-prompt.md)
@@ -302,9 +301,6 @@ No. `five_hour` and `seven_day` are both subscription billing — different acco
302
301
  **Will the 2026-06-15 split break my setup? / What if Anthropic ships another silent change?**
303
302
  No, and it's caught automatically — see [The deadline](#the-deadline-2026-06-15) and [How it stays working](#how-it-works-and-how-it-stays-working). dario rewrites every request to interactive-CC shape before it reaches `api.anthropic.com`, and the three-class drift watcher picks up new changes (npm-release hourly, remote-config every 30 min, classifier-rule daily). v3.38.5 + v3.38.6 — 13 minutes apart, same day as v2.1.142's silent drops — are the prior art.
304
303
 
305
- **I'm getting `404 not_found` for `claude-fable-5` / `fable`. Did dario break?**
306
- No — Claude Fable 5 and Mythos 5 were disabled for **all** Anthropic customers by a US-government legal directive on 2026-06-12 (every plan and tier; [details](https://www.anthropic.com/news/fable-mythos-access)). `api.anthropic.com` returns `not_found` for them, so no subscription or proxy can route them. Since v4.8.71 dario drops both families from `/v1/models` and rejects them locally with a clear 404 pointing at `claude-opus-4-8` / `claude-sonnet-5`, instead of forwarding into a confusing upstream error. When access is restored, set `DARIO_SUSPENDED_MODELS=` (empty) on the instance to re-enable — no upgrade needed.
307
-
308
304
  Full FAQ: [`docs/faq.md`](./docs/faq.md)
309
305
 
310
306
  ---
@@ -20,9 +20,25 @@ export interface HealthStatusLike {
20
20
  expiresIn?: string;
21
21
  refreshFailures?: number;
22
22
  lastRefreshError?: string;
23
+ /** dario's package version, surfaced to internal callers on /health (#640). */
24
+ version?: string;
23
25
  }
24
26
  export interface HealthResponse {
25
27
  httpStatus: number;
26
28
  body: Record<string, unknown>;
27
29
  }
30
+ export interface PoolAccountStatusLike {
31
+ expiresAt: number;
32
+ inAuthCooldown: boolean;
33
+ }
34
+ export interface PoolDerivedStatus {
35
+ authenticated: boolean;
36
+ status: 'healthy' | 'broken' | 'none';
37
+ expiresAt?: number;
38
+ expiresIn?: string;
39
+ /** Distinguishes the pool-derived shape from single-account getStatus(). */
40
+ mode: 'pool';
41
+ accounts: number;
42
+ }
43
+ export declare function derivePoolStatus(accounts: readonly PoolAccountStatusLike[], now: number, adminEnabled: boolean): PoolDerivedStatus;
28
44
  export declare function buildHealthResponse(s: HealthStatusLike, requestCount: number, viaPublicTunnel: boolean): HealthResponse;
@@ -14,6 +14,51 @@
14
14
  * The HTTP status (200 healthy / 503 degraded) is identical either way, so external
15
15
  * uptime monitoring that keys on the status code is unaffected.
16
16
  */
17
+ function formatMsLeft(ms) {
18
+ const clamped = Math.max(0, ms);
19
+ return `${Math.floor(clamped / 3_600_000)}h ${Math.floor((clamped % 3_600_000) / 60_000)}m`;
20
+ }
21
+ export function derivePoolStatus(accounts, now, adminEnabled) {
22
+ if (accounts.length === 0) {
23
+ // Empty admin pool: 'none'/503 is CORRECT here (every LLM request 503s
24
+ // until an account exists) — but say how to fix it instead of implying
25
+ // `dario login`, which is exactly what an admin-mode operator avoids.
26
+ return {
27
+ authenticated: false,
28
+ status: 'none',
29
+ mode: 'pool',
30
+ accounts: 0,
31
+ expiresIn: adminEnabled
32
+ ? 'no accounts yet — add one via POST /admin/login/start'
33
+ : 'no accounts yet — run `dario accounts add <alias>`',
34
+ };
35
+ }
36
+ const usable = accounts.filter((a) => !a.inAuthCooldown);
37
+ if (usable.length === 0) {
38
+ // Every account is routing-excluded after upstream auth failures — the
39
+ // next request will fail, which is the deadness /health exists to signal.
40
+ return {
41
+ authenticated: false,
42
+ status: 'broken',
43
+ mode: 'pool',
44
+ accounts: accounts.length,
45
+ expiresAt: Math.min(...accounts.map((a) => a.expiresAt)),
46
+ expiresIn: 'all accounts in auth-cooldown',
47
+ };
48
+ }
49
+ // Earliest expiry among USABLE accounts — the pool's background refresh
50
+ // (15-min loop) keeps these rolling, mirroring what the startup banner
51
+ // reports for a warm pool.
52
+ const earliest = Math.min(...usable.map((a) => a.expiresAt));
53
+ return {
54
+ authenticated: true,
55
+ status: 'healthy',
56
+ mode: 'pool',
57
+ accounts: accounts.length,
58
+ expiresAt: earliest,
59
+ expiresIn: formatMsLeft(earliest - now),
60
+ };
61
+ }
17
62
  export function buildHealthResponse(s, requestCount, viaPublicTunnel) {
18
63
  const dead = s.status === 'broken' ||
19
64
  s.status === 'none' ||
@@ -24,6 +69,9 @@ export function buildHealthResponse(s, requestCount, viaPublicTunnel) {
24
69
  ? liveness
25
70
  : {
26
71
  ...liveness,
72
+ // Version for internal callers only — an update-check signal (#640),
73
+ // withheld from the public-tunnel view alongside the OAuth internals.
74
+ ...(s.version ? { version: s.version } : {}),
27
75
  oauth: s.status,
28
76
  expiresIn: s.expiresIn,
29
77
  requests: requestCount,
@@ -118,4 +118,14 @@ export declare function getModelCatalog(deps?: CatalogDeps): Promise<ModelCatalo
118
118
  export declare function getCachedBases(): readonly string[];
119
119
  /** Fire-and-forget warmup so the first client /v1/models call is served warm. */
120
120
  export declare function prewarmModelCatalog(deps?: CatalogDeps): void;
121
+ /**
122
+ * Reset the failed-fetch backoff and kick a refetch now. For the moment
123
+ * upstream auth first becomes available — e.g. the first account hot-added
124
+ * through the admin API (#599): the startup prewarm was skipped (or a client
125
+ * /v1/models call already failed) while the pool was empty, and the 5-min
126
+ * retry backoff would otherwise keep serving the baked list even though a
127
+ * bearer now exists (#636). No-ops into a plain cache read when the catalog
128
+ * is already fresh from upstream.
129
+ */
130
+ export declare function retryModelCatalogNow(deps?: CatalogDeps): void;
121
131
  export declare function _resetModelCatalogForTest(): void;
@@ -302,6 +302,19 @@ export function getCachedBases() {
302
302
  export function prewarmModelCatalog(deps = {}) {
303
303
  void getModelCatalog(deps);
304
304
  }
305
+ /**
306
+ * Reset the failed-fetch backoff and kick a refetch now. For the moment
307
+ * upstream auth first becomes available — e.g. the first account hot-added
308
+ * through the admin API (#599): the startup prewarm was skipped (or a client
309
+ * /v1/models call already failed) while the pool was empty, and the 5-min
310
+ * retry backoff would otherwise keep serving the baked list even though a
311
+ * bearer now exists (#636). No-ops into a plain cache read when the catalog
312
+ * is already fresh from upstream.
313
+ */
314
+ export function retryModelCatalogNow(deps = {}) {
315
+ lastAttempt = 0;
316
+ void getModelCatalog(deps);
317
+ }
305
318
  export function _resetModelCatalogForTest() {
306
319
  cache = null;
307
320
  lastAttempt = 0;
package/dist/proxy.js CHANGED
@@ -7,7 +7,8 @@ import { homedir } from 'node:os';
7
7
  import { setDefaultResultOrder } from 'node:dns';
8
8
  import { arch, platform } from 'node:process';
9
9
  import { getAccessToken, getStatus } from './oauth.js';
10
- import { buildHealthResponse } from './health-response.js';
10
+ import { buildHealthResponse, derivePoolStatus } from './health-response.js';
11
+ import { darioVersion } from './version.js';
11
12
  import { buildCCRequest, applyCcPromptCaching, parseEffortSuffix, reverseMapResponse, createStreamingReverseMapper, orderHeadersForOutbound, CC_TEMPLATE } from './cc-template.js';
12
13
  import { stampCch } from './cch.js';
13
14
  import { describeTemplate, detectDrift, checkCCCompat } from './live-fingerprint.js';
@@ -21,7 +22,7 @@ import { createTokenBucket } from './rate-limit.js';
21
22
  import { getOpenAIBackend, isOpenAIModel, forwardToOpenAI } from './openai-backend.js';
22
23
  import { RequestQueue, QueueFullError, QueueTimeoutError, DEFAULT_MAX_CONCURRENT, DEFAULT_MAX_QUEUED, DEFAULT_QUEUE_TIMEOUT_MS } from './request-queue.js';
23
24
  import { redactSecrets } from './redact.js';
24
- import { BAKED_BASE_MODELS, withLongContextVariants, buildOpenAIModelsList, getModelCatalog, getCachedBases, resolveAliasAgainst, prewarmModelCatalog, isSuspendedModel } from './model-catalog.js';
25
+ import { BAKED_BASE_MODELS, withLongContextVariants, buildOpenAIModelsList, getModelCatalog, getCachedBases, resolveAliasAgainst, prewarmModelCatalog, retryModelCatalogNow, isSuspendedModel } from './model-catalog.js';
25
26
  const ANTHROPIC_API = 'https://api.anthropic.com';
26
27
  const DEFAULT_PORT = 3456;
27
28
  const MAX_BODY_BYTES = 10 * 1024 * 1024; // 10 MB — generous for large prompts, prevents abuse
@@ -1201,17 +1202,50 @@ export async function startProxy(opts = {}) {
1201
1202
  ...SECURITY_HEADERS,
1202
1203
  };
1203
1204
  const JSON_HEADERS = { 'Content-Type': 'application/json', ...SECURITY_HEADERS };
1205
+ // Pool-aware status for /status + /health (#636). In pool mode the legacy
1206
+ // single-account getStatus() reads credentials.json, which a login-less
1207
+ // pool setup (#618 pool-at-1, #599 admin bootstrap) legitimately doesn't
1208
+ // have — those endpoints reported none/503 while the pool served fine,
1209
+ // breaking docker healthchecks and making the TUI claim the proxy was down.
1210
+ async function currentStatus() {
1211
+ if (!pool)
1212
+ return getStatus();
1213
+ const now = Date.now();
1214
+ return derivePoolStatus(pool.all().map((a) => ({ expiresAt: a.expiresAt, inAuthCooldown: isInAuthCooldown(a, now) })), now, adminEnabled);
1215
+ }
1204
1216
  // Model catalog wiring — /v1/models serves the upstream-autodetected set,
1205
1217
  // authenticated the same way the request path is (per-token API key when
1206
- // ANTHROPIC_UPSTREAM_API_KEY is set, OAuth bearer otherwise). Prewarmed so
1207
- // the first client call is answered from cache; every failure path inside
1218
+ // ANTHROPIC_UPSTREAM_API_KEY is set, OAuth bearer otherwise from the pool
1219
+ // when pool mode is active, so a login-less setup doesn't fail the fetch
1220
+ // with a misleading "Run `dario login` first" (#636)). Prewarmed so the
1221
+ // first client call is answered from cache; every failure path inside
1208
1222
  // getModelCatalog falls back to the baked list, so the route always 200s.
1209
1223
  const catalogDeps = {
1210
1224
  upstreamApiKey: upstreamApiKey || undefined,
1211
- getToken: getAccessToken,
1225
+ getToken: pool
1226
+ ? async () => {
1227
+ const now = Date.now();
1228
+ const accounts = pool.all();
1229
+ if (accounts.length === 0) {
1230
+ throw new Error(adminEnabled
1231
+ ? 'pool has no accounts yet — add one via POST /admin/login/start'
1232
+ : 'pool has no accounts yet — run `dario accounts add <alias>`');
1233
+ }
1234
+ const usable = accounts.filter((a) => !isInAuthCooldown(a, now) && a.expiresAt > now);
1235
+ return (usable[0] ?? accounts[0]).accessToken;
1236
+ }
1237
+ : getAccessToken,
1212
1238
  log: verbose ? (m) => console.log(m) : undefined,
1213
1239
  };
1214
- prewarmModelCatalog(catalogDeps);
1240
+ if (pool && pool.size === 0) {
1241
+ // Empty admin pool: skip the prewarm instead of logging a guaranteed
1242
+ // failure at startup. The catalog refetches when the first account is
1243
+ // hot-added (onAccountsChanged below).
1244
+ console.log('[dario] model catalog: no accounts yet — serving baked list until one is added');
1245
+ }
1246
+ else {
1247
+ prewarmModelCatalog(catalogDeps);
1248
+ }
1215
1249
  const ERR_UNAUTH = JSON.stringify({ error: 'Unauthorized', message: 'Invalid or missing API key' });
1216
1250
  const ERR_FORBIDDEN = JSON.stringify({ error: 'Forbidden', message: 'Path not allowed. Supported paths: POST /v1/messages, POST /v1/messages/count_tokens, POST /v1/chat/completions, GET /v1/models' });
1217
1251
  const ERR_METHOD = JSON.stringify({ error: 'Method not allowed' });
@@ -1234,11 +1268,11 @@ export async function startProxy(opts = {}) {
1234
1268
  // and dependent services (`depends_on: service_healthy`) need this to
1235
1269
  // react instead of cheerfully passing while every /v1/messages 401s.
1236
1270
  if (urlPath === '/health' || urlPath === '/') {
1237
- const s = await getStatus();
1271
+ const s = await currentStatus();
1238
1272
  // Public requests arrive through the Cloudflare tunnel (the edge stamps
1239
1273
  // `cf-ray`); they get only the liveness verdict, never the OAuth internals.
1240
1274
  // See buildHealthResponse for the full rationale.
1241
- const { httpStatus, body } = buildHealthResponse(s, requestCount, req.headers['cf-ray'] !== undefined);
1275
+ const { httpStatus, body } = buildHealthResponse({ ...s, version: darioVersion() }, requestCount, req.headers['cf-ray'] !== undefined);
1242
1276
  res.writeHead(httpStatus, JSON_HEADERS);
1243
1277
  res.end(JSON.stringify(body));
1244
1278
  return;
@@ -1263,6 +1297,12 @@ export async function startProxy(opts = {}) {
1263
1297
  const size = reconcilePoolAccounts(pool, await loadAllAccounts());
1264
1298
  if (verbose)
1265
1299
  console.log(`[dario] admin: pool hot-reloaded — ${size} account${size === 1 ? '' : 's'}`);
1300
+ // The startup prewarm was skipped (or failed) while the pool was
1301
+ // empty; now that a bearer exists, refetch past the failed-fetch
1302
+ // backoff so /v1/models upgrades from the baked list without
1303
+ // waiting out the retry window (#636).
1304
+ if (size > 0)
1305
+ retryModelCatalogNow(catalogDeps);
1266
1306
  }
1267
1307
  catch (err) {
1268
1308
  console.error(`[dario] admin: pool hot-reload failed: ${err instanceof Error ? err.message : err}`);
@@ -1336,9 +1376,11 @@ export async function startProxy(opts = {}) {
1336
1376
  }
1337
1377
  // Status endpoint
1338
1378
  if (urlPath === '/status') {
1339
- const s = await getStatus();
1379
+ const s = await currentStatus();
1380
+ // Version for auto-update checks (#640) — /status is key-gated (loopback
1381
+ // or DARIO_API_KEY), so no public-disclosure concern like /health has.
1340
1382
  res.writeHead(200, JSON_HEADERS);
1341
- res.end(JSON.stringify(s));
1383
+ res.end(JSON.stringify({ version: darioVersion(), ...s }));
1342
1384
  return;
1343
1385
  }
1344
1386
  // Pool status endpoint — shows loaded accounts, headroom, and the
@@ -30,13 +30,20 @@ export declare class ProxyClient {
30
30
  constructor(opts: ProxyClientOpts);
31
31
  /**
32
32
  * GET a JSON endpoint. Rejects on non-2xx, network failure, JSON
33
- * parse error, or timeout.
33
+ * parse error, or timeout. `opts.anyStatus` accepts every HTTP status
34
+ * and parses the body regardless — for endpoints like /health that
35
+ * deliberately answer 503 with a JSON body.
34
36
  */
35
- getJson<T = unknown>(path: string): Promise<T>;
37
+ getJson<T = unknown>(path: string, opts?: {
38
+ anyStatus?: boolean;
39
+ }): Promise<T>;
36
40
  /**
37
- * Reachability probe — GET /health, returns parsed payload on
38
- * success or null on any failure. Never throws; callers use the
39
- * null as "proxy not running / unreachable".
41
+ * Reachability probe — GET /health, returns the parsed payload or null.
42
+ * /health answers 503 WITH a JSON body when upstream auth is degraded —
43
+ * that's a running proxy telling us it's unhealthy, not an unreachable
44
+ * proxy, so any HTTP response with a JSON body resolves (#636). Null is
45
+ * reserved for no-HTTP-response-at-all (connection refused, timeout) or
46
+ * a non-JSON body (something else is squatting on the port).
40
47
  */
41
48
  health(): Promise<HealthResponse | null>;
42
49
  /**
@@ -28,9 +28,11 @@ export class ProxyClient {
28
28
  }
29
29
  /**
30
30
  * GET a JSON endpoint. Rejects on non-2xx, network failure, JSON
31
- * parse error, or timeout.
31
+ * parse error, or timeout. `opts.anyStatus` accepts every HTTP status
32
+ * and parses the body regardless — for endpoints like /health that
33
+ * deliberately answer 503 with a JSON body.
32
34
  */
33
- async getJson(path) {
35
+ async getJson(path, opts) {
34
36
  const url = new URL(this.baseUrl + path);
35
37
  return new Promise((resolve, reject) => {
36
38
  const req = httpRequest({
@@ -44,7 +46,7 @@ export class ProxyClient {
44
46
  res.on('data', (c) => chunks.push(c));
45
47
  res.on('end', () => {
46
48
  const body = Buffer.concat(chunks).toString('utf-8');
47
- if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
49
+ if (!opts?.anyStatus && (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300)) {
48
50
  reject(new Error(`HTTP ${res.statusCode}: ${body.slice(0, 200)}`));
49
51
  return;
50
52
  }
@@ -65,13 +67,16 @@ export class ProxyClient {
65
67
  });
66
68
  }
67
69
  /**
68
- * Reachability probe — GET /health, returns parsed payload on
69
- * success or null on any failure. Never throws; callers use the
70
- * null as "proxy not running / unreachable".
70
+ * Reachability probe — GET /health, returns the parsed payload or null.
71
+ * /health answers 503 WITH a JSON body when upstream auth is degraded —
72
+ * that's a running proxy telling us it's unhealthy, not an unreachable
73
+ * proxy, so any HTTP response with a JSON body resolves (#636). Null is
74
+ * reserved for no-HTTP-response-at-all (connection refused, timeout) or
75
+ * a non-JSON body (something else is squatting on the port).
71
76
  */
72
77
  async health() {
73
78
  try {
74
- return await this.getJson('/health');
79
+ return await this.getJson('/health', { anyStatus: true });
75
80
  }
76
81
  catch {
77
82
  return null;
@@ -1,8 +1,16 @@
1
1
  /**
2
2
  * Accounts tab — list of OAuth subscription accounts in the pool.
3
3
  *
4
- * Read-mostly. Mutations (add/remove) require the CLI the tab
5
- * shows the relevant command in the footer.
4
+ * Source of truth is the RUNNING PROXY's live pool (`GET /accounts`), not a
5
+ * local disk read: the TUI is its own process, and in a containerized / admin
6
+ * (#599) / login-less-pool (#630) deployment the accounts live in the proxy's
7
+ * volume, so reading `~/.dario/accounts/` in the TUI process comes up empty
8
+ * while the proxy serves several accounts fine (#641). We fall back to the disk
9
+ * read only when the proxy is unreachable, and flag it so the user knows the
10
+ * view may be stale.
11
+ *
12
+ * Read-mostly. Mutations (add/remove) require the CLI or the admin API — the
13
+ * tab shows the relevant command in the footer.
6
14
  *
7
15
  * Layout:
8
16
  *
@@ -16,17 +24,21 @@
16
24
  * To add: `dario accounts add <alias>`
17
25
  * To remove: `dario accounts remove <alias>`
18
26
  */
19
- import type { Tab } from '../tab.js';
27
+ import type { Tab, TabContext } from '../tab.js';
20
28
  export interface AccountsState {
21
29
  loading: boolean;
22
30
  accounts: Array<{
23
31
  alias: string;
24
32
  expiresAt: number;
25
- /** Optional rate-limit fields populated when /accounts endpoint exists. */
33
+ /** Live pool fields present when sourced from the proxy's `/accounts`. */
26
34
  util5h?: number;
27
35
  util7d?: number;
36
+ status?: string;
28
37
  }>;
29
38
  error: string | null;
39
+ /** Where the list came from: the running proxy's pool, the proxy's
40
+ * single-account mode, or a local disk fallback when the proxy is down. */
41
+ source?: 'pool' | 'single-account' | 'disk';
30
42
  }
31
43
  export declare const AccountsTab: Tab<AccountsState>;
32
- export declare function refreshAccounts(): Promise<AccountsState>;
44
+ export declare function refreshAccounts(ctx?: TabContext<AccountsState>): Promise<AccountsState>;
@@ -1,8 +1,16 @@
1
1
  /**
2
2
  * Accounts tab — list of OAuth subscription accounts in the pool.
3
3
  *
4
- * Read-mostly. Mutations (add/remove) require the CLI the tab
5
- * shows the relevant command in the footer.
4
+ * Source of truth is the RUNNING PROXY's live pool (`GET /accounts`), not a
5
+ * local disk read: the TUI is its own process, and in a containerized / admin
6
+ * (#599) / login-less-pool (#630) deployment the accounts live in the proxy's
7
+ * volume, so reading `~/.dario/accounts/` in the TUI process comes up empty
8
+ * while the proxy serves several accounts fine (#641). We fall back to the disk
9
+ * read only when the proxy is unreachable, and flag it so the user knows the
10
+ * view may be stale.
11
+ *
12
+ * Read-mostly. Mutations (add/remove) require the CLI or the admin API — the
13
+ * tab shows the relevant command in the footer.
6
14
  *
7
15
  * Layout:
8
16
  *
@@ -25,8 +33,8 @@ export const AccountsTab = {
25
33
  initialState() {
26
34
  return { loading: true, accounts: [], error: null };
27
35
  },
28
- async onMount(_state, _ctx) {
29
- return refreshAccounts();
36
+ async onMount(_state, ctx) {
37
+ return refreshAccounts(ctx);
30
38
  },
31
39
  onKey(state, key) {
32
40
  if (key.name === 'printable' && key.ch === 'r' && !key.ctrl) {
@@ -34,6 +42,18 @@ export const AccountsTab = {
34
42
  }
35
43
  return undefined;
36
44
  },
45
+ onTick(state, ctx) {
46
+ // onKey can only return new state, not run async work — so a manual
47
+ // refresh ('r') just sets loading:true and this tick drives the refetch.
48
+ // `refreshInFlight` guards against the 250ms tick stacking overlapping
49
+ // fetches while one is already running.
50
+ if (state.loading && !refreshInFlight) {
51
+ refreshInFlight = true;
52
+ void refreshAccounts(ctx)
53
+ .then((next) => ctx.setState(next))
54
+ .finally(() => { refreshInFlight = false; });
55
+ }
56
+ },
37
57
  render(state, dimv) {
38
58
  const lines = [];
39
59
  const w = dimv.cols;
@@ -45,18 +65,42 @@ export const AccountsTab = {
45
65
  }
46
66
  if (state.accounts.length === 0) {
47
67
  lines.push('');
48
- lines.push(' ' + dim('No accounts in the pool.'));
49
- lines.push(' ' + 'Add one: ' + fg('cyan', 'dario accounts add <alias>'));
68
+ if (state.source === 'single-account') {
69
+ lines.push(' ' + dim('Single-account mode (`dario login`) no pool.'));
70
+ lines.push(' ' + 'Start a pool: ' + fg('cyan', 'dario accounts add <alias>'));
71
+ }
72
+ else {
73
+ lines.push(' ' + dim('No accounts in the pool.'));
74
+ lines.push(' ' + 'Add one: ' + fg('cyan', 'dario accounts add <alias>'));
75
+ }
50
76
  return lines.join('\n');
51
77
  }
78
+ // Live pool data (from /accounts) carries utilization; the disk fallback
79
+ // doesn't, so show the util columns only when the pool populated them.
80
+ const hasUtil = state.accounts.some((a) => a.util5h !== undefined);
81
+ if (state.source === 'disk') {
82
+ lines.push(' ' + fg('yellow', 'proxy unreachable — showing on-disk accounts (may be stale)'));
83
+ }
52
84
  // Header row
53
- lines.push(' ' + dim(pad('alias', 20) + pad('expires', 16) + pad('source', 24)));
54
- lines.push(' ' + dim(''.repeat(Math.min(w - 4, 60))));
85
+ lines.push(' ' + dim(hasUtil
86
+ ? pad('alias', 20) + pad('expires', 14) + pad('util5h', 9) + pad('util7d', 9) + pad('status', 14)
87
+ : pad('alias', 20) + pad('expires', 16) + pad('source', 24)));
88
+ lines.push(' ' + dim('─'.repeat(Math.min(w - 4, 66))));
55
89
  for (const acc of state.accounts) {
56
90
  const aliasCol = pad(acc.alias, 20);
57
- const expiresCol = pad(formatExpiry(acc.expiresAt), 16);
58
- const sourceCol = '~/.dario/accounts/' + acc.alias + '.json';
59
- lines.push(' ' + aliasCol + expiresCol + dim(sourceCol));
91
+ if (hasUtil) {
92
+ const expiresCol = pad(formatExpiry(acc.expiresAt), 14);
93
+ const u5 = pad(acc.util5h !== undefined ? `${Math.round(acc.util5h * 100)}%` : '—', 9);
94
+ const u7 = pad(acc.util7d !== undefined ? `${Math.round(acc.util7d * 100)}%` : '—', 9);
95
+ const statusCol = acc.status ?? '—';
96
+ const statusFg = statusCol === 'auth-cooldown' ? fg('yellow', statusCol) : dim(statusCol);
97
+ lines.push(' ' + aliasCol + expiresCol + u5 + u7 + statusFg);
98
+ }
99
+ else {
100
+ const expiresCol = pad(formatExpiry(acc.expiresAt), 16);
101
+ const sourceCol = '~/.dario/accounts/' + acc.alias + '.json';
102
+ lines.push(' ' + aliasCol + expiresCol + dim(sourceCol));
103
+ }
60
104
  }
61
105
  lines.push('');
62
106
  lines.push(' ' + dim('Mutations via CLI:'));
@@ -69,27 +113,62 @@ export const AccountsTab = {
69
113
  return lines.join('\n');
70
114
  },
71
115
  };
72
- export async function refreshAccounts() {
116
+ /** Guards the onTick refetch against overlapping in-flight fetches. */
117
+ let refreshInFlight = false;
118
+ export async function refreshAccounts(ctx) {
119
+ // Preferred source: the running proxy's live pool. This is what actually
120
+ // serves traffic, and it works regardless of which process/host/volume the
121
+ // TUI itself runs on — the fix for #641, where a containerized proxy held
122
+ // the accounts and the TUI's local disk read came up empty.
123
+ if (ctx) {
124
+ try {
125
+ const r = await ctx.client.getJson('/accounts');
126
+ if (r.mode === 'single-account') {
127
+ return { loading: false, accounts: [], error: null, source: 'single-account' };
128
+ }
129
+ if (Array.isArray(r.accounts)) {
130
+ const now = Date.now();
131
+ return {
132
+ loading: false,
133
+ source: 'pool',
134
+ accounts: r.accounts.map((a) => ({
135
+ alias: a.alias,
136
+ expiresAt: now + (a.expiresInMs ?? 0),
137
+ util5h: a.util5h,
138
+ util7d: a.util7d,
139
+ status: a.status,
140
+ })),
141
+ error: null,
142
+ };
143
+ }
144
+ // Unknown shape — fall through to the disk read below.
145
+ }
146
+ catch {
147
+ // Proxy unreachable (not running, wrong port, missing key) — fall back
148
+ // to the on-disk view so a standalone TUI still shows something, flagged
149
+ // stale so the user knows it isn't the live pool.
150
+ return diskFallback();
151
+ }
152
+ }
153
+ return diskFallback();
154
+ }
155
+ async function diskFallback() {
73
156
  try {
74
157
  const { listAccountAliases, loadAllAccounts } = await import('../../accounts.js');
75
158
  const aliases = await listAccountAliases();
76
159
  if (aliases.length === 0) {
77
- // Single-account / login.json path show a synthetic "default"
78
- // entry sourced from ~/.dario/credentials.json or keychain.
79
- return { loading: false, accounts: [], error: null };
160
+ return { loading: false, accounts: [], error: null, source: 'disk' };
80
161
  }
81
162
  const all = await loadAllAccounts();
82
163
  return {
83
164
  loading: false,
84
- accounts: all.map(a => ({
85
- alias: a.alias,
86
- expiresAt: a.expiresAt,
87
- })),
165
+ source: 'disk',
166
+ accounts: all.map((a) => ({ alias: a.alias, expiresAt: a.expiresAt })),
88
167
  error: null,
89
168
  };
90
169
  }
91
170
  catch (e) {
92
- return { loading: false, accounts: [], error: e.message };
171
+ return { loading: false, accounts: [], error: e.message, source: 'disk' };
93
172
  }
94
173
  }
95
174
  function formatExpiry(expiresAt) {
@@ -88,7 +88,12 @@ export const StatusTab = {
88
88
  // ── Proxy section ──────────────────────────────────────────
89
89
  lines.push(' ' + brand('Proxy'));
90
90
  if (state.health) {
91
- lines.push(' ' + renderKvRow('Status', fg('green', state.health.status), w - 4));
91
+ // /health answers 'degraded' (HTTP 503) from a RUNNING proxy whose
92
+ // upstream auth is unhealthy — render that honestly instead of the old
93
+ // behavior of collapsing any non-2xx into "unreachable" (#636).
94
+ const healthOk = state.health.status === 'ok';
95
+ lines.push(' ' + renderKvRow('Status', healthOk ? fg('green', state.health.status)
96
+ : fg('yellow', `${state.health.status} — proxy running, upstream auth not ready`), w - 4));
92
97
  lines.push(' ' + renderKvRow('OAuth', formatOauth(state.health.oauth, state.health.expiresIn), w - 4));
93
98
  lines.push(' ' + renderKvRow('Requests', String(state.health.requests ?? 0), w - 4));
94
99
  }
@@ -220,8 +225,11 @@ function formatOauth(label, expiresIn) {
220
225
  return fg('yellow', 'expired (refresh on next request)');
221
226
  if (label === 'broken')
222
227
  return fg('red', 'broken — run `dario login`');
228
+ // Pool mode passes a how-to-fix hint through expiresIn (e.g. "no accounts
229
+ // yet — add one via POST /admin/login/start"); single-account 'none' has no
230
+ // expiresIn and keeps the classic label.
223
231
  if (label === 'none')
224
- return dim('no credentials');
232
+ return dim(expiresIn ? `none — ${expiresIn}` : 'no credentials');
225
233
  return label;
226
234
  }
227
235
  function formatAgo(ts) {
@@ -0,0 +1,3 @@
1
+ export declare function darioVersion(): string;
2
+ /** Test-only: clear the memoized version. */
3
+ export declare function _resetVersionCacheForTest(): void;
@@ -0,0 +1,32 @@
1
+ /**
2
+ * dario's own package version, read once from the bundled package.json.
3
+ *
4
+ * Surfaced on `/status` and `/health` (#640) so a headless operator can confirm
5
+ * an auto-update actually rolled the running proxy — `curl /health | jq .version`
6
+ * beats exec-ing into the container to read package.json.
7
+ */
8
+ import { readFileSync } from 'node:fs';
9
+ import { join, dirname } from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+ let cached = null;
12
+ export function darioVersion() {
13
+ if (cached !== null)
14
+ return cached;
15
+ let v = 'unknown';
16
+ try {
17
+ // dist/version.js → ../package.json (same layout the MCP server + CLI use).
18
+ const here = dirname(fileURLToPath(import.meta.url));
19
+ const pkg = JSON.parse(readFileSync(join(here, '..', 'package.json'), 'utf-8'));
20
+ if (typeof pkg.version === 'string')
21
+ v = pkg.version;
22
+ }
23
+ catch {
24
+ // package.json missing/malformed — keep 'unknown', never throw.
25
+ }
26
+ cached = v;
27
+ return v;
28
+ }
29
+ /** Test-only: clear the memoized version. */
30
+ export function _resetVersionCacheForTest() {
31
+ cached = null;
32
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askalf/dario",
3
- "version": "4.8.116",
3
+ "version": "4.8.118",
4
4
  "description": "Use your Claude Pro/Max subscription in any tool — Cursor, Cline, Aider, the Agent SDK, your scripts — at subscription pricing, not per-token API bills. One local Anthropic + OpenAI-compatible endpoint.",
5
5
  "type": "module",
6
6
  "bin": {