@askalf/dario 3.38.5 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -23,13 +23,35 @@ You're already paying $20, $100, or $200 a month for Claude. Then Cursor wants a
23
23
  ```bash
24
24
  npm install -g @askalf/dario
25
25
  dario login # uses your existing Claude Code credentials
26
- dario proxy
26
+ dario proxy # start the server (separate terminal or background)
27
27
  export ANTHROPIC_BASE_URL=http://localhost:3456
28
28
  export ANTHROPIC_API_KEY=dario
29
29
  ```
30
30
 
31
31
  That's the whole setup. Every tool that honors those env vars now runs on your subscription.
32
32
 
33
+ **New in v4:** type `dario` (no args) in another terminal to open the interactive TUI — live request stream, per-model burn-rate, rate-limit utilization, and a config editor that writes to `~/.dario/config.json`. Migrating from v3? See [MIGRATION.md](MIGRATION.md).
34
+
35
+ ```
36
+ ┌─ dario v4.0.0 ───────────────[ q quit · ? help · Tab next panel ]┐
37
+ │ Status Config ▎Analytics▎ Hits Accounts Backends │
38
+ ├──────────────────────────────────────────────────────────────────┤
39
+ │ Analytics — last 60 min │
40
+ │ │
41
+ │ Requests: 247 (4.1/min) │
42
+ │ Tokens in: 142,830 │
43
+ │ Tokens out: 38,200 │
44
+ │ │
45
+ │ Per-model: │
46
+ │ opus-4-7 ████████████████████░ 72% │
47
+ │ sonnet-4-6 █████░░░░░░░░░░░░░░░░ 22% │
48
+ │ │
49
+ │ Rate-limit: │
50
+ │ 5h ████░░░░░░░░░░░░░░░░░░░░░░ 18% │
51
+ │ 7d ██░░░░░░░░░░░░░░░░░░░░░░░░ 8% │
52
+ └──────────────────────────────────────────────────────────────────┘
53
+ ```
54
+
33
55
  ---
34
56
 
35
57
  ## The money
@@ -2,9 +2,21 @@
2
2
  * Token analytics — per-request billing tracking, utilization trends,
3
3
  * window exhaustion predictions, cost estimation.
4
4
  *
5
- * In-memory rolling window; exposed via the /analytics endpoint when
6
- * pool mode is active.
5
+ * In-memory rolling window. Exposed via two endpoints on the running
6
+ * proxy:
7
+ *
8
+ * - GET /analytics — rolling summary (`AnalyticsSummary`)
9
+ * - GET /analytics/stream — Server-Sent Events of new `RequestRecord`s
10
+ * as they're appended. The v4 TUI's Hits
11
+ * tab subscribes here for the live request
12
+ * feed; non-TUI clients can `curl -N` it.
13
+ *
14
+ * Pre-v4 the class only emitted data when pool mode was active; v4
15
+ * promotes analytics to always-on so single-account users get the same
16
+ * UX. The EventEmitter mixin below makes the streaming endpoint cheap —
17
+ * each subscriber listens for `'record'` and writes one SSE frame.
7
18
  */
