@askalf/dario 3.24.0 → 3.26.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 +89 -1
- package/dist/doctor.js +24 -0
- package/dist/proxy.d.ts +1 -0
- package/dist/proxy.js +41 -15
- package/dist/stream-drain.d.ts +60 -0
- package/dist/stream-drain.js +68 -0
- package/dist/subagent.d.ts +74 -0
- package/dist/subagent.js +167 -0
- package/package.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -214,7 +214,13 @@ async function proxy() {
|
|
|
214
214
|
// calc lives in src/pacing.ts; the flags just feed it.
|
|
215
215
|
const pacingMinMs = parsePositiveIntFlag('--pace-min=');
|
|
216
216
|
const pacingJitterMs = parsePositiveIntFlag('--pace-jitter=');
|
|
217
|
-
|
|
217
|
+
// --drain-on-close (v3.25, direction #5). When set, a client
|
|
218
|
+
// disconnect no longer aborts the upstream SSE — dario keeps
|
|
219
|
+
// draining the stream to EOF so Anthropic sees the CC-shaped
|
|
220
|
+
// read-to-completion pattern. Costs tokens (the response is fully
|
|
221
|
+
// generated even if nobody reads it), so it's opt-in.
|
|
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 });
|
|
218
224
|
}
|
|
219
225
|
function parsePositiveIntFlag(prefix) {
|
|
220
226
|
const found = args.find(a => a.startsWith(prefix));
|
|
@@ -450,6 +456,11 @@ async function help() {
|
|
|
450
456
|
dario backend remove N Remove an OpenAI-compat backend
|
|
451
457
|
dario shim -- CMD ARGS Run CMD inside the dario shim (experimental,
|
|
452
458
|
stealth fingerprint via in-process fetch patch)
|
|
459
|
+
dario subagent install Register ~/.claude/agents/dario.md so Claude Code
|
|
460
|
+
can delegate dario diagnostics / template-refresh
|
|
461
|
+
operations to a named sub-agent (v3.26)
|
|
462
|
+
dario subagent remove Remove the registered sub-agent file
|
|
463
|
+
dario subagent status Show whether the sub-agent is installed
|
|
453
464
|
dario doctor Print a health report: dario / Node / CC /
|
|
454
465
|
template / drift / OAuth / pool / backends
|
|
455
466
|
|
|
@@ -486,6 +497,14 @@ async function help() {
|
|
|
486
497
|
Default: 0 (off). Set to e.g. 300 to hide
|
|
487
498
|
the floor from long-run inter-arrival
|
|
488
499
|
statistics. (v3.24)
|
|
500
|
+
--drain-on-close When the client disconnects mid-stream,
|
|
501
|
+
keep consuming the upstream SSE to EOF
|
|
502
|
+
so Anthropic sees the same read-to-
|
|
503
|
+
completion pattern native Claude Code
|
|
504
|
+
produces. Trades tokens (the response
|
|
505
|
+
is fully generated even if nobody reads
|
|
506
|
+
it) for fingerprint fidelity. Bounded by
|
|
507
|
+
the 5-minute upstream timeout. (v3.25)
|
|
489
508
|
--port=PORT Port to listen on (default: 3456)
|
|
490
509
|
--host=ADDRESS Address to bind to (default: 127.0.0.1)
|
|
491
510
|
Use 0.0.0.0 for LAN; see README for DARIO_API_KEY
|
|
@@ -560,6 +579,74 @@ async function shim() {
|
|
|
560
579
|
process.exit(1);
|
|
561
580
|
}
|
|
562
581
|
}
|
|
582
|
+
async function subagent() {
|
|
583
|
+
const sub = args[1] ?? 'status';
|
|
584
|
+
const { installSubagent, removeSubagent, loadSubagentStatus, SUBAGENT_NAME } = await import('./subagent.js');
|
|
585
|
+
if (sub === 'install') {
|
|
586
|
+
const r = installSubagent();
|
|
587
|
+
console.log('');
|
|
588
|
+
console.log(' dario — Sub-agent install');
|
|
589
|
+
console.log(' ─────────────────────────');
|
|
590
|
+
console.log('');
|
|
591
|
+
if (r.action === 'unchanged') {
|
|
592
|
+
console.log(` Already up to date at ${r.path} (v${r.version}).`);
|
|
593
|
+
}
|
|
594
|
+
else {
|
|
595
|
+
console.log(` ${r.action === 'created' ? 'Installed' : 'Updated'} at ${r.path} (v${r.version}).`);
|
|
596
|
+
}
|
|
597
|
+
console.log('');
|
|
598
|
+
console.log(' Claude Code will pick up the new sub-agent on its next startup.');
|
|
599
|
+
console.log(` Invoke it from CC with: "Use the ${SUBAGENT_NAME} sub-agent to …"`);
|
|
600
|
+
console.log('');
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
if (sub === 'remove' || sub === 'uninstall') {
|
|
604
|
+
const r = removeSubagent();
|
|
605
|
+
console.log('');
|
|
606
|
+
console.log(' dario — Sub-agent remove');
|
|
607
|
+
console.log(' ────────────────────────');
|
|
608
|
+
console.log('');
|
|
609
|
+
if (r.removed) {
|
|
610
|
+
console.log(` Removed ${r.path}.`);
|
|
611
|
+
}
|
|
612
|
+
else {
|
|
613
|
+
console.log(` Nothing to remove — ${r.path} was not present.`);
|
|
614
|
+
}
|
|
615
|
+
console.log('');
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
if (sub === 'status') {
|
|
619
|
+
const s = loadSubagentStatus();
|
|
620
|
+
console.log('');
|
|
621
|
+
console.log(' dario — Sub-agent status');
|
|
622
|
+
console.log(' ────────────────────────');
|
|
623
|
+
console.log('');
|
|
624
|
+
console.log(` Path: ${s.path}`);
|
|
625
|
+
console.log(` ~/.claude/agents: ${s.agentsDirExists ? 'exists' : 'missing (Claude Code not installed?)'}`);
|
|
626
|
+
if (!s.installed) {
|
|
627
|
+
console.log(' Installed: no');
|
|
628
|
+
console.log('');
|
|
629
|
+
console.log(' Install with: dario subagent install');
|
|
630
|
+
}
|
|
631
|
+
else {
|
|
632
|
+
console.log(` Installed: yes (v${s.fileVersion ?? 'unknown'})`);
|
|
633
|
+
if (!s.current) {
|
|
634
|
+
console.log(' Note: file version does not match installed dario — run `dario subagent install` to refresh.');
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
console.log('');
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
console.error('');
|
|
641
|
+
console.error(' Usage: dario subagent <install | remove | status>');
|
|
642
|
+
console.error('');
|
|
643
|
+
console.error(' install Write ~/.claude/agents/dario.md so Claude Code can');
|
|
644
|
+
console.error(' delegate dario diagnostics to a named sub-agent.');
|
|
645
|
+
console.error(' remove Remove the installed sub-agent file.');
|
|
646
|
+
console.error(' status Report whether the sub-agent is installed (default).');
|
|
647
|
+
console.error('');
|
|
648
|
+
process.exit(1);
|
|
649
|
+
}
|
|
563
650
|
async function doctor() {
|
|
564
651
|
const { runChecks, formatChecks, exitCodeFor } = await import('./doctor.js');
|
|
565
652
|
console.log('');
|
|
@@ -598,6 +685,7 @@ const commands = {
|
|
|
598
685
|
accounts,
|
|
599
686
|
backend,
|
|
600
687
|
shim,
|
|
688
|
+
subagent,
|
|
601
689
|
doctor,
|
|
602
690
|
help,
|
|
603
691
|
version,
|
package/dist/doctor.js
CHANGED
|
@@ -197,6 +197,30 @@ export async function runChecks() {
|
|
|
197
197
|
catch (err) {
|
|
198
198
|
checks.push({ status: 'warn', label: 'Backends', detail: `check failed: ${err.message}` });
|
|
199
199
|
}
|
|
200
|
+
// ---- CC sub-agent (v3.26, direction #2)
|
|
201
|
+
try {
|
|
202
|
+
const { loadSubagentStatus } = await import('./subagent.js');
|
|
203
|
+
const s = loadSubagentStatus();
|
|
204
|
+
if (!s.agentsDirExists) {
|
|
205
|
+
checks.push({ status: 'info', label: 'Sub-agent', detail: 'not installed (~/.claude/agents missing — Claude Code not installed?)' });
|
|
206
|
+
}
|
|
207
|
+
else if (!s.installed) {
|
|
208
|
+
checks.push({ status: 'info', label: 'Sub-agent', detail: 'not installed — run `dario subagent install` to enable CC integration' });
|
|
209
|
+
}
|
|
210
|
+
else if (!s.current) {
|
|
211
|
+
checks.push({
|
|
212
|
+
status: 'warn',
|
|
213
|
+
label: 'Sub-agent',
|
|
214
|
+
detail: `installed v${s.fileVersion ?? 'unknown'}, does not match this dario — run \`dario subagent install\` to refresh`,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
else {
|
|
218
|
+
checks.push({ status: 'ok', label: 'Sub-agent', detail: `installed v${s.fileVersion} at ${s.path}` });
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
catch (err) {
|
|
222
|
+
checks.push({ status: 'warn', label: 'Sub-agent', detail: `check failed: ${err.message}` });
|
|
223
|
+
}
|
|
200
224
|
// ---- ~/.dario dir
|
|
201
225
|
try {
|
|
202
226
|
const home = join(homedir(), '.dario');
|
package/dist/proxy.d.ts
CHANGED
|
@@ -15,6 +15,7 @@ interface ProxyOptions {
|
|
|
15
15
|
strictTls?: boolean;
|
|
16
16
|
pacingMinMs?: number;
|
|
17
17
|
pacingJitterMs?: number;
|
|
18
|
+
drainOnClose?: boolean;
|
|
18
19
|
}
|
|
19
20
|
export declare function sanitizeError(err: unknown): string;
|
|
20
21
|
export declare function startProxy(opts?: ProxyOptions): Promise<void>;
|
package/dist/proxy.js
CHANGED
|
@@ -584,6 +584,15 @@ export async function startProxy(opts = {}) {
|
|
|
584
584
|
if (verbose) {
|
|
585
585
|
console.log(`[dario] pacing: min=${pacingCfg.minGapMs}ms jitter=${pacingCfg.jitterMs}ms`);
|
|
586
586
|
}
|
|
587
|
+
// Stream-consumption replay (v3.25, direction #5). When on, a client
|
|
588
|
+
// disconnect no longer aborts the upstream fetch — we keep consuming
|
|
589
|
+
// the SSE so Anthropic sees a CC-shaped read-to-EOF pattern. See
|
|
590
|
+
// src/stream-drain.ts for the rationale + tradeoff.
|
|
591
|
+
const { decideOnClientClose, resolveDrainOnClose } = await import('./stream-drain.js');
|
|
592
|
+
const drainOnClose = resolveDrainOnClose(opts.drainOnClose);
|
|
593
|
+
if (verbose) {
|
|
594
|
+
console.log(`[dario] drain-on-close: ${drainOnClose ? 'enabled' : 'disabled'}`);
|
|
595
|
+
}
|
|
587
596
|
// Optional proxy authentication — pre-encode key buffer for performance
|
|
588
597
|
const apiKey = process.env.DARIO_API_KEY;
|
|
589
598
|
const apiKeyBuf = apiKey ? Buffer.from(apiKey) : null;
|
|
@@ -1116,11 +1125,15 @@ export async function startProxy(opts = {}) {
|
|
|
1116
1125
|
'x-stainless-timeout': '600',
|
|
1117
1126
|
};
|
|
1118
1127
|
// Client-disconnect abort: if the client drops the connection before
|
|
1119
|
-
// we've finished sending the response, we
|
|
1120
|
-
// Anthropic stops generating (and billing) a
|
|
1121
|
-
// read.
|
|
1122
|
-
//
|
|
1128
|
+
// we've finished sending the response, we default to aborting the
|
|
1129
|
+
// upstream fetch so Anthropic stops generating (and billing) a
|
|
1130
|
+
// response nobody will read. With `--drain-on-close` set, we
|
|
1131
|
+
// instead keep the reader spinning to consume the full SSE — see
|
|
1132
|
+
// src/stream-drain.ts for the fingerprint rationale. The 5-minute
|
|
1133
|
+
// upstream timeout shares the same controller, so a hung upstream
|
|
1134
|
+
// still gets cut off regardless of drain mode.
|
|
1123
1135
|
const upstreamAbort = new AbortController();
|
|
1136
|
+
let clientDisconnected = false;
|
|
1124
1137
|
upstreamTimeout = setTimeout(() => {
|
|
1125
1138
|
if (!upstreamAbort.signal.aborted) {
|
|
1126
1139
|
upstreamAbortReason = 'timeout';
|
|
@@ -1128,13 +1141,18 @@ export async function startProxy(opts = {}) {
|
|
|
1128
1141
|
}
|
|
1129
1142
|
}, UPSTREAM_TIMEOUT_MS);
|
|
1130
1143
|
onClientClose = () => {
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
// normal teardown happens AFTER res.writableEnded becomes true.
|
|
1134
|
-
if (!res.writableEnded && !upstreamAbort.signal.aborted) {
|
|
1144
|
+
const action = decideOnClientClose(res.writableEnded, upstreamAbort.signal.aborted, drainOnClose);
|
|
1145
|
+
if (action === 'abort') {
|
|
1135
1146
|
upstreamAbortReason = 'client_closed';
|
|
1136
1147
|
upstreamAbort.abort();
|
|
1137
1148
|
}
|
|
1149
|
+
else if (action === 'drain') {
|
|
1150
|
+
clientDisconnected = true;
|
|
1151
|
+
if (verbose)
|
|
1152
|
+
console.log(`[dario] #${requestCount} client disconnected — draining upstream to EOF`);
|
|
1153
|
+
}
|
|
1154
|
+
// noop: either res is already ended (normal teardown) or upstream
|
|
1155
|
+
// is already aborted for another reason.
|
|
1138
1156
|
};
|
|
1139
1157
|
req.on('close', onClientClose);
|
|
1140
1158
|
const startTime = Date.now();
|
|
@@ -1448,6 +1466,14 @@ export async function startProxy(opts = {}) {
|
|
|
1448
1466
|
const streamMapper = ccToolMap && !isOpenAI
|
|
1449
1467
|
? createStreamingReverseMapper(ccToolMap, reqCtx)
|
|
1450
1468
|
: null;
|
|
1469
|
+
// Gated writer — a no-op once the downstream client has gone away
|
|
1470
|
+
// in drain-on-close mode. The read loop keeps consuming so the
|
|
1471
|
+
// upstream sees a full-length read; writes to a closed socket are
|
|
1472
|
+
// suppressed to avoid EPIPE/warnings and pointless work.
|
|
1473
|
+
const writeToClient = (chunk) => {
|
|
1474
|
+
if (!clientDisconnected)
|
|
1475
|
+
res.write(chunk);
|
|
1476
|
+
};
|
|
1451
1477
|
try {
|
|
1452
1478
|
let buffer = '';
|
|
1453
1479
|
const MAX_LINE_LENGTH = 1_000_000; // 1MB max per SSE line
|
|
@@ -1501,8 +1527,8 @@ export async function startProxy(opts = {}) {
|
|
|
1501
1527
|
type: 'upstream_protocol_error',
|
|
1502
1528
|
},
|
|
1503
1529
|
});
|
|
1504
|
-
|
|
1505
|
-
|
|
1530
|
+
writeToClient(`data: ${errPayload}\n\n`);
|
|
1531
|
+
writeToClient('data: [DONE]\n\n');
|
|
1506
1532
|
upstreamAbortReason = 'sse_overflow';
|
|
1507
1533
|
upstreamAbort.abort();
|
|
1508
1534
|
break;
|
|
@@ -1512,28 +1538,28 @@ export async function startProxy(opts = {}) {
|
|
|
1512
1538
|
for (const line of lines) {
|
|
1513
1539
|
const translated = translateStreamChunk(line);
|
|
1514
1540
|
if (translated)
|
|
1515
|
-
|
|
1541
|
+
writeToClient(translated);
|
|
1516
1542
|
}
|
|
1517
1543
|
}
|
|
1518
1544
|
else if (streamMapper) {
|
|
1519
1545
|
const out = streamMapper.feed(value);
|
|
1520
1546
|
if (out.length > 0)
|
|
1521
|
-
|
|
1547
|
+
writeToClient(out);
|
|
1522
1548
|
}
|
|
1523
1549
|
else {
|
|
1524
|
-
|
|
1550
|
+
writeToClient(value);
|
|
1525
1551
|
}
|
|
1526
1552
|
}
|
|
1527
1553
|
// Flush remaining buffer
|
|
1528
1554
|
if (isOpenAI && buffer.trim()) {
|
|
1529
1555
|
const translated = translateStreamChunk(buffer);
|
|
1530
1556
|
if (translated)
|
|
1531
|
-
|
|
1557
|
+
writeToClient(translated);
|
|
1532
1558
|
}
|
|
1533
1559
|
if (streamMapper) {
|
|
1534
1560
|
const tail = streamMapper.end();
|
|
1535
1561
|
if (tail.length > 0)
|
|
1536
|
-
|
|
1562
|
+
writeToClient(tail);
|
|
1537
1563
|
}
|
|
1538
1564
|
}
|
|
1539
1565
|
catch (err) {
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stream-consumption replay (v3.25, direction #5 — behavioral fidelity).
|
|
3
|
+
*
|
|
4
|
+
* Native Claude Code, when it streams a response from `/v1/messages`, reads
|
|
5
|
+
* the SSE to its final event before closing the socket — even when the
|
|
6
|
+
* consumer logically already has enough. Third-party consumers routed
|
|
7
|
+
* through dario's proxy often abort mid-stream (close their request the
|
|
8
|
+
* instant they see the tool-use content block they wanted). Dario's
|
|
9
|
+
* default has been to propagate that abort upstream by triggering
|
|
10
|
+
* `upstreamAbort.abort()` from the `req.on('close')` handler — clean from
|
|
11
|
+
* a billing standpoint (Anthropic stops generating, stops billing), but a
|
|
12
|
+
* fingerprint axis: "connection closed mid-stream" vs CC's "connection
|
|
13
|
+
* read to EOF" is visible on Anthropic's side.
|
|
14
|
+
*
|
|
15
|
+
* `--drain-on-close` / `DARIO_DRAIN_ON_CLOSE=1` flips the tradeoff: when
|
|
16
|
+
* the downstream client disconnects, dario suppresses the upstream abort
|
|
17
|
+
* and keeps the reader loop spinning until the upstream emits its final
|
|
18
|
+
* event (or `UPSTREAM_TIMEOUT_MS` fires as a hard ceiling — we don't
|
|
19
|
+
* linger on dead upstreams). Writes to the closed `res` are gated off;
|
|
20
|
+
* the reads and any accumulator state (analytics, tool-map) continue so
|
|
21
|
+
* the captured usage numbers are complete rather than truncated.
|
|
22
|
+
*
|
|
23
|
+
* This has a real cost — you pay tokens for a response your consumer
|
|
24
|
+
* isn't going to read — so it's deliberately opt-in. Users on an
|
|
25
|
+
* unmetered subscription who care more about fingerprint than wasted
|
|
26
|
+
* generation can flip it on globally.
|
|
27
|
+
*
|
|
28
|
+
* This module exposes the *decision* as a pure function so the test
|
|
29
|
+
* suite can exercise every branch without spinning up a socket. The
|
|
30
|
+
* proxy wires the decision into its existing `onClientClose` handler.
|
|
31
|
+
*/
|
|
32
|
+
export type ClientCloseAction = 'abort' | 'drain' | 'noop';
|
|
33
|
+
/**
|
|
34
|
+
* Decide what `onClientClose` should do when the client's `req.on('close')`
|
|
35
|
+
* fires. Pure over its three inputs.
|
|
36
|
+
*
|
|
37
|
+
* `writableEnded` — `res.writableEnded` at the moment the handler
|
|
38
|
+
* runs. `true` means the response is already
|
|
39
|
+
* finished (the 'close' event is a normal
|
|
40
|
+
* teardown notification after res.end()) — no
|
|
41
|
+
* action needed.
|
|
42
|
+
* `upstreamAborted` — whether upstream has already been aborted for
|
|
43
|
+
* some other reason (timeout, overflow, pool
|
|
44
|
+
* failover). Don't double-abort.
|
|
45
|
+
* `drainOnClose` — the runtime-configured knob.
|
|
46
|
+
*
|
|
47
|
+
* Returns:
|
|
48
|
+
* `'noop'` — already finished / already aborted; handler should return.
|
|
49
|
+
* `'abort'` — fire `upstreamAbort.abort()` (the v3.24-and-earlier default).
|
|
50
|
+
* `'drain'` — leave upstream alive; gate off client writes; let the
|
|
51
|
+
* read loop consume to EOF (bounded by UPSTREAM_TIMEOUT_MS).
|
|
52
|
+
*/
|
|
53
|
+
export declare function decideOnClientClose(writableEnded: boolean, upstreamAborted: boolean, drainOnClose: boolean): ClientCloseAction;
|
|
54
|
+
/**
|
|
55
|
+
* Resolve the `drainOnClose` effective setting from explicit options +
|
|
56
|
+
* `DARIO_DRAIN_ON_CLOSE` env var. Truthy env values: `'1'`, `'true'`,
|
|
57
|
+
* `'yes'` (case-insensitive). Anything else (including unset) is false.
|
|
58
|
+
* Explicit `true`/`false` on the options object always wins.
|
|
59
|
+
*/
|
|
60
|
+
export declare function resolveDrainOnClose(explicit: boolean | undefined, env?: NodeJS.ProcessEnv): boolean;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stream-consumption replay (v3.25, direction #5 — behavioral fidelity).
|
|
3
|
+
*
|
|
4
|
+
* Native Claude Code, when it streams a response from `/v1/messages`, reads
|
|
5
|
+
* the SSE to its final event before closing the socket — even when the
|
|
6
|
+
* consumer logically already has enough. Third-party consumers routed
|
|
7
|
+
* through dario's proxy often abort mid-stream (close their request the
|
|
8
|
+
* instant they see the tool-use content block they wanted). Dario's
|
|
9
|
+
* default has been to propagate that abort upstream by triggering
|
|
10
|
+
* `upstreamAbort.abort()` from the `req.on('close')` handler — clean from
|
|
11
|
+
* a billing standpoint (Anthropic stops generating, stops billing), but a
|
|
12
|
+
* fingerprint axis: "connection closed mid-stream" vs CC's "connection
|
|
13
|
+
* read to EOF" is visible on Anthropic's side.
|
|
14
|
+
*
|
|
15
|
+
* `--drain-on-close` / `DARIO_DRAIN_ON_CLOSE=1` flips the tradeoff: when
|
|
16
|
+
* the downstream client disconnects, dario suppresses the upstream abort
|
|
17
|
+
* and keeps the reader loop spinning until the upstream emits its final
|
|
18
|
+
* event (or `UPSTREAM_TIMEOUT_MS` fires as a hard ceiling — we don't
|
|
19
|
+
* linger on dead upstreams). Writes to the closed `res` are gated off;
|
|
20
|
+
* the reads and any accumulator state (analytics, tool-map) continue so
|
|
21
|
+
* the captured usage numbers are complete rather than truncated.
|
|
22
|
+
*
|
|
23
|
+
* This has a real cost — you pay tokens for a response your consumer
|
|
24
|
+
* isn't going to read — so it's deliberately opt-in. Users on an
|
|
25
|
+
* unmetered subscription who care more about fingerprint than wasted
|
|
26
|
+
* generation can flip it on globally.
|
|
27
|
+
*
|
|
28
|
+
* This module exposes the *decision* as a pure function so the test
|
|
29
|
+
* suite can exercise every branch without spinning up a socket. The
|
|
30
|
+
* proxy wires the decision into its existing `onClientClose` handler.
|
|
31
|
+
*/
|
|
32
|
+
/**
|
|
33
|
+
* Decide what `onClientClose` should do when the client's `req.on('close')`
|
|
34
|
+
* fires. Pure over its three inputs.
|
|
35
|
+
*
|
|
36
|
+
* `writableEnded` — `res.writableEnded` at the moment the handler
|
|
37
|
+
* runs. `true` means the response is already
|
|
38
|
+
* finished (the 'close' event is a normal
|
|
39
|
+
* teardown notification after res.end()) — no
|
|
40
|
+
* action needed.
|
|
41
|
+
* `upstreamAborted` — whether upstream has already been aborted for
|
|
42
|
+
* some other reason (timeout, overflow, pool
|
|
43
|
+
* failover). Don't double-abort.
|
|
44
|
+
* `drainOnClose` — the runtime-configured knob.
|
|
45
|
+
*
|
|
46
|
+
* Returns:
|
|
47
|
+
* `'noop'` — already finished / already aborted; handler should return.
|
|
48
|
+
* `'abort'` — fire `upstreamAbort.abort()` (the v3.24-and-earlier default).
|
|
49
|
+
* `'drain'` — leave upstream alive; gate off client writes; let the
|
|
50
|
+
* read loop consume to EOF (bounded by UPSTREAM_TIMEOUT_MS).
|
|
51
|
+
*/
|
|
52
|
+
export function decideOnClientClose(writableEnded, upstreamAborted, drainOnClose) {
|
|
53
|
+
if (writableEnded || upstreamAborted)
|
|
54
|
+
return 'noop';
|
|
55
|
+
return drainOnClose ? 'drain' : 'abort';
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Resolve the `drainOnClose` effective setting from explicit options +
|
|
59
|
+
* `DARIO_DRAIN_ON_CLOSE` env var. Truthy env values: `'1'`, `'true'`,
|
|
60
|
+
* `'yes'` (case-insensitive). Anything else (including unset) is false.
|
|
61
|
+
* Explicit `true`/`false` on the options object always wins.
|
|
62
|
+
*/
|
|
63
|
+
export function resolveDrainOnClose(explicit, env = process.env) {
|
|
64
|
+
if (typeof explicit === 'boolean')
|
|
65
|
+
return explicit;
|
|
66
|
+
const v = (env.DARIO_DRAIN_ON_CLOSE ?? '').toLowerCase();
|
|
67
|
+
return v === '1' || v === 'true' || v === 'yes';
|
|
68
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CC sub-agent hook (v3.26, direction #2).
|
|
3
|
+
*
|
|
4
|
+
* Claude Code reads sub-agent definitions from `~/.claude/agents/*.md` —
|
|
5
|
+
* a YAML-frontmatter markdown file that exposes a tool-scoped prompt
|
|
6
|
+
* context CC can delegate work into. Installing a "dario" sub-agent
|
|
7
|
+
* gives the user (or CC itself, via the Task tool) a named handle to
|
|
8
|
+
* delegate dario operations into: refreshing the baked template from
|
|
9
|
+
* a live capture, checking proxy health, listing pool / backend state.
|
|
10
|
+
*
|
|
11
|
+
* The sub-agent runs with Bash + Read tool access only — it can invoke
|
|
12
|
+
* the `dario` CLI to produce reports, but it cannot modify dario state
|
|
13
|
+
* (accounts add/remove, backend configuration, etc.) without the user
|
|
14
|
+
* explicitly running those commands in their own session. That boundary
|
|
15
|
+
* is baked into the prompt so the sub-agent doesn't accidentally take
|
|
16
|
+
* destructive actions on the user's behalf.
|
|
17
|
+
*
|
|
18
|
+
* The file content is versioned via an inline `dario-sub-agent-version:`
|
|
19
|
+
* marker so a later release can detect stale installations (same axis as
|
|
20
|
+
* the `_schemaVersion` check on the live-fingerprint cache). `readStatus`
|
|
21
|
+
* is pure over `(fileExists, fileBody)` so the tests exercise every
|
|
22
|
+
* branch without touching the filesystem.
|
|
23
|
+
*/
|
|
24
|
+
export declare const SUBAGENT_NAME = "dario";
|
|
25
|
+
export declare const SUBAGENT_FILENAME = "dario.md";
|
|
26
|
+
/** `~/.claude/agents/dario.md`. */
|
|
27
|
+
export declare function getSubagentPath(): string;
|
|
28
|
+
/**
|
|
29
|
+
* Construct the full sub-agent file body for a given dario version. Pure
|
|
30
|
+
* function — the tests pin the output so a change to the content is a
|
|
31
|
+
* deliberate, diffable update.
|
|
32
|
+
*/
|
|
33
|
+
export declare function buildSubagentFile(darioVersion: string): string;
|
|
34
|
+
export interface SubagentStatus {
|
|
35
|
+
installed: boolean;
|
|
36
|
+
path: string;
|
|
37
|
+
/** Parsed from the inline `dario-sub-agent-version:` marker when the file is present. */
|
|
38
|
+
fileVersion: string | null;
|
|
39
|
+
/** Whether the installed file matches the version currently being built. */
|
|
40
|
+
current: boolean;
|
|
41
|
+
/** Whether `~/.claude/agents/` exists (is CC installed / agents dir created?). */
|
|
42
|
+
agentsDirExists: boolean;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Pure status computation over (fileExists, fileBody, currentVersion).
|
|
46
|
+
* Separated from `loadStatus` so tests can feed synthetic bodies and
|
|
47
|
+
* exercise every branch without the filesystem.
|
|
48
|
+
*/
|
|
49
|
+
export declare function computeSubagentStatus(path: string, fileExists: boolean, fileBody: string | null, agentsDirExists: boolean, currentVersion: string): SubagentStatus;
|
|
50
|
+
/**
|
|
51
|
+
* Read the current on-disk status. Safe to call whether or not
|
|
52
|
+
* `~/.claude/` exists; a missing directory is reported via
|
|
53
|
+
* `agentsDirExists: false` so the caller can decide whether to create it
|
|
54
|
+
* (install) or just skip (status).
|
|
55
|
+
*/
|
|
56
|
+
export declare function loadSubagentStatus(): SubagentStatus;
|
|
57
|
+
/**
|
|
58
|
+
* Install or refresh the sub-agent. Creates `~/.claude/agents/` if it
|
|
59
|
+
* doesn't exist. Returns what happened so the CLI can log accurately.
|
|
60
|
+
*/
|
|
61
|
+
export declare function installSubagent(): {
|
|
62
|
+
path: string;
|
|
63
|
+
action: 'created' | 'updated' | 'unchanged';
|
|
64
|
+
version: string;
|
|
65
|
+
};
|
|
66
|
+
/**
|
|
67
|
+
* Remove the sub-agent file. Idempotent — returns `{ removed: false }`
|
|
68
|
+
* if the file wasn't present. Does not remove the parent `~/.claude/agents/`
|
|
69
|
+
* directory even if it becomes empty (user may have other sub-agents).
|
|
70
|
+
*/
|
|
71
|
+
export declare function removeSubagent(): {
|
|
72
|
+
path: string;
|
|
73
|
+
removed: boolean;
|
|
74
|
+
};
|
package/dist/subagent.js
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CC sub-agent hook (v3.26, direction #2).
|
|
3
|
+
*
|
|
4
|
+
* Claude Code reads sub-agent definitions from `~/.claude/agents/*.md` —
|
|
5
|
+
* a YAML-frontmatter markdown file that exposes a tool-scoped prompt
|
|
6
|
+
* context CC can delegate work into. Installing a "dario" sub-agent
|
|
7
|
+
* gives the user (or CC itself, via the Task tool) a named handle to
|
|
8
|
+
* delegate dario operations into: refreshing the baked template from
|
|
9
|
+
* a live capture, checking proxy health, listing pool / backend state.
|
|
10
|
+
*
|
|
11
|
+
* The sub-agent runs with Bash + Read tool access only — it can invoke
|
|
12
|
+
* the `dario` CLI to produce reports, but it cannot modify dario state
|
|
13
|
+
* (accounts add/remove, backend configuration, etc.) without the user
|
|
14
|
+
* explicitly running those commands in their own session. That boundary
|
|
15
|
+
* is baked into the prompt so the sub-agent doesn't accidentally take
|
|
16
|
+
* destructive actions on the user's behalf.
|
|
17
|
+
*
|
|
18
|
+
* The file content is versioned via an inline `dario-sub-agent-version:`
|
|
19
|
+
* marker so a later release can detect stale installations (same axis as
|
|
20
|
+
* the `_schemaVersion` check on the live-fingerprint cache). `readStatus`
|
|
21
|
+
* is pure over `(fileExists, fileBody)` so the tests exercise every
|
|
22
|
+
* branch without touching the filesystem.
|
|
23
|
+
*/
|
|
24
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync } from 'node:fs';
|
|
25
|
+
import { homedir } from 'node:os';
|
|
26
|
+
import { join, dirname } from 'node:path';
|
|
27
|
+
import { fileURLToPath } from 'node:url';
|
|
28
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
29
|
+
export const SUBAGENT_NAME = 'dario';
|
|
30
|
+
export const SUBAGENT_FILENAME = `${SUBAGENT_NAME}.md`;
|
|
31
|
+
/** `~/.claude/agents/dario.md`. */
|
|
32
|
+
export function getSubagentPath() {
|
|
33
|
+
return join(homedir(), '.claude', 'agents', SUBAGENT_FILENAME);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Construct the full sub-agent file body for a given dario version. Pure
|
|
37
|
+
* function — the tests pin the output so a change to the content is a
|
|
38
|
+
* deliberate, diffable update.
|
|
39
|
+
*/
|
|
40
|
+
export function buildSubagentFile(darioVersion) {
|
|
41
|
+
return `---
|
|
42
|
+
name: ${SUBAGENT_NAME}
|
|
43
|
+
description: Use this sub-agent for dario-related diagnostics and template-refresh operations. It can invoke the \`dario\` CLI (via Bash) to run a health report, refresh the baked CC request template from a live capture, or check the proxy's account pool and backend configuration. It will not modify dario state (credentials, accounts, backends) without explicit user authorization.
|
|
44
|
+
tools: Bash, Read
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
<!-- dario-sub-agent-version: ${darioVersion} -->
|
|
48
|
+
<!-- managed by: dario subagent install / remove -->
|
|
49
|
+
|
|
50
|
+
You are **dario's integration sub-agent**. You have access to the \`dario\` CLI via the Bash tool. Your job is to help the user with dario-related diagnostics and refresh operations by running read-only or user-requested commands and summarizing the output.
|
|
51
|
+
|
|
52
|
+
## What you can do (read-only — no user confirmation required)
|
|
53
|
+
|
|
54
|
+
- **\`dario doctor\`** — produce a health report covering Node / platform, runtime TLS fingerprint, CC binary + compatibility range, template source + drift, OAuth state, account pool, and backend configuration. Summarize any \`[WARN]\` or \`[FAIL]\` rows in plain language and suggest the fix hinted in the detail column.
|
|
55
|
+
- **\`dario status\`** — quick auth status (authenticated, expires in, claim).
|
|
56
|
+
- **\`dario accounts list\`** — list the configured account pool and per-account token expiry.
|
|
57
|
+
- **\`dario backend list\`** — list configured OpenAI-compat backends (OpenRouter, Groq, LiteLLM, etc.).
|
|
58
|
+
- **\`dario --version\`** — report the installed dario version.
|
|
59
|
+
|
|
60
|
+
## What requires explicit user authorization first
|
|
61
|
+
|
|
62
|
+
Before invoking any of these, ask the user to confirm:
|
|
63
|
+
|
|
64
|
+
- \`dario login\` / \`dario refresh\` — mutates credentials.
|
|
65
|
+
- \`dario accounts add/remove\` — mutates the account pool.
|
|
66
|
+
- \`dario backend add/remove\` — mutates backend configuration.
|
|
67
|
+
- \`dario logout\` — deletes stored credentials.
|
|
68
|
+
|
|
69
|
+
## What you should NOT do
|
|
70
|
+
|
|
71
|
+
- Do not run \`dario proxy\` — the proxy is a long-running server; invoking it from a sub-agent context would block the parent CC session indefinitely.
|
|
72
|
+
- Do not modify \`~/.dario/\` files directly (credentials.json, accounts/, backends.json). Use the CLI.
|
|
73
|
+
- Do not dump credentials, tokens, or bearer values in your output.
|
|
74
|
+
|
|
75
|
+
## Style
|
|
76
|
+
|
|
77
|
+
- Lead with the headline answer (one line).
|
|
78
|
+
- For diagnostics, group findings by severity (FAIL → WARN → OK).
|
|
79
|
+
- When suggesting a fix, quote the exact command the user should run.
|
|
80
|
+
- Keep output concise — the user is delegating to you because they want a summary, not a transcript.
|
|
81
|
+
`;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Pure status computation over (fileExists, fileBody, currentVersion).
|
|
85
|
+
* Separated from `loadStatus` so tests can feed synthetic bodies and
|
|
86
|
+
* exercise every branch without the filesystem.
|
|
87
|
+
*/
|
|
88
|
+
export function computeSubagentStatus(path, fileExists, fileBody, agentsDirExists, currentVersion) {
|
|
89
|
+
if (!fileExists || fileBody === null) {
|
|
90
|
+
return { installed: false, path, fileVersion: null, current: false, agentsDirExists };
|
|
91
|
+
}
|
|
92
|
+
const m = /<!-- dario-sub-agent-version: ([^ ]+) -->/.exec(fileBody);
|
|
93
|
+
const fileVersion = m ? m[1] : null;
|
|
94
|
+
const current = fileVersion === currentVersion;
|
|
95
|
+
return { installed: true, path, fileVersion, current, agentsDirExists };
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Read the current on-disk status. Safe to call whether or not
|
|
99
|
+
* `~/.claude/` exists; a missing directory is reported via
|
|
100
|
+
* `agentsDirExists: false` so the caller can decide whether to create it
|
|
101
|
+
* (install) or just skip (status).
|
|
102
|
+
*/
|
|
103
|
+
export function loadSubagentStatus() {
|
|
104
|
+
const path = getSubagentPath();
|
|
105
|
+
const agentsDir = dirname(path);
|
|
106
|
+
const agentsDirExists = existsSync(agentsDir);
|
|
107
|
+
const fileExists = existsSync(path);
|
|
108
|
+
let fileBody = null;
|
|
109
|
+
if (fileExists) {
|
|
110
|
+
try {
|
|
111
|
+
fileBody = readFileSync(path, 'utf-8');
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
fileBody = null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return computeSubagentStatus(path, fileExists, fileBody, agentsDirExists, currentDarioVersion());
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Install or refresh the sub-agent. Creates `~/.claude/agents/` if it
|
|
121
|
+
* doesn't exist. Returns what happened so the CLI can log accurately.
|
|
122
|
+
*/
|
|
123
|
+
export function installSubagent() {
|
|
124
|
+
const path = getSubagentPath();
|
|
125
|
+
const agentsDir = dirname(path);
|
|
126
|
+
if (!existsSync(agentsDir)) {
|
|
127
|
+
mkdirSync(agentsDir, { recursive: true });
|
|
128
|
+
}
|
|
129
|
+
const version = currentDarioVersion();
|
|
130
|
+
const desired = buildSubagentFile(version);
|
|
131
|
+
let existingBody = null;
|
|
132
|
+
const exists = existsSync(path);
|
|
133
|
+
if (exists) {
|
|
134
|
+
try {
|
|
135
|
+
existingBody = readFileSync(path, 'utf-8');
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
existingBody = null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (existingBody === desired) {
|
|
142
|
+
return { path, action: 'unchanged', version };
|
|
143
|
+
}
|
|
144
|
+
writeFileSync(path, desired, 'utf-8');
|
|
145
|
+
return { path, action: exists ? 'updated' : 'created', version };
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Remove the sub-agent file. Idempotent — returns `{ removed: false }`
|
|
149
|
+
* if the file wasn't present. Does not remove the parent `~/.claude/agents/`
|
|
150
|
+
* directory even if it becomes empty (user may have other sub-agents).
|
|
151
|
+
*/
|
|
152
|
+
export function removeSubagent() {
|
|
153
|
+
const path = getSubagentPath();
|
|
154
|
+
if (!existsSync(path))
|
|
155
|
+
return { path, removed: false };
|
|
156
|
+
unlinkSync(path);
|
|
157
|
+
return { path, removed: true };
|
|
158
|
+
}
|
|
159
|
+
function currentDarioVersion() {
|
|
160
|
+
try {
|
|
161
|
+
const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8'));
|
|
162
|
+
return typeof pkg.version === 'string' ? pkg.version : '0.0.0';
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
return '0.0.0';
|
|
166
|
+
}
|
|
167
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@askalf/dario",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.26.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/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/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",
|