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

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 (61) hide show
  1. package/README.md +2 -2
  2. package/THIRD_PARTY_NOTICES.md +4 -3
  3. package/package.json +9 -7
  4. package/prompts/designer.md +15 -1
  5. package/prompts/master.md +4 -1
  6. package/src/ask/project-styles.ts +64 -0
  7. package/src/ask/tool.ts +23 -4
  8. package/src/ask/types.ts +1 -1
  9. package/src/ask/web.ts +27 -6
  10. package/src/classifier/classifier.ts +14 -2
  11. package/src/classifier/hosts.ts +12 -18
  12. package/src/classifier/instance.ts +7 -4
  13. package/src/execution/delegation.ts +2 -1
  14. package/src/execution/pi-runner.ts +11 -2
  15. package/src/lobby/host.ts +6 -0
  16. package/src/lobby/keys.ts +2 -1
  17. package/src/lobby/service.ts +75 -7
  18. package/src/lobby/session-questions.ts +49 -0
  19. package/src/lobby/sessions.ts +66 -0
  20. package/src/pi/auto-compaction.ts +46 -19
  21. package/src/pi/events.ts +2 -0
  22. package/src/pi/fresh-context.ts +10 -8
  23. package/src/pi/plan-checklist.ts +47 -15
  24. package/src/pi/tools.ts +2 -2
  25. package/src/schemas/task.ts +14 -0
  26. package/src/state/persistence.ts +4 -1
  27. package/src/webui/api/index.ts +36 -2
  28. package/src/webui/api/lobby.ts +1 -1
  29. package/src/webui/api/plan-facts.ts +9 -4
  30. package/src/webui/api/sessions.ts +21 -0
  31. package/src/webui/api/tasks.ts +12 -1
  32. package/src/webui/api/terminal.ts +42 -0
  33. package/src/webui/dev/fake-service.ts +42 -1
  34. package/src/webui/palette-core.ts +228 -228
  35. package/src/webui/protocol.ts +29 -2
  36. package/src/webui/server.ts +16 -1
  37. package/src/webui/terminal.ts +292 -0
  38. package/src/workflow/workflow.ts +3 -2
  39. package/webui/dist/assets/TerminalView-BrP-ENHg.css +1 -0
  40. package/webui/dist/assets/TerminalView-D_j85u4O.js +16 -0
  41. package/webui/dist/assets/dm-sans-latin-wght-normal-Xz1IZZA0.woff2 +0 -0
  42. package/webui/dist/assets/figtree-latin-wght-normal-D_ZTVpCC.woff2 +0 -0
  43. package/webui/dist/assets/fira-code-latin-wght-normal-CHoedHDv.woff2 +0 -0
  44. package/webui/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  45. package/webui/dist/assets/geist-mono-latin-wght-normal-XN7g48iV.woff2 +0 -0
  46. package/webui/dist/assets/ibm-plex-sans-latin-wght-normal-IvpUvPa2.woff2 +0 -0
  47. package/webui/dist/assets/index-BkM--P3x.js +115 -0
  48. package/webui/dist/assets/index-DrHXotS7.css +2 -0
  49. package/webui/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  50. package/webui/dist/assets/manrope-latin-wght-normal-DHIcAJRg.woff2 +0 -0
  51. package/webui/dist/assets/nunito-latin-wght-normal-BzFMHfZw.woff2 +0 -0
  52. package/webui/dist/assets/outfit-latin-wght-normal-Bc-8i84L.woff2 +0 -0
  53. package/webui/dist/assets/plus-jakarta-sans-latin-wght-normal-eXO_dkmS.woff2 +0 -0
  54. package/webui/dist/assets/roboto-mono-latin-wght-normal-CZtBPCCa.woff2 +0 -0
  55. package/webui/dist/assets/source-code-pro-latin-wght-normal-BP8Zz55n.woff2 +0 -0
  56. package/webui/dist/assets/source-sans-3-latin-wght-normal-Bo_j9asI.woff2 +0 -0
  57. package/webui/dist/assets/space-grotesk-latin-wght-normal-BhU9QXUp.woff2 +0 -0
  58. package/webui/dist/build.json +110 -94
  59. package/webui/dist/index.html +2 -2
  60. package/webui/dist/assets/index-B5AnxwRU.css +0 -2
  61. package/webui/dist/assets/index-CznnCkjK.js +0 -114
