@askalf/dario 4.0.1 → 4.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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.1",
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": {