@a-t-h-i/bot-lobby 0.5.0 → 0.6.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.
@@ -6,32 +6,41 @@
6
6
  * turn into the feed, passes plan comments to the Master that owns a task and
7
7
  * records each Master turn in the metrics log.
8
8
  */
9
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
10
- import { getSelectListTheme } from "@earendil-works/pi-coding-agent";
9
+ import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
10
+ import { getSelectListTheme, SessionManager } from "@earendil-works/pi-coding-agent";
11
11
  import { Key, type OverlayHandle, type TUI } from "@earendil-works/pi-tui";
12
- import { TERMINAL_STATES } from "../schemas/task.ts";
12
+ import { TERMINAL_STATES, type Task } from "../schemas/task.ts";
13
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";
14
+ import { detectProjectRoot, loadConfig, saveConfig } from "../state/project.ts";
15
+ import { addPlanComment, readPlanComments } from "../state/comments.ts";
16
+ import { isAutoMode } from "../state/auto.ts";
17
+ import { sendToInbox } from "../state/inbox.ts";
18
+ import { discardPlannedTask, listPlannedTasks, type PlannedTask } from "../state/backlog.ts";
17
19
  import { appendMetrics, readMetrics, type MetricStatus } from "../state/metrics.ts";
18
20
  import { describeToolCall } from "../pi/activity.ts";
19
- import { applyStatus, currentZenTask, isMinimized, onMinimizeChange, onRunUpdates, setMinimized, setWidgetSuppressor, ZenScene, zenSnapshot } from "../pi/ui.ts";
21
+ import { applyStatus, currentZenTask, isMinimized, onMinimizeChange, onRunUpdates, persistedRuns, setMinimized, setWidgetSuppressor, ZenScene, zenSnapshot } from "../pi/ui.ts";
22
+ import { panelLines } from "../pi/zen.ts";
23
+ import { shortTitle } from "../text.ts";
20
24
  import { isSubagentProcess } from "../pi/quiet.ts";
21
25
  import { modelRef, resolveLobbyProfile, resolvePanelProfile } from "../pi/model-support.ts";
22
26
  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";
27
+ import { startPlannedTask, startTask } from "../pi/start-task.ts";
28
+ import type { LobbyAgentKind, LobbyPanel, PanelMember } from "../schemas/configuration.ts";
29
+ import { chatFromEntries, lobbyFeed, narrateEvent, type AgentEventLike } from "./feed.ts";
30
+ import { launchPi, SessionRegistry, type BackgroundSession, type SessionLauncher } from "./sessions.ts";
31
+ import { SessionChats } from "./session-files.ts";
32
+ import { answerMessage, dialogAsker, loadAskTool, questionnaires, toolAsker, type Asker } from "./ask.ts";
26
33
  import { QuickFixQueue } from "./quickfix.ts";
27
34
  import { PlanningSession, type PlannerSeed } from "./planner.ts";
28
35
  import { execCommand, IssuesState } from "./issues.ts";
29
36
  import { LobbyView, type LobbyHost, type TabId } from "./view.ts";
30
37
  import type { LobbyTheme } from "./layout.ts";
38
+ import { createMarkdownRenderer } from "./markdown.ts";
39
+ import { deliverComments, onOwnerEvent, setAuto } from "../pi/owner.ts";
40
+ import { openEntrySettings, openSettings } from "../pi/settings-ui.ts";
31
41
 
32
42
  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;
43
+ export { deliverComments };
35
44
 
36
45
  interface Runtime {
37
46
  pi: ExtensionAPI;
@@ -48,8 +57,15 @@ interface Runtime {
48
57
  quickfix: QuickFixQueue;
49
58
  planner?: PlanningSession;
50
59
  issues: IssuesState;
51
- poll?: ReturnType<typeof setInterval>;
52
60
  unsubscribeFeed?: () => void;
61
+ /** The ask-user-question tool (or pi's dialogs), loaded once on first use. */
62
+ asker?: Promise<Asker>;
63
+ /** A questionnaire is on screen. */
64
+ asking: boolean;
65
+ /** The lobby turned the terminal's mouse reporting on (pi's regular screen only). */
66
+ mouse: boolean;
67
+ /** Settings opened from the lobby are on screen; the lobby stays aside until they close. */
68
+ inSettings: boolean;
53
69
  }
54
70
 
55
71
  let runtime: Runtime | undefined;
@@ -58,6 +74,11 @@ export function isLobbyVisible(): boolean {
58
74
  return runtime?.visible === true;
59
75
  }
60
76
 
77
+ /** The lobby's view, once it has been shown (tests drive it through this). */
78
+ export function lobbyView(): LobbyView | undefined {
79
+ return runtime?.view;
80
+ }
81
+
61
82
  function rerender(): void {
62
83
  if (runtime?.visible) runtime.tui?.requestRender();
63
84
  }
@@ -74,21 +95,6 @@ function lobbyProfile(state: Runtime, kind: LobbyAgentKind) {
74
95
  });
75
96
  }
