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

The terminal's usage screen says what the crew's tokens bought: dollars, runs, changes landed and cost per change, as MCP `returns` does #7283

Merged opened this

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

Merged

mason/forge:mason/7271-the-terminal-s-usage-screen-says-what-th → main 6 commits fork 4 threads · 4 unresolved

  • Merged. mason/7271-the-terminal-s-usage-screen-says-what-th was merged into main as b2e06c6.

Review

Sign in to review this change.
9 files changed +236 −15
README.md +2
@@ -429,6 +429,8 @@ This page is the front door. Everything else is a file of its own under
429429 - [`docs/DESIGN.md`](docs/DESIGN.md) — the design system's spine; its sections are under `docs/design/sections/`.
430430 - [`docs/SPIKE-CLOUDFLARE.md`](docs/SPIKE-CLOUDFLARE.md), [`docs/AUDIT-MANY-HANDS.md`](docs/AUDIT-MANY-HANDS.md), [`docs/BACKLOG.md`](docs/BACKLOG.md) and [`docs/research/`](docs/research/) — a spike, an audit, a backlog and research notes, kept as written.
431431
432 What the crew's tokens bought is on the board (`/board/returns`), in MCP `returns`, and in the terminal: the palette's Usage carries a Returns line (dollars, runs, changes landed, cost per landed change; `[`/`]` move between 1, 7, 30 and 90 days), and Usage of a repository (`owner/name`) narrows it to one app. Owner facts only (`src/actions/returns.ts`); see [`docs/OPERATING.md`](docs/OPERATING.md).
433
432434 The terminal's palette has `Signed in` for anyone signed in: your browsers, tokens and this terminal's key, with when each was last seen; `d` signs the selected browser out after a confirm, as `/settings` and MCP `sessions` do (`docs/CREW.md`, "Where you are signed in").
433435
434436 `/changelog` lists the last 30 releases of `FORGE_REPO` (public only) with their pulls, for anyone; the terminal's palette has `Changelog` for the same list (`docs/features/changelog.md`).
docs/OPERATING.md +1 −1
@@ -163,7 +163,7 @@ A forge admin opens the palette (`ctrl+k`), picks **Secrets**, and sees the seal
163163
164164 A forge admin sees every declared flag at `/settings/flags` (Settings → Flags): why it exists, its default, the owner issue and its live value. Set it for the whole forge, for listed user ids or roles, or for a percentage of people; Clear returns it to the default. A change is validated, kept in the change record and live on the next request, with no restart. In the terminal the same admin opens the command palette (`ctrl+k`), picks Flags, and presses enter to set a flag from a line such as `forge=on users=u1,u2 roles=admin percent=30` or `x` to clear it; an agent is never an admin there either. The MCP `flag_set` tool does the same with the same checks, for an admin token. The page and the terminal screen both list flags through one read, `readFlags` in `src/actions/flags_view.ts`.
165165
166 Settings → Usage is one page for a forge admin, opening at `/settings/spend`, with four tabs: Spend, Routes (`/settings/usage`), Capacity (`/settings/capacity`) and Telemetry (`/settings/telemetry`); each tab keeps its own URL and permission. Spend is admin-only (anyone else gets 404) and holds what the crew's runs spent, by credential, app, agent, issue and run kind; it used to sit on Subscriptions, which now links to it. Routes lists every route the router knows: its requests over the last 30 days, by role and by surface, and the hour of its last use. Routes with no request in the window are listed first, never-used ones before the longest-unused, as candidates to drop; the rest follow by count. Counts are per pattern, not per method. `usage` is also a boards source, for an admin only (an agent is refused); its `hour` column is text like `2026-10-16T13`, so group by a column such as `pattern` or `role`, not by a time bucket. In the terminal the same admin opens the command palette (`ctrl+k`), picks Usage, and reads the Routes list only — the spend breakdown, capacity and telemetry tabs are web-only, and the terminal has never shown spend; an agent is never an admin there either.
166 Settings → Usage is one page for a forge admin, opening at `/settings/spend`, with four tabs: Spend, Routes (`/settings/usage`), Capacity (`/settings/capacity`) and Telemetry (`/settings/telemetry`); each tab keeps its own URL and permission. Spend is admin-only (anyone else gets 404) and holds what the crew's runs spent, by credential, app, agent, issue and run kind; it used to sit on Subscriptions, which now links to it. Routes lists every route the router knows: its requests over the last 30 days, by role and by surface, and the hour of its last use. Routes with no request in the window are listed first, never-used ones before the longest-unused, as candidates to drop; the rest follow by count. Counts are per pattern, not per method. `usage` is also a boards source, for an admin only (an agent is refused); its `hour` column is text like `2026-10-16T13`, so group by a column such as `pattern` or `role`, not by a time bucket. In the terminal the same admin opens the command palette (`ctrl+k`), picks Usage, and reads the Routes list under a Returns line: what the crew's tokens bought over the last 7 days (dollars, runs, changes landed, the median cost of a landed change), as MCP `returns` and `/board/returns` read it; `[` and `]` move the window between 1, 7, 30 and 90 days, and a window with no runs reads "nothing spent". The palette's Usage of a repository (`owner/name`) shows that line for one app alone, as `repo` does in MCP, with no routes (they are counted forge-wide); it is for whoever may read the repository's owner facts, and anyone `returns` refuses sees no line and no window keys. The spend breakdown, capacity and telemetry tabs are web-only; an agent is never an admin there either.
167167
168168 Every read of a flag is counted in memory by UTC day and variant (on or off) and written in one batch every ten seconds and at exit, so a request runs no extra query. Beside each flag, Settings → Flags shows its reads per variant for the last 7 and 30 days. When one variant leads, "Make on/off the default" sets the flag to that value for everyone (user, role and percentage rules are cleared), and opens an issue on the forge's repository asking the crew to remove the losing path and the flag; if the issue cannot be opened, the flag is left as it was. The button shows only when the 30-day leader has at least 50 reads in all, the variants are not tied, and the flag's forge value is not already the leader. In the terminal the Flags screen shows the same `Uses, 7 days / 30 days` line for the flag under the cursor, and `w` does the same as the button (same admin, non-agent gate). Counts are of reads, not of people.
169169
src/core/money.ts added +4
@@ -0,0 +1,4 @@
1 /** Dollars as every surface writes them, the board, its returns page and the terminal alike. */
2
3 /** Cents under ten, whole above. */
4 export const usd = (n: number): string => `$${n < 10 ? n.toFixed(2) : n.toFixed(0)}`;
src/tui/forge.ts +6
@@ -125,6 +125,12 @@ const REPO_VERBS: readonly {
125125 title: "Milestones in a repository",
126126 open: (f, ctx, repo) => limbsScreen(f, ctx, repo),
127127 },
128 {
129 // What the crew's tokens bought on one app, as MCP `returns` with `repo` (#7271).
130 id: "repo-usage",
131 title: "Usage of a repository",
132 open: (f, ctx, repo) => usageScreen(f, ctx, repo),
133 },
128134 { id: "new-issue", title: "New issue", open: newIssueScreen },
129135 { id: "new-pull", title: "New pull request", open: newPullScreen },
130136 {
src/tui/screens/usage.ts +61 −10
@@ -4,11 +4,20 @@
44 * first with their last use or `never`, as Settings → Usage lists them
55 * (`routeUsage`). The gate is the page's: an admin who is not an agent;
66 * anyone else sees nothing.
7 *
8 * Above it, what the crew's tokens bought (#7271), as MCP `returns` reads
9 * it (`readReturns`): dollars, runs, changes landed and cost per change over
10 * 1, 7, 30 or 90 days, `[`/`]` to move. A repository's usage narrows it to
11 * that app and lists no routes; someone `readReturns` refuses sees no block.
712 */
813
914 import { USAGE_ADMIN_ONLY } from "../../actions/admin_limits_words.ts";
15 import { readReturns } from "../../actions/returns.ts";
1016 import { mayManageFlags } from "../../core/flags.ts";
1117 import { ROLES, SURFACES } from "../../core/usage_count.ts";
18 import { usd } from "../../core/money.ts";
19 import type { Repo } from "../../core/repos.ts";
20 import type { Returns } from "../../core/returns.ts";
1221 import { routeUsage, WINDOW_DAYS, type RouteUsage } from "../../core/usage_report.ts";
1322 import { knownPatterns } from "../../http/server.ts";
outdated

Importing usd from the web page module couples the TUI to the HTML layer. Move it to a shared core module and import it from there in both places.

1423 import type { Ctx, Screen } from "../app/app.ts";
@@ -30,24 +39,66 @@ export function usageRows(r: readonly RouteUsage[]): ListRow[] {
3039 }));
3140 }
3241
33 export function usageScreen(f: Session, ctx: Ctx): Screen {
42 /** The windows `[`/`]` move between, in days; 7 is where the screen opens. */
43 export const RETURNS_SPANS = [1, 7, 30, 90] as const;
44
45 const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`;
46
47 /** The returns block's one line, in words: "nothing spent" when no run ran, never a NaN. */
48 export function returnsLine(r: Returns, days: number): string {
49 const span = `Returns, ${plural(days, "day", "days")}: `;
50 if (r.spent.runs === 0) return `${span}nothing spent · ${plural(r.landed, "change", "changes")} landed`;
51 const per = r.onLanded.perChange;
52 return `${span}${usd(r.spent.usd)} over ${plural(r.spent.runs, "run", "runs")} · ${plural(r.landed, "change", "changes")} landed · ${
53 per.changes === 0 ? "no landed change to cost" : `${usd(per.median)} a landed change (median)`
54 }`;
55 }
56
57 export function usageScreen(f: Session, ctx: Ctx, repo?: Repo): Screen {
3458 const state = new ListState();
35 const allowed = mayManageFlags(f.user);
59 const allowed = repo === undefined && mayManageFlags(f.user);
3660 // Read once when the screen opens: the report scans the whole table, so a frame or a key never re-runs it.
3761 const rows: RouteUsage[] = allowed ? routeUsage(f.db, knownPatterns(), new Date()) : [];
62 const scope = repo === undefined ? {} : { repo: `${repo.ownerName}/${repo.name}` };
63 let span = RETURNS_SPANS.indexOf(7);
64 // Read when the window moves, never per frame; undefined is a refusal, and then there is no block.
65 const read = (): string | undefined => {
66 const days = RETURNS_SPANS[span]!;
67 const r = readReturns(f.app, f.user, { ...scope, days });
68 return r.ok ? returnsLine(r.value, days) : undefined;
69 };
70 let returns = read();
3871 return {
39 title: "Usage",
40 keys: [binding(["j", "k"], "move cursor")],
72 title: repo === undefined ? "Usage" : `Usage · ${repo.ownerName}/${repo.name}`,
73 keys: [
74 binding(["j", "k"], "move cursor", { hidden: rows.length === 0 }),
75 binding(["[", "]"], "returns window", { hidden: returns === undefined }),
76 ],
4177 render(area, s) {
4278 const c = ctx.theme.c;
43 const [head, list] = split(area, "v", [len(1), fill(1)]);
79 const [top, head, list] = split(area, "v", [len(returns === undefined ? 0 : 1), len(1), fill(1)]);
80 if (returns !== undefined)
81 s.text(top!.x, top!.y, truncate(returns, top!.w), style(c.fg, c.bg), top!.x + top!.w);
4482 const all = rows;
45 const text = !allowed
46 ? USAGE_ADMIN_ONLY
47 : `${all.filter((r) => r.total === 0).length} of ${all.length} routes had no request in ${WINDOW_DAYS} days; unused first.`;
83 const text =
84 repo !== undefined
85 ? returns === undefined ? "Nothing here is yours to see." : "Routes are counted forge-wide: Usage, without a repository."
86 : !allowed
87 ? USAGE_ADMIN_ONLY
88 : `${all.filter((r) => r.total === 0).length} of ${all.length} routes had no request in ${WINDOW_DAYS} days; unused first.`;
4889 s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
49 drawList(s, list!, c, state, usageRows(all));
90 // A repository's usage has no routes to list: no empty pane under the line.
91 if (repo === undefined) drawList(s, list!, c, state, usageRows(all));
92 },
93 key: (e) => {
94 if (returns !== undefined && !e.ctrl && !e.alt && (e.key === "[" || e.key === "]")) {
95 const next = Math.max(0, Math.min(RETURNS_SPANS.length - 1, span + (e.key === "]" ? 1 : -1)));
96 if (next === span) return true;
97 span = next;
98 returns = read();
99 return true;
100 }
101 return state.key(e, rows.length);
50102 },
51 key: (e) => state.key(e, rows.length),
52103 };
53104 }
src/web/pages/board.ts +1 −2
@@ -14,6 +14,7 @@
1414 * pointer. The old big view (`/big`, every address it had) lands here.
1515 */
1616
17 import { usd } from "../../core/money.ts";
1718 import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts";
1819 import { sentinelNotice } from "../../crew/sentinel.ts";
1920 import { appsForRepos, type AppRecord } from "../../core/apps.ts";
@@ -466,8 +467,6 @@ function asksStrip(ctx: Ctx): Html | null {
466467
467468 // ---- what the tokens bought ----
468469

Non-blocking: removing usd left two blank lines here; one is enough.

469 /** Dollars as the board writes them: cents under ten, whole above. */
470 export const usd = (n: number): string => `$${n < 10 ? n.toFixed(2) : n.toFixed(0)}`;
471470

Nit: removing usd left two blank lines in a row between the section marker and the doc comment. Drop one the next time this file is touched.

472471 /**
473472 * The week's spend against what it landed, in one line, owner facts only:
src/web/pages/returns.ts +1 −1
@@ -15,7 +15,7 @@ import { notFoundPage, signInFirst } from "../chrome.ts";
1515 import { html, type Html } from "../html.ts";
1616 import { page } from "../layout.ts";
1717 import { card, listCard, row } from "../ui.ts";
18 import { usd } from "./board.ts";
18 import { usd } from "../../core/money.ts";
1919
2020 const runs = (m: Money): string => `${m.runs} ${m.runs === 1 ? "run" : "runs"}`;
2121
test/tui-report.test.ts +2 −1
@@ -74,7 +74,8 @@ afterAll(async () => {
7474
7575 describe("report a problem in the terminal", () => {
7676 test("the palette has the row", async () => {
77 expect(await palette(bob, [":"])).toContain("alice/lobby — Report a problem");
77 // Filtered, so the row's presence does not depend on how many others the palette lists.
78 expect(await palette(bob, [":", "Report a"])).toContain("alice/lobby — Report a problem");
7879 });
7980
8081 test("a text opens an issue titled by its first line", async () => {
test/tui-usage-returns.test.ts added +158
@@ -0,0 +1,158 @@
1 /**
2 * The terminal's usage screen says what the crew's tokens bought (#7271), as
3 * MCP `returns` reads it: dollars, runs, changes landed and cost per change
4 * over 7 days, `[`/`]` to 1, 30 and 90; a repository's usage narrows to that
5 * app; someone `returns` refuses sees no block; no runs is "nothing spent".
6 */
7 import { afterAll, beforeAll, 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 { addToCrew, createAgent } from "../src/actions/crew.ts";
12 import { openIssue } from "../src/actions/issues.ts";
13 import { openPull } from "../src/actions/pulls.ts";
14 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
15 import { createApp, type App as Forge } from "../src/app.ts";
16 import type { Repo } from "../src/core/repos.ts";
17 import { createRun } from "../src/core/runs.ts";
18 import { createUser, getUserByName } from "../src/core/users.ts";
19 import { createForgeApp } from "../src/tui/forge.ts";
20 import { headlessTerminal } from "../src/tui/term/terminal.ts";
21 import { quietLogs } from "./helpers/globals.ts";
22 import { settleApp } from "./helpers/tui.ts";
23
24 quietLogs();
25
26 let dataDir: string;
27 let forge: Forge;
28 const DAY = 86_400_000;
29
30 function boot(username: string) {
31 const user = getUserByName(forge.db, username)!;
32 const { app } = createForgeApp(
33 forge,
34 { sshUsername: "t", remoteAddress: "127.0.0.1", user },
35 { terminal: headlessTerminal(160, 32), exitHooks: false },
36 );
37 app.start();
38 const key = (k: string, ctrl = false) => app.dispatch({ type: "key", key: k, ctrl, alt: false, shift: false });
39 const type = async (word: string) => {
40 for (const ch of word) key(ch);
41 await settleApp(app);
42 };
43 const enter = async () => {
44 key("enter");
45 await settleApp(app);
46 };
47 return {
48 text: () => app.screen.toText(),
49 key: async (k: string) => {
50 key(k);
51 await settleApp(app);
52 },
53 /** A palette command, and the `owner/name` its prompt asks for when it asks. */
54 open: async (command: string, spec?: string) => {
55 key("k", true);
56 await type(command);
57 await enter();
58 if (spec !== undefined) {
59 await type(spec);
60 await enter();
61 }
62 },
63 stop: () => app.stop(),
64 };
65 }
66
67 /** One agent run on a new issue, ended `ago` days back; with `merged`, its pull merged then too. */
68 async function spend(repo: Repo, usd: number, ago: number, merged?: string): Promise<void> {
69 const alice = getUserByName(forge.db, "alice")!;
70 const bot = getUserByName(forge.db, "bot")!;
71 const issue = openIssue(forge, alice, repo, { title: `work ${usd}` });
72 if (!issue.ok) throw new Error(String(issue.error));
73 const at = new Date(Date.now() - ago * DAY - 60_000).toISOString();
74 const made = createRun(forge.db, {
75 repoId: repo.id, kind: "agent", issueId: issue.value.id, branch: merged ?? "", actorId: bot.id, reason: "assigned",
76 });
77 forge.db
78 .query("UPDATE run SET state = 'ok', cost_usd = ?2, summary = 'ok', created_at = ?3, started_at = ?3, finished_at = ?3 WHERE id = ?1")
79 .run(made.id, usd, at);
80 if (merged === undefined) return;
81 await commitToBranch(forge, alice, repo, {
82 branch: merged, base: "main", message: merged, files: [{ path: `${merged.replace(/\W/g, "-")}.md`, content: "work\n" }],
83 });
84 const pull = await openPull(forge, bot, repo, {
85 title: `Work on #${issue.value.number}`, body: `Closes #${issue.value.number}`, baseBranch: "main", headBranch: merged,
86 });
87 if (!pull.ok) throw new Error(String(pull.error));
88 forge.db.query("UPDATE pull SET merged_at = ?2 WHERE issue_id = ?1").run(pull.value.id, at);
89 }
90
91 beforeAll(async () => {
92 dataDir = await mkdtemp(join(tmpdir(), "forge-tui-usage-returns-"));
93 forge = createApp({ dataDir, port: 0 });
94 for (const [username, adminIfFirst] of [["alice", true], ["bob", false]] as const) {
95 const made = await createUser(forge.db, {
96 origin: "seed",
97 trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst,
98 });
99 if (!made.ok) throw new Error(made.error);
100 }
101 const alice = getUserByName(forge.db, "alice")!;
102 await createAgent(forge, alice, { username: "bot" });
103 const repos: Repo[] = [];
104 for (const name of ["mill", "kiln", "empty"]) {
105 const made = await createRepo(forge, alice, { name, visibility: "public" });
106 if (!made.ok) throw new Error(String(made.error));
107 await commitToBranch(forge, alice, made.value, { message: "seed", files: [{ path: "README.md", content: `# ${name}\n` }] });
108 addToCrew(forge, alice, made.value, "bot");
109 repos.push(made.value);
110 }
111 const [mill, kiln] = repos;
112 await spend(mill!, 1, 0, "bot/1-landed"); // this week: $1, landed
113 await spend(mill!, 0.5, 1); // this week: $0.50, nothing merged
114 await spend(mill!, 3, 20, "bot/3-old"); // this month: $3, landed
115 await spend(kiln!, 5, 2); // this week, another app: $5, nothing merged
116 }, 30_000);
117 afterAll(async () => {
118 forge.close();
119 await rm(dataDir, { recursive: true, force: true });
120 });
121
122 test("the owner's usage says what 7 days bought, and ] widens it to 30", async () => {
123 const h = boot("alice");
124 await h.open("Usage");
125 expect(h.text()).toContain("Returns, 7 days: $6.50 over 3 runs · 1 change landed · $1.00 a landed change (median)");
126 await h.key("]");
127 expect(h.text()).toContain("Returns, 30 days: $9.50 over 4 runs · 2 changes landed · $3.00 a landed change (median)");
128 await h.key("[");
129 await h.key("[");
130 expect(h.text()).toContain("Returns, 1 day: $1.00 over 1 run · 1 change landed · $1.00 a landed change (median)");
131 h.stop();
132 });
133
134 test("a repository's usage narrows to that app, and no runs reads nothing spent", async () => {
135 const h = boot("alice");
136 await h.open("Usage of a repository", "alice/kiln");
137 expect(h.text()).toContain("Returns, 7 days: $5.00 over 1 run · 0 changes landed · no landed change to cost");
138 expect(h.text()).not.toContain("routes had no request");
139 h.stop();
140 const e = boot("alice");
141 await e.open("Usage of a repository", "alice/empty");
142 expect(e.text()).toContain("Returns, 7 days: nothing spent · 0 changes landed");
143 expect(e.text()).not.toContain("NaN");
144 e.stop();
145 });
146
147 test("a reader the returns tool refuses sees no returns block", async () => {
148 const h = boot("bob");
149 await h.open("Usage of a repository", "alice/mill");
150 expect(h.text()).toContain("Usage · alice/mill");
151 expect(h.text()).not.toContain("Returns,");
152 expect(h.text()).not.toContain("$");
153 const before = h.text();
154 await h.key("]");
155 await h.key("[");
156 expect(h.text()).toBe(before);
157 h.stop();
158 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs