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

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 (71) hide show
  1. package/README.md +70 -79
  2. package/package.json +4 -2
  3. package/prompts/master.md +3 -2
  4. package/prompts/qa.md +33 -11
  5. package/prompts/reviewer.md +5 -2
  6. package/prompts/worker.md +5 -2
  7. package/src/ask/relay.ts +1 -2
  8. package/src/ask/tool.ts +4 -6
  9. package/src/ask/types.ts +16 -0
  10. package/src/ask/web.ts +63 -0
  11. package/src/index.ts +7 -1
  12. package/src/lobby/ask.ts +4 -5
  13. package/src/lobby/blocks.ts +57 -0
  14. package/src/lobby/feed.ts +1 -1
  15. package/src/lobby/host.ts +178 -0
  16. package/src/lobby/keys.ts +10 -41
  17. package/src/lobby/prompt-hub.ts +30 -77
  18. package/src/lobby/prompts.ts +8 -12
  19. package/src/lobby/runtime.ts +49 -310
  20. package/src/lobby/service.ts +54 -134
  21. package/src/lobby/task-rows.ts +28 -0
  22. package/src/pi/commands.ts +14 -20
  23. package/src/pi/model-settings.ts +112 -0
  24. package/src/pi/start-task.ts +1 -1
  25. package/src/pi/tools.ts +6 -7
  26. package/src/pi/ui.ts +0 -9
  27. package/src/schemas/configuration.ts +9 -33
  28. package/src/state/attachments.ts +82 -0
  29. package/src/webui/api/index.ts +17 -15
  30. package/src/webui/api/lobby.ts +4 -3
  31. package/src/webui/api/planner.ts +6 -4
  32. package/src/webui/api/quickfix.ts +4 -3
  33. package/src/webui/api/sessions.ts +9 -7
  34. package/src/webui/api/settings.ts +20 -8
  35. package/src/webui/api/status.ts +4 -15
  36. package/src/webui/api/tasks.ts +11 -15
  37. package/src/webui/command.ts +35 -19
  38. package/src/webui/dev/fake-service.ts +1 -2
  39. package/src/webui/dev/fixtures.ts +2 -5
  40. package/src/webui/events.ts +1 -1
  41. package/src/webui/protocol.ts +22 -11
  42. package/src/webui/server.ts +42 -1
  43. package/src/webui/uploads.ts +68 -0
  44. package/webui/dist/assets/index-B1PnCyl2.css +2 -0
  45. package/webui/dist/assets/index-DI9Yq9IC.js +90 -0
  46. package/webui/dist/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  47. package/webui/dist/build.json +142 -110
  48. package/webui/dist/index.html +4 -3
  49. package/src/ask/dialog.ts +0 -193
  50. package/src/ask/image.ts +0 -202
  51. package/src/ask/png.ts +0 -179
  52. package/src/ask/state.ts +0 -175
  53. package/src/ask/view.ts +0 -163
  54. package/src/lobby/layout.ts +0 -496
  55. package/src/lobby/markdown.ts +0 -121
  56. package/src/lobby/mini.ts +0 -167
  57. package/src/lobby/tabs/excalidraw.ts +0 -95
  58. package/src/lobby/tabs/git.ts +0 -162
  59. package/src/lobby/tabs/home.ts +0 -544
  60. package/src/lobby/tabs/issues.ts +0 -73
  61. package/src/lobby/tabs/knowledge.ts +0 -135
  62. package/src/lobby/tabs/metrics.ts +0 -300
  63. package/src/lobby/tabs/plan.ts +0 -262
  64. package/src/lobby/tabs/quickfix.ts +0 -136
  65. package/src/lobby/tabs/tasks.ts +0 -412
  66. package/src/lobby/theme.ts +0 -30
  67. package/src/lobby/view.ts +0 -3018
  68. package/src/pi/settings-ui.ts +0 -708
  69. package/src/width.ts +0 -102
  70. package/webui/dist/assets/index-Bk-lpAGm.css +0 -2
  71. package/webui/dist/assets/index-E6MPcbdC.js +0 -80
@@ -1,13 +1,12 @@
1
1
  /**
2
- * The lobby's backend, without the terminal: every host member the view
3
- * needs that does not draw anything. `createLobbyService(state)` builds it
4
- * from the same state the terminal lobby runs on, so a later web server can
5
- * drive the lobby through this module without a TUI. Putting a question on
6
- * screen stays with the terminal; the dialog data and its answer live here.
2
+ * The lobby's backend: `createLobbyService(state)` builds the service the web
3
+ * server drives from the runtime's state. Questions go to the page through
4
+ * the prompt hub (see ../ask/web.ts).
7
5
  */
8
6
  import { SessionManager, type ExtensionContext } from "@earendil-works/pi-coding-agent";
9
7
  import { TERMINAL_STATES, type Task } from "../schemas/task.ts";
10
8
  import { loadTask, peekTasks } from "../state/persistence.ts";
9
+ import { releaseAttachments } from "../state/attachments.ts";
11
10
  import { loadConfig, saveConfig as writeConfig } from "../state/project.ts";
12
11
  import { addPlanComment, readPlanComments } from "../state/comments.ts";
13
12
  import { isAutoMode } from "../state/auto.ts";
@@ -16,7 +15,7 @@ import { livePresence } from "../state/presence.ts";
16
15
  import { archiveTask as archiveTaskOnDisk, deleteTask as deleteTaskOnDisk, listArchivedTasks, restoreTask as restoreTaskOnDisk } from "../state/archive.ts";
17
16
  import { discardPlannedTask, listPlannedTasks, type PlannedTask } from "../state/backlog.ts";
18
17
  import { readClassifierMetrics, readMetrics } from "../state/metrics.ts";
19
- import { currentZenTask, isMinimized, taskSnapshot } from "../pi/ui.ts";
18
+ import { currentZenTask, taskSnapshot } from "../pi/ui.ts";
20
19
  import { taskName } from "../text.ts";
21
20
  import { describeWorkspace } from "../execution/workspace.ts";
22
21
  import { stripStartFlags } from "../pi/start-flags.ts";
@@ -29,21 +28,18 @@ import { chatFromEntries, lobbyFeed, type ChatEntry } from "./feed.ts";
29
28
  import { classifier, effortFor, hintsFor } from "../classifier/instance.ts";
30
29
  import { launchPi, SessionRegistry, type BackgroundSession, type DialogAnswer, type SessionLauncher } from "./sessions.ts";
31
30
  import { SessionChats } from "./session-files.ts";
32
- import { answerMessage, askUser, questionnaires, settledQuestions, type Asker } from "./ask.ts";
31
+ import { answerMessage, questionnaires, settledQuestions } from "./ask.ts";
32
+ import { askUser } from "../ask/web.ts";
33
+ import type { Asker } from "../ask/types.ts";
33
34
  import { PlanningSession, type PlannerSeed } from "./planner.ts";
35
+ import { applyMasterModel } from "../pi/model-settings.ts";
34
36
  import { lobbyTopics } from "./topics.ts";
35
- import { promptHub } from "./prompt-hub.ts";
36
37
  import { checkSession } from "../excalidraw/check.ts";
37
38
  import type { LobbyAgentKind, PanelMember } from "../schemas/configuration.ts";
38
- import type { LiveSession, LobbyHost, SwitchTarget } from "./view.ts";
39
+ import type { LiveSession, LobbyService, SwitchTarget } from "./host.ts";
40
+ import { pushNotice, type NoticeLevel } from "../webui/notices.ts";
39
41
  import type { Runtime } from "./runtime.ts";
40
42
 
41
- /**
42
- * The lobby without its terminal-only members (rows, theme, hide,
43
- * requestRender, now, keys, panels, savePanels, editText, openSettings).
44
- */
45
- export type LobbyService = Omit<LobbyHost, "rows" | "theme" | "hide" | "requestRender" | "now" | "keys" | "panels" | "savePanels" | "editText" | "openSettings">;
46
-
47
43
  /** The state background callbacks report to; set while the lobby service runs. */
48
44
  let serviceState: Runtime | undefined;
49
45
 
@@ -52,36 +48,14 @@ export function setServiceState(state: Runtime | undefined): void {
52
48
  serviceState = state;
53
49
  }
54
50
 
55
- /** Repaints asked for by what happens in the background come at most this often. */
56
- export const FRAME_MS = 40;
57
- let lastFrame = 0;
58
- let frameTimer: ReturnType<typeof setTimeout> | undefined;
59
-
60
51
  /**
61
- * Repaint the lobby for something that happened in the background. A reply
62
- * streams dozens of events a second; they share frames, at most one per
63
- * `FRAME_MS`, instead of each asking for its own. Keys repaint at once
64
- * through pi.
52
+ * Tell the user: a toast in the page, and in pi's own terminal too when it is
53
+ * a warning or an error (the page may not be open).
65
54
  */
66
- export function repaintLobby(): void {
67
- const state = serviceState;
68
- // Hidden, only the one-line status under the editor changes.
69
- if (!state || (!state.visible && !miniShown(state)) || frameTimer) return;
70
- const wait = lastFrame + FRAME_MS - Date.now();
71
- const paint = () => {
72
- frameTimer = undefined;
73
- lastFrame = Date.now();
74
- const current = serviceState;
75
- if (current?.visible || (current && miniShown(current))) current.tui?.requestRender();
76
- };
77
- if (wait <= 0) return paint();
78
- frameTimer = setTimeout(paint, wait);
79
- frameTimer.unref?.();
80
- }
81
-
82
- /** The status line shows under the editor while the lobby is hidden (not while bot-lobby is minimized), unless settings turn it off. */
83
- export function miniShown(state: Runtime): boolean {
84
- return !state.visible && !isMinimized() && loadConfig().lobby.miniLine;
55
+ export function lobbyNotify(state: Runtime, message: string, level: "info" | "warning" | "error" = "info"): void {
56
+ lobbyTopics.bump("notices");
57
+ pushNotice(message, level as NoticeLevel);
58
+ if (level !== "info") state.ctx.ui.notify(message, level);
85
59
  }
86
60
 
87
61
  function sessionModel(ctx: ExtensionContext): string | undefined {
@@ -92,7 +66,7 @@ export function lobbyProfile(state: Runtime, kind: LobbyAgentKind) {
92
66
  return resolveLobbyProfile(loadConfig(), kind, {
93
67
  lookup: modelLookup(state.ctx),
94
68
  sessionModel: sessionModel(state.ctx),
95
- warn: (message) => state.ctx.ui.notify(message, "warning"),
69
+ warn: (message) => lobbyNotify(state, message, "warning"),
96
70
  });
97
71
  }
98
72
 
@@ -118,15 +92,14 @@ function addComment(state: Runtime, taskId: string, text: string): string {
118
92
 
119
93
  function failed(state: Runtime, what: string, error: Error): void {
120
94
  lobbyFeed.log("LOBBY", `${what} — ${error.message}`, "error");
121
- lobbyTopics.bump("notices");
122
- state.ctx.ui.notify(`bot-lobby: ${what} — ${error.message}`, "error");
95
+ lobbyNotify(state, `bot-lobby: ${what} — ${error.message}`, "error");
123
96
  }
124
97
 
125
98
  function toOracle(state: Runtime, text: string): string | undefined {
126
99
  const busy = !state.ctx.isIdle();
127
100
  if (text.startsWith("/")) {
128
101
  state.pi.sendUserMessage(text, { expandPromptTemplates: true, ...(busy ? { deliverAs: "followUp" as const } : {}) });
129
- return `sent ${text.split(/\s+/)[0]} to pi (built-in commands need the lobby hidden: alt+l)`;
102
+ return `sent ${text.split(/\s+/)[0]} to pi`;
130
103
  }
131
104
  // While the oracle decides where a request goes, what the user types joins that conversation.
132
105
  if (!currentZenTask() && !pendingRequest()) {
@@ -143,9 +116,7 @@ function startPlanned(state: Runtime, plan: PlannedTask): string {
143
116
  startPlannedTask(state.pi, state.ctx, state.configDir, plan.id)
144
117
  .then((task) => {
145
118
  if (typeof task === "string") return failed(state, `could not start ${plan.id}`, new Error(task));
146
- state.view?.setTab("lobby");
147
119
  lobbyTopics.bump("tasks");
148
- repaintLobby();
149
120
  })
150
121
  .catch((error: Error) => failed(state, `could not start ${plan.id}`, error));
151
122
  return `starting ${plan.id} here — its agreed plan needs no approval…`;
@@ -167,10 +138,7 @@ const chats = new SessionChats(async () => {
167
138
  if (!dirs.has(state.root)) dirs.set(state.root, undefined);
168
139
  const lists = await Promise.all([...dirs].map(([cwd, dir]) => SessionManager.list(cwd, dir).catch(() => [])));
169
140
  return lists.flat();
170
- }, () => {
171
- lobbyTopics.bump("sessions");
172
- repaintLobby();
173
- });
141
+ }, () => lobbyTopics.bump("sessions"));
174
142
 
175
143
  /** Launch background sessions with something else (tests); resets the registry. */
176
144
  export function setSessionLauncher(next: SessionLauncher | undefined): void {
@@ -199,7 +167,7 @@ export function backgroundSessions(): readonly BackgroundSession[] {
199
167
  return registry?.sessions ?? [];
200
168
  }
201
169
 
202
- /** A background session changed: redraw, and say so when one asks something or fails while the lobby is hidden. */
170
+ /** A background session changed: say so when one asks something or fails. */
203
171
  function sessionsChanged(): void {
204
172
  lobbyTopics.bump("sessions");
205
173
  const state = serviceState;
@@ -207,20 +175,13 @@ function sessionsChanged(): void {
207
175
  if (session.sessionId && session.sessionFile) chats.remember(session.sessionId, session.sessionFile);
208
176
  const seen = announced.get(session.key) ?? 0;
209
177
  announced.set(session.key, session.dialogs.length);
210
- if (!state || state.visible) continue;
211
- if (session.dialogs.length > seen) {
212
- lobbyTopics.bump("notices");
213
- state.ctx.ui.notify(`bot-lobby: ${session.name} is waiting for you — alt+l opens the lobby`, "info");
214
- }
178
+ if (!state) continue;
179
+ if (session.dialogs.length > seen) lobbyNotify(state, `bot-lobby: ${session.name} is waiting for you — see the Sessions page`, "info");
215
180
  if (!session.alive && !exitsSaid.has(session.key)) {
216
181
  exitsSaid.add(session.key);
217
- if (session.exitCode) {
218
- lobbyTopics.bump("notices");
219
- state.ctx.ui.notify(`bot-lobby: ${session.name} exited (${session.exitCode})${session.lastError() ? `: ${session.lastError()}` : ""}`, "warning");
220
- }
182
+ if (session.exitCode) lobbyNotify(state, `bot-lobby: ${session.name} exited (${session.exitCode})${session.lastError() ? `: ${session.lastError()}` : ""}`, "warning");
221
183
  }
222
184
  }
223
- repaintLobby();
224
185
  }
225
186
 
226
187
  /** Start a task in its own new pi session, named after the task; the session, or why not. */
@@ -245,39 +206,6 @@ function startSession(state: Runtime, start: { request?: string; plan?: PlannedT
245
206
  }
246
207
  }
247
208
 
248
- /** The oldest question a background session waits on, if any (data only; the terminal draws it). */
249
- export function peekSessionDialog(session: BackgroundSession) {
250
- return session.dialogs[0];
251
- }
252
-
253
- /** Answer a background session's dialog (data only; the terminal draws it). */
254
- export function answerSessionDialog(session: BackgroundSession, id: string, answer: DialogAnswer): void {
255
- session.answer(id, answer);
256
- }
257
-
258
- /** Put the oldest question a background session waits on to the user, with pi's own dialogs; escape cancels it as it would there. */
259
- async function answerDialog(state: Runtime, session: BackgroundSession): Promise<void> {
260
- const dialog = peekSessionDialog(session);
261
- if (!dialog) return;
262
- const title = `${session.name} — ${dialog.title}`;
263
- const ui = state.ctx.ui;
264
- try {
265
- await promptHub.run("sessionDialog", session.name, { dialog }, async () => {
266
- if (dialog.method === "select") {
267
- const value = await ui.select(title, dialog.options ?? []);
268
- answerSessionDialog(session, dialog.id, value === undefined ? { cancelled: true } : { value });
269
- } else if (dialog.method === "confirm") {
270
- answerSessionDialog(session, dialog.id, { confirmed: await ui.confirm(title, dialog.message ?? "") });
271
- } else {
272
- const value = dialog.method === "input" ? await ui.input(title, dialog.placeholder) : await ui.editor(title, dialog.prefill);
273
- answerSessionDialog(session, dialog.id, value === undefined ? { cancelled: true } : { value });
274
- }
275
- });
276
- } catch (error) {
277
- failed(state, `could not answer ${session.name}`, error as Error);
278
- }
279
- }
280
-
281
209
  /** Leave a message for a task another terminal's session drives; its owner passes it to the oracle. */
282
210
  function sendToTask(state: Runtime, taskId: string, text: string): string {
283
211
  const task = loadTask(state.root, state.configDir, taskId);
@@ -350,16 +278,6 @@ function sendToSession(state: Runtime, sessionId: string, text: string): string
350
278
  return "sent — that session passes it to its oracle within a few seconds";
351
279
  }
352
280
 
353
- /** The lobby reopens on the session this window switches to (set just before asking pi to switch). */
354
- let reopenAfterSwitch = false;
355
-
356
- /** Whether a switch asked for from the lobby is still owed its reopen; clears the flag. */
357
- export function takeReopenAfterSwitch(): boolean {
358
- const value = reopenAfterSwitch;
359
- reopenAfterSwitch = false;
360
- return value;
361
- }
362
-
363
281
  /**
364
282
  * Run another session in this window through `/bot-lobby switch`, which pi
365
283
  * runs with a command context (the only one that may replace the session).
@@ -367,9 +285,8 @@ export function takeReopenAfterSwitch(): boolean {
367
285
  * task no session owns is claimed instead.
368
286
  */
369
287
  async function switchTo(state: Runtime, target: SwitchTarget): Promise<string> {
370
- if (!state.ctx.isIdle()) return "this window's oracle is working — esc stops it, then switch";
288
+ if (!state.ctx.isIdle()) return "this window's oracle is working — stop it, then switch";
371
289
  if (target.claimTaskId) {
372
- reopenAfterSwitch = false;
373
290
  state.pi.sendUserMessage(`/bot-lobby claim ${target.claimTaskId}`, { expandPromptTemplates: true });
374
291
  return `taking ${target.claimTaskId} over in this window`;
375
292
  }
@@ -380,7 +297,6 @@ async function switchTo(state: Runtime, target: SwitchTarget): Promise<string> {
380
297
  }
381
298
  file ??= target.sessionId ? await chats.locate(target.sessionId) : undefined;
382
299
  if (!file) return `could not find ${target.name}'s session file — /resume lists every saved session`;
383
- reopenAfterSwitch = true;
384
300
  state.pi.sendUserMessage(`/bot-lobby switch ${file}`, { expandPromptTemplates: true });
385
301
  return `switching this window to ${target.name}…`;
386
302
  }
@@ -390,7 +306,7 @@ function drivenElsewhere(state: Runtime, task: Task): string | undefined {
390
306
  if (TERMINAL_STATES.includes(task.state) || !task.ownerSessionId) return undefined;
391
307
  if (task.ownerSessionId === state.ctx.sessionManager.getSessionId()) return `this window drives ${task.id} — cancel it first (/bot-lobby cancel ${task.id})`;
392
308
  const background = backgroundSessions().find((session) => session.alive && session.sessionId === task.ownerSessionId);
393
- if (background) return `${background.name} is driving ${task.id} in the background — stop it first (x x)`;
309
+ if (background) return `${background.name} is driving ${task.id} in the background — stop it first`;
394
310
  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`;
395
311
  return undefined;
396
312
  }
@@ -402,12 +318,13 @@ function archiveTask(state: Runtime, taskId: string): string {
402
318
  if (busy) return busy;
403
319
  try {
404
320
  archiveTaskOnDisk(state.root, state.configDir, taskId);
321
+ releaseAttachments(taskId);
405
322
  } catch (error) {
406
323
  return (error as Error).message;
407
324
  }
408
325
  lobbyFeed.log("LOBBY", `archived ${taskId}`, "info");
409
326
  lobbyTopics.bump("tasks");
410
- return `archived ${taskId}${TERMINAL_STATES.includes(task.state) ? "" : " (abandoned first)"} — v shows archived tasks, a restores one`;
327
+ return `archived ${taskId}${TERMINAL_STATES.includes(task.state) ? "" : " (abandoned first)"} — show archived tasks to restore it`;
411
328
  }
412
329
 
413
330
  function restoreTask(state: Runtime, taskId: string): string {
@@ -430,6 +347,7 @@ function deleteTask(state: Runtime, taskId: string, where: "list" | "archive"):
430
347
  }
431
348
  try {
432
349
  deleteTaskOnDisk(state.root, state.configDir, taskId, where);
350
+ releaseAttachments(taskId);
433
351
  } catch (error) {
434
352
  return (error as Error).message;
435
353
  }
@@ -443,7 +361,7 @@ export function reviewProfile(state: Runtime) {
443
361
  return resolveReviewProfile(loadConfig(), {
444
362
  lookup: modelLookup(state.ctx),
445
363
  sessionModel: sessionModel(state.ctx),
446
- warn: (message) => state.ctx.ui.notify(message, "warning"),
364
+ warn: (message) => lobbyNotify(state, message, "warning"),
447
365
  });
448
366
  }
449
367
 
@@ -451,7 +369,7 @@ export function seatProfile(state: Runtime, member: PanelMember) {
451
369
  return resolvePanelProfile(loadConfig(), member, {
452
370
  lookup: modelLookup(state.ctx),
453
371
  sessionModel: sessionModel(state.ctx),
454
- warn: (message) => state.ctx.ui.notify(message, "warning"),
372
+ warn: (message) => lobbyNotify(state, message, "warning"),
455
373
  });
456
374
  }
457
375
 
@@ -473,20 +391,13 @@ function newPlanner(state: Runtime, seed?: PlannerSeed, seats?: readonly PanelMe
473
391
  stallTimeoutMs: workflow.stallTimeoutMs,
474
392
  toolStallTimeoutMs: workflow.toolStallTimeoutMs,
475
393
  feed: lobbyFeed,
476
- onChange: () => {
477
- lobbyTopics.bump("planner");
478
- repaintLobby();
479
- },
480
- onRound: (session) => {
481
- lobbyTopics.bump("planner");
482
- // Put the questions to the user at once when they are looking at the Plan tab.
483
- if (loadConfig().lobby.autoAsk && state.visible && state.view?.tab === "plan" && session.awaitingAnswers) void answerPanel(state);
484
- },
394
+ onChange: () => lobbyTopics.bump("planner"),
395
+ onRound: () => lobbyTopics.bump("planner"),
485
396
  }, seed);
486
397
  return state.planner;
487
398
  }
488
399
 
489
- /** The questionnaire (pi's own dialogs where it cannot be drawn); tests swap it. */
400
+ /** The questionnaire in the page; tests swap it. */
490
401
  function panelAsker(state: Runtime): Asker {
491
402
  return state.asker ?? askUser;
492
403
  }
@@ -504,10 +415,10 @@ export async function savePlan(state: Runtime | undefined): Promise<string> {
504
415
  state.asking = true;
505
416
  try {
506
417
  const asker = panelAsker(state);
507
- return await session.saveWithSplit((questions, signal) => promptHub.run("questionnaire", "planner", { questions }, () => asker(questions, state.ctx, signal), { signal }), { splitAbove: loadConfig().lobby.splitPlanAbove });
418
+ return await session.saveWithSplit((questions, signal) => asker(questions, state.ctx, signal, "planner"), { splitAbove: loadConfig().lobby.splitPlanAbove });
508
419
  } finally {
509
420
  state.asking = false;
510
- repaintLobby();
421
+ lobbyTopics.bump("planner");
511
422
  }
512
423
  }
513
424
 
@@ -529,10 +440,9 @@ export async function answerPanel(state: Runtime | undefined): Promise<string> {
529
440
  const chunks = questionnaires(session.questions);
530
441
  for (let index = session.answered.length; index < chunks.length; index++) {
531
442
  const questions = chunks[index]!;
532
- const result = await promptHub.run("questionnaire", "planner", { questions }, () => asker(questions, state.ctx));
443
+ const result = await asker(questions, state.ctx, undefined, "planner");
533
444
  if (result.cancelled) {
534
- repaintLobby();
535
- 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";
445
+ return session.answered.length > 0 ? `answers kept — ${chunks.length - session.answered.length} questionnaire${chunks.length - session.answered.length === 1 ? "" : "s"} left; answer again to resume` : "questions put away — answer again to bring them back";
536
446
  }
537
447
  session.answered = [...session.answered, result];
538
448
  }
@@ -547,7 +457,7 @@ export async function answerPanel(state: Runtime | undefined): Promise<string> {
547
457
  return `could not put the questions: ${(error as Error).message}`;
548
458
  } finally {
549
459
  state.asking = false;
550
- repaintLobby();
460
+ lobbyTopics.bump("planner");
551
461
  }
552
462
  }
553
463
 
@@ -560,7 +470,6 @@ function refreshWorkspace(state: Runtime): void {
560
470
  if (serviceState !== state || (info.name === state.workspace.name && info.branch === state.workspace.branch)) return;
561
471
  state.workspace = info;
562
472
  lobbyTopics.bump("status");
563
- repaintLobby();
564
473
  })
565
474
  .catch(() => {})
566
475
  .finally(() => {
@@ -570,6 +479,11 @@ function refreshWorkspace(state: Runtime): void {
570
479
 
571
480
  /** The lobby's backend for `state`: every host member that draws nothing. */
572
481
  export function createLobbyService(state: Runtime): LobbyService {
482
+ const masterKey = () => {
483
+ const { master } = loadConfig();
484
+ return `${master.model}|${master.thinking}`;
485
+ };
486
+ let appliedMaster = masterKey();
573
487
  return {
574
488
  sessionId: () => state.ctx.sessionManager.getSessionId(),
575
489
  zen: () => {
@@ -600,8 +514,15 @@ export function createLobbyService(state: Runtime): LobbyService {
600
514
  planningRounds: () => loadConfig().lobby.maxPlanningRounds,
601
515
  config: () => loadConfig(),
602
516
  saveConfig: (next) => writeConfig(next),
603
- configChanged: () => state.view?.reloadConfig(),
604
- models: () => {
517
+ configChanged: () => {
518
+ lobbyTopics.bump("status");
519
+ // A new master model or effort takes hold in this session at once.
520
+ const next = masterKey();
521
+ if (next === appliedMaster) return;
522
+ appliedMaster = next;
523
+ void applyMasterModel(state.pi, state.ctx, loadConfig()).catch((error: Error) => lobbyNotify(state, `bot-lobby: could not apply the master model — ${error.message}`, "warning"));
524
+ },
525
+ sessionModel: () => sessionModel(state.ctx), models: () => {
605
526
  try {
606
527
  const scoped = state.ctx.scopedModels;
607
528
  const usable = scoped.length > 0 ? scoped.map((entry) => entry.model) : state.ctx.modelRegistry.getAvailable();
@@ -637,7 +558,6 @@ export function createLobbyService(state: Runtime): LobbyService {
637
558
  sessionName: () => state.pi.getSessionName(),
638
559
  sessions: () => backgroundSessions(),
639
560
  startSession: (start) => startSession(state, start),
640
- answerDialog: (session) => answerDialog(state, session),
641
561
  isAuto: (taskId) => autoFor(state, taskId),
642
562
  setAuto: (taskId, on) => switchAuto(state, taskId, on),
643
563
  sendToTask: (taskId, text) => sendToTask(state, taskId, text),
@@ -0,0 +1,28 @@
1
+ /** The shapes of the Tasks tab's rows, shared by the web API and the page's words. */
2
+
3
+ export type TaskSection = "mine" | "others" | "pending" | "recent" | "archived";
4
+
5
+ /** How a row is ticked off: still to do (a plan, or a task under way), completed, or abandoned. */
6
+ export type CheckState = "open" | "done" | "dropped";
7
+
8
+ export interface TaskRow {
9
+ /** A task on the list, a saved plan, or a task in the archive. */
10
+ kind: "task" | "plan" | "archived";
11
+ id: string;
12
+ title: string;
13
+ section: TaskSection;
14
+ /** Task state, or `pending` for a saved plan. */
15
+ status: string;
16
+ paused?: boolean;
17
+ check: CheckState;
18
+ /** Plan steps done, once the task has a plan. */
19
+ progress?: { done: number; total: number };
20
+ /** Who drives it, for tasks other sessions own. */
21
+ owner?: string;
22
+ /** How long ago it finished (finished tasks), was saved (plans) or was archived, as `3h`. */
23
+ age?: string;
24
+ /** The GitHub issue a plan came from. */
25
+ issue?: number;
26
+ /** Auto mode is on: the oracle drives it without asking. */
27
+ auto?: boolean;
28
+ }
@@ -19,12 +19,11 @@ import { readFirstExisting } from "../knowledge/store.ts";
19
19
  import { overThreshold } from "../knowledge/compactor.ts";
20
20
  import { applyApprovalChoice, describeTask, describeOversizedKnowledge, lastQaAsks, waiveQa, type ApprovalChoice } from "../workflow/workflow.ts";
21
21
  import { applyStatus, registerRevealShortcut, setMinimized } from "./ui.ts";
22
- import { classifierSummary, openSettings } from "./settings-ui.ts";
22
+ import { classifierSummary } from "./model-settings.ts";
23
23
  import { keyStatus } from "../classifier/instance.ts";
24
24
  import { kickoff, startPlannedTask } from "./start-task.ts";
25
25
  import { startRequest } from "./route.ts";
26
26
  import { setAuto, toggleOwnAuto } from "./owner.ts";
27
- import { autoOpenLobby, showLobby } from "../lobby/runtime.ts";
28
27
  import { registerWebServer, webCommand } from "../webui/command.ts";
29
28
  import { modelRef, thinkingMismatches } from "./model-support.ts";
30
29
  import { describeRun, runFromLog } from "./run-summary.ts";
@@ -35,7 +34,7 @@ import type { GitIsolation } from "../schemas/configuration.ts";
35
34
  import { START_FLAG } from "./start-flags.ts";
36
35
 
37
36
  const HELP = [
38
- "/bot-lobby Open the lobby: tasks, plan, quick fix, metrics (alt+l)",
37
+ "/bot-lobby Open the web lobby in your browser: tasks, plan, quick fix, metrics",
39
38
  "/bot-lobby <request> Start a request: a quick fix when one agent can do it alone (the oracle confirms), else a task",
40
39
  "/bot-lobby --task [--auto] <request> Always a task (also when the request begins with a subcommand word)",
41
40
  "/bot-lobby --budget 90m <request> Start a task with a time budget the oracle divides between its agents",
@@ -50,14 +49,14 @@ const HELP = [
50
49
  "/bot-lobby accept [taskId] Accept a task's work as it is, without a QA pass; the oracle then completes it",
51
50
  "/bot-lobby knowledge Show persistent knowledge files",
52
51
  "/bot-lobby runs [taskId] Recent subagent runs: time, turns, tokens, cost, model",
53
- "/bot-lobby settings Edit per-agent model/thinking/instructions",
52
+ "/bot-lobby settings Open the web lobby's Settings: per-agent model, effort and instructions",
54
53
  "/bot-lobby config Show effective configuration",
55
54
  "/bot-lobby minimize|restore Hide or restore bot-lobby for this session (ctrl+shift+m)",
56
55
  "/bot-lobby claim <taskId> Take ownership of an orphaned task",
57
56
  "/bot-lobby auto [on|off] Auto mode: the oracle drives this session's task without asking (alt+g)",
58
57
  "/bot-lobby start-plan PLAN-… [auto] Start a planned task here; its agreed plan needs no approval",
59
- "/bot-lobby switch <session.jsonl> Run a saved session in this window (the lobby's session browser uses it: alt+o, s)",
60
- "/bot-lobby web [stop|link|reset] Start the browser UI on this machine (loopback only), or stop it, print its link, reset its link",
58
+ "/bot-lobby switch <session.jsonl> Run a saved session in this window (the web lobby's session browser uses it)",
59
+ "/bot-lobby web [stop|link|reset] Open the browser UI (it starts with pi, on this machine only), or stop it, print its link, reset its link",
61
60
  "/bot-lobby help This help",
62
61
  ].join("\n");
63
62
 
@@ -306,7 +305,7 @@ function showConfig(ctx: ExtensionCommandContext): void {
306
305
  async function switchCommand(ctx: ExtensionCommandContext, path: string): Promise<void> {
307
306
  if (!existsSync(path)) return ctx.ui.notify(`bot-lobby: no session file at ${path}`, "warning");
308
307
  if (ctx.sessionManager.getSessionFile() === path) return ctx.ui.notify("bot-lobby: this window already runs that session", "info");
309
- if (!ctx.isIdle()) return ctx.ui.notify("bot-lobby: the oracle is working — esc stops it, then switch", "warning");
308
+ if (!ctx.isIdle()) return ctx.ui.notify("bot-lobby: the oracle is working — stop it, then switch", "warning");
310
309
  const result = await ctx.switchSession(path);
311
310
  if (result.cancelled) ctx.ui.notify("bot-lobby: the switch was cancelled", "warning");
312
311
  }
@@ -336,19 +335,14 @@ export function registerCommands(pi: ExtensionAPI, configDir: string): void {
336
335
  const { sub, rest, restText, auto, budget, budgetError, track, task, isolation } = parseCommand(args ?? "");
337
336
  if (budgetError) return ctx.ui.notify(`bot-lobby: ${budgetError}`, "warning");
338
337
  if (!sub) {
339
- if (!restText) {
340
- if (!showLobby()) ctx.ui.notify(HELP, "info");
341
- return;
342
- }
338
+ if (!restText) return webCommand(ctx, undefined);
343
339
  // A request one agent can do alone may go to the quick-fix agent once the oracle confirms; the rest start as tasks.
344
- const started = await startRequest(pi, ctx, configDir, restText, { ...(auto ? { auto } : {}), ...(budget ? { budget } : {}), ...(track ? { track } : {}), ...(task ? { task } : {}), ...(isolation ? { isolation } : {}) });
345
- if (started === "task") autoOpenLobby();
340
+ await startRequest(pi, ctx, configDir, restText, { ...(auto ? { auto } : {}), ...(budget ? { budget } : {}), ...(track ? { track } : {}), ...(task ? { task } : {}), ...(isolation ? { isolation } : {}) });
346
341
  return;
347
342
  }
348
343
  switch (sub) {
349
344
  case "lobby":
350
- if (!showLobby()) ctx.ui.notify("The lobby needs pi's interactive terminal UI.", "warning");
351
- return;
345
+ return webCommand(ctx, undefined);
352
346
  case "help":
353
347
  return ctx.ui.notify(HELP, "info");
354
348
  case "status":
@@ -376,7 +370,7 @@ export function registerCommands(pi: ExtensionAPI, configDir: string): void {
376
370
  case "knowledge":
377
371
  return showKnowledge(ctx, configDir);
378
372
  case "settings":
379
- return openSettings(pi, ctx);
373
+ return webCommand(ctx, undefined);
380
374
  case "minimize":
381
375
  setMinimized(true);
382
376
  return applyStatus(ctx, detectProjectRoot(ctx.cwd, configDir), configDir);
@@ -393,8 +387,8 @@ export function registerCommands(pi: ExtensionAPI, configDir: string): void {
393
387
  return webCommand(ctx, rest[0]);
394
388
  case "start-plan": {
395
389
  const started = await startPlannedTask(pi, ctx, configDir, rest[0]!, { auto: rest[1] === "auto" });
396
- if (typeof started === "string") return ctx.ui.notify(`bot-lobby: ${started}`, "warning");
397
- return autoOpenLobby();
390
+ if (typeof started === "string") ctx.ui.notify(`bot-lobby: ${started}`, "warning");
391
+ return;
398
392
  }
399
393
  default:
400
394
  return showConfig(ctx);
@@ -403,7 +397,7 @@ export function registerCommands(pi: ExtensionAPI, configDir: string): void {
403
397
  });
404
398
 
405
399
  pi.registerCommand("bot-lobby-settings", {
406
- description: "Edit per-agent model, thinking, and instructions",
407
- handler: async (_args, ctx) => openSettings(pi, ctx),
400
+ description: "Open the web lobby's settings: per-agent model, effort, and instructions",
401
+ handler: async (_args, ctx) => webCommand(ctx, undefined),
408
402
  });
409
403
  }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * What the settings page writes to the config, in one place: a profile entry
3
+ * patched into a config copy, the master's model and thinking applied to the
4
+ * live session, the subagents pinned to the session model, and the
5
+ * classifier's one-line summary.
6
+ */
7
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
8
+ import {
9
+ INHERIT_MODEL,
10
+ isThinkingLevel,
11
+ SUBAGENT_KINDS,
12
+ type AgentModelConfig,
13
+ type BotLobbyConfig,
14
+ type SubagentKind,
15
+ } from "../schemas/configuration.ts";
16
+ import { modelLookup } from "./tools.ts";
17
+ import { describeKey, jevEndpoint, type StatusSource } from "../classifier/hosts.ts";
18
+
19
+ /** One settings entry: the master or a subagent profile. */
20
+ export type SettingsKind = "master" | SubagentKind;
21
+ export const SETTINGS_KINDS: readonly SettingsKind[] = ["master", ...SUBAGENT_KINDS];
22
+
23
+ /** The editable fields of one entry; scouts have no thinking and no instructions of their own. */
24
+ export interface EntryView {
25
+ model: string;
26
+ thinking?: string;
27
+ instructions?: string;
28
+ timeoutMs?: number;
29
+ /** The model this agent switches to when its own runs out of usage; unset = none. */
30
+ fallbackModel?: string;
31
+ fallbackThinking?: string;
32
+ }
33
+
34
+ export function entryView(config: BotLobbyConfig, kind: SettingsKind): EntryView {
35
+ if (kind === "master") return config.master;
36
+ if (kind === "scout") return { model: config.scout.model, timeoutMs: config.scout.timeoutMs, ...(config.scout.fallbackModel ? { fallbackModel: config.scout.fallbackModel } : {}) };
37
+ if (kind === "researcher") return config.researcher;
38
+ if (kind === "quickfix") return config.quickFix;
39
+ if (kind === "planner") return config.planner;
40
+ return config.agents[kind];
41
+ }
42
+
43
+ interface EntryPatch {
44
+ model?: string;
45
+ thinking?: string;
46
+ instructions?: string;
47
+ timeoutMs?: number;
48
+ /** `inherit` clears the fallback (its thinking level with it). */
49
+ fallbackModel?: string;
50
+ fallbackThinking?: string;
51
+ }
52
+
53
+ /** A settings entry with the fallback patch applied: `inherit` removes the fallback model and its thinking. */
54
+ function withFallbackPatch<T extends { fallbackModel?: string; fallbackThinking?: string }>(entry: T): T {
55
+ if (entry.fallbackModel !== INHERIT_MODEL) return entry;
56
+ const { fallbackModel: _model, fallbackThinking: _thinking, ...rest } = entry;
57
+ return rest as T;
58
+ }
59
+
60
+ /** Apply a patch to one entry in a config copy; scouts ignore thinking and instructions. */
61
+ export function patchEntry(config: BotLobbyConfig, kind: SettingsKind, patch: EntryPatch): BotLobbyConfig {
62
+ const next: BotLobbyConfig = { ...config, agents: { ...config.agents } };
63
+ if (kind === "master") next.master = withFallbackPatch({ ...config.master, ...patch } as AgentModelConfig);
64
+ else if (kind === "scout") {
65
+ const fallbackModel = patch.fallbackModel ?? config.scout.fallbackModel;
66
+ next.scout = withFallbackPatch({
67
+ model: patch.model ?? config.scout.model,
68
+ timeoutMs: patch.timeoutMs ?? config.scout.timeoutMs,
69
+ ...(fallbackModel ? { fallbackModel } : {}),
70
+ });
71
+ } else if (kind === "researcher") next.researcher = withFallbackPatch({ ...config.researcher, ...patch } as AgentModelConfig);
72
+ else if (kind === "quickfix") next.quickFix = withFallbackPatch({ ...config.quickFix, ...patch } as AgentModelConfig);
73
+ else if (kind === "planner") next.planner = withFallbackPatch({ ...config.planner, ...patch } as AgentModelConfig);
74
+ else next.agents[kind] = withFallbackPatch({ ...config.agents[kind], ...patch } as AgentModelConfig);
75
+ return next;
76
+ }
77
+
78
+ /** Apply the master model/thinking to the live session; "inherit" leaves it alone. */
79
+ export async function applyMasterModel(pi: ExtensionAPI, ctx: ExtensionContext, config: BotLobbyConfig): Promise<void> {
80
+ const { model, thinking } = config.master;
81
+ if (model !== INHERIT_MODEL) {
82
+ const found = modelLookup(ctx)(model);
83
+ if (!found) ctx.ui.notify(`bot-lobby: unknown master model "${model}".`, "warning");
84
+ else if (!(await pi.setModel(found))) ctx.ui.notify(`bot-lobby: no auth for ${model}.`, "warning");
85
+ }
86
+ if (isThinkingLevel(thinking)) pi.setThinkingLevel(thinking);
87
+ }
88
+
89
+ /**
90
+ * Subagents never inherit silently: any subagent whose model is still unset is
91
+ * pinned to the session's current model and saved, so settings always show
92
+ * what each agent runs on.
93
+ */
94
+ export function prefillModels(config: BotLobbyConfig, sessionModel: string | undefined): { config: BotLobbyConfig; filled: SubagentKind[] } {
95
+ if (!sessionModel) return { config, filled: [] };
96
+ let next = config;
97
+ const filled: SubagentKind[] = [];
98
+ for (const kind of SUBAGENT_KINDS) {
99
+ if (entryView(next, kind).model !== INHERIT_MODEL) continue;
100
+ next = patchEntry(next, kind, { model: sessionModel });
101
+ filled.push(kind);
102
+ }
103
+ return { config: next, filled };
104
+ }
105
+
106
+ /** `on · OpenCode Zen · jev-1.13-free · key stored in pi`, for the settings menu and `/bot-lobby config`. */
107
+ export function classifierSummary(config: BotLobbyConfig, status: StatusSource | undefined): string {
108
+ const { host, model } = jevEndpoint(config.classifier, status);
109
+ const via = config.classifier.provider === "auto" ? `${host.label} (auto)` : host.label;
110
+ return [config.classifier.enabled ? "on" : "off", via, model, `key ${describeKey(host, status)}`].join(" · ");
111
+ }
112
+
@@ -14,7 +14,7 @@ import { detectProjectRoot, loadConfig } from "../state/project.ts";
14
14
  import { transition } from "../state/task-state.ts";
15
15
  import { shortTitle } from "../text.ts";
16
16
  import { applyStatus } from "./ui.ts";
17
- import { applyMasterModel } from "./settings-ui.ts";
17
+ import { applyMasterModel } from "./model-settings.ts";
18
18
  import { setAutoMode } from "../state/auto.ts";
19
19
  import { listPlannedTasks, loadPlannedTask, markPlannedTaskStarted, plannedTaskRequest, type PlannedTask } from "../state/backlog.ts";
20
20
  import { triageFor } from "../classifier/instance.ts";