76
97
 
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
98
  function addComment(state: Runtime, taskId: string, text: string): string {
93
99
  const task = loadTask(state.root, state.configDir, taskId);
94
100
  if (!task) return `no task ${taskId}`;
@@ -130,15 +136,155 @@ function toOracle(state: Runtime, text: string): string | undefined {
130
136
  function startPlanned(state: Runtime, plan: PlannedTask): string {
131
137
  const current = currentZenTask();
132
138
  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))
139
+ startPlannedTask(state.pi, state.ctx, state.configDir, plan.id)
134
140
  .then((task) => {
135
- if (!task) return;
136
- markPlannedTaskStarted(state.root, state.configDir, plan.id, task.id);
141
+ if (typeof task === "string") return failed(state, `could not start ${plan.id}`, new Error(task));
137
142
  state.view?.setTab("lobby");
138
143
  rerender();
139
144
  })
140
145
  .catch((error: Error) => failed(state, `could not start ${plan.id}`, error));
141
- return `starting ${plan.id} as a task…`;
146
+ return `starting ${plan.id} here — its agreed plan needs no approval…`;
147
+ }
148
+
149
+ /* ------------------------------------------------- background sessions */
150
+
151
+ let launcher: SessionLauncher = launchPi;
152
+ let registry: SessionRegistry | undefined;
153
+ let exitHooked = false;
154
+ /** How many questions of each session were already announced, and which exits, so each is said once. */
155
+ const announced = new Map<string, number>();
156
+ const exitsSaid = new Set<string>();
157
+ /** Other sessions' conversations, read from their files. */
158
+ const chats = new SessionChats(async () => {
159
+ const state = runtime;
160
+ if (!state) return [];
161
+ const dirs = new Map<string, string | undefined>([[state.ctx.cwd, state.ctx.sessionManager.getSessionDir()]]);
162
+ if (!dirs.has(state.root)) dirs.set(state.root, undefined);
163
+ const lists = await Promise.all([...dirs].map(([cwd, dir]) => SessionManager.list(cwd, dir).catch(() => [])));
164
+ return lists.flat();
165
+ }, () => rerender());
166
+
167
+ /** Launch background sessions with something else (tests); resets the registry. */
168
+ export function setSessionLauncher(next: SessionLauncher | undefined): void {
169
+ registry?.stopAll();
170
+ registry = undefined;
171
+ launcher = next ?? launchPi;
172
+ announced.clear();
173
+ exitsSaid.clear();
174
+ }
175
+
176
+ /**
177
+ * The background sessions this window started. The registry belongs to the
178
+ * process, not to one pi session, so switching sessions here keeps them
179
+ * running; they stop when pi exits (each is a saved session `/resume` finds).
180
+ */
181
+ function sessionRegistry(): SessionRegistry {
182
+ if (!registry) registry = new SessionRegistry(launcher, sessionsChanged);
183
+ if (!exitHooked) {
184
+ exitHooked = true;
185
+ process.once("exit", () => registry?.stopAll());
186
+ }
187
+ return registry;
188
+ }
189
+
190
+ export function backgroundSessions(): readonly BackgroundSession[] {
191
+ return registry?.sessions ?? [];
192
+ }
193
+
194
+ /** A background session changed: redraw, and say so when one asks something or fails while the lobby is hidden. */
195
+ function sessionsChanged(): void {
196
+ const state = runtime;
197
+ for (const session of registry?.sessions ?? []) {
198
+ if (session.sessionId && session.sessionFile) chats.remember(session.sessionId, session.sessionFile);
199
+ const seen = announced.get(session.key) ?? 0;
200
+ announced.set(session.key, session.dialogs.length);
201
+ if (!state || state.visible) continue;
202
+ if (session.dialogs.length > seen) state.ctx.ui.notify(`bot-lobby: ${session.name} is waiting for you — alt+l opens the lobby`, "info");
203
+ if (!session.alive && !exitsSaid.has(session.key)) {
204
+ exitsSaid.add(session.key);
205
+ if (session.exitCode) state.ctx.ui.notify(`bot-lobby: ${session.name} exited (${session.exitCode})${session.lastError() ? `: ${session.lastError()}` : ""}`, "warning");
206
+ }
207
+ }
208
+ rerender();
209
+ }
210
+
211
+ /** Start a task in its own new pi session, named after the task; the session, or why not. */
212
+ function startSession(state: Runtime, start: { request?: string; plan?: PlannedTask; auto?: boolean }): BackgroundSession | string {
213
+ const request = start.request?.trim();
214
+ const plan = start.plan;
215
+ if (!plan && !request) return "describe the task first";
216
+ if (!plan && request!.startsWith("/")) return "a new session starts from a task description, not a command";
217
+ if (plan) {
218
+ if (plan.status !== "pending") return `${plan.id} was already started${plan.startedTaskId ? ` as ${plan.startedTaskId}` : ""}`;
219
+ const starting = backgroundSessions().find((session) => session.alive && session.planId === plan.id);
220
+ if (starting) return `${plan.id} is already starting in ${starting.name}`;
221
+ }
222
+ const name = plan?.title ?? shortTitle(request!);
223
+ try {
224
+ const session = sessionRegistry().start(state.ctx.cwd, { name, ...(plan ? { planId: plan.id } : { request: request! }), ...(start.auto ? { auto: true } : {}) }, sessionModel(state.ctx));
225
+ lobbyFeed.log("LOBBY", `started "${name}" in a new session`, "success");
226
+ return session;
227
+ } catch (error) {
228
+ return `could not start a new session — ${(error as Error).message}`;
229
+ }
230
+ }
231
+
232
+ /** Put the oldest question a background session waits on to the user, with pi's own dialogs; escape cancels it as it would there. */
233
+ async function answerDialog(state: Runtime, session: BackgroundSession): Promise<void> {
234
+ const dialog = session.dialogs[0];
235
+ if (!dialog) return;
236
+ const title = `${session.name} — ${dialog.title}`;
237
+ const ui = state.ctx.ui;
238
+ try {
239
+ if (dialog.method === "select") {
240
+ const value = await ui.select(title, dialog.options ?? []);
241
+ session.answer(dialog.id, value === undefined ? { cancelled: true } : { value });
242
+ } else if (dialog.method === "confirm") {
243
+ session.answer(dialog.id, { confirmed: await ui.confirm(title, dialog.message ?? "") });
244
+ } else {
245
+ const value = dialog.method === "input" ? await ui.input(title, dialog.placeholder) : await ui.editor(title, dialog.prefill);
246
+ session.answer(dialog.id, value === undefined ? { cancelled: true } : { value });
247
+ }
248
+ } catch (error) {
249
+ failed(state, `could not answer ${session.name}`, error as Error);
250
+ }
251
+ }
252
+
253
+ /** Leave a message for a task another terminal's session drives; its owner passes it to the oracle. */
254
+ function sendToTask(state: Runtime, taskId: string, text: string): string {
255
+ const task = loadTask(state.root, state.configDir, taskId);
256
+ if (!task) return `no task ${taskId}`;
257
+ if (TERMINAL_STATES.includes(task.state)) return `${taskId} is ${task.state}`;
258
+ try {
259
+ sendToInbox(state.root, state.configDir, taskId, text, state.ctx.sessionManager.getSessionId());
260
+ } catch (error) {
261
+ return (error as Error).message;
262
+ }
263
+ lobbyFeed.log("LOBBY", `message for ${taskId}'s oracle saved`, "info");
264
+ return task.ownerSessionId ? `sent — the session driving ${taskId} passes it to its oracle` : `saved — ${taskId} has no owning session; its oracle gets it once a session claims it`;
265
+ }
266
+
267
+ /** Auto mode as the lobby last read it, per task: every frame asks, the file is read at most once a second. */
268
+ const autoSeen = new Map<string, { at: number; on: boolean }>();
269
+ const AUTO_READ_MS = 1000;
270
+
271
+ function autoFor(state: Runtime, taskId: string): boolean {
272
+ const now = Date.now();
273
+ const seen = autoSeen.get(taskId);
274
+ if (seen && now - seen.at < AUTO_READ_MS) return seen.on;
275
+ const on = isAutoMode(state.root, state.configDir, taskId);
276
+ autoSeen.set(taskId, { at: now, on });
277
+ return on;
278
+ }
279
+
280
+ function switchAuto(state: Runtime, taskId: string, on: boolean): void {
281
+ setAuto(state.root, state.configDir, taskId, on, state.ctx.sessionManager.getSessionId());
282
+ autoSeen.set(taskId, { at: Date.now(), on });
283
+ }
284
+
285
+ /** A task's status box without animations, for a session other than this window's. */
286
+ function taskScene(state: Runtime, task: Task, width: number, height: number): string[] {
287
+ return panelLines(task, persistedRuns(task), Date.now(), false, { width, rows: Math.floor(height / 0.75), still: true, theme: state.ctx.ui.theme });
142
288
  }
143
289
 
144
290
  function seatProfile(state: Runtime, member: PanelMember) {
@@ -164,21 +310,90 @@ function newPlanner(state: Runtime, seed?: PlannerSeed, seats?: readonly PanelMe
164
310
  toolStallTimeoutMs: workflow.toolStallTimeoutMs,
165
311
  feed: lobbyFeed,
166
312
  onChange: rerender,
313
+ onRound: (session) => {
314
+ // Put the questions to the user at once when they are looking at the Plan tab.
315
+ if (loadConfig().lobby.autoAsk && state.visible && state.view?.tab === "plan" && session.awaitingAnswers) void answerPanel(state);
316
+ },
167
317
  }, seed);
168
318
  return state.planner;
169
319
  }
170
320
 
321
+ /** The library's questionnaire when it loads, pi's own dialogs otherwise. */
322
+ function panelAsker(state: Runtime): Promise<Asker> {
323
+ state.asker ??= loadAskTool(state.pi).then((tool) => (tool ? toolAsker(tool) : dialogAsker()));
324
+ return state.asker;
325
+ }
326
+
327
+ /**
328
+ * The oracle puts the round's questions to the user, one at a time; answered
329
+ * questionnaires are kept if the user stops, so the next call resumes there.
330
+ * Once every questionnaire is done, the answers (and any line comments) start
331
+ * the next round. Returns a notice for the lobby.
332
+ */
333
+ export async function answerPanel(state: Runtime | undefined = runtime): Promise<string> {
334
+ const session = state?.planner;
335
+ if (!state || !session) return "no planning session";
336
+ if (session.busy) return "the panel is still thinking";
337
+ if (session.questions.length === 0) return "no open questions";
338
+ if (state.asking) return "the questions are already open";
339
+ state.asking = true;
340
+ try {
341
+ const asker = await panelAsker(state);
342
+ const chunks = questionnaires(session.questions);
343
+ for (let index = session.answered.length; index < chunks.length; index++) {
344
+ const result = await asker(chunks[index]!, state.ctx);
345
+ if (result.cancelled) {
346
+ rerender();
347
+ return session.answered.length > 0 ? `answers kept — ${chunks.length - session.answered.length} questionnaire${chunks.length - session.answered.length === 1 ? "" : "s"} left; enter resumes` : "questions put away — enter brings them back";
348
+ }
349
+ session.answered = [...session.answered, result];
350
+ }
351
+ const message = answerMessage(session.questions, session.answered);
352
+ if (!message) {
353
+ session.answered = [];
354
+ return "nothing was answered — the questions stay open";
355
+ }
356
+ void session.send(message);
357
+ return "answers sent — the panel is on the next round";
358
+ } catch (error) {
359
+ return `could not put the questions: ${(error as Error).message}`;
360
+ } finally {
361
+ state.asking = false;
362
+ rerender();
363
+ }
364
+ }
365
+
366
+ const renderMarkdown = createMarkdownRenderer();
367
+ const lobbyThemes = new WeakMap<Theme, LobbyTheme>();
368
+
369
+ /** pi's theme as the lobby draws with it, plus Markdown rendering; one wrapper per theme so caches stay warm. */
370
+ function lobbyTheme(theme: Theme): LobbyTheme {
371
+ let wrapped = lobbyThemes.get(theme);
372
+ if (!wrapped) {
373
+ wrapped = {
374
+ fg: (color, text) => theme.fg(color, text),
375
+ bold: (text) => theme.bold(text),
376
+ italic: (text) => theme.italic(text),
377
+ bg: (color, text) => theme.bg(color, text),
378
+ markdown: renderMarkdown,
379
+ strike: (text) => theme.strikethrough(text),
380
+ };
381
+ lobbyThemes.set(theme, wrapped);
382
+ }
383
+ return wrapped;
384
+ }
385
+
171
386
  function host(state: Runtime, tui: TUI): LobbyHost {
172
387
  return {
173
388
  rows: () => tui.terminal.rows,
174
- theme: () => state.ctx.ui.theme as LobbyTheme,
389
+ theme: () => lobbyTheme(state.ctx.ui.theme),
175
390
  sessionId: () => state.ctx.sessionManager.getSessionId(),
176
391
  zen: () => {
177
392
  const snapshot = zenSnapshot();
178
393
  return { ...(snapshot.task ? { task: snapshot.task } : {}), runs: snapshot.runs };
179
394
  },
180
395
  // 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),
396
+ scene: (width, height, animated) => state.scene.lines(width, Math.floor(height / 0.75), state.ctx.ui.theme, Date.now(), !animated),
182
397
  advanceScene: (now) => {
183
398
  state.scene.advance(now);
184
399
  return state.scene.delay(now);
@@ -198,7 +413,13 @@ function host(state: Runtime, tui: TUI): LobbyHost {
198
413
  quickfix: state.quickfix,
199
414
  planner: () => state.planner,
200
415
  newPlanner: (seed, seats) => newPlanner(state, seed, seats),
416
+ answerPanel: () => answerPanel(state),
201
417
  defaultPanel: () => loadConfig().lobby.planningPanel,
418
+ issuesEnabled: () => loadConfig().lobby.issues,
419
+ panels: () => loadConfig().lobby.panels,
420
+ savePanels: (panels) => savePanels(panels),
421
+ keys: () => loadConfig().lobby.keys,
422
+ openSettings: (entry) => lobbySettings(state, entry),
202
423
  seatLabel: (member) => {
203
424
  const profile = seatProfile(state, member);
204
425
  return `${profile.model ?? "session model"} · ${profile.thinking}`;
@@ -208,10 +429,76 @@ function host(state: Runtime, tui: TUI): LobbyHost {
208
429
  const profile = lobbyProfile(state, kind);
209
430
  return `${profile.model ?? "session model"} · ${profile.thinking}`;
210
431
  },
432
+ sessionName: () => state.pi.getSessionName(),
433
+ sessions: () => backgroundSessions(),
434
+ startSession: (start) => startSession(state, start),
435
+ answerDialog: (session) => answerDialog(state, session),
436
+ isAuto: (taskId) => autoFor(state, taskId),
437
+ setAuto: (taskId, on) => switchAuto(state, taskId, on),
438
+ sendToTask: (taskId, text) => sendToTask(state, taskId, text),
439
+ sessionChat: (sessionId) => chats.chat(sessionId),
440
+ taskScene: (task, width, height) => taskScene(state, task, width, height),
211
441
  requestRender: () => tui.requestRender(),
212
442
  };
213
443
  }
214
444
 
445
+ /**
446
+ * bot-lobby's settings (or one agent's entry) from inside the lobby. The
447
+ * lobby stays aside for the whole visit, not only for each menu, so it does
448
+ * not flash between them, and rereads the config when it comes back.
449
+ */
450
+ async function lobbySettings(state: Runtime, entry?: "quickfix" | "planner"): Promise<void> {
451
+ if (state.inSettings) return;
452
+ state.inSettings = true;
453
+ setMouse(state, false);
454
+ state.handle?.setHidden(true);
455
+ try {
456
+ if (entry) await openEntrySettings(state.pi, state.ctx, entry);
457
+ else await openSettings(state.pi, state.ctx);
458
+ } catch (error) {
459
+ state.ctx.ui.notify(`bot-lobby: settings failed — ${(error as Error).message}`, "warning");
460
+ } finally {
461
+ state.inSettings = false;
462
+ state.asideForPrompt = false;
463
+ if (state.visible && state.handle) {
464
+ state.handle.setHidden(false);
465
+ state.handle.focus();
466
+ setMouse(state, true);
467
+ }
468
+ state.view?.reloadConfig();
469
+ rerender();
470
+ }
471
+ }
472
+
473
+ /** Remember which panes show, keeping every other setting as the file has it now. */
474
+ function savePanels(panels: Record<LobbyPanel, boolean>): void {
475
+ try {
476
+ const config = loadConfig();
477
+ saveConfig({ ...config, lobby: { ...config.lobby, panels: { ...panels } } });
478
+ } catch {
479
+ // A read-only config only means the choice lasts for this session.
480
+ }
481
+ }
482
+
483
+ /** SGR mouse reporting: presses, releases and the wheel, in cell coordinates. */
484
+ const MOUSE_ON = "\x1b[?1000h\x1b[?1006h";
485
+ const MOUSE_OFF = "\x1b[?1006l\x1b[?1000l";
486
+
487
+ /**
488
+ * In pi's regular screen nothing reports the mouse, so the lobby turns
489
+ * reporting on while it is up (clicks pick tabs and draft lines, the wheel
490
+ * scrolls) and off whenever it steps aside. Full-screen pi reports the mouse
491
+ * itself and hands the events to the lobby.
492
+ */
493
+ function setMouse(state: Runtime, on: boolean): void {
494
+ const tui = state.tui;
495
+ if (!tui || tui.mode === "fullscreen") return;
496
+ const want = on && loadConfig().lobby.mouse;
497
+ if (want === state.mouse) return;
498
+ state.mouse = want;
499
+ tui.terminal.write(want ? MOUSE_ON : MOUSE_OFF);
500
+ }
501
+
215
502
  /** Show the lobby (optionally on `tab`); false when there is no interactive TUI. */
216
503
  export function showLobby(tab?: TabId): boolean {
217
504
  const state = runtime;
@@ -229,6 +516,7 @@ export function showLobby(tab?: TabId): boolean {
229
516
  state.asideForPrompt = false;
230
517
  if (tab) state.view.setTab(tab);
231
518
  state.view.start();
519
+ setMouse(state, true);
232
520
  applyStatus(state.ctx, state.root, state.configDir);
233
521
  state.tui.requestRender();
234
522
  return true;
@@ -239,6 +527,7 @@ export function hideLobby(): void {
239
527
  if (!state?.view || !state.visible) return;
240
528
  state.visible = false;
241
529
  state.asideForPrompt = false;
530
+ setMouse(state, false);
242
531
  state.view.stop();
243
532
  state.handle?.setHidden(true);
244
533
  applyStatus(state.ctx, state.root, state.configDir);
@@ -261,23 +550,26 @@ export function autoOpenLobby(): void {
261
550
  /** Step aside while pi shows a dialog (an approval, a question), and come back after. */
262
551
  function promptStarted(): void {
263
552
  const state = runtime;
264
- if (!state?.visible || !state.handle) return;
553
+ if (!state?.visible || !state.handle || state.inSettings) return;
265
554
  state.asideForPrompt = true;
555
+ setMouse(state, false);
266
556
  state.handle.setHidden(true);
267
557
  }
268
558
 
269
559
  function promptEnded(): void {
270
560
  const state = runtime;
271
- if (!state?.asideForPrompt || !state.handle) return;
561
+ if (!state?.asideForPrompt || !state.handle || state.inSettings) return;
272
562
  state.asideForPrompt = false;
273
- if (state.visible) state.handle.setHidden(false);
563
+ if (!state.visible) return;
564
+ state.handle.setHidden(false);
565
+ setMouse(state, true);
274
566
  }
275
567
 
276
568
  function shutdown(): void {
277
569
  const state = runtime;
278
570
  if (!state) return;
279
571
  runtime = undefined;
280
- if (state.poll) clearInterval(state.poll);
572
+ setMouse(state, false);
281
573
  state.unsubscribeFeed?.();
282
574
  state.quickfix.cancelAll();
283
575
  state.planner?.cancel();
@@ -301,6 +593,9 @@ export function initLobby(pi: ExtensionAPI, ctx: ExtensionContext, configDir: st
301
593
  root,
302
594
  visible: false,
303
595
  asideForPrompt: false,
596
+ asking: false,
597
+ mouse: false,
598
+ inSettings: false,
304
599
  scene: new ZenScene(),
305
600
  quickfix: undefined as unknown as QuickFixQueue,
306
601
  issues: new IssuesState(execCommand, ctx.cwd, rerender),
@@ -332,8 +627,15 @@ export function initLobby(pi: ExtensionAPI, ctx: ExtensionContext, configDir: st
332
627
  state.tui = tui;
333
628
  return { render: () => [], invalidate: () => {} };
334
629
  }, { placement: "belowEditor" });
335
- state.poll = setInterval(() => deliverComments(), COMMENT_POLL_MS);
336
- state.poll.unref?.();
630
+ // The owner's clock (pi/owner.ts) delivers comments and messages and drives auto mode; the lobby logs what it did.
631
+ onOwnerEvent((event) => {
632
+ if (event.kind === "comments") lobbyFeed.log("LOBBY", `passed ${event.count} plan comment${event.count === 1 ? "" : "s"} on ${event.taskId} to the oracle`, "info");
633
+ else if (event.kind === "inbox") lobbyFeed.log("LOBBY", `passed ${event.count} message${event.count === 1 ? "" : "s"} from another session to the oracle`, "info");
634
+ else if (event.kind === "auto") lobbyFeed.log("LOBBY", `auto mode ${event.on ? "on" : "off"} for ${event.taskId}`, event.on ? "success" : "info");
635
+ else if (event.kind === "nudge") lobbyFeed.log("LOBBY", `auto mode: keeping the oracle going on ${event.taskId}`, "info");
636
+ else lobbyFeed.log("LOBBY", `auto mode: no progress on ${event.taskId} — it needs you`, "warning");
637
+ rerender();
638
+ });
337
639
  }
338
640
 
339
641
  interface MasterTurn {
@@ -381,37 +683,22 @@ export function registerLobbyEvents(pi: ExtensionAPI, configDir: string): void {
381
683
  const taskId = currentZenTask()?.id;
382
684
  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
685
  });
686
+ const narrate = (event: AgentEventLike) => narrateEvent(lobbyFeed, event, describeToolCall);
384
687
  pi.on("tool_execution_start", (event) => {
385
688
  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);
689
+ narrate(event);
394
690
  });
691
+ pi.on("tool_execution_end", (event) => narrate(event));
692
+ pi.on("message_update", (event) => narrate(event as AgentEventLike));
395
693
  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
- }
694
+ const message = event.message as { role?: string; usage?: { input?: number; output?: number; cost?: { total?: number } } };
695
+ if (message.role === "assistant" && turn) {
696
+ turn.turns += 1;
697
+ turn.input += message.usage?.input ?? 0;
698
+ turn.output += message.usage?.output ?? 0;
699
+ turn.cost += message.usage?.cost?.total ?? 0;
411
700
  }
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);
701
+ narrate(event as AgentEventLike);
415
702
  });
416
703
  pi.on("agent_end", (event, ctx) => {
417
704
  track(ctx);