@a-t-h-i/bot-lobby 0.6.58 → 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 (79) hide show
  1. package/THIRD_PARTY_NOTICES.md +16 -3
  2. package/package.json +6 -1
  3. package/src/lobby/feed.ts +10 -0
  4. package/src/lobby/keys.ts +1 -0
  5. package/src/lobby/runtime.ts +8 -0
  6. package/src/pi/owner.ts +35 -4
  7. package/src/webui/api/index.ts +12 -0
  8. package/src/webui/code.ts +202 -0
  9. package/src/webui/dev/fake-service.ts +16 -2
  10. package/src/webui/protocol.ts +10 -0
  11. package/src/webui/static.ts +5 -2
  12. package/webui/dist/assets/CodeSurface-BtzJOVyH.js +448 -0
  13. package/webui/dist/assets/{TerminalView-D1GcmM8g.js → TerminalView-Cz-KNWsq.js} +2 -2
  14. package/webui/dist/assets/astro-p42oEArs.js +1 -0
  15. package/webui/dist/assets/c-DaO8xguW.js +1 -0
  16. package/webui/dist/assets/ccount-DQxSH8e6.js +1 -0
  17. package/webui/dist/assets/csharp-dmMxLVLW.js +1 -0
  18. package/webui/dist/assets/css-C8fQr_0Q.js +1 -0
  19. package/webui/dist/assets/diff-woXpYk--.js +1 -0
  20. package/webui/dist/assets/dockerfile-IyjqRm3v.js +1 -0
  21. package/webui/dist/assets/dotenv-CuH5my32.js +1 -0
  22. package/webui/dist/assets/fish-BJitypiv.js +1 -0
  23. package/webui/dist/assets/go-rLFTqkRN.js +1 -0
  24. package/webui/dist/assets/graphql-AVu4aKQX.js +1 -0
  25. package/webui/dist/assets/html-wSbaAAK3.js +1 -0
  26. package/webui/dist/assets/index-D5fvY8f8.js +112 -0
  27. package/webui/dist/assets/index-DFvk8ADr.css +2 -0
  28. package/webui/dist/assets/ini-B5eOa1yu.js +1 -0
  29. package/webui/dist/assets/java-DdHNQ9hk.js +1 -0
  30. package/webui/dist/assets/javascript-BgS3c2Ky.js +1 -0
  31. package/webui/dist/assets/json-qhed-kSA.js +1 -0
  32. package/webui/dist/assets/json5-BR5RXkoi.js +1 -0
  33. package/webui/dist/assets/jsonc-CYpm1nAK.js +1 -0
  34. package/webui/dist/assets/jsonl-CmCQp5Yx.js +1 -0
  35. package/webui/dist/assets/jsx-gTgmArGx.js +1 -0
  36. package/webui/dist/assets/kotlin-DhhofPvG.js +1 -0
  37. package/webui/dist/assets/kts-DFAlwc6n.js +1 -0
  38. package/webui/dist/assets/less-DVTAwKKz.js +1 -0
  39. package/webui/dist/assets/lua-xZcbXUE9.js +1 -0
  40. package/webui/dist/assets/makefile-Dixweg8N.js +1 -0
  41. package/webui/dist/assets/markdown-BYOwaDjH.js +1 -0
  42. package/webui/dist/assets/php-C6PDnbTe.js +1 -0
  43. package/webui/dist/assets/pierre-dark-CpLgRqie.js +1 -0
  44. package/webui/dist/assets/pierre-dark-protanopia-deuteranopia-B35FxJx-.js +1 -0
  45. package/webui/dist/assets/pierre-dark-soft-kZQmAZld.js +1 -0
  46. package/webui/dist/assets/pierre-dark-tritanopia-CpjhbsIL.js +1 -0
  47. package/webui/dist/assets/pierre-dark-vibrant-CpQYzh95.js +1 -0
  48. package/webui/dist/assets/pierre-light-CoaEpmwp.js +1 -0
  49. package/webui/dist/assets/pierre-light-protanopia-deuteranopia-0fSaH845.js +1 -0
  50. package/webui/dist/assets/pierre-light-soft-lWLdNTOI.js +1 -0
  51. package/webui/dist/assets/pierre-light-tritanopia-CEbqgOJL.js +1 -0
  52. package/webui/dist/assets/pierre-light-vibrant-D80Fkn33.js +1 -0
  53. package/webui/dist/assets/postcss-D_Wz94rJ.js +1 -0
  54. package/webui/dist/assets/powershell-QtSnmXcu.js +1 -0
  55. package/webui/dist/assets/prisma-DMgNhnYS.js +1 -0
  56. package/webui/dist/assets/properties-CxAuzHS9.js +1 -0
  57. package/webui/dist/assets/python-gzcpVVnB.js +1 -0
  58. package/webui/dist/assets/rolldown-runtime-hePW80VL.js +1 -0
  59. package/webui/dist/assets/ruby-CPL7DL0w.js +1 -0
  60. package/webui/dist/assets/rust-DpiIf5JO.js +1 -0
  61. package/webui/dist/assets/sass-DXrisJhu.js +1 -0
  62. package/webui/dist/assets/scss-CHVQqdPx.js +1 -0
  63. package/webui/dist/assets/shellscript-CLZ0U2zV.js +1 -0
  64. package/webui/dist/assets/sql-CEbI0hMM.js +1 -0
  65. package/webui/dist/assets/svelte-DKhxH1PN.js +1 -0
  66. package/webui/dist/assets/swift-CyEgAFGc.js +1 -0
  67. package/webui/dist/assets/toml-CcmNWLt0.js +1 -0
  68. package/webui/dist/assets/tsx-CUOciP0b.js +1 -0
  69. package/webui/dist/assets/typescript-BydbNFcO.js +1 -0
  70. package/webui/dist/assets/vue-kBwcnTZb.js +1 -0
  71. package/webui/dist/assets/wasm-BnjxR4X6.js +1 -0
  72. package/webui/dist/assets/xml-CeXJrFlG.js +1 -0
  73. package/webui/dist/assets/yaml-rwi0_p6S.js +1 -0
  74. package/webui/dist/assets/yml-s9iVJXCw.js +1 -0
  75. package/webui/dist/assets/zsh-B7CDPTg5.js +1 -0
  76. package/webui/dist/build.json +44 -16
  77. package/webui/dist/index.html +4 -2
  78. package/webui/dist/assets/index-Dnz2q4t0.js +0 -112
  79. package/webui/dist/assets/index-nzFYIE8J.css +0 -2
@@ -4,12 +4,13 @@
4
4
 
5
5
  The web UI's look (colours, radii, shadows, control and dialog styles, the
6
6
  sidebar rail, the chat rows, the work log, the composer (its surface, footer controls and send button), the select
7
- fields and drop-down lists, the loading and status animations and the
8
- built-in colour themes), and the third-party harness support (the model
7
+ fields and drop-down lists, the loading and status animations, the code
8
+ viewer with its diff mode and the built-in colour themes), and the third-party harness support (the model
9
9
  manifest, CLI flags, prompt injection and ACP handling behind Settings >
10
10
  Harnesses), is adapted from T3 Code,
11
11
  https://github.com/pingdotgg/t3code. No T3 Code package is a
12
- dependency; the styles and markup below were ported into this repository and
12
+ dependency (the code viewer uses the same diff and highlighting library T3
13
+ Code does, `@pierre/diffs`, as an ordinary npm dependency); the styles and markup below were ported into this repository and
13
14
  rewired to bot-lobby's own data layer. Each adapted file starts with a comment
14
15
  naming the file it came from.
15
16
 
@@ -38,6 +39,9 @@ naming the file it came from.
38
39
  | `webui/src/app/Composer.tsx` | `apps/web/src/components/chat/ComposerSurface.tsx`, `ChatComposer.tsx`, `ComposerPrimaryActions.tsx`, `ComposerControl.tsx` |
39
40
  | `webui/src/app/composer/ComposerCommandMenu.tsx` | `apps/web/src/components/chat/ComposerCommandMenu.tsx`, `ComposerBanner.tsx` (the attached drawer surface), `composer-logic.ts` (the `/` and `@` trigger) |
40
41
  | `webui/src/app/TerminalDrawer.tsx` | `apps/web/src/components/ThreadTerminalDrawer.tsx` (the drawer, its floating toolbar and terminal list) |
