@a-t-h-i/bot-lobby 0.6.57 → 0.6.59

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 (118) hide show
  1. package/README.md +17 -11
  2. package/THIRD_PARTY_NOTICES.md +17 -3
  3. package/package.json +6 -1
  4. package/prompts/lobby-chat.md +39 -0
  5. package/prompts/master.md +7 -7
  6. package/prompts/reviewer.md +6 -6
  7. package/src/classifier/instance.ts +1 -1
  8. package/src/classifier/lobby.ts +37 -0
  9. package/src/classifier/triage.ts +2 -24
  10. package/src/execution/delegation.ts +2 -2
  11. package/src/index.ts +0 -3
  12. package/src/lobby/chat-agents.ts +293 -0
  13. package/src/lobby/feed.ts +73 -17
  14. package/src/lobby/host.ts +8 -4
  15. package/src/lobby/keys.ts +1 -0
  16. package/src/lobby/planner.ts +5 -5
  17. package/src/lobby/pr-review.ts +2 -2
  18. package/src/lobby/profile.ts +16 -0
  19. package/src/lobby/prompts.ts +56 -4
  20. package/src/lobby/recovery.ts +10 -42
  21. package/src/lobby/runtime.ts +27 -45
  22. package/src/lobby/service.ts +142 -13
  23. package/src/lobby/session-files.ts +4 -1
  24. package/src/lobby/topics.ts +0 -1
  25. package/src/master/master.ts +1 -1
  26. package/src/pi/commands.ts +6 -8
  27. package/src/pi/events.ts +11 -3
  28. package/src/pi/fresh-context.ts +1 -1
  29. package/src/pi/model-settings.ts +0 -2
  30. package/src/pi/model-support.ts +11 -2
  31. package/src/pi/owner.ts +35 -4
  32. package/src/pi/start-task.ts +3 -9
  33. package/src/pi/switch.ts +1 -1
  34. package/src/schemas/configuration.ts +14 -24
  35. package/src/state/changes.ts +25 -23
  36. package/src/state/metrics.ts +5 -8
  37. package/src/state/recovery.ts +3 -3
  38. package/src/webui/api/index.ts +12 -18
  39. package/src/webui/api/lobby.ts +9 -6
  40. package/src/webui/code.ts +202 -0
  41. package/src/webui/dev/fake-service.ts +28 -62
  42. package/src/webui/dev/fixtures.ts +1 -4
  43. package/src/webui/protocol.ts +15 -9
  44. package/src/webui/static.ts +5 -2
  45. package/src/workflow/track.ts +1 -52
  46. package/src/workflow/workflow.ts +2 -2
  47. package/webui/dist/assets/CodeSurface-BtzJOVyH.js +448 -0
  48. package/webui/dist/assets/{TerminalView-C8VC7KXy.js → TerminalView-Cz-KNWsq.js} +2 -2
  49. package/webui/dist/assets/astro-p42oEArs.js +1 -0
  50. package/webui/dist/assets/c-DaO8xguW.js +1 -0
  51. package/webui/dist/assets/ccount-DQxSH8e6.js +1 -0
  52. package/webui/dist/assets/csharp-dmMxLVLW.js +1 -0
  53. package/webui/dist/assets/css-C8fQr_0Q.js +1 -0
  54. package/webui/dist/assets/diff-woXpYk--.js +1 -0
  55. package/webui/dist/assets/dockerfile-IyjqRm3v.js +1 -0
  56. package/webui/dist/assets/dotenv-CuH5my32.js +1 -0
  57. package/webui/dist/assets/fish-BJitypiv.js +1 -0
  58. package/webui/dist/assets/go-rLFTqkRN.js +1 -0
  59. package/webui/dist/assets/graphql-AVu4aKQX.js +1 -0
  60. package/webui/dist/assets/html-wSbaAAK3.js +1 -0
  61. package/webui/dist/assets/index-D5fvY8f8.js +112 -0
  62. package/webui/dist/assets/index-DFvk8ADr.css +2 -0
  63. package/webui/dist/assets/ini-B5eOa1yu.js +1 -0
  64. package/webui/dist/assets/java-DdHNQ9hk.js +1 -0
  65. package/webui/dist/assets/javascript-BgS3c2Ky.js +1 -0
  66. package/webui/dist/assets/json-qhed-kSA.js +1 -0
  67. package/webui/dist/assets/json5-BR5RXkoi.js +1 -0
  68. package/webui/dist/assets/jsonc-CYpm1nAK.js +1 -0
  69. package/webui/dist/assets/jsonl-CmCQp5Yx.js +1 -0
  70. package/webui/dist/assets/jsx-gTgmArGx.js +1 -0
  71. package/webui/dist/assets/kotlin-DhhofPvG.js +1 -0
  72. package/webui/dist/assets/kts-DFAlwc6n.js +1 -0
  73. package/webui/dist/assets/less-DVTAwKKz.js +1 -0
  74. package/webui/dist/assets/lua-xZcbXUE9.js +1 -0
  75. package/webui/dist/assets/makefile-Dixweg8N.js +1 -0
  76. package/webui/dist/assets/markdown-BYOwaDjH.js +1 -0
  77. package/webui/dist/assets/php-C6PDnbTe.js +1 -0
  78. package/webui/dist/assets/pierre-dark-CpLgRqie.js +1 -0
  79. package/webui/dist/assets/pierre-dark-protanopia-deuteranopia-B35FxJx-.js +1 -0
  80. package/webui/dist/assets/pierre-dark-soft-kZQmAZld.js +1 -0
  81. package/webui/dist/assets/pierre-dark-tritanopia-CpjhbsIL.js +1 -0
  82. package/webui/dist/assets/pierre-dark-vibrant-CpQYzh95.js +1 -0
  83. package/webui/dist/assets/pierre-light-CoaEpmwp.js +1 -0
  84. package/webui/dist/assets/pierre-light-protanopia-deuteranopia-0fSaH845.js +1 -0
  85. package/webui/dist/assets/pierre-light-soft-lWLdNTOI.js +1 -0
  86. package/webui/dist/assets/pierre-light-tritanopia-CEbqgOJL.js +1 -0
  87. package/webui/dist/assets/pierre-light-vibrant-D80Fkn33.js +1 -0
  88. package/webui/dist/assets/postcss-D_Wz94rJ.js +1 -0
  89. package/webui/dist/assets/powershell-QtSnmXcu.js +1 -0
  90. package/webui/dist/assets/prisma-DMgNhnYS.js +1 -0
  91. package/webui/dist/assets/properties-CxAuzHS9.js +1 -0
  92. package/webui/dist/assets/python-gzcpVVnB.js +1 -0
  93. package/webui/dist/assets/rolldown-runtime-hePW80VL.js +1 -0
  94. package/webui/dist/assets/ruby-CPL7DL0w.js +1 -0
  95. package/webui/dist/assets/rust-DpiIf5JO.js +1 -0
  96. package/webui/dist/assets/sass-DXrisJhu.js +1 -0
  97. package/webui/dist/assets/scss-CHVQqdPx.js +1 -0
  98. package/webui/dist/assets/shellscript-CLZ0U2zV.js +1 -0
  99. package/webui/dist/assets/sql-CEbI0hMM.js +1 -0
  100. package/webui/dist/assets/svelte-DKhxH1PN.js +1 -0
  101. package/webui/dist/assets/swift-CyEgAFGc.js +1 -0
  102. package/webui/dist/assets/toml-CcmNWLt0.js +1 -0
  103. package/webui/dist/assets/tsx-CUOciP0b.js +1 -0
  104. package/webui/dist/assets/typescript-BydbNFcO.js +1 -0
  105. package/webui/dist/assets/vue-kBwcnTZb.js +1 -0
  106. package/webui/dist/assets/wasm-BnjxR4X6.js +1 -0
  107. package/webui/dist/assets/xml-CeXJrFlG.js +1 -0
  108. package/webui/dist/assets/yaml-rwi0_p6S.js +1 -0
  109. package/webui/dist/assets/yml-s9iVJXCw.js +1 -0
  110. package/webui/dist/assets/zsh-B7CDPTg5.js +1 -0
  111. package/webui/dist/build.json +61 -45
  112. package/webui/dist/index.html +4 -2
  113. package/prompts/quickfix.md +0 -67
  114. package/src/lobby/quickfix.ts +0 -546
  115. package/src/pi/route.ts +0 -180
  116. package/src/webui/api/quickfix.ts +0 -49
  117. package/webui/dist/assets/index-Bs1_77_1.js +0 -114
  118. package/webui/dist/assets/index-DOLQ4FDd.css +0 -2
