@a-t-h-i/bot-lobby 0.6.3 → 0.6.4

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 (57) hide show
  1. package/README.md +102 -11
  2. package/package.json +1 -4
  3. package/prompts/master.md +47 -1
  4. package/prompts/researcher.md +8 -2
  5. package/prompts/reviewer.md +42 -0
  6. package/prompts/worker.md +7 -0
  7. package/src/ask/dialog.ts +167 -0
  8. package/src/ask/image.ts +202 -0
  9. package/src/ask/png.ts +179 -0
  10. package/src/ask/relay.ts +89 -0
  11. package/src/ask/state.ts +160 -0
  12. package/src/ask/tool.ts +126 -0
  13. package/src/ask/types.ts +55 -0
  14. package/src/ask/view.ts +159 -0
  15. package/src/execution/agent-runner.ts +103 -11
  16. package/src/execution/git.ts +111 -14
  17. package/src/execution/pi-runner.ts +164 -11
  18. package/src/index.ts +6 -0
  19. package/src/lobby/ask.ts +10 -120
  20. package/src/lobby/layout.ts +27 -8
  21. package/src/lobby/markdown.ts +36 -6
  22. package/src/lobby/planner.ts +1 -1
  23. package/src/lobby/quickfix.ts +21 -0
  24. package/src/lobby/runtime.ts +14 -36
  25. package/src/lobby/tabs/home.ts +112 -48
  26. package/src/lobby/tabs/issues.ts +4 -3
  27. package/src/lobby/tabs/plan.ts +4 -4
  28. package/src/lobby/tabs/quickfix.ts +7 -1
  29. package/src/lobby/tabs/tasks.ts +14 -4
  30. package/src/lobby/theme.ts +30 -0
  31. package/src/lobby/view.ts +27 -5
  32. package/src/master/decisions.ts +1 -1
  33. package/src/master/master.ts +41 -4
  34. package/src/master/research.ts +5 -2
  35. package/src/pi/commands.ts +94 -10
  36. package/src/pi/events.ts +47 -10
  37. package/src/pi/quiet.ts +22 -4
  38. package/src/pi/start-task.ts +8 -2
  39. package/src/pi/tools.ts +26 -8
  40. package/src/pi/ui.ts +6 -1
  41. package/src/pi/zen-metrics.ts +13 -3
  42. package/src/pi/zen.ts +16 -9
  43. package/src/roles/reviewer.ts +23 -4
  44. package/src/roles/worker.ts +18 -0
  45. package/src/schemas/configuration.ts +4 -0
  46. package/src/schemas/findings.ts +14 -0
  47. package/src/schemas/task.ts +21 -0
  48. package/src/state/budget.ts +274 -0
  49. package/src/state/changes.ts +231 -0
  50. package/src/text.ts +28 -2
  51. package/src/web/extract.ts +332 -0
  52. package/src/web/fetch.ts +232 -0
  53. package/src/web/html.ts +183 -0
  54. package/src/web/read.ts +113 -0
  55. package/src/web/search.ts +202 -0
  56. package/src/web/tools.ts +279 -0
  57. package/src/workflow/workflow.ts +592 -42
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Images in the questionnaire: a screenshot or rendered mockup an option
3
+ * carries, drawn in its preview. Terminals that speak the Kitty graphics
4
+ * protocol (Kitty, Ghostty, WezTerm) get the image itself; everywhere else a
5
+ * PNG is drawn with coloured half-blocks, which any colour terminal shows and
6
+ * which sit in a box like text. Other formats there get a line saying what
7
+ * and where the file is. `BOT_LOBBY_IMAGES=blocks` always draws blocks;
8
+ * `off` never draws images.
9
+ */
10
+ import { readFile, stat } from "node:fs/promises";
11
+ import { basename } from "node:path";
12
+ import { getCapabilities, getImageDimensions, renderImage, type ImageDimensions } from "@earendil-works/pi-tui";
13
+ import { paint, type LobbyTheme } from "../lobby/layout.ts";
14
+ import { decodePng, type Pixels } from "./png.ts";
15
+
16
+ /** Larger files are not read. */
17
+ export const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
18
+
19
+ export type ImageMime = "image/png" | "image/jpeg" | "image/gif" | "image/webp";
20
+
21
+ export interface LoadedImage {
22
+ path: string;
23
+ mime: ImageMime;
24
+ base64: string;
25
+ dimensions: ImageDimensions;
26
+ /** Decoded on first use, for the half-block drawing (PNG only). */
27
+ pixels?: Pixels | string;
28
+ /** Kitty reuses one id per image so a redraw replaces it. */
29
+ kittyId?: number;
30
+ /** Half-block drawings already made, by size: every key press redraws the questionnaire. */
31
+ blocks?: Map<string, string[]>;
32
+ }
33
+
34
+ /** An image option whose file could not be read, and why. */
35
+ export interface MissingImage {
36
+ path: string;
37
+ error: string;
38
+ }
39
+
40
+ export type ImagePreview = LoadedImage | MissingImage;
41
+
42
+ const EXTENSIONS = /\.(png|jpe?g|gif|webp)$/i;
43
+
44
+ export function isImagePath(path: string): boolean {
45
+ return EXTENSIONS.test(path.trim());
46
+ }
47
+
48
+ function sniff(bytes: Uint8Array): ImageMime | undefined {
49
+ if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) return "image/png";
50
+ if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return "image/jpeg";
51
+ if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46) return "image/gif";
52
+ if (bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46 && bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50) return "image/webp";
53
+ return undefined;
54
+ }
55
+
56
+ /** Read an option's image file: a PNG, JPEG, GIF or WebP of at most 10 MB. */
57
+ export async function loadImage(path: string): Promise<ImagePreview> {
58
+ try {
59
+ const info = await stat(path);
60
+ if (!info.isFile()) return { path, error: "not a file" };
61
+ if (info.size > MAX_IMAGE_BYTES) return { path, error: `too large to show (${Math.round(info.size / 1024 / 1024)} MB)` };
62
+ const bytes = new Uint8Array(await readFile(path));
63
+ const mime = sniff(bytes);
64
+ if (!mime) return { path, error: "not a PNG, JPEG, GIF or WebP image" };
65
+ const base64 = Buffer.from(bytes).toString("base64");
66
+ const dimensions = getImageDimensions(base64, mime) ?? { widthPx: 800, heightPx: 600 };
67
+ return { path, mime, base64, dimensions };
68
+ } catch (error) {
69
+ const code = (error as { code?: string }).code;
70
+ return { path, error: code === "ENOENT" ? "no such file" : error instanceof Error ? error.message : String(error) };
71
+ }
72
+ }
73
+
74
+ /** Every option image the questions carry, read once before the questionnaire opens. */
75
+ export async function loadImages(paths: readonly string[]): Promise<Map<string, ImagePreview>> {
76
+ const unique = [...new Set(paths.filter(Boolean))];
77
+ const loaded = await Promise.all(unique.map((path) => loadImage(path)));
78
+ return new Map(unique.map((path, index) => [path, loaded[index]!]));
79
+ }
80
+
81
+ type Mode = "native" | "blocks" | "off";
82
+
83
+ function mode(): Mode {
84
+ const chosen = (process.env.BOT_LOBBY_IMAGES ?? "").trim().toLowerCase();
85
+ if (chosen === "off" || chosen === "blocks") return chosen;
86
+ return getCapabilities().images === "kitty" ? "native" : "blocks";
87
+ }
88
+
89
+ function pixelsOf(image: LoadedImage): Pixels | string {
90
+ if (image.pixels === undefined) {
91
+ if (image.mime !== "image/png") image.pixels = "only PNG images can be drawn in this terminal";
92
+ else {
93
+ try {
94
+ image.pixels = decodePng(Buffer.from(image.base64, "base64"));
95
+ } catch (error) {
96
+ image.pixels = error instanceof Error ? error.message : String(error);
97
+ }
98
+ }
99
+ }
100
+ return image.pixels;
101
+ }
102
+
103
+ /** The cell grid an image fits in: at most `columns` × `rows`, its aspect kept (a cell is about twice as tall as wide). */
104
+ function fitCells(dimensions: ImageDimensions, columns: number, rows: number): { columns: number; rows: number } {
105
+ const aspect = dimensions.heightPx / Math.max(1, dimensions.widthPx);
106
+ let width = Math.max(1, columns);
107
+ let height = Math.max(1, Math.round((width * aspect) / 2));
108
+ if (height > rows) {
109
+ height = Math.max(1, rows);
110
+ width = Math.max(1, Math.min(columns, Math.round((height * 2) / aspect)));
111
+ }
112
+ return { columns: width, rows: height };
113
+ }
114
+
115
+ /** Average the pixels of one block of the image, over the box's dark background where they are see-through. */
116
+ function averageColor(pixels: Pixels, x0: number, y0: number, x1: number, y1: number): [number, number, number] {
117
+ let r = 0;
118
+ let g = 0;
119
+ let b = 0;
120
+ let count = 0;
121
+ const backdrop = 24;
122
+ for (let y = y0; y < Math.max(y1, y0 + 1); y += 1) {
123
+ for (let x = x0; x < Math.max(x1, x0 + 1); x += 1) {
124
+ const at = (Math.min(y, pixels.height - 1) * pixels.width + Math.min(x, pixels.width - 1)) * 4;
125
+ const alpha = pixels.data[at + 3]! / 255;
126
+ r += pixels.data[at]! * alpha + backdrop * (1 - alpha);
127
+ g += pixels.data[at + 1]! * alpha + backdrop * (1 - alpha);
128
+ b += pixels.data[at + 2]! * alpha + backdrop * (1 - alpha);
129
+ count += 1;
130
+ }
131
+ }
132
+ return [Math.round(r / count), Math.round(g / count), Math.round(b / count)];
133
+ }
134
+
135
+ /** Colour escape for a cell's foreground or background: 24-bit where the terminal has it, else the nearest of the 256. */
136
+ function colour(rgb: [number, number, number], background: boolean, trueColor: boolean): string {
137
+ const [r, g, b] = rgb;
138
+ if (trueColor) return `\x1b[${background ? 48 : 38};2;${r};${g};${b}m`;
139
+ const level = (value: number) => (value < 48 ? 0 : value < 115 ? 1 : Math.min(5, Math.floor((value - 35) / 40)));
140
+ return `\x1b[${background ? 48 : 38};5;${16 + 36 * level(r) + 6 * level(g) + level(b)}m`;
141
+ }
142
+
143
+ /** An image as rows of half-blocks: each cell's upper half is the foreground colour, its lower half the background. */
144
+ export function halfBlocks(pixels: Pixels, columns: number, rows: number, trueColor = true): string[] {
145
+ const cells = fitCells({ widthPx: pixels.width, heightPx: pixels.height }, columns, rows);
146
+ const lines: string[] = [];
147
+ const stepX = pixels.width / cells.columns;
148
+ const stepY = pixels.height / (cells.rows * 2);
149
+ for (let row = 0; row < cells.rows; row += 1) {
150
+ let line = "";
151
+ for (let column = 0; column < cells.columns; column += 1) {
152
+ const x0 = Math.floor(column * stepX);
153
+ const x1 = Math.floor((column + 1) * stepX);
154
+ const top = averageColor(pixels, x0, Math.floor(row * 2 * stepY), x1, Math.floor((row * 2 + 1) * stepY));
155
+ const bottom = averageColor(pixels, x0, Math.floor((row * 2 + 1) * stepY), x1, Math.floor((row * 2 + 2) * stepY));
156
+ line += `${colour(top, false, trueColor)}${colour(bottom, true, trueColor)}▀`;
157
+ }
158
+ lines.push(`${line}\x1b[0m`);
159
+ }
160
+ return lines;
161
+ }
162
+
163
+ function describe(preview: ImagePreview): string {
164
+ if ("error" in preview) return `${basename(preview.path)}: ${preview.error}`;
165
+ return `${basename(preview.path)} · ${preview.dimensions.widthPx}×${preview.dimensions.heightPx} ${preview.mime.slice(6).toUpperCase()}`;
166
+ }
167
+
168
+ /**
169
+ * An option's image drawn in at most `columns` × `rows` cells, with a caption
170
+ * line under it naming the file; or, where it cannot be drawn, lines saying
171
+ * what and where it is.
172
+ */
173
+ export function imageLines(preview: ImagePreview, columns: number, rows: number, theme?: LobbyTheme): string[] {
174
+ const caption = paint(theme, "dim", describe(preview));
175
+ if ("error" in preview) return [paint(theme, "warning", `Image not shown: ${describe(preview)}`)];
176
+ const room = Math.max(1, rows - 1);
177
+ const how = mode();
178
+ if (how === "native") {
179
+ const size = fitCells(preview.dimensions, columns, room);
180
+ const drawn = renderImage(preview.base64, preview.dimensions, { maxWidthCells: size.columns, maxHeightCells: size.rows, moveCursor: false, ...(preview.kittyId ? { imageId: preview.kittyId } : {}) });
181
+ if (drawn) {
182
+ if (drawn.imageId) preview.kittyId = drawn.imageId;
183
+ return [drawn.sequence, ...Array.from({ length: Math.max(0, drawn.rows - 1) }, () => ""), caption];
184
+ }
185
+ }
186
+ if (how !== "off") {
187
+ const pixels = pixelsOf(preview);
188
+ if (typeof pixels !== "string") {
189
+ const trueColor = getCapabilities().trueColor !== false;
190
+ const key = `${columns}x${room}:${trueColor}`;
191
+ preview.blocks ??= new Map();
192
+ let drawn = preview.blocks.get(key);
193
+ if (!drawn) {
194
+ drawn = halfBlocks(pixels, columns, room, trueColor);
195
+ preview.blocks.set(key, drawn);
196
+ }
197
+ return [...drawn, caption];
198
+ }
199
+ return [paint(theme, "muted", `${describe(preview)}: ${pixels}; open ${preview.path} to see it`)];
200
+ }
201
+ return [paint(theme, "muted", `${describe(preview)}: open ${preview.path} to see it`)];
202
+ }
package/src/ask/png.ts ADDED
@@ -0,0 +1,179 @@
1
+ /**
2
+ * A small PNG decoder: every colour type and bit depth, interlaced or not,
3
+ * into 8-bit RGBA. Enough to draw a screenshot as coloured blocks in any
4
+ * terminal; no dependency (zlib is Node's).
5
+ */
6
+ import { inflateSync } from "node:zlib";
7
+
8
+ export interface Pixels {
9
+ width: number;
10
+ height: number;
11
+ /** RGBA, 4 bytes per pixel, row by row. */
12
+ data: Uint8Array;
13
+ }
14
+
15
+ const SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
16
+ /** Larger images are not decoded (a screenshot is far smaller). */
17
+ const MAX_PIXELS = 50_000_000;
18
+ const CHANNELS: Record<number, number> = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 };
19
+ /** Adam7: each pass's first column and row, and the steps between them. */
20
+ const ADAM7 = [
21
+ [0, 0, 8, 8],
22
+ [4, 0, 8, 8],
23
+ [0, 4, 4, 8],
24
+ [2, 0, 4, 4],
25
+ [0, 2, 2, 4],
26
+ [1, 0, 2, 2],
27
+ [0, 1, 1, 2],
28
+ ] as const;
29
+
30
+ export function isPng(bytes: Uint8Array): boolean {
31
+ return bytes.length >= 8 && SIGNATURE.every((value, index) => bytes[index] === value);
32
+ }
33
+
34
+ function paeth(a: number, b: number, c: number): number {
35
+ const p = a + b - c;
36
+ const pa = Math.abs(p - a);
37
+ const pb = Math.abs(p - b);
38
+ const pc = Math.abs(p - c);
39
+ return pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
40
+ }
41
+
42
+ /** Undo the per-row filters of one (sub)image in place, returning its raw rows. */
43
+ function unfilter(source: Uint8Array, offset: number, rowBytes: number, rows: number, bpp: number): { rows: Uint8Array[]; next: number } {
44
+ const out: Uint8Array[] = [];
45
+ let previous = new Uint8Array(rowBytes);
46
+ let at = offset;
47
+ for (let row = 0; row < rows; row += 1) {
48
+ if (at + 1 + rowBytes > source.length) throw new Error("the PNG image data is cut short");
49
+ const filter = source[at]!;
50
+ const line = source.slice(at + 1, at + 1 + rowBytes);
51
+ at += 1 + rowBytes;
52
+ for (let index = 0; index < rowBytes; index += 1) {
53
+ const left = index >= bpp ? line[index - bpp]! : 0;
54
+ const up = previous[index]!;
55
+ const corner = index >= bpp ? previous[index - bpp]! : 0;
56
+ switch (filter) {
57
+ case 0:
58
+ break;
59
+ case 1:
60
+ line[index] = (line[index]! + left) & 255;
61
+ break;
62
+ case 2:
63
+ line[index] = (line[index]! + up) & 255;
64
+ break;
65
+ case 3:
66
+ line[index] = (line[index]! + ((left + up) >> 1)) & 255;
67
+ break;
68
+ case 4:
69
+ line[index] = (line[index]! + paeth(left, up, corner)) & 255;
70
+ break;
71
+ default:
72
+ throw new Error(`unknown PNG filter ${filter}`);
73
+ }
74
+ }
75
+ out.push(line);
76
+ previous = line;
77
+ }
78
+ return { rows: out, next: at };
79
+ }
80
+
81
+ /** Decode a PNG into RGBA pixels; throws with a reason when it cannot. */
82
+ export function decodePng(bytes: Uint8Array): Pixels {
83
+ if (!isPng(bytes)) throw new Error("not a PNG file");
84
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
85
+ let width = 0;
86
+ let height = 0;
87
+ let depth = 8;
88
+ let type = 6;
89
+ let interlace = 0;
90
+ let palette: Uint8Array | undefined;
91
+ let alphas: Uint8Array | undefined;
92
+ let transparent: number[] | undefined;
93
+ const data: Uint8Array[] = [];
94
+ for (let at = 8; at + 8 <= bytes.length; ) {
95
+ const length = view.getUint32(at);
96
+ const name = String.fromCharCode(...bytes.subarray(at + 4, at + 8));
97
+ const body = bytes.subarray(at + 8, at + 8 + length);
98
+ at += 12 + length;
99
+ if (name === "IHDR") {
100
+ const header = new DataView(body.buffer, body.byteOffset, body.byteLength);
101
+ width = header.getUint32(0);
102
+ height = header.getUint32(4);
103
+ depth = body[8]!;
104
+ type = body[9]!;
105
+ interlace = body[12]!;
106
+ } else if (name === "PLTE") palette = body;
107
+ else if (name === "tRNS") {
108
+ if (type === 3) alphas = body;
109
+ else if (type === 0 && body.length >= 2) transparent = [(body[0]! << 8) | body[1]!];
110
+ else if (type === 2 && body.length >= 6) transparent = [(body[0]! << 8) | body[1]!, (body[2]! << 8) | body[3]!, (body[4]! << 8) | body[5]!];
111
+ } else if (name === "IDAT") data.push(body);
112
+ else if (name === "IEND") break;
113
+ }
114
+ const channels = CHANNELS[type];
115
+ if (!width || !height || channels === undefined) throw new Error("the PNG header is missing or unsupported");
116
+ if (width * height > MAX_PIXELS) throw new Error(`the PNG is too large to draw (${width}×${height})`);
117
+ if (type === 3 && !palette) throw new Error("the PNG palette is missing");
118
+ const joined = new Uint8Array(data.reduce((total, chunk) => total + chunk.length, 0));
119
+ let offset = 0;
120
+ for (const chunk of data) {
121
+ joined.set(chunk, offset);
122
+ offset += chunk.length;
123
+ }
124
+ const raw = inflateSync(joined);
125
+ const bitsPerPixel = channels * depth;
126
+ const bpp = Math.max(1, bitsPerPixel >> 3);
127
+ const out = new Uint8Array(width * height * 4);
128
+ const max = (1 << depth) - 1;
129
+
130
+ const sample = (row: Uint8Array, index: number): number => {
131
+ if (depth === 8) return row[index]!;
132
+ if (depth === 16) return (row[index * 2]! << 8) | row[index * 2 + 1]!;
133
+ const bit = index * depth;
134
+ return (row[bit >> 3]! >> (8 - depth - (bit & 7))) & max;
135
+ };
136
+ const to8 = (value: number) => (depth === 16 ? value >> 8 : depth === 8 ? value : Math.round((value * 255) / max));
137
+
138
+ const put = (rowData: Uint8Array, column: number, x: number, y: number) => {
139
+ const target = (y * width + x) * 4;
140
+ const base = column * channels;
141
+ if (type === 3) {
142
+ const index = sample(rowData, column);
143
+ out[target] = palette![index * 3] ?? 0;
144
+ out[target + 1] = palette![index * 3 + 1] ?? 0;
145
+ out[target + 2] = palette![index * 3 + 2] ?? 0;
146
+ out[target + 3] = alphas?.[index] ?? 255;
147
+ return;
148
+ }
149
+ const values = Array.from({ length: channels }, (_, channel) => sample(rowData, base + channel));
150
+ const gray = type === 0 || type === 4;
151
+ const [r, g, b] = gray ? [values[0]!, values[0]!, values[0]!] : [values[0]!, values[1]!, values[2]!];
152
+ const alpha = type === 4 ? to8(values[1]!) : type === 6 ? to8(values[3]!) : 255;
153
+ const clear = transparent && (gray ? values[0] === transparent[0] : values[0] === transparent[0] && values[1] === transparent[1] && values[2] === transparent[2]);
154
+ out[target] = to8(r);
155
+ out[target + 1] = to8(g);
156
+ out[target + 2] = to8(b);
157
+ out[target + 3] = clear ? 0 : alpha;
158
+ };
159
+
160
+ if (interlace === 0) {
161
+ const { rows } = unfilter(raw, 0, Math.ceil((width * bitsPerPixel) / 8), height, bpp);
162
+ rows.forEach((row, y) => {
163
+ for (let x = 0; x < width; x += 1) put(row, x, x, y);
164
+ });
165
+ } else {
166
+ let at = 0;
167
+ for (const [x0, y0, dx, dy] of ADAM7) {
168
+ const passWidth = Math.ceil((width - x0) / dx);
169
+ const passHeight = Math.ceil((height - y0) / dy);
170
+ if (passWidth <= 0 || passHeight <= 0) continue;
171
+ const pass = unfilter(raw, at, Math.ceil((passWidth * bitsPerPixel) / 8), passHeight, bpp);
172
+ at = pass.next;
173
+ pass.rows.forEach((row, py) => {
174
+ for (let px = 0; px < passWidth; px += 1) put(row, px, x0 + px * dx, y0 + py * dy);
175
+ });
176
+ }
177
+ }
178
+ return { width, height, data: out };
179
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Asking the user from a subagent. A subagent runs as `pi --mode rpc` with no
3
+ * terminal of its own, so its `ask_user_question` goes out as an RPC editor
4
+ * dialog carrying the questions (marked with `RELAY_TITLE`); the runner in the
5
+ * master process answers it by putting the questionnaire to the user and
6
+ * sends the answers back the same way. Only runs the master lets ask (the
7
+ * designer's) get the tool: it sets `ASK_ENV` for them.
8
+ */
9
+ import { tmpdir } from "node:os";
10
+ import { isAbsolute, join, resolve } from "node:path";
11
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
12
+ import type { Asker } from "./dialog.ts";
13
+ import { MAX_OPTIONS, MAX_QUESTIONS, type AskQuestion, type AskResult } from "./types.ts";
14
+
15
+ /** Env flag the master sets for a subagent that may ask the user. */
16
+ export const ASK_ENV = "BOT_LOBBY_ASK";
17
+ /** Title of the RPC dialog that carries a relayed questionnaire. */
18
+ export const RELAY_TITLE = "bot-lobby:ask_user_question";
19
+
20
+ export function relayEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
21
+ return env[ASK_ENV] === "1";
22
+ }
23
+
24
+ /** Where an agent saves the images it shows the user for a task: outside the repository, so they are never part of its change. */
25
+ export function previewDir(taskId: string): string {
26
+ return join(tmpdir(), "bot-lobby-previews", taskId.replace(/[^\w.-]/g, "_"));
27
+ }
28
+
29
+ /** Image paths made absolute, so the master finds them whatever its cwd. */
30
+ function withAbsoluteImages(questions: readonly AskQuestion[], cwd: string): AskQuestion[] {
31
+ return questions.map((question) => ({
32
+ ...question,
33
+ options: question.options.map((option) => (option.image && !isAbsolute(option.image) ? { ...option, image: resolve(cwd, option.image) } : option)),
34
+ }));
35
+ }
36
+
37
+ /** The answers the master sent back, or undefined when they do not read as answers. */
38
+ export function readRelayAnswer(value: string | undefined): AskResult | undefined {
39
+ if (value === undefined) return undefined;
40
+ try {
41
+ const parsed = JSON.parse(value) as Partial<AskResult>;
42
+ if (!Array.isArray(parsed.answers) || typeof parsed.cancelled !== "boolean") return undefined;
43
+ return { answers: parsed.answers, cancelled: parsed.cancelled, ...(typeof parsed.globalNote === "string" ? { globalNote: parsed.globalNote } : {}) };
44
+ } catch {
45
+ return undefined;
46
+ }
47
+ }
48
+
49
+ const text = (value: unknown): value is string => typeof value === "string";
50
+
51
+ /** One relayed question as the questionnaire can show it, or undefined when it is not one. */
52
+ function asQuestion(value: unknown): AskQuestion | undefined {
53
+ const raw = value as Partial<AskQuestion> | null;
54
+ if (!raw || !text(raw.question) || !text(raw.header) || !Array.isArray(raw.options)) return undefined;
55
+ const options = raw.options
56
+ .filter((option) => option && text(option.label))
57
+ .slice(0, MAX_OPTIONS)
58
+ .map((option) => ({
59
+ label: option.label,
60
+ ...(text(option.description) ? { description: option.description } : {}),
61
+ ...(text(option.preview) ? { preview: option.preview } : {}),
62
+ ...(text(option.image) ? { image: option.image } : {}),
63
+ }));
64
+ if (options.length === 0) return undefined;
65
+ return { question: raw.question, header: raw.header, options, ...(raw.multiSelect === true ? { multiSelect: true } : {}) };
66
+ }
67
+
68
+ /**
69
+ * The questions a relayed dialog carries, or undefined when it is not one.
70
+ * The master takes only what the questionnaire can show from them: the
71
+ * subagent checked them, but its process is not trusted to have.
72
+ */
73
+ export function readRelayRequest(title: string | undefined, prefill: string | undefined): AskQuestion[] | undefined {
74
+ if (title !== RELAY_TITLE || !prefill) return undefined;
75
+ try {
76
+ const parsed = JSON.parse(prefill) as { questions?: unknown };
77
+ if (!Array.isArray(parsed.questions)) return undefined;
78
+ const questions = parsed.questions.slice(0, MAX_QUESTIONS).map(asQuestion);
79
+ return questions.length > 0 && questions.every(Boolean) ? (questions as AskQuestion[]) : undefined;
80
+ } catch {
81
+ return undefined;
82
+ }
83
+ }
84
+
85
+ /** Inside a subagent: the questions go to the master as an RPC dialog, and its answers come back. */
86
+ export const relayAsker: Asker = async (questions, ctx: ExtensionContext) => {
87
+ const value = await ctx.ui.editor(RELAY_TITLE, JSON.stringify({ questions: withAbsoluteImages(questions, ctx.cwd) }));
88
+ return readRelayAnswer(value) ?? { answers: [], cancelled: true };
89
+ };
@@ -0,0 +1,160 @@
1
+ /**
2
+ * The questionnaire as state and keys, with no terminal in sight: each key
3
+ * press is one pure step, so every path (pick, toggle, type your own answer,
4
+ * move between questions, submit, cancel) is tested directly.
5
+ *
6
+ * Every question lists its options, then a row for the user's own answer.
7
+ * Enter on an option answers a single-choice question and moves on; space
8
+ * toggles options of a multi-choice one and enter moves on. Answering the
9
+ * last question submits; ←/→ move between questions first. Esc stops typing,
10
+ * or puts the questions away.
11
+ */
12
+ import type { AskAnswer, AskQuestion, AskResult } from "./types.ts";
13
+
14
+ export type AskKey =
15
+ | { type: "up" }
16
+ | { type: "down" }
17
+ | { type: "left" }
18
+ | { type: "right" }
19
+ | { type: "enter" }
20
+ | { type: "space" }
21
+ | { type: "escape" }
22
+ | { type: "backspace" }
23
+ /** 1-9: pick (or toggle) that option. */
24
+ | { type: "digit"; value: number }
25
+ /** Typed or pasted text, only used while writing an answer. */
26
+ | { type: "text"; value: string };
27
+
28
+ export interface AskState {
29
+ questions: readonly AskQuestion[];
30
+ /** The question in view. */
31
+ tab: number;
32
+ /** The focused row of each question: an option index, or `options.length` for the own-answer row. */
33
+ cursor: number[];
34
+ /** Options picked in each question (one for a single-choice question). */
35
+ picked: number[][];
36
+ /** The user's own answer to each question, when they wrote one. */
37
+ typed: Array<string | undefined>;
38
+ /** Writing the own answer of the question in view. */
39
+ editing: boolean;
40
+ draft: string;
41
+ /** Set once the user submits or puts the questions away. */
42
+ result?: AskResult;
43
+ }
44
+
45
+ export function initialState(questions: readonly AskQuestion[]): AskState {
46
+ return {
47
+ questions,
48
+ tab: 0,
49
+ cursor: questions.map(() => 0),
50
+ picked: questions.map(() => []),
51
+ typed: questions.map(() => undefined),
52
+ editing: false,
53
+ draft: "",
54
+ };
55
+ }
56
+
57
+ /** Whether a question has an answer yet. */
58
+ export function isAnswered(state: AskState, index: number): boolean {
59
+ return (state.picked[index]?.length ?? 0) > 0 || Boolean(state.typed[index]?.trim());
60
+ }
61
+
62
+ /** The answers given, in the order asked; unanswered questions are left out. */
63
+ export function answersOf(state: AskState): AskAnswer[] {
64
+ const answers: AskAnswer[] = [];
65
+ state.questions.forEach((question, index) => {
66
+ const picked = (state.picked[index] ?? []).map((option) => question.options[option]!.label);
67
+ const own = state.typed[index]?.trim();
68
+ if (question.multiSelect) {
69
+ const selected = own ? [...picked, own] : picked;
70
+ if (selected.length > 0) answers.push({ questionIndex: index, question: question.question, kind: "multi", answer: selected.join(", "), selected });
71
+ } else if (own) answers.push({ questionIndex: index, question: question.question, kind: "custom", answer: own });
72
+ else if (picked[0]) answers.push({ questionIndex: index, question: question.question, kind: "option", answer: picked[0] });
73
+ });
74
+ return answers;
75
+ }
76
+
77
+ function set<T>(list: readonly T[], index: number, value: T): T[] {
78
+ const next = [...list];
79
+ next[index] = value;
80
+ return next;
81
+ }
82
+
83
+ /** The next question, or the submitted result after the last one. */
84
+ function advance(state: AskState): AskState {
85
+ if (state.tab < state.questions.length - 1) return { ...state, tab: state.tab + 1, editing: false, draft: "" };
86
+ return { ...state, editing: false, draft: "", result: { answers: answersOf(state), cancelled: false } };
87
+ }
88
+
89
+ function choose(state: AskState, option: number): AskState {
90
+ const question = state.questions[state.tab]!;
91
+ if (option < 0 || option >= question.options.length) return state;
92
+ if (question.multiSelect) {
93
+ const picked = state.picked[state.tab] ?? [];
94
+ const next = picked.includes(option) ? picked.filter((entry) => entry !== option) : [...picked, option].sort((a, b) => a - b);
95
+ return { ...state, cursor: set(state.cursor, state.tab, option), picked: set(state.picked, state.tab, next) };
96
+ }
97
+ // One answer to a single-choice question: picking an option replaces any words of the user's own.
98
+ return advance({ ...state, cursor: set(state.cursor, state.tab, option), picked: set(state.picked, state.tab, [option]), typed: set(state.typed, state.tab, undefined) });
99
+ }
100
+
101
+ function typing(state: AskState, key: AskKey): AskState {
102
+ switch (key.type) {
103
+ case "text":
104
+ return { ...state, draft: state.draft + key.value.replace(/[\r\n\t]+/g, " ") };
105
+ case "space":
106
+ return { ...state, draft: `${state.draft} ` };
107
+ case "digit":
108
+ return { ...state, draft: state.draft + String(key.value) };
109
+ case "backspace":
110
+ return { ...state, draft: [...state.draft].slice(0, -1).join("") };
111
+ case "escape":
112
+ return { ...state, editing: false, draft: "" };
113
+ case "enter": {
114
+ const own = state.draft.trim();
115
+ const question = state.questions[state.tab]!;
116
+ const typed = set(state.typed, state.tab, own || undefined);
117
+ // Your own words answer a single-choice question instead of a picked option.
118
+ const picked = own && !question.multiSelect ? set(state.picked, state.tab, []) : state.picked;
119
+ const next = { ...state, typed, picked, editing: false, draft: "" };
120
+ return own ? advance(next) : next;
121
+ }
122
+ default:
123
+ return state;
124
+ }
125
+ }
126
+
127
+ /** One key press. */
128
+ export function step(state: AskState, key: AskKey): AskState {
129
+ if (state.result) return state;
130
+ if (state.editing) return typing(state, key);
131
+ const question = state.questions[state.tab];
132
+ if (!question) return { ...state, result: { answers: [], cancelled: false } };
133
+ const rows = question.options.length + 1;
134
+ const cursor = state.cursor[state.tab] ?? 0;
135
+ switch (key.type) {
136
+ case "up":
137
+ return { ...state, cursor: set(state.cursor, state.tab, (cursor - 1 + rows) % rows) };
138
+ case "down":
139
+ return { ...state, cursor: set(state.cursor, state.tab, (cursor + 1) % rows) };
140
+ case "left":
141
+ return state.tab > 0 ? { ...state, tab: state.tab - 1 } : state;
142
+ case "right":
143
+ return state.tab < state.questions.length - 1 ? { ...state, tab: state.tab + 1 } : state;
144
+ case "digit":
145
+ return choose(state, key.value - 1);
146
+ case "space":
147
+ return cursor < question.options.length && question.multiSelect ? choose(state, cursor) : state;
148
+ case "enter": {
149
+ if (cursor === question.options.length) return { ...state, editing: true, draft: state.typed[state.tab] ?? "" };
150
+ if (!question.multiSelect) return choose(state, cursor);
151
+ // A multi-choice question moves on with what is picked; with nothing picked, enter picks the focused option.
152
+ if (!isAnswered(state, state.tab)) return advance(choose(state, cursor));
153
+ return advance(state);
154
+ }
155
+ case "escape":
156
+ return { ...state, result: { answers: answersOf(state), cancelled: true } };
157
+ default:
158
+ return state;
159
+ }
160
+ }