@a-t-h-i/bot-lobby 0.6.24 → 0.6.25

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 (48) hide show
  1. package/README.md +8 -2
  2. package/package.json +1 -1
  3. package/prompts/designer.md +14 -0
  4. package/prompts/master.md +18 -0
  5. package/prompts/panel.md +21 -1
  6. package/prompts/planner.md +17 -1
  7. package/prompts/qa.md +23 -3
  8. package/prompts/quickfix.md +4 -0
  9. package/prompts/reviewer.md +17 -0
  10. package/prompts/worker.md +21 -6
  11. package/src/ask/tool.ts +3 -3
  12. package/src/delivery/review.ts +1 -1
  13. package/src/execution/git.ts +77 -0
  14. package/src/execution/lint.ts +435 -0
  15. package/src/lobby/ask.ts +4 -2
  16. package/src/lobby/feed.ts +39 -4
  17. package/src/lobby/host.ts +2 -0
  18. package/src/lobby/planner.ts +54 -5
  19. package/src/lobby/pr-review.ts +2 -1
  20. package/src/lobby/quickfix.ts +34 -1
  21. package/src/lobby/runtime.ts +19 -2
  22. package/src/lobby/service.ts +2 -0
  23. package/src/master/master.ts +3 -0
  24. package/src/pi/commands.ts +25 -10
  25. package/src/pi/events.ts +3 -1
  26. package/src/pi/switch.ts +69 -0
  27. package/src/pi/tools.ts +7 -0
  28. package/src/pi/ui.ts +2 -14
  29. package/src/schemas/configuration.ts +51 -1
  30. package/src/schemas/lint.ts +55 -0
  31. package/src/schemas/task.ts +5 -0
  32. package/src/state/changes.ts +8 -2
  33. package/src/webui/api/index.ts +2 -1
  34. package/src/webui/api/knowledge.ts +11 -3
  35. package/src/webui/api/settings.ts +6 -1
  36. package/src/webui/command.ts +14 -3
  37. package/src/webui/dev/fake-service.ts +1 -0
  38. package/src/webui/dev/fixtures.ts +2 -1
  39. package/src/webui/protocol.ts +10 -1
  40. package/src/workflow/lint.ts +221 -0
  41. package/src/workflow/track.ts +12 -4
  42. package/src/workflow/workflow.ts +70 -13
  43. package/webui/dist/assets/index-BRVbs5In.js +105 -0
  44. package/webui/dist/assets/index-BeRXVi_r.css +2 -0
  45. package/webui/dist/build.json +40 -32
  46. package/webui/dist/index.html +2 -2
  47. package/webui/dist/assets/index-BSewK1QO.css +0 -2
  48. package/webui/dist/assets/index-CHQYSZXA.js +0 -105
