@askalf/dario 6.0.25 → 6.0.26

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
@@ -20,7 +20,7 @@
20
20
 
21
21
  <sub><code>npm i -g @askalf/dario</code> · <strong>0</strong> runtime deps · <a href="https://www.npmjs.com/package/@askalf/dario">SLSA-attested</a> every release · nothing phones home · ~30k lines you can read in a weekend · independent, unofficial, third-party (<a href="DISCLAIMER.md">DISCLAIMER.md</a>)</sub>
22
22
 
23
- <sub>Part of <a href="#own-your-stack"><strong>Own Your Stack</strong></a> — 12 open tools for owning your AI infra: <a href="https://github.com/askalf/truecopy">truecopy</a> · <a href="https://github.com/askalf/strongroom">strongroom</a> · <a href="https://github.com/askalf/fieldpass">fieldpass</a> · <a href="https://github.com/askalf/plumbline">plumbline</a> · <a href="#own-your-stack">full family ↓</a></sub>
23
+ <sub>Part of <a href="#own-your-stack"><strong>Own Your Stack</strong></a> — 11 open tools for owning your AI infra: <a href="https://github.com/askalf/redstamp">redstamp</a> · <a href="https://github.com/askalf/truecopy">truecopy</a> · <a href="https://github.com/askalf/fieldpass">fieldpass</a> · <a href="https://github.com/askalf/plumbline">plumbline</a> · <a href="#own-your-stack">full family ↓</a></sub>
24
24
 
25
25
  </div>
26
26
 
@@ -456,16 +456,16 @@ dario is the routing layer of **[Own Your Stack](https://github.com/askalf)**
456
456
 
457
457
  - **[dario](https://github.com/askalf/dario)** — own your routing _(you are here)_
458
458
  - **[hybrid](https://github.com/askalf/hybrid)** — own your inference
459
- - **[deepdive](https://github.com/askalf/deepdive)** — own your research
460
- - **[hands](https://github.com/askalf/hands)** — own your computer-use
461
459
  - **[browser-bridge](https://github.com/askalf/browser-bridge)** — own your browser
462
460
  - **[redstamp](https://github.com/askalf/redstamp)** — own your agent security
463
461
  - **[truecopy](https://github.com/askalf/truecopy)** — own your agent skills
464
- - **[strongroom](https://github.com/askalf/strongroom)** — own your agent secrets
465
- - **[cordon](https://github.com/askalf/cordon)** — own your prompts
462
+ - **[agent-security-stack](https://github.com/askalf/agent-security-stack)** — own your agent security stack: redstamp + truecopy + strongroom leases, one MCP server
463
+ - **[cordon](https://github.com/askalf/cordon)** — own your prompts · [pair it with dario](./docs/integrations/cordon.md)
466
464
  - **[fieldpass](https://github.com/askalf/fieldpass)** — own your agent browser
467
465
  - **[plumbline](https://github.com/askalf/plumbline)** — own your agent oversight
468
466
  - **[amnesia](https://github.com/askalf/amnesia)** — own your search
467
+ - **[pgflex](https://github.com/askalf/pgflex)** — own your Postgres
468
+ - **[redisflex](https://github.com/askalf/redisflex)** — own your Redis
469
469
  - **[askalf](https://askalf.org)** — own your operation: the AI operation that runs Sprayberry Labs
470
470
 
471
471
  ---
@@ -6,6 +6,8 @@ export interface AccountCredentials {
6
6
  scopes: string[];
7
7
  deviceId: string;
8
8
  accountUuid: string;
9
+ /** Epoch ms of the OAuth grant; see OAuthTokens.grantedAt / refresh-grant.ts. */
10
+ grantedAt?: number;
9
11
  }
10
12
  export declare function listAccountAliases(): Promise<string[]>;
11
13
  export declare function loadAccount(alias: string): Promise<AccountCredentials | null>;
package/dist/accounts.js CHANGED
@@ -373,6 +373,7 @@ export async function addAccountViaOAuth(alias) {
373
373
  scopes: tokens.scope?.split(' ') ?? cfg.scopes.split(' '),
374
374
  deviceId: identity.deviceId,
375
375
  accountUuid: identity.accountUuid,
376
+ grantedAt: Date.now(),
376
377
  };
377
378
  await saveAccount(creds);
378
379
  resolve(creds);
@@ -508,6 +509,7 @@ export async function completeAddAccount(alias, code, codeVerifier, state) {
508
509
  scopes: tokens.scope?.split(' ') ?? cfg.scopes.split(' '),
509
510
  deviceId: identity.deviceId,
510
511
  accountUuid: identity.accountUuid,
512
+ grantedAt: Date.now(),
511
513
  };
512
514
  await saveAccount(creds);
513
515
  return creds;
@@ -582,6 +584,7 @@ export async function addAccountFromKeychain(alias, target) {
582
584
  scopes: oauth.scopes ?? ['user:inference'],
583
585
  deviceId: identity.deviceId,
584
586
  accountUuid: identity.accountUuid,
587
+ grantedAt: oauth.grantedAt,
585
588
  };
586
589
  await saveAccount(creds);
587
590
  return creds;
@@ -641,6 +644,7 @@ export async function ensureLoginCredentialsInPool(alias = MIGRATED_LOGIN_ALIAS)
641
644
  scopes: tok.scopes ?? [],
642
645
  deviceId: identity.deviceId,
643
646
  accountUuid: identity.accountUuid,
647
+ grantedAt: tok.grantedAt,
644
648
  });
645
649
  return alias;
646
650
  }
@@ -719,6 +723,7 @@ export async function resyncLoginFromCredentialsIfStale() {
719
723
  scopes: tok.scopes ?? loginAcc.scopes ?? [],
720
724
  deviceId: loginAcc.deviceId,
721
725
  accountUuid: loginAcc.accountUuid,
726
+ grantedAt: tok.grantedAt ?? loginAcc.grantedAt,
722
727
  });
723
728
  return 'resynced';
724
729
  }
@@ -769,6 +774,7 @@ export async function mirrorLoginToCredentials(refreshed) {
769
774
  refreshToken: refreshed.refreshToken,
770
775
  expiresAt: refreshed.expiresAt,
771
776
  scopes: refreshed.scopes ?? creds?.claudeAiOauth?.scopes ?? [],
777
+ grantedAt: refreshed.grantedAt ?? creds?.claudeAiOauth?.grantedAt,
772
778
  });
773
779
  return 'mirrored';
774
780
  }
@@ -72,6 +72,8 @@ export interface AdminAccountRecord {
72
72
  alias: string;
73
73
  scopes: string[];
74
74
  expiresAt: number;
75
+ /** Epoch ms of the OAuth grant (refresh-grant.ts); undefined when unknown. */
76
+ grantedAt?: number;
75
77
  }
76
78
  /** Live per-account pool status keyed by alias — see `AdminDeps.poolStatus`. */
77
79
  export interface AdminAccountLive {
package/dist/admin-api.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { timingSafeEqual } from 'node:crypto';
2
2
  import { startAddAccount, completeAddAccount, removeAccount, listAccountAliases, loadAccount, } from './accounts.js';
3
3
  import { parseManualPaste } from './oauth.js';
4
+ import { grantAge } from './refresh-grant.js';
4
5
  const PENDING_TTL_MS = 10 * 60_000;
5
6
  const MAX_PENDING = 64; // backstop against unbounded growth (distinct aliases)
6
7
  const ACCOUNTS_PREFIX = '/admin/accounts/';
@@ -98,7 +99,7 @@ async function defaultListAccounts() {
98
99
  const aliases = await listAccountAliases();
99
100
  const loaded = await Promise.all(aliases.map(async (alias) => {
100
101
  const a = await loadAccount(alias);
101
- return a ? { alias: a.alias, scopes: a.scopes, expiresAt: a.expiresAt } : null;
102
+ return a ? { alias: a.alias, scopes: a.scopes, expiresAt: a.expiresAt, ...(a.grantedAt !== undefined ? { grantedAt: a.grantedAt } : {}) } : null;
102
103
  }));
103
104
  return loaded.filter((a) => a !== null);
104
105
  }
@@ -345,10 +346,15 @@ export async function handleAdminRequest(req, res, urlPath, deps) {
345
346
  const live = deps.poolStatus?.() ?? null;
346
347
  const accounts = records.map((r) => {
347
348
  const l = live?.get(r.alias);
349
+ const grant = grantAge(r.grantedAt, now);
348
350
  return {
349
351
  alias: r.alias,
350
352
  scopes: r.scopes,
351
353
  expires_in_ms: Math.max(0, r.expiresAt - now),
354
+ granted_at: r.grantedAt ?? null,
355
+ grant_age_days: grant.ageDays,
356
+ grant_level: grant.level,
357
+ refresh_wall_at: grant.wallAt,
352
358
  // Inline the running pool's live status when this account is in it.
353
359
  ...(l ? {
354
360
  util5h: l.util5h,
package/dist/cli.js CHANGED
@@ -24,6 +24,7 @@ import { pathToFileURL } from 'node:url';
24
24
  import { startAutoOAuthFlow, startManualOAuthFlow, detectHeadlessEnvironment, getStatus, refreshTokens, loadCredentials, readLineFromStdin } from './oauth.js';
25
25
  import { startProxy, sanitizeError, parseModelAliasSpecs } from './proxy.js';
26
26
  import { VALID_EFFORT_VALUES } from './cc-template.js';
27
+ import { grantAge, describeGrantAge } from './refresh-grant.js';
27
28
  import { listAccountAliases, loadAllAccounts, addAccountViaOAuth, addAccountViaManualOAuth, addAccountFromKeychain, KeychainImportError, removeAccount, ensureLoginCredentialsInPool, resyncLoginFromCredentialsIfStale, MIGRATED_LOGIN_ALIAS } from './accounts.js';
28
29
  import { listCodexAccountAliases, loadAllCodexAccounts, startAddCodexAccount, completeAddCodexAccount, removeCodexAccount, parseCodexManualPaste } from './codex-accounts.js';
29
30
  import { listBackends, saveBackend, removeBackend } from './openai-backend.js';
@@ -916,6 +917,7 @@ async function accounts() {
916
917
  const mins = Math.floor((msLeft % 3600000) / 60000);
917
918
  const expiry = msLeft > 0 ? `${hours}h ${mins}m` : 'expired';
918
919
  console.log(` ${a.alias.padEnd(20)} token expires in ${expiry}`);
920
+ console.log(` ${''.padEnd(20)} ${describeGrantAge(grantAge(a.grantedAt, now))}`);
919
921
  }
920
922
  console.log('');
921
923
  return;
@@ -10,6 +10,7 @@
10
10
  * rest of the report down — every check is wrapped so a broken sub-
11
11
  * system surfaces as `fail` instead of crashing the CLI.
12
12
  */
13
+ import { type GrantThresholds } from './refresh-grant.js';
13
14
  export type CheckStatus = 'ok' | 'warn' | 'fail' | 'info';
14
15
  export interface Check {
15
16
  /** 'ok' passes; 'warn' is advisory; 'fail' blocks (exit code 1); 'info' is neutral. */
@@ -125,6 +126,21 @@ export declare function oauthCheckRow(input: {
125
126
  poolHealthy: number;
126
127
  poolTotal: number;
127
128
  }): Check;
129
+ /**
130
+ * The refresh-token grant row (refresh-grant.ts), as a pure decision. A token
131
+ * refresh keeps the access token fresh; it does not move the ~28-day wall on
132
+ * the grant. Worst seat decides the row: urgent → fail, warn → warn, unknown
133
+ * (unstamped) → info, all ok → ok. Every seat is listed so the operator sees
134
+ * which one to re-grant.
135
+ */
136
+ export declare function checkRefreshGrant(input: {
137
+ accounts: Array<{
138
+ alias: string;
139
+ grantedAt?: number | null;
140
+ }>;
141
+ now: number;
142
+ thresholds?: GrantThresholds;
143
+ }): Check[];
128
144
  export declare function checkIdentityDrift(input: IdentityDriftInput): Check[];
129
145
  export declare function probeNpmLatestCC(): string | null;
130
146
  /**
@@ -10,6 +10,7 @@
10
10
  * rest of the report down — every check is wrapped so a broken sub-
11
11
  * system surfaces as `fail` instead of crashing the CLI.
12
12
  */
13
+ import { grantAge, grantThresholds, worstGrantLevel, describeGrantAge } from './refresh-grant.js';
13
14
  import { readFileSync } from 'node:fs';
14
15
  import { join, dirname } from 'node:path';
15
16
  import { fileURLToPath } from 'node:url';
@@ -172,6 +173,25 @@ export function oauthCheckRow(input) {
172
173
  detail: legacyStatus === 'none' ? 'not authenticated — run `dario login`' : legacyStatus,
173
174
  };
174
175
  }
176
+ /**
177
+ * The refresh-token grant row (refresh-grant.ts), as a pure decision. A token
178
+ * refresh keeps the access token fresh; it does not move the ~28-day wall on
179
+ * the grant. Worst seat decides the row: urgent → fail, warn → warn, unknown
180
+ * (unstamped) → info, all ok → ok. Every seat is listed so the operator sees
181
+ * which one to re-grant.
182
+ */
183
+ export function checkRefreshGrant(input) {
184
+ if (input.accounts.length === 0)
185
+ return [];
186
+ const t = input.thresholds ?? grantThresholds();
187
+ const ages = input.accounts.map((a) => ({ alias: a.alias, age: grantAge(a.grantedAt, input.now, t) }));
188
+ const worst = worstGrantLevel(ages.map((a) => a.age.level));
189
+ const status = worst === 'urgent' ? 'fail' : worst === 'warn' ? 'warn' : worst === 'unknown' ? 'info' : 'ok';
190
+ const perSeat = ages.map((a) => `${a.alias}: ${describeGrantAge(a.age, t)}`).join('; ');
191
+ const fix = worst === 'ok' ? '' : REGRANT_FIX;
192
+ return [{ status, label: 'Refresh grant', detail: `${perSeat}${fix}` }];
193
+ }
194
+ const REGRANT_FIX = " — re-grant with `dario accounts add <alias>` (or `dario login --force-reauth` for the login seat); the new grant restarts the clock";
175
195
  export function checkIdentityDrift(input) {
176
196
  const { live, poolAccounts } = input;
177
197
  // Short-prefix for surfaced IDs — 64-char userIDs and 36-char UUIDs are
@@ -905,6 +925,7 @@ export async function runChecks(opts = {}) {
905
925
  (expired > 0 ? `, ${expired} expired` : '') +
906
926
  (aliases.length === 1 ? ' (a pool of one — `dario accounts add <alias>` to load-balance)' : ''),
907
927
  });
928
+ checks.push(...checkRefreshGrant({ accounts: loaded.map((a) => ({ alias: a.alias, grantedAt: a.grantedAt })), now }));
908
929
  // Next-account-in-rotation surfacing. The proxy's per-request
909
930
  // selector picks by max headroom (with 7d_<family> per-model
910
931
  // bucket considered when a request's model family is known);
@@ -14,7 +14,15 @@
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
+ /** Pool-wide refresh-token grant age (refresh-grant.ts), internal-only. */
18
+ export interface RefreshGrantSummary {
19
+ level: 'ok' | 'warn' | 'urgent' | 'unknown';
20
+ oldestAgeDays: number | null;
21
+ daysToWall: number | null;
22
+ seats: Record<string, 'ok' | 'warn' | 'urgent' | 'unknown'>;
23
+ }
17
24
  export interface HealthStatusLike {
25
+ refreshGrant?: RefreshGrantSummary;
18
26
  status: string;
19
27
  canRefresh?: boolean;
20
28
  /**
@@ -25,6 +33,18 @@ export interface HealthStatusLike {
25
33
  * buildHealthResponse.
26
34
  */
27
35
  upstreamApiKeyMode?: boolean;
36
+ /**
37
+ * A stored Codex account is serving in place of the Claude pool: the proxy
38
+ * runs `--no-claude-auth` (pool deliberately empty) AND at least one Codex
39
+ * account is present, per the same presence check the codex router asks.
40
+ * Same reasoning as upstreamApiKeyMode — OAuth state is not evidence about
41
+ * serving, so an empty pool must not read as 503 (found by
42
+ * codex-drift-watch.yml: its --no-claude-auth proxy never passed /health).
43
+ * The flag ALONE is not evidence: `--no-claude-auth` with no account and no
44
+ * API key starts fine and can serve nothing — that stays 503 (review on
45
+ * #1224), which is why the caller, not this function, resolves presence.
46
+ */
47
+ codexServes?: boolean;
28
48
  expiresIn?: string;
29
49
  refreshFailures?: number;
30
50
  lastRefreshError?: string;
@@ -99,7 +99,10 @@ export function buildHealthResponse(s, requestCount, includeInternal, now = Date
99
99
  // even though the suite's own traffic was being served by the API key.) The
100
100
  // probe below stays authoritative in BOTH modes, so a real failed round-trip
101
101
  // still degrades — this narrows the structural guess, not the measurement.
102
+ // The same holds when a Codex account serves under --no-claude-auth: the
103
+ // pool is empty BY DESIGN, so OAuth 'none' says nothing about liveness.
102
104
  const structurallyDead = s.upstreamApiKeyMode !== true &&
105
+ s.codexServes !== true &&
103
106
  (s.status === 'broken' ||
104
107
  s.status === 'none' ||
105
108
  (s.status === 'expired' && s.canRefresh === false));
@@ -131,6 +134,7 @@ export function buildHealthResponse(s, requestCount, includeInternal, now = Date
131
134
  ...(s.queue ? { queue: withStalledFor(s.queue, now) } : {}),
132
135
  ...(s.probe ? { probe: { ...s.probe, ageMs: Math.max(0, now - s.probe.checkedAt) } } : {}),
133
136
  ...(s.refreshFailures ? { refreshFailures: s.refreshFailures } : {}),
137
+ ...(s.refreshGrant ? { refreshGrant: s.refreshGrant } : {}),
134
138
  }
135
139
  : liveness;
136
140
  return { httpStatus, body };
package/dist/oauth.d.ts CHANGED
@@ -18,6 +18,14 @@ export interface OAuthTokens {
18
18
  refreshToken: string;
19
19
  expiresAt: number;
20
20
  scopes: string[];
21
+ /**
22
+ * Epoch ms of the OAuth grant this refresh-token family descends from.
23
+ * Set by the login flows, preserved across refreshes (a rotation does not
24
+ * extend Anthropic's ~28-day refresh-token lifetime — see refresh-grant.ts).
25
+ * Absent on credentials minted before this field existed or imported from a
26
+ * Claude Code keychain, which never recorded it.
27
+ */
28
+ grantedAt?: number;
21
29
  }
22
30
  export interface CredentialsFile {
23
31
  claudeAiOauth: OAuthTokens;
package/dist/oauth.js CHANGED
@@ -597,6 +597,7 @@ async function exchangeCodeWithRedirect(code, codeVerifier, state, port) {
597
597
  refreshToken: data.refresh_token,
598
598
  expiresAt: Date.now() + data.expires_in * 1000,
599
599
  scopes: data.scope?.split(' ') || ['user:inference'],
600
+ grantedAt: Date.now(),
600
601
  };
601
602
  await saveCredentials({ claudeAiOauth: tokens });
602
603
  return tokens;
@@ -739,6 +740,7 @@ async function exchangeCodeManual(code, codeVerifier, state) {
739
740
  refreshToken: data.refresh_token,
740
741
  expiresAt: Date.now() + data.expires_in * 1000,
741
742
  scopes: data.scope?.split(' ') || ['user:inference'],
743
+ grantedAt: Date.now(),
742
744
  };
743
745
  await saveCredentials({ claudeAiOauth: tokens });
744
746
  return tokens;
@@ -854,6 +856,7 @@ async function doRefreshTokens() {
854
856
  refreshToken: data.refresh_token,
855
857
  expiresAt: Date.now() + data.expires_in * 1000,
856
858
  scopes: oauth.scopes,
859
+ grantedAt: oauth.grantedAt,
857
860
  };
858
861
  await saveCredentials({ claudeAiOauth: tokens });
859
862
  consecutiveRefreshFailures = 0;
package/dist/pool.d.ts CHANGED
@@ -73,6 +73,8 @@ export interface PoolAccount {
73
73
  identity: AccountIdentity;
74
74
  rateLimit: RateLimitSnapshot;
75
75
  requestCount: number;
76
+ /** Epoch ms of the OAuth grant (refresh-grant.ts); undefined when unknown. */
77
+ grantedAt?: number;
76
78
  /**
77
79
  * Auth-failure cool-down (dario#234). Set when an upstream returns
78
80
  * 401/403 or an `authentication_error` / `permission_error` /
@@ -198,6 +200,7 @@ export declare class AccountPool {
198
200
  expiresAt: number;
199
201
  deviceId: string;
200
202
  accountUuid: string;
203
+ grantedAt?: number;
201
204
  }): void;
202
205
  remove(alias: string): boolean;
203
206
  get size(): number;
@@ -288,6 +291,7 @@ export interface ReconcilableAccount {
288
291
  expiresAt: number;
289
292
  deviceId: string;
290
293
  accountUuid: string;
294
+ grantedAt?: number;
291
295
  }
292
296
  /**
293
297
  * Reconcile a live pool against the current on-disk account set: add or refresh
package/dist/pool.js CHANGED
@@ -277,6 +277,7 @@ export class AccountPool {
277
277
  accessToken: opts.accessToken,
278
278
  refreshToken: opts.refreshToken,
279
279
  expiresAt: opts.expiresAt,
280
+ grantedAt: opts.grantedAt ?? existing?.grantedAt,
280
281
  identity: existing?.identity ?? {
281
282
  deviceId: opts.deviceId,
282
283
  accountUuid: opts.accountUuid,
@@ -626,6 +627,7 @@ export function reconcilePoolAccounts(pool, accounts) {
626
627
  expiresAt: a.expiresAt,
627
628
  deviceId: a.deviceId,
628
629
  accountUuid: a.accountUuid,
630
+ grantedAt: a.grantedAt,
629
631
  });
630
632
  }
631
633
  for (const existing of pool.all()) {
package/dist/proxy.js CHANGED
@@ -16,6 +16,7 @@ import { AccountPool, computeStickyKey, parseRateLimits, modelFamily, isInAuthCo
16
16
  import { Analytics, billingBucketFromClaim, formatUsageLogLine, SUBSCRIPTION_CLAIMS, CODEX_CLAIM } from './analytics.js';
17
17
  import { OverageGuard, buildHaltErrorBody } from './overage-guard.js';
18
18
  import { notify as osNotify } from './notify.js';
19
+ import { grantAge, grantThresholds, worstGrantLevel, describeGrantAge } from './refresh-grant.js';
19
20
  import { loadAllAccounts, loadAccount, saveAccount, refreshAccountToken, resyncLoginFromCredentialsIfStale, ensureLoginCredentialsInPool, mirrorLoginToCredentials } from './accounts.js';
20
21
  import { handleAdminRequest } from './admin-api.js';
21
22
  import { createTokenBucket } from './rate-limit.js';
@@ -1393,6 +1394,7 @@ export async function startProxy(opts = {}) {
1393
1394
  expiresAt: acc.expiresAt,
1394
1395
  deviceId: acc.deviceId,
1395
1396
  accountUuid: acc.accountUuid,
1397
+ grantedAt: acc.grantedAt,
1396
1398
  });
1397
1399
  }
1398
1400
  // Startup self-heal (dario#790): eagerly refresh any account whose access
@@ -1428,10 +1430,37 @@ export async function startProxy(opts = {}) {
1428
1430
  }));
1429
1431
  }
1430
1432
  }
1433
+ // Refresh-token grant watch (refresh-grant.ts). The access-token refresh
1434
+ // below cannot save a seat whose GRANT is at the ~28-day wall — the family
1435
+ // dies mid-refresh with invalid_grant and every request on it fails over.
1436
+ // Say so before it happens: once per level per seat, repeated daily while it
1437
+ // stays there, on stderr and as an OS notification.
1438
+ const grantWatch = new Map();
1439
+ const GRANT_NAG_MS = 24 * 60 * 60 * 1000;
1440
+ const watchGrants = () => {
1441
+ const now = Date.now();
1442
+ const t = grantThresholds();
1443
+ for (const acc of pool.all()) {
1444
+ const age = grantAge(acc.grantedAt, now, t);
1445
+ const prev = grantWatch.get(acc.alias);
1446
+ if (age.level !== 'warn' && age.level !== 'urgent') {
1447
+ grantWatch.delete(acc.alias);
1448
+ continue;
1449
+ }
1450
+ if (prev && prev.level === age.level && now - prev.at < GRANT_NAG_MS)
1451
+ continue;
1452
+ grantWatch.set(acc.alias, { level: age.level, at: now });
1453
+ console.warn(`[dario] refresh-token grant ${age.level.toUpperCase()} for account "${acc.alias}": ${describeGrantAge(age, t)}`);
1454
+ osNotify(`dario: seat "${acc.alias}" refresh-token grant ${age.level}`, describeGrantAge(age, t));
1455
+ }
1456
+ };
1457
+ if (!opts.noClaudeAuth)
1458
+ watchGrants();
1431
1459
  // Background refresh — keep every account's token fresh without blocking requests
1432
1460
  const refreshInterval = setInterval(async () => {
1433
1461
  if (opts.noClaudeAuth)
1434
1462
  return; // never touch the Claude token in OpenAI-only mode
1463
+ watchGrants();
1435
1464
  for (const acc of pool.all()) {
1436
1465
  if (acc.expiresAt < Date.now() + 45 * 60 * 1000) {
1437
1466
  try {
@@ -1482,6 +1511,7 @@ export async function startProxy(opts = {}) {
1482
1511
  expiresAt: acc.expiresAt,
1483
1512
  deviceId: acc.deviceId,
1484
1513
  accountUuid: acc.accountUuid,
1514
+ grantedAt: acc.grantedAt,
1485
1515
  });
1486
1516
  }
1487
1517
  }
@@ -1994,10 +2024,25 @@ export async function startProxy(opts = {}) {
1994
2024
  upstreamApiKey: upstreamApiKey || undefined,
1995
2025
  })
1996
2026
  : undefined;
2027
+ const grantNow = Date.now();
2028
+ const grantAges = pool.all().map((a) => ({ alias: a.alias, age: grantAge(a.grantedAt, grantNow) }));
2029
+ const refreshGrant = grantAges.length === 0 ? undefined : {
2030
+ level: worstGrantLevel(grantAges.map((g) => g.age.level)),
2031
+ oldestAgeDays: grantAges.reduce((m, g) => g.age.ageDays === null ? m : Math.max(m ?? -1, g.age.ageDays), null),
2032
+ daysToWall: grantAges.reduce((m, g) => g.age.daysToWall === null ? m : Math.min(m ?? Number.MAX_SAFE_INTEGER, g.age.daysToWall), null),
2033
+ seats: Object.fromEntries(grantAges.map((g) => [g.alias, g.age.level])),
2034
+ };
1997
2035
  const { httpStatus, body } = buildHealthResponse({
1998
2036
  ...s,
2037
+ ...(refreshGrant ? { refreshGrant } : {}),
1999
2038
  version: darioVersion(),
2000
2039
  upstreamApiKeyMode: !!upstreamApiKey,
2040
+ // --no-claude-auth: the empty Claude pool is deliberate — but only a
2041
+ // present Codex account is evidence something serves. Same presence
2042
+ // check the codex router asks per request (#1138), so an account
2043
+ // added or removed mid-run is reflected without a restart. Skipped
2044
+ // entirely outside that mode so the Claude path never stats the dir.
2045
+ codexServes: opts.noClaudeAuth === true && await hasAnyCodexAccount(),
2001
2046
  ...(probe ? { probe } : {}),
2002
2047
  // pool.size === 0 is single-account mode (session-id registry drives
2003
2048
  // the SESSION_ID slot); a loaded pool routes via sticky bindings.
@@ -2147,6 +2192,7 @@ export async function startProxy(opts = {}) {
2147
2192
  // timestamp at all. `updatedAt` was already on the snapshot; it was
2148
2193
  // simply never surfaced. null means "never observed" (no response has
2149
2194
  // been served on this account yet) rather than "observed at epoch 0".
2195
+ const grant = grantAge(a.grantedAt, now);
2150
2196
  return {
2151
2197
  alias: a.alias,
2152
2198
  util5h: a.rateLimit.util5h,
@@ -2156,6 +2202,13 @@ export async function startProxy(opts = {}) {
2156
2202
  status: inCooldown ? 'auth-cooldown' : a.rateLimit.status,
2157
2203
  requestCount: a.requestCount,
2158
2204
  expiresInMs: Math.max(0, a.expiresAt - now),
2205
+ // Refresh-token grant age (refresh-grant.ts): the wall a token
2206
+ // refresh cannot move. null fields = grant date unknown.
2207
+ grantedAt: a.grantedAt ?? null,
2208
+ grantAgeDays: grant.ageDays,
2209
+ grantLevel: grant.level,
2210
+ refreshWallAt: grant.wallAt,
2211
+ daysToWall: grant.daysToWall,
2159
2212
  ...(inCooldown
2160
2213
  ? {
2161
2214
  lastAuthFailureAt: a.lastAuthFailureAt,
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Refresh-token grant age.
3
+ *
4
+ * Anthropic's OAuth refresh token has a hard lifetime measured from the
5
+ * ORIGINAL grant, not from the last rotation. A seat that refreshed every 8h
6
+ * for four weeks still died with `invalid_grant "Refresh token expired"`
7
+ * 28 days 10 hours after its grant (observed 2026-09-05; the only data point,
8
+ * hence the conservative defaults below). The token itself is opaque and the
9
+ * token endpoint reports no refresh expiry, so the calendar is the only
10
+ * signal — and until this module existed dario did not keep the calendar:
11
+ * `grantedAt` is recorded by every code path that performs a grant and
12
+ * preserved across refreshes, and every surface (`/health`, `/accounts`,
13
+ * `dario accounts list`, `dario doctor`, the background refresh loop) reads
14
+ * the age through here.
15
+ *
16
+ * Levels:
17
+ * ok age < warn
18
+ * warn warn ≤ age < urgent — re-grant this week
19
+ * urgent urgent ≤ age — re-grant today; the wall is ~lifetime
20
+ * unknown no grantedAt — seat minted before this field existed
21
+ * (or hand-installed); re-grant to start
22
+ * the clock
23
+ *
24
+ * All three thresholds are env-tunable so an operator who observes a
25
+ * different wall can move them without a release:
26
+ * DARIO_REFRESH_GRANT_LIFETIME_DAYS (28)
27
+ * DARIO_REFRESH_GRANT_WARN_DAYS (21)
28
+ * DARIO_REFRESH_GRANT_URGENT_DAYS (26)
29
+ */
30
+ export type GrantLevel = 'ok' | 'warn' | 'urgent' | 'unknown';
31
+ export interface GrantThresholds {
32
+ lifetimeDays: number;
33
+ warnDays: number;
34
+ urgentDays: number;
35
+ }
36
+ export interface GrantAge {
37
+ level: GrantLevel;
38
+ /** Whole days since the grant; null when unknown. */
39
+ ageDays: number | null;
40
+ /** Epoch ms of the projected wall (grantedAt + lifetime); null when unknown. */
41
+ wallAt: number | null;
42
+ /** Whole days until the wall (negative once past it); null when unknown. */
43
+ daysToWall: number | null;
44
+ }
45
+ /** Thresholds from the environment, with the documented defaults. Ordering is
46
+ * enforced (warn ≤ urgent ≤ lifetime) so a misconfigured pair can never make
47
+ * a seat skip a level or page after the wall. */
48
+ export declare function grantThresholds(env?: NodeJS.ProcessEnv): GrantThresholds;
49
+ /** Age a single grant. `grantedAt` undefined/null/non-finite → unknown. */
50
+ export declare function grantAge(grantedAt: number | null | undefined, now: number, t?: GrantThresholds): GrantAge;
51
+ /** The level a pool reports as a whole: the worst seat wins. `unknown` ranks
52
+ * between ok and warn — a pool whose seats are all unstamped is not healthy
53
+ * knowledge, but it is not a page either. */
54
+ export declare function worstGrantLevel(levels: readonly GrantLevel[]): GrantLevel;
55
+ /** One-line human summary, shared by `accounts list` and doctor. */
56
+ export declare function describeGrantAge(a: GrantAge, t?: GrantThresholds): string;
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Refresh-token grant age.
3
+ *
4
+ * Anthropic's OAuth refresh token has a hard lifetime measured from the
5
+ * ORIGINAL grant, not from the last rotation. A seat that refreshed every 8h
6
+ * for four weeks still died with `invalid_grant "Refresh token expired"`
7
+ * 28 days 10 hours after its grant (observed 2026-09-05; the only data point,
8
+ * hence the conservative defaults below). The token itself is opaque and the
9
+ * token endpoint reports no refresh expiry, so the calendar is the only
10
+ * signal — and until this module existed dario did not keep the calendar:
11
+ * `grantedAt` is recorded by every code path that performs a grant and
12
+ * preserved across refreshes, and every surface (`/health`, `/accounts`,
13
+ * `dario accounts list`, `dario doctor`, the background refresh loop) reads
14
+ * the age through here.
15
+ *
16
+ * Levels:
17
+ * ok age < warn
18
+ * warn warn ≤ age < urgent — re-grant this week
19
+ * urgent urgent ≤ age — re-grant today; the wall is ~lifetime
20
+ * unknown no grantedAt — seat minted before this field existed
21
+ * (or hand-installed); re-grant to start
22
+ * the clock
23
+ *
24
+ * All three thresholds are env-tunable so an operator who observes a
25
+ * different wall can move them without a release:
26
+ * DARIO_REFRESH_GRANT_LIFETIME_DAYS (28)
27
+ * DARIO_REFRESH_GRANT_WARN_DAYS (21)
28
+ * DARIO_REFRESH_GRANT_URGENT_DAYS (26)
29
+ */
30
+ const DAY_MS = 86_400_000;
31
+ function envDays(name, fallback, env) {
32
+ const raw = env[name];
33
+ if (raw === undefined || raw === '')
34
+ return fallback;
35
+ const n = Number(raw);
36
+ return Number.isFinite(n) && n > 0 ? n : fallback;
37
+ }
38
+ /** Thresholds from the environment, with the documented defaults. Ordering is
39
+ * enforced (warn ≤ urgent ≤ lifetime) so a misconfigured pair can never make
40
+ * a seat skip a level or page after the wall. */
41
+ export function grantThresholds(env = process.env) {
42
+ const lifetimeDays = envDays('DARIO_REFRESH_GRANT_LIFETIME_DAYS', 28, env);
43
+ const urgentDays = Math.min(envDays('DARIO_REFRESH_GRANT_URGENT_DAYS', 26, env), lifetimeDays);
44
+ const warnDays = Math.min(envDays('DARIO_REFRESH_GRANT_WARN_DAYS', 21, env), urgentDays);
45
+ return { lifetimeDays, warnDays, urgentDays };
46
+ }
47
+ /** Age a single grant. `grantedAt` undefined/null/non-finite → unknown. */
48
+ export function grantAge(grantedAt, now, t = grantThresholds()) {
49
+ if (grantedAt === undefined || grantedAt === null || !Number.isFinite(grantedAt) || grantedAt <= 0) {
50
+ return { level: 'unknown', ageDays: null, wallAt: null, daysToWall: null };
51
+ }
52
+ const ageDays = Math.floor(Math.max(0, now - grantedAt) / DAY_MS);
53
+ const wallAt = grantedAt + t.lifetimeDays * DAY_MS;
54
+ const daysToWall = Math.floor((wallAt - now) / DAY_MS);
55
+ const level = ageDays >= t.urgentDays ? 'urgent' : ageDays >= t.warnDays ? 'warn' : 'ok';
56
+ return { level, ageDays, wallAt, daysToWall };
57
+ }
58
+ const LEVEL_RANK = { ok: 0, unknown: 1, warn: 2, urgent: 3 };
59
+ /** The level a pool reports as a whole: the worst seat wins. `unknown` ranks
60
+ * between ok and warn — a pool whose seats are all unstamped is not healthy
61
+ * knowledge, but it is not a page either. */
62
+ export function worstGrantLevel(levels) {
63
+ let worst = 'ok';
64
+ for (const l of levels)
65
+ if (LEVEL_RANK[l] > LEVEL_RANK[worst])
66
+ worst = l;
67
+ return worst;
68
+ }
69
+ /** One-line human summary, shared by `accounts list` and doctor. */
70
+ export function describeGrantAge(a, t = grantThresholds()) {
71
+ if (a.level === 'unknown' || a.ageDays === null || a.daysToWall === null) {
72
+ return 'grant date unknown — re-grant to start the ~' + t.lifetimeDays + 'd refresh-token clock';
73
+ }
74
+ const wall = a.daysToWall >= 0 ? `~${a.daysToWall}d to the ~${t.lifetimeDays}d wall` : `${-a.daysToWall}d PAST the ~${t.lifetimeDays}d wall`;
75
+ switch (a.level) {
76
+ case 'ok': return `grant ${a.ageDays}d old, ${wall}`;
77
+ case 'warn': return `grant ${a.ageDays}d old, ${wall} — re-grant this week`;
78
+ case 'urgent': return `grant ${a.ageDays}d old, ${wall} — re-grant TODAY or the seat dies mid-refresh`;
79
+ }
80
+ }
@@ -14,7 +14,7 @@ For a one-page status table of every tool dario supports — working / inferred
14
14
  | GitHub Copilot | `run_in_terminal`, `insert_edit_into_file`, `semantic_search`, `codebase_search`, `list_dir`, `fetch_webpage` |
15
15
  | OpenHands | `execute_bash`, `str_replace_editor` |
16
16
  | OpenClaw | `exec`, `process`, `web_search`, `web_fetch`, `browser`, `message` |
17
- | hands ([askalf/hands](https://github.com/askalf/hands)) | Anthropic beta computer-use tools (`computer`, `bash`, `str_replace_based_edit_tool`) — auto-preserved via system-prompt identity match (v3.33.0) |
17
+ | hands ([askalf/hands](https://github.com/askalf/hands), archived 2026-09-06) | Anthropic beta computer-use tools (`computer`, `bash`, `str_replace_based_edit_tool`) — auto-preserved via system-prompt identity match (v3.33.0) |
18
18
  | Hermes Agent (Nous Research) | `terminal`, `process`, `read_file`, `write_file`, `patch`, `search_files`, `web_search`, `web_extract`, `todo` mapped directly. Hermes-specific tools (`browser_*`, `vision_analyze`, `image_generate`, `skill_*`, `memory`, `session_search`, `cronjob`, `send_message`, `ha_*`, `mixture_of_agents`, `delegate_task`, `execute_code`, `text_to_speech`) have no CC equivalent and auto-preserve through the identity detector. Also consider `--max-tokens=client` so Hermes's 64k/128k per-model caps survive dario's outbound pin. |
19
19
 
20
20
  Text-tool clients (Cline / Kilo Code / Roo Code and forks) are auto-detected via system-prompt identity markers and automatically flipped into preserve-tools mode, because mixing CC's `tools` array with their XML protocol makes the model emit `<function_calls><invoke>` that their parsers can't read. The same identity path also catches `hands` (askalf's computer-use agent) — its tool names overlap with `TOOL_MAP` but its schemas diverge, so identity match → preserve-tools is the only correct routing. If you run dario specifically for wire-level fidelity and would rather pick `--preserve-tools` yourself, `--no-auto-detect` (v3.20.1, aka `--no-auto-preserve`) disables the heuristic — explicit operator choice then wins.
@@ -208,9 +208,9 @@ OpenClaw uses the standard `ANTHROPIC_BASE_URL` and `ANTHROPIC_API_KEY` env vars
208
208
 
209
209
  For a full end-to-end walkthrough — auth-profiles handling, classifier-filter protection, subscription-billing verification, multi-account pool, and the gotchas that bite first-time users — see [`openclaw-walkthrough.md`](./openclaw-walkthrough.md).
210
210
 
211
- ### hands
211
+ ### hands (archived)
212
212
 
213
- [hands](https://github.com/askalf/hands) is a sister project to dario — a local computer-use agent that drives your OS through its native shell instead of a screenshot loop. Two modes: Claude Login (uses the `claude` CLI directly, no dario required) and SDK mode (audit-logged, supports `--dry-run`, routes through dario for $0 per task).
213
+ [hands](https://github.com/askalf/hands) (archived 2026-09-06; the wire-format notes below still apply to any computer-use agent) was a sister project to dario — a local computer-use agent that drives your OS through its native shell instead of a screenshot loop. Two modes: Claude Login (uses the `claude` CLI directly, no dario required) and SDK mode (audit-logged, supports `--dry-run`, routes through dario for $0 per task).
214
214
 
215
215
  ```bash
216
216
  # SDK mode — env vars route the Anthropic SDK through dario
@@ -18,7 +18,7 @@ Status legend:
18
18
  | **Zed** | Anthropic | Claude backend | ✅ Working | [`agent-compat.md#zed`](./agent-compat.md#zed) |
19
19
  | **OpenHands** | Anthropic | Claude backend | ✅ Working | Full walkthrough: [`openhands-walkthrough.md`](./openhands-walkthrough.md) |
20
20
  | **OpenClaw** | Anthropic | Claude backend | ✅ Working | Full walkthrough: [`openclaw-walkthrough.md`](./openclaw-walkthrough.md). Identity-detected for preserve-tools. |
21
- | **hands** | Anthropic | Claude backend | ✅ Working | Full walkthrough: [`hands-walkthrough.md`](./hands-walkthrough.md). Identity-detected. |
21
+ | **hands** (archived) | Anthropic | Claude backend | ✅ Working | Repo archived 2026-09-06. Walkthrough kept for the wire format: [`hands-walkthrough.md`](./hands-walkthrough.md). Identity-detected. |
22
22
  | **CC sub-agents** | Anthropic | Claude backend | ✅ Working | `dario subagent install` registers a CC sub-agent that exposes `dario doctor` and other read-only diagnostics inside any CC session. [`sub-agent.md`](./sub-agent.md) |
23
23
  | **Claude Agent SDK** | Anthropic | Claude backend | ✅ Working | `baseURL: 'http://localhost:3456'` on the `Anthropic` client. SDK examples in [`usage.md`](./usage.md). |
24
24
  | **MCP clients (any)** | MCP / JSON-RPC | dario as MCP server | ✅ Working | `dario mcp` exposes dario as a read-only MCP server. [`mcp-server.md`](./mcp-server.md) |
@@ -0,0 +1,97 @@
1
+ # dario + cordon: share a subscription without leaking PII
2
+
3
+ dario lets a team share one Claude or ChatGPT subscription through a single API endpoint. That endpoint sees every prompt. Put [cordon](https://github.com/askalf/cordon) in front of it and raw email addresses, phone numbers, card numbers, SSNs and API keys are tokenized **before** they reach dario, the provider, or the provider's logs. The client still gets the real values back in the reply.
4
+
5
+ ```
6
+ client ──▶ cordon :8080 ──▶ dario :3456 ──▶ Anthropic / OpenAI
7
+ │ redact / tokenize │ subscription auth, pool, failover
8
+ └ restore in the reply └ nothing changes here
9
+ ```
10
+
11
+ Both speak the Anthropic and OpenAI wire formats, so nothing about your clients changes except the base URL.
12
+
13
+ ## Compose
14
+
15
+ ```yaml
16
+ services:
17
+ dario:
18
+ image: ghcr.io/askalf/dario:latest
19
+ environment:
20
+ DARIO_API_KEY: ${DARIO_API_KEY} # required, see docker.md
21
+ DARIO_HOST: 0.0.0.0
22
+ volumes:
23
+ - dario_data:/home/dario/.dario
24
+ expose: ["3456"]
25
+
26
+ cordon:
27
+ # cordon does not publish an image yet; build it from the repo.
28
+ build: https://github.com/askalf/cordon.git
29
+ environment:
30
+ ANTHROPIC_BASE: http://dario:3456
31
+ OPENAI_BASE: http://dario:3456
32
+ DEFAULT_MODE: reversible # de-identify upstream, restore in the reply
33
+ FAIL_MODE: closed # if cordon cannot redact, it refuses, never forwards raw
34
+ ACTIVE_SETS: pii,pci,secrets # add phi if you need MRN/date detection
35
+ ADMIN_TOKEN: ${CORDON_ADMIN_TOKEN}
36
+ AUDIT_LOG: /app/data/audit.jsonl
37
+ POLICY_STORE: /app/data/policies.json
38
+ volumes:
39
+ - cordon_data:/app/data
40
+ ports: ["8080:8080"] # the only port clients need
41
+
42
+ volumes:
43
+ dario_data:
44
+ cordon_data:
45
+ ```
46
+
47
+ Only cordon is published. dario stays reachable from cordon alone.
48
+
49
+ ## Point clients at cordon
50
+
51
+ Use the same dario key you already hand out. cordon forwards `x-api-key` and `authorization` verbatim and never terminates auth.
52
+
53
+ ```bash
54
+ # Anthropic-compatible clients
55
+ export ANTHROPIC_BASE_URL=http://localhost:8080
56
+ export ANTHROPIC_API_KEY=$DARIO_API_KEY
57
+
58
+ # OpenAI-compatible clients
59
+ export OPENAI_BASE_URL=http://localhost:8080/v1
60
+ export OPENAI_API_KEY=$DARIO_API_KEY
61
+ ```
62
+
63
+ ## What you get
64
+
65
+ ```bash
66
+ curl localhost:8080/v1/messages \
67
+ -H "x-api-key: $DARIO_API_KEY" -H 'anthropic-version: 2023-06-01' \
68
+ -H 'content-type: application/json' \
69
+ -d '{"model":"claude-sonnet-5","max_tokens":64,"messages":[{"role":"user",
70
+ "content":"draft a reply to jane@acme.com about card 4012-8888-8888-1881"}]}'
71
+ ```
72
+
73
+ | | value |
74
+ |---|---|
75
+ | the model sees | `draft a reply to <EMAIL_7F3A2B_1> about card <CREDIT_CARD_7F3A2B_1>` |
76
+ | the client gets | the reply with `jane@acme.com` and the card number restored |
77
+ | response headers | `X-Redacted: 2`, `X-Redacted-Types: EMAIL:1,CREDIT_CARD:1` |
78
+ | audit log | counts and types per request, hash-chained, never values |
79
+
80
+ Streaming works in reversible mode; tokens are restored as they arrive.
81
+
82
+ ## Per-request control
83
+
84
+ | header | effect |
85
+ |---|---|
86
+ | `X-Redact-Mode: strip` | irreversible placeholders, nothing restored |
87
+ | `X-Redact-Mode: off` | passthrough, still logged as a bypass |
88
+ | `X-Redact-Sets: pii,secrets` | narrow the detector set for this call |
89
+ | `X-Tenant: <id>` | pick a tenant policy instead of deriving it from the key |
90
+
91
+ Detection is deterministic (regex plus checksum validators, no ML), so a redaction never silently rewrites prose that isn't PII. Per-tenant policies and the audit chain are documented in the [cordon README](https://github.com/askalf/cordon#per-tenant-policy--admin).
92
+
93
+ ## What this does not do
94
+
95
+ - It does not hide the prompt from dario's own logs before redaction, because there is nothing before redaction: cordon is the first hop.
96
+ - It does not replace provider-side data handling terms. It reduces what those terms apply to.
97
+ - `phi` is off by default. Its date detector redacts framework context (version strings, changelog dates) in coding prompts. Turn it on for clinical workloads only.
@@ -1,5 +1,7 @@
1
1
  # dario + hands — battletested setup
2
2
 
3
+ > **Note:** hands was archived 2026-09-06; this walkthrough is kept for the wire-format details, which still apply to any computer-use agent.
4
+
3
5
  End-to-end walkthrough for running [hands](https://github.com/askalf/hands) — a local computer-use agent that drives your OS through its native shell — through dario so the model spend bills against your Claude Pro / Max subscription instead of per-token overage on the computer-use beta. Covers install → mode selection → first run → verification → the gotchas that bite first-time users.
4
6
 
5
7
  This is the **first-party** walkthrough. hands is one of dario's sister projects under [askalf](https://github.com/askalf), so unlike the OpenHands / OpenClaw guides where dario is *integrating* with someone else's tool, this is the canonical end-to-end stack we run ourselves. Most of the integration work has already been done on both ends: dario v3.33.0 auto-detects hands via system-prompt identity match and preserves the computer-use beta tools (`computer`, `bash`, `str_replace_based_edit_tool`) without you needing any flag.
@@ -92,3 +92,20 @@ curl http://localhost:3456/analytics # per-account / per-model stats, burn ra
92
92
  ```
93
93
 
94
94
  Every request carries a `billingBucket` field (`subscription` / `subscription_fallback` / `extra_usage` / `api` / `unknown`) so you can see which bucket each request billed against and a `subscriptionPercent` headline number tells you at a glance whether dario is actually routing through your subscription or silently falling to API overage.
95
+
96
+ ## Refresh-token grant age
97
+
98
+ A token refresh keeps the access token fresh. It does not move the wall on the refresh token: Anthropic expires the refresh-token family about **28 days after the original OAuth grant**, rotation or not. A seat that refreshed every 8h for four weeks still died with `invalid_grant "Refresh token expired"` 28d 10h after its grant (2026-09-05), and every request on it failed over silently.
99
+
100
+ dario records `grantedAt` on every grant (`dario login`, `dario accounts add`, the admin login flow), preserves it across refreshes, and ages it everywhere the pool is inspected:
101
+
102
+ ```bash
103
+ dario accounts list # "grant 12d old, ~16d to the ~28d wall" under each seat
104
+ dario doctor # "Refresh grant" row: warn at 21d, fail at 26d, info when unknown
105
+ curl http://localhost:3456/accounts # grantedAt, grantAgeDays, grantLevel, refreshWallAt, daysToWall per seat
106
+ curl http://localhost:3456/health # refreshGrant: { level, oldestAgeDays, daysToWall, seats } (trusted callers)
107
+ ```
108
+
109
+ The proxy also warns on stderr and sends an OS notification when a seat crosses `warn` or `urgent` (once per level, repeated daily while it stays there). A seat minted before this field existed, or imported from a Claude Code keychain, reports `unknown` — re-grant it to start the clock.
110
+
111
+ Re-grant a seat before the wall with `dario accounts add <alias>` (remove the old entry first) or `dario login --force-reauth` for the `login` seat. Thresholds: `DARIO_REFRESH_GRANT_LIFETIME_DAYS` (28), `DARIO_REFRESH_GRANT_WARN_DAYS` (21), `DARIO_REFRESH_GRANT_URGENT_DAYS` (26).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askalf/dario",
3
- "version": "6.0.25",
3
+ "version": "6.0.26",
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": {