@askalf/dario 3.31.20 → 3.32.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
@@ -281,7 +281,9 @@ Dario's built-in `TOOL_MAP` carries **~66 schema-verified entries** covering the
281
281
  | OpenClaw | `exec`, `process`, `web_search`, `web_fetch`, `browser`, `message` |
282
282
  | Hermes Agent (Nous Research) | `terminal`, `process`, `read_file`, `write_file`, `patch`, `search_files`, `web_search`, `web_extract`, `todo` mapped directly. Hermes-specific tools (`browser_*`, `vision_analyze`, `image_generate`, `skill_*`, `memory`, `session_search`, `cronjob`, `send_message`, `ha_*`, `mixture_of_agents`, `delegate_task`, `execute_code`, `text_to_speech`) have no CC equivalent and auto-preserve through the identity detector (`You are Hermes Agent` or `created by Nous Research` in the system prompt flips dario into preserve-tools for Hermes sessions automatically — v3.30.13). Also consider `--max-tokens=client` so Hermes's 64k/128k per-model caps survive dario's outbound pin. |
283
283
 
284
- Text-tool clients (Cline / Kilo Code / Roo Code and forks) are auto-detected via system-prompt identity markers and automatically flipped into preserve-tools mode, because mixing CC's `tools` array with their XML protocol makes the model emit `<function_calls><invoke>` that their parsers can't read. If you run dario specifically for wire-level fidelity and would rather pick `--preserve-tools` yourself, `--no-auto-detect` (v3.20.1, aka `--no-auto-preserve`) disables the heuristic — explicit operator choice then wins.
284
+ Text-tool clients (Cline / Kilo Code / Roo Code and forks) are auto-detected via system-prompt identity markers and automatically flipped into preserve-tools mode, because mixing CC's `tools` array with their XML protocol makes the model emit `<function_calls><invoke>` that their parsers can't read. The same identity path also catches `arnie` (askalf's portable IT-troubleshooting CLI) — its tool names overlap with `TOOL_MAP` but its schemas diverge, so identity match → preserve-tools is the only correct routing. If you run dario specifically for wire-level fidelity and would rather pick `--preserve-tools` yourself, `--no-auto-detect` (v3.20.1, aka `--no-auto-preserve`) disables the heuristic — explicit operator choice then wins.
285
+
286
+ Beyond the identity path, dario falls back to a **structural** check: when a request carries 3+ tools and ≥80% of them aren't in `TOOL_MAP`, that's a custom client whose tool surface has effectively no overlap with CC's, and round-robin remap onto CC fallback slots silently corrupts the calls. The structural fallback flips those requests to preserve-tools too, with `client: 'unknown-non-cc'` in the request log. This catches in-house agents and OpenClaw derivatives that we haven't added an explicit pattern for, without needing per-client maintenance. `--no-auto-detect` disables both paths.
285
287
 
286
288
  If your agent's tool names aren't pre-mapped and its tools carry fields CC's schema doesn't have, there are two escape hatches: **`--preserve-tools`** (forward your schema verbatim, lose the CC wire shape) or **`--hybrid-tools`** (keep the CC wire shape, fill request-context fields from headers). See [Custom tool schemas](#custom-tool-schemas).
287
289
 
@@ -336,7 +338,8 @@ A version marker (`<!-- dario-sub-agent-version: X -->`) embedded in the markdow
336
338
  |---|---|
337
339
  | `dario login [--manual]` | Log in to the Claude backend. Detects CC credentials or runs its own OAuth flow. `--manual` (v3.20) mirrors CC's code-paste flow for SSH / container setups without a browser. |
338
340
  | `dario proxy` | Start the local API proxy on port 3456 |
339
- | `dario doctor [--probe] [--auth-check] [--json]` | Aggregated health report — dario / Node / runtime-TLS / CC binary + compat / template + drift / OAuth / pool / backends / sub-agent. `--probe` (v3.31.7) hits the live `claude.ai/oauth/authorize` endpoint and surfaces the verdict, so scope-policy drift is catchable from a user's machine (not just CI). `--auth-check` (v3.31.9) opens a one-shot `x-api-key` listener and classifies whatever a client actually sends (match / mismatch / no-auth / timeout), with only redacted previews in output. `--json` (v3.31.8) emits structured output for claude-bridge's `/status`, deepdive's health probes, and CI scrapers. |
341
+ | `dario doctor [--probe] [--auth-check] [--json] [--bun-bootstrap]` | Aggregated health report — dario / Node / runtime-TLS / CC binary + compat / template + drift / **per-request overhead** / OAuth / pool + **pool routing** (next account in rotation when 2+ loaded) / backends / sub-agent. `--probe` (v3.31.7) hits the live `claude.ai/oauth/authorize` endpoint and surfaces the verdict, so scope-policy drift is catchable from a user's machine (not just CI). `--auth-check` (v3.31.9) opens a one-shot `x-api-key` listener and classifies whatever a client actually sends (match / mismatch / no-auth / timeout), with only redacted previews in output. `--json` (v3.31.8) emits structured output for claude-bridge's `/status`, deepdive's health probes, and CI scrapers. `--bun-bootstrap` runs the canonical bun.sh installer when the runtime/TLS check is warning that Bun isn't on PATH. |
342
+ | `dario usage [--port=N] [--json]` | Burn-rate summary of the running proxy's traffic over the last 60 minutes: requests, input/output tokens, avg latency, error rate, subscription % vs. extra-usage, estimated API-equivalent cost, plus per-account breakdown when pool mode is active. Hits `/analytics` on the local proxy. When the proxy isn't reachable, prints a hint pointing at `dario doctor --usage` (the one-off rate-limit probe). `--json` emits the raw `/analytics` payload for status bars / CI dashboards. Also exposed as the `usage` tool in `dario mcp`. |
340
343
  | `dario config [--json]` | Prints the effective dario configuration with credentials redacted. Complementary to `doctor` — doctor answers *is it working?*, config answers *what IS it?* (v3.31.10) |
341
344
  | `dario upgrade` | Safe wrapper over `npm install -g @askalf/dario@latest` — probes npm for the `@latest` version first (3s timeout, 60s cache), refuses to run if already on latest, fails with a clear hint if npm is missing. (v3.31.10) |
342
345
  | `dario status` | Show Claude backend OAuth token health and expiry |
@@ -357,11 +360,14 @@ A version marker (`<!-- dario-sub-agent-version: X -->`) embedded in the markdow
357
360
  | `--preserve-tools` / `--keep-tools` | Keep client tool schemas instead of remapping to CC's. Required for clients whose tools have fields CC doesn't — see [Custom tool schemas](#custom-tool-schemas). Auto-enabled for Cline / Kilo Code / Roo Code and forks (detected via system-prompt identity markers). | off (auto for text-tool clients) |
358
361
  | `--no-auto-detect` / `--no-auto-preserve` | Disable the text-tool-client detector so the CC wire shape stays intact on Cline/Kilo/Roo prompts (v3.20.1, dario#40). Explicit `--preserve-tools` still wins. | off |
359
362
  | `--hybrid-tools` / `--context-inject` | Remap to CC tools **and** inject request-context values (`sessionId`, `requestId`, `channelId`, `userId`, `timestamp`) into client-declared fields CC's schema doesn't carry. See [Hybrid tool mode](#hybrid-tool-mode). | off |
363
+ | `--merge-tools` / `--append-tools` | **EXPERIMENTAL.** Send CC's canonical tools first, append the client's custom tools after (deduped by name, case-insensitive). Model can call either side; tool calls flow back unchanged. Mutually exclusive with `--preserve-tools` and `--hybrid-tools`. Anthropic's billing classifier may flip routing on the appended suffix — validate with `--verbose` and watch the `billing: <bucket>` line on the first 1-2 requests before relying on it. | off |
360
364
  | `--model=<name>` | Force a model. Shortcuts (`opus`, `sonnet`, `haiku`), full IDs (`claude-opus-4-7`), or a **provider prefix** (`openai:gpt-4o`, `groq:llama-3.3-70b`, `claude:opus`, `local:qwen-coder`) to force the backend server-wide. | passthrough |
361
365
  | `--port=<n>` | Port to listen on | `3456` |
362
366
  | `--host=<addr>` / `DARIO_HOST` | Bind address. Use `0.0.0.0` for LAN, or a specific IP (e.g. a Tailscale interface). When non-loopback, also set `DARIO_API_KEY`. | `127.0.0.1` |
363
367
  | `--verbose` / `-v` | Log every request (one line per request — method + path + billing bucket) | off |
364
368
  | `--verbose=2` / `-vv` / `DARIO_LOG_BODIES=1` | Also dump the outbound request body (redacted: bearer tokens, `sk-ant-*` keys, JWTs stripped; capped at 8KB). For wire-level client-compat debugging. | off |
369
+ | `--log-file=<path>` / `DARIO_LOG_FILE` | Append one JSON-ND record per completed request to PATH. Useful for backgrounded proxies where stdout is unobserved (where `--verbose` can't help). Field set: `ts`, `req`, `method`, `path`, `model`, `status`, `latency_ms`, `in_tokens`, `out_tokens`, `cache_read`, `cache_create`, `claim`, `bucket`, `account`, `client`, `preserve_tools`, `stream`, plus `reject` / `error` on failure paths. Secrets scrubbed via the same redactor that `--verbose-bodies` uses; no request bodies. | off |
370
+ | `--passthrough-betas=<csv>` / `DARIO_PASSTHROUGH_BETAS` | Beta flags ALWAYS forwarded upstream regardless of CC's captured set or the client's `anthropic-beta` header. Bypasses the billable-beta filter (so `extended-cache-ttl-*` survives if you opt in). Per-account rejection cache still applies — a pinned flag the upstream 400's gets dropped on retry rather than re-sent forever. Use when you know a beta works on your account but isn't in the captured template, or when client traffic should be force-augmented. Empty flag value (`--passthrough-betas=`) clears the env-default. | off |
365
371
  | `--strict-tls` / `DARIO_STRICT_TLS=1` | Refuse to start proxy mode unless runtime classifies as `bun-match` — i.e. the TLS ClientHello matches CC's. See [Wire-fidelity axes](#wire-fidelity-axes). (v3.23) | off |
366
372
  | `--pace-min=<ms>` / `DARIO_PACE_MIN_MS` | Minimum inter-request gap in ms. Replaces the legacy hardcoded 500 ms. (v3.24) | `500` |
367
373
  | `--pace-jitter=<ms>` / `DARIO_PACE_JITTER_MS` | Uniform random jitter added to each gap. Dissolves the minimum-inter-arrival observable edge. (v3.24) | `0` |
@@ -116,6 +116,12 @@ function candidatePaths() {
116
116
  const home = homedir();
117
117
  if (platform() === 'win32') {
118
118
  return [
119
+ // CC v2.x ships a Bun-compiled standalone exe under bin/.
120
+ // Earlier (v1.x) layouts used cli.js / cli.mjs at the package
121
+ // root. Both are kept in the search list so we work across the
122
+ // upgrade without forcing every user onto a fresh capture.
123
+ join(home, 'AppData', 'Roaming', 'npm', 'node_modules', '@anthropic-ai', 'claude-code', 'bin', 'claude.exe'),
124
+ join(home, '.claude', 'local', 'node_modules', '@anthropic-ai', 'claude-code', 'bin', 'claude.exe'),
119
125
  join(home, '.local', 'bin', 'claude.exe'),
120
126
  join(home, 'AppData', 'Roaming', 'npm', 'node_modules', '@anthropic-ai', 'claude-code', 'cli.js'),
121
127
  join(home, 'AppData', 'Roaming', 'npm', 'node_modules', '@anthropic-ai', 'claude-code', 'cli.mjs'),
@@ -124,6 +130,10 @@ function candidatePaths() {
124
130
  ];
125
131
  }
126
132
  return [
133
+ // v2.x bin/claude precompiled exe — checked before legacy cli.js/.mjs.
134
+ '/usr/local/lib/node_modules/@anthropic-ai/claude-code/bin/claude',
135
+ '/opt/homebrew/lib/node_modules/@anthropic-ai/claude-code/bin/claude',
136
+ join(home, '.claude', 'local', 'node_modules', '@anthropic-ai', 'claude-code', 'bin', 'claude'),
127
137
  join(home, '.local', 'bin', 'claude'),
128
138
  '/usr/local/bin/claude',
129
139
  '/opt/homebrew/bin/claude',
@@ -120,6 +120,30 @@ export declare function scrubFrameworkIdentifiers(text: string): string;
120
120
  * Reported via @vmvarg4 on X after the v3.30.5 marketing push.
121
121
  */
122
122
  export declare function detectTextToolClient(systemText: string): string | null;
123
+ /**
124
+ * Structural fallback for non-CC clients that the identity-string
125
+ * detector doesn't recognize. When the operator hands us 3+ tools and
126
+ * ≥80% of them don't appear in TOOL_MAP, we're looking at a custom
127
+ * client whose tool surface has effectively no overlap with CC's.
128
+ * Default-mode round-robin onto CC fallback slots silently corrupts
129
+ * those calls (the client gets back a Glob/Read/Bash response shape
130
+ * its own tool can't parse).
131
+ *
132
+ * Returns 'unknown-non-cc' for that case so buildCCRequest can flip
133
+ * to preserve-tools — the only correct routing for a tool surface
134
+ * dario doesn't understand. Unlike the identity-string detector, this
135
+ * catches future clients we haven't added an explicit pattern for
136
+ * (in-house agents, OpenClaw derivatives, etc.) without needing
137
+ * per-client maintenance.
138
+ *
139
+ * Threshold reasoning:
140
+ * - len < 3: too few tools to be confident; let the existing detector
141
+ * decide. Single-purpose bridges and partial loads land here.
142
+ * - 80% unmapped: leaves room for a non-CC client that legitimately
143
+ * reuses 1-2 of TOOL_MAP's bash/grep/read aliases. 100% would miss
144
+ * those; 50% would catch Cline forks that use 4 mapped + 4 custom.
145
+ */
146
+ export declare function detectNonCCByTools(clientTools: Array<Record<string, unknown>> | undefined): string | null;
123
147
  /**
124
148
  * Flatten an Anthropic-shaped `system` field (string or array of text
125
149
  * blocks) to a single joined string. Skips the billing-tag block so
@@ -218,6 +242,7 @@ export declare function buildCCRequest(clientBody: Record<string, unknown>, bill
218
242
  }, opts?: {
219
243
  preserveTools?: boolean;
220
244
  hybridTools?: boolean;
245
+ mergeTools?: boolean;
221
246
  noAutoDetect?: boolean;
222
247
  effort?: EffortValue;
223
248
  maxTokens?: number | 'client';
@@ -245,6 +245,16 @@ export function detectTextToolClient(systemText) {
245
245
  return 'hermes';
246
246
  if (/\bcreated by Nous Research\b/.test(systemText))
247
247
  return 'hermes';
248
+ // arnie (askalf) — IT-troubleshooting CLI built on the Anthropic SDK.
249
+ // Identity line is stable across versions ("You are Arnie, a portable
250
+ // IT tech troubleshooting assistant ..."). Tool *names* (shell, read_file,
251
+ // grep, ...) overlap with TOOL_MAP so structural fallback won't catch it,
252
+ // but the *schemas* diverge from CC's (arnie's shell takes {cmd, timeout_s,
253
+ // working_directory}; CC's Bash takes {command, description}) so default
254
+ // round-robin remap silently corrupts the calls. Identity match → auto
255
+ // preserve-tools is the only correct routing.
256
+ if (/\bYou are Arnie\b/.test(systemText))
257
+ return 'arnie';
248
258
  // Protocol-signature fallback — unique to the Cline family and its
249
259
  // forks; survives a forked system prompt that edited the identity
250
260
  // string out but kept the tool protocol intact.
@@ -256,6 +266,43 @@ export function detectTextToolClient(systemText) {
256
266
  return 'cline-like';
257
267
  return null;
258
268
  }
269
+ /**
270
+ * Structural fallback for non-CC clients that the identity-string
271
+ * detector doesn't recognize. When the operator hands us 3+ tools and
272
+ * ≥80% of them don't appear in TOOL_MAP, we're looking at a custom
273
+ * client whose tool surface has effectively no overlap with CC's.
274
+ * Default-mode round-robin onto CC fallback slots silently corrupts
275
+ * those calls (the client gets back a Glob/Read/Bash response shape
276
+ * its own tool can't parse).
277
+ *
278
+ * Returns 'unknown-non-cc' for that case so buildCCRequest can flip
279
+ * to preserve-tools — the only correct routing for a tool surface
280
+ * dario doesn't understand. Unlike the identity-string detector, this
281
+ * catches future clients we haven't added an explicit pattern for
282
+ * (in-house agents, OpenClaw derivatives, etc.) without needing
283
+ * per-client maintenance.
284
+ *
285
+ * Threshold reasoning:
286
+ * - len < 3: too few tools to be confident; let the existing detector
287
+ * decide. Single-purpose bridges and partial loads land here.
288
+ * - 80% unmapped: leaves room for a non-CC client that legitimately
289
+ * reuses 1-2 of TOOL_MAP's bash/grep/read aliases. 100% would miss
290
+ * those; 50% would catch Cline forks that use 4 mapped + 4 custom.
291
+ */
292
+ export function detectNonCCByTools(clientTools) {
293
+ if (!clientTools || clientTools.length < 3)
294
+ return null;
295
+ let unmapped = 0;
296
+ for (const tool of clientTools) {
297
+ const name = (tool.name || '').toLowerCase();
298
+ if (!TOOL_MAP[name])
299
+ unmapped++;
300
+ }
301
+ if (unmapped / clientTools.length >= 0.8) {
302
+ return 'unknown-non-cc';
303
+ }
304
+ return null;
305
+ }
259
306
  /**
260
307
  * Flatten an Anthropic-shaped `system` field (string or array of text
261
308
  * blocks) to a single joined string. Skips the billing-tag block so
@@ -789,8 +836,8 @@ export function buildCCRequest(clientBody, billingTag, cacheControl, identity, o
789
836
  // Cline / Kilo Code / Roo Code (and forks) ship an XML tool-invocation
790
837
  // protocol in the system prompt. Peek at it before scrubbing so the
791
838
  // brand name is still present, decide whether to auto-switch into
792
- // preserve-tools behavior below. Explicit --hybrid-tools outranks the
793
- // heuristic (operator opt-in wins). dario#40.
839
+ // preserve-tools behavior below. Explicit --hybrid-tools / --merge-tools
840
+ // outrank the heuristic (operator opt-in wins). dario#40.
794
841
  //
795
842
  // `noAutoDetect` skips the detector entirely — operators who want the
796
843
  // full CC fingerprint restored (tools array included) even when their
@@ -799,9 +846,22 @@ export function buildCCRequest(clientBody, billingTag, cacheControl, identity, o
799
846
  const rawSystemForDetection = extractSystemText(clientBody);
800
847
  const detectedClient = opts.noAutoDetect
801
848
  ? undefined
802
- : (detectTextToolClient(rawSystemForDetection) ?? undefined);
803
- const autoPreserve = Boolean(detectedClient) && !opts.hybridTools;
849
+ : (detectTextToolClient(rawSystemForDetection)
850
+ ?? detectNonCCByTools(clientTools)
851
+ ?? undefined);
852
+ const autoPreserve = Boolean(detectedClient) && !opts.hybridTools && !opts.mergeTools;
804
853
  const effectivePreserveTools = Boolean(opts.preserveTools) || autoPreserve;
854
+ // Merge mode is the third tool-routing axis. Wire shape: CC's canonical
855
+ // tool array is sent first (so the fingerprint axis "tools[]" still
856
+ // matches CC's wire footprint), and the client's tools are appended
857
+ // after — deduped by name, case-insensitive. The model sees the union
858
+ // and may call either side; tool calls flow back unchanged because we
859
+ // skip the reverse-map (any rewriting would be lossy in both directions).
860
+ //
861
+ // Mutually exclusive with preserveTools and hybridTools — three flags
862
+ // would mean three different bodies; the operator must pick one. The
863
+ // proxy CLI enforces the mutex at startup, this just respects it.
864
+ const effectiveMergeTools = Boolean(opts.mergeTools) && !effectivePreserveTools && !opts.hybridTools;
805
865
  // ── Strip thinking from history ──
806
866
  for (const msg of messages) {
807
867
  if (msg.role === 'assistant' && Array.isArray(msg.content)) {
@@ -844,7 +904,7 @@ export function buildCCRequest(clientBody, billingTag, cacheControl, identity, o
844
904
  // the fingerprint risk on their own account.
845
905
  const activeToolMap = new Map();
846
906
  const unmappedTools = [];
847
- if (clientTools && !effectivePreserveTools) {
907
+ if (clientTools && !effectivePreserveTools && !effectiveMergeTools) {
848
908
  // Two passes so the unmapped-tool distributor can avoid colliding with
849
909
  // CC tools the client already uses directly. Without this, a client
850
910
  // sending both `WebSearch` and some unmapped tool like `memory_get`
@@ -1027,10 +1087,38 @@ export function buildCCRequest(clientBody, billingTag, cacheControl, identity, o
1027
1087
  ],
1028
1088
  };
1029
1089
  // Tools come before metadata in CC's key order.
1030
- // preserveTools mode: pass client tools through unchanged (better for real
1031
- // agents with custom schemas, but loses the CC tool fingerprint).
1090
+ // - preserveTools mode: pass client tools through unchanged (better for
1091
+ // real agents with custom schemas, but loses the CC tool fingerprint).
1092
+ // - mergeTools mode: send CC's canonical tools FIRST then append the
1093
+ // client's tools, deduped by name (case-insensitive). The model sees
1094
+ // the union; tool calls flow back unchanged because activeToolMap is
1095
+ // empty in this branch. Trade-off documented in the README: the
1096
+ // wire-shape "tools[]" axis still contains CC's array as a prefix,
1097
+ // but the suffix is operator-supplied custom shapes — Anthropic's
1098
+ // classifier may flip routing on the difference. Verify locally
1099
+ // before relying on it.
1032
1100
  if (clientTools && clientTools.length > 0) {
1033
- ccRequest.tools = effectivePreserveTools ? clientTools : CC_TOOL_DEFINITIONS;
1101
+ if (effectivePreserveTools) {
1102
+ ccRequest.tools = clientTools;
1103
+ }
1104
+ else if (effectiveMergeTools) {
1105
+ const ccNames = new Set(CC_TOOL_DEFINITIONS.map((t) => t.name.toLowerCase()));
1106
+ const appended = clientTools.filter((t) => {
1107
+ const name = t.name?.toLowerCase();
1108
+ return name !== undefined && !ccNames.has(name);
1109
+ });
1110
+ ccRequest.tools = [...CC_TOOL_DEFINITIONS, ...appended];
1111
+ }
1112
+ else {
1113
+ ccRequest.tools = CC_TOOL_DEFINITIONS;
1114
+ }
1115
+ }
1116
+ else if (effectiveMergeTools) {
1117
+ // Operator opted into merge but the client sent no tools. Still
1118
+ // emit the CC base array — that preserves the fingerprint shape
1119
+ // (zero-tools requests are themselves a divergence from CC's
1120
+ // wire footprint).
1121
+ ccRequest.tools = CC_TOOL_DEFINITIONS;
1034
1122
  }
1035
1123
  // Metadata
1036
1124
  ccRequest.metadata = {
package/dist/cli.d.ts CHANGED
@@ -10,6 +10,19 @@
10
10
  * dario logout — Remove saved credentials
11
11
  */
12
12
  import { type EffortValue } from './cc-template.js';
13
+ /**
14
+ * Parse `--passthrough-betas=<csv>` (or the env-var fallback) into a
15
+ * deduped, trimmed list. The CLI flag wins over the env var when both
16
+ * are set — that's the convention every other dario flag uses.
17
+ *
18
+ * Edge cases:
19
+ * - `--passthrough-betas=` (explicit empty) → returns []. The
20
+ * operator typed an empty value; this is the documented "clear the
21
+ * env-default, run with no pinned betas" override.
22
+ * - flag missing entirely → falls back to envVar.
23
+ * - empty entries / whitespace-only entries / duplicates are dropped.
24
+ */
25
+ export declare function parsePassthroughBetasFlag(args: string[], envVar: string | undefined): string[];
13
26
  /**
14
27
  * Parse `--max-tokens=<N|client>` + `DARIO_MAX_TOKENS` env (dario#88).
15
28
  * Numeric values pin; `client` (case-insensitive) = passthrough client's
package/dist/cli.js CHANGED
@@ -181,8 +181,19 @@ async function proxy() {
181
181
  const passthrough = args.includes('--passthrough') || args.includes('--thin');
182
182
  const preserveTools = args.includes('--preserve-tools') || args.includes('--keep-tools');
183
183
  const hybridTools = args.includes('--hybrid-tools') || args.includes('--context-inject');
184
- if (preserveTools && hybridTools) {
185
- console.error('[dario] --preserve-tools and --hybrid-tools are mutually exclusive. Pick one.');
184
+ const mergeTools = args.includes('--merge-tools') || args.includes('--append-tools');
185
+ // The three modes shape the outbound `tools` array differently;
186
+ // combining any two would mean two different bodies. Caught here so
187
+ // the operator gets a clear error instead of one flag silently
188
+ // winning. startProxy enforces the same mutex defensively.
189
+ const toolModeCount = [preserveTools, hybridTools, mergeTools].filter(Boolean).length;
190
+ if (toolModeCount > 1) {
191
+ const picked = [
192
+ preserveTools && '--preserve-tools',
193
+ hybridTools && '--hybrid-tools',
194
+ mergeTools && '--merge-tools',
195
+ ].filter(Boolean).join(', ');
196
+ console.error(`[dario] tool-routing flags are mutually exclusive. Pick one (got: ${picked}).`);
186
197
  process.exit(1);
187
198
  }
188
199
  // Opt-out for v3.19.3's text-tool-client auto-detection. Operators who
@@ -268,6 +279,17 @@ async function proxy() {
268
279
  // the server side, so passing through a too-high value returns a clean
269
280
  // 400 rather than silently accepting beyond-model-max.
270
281
  const maxTokens = resolveMaxTokensFlag(args, process.env['DARIO_MAX_TOKENS']);
282
+ // --log-file <path> — append a one-line JSON record per completed
283
+ // request. Useful for backgrounded proxies where stdout is unobserved.
284
+ // Falls back to DARIO_LOG_FILE; off by default. Path is opened with
285
+ // append mode so multiple proxy restarts share a rolling history.
286
+ const logFile = parseLogFileFlag(args) ?? process.env['DARIO_LOG_FILE'] ?? undefined;
287
+ // --passthrough-betas=name1,name2 — operator-pinned beta allow-list.
288
+ // Names listed here are always forwarded to Anthropic regardless of
289
+ // CC's captured set or the client's own beta header; bypasses the
290
+ // billable-filter. Empty values are dropped. Falls back to
291
+ // DARIO_PASSTHROUGH_BETAS env var.
292
+ const passthroughBetas = parsePassthroughBetasFlag(args, process.env['DARIO_PASSTHROUGH_BETAS']);
271
293
  // Non-loopback bind without DARIO_API_KEY turns dario into an open
272
294
  // OAuth-subscription relay for anyone on the reachable network. Refuse
273
295
  // to start rather than rely on the operator to read the startup banner.
@@ -287,7 +309,56 @@ async function proxy() {
287
309
  console.error(`[dario] Override (not recommended): pass --unsafe-no-auth if you have out-of-band network controls and accept the risk.`);
288
310
  process.exit(1);
289
311
  }
290
- await startProxy({ port, host, verbose, verboseBodies, model, passthrough, preserveTools, hybridTools, noAutoDetect, strictTls, pacingMinMs, pacingJitterMs, drainOnClose, sessionIdleRotateMs, sessionRotateJitterMs, sessionMaxAgeMs, sessionPerClient, preserveOrchestrationTags, noLiveCapture, strictTemplate, maxConcurrent, maxQueued, queueTimeoutMs, effort, maxTokens });
312
+ await startProxy({ port, host, verbose, verboseBodies, model, passthrough, preserveTools, hybridTools, mergeTools, noAutoDetect, strictTls, pacingMinMs, pacingJitterMs, drainOnClose, sessionIdleRotateMs, sessionRotateJitterMs, sessionMaxAgeMs, sessionPerClient, preserveOrchestrationTags, noLiveCapture, strictTemplate, maxConcurrent, maxQueued, queueTimeoutMs, effort, maxTokens, logFile, passthroughBetas });
313
+ }
314
+ /**
315
+ * Parse `--passthrough-betas=<csv>` (or the env-var fallback) into a
316
+ * deduped, trimmed list. The CLI flag wins over the env var when both
317
+ * are set — that's the convention every other dario flag uses.
318
+ *
319
+ * Edge cases:
320
+ * - `--passthrough-betas=` (explicit empty) → returns []. The
321
+ * operator typed an empty value; this is the documented "clear the
322
+ * env-default, run with no pinned betas" override.
323
+ * - flag missing entirely → falls back to envVar.
324
+ * - empty entries / whitespace-only entries / duplicates are dropped.
325
+ */
326
+ export function parsePassthroughBetasFlag(args, envVar) {
327
+ const eqArg = args.find((a) => a.startsWith('--passthrough-betas='));
328
+ // When the flag is present at all (even with an empty value), it owns
329
+ // the result. Only fall back to the env var when the flag is absent.
330
+ const raw = eqArg !== undefined ? eqArg.slice('--passthrough-betas='.length) : envVar;
331
+ if (!raw)
332
+ return [];
333
+ const seen = new Set();
334
+ const out = [];
335
+ for (const piece of raw.split(',')) {
336
+ const trimmed = piece.trim();
337
+ if (trimmed.length > 0 && !seen.has(trimmed)) {
338
+ seen.add(trimmed);
339
+ out.push(trimmed);
340
+ }
341
+ }
342
+ return out;
343
+ }
344
+ /**
345
+ * Parse `--log-file=<path>` or `--log-file <path>`. Returns the path
346
+ * string when present, undefined otherwise. An empty path (e.g.
347
+ * `--log-file=`) is treated as unset so the env-var fallback can apply.
348
+ */
349
+ function parseLogFileFlag(args) {
350
+ const eqArg = args.find(a => a.startsWith('--log-file='));
351
+ if (eqArg) {
352
+ const value = eqArg.slice('--log-file='.length);
353
+ return value.length > 0 ? value : undefined;
354
+ }
355
+ const idx = args.indexOf('--log-file');
356
+ if (idx >= 0 && idx + 1 < args.length) {
357
+ const value = args[idx + 1];
358
+ if (value && !value.startsWith('-'))
359
+ return value;
360
+ }
361
+ return undefined;
291
362
  }
292
363
  /**
293
364
  * Parse `--max-tokens=<N|client>` + `DARIO_MAX_TOKENS` env (dario#88).
@@ -671,11 +742,30 @@ async function help() {
671
742
  DARIO_API_KEY — with redacted previews and
672
743
  a targeted diagnosis (dario#97 class). Use
673
744
  --timeout-ms=N to adjust the 30s default.
745
+ dario doctor --bun-bootstrap
746
+ One-shot Bun installer. Closes the gap
747
+ between "doctor warned about Node-only TLS
748
+ fingerprint" and "Bun on PATH" without
749
+ copy-pasting a curl-to-shell line. Skips
750
+ when Bun is already installed. Pure
751
+ delegation to the official installer at
752
+ bun.com — dario does not vendor or pin a
753
+ Bun version.
674
754
  dario config Print the effective configuration (port,
675
755
  host, DARIO_API_KEY state, OAuth status,
676
756
  pool, backends, paths) with credentials
677
757
  redacted. Safe to paste into bug reports.
678
758
  --json for structured output.
759
+ dario usage Burn-rate summary of the running proxy's
760
+ traffic (last 60 min): requests, token
761
+ totals, subscription % vs. extra-usage,
762
+ per-account rotation if pool mode is on.
763
+ Hits /analytics on the local proxy. Works
764
+ only when proxy is running; for a one-off
765
+ rate-limit snapshot from Anthropic, see
766
+ \`dario doctor --usage\`. --port=N to target
767
+ a non-default port; --json for the raw
768
+ /analytics payload.
679
769
  dario upgrade npm install -g @askalf/dario@latest with a
680
770
  pre-flight current-vs-latest check.
681
771
 
@@ -691,6 +781,14 @@ async function help() {
691
781
  Loses subscription routing; use for custom agents
692
782
  --hybrid-tools Remap to CC tools, inject sessionId/requestId/etc.
693
783
  Keeps subscription routing for custom agents
784
+ --merge-tools Send CC's canonical tools first, append the
785
+ client's custom tools after (deduped by name).
786
+ Model can call either; tool calls flow back
787
+ unchanged. EXPERIMENTAL — Anthropic's billing
788
+ classifier may flip routing on the appended
789
+ tail. Validate with --verbose on the first
790
+ 1-2 requests. Mutually exclusive with
791
+ --preserve-tools and --hybrid-tools.
694
792
  --no-auto-detect Disable Cline/Kilo/Roo auto-preserve-tools
695
793
  (v3.19.3 behavior). Keeps CC fingerprint
696
794
  intact even when a text-tool client is
@@ -801,6 +899,20 @@ async function help() {
801
899
  --verbose, -v Log all requests
802
900
  --verbose=2, -vv Also dump redacted request bodies
803
901
  (env: DARIO_LOG_BODIES=1)
902
+ --log-file=PATH Append one JSON-ND record per completed
903
+ request to PATH. Useful for backgrounded
904
+ proxies where stdout is unobserved (where
905
+ --verbose can't help). Secrets scrubbed,
906
+ no request bodies. Env: DARIO_LOG_FILE.
907
+ --passthrough-betas=CSV Beta flags to ALWAYS forward upstream
908
+ regardless of CC's captured set or the
909
+ client's anthropic-beta header. Bypasses
910
+ the billable-beta filter. Per-account
911
+ rejection cache still applies (so a flag
912
+ upstream 400's gets dropped, not retried
913
+ forever). Use when you know a beta works
914
+ on your account but isn't in the captured
915
+ template. Env: DARIO_PASSTHROUGH_BETAS.
804
916
 
805
917
  Quick start:
806
918
  dario login # auto-detects Claude Code credentials
@@ -982,6 +1094,53 @@ async function doctor() {
982
1094
  const usage = args.includes('--usage');
983
1095
  const asJson = args.includes('--json');
984
1096
  const authCheck = args.includes('--auth-check');
1097
+ const bunBoot = args.includes('--bun-bootstrap');
1098
+ if (bunBoot) {
1099
+ // One-shot Bun installer. Closes the gap between "doctor warned
1100
+ // about Node-only TLS fingerprint" and "Bun is on PATH" without
1101
+ // making the user copy-paste a curl line from the README.
1102
+ // Probe first so we don't reinstall on a Bun-already-present host.
1103
+ const { probeBunVersion, bunBootstrap } = await import('./runtime-fingerprint.js');
1104
+ console.log('');
1105
+ console.log(' dario — Bun bootstrap');
1106
+ console.log(' ─────────────────────');
1107
+ console.log('');
1108
+ const existing = probeBunVersion();
1109
+ if (existing) {
1110
+ console.log(` Bun v${existing} already on PATH — nothing to install.`);
1111
+ console.log(' If dario is still running on Node, the auto-relaunch was bypassed (DARIO_NO_BUN set,');
1112
+ console.log(' or invoked through a wrapper that strips it). Re-run \`dario proxy\` directly.');
1113
+ console.log('');
1114
+ return;
1115
+ }
1116
+ console.log(' Bun is not on PATH. Running the official upstream installer:');
1117
+ console.log('');
1118
+ const result = await bunBootstrap();
1119
+ console.log('');
1120
+ if (result.exitCode === 0) {
1121
+ // Probe again — installer may write into a directory that the
1122
+ // current shell doesn't have on PATH yet (typical: ~/.bun/bin
1123
+ // appended to a profile that hasn't reloaded). We can't fix that
1124
+ // for the running shell; just call it out so the user knows what
1125
+ // to do next.
1126
+ const after = probeBunVersion();
1127
+ if (after) {
1128
+ console.log(` Bun v${after} installed. Re-run \`dario proxy\` to auto-relaunch under it.`);
1129
+ }
1130
+ else {
1131
+ console.log(' Installer reported success, but \`bun --version\` still fails from this shell.');
1132
+ console.log(' Open a new terminal (or source the profile the installer touched), then re-run');
1133
+ console.log(' \`dario doctor\` to confirm.');
1134
+ }
1135
+ console.log('');
1136
+ return;
1137
+ }
1138
+ console.error(` Installer exited with code ${result.exitCode}.`);
1139
+ console.error(` Manual fallback: ${result.runner}`);
1140
+ console.error(' Or visit https://bun.com for platform-specific instructions.');
1141
+ console.error('');
1142
+ process.exit(result.exitCode);
1143
+ }
985
1144
  if (authCheck) {
986
1145
  console.log('');
987
1146
  console.log(' dario — Auth Check');
@@ -1133,6 +1292,112 @@ async function upgrade() {
1133
1292
  console.log(' Upgrade complete. Run `dario --version` to confirm.');
1134
1293
  console.log('');
1135
1294
  }
1295
+ /**
1296
+ * `dario usage` — focused burn-rate summary of the running proxy's
1297
+ * traffic. Hits `/analytics` on the local proxy (default port 3456,
1298
+ * overridable with --port=N or DARIO_USAGE_PORT) and prints a
1299
+ * human-readable digest: requests in the last hour, token totals,
1300
+ * subscription % vs. extra-usage, per-account rotation if pool mode
1301
+ * is active.
1302
+ *
1303
+ * When the proxy isn't running on the expected port, prints a hint
1304
+ * pointing at `dario doctor --usage` (which fires a Haiku rate-limit
1305
+ * probe directly to Anthropic — different purpose, but the closest
1306
+ * substitute when there's no live proxy traffic to summarize).
1307
+ *
1308
+ * --json mode emits the raw /analytics payload for machine consumption
1309
+ * (CI dashboards, status bars, the MCP `usage` tool that wraps this).
1310
+ */
1311
+ async function usage() {
1312
+ const portArg = args.find(a => a.startsWith('--port='));
1313
+ const port = portArg
1314
+ ? parseInt(portArg.split('=')[1], 10)
1315
+ : process.env['DARIO_USAGE_PORT']
1316
+ ? parseInt(process.env['DARIO_USAGE_PORT'], 10)
1317
+ : 3456;
1318
+ const asJson = args.includes('--json');
1319
+ const url = `http://127.0.0.1:${port}/analytics`;
1320
+ let payload = null;
1321
+ let connectError = null;
1322
+ try {
1323
+ const res = await fetch(url, { signal: AbortSignal.timeout(3000) });
1324
+ if (!res.ok) {
1325
+ connectError = `proxy responded ${res.status}`;
1326
+ }
1327
+ else {
1328
+ payload = await res.json();
1329
+ }
1330
+ }
1331
+ catch (err) {
1332
+ connectError = err instanceof Error ? err.message : String(err);
1333
+ }
1334
+ if (asJson) {
1335
+ if (payload) {
1336
+ process.stdout.write(JSON.stringify(payload, null, 2) + '\n');
1337
+ return;
1338
+ }
1339
+ process.stdout.write(JSON.stringify({ error: 'proxy not reachable', port, detail: connectError }, null, 2) + '\n');
1340
+ process.exit(1);
1341
+ }
1342
+ console.log('');
1343
+ console.log(' dario — Usage');
1344
+ console.log(' ─────────────');
1345
+ console.log('');
1346
+ if (!payload) {
1347
+ console.log(` Proxy not reachable on http://127.0.0.1:${port} (${connectError ?? 'no response'}).`);
1348
+ console.log(' `dario usage` summarizes traffic from a running proxy (live history).');
1349
+ console.log(' For a one-off rate-limit snapshot from Anthropic, run:');
1350
+ console.log('');
1351
+ console.log(' dario doctor --usage');
1352
+ console.log('');
1353
+ console.log(' Costs ~1 subscription request; works without a running proxy.');
1354
+ console.log('');
1355
+ process.exit(1);
1356
+ }
1357
+ // Pool mode response shape:
1358
+ // { window: { minutes, requests, ...stats }, allTime: {...},
1359
+ // perAccount, perModel, utilization, predictions }
1360
+ // Single-account mode response shape:
1361
+ // { mode: 'single-account', note: '...' }
1362
+ if (payload.mode === 'single-account') {
1363
+ console.log(' Mode: single-account');
1364
+ console.log('');
1365
+ console.log(` ${payload.note}`);
1366
+ console.log('');
1367
+ console.log(' For a live snapshot of your subscription rate limit, run:');
1368
+ console.log(' dario doctor --usage');
1369
+ console.log('');
1370
+ return;
1371
+ }
1372
+ const win = payload.window;
1373
+ const allTime = payload.allTime;
1374
+ const perAccount = payload.perAccount;
1375
+ console.log(' Mode: pool');
1376
+ console.log(` Window: last ${win?.minutes ?? 60} minutes`);
1377
+ console.log('');
1378
+ console.log(` Requests: ${win?.requests ?? 0}` + (allTime ? ` (all-time: ${allTime.requests ?? 0})` : ''));
1379
+ if (win && win.requests > 0) {
1380
+ console.log(` Input tokens: ${(win.totalInputTokens ?? 0).toLocaleString()}`);
1381
+ console.log(` Output tokens: ${(win.totalOutputTokens ?? 0).toLocaleString()}`);
1382
+ console.log(` Avg latency: ${win.avgLatencyMs ?? 0} ms`);
1383
+ if ((win.errorRate ?? 0) > 0) {
1384
+ console.log(` Error rate: ${((win.errorRate ?? 0) * 100).toFixed(1)}%`);
1385
+ }
1386
+ console.log(` Subscription %: ${win.subscriptionPercent ?? 0}%`);
1387
+ if ((win.estimatedCost ?? 0) > 0) {
1388
+ console.log(` Est. cost: $${(win.estimatedCost ?? 0).toFixed(4)} (would-be API cost)`);
1389
+ }
1390
+ }
1391
+ if (perAccount && Object.keys(perAccount).length > 0) {
1392
+ console.log('');
1393
+ console.log(' Per-account:');
1394
+ const aliasWidth = Math.max(...Object.keys(perAccount).map((a) => a.length));
1395
+ for (const [alias, stats] of Object.entries(perAccount)) {
1396
+ console.log(` ${alias.padEnd(aliasWidth)} ${stats.requests} req${stats.requests === 1 ? '' : 's'} (${stats.subscriptionPercent}% subscription)`);
1397
+ }
1398
+ }
1399
+ console.log('');
1400
+ }
1136
1401
  // Main
1137
1402
  const commands = {
1138
1403
  login,
@@ -1148,6 +1413,7 @@ const commands = {
1148
1413
  doctor,
1149
1414
  config,
1150
1415
  upgrade,
1416
+ usage,
1151
1417
  help,
1152
1418
  version,
1153
1419
  '--help': help,