@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
package/src/pi/tools.ts CHANGED
@@ -9,10 +9,9 @@ import { classifier, effortFor, hintsFor, knowledgeFor, triageFor } from "../cla
9
9
  import { truncate } from "../text.ts";
10
10
  import { applyStatus, reportRuns, summarizeRun } from "./ui.ts";
11
11
  import { whileAsking } from "../state/budget.ts";
12
- import { askUser } from "../ask/dialog.ts";
13
- import { promptHub } from "../lobby/prompt-hub.ts";
12
+ import { askChoice, askText, askUser, canAsk } from "../ask/web.ts";
14
13
  import type { AskQuestion } from "../ask/types.ts";
15
- import { unescapeBreaks } from "../lobby/markdown.ts";
14
+ import { unescapeBreaks } from "../lobby/blocks.ts";
16
15
  import { isQuiet } from "./quiet.ts";
17
16
  import { checkThinking, createProfileResolver, modelRef, type ModelLookup } from "./model-support.ts";
18
17
  import { agentName, describeRun } from "./run-summary.ts";
@@ -126,11 +125,11 @@ export function workflowDeps(
126
125
  triage: (request, triageSignal) => triageFor({ cwd: ctx.cwd, root, configDir }, request, triageSignal),
127
126
  effort: effortFor((model, thinking) => checkThinking(modelLookup(ctx)(model), thinking).level),
128
127
  // Time spent waiting on the user is not the task's: its budget clock waits too.
129
- // A free-text answer may run to several lines (Shift+Enter), so it is asked in the multi-line editor.
130
- ask: async (question) => (hasUI ? whileAsking(() => promptHub.run("text", "orchestrate", { question }, () => ctx.ui.editor(question))) : undefined),
131
- choose: async (title, options) => (hasUI ? whileAsking(() => promptHub.run("choose", "orchestrate", { title, options }, () => ctx.ui.select(title, options))) : undefined),
128
+ // The questions go to the web page, where a free-text answer may run to several lines.
129
+ ask: async (question) => (canAsk() ? whileAsking(() => askText(question)) : undefined),
130
+ choose: async (title, options) => (canAsk() ? whileAsking(() => askChoice(title, options)) : undefined),
132
131
  // An agent's own questions (the designer's), relayed as the questionnaire.
133
- ...(hasUI ? { askQuestions: (questions: AskQuestion[], from: string, askSignal?: AbortSignal) => whileAsking(() => promptHub.run("questionnaire", from, { questions }, () => askUser(questions, ctx, askSignal, from), askSignal ? { signal: askSignal } : undefined)) } : {}),
132
+ ...(canAsk() ? { askQuestions: (questions: AskQuestion[], from: string, askSignal?: AbortSignal) => whileAsking(() => askUser(questions, ctx, askSignal, from)) } : {}),
134
133
  notify: (message, level = "info") => {
135
134
  if (hasUI) ctx.ui.notify(message, level);
136
135
  },
package/src/pi/ui.ts CHANGED
@@ -80,17 +80,8 @@ export function isMinimized(): boolean {
80
80
  return minimized;
81
81
  }
82
82
 
83
- /** Told when minimize changes, so the lobby can step aside too. */
84
- let minimizeListener: ((value: boolean) => void) | undefined;
85
-
86
- export function onMinimizeChange(listener: ((value: boolean) => void) | undefined): void {
87
- minimizeListener = listener;
88
- }
89
-
90
83
  export function setMinimized(value: boolean): void {
91
- if (minimized === value) return;
92
84
  minimized = value;
93
- minimizeListener?.(value);
94
85
  }
95
86
 
96
87
  /** Flip minimize/restore and refresh the footer; the session is unchanged. */
@@ -120,39 +120,24 @@ export function isPanelMember(value: string): value is PanelMember {
120
120
  export const LOBBY_PANELS = ["conversation", "activity", "thinking"] as const;
121
121
  export type LobbyPanel = (typeof LOBBY_PANELS)[number];
122
122
 
123
- /** Where the web UI's questions are answered: in the browser, or only in the terminal. */
124
- export const LOBBY_WEB_QUESTIONS = ["both", "terminal"] as const;
125
- export type LobbyWebQuestions = (typeof LOBBY_WEB_QUESTIONS)[number];
126
-
127
- /** The loopback web UI: off until started with `/bot-lobby web`. */
123
+ /** The loopback web UI: it starts with every interactive session. */
128
124
  export interface LobbyWebConfig {
129
- /** Start the server with the session. */
130
- enabled: boolean;
131
125
  /** Base port (tried, then the next free up to +20); 0 means any free port. */
132
126
  port: number;
133
- /** Open the link in the browser on `/bot-lobby web`. */
127
+ /** Open the link in the browser when the session starts. */
134
128
  openBrowser: boolean;
135
- questions: LobbyWebQuestions;
136
129
  }
137
130
 
138
- /** The full-screen lobby. */
131
+ /** The web lobby. */
139
132
  export interface LobbyConfig {
140
- /** Open by itself when this session starts or resumes a task. */
141
- autoOpen: boolean;
142
133
  /** Who sits on the planning panel next to the oracle, until toggled in the Plan tab. */
143
134
  planningPanel: PanelMember[];
144
- /** Put the panel's questions to the user as soon as a round ends, while the Plan tab is open. */
145
- autoAsk: boolean;
146
135
  /** Show the GitHub Issues tab (off for now). */
147
136
  issues: boolean;
148
- /** Which panes show; toggled with keys in the lobby and remembered here. */
137
+ /** Which panes the Lobby tab shows. */
149
138
  panels: Record<LobbyPanel, boolean>;
150
- /** Key overrides by action name, e.g. `{ "toggleThinking": "alt+t" }`. */
139
+ /** Key overrides by action name, e.g. `{ "search": "alt+f" }`. */
151
140
  keys: Record<string, string>;
152
- /** A one-line status (task steps, planning round, quick fix, or idle) under the editor while the lobby is hidden. */
153
- miniLine: boolean;
154
- /** Clicks and the wheel work in the lobby (click a draft line to comment on it); shift+drag still selects text. */
155
- mouse: boolean;
156
141
  /**
157
142
  * Planning rounds before the oracle finalizes the plan on its own: the last
158
143
  * round skips the seats and asks nothing; later replies only revise. 0 = unlimited.
@@ -271,17 +256,13 @@ export const DEFAULT_CONFIG: BotLobbyConfig = {
271
256
  scratchpadMaxChars: 2000,
272
257
  },
273
258
  lobby: {
274
- autoOpen: true,
275
259
  planningPanel: [...PANEL_MEMBERS],
276
- autoAsk: true,
277
260
  issues: false,
278
261
  panels: { conversation: true, activity: true, thinking: true },
279
262
  keys: {},
280
- mouse: true,
281
- miniLine: true,
282
263
  maxPlanningRounds: 5,
283
264
  splitPlanAbove: 8,
284
- web: { enabled: false, port: 7347, openBrowser: true, questions: "both" },
265
+ web: { port: 7347, openBrowser: true },
285
266
  },
286
267
  classifier: {
287
268
  enabled: false,
@@ -344,7 +325,7 @@ function flag(value: unknown, fallback: boolean): boolean {
344
325
  }
345
326
 
346
327
  function normalizeLobby(value: unknown): LobbyConfig {
347
- const source = value as { autoOpen?: unknown; planningPanel?: unknown; autoAsk?: unknown; issues?: unknown; panels?: unknown; keys?: unknown; mouse?: unknown; miniLine?: unknown; maxPlanningRounds?: unknown; splitPlanAbove?: unknown; web?: unknown } | undefined;
328
+ const source = value as { planningPanel?: unknown; issues?: unknown; panels?: unknown; keys?: unknown; maxPlanningRounds?: unknown; splitPlanAbove?: unknown; web?: unknown } | undefined;
348
329
  const defaults = DEFAULT_CONFIG.lobby;
349
330
  const panel = Array.isArray(source?.planningPanel)
350
331
  ? [...new Set(source.planningPanel.filter((entry): entry is PanelMember => typeof entry === "string" && isPanelMember(entry)))]
@@ -352,26 +333,21 @@ function normalizeLobby(value: unknown): LobbyConfig {
352
333
  const panels = (source?.panels ?? {}) as Partial<Record<LobbyPanel, unknown>>;
353
334
  const keys = source?.keys && typeof source.keys === "object" ? source.keys as Record<string, unknown> : {};
354
335
  return {
355
- autoOpen: flag(source?.autoOpen, defaults.autoOpen),
356
336
  planningPanel: PANEL_MEMBERS.filter((member) => panel.includes(member)),
357
- autoAsk: flag(source?.autoAsk, defaults.autoAsk),
358
337
  issues: flag(source?.issues, defaults.issues),
359
338
  panels: Object.fromEntries(LOBBY_PANELS.map((name) => [name, flag(panels[name], defaults.panels[name])])) as Record<LobbyPanel, boolean>,
360
339
  keys: Object.fromEntries(Object.entries(keys).filter((entry): entry is [string, string] => typeof entry[1] === "string" && entry[1].trim().length > 0)),
361
- mouse: flag(source?.mouse, defaults.mouse),
362
- miniLine: flag(source?.miniLine, defaults.miniLine),
363
340
  maxPlanningRounds: roundLimit(source?.maxPlanningRounds, defaults.maxPlanningRounds),
364
341
  splitPlanAbove: roundLimit(source?.splitPlanAbove, defaults.splitPlanAbove),
365
342
  web: normalizeLobbyWeb(source?.web, defaults.web),
366
343
  };
367
344
  }
368
345
 
369
- /** The loopback web UI's config: 0 or a real port, `both` or `terminal`; anything else keeps the default. */
346
+ /** The loopback web UI's config: 0 or a real port; anything else keeps the default. */
370
347
  function normalizeLobbyWeb(value: unknown, fallback: LobbyWebConfig): LobbyWebConfig {
371
348
  const source = (value && typeof value === "object" ? value : {}) as Record<string, unknown>;
372
349
  const port = source.port === 0 ? 0 : webPort(source.port, fallback.port);
373
- const questions = source.questions === "both" || source.questions === "terminal" ? source.questions : fallback.questions;
374
- return { enabled: flag(source.enabled, fallback.enabled), port, openBrowser: flag(source.openBrowser, fallback.openBrowser), questions };
350
+ return { port, openBrowser: flag(source.openBrowser, fallback.openBrowser) };
375
351
  }
376
352
 
377
353
  /** A TCP port (0 means any free port); anything else keeps the default. */
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Files attached in the lobby's composer live in a temp folder outside the
3
+ * repository. Each one sent while a task was in play is remembered against
4
+ * that task, and removed when the task completes, is abandoned, archived or
5
+ * deleted. Files never sent, or sent with no task, go after a few days.
6
+ */
7
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+
11
+ /** The folder the page's preview route serves attachments from. */
12
+ export function attachmentDir(): string {
13
+ return join(tmpdir(), "bot-lobby-previews", "attachments");
14
+ }
15
+
16
+ function indexPath(): string {
17
+ return join(tmpdir(), "bot-lobby-previews", "attachments.json");
18
+ }
19
+
20
+ function readIndex(): Record<string, string> {
21
+ try {
22
+ const parsed = JSON.parse(readFileSync(indexPath(), "utf8")) as Record<string, unknown>;
23
+ return Object.fromEntries(Object.entries(parsed).filter((entry): entry is [string, string] => typeof entry[1] === "string"));
24
+ } catch {
25
+ return {};
26
+ }
27
+ }
28
+
29
+ function writeIndex(index: Record<string, string>): void {
30
+ try {
31
+ mkdirSync(join(tmpdir(), "bot-lobby-previews"), { recursive: true });
32
+ writeFileSync(indexPath(), JSON.stringify(index), { mode: 0o600 });
33
+ } catch {
34
+ // The files then fall to the age sweep.
35
+ }
36
+ }
37
+
38
+ /** Remember that `ids` belong to `taskId`. */
39
+ export function claimAttachments(ids: readonly string[], taskId: string): void {
40
+ if (ids.length === 0) return;
41
+ const index = readIndex();
42
+ for (const id of ids) index[id] = taskId;
43
+ writeIndex(index);
44
+ }
45
+
46
+ /** Delete every file that was sent while `taskId` was in play. */
47
+ export function releaseAttachments(taskId: string): number {
48
+ const index = readIndex();
49
+ let removed = 0;
50
+ for (const [id, owner] of Object.entries(index)) {
51
+ if (owner !== taskId) continue;
52
+ try {
53
+ rmSync(join(attachmentDir(), id), { force: true });
54
+ removed += 1;
55
+ } catch {
56
+ // A file already gone is the goal.
57
+ }
58
+ delete index[id];
59
+ }
60
+ if (removed > 0 || Object.keys(index).length !== Object.keys(readIndex()).length) writeIndex(index);
61
+ return removed;
62
+ }
63
+
64
+ /** Delete files older than `maxAgeMs` that no task claimed. */
65
+ export function sweepAttachments(maxAgeMs: number, now = Date.now()): number {
66
+ const dir = attachmentDir();
67
+ if (!existsSync(dir)) return 0;
68
+ const claimed = new Set(Object.keys(readIndex()));
69
+ let removed = 0;
70
+ for (const name of readdirSync(dir)) {
71
+ if (claimed.has(name)) continue;
72
+ try {
73
+ if (now - statSync(join(dir, name)).mtimeMs > maxAgeMs) {
74
+ rmSync(join(dir, name), { force: true });
75
+ removed += 1;
76
+ }
77
+ } catch {
78
+ // Skip what cannot be read.
79
+ }
80
+ }
81
+ return removed;
82
+ }
@@ -9,7 +9,7 @@ import type { ServerResponse } from "node:http";
9
9
  import { Type, type TObject } from "typebox";
10
10
  import { Compile } from "typebox/compile";
11
11
  import { statusFor, type ApiName, type ErrorCode } from "../protocol.ts";
12
- import type { LobbyService } from "../../lobby/service.ts";
12
+ import type { LobbyService } from "../../lobby/host.ts";
13
13
  import { statusGet } from "./status.ts";
14
14
  import { lobbyAbort, lobbyHistory, lobbySend, lobbySnapshot } from "./lobby.ts";
15
15
  import { promptsAnswer, promptsDismiss, promptsList } from "./prompts.ts";
@@ -78,6 +78,8 @@ const Empty = Type.Object({}, { additionalProperties: false });
78
78
  const TaskId = Type.String({ minLength: 1, maxLength: 200 });
79
79
  const GhNumber = Type.Integer({ minimum: 1, maximum: 1_000_000_000 });
80
80
  const NoticeText = Type.String({ maxLength: 20_000 });
81
+ /** Ids of files uploaded through `files.upload`; the message names them by path. */
82
+ const Attachments = Type.Optional(Type.Array(Type.String({ minLength: 1, maxLength: 200 }), { maxItems: 8 }));
81
83
  const CommentText = Type.String({ maxLength: 10_000 });
82
84
  const DialogAnswer = Type.Union([
83
85
  Type.Object({ value: Type.String() }, { additionalProperties: false }),
@@ -112,8 +114,8 @@ function buildRoutes(): Record<string, Route> {
112
114
  run: (body, ctx) => lobbyHistory(body as { before?: number }, ctx),
113
115
  };
114
116
  routes["lobby.send"] = {
115
- schema: Type.Object({ text: Type.String({ maxLength: 20_000 }) }, { additionalProperties: false }),
116
- run: (body, ctx) => lobbySend(body as { text: string }, ctx),
117
+ schema: Type.Object({ text: Type.String({ maxLength: 20_000 }), attachments: Attachments }, { additionalProperties: false }),
118
+ run: (body, ctx) => lobbySend(body as { text: string; attachments?: string[] }, ctx),
117
119
  };
118
120
  routes["lobby.abort"] = { schema: Empty, run: (_body, ctx) => lobbyAbort(ctx) };
119
121
  routes["tasks.list"] = { schema: Empty, run: (_body, ctx) => tasksList(ctx) };
@@ -127,8 +129,8 @@ function buildRoutes(): Record<string, Route> {
127
129
  run: (body, ctx) => tasksComments(body as { taskId: string }, ctx),
128
130
  };
129
131
  routes["tasks.comment"] = {
130
- schema: Type.Object({ taskId: TaskId, text: CommentText }, { additionalProperties: false }),
131
- run: (body, ctx) => tasksComment(body as { taskId: string; text: string }, ctx),
132
+ schema: Type.Object({ taskId: TaskId, text: CommentText, attachments: Attachments }, { additionalProperties: false }),
133
+ run: (body, ctx) => tasksComment(body as { taskId: string; text: string; attachments?: string[] }, ctx),
132
134
  };
133
135
  routes["tasks.archive"] = {
134
136
  schema: Type.Object({ taskId: TaskId }, { additionalProperties: false }),
@@ -147,8 +149,8 @@ function buildRoutes(): Record<string, Route> {
147
149
  run: (body, ctx) => tasksAuto(body as { taskId: string; on: boolean }, ctx),
148
150
  };
149
151
  routes["tasks.message"] = {
150
- schema: Type.Object({ taskId: TaskId, text: NoticeText }, { additionalProperties: false }),
151
- run: (body, ctx) => tasksMessage(body as { taskId: string; text: string }, ctx),
152
+ schema: Type.Object({ taskId: TaskId, text: NoticeText, attachments: Attachments }, { additionalProperties: false }),
153
+ run: (body, ctx) => tasksMessage(body as { taskId: string; text: string; attachments?: string[] }, ctx),
152
154
  };
153
155
  routes["plans.get"] = {
154
156
  schema: Type.Object({ planId: TaskId }, { additionalProperties: false }),
@@ -168,16 +170,16 @@ function buildRoutes(): Record<string, Route> {
168
170
  run: (body, ctx) => sessionsChat(body as { key?: string; sessionId?: string; before?: number }, ctx),
169
171
  };
170
172
  routes["sessions.start"] = {
171
- schema: Type.Object({ request: Type.Optional(NoticeText), planId: Type.Optional(TaskId), auto: Type.Optional(Type.Boolean()) }, { additionalProperties: false }),
172
- run: (body, ctx) => sessionsStart(body as { request?: string; planId?: string; auto?: boolean }, ctx),
173
+ schema: Type.Object({ request: Type.Optional(NoticeText), planId: Type.Optional(TaskId), auto: Type.Optional(Type.Boolean()), attachments: Attachments }, { additionalProperties: false }),
174
+ run: (body, ctx) => sessionsStart(body as { request?: string; planId?: string; auto?: boolean; attachments?: string[] }, ctx),
173
175
  };
174
176
  routes["sessions.stop"] = {
175
177
  schema: Type.Object({ key: TaskId }, { additionalProperties: false }),
176
178
  run: (body, ctx) => sessionsStop(body as { key: string }, ctx),
177
179
  };
178
180
  routes["sessions.message"] = {
179
- schema: Type.Object({ key: Type.Optional(TaskId), sessionId: Type.Optional(TaskId), text: NoticeText }, { additionalProperties: false }),
180
- run: (body, ctx) => sessionsMessage(body as { key?: string; sessionId?: string; text: string }, ctx),
181
+ schema: Type.Object({ key: Type.Optional(TaskId), sessionId: Type.Optional(TaskId), text: NoticeText, attachments: Attachments }, { additionalProperties: false }),
182
+ run: (body, ctx) => sessionsMessage(body as { key?: string; sessionId?: string; text: string; attachments?: string[] }, ctx),
181
183
  };
182
184
  routes["sessions.switch"] = {
183
185
  schema: Type.Object({ key: Type.Optional(TaskId), sessionId: Type.Optional(TaskId), claimTaskId: Type.Optional(TaskId) }, { additionalProperties: false }),
@@ -202,8 +204,8 @@ function buildRoutes(): Record<string, Route> {
202
204
  run: (body, ctx) => plannerNew(body as { seed?: { issue: { number: number; title: string; url?: string }; body: string }; seats?: ("backend" | "designer" | "qa" | "researcher")[] }, ctx),
203
205
  };
204
206
  routes["planner.send"] = {
205
- schema: Type.Object({ text: NoticeText }, { additionalProperties: false }),
206
- run: (body, ctx) => plannerSend(body as { text: string }, ctx),
207
+ schema: Type.Object({ text: NoticeText, attachments: Attachments }, { additionalProperties: false }),
208
+ run: (body, ctx) => plannerSend(body as { text: string; attachments?: string[] }, ctx),
207
209
  };
208
210
  routes["planner.toggleSeat"] = {
209
211
  schema: Type.Object({ member: Member }, { additionalProperties: false }),
@@ -218,8 +220,8 @@ function buildRoutes(): Record<string, Route> {
218
220
  routes["planner.save"] = { schema: Empty, run: (_body, ctx) => plannerSave(ctx) };
219
221
  routes["quickfix.list"] = { schema: Empty, run: (_body, ctx) => quickfixList(ctx) };
220
222
  routes["quickfix.submit"] = {
221
- schema: Type.Object({ text: NoticeText }, { additionalProperties: false }),
222
- run: (body, ctx) => quickfixSubmit(body as { text: string }, ctx),
223
+ schema: Type.Object({ text: NoticeText, attachments: Attachments }, { additionalProperties: false }),
224
+ run: (body, ctx) => quickfixSubmit(body as { text: string; attachments?: string[] }, ctx),
223
225
  };
224
226
  routes["quickfix.cancel"] = {
225
227
  schema: Type.Object({ id: TaskId }, { additionalProperties: false }),
@@ -7,6 +7,7 @@
7
7
  import { MAX_ACTIVITY, MAX_CHAT, MAX_THOUGHTS } from "../../lobby/feed.ts";
8
8
  import type { Task } from "../../schemas/task.ts";
9
9
  import type { ApiContext } from "./index.ts";
10
+ import { withAttachments } from "../uploads.ts";
10
11
  import { fail } from "./index.ts";
11
12
  import { taskSteps } from "./plan-facts.ts";
12
13
  import type { LobbySnapshot, SnapshotTask } from "../protocol.ts";
@@ -74,9 +75,9 @@ export function lobbyHistory(body: { before?: number }, ctx: ApiContext): { entr
74
75
  }
75
76
 
76
77
  /** Text for the oracle; a notice when it was not simply sent. */
77
- export function lobbySend(body: { text: string }, ctx: ApiContext): { notice?: string } {
78
- if (!body.text.trim()) return { notice: "type something first" };
79
- const notice = ctx.service.toOracle(body.text);
78
+ export function lobbySend(body: { text: string; attachments?: string[] }, ctx: ApiContext): { notice?: string } {
79
+ if (!body.text.trim() && !body.attachments?.length) return { notice: "type something first" };
80
+ const notice = ctx.service.toOracle(withAttachments(body.text, body.attachments, ctx.service.zen().task?.id));
80
81
  return notice ? { notice } : {};
81
82
  }
82
83
 
@@ -8,6 +8,7 @@ import { MEMBER_LABELS } from "../../lobby/planner.ts";
8
8
  import type { PanelMember } from "../../schemas/configuration.ts";
9
9
  import type { PlannerSnapshot } from "../protocol.ts";
10
10
  import type { ApiContext } from "./index.ts";
11
+ import { withAttachments } from "../uploads.ts";
11
12
  import { fail } from "./index.ts";
12
13
 
13
14
  /** The planning session as the page reads it; absent means no session yet. */
@@ -47,11 +48,12 @@ export function plannerNew(body: { seed?: { issue: { number: number; title: stri
47
48
  }
48
49
 
49
50
  /** Send a message to the panel; the round runs in the background. */
50
- export function plannerSend(body: { text: string }, ctx: ApiContext): { notice: string } {
51
- if (!body.text.trim()) fail(400, "bad_request", "describe the task to plan");
51
+ export function plannerSend(body: { text: string; attachments?: string[] }, ctx: ApiContext): { notice: string } {
52
+ if (!body.text.trim() && !body.attachments?.length) fail(400, "bad_request", "describe the task to plan");
53
+ const text = withAttachments(body.text, body.attachments);
52
54
  const session = ctx.service.planner() ?? ctx.service.newPlanner(undefined, [...ctx.service.defaultPanel()]);
53
- if (session!.busy) fail(409, "conflict", "the panel is still thinking — x stops it");
54
- session!.send(body.text).catch(() => {});
55
+ if (session!.busy) fail(409, "conflict", "the panel is still thinking — wait for the round to finish");
56
+ session!.send(text).catch(() => {});
55
57
  return { notice: "sent — the panel is on the next round" };
56
58
  }
57
59
 
@@ -5,6 +5,7 @@
5
5
  * terminal shows.
6
6
  */
7
7
  import type { ApiContext } from "./index.ts";
8
+ import { withAttachments } from "../uploads.ts";
8
9
  import { fail } from "./index.ts";
9
10
  import type { QuickFixJob } from "../../lobby/quickfix.ts";
10
11
 
@@ -14,9 +15,9 @@ export function quickfixList(ctx: ApiContext): { jobs: QuickFixJob[] } {
14
15
  }
15
16
 
16
17
  /** Queue a quick fix; it starts at once when nothing else is running. */
17
- export function quickfixSubmit(body: { text: string }, ctx: ApiContext): { notice: string; id: string } {
18
- if (!body.text.trim()) fail(400, "bad_request", "describe a quick change first");
19
- const job = ctx.service.quickfix.submit(body.text);
18
+ export function quickfixSubmit(body: { text: string; attachments?: string[] }, ctx: ApiContext): { notice: string; id: string } {
19
+ if (!body.text.trim() && !body.attachments?.length) fail(400, "bad_request", "describe a quick change first");
20
+ const job = ctx.service.quickfix.submit(withAttachments(body.text, body.attachments));
20
21
  return { notice: ctx.service.quickfix.running?.id === job.id ? `${job.id} started` : `${job.id} queued behind the running quick fix`, id: job.id };
21
22
  }
22
23
 
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import type { BackgroundSession, DialogAnswer } from "../../lobby/sessions.ts";
9
9
  import type { ApiContext } from "./index.ts";
10
+ import { withAttachments } from "../uploads.ts";
10
11
  import { fail } from "./index.ts";
11
12
  import type { BackgroundSessionInfo } from "../protocol.ts";
12
13
 
@@ -50,7 +51,7 @@ function pageChat(all: unknown[], older: boolean, before?: number): { entries: u
50
51
  }
51
52
 
52
53
  /** Start a task in a new background session, from a request or a saved plan. */
53
- export function sessionsStart(body: { request?: string; planId?: string; auto?: boolean }, ctx: ApiContext): { notice: string; key?: string } {
54
+ export function sessionsStart(body: { request?: string; planId?: string; auto?: boolean; attachments?: string[] }, ctx: ApiContext): { notice: string; key?: string } {
54
55
  if (body.request?.trim() && body.planId) fail(400, "bad_request", "start from a request or a plan, not both");
55
56
  if (body.planId) {
56
57
  const plan = ctx.service.plans().find((entry) => entry.id === body.planId);
@@ -59,8 +60,8 @@ export function sessionsStart(body: { request?: string; planId?: string; auto?:
59
60
  if (typeof session === "string") return { notice: session };
60
61
  return { notice: `started ${session.name} in a new session`, key: session.key };
61
62
  }
62
- if (!body.request?.trim()) fail(400, "bad_request", "describe the task first");
63
- const session = ctx.service.startSession({ request: body.request!.trim(), ...(body.auto ? { auto: true } : {}) });
63
+ if (!body.request?.trim() && !body.attachments?.length) fail(400, "bad_request", "describe the task first");
64
+ const session = ctx.service.startSession({ request: withAttachments((body.request ?? "").trim(), body.attachments), ...(body.auto ? { auto: true } : {}) });
64
65
  if (typeof session === "string") return { notice: session };
65
66
  return { notice: `started ${session.name} in a new session`, key: session.key };
66
67
  }
@@ -74,16 +75,17 @@ export function sessionsStop(body: { key: string }, ctx: ApiContext): { notice:
74
75
  }
75
76
 
76
77
  /** Message a session: background ones directly, other terminals through their inbox. */
77
- export function sessionsMessage(body: { key?: string; sessionId?: string; text: string }, ctx: ApiContext): { notice?: string } {
78
+ export function sessionsMessage(body: { key?: string; sessionId?: string; text: string; attachments?: string[] }, ctx: ApiContext): { notice?: string } {
78
79
  if (!body.key && !body.sessionId) fail(400, "bad_request", "a session key or id is required");
79
- if (!body.text.trim()) return { notice: "type something first" };
80
+ if (!body.text.trim() && !body.attachments?.length) return { notice: "type something first" };
81
+ const text = withAttachments(body.text, body.attachments);
80
82
  const background = backgroundOf(ctx, body.key, body.sessionId);
81
83
  if (background) {
82
84
  if (!background.alive) fail(409, "conflict", `${background.name} has ended`);
83
- background.send(body.text);
85
+ background.send(text);
84
86
  return {};
85
87
  }
86
- return { notice: ctx.service.sendToSession(body.sessionId!, body.text) };
88
+ return { notice: ctx.service.sendToSession(body.sessionId!, text) };
87
89
  }
88
90
 
89
91
  /** Run a session in this window (stopping its background process first). */
@@ -1,14 +1,15 @@
1
1
  /**
2
2
  * `settings.get` / `settings.set`: the effective bot-lobby config (never a
3
3
  * secret) and the models Pi offers. A set merges the patch over the current
4
- * config, normalises it through the same `resolveConfig` the terminal uses and
5
- * saves it the way the settings menu does, so the terminal sees the change.
6
- * The one terminal wording reused here is the port's; everything else the
4
+ * config, normalises it through `resolveConfig` and saves it; everything the
7
5
  * normaliser clamps or drops, so no unknown field is ever written.
8
6
  */
9
7
  import { resolveConfig, type BotLobbyConfig } from "../../schemas/configuration.ts";
10
8
  import { loadConfig, saveConfig } from "../../state/project.ts";
11
9
  import { lobbyTopics } from "../../lobby/topics.ts";
10
+ import { prefillModels } from "../../pi/model-settings.ts";
11
+ import { kindLabel } from "../../pi/model-support.ts";
12
+ import { pushNotice } from "../notices.ts";
12
13
  import type { SettingsInfo } from "../protocol.ts";
13
14
  import type { ApiContext } from "./index.ts";
14
15
  import { fail } from "./index.ts";
@@ -49,7 +50,7 @@ function writeConfig(ctx: ApiContext, config: BotLobbyConfig): void {
49
50
  else saveConfig(config);
50
51
  }
51
52
 
52
- /** Reject a port that is not 0 or 1-65535, with the terminal's wording. */
53
+ /** Reject a port that is not 0 or 1-65535, . */
53
54
  function checkPort(patch: Record<string, unknown>): void {
54
55
  const lobby = isPlain(patch.lobby) ? patch.lobby : undefined;
55
56
  const web = lobby && isPlain(lobby.web) ? lobby.web : undefined;
@@ -65,7 +66,7 @@ function agentEntries(patch: Record<string, unknown>): unknown[] {
65
66
  return [patch.master, ...agents, patch.researcher, patch.quickFix, patch.planner, patch.scout];
66
67
  }
67
68
 
68
- /** Reject a time limit that is not a positive number, with the terminal's wording. */
69
+ /** Reject a time limit that is not a positive number, . */
69
70
  function checkTimeouts(patch: Record<string, unknown>): void {
70
71
  for (const entry of agentEntries(patch)) {
71
72
  const timeout = isPlain(entry) ? entry.timeoutMs : undefined;
@@ -76,12 +77,23 @@ function checkTimeouts(patch: Record<string, unknown>): void {
76
77
  }
77
78
  }
78
79
 
79
- /** The page's first settings call. */
80
+ /**
81
+ * The page's first settings call. Subagents never inherit a model silently:
82
+ * any whose model is still unset is pinned to the session's model and saved,
83
+ * so the page always shows what each agent runs on.
84
+ */
80
85
  export function settingsGet(ctx: ApiContext): SettingsInfo {
81
- return { config: stripSecrets(readConfig(ctx)), models: ctx.service.models?.() ?? [] };
86
+ let config = readConfig(ctx);
87
+ const prefilled = prefillModels(config, ctx.service.sessionModel?.());
88
+ if (prefilled.filled.length > 0) {
89
+ writeConfig(ctx, prefilled.config);
90
+ config = prefilled.config;
91
+ pushNotice(`${prefilled.filled.map(kindLabel).join(", ")} now run on the session model; change them here any time.`);
92
+ }
93
+ return { config: stripSecrets(config), models: ctx.service.models?.() ?? [] };
82
94
  }
83
95
 
84
- /** Merge, normalise and save the patch; the terminal rerenders and rereads the config. */
96
+ /** Merge, normalise and save the patch; the lobby rereads the config. */
85
97
  export function settingsSet(body: { patch: Record<string, unknown> }, ctx: ApiContext): { config: BotLobbyConfig } {
86
98
  checkPort(body.patch);
87
99
  checkTimeouts(body.patch);
@@ -1,11 +1,10 @@
1
1
  /**
2
2
  * `status.get`: everything the page shell needs before it reads a tab —
3
3
  * workspace, session, tabs, shortcuts and open windows. Tab ids and labels
4
- * come from the shared strings, shortcuts from the shared key map (with the
5
- * web-only tab cycling defaults), so the page matches the terminal exactly.
4
+ * come from the shared strings, shortcuts from the key map.
6
5
  */
7
6
  import { loadConfig } from "../../state/project.ts";
8
- import { LOBBY_ACTIONS, keyLabel, webKeyMap } from "../../lobby/keys.ts";
7
+ import { LOBBY_ACTIONS, keyLabel, keyMap } from "../../lobby/keys.ts";
9
8
  import { TAB_LABELS, visibleTabs } from "../../lobby/prompts.ts";
10
9
  import type { ApiContext } from "./index.ts";
11
10
  import type { KeyInfo, StatusInfo, TabInfo } from "../protocol.ts";
@@ -15,7 +14,7 @@ function tabs(issuesEnabled: boolean): TabInfo[] {
15
14
  }
16
15
 
17
16
  function keys(overrides: Readonly<Record<string, string>>): KeyInfo[] {
18
- const map = webKeyMap(overrides);
17
+ const map = keyMap(overrides);
19
18
  return (Object.keys(LOBBY_ACTIONS) as Array<keyof typeof LOBBY_ACTIONS>).map((action) => ({
20
19
  action,
21
20
  key: map[action],
@@ -24,16 +23,6 @@ function keys(overrides: Readonly<Record<string, string>>): KeyInfo[] {
24
23
  }));
25
24
  }
26
25
 
27
- /** Whether pi itself is asking in the terminal (a later surface reports it; false until one does). */
28
- function terminalDialog(ctx: ApiContext): boolean {
29
- const extra = ctx.service as unknown as { terminalDialog?: () => boolean };
30
- try {
31
- return extra.terminalDialog?.() ?? false;
32
- } catch {
33
- return false;
34
- }
35
- }
36
-
37
26
  /** The page shell's first call. */
38
27
  export function statusGet(ctx: ApiContext): StatusInfo {
39
28
  const service = ctx.service;
@@ -45,9 +34,9 @@ export function statusGet(ctx: ApiContext): StatusInfo {
45
34
  sessionId: service.sessionId() ?? undefined,
46
35
  sessionName: service.sessionName() ?? undefined,
47
36
  busy: service.masterBusy(),
48
- terminalDialog: terminalDialog(ctx),
49
37
  port: ctx.port,
50
38
  issuesEnabled,
39
+ panels: loadConfig().lobby.panels,
51
40
  tabs: tabs(issuesEnabled),
52
41
  keys: keys(loadConfig().lobby.keys),
53
42
  windows: [],
@@ -1,23 +1,19 @@
1
1
  /**
2
2
  * The Tasks tab over HTTP: rows, archived tasks, plan comments, and the row
3
- * actions (comment, archive, restore, delete, auto mode, message). Actions
4
- * answer with the same notice text the terminal shows.
5
- *
6
- * The row shape mirrors `taskRows` in `src/lobby/tabs/tasks.ts`, whose pure
7
- * data part is rebuilt here instead of imported: that module draws through
8
- * `layout.ts` and would load the TUI (`@earendil-works/pi-tui`) into the
9
- * server. The one type import above is erased at compile time.
3
+ * actions (comment, archive, restore, delete, auto mode, message). The row
4
+ * shape is `TaskRow` in `src/lobby/task-rows.ts`.
10
5
  */
11
6
  import { TERMINAL_STATES, taskRequest, type Task } from "../../schemas/task.ts";
12
7
  import type { PlannedTask } from "../../state/backlog.ts";
13
8
  import { describeRun, runFromLog } from "../../pi/run-summary.ts";
14
9
  import { pendingApprovals } from "../../workflow/approvals.ts";
15
- import type { TaskRow } from "../../lobby/tabs/tasks.ts";
10
+ import type { TaskRow } from "../../lobby/task-rows.ts";
16
11
  import type { TaskDetail } from "../protocol.ts";
17
12
  import type { ApiContext } from "./index.ts";
13
+ import { withAttachments } from "../uploads.ts";
18
14
  import { fail } from "./index.ts";
19
15
  import { taskSteps } from "./plan-facts.ts";
20
- import type { TaskSection } from "../../lobby/tabs/tasks.ts";
16
+ import type { TaskSection } from "../../lobby/task-rows.ts";
21
17
 
22
18
  interface RowContext {
23
19
  names: ReadonlyMap<string, string>;
@@ -140,9 +136,9 @@ export function tasksComments(body: { taskId: string }, ctx: ApiContext): { comm
140
136
  }
141
137
 
142
138
  /** Comment on a task's plan; blank text is a no-op notice like the lobby's. */
143
- export function tasksComment(body: { taskId: string; text: string }, ctx: ApiContext): { notice?: string } {
144
- if (!body.text.trim()) return { notice: "type something first" };
145
- return { notice: ctx.service.comment(body.taskId, body.text) };
139
+ export function tasksComment(body: { taskId: string; text: string; attachments?: string[] }, ctx: ApiContext): { notice?: string } {
140
+ if (!body.text.trim() && !body.attachments?.length) return { notice: "type something first" };
141
+ return { notice: ctx.service.comment(body.taskId, withAttachments(body.text, body.attachments, body.taskId)) };
146
142
  }
147
143
 
148
144
  /** Archive, restore or delete a task; each answers the terminal's notice. */
@@ -171,9 +167,9 @@ export function tasksAuto(body: { taskId: string; on: boolean }, ctx: ApiContext
171
167
  }
172
168
 
173
169
  /** Leave a message for a task's oracle; blank text is a no-op notice. */
174
- export function tasksMessage(body: { taskId: string; text: string }, ctx: ApiContext): { notice: string } {
175
- if (!body.text.trim()) return { notice: "type something first" };
176
- return { notice: ctx.service.sendToTask(body.taskId, body.text) };
170
+ export function tasksMessage(body: { taskId: string; text: string; attachments?: string[] }, ctx: ApiContext): { notice: string } {
171
+ if (!body.text.trim() && !body.attachments?.length) return { notice: "type something first" };
172
+ return { notice: ctx.service.sendToTask(body.taskId, withAttachments(body.text, body.attachments, body.taskId)) };
177
173
  }
178
174
 
179
175
  /** How many recent runs the detail lists (the terminal's count). */