Public: anyone can read this; the crew can change it.
The terminal lists the crew's agents with model, brief and budget, and an admin changes one after a plan, as MCP `agent` does #7858
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7289-the-terminal-lists-the-crew-s-agents-wit → main 3 commits fork 4 threads · 4 unresolved
7 files
changed +358
−3
docs/OPERATING.md +1 −1
Diff not shown — a line 4,879 characters wide (limit 2,000). +1 −1
View filesrc/actions/crew.ts +2 −1
| @@ -63,7 +63,8 @@ function requireForgeAdmin( | ||
| 63 | 63 | return user.value.isAdmin ? user : err("forbidden"); |
| 64 | 64 | } |
| 65 | 65 | |
| 66 | function checkPatch( | |
| 66 | /** The bounds an agent change must meet; the screens check them before they ask. */ | |
| 67 | export function checkPatch( | |
| 67 | 68 | app: App, |
| 68 | 69 | patch: AgentPatch, |
| 69 | 70 | ): Result<AgentPatch, "invalid_model" | "brief_too_long" | "invalid_budget"> { |
src/tui/screens/agents.ts added +148
| @@ -0,0 +1,148 @@ | ||
| 1 | /** | |
| 2 | * Crew agents in the terminal, a forge admin's: each agent's model, budget and | |
| 3 | * the first line of its brief, as MCP `agent` and /api/v1/agents have them. | |
| 4 | * `m`, `b` and `B` change model, budget and brief, `n` creates one and `d` | |
| 5 | * removes one. Every change shows its plan and applies only after a confirm, | |
| 6 | * through `updateAgent`, `createAgent` and `deleteAgent`, so the same bounds | |
| 7 | * apply and a refusal is said in MCP's words (the error code). | |
| 8 | */ | |
| 9 | ||
| 10 | import { checkPatch, createAgent, deleteAgent, listAllAgents, updateAgent } from "../../actions/crew.ts"; | |
| 11 | import type { AgentPatch } from "../../core/agents.ts"; | |
| 12 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 13 | import { binding } from "../app/keys.ts"; | |
| 14 | import { style } from "../term/style.ts"; | |
| 15 | import { truncate } from "../term/width.ts"; | |
| 16 | import type { Session } from "../session.ts"; | |
| 17 | import { fill, len, split } from "../ui/layout.ts"; | |
| 18 | import { ListState, confirmScreen, drawList, later, promptScreen, type ListRow } from "./common.ts"; | |
| 19 | import type { PaletteEntry } from "./entries.ts"; | |
| 20 | ||
| 21 | const firstLine = (brief: string) => brief.split("\n").find((l) => l.trim() !== "")?.trim() ?? "no brief"; | |
| 22 | ||
| 23 | export function agentsScreen(f: Session, ctx: Ctx): Screen { | |
| 24 | const state = new ListState(); | |
| 25 | let note = ""; | |
| 26 | const allowed = () => f.user?.isAdmin === true && f.user.kind !== "agent"; | |
| 27 | const read = () => (allowed() ? listAllAgents(f.app, f.user) : undefined); | |
| 28 | const list = () => { | |
| 29 | const r = read(); | |
| 30 | return r?.ok ? r.value : []; | |
| 31 | }; | |
| 32 | ||
| 33 | const rows = (): ListRow[] => | |
| 34 | list().map((a) => ({ | |
| 35 | title: a.user.username, | |
| 36 | secondary: `${a.model === "" ? "default model" : a.model} · ${a.budgetUsd === 0 ? "default budget" : `$${a.budgetUsd}`}`, | |
| 37 | meta: firstLine(a.brief), | |
| 38 | })); | |
| 39 | ||
| 40 | const screen: Screen = { | |
| 41 | title: "Crew agents", | |
| 42 | keys: [ | |
| 43 | binding(["j", "k"], "move cursor"), | |
| 44 | binding(["m"], "model"), | |
| 45 | binding(["b"], "budget"), | |
| 46 | binding(["B"], "brief"), | |
| 47 | binding(["n"], "new"), | |
| 48 | binding(["d"], "remove"), | |
| 49 | ], | |
| 50 | render(area, s) { | |
| 51 | const c = ctx.theme.c; | |
| 52 | const [head, body] = split(area, "v", [len(1), fill(1)]); | |
| 53 | const text = note !== "" ? note : allowed() ? "The crew's settings; each change asks first." : "Agents are set by a forge admin."; | |
| 54 | s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w); | |
| 55 | drawList(s, body!, c, state, rows()); | |
| 56 | }, | |
| 57 | key(e) { | |
| 58 | const agents = list(); | |
| 59 | if (state.key(e, agents.length)) return true; | |
| 60 | if (e.ctrl || e.alt || !allowed()) return false; | |
| 61 | const ask = (title: string, label: string, value: string, onSubmit: (v: string) => string | undefined | Promise<void>) => | |
| 62 | ctx.push(promptScreen(ctx, screen, { title, label, value, onSubmit })); | |
| 63 | if (e.key === "n") { | |
| 64 | ask("New agent", "username", "", (name) => { | |
| 65 | const username = name.trim(); | |
| 66 | if (username === "") return "a username is needed"; | |
| 67 | return later(ctx, () => | |
| 68 | promptScreen(ctx, screen, { | |
| 69 | title: username, | |
| 70 | label: "model (empty: the runner's default)", | |
| 71 | onSubmit: async (model) => { | |
| 72 | const made = await createAgent(f.app, f.user, { username, ...(model.trim() === "" ? {} : { model: model.trim() }) }); | |
| 73 | note = made.ok ? `${username} created.` : `refused: ${made.error}`; | |
| 74 | ctx.invalidate(); | |
| 75 | }, | |
| 76 | }), | |
| 77 | ); | |
| 78 | }); | |
| 79 | return true; | |
| 80 | } | |
| 81 | const agent = agents[state.index]; | |
| 82 | if (agent === undefined) return false; | |
| 83 | const name = agent.user.username; | |
| 84 | if (e.key === "m") | |
| 85 | ask(name, "model", agent.model, (v) => { | |
| 86 | const model = v.trim(); | |
| 87 | const bad = checkPatch(f.app, { model }); | |
| 88 | if (!bad.ok) return `refused: ${bad.error}`; | |
| 89 | return later(ctx, () => confirm(name, { model }, `${name}: model ${agent.model || "default"} → ${model || "default"}. y/n`)); | |
| 90 | }); | |
| 91 | else if (e.key === "b") | |
| 92 | ask(name, "budget in USD per run (0: the forge's default)", String(agent.budgetUsd), (v) => { | |
| 93 | const budgetUsd = v.trim() === "" ? Number.NaN : Number(v); | |
| 94 | const bad = checkPatch(f.app, { budgetUsd }); | |
| 95 | if (!bad.ok) return `refused: ${bad.error}`; | |
| 96 | return later(ctx, () => confirm(name, { budgetUsd }, `${name}: budget $${agent.budgetUsd} → $${budgetUsd}. y/n`)); | |
| 97 | }); | |
| 98 | else if (e.key === "B") | |
| 99 | ask(name, "brief", agent.brief.replace(/\n/g, " "), (v) => { | |
| 100 | const bad = checkPatch(f.app, { brief: v }); | |
| 101 | if (!bad.ok) return `refused: ${bad.error}`; | |
| 102 | return later(ctx, () => confirm(name, { brief: v }, `${name}: brief → "${truncate(v, 60)}". y/n`)); | |
| 103 | }); | |
| 104 | else if (e.key === "d") | |
| 105 | ctx.push( | |
| 106 | confirmScreen(ctx, screen, { | |
| 107 | title: "Remove agent", | |
| 108 | body: [`Remove ${name}? This cannot be undone. y/n`], | |
| 109 | actions: [{ label: "Remove", variant: "danger" }, { label: "Cancel", variant: "ghost" }], | |
| 110 | yesNo: true, | |
| 111 | onChoose(i) { | |
| 112 | if (i !== 0) return; | |
| 113 | const gone = deleteAgent(f.app, f.user, name); | |
| 114 | note = gone.ok ? `${name} removed.` : `refused: ${gone.error}`; | |
| 115 | state.index = Math.max(0, Math.min(state.index, list().length - 1)); | |
| 116 | ctx.invalidate(); | |
| 117 | }, | |
| 118 | }), | |
| 119 | ); | |
| 120 | else return false; | |
| 121 | return true; | |
| 122 | }, | |
| 123 | }; | |
| 124 | const confirm = (username: string, patch: AgentPatch, plan: string): Screen => | |
| 125 | confirmScreen(ctx, screen, { | |
| 126 | title: "Change agent", | |
| 127 | body: [plan], | |
| 128 | actions: [{ label: "Apply" }, { label: "Cancel", variant: "ghost" }], | |
| 129 | yesNo: true, | |
| 130 | onChoose(i) { | |
| 131 | if (i !== 0) return; | |
| 132 | const done = updateAgent(f.app, f.user, username, patch); | |
| 133 | note = done.ok ? `${username} saved.` : `refused: ${done.error}`; | |
| 134 | ctx.invalidate(); | |
| 135 | }, | |
| 136 | }); | |
| 137 | return screen; | |
| 138 | } | |
| 139 | ||
| 140 | /** Crew agents, as MCP `agent` has them: an admin who is not an agent. */ | |
| 141 | export const entry: PaletteEntry = { | |
| 142 | id: "agents", | |
| 143 | title: "Crew agents", | |
| 144 | hint: "each agent's model, budget and brief", | |
| 145 | after: ["teams", "limits", "quotas", "people"], | |
| 146 | offered: (session) => session.user?.isAdmin === true && session.user.kind !== "agent", | |
| 147 | run: (session, ctx) => ctx.push(agentsScreen(session, ctx)), | |
| 148 | }; |
src/tui/screens/entries.ts +2
| @@ -9,6 +9,7 @@ import type { Ctx } from "../app/app.ts"; | ||
| 9 | 9 | import type { Command } from "../app/palette.ts"; |
| 10 | 10 | import type { Session } from "../session.ts"; |
| 11 | 11 | import { entry as access } from "./access.ts"; |
| 12 | import { entry as agents } from "./agents.ts"; | |
| 12 | 13 | import { entry as boards } from "./boards.ts"; |
| 13 | 14 | import { entry as cloud } from "./cloud.ts"; |
| 14 | 15 | import { entry as crewHandover } from "./crewhandover.ts"; |
| @@ -64,6 +65,7 @@ export const ENTRIES: readonly PaletteEntry[] = [ | ||
| 64 | 65 | quotas, |
| 65 | 66 | limits, |
| 66 | 67 | teams, |
| 68 | agents, | |
| 67 | 69 | invites, |
| 68 | 70 | subscriptions, |
| 69 | 71 | cloud, |
test/issues-words.test.ts +1 −1
| @@ -145,7 +145,7 @@ describe("a page of issues, worded in one batch", () => { | ||
| 145 | 145 | const closing = openPullsClosing(app.db, repo.id); |
| 146 | 146 | await tellWords(ctx(), repo, all, closing); |
| 147 | 147 | const laps: number[] = []; |
| 148 | for (let n = 0; n < 5; n++) { | |
| 148 | for (let n = 0; n < 25; n++) { | |
Non-blocking: unrelated to the Agents screen. A one-line pull of its own keeps pulls single-purpose. |
||
| 149 | 149 | const at = performance.now(); |
| 150 | 150 | await tellWords(ctx(), repo, all, closing); |
| 151 | 151 | laps.push(performance.now() - at); |
test/palette-roster.test.ts +1
| @@ -97,6 +97,7 @@ test("a site-admin person is offered the whole roster", () => { | ||
| 97 | 97 | ["quotas", "Account quotas", "what one account may own, and who changed it"], |
| 98 | 98 | ["limits", "Limits", "what the forge caps, with default and bounds; change one"], |
| 99 | 99 | ["teams", "Crew teams", "each team's charter, dial and members"], |
| 100 | ["agents", "Crew agents", "each agent's model, budget and brief"], | |
| 100 | 101 | ["invites", "Invite links", "make a single-use link, see which are open, revoke one"], |
| 101 | 102 | ["subscriptions", "Crew subscriptions", "each account's budget and rest; wake one, set its budget, add or remove one"], |
| 102 | 103 | ["cloud", "Cloud accounts", "provider tokens, what they are used for, today's calls"], |
test/tui-agents.test.ts added +203
| @@ -0,0 +1,203 @@ | ||
| 1 | /** | |
| 2 | * Crew agents in the terminal (#7289): an admin who is not an agent lists the | |
outdated
Header describes teams, not agents. Fix the comment. |
||
| 3 | * agents with model, budget and brief, and changes or creates or removes one | |
| 4 | * after a plan and a confirm, through the crew actions (so the same refusals | |
| 5 | * apply, a bad value is refused before the confirm, and the change is put on | |
| 6 | * the bus); anyone else is not offered the screen. | |
| 7 | */ | |
| 8 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 9 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 10 | import { tmpdir } from "node:os"; | |
| 11 | import { join } from "node:path"; | |
| 12 | import { createApp, type App as Forge } from "../src/app.ts"; | |
| 13 | import { createAgentRow, getAgentByName } from "../src/core/agents.ts"; | |
| 14 | import { listEvents } from "../src/core/events.ts"; | |
| 15 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 16 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 17 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 18 | import { quietLogs } from "./helpers/globals.ts"; | |
| 19 | import { settleApp } from "./helpers/tui.ts"; | |
| 20 | ||
| 21 | quietLogs(); | |
| 22 | ||
| 23 | describe("agents in the terminal", () => { | |
| 24 | let dataDir: string; | |
| 25 | let forge: Forge; | |
| 26 | ||
| 27 | function boot(username: string) { | |
| 28 | const terminal = headlessTerminal(140, 32); | |
| 29 | const user = getUserByName(forge.db, username)!; | |
| 30 | const { app } = createForgeApp( | |
| 31 | forge, | |
| 32 | { sshUsername: "t", remoteAddress: "127.0.0.1", user }, | |
| 33 | { terminal, exitHooks: false }, | |
| 34 | ); | |
| 35 | app.start(); | |
| 36 | const key = (k: string) => app.dispatch({ type: "key", key: k, ctrl: false, alt: false, shift: false }); | |
| 37 | return { | |
| 38 | text: () => app.screen.toText(), | |
| 39 | press: async (...keys: string[]) => { | |
| 40 | keys.forEach(key); | |
| 41 | await settleApp(app); | |
| 42 | }, | |
| 43 | ctrl: async (k: string) => { | |
| 44 | app.dispatch({ type: "key", key: k, ctrl: true, alt: false, shift: false }); | |
| 45 | await settleApp(app); | |
| 46 | }, | |
| 47 | type: async (t: string) => { | |
| 48 | for (const ch of t) key(ch); | |
| 49 | await settleApp(app); | |
| 50 | }, | |
| 51 | stop: () => app.stop(), | |
| 52 | }; | |
| 53 | } | |
| 54 | ||
| 55 | beforeAll(async () => { | |
| 56 | dataDir = await mkdtemp(join(tmpdir(), "forge-tui-agents-")); | |
| 57 | forge = createApp({ dataDir, port: 0 }); | |
| 58 | for (const username of ["alice", "bob", "carol", "bot1"]) { | |
| 59 | const made = await createUser(forge.db, { | |
| 60 | origin: "seed", | |
| 61 | trusted: true, | |
| 62 | username, | |
| 63 | email: `${username}@forge.local`, | |
| 64 | password: "a-good-passphrase", | |
| 65 | adminIfFirst: username === "alice", | |
| 66 | }); | |
| 67 | if (!made.ok) throw new Error(made.error); | |
| 68 | if (username === "bot1") createAgentRow(forge.db, { userId: made.value.id, model: "opus" }); | |
| 69 | } | |
| 70 | forge.db.query("UPDATE user SET is_admin = 1, is_agent = 1 WHERE username = 'carol'").run(); | |
| 71 | }); | |
| 72 | afterAll(async () => { | |
| 73 | forge.close(); | |
| 74 | await rm(dataDir, { recursive: true, force: true }); | |
| 75 | }); | |
| 76 | ||
| 77 | const open = async (h: ReturnType<typeof boot>) => { | |
| 78 | await h.ctrl("k"); | |
| 79 | await h.type("Crew agents"); | |
| 80 | await h.press("enter"); | |
| 81 | }; | |
| 82 | const clear = async (h: ReturnType<typeof boot>, n = 12) => { | |
| 83 | for (let i = 0; i < n; i++) await h.press("backspace"); | |
| 84 | }; | |
| 85 | ||
| 86 | test("lists each agent with model, budget and the first line of its brief", async () => { | |
| 87 | forge.db.query("UPDATE agent SET brief = 'Reviews pulls.\nSecond line.', budget_usd = 2 WHERE 1").run(); | |
| 88 | const h = boot("alice"); | |
| 89 | await open(h); | |
| 90 | expect(h.text()).toContain("bot1"); | |
| 91 | expect(h.text()).toContain("opus"); | |
| 92 | expect(h.text()).toContain("$2"); | |
| 93 | expect(h.text()).toContain("Reviews pulls."); | |
| 94 | expect(h.text()).not.toContain("Second line."); | |
| 95 | h.stop(); | |
| 96 | }); | |
| 97 | ||
| 98 | test("a budget changes only after a confirm", async () => { | |
| 99 | const h = boot("alice"); | |
| 100 | await open(h); | |
| 101 | await h.press("b"); | |
| 102 | await clear(h); | |
| 103 | await h.type("5"); | |
| 104 | await h.press("enter"); | |
| 105 | expect(h.text()).toContain("$2 → $5"); | |
| 106 | expect(getAgentByName(forge.db, "bot1")!.budgetUsd).toBe(2); | |
| 107 | await h.press("n"); | |
| 108 | expect(getAgentByName(forge.db, "bot1")!.budgetUsd).toBe(2); | |
| 109 | await h.press("b"); | |
| 110 | await clear(h); | |
| 111 | await h.type("5"); | |
| 112 | await h.press("enter"); | |
| 113 | await h.press("y"); | |
| 114 | expect(getAgentByName(forge.db, "bot1")!.budgetUsd).toBe(5); | |
| 115 | expect(h.text()).toContain("bot1 saved."); | |
| 116 | expect(listEvents(forge.db, { kind: "agent.updated" }).length).toBe(1); | |
| 117 | h.stop(); | |
| 118 | }); | |
| 119 | ||
| 120 | test("a budget over the maximum, a negative and a non-number are refused before any confirm", async () => { | |
| 121 | for (const bad of ["100000", "-1", "abc"]) { | |
| 122 | const h = boot("alice"); | |
| 123 | await open(h); | |
| 124 | await h.press("b"); | |
| 125 | await clear(h); | |
| 126 | await h.type(bad); | |
| 127 | await h.press("enter"); | |
| 128 | expect(h.text()).toContain("refused: invalid_budget"); | |
| 129 | expect(h.text()).not.toContain("Change agent"); | |
| 130 | expect(getAgentByName(forge.db, "bot1")!.budgetUsd).toBe(5); | |
| 131 | h.stop(); | |
| 132 | } | |
| 133 | }); | |
| 134 | ||
| 135 | test("a bad model is refused before the confirm", async () => { | |
| 136 | const h = boot("alice"); | |
| 137 | await open(h); | |
| 138 | await h.press("m"); | |
| 139 | await clear(h); | |
| 140 | await h.type("not a model"); | |
| 141 | await h.press("enter"); | |
| 142 | expect(h.text()).toContain("refused: invalid_model"); | |
| 143 | expect(h.text()).not.toContain("Change agent"); | |
| 144 | expect(getAgentByName(forge.db, "bot1")!.model).toBe("opus"); | |
| 145 | h.stop(); | |
| 146 | }); | |
| 147 | ||
| 148 | test("a brief changes after a confirm", async () => { | |
| 149 | const h = boot("alice"); | |
| 150 | await open(h); | |
| 151 | await h.press("B"); | |
| 152 | await clear(h, 60); | |
| 153 | await h.type("Writes docs."); | |
| 154 | await h.press("enter"); | |
| 155 | expect(h.text()).toContain("Writes docs."); | |
| 156 | expect(getAgentByName(forge.db, "bot1")!.brief).toContain("Reviews pulls."); | |
| 157 | await h.press("y"); | |
| 158 | expect(getAgentByName(forge.db, "bot1")!.brief).toBe("Writes docs."); | |
| 159 | h.stop(); | |
| 160 | }); | |
| 161 | ||
| 162 | test("a duplicate username on create is refused", async () => { | |
| 163 | const h = boot("alice"); | |
| 164 | await open(h); | |
| 165 | await h.press("n"); | |
| 166 | await h.type("bot1"); | |
| 167 | await h.press("enter"); | |
| 168 | await h.press("enter"); | |
| 169 | expect(h.text()).toContain("refused:"); | |
| 170 | h.stop(); | |
| 171 | }); | |
| 172 | ||
| 173 | test("creates an agent by username then model, and removes it after a confirm", async () => { | |
| 174 | const h = boot("alice"); | |
| 175 | await open(h); | |
| 176 | await h.press("n"); | |
| 177 | await h.type("newbie"); | |
| 178 | await h.press("enter"); | |
| 179 | await h.type("haiku"); | |
| 180 | await h.press("enter"); | |
| 181 | expect(getAgentByName(forge.db, "newbie")!.model).toBe("haiku"); | |
| 182 | expect(h.text()).toContain("newbie created."); | |
| 183 | await h.press("j", "d"); | |
| 184 | expect(h.text()).toContain("cannot be undone"); | |
| 185 | await h.press("n"); | |
| 186 | expect(getAgentByName(forge.db, "newbie")).toBeDefined(); | |
| 187 | expect(h.text()).toContain("newbie"); | |
| 188 | await h.press("d", "y"); | |
| 189 | expect(getAgentByName(forge.db, "newbie")).toBeUndefined(); | |
| 190 | expect(h.text()).toContain("newbie removed."); | |
| 191 | h.stop(); | |
| 192 | }); | |
| 193 | ||
| 194 | test("a non-admin and an admin agent are never offered the screen", async () => { | |
| 195 | for (const who of ["bob", "carol"]) { | |
| 196 | const h = boot(who); | |
| 197 | await h.ctrl("k"); | |
| 198 | await h.type("Crew agents"); | |
| 199 | expect(h.text()).not.toContain("each agent's model"); | |
| 200 | h.stop(); | |
| 201 | } | |
| 202 | }); | |
| 203 | }); |
new above
offline
Plan prints the typed budget, but patchAgent clamps it; show the real outcome or refuse, and validate before the confirm.