@askalf/dario 3.38.6 → 4.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/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
+ };
@@ -0,0 +1,326 @@
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
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, unlinkSync } from 'node:fs';
24
+ import { homedir } from 'node:os';
25
+ import { dirname, join } from 'node:path';
26
+ /**
27
+ * Bumped on any incompatible shape change. v4.0.0 ships schema v1. A
28
+ * future shape change would either add a new optional field (no bump)
29
+ * or rename / restructure (bump to v2 with a migration in `loadConfig`).
30
+ */
31
+ export const CONFIG_SCHEMA_VERSION = 1;
32
+ /**
33
+ * Default `~/.dario/config.json` location. Override in tests via
34
+ * `loadConfig(path)` / `saveConfig(path, …)`.
35
+ */
36
+ export const DEFAULT_CONFIG_PATH = join(homedir(), '.dario', 'config.json');
37
+ /**
38
+ * Defaults match the v3.x CLI flag defaults exactly. Any value not
39
+ * specified in config.json resolves to its corresponding default here.
40
+ * Updates to a flag default MUST land here too so they stay in sync.
41
+ */
42
+ export function defaultConfig() {
43
+ return {
44
+ version: CONFIG_SCHEMA_VERSION,
45
+ port: 3456,
46
+ host: '127.0.0.1',
47
+ model: null,
48
+ passthrough: false,
49
+ preserveTools: false,
50
+ hybridTools: false,
51
+ mergeTools: false,
52
+ noAutoDetect: false,
53
+ strictTls: false,
54
+ strictTemplate: false,
55
+ noLiveCapture: false,
56
+ drainOnClose: false,
57
+ stealth: false,
58
+ pacing: { minMs: 500, jitterMs: 0 },
59
+ thinkTime: { baseMs: 0, perTokenMs: 0, jitterMs: 0, maxMs: 30_000 },
60
+ sessionStart: { minMs: 0, jitterMs: 0 },
61
+ session: {
62
+ idleRotateMs: 900_000,
63
+ rotateJitterMs: 0,
64
+ maxAgeMs: null,
65
+ perClient: false,
66
+ },
67
+ queue: { maxConcurrent: null, maxQueued: null, timeoutMs: null },
68
+ effort: null,
69
+ maxTokens: null,
70
+ passthroughBetas: [],
71
+ systemPrompt: null,
72
+ preserveOrchestrationTags: false,
73
+ logFile: null,
74
+ };
75
+ }
76
+ /**
77
+ * Load the config file at `path` (default ~/.dario/config.json).
78
+ *
79
+ * Returns `{ config, source }` where `source` describes the load outcome
80
+ * for the caller's UI:
81
+ *
82
+ * - 'file' — successfully loaded
83
+ * - 'missing' — file doesn't exist; defaults returned (not an error)
84
+ * - 'invalid' — file exists but parse / shape check failed; defaults
85
+ * returned. The TUI surfaces this so the user knows
86
+ * their saved settings were ignored.
87
+ *
88
+ * The loaded shape is type-checked field-by-field: unknown keys pass
89
+ * through (forward-compat), known keys with wrong types are dropped.
90
+ * Strict validation would force a config migration on every shape
91
+ * tweak; loose-but-typed lets the file evolve without breaking older
92
+ * dario installs that haven't been restarted.
93
+ */
94
+ export function loadConfig(path = DEFAULT_CONFIG_PATH) {
95
+ if (!existsSync(path)) {
96
+ return { config: defaultConfig(), source: 'missing' };
97
+ }
98
+ let raw;
99
+ try {
100
+ raw = readFileSync(path, 'utf-8');
101
+ }
102
+ catch (err) {
103
+ return {
104
+ config: defaultConfig(),
105
+ source: 'invalid',
106
+ error: `read failed: ${err.message}`,
107
+ };
108
+ }
109
+ let parsed;
110
+ try {
111
+ parsed = JSON.parse(raw);
112
+ }
113
+ catch (err) {
114
+ return {
115
+ config: defaultConfig(),
116
+ source: 'invalid',
117
+ error: `JSON parse failed: ${err.message}`,
118
+ };
119
+ }
120
+ if (!isPlainObject(parsed)) {
121
+ return {
122
+ config: defaultConfig(),
123
+ source: 'invalid',
124
+ error: `top-level value is not an object (got ${Array.isArray(parsed) ? 'array' : typeof parsed})`,
125
+ };
126
+ }
127
+ // Future schema bumps: dispatch on parsed.version here and run the
128
+ // appropriate migration. For now we accept any version field but
129
+ // pass the rest through field-by-field validation.
130
+ const typed = sanitize(parsed);
131
+ // Merge over defaults so callers always get a fully-populated shape.
132
+ return {
133
+ config: mergeOver(defaultConfig(), typed),
134
+ source: 'file',
135
+ };
136
+ }
137
+ /**
138
+ * Atomically write `config` to `path`. Writes to `<path>.tmp`, then
139
+ * renames into place — guarantees a reader never observes a half-written
140
+ * file. Creates parent directories if missing.
141
+ *
142
+ * Throws on permission / disk failures (caller handles + surfaces to
143
+ * the TUI's status line). Does NOT throw on a no-op rewrite of the
144
+ * same content; that's a cheap idempotent path.
145
+ */
146
+ export function saveConfig(path = DEFAULT_CONFIG_PATH, config) {
147
+ const parent = dirname(path);
148
+ if (!existsSync(parent)) {
149
+ mkdirSync(parent, { recursive: true, mode: 0o700 });
150
+ }
151
+ const json = JSON.stringify({ ...config, version: CONFIG_SCHEMA_VERSION }, null, 2) + '\n';
152
+ const tmp = `${path}.tmp.${process.pid}.${Date.now()}`;
153
+ try {
154
+ writeFileSync(tmp, json, { mode: 0o600 });
155
+ renameSync(tmp, path);
156
+ }
157
+ catch (err) {
158
+ // Best-effort cleanup of the temp file so we don't leave debris.
159
+ try {
160
+ unlinkSync(tmp);
161
+ }
162
+ catch { /* ignore */ }
163
+ throw err;
164
+ }
165
+ }
166
+ /**
167
+ * Deep-merge `over` into `base`, preferring `over` values where defined.
168
+ * Nested objects are merged recursively; arrays and primitives are
169
+ * replaced wholesale (no array-element merge — that'd be surprising).
170
+ *
171
+ * `undefined` in `over` is treated as "absent" and falls through to
172
+ * the `base` value. `null` is a real value and overrides.
173
+ */
174
+ export function mergeOver(base, over) {
175
+ const out = { ...base };
176
+ for (const [k, v] of Object.entries(over)) {
177
+ if (v === undefined)
178
+ continue;
179
+ if (isPlainObject(v) && isPlainObject(out[k])) {
180
+ // Recurse into nested object groups (pacing, thinkTime, …) so a
181
+ // partial override on one sub-field doesn't wipe siblings.
182
+ out[k] = mergeOver(out[k], v);
183
+ }
184
+ else {
185
+ out[k] = v;
186
+ }
187
+ }
188
+ return out;
189
+ }
190
+ /**
191
+ * Resolve the effective config: load file, layer env vars on top, layer
192
+ * CLI flags on top.
193
+ *
194
+ * defaults < config.json < env < cli
195
+ *
196
+ * `cliOverrides` and `envOverrides` are partial — only the keys the
197
+ * caller actually wants to override should be set. `undefined` keys
198
+ * are skipped, so the existing flag parsers in cli.ts can pass through
199
+ * their normalized output without filtering nulls.
200
+ */
201
+ export function resolveConfig(opts) {
202
+ const fromFile = loadConfig(opts.path);
203
+ const withEnv = mergeOver(fromFile.config, opts.envOverrides ?? {});
204
+ const withCli = mergeOver(withEnv, opts.cliOverrides ?? {});
205
+ return { ...fromFile, config: withCli };
206
+ }
207
+ // ── internals ────────────────────────────────────────────────────────
208
+ function isPlainObject(v) {
209
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
210
+ }
211
+ /**
212
+ * Field-by-field type-check. Drops keys whose values don't match the
213
+ * expected type — better to silently fall back to a default than to
214
+ * abort startup on a stray manually-edited typo. Unknown top-level
215
+ * keys pass through unchanged (forward-compat for future fields).
216
+ */
217
+ function sanitize(parsed) {
218
+ const out = { version: CONFIG_SCHEMA_VERSION };
219
+ const pickNumber = (k) => typeof parsed[k] === 'number' && Number.isFinite(parsed[k]) ? parsed[k] : undefined;
220
+ const pickBool = (k) => typeof parsed[k] === 'boolean' ? parsed[k] : undefined;
221
+ const pickString = (k) => typeof parsed[k] === 'string' ? parsed[k] : undefined;
222
+ const pickStringOrNull = (k) => {
223
+ if (parsed[k] === null)
224
+ return null;
225
+ if (typeof parsed[k] === 'string')
226
+ return parsed[k];
227
+ return undefined;
228
+ };
229
+ const pickNumberOrNull = (k) => {
230
+ if (parsed[k] === null)
231
+ return null;
232
+ if (typeof parsed[k] === 'number' && Number.isFinite(parsed[k]))
233
+ return parsed[k];
234
+ return undefined;
235
+ };
236
+ if (typeof parsed.version === 'number')
237
+ out.version = parsed.version;
238
+ if (pickNumber('port') !== undefined)
239
+ out.port = pickNumber('port');
240
+ if (pickString('host') !== undefined)
241
+ out.host = pickString('host');
242
+ const model = pickStringOrNull('model');
243
+ if (model !== undefined)
244
+ out.model = model;
245
+ for (const k of ['passthrough', 'preserveTools', 'hybridTools', 'mergeTools',
246
+ 'noAutoDetect', 'strictTls', 'strictTemplate', 'noLiveCapture',
247
+ 'drainOnClose', 'stealth', 'preserveOrchestrationTags']) {
248
+ const v = pickBool(k);
249
+ // Each `k` is a literal boolean-typed field on DarioConfig (verified
250
+ // by the `as const` tuple type above), so the assignment is sound
251
+ // — we route through `unknown` because TS can't narrow the union
252
+ // of literal keys to a single typed assignment at compile time.
253
+ if (v !== undefined)
254
+ out[k] = v;
255
+ }
256
+ // Nested groups — sanitize each, drop if not an object
257
+ if (isPlainObject(parsed.pacing)) {
258
+ out.pacing = {};
259
+ if (typeof parsed.pacing.minMs === 'number')
260
+ out.pacing.minMs = parsed.pacing.minMs;
261
+ if (typeof parsed.pacing.jitterMs === 'number')
262
+ out.pacing.jitterMs = parsed.pacing.jitterMs;
263
+ }
264
+ if (isPlainObject(parsed.thinkTime)) {
265
+ out.thinkTime = {};
266
+ for (const k of ['baseMs', 'perTokenMs', 'jitterMs', 'maxMs']) {
267
+ if (typeof parsed.thinkTime[k] === 'number') {
268
+ out.thinkTime[k] = parsed.thinkTime[k];
269
+ }
270
+ }
271
+ }
272
+ if (isPlainObject(parsed.sessionStart)) {
273
+ out.sessionStart = {};
274
+ if (typeof parsed.sessionStart.minMs === 'number')
275
+ out.sessionStart.minMs = parsed.sessionStart.minMs;
276
+ if (typeof parsed.sessionStart.jitterMs === 'number')
277
+ out.sessionStart.jitterMs = parsed.sessionStart.jitterMs;
278
+ }
279
+ if (isPlainObject(parsed.session)) {
280
+ out.session = {};
281
+ if (typeof parsed.session.idleRotateMs === 'number')
282
+ out.session.idleRotateMs = parsed.session.idleRotateMs;
283
+ if (typeof parsed.session.rotateJitterMs === 'number')
284
+ out.session.rotateJitterMs = parsed.session.rotateJitterMs;
285
+ if (parsed.session.maxAgeMs === null || typeof parsed.session.maxAgeMs === 'number') {
286
+ out.session.maxAgeMs = parsed.session.maxAgeMs;
287
+ }
288
+ if (typeof parsed.session.perClient === 'boolean')
289
+ out.session.perClient = parsed.session.perClient;
290
+ }
291
+ if (isPlainObject(parsed.queue)) {
292
+ out.queue = {};
293
+ for (const k of ['maxConcurrent', 'maxQueued', 'timeoutMs']) {
294
+ const v = parsed.queue[k];
295
+ if (v === null || (typeof v === 'number' && Number.isFinite(v))) {
296
+ out.queue[k] = v;
297
+ }
298
+ }
299
+ }
300
+ const effort = pickStringOrNull('effort');
301
+ if (effort !== undefined)
302
+ out.effort = effort;
303
+ // maxTokens is special — it's a number, the string 'client', or null
304
+ if (parsed.maxTokens === null)
305
+ out.maxTokens = null;
306
+ else if (parsed.maxTokens === 'client')
307
+ out.maxTokens = 'client';
308
+ else {
309
+ const n = pickNumber('maxTokens');
310
+ if (n !== undefined)
311
+ out.maxTokens = n;
312
+ }
313
+ if (Array.isArray(parsed.passthroughBetas)) {
314
+ out.passthroughBetas = parsed.passthroughBetas
315
+ .filter((x) => typeof x === 'string');
316
+ }
317
+ const sysPrompt = pickStringOrNull('systemPrompt');
318
+ if (sysPrompt !== undefined)
319
+ out.systemPrompt = sysPrompt;
320
+ const logFile = pickStringOrNull('logFile');
321
+ if (logFile !== undefined)
322
+ out.logFile = logFile;
323
+ // Silence unused-warning helper.
324
+ void pickNumberOrNull;
325
+ return out;
326
+ }
@@ -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.142";
285
+ readonly maxTested: "2.1.143";
286
286
  };
287
287
  /**
288
288
  * Compare two dotted-numeric version strings. Returns negative if `a<b`,