@a-t-h-i/bot-lobby 0.6.48 → 0.6.49

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a-t-h-i/bot-lobby",
3
- "version": "0.6.48",
3
+ "version": "0.6.49",
4
4
  "description": "Structured multi-agent software engineering orchestrator for Pi",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -125,7 +125,21 @@ describe it: ask with `ask_user_question` and give every option an
125
125
  `htmlPreview`, a mockup of that option in HTML and CSS, laid out for a page
126
126
  about 1200px wide, with the real content, palette and proportions of what you
127
127
  would build, in the project's existing design language, never grey
128
- placeholder boxes. Make every mockup interactive: use real `button`, `input`,
128
+ placeholder boxes.
129
+
130
+ Build mockups from the project's real styles whenever it has them, not from a
131
+ lookalike. Find the stylesheets that carry its design tokens, theme and
132
+ component styles (for example `src/index.css`, `app/globals.css`, a
133
+ `theme.css`) and name them in the mockup's `stylesheets`: their rules go in
134
+ front of your CSS, Tailwind `@theme` tokens included, so `var(--primary)`, the
135
+ font stacks, the radii and the project's own classes work in the mockup. Use
136
+ the markup and class names of the project's existing components (read them
137
+ first), and write CSS only for what the option changes. Utility classes that
138
+ only exist after a build (Tailwind's `px-3`, `bg-primary`) do not reach the
139
+ mockup: write those few rules yourself with the project's variables. When the
140
+ styles live in JavaScript (CSS-in-JS, a theme object), copy their actual
141
+ values into the mockup's CSS. Only a project with no UI yet gets styles you
142
+ choose, and then you ask first. Make every mockup interactive: use real `button`, `input`,
129
143
  `select`, `textarea`, `label` and `details`/`summary` elements with their
130
144
  hover, focus, active, checked and open states, and CSS transitions and
131
145
  `@keyframes` for the motion the option proposes (a pop-over opening, a
package/prompts/master.md CHANGED
@@ -144,7 +144,10 @@ direction), show the choices rather than describe them. When you ask about a
144
144
  visual decision, use `ask_user_question` and give each option an
145
145
  `htmlPreview` mockup (HTML and CSS of that option in the project's existing
146
146
  design language, laid out for a page about 1200px wide, with real controls and
147
- CSS states and animations so the user can try it); the user expands the
147
+ CSS states and animations so the user can try it). When the project has
148
+ stylesheets, name them in the mockup's `stylesheets` so it wears the project's
149
+ real tokens and classes, and build it from the project's own component markup;
150
+ the user expands the
148
151
  mockups and scrolls through the options side by side. When the choice needs real design work, delegate it to
149
152
  the designer, who asks with its own mockups. In planning, the panel draws
150
153
  mockups under its options the same way.
