@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
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Every lobby change routed through one versioned emitter. Each topic carries
3
+ * a version that only grows; a later web server follows them over SSE, while
4
+ * the terminal subscribes to everything and repaints, exactly as before.
5
+ * Dependency-free on purpose: sources bump without importing the terminal.
6
+ */
7
+
8
+ export const LOBBY_TOPICS = [
9
+ "lobby",
10
+ "tasks",
11
+ "plans",
12
+ "planner",
13
+ "quickfix",
14
+ "sessions",
15
+ "metrics",
16
+ "git",
17
+ "issues",
18
+ "knowledge",
19
+ "excalidraw",
20
+ "prompts",
21
+ "notices",
22
+ "status",
23
+ ] as const;
24
+
25
+ export type LobbyTopic = (typeof LOBBY_TOPICS)[number];
26
+
27
+ /** A change on `topic`, now at `version`. */
28
+ export type LobbyTopicListener = (topic: LobbyTopic, version: number) => void;
29
+
30
+ function isTopic(value: string): value is LobbyTopic {
31
+ return (LOBBY_TOPICS as readonly string[]).includes(value);
32
+ }
33
+
34
+ /** One version per topic; listeners hear every bump in order. */
35
+ export class LobbyTopics {
36
+ private readonly versionsByTopic = new Map<LobbyTopic, number>();
37
+ private readonly listeners = new Set<LobbyTopicListener>();
38
+
39
+ /** The current version of `topic` (0 when nothing bumped it yet). */
40
+ version(topic: LobbyTopic): number {
41
+ return this.versionsByTopic.get(topic) ?? 0;
42
+ }
43
+
44
+ /** Every topic with its current version. */
45
+ versions(): Record<LobbyTopic, number> {
46
+ const snapshot = {} as Record<LobbyTopic, number>;
47
+ for (const topic of LOBBY_TOPICS) snapshot[topic] = this.version(topic);
48
+ return snapshot;
49
+ }
50
+
51
+ /** Record a change on `topic` and tell every listener. */
52
+ bump(topic: LobbyTopic): number {
53
+ if (!isTopic(topic)) return 0;
54
+ const version = this.version(topic) + 1;
55
+ this.versionsByTopic.set(topic, version);
56
+ for (const listener of [...this.listeners]) listener(topic, version);
57
+ return version;
58
+ }
59
+
60
+ /** Hear every bump; returns the unsubscribe function. */
61
+ subscribe(listener: LobbyTopicListener): () => void {
62
+ this.listeners.add(listener);
63
+ return () => this.unsubscribe(listener);
64
+ }
65
+
66
+ /** Stop hearing bumps. */
67
+ unsubscribe(listener: LobbyTopicListener): void {
68
+ this.listeners.delete(listener);
69
+ }
70
+
71
+ /** The terminal's subscription: every topic repaints, as before. */
72
+ onChange(listener: () => void): () => void {
73
+ return this.subscribe(() => listener());
74
+ }
75
+ }
76
+
77
+ /** The process-wide topics; every lobby source bumps these. */
78
+ export const lobbyTopics = new LobbyTopics();
package/src/lobby/view.ts CHANGED
@@ -16,15 +16,15 @@ import type { AgentRun } from "../schemas/findings.ts";
16
16
  import type { PlannedTask } from "../state/backlog.ts";
17
17
  import type { PlanComment } from "../state/comments.ts";
18
18
  import { aggregateMetrics, collectMetrics, SORT_KEYS, sortGroups, summarizeClassifier, taskStats, taskTimesByModel, type GroupBy, type MetricRecord, type SortKey } from "../state/metrics.ts";
19
- import { PANEL_MEMBERS, type LobbyAgentKind, type LobbyPanel, type PanelMember } from "../schemas/configuration.ts";
19
+ import { PANEL_MEMBERS, type BotLobbyConfig, type LobbyAgentKind, type LobbyPanel, type PanelMember } from "../schemas/configuration.ts";
20
20
  import type { ChatEntry, LobbyFeed } from "./feed.ts";
21
21
  import type { BackgroundSession } from "./sessions.ts";
22
22
  import type { QuickFixQueue } from "./quickfix.ts";
23
23
  import { MEMBER_LABELS, ORACLE_LABEL, type PlannerSeed, type PlanningSession } from "./planner.ts";
24
24
  import { issueText, type IssuesState } from "./issues.ts";
25
25
  import { agentsIndicator, workingAgents } from "./mini.ts";
