@a-t-h-i/bot-lobby 0.6.47 → 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.
@@ -68,5 +68,10 @@ fontsource packages, `@fontsource-variable/*`) as files in `webui/dist/assets`,
68
68
  so the Typeface setting works offline. Each is under the SIL Open Font
69
69
  License 1.1 (https://openfontlicense.org): Inter (The Inter Project Authors),
70
70
  Geist and Geist Mono (Vercel), IBM Plex Sans (IBM Corp.), Manrope (The
71
- Manrope Project Authors) and JetBrains Mono (The JetBrains Mono Project
72
- Authors).
71
+ Manrope Project Authors), JetBrains Mono (The JetBrains Mono Project
72
+ Authors), DM Sans (The DM Sans Project Authors), Plus Jakarta Sans (The Plus
73
+ Jakarta Sans Project Authors), Figtree (The Figtree Project Authors), Outfit
74
+ (The Outfit Project Authors), Nunito (The Nunito Project Authors), Space
75
+ Grotesk (The Space Grotesk Project Authors), Source Sans 3 and Source Code Pro
76
+ (Adobe), Fira Code (The Fira Code Project Authors) and Roboto Mono (The Roboto
77
+ Mono Project Authors).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a-t-h-i/bot-lobby",
3
- "version": "0.6.47",
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",
@@ -57,12 +57,22 @@
57
57
  "@earendil-works/pi-ai": "1.0.1",
58
58
  "@earendil-works/pi-coding-agent": "1.0.1",
59
59
  "@earendil-works/pi-tui": "1.0.1",
60
+ "@fontsource-variable/dm-sans": "^5.3.0",
61
+ "@fontsource-variable/figtree": "^5.3.0",
62
+ "@fontsource-variable/fira-code": "^5.3.0",
60
63
  "@fontsource-variable/geist": "^5.3.0",
61
64
  "@fontsource-variable/geist-mono": "^5.3.0",
62
65
  "@fontsource-variable/ibm-plex-sans": "^5.3.0",
63
66
  "@fontsource-variable/inter": "^5.3.0",
64
67
  "@fontsource-variable/jetbrains-mono": "^5.3.0",
65
68
  "@fontsource-variable/manrope": "^5.3.0",
69
+ "@fontsource-variable/nunito": "^5.3.0",
70
+ "@fontsource-variable/outfit": "^5.3.0",
71
+ "@fontsource-variable/plus-jakarta-sans": "^5.3.0",
72
+ "@fontsource-variable/roboto-mono": "^5.3.0",
73
+ "@fontsource-variable/source-code-pro": "^5.3.1",
74
+ "@fontsource-variable/source-sans-3": "^5.3.1",
75
+ "@fontsource-variable/space-grotesk": "^5.3.0",
66
76
  "@playwright/test": "1.62.0",
67
77
  "@tailwindcss/vite": "4.3.3",
68
78
  "@types/node": "^22.10.0",
@@ -6,36 +6,50 @@ accessibility, frontend performance and the design language. Everything that
6
6
  runs in the browser is yours, including client-side logic and canvas,
7
7
  WebGL/three.js graphics.
8
8
 
