agent-dealer 1.2.1 → 1.2.2

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 (43) hide show
  1. package/bundle/server/dist/adapters/linear-inbox.js +9 -5
  2. package/bundle/server/dist/capacity/adapter.js +2 -0
  3. package/bundle/server/dist/capacity/claude-events.js +18 -0
  4. package/bundle/server/dist/capacity/claude-events.test.js +7 -0
  5. package/bundle/server/dist/capacity/codex-app-server.js +100 -10
  6. package/bundle/server/dist/capacity/codex-app-server.test.js +195 -2
  7. package/bundle/server/dist/capacity/cursor-individual-credentials.js +238 -0
  8. package/bundle/server/dist/capacity/cursor-individual-credentials.test.js +185 -0
  9. package/bundle/server/dist/capacity/cursor-individual.js +970 -0
  10. package/bundle/server/dist/capacity/cursor-individual.test.js +815 -0
  11. package/bundle/server/dist/capacity/muse.js +94 -43
  12. package/bundle/server/dist/capacity/muse.test.js +95 -13
  13. package/bundle/server/dist/capacity/service.js +1 -0
  14. package/bundle/server/dist/db/index.js +30 -0
  15. package/bundle/server/dist/db/schema.sql +27 -0
  16. package/bundle/server/dist/index.js +2 -0
  17. package/bundle/server/dist/repository/cursor-individual-billing.js +84 -0
  18. package/bundle/server/dist/repository/repository-mappings.js +46 -0
  19. package/bundle/server/dist/repository/repository-mappings.test.js +83 -0
  20. package/bundle/server/dist/repository/runtime-capacity.js +8 -3
  21. package/bundle/server/dist/repository/runtime-capacity.test.js +4 -0
  22. package/bundle/server/dist/routes/cursor-individual-billing.test.js +80 -0
  23. package/bundle/server/dist/routes/index.js +32 -0
  24. package/bundle/server/dist/routes/intake-linear.test.js +55 -1
  25. package/bundle/server/dist/routes/repository-mappings.js +22 -0
  26. package/bundle/server/dist/routes/repository-mappings.test.js +110 -0
  27. package/bundle/server/dist/routes/runtime-capacity.test.js +72 -2
  28. package/bundle/server/package.json +2 -2
  29. package/bundle/server/static-ui/assets/index-0GDYfbaM.js +60 -0
  30. package/bundle/server/static-ui/assets/index-DUYzboWh.css +1 -0
  31. package/bundle/server/static-ui/index.html +2 -2
  32. package/bundle/shared/dist/index.d.ts +6 -6
  33. package/bundle/shared/dist/linear-intake.d.ts +71 -0
  34. package/bundle/shared/dist/linear-intake.js +90 -0
  35. package/bundle/shared/dist/linear-intake.test.js +80 -1
  36. package/bundle/shared/dist/runtime-capacity.d.ts +89 -0
  37. package/bundle/shared/dist/runtime-capacity.js +60 -0
  38. package/bundle/shared/dist/runtime-capacity.test.js +1 -0
  39. package/bundle/shared/package.json +1 -1
  40. package/dist/doctor.js +6 -0
  41. package/package.json +1 -1
  42. package/bundle/server/static-ui/assets/index-ChnI0F02.css +0 -1
  43. package/bundle/server/static-ui/assets/index-CyB1573Q.js +0 -60
@@ -1,29 +1,31 @@
1
1
  // packages/server/src/capacity/muse.ts
2
2
  //
3
3
  // NOT-247: Muse Code capacity adapter — read stable MSP usage windows.
4
+ // NOT-263: handshake repaired against the stable schema embedded in the
5
+ // shipped binary (`muse schema generate-json-schema`).
4
6
  //
5
- // Reads observed capacity through the versioned `muse serve` Session Protocol
6
- // (MSP 1.3) without sending a prompt and without consuming model tokens: a
7
- // single `usage/read` JSON-RPC request over a one-shot managed `muse serve`
8
- // stdio connection, then shutdown. `usage/changed` notifications received
9
- // while the connection is alive are recorded. The client enforces a read-only
10
- // allowlist (`usage/read`) — any other method throws before it is written, so
11
- // polling can never start a session, send a prompt, or run model work. It
12
- // never touches the Keychain or undocumented endpoints.
7
+ // Reads observed capacity through the stable Muse Session Protocol without
8
+ // sending a prompt and without consuming model tokens: `muse serve` over a
9
+ // one-shot managed stdio connection, then `initialize` → `initialized` → one
10
+ // `usage/read`, then shutdown. `usage/changed` notifications received while
11
+ // the connection is alive are recorded. The client enforces a read-only
12
+ // allowlist (`initialize`, `initialized`, `usage/read`) — any other method
13
+ // throws before it is written, so polling can never start a session, send a
14
+ // prompt, or run model work. It never touches the Keychain or undocumented
15
+ // endpoints.
13
16
  //
14
- // MSP 1.3 `usage/read` shape (per the contract):
17
+ // Stable `usage/read` shape:
15
18
  // result: {
