@a-t-h-i/bot-lobby 0.6.11 → 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 (64) hide show
  1. package/README.md +26 -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 +6 -4
  20. package/src/roles/reviewer.ts +9 -1
  21. package/src/schemas/configuration.ts +33 -1
  22. package/src/state/changes.ts +1 -1
  23. package/src/state/persistence.ts +1 -1
  24. package/src/state/presence.ts +2 -0
  25. package/src/webui/api/excalidraw.ts +105 -0
  26. package/src/webui/api/gh-error.ts +17 -0
  27. package/src/webui/api/git.ts +98 -0
  28. package/src/webui/api/index.ts +379 -0
  29. package/src/webui/api/issues.ts +50 -0
  30. package/src/webui/api/knowledge.ts +79 -0
  31. package/src/webui/api/lobby.ts +91 -0
  32. package/src/webui/api/metrics.ts +63 -0
  33. package/src/webui/api/plan-facts.ts +29 -0
  34. package/src/webui/api/planner.ts +98 -0
  35. package/src/webui/api/plans.ts +44 -0
  36. package/src/webui/api/prompts.ts +35 -0
  37. package/src/webui/api/quickfix.ts +48 -0
  38. package/src/webui/api/sessions.ts +114 -0
  39. package/src/webui/api/settings.ts +93 -0
  40. package/src/webui/api/status.ts +55 -0
  41. package/src/webui/api/tasks.ts +204 -0
  42. package/src/webui/auth.ts +153 -0
  43. package/src/webui/command.ts +152 -0
  44. package/src/webui/dev/fake-service.ts +546 -0
  45. package/src/webui/dev/fixtures.ts +180 -0
  46. package/src/webui/dev/serve.ts +54 -0
  47. package/src/webui/events.ts +207 -0
  48. package/src/webui/files.ts +88 -0
  49. package/src/webui/notices.ts +61 -0
  50. package/src/webui/open.ts +35 -0
  51. package/src/webui/protocol.ts +565 -0
  52. package/src/webui/server.ts +315 -0
  53. package/src/webui/static.ts +125 -0
  54. package/src/workflow/workflow.ts +22 -6
  55. package/webui/dist/assets/index-Bk-lpAGm.css +2 -0
  56. package/webui/dist/assets/index-E6MPcbdC.js +80 -0
  57. package/webui/dist/build.json +494 -0
  58. package/webui/dist/icon-192.png +0 -0
  59. package/webui/dist/icon-512.png +0 -0
  60. package/webui/dist/index.html +18 -0
  61. package/webui/dist/manifest.webmanifest +36 -0
  62. package/webui/dist/offline.html +57 -0
  63. package/webui/dist/sw.js +88 -0
  64. package/webui/dist/vite.svg +1 -0