9
- You have real visual taste. Your work is calm, considered and quietly
10
- delightful: the understated, crafted aesthetic of Anthropic's latest models,
11
- not a generic template. Every screen should feel intentional — clear hierarchy,
12
- room to breathe, and small moments of feedback that make the product feel alive
13
- without ever getting in the way.
14
-
15
- ## Existing design language comes first
16
-
17
- Inspect the existing application before introducing new UI patterns. Prefer
18
- extending existing components, tokens, spacing, typography, colors,
19
- interactions and layouts; never introduce a visually similar but separate
20
- component when an existing one can be extended. Where no design system exists,
21
- establish a small one (tokens plus a few primitives) rather than scattering
22
- one-off values.
23
-
24
- ## Aesthetic
25
-
26
- - Calm and editorial: generous whitespace, content first, nothing competing
27
- for attention. Remove before you add.
28
- - Considered typography carries the hierarchy; decoration does not.
29
- - A restrained, warm palette: warm off-white and near-black neutrals rather
30
- than pure `#fff`/`#000`, with one purposeful accent used sparingly for primary
31
- actions, focus and key states.
32
- - Soft radii, hairline borders and subtle depth. No gratuitous gradients,
33
- glassmorphism, heavy shadows or visual noise unless the product already
34
- speaks that language.
35
- - Consistency is taste: the same thing always looks and behaves the same way.
9
+ You bring craft, not a house style. The look of the product is the user's
10
+ call: you never impose an aesthetic of your own, and nothing visual should
11
+ surprise the user once it is built.
12
+
13
+ ## The existing design language comes first
14
+
15
+ In a project that already has a UI, follow its design language exactly: its
16
+ components, tokens, spacing, typography, colours, radii, shadows, icons,
17
+ element types and motion. Inspect the existing application before you add
18
+ anything, extend the components it has instead of making visually similar
19
+ new ones, and match how it already does the same kind of thing (if it uses
20
+ pop-overs for quick edits, so do you). Depart from it only when the user
21
+ explicitly asks for a different style, and then only as far as they asked.
22
+ Where no design system exists yet, establish a small one (tokens plus a few
23
+ primitives) from the choices the user makes, rather than scattering one-off
24
+ values.
25
+
26
+ ## Ask, don't assume
27
+
28
+ The user makes the design decisions; you make them concrete. Before you build
29
+ anything visual that the existing UI does not already settle, ask the user
30
+ with `ask_user_question` about every choice that is theirs to make, for
31
+ example:
32
+
33
+ - the visual style or direction (when there is no existing one to follow);
34
+ - sizes: buttons and controls, spacing density, type scale;
35
+ - element types: a modal or a pop-over, a drawer or a page, rich text or a
36
+ plain text field, a tooltip or inline help, tabs or a segmented switch,
37
+ a toast or an inline message;
38
+ - motion: whether something animates, how, and how fast;
39
+ - colours, radii, icons and empty, loading and error states where they are
40
+ new.
41
+
42
+ Group related choices into one questionnaire (one question each, with a
43
+ recommendation marked) instead of asking one at a time, and say what the
44
+ existing UI already does when it does. Decide yourself only what the existing
45
+ design language already answers, or what the user has said to leave to you.
46
+ When you report, list each design decision and who made it.
36
47
 
37
48
  ## Styling craft
38
49
 
50
+ Sound practice for whatever style the project has or the user picks; where the
51
+ existing UI does something differently, the existing UI wins.
52
+
39
53
  - **Type:** a modular scale (ratio about 1.2–1.25) with at most two families.
40
54
  Body text at 16px or larger, line-height about 1.5; headings tighter (about
41
55
  1.1–1.25) with slightly reduced letter-spacing at large sizes. Keep line
@@ -91,8 +105,8 @@ product, and a generic render is a failed one.
91
105
 
92
106
  ## Interaction and motion
93
107
 
94
- You like interactive UIs that give the user subtle, fun feedback — never
95
- over-animated.
108
+ Motion and feedback follow the existing UI and what the user chose; where
109
+ neither says, ask. As defaults to offer:
96
110
 
97
111
  - Every action gets a response: pressed states, optimistic updates, inline
98
112
  confirmation ("Saved"), skeletons or progress for waits over ~300ms, and
@@ -100,25 +114,43 @@ over-animated.
100
114
  - Motion explains change: animate only `transform` and `opacity`, 150–250ms,
101
115
  ease-out when entering and ease-in when leaving. Never animate layout
102
116
  properties, and never delay the user to show an animation.
103
- - Small playful touches are welcome where they fit the product — a gentle
104
- check-mark draw, a soft spring on a toggle, a light stagger on a short list —
105
- but no looping or attention-seeking motion.
106
117
  - Always honor `prefers-reduced-motion`: keep the state change, drop the
107
118
  movement.
108
119
 
109
120
  ## Show the options
110
121
 
111
122
  When a visual decision is the user's to make (a layout, a screen, how a