@@ -0,0 +1,64 @@
1
+ /**
2
+ * A mockup in the project's own clothes. An `htmlPreview` can name the
3
+ * project's stylesheets (`stylesheets: ["src/index.css"]`); before the
4
+ * question is asked their rules are read and put in front of the mockup's own
5
+ * CSS, so the mockup wears the project's real tokens, fonts stacks and
6
+ * component classes instead of an approximation of them. Tailwind v4 sources
7
+ * are made readable for a browser on the way: `@theme` blocks become `:root`
8
+ * (their variables are the design tokens) and build-time directives
9
+ * (`@import`, `@plugin`, `@apply`, …) are dropped. Paths outside the project,
10
+ * files that are not CSS and anything past the size cap are refused.
11
+ */
12
+ import { readFileSync, realpathSync, statSync } from "node:fs";
13
+ import { extname, isAbsolute, relative, resolve } from "node:path";
14
+ import { MAX_PREVIEW_CSS } from "./types.ts";
15
+
16
+ /** How many stylesheets one mockup may name. */
17
+ export const MAX_STYLESHEETS = 6;
18
+
19
+ /** The browser-readable rules of one project stylesheet. */
20
+ export function browserCss(source: string): string {
21
+ return source
22
+ .replace(/\/\*[\s\S]*?\*\//g, "")
23
+ .replace(/@theme(\s+(inline|static|reference))*\s*\{/g, ":root {")
24
+ .replace(/@(import|plugin|source|config|reference|custom-variant|tailwind)\b[^;{]*;/g, "")
25
+ .replace(/@apply\b[^;}]*;?/g, "")
26
+ .replace(/url\([^)]*\)/g, "none")
27
+ .replace(/image-set\([^;]*\)/g, "none")
28
+ // The preview refuses a whole stylesheet that holds a backslash (escaped class names, CSS escapes), so those rules go.
29
+ .replace(/[^{}]*\{[^{}]*\}/g, (rule) => (rule.includes("\\") ? "" : rule))
30
+ .replace(/^[^{}\n]*\\[^{}\n]*;\s*$/gm, "")
31
+ .replace(/(expression|behavior|-moz-binding)[^;}]*;?/gi, "")
32
+ .replace(/\n{3,}/g, "\n\n")
33
+ .trim();
34
+ }
35
+
36
+ /** Read the named stylesheets from the project; throws with a reason the model can act on. */
37
+ export function projectStyles(root: string, paths: readonly string[]): string {
38
+ if (paths.length > MAX_STYLESHEETS) throw new Error(`name at most ${MAX_STYLESHEETS} stylesheets`);
39
+ const base = realpathSync(root);
40
+ const parts: string[] = [];
41
+ for (const path of paths) {
42
+ if (![".css", ".pcss"].includes(extname(path).toLowerCase())) throw new Error(`${path} is not a .css file`);
43
+ let file: string;
44
+ try {
45
+ file = realpathSync(resolve(base, path));
46
+ } catch {
47
+ throw new Error(`${path} does not exist in the project`);
48
+ }
49
+ const inside = relative(base, file);
50
+ if (!inside || inside.startsWith("..") || isAbsolute(inside)) throw new Error(`${path} is outside the project`);
51
+ if (!statSync(file).isFile()) throw new Error(`${path} is not a file`);
52
+ parts.push(`/* ${inside} */\n${browserCss(readFileSync(file, "utf8"))}`);
53
+ }
54
+ return parts.join("\n");
55
+ }
56
+
57
+ /** The mockup's CSS with the project's stylesheets in front; throws when the whole would not fit. */
58
+ export function withProjectStyles(root: string, preview: { html: string; css?: string; stylesheets?: string[] }): { html: string; css?: string } {
59
+ const { stylesheets, ...rest } = preview;
60
+ if (!stylesheets?.length) return rest;
61
+ const css = [projectStyles(root, stylesheets), rest.css ?? ""].filter(Boolean).join("\n");
62
+ if (css.length > MAX_PREVIEW_CSS) throw new Error(`the stylesheets and the mockup's CSS come to ${css.length} characters, over the ${MAX_PREVIEW_CSS} a mockup can carry: name the file with the design tokens and the components the mockup uses, not the whole build`);
63
+ return { ...rest, css };
64
+ }
package/src/ask/tool.ts CHANGED
@@ -12,9 +12,10 @@ import { Type } from "typebox";
12
12
  import { isSubagentProcess } from "../pi/quiet.ts";
13
13
  import { askUser } from "./web.ts";
14
14
  import { relayAsker, relayEnabled } from "./relay.ts";
15
- import { ASK_TOOL, isImagePath, validHtmlPreview, MAX_PREVIEW_HTML, MAX_PREVIEW_CSS, MAX_HEADER, MAX_LABEL, MAX_OPTIONS, MAX_QUESTIONS, MIN_OPTIONS, RESERVED, type AskQuestion, type AskResult, type Asker } from "./types.ts";
15
+ import { ASK_TOOL, isImagePath, validHtmlPreview, MAX_PREVIEW_HTML, MAX_PREVIEW_CSS, MAX_HEADER, MAX_LABEL, MAX_OPTIONS, MAX_QUESTIONS, MIN_OPTIONS, RESERVED, type AskOption, type AskQuestion, type AskResult, type Asker } from "./types.ts";
16
16
 
17
17
  import { detectProjectRoot } from "../state/project.ts";
18
+ import { MAX_STYLESHEETS, withProjectStyles } from "./project-styles.ts";
18
19
  import { ownedTask } from "../state/persistence.ts";
19
20
  import { withBlockingRequest } from "../state/blocking-requests.ts";
20
21
 