26
- import { actionFor, keyLabel, keyMap, LOBBY_ACTIONS, type KeyMap, type LobbyAction } from "./keys.ts";
27
- import { beside, BRANCH_GLYPH, bold, box, fit, highlight, pageStep, pagerButton, paint, rule, selectRow, spinner, spread, windowStart, wrap, wrapHanging, type LobbyTheme, type PaneBox, type PaneLayout, type PaneMark } from "./layout.ts";
26
+ import { actionFor, keyLabel, keyMap, LOBBY_ACTIONS, tabJumpKey, type KeyMap, type LobbyAction } from "./keys.ts";
27
+ import { beside, BRANCH_GLYPH, bold, box, fit, highlight, pageStep, pagerButton, paint, rule, selectRow, spinner, spread, windowStart, wrapHanging, type LobbyTheme, type PaneBox, type PaneLayout, type PaneMark } from "./layout.ts";
28
28
  import { chatTail, HOME_PANES, renderHome, type HomePane } from "./tabs/home.ts";
29
29
  import { filterRows, pips, planDetailLines, renderTasks, taskDetailLines, taskProgress, taskRows, tasksWidths, type TaskRow } from "./tabs/tasks.ts";
30
30
  import { renderPlan, type PlanLayout, type PlanView, type SeatView } from "./tabs/plan.ts";
@@ -43,25 +43,10 @@ import { filterRecords, renderMetrics } from "./tabs/metrics.ts";
43
43
  /** The mark before a branch name in the title (shared with the task details). */
44
44
  export { BRANCH_GLYPH };
45
45
 
46
- export const TAB_IDS = ["lobby", "tasks", "plan", "quickfix", "issues", "metrics", "git", "knowledge", "excalidraw"] as const;
47
- export type TabId = (typeof TAB_IDS)[number];
48
-
49
- export const TAB_LABELS: Record<TabId, string> = {
50
- lobby: "Lobby",
51
- tasks: "Tasks",
52
- plan: "Plan",
53
- quickfix: "Quick fix",
54
- issues: "Issues",
55
- metrics: "Metrics",
56
- git: "Git",
57
- knowledge: "Knowledge",
58
- excalidraw: "Excalidraw",
59
- };
60
-
61
- /** The tabs on show: Issues only while `lobby.issues` switches it on. */
62
- export function visibleTabs(issues: boolean): TabId[] {
63
- return TAB_IDS.filter((tab) => issues || tab !== "issues");
64
- }
46
+ export { TAB_IDS, TAB_LABELS, visibleTabs } from "./prompts.ts";
47
+ export type { TabId } from "./prompts.ts";
48
+ // Local bindings for the uses below; the definitions live in prompts.ts.
49
+ import { TAB_LABELS, visibleTabs, type TabId } from "./prompts.ts";
65
50
 
66
51
  /** Tabs where the prompt is the point: they open in typing mode and typing in browsing mode resumes it. */
67
52
  const PROMPT_FIRST: ReadonlySet<TabId> = new Set(["lobby", "plan", "quickfix"]);
@@ -135,6 +120,13 @@ export const OLDER_NOTE = "↑ earlier messages load as you scroll up";
135
120
  const SECTION_OF: Record<SessionWhere, string> = { "this window": "THIS WINDOW", background: "BACKGROUND", "other terminal": "OTHER TERMINALS", "not running": "NOT RUNNING" };
136
121
  const WHERE_MARKS: Record<SessionWhere, string> = { "this window": "●", background: "◆", "other terminal": "◇", "not running": "○" };
137
122
 
123
+ /** One model Pi offers, for the settings page's picker: `provider/id`, its label and thinking levels. */
124
+ export interface ModelChoice {
125
+ id: string;
126
+ label: string;
127
+ thinkingLevels: string[];
128
+ }
129
+
138
130
  /** Everything the view needs from pi and bot-lobby. */
