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

Merged opened this

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

Merged

scout/forge:scout/7289-the-terminal-lists-the-crew-s-agents-wit → main 3 commits fork 4 threads · 4 unresolved

  • Merged. scout/7289-the-terminal-lists-the-crew-s-agents-wit was merged into main as e97c00b.

Review

Sign in to review this change.
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 file
src/actions/crew.ts +2 −1
@@ -63,7 +63,8 @@ function requireForgeAdmin(
6363 return user.value.isAdmin ? user : err("forbidden");
6464 }
6565
66 function checkPatch(
66 /** The bounds an agent change must meet; the screens check them before they ask. */
67 export function checkPatch(
6768 app: App,
6869 patch: AgentPatch,
6970 ): 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")
outdated

Plan prints the typed budget, but patchAgent clamps it; show the real outcome or refuse, and validate before the confirm.

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";
99 import type { Command } from "../app/palette.ts";
1010 import type { Session } from "../session.ts";
1111 import { entry as access } from "./access.ts";
12 import { entry as agents } from "./agents.ts";
1213 import { entry as boards } from "./boards.ts";
1314 import { entry as cloud } from "./cloud.ts";
1415 import { entry as crewHandover } from "./crewhandover.ts";
@@ -64,6 +65,7 @@ export const ENTRIES: readonly PaletteEntry[] = [
6465 quotas,
6566 limits,
6667 teams,
68 agents,
6769 invites,
6870 subscriptions,
6971 cloud,
test/issues-words.test.ts +1 −1
@@ -145,7 +145,7 @@ describe("a page of issues, worded in one batch", () => {
145145 const closing = openPullsClosing(app.db, repo.id);
146146 await tellWords(ctx(), repo, all, closing);
147147 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.

149149 const at = performance.now();
150150 await tellWords(ctx(), repo, all, closing);
151151 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", () => {
9797 ["quotas", "Account quotas", "what one account may own, and who changed it"],
9898 ["limits", "Limits", "what the forge caps, with default and bounds; change one"],
9999 ["teams", "Crew teams", "each team's charter, dial and members"],
100 ["agents", "Crew agents", "each agent's model, budget and brief"],
100101 ["invites", "Invite links", "make a single-use link, see which are open, revoke one"],
101102 ["subscriptions", "Crew subscriptions", "each account's budget and rest; wake one, set its budget, add or remove one"],
102103 ["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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs