@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.
- package/README.md +26 -3
- package/package.json +31 -4
- package/src/ask/tool.ts +2 -1
- package/src/lobby/keys.ts +14 -1
- package/src/lobby/mini.ts +9 -1
- package/src/lobby/models/metrics.ts +14 -0
- package/src/lobby/planner.ts +1 -1
- package/src/lobby/prompt-hub.ts +161 -0
- package/src/lobby/prompts.ts +35 -0
- package/src/lobby/runtime.ts +115 -566
- package/src/lobby/service.ts +655 -0
- package/src/lobby/tabs/home.ts +1 -1
- package/src/lobby/tabs/metrics.ts +4 -6
- package/src/lobby/topics.ts +78 -0
- package/src/lobby/view.ts +29 -24
- package/src/pi/commands.ts +8 -2
- package/src/pi/owner.ts +2 -0
- package/src/pi/settings-ui.ts +52 -1
- package/src/pi/tools.ts +6 -4
- package/src/roles/reviewer.ts +9 -1
- package/src/schemas/configuration.ts +33 -1
- package/src/state/changes.ts +1 -1
- package/src/state/persistence.ts +1 -1
- package/src/state/presence.ts +2 -0
- package/src/webui/api/excalidraw.ts +105 -0
- package/src/webui/api/gh-error.ts +17 -0
- package/src/webui/api/git.ts +98 -0
- package/src/webui/api/index.ts +379 -0
- package/src/webui/api/issues.ts +50 -0
- package/src/webui/api/knowledge.ts +79 -0
- package/src/webui/api/lobby.ts +91 -0
- package/src/webui/api/metrics.ts +63 -0
- package/src/webui/api/plan-facts.ts +29 -0
- package/src/webui/api/planner.ts +98 -0
- package/src/webui/api/plans.ts +44 -0
- package/src/webui/api/prompts.ts +35 -0
- package/src/webui/api/quickfix.ts +48 -0
- package/src/webui/api/sessions.ts +114 -0
- package/src/webui/api/settings.ts +93 -0
- package/src/webui/api/status.ts +55 -0
- package/src/webui/api/tasks.ts +204 -0
- package/src/webui/auth.ts +153 -0
- package/src/webui/command.ts +152 -0
- package/src/webui/dev/fake-service.ts +546 -0
- package/src/webui/dev/fixtures.ts +180 -0
- package/src/webui/dev/serve.ts +54 -0
- package/src/webui/events.ts +207 -0
- package/src/webui/files.ts +88 -0
- package/src/webui/notices.ts +61 -0
- package/src/webui/open.ts +35 -0
- package/src/webui/protocol.ts +565 -0
- package/src/webui/server.ts +315 -0
- package/src/webui/static.ts +125 -0
- package/src/workflow/workflow.ts +22 -6
- package/webui/dist/assets/index-Bk-lpAGm.css +2 -0
- package/webui/dist/assets/index-E6MPcbdC.js +80 -0
- package/webui/dist/build.json +494 -0
- package/webui/dist/icon-192.png +0 -0
- package/webui/dist/icon-512.png +0 -0
- package/webui/dist/index.html +18 -0
- package/webui/dist/manifest.webmanifest +36 -0
- package/webui/dist/offline.html +57 -0
- package/webui/dist/sw.js +88 -0
- 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
|
|
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
|

|
|
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: **
|
|
16
|
-
|
|
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.
|
|
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
|
+
}
|
package/src/lobby/planner.ts
CHANGED
|
@@ -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,
|
|
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";
|