package/README.md CHANGED
@@ -12,7 +12,7 @@ pi install npm:@a-t-h-i/bot-lobby
12
12
 
13
13
  Try it first with `pi -e npm:@a-t-h-i/bot-lobby`, or from a checkout with `pi -e ./src/index.ts`.
14
14
  The questionnaire and web tools are built in, so don't also install extensions with the same tool names.
15
- Requires **Pi 1.0.x** (1.0.1 or later).
15
+ Requires **Pi 1.3.x** (1.3.0 or later).
16
16
 
17
17
  ## Use
18
18
 
@@ -51,7 +51,7 @@ or external boards.
51
51
  The composer appears only on **Lobby**, **Tasks**, **Plan**, **Quick fix** and **Knowledge**, including
52
52
  supported detail routes and Knowledge's Docs/Chat views. On Git, Metrics and Settings it stays mounted
53
53
  but hidden and inert, preserving drafts and attachments (including uploads that finish while hidden)
54
- without taking space. Phone navigation remains available.
54
+ without taking space.
55
55
 
56
56
  | Agent | Does |
57
57
  | --- | --- |
@@ -3,7 +3,7 @@
3
3
  ## T3 Code
4
4
 
5
5
  The web UI's look (colours, radii, shadows, control and dialog styles, the
6
- sidebar rail, the chat rows, the work log, the composer surface, the select
6
+ sidebar rail, the chat rows, the work log, the composer (its surface, footer controls and send button), the select
7
7
  fields and drop-down lists, the loading and status animations and the
8
8
  built-in colour themes) is adapted from T3 Code,
9
9
  https://github.com/pingdotgg/t3code. No T3 Code package is a
@@ -32,8 +32,9 @@ naming the file it came from.
32
32
  | `webui/src/components/reui/badge.tsx` | `apps/web/src/components/ui/badge.tsx` |
33
33
  | `webui/src/components/reui/select.tsx` | `apps/web/src/components/ui/select.tsx` |
34
34
  | `webui/src/components/reui/popover.tsx` | `apps/web/src/components/ui/popover.tsx` |
35
- | `webui/src/app/Sidebar.tsx` | `apps/web/src/components/ui/sidebar.tsx` |
36
- | `webui/src/app/Composer.tsx` | `apps/web/src/components/chat/ComposerSurface.tsx` |
35
+ | `webui/src/app/Sidebar.tsx` | `apps/web/src/components/Sidebar.tsx`, `apps/web/src/components/ui/sidebar.tsx` |
36
+ | `webui/src/app/Composer.tsx` | `apps/web/src/components/chat/ComposerSurface.tsx`, `ChatComposer.tsx`, `ComposerPrimaryActions.tsx`, `ComposerControl.tsx` |
37
+ | `webui/src/app/TerminalDrawer.tsx` | `apps/web/src/components/ThreadTerminalDrawer.tsx` (the drawer, its floating toolbar and terminal list) |
37
38
  | `webui/src/tabs/lobby/InlineActivity.tsx` | `apps/web/src/components/chat/WorkLog.tsx` |
38
39
  | `src/webui/palette-core.ts` (the built-in themes) | `packages/shared/src/themePalettes.ts` (Blossom is their T3 Chat theme, renamed) |
39
40
 
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.50",
4
4
  "description": "Structured multi-agent software engineering orchestrator for Pi",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -47,16 +47,16 @@
47
47
  "test": "node --test --test-reporter=dot test/*.test.ts"
48
48
  },