@@ -0,0 +1,202 @@
1
+ /**
2
+ * The code viewer's files: what changed in the project's working tree against
3
+ * its last commit, one file's text with what the last commit held, and saving
4
+ * an edit. Every path is jailed to the project folder (resolved with
5
+ * `realpath`, so a symlink cannot reach out), `.git` is never read or
6
+ * written, and git runs with arguments only, never a shell.
7
+ */
8
+ import { execFile } from "node:child_process";
9
+ import { lstat, readFile, realpath, stat, writeFile } from "node:fs/promises";
10
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
11
+ import { HttpError } from "./api/index.ts";
12
+ import type { CodeChange, CodeChanges, CodeFile, CodeSaved } from "./protocol.ts";
13
+
14
+ /** Past this many bytes a file is not sent (the page says it is too large). */
15
+ export const CODE_MAX_BYTES = 900_000;
16
+ /** At most this many changed files are listed. */
17
+ const MAX_CHANGES = 500;
18
+
19
+ function git(root: string, args: string[], maxBuffer = 8_000_000): Promise<string | undefined> {
20
+ return new Promise((done) => {
21
+ execFile("git", ["-c", "core.quotepath=off", ...args], { cwd: root, maxBuffer, windowsHide: true, encoding: "utf8" }, (error, stdout) => {
22
+ done(error ? undefined : stdout);
23
+ });
24
+ });
25
+ }
26
+
27
+ /** Repository-relative with forward slashes, whatever the platform. */
28
+ function slashed(path: string): string {
29
+ return path.split(sep).join("/");
30
+ }
31
+
32
+ function insideGitDir(rel: string): boolean {
33
+ return rel.split("/").includes(".git");
34
+ }
35
+
36
+ /** A path the page sent, as a project-relative path, or a refusal. Absolute paths must sit inside the project. */
37
+ export function projectPath(root: string, path: string): string {
38
+ const raw = path.trim().replace(/^file:\/\//, "");
39
+ if (!raw || raw.includes("\0") || raw.length > 4096) throw new HttpError(400, "bad_request", "name a file in the project");
40
+ const full = isAbsolute(raw) ? resolve(raw) : resolve(root, raw);
41
+ const rel = relative(root, full);
42
+ if (!rel || rel.startsWith("..") || isAbsolute(rel)) throw new HttpError(403, "forbidden", "that file is outside the project");
43
+ if (insideGitDir(slashed(rel))) throw new HttpError(403, "forbidden", "the .git folder is not shown");
44
+ return slashed(rel);
45
+ }
46
+
47
+ /** The file's real path, refused when a link leads it out of the project. */
48
+ async function jailed(root: string, rel: string): Promise<string> {
49
+ const realRoot = await realpath(root);
50
+ const target = join(realRoot, rel);
51
+ let real: string;
52
+ try {
53
+ real = await realpath(target);
54
+ } catch {
55
+ // A file that does not exist yet: its folder must still be inside.
56
+ real = join(await realpath(dirname(target)).catch(() => dirname(target)), basename(target));
57
+ }
58
+ if (real !== realRoot && !real.startsWith(realRoot + sep)) throw new HttpError(403, "forbidden", "that file is outside the project");
59
+ if (insideGitDir(slashed(relative(realRoot, real)))) throw new HttpError(403, "forbidden", "the .git folder is not shown");
60
+ return real;
61
+ }
62
+
63
+ const STATUS: Record<string, CodeChange["status"]> = { M: "modified", T: "modified", A: "added", D: "deleted", R: "renamed", C: "added", U: "modified" };
64
+
65
+ /** `git status --porcelain=v1 -z` read into changes (index and working tree folded together). */
66
+ export function parseStatus(out: string): CodeChange[] {
67
+ const parts = out.split("\0");
68
+ const changes: CodeChange[] = [];
69
+ for (let at = 0; at < parts.length; at++) {
70
+ const entry = parts[at]!;
71
+ if (entry.length < 4) continue;
72
+ const code = entry.slice(0, 2);
73
+ const path = entry.slice(3);
74
+ if (code === "??") {
75
+ changes.push({ path, status: "untracked" });
76
+ continue;
77
+ }
78
+ if (code === "!!") continue;
79
+ const letter = code[1] !== " " ? code[1]! : code[0]!;
80
+ const renamed = code[0] === "R" || code[0] === "C";
81
+ const from = renamed ? parts[++at] : undefined;
82
+ const status = code.includes("D") ? "deleted" : renamed && code[0] === "R" ? "renamed" : STATUS[letter] ?? "modified";
83
+ changes.push({ path, status, ...(from && status === "renamed" ? { from } : {}) });
84
+ }
85
+ return changes;
86
+ }
87
+
88
+ /** `git diff --numstat -z HEAD` as added and removed lines per path (binary files count none). */
89
+ export function parseNumstat(out: string): Map<string, { additions: number; deletions: number }> {
90
+ const counts = new Map<string, { additions: number; deletions: number }>();
91
+ const parts = out.split("\0");
92
+ for (let at = 0; at < parts.length; at++) {
93
+ const entry = parts[at]!;
94
+ const match = /^(-|\d+)\t(-|\d+)\t(.*)$/.exec(entry);
95
+ if (!match) continue;
96
+ let path = match[3]!;
97
+ // A rename names no path on its line; the old and new paths follow.
98
+ if (!path) {
99
+ at += 2;
100
+ path = parts[at] ?? "";
101
+ }
102
+ counts.set(path, { additions: Number(match[1]) || 0, deletions: Number(match[2]) || 0 });
103
+ }
104
+ return counts;
105
+ }
106
+
107
+ function lineCount(text: string): number {
108
+ if (!text) return 0;
109
+ return text.split("\n").length - (text.endsWith("\n") ? 1 : 0);
110
+ }
111
+
112
+ /** Every changed file in the working tree against HEAD, with its line counts. */
113
+ export async function codeChanges(root: string): Promise<CodeChanges> {
114
+ const status = await git(root, ["status", "--porcelain=v1", "-z", "--untracked-files=all"]);
115
+ if (status === undefined) return { git: false, files: [], truncated: false };
116
+ const head = await git(root, ["rev-parse", "--verify", "--quiet", "HEAD"]);
117
+ const numstat = head ? parseNumstat((await git(root, ["diff", "--numstat", "-z", "-M", "HEAD"])) ?? "") : new Map();
118
+ const all = parseStatus(status).filter((change) => !insideGitDir(change.path));
119
+ const files = all.slice(0, MAX_CHANGES);
120
+ for (const file of files) {
121
+ const counted = numstat.get(file.path);
122
+ if (counted) Object.assign(file, counted);
123
+ else if (file.status === "untracked" || file.status === "added") {
124
+ try {
125
+ const real = await jailed(root, file.path);
126
+ const info = await stat(real);
127
+ if (info.isFile() && info.size <= CODE_MAX_BYTES) {
128
+ const text = await readFile(real, "utf8");
129
+ if (!text.includes("\0")) Object.assign(file, { additions: lineCount(text), deletions: 0 });
130
+ }
131
+ } catch {
132
+ // Counts stay off for a file that cannot be read.
133
+ }
134
+ }
135
+ }
136
+ files.sort((a, b) => a.path.localeCompare(b.path));
137
+ return { git: true, files, truncated: all.length > files.length };
138
+ }
139
+
140
+ /** A bare file name (`zen.ts`, as the activity log says it) as the one project file it names, changed files first. */
141
+ async function findByName(root: string, name: string): Promise<string | undefined> {
142
+ if (name.includes("/") || name.includes("\\")) return undefined;
143
+ const changed = (await codeChanges(root)).files.filter((file) => file.status !== "deleted" && basename(file.path) === name);
144
+ if (changed.length > 0) return changed[0]!.path;
145
+ const listed = await git(root, ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], 32_000_000);
146
+ if (!listed) return undefined;
147
+ const matches = listed.split("\0").filter((path) => path && basename(path) === name);
148
+ return matches.length > 0 ? matches.sort((a, b) => a.split("/").length - b.split("/").length || a.localeCompare(b))[0] : undefined;
149
+ }
150
+
151
+ function looksBinary(buffer: Buffer): boolean {
152
+ return buffer.subarray(0, 8000).includes(0);
153
+ }
154
+
155
+ /** What HEAD holds for the file, or undefined when it holds nothing (a new file, no commits, no git). */
156
+ async function headText(root: string, rel: string, from?: string): Promise<string | undefined> {
157
+ const out = await git(root, ["show", `HEAD:${from ?? rel}`], CODE_MAX_BYTES * 2);
158
+ return out === undefined || out.includes("\0") ? undefined : out;
159
+ }
160
+
161
+ /** One file to show: its text (unless binary or too large), its change, and what the last commit held. */
162
+ export async function codeRead(root: string, path: string): Promise<CodeFile> {
163
+ let rel = projectPath(root, path);
164
+ let real = await jailed(root, rel);
165
+ let info = await lstat(real).then(() => stat(real)).catch(() => undefined);
166
+ if (!info) {
167
+ const found = await findByName(root, rel);
168
+ if (found) {
169
+ rel = found;
170
+ real = await jailed(root, rel);
171
+ info = await stat(real).catch(() => undefined);
172
+ }
173
+ }
174
+ const change = (await codeChanges(root)).files.find((file) => file.path === rel);
175
+ if (!info && change?.status !== "deleted") throw new HttpError(404, "not_found", `${rel} is not in the project`);
176
+ if (info && !info.isFile()) throw new HttpError(400, "bad_request", `${rel} is not a file`);
177
+ const original = change && change.status !== "untracked" && change.status !== "added" ? await headText(root, rel, change.from) : undefined;
178
+ const base: CodeFile = { path: rel, size: info?.size ?? 0, mtimeMs: info?.mtimeMs ?? 0, exists: Boolean(info), ...(change ? { change } : {}), ...(original !== undefined ? { original } : {}) };
179
+ if (!info) return { ...base, contents: "" };
180
+ if (info.size > CODE_MAX_BYTES) return { ...base, tooLarge: true };
181
+ const buffer = await readFile(real);
182
+ if (looksBinary(buffer)) return { ...base, binary: true };
183
+ return { ...base, contents: buffer.toString("utf8") };
184
+ }
185
+
186
+ /**
187
+ * Save the file. `mtimeMs` is when the page read it: a file changed on disk
188
+ * since (an agent edited it) is not overwritten unless `force` says so.
189
+ */
190
+ export async function codeSave(root: string, body: { path: string; contents: string; mtimeMs?: number; force?: boolean }): Promise<CodeSaved> {
191
+ const rel = projectPath(root, body.path);
192
+ const real = await jailed(root, rel);
193
+ const info = await stat(real).catch(() => undefined);
194
+ if (!info) throw new HttpError(404, "not_found", `${rel} is no longer in the project`);
195
+ if (!info.isFile()) throw new HttpError(400, "bad_request", `${rel} is not a file`);
196
+ if (!body.force && body.mtimeMs !== undefined && Math.abs(info.mtimeMs - body.mtimeMs) > 1) {
197
+ throw new HttpError(409, "conflict", `${rel} changed on disk since you opened it`);
198
+ }
199
+ await writeFile(real, body.contents, "utf8");
200
+ const after = await stat(real);
201
+ return { path: rel, mtimeMs: after.mtimeMs, notice: `saved ${rel}` };
202
+ }
@@ -8,6 +8,7 @@ import { resolveConfig, type BotLobbyConfig } from "../../schemas/configuration.
8
8
  import { LobbyFeed } from "../../lobby/feed.ts";
9
9
  import { promptHub } from "../../lobby/prompt-hub.ts";
10
10
  import { lobbyTopics } from "../../lobby/topics.ts";
11
+ import { CHAT_AGENT_SOURCES, parseLobbyCommand, parseMention } from "../../lobby/prompts.ts";
11
12
  import type { BackgroundSession, DialogAnswer, SessionDialog } from "../../lobby/sessions.ts";
12
13
  import { cleanSessionName } from "../../lobby/sessions.ts";
13
14
  import type { PlanComment } from "../../state/comments.ts";
@@ -65,32 +66,6 @@ function defaultPlanner(): NonNullable<ScenarioFixture["mockPlanner"]> {
65
66
  return { seats: ["backend", "designer", "qa", "researcher"], members: [], messages: [], questions: [], notes: [], round: 0, retryable: false };
66
67
  }
67
68
 
68
- interface FakeQuickFixJob {
69
- id: string;
70
- prompt: string;
71
- status: string;
72
- createdAt: number;
73
- steps: Array<{ at: number; text: string; pending: boolean }>;
74
- tools: number;
75
- turns: number;
76
- note?: string;
77
- report?: string;
78
- }
79
-
80
- function fakeQuickFixJobs(entries: Array<Record<string, unknown>>): FakeQuickFixJob[] {
81
- return entries.map((job, index) => ({
82
- id: typeof job.id === "string" ? job.id : `QF-${index + 1}`,
83
- prompt: typeof job.prompt === "string" ? job.prompt : "",
84
- status: typeof job.status === "string" ? job.status : "success",
85
- createdAt: typeof job.createdAt === "number" ? job.createdAt : Date.now(),
86
- steps: [],
87
- tools: 0,
88
- turns: 0,
89
- ...(typeof job.note === "string" ? { note: job.note } : {}),
90
- ...(typeof job.report === "string" ? { report: job.report } : {}),
91
- }));
92
- }
93
-
94
69
  const DETAIL_ONLY = ["body", "state", "mergeable", "files", "notes", "comments"] as const;
