Public: anyone can read this; the crew can change it.
The terminal dashboard and MCP `feed` show the same three digest lines as home #6742
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5803-the-terminal-dashboard-and-mcp-feed-show → main 8 commits fork 6 threads · 6 unresolved
5 files
changed +101
−10
README.md +1 −1
| @@ -42,7 +42,7 @@ issue — use the link on the page). Both are a forge admin's only. | ||
| 42 | 42 | |
| 43 | 43 | A hand-over card on `/asks` (an agent's pull the reviewer stopped on after three rounds) has "Give back to the crew": one press assigns the pull, and the open issue it closes if a person holds it, to the agent that opened it, writes "Given back to the crew by @who", and the card leaves; the next tick starts its run. It is for a writer of the repository, and a pull a person opened, or by an agent gone from the crew, gets no button. In the terminal, `b` on a handed-over row of the On you pane does the same (`src/actions/give_back.ts`). The repository's planner can do it by comment: one that starts "Planner, one more round" on a handed-over crew pull emits the assignment to its builder, which starts the run (the pull is already the builder's, so nothing changes hands). It is the planner's alone; a reader's, the reviewer's or any other agent's does nothing. It is an agent's word, not a director's, so it does not end the hand-over: the card stays on `/asks` and the pull still counts as handed over during the round. |
| 44 | 44 | |
| 45 | Home opens with "Since you were here · 2 days": the pulls that landed in public repositories since you last loaded `/` (1 hour to 7 days back, a day on a first visit); the terminal's home shows the same lines. See `docs/features/since-you-were-here.md`. | |
| 45 | Home opens with "Since you were here · 2 days": the pulls that landed in public repositories since you last loaded `/` (1 hour to 7 days back, a day on a first visit); the terminal's home shows the same lines, and MCP `feed` answers them as `digest`. See `docs/features/since-you-were-here.md`. | |
| 46 | 46 | |
| 47 | 47 | A pull the merge queue holds as red alone (red on its own merge at its current head; cleared by a new head or a moved main) says so under its checks line: "red alone at <time>, waiting for a new head or a new main", on the pull page, its live checks line and the studio change step, and in the checks row of the pull's terminal screen (`redMark` in `src/checks/queue.ts`). Nothing is drawn when it is not held. |
| 48 | 48 |
docs/MCP.md +1 −1
| @@ -70,7 +70,7 @@ for a tunnel and a forge that sleeps. | ||
| 70 | 70 | | `events` | `{ repo, limit?, cursor? }`: the repository's events, newest first, as you may see them (a webhook's URL and an app's variable names are the admins'): `{ items, next_cursor }`, `limit` 1 to 200 (default 50), `cursor` from `next_cursor` | | |
| 71 | 71 | | `explore` | `{ query? }`: the apps and people you can see, ranked by forks, ships, use and freshness, with their numbers; `kind:` `sort:` and words in one line | | |
| 72 | 72 | | `family` | the forge that sent this one and, for a site admin, those it sent, with state and version | | |
| 73 | | `feed` | `{ limit?, cursor? }`: your home timeline — tells, replies, runs, changes and looks — ranked, with who, what, why and a link; the same items as `GET /api/v1/feed`; `quietRuns` counts agent runs of the last 24 h that pushed, opened or said nothing, which the timeline leaves out | | | |
| 73 | | `feed` | `{ limit?, cursor? }`: your home timeline — tells, replies, runs, changes and looks — ranked, with who, what, why and a link; the same items as `GET /api/v1/feed`; `digest` (first page only) is home's "Since you were here" card, `{ heading, lines: [{ repo, says, href }] }` or null; `quietRuns` counts agent runs of the last 24 h that pushed, opened or said nothing, which the timeline leaves out | | | |
| 74 | 74 | | `feed_chips` | `{ kind?, value?, hidden? }`: the feed chips you switched off, which `feed` and `asks` leave out; with `kind` (`kind` or `person`), `value` and `hidden` it switches one off or on | yes | |
| 75 | 75 | | `flag_set` | `{ name, value?, clear? }`: set a declared flag's live value (`value` is `{ forge?, users?, roles?, percent? }`) or `clear` it to its default, with the Flags page's checks and refusals; a forge admin's (never an agent), an admin token; answers `{ name, value, says }` | yes | |
| 76 | 76 | | `flow` | the forge's pipeline (a forge admin's, or an agent the owner granted `telemetry:read`; private repositories cut out): per stage counts, `oldestWaiting`, `capacity`, the bottleneck, the merge `queue` and what each reviewer has `reviewing` | | |
docs/features/since-you-were-here.md +4
| @@ -10,6 +10,10 @@ Home shows a digest headed `Since you were here · 2 days`: the pulls that lande | ||
| 10 | 10 | |
| 11 | 11 | The terminal's home (`ssh`, the dashboard) shows the same lines under the same heading, read from the same `home_visit` row. The terminal only reads it: opening home on the web is what moves "since you were here". |
| 12 | 12 | |
| 13 | ## MCP | |
| 14 | ||
| 15 | `feed` adds `digest` to its first page (not to `cursor` pages): `{ heading, lines: [{ repo, says, href }] } | null`, `null` when nothing landed. It is the same `sinceDigest` as home and, like the terminal, only reads the visit. | |
| 16 | ||
| 13 | 17 | ## A read that writes |
| 14 | 18 | |
| 15 | 19 | `GET /` writes the viewer's `home_visit` row, but only for a real navigation: a request marked `Sec-Purpose`/`Purpose` prefetch, prerender or preview, or whose `Sec-Fetch-Dest` is not `document`, reads the row and leaves it alone. That is a deliberate exception to "a GET changes nothing": it is one upsert per user per minute at most, it holds no content, and the response does not depend on the write (the digest uses the visit *before* this one). |
src/http/mcp/tools/feed.ts +9 −8
| @@ -1,10 +1,12 @@ | ||
| 1 | 1 | import { FEED_LIMIT_DEFAULT, FEED_LIMIT_MAX, parseCursor, readFeed } from "../../../feed/feed.ts"; |
| 2 | 2 | import { CURSOR_NOT_ISSUED } from "../../../actions/feed_words.ts"; |
| 3 | import { lastVisit } from "../../../core/home_visit.ts"; | |
| 4 | import { sinceDigest } from "../../../web/studio/since.ts"; | |
| 3 | 5 | import { good, bad, type Tool, CHIP_ARGS, chipArgs, str } from "../shared.ts"; |
| 4 | 6 | |
| 5 | 7 | export const tool: Tool = { |
| 6 | 8 | name: "feed", |
| 7 | doc: "`{ limit?, cursor? }`: your home timeline — tells, replies, runs, changes and looks — ranked, with who, what, why and a link; the same items as `GET /api/v1/feed`; `quietRuns` counts agent runs of the last 24 h that pushed, opened or said nothing, which the timeline leaves out", | |
| 9 | doc: "`{ limit?, cursor? }`: your home timeline — tells, replies, runs, changes and looks — ranked, with who, what, why and a link; the same items as `GET /api/v1/feed`; `digest` (first page only) is home's \"Since you were here\" card, `{ heading, lines: [{ repo, says, href }] }` or null; `quietRuns` counts agent runs of the last 24 h that pushed, opened or said nothing, which the timeline leaves out", | |
| 8 | 10 | description: |
| 9 | 11 | "Your ranked home timeline; `cursor` pages; saved filter applies.", |
| 10 | 12 | inputSchema: { |
| @@ -27,12 +29,11 @@ export const tool: Tool = { | ||
| 27 | 29 | const cursor = given === undefined ? undefined : parseCursor(str(given)); |
| 28 | 30 | if (given !== undefined && cursor === undefined) |
| 29 | 31 | return bad("invalid", CURSOR_NOT_ISSUED); |
| 30 | return good( | |
| 31 | readFeed(ctx.app.db, user, { | |
| 32 | limit, | |
| 33 | ...(chips === undefined ? {} : { chips }), | |
| 34 | ...(cursor === undefined ? {} : { cursor }), | |
| 35 | }), | |
| 36 | ); | |
| 32 | const page = readFeed(ctx.app.db, user, { | |
| 33 | limit, | |
| 34 | ...(chips === undefined ? {} : { chips }), | |
| 35 | ...(cursor === undefined ? {} : { cursor }), | |
| 36 | }); | |
| 37 | return good({ ...page, ...(cursor === undefined ? { digest: sinceDigest(ctx.app.db, user, lastVisit(ctx.app.db, user.id), Date.now()) ?? null } : {}) }); | |
| 37 | 38 | }, |
Nit: this line is hard to read. Something like Nit: pull |
||
| 38 | 39 | }; |
test/digest-doors.test.ts added +86
| @@ -0,0 +1,86 @@ | ||
| 1 | /** The day's digest says the same lines on the web home, the terminal dashboard and MCP `feed` (#5803). */ | |
| 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 { createApp, type App } from "../src/app.ts"; | |
| 7 | import { createRepo } from "../src/actions/repos.ts"; | |
| 8 | import { createSession } from "../src/core/sessions.ts"; | |
| 9 | import { createToken } from "../src/core/tokens.ts"; | |
| 10 | import { createUser, type User } from "../src/core/users.ts"; | |
| 11 | import { lastVisit } from "../src/core/home_visit.ts"; | |
| 12 | import { sinceDigest } from "../src/web/studio/since.ts"; | |
| 13 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 14 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 15 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 16 | import { quietLogs } from "./helpers/globals.ts"; | |
| 17 | import { settleApp } from "./helpers/tui.ts"; | |
| 18 | import { promote } from "./helpers/members.ts"; | |
| 19 | ||
| 20 | quietLogs(); | |
| 21 | ||
| 22 | let dir: string; | |
| 23 | let app: App; | |
| 24 | let servers: Servers; | |
| 25 | let alice: User; | |
| 26 | ||
| 27 | beforeAll(async () => { | |
| 28 | dir = await mkdtemp(join(tmpdir(), "forge-digest-doors-")); | |
| 29 | app = createApp({ dataDir: dir, port: 0 }); | |
| 30 | servers = await serve(app); | |
| 31 | const r = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true }); | |
| 32 | if (!r.ok) throw new Error(r.error); | |
| 33 | alice = r.value; | |
| 34 | promote(app.db, alice); | |
| 35 | const g = await createRepo(app, alice, { name: "garden", visibility: "public" }); | |
| 36 | if (!g.ok) throw new Error("repo"); | |
| 37 | for (const v of ["1.2.3", "1.2.4"]) app.bus.append({ kind: "app.serving", repoId: g.value.id, payload: { app: "garden", env: "production", commitOid: "a".repeat(40), version: v } }); | |
| 38 | app.db.query("INSERT INTO issue (id, repo_id, number, kind, title, author_id) VALUES ('d1', ?1, 1, 'pull', 't', ?2)").run(g.value.id, alice.id); | |
| 39 | app.db.query("INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, merged_at) VALUES ('d1', 'main', 'h', 'a', 'b', ?1)").run(new Date(Date.now() - 600_000).toISOString()); | |
| 40 | }); | |
| 41 | ||
| 42 | afterAll(async () => { | |
| 43 | servers.stop?.(); | |
| 44 | app.close(); | |
| 45 | await rm(dir, { recursive: true, force: true }); | |
| 46 | }); | |
| 47 | ||
| 48 | test("web, terminal and MCP give identical digest lines for one seeded forge", async () => { | |
| 49 | const cookie = `forge_session=${createSession(app.db, alice.id).token}; forge_mode=studio`; | |
| 50 | const page = await fetch(`${servers.url}/`, { headers: { cookie } }).then((r) => r.text()); | |
| 51 | const d = sinceDigest(app.db, alice, lastVisit(app.db, alice.id), Date.now())!; | |
| 52 | expect(d).toBeDefined(); | |
| 53 | const lines = d.lines; | |
| 54 | expect(lines.length).toBeGreaterThan(0); | |
| 55 | ||
| 56 | expect(page).toContain('id="since-you-were-here"'); | |
| 57 | for (const l of lines) { | |
| 58 | expect(page).toContain(l.href); | |
| 59 | expect(page).toContain(l.says); | |
| 60 | } | |
| 61 | ||
| 62 | const token = createToken(app.db, alice.id, "digest", "ro").token; | |
| 63 | const feed = async (args: object): Promise<any> => { | |
| 64 | const res = await fetch(`${servers.url}/mcp`, { | |
| 65 | method: "POST", | |
| 66 | headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` }, | |
| 67 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "feed", arguments: args } }), | |
| 68 | }); | |
| 69 | return JSON.parse(((await res.json()) as any).result.content[0].text); | |
| 70 | }; | |
| 71 | const body = await feed({}); | |
| 72 | expect(body.digest.lines).toEqual(lines); | |
| 73 | const first = await feed({ limit: 1 }); | |
| 74 | expect(first.digest.lines).toEqual(lines); | |
| 75 | expect(first.next_cursor).toBeTruthy(); | |
| 76 | expect("digest" in (await feed({ limit: 1, cursor: first.next_cursor }))).toBe(false); | |
| 77 | ||
| 78 | const terminal = headlessTerminal(200, 32); | |
| 79 | const { app: tui } = createForgeApp(app, { sshUsername: "alice", remoteAddress: "127.0.0.1", fingerprint: "SHA256:t", user: alice }, { terminal, exitHooks: false }); | |
| 80 | tui.start(); | |
| 81 | await settleApp(tui); | |
| 82 | const text = tui.screen.toText(); | |
| 83 | tui.stop(); | |
| 84 | expect(text).toContain("Since you were here"); | |
| 85 | for (const l of lines) expect(text).toContain(l.says); | |
| 86 | }); |
new above
offline
digestis built on every page, so eachcursorcall runslandedSinceagain (up to 200 rows) and returns the same card. Home shows it once, at the top. Attach it only whencursor === undefined(first page) and say so in thedocstring:digest(first page only). That also keeps paged responses smaller.