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

The site-admin palette entries live beside their screens, and AGENTS.md says a new screen adds its entry there #7743

Merged opened this

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

Merged

mason/forge:mason/7732-the-site-admin-palette-entries-live-besi → main 3 commits fork

  • Merged. mason/7732-the-site-admin-palette-entries-live-besi was merged into main as 02b4dc9.

Review

Sign in to review this change.
23 files changed +369 −224
AGENTS.md +2
@@ -7,3 +7,5 @@ README.md is capped: a new feature's how-to goes in `docs/features/<name>.md`, n
77 When you write an issue, end it with a `Files:` line naming the files the work touches (`Files: src/a.ts, docs/b.md`). The hot-file hold reads only that line when it is there; without it, every path the issue mentions counts.
88
99 A new `forge` command is a new file in `src/cli/commands/` (exporting `command`) plus one line in its `index.ts`.
10
11 A new terminal screen offered only to site admins exports its palette `entry` from its own module and adds one line to `ENTRIES` in `src/tui/screens/entries.ts`; `after` names the commands it follows. Entries offered to everyone, and entries gated by a helper (`mayCapacity`, `mayManageFlags`, `canSeeMemory`, `telemetryReader` and the like) rather than by `isAdmin`, stay in `forgeCommands` in `src/tui/forge.ts`.
src/tui/forge.ts +7 −220
@@ -12,7 +12,6 @@ import type { App as Forge } from "../app.ts";
1212 import { search } from "../actions/search.ts";
1313 import { cleanQuery } from "../core/search.ts";
1414 import { guestCodeSearch } from "../http/strangers.ts";
15 import { sentinelSummary } from "../crew/sentinel.ts";
1615 import { canRead, getRepo, type Repo } from "../core/repos.ts";
1716 import { sourceFor } from "../view/source.ts";
1817 import { App, type Ctx, type Screen } from "./app/app.ts";
@@ -26,18 +25,15 @@ import {
2625 ORIGIN_MARK,
2726 PULL_MARK,
2827 errorText,
29 pickerScreen,
3028 promptScreen,
29 blank,
3130 openOnWeb,
3231 runAsync,
3332 } from "./screens/common.ts";
3433 import { chainScreen } from "./screens/chain.ts";
3534 import { canSeeMemory } from "../boards/memory.ts";
36 import { boardsScreen } from "./screens/boards.ts";
3735 import { askedScreen } from "./screens/asked.ts";
3836 import { deciderScreen } from "./screens/decider.ts";
39 import { forgesScreen } from "./screens/forges.ts";
40 import { clientRows } from "../spawn/clients.ts";
4137 import { familyScreen } from "./screens/family.ts";
4238 import { familyLines, familyView } from "../spawn/family.ts";
4339 import { issueScreen } from "./screens/issue.ts";
@@ -45,39 +41,24 @@ import { pullScreen } from "./screens/pull.ts";
4541 import { blobScreen } from "./screens/repo.ts";
4642 import { dashboardScreen } from "./screens/dashboard.ts";
4743 import { mayManageFlags } from "../core/flags.ts";
48 import { accessScreen } from "./screens/access.ts";
4944 import { mayEditTrustedHosts } from "../origins/trusted-hosts.ts";
5045 import { trustedHostsPrompt } from "./screens/trustedhosts.ts";
5146 import { flagsScreen } from "./screens/flags.ts";
52 import { secretsScreen } from "./screens/secrets.ts";
53 import { scorecardScreen } from "./screens/scorecard.ts";
54 import { crewSplitsScreen } from "./screens/crewsplits.ts";
55 import { crewHandoverScreen } from "./screens/crewhandover.ts";
5647 import { usageScreen } from "./screens/usage.ts";
5748 import { canScreen } from "./screens/can.ts";
58 import { peopleScreen } from "./screens/people.ts";
59 import { modelCeilingPrompt, modelGrantPrompt, modelScreen } from "./screens/model.ts";
49 import { modelScreen } from "./screens/model.ts";
6050 import { notifyScreen } from "./screens/notify.ts";
6151 import { sshKeysScreen } from "./screens/sshkeys.ts";
6252 import { proposalTokensPrompt } from "./screens/proposal_tokens.ts";
63 import { invitesScreen } from "./screens/invites.ts";
64 import { subscriptionsScreen } from "./screens/subscriptions.ts";
6553 import { signInsScreen } from "./screens/signins.ts";
66 import { limitsScreen } from "./screens/limits.ts";
67 import { quotasScreen } from "./screens/quotas.ts";
68 import { teamsScreen } from "./screens/teams.ts";
69 import { ENTRIES } from "./screens/entries.ts";
54 import { addEntries } from "./screens/entries.ts";
7055 import { capacityScreen, mayCapacity } from "./screens/capacity.ts";
71 import { resilienceScreen } from "./screens/resilience.ts";
7256 import { forgeCeiling } from "../flow/ceiling.ts";
7357 import { flowLines, flowScreen } from "./screens/flow.ts";
7458 import { readHeld } from "../flow/held.ts";
75 import { forgeLines, memoryScreen, systemLines, systemScreen } from "./screens/system.ts";
76 import { readHealth } from "../actions/health.ts";
77 import { grantable, READS_KEPT, recentReads } from "../core/telemetry.ts";
78 import { noteRead, setTelemetryGrant, telemetryReader } from "../actions/telemetry.ts";
59 import { memoryScreen } from "./screens/system.ts";
60 import { noteRead, telemetryReader } from "../actions/telemetry.ts";
7961 import { queuesFor, systemFor } from "../system/telemetry.ts";
80 import { live as liveMachine } from "../system/live.ts";
8162 import { apiScreen } from "./screens/api.ts";
8263 import { changelogScreen } from "./screens/changelog.ts";
8364 import { releasesScreen } from "./screens/releases.ts";
@@ -293,26 +274,6 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] {
293274 run: () => ctx.push(signInsScreen(session, ctx)),
294275 },
295276 );
296 // The forge's own faults, as the board's strip shows them: a site admin's.
297 if (session.user?.isAdmin === true)
298 commands.push({
299 id: "sentinel",
300 title: "Sentinel",
301 hint: "the forge's open faults",
302 run: () =>
303 ctx.toast(
304 sentinelSummary(session.db) ?? "The sentinel has not hunted yet",
305 "default",
306 ),
307 });
308 // The boards, as the page gates them: a site admin who is a person, never an agent (#1817).
309 if (session.user?.isAdmin === true && session.user.kind !== "agent")
310 commands.push({
311 id: "boards",
312 title: "Boards",
313 hint: "the forge's boards and their panels",
314 run: () => ctx.push(boardsScreen(session, ctx)),
315 });
316277 // The forge's RSS beside its JS heap, as /system gates it (#2151).
317278 if (canSeeMemory(session.user))
318279 commands.push({
@@ -340,52 +301,6 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] {
340301 )));
341302 },
342303 });
343 // The machine, as /system says it, and which agents may read it: the owner's, a person (#1789).
344 if (session.user?.isAdmin === true && session.user.kind !== "agent") {
345 commands.push({
346 id: "system",
347 title: "System",
348 hint: "the machine now: CPU, memory, disks, processes",
349 run: () => ctx.push(systemScreen(ctx, [
350 ...forgeLines(readHealth(session.app, session.user)),
351 "",
352 ...systemLines(liveMachine().read()),
353 ], session)),
354 });
355 commands.push({
356 id: "telemetry",
357 title: "Telemetry",
358 hint: "which agents may read flow and system; enter flips the grant",
359 run: () => {
360 const actor = session.user;
361 const agents = grantable(session.db);
362 if (agents.length === 0) return ctx.toast("No agent accounts yet");
363 const last = new Map<string, { n: number; what: string; at: string }>();
364 for (const r of recentReads(session.db, READS_KEPT).reverse()) {
365 last.set(r.username, { n: (last.get(r.username)?.n ?? 0) + 1, what: r.what, at: r.at });
366 }
367 const readsOf = (name: string): string => {
368 const l = last.get(name);
369 return l ? `${l.n} read${l.n === 1 ? "" : "s"}, last ${l.what} ${l.at}` : "no reads";
370 };
371 ctx.push(
372 pickerScreen(ctx, blank("Telemetry"), {
373 title: "Telemetry: telemetry:read",
374 items: agents.map((a) => ({ id: a.username, label: a.username, hint: `${a.granted ? "granted" : "not granted"} · ${readsOf(a.username)}` })),
375 onPick: ([name]) => {
376 const was = agents.find((a) => a.username === name);
377 if (!was) return;
378 const done = setTelemetryGrant(session.app, actor, was.username, !was.granted);
379 ctx.toast(
380 done.ok ? `${was.username}: telemetry:read ${done.value.on ? "granted" : "revoked"}` : "Could not change the grant",
381 done.ok ? "success" : "attn",
382 );
383 },
384 }),
385 );
386 },
387 });
388 }
389304 // The forge's capacity and guards, as MCP `capacity` gates them: a forge admin who is a person.
390305 if (mayCapacity(session))
391306 commands.push({
@@ -394,14 +309,6 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] {
394309 hint: "concurrency and guards, live; e changes a settable one",
395310 run: () => ctx.push(capacityScreen(session, ctx)),
396311 });
397 // Where snapshots go and each THREATS.md protection's standing, as /resilience says it: a site admin's; a person admin sets or forgets the destination and the mirror here.
398 if (session.user?.isAdmin === true)
399 commands.push({
400 id: "resilience",
401 title: "Resilience",
402 hint: "where snapshots go and the mirror; each protection green, red or untested",
403 run: () => ctx.push(resilienceScreen(session, ctx)),
404 });
405312 // The forge's API operations, as /api lists them; Enter runs a GET: for anyone.
406313 commands.push({
407314 id: "api",
@@ -440,14 +347,6 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] {
440347 hint: "hold new work on a file, release it, or list the holds",
441348 run: () => ctx.push(fileHoldsPrompt(session, ctx, blank("Held files"))),
442349 });
443 // What outside agents tried, as Settings → Access says it: a site admin's (#1768).
444 if (session.user?.isAdmin === true && session.user.kind !== "agent")
445 commands.push({
446 id: "access",
447 title: "Access",
448 hint: "what outside agents tried",
449 run: () => ctx.push(accessScreen(session, ctx)),
450 });
451350 // Settings → Flags, as the page has it: an admin who is not an agent (#1970).
452351 if (mayManageFlags(session.user))
453352 commands.push({
@@ -456,14 +355,6 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] {
456355 hint: "declared flags and their live values",
457356 run: () => ctx.push(flagsScreen(session, ctx)),
458357 });
459 // Settings → Secrets, as the page has it: a forge admin; names only, never a value.
460 if (session.user?.isAdmin === true)
461 commands.push({
462 id: "secrets",
463 title: "Secrets",
464 hint: "sealed secrets by name; set or remove one",
465 run: () => ctx.push(secretsScreen(session, ctx)),
466 });
467358 // Settings → Origins' trusted internal hosts: the same admin gate as the page.
468359 if (mayEditTrustedHosts(session.user))
469360 commands.push({
@@ -480,30 +371,6 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] {
480371 hint: "every route, unused ones first",
481372 run: () => ctx.push(usageScreen(session, ctx)),
482373 });
483 // Settings → Crew's scorecard by model and prompt version: an admin (#2682).
484 if (session.user?.isAdmin === true)
485 commands.push({
486 id: "crew-scorecard",
487 title: "Crew scorecard",
488 hint: "agent runs per model and per prompt version",
489 run: () => ctx.push(crewSplitsScreen(session, ctx)),
490 });
491 // Settings → Crew's hand-overs: issues parked twice, the last restore drill.
492 if (session.user?.isAdmin === true)
493 commands.push({
494 id: "crew-handover",
495 title: "Crew hand-overs",
496 hint: "issues parked twice, and the last restore drill",
497 run: () => ctx.push(crewHandoverScreen(session, ctx)),
498 });
499 // Settings → Crew's scorecard per agent, as the page has it: a forge admin (#2677).
500 if (session.user?.isAdmin === true)
501 commands.push({
502 id: "scorecard",
503 title: "Crew scorecard per agent",
504 hint: "each agent's pulls landed, review rounds, red checks, reverts, time to land",
505 run: () => ctx.push(scorecardScreen(session, ctx)),
506 });
507374 // Proposal tokens for a repository you write, as the API and MCP have them (#2148).
508375 if (session.user !== undefined)
509376 commands.push({
@@ -520,21 +387,6 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] {
520387 hint: "what your crew may spend, and add your own subscription",
521388 run: () => ctx.push(modelScreen(session, ctx)),
522389 });
523 // A capped monthly grant to an account: a forge admin who is a person (#1145).
524 if (session.user?.isAdmin === true && session.user.kind === "user")
525 commands.push({
526 id: "model-grant",
527 title: "Model grant",
528 hint: "give an account dollars a month, or take it back",
529 run: () => ctx.push(modelGrantPrompt(session, ctx, blank("Model grant"))),
530 });
531 if (session.user?.isAdmin === true && session.user.kind === "user")
532 commands.push({
533 id: "model-ceiling",
534 title: "Model ceiling",
535 hint: "set an account's dollars a day and a run",
536 run: () => ctx.push(modelCeilingPrompt(session, ctx, blank("Model ceiling"))),
537 });
538390 // The caller's own role and row of the roles table, as /settings and `whoami_can` have it (#2304).
539391 if (session.user !== undefined)
540392 commands.push({
@@ -543,68 +395,8 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] {
543395 hint: "your role and what it allows here",
544396 run: () => ctx.push(canScreen(session, ctx)),
545397 });
546 // Guests, members and admins, as /settings/people has them (#2301).
547 if (session.user?.isAdmin === true && session.user.kind === "user")
548 commands.push({
549 id: "people",
550 title: "People",
551 hint: "who is a guest, a member or an admin; promote or demote",
552 run: () => ctx.push(peopleScreen(session, ctx)),
553 });
554 // Account quotas, as /admin/limits has them: an admin who is not an agent (#2191).
555 if (session.user?.isAdmin === true && session.user.kind !== "agent")
556 commands.push({
557 id: "quotas",
558 title: "Account quotas",
559 hint: "what one account may own, and who changed it",
560 run: () => ctx.push(quotasScreen(session, ctx)),
561 });
562 // The forge's limits, as MCP `limits` has them: an admin who is not an agent (#7268).
563 if (session.user?.isAdmin === true && session.user.kind !== "agent")
564 commands.push({
565 id: "limits",
566 title: "Limits",
567 hint: "what the forge caps, with default and bounds; change one",
568 run: () => ctx.push(limitsScreen(session, ctx)),
569 });
570 // Crew teams, as /settings/teams has them: an admin who is not an agent (#2679).
571 if (session.user?.isAdmin === true && session.user.kind !== "agent")
572 commands.push({
573 id: "teams",
574 title: "Crew teams",
575 hint: "each team's charter, dial and members",
576 run: () => ctx.push(teamsScreen(session, ctx)),
577 });
578 // Invite links, as /admin/limits has them: an admin who is a person (#2337).
579 if (session.user?.isAdmin === true && session.user.kind === "user")
580 commands.push({
581 id: "invites",
582 title: "Invite links",
583 hint: "make a single-use link, see which are open, revoke one",
584 run: () => ctx.push(invitesScreen(session, ctx)),
585 });
586 // The crew's Claude subscriptions, as MCP `subscriptions` has them: a forge admin (#7244).
587 if (session.user?.isAdmin === true && session.user.kind === "user")
588 commands.push({
589 id: "subscriptions",
590 title: "Crew subscriptions",
591 hint: "each account's budget and rest; wake one, set its budget, add or remove one",
592 run: () => ctx.push(subscriptionsScreen(session, ctx)),
593 });
594 // Entries that live beside their screen: cloud, domains, owner.
595 for (const e of ENTRIES)
596 if (e.offered(session)) commands.push({ id: e.id, title: e.title, hint: e.hint, run: () => e.run(session, ctx) });
597 // The forges this one sent, as /forges lists them: an admin's only.
598 if (session.user?.isAdmin === true)
599 commands.push({
600 id: "forges",
601 title: "Forges you sent",
602 hint: "each forge sent, claimed or not, and its apps",
603 run: () =>
604 runAsync(ctx, async () => {
605 ctx.push(forgesScreen(ctx, await clientRows(session.app)));
606 }),
607 });
398 // Entries that live beside their screen, each at the place it names.
399 addEntries(commands, session, ctx);
608400 commands.push({
609401 id: "family",
610402 title: "Family",
@@ -819,11 +611,6 @@ function countsHint(
819611 .join(" ");
820612 }
821613
822 /** A screen that draws nothing — the parent a palette-launched modal has. */
823 function blank(title: string): Screen {
824 return { title, keys: [], render: () => undefined, key: () => false };
825 }
826
827614 /** A prompt with no parent to draw behind it — the palette closed already. */
828615 function askFor(
829616 ctx: Ctx,
src/tui/screens/access.ts +11
@@ -16,6 +16,7 @@ import { style } from "../term/style.ts";
1616 import { truncate } from "../term/width.ts";
1717 import type { Session } from "../session.ts";
1818 import { ListState, drawList, promptScreen, type ListRow } from "./common.ts";
19 import type { PaletteEntry } from "./entries.ts";
1920
2021 type Filter = "since" | "ua" | "path";
2122
@@ -111,3 +112,13 @@ export function accessScreen(f: Session, ctx: Ctx): Screen {
111112 };
112113 return screen;
113114 }
115
116 /** What outside agents tried, as Settings → Access says it: a site admin's (#1768). */
117 export const entry: PaletteEntry = {
118 id: "access",
119 title: "Access",
120 hint: "what outside agents tried",
121 after: ["file-holds"],
122 offered: (session) => session.user?.isAdmin === true && session.user.kind !== "agent",
123 run: (session, ctx) => ctx.push(accessScreen(session, ctx)),
124 };
src/tui/screens/boards.ts +11
@@ -23,6 +23,7 @@ import { emptyState } from "../ui/widgets.ts";
2323 import { style } from "../term/style.ts";
2424 import { truncate } from "../term/width.ts";
2525 import { ListState, Scroll, drawList, promptScreen, drawLines, hitList, hitScroll, metaOf, plural, runAsync, type ListRow } from "./common.ts";
26 import type { PaletteEntry } from "./entries.ts";
2627
2728 /** One line of plain text: a title or a query value may carry escapes the terminal must not obey. */
2829 export const plain = (text: string): string =>
@@ -247,3 +248,13 @@ export function boardsScreen(f: Session, ctx: Ctx): Screen {
247248 };
248249 return screen;
249250 }
251
252 /** The boards, as the page gates them: a site admin who is a person, never an agent (#1817). */
253 export const entry: PaletteEntry = {
254 id: "boards",
255 title: "Boards",
256 hint: "the forge's boards and their panels",
257 after: ["sentinel"],
258 offered: (session) => session.user?.isAdmin === true && session.user.kind !== "agent",
259 run: (session, ctx) => ctx.push(boardsScreen(session, ctx)),
260 };
src/tui/screens/common.ts +5
@@ -1072,3 +1072,8 @@ export function ago(iso: string): string {
10721072 }
10731073
10741074 export { plural } from "../../kernel/plural.ts";
1075
1076 /** A screen that draws nothing — the parent a palette-launched modal has. */
1077 export function blank(title: string): Screen {
1078 return { title, keys: [], render: () => undefined, key: () => false };
1079 }
src/tui/screens/crewhandover.ts +11
@@ -15,6 +15,7 @@ import { fill, len, split } from "../ui/layout.ts";
1515 import { issueScreen } from "./issue.ts";
1616 import { local } from "./where.ts";
1717 import { ListState, drawList, type ListRow } from "./common.ts";
18 import type { PaletteEntry } from "./entries.ts";
1819
1920 export function crewHandoverScreen(f: Session, ctx: Ctx): Screen {
2021 const state = new ListState();
@@ -80,3 +81,13 @@ export function crewHandoverScreen(f: Session, ctx: Ctx): Screen {
8081 },
8182 };
8283 }
84
85 /** Settings → Crew's hand-overs: issues parked twice, the last restore drill. */
86 export const entry: PaletteEntry = {
87 id: "crew-handover",
88 title: "Crew hand-overs",
89 hint: "issues parked twice, and the last restore drill",
90 after: ["crew-scorecard"],
91 offered: (session) => session.user?.isAdmin === true,
92 run: (session, ctx) => ctx.push(crewHandoverScreen(session, ctx)),
93 };
src/tui/screens/crewsplits.ts +11
@@ -13,6 +13,7 @@ import { truncate } from "../term/width.ts";
1313 import type { Session } from "../session.ts";
1414 import { fill, len, split } from "../ui/layout.ts";
1515 import { ListState, drawList, type ListRow } from "./common.ts";
16 import type { PaletteEntry } from "./entries.ts";
1617
1718 export function splitRows(
1819 label: string,
@@ -62,3 +63,13 @@ export function crewSplitsScreen(f: Session, ctx: Ctx): Screen {
6263 key: (e) => state.key(e, rows.length),
6364 };
6465 }
66
67 /** Settings → Crew's scorecard by model and prompt version: an admin (#2682). */
68 export const entry: PaletteEntry = {
69 id: "crew-scorecard",
70 title: "Crew scorecard",
71 hint: "agent runs per model and per prompt version",
72 after: ["usage", "trusted-hosts", "secrets"],
73 offered: (session) => session.user?.isAdmin === true,
74 run: (session, ctx) => ctx.push(crewSplitsScreen(session, ctx)),
75 };
src/tui/screens/entries.ts +67 −1
@@ -6,18 +6,84 @@
66 */
77
88 import type { Ctx } from "../app/app.ts";
9 import type { Command } from "../app/palette.ts";
910 import type { Session } from "../session.ts";
11 import { entry as access } from "./access.ts";
12 import { entry as boards } from "./boards.ts";
1013 import { entry as cloud } from "./cloud.ts";
14 import { entry as crewHandover } from "./crewhandover.ts";
15 import { entry as crewSplits } from "./crewsplits.ts";
1116 import { entry as domains } from "./domains.ts";
17 import { entry as forges } from "./forges.ts";
18 import { entry as invites } from "./invites.ts";
19 import { entry as limits } from "./limits.ts";
20 import { modelCeilingEntry, modelGrantEntry } from "./model.ts";
1221 import { entry as owner } from "./owner.ts";
22 import { entry as people } from "./people.ts";
23 import { entry as quotas } from "./quotas.ts";
24 import { entry as resilience } from "./resilience.ts";
25 import { entry as scorecard } from "./scorecard.ts";
26 import { entry as secrets } from "./secrets.ts";
27 import { entry as sentinel } from "./sentinel.ts";
28 import { entry as subscriptions } from "./subscriptions.ts";
29 import { entry as system } from "./system.ts";
30 import { entry as teams } from "./teams.ts";
31 import { entry as telemetry } from "./telemetry.ts";
1332
1433 export interface PaletteEntry {
1534 readonly id: string;
1635 readonly title: string;
1736 readonly hint: string;
37 /**
38 * Ids of the commands it follows, nearest first; the first one the session
39 * has wins. Without it, or with none present, the entry goes where
40 * `addEntries` is called.
41 */
42 readonly after?: readonly string[];
1843 /** Whether this session is offered the entry. */
1944 offered(session: Session): boolean;
2045 run(session: Session, ctx: Ctx): void;
2146 }
2247
23 export const ENTRIES: readonly PaletteEntry[] = [cloud, domains, owner];
48 /** In the order the palette offers them; an entry follows the ones it names in `after`. */
49 export const ENTRIES: readonly PaletteEntry[] = [
50 sentinel,
51 boards,
52 system,
53 telemetry,
54 resilience,
55 access,
56 secrets,
57 crewSplits,
58 crewHandover,
59 scorecard,
60 modelGrantEntry,
61 modelCeilingEntry,
62 people,
63 quotas,
64 limits,
65 teams,
66 invites,
67 subscriptions,
68 cloud,
69 domains,
70 owner,
71 forges,
72 ];
73
74 /** Put each entry the session is offered into `commands`, after the command it names or at the end. */
75 export function addEntries(commands: Command[], session: Session, ctx: Ctx): void {
76 const placed = new Map<string, number>();
77 for (const e of ENTRIES) {
78 if (!e.offered(session)) continue;
79 const command: Command = { id: e.id, title: e.title, hint: e.hint, run: () => e.run(session, ctx) };
80 const anchor = e.after?.find((id) => commands.some((c) => c.id === id));
81 if (anchor === undefined) {
82 commands.push(command);
83 continue;
84 }
85 const before = placed.get(anchor) ?? 0;
86 commands.splice(commands.findIndex((c) => c.id === anchor) + 1 + before, 0, command);
87 placed.set(anchor, before + 1);
88 }
89 }
src/tui/screens/forges.ts +14 −1
@@ -10,7 +10,9 @@ import { ATTENTION_WORDS, CLAIM_WORDS, claimState, runningWords, spendWords, typ
1010 import { useLine } from "../../spawn/usage.ts";
1111 import { timeAgo } from "../../web/ui.ts";
1212 import { card } from "../ui/widgets.ts";
13 import { drawFacts } from "./common.ts";
13 import { drawFacts, runAsync } from "./common.ts";
14 import type { PaletteEntry } from "./entries.ts";
15 import { clientRows } from "../../spawn/clients.ts";
1416
1517 /** [name, "Not reporting · for Maria · Claimed · 3 apps running"] per row; an empty line when none. */
1618 export function forgeLines(rows: readonly ClientRow[]): [string, string][] {
@@ -51,3 +53,14 @@ export function forgesScreen(ctx: Ctx, rows: readonly ClientRow[]): Screen {
5153 },
5254 };
5355 }
56
57 /** The forges this one sent, as /forges lists them: an admin's only. */
58 export const entry: PaletteEntry = {
59 id: "forges",
60 title: "Forges you sent",
61 hint: "each forge sent, claimed or not, and its apps",
62 offered: (session) => session.user?.isAdmin === true,
63 run: (session, ctx) => runAsync(ctx, async () => {
64 ctx.push(forgesScreen(ctx, await clientRows(session.app)));
65 }),
66 };
src/tui/screens/invites.ts +11
@@ -21,6 +21,7 @@ import { truncate } from "../term/width.ts";
2121 import type { Session } from "../session.ts";
2222 import { fill, len, split } from "../ui/layout.ts";
2323 import { ListState, confirmScreen, drawList, pickerScreen, promptScreen, type ListRow } from "./common.ts";
24 import type { PaletteEntry } from "./entries.ts";
2425
2526 export function invitesScreen(f: Session, ctx: Ctx): Screen {
2627 const state = new ListState();
@@ -141,3 +142,13 @@ export function invitesScreen(f: Session, ctx: Ctx): Screen {
141142 };
142143 return screen;
143144 }
145
146 /** Invite links, as /admin/limits has them: an admin who is a person (#2337). */
147 export const entry: PaletteEntry = {
148 id: "invites",
149 title: "Invite links",
150 hint: "make a single-use link, see which are open, revoke one",
151 after: ["teams", "limits", "quotas", "people", "can"],
152 offered: (session) => session.user?.isAdmin === true && session.user.kind === "user",
153 run: (session, ctx) => ctx.push(invitesScreen(session, ctx)),
154 };
src/tui/screens/limits.ts +11
@@ -16,6 +16,7 @@ import { truncate } from "../term/width.ts";
1616 import type { Session } from "../session.ts";
1717 import { fill, len, split } from "../ui/layout.ts";
1818 import { ListState, confirmScreen, drawList, promptScreen, type ListRow } from "./common.ts";
19 import type { PaletteEntry } from "./entries.ts";
1920
2021 export function limitsScreen(f: Session, ctx: Ctx): Screen {
2122 const state = new ListState();
@@ -99,3 +100,13 @@ export function limitsScreen(f: Session, ctx: Ctx): Screen {
99100 };
100101 return screen;
101102 }
103
104 /** The forge's limits, as MCP `limits` has them: an admin who is not an agent (#7268). */
105 export const entry: PaletteEntry = {
106 id: "limits",
107 title: "Limits",
108 hint: "what the forge caps, with default and bounds; change one",
109 after: ["quotas", "people", "can"],
110 offered: (session) => session.user?.isAdmin === true && session.user.kind !== "agent",
111 run: (session, ctx) => ctx.push(limitsScreen(session, ctx)),
112 };
src/tui/screens/model.ts +22 −1
@@ -22,7 +22,8 @@ import { binding } from "../app/keys.ts";
2222 import { style } from "../term/style.ts";
2323 import { truncate } from "../term/width.ts";
2424 import type { Session } from "../session.ts";
25 import { later, promptScreen } from "./common.ts";
25 import { blank, later, promptScreen } from "./common.ts";
26 import type { PaletteEntry } from "./entries.ts";
2627
2728 /** The signed-in person's budget line, and `a` to add their own subscription. */
2829 export function modelScreen(f: Session, ctx: Ctx): Screen {
@@ -131,3 +132,23 @@ export function modelCeilingPrompt(f: Session, ctx: Ctx, parent: Screen): Screen
131132 ),
132133 });
133134 }
135
136 /** A capped monthly grant to an account: a forge admin who is a person (#1145). */
137 export const modelGrantEntry: PaletteEntry = {
138 id: "model-grant",
139 title: "Model grant",
140 hint: "give an account dollars a month, or take it back",
141 after: ["model"],
142 offered: (session) => session.user?.isAdmin === true && session.user.kind === "user",
143 run: (session, ctx) => ctx.push(modelGrantPrompt(session, ctx, blank("Model grant"))),
144 };
145
146 /** An account's dollars a day and a run: a forge admin who is a person (#1145). */
147 export const modelCeilingEntry: PaletteEntry = {
148 id: "model-ceiling",
149 title: "Model ceiling",
150 hint: "set an account's dollars a day and a run",
151 after: ["model-grant"],
152 offered: (session) => session.user?.isAdmin === true && session.user.kind === "user",
153 run: (session, ctx) => ctx.push(modelCeilingPrompt(session, ctx, blank("Model ceiling"))),
154 };
src/tui/screens/people.ts +11
@@ -22,6 +22,7 @@ import type { Session } from "../session.ts";
2222 import { style } from "../term/style.ts";
2323 import { truncate } from "../term/width.ts";
2424 import { confirmScreen, drawList, later, ListState, promptScreen, runAsync, type ListRow } from "./common.ts";
25 import type { PaletteEntry } from "./entries.ts";
2526
2627 /**
2728 * The accounts page's no-mail card in one line that fits 80 columns: without
@@ -164,3 +165,13 @@ export function peopleScreen(f: Session, ctx: Ctx): Screen {
164165 };
165166 return screen;
166167 }
168
169 /** Guests, members and admins, as /settings/people has them (#2301). */
170 export const entry: PaletteEntry = {
171 id: "people",
172 title: "People",
173 hint: "who is a guest, a member or an admin; promote or demote",
174 after: ["can"],
175 offered: (session) => session.user?.isAdmin === true && session.user.kind === "user",
176 run: (session, ctx) => ctx.push(peopleScreen(session, ctx)),
177 };
src/tui/screens/quotas.ts +11
@@ -17,6 +17,7 @@ import { truncate } from "../term/width.ts";
1717 import type { Session } from "../session.ts";
1818 import { fill, len, split } from "../ui/layout.ts";
1919 import { ListState, drawList, promptScreen, type ListRow } from "./common.ts";
20 import type { PaletteEntry } from "./entries.ts";
2021
2122 const limit = (v: number | string | null): string => (v === null || v === "" ? "no limit" : String(v));
2223
@@ -95,3 +96,13 @@ export function quotasScreen(f: Session, ctx: Ctx): Screen {
9596 };
9697 return screen;
9798 }
99
100 /** Account quotas, as /admin/limits has them: an admin who is not an agent (#2191). */
101 export const entry: PaletteEntry = {
102 id: "quotas",
103 title: "Account quotas",
104 hint: "what one account may own, and who changed it",
105 after: ["people", "can"],
106 offered: (session) => session.user?.isAdmin === true && session.user.kind !== "agent",
107 run: (session, ctx) => ctx.push(quotasScreen(session, ctx)),
108 };
src/tui/screens/resilience.ts +11
@@ -38,6 +38,7 @@ import type { Session } from "../session.ts";
3838 import { pad, wrap } from "../term/width.ts";
3939 import { dialog } from "../ui/widgets.ts";
4040 import { confirmScreen, later, promptScreen, runAsync } from "./common.ts";
41 import type { PaletteEntry } from "./entries.ts";
4142
4243 const WIDTH = 72;
4344 const INNER = WIDTH - 6;
@@ -283,3 +284,13 @@ export function resilienceScreen(f: Session, ctx: Ctx): Screen {
283284 };
284285 return screen;
285286 }
287
288 /** Where snapshots go and each THREATS.md protection's standing, as /resilience says it: a site admin's; a person admin sets or forgets the destination and the mirror here. */
289 export const entry: PaletteEntry = {
290 id: "resilience",
291 title: "Resilience",
292 hint: "where snapshots go and the mirror; each protection green, red or untested",
293 after: ["capacity", "flow", "system-memory", "sentinel"],
294 offered: (session) => session.user?.isAdmin === true,
295 run: (session, ctx) => ctx.push(resilienceScreen(session, ctx)),
296 };
src/tui/screens/scorecard.ts +11
@@ -29,6 +29,7 @@ import { truncate } from "../term/width.ts";
2929 import type { Session } from "../session.ts";
3030 import { fill, len, split } from "../ui/layout.ts";
3131 import { ListState, drawList, type ListRow } from "./common.ts";
32 import type { PaletteEntry } from "./entries.ts";
3233
3334 const landed = (s: Scorecard | undefined) =>
3435 s === undefined ? "—" : `${s.landed} of ${s.opened}`;
@@ -171,3 +172,13 @@ export function scorecardScreen(f: Session, ctx: Ctx): Screen {
171172 key: (e) => state.key(e, rows.length),
172173 };
173174 }
175
176 /** Settings → Crew's scorecard per agent, as the page has it: a forge admin (#2677). */
177 export const entry: PaletteEntry = {
178 id: "scorecard",
179 title: "Crew scorecard per agent",
180 hint: "each agent's pulls landed, review rounds, red checks, reverts, time to land",
181 after: ["crew-handover"],
182 offered: (session) => session.user?.isAdmin === true,
183 run: (session, ctx) => ctx.push(scorecardScreen(session, ctx)),
184 };
src/tui/screens/secrets.ts +11
@@ -17,6 +17,7 @@ import { truncate } from "../term/width.ts";
1717 import type { Session } from "../session.ts";
1818 import { fill, len, split } from "../ui/layout.ts";
1919 import { confirmScreen, drawList, later, ListState, promptScreen } from "./common.ts";
20 import type { PaletteEntry } from "./entries.ts";
2021
2122 /** The action's refusals in this screen's words. */
2223 const ERRORS: Record<string, string> = {
@@ -170,3 +171,13 @@ export function secretsScreen(f: Session, ctx: Ctx): Screen {
170171 };
171172 return screen;
172173 }
174
175 /** Settings → Secrets, as the page has it: a forge admin; names only, never a value. */
176 export const entry: PaletteEntry = {
177 id: "secrets",
178 title: "Secrets",
179 hint: "sealed secrets by name; set or remove one",
180 after: ["flags", "access", "file-holds"],
181 offered: (session) => session.user?.isAdmin === true,
182 run: (session, ctx) => ctx.push(secretsScreen(session, ctx)),
183 };
src/tui/screens/sentinel.ts added +14
@@ -0,0 +1,14 @@
1 /** The Sentinel palette entry. */
2
3 import type { PaletteEntry } from "./entries.ts";
4 import { sentinelSummary } from "../../crew/sentinel.ts";
5
6 /** The forge's own faults, as the board's strip shows them: a site admin's. */
7 export const entry: PaletteEntry = {
8 id: "sentinel",
9 title: "Sentinel",
10 hint: "the forge's open faults",
11 after: ["signins"],
12 offered: (session) => session.user?.isAdmin === true,
13 run: (session, ctx) => ctx.toast(sentinelSummary(session.db) ?? "The sentinel has not hunted yet", "default"),
14 };
src/tui/screens/subscriptions.ts +11
@@ -29,6 +29,7 @@ import { truncate } from "../term/width.ts";
2929 import type { Session } from "../session.ts";
3030 import { fill, len, split } from "../ui/layout.ts";
3131 import { ListState, confirmScreen, drawList, later, promptScreen, type ListRow } from "./common.ts";
32 import type { PaletteEntry } from "./entries.ts";
3233
3334 const money = (n: number): string => `$${n.toFixed(2)}`;
3435
@@ -171,3 +172,13 @@ export function subscriptionsScreen(f: Session, ctx: Ctx): Screen {
171172 };
172173 return screen;
173174 }
175
176 /** The crew's Claude subscriptions, as MCP `subscriptions` has them: a forge admin (#7244). */
177 export const entry: PaletteEntry = {
178 id: "subscriptions",
179 title: "Crew subscriptions",
180 hint: "each account's budget and rest; wake one, set its budget, add or remove one",
181 after: ["invites", "teams", "limits", "quotas", "people", "can"],
182 offered: (session) => session.user?.isAdmin === true && session.user.kind === "user",
183 run: (session, ctx) => ctx.push(subscriptionsScreen(session, ctx)),
184 };
src/tui/screens/system.ts +17 −1
@@ -19,7 +19,6 @@
1919
2020 import type { Ctx, Screen } from "../app/app.ts";
2121 import type { SystemView } from "../../system/view.ts";
22 import type { readHealth } from "../../actions/health.ts";
2322 import { rolloutLine, type ForgeHealth } from "../../kernel/guard.ts";
2423 import { canSeeMemory, memoryHours } from "../../boards/memory.ts";
2524 import { telemetryReader } from "../../actions/telemetry.ts";
@@ -33,6 +32,9 @@ import { style } from "../term/style.ts";
3332 import { pad, truncate, wrap } from "../term/width.ts";
3433 import { dialog } from "../ui/widgets.ts";
3534 import { Scroll, confirmScreen, drawLines, hitScroll, later, pickerScreen } from "./common.ts";
35 import type { PaletteEntry } from "./entries.ts";
36 import { readHealth } from "../../actions/health.ts";
37 import { live as liveMachine } from "../../system/live.ts";
3638
3739 const WIDTH = 72;
3840 const GROUPS = 6;
@@ -260,3 +262,17 @@ export function memoryScreen(session: Session, ctx: Ctx): Screen {
260262 },
261263 };
262264 }
265
266 /** The machine, as /system says it, and which agents may read it: the owner's, a person (#1789). */
267 export const entry: PaletteEntry = {
268 id: "system",
269 title: "System",
270 hint: "the machine now: CPU, memory, disks, processes",
271 after: ["flow", "system-memory", "boards"],
272 offered: (session) => session.user?.isAdmin === true && session.user.kind !== "agent",
273 run: (session, ctx) => ctx.push(systemScreen(ctx, [
274 ...forgeLines(readHealth(session.app, session.user)),
275 "",
276 ...systemLines(liveMachine().read()),
277 ], session)),
278 };
src/tui/screens/teams.ts +11
@@ -15,6 +15,7 @@ import { truncate } from "../term/width.ts";
1515 import type { Session } from "../session.ts";
1616 import { fill, len, split } from "../ui/layout.ts";
1717 import { ListState, drawList, promptScreen, type ListRow } from "./common.ts";
18 import type { PaletteEntry } from "./entries.ts";
1819
1920 export function teamsScreen(f: Session, ctx: Ctx): Screen {
2021 const state = new ListState();
@@ -95,3 +96,13 @@ export function teamsScreen(f: Session, ctx: Ctx): Screen {
9596 };
9697 return screen;
9798 }
99
100 /** Crew teams, as /settings/teams has them: an admin who is not an agent (#2679). */
101 export const entry: PaletteEntry = {
102 id: "teams",
103 title: "Crew teams",
104 hint: "each team's charter, dial and members",
105 after: ["limits", "quotas", "people", "can"],
106 offered: (session) => session.user?.isAdmin === true && session.user.kind !== "agent",
107 run: (session, ctx) => ctx.push(teamsScreen(session, ctx)),
108 };
src/tui/screens/telemetry.ts added +43
@@ -0,0 +1,43 @@
1 /** The Telemetry palette entry: which agents may read flow and system. */
2
3 import { setTelemetryGrant } from "../../actions/telemetry.ts";
4 import { grantable, READS_KEPT, recentReads } from "../../core/telemetry.ts";
5 import { blank, pickerScreen } from "./common.ts";
6 import type { PaletteEntry } from "./entries.ts";
7
8 /** Grant or revoke `telemetry:read` on an agent account: a site admin who is a person. */
9 export const entry: PaletteEntry = {
10 id: "telemetry",
11 title: "Telemetry",
12 hint: "which agents may read flow and system; enter flips the grant",
13 after: ["system"],
14 offered: (session) => session.user?.isAdmin === true && session.user.kind !== "agent",
15 run: (session, ctx) => {
16 const actor = session.user;
17 const agents = grantable(session.db);
18 if (agents.length === 0) return ctx.toast("No agent accounts yet");
19 const last = new Map<string, { n: number; what: string; at: string }>();
20 for (const r of recentReads(session.db, READS_KEPT).reverse()) {
21 last.set(r.username, { n: (last.get(r.username)?.n ?? 0) + 1, what: r.what, at: r.at });
22 }
23 const readsOf = (name: string): string => {
24 const l = last.get(name);
25 return l ? `${l.n} read${l.n === 1 ? "" : "s"}, last ${l.what} ${l.at}` : "no reads";
26 };
27 ctx.push(
28 pickerScreen(ctx, blank("Telemetry"), {
29 title: "Telemetry: telemetry:read",
30 items: agents.map((a) => ({ id: a.username, label: a.username, hint: `${a.granted ? "granted" : "not granted"} · ${readsOf(a.username)}` })),
31 onPick: ([name]) => {
32 const was = agents.find((a) => a.username === name);
33 if (!was) return;
34 const done = setTelemetryGrant(session.app, actor, was.username, !was.granted);
35 ctx.toast(
36 done.ok ? `${was.username}: telemetry:read ${done.value.on ? "granted" : "revoked"}` : "Could not change the grant",
37 done.ok ? "success" : "attn",
38 );
39 },
40 }),
41 );
42 },
43 };
test/palette-entries.test.ts added +35
@@ -0,0 +1,35 @@
1 import { expect, test } from "bun:test";
2 import type { Command } from "../src/tui/app/palette.ts";
3 import { addEntries, ENTRIES, type PaletteEntry } from "../src/tui/screens/entries.ts";
4
5 const base = (...ids: string[]): Command[] => ids.map((id) => ({ id, title: id, run: () => undefined }));
6 const stub = (id: string, after?: string[]): PaletteEntry => ({
7 id, title: id, hint: "", ...(after ? { after } : {}), offered: () => true, run: () => undefined,
8 });
9 const place = (commands: Command[], entries: PaletteEntry[]): string[] => {
10 const saved = [...ENTRIES];
11 (ENTRIES as PaletteEntry[]).splice(0, ENTRIES.length, ...entries);
12 try {
13 addEntries(commands, {} as never, {} as never);
14 } finally {
15 (ENTRIES as PaletteEntry[]).splice(0, ENTRIES.length, ...saved);
16 }
17 return commands.map((c) => c.id);
18 };
19
20 test("entries naming one anchor stack after it in list order", () => {
21 expect(place(base("a", "b", "c"), [stub("x", ["a"]), stub("y", ["a"])])).toEqual(["a", "x", "y", "b", "c"]);
22 });
23
24 test("the first id of `after` the session has wins", () => {
25 expect(place(base("a", "b", "c"), [stub("x", ["missing", "b", "a"])])).toEqual(["a", "b", "x", "c"]);
26 });
27
28 test("with no anchor present, or none named, an entry goes at the end", () => {
29 expect(place(base("a", "b"), [stub("x", ["missing"]), stub("y")])).toEqual(["a", "b", "x", "y"]);
30 });
31
32 test("an entry the session is not offered is left out", () => {
33 const hidden = { ...stub("x", ["a"]), offered: () => false };
34 expect(place(base("a", "b"), [hidden])).toEqual(["a", "b"]);
35 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs