@a-t-h-i/bot-lobby 0.6.12 → 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 (62) hide show
  1. package/README.md +11 -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 +4 -3
  20. package/src/schemas/configuration.ts +33 -1
  21. package/src/state/changes.ts +1 -1
  22. package/src/state/persistence.ts +1 -1
  23. package/src/state/presence.ts +2 -0
  24. package/src/webui/api/excalidraw.ts +105 -0
  25. package/src/webui/api/gh-error.ts +17 -0
  26. package/src/webui/api/git.ts +98 -0
  27. package/src/webui/api/index.ts +379 -0
  28. package/src/webui/api/issues.ts +50 -0
  29. package/src/webui/api/knowledge.ts +79 -0
  30. package/src/webui/api/lobby.ts +91 -0
  31. package/src/webui/api/metrics.ts +63 -0
  32. package/src/webui/api/plan-facts.ts +29 -0
  33. package/src/webui/api/planner.ts +98 -0
  34. package/src/webui/api/plans.ts +44 -0
  35. package/src/webui/api/prompts.ts +35 -0
  36. package/src/webui/api/quickfix.ts +48 -0
  37. package/src/webui/api/sessions.ts +114 -0
  38. package/src/webui/api/settings.ts +93 -0
  39. package/src/webui/api/status.ts +55 -0
  40. package/src/webui/api/tasks.ts +204 -0
  41. package/src/webui/auth.ts +153 -0
  42. package/src/webui/command.ts +152 -0
  43. package/src/webui/dev/fake-service.ts +546 -0
  44. package/src/webui/dev/fixtures.ts +180 -0
  45. package/src/webui/dev/serve.ts +54 -0
  46. package/src/webui/events.ts +207 -0
  47. package/src/webui/files.ts +88 -0
  48. package/src/webui/notices.ts +61 -0
  49. package/src/webui/open.ts +35 -0
  50. package/src/webui/protocol.ts +565 -0
  51. package/src/webui/server.ts +315 -0
  52. package/src/webui/static.ts +125 -0
  53. package/webui/dist/assets/index-Bk-lpAGm.css +2 -0
  54. package/webui/dist/assets/index-E6MPcbdC.js +80 -0
  55. package/webui/dist/build.json +494 -0
  56. package/webui/dist/icon-192.png +0 -0
  57. package/webui/dist/icon-512.png +0 -0
  58. package/webui/dist/index.html +18 -0
  59. package/webui/dist/manifest.webmanifest +36 -0
  60. package/webui/dist/offline.html +57 -0
  61. package/webui/dist/sw.js +88 -0
  62. 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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a-t-h-i/bot-lobby",
3
- "version": "0.6.12",
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";