Public: anyone can read this; the crew can change it.
The terminal's System screen grants an agent telemetry:read or takes it back with `g` and a confirm, as MCP `telemetry_grant` does #7361
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7348-the-terminal-s-system-screen-grants-an-a → main 3 commits fork 2 threads · 2 unresolved
4 files
changed +122
−8
README.md +2
| @@ -54,6 +54,8 @@ A hand-over card on `/asks` (an agent's pull the reviewer stopped on after three | ||
| 54 | 54 | |
| 55 | 55 | Home opens with "Since you were here · 2 days": the pulls that landed in public repositories since you last loaded `/` (1 hour to 7 days back, a day on a first visit); the terminal's home shows the same lines, and MCP `feed` answers them as `digest`. See `docs/features/since-you-were-here.md`. |
| 56 | 56 | |
| 57 | In the terminal, the System screen (palette: System) shows a forge admin who is a person one more line, `telemetry:read held by: …` (the agents holding the grant), and `g` there picks an agent and asks `grant <agent> telemetry:read? y/n` (or `take back …`; the default is n). `y` calls `setTelemetryGrant`, as MCP `telemetry_grant` does; anyone that tool refuses sees neither the line nor the key hint, and `g` answers them in the tool's words. | |
| 58 | ||
| 57 | 59 | An admin's home and the terminal's Flow dialog also say when the forge is clogged and how long the last clog took to notice and to clear: `docs/features/clog-watch.md`. |
| 58 | 60 | |
| 59 | 61 | A pull the merge queue holds as red alone (red on its own merge at its current head; cleared by a new head or a moved main) says so under its checks line: "red alone at <time>, waiting for a new head or a new main", on the pull page, its live checks line and the studio change step, and in the checks row of the pull's terminal screen (`redMark` in `src/checks/queue.ts`). Nothing is drawn when it is not held. |
src/tui/forge.ts +1 −1
| @@ -325,7 +325,7 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 325 | 325 | ...forgeLines(readHealth(session.app, session.user)), |
| 326 | 326 | "", |
| 327 | 327 | ...systemLines(liveMachine().read()), |
| 328 | ])), | |
| 328 | ], session)), | |
| 329 | 329 | }); |
| 330 | 330 | commands.push({ |
| 331 | 331 | id: "telemetry", |
src/tui/screens/system.ts +55 −6
| @@ -24,11 +24,13 @@ import { rolloutLine, type ForgeHealth } from "../../kernel/guard.ts"; | ||
| 24 | 24 | import { canSeeMemory, memoryHours } from "../../boards/memory.ts"; |
| 25 | 25 | import { binding } from "../app/keys.ts"; |
| 26 | 26 | import type { Session } from "../session.ts"; |
| 27 | import { setTelemetryGrant } from "../../actions/telemetry.ts"; | |
| 28 | import { grantable } from "../../core/telemetry.ts"; | |
| 27 | 29 | import { bytes, spark } from "../../system/format.ts"; |
| 28 | 30 | import { style } from "../term/style.ts"; |
| 29 | 31 | import { pad, truncate, wrap } from "../term/width.ts"; |
| 30 | 32 | import { dialog } from "../ui/widgets.ts"; |
| 31 | import { Scroll, drawLines, hitScroll } from "./common.ts"; | |
| 33 | import { Scroll, confirmScreen, drawLines, hitScroll, later, pickerScreen } from "./common.ts"; | |
| 32 | 34 | |
| 33 | 35 | const WIDTH = 72; |
| 34 | 36 | const GROUPS = 6; |
| @@ -101,14 +103,59 @@ export function systemLines(v: SystemView): string[] { | ||
| 101 | 103 | ]; |
| 102 | 104 | } |
| 103 | 105 | |
| 104 | export function systemScreen(ctx: Ctx, lines: readonly string[]): Screen { | |
| 105 | return { | |
| 106 | /** Who may grant telemetry:read: the MCP tool's gate (`mayBoards`), a forge admin who is a person. */ | |
| 107 | const mayGrant = (session: Session | undefined): boolean => session?.user?.isAdmin === true && session.user.kind !== "agent"; | |
| 108 | ||
| 109 | /** The agents holding the grant, in one line. */ | |
| 110 | export function grantLine(session: Session): string { | |
| 111 | const held = grantable(session.db).filter((a) => a.granted).map((a) => a.username); | |
| 112 | return held.length === 0 ? "telemetry:read held by: no agent" : `telemetry:read held by: ${held.join(", ")}`; | |
| 113 | } | |
| 114 | ||
| 115 | /** The words for a refused grant, as MCP `telemetry_grant` says them. */ | |
| 116 | const grantRefused = (error: "not_found" | "not_an_agent"): string => | |
| 117 | error === "not_an_agent" ? "Only an agent account can be granted telemetry:read." : "no such account"; | |
| 118 | ||
| 119 | export function systemScreen(ctx: Ctx, lines: readonly string[], session?: Session): Screen { | |
| 120 | const offered = session !== undefined && mayGrant(session); | |
| 121 | const askGrant = (): void => { | |
| 122 | if (!session) return; | |
| 123 | if (!mayGrant(session)) return ctx.toast("telemetry:read is a forge admin's to grant", "danger"); | |
| 124 | const agents = grantable(session.db); | |
| 125 | if (agents.length === 0) return ctx.toast("No agent accounts yet"); | |
| 126 | later(ctx, () => | |
| 127 | pickerScreen(ctx, screen, { | |
| 128 | title: "Grant telemetry:read to", | |
| 129 | items: agents.map((a) => ({ id: a.username, label: a.username, hint: a.granted ? "granted" : "not granted" })), | |
| 130 | onPick: ([name]) => { | |
| 131 | const target = agents.find((a) => a.username === name); | |
| 132 | if (!target) return; | |
| 133 | const on = !target.granted; | |
| 134 | later(ctx, () => | |
| 135 | confirmScreen(ctx, screen, { | |
| 136 | title: "Telemetry", | |
| 137 | body: [on ? `grant ${target.username} telemetry:read? y/n` : `take back ${target.username} telemetry:read? y/n`], | |
| 138 | actions: [{ label: "y" }, { label: "n" }], | |
| 139 | selected: 1, | |
| 140 | onChoose: (choice) => { | |
| 141 | if (choice !== 0) return; | |
| 142 | const set = setTelemetryGrant(session.app, session.user, target.username, on); | |
| 143 | if (!set.ok) return ctx.toast(grantRefused(set.error), "danger"); | |
| 144 | ctx.toast(`${target.username} ${on ? "may now read" : "no longer reads"} telemetry.`); | |
| 145 | }, | |
| 146 | }), | |
| 147 | ); | |
| 148 | }, | |
| 149 | }), | |
| 150 | ); | |
| 151 | }; | |
| 152 | const screen: Screen = { | |
| 106 | 153 | title: "System", |
| 107 | keys: [binding(["enter", "esc", "q"], "close")], | |
| 154 | keys: [...(offered ? [binding(["g"], "grant or take back telemetry")] : []), binding(["enter", "esc", "q"], "close")], | |
| 108 | 155 | render(area, s) { |
| 109 | 156 | const buttons = dialog(s, area, ctx.theme.c, { |
| 110 | 157 | title: "System", |
| 111 | body: lines, | |
| 158 | body: offered ? [...lines, "", grantLine(session), "g: grant or take back telemetry:read"] : lines, | |
| 112 | 159 | actions: [{ label: "Close" }], |
| 113 | 160 | selected: 0, |
| 114 | 161 | width: WIDTH, |
| @@ -116,10 +163,12 @@ export function systemScreen(ctx: Ctx, lines: readonly string[]): Screen { | ||
| 116 | 163 | buttons.forEach((rect) => ctx.hit(rect, () => ctx.pop())); |
| 117 | 164 | }, |
| 118 | 165 | key(e) { |
| 119 | if (e.key === "enter" || e.key === "esc" || e.key === "q") ctx.pop(); | |
| 166 | if (e.key === "g" && session) askGrant(); | |
| 167 | else if (e.key === "enter" || e.key === "esc" || e.key === "q") ctx.pop(); | |
| 120 | 168 | return true; |
| 121 | 169 | }, |
| 122 | 170 | }; |
| 171 | return screen; | |
| 123 | 172 | } |
| 124 | 173 | |
| 125 | 174 | const mib = (n: number) => `${(n / 1_048_576).toFixed(1)}`; |
test/tui-system.test.ts +64 −1
| @@ -13,7 +13,9 @@ import { createApp, type App } from "../src/app.ts"; | ||
| 13 | 13 | import { recordMemory } from "../src/boards/memory.ts"; |
| 14 | 14 | import { createUser, type User } from "../src/core/users.ts"; |
| 15 | 15 | import { createForgeApp, forgeCommands, Session } from "../src/tui/forge.ts"; |
| 16 | import { forgeLines, memoryLines, memoryScreen } from "../src/tui/screens/system.ts"; | |
| 16 | import { setTelemetryGrant } from "../src/actions/telemetry.ts"; | |
| 17 | import { hasGrant } from "../src/core/telemetry.ts"; | |
| 18 | import { forgeLines, memoryLines, memoryScreen, systemScreen } from "../src/tui/screens/system.ts"; | |
| 17 | 19 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; |
| 18 | 20 | import { quietLogs } from "./helpers/globals.ts"; |
| 19 | 21 | import { settleApp } from "./helpers/tui.ts"; |
| @@ -101,6 +103,67 @@ describe("tui system memory", () => { | ||
| 101 | 103 | }); |
| 102 | 104 | }); |
| 103 | 105 | |
| 106 | describe("tui system telemetry grant", () => { | |
| 107 | const boot = (user: User) => { | |
| 108 | const { app: tui, session } = createForgeApp(app, identity(user), { terminal: headlessTerminal(120, 40), exitHooks: false }); | |
| 109 | tui.start(); | |
| 110 | tui.ctx.push(systemScreen(tui.ctx, ["CPU"], session)); | |
| 111 | const press = async (...keys: string[]) => { | |
| 112 | for (const key of keys) tui.dispatch({ type: "key", key, ctrl: false, alt: false, shift: false }); | |
| 113 | await settleApp(tui); | |
| 114 | }; | |
| 115 | return { tui, press }; | |
| 116 | }; | |
| 117 | ||
| 118 | test("an admin grants and takes back after a confirm", async () => { | |
| 119 | const h = boot(carol); | |
| 120 | await settleApp(h.tui); | |
| 121 | expect(h.tui.screen.toText()).toContain("telemetry:read held by: no agent"); | |
| 122 | await h.press("g"); | |
| 123 | await h.press("enter"); | |
| 124 | expect(h.tui.screen.toText()).toContain("grant sysbot telemetry:read? y/n"); | |
| 125 | await h.press("left", "enter"); | |
| 126 | expect(hasGrant(app.db, bot.id)).toBe(true); | |
| 127 | expect(h.tui.screen.toText()).toContain("telemetry:read held by: sysbot"); | |
| 128 | await h.press("g"); | |
| 129 | await h.press("enter"); | |
| 130 | expect(h.tui.screen.toText()).toContain("take back sysbot telemetry:read? y/n"); | |
| 131 | await h.press("enter"); | |
| 132 | expect(hasGrant(app.db, bot.id)).toBe(true); | |
| 133 | await h.press("g"); | |
| 134 | await h.press("enter"); | |
| 135 | await h.press("left", "enter"); | |
| 136 | expect(hasGrant(app.db, bot.id)).toBe(false); | |
| 137 | h.tui.stop?.(); | |
| 138 | }); | |
| 139 | ||
| 140 | test("anyone the tool refuses is not offered g and is refused in its words", async () => { | |
| 141 | const h = boot(bob); | |
| 142 | await settleApp(h.tui); | |
| 143 | expect(h.tui.screen.toText()).not.toContain("telemetry:read"); | |
| 144 | await h.press("g"); | |
| 145 | expect(h.tui.screen.toText()).toContain("telemetry:read is a forge admin's to grant"); | |
| 146 | expect(h.tui.screen.toText()).not.toContain("Grant telemetry:read to"); | |
| 147 | expect(h.tui.screen.toText()).toContain("CPU"); | |
| 148 | expect(setTelemetryGrant(app, bob, "sysbot", true)).toEqual({ ok: false, error: "not_found" }); | |
| 149 | expect(hasGrant(app.db, bot.id)).toBe(false); | |
| 150 | h.tui.stop?.(); | |
| 151 | }); | |
| 152 | ||
| 153 | test("the System command passes the session, so the admin has the line and g", async () => { | |
| 154 | const { app: tui, session } = createForgeApp(app, identity(carol), { terminal: headlessTerminal(120, 40), exitHooks: false }); | |
| 155 | tui.start(); | |
| 156 | const system = forgeCommands(session)(tui.ctx).find((c) => c.id === "system"); | |
| 157 | system!.run(); | |
| 158 | await settleApp(tui); | |
| 159 | expect(tui.screen.toText()).toContain("telemetry:read held by:"); | |
| 160 | tui.dispatch({ type: "key", key: "g", ctrl: false, alt: false, shift: false }); | |
| 161 | await settleApp(tui); | |
| 162 | expect(tui.screen.toText()).toContain("Grant telemetry:read to"); | |
| 163 | tui.stop?.(); | |
| 164 | }); | |
| 165 | }); | |
| 166 | ||
| 104 | 167 | describe("tui system: the forge's own state (#7296)", () => { |
| 105 | 168 | const SHA = "0123456789abcdef0123456789abcdef01234567"; |
| 106 | 169 | const saved = ["FORGE_GUARD_DIR", "FORGE_VERSION", "FORGE_COMMIT"].map((k) => [k, process.env[k]] as const); |
new above
offline
Also assert that no picker or dialog opened after
g(the screen text should still be the System screen), not only the toast.