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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +102 -11
  2. package/package.json +1 -4
  3. package/prompts/master.md +47 -1
  4. package/prompts/researcher.md +8 -2
  5. package/prompts/reviewer.md +42 -0
  6. package/prompts/worker.md +7 -0
  7. package/src/ask/dialog.ts +167 -0
  8. package/src/ask/image.ts +202 -0
  9. package/src/ask/png.ts +179 -0
  10. package/src/ask/relay.ts +89 -0
  11. package/src/ask/state.ts +160 -0
  12. package/src/ask/tool.ts +126 -0
  13. package/src/ask/types.ts +55 -0
  14. package/src/ask/view.ts +159 -0
  15. package/src/execution/agent-runner.ts +103 -11
  16. package/src/execution/git.ts +111 -14
  17. package/src/execution/pi-runner.ts +164 -11
  18. package/src/index.ts +6 -0
  19. package/src/lobby/ask.ts +10 -120
  20. package/src/lobby/layout.ts +27 -8
  21. package/src/lobby/markdown.ts +36 -6
  22. package/src/lobby/planner.ts +1 -1
  23. package/src/lobby/quickfix.ts +21 -0
  24. package/src/lobby/runtime.ts +14 -36
  25. package/src/lobby/tabs/home.ts +112 -48
  26. package/src/lobby/tabs/issues.ts +4 -3
  27. package/src/lobby/tabs/plan.ts +4 -4
  28. package/src/lobby/tabs/quickfix.ts +7 -1
  29. package/src/lobby/tabs/tasks.ts +14 -4
  30. package/src/lobby/theme.ts +30 -0
  31. package/src/lobby/view.ts +27 -5
  32. package/src/master/decisions.ts +1 -1
  33. package/src/master/master.ts +41 -4
  34. package/src/master/research.ts +5 -2
  35. package/src/pi/commands.ts +94 -10
  36. package/src/pi/events.ts +47 -10
  37. package/src/pi/quiet.ts +22 -4
  38. package/src/pi/start-task.ts +8 -2
  39. package/src/pi/tools.ts +26 -8
  40. package/src/pi/ui.ts +6 -1
  41. package/src/pi/zen-metrics.ts +13 -3
  42. package/src/pi/zen.ts +16 -9
  43. package/src/roles/reviewer.ts +23 -4
  44. package/src/roles/worker.ts +18 -0
  45. package/src/schemas/configuration.ts +4 -0
  46. package/src/schemas/findings.ts +14 -0
  47. package/src/schemas/task.ts +21 -0
  48. package/src/state/budget.ts +274 -0
  49. package/src/state/changes.ts +231 -0
  50. package/src/text.ts +28 -2
  51. package/src/web/extract.ts +332 -0
  52. package/src/web/fetch.ts +232 -0
  53. package/src/web/html.ts +183 -0
  54. package/src/web/read.ts +113 -0
  55. package/src/web/search.ts +202 -0
  56. package/src/web/tools.ts +279 -0
  57. package/src/workflow/workflow.ts +592 -42
@@ -4,7 +4,7 @@
4
4
  * becomes a task.
5
5
  */
6
6
  import type { IssueDetail, IssueSummary } from "../issues.ts";
7
- import { bold, since, columns, fill, paint, rule, selectRow, spinner, split, windowStart, wrap, type LobbyTheme } from "../layout.ts";
7
+ import { bold, since, columns, fill, markdownLines, paint, rule, selectRow, spinner, split, windowStart, wrap, type LobbyTheme } from "../layout.ts";
8
8
 
9
9
  export interface IssuesTabInput {
10
10
  issues: readonly IssueSummary[];
@@ -31,9 +31,10 @@ export function issueDetailLines(issue: IssueDetail, width: number, now: number,
31
31
  ].filter(Boolean);
32
32
  const lines = [...wrap(bold(theme, `#${issue.number} ${issue.title}`), width), facts.join(paint(theme, "dim", " · "))];
33
33
  if (issue.url) lines.push(paint(theme, "dim", issue.url));
34
- lines.push("", paint(theme, "dim", "p plans it with the planning panel, then save it as a task"), "", ...wrap(issue.body.trim() || paint(theme, "dim", "(no description)"), width));
34
+ // Issues and their comments are GitHub Markdown.
35
+ lines.push("", paint(theme, "dim", "p plans it with the planning panel, then save it as a task"), "", ...(issue.body.trim() ? markdownLines(issue.body.trim(), width, theme) : [paint(theme, "dim", "(no description)")]));
35
36
  for (const comment of issue.comments) {
36
- lines.push("", rule(width, comment.author ?? "comment", theme, comment.createdAt ? since(now - Date.parse(comment.createdAt)) : ""), ...wrap(comment.body.trim(), width));
37
+ lines.push("", rule(width, comment.author ?? "comment", theme, comment.createdAt ? since(now - Date.parse(comment.createdAt)) : ""), ...markdownLines(comment.body.trim(), width, theme));
37
38
  }
38
39
  return lines;
39
40
  }
@@ -9,7 +9,7 @@
9
9
  import { stripTerminalSequences } from "@earendil-works/pi-tui";
10
10
  import { ORACLE_LABEL, type LineComment, type PanelNote, type PanelQuestion, type PlannerMessage, type PlannerReply, type PlannerSeed, type RoundMode } from "../planner.ts";
11
11
  import type { PlannedTask } from "../../state/backlog.ts";
12
- import { beside, bold, box, clock, fill, fit, italic, markdownLines, notePane, paint, selectRow, spinner, wrap, wrapHanging, type LobbyTheme, type PaneLayout } from "../layout.ts";
12
+ import { beside, bold, box, clock, fill, fit, italic, markdownHanging, markdownLines, notePane, paint, selectRow, spinner, wrap, wrapHanging, type LobbyTheme, type PaneLayout } from "../layout.ts";
13
13
  import { sourceColor, speakerLine, tailWindow, youLines } from "./home.ts";
14
14
 
15
15
  export interface SeatView {
@@ -152,7 +152,7 @@ function questionLines(questions: ReadonlyArray<{ question: PanelQuestion; numbe
152
152
  const lead = `${paint(theme, "dim", `${String(number).padStart(2)}.`)} ${bold(theme, paint(theme, sourceColor(question.from), question.from.padEnd(LABEL_WIDTH)))} `;
153
153
  const indent = " ".repeat(4 + LABEL_WIDTH);
154
154
  const options = question.options.map((option) => wrapHanging(`${indent}${paint(theme, "dim", "○")} `, `${option.label}${option.description ? paint(theme, "dim", ` — ${option.description}`) : ""}`, width)).flat();
155
- return [...wrapHanging(lead, question.text, width), ...options];
155
+ return [...markdownHanging(lead, question.text, width, theme), ...options];
156
156
  });
157
157
  }
158
158
 
@@ -178,7 +178,7 @@ export function conversationLines(view: PlanView, width: number, theme?: LobbyTh
178
178
  continue;
179
179
  }
180
180
  lines.push(speakerLine("panel", width, theme, time));
181
- lines.push(...(questions.length > 0 ? questionLines(questions, width, theme) : wrap(message.text, width).map((line) => (line ? ` ${line}` : ""))));
181
+ lines.push(...(questions.length > 0 ? questionLines(questions, width, theme) : markdownLines(message.text, Math.max(1, width - 2), theme).map((line) => (line ? ` ${line}` : ""))));
182
182
  for (const entry of message.decided ?? []) {
183
183
  lines.push(...wrapHanging(` ${paint(theme, "success", "✓")} `, `${paint(theme, sourceColor(entry.from), `[${entry.from}]`)} ${entry.question} → ${bold(theme, entry.answer)} ${paint(theme, "dim", `· decided by the classifier (${entry.probability.toFixed(2)}); comment on the plan to overrule`)}`, width));
184
184
  }
@@ -210,7 +210,7 @@ export function draftLines(view: PlanView, width: number, theme?: LobbyTheme): D
210
210
  }
211
211
  if (view.notes.length > 0) {
212
212
  lines.push(line(""), line(bold(theme, paint(theme, "mdHeading", "What each seat needs"))));
213
- for (const note of view.notes) for (const wrapped of wrapHanging(`${paint(theme, sourceColor(note.from), note.from.padEnd(LABEL_WIDTH))} `, note.text, width)) lines.push(line(wrapped));
213
+ for (const note of view.notes) for (const wrapped of markdownHanging(`${paint(theme, sourceColor(note.from), note.from.padEnd(LABEL_WIDTH))} `, note.text, width, theme)) lines.push(line(wrapped));
214
214
  }
215
215
  return lines;
216
216
  }
@@ -61,7 +61,9 @@ export function jobDetailLines(job: QuickFixJob, width: number, tick: number, no
61
61
  job.tools ? `${job.tools} tool${job.tools === 1 ? "" : "s"}` : "",
62
62
  job.usage?.cost ? `$${job.usage.cost.toFixed(2)}` : "",
63
63
  ].filter(Boolean);
64
- const lines = [paint(theme, "dim", job.id), facts.join(paint(theme, "dim", " · ")), "", ...wrap(bold(theme, job.prompt), width)];
64
+ // What you asked, in Markdown as your messages read in the conversation (a pasted snippet, a list of steps).
65
+ const asked = theme?.markdown ? markdownLines(job.prompt, width, theme, true, "you") : wrap(bold(theme, job.prompt), width);
66
+ const lines = [paint(theme, "dim", job.id), facts.join(paint(theme, "dim", " · ")), "", ...asked];
65
67
  if (job.route) lines.push("", ...wrap(paint(theme, "dim", `↓ routed ${job.route}`), width));
66
68
  if (job.note) lines.push("", ...wrap(paint(theme, "warning", job.status === "held" ? `‖ Held: ${job.note}. r runs it anyway · t starts it as a task in a new session.` : job.note), width));
67
69
  lines.push("", rule(width, "Steps", theme));
@@ -70,6 +72,10 @@ export function jobDetailLines(job: QuickFixJob, width: number, tick: number, no
70
72
  const lead = `${paint(theme, "dim", clock(step.at))} ${step.pending && job.status === "running" ? paint(theme, "accent", spinner(tick)) : paint(theme, "dim", "·")} `;
71
73
  lines.push(...wrapHanging(lead, step.pending && job.status === "running" ? `${step.text}…` : paint(theme, "muted", step.text), width));
72
74
  }
75
+ if (job.files?.length) {
76
+ lines.push("", rule(width, "Edited", theme));
77
+ for (const file of job.files) lines.push(...wrapHanging(paint(theme, "dim", "· "), paint(theme, "muted", file), width));
78
+ }
73
79
  if (job.error) lines.push("", ...wrap(paint(theme, "error", `✗ ${job.error}`), width));
74
80
  if (job.report) {
75
81
  lines.push("", rule(width, "Report", theme), ...markdownLines(job.report, width, theme));
@@ -11,7 +11,8 @@ import { planChecklist } from "../../pi/zen.ts";
11
11
  import { persistedRuns } from "../../pi/ui.ts";
12
12
  import { describeRun, runFromLog } from "../../pi/run-summary.ts";
13
13
  import { pendingApprovals } from "../../workflow/approvals.ts";
14
- import { ago, beside, bold, box, detailWindow, fill, markdownLines, notePane, paint, position, rule, selectRow, since, spread, strike, windowStart, wrap, wrapHanging, type LobbyColor, type LobbyTheme, type PaneLayout } from "../layout.ts";
14
+ import { textWidth } from "../../width.ts";
15
+ import { ago, beside, bold, box, detailWindow, fill, markdownHanging, markdownLines, notePane, paint, position, rule, selectRow, since, spread, strike, windowStart, wrap, wrapHanging, type LobbyColor, type LobbyTheme, type PaneLayout } from "../layout.ts";
15
16
 
16
17
  export type TaskSection = "mine" | "others" | "pending" | "recent" | "archived";
17
18
 
@@ -240,6 +241,13 @@ export function listCount(rows: readonly TaskRow[], query?: string): string {
240
241
  const COMMENT_MARKS: Record<PlanComment["status"], string> = { open: "○", delivered: "◐", addressed: "✓" };
241
242
  const COMMENT_WORDS: Record<PlanComment["status"], string> = { open: "waiting for the owning session", delivered: "sent to the oracle", addressed: "plan amended" };
242
243
 
244
+ /** `note` at the end of the last line when it fits there, else on a line of its own under it. */
245
+ function trailing(lines: string[], note: string, width: number): string[] {
246
+ const last = lines.at(-1) ?? "";
247
+ if (textWidth(last) + 1 + textWidth(note) <= width) return [...lines.slice(0, -1), `${last} ${note}`];
248
+ return [...lines, ...wrapHanging(" ", note, width)];
249
+ }
250
+
243
251
  function section(title: string, width: number, theme?: LobbyTheme, right = ""): string[] {
244
252
  return ["", rule(width, title, theme, right)];
245
253
  }
@@ -268,7 +276,8 @@ export function taskDetailLines(task: Task, comments: readonly PlanComment[], se
268
276
  lines.push("", ` ${pips(done, steps.length, cells, theme)} ${paint(theme, "muted", `${done} of ${steps.length} steps`)}`);
269
277
  }
270
278
  const request = taskRequest(task);
271
- if (request && request !== task.title) lines.push(...section("Request", width, theme), ...wrap(paint(theme, "muted", request), width));
279
+ // Requests are often Markdown: a plan agreed in the planning panel, an issue, a pasted spec.
280
+ if (request && request !== task.title) lines.push(...section("Request", width, theme), ...markdownLines(request, width, theme));
272
281
  if (task.plan) {
273
282
  if (steps.length > 0) {
274
283
  lines.push(...section("Progress", width, theme, `${done}/${steps.length} steps`));
@@ -291,11 +300,12 @@ export function taskDetailLines(task: Task, comments: readonly PlanComment[], se
291
300
  if (comments.length === 0) lines.push(paint(theme, "dim", TERMINAL_STATES.includes(task.state) ? "No comments." : "No comments yet — press c to comment on the plan; the oracle amends it."));
292
301
  for (const comment of comments) {
293
302
  const color = comment.status === "addressed" ? "success" : comment.status === "delivered" ? "accent" : "warning";
294
- lines.push(...wrapHanging(`${paint(theme, color, COMMENT_MARKS[comment.status])} `, `${comment.text} ${paint(theme, "dim", `— ${COMMENT_WORDS[comment.status]}, ${since(now - Date.parse(comment.createdAt))}`)}`, width));
303
+ const said = markdownHanging(`${paint(theme, color, COMMENT_MARKS[comment.status])} `, comment.text, width, theme);
304
+ lines.push(...trailing(said, paint(theme, "dim", `— ${COMMENT_WORDS[comment.status]}, ${since(now - Date.parse(comment.createdAt))}`), width));
295
305
  }
296
306
  if (task.amendments.length > 0) {
297
307
  lines.push(...section("Amendments", width, theme));
298
- for (const amendment of task.amendments) lines.push(...wrapHanging("- ", amendment, width));
308
+ for (const amendment of task.amendments) lines.push(...markdownHanging("- ", amendment, width, theme));
299
309
  }
300
310
  const approvals = pendingApprovals(task);
301
311
  if (approvals.length > 0 || task.blockers.length > 0) {
@@ -0,0 +1,30 @@
1
+ /**
2
+ * pi's theme as bot-lobby draws with it (the lobby, the questionnaire), plus
3
+ * Markdown rendering; one wrapper (and one Markdown renderer with its cache)
4
+ * per theme, so caches stay warm and a theme switch starts fresh.
5
+ */
6
+ import { getMarkdownTheme, type Theme } from "@earendil-works/pi-coding-agent";
7
+ import type { LobbyTheme } from "./layout.ts";
8
+ import { createMarkdownRenderer } from "./markdown.ts";
9
+
10
+ const lobbyThemes = new WeakMap<Theme, LobbyTheme>();
11
+
12
+ export function lobbyTheme(theme: Theme): LobbyTheme {
13
+ let wrapped = lobbyThemes.get(theme);
14
+ if (!wrapped) {
15
+ wrapped = {
16
+ fg: (color, text) => theme.fg(color, text),
17
+ bold: (text) => theme.bold(text),
18
+ italic: (text) => theme.italic(text),
19
+ bg: (color, text) => theme.bg(color, text),
20
+ // The same looks pi gives your messages and the thinking it shows.
21
+ markdown: createMarkdownRenderer(getMarkdownTheme(), {
22
+ you: { color: (text) => theme.fg("accent", text) },
23
+ thought: { color: (text) => theme.fg("thinkingText", text), italic: true },
24
+ }),
25
+ strike: (text) => theme.strikethrough(text),
26
+ };
27
+ lobbyThemes.set(theme, wrapped);
28
+ }
29
+ return wrapped;
30
+ }
package/src/lobby/view.ts CHANGED
@@ -23,7 +23,7 @@ import type { QuickFixQueue } from "./quickfix.ts";
23
23
  import { MEMBER_LABELS, ORACLE_LABEL, type PlannerSeed, type PlanningSession } from "./planner.ts";
24
24
  import { issueText, type IssuesState } from "./issues.ts";
25
25
  import { actionFor, keyLabel, keyMap, LOBBY_ACTIONS, type KeyMap, type LobbyAction } from "./keys.ts";
26
- import { beside, bold, box, fit, highlight, paint, rule, selectRow, spinner, spread, windowStart, wrap, wrapHanging, type LobbyTheme, type PaneLayout } from "./layout.ts";
26
+ import { beside, bold, box, fit, highlight, paint, rule, selectRow, spinner, spread, windowStart, wrap, wrapHanging, type LobbyTheme, type PaneBox, type PaneLayout, type PaneMark } from "./layout.ts";
27
27
  import { chatTail, HOME_PANES, renderHome, type HomePane } from "./tabs/home.ts";
28
28
  import { filterRows, pips, planDetailLines, renderTasks, taskDetailLines, taskProgress, taskRows, tasksWidths, type TaskRow } from "./tabs/tasks.ts";
29
29
  import { renderPlan, type PlanLayout, type PlanView, type SeatView } from "./tabs/plan.ts";
@@ -320,6 +320,8 @@ export class LobbyView implements Component, Focusable {
320
320
  homeFocus: HomePane = "conversation";
321
321
  /** How many lines each pane held last frame, so a pane scrolled back stays on what you are reading as lines arrive. */
322
322
  private readonly seenTotals = new Map<string, number>();
323
+ /** Each Lobby pane's newest entry last frame: those panes measure what arrived below it, as their totals are estimates. */
324
+ private readonly marks = new Map<string, PaneMark>();
323
325
  /** Where the current tab's scrollable panes landed in the last frame. */
324
326
  private readonly panes: PaneLayout = new Map();
325
327
  private tasksSelected = 0;
@@ -894,11 +896,10 @@ export class LobbyView implements Component, Focusable {
894
896
  const limit = (name: string, offset: number, anchored: boolean): number => {
895
897
  const pane = this.panes.get(name);
896
898
  if (!pane) return offset;
897
- const seen = this.seenTotals.get(name);
898
- this.seenTotals.set(name, pane.total);
899
+ const grew = this.grewBelow(name, pane);
899
900
  let next = offset;
900
- if (anchored && next > 0 && seen !== undefined && pane.total > seen) {
901
- next += pane.total - seen;
901
+ if (anchored && next > 0 && grew > 0) {
902
+ next += grew;
902
903
  moved = true;
903
904
  }
904
905
  return Math.min(next, Math.max(0, pane.total - pane.rows));
@@ -922,6 +923,25 @@ export class LobbyView implements Component, Focusable {
922
923
  return moved;
923
924
  }
924
925
 
926
+ /**
927
+ * Lines that arrived below a pane since it was last looked at, counted once.
928
+ * A pane that measures them from its newest entry says so; for the others
929
+ * (their totals exact) the total's growth stands in. An estimated total also
930
+ * moves as scrolling draws more or fewer of the entries it estimates, and
931
+ * following that pinned a pane scrolled to its top there.
932
+ */
933
+ private grewBelow(name: string, pane: PaneBox): number {
934
+ const seen = this.seenTotals.get(name);
935
+ this.seenTotals.set(name, pane.total);
936
+ const follow = pane.follow;
937
+ if (!follow) return seen === undefined ? 0 : pane.total - seen;
938
+ if (follow.mark) this.marks.set(name, follow.mark);
939
+ else this.marks.delete(name);
940
+ const grew = follow.grew;
941
+ follow.grew = 0;
942
+ return grew;
943
+ }
944
+
925
945
  /** Move the draft cursor by `delta` lines, skipping blank ones, and keep it on screen. */
926
946
  private moveCursor(delta: number): void {
927
947
  const lines = this.planLayout.draftText;
@@ -2276,6 +2296,7 @@ export class LobbyView implements Component, Focusable {
2276
2296
  others,
2277
2297
  pending: this.data.plans.filter((plan) => plan.status === "pending").length,
2278
2298
  offsets: this.homeOffsets,
2299
+ anchors: this.marks,
2279
2300
  ...(this.mode === "browse" ? { focus: this.homeFocus } : {}),
2280
2301
  panes: this.panes,
2281
2302
  tick: this.tick,
@@ -2306,6 +2327,7 @@ export class LobbyView implements Component, Focusable {
2306
2327
  others: 0,
2307
2328
  pending: 0,
2308
2329
  offsets: this.homeOffsets,
2330
+ anchors: this.marks,
2309
2331
  ...(this.mode === "browse" ? { focus: this.homeFocus } : {}),
2310
2332
  panes: this.panes,
2311
2333
  tick: this.tick,
@@ -7,7 +7,7 @@ import { detectGaps, domainsInvolved } from "./synthesis.ts";
7
7
  export function completionBlockers(task: Task, pendingCount: number): string[] {
8
8
  const blockers: string[] = [];
9
9
  if (!task.plan) blockers.push("no approved plan is recorded");
10
- if (task.qaVerdict !== "pass") blockers.push(`QA gate is ${task.qaVerdict ?? "not run"}`);
10
+ if (task.qaVerdict !== "pass" && !task.qaWaiver) blockers.push(`QA gate is ${task.qaVerdict ?? "not run"}`);
11
11
  if (pendingCount > 0) blockers.push(`${pendingCount} unresolved approval request(s)`);
12
12
  if (task.blockers.length > 0) blockers.push(`${task.blockers.length} unresolved blocker(s)`);
13
13
  return blockers;
@@ -4,8 +4,8 @@ import { profileFor, type BotLobbyConfig, type ProfileResolver } from "../schema
4
4
  import type { Domain } from "../schemas/agent.ts";
5
5
  import type { AgentRun, ReviewResult, ScoutResult, WorkerResult } from "../schemas/findings.ts";
6
6
  import { domainSpec } from "../agents/registry.ts";
7
- import { runAgent, runParallel, watchdogOptions, type AgentRequest } from "../execution/agent-runner.ts";
8
- import { spawnPiProcess, type ProcessRunner } from "../execution/pi-runner.ts";
7
+ import { runAgent, runParallel, watchdogOptions, type AgentRequest, type AgentTime } from "../execution/agent-runner.ts";
8
+ import { spawnPiProcess, type ProcessRunner, type RelayAsk } from "../execution/pi-runner.ts";
9
9
  import { readAgentKnowledge, writeFileEnsured } from "../knowledge/store.ts";
10
10
  import { selectKnowledge, type KnowledgeSelection } from "../knowledge/selector.ts";
11
11
  import { summarizeOutcomes } from "./synthesis.ts";
@@ -24,6 +24,8 @@ export interface ScoutOutcome {
24
24
  }
25
25
 
26
26
  export interface ScoutRequest {
27
+ /** Under a task time budget: the batch's time (every scout runs within it). */
28
+ time?: AgentTime;
27
29
  taskId: string;
28
30
  /** Requirements/objective text used for context selection. */
29
31
  taskText: string;
@@ -65,7 +67,7 @@ function scoutContext(request: ScoutRequest, domain: Domain, likely = ""): Agent
65
67
  task: request.taskText,
66
68
  ...selectKnowledge(`${request.taskText} ${domainSpec(domain).scoutFocus}`, slices),
67
69
  instructions: request.config.agents[domain].instructions,
68
- workflowContext: [`Task state: scouting. Domain: ${domain}. Read-only reconnaissance; no implementation.`, likely].filter(Boolean).join("\n\n"),
70
+ workflowContext: [`Task state: scouting. Domain: ${domain}. Read-only reconnaissance; no implementation.`, likely, request.time?.note ?? ""].filter(Boolean).join("\n\n"),
69
71
  };
70
72
  }
71
73
 
@@ -120,6 +122,7 @@ export async function runScouts(request: ScoutRequest, run: ProcessRunner = spaw
120
122
  context: scoutContext(request, domain, likely[index]),
121
123
  ...(extraTools.length > 0 ? { extraTools } : {}),
122
124
  ...profileFields(request.config, request.profile, domain, "scout"),
125
+ ...(request.time ? { time: { ...request.time } } : {}),
123
126
  cwd: request.cwd,
124
127
  signal: request.signal,
125
128
  onUpdate: request.onUpdate,
@@ -203,6 +206,21 @@ export interface WorkerRequest {
203
206
  hints?: FileHinter;
204
207
  /** Lowers thinking (or the model) for a step the classifier judges simple or trivial. */
205
208
  effort?: EffortRouter;
209
+ /** Under a task time budget: the step's time, and who decides on more when it runs out. */
210
+ time?: AgentTime;
211
+ /** The worker may ask the user (the designer): its questions are relayed, and its images saved in `previews`. */
212
+ ask?: { onAsk: RelayAsk; previews: string };
213
+ }
214
+
215
+ /** What a worker that may ask the user is told about it. */
216
+ function askGuidance(previews: string): string {
217
+ return [
218
+ "You can ask the user with ask_user_question: the oracle relays it, and your clock stops while they answer.",
219
+ "Ask when a design decision is genuinely theirs (a visual direction, a layout, a style), once, with all such questions together, before you build it; decide everything else yourself.",
220
+ "Show each option. Give it a `preview`: a Markdown wireframe (a fenced block drawn with box characters) or a short snippet of the component.",
221
+ `When you can render an option (a screenshot from the project's own tooling, a rendered mockup), save it as a PNG under ${previews} (never in the repository) and give its path as the option's \`image\`.`,
222
+ "Put the option you recommend first. The answers are recorded as the task's decisions: build what the user chose.",
223
+ ].join(" ");
206
224
  }
207
225
 
208
226
  function workerWorkflowContext(request: WorkerRequest, likely = ""): string {
@@ -218,6 +236,8 @@ function workerWorkflowContext(request: WorkerRequest, likely = ""): string {
218
236
  ? `Scout findings for your domain:\n${summarizeOutcomes(own, 1500)}`
219
237
  : "No scout findings were collected for your domain; verify the repository yourself.",
220
238
  likely,
239
+ request.time?.note ?? "",
240
+ request.ask ? askGuidance(request.ask.previews) : "",
221
241
  ].filter(Boolean).join("\n\n");
222
242
  }
223
243
 
@@ -253,11 +273,18 @@ export async function runWorker(
253
273
  ...watchdogOptions(request.config.workflow),
254
274
  ...request.agent,
255
275
  ...(extraTools.length > 0 ? { extraTools } : {}),
276
+ // One allotment for the step: a routed attempt that falls short re-runs on what is left of it.
277
+ ...(request.time ? { time: request.time } : {}),
278
+ ...(request.ask ? { onAsk: request.ask.onAsk } : {}),
256
279
  };
257
280
  let outcome = workerOutcome(request.domain, await runAgent(route ? { ...base, ...routedFields(route) } : base, run));
258
281
  // A routed step that fell short runs again at the configured model and thinking.
259
282
  if (route && fellShort(outcome.run, outcome.issues) && !request.signal?.aborted) {
283
+ const routed = outcome.run.edited ?? [];
260
284
  outcome = workerOutcome(request.domain, await runAgent(base, run));
285
+ // The step owns what the routed attempt edited too.
286
+ const edited = [...new Set([...routed, ...(outcome.run.edited ?? [])])];
287
+ if (edited.length > 0) outcome = { ...outcome, run: { ...outcome.run, edited } };
261
288
  }
262
289
  return outcome;
263
290
  }
@@ -284,6 +311,12 @@ export interface ReviewerRequest {
284
311
  workerSummary: string;
285
312
  scoutOutcomes: ScoutOutcome[];
286
313
  diff: string;
314
+ /** Who changed each changed file (planned, quick fix, pre-existing, …), one line each. */
315
+ provenance?: string;
316
+ /** What the previous QA round asked for, when there was one: this round verifies it. */
317
+ previousRound?: string;
318
+ /** Under a task time budget: the gate's time. */
319
+ time?: AgentTime;
287
320
  instruction?: string;
288
321
  cwd: string;
289
322
  dataRoots: readonly string[];
@@ -301,8 +334,11 @@ function reviewerContext(request: ReviewerRequest): string {
301
334
  return [
302
335
  `Task state: reviewing. Domain: ${request.domain}.`,
303
336
  "You may not modify implementation. Report required changes instead.",
304
- request.workerSummary ? `Worker summary:\n${truncate(request.workerSummary, 2000)}` : "No worker summary available.",
337
+ request.previousRound ?? "",
338
+ request.workerSummary ? `Worker summary (each domain's newest entries):\n${truncate(request.workerSummary, 4800)}` : "No worker summary available.",
305
339
  owns.length > 0 ? `Scout findings:\n${summarizeOutcomes(owns, 1200)}` : "",
340
+ request.provenance ? `Who changed each file (bot-lobby's record of every agent's edit and write calls; judge each as your role's Change provenance says):\n${request.provenance}` : "",
341
+ request.time?.note ?? "",
306
342
  `Repository changes:\n${request.diff}`,
307
343
  ]
308
344
  .filter((line) => line.length > 0)
@@ -334,6 +370,7 @@ function reviewerAgentRequest(request: ReviewerRequest, selected: KnowledgeSelec
334
370
  workflowContext: reviewerContext(request),
335
371
  },
336
372
  ...profileFields(request.config, request.profile, request.domain, "reviewer"),
373
+ ...(request.time ? { time: request.time } : {}),
337
374
  cwd: request.cwd,
338
375
  signal: request.signal,
339
376
  onUpdate: request.onUpdate,
@@ -3,7 +3,7 @@ import { join } from "node:path";
3
3
  import type { Domain } from "../schemas/agent.ts";
4
4
  import { profileFor, type BotLobbyConfig, type ProfileResolver } from "../schemas/configuration.ts";
5
5
  import type { AgentRun, ResearchResult } from "../schemas/findings.ts";
6
- import { runAgent, watchdogOptions, type AgentRequest } from "../execution/agent-runner.ts";
6
+ import { runAgent, watchdogOptions, type AgentRequest, type AgentTime } from "../execution/agent-runner.ts";
7
7
  import { spawnPiProcess, type ProcessRunner } from "../execution/pi-runner.ts";
8
8
  import { writeFileEnsured } from "../knowledge/store.ts";
9
9
  import { isResearchResultUsable, parseResearchResult, validateResearchResult } from "../roles/researcher.ts";
@@ -27,6 +27,8 @@ export interface ResearchRequest {
27
27
  profile?: ProfileResolver;
28
28
  signal?: AbortSignal;
29
29
  onUpdate?: (run: AgentRun) => void;
30
+ /** Under a task time budget: the researcher's time. */
31
+ time?: AgentTime;
30
32
  }
31
33
 
32
34
  export function researchResultPath(taskDir: string, domain: Domain): string {
@@ -56,7 +58,7 @@ function researchContext(request: ResearchRequest): AgentRequest["context"] {
56
58
  return {
57
59
  task: request.instruction,
58
60
  instructions: request.config.agents[request.domain].instructions,
59
- workflowContext: `Task state: research requested by the Master. Domain: ${request.domain}. Read-only; report cited findings only.`,
61
+ workflowContext: [`Task state: research requested by the Master. Domain: ${request.domain}. Read-only; report cited findings only.`, request.time?.note ?? ""].filter(Boolean).join("\n\n"),
60
62
  };
61
63
  }
62
64
 
@@ -86,6 +88,7 @@ export async function runResearch(request: ResearchRequest, run: ProcessRunner =
86
88
  model: profile.model,
87
89
  thinking: profile.thinking,
88
90
  timeoutMs: profile.timeoutMs,
91
+ ...(request.time ? { time: request.time } : {}),
89
92
  cwd: request.cwd,
90
93
  signal: request.signal,
91
94
  onUpdate: request.onUpdate,
@@ -17,7 +17,7 @@ import { transition } from "../state/task-state.ts";
17
17
  import { AGENT_DIR_NAMES, KNOWLEDGE_FILES, knowledgeDir, type KnowledgeAgent } from "../knowledge/paths.ts";
18
18
  import { readFirstExisting } from "../knowledge/store.ts";
19
19
  import { overThreshold } from "../knowledge/compactor.ts";
20
- import { applyApprovalChoice, describeTask, describeOversizedKnowledge, type ApprovalChoice } from "../workflow/workflow.ts";
20
+ import { applyApprovalChoice, describeTask, describeOversizedKnowledge, lastQaAsks, waiveQa, type ApprovalChoice } from "../workflow/workflow.ts";
21
21
  import { applyStatus, registerRevealShortcut, setMinimized } from "./ui.ts";
22
22
  import { classifierSummary, openSettings } from "./settings-ui.ts";
23
23
  import { keyStatus } from "../classifier/instance.ts";
@@ -27,16 +27,20 @@ import { autoOpenLobby, showLobby } from "../lobby/runtime.ts";
27
27
  import { modelRef, thinkingMismatches } from "./model-support.ts";
28
28
  import { describeRun, runFromLog } from "./run-summary.ts";
29
29
  import { modelLookup } from "./tools.ts";
30
+ import { budgetLine, budgetState, parseMinutes, readBudget, setBudget, startClock } from "../state/budget.ts";
30
31
 
31
32
  const HELP = [
32
33
  "/bot-lobby Open the lobby: tasks, plan, quick fix, metrics (alt+l)",
33
34
  "/bot-lobby <request> Start a task through the workflow",
34
35
  "/bot-lobby --task [--auto] <request> Start a task even when the request begins with a subcommand word",
36
+ "/bot-lobby --budget 90m <request> Start a task with a time budget the oracle divides between its agents",
37
+ "/bot-lobby budget [90m|off] Show or set this session's task time budget",
35
38
  "/bot-lobby status [taskId] Show the active task",
36
39
  "/bot-lobby tasks List tasks",
37
40
  "/bot-lobby pause|resume Pause or resume the active task",
38
41
  "/bot-lobby cancel [taskId] Abandon a task",
39
42
  "/bot-lobby approve|amend <text>|decline Answer the current proposal",
43
+ "/bot-lobby accept [taskId] Accept a task's work as it is, without a QA pass; the oracle then completes it",
40
44
  "/bot-lobby knowledge Show persistent knowledge files",
41
45
  "/bot-lobby runs [taskId] Recent subagent runs: time, turns, tokens, cost, model",
42
46
  "/bot-lobby settings Edit per-agent model/thinking/instructions",
@@ -50,21 +54,48 @@ const HELP = [
50
54
  ].join("\n");
51
55
 
52
56
  /** Subcommands only win when no free-form text follows (so tasks still start). */
53
- const SUBCOMMANDS = new Set(["lobby", "help", "status", "runs", "tasks", "pause", "resume", "cancel", "approve", "amend", "decline", "knowledge", "config", "settings", "minimize", "restore", "claim", "auto", "start-plan", "switch"]);
57
+ const SUBCOMMANDS = new Set(["lobby", "help", "status", "runs", "tasks", "pause", "resume", "cancel", "approve", "amend", "decline", "accept", "budget", "knowledge", "config", "settings", "minimize", "restore", "claim", "auto", "start-plan", "switch"]);
54
58
 
55
59
  function isTaskId(value: string | undefined): boolean {
56
60
  return Boolean(value && /^TASK-/.test(value));
57
61
  }
58
62
 
59
- export function parseCommand(args: string): { sub: string | undefined; rest: string[]; restText: string; auto?: boolean } {
60
- const trimmed = args.trim();
61
- // `--task [--auto] <request>` always starts a task (a background session started from the lobby sends this).
62
- const forced = /^--task(\s+--auto)?(?:\s+([\s\S]*))?$/.exec(trimmed);
63
- if (forced) return { sub: undefined, rest: [], restText: (forced[2] ?? "").trim(), ...(forced[1] ? { auto: true } : {}) };
63
+ /** A task start's leading flags: `--task` (always a task), `--auto`, `--budget <time>` (or `--budget=<time>`). */
64
+ const START_FLAG = /^--(task|auto)(?=\s|$)\s*|^--budget(?:=|\s+)(\S+)\s*/;
65
+
66
+ export interface ParsedCommand {
67
+ sub: string | undefined;
68
+ rest: string[];
69
+ restText: string;
70
+ auto?: boolean;
71
+ /** Minutes from `--budget`. */
72
+ budget?: number;
73
+ /** `--budget` with something that is not a time. */
74
+ budgetError?: string;
75
+ }
76
+
77
+ export function parseCommand(args: string): ParsedCommand {
78
+ let trimmed = args.trim();
79
+ // Leading flags start a task: what follows is its request, even when it begins with a subcommand word
80
+ // (a background session started from the lobby sends `--task [--auto] <request>`).
81
+ let flagged = false;
82
+ const extras: Pick<ParsedCommand, "auto" | "budget" | "budgetError"> = {};
83
+ for (let match = START_FLAG.exec(trimmed); match; match = START_FLAG.exec(trimmed)) {
84
+ flagged = true;
85
+ if (match[1] === "auto") extras.auto = true;
86
+ else if (match[2] !== undefined) {
87
+ const minutes = parseMinutes(match[2]);
88
+ if (minutes) extras.budget = minutes;
89
+ else extras.budgetError = `"${match[2]}" is not a time budget (try 90m, 1h or 1h30m)`;
90
+ }
91
+ trimmed = trimmed.slice(match[0].length);
92
+ }
93
+ if (flagged) return { sub: undefined, rest: [], restText: trimmed.trim(), ...extras };
64
94
  const [sub, ...rest] = trimmed.split(/\s+/).filter(Boolean);
65
95
  if (!sub || !SUBCOMMANDS.has(sub)) return { sub: undefined, rest: [], restText: trimmed };
66
96
  const takesArgs = sub === "amend" || sub === "claim"
67
97
  || (sub === "auto" && rest.length === 1 && /^(on|off)$/i.test(rest[0]!))
98
+ || (sub === "budget" && rest.length >= 1 && rest.length <= 2 && (/^off$/i.test(rest[0]!) || parseMinutes(rest.join("")) !== undefined))
68
99
  || (sub === "start-plan" && rest.length >= 1 && rest.length <= 2 && /^PLAN-/.test(rest[0]!) && (rest.length === 1 || rest[1] === "auto"))
69
100
  || (sub === "switch" && rest.length >= 1 && /\.jsonl$/.test(rest.join(" ")));
70
101
  if (!takesArgs && rest.length > 0 && !(rest.length === 1 && isTaskId(rest[0]))) {
@@ -87,7 +118,9 @@ function showStatus(ctx: ExtensionCommandContext, configDir: string, taskId?: st
87
118
  broken.length > 0 ? `Unreadable task state: ${broken.join(", ")}` : "",
88
119
  ].filter(Boolean).join("\n");
89
120
  const footer = knowledge ? `\n${knowledge}` : "";
90
- ctx.ui.notify(task ? `${describeTask(task)}${footer}` : `No bot-lobby task found in ${root}.${footer}`, task ? "info" : "warning");
121
+ const budget = task ? readBudget(root, configDir, task.id) : undefined;
122
+ const time = task && budget ? `\n${budgetLine(budget, budgetState(task.id, budget, task.qaVerdict === "pass"))}` : "";
123
+ ctx.ui.notify(task ? `${describeTask(task)}${time}${footer}` : `No bot-lobby task found in ${root}.${footer}`, task ? "info" : "warning");
91
124
  }
92
125
 
93
126
  /** Recent runs of the active (or named) task, newest last, so slow models are easy to spot. */
@@ -175,6 +208,52 @@ function answerProposal(
175
208
  }
176
209
  }
177
210
 
211
+ /**
212
+ * `/bot-lobby accept [taskId]`: the user accepts a task's work as it stands,
213
+ * without a QA pass (QA keeps asking for more, and the user has seen enough).
214
+ * The gate is waived and blockers cleared; the oracle is asked to complete it.
215
+ */
216
+ function acceptWork(pi: ExtensionAPI, ctx: ExtensionCommandContext, configDir: string, taskId?: string): void {
217
+ const root = detectProjectRoot(ctx.cwd, configDir);
218
+ const sessionId = ctx.sessionManager.getSessionId();
219
+ const task = selectTask(root, configDir, taskId, sessionId);
220
+ if (!task || TERMINAL_STATES.includes(task.state)) return ctx.ui.notify("bot-lobby: no active task to accept.", "warning");
221
+ if (task.ownerSessionId && task.ownerSessionId !== sessionId) {
222
+ return ctx.ui.notify(`${task.id} is owned by another pi session; accept it there, or /bot-lobby claim ${task.id} first.`, "warning");
223
+ }
224
+ if (!["implementing", "reviewing", "blocked"].includes(task.state)) {
225
+ return ctx.ui.notify(`bot-lobby: ${task.id} is still ${task.state}; there is no work to accept yet.`, "warning");
226
+ }
227
+ if (task.qaVerdict !== "pass" && !task.qaWaiver) waiveQa(task, lastQaAsks(task), "with /bot-lobby accept");
228
+ saveTask(root, configDir, task);
229
+ applyStatus(ctx, root, configDir);
230
+ ctx.ui.notify(`bot-lobby: ${task.id} accepted as it is; the oracle will complete it.`, "info");
231
+ const message = `The user accepted ${task.id}'s work as it is (the QA gate is waived). Call orchestrate action=complete now with a short summary that names anything QA still asked for.`;
232
+ pi.sendUserMessage(message, ctx.isIdle() ? undefined : { deliverAs: "followUp" });
233
+ }
234
+
235
+ /**
236
+ * `/bot-lobby budget [time|off]`: show, set or remove the time budget of this
237
+ * session's task. Time already spent is kept; the oracle sees the change on
238
+ * its next step.
239
+ */
240
+ function budgetCommand(ctx: ExtensionCommandContext, configDir: string, value: string): void {
241
+ const root = detectProjectRoot(ctx.cwd, configDir);
242
+ const task = activeTask(root, configDir, ctx.sessionManager.getSessionId());
243
+ if (!task) return ctx.ui.notify("bot-lobby: no active task in this session — a time budget applies to a task (start one with /bot-lobby --budget 90m <request>)", "warning");
244
+ if (!value) {
245
+ const budget = readBudget(root, configDir, task.id);
246
+ return ctx.ui.notify(budget ? `${task.id} — ${budgetLine(budget, budgetState(task.id, budget, task.qaVerdict === "pass"))}` : `${task.id} has no time budget. Set one with /bot-lobby budget 90m.`, "info");
247
+ }
248
+ const minutes = /^off$/i.test(value) ? 0 : parseMinutes(value);
249
+ if (minutes === undefined) return ctx.ui.notify(`bot-lobby: "${value}" is not a time budget (try 90m, 1h or 1h30m)`, "warning");
250
+ const budget = setBudget(root, configDir, task.id, minutes);
251
+ // Set while the oracle works: its clock runs from now, not from its next turn.
252
+ if (budget && !ctx.isIdle()) startClock(root, configDir, task.id);
253
+ applyStatus(ctx, root, configDir);
254
+ ctx.ui.notify(budget ? `bot-lobby: ${task.id} — ${budgetLine(budget, budgetState(task.id, budget, task.qaVerdict === "pass"))}` : `bot-lobby: ${task.id} has no time budget now.`, "info");
255
+ }
256
+
178
257
  function showKnowledge(ctx: ExtensionCommandContext, configDir: string): void {
179
258
  const root = detectProjectRoot(ctx.cwd, configDir);
180
259
  const cfg = loadConfig();
@@ -236,13 +315,14 @@ export function registerCommands(pi: ExtensionAPI, configDir: string): void {
236
315
  return filtered.length > 0 ? filtered : null;
237
316
  },
238
317
  handler: async (args, ctx) => {
239
- const { sub, rest, restText, auto } = parseCommand(args ?? "");
318
+ const { sub, rest, restText, auto, budget, budgetError } = parseCommand(args ?? "");
319
+ if (budgetError) return ctx.ui.notify(`bot-lobby: ${budgetError}`, "warning");
240
320
  if (!sub) {
241
321
  if (!restText) {
242
322
  if (!showLobby()) ctx.ui.notify(HELP, "info");
243
323
  return;
244
324
  }
245
- if (await startTask(pi, ctx, configDir, restText, auto ? { auto } : {})) autoOpenLobby();
325
+ if (await startTask(pi, ctx, configDir, restText, { ...(auto ? { auto } : {}), ...(budget ? { budget } : {}) })) autoOpenLobby();
246
326
  return;
247
327
  }
248
328
  switch (sub) {
@@ -269,6 +349,10 @@ export function registerCommands(pi: ExtensionAPI, configDir: string): void {
269
349
  return answerProposal(ctx, configDir, "amend", restText);
270
350
  case "decline":
271
351
  return answerProposal(ctx, configDir, "decline");
352
+ case "accept":
353
+ return acceptWork(pi, ctx, configDir, rest[0]);
354
+ case "budget":
355
+ return budgetCommand(ctx, configDir, rest.join(""));
272
356
  case "knowledge":
273
357
  return showKnowledge(ctx, configDir);
274
358
  case "settings":