49
49
  "peerDependencies": {
50
- "@earendil-works/pi-ai": ">=1.0.1 <1.1.0",
51
- "@earendil-works/pi-coding-agent": ">=1.0.1 <1.1.0",
52
- "@earendil-works/pi-tui": ">=1.0.1 <1.1.0",
50
+ "@earendil-works/pi-ai": ">=1.3.0 <1.4.0",
51
+ "@earendil-works/pi-coding-agent": ">=1.3.0 <1.4.0",
52
+ "@earendil-works/pi-tui": ">=1.3.0 <1.4.0",
53
53
  "typebox": "*"
54
54
  },
55
55
  "devDependencies": {
56
56
  "@axe-core/playwright": "4.13.0",
57
- "@earendil-works/pi-ai": "1.0.1",
58
- "@earendil-works/pi-coding-agent": "1.0.1",
59
- "@earendil-works/pi-tui": "1.0.1",
57
+ "@earendil-works/pi-ai": "1.3.0",
58
+ "@earendil-works/pi-coding-agent": "1.3.0",
59
+ "@earendil-works/pi-tui": "1.3.0",
60
60
  "@fontsource-variable/dm-sans": "^5.3.0",
61
61
  "@fontsource-variable/figtree": "^5.3.0",
62
62
  "@fontsource-variable/fira-code": "^5.3.0",
@@ -79,6 +79,8 @@
79
79
  "@types/react": "19.3.0",
80
80
  "@types/react-dom": "19.3.0",
81
81
  "@vitejs/plugin-react": "6.1.1",
82
+ "@xterm/addon-fit": "0.11.0",
83
+ "@xterm/xterm": "6.0.0",
82
84
  "class-variance-authority": "0.7.1",
83
85
  "clsx": "2.1.1",
84
86
  "lucide-react": "1.49.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
  }
@@ -2,7 +2,8 @@
2
2
  * The classifier facade every feature calls. It never throws into the
3
3
  * workflow: any failure (no key, a timeout, an API error, a request over
4
4
  * budget) returns undefined, and the caller falls back to what bot-lobby did
5
- * before the classifier existed. It answers with Jev on its host, or with the
5
+ * before the classifier existed. It answers with Jev on its host (through pi's
6
+ * classifier catalog and logins when pi lists it), or with the
6
7
  * model `classifier.engine` names (any classifier or chat model pi is signed
7
8
  * in to; see `models.ts`). Three failures in a row pause it for ten
8
9
  * minutes with a single warning, so a dead endpoint cannot tax every step.
@@ -12,7 +13,7 @@ import type { ClassifierConfig, ClassifierFeature } from "../schemas/configurati
12
13
  import type { MetricRecord } from "../state/metrics.ts";
13
14
  import { JevError, noul, systemOne, type Answer, type FetchLike, type SystemOneRequest } from "./client.ts";
14
15
  import { fitsBudget, requestSize, LIMITS } from "./limits.ts";
15
- import { keyHint, resolveTarget, type KeySource } from "./hosts.ts";
16
+ import { keyHint, piModelRef, resolveTarget, type KeySource } from "./hosts.ts";
16
17
  import type { EngineAnswer } from "./models.ts";
17
18
 
18
19
  export type ClassifierPurpose = ClassifierFeature | "test";
@@ -32,6 +33,8 @@ export interface ClassifierDeps {
32
33
  keys?: KeySource;
33
34
  /** Answers with the model `classifier.engine` names, through pi's registry; absent before a session starts. */
34
35
  engine?: EngineAnswer;
36
+ /** Whether pi's registry lists a classifier model (`provider/id`); Jev on its host goes through pi when it does. */
37
+ lists?: (ref: string) => boolean;
35
38
  fetch?: FetchLike;
36
39
  sleep?: (ms: number) => Promise<void>;
37
40
  env?: NodeJS.ProcessEnv;
@@ -112,6 +115,15 @@ export class Classifier {
112
115
  }
113
116
  return { ok: false, error };
114
117
  }
