@a-t-h-i/bot-lobby 0.6.11 → 0.6.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +26 -3
  2. package/package.json +31 -4
  3. package/src/ask/tool.ts +2 -1
  4. package/src/lobby/keys.ts +14 -1
  5. package/src/lobby/mini.ts +9 -1
  6. package/src/lobby/models/metrics.ts +14 -0
  7. package/src/lobby/planner.ts +1 -1
  8. package/src/lobby/prompt-hub.ts +161 -0
  9. package/src/lobby/prompts.ts +35 -0
  10. package/src/lobby/runtime.ts +115 -566
  11. package/src/lobby/service.ts +655 -0
  12. package/src/lobby/tabs/home.ts +1 -1
  13. package/src/lobby/tabs/metrics.ts +4 -6
  14. package/src/lobby/topics.ts +78 -0
  15. package/src/lobby/view.ts +29 -24
  16. package/src/pi/commands.ts +8 -2
  17. package/src/pi/owner.ts +2 -0
  18. package/src/pi/settings-ui.ts +52 -1
  19. package/src/pi/tools.ts +6 -4
  20. package/src/roles/reviewer.ts +9 -1
  21. package/src/schemas/configuration.ts +33 -1
  22. package/src/state/changes.ts +1 -1
  23. package/src/state/persistence.ts +1 -1
  24. package/src/state/presence.ts +2 -0
  25. package/src/webui/api/excalidraw.ts +105 -0
  26. package/src/webui/api/gh-error.ts +17 -0
  27. package/src/webui/api/git.ts +98 -0
  28. package/src/webui/api/index.ts +379 -0
  29. package/src/webui/api/issues.ts +50 -0
  30. package/src/webui/api/knowledge.ts +79 -0
  31. package/src/webui/api/lobby.ts +91 -0
  32. package/src/webui/api/metrics.ts +63 -0
  33. package/src/webui/api/plan-facts.ts +29 -0
  34. package/src/webui/api/planner.ts +98 -0
  35. package/src/webui/api/plans.ts +44 -0
  36. package/src/webui/api/prompts.ts +35 -0
  37. package/src/webui/api/quickfix.ts +48 -0
  38. package/src/webui/api/sessions.ts +114 -0
  39. package/src/webui/api/settings.ts +93 -0
  40. package/src/webui/api/status.ts +55 -0
  41. package/src/webui/api/tasks.ts +204 -0
  42. package/src/webui/auth.ts +153 -0
  43. package/src/webui/command.ts +152 -0
  44. package/src/webui/dev/fake-service.ts +546 -0
  45. package/src/webui/dev/fixtures.ts +180 -0
  46. package/src/webui/dev/serve.ts +54 -0
  47. package/src/webui/events.ts +207 -0
  48. package/src/webui/files.ts +88 -0
  49. package/src/webui/notices.ts +61 -0
  50. package/src/webui/open.ts +35 -0
  51. package/src/webui/protocol.ts +565 -0
  52. package/src/webui/server.ts +315 -0
  53. package/src/webui/static.ts +125 -0
  54. package/src/workflow/workflow.ts +22 -6
  55. package/webui/dist/assets/index-Bk-lpAGm.css +2 -0
  56. package/webui/dist/assets/index-E6MPcbdC.js +80 -0
  57. package/webui/dist/build.json +494 -0
  58. package/webui/dist/icon-192.png +0 -0
  59. package/webui/dist/icon-512.png +0 -0
  60. package/webui/dist/index.html +18 -0
  61. package/webui/dist/manifest.webmanifest +36 -0
  62. package/webui/dist/offline.html +57 -0
  63. package/webui/dist/sw.js +88 -0
  64. package/webui/dist/vite.svg +1 -0
package/README.md CHANGED
@@ -1,6 +1,9 @@
1
1
  # Bot-Lobby
2
2
 
3
- A [Pi](https://pi.dev) extension that turns Pi into a multi-agent software team.
3
+ A [Pi](https://pi.dev) extension that forces you to vibe code intentionally.
4
+ You still describe what you want in plain words, but you don't just hand it
5
+ over: you and the model work out together what's needed until the picture is
6
+ clear, and then a multi-agent software team builds what you agreed on.
4
7
 
5
8
  ![The Lobby tab: your conversation with the oracle, the activity log of every agent, and their latest thoughts](https://raw.githubusercontent.com/a-t-h-i/bot-lobby/main/docs/gallery.png)
6
9
 
@@ -12,8 +15,9 @@ work to three domain agents — **Designer+Frontend**, **Backend** and **QA**
12
15
  each running in its own isolated `pi` process. QA's reviewer is the quality
13
16
  gate before anything is marked done.
14
17
 
15
- The rule: **LLMs decide, the engine enforces.** Agents propose; the
16
- extension validates every state change, permission and approval through one
18
+ The rule: **you decide with the LLMs, and the engine enforces.** Agents
19
+ propose; you shape and approve the plan with the oracle; the extension
20
+ validates every state change, permission and approval through one
17
21
  `orchestrate` tool.
18
22
 
19
23
  The oracle is meant to be your most capable model; the agents can be smaller
@@ -61,6 +65,10 @@ extra instructions.
61
65
  | `/bot-lobby settings \| config` | Edit settings / show the effective config |
62
66
  | `/bot-lobby knowledge` | Knowledge file sizes |
63
67
  | `/bot-lobby minimize \| restore` | Hide bot-lobby in this session (`ctrl+shift+m`) |
68
+ | `/bot-lobby web` | Start the browser UI on this machine (loopback only) and open its link |
69
+ | `/bot-lobby web link` | Print the browser UI's link |
70
+ | `/bot-lobby web stop` | Stop the browser UI |
71
+ | `/bot-lobby web reset` | Reset the browser UI's link (its old cookies stop working) |
64
72
 
65
73
  ## Quick fix or the team
66
74
 
@@ -325,6 +333,14 @@ knowledge, standards, decisions and completed tasks — files on the left, the
325
333
  open file's entries on the right (a heading, a bullet, a paragraph), one of
326
334
  them picked. Files past the compaction threshold are marked.
327
335
 
336
+ Completion is gated on compaction: the engine refuses `complete` while any
337
+ knowledge file is over the threshold (default 20,000 chars,
338
+ `knowledge.compactionThreshold`). The Master first disperses domain-relevant
339
+ facts where they belong with `action=knowledge (domain=designer|backend|qa)`
340
+ — per-agent files fill up through that dispersal — then rewrites each
341
+ oversized file with `action=compact` (which archives the previous version)
342
+ before the task closes.
343
+
328
344
  - `e` edits the picked entry: it comes into the prompt (Shift+Enter for a new
329
345
  line, Enter saves). `n` adds an entry after it, `d d` deletes it, `E` edits
330
346
  the whole file in pi's editor. Every write archives the version before
@@ -361,6 +377,12 @@ planner), and those agents can look at what you drew and add to it.
361
377
  sessions are all look-only gets no drawing tool.
362
378
  - `t` **checks** the session: joins the room for a moment and says whether the
363
379
  server can be reached, who is in it, and how much is on the board. `r` renames it.
380
+ - `orchestrate action=whiteboard [name=...]` lets the Master create its own
381
+ session: a room on the configured collaboration server, assigned to itself,
382
+ with the join link returned. Its seat (opened by `excalidraw_read` /
383
+ `excalidraw_draw`) holds the room alive for the pi session's lifetime, so it
384
+ needs no one else in the room. The room is not guaranteed to persist after
385
+ the last connection leaves.
364
386
 
365
387
  **What an assigned agent can do.** It gets two tools and the room link:
366
388
  `excalidraw_read` describes the board in words (shapes with their labels, arrows
@@ -647,6 +669,7 @@ unavailable, it runs again on the fallback instead of failing the task.
647
669
  | Scouts and the QA gate can't edit code | Scouts get read-only tools; the QA gate adds only `bash` for tests |
648
670
  | New dependencies and architecture changes need approval | Parsed from worker reports; the domain is blocked until resolved |
649
671
  | Only the Master writes knowledge | Agents can only propose it |
672
+ | Knowledge is compacted before done | `complete` is refused while any knowledge file is over the threshold; disperse with `action=knowledge (domain=designer\|backend\|qa)`, then `action=compact` |
650
673
  | "Done" is earned | Needs a plan, a passing QA gate that ran checks (or your explicit acceptance), and no open blockers; on the fast track, a finished worker step, and QA's part only when the change needs tests |
651
674
  | Parallel workers don't clobber files | Edits need a file-desk claim |
652
675
  | A crash doesn't corrupt a task | State is on disk; tasks resume from their state |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a-t-h-i/bot-lobby",
3
- "version": "0.6.11",
3
+ "version": "0.6.13",
4
4
  "description": "Structured multi-agent software engineering orchestrator for Pi",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -25,7 +25,8 @@
25
25
  },
26
26
  "files": [
27
27
  "src",
28
- "prompts"
28
+ "prompts",
29
+ "webui/dist"
29
30
  ],
30
31
  "publishConfig": {
31
32
  "access": "public"
@@ -37,7 +38,11 @@
37
38
  "image": "https://raw.githubusercontent.com/a-t-h-i/bot-lobby/main/docs/gallery.png"
38
39
  },
39
40
  "scripts": {
40
- "typecheck": "tsc --noEmit",
41
+ "typecheck": "tsc --noEmit && tsc -p webui --noEmit",
42
+ "web:dev": "node src/webui/dev/serve.ts",
43
+ "web:build": "vite build --config webui/vite.config.ts && node webui/scripts/build-info.mjs",
44
+ "web:typecheck": "tsc -p webui --noEmit",
45
+ "web:check": "playwright test",
41
46
  "test": "node --test --test-reporter=dot test/*.test.ts"
42
47
  },
43
48
  "peerDependencies": {
@@ -50,10 +55,32 @@
50
55
  "@earendil-works/pi-ai": "0.87.0",
51
56
  "@earendil-works/pi-coding-agent": "0.87.0",
52
57
  "@earendil-works/pi-tui": "0.87.0",
58
+ "@axe-core/playwright": "4.13.0",
59
+ "@playwright/test": "1.62.0",
60
+ "@shadcn/react": "0.3.1",
61
+ "@tailwindcss/vite": "4.3.3",
53
62
  "@types/node": "^22.10.0",
63
+ "@types/react": "19.3.0",
64
+ "@types/react-dom": "19.3.0",
65
+ "@vitejs/plugin-react": "6.1.1",
66
+ "class-variance-authority": "0.7.1",
67
+ "clsx": "2.1.1",
68
+ "lucide-react": "1.49.0",
69
+ "radix-ui": "1.6.7",
70
+ "react": "19.3.0",
71
+ "react-dom": "19.3.0",
72
+ "react-markdown": "10.1.0",
73
+ "recharts": "3.10.1",
74
+ "remark-gfm": "4.0.1",
75
+ "shiki": "4.5.0",
54
76
  "socket.io": "4.8.4",
77
+ "sonner": "2.0.8",
78
+ "tailwind-merge": "3.7.0",
79
+ "tailwindcss": "4.3.3",
80
+ "tw-animate-css": "1.4.0",
55
81
  "typebox": "1.3.27",
56
- "typescript": "^5.7.0"
82
+ "typescript": "^5.7.0",
83
+ "vite": "8.3.2"
57
84
  },
58
85
  "dependencies": {
59
86
  "https-proxy-agent": "^9.1.0",
package/src/ask/tool.ts CHANGED
@@ -11,6 +11,7 @@ import { Container, Text } from "@earendil-works/pi-tui";
11
11
  import { Type } from "typebox";
12
12
  import { isSubagentProcess } from "../pi/quiet.ts";
13
13
  import { askUser, type Asker } from "./dialog.ts";
14
+ import { promptHub } from "../lobby/prompt-hub.ts";
14
15
  import { isImagePath } from "./image.ts";
15
16
  import { relayAsker, relayEnabled } from "./relay.ts";
16
17
  import { ASK_TOOL, MAX_HEADER, MAX_LABEL, MAX_OPTIONS, MAX_QUESTIONS, MIN_OPTIONS, RESERVED, type AskQuestion, type AskResult } from "./types.ts";
@@ -114,7 +115,7 @@ export function registerAskTool(pi: ExtensionAPI, ask?: Asker): void {
114
115
  const questions = (params as { questions: AskQuestion[] }).questions;
115
116
  const invalid = invalidQuestions(questions);
116
117
  if (invalid) throw new Error(`${invalid}.`);
117
- const result = await asker(questions, ctx, signal);
118
+ const result = await promptHub.run("questionnaire", "oracle", { questions }, () => asker(questions, ctx, signal), { signal });
118
119
  return { content: [{ type: "text", text: answerSummary(questions, result) }], details: result };
119
120
  },
120
121
  renderCall(args, theme) {
package/src/lobby/keys.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * (`{ "toggleThinking": "alt+t" }`). They work in typing and browsing mode
5
5
  * alike, so each default is a key that never types a character.
6
6
  */
7
- import { matchesKey, type KeyId } from "@earendil-works/pi-tui";
7
+ import { Key, matchesKey, type KeyId } from "@earendil-works/pi-tui";
8
8
 
9
9
  export const LOBBY_ACTIONS = {
10
10
  hide: { key: "alt+l", help: "hide the lobby (back to pi)" },
@@ -37,6 +37,19 @@ export function keyMap(overrides: Readonly<Record<string, string>> = {}): KeyMap
37
37
  return map;
38
38
  }
39
39
 
40
+ /** Alt+digit jumps to the tab at `index`; the same key the TUI matches. */
41
+ export function tabJumpKey(index: number): KeyId {
42
+ return Key.alt(String(index + 1) as "1");
43
+ }
44
+
45
+ /** `keyMap` with the web-only tab cycling defaults; user overrides win. */
46
+ export function webKeyMap(overrides: Readonly<Record<string, string>> = {}): KeyMap {
47
+ const map = keyMap(overrides);
48
+ if (!overrides.nextTab?.trim()) map.nextTab = "alt+]";
49
+ if (!overrides.prevTab?.trim()) map.prevTab = "alt+[";
50
+ return map;
51
+ }
52
+
40
53
  /** The action `data` triggers under `map`, if any. */
41
54
  export function actionFor(data: string, map: KeyMap): LobbyAction | undefined {
42
55
  for (const action of Object.keys(map) as LobbyAction[]) {
package/src/lobby/mini.ts CHANGED
@@ -36,6 +36,8 @@ export interface MiniInput {
36
36
  runs: readonly AgentRun[];
37
37
  planning?: MiniPlanning;
38
38
  quickfix?: MiniQuickFix;
39
+ /** The loopback web UI's link, while its server runs. */
40
+ webLink?: string;
39
41
  /** The key that opens the lobby, as shown. */
40
42
  key: string;
41
43
  }
@@ -107,10 +109,16 @@ function quickFixSegment(quickfix: MiniQuickFix | undefined, theme?: LobbyTheme)
107
109
  return `${paint(theme, "accent", "quick fix")} ${paint(theme, "success", quickfix.running ? "◐" : "○")} ${paint(theme, "muted", quickfix.title)}${paint(theme, "dim", queued)}`;
108
110
  }
109
111
 
112
+ /** The web link, while the server runs; pure: the link arrives as input. */
113
+ function webSegment(link: string | undefined, theme?: LobbyTheme): string | undefined {
114
+ if (!link) return undefined;
115
+ return paint(theme, "dim", link);
116
+ }
117
+
110
118
  /** The status line, at most `width` columns. */
111
119
  export function miniLine(input: MiniInput, width: number, theme?: LobbyTheme): string {
112
120
  if (width < 12) return "";
113
- const segments = [taskSegment(input, theme), planningSegment(input.planning, theme), quickFixSegment(input.quickfix, theme)].filter((segment): segment is string => Boolean(segment));
121
+ const segments = [taskSegment(input, theme), planningSegment(input.planning, theme), quickFixSegment(input.quickfix, theme), webSegment(input.webLink, theme)].filter((segment): segment is string => Boolean(segment));
114
122
  const body = segments.length > 0 ? segments.join(paint(theme, "dim", " │ ")) : paint(theme, "dim", "idle");
115
123
  const hint = paint(theme, "dim", ` ${input.key} opens`);
116
124
  const line = ` ${paint(theme, "accent", "◆ bot-lobby")} ${body}`;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Pure metrics data helpers shared by the terminal tab and the web API.
3
+ * This module touches neither the terminal nor pi-tui (unlike
4
+ * `../tabs/metrics.ts`, which draws through `layout.ts`), so the loopback
5
+ * server can import it freely.
6
+ */
7
+ import type { MetricRecord } from "../../state/metrics.ts";
8
+
9
+ /** Runs whose agent, model, thinking level, kind, status or task mention `query`. */
10
+ export function filterRecords(records: readonly MetricRecord[], query: string | undefined): MetricRecord[] {
11
+ const needle = query?.trim().toLowerCase();
12
+ if (!needle) return [...records];
13
+ return records.filter((record) => [record.agent, record.model ?? "", record.thinking ?? "", record.kind, record.status, record.taskId ?? ""].join(" ").toLowerCase().includes(needle));
14
+ }
@@ -27,7 +27,7 @@ import { describeToolCall } from "../pi/activity.ts";
27
27
  import { appendMetrics, type MetricRecord } from "../state/metrics.ts";
28
28
  import { savePlannedTask, type IssueRef, type PlannedTask } from "../state/backlog.ts";
29
29
  import { planSteps } from "../pi/plan-checklist.ts";
30
- import { KEEP_WHOLE, MAX_SPLIT_REVISIONS, partBrief, partInfo, parseSplit, splitFailedQuestion, splitLabel, splitProblems, splitPrompt, splitQuestion, splitRequest, type SplitProposal } from "./split.ts";
30
+ import { KEEP_WHOLE, MAX_SPLIT_REVISIONS, partBrief, partInfo, parseSplit, splitFailedQuestion, splitProblems, splitPrompt, splitQuestion, splitRequest, type SplitProposal } from "./split.ts";
31
31
  import type { AskQuestion, AskResult } from "../ask/types.ts";
32
32
  import { PANEL_MEMBERS, type PanelMember } from "../schemas/configuration.ts";
33
33
  import { truncate } from "../text.ts";
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Bot-lobby's questions answerable from several surfaces, with the first
3
+ * answer winning. A surface registers (`terminal` today; the web page later);
4
+ * `open` shows the prompt on every surface, `answer` settles it once, and a
5
+ * cancelled caller's `signal` withdraws every surface. The terminal drives
6
+ * its prompts through `run`, which wraps today's code unchanged.
7
+ */
8
+ import { lobbyTopics } from "./topics.ts";
9
+
10
+ export type PromptKind = "questionnaire" | "choose" | "confirm" | "text" | "sessionDialog";
11
+
12
+ /** A pending question in the shape the later web API lists. */
13
+ export interface WebPrompt {
14
+ id: string;
15
+ kind: PromptKind;
16
+ createdAt: number;
17
+ from: string;
18
+ payload: unknown;
19
+ }
20
+
21
+ /** One surface a prompt shows on; `withdraw` takes it back off screen. */
22
+ export interface PromptSurface {
23
+ name: string;
24
+ show(prompt: WebPrompt): void;
25
+ withdraw(id: string): void;
26
+ }
27
+
28
+ interface Pending {
29
+ prompt: WebPrompt;
30
+ }
31
+
32
+ interface Outcome {
33
+ id: string;
34
+ value: unknown;
35
+ how: "answered" | "dismissed" | "cancelled";
36
+ }
37
+
38
+ /** How many settled prompts are remembered (late answers report 409 off this). */
39
+ const MAX_OUTCOMES = 50;
40
+
41
+ function nextId(seq: number): string {
42
+ return `p${seq}-${Date.now().toString(36)}`;
43
+ }
44
+
45
+ function showOn(surfaces: Iterable<PromptSurface>, prompt: WebPrompt): void {
46
+ for (const surface of surfaces) surface.show(prompt);
47
+ }
48
+
49
+ function withdrawFrom(surfaces: Iterable<PromptSurface>, id: string): void {
50
+ for (const surface of surfaces) surface.withdraw(id);
51
+ }
52
+
53
+ /** Questions from several surfaces; the first answer wins. */
54
+ export class PromptHub {
55
+ private seq = 0;
56
+ private readonly pendingMap = new Map<string, Pending>();
57
+ private readonly surfaces = new Map<string, PromptSurface>();
58
+ private readonly outcomes: Outcome[] = [];
59
+
60
+ /** Put a surface on show duty; returns its unregister function. */
61
+ registerSurface(surface: PromptSurface): () => void {
62
+ this.surfaces.set(surface.name, surface);
63
+ for (const entry of this.pendingMap.values()) surface.show(entry.prompt);
64
+ return () => {
65
+ this.surfaces.delete(surface.name);
66
+ };
67
+ }
68
+
69
+ /** The questions still waiting for an answer, oldest first. */
70
+ pending(): WebPrompt[] {
71
+ return [...this.pendingMap.values()].map((entry) => entry.prompt);
72
+ }
73
+
74
+ /** Show a prompt on every surface; the answer settles it later. */
75
+ open(kind: PromptKind, from: string, payload: unknown): WebPrompt {
76
+ this.seq += 1;
77
+ const prompt: WebPrompt = { id: nextId(this.seq), kind, createdAt: Date.now(), from, payload };
78
+ this.pendingMap.set(prompt.id, { prompt });
79
+ showOn(this.surfaces.values(), prompt);
80
+ lobbyTopics.bump("prompts");
81
+ return prompt;
82
+ }
83
+
84
+ /**
85
+ * Settle `id` with `value`. True when this call won; false when the prompt
86
+ * is already settled or unknown (the later web API answers 409 then).
87
+ */
88
+ answer(id: string, value: unknown): boolean {
89
+ if (!this.pendingMap.has(id)) return false;
90
+ this.settle(id, value, "answered");
91
+ return true;
92
+ }
93
+
94
+ /** Take `id` back without an answer; false when already settled or unknown. */
95
+ dismiss(id: string): boolean {
96
+ if (!this.pendingMap.has(id)) return false;
97
+ this.settle(id, undefined, "dismissed");
98
+ return true;
99
+ }
100
+
101
+ /** What settled `id`, if it is still remembered. */
102
+ outcome(id: string): Outcome | undefined {
103
+ return this.outcomes.find((entry) => entry.id === id);
104
+ }
105
+
106
+ private settle(id: string, value: unknown, how: Outcome["how"]): void {
107
+ this.pendingMap.delete(id);
108
+ withdrawFrom(this.surfaces.values(), id);
109
+ this.outcomes.push({ id, value, how });
110
+ if (this.outcomes.length > MAX_OUTCOMES) this.outcomes.splice(0, this.outcomes.length - MAX_OUTCOMES);
111
+ lobbyTopics.bump("prompts");
112
+ }
113
+
114
+ /**
115
+ * Run today's terminal code for a prompt: it shows on every surface, the
116
+ * terminal's `show` answers it, and a cancelled `signal` withdraws them
117
+ * all. With only the terminal registered this is exactly today's behaviour.
118
+ */
119
+ async run<T>(kind: PromptKind, from: string, payload: unknown, show: (prompt: WebPrompt) => Promise<T>, options?: { signal?: AbortSignal }): Promise<T> {
120
+ const prompt = this.open(kind, from, payload);
121
+ const signal = options?.signal;
122
+ if (signal?.aborted) return this.cancelled(prompt.id, show, prompt);
123
+ const onAbort = () => this.dismiss(prompt.id);
124
+ signal?.addEventListener("abort", onAbort, { once: true });
125
+ try {
126
+ const value = await show(prompt);
127
+ return this.claim(prompt.id, value);
128
+ } finally {
129
+ signal?.removeEventListener("abort", onAbort);
130
+ }
131
+ }
132
+
133
+ /** The caller was already cancelled: withdraw every surface, keep today's result. */
134
+ private async cancelled<T>(id: string, show: (prompt: WebPrompt) => Promise<T>, prompt: WebPrompt): Promise<T> {
135
+ this.pendingMap.delete(id);
136
+ withdrawFrom(this.surfaces.values(), id);
137
+ lobbyTopics.bump("prompts");
138
+ return this.remember(id, await show(prompt));
139
+ }
140
+
141
+ /** Claim the win for `value`, unless another surface answered first. */
142
+ private claim<T>(id: string, value: T): T {
143
+ if (!this.pendingMap.has(id)) return this.remembered(id, value);
144
+ this.settle(id, value, "answered");
145
+ return value;
146
+ }
147
+
148
+ private remember<T>(id: string, value: T): T {
149
+ if (!this.outcome(id)) this.outcomes.push({ id, value, how: "cancelled" });
150
+ lobbyTopics.bump("prompts");
151
+ return value;
152
+ }
153
+
154
+ private remembered<T>(id: string, fallback: T): T {
155
+ const outcome = this.outcome(id);
156
+ return (outcome?.value ?? fallback) as T;
157
+ }
158
+ }
159
+
160
+ /** The process-wide hub; the terminal surface registers at lobby start. */
161
+ export const promptHub = new PromptHub();
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Strings the terminal lobby and the web lobby share: tab metadata and the
3
+ * short status lines shown while a question is answered elsewhere. Pure on
4
+ * purpose (no `node:*`, no `@earendil-works/pi-tui`): the web page imports
5
+ * this file directly.
6
+ */
7
+
8
+ export const TAB_IDS = ["lobby", "tasks", "plan", "quickfix", "issues", "metrics", "git", "knowledge", "excalidraw"] as const;
9
+ export type TabId = (typeof TAB_IDS)[number];
10
+
11
+ export const TAB_LABELS: Record<TabId, string> = {
12
+ lobby: "Lobby",
13
+ tasks: "Tasks",
14
+ plan: "Plan",
15
+ quickfix: "Quick fix",
16
+ issues: "Issues",
17
+ metrics: "Metrics",
18
+ git: "Git",
19
+ knowledge: "Knowledge",
20
+ excalidraw: "Excalidraw",
21
+ };
22
+
23
+ /** The tabs on show: Issues only while `lobby.issues` switches it on. */
24
+ export function visibleTabs(issues: boolean): TabId[] {
25
+ return TAB_IDS.filter((tab) => issues || tab !== "issues");
26
+ }
27
+
28
+ /** A question answered in the terminal while the web lobby watched it. */
29
+ export const ANSWERED_IN_TERMINAL = "already answered in the terminal";
30
+
31
+ /** Pi itself is asking in the terminal; the web lobby waits. */
32
+ export const PI_ASKING_IN_TERMINAL = "pi is asking in the terminal";
33
+
34
+ /** The window is too narrow to show the lobby. */
35
+ export const NARROW_WINDOW = "the window is too narrow to show the lobby";