@@ -24,6 +25,7 @@ const OptionSchema = Type.Object({
24
25
  htmlPreview: Type.Optional(Type.Object({
25
26
  html: Type.String({ maxLength: MAX_PREVIEW_HTML, description: "HTML of a mockup of this option. Controls (button, input, select, textarea, label, details/summary) stay live and CSS transitions and keyframes run, so the user can try it; scripts, forms, images, fonts and links are stripped." }),
26
27
  css: Type.Optional(Type.String({ maxLength: MAX_PREVIEW_CSS })),
28
+ stylesheets: Type.Optional(Type.Array(Type.String(), { maxItems: MAX_STYLESHEETS, description: "Project-relative paths of the project's own CSS files (design tokens, theme, component styles, e.g. \"src/index.css\") whose rules go in front of `css`, so the mockup uses the project's real variables and classes. Tailwind `@theme` tokens are kept; build-time directives such as `@apply` are dropped. Together with `css` at most " + MAX_PREVIEW_CSS + " characters." })),
27
29
  }, { additionalProperties: false, description: "An interactive HTML/CSS mockup of this option (no scripts: interaction through CSS states), laid out for a page about 1200px wide: shown as a thumbnail beside the question, which the user expands to scroll through every option's mockup. Give one to every option of a visual question (a layout, a screen, a component's look)." })),
28
30
  label: Type.String({ maxLength: MAX_LABEL, description: `The option as the user sees and picks it: 1-5 words, at most ${MAX_LABEL} characters.` }),
29
31
  description: Type.Optional(Type.String({ description: "What choosing it means: its trade-offs or consequences. Markdown." })),
@@ -73,6 +75,23 @@ export function invalidQuestions(questions: readonly AskQuestion[]): string | un
73
75
  return undefined;
74
76
  }
75
77
 
78
+ /** Each mockup that names project stylesheets gets their rules in front of its own CSS. */
79
+ export function dressMockups(questions: readonly AskQuestion[], root: string | undefined): AskQuestion[] {
80
+ return questions.map((question, index) => ({
81
+ ...question,
82
+ options: question.options.map((option) => {
83
+ const preview = option.htmlPreview as (AskOption["htmlPreview"] & { stylesheets?: string[] }) | undefined;
84
+ if (!preview?.stylesheets) return option;
85
+ if (!root) throw new Error(`question ${index + 1}: the project's stylesheets cannot be read here; put the styles in \`css\`.`);
86
+ try {
87
+ return { ...option, htmlPreview: withProjectStyles(root, preview) };
88
+ } catch (error) {
89
+ throw new Error(`question ${index + 1}, "${option.label}": ${error instanceof Error ? error.message : String(error)}.`);
90
+ }
91
+ }),
92
+ }));
93
+ }
94
+
76
95
  /** What the model does with questions the user left unanswered: wait, never fill them in. */
77
96
  const STILL_OPEN = "Do not assume answers, do not pick the recommended options, and do not go on with work that depends on them. Say in one short line that the questions are waiting, end your turn, and put them to the user again when they next write.";
78
97
  const NOT_ANSWERED = `The user left the questions without answering. ${STILL_OPEN}`;
@@ -119,11 +138,11 @@ export function registerAskTool(pi: ExtensionAPI, ask?: Asker, configDir = ".pi"
119
138
  ],
120
139
  parameters: AskParams,
121
140
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
122
- const questions = (params as { questions: AskQuestion[] }).questions;
123
- const invalid = invalidQuestions(questions);
124
- if (invalid) throw new Error(`${invalid}.`);
125
141
  // A context without a cwd or session cannot be attributed to a task, so the ask runs untracked.
126
142
  const root = ctx.cwd ? detectProjectRoot(ctx.cwd, configDir) : undefined;
143
+ const questions = dressMockups((params as { questions: AskQuestion[] }).questions, root ?? ctx.cwd);
144
+ const invalid = invalidQuestions(questions);
145
+ if (invalid) throw new Error(`${invalid}.`);
127
146
  const sessionId = ctx.sessionManager?.getSessionId();
128
147
  const task = subagent || !root || !sessionId ? undefined : ownedTask(root, configDir, sessionId);
129
148
  const tracked = task && root && sessionId ? { root, configDir, taskId: task.id, sessionId } : undefined;
package/src/ask/types.ts CHANGED
@@ -17,7 +17,7 @@ export const MAX_OPTIONS = 4;
17
17
  export const MAX_HEADER = 16;
18
18
  export const MAX_LABEL = 60;
19
19
  export const MAX_PREVIEW_HTML = 100_000;
20
- export const MAX_PREVIEW_CSS = 50_000;
20
+ export const MAX_PREVIEW_CSS = 120_000;
21
21
 
22
22
  /** The row under every question's options where the user writes their own answer. */
23
23
  export const OWN_ANSWER = "Type something.";
package/src/ask/web.ts CHANGED
@@ -3,18 +3,36 @@
3
3
  * the prompt hub, and waits there for the answer. Nothing is drawn in the
4
4
  * terminal. A question nobody can answer (no page is served, as in a print
5
5
  * run) is put away at once, so a model never waits on a screen that is not
6
- * there.
6
+ * there. A background session (started "in a new session" from the lobby) has
7
+ * no page of its own: its questions go to the window that started it, as RPC
8
+ * dialogs, and that window puts them in its own pop-up.
7
9
  */
8
10
  import { isAbsolute, resolve } from "node:path";
9
11
  import { currentWebServer } from "../webui/server.ts";
10
12
  import { promptHub } from "../lobby/prompt-hub.ts";
13
+ import { relayAsker } from "./relay.ts";
14
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
11
15
  import { validHtmlPreview, type AskAnswer, type AskQuestion, type AskResult, type Asker } from "./types.ts";
12
16
 
13
17
  let availability: (() => boolean) | undefined;
14
18
 
15
- /** Whether the page is up to answer. */
19
+ /** Env flag the lobby sets on the headless sessions it starts. */
20
+ export const BACKGROUND_ENV = "BOT_LOBBY_BACKGROUND";
21
+
22
+ /** Whether this pi is a background session, asking through the window that started it. */
23
+ export function isBackgroundSession(env: NodeJS.ProcessEnv = process.env): boolean {
24
+ // Its subagents inherit the flag; they ask through their own runner, never the window directly.
25
+ return env[BACKGROUND_ENV] === "1" && env.BOT_LOBBY_SUBAGENT !== "1";
26
+ }
27
+
28
+ /** Whether questions go through the starting window rather than a page of this pi's own. */
29
+ function viaWindow(): boolean {
30
+ return currentWebServer() === undefined && isBackgroundSession();
31
+ }
32
+
33
+ /** Whether the page (this pi's, or the starting window's) is up to answer. */
16
34
  export function canAsk(): boolean {
17
- return availability ? availability() : currentWebServer() !== undefined;
35
+ return availability ? availability() : currentWebServer() !== undefined || isBackgroundSession();
18
36
  }
19
37
 
20
38
  /** Tests say whether a page is there to answer. */
@@ -31,7 +49,7 @@ function withAbsoluteImages(questions: readonly AskQuestion[], cwd: string): Ask
31
49
  }