95
70
 
96
71
  /** A list row: the detail fixture without the fields only a detail carries. */
@@ -331,6 +306,9 @@ function streamReply(service: object, feed: LobbyFeed, fixture: ScenarioFixture)
331
306
  }
332
307
  }
333
308
 
309
+ /** How long a scripted @ mention reply takes, so the page shows the agent at work first. */
310
+ const ANSWER_MS = 1500;
311
+
334
312
  /** A `LobbyService` answering from the `name` scenario's fixtures. */
335
313
  export function createFixtureService(name: string, feed = new LobbyFeed()): LobbyService {
336
314
  const fixture = loadScenario(name);
@@ -343,6 +321,8 @@ export function createFixtureService(name: string, feed = new LobbyFeed()): Lobb
343
321
  const auto = new Set(fixture.autoTasks ?? []);
344
322
  const comments = new Map<string, Array<Record<string, unknown>>>(Object.entries(fixture.taskComments ?? {}));
345
323
  const backgrounds = (fixture.backgroundSessions ?? []).map(fakeBackground);
324
+ // The agent other than the oracle answering an @ mention, for as long as its scripted reply takes.
325
+ let answering: string | undefined;
346
326
  const archivedBackgrounds: BackgroundSession[] = [];
347
327
  let sessionName = fixture.status.sessionName;
348
328
  let planning = fakePlanner(fixture.mockPlanner ?? defaultPlanner());
@@ -365,44 +345,12 @@ export function createFixtureService(name: string, feed = new LobbyFeed()): Lobb
365
345
  comment: () => "note saved — every agent reads it under this entry",
366
346
  unnote: () => "note removed",
367
347
  };
