@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
@@ -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,26 +17,33 @@ 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";
24
- import { kickoff, startPlannedTask, startTask } from "./start-task.ts";
24
+ import { kickoff, startPlannedTask } from "./start-task.ts";
25
+ import { startRequest } from "./route.ts";
25
26
  import { setAuto, toggleOwnAuto } from "./owner.ts";
26
27
  import { autoOpenLobby, showLobby } from "../lobby/runtime.ts";
27
28
  import { modelRef, thinkingMismatches } from "./model-support.ts";
28
29
  import { describeRun, runFromLog } from "./run-summary.ts";
29
30
  import { modelLookup } from "./tools.ts";
31
+ import { budgetLine, budgetState, parseMinutes, readBudget, setBudget, startClock } from "../state/budget.ts";
32
+ import { qaStillDue } from "../workflow/track.ts";
30
33
 
31
34
  const HELP = [
32
35
  "/bot-lobby Open the lobby: tasks, plan, quick fix, metrics (alt+l)",
33
- "/bot-lobby <request> Start a task through the workflow",
34
- "/bot-lobby --task [--auto] <request> Start a task even when the request begins with a subcommand word",
36
+ "/bot-lobby <request> Start a request: a quick fix when one agent can do it alone (the oracle confirms), else a task",
37
+ "/bot-lobby --task [--auto] <request> Always a task (also when the request begins with a subcommand word)",
38
+ "/bot-lobby --budget 90m <request> Start a task with a time budget the oracle divides between its agents",
39
+ "/bot-lobby --fast|--full <request> Start a task on the fast track (straight to the agents it needs) or the full workflow, whatever it reads as",
40
+ "/bot-lobby budget [90m|off] Show or set this session's task time budget",
35
41
  "/bot-lobby status [taskId] Show the active task",
36
42
  "/bot-lobby tasks List tasks",
37
43
  "/bot-lobby pause|resume Pause or resume the active task",
38
44
  "/bot-lobby cancel [taskId] Abandon a task",
39
45
  "/bot-lobby approve|amend <text>|decline Answer the current proposal",
46
+ "/bot-lobby accept [taskId] Accept a task's work as it is, without a QA pass; the oracle then completes it",
40
47
  "/bot-lobby knowledge Show persistent knowledge files",
41
48
  "/bot-lobby runs [taskId] Recent subagent runs: time, turns, tokens, cost, model",
42
49
  "/bot-lobby settings Edit per-agent model/thinking/instructions",
@@ -50,21 +57,54 @@ const HELP = [
50
57
  ].join("\n");
51
58
 
52
59
  /** 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"]);
60
+ 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
61
 
55
62
  function isTaskId(value: string | undefined): boolean {
56
63
  return Boolean(value && /^TASK-/.test(value));
57
64
  }
58
65
 
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 } : {}) };
66
+ /** A task start's leading flags: `--task` (always a task), `--auto`, `--fast`, `--full`, `--budget <time>` (or `--budget=<time>`). */
67
+ const START_FLAG = /^--(task|auto|fast|full)(?=\s|$)\s*|^--budget(?:=|\s+)(\S+)\s*/;
68
+
69
+ export interface ParsedCommand {
70
+ sub: string | undefined;
71
+ rest: string[];
72
+ restText: string;
73
+ auto?: boolean;
74
+ /** Minutes from `--budget`. */
75
+ budget?: number;
76
+ /** `--budget` with something that is not a time. */
77
+ budgetError?: string;
78
+ /** `--fast` or `--full`: the task's path, whatever its request reads as. */
79
+ track?: "fast" | "full";
80
+ /** `--task`: a task, never routed to the quick-fix agent. */
81
+ task?: boolean;
82
+ }
83
+
84
+ export function parseCommand(args: string): ParsedCommand {
85
+ let trimmed = args.trim();
86
+ // Leading flags start a task: what follows is its request, even when it begins with a subcommand word
87
+ // (a background session started from the lobby sends `--task [--auto] <request>`).
88
+ let flagged = false;
89
+ const extras: Pick<ParsedCommand, "auto" | "budget" | "budgetError" | "track" | "task"> = {};
90
+ for (let match = START_FLAG.exec(trimmed); match; match = START_FLAG.exec(trimmed)) {
91
+ flagged = true;
92
+ if (match[1] === "auto") extras.auto = true;
93
+ else if (match[1] === "task") extras.task = true;
94
+ else if (match[1] === "fast" || match[1] === "full") extras.track = match[1];
95
+ else if (match[2] !== undefined) {
96
+ const minutes = parseMinutes(match[2]);
97
+ if (minutes) extras.budget = minutes;
98
+ else extras.budgetError = `"${match[2]}" is not a time budget (try 90m, 1h or 1h30m)`;
99
+ }
100
+ trimmed = trimmed.slice(match[0].length);
101
+ }
102
+ if (flagged) return { sub: undefined, rest: [], restText: trimmed.trim(), ...extras };
64
103
  const [sub, ...rest] = trimmed.split(/\s+/).filter(Boolean);
65
104
  if (!sub || !SUBCOMMANDS.has(sub)) return { sub: undefined, rest: [], restText: trimmed };
66
105
  const takesArgs = sub === "amend" || sub === "claim"
67
106
  || (sub === "auto" && rest.length === 1 && /^(on|off)$/i.test(rest[0]!))
107
+ || (sub === "budget" && rest.length >= 1 && rest.length <= 2 && (/^off$/i.test(rest[0]!) || parseMinutes(rest.join("")) !== undefined))
68
108
  || (sub === "start-plan" && rest.length >= 1 && rest.length <= 2 && /^PLAN-/.test(rest[0]!) && (rest.length === 1 || rest[1] === "auto"))
69
109
  || (sub === "switch" && rest.length >= 1 && /\.jsonl$/.test(rest.join(" ")));
70
110
  if (!takesArgs && rest.length > 0 && !(rest.length === 1 && isTaskId(rest[0]))) {
@@ -87,7 +127,9 @@ function showStatus(ctx: ExtensionCommandContext, configDir: string, taskId?: st
87
127
  broken.length > 0 ? `Unreadable task state: ${broken.join(", ")}` : "",
88
128
  ].filter(Boolean).join("\n");
89
129
  const footer = knowledge ? `\n${knowledge}` : "";
90
- ctx.ui.notify(task ? `${describeTask(task)}${footer}` : `No bot-lobby task found in ${root}.${footer}`, task ? "info" : "warning");
130
+ const budget = task ? readBudget(root, configDir, task.id) : undefined;
131
+ const time = task && budget ? `\n${budgetLine(budget, budgetState(task.id, budget, !qaStillDue(task)))}` : "";
132
+ ctx.ui.notify(task ? `${describeTask(task)}${time}${footer}` : `No bot-lobby task found in ${root}.${footer}`, task ? "info" : "warning");
91
133
  }
92
134
 
93
135
  /** Recent runs of the active (or named) task, newest last, so slow models are easy to spot. */
@@ -175,6 +217,52 @@ function answerProposal(
175
217
  }
176
218
  }
177
219
 
220
+ /**
221
+ * `/bot-lobby accept [taskId]`: the user accepts a task's work as it stands,
222
+ * without a QA pass (QA keeps asking for more, and the user has seen enough).
223
+ * The gate is waived and blockers cleared; the oracle is asked to complete it.
224
+ */
225
+ function acceptWork(pi: ExtensionAPI, ctx: ExtensionCommandContext, configDir: string, taskId?: string): void {
226
+ const root = detectProjectRoot(ctx.cwd, configDir);
227
+ const sessionId = ctx.sessionManager.getSessionId();
228
+ const task = selectTask(root, configDir, taskId, sessionId);
229
+ if (!task || TERMINAL_STATES.includes(task.state)) return ctx.ui.notify("bot-lobby: no active task to accept.", "warning");
230
+ if (task.ownerSessionId && task.ownerSessionId !== sessionId) {
231
+ return ctx.ui.notify(`${task.id} is owned by another pi session; accept it there, or /bot-lobby claim ${task.id} first.`, "warning");
232
+ }
233
+ if (!["implementing", "reviewing", "blocked"].includes(task.state)) {
234
+ return ctx.ui.notify(`bot-lobby: ${task.id} is still ${task.state}; there is no work to accept yet.`, "warning");
235
+ }
236
+ if (task.qaVerdict !== "pass" && !task.qaWaiver) waiveQa(task, lastQaAsks(task), "with /bot-lobby accept");
237
+ saveTask(root, configDir, task);
238
+ applyStatus(ctx, root, configDir);
239
+ ctx.ui.notify(`bot-lobby: ${task.id} accepted as it is; the oracle will complete it.`, "info");
240
+ 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.`;
241
+ pi.sendUserMessage(message, ctx.isIdle() ? undefined : { deliverAs: "followUp" });
242
+ }
243
+
244
+ /**
245
+ * `/bot-lobby budget [time|off]`: show, set or remove the time budget of this
246
+ * session's task. Time already spent is kept; the oracle sees the change on
247
+ * its next step.
248
+ */
249
+ function budgetCommand(ctx: ExtensionCommandContext, configDir: string, value: string): void {
250
+ const root = detectProjectRoot(ctx.cwd, configDir);
251
+ const task = activeTask(root, configDir, ctx.sessionManager.getSessionId());
252
+ 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");
253
+ if (!value) {
254
+ const budget = readBudget(root, configDir, task.id);
255
+ return ctx.ui.notify(budget ? `${task.id} — ${budgetLine(budget, budgetState(task.id, budget, !qaStillDue(task)))}` : `${task.id} has no time budget. Set one with /bot-lobby budget 90m.`, "info");
256
+ }
257
+ const minutes = /^off$/i.test(value) ? 0 : parseMinutes(value);
258
+ if (minutes === undefined) return ctx.ui.notify(`bot-lobby: "${value}" is not a time budget (try 90m, 1h or 1h30m)`, "warning");
259
+ const budget = setBudget(root, configDir, task.id, minutes);
260
+ // Set while the oracle works: its clock runs from now, not from its next turn.
261
+ if (budget && !ctx.isIdle()) startClock(root, configDir, task.id);
262
+ applyStatus(ctx, root, configDir);
263
+ ctx.ui.notify(budget ? `bot-lobby: ${task.id} — ${budgetLine(budget, budgetState(task.id, budget, !qaStillDue(task)))}` : `bot-lobby: ${task.id} has no time budget now.`, "info");
264
+ }
265
+
178
266
  function showKnowledge(ctx: ExtensionCommandContext, configDir: string): void {
179
267
  const root = detectProjectRoot(ctx.cwd, configDir);
180
268
  const cfg = loadConfig();
@@ -236,13 +324,16 @@ export function registerCommands(pi: ExtensionAPI, configDir: string): void {
236
324
  return filtered.length > 0 ? filtered : null;
237
325
  },
238
326
  handler: async (args, ctx) => {
239
- const { sub, rest, restText, auto } = parseCommand(args ?? "");
327
+ const { sub, rest, restText, auto, budget, budgetError, track, task } = parseCommand(args ?? "");
328
+ if (budgetError) return ctx.ui.notify(`bot-lobby: ${budgetError}`, "warning");
240
329
  if (!sub) {
241
330
  if (!restText) {
242
331
  if (!showLobby()) ctx.ui.notify(HELP, "info");
243
332
  return;
244
333
  }
245
- if (await startTask(pi, ctx, configDir, restText, auto ? { auto } : {})) autoOpenLobby();
334
+ // A request one agent can do alone may go to the quick-fix agent once the oracle confirms; the rest start as tasks.
335
+ const started = await startRequest(pi, ctx, configDir, restText, { ...(auto ? { auto } : {}), ...(budget ? { budget } : {}), ...(track ? { track } : {}), ...(task ? { task } : {}) });
336
+ if (started === "task") autoOpenLobby();
246
337
  return;
247
338
  }
248
339
  switch (sub) {
@@ -269,6 +360,10 @@ export function registerCommands(pi: ExtensionAPI, configDir: string): void {
269
360
  return answerProposal(ctx, configDir, "amend", restText);
270
361
  case "decline":
271
362
  return answerProposal(ctx, configDir, "decline");
363
+ case "accept":
364
+ return acceptWork(pi, ctx, configDir, rest[0]);
365
+ case "budget":
366
+ return budgetCommand(ctx, configDir, rest.join(""));
272
367
  case "knowledge":
273
368
  return showKnowledge(ctx, configDir);
274
369
  case "settings":
package/src/pi/events.ts CHANGED
@@ -9,20 +9,38 @@ import { describeTask } from "../workflow/workflow.ts";
9
9
  import { truncate } from "../text.ts";
10
10
  import { applyStatus, clearStatus, isMinimized, setMinimized, setOracleActivity } from "./ui.ts";
11
11
  import { ORACLE_THINKING, oracleActivityWord } from "./activity.ts";
12
- import { isSubagentProcess, visibleTools } from "./quiet.ts";
12
+ import { isSubagentProcess, webToolsFor } from "./quiet.ts";
13
13
  import { registerQuietTools } from "./tool-renderers.ts";
14
14
  import { taskRequest, type Task, type TaskState } from "../schemas/task.ts";
15
15
  import { pendingComments, readPlanComments, type PlanComment } from "../state/comments.ts";
16
16
  import { isAutoMode } from "../state/auto.ts";
17
17
  import { triageContext } from "../classifier/triage.ts";
18
+ import { qaStillDue } from "../workflow/track.ts";
18
19
  import { previousTaskNote } from "./fresh-context.ts";
20
+ import { budgetLine, budgetState, pauseClocks, readBudget, resumeClocks, startClock, stopClocks } from "../state/budget.ts";
21
+
22
+ /** Tools that wait on the user: the task's clock waits with them. */
23
+ const ASKING_TOOLS: ReadonlySet<string> = new Set(["ask_user_question"]);
19
24
 
20
25
  /** States in which the triage still helps the Master shape the task; once it is planned, the hints are noise. */
21
26
  const SHAPING_STATES: ReadonlySet<TaskState> = new Set(["created", "clarifying", "scouting", "synthesizing", "awaiting_approval"]);
22
27
 
23
- /** The Master's workflow context: the task's state, plus the classifier's triage while the task is being shaped. */
24
- export function masterWorkflowContext(task: Task): string {
25
- return [describeTask(task), SHAPING_STATES.has(task.state) ? triageContext(task.triage) : ""].filter(Boolean).join("\n\n");
28
+ /** The Master's workflow context: the task's state, where its time budget stands, and the classifier's triage while the task is being shaped. */
29
+ export function masterWorkflowContext(task: Task, time = ""): string {
30
+ return [describeTask(task), time, SHAPING_STATES.has(task.state) ? triageContext(task.triage) : ""].filter(Boolean).join("\n\n");
31
+ }
32
+
33
+ /** The oracle's view of the task's time budget: where it stands and how to spend it; "" without one. */
34
+ export function budgetContext(root: string, configDir: string, task: Task): string {
35
+ const budget = readBudget(root, configDir, task.id);
36
+ if (!budget) return "";
37
+ const state = budgetState(task.id, budget, !qaStillDue(task));
38
+ const running = budget.allotments.filter((entry) => !entry.endedAt).map((entry) => `${entry.who} (${entry.minutes}m)`);
39
+ return [
40
+ budgetLine(budget, state),
41
+ running.length > 0 ? `Running now: ${running.join(", ")}.` : "",
42
+ "Divide what is left by scope: pass `minutes` on each implement (per assignment in a parallel batch), keeping the QA gate's reserve. Every agent is told its minutes; one that runs out reports where it left off and the user decides on more.",
43
+ ].filter(Boolean).join(" ");
26
44
  }
27
45
 
28
46
  export function masterTaskContext(task: Task, comments: readonly PlanComment[] = [], auto = false): string {
@@ -47,12 +65,11 @@ export function masterTaskContext(task: Task, comments: readonly PlanComment[] =
47
65
  * section rather than replacing the whole prompt.
48
66
  */
49
67
  export function registerLifecycle(pi: ExtensionAPI, configDir: string): void {
68
+ // Web tools the oracle put away for the task in hand, given back when it ends.
69
+ let hiddenWeb: string[] = [];
50
70
  pi.on("session_start", (_event, ctx) => {
51
71
  // Master-only: subagents keep their own --tools allowlist (see quiet.ts).
52
- if (!isSubagentProcess()) {
53
- registerQuietTools(pi);
54
- pi.setActiveTools(visibleTools(pi.getActiveTools()));
55
- }
72
+ if (!isSubagentProcess()) registerQuietTools(pi);
56
73
  const root = detectProjectRoot(ctx.cwd, configDir);
57
74
  setMinimized(false);
58
75
  applyStatus(ctx, root, configDir);
@@ -65,25 +82,38 @@ export function registerLifecycle(pi: ExtensionAPI, configDir: string): void {
65
82
  // Parallel tool calls: the newest still-running call keeps the word.
66
83
  const inFlight = new Map<string, string>();
67
84
  const showOracle = () => setOracleActivity([...inFlight.values()].at(-1) ?? ORACLE_THINKING);
68
- pi.on("agent_start", () => {
85
+ // A task's time budget counts while the oracle works on it, not while it waits on the user.
86
+ const asking = new Set<string>();
87
+ pi.on("agent_start", (_event, ctx) => {
69
88
  if (isSubagentProcess()) return;
70
89
  inFlight.clear();
71
90
  showOracle();
91
+ const root = detectProjectRoot(ctx.cwd, configDir);
92
+ const task = activeTask(root, configDir, ctx.sessionManager.getSessionId());
93
+ if (task) startClock(root, configDir, task.id);
72
94
  });
73
95
  pi.on("tool_execution_start", (event) => {
74
96
  if (isSubagentProcess()) return;
75
97
  inFlight.set(event.toolCallId, oracleActivityWord(event.toolName, event.args));
76
98
  showOracle();
99
+ if (ASKING_TOOLS.has(event.toolName) && !asking.has(event.toolCallId)) {
100
+ asking.add(event.toolCallId);
101
+ pauseClocks();
102
+ }
77
103
  });
78
104
  pi.on("tool_execution_end", (event) => {
79
105
  if (isSubagentProcess()) return;
80
106
  inFlight.delete(event.toolCallId);
81
107
  showOracle();
108
+ if (asking.delete(event.toolCallId)) resumeClocks();
82
109
  });
83
110
  pi.on("agent_end", () => {
84
111
  if (isSubagentProcess()) return;
85
112
  inFlight.clear();
86
113
  setOracleActivity(undefined);
114
+ for (const _call of asking) resumeClocks();
115
+ asking.clear();
116
+ stopClocks();
87
117
  });
88
118
  pi.on("session_shutdown", (_event, ctx) => {
89
119
  cancelAllRuns();
@@ -95,6 +125,14 @@ export function registerLifecycle(pi: ExtensionAPI, configDir: string): void {
95
125
  const sessionId = ctx.sessionManager.getSessionId();
96
126
  const hidden = isSubagentProcess() || isMinimized();
97
127
  const task = hidden ? undefined : activeTask(root, configDir, sessionId);
128
+ if (!isSubagentProcess()) {
129
+ // The researcher is the task's only web path; without a task pi keeps them.
130
+ const tools = webToolsFor(pi.getActiveTools(), Boolean(task), hiddenWeb);
131
+ if (tools) {
132
+ hiddenWeb = tools.hidden;
133
+ pi.setActiveTools(tools.active);
134
+ }
135
+ }
98
136
  if (!task) {
99
137
  // After a task ended and cleared the context, a line on where its record is, in case the user refers back to it.
100
138
  const previous = hidden ? undefined : previousTaskNote(ctx, configDir);
@@ -110,7 +148,7 @@ export function registerLifecycle(pi: ExtensionAPI, configDir: string): void {
110
148
  standards: selected.standards,
111
149
  knowledge: selected.knowledge,
112
150
  decisions: selected.decisions,
113
- workflowContext: masterWorkflowContext(task),
151
+ workflowContext: masterWorkflowContext(task, budgetContext(root, configDir, task)),
114
152
  instructions: loadConfig().master.instructions,
115
153
  });
116
154
  });
package/src/pi/quiet.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Quiet mode for the master transcript: built-in tool rows render zero lines
3
- * by default and `alt+t` reveals a compact one-line summary per call. The web
4
- * tools are removed from the master's active set so the researcher subagent,
5
- * running in its own process, stays the only web path.
3
+ * by default and `alt+t` reveals a compact one-line summary per call. While a
4
+ * task runs, the web tools are removed from the master's active set so the
5
+ * researcher subagent, running in its own process, stays the task's only web
6
+ * path; without a task, pi has them like any other tool.
6
7
  *
7
8
  * bot-lobby's extension also loads inside the subagent processes it spawns
8
9
  * (see `spawnPiProcess`). Those children receive their tools from an explicit
@@ -10,7 +11,7 @@
10
11
  * never run there or research silently degrades to repository-only.
11
12
  */
12
13
 
13
- /** Web tools owned by pi-web-access; the researcher gets them, the master does not. */
14
+ /** bot-lobby's web tools (src/web/tools.ts): the researcher's, and pi's outside a task, never the oracle's. */
14
15
  export const WEB_TOOL_NAMES: readonly string[] = [
15
16
  "web_search",
16
17
  "fetch_content",
@@ -40,6 +41,23 @@ export function visibleTools(active: string[]): string[] {
40
41
  return active.filter((name) => !WEB_TOOL_NAMES.includes(name));
41
42
  }
42
43
 
44
+ /**
45
+ * The master's active tools for a turn: without the web tools while a task
46
+ * is active, and with those it took away given back once none is. Only tools
47
+ * it hid itself come back, so a tool the user turned off stays off. Returns
48
+ * undefined when nothing changes.
49
+ */
50
+ export function webToolsFor(active: string[], taskActive: boolean, hidden: readonly string[]): { active: string[]; hidden: string[] } | undefined {
51
+ if (taskActive) {
52
+ const taken = active.filter((name) => WEB_TOOL_NAMES.includes(name));
53
+ if (taken.length === 0) return undefined;
54
+ return { active: visibleTools(active), hidden: [...new Set([...hidden, ...taken])] };
55
+ }
56
+ const back = hidden.filter((name) => !active.includes(name));
57
+ if (hidden.length === 0) return undefined;
58
+ return { active: [...active, ...back], hidden: [] };
59
+ }
60
+
43
61
  /** True inside a bot-lobby subagent process (marked in `spawnPiProcess`). */
44
62
  export function isSubagentProcess(): boolean {
45
63
  return process.env.BOT_LOBBY_SUBAGENT === "1";