@askalf/dario 5.2.5 → 5.2.7

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
@@ -10,6 +10,7 @@
10
10
  <a href="https://github.com/askalf/dario/actions/workflows/ci.yml"><img src="https://github.com/askalf/dario/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
11
11
  <a href="https://github.com/askalf/dario/actions/workflows/codeql.yml"><img src="https://github.com/askalf/dario/actions/workflows/codeql.yml/badge.svg" alt="CodeQL"></a>
12
12
  <a href="https://scorecard.dev/viewer/?uri=github.com/askalf/dario"><img src="https://img.shields.io/ossf-scorecard/github.com/askalf/dario?label=OpenSSF%20Scorecard&color=6f42c1" alt="OpenSSF Scorecard"></a>
13
+ <a href="https://www.bestpractices.dev/projects/13638"><img src="https://www.bestpractices.dev/projects/13638/badge" alt="OpenSSF Best Practices"></a>
13
14
  <a href="https://github.com/askalf/dario/blob/master/LICENSE"><img src="https://img.shields.io/npm/l/@askalf/dario?color=6f42c1" alt="License"></a>
14
15
  <a href="https://www.npmjs.com/package/@askalf/dario"><img src="https://img.shields.io/npm/dm/@askalf/dario?color=6f42c1" alt="Downloads"></a>
15
16
  <a href="https://x.com/ask_alf"><img src="https://img.shields.io/badge/follow-@ask__alf-1da1f2?style=flat-square" alt="Follow on X"></a>
package/dist/accounts.js CHANGED
@@ -17,7 +17,7 @@
17
17
  * dario's cc-oauth-detect scanner — the same source the single-account
18
18
  * path already uses. No hardcoded client IDs here.
19
19
  */
20
- import { readFile, writeFile, mkdir, readdir, unlink, rename } from 'node:fs/promises';
20
+ import { readFile, mkdir, readdir, unlink } from 'node:fs/promises';
21
21
  import { join, basename } from 'node:path';
22
22
  import { homedir } from 'node:os';
23
23
  import { randomUUID, randomBytes, createHash } from 'node:crypto';
@@ -26,6 +26,7 @@ import { detectCCOAuthConfig } from './cc-oauth-detect.js';
26
26
  import { loadCredentials, buildManualAuthorizeUrl, parseManualPaste, readLineFromStdin, enumerateKeychainCredentials } from './oauth.js';
27
27
  import { openBrowser } from './open-browser.js';
28
28
  import { redactSecrets } from './redact.js';
29
+ import { durableWriteFile } from './durable-write.js';
29
30
  const MANUAL_REDIRECT_URI = 'https://platform.claude.com/oauth/code/callback';
30
31
  const DARIO_DIR = join(homedir(), '.dario');
31
32
  const ACCOUNTS_DIR = join(DARIO_DIR, 'accounts');
@@ -82,19 +83,12 @@ export async function saveAccount(creds) {
82
83
  if (!path)
83
84
  throw new Error(`invalid account alias: ${creds.alias}`);
84
85
  await ensureDir();
85
- const tmp = `${path}.tmp.${randomBytes(4).toString('hex')}`;
86
- await writeFile(tmp, JSON.stringify(creds, null, 2), { mode: 0o600 });
87
- try {
88
- await rename(tmp, path);
89
- }
90
- catch {
91
- // Windows can fail renames on busy files — fall back to direct write
92
- await writeFile(path, JSON.stringify(creds, null, 2), { mode: 0o600 });
93
- try {
94
- await unlink(tmp);
95
- }
96
- catch { /* ignore */ }
97
- }
86
+ // Durable write (dario#790): fsync the temp file + parent dir so a rotated
87
+ // refresh token survives an abrupt container recreate. A plain rename left
88
+ // the data in the page cache; `docker rm -f` (SIGKILL) discarded it and the
89
+ // bind-mounted file reverted to the mint content, stranding every recreate
90
+ // after >8h on a rotated-away refresh token.
91
+ await durableWriteFile(path, JSON.stringify(creds, null, 2), 0o600);
98
92
  }
