@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.
- package/README.md +11 -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 +4 -3
- 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/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
|
@@ -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,
|
|
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
|
|
47
|
-
export type TabId
|
|
48
|
-
|
|
49
|
-
|
|
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,
|
|
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
|
-
|
|
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);
|
package/src/pi/commands.ts
CHANGED
|
@@ -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.
|
package/src/pi/settings-ui.ts
CHANGED
|
@@ -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 === "
|
|
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;
|
package/src/state/changes.ts
CHANGED
|
@@ -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 {
|
|
13
|
+
import type { Task } from "../schemas/task.ts";
|
|
14
14
|
import { dataRoot } from "./project.ts";
|
|
15
15
|
|
|
16
16
|
export type ChangeSource = "quickfix" | "worker";
|
package/src/state/persistence.ts
CHANGED
|
@@ -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 {
|
|
148
|
+
for (const { dir } of taskEntries(root, configDir)) {
|
|
149
149
|
const task = readTaskAt(dir);
|
|
150
150
|
if (task) tasks.push(task);
|
|
151
151
|
}
|
package/src/state/presence.ts
CHANGED
|
@@ -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
|
+
}
|