19
+ import { EventEmitter } from 'node:events';
8
20
  export interface RequestRecord {
9
21
  timestamp: number;
10
22
  account: string;
@@ -43,11 +55,28 @@ export type BillingBucket = 'subscription' | 'subscription_fallback' | 'extra_us
43
55
  * billing bucket. Pure function; no state; safe to call from any context.
44
56
  */
45
57
  export declare function billingBucketFromClaim(claim: string | null | undefined): BillingBucket;
46
- export declare class Analytics {
58
+ export declare class Analytics extends EventEmitter {
47
59
  private records;
48
60
  private maxRecords;
49
61
  constructor(maxRecords?: number);
62
+ /**
63
+ * Append a request record to the rolling window and fan it out to
64
+ * any `'record'` listeners (the SSE stream subscribers). Emit happens
65
+ * AFTER the push so a subscriber that re-queries `recent()` from
66
+ * inside its handler sees the new record.
67
+ *
68
+ * The emit is wrapped in try/catch so a misbehaving subscriber can't
69
+ * crash the proxy hot-path; errors land on stderr (visible in
70
+ * --verbose) but don't propagate.
71
+ */
50
72
  record(r: RequestRecord): void;
73
+ /**
74
+ * Return the most recent `n` records (newest last). Used by the SSE
75
+ * endpoint to send a backlog snapshot before the live tail starts,
76
+ * so a freshly-attached TUI sees the recent state instead of an
77
+ * empty list.
78
+ */
79
+ recent(n?: number): RequestRecord[];
51
80
  /** Parse usage from a non-streaming Anthropic response body. */
52
81
  static parseUsage(body: Record<string, unknown>): {
53
82
  inputTokens: number;
package/dist/analytics.js CHANGED
@@ -2,9 +2,21 @@
2
2
  * Token analytics — per-request billing tracking, utilization trends,
3
3
  * window exhaustion predictions, cost estimation.
4
4
  *
5
- * In-memory rolling window; exposed via the /analytics endpoint when
6
- * pool mode is active.
5
+ * In-memory rolling window. Exposed via two endpoints on the running
6
+ * proxy:
7
+ *
8
+ * - GET /analytics — rolling summary (`AnalyticsSummary`)
9
+ * - GET /analytics/stream — Server-Sent Events of new `RequestRecord`s
10
+ * as they're appended. The v4 TUI's Hits
11
+ * tab subscribes here for the live request
12
+ * feed; non-TUI clients can `curl -N` it.
13
+ *
14
+ * Pre-v4 the class only emitted data when pool mode was active; v4
15
+ * promotes analytics to always-on so single-account users get the same
16
+ * UX. The EventEmitter mixin below makes the streaming endpoint cheap —
17
+ * each subscriber listens for `'record'` and writes one SSE frame.
7
18
  */
19
+ import { EventEmitter } from 'node:events';
8
20
  /**
9
21
  * Map the raw `representative-claim` header value to a human-friendly
10
22
  * billing bucket. Pure function; no state; safe to call from any context.
@@ -39,17 +51,50 @@ function estimateCost(record) {
39
51
  (record.cacheReadTokens * p.cacheRead) +
40
52
  (record.cacheCreateTokens * p.cacheCreate)) / 1_000_000;
41
53
  }
42
- export class Analytics {
54
+ export class Analytics extends EventEmitter {
43
55
  records = [];
44
56
  maxRecords;
45
57
  constructor(maxRecords = 10_000) {
58
+ super();
59
+ // High default — the /analytics/stream SSE endpoint creates one
60
+ // listener per active subscriber, and Node warns at 10 by default.
61
+ // 100 is generous for the TUI use case (one process, ~5 tabs)
62
+ // without hiding genuine leaks.
63
+ this.setMaxListeners(100);
46
64
  this.maxRecords = maxRecords;
47
65
  }
66
+ /**
67
+ * Append a request record to the rolling window and fan it out to
68
+ * any `'record'` listeners (the SSE stream subscribers). Emit happens
69
+ * AFTER the push so a subscriber that re-queries `recent()` from
70
+ * inside its handler sees the new record.
71
+ *
72
+ * The emit is wrapped in try/catch so a misbehaving subscriber can't
73
+ * crash the proxy hot-path; errors land on stderr (visible in
74
+ * --verbose) but don't propagate.
75
+ */
48
76
  record(r) {
49
77
  this.records.push(r);
50
78
  if (this.records.length > this.maxRecords) {
51
79
  this.records = this.records.slice(-this.maxRecords);
52
80
  }
81
+ try {
82
+ this.emit('record', r);
83
+ }
84
+ catch (err) {
85
+ // Subscriber threw — log + swallow. Not catastrophic; the record
86
+ // itself is already in the rolling window.
87
+ console.error('[dario] analytics subscriber threw:', err.message);
88
+ }
89
+ }
90
+ /**
91
+ * Return the most recent `n` records (newest last). Used by the SSE
92
+ * endpoint to send a backlog snapshot before the live tail starts,
93
+ * so a freshly-attached TUI sees the recent state instead of an
94
+ * empty list.
95
+ */
96
+ recent(n = 100) {
97
+ return this.records.slice(-n);
53
98
  }
54
99
  /** Parse usage from a non-streaming Anthropic response body. */
55
100
  static parseUsage(body) {
@@ -833,16 +833,24 @@ const TOOL_MAP = {
833
833
  // • hybrid mode → dropped, so the model doesn't see a broken tool;
834
834
  // • --preserve-tools → client's real schema flows through untouched
835
835
  // (recommended for agents that depend on ask-user flows).
836
- todo_read: {
837
- ccTool: 'TodoWrite',
838
- translateArgs: () => ({ todos: [] }),
839
- translateBack: () => ({}),
840
- },
841
- todo_write: {
842
- ccTool: 'TodoWrite',
843
- translateArgs: (a) => ({ todos: a.todos || [] }),
844
- translateBack: (a) => ({ todos: a.todos ?? [] }),
845
- },
836
+ // Intentionally unmapped (CC v2.1.142): Anthropic removed TodoWrite /
837
+ // TodoRead from the CC tool catalog in favor of the Task* family
838
+ // (TaskCreate / TaskGet / TaskList / TaskOutput / TaskStop / TaskUpdate).
839
+ // The previous `todo_read`/`todo_write` → `TodoWrite` mappings now point
840
+ // at a destination tool that no longer exists in the bundled or live
841
+ // template, so the schema-contract test correctly fails for them.
842
+ //
843
+ // We drop the mappings rather than remap to Task* because the semantics
844
+ // diverge: TodoWrite replaced an entire flat todo list per call; Task*
845
+ // is single-task-by-ID. A `todo_write` → `TaskCreate` rewrite would
846
+ // silently truncate a list-write to creating only the first item. The
847
+ // unmapped-tool path handles legacy clients honestly:
848
+ // • default mode → round-robin to a fallback CC tool (lossy but the
849
+ // upstream accepts the request);
850
+ // • hybrid mode → dropped, so the model doesn't see a phantom tool;
851
+ // • --preserve-tools → client's real schema flows through untouched
852
+ // (recommended for clients that actually depend on todo semantics).
853
+ //
846
854
  // Intentionally unmapped (dario#43): CC has no notebook-read tool, and
847
855
  // routing a read to NotebookEdit with empty new_source either fails the
848
856
  // schema (`new_source` required) or executes a destructive no-op edit.
package/dist/cli.js CHANGED
@@ -31,7 +31,28 @@ import { parseOutboundProxy, installOutboundProxyWrapper } from './outbound-prox
31
31
  // `args` to read their own flags. Reading argv is harmless on import; only
32
32
  // the handler dispatch at the bottom is gated behind the main-entry check.
33
33
  const args = process.argv.slice(2);
34
- const command = args[0] ?? 'proxy';
34
+ /**
35
+ * Default command when invoked with no args.
36
+ *
37
+ * v3.x: `dario` started the proxy (default = 'proxy').
38
+ * v4.0: `dario` opens the interactive TUI (default = 'tui').
39
+ *
40
+ * Migration: scripts that ran bare `dario` to launch the proxy need
41
+ * to switch to `dario proxy`. The TUI itself surfaces a "proxy
42
+ * unreachable" hint with the exact command if it doesn't see one
43
+ * running, so users discovering the change get pointed to the fix.
44
+ *
45
+ * `--no-tui` opt-out runs help instead (escape hatch for users who
46
+ * want the v3-style behavior without explicitly typing `proxy`, e.g.
47
+ * inside CI scripts that grep `dario` output).
48
+ */
49
+ const DEFAULT_COMMAND = args.includes('--no-tui') ? 'help' : 'tui';
50
+ // --no-tui is a meta-flag (controls DEFAULT_COMMAND above) not a command
51
+ // itself, so when it appears as args[0] we still resolve to the default.
52
+ // All other args pass through unchanged — `dario proxy`, `dario --help`,
53
+ // `dario --version` etc. still dispatch as expected.
54
+ const positionalArgs = args.filter((a) => a !== '--no-tui');
55
+ const command = positionalArgs[0] ?? DEFAULT_COMMAND;
35
56
  async function login() {
36
57
  console.log('');
37
58
  console.log(' dario — Claude Login');
@@ -190,18 +211,33 @@ async function logout() {
190
211
  }
191
212
  }
192
213
  async function proxy() {
214
+ // v4: load ~/.dario/config.json once at startup so file-stored values
215
+ // serve as defaults below where no CLI flag / env var supplies one.
216
+ // Precedence per M1: defaults < file < env < CLI. Missing-file is
217
+ // treated as "no file values" — the existing CLI/env paths see no
218
+ // change. Invalid file is logged but doesn't abort; we still start
219
+ // with whatever the env+flags provide.
220
+ const { loadConfig } = await import('./config-file.js');
221
+ const fileResult = loadConfig();
222
+ const fileCfg = fileResult.config;
223
+ if (fileResult.source === 'invalid') {
224
+ console.warn(`[dario] config file present but invalid (${fileResult.error}) — using defaults + env + flags only.`);
225
+ }
193
226
  const portArg = args.find(a => a.startsWith('--port='));
194
- const port = portArg ? parseInt(portArg.split('=')[1]) : 3456;
227
+ // Precedence: --port flag > DARIO_PORT env > config file > built-in default 3456
228
+ const portFromCli = portArg ? parseInt(portArg.split('=')[1], 10) : undefined;
229
+ const portFromEnv = process.env['DARIO_PORT'] ? parseInt(process.env['DARIO_PORT'], 10) : undefined;
230
+ const port = portFromCli ?? portFromEnv ?? fileCfg.port ?? 3456;
195
231
  if (isNaN(port) || port < 1 || port > 65535) {
196
232
  console.error('[dario] Invalid port. Must be 1-65535.');
197
233
  process.exit(1);
198
234
  }
199
- // Bind address — accepts --host=<addr>; falls through to DARIO_HOST env
200
- // var or the default of 127.0.0.1 inside startProxy. The sanity check
201
- // here only rejects obviously bad shapes; real address validation
202
- // happens when the OS tries to bind.
235
+ // Bind address — --host > DARIO_HOST > config file > startProxy's
236
+ // built-in 127.0.0.1 default. The regex sanity-check rejects
237
+ // obviously bad shapes; real address validation happens at bind time.
203
238
  const hostArg = args.find(a => a.startsWith('--host='));
204
- const host = hostArg ? hostArg.split('=')[1] : undefined;
239
+ const host = hostArg ? hostArg.split('=')[1]
240
+ : (process.env['DARIO_HOST'] || fileCfg.host || undefined);
205
241
  if (host !== undefined && !/^[a-zA-Z0-9._:-]+$/.test(host)) {
206
242
  console.error('[dario] Invalid --host. Must be an IP address or hostname.');
207
243
  process.exit(1);
@@ -247,34 +283,33 @@ async function proxy() {
247
283
  const model = modelArg ? modelArg.split('=')[1] : undefined;
248
284
  // --pace-min=MS / --pace-jitter=MS (v3.24, direction #6 — behavioral
249
285
  // smoothing). Inter-request gap floor + optional uniform-random jitter.
250
- // Defaults preserve v3.23 behavior (500ms floor, no jitter). The pure
251
- // calc lives in src/pacing.ts; the flags just feed it.
252
- const pacingMinMs = parsePositiveIntFlag('--pace-min=');
253
- const pacingJitterMs = parsePositiveIntFlag('--pace-jitter=');
286
+ // v4: ~/.dario/config.json's `pacing.{minMs,jitterMs}` is the fallback
287
+ // when no CLI flag is set, so the TUI's Config tab can persist edits.
288
+ // The pure calc lives in src/pacing.ts; flags+config just feed it.
289
+ const pacingMinMs = parsePositiveIntFlag('--pace-min=') ?? fileCfg.pacing?.minMs;
290
+ const pacingJitterMs = parsePositiveIntFlag('--pace-jitter=') ?? fileCfg.pacing?.jitterMs;
254
291
  // --think-time-* / --session-start-* — behavioral smoothing extension.
255
- // Closes the temporal axis the wire-fidelity work doesn't touch:
256
- // response-length-correlated read time between requests, and per-
257
- // session opening latency. All defaults 0 = off (opt-in).
258
- const thinkTimeBaseMs = parsePositiveIntFlag('--think-time-base=');
259
- const thinkTimePerTokenMs = parsePositiveIntFlag('--think-time-per-token=');
260
- const thinkTimeJitterMs = parsePositiveIntFlag('--think-time-jitter=');
261
- const thinkTimeMaxMs = parsePositiveIntFlag('--think-time-max=');
262
- const sessionStartMinMs = parsePositiveIntFlag('--session-start-min=');
263
- const sessionStartJitterMs = parsePositiveIntFlag('--session-start-jitter=');
292
+ // Same v4 precedence (flag > file > built-in default).
293
+ const thinkTimeBaseMs = parsePositiveIntFlag('--think-time-base=') ?? fileCfg.thinkTime?.baseMs;
294
+ const thinkTimePerTokenMs = parsePositiveIntFlag('--think-time-per-token=') ?? fileCfg.thinkTime?.perTokenMs;
295
+ const thinkTimeJitterMs = parsePositiveIntFlag('--think-time-jitter=') ?? fileCfg.thinkTime?.jitterMs;
296
+ const thinkTimeMaxMs = parsePositiveIntFlag('--think-time-max=') ?? fileCfg.thinkTime?.maxMs;
297
+ const sessionStartMinMs = parsePositiveIntFlag('--session-start-min=') ?? fileCfg.sessionStart?.minMs;
298
+ const sessionStartJitterMs = parsePositiveIntFlag('--session-start-jitter=') ?? fileCfg.sessionStart?.jitterMs;
264
299
  // --stealth flips all three pacing layers (pace, think, session-start)
265
- // into their behavioral-stealth presets so the request inter-arrival
266
- // distribution matches real interactive CC. One knob instead of six.
267
- // Per-knob explicit flags / env vars still win, so operators can
268
- // toggle stealth on and then tune individual axes.
300
+ // into their behavioral-stealth presets. Same v4 precedence (flag >
301
+ // env > file > false). Per-knob flags above still win even when
302
+ // stealth is on, so operators can flip on then tune individual axes.
269
303
  const stealth = args.includes('--stealth')
270
304
  || parseBooleanEnv(process.env['DARIO_STEALTH'])
305
+ || fileCfg.stealth
271
306
  || undefined;
272
307
  // --drain-on-close (v3.25, direction #5). When set, a client
273
308
  // disconnect no longer aborts the upstream SSE — dario keeps
274
309
  // draining the stream to EOF so Anthropic sees the CC-shaped
275
310
  // read-to-completion pattern. Costs tokens (the response is fully
276
311
  // generated even if nobody reads it), so it's opt-in.
277
- const drainOnClose = args.includes('--drain-on-close') || undefined;
312
+ const drainOnClose = args.includes('--drain-on-close') || fileCfg.drainOnClose || undefined;
278
313
  // --session-* knobs (v3.28, direction #1). Control the single-account
279
314
  // session-id lifecycle: idle threshold, jitter on that threshold, hard
280
315
  // max-age, and whether to give each upstream client its own session.
@@ -1711,6 +1746,56 @@ async function usage() {
1711
1746
  }
1712
1747
  console.log('');
1713
1748
  }
1749
+ /**
1750
+ * `dario tui` (or `dario` with no args) — opens the interactive
1751
+ * terminal UI. v4 entry point.
1752
+ *
1753
+ * The TUI is a viewer/configurator; it expects a `dario proxy`
1754
+ * already running locally for live analytics. When no proxy is
1755
+ * reachable, each tab degrades gracefully — Status shows
1756
+ * "unreachable" with the start-command hint, Analytics + Hits show
1757
+ * the same. Accounts + Backends + Config don't need the proxy at
1758
+ * all (they read disk directly).
1759
+ *
1760
+ * Bails out early if stdin isn't a TTY — the TUI can't function in
1761
+ * a pipe / redirect. The error message points at `dario proxy` (the
1762
+ * non-interactive entry) for that case.
1763
+ *
1764
+ * --port=<n> target a non-default proxy port (default 3456)
1765
+ * --api-key=KEY authenticate against a DARIO_API_KEY-protected proxy
1766
+ */
1767
+ async function tui() {
1768
+ if (!process.stdin.isTTY) {
1769
+ console.error('[dario] TUI requires an interactive terminal.');
1770
+ console.error(' Pipe / redirect detected on stdin.');
1771
+ console.error(' Run `dario proxy` for the non-interactive proxy server,');
1772
+ console.error(' or `dario --no-tui` to print help instead.');
1773
+ process.exit(1);
1774
+ }
1775
+ const portArg = args.find((a) => a.startsWith('--port='));
1776
+ const port = portArg ? parseInt(portArg.split('=')[1], 10) : 3456;
1777
+ const apiKeyArg = args.find((a) => a.startsWith('--api-key='));
1778
+ const apiKey = apiKeyArg
1779
+ ? apiKeyArg.split('=')[1]
1780
+ : (process.env['DARIO_API_KEY'] || undefined);
1781
+ const { startTuiApp } = await import('./tui/tui-app.js');
1782
+ await startTuiApp({
1783
+ version: pkgVersion(),
1784
+ proxyUrl: `http://127.0.0.1:${port}`,
1785
+ apiKey,
1786
+ });
1787
+ }
1788
+ function pkgVersion() {
1789
+ try {
1790
+ // Read from the same package.json the rest of the CLI uses.
1791
+ const pkgUrl = new URL('../package.json', import.meta.url);
1792
+ const fs = require('node:fs');
1793
+ return JSON.parse(fs.readFileSync(pkgUrl, 'utf-8')).version || 'unknown';
1794
+ }
1795
+ catch {
1796
+ return 'unknown';
1797
+ }
1798
+ }
1714
1799
  // Main
1715
1800
  const commands = {
1716
1801
  login,
@@ -1727,6 +1812,7 @@ const commands = {
1727
1812
  config,
1728
1813
  upgrade,
1729
1814
  usage,
1815
+ tui,
1730
1816
  help,
1731
1817
  version,
1732
1818
  '--help': help,
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Config file foundation — v4.
3
+ *
4
+ * Persists user-tunable settings to `~/.dario/config.json` so the TUI
5
+ * can read + write them without having to manage shell scripts. Establishes
6
+ * the precedence chain that every effective value passes through:
7
+ *
8
+ * defaults < config.json < env var < CLI flag
9
+ *
10
+ * Existing CLI flags + env vars continue to work unchanged. The config
11
+ * file is purely additive — a missing file resolves to defaults, exactly
12
+ * as v3 already behaved (since v3 had no config file).
13
+ *
14
+ * Atomic write: write to `config.json.tmp`, fsync, rename. Same primitive
15
+ * shape `atomicWriteJson` in src/live-fingerprint.ts uses for the
16
+ * captured CC template.
17
+ *
18
+ * Unknown keys in the loaded file are preserved (forward-compat for
19
+ * future schema versions); validation is best-effort, not strict — a
20
+ * corrupt or partial file falls back to defaults rather than aborting
21
+ * the process.
22
+ */
23
+ /**
24
+ * Bumped on any incompatible shape change. v4.0.0 ships schema v1. A
25
+ * future shape change would either add a new optional field (no bump)
26
+ * or rename / restructure (bump to v2 with a migration in `loadConfig`).
27
+ */
28
+ export declare const CONFIG_SCHEMA_VERSION = 1;
29
+ /**
30
+ * Default `~/.dario/config.json` location. Override in tests via
31
+ * `loadConfig(path)` / `saveConfig(path, …)`.
32
+ */
33
+ export declare const DEFAULT_CONFIG_PATH: string;
34
+ /**
35
+ * Every user-tunable setting. Grouped into sub-objects when the knobs
36
+ * cluster naturally (pacing, thinkTime, session, queue) so the TUI's
37
+ * Config tab can render each cluster as a folder without extra glue.
38
+ *
39
+ * Optional everywhere — a partially-populated config file is valid; the
40
+ * proxy fills in defaults for whatever's absent.
41
+ */
42
+ export interface DarioConfig {
43
+ /** Schema version of this file. Required for forward-compat. */
44
+ version: number;
45
+ port?: number;
46
+ host?: string;
47
+ model?: string | null;
48
+ passthrough?: boolean;
49
+ preserveTools?: boolean;
50
+ hybridTools?: boolean;
51
+ mergeTools?: boolean;
52
+ noAutoDetect?: boolean;
53
+ strictTls?: boolean;
54
+ strictTemplate?: boolean;
55
+ noLiveCapture?: boolean;
56
+ drainOnClose?: boolean;
57
+ stealth?: boolean;
58
+ pacing?: {
59
+ minMs?: number;
60
+ jitterMs?: number;
61
+ };
62
+ thinkTime?: {
63
+ baseMs?: number;
64
+ perTokenMs?: number;
65
+ jitterMs?: number;
66
+ maxMs?: number;
67
+ };
68
+ sessionStart?: {
69
+ minMs?: number;
70
+ jitterMs?: number;
71
+ };
72
+ session?: {
73
+ idleRotateMs?: number;
74
+ rotateJitterMs?: number;
75
+ maxAgeMs?: number | null;
76
+ perClient?: boolean;
77
+ };
78
+ queue?: {
79
+ maxConcurrent?: number | null;
80
+ maxQueued?: number | null;
81
+ timeoutMs?: number | null;
82
+ };
83
+ effort?: string | null;
84
+ maxTokens?: number | 'client' | null;
85
+ passthroughBetas?: string[];
86
+ systemPrompt?: string | null;
87
+ preserveOrchestrationTags?: boolean;
88
+ logFile?: string | null;
89
+ }
90
+ /**
91
+ * Defaults match the v3.x CLI flag defaults exactly. Any value not
92
+ * specified in config.json resolves to its corresponding default here.
93
+ * Updates to a flag default MUST land here too so they stay in sync.
94
+ */
95
+ export declare function defaultConfig(): DarioConfig;
96
+ /**
97
+ * Load the config file at `path` (default ~/.dario/config.json).
98
+ *
99
+ * Returns `{ config, source }` where `source` describes the load outcome
100
+ * for the caller's UI:
101
+ *
102
+ * - 'file' — successfully loaded
103
+ * - 'missing' — file doesn't exist; defaults returned (not an error)
104
+ * - 'invalid' — file exists but parse / shape check failed; defaults
105
+ * returned. The TUI surfaces this so the user knows
106
+ * their saved settings were ignored.
107
+ *
108
+ * The loaded shape is type-checked field-by-field: unknown keys pass
109
+ * through (forward-compat), known keys with wrong types are dropped.
110
+ * Strict validation would force a config migration on every shape
111
+ * tweak; loose-but-typed lets the file evolve without breaking older
112
+ * dario installs that haven't been restarted.
113
+ */
114
+ export declare function loadConfig(path?: string): {
115
+ config: DarioConfig;
116
+ source: 'file' | 'missing' | 'invalid';
117
+ error?: string;
118
+ };
119
+ /**
120
+ * Atomically write `config` to `path`. Writes to `<path>.tmp`, then
121
+ * renames into place — guarantees a reader never observes a half-written
122
+ * file. Creates parent directories if missing.
123
+ *
124
+ * Throws on permission / disk failures (caller handles + surfaces to
125
+ * the TUI's status line). Does NOT throw on a no-op rewrite of the
126
+ * same content; that's a cheap idempotent path.
127
+ */
128
+ export declare function saveConfig(path: string | undefined, config: DarioConfig): void;
129
+ /**
130
+ * Deep-merge `over` into `base`, preferring `over` values where defined.
131
+ * Nested objects are merged recursively; arrays and primitives are
132
+ * replaced wholesale (no array-element merge — that'd be surprising).
133
+ *
134
+ * `undefined` in `over` is treated as "absent" and falls through to
135
+ * the `base` value. `null` is a real value and overrides.
136
+ */
137
+ export declare function mergeOver<T extends object>(base: T, over: Partial<T>): T;
138
+ /**
139
+ * Resolve the effective config: load file, layer env vars on top, layer
140
+ * CLI flags on top.
141
+ *
142
+ * defaults < config.json < env < cli
143
+ *
144
+ * `cliOverrides` and `envOverrides` are partial — only the keys the
145
+ * caller actually wants to override should be set. `undefined` keys
146
+ * are skipped, so the existing flag parsers in cli.ts can pass through
147
+ * their normalized output without filtering nulls.
148
+ */
149
+ export declare function resolveConfig(opts: {
150
+ path?: string;
151
+ envOverrides?: Partial<DarioConfig>;
152
+ cliOverrides?: Partial<DarioConfig>;
153
+ }): {
154
+ config: DarioConfig;
155
+ source: 'file' | 'missing' | 'invalid';
156
+ error?: string;
157
+ };