42
+ | `webui/src/app/code/CodeViewer.tsx` | `apps/web/src/components/DiffPanel.tsx`, `DiffPanelShell.tsx` (diff layout and wrap toggles), `components/diffs/DiffFileTree.tsx` (changed files list), `components/files/FilePreviewPanel.tsx` (file header) |
43
+ | `webui/src/app/code/CodeSurface.tsx` | `apps/web/src/components/files/FilePreviewPanel.tsx` (`EditableFileSurface`), `components/files/ReadOnlySourcePreview.tsx`, `components/DiffPanel.tsx` |
44
+ | `webui/src/app/code/codeRendering.ts` | `apps/web/src/lib/diffRendering.ts` (`DIFF_SURFACE_THEME_UNSAFE_CSS`, `resolveDiffThemeName`), `lib/syntaxHighlighting.ts` (`PREFERRED_HIGHLIGHTER`), `components/diffs/StyledDiffCodeView.tsx`, `components/files/fileSurfaceChrome.tsx` |
41
45
  | `webui/src/tabs/lobby/InlineActivity.tsx` | `apps/web/src/components/chat/WorkLog.tsx` |
42
46
  | `src/webui/palette-core.ts` (the built-in themes) | `packages/shared/src/themePalettes.ts` (Blossom is their T3 Chat theme, renamed) |
43
47
  | `src/harness/model-manifest.json` (copied as is) | `apps/server/src/provider/model-manifest.json` |
@@ -88,3 +92,12 @@ Jakarta Sans Project Authors), Figtree (The Figtree Project Authors), Outfit
88
92
  Grotesk (The Space Grotesk Project Authors), Source Sans 3 and Source Code Pro
89
93
  (Adobe), Fira Code (The Fira Code Project Authors) and Roboto Mono (The Roboto
90
94
  Mono Project Authors).
95
+
96
+ ## Code viewer libraries
97
+
98
+ The code viewer's chunks in `webui/dist/assets` bundle `@pierre/diffs`,
99
+ `@pierre/theming` and `@pierre/theme` (The Pierre Computer Company, Apache
100
+ License 2.0, https://www.apache.org/licenses/LICENSE-2.0), Shiki and its
101
+ grammars (`shiki`, `@shikijs/*`, Pine Wu and Anthony Fu, MIT; each grammar
102
+ keeps its own licence, listed in `@shikijs/langs`) and `diff` (Kevin Decker,
103
+ BSD 3-Clause).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a-t-h-i/bot-lobby",
3
- "version": "0.6.58",
3
+ "version": "0.6.59",
4
4
  "description": "Structured multi-agent software engineering orchestrator for Pi",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -73,7 +73,12 @@
73
73
  "@fontsource-variable/source-code-pro": "^5.3.1",
74
74
  "@fontsource-variable/source-sans-3": "^5.3.1",
75
75
  "@fontsource-variable/space-grotesk": "^5.3.0",
76
+ "@pierre/diffs": "^1.5.2",
76
77
  "@playwright/test": "1.62.0",
78
+ "@shikijs/core": "^4.5.0",
79
+ "@shikijs/engine-javascript": "^4.5.0",
80
+ "@shikijs/engine-oniguruma": "^4.5.0",
81
+ "@shikijs/langs": "^4.5.0",
77
82
  "@tailwindcss/vite": "4.3.3",
78
83
  "@types/node": "^22.10.0",
79
84
  "@types/react": "19.3.0",
package/src/lobby/feed.ts CHANGED
@@ -133,6 +133,11 @@ export class LobbyFeed {
133
133
  if (this.settle(undefined, key, isError ? "error" : undefined)) this.touch();
134
134
  }
135
135
 
136
+ /** Settle every step `source` still has open (a turn that ended without closing its tool calls). */
137
+ endAll(source: string): void {
138
+ if (this.settle(source, undefined)) this.touch();
139
+ }
140
+
136
141
  /** The next step of an agent that reports one step at a time: the previous one settles. */
137
142
  step(source: string, text: string, key: string, at = Date.now()): void {
138
143
  this.settle(undefined, key);
@@ -365,11 +370,16 @@ export function chatText(role: "user" | "assistant", text: string): Array<{ role
365
370
  const request = /\nRequest: ([\s\S]*?)(?:\nState: |$)/.exec(body)?.[1]?.trim();
366
371
  return [{ role: "note", text: `task started · ${kickoff[2]}` }, ...(request ? [{ role: "you" as const, text: request }] : [])];
367
372
  }
373
+ // Auto mode's nudge to keep going (pi/owner.ts autoNudge) shows as one short line.
374
+ if (/^Auto mode: \S+ is \S+\. Keep driving it to completion/.test(body)) return [{ role: "note", text: AUTO_NOTE }];
368
375
  const comment = /^The user left (?:a comment|\d+ comments) on (the approved plan|the proposal) of (\S+) from the lobby:/.exec(body);
369
376
  if (comment) return [{ role: "note", text: `your comment on ${comment[1]} went to the oracle` }];
370
377
  return [{ role: "you", text: body }];
371
378
  }
372
379
 
380
+ /** What the conversation shows where auto mode kept the oracle going. */
381
+ export const AUTO_NOTE = "auto mode";
382
+
373
383
  /** The session entry bot-lobby leaves where the oracle's context starts over (see pi/fresh-context.ts). */
374
384
  export const CONTEXT_MARK = "bot-lobby-context";
375
385
 
package/src/lobby/keys.ts CHANGED
@@ -13,6 +13,7 @@ export const LOBBY_ACTIONS = {
13
13
  activity: { key: "alt+a", help: "open or fold every step in the Lobby conversation" },
14
14
  thinking: { key: "alt+t", help: "show or hide Thinking on the Lobby" },
15
15
  terminal: { key: "ctrl+`", help: "show or hide the terminal" },
16
+ code: { key: "alt+e", help: "open or close the code viewer: changed files, diffs and editing" },
16
17
  nextTab: { key: "alt+]", help: "next tab" },
17
18
  prevTab: { key: "alt+[", help: "previous tab" },
18
19
  } as const;
@@ -195,6 +195,7 @@ export function startLobbyService(pi: ExtensionAPI, ctx: ExtensionContext, confi
195
195
  if (event.kind === "comments") lobbyFeed.log("LOBBY", `passed ${event.count} plan comment${event.count === 1 ? "" : "s"} on ${event.taskId} to the oracle`, "info");
196
196
  else if (event.kind === "inbox" || event.kind === "messages") lobbyFeed.log("LOBBY", `passed ${event.count} message${event.count === 1 ? "" : "s"} from another session to the oracle`, "info");
197
197
  else if (event.kind === "auto") lobbyFeed.log("LOBBY", `auto mode ${event.on ? "on" : "off"} for ${event.taskId}`, event.on ? "success" : "info");
198
+ else if (event.kind === "held") lobbyFeed.log("LOBBY", `auto mode: you stopped the oracle on ${event.taskId}; it waits for your next message`, "info");
198
199
  else if (event.kind === "nudge") lobbyFeed.log("LOBBY", `auto mode: keeping the oracle going on ${event.taskId}`, "info");
199
200
  else lobbyFeed.log("LOBBY", `auto mode: no progress on ${event.taskId} — it needs you`, "warning");
200
201
  });
@@ -333,6 +334,9 @@ export function registerLobbyEvents(pi: ExtensionAPI, configDir: string): void {
333
334
  const finished = turn;
334
335
  turn = undefined;
335
336
  lobbyFeed.replyEnd();
337
+ // A tool call cut off by a stop or a steer never ends on its own; the turn's end closes it.
338
+ lobbyFeed.endAll("MASTER");
339
+ lobbyTopics.bump("status");
336
340
  const taskId = finished?.taskId ?? currentZenTask()?.id;
337
341
  if (!finished || !taskId || !runtime) return;
338
342
  appendMetrics(runtime.root, configDir, [{
@@ -352,6 +356,10 @@ export function registerLobbyEvents(pi: ExtensionAPI, configDir: string): void {
352
356
  taskId,
353
357
  }]);
354
358
  lobbyTopics.bump("metrics");
359
+ });
360
+ // pi is idle only once the run has settled (after agent_end, retries and continuations): the page's busy flag rereads then.
361
+ pi.on("agent_settled", () => {
362
+ lobbyFeed.endAll("MASTER");
355
363
  lobbyTopics.bump("status");
356
364
  });
357
365
  }
package/src/pi/owner.ts CHANGED
@@ -32,7 +32,8 @@ export type OwnerEvent =
32
32
  | { kind: "messages"; count: number }
33
33
  | { kind: "auto"; taskId: string; on: boolean }
34
34
  | { kind: "nudge"; taskId: string }
35
- | { kind: "stalled"; taskId: string };
35
+ | { kind: "stalled"; taskId: string }
36
+ | { kind: "held"; taskId: string };
36
37
 
37
38
  interface Owner {
38
39
  pi: ExtensionAPI;
@@ -43,6 +44,8 @@ interface Owner {
43
44
  configDir: string;
44
45
  timer?: ReturnType<typeof setInterval>;
45
46
  auto?: AutoTrack;
47
+ /** The user stopped the oracle: auto mode waits until they start a turn again or switch it. */
48
+ held?: boolean;
46
49
  /** The session's task as this tick read it: one read serves the whole tick. */
47
50
  tick?: { task: Task | undefined };
48
51
  }
@@ -177,7 +180,7 @@ export function driveAuto(): boolean {
177
180
  state.auto = undefined;
178
181
  return false;
179
182
  }
180
- if (!state.ctx.isIdle()) return false;
183
+ if (state.held || !state.ctx.isIdle()) return false;
181
184
  const step = autoStep(state.auto, taskFingerprint(task));
182
185
  state.auto = step.track;
183
186
  if (step.stalledNow) {
@@ -199,6 +202,7 @@ export function setAuto(root: string, configDir: string, taskId: string, on: boo
199
202
  const task = ownTask(state);
200
203
  if (task?.id !== taskId) return;
201
204
  state.auto = undefined;
205
+ state.held = false;
202
206
  if (on) driveAuto();
203
207
  }
204
208
 
@@ -213,6 +217,27 @@ export function toggleOwnAuto(): string {
213
217
  return on ? `auto mode on for ${task.id}: the oracle drives it to completion without asking you` : `auto mode off for ${task.id}: the oracle asks you again`;
214
218
  }
215
219
 
220
+ /** Whether a finished turn was stopped by the user (its last reply was aborted). */
221
+ export function stoppedTurn(messages: readonly unknown[] | undefined): boolean {
222
+ const last = [...(messages ?? [])].reverse().find((message) => (message as { role?: string })?.role === "assistant") as { stopReason?: string } | undefined;
223
+ return last?.stopReason === "aborted";
224
+ }
225
+
226
+ /**
227
+ * A turn ended. One the user stopped holds auto mode, so the next tick does
228
+ * not start the task again; any turn that starts after it (the user's next
229
+ * message) lets auto mode carry on.
230
+ */
231
+ export function turnEnded(messages: readonly unknown[] | undefined): void {
232
+ const state = owner;
233
+ if (!state || !stoppedTurn(messages)) return;
234
+ const task = ownTask(state);
235
+ if (state.held || !live(task) || !isAutoMode(state.root, state.configDir, task.id)) return;
236
+ state.held = true;
237
+ state.auto = undefined;
238
+ listener?.({ kind: "held", taskId: task.id });
239
+ }
240
+
216
241
  /** One tick of the owner's clock. */
217
242
  export function ownerTick(): void {
218
243
  const state = owner;
@@ -260,8 +285,14 @@ export function registerOwner(pi: ExtensionAPI, configDir: string): void {
260
285
  };
261
286
  pi.on("session_start", (_event, ctx) => startOwner(pi, ctx, configDir));
262
287
  pi.on("session_shutdown", () => stop());
263
- pi.on("agent_start", (_event, ctx) => track(ctx));
264
- pi.on("agent_end", (_event, ctx) => track(ctx));
288
+ pi.on("agent_start", (_event, ctx) => {
289
+ track(ctx);
290
+ if (owner) owner.held = false;
291
+ });
292
+ pi.on("agent_end", (event, ctx) => {
293
+ track(ctx);
294
+ turnEnded((event as { messages?: readonly unknown[] }).messages);
295
+ });
265
296
  // Auto mode has nobody to answer a question: tell the oracle to decide instead.
266
297
  pi.on("tool_call", (event, ctx) => {
267
298
  if (event.toolName !== "ask_user_question" || !owner) return undefined;
@@ -25,6 +25,7 @@ import { terminalClose, terminalInput, terminalList, terminalOpen, terminalResiz
25
25
  import { MAX_INPUT_CHARS } from "../terminal.ts";
26
26
  import { themesChoose, themesGet, themesRemove, themesRename, themesSave } from "./themes.ts";
27
27
  import { noticeText, pushNotice } from "../notices.ts";
28
+ import { codeChanges, codeRead, codeSave } from "../code.ts";
28
29
 
29
30
  /** What a handler reads besides the request body. */
30
31
  export interface ApiContext {
@@ -360,6 +361,17 @@ function buildRoutes(): Record<string, Route> {
360
361
  schema: Type.Object({ text: Type.String({ maxLength: 20_000 }) }, { additionalProperties: false }),
361
362
  run: (body, ctx) => issuesCreate(body as { text: string }, ctx),
362
363
  };
364
+ const codeRoot = (ctx: ApiContext) => ctx.service.projectRoot?.() ?? process.cwd();
365
+ const CodePath = Type.String({ minLength: 1, maxLength: 4096 });
366
+ routes["code.changes"] = { schema: Empty, run: (_body, ctx) => codeChanges(codeRoot(ctx)) };
367
+ routes["code.read"] = {
368
+ schema: Type.Object({ path: CodePath }, { additionalProperties: false }),
369
+ run: (body, ctx) => codeRead(codeRoot(ctx), (body as { path: string }).path),
370
+ };
371
+ routes["code.save"] = {
372
+ schema: Type.Object({ path: CodePath, contents: Type.String({ maxLength: 900_000 }), mtimeMs: Type.Optional(Type.Number()), force: Type.Optional(Type.Boolean()) }, { additionalProperties: false }),
373
+ run: (body, ctx) => codeSave(codeRoot(ctx), body as { path: string; contents: string; mtimeMs?: number; force?: boolean }),
374
+ };
363
375
  routes["settings.get"] = { schema: Empty, run: (_body, ctx) => settingsGet(ctx) };
364
376
  routes["settings.set"] = {
365
377
  // The patch's shape is the normaliser's job (`resolveConfig`); only a plain object is required here.
@@ -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
+ }
@@ -306,6 +306,9 @@ function streamReply(service: object, feed: LobbyFeed, fixture: ScenarioFixture)
306
306
  }
307
307
  }
308
308
 
309
+ /** How long a scripted @ mention reply takes, so the page shows the agent at work first. */
310
+ const ANSWER_MS = 1500;
311
+
309
312
  /** A `LobbyService` answering from the `name` scenario's fixtures. */
310
313
  export function createFixtureService(name: string, feed = new LobbyFeed()): LobbyService {
311
314
  const fixture = loadScenario(name);
@@ -318,6 +321,8 @@ export function createFixtureService(name: string, feed = new LobbyFeed()): Lobb
318
321
  const auto = new Set(fixture.autoTasks ?? []);
319
322
  const comments = new Map<string, Array<Record<string, unknown>>>(Object.entries(fixture.taskComments ?? {}));
320
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;
321
326
  const archivedBackgrounds: BackgroundSession[] = [];
322
327
  let sessionName = fixture.status.sessionName;
323
328
  let planning = fakePlanner(fixture.mockPlanner ?? defaultPlanner());
@@ -344,7 +349,8 @@ export function createFixtureService(name: string, feed = new LobbyFeed()): Lobb
344
349
  const service = {
345
350
  sessionId: () => fixture.status.sessionId,
346
351
  sessionName: () => sessionName,
347
- masterBusy: () => fixture.status.busy,
352
+ masterBusy: () => fixture.status.busy || answering !== undefined,
353
+ answering: () => answering,
348
354
  config: () => config,
349
355
  linters: () => fixture.settings?.linters ?? [{ tool: "ESLint", folder: "", installed: true }, { tool: "Ruff", folder: "services/api", installed: false }],
350
356
  saveConfig: (next: BotLobbyConfig) => {
@@ -389,8 +395,16 @@ export function createFixtureService(name: string, feed = new LobbyFeed()): Lobb
389
395
  const mention = parseMention(text);
390
396
  if (mention && mention.agent !== "master") {
391
397
  const source = CHAT_AGENT_SOURCES[mention.agent];
398
+ answering = source;
392
399
  feed.say("you", mention.rest, Date.now(), { to: source });
393
- const reply = setTimeout(() => feed.say("oracle", `${source} here: looked into it, and it is done.`, Date.now(), { agent: source }), 400);
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);
394
408
  unref(reply);
395
409
  return undefined;
396
410
  }
@@ -20,6 +20,13 @@ import type { HarnessModel } from "../harness/catalog.ts";
20
20
  export interface ProjectInfo { id: string; name: string; cwd: string; port: number }
21
21
  export interface FolderListing { path: string; parent?: string; folders: Array<{ name: string; path: string }>; truncated: boolean }
22
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
+
23
30
  export type { WebPrompt };
24
31
  export type { LobbyTopic };
25
32
  export type { DialogAnswer, SessionDialog, TaskRow, PlanComment };
@@ -610,6 +617,9 @@ export interface Api {
610
617
  "planner.previousDelete": { request: { id: string }; result: { notice: string } };
611
618
  "planner.previousOpen": { request: { id: string }; result: { notice: string } };
612
619
  "planner.save": { request: Record<string, never>; result: { notice: 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 };
613
623
  "projects.browse": { request: { path?: string }; result: FolderListing };
614
624
  "projects.open": { request: { path: string }; result: { project: ProjectInfo } };
615
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'",