139
131
  export interface LobbyHost {
140
132
  rows(): number;
@@ -184,6 +176,14 @@ export interface LobbyHost {
184
176
  checkExcalidraw(session: ExcalidrawSession): Promise<SessionCheck>;
185
177
  /** Edit a text in pi's multi-line editor (the lobby steps aside meanwhile); the new text, or undefined when cancelled. */
186
178
  editText(title: string, text: string): Promise<string | undefined>;
179
+ /** The effective config, never a secret; the web settings page reads it. */
180
+ config?(): BotLobbyConfig;
181
+ /** Save a whole config, the way the settings menu does; the web settings page writes it. */
182
+ saveConfig?(config: BotLobbyConfig): void;
183
+ /** Reload cached config (keys, panes) after a settings change. */
184
+ configChanged?(): void;
185
+ /** The models Pi offers, for the settings page's picker. */
186
+ models?(): ModelChoice[];
187
187
  /** The Issues tab is switched on (`lobby.issues`). */
188
188
  issuesEnabled(): boolean;
189
189
  /** Which panes show (`lobby.panels`), and remembering a change. */
@@ -719,7 +719,7 @@ export class LobbyView implements Component, Focusable {
719
719
  if (action) return this.runAction(action);
720
720
  const tabs = this.tabs();
721
721
  for (const [index, tab] of tabs.entries()) {
722
- if (matchesKey(data, Key.alt(String(index + 1) as "1"))) return this.setTab(tab);
722
+ if (matchesKey(data, tabJumpKey(index))) return this.setTab(tab);
723
723
  }
724
724
  if (matchesKey(data, Key.ctrl("c"))) {
725
725
  if (this.editor.getText()) return this.editor.setText("");
@@ -804,7 +804,12 @@ export class LobbyView implements Component, Focusable {
804
804
  if (matchesKey(data, Key.left) || matchesKey(data, Key.right)) return this.movePaneFocus(matchesKey(data, Key.left) ? -1 : 1);
805
805
  if (matchesKey(data, Key.home) || matchesKey(data, Key.end)) return this.scrollToEdge(matchesKey(data, Key.home));
806
806
  const tabs = this.tabs();
807
- if (!PROMPT_FIRST.has(this.tab) && /^[1-9]$/.test(data) && Number(data) <= tabs.length) return this.setTab(tabs[Number(data) - 1]!);
807
+ const digit = /^[1-9]$/.test(data) ? Number(data) - 1 : -1;
808
+ if (!PROMPT_FIRST.has(this.tab) && digit >= 0 && digit < tabs.length) {
809
+ // Plain digits jump without Alt; the Alt key stays the shared definition.
810
+ void tabJumpKey(digit);
811
+ return this.setTab(tabs[digit]!);
812
+ }
808
813
  if (data === "i" && this.tab !== "metrics" && this.tab !== "tasks" && this.tab !== "issues" && this.tab !== "git" && this.tab !== "knowledge" && this.tab !== "excalidraw") return this.setMode("type");
809
814
  if (this.tabCommand(data)) return;
810
815
  const printable = isPrintable(data);
@@ -25,6 +25,7 @@ import { kickoff, startPlannedTask } from "./start-task.ts";
25
25
  import { startRequest } from "./route.ts";
26
26
  import { setAuto, toggleOwnAuto } from "./owner.ts";
27
27
  import { autoOpenLobby, showLobby } from "../lobby/runtime.ts";
28
+ import { registerWebServer, webCommand } from "../webui/command.ts";
28
29
  import { modelRef, thinkingMismatches } from "./model-support.ts";
29
30
  import { describeRun, runFromLog } from "./run-summary.ts";
30
31
  import { modelLookup } from "./tools.ts";
@@ -56,11 +57,12 @@ const HELP = [
56
57
  "/bot-lobby auto [on|off] Auto mode: the oracle drives this session's task without asking (alt+g)",
57
58
  "/bot-lobby start-plan PLAN-… [auto] Start a planned task here; its agreed plan needs no approval",
58
59
  "/bot-lobby switch <session.jsonl> Run a saved session in this window (the lobby's session browser uses it: alt+o, s)",
60
+ "/bot-lobby web [stop|link|reset] Start the browser UI on this machine (loopback only), or stop it, print its link, reset its link",
59
61
  "/bot-lobby help This help",
60
62
  ].join("\n");
61
63
 
62
64
  /** Subcommands only win when no free-form text follows (so tasks still start). */
63
- const SUBCOMMANDS = new Set(["lobby", "help", "status", "runs", "tasks", "pause", "resume", "cancel", "approve", "amend", "decline", "accept", "budget", "knowledge", "config", "settings", "minimize", "restore", "claim", "auto", "start-plan", "switch"]);
65
+ const SUBCOMMANDS = new Set(["lobby", "help", "status", "runs", "tasks", "pause", "resume", "cancel", "approve", "amend", "decline", "accept", "budget", "knowledge", "config", "settings", "minimize", "restore", "claim", "auto", "start-plan", "switch", "web"]);
64
66
 
65
67
  /** `Task-Change-Table-Font-27-09-2026`, or an older `TASK-add-login` id. */
66
68
  function isTaskId(value: string | undefined): boolean {
@@ -111,7 +113,8 @@ export function parseCommand(args: string): ParsedCommand {
111
113
  || (sub === "auto" && rest.length === 1 && /^(on|off)$/i.test(rest[0]!))
112
114
  || (sub === "budget" && rest.length >= 1 && rest.length <= 2 && (/^off$/i.test(rest[0]!) || parseMinutes(rest.join("")) !== undefined))
113
115
  || (sub === "start-plan" && rest.length >= 1 && rest.length <= 2 && /^PLAN-/.test(rest[0]!) && (rest.length === 1 || rest[1] === "auto"))
114
- || (sub === "switch" && rest.length >= 1 && /\.jsonl$/.test(rest.join(" ")));
116
+ || (sub === "switch" && rest.length >= 1 && /\.jsonl$/.test(rest.join(" ")))
117
+ || (sub === "web" && rest.length === 1 && ["stop", "link", "reset"].includes(rest[0]!));
115
118
  if (!takesArgs && rest.length > 0 && !(rest.length === 1 && isTaskId(rest[0]))) {
116
119
  return { sub: undefined, rest: [], restText: trimmed };
117
120
  }
@@ -321,6 +324,7 @@ function autoCommand(ctx: ExtensionCommandContext, configDir: string, value: str
321
324
 
322
325
  export function registerCommands(pi: ExtensionAPI, configDir: string): void {
323
326
  registerRevealShortcut(pi, configDir);
327
+ registerWebServer(pi);
324
328
  pi.registerCommand("bot-lobby", {
325
329
  description: "Structured multi-agent engineering orchestrator",
326
330
  getArgumentCompletions: (prefix) => {
@@ -385,6 +389,8 @@ export function registerCommands(pi: ExtensionAPI, configDir: string): void {
385
389
  return autoCommand(ctx, configDir, rest[0]);
386
390
  case "switch":
387
391
  return switchCommand(ctx, restText);
392
+ case "web":
393
+ return webCommand(ctx, rest[0]);
388
394
  case "start-plan": {
389
395
  const started = await startPlannedTask(pi, ctx, configDir, rest[0]!, { auto: rest[1] === "auto" });
390
396
  if (typeof started === "string") return ctx.ui.notify(`bot-lobby: ${started}`, "warning");
package/src/pi/owner.ts CHANGED
@@ -15,6 +15,7 @@ import { detectProjectRoot, loadConfig } from "../state/project.ts";
15
15
  import { commentMessage, markCommentsDelivered, readPlanComments, undeliveredComments } from "../state/comments.ts";
16
16
  import { inboxMessage, markInboxDelivered, markSessionInboxDelivered, readInbox, readSessionInbox } from "../state/inbox.ts";
17
17
  import { removePresence, writePresence } from "../state/presence.ts";
18
+ import { currentWebServer } from "../webui/server.ts";
18
19
  import { isAutoMode, setAutoMode } from "../state/auto.ts";
19
20
  import { isSubagentProcess } from "./quiet.ts";
20
21
  import { isMinimized } from "./ui.ts";
@@ -119,6 +120,7 @@ export function heartbeat(): void {
119
120
  ...(name ? { name } : {}),
120
121
  ...(sessionFile ? { sessionFile } : {}),
121
122
  ...(live(task) ? { taskId: task.id } : {}),
123
+ ...(currentWebServer() ? { webPort: currentWebServer()!.port } : {}),
122
124
  });
123
125
  } catch {
124
126
  // A read-only project only means other windows do not see this one.
@@ -466,6 +466,55 @@ export function nextSplitLimit(current: number): number {
466
466
  return SPLIT_PLAN_CHOICES.find((choice) => choice > current) ?? 0;
467
467
  }
468
468
 
469
+ /** `on · port 7347 · opens the browser · questions both`, for the settings menu. */
470
+ export function webSummary(config: BotLobbyConfig): string {
471
+ const web = config.lobby.web;
472
+ return `${web.enabled ? "on" : "off"} · port ${web.port === 0 ? "any" : web.port} · ${web.openBrowser ? "opens the browser" : "link only"} · questions ${web.questions}`;
473
+ }
474
+
475
+ /** The next place the web UI's questions are answered (`both`, in the browser or the terminal). */
476
+ export function nextWebQuestions(current: string): "both" | "terminal" {
477
+ return current === "both" ? "terminal" : "both";
478
+ }
479
+
480
+ /** A port typed in the settings menu: 0 (any free port) or a real port; undefined when it is not a port. */
481
+ export function parseWebPort(typed: string): number | undefined {
482
+ if (!/^\d+$/.test(typed.trim())) return undefined;
483
+ const port = Number(typed.trim());
484
+ return port === 0 || (port > 0 && port <= 65535) ? port : undefined;
485
+ }
486
+
487
+ /** The loopback web UI's own settings: on/off, its port, the browser and where its questions are answered. */
488
+ async function editWeb(ctx: ExtensionContext): Promise<void> {
489
+ const typed = await pick(ctx, "bot-lobby settings · Web UI", [
490
+ { value: "enabled", label: "Web UI", description: `${loadConfig().lobby.web.enabled ? "on" : "off"} · start the loopback browser UI with /bot-lobby web` },
491
+ { value: "port", label: "Port", description: `${loadConfig().lobby.web.port === 0 ? "any free port" : loadConfig().lobby.web.port} · the base port (then the next free up to +20); 0 means any free port` },
492
+ { value: "browser", label: "Open browser", description: `${loadConfig().lobby.web.openBrowser ? "on" : "off"} · open the link in the browser on /bot-lobby web` },
493
+ { value: "questions", label: "Questions", description: `${loadConfig().lobby.web.questions} · where the web UI's questions are answered: both, or the terminal only` },
494
+ { value: "back", label: "Back" },
495
+ ]);
496
+ if (!typed || typed === "back") return;
497
+ const config = loadConfig();
498
+ if (typed === "enabled") saveConfig({ ...config, lobby: { ...config.lobby, web: { ...config.lobby.web, enabled: !config.lobby.web.enabled } } });
499
+ else if (typed === "browser") saveConfig({ ...config, lobby: { ...config.lobby, web: { ...config.lobby.web, openBrowser: !config.lobby.web.openBrowser } } });
500
+ else if (typed === "questions") saveConfig({ ...config, lobby: { ...config.lobby, web: { ...config.lobby.web, questions: nextWebQuestions(config.lobby.web.questions) } } });
501
+ else await editWebPort(ctx);
502
+ await editWeb(ctx);
503
+ }
504
+
505
+ /** Type the web UI's port; 0 means any free port. */
506
+ async function editWebPort(ctx: ExtensionContext): Promise<void> {
507
+ const typed = (await ctx.ui.input("Web UI port (0: any free port)", String(loadConfig().lobby.web.port)))?.trim();
508
+ if (!typed) return;
509
+ const port = parseWebPort(typed);
510
+ if (port === undefined) {
511
+ ctx.ui.notify(`bot-lobby: "${typed}" is not a port (0, or 1-65535).`, "warning");
512
+ return;
513
+ }
514
+ const config = loadConfig();
515
+ saveConfig({ ...config, lobby: { ...config.lobby, web: { ...config.lobby.web, port } } });
516
+ }
517
+
469
518
  /** On/off settings for the lobby, and the planning round limit; enter flips or cycles one and saves it. Key rebinding stays in the file (lobby.keys). */
470
519
  async function editLobby(ctx: ExtensionContext): Promise<void> {
471
520
  for (;;) {
@@ -473,10 +522,12 @@ async function editLobby(ctx: ExtensionContext): Promise<void> {
473
522
  const items: SelectItem[] = LOBBY_SWITCHES.map((entry) => ({ value: entry.id, label: entry.label, description: `${lobbySwitch(config, entry.id) ? "on" : "off"} · ${entry.help}` }));
474
523
  items.push({ value: "rounds", label: "Planning rounds", description: `${roundLimitLabel(config.lobby.maxPlanningRounds)} · enter cycles 2, 3, 5, 8, unlimited; the last round the oracle settles alone` });
475
524
  items.push({ value: "split", label: "Split long plans", description: `${splitLimitLabel(config.lobby.splitPlanAbove)} · saving a plan with more steps offers to split it into up to 5 tasks · enter cycles 6, 8, 10, 12, never` });
525
+ items.push({ value: "web", label: "Web UI", description: webSummary(config) });
476
526
  items.push({ value: "back", label: "Back", description: `keys: lobby.keys in ${globalConfigPath()}` });
477
527
  const choice = await pick(ctx, "bot-lobby settings · Lobby", items);
478
528
  if (!choice || choice === "back") return;
479
- if (choice === "rounds") saveConfig({ ...config, lobby: { ...config.lobby, maxPlanningRounds: nextRoundLimit(config.lobby.maxPlanningRounds) } });
529
+ if (choice === "web") await editWeb(ctx);
530
+ else if (choice === "rounds") saveConfig({ ...config, lobby: { ...config.lobby, maxPlanningRounds: nextRoundLimit(config.lobby.maxPlanningRounds) } });
480
531
  else if (choice === "split") saveConfig({ ...config, lobby: { ...config.lobby, splitPlanAbove: nextSplitLimit(config.lobby.splitPlanAbove) } });
481
532
  else saveConfig(toggleLobbySwitch(config, choice as LobbySwitch));
482
533
  }
package/src/pi/tools.ts CHANGED
@@ -10,6 +10,7 @@ import { truncate } from "../text.ts";
10
10
  import { applyStatus, reportRuns, summarizeRun } from "./ui.ts";
11
11
  import { whileAsking } from "../state/budget.ts";
12
12
  import { askUser } from "../ask/dialog.ts";
13
+ import { promptHub } from "../lobby/prompt-hub.ts";
13
14
  import type { AskQuestion } from "../ask/types.ts";
14
15
  import { unescapeBreaks } from "../lobby/markdown.ts";
15
16
  import { isQuiet } from "./quiet.ts";
@@ -126,10 +127,10 @@ export function workflowDeps(
126
127
  effort: effortFor((model, thinking) => checkThinking(modelLookup(ctx)(model), thinking).level),
127
128
  // Time spent waiting on the user is not the task's: its budget clock waits too.
128
129
  // A free-text answer may run to several lines (Shift+Enter), so it is asked in the multi-line editor.
129
- ask: async (question) => (hasUI ? whileAsking(() => ctx.ui.editor(question)) : undefined),
130
- choose: async (title, options) => (hasUI ? whileAsking(() => ctx.ui.select(title, options)) : undefined),
130
+ ask: async (question) => (hasUI ? whileAsking(() => promptHub.run("text", "orchestrate", { question }, () => ctx.ui.editor(question))) : undefined),
131
+ choose: async (title, options) => (hasUI ? whileAsking(() => promptHub.run("choose", "orchestrate", { title, options }, () => ctx.ui.select(title, options))) : undefined),
131
132
  // An agent's own questions (the designer's), relayed as the questionnaire.
132
- ...(hasUI ? { askQuestions: (questions: AskQuestion[], from: string, askSignal?: AbortSignal) => whileAsking(() => askUser(questions, ctx, askSignal, from)) } : {}),
133
+ ...(hasUI ? { askQuestions: (questions: AskQuestion[], from: string, askSignal?: AbortSignal) => whileAsking(() => promptHub.run("questionnaire", from, { questions }, () => askUser(questions, ctx, askSignal, from), askSignal ? { signal: askSignal } : undefined)) } : {}),
133
134
  notify: (message, level = "info") => {
134
135
  if (hasUI) ctx.ui.notify(message, level);
135
136
  },
@@ -120,6 +120,21 @@ export function isPanelMember(value: string): value is PanelMember {
120
120
  export const LOBBY_PANELS = ["conversation", "activity", "thinking"] as const;
121
121
  export type LobbyPanel = (typeof LOBBY_PANELS)[number];
122
122
 
123
+ /** Where the web UI's questions are answered: in the browser, or only in the terminal. */
124
+ export const LOBBY_WEB_QUESTIONS = ["both", "terminal"] as const;
125
+ export type LobbyWebQuestions = (typeof LOBBY_WEB_QUESTIONS)[number];
126
+
127
+ /** The loopback web UI: off until started with `/bot-lobby web`. */
128
+ export interface LobbyWebConfig {
129
+ /** Start the server with the session. */
130
+ enabled: boolean;
131
+ /** Base port (tried, then the next free up to +20); 0 means any free port. */
132
+ port: number;
133
+ /** Open the link in the browser on `/bot-lobby web`. */
134
+ openBrowser: boolean;
135
+ questions: LobbyWebQuestions;
136
+ }
137
+
123
138
  /** The full-screen lobby. */
124
139
  export interface LobbyConfig {
125
140
  /** Open by itself when this session starts or resumes a task. */
@@ -148,6 +163,8 @@ export interface LobbyConfig {
148
163
  * tasks when it is saved (the oracle proposes, the user decides); 0 = never.
149
164
  */
150
165
  splitPlanAbove: number;
166
+ /** The loopback web UI (port, browser, where its questions are answered). */
167
+ web: LobbyWebConfig;
151
168
  }
152
169
 
153
170
  /** Decisions the classifier can make, each switched on or off on its own. */
@@ -264,6 +281,7 @@ export const DEFAULT_CONFIG: BotLobbyConfig = {
264
281
  miniLine: true,
265
282
  maxPlanningRounds: 5,
266
283
  splitPlanAbove: 8,
284
+ web: { enabled: false, port: 7347, openBrowser: true, questions: "both" },
267
285
  },
268
286
  classifier: {
269
287
  enabled: false,
@@ -326,7 +344,7 @@ function flag(value: unknown, fallback: boolean): boolean {
326
344
  }
327
345
 
328
346
  function normalizeLobby(value: unknown): LobbyConfig {
329
- const source = value as { autoOpen?: unknown; planningPanel?: unknown; autoAsk?: unknown; issues?: unknown; panels?: unknown; keys?: unknown; mouse?: unknown; miniLine?: unknown; maxPlanningRounds?: unknown; splitPlanAbove?: unknown } | undefined;
347
+ const source = value as { autoOpen?: unknown; planningPanel?: unknown; autoAsk?: unknown; issues?: unknown; panels?: unknown; keys?: unknown; mouse?: unknown; miniLine?: unknown; maxPlanningRounds?: unknown; splitPlanAbove?: unknown; web?: unknown } | undefined;
330
348
  const defaults = DEFAULT_CONFIG.lobby;
331
349
  const panel = Array.isArray(source?.planningPanel)
332
350
  ? [...new Set(source.planningPanel.filter((entry): entry is PanelMember => typeof entry === "string" && isPanelMember(entry)))]
@@ -344,9 +362,23 @@ function normalizeLobby(value: unknown): LobbyConfig {
344
362
  miniLine: flag(source?.miniLine, defaults.miniLine),
345
363
  maxPlanningRounds: roundLimit(source?.maxPlanningRounds, defaults.maxPlanningRounds),
346
364
  splitPlanAbove: roundLimit(source?.splitPlanAbove, defaults.splitPlanAbove),
365
+ web: normalizeLobbyWeb(source?.web, defaults.web),
347
366
  };
348
367
  }
349
368
 
369
+ /** The loopback web UI's config: 0 or a real port, `both` or `terminal`; anything else keeps the default. */
370
+ function normalizeLobbyWeb(value: unknown, fallback: LobbyWebConfig): LobbyWebConfig {
371
+ const source = (value && typeof value === "object" ? value : {}) as Record<string, unknown>;
372
+ const port = source.port === 0 ? 0 : webPort(source.port, fallback.port);
373
+ const questions = source.questions === "both" || source.questions === "terminal" ? source.questions : fallback.questions;
374
+ return { enabled: flag(source.enabled, fallback.enabled), port, openBrowser: flag(source.openBrowser, fallback.openBrowser), questions };
375
+ }
376
+
377
+ /** A TCP port (0 means any free port); anything else keeps the default. */
378
+ function webPort(value: unknown, fallback: number): number {
379
+ return typeof value === "number" && Number.isInteger(value) && value > 0 && value <= 65535 ? value : fallback;
380
+ }
381
+
350
382
  /** A whole number of rounds, 0 for unlimited; anything else keeps the default. */
351
383
  function roundLimit(value: unknown, fallback: number): number {
352
384
  return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : fallback;
@@ -10,7 +10,7 @@
10
10
  import { appendFileSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
11
11
  import { dirname, isAbsolute, join, relative, resolve } from "node:path";
12
12
  import type { Domain } from "../schemas/agent.ts";
13
- import type { ChangeBaseline, Task } from "../schemas/task.ts";
13
+ import type { Task } from "../schemas/task.ts";
14
14
  import { dataRoot } from "./project.ts";
15
15
 
16
16
  export type ChangeSource = "quickfix" | "worker";
@@ -145,7 +145,7 @@ function taskEntries(root: string, configDir: string): { entry: string; dir: str
145
145
  /** All tasks on disk, newest first. Unreadable task dirs are skipped. */
146
146
  export function listTasks(root: string, configDir: string): Task[] {
147
147
  const tasks: Task[] = [];
148
- for (const { entry, dir } of taskEntries(root, configDir)) {
148
+ for (const { dir } of taskEntries(root, configDir)) {
149
149
  const task = readTaskAt(dir);
150
150
  if (task) tasks.push(task);
151
151
  }
@@ -19,6 +19,8 @@ export interface Presence {
19
19
  sessionFile?: string;
20
20
  /** The task it drives, if any. */
21
21
  taskId?: string;
22
+ /** The loopback web UI's port, while this session's server runs. */
23
+ webPort?: number;
22
24
  /** `tui` for a terminal, `rpc` for a session another window started in the background. */
23
25
  mode: string;
24
26
  updatedAt: string;
@@ -0,0 +1,105 @@
1
+ /**
2
+ * The Excalidraw tab over HTTP: the shared sessions, adding one from a link
3
+ * or making a new room, assigning agents, and checking a room. Sessions are
4
+ * listed with masked links (`room <id>`); only `reveal` returns the full
5
+ * link, so the page holds a room key only while it shows one. Actions answer
6
+ * with the same notice text the terminal shows.
7
+ */
8
+ import { EXCALIDRAW_AGENTS, type ExcalidrawAgent, type ExcalidrawSession } from "../../excalidraw/sessions.ts";
9
+ import { maskedLink, parseRoomLink } from "../../excalidraw/room.ts";
10
+ import type { ExcalidrawAgentName, ExcalidrawSessionInfo } from "../protocol.ts";
11
+ import type { ApiContext } from "./index.ts";
12
+ import { fail } from "./index.ts";
13
+
14
+ /** A session with its key hidden, for every call but `reveal`. */
15
+ export function masked(session: ExcalidrawSession): ExcalidrawSessionInfo {
16
+ const link = parseRoomLink(session.link);
17
+ return {
18
+ id: session.id,
19
+ name: session.name,
20
+ masked: link ? maskedLink(link) : "invalid link",
21
+ agents: [...session.agents],
22
+ contribute: session.contribute,
23
+ addedAt: session.addedAt,
24
+ };
25
+ }
26
+
27
+ function pick(ctx: ApiContext, id: string): ExcalidrawSession {
28
+ const session = ctx.service.excalidraw.list().find((entry) => entry.id === id);
29
+ if (!session) fail(404, "not_found", "no such session");
30
+ return session!;
31
+ }
32
+
33
+ /** Every session, oldest first, with masked links. */
34
+ export function excalidrawList(ctx: ApiContext): { sessions: ExcalidrawSessionInfo[] } {
35
+ return { sessions: ctx.service.excalidraw.list().map(masked) };
36
+ }
37
+
38
+ /** Add a session from a link the user pasted, named `name` (or after its room). */
39
+ export function excalidrawAdd(body: { link: string; name?: string }, ctx: ApiContext): { notice: string; id?: string } {
40
+ const added = ctx.service.excalidraw.add(body.link, body.name);
41
+ if (!added.session) {
42
+ if (added.notice.startsWith("that is not")) fail(400, "bad_request", added.notice);
43
+ fail(409, "conflict", added.notice);
44
+ }
45
+ return { notice: added.notice, id: added.session!.id };
46
+ }
47
+
48
+ /** Make a new room, for the user to open in Excalidraw. */
49
+ export function excalidrawCreate(body: { name?: string }, ctx: ApiContext): { notice: string; id?: string } {
50
+ const made = ctx.service.excalidraw.create(body.name);
51
+ if (!made.session) fail(409, "conflict", made.notice);
52
+ return { notice: made.notice, id: made.session!.id };
53
+ }
54
+
55
+ /** Remove a session (agents lose it at once; the room itself is untouched). */
56
+ export function excalidrawRemove(body: { id: string }, ctx: ApiContext): { notice: string } {
57
+ const notice = ctx.service.excalidraw.remove(body.id);
58
+ if (notice === "no such session") fail(404, "not_found", notice);
59
+ return { notice };
60
+ }
61
+
62
+ /** Rename a session. */
63
+ export function excalidrawRename(body: { id: string; name: string }, ctx: ApiContext): { notice: string } {
64
+ const notice = ctx.service.excalidraw.rename(body.id, body.name);
65
+ if (notice === "no such session") fail(404, "not_found", notice);
66
+ if (notice === "a session needs a name") fail(400, "bad_request", notice);
67
+ return { notice };
68
+ }
69
+
70
+ /** Assign a session to an agent, or take it back. */
71
+ export function excalidrawToggleAgent(body: { id: string; agent: ExcalidrawAgentName }, ctx: ApiContext): { notice: string } {
72
+ if (!isAgent(body.agent)) fail(400, "bad_request", `no agent ${body.agent}`);
73
+ const notice = ctx.service.excalidraw.toggleAgent(body.id, body.agent as ExcalidrawAgent);
74
+ if (notice === "no such session") fail(404, "not_found", notice);
75
+ return { notice };
76
+ }
77
+
78
+ function isAgent(value: string): value is ExcalidrawAgent {
79
+ return (EXCALIDRAW_AGENTS as readonly string[]).includes(value);
80
+ }
81
+
82
+ /** Assign a session to every agent, or (when they all have it) to none. */
83
+ export function excalidrawToggleAll(body: { id: string }, ctx: ApiContext): { notice: string } {
84
+ const notice = ctx.service.excalidraw.toggleAll(body.id);
85
+ if (notice === "no such session") fail(404, "not_found", notice);
86
+ return { notice };
87
+ }
88
+
89
+ /** Let the agents draw in a session, or only look at it. */
90
+ export function excalidrawToggleContribute(body: { id: string }, ctx: ApiContext): { notice: string } {
91
+ const notice = ctx.service.excalidraw.toggleContribute(body.id);
92
+ if (notice === "no such session") fail(404, "not_found", notice);
93
+ return { notice };
94
+ }
95
+
96
+ /** Check a session: join the room for a moment and report who is there. */
97
+ export async function excalidrawCheck(body: { id: string }, ctx: ApiContext): Promise<{ ok: boolean; text: string }> {
98
+ const session = pick(ctx, body.id);
99
+ return ctx.service.checkExcalidraw(session);
100
+ }
101
+
102
+ /** The full room link — the only call that returns one. */
103
+ export function excalidrawReveal(body: { id: string }, ctx: ApiContext): { link: string } {
104
+ return { link: pick(ctx, body.id).link };
105
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * A failed `gh` read as an HTTP refusal. `gh` owns authentication and the
3
+ * repository, and its failures arrive as one readable line (`ghError`): a
4
+ * number it cannot find is 404, anything else (not installed, not signed in,
5
+ * no GitHub remote, a timeout) is a 500 carrying that line. No token or
6
+ * command line is ever in the message.
7
+ */
8
+ import { fail } from "./index.ts";
9
+
10
+ const UNKNOWN_NUMBER = /could not resolve|no (pull request|issue)s? found|not found/i;
11
+
12
+ /** Refuse with `gh`'s own words (`what` names the read when it left none). */
13
+ export function ghRefusal(message: string | undefined, what: string): never {
14
+ const text = message ?? `could not read ${what}`;
15
+ if (UNKNOWN_NUMBER.test(text)) fail(404, "not_found", text);
16
+ fail(500, "failed", text);
17
+ }