@@ -0,0 +1,435 @@
1
+ /**
2
+ * The lint gate's runner: the project's own linter, run by the engine (never
3
+ * by an agent) on the files a task's agents touched, and only those. Each
4
+ * touched file goes to every linter whose config sits nearest above it
5
+ * (ESLint, Biome, Oxlint, Ruff), run from that config's folder with the
6
+ * binary the project installed, so the packages of a monorepo each lint by
7
+ * their own rules; or, when Settings name a command, every touched file goes
8
+ * to that. A linter that is configured but not installed, times out or cannot
9
+ * start reads as "could not run", never as findings: lint never holds a task
10
+ * on a failure of its own. No shell is involved, so a file name is only ever
11
+ * an argument.
12
+ */
13
+ import { execFile } from "node:child_process";
14
+ import { createHash } from "node:crypto";
15
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
16
+ import { dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
17
+ import type { LintConfig } from "../schemas/configuration.ts";
18
+ import type { LintFinding, LintRun } from "../schemas/lint.ts";
19
+ import { tail } from "../text.ts";
20
+
21
+ export type { LintFinding, LintReport, LintRun, LintState } from "../schemas/lint.ts";
22
+
23
+ /** What one linter call gave back: an exit code (null when it never ran to the end), its output, why it failed to run. */
24
+ export interface LintExecResult {
25
+ code: number | null;
26
+ stdout: string;
27
+ stderr: string;
28
+ /** missing (not found), timeout, cancelled, or the spawn error. */
29
+ failure?: string;
30
+ }
31
+
32
+ export type LintExec = (command: string, args: readonly string[], options: { cwd: string; timeoutMs: number; signal?: AbortSignal }) => Promise<LintExecResult>;
33
+
34
+ const MAX_BUFFER = 16 * 1024 * 1024;
35
+ /** Findings kept per run; the counts stay whole. */
36
+ export const MAX_FINDINGS = 60;
37
+ const OUTPUT_TAIL = 3000;
38
+
39
+ export const execLint: LintExec = (command, args, options) => new Promise((done) => {
40
+ execFile(command, [...args], { cwd: options.cwd, timeout: options.timeoutMs, maxBuffer: MAX_BUFFER, windowsHide: true, ...(options.signal ? { signal: options.signal } : {}) }, (error, stdout, stderr) => {
41
+ const out = String(stdout ?? "");
42
+ const err = String(stderr ?? "");
43
+ if (!error) return done({ code: 0, stdout: out, stderr: err });
44
+ const failure = error as NodeJS.ErrnoException & { killed?: boolean; signal?: string; code?: unknown };
45
+ if (failure.name === "AbortError") return done({ code: null, stdout: out, stderr: err, failure: "cancelled" });
46
+ if (failure.code === "ENOENT") return done({ code: null, stdout: out, stderr: err, failure: "missing" });
47
+ if (failure.killed || failure.signal) return done({ code: null, stdout: out, stderr: err, failure: "timeout" });
48
+ if (typeof failure.code === "number") return done({ code: failure.code, stdout: out, stderr: err });
49
+ done({ code: null, stdout: out, stderr: err, failure: failure.message });
50
+ });
51
+ });
52
+
53
+ /** What a linter's output says, or undefined when it does not read as a lint result (a crash, a config error). */
54
+ interface Parsed {
55
+ findings: LintFinding[];
56
+ }
57
+
58
+ interface Linter {
59
+ name: string;
60
+ /** Files whose presence in a folder makes it this linter's: the nearest such folder above a file is where it runs. */
61
+ configs: readonly string[];
62
+ /** A folder whose package.json or pyproject.toml configures it counts too. */
63
+ configured?: (folder: string) => boolean;
64
+ extensions: readonly string[];
65
+ /** The installed binary as `[command, ...leading args]`, looked for from `folder` up to the repository's top. */
66
+ binary(folder: string, top: string): string[] | undefined;
67
+ args(files: readonly string[]): string[];
68
+ read(result: LintExecResult, folder: string, top: string): Parsed | undefined;
69
+ }
70
+
71
+ const JS = [".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts", ".vue", ".svelte", ".astro"];
72
+
73
+ function readText(path: string): string | undefined {
74
+ try {
75
+ return readFileSync(path, "utf8");
76
+ } catch {
77
+ return undefined;
78
+ }
79
+ }
80
+
81
+ function json(text: string): unknown {
82
+ try {
83
+ return JSON.parse(text);
84
+ } catch {
85
+ return undefined;
86
+ }
87
+ }
88
+
89
+ /** Folders from `start` up to `top`, nearest first. */
90
+ function upward(start: string, top: string): string[] {
91
+ const folders: string[] = [];
92
+ let folder = start;
93
+ for (;;) {
94
+ folders.push(folder);
95
+ if (folder === top) break;
96
+ const parent = dirname(folder);
97
+ const rel = relative(top, parent);
98
+ if (parent === folder || rel.startsWith("..") || isAbsolute(rel)) break;
99
+ folder = parent;
100
+ }
101
+ return folders;
102
+ }
103
+
104
+ /** A package's bin script, run with this Node, found in `node_modules` from `folder` up to `top`. */
105
+ function nodeBin(pkg: string, bin: string): (folder: string, top: string) => string[] | undefined {
106
+ return (folder, top) => {
107
+ for (const at of upward(folder, top)) {
108
+ const dir = join(at, "node_modules", pkg);
109
+ const manifest = json(readText(join(dir, "package.json")) ?? "") as { bin?: string | Record<string, string> } | undefined;
110
+ if (!manifest) continue;
111
+ const entry = typeof manifest.bin === "string" ? manifest.bin : manifest.bin?.[bin];
112
+ if (entry && existsSync(join(dir, entry))) return [process.execPath, join(dir, entry)];
113
+ }
114
+ return undefined;
115
+ };
116
+ }
117
+
118
+ /** Ruff from a virtualenv in the project, else from PATH (where a missing one reads as not installed). */
119
+ function ruffBin(folder: string, top: string): string[] {
120
+ const names = process.platform === "win32" ? [join(".venv", "Scripts", "ruff.exe"), join("venv", "Scripts", "ruff.exe")] : [join(".venv", "bin", "ruff"), join("venv", "bin", "ruff")];
121
+ for (const at of upward(folder, top)) for (const name of names) if (existsSync(join(at, name))) return [join(at, name)];
122
+ return ["ruff"];
123
+ }
124
+
125
+ /** A path a linter printed (absolute, or relative to where it ran) as the repository names it. */
126
+ const repoPath = (top: string, file: string) => relative(top, resolve(top, file)).split("\\").join("/");
127
+
128
+ function eslintRead(result: LintExecResult, _folder: string, top: string): Parsed | undefined {
129
+ // 2 is ESLint's own failure (a config error, a crash), not findings.
130
+ if (result.code !== 0 && result.code !== 1) return undefined;
131
+ const reports = json(result.stdout);
132
+ if (!Array.isArray(reports)) return undefined;
133
+ const findings: LintFinding[] = [];
134
+ for (const report of reports as Array<{ filePath?: string; messages?: Array<{ ruleId?: string | null; severity?: number; message?: string; line?: number; column?: number }> }>) {
135
+ for (const message of report.messages ?? []) {
136
+ // A file the config does not cover is skipped, not a finding.
137
+ if (!message.ruleId && /^File ignored\b/.test(message.message ?? "")) continue;
138
+ findings.push({
139
+ file: repoPath(top, report.filePath ?? ""),
140
+ ...(message.line ? { line: message.line } : {}),
141
+ ...(message.column ? { column: message.column } : {}),
142
+ ...(message.ruleId ? { rule: message.ruleId } : {}),
143
+ message: message.message ?? "",
144
+ severity: message.severity === 2 ? "error" : "warning",
145
+ });
146
+ }
147
+ }
148
+ return { findings };
149
+ }
150
+
151
+ function oxlintRead(result: LintExecResult, folder: string, top: string): Parsed | undefined {
152
+ if (result.code !== 0 && result.code !== 1) return undefined;
153
+ const raw = json(result.stdout) as { diagnostics?: unknown[] } | unknown[] | undefined;
154
+ const list = Array.isArray(raw) ? raw : Array.isArray(raw?.diagnostics) ? raw.diagnostics : undefined;
155
+ if (!list) return undefined;
156
+ return {
157
+ findings: (list as Array<{ message?: string; code?: string; severity?: string; filename?: string; labels?: Array<{ span?: { line?: number; column?: number } }> }>).map((entry) => {
158
+ const span = entry.labels?.[0]?.span;
159
+ return {
160
+ file: repoPath(top, resolve(folder, entry.filename ?? "")),
161
+ ...(span?.line ? { line: span.line } : {}),
162
+ ...(span?.column ? { column: span.column } : {}),
163
+ ...(entry.code ? { rule: entry.code } : {}),
164
+ message: entry.message ?? "",
165
+ severity: entry.severity === "error" ? "error" : "warning",
166
+ } satisfies LintFinding;
167
+ }),
168
+ };
169
+ }
170
+
171
+ /** Biome's GitHub reporter: `::error title=lint/x/y,file=a.ts,line=1,endLine=1,col=1,endColumn=9::message`. */
172
+ function biomeRead(result: LintExecResult, folder: string, top: string): Parsed | undefined {
173
+ if (result.code !== 0 && result.code !== 1) return undefined;
174
+ const findings: LintFinding[] = [];
175
+ for (const line of `${result.stdout}\n${result.stderr}`.split("\n")) {
176
+ const match = /^::(error|warning|notice)\s+([^:]*)::(.*)$/.exec(line.trim());
177
+ if (!match) continue;
178
+ const fields = Object.fromEntries(match[2]!.split(",").map((pair) => { const at = pair.indexOf("="); return [pair.slice(0, at), pair.slice(at + 1)]; }));
179
+ if (!fields.file) continue;
180
+ findings.push({
181
+ file: repoPath(top, resolve(folder, fields.file)),
182
+ ...(Number(fields.line) ? { line: Number(fields.line) } : {}),
183
+ ...(Number(fields.col) ? { column: Number(fields.col) } : {}),
184
+ ...(fields.title ? { rule: fields.title } : {}),
185
+ message: match[3]!.trim(),
186
+ severity: match[1] === "error" ? "error" : "warning",
187
+ });
188
+ }
189
+ // Exit 1 with nothing read is Biome failing on its own (a broken config), not a clean run.
190
+ if (result.code === 1 && findings.length === 0) return undefined;
191
+ return { findings };
192
+ }
193
+
194
+ function ruffRead(result: LintExecResult, folder: string, top: string): Parsed | undefined {
195
+ if (result.code !== 0 && result.code !== 1) return undefined;
196
+ const list = json(result.stdout);
197
+ if (!Array.isArray(list)) return undefined;
198
+ return {
199
+ findings: (list as Array<{ code?: string | null; message?: string; filename?: string; location?: { row?: number; column?: number } }>).map((entry) => ({
200
+ file: repoPath(top, resolve(folder, entry.filename ?? "")),
201
+ ...(entry.location?.row ? { line: entry.location.row } : {}),
202
+ ...(entry.location?.column ? { column: entry.location.column } : {}),
203
+ ...(entry.code ? { rule: entry.code } : {}),
204
+ message: entry.message ?? "",
205
+ severity: "error" as const,
206
+ })),
207
+ };
208
+ }
209
+
210
+ function packageHas(folder: string, key: string): boolean {
211
+ const manifest = json(readText(join(folder, "package.json")) ?? "") as Record<string, unknown> | undefined;
212
+ return Boolean(manifest && typeof manifest === "object" && key in manifest);
213
+ }
214
+
215
+ export const LINTERS: readonly Linter[] = [
216
+ {
217
+ name: "ESLint",
218
+ configs: ["eslint.config.js", "eslint.config.mjs", "eslint.config.cjs", "eslint.config.ts", "eslint.config.mts", "eslint.config.cts", ".eslintrc", ".eslintrc.js", ".eslintrc.cjs", ".eslintrc.json", ".eslintrc.yml", ".eslintrc.yaml"],
219
+ configured: (folder) => packageHas(folder, "eslintConfig"),
220
+ extensions: JS,
221
+ binary: nodeBin("eslint", "eslint"),
222
+ args: (files) => ["--format", "json", ...files],
223
+ read: eslintRead,
224
+ },
225
+ {
226
+ name: "Biome",
227
+ configs: ["biome.json", "biome.jsonc"],
228
+ extensions: [".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts", ".json", ".jsonc", ".css", ".graphql"],
229
+ binary: nodeBin("@biomejs/biome", "biome"),
230
+ args: (files) => ["lint", "--reporter=github", "--colors=off", "--no-errors-on-unmatched", ...files],
231
+ read: biomeRead,
232
+ },
233
+ {
234
+ name: "Oxlint",
235
+ configs: [".oxlintrc.json", "oxlint.config.ts"],
236
+ extensions: JS,
237
+ binary: nodeBin("oxlint", "oxlint"),
238
+ args: (files) => ["--format", "json", ...files],
239
+ read: oxlintRead,
240
+ },
241
+ {
242
+ name: "Ruff",
243
+ configs: ["ruff.toml", ".ruff.toml"],
244
+ configured: (folder) => /^\[tool\.ruff[\].]/m.test(readText(join(folder, "pyproject.toml")) ?? ""),
245
+ extensions: [".py", ".pyi"],
246
+ binary: ruffBin,
247
+ args: (files) => ["check", "--output-format=json", "--no-fix", "--force-exclude", ...files],
248
+ read: ruffRead,
249
+ },
250
+ ];
251
+
252
+ function configuredIn(linter: Linter, folder: string): boolean {
253
+ return linter.configs.some((name) => existsSync(join(folder, name))) || Boolean(linter.configured?.(folder));
254
+ }
255
+
256
+ /** A file as an argument: relative to where the linter runs, never mistakable for an option. */
257
+ function argument(folder: string, top: string, file: string): string {
258
+ const rel = relative(folder, join(top, file)).split("\\").join("/");
259
+ return rel.startsWith("-") ? `./${rel}` : rel;
260
+ }
261
+
262
+ /** Split a command line into words, with quotes ('…', "…") holding spaces; no shell ever reads it. */
263
+ export function commandWords(command: string): string[] {
264
+ const words: string[] = [];
265
+ let word = "";
266
+ let quote: string | undefined;
267
+ let started = false;
268
+ for (const char of command) {
269
+ if (quote) {
270
+ if (char === quote) quote = undefined;
271
+ else word += char;
272
+ } else if (char === "'" || char === "\"") {
273
+ quote = char;
274
+ started = true;
275
+ } else if (/\s/.test(char)) {
276
+ if (started || word) words.push(word);
277
+ word = "";
278
+ started = false;
279
+ } else word += char;
280
+ }
281
+ if (started || word) words.push(word);
282
+ return words;
283
+ }
284
+
285
+ /** `src/a.ts:12:5: message`, `src/a.ts:12: message` or `src/a.ts(12,5): message` lines that name a touched file. */
286
+ function genericFindings(text: string, files: readonly string[]): LintFinding[] {
287
+ const findings: LintFinding[] = [];
288
+ for (const line of text.split("\n")) {
289
+ const match = /^\s*(?:\.\/)?([^\s:(][^:(]*?)(?::(\d+)(?::(\d+))?|\((\d+),(\d+)\))[:\s-]+(.+)$/.exec(line);
290
+ if (!match) continue;
291
+ const file = files.find((entry) => entry === match[1] || entry.endsWith(`/${match[1]}`));
292
+ if (!file) continue;
293
+ const lineNo = Number(match[2] ?? match[4]);
294
+ const column = Number(match[3] ?? match[5]);
295
+ findings.push({ file, ...(lineNo ? { line: lineNo } : {}), ...(column ? { column } : {}), message: match[6]!.trim(), severity: /\bwarn(ing)?\b/i.test(match[6]!) && !/\berror\b/i.test(match[6]!) ? "warning" : "error" });
296
+ }
297
+ return findings;
298
+ }
299
+
300
+ function unavailable(tool: string, folder: string, files: string[], output: string, ms = 0): LintRun {
301
+ return { tool, folder, files, state: "unavailable", errors: 0, warnings: 0, findings: [], output, ms };
302
+ }
303
+
304
+ function why(result: LintExecResult, tool: string, timeoutMs: number): string {
305
+ if (result.failure === "missing") return `${tool} is not installed here.`;
306
+ if (result.failure === "timeout") return `${tool} took longer than its ${Math.round(timeoutMs / 1000)}s limit.`;
307
+ if (result.failure === "cancelled") return `${tool} was stopped.`;
308
+ const said = tail(`${result.stderr}\n${result.stdout}`.trim(), OUTPUT_TAIL);
309
+ return `${tool} failed to run${result.code !== null ? ` (exit ${result.code})` : ""}${result.failure ? `: ${result.failure}` : ""}${said ? `\n${said}` : ""}`;
310
+ }
311
+
312
+ function counted(tool: string, folder: string, files: string[], findings: LintFinding[], ms: number, output?: string): LintRun {
313
+ const errors = findings.filter((finding) => finding.severity === "error").length;
314
+ const warnings = findings.length - errors;
315
+ return { tool, folder, files, state: errors > 0 ? "failing" : "passing", errors, warnings, findings, ...(output ? { output } : {}), ms };
316
+ }
317
+
318
+ export interface LintPlan {
319
+ tool: string;
320
+ /** Absolute. */
321
+ folder: string;
322
+ files: string[];
323
+ linter?: Linter;
324
+ }
325
+
326
+ /**
327
+ * Which linter gets which touched file: each file goes to every built-in
328
+ * linter that handles its extension and is configured in a folder above it
329
+ * (the nearest such folder is where that linter runs). With a command in
330
+ * Settings, the files it is given by extension (all of them by default) go to
331
+ * that alone, from the repository's top.
332
+ */
333
+ export function planLint(top: string, files: readonly string[], config: Pick<LintConfig, "command" | "extensions">): LintPlan[] {
334
+ if (config.command.trim()) {
335
+ const wanted = config.extensions.length ? files.filter((file) => config.extensions.includes(extname(file).toLowerCase())) : [...files];
336
+ return wanted.length ? [{ tool: commandWords(config.command)[0] ?? "lint", folder: top, files: wanted }] : [];
337
+ }
338
+ const groups = new Map<string, LintPlan>();
339
+ for (const file of files) {
340
+ const ext = extname(file).toLowerCase();
341
+ for (const linter of LINTERS) {
342
+ if (!linter.extensions.includes(ext)) continue;
343
+ const folder = upward(dirname(join(top, file)), top).find((at) => configuredIn(linter, at));
344
+ if (!folder) continue;
345
+ const key = `${linter.name}\0${folder}`;
346
+ const group = groups.get(key) ?? { tool: linter.name, folder, files: [], linter };
347
+ group.files.push(file);
348
+ groups.set(key, group);
349
+ }
350
+ }
351
+ return [...groups.values()];
352
+ }
353
+
354
+ /** Run one planned linter over its files. */
355
+ export async function runPlan(plan: LintPlan, top: string, config: Pick<LintConfig, "command" | "timeoutMs">, exec: LintExec = execLint, signal?: AbortSignal): Promise<LintRun> {
356
+ const started = Date.now();
357
+ const folder = repoPath(top, plan.folder);
358
+ const files = plan.files;
359
+ if (!plan.linter) {
360
+ const words = commandWords(config.command);
361
+ const holder = words.indexOf("{files}");
362
+ const given = files.map((file) => argument(top, top, file));
363
+ const args = holder >= 0 ? [...words.slice(1, holder), ...given, ...words.slice(holder + 1)] : [...words.slice(1), ...given];
364
+ const result = await exec(words[0]!, args, { cwd: top, timeoutMs: config.timeoutMs, ...(signal ? { signal } : {}) });
365
+ const ms = Date.now() - started;
366
+ if (result.code === null) return unavailable(plan.tool, folder, files, why(result, plan.tool, config.timeoutMs), ms);
367
+ if (result.code === 0) return counted(plan.tool, folder, files, [], ms);
368
+ const said = `${result.stdout}\n${result.stderr}`;
369
+ const findings = genericFindings(said, files);
370
+ // A failing command whose lines name no touched file still failed: one finding stands for it.
371
+ const read = findings.length ? findings : [{ file: files[0]!, message: `${plan.tool} exited ${result.code}`, severity: "error" as const }];
372
+ return counted(plan.tool, folder, files, read, ms, tail(said.trim(), OUTPUT_TAIL));
373
+ }
374
+ const binary = plan.linter.binary(plan.folder, top);
375
+ if (!binary) return unavailable(plan.tool, folder, files, `${plan.tool} is configured${folder ? ` in ${folder}/` : ""} but not installed: install the project's dependencies there.`);
376
+ const result = await exec(binary[0]!, [...binary.slice(1), ...plan.linter.args(files.map((file) => argument(plan.folder, top, file)))], { cwd: plan.folder, timeoutMs: config.timeoutMs, ...(signal ? { signal } : {}) });
377
+ const ms = Date.now() - started;
378
+ if (result.code === null) return unavailable(plan.tool, folder, files, why(result, plan.tool, config.timeoutMs), ms);
379
+ const parsed = plan.linter.read(result, plan.folder, top);
380
+ if (!parsed) return unavailable(plan.tool, folder, files, why(result, plan.tool, config.timeoutMs), ms);
381
+ return counted(plan.tool, folder, files, parsed.findings, ms);
382
+ }
383
+
384
+ /** A key for the touched files as they stand and the settings that lint them: equal keys, equal results. */
385
+ export function lintFingerprint(top: string, files: readonly string[], config: Pick<LintConfig, "command" | "extensions">): string {
386
+ const hash = createHash("sha256");
387
+ hash.update(JSON.stringify([config.command, config.extensions]));
388
+ for (const file of [...files].sort()) {
389
+ let stamp = "gone";
390
+ try {
391
+ const stat = statSync(join(top, file));
392
+ stamp = `${stat.size}:${stat.mtimeMs}`;
393
+ } catch {
394
+ // A deleted file keys as gone.
395
+ }
396
+ hash.update(`\0${file}\0${stamp}`);
397
+ }
398
+ return hash.digest("hex").slice(0, 24);
399
+ }
400
+
401
+ /** A linter the project configures, where, and whether its binary is there to run. */
402
+ export interface FoundLinter {
403
+ tool: string;
404
+ /** Repository-relative ("" at the top). */
405
+ folder: string;
406
+ installed: boolean;
407
+ }
408
+
409
+ const SKIP = new Set(["node_modules", ".git", "dist", "build", "out", "coverage", ".venv", "venv", "target", ".next", ".turbo", "vendor"]);
410
+
411
+ /** The linters configured in the project (its top and folders up to `depth` below, skipping dependencies and builds), for Settings. */
412
+ export function findLinters(top: string, depth = 3, limit = 40): FoundLinter[] {
413
+ const found: FoundLinter[] = [];
414
+ const visit = (folder: string, level: number) => {
415
+ if (found.length >= limit) return;
416
+ for (const linter of LINTERS) {
417
+ if (configuredIn(linter, folder)) found.push({ tool: linter.name, folder: repoPath(top, folder), installed: linter.name === "Ruff" ? ruffBin(folder, top)[0] !== "ruff" || onPath("ruff") : Boolean(linter.binary(folder, top)) });
418
+ }
419
+ if (level >= depth) return;
420
+ let entries: string[] = [];
421
+ try {
422
+ entries = readdirSync(folder, { withFileTypes: true }).filter((entry) => entry.isDirectory() && !entry.name.startsWith(".") && !SKIP.has(entry.name)).map((entry) => entry.name).sort();
423
+ } catch {
424
+ return;
425
+ }
426
+ for (const name of entries) visit(join(folder, name), level + 1);
427
+ };
428
+ visit(top, 0);
429
+ return found;
430
+ }
431
+
432
+ function onPath(command: string): boolean {
433
+ const extensions = process.platform === "win32" ? [".exe", ".cmd", ""] : [""];
434
+ return (process.env.PATH ?? "").split(process.platform === "win32" ? ";" : ":").some((dir) => dir && extensions.some((ext) => existsSync(join(dir, `${command}${ext}`))));
435
+ }
package/src/lobby/ask.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * The oracle puts the planning panel's questions to the user through the
3
3
  * questionnaire (`../ask`): a card per question with each seat's options (the
4
- * recommended one first when the answer is obvious), and a field for an answer in the user's own words.
4
+ * recommended one first when the answer is obvious, with the mockup a seat drew
5
+ * for it), and a field for an answer in the user's own words.
5
6
  * The panel's questions become questionnaires of at most four, and the
6
7
  * answers become the user's turn for the next round.
7
8
  */
@@ -32,7 +33,8 @@ export function toAskQuestion(question: PanelQuestion): AskQuestion {
32
33
  if (!label || RESERVED.has(label.toLowerCase())) continue;
33
34
  for (let n = 2; seen.has(label.toLowerCase()); n++) label = clip(`${option.label} (${n})`, MAX_LABEL);
34
35
  seen.add(label.toLowerCase());
35
- options.push({ label, description: option.description.trim() || label });
36
+ // A seat's mockup goes with its option, so the user sees it (and expands it) in the questionnaire.
37
+ options.push({ label, description: option.description.trim() || label, ...(option.mockup ? { htmlPreview: option.mockup } : {}) });
36
38
  if (options.length === MAX_OPTIONS) break;
37
39
  }
38
40
  for (const filler of DEFAULT_OPTIONS) {
package/src/lobby/feed.ts CHANGED
@@ -69,6 +69,8 @@ export class LobbyFeed {
69
69
  /** Last step reported per run, so repeated run updates log each step once. */
70
70
  private readonly runSteps = new Map<string, string>();
71
71
  private readonly runThoughts = new Map<string, string>();
72
+ /** The live whole thought of each piece of work still under way (a run, a quick fix, a review), by its key. */
73
+ private readonly liveThoughts = new Map<string, number>();
72
74
  private readonly runStatus = new Map<string, AgentRun["status"]>();
73
75
  /** Runs already reported as switched to their fallback model. */
74
76
 
@@ -126,8 +128,10 @@ export class LobbyFeed {
126
128
 
127
129
  /** Stream the Master's (or any source's) live thought. */
128
130
  thinkDelta(source: string, delta: string, at = Date.now()): void {
129
- const last = this.thoughts.at(-1);
130
- if (last && last.live && last.source === source) {
131
+ // The source's own streaming entry, even when another agent's thought landed after it.
132
+ const keyed = new Set(this.liveThoughts.values());
133
+ const last = [...this.thoughts].reverse().find((entry) => entry.live && entry.source === source && !keyed.has(entry.id));
134
+ if (last) {
131
135
  const text = last.text + delta;
132
136
  last.text = text.length > MAX_THOUGHT_TEXT ? trimThought(text) : text;
133
137
  } else {
@@ -138,7 +142,8 @@ export class LobbyFeed {
138
142
 
139
143
  /** Close the live thought of `source`, replacing it with the final text when given. */
140
144
  thinkEnd(source: string, text?: string): void {
141
- const last = [...this.thoughts].reverse().find((entry) => entry.source === source && entry.live);
145
+ const keyed = new Set(this.liveThoughts.values());
146
+ const last = [...this.thoughts].reverse().find((entry) => entry.source === source && entry.live && !keyed.has(entry.id));
142
147
  if (!last) {
143
148
  if (text?.trim()) this.thought(source, text);
144
149
  return;
@@ -157,6 +162,32 @@ export class LobbyFeed {
157
162
  this.touch();
158
163
  }
159
164
 
165
+ /**
166
+ * A whole thought from work still under way (`key`: its run, quick fix or
167
+ * review): live, so the Thinking orb takes its agent's colour, until the
168
+ * same work's next thought or `settleThought(key)` when it ends.
169
+ */
170
+ liveThought(source: string, text: string, key: string, at = Date.now()): void {
171
+ const body = text.trim();
172
+ if (!body) return;
173
+ this.settleThought(key, false);
174
+ const entry: ThoughtEntry = { id: this.nextId++, at, source, text: body.slice(-MAX_THOUGHT_TEXT), live: true };
175
+ this.thoughts = bounded([...this.thoughts, entry], MAX_THOUGHTS);
176
+ this.liveThoughts.set(key, entry.id);
177
+ this.touch();
178
+ }
179
+
180
+ /** The work behind `key` has ended: its last thought is no longer live. */
181
+ settleThought(key: string, touch = true): void {
182
+ const id = this.liveThoughts.get(key);
183
+ if (id === undefined) return;
184
+ this.liveThoughts.delete(key);
185
+ const entry = this.thoughts.find((thought) => thought.id === id);
186
+ if (!entry?.live) return;
187
+ entry.live = false;
188
+ if (touch) this.touch();
189
+ }
190
+
160
191
  /** Stream the oracle's reply text as it arrives. */
161
192
  replyDelta(delta: string): void {
162
193
  this.reply = trimReply(this.reply + delta);
@@ -207,8 +238,11 @@ export class LobbyFeed {
207
238
  }
208
239
  if (run.thought && this.runThoughts.get(key) !== run.thought) {
209
240
  this.runThoughts.set(key, run.thought);
210
- this.thought(source, run.thought, at);
241
+ // Live while the run goes on: the orb shows who is thinking, not only the oracle.
242
+ if (run.status === "running") this.liveThought(source, run.thought, key, at);
243
+ else this.thought(source, run.thought, at);
211
244
  }
245
+ if (run.status !== "running") this.settleThought(key);
212
246
  if (run.status !== "running" && before !== run.status) {
213
247
  this.settle(undefined, key);
214
248
  const kind: ActivityKind = run.status === "success" ? (run.wrappedUp ? "warning" : "success") : run.status === "cancelled" ? "warning" : "error";
@@ -225,6 +259,7 @@ export class LobbyFeed {
225
259
  this.reply = "";
226
260
  this.runSteps.clear();
227
261
  this.runThoughts.clear();
262
+ this.liveThoughts.clear();
228
263
  this.runStatus.clear();
229
264
  this.touch();
230
265
  }
package/src/lobby/host.ts CHANGED
@@ -87,6 +87,8 @@ export interface ModelChoice {
87
87
  /** Everything the web server needs from pi and bot-lobby. */
88
88
  export interface LobbyService {
89
89
  projectRoot?(): string;
90
+ /** The linters the project configures, for the Settings page's Linting section. */
91
+ linters?(): Array<{ tool: string; folder: string; installed: boolean }>;
90
92
  sessionId(): string | undefined;
91
93
  /** This session's active task and its runs, as the lobby reads them. */
92
94
  zen(): { task?: Task; runs: readonly AgentRun[] };
@@ -61,8 +61,13 @@ const MEMBER_SEATS: Record<PanelMember, string> = {
61
61
  export interface PanelOption {
62
62
  label: string;
63
63
  description: string;
64
+ /** A static HTML/CSS mockup of the option (a fenced `mockup` block under it), shown as a thumbnail the user expands. */
65
+ mockup?: { html: string; css?: string };
64
66
  }
65
67
 
68
+ /** Longest mockup kept per option; a longer one is dropped rather than cut mid-tag. */
69
+ export const MAX_MOCKUP_CHARS = 30_000;
70
+
66
71
  /** A question as a seat or the oracle wrote it, with the options it offers (recommended first). */
67
72
  export interface AskedQuestion {
68
73
  text: string;
@@ -273,7 +278,22 @@ export function questionItems(body: string | undefined): AskedQuestion[] {
273
278
  if (!body) return [];
274
279
  const items: AskedQuestion[] = [];
275
280
  let current: AskedQuestion | undefined;
281
+ // A fenced `mockup` (or `html`) block under an option is that option's mockup, never more question text.
282
+ let fence: { option?: PanelOption | undefined; lines: string[]; marker: string } | undefined;
276
283
  for (const line of body.split("\n")) {
284
+ if (fence) {
285
+ if (line.trim() === fence.marker) {
286
+ const html = dedent(fence.lines).trim();
287
+ if (fence.option && html && html.length <= MAX_MOCKUP_CHARS) fence.option.mockup = { html };
288
+ fence = undefined;
289
+ } else fence.lines.push(line);
290
+ continue;
291
+ }
292
+ const opening = /^\s*(`{3,}|~{3,})\s*(?:mockup|html)\s*$/i.exec(line);
293
+ if (opening) {
294
+ fence = { lines: [], marker: opening[1]!, ...(current?.options.length ? { option: current.options.at(-1) } : {}) };
295
+ continue;
296
+ }
277
297
  const match = /^(\s*)(?:\d+[.)]|[-*])\s+(.*\S)\s*$/.exec(line);
278
298
  if (match && (match[1]!.length === 0 || !current)) {
279
299
  current = { text: match[2]!.replace(/\*\*/g, ""), options: [] };
@@ -289,6 +309,34 @@ export function questionItems(body: string | undefined): AskedQuestion[] {
289
309
  return items.map(inlineOptions);
290
310
  }
291
311
 
312
+ /** Lines with their shared indentation removed. */
313
+ function dedent(lines: readonly string[]): string {
314
+ const indents = lines.filter((line) => line.trim()).map((line) => /^\s*/.exec(line)![0].length);
315
+ const cut = indents.length ? Math.min(...indents) : 0;
316
+ return lines.map((line) => line.slice(cut)).join("\n");
317
+ }
318
+
319
+ const plainOption = (label: string): string => optionLabel({ label, description: "" }).toLowerCase();
320
+
321
+ /**
322
+ * The oracle relays a seat's question in its own words, without repeating the
323
+ * seat's mockups: an option it keeps by label keeps the seat's mockup (from
324
+ * the seat it tags first, then any seat).
325
+ */
326
+ export function carryMockups(questions: readonly PanelQuestion[], seatQuestions: readonly PanelQuestion[]): PanelQuestion[] {
327
+ const drawn = seatQuestions.flatMap((question) => question.options.filter((option) => option.mockup).map((option) => ({ from: question.from, label: plainOption(option.label), mockup: option.mockup! })));
328
+ if (drawn.length === 0) return [...questions];
329
+ return questions.map((question) => ({
330
+ ...question,
331
+ options: question.options.map((option) => {
332
+ if (option.mockup) return option;
333
+ const label = plainOption(option.label);
334
+ const match = drawn.find((entry) => entry.label === label && entry.from === question.from) ?? drawn.find((entry) => entry.label === label);
335
+ return match ? { ...option, mockup: match.mockup } : option;
336
+ }),
337
+ }));
338
+ }
339
+
292
340
  /**
293
341
  * The round's questions for the user. The oracle chooses them from its own
294
342
  * and the seats' (tagged with the seat each serves) and decides the rest; only
@@ -296,7 +344,7 @@ export function questionItems(body: string | undefined): AskedQuestion[] {
296
344
  * `MAX_QUESTIONS`, so a round is answered in one questionnaire.
297
345
  */
298
346
  export function roundQuestions(reply: PlannerReply | undefined, seatQuestions: readonly PanelQuestion[]): PanelQuestion[] {
299
- const chosen = reply ? reply.questions.map((question) => tagged(question, ORACLE_LABEL)) : [...seatQuestions];
347
+ const chosen = reply ? carryMockups(reply.questions.map((question) => tagged(question, ORACLE_LABEL)), seatQuestions) : [...seatQuestions];
300
348
  return chosen.slice(0, MAX_QUESTIONS);
301
349
  }
302
350
 
@@ -330,8 +378,8 @@ export function parseMemberReply(text: string): MemberReply {
330
378
  return { status: ready ? "ready" : "open", questions, notes: listItems(parts.get("notes")) };
331
379
  }
332
380
 
333
- function optionLines(options: readonly PanelOption[]): string[] {
334
- return options.map((option) => ` - ${option.label}${option.description ? ` — ${option.description}` : ""}`);
381
+ function optionLines(options: readonly PanelOption[], marks = false): string[] {
382
+ return options.map((option) => ` - ${option.label}${option.description ? ` — ${option.description}` : ""}${marks && option.mockup ? " [mockup]" : ""}`);
335
383
  }
336
384
 
337
385
  function questionLine(question: PanelQuestion, index: number): string {
@@ -429,7 +477,7 @@ export function panelSection(outcomes: readonly MemberOutcome[]): string {
429
477
  const { status, questions, notes } = outcome.reply;
430
478
  return [
431
479
  `### ${label} — ${status === "ready" ? "READY" : "OPEN"}`,
432
- questions.length > 0 ? `Questions for the user:\n${questions.map((question) => [`- ${question.text}`, ...optionLines(question.options)].join("\n")).join("\n")}` : "",
480
+ questions.length > 0 ? `Questions for the user:\n${questions.map((question) => [`- ${question.text}`, ...optionLines(question.options, true)].join("\n")).join("\n")}` : "",
433
481
  notes.length > 0 ? `Notes:\n${notes.map((note) => `- ${note}`).join("\n")}` : "",
434
482
  ].filter(Boolean).join("\n");
435
483
  });
@@ -798,7 +846,7 @@ export class PlanningSession {
798
846
  const step = describeToolCall(event.toolName, event.args);
799
847
  setStep(step);
800
848
  this.deps.feed?.step(label, step, this.stepKey(label));
801
- } else if (event.type === "thought") this.deps.feed?.thought(label, event.text);
849
+ } else if (event.type === "thought") this.deps.feed?.liveThought(label, event.text, this.stepKey(label));
802
850
  else if (event.type === "thinking" || event.type === "writing") setStep(event.type);
803
851
  else return;
804
852
  this.deps.onChange?.();
@@ -828,6 +876,7 @@ export class PlanningSession {
828
876
  outcome = { status: "failed", output: "", error: (error as Error).message, usage: { input: 0, output: 0, cost: 0, turns: 0 } };
829
877
  }
830
878
  this.deps.feed?.end(this.stepKey(label), outcome.status !== "success");
879
+ this.deps.feed?.settleThought(this.stepKey(label));
831
880
  const metric = planningMetric(kind, label, this.turns, startedAt, outcome.status, profile, outcome.model, outcome.usage);
832
881
  appendMetrics(this.deps.root, this.deps.configDir, [routedFrom ? { ...metric, routedFrom } : metric]);
833
882
  return outcome;