agent-dealer 1.1.10 → 1.2.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.
Files changed (30) hide show
  1. package/bundle/server/dist/capacity/claude-events.js +368 -0
  2. package/bundle/server/dist/capacity/claude-events.test.js +322 -0
  3. package/bundle/server/dist/capacity/codex-app-server.js +602 -0
  4. package/bundle/server/dist/capacity/codex-app-server.test.js +303 -0
  5. package/bundle/server/dist/capacity/cursor-team.js +663 -0
  6. package/bundle/server/dist/capacity/cursor-team.test.js +556 -0
  7. package/bundle/server/dist/capacity/muse.js +597 -0
  8. package/bundle/server/dist/capacity/muse.test.js +282 -0
  9. package/bundle/server/dist/coordinator/developer-effect.js +12 -0
  10. package/bundle/server/dist/coordinator/prompts.js +1 -1
  11. package/bundle/server/dist/coordinator/prompts.test.js +4 -2
  12. package/bundle/server/dist/coordinator/reviewer-effect.js +12 -0
  13. package/bundle/server/dist/db/index.js +44 -0
  14. package/bundle/server/dist/db/schema.sql +25 -0
  15. package/bundle/server/dist/repository/cursor-team-billing.js +103 -0
  16. package/bundle/server/dist/repository/runtime-capacity.js +16 -0
  17. package/bundle/server/dist/routes/cursor-team-billing.test.js +88 -0
  18. package/bundle/server/dist/routes/index.js +53 -2
  19. package/bundle/server/dist/routes/runtime-capacity.test.js +71 -1
  20. package/bundle/server/package.json +2 -2
  21. package/bundle/server/static-ui/assets/index-J1u7JDns.css +1 -0
  22. package/bundle/server/static-ui/assets/index-obJbiSaU.js +60 -0
  23. package/bundle/server/static-ui/index.html +2 -2
  24. package/bundle/shared/dist/runtime-capacity.d.ts +96 -0
  25. package/bundle/shared/dist/runtime-capacity.js +77 -0
  26. package/bundle/shared/dist/runtime-capacity.test.js +50 -1
  27. package/bundle/shared/package.json +1 -1
  28. package/package.json +1 -1
  29. package/bundle/server/static-ui/assets/index-C2UigoIF.js +0 -60
  30. package/bundle/server/static-ui/assets/index-DMhdyiFQ.css +0 -1
@@ -0,0 +1,368 @@
1
+ // packages/server/src/capacity/claude-events.ts
2
+ //
3
+ // NOT-248: Claude capacity adapter over naturally emitted structured
4
+ // rate-limit events from Dealer-managed Claude sessions.
5
+ //
6
+ // Observational only. This module parses `rate_limit_event.rate_limit_info.
7
+ // unifiedWindows` out of session NDJSON that Claude Code already emitted
8
+ // during real work and persists the windows as normalized capacity snapshots
9
+ // (source `observed_event`). It never launches `claude -p`, `/usage`, or any
10
+ // other synthetic model call, never touches Anthropic's undocumented OAuth
11
+ // usage endpoint, and never reads plan names, token totals, or session cost.
12
+ //
13
+ // Separation from NOT-111 hard caps: this module never reads or writes
14
+ // `runtime_availability`. A rejected rate-limit event still flows through
15
+ // `runners/usage-cap.ts` for defer/admission; a session whose events carry
16
+ // unified windows additionally flows through here for capacity — the two
17
+ // states coexist on independent rows.
18
+ //
19
+ // Observed-at semantics (repair round 2 — a session-end stamp is a lie for a
20
+ // long session, so each event keeps its own time):
21
+ // - When the event carries its own timestamp it is used (clamped to `nowMs` —
22
+ // an event cannot be newer than the session end that is ingesting it).
23
+ // - Otherwise the caller passes the session's spawn start
24
+ // (`fallbackObservedAtMs`); the event happened no earlier than spawn, so
25
+ // this understates freshness rather than overstating it.
26
+ // - Only when neither exists does ingestion time (`nowMs`) apply.
27
+ // - A reading never overwrites a stored row whose `observed_at` is newer, so
28
+ // a later-finishing long session cannot clobber a concurrent session's
29
+ // fresher reading.
30
+ //
31
+ // Provider shape (tolerant — only the dotted path above is contractual):
32
+ // - `unifiedWindows` may be an array of window objects or a map of
33
+ // bucket-name → window object. Array entries name their bucket via
34
+ // `window`, `bucket`, `name`, `id`, `key`, `limitType`, `rateLimitType`,
35
+ // or `type` (first present string wins).
36
+ // - Utilization is a 0–1 fraction (`utilization`, `used`, `usedFraction`,
37
+ // `used_fraction`); an explicit 0–100 scale (`usedPercent`,
38
+ // `used_percent`, `utilizationPercent`, `utilization_percent`) takes
39
+ // precedence when both are present. A bare finite number as a map value
40
+ // reads as a utilization fraction. Entries without a usable scale are
41
+ // skipped — never fabricated.
42
+ // - Reset time accepts epoch seconds, epoch milliseconds, or ISO-8601
43
+ // (`resetsAt`, `resets_at`, `resetAt`, `reset_at`); missing/unparsable
44
+ // resets persist as null.
45
+ // - Duration accepts explicit minute fields when present; otherwise it is
46
+ // inferred from well-known bucket names only (`five_hour` → 300,
47
+ // `seven_day*`/`weekly` → 10080). Anything else keeps duration null and
48
+ // the provider label verbatim — never guessed.
49
+ // - Every distinct provider bucket keeps its identity: `providerBucket` is
50
+ // the raw bucket string and `windowKey` is namespaced from it, so
51
+ // five-hour, seven-day, and model/overage-specific seven-day buckets
52
+ // persist as independent rows.
53
+ import fs from "node:fs";
54
+ import { parseNdjson } from "../runners/stream-json.js";
55
+ import { listCapacitySnapshots, recordCapacitySnapshots, } from "../repository/runtime-capacity.js";
56
+ import { normalizeAdapterWindow, } from "./adapter.js";
57
+ export const CLAUDE_RUNTIME = "claude_code";
58
+ /** Evidence refs are static pointers — never credentials or raw payloads. */
59
+ export const CLAUDE_UNIFIED_WINDOWS_EVIDENCE_REF = "claude-session:unified-windows";
60
+ function asFiniteNumber(value) {
61
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
62
+ }
63
+ function pickNumber(candidates) {
64
+ for (const c of candidates) {
65
+ const n = asFiniteNumber(c);
66
+ if (n !== null)
67
+ return n;
68
+ }
69
+ return null;
70
+ }
71
+ function pickString(candidates) {
72
+ for (const c of candidates) {
73
+ if (typeof c === "string" && c.length > 0)
74
+ return c;
75
+ }
76
+ return null;
77
+ }
78
+ /** resetsAt arrives as epoch seconds, epoch ms, or ISO-8601 — normalize to ISO. */
79
+ export function normalizeClaudeResetsAt(value) {
80
+ if (value === null || value === undefined)
81
+ return null;
82
+ if (typeof value === "number") {
83
+ if (!Number.isFinite(value) || value <= 0)
84
+ return null;
85
+ const ms = value > 1e12 ? value : value * 1000;
86
+ const d = new Date(ms);
87
+ return Number.isFinite(d.getTime()) ? d.toISOString() : null;
88
+ }
89
+ if (typeof value === "string") {
90
+ const trimmed = value.trim();
91
+ if (!trimmed)
92
+ return null;
93
+ if (/^-?\d+(\.\d+)?$/.test(trimmed))
94
+ return normalizeClaudeResetsAt(Number(trimmed));
95
+ const ms = Date.parse(trimmed);
96
+ return Number.isFinite(ms) ? new Date(ms).toISOString() : null;
97
+ }
98
+ return null;
99
+ }
100
+ /**
101
+ * Parse an event timestamp (ISO-8601, epoch seconds, or epoch milliseconds)
102
+ * to epoch ms. Returns null when the value carries no usable time — the
103
+ * caller then falls back conservatively to the session spawn start.
104
+ */
105
+ export function parseClaudeEventTimestamp(value) {
106
+ if (typeof value === "number") {
107
+ if (!Number.isFinite(value) || value <= 0)
108
+ return null;
109
+ return value > 1e12 ? value : value * 1000;
110
+ }
111
+ if (typeof value === "string") {
112
+ const trimmed = value.trim();
113
+ if (!trimmed)
114
+ return null;
115
+ if (/^-?\d+(\.\d+)?$/.test(trimmed))
116
+ return parseClaudeEventTimestamp(Number(trimmed));
117
+ const ms = Date.parse(trimmed);
118
+ return Number.isFinite(ms) ? ms : null;
119
+ }
120
+ return null;
121
+ }
122
+ const EVENT_TIMESTAMP_KEYS = [
123
+ "timestamp",
124
+ "eventTimestamp",
125
+ "event_timestamp",
126
+ "emittedAt",
127
+ "emitted_at",
128
+ "observedAt",
129
+ "observed_at",
130
+ "ts",
131
+ "time",
132
+ ];
133
+ /**
134
+ * Per-event observed time: the event's own timestamp when present and valid
135
+ * (clamped to `nowMs`), otherwise the session spawn start passed by the
136
+ * caller, otherwise ingestion time. Stream-json lines carry no documented
137
+ * timestamp field, so several spellings are accepted — an unknown shape
138
+ * simply falls through to the conservative spawn-start fallback.
139
+ */
140
+ export function claudeEventObservedAt(event, nowMs, fallbackObservedAtMs) {
141
+ const fallback = new Date(Math.min(fallbackObservedAtMs, nowMs)).toISOString();
142
+ for (const key of EVENT_TIMESTAMP_KEYS) {
143
+ if (event[key] === undefined || event[key] === null)
144
+ continue;
145
+ const parsed = parseClaudeEventTimestamp(event[key]);
146
+ if (parsed === null)
147
+ continue;
148
+ return new Date(Math.min(parsed, nowMs)).toISOString();
149
+ }
150
+ return fallback;
151
+ }
152
+ function sanitizeKeySegment(name) {
153
+ const s = name
154
+ .toLowerCase()
155
+ .replace(/[^a-z0-9]+/g, "_")
156
+ .replace(/^_+|_+$/g, "");
157
+ return s || "window";
158
+ }
159
+ /**
160
+ * Duration inference from well-known provider bucket names only. Model and
161
+ * overage variants (e.g. `seven_day_sonnet`) share the seven-day length but
162
+ * keep their own bucket identity — only the duration is shared, never the
163
+ * row.
164
+ */
165
+ export function inferClaudeDurationMinutes(bucket) {
166
+ const b = bucket.toLowerCase();
167
+ if (/five[\s_-]*hour/.test(b))
168
+ return 300;
169
+ if (/seven[\s_-]*day/.test(b) || b === "weekly")
170
+ return 10080;
171
+ return null;
172
+ }
173
+ function windowReadingFromEntry(bucket, entry, observedAt) {
174
+ const cleanBucket = bucket.trim();
175
+ if (!cleanBucket)
176
+ return null;
177
+ let usedFraction = null;
178
+ let usedPercent = null;
179
+ let resetRaw = null;
180
+ let durationRaw = null;
181
+ if (typeof entry === "number") {
182
+ // Bare map value (`"five_hour": 0.17`) reads as a utilization fraction.
183
+ usedFraction = asFiniteNumber(entry);
184
+ }
185
+ else if (entry && typeof entry === "object") {
186
+ const w = entry;
187
+ usedPercent = pickNumber([
188
+ w.usedPercent,
189
+ w.used_percent,
190
+ w.utilizationPercent,
191
+ w.utilization_percent,
192
+ ]);
193
+ usedFraction = pickNumber([w.utilization, w.used, w.usedFraction, w.used_fraction]);
194
+ resetRaw = w.resetsAt ?? w.resets_at ?? w.resetAt ?? w.reset_at ?? null;
195
+ durationRaw = pickNumber([
196
+ w.durationMinutes,
197
+ w.duration_minutes,
198
+ w.windowDurationMins,
199
+ w.window_duration_mins,
200
+ w.windowDurationMinutes,
201
+ ]);
202
+ }
203
+ else {
204
+ return null;
205
+ }
206
+ if (usedPercent === null && usedFraction === null)
207
+ return null;
208
+ const durationMinutes = durationRaw !== null ? Math.round(durationRaw) : inferClaudeDurationMinutes(cleanBucket);
209
+ return {
210
+ windowKey: `claude_unified_${sanitizeKeySegment(cleanBucket)}`,
211
+ providerBucket: cleanBucket,
212
+ durationMinutes,
213
+ providerLabel: cleanBucket,
214
+ usedValue: usedPercent ?? usedFraction,
215
+ usedUnit: usedPercent !== null ? "percent" : "fraction",
216
+ ...(usedPercent !== null ? { usedPercent } : { usedFraction: usedFraction }),
217
+ resetAt: normalizeClaudeResetsAt(resetRaw),
218
+ observedAt,
219
+ source: "observed_event",
220
+ evidenceRef: CLAUDE_UNIFIED_WINDOWS_EVIDENCE_REF,
221
+ };
222
+ }
223
+ /**
224
+ * Normalize one `rate_limit_info` object's `unifiedWindows` into adapter
225
+ * readings. Returns null when nothing usable is present (missing field,
226
+ * wrong shape, or no entry with a usable scale) — the caller records
227
+ * nothing rather than fabricating a value.
228
+ */
229
+ export function claudeUnifiedWindowsToReadings(rateLimitInfo, observedAt = new Date().toISOString()) {
230
+ if (!rateLimitInfo || typeof rateLimitInfo !== "object")
231
+ return null;
232
+ const raw = rateLimitInfo.unifiedWindows;
233
+ if (!raw || typeof raw !== "object")
234
+ return null;
235
+ // Last entry wins per window key; identities never collapse across
236
+ // distinct buckets.
237
+ const byKey = new Map();
238
+ const push = (bucket, entry) => {
239
+ if (typeof bucket !== "string" || !bucket.trim())
240
+ return;
241
+ const reading = windowReadingFromEntry(bucket, entry, observedAt);
242
+ if (reading)
243
+ byKey.set(reading.windowKey, reading);
244
+ };
245
+ if (Array.isArray(raw)) {
246
+ for (const item of raw) {
247
+ if (!item || typeof item !== "object")
248
+ continue;
249
+ const w = item;
250
+ push(pickString([
251
+ w.window,
252
+ w.bucket,
253
+ w.name,
254
+ w.id,
255
+ w.key,
256
+ w.limitType,
257
+ w.limit_type,
258
+ w.rateLimitType,
259
+ w.rate_limit_type,
260
+ w.type,
261
+ ]), item);
262
+ }
263
+ }
264
+ else {
265
+ for (const [bucket, entry] of Object.entries(raw)) {
266
+ push(bucket, entry);
267
+ }
268
+ }
269
+ return byKey.size > 0 ? { windows: [...byKey.values()] } : null;
270
+ }
271
+ /**
272
+ * Scan Claude session events for `rate_limit_event`s carrying usable
273
+ * `unifiedWindows`. Status is irrelevant — allowed events carry the
274
+ * utilization signal; rejected ones additionally drive NOT-111 elsewhere.
275
+ * Each window keeps the freshest reading seen for its key (ties break toward
276
+ * the later event), and every reading is stamped with its own event time —
277
+ * never a single session-end stamp. Returns null when no event yields a
278
+ * reading.
279
+ */
280
+ export function extractClaudeCapacityFromEvents(events, nowMs = Date.now(), fallbackObservedAtMs = nowMs) {
281
+ const byKey = new Map();
282
+ for (const e of events) {
283
+ if (e.type !== "rate_limit_event")
284
+ continue;
285
+ const info = e.rate_limit_info;
286
+ if (!info || typeof info !== "object")
287
+ continue;
288
+ const observedAt = claudeEventObservedAt(e, nowMs, fallbackObservedAtMs);
289
+ const parsed = claudeUnifiedWindowsToReadings(info, observedAt);
290
+ if (!parsed)
291
+ continue;
292
+ for (const w of parsed.windows) {
293
+ const prev = byKey.get(w.windowKey);
294
+ // observedAt is always set by windowReadingFromEntry; `?? ""` only
295
+ // satisfies the optional field type (NaN never wins a comparison).
296
+ if (!prev || Date.parse(w.observedAt ?? "") >= Date.parse(prev.observedAt ?? "")) {
297
+ byKey.set(w.windowKey, w);
298
+ }
299
+ }
300
+ }
301
+ if (byKey.size === 0)
302
+ return null;
303
+ return { runtime: CLAUDE_RUNTIME, windows: [...byKey.values()], unavailable: [] };
304
+ }
305
+ /**
306
+ * Persist naturally observed unified windows as normalized capacity
307
+ * snapshots (per-window upserts — siblings not in this observation are left
308
+ * untouched, and a stored row newer than the incoming reading is never
309
+ * overwritten). Returns the number of windows persisted, or null when the
310
+ * runtime is not Claude or no usable windows were observed. Never touches
311
+ * `runtime_availability`.
312
+ */
313
+ export function recordClaudeCapacityFromEvents(events, runtime, nowMs = Date.now(), fallbackObservedAtMs = nowMs) {
314
+ if (runtime !== CLAUDE_RUNTIME)
315
+ return null;
316
+ const result = extractClaudeCapacityFromEvents(events, nowMs, fallbackObservedAtMs);
317
+ if (!result)
318
+ return null;
319
+ const normalized = result.windows.map((w) => normalizeAdapterWindow(runtime, w, nowMs));
320
+ const persistable = normalized.filter((n) => n.unavailableReason === null && n.remainingPercent !== null);
321
+ if (persistable.length === 0)
322
+ return null;
323
+ const storedObservedAt = new Map(listCapacitySnapshots(runtime).map((row) => [row.windowKey, row.observedAt]));
324
+ const fresh = persistable.filter((w) => {
325
+ const prev = storedObservedAt.get(w.windowKey);
326
+ if (!prev)
327
+ return true;
328
+ const prevMs = Date.parse(prev);
329
+ const nextMs = Date.parse(w.observedAt);
330
+ if (!Number.isFinite(prevMs))
331
+ return true;
332
+ if (!Number.isFinite(nextMs))
333
+ return false;
334
+ return nextMs >= prevMs;
335
+ });
336
+ if (fresh.length === 0)
337
+ return 0;
338
+ recordCapacitySnapshots(runtime, fresh.map((w) => ({
339
+ windowKey: w.windowKey,
340
+ providerBucket: w.providerBucket,
341
+ durationMinutes: w.durationMinutes,
342
+ displayLabel: w.displayLabel,
343
+ usedValue: w.usedValue,
344
+ usedUnit: w.usedUnit,
345
+ remainingPercent: w.remainingPercent,
346
+ resetAt: w.resetAt,
347
+ observedAt: w.observedAt,
348
+ freshUntil: w.freshUntil,
349
+ expiresAt: w.expiresAt,
350
+ source: w.source,
351
+ unavailableReason: w.unavailableReason,
352
+ evidenceRef: w.evidenceRef,
353
+ })));
354
+ return fresh.length;
355
+ }
356
+ /**
357
+ * File variant for session-end ingestion from a Dealer-managed Claude log.
358
+ * `fallbackObservedAtMs` is the session's spawn start. Returns the persisted
359
+ * window count, or null when the file is missing, the runtime is not Claude,
360
+ * or no usable windows were observed.
361
+ */
362
+ export function recordClaudeCapacityFromLog(logPath, runtime, nowMs = Date.now(), fallbackObservedAtMs = nowMs) {
363
+ if (runtime !== CLAUDE_RUNTIME)
364
+ return null;
365
+ if (!fs.existsSync(logPath))
366
+ return null;
367
+ return recordClaudeCapacityFromEvents(parseNdjson(fs.readFileSync(logPath, "utf8")), runtime, nowMs, fallbackObservedAtMs);
368
+ }