16
- // protocol: "msp/1.3",
17
19
  // usage?: {
18
20
  // observedAtMs: number,
19
21
  // tier: string, // dropped: never persisted, never served
20
- // rolling?: { usedPercent, resetsAtMs, windowDurationMins },
22
+ // window?: { usedPercent, resetsAtMs, windowDurationMins },
21
23
  // weekly?: { usedPercent, resetsAtMs }
22
24
  // }
23
25
  // }
24
26
  // `usage` may be omitted when Muse has no observation — preserved as N/A
25
- // (`missing`). The rolling label is derived from `windowDurationMins` by the
26
- // shared normalization, never hardcoded.
27
+ // (`missing`). The rolling (`window`) label is derived from
28
+ // `windowDurationMins` by the shared normalization, never hardcoded.
27
29
  //
28
30
  // Failure semantics (shared CapacityUnavailableReason enum only):
29
31
  // - No credential (no META_API_KEY and no login file) / `usage` omitted /
@@ -42,7 +44,16 @@ import { MUSE_CLI_ENV, resolveMuseAuthFile, resolveMuseBin } from "../cli-env.js
42
44
  import { normalizeAdapterWindow, normalizeUnavailableWindow, } from "./adapter.js";
43
45
  export const MUSE_RUNTIME = "muse_code";
44
46
  /** Methods this client may ever send. Anything else throws before write. */
45
- export const MSP_READ_ONLY_METHODS = ["usage/read"];
47
+ export const MSP_READ_ONLY_METHODS = ["initialize", "initialized", "usage/read"];
48
+ /**
49
+ * Client identity sent in the `initialize` handshake. The stable schema
50
+ * requires `clientInfo.name` to match `^[a-z0-9_]+$` plus a version string.
51
+ * Version is kept in sync with `packages/server/package.json`.
52
+ */
53
+ export const MUSE_CLIENT_INFO = {
54
+ name: "agent_dealer",
55
+ version: "1.2.1",
56
+ };
46
57
  /** Notification the client records (never sends) while connected. */
47
58
  export const MSP_USAGE_CHANGED = "usage/changed";
48
59
  /** Methods that would start billable model work — never sent, asserted in tests. */
@@ -61,21 +72,18 @@ function readOnlyRequest(id, method, params) {
61
72
  assertMuseReadOnlyMethod(method);
62
73
  return `${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`;
63
74
  }
75
+ function readOnlyNotification(method, params) {
76
+ assertMuseReadOnlyMethod(method);
77
+ return `${JSON.stringify({ jsonrpc: "2.0", method, params })}\n`;
78
+ }
64
79
  /**
65
- * Versioned Session Protocol argv. Fixed: capacity reads never `exec`.
66
- *
67
- * Caveat: this argv and the handshake-free single `usage/read` below were
68
- * built from the ticket contract brief — the official Muse Code docs were
69
- * unreachable at implementation time, so they are unverified against the
70
- * published MSP surface. The committed fake (`fixtures/fake-muse-serve.mjs`)
71
- * likewise enforces no handshake, so tests cannot catch a handshake
72
- * requirement either. If a live `muse serve` demands an `initialize` step,
73
- * the read-only allowlist will reject it and every live read will fail
74
- * closed as `missing`/`unparsable` (never billed); re-check the argv against
75
- * the docs before debugging any live failure.
80
+ * Stable Session Protocol argv. Fixed: capacity reads never `exec`, and the
81
+ * host takes no `--protocol` flag — `muse serve` with that flag exits 2
82
+ * against the shipped binary, so this stays exactly `["serve"]`.
76
83
  */
77
- export const MUSE_SERVE_ARGV = ["serve", "--protocol", "msp/1.3"];
78
- /** Single JSON-RPC request id for the one-shot read. */
84
+ export const MUSE_SERVE_ARGV = ["serve"];
85
+ /** JSON-RPC request ids for the one-shot read (init handshake, then usage). */
86
+ export const MSP_INITIALIZE_ID = "muse-capacity-init";
79
87
  export const MSP_USAGE_READ_ID = "muse-capacity-1";
80
88
  /** Weekly windows carry no duration in MSP 1.3; a week in minutes. */
81
89
  export const MUSE_WEEKLY_DURATION_MINUTES = 10080;
@@ -133,6 +141,9 @@ function windowReadingFromEntry(kind, entry, observedAt) {
133
141
  resetAt,
134
142
  observedAt,
135
143
  source: "supported_protocol",
144
+ // Muse reports exactly this one rolling/weekly pair — it is always the
145
+ // account-wide critical window, never a model-specific extra.
146
+ criticalRole: kind === "rolling" ? "five_hour" : "weekly",
136
147
  };
137
148
  }