118
+ const ref = piModelRef(host, config);
119
+ if (!config.baseUrl && this.deps.engine && this.deps.lists?.(ref)) {
120
+ const result = await this.callEngine(purpose, ref, request, options);
121
+ // A limited-time free model that ends answers 404/410; never switch to the paid one silently.
122
+ if (!result.ok && model.endsWith("-free") && /\b(404|410)\b|not found|gone/i.test(result.error)) {
123
+ return { ok: false, error: `${result.error} — ${host.label}'s free ${model} may have ended; set the classifier's model to ${model.replace(/-free$/, "")} (paid) in /bot-lobby settings to keep using Jev there` };
124
+ }
125
+ return result;
126
+ }
115
127
  const started = this.now();
116
128
  try {
117
129
  const response = await systemOne(request, {
@@ -6,14 +6,17 @@
6
6
  * - OpenCode Zen serves Jev with the OpenCode key pi uses for Zen and Go
7
7
  * (`/login opencode` or `opencode-go`, or OPENCODE_API_KEY), on the free
8
8
  * `jev-1.13-free` model while OpenCode offers it.
9
- * - TypeSafe direct: bot-lobby registers a `typesafe` provider so `/login
10
- * typesafe` stores the key in pi's auth.json.
9
+ * - TypeSafe direct: pi's own `typesafe` provider (`/login typesafe` or
10
+ * TYPESAFE_API_KEY).
11
11
  * - OpenRouter and Vercel AI Gateway use the key pi holds for them.
12
12
  *
13
+ * Every host's Jev is in pi's classifier catalog, so the classifier calls it
14
+ * through pi (`piModel` is its id there); only a custom URL or a model pi does
15
+ * not list goes to the System One API directly (see `classifier.ts`).
16
+ *
13
17
  * `auto` (the default) takes OpenCode when pi holds an OpenCode key, else
14
18
  * TypeSafe.
15
19
  */
16
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
17
20
  import type { ClassifierConfig, JevHostName } from "../schemas/configuration.ts";
18
21
 
19
22
  export type JevHostId = Exclude<JevHostName, "auto">;
@@ -27,13 +30,15 @@ export interface JevHost {
27
30
  env: string;
28
31
  baseUrl: string;
29
32
  model: string;
33
+ /** The default model's id in pi's classifier catalog, when it differs from `model`. */
34
+ piModel?: string;
30
35
  headers?: Readonly<Record<string, string>>;
31
36
  }
32
37
 
33
38
  export const JEV_HOST_TABLE: Readonly<Record<JevHostId, JevHost>> = Object.freeze({
34
39
  opencode: { name: "opencode", label: "OpenCode Zen", piProviders: ["opencode", "opencode-go"], env: "OPENCODE_API_KEY", baseUrl: "https://opencode.ai/zen", model: "jev-1.13-free" },
35
40
  typesafe: { name: "typesafe", label: "TypeSafe", piProviders: ["typesafe"], env: "TYPESAFE_API_KEY", baseUrl: "https://api.typesafe.ai", model: "jev-latest" },
36
- openrouter: { name: "openrouter", label: "OpenRouter", piProviders: ["openrouter"], env: "OPENROUTER_API_KEY", baseUrl: "https://openrouter.ai/api", model: "jev-latest", headers: { "HTTP-Referer": "https://github.com/a-t-h-i/bot-lobby", "X-Title": "bot-lobby" } },
41
+ openrouter: { name: "openrouter", label: "OpenRouter", piProviders: ["openrouter"], env: "OPENROUTER_API_KEY", baseUrl: "https://openrouter.ai/api", model: "jev-latest", piModel: "~typesafe/jev-latest", headers: { "HTTP-Referer": "https://github.com/a-t-h-i/bot-lobby", "X-Title": "bot-lobby" } },
37
42
  vercel: { name: "vercel", label: "Vercel AI Gateway", piProviders: ["vercel-ai-gateway"], env: "AI_GATEWAY_API_KEY", baseUrl: "https://ai-gateway.vercel.sh/typesafe", model: "typesafe-ai/jev" },
38
43
  });
39
44
 
@@ -129,18 +134,7 @@ export function describeKey(host: JevHost, status: StatusSource | undefined, env
129
134
  return `missing: ${host.name === "typesafe" ? "/login typesafe → Use an API key" : `/login ${host.piProviders[0]}`}, or set ${host.env}`;
130
135
  }
131
136
 
132
- /**
133
- * Register TypeSafe as a pi provider with no chat models, so `/login
134
- * typesafe` stores the Jev key with pi's own locking and `/logout` removes
135
- * it. Nothing is added to `/model`. A pi that refuses the registration only
136
- * loses the login entry: the environment variable still works.
137
- */
138
- export function registerJevProvider(pi: ExtensionAPI): boolean {
139
- const host = JEV_HOST_TABLE.typesafe;
140
- try {
141
- pi.registerProvider(host.piProviders[0]!, { name: "TypeSafe (Jev classifier)", baseUrl: host.baseUrl, apiKey: `$${host.env}`, models: [] });
142
- return true;
143
- } catch {
144
- return false;
145
- }
137
+ /** The `provider/id` pi lists a host's Jev under: the host's first pi provider, with the config's model if it names one. */
138
+ export function piModelRef(host: JevHost, config: Pick<ClassifierConfig, "model">): string {
139
+ return `${host.piProviders[0]}/${config.model || host.piModel || host.model}`;
146
140
  }
@@ -11,7 +11,7 @@ import { isSubagentProcess } from "../pi/quiet.ts";
11
11
  import { Classifier } from "./classifier.ts";
12
12
  import { scriptPicker } from "./scripts.ts";
13
13
  import type { ScriptPicker } from "../scripts/pick.ts";
14
- import { registerJevProvider, type KeySource, type KeyStatus } from "./hosts.ts";
14
+ import type { KeySource, KeyStatus } from "./hosts.ts";
15
15
  import { fileHinter, type FileHinter, type FileScope } from "./files.ts";
16
16
  import { withRepoMap } from "../knowledge/repo-map.ts";
17
17
  import { knowledgePicker, type KnowledgePicker } from "./knowledge.ts";
@@ -19,7 +19,7 @@ import { lobbyFeed } from "../lobby/feed.ts";
19
19
  import { triageLine, triageWithContext } from "./triage.ts";
20
20
  import type { TaskTriage } from "../schemas/task.ts";
21
21
  import { effortRouter, type EffortRouter } from "./effort.ts";
22
- import { engineAnswer, engineClassifiers, type EngineAnswer, type EngineModel, type Registry } from "./models.ts";
22
+ import { engineAnswer, engineClassifiers, splitRef, type EngineAnswer, type EngineModel, type Registry } from "./models.ts";
23
23
 
24
24
  interface Binding {
25
25
  cwd: string;
@@ -43,6 +43,10 @@ export function classifier(): Classifier {
43
43
  if (!binding) throw new Error(`${ref} is not reachable before a pi session starts`);
44
44
  return binding.engine(ref, request, options);
45
45
  },
46
+ lists: (ref) => {
47
+ const parts = splitRef(ref);
48
+ return Boolean(parts && binding?.registry.findOfType("classifier", parts.provider, parts.id));
49
+ },
46
50
  metrics: (record) => {
47
51
  if (binding) appendMetrics(binding.root, binding.configDir, [record]);
48
52
  },
@@ -114,9 +118,8 @@ export function availableClassifiers(): EngineModel[] {
114
118
  return classifierModels;
115
119
  }
116
120
 
117
- /** Register the `typesafe` login entry and bind the classifier to each session's key store. */
121
+ /** Bind the classifier to each session's key store and model registry. */
118
122
  export function registerClassifier(pi: ExtensionAPI, configDir: string): void {
119
- registerJevProvider(pi);
120
123
  pi.on("session_start", (_event, ctx) => {
121
124
  binding = {
122
125
  cwd: ctx.cwd,
@@ -75,7 +75,7 @@ export async function delegationHost(options: PiRunOptions, run: ProcessRunner)
75
75
  const tools = [...new Set([...baseTools, "codemode", "tool_search", ...(canDelegate(agent, quota.used) ? [DELEGATE_TOOL] : [])])];
76
76
  const mcpTools = options.mcpTools ?? agentProfile(config, agent.domain, agent.role).mcpTools ?? [];
77
77
  const policy: AgentPolicy = { tools, mcpTools, readOnly: !editable, depth };
78
- if (depth > 1) return { tools, env: { ...options.env, [AGENT_ENV]: JSON.stringify(policy), [DELEGATION_ENV]: "" }, note: "You are a child agent. Finish only your assigned brief and report evidence to your parent; you cannot delegate further.", close: async () => {}, onStart: options.onStart, children: [] as AgentRun[] };
78
+ if (depth > 1) return { tools, mcpTools, env: { ...options.env, [AGENT_ENV]: JSON.stringify(policy), [DELEGATION_ENV]: "" }, note: "You are a child agent. Finish only your assigned brief and report evidence to your parent; you cannot delegate further.", close: async () => {}, onStart: options.onStart, children: [] as AgentRun[] };
79
79
 
80
80
  const address = options.env?.[DESK_ENV.address];
81
81
  let desk = address ? deskSessionAt(address) : undefined;
@@ -189,6 +189,7 @@ export async function delegationHost(options: PiRunOptions, run: ProcessRunner)
189
189
  let closed = false;
190
190
  return {
191
191
  tools: desk ? [...new Set([...tools, ...DESK_TOOLS])] : tools,
192
+ mcpTools,
192
193
  env: { ...options.env, ...(desk ? desk.env(parentDeskId) : {}), [AGENT_ENV]: JSON.stringify({ ...policy, tools: desk ? [...new Set([...tools, ...DESK_TOOLS])] : tools }), [DELEGATION_ENV]: JSON.stringify({ address: server.address, token, timeoutMs: options.timeoutMs }) },
193
194
  note: `${canDelegate(agent, quota.used) ? `Work directly when you can finish the brief yourself. Use ${DELEGATE_TOOL} only for useful, separable work with disjoint files, at most five children total; children share your remaining time and cannot delegate further. Integrate and verify their work yourself. ` : ""}Use codemode for concise parallel calls, inspect failures, and return only useful evidence.${desk ? " Before edit/write, claim_file; handover_file when finished. Release files before delegating or waiting." : ""}`,
194
195
  onStart: (handle: Parameters<NonNullable<PiRunOptions["onStart"]>>[0]) => {
@@ -362,12 +362,21 @@ export function finishedThought(line: string): string | undefined {
362
362
  return text.length > MAX_THOUGHT_CHARS ? `${text.slice(0, MAX_THOUGHT_CHARS - 1)}…` : text;
363
363
  }
364
364
 
365
+ /** pi's MCP resource tools; the agent policy lets them reach only servers with a granted tool. */
366
+ export const MCP_RESOURCE_TOOLS: readonly string[] = ["list_mcp_resources", "list_mcp_resource_templates", "read_mcp_resource"];
367
+
365
368
  /** Build the `pi` argv for one isolated, headless RPC agent run; the task goes over stdin. */
366
369
  export function buildPiArgs(options: Omit<PiRunOptions, "task"> & { systemPromptFile?: string }): string[] {
367
370
  const args = ["--mode", "rpc", "--no-session", "--no-prompt-templates", "--no-themes", "--extension", fileURLToPath(new URL("../index.ts", import.meta.url))];
368
371
  if (options.model && options.model !== "inherit") args.push("--model", options.model);
369
372
  if (options.thinking) args.push("--thinking", options.thinking);
370
- if (options.tools && options.tools.length > 0) args.push("--tools", options.tools.join(","));
373
+ if (options.tools && options.tools.length > 0) {
374
+ // pi keeps every MCP tool under --tools unless an entry names one, so name the granted ones (and the resource
375
+ // tools their servers need), or run without MCP at all when nothing is granted.
376
+ const mcp = options.mcpTools?.filter((name) => name.startsWith("mcp__")) ?? [];
377
+ args.push("--tools", [...options.tools, ...(mcp.length ? [...mcp, ...MCP_RESOURCE_TOOLS] : [])].join(","));
378
+ if (!mcp.length) args.push("--no-mcp");
379
+ }
371
380
  if (options.systemPromptFile) args.push("--append-system-prompt", options.systemPromptFile);
372
381
  return args;
373
382
  }
@@ -775,7 +784,7 @@ export async function runPiAgent(given: PiRunOptions, run: ProcessRunner = spawn
775
784
  try {
776
785
  prompt = writePromptFile([options.systemPrompt, host.note].filter(Boolean).join("\n\n"));
777
786
  if (options.signal?.aborted) return { status: "cancelled", output: "", usage: { input: 0, output: 0, cost: 0, turns: 0 } };
778
- const args = buildPiArgs({ ...options, tools: host.tools, systemPromptFile: prompt.file });
787
+ const args = buildPiArgs({ ...options, tools: host.tools, mcpTools: host.mcpTools, systemPromptFile: prompt.file });
779
788
  const outcome = await run(args, {
780
789
  cwd: options.cwd,
781
790
  signal: options.signal,
package/src/lobby/host.ts CHANGED
@@ -160,6 +160,10 @@ export interface LobbyService {
160
160
  sessionName(): string | undefined;
161
161
  /** Background sessions this window started, oldest first. */
162
162
  sessions(): readonly BackgroundSession[];
163
+ /** Rename this window's session (no key) or a background one; returns a notice (throws when it cannot). */
164
+ renameSession?(key: string | undefined, name: string): string;
165
+ /** Put an ended background session away from the list, or bring it back; returns a notice (throws when it cannot). */
166
+ archiveSession?(key: string, archived: boolean): string;
163
167
  /** Start a task in a new background session, named after the task; the session, or why not. */
164
168
  startSession(start: { request?: string; plan?: PlannedTask; auto?: boolean }): BackgroundSession | string;
165
169
  /** Auto mode for any task, whichever session drives it. */
@@ -174,6 +178,8 @@ export interface LobbyService {
174
178
  */
175
179
  resumeTask?(taskId: string): Promise<{ notice: string; key?: string }>;
176
180
  acceptTask?(taskId: string): Promise<{ notice: string }>;
181
+ /** Skip a plan step (0-based, named by its text too) with the user's reason. */
182
+ skipTaskStep?(taskId: string, step: number, text: string, reason: string): Promise<{ notice: string }>;
177
183
  forceCloseTask?(taskId: string, title: string): Promise<{ notice: string }>;
178
184
  canForceCloseTask?(taskId: string): boolean;
179
185
  resolveTaskApproval?(taskId: string, approvalId: string, decision: "approved" | "rejected", note?: string): Promise<{ notice: string }>;
package/src/lobby/keys.ts CHANGED
@@ -9,9 +9,10 @@ export const LOBBY_ACTIONS = {
9
9
  help: { key: "alt+h", help: "show or hide this list" },
10
10
  settings: { key: "alt+s", help: "settings: each agent's model and effort" },
11
11
  savePlan: { key: "ctrl+s", help: "save the plan from the Plan tab" },
12
- sidebar: { key: "alt+b", help: "open the sidebar on a phone" },
12
+ sidebar: { key: "alt+b", help: "fold or open the sidebar (on a phone, open it)" },
13
13
  activity: { key: "alt+a", help: "open or fold every step in the Lobby conversation" },
14
14
  thinking: { key: "alt+t", help: "show or hide Thinking on the Lobby" },
15
+ terminal: { key: "ctrl+`", help: "show or hide the terminal" },
15
16
  nextTab: { key: "alt+]", help: "next tab" },
16
17
  prevTab: { key: "alt+[", help: "previous tab" },
17
18
  } as const;