32
50
 
33
51
  /** What the page sent back, kept to the shape of a result; anything else reads as put away. */
34
- function readResult(value: unknown): AskResult | undefined {
52
+ export function readResult(value: unknown): AskResult | undefined {
35
53
  const raw = value as Partial<AskResult> | null;
36
54
  if (!raw || !Array.isArray(raw.answers)) return undefined;
37
55
  return {
@@ -45,20 +63,23 @@ function readResult(value: unknown): AskResult | undefined {
45
63
  export const askUser: Asker = async (questions, ctx, signal, from) => {
46
64
  if (questions.some((question) => question.options.some((option) => option.htmlPreview !== undefined && !validHtmlPreview(option.htmlPreview)))) throw new Error("Invalid static HTML/CSS preview");
47
65
  if (questions.length === 0 || !canAsk()) return { answers: [], cancelled: true };
66
+ if (viaWindow()) return relayAsker(questions, ctx, signal, from);
48
67
  const outcome = await promptHub.ask("questionnaire", from ?? "oracle", { questions: withAbsoluteImages(questions, ctx.cwd) }, signal ? { signal } : undefined);
49
68
  return (outcome.how === "answered" ? readResult(outcome.value) : undefined) ?? { answers: [], cancelled: true };
50
69
  };
51
70
 
52
71
  /** A free-text answer (possibly several lines) in the page; undefined when put away. */
53
- export async function askText(question: string, from = "orchestrate"): Promise<string | undefined> {
72
+ export async function askText(question: string, from = "orchestrate", ctx?: Pick<ExtensionContext, "ui">): Promise<string | undefined> {
54
73
  if (!canAsk()) return undefined;
74
+ if (viaWindow()) return ctx ? (await ctx.ui.editor(question, ""))?.trim() || undefined : undefined;
55
75
  const outcome = await promptHub.ask("text", from, { question });
56
76
  return outcome.how === "answered" && typeof outcome.value === "string" ? outcome.value : undefined;
57
77
  }
58
78
 
59
79
  /** One of `options` picked in the page; undefined when put away. */
60
- export async function askChoice(title: string, options: string[], from = "orchestrate"): Promise<string | undefined> {
80
+ export async function askChoice(title: string, options: string[], from = "orchestrate", ctx?: Pick<ExtensionContext, "ui">): Promise<string | undefined> {
61
81
  if (!canAsk()) return undefined;
82
+ if (viaWindow()) return ctx ? ctx.ui.select(title, options) : undefined;
62
83
  const outcome = await promptHub.ask("choose", from, { title, options });
63
84
  return outcome.how === "answered" && typeof outcome.value === "string" ? outcome.value : undefined;
64
85
  }
@@ -37,6 +37,7 @@ import { deliverComments, setAuto } from "../pi/owner.ts";
37
37
  import { chatFromEntries, lobbyFeed, type ChatEntry } from "./feed.ts";
38
38
  import { availableClassifiers, classifier, effortFor, hintsFor } from "../classifier/instance.ts";
39
39
  import { launchPi, SessionRegistry, type BackgroundSession, type SessionLauncher } from "./sessions.ts";
40
+ import { askForSession } from "./session-questions.ts";
40
41
  import { SessionChats } from "./session-files.ts";
41
42
  import { answerMessage, questionnaires, settledQuestions } from "./ask.ts";
42
43
  import { askUser } from "../ask/web.ts";
@@ -270,7 +271,7 @@ function startSession(state: Runtime, start: { request?: string; plan?: PlannedT
270
271
  // A request may lead with flags (`--worktree add login`); the session is named after what follows them.
271
272
  const name = taskName(plan?.title ?? (stripStartFlags(request!) || request!));
272
273
  try {
273
- const session = sessionRegistry().start(state.ctx.cwd, { name, projectRoot: state.root, onDialog: dialogTracker(state), ...(plan ? { planId: plan.id } : { request: request! }), ...(start.auto ? { auto: true } : {}) }, sessionModel(state.ctx));
274
+ const session = sessionRegistry().start(state.ctx.cwd, { name, projectRoot: state.root, onDialog: dialogTracker(state), ask: askForSession, ...(plan ? { planId: plan.id } : { request: request! }), ...(start.auto ? { auto: true } : {}) }, sessionModel(state.ctx));
274
275
  lobbyFeed.log("LOBBY", `started "${name}" in a new session`, "success");
275
276
  return session;
276
277
  } catch (error) {
@@ -284,7 +285,7 @@ export function resumeSession(state: Runtime, start: Relaunch): BackgroundSessio
284
285
  const session = sessionRegistry().start(state.ctx.cwd, {
285
286
  name: start.name,
286
287
  projectRoot: state.root,
287
- onDialog: dialogTracker(state),
288
+ onDialog: dialogTracker(state), ask: askForSession,
288
289
  sessionFile: start.sessionFile,
289
290
  ...(start.message ? { message: start.message } : {}),
290
291
  });
@@ -374,7 +375,7 @@ async function resumeTask(state: Runtime, taskId: string): Promise<{ notice: str
374
375
  const session = sessionRegistry().start(state.ctx.cwd, {
375
376
  name: task.title,
376
377
  projectRoot: state.root,
377
- onDialog: dialogTracker(state),
378
+ onDialog: dialogTracker(state), ask: askForSession,
378
379
  taskId,
379
380
  ...(file ? { sessionFile: file } : {}),
380
381
  message: `/bot-lobby carry-on ${taskId}`,
@@ -0,0 +1,49 @@
1
+ /**
2
+ * A background session's questions, asked wherever the user is. A session
3
+ * started "in a new session" runs headless, so every question it asks (its
4
+ * oracle's questionnaire, a pick, a line of text, a yes/no) reaches this
5
+ * window as an RPC dialog. Each one is put in this window's question queue,
6
+ * the same pop-up the user's own session asks in, named after the session it
7
+ * comes from, so nobody has to switch sessions to answer. The answer goes
8
+ * back to the session; a dialog answered some other way (or a session that
9
+ * ends) takes its question back out of the queue.
10
+ */
11
+ import { readRelayRequest } from "../ask/relay.ts";
12
+ import { readResult } from "../ask/web.ts";
13
+ import { promptHub, type PromptHub } from "./prompt-hub.ts";
14
+ import type { DialogAnswer, SessionDialog } from "./sessions.ts";
15
+
16
+ export interface SessionQuestion {
17
+ /** The session's name, shown as who asks. */
18
+ from: string;
19
+ dialog: SessionDialog;
20
+ signal: AbortSignal;
21
+ }
22
+
23
+ /** Put a session's dialog to the user in the question pop-up; undefined when it was taken back first. */
24
+ export async function askForSession({ from, dialog, signal }: SessionQuestion, hub: PromptHub = promptHub): Promise<DialogAnswer | undefined> {
25
+ const options = { signal };
26
+ const relayed = dialog.method === "editor" ? readRelayRequest(dialog.title, dialog.prefill) : undefined;
27
+ if (relayed) {
28
+ const outcome = await hub.ask("questionnaire", from, { questions: relayed }, options);
29
+ if (outcome.how === "cancelled") return undefined;
30
+ const result = outcome.how === "answered" ? readResult(outcome.value) : undefined;
31
+ return { value: JSON.stringify(result ?? { answers: [], cancelled: true }) };
32
+ }
33
+ if (dialog.method === "select") {
34
+ const outcome = await hub.ask("choose", from, { title: dialog.title, options: dialog.options ?? [] }, options);
35
+ return settled(outcome, (value) => (typeof value === "string" ? { value } : undefined));
36
+ }
37
+ if (dialog.method === "confirm") {
38
+ const outcome = await hub.ask("confirm", from, { question: dialog.title, ...(dialog.message ? { detail: dialog.message } : {}) }, options);
39
+ return settled(outcome, (value) => (typeof value === "boolean" ? { confirmed: value } : undefined));
40
+ }
41
+ const outcome = await hub.ask("text", from, { question: dialog.title, ...(dialog.placeholder ? { placeholder: dialog.placeholder } : {}) }, options);
42
+ return settled(outcome, (value) => (typeof value === "string" ? { value } : undefined));
43
+ }
44
+
45
+ function settled(outcome: { how: "answered" | "dismissed" | "cancelled"; value: unknown }, read: (value: unknown) => DialogAnswer | undefined): DialogAnswer | undefined {
46
+ if (outcome.how === "cancelled") return undefined;
47
+ if (outcome.how === "dismissed") return { cancelled: true };
48
+ return read(outcome.value) ?? { cancelled: true };
49
+ }
@@ -54,6 +54,11 @@ export interface SessionStart {
54
54
  message?: string;
55
55
  /** The task it was started to carry on (resumed from the Tasks screen). */
56
56
  taskId?: string;
57
+ /**
58
+ * Put a dialog to the user (this window's question pop-up); resolves with the answer, or undefined when the
59
+ * dialog was settled some other way first (`signal` aborts then).
60
+ */
61
+ ask?: (question: { from: string; dialog: SessionDialog; signal: AbortSignal }) => Promise<DialogAnswer | undefined>;
57
62
  }
58
63
 
59
64
  let counter = 0;
@@ -80,6 +85,9 @@ export class BackgroundSession {
80
85
  /** Dialogs the session waits on, oldest first. */
81
86
  readonly dialogs: SessionDialog[] = [];
82
87
  private readonly onDialog: NonNullable<SessionStart["onDialog"]>;
88
+ private readonly ask?: SessionStart["ask"];
89
+ /** The dialogs being asked in the question pop-up, to take back when they are settled another way. */
90
+ private readonly asking = new Map<string, AbortController>();
83
91
  private readonly proc: SessionProcess;
84
92
  private buffer = "";
85
93
  private stderr = "";
@@ -96,6 +104,7 @@ export class BackgroundSession {
96
104
  if (start.planId) this.planId = start.planId;
97
105
  if (start.taskId) this.taskId = start.taskId;
98
106
  this.onDialog = start.onDialog ?? (() => {});
107
+ if (start.ask) this.ask = start.ask;
99
108
  this.proc = proc;
100
109
  this.onChange = onChange;
101
110
  proc.stdout?.on("data", (chunk) => this.receive(String(chunk)));
@@ -121,6 +130,8 @@ export class BackgroundSession {
121
130
  this.exitCode = code;
122
131
  for (const dialog of this.dialogs) this.onDialog(dialog.id, false, this.sessionId);
123
132
  this.dialogs.length = 0;
133
+ for (const controller of this.asking.values()) controller.abort();
134
+ this.asking.clear();
124
135
  if (this.killTimer) clearTimeout(this.killTimer);
125
136
  this.feed.say("note", reason);
126
137
  for (const resolve of this.exitWaiters.splice(0)) resolve();
@@ -175,6 +186,8 @@ export class BackgroundSession {
175
186
  const index = this.dialogs.findIndex((dialog) => dialog.id === id);
176
187
  if (index < 0) return;
177
188
  this.dialogs.splice(index, 1);
189
+ this.asking.get(id)?.abort();
190
+ this.asking.delete(id);
178
191
  this.onDialog(id, false, this.sessionId);
179
192
  if (this.alive && this.proc.stdin) this.proc.stdin.write(`${JSON.stringify({ type: "extension_ui_response", id, ...answer })}\n`);
180
193
  this.onChange();
@@ -248,6 +261,21 @@ export class BackgroundSession {
248
261
  this.onChange();
249
262
  }
250
263
 
264
+ /** Ask the dialog in the question pop-up, wherever the user is; its answer goes back to the session. */
265
+ private putToUser(dialog: SessionDialog): void {
266
+ if (!this.ask) return;
267
+ const controller = new AbortController();
268
+ this.asking.set(dialog.id, controller);
269
+ this.ask({ from: this.name, dialog, signal: controller.signal }).then(
270
+ (answer) => {
271
+ if (this.asking.get(dialog.id) !== controller) return;
272
+ this.asking.delete(dialog.id);
273
+ if (answer) this.answer(dialog.id, answer);
274
+ },
275
+ () => this.asking.delete(dialog.id),
276
+ );
277
+ }
278
+
251
279
  private uiRequest(event: Record<string, unknown>): void {
252
280
  const id = String(event.id ?? "");
253
281
  const method = String(event.method ?? "");
@@ -262,6 +290,7 @@ export class BackgroundSession {
262
290
  ...(typeof event.prefill === "string" ? { prefill: event.prefill } : {}),
263
291
  });
264
292
  this.onDialog(id, true, this.sessionId);
293
+ this.putToUser(this.dialogs.at(-1)!);
265
294
  this.feed.log("LOBBY", `waiting for you: ${String(event.title ?? "a question").split("\n")[0]}`, "warning");
266
295
  } else if (method === "notify") {
267
296
  const level = event.notifyType === "error" ? "error" : event.notifyType === "warning" ? "warning" : "info";
@@ -299,6 +328,8 @@ export const launchPi = (args: string[], cwd: string, overrides: NodeJS.ProcessE
299
328
  const env: NodeJS.ProcessEnv = { ...process.env, PI_SKIP_VERSION_CHECK: "1" };
300
329
  delete env.BOT_LOBBY_SUBAGENT;
301
330
  delete env.BOT_LOBBY_WEB_PROJECT;
331
+ // It has no page of its own: its questions come to this window (see ask/web.ts).
332
+ env.BOT_LOBBY_BACKGROUND = "1";
302
333
  Object.assign(env, overrides);
303
334
  const proc: ChildProcess = spawn(invocation.command, invocation.args, { cwd, env, shell: false, detached: POSIX, windowsHide: true, stdio: ["pipe", "pipe", "pipe"] });
304
335
  return proc as unknown as SessionProcess;
package/src/pi/tools.ts CHANGED
@@ -132,8 +132,8 @@ export function workflowDeps(
132
132
  effort: effortFor((model, thinking) => checkThinking(modelLookup(ctx)(model), thinking).level),
133
133
  // Time spent waiting on the user is not the task's: its budget clock waits too.
134
134
  // The questions go to the web page, where a free-text answer may run to several lines.
135
- ask: async (question) => (canAsk() ? whileAsking(() => askText(question)) : undefined),
136
- choose: async (title, options) => (canAsk() ? whileAsking(() => askChoice(title, options)) : undefined),
135
+ ask: async (question) => (canAsk() ? whileAsking(() => askText(question, undefined, ctx)) : undefined),
136
+ choose: async (title, options) => (canAsk() ? whileAsking(() => askChoice(title, options, undefined, ctx)) : undefined),
137
137
  // An agent's own questions (the designer's), relayed as the questionnaire.
138
138
  ...(canAsk() ? { askQuestions: (questions: AskQuestion[], from: string, askSignal?: AbortSignal) => whileAsking(() => askUser(questions, ctx, askSignal, from)) } : {}),
139
139
  notify: (message, level = "info") => {