@askalf/dario 4.0.1 → 4.1.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/proxy.js CHANGED
@@ -10,6 +10,8 @@ import { buildCCRequest, reverseMapResponse, createStreamingReverseMapper, order
10
10
  import { describeTemplate, detectDrift, checkCCCompat } from './live-fingerprint.js';
11
11
  import { AccountPool, computeStickyKey, parseRateLimits, modelFamily, isInAuthCooldown, authCooldownMs } from './pool.js';
12
12
  import { Analytics, billingBucketFromClaim } from './analytics.js';
13
+ import { OverageGuard, buildHaltErrorBody } from './overage-guard.js';
14
+ import { notify as osNotify } from './notify.js';
13
15
  import { loadAllAccounts, loadAccount, refreshAccountToken, resyncLoginFromCredentialsIfStale } from './accounts.js';
14
16
  import { getOpenAIBackend, isOpenAIModel, forwardToOpenAI } from './openai-backend.js';
15
17
  import { RequestQueue, QueueFullError, QueueTimeoutError, DEFAULT_MAX_CONCURRENT, DEFAULT_MAX_QUEUED, DEFAULT_QUEUE_TIMEOUT_MS } from './request-queue.js';
@@ -573,6 +575,31 @@ export async function startProxy(opts = {}) {
573
575
  // endpoint, but burn-rate / per-request visibility is useful for
574
576
  // single-account users too.
575
577
  const analytics = new Analytics();
578
+ // Overage-guard (v4.1, dario#288). Resolved from opts with built-in
579
+ // defaults (enabled=true, behavior='halt', cooldown=30min, notifyOs=true)
580
+ // so an opts-less proxy still gets protection. The notifier is wired
581
+ // separately below once notify.ts is loaded.
582
+ const overageGuard = new OverageGuard({
583
+ enabled: opts.overageGuardEnabled ?? true,
584
+ behavior: opts.overageGuardBehavior ?? 'halt',
585
+ cooldownMs: opts.overageGuardCooldownMs ?? 30 * 60 * 1000,
586
+ notifyOs: opts.overageGuardNotifyOs ?? true,
587
+ notifier: osNotify,
588
+ });
589
+ overageGuard.attach(analytics);
590
+ // Surface halt + resume to the foreground startup banner so an
591
+ // operator running `dario proxy` directly sees the event even without
592
+ // a TUI attached. -v / --verbose is not required — this is loud by
593
+ // design.
594
+ overageGuard.on('halt', (state) => {
595
+ console.error(`[dario] OVERAGE-GUARD HALTED: ${state.request.model} on account=${state.request.account} returned representative-claim=overage at ${new Date(state.request.timestamp).toISOString()}. Returning 503 to new requests until \`dario resume\` or cooldown expires (${new Date(state.cooldownUntil).toISOString()}). See dario#288.`);
596
+ });
597
+ overageGuard.on('warn', (state) => {
598
+ console.error(`[dario] OVERAGE-GUARD WARN: ${state.request.model} on account=${state.request.account} returned representative-claim=overage at ${new Date(state.request.timestamp).toISOString()}. Behavior=warn — proxy continuing to forward; investigate before bill bleeds. See dario#288.`);
599
+ });
600
+ overageGuard.on('resume', (info) => {
601
+ console.error(`[dario] overage-guard resumed (${info.reason}). Normal request handling restored.`);
602
+ });
576
603
  let status;
577
604
  if (pool) {
578
605
  for (const acc of accountsList) {
@@ -955,7 +982,16 @@ export async function startProxy(opts = {}) {
955
982
  for (const past of analytics.recent(50)) {
956
983
  res.write(`data: ${JSON.stringify(past)}\n\n`);
957
984
  }
958
- // Live tail
985
+ // Backlog the current halt state if any — a TUI attaching mid-halt
986
+ // needs to see the banner immediately without waiting for the
987
+ // next overage hit (which won't come, because the proxy is halted).
988
+ const haltedNow = overageGuard.state();
989
+ if (haltedNow) {
990
+ res.write(`event: overage_halt\ndata: ${JSON.stringify(haltedNow)}\n\n`);
991
+ }
992
+ // Live tail — request records on default 'message' event, halt /
993
+ // warn / resume on named events so the TUI can route on event type
994
+ // without changing the existing record shape.
959
995
  const onRecord = (r) => {
960
996
  // Use try/catch so a broken socket (peer hung up between events)
961
997
  // doesn't crash the request hot-path — Analytics already wraps
@@ -965,7 +1001,28 @@ export async function startProxy(opts = {}) {
965
1001
  }
966
1002
  catch { /* ignored */ }
967
1003
  };
1004
+ const onHalt = (state) => {
1005
+ try {
1006
+ res.write(`event: overage_halt\ndata: ${JSON.stringify(state)}\n\n`);
1007
+ }
1008
+ catch { /* ignored */ }
1009
+ };
1010
+ const onWarn = (state) => {
1011
+ try {
1012
+ res.write(`event: overage_warn\ndata: ${JSON.stringify(state)}\n\n`);
1013
+ }
1014
+ catch { /* ignored */ }
1015
+ };
1016
+ const onResume = (info) => {
1017
+ try {
1018
+ res.write(`event: overage_resume\ndata: ${JSON.stringify(info)}\n\n`);
1019
+ }
1020
+ catch { /* ignored */ }
1021
+ };
968
1022
  analytics.on('record', onRecord);
1023
+ overageGuard.on('halt', onHalt);
1024
+ overageGuard.on('warn', onWarn);
1025
+ overageGuard.on('resume', onResume);
969
1026
  // Heartbeat every 25s — SSE comments are ignored by clients but
970
1027
  // keep middle-boxes (CDNs, dev-proxies) from closing the pipe.
971
1028
  const heartbeat = setInterval(() => {
@@ -977,10 +1034,39 @@ export async function startProxy(opts = {}) {
977
1034
  heartbeat.unref?.();
978
1035
  req.on('close', () => {
979
1036
  analytics.off('record', onRecord);
1037
+ overageGuard.off('halt', onHalt);
1038
+ overageGuard.off('warn', onWarn);
1039
+ overageGuard.off('resume', onResume);
980
1040
  clearInterval(heartbeat);
981
1041
  });
982
1042
  return;
983
1043
  }
1044
+ // POST /admin/resume — clear overage-guard halt state (v4.1, dario#288).
1045
+ // Idempotent: returns 200 with `wasHalted: false` if the proxy is
1046
+ // already running normally. Auth gating is the same as every other
1047
+ // endpoint (loopback-bind by default; DARIO_API_KEY needed for
1048
+ // non-loopback). GET returns the current state for read-only queries.
1049
+ if (urlPath === '/admin/resume' && req.method === 'GET') {
1050
+ const state = overageGuard.state();
1051
+ res.writeHead(200, { ...JSON_HEADERS, 'Access-Control-Allow-Origin': corsOrigin });
1052
+ res.end(JSON.stringify({
1053
+ halted: state !== null,
1054
+ state,
1055
+ config: overageGuard.config(),
1056
+ }));
1057
+ return;
1058
+ }
1059
+ if (urlPath === '/admin/resume' && req.method === 'POST') {
1060
+ const wasHalted = overageGuard.state() !== null;
1061
+ overageGuard.clear('manual');
1062
+ res.writeHead(200, { ...JSON_HEADERS, 'Access-Control-Allow-Origin': corsOrigin });
1063
+ res.end(JSON.stringify({
1064
+ ok: true,
1065
+ wasHalted,
1066
+ resumedAt: new Date().toISOString(),
1067
+ }));
1068
+ return;
1069
+ }
984
1070
  if (urlPath === '/v1/models' && req.method === 'GET') {
985
1071
  requestCount++;
986
1072
  res.writeHead(200, { ...JSON_HEADERS, 'Access-Control-Allow-Origin': corsOrigin });
@@ -1006,6 +1092,25 @@ export async function startProxy(opts = {}) {
1006
1092
  res.end(ERR_METHOD);
1007
1093
  return;
1008
1094
  }
1095
+ // Overage-guard halt check (v4.1, dario#288). Subscribers should never
1096
+ // see a single `representative-claim: overage` response during normal
1097
+ // operation; one means traffic is being reclassified to per-token
1098
+ // billing. Block upstream forwarding with a 503 + Anthropic-shaped
1099
+ // error body until the user runs `dario resume` or the cooldown
1100
+ // auto-expires. Health / status / analytics / admin endpoints above
1101
+ // bypass this check intentionally — the TUI needs them to surface
1102
+ // the halt and the user needs /admin/resume to clear it.
1103
+ if (overageGuard.isHalted()) {
1104
+ requestCount++;
1105
+ const state = overageGuard.state();
1106
+ writeLogLine(logFileStream, {
1107
+ ts: new Date().toISOString(), req: requestCount,
1108
+ method: req.method ?? '', path: urlPath, status: 503, reject: 'overage-halt',
1109
+ });
1110
+ res.writeHead(503, { ...JSON_HEADERS, 'Access-Control-Allow-Origin': corsOrigin });
1111
+ res.end(JSON.stringify(buildHaltErrorBody(state)));
1112
+ return;
1113
+ }
1009
1114
  // Proxy to Anthropic (with concurrency control). The bounded queue
1010
1115
  // replaces the v3.30.x-and-earlier unbounded semaphore — dario#80. A
1011
1116
  // queue-full condition returns an explicit 429 with a `"queue-full"`
@@ -48,10 +48,53 @@ export declare class ProxyClient {
48
48
  * Auto-reconnect is intentionally NOT included. The Hits tab decides
49
49
  * when to retry (and how often) — pushing that policy into here would
50
50
  * couple the client to UI semantics.
51
+ *
52
+ * v4.1 (dario#288): the proxy emits named events alongside the default
53
+ * 'message' event — `event: overage_halt`, `event: overage_warn`,
54
+ * `event: overage_resume`. The `eventType` passed to `onMessage` is
55
+ * the value of the `event:` line on the frame (or `'message'` for an
56
+ * unlabeled / default frame). Existing consumers that pass a
57
+ * single-arg callback continue to work unchanged.
58
+ */
59
+ subscribeAnalyticsStream<T = unknown>(onMessage: (msg: T, eventType?: string) => void, onError?: (err: Error) => void): () => void;
60
+ /**
61
+ * Query the overage-guard state (v4.1, dario#288). Returns the current
62
+ * halt state + configuration. Returns null on any error so the Status
63
+ * tab can render "unknown" without crashing.
51
64
  */
52
- subscribeAnalyticsStream<T = unknown>(onMessage: (msg: T) => void, onError?: (err: Error) => void): () => void;
65
+ getOverageGuard(): Promise<OverageGuardStatus | null>;
66
+ /**
67
+ * Clear the overage-guard halt state. POSTs /admin/resume. Returns the
68
+ * server's response (`wasHalted` indicates whether the call actually
69
+ * cleared a halt vs no-op'd on already-clear state).
70
+ */
71
+ resume(): Promise<{
72
+ ok: boolean;
73
+ wasHalted: boolean;
74
+ resumedAt: string;
75
+ }>;
53
76
  private headers;
54
77
  }
78
+ export interface OverageGuardStatus {
79
+ halted: boolean;
80
+ state: {
81
+ since: number;
82
+ cooldownUntil: number;
83
+ reason: string;
84
+ request: {
85
+ timestamp: number;
86
+ model: string;
87
+ account: string;
88
+ claim: string;
89
+ };
90
+ } | null;
91
+ config: {
92
+ enabled: boolean;
93
+ behavior: 'halt' | 'warn';
94
+ cooldownMs: number;
95
+ notifyOs: boolean;
96
+ };
97
+ }
55
98
  export interface HealthResponse {
56
99
  status: string;
57
100
  oauth: string;
@@ -86,6 +86,13 @@ export class ProxyClient {
86
86
  * Auto-reconnect is intentionally NOT included. The Hits tab decides
87
87
  * when to retry (and how often) — pushing that policy into here would
88
88
  * couple the client to UI semantics.
89
+ *
90
+ * v4.1 (dario#288): the proxy emits named events alongside the default
91
+ * 'message' event — `event: overage_halt`, `event: overage_warn`,
92
+ * `event: overage_resume`. The `eventType` passed to `onMessage` is
93
+ * the value of the `event:` line on the frame (or `'message'` for an
94
+ * unlabeled / default frame). Existing consumers that pass a
95
+ * single-arg callback continue to work unchanged.
89
96
  */
90
97
  subscribeAnalyticsStream(onMessage, onError) {
91
98
  const url = new URL(this.baseUrl + '/analytics/stream');
@@ -121,15 +128,19 @@ export class ProxyClient {
121
128
  while ((idx = buf.indexOf('\n\n')) >= 0) {
122
129
  const frame = buf.slice(0, idx);
123
130
  buf = buf.slice(idx + 2);
124
- const dataLines = frame.split('\n')
131
+ const lines = frame.split('\n');
132
+ const dataLines = lines
125
133
  .filter(l => l.startsWith('data:'))
126
134
  .map(l => l.slice(5).replace(/^ /, ''));
127
135
  if (dataLines.length === 0)
128
136
  continue;
137
+ // Pull the `event:` line if present. Default is 'message' per SSE spec.
138
+ const eventLine = lines.find(l => l.startsWith('event:'));
139
+ const eventType = eventLine ? eventLine.slice(6).trim() : 'message';
129
140
  const payload = dataLines.join('\n');
130
141
  try {
131
142
  const parsed = JSON.parse(payload);
132
- onMessage(parsed);
143
+ onMessage(parsed, eventType);
133
144
  }
134
145
  catch (e) {
135
146
  onError?.(new Error(`SSE parse: ${e.message}`));
@@ -157,6 +168,59 @@ export class ProxyClient {
157
168
  catch { /* ignored */ }
158
169
  };
159
170
  }
171
+ /**
172
+ * Query the overage-guard state (v4.1, dario#288). Returns the current
173
+ * halt state + configuration. Returns null on any error so the Status
174
+ * tab can render "unknown" without crashing.
175
+ */
176
+ async getOverageGuard() {
177
+ try {
178
+ return await this.getJson('/admin/resume');
179
+ }
180
+ catch {
181
+ return null;
182
+ }
183
+ }
184
+ /**
185
+ * Clear the overage-guard halt state. POSTs /admin/resume. Returns the
186
+ * server's response (`wasHalted` indicates whether the call actually
187
+ * cleared a halt vs no-op'd on already-clear state).
188
+ */
189
+ async resume() {
190
+ const url = new URL(this.baseUrl + '/admin/resume');
191
+ return new Promise((resolve, reject) => {
192
+ const req = httpRequest({
193
+ hostname: url.hostname,
194
+ port: url.port || 80,
195
+ path: url.pathname,
196
+ method: 'POST',
197
+ headers: { ...this.headers(), 'Content-Type': 'application/json', 'Content-Length': '2' },
198
+ }, (res) => {
199
+ const chunks = [];
200
+ res.on('data', (c) => chunks.push(c));
201
+ res.on('end', () => {
202
+ const body = Buffer.concat(chunks).toString('utf-8');
203
+ if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) {
204
+ reject(new Error(`HTTP ${res.statusCode}: ${body.slice(0, 200)}`));
205
+ return;
206
+ }
207
+ try {
208
+ resolve(JSON.parse(body));
209
+ }
210
+ catch (e) {
211
+ reject(new Error(`JSON parse: ${e.message}`));
212
+ }
213
+ });
214
+ res.on('error', reject);
215
+ });
216
+ req.on('error', reject);
217
+ req.setTimeout(this.timeoutMs, () => {
218
+ req.destroy(new Error(`timeout after ${this.timeoutMs}ms`));
219
+ });
220
+ req.write('{}');
221
+ req.end();
222
+ });
223
+ }
160
224
  headers() {
161
225
  const h = {};
162
226
  if (this.apiKey)
@@ -102,6 +102,19 @@ export const AnalyticsTab = {
102
102
  lines.push(' ' + pad('7d', 6) +
103
103
  fg('cyan', progressBar(s.utilization.lastUtil7d, barWidth)) +
104
104
  ' ' + dim(`${(s.utilization.lastUtil7d * 100).toFixed(0)}%`));
105
+ // Overage bucket (v4.1, dario#288). Count of requests that landed in
106
+ // the overage bucket within the rolling window. Empty bar in normal
107
+ // operation; non-zero count renders in red. Hard zero IS the success
108
+ // signal here — anything else is "investigate immediately."
109
+ const overageCount = s.window.billingBucketBreakdown?.extra_usage ?? 0;
110
+ const totalCount = Object.values(s.window.billingBucketBreakdown ?? {}).reduce((a, b) => a + b, 0);
111
+ const overageFrac = totalCount > 0 ? overageCount / totalCount : 0;
112
+ const overageColor = overageCount > 0 ? 'red' : 'cyan';
113
+ lines.push(' ' + pad('Overage', 6) +
114
+ fg(overageColor, progressBar(overageFrac, barWidth)) +
115
+ ' ' + (overageCount > 0
116
+ ? fg('red', `${overageCount} req`) + dim(` of ${totalCount}`)
117
+ : dim('0 ← clean')));
105
118
  // ── Billing buckets ───────────────────────────────────────
106
119
  const buckets = s.window.billingBucketBreakdown;
107
120
  const totalBucketCount = Object.values(buckets).reduce((a, b) => a + b, 0);
@@ -38,6 +38,11 @@ const FIELDS = [
38
38
  { path: 'thinkTime.maxMs', label: 'Think-time cap (ms)', type: 'number', hint: 'upper bound for the whole formula' },
39
39
  { path: 'sessionStart.minMs', label: 'Session-start min', type: 'number', hint: 'first-request delay floor' },
40
40
  { path: 'sessionStart.jitterMs', label: 'Session-start jitter', type: 'number' },
41
+ // ── Overage-guard (v4.1, dario#288) ─────────────────────────
42
+ { path: 'overageGuard.enabled', label: 'Overage-guard', type: 'bool', hint: 'halt proxy on any representative-claim=overage' },
43
+ { path: 'overageGuard.behavior', label: 'Overage behavior', type: 'string', hint: '"halt" (default) or "warn"' },
44
+ { path: 'overageGuard.cooldownMs', label: 'Overage cooldown (ms)', type: 'number', hint: 'auto-resume delay; default 1800000 (30 min)' },
45
+ { path: 'overageGuard.notifyOs', label: 'Overage OS-notify', type: 'bool', hint: 'native desktop notification on halt' },
41
46
  ];
42
47
  export const ConfigTab = {
43
48
  id: 'config',
@@ -25,10 +25,24 @@
25
25
  */
26
26
  import type { Tab } from '../tab.js';
27
27
  import type { RequestRecord } from '../../analytics.js';
28
+ /** Live overage-halt state — populated from SSE event:overage_halt frames. */
29
+ interface HitsHaltState {
30
+ since: number;
31
+ cooldownUntil: number;
32
+ request: {
33
+ timestamp: number;
34
+ model: string;
35
+ account: string;
36
+ claim: string;
37
+ };
38
+ }
28
39
  export interface HitsState {
29
40
  buffer: RequestRecord[];
30
41
  selectedIdx: number;
31
42
  subscribed: boolean;
32
43
  connectionError: string | null;
44
+ /** Overage-guard halt banner (v4.1, dario#288). Null when running normally. */
45
+ halt: HitsHaltState | null;
33
46
  }
34
47
  export declare const HitsTab: Tab<HitsState>;
48
+ export {};
@@ -32,13 +32,28 @@ export const HitsTab = {
32
32
  label: 'Hits',
33
33
  hotkey: 'h',
34
34
  initialState() {
35
- return { buffer: [], selectedIdx: -1, subscribed: false, connectionError: null };
35
+ return { buffer: [], selectedIdx: -1, subscribed: false, connectionError: null, halt: null };
36
36
  },
37
37
  onMount(_state, ctx) {
38
38
  // Subscribe to the live stream. Each record is prepended-conceptually
39
39
  // (we push to the array and render in reverse, which keeps the
40
40
  // buffer's mutation simple — Array.push is O(1) while unshift is O(n)).
41
- const close = ctx.client.subscribeAnalyticsStream((record) => {
41
+ //
42
+ // The same stream carries named events for overage-halt / -resume
43
+ // (v4.1, dario#288). The SSE event type is the second argument; we
44
+ // route on it.
45
+ const close = ctx.client.subscribeAnalyticsStream((payload, eventType) => {
46
+ if (eventType === 'overage_halt' || eventType === 'overage_warn') {
47
+ const state = payload;
48
+ ctx.setState((s) => ({ ...s, halt: state }));
49
+ return;
50
+ }
51
+ if (eventType === 'overage_resume') {
52
+ ctx.setState((s) => ({ ...s, halt: null }));
53
+ return;
54
+ }
55
+ // Default ('message') = RequestRecord
56
+ const record = payload;
42
57
  ctx.setState((s) => {
43
58
  const next = {
44
59
  ...s,
@@ -123,6 +138,16 @@ export const HitsTab = {
123
138
  const colIn = 8, colOut = 7, colLat = 7, colStatus = 5;
124
139
  lines.push(' ' + brand('Hits') +
125
140
  dim(` ${state.buffer.length} buffered · ${state.subscribed ? fg('green', 'live') : fg('yellow', 'disconnected')}`));
141
+ // ── Overage-halt banner (v4.1, dario#288) ──────────────────
142
+ // Pinned at the top so it's always visible while scrolling the buffer.
143
+ if (state.halt) {
144
+ const since = formatTimestamp(state.halt.since);
145
+ const cooldown = formatRemaining(state.halt.cooldownUntil - Date.now());
146
+ const line1 = ` ${fg('red', '⚠ HALTED')} overage detected at ${since} on ${state.halt.request.model} (account=${state.halt.request.account})`;
147
+ const line2 = ` ${dim('→ New /v1/messages requests return 503 until')} ${fg('cyan', 'R')} ${dim('here, or')} ${fg('cyan', 'dario resume')}${dim(' from any shell. Auto-resume in')} ${cooldown}${dim('.')}`;
148
+ lines.push(line1);
149
+ lines.push(line2);
150
+ }
126
151
  lines.push('');
127
152
  // Header row (aligned with data rows)
128
153
  lines.push(' ' + dim(pad('time', colTime) +
@@ -133,7 +158,10 @@ export const HitsTab = {
133
158
  pad('st', colStatus)));
134
159
  for (let i = startIdx; i < endIdx; i++) {
135
160
  const r = newestFirst[i];
136
- const marker = i === state.selectedIdx ? fg('cyan', '▎') : ' ';
161
+ const isOverage = r.claim === 'overage';
162
+ const marker = i === state.selectedIdx ? fg('cyan', '▎')
163
+ : isOverage ? fg('red', '!')
164
+ : ' ';
137
165
  const row = marker + ' ' +
138
166
  pad(formatTime(r.timestamp), colTime) +
139
167
  pad(shortenModel(r.model), colModel) +
@@ -141,7 +169,16 @@ export const HitsTab = {
141
169
  pad(formatTokens(r.outputTokens), colOut) +
142
170
  pad(formatLatency(r.latencyMs), colLat) +
143
171
  pad(formatStatus(r.status), colStatus);
144
- lines.push(i === state.selectedIdx ? inverse(truncate(row, w - 2)) : truncate(row, w - 2));
172
+ // Overage rows render in red even when unselected; selection still
173
+ // wins via the inverse() wrapper so the user can drill into one.
174
+ let styled;
175
+ if (i === state.selectedIdx)
176
+ styled = inverse(truncate(row, w - 2));
177
+ else if (isOverage)
178
+ styled = fg('red', truncate(row, w - 2));
179
+ else
180
+ styled = truncate(row, w - 2);
181
+ lines.push(styled);
145
182
  }
146
183
  // Scroll hint
147
184
  if (newestFirst.length > listRows) {
@@ -221,3 +258,16 @@ function tokenBreakdown(r) {
221
258
  parts.push(`thinking ${r.thinkingTokens}`);
222
259
  return parts.join(' / ');
223
260
  }
261
+ function formatTimestamp(ts) {
262
+ const d = new Date(ts);
263
+ return `${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`;
264
+ }
265
+ function formatRemaining(ms) {
266
+ if (ms <= 0)
267
+ return fg('yellow', 'now');
268
+ const s = Math.floor(ms / 1000);
269
+ if (s < 60)
270
+ return `${s}s`;
271
+ const m = Math.floor(s / 60);
272
+ return m < 60 ? `${m}m ${s % 60}s` : `${Math.floor(m / 60)}h ${m % 60}m`;
273
+ }
@@ -20,6 +20,7 @@
20
20
  * └─────────────────────────────────────────────────┘
21
21
  */
22
22
  import type { Tab, TabContext } from '../tab.js';
23
+ import type { OverageGuardStatus } from '../proxy-client.js';
23
24
  export interface StatusState {
24
25
  loading: boolean;
25
26
  /** Proxy /health response, or null if unreachable. */
@@ -31,6 +32,12 @@ export interface StatusState {
31
32
  } | null;
32
33
  /** Config-file load source: file | missing | invalid. */
33
34
  configSource: 'file' | 'missing' | 'invalid' | null;
35
+ /** Overage-guard state from /admin/resume — null if unreachable. */
36
+ overageGuard: OverageGuardStatus | null;
37
+ /** Transient: did we just attempt a manual resume? */
38
+ resumePending: boolean;
39
+ resumeMessage: string | null;
40
+ resumeKind: 'success' | 'info' | 'error' | null;
34
41
  /** Last refresh timestamp (ms). */
35
42
  lastRefreshAt: number;
36
43
  /** Error from the last refresh attempt, if any. */
@@ -43,3 +50,10 @@ export declare const StatusTab: Tab<StatusState>;
43
50
  * 'r' without re-running the full onMount flow.
44
51
  */
45
52
  export declare function refreshStatus(ctx: TabContext): Promise<StatusState>;
53
+ /**
54
+ * Fire the manual-resume POST and update state. Called by TuiApp when the
55
+ * Status tab returns a state with resumePending=true (the `R` key path).
56
+ * Lives here next to refreshStatus so all status-tab-side-effects sit
57
+ * together.
58
+ */
59
+ export declare function performResume(ctx: TabContext<StatusState>): Promise<Partial<StatusState>>;
@@ -30,6 +30,10 @@ export const StatusTab = {
30
30
  loading: true,
31
31
  health: null,
32
32
  configSource: null,
33
+ overageGuard: null,
34
+ resumePending: false,
35
+ resumeMessage: null,
36
+ resumeKind: null,
33
37
  lastRefreshAt: 0,
34
38
  error: null,
35
39
  };
@@ -37,12 +41,40 @@ export const StatusTab = {
37
41
  async onMount(_state, ctx) {
38
42
  return refreshStatus(ctx);
39
43
  },
44
+ onTick(state, ctx) {
45
+ // Drive the async side-effects that onKey can't fire directly.
46
+ if (state.resumePending) {
47
+ void performResume(ctx).then((delta) => ctx.setState(delta));
48
+ return;
49
+ }
50
+ // Poll the overage-guard state every 2s so the halt countdown stays
51
+ // current without the user having to press `r`. Cheap GET; the proxy
52
+ // is on loopback. Skip when the rest of the status is still loading.
53
+ if (!state.loading && state.overageGuard !== null && state.overageGuard.halted) {
54
+ // While halted, refresh every 2s so the countdown updates.
55
+ const since = Date.now() - state.lastRefreshAt;
56
+ if (since >= 2000) {
57
+ void ctx.client.getOverageGuard().then((g) => {
58
+ if (g)
59
+ ctx.setState({ overageGuard: g, lastRefreshAt: Date.now() });
60
+ });
61
+ }
62
+ }
63
+ },
40
64
  onKey(state, key) {
41
- // `r` triggers a manual refresh by signaling the parent to call
42
- // onMount again. The parent watches for a sentinel state.
65
+ // `r` triggers a manual refresh signal the parent to call onMount again.
43
66
  if (key.name === 'printable' && key.ch === 'r' && !key.ctrl) {
44
67
  return { ...state, loading: true };
45
68
  }
69
+ // `R` (shift-r) resumes the overage-guard halt state when one is active.
70
+ // Returning a sentinel state with resumePending=true signals the parent
71
+ // to fire the async resume() call.
72
+ if (key.name === 'printable' && key.ch === 'R' && !key.ctrl) {
73
+ if (state.overageGuard?.halted) {
74
+ return { ...state, resumePending: true, resumeMessage: 'Resuming…', resumeKind: 'info' };
75
+ }
76
+ return { ...state, resumeMessage: 'Nothing to resume — proxy is not halted.', resumeKind: 'info' };
77
+ }
46
78
  return undefined;
47
79
  },
48
80
  render(state, dim_) {
@@ -75,12 +107,52 @@ export const StatusTab = {
75
107
  : dim('not loaded');
76
108
  lines.push(' ' + renderKvRow('Source', sourceLabel, w - 4));
77
109
  lines.push('');
110
+ // ── Overage-guard section (v4.1, dario#288) ────────────────
111
+ if (state.overageGuard) {
112
+ lines.push(' ' + brand('Overage-guard'));
113
+ if (state.overageGuard.halted && state.overageGuard.state) {
114
+ const s = state.overageGuard.state;
115
+ const remainingMs = Math.max(0, s.cooldownUntil - Date.now());
116
+ const remaining = formatDuration(remainingMs);
117
+ // Red banner header — this is the loud surface when halted
118
+ lines.push(' ' + fg('red', '⚠ HALTED') + ' ' + dim(`overage detected ${formatAgo(s.since)} ago`));
119
+ lines.push(' ' + renderKvRow('Request', `${s.request.model} ${dim('account=' + s.request.account)}`, w - 4));
120
+ lines.push(' ' + renderKvRow('Cause', `representative-claim = ${fg('red', s.request.claim)}`, w - 4));
121
+ lines.push(' ' + renderKvRow('Auto-resume in', remaining === '0s' ? fg('yellow', 'now (cooldown elapsed)') : remaining, w - 4));
122
+ lines.push(' ' + renderKvRow('Manual resume', `press ${fg('cyan', 'R')} here, or ${fg('cyan', 'dario resume')} from any shell`, w - 4));
123
+ }
124
+ else {
125
+ lines.push(' ' + renderKvRow('State', fg('green', 'normal'), w - 4));
126
+ const cfg = state.overageGuard.config;
127
+ lines.push(' ' + renderKvRow('Mode', `${cfg.enabled ? fg('green', 'enabled') : fg('yellow', 'disabled')} ${dim(`behavior=${cfg.behavior} cooldown=${formatDuration(cfg.cooldownMs)}`)}`, w - 4));
128
+ }
129
+ if (state.resumeMessage) {
130
+ const c = state.resumeKind === 'error' ? 'red' : state.resumeKind === 'success' ? 'green' : 'cyan';
131
+ lines.push(' ' + fg(c, state.resumeMessage));
132
+ }
133
+ lines.push('');
134
+ }
78
135
  // ── Footer hint ────────────────────────────────────────────
79
136
  lines.push('');
80
- lines.push(' ' + dim(`Last refresh: ${formatAgo(state.lastRefreshAt)}. Press ${fg('cyan', 'r')} to refresh.`));
137
+ const resumeHint = state.overageGuard?.halted ? ` · ${fg('cyan', 'R')} resume` : '';
138
+ lines.push(' ' + dim(`Last refresh: ${formatAgo(state.lastRefreshAt)}. ${fg('cyan', 'r')} refresh${resumeHint}.`));
81
139
  return lines.join('\n');
82
140
  },
83
141
  };
142
+ function formatDuration(ms) {
143
+ if (ms <= 0)
144
+ return '0s';
145
+ const s = Math.floor(ms / 1000);
146
+ if (s < 60)
147
+ return `${s}s`;
148
+ const m = Math.floor(s / 60);
149
+ const rs = s % 60;
150
+ if (m < 60)
151
+ return rs > 0 ? `${m}m ${rs}s` : `${m}m`;
152
+ const h = Math.floor(m / 60);
153
+ const rm = m % 60;
154
+ return rm > 0 ? `${h}h ${rm}m` : `${h}h`;
155
+ }
84
156
  /**
85
157
  * Refresh the Status tab's data — probe /health, load config file
86
158
  * metadata. Exported separately so the parent can re-invoke on key
@@ -98,14 +170,48 @@ export async function refreshStatus(ctx) {
98
170
  catch (e) {
99
171
  error = e.message;
100
172
  }
173
+ // Overage-guard state — best-effort; never throws (proxy-client wraps the
174
+ // GET in try/catch and returns null). Surface as 'unknown' when null.
175
+ const overageGuard = await ctx.client.getOverageGuard();
101
176
  return {
102
177
  loading: false,
103
178
  health,
104
179
  configSource: fileResult.source,
180
+ overageGuard,
181
+ resumePending: false,
182
+ resumeMessage: null,
183
+ resumeKind: null,
105
184
  lastRefreshAt: Date.now(),
106
185
  error,
107
186
  };
108
187
  }
188
+ /**
189
+ * Fire the manual-resume POST and update state. Called by TuiApp when the
190
+ * Status tab returns a state with resumePending=true (the `R` key path).
191
+ * Lives here next to refreshStatus so all status-tab-side-effects sit
192
+ * together.
193
+ */
194
+ export async function performResume(ctx) {
195
+ try {
196
+ const result = await ctx.client.resume();
197
+ const refreshed = await ctx.client.getOverageGuard();
198
+ return {
199
+ overageGuard: refreshed,
200
+ resumePending: false,
201
+ resumeMessage: result.wasHalted
202
+ ? `Resumed at ${result.resumedAt}.`
203
+ : 'Already running normally — no-op.',
204
+ resumeKind: 'success',
205
+ };
206
+ }
207
+ catch (e) {
208
+ return {
209
+ resumePending: false,
210
+ resumeMessage: `Resume failed: ${e.message}`,
211
+ resumeKind: 'error',
212
+ };
213
+ }
214
+ }
109
215
  function formatOauth(label, expiresIn) {
110
216
  if (label === 'healthy') {
111
217
  return fg('green', expiresIn ? `healthy (expires in ${expiresIn})` : 'healthy');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askalf/dario",
3
- "version": "4.0.1",
3
+ "version": "4.1.0",
4
4
  "description": "Use your Claude Pro/Max subscription in any tool — Cursor, Cline, Aider, the Agent SDK, your scripts — at subscription pricing, not per-token API bills. One local Anthropic + OpenAI-compatible endpoint.",
5
5
  "type": "module",
6
6
  "bin": {