138
149
  /**
@@ -153,7 +164,7 @@ function extractUsageObject(payload) {
153
164
  return { error: "usage is not an object" };
154
165
  return { usage: usage };
155
166
  }
156
- if ("observedAtMs" in result || "rolling" in result || "weekly" in result) {
167
+ if ("observedAtMs" in result || "window" in result || "rolling" in result || "weekly" in result) {
157
168
  return { usage: result };
158
169
  }
159
170
  const keys = Object.keys(result);
@@ -162,25 +173,27 @@ function extractUsageObject(payload) {
162
173
  return { error: "result carries no usage observation" };
163
174
  }
164
175
  /**
165
- * Normalize MSP 1.3 `usage` into adapter readings. Returns null when the
166
- * payload carries no observation or is malformed (callers needing the
167
- * distinction check the payload shape first). Entries that are individually
168
- * malformed are skipped — the caller re-reports present-but-bad keys as
169
- * per-window `unparsable` readings so a bad sibling never sinks a good
170
- * window.
176
+ * Normalize stable `usage` into adapter readings. The rolling window arrives
177
+ * as `usage.window` (a legacy `usage.rolling` spelling is tolerated and maps
178
+ * to the same `rolling_all_models` snapshot). Returns null when the payload
179
+ * carries no observation or is malformed (callers needing the distinction
180
+ * check the payload shape first). Entries that are individually malformed
181
+ * are skipped — the caller re-reports present-but-bad keys as per-window
182
+ * `unparsable` readings so a bad sibling never sinks a good window.
171
183
  */