368
- const qfJobs = fakeQuickFixJobs(fixture.mockQuickfix ?? []);
369
- let qfCounter = qfJobs.length;
370
- const quickfix = {
371
- jobs: qfJobs,
372
- get running() {
373
- return qfJobs.find((job) => job.status === "running");
374
- },
375
- submit(prompt: string) {
376
- const job: FakeQuickFixJob = { id: `QF-${(qfCounter += 1)}`, prompt: prompt.trim(), status: "queued", createdAt: Date.now(), steps: [], tools: 0, turns: 0 };
377
- qfJobs.push(job);
378
- return job;
379
- },
380
- cancel(id: string): boolean {
381
- const job = qfJobs.find((entry) => entry.id === id);
382
- if (!job || (job.status !== "queued" && job.status !== "running")) return false;
383
- job.status = "cancelled";
384
- return true;
385
- },
386
- runAnyway(id: string): boolean {
387
- const job = qfJobs.find((entry) => entry.id === id);
388
- if (!job || job.status !== "held") return false;
389
- job.status = "queued";
390
- delete job.note;
391
- return true;
392
- },
393
- movedToTask(id: string): boolean {
394
- const job = qfJobs.find((entry) => entry.id === id);
395
- if (!job || job.status !== "held") return false;
396
- job.status = "cancelled";
397
- job.note = "started as a task in a new session";
398
- return true;
399
- },
400
- };
401
348
  let commentSeq = 0;
402
349
  const service = {
403
350
  sessionId: () => fixture.status.sessionId,
404
351
  sessionName: () => sessionName,
405
- masterBusy: () => fixture.status.busy,
352
+ masterBusy: () => fixture.status.busy || answering !== undefined,
353
+ answering: () => answering,
406
354
  config: () => config,
407
355
  linters: () => fixture.settings?.linters ?? [{ tool: "ESLint", folder: "", installed: true }, { tool: "Ruff", folder: "services/api", installed: false }],
408
356
  saveConfig: (next: BotLobbyConfig) => {
@@ -441,7 +389,26 @@ export function createFixtureService(name: string, feed = new LobbyFeed()): Lobb
441
389
  zen: () => ({ ...(fixture.zen.task ? { task: { ...fixture.zen.task } } : {}), runs: [...fixture.zen.runs] }),
442
390
  feed,
443
391
  toOracle: (text: string) => {
444
- feed.say("you", text);
392
+ const command = parseLobbyCommand(text);
393
+ if (command?.command === "plan") return { notice: "planning on the Plan tab", open: "plan" as const };
394
+ if (command) return "starting a task — the oracle takes it from here";
395
+ const mention = parseMention(text);
396
+ if (mention && mention.agent !== "master") {
397
+ const source = CHAT_AGENT_SOURCES[mention.agent];
398
+ answering = source;
399
+ feed.say("you", mention.rest, Date.now(), { to: source });
400
+ lobbyTopics.bump("lobby");
401
+ lobbyTopics.bump("status");
402
+ const reply = setTimeout(() => {
403
+ answering = undefined;
404
+ feed.say("oracle", `${source} here: looked into it, and it is done.`, Date.now(), { agent: source });
405
+ lobbyTopics.bump("lobby");
406
+ lobbyTopics.bump("status");
407
+ }, ANSWER_MS);
408
+ unref(reply);
409
+ return undefined;
410
+ }
411
+ feed.say("you", mention?.rest || text);
445
412
  streamReply(service, feed, fixture);
446
413
  return undefined;
447
414
  },
@@ -593,7 +560,6 @@ export function createFixtureService(name: string, feed = new LobbyFeed()): Lobb
593
560
  },
594
561
  defaultPanel: () => ["backend", "designer", "qa", "researcher"],
595
562
  planningRounds: () => 5,
596
- quickfix,
597
563
  pulls: fakePulls(fixture.mockPulls ?? []),
598
564
  reviews: fakeReviews(fixture.mockReviews ?? [], fixture.mockReads ?? []),
599
565
  issues: fakeIssues((fixture.mockIssues ?? []).map((issue) => ({ ...issue }))),
@@ -46,7 +46,7 @@ export interface ScenarioFixture {
46
46
  };
47
47
  zen: { task?: Partial<SnapshotTask> & { id: string; title: string; state: string; plan?: string }; runs: unknown[] };
48
48
  feed: {
49
- chat: Array<{ role: ChatRole; text: string }>;
49
+ chat: Array<{ role: ChatRole; text: string; agent?: string; to?: string }>;
50
50
  activity: Array<{ source: string; text: string; kind: "info" | "success" | "warning" | "error"; pending: boolean }>;
51
51
  /** `live` thoughts are still streaming, as an agent thinking now. */
52
52
  thoughts: Array<{ source: string; text: string; live?: boolean }>;
@@ -91,8 +91,6 @@ export interface ScenarioFixture {
91
91
  round: number;
92
92
  retryable: boolean;
93
93
  };
94
- /** Quick-fix jobs for the `quickfix.*` calls, oldest first (absent means none). */
95
- mockQuickfix?: Array<Record<string, unknown>>;
96
94
  /** Metric records for `metrics.get` (absent means none). */
97
95
  mockMetrics?: Array<Record<string, unknown>>;
98
96
  /** Classifier records for `metrics.get` (absent means none). */
@@ -164,7 +162,6 @@ export function loadScenario(name: string | undefined): ScenarioFixture {
164
162
  liveSessions: Array.isArray(parsed.liveSessions) ? (parsed.liveSessions as ScenarioFixture["liveSessions"]) : [],
165
163
  mockPreviousPlans: Array.isArray(parsed.mockPreviousPlans) ? (parsed.mockPreviousPlans as ScenarioFixture["mockPreviousPlans"]) : [],
166
164
  mockPlanner: isRecord(parsed.mockPlanner) ? (parsed.mockPlanner as unknown as ScenarioFixture["mockPlanner"]) : undefined,
167
- mockQuickfix: Array.isArray(parsed.mockQuickfix) ? (parsed.mockQuickfix as ScenarioFixture["mockQuickfix"]) : [],
168
165
  mockMetrics: Array.isArray(parsed.mockMetrics) ? (parsed.mockMetrics as ScenarioFixture["mockMetrics"]) : [],
169
166
  mockClassifierMetrics: Array.isArray(parsed.mockClassifierMetrics) ? (parsed.mockClassifierMetrics as ScenarioFixture["mockClassifierMetrics"]) : [],
170
167
  mockKnowledge: Array.isArray(parsed.mockKnowledge) ? (parsed.mockKnowledge as ScenarioFixture["mockKnowledge"]) : [],
@@ -14,19 +14,25 @@ import type { PlanComment } from "../state/comments.ts";
14
14
  import type { BotLobbyConfig, PanelMember } from "../schemas/configuration.ts";
15
15
  import type { QaRiskAssessment } from "../schemas/task.ts";
16
16
  import type { MemberState, PanelNote, PanelQuestion, PlannerMessage } from "../lobby/planner.ts";
17
- import type { QuickFixJob } from "../lobby/quickfix.ts";
18
17
  import type { HarnessStatus } from "../harness/status.ts";
19
18
  import type { HarnessModel } from "../harness/catalog.ts";
20
19
 
21
20
  export interface ProjectInfo { id: string; name: string; cwd: string; port: number }
22
21
  export interface FolderListing { path: string; parent?: string; folders: Array<{ name: string; path: string }>; truncated: boolean }
23
22
 
23
+ /** A file changed in the working tree against HEAD (`code.changes`); counts are lines, absent for binary files. */
24
+ export interface CodeChange { path: string; status: "modified" | "added" | "deleted" | "renamed" | "untracked"; from?: string; additions?: number; deletions?: number }
25
+ export interface CodeChanges { git: boolean; files: CodeChange[]; truncated: boolean }
26
+ /** One file for the code viewer (`code.read`): its text unless binary or too large, its change, and HEAD's text when it had one. */
27
+ export interface CodeFile { path: string; size: number; mtimeMs: number; exists: boolean; contents?: string; binary?: boolean; tooLarge?: boolean; change?: CodeChange; original?: string }
28
+ export interface CodeSaved { path: string; mtimeMs: number; notice: string }
29
+
24
30
  export type { WebPrompt };
25
31
  export type { LobbyTopic };
26
32
  export type { DialogAnswer, SessionDialog, TaskRow, PlanComment };
27
33
  export type { LiveSession };
28
34
  export type { HarnessStatus, HarnessModel };
29
- export type { PanelMember, MemberState, PanelNote, PanelQuestion, PlannerMessage, QuickFixJob, BotLobbyConfig };
35
+ export type { PanelMember, MemberState, PanelNote, PanelQuestion, PlannerMessage, BotLobbyConfig };
30
36
 
31
37
  /** Every API answer: the result, or why not. */
32
38
  export type ApiReply<T> = { ok: true; result: T } | { ok: false; error: string; code: ErrorCode };
@@ -155,7 +161,9 @@ export interface LobbySnapshot {
155
161
  task?: SnapshotTask;
156
162
  runs: unknown[];
157
163
  /** Newest chat messages (at most 100), oldest first. */
158
- chat: Array<{ id: number; at: number; role: string; text: string }>;
164
+ chat: Array<{ id: number; at: number; role: string; text: string; agent?: string; to?: string }>;
165
+ /** The agent (other than the oracle) answering in the chat now: `DESIGN`, `DEV`, …; absent when none is. */
166
+ answering?: string;
159
167
  /** The oracle's reply while it streams; absent between replies. */
160
168
  reply?: string;
161
169
  /** Newest activity entries (at most 400), oldest first. */
@@ -553,7 +561,7 @@ export interface Api {
553
561
  "status.get": { request: Record<string, never>; result: StatusInfo };
554
562
  "lobby.snapshot": { request: Record<string, never>; result: LobbySnapshot };
555
563
  "lobby.history": { request: { before?: number }; result: { entries: unknown[]; hasOlder: boolean } };
556
- "lobby.send": { request: { text: string; attachments?: string[] }; result: { notice?: string } };
564
+ "lobby.send": { request: { text: string; attachments?: string[] }; result: { notice?: string; open?: "plan" } };
557
565
  "lobby.abort": { request: Record<string, never>; result: Record<string, never> };
558
566
  "tasks.list": { request: Record<string, never>; result: { rows: TaskRow[] } };
559
567
  "tasks.archived": { request: Record<string, never>; result: { rows: TaskRow[] } };
@@ -609,11 +617,9 @@ export interface Api {
609
617
  "planner.previousDelete": { request: { id: string }; result: { notice: string } };
610
618
  "planner.previousOpen": { request: { id: string }; result: { notice: string } };
611
619
  "planner.save": { request: Record<string, never>; result: { notice: string } };
612
- "quickfix.list": { request: Record<string, never>; result: { jobs: QuickFixJob[] } };
613
- "quickfix.submit": { request: { text: string; attachments?: string[] }; result: { notice: string; id: string } };
614
- "quickfix.cancel": { request: { id: string }; result: { notice: string } };
615
- "quickfix.runAnyway": { request: { id: string }; result: { notice: string } };
616
- "quickfix.movedToTask": { request: { id: string }; result: { notice: string; key?: string } };
620
+ "code.changes": { request: Record<string, never>; result: CodeChanges };
621
+ "code.read": { request: { path: string }; result: CodeFile };
622
+ "code.save": { request: { path: string; contents: string; mtimeMs?: number; force?: boolean }; result: CodeSaved };
617
623
  "projects.browse": { request: { path?: string }; result: FolderListing };
618
624
  "projects.open": { request: { path: string }; result: { project: ProjectInfo } };
619
625
  "knowledge.files": { request: Record<string, never>; result: { files: KnowledgeFileInfo[]; models?: Partial<Record<KnowledgeAgentName, string>> } };
@@ -46,11 +46,14 @@ export function freshNonce(): string {
46
46
  return randomBytes(16).toString("base64");
47
47
  }
48
48
 
49
- /** The page loads nothing from anywhere else, so model Markdown cannot pull in a tracker or a script. */
49
+ /**
50
+ * The page loads nothing from anywhere else, so model Markdown cannot pull in a tracker or a script.
51
+ * `'wasm-unsafe-eval'` lets the code viewer's highlighter compile its own bundled WebAssembly; it allows no `eval` of script.
52
+ */
50
53
  export function cspHeader(nonce: string): string {
51
54
  return [
52
55
  "default-src 'self'",
53
- `script-src 'self' 'nonce-${nonce}'`,
56
+ `script-src 'self' 'nonce-${nonce}' 'wasm-unsafe-eval'`,
54
57
  "style-src 'self' 'unsafe-inline'",
55
58
  "img-src 'self' data: blob:",
56
59
  "connect-src 'self'",
@@ -13,7 +13,6 @@
13
13
  * `action=track`; the user can force either path with `--fast` or `--full`.
14
14
  * Once work is under way a track only gets stricter.
15
15
  */
16
- import type { Domain } from "../schemas/agent.ts";
17
16
  import type { Task, TaskTrack, TaskTriage, TrackMember, TrackPath, TriageSize } from "../schemas/task.ts";
18
17
 
19
18
  export const TRACK_MEMBERS: readonly TrackMember[] = ["designer", "backend", "qa", "researcher"];
@@ -266,7 +265,7 @@ export function readRequest(request: string): RequestReading {
266
265
  const YES = 0.5;
267
266
  /** Below this the classifier's size is a guess, and the rules' size stands. */
268
267
  const SIZE_CONFIDENCE = 0.6;
269
- /** The classifier's "one engineer can do it alone" taken as a yes (`classifier.thresholds.quickFixAt`'s default). */
268
+ /** The classifier's "one engineer can do it alone" taken as a yes. */
270
269
  const SOLO_YES = 0.7;
271
270
 
272
271
  export interface TrackOptions {
@@ -354,56 +353,6 @@ export function satOut(task: Task, member: TrackMember): boolean {
354
353
  return Boolean(task.track?.satOut?.includes(member));
355
354
  }
356
355
 
357
- /* ------------------------------------------------------------ quick fix or task */
358
-
359
- /** Where a new request goes before any task exists: one agent now (the lobby's quick fix), or the team. */
360
- export interface RequestRoute {
361
- to: "quickfix" | "task";
362
- size: TriageSize;
363
- /** The domain that would build it: a quick feature (medium or larger) runs on its model, thinking and time limit. */
364
- builder: Domain;
365
- /** Why, a few words each. */
366
- reasons: string[];
367
- source: "classifier" | "rules";
368
- }
369
-
370
- /**
371
- * Whether one agent can do a new request alone, right away. The classifier's
372
- * "one engineer, alone, now" answer decides when it is on and sure either way
373
- * (`quickFixAt`); the rules decide otherwise: small and in one area, or
374
- * self-contained (one file, one page) even when rich. Anything serious,
375
- * unclear, large, spread across frontend and backend, or needing outside
376
- * facts goes to the team. The oracle confirms a quick fix before it runs.
377
- */
378
- export function chooseRoute(request: string, triage: TaskTriage | undefined, quickFixAt = SOLO_YES): RequestRoute {
379
- const rules = readRequest(request);
380
- const building = rules.roster.filter((member) => member === "designer" || member === "backend");
381
- const against = [
382
- rules.risk ? `serious: touches ${rules.risk}` : "",
383
- rules.unclear ? `unclear: ${rules.unclear}` : "",
384
- rules.size === "large" ? rules.reasons[0]! : "",
385
- rules.size === "medium" && !rules.solo ? rules.reasons[0]! : "",
386
- building.length > 1 ? "touches frontend and backend" : "",
387
- rules.roster.includes("researcher") ? "needs outside facts (the researcher)" : "",
388
- ].filter(Boolean);
389
- const size = triage && triage.sizeConfidence >= SIZE_CONFIDENCE ? triage.size : rules.size;
390
- const frontend = triage ? (triage.domains.designer ?? 0) >= Math.max(YES, triage.domains.backend ?? 0) : building.length === 1 && building[0] === "designer";
391
- const builder: Domain = frontend ? "designer" : "backend";
392
- const one = building.length === 1 ? (building[0] === "designer" ? "frontend" : "backend") : "one place";
393
- const base = { size, builder };
394
- const ruled: RequestRoute = against.length > 0
395
- ? { ...base, to: "task", reasons: against, source: "rules" }
396
- : { ...base, to: "quickfix", reasons: [rules.solo ? `self-contained (${rules.solo}): one agent can build it end to end` : `${rules.reasons[0]}, in ${one}`], source: "rules" };
397
- const solo = triage?.solo;
398
- if (!triage || solo === undefined) return ruled;
399
- // Serious is serious, whatever the classifier makes of the work itself.
400
- if (rules.risk) return { ...base, to: "task", reasons: [`serious: touches ${rules.risk}`], source: "rules" };
401
- if (triage.ambiguous >= YES) return { ...base, to: "task", reasons: [`the classifier reads it as ambiguous (${triage.ambiguous.toFixed(2)})`], source: "classifier" };
402
- if (solo >= quickFixAt) return { ...base, to: "quickfix", reasons: [`the classifier: one engineer can do it alone (${solo.toFixed(2)})`], source: "classifier" };
403
- if (solo <= 1 - quickFixAt) return { ...base, to: "task", reasons: [`the classifier: it needs the team (one engineer alone: ${solo.toFixed(2)})`], source: "classifier" };
404
- return { ...ruled, reasons: [...ruled.reasons, `the classifier is unsure (${solo.toFixed(2)})`] };
405
- }
406
-
407
356
  /* ------------------------------------------------------------ what a track asks of the engine */
408
357
 
409
358
  export function onFastTrack(task: Task): boolean {
@@ -914,7 +914,7 @@ function provenanceNote(files: readonly FileProvenance[] | undefined): string {
914
914
  const kinds = new Set(files.flatMap((file) => file.kinds));
915
915
  return [
916
916
  `Changed files: ${provenanceSummary(files)}.`,
917
- kinds.has("quickfix") ? "The user asked for the quick fixes directly: never revert them or send them back as fixes." : "",
917
+ kinds.has("chat") ? "The user asked for the lobby chat's changes directly: never revert them or send them back as fixes." : "",
918
918
  kinds.has("pre-existing") || kinds.has("other-task") ? "Pre-existing changes and other tasks' are not this task's to review or revert." : "",
919
919
  kinds.has("unattributed") ? "No agent recorded the unattributed edits: ask the user before counting them in or reverting them." : "",
920
920
  ]
@@ -1369,7 +1369,7 @@ function qaReport(header: string, result: ReviewResult, decision: LoopDecision,
1369
1369
  .join("\n");
1370
1370
  }
1371
1371
 
1372
- /** Changes of this task's own: its agents' (or nobody's on record), not quick fixes, other tasks' or what was there before. */
1372
+ /** Changes of this task's own: its agents' (or nobody's on record), not the lobby chat's, other tasks' or what was there before. */
1373
1373
  const TASKS_OWN = new Set(["planned", "unattributed"]);
1374
1374
 
1375
1375
  /**