@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.
- package/THIRD_PARTY_NOTICES.md +16 -3
- package/package.json +6 -1
- package/src/lobby/feed.ts +10 -0
- package/src/lobby/keys.ts +1 -0
- package/src/lobby/runtime.ts +8 -0
- package/src/pi/owner.ts +35 -4
- package/src/webui/api/index.ts +12 -0
- package/src/webui/code.ts +202 -0
- package/src/webui/dev/fake-service.ts +16 -2
- package/src/webui/protocol.ts +10 -0
- package/src/webui/static.ts +5 -2
- package/webui/dist/assets/CodeSurface-BtzJOVyH.js +448 -0
- package/webui/dist/assets/{TerminalView-D1GcmM8g.js → TerminalView-Cz-KNWsq.js} +2 -2
- package/webui/dist/assets/astro-p42oEArs.js +1 -0
- package/webui/dist/assets/c-DaO8xguW.js +1 -0
- package/webui/dist/assets/ccount-DQxSH8e6.js +1 -0
- package/webui/dist/assets/csharp-dmMxLVLW.js +1 -0
- package/webui/dist/assets/css-C8fQr_0Q.js +1 -0
- package/webui/dist/assets/diff-woXpYk--.js +1 -0
- package/webui/dist/assets/dockerfile-IyjqRm3v.js +1 -0
- package/webui/dist/assets/dotenv-CuH5my32.js +1 -0
- package/webui/dist/assets/fish-BJitypiv.js +1 -0
- package/webui/dist/assets/go-rLFTqkRN.js +1 -0
- package/webui/dist/assets/graphql-AVu4aKQX.js +1 -0
- package/webui/dist/assets/html-wSbaAAK3.js +1 -0
- package/webui/dist/assets/index-D5fvY8f8.js +112 -0
- package/webui/dist/assets/index-DFvk8ADr.css +2 -0
- package/webui/dist/assets/ini-B5eOa1yu.js +1 -0
- package/webui/dist/assets/java-DdHNQ9hk.js +1 -0
- package/webui/dist/assets/javascript-BgS3c2Ky.js +1 -0
- package/webui/dist/assets/json-qhed-kSA.js +1 -0
- package/webui/dist/assets/json5-BR5RXkoi.js +1 -0
- package/webui/dist/assets/jsonc-CYpm1nAK.js +1 -0
- package/webui/dist/assets/jsonl-CmCQp5Yx.js +1 -0
- package/webui/dist/assets/jsx-gTgmArGx.js +1 -0
- package/webui/dist/assets/kotlin-DhhofPvG.js +1 -0
- package/webui/dist/assets/kts-DFAlwc6n.js +1 -0
- package/webui/dist/assets/less-DVTAwKKz.js +1 -0
- package/webui/dist/assets/lua-xZcbXUE9.js +1 -0
- package/webui/dist/assets/makefile-Dixweg8N.js +1 -0
- package/webui/dist/assets/markdown-BYOwaDjH.js +1 -0
- package/webui/dist/assets/php-C6PDnbTe.js +1 -0
- package/webui/dist/assets/pierre-dark-CpLgRqie.js +1 -0
- package/webui/dist/assets/pierre-dark-protanopia-deuteranopia-B35FxJx-.js +1 -0
- package/webui/dist/assets/pierre-dark-soft-kZQmAZld.js +1 -0
- package/webui/dist/assets/pierre-dark-tritanopia-CpjhbsIL.js +1 -0
- package/webui/dist/assets/pierre-dark-vibrant-CpQYzh95.js +1 -0
- package/webui/dist/assets/pierre-light-CoaEpmwp.js +1 -0
- package/webui/dist/assets/pierre-light-protanopia-deuteranopia-0fSaH845.js +1 -0
- package/webui/dist/assets/pierre-light-soft-lWLdNTOI.js +1 -0
- package/webui/dist/assets/pierre-light-tritanopia-CEbqgOJL.js +1 -0
- package/webui/dist/assets/pierre-light-vibrant-D80Fkn33.js +1 -0
- package/webui/dist/assets/postcss-D_Wz94rJ.js +1 -0
- package/webui/dist/assets/powershell-QtSnmXcu.js +1 -0
- package/webui/dist/assets/prisma-DMgNhnYS.js +1 -0
- package/webui/dist/assets/properties-CxAuzHS9.js +1 -0
- package/webui/dist/assets/python-gzcpVVnB.js +1 -0
- package/webui/dist/assets/rolldown-runtime-hePW80VL.js +1 -0
- package/webui/dist/assets/ruby-CPL7DL0w.js +1 -0
- package/webui/dist/assets/rust-DpiIf5JO.js +1 -0
- package/webui/dist/assets/sass-DXrisJhu.js +1 -0
- package/webui/dist/assets/scss-CHVQqdPx.js +1 -0
- package/webui/dist/assets/shellscript-CLZ0U2zV.js +1 -0
- package/webui/dist/assets/sql-CEbI0hMM.js +1 -0
- package/webui/dist/assets/svelte-DKhxH1PN.js +1 -0
- package/webui/dist/assets/swift-CyEgAFGc.js +1 -0
- package/webui/dist/assets/toml-CcmNWLt0.js +1 -0
- package/webui/dist/assets/tsx-CUOciP0b.js +1 -0
- package/webui/dist/assets/typescript-BydbNFcO.js +1 -0
- package/webui/dist/assets/vue-kBwcnTZb.js +1 -0
- package/webui/dist/assets/wasm-BnjxR4X6.js +1 -0
- package/webui/dist/assets/xml-CeXJrFlG.js +1 -0
- package/webui/dist/assets/yaml-rwi0_p6S.js +1 -0
- package/webui/dist/assets/yml-s9iVJXCw.js +1 -0
- package/webui/dist/assets/zsh-B7CDPTg5.js +1 -0
- package/webui/dist/build.json +44 -16
- package/webui/dist/index.html +4 -2
- package/webui/dist/assets/index-Dnz2q4t0.js +0 -112
- package/webui/dist/assets/index-nzFYIE8J.css +0 -2
package/THIRD_PARTY_NOTICES.md
CHANGED
|
@@ -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
|
|
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
|
|
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.
|
|
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;
|
package/src/lobby/runtime.ts
CHANGED
|
@@ -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) =>
|
|
264
|
-
|
|
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;
|
package/src/webui/api/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/webui/protocol.ts
CHANGED
|
@@ -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>> } };
|
package/src/webui/static.ts
CHANGED
|
@@ -46,11 +46,14 @@ export function freshNonce(): string {
|
|
|
46
46
|
return randomBytes(16).toString("base64");
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
/**
|
|
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'",
|