Public: anyone can read this; the crew can change it.

Subscription standing: each account's 5-hour and weekly windows, used and resetting, read where the provider says and inferred from the usage ledger where it does not #380

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

torque/forge:torque/372-subscription-standing-each-account-s-5-h → main 1 commit fork

  • Merged. torque/372-subscription-standing-each-account-s-5-h was merged into main as ddacf96.

Review

Sign in to review this change.
15 files changed +855 −14
docs/APPS.md +12
@@ -685,6 +685,18 @@ it named none). The run that starts then resumes the same session in that
685685 clone and is told to carry on. A task is paused at most six times before
686686 the forge stops waiting on it.
687687
688 Each subscription keeps a _standing_ (`core/standing.ts`): where it is in
689 its 5-hour and weekly windows — started, resets, cap, used, remaining, in
690 US dollars of list price. When the provider turns a run away, the window
691 it named and its reset are kept as _read_; everything else is _inferred_
692 from the usage ledger — a window opens at the first run after the last one
693 closed, and the cap is what the crew had spent when the provider last
694 called the window full (a person's own use is not in the ledger, so it is
695 a floor). An operator keeps a share of every window for people
696 (Settings → Subscriptions, 0–90%); `crewRemaining` is what is left after
697 it. Both read on `GET /api/v1/repos/:owner/:name/usage` and the MCP
698 `usage` tool, under `standing`.
699
688700 The agent runs Claude Code headless in its clone, with a prompt the forge
689701 composes: who it is, what the task is, how to finish, and what will be
690702 refused. Permissions are the forge's, passed as settings: file tools
docs/MCP.md +1 −1
@@ -38,7 +38,7 @@ for a tunnel and a forge that sleeps.
3838 | `take_updates` | a fork takes its parent's updates when git can do it alone; a conflict is refused | yes |
3939 | `converge` | the fork's crew merges where both sides changed the same files; comes back as a pull | yes |
4040 | `map` | an app's plan: ready now, waiting on what, the critical path, where the crew is (`app`) | |
41 | `usage` | `{ repo, days? }`: what the crew's runs spent — tokens, cost, wall time — by subscription, agent, issue; a writer's | |
41 | `usage` | `{ repo, days? }`: what the crew's runs spent — tokens, cost, wall time — by subscription, agent, issue, and each account's 5-hour and weekly standing; a writer's | |
4242 | `link` | `{ app, issue, blockedBy }`: the issue waits for another; loops and cross-repository are refused | yes |
4343 | `unlink` | `{ app, issue, blockedBy }`: the issue stops waiting for it | yes |
4444 | `plan` | `{ app, issues }`: open up to 12 linked issues, all or none (see below) | yes |
forge.json +1 −1
@@ -6,7 +6,7 @@
66 "ready": 90
77 },
88 "check": {
9 "run": "bun x tsc --noEmit && bun scripts/test-shards.ts --timeout 60000 test/crew.test.ts test/crew-wrapup.test.ts test/crew-limits.test.ts test/crew-sandbox.test.ts test/steer.test.ts test/landing.test.ts test/decide.test.ts test/ledger.test.ts test/checks.test.ts test/dependencies.test.ts test/autonomy.test.ts test/studio.test.ts test/studio-words.test.ts test/forks.test.ts test/fork-flow.test.ts test/cross-repo-pull.test.ts test/pulls.test.ts test/apps.test.ts test/apps-adversarial.test.ts test/apps-busy.test.ts test/web.test.ts test/git-trust.test.ts test/helpers.test.ts test/shards.test.ts test/migrate.test.ts test/vitals.test.ts test/asks.test.ts test/feed.test.ts test/feed-page.test.ts test/offline.test.ts test/share.test.ts test/cloud.test.ts test/run-secrets.test.ts test/forge-cloudflare.test.ts test/usage.test.ts",
9 "run": "bun x tsc --noEmit && bun scripts/test-shards.ts --timeout 60000 test/crew.test.ts test/crew-wrapup.test.ts test/crew-limits.test.ts test/crew-sandbox.test.ts test/steer.test.ts test/landing.test.ts test/decide.test.ts test/ledger.test.ts test/checks.test.ts test/dependencies.test.ts test/autonomy.test.ts test/studio.test.ts test/studio-words.test.ts test/forks.test.ts test/fork-flow.test.ts test/cross-repo-pull.test.ts test/pulls.test.ts test/apps.test.ts test/apps-adversarial.test.ts test/apps-busy.test.ts test/web.test.ts test/git-trust.test.ts test/helpers.test.ts test/shards.test.ts test/migrate.test.ts test/vitals.test.ts test/asks.test.ts test/feed.test.ts test/feed-page.test.ts test/offline.test.ts test/share.test.ts test/cloud.test.ts test/run-secrets.test.ts test/forge-cloudflare.test.ts test/usage.test.ts test/standing.test.ts",
1010 "timeout": 1800
1111 }
1212 }
src/actions/usage.ts +26 −1
@@ -5,12 +5,17 @@
55 * the predicate the run pages use for cost), so a reader who cannot write
66 * the repository is refused. The API route and the MCP `usage` tool both
77 * answer from here, so they never disagree.
8 *
9 * With it, each account's standing (`core/standing.ts`): where it is in its
10 * 5-hour and weekly windows, account-wide. The accounts shown are those
11 * granted here or on the owner, and those that paid for a run here.
812 */
913
1014 import type { App } from "../app.ts";
1115 import { getIssuesByIds } from "../core/issues.ts";
1216 import { canSeeOwnerFacts, type Repo } from "../core/repos.ts";
13 import { listSubscriptions } from "../core/subscriptions.ts";
17 import { standing, type Standing } from "../core/standing.ts";
18 import { listGrants, listSubscriptions } from "../core/subscriptions.ts";
1419 import {
1520 recentUsage,
1621 usageTotals,
@@ -56,6 +61,10 @@ export interface UsageReport {
5661 readonly agent: string | null;
5762 readonly issue: number | null;
5863 })[];
64 /** Each account this app spends: its windows, now, every figure read or inferred. */
65 readonly standing: readonly (Omit<Standing, "subscriptionId"> & {
66 readonly subscription: string;
67 })[];
5968 }
6069
6170 const ZERO: Omit<UsageTotals, "key"> = {
@@ -121,6 +130,21 @@ export function readUsage(
121130 ZERO,
122131 );
123132
133 // Granted at the repository or its owner, any agent, or spent here.
134 const accounts = new Set<string>();
135 for (const g of [...listGrants(db, repo.id), ...listGrants(db, repo.ownerId)])
136 accounts.add(g.subscriptionId);
137 for (const row of bySubscription)
138 if (row.key !== null && subscriptions.has(row.key)) accounts.add(row.key);
139 const standings = [...accounts]
140 .flatMap((id) => {
141 const s = standing(db, id, now);
142 if (s === undefined) return [];
143 const { subscriptionId: _, ...rest } = s;
144 return [{ subscription: subscriptions.get(id) ?? id, ...rest }];
145 })
146 .sort((a, b) => a.subscription.localeCompare(b.subscription));
147
124148 return ok({
125149 app: `${repo.ownerName}/${repo.name}`,
126150 days,
@@ -138,5 +162,6 @@ export function readUsage(
138162 agent: agentName(r.agentId),
139163 issue: issueNumber(r.issueId),
140164 })),
165 standing: standings,
141166 });
142167 }
src/core/schema.ts +16
@@ -1025,4 +1025,20 @@ export const migrations: readonly string[] = [
10251025 CREATE INDEX IF NOT EXISTS usage_repo ON usage(repo_id, at);
10261026 CREATE INDEX IF NOT EXISTS usage_subscription ON usage(subscription_id, at);
10271027 `,
1028
1029 // 38 — a subscription's standing (`core/standing.ts`): the last time the
1030 // provider said each window (5-hour, weekly) was full and when it resets,
1031 // and the share of every window the operator keeps for people.
1032 `
1033 CREATE TABLE subscription_limit (
1034 subscription_id TEXT NOT NULL REFERENCES subscription(id) ON DELETE CASCADE,
1035 span TEXT NOT NULL CHECK (span IN ('5h','week')),
1036 resets_at TEXT NOT NULL,
1037 observed_at TEXT NOT NULL,
1038 said TEXT NOT NULL DEFAULT '',
1039 PRIMARY KEY (subscription_id, span)
1040 );
1041 ALTER TABLE subscription ADD COLUMN headroom_pct INTEGER NOT NULL DEFAULT 0
1042 CHECK (headroom_pct BETWEEN 0 AND 90);
1043 `,
10281044 ];
src/core/standing.ts added +336
@@ -0,0 +1,336 @@
1 /**
2 * Standing — where one subscription is in each of the windows its provider
3 * meters it over: the 5-hour session window and the week. Per window: when
4 * it started and resets, the cap, what is used, what remains, and what of
5 * that the crew may spend once the operator's headroom for people is kept.
6 *
7 * Every figure says where it came from:
8 * - `read` — the provider said it. Claude Code's refusal names the window
9 * and its reset (`crew/limits.ts`'s `limitOf`); the runner notes it here
10 * with `noteLimit`. While that reset is ahead, the window is full.
11 * - `inferred` — worked out from the usage ledger (`core/usage.ts`).
12 *
13 * Units, stated once: money is US dollars of the provider's list-price
14 * equivalent (`usage.cost_usd`), rounded to 1/10 000 of a dollar; tokens
15 * are whole tokens; instants are ISO 8601, UTC.
16 *
17 * How the ledger is read:
18 * - A run counts in the window it started in (`at - wallMs`).
19 * - Where no reset is read, a window opens at the first run to start once
20 * the last one closed, and lasts its length (5 h, 7 d) — the way Claude
21 * meters a session. The chain starts two window-lengths back, or at the
22 * last read reset if that is later; nothing started means no window is
23 * open, and nothing is used.
24 * - A weekly reset once read repeats every 7 days, so a past one projects
25 * to the next (`inferred`, being a projection).
26 * - The cap is what the ledger shows spent in the window when the provider
27 * last said it was full. A person's own use of the account is not in the
28 * ledger, so the cap is a floor; no refusal yet means no cap (null), and
29 * then no remaining either.
30 *
31 * - The ledger is read five weeks back at most; a refusal older than that
32 * no longer sets a cap.
33 *
34 * Pure data: no authz, no events.
35 */
36
37 import type { Database } from "bun:sqlite";
38
39 /** The usage windows a Claude subscription is metered over. */
40 export type LimitWindow = "5h" | "week";
41
42 export const WINDOWS: readonly LimitWindow[] = ["5h", "week"];
43
44 /** Each window's length, in milliseconds. */
45 export const WINDOW_MS: Readonly<Record<LimitWindow, number>> = {
46 "5h": 5 * 3_600_000,
47 week: 7 * 86_400_000,
48 };
49
50 /** The most of a window an operator may keep for people, in percent. */
51 export const HEADROOM_MAX = 90;
52
53 export type Source = "read" | "inferred";
54
55 /** A number or instant, and whether the provider said it or the ledger did. */
56 export interface Figure<T> {
57 readonly value: T;
58 readonly source: Source;
59 }
60
61 export interface WindowStanding {
62 readonly window: LimitWindow;
63 /** The open window; null when nothing has started one. */
64 readonly startsAt: Figure<string> | null;
65 readonly resetsAt: Figure<string> | null;
66 /** US dollars the window holds; null until the provider has refused once. */
67 readonly cap: Figure<number> | null;
68 /** US dollars spent in the window by the crew. */
69 readonly used: Figure<number>;
70 readonly remaining: Figure<number> | null;
71 /** US dollars of the window kept for people: cap × headroom share. */
72 readonly kept: Figure<number> | null;
73 /** What the crew may still spend: remaining − kept, never below 0. */
74 readonly crewRemaining: Figure<number> | null;
75 /** Ledger lines counted in `used`, and their tokens. */
76 readonly runs: number;
77 readonly tokensIn: number;
78 readonly tokensOut: number;
79 /** The provider's words when it last refused in this window kind. */
80 readonly said: string | null;
81 }
82
83 export interface Standing {
84 readonly subscriptionId: string;
85 /** The share of each window the crew never spends, in whole percent. */
86 readonly headroomPct: number;
87 readonly windows: readonly WindowStanding[];
88 }
89
90 export interface Limit {
91 readonly window: LimitWindow;
92 readonly resetsAt: string;
93 readonly observedAt: string;
94 readonly said: string;
95 }
96
97 /**
98 * The provider said `window` is full until `resetsAt`. The newest word for
99 * a window replaces the last; an unknown subscription is ignored.
100 */
101 export function noteLimit(
102 db: Database,
103 subscriptionId: string,
104 limit: Limit,
105 ): void {
106 db.query(
107 `INSERT INTO subscription_limit (subscription_id, span, resets_at, observed_at, said)
108 SELECT ?1, ?2, ?3, ?4, ?5 WHERE EXISTS (SELECT 1 FROM subscription WHERE id = ?1)
109 ON CONFLICT (subscription_id, span) DO UPDATE SET
110 resets_at = excluded.resets_at, observed_at = excluded.observed_at,
111 said = excluded.said`,
112 ).run(
113 subscriptionId,
114 limit.window,
115 limit.resetsAt,
116 limit.observedAt,
117 limit.said.slice(0, 160),
118 );
119 }
120
121 function limitsOf(
122 db: Database,
123 subscriptionId: string,
124 ): Map<LimitWindow, Limit> {
125 const rows = db
126 .query<
127 { span: LimitWindow; resets_at: string; observed_at: string; said: string },
128 [string]
129 >(
130 `SELECT span, resets_at, observed_at, said FROM subscription_limit
131 WHERE subscription_id = ?1`,
132 )
133 .all(subscriptionId);
134 return new Map(
135 rows.map((r) => [
136 r.span,
137 {
138 window: r.span,
139 resetsAt: r.resets_at,
140 observedAt: r.observed_at,
141 said: r.said,
142 },
143 ]),
144 );
145 }
146
147 /** A whole percent 0–90 from text; undefined for anything else. */
148 export function headroomPct(text: string): number | undefined {
149 if (!/^\d{1,2}$/.test(text.trim())) return undefined;
150 const n = Number(text.trim());
151 return n <= HEADROOM_MAX ? n : undefined;
152 }
153
154 /** The operator's share of each window kept for people; false if no such account. */
155 export function setHeadroom(
156 db: Database,
157 subscriptionId: string,
158 pct: number,
159 ): boolean {
160 if (!Number.isInteger(pct) || pct < 0 || pct > HEADROOM_MAX) return false;
161 return (
162 db
163 .query("UPDATE subscription SET headroom_pct = ?2 WHERE id = ?1")
164 .run(subscriptionId, pct).changes > 0
165 );
166 }
167
168 export function getHeadroom(
169 db: Database,
170 subscriptionId: string,
171 ): number | undefined {
172 return db
173 .query<{ headroom_pct: number }, [string]>(
174 "SELECT headroom_pct FROM subscription WHERE id = ?1",
175 )
176 .get(subscriptionId)?.headroom_pct;
177 }
178
179 /** One ledger line as the window arithmetic needs it. */
180 interface Spend {
181 readonly start: number;
182 readonly costUsd: number;
183 readonly tokensIn: number;
184 readonly tokensOut: number;
185 }
186
187 const money = (n: number): number => Math.round(n * 10_000) / 10_000;
188 const iso = (ms: number): string => new Date(ms).toISOString();
189
190 /**
191 * The account's standing in each window at `now`, or undefined when there
192 * is no such subscription. One ledger read covers both windows.
193 */
194 export function standing(
195 db: Database,
196 subscriptionId: string,
197 now: Date,
198 ): Standing | undefined {
199 const pct = getHeadroom(db, subscriptionId);
200 if (pct === undefined) return undefined;
201 const limits = limitsOf(db, subscriptionId);
202 const t = now.getTime();
203 // Two weeks back holds every window's chain; a refusal's window is read
204 // too, so long as it opened within the last five weeks.
205 const floor = t - 5 * WINDOW_MS.week;
206 const since = Math.max(
207 floor,
208 Math.min(
209 t - 2 * WINDOW_MS.week,
210 ...[...limits.values()].map(
211 (l) => Date.parse(l.resetsAt) - WINDOW_MS[l.window],
212 ),
213 ),
214 );
215 const spends: Spend[] = db
216 .query<
217 { at: string; wall_ms: number; cost_usd: number; tokens_in: number; tokens_out: number },
218 [string, string]
219 >(
220 `SELECT at, wall_ms, cost_usd, tokens_in, tokens_out FROM usage
221 WHERE subscription_id = ?1 AND at >= ?2`,
222 )
223 .all(subscriptionId, iso(since))
224 .map((r) => ({
225 start: Date.parse(r.at) - r.wall_ms,
226 costUsd: r.cost_usd,
227 tokensIn: r.tokens_in,
228 tokensOut: r.tokens_out,
229 }))
230 .sort((a, b) => a.start - b.start);
231 return {
232 subscriptionId,
233 headroomPct: pct,
234 windows: WINDOWS.map((w) =>
235 windowStanding(w, limits.get(w), spends, pct, t, since),
236 ),
237 };
238 }
239
240 function windowStanding(
241 window: LimitWindow,
242 limit: Limit | undefined,
243 spends: readonly Spend[],
244 pct: number,
245 t: number,
246 since: number,
247 ): WindowStanding {
248 const length = WINDOW_MS[window];
249 const reset = limit === undefined ? undefined : Date.parse(limit.resetsAt);
250
251 // Where the open window lies, and on whose word.
252 let open: { start: number; end: number; source: Source } | undefined;
253 if (reset !== undefined && reset > t) {
254 open = { start: reset - length, end: reset, source: "read" };
255 } else if (reset !== undefined && window === "week") {
256 const end = reset + Math.ceil((t - reset + 1) / length) * length;
257 open = { start: end - length, end, source: "inferred" };
258 } else {
259 let start = -Infinity;
260 let end = -Infinity;
261 const from = Math.max(t - 2 * length, reset ?? -Infinity);
262 for (const s of spends) {
263 if (s.start < from || s.start > t) continue;
264 if (s.start >= end) {
265 start = s.start;
266 end = s.start + length;
267 }
268 }
269 if (end > t) open = { start, end, source: "inferred" };
270 }
271
272 const sum = (from: number, to: number) => {
273 let costUsd = 0;
274 let tokensIn = 0;
275 let tokensOut = 0;
276 let runs = 0;
277 for (const s of spends)
278 if (s.start >= from && s.start <= to) {
279 costUsd += s.costUsd;
280 tokensIn += s.tokensIn;
281 tokensOut += s.tokensOut;
282 runs++;
283 }
284 return { costUsd: money(costUsd), tokensIn, tokensOut, runs };
285 };
286
287 const spent =
288 open === undefined
289 ? { costUsd: 0, tokensIn: 0, tokensOut: 0, runs: 0 }
290 : sum(open.start, Math.min(t, open.end - 1));
291
292 // The cap: what the crew had spent in the window the provider last
293 // called full, up to the moment it said so. A refusal older than the
294 // ledger read is stale, and gives none.
295 const capUsd =
296 limit === undefined || reset === undefined || reset - length < since
297 ? 0
298 : sum(reset - length, Date.parse(limit.observedAt)).costUsd;
299 const cap: Figure<number> | null =
300 capUsd > 0 ? { value: capUsd, source: "inferred" } : null;
301
302 const full = open?.source === "read";
303 const remaining: Figure<number> | null = full
304 ? { value: 0, source: "read" }
305 : cap === null
306 ? null
307 : { value: money(Math.max(0, cap.value - spent.costUsd)), source: "inferred" };
308 const kept: Figure<number> | null =
309 cap === null ? null : { value: money((cap.value * pct) / 100), source: "inferred" };
310 const crewRemaining: Figure<number> | null =
311 remaining === null
312 ? null
313 : remaining.source === "read"
314 ? remaining
315 : {
316 value: money(Math.max(0, remaining.value - (kept?.value ?? 0))),
317 source: "inferred",
318 };
319
320 return {
321 window,
322 startsAt:
323 open === undefined ? null : { value: iso(open.start), source: open.source },
324 resetsAt:
325 open === undefined ? null : { value: iso(open.end), source: open.source },
326 cap,
327 used: { value: spent.costUsd, source: "inferred" },
328 remaining,
329 kept,
330 crewRemaining,
331 runs: spent.runs,
332 tokensIn: spent.tokensIn,
333 tokensOut: spent.tokensOut,
334 said: limit?.said ?? null,
335 };
336 }
src/crew/limits.ts +31 −8
@@ -5,6 +5,7 @@
55 * it will listen; the person who asked never has to press anything.
66 */
77
8 import type { LimitWindow } from "../core/standing.ts";
89 import type { ClaudeOutcome } from "./claude.ts";
910
1011 /** How many times one task may be paused before the forge gives up on it. */
@@ -53,11 +54,35 @@ const MONTHS = [
5354 * Anything else waits the default.
5455 */
5556 export function resumeAt(message: string, now: Date): Date {
56 const match =
57 /resets?\s+(?:at\s+)?(?:([a-z]{3,9})\.?\s+(\d{1,2}),?\s+(?:at\s+)?)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)\s*\(([^)]+)\)/i.exec(
58 message,
59 );
60 if (match === null) return new Date(now.getTime() + DEFAULT_WAIT_MS);
57 const at = resetOf(message, now);
58 if (at === undefined) return new Date(now.getTime() + DEFAULT_WAIT_MS);
59 return new Date(at.getTime() + SLACK_MS);
60 }
61
62 /**
63 * Which window the provider said is full, and the instant it resets — read
64 * from its own words, or undefined when it named no time. The week is named
65 * (`weekly limit`) or dated (`resets Sep 26 at 9pm`); a bare clock time is
66 * the 5-hour session window. No slack is added: this is the provider's time.
67 */
68 export function limitOf(
69 message: string,
70 now: Date,
71 ): { window: LimitWindow; resetsAt: Date } | undefined {
72 const match = RESET.exec(message);
73 const resetsAt = resetOf(message, now);
74 if (match === null || resetsAt === undefined) return undefined;
75 const weekly = match[1] !== undefined || /\bweek(?:ly)?\b/i.test(message);
76 return { window: weekly ? "week" : "5h", resetsAt };
77 }
78
79 const RESET =
80 /resets?\s+(?:at\s+)?(?:([a-z]{3,9})\.?\s+(\d{1,2}),?\s+(?:at\s+)?)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)\s*\(([^)]+)\)/i;
81
82 /** The reset instant a message names, exactly; undefined when it names none. */
83 function resetOf(message: string, now: Date): Date | undefined {
84 const match = RESET.exec(message);
85 if (match === null) return undefined;
6186 const [, monthName, day, h, m, meridiem, zone] = match;
6287 const hour = (Number(h) % 12) + (meridiem!.toLowerCase() === "pm" ? 12 : 0);
6388 const minute = Number(m ?? "0");
@@ -69,9 +94,7 @@ export function resumeAt(message: string, now: Date): Date {
6994 monthName !== undefined && month >= 0
7095 ? { month: month + 1, day: Number(day) }
7196 : undefined;
72 const at = nextWallTime(now, hour, minute, zone!, date);
73 if (at === undefined) return new Date(now.getTime() + DEFAULT_WAIT_MS);
74 return new Date(at.getTime() + SLACK_MS);
97 return nextWallTime(now, hour, minute, zone!, date);
7598 }
7699
77100 /** `12:04 UTC` — how a resume time reads on a card. */
src/crew/runner.ts +20 −2
@@ -67,7 +67,14 @@ import {
6767 } from "./brief.ts";
6868 import { policyBrief } from "../contract/audit.ts";
6969 import { BUDGET_REACHED, runClaude, type ClaudeOutcome } from "./claude.ts";
70 import { isRateLimited, MAX_PAUSES, resumeAt, whenText } from "./limits.ts";
70 import {
71 isRateLimited,
72 limitOf,
73 MAX_PAUSES,
74 resumeAt,
75 whenText,
76 } from "./limits.ts";
77 import { noteLimit } from "../core/standing.ts";
7178 import {
7279 pickSubscription,
7380 restSubscription,
@@ -755,7 +762,18 @@ async function work(
755762 // The account rests until the provider's time; another on the same
756763 // grant, if one is awake, takes the run straight away.
757764 if (picked.kind === "ready") {
758 const until = resumeAt(result.result, new Date());
765 const now = new Date();
766 const until = resumeAt(result.result, now);
767 // Which window is full and when it resets, in the provider's own
768 // words, for the account's standing (`core/standing.ts`).
769 const limit = limitOf(result.result, now);
770 if (limit !== undefined)
771 noteLimit(app.db, picked.subscription.id, {
772 window: limit.window,
773 resetsAt: limit.resetsAt.toISOString(),
774 observedAt: now.toISOString(),
775 said,
776 });
759777 restSubscription(
760778 app.db,
761779 picked.subscription.id,
src/http/mcp.ts +1 −1
@@ -513,7 +513,7 @@ const TOOLS: readonly Tool[] = [
513513 {
514514 name: "usage",
515515 description:
516 "What the crew's runs on an app spent over the last days (1–90, default 7): tokens in and out, cost in US dollars, wall time and outcome, by subscription, agent and issue, with the newest runs. The same data as /api/v1/repos/:owner/:name/usage; refused unless you can write the app.",
516 "What the crew's runs on an app spent over the last days (1–90, default 7): tokens in and out, cost in US dollars, wall time and outcome, by subscription, agent and issue, with the newest runs, and each account's standing in its 5-hour and weekly windows (cap, used, remaining, resets at — each figure read from the provider or inferred from the ledger). The same data as /api/v1/repos/:owner/:name/usage; refused unless you can write the app.",
517517 inputSchema: {
518518 type: "object",
519519 properties: {
src/http/server.ts +6
@@ -65,6 +65,7 @@ import {
6565 subscriptionAdd,
6666 subscriptionDelete,
6767 subscriptionsPage,
68 subscriptionHeadroom,
6869 subscriptionWake,
6970 } from "../web/pages/subscriptions.ts";
7071 import {
@@ -494,6 +495,11 @@ export function buildRouter(): Router<Handler> {
494495 router.add("POST", "/settings/subscriptions", subscriptionAdd);
495496 router.add("POST", "/settings/subscriptions/:id/delete", subscriptionDelete);
496497 router.add("POST", "/settings/subscriptions/:id/wake", subscriptionWake);
498 router.add(
499 "POST",
500 "/settings/subscriptions/:id/headroom",
501 subscriptionHeadroom,
502 );
497503 router.add("GET", "/settings/secrets", secretsPage);
498504 router.add("POST", "/settings/secrets", secretSet);
499505 router.add("POST", "/settings/secrets/delete", secretDelete);
src/web/pages/subscriptions.ts +88
@@ -18,6 +18,14 @@ import {
1818 wakeSubscription,
1919 type Subscription,
2020 } from "../../core/subscriptions.ts";
21 import {
22 HEADROOM_MAX,
23 headroomPct,
24 setHeadroom,
25 standing,
26 type Figure,
27 type WindowStanding,
28 } from "../../core/standing.ts";
2129 import { getUserById } from "../../core/users.ts";
2230 import type { Ctx } from "../../http/context.ts";
2331 import { notFoundPage, settingsLayout, signInFirst } from "../chrome.ts";
@@ -51,6 +59,23 @@ function requireAdmin(ctx: Ctx): Response | undefined {
5159 const kindText = (s: Subscription): string =>
5260 s.kind === "oauth" ? "Claude subscription" : "API key";
5361
62 const usd = (n: number): string => `$${n.toFixed(2)}`;
63 const mark = (f: Figure<unknown>): string => (f.source === "read" ? "" : "~");
64
65 /** `5-hour: ~$3.20 used of ~$10.00 · resets 12:04 UTC` — one window, plainly. */
66 function windowText(w: WindowStanding): string {
67 const name = w.window === "5h" ? "5-hour" : "weekly";
68 const used = `${mark(w.used)}${usd(w.used.value)} used`;
69 const of = w.cap === null ? "" : ` of ${mark(w.cap)}${usd(w.cap.value)}`;
70 const full =
71 w.remaining?.source === "read" && w.remaining.value === 0 ? " · full" : "";
72 const resets =
73 w.resetsAt === null
74 ? " · opens on next use"
75 : ` · resets ${w.resetsAt.source === "read" ? "" : "~"}${whenText(new Date(w.resetsAt.value))}`;
76 return `${name}: ${used}${of}${full}${resets}`;
77 }
78
5479 const resting = (s: Subscription, now: Date): boolean =>
5580 s.restingUntil !== null && s.restingUntil > now.toISOString();
5681
@@ -85,6 +110,7 @@ export function subscriptionsPage(ctx: Ctx): Response {
85110 : listCard(
86111 pool.map((s) => {
87112 const rests = resting(s, now);
113 const st = standing(ctx.app.db, s.id, now);
88114 const by =
89115 s.createdBy === null
90116 ? undefined
@@ -108,8 +134,36 @@ export function subscriptionsPage(ctx: Ctx): Response {
108134 : html`last used ${timeAgo(s.lastUsedAt)}`}${when(
109135 rests && s.restingSaid !== "",
110136 () => html` · ${s.restingSaid}`,
137 )}${when(
138 st !== undefined,
139 () =>
140 html`<br />${st!.windows.map(windowText).join(" · ")}`,
111141 )}`,
112142 trail: html`${when(
143 st !== undefined,
144 () =>
145 html`<form
146 class="headroom row-actions"
147 method="post"
148 action="${POOL}/${s.id}/headroom"
149 >
150 <label for="h-${s.id}" class="small muted"
151 >kept for people, %</label
152 >
153 <input
154 class="input"
155 id="h-${s.id}"
156 name="headroom"
157 type="number"
158 min="0"
159 max="${HEADROOM_MAX}"
160 step="1"
161 value="${st!.headroomPct}"
162 />
163 ${btn("Keep", { size: "sm" })}
164 </form>`,
165 )}
166 ${when(
113167 rests,
114168 () =>
115169 html`<form
@@ -202,6 +256,16 @@ export function subscriptionsPage(ctx: Ctx): Response {
202256 return shell(
203257 ctx,
204258 html`${list}
259 ${when(
260 pool.length > 0,
261 () =>
262 html`<p class="muted small mt">
263 Windows in US dollars of list price. A figure marked ~ is inferred
264 from what the crew's runs spent; the rest the provider said. The
265 cap is learned the first time the provider calls a window full,
266 and the share kept for people is never spent by the crew.
267 </p>`,
268 )}
205269 <div class="mt">${add}</div>
206270 ${used === null ? null : html`<div class="mt">${used}</div>`}`,
207271 );
@@ -265,6 +329,30 @@ export async function subscriptionWake(ctx: Ctx): Promise<Response> {
265329 return flashTo(ctx, POOL, { message: "In use again." });
266330 }
267331
332 // POST /settings/subscriptions/:id/headroom
333 export async function subscriptionHeadroom(ctx: Ctx): Promise<Response> {
334 const denied = requireAdmin(ctx);
335 if (denied) return denied;
336 const form = await readForm(ctx, "admin");
337 if (form instanceof Response) return form;
338 const pct = headroomPct(field(form, "headroom"));
339 if (pct === undefined)
340 return flashTo(ctx, POOL, {
341 tone: "danger",
342 message: `Headroom is a whole percent from 0 to ${HEADROOM_MAX}.`,
343 });
344 const saved = setHeadroom(ctx.app.db, ctx.params.id ?? "", pct);
345 return flashTo(
346 ctx,
347 POOL,
348 saved
349 ? {
350 message: `The crew now leaves ${pct}% of each window for people.`,
351 }
352 : { tone: "danger", message: "No such subscription." },
353 );
354 }
355
268356 // ---- grants, on a repository's Crew settings and an organization's page ----
269357
270358 /**
src/web/style/content.ts +3
@@ -294,6 +294,9 @@ a.origin-chip:hover { border-color: var(--border-strong); color: var(--fg); }
294294 .grants th[scope="row"] { text-align: left; font-weight: 500; white-space: nowrap; }
295295 .grants input[type="checkbox"] { width: 16px; height: 16px; accent-color: var(--accent); }
296296 .grants-foot { display: flex; align-items: center; gap: 12px; padding: 10px 12px; border-top: 1px solid var(--border); }
297 /* A subscription's headroom for people: a small number beside its windows. */
298 .headroom { display: flex; align-items: center; gap: 6px; }
299 .headroom .input { width: 5em; }
297300 .backups-now { margin-top: 12px; }
298301 .backups-error { display: flex; align-items: center; gap: 8px; color: var(--danger); }
299302 .backups-restore { margin: 8px 0 0; padding: 8px 10px; font-size: 12px; white-space: pre-wrap; word-break: break-all; background: var(--muted); border-radius: var(--radius); }
test/standing.test.ts added +251
@@ -0,0 +1,251 @@
1 /**
2 * A subscription's standing in its 5-hour and weekly windows, against
3 * windows worked by hand: which runs fall in, what they spent, when the
4 * window opened and resets, and which of those the provider said.
5 */
6
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { createRepo } from "../src/actions/repos.ts";
12 import { createApp, type App } from "../src/app.ts";
13 import type { Repo } from "../src/core/repos.ts";
14 import { createRun } from "../src/core/runs.ts";
15 import {
16 getHeadroom,
17 headroomPct,
18 noteLimit,
19 setHeadroom,
20 standing,
21 type WindowStanding,
22 } from "../src/core/standing.ts";
23 import { addSubscription, deleteSubscription } from "../src/core/subscriptions.ts";
24 import { recordUsage } from "../src/core/usage.ts";
25 import { createUser } from "../src/core/users.ts";
26 import { limitOf, resumeAt } from "../src/crew/limits.ts";
27
28 describe("which window the provider says is full", () => {
29 test("a bare clock time is the 5-hour window, at that time exactly", () => {
30 const now = new Date("2026-10-02T05:00:00Z");
31 const said = "You've hit your session limit · resets 7:20am (UTC)";
32 expect(limitOf(said, now)).toEqual({
33 window: "5h",
34 resetsAt: new Date("2026-10-02T07:20:00Z"),
35 });
36 // The pause still waits a minute past it.
37 expect(resumeAt(said, now).toISOString()).toBe("2026-10-02T07:21:00.000Z");
38 });
39 test("a dated reset, or one named weekly, is the week's", () => {
40 // 9pm in Chicago in September is daylight time, UTC−5: 02:00Z next day.
41 expect(
42 limitOf(
43 "You've hit your weekly limit · resets Sep 26 at 9pm (America/Chicago)",
44 new Date("2026-09-24T00:00:00Z"),
45 ),
46 ).toEqual({ window: "week", resetsAt: new Date("2026-09-27T02:00:00Z") });
47 expect(
48 limitOf("Weekly limit reached · resets 3am (UTC)", new Date("2026-10-02T05:00:00Z")),
49 ).toEqual({ window: "week", resetsAt: new Date("2026-10-03T03:00:00Z") });
50 });
51 test("no time named is nothing read", () => {
52 expect(limitOf("rate limit exceeded", new Date())).toBeUndefined();
53 expect(limitOf("resets 7:20am (Not/AZone)", new Date())).toBeUndefined();
54 });
55 });
56
57 describe("the headroom an operator keeps", () => {
58 test("is a whole percent 0–90", () => {
59 expect(headroomPct("0")).toBe(0);
60 expect(headroomPct("20")).toBe(20);
61 expect(headroomPct(" 90 ")).toBe(90);
62 for (const bad of ["91", "-1", "12.5", "", "1e1", "100", "abc"])
63 expect(headroomPct(bad)).toBeUndefined();
64 });
65 });
66
67 describe("a subscription's standing", () => {
68 let dataDir: string;
69 let app: App;
70 let repo: Repo;
71
72 beforeAll(async () => {
73 dataDir = await mkdtemp(join(tmpdir(), "forge-standing-"));
74 app = createApp({ dataDir, port: 0 });
75 const a = await createUser(app.db, {
76 username: "alice",
77 email: "[email protected]",
78 password: "a-good-passphrase",
79 adminIfFirst: true,
80 });
81 if (!a.ok) throw new Error(a.error);
82 const made = await createRepo(app, a.value, { name: "one", visibility: "private" });
83 if (!made.ok) throw new Error("repo");
84 repo = made.value;
85 });
86 afterAll(async () => {
87 app.close();
88 await rm(dataDir, { recursive: true, force: true });
89 });
90
91 let n = 0;
92 const account = (): string => {
93 const added = addSubscription(app.db, dataDir, {
94 name: `account ${++n}`,
95 token: `sk-ant-oat01-standing-test-${n}`,
96 });
97 if (!added.ok) throw new Error(added.error);
98 return added.value.id;
99 };
100 /** A run on `sub` that started at `start`, ran `minutes`, spent `usd`. */
101 const spend = (sub: string, start: string, minutes: number, usd: number): void => {
102 const wallMs = minutes * 60_000;
103 recordUsage(app.db, {
104 runId: createRun(app.db, { repoId: repo.id, kind: "agent" }).id,
105 repoId: repo.id,
106 issueId: null,
107 agentId: null,
108 subscriptionId: sub,
109 tokensIn: 1000,
110 tokensOut: 100,
111 costUsd: usd,
112 wallMs,
113 outcome: "ok",
114 at: new Date(Date.parse(start) + wallMs).toISOString(),
115 });
116 };
117 const win = (sub: string, now: string, w: "5h" | "week"): WindowStanding =>
118 standing(app.db, sub, new Date(now))!.windows.find((x) => x.window === w)!;
119
120 test("with nothing said, each window opens at the first run after the last closed", () => {
121 const sub = account();
122 spend(sub, "2026-10-02T03:00:00Z", 10, 4); // opens 03:00–08:00
123 spend(sub, "2026-10-02T08:00:00Z", 30, 1.25); // at 08:00 that one has closed: opens 08:00–13:00
124 spend(sub, "2026-10-02T10:00:00Z", 10, 0.5); // inside 08:00–13:00
125 const now = "2026-10-02T12:00:00Z";
126
127 // 5 h: 08:00–13:00 holds 1.25 + 0.5 = 1.75. No refusal yet, so no cap.
128 const session = win(sub, now, "5h");
129 expect(session.startsAt).toEqual({ value: "2026-10-02T08:00:00.000Z", source: "inferred" });
130 expect(session.resetsAt).toEqual({ value: "2026-10-02T13:00:00.000Z", source: "inferred" });
131 expect(session.used).toEqual({ value: 1.75, source: "inferred" });
132 expect(session.runs).toBe(2);
133 expect(session.tokensIn).toBe(2000);
134 expect(session.cap).toBeNull();
135 expect(session.remaining).toBeNull();
136 expect(session.crewRemaining).toBeNull();
137
138 // The week opened with the 03:00 run and holds all three: 5.75.
139 const week = win(sub, now, "week");
140 expect(week.startsAt).toEqual({ value: "2026-10-02T03:00:00.000Z", source: "inferred" });
141 expect(week.resetsAt).toEqual({ value: "2026-10-09T03:00:00.000Z", source: "inferred" });
142 expect(week.used.value).toBe(5.75);
143
144 // At 13:00 the 5-hour window has closed and nothing has opened another.
145 const later = win(sub, "2026-10-02T13:00:00Z", "5h");
146 expect(later.startsAt).toBeNull();
147 expect(later.resetsAt).toBeNull();
148 expect(later.used).toEqual({ value: 0, source: "inferred" });
149 });
150
151 test("the provider's refusal is read: full until its reset, and the ledger then gives the cap", () => {
152 const sub = account();
153 expect(setHeadroom(app.db, sub, 20)).toBe(true);
154 spend(sub, "2026-10-02T08:00:00Z", 30, 2); // before the window the provider names
155 spend(sub, "2026-10-02T09:30:00Z", 30, 3);
156 spend(sub, "2026-10-02T10:30:00Z", 20, 2); // the run that was refused at 11:00
157 noteLimit(app.db, sub, {
158 window: "5h",
159 resetsAt: "2026-10-02T14:00:00.000Z",
160 observedAt: "2026-10-02T11:00:00.000Z",
161 said: "You've hit your session limit · resets 2pm (UTC)",
162 });
163
164 // At 12:00: the window is 09:00–14:00 on the provider's word. In it
165 // the crew had spent 3 + 2 = 5 when it was refused: the cap. Remaining
166 // is 0, read; 20% of 5 = 1 is kept for people.
167 const full = win(sub, "2026-10-02T12:00:00Z", "5h");
168 expect(full.startsAt).toEqual({ value: "2026-10-02T09:00:00.000Z", source: "read" });
169 expect(full.resetsAt).toEqual({ value: "2026-10-02T14:00:00.000Z", source: "read" });
170 expect(full.used).toEqual({ value: 5, source: "inferred" });
171 expect(full.cap).toEqual({ value: 5, source: "inferred" });
172 expect(full.remaining).toEqual({ value: 0, source: "read" });
173 expect(full.kept).toEqual({ value: 1, source: "inferred" });
174 expect(full.crewRemaining).toEqual({ value: 0, source: "read" });
175 expect(full.said).toContain("session limit");
176
177 // At 15:00 the reset has passed and nothing has run since: no window
178 // open, the cap still known, everything but the 20% the crew's.
179 const after = win(sub, "2026-10-02T15:00:00Z", "5h");
180 expect(after.startsAt).toBeNull();
181 expect(after.cap).toEqual({ value: 5, source: "inferred" });
182 expect(after.remaining).toEqual({ value: 5, source: "inferred" });
183 expect(after.crewRemaining).toEqual({ value: 4, source: "inferred" });
184
185 // A run at 14:30 for $1.20 opens the next window, 14:30–19:30:
186 // remaining 5 − 1.2 = 3.8, the crew's 3.8 − 1 = 2.8.
187 spend(sub, "2026-10-02T14:30:00Z", 10, 1.2);
188 const next = win(sub, "2026-10-02T15:00:00Z", "5h");
189 expect(next.startsAt).toEqual({ value: "2026-10-02T14:30:00.000Z", source: "inferred" });
190 expect(next.resetsAt).toEqual({ value: "2026-10-02T19:30:00.000Z", source: "inferred" });
191 expect(next.used.value).toBe(1.2);
192 expect(next.remaining).toEqual({ value: 3.8, source: "inferred" });
193 expect(next.crewRemaining).toEqual({ value: 2.8, source: "inferred" });
194 });
195
196 test("a weekly reset once read repeats every seven days", () => {
197 const sub = account();
198 spend(sub, "2026-09-25T10:00:00Z", 60, 30); // in the week the provider closed
199 spend(sub, "2026-09-29T10:00:00Z", 60, 6); // in the week after
200 spend(sub, "2026-10-02T09:00:00Z", 60, 4.5);
201 noteLimit(app.db, sub, {
202 window: "week",
203 resetsAt: "2026-09-28T02:00:00.000Z",
204 observedAt: "2026-09-27T20:00:00.000Z",
205 said: "You've hit your weekly limit · resets Sep 27 at 9pm (America/Chicago)",
206 });
207 // Sep 28 02:00 + 7 d = Oct 5 02:00, the first reset after Oct 2 12:00.
208 // Used since Sep 28 02:00: 6 + 4.5. The cap is the week to Sep 27 20:00: 30.
209 const week = win(sub, "2026-10-02T12:00:00Z", "week");
210 expect(week.startsAt).toEqual({ value: "2026-09-28T02:00:00.000Z", source: "inferred" });
211 expect(week.resetsAt).toEqual({ value: "2026-10-05T02:00:00.000Z", source: "inferred" });
212 expect(week.used.value).toBe(10.5);
213 expect(week.cap).toEqual({ value: 30, source: "inferred" });
214 expect(week.remaining).toEqual({ value: 19.5, source: "inferred" });
215 // Exactly at a projected reset, the next week begins.
216 const edge = win(sub, "2026-10-05T02:00:00Z", "week");
217 expect(edge.startsAt!.value).toBe("2026-10-05T02:00:00.000Z");
218 expect(edge.used.value).toBe(0);
219 });
220
221 test("the newest word for a window replaces the last; a removed account has none", () => {
222 const sub = account();
223 const said = (resetsAt: string) =>
224 noteLimit(app.db, sub, {
225 window: "5h",
226 resetsAt,
227 observedAt: "2026-10-02T11:00:00.000Z",
228 said: "x".repeat(500),
229 });
230 said("2026-10-02T13:00:00.000Z");
231 said("2026-10-02T14:00:00.000Z");
232 const w = win(sub, "2026-10-02T12:00:00Z", "5h");
233 expect(w.resetsAt!.value).toBe("2026-10-02T14:00:00.000Z");
234 expect(w.said!.length).toBe(160);
235 expect(setHeadroom(app.db, sub, 91)).toBe(false);
236 expect(setHeadroom(app.db, sub, -1)).toBe(false);
237 expect(getHeadroom(app.db, sub)).toBe(0);
238 expect(deleteSubscription(app.db, sub)).toBe(true);
239 expect(standing(app.db, sub, new Date())).toBeUndefined();
240 expect(setHeadroom(app.db, sub, 10)).toBe(false);
241 // A note for an account that is not there is dropped, not kept.
242 said("2026-10-02T15:00:00.000Z");
243 expect(
244 app.db
245 .query<{ n: number }, [string]>(
246 "SELECT COUNT(*) AS n FROM subscription_limit WHERE subscription_id = ?1",
247 )
248 .get(sub)!.n,
249 ).toBe(0);
250 });
251 });
test/subscriptions.test.ts +40
@@ -1,3 +1,4 @@
1 import { getHeadroom } from "../src/core/standing.ts";
12 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
23 import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs";
34 import { mkdtemp, rm } from "node:fs/promises";
@@ -505,6 +506,36 @@ describe("the pages", () => {
505506 expect(html).toContain("@acme");
506507 });
507508
509 test("an admin keeps a share of each window for people, and sees each window", async () => {
510 const work = listSubscriptions(app.db)[0]!;
511 let html = await get("/settings/subscriptions");
512 expect(html).toContain(`action="/settings/subscriptions/${work.id}/headroom"`);
513 expect(html).toContain("5-hour: ~$0.00 used · opens on next use");
514 const kept = await post(`/settings/subscriptions/${work.id}/headroom`, {
515 headroom: "25",
516 });
517 expect(kept.status).toBe(303);
518 expect(getHeadroom(app.db, work.id)).toBe(25);
519 for (const bad of ["91", "-5", "ten", ""]) {
520 const refused = await post(`/settings/subscriptions/${work.id}/headroom`, {
521 headroom: bad,
522 });
523 expect(refused.status).toBe(303);
524 }
525 expect(getHeadroom(app.db, work.id)).toBe(25);
526 html = await get("/settings/subscriptions");
527 expect(html).toContain('value="25"');
528 // Without the page's own origin, nothing changes.
529 const forged = await fetch(`${base}/settings/subscriptions/${work.id}/headroom`, {
530 method: "POST",
531 headers: { cookie, origin: "https://elsewhere.example" },
532 body: new URLSearchParams({ headroom: "0" }),
533 redirect: "manual",
534 });
535 expect(forged.status).toBe(403);
536 expect(getHeadroom(app.db, work.id)).toBe(25);
537 });
538
508539 test("nobody but an admin reaches the pool", async () => {
509540 const signed = await fetch(`${base}/signup`, {
510541 method: "POST",
@@ -528,5 +559,14 @@ describe("the pages", () => {
528559 });
529560 expect(posted.status).toBe(404);
530561 expect(listSubscriptions(app.db)).toHaveLength(1);
562 const work = listSubscriptions(app.db)[0]!;
563 const headroom = await fetch(`${base}/settings/subscriptions/${work.id}/headroom`, {
564 method: "POST",
565 headers: { cookie: guest, origin: base },
566 body: new URLSearchParams({ headroom: "0" }),
567 redirect: "manual",
568 });
569 expect(headroom.status).toBe(404);
570 expect(getHeadroom(app.db, work.id)).toBe(25);
531571 });
532572 });
test/usage.test.ts +23
@@ -428,6 +428,25 @@ echo '{"type":"result","subtype":"success","is_error":false,"result":"Added CREW
428428 expect(body.recent[0].subscription).toBe("work");
429429 expect(JSON.stringify(body)).not.toContain(KEY);
430430
431 // Each account's standing rides along. The personal plan's week is full
432 // on the provider's word, until the reset it named; the work key's
433 // windows are inferred from the one run it paid for.
434 expect(body.standing.map((s: any) => s.subscription)).toEqual([
435 "personal",
436 "work",
437 ]);
438 const [mine, theirs] = body.standing;
439 const week = mine.windows.find((w: any) => w.window === "week");
440 expect(week.resetsAt.source).toBe("read");
441 expect(week.resetsAt.value.slice(5, 16)).toBe("09-26T21:00");
442 expect(week.remaining).toEqual({ value: 0, source: "read" });
443 expect(week.said).toContain("weekly limit");
444 const session = theirs.windows.find((w: any) => w.window === "5h");
445 expect(session.used).toEqual({ value: 0.0375, source: "inferred" });
446 expect(session.startsAt.source).toBe("inferred");
447 expect(session.cap).toBeNull();
448 expect(session.tokensIn).toBe(32_120);
449
431450 // A window that is not 1–90 days is refused, not guessed at.
432451 const bad = await fetch(`${url}?days=365`, {
433452 headers: { authorization: `Bearer ${owner}` },
@@ -465,6 +484,10 @@ echo '{"type":"result","subtype":"success","is_error":false,"result":"Added CREW
465484 const told = await mcp(owner, { repo: "alice/garden", days: 7 });
466485 const value = JSON.parse(told.result.content[0].text);
467486 expect(value.total).toEqual(body.total);
487 expect(value.standing.map((s: any) => s.subscription)).toEqual([
488 "personal",
489 "work",
490 ]);
468491 const no = await mcp(reader, { repo: "alice/garden" });
469492 expect(no.result.isError).toBe(true);
470493 expect(no.result.content[0].text).toContain("forbidden");

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs