@a-t-h-i/bot-lobby 0.6.12 → 0.6.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +11 -3
  2. package/package.json +31 -4
  3. package/src/ask/tool.ts +2 -1
  4. package/src/lobby/keys.ts +14 -1
  5. package/src/lobby/mini.ts +9 -1
  6. package/src/lobby/models/metrics.ts +14 -0
  7. package/src/lobby/planner.ts +1 -1
  8. package/src/lobby/prompt-hub.ts +161 -0
  9. package/src/lobby/prompts.ts +35 -0
  10. package/src/lobby/runtime.ts +115 -566
  11. package/src/lobby/service.ts +655 -0
  12. package/src/lobby/tabs/home.ts +1 -1
  13. package/src/lobby/tabs/metrics.ts +4 -6
  14. package/src/lobby/topics.ts +78 -0
  15. package/src/lobby/view.ts +29 -24
  16. package/src/pi/commands.ts +8 -2
  17. package/src/pi/owner.ts +2 -0
  18. package/src/pi/settings-ui.ts +52 -1
  19. package/src/pi/tools.ts +4 -3
  20. package/src/schemas/configuration.ts +33 -1
  21. package/src/state/changes.ts +1 -1
  22. package/src/state/persistence.ts +1 -1
  23. package/src/state/presence.ts +2 -0
  24. package/src/webui/api/excalidraw.ts +105 -0
  25. package/src/webui/api/gh-error.ts +17 -0
  26. package/src/webui/api/git.ts +98 -0
  27. package/src/webui/api/index.ts +379 -0
  28. package/src/webui/api/issues.ts +50 -0
  29. package/src/webui/api/knowledge.ts +79 -0
  30. package/src/webui/api/lobby.ts +91 -0
  31. package/src/webui/api/metrics.ts +63 -0
  32. package/src/webui/api/plan-facts.ts +29 -0
  33. package/src/webui/api/planner.ts +98 -0
  34. package/src/webui/api/plans.ts +44 -0
  35. package/src/webui/api/prompts.ts +35 -0
  36. package/src/webui/api/quickfix.ts +48 -0
  37. package/src/webui/api/sessions.ts +114 -0
  38. package/src/webui/api/settings.ts +93 -0
  39. package/src/webui/api/status.ts +55 -0
  40. package/src/webui/api/tasks.ts +204 -0
  41. package/src/webui/auth.ts +153 -0
  42. package/src/webui/command.ts +152 -0
  43. package/src/webui/dev/fake-service.ts +546 -0
  44. package/src/webui/dev/fixtures.ts +180 -0
  45. package/src/webui/dev/serve.ts +54 -0
  46. package/src/webui/events.ts +207 -0
  47. package/src/webui/files.ts +88 -0
  48. package/src/webui/notices.ts +61 -0
  49. package/src/webui/open.ts +35 -0
  50. package/src/webui/protocol.ts +565 -0
  51. package/src/webui/server.ts +315 -0
  52. package/src/webui/static.ts +125 -0
  53. package/webui/dist/assets/index-Bk-lpAGm.css +2 -0
  54. package/webui/dist/assets/index-E6MPcbdC.js +80 -0
  55. package/webui/dist/build.json +494 -0
  56. package/webui/dist/icon-192.png +0 -0
  57. package/webui/dist/icon-512.png +0 -0
  58. package/webui/dist/index.html +18 -0
  59. package/webui/dist/manifest.webmanifest +36 -0
  60. package/webui/dist/offline.html +57 -0
  61. package/webui/dist/sw.js +88 -0
  62. package/webui/dist/vite.svg +1 -0
@@ -7,55 +7,46 @@
7
7
  * records each Master turn in the metrics log.
8
8
  */
9
9
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
10
- import { getSelectListTheme, SessionManager } from "@earendil-works/pi-coding-agent";
10
+ import { getSelectListTheme } from "@earendil-works/pi-coding-agent";
11
11
  import { Key, type OverlayHandle, type TUI } from "@earendil-works/pi-tui";
12
- import { TERMINAL_STATES, type Task } from "../schemas/task.ts";
13
- import { loadTask, peekTasks } from "../state/persistence.ts";
14
12
  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, sendToSession as leaveForSession } from "../state/inbox.ts";
18
- import { livePresence } from "../state/presence.ts";
19
- import { archiveTask as archiveTaskOnDisk, deleteTask as deleteTaskOnDisk, listArchivedTasks, restoreTask as restoreTaskOnDisk } from "../state/archive.ts";
20
- import { discardPlannedTask, listPlannedTasks, type PlannedTask } from "../state/backlog.ts";
21
- import { appendMetrics, readClassifierMetrics, readMetrics, type MetricStatus } from "../state/metrics.ts";
13
+ import { appendMetrics, type MetricStatus } from "../state/metrics.ts";
22
14
  import { describeToolCall } from "../pi/activity.ts";
23
15
  import { applyStatus, currentZenTask, isMinimized, onMinimizeChange, onRunUpdates, setMinimized, taskSnapshot } from "../pi/ui.ts";
24
- import { taskName } from "../text.ts";
25
- import { describeWorkspace, type WorkspaceInfo } from "../execution/workspace.ts";
16
+ import type { WorkspaceInfo } from "../execution/workspace.ts";
26
17
  import { basename } from "node:path";
27
- import { stripStartFlags } from "../pi/start-flags.ts";
28
18
  import { isSubagentProcess } from "../pi/quiet.ts";
29
- import { modelRef, resolveLobbyProfile, resolvePanelProfile, resolveReviewProfile } from "../pi/model-support.ts";
19
+ import { modelRef } from "../pi/model-support.ts";
30
20
  import { modelLookup } from "../pi/tools.ts";
31
- import { startPlannedTask } from "../pi/start-task.ts";
32
- import { pendingRequest, setQuickFixHandoff, startRequest } from "../pi/route.ts";
21
+ import { setQuickFixHandoff } from "../pi/route.ts";
22
+ import { currentWebServer } from "../webui/server.ts";
33
23
  import type { Domain } from "../schemas/agent.ts";
34
- import type { LobbyAgentKind, LobbyPanel, PanelMember } from "../schemas/configuration.ts";
35
- import { chatFromEntries, lobbyFeed, narrateEvent, type AgentEventLike, type ChatEntry } from "./feed.ts";
24
+ import type { LobbyPanel } from "../schemas/configuration.ts";
25
+ import { chatFromEntries, lobbyFeed, narrateEvent, type AgentEventLike } from "./feed.ts";
36
26
  import { classifier, effortFor, hintsFor } from "../classifier/instance.ts";
37
27
  import { checkThinking } from "../pi/model-support.ts";
38
- import { launchPi, SessionRegistry, type BackgroundSession, type SessionLauncher } from "./sessions.ts";
39
- import { SessionChats } from "./session-files.ts";
40
- import { answerMessage, askUser, questionnaires, settledQuestions, type Asker } from "./ask.ts";
41
28
  import { jobTitle, QuickFixQueue } from "./quickfix.ts";
29
+ import type { Asker } from "./ask.ts";
42
30
  import { miniLine, type MiniInput } from "./mini.ts";
43
- import { PlanningSession, type PlannerSeed } from "./planner.ts";
31
+ import type { PlanningSession } from "./planner.ts";
44
32
  import { execCommand, IssuesState } from "./issues.ts";
45
33
  import { PullsState } from "./pulls.ts";
46
34
  import { PullReviews } from "./pr-review.ts";
47
35
  import { KnowledgeBook } from "./knowledge.ts";
48
36
  import { ExcalidrawBook } from "../excalidraw/sessions.ts";
49
- import { checkSession } from "../excalidraw/check.ts";
50
- import { LobbyView, type LiveSession, type LobbyHost, type SwitchTarget, type TabId } from "./view.ts";
37
+ import { LobbyView, type LobbyHost, type TabId } from "./view.ts";
38
+ import { createLobbyService, lobbyProfile, miniShown, repaintLobby, reviewProfile, seatProfile, setServiceState, takeReopenAfterSwitch, savePlan as serviceSavePlan, answerPanel as serviceAnswerPanel, type LobbyService } from "./service.ts";
39
+ import { lobbyTopics } from "./topics.ts";
40
+ import { promptHub } from "./prompt-hub.ts";
41
+ export { backgroundSessions, FRAME_MS, setSessionLauncher } from "./service.ts";
51
42
  import { lobbyTheme } from "./theme.ts";
52
- import { deliverComments, onOwnerEvent, setAuto } from "../pi/owner.ts";
43
+ import { deliverComments, onOwnerEvent } from "../pi/owner.ts";
53
44
  import { openEntrySettings, openSettings } from "../pi/settings-ui.ts";
54
45
 
55
46
  export const ANCHOR_KEY = "bot-lobby-anchor";
56
47
  export { deliverComments };
57
48
 
58
- interface Runtime {
49
+ export interface Runtime {
59
50
  pi: ExtensionAPI;
60
51
  configDir: string;
61
52
  ctx: ExtensionContext;
@@ -74,6 +65,10 @@ interface Runtime {
74
65
  knowledge: KnowledgeBook;
75
66
  excalidraw: ExcalidrawBook;
76
67
  unsubscribeFeed?: () => void;
68
+ /** The terminal's subscription to every topic (repaints, as before). */
69
+ unsubscribeTopics?: () => void;
70
+ /** The terminal's registration as the hub's only prompt surface. */
71
+ unregisterTerminalSurface?: () => void;
77
72
  /** Puts the panel's questions to the user: the questionnaire unless a test sets another. */
78
73
  asker?: Asker;
79
74
  /** A questionnaire is on screen. */
@@ -88,6 +83,12 @@ interface Runtime {
88
83
  }
89
84
 
90
85
  let runtime: Runtime | undefined;
86
+ let lobbyService: LobbyService | undefined;
87
+
88
+ /** The lobby's backend, once its service has started (terminal and web alike). */
89
+ export function currentLobbyService(): LobbyService | undefined {
90
+ return lobbyService;
91
+ }
91
92
 
92
93
  export function isLobbyVisible(): boolean {
93
94
  return runtime?.visible === true;
@@ -98,34 +99,9 @@ export function lobbyView(): LobbyView | undefined {
98
99
  return runtime?.view;
99
100
  }
100
101
 
101
- /** Repaints asked for by what happens in the background (a streamed token, a run update) come at most this often. */
102
- export const FRAME_MS = 40;
103
- let lastFrame = 0;
104
- let frameTimer: ReturnType<typeof setTimeout> | undefined;
105
-
106
- /**
107
- * Repaint the lobby for something that happened in the background. A reply
108
- * streams dozens of events a second; they share frames, at most one per
109
- * `FRAME_MS`, instead of each asking for its own. Keys repaint at once
110
- * through pi.
111
- */
102
+ /** Repaint through the lobby service's frame throttle (see service.ts). */
112
103
  function rerender(): void {
113
- // Hidden, only the one-line status under the editor changes.
114
- if (!runtime || (!runtime.visible && !miniShown(runtime)) || frameTimer) return;
115
- const wait = lastFrame + FRAME_MS - Date.now();
116
- const paint = () => {
117
- frameTimer = undefined;
118
- lastFrame = Date.now();
119
- if (runtime?.visible || (runtime && miniShown(runtime))) runtime.tui?.requestRender();
120
- };
121
- if (wait <= 0) return paint();
122
- frameTimer = setTimeout(paint, wait);
123
- frameTimer.unref?.();
124
- }
125
-
126
- /** The status line shows under the editor while the lobby is hidden (not while bot-lobby is minimized), unless settings turn it off. */
127
- function miniShown(state: Runtime): boolean {
128
- return !state.visible && !isMinimized() && loadConfig().lobby.miniLine;
104
+ repaintLobby();
129
105
  }
130
106
 
131
107
  /** What the status line shows: the task, the planning round, the quick fix in hand. */
@@ -134,525 +110,38 @@ function miniInput(state: Runtime): MiniInput {
134
110
  const planner = state.planner;
135
111
  const job = state.quickfix.running ?? state.quickfix.jobs.find((entry) => entry.status === "queued");
136
112
  const queued = state.quickfix.jobs.filter((entry) => entry.status === "queued" && entry !== job).length;
113
+ const webLink = currentWebServer()?.link;
137
114
  return {
138
115
  ...(snapshot.task ? { task: snapshot.task } : {}),
139
116
  runs: snapshot.runs,
140
117
  ...(planner ? { planning: { busy: planner.busy, round: planner.turns, limit: loadConfig().lobby.maxPlanningRounds, questions: planner.awaitingAnswers ? planner.questions.length : 0, ready: planner.reply?.status === "ready", saved: Boolean(planner.saved) } } : {}),
141
118
  ...(job ? { quickfix: { title: jobTitle(job), running: job.status === "running", queued } } : {}),
119
+ ...(webLink ? { webLink } : {}),
142
120
  key: "Alt+L",
143
121
  };
144
122
  }
145
123
 
146
- function sessionModel(ctx: ExtensionContext): string | undefined {
147
- return ctx.model ? modelRef(ctx.model) : undefined;
148
- }
149
-
150
- function lobbyProfile(state: Runtime, kind: LobbyAgentKind) {
151
- return resolveLobbyProfile(loadConfig(), kind, {
152
- lookup: modelLookup(state.ctx),
153
- sessionModel: sessionModel(state.ctx),
154
- warn: (message) => state.ctx.ui.notify(message, "warning"),
155
- });
156
- }
157
-
158
- function addComment(state: Runtime, taskId: string, text: string): string {
159
- const task = loadTask(state.root, state.configDir, taskId);
160
- if (!task) return `no task ${taskId}`;
161
- if (TERMINAL_STATES.includes(task.state)) return `${taskId} is ${task.state}; there is no plan left to change`;
162
- const sessionId = state.ctx.sessionManager.getSessionId();
163
- try {
164
- addPlanComment(state.root, state.configDir, taskId, text, sessionId);
165
- } catch (error) {
166
- return (error as Error).message;
167
- }
168
- lobbyFeed.log("LOBBY", `comment on ${taskId}'s plan saved`, "info");
169
- if (task.ownerSessionId === sessionId) {
170
- if (deliverComments() === 0) return `comment saved — it reaches the oracle once ${taskId} is resumed or restored`;
171
- return `comment sent to the oracle — it will ${task.plan ? "amend the plan" : "revise the proposal"}`;
172
- }
173
- if (!task.ownerSessionId) return `comment saved — ${taskId} has no owning session; it is delivered once a session claims it`;
174
- return `comment saved — the session driving ${taskId} passes it to its oracle`;
175
- }
176
-
177
- function failed(state: Runtime, what: string, error: Error): void {
178
- lobbyFeed.log("LOBBY", `${what} — ${error.message}`, "error");
179
- state.ctx.ui.notify(`bot-lobby: ${what} — ${error.message}`, "error");
180
- }
181
-
182
- function toOracle(state: Runtime, text: string): string | undefined {
183
- const busy = !state.ctx.isIdle();
184
- if (text.startsWith("/")) {
185
- state.pi.sendUserMessage(text, { expandPromptTemplates: true, ...(busy ? { deliverAs: "followUp" as const } : {}) });
186
- return `sent ${text.split(/\s+/)[0]} to pi (built-in commands need the lobby hidden: alt+l)`;
187
- }
188
- // While the oracle decides where a request goes, what the user types joins that conversation.
189
- if (!currentZenTask() && !pendingRequest()) {
190
- startRequest(state.pi, state.ctx, state.configDir, text).catch((error: Error) => failed(state, "could not start the task", error));
191
- return "reading your request — a quick fix or a task, the oracle takes it from here";
192
- }
193
- state.pi.sendUserMessage(text, busy ? { deliverAs: "steer" } : undefined);
194
- return undefined;
195
- }
196
-
197
- function startPlanned(state: Runtime, plan: PlannedTask): string {
198
- const current = currentZenTask();
199
- if (current) return `this session already drives ${current.id}; finish or cancel it first, or start ${plan.id} from another session`;
200
- startPlannedTask(state.pi, state.ctx, state.configDir, plan.id)
201
- .then((task) => {
202
- if (typeof task === "string") return failed(state, `could not start ${plan.id}`, new Error(task));
203
- state.view?.setTab("lobby");
204
- rerender();
205
- })
206
- .catch((error: Error) => failed(state, `could not start ${plan.id}`, error));
207
- return `starting ${plan.id} here — its agreed plan needs no approval…`;
208
- }
209
-
210
- /* ------------------------------------------------- background sessions */
211
-
212
- let launcher: SessionLauncher = launchPi;
213
- let registry: SessionRegistry | undefined;
214
- let exitHooked = false;
215
- /** How many questions of each session were already announced, and which exits, so each is said once. */
216
- const announced = new Map<string, number>();
217
- const exitsSaid = new Set<string>();
218
- /** Other sessions' conversations, read from their files. */
219
- const chats = new SessionChats(async () => {
220
- const state = runtime;
221
- if (!state) return [];
222
- const dirs = new Map<string, string | undefined>([[state.ctx.cwd, state.ctx.sessionManager.getSessionDir()]]);
223
- if (!dirs.has(state.root)) dirs.set(state.root, undefined);
224
- const lists = await Promise.all([...dirs].map(([cwd, dir]) => SessionManager.list(cwd, dir).catch(() => [])));
225
- return lists.flat();
226
- }, () => rerender());
227
-
228
- /** Launch background sessions with something else (tests); resets the registry. */
229
- export function setSessionLauncher(next: SessionLauncher | undefined): void {
230
- registry?.stopAll();
231
- registry = undefined;
232
- launcher = next ?? launchPi;
233
- announced.clear();
234
- exitsSaid.clear();
235
- }
236
-
237
- /**
238
- * The background sessions this window started. The registry belongs to the
239
- * process, not to one pi session, so switching sessions here keeps them
240
- * running; they stop when pi exits (each is a saved session `/resume` finds).
241
- */
242
- function sessionRegistry(): SessionRegistry {
243
- if (!registry) registry = new SessionRegistry(launcher, sessionsChanged);
244
- if (!exitHooked) {
245
- exitHooked = true;
246
- process.once("exit", () => registry?.stopAll());
247
- }
248
- return registry;
249
- }
250
-
251
- export function backgroundSessions(): readonly BackgroundSession[] {
252
- return registry?.sessions ?? [];
253
- }
254
-
255
- /** A background session changed: redraw, and say so when one asks something or fails while the lobby is hidden. */
256
- function sessionsChanged(): void {
257
- const state = runtime;
258
- for (const session of registry?.sessions ?? []) {
259
- if (session.sessionId && session.sessionFile) chats.remember(session.sessionId, session.sessionFile);
260
- const seen = announced.get(session.key) ?? 0;
261
- announced.set(session.key, session.dialogs.length);
262
- if (!state || state.visible) continue;
263
- if (session.dialogs.length > seen) state.ctx.ui.notify(`bot-lobby: ${session.name} is waiting for you — alt+l opens the lobby`, "info");
264
- if (!session.alive && !exitsSaid.has(session.key)) {
265
- exitsSaid.add(session.key);
266
- if (session.exitCode) state.ctx.ui.notify(`bot-lobby: ${session.name} exited (${session.exitCode})${session.lastError() ? `: ${session.lastError()}` : ""}`, "warning");
267
- }
268
- }
269
- rerender();
270
- }
271
-
272
- /** Start a task in its own new pi session, named after the task; the session, or why not. */
273
- function startSession(state: Runtime, start: { request?: string; plan?: PlannedTask; auto?: boolean }): BackgroundSession | string {
274
- const request = start.request?.trim();
275
- const plan = start.plan;
276
- if (!plan && !request) return "describe the task first";
277
- if (!plan && request!.startsWith("/")) return "a new session starts from a task description, not a command";
278
- if (plan) {
279
- if (plan.status !== "pending") return `${plan.id} was already started${plan.startedTaskId ? ` as ${plan.startedTaskId}` : ""}`;
280
- const starting = backgroundSessions().find((session) => session.alive && session.planId === plan.id);
281
- if (starting) return `${plan.id} is already starting in ${starting.name}`;
282
- }
283
- // A request may lead with flags (`--worktree add login`); the session is named after what follows them.
284
- const name = taskName(plan?.title ?? (stripStartFlags(request!) || request!));
285
- try {
286
- const session = sessionRegistry().start(state.ctx.cwd, { name, ...(plan ? { planId: plan.id } : { request: request! }), ...(start.auto ? { auto: true } : {}) }, sessionModel(state.ctx));
287
- lobbyFeed.log("LOBBY", `started "${name}" in a new session`, "success");
288
- return session;
289
- } catch (error) {
290
- return `could not start a new session — ${(error as Error).message}`;
291
- }
292
- }
293
-
294
- /** Put the oldest question a background session waits on to the user, with pi's own dialogs; escape cancels it as it would there. */
295
- async function answerDialog(state: Runtime, session: BackgroundSession): Promise<void> {
296
- const dialog = session.dialogs[0];
297
- if (!dialog) return;
298
- const title = `${session.name} — ${dialog.title}`;
299
- const ui = state.ctx.ui;
300
- try {
301
- if (dialog.method === "select") {
302
- const value = await ui.select(title, dialog.options ?? []);
303
- session.answer(dialog.id, value === undefined ? { cancelled: true } : { value });
304
- } else if (dialog.method === "confirm") {
305
- session.answer(dialog.id, { confirmed: await ui.confirm(title, dialog.message ?? "") });
306
- } else {
307
- const value = dialog.method === "input" ? await ui.input(title, dialog.placeholder) : await ui.editor(title, dialog.prefill);
308
- session.answer(dialog.id, value === undefined ? { cancelled: true } : { value });
309
- }
310
- } catch (error) {
311
- failed(state, `could not answer ${session.name}`, error as Error);
312
- }
313
- }
314
-
315
- /** Leave a message for a task another terminal's session drives; its owner passes it to the oracle. */
316
- function sendToTask(state: Runtime, taskId: string, text: string): string {
317
- const task = loadTask(state.root, state.configDir, taskId);
318
- if (!task) return `no task ${taskId}`;
319
- if (TERMINAL_STATES.includes(task.state)) return `${taskId} is ${task.state}`;
320
- try {
321
- sendToInbox(state.root, state.configDir, taskId, text, state.ctx.sessionManager.getSessionId());
322
- } catch (error) {
323
- return (error as Error).message;
324
- }
325
- lobbyFeed.log("LOBBY", `message for ${taskId}'s oracle saved`, "info");
326
- 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`;
327
- }
328
-
329
- /** Auto mode as the lobby last read it, per task: every frame asks, the file is read at most once a second. */
330
- const autoSeen = new Map<string, { at: number; on: boolean }>();
331
- const AUTO_READ_MS = 1000;
332
-
333
- function autoFor(state: Runtime, taskId: string): boolean {
334
- const now = Date.now();
335
- const seen = autoSeen.get(taskId);
336
- if (seen && now - seen.at < AUTO_READ_MS) return seen.on;
337
- const on = isAutoMode(state.root, state.configDir, taskId);
338
- autoSeen.set(taskId, { at: now, on });
339
- return on;
340
- }
341
-
342
- function switchAuto(state: Runtime, taskId: string, on: boolean): void {
343
- setAuto(state.root, state.configDir, taskId, on, state.ctx.sessionManager.getSessionId());
344
- autoSeen.set(taskId, { at: Date.now(), on });
345
- }
346
-
347
- /** Heartbeats of the other sessions running in this project, reread at most once a second (every frame asks). */
348
- let liveSeen: { at: number; root: string; sessions: LiveSession[] } | undefined;
349
- const LIVE_READ_MS = 1000;
350
-
351
- function liveSessions(state: Runtime): LiveSession[] {
352
- const now = Date.now();
353
- if (liveSeen && liveSeen.root === state.root && now - liveSeen.at < LIVE_READ_MS) return liveSeen.sessions;
354
- const me = state.ctx.sessionManager.getSessionId();
355
- let sessions: LiveSession[] = [];
356
- try {
357
- sessions = livePresence(state.root, state.configDir, now).filter((presence) => presence.sessionId !== me);
358
- } catch {
359
- // An unreadable folder only means no other sessions are listed.
360
- }
361
- liveSeen = { at: now, root: state.root, sessions };
362
- return sessions;
363
- }
364
-
365
- /**
366
- * A session's whole conversation, oldest first: this window's from pi's
367
- * branch (already in memory), another's from its session file. Loaded only
368
- * while the lobby is scrolled back through it.
369
- */
370
- function chatHistory(state: Runtime, sessionId?: string): ChatEntry[] {
371
- if (sessionId) return chats.history(sessionId);
372
- return chatFromEntries(state.ctx.sessionManager.getBranch(), Number.POSITIVE_INFINITY).map((line, index) => ({ id: -(index + 1), at: line.at ?? 0, role: line.role, text: line.text }));
373
- }
374
-
375
- /** Leave a message for a session running in another terminal. */
376
- function sendToSession(state: Runtime, sessionId: string, text: string): string {
377
- try {
378
- leaveForSession(state.root, state.configDir, sessionId, text, state.ctx.sessionManager.getSessionId());
379
- } catch (error) {
380
- return (error as Error).message;
381
- }
382
- lobbyFeed.log("LOBBY", "message for another session's oracle saved", "info");
383
- return "sent — that session passes it to its oracle within a few seconds";
384
- }
385
-
386
- /** The lobby reopens on the session this window switches to (set just before asking pi to switch). */
387
- let reopenAfterSwitch = false;
388
-
389
- /**
390
- * Run another session in this window through `/bot-lobby switch`, which pi
391
- * runs with a command context (the only one that may replace the session).
392
- * A background session is stopped first, so one process writes its file; a
393
- * task no session owns is claimed instead.
394
- */
395
- async function switchTo(state: Runtime, target: SwitchTarget): Promise<string> {
396
- if (!state.ctx.isIdle()) return "this window's oracle is working — esc stops it, then switch";
397
- if (target.claimTaskId) {
398
- reopenAfterSwitch = false;
399
- state.pi.sendUserMessage(`/bot-lobby claim ${target.claimTaskId}`, { expandPromptTemplates: true });
400
- return `taking ${target.claimTaskId} over in this window`;
401
- }
402
- let file = target.background?.sessionFile;
403
- if (target.background) {
404
- target.background.stop();
405
- await target.background.whenExited();
406
- }
407
- file ??= target.sessionId ? await chats.locate(target.sessionId) : undefined;
408
- if (!file) return `could not find ${target.name}'s session file — /resume lists every saved session`;
409
- reopenAfterSwitch = true;
410
- state.pi.sendUserMessage(`/bot-lobby switch ${file}`, { expandPromptTemplates: true });
411
- return `switching this window to ${target.name}…`;
412
- }
413
-
414
- /** Why a task cannot be archived or deleted now: a running session drives it. */
415
- function drivenElsewhere(state: Runtime, task: Task): string | undefined {
416
- if (TERMINAL_STATES.includes(task.state) || !task.ownerSessionId) return undefined;
417
- if (task.ownerSessionId === state.ctx.sessionManager.getSessionId()) return `this window drives ${task.id} — cancel it first (/bot-lobby cancel ${task.id})`;
418
- const background = backgroundSessions().find((session) => session.alive && session.sessionId === task.ownerSessionId);
419
- if (background) return `${background.name} is driving ${task.id} in the background — stop it first (x x)`;
420
- if (liveSessions(state).some((session) => session.sessionId === task.ownerSessionId)) return `a session in another terminal is driving ${task.id} — finish or cancel it there first`;
421
- return undefined;
422
- }
423
-
424
- function archiveTask(state: Runtime, taskId: string): string {
425
- const task = loadTask(state.root, state.configDir, taskId);
426
- if (!task) return `no task ${taskId}`;
427
- const busy = drivenElsewhere(state, task);
428
- if (busy) return busy;
429
- try {
430
- archiveTaskOnDisk(state.root, state.configDir, taskId);
431
- } catch (error) {
432
- return (error as Error).message;
433
- }
434
- lobbyFeed.log("LOBBY", `archived ${taskId}`, "info");
435
- return `archived ${taskId}${TERMINAL_STATES.includes(task.state) ? "" : " (abandoned first)"} — v shows archived tasks, a restores one`;
436
- }
437
-
438
- function restoreTask(state: Runtime, taskId: string): string {
439
- try {
440
- restoreTaskOnDisk(state.root, state.configDir, taskId);
441
- } catch (error) {
442
- return (error as Error).message;
443
- }
444
- lobbyFeed.log("LOBBY", `restored ${taskId}`, "info");
445
- return `restored ${taskId} to the task list`;
446
- }
447
-
448
- function deleteTask(state: Runtime, taskId: string, where: "list" | "archive"): string {
449
- if (where === "list") {
450
- const task = loadTask(state.root, state.configDir, taskId);
451
- if (!task) return `no task ${taskId}`;
452
- const busy = drivenElsewhere(state, task);
453
- if (busy) return busy;
454
- }
455
- try {
456
- deleteTaskOnDisk(state.root, state.configDir, taskId, where);
457
- } catch (error) {
458
- return (error as Error).message;
459
- }
460
- lobbyFeed.log("LOBBY", `deleted ${taskId}`, "warning");
461
- return `deleted ${taskId} for good`;
462
- }
463
-
464
- /** What a pull request review runs on: QA's model, thinking and time limit. */
465
- function reviewProfile(state: Runtime) {
466
- return resolveReviewProfile(loadConfig(), {
467
- lookup: modelLookup(state.ctx),
468
- sessionModel: sessionModel(state.ctx),
469
- warn: (message) => state.ctx.ui.notify(message, "warning"),
470
- });
471
- }
472
-
473
- function seatProfile(state: Runtime, member: PanelMember) {
474
- return resolvePanelProfile(loadConfig(), member, {
475
- lookup: modelLookup(state.ctx),
476
- sessionModel: sessionModel(state.ctx),
477
- warn: (message) => state.ctx.ui.notify(message, "warning"),
478
- });
479
- }
480
-
481
- function newPlanner(state: Runtime, seed?: PlannerSeed, seats?: readonly PanelMember[]): PlanningSession {
482
- state.planner?.cancel();
483
- const config = loadConfig();
484
- const workflow = config.workflow;
485
- state.planner = new PlanningSession({
486
- cwd: state.ctx.cwd,
487
- root: state.root,
488
- configDir: state.configDir,
489
- profile: () => lobbyProfile(state, "planner"),
490
- memberProfile: (member) => seatProfile(state, member),
491
- panel: seats ?? config.lobby.planningPanel,
492
- maxRounds: () => loadConfig().lobby.maxPlanningRounds,
493
- classifier: classifier(),
494
- hints: hintsFor({ cwd: state.ctx.cwd, root: state.root, configDir: state.configDir }),
495
- effort: effortFor((model, thinking) => checkThinking(modelLookup(state.ctx)(model), thinking).level),
496
- stallTimeoutMs: workflow.stallTimeoutMs,
497
- toolStallTimeoutMs: workflow.toolStallTimeoutMs,
498
- feed: lobbyFeed,
499
- onChange: rerender,
500
- onRound: (session) => {
501
- // Put the questions to the user at once when they are looking at the Plan tab.
502
- if (loadConfig().lobby.autoAsk && state.visible && state.view?.tab === "plan" && session.awaitingAnswers) void answerPanel(state);
503
- },
504
- }, seed);
505
- return state.planner;
506
- }
507
-
508
- /** The questionnaire (pi's own dialogs where it cannot be drawn); tests swap it. */
509
- function panelAsker(state: Runtime): Asker {
510
- return state.asker ?? askUser;
511
- }
512
-
513
- /**
514
- * Save the plan as a pending task. A plan with many steps is first split by
515
- * the oracle into up to five tasks, which the user takes, changes or declines
516
- * in a questionnaire; the split's questions open over the lobby like the
517
- * panel's. Returns a notice for the lobby.
518
- */
124
+ /** Save the plan as a pending task; the service holds the implementation. */
519
125
  export async function savePlan(state: Runtime | undefined = runtime): Promise<string> {
520
- const session = state?.planner;
521
- if (!state || !session) return "no planning session";
522
- if (state.asking) return "a questionnaire is already open";
523
- state.asking = true;
524
- try {
525
- const asker = panelAsker(state);
526
- return await session.saveWithSplit((questions, signal) => asker(questions, state.ctx, signal), { splitAbove: loadConfig().lobby.splitPlanAbove });
527
- } finally {
528
- state.asking = false;
529
- rerender();
530
- }
126
+ return serviceSavePlan(state);
531
127
  }
532
128
 
533
- /**
534
- * The oracle puts the round's questions to the user, one at a time; answered
535
- * questionnaires are kept if the user stops, so the next call resumes there.
536
- * Once every questionnaire is done, the answers (and any line comments) start
537
- * the next round. Returns a notice for the lobby.
538
- */
129
+ /** Put the panel's open questions to the user; the service holds the implementation. */
539
130
  export async function answerPanel(state: Runtime | undefined = runtime): Promise<string> {
540
- const session = state?.planner;
541
- if (!state || !session) return "no planning session";
542
- if (session.busy) return "the panel is still thinking";
543
- if (session.questions.length === 0) return "no open questions";
544
- if (state.asking) return "the questions are already open";
545
- state.asking = true;
546
- try {
547
- const asker = panelAsker(state);
548
- const chunks = questionnaires(session.questions);
549
- for (let index = session.answered.length; index < chunks.length; index++) {
550
- const result = await asker(chunks[index]!, state.ctx);
551
- if (result.cancelled) {
552
- rerender();
553
- 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";
554
- }
555
- session.answered = [...session.answered, result];
556
- }
557
- const message = answerMessage(session.questions, session.answered);
558
- if (!message) {
559
- session.answered = [];
560
- return "nothing was answered — the questions stay open";
561
- }
562
- void session.send(message, settledQuestions(session.questions, session.answered));
563
- return "answers sent — the panel is on the next round";
564
- } catch (error) {
565
- return `could not put the questions: ${(error as Error).message}`;
566
- } finally {
567
- state.asking = false;
568
- rerender();
569
- }
570
- }
571
-
572
- /** Read the repository name and branch again, off the render path; the title repaints when they changed. */
573
- function refreshWorkspace(state: Runtime): void {
574
- if (state.readingWorkspace) return;
575
- state.readingWorkspace = true;
576
- describeWorkspace(state.ctx.cwd)
577
- .then((info) => {
578
- if (runtime !== state || (info.name === state.workspace.name && info.branch === state.workspace.branch)) return;
579
- state.workspace = info;
580
- rerender();
581
- })
582
- .catch(() => {})
583
- .finally(() => {
584
- state.readingWorkspace = false;
585
- });
131
+ return serviceAnswerPanel(state);
586
132
  }
587
133
 
588
134
  function host(state: Runtime, tui: TUI): LobbyHost {
589
135
  return {
136
+ ...createLobbyService(state),
590
137
  rows: () => tui.terminal.rows,
591
138
  theme: () => lobbyTheme(state.ctx.ui.theme),
592
- sessionId: () => state.ctx.sessionManager.getSessionId(),
593
- zen: () => {
594
- const snapshot = taskSnapshot();
595
- return { ...(snapshot.task ? { task: snapshot.task } : {}), runs: snapshot.runs };
596
- },
597
- feed: lobbyFeed,
598
- masterBusy: () => !state.ctx.isIdle(),
599
- tasks: () => peekTasks(state.root, state.configDir),
600
- plans: () => listPlannedTasks(state.root, state.configDir),
601
- comments: (taskId) => readPlanComments(state.root, state.configDir, taskId),
602
- metrics: () => readMetrics(state.root, state.configDir),
603
- classifierMetrics: () => readClassifierMetrics(state.root, state.configDir),
604
- toOracle: (text) => toOracle(state, text),
605
- comment: (taskId, text) => addComment(state, taskId, text),
606
- startPlanned: (plan) => startPlanned(state, plan),
607
- discardPlan: (id) => discardPlannedTask(state.root, state.configDir, id),
608
- abortMaster: () => state.ctx.abort(),
609
139
  hide: () => hideLobby(),
610
- quickfix: state.quickfix,
611
- planner: () => state.planner,
612
- newPlanner: (seed, seats) => newPlanner(state, seed, seats),
613
- answerPanel: () => answerPanel(state),
614
- savePlan: () => savePlan(state),
615
- defaultPanel: () => loadConfig().lobby.planningPanel,
616
- planningRounds: () => loadConfig().lobby.maxPlanningRounds,
617
- issuesEnabled: () => loadConfig().lobby.issues,
618
140
  panels: () => loadConfig().lobby.panels,
619
141
  savePanels: (panels) => savePanels(panels),
620
142
  keys: () => loadConfig().lobby.keys,
621
143
  openSettings: (entry) => lobbySettings(state, entry),
622
- seatLabel: (member) => {
623
- const profile = seatProfile(state, member);
624
- return `${profile.model ?? "session model"} · ${profile.thinking}`;
625
- },
626
- issues: state.issues,
627
- pulls: state.pulls,
628
- reviews: state.reviews,
629
- knowledge: state.knowledge,
630
- excalidraw: state.excalidraw,
631
- checkExcalidraw: (session) => checkSession(session.link, session.name),
632
144
  editText: (title, text) => editText(state, title, text),
633
- profileLabel: (kind) => {
634
- const profile = lobbyProfile(state, kind);
635
- return `${profile.model ?? "session model"} · ${profile.thinking}`;
636
- },
637
- workspace: () => state.workspace,
638
- refreshWorkspace: () => refreshWorkspace(state),
639
- sessionName: () => state.pi.getSessionName(),
640
- sessions: () => backgroundSessions(),
641
- startSession: (start) => startSession(state, start),
642
- answerDialog: (session) => answerDialog(state, session),
643
- isAuto: (taskId) => autoFor(state, taskId),
644
- setAuto: (taskId, on) => switchAuto(state, taskId, on),
645
- sendToTask: (taskId, text) => sendToTask(state, taskId, text),
646
- sendToSession: (sessionId, text) => sendToSession(state, sessionId, text),
647
- liveSessions: () => liveSessions(state),
648
- switchTo: (target) => switchTo(state, target),
649
- archivedTasks: () => listArchivedTasks(state.root, state.configDir),
650
- archiveTask: (taskId) => archiveTask(state, taskId),
651
- restoreTask: (taskId) => restoreTask(state, taskId),
652
- deleteTask: (taskId, where) => deleteTask(state, taskId, where),
653
- sessionChat: (sessionId) => chats.chat(sessionId),
654
- hasOlderChat: (sessionId) => chats.hasOlder(sessionId),
655
- chatHistory: (sessionId) => chatHistory(state, sessionId),
656
145
  requestRender: () => tui.requestRender(),
657
146
  };
658
147
  }
@@ -774,6 +263,7 @@ export function autoOpenLobby(): void {
774
263
 
775
264
  /** Step aside while pi shows a dialog (an approval, a question), and come back after. */
776
265
  function promptStarted(): void {
266
+ lobbyTopics.bump("status");
777
267
  const state = runtime;
778
268
  if (!state?.visible || !state.handle || state.inSettings) return;
779
269
  state.asideForPrompt = true;
@@ -782,6 +272,7 @@ function promptStarted(): void {
782
272
  }
783
273
 
784
274
  function promptEnded(): void {
275
+ lobbyTopics.bump("status");
785
276
  const state = runtime;
786
277
  if (!state?.asideForPrompt || !state.handle || state.inSettings) return;
787
278
  state.asideForPrompt = false;
@@ -794,9 +285,13 @@ function shutdown(): void {
794
285
  const state = runtime;
795
286
  if (!state) return;
796
287
  runtime = undefined;
288
+ lobbyService = undefined;
289
+ setServiceState(undefined);
797
290
  setQuickFixHandoff(undefined);
798
291
  setMouse(state, false);
799
292
  state.unsubscribeFeed?.();
293
+ state.unsubscribeTopics?.();
294
+ state.unregisterTerminalSurface?.();
800
295
  state.quickfix.cancelAll();
801
296
  state.reviews.cancelAll();
802
297
  state.planner?.cancel();
@@ -820,14 +315,23 @@ function handToQuickFix(state: Runtime, request: string, builder: Domain | undef
820
315
  note: `The oracle sent your request here: ${reason.replace(/[.\s]+$/, "")}.${builder ? ` A quick feature: it runs on ${builder === "designer" ? "DESIGN" : "DEV"}'s model, thinking and time limit.` : ""}`,
821
316
  });
822
317
  showLobby("quickfix");
318
+ lobbyTopics.bump("quickfix");
823
319
  state.view?.showQuickFix(job.id);
824
320
  return job.id;
825
321
  }
826
322
 
827
- /** Create the session's lobby runtime (interactive master sessions only). */
828
- export function initLobby(pi: ExtensionAPI, ctx: ExtensionContext, configDir: string): void {
323
+ /** Whether the web server wants the lobby service without the terminal (a later step flips this on). */
324
+ function webServerOn(): boolean {
325
+ return false;
326
+ }
327
+
328
+ /**
329
+ * Start the lobby's backend: state, service and background wiring. Runs in
330
+ * the terminal and, once the web server exists, whenever it is on.
331
+ */
332
+ export function startLobbyService(pi: ExtensionAPI, ctx: ExtensionContext, configDir: string): Runtime | undefined {
829
333
  shutdown();
830
- if (isSubagentProcess() || ctx.mode !== "tui") return;
334
+ if (isSubagentProcess() || (ctx.mode !== "tui" && !webServerOn())) return undefined;
831
335
  const root = detectProjectRoot(ctx.cwd, configDir);
832
336
  const workflow = loadConfig().workflow;
833
337
  const state: Runtime = {
@@ -843,8 +347,14 @@ export function initLobby(pi: ExtensionAPI, ctx: ExtensionContext, configDir: st
843
347
  workspace: { name: basename(ctx.cwd) || ctx.cwd },
844
348
  readingWorkspace: false,
845
349
  quickfix: undefined as unknown as QuickFixQueue,
846
- issues: new IssuesState(execCommand, ctx.cwd, rerender),
847
- pulls: new PullsState(execCommand, ctx.cwd, rerender),
350
+ issues: new IssuesState(execCommand, ctx.cwd, () => {
351
+ lobbyTopics.bump("issues");
352
+ rerender();
353
+ }),
354
+ pulls: new PullsState(execCommand, ctx.cwd, () => {
355
+ lobbyTopics.bump("git");
356
+ rerender();
357
+ }),
848
358
  reviews: undefined as unknown as PullReviews,
849
359
  knowledge: new KnowledgeBook({
850
360
  root,
@@ -864,8 +374,12 @@ export function initLobby(pi: ExtensionAPI, ctx: ExtensionContext, configDir: st
864
374
  stallTimeoutMs: workflow.stallTimeoutMs,
865
375
  toolStallTimeoutMs: workflow.toolStallTimeoutMs,
866
376
  feed: lobbyFeed,
867
- onChange: rerender,
377
+ onChange: () => {
378
+ lobbyTopics.bump("git");
379
+ rerender();
380
+ },
868
381
  notify: (message, level) => {
382
+ lobbyTopics.bump("notices");
869
383
  if (!state.visible) ctx.ui.notify(message, level);
870
384
  },
871
385
  classifier: classifier(),
@@ -878,8 +392,12 @@ export function initLobby(pi: ExtensionAPI, ctx: ExtensionContext, configDir: st
878
392
  stallTimeoutMs: workflow.stallTimeoutMs,
879
393
  toolStallTimeoutMs: workflow.toolStallTimeoutMs,
880
394
  feed: lobbyFeed,
881
- onChange: rerender,
395
+ onChange: () => {
396
+ lobbyTopics.bump("quickfix");
397
+ rerender();
398
+ },
882
399
  notify: (message, level) => {
400
+ lobbyTopics.bump("notices");
883
401
  if (!state.visible) ctx.ui.notify(message, level);
884
402
  },
885
403
  hints: hintsFor({ cwd: ctx.cwd, root, configDir }),
@@ -887,23 +405,35 @@ export function initLobby(pi: ExtensionAPI, ctx: ExtensionContext, configDir: st
887
405
  effort: effortFor((model, thinking) => checkThinking(modelLookup(ctx)(model), thinking).level),
888
406
  });
889
407
  runtime = state;
408
+ lobbyService = createLobbyService(state);
409
+ setServiceState(state);
410
+ // Topic routing (src/lobby/topics.ts): the terminal hears every topic and
411
+ // repaints, as before. Sources bump on their way to rerender — lobbyFeed
412
+ // (lobby), run updates (tasks), quick fix (quickfix), planner (planner),
413
+ // background sessions (sessions), metrics (metrics), pulls/reviews (git),
414
+ // issues (issues), the prompt hub (prompts), owner/notify events (notices),
415
+ // dialogs/busy/workspace (status), link checks (excalidraw). File polling
416
+ // for tasks/plans/knowledge starts once a non-terminal listener attaches.
417
+ state.unsubscribeTopics = lobbyTopics.onChange(rerender);
418
+ state.unregisterTerminalSurface = promptHub.registerSurface({ name: "terminal", show() {}, withdraw() {} });
890
419
  setQuickFixHandoff((request, builder, reason) => (runtime === state ? handToQuickFix(state, request, builder, reason) : undefined));
891
420
  lobbyFeed.clear();
892
421
  // Only the newest messages are kept; the feed learns whether earlier ones exist, and loads them when scrolled to.
893
422
  lobbyFeed.seedChat(chatFromEntries(ctx.sessionManager.getBranch(), Number.POSITIVE_INFINITY));
894
- state.unsubscribeFeed = lobbyFeed.onChange(rerender);
895
- onRunUpdates((runs) => lobbyFeed.runs(runs));
423
+ state.unsubscribeFeed = lobbyFeed.onChange(() => {
424
+ lobbyTopics.bump("lobby");
425
+ rerender();
426
+ });
427
+ onRunUpdates((runs) => {
428
+ lobbyFeed.runs(runs);
429
+ lobbyTopics.bump("tasks");
430
+ });
896
431
  onMinimizeChange((value) => {
897
432
  if (value) hideLobby();
898
433
  });
899
- // Capture pi's TUI through a zero-line widget below the editor.
900
- ctx.ui.setWidget(ANCHOR_KEY, (tui) => {
901
- state.tui = tui;
902
- // Zero lines while the lobby is up (or bot-lobby is minimized); one status line when it is hidden.
903
- return { render: (width: number) => (miniShown(state) ? [miniLine(miniInput(state), width, lobbyTheme(state.ctx.ui.theme))] : []), invalidate: () => {} };
904
- }, { placement: "belowEditor" });
905
434
  // The owner's clock (pi/owner.ts) delivers comments and messages and drives auto mode; the lobby logs what it did.
906
435
  onOwnerEvent((event) => {
436
+ lobbyTopics.bump("notices");
907
437
  if (event.kind === "comments") lobbyFeed.log("LOBBY", `passed ${event.count} plan comment${event.count === 1 ? "" : "s"} on ${event.taskId} to the oracle`, "info");
908
438
  else if (event.kind === "inbox" || event.kind === "messages") lobbyFeed.log("LOBBY", `passed ${event.count} message${event.count === 1 ? "" : "s"} from another session to the oracle`, "info");
909
439
  else if (event.kind === "auto") lobbyFeed.log("LOBBY", `auto mode ${event.on ? "on" : "off"} for ${event.taskId}`, event.on ? "success" : "info");
@@ -911,6 +441,24 @@ export function initLobby(pi: ExtensionAPI, ctx: ExtensionContext, configDir: st
911
441
  else lobbyFeed.log("LOBBY", `auto mode: no progress on ${event.taskId} — it needs you`, "warning");
912
442
  rerender();
913
443
  });
444
+ return state;
445
+ }
446
+
447
+ /** Mount the terminal overlay: the zero-line anchor widget that captures pi's TUI. Runs only in the terminal. */
448
+ function mountTerminalLobby(state: Runtime): void {
449
+ const ctx = state.ctx;
450
+ // Capture pi's TUI through a zero-line widget below the editor.
451
+ ctx.ui.setWidget(ANCHOR_KEY, (tui) => {
452
+ state.tui = tui;
453
+ // Zero lines while the lobby is up (or bot-lobby is minimized); one status line when it is hidden.
454
+ return { render: (width: number) => (miniShown(state) ? [miniLine(miniInput(state), width, lobbyTheme(state.ctx.ui.theme))] : []), invalidate: () => {} };
455
+ }, { placement: "belowEditor" });
456
+ }
457
+
458
+ /** Create the session's lobby runtime (interactive master sessions only). */
459
+ export function initLobby(pi: ExtensionAPI, ctx: ExtensionContext, configDir: string): void {
460
+ const state = startLobbyService(pi, ctx, configDir);
461
+ if (state && ctx.mode === "tui") mountTerminalLobby(state);
914
462
  }
915
463
 
916
464
  interface MasterTurn {
@@ -949,16 +497,15 @@ export function registerLobbyEvents(pi: ExtensionAPI, configDir: string): void {
949
497
  pi.on("session_start", (_event, ctx) => {
950
498
  initLobby(pi, ctx, configDir);
951
499
  // A switch asked for from the lobby lands back in the lobby, on the session it switched to.
952
- if (reopenAfterSwitch) {
953
- reopenAfterSwitch = false;
954
- showLobby("lobby");
955
- } else autoOpenLobby();
500
+ if (takeReopenAfterSwitch()) showLobby("lobby");
501
+ else autoOpenLobby();
956
502
  });
957
503
  pi.on("session_shutdown", () => shutdown());
958
504
  pi.on("ui_prompt_start", () => promptStarted());
959
505
  pi.on("ui_prompt_end", () => promptEnded());
960
506
  pi.on("agent_start", (_event, ctx) => {
961
507
  track(ctx);
508
+ lobbyTopics.bump("status");
962
509
  const taskId = currentZenTask()?.id;
963
510
  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 };
964
511
  });
@@ -1002,5 +549,7 @@ export function registerLobbyEvents(pi: ExtensionAPI, configDir: string): void {
1002
549
  cost: finished.cost,
1003
550
  taskId,
1004
551
  }]);
552
+ lobbyTopics.bump("metrics");
553
+ lobbyTopics.bump("status");
1005
554
  });
1006
555
  }