@askalf/dario 3.26.0 → 3.28.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/dist/cli.js CHANGED
@@ -220,7 +220,16 @@ async function proxy() {
220
220
  // read-to-completion pattern. Costs tokens (the response is fully
221
221
  // generated even if nobody reads it), so it's opt-in.
222
222
  const drainOnClose = args.includes('--drain-on-close') || undefined;
223
- await startProxy({ port, host, verbose, verboseBodies, model, passthrough, preserveTools, hybridTools, noAutoDetect, strictTls, pacingMinMs, pacingJitterMs, drainOnClose });
223
+ // --session-* knobs (v3.28, direction #1). Control the single-account
224
+ // session-id lifecycle: idle threshold, jitter on that threshold, hard
225
+ // max-age, and whether to give each upstream client its own session.
226
+ // All defaults preserve v3.27 behaviour exactly. Logic lives in
227
+ // src/session-rotation.ts; these flags just feed resolveSessionRotationConfig.
228
+ const sessionIdleRotateMs = parsePositiveIntFlag('--session-idle-rotate=');
229
+ const sessionRotateJitterMs = parsePositiveIntFlag('--session-rotate-jitter=');
230
+ const sessionMaxAgeMs = parsePositiveIntFlag('--session-max-age=');
231
+ const sessionPerClient = args.includes('--session-per-client') || undefined;
232
+ await startProxy({ port, host, verbose, verboseBodies, model, passthrough, preserveTools, hybridTools, noAutoDetect, strictTls, pacingMinMs, pacingJitterMs, drainOnClose, sessionIdleRotateMs, sessionRotateJitterMs, sessionMaxAgeMs, sessionPerClient });
224
233
  }
