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

Your account lists the machine shells you hold, live and recent, and the sessions MCP tool lists them too #10609

Merged opened this

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

Merged

scout/forge:scout/10544-your-account-lists-the-machine-shells-yo → main 5 commits fork 4 threads · 4 unresolved

  • Merged. scout/10544-your-account-lists-the-machine-shells-yo was merged into main as a6495ed.

Review

Sign in to review this change.
14 files changed +412 −6
docs/design/audit.md +1
@@ -133,6 +133,7 @@ Machine routes are not pages and have no entry: `/audit.csv`,
133133 - `/settings` — `pages/account.ts` · my account · the rest of settings · colour 1.
134134 - `/settings/password` — `pages/account.ts` · change password · `/settings/passkeys` · colour 1.
135135 - `/settings/tokens` — `pages/account.ts` · my API tokens · `/settings/keys` (credentials of two kinds on two pages) · colour 1.
136 - `/settings/shells` — `pages/account.ts` · the machine shells I hold, live then ended · `/settings/keys` · colour 1.
136137 - `/settings/keys` — `pages/account.ts` · my SSH keys · `/settings/tokens` · colour 1.
137138 - `/settings/passkeys` — `pages/passkeys.ts` · my passkeys · `/settings/password` · none seen.
138139 - `/settings/confirms` — `pages/confirms.ts` · what the forge's agent asked to do and waits for my yes · the terminal's Confirms (y) · none seen.
docs/features/my-shells.md added +16
@@ -0,0 +1,16 @@
1 # Your shells
2
3 The machine shells you hold: the live ones first (started, last input, last output, how many are attached, CPU and
4 memory when the cell reports them, a dash when it does not), then the last ten that ended and why. Read only, your
5 own account only; another person's shells are never listed.
6
7 Try it:
8 - Web: open `/settings/shells`, or press the palette and choose **Your shells**. With none, it says so in one line.
9 - Terminal: open the palette (`/`) and choose **Your shells**; `r` reads again, `esc` closes.
10 - MCP: `sessions` with `kind: "shells"` returns the same rows.
11
12 Both doors read `myShells` in `src/machines/shell.ts`. The terminal screen is `src/tui/screens/shells.ts`; the page is
13 `shellsPage` in `src/web/pages/account.ts`. Left out: ending a shell from the list, and a Settings sidebar row (the
14 settings page's first-view budget has no word to spare).
15
16 Pinned by `test/my-shells.test.ts`.
src/http/mcp/tools/sessions.ts +11 −1
@@ -1,9 +1,10 @@
11 import { accountSignIns, endAccountSession } from "../../../actions/signins.ts";
2 import { myShells } from "../../../machines/shell.ts";
23 import { good, bad, type Tool } from "../shared.ts";
34
45 export const tool: Tool = {
56 name: "sessions",
6 doc: "`{ username?, action?, id? }`: where you (or, as the forge's owner, a person) are signed in: tokens and browsers, as `GET /api/v1/users/:username/signins` lists them, never a secret; `action: \"end\"` with a session `id` signs that browser out, as `DELETE /api/v1/users/:username/sessions/:id` does, and says so when it was this one; an admin-scope token, the route's refusals",
7 doc: "`{ username?, action?, id?, kind? }`: where you (or, as the forge's owner, a person) are signed in: tokens and browsers, as `GET /api/v1/users/:username/signins` lists them, never a secret; `action: \"end\"` with a session `id` signs that browser out, as `DELETE /api/v1/users/:username/sessions/:id` does, and says so when it was this one; `kind: \"shells\"` lists your machine shells, live then ended; an admin-scope token, the route's refusals",
78 description: "Where you are signed in: your tokens and browsers, never a secret; `username` for a person as owner; action end + id signs a browser out.",
89 inputSchema: {
910 type: "object",
@@ -11,6 +12,7 @@ export const tool: Tool = {
1112 username: { type: "string", maxLength: 64 },
1213 action: { type: "string", enum: ["list", "end"] },
1314 id: { type: "string", maxLength: 64 },
15 kind: { type: "string", enum: ["signins", "shells"] },
1416 },
1517 },
1618 writes: true,
@@ -19,6 +21,14 @@ export const tool: Tool = {
1921 run(ctx, user, args) {
2022 const name = typeof args["username"] === "string" && args["username"] !== "" ? args["username"] : user.username;
2123 const action = args["action"] ?? "list";
24 const kind = args["kind"] ?? "signins";
25 if (kind !== "signins" && kind !== "shells") return bad("bad_kind", "kind is signins or shells");
26 if (kind === "shells") {
27 if (action !== "list") return bad("bad_action", "shells are listed, not ended, here");
28 if (name !== user.username) return bad("not_found", "shells are your own");
29 const shells = myShells(ctx.app, user.id);
30 return good({ shells, says: `${shells.length} shell${shells.length === 1 ? "" : "s"}.` });
31 }
2232 if (action !== "list" && action !== "end") return bad("bad_action", "action is list or end");
2333 if (action === "end") {
2434 const id = args["id"];
src/http/settings_routes.ts +2
@@ -88,6 +88,7 @@ import {
8888 } from "../web/pages/crew.ts";
8989 import {
9090 accountPage,
91 shellsPage,
9192 notifyUpdate,
9293 edgeLink,
9394 edgeUnlink,
@@ -146,6 +147,7 @@ export function settingsRoutes(router: Router<Handler>, knownPatterns: () => rea
146147 router.add("POST", "/settings/everyday/options", everydayOptions);
147148 router.add("POST", "/settings/everyday/switch", everydaySwitch);
148149 router.add("POST", "/settings/everyday/keep", everydayKeep);
150 router.add("GET", "/settings/shells", shellsPage);
149151 router.add("GET", "/settings/tokens", tokensPage);
150152 router.add("POST", "/settings/tokens", tokenCreate);
151153 router.add("POST", "/settings/tokens/:id/delete", tokenDelete);
src/machines/shell.ts +93 −2
@@ -26,7 +26,7 @@ import { cellHides } from "../spawn/hosted-mark.ts";
2626 import { join } from "node:path";
2727 import type { Database } from "bun:sqlite";
2828 import type { App } from "../app.ts";
29 import { openCell, resolveLimits, type Cell, type CellSpec } from "../apps/contain.ts";
29 import { cellCounters, openCell, resolveLimits, type Cell, type CellSpec } from "../apps/contain.ts";
3030 import { startTerminal, type TerminalProgram } from "../apps/tty.ts";
3131 import { holdOpen } from "../http/caller.ts";
3232 import { canWrite, getRepo, getRepoById, type Repo } from "../core/repos.ts";
@@ -440,7 +440,7 @@ function start(app: App, grant: ShellGrant, opts: ShellOptions, cols: number, ro
440440 app,
441441 dir,
442442 grant.machine,
443 `machine:${grant.repo.id}/${grant.machine.name}`,
443 cellLabel(grant.repo.id, grant.machine.name, grant.user.id),
444444 ownAppUrls(ownApp(app, grant.repo)),
445445 ),
446446 );
@@ -501,6 +501,97 @@ function start(app: App, grant: ShellGrant, opts: ShellOptions, cols: number, ro
501501 }
502502 }
503503
504 /** The label a shell's cell is watched under: one per person, so its counters are theirs alone. */
505 export const cellLabel = (repoId: string, machine: string, userId: string): string =>
506 `machine:${repoId}/${machine}/${userId}`;
507
508 /** One shell a person holds or held: plain data for the account page and the `sessions` tool. */
509 export interface ShellRow {
510 readonly id: string;
511 /** `owner/name`. */
512 readonly repository: string;
513 readonly machine: string;
514 readonly startedAt: string;
515 /** Null when the shell has ended: its last keystroke is not kept. */
516 readonly lastInputAt: string | null;
517 readonly lastOutputAt: string | null;
518 /** Connections attached now; 0 is detached. */
519 readonly peers: number;
520 /** Cores in use and resident MiB, null where the cell does not report them. */
521 readonly cpuCores: number | null;
522 readonly memoryMiB: number | null;
523 readonly endedAt: string | null;
524 readonly endReason: EndReason | null;
525 /** The end in words, null while live. */
526 readonly endedBecause: string | null;
527 }
528
529 const iso = (ms: number): string => new Date(ms).toISOString();
530
531 /** The shells running now for one person, newest first. */
532 export function liveShells(app: App, userId: string): ShellRow[] {
533 const counters = new Map(cellCounters().map((c) => [c.label, c]));
534 const rows: ShellRow[] = [];
535 for (const s of sessionsOf(app).values()) {
536 if (s.userId !== userId) continue;
537 const repo = getRepoById(app.db, s.repoId);
538 const started = app.db
539 .query<{ machine: string; started_at: string }, [string]>("SELECT machine, started_at FROM machine_sessions WHERE id = ?")
540 .get(s.row);
541 if (repo === undefined || started === null) continue;
542 const counter = counters.get(cellLabel(s.repoId, started.machine, userId));
543 rows.push({
544 id: s.row,
545 repository: `${repo.ownerName}/${repo.name}`,
546 machine: started.machine,
547 startedAt: started.started_at,
548 lastInputAt: iso(s.lastInput),
549 lastOutputAt: iso(s.lastOutput),
550 peers: s.peers.size,
551 cpuCores: counter?.cores ?? null,
552 memoryMiB: counter === undefined ? null : Math.round(counter.memoryBytes / 1048576),
553 endedAt: null,
554 endReason: null,
555 endedBecause: null,
556 });
557 }
558 return rows.sort((a, b) => b.startedAt.localeCompare(a.startedAt));
559 }
560
561 /** How many ended shells `myShells` adds after the live ones. */
562 export const RECENT_ENDED = 10;
563
564 /** A person's shells: the live ones, newest first, then the last ended ones, newest first. */
565 export function myShells(app: App, userId: string, ended = RECENT_ENDED): ShellRow[] {
566 const gone = app.db
567 .query<
568 { id: string; machine: string; started_at: string; ended_at: string; end_reason: EndReason; owner_name: string; name: string },
569 [string, number]
570 >(
571 `SELECT s.id, s.machine, s.started_at, s.ended_at, s.end_reason, o.username AS owner_name, r.name
572 FROM machine_sessions s JOIN repo r ON r.id = s.repo_id JOIN user o ON o.id = r.owner_id
573 WHERE s.owner_id = ? AND s.ended_at IS NOT NULL ORDER BY s.started_at DESC LIMIT ?`,
574 )
575 .all(userId, ended)
576 .map(
577 (r): ShellRow => ({
578 id: r.id,
579 repository: `${r.owner_name}/${r.name}`,
580 machine: r.machine,
581 startedAt: r.started_at,
582 lastInputAt: null,
583 lastOutputAt: null,
584 peers: 0,
585 cpuCores: null,
586 memoryMiB: null,
587 endedAt: r.ended_at,
588 endReason: r.end_reason,
589 endedBecause: ENDED[r.end_reason] ?? r.end_reason,
590 }),
591 );
592 return [...liveShells(app, userId), ...gone];
593 }
594
504595 /** End every session (the forge is stopping). */
505596 export function stopShells(app: App): void {
506597 for (const session of [...sessionsOf(app).values()]) end(app, session, "stopped");
src/tui/forge.ts +9
@@ -17,6 +17,7 @@ import { sourceFor } from "../view/source.ts";
1717 import { fleetCommand } from "./screens/fleet.ts";
1818 import { dataCommand } from "./screens/data.ts";
1919 import { packsCommand } from "./screens/packs.ts";
20 import { shellsScreen } from "./screens/shells.ts";
2021 import { forkTreeScreen } from "./screens/fork_tree.ts";
2122 import { roadmapScreen } from "./screens/roadmap.ts";
2223 import { App, type Ctx, type Screen } from "./app/app.ts";
@@ -342,6 +343,14 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] {
342343 hint: "what it closed, deferred and asked you about today",
343344 run: () => ctx.push(deciderScreen(session, ctx)),
344345 });
346 // The machine shells this person holds (#10544); an agent holds none.
347 if (session.signedIn && session.user?.kind !== "agent")
348 commands.push({
349 id: "shells",
350 title: "Your shells",
351 hint: "the machine shells you hold, live then ended",
352 run: () => ctx.push(shellsScreen(session, ctx)),
353 });
345354 // Where a decision finds the owner; an agent is never offered it.
346355 if (session.signedIn && session.user?.kind !== "agent")
347356 commands.push({
src/tui/screens/shells.ts added +51
@@ -0,0 +1,51 @@
1 /**
2 * Your shells in the terminal (#10544): the machine shells you hold, live
3 * first, then the last ended ones. Read only; the rows are `myShells`, the
4 * same the account page and MCP `sessions` (kind: shells) print.
5 */
6
7 import { myShells, type ShellRow } from "../../machines/shell.ts";
8 import type { Ctx, Screen } from "../app/app.ts";
9 import { binding } from "../app/keys.ts";
10 import { style } from "../term/style.ts";
11 import { truncate } from "../term/width.ts";
12 import type { Session } from "../session.ts";
13
14 const reading = (n: number | null, unit: string): string => (n === null ? "-" : `${n}${unit}`);
15
16 /** One shell as a line of plain words. */
17 export function shellLine(s: ShellRow): string {
18 if (s.endedAt !== null) return `${s.repository} · ${s.machine} ended ${s.endedAt.slice(0, 16).replace("T", " ")}, ${s.endedBecause}`;
19 const cpu = reading(s.cpuCores === null ? null : Math.round(s.cpuCores * 100), "%");
20 return `${s.repository} · ${s.machine} running, ${s.peers === 0 ? "nobody attached" : `${s.peers} attached`}, CPU ${cpu}, memory ${reading(s.memoryMiB, " MiB")}`;
21 }
22
23 export function shellsScreen(f: Session, ctx: Ctx): Screen {
24 let rows: ShellRow[] = f.user === undefined ? [] : myShells(f.app, f.user.id);
25 return {
26 title: "Your shells",
27 keys: [binding(["r"], "read again"), binding(["esc"], "close")],
28 render(area, s) {
29 const c = ctx.theme.c;
30 if (rows.length === 0) {
31 s.text(area.x + 1, area.y, "You have opened no shell on a machine yet.", style(c.mutedFg, c.bg), area.x + area.w);
32 return;
33 }
34 rows.forEach((row, i) => {
35 if (i < area.h) s.text(area.x + 1, area.y + i, truncate(shellLine(row), area.w - 2), style(row.endedAt === null ? c.fg : c.mutedFg, c.bg), area.x + area.w);
36 });
37 },
38 key(e) {
39 if (e.ctrl || e.alt) return false;
40 if (e.key === "r") {
41 rows = f.user === undefined ? [] : myShells(f.app, f.user.id);
42 return true;
43 }
44 if (e.key === "esc" || e.key === "q") {
45 ctx.pop();
46 return true;
47 }
48 return false;
49 },
50 };
51 }
src/web/layout.ts +1
@@ -398,6 +398,7 @@ const STATIC_PALETTE: PaletteItem[] = [
398398 { t: "Access tokens", h: "/settings/tokens", c: "Settings" },
399399 { t: "SSH keys", h: "/settings/keys", c: "Settings" },
400400 { t: "Origins", h: "/settings/origins", c: "Settings" },
401 { t: "Your shells", h: "/settings/shells", c: "Settings" },
outdated

This link is dead for anyone with no shells (see account.ts). Fix with the empty state.

401402 ];
402403
403404 /** What a signed-out visitor can reach: places to go, not Create and Settings rows that end at a sign-in wall. */
src/web/pages/account.ts +42 −1
@@ -74,6 +74,7 @@ import {
7474 field as uiField,
7575 icon,
7676 } from "../ui.ts";
77 import { myShells } from "../../machines/shell.ts";
7778 import { everydayLine } from "./everyday.ts";
7879 import { passkeyJs } from "../passkey-client.ts";
7980
@@ -97,7 +98,7 @@ const help = (notes: readonly (readonly [string, string | Html])[]): Html =>
9798 </dl>
9899 </details>`;
99100
100 type Section = "profile" | "password" | "tokens" | "keys" | "passkeys";
101 type Section = "profile" | "password" | "tokens" | "keys" | "passkeys" | "shells";
101102
102103 // The section list lives beside the newest page that joins it, so adding one
103104 // cannot leave the others pointing at a nav that has forgotten it.
@@ -393,6 +394,46 @@ function profileView(ctx: Ctx, values: ProfileForm): Html {
393394 )}`;
394395 }
395396
397 /** A reading the cell may not give: a dash, never a zero that says "idle". */
398 const reading = (n: number | null, unit: string): string => (n === null ? "–" : `${n}${unit}`);
399
400 /** The machine shells this person holds and last held (#10544). Read only; the live row says its few facts in a `dl`. */
401 export function shellsView(ctx: Ctx, user: User): Html {
402 const shells = myShells(ctx.app, user.id);
403 if (shells.length === 0) return html`<p class="muted" id="shells">You have opened no shell on a machine yet.</p>`;
404 return html`<ul class="list setting-list" id="shells">
outdated

Box 2 asks for an empty line that says so. Returning nothing leaves the palette link '/settings#shells' pointing at no anchor. Render one plain line, and fit the budget (e.g. a separate /settings/shells page).

405 ${shells.map((s) =>
406 s.endedAt === null
407 ? row({
408 title: html`${s.repository} · ${s.machine}`,
409 meta: html`Started ${time(s.startedAt)}`,
410 trail: badge("running", { tone: "success" }),
outdated

Principle 1: the ● glyph and the 'running' badge say the same thing. Keep one.

411 facts: [
412 { label: "Last input", value: s.lastInputAt ? time(s.lastInputAt) : "–" },
413 { label: "Last output", value: s.lastOutputAt ? time(s.lastOutputAt) : "–" },
414 { label: "Attached", value: s.peers === 0 ? "nobody" : String(s.peers) },
outdated

Principles 7 and 8: seven facts joined by dots in one line is hard to read. Show the few that matter in the row and put the rest in a dl row; use plain words.

415 { label: "CPU", value: reading(s.cpuCores === null ? null : Math.round(s.cpuCores * 100), "%") },
416 { label: "Memory", value: reading(s.memoryMiB, " MiB") },
417 ],
418 data: { session: s.id },
419 })
420 : row({
421 title: html`${s.repository} · ${s.machine}`,
422 meta: html`Ended ${time(s.endedAt)} · ${s.endedBecause}`,
423 data: { session: s.id },
424 }),
425 )}
426 </ul>`;
427 }
428
429 // GET /settings/shells
430 export function shellsPage(ctx: Ctx): Response {
431 if (!ctx.auth.user) return signInFirst(ctx);
432 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
433 const body = shellsView(ctx, ctx.auth.user);
434 return timePhase(ctx.request, "render", () => accountShell(ctx, "shells", "Shells", body));
435 }
436
396437 // GET /settings
397438 export function accountPage(ctx: Ctx): Response {
398439 if (!ctx.auth.user) return signInFirst(ctx);
src/web/pages/origins.ts +1
@@ -93,6 +93,7 @@ export type AccountSection =
9393 | "confirms"
9494 | "password"
9595 | "tokens"
96 | "shells"
9697 | "keys"
9798 | "model"
9899 | "passkeys"
test/my-shells.test.ts added +117
@@ -0,0 +1,117 @@
1 /**
2 * Your shells (#10544): the read returns a live session with its real start
3 * and last-input times; the account page lists it, escaped, with the ended
4 * ones after it; `sessions` with `kind: "shells"` returns the same rows.
5 */
6 import { afterAll, beforeAll, expect, test } from "bun:test";
7 import { mkdtempSync } from "node:fs";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createApp, type App } from "../src/app.ts";
11 import { createRepo, type Repo } from "../src/core/repos.ts";
12 import { createSession } from "../src/core/sessions.ts";
13 import { createUser } from "../src/core/users.ts";
14 import { serve, type Servers } from "../src/http/server.ts";
15 import { liveShells, myShells, recordEnd, recordStart, setShellOptions, stopShells } from "../src/machines/shell.ts";
16 import { tokenFor } from "./helpers/tokens.ts";
17 import { quietLogs } from "./helpers/globals.ts";
18
19 quietLogs();
20
21 let app: App;
22 let servers: Servers;
23 let repo: Repo;
24 let aliceId: string;
25 let cookie: string;
26
27 beforeAll(async () => {
28 app = createApp({ dataDir: mkdtempSync(join(tmpdir(), "my-shells-")), port: 0 });
29 setShellOptions(app, { idleMs: 60_000, contain: "uncontained", command: "exec /bin/sh", handshakesPerMinute: 1000 });
30 const made = await createUser(app.db, { username: "alice", email: "[email protected]", password: "correct horse battery", origin: "seed", isAdmin: true, trusted: true });
31 if (!made.ok) throw new Error(String(made.error));
32 aliceId = made.value.id;
33 cookie = `forge_session=${createSession(app.db, aliceId).token}`;
34 const r = await createRepo(app.db, app.reposRoot, { ownerId: aliceId, name: "lab", visibility: "public" });
35 if (!r.ok) throw new Error(String(r.error));
36 repo = r.value;
37 servers = await serve(app);
38 });
39
40 afterAll(() => {
41 stopShells(app);
42 servers.stop();
43 app.close();
44 });
45
46 const page = async () => (await fetch(`${servers.url}/settings/shells`, { headers: { cookie } })).text();
47
48 async function mcpShells(): Promise<any> {
49 const res = await fetch(`${servers.url}/mcp`, {
50 method: "POST",
51 headers: {
52 "content-type": "application/json",
53 accept: "application/json, text/event-stream",
54 authorization: `Bearer ${tokenFor(app, "alice", "admin")}`,
55 },
56 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "sessions", arguments: { kind: "shells" } } }),
57 });
58 const body = (await res.json()) as any;
59 return JSON.parse(body.result.content[0].text);
60 }
61
62 test("with no shell the page says so in one line", async () => {
63 expect(myShells(app, aliceId)).toEqual([]);
64 const html = await page();
65 expect(html).toContain("You have opened no shell on a machine yet.");
66 expect((await mcpShells()).shells).toEqual([]);
67 });
68
69 test("a live shell is read with its real times; ended ones follow, escaped, on the page and in the tool", async () => {
70 const before = Date.now();
71 const ws = new WebSocket(`${servers.url.replace(/^http/, "ws")}/alice/lab/machines/standard/shell`, {
72 headers: { cookie, origin: new URL(servers.url).origin },
73 } as unknown as string[]);
74 await new Promise((ok, no) => {
75 ws.addEventListener("open", ok);
76 ws.addEventListener("error", () => no(new Error("refused")));
77 });
78 ws.send(new TextEncoder().encode("echo hi\n"));
79 await Bun.sleep(200);
80
81 // An ended shell, its machine name hostile: the page must escape it.
82 const old = recordStart(app.db, repo.id, "<b>x</b>", aliceId);
83 recordEnd(app.db, old, "idle");
84
85 const live = liveShells(app, aliceId);
86 expect(live).toHaveLength(1);
87 const s = live[0]!;
88 expect(s).toMatchObject({ repository: "alice/lab", machine: "standard", peers: 1, endedAt: null });
89 expect(Date.parse(s.startedAt)).toBeGreaterThanOrEqual(before - 1000);
90 expect(Date.parse(s.lastInputAt!)).toBeGreaterThanOrEqual(Date.parse(s.startedAt));
91 // No cell here, so no reading: a dash, not a zero.
92 expect(s.cpuCores).toBeNull();
93 expect(s.memoryMiB).toBeNull();
94
95 const rows = myShells(app, aliceId);
96 expect(rows.map((r) => r.endedAt === null)).toEqual([true, false]);
97 expect(rows[1]).toMatchObject({ endReason: "idle", endedBecause: "idle too long" });
98
99 const html = await page();
100 expect(html).toContain("alice/lab · standard");
101 expect(html).toMatch(/<dt>Attached<\/dt><dd>1<\/dd>/);
102 expect(html).toMatch(/<dt>CPU<\/dt><dd>–<\/dd>/);
103 // One marker for "running", not a glyph and a badge.
104 expect(html.match(/running/g)?.length).toBe(1);
105 expect(html).toContain("idle too long");
106 expect(html).not.toContain("<b>x</b>");
107 expect(html).toContain("&lt;b&gt;x&lt;/b&gt;");
108 expect(html.indexOf("alice/lab · standard")).toBeLessThan(html.indexOf("idle too long"));
109
110 expect((await mcpShells()).shells).toEqual(JSON.parse(JSON.stringify(rows)));
111
112 // The palette reaches it.
113 expect(html).toContain("Your shells");
114 expect(html).toContain("/settings/shells");
115
116 ws.close();
117 });
test/palette-roster.test.ts +2
@@ -80,6 +80,7 @@ test("a site-admin person is offered the whole roster", () => {
8080 ["asked", "Asked", "your thoughts and what became of them"],
8181 ["thought-notes", "Notes", "write an idea and watch it become shipped work"],
8282 ["decider", "Decider", "what it closed, deferred and asked you about today"],
83 ["shells", "Your shells", "the machine shells you hold, live then ended"],
8384 ["notify", "Phone notifications", "where the crew reaches your phone; s sets, x turns off"],
8485 ["confirms", "Confirms", "what the forge's agent asked to do; y does it once"],
8586 ["trusted", "Trusted sources", "who folds in without review; a trusts, x removes, s stops all, v reverts"],
@@ -275,6 +276,7 @@ test("a member person is offered the signed-in roster without the admin entries"
275276 ["asked", "Asked", "your thoughts and what became of them"],
276277 ["thought-notes", "Notes", "write an idea and watch it become shipped work"],
277278 ["decider", "Decider", "what it closed, deferred and asked you about today"],
279 ["shells", "Your shells", "the machine shells you hold, live then ended"],
278280 ["notify", "Phone notifications", "where the crew reaches your phone; s sets, x turns off"],
279281 ["confirms", "Confirms", "what the forge's agent asked to do; y does it once"],
280282 ["conversations", "Conversations", "your talks with the forge's agent; n starts one, r renames, x archives"],
test/pull-quiet.test.ts +2 −2
@@ -66,8 +66,8 @@ afterAll(async () => {
6666 const get = async (path: string) => (await fetch(`${servers.url}${path}`, { headers: { cookie } })).text();
6767 const bytes = (t: string) => new TextEncoder().encode(t).length;
6868
69 // Measured 31,084 (pull) and 25,064 (list) with the Displays entry in the account menu and palette. Each budget is that plus ~160 B, the spread the port digits and times in the page showed, so it holds the page from growing and is not a tighter squeeze.
70 const BUDGET = { pull: 31_250, list: 25_230 };
69 // Measured 31,130 (pull; 31,084 before the Your shells palette entry) and 25,064 (list) with the Displays entry in the account menu and palette. Each budget is that plus ~160 B, the spread the port digits and times in the page showed, so it holds the page from growing and is not a tighter squeeze.
70 const BUDGET = { pull: 31_400, list: 25_400 };
7171
7272 test("the pull page's check is a line with a dot and its word, not a card around a badge", async () => {
7373 const text = await get(`/alice/r/pulls/${number}`);
test/tui-my-shells.test.ts added +64
@@ -0,0 +1,64 @@
1 /** Your shells in the terminal (#10544): the palette opens it; own rows only; a person with none is told so. */
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/core/repos.ts";
8 import { createUser, getUserByName } from "../src/core/users.ts";
9 import { recordEnd, recordStart } from "../src/machines/shell.ts";
10 import { createForgeApp } from "../src/tui/forge.ts";
11 import { headlessTerminal } from "../src/tui/term/terminal.ts";
12 import { quietLogs } from "./helpers/globals.ts";
13 import { settleApp } from "./helpers/tui.ts";
14
15 quietLogs();
16
17 let dir: string;
18 let app: App;
19
20 beforeAll(async () => {
21 dir = await mkdtemp(join(tmpdir(), "tui-my-shells-"));
22 app = createApp({ dataDir: dir, port: 0 });
23 for (const name of ["alice", "bob"]) {
24 const u = await createUser(app.db, { username: name, email: `${name}@x.test`, password: "correct horse battery", origin: "seed", isAdmin: false, trusted: true });
25 if (!u.ok) throw new Error(String(u.error));
26 }
27 const alice = getUserByName(app.db, "alice")!;
28 const r = await createRepo(app.db, app.reposRoot, { ownerId: alice.id, name: "lab", visibility: "public" });
29 if (!r.ok) throw new Error(String(r.error));
30 recordEnd(app.db, recordStart(app.db, r.value.id, "standard", alice.id), "idle");
31 });
32
33 afterAll(async () => {
34 app.close();
35 await rm(dir, { recursive: true, force: true });
36 });
37
38 async function shellsScreenText(username: string): Promise<string> {
39 const { app: tui } = createForgeApp(
40 app,
41 { sshUsername: "t", remoteAddress: "127.0.0.1", user: getUserByName(app.db, username)! },
42 { terminal: headlessTerminal(140, 32), exitHooks: false },
43 );
44 tui.start();
45 const key = async (k: string, ctrl = false) => {
46 tui.dispatch({ type: "key", key: k, ctrl, alt: false, shift: false });
47 await settleApp(tui);
48 };
49 await key("k", true);
50 for (const ch of "Your shells") await key(ch);
51 await key("enter");
52 const text = tui.screen.toText();
53 tui.stop();
54 return text;
55 }
56
57 test("the owner sees the ended shell and why; another person is told they have none", async () => {
58 const mine = await shellsScreenText("alice");
59 expect(mine).toContain("alice/lab · standard");
60 expect(mine).toContain("idle too long");
61 const theirs = await shellsScreenText("bob");
62 expect(theirs).toContain("You have opened no shell on a machine yet.");
63 expect(theirs).not.toContain("alice/lab");
64 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs