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

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 (66) hide show
  1. package/README.md +180 -17
  2. package/package.json +12 -6
  3. package/prompts/backend.md +3 -1
  4. package/prompts/designer.md +24 -1
  5. package/prompts/master.md +98 -19
  6. package/prompts/quickfix.md +16 -3
  7. package/prompts/researcher.md +8 -2
  8. package/prompts/reviewer.md +42 -0
  9. package/prompts/worker.md +7 -0
  10. package/src/agents/backend.ts +2 -2
  11. package/src/agents/designer.ts +2 -2
  12. package/src/ask/dialog.ts +167 -0
  13. package/src/ask/image.ts +202 -0
  14. package/src/ask/png.ts +179 -0
  15. package/src/ask/relay.ts +89 -0
  16. package/src/ask/state.ts +160 -0
  17. package/src/ask/tool.ts +126 -0
  18. package/src/ask/types.ts +55 -0
  19. package/src/ask/view.ts +159 -0
  20. package/src/classifier/triage.ts +24 -7
  21. package/src/execution/agent-runner.ts +103 -11
  22. package/src/execution/git.ts +111 -14
  23. package/src/execution/pi-runner.ts +164 -11
  24. package/src/index.ts +9 -0
  25. package/src/lobby/ask.ts +10 -120
  26. package/src/lobby/feed.ts +6 -1
  27. package/src/lobby/layout.ts +27 -8
  28. package/src/lobby/markdown.ts +36 -6
  29. package/src/lobby/planner.ts +1 -1
  30. package/src/lobby/quickfix.ts +50 -3
  31. package/src/lobby/runtime.ts +41 -40
  32. package/src/lobby/tabs/home.ts +112 -48
  33. package/src/lobby/tabs/issues.ts +4 -3
  34. package/src/lobby/tabs/plan.ts +4 -4
  35. package/src/lobby/tabs/quickfix.ts +8 -2
  36. package/src/lobby/tabs/tasks.ts +16 -5
  37. package/src/lobby/theme.ts +30 -0
  38. package/src/lobby/view.ts +36 -5
  39. package/src/master/decisions.ts +10 -2
  40. package/src/master/master.ts +41 -4
  41. package/src/master/research.ts +5 -2
  42. package/src/pi/commands.ts +108 -13
  43. package/src/pi/events.ts +48 -10
  44. package/src/pi/quiet.ts +22 -4
  45. package/src/pi/route.ts +180 -0
  46. package/src/pi/start-task.ts +56 -4
  47. package/src/pi/tools.ts +32 -9
  48. package/src/pi/ui.ts +6 -1
  49. package/src/pi/zen-metrics.ts +13 -3
  50. package/src/pi/zen.ts +16 -9
  51. package/src/roles/reviewer.ts +23 -4
  52. package/src/roles/worker.ts +28 -2
  53. package/src/schemas/configuration.ts +15 -0
  54. package/src/schemas/findings.ts +14 -0
  55. package/src/schemas/task.ts +52 -0
  56. package/src/state/budget.ts +274 -0
  57. package/src/state/changes.ts +231 -0
  58. package/src/text.ts +28 -2
  59. package/src/web/extract.ts +332 -0
  60. package/src/web/fetch.ts +232 -0
  61. package/src/web/html.ts +183 -0
  62. package/src/web/read.ts +113 -0
  63. package/src/web/search.ts +202 -0
  64. package/src/web/tools.ts +279 -0
  65. package/src/workflow/track.ts +436 -0
  66. package/src/workflow/workflow.ts +734 -46
@@ -5,10 +5,37 @@
5
5
  * text, since the lobby repaints many times a second.
6
6
  */
7
7
  import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
8
- import { Markdown, stripTerminalSequences, type MarkdownTheme } from "@earendil-works/pi-tui";
8
+ import { Markdown, stripTerminalSequences, type DefaultTextStyle, type MarkdownOptions, type MarkdownTheme } from "@earendil-works/pi-tui";
9
+
10
+ /**
11
+ * Whose words a piece of Markdown is, which sets how its plain text reads, as
12
+ * pi draws the same things: an agent's reply in the theme's text colour, your
13
+ * own words in yours (list markers and backslash escapes kept as typed), a
14
+ * thought dimmed and in italics.
15
+ */
16
+ export type MarkdownStyle = "reply" | "you" | "thought";
17
+
18
+ /** The plain-text look of each style but a reply; the lobby takes them from the session's theme. */
19
+ export type MarkdownStyles = Partial<Record<Exclude<MarkdownStyle, "reply">, DefaultTextStyle>>;
9
20
 