225
234
  function parsePositiveIntFlag(prefix) {
226
235
  const found = args.find(a => a.startsWith(prefix));
@@ -461,6 +470,14 @@ async function help() {
461
470
  operations to a named sub-agent (v3.26)
462
471
  dario subagent remove Remove the registered sub-agent file
463
472
  dario subagent status Show whether the sub-agent is installed
473
+ dario mcp Run dario as an MCP (Model Context Protocol)
474
+ server on stdio. Exposes read-only tools
475
+ (doctor, status, accounts_list, backends_list,
476
+ subagent_status, fingerprint_info) so MCP
477
+ clients (Claude Desktop, IDEs, etc.) can
478
+ inspect dario's state. No destructive ops
479
+ are exposed — mutations still require the
480
+ CLI. (v3.27)
464
481
  dario doctor Print a health report: dario / Node / CC /
465
482
  template / drift / OAuth / pool / backends
466
483
 
@@ -505,6 +522,26 @@ async function help() {
505
522
  is fully generated even if nobody reads
506
523
  it) for fingerprint fidelity. Bounded by
507
524
  the 5-minute upstream timeout. (v3.25)
525
+ --session-idle-rotate=MS Idle ms before the single-account session
526
+ id rotates (default: 900000 = 15 min).
527
+ Real CC rotates once per conversation, not
528
+ per call; the default matches its observed
529
+ cadence. Pool mode is unaffected. (v3.28)
530
+ --session-rotate-jitter=MS
531
+ Max additional uniform-random jitter (ms)
532
+ added to the idle threshold, sampled once
533
+ per session at creation. Default: 0 (off).
534
+ Hides the exact threshold from long-run
535
+ rotation statistics. (v3.28)
536
+ --session-max-age=MS Hard cap on a session id's lifetime
537
+ regardless of activity. Default: off. Set
538
+ for always-on pipelines where an idle
539
+ window would never trigger. (v3.28)
540
+ --session-per-client Give each upstream client (keyed by
541
+ x-session-id / x-client-session-id
542
+ header) its own rotated session id.
543
+ Default: off (single session across all
544
+ clients, v3.27 behaviour). (v3.28)
508
545
  --port=PORT Port to listen on (default: 3456)
509
546
  --host=ADDRESS Address to bind to (default: 127.0.0.1)
510
547
  Use 0.0.0.0 for LAN; see README for DARIO_API_KEY
@@ -647,6 +684,45 @@ async function subagent() {
647
684
  console.error('');
648
685
  process.exit(1);
649
686
  }
687
+ async function mcp() {
688
+ // MCP-over-stdio: protocol frames on stdout ONLY. Any stray console.log
689
+ // from downstream modules (doctor / oauth / accounts helpers) would
690
+ // corrupt the frame stream, so redirect them to stderr defensively for
691
+ // the lifetime of the server. Restored in the finally block for tests /
692
+ // embedders that re-use the process after `dario mcp`.
693
+ const origLog = console.log;
694
+ const origInfo = console.info;
695
+ console.log = (...a) => console.error(...a);
696
+ console.info = (...a) => console.error(...a);
697
+ try {
698
+ const [{ buildDefaultToolRegistry }, { runMcpServer }] = await Promise.all([
699
+ import('./mcp/tools.js'),
700
+ import('./mcp/server.js'),
701
+ ]);
702
+ const { readFile } = await import('node:fs/promises');
703
+ const { fileURLToPath } = await import('node:url');
704
+ const here = join(fileURLToPath(import.meta.url), '..', '..');
705
+ let pkgVersion = 'unknown';
706
+ try {
707
+ const pkg = JSON.parse(await readFile(join(here, 'package.json'), 'utf-8'));
708
+ if (typeof pkg.version === 'string')
709
+ pkgVersion = pkg.version;
710
+ }
711
+ catch {
712
+ // package.json missing or malformed — fall back to 'unknown' but let
713
+ // the server keep running so tool responses are still usable.
714
+ }
715
+ const tools = await buildDefaultToolRegistry();
716
+ await runMcpServer({
717
+ tools,
718
+ server: { name: 'dario', version: pkgVersion },
719
+ });
720
+ }
721
+ finally {
722
+ console.log = origLog;
723
+ console.info = origInfo;
724
+ }
725
+ }
650
726
  async function doctor() {
651
727
  const { runChecks, formatChecks, exitCodeFor } = await import('./doctor.js');
652
728
  console.log('');
@@ -686,6 +762,7 @@ const commands = {
686
762
  backend,
687
763
  shim,
688
764
  subagent,
765
+ mcp,
689
766
  doctor,
690
767
  help,
691
768
  version,
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Minimal MCP (Model Context Protocol) implementation — JSON-RPC 2.0 + the
3
+ * subset of methods dario needs to expose as an MCP server (direction #4,
4
+ * v3.27). Zero-runtime-deps policy means we don't pull in
5
+ * `@modelcontextprotocol/sdk`; the protocol surface we need is small enough
6
+ * to hand-roll correctly.
7
+ *
8
+ * MCP over stdio uses newline-delimited JSON — each line on stdin is one
9
+ * complete JSON-RPC message; each line on stdout is one complete response
10
+ * or notification. That's what `parseLine` and `encodeMessage` handle.
11
+ *
12
+ * We implement three methods from the MCP spec:
13
+ * initialize — handshake, server replies with capabilities.
14
+ * tools/list — enumerate exposed tools + their JSON schemas.
15
+ * tools/call — invoke a named tool with structured arguments.
16
+ *
17
+ * Plus the standard JSON-RPC error shapes. Notifications (no-`id` messages)
18
+ * are accepted and, for the only one we care about (`notifications/initialized`),
19
+ * acknowledged silently.
20
+ *
21
+ * Kept pure on purpose so the tests can exercise every branch without any
22
+ * stdio — `handleMessage` takes a raw JSON-RPC payload and a tool registry
23
+ * and returns either a response string or null (for notifications).
24
+ */
25
+ /** MCP spec revisions ship with different wire quirks; pin the one we test against. */
26
+ export declare const MCP_PROTOCOL_VERSION = "2024-11-05";
27
+ /** JSON-RPC 2.0 error codes (https://www.jsonrpc.org/specification). */
28
+ export declare const JSONRPC_ERRORS: {
29
+ readonly PARSE_ERROR: -32700;
30
+ readonly INVALID_REQUEST: -32600;
31
+ readonly METHOD_NOT_FOUND: -32601;
32
+ readonly INVALID_PARAMS: -32602;
33
+ readonly INTERNAL_ERROR: -32603;
34
+ };
35
+ export interface JsonRpcRequest {
36
+ jsonrpc: '2.0';
37
+ id: string | number;
38
+ method: string;
39
+ params?: unknown;
40
+ }
41
+ export interface JsonRpcNotification {
42
+ jsonrpc: '2.0';
43
+ method: string;
44
+ params?: unknown;
45
+ }
46
+ export interface JsonRpcResponse {
47
+ jsonrpc: '2.0';
48
+ id: string | number | null;
49
+ result?: unknown;
50
+ error?: {
51
+ code: number;
52
+ message: string;
53
+ data?: unknown;
54
+ };
55
+ }
56
+ export type JsonRpcMessage = JsonRpcRequest | JsonRpcNotification;
57
+ /** Shape of an MCP tool's content block — we only emit text content. */
58
+ export interface McpToolContent {
59
+ type: 'text';
60
+ text: string;
61
+ }
62
+ export interface McpToolResult {
63
+ content: McpToolContent[];
64
+ /** If a tool ran but the operation itself failed (e.g. upstream error), set isError: true. */
65
+ isError?: boolean;
66
+ }
67
+ export interface McpTool {
68
+ name: string;
69
+ description: string;
70
+ inputSchema: {
71
+ type: 'object';
72
+ properties: Record<string, unknown>;
73
+ required?: string[];
74
+ };
75
+ handler: (args: Record<string, unknown>) => Promise<McpToolResult>;
76
+ }
77
+ /** Server identity the client sees on `initialize`. */
78
+ export interface ServerInfo {
79
+ name: string;
80
+ version: string;
81
+ }
82
+ /**
83
+ * Parse one newline-delimited JSON line into a JSON-RPC message.
84
+ * Returns `{ ok: true, msg }` on success or `{ ok: false, error }` so the
85
+ * caller can emit the canonical -32700 parse-error response. Blank lines
86
+ * are reported as ok=false with no error — they're legal stdio framing
87
+ * noise, not protocol violations.
88
+ */
89
+ export declare function parseLine(line: string): {
90
+ ok: true;
91
+ msg: JsonRpcMessage;
92
+ } | {
93
+ ok: false;
94
+ error: string | null;
95
+ };
96
+ /**
97
+ * Shape a successful response. `id` is echoed from the request.
98
+ */
99
+ export declare function successResponse(id: string | number, result: unknown): JsonRpcResponse;
100
+ /**
101
+ * Shape an error response with a JSON-RPC error code + message. When the
102
+ * originating message was unparseable (no id extractable), pass `null`.
103
+ */
104
+ export declare function errorResponse(id: string | number | null, code: number, message: string, data?: unknown): JsonRpcResponse;
105
+ /** Encode a response as a newline-terminated string ready for stdout. */
106
+ export declare function encodeMessage(msg: JsonRpcResponse): string;
107
+ /**
108
+ * Core request-dispatch routine. Pure: given a parsed message + a tool
109
+ * registry + server identity, returns the JSON-RPC response (or null for
110
+ * a notification that expects no reply).
111
+ */
112
+ export declare function handleMessage(msg: JsonRpcMessage, tools: McpTool[], server: ServerInfo): Promise<JsonRpcResponse | null>;
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Minimal MCP (Model Context Protocol) implementation — JSON-RPC 2.0 + the
3
+ * subset of methods dario needs to expose as an MCP server (direction #4,
4
+ * v3.27). Zero-runtime-deps policy means we don't pull in
5
+ * `@modelcontextprotocol/sdk`; the protocol surface we need is small enough
6
+ * to hand-roll correctly.
7
+ *
8
+ * MCP over stdio uses newline-delimited JSON — each line on stdin is one
9
+ * complete JSON-RPC message; each line on stdout is one complete response
10
+ * or notification. That's what `parseLine` and `encodeMessage` handle.
11
+ *
12
+ * We implement three methods from the MCP spec:
13
+ * initialize — handshake, server replies with capabilities.
14
+ * tools/list — enumerate exposed tools + their JSON schemas.
15
+ * tools/call — invoke a named tool with structured arguments.
16
+ *
17
+ * Plus the standard JSON-RPC error shapes. Notifications (no-`id` messages)
18
+ * are accepted and, for the only one we care about (`notifications/initialized`),
19
+ * acknowledged silently.
20
+ *
21
+ * Kept pure on purpose so the tests can exercise every branch without any
22
+ * stdio — `handleMessage` takes a raw JSON-RPC payload and a tool registry
23
+ * and returns either a response string or null (for notifications).
24
+ */
25
+ /** MCP spec revisions ship with different wire quirks; pin the one we test against. */
26
+ export const MCP_PROTOCOL_VERSION = '2024-11-05';
27
+ /** JSON-RPC 2.0 error codes (https://www.jsonrpc.org/specification). */
28
+ export const JSONRPC_ERRORS = {
29
+ PARSE_ERROR: -32700,
30
+ INVALID_REQUEST: -32600,
31
+ METHOD_NOT_FOUND: -32601,
32
+ INVALID_PARAMS: -32602,
33
+ INTERNAL_ERROR: -32603,
34
+ };
35
+ /**
36
+ * Parse one newline-delimited JSON line into a JSON-RPC message.
37
+ * Returns `{ ok: true, msg }` on success or `{ ok: false, error }` so the
38
+ * caller can emit the canonical -32700 parse-error response. Blank lines
39
+ * are reported as ok=false with no error — they're legal stdio framing
40
+ * noise, not protocol violations.
41
+ */
42
+ export function parseLine(line) {
43
+ const trimmed = line.trim();
44
+ if (trimmed.length === 0)
45
+ return { ok: false, error: null };
46
+ let parsed;
47
+ try {
48
+ parsed = JSON.parse(trimmed);
49
+ }
50
+ catch (e) {
51
+ return { ok: false, error: e.message };
52
+ }
53
+ if (!parsed || typeof parsed !== 'object')
54
+ return { ok: false, error: 'top-level not an object' };
55
+ const obj = parsed;
56
+ if (obj.jsonrpc !== '2.0')
57
+ return { ok: false, error: 'missing or wrong jsonrpc version' };
58
+ if (typeof obj.method !== 'string')
59
+ return { ok: false, error: 'method must be a string' };
60
+ return { ok: true, msg: obj };
61
+ }
62
+ /**
63
+ * Shape a successful response. `id` is echoed from the request.
64
+ */
65
+ export function successResponse(id, result) {
66
+ return { jsonrpc: '2.0', id, result };
67
+ }
68
+ /**
69
+ * Shape an error response with a JSON-RPC error code + message. When the
70
+ * originating message was unparseable (no id extractable), pass `null`.
71
+ */
72
+ export function errorResponse(id, code, message, data) {
73
+ const err = { code, message };
74
+ if (data !== undefined)
75
+ err.data = data;
76
+ return { jsonrpc: '2.0', id, error: err };
77
+ }
78
+ /** Encode a response as a newline-terminated string ready for stdout. */
79
+ export function encodeMessage(msg) {
80
+ return JSON.stringify(msg) + '\n';
81
+ }
82
+ /**
83
+ * Core request-dispatch routine. Pure: given a parsed message + a tool
84
+ * registry + server identity, returns the JSON-RPC response (or null for
85
+ * a notification that expects no reply).
86
+ */
87
+ export async function handleMessage(msg, tools, server) {
88
+ const isNotification = !('id' in msg) || msg.id === undefined;
89
+ const id = isNotification ? null : msg.id;
90
+ // Notifications — no response expected. Only handle the ones we care about;
91
+ // silently ignore others (per JSON-RPC spec).
92
+ if (isNotification) {
93
+ return null;
94
+ }
95
+ const reqId = id;
96
+ switch (msg.method) {
97
+ case 'initialize':
98
+ return successResponse(reqId, {
99
+ protocolVersion: MCP_PROTOCOL_VERSION,
100
+ capabilities: { tools: {} },
101
+ serverInfo: server,
102
+ });
103
+ case 'tools/list':
104
+ return successResponse(reqId, {
105
+ tools: tools.map((t) => ({
106
+ name: t.name,
107
+ description: t.description,
108
+ inputSchema: t.inputSchema,
109
+ })),
110
+ });
111
+ case 'tools/call': {
112
+ const params = (msg.params ?? {});
113
+ if (typeof params.name !== 'string') {
114
+ return errorResponse(reqId, JSONRPC_ERRORS.INVALID_PARAMS, 'tools/call requires string `name`');
115
+ }
116
+ const tool = tools.find((t) => t.name === params.name);
117
+ if (!tool) {
118
+ return errorResponse(reqId, JSONRPC_ERRORS.METHOD_NOT_FOUND, `unknown tool: ${params.name}`);
119
+ }
120
+ const argsVal = params.arguments;
121
+ const args = (argsVal && typeof argsVal === 'object' && !Array.isArray(argsVal))
122
+ ? argsVal
123
+ : {};
124
+ try {
125
+ const result = await tool.handler(args);
126
+ return successResponse(reqId, result);
127
+ }
128
+ catch (err) {
129
+ return errorResponse(reqId, JSONRPC_ERRORS.INTERNAL_ERROR, `tool handler threw: ${err.message}`);
130
+ }
131
+ }
132
+ default:
133
+ return errorResponse(reqId, JSONRPC_ERRORS.METHOD_NOT_FOUND, `unknown method: ${msg.method}`);
134
+ }
135
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * MCP server runtime — bridges newline-delimited JSON-RPC on stdio to the
3
+ * pure `handleMessage` dispatcher in `./protocol.ts`. Everything stateful
4
+ * (stream I/O, serialization, logging) lives here; the protocol module
5
+ * stays pure so its tests don't need fake streams.
6
+ *
7
+ * Why serial: for await (const line of rl) processes one line at a time
8
+ * and we await the handler before reading the next. MCP clients tolerate
9
+ * both ordered and out-of-order responses, but ordered keeps the stdio
10
+ * frame sequence deterministic, avoids interleaving partial writes, and
11
+ * — the boring reason — matches what the tests can easily assert on.
12
+ *
13
+ * Why `runMcpServer` takes injectable streams: makes the event loop
14
+ * testable end-to-end with a PassThrough pair, no child process needed.
15
+ */
16
+ import { type McpTool, type ServerInfo } from './protocol.js';
17
+ export interface RunServerOptions {
18
+ tools: McpTool[];
19
+ server: ServerInfo;
20
+ /** Stream of newline-delimited JSON-RPC messages. Defaults to `process.stdin`. */
21
+ stdin?: NodeJS.ReadableStream;
22
+ /** Sink for newline-delimited JSON-RPC responses. Defaults to `process.stdout`. */
23
+ stdout?: NodeJS.WritableStream;
24
+ /** Diagnostic channel. Defaults to `process.stderr`. */
25
+ stderr?: NodeJS.WritableStream;
26
+ /** Hook fired when a handler throws unexpectedly — primarily for tests. */
27
+ onError?: (err: unknown, line: string) => void;
28
+ }
29
+ export declare function runMcpServer(opts: RunServerOptions): Promise<void>;
@@ -0,0 +1,63 @@
1
+ /**
2
+ * MCP server runtime — bridges newline-delimited JSON-RPC on stdio to the
3
+ * pure `handleMessage` dispatcher in `./protocol.ts`. Everything stateful
4
+ * (stream I/O, serialization, logging) lives here; the protocol module
5
+ * stays pure so its tests don't need fake streams.
6
+ *
7
+ * Why serial: for await (const line of rl) processes one line at a time
8
+ * and we await the handler before reading the next. MCP clients tolerate
9
+ * both ordered and out-of-order responses, but ordered keeps the stdio
10
+ * frame sequence deterministic, avoids interleaving partial writes, and
11
+ * — the boring reason — matches what the tests can easily assert on.
12
+ *
13
+ * Why `runMcpServer` takes injectable streams: makes the event loop
14
+ * testable end-to-end with a PassThrough pair, no child process needed.
15
+ */
16
+ import { createInterface } from 'node:readline';
17
+ import { handleMessage, parseLine, errorResponse, encodeMessage, JSONRPC_ERRORS, } from './protocol.js';
18
+ /**
19
+ * Back-pressure-aware line write. Using the callback form of
20
+ * `stream.write` means we wait for the chunk to drain before proceeding —
21
+ * on a slow stdout consumer that prevents us from buffering unboundedly.
22
+ */
23
+ function writeLine(stream, chunk) {
24
+ return new Promise((resolve, reject) => {
25
+ stream.write(chunk, (err) => (err ? reject(err) : resolve()));
26
+ });
27
+ }
28
+ export async function runMcpServer(opts) {
29
+ const stdin = opts.stdin ?? process.stdin;
30
+ const stdout = opts.stdout ?? process.stdout;
31
+ const stderr = opts.stderr ?? process.stderr;
32
+ const rl = createInterface({ input: stdin, crlfDelay: Infinity });
33
+ for await (const line of rl) {
34
+ const parsed = parseLine(line);
35
+ if (!parsed.ok) {
36
+ // Blank lines are legal framing noise, not errors — skip silently.
37
+ if (parsed.error === null)
38
+ continue;
39
+ await writeLine(stdout, encodeMessage(errorResponse(null, JSONRPC_ERRORS.PARSE_ERROR, `parse error: ${parsed.error}`)));
40
+ continue;
41
+ }
42
+ try {
43
+ const response = await handleMessage(parsed.msg, opts.tools, opts.server);
44
+ if (response !== null) {
45
+ await writeLine(stdout, encodeMessage(response));
46
+ }
47
+ }
48
+ catch (err) {
49
+ // `handleMessage` already wraps tool-handler errors into -32603 responses;
50
+ // anything reaching here is a bug in the dispatcher itself. Still, don't
51
+ // let it crash the server — emit a synthetic error response and log.
52
+ const message = err?.message ?? 'internal error';
53
+ const id = 'id' in parsed.msg && parsed.msg.id !== undefined
54
+ ? parsed.msg.id
55
+ : null;
56
+ if (opts.onError)
57
+ opts.onError(err, line);
58
+ else
59
+ stderr.write(`[dario mcp] unhandled: ${message}\n`);
60
+ await writeLine(stdout, encodeMessage(errorResponse(id, JSONRPC_ERRORS.INTERNAL_ERROR, message)));
61
+ }
62
+ }
63
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * MCP tool registry for the dario MCP server (v3.27, direction #4).
3
+ *
4
+ * Each tool wraps an existing dario subsystem that's already covered by
5
+ * its own tests, so these wrappers stay thin — fetch data, format it as
6
+ * text content, return. All tools are read-only. Destructive operations
7
+ * (login/logout, accounts add/remove, backend add/remove, proxy start)
8
+ * are deliberately NOT exposed: an MCP client shouldn't be able to
9
+ * mutate the user's dario state just by being connected, same boundary
10
+ * as the sub-agent prompt (v3.26).
11
+ *
12
+ * `buildToolRegistry` is a factory so tests can inject fake backends for
13
+ * each dario subsystem. In production, `buildDefaultToolRegistry` wires
14
+ * up the real dynamic imports — the imports live inside the factory so
15
+ * `src/mcp/protocol.ts` stays a pure module, decoupled from any of
16
+ * dario's heavier code paths.
17
+ */
18
+ import type { McpTool } from './protocol.js';
19
+ /**
20
+ * Injectable data sources for the tool handlers. Production wiring in
21
+ * `buildDefaultToolRegistry` fills these in with the real dario imports;
22
+ * tests can substitute pure synthetic data to avoid touching network /
23
+ * filesystem / OAuth state.
24
+ */
25
+ export interface ToolDataSources {
26
+ doctor: () => Promise<Array<{
27
+ status: string;
28
+ label: string;
29
+ detail: string;
30
+ }>>;
31
+ status: () => Promise<{
32
+ authenticated: boolean;
33
+ status: string;
34
+ expiresIn?: string;
35
+ canRefresh?: boolean;
36
+ }>;
37
+ accounts: () => Promise<Array<{
38
+ alias: string;
39
+ expiresAt: number;
40
+ }>>;
41
+ backends: () => Promise<Array<{
42
+ name: string;
43
+ baseUrl: string;
44
+ model?: string;
45
+ }>>;
46
+ subagent: () => Promise<{
47
+ installed: boolean;
48
+ path: string;
49
+ fileVersion: string | null;
50
+ current: boolean;
51
+ agentsDirExists: boolean;
52
+ }>;
53
+ fingerprint: () => Promise<{
54
+ runtime: string;
55
+ runtimeVersion: string;
56
+ status: string;
57
+ detail: string;
58
+ templateSource: string;
59
+ templateSchema: number | null;
60
+ }>;
61
+ darioVersion: () => string;
62
+ }
63
+ export declare function buildToolRegistry(data: ToolDataSources): McpTool[];
64
+ /**
65
+ * Default production wiring — imports dario's real subsystems. Kept out
66
+ * of `buildToolRegistry` so the registry factory stays pure over its
67
+ * data sources (and the unit tests don't pay for dynamic imports).
68
+ */
69
+ export declare function buildDefaultToolRegistry(): Promise<McpTool[]>;
@@ -0,0 +1,205 @@
1
+ /**
2
+ * MCP tool registry for the dario MCP server (v3.27, direction #4).
3
+ *
4
+ * Each tool wraps an existing dario subsystem that's already covered by
5
+ * its own tests, so these wrappers stay thin — fetch data, format it as
6
+ * text content, return. All tools are read-only. Destructive operations
7
+ * (login/logout, accounts add/remove, backend add/remove, proxy start)
8
+ * are deliberately NOT exposed: an MCP client shouldn't be able to
9
+ * mutate the user's dario state just by being connected, same boundary
10
+ * as the sub-agent prompt (v3.26).
11
+ *
12
+ * `buildToolRegistry` is a factory so tests can inject fake backends for
13
+ * each dario subsystem. In production, `buildDefaultToolRegistry` wires
14
+ * up the real dynamic imports — the imports live inside the factory so
15
+ * `src/mcp/protocol.ts` stays a pure module, decoupled from any of
16
+ * dario's heavier code paths.
17
+ */
18
+ function textResult(text, isError = false) {
19
+ return {
20
+ content: [{ type: 'text', text }],
21
+ ...(isError ? { isError: true } : {}),
22
+ };
23
+ }
24
+ export function buildToolRegistry(data) {
25
+ const emptyObjectSchema = { type: 'object', properties: {}, required: [] };
26
+ return [
27
+ {
28
+ name: 'doctor',
29
+ description: 'Run dario\'s health-report checks and return the formatted output. Covers: dario version, Node, platform, runtime TLS fingerprint, CC binary + compat, template source + drift, OAuth state, account pool, backends, CC sub-agent install. No side effects.',
30
+ inputSchema: emptyObjectSchema,
31
+ handler: async () => {
32
+ const checks = await data.doctor();
33
+ if (checks.length === 0)
34
+ return textResult('No checks produced output.');
35
+ const labelWidth = checks.reduce((n, c) => Math.max(n, c.label.length), 0);
36
+ const prefix = {
37
+ ok: '[ OK ]', warn: '[WARN]', fail: '[FAIL]', info: '[INFO]',
38
+ };
39
+ const lines = checks.map((c) => `${prefix[c.status] ?? '[????]'} ${c.label.padEnd(labelWidth)} ${c.detail}`);
40
+ const failed = checks.filter((c) => c.status === 'fail').length;
41
+ const warned = checks.filter((c) => c.status === 'warn').length;
42
+ const summary = `\n\nSummary: ${checks.length} checks — ${failed} fail, ${warned} warn`;
43
+ return textResult(lines.join('\n') + summary);
44
+ },
45
+ },
46
+ {
47
+ name: 'status',
48
+ description: 'Report the dario OAuth authentication status: whether credentials are present, valid, and when they expire. Read-only.',
49
+ inputSchema: emptyObjectSchema,
50
+ handler: async () => {
51
+ const s = await data.status();
52
+ if (!s.authenticated) {
53
+ const detail = s.status === 'none'
54
+ ? 'No credentials — run `dario login`.'
55
+ : s.status === 'expired' && s.canRefresh
56
+ ? 'Credentials expired but refreshable — run `dario refresh` or `dario proxy`.'
57
+ : `Not authenticated (status: ${s.status}).`;
58
+ return textResult(`Authenticated: no\n${detail}`);
59
+ }
60
+ return textResult(`Authenticated: yes\nStatus: ${s.status}\nExpires in: ${s.expiresIn ?? 'unknown'}`);
61
+ },
62
+ },
63
+ {
64
+ name: 'accounts_list',
65
+ description: 'List the accounts configured in dario\'s multi-account pool (~/.dario/accounts/). Returns alias + token expiry per account. Read-only.',
66
+ inputSchema: emptyObjectSchema,
67
+ handler: async () => {
68
+ const accounts = await data.accounts();
69
+ if (accounts.length === 0) {
70
+ return textResult('No pool accounts configured. dario runs in single-account mode from ~/.dario/credentials.json.');
71
+ }
72
+ const now = Date.now();
73
+ const lines = accounts.map((a) => {
74
+ const msLeft = Math.max(0, a.expiresAt - now);
75
+ const hours = Math.floor(msLeft / 3600000);
76
+ const mins = Math.floor((msLeft % 3600000) / 60000);
77
+ const expiry = msLeft > 0 ? `${hours}h ${mins}m` : 'expired';
78
+ return ` ${a.alias.padEnd(20)} token expires in ${expiry}`;
79
+ });
80
+ const note = accounts.length < 2
81
+ ? '\n\nPool mode activates at 2+ accounts — currently single-account.'
82
+ : '';
83
+ return textResult(`${accounts.length} account${accounts.length === 1 ? '' : 's'}:\n${lines.join('\n')}${note}`);
84
+ },
85
+ },
86
+ {
87
+ name: 'backends_list',
88
+ description: 'List configured OpenAI-compat backends (OpenAI, OpenRouter, Groq, LiteLLM, Ollama, etc.). Read-only — does not expose API keys.',
89
+ inputSchema: emptyObjectSchema,
90
+ handler: async () => {
91
+ const backends = await data.backends();
92
+ if (backends.length === 0) {
93
+ return textResult('No OpenAI-compat backends configured. Claude subscription is the only route.');
94
+ }
95
+ const lines = backends.map((b) => ` ${b.name.padEnd(20)} ${b.baseUrl}${b.model ? ` (default model: ${b.model})` : ''}`);
96
+ return textResult(`${backends.length} backend${backends.length === 1 ? '' : 's'}:\n${lines.join('\n')}`);
97
+ },
98
+ },
99
+ {
100
+ name: 'subagent_status',
101
+ description: 'Report whether the dario CC sub-agent (~/.claude/agents/dario.md) is installed and whether it matches the running dario version. Read-only.',
102
+ inputSchema: emptyObjectSchema,
103
+ handler: async () => {
104
+ const s = await data.subagent();
105
+ const lines = [];
106
+ lines.push(`Path: ${s.path}`);
107
+ lines.push(`~/.claude/agents exists: ${s.agentsDirExists ? 'yes' : 'no'}`);
108
+ lines.push(`Installed: ${s.installed ? `yes (v${s.fileVersion ?? 'unknown'})` : 'no'}`);
109
+ if (s.installed && !s.current) {
110
+ lines.push('Note: file version does not match running dario — run `dario subagent install` to refresh.');
111
+ }
112
+ if (!s.installed && s.agentsDirExists) {
113
+ lines.push('Install with: `dario subagent install`.');
114
+ }
115
+ return textResult(lines.join('\n'));
116
+ },
117
+ },
118
+ {
119
+ name: 'fingerprint_info',
120
+ description: 'Report dario\'s runtime / TLS fingerprint state: whether the proxy is running under Bun (matches CC\'s TLS stack) or Node (diverges), which template source is active (live-captured vs bundled), and the template schema version. Read-only.',
121
+ inputSchema: emptyObjectSchema,
122
+ handler: async () => {
123
+ const f = await data.fingerprint();
124
+ const lines = [];
125
+ lines.push(`Runtime: ${f.runtime} ${f.runtimeVersion}`);
126
+ lines.push(`TLS status: ${f.status}`);
127
+ lines.push(`TLS detail: ${f.detail}`);
128
+ lines.push(`Template source: ${f.templateSource}`);
129
+ lines.push(`Template schema: v${f.templateSchema ?? '?'}`);
130
+ lines.push(`dario version: ${data.darioVersion()}`);
131
+ return textResult(lines.join('\n'));
132
+ },
133
+ },
134
+ ];
135
+ }
136
+ /**
137
+ * Default production wiring — imports dario's real subsystems. Kept out
138
+ * of `buildToolRegistry` so the registry factory stays pure over its
139
+ * data sources (and the unit tests don't pay for dynamic imports).
140
+ */
141
+ export async function buildDefaultToolRegistry() {
142
+ const [doctorMod, oauthMod, accountsMod, backendMod, subagentMod, runtimeMod, templateMod, pkgVersion] = await Promise.all([
143
+ import('../doctor.js'),
144
+ import('../oauth.js'),
145
+ import('../accounts.js'),
146
+ import('../openai-backend.js'),
147
+ import('../subagent.js'),
148
+ import('../runtime-fingerprint.js'),
149
+ import('../cc-template.js'),
150
+ readDarioVersion(),
151
+ ]);
152
+ return buildToolRegistry({
153
+ doctor: async () => {
154
+ const checks = await doctorMod.runChecks();
155
+ return checks.map((c) => ({
156
+ status: c.status,
157
+ label: c.label,
158
+ detail: c.detail,
159
+ }));
160
+ },
161
+ status: async () => oauthMod.getStatus(),
162
+ accounts: async () => {
163
+ const loaded = await accountsMod.loadAllAccounts();
164
+ return loaded.map((a) => ({
165
+ alias: a.alias,
166
+ expiresAt: a.expiresAt,
167
+ }));
168
+ },
169
+ backends: async () => {
170
+ const backends = await backendMod.listBackends();
171
+ return backends.map((b) => ({
172
+ name: b.name,
173
+ baseUrl: b.baseUrl,
174
+ model: b.model,
175
+ }));
176
+ },
177
+ subagent: async () => subagentMod.loadSubagentStatus(),
178
+ fingerprint: async () => {
179
+ const rt = runtimeMod.detectRuntimeFingerprint();
180
+ const tmpl = templateMod.CC_TEMPLATE;
181
+ return {
182
+ runtime: rt.runtime,
183
+ runtimeVersion: rt.runtimeVersion,
184
+ status: rt.status,
185
+ detail: rt.detail,
186
+ templateSource: tmpl._source ?? 'unknown',
187
+ templateSchema: tmpl._schemaVersion ?? null,
188
+ };
189
+ },
190
+ darioVersion: () => pkgVersion,
191
+ });
192
+ }
193
+ async function readDarioVersion() {
194
+ try {
195
+ const { readFileSync } = await import('node:fs');
196
+ const { fileURLToPath } = await import('node:url');
197
+ const { dirname, join } = await import('node:path');
198
+ const here = dirname(fileURLToPath(import.meta.url));
199
+ const pkg = JSON.parse(readFileSync(join(here, '..', '..', 'package.json'), 'utf-8'));
200
+ return typeof pkg.version === 'string' ? pkg.version : 'unknown';
201
+ }
202
+ catch {
203
+ return 'unknown';
204
+ }
205
+ }
package/dist/proxy.d.ts CHANGED
@@ -16,6 +16,10 @@ interface ProxyOptions {
16
16
  pacingMinMs?: number;
17
17
  pacingJitterMs?: number;
18
18
  drainOnClose?: boolean;
19
+ sessionIdleRotateMs?: number;
20
+ sessionRotateJitterMs?: number;
21
+ sessionMaxAgeMs?: number;
22
+ sessionPerClient?: boolean;
19
23
  }
20
24
  export declare function sanitizeError(err: unknown): string;
21
25
  export declare function startProxy(opts?: ProxyOptions): Promise<void>;
package/dist/proxy.js CHANGED
@@ -97,11 +97,17 @@ function extractFirstUserMessage(body) {
97
97
  //
98
98
  // v3.19 keeps the id stable through a conversation window and rotates
99
99
  // only after an idle gap long enough to credibly indicate a new
100
- // conversation (SESSION_IDLE_ROTATE_MS). Pool mode still uses the
101
- // per-account identity.sessionId (stable across the account's lifetime).
100
+ // conversation. Pool mode still uses the per-account identity.sessionId
101
+ // (stable across the account's lifetime).
102
+ //
103
+ // v3.28 generalises the single hardcoded 15-min window into a tunable
104
+ // registry (see src/session-rotation.ts) with optional jitter, max-age,
105
+ // and per-client keying. SESSION_ID below is kept only as a mirror of
106
+ // the default single-account session so out-of-band consumers (presence
107
+ // ping, diagnostic logs) can read the most recent id without going
108
+ // through the registry. It's refreshed after every dispatch-path call
109
+ // that assigns a new id.
102
110
  let SESSION_ID = randomUUID();
103
- let SESSION_LAST_USED = 0;
104
- const SESSION_IDLE_ROTATE_MS = 15 * 60 * 1000;
105
111
  const OS_NAME = platform === 'win32' ? 'Windows' : platform === 'darwin' ? 'MacOS' : 'Linux';
106
112
  // Claude Code device identity — required for Max plan billing classification.
107
113
  // Without metadata.user_id, Anthropic classifies requests as third-party and
@@ -593,6 +599,22 @@ export async function startProxy(opts = {}) {
593
599
  if (verbose) {
594
600
  console.log(`[dario] drain-on-close: ${drainOnClose ? 'enabled' : 'disabled'}`);
595
601
  }
602
+ // Session-ID lifecycle (v3.28, direction #1). Replaces the v3.27 hardcoded
603
+ // 15-minute idle window with a tunable registry: idle threshold, jitter on
604
+ // that threshold, optional hard max-age, and optional per-client keying.
605
+ // Defaults preserve v3.27 behavior exactly. See src/session-rotation.ts.
606
+ const { SessionRegistry, resolveSessionRotationConfig } = await import('./session-rotation.js');
607
+ const sessionCfg = resolveSessionRotationConfig({
608
+ idleRotateMs: opts.sessionIdleRotateMs,
609
+ jitterMs: opts.sessionRotateJitterMs,
610
+ maxAgeMs: opts.sessionMaxAgeMs,
611
+ perClient: opts.sessionPerClient,
612
+ });
613
+ const sessionRegistry = new SessionRegistry(sessionCfg, () => randomUUID());
614
+ if (verbose) {
615
+ const maxAge = sessionCfg.maxAgeMs !== undefined ? `${sessionCfg.maxAgeMs}ms` : 'off';
616
+ console.log(`[dario] session: idle=${sessionCfg.idleRotateMs}ms jitter=${sessionCfg.jitterMs}ms maxAge=${maxAge} perClient=${sessionCfg.perClient}`);
617
+ }
596
618
  // Optional proxy authentication — pre-encode key buffer for performance
597
619
  const apiKey = process.env.DARIO_API_KEY;
598
620
  const apiKeyBuf = apiKey ? Buffer.from(apiKey) : null;
@@ -958,6 +980,9 @@ export async function startProxy(opts = {}) {
958
980
  // selection toward one we already paid cache cost on — passthrough
959
981
  // users aren't doing template replay anyway).
960
982
  let stickyKey = null;
983
+ // Outbound session id resolved once — either inside the template build
984
+ // (so body metadata matches) or below for passthrough (no body build).
985
+ let preBodySessionId;
961
986
  // Request context for hybrid-mode field injection (#33). Built once
962
987
  // per request from incoming headers so the reverse mapper can fill
963
988
  // client-declared fields like `sessionId` that CC's schema doesn't
@@ -1010,9 +1035,28 @@ export async function startProxy(opts = {}) {
1010
1035
  }
1011
1036
  }
1012
1037
  }
1038
+ // Resolve the outbound session id before the body build so the
1039
+ // metadata.session_id in the CC body and the x-claude-code-session-id
1040
+ // header both use the same value. v3.27 consulted SESSION_ID twice
1041
+ // with rotation between the reads, so on rotation events body and
1042
+ // header disagreed — harmless for plain operation but a fingerprint
1043
+ // in its own right.
1044
+ if (poolAccount) {
1045
+ preBodySessionId = poolAccount.identity.sessionId;
1046
+ }
1047
+ else {
1048
+ const clientKey = req.headers['x-session-id']
1049
+ ?? req.headers['x-client-session-id'];
1050
+ const assigned = sessionRegistry.getOrCreate(clientKey, Date.now());
1051
+ preBodySessionId = assigned.sessionId;
1052
+ SESSION_ID = assigned.sessionId;
1053
+ if (verbose && assigned.rotated && assigned.reason !== 'rotate-new') {
1054
+ console.log(`[dario] #${requestCount} session: rotate (${assigned.reason})`);
1055
+ }
1056
+ }
1013
1057
  const bodyIdentity = poolAccount
1014
1058
  ? poolAccount.identity
1015
- : { deviceId: identity.deviceId, accountUuid: identity.accountUuid, sessionId: SESSION_ID };
1059
+ : { deviceId: identity.deviceId, accountUuid: identity.accountUuid, sessionId: preBodySessionId };
1016
1060
  const { body: ccBody, toolMap, detectedClient } = buildCCRequest(r, billingTag, CACHE_1H, bodyIdentity, {
1017
1061
  preserveTools: opts.preserveTools ?? false,
1018
1062
  hybridTools: opts.hybridTools ?? false,
@@ -1102,18 +1146,30 @@ export async function startProxy(opts = {}) {
1102
1146
  }
1103
1147
  lastRequestTime = Date.now();
1104
1148
  // Session ID: pool mode uses the per-account identity.sessionId (stable
1105
- // per account). Single-account mode keeps SESSION_ID stable through
1106
- // active conversations and rotates only after an idle gap that looks
1107
- // like a new conversation matches CC's observed cadence (see note
1108
- // at SESSION_ID declaration).
1109
- if (!poolAccount) {
1110
- const nowTs = Date.now();
1111
- if (SESSION_LAST_USED === 0 || nowTs - SESSION_LAST_USED > SESSION_IDLE_ROTATE_MS) {
1112
- SESSION_ID = randomUUID();
1149
+ // per account). Single-account mode delegates to the session registry
1150
+ // (src/session-rotation.ts) which applies the configured idle / jitter /
1151
+ // max-age / per-client policy. Resolution happens earlier, at body-build
1152
+ // time, so the CC body's metadata.session_id and the outbound
1153
+ // x-claude-code-session-id header always agree. preBodySessionId holds
1154
+ // the template-build value; in passthrough mode (no template build)
1155
+ // the registry is consulted here instead.
1156
+ let outboundSessionId;
1157
+ if (poolAccount) {
1158
+ outboundSessionId = poolAccount.identity.sessionId;
1159
+ }
1160
+ else if (preBodySessionId !== undefined) {
1161
+ outboundSessionId = preBodySessionId;
1162
+ }
1163
+ else {
1164
+ const clientKey = req.headers['x-session-id']
1165
+ ?? req.headers['x-client-session-id'];
1166
+ const assigned = sessionRegistry.getOrCreate(clientKey, Date.now());
1167
+ outboundSessionId = assigned.sessionId;
1168
+ SESSION_ID = assigned.sessionId;
1169
+ if (verbose && assigned.rotated && assigned.reason !== 'rotate-new') {
1170
+ console.log(`[dario] #${requestCount} session: rotate (${assigned.reason})`);
1113
1171
  }
1114
- SESSION_LAST_USED = nowTs;
1115
1172
  }
1116
- const outboundSessionId = poolAccount ? poolAccount.identity.sessionId : SESSION_ID;
1117
1173
  const headers = {
1118
1174
  ...staticHeaders,
1119
1175
  'Authorization': `Bearer ${accessToken}`,
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Session-ID lifecycle (v3.28, direction #1 — interactive-side rotation).
3
+ *
4
+ * Every outbound request to Anthropic carries a session identifier in the
5
+ * CC request body's metadata. Real Claude Code holds that id stable through
6
+ * a conversation and mints a new one when the user returns after an idle
7
+ * gap — roughly "one id per conversation", not per HTTP call. A proxy that
8
+ * rotates per-request looks synthetic; one that never rotates looks equally
9
+ * synthetic over long sessions. v3.19 tightened the per-request leak into a
10
+ * single hardcoded 15-minute idle window; this module generalises that into
11
+ * a registry so operators can tune the behaviour and so the multi-client
12
+ * case (dario fanning multiple UIs through one proxy) stops sharing one id.
13
+ *
14
+ * Three independent knobs:
15
+ *
16
+ * idleRotateMs — the v3.19 behaviour: rotate after this many ms of no
17
+ * traffic on a given session. Default 15 min preserves
18
+ * v3.27 exactly when the other knobs stay at defaults.
19
+ *
20
+ * jitterMs — the observable idle threshold for a given session is
21
+ * idleRotateMs + U(0, jitterMs), sampled once at session
22
+ * creation. A zero-jitter proxy rotates at exactly the
23
+ * same interval every time; adding jitter means the floor
24
+ * can't be inferred from long-run rotation cadence.
25
+ *
26
+ * maxAgeMs — hard cap on a session's total lifetime regardless of
27
+ * activity. Optional (undefined disables). A chatty
28
+ * always-on pipeline would otherwise keep one session id
29
+ * alive for days; real CC conversations don't.
30
+ *
31
+ * perClient — when true, the registry keys sessions by the caller's
32
+ * `x-session-id` / `x-client-session-id` header so two
33
+ * upstream UIs talking to one dario don't collapse onto
34
+ * a single session id. Default false preserves v3.27
35
+ * single-account semantics.
36
+ *
37
+ * Pure logic (decideSessionRotation) is separated from the stateful cache
38
+ * (SessionRegistry) so tests can walk every decision branch without Maps,
39
+ * timers, or UUID sources. The proxy injects a `() => string` id factory
40
+ * (randomUUID) and `() => number` rng so both are swappable in tests.
41
+ *
42
+ * Pool mode is unaffected — each account carries a stable identity.sessionId
43
+ * for its lifetime, and the caller doesn't consult this registry. This
44
+ * module only governs the single-account SESSION_ID slot.
45
+ */
46
+ export interface SessionRotationConfig {
47
+ /** Idle threshold in ms: if no traffic for this long, the session rotates on the next request. */
48
+ idleRotateMs: number;
49
+ /** Max additional uniform-random ms added to the idle threshold at session creation. Pass 0 to disable. */
50
+ jitterMs: number;
51
+ /** Optional hard cap on session lifetime in ms. Undefined = no cap. */
52
+ maxAgeMs?: number;
53
+ /** When true, key sessions by client header so multiple upstreams get distinct ids. Default false. */
54
+ perClient: boolean;
55
+ }
56
+ export interface SessionEntry {
57
+ /** The session id sent to Anthropic in the outbound body. */
58
+ upstreamSessionId: string;
59
+ /** Wall-clock creation time (ms since epoch). */
60
+ createdAt: number;
61
+ /** Wall-clock time of last outbound use (ms since epoch). */
62
+ lastUsedAt: number;
63
+ /** Jitter offset sampled once at creation; added to cfg.idleRotateMs to get this session's effective idle threshold. */
64
+ idleJitterOffsetMs: number;
65
+ }
66
+ export type RotationDecision = 'keep' | 'rotate-new' | 'rotate-idle' | 'rotate-age';
67
+ /**
68
+ * Pure decision: should the given entry be rotated at `now`?
69
+ *
70
+ * Returns 'rotate-new' when no entry exists yet (first use for this key).
71
+ * Returns 'rotate-idle' when traffic has been silent for longer than this
72
+ * entry's sampled threshold. Returns 'rotate-age' when the entry's
73
+ * absolute lifetime exceeds cfg.maxAgeMs (when set). Otherwise 'keep'.
74
+ *
75
+ * Idle is checked before age so an idle-but-young session rotates on a
76
+ * fresh conversation boundary rather than churning mid-conversation at
77
+ * exactly its max-age. Negative config values are clamped to 0 (lenient:
78
+ * a typoed flag should behave like "rotate eagerly", not crash startup).
79
+ */
80
+ export declare function decideSessionRotation(entry: SessionEntry | undefined, now: number, cfg: SessionRotationConfig): RotationDecision;
81
+ /** Result of SessionRegistry.getOrCreate — both the id to send and why it was chosen. */
82
+ export interface RegistryResult {
83
+ sessionId: string;
84
+ rotated: boolean;
85
+ reason: RotationDecision;
86
+ }
87
+ /**
88
+ * Per-client session cache with rotation + LRU eviction.
89
+ *
90
+ * Not concurrency-safe — the proxy's dispatch loop is single-threaded
91
+ * JavaScript and call sites are serialized by the event loop. The
92
+ * registry is intentionally a plain Map, not a TTL cache, because
93
+ * rotation timing is part of the observable behaviour we're modelling
94
+ * and a background sweeper would add a separate dimension (WHEN entries
95
+ * disappear) that doesn't exist in a real CC client.
96
+ *
97
+ * maxEntries defaults to 1024 — more than enough for any reasonable
98
+ * fan-out while capping memory growth against a pathological client
99
+ * that sends a fresh session header on every request.
100
+ */
101
+ export declare class SessionRegistry {
102
+ private readonly cfg;
103
+ private readonly newId;
104
+ private readonly rng;
105
+ private readonly maxEntries;
106
+ private readonly entries;
107
+ constructor(cfg: SessionRotationConfig, newId: () => string, rng?: () => number, maxEntries?: number);
108
+ /**
109
+ * Resolve the outbound session id for a given client key at time `now`.
110
+ *
111
+ * `clientKey` is the caller-side session header when cfg.perClient is
112
+ * true, and ignored (replaced with 'default') when perClient is false.
113
+ * Callers pass the raw header value and let the registry decide —
114
+ * otherwise flipping perClient at runtime would require threading
115
+ * the decision to every call site.
116
+ *
117
+ * Updates lastUsedAt on the entry (whether kept or freshly minted),
118
+ * and nudges the entry to the end of the insertion-order map so
119
+ * eviction under maxEntries pressure is LRU.
120
+ */
121
+ getOrCreate(clientKey: string | undefined, now: number): RegistryResult;
122
+ /**
123
+ * Read the current id for a client key without touching lastUsedAt.
124
+ *
125
+ * Used by out-of-band consumers (e.g. presence pings) that want to
126
+ * reflect the most recently assigned session id but must not count
127
+ * as activity for rotation purposes. Returns undefined if no entry.
128
+ */
129
+ peek(clientKey: string | undefined): string | undefined;
130
+ size(): number;
131
+ clear(): void;
132
+ private evictIfOverCap;
133
+ }
134
+ /**
135
+ * Resolve a SessionRotationConfig from explicit options, env vars, and defaults.
136
+ *
137
+ * Precedence (highest first):
138
+ * 1. Explicit argument (typically from CLI flag)
139
+ * 2. DARIO_SESSION_IDLE_ROTATE_MS / DARIO_SESSION_JITTER_MS /
140
+ * DARIO_SESSION_MAX_AGE_MS / DARIO_SESSION_PER_CLIENT env vars
141
+ * 3. Defaults: idleRotateMs=15min, jitterMs=0, maxAgeMs=undefined,
142
+ * perClient=false — exactly matches the hardcoded v3.27 behaviour.
143
+ *
144
+ * Invalid numeric strings fall through to the next source. For perClient,
145
+ * '1' / 'true' / 'yes' (case-insensitive) enable; anything else stays at
146
+ * the explicit or default value.
147
+ */
148
+ export declare function resolveSessionRotationConfig(explicit?: {
149
+ idleRotateMs?: number;
150
+ jitterMs?: number;
151
+ maxAgeMs?: number;
152
+ perClient?: boolean;
153
+ }, env?: NodeJS.ProcessEnv): SessionRotationConfig;
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Session-ID lifecycle (v3.28, direction #1 — interactive-side rotation).
3
+ *
4
+ * Every outbound request to Anthropic carries a session identifier in the
5
+ * CC request body's metadata. Real Claude Code holds that id stable through
6
+ * a conversation and mints a new one when the user returns after an idle
7
+ * gap — roughly "one id per conversation", not per HTTP call. A proxy that
8
+ * rotates per-request looks synthetic; one that never rotates looks equally
9
+ * synthetic over long sessions. v3.19 tightened the per-request leak into a
10
+ * single hardcoded 15-minute idle window; this module generalises that into
11
+ * a registry so operators can tune the behaviour and so the multi-client
12
+ * case (dario fanning multiple UIs through one proxy) stops sharing one id.
13
+ *
14
+ * Three independent knobs:
15
+ *
16
+ * idleRotateMs — the v3.19 behaviour: rotate after this many ms of no
17
+ * traffic on a given session. Default 15 min preserves
18
+ * v3.27 exactly when the other knobs stay at defaults.
19
+ *
20
+ * jitterMs — the observable idle threshold for a given session is
21
+ * idleRotateMs + U(0, jitterMs), sampled once at session
22
+ * creation. A zero-jitter proxy rotates at exactly the
23
+ * same interval every time; adding jitter means the floor
24
+ * can't be inferred from long-run rotation cadence.
25
+ *
26
+ * maxAgeMs — hard cap on a session's total lifetime regardless of
27
+ * activity. Optional (undefined disables). A chatty
28
+ * always-on pipeline would otherwise keep one session id
29
+ * alive for days; real CC conversations don't.
30
+ *
31
+ * perClient — when true, the registry keys sessions by the caller's
32
+ * `x-session-id` / `x-client-session-id` header so two
33
+ * upstream UIs talking to one dario don't collapse onto
34
+ * a single session id. Default false preserves v3.27
35
+ * single-account semantics.
36
+ *
37
+ * Pure logic (decideSessionRotation) is separated from the stateful cache
38
+ * (SessionRegistry) so tests can walk every decision branch without Maps,
39
+ * timers, or UUID sources. The proxy injects a `() => string` id factory
40
+ * (randomUUID) and `() => number` rng so both are swappable in tests.
41
+ *
42
+ * Pool mode is unaffected — each account carries a stable identity.sessionId
43
+ * for its lifetime, and the caller doesn't consult this registry. This
44
+ * module only governs the single-account SESSION_ID slot.
45
+ */
46
+ /**
47
+ * Pure decision: should the given entry be rotated at `now`?
48
+ *
49
+ * Returns 'rotate-new' when no entry exists yet (first use for this key).
50
+ * Returns 'rotate-idle' when traffic has been silent for longer than this
51
+ * entry's sampled threshold. Returns 'rotate-age' when the entry's
52
+ * absolute lifetime exceeds cfg.maxAgeMs (when set). Otherwise 'keep'.
53
+ *
54
+ * Idle is checked before age so an idle-but-young session rotates on a
55
+ * fresh conversation boundary rather than churning mid-conversation at
56
+ * exactly its max-age. Negative config values are clamped to 0 (lenient:
57
+ * a typoed flag should behave like "rotate eagerly", not crash startup).
58
+ */
59
+ export function decideSessionRotation(entry, now, cfg) {
60
+ if (!entry)
61
+ return 'rotate-new';
62
+ const idleBase = Math.max(0, cfg.idleRotateMs);
63
+ const idleThreshold = idleBase + Math.max(0, entry.idleJitterOffsetMs);
64
+ if (now - entry.lastUsedAt > idleThreshold)
65
+ return 'rotate-idle';
66
+ if (cfg.maxAgeMs !== undefined && cfg.maxAgeMs > 0 && now - entry.createdAt > cfg.maxAgeMs) {
67
+ return 'rotate-age';
68
+ }
69
+ return 'keep';
70
+ }
71
+ /**
72
+ * Per-client session cache with rotation + LRU eviction.
73
+ *
74
+ * Not concurrency-safe — the proxy's dispatch loop is single-threaded
75
+ * JavaScript and call sites are serialized by the event loop. The
76
+ * registry is intentionally a plain Map, not a TTL cache, because
77
+ * rotation timing is part of the observable behaviour we're modelling
78
+ * and a background sweeper would add a separate dimension (WHEN entries
79
+ * disappear) that doesn't exist in a real CC client.
80
+ *
81
+ * maxEntries defaults to 1024 — more than enough for any reasonable
82
+ * fan-out while capping memory growth against a pathological client
83
+ * that sends a fresh session header on every request.
84
+ */
85
+ export class SessionRegistry {
86
+ cfg;
87
+ newId;
88
+ rng;
89
+ maxEntries;
90
+ entries = new Map();
91
+ constructor(cfg, newId, rng = Math.random, maxEntries = 1024) {
92
+ this.cfg = cfg;
93
+ this.newId = newId;
94
+ this.rng = rng;
95
+ this.maxEntries = maxEntries;
96
+ }
97
+ /**
98
+ * Resolve the outbound session id for a given client key at time `now`.
99
+ *
100
+ * `clientKey` is the caller-side session header when cfg.perClient is
101
+ * true, and ignored (replaced with 'default') when perClient is false.
102
+ * Callers pass the raw header value and let the registry decide —
103
+ * otherwise flipping perClient at runtime would require threading
104
+ * the decision to every call site.
105
+ *
106
+ * Updates lastUsedAt on the entry (whether kept or freshly minted),
107
+ * and nudges the entry to the end of the insertion-order map so
108
+ * eviction under maxEntries pressure is LRU.
109
+ */
110
+ getOrCreate(clientKey, now) {
111
+ const key = this.cfg.perClient ? (clientKey && clientKey.length > 0 ? clientKey : 'default') : 'default';
112
+ const existing = this.entries.get(key);
113
+ const decision = decideSessionRotation(existing, now, this.cfg);
114
+ if (decision === 'keep' && existing) {
115
+ existing.lastUsedAt = now;
116
+ // Re-insert to refresh LRU position.
117
+ this.entries.delete(key);
118
+ this.entries.set(key, existing);
119
+ return { sessionId: existing.upstreamSessionId, rotated: false, reason: 'keep' };
120
+ }
121
+ const jitterOffset = this.cfg.jitterMs > 0 ? Math.floor(this.rng() * this.cfg.jitterMs) : 0;
122
+ const entry = {
123
+ upstreamSessionId: this.newId(),
124
+ createdAt: now,
125
+ lastUsedAt: now,
126
+ idleJitterOffsetMs: jitterOffset,
127
+ };
128
+ this.entries.set(key, entry);
129
+ this.evictIfOverCap();
130
+ return { sessionId: entry.upstreamSessionId, rotated: true, reason: decision };
131
+ }
132
+ /**
133
+ * Read the current id for a client key without touching lastUsedAt.
134
+ *
135
+ * Used by out-of-band consumers (e.g. presence pings) that want to
136
+ * reflect the most recently assigned session id but must not count
137
+ * as activity for rotation purposes. Returns undefined if no entry.
138
+ */
139
+ peek(clientKey) {
140
+ const key = this.cfg.perClient ? (clientKey && clientKey.length > 0 ? clientKey : 'default') : 'default';
141
+ return this.entries.get(key)?.upstreamSessionId;
142
+ }
143
+ size() {
144
+ return this.entries.size;
145
+ }
146
+ clear() {
147
+ this.entries.clear();
148
+ }
149
+ evictIfOverCap() {
150
+ while (this.entries.size > this.maxEntries) {
151
+ const oldest = this.entries.keys().next().value;
152
+ if (oldest === undefined)
153
+ break;
154
+ this.entries.delete(oldest);
155
+ }
156
+ }
157
+ }
158
+ /**
159
+ * Resolve a SessionRotationConfig from explicit options, env vars, and defaults.
160
+ *
161
+ * Precedence (highest first):
162
+ * 1. Explicit argument (typically from CLI flag)
163
+ * 2. DARIO_SESSION_IDLE_ROTATE_MS / DARIO_SESSION_JITTER_MS /
164
+ * DARIO_SESSION_MAX_AGE_MS / DARIO_SESSION_PER_CLIENT env vars
165
+ * 3. Defaults: idleRotateMs=15min, jitterMs=0, maxAgeMs=undefined,
166
+ * perClient=false — exactly matches the hardcoded v3.27 behaviour.
167
+ *
168
+ * Invalid numeric strings fall through to the next source. For perClient,
169
+ * '1' / 'true' / 'yes' (case-insensitive) enable; anything else stays at
170
+ * the explicit or default value.
171
+ */
172
+ export function resolveSessionRotationConfig(explicit = {}, env = process.env) {
173
+ const idleRotateMs = pickNonNegativeInt(explicit.idleRotateMs, env.DARIO_SESSION_IDLE_ROTATE_MS) ?? 15 * 60 * 1000;
174
+ const jitterMs = pickNonNegativeInt(explicit.jitterMs, env.DARIO_SESSION_JITTER_MS) ?? 0;
175
+ const maxAgeMs = pickPositiveInt(explicit.maxAgeMs, env.DARIO_SESSION_MAX_AGE_MS);
176
+ const perClient = pickBool(explicit.perClient, env.DARIO_SESSION_PER_CLIENT) ?? false;
177
+ return { idleRotateMs, jitterMs, maxAgeMs, perClient };
178
+ }
179
+ function pickNonNegativeInt(...candidates) {
180
+ for (const c of candidates) {
181
+ if (c === undefined || c === null || c === '')
182
+ continue;
183
+ const n = typeof c === 'number' ? c : parseInt(c, 10);
184
+ if (Number.isFinite(n) && n >= 0)
185
+ return Math.floor(n);
186
+ }
187
+ return undefined;
188
+ }
189
+ function pickPositiveInt(...candidates) {
190
+ for (const c of candidates) {
191
+ if (c === undefined || c === null || c === '')
192
+ continue;
193
+ const n = typeof c === 'number' ? c : parseInt(c, 10);
194
+ if (Number.isFinite(n) && n > 0)
195
+ return Math.floor(n);
196
+ }
197
+ return undefined;
198
+ }
199
+ function pickBool(...candidates) {
200
+ for (const c of candidates) {
201
+ if (c === undefined || c === null)
202
+ continue;
203
+ if (typeof c === 'boolean')
204
+ return c;
205
+ const s = c.trim().toLowerCase();
206
+ if (s === '')
207
+ continue;
208
+ if (s === '1' || s === 'true' || s === 'yes' || s === 'on')
209
+ return true;
210
+ if (s === '0' || s === 'false' || s === 'no' || s === 'off')
211
+ return false;
212
+ }
213
+ return undefined;
214
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askalf/dario",
3
- "version": "3.26.0",
3
+ "version": "3.28.0",
4
4
  "description": "A local LLM router. One endpoint, every provider — Claude subscriptions, OpenAI, OpenRouter, Groq, local LiteLLM, any OpenAI-compat endpoint — your tools don't need to change.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -21,7 +21,7 @@
21
21
  ],
22
22
  "scripts": {
23
23
  "build": "tsc && cp src/cc-template-data.json dist/ && node -e \"require('fs').mkdirSync('dist/shim',{recursive:true})\" && cp src/shim/runtime.cjs dist/shim/",
24
- "test": "node test/issue-29-tool-translation.mjs && node test/hybrid-tools.mjs && node test/tool-schema-contract.mjs && node test/scrub-paths.mjs && node test/provider-prefix.mjs && node test/analytics-recording.mjs && node test/analytics-billing-bucket.mjs && node test/failover-429.mjs && node test/pool-sticky.mjs && node test/sealed-pool.mjs && node test/live-fingerprint.mjs && node test/shim-runtime.mjs && node test/shim-e2e.mjs && node test/proxy-header-order.mjs && node test/proxy-body-order.mjs && node test/runtime-fingerprint.mjs && node test/pacing.mjs && node test/stream-drain.mjs && node test/subagent.mjs && node test/drift-detection.mjs && node test/compat-range.mjs && node test/doctor-formatter.mjs && node test/atomic-write.mjs && node test/account-refresh-singleflight.mjs && node test/streaming-edge-cases.mjs && node test/client-detection.mjs && node test/manual-oauth-flow.mjs && node test/scrub-template.mjs",
24
+ "test": "node test/issue-29-tool-translation.mjs && node test/hybrid-tools.mjs && node test/tool-schema-contract.mjs && node test/scrub-paths.mjs && node test/provider-prefix.mjs && node test/analytics-recording.mjs && node test/analytics-billing-bucket.mjs && node test/failover-429.mjs && node test/pool-sticky.mjs && node test/sealed-pool.mjs && node test/live-fingerprint.mjs && node test/shim-runtime.mjs && node test/shim-e2e.mjs && node test/proxy-header-order.mjs && node test/proxy-body-order.mjs && node test/runtime-fingerprint.mjs && node test/pacing.mjs && node test/stream-drain.mjs && node test/subagent.mjs && node test/mcp-protocol.mjs && node test/mcp-tools.mjs && node test/mcp-e2e.mjs && node test/session-rotation.mjs && node test/drift-detection.mjs && node test/compat-range.mjs && node test/doctor-formatter.mjs && node test/atomic-write.mjs && node test/account-refresh-singleflight.mjs && node test/streaming-edge-cases.mjs && node test/client-detection.mjs && node test/manual-oauth-flow.mjs && node test/scrub-template.mjs",
25
25
  "audit": "npm audit --production --audit-level=high",
26
26
  "prepublishOnly": "npm run build",
27
27
  "start": "node dist/cli.js",