@a-t-h-i/bot-lobby 0.4.0 → 0.5.0

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.
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Quick fixes: a direct prompt from the lobby goes straight to one coding
3
+ * subagent — no scouting, proposal, plan or QA gate — while any bot-lobby task
4
+ * keeps running. Jobs run one at a time in submission order so two quick fixes
5
+ * never edit the tree at once; each streams plain-words steps into its own log
6
+ * and the lobby feed, and every finished job lands in the metrics log.
7
+ */
8
+ import { loadPrompt } from "../prompts/loader.ts";
9
+ import { runPiAgent, spawnPiProcess, type PiStreamEvent, type ProcessRunner } from "../execution/pi-runner.ts";
10
+ import { describeToolCall } from "../pi/activity.ts";
11
+ import { appendMetrics, type MetricRecord } from "../state/metrics.ts";
12
+ import type { LobbyFeed } from "./feed.ts";
13
+
14
+ /** A quick fix edits code, so it gets the full coding tool set. */
15
+ export const QUICK_FIX_TOOLS: readonly string[] = ["read", "bash", "edit", "write", "grep", "find", "ls"];
16
+
17
+ export const QUICK_FIX_SOURCE = "QUICK FIX";
18
+
19
+ export type QuickFixStatus = "queued" | "running" | "success" | "failed" | "cancelled" | "timeout";
20
+
21
+ export interface QuickFixStep {
22
+ at: number;
23
+ text: string;
24
+ pending: boolean;
25
+ }
26
+
27
+ export interface QuickFixJob {
28
+ id: string;
29
+ prompt: string;
30
+ status: QuickFixStatus;
31
+ createdAt: number;
32
+ startedAt?: number;
33
+ finishedAt?: number;
34
+ model?: string;
35
+ thinking?: string;
36
+ steps: QuickFixStep[];
37
+ tools: number;
38
+ turns: number;
39
+ report?: string;
40
+ error?: string;
41
+ usage?: { input: number; output: number; cost: number; turns: number };
42
+ }
43
+
44
+ export interface QuickFixProfile {
45
+ model?: string;
46
+ thinking: string;
47
+ timeoutMs: number;
48
+ instructions?: string;
49
+ }
50
+
51
+ export interface QuickFixDeps {
52
+ cwd: string;
53
+ root: string;
54
+ configDir: string;
55
+ /** Model, thinking and time limit for the next job, read from settings at start time. */
56
+ profile: () => QuickFixProfile;
57
+ stallTimeoutMs?: number;
58
+ toolStallTimeoutMs?: number;
59
+ feed?: LobbyFeed;
60
+ runProcess?: ProcessRunner;
61
+ /** Called after every change so the lobby can repaint. */
62
+ onChange?: () => void;
63
+ notify?: (message: string, level: "info" | "warning" | "error") => void;
64
+ }
65
+
66
+ export const MAX_JOBS = 30;
67
+ export const MAX_STEPS = 200;
68
+
69
+ function isActive(job: QuickFixJob): boolean {
70
+ return job.status === "queued" || job.status === "running";
71
+ }
72
+
73
+ /** The system prompt a quick fix runs with: the quick-fix role plus the user's custom instructions. */
74
+ export function quickFixPrompt(instructions?: string): string {
75
+ const custom = instructions?.trim();
76
+ return custom ? `${loadPrompt("quickfix.md")}\n\n## Custom Instructions\n\n${custom}` : loadPrompt("quickfix.md");
77
+ }
78
+
79
+ /** First line of a prompt, for lists. */
80
+ export function jobTitle(job: Pick<QuickFixJob, "prompt">): string {
81
+ return job.prompt.split("\n").find((line) => line.trim())?.trim() ?? "(empty)";
82
+ }
83
+
84
+ export class QuickFixQueue {
85
+ jobs: QuickFixJob[] = [];
86
+ private readonly deps: QuickFixDeps;
87
+ private readonly controllers = new Map<string, AbortController>();
88
+ private counter = 0;
89
+
90
+ constructor(deps: QuickFixDeps) {
91
+ this.deps = deps;
92
+ }
93
+
94
+ get running(): QuickFixJob | undefined {
95
+ return this.jobs.find((job) => job.status === "running");
96
+ }
97
+
98
+ /** Queue a quick fix; it starts at once when nothing else is running. */
99
+ submit(prompt: string, now = Date.now()): QuickFixJob {
100
+ const text = prompt.trim();
101
+ if (!text) throw new Error("a quick fix needs a prompt");
102
+ const job: QuickFixJob = { id: `QF-${++this.counter}`, prompt: text, status: "queued", createdAt: now, steps: [], tools: 0, turns: 0 };
103
+ this.jobs = [...this.jobs, job].filter((entry, index, all) => isActive(entry) || index >= all.length - MAX_JOBS);
104
+ this.changed();
105
+ this.pump();
106
+ return job;
107
+ }
108
+
109
+ /** Cancel a queued or running job; finished jobs are left alone. */
110
+ cancel(id: string): boolean {
111
+ const job = this.jobs.find((entry) => entry.id === id);
112
+ if (!job || !isActive(job)) return false;
113
+ if (job.status === "queued") {
114
+ job.status = "cancelled";
115
+ job.finishedAt = Date.now();
116
+ this.changed();
117
+ return true;
118
+ }
119
+ this.controllers.get(id)?.abort();
120
+ return true;
121
+ }
122
+
123
+ /** Abort everything (session shutdown). */
124
+ cancelAll(): void {
125
+ for (const job of this.jobs) if (job.status === "queued") job.status = "cancelled";
126
+ for (const controller of this.controllers.values()) controller.abort();
127
+ }
128
+
129
+ private changed(): void {
130
+ this.deps.onChange?.();
131
+ }
132
+
133
+ private pump(): void {
134
+ if (this.running) return;
135
+ const next = this.jobs.find((job) => job.status === "queued");
136
+ if (next) void this.run(next);
137
+ }
138
+
139
+ private addStep(job: QuickFixJob, text: string, at: number): void {
140
+ for (const step of job.steps) step.pending = false;
141
+ job.steps = [...job.steps, { at, text, pending: true }].slice(-MAX_STEPS);
142
+ this.deps.feed?.step(QUICK_FIX_SOURCE, text, job.id, at);
143
+ }
144
+
145
+ private onEvent(job: QuickFixJob, event: PiStreamEvent): void {
146
+ const now = Date.now();
147
+ if (event.type === "tool_execution_start") {
148
+ job.tools += 1;
149
+ this.addStep(job, describeToolCall(event.toolName, event.args), now);
150
+ } else if (event.type === "turn_start") job.turns += 1;
151
+ else if (event.type === "thought") this.deps.feed?.thought(QUICK_FIX_SOURCE, event.text, now);
152
+ else if (event.type === "retry") this.addStep(job, `provider retry ${event.attempt}/${event.maxAttempts}`, now);
153
+ else return;
154
+ this.changed();
155
+ }
156
+
157
+ private async run(job: QuickFixJob): Promise<void> {
158
+ const profile = this.deps.profile();
159
+ const controller = new AbortController();
160
+ this.controllers.set(job.id, controller);
161
+ job.status = "running";
162
+ job.startedAt = Date.now();
163
+ job.thinking = profile.thinking;
164
+ if (profile.model) job.model = profile.model;
165
+ this.deps.feed?.log(QUICK_FIX_SOURCE, `started: ${jobTitle(job)}`, "info", job.startedAt);
166
+ this.changed();
167
+ try {
168
+ const result = await runPiAgent(
169
+ {
170
+ cwd: this.deps.cwd,
171
+ task: job.prompt,
172
+ systemPrompt: quickFixPrompt(profile.instructions),
173
+ tools: QUICK_FIX_TOOLS,
174
+ model: profile.model,
175
+ thinking: profile.thinking,
176
+ timeoutMs: profile.timeoutMs,
177
+ signal: controller.signal,
178
+ stallTimeoutMs: this.deps.stallTimeoutMs,
179
+ toolStallTimeoutMs: this.deps.toolStallTimeoutMs,
180
+ onEvent: (event) => this.onEvent(job, event),
181
+ },
182
+ this.deps.runProcess ?? spawnPiProcess,
183
+ );
184
+ job.status = result.status;
185
+ job.report = result.output.trim() || undefined;
186
+ job.error = result.error;
187
+ job.usage = result.usage;
188
+ if (result.model) job.model = result.model;
189
+ } catch (error) {
190
+ job.status = "failed";
191
+ job.error = (error as Error).message;
192
+ } finally {
193
+ this.controllers.delete(job.id);
194
+ this.finish(job);
195
+ }
196
+ }
197
+
198
+ private finish(job: QuickFixJob): void {
199
+ job.finishedAt = Date.now();
200
+ for (const step of job.steps) step.pending = false;
201
+ this.deps.feed?.end(job.id, job.status !== "success");
202
+ const ok = job.status === "success";
203
+ const outcome = ok ? "done" : `${job.status}${job.error ? ` — ${job.error.split("\n")[0]}` : ""}`;
204
+ this.deps.feed?.log(QUICK_FIX_SOURCE, `${outcome}: ${jobTitle(job)}`, ok ? "success" : job.status === "cancelled" ? "warning" : "error", job.finishedAt);
205
+ appendMetrics(this.deps.root, this.deps.configDir, [quickFixMetric(job)]);
206
+ this.deps.notify?.(`bot-lobby quick fix ${outcome}: ${jobTitle(job)}`, ok ? "info" : "warning");
207
+ this.changed();
208
+ this.pump();
209
+ }
210
+ }
211
+
212
+ export function quickFixMetric(job: QuickFixJob): MetricRecord {
213
+ const started = job.startedAt ?? job.createdAt;
214
+ return {
215
+ id: `${job.id}-${started}`,
216
+ kind: "quickfix",
217
+ agent: QUICK_FIX_SOURCE,
218
+ ...(job.model ? { model: job.model } : {}),
219
+ ...(job.thinking ? { thinking: job.thinking } : {}),
220
+ status: job.status === "queued" || job.status === "running" ? "failed" : job.status,
221
+ startedAt: new Date(started).toISOString(),
222
+ durationMs: Math.max(0, (job.finishedAt ?? started) - started),
223
+ turns: job.usage?.turns || job.turns || undefined,
224
+ tools: job.tools,
225
+ ...(job.usage ? { input: job.usage.input, output: job.usage.output, cost: job.usage.cost } : {}),
226
+ };
227
+ }
@@ -0,0 +1,440 @@
1
+ /**
2
+ * The lobby inside pi: one runtime per interactive session. It mounts the
3
+ * full-screen view as an overlay on pi's own TUI (captured through a zero-line
4
+ * anchor widget, so pi does not treat the lobby as a blocking dialog), steps
5
+ * aside while a real dialog asks the user something, narrates the Master's own
6
+ * turn into the feed, passes plan comments to the Master that owns a task and
7
+ * records each Master turn in the metrics log.
8
+ */
9
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
10
+ import { getSelectListTheme } from "@earendil-works/pi-coding-agent";
11
+ import { Key, type OverlayHandle, type TUI } from "@earendil-works/pi-tui";
12
+ import { TERMINAL_STATES } from "../schemas/task.ts";
13
+ import { listTasks, loadTask } from "../state/persistence.ts";
14
+ import { detectProjectRoot, loadConfig } from "../state/project.ts";
15
+ import { addPlanComment, commentMessage, markCommentsDelivered, readPlanComments, undeliveredComments } from "../state/comments.ts";
16
+ import { discardPlannedTask, listPlannedTasks, markPlannedTaskStarted, plannedTaskRequest, type PlannedTask } from "../state/backlog.ts";
17
+ import { appendMetrics, readMetrics, type MetricStatus } from "../state/metrics.ts";
18
+ import { describeToolCall } from "../pi/activity.ts";
19
+ import { applyStatus, currentZenTask, isMinimized, onMinimizeChange, onRunUpdates, setMinimized, setWidgetSuppressor, ZenScene, zenSnapshot } from "../pi/ui.ts";
20
+ import { isSubagentProcess } from "../pi/quiet.ts";
21
+ import { modelRef, resolveLobbyProfile, resolvePanelProfile } from "../pi/model-support.ts";
22
+ import { modelLookup } from "../pi/tools.ts";
23
+ import { startTask } from "../pi/start-task.ts";
24
+ import type { LobbyAgentKind, PanelMember } from "../schemas/configuration.ts";
25
+ import { chatFromEntries, chatText, lobbyFeed, textOf } from "./feed.ts";
26
+ import { QuickFixQueue } from "./quickfix.ts";
27
+ import { PlanningSession, type PlannerSeed } from "./planner.ts";
28
+ import { execCommand, IssuesState } from "./issues.ts";
29
+ import { LobbyView, type LobbyHost, type TabId } from "./view.ts";
30
+ import type { LobbyTheme } from "./layout.ts";
31
+
32
+ export const ANCHOR_KEY = "bot-lobby-anchor";
33
+ /** How often the owning session looks for new plan comments. */
34
+ export const COMMENT_POLL_MS = 3000;
35
+
36
+ interface Runtime {
37
+ pi: ExtensionAPI;
38
+ configDir: string;
39
+ ctx: ExtensionContext;
40
+ root: string;
41
+ tui?: TUI;
42
+ view?: LobbyView;
43
+ handle?: OverlayHandle;
44
+ visible: boolean;
45
+ /** Stepped aside while pi shows a dialog. */
46
+ asideForPrompt: boolean;
47
+ scene: ZenScene;
48
+ quickfix: QuickFixQueue;
49
+ planner?: PlanningSession;
50
+ issues: IssuesState;
51
+ poll?: ReturnType<typeof setInterval>;
52
+ unsubscribeFeed?: () => void;
53
+ }
54
+
55
+ let runtime: Runtime | undefined;
56
+
57
+ export function isLobbyVisible(): boolean {
58
+ return runtime?.visible === true;
59
+ }
60
+
61
+ function rerender(): void {
62
+ if (runtime?.visible) runtime.tui?.requestRender();
63
+ }
64
+
65
+ function sessionModel(ctx: ExtensionContext): string | undefined {
66
+ return ctx.model ? modelRef(ctx.model) : undefined;
67
+ }
68
+
69
+ function lobbyProfile(state: Runtime, kind: LobbyAgentKind) {
70
+ return resolveLobbyProfile(loadConfig(), kind, {
71
+ lookup: modelLookup(state.ctx),
72
+ sessionModel: sessionModel(state.ctx),
73
+ warn: (message) => state.ctx.ui.notify(message, "warning"),
74
+ });
75
+ }
76
+
77
+ /** Pass new comments on this session's task to its Master (held while minimized or paused). */
78
+ export function deliverComments(): number {
79
+ const state = runtime;
80
+ if (!state || isMinimized()) return 0;
81
+ const task = currentZenTask();
82
+ if (!task || task.paused || TERMINAL_STATES.includes(task.state)) return 0;
83
+ const fresh = undeliveredComments(readPlanComments(state.root, state.configDir, task.id));
84
+ if (fresh.length === 0) return 0;
85
+ const current = loadTask(state.root, state.configDir, task.id) ?? task;
86
+ state.pi.sendUserMessage(commentMessage(task.id, fresh, Boolean(current.plan)), state.ctx.isIdle() ? undefined : { deliverAs: "steer" });
87
+ markCommentsDelivered(state.root, state.configDir, task.id, fresh.map((comment) => comment.id));
88
+ lobbyFeed.log("LOBBY", `passed ${fresh.length} plan comment${fresh.length === 1 ? "" : "s"} on ${task.id} to the oracle`, "info");
89
+ return fresh.length;
90
+ }
91
+
92
+ function addComment(state: Runtime, taskId: string, text: string): string {
93
+ const task = loadTask(state.root, state.configDir, taskId);
94
+ if (!task) return `no task ${taskId}`;
95
+ if (TERMINAL_STATES.includes(task.state)) return `${taskId} is ${task.state}; there is no plan left to change`;
96
+ const sessionId = state.ctx.sessionManager.getSessionId();
97
+ try {
98
+ addPlanComment(state.root, state.configDir, taskId, text, sessionId);
99
+ } catch (error) {
100
+ return (error as Error).message;
101
+ }
102
+ lobbyFeed.log("LOBBY", `comment on ${taskId}'s plan saved`, "info");
103
+ if (task.ownerSessionId === sessionId) {
104
+ if (deliverComments() === 0) return `comment saved — it reaches the oracle once ${taskId} is resumed or restored`;
105
+ return `comment sent to the oracle — it will ${task.plan ? "amend the plan" : "revise the proposal"}`;
106
+ }
107
+ if (!task.ownerSessionId) return `comment saved — ${taskId} has no owning session; it is delivered once a session claims it`;
108
+ return `comment saved — the session driving ${taskId} passes it to its oracle`;
109
+ }
110
+
111
+ function failed(state: Runtime, what: string, error: Error): void {
112
+ lobbyFeed.log("LOBBY", `${what} — ${error.message}`, "error");
113
+ state.ctx.ui.notify(`bot-lobby: ${what} — ${error.message}`, "error");
114
+ }
115
+
116
+ function toOracle(state: Runtime, text: string): string | undefined {
117
+ const busy = !state.ctx.isIdle();
118
+ if (text.startsWith("/")) {
119
+ state.pi.sendUserMessage(text, { expandPromptTemplates: true, ...(busy ? { deliverAs: "followUp" as const } : {}) });
120
+ return `sent ${text.split(/\s+/)[0]} to pi (built-in commands need the lobby hidden: alt+l)`;
121
+ }
122
+ if (!currentZenTask()) {
123
+ startTask(state.pi, state.ctx, state.configDir, text).catch((error: Error) => failed(state, "could not start the task", error));
124
+ return "starting a task — the oracle takes it from here";
125
+ }
126
+ state.pi.sendUserMessage(text, busy ? { deliverAs: "steer" } : undefined);
127
+ return undefined;
128
+ }
129
+
130
+ function startPlanned(state: Runtime, plan: PlannedTask): string {
131
+ const current = currentZenTask();
132
+ if (current) return `this session already drives ${current.id}; finish or cancel it first, or start ${plan.id} from another session`;
133
+ startTask(state.pi, state.ctx, state.configDir, plannedTaskRequest(plan))
134
+ .then((task) => {
135
+ if (!task) return;
136
+ markPlannedTaskStarted(state.root, state.configDir, plan.id, task.id);
137
+ state.view?.setTab("lobby");
138
+ rerender();
139
+ })
140
+ .catch((error: Error) => failed(state, `could not start ${plan.id}`, error));
141
+ return `starting ${plan.id} as a task…`;
142
+ }
143
+
144
+ function seatProfile(state: Runtime, member: PanelMember) {
145
+ return resolvePanelProfile(loadConfig(), member, {
146
+ lookup: modelLookup(state.ctx),
147
+ sessionModel: sessionModel(state.ctx),
148
+ warn: (message) => state.ctx.ui.notify(message, "warning"),
149
+ });
150
+ }
151
+
152
+ function newPlanner(state: Runtime, seed?: PlannerSeed, seats?: readonly PanelMember[]): PlanningSession {
153
+ state.planner?.cancel();
154
+ const config = loadConfig();
155
+ const workflow = config.workflow;
156
+ state.planner = new PlanningSession({
157
+ cwd: state.ctx.cwd,
158
+ root: state.root,
159
+ configDir: state.configDir,
160
+ profile: () => lobbyProfile(state, "planner"),
161
+ memberProfile: (member) => seatProfile(state, member),
162
+ panel: seats ?? config.lobby.planningPanel,
163
+ stallTimeoutMs: workflow.stallTimeoutMs,
164
+ toolStallTimeoutMs: workflow.toolStallTimeoutMs,
165
+ feed: lobbyFeed,
166
+ onChange: rerender,
167
+ }, seed);
168
+ return state.planner;
169
+ }
170
+
171
+ function host(state: Runtime, tui: TUI): LobbyHost {
172
+ return {
173
+ rows: () => tui.terminal.rows,
174
+ theme: () => state.ctx.ui.theme as LobbyTheme,
175
+ sessionId: () => state.ctx.sessionManager.getSessionId(),
176
+ zen: () => {
177
+ const snapshot = zenSnapshot();
178
+ return { ...(snapshot.task ? { task: snapshot.task } : {}), runs: snapshot.runs };
179
+ },
180
+ // The scene's height budget is 3/4 of the rows it is given.
181
+ scene: (width, height) => state.scene.lines(width, Math.floor(height / 0.75), state.ctx.ui.theme),
182
+ advanceScene: (now) => {
183
+ state.scene.advance(now);
184
+ return state.scene.delay(now);
185
+ },
186
+ feed: lobbyFeed,
187
+ masterBusy: () => !state.ctx.isIdle(),
188
+ tasks: () => listTasks(state.root, state.configDir),
189
+ plans: () => listPlannedTasks(state.root, state.configDir),
190
+ comments: (taskId) => readPlanComments(state.root, state.configDir, taskId),
191
+ metrics: () => readMetrics(state.root, state.configDir),
192
+ toOracle: (text) => toOracle(state, text),
193
+ comment: (taskId, text) => addComment(state, taskId, text),
194
+ startPlanned: (plan) => startPlanned(state, plan),
195
+ discardPlan: (id) => discardPlannedTask(state.root, state.configDir, id),
196
+ abortMaster: () => state.ctx.abort(),
197
+ hide: () => hideLobby(),
198
+ quickfix: state.quickfix,
199
+ planner: () => state.planner,
200
+ newPlanner: (seed, seats) => newPlanner(state, seed, seats),
201
+ defaultPanel: () => loadConfig().lobby.planningPanel,
202
+ seatLabel: (member) => {
203
+ const profile = seatProfile(state, member);
204
+ return `${profile.model ?? "session model"} · ${profile.thinking}`;
205
+ },
206
+ issues: state.issues,
207
+ profileLabel: (kind) => {
208
+ const profile = lobbyProfile(state, kind);
209
+ return `${profile.model ?? "session model"} · ${profile.thinking}`;
210
+ },
211
+ requestRender: () => tui.requestRender(),
212
+ };
213
+ }
214
+
215
+ /** Show the lobby (optionally on `tab`); false when there is no interactive TUI. */
216
+ export function showLobby(tab?: TabId): boolean {
217
+ const state = runtime;
218
+ if (!state?.tui) return false;
219
+ if (isMinimized()) setMinimized(false);
220
+ if (!state.view || !state.handle) {
221
+ const theme = state.ctx.ui.theme;
222
+ state.view = new LobbyView(state.tui, host(state, state.tui), { borderColor: (text) => theme.fg("border", text), selectList: getSelectListTheme() });
223
+ state.handle = state.tui.showOverlay(state.view, { width: "100%", maxHeight: "100%", anchor: "top-left" });
224
+ } else {
225
+ state.handle.setHidden(false);
226
+ state.handle.focus();
227
+ }
228
+ state.visible = true;
229
+ state.asideForPrompt = false;
230
+ if (tab) state.view.setTab(tab);
231
+ state.view.start();
232
+ applyStatus(state.ctx, state.root, state.configDir);
233
+ state.tui.requestRender();
234
+ return true;
235
+ }
236
+
237
+ export function hideLobby(): void {
238
+ const state = runtime;
239
+ if (!state?.view || !state.visible) return;
240
+ state.visible = false;
241
+ state.asideForPrompt = false;
242
+ state.view.stop();
243
+ state.handle?.setHidden(true);
244
+ applyStatus(state.ctx, state.root, state.configDir);
245
+ state.tui?.requestRender();
246
+ }
247
+
248
+ export function toggleLobby(): boolean {
249
+ if (isLobbyVisible()) {
250
+ hideLobby();
251
+ return true;
252
+ }
253
+ return showLobby();
254
+ }
255
+
256
+ /** Open the lobby when this session drives a task and the config asks for it. */
257
+ export function autoOpenLobby(): void {
258
+ if (runtime && loadConfig().lobby.autoOpen && currentZenTask()) showLobby("lobby");
259
+ }
260
+
261
+ /** Step aside while pi shows a dialog (an approval, a question), and come back after. */
262
+ function promptStarted(): void {
263
+ const state = runtime;
264
+ if (!state?.visible || !state.handle) return;
265
+ state.asideForPrompt = true;
266
+ state.handle.setHidden(true);
267
+ }
268
+
269
+ function promptEnded(): void {
270
+ const state = runtime;
271
+ if (!state?.asideForPrompt || !state.handle) return;
272
+ state.asideForPrompt = false;
273
+ if (state.visible) state.handle.setHidden(false);
274
+ }
275
+
276
+ function shutdown(): void {
277
+ const state = runtime;
278
+ if (!state) return;
279
+ runtime = undefined;
280
+ if (state.poll) clearInterval(state.poll);
281
+ state.unsubscribeFeed?.();
282
+ state.quickfix.cancelAll();
283
+ state.planner?.cancel();
284
+ state.view?.dispose();
285
+ state.handle?.hide();
286
+ setWidgetSuppressor(() => false);
287
+ onRunUpdates(undefined);
288
+ onMinimizeChange(undefined);
289
+ }
290
+
291
+ /** Create the session's lobby runtime (interactive master sessions only). */
292
+ export function initLobby(pi: ExtensionAPI, ctx: ExtensionContext, configDir: string): void {
293
+ shutdown();
294
+ if (isSubagentProcess() || ctx.mode !== "tui") return;
295
+ const root = detectProjectRoot(ctx.cwd, configDir);
296
+ const workflow = loadConfig().workflow;
297
+ const state: Runtime = {
298
+ pi,
299
+ configDir,
300
+ ctx,
301
+ root,
302
+ visible: false,
303
+ asideForPrompt: false,
304
+ scene: new ZenScene(),
305
+ quickfix: undefined as unknown as QuickFixQueue,
306
+ issues: new IssuesState(execCommand, ctx.cwd, rerender),
307
+ };
308
+ state.quickfix = new QuickFixQueue({
309
+ cwd: ctx.cwd,
310
+ root,
311
+ configDir,
312
+ profile: () => lobbyProfile(state, "quickfix"),
313
+ stallTimeoutMs: workflow.stallTimeoutMs,
314
+ toolStallTimeoutMs: workflow.toolStallTimeoutMs,
315
+ feed: lobbyFeed,
316
+ onChange: rerender,
317
+ notify: (message, level) => {
318
+ if (!state.visible) ctx.ui.notify(message, level);
319
+ },
320
+ });
321
+ runtime = state;
322
+ lobbyFeed.clear();
323
+ lobbyFeed.seedChat(chatFromEntries(ctx.sessionManager.getBranch()));
324
+ state.unsubscribeFeed = lobbyFeed.onChange(rerender);
325
+ setWidgetSuppressor(() => runtime?.visible === true);
326
+ onRunUpdates((runs) => lobbyFeed.runs(runs));
327
+ onMinimizeChange((value) => {
328
+ if (value) hideLobby();
329
+ });
330
+ // Capture pi's TUI through a zero-line widget below the editor.
331
+ ctx.ui.setWidget(ANCHOR_KEY, (tui) => {
332
+ state.tui = tui;
333
+ return { render: () => [], invalidate: () => {} };
334
+ }, { placement: "belowEditor" });
335
+ state.poll = setInterval(() => deliverComments(), COMMENT_POLL_MS);
336
+ state.poll.unref?.();
337
+ }
338
+
339
+ interface MasterTurn {
340
+ startedAt: number;
341
+ /** The task the turn worked on; kept from the start so the turn that completes it still counts. */
342
+ taskId?: string;
343
+ model?: string;
344
+ thinking: string;
345
+ tools: number;
346
+ turns: number;
347
+ input: number;
348
+ output: number;
349
+ cost: number;
350
+ }
351
+
352
+ function turnStatus(messages: readonly unknown[]): MetricStatus {
353
+ const last = [...messages].reverse().find((message) => (message as { role?: string })?.role === "assistant") as { stopReason?: string } | undefined;
354
+ if (last?.stopReason === "aborted") return "cancelled";
355
+ if (last?.stopReason === "error") return "failed";
356
+ return "success";
357
+ }
358
+
359
+ /** Narrate the Master's own turn into the feed and record it as a metric; the lobby follows pi's dialogs. */
360
+ export function registerLobbyEvents(pi: ExtensionAPI, configDir: string): void {
361
+ if (isSubagentProcess()) return;
362
+ let turn: MasterTurn | undefined;
363
+ const track = (ctx: ExtensionContext) => {
364
+ if (runtime) runtime.ctx = ctx;
365
+ };
366
+ pi.registerShortcut(Key.alt("l"), {
367
+ description: "bot-lobby: open or hide the lobby",
368
+ handler: (ctx) => {
369
+ if (!toggleLobby()) ctx.ui.notify("The lobby needs pi's interactive terminal UI.", "warning");
370
+ },
371
+ });
372
+ pi.on("session_start", (_event, ctx) => {
373
+ initLobby(pi, ctx, configDir);
374
+ autoOpenLobby();
375
+ });
376
+ pi.on("session_shutdown", () => shutdown());
377
+ pi.on("ui_prompt_start", () => promptStarted());
378
+ pi.on("ui_prompt_end", () => promptEnded());
379
+ pi.on("agent_start", (_event, ctx) => {
380
+ track(ctx);
381
+ const taskId = currentZenTask()?.id;
382
+ turn = { startedAt: Date.now(), ...(taskId ? { taskId } : {}), ...(ctx.model ? { model: modelRef(ctx.model) } : {}), thinking: pi.getThinkingLevel(), tools: 0, turns: 0, input: 0, output: 0, cost: 0 };
383
+ });
384
+ pi.on("tool_execution_start", (event) => {
385
+ if (turn) turn.tools += 1;
386
+ lobbyFeed.begin("MASTER", describeToolCall(event.toolName, event.args), event.toolCallId);
387
+ });
388
+ pi.on("tool_execution_end", (event) => lobbyFeed.end(event.toolCallId, event.isError));
389
+ pi.on("message_update", (event) => {
390
+ const update = event.assistantMessageEvent;
391
+ if (update.type === "thinking_delta") lobbyFeed.thinkDelta("MASTER", update.delta);
392
+ else if (update.type === "thinking_end") lobbyFeed.thinkEnd("MASTER", update.content);
393
+ else if (update.type === "text_delta") lobbyFeed.replyDelta(update.delta);
394
+ });
395
+ pi.on("message_end", (event) => {
396
+ const message = event.message as { role?: string; content?: unknown; stopReason?: string; errorMessage?: string; usage?: { input?: number; output?: number; cost?: { total?: number } } };
397
+ if (message.role === "assistant") {
398
+ lobbyFeed.replyEnd();
399
+ lobbyFeed.thinkEnd("MASTER");
400
+ if (message.stopReason === "error") {
401
+ const error = (message.errorMessage ?? "the model call failed").split("\n")[0]!;
402
+ lobbyFeed.say("note", `✗ the oracle's turn failed: ${error}`);
403
+ lobbyFeed.log("MASTER", `turn failed — ${error}`, "error");
404
+ }
405
+ if (turn) {
406
+ turn.turns += 1;
407
+ turn.input += message.usage?.input ?? 0;
408
+ turn.output += message.usage?.output ?? 0;
409
+ turn.cost += message.usage?.cost?.total ?? 0;
410
+ }
411
+ }
412
+ if (message.role !== "user" && message.role !== "assistant") return;
413
+ const line = chatText(message.role, textOf(message.content));
414
+ if (line) lobbyFeed.say(line.role, line.text);
415
+ });
416
+ pi.on("agent_end", (event, ctx) => {
417
+ track(ctx);
418
+ const finished = turn;
419
+ turn = undefined;
420
+ lobbyFeed.replyEnd();
421
+ const taskId = finished?.taskId ?? currentZenTask()?.id;
422
+ if (!finished || !taskId || !runtime) return;
423
+ appendMetrics(runtime.root, configDir, [{
424
+ id: `master-${finished.startedAt}`,
425
+ kind: "master",
426
+ agent: "MASTER",
427
+ ...(finished.model ? { model: finished.model } : {}),
428
+ thinking: finished.thinking,
429
+ status: turnStatus(event.messages),
430
+ startedAt: new Date(finished.startedAt).toISOString(),
431
+ durationMs: Date.now() - finished.startedAt,
432
+ ...(finished.turns ? { turns: finished.turns } : {}),
433
+ tools: finished.tools,
434
+ input: finished.input,
435
+ output: finished.output,
436
+ cost: finished.cost,
437
+ taskId,
438
+ }]);
439
+ });
440
+ }