10
21
  /** Markdown rendered to lines at a width; `keep: false` for text seen once (a reply still streaming), so it does not crowd the cache. */
11
- export type MarkdownRenderer = (text: string, width: number, keep?: boolean) => string[];
22
+ export type MarkdownRenderer = (text: string, width: number, keep?: boolean, style?: MarkdownStyle) => string[];
23
+
24
+ /** How pi renders what you write: your ordered-list markers and backslash escapes stay as you typed them. */
25
+ const YOUR_WORDS: MarkdownOptions = { preserveOrderedListMarkers: true, preserveBackslashEscapes: true };
26
+
27
+ /**
28
+ * Line breaks a model wrote out as `\n` (text escaped twice on its way here,
29
+ * so a whole plan arrives as one line): made real when the text has no real
30
+ * line break at all, outside inline code, where `\n` is usually meant.
31
+ */
32
+ export function unescapeBreaks(text: string): string {
33
+ if (text.includes("\n") || !text.includes("\\n")) return text;
34
+ return text
35
+ .split(/(`[^`\n]*`)/)
36
+ .map((part, index) => (index % 2 === 1 ? part : part.replace(/(?:\\r)?\\n/g, "\n")))
37
+ .join("");
38
+ }
12
39
 
13
40
  /** A heading line as pi renders it: optional styling, then `#`…`######` and a space. */
14
41
  const HEADING_HASHES = /^((?:\x1b\[[0-9;]*m)*)#{1,6} ((?:\x1b\[[0-9;]*m)*)/;
@@ -29,10 +56,10 @@ export const CACHE_LIMIT = 512;
29
56
  * theme object on every call, so the theme is taken once, here: a theme
30
57
  * switch makes a new renderer (the lobby keeps one per pi theme).
31
58
  */
32
- export function createMarkdownRenderer(theme: MarkdownTheme = getMarkdownTheme()): MarkdownRenderer {
59
+ export function createMarkdownRenderer(theme: MarkdownTheme = getMarkdownTheme(), styles: MarkdownStyles = {}): MarkdownRenderer {
33
60
  const cache = new Map<string, string[]>();
34
- return (text, width, keep = true) => {
35
- const key = `${width}\0${text}`;
61
+ return (text, width, keep = true, style = "reply") => {
62
+ const key = `${style}\0${width}\0${text}`;
36
63
  const hit = cache.get(key);
37
64
  if (hit) {
38
65
  // Most recently used last, so the oldest is the one dropped.
@@ -40,7 +67,10 @@ export function createMarkdownRenderer(theme: MarkdownTheme = getMarkdownTheme()
40
67
  cache.set(key, hit);
41
68
  return hit;
42
69
  }
43
- const lines = new Markdown(text, 0, 0, theme).render(Math.max(1, width)).map((line) => tidyHeading(line.trimEnd(), theme.bold));
70
+ const look = style === "reply" ? undefined : styles[style];
71
+ const lines = new Markdown(unescapeBreaks(text), 0, 0, theme, look, style === "you" ? YOUR_WORDS : undefined)
72
+ .render(Math.max(1, width))
73
+ .map((line) => tidyHeading(line.trimEnd(), theme.bold));
44
74
  // Drop the blank lines pi pads the render with at either end.
45
75
  while (lines.length > 0 && !lines[0]!.trim()) lines.shift();
46
76
  while (lines.length > 0 && !lines.at(-1)!.trim()) lines.pop();
@@ -37,7 +37,7 @@ import { fellShort, profileLabel, type EffortRouter, type EffortScore } from "..
37
37
 
38
38
  /** Seats and the oracle read the repository to ask informed questions; they never edit. */
39
39
  export const PLANNER_TOOLS: readonly string[] = ["read", "grep", "find", "ls"];
40
- /** RESEARCH also gets the web tools (from pi-web-access; pi ignores them when it is not installed). */
40
+ /** RESEARCH also gets bot-lobby's web tools (src/web/tools.ts). */
41
41
  export const RESEARCH_PANEL_TOOLS: readonly string[] = [...PLANNER_TOOLS, "web_search", "fetch_content", "source_check", "get_search_content"];
42
42
 
43
43
  /** The chair of the panel, as the Plan tab, the feed and the metrics name it. */
@@ -9,6 +9,7 @@ import { loadPrompt } from "../prompts/loader.ts";
9
9
  import { runPiAgent, spawnPiProcess, type PiStreamEvent, type ProcessRunner } from "../execution/pi-runner.ts";
10
10
  import { describeToolCall } from "../pi/activity.ts";
11
11
  import { appendMetrics, type MetricRecord } from "../state/metrics.ts";
12
+ import { appendChange, EditLog } from "../state/changes.ts";
12
13
  import type { LobbyFeed } from "./feed.ts";
13
14
  import type { FileHinter } from "../classifier/files.ts";
14
15
  import type { Classifier } from "../classifier/classifier.ts";
@@ -51,6 +52,21 @@ export interface QuickFixJob {
51
52
  /** The classifier routed it down (`trivial 0.88: p/big · low → p/cheap · low`); cleared when it re-ran on the configured profile. */
52
53
  route?: string;
53
54
  routedFrom?: string;
55
+ /** Files it changed with `edit`/`write`, as shown (relative to the project); the change ledger lets a running task's QA gate tell them from its own. */
56
+ files?: string[];
57
+ /** Runs on this profile instead of the quick-fix settings: a quick feature the oracle routed here gets its builder's model, thinking and time. */
58
+ profile?: QuickFixProfile;
59
+ /** The oracle sent it here from a request typed to the lobby. */
60
+ routed?: boolean;
61
+ }
62
+
63
+ /** How a job enters the queue besides its prompt. */
64
+ export interface SubmitOptions {
65
+ /** Skip the classifier's "looks like a task" hold (the oracle already routed it here). */
66
+ force?: boolean;
67
+ profile?: QuickFixProfile;
68
+ /** Shown with the job: where it came from. */
69
+ note?: string;
54
70
  }
55
71
 
56
72
  export interface QuickFixProfile {
@@ -103,6 +119,7 @@ export class QuickFixQueue {
103
119
  jobs: QuickFixJob[] = [];
104
120
  private readonly deps: QuickFixDeps;
105
121
  private readonly controllers = new Map<string, AbortController>();
122
+ private readonly edits = new Map<string, EditLog>();
106
123
  private counter = 0;
107
124
 
108
125
  constructor(deps: QuickFixDeps) {
@@ -114,10 +131,21 @@ export class QuickFixQueue {
114
131
  }
115
132
 
116
133
  /** Queue a quick fix; it starts at once when nothing else is running. */
117
- submit(prompt: string, now = Date.now()): QuickFixJob {
134
+ submit(prompt: string, now = Date.now(), options: SubmitOptions = {}): QuickFixJob {
118
135
  const text = prompt.trim();
119
136
  if (!text) throw new Error("a quick fix needs a prompt");
120
- const job: QuickFixJob = { id: `QF-${++this.counter}`, prompt: text, status: "queued", createdAt: now, steps: [], tools: 0, turns: 0 };
137
+ const job: QuickFixJob = {
138
+ id: `QF-${++this.counter}`,
139
+ prompt: text,
140
+ status: "queued",
141
+ createdAt: now,
142
+ steps: [],
143
+ tools: 0,
144
+ turns: 0,
145
+ ...(options.force ? { force: true, routed: true } : {}),
146
+ ...(options.profile ? { profile: options.profile } : {}),
147
+ ...(options.note ? { note: options.note } : {}),
148
+ };
121
149
  this.jobs = [...this.jobs, job].filter((entry, index, all) => isActive(entry) || index >= all.length - MAX_JOBS);
122
150
  this.changed();
123
151
  this.pump();
@@ -188,6 +216,8 @@ export class QuickFixQueue {
188
216
  const now = Date.now();
189
217
  if (event.type === "tool_execution_start") {
190
218
  job.tools += 1;
219
+ const edits = this.edits.get(job.id);
220
+ if (edits?.note(event.toolName, event.args)) job.files = edits.shown();
191
221
  this.addStep(job, describeToolCall(event.toolName, event.args), now);
192
222
  } else if (event.type === "turn_start") job.turns += 1;
193
223
  else if (event.type === "thought") this.deps.feed?.thought(QUICK_FIX_SOURCE, event.text, now);
@@ -197,7 +227,7 @@ export class QuickFixQueue {
197
227
  }
198
228
 
199
229
  private async run(job: QuickFixJob): Promise<void> {
200
- const profile = this.deps.profile();
230
+ const profile = job.profile ?? this.deps.profile();
201
231
  const controller = new AbortController();
202
232
  this.controllers.set(job.id, controller);
203
233
  job.status = "running";
@@ -211,6 +241,7 @@ export class QuickFixQueue {
211
241
  return this.hold(job, held);
212
242
  }
213
243
  this.deps.feed?.log(QUICK_FIX_SOURCE, `started: ${jobTitle(job)}`, "info", job.startedAt);
244
+ this.edits.set(job.id, new EditLog(this.deps.cwd));
214
245
  try {
215
246
  const [likely, route] = await Promise.all([this.likely(job.prompt, controller.signal), this.route(job.prompt, profile, controller.signal)]);
216
247
  const attempt = (model: string | undefined, thinking: string) => runPiAgent(
@@ -280,6 +311,8 @@ export class QuickFixQueue {
280
311
  try {
281
312
  const sized = await quickFixSize(jev, job.prompt, signal);
282
313
  if (!sized || sized.size !== "large" || sized.confidence < jev.config.thresholds.quickFixLargeAt) return undefined;
314
+ // Large but still one engineer's work (a rich page in one file): it runs.
315
+ if ((sized.solo ?? 0) >= jev.config.thresholds.quickFixAt) return undefined;
283
316
  return `looks like a task (large, ${sized.confidence.toFixed(2)})`;
284
317
  } catch {
285
318
  return undefined;
@@ -309,6 +342,20 @@ export class QuickFixQueue {
309
342
  private finish(job: QuickFixJob): void {
310
343
  job.finishedAt = Date.now();
311
344
  for (const step of job.steps) step.pending = false;
345
+ const edited = this.edits.get(job.id)?.list() ?? [];
346
+ this.edits.delete(job.id);
347
+ // Whatever it edited, even when it failed, is on record for a task's QA gate as the user's own request.
348
+ if (edited.length > 0) {
349
+ appendChange(this.deps.root, this.deps.configDir, {
350
+ source: "quickfix",
351
+ id: job.id,
352
+ what: jobTitle(job),
353
+ files: edited,
354
+ startedAt: new Date(job.startedAt ?? job.createdAt).toISOString(),
355
+ finishedAt: new Date(job.finishedAt).toISOString(),
356
+ status: job.status,
357
+ });
358
+ }
312
359
  this.deps.feed?.end(job.id, job.status !== "success");
313
360
  const ok = job.status === "success";
314
361
  const outcome = ok ? "done" : `${job.status}${job.error ? ` — ${job.error.split("\n")[0]}` : ""}`;
@@ -6,8 +6,8 @@
6
6
  * turn into the feed, passes plan comments to the Master that owns a task and
7
7
  * records each Master turn in the metrics log.
8
8
  */
9
- import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
10
- import { getMarkdownTheme, getSelectListTheme, SessionManager } from "@earendil-works/pi-coding-agent";
9
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
10
+ import { getSelectListTheme, SessionManager } from "@earendil-works/pi-coding-agent";
11
11
  import { Key, type OverlayHandle, type TUI } from "@earendil-works/pi-tui";
12
12
  import { TERMINAL_STATES, type Task } from "../schemas/task.ts";
13
13
  import { loadTask, peekTasks } from "../state/persistence.ts";
@@ -26,22 +26,24 @@ import { shortTitle } from "../text.ts";
26
26
  import { isSubagentProcess } from "../pi/quiet.ts";
27
27
  import { modelRef, resolveLobbyProfile, resolvePanelProfile } from "../pi/model-support.ts";
28
28
  import { modelLookup } from "../pi/tools.ts";
29
- import { startPlannedTask, startTask } from "../pi/start-task.ts";
29
+ import { startPlannedTask } from "../pi/start-task.ts";
30
+ import { pendingRequest, setQuickFixHandoff, startRequest } from "../pi/route.ts";
31
+ import type { Domain } from "../schemas/agent.ts";
30
32
  import type { LobbyAgentKind, LobbyPanel, PanelMember } from "../schemas/configuration.ts";
31
33
  import { chatFromEntries, lobbyFeed, narrateEvent, type AgentEventLike, type ChatEntry } from "./feed.ts";
32
34
  import { classifier, effortFor, hintsFor } from "../classifier/instance.ts";
33
35
  import { checkThinking } from "../pi/model-support.ts";
34
36
  import { launchPi, SessionRegistry, type BackgroundSession, type SessionLauncher } from "./sessions.ts";
35
37
  import { SessionChats } from "./session-files.ts";
36
- import { answerMessage, dialogAsker, loadAskTool, questionnaires, toolAsker, type Asker } from "./ask.ts";
38
+ import { answerMessage, askUser, questionnaires, type Asker } from "./ask.ts";
37
39
  import { QuickFixQueue } from "./quickfix.ts";
38
40
  import { PlanningSession, type PlannerSeed } from "./planner.ts";
39
41
  import { execCommand, IssuesState } from "./issues.ts";
40
42
  import { LobbyView, type LiveSession, type LobbyHost, type SwitchTarget, type TabId } from "./view.ts";
41
- import type { LobbyTheme } from "./layout.ts";
42
- import { createMarkdownRenderer } from "./markdown.ts";
43
+ import { lobbyTheme } from "./theme.ts";
43
44
  import { deliverComments, onOwnerEvent, setAuto } from "../pi/owner.ts";
44
45
  import { openEntrySettings, openSettings } from "../pi/settings-ui.ts";
46
+ import { budgetClock } from "../state/budget.ts";
45
47
 
46
48
  export const ANCHOR_KEY = "bot-lobby-anchor";
47
49
  export { deliverComments };
@@ -62,8 +64,8 @@ interface Runtime {
62
64
  planner?: PlanningSession;
63
65
  issues: IssuesState;
64
66
  unsubscribeFeed?: () => void;
65
- /** The ask-user-question tool (or pi's dialogs), loaded once on first use. */
66
- asker?: Promise<Asker>;
67
+ /** Puts the panel's questions to the user: the questionnaire unless a test sets another. */
68
+ asker?: Asker;
67
69
  /** A questionnaire is on screen. */
68
70
  asking: boolean;
69
71
  /** The lobby turned the terminal's mouse reporting on (pi's regular screen only). */
@@ -149,9 +151,10 @@ function toOracle(state: Runtime, text: string): string | undefined {
149
151
  state.pi.sendUserMessage(text, { expandPromptTemplates: true, ...(busy ? { deliverAs: "followUp" as const } : {}) });
150
152
  return `sent ${text.split(/\s+/)[0]} to pi (built-in commands need the lobby hidden: alt+l)`;
151
153
  }
152
- if (!currentZenTask()) {
153
- startTask(state.pi, state.ctx, state.configDir, text).catch((error: Error) => failed(state, "could not start the task", error));
154
- return "starting a task — the oracle takes it from here";
154
+ // While the oracle decides where a request goes, what the user types joins that conversation.
155
+ if (!currentZenTask() && !pendingRequest()) {
156
+ startRequest(state.pi, state.ctx, state.configDir, text).catch((error: Error) => failed(state, "could not start the task", error));
157
+ return "reading your request — a quick fix or a task, the oracle takes it from here";
155
158
  }
156
159
  state.pi.sendUserMessage(text, busy ? { deliverAs: "steer" } : undefined);
157
160
  return undefined;
@@ -425,7 +428,9 @@ function deleteTask(state: Runtime, taskId: string, where: "list" | "archive"):
425
428
 
426
429
  /** A task's status box without animations, for a session other than this window's. */
427
430
  function taskScene(state: Runtime, task: Task, width: number, height: number): string[] {
428
- return panelLines(task, persistedRuns(task), Date.now(), false, { width, rows: Math.floor(height / 0.75), still: true, theme: state.ctx.ui.theme });
431
+ const now = Date.now();
432
+ const time = budgetClock(state.root, state.configDir, task.id, now);
433
+ return panelLines(task, persistedRuns(task), now, false, { width, rows: Math.floor(height / 0.75), still: true, theme: state.ctx.ui.theme, ...(time ? { time } : {}) });
429
434
  }
430
435
 
431
436
  function seatProfile(state: Runtime, member: PanelMember) {
@@ -463,10 +468,9 @@ function newPlanner(state: Runtime, seed?: PlannerSeed, seats?: readonly PanelMe
463
468
  return state.planner;
464
469
  }
465
470
 
466
- /** The library's questionnaire when it loads, pi's own dialogs otherwise. */
467
- function panelAsker(state: Runtime): Promise<Asker> {
468
- state.asker ??= loadAskTool(state.pi).then((tool) => (tool ? toolAsker(tool) : dialogAsker()));
469
- return state.asker;
471
+ /** The questionnaire (pi's own dialogs where it cannot be drawn); tests swap it. */
472
+ function panelAsker(state: Runtime): Asker {
473
+ return state.asker ?? askUser;
470
474
  }
471
475
 
472
476
  /**
@@ -483,7 +487,7 @@ export async function answerPanel(state: Runtime | undefined = runtime): Promise
483
487
  if (state.asking) return "the questions are already open";
484
488
  state.asking = true;
485
489
  try {
486
- const asker = await panelAsker(state);
490
+ const asker = panelAsker(state);
487
491
  const chunks = questionnaires(session.questions);
488
492
  for (let index = session.answered.length; index < chunks.length; index++) {
489
493
  const result = await asker(chunks[index]!, state.ctx);
@@ -508,29 +512,6 @@ export async function answerPanel(state: Runtime | undefined = runtime): Promise
508
512
  }
509
513
  }
510
514
 
511
- const lobbyThemes = new WeakMap<Theme, LobbyTheme>();
512
-
513
- /**
514
- * pi's theme as the lobby draws with it, plus Markdown rendering; one wrapper
515
- * (and one Markdown renderer with its cache) per theme, so caches stay warm
516
- * and a theme switch starts fresh.
517
- */
518
- function lobbyTheme(theme: Theme): LobbyTheme {
519
- let wrapped = lobbyThemes.get(theme);
520
- if (!wrapped) {
521
- wrapped = {
522
- fg: (color, text) => theme.fg(color, text),
523
- bold: (text) => theme.bold(text),
524
- italic: (text) => theme.italic(text),
525
- bg: (color, text) => theme.bg(color, text),
526
- markdown: createMarkdownRenderer(getMarkdownTheme()),
527
- strike: (text) => theme.strikethrough(text),
528
- };
529
- lobbyThemes.set(theme, wrapped);
530
- }
531
- return wrapped;
532
- }
533
-
534
515
  function host(state: Runtime, tui: TUI): LobbyHost {
535
516
  return {
536
517
  rows: () => tui.terminal.rows,
@@ -728,6 +709,7 @@ function shutdown(): void {
728
709
  const state = runtime;
729
710
  if (!state) return;
730
711
  runtime = undefined;
712
+ setQuickFixHandoff(undefined);
731
713
  setMouse(state, false);
732
714
  state.unsubscribeFeed?.();
733
715
  state.quickfix.cancelAll();
@@ -739,6 +721,24 @@ function shutdown(): void {
739
721
  onMinimizeChange(undefined);
740
722
  }
741
723
 
724
+ /**
725
+ * The oracle routed a request to the quick-fix agent: queue it (no "looks like
726
+ * a task" hold, the oracle already decided), on its builder's settings when it
727
+ * is a quick feature, and show it on the Quick fix tab.
728
+ */
729
+ function handToQuickFix(state: Runtime, request: string, builder: Domain | undefined, reason: string): string {
730
+ const quick = lobbyProfile(state, "quickfix");
731
+ const profile = builder ? { ...seatProfile(state, builder), instructions: quick.instructions } : undefined;
732
+ const job = state.quickfix.submit(request, Date.now(), {
733
+ force: true,
734
+ ...(profile ? { profile } : {}),
735
+ note: `The oracle sent your request here: ${reason.replace(/[.\s]+$/, "")}.${builder ? ` A quick feature: it runs on ${builder === "designer" ? "DESIGN" : "DEV"}'s model, thinking and time limit.` : ""}`,
736
+ });
737
+ showLobby("quickfix");
738
+ state.view?.showQuickFix(job.id);
739
+ return job.id;
740
+ }
741
+
742
742
  /** Create the session's lobby runtime (interactive master sessions only). */
743
743
  export function initLobby(pi: ExtensionAPI, ctx: ExtensionContext, configDir: string): void {
744
744
  shutdown();
@@ -776,6 +776,7 @@ export function initLobby(pi: ExtensionAPI, ctx: ExtensionContext, configDir: st
776
776
  effort: effortFor((model, thinking) => checkThinking(modelLookup(ctx)(model), thinking).level),
777
777
  });
778
778
  runtime = state;
779
+ setQuickFixHandoff((request, builder, reason) => (runtime === state ? handToQuickFix(state, request, builder, reason) : undefined));
779
780
  lobbyFeed.clear();
780
781
  // Only the newest messages are kept; the feed learns whether earlier ones exist, and loads them when scrolled to.
781
782
  lobbyFeed.seedChat(chatFromEntries(ctx.sessionManager.getBranch(), Number.POSITIVE_INFINITY));