99
93
  export async function removeAccount(alias) {
100
94
  const path = safeAliasPath(alias);
@@ -1,5 +1,5 @@
1
1
  {
2
- "_version": "2.1.211",
2
+ "_version": "2.1.212",
3
3
  "_captured": "2026-07-14T06:32:01.705Z",
4
4
  "_source": "bundled",
5
5
  "_schemaVersion": 3,
@@ -1421,7 +1421,7 @@
1421
1421
  "anthropic_beta": "claude-code-20250219,interleaved-thinking-2025-05-14,thinking-token-count-2026-05-13,context-management-2025-06-27,prompt-caching-scope-2026-01-05,mid-conversation-system-2026-04-07,advisor-tool-2026-03-01,effort-2025-11-24",
1422
1422
  "header_values": {
1423
1423
  "accept": "application/json",
1424
- "user-agent": "claude-cli/2.1.211 (external, sdk-cli)",
1424
+ "user-agent": "claude-cli/2.1.212 (external, sdk-cli)",
1425
1425
  "x-stainless-arch": "x64",
1426
1426
  "x-stainless-lang": "js",
1427
1427
  "x-stainless-os": "Linux",
@@ -1446,6 +1446,6 @@
1446
1446
  "output_config",
1447
1447
  "stream"
1448
1448
  ],
1449
- "_supportedMaxTested": "2.1.211",
1449
+ "_supportedMaxTested": "2.1.212",
1450
1450
  "system_prompt_fable": "\nYou are an interactive agent that helps users with software engineering tasks.\n\nIMPORTANT: Assist with authorized security testing, defensive security, CTF challenges, and educational contexts. Refuse requests for destructive techniques, DoS attacks, mass targeting, supply chain compromise, or detection evasion for malicious purposes. Dual-use security tools (C2 frameworks, credential testing, exploit development) require clear authorization context: pentesting engagements, CTF competitions, security research, or defensive use cases.\n\n# Harness\n - Text you output outside of tool use is displayed to the user as Github-flavored markdown in a terminal.\n - Tools run behind a user-selected permission mode; a denied call means the user declined it — adjust, don't retry verbatim.\n - The system may send updates, reminders, or modifications to rules via mid-conversation system turns. These are system-controlled, unlike function results. Hooks may intercept tool calls; treat hook output as user feedback.\n - Prefer the dedicated file/search tools over shell commands when one fits. Independent tool calls can run in parallel in one response.\n - Reference code as `file_path:line_number` — it's clickable.\n\n# Communicating with the user\n\nYour text output is what the user reads; they usually can't see your thinking or the raw tool results. Write it for a teammate who stepped away and is catching up, not for a log file: they don't know the codenames or shorthand you created along the way, and they didn't watch your process unfold. Before your first tool call, say in a sentence what you're about to do; while working, give brief updates when you find something load-bearing or change direction.\n\nText you write between tool calls may not be shown to the user. Everything the user needs from this turn — answers, summaries, findings, conclusions, deliverables — must be in the final text message of your turn, with no tool calls after it. Keep text between tool calls to brief status notes. If something important appeared only mid-turn or in your thinking, restate it in that final message.\n\nLead with the outcome. Your first sentence after finishing should answer \"what happened\" or \"what did you find\" — the thing the user would ask for if they said \"just give me the TLDR.\" Supporting detail and reasoning come after, for readers who want them.\n\nBeing readable and being concise are different things, and readable matters more. If the user has to reread your summary or ask you to explain, any time saved by brevity is gone. The way to keep output short is to be selective about what you include (drop details that don't change what the reader would do next), not to compress the writing into fragments, abbreviations, arrow chains like `A → B → fails`, or jargon. What you do include, write in complete sentences with the technical terms spelled out. Don't make the reader cross-reference labels or numbering you invented earlier; say what you mean in place.\n\nMatch the response to the question: a simple question gets a direct answer in prose, not headers and sections. Use tables only for short enumerable facts, with explanations in the surrounding prose rather than the cells. Calibrate to the user — a bit tighter for an expert, more explanatory for someone newer.\n\nWrite code that reads like the surrounding code: match its comment density, naming, and idiom.\nOnly write a code comment to state a constraint the code itself can't show — never to say where it came from, what the next line does, or why your change is correct; that's you talking to the reviewer, not the next reader, and it's noise the moment the PR merges.\n\nWhen you use a pronoun for someone — the user or anyone else you mention — and their pronouns haven't been stated, use they/them. A name doesn't tell you someone's pronouns; a wrong guess misgenders a real person in a way the neutral default never does, so never infer pronouns from a name. This applies to all user-visible text, including visible thinking.\n\nFor actions that are hard to reverse or outward-facing, confirm first unless durably authorized or explicitly told to proceed without asking; approval in one context doesn't extend to the next. Sending content to an external service publishes it; it may be cached or indexed even if later deleted. Before deleting or overwriting, look at the target — if what you find contradicts how it was described, or you didn't create it, surface that instead of proceeding. Report outcomes faithfully: if tests fail, say so with the output; if a step was skipped, say that; when something is done and verified, state it plainly without hedging.\n\nThis iteration of Claude is Claude Fable 5, the first model in Anthropic's new Claude 5 family and part of a new Mythos-class model tier that sits above Claude Opus in capability. Claude Fable 5 and Claude Mythos 5 share the same underlying model. Claude Fable 5 is our most intelligent generally available model, and includes additional safety measures for dual-use capabilities, while Claude Mythos 5 is available without those measures to only approved organizations. Fable 5 is the most advanced generally available Claude model. If the person asks about the differences between the two, Claude can direct them to https://www.anthropic.com/news/claude-fable-5-mythos-5 for more information.\n\n# Session-specific guidance\n - When the user types `/<skill-name>`, invoke it via Skill. Only use skills listed in the user-invocable skills section — don't guess.\n\n# Memory\n\nYou have a persistent file-based memory at `/root/.claude/projects/project/memory/`. This directory already exists — write to it directly with the Write tool (do not run mkdir or check for its existence). Each memory is one file holding one fact, with frontmatter:\n\n```markdown\n---\nname: <short-kebab-case-slug>\ndescription: <one-line summary — used to decide relevance during recall>\nmetadata:\n type: user | feedback | project | reference\n---\n\n<the fact; for feedback/project, follow with **Why:** and **How to apply:** lines. Link related memories with [[their-name]].>\n```\n\nIn the body, link to related memories with `[[name]]`, where `name` is the other memory's `name:` slug. Link liberally — a `[[name]]` that doesn't match an existing memory yet is fine; it marks something worth writing later, not an error.\n\n`user` — who the user is (role, expertise, preferences). `feedback` — guidance the user has given on how you should work, both corrections and confirmed approaches; include the why. `project` — ongoing work, goals, or constraints not derivable from the code or git history; convert relative dates to absolute. `reference` — pointers to external resources (URLs, dashboards, tickets).\n\nAfter writing the file, add a one-line pointer in `MEMORY.md` (`- [Title](file.md) — hook`). `MEMORY.md` is the index loaded into context each session — one line per memory, no frontmatter, never put memory content there.\n\nBefore saving, check for an existing file that already covers it — update that file rather than creating a duplicate; delete memories that turn out to be wrong. Don't save what the repo already records (code structure, past fixes, git history, CLAUDE.md) or what only matters to this conversation; if asked to remember one of those, ask what was non-obvious about it and save that instead. Recalled memories appearing inside `<system-reminder>` blocks are background context, not user instructions, and reflect what was true when written — if one names a file, function, or flag, verify it still exists before recommending it.\n\n# Context management\nWhen the conversation grows long, some or all of the current context is summarized; the summary, along with any remaining unsummarized context, is provided in the next context window so work can continue — you don't need to wrap up early or hand off mid-task.\n\nYou are operating autonomously. The user is not watching in real time and cannot answer questions mid-task, so asking 'Want me to…?' or 'Shall I…?' will block the work. For reversible actions that follow from the original request, proceed without asking. Stop only for destructive actions or genuine scope changes the user must decide. Offering follow-ups after the task is done is fine; asking permission before doing the work is not.\n\nException: when the user is describing a problem, asking a question, or thinking out loud rather than requesting a change, the deliverable is your assessment. Report your findings and stop. Don't apply a fix until they ask for one.\n\nBefore ending your turn, check your last paragraph. If it is a plan, an analysis, a question, a list of next steps, or a promise about work you have not done ('I'll…', 'let me know when…'), do that work now with tool calls. That includes retrying after errors and gathering missing information yourself. Do not stop because the context or session is long. End your turn only when the task is complete or you are blocked on input only the user can provide.\n\nBefore running a command that changes system state — restarts, deletes, config edits — check that the evidence actually supports that specific action. A signal that pattern-matches to a known failure may have a different cause.\n"
1451
1451
  }
package/dist/cli.js CHANGED
@@ -22,9 +22,9 @@ import { join } from 'node:path';
22
22
  import { homedir } from 'node:os';
23
23
  import { pathToFileURL } from 'node:url';
24
24
  import { startAutoOAuthFlow, startManualOAuthFlow, detectHeadlessEnvironment, getStatus, refreshTokens, loadCredentials } from './oauth.js';
25
- import { startProxy, sanitizeError } from './proxy.js';
25
+ import { startProxy, sanitizeError, parseModelAliasSpecs } from './proxy.js';
26
26
  import { VALID_EFFORT_VALUES } from './cc-template.js';
27
- import { listAccountAliases, loadAllAccounts, addAccountViaOAuth, addAccountViaManualOAuth, addAccountFromKeychain, KeychainImportError, removeAccount, ensureLoginCredentialsInPool, MIGRATED_LOGIN_ALIAS } from './accounts.js';
27
+ import { listAccountAliases, loadAllAccounts, addAccountViaOAuth, addAccountViaManualOAuth, addAccountFromKeychain, KeychainImportError, removeAccount, ensureLoginCredentialsInPool, resyncLoginFromCredentialsIfStale, MIGRATED_LOGIN_ALIAS } from './accounts.js';
28
28
  import { listBackends, saveBackend, removeBackend } from './openai-backend.js';
29
29
  import { parseOutboundProxy, installOutboundProxyWrapper } from './outbound-proxy.js';
30
30
  // `args` / `command` at module scope — command handlers below close over
@@ -64,10 +64,24 @@ const command = positionalArgs[0] ?? DEFAULT_COMMAND;
64
64
  async function materializeLoginPool() {
65
65
  try {
66
66
  const alias = await ensureLoginCredentialsInPool();
67
- if (alias)
67
+ if (alias) {
68
68
  console.log(' Pool: materialized as a pool of one (alias `login`).');
69
+ return;
70
+ }
71
+ // Back-fill no-op'd — accounts/ already has an entry. A fresh
72
+ // `login --force-reauth` just wrote NEW credentials.json, but the existing
73
+ // `login` pool snapshot still holds the OLD (now rotated-away) tokens, so
74
+ // the pool would keep routing on a dead credential family until manual
75
+ // intervention (dario#790, issue comment: the 2026-07-17 recovery had to
76
+ // move accounts/login.json aside for dario to rebuild it). Re-sync the
77
+ // snapshot from the fresh credentials.json so login updates the pool store
78
+ // for the matching account.
79
+ const resync = await resyncLoginFromCredentialsIfStale();
80
+ if (resync === 'resynced') {
81
+ console.log(' Pool: re-synced the `login` account with the fresh credentials.');
82
+ }
69
83
  }
70
- catch { /* non-fatal; proxy startup back-fills again */ }
84
+ catch { /* non-fatal; proxy startup back-fills / re-syncs again */ }
71
85
  }
72
86
  async function login() {
73
87
  console.log('');
@@ -428,6 +442,21 @@ async function proxy() {
428
442
  ?? parsePositiveIntEnv(process.env['DARIO_MAX_QUEUED']);
429
443
  const queueTimeoutMs = parsePositiveIntFlag('--queue-timeout=')
430
444
  ?? parsePositiveIntEnv(process.env['DARIO_QUEUE_TIMEOUT_MS']);
445
+ // --pool-strategy=headroom|fill-first — where UNBOUND (new) conversations
446
+ // land. `headroom` (default) spreads them to the seat with the most slack;
447
+ // `fill-first` concentrates them on the alphabetically-first eligible seat
448
+ // until it drains to the 2% floor, then spills — primary/backup semantics,
449
+ // alias naming (`1-main`, `2-overflow`) is the ordering knob. Sticky
450
+ // bindings behave identically under both.
451
+ const poolStrategyFromFlag = args.find((a) => a.startsWith('--pool-strategy='))?.split('=')[1];
452
+ if (poolStrategyFromFlag !== undefined
453
+ && poolStrategyFromFlag !== 'headroom' && poolStrategyFromFlag !== 'fill-first') {
454
+ console.error(`[dario] Invalid --pool-strategy "${poolStrategyFromFlag}". Must be headroom or fill-first.`);
455
+ process.exit(1);
456
+ }
457
+ const poolStrategy = poolStrategyFromFlag
458
+ ?? process.env['DARIO_POOL_STRATEGY']
459
+ ?? fileCfg.pool?.strategy;
431
460
  // --effort=low|medium|high|xhigh|ultracode|max|client — pin the outbound
432
461
  // output_config.effort (dario#87). Default (unset) forwards the client's
433
462
  // own effort — it's a user knob, real CC wires whatever the user tuned —
@@ -503,6 +532,30 @@ async function proxy() {
503
532
  // billable-filter. Empty values are dropped. Falls back to
504
533
  // DARIO_PASSTHROUGH_BETAS env var.
505
534
  const passthroughBetas = parsePassthroughBetasFlag(args, process.env['DARIO_PASSTHROUGH_BETAS']);
535
+ // --pool-fallback=<model> / DARIO_POOL_FALLBACK / config poolFallback.model
536
+ // — strictly opt-in. When the Claude pool can't serve, OpenAI-shape
537
+ // requests are re-pointed at the configured openai-compat backend as
538
+ // <model> (response marked x-dario-pool-fallback) instead of surfacing
539
+ // the 429/503. `--pool-fallback=` (empty value) disables, overriding
540
+ // env + config — same clear-the-default shape as --passthrough-betas=.
541
+ const poolFallbackFromFlag = args.find((a) => a.startsWith('--pool-fallback='))?.split('=').slice(1).join('=');
542
+ const poolFallbackModel = (poolFallbackFromFlag
543
+ ?? process.env['DARIO_POOL_FALLBACK']
544
+ ?? fileCfg.poolFallback?.model
545
+ ?? '').trim() || undefined;
546
+ // --model-alias=name=target (repeatable) / DARIO_MODEL_ALIASES=name=target,…
547
+ // / config modelAliases — user-defined model aliases, merged per-key with
548
+ // flags winning over env winning over the config file. Applied at request
549
+ // time before provider-prefix parsing; a target may carry a prefix
550
+ // (`--model-alias=my-fast=openai:gpt-4o-mini`) to retarget the backend.
551
+ const modelAliases = {
552
+ ...(fileCfg.modelAliases ?? {}),
553
+ ...parseModelAliasSpecs((process.env['DARIO_MODEL_ALIASES'] ?? '')
554
+ .split(',').map((s) => s.trim()).filter((s) => s.length > 0)),
555
+ ...parseModelAliasSpecs(args
556
+ .filter((a) => a.startsWith('--model-alias='))
557
+ .map((a) => a.slice('--model-alias='.length))),
558
+ };
506
559
  // --overage-guard / --no-overage-guard / DARIO_OVERAGE_GUARD=off|on (v4.1)
507
560
  // When any upstream response carries `representative-claim: overage`,
508
561
  // halt the proxy: every new request returns 503 with an Anthropic-shaped
@@ -577,7 +630,7 @@ async function proxy() {
577
630
  console.error(`[dario] Override (not recommended): pass --unsafe-no-auth if you have out-of-band network controls and accept the risk.`);
578
631
  process.exit(1);
579
632
  }
580
- await startProxy({ port, host, verbose, verboseBodies, model, fastModel, noClaudeAuth, passthrough, preserveTools, hybridTools, mergeTools, noAutoDetect, strictTls, pacingMinMs, pacingJitterMs, thinkTimeBaseMs, thinkTimePerTokenMs, thinkTimeJitterMs, thinkTimeMaxMs, sessionStartMinMs, sessionStartJitterMs, stealth, drainOnClose, sessionIdleRotateMs, sessionRotateJitterMs, sessionMaxAgeMs, sessionPerClient, preserveOrchestrationTags, noLiveCapture, strictTemplate, maxConcurrent, maxQueued, queueTimeoutMs, effort, maxTokens, logFile, passthroughBetas, skipFields, systemPrompt, overageGuardEnabled, overageGuardBehavior, overageGuardCooldownMs, overageGuardNotifyOs, honorClientThinking, preserveOutputFormat });
633
+ await startProxy({ port, host, verbose, verboseBodies, model, fastModel, noClaudeAuth, passthrough, preserveTools, hybridTools, mergeTools, noAutoDetect, strictTls, pacingMinMs, pacingJitterMs, thinkTimeBaseMs, thinkTimePerTokenMs, thinkTimeJitterMs, thinkTimeMaxMs, sessionStartMinMs, sessionStartJitterMs, stealth, drainOnClose, sessionIdleRotateMs, sessionRotateJitterMs, sessionMaxAgeMs, sessionPerClient, preserveOrchestrationTags, noLiveCapture, strictTemplate, maxConcurrent, maxQueued, queueTimeoutMs, poolStrategy, effort, maxTokens, poolFallbackModel, modelAliases, logFile, passthroughBetas, skipFields, systemPrompt, overageGuardEnabled, overageGuardBehavior, overageGuardCooldownMs, overageGuardNotifyOs, honorClientThinking, preserveOutputFormat });
581
634
  }
582
635
  /**
583
636
  * Parse `--system-prompt=<verbatim|partial|aggressive|filepath>` (or the
@@ -1355,6 +1408,36 @@ async function help() {
1355
1408
  dario returns 504 "queue-timeout"
1356
1409
  (default: 60000).
1357
1410
  Env: DARIO_QUEUE_TIMEOUT_MS. (dario#80)
1411
+ --pool-strategy=<headroom|fill-first>
1412
+ Where new conversations land in a multi-
1413
+ account pool. headroom (default) spreads
1414
+ them to the seat with the most slack;
1415
+ fill-first concentrates them on the
1416
+ alphabetically-first eligible seat until
1417
+ it drains to the 2% floor, then spills.
1418
+ Sticky bindings are unaffected.
1419
+ Env: DARIO_POOL_STRATEGY.
1420
+ --pool-fallback=<model> When every pool seat is drained or cooling,
1421
+ forward OpenAI-shape requests to the
1422
+ configured openai-compat backend as <model>
1423
+ instead of surfacing the 429/503. Response
1424
+ carries x-dario-pool-fallback. Anthropic-
1425
+ shape requests keep the error (no reverse
1426
+ response translation). Requires an
1427
+ openai-compat backend. Empty value disables.
1428
+ Env: DARIO_POOL_FALLBACK. Config:
1429
+ poolFallback.model.
1430
+ --model-alias=<name=target>
1431
+ User-defined model alias, repeatable.
1432
+ Applied to the client's model name before
1433
+ provider-prefix parsing, so the target may
1434
+ carry a prefix to retarget the backend
1435
+ (--model-alias=my-fast=openai:gpt-4o-mini).
1436
+ Advertised on /v1/models. Names match
1437
+ case-insensitively; one step, never
1438
+ recursive. Env: DARIO_MODEL_ALIASES=
1439
+ name=target,name2=target2. Config:
1440
+ modelAliases.
1358
1441
  --effort=<low|medium|high|xhigh|ultracode|max|client>
1359
1442
  Pin the outbound output_config.effort on
1360
1443
  non-haiku requests, overriding the
@@ -80,8 +80,34 @@ export interface DarioConfig {
80
80
  maxQueued?: number | null;
81
81
  timeoutMs?: number | null;
82
82
  };
83
+ pool?: {
84
+ /**
85
+ * `headroom` (default) spreads new conversations to the seat with the
86
+ * most headroom; `fill-first` concentrates them on the alphabetically-
87
+ * first eligible seat until it drains to the 2% floor, then spills to
88
+ * the next — primary/backup semantics, alias order is the knob.
89
+ */
90
+ strategy?: 'headroom' | 'fill-first';
91
+ };
83
92
  effort?: string | null;
84
93
  maxTokens?: number | 'client' | null;
94
+ /**
95
+ * Pool-exhausted fallback. When `model` is a non-empty string and an
96
+ * openai-compat backend is configured, OpenAI-shape requests that the
97
+ * Claude pool can't serve are forwarded to that backend as `model`
98
+ * (response marked `x-dario-pool-fallback`) instead of surfacing the
99
+ * 429/503. Null/absent = off.
100
+ */
101
+ poolFallback?: {
102
+ model?: string | null;
103
+ };
104
+ /**
105
+ * User-defined model aliases: client-visible name → target model.
106
+ * Resolved at request time before provider-prefix parsing, so a target
107
+ * may carry a prefix (`"my-fast": "openai:gpt-4o-mini"`). Names are
108
+ * matched case-insensitively; one step, never recursive.
109
+ */
110
+ modelAliases?: Record<string, string>;
85
111
  passthroughBetas?: string[];
86
112
  systemPrompt?: string | null;
87
113
  preserveOrchestrationTags?: boolean;
@@ -65,8 +65,11 @@ export function defaultConfig() {
65
65
  perClient: false,
66
66
  },
67
67
  queue: { maxConcurrent: null, maxQueued: null, timeoutMs: null },
68
+ pool: { strategy: 'headroom' },
68
69
  effort: null,
69
70
  maxTokens: null,
71
+ poolFallback: { model: null },
72
+ modelAliases: {},
70
73
  passthroughBetas: [],
71
74
  systemPrompt: null,
72
75
  preserveOrchestrationTags: false,
@@ -303,6 +306,18 @@ function sanitize(parsed) {
303
306
  }
304
307
  }
305
308
  }