@@ -0,0 +1,315 @@
1
+ /**
2
+ * The lobby's loopback server: the committed `webui/dist` page, a JSON API
3
+ * and an event stream over the shared `LobbyService`, on 127.0.0.1 only.
4
+ * Opt-in (a later step starts it); nothing here writes to stdout or stderr —
5
+ * route errors go to `lobbyFeed`. One server per process.
6
+ */
7
+ import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
8
+ import type { AddressInfo } from "node:net";
9
+ import { loadConfig } from "../state/project.ts";
10
+ import { lobbyFeed } from "../lobby/feed.ts";
11
+ import type { LobbyService } from "../lobby/service.ts";
12
+ import {
13
+ MAX_BODY_BYTES,
14
+ LoginGuard,
15
+ isCrossSite,
16
+ isHostAllowed,
17
+ isSignedIn,
18
+ linkToken,
19
+ loadOrCreateSecret,
20
+ resetSecret,
21
+ safeEqual,
22
+ sessionCookie,
23
+ sessionValue,
24
+ } from "./auth.ts";
25
+ import { callName, HttpError, routeApiCall, sendError, sendJson } from "./api/index.ts";
26
+ import { distDir, serveStatic } from "./static.ts";
27
+ import { EventHub } from "./events.ts";
28
+ import { servePreview } from "./files.ts";
29
+
30
+ /** The default port; the server tries it, then the next free port up to +20. */
31
+ export const DEFAULT_PORT = 7347;
32
+ /** How many ports past the base are tried. */
33
+ export const PORT_RANGE = 20;
34
+
35
+ export interface WebServerOptions {
36
+ service: LobbyService;
37
+ /** Base port (config `lobby.web.port` once a later step adds it). */
38
+ port?: number;
39
+ /** The built page; defaults to `webui/dist`. */
40
+ dist?: string;
41
+ /** Skip the secret file (tests); otherwise loaded from `web.json`. */
42
+ secret?: Buffer;
43
+ /** Stream heartbeat for tests; 15 s in production. */
44
+ heartbeatMs?: number;
45
+ }
46
+
47
+ export interface WebServer {
48
+ /** The link to print: the token rides in the fragment. */
49
+ link: string;
50
+ port: number;
51
+ /** Open event streams (the page in each browser tab). */
52
+ clients(): number;
53
+ /** Follow another service (session switch); open streams hear `hello` again. */
54
+ rebind(service: LobbyService): void;
55
+ /** A new secret (every cookie until now stops working); returns the new link. */
56
+ reset(): string;
57
+ close(): Promise<void>;
58
+ }
59
+
60
+ let current: WebServer | undefined;
61
+ let exitHooked = false;
62
+
63
+ /** The base port: config `lobby.web.port` once it exists, else the default. */
64
+ function basePort(override?: number): number {
65
+ if (override !== undefined) return override;
66
+ try {
67
+ const web = (loadConfig().lobby as unknown as { web?: { port?: number } }).web;
68
+ if (typeof web?.port === "number" && web.port >= 0) return web.port;
69
+ } catch {
70
+ // An unreadable config only means the default port.
71
+ }
72
+ return DEFAULT_PORT;
73
+ }
74
+
75
+ function hookExit(): void {
76
+ if (exitHooked) return;
77
+ exitHooked = true;
78
+ process.once("exit", () => {
79
+ void current?.close();
80
+ });
81
+ }
82
+
83
+ /** Read a JSON body, capped at a megabyte (413 past it, 400 when it is no JSON). */
84
+ async function readJson(req: IncomingMessage): Promise<unknown> {
85
+ const chunks: Buffer[] = [];
86
+ let size = 0;
87
+ for await (const chunk of req) {
88
+ const buffer = chunk as Buffer;
89
+ size += buffer.length;
90
+ if (size > MAX_BODY_BYTES) throw new HttpError(413, "too_large", "the body is larger than 1,000,000 bytes");
91
+ chunks.push(buffer);
92
+ }
93
+ const text = Buffer.concat(chunks).toString("utf8");
94
+ if (!text.trim()) return {};
95
+ try {
96
+ return JSON.parse(text);
97
+ } catch {
98
+ throw new HttpError(400, "bad_request", "the body is not JSON");
99
+ }
100
+ }
101
+
102
+ function numParam(url: URL, name: string): number | undefined {
103
+ const raw = url.searchParams.get(name);
104
+ if (raw === null) return undefined;
105
+ const value = Number(raw);
106
+ return Number.isFinite(value) && value >= 0 ? value : undefined;
107
+ }
108
+
109
+ interface RouteState {
110
+ service: LobbyService;
111
+ port: number;
112
+ link: string;
113
+ session: string;
114
+ guard: LoginGuard;
115
+ hub: EventHub;
116
+ root: string;
117
+ }
118
+
119
+ async function login(req: IncomingMessage, res: ServerResponse, state: RouteState): Promise<void> {
120
+ if (state.guard.blocked()) {
121
+ sendError(res, "rate_limited", "too many wrong links; wait a minute", 429);
122
+ return;
123
+ }
124
+ const body = (await readJson(req)) as { token?: unknown };
125
+ if (typeof body.token !== "string" || !safeEqual(body.token, state.link)) {
126
+ state.guard.noteFailure();
127
+ sendError(res, "unauthorized", "this link is not this lobby's (or it was reset)", 401);
128
+ return;
129
+ }
130
+ sendJson(res, 200, { ok: true, result: {} }, { "Set-Cookie": sessionCookie(state.session) });
131
+ }
132
+
133
+ function openStream(req: IncomingMessage, res: ServerResponse, url: URL, state: RouteState): void {
134
+ state.hub.add(req, res, {
135
+ activityAfter: numParam(url, "activityAfter"),
136
+ thoughtsAfter: numParam(url, "thoughtsAfter"),
137
+ chatAfter: numParam(url, "chatAfter"),
138
+ });
139
+ req.on("close", () => state.hub.remove(res));
140
+ }
141
+
142
+ async function routeApi(req: IncomingMessage, res: ServerResponse, url: URL, state: RouteState): Promise<void> {
143
+ if (req.method !== "POST") {
144
+ sendError(res, "unsupported", "only POST", 405);
145
+ return;
146
+ }
147
+ if (!(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) {
148
+ sendError(res, "unsupported", "send JSON", 415);
149
+ return;
150
+ }
151
+ if (url.pathname === "/api/auth.login") return login(req, res, state);
152
+ if (!isSignedIn(req.headers.cookie, state.session)) {
153
+ sendError(res, "unauthorized", "open the link Pi printed", 401);
154
+ return;
155
+ }
156
+ const name = callName(url);
157
+ if (name === undefined) {
158
+ sendError(res, "not_found", `no call ${url.pathname.slice("/api/".length)}`, 404);
159
+ return;
160
+ }
161
+ const result = await routeApiCall(name, await readJson(req), { service: state.service, port: state.port });
162
+ sendJson(res, 200, { ok: true, result });
163
+ }
164
+
165
+ function previewNames(url: URL): { task: string; name: string } | undefined {
166
+ const parts = url.pathname.slice("/files/preview/".length).split("/");
167
+ if (parts.length !== 2 || !parts[0] || !parts[1]) return undefined;
168
+ return { task: parts[0]!, name: parts[1]! };
169
+ }
170
+
171
+ async function route(req: IncomingMessage, res: ServerResponse, state: RouteState): Promise<void> {
172
+ if (!isHostAllowed(req.headers.host, state.port)) {
173
+ sendError(res, "forbidden", "unknown host", 403);
174
+ return;
175
+ }
176
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
177
+ if (url.pathname.startsWith("/files/preview/")) {
178
+ if (req.method !== "GET") {
179
+ sendError(res, "unsupported", "only GET", 405);
180
+ return;
181
+ }
182
+ const names = previewNames(url);
183
+ if (!names) {
184
+ sendError(res, "not_found", "no such preview", 404);
185
+ return;
186
+ }
187
+ await servePreview(res, names.task, names.name, isSignedIn(req.headers.cookie, state.session));
188
+ return;
189
+ }
190
+ if (!url.pathname.startsWith("/api/")) {
191
+ await serveStatic(req, res, url.pathname, state.root);
192
+ return;
193
+ }
194
+ if (isCrossSite(req.headers.origin, req.headers["sec-fetch-site"] as string | undefined, state.port)) {
195
+ sendError(res, "forbidden", "cross-site request", 403);
196
+ return;
197
+ }
198
+ if (url.pathname === "/api/events") {
199
+ if (req.method !== "GET") {
200
+ sendError(res, "unsupported", "only GET", 405);
201
+ return;
202
+ }
203
+ if (!isSignedIn(req.headers.cookie, state.session)) {
204
+ sendError(res, "unauthorized", "open the link Pi printed", 401);
205
+ return;
206
+ }
207
+ openStream(req, res, url, state);
208
+ return;
209
+ }
210
+ await routeApi(req, res, url, state);
211
+ }
212
+
213
+ function tryListen(server: Server, port: number): Promise<void> {
214
+ return new Promise((resolve, reject) => {
215
+ const onError = (error: Error) => {
216
+ server.removeListener("listening", onListening);
217
+ reject(error);
218
+ };
219
+ const onListening = () => {
220
+ server.removeListener("error", onError);
221
+ resolve();
222
+ };
223
+ server.once("error", onError);
224
+ server.once("listening", onListening);
225
+ server.listen(port, "127.0.0.1");
226
+ });
227
+ }
228
+
229
+ function isBusy(error: unknown): boolean {
230
+ return (error as { code?: string }).code === "EADDRINUSE";
231
+ }
232
+
233
+ /** Start the loopback server; one per process (a second start throws). */
234
+ export async function startWebServer(options: WebServerOptions): Promise<WebServer> {
235
+ if (current) throw new Error("the web server is already running");
236
+ const loaded = options.secret ? { secret: options.secret, created: false, replaced: false } : loadOrCreateSecret();
237
+ if (loaded.replaced) lobbyFeed.log("LOBBY", "the web link was reset: web.json was unreadable", "warning");
238
+ const link = linkToken(loaded.secret);
239
+ const session = sessionValue(loaded.secret);
240
+ const hub = new EventHub(options.heartbeatMs);
241
+ hub.attach(options.service);
242
+ const state: RouteState = {
243
+ service: options.service,
244
+ port: 0,
245
+ link,
246
+ session,
247
+ guard: new LoginGuard(),
248
+ hub,
249
+ root: distDir(options.dist),
250
+ };
251
+ const server: Server = createServer((req, res) => {
252
+ route(req, res, state).catch((error: Error) => {
253
+ if (res.headersSent) {
254
+ res.destroy();
255
+ return;
256
+ }
257
+ if (error instanceof HttpError) sendError(res, error.code, error.message, error.status);
258
+ else {
259
+ lobbyFeed.log("LOBBY", `the web server could not do that — ${error.message}`, "error");
260
+ sendError(res, "failed", "the lobby could not do that", 500);
261
+ }
262
+ });
263
+ });
264
+ const base = basePort(options.port);
265
+ let bound = 0;
266
+ for (let port = base; port <= base + PORT_RANGE; port += 1) {
267
+ try {
268
+ await tryListen(server, port);
269
+ bound = (server.address() as AddressInfo)?.port || port;
270
+ break;
271
+ } catch (error) {
272
+ if (!isBusy(error)) throw error;
273
+ }
274
+ }
275
+ if (!bound) {
276
+ hub.close();
277
+ throw new Error(`no free port from ${base} to ${base + PORT_RANGE}`);
278
+ }
279
+ state.port = bound;
280
+ hookExit();
281
+ const api: WebServer = {
282
+ link: `http://127.0.0.1:${bound}/#token=${link}`,
283
+ port: bound,
284
+ clients: () => hub.clients(),
285
+ rebind: (service) => {
286
+ state.service = service;
287
+ hub.attach(service);
288
+ hub.hello();
289
+ },
290
+ reset: () => {
291
+ const secret = resetSecret();
292
+ state.link = linkToken(secret);
293
+ state.session = sessionValue(secret);
294
+ state.guard = new LoginGuard();
295
+ return `http://127.0.0.1:${state.port}/#token=${state.link}`;
296
+ },
297
+ close: async () => {
298
+ hub.close();
299
+ await new Promise<void>((resolve) => {
300
+ server.close(() => resolve());
301
+ server.closeAllConnections();
302
+ });
303
+ if (current === api) current = undefined;
304
+ },
305
+ };
306
+ current = api;
307
+ return api;
308
+ }
309
+
310
+ /** The running server, if any. */
311
+ export function currentWebServer(): WebServer | undefined {
312
+ return current;
313
+ }
314
+
315
+
@@ -0,0 +1,125 @@
1
+ /**
2
+ * The built page (`webui/dist`), served from loopback with a per-response
3
+ * Content-Security-Policy nonce. `index.html` carries `__CSP_NONCE__` (its
4
+ * meta and the nonce attributes Vite emits); every response replaces each
5
+ * occurrence with a fresh nonce and sends the CSP carrying that same nonce.
6
+ */
7
+ import type { IncomingMessage, ServerResponse } from "node:http";
8
+ import { readFile } from "node:fs/promises";
9
+ import { extname, join, normalize, sep } from "node:path";
10
+ import { randomBytes } from "node:crypto";
11
+ import { applyBaseHeaders, HttpError } from "./api/index.ts";
12
+
13
+ /** The placeholder the built `index.html` carries where the nonce goes. */
14
+ export const NONCE_PLACEHOLDER = "__CSP_NONCE__";
15
+
16
+ const TYPES: Record<string, string> = {
17
+ ".html": "text/html; charset=utf-8",
18
+ ".js": "text/javascript; charset=utf-8",
19
+ ".css": "text/css; charset=utf-8",
20
+ ".json": "application/json; charset=utf-8",
21
+ ".svg": "image/svg+xml",
22
+ ".png": "image/png",
23
+ ".jpg": "image/jpeg",
24
+ ".jpeg": "image/jpeg",
25
+ ".gif": "image/gif",
26
+ ".webp": "image/webp",
27
+ ".ico": "image/x-icon",
28
+ ".woff": "font/woff",
29
+ ".woff2": "font/woff2",
30
+ ".ttf": "font/ttf",
31
+ ".webmanifest": "application/manifest+json",
32
+ ".map": "application/json; charset=utf-8",
33
+ };
34
+
35
+ /** Where the built page lives; tests point `BOT_LOBBY_WEBUI_DIST` at a fixture. */
36
+ export function distDir(override?: string): string {
37
+ return override ?? process.env.BOT_LOBBY_WEBUI_DIST ?? join(process.cwd(), "webui", "dist");
38
+ }
39
+
40
+ /** A fresh base64 nonce (16 random bytes) for one `index.html` response. */
41
+ export function freshNonce(): string {
42
+ return randomBytes(16).toString("base64");
43
+ }
44
+
45
+ /** The page loads nothing from anywhere else, so model Markdown cannot pull in a tracker or a script. */
46
+ export function cspHeader(nonce: string): string {
47
+ return [
48
+ "default-src 'self'",
49
+ `script-src 'self' 'nonce-${nonce}'`,
50
+ "style-src 'self' 'unsafe-inline'",
51
+ "img-src 'self' data: blob:",
52
+ "connect-src 'self'",
53
+ "font-src 'self'",
54
+ "base-uri 'none'",
55
+ "form-action 'self'",
56
+ "frame-ancestors 'none'",
57
+ ].join("; ");
58
+ }
59
+
60
+ /** Resolve a URL path to a file under `root`, or undefined when refused. */
61
+ export function resolveStaticPath(urlPath: string, root: string): string | undefined {
62
+ let decoded: string;
63
+ try {
64
+ decoded = decodeURIComponent(urlPath);
65
+ } catch {
66
+ return undefined;
67
+ }
68
+ const relative = normalize(decoded === "/" ? "index.html" : decoded.replace(/^\/+/, ""));
69
+ if (relative === "" || relative.startsWith("..") || relative.includes(`..${sep}`)) return undefined;
70
+ const file = join(root, relative);
71
+ if (!file.startsWith(root + sep) && file !== join(root, "index.html")) return undefined;
72
+ if (TYPES[extname(file).toLowerCase()] === undefined) return undefined;
73
+ return file;
74
+ }
75
+
76
+ /** Refusals from static serving read as 404s (nothing outside the folder exists). */
77
+ function missing(): HttpError {
78
+ return new HttpError(404, "not_found", "no such file");
79
+ }
80
+
81
+ function sendHtml(res: ServerResponse, html: string, nonce: string): void {
82
+ applyBaseHeaders(res);
83
+ res.writeHead(200, {
84
+ "Content-Type": TYPES[".html"]!,
85
+ "Content-Security-Policy": cspHeader(nonce),
86
+ "Cache-Control": "no-store",
87
+ });
88
+ res.end(html);
89
+ }
90
+
91
+ function sendAsset(res: ServerResponse, body: Buffer, ext: string): void {
92
+ applyBaseHeaders(res);
93
+ res.writeHead(200, { "Content-Type": TYPES[ext]!, "Cache-Control": "public, max-age=31536000, immutable" });
94
+ res.end(body);
95
+ }
96
+
97
+ /**
98
+ * Serve one static path. Only GET and HEAD; `index.html` gets a fresh nonce
99
+ * per response, hashed assets are immutable for a year. Throws `HttpError`
100
+ * (404) for traversals, unknown extensions and missing files.
101
+ */
102
+ export async function serveStatic(req: IncomingMessage, res: ServerResponse, urlPath: string, root: string): Promise<void> {
103
+ if (req.method !== "GET" && req.method !== "HEAD") throw new HttpError(405, "unsupported", "only GET");
104
+ const file = resolveStaticPath(urlPath, root);
105
+ if (!file) throw missing();
106
+ let body: Buffer;
107
+ try {
108
+ body = await readFile(file);
109
+ } catch {
110
+ throw missing();
111
+ }
112
+ if (req.method === "HEAD") {
113
+ applyBaseHeaders(res);
114
+ res.writeHead(200, { "Content-Type": TYPES[extname(file).toLowerCase()]! });
115
+ res.end();
116
+ return;
117
+ }
118
+ const ext = extname(file).toLowerCase();
119
+ if (ext === ".html") {
120
+ const nonce = freshNonce();
121
+ sendHtml(res, body.toString("utf8").split(NONCE_PLACEHOLDER).join(nonce), nonce);
122
+ return;
123
+ }
124
+ sendAsset(res, body, ext);
125
+ }
@@ -20,6 +20,7 @@ import { ownerlessTask, readTaskArtifact, removeTaskScratchpads, saveTask, selec
20
20
  import { dataRoot, readDataRoots } from "../state/project.ts";
21
21
  import { appendCompletedTask, appendDecision, applyKnowledge, readFileOr, writeFileEnsured, type KnowledgeKind } from "../knowledge/store.ts";
22
22
  import { compactKnowledgeFile, overThreshold } from "../knowledge/compactor.ts";
23
+ import { ExcalidrawBook } from "../excalidraw/sessions.ts";
23
24
  import { knowledgeDir, type KnowledgeAgent } from "../knowledge/paths.ts";
24
25
  import { writeScratchpad } from "../state/persistence.ts";
25
26
  import { spawnPiProcess, type ProcessRunner, type RelayAsk } from "../execution/pi-runner.ts";
@@ -75,6 +76,7 @@ export const ORCHESTRATE_ACTIONS = [
75
76
  "qa",
76
77
  "knowledge",
77
78
  "compact",
79
+ "whiteboard",
78
80
  "complete",
79
81
  "block",
80
82
  "resume",
@@ -112,6 +114,8 @@ export interface OrchestrateParams {
112
114
  kind?: KnowledgeKind;
113
115
  /** compact: the knowledge file being rewritten. */
114
116
  file?: string;
117
+ /** whiteboard: name for the new Excalidraw session. */
118
+ name?: string;
115
119
  approvalId?: string;
116
120
  decision?: "approved" | "rejected";
117
121
  note?: string;
@@ -1270,6 +1274,19 @@ function handleCompact(task: Task, params: OrchestrateParams, deps: WorkflowDeps
1270
1274
  return `Compacted ${agent}/${file}: ${outcome.before} -> ${outcome.after} chars. Previous version archived at ${outcome.archive}.`;
1271
1275
  }
1272
1276
 
1277
+ /**
1278
+ * The Master creates its own whiteboard: a room on the configured server,
1279
+ * assigned to itself so its seat keeps the room alive for this pi session.
1280
+ */
1281
+ function handleWhiteboard(task: Task, params: OrchestrateParams, deps: WorkflowDeps): string {
1282
+ const book = new ExcalidrawBook({ root: deps.root });
1283
+ const made = book.create(params.name?.trim() || undefined);
1284
+ if (!made.session) throw new Error(made.notice);
1285
+ book.toggleAgent(made.session.id, "master");
1286
+ recordDecision(task, `Created Excalidraw session "${made.session.name}" and assigned it to Master.`);
1287
+ return `Created Excalidraw session "${made.session.name}" and assigned it to you. Join link: ${made.session.link}. Call excalidraw_read to open your seat; the connection stays open for this pi session and keeps the room alive without anyone else in it. It ends when the pi session ends.`;
1288
+ }
1289
+
1273
1290
  /** §63: record history, drop scratchpads, then mark the task completed. */
1274
1291
  async function handleComplete(task: Task, params: OrchestrateParams, deps: WorkflowDeps): Promise<string> {
1275
1292
  // The fast track completes straight from its work; the full workflow from review.
@@ -1279,6 +1296,9 @@ async function handleComplete(task: Task, params: OrchestrateParams, deps: Workf
1279
1296
  if (task.state === "blocked") throw new Error("cannot complete: the task is blocked, and the user did not accept its work as it is");
1280
1297
  const blockers = completionBlockers(task, pendingApprovals(task).length);
1281
1298
  if (blockers.length > 0) throw new Error(`cannot complete: ${blockers.join("; ")}`);
1299
+ // The gate refuses before any side effect, so a refusal leaves the task untouched.
1300
+ const oversized = overThreshold(readDataRoots(deps.root, deps.configDir), deps.config.knowledge.compactionThreshold);
1301
+ if (oversized.length > 0) throw new Error(`cannot complete: knowledge files over the compaction threshold: ${oversized.map((entry) => `${entry.agent}/${entry.file} (${entry.chars})`).join(", ")}. First record domain-relevant facts where they belong with action=knowledge (domain=designer|backend|qa), then rewrite each oversized file with action=compact, then call complete again.`);
1282
1302
  const summary = params.text?.trim() || task.proposal || task.title;
1283
1303
  flushDecisions(deps, task);
1284
1304
  recordCompletion(deps, task, summary);
@@ -1286,12 +1306,7 @@ async function handleComplete(task: Task, params: OrchestrateParams, deps: Workf
1286
1306
  task.blockers = [];
1287
1307
  if (task.state === "implementing") transition(task, "reviewing");
1288
1308
  transition(task, "completed");
1289
- const oversized = overThreshold(readDataRoots(deps.root, deps.configDir), deps.config.knowledge.compactionThreshold);
1290
- const advice =
1291
- oversized.length > 0
1292
- ? `\nKnowledge files over the compaction threshold: ${oversized.map((entry) => `${entry.agent}/${entry.file} (${entry.chars})`).join(", ")}. Compact them with action=compact when convenient.`
1293
- : "";
1294
- return `Task ${task.id} completed. History recorded and temporary scratchpads removed.${advice}`;
1309
+ return `Task ${task.id} completed. History recorded and temporary scratchpads removed.`;
1295
1310
  }
1296
1311
 
1297
1312
  /** Asked when the oracle completes a task QA has not passed (the user told it to finish, say). */
@@ -1653,6 +1668,7 @@ const HANDLERS: Record<OrchestrateAction, (task: Task, params: OrchestrateParams
1653
1668
  qa: handleQa,
1654
1669
  knowledge: handleKnowledge,
1655
1670
  compact: handleCompact,
1671
+ whiteboard: handleWhiteboard,
1656
1672
  complete: handleComplete,
1657
1673
  block: handleBlock,
1658
1674
  resume: handleResume,