172
184
  export function museUsageToReadings(payload, observedAt = new Date().toISOString()) {
173
185
  const { usage, error } = extractUsageObject(payload);
174
186
  if (error !== undefined || usage === undefined)
175
187
  return null;
176
188
  const u = usage;
177
- const hasRolling = u.rolling !== undefined;
189
+ const rollingEntry = u.window !== undefined ? u.window : u.rolling;
190
+ const hasRolling = rollingEntry !== undefined;
178
191
  const hasWeekly = u.weekly !== undefined;
179
192
  const observedAtMs = typeof u.observedAtMs === "number" && Number.isFinite(u.observedAtMs) ? u.observedAtMs : null;
180
193
  const observed = observedAtMs !== null ? new Date(observedAtMs).toISOString() : observedAt;
181
194
  const out = [];
182
195
  if (hasRolling) {
183
- const rolling = windowReadingFromEntry("rolling", u.rolling, observed);
196
+ const rolling = windowReadingFromEntry("rolling", rollingEntry, observed);
184
197
  if (rolling)
185
198
  out.push(rolling);
186
199
  }
@@ -200,11 +213,15 @@ export function museBadWindowKinds(payload) {
200
213
  if (error !== undefined || usage === undefined)
201
214
  return [];
202
215
  const u = usage;
216
+ const entries = {
217
+ rolling: u.window !== undefined ? u.window : u.rolling,
218
+ weekly: u.weekly,
219
+ };
203
220
  const bad = [];
204
221
  for (const kind of ["rolling", "weekly"]) {
205
- if (u[kind] === undefined)
222
+ if (entries[kind] === undefined)
206
223
  continue;
207
- const probe = windowReadingFromEntry(kind, u[kind], new Date().toISOString());
224
+ const probe = windowReadingFromEntry(kind, entries[kind], new Date().toISOString());
208
225
  if (!probe)
209
226
  bad.push(kind);
210
227
  }
@@ -253,10 +270,11 @@ function hasMuseCredential(env, authFilePath) {
253
270
  }
254
271
  }
255
272
  /**
256
- * Spawn `muse serve`, send one `usage/read`, record any `usage/changed`
257
- * notifications, then shut the connection down. Never throws for
258
- * provider-side failures — those come back as `failure.kind`. Never sends
259
- * anything outside MSP_READ_ONLY_METHODS.
273
+ * Spawn `muse serve`, run the stable handshake (`initialize` with a
274
+ * `clientInfo` identity, then the `initialized` notification), send one
275
+ * `usage/read`, record any `usage/changed` notifications, then shut the
276
+ * connection down. Never throws for provider-side failures — those come back
277
+ * as `failure.kind`. Never sends anything outside MSP_READ_ONLY_METHODS.
260
278
  */
261
279
  export function requestMuseUsage(opts = {}) {
262
280
  const command = opts.command ?? resolveMuseBin();
@@ -272,6 +290,7 @@ export function requestMuseUsage(opts = {}) {
272
290
  let settled = false;
273
291
  let child;
274
292
  let buffer = "";
293
+ let initResolve = null;
275
294
  let readResolve = null;
276
295
  const finish = (out) => {
277
296
  if (settled)
@@ -298,7 +317,12 @@ export function requestMuseUsage(opts = {}) {
298
317
  updates.push(msg.params ?? null);
299
318
  return;
300
319
  }
301
- if (msg.id === MSP_USAGE_READ_ID && readResolve) {
320
+ if (msg.id === MSP_INITIALIZE_ID && initResolve) {
321
+ const cb = initResolve;
322
+ initResolve = null;
323
+ cb(msg);
324
+ }
325
+ else if (msg.id === MSP_USAGE_READ_ID && readResolve) {
302
326
  const cb = readResolve;
303
327
  readResolve = null;
304
328
  cb(msg);
@@ -361,6 +385,28 @@ export function requestMuseUsage(opts = {}) {
361
385
  }
362
386
  };
363
387
  void (async () => {
388
+ if (!write(readOnlyRequest(MSP_INITIALIZE_ID, "initialize", { clientInfo: { ...MUSE_CLIENT_INFO } }))) {
389
+ fail("unavailable");
390
+ return;
391
+ }
392
+ const init = await new Promise((res) => {
393
+ initResolve = res;
394
+ });
395
+ if (settled)
396
+ return;
397
+ if (!init || init.error) {
398
+ if (init?.error && errorLooksAuth(init.error))
399
+ fail("unauthenticated");
400
+ else if (init?.error && init.error.code === -32601)
401
+ fail("unsupported");
402
+ else
403
+ fail("unavailable");
404
+ return;
405
+ }
406
+ if (!write(readOnlyNotification("initialized", {}))) {
407
+ fail("unavailable");
408
+ return;
409
+ }
364
410
  if (!write(readOnlyRequest(MSP_USAGE_READ_ID, "usage/read", {}))) {
365
411
  fail("unavailable");
366
412
  return;
@@ -504,6 +550,11 @@ export async function readMuseCapacity(opts = {}) {
504
550
  durationMinutes: kind === "weekly" ? MUSE_WEEKLY_DURATION_MINUTES : null,
505
551
  reason: "unparsable",
506
552
  observedAt,
553
+ // This unavailable reading still stands in for the account-wide
554
+ // critical half it replaces — untagging it would make the client
555
+ // synthesize a second, duplicate N/A row for the same half (NOT-264
556
+ // review finding).
557
+ criticalRole: kind === "rolling" ? "five_hour" : "weekly",
507
558
  });
508
559
  }
509
560
  if (windows.length === 0 && unavailable.length === 0) {
@@ -1,19 +1,23 @@
1
1
  // packages/server/src/capacity/muse.test.ts
2
2
  //
3
3
  // NOT-247: Muse serve adapter — fake-subprocess coverage only, never a live
4
- // Muse request. A fake MSP 1.3 server returns rolling + weekly usage (two
4
+ // Muse request. A fake stable MSP server returns window + weekly usage (two
5
5
  // independent snapshots with correct remaining percents and reset times), the
6
6
  // rolling label derives from `windowDurationMins` (never hardcoded),
7
7
  // failure modes map to explicit N/A reasons without touching runtime health,
8
- // and the recorded protocol traffic proves the client never sends a prompt.
8
+ // and the recorded protocol traffic proves the client runs the
9
+ // initialize → initialized → usage/read handshake and never sends a prompt.
10
+ // NOT-263: the fake enforces the stable contract (no `--protocol` argv,
11
+ // handshake order, `usage.window` field names).
9
12
  import { test, before } from "node:test";
10
13
  import assert from "node:assert/strict";
14
+ import { spawn } from "node:child_process";
11
15
  import fs from "node:fs";
12
16
  import os from "node:os";
13
17
  import path from "node:path";
14
18
  process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-muse-cap-"));
15
- import { assertMuseReadOnlyMethod, createMuseCapacityAdapter, MSP_FORBIDDEN_MODEL_METHODS, museRefreshThrottleMs, museUsageToReadings, maybeRefreshMuseCapacityFromServe, normalizeMuseResetsAt, normalizeMuseUnavailable, readMuseCapacity, refreshMuseCapacityFromServe, requestMuseUsage, resetMuseCapacityRefreshState, } from "./muse.js";
16
- import { normalizeAdapterWindow } from "./adapter.js";
19
+ import { assertMuseReadOnlyMethod, createMuseCapacityAdapter, MSP_FORBIDDEN_MODEL_METHODS, MUSE_CLIENT_INFO, MUSE_SERVE_ARGV, museRefreshThrottleMs, museUsageToReadings, maybeRefreshMuseCapacityFromServe, normalizeMuseResetsAt, normalizeMuseUnavailable, readMuseCapacity, refreshMuseCapacityFromServe, requestMuseUsage, resetMuseCapacityRefreshState, } from "./muse.js";
20
+ import { normalizeAdapterWindow, normalizeUnavailableWindow } from "./adapter.js";
17
21
  const { migrate } = await import("../db/index.js");
18
22
  const { clearAllCapacitySnapshots, listCapacitySnapshots } = await import("../repository/runtime-capacity.js");
19
23
  const { clearAllRuntimeAvailability, runtimeAvailability, } = await import("../repository/runtime-availability.js");
@@ -40,6 +44,14 @@ function fakeOpts(mode, extra = {}) {
40
44
  },
41
45
  };
42
46
  }
47
+ test("serve argv is the shipping contract: no --protocol flag", () => {
48
+ assert.deepEqual([...MUSE_SERVE_ARGV], ["serve"]);
49
+ assert.ok(!MUSE_SERVE_ARGV.includes("--protocol"), "muse serve takes no --protocol flag");
50
+ });
51
+ test("handshake identity matches the stable clientInfo shape", () => {
52
+ assert.match(MUSE_CLIENT_INFO.name, /^[a-z0-9_]+$/);
53
+ assert.ok(MUSE_CLIENT_INFO.version.length > 0, "versioned client identity required");
54
+ });
43
55
  test("usage windows keep identity, percent, duration, and reset", () => {
44
56
  const now = Date.now();
45
57
  const parsed = museUsageToReadings({
@@ -47,7 +59,7 @@ test("usage windows keep identity, percent, duration, and reset", () => {
47
59
  usage: {
48
60
  observedAtMs: now,
49
61
  tier: "contributor",
50
- rolling: { usedPercent: 60, resetsAtMs: now + 2 * 3600_000, windowDurationMins: 300 },
62
+ window: { usedPercent: 60, resetsAtMs: now + 2 * 3600_000, windowDurationMins: 300 },
51
63
  weekly: { usedPercent: 25, resetsAtMs: now + 3 * 24 * 3600_000 },
52
64
  },
53
65
  }, new Date(now).toISOString());
@@ -62,16 +74,30 @@ test("usage windows keep identity, percent, duration, and reset", () => {
62
74
  const normalized = normalizeAdapterWindow("muse_code", rolling, now);
63
75
  assert.equal(normalized.displayLabel, "5H");
64
76
  assert.equal(normalized.remainingPercent, 40);
77
+ assert.equal(normalized.criticalRole, "five_hour");
65
78
  const weekly = parsed.windows.find((w) => w.windowKey === "weekly_all_models");
66
79
  assert.equal(normalizeAdapterWindow("muse_code", weekly, now).displayLabel, "1W");
67
80
  assert.equal(normalizeAdapterWindow("muse_code", weekly, now).remainingPercent, 75);
81
+ assert.equal(normalizeAdapterWindow("muse_code", weekly, now).criticalRole, "weekly");
82
+ });
83
+ test("legacy rolling spelling maps to the same snapshot", () => {
84
+ const now = Date.now();
85
+ const parsed = museUsageToReadings({
86
+ usage: {
87
+ observedAtMs: now,
88
+ rolling: { usedPercent: 60, resetsAtMs: now + 2 * 3600_000, windowDurationMins: 300 },
89
+ },
90
+ }, new Date(now).toISOString());
91
+ assert.ok(parsed);
92
+ assert.equal(parsed.windows.length, 1);
93
+ assert.equal(parsed.windows[0].windowKey, "rolling_all_models");
68
94
  });
69
95
  test("rolling label derives from windowDurationMins, never a hardcoded 5H", () => {
70
96
  const now = Date.now();
71
97
  const parsed = museUsageToReadings({
72
98
  usage: {
73
99
  observedAtMs: now,
74
- rolling: { usedPercent: 10, resetsAtMs: now + 6 * 3600_000, windowDurationMins: 720 },
100
+ window: { usedPercent: 10, resetsAtMs: now + 6 * 3600_000, windowDurationMins: 720 },
75
101
  },
76
102
  }, new Date(now).toISOString());
77
103
  assert.ok(parsed);
@@ -86,7 +112,7 @@ test("payloads without an observation read null, not empty", () => {
86
112
  assert.equal(museUsageToReadings({ protocol: "msp/1.3" }), null);
87
113
  assert.equal(museUsageToReadings({ usage: null }), null);
88
114
  assert.equal(museUsageToReadings({ usage: { observedAtMs: Date.now() } }).windows.length, 0);
89
- assert.equal(museUsageToReadings({ usage: { observedAtMs: Date.now(), rolling: { windowDurationMins: 300 } } })
115
+ assert.equal(museUsageToReadings({ usage: { observedAtMs: Date.now(), window: { windowDurationMins: 300 } } })
90
116
  .windows.length, 0);
91
117
  });
92
118
  test("resetsAtMs accepts ms, seconds, and ISO; rejects garbage", () => {
@@ -99,7 +125,9 @@ test("resetsAtMs accepts ms, seconds, and ISO; rejects garbage", () => {
99
125
  assert.equal(normalizeMuseResetsAt(null), null);
100
126
  assert.equal(normalizeMuseResetsAt(-5), null);
101
127
  });
102
- test("only usage/read can be sent; session prompts throw before write", () => {
128
+ test("only the handshake and usage/read can be sent; session prompts throw before write", () => {
129
+ assertMuseReadOnlyMethod("initialize");
130
+ assertMuseReadOnlyMethod("initialized");
103
131
  assertMuseReadOnlyMethod("usage/read");
104
132
  for (const m of ["session/start", "session/prompt", "session/resume", "exec", "usage/write"]) {
105
133
  assert.throws(() => assertMuseReadOnlyMethod(m), /refusing non-read method/);
@@ -144,31 +172,85 @@ test("usage/changed notifications are consumed while the connection is alive", a
144
172
  assert.equal(read.failure, null);
145
173
  assert.equal(read.updates.length, 1);
146
174
  });
147
- test("protocol traffic is read-only: usage/read and nothing else", async () => {
175
+ test("protocol traffic is the stable handshake then exactly one usage/read", async () => {
148
176
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-muse-record-"));
149
177
  const recordPath = path.join(dir, "methods.jsonl");
150
178
  const { opts } = fakeOpts("full", { env: { FAKE_MSP_RECORD: recordPath } });
151
179
  await readMuseCapacity(opts);
152
- const methods = fs
180
+ const lines = fs
153
181
  .readFileSync(recordPath, "utf8")
154
182
  .split("\n")
155
183
  .filter(Boolean)
156
- .map((l) => String(JSON.parse(l).method));
157
- assert.ok(methods.length >= 1, `expected a read, got ${methods}`);
184
+ .map((l) => JSON.parse(l));
185
+ const methods = lines.map((l) => String(l.method));
186
+ assert.deepEqual(methods, ["initialize", "initialized", "usage/read"]);
187
+ const init = lines[0];
188
+ assert.equal(init.params?.clientInfo?.name, MUSE_CLIENT_INFO.name);
189
+ assert.match(String(init.params?.clientInfo?.name ?? ""), /^[a-z0-9_]+$/);
190
+ assert.equal(init.params?.clientInfo?.version, MUSE_CLIENT_INFO.version);
158
191
  for (const m of methods) {
159
- assert.equal(m, "usage/read");
160
192
  assert.ok(!/session|prompt|exec|message|turn|thread/i.test(m), `billable method ${m}`);
161
193
  }
162
194
  });
195
+ test("fake rejects the removed --protocol argv like the shipped binary", async () => {
196
+ clearAllCapacitySnapshots();
197
+ const now = Date.now();
198
+ const result = await readMuseCapacity({
199
+ command: process.execPath,
200
+ args: [FAKE, "--protocol", "msp/1.3"],
201
+ env: { META_API_KEY: "test-fake-key", FAKE_MSP_MODE: "full" },
202
+ timeoutMs: 10_000,
203
+ nowMs: now,
204
+ });
205
+ assert.equal(result.windows.length, 0);
206
+ assert.equal(result.unavailable.length, 1);
207
+ assert.equal(result.unavailable[0].windowKey, "muse_account_usage");
208
+ assert.equal(result.unavailable[0].reason, "missing");
209
+ });
210
+ test("fake requires initialize before usage/read", async () => {
211
+ const child = spawn(process.execPath, [FAKE], {
212
+ stdio: ["pipe", "pipe", "pipe"],
213
+ env: { ...process.env, FAKE_MSP_MODE: "full", META_API_KEY: "test-fake-key" },
214
+ });
215
+ try {
216
+ const first = await new Promise((resolve, reject) => {
217
+ const timer = setTimeout(() => reject(new Error("timed out waiting for fake response")), 5000);
218
+ timer.unref?.();
219
+ let buf = "";
220
+ child.stdout.on("data", (chunk) => {
221
+ buf += chunk.toString();
222
+ const idx = buf.indexOf("\n");
223
+ if (idx >= 0) {
224
+ clearTimeout(timer);
225
+ resolve(buf.slice(0, idx));
226
+ }
227
+ });
228
+ child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id: "bare-read", method: "usage/read", params: {} })}\n`);
229
+ });
230
+ const msg = JSON.parse(first);
231
+ assert.ok(msg.error, "bare usage/read is rejected before the handshake");
232
+ assert.match(String(msg.error?.message ?? ""), /initialize/i);
233
+ }
234
+ finally {
235
+ child.kill();
236
+ }
237
+ });
163
238
  test("a malformed sibling window does not sink the good one", async () => {
164
239
  clearAllCapacitySnapshots();
165
240
  const { opts } = fakeOpts("partial-bad-weekly");
166
241
  const result = await readMuseCapacity(opts);
167
242
  assert.equal(result.windows.length, 1);
168
243
  assert.equal(result.windows[0].windowKey, "rolling_all_models");
244
+ assert.equal(result.windows[0].criticalRole, "five_hour");
169
245
  assert.equal(result.unavailable.length, 1);
170
246
  assert.equal(result.unavailable[0].windowKey, "weekly_all_models");
171
247
  assert.equal(result.unavailable[0].reason, "unparsable");
248
+ // NOT-264 review finding: the unavailable half must still carry its
249
+ // criticalRole, or the client can't find it and synthesizes a second,
250
+ // duplicate N/A row for the same half instead of one.
251
+ assert.equal(result.unavailable[0].criticalRole, "weekly");
252
+ const normalizedUnavailable = normalizeUnavailableWindow("muse_code", result.unavailable[0]);
253
+ assert.equal(normalizedUnavailable.criticalRole, "weekly");
172
254
  });
173
255
  async function expectUnavailable(mode, reason, extra = {}) {
174
256
  clearAllCapacitySnapshots();
@@ -47,6 +47,7 @@ export async function ingestAdapterResult(result) {
47
47
  source: w.source,
48
48
  unavailableReason: w.unavailableReason,
49
49
  evidenceRef: w.evidenceRef,
50
+ criticalRole: w.criticalRole,
50
51
  })));
51
52
  }
52
53
  /** Classify a stored window at read time (freshness/expiry/reset, not just flags). */
@@ -389,6 +389,11 @@ export function migrate() {
389
389
  ON runtime_capacity_snapshots(runtime);
390
390
  `);
391
391
  }
392
+ // NOT-264 repair: critical_role on a table that predates it.
393
+ const capacityCols = db.prepare("PRAGMA table_info(runtime_capacity_snapshots)").all();
394
+ if (!capacityCols.some((c) => c.name === "critical_role")) {
395
+ db.exec("ALTER TABLE runtime_capacity_snapshots ADD COLUMN critical_role TEXT");
396
+ }
392
397
  // NOT-249: normalized Cursor-team billing snapshot for databases created
393
398
  // before schema.sql declared the table (same upgrade-path pattern as
394
399
  // runtime_capacity_snapshots above).
@@ -433,6 +438,31 @@ export function migrate() {
433
438
  db.exec("ALTER TABLE cursor_team_billing_snapshots ADD COLUMN member_limit_override_count REAL");
434
439
  }
435
440
  }
441
+ // NOT-250: normalized Cursor-individual billing snapshot for databases
442
+ // created before schema.sql declared the table (same upgrade-path pattern
443
+ // as cursor_team_billing_snapshots above).
444
+ const cursorIndividualBilling = db
445
+ .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'cursor_individual_billing_snapshots'")
446
+ .get();
447
+ if (!cursorIndividualBilling) {
448
+ db.exec(`
449
+ CREATE TABLE cursor_individual_billing_snapshots (
450
+ id INTEGER NOT NULL PRIMARY KEY CHECK (id = 1),
451
+ cycle_label TEXT,
452
+ cycle_start TEXT,
453
+ cycle_end TEXT,
454
+ usage_value REAL,
455
+ usage_unit TEXT,
456
+ remaining_percent REAL,
457
+ source TEXT NOT NULL,
458
+ unavailable_reason TEXT,
459
+ observed_at TEXT NOT NULL,
460
+ fresh_until TEXT,
461
+ expires_at TEXT,
462
+ evidence_ref TEXT
463
+ );
464
+ `);
465
+ }
436
466
  // NOT-103: sequential issue admission queue.
437
467
  const queueEntries = db
438
468
  .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'queue_entries'")
@@ -400,6 +400,12 @@ CREATE TABLE IF NOT EXISTS runtime_capacity_snapshots (
400
400
  source TEXT NOT NULL,
401
401
  unavailable_reason TEXT,
402
402
  evidence_ref TEXT,
403
+ -- NOT-264 repair: which account-wide critical window this is ('five_hour'
404
+ -- / 'weekly'), set once by the adapter that knows the provider's own
405
+ -- identity (e.g. Codex's rateLimits aggregate, or the detailed limit pair
406
+ -- it collapsed into) — never re-derived client-side from a window key or
407
+ -- label, which cannot disambiguate multiple same-duration buckets.
408
+ critical_role TEXT,
403
409
  PRIMARY KEY (runtime, window_key)
404
410
  );
405
411
 
@@ -431,6 +437,27 @@ CREATE TABLE IF NOT EXISTS cursor_team_billing_snapshots (
431
437
  evidence_ref TEXT
432
438
  );
433
439
 
440
+ -- NOT-250: normalized Cursor-individual billing snapshot (single row, id = 1).
441
+ -- Experimental dashboard billing-cycle state, not per-runtime quota windows:
442
+ -- independent of both runtime_capacity_snapshots and runtime_availability
443
+ -- (NOT-111 health). Carries normalized values plus a static evidence pointer
444
+ -- only — never credential material.
445
+ CREATE TABLE IF NOT EXISTS cursor_individual_billing_snapshots (
446
+ id INTEGER NOT NULL PRIMARY KEY CHECK (id = 1),
447
+ cycle_label TEXT,
448
+ cycle_start TEXT,
449
+ cycle_end TEXT,
450
+ usage_value REAL,
451
+ usage_unit TEXT,
452
+ remaining_percent REAL,
453
+ source TEXT NOT NULL,
454
+ unavailable_reason TEXT,
455
+ observed_at TEXT NOT NULL,
456
+ fresh_until TEXT,
457
+ expires_at TEXT,
458
+ evidence_ref TEXT
459
+ );
460
+
434
461
  -- NOT-111: account-level runtime usage caps (keyed per runtime, not per agent profile).
435
462
  CREATE TABLE IF NOT EXISTS runtime_availability (
436
463
  runtime TEXT PRIMARY KEY,
@@ -10,6 +10,7 @@ import { registerQueueRoutes } from "./routes/queue.js";
10
10
  import { registerHumanActionRoutes } from "./routes/human-actions.js";
11
11
  import { registerExecutionAnalysisRoutes } from "./routes/execution-analysis.js";
12
12
  import { registerExecutionReportRoutes } from "./routes/execution-report.js";
13
+ import { registerRepositoryMappingsRoutes } from "./routes/repository-mappings.js";
13
14
  import { recoverCoordinator } from "./coordinator/recovery.js";
14
15
  import { startCoordinatorLoop } from "./coordinator/worker-loop.js";
15
16
  import { startLinearUsageSummary } from "./adapters/linear-graphql.js";
@@ -64,6 +65,7 @@ async function main() {
64
65
  await registerHumanActionRoutes(app);
65
66
  await registerExecutionAnalysisRoutes(app);
66
67
  await registerExecutionReportRoutes(app);
68
+ await registerRepositoryMappingsRoutes(app);
67
69
  const uiDist = await registerStaticUi(app);
68
70
  // Without this, getEffectHandler() falls back to the always-session_failed placeholders
69
71
  // (effect-registry.ts) and every started issue would burn its infra retries and
@@ -0,0 +1,84 @@
1
+ // packages/server/src/repository/cursor-individual-billing.ts
2
+ //
3
+ // NOT-250: durable normalized Cursor-individual billing snapshot. Single-row
4
+ // table (`id = 1`): the experimental dashboard adapter reports one
5
+ // billing-cycle state, not per-window readings. Independent of
6
+ // `runtime_availability` (NOT-111 connection health) and written alongside
7
+ // (never instead of) `runtime_capacity_snapshots` — nothing here reads or
8
+ // writes either table.
9
+ //
10
+ // Credential hygiene: this table carries normalized billing values and a
11
+ // static evidence pointer only. The raw local-login token must never be
12
+ // written here — the adapter never passes it to this module, and tests scan
13
+ // stored rows for fixture secrets.
14
+ import { getDb } from "../db/index.js";
15
+ function rowToSnapshot(row) {
16
+ return {
17
+ cycleLabel: row.cycle_label,
18
+ cycleStart: row.cycle_start,
19
+ cycleEnd: row.cycle_end,
20
+ usageValue: row.usage_value,
21
+ usageUnit: row.usage_unit,
22
+ remainingPercent: row.remaining_percent,
23
+ source: row.source,
24
+ unavailableReason: row.unavailable_reason,
25
+ observedAt: row.observed_at,
26
+ freshUntil: row.fresh_until,
27
+ expiresAt: row.expires_at,
28
+ evidenceRef: row.evidence_ref,
29
+ };
30
+ }
31
+ /** Upsert the latest normalized individual billing observation (single row). */
32
+ export function writeCursorIndividualBillingRow(input) {
33
+ getDb()
34
+ .prepare(`
35
+ INSERT INTO cursor_individual_billing_snapshots (
36
+ id, cycle_label, cycle_start, cycle_end, usage_value, usage_unit,
37
+ remaining_percent, source, unavailable_reason,
38
+ observed_at, fresh_until, expires_at, evidence_ref
39
+ )
40
+ VALUES (
41
+ 1, @cycle_label, @cycle_start, @cycle_end, @usage_value, @usage_unit,
42
+ @remaining_percent, @source, @unavailable_reason,
43
+ @observed_at, @fresh_until, @expires_at, @evidence_ref
44
+ )
45
+ ON CONFLICT(id) DO UPDATE SET
46
+ cycle_label = excluded.cycle_label,
47
+ cycle_end = excluded.cycle_end,
48
+ cycle_start = excluded.cycle_start,
49
+ usage_value = excluded.usage_value,
50
+ usage_unit = excluded.usage_unit,
51
+ remaining_percent = excluded.remaining_percent,
52
+ source = excluded.source,
53
+ unavailable_reason = excluded.unavailable_reason,
54
+ observed_at = excluded.observed_at,
55
+ fresh_until = excluded.fresh_until,
56
+ expires_at = excluded.expires_at,
57
+ evidence_ref = excluded.evidence_ref
58
+ `)
59
+ .run({
60
+ cycle_label: input.cycleLabel,
61
+ cycle_start: input.cycleStart,
62
+ cycle_end: input.cycleEnd,
63
+ usage_value: input.usageValue,
64
+ usage_unit: input.usageUnit,
65
+ remaining_percent: input.remainingPercent,
66
+ source: input.source,
67
+ unavailable_reason: input.unavailableReason,
68
+ observed_at: input.observedAt,
69
+ fresh_until: input.freshUntil,
70
+ expires_at: input.expiresAt,
71
+ evidence_ref: input.evidenceRef,
72
+ });
73
+ }
74
+ /** The latest normalized individual billing observation, if any. */
75
+ export function readCursorIndividualBillingRow() {
76
+ const row = getDb()
77
+ .prepare("SELECT * FROM cursor_individual_billing_snapshots WHERE id = 1")
78
+ .get();
79
+ return row ? rowToSnapshot(row) : null;
80
+ }
81
+ /** Test helper — clear the individual billing snapshot (leaves all other tables alone). */
82
+ export function clearCursorIndividualBilling() {
83
+ getDb().exec("DELETE FROM cursor_individual_billing_snapshots");
84
+ }