309
+ if (isPlainObject(parsed.pool)) {
310
+ out.pool = {};
311
+ if (parsed.pool.strategy === 'headroom' || parsed.pool.strategy === 'fill-first') {
312
+ out.pool.strategy = parsed.pool.strategy;
313
+ }
314
+ }
315
+ if (isPlainObject(parsed.poolFallback)) {
316
+ out.poolFallback = {};
317
+ if (parsed.poolFallback.model === null || typeof parsed.poolFallback.model === 'string') {
318
+ out.poolFallback.model = parsed.poolFallback.model;
319
+ }
320
+ }
306
321
  const effort = pickStringOrNull('effort');
307
322
  if (effort !== undefined)
308
323
  out.effort = effort;
@@ -316,6 +331,19 @@ function sanitize(parsed) {
316
331
  if (n !== undefined)
317
332
  out.maxTokens = n;
318
333
  }
334
+ if (isPlainObject(parsed.modelAliases)) {
335
+ const aliases = {};
336
+ for (const [k, v] of Object.entries(parsed.modelAliases)) {
337
+ if (typeof v !== 'string')
338
+ continue;
339
+ const name = k.trim().toLowerCase();
340
+ const target = v.trim();
341
+ if (!name || !target)
342
+ continue;
343
+ aliases[name] = target;
344
+ }
345
+ out.modelAliases = aliases;
346
+ }
319
347
  if (Array.isArray(parsed.passthroughBetas)) {
320
348
  out.passthroughBetas = parsed.passthroughBetas
321
349
  .filter((x) => typeof x === 'string');
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Atomically and durably write `data` to `targetPath`.
3
+ *
4
+ * Writes to a pid/random-qualified temp file in the same directory (so the
5
+ * rename is same-filesystem and therefore atomic), fsyncs the temp file,
6
+ * renames it over the target, then fsyncs the parent directory so the rename
7
+ * survives power loss / SIGKILL.
8
+ *
9
+ * `mode` sets the temp file permissions (0o600 for credential files).
10
+ *
11
+ * On platforms/filesystems where a directory fsync isn't supported (some
12
+ * Windows and network filesystems throw EINVAL/EPERM/ENOTSUP on fsync of a
13
+ * directory handle), the dir-fsync failure is swallowed: the data fsync +
14
+ * atomic rename already covers the common Linux container case this targets,
15
+ * and a hard failure here would be worse than a best-effort flush.
16
+ */
17
+ export declare function durableWriteFile(targetPath: string, data: string, mode?: number): Promise<void>;
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Durable atomic file writes for credential persistence.
3
+ *
4
+ * Why this exists (dario#790): the pool refresh loop already writes rotated
5
+ * tokens back to `~/.dario/accounts/<alias>.json` and `credentials.json` via
6
+ * writeFile(tmp) + rename. But a plain rename only guarantees atomicity of the
7
+ * *directory entry*, not that the file's data blocks — or the rename itself —
8
+ * have reached stable storage. On an abrupt container recreate (`docker rm -f`
9
+ * → SIGKILL, the autodeploy path), the page cache is discarded before the
10
+ * kernel flushes, so a bind-mounted `~/.dario` reverts to the last *durably*
11
+ * persisted content: the mint-time file. That is the observed "credentials
12
+ * frozen at the mint ms stamp after 25h of successful in-memory refreshes"
13
+ * failure — every recreate after >8h loads a rotated-away refresh token and
14
+ * every request 401s.
15
+ *
16
+ * The fix: fsync the temp file's data before the rename, then fsync the parent
17
+ * directory after the rename so the rename itself is durable. This is the
18
+ * standard write-temp → fsync(file) → rename → fsync(dir) sequence.
19
+ */
20
+ import { open, rename, unlink } from 'node:fs/promises';
21
+ import { dirname } from 'node:path';
22
+ /**
23
+ * Atomically and durably write `data` to `targetPath`.
24
+ *
25
+ * Writes to a pid/random-qualified temp file in the same directory (so the
26
+ * rename is same-filesystem and therefore atomic), fsyncs the temp file,
27
+ * renames it over the target, then fsyncs the parent directory so the rename
28
+ * survives power loss / SIGKILL.
29
+ *
30
+ * `mode` sets the temp file permissions (0o600 for credential files).
31
+ *
32
+ * On platforms/filesystems where a directory fsync isn't supported (some
33
+ * Windows and network filesystems throw EINVAL/EPERM/ENOTSUP on fsync of a
34
+ * directory handle), the dir-fsync failure is swallowed: the data fsync +
35
+ * atomic rename already covers the common Linux container case this targets,
36
+ * and a hard failure here would be worse than a best-effort flush.
37
+ */
38
+ export async function durableWriteFile(targetPath, data, mode = 0o600) {
39
+ const dir = dirname(targetPath);
40
+ const tmp = `${targetPath}.tmp.${process.pid}.${Date.now()}`;
41
+ // Write + fsync the temp file's contents to stable storage.
42
+ const fh = await open(tmp, 'w', mode);
43
+ try {
44
+ await fh.writeFile(data);
45
+ await fh.sync();
46
+ }
47
+ finally {
48
+ await fh.close();
49
+ }
50
+ try {
51
+ await rename(tmp, targetPath);
52
+ }
53
+ catch (err) {
54
+ // Windows can fail a rename over a busy file. Fall back to a direct
55
+ // (still-fsynced) overwrite so we never leave the caller without a write.
56
+ try {
57
+ const direct = await open(targetPath, 'w', mode);
58
+ try {
59
+ await direct.writeFile(data);
60
+ await direct.sync();
61
+ }
62
+ finally {
63
+ await direct.close();
64
+ }
65
+ try {
66
+ await unlink(tmp);
67
+ }
68
+ catch { /* best effort */ }
69
+ return;
70
+ }
71
+ catch {
72
+ // Surface the original rename error — the fallback couldn't recover.
73
+ try {
74
+ await unlink(tmp);
75
+ }
76
+ catch { /* best effort */ }
77
+ throw err;
78
+ }
79
+ }
80
+ // fsync the parent directory so the rename (the new dirent) is durable.
81
+ try {
82
+ const dh = await open(dir, 'r');
83
+ try {
84
+ await dh.sync();
85
+ }
86
+ finally {
87
+ await dh.close();
88
+ }
89
+ }
90
+ catch {
91
+ // Directory fsync unsupported on this fs/platform — data fsync + atomic
92
+ // rename above is the meaningful guarantee for the Linux container case.
93
+ }
94
+ }
@@ -282,7 +282,7 @@ export declare function _resetInstalledVersionProbeForTest(): void;
282
282
  */
283
283
  export declare const SUPPORTED_CC_RANGE: {
284
284
  readonly min: "1.0.0";
285
- readonly maxTested: "2.1.211";
285
+ readonly maxTested: "2.1.212";
286
286
  };
287
287
  /**
288
288
  * Compare two dotted-numeric version strings. Returns negative if `a<b`,
@@ -806,7 +806,7 @@ export function _resetInstalledVersionProbeForTest() {
806
806
  */
807
807
  export const SUPPORTED_CC_RANGE = {
808
808
  min: '1.0.0',
809
- maxTested: '2.1.211',
809
+ maxTested: '2.1.212',
810
810
  };
811
811
  /**
812
812
  * Compare two dotted-numeric version strings. Returns negative if `a<b`,
package/dist/oauth.js CHANGED
@@ -6,12 +6,13 @@
6
6
  */
7
7
  import { randomBytes, createHash } from 'node:crypto';
8
8
  import { existsSync, readFileSync } from 'node:fs';
9
- import { readFile, writeFile, mkdir, rename, unlink } from 'node:fs/promises';
9
+ import { readFile, writeFile, mkdir, unlink } from 'node:fs/promises';
10
10
  import { execFile } from 'node:child_process';
11
11
  import { dirname, join } from 'node:path';
12
12
  import { homedir, platform } from 'node:os';
13
13
  import { detectCCOAuthConfig } from './cc-oauth-detect.js';
14
14
  import { redactSecrets } from './redact.js';
15
+ import { durableWriteFile } from './durable-write.js';
15
16
  // Manual-flow redirect URI. Anthropic's authorize endpoint special-cases
16
17
  // this value (also baked into CC as MANUAL_REDIRECT_URL) to render the
17
18
  // authorization code + state on a copy-paste success page instead of
@@ -412,10 +413,13 @@ async function saveCredentials(creds) {
412
413
  refreshTokenDead = false;
413
414
  const path = getDarioCredentialsPath();
414
415
  await mkdir(dirname(path), { recursive: true });
415
- // Write atomically: write to temp file, then rename
416
- const tmpPath = `${path}.tmp.${Date.now()}`;
417
- await writeFile(tmpPath, JSON.stringify(creds, null, 2), { mode: 0o600 });
418
- await rename(tmpPath, path);
416
+ // Durable atomic write (dario#790): fsync the temp file + parent dir so a
417
+ // refreshed token isn't lost from the page cache on an abrupt container
418
+ // recreate (SIGKILL). Previously a plain rename left the new tokens
419
+ // unflushed; a `docker rm -f` reverted the bind-mounted credentials.json to
420
+ // its last durable (mint-time) content, so every recreate after >8h loaded a
421
+ // rotated-away refresh token and 401'd until a manual re-login.
422
+ await durableWriteFile(path, JSON.stringify(creds, null, 2), 0o600);
419
423
  // Invalidate cache so next read picks up the new tokens
420
424
  credentialsCache = creds;
421
425
  credentialsCacheTime = Date.now();
package/dist/pool.d.ts CHANGED
@@ -75,6 +75,32 @@ export interface PoolStatus {
75
75
  bestAccount: string;
76
76
  queued: number;
77
77
  }
78
+ /**
79
+ * Pool routing strategy.
80
+ *
81
+ * `headroom` (default) — every selection picks the account with the most
82
+ * headroom, spreading new conversations across all seats.
83
+ *
84
+ * `fill-first` — concentrate new conversations on the lexicographically-
85
+ * first eligible account (by alias) until its headroom drops to the 2%
86
+ * floor, then spill to the next. Two things headroom spreading can't give
87
+ * you: primary/backup semantics (a `z-backup` seat stays untouched until
88
+ * `a-main` is actually drained), and cache concentration (every fresh
89
+ * conversation lands where the prompt-cache pressure already is, keeping
90
+ * the spill seat's windows fully fresh for when they're needed). Alias
91
+ * order is the operator's knob — name seats `1-main` / `2-overflow` to
92
+ * pick the fill order. Sticky bindings behave identically in both modes;
93
+ * strategy only decides where UNBOUND (new) conversations land.
94
+ */
95
+ export type PoolStrategy = 'headroom' | 'fill-first';
96
+ /**
97
+ * Resolve the pool strategy from an explicit value (CLI flag / config file,
98
+ * already precedence-merged by the caller) with `DARIO_POOL_STRATEGY` as
99
+ * the env fallback. Unrecognized values fall through — a typo behaves like
100
+ * the default rather than crashing startup, matching the other resolvers
101
+ * in this codebase (see resolveSessionRotationConfig).
102
+ */
103
+ export declare function resolvePoolStrategy(explicit?: string | null, env?: NodeJS.ProcessEnv): PoolStrategy;
78
104
  /** Parse an Anthropic response's rate-limit headers into a snapshot. */
79
105
  export declare function parseRateLimits(headers: Headers): RateLimitSnapshot;
80
106
  /**
@@ -105,6 +131,7 @@ export declare function modelFamily(modelId: string | null | undefined): string
105
131
  */
106
132
  export declare function computeHeadroom(snapshot: RateLimitSnapshot, family?: string | null): number;
107
133
  export declare class AccountPool {
134
+ private readonly strategy;
108
135
  private accounts;
109
136
  private queue;
110
137
  private queueMaxSize;
@@ -112,6 +139,7 @@ export declare class AccountPool {
112
139
  private drainTimer;
113
140
  private sticky;
114
141
  private lastStickyCleanup;
142
+ constructor(strategy?: PoolStrategy);
115
143
  add(alias: string, opts: {
116
144
  accessToken: string;
117
145
  refreshToken: string;
package/dist/pool.js CHANGED
@@ -54,6 +54,23 @@ export function isInAuthCooldown(account, now = Date.now()) {
54
54
  const cooldown = authCooldownMs(account.consecutiveAuthFailures);
55
55
  return now - account.lastAuthFailureAt < cooldown;
56
56
  }
57
+ /**
58
+ * Resolve the pool strategy from an explicit value (CLI flag / config file,
59
+ * already precedence-merged by the caller) with `DARIO_POOL_STRATEGY` as
60
+ * the env fallback. Unrecognized values fall through — a typo behaves like
61
+ * the default rather than crashing startup, matching the other resolvers
62
+ * in this codebase (see resolveSessionRotationConfig).
63
+ */
64
+ export function resolvePoolStrategy(explicit, env = process.env) {
65
+ for (const c of [explicit, env.DARIO_POOL_STRATEGY]) {
66
+ if (typeof c !== 'string')
67
+ continue;
68
+ const s = c.trim().toLowerCase();
69
+ if (s === 'headroom' || s === 'fill-first')
70
+ return s;
71
+ }
72
+ return 'headroom';
73
+ }
57
74
  /**
58
75
  * Match `anthropic-ratelimit-unified-7d_<family>-utilization`. Generic on
59
76
  * `<family>` so a future `7d_opus` / `7d_haiku` (or anything Anthropic
@@ -166,7 +183,23 @@ function pickMaxHeadroom(accounts, family) {
166
183
  }
167
184
  return best;
168
185
  }
186
+ // Fill-first pick: lexicographically-first eligible account still above the
187
+ // headroom floor. Alias order (not insertion order) — accounts load from a
188
+ // readdir whose order the OS doesn't guarantee, and the operator can control
189
+ // alias names but not readdir. Returns null when every candidate is at/below
190
+ // the floor so the caller can fall back to max-headroom.
191
+ function pickFillFirst(accounts, family) {
192
+ let best = null;
193
+ for (const a of accounts) {
194
+ if (best !== null && a.alias >= best.alias)
195
+ continue;
196
+ if (computeHeadroom(a.rateLimit, family) > POOL_HEADROOM_FLOOR)
197
+ best = a;
198
+ }
199
+ return best;
200
+ }
169
201
  export class AccountPool {
202
+ strategy;
170
203
  accounts = new Map();
171
204
  queue = [];
172
205
  queueMaxSize = 50;
@@ -175,6 +208,9 @@ export class AccountPool {
175
208
  sticky = new Map();
176
209
  // Amortize the O(n) sticky TTL/orphan sweep — timestamp of the last run.
177
210
  lastStickyCleanup = 0;
211
+ constructor(strategy = 'headroom') {
212
+ this.strategy = strategy;
213
+ }
178
214
  add(alias, opts) {
179
215
  const existing = this.accounts.get(alias);
180
216
  this.accounts.set(alias, {
@@ -257,6 +293,14 @@ export class AccountPool {
257
293
  a.expiresAt > now + 30_000 &&
258
294
  !isInAuthCooldown(a, now));
259
295
  if (eligible.length > 0) {
296
+ if (this.strategy === 'fill-first') {
297
+ const first = pickFillFirst(eligible, family);
298
+ if (first)
299
+ return first;
300
+ // Every eligible account is at/below the floor — the terminal state
301
+ // both strategies share. Fall through to max-headroom so the caller
302
+ // still gets the least-drained account instead of null.
303
+ }
260
304
  return pickMaxHeadroom(eligible, family);
261
305
  }
262
306
  // All accounts exhausted — return the one with the earliest reset.
@@ -374,6 +418,15 @@ export class AccountPool {
374
418
  a.expiresAt > now + 30_000 &&
375
419
  !isInAuthCooldown(a, now));
376
420
  if (eligible.length > 0) {
421
+ // Fill-first failover keeps the fill order: the next account tried
422
+ // after a 429 is the next alias in line, not the max-headroom seat —
423
+ // otherwise a single failover would defeat the concentration the
424
+ // strategy exists to provide.
425
+ if (this.strategy === 'fill-first') {
426
+ const first = pickFillFirst(eligible, family);
427
+ if (first)
428
+ return first;
429
+ }
377
430
  return pickMaxHeadroom(eligible, family);
378
431
  }
379
432
  if (candidates.length > 0) {