112
- component looks, a direction for the whole UI), show it rather than describe
113
- it: ask with `ask_user_question` and give every option an `htmlPreview`, a
114
- static mockup of that option — HTML and CSS only (no scripts, images, fonts or
115
- links: the sandbox strips them), laid out for a page about 1200px wide, with
116
- the real content, palette and proportions of what you would build, never grey
117
- placeholder boxes. The user sees the focused option's mockup beside the
118
- question, expands it and scrolls through all the options side by side, so
119
- draw each option at the same fidelity. Ask before you build, and only about
120
- what is theirs to choose; decide the rest yourself. In the planning panel,
121
- draw the same mockups as fenced `mockup` blocks under your options.
123
+ component looks or behaves, a direction for the whole UI), show it rather than
124
+ describe it: ask with `ask_user_question` and give every option an
125
+ `htmlPreview`, a mockup of that option in HTML and CSS, laid out for a page
126
+ about 1200px wide, with the real content, palette and proportions of what you
127
+ would build, in the project's existing design language, never grey
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`,
143
+ `select`, `textarea`, `label` and `details`/`summary` elements with their
144
+ hover, focus, active, checked and open states, and CSS transitions and
145
+ `@keyframes` for the motion the option proposes (a pop-over opening, a
146
+ toggle, a list arriving), at the speed you would ship. There are no scripts,
147
+ images, fonts or links (the sandbox strips them), so build the behaviour from
148
+ CSS: `:hover`, `:focus-within`, `:checked` with sibling selectors, and
149
+ `details[open]`. The user sees the focused option's mockup beside the
150
+ question, tries it there, expands it and scrolls through all the options side
151
+ by side, so draw each option at the same fidelity. Ask before you build. In
152
+ the planning panel, draw the same mockups as fenced `mockup` blocks under
153
+ your options.
122
154
 
123
155
  ## Accessibility
124
156
 
@@ -133,8 +165,8 @@ screen.
133
165
 
134
166
  - Mobile-first, responsive from 320px up; test the narrowest and widest
135
167
  layouts.
136
- - Reuse existing components and tokens; keep components small, typed and
137
- composable, with state lifted only as far as needed.
168
+ - Reuse the project's existing components, styles and tokens; keep components
169
+ small, typed and composable, with state lifted only as far as needed.
138
170
  - Performance is UX: no layout shift (reserve space for media and async
139
171
  content), lazy-load below-the-fold assets, size images correctly, keep
140
172
  bundles lean and avoid needless re-renders.
package/prompts/master.md CHANGED
@@ -142,9 +142,13 @@ and reason about the change inside it:
142
142
  For anything the user will see (a UI request, a layout, a screen, a visual
143
143
  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
- `htmlPreview` mockup (static HTML and CSS of that option, laid out for a page
146
- about 1200px wide); the user expands the mockups and scrolls through the
147
- options side by side. When the choice needs real design work, delegate it to
145
+ `htmlPreview` mockup (HTML and CSS of that option in the project's existing
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). 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
151
+ mockups and scrolls through the options side by side. When the choice needs real design work, delegate it to
148
152
  the designer, who asks with its own mockups. In planning, the panel draws
149
153
  mockups under its options the same way.
150
154
 
@@ -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
 
@@ -22,9 +23,10 @@ export { ASK_TOOL };
22
23
 
23
24
  const OptionSchema = Type.Object({
24
25
  htmlPreview: Type.Optional(Type.Object({
25
- html: Type.String({ maxLength: MAX_PREVIEW_HTML, description: "Static HTML of a mockup of this option; scripts, images, fonts and links are stripped." }),
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 })),
27
- }, { additionalProperties: false, description: "A static HTML/CSS mockup of this option, 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
+ 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." })),
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." })),
30
32
  preview: Type.Optional(Type.String({ description: "Markdown shown beside the options while this one is focused: a mockup, a code snippet, a diagram, a config. Only when seeing it helps the user compare." })),
@@ -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
  }
@@ -46,6 +46,8 @@ export interface AgentRequest {
46
46
  context: AgentContext;
47
47
  model?: string;
48
48
  mcpTools?: string[];
49
+ /** False when the agent's settings turn its auto-compaction off. */
50
+ autoCompact?: boolean;
49
51
  delegationQuota?: { used: number };
50
52
  thinking?: string;
51
53
  timeoutMs: number;
@@ -254,6 +256,7 @@ async function runAgentOnce(request: AgentRequest, run: ProcessRunner, attempt:
254
256
  request.onUpdate?.(child);
255
257
  },
256
258
  mcpTools: request.mcpTools,
259
+ ...(request.autoCompact === false ? { autoCompact: false } : {}),
257
260
  task: request.instruction,
258
261
  systemPrompt,
259
262
  tools: toolsFor(request),
@@ -1,6 +1,7 @@
1
1
  import type { AgentRun } from "../schemas/findings.ts";
2
2
  import type { Domain, Role } from "../schemas/agent.ts";
3
3
  import { delegationHost } from "./delegation.ts";
4
+ import { AUTO_COMPACT_ENV } from "../pi/quiet.ts";
4
5
  import { fileURLToPath } from "node:url";
5
6
  import { spawn, type ChildProcess } from "node:child_process";
6
7
  import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
@@ -44,6 +45,8 @@ export interface PiRunOptions {
44
45
  onEvent?: (event: PiStreamEvent) => void;
45
46
  /** Extra environment for the subagent process. */
46
47
  env?: Record<string, string>;
48
+ /** False when the agent's settings turn its auto-compaction off. */
49
+ autoCompact?: boolean;
47
50
  /** Kill the agent after this long without any output; 0 disables. */
48
51
  stallTimeoutMs?: number;
49
52
  /** Silence allowed while one tool call is in flight (tests, builds); 0 disables. */
@@ -765,7 +768,8 @@ function writePromptFile(prompt: string): { file: string; cleanup: () => void }
765
768
  }
766
769
 
767
770
  /** Run one isolated subagent and normalise its outcome. */
768
- export async function runPiAgent(options: PiRunOptions, run: ProcessRunner = spawnPiProcess): Promise<PiRunResult> {
771
+ export async function runPiAgent(given: PiRunOptions, run: ProcessRunner = spawnPiProcess): Promise<PiRunResult> {
772
+ const options = given.autoCompact === false ? { ...given, env: { ...given.env, [AUTO_COMPACT_ENV]: "0" } } : given;
769
773
  const host = await delegationHost(options, run);
770
774
  let prompt: ReturnType<typeof writePromptFile> | undefined;
771
775
  try {
@@ -0,0 +1,66 @@
1
+ /**
2
+ * What the oracle learns, shared on its compaction loop. When the oracle's own context is compacted, the checkpoint
3
+ * it writes ends with a "Knowledge to share" section: durable project facts, standards and decisions, each tagged
4
+ * with the agent it belongs to. Those lines go into that agent's knowledge files (through `applyKnowledge`, which
5
+ * drops duplicates), so the oracle keeps its own knowledge current and the domain agents (designer, backend, QA)
6
+ * get what concerns them without the oracle having to remember to write it down.
7
+ */
8
+ import { applyKnowledge, type KnowledgeKind } from "./store.ts";
9
+ import type { KnowledgeAgent } from "./paths.ts";
10
+
11
+ export const DIGEST_HEADING = "Knowledge to share";
12
+
13
+ /** Added to the oracle's compaction instruction. */
14
+ export const DIGEST_INSTRUCTION = [
15
+ `Finish with a section headed "## ${DIGEST_HEADING}": the durable things this stretch of work established about the project that an agent will need again, one per line, each as \`- [agent:kind] text\`.`,
16
+ "agent is master (you: how the user works, project-wide facts), designer (UI, UX, design language, frontend), backend (APIs, data, services, infrastructure) or qa (testing, verification, known failures).",
17
+ "kind is knowledge (a fact or a useful pattern), standard (a rule the user wants followed) or decision (a choice the user made, with its reason).",
18
+ "Give the user's own words where they set a rule or a preference. Leave out task progress, guesses, and anything only true for this task. Write `- none` when nothing qualifies. At most 12 lines.",
19
+ ].join(" ");
20
+
21
+ const AGENTS: readonly KnowledgeAgent[] = ["master", "designer", "backend", "qa"];
22
+ const KINDS: readonly KnowledgeKind[] = ["knowledge", "standard", "decision"];
23
+ const MAX_ITEMS = 12;
24
+ const MAX_CHARS = 400;
25
+
26
+ export interface DigestItem {
27
+ agent: KnowledgeAgent;
28
+ kind: KnowledgeKind;
29
+ text: string;
30
+ }
31
+
32
+ /** The tagged lines under the "Knowledge to share" heading; anything malformed is skipped. */
33
+ export function parseDigest(summary: string): DigestItem[] {
34
+ const lines = summary.split(/\r?\n/);
35
+ const start = lines.findIndex((line) => /^#{1,4}\s*knowledge to share\s*$/i.test(line.trim()));
36
+ if (start < 0) return [];
37
+ const items: DigestItem[] = [];
38
+ for (const line of lines.slice(start + 1)) {
39
+ if (/^#{1,4}\s/.test(line.trim())) break;
40
+ const match = /^\s*[-*]\s*\[\s*([a-z]+)\s*:\s*([a-z]+)\s*\]\s*(.+?)\s*$/i.exec(line);
41
+ if (!match) continue;
42
+ const agent = match[1]!.toLowerCase() as KnowledgeAgent;
43
+ const kind = match[2]!.toLowerCase() as KnowledgeKind;
44
+ const text = match[3]!.replace(/\s+/g, " ").trim();
45
+ if (!AGENTS.includes(agent) || !KINDS.includes(kind) || !text || text.length > MAX_CHARS) continue;
46
+ items.push({ agent, kind, text });
47
+ if (items.length >= MAX_ITEMS) break;
48
+ }
49
+ return items;
50
+ }
51
+
52
+ /** Write each item into its agent's knowledge; returns how many were new, by agent. */
53
+ export function shareDigest(dataRoot: string, items: readonly DigestItem[], now = new Date()): Partial<Record<KnowledgeAgent, number>> {
54
+ const added: Partial<Record<KnowledgeAgent, number>> = {};
55
+ for (const item of items) {
56
+ const text = item.kind === "decision" ? item.text : `- ${item.text}`;
57
+ if (applyKnowledge({ dataRoot, agent: item.agent, kind: item.kind, text, now }).result === "added") added[item.agent] = (added[item.agent] ?? 0) + 1;
58
+ }
59
+ return added;
60
+ }
61
+
62
+ /** "2 for the designer, 1 for QA" */
63
+ export function describeShared(added: Partial<Record<KnowledgeAgent, number>>): string {
64
+ const names: Record<KnowledgeAgent, string> = { master: "the oracle", designer: "the designer", backend: "backend", qa: "QA" };
65
+ return AGENTS.filter((agent) => added[agent]).map((agent) => `${added[agent]} for ${names[agent]}`).join(", ");
66
+ }
@@ -1008,6 +1008,8 @@ export class PlanningSession {
1008
1008
  model,
1009
1009
  thinking,
1010
1010
  mcpTools: profile.mcpTools,
1011
+ ...(profile.autoCompact === false ? { autoCompact: false } : {}),
1012
+ ...(profile.autoCompact === false ? { autoCompact: false } : {}),
1011
1013
  onChildRun: (child) => {
1012
1014
  this.deps.feed?.step(label, `child ${child.runId.slice(0, 8)}: ${child.step ?? child.status}`, child.runId);
1013
1015
  if (child.status !== "running") this.deps.feed?.end(child.runId, child.status !== "success");
@@ -279,6 +279,7 @@ export class PullReviews {
279
279
  model,
280
280
  thinking,
281
281
  mcpTools: profile.mcpTools,
282
+ ...(profile.autoCompact === false ? { autoCompact: false } : {}),
282
283
  onChildRun: (child) => {
283
284
  this.deps.feed?.step(REVIEW_SOURCE, `child ${child.runId.slice(0, 8)}: ${child.step ?? child.status}`, child.runId);
284
285
  if (child.status !== "running") this.deps.feed?.end(child.runId, child.status !== "success");
@@ -92,6 +92,8 @@ export interface QuickFixProfile {
92
92
  timeoutMs: number;
93
93
  instructions?: string;
94
94
  mcpTools?: string[];
95
+ /** False when its settings turn the agent's auto-compaction off. */
96
+ autoCompact?: boolean;
95
97
  /** Where the job goes when its model runs out of usage or is unavailable. */
96
98
  }
97
99
 
@@ -340,6 +342,8 @@ export class QuickFixQueue {
340
342
  model,
341
343
  thinking,
342
344
  mcpTools: profile.mcpTools,
345
+ ...(profile.autoCompact === false ? { autoCompact: false } : {}),
346
+ ...(profile.autoCompact === false ? { autoCompact: false } : {}),
343
347
  delegationQuota: quota,
344
348
  agent: { runId: job.id, taskId: job.id, domain: "backend", role: "worker" },
345
349
  onChildRun: (child) => {
@@ -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}`,