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

The terminal's run screen says a word to a running crew run with `s`, as the run page's steer box and MCP `steer` do #7309

Merged opened this

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

Merged

mason/forge:mason/7288-the-terminal-s-run-screen-says-a-word-to → main 5 commits fork 3 threads · 3 unresolved

  • Merged. mason/7288-the-terminal-s-run-screen-says-a-word-to was merged into main as 7318f65.

Review

Sign in to review this change.
9 files changed +345 −14
docs/design/sections/6g-terminal-apps-runs-crew.md +5
@@ -21,6 +21,11 @@ remains is where the work is reviewed:
2121 the summary. `enter` on one hands over its web page,
2222 `/:owner/:name/runs/:id`, where the live log, cancel and steer are — as a
2323 link when the forge knows its public URL, as the path otherwise.
24 - **Steer** a running agent run from the terminal: `S` on the runs list, `s`
25 on an issue's Crew card and on a pull's Crew tab. Offered to a writer who
26 is a person, never an agent, on a running agent run only; it sends through
27 the same action as the run page's steer box and MCP `steer`, and the toast
28 says whether the agent heard it or it became a comment.
2429 - **Settings** — the App tab (enable with slug, access and preview data; edit;
2530 `D` to stop running as an app behind a confirm), `C` for require checks on
2631 General, the Crew tab (`a` adds an agent from the forge's, `d` removes).
src/actions/runs.ts +7
@@ -320,6 +320,13 @@ export type SteerOutcome =
320320 /** No session was listening; it went on the conversation for the next run. */
321321 | "commented";
322322
323 /** What each outcome means, said the same on every surface that steers. */
324 export const STEER_SAYS: Record<SteerOutcome, string> = {
325 sent: "The agent hears it at its next step.",
326 commented:
327 "No session was listening; it went on the conversation for the next run.",
328 };
329
323330 /**
324331 * Say something to an agent mid-run. A running agent hears it as its next
325332 * user turn. A run that is queued, paused, or over has nobody listening,
src/crew/runner.ts +14 −1
@@ -203,6 +203,17 @@ export { crewRoot, workdirFor } from "./workdir.ts";
203203 /** Runs with an agent listening: what a person says reaches it next. */
204204 const steering = new Map<string, (text: string) => boolean>();
205205
206 /** A session starts listening to a run; the answer stops it. */
207 export function listenForSteer(
208 runId: string,
209 send: (text: string) => boolean,
210 ): () => void {
211 steering.set(runId, send);
212 return () => {
213 if (steering.get(runId) === send) steering.delete(runId);
214 };
215 }
216
206217 /**
207218 * Say something to an agent while its run goes. Written to the run's log
208219 * as the person's line first, so the record holds it whether or not the
@@ -1347,7 +1358,9 @@ async function work(
13471358 timeoutMs: Math.max(deadline - Date.now(), 0),
13481359 log: write,
13491360 signal,
1350 onSteer: (send) => steering.set(run.id, send),
1361 onSteer: (send) => {
1362 listenForSteer(run.id, send);
1363 },
13511364 onUsage: (so) =>
13521365 meterLive(
13531366 app,
src/http/mcp/tools/steer.ts +2 −5
@@ -1,4 +1,4 @@
1 import { steerRepoRun } from "../../../actions/runs.ts";
1 import { STEER_SAYS, steerRepoRun } from "../../../actions/runs.ts";
22 import { good, bad, type Tool, str, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
33
44 export const tool: Tool = {
@@ -23,10 +23,7 @@ export const tool: Tool = {
2323 : `refused: ${steered.error}`,
2424 );
2525 return good({
26 says:
27 steered.value === "sent"
28 ? "The agent hears it at its next step."
29 : "No session was listening; it went on the conversation for the next run.",
26 says: STEER_SAYS[steered.value],
3027 outcome: steered.value,
3128 });
3229 },
src/tui/screens/issue.ts +10 −1
@@ -65,7 +65,7 @@ import {
6565 } from "./common.ts";
6666 import { assignPicker, assignedText, queuedText } from "./assign.ts";
6767 import { liveBindings, liveKey } from "./mirror.ts";
68 import { RunList, openRunLog } from "./runlist.ts";
68 import { RunList, maySteer, openRunLog, steerPrompt } from "./runlist.ts";
6969 import { Composer, threadLines, type ThreadEntry } from "./thread.ts";
7070 import { local, localRepo, whereRows, whereStrip } from "./where.ts";
7171
@@ -422,6 +422,9 @@ export function issueScreen(
422422 focus === "crew"
423423 ? binding(["enter"], "open the run")
424424 : binding(["j", "k"], "scroll"),
425 ...(focus === "crew" && repo !== undefined && maySteer(f, repo, crew.selected)
426 ? [binding(["s"], "steer")]
427 : []),
425428 ...(mayWrite()
426429 ? [
427430 binding(["c"], "comment"),
@@ -606,6 +609,12 @@ export function issueScreen(
606609 openRun();
607610 return true;
608611 }
612 if (e.key === "s") {
613 const run = crew.selected;
614 if (run !== undefined && repo !== undefined && maySteer(f, repo, run))
615 steerPrompt(f, ctx, screen, repo, run, () => runAsync(ctx, reload));
616 return true;
617 }
609618 }
610619 if (e.key === "a" && mayWrite()) {
611620 assign();
src/tui/screens/pull.ts +15 −3
@@ -120,7 +120,7 @@ import { FINISH_REFUSALS, finishConflicts } from "../../actions/finish-conflicts
120120 import { PROPOSE_WHY, proposalOf, proposeResolution } from "../../crew/proposal.ts";
121121 import { conflictScreen } from "./conflicts.ts";
122122 import { reviewScreen } from "./review.ts";
123 import { RunList, openRunLog } from "./runlist.ts";
123 import { RunList, maySteer, openRunLog, steerPrompt } from "./runlist.ts";
124124 import { runTone } from "./runwords.ts";
125125 import { Composer, threadLines, type ThreadEntry } from "./thread.ts";
126126 import { local, localRepo, whereRows, whereStrip } from "./where.ts";
@@ -826,7 +826,13 @@ export function pullScreen(
826826 ...(tab === 1
827827 ? view.keys
828828 : tab === 2
829 ? [binding(["j", "k"], "move"), binding(["enter"], "open the run")]
829 ? [
830 binding(["j", "k"], "move"),
831 binding(["enter"], "open the run"),
832 ...(repo !== undefined && maySteer(f, repo, crew.selected)
833 ? [binding(["s"], "steer")]
834 : []),
835 ]
830836 : [binding(["j", "k"], "scroll")]),
831837 ...(isApp && preview !== undefined
832838 ? [binding(["O"], "open the preview")]
@@ -1135,7 +1141,7 @@ export function pullScreen(
11351141 seeConflicts();
11361142 return true;
11371143 }
1138 // The crew tab is a list: the cursor moves, enter opens the run.
1144 // The crew tab is a list: the cursor moves, enter opens the run, `s` steers it.
11391145 if (tab === 2) {
11401146 if (crew.state.key(e, crew.runs.length)) return true;
11411147 if (e.key === "enter") {
@@ -1144,6 +1150,12 @@ export function pullScreen(
11441150 openRunLog(f, ctx, repo, run);
11451151 return true;
11461152 }
1153 if (e.key === "s") {
1154 const run = crew.selected;
1155 if (run !== undefined && repo !== undefined && maySteer(f, repo, run))
1156 steerPrompt(f, ctx, screen, repo, run, () => runAsync(ctx, reload));
1157 return true;
1158 }
11471159 }
11481160 // A thread the cursor is standing in owns `x` and `enter` before the
11491161 // screen does: settling a conversation is the act on offer there, and
src/tui/screens/runlist.ts +57 −2
@@ -10,12 +10,14 @@
1010 * the summary that says what failed.
1111 */
1212
13 import { STEER_SAYS, steerRepoRun } from "../../actions/runs.ts";
14 import { SAY_SOMETHING_WORDS } from "../../actions/tell_words.ts";
1315 import { placementWords } from "../../checks/placement.ts";
1416 import { getIssuesByIds, type Issue } from "../../core/issues.ts";
15 import type { Repo } from "../../core/repos.ts";
17 import { canWrite, type Repo } from "../../core/repos.ts";
1618 import type { Run, RunState } from "../../core/runs.ts";
1719 import { getUsersByIds } from "../../core/users.ts";
18 import type { Ctx } from "../app/app.ts";
20 import type { Ctx, Screen } from "../app/app.ts";
1921 import type { Rect, Screen as Surface } from "../term/screen.ts";
2022 import type { Tokens } from "../ui/theme.ts";
2123 import { icon, type IconSet } from "../ui/icons.ts";
@@ -23,8 +25,10 @@ import type { Session } from "../session.ts";
2325 import {
2426 ListState,
2527 drawList,
28 errorText,
2629 hitList,
2730 metaOf,
31 promptScreen,
2832 relTime,
2933 type ListRow,
3034 } from "./common.ts";
@@ -175,3 +179,54 @@ export function openRunLog(
175179 ): void {
176180 ctx.push(runLogScreen(f, ctx, repo, run.id));
177181 }
182
183 /**
184 * Whether the viewer may say a word to this run: an agent's run that is
185 * running, and a person who may write here. An agent session is never
186 * offered it — a run costs money, and steering one is a person's call, as
187 * `steerRepoRun` refuses it too.
188 */
189 export function maySteer(f: Session, repo: Repo, run: Run | undefined): boolean {
190 return (
191 run !== undefined &&
192 run.kind === "agent" &&
193 run.state === "running" &&
194 f.user !== undefined &&
195 f.user.kind !== "agent" &&
196 canWrite(f.db, repo, f.user)
197 );
198 }
199
200 /**
201 * Steer: a one-line composer whose enter sends the words through
202 * `steerRepoRun`, as the run page's steer box and MCP `steer` do. Empty text
203 * keeps the composer open with the refusal; the outcome is a toast in the
204 * words MCP answers with.
205 */
206 export function steerPrompt(
207 f: Session,
208 ctx: Ctx,
209 parent: Screen,
210 repo: Repo,
211 run: Run,
212 after: () => void = () => {},
213 ): void {
214 ctx.push(
215 promptScreen(ctx, parent, {
216 title: "Steer the run",
217 label: `Say a word to ${runTitle(run, runActors(f, [run]))}`,
218 placeholder: "One sentence the agent hears next",
219 onSubmit: (text) => {
220 const steered = steerRepoRun(f.app, f.user, repo, run.id, text);
221 if (!steered.ok) {
222 if (steered.error === "empty") return SAY_SOMETHING_WORDS;
223 ctx.toast(`Could not steer: ${errorText(steered.error)}.`, "danger");
224 return undefined;
225 }
226 ctx.toast(`${steered.value === "sent" ? "Sent" : "Commented"}. ${STEER_SAYS[steered.value]}`);
227 after();
228 return undefined;
229 },
230 }),
231 );
232 }
src/tui/screens/runs.ts +11 −2
@@ -3,7 +3,9 @@
33 * (All, Failed, Running, Succeeded) — the web runs page's `?state=` row.
44 * Read through `listRepoRuns`, so it is gated like every other run read.
55 * `x` cancels a run that has not finished; `a` queues a failed or cancelled
6 * agent run's task again (`requeueRepoRun`), each after a confirm.
6 * agent run's task again (`requeueRepoRun`), each after a confirm; `S` says a
7 * word to a run while an agent runs it (`s` is the state row here; the crew
8 * cards steer with `s`).
79 */
810
911 import { cancelRepoRun, listRepoRuns, requeueRepoRun } from "../../actions/runs.ts";
@@ -20,7 +22,7 @@ import { truncate } from "../term/width.ts";
2022 import { fill, len, split } from "../ui/layout.ts";
2123 import { emptyState } from "../ui/widgets.ts";
2224 import { confirmScreen } from "./common.ts";
23 import { RunList, openRunLog, runTitle } from "./runlist.ts";
25 import { RunList, maySteer, openRunLog, runTitle, steerPrompt } from "./runlist.ts";
2426
2527 const LIMIT = 50;
2628
@@ -130,6 +132,7 @@ export function runsScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
130132 binding(["j", "k"], "move"),
131133 binding(["enter"], "open the log"),
132134 ...(cancellable() === undefined ? [] : [binding(["x"], "cancel")]),
135 ...(maySteer(f, repo, list.selected) ? [binding(["S"], "steer")] : []),
133136 ...(requeueable() === undefined ? [] : [binding(["a"], "run again")]),
134137 binding(["s"], "state: All, Failed, Running, Succeeded (cycle)"),
135138 binding(["r"], "refresh"),
@@ -170,6 +173,12 @@ export function runsScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
170173 ask();
171174 return true;
172175 }
176 if (e.key === "S") {
177 const run = list.selected;
178 if (run !== undefined && maySteer(f, repo, run))
179 steerPrompt(f, ctx, self, repo, run, read);
180 return true;
181 }
173182 if (e.key === "a") {
174183 askRequeue();
175184 return true;
test/tui-steer.test.ts added +224
@@ -0,0 +1,224 @@
1 /** The terminal says a word to a running crew run, as the run page and MCP `steer` do (#7288). */

This file covers the runs list and the issue card, but not the pull's Crew tab, which this change also adds. One more test is needed here.

2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { addToCrew, createAgent } from "../src/actions/crew.ts";
7 import { openIssue } from "../src/actions/issues.ts";
8 import { openPull } from "../src/actions/pulls.ts";
9 import { commitToBranch, createRepo as makeRepo } from "../src/actions/repos.ts";
10 import { createApp, type App as Forge } from "../src/app.ts";
11 import { listComments, type Issue } from "../src/core/issues.ts";
12 import { addTeamMember, createOrg, createTeam } from "../src/core/orgs.ts";
13 import { canWrite, createRepo, type Repo } from "../src/core/repos.ts";
14 import { claimRun, createRun, finishRun } from "../src/core/runs.ts";
15 import type { User } from "../src/core/users.ts";
16 import { listenForSteer } from "../src/crew/runner.ts";
17 import { createForgeApp } from "../src/tui/forge.ts";
18 import { issueScreen } from "../src/tui/screens/issue.ts";
19 import { pullScreen } from "../src/tui/screens/pull.ts";
20 import { runsScreen } from "../src/tui/screens/runs.ts";
21 import { headlessTerminal } from "../src/tui/term/terminal.ts";
22 import { local } from "../src/tui/screens/where.ts";
23 import { seedAdmin } from "./helpers/admin.ts";
24 import { quietLogs } from "./helpers/globals.ts";
25 import { settleApp } from "./helpers/tui.ts";
26
27 quietLogs();
28
29 let dir: string;
30 let forge: Forge;
31 let alice: User;
32 let bot: User;
33 let orgId: string;
34 let n = 0;
35
36 beforeAll(async () => {
37 dir = await mkdtemp(join(tmpdir(), "forge-tui-steer-"));
38 forge = createApp({ dataDir: dir, port: 0 });
39 const a = await seedAdmin(forge, { username: "alice", email: "[email protected]", password: "a-long-passphrase" });
40 if (!a.ok) throw new Error("signup failed");
41 alice = a.value;
42 const agent = await createAgent(forge, alice, { username: "robo" });
43 if (!agent.ok) throw new Error("agent failed");
44 bot = agent.value.user;
45 // An org's repositories, so a team can make the agent a writer.
46 const org = createOrg(forge.db, "acme", alice.id);
47 if (!org.ok) throw new Error("org failed");
48 orgId = org.value.id;
49 const writers = createTeam(forge.db, orgId, "Writers", "write");
50 if (!writers.ok) throw new Error("team failed");
51 addTeamMember(forge.db, writers.value, bot.id);
52 });
53 afterAll(async () => {
54 forge.close?.();
55 await rm(dir, { recursive: true, force: true });
56 });
57
58 /** A repository with one issue and one agent run on it, claimed unless `finished`. */
59 async function seed(finished = false): Promise<{ repo: Repo; issue: Issue; runId: string }> {
60 const r = await createRepo(forge.db, forge.reposRoot, { ownerId: orgId, name: `lamp${n++}`, visibility: "public" });
61 if (!r.ok) throw new Error("repo failed");
62 if (!addToCrew(forge, alice, r.value, "robo").ok) throw new Error("crew failed");
63 const issue = openIssue(forge, alice, r.value, { title: "Say hello" });
64 if (!issue.ok) throw new Error("issue failed");
65 const run = createRun(forge.db, {
66 repoId: r.value.id,
67 kind: "agent",
68 issueId: issue.value.id,
69 actorId: bot.id,
70 });
71 claimRun(forge.db, run.id);
72 if (finished) finishRun(forge.db, run.id, { state: "ok", summary: "done" });
73 return { repo: r.value, issue: issue.value, runId: run.id };
74 }
75
76 async function open(user: User, push: (session: any, ctx: any) => any) {
77 const { app, session } = createForgeApp(
78 forge,
79 { sshUsername: user.username, remoteAddress: "127.0.0.1", user },
80 { terminal: headlessTerminal(120, 30), exitHooks: false },
81 );
82 app.start();
83 await settleApp(app);
84 app.push(push(session, app.ctx));
85 await settleApp(app);
86 const press = async (key: string): Promise<string> => {
87 app.dispatch({ type: "key", key, ctrl: false, alt: false, shift: false });
88 await settleApp(app);
89 return app.screen.toText();
90 };
91 const type = async (text: string): Promise<void> => {
92 for (const ch of text) await press(ch);
93 };
94 return { app, press, type, text: () => app.screen.toText() };
95 }
96
97 test("S on the runs list sends the word to the running agent and says so", async () => {
98 const { repo, runId } = await seed();
99 const heard: string[] = [];
100 const stop = listenForSteer(runId, (t) => {
101 heard.push(t);
102 return true;
103 });
104 const h = await open(alice, (s, ctx) => runsScreen(s, ctx, repo));
105 expect(h.text()).toMatch(/S +steer/);
106 expect(await h.press("S")).toContain("Steer the run");
107 await h.type("also add a test");
108 const text = await h.press("enter");
109 expect(heard).toEqual(["also add a test"]);
110 expect(text).toContain("Sent. The agent hears it at its next step.");
111 stop();
112 h.app.stop();
113 });
114
115 test("with no session listening the word goes on the conversation", async () => {
116 const { repo, issue } = await seed();
117 const h = await open(alice, (s, ctx) => runsScreen(s, ctx, repo));
118 await h.press("S");
119 await h.type("try the other door");
120 const text = await h.press("enter");
121 expect(text).toContain("Commented.");
122 expect(listComments(forge.db, issue.id).map((c) => c.body)).toContain("try the other door");
123 h.app.stop();
124 });
125
126 test("empty text is refused in place and nothing is said", async () => {
127 const { repo, runId } = await seed();
128 const heard: string[] = [];
129 const stop = listenForSteer(runId, (t) => {
130 heard.push(t);
131 return true;
132 });
133 const h = await open(alice, (s, ctx) => runsScreen(s, ctx, repo));
134 await h.press("S");
135 await h.type(" ");
136 const text = await h.press("enter");
137 expect(text).toContain("Steer the run");
138 expect(text).toContain("Say something first.");
139 expect(heard).toEqual([]);
140 stop();
141 h.app.stop();
142 });
143
144 test("a finished run is not offered steering", async () => {
145 const { repo, issue } = await seed(true);
146 const h = await open(alice, (s, ctx) => runsScreen(s, ctx, repo));
147 expect(h.text()).not.toMatch(/S +steer/);
148 expect(await h.press("S")).not.toContain("Steer the run");
149 expect(listComments(forge.db, issue.id)).toHaveLength(0);
150 h.app.stop();
151 });
152
153 test("the issue's crew card steers with s", async () => {
154 const { repo, issue, runId } = await seed();
155 const heard: string[] = [];
156 const stop = listenForSteer(runId, (t) => {
157 heard.push(t);
158 return true;
159 });
160 const h = await open(alice, (s, ctx) => issueScreen(s, ctx, local(s, repo), issue.number));
161 await h.press("tab");
162 expect(h.text()).toMatch(/s +steer/);
163 expect(await h.press("s")).toContain("Steer the run");
164 await h.type("smaller steps");
165 expect(await h.press("enter")).toContain("Sent.");
166 expect(heard).toEqual(["smaller steps"]);
167 stop();
168 h.app.stop();
169 });
170
171 test("the pull's Crew tab steers with s", async () => {
172 const repo = await makeRepo(forge, alice, { name: `pull${n++}`, visibility: "public" });
173 if (!repo.ok) throw new Error(String(repo.error));
174 for (const branch of ["main", "topic"]) {
175 const done = await commitToBranch(forge, alice, repo.value, {
176 branch,
177 message: "edit",
178 files: [{ path: `${branch}.txt`, content: "x\n" }],
179 });
180 if (!done.ok) throw new Error(JSON.stringify(done.error));
181 }
182 if (!addToCrew(forge, alice, repo.value, "robo").ok) throw new Error("crew failed");
183 const pull = await openPull(forge, alice, repo.value, { title: "Topic", baseBranch: "main", headBranch: "topic" });
184 if (!pull.ok) throw new Error(String(pull.error));
185 const run = createRun(forge.db, { repoId: repo.value.id, kind: "agent", issueId: pull.value.id, actorId: bot.id });
186 claimRun(forge.db, run.id);
187 const heard: string[] = [];
188 const stop = listenForSteer(run.id, (t) => {
189 heard.push(t);
190 return true;
191 });
192 const h = await open(alice, (s, ctx) => pullScreen(s, ctx, local(s, repo.value), pull.value.number));
193 const end = Date.now() + 3_000;
194 while (!h.text().includes("Crew")) {
195 if (Date.now() > end) throw new Error(h.text());
196 await settleApp(h.app);
197 await Bun.sleep(20);

Non-blocking: a 20 s ceiling is generous for a unit test. Consider a smaller one so a hang fails fast and stays within #1242's per-test budget.

198 }
199 await h.press("tab");
200 await h.press("tab");
201 expect(h.text()).toMatch(/s +steer/);
202 expect(await h.press("s")).toContain("Steer the run");
203 await h.type("one file at a time");
204 expect(await h.press("enter")).toContain("Sent.");
205 expect(heard).toEqual(["one file at a time"]);
206 stop();
207 h.app.stop();
208 });
209
210 test("an agent session that may write is not offered steering", async () => {
211 const { repo, runId } = await seed();
212 expect(canWrite(forge.db, repo, bot)).toBe(true);
213 const heard: string[] = [];
214 const stop = listenForSteer(runId, (t) => {
215 heard.push(t);
216 return true;
217 });
218 const h = await open(bot, (s, ctx) => runsScreen(s, ctx, repo));
219 expect(h.text()).not.toMatch(/S +steer/);
220 expect(await h.press("S")).not.toContain("Steer the run");
221 expect(heard).toEqual([]);
222 stop();
223 h.app.stop();
224 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs