@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
package/src/pi/events.ts CHANGED
@@ -9,20 +9,37 @@ 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
18
  import { previousTaskNote } from "./fresh-context.ts";
19
+ import { budgetLine, budgetState, pauseClocks, readBudget, resumeClocks, startClock, stopClocks } from "../state/budget.ts";
20
+
21
+ /** Tools that wait on the user: the task's clock waits with them. */
22
+ const ASKING_TOOLS: ReadonlySet<string> = new Set(["ask_user_question"]);
19
23
 
20
24
  /** States in which the triage still helps the Master shape the task; once it is planned, the hints are noise. */
21
25
  const SHAPING_STATES: ReadonlySet<TaskState> = new Set(["created", "clarifying", "scouting", "synthesizing", "awaiting_approval"]);
22
26
 
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");
27
+ /** 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. */
28
+ export function masterWorkflowContext(task: Task, time = ""): string {
29
+ return [describeTask(task), time, SHAPING_STATES.has(task.state) ? triageContext(task.triage) : ""].filter(Boolean).join("\n\n");
30
+ }
31
+
32
+ /** The oracle's view of the task's time budget: where it stands and how to spend it; "" without one. */
33
+ export function budgetContext(root: string, configDir: string, task: Task): string {
34
+ const budget = readBudget(root, configDir, task.id);
35
+ if (!budget) return "";
36
+ const state = budgetState(task.id, budget, task.qaVerdict === "pass");
37
+ const running = budget.allotments.filter((entry) => !entry.endedAt).map((entry) => `${entry.who} (${entry.minutes}m)`);
38
+ return [
39
+ budgetLine(budget, state),
40
+ running.length > 0 ? `Running now: ${running.join(", ")}.` : "",
41
+ "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.",
42
+ ].filter(Boolean).join(" ");
26
43
  }
27
44
 
28
45
  export function masterTaskContext(task: Task, comments: readonly PlanComment[] = [], auto = false): string {
@@ -47,12 +64,11 @@ export function masterTaskContext(task: Task, comments: readonly PlanComment[] =
47
64
  * section rather than replacing the whole prompt.
48
65
  */
49
66
  export function registerLifecycle(pi: ExtensionAPI, configDir: string): void {
67
+ // Web tools the oracle put away for the task in hand, given back when it ends.
68
+ let hiddenWeb: string[] = [];
50
69
  pi.on("session_start", (_event, ctx) => {
51
70
  // Master-only: subagents keep their own --tools allowlist (see quiet.ts).
52
- if (!isSubagentProcess()) {
53
- registerQuietTools(pi);
54
- pi.setActiveTools(visibleTools(pi.getActiveTools()));
55
- }
71
+ if (!isSubagentProcess()) registerQuietTools(pi);
56
72
  const root = detectProjectRoot(ctx.cwd, configDir);
57
73
  setMinimized(false);
58
74
  applyStatus(ctx, root, configDir);
@@ -65,25 +81,38 @@ export function registerLifecycle(pi: ExtensionAPI, configDir: string): void {
65
81
  // Parallel tool calls: the newest still-running call keeps the word.
66
82
  const inFlight = new Map<string, string>();
67
83
  const showOracle = () => setOracleActivity([...inFlight.values()].at(-1) ?? ORACLE_THINKING);
68
- pi.on("agent_start", () => {
84
+ // A task's time budget counts while the oracle works on it, not while it waits on the user.
85
+ const asking = new Set<string>();
86
+ pi.on("agent_start", (_event, ctx) => {
69
87
  if (isSubagentProcess()) return;
70
88
  inFlight.clear();
71
89
  showOracle();
90
+ const root = detectProjectRoot(ctx.cwd, configDir);
91
+ const task = activeTask(root, configDir, ctx.sessionManager.getSessionId());
92
+ if (task) startClock(root, configDir, task.id);
72
93
  });
73
94
  pi.on("tool_execution_start", (event) => {
74
95
  if (isSubagentProcess()) return;
75
96
  inFlight.set(event.toolCallId, oracleActivityWord(event.toolName, event.args));
76
97
  showOracle();
98
+ if (ASKING_TOOLS.has(event.toolName) && !asking.has(event.toolCallId)) {
99
+ asking.add(event.toolCallId);
100
+ pauseClocks();
101
+ }
77
102
  });
78
103
  pi.on("tool_execution_end", (event) => {
79
104
  if (isSubagentProcess()) return;
80
105
  inFlight.delete(event.toolCallId);
81
106
  showOracle();
107
+ if (asking.delete(event.toolCallId)) resumeClocks();
82
108
  });
83
109
  pi.on("agent_end", () => {
84
110
  if (isSubagentProcess()) return;
85
111
  inFlight.clear();
86
112
  setOracleActivity(undefined);
113
+ for (const _call of asking) resumeClocks();
114
+ asking.clear();
115
+ stopClocks();
87
116
  });
88
117
  pi.on("session_shutdown", (_event, ctx) => {
89
118
  cancelAllRuns();
@@ -95,6 +124,14 @@ export function registerLifecycle(pi: ExtensionAPI, configDir: string): void {
95
124
  const sessionId = ctx.sessionManager.getSessionId();
96
125
  const hidden = isSubagentProcess() || isMinimized();
97
126
  const task = hidden ? undefined : activeTask(root, configDir, sessionId);
127
+ if (!isSubagentProcess()) {
128
+ // The researcher is the task's only web path; without a task pi keeps them.
129
+ const tools = webToolsFor(pi.getActiveTools(), Boolean(task), hiddenWeb);
130
+ if (tools) {
131
+ hiddenWeb = tools.hidden;
132
+ pi.setActiveTools(tools.active);
133
+ }
134
+ }
98
135
  if (!task) {
99
136
  // After a task ended and cleared the context, a line on where its record is, in case the user refers back to it.
100
137
  const previous = hidden ? undefined : previousTaskNote(ctx, configDir);
@@ -110,7 +147,7 @@ export function registerLifecycle(pi: ExtensionAPI, configDir: string): void {
110
147
  standards: selected.standards,
111
148
  knowledge: selected.knowledge,
112
149
  decisions: selected.decisions,
113
- workflowContext: masterWorkflowContext(task),
150
+ workflowContext: masterWorkflowContext(task, budgetContext(root, configDir, task)),
114
151
  instructions: loadConfig().master.instructions,
115
152
  });
116
153
  });
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";
@@ -16,6 +16,7 @@ import { setAutoMode } from "../state/auto.ts";
16
16
  import { loadPlannedTask, markPlannedTaskStarted, plannedTaskRequest } from "../state/backlog.ts";
17
17
  import { triageFor } from "../classifier/instance.ts";
18
18
  import { freshContextOn, markContext } from "./fresh-context.ts";
19
+ import { setBudget } from "../state/budget.ts";
19
20
 
20
21
  function uniqueTaskId(root: string, configDir: string, request: string): string {
21
22
  const base = nextTaskId(request);
@@ -25,12 +26,13 @@ function uniqueTaskId(root: string, configDir: string, request: string): string
25
26
  return id;
26
27
  }
27
28
 
28
- export function kickoff(task: Task): string {
29
+ export function kickoff(task: Task, budgetMinutes = 0): string {
29
30
  return [
30
31
  `A bot-lobby task is active: ${task.id}`,
31
32
  `Title: ${task.title}`,
32
33
  `Request: ${taskRequest(task)}`,
33
34
  `State: ${task.state}`,
35
+ ...(budgetMinutes > 0 ? [`Time budget: ${budgetMinutes} minutes of work, for you and every agent. Size the plan to fit it and divide it by scope (see Time budget in your prompt).`] : []),
34
36
  "",
35
37
  "Drive it with the orchestrate tool:",
36
38
  "1. clarify if the request is genuinely ambiguous,",
@@ -52,6 +54,8 @@ export interface StartOptions {
52
54
  auto?: boolean;
53
55
  /** The task's short title (a planned task's own title); derived from the request otherwise. */
54
56
  title?: string;
57
+ /** Minutes of work time the task gets; `workflow.taskBudgetMinutes` when absent. */
58
+ budget?: number;
55
59
  }
56
60
 
57
61
  export async function startTask(pi: ExtensionAPI, ctx: ExtensionContext, configDir: string, request: string, options: StartOptions = {}): Promise<Task | undefined> {
@@ -71,6 +75,8 @@ export async function startTask(pi: ExtensionAPI, ctx: ExtensionContext, configD
71
75
  const triage = await triageFor({ cwd: ctx.cwd, root, configDir }, request);
72
76
  if (triage) task.triage = triage;
73
77
  saveTask(root, configDir, task);
78
+ const budgetMinutes = options.budget ?? loadConfig().workflow.taskBudgetMinutes;
79
+ if (budgetMinutes > 0) setBudget(root, configDir, task.id, budgetMinutes);
74
80
  if (options.auto) setAutoMode(root, configDir, task.id, true, sessionId);
75
81
  // A session that starts a task is named after it, so /resume and the lobby list it by name.
76
82
  if (!pi.getSessionName()) pi.setSessionName(task.title);
@@ -80,7 +86,7 @@ export async function startTask(pi: ExtensionAPI, ctx: ExtensionContext, configD
80
86
  // The oracle takes the task on with a clean context: nothing said before the kickoff is sent to its model.
81
87
  if (freshContextOn()) markContext(pi, { kind: "start", taskId: task.id, at: Date.now() });
82
88
  // A kickoff while pi is still busy (another turn) queues behind it instead of throwing.
83
- pi.sendUserMessage(kickoff(task), ctx.isIdle() ? undefined : { deliverAs: "followUp" });
89
+ pi.sendUserMessage(kickoff(task, budgetMinutes), ctx.isIdle() ? undefined : { deliverAs: "followUp" });
84
90
  return task;
85
91
  }
86
92
 
package/src/pi/tools.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { StringEnum } from "@earendil-works/pi-ai";
2
- import type { AgentToolUpdateCallback, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
- import { Container, Text } from "@earendil-works/pi-tui";
2
+ import { getMarkdownTheme, type AgentToolUpdateCallback, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import { Container, Markdown, Text } from "@earendil-works/pi-tui";
4
4
  import { Type } from "typebox";
5
5
  import type { AgentRun } from "../schemas/findings.ts";
6
6
  import type { ProcessRunner } from "../execution/pi-runner.ts";
@@ -8,6 +8,10 @@ import { detectProjectRoot, loadConfig } from "../state/project.ts";
8
8
  import { classifier, effortFor, hintsFor, triageFor } from "../classifier/instance.ts";
9
9
  import { truncate } from "../text.ts";
10
10
  import { applyStatus, reportRuns, summarizeRun } from "./ui.ts";
11
+ import { whileAsking } from "../state/budget.ts";
12
+ import { askUser } from "../ask/dialog.ts";
13
+ import type { AskQuestion } from "../ask/types.ts";
14
+ import { unescapeBreaks } from "../lobby/markdown.ts";
11
15
  import { isQuiet } from "./quiet.ts";
12
16
  import { checkThinking, createProfileResolver, modelRef, type ModelLookup } from "./model-support.ts";
13
17
  import { agentName, describeRun } from "./run-summary.ts";
@@ -36,16 +40,18 @@ const OrchestrateSchema = Type.Object({
36
40
  Type.Object({
37
41
  domain: Type.String({ description: "designer, backend, or qa" }),
38
42
  task: Type.String({ description: "the concrete step(s) for that domain's worker" }),
43
+ minutes: Type.Optional(Type.Number({ description: "under a time budget: minutes for this worker, by its scope" })),
39
44
  }),
40
45
  { description: "implement: run several domains in parallel (distinct domains); workers share files through the file desk" },
41
46
  ),
42
47
  ),
48
+ minutes: Type.Optional(Type.Number({ description: "implement (under a time budget): minutes for this step, by its scope; budget: more minutes to ask the user for" })),
43
49
  approvalId: Type.Optional(Type.String({ description: "resolve_approval: the approval id from a worker result" })),
44
50
  decision: Type.Optional(
45
51
  StringEnum(["approved", "rejected"] as const, { description: "resolve_approval: approve or reject the request" }),
46
52
  ),
47
53
  note: Type.Optional(Type.String({ description: "resolve_approval: rationale, or what to do instead when rejected" })),
48
- reason: Type.Optional(Type.String({ description: "block: why the task cannot continue" })),
54
+ reason: Type.Optional(Type.String({ description: "block: why the task cannot continue; budget: why the task needs more time" })),
49
55
  text: Type.Optional(Type.String({ description: "decide/complete/knowledge: the decision, completion summary, or knowledge text" })),
50
56
  file: Type.Optional(Type.String({ description: "compact: the knowledge file to rewrite, e.g. knowledge.md" })),
51
57
  kind: Type.Optional(
@@ -64,7 +70,8 @@ const DESCRIPTION = [
64
70
  "review), knowledge (record approved knowledge or a decision),",
65
71
  "compact (replace a knowledge file with a rewritten version, archiving the old one),",
66
72
  "resolve_approval (approve or reject a request), complete (declare the task done after the gates",
67
- "pass), block/resume (escalate or continue), status, cancel.",
73
+ "pass), block/resume (escalate or continue), budget (under a time budget: where it stands, or ask the",
74
+ "user for more minutes with a reason), status, cancel.",
68
75
  "The engine validates every step against the task state machine, so a rejected action means the workflow is not at that step yet.",
69
76
  ].join(" ");
70
77
 
@@ -109,8 +116,11 @@ export function workflowDeps(
109
116
  classifier: classifier(),
110
117
  triage: (request, triageSignal) => triageFor({ cwd: ctx.cwd, root, configDir }, request, triageSignal),
111
118
  effort: effortFor((model, thinking) => checkThinking(modelLookup(ctx)(model), thinking).level),
112
- ask: async (question) => (hasUI ? ctx.ui.input(question) : undefined),
113
- choose: async (title, options) => (hasUI ? ctx.ui.select(title, options) : undefined),
119
+ // Time spent waiting on the user is not the task's: its budget clock waits too.
120
+ ask: async (question) => (hasUI ? whileAsking(() => ctx.ui.input(question)) : undefined),
121
+ choose: async (title, options) => (hasUI ? whileAsking(() => ctx.ui.select(title, options)) : undefined),
122
+ // An agent's own questions (the designer's), relayed as the questionnaire.
123
+ ...(hasUI ? { askQuestions: (questions: AskQuestion[], from: string, askSignal?: AbortSignal) => whileAsking(() => askUser(questions, ctx, askSignal, from)) } : {}),
114
124
  notify: (message, level = "info") => {
115
125
  if (hasUI) ctx.ui.notify(message, level);
116
126
  },
@@ -135,6 +145,14 @@ function runReporter(
135
145
  };
136
146
  }
137
147
 
148
+ /** A header line over Markdown, rendered as pi renders its own messages (a proposal's bullets, a report's code spans). */
149
+ function headedMarkdown(header: string, body: string, color: (text: string) => string): Container {
150
+ const box = new Container();
151
+ box.addChild(new Text(header, 0, 0));
152
+ if (body.trim()) box.addChild(new Markdown(unescapeBreaks(body), 0, 0, getMarkdownTheme(), { color }));
153
+ return box;
154
+ }
155
+
138
156
  /** TUI-only transcript entries; these never enter the model's context. */
139
157
  function registerBotLobbyEntries(pi: ExtensionAPI): void {
140
158
  pi.registerEntryRenderer("bot-lobby", (entry, { expanded }, theme) => {
@@ -145,7 +163,7 @@ function registerBotLobbyEntries(pi: ExtensionAPI): void {
145
163
  }
146
164
  const header = `bot-lobby ${data?.taskId ?? ""} — ${data?.kind ?? "note"}`.trim();
147
165
  const body = data?.text ?? "";
148
- return new Text(`${theme.fg("accent", theme.bold(header))}\n${theme.fg("toolOutput", expanded ? body : truncate(body, 600))}`, 0, 0);
166
+ return headedMarkdown(theme.fg("accent", theme.bold(header)), expanded ? body : truncate(body, 600), (text) => theme.fg("toolOutput", text));
149
167
  });
150
168
  }
151
169
 
@@ -200,7 +218,7 @@ export function registerOrchestrateTool(pi: ExtensionAPI, configDir: string, run
200
218
  const body = result.content[0]?.type === "text" ? result.content[0].text : "";
201
219
  const icon = details?.ok ? theme.fg("success", "✓") : theme.fg("warning", "!");
202
220
  const header = `${icon} ${theme.fg("toolTitle", theme.bold("orchestrate"))} ${theme.fg("accent", details?.taskId ?? "")} ${theme.fg("muted", `→ ${details?.state ?? "?"}`)}`;
203
- return new Text(expanded && body ? `${header}\n${theme.fg("dim", truncate(body, 2000))}` : header, 0, 0);
221
+ return expanded && body ? headedMarkdown(header, truncate(body, 2000), (text) => theme.fg("dim", text)) : new Text(header, 0, 0);
204
222
  },
205
223
  });
206
224
  }
package/src/pi/ui.ts CHANGED
@@ -26,6 +26,7 @@ import { slotSituations } from "./zen-metrics.ts";
26
26
  import type { Situation } from "./kaomoji.ts";
27
27
  import { isQuiet, isSubagentProcess, toggleQuiet } from "./quiet.ts";
28
28
  import { panelLines, type ExpressionFrames, type OracleMotion } from "./zen.ts";
29
+ import { budgetClock } from "../state/budget.ts";
29
30
 
30
31
  export const STATUS_KEY = "bot-lobby";
31
32
 
@@ -52,6 +53,8 @@ let zenOn = false;
52
53
  * keys the widget's render cache.
53
54
  */
54
55
  let zenState: { task: Task | undefined; live: AgentRun[]; runs: AgentRun[] } = { task: undefined, live: [], runs: [] };
56
+ /** Where the widget's task lives, so its time budget can be read. */
57
+ let zenPaths: { root: string; configDir: string } | undefined;
55
58
  let zenVersion = 0;
56
59
 
57
60
  /** Latest master tool activity; the oracle's speech bubble shows it. */
@@ -255,7 +258,8 @@ export class ZenScene {
255
258
  const motion = this.motion(now);
256
259
  const key = `${width}|${rows}|${this.tick}|${frameKey}|${motion.phase}|${motion.talk}|${zenVersion}|${Math.floor(now / 1000)}|${quiet}|${still}`;
257
260
  if (this.cache && this.cache.key === key && this.cache.theme === theme) return this.cache.lines;
258
- const opts = { width, rows, tick: this.tick, theme, expressions, variants, oracleActivity, oracleMotion: motion, still };
261
+ const time = zenState.task && zenPaths ? budgetClock(zenPaths.root, zenPaths.configDir, zenState.task.id, now) : undefined;
262
+ const opts = { width, rows, tick: this.tick, theme, expressions, variants, oracleActivity, oracleMotion: motion, still, ...(time ? { time } : {}) };
259
263
  const lines = panelLines(zenState.task, zenState.runs, now, quiet, opts).map((line) => clip(line, width));
260
264
  this.cache = { key, theme, lines };
261
265
  return lines;
@@ -356,6 +360,7 @@ function leaveZen(ctx: ExtensionContext): void {
356
360
  export function applyStatus(ctx: ExtensionContext, root: string, configDir: string, runs: AgentRun[] = []): void {
357
361
  const sessionId = ctx.sessionManager.getSessionId();
358
362
  const task = isSubagentProcess() || minimized ? undefined : activeTask(root, configDir, sessionId);
363
+ zenPaths = { root, configDir };
359
364
  const sameTask = zenState.task?.id === task?.id;
360
365
  setZenState(task, mergeRuns(sameTask ? zenState.live : [], slim(runs)));
361
366
  ctx.ui.setStatus(STATUS_KEY, statusText(task, minimized));
@@ -11,6 +11,13 @@ import { SLOT_IDS, SLOT_LABELS, type SlotId, type SlotState } from "./mascot-art
11
11
  import { formatDuration, planChecklist } from "./zen.ts";
12
12
  import { QUIET_MS, quietFor, slotOf } from "./run-summary.ts";
13
13
  import type { Situation } from "./kaomoji.ts";
14
+ import { formatMinutes } from "../state/budget.ts";
15
+
16
+ /** A task's time budget as the scene shows it: work time spent of the whole. */
17
+ export interface SceneTime {
18
+ usedMs: number;
19
+ totalMs: number;
20
+ }
14
21
 
15
22
  export type { SlotId, SlotState };
16
23
 
@@ -70,7 +77,10 @@ function latestRunFor(runs: AgentRun[], id: SlotId): AgentRun | undefined {
70
77
  function runElapsedLabel(run: AgentRun | undefined, now: number): string {
71
78
  if (!run) return "—";
72
79
  const end = run.finishedAt ? Date.parse(run.finishedAt) : now;
73
- return formatDuration(end - Date.parse(run.startedAt));
80
+ const elapsed = end - Date.parse(run.startedAt);
81
+ // Under a time budget: minutes spent of the minutes given (`12/30m`).
82
+ if (run.allotMs) return `${Math.max(0, Math.floor(elapsed / 60_000))}/${Math.round((run.allotMs + (run.extendedMs ?? 0)) / 60_000)}m`;
83
+ return formatDuration(elapsed);
74
84
  }
75
85
 
76
86
  function slotView(id: SlotId, runs: AgentRun[], now: number): SlotView {
@@ -89,7 +99,7 @@ function slotView(id: SlotId, runs: AgentRun[], now: number): SlotView {
89
99
  };
90
100
  }
91
101
 
92
- export function sceneMetrics(task: Task, runs: AgentRun[], now: number): SceneMetrics {
102
+ export function sceneMetrics(task: Task, runs: AgentRun[], now: number, time?: SceneTime): SceneMetrics {
93
103
  const checklist = planChecklist(task.plan ?? "", runs);
94
104
  const total = checklist.length;
95
105
  const done = checklist.filter((step) => step.status === "done").length;
@@ -98,7 +108,7 @@ export function sceneMetrics(task: Task, runs: AgentRun[], now: number): SceneMe
98
108
  const elapsed = Number.isFinite(created) ? Math.max(0, now - created) : 0;
99
109
  return {
100
110
  ...summary,
101
- elapsedLabel: formatDuration(elapsed),
111
+ elapsedLabel: time ? `${formatMinutes(time.usedMs)} of ${formatMinutes(time.totalMs)}` : formatDuration(elapsed),
102
112
  slots: SLOT_IDS.map((id) => slotView(id, runs, now)),
103
113
  };
104
114
  }
package/src/pi/zen.ts CHANGED
@@ -37,7 +37,8 @@ import {
37
37
  type LargeSlot,
38
38
  type LargeTaskRow,
39
39
  } from "./zen-large.ts";
40
- import { runStatus, sceneMetrics, slotSituation, type SceneMetrics, type SlotView } from "./zen-metrics.ts";
40
+ import { runStatus, sceneMetrics, slotSituation, type SceneMetrics, type SceneTime, type SlotView } from "./zen-metrics.ts";
41
+ import { formatMinutes } from "../state/budget.ts";
41
42
  import { feedLine, QUIET_MS, quietFor } from "./run-summary.ts";
42
43
  import { shortDuration } from "../text.ts";
43
44
 
@@ -378,18 +379,22 @@ function checklistRow(step: PlanStep, ordinal: number): string {
378
379
  return ` ${icon} ${ordinal}. ${step.text}`;
379
380
  }
380
381
 
381
- function headerLine(task: Task, now: number, quiet: boolean): string {
382
+ function headerLine(task: Task, now: number, quiet: boolean, time?: SceneTime): string {
382
383
  const paused = task.paused ? " (paused)" : "";
383
- const elapsed = formatDuration(now - Date.parse(task.createdAt));
384
+ const elapsed = time ? `${formatMinutes(time.usedMs)} of ${formatMinutes(time.totalMs)}` : formatDuration(now - Date.parse(task.createdAt));
384
385
  const mode = quiet ? "tools hidden (alt+t)" : "tools shown";
385
386
  return `bot-lobby ${task.id} · ${task.state}${paused} ⏱ ${elapsed} · ${mode}`;
386
387
  }
387
388
 
388
- /** The pending approval or blocker the user must resolve, with its severity. */
389
- function taskAlert(task: Task): { text: string; kind: "warning" | "error" } | undefined {
389
+ /** The pending approval or blocker the user must resolve, with its severity; then a spent time budget. */
390
+ function taskAlert(task: Task, time?: SceneTime): { text: string; kind: "warning" | "error" } | undefined {
390
391
  const pending = task.approvals.filter((approval) => approval.status === "pending");
391
392
  if (pending.length > 0) return { text: `approvals pending: ${pending.map((approval) => approval.id).join(", ")}`, kind: "warning" };
392
393
  if (task.blockers.length > 0) return { text: `blocked: ${truncate(task.blockers[0]!.reason, 60)}`, kind: "error" };
394
+ if (time && time.usedMs >= time.totalMs * 0.9) {
395
+ const spent = time.usedMs >= time.totalMs;
396
+ return { text: `time budget ${spent ? "spent" : "nearly spent"}: ${formatMinutes(time.usedMs)} of ${formatMinutes(time.totalMs)}`, kind: "warning" };
397
+ }
393
398
  return undefined;
394
399
  }
395
400
 
@@ -518,10 +523,10 @@ function compactPanel(
518
523
  opts: PanelOptions,
519
524
  width: number,
520
525
  ): string[] {
521
- const metrics = sceneMetrics(task, runs, now);
526
+ const metrics = sceneMetrics(task, runs, now, opts.time);
522
527
  const expressions = opts.expressions ?? {};
523
528
  const strip = opts.still ? [] : compactStrip(task.state, metrics.slots, expressions, opts.theme);
524
- const fixed = [...bannerLines(width), headerLine(task, now, quiet), ...strip];
529
+ const fixed = [...bannerLines(width), headerLine(task, now, quiet, opts.time), ...strip];
525
530
  const tail = tailLines(task, runs, now, tick, steps, opts.theme);
526
531
  const room = Math.max(0, MAX_PANEL_LINES - fixed.length - tail.length);
527
532
  return [...fixed, ...tail, ...checklistLines(steps, room)].map((line) => clip(line, width));
@@ -577,8 +582,8 @@ function sceneInput(
577
582
  steps: PlanStep[],
578
583
  opts: PanelOptions,
579
584
  ): LargeSceneInput {
580
- const metrics = sceneMetrics(task, runs, now);
581
- const alert = taskAlert(task);
585
+ const metrics = sceneMetrics(task, runs, now, opts.time);
586
+ const alert = taskAlert(task, opts.time);
582
587
  return {
583
588
  taskId: task.id,
584
589
  taskTitle: task.title,
@@ -616,6 +621,8 @@ export interface PanelOptions {
616
621
  oracleMotion?: OracleMotion;
617
622
  /** Leave out the oracle and agent animations: status, alert, activity and checklist only. */
618
623
  still?: boolean;
624
+ /** The task's time budget: the elapsed label reads against it. */
625
+ time?: SceneTime;
619
626
  }
620
627
 
621
628
  /** The oracle's clocked animation state beyond its expression frame (see expressions.ts). */
@@ -30,15 +30,23 @@ function parseVerdict(text: string | undefined): Verdict {
30
30
  return "blocked";
31
31
  }
32
32
 
33
+ /** The severity a finding was tagged with, or undefined when it carries none. */
34
+ function taggedSeverity(text: string): Severity | undefined {
35
+ const match = /^\[([^\]]+)\]/.exec(text);
36
+ return SEVERITIES.find((candidate) => match?.[1]?.toLowerCase().includes(candidate));
37
+ }
38
+
33
39
  function parseFinding(text: string): ReviewFinding {
34
40
  const match = /^\[([^\]]+)\]\s*(.*)$/.exec(text);
35
- const severity = SEVERITIES.find((candidate) => match?.[1]?.toLowerCase().includes(candidate)) ?? "info";
36
- return { severity, text: (match?.[2] ?? text).trim() };
41
+ return { severity: taggedSeverity(text) ?? "info", text: (match?.[2] ?? text).trim() };
37
42
  }
38
43
 
39
44
  /** The QA gate never passes by default: a PASS must cite at least one executed check. */
40
45
  export const UNVERIFIED_PASS = "unverified pass: PASS without executed checks under ## Verification, downgraded to CHANGES_REQUIRED";
41
46
 
47
+ /** Minor and info findings never hold the gate; what the reviewer asked for is kept as follow-ups. */
48
+ export const MINOR_ONLY = "CHANGES_REQUIRED for minor or info findings only: passed, its asks kept as follow-ups";
49
+
42
50
  /** A verification bullet names a command or check and its result (`- cmd — result`). */
43
51
  function executedChecks(verification: string): string[] {
44
52
  return bullets(verification).filter((entry) => /\S\s*(?:—|–|-{1,2}|:|=>|→)\s*\S/.test(entry));
@@ -49,23 +57,34 @@ export function parseReviewResult(domain: Domain, raw: string): ReviewResult {
49
57
  const sections = parseSections(raw);
50
58
  const verification = findSection(sections, "verification") ?? "";
51
59
  const requiredChanges = bullets(findSection(sections, "required changes"));
60
+ const entries = bullets(findSection(sections, "findings"));
52
61
  let verdict = parseVerdict(findSection(sections, "verdict"));
53
62
  let downgraded: string | undefined;
54
- if (verdict === "pass" && executedChecks(verification).length === 0) {
63
+ let relaxed: string | undefined;
64
+ const checked = executedChecks(verification).length > 0;
65
+ if (verdict === "pass" && !checked) {
55
66
  verdict = "changes_required";
56
67
  downgraded = UNVERIFIED_PASS;
57
68
  requiredChanges.push("Re-run the QA gate and record the checks actually executed, each as `- command — result`.");
69
+ } else if (
70
+ verdict === "changes_required" && checked && entries.length > 0
71
+ // Only findings explicitly tagged minor or info: an untagged one may be serious.
72
+ && entries.every((entry) => { const severity = taggedSeverity(entry); return severity === "minor" || severity === "info"; })
73
+ ) {
74
+ verdict = "pass";
75
+ relaxed = MINOR_ONLY;
58
76
  }
59
77
  return {
60
78
  domain,
61
79
  role: "reviewer",
62
80
  verdict,
63
- findings: bullets(findSection(sections, "findings")).map(parseFinding),
81
+ findings: entries.map(parseFinding),
64
82
  verification,
65
83
  requiredChanges,
66
84
  optionalImprovements: bullets(findSection(sections, "optional improvements")),
67
85
  pushback: parsePushback(sections),
68
86
  ...(downgraded ? { downgraded } : {}),
87
+ ...(relaxed ? { relaxed } : {}),
69
88
  raw,
70
89
  };
71
90
  }
@@ -25,6 +25,8 @@ export const workerSpec: RoleSpec = {
25
25
  "the matching section instead. Do not narrate. Report only what you actually changed and verified.",
26
26
  "An optional `## Pushback` (`**Request:**`, `**Reason:**`, optional `**Alternative:**`) states a change you",
27
27
  "believe is wrong, with your reason, instead of doing it; still complete everything else you safely can.",
28
+ "Stopped because your time is up, also add `## Left Off` (what you were doing, what is still to do) and",
29
+ "`## More Time` (`N minutes — why`).",
28
30
  ].join(" "),
29
31
  };
30
32
 
@@ -65,6 +67,15 @@ export function parseBlockers(
65
67
  ];
66
68
  }
67
69
 
70
+ /** `## More Time` as the minutes asked for (`15 minutes`, `1h`, `20m`) and the reason given. */
71
+ export function parseMoreTime(text: string): { minutes?: number; reason: string } {
72
+ const flat = text.replace(/^[-*]\s*/gm, "").replace(/\s+/g, " ").trim();
73
+ const match = /(\d+(?:\.\d+)?)\s*(h|hrs?|hours?|m|mins?|minutes?)\b/i.exec(flat);
74
+ const minutes = match ? Math.round(Number(match[1]) * (/^h/i.test(match[2]!) ? 60 : 1)) : undefined;
75
+ const reason = match ? flat.slice(match.index + match[0].length).replace(/^\s*(?:—|–|-|:|,|because|to)?\s*/i, "").trim() : flat;
76
+ return { ...(minutes && minutes > 0 ? { minutes } : {}), reason: reason || flat };
77
+ }
78
+
68
79
  /** Parse a worker's markdown into a structured result (never throws). */
69
80
  export function parseWorkerResult(domain: Domain, raw: string, now = new Date().toISOString()): WorkerResult {
70
81
  const sections = parseSections(raw);
@@ -72,6 +83,7 @@ export function parseWorkerResult(domain: Domain, raw: string, now = new Date().
72
83
  domain,
73
84
  role: "worker",
74
85
  completed: findSection(sections, "completed") ?? "",
86
+ ...leftOffFields(sections),
75
87
  filesChanged: bullets(findSection(sections, "files changed")).map((entry): FileChange => {
76
88
  const file = parseFileBullet(entry);
77
89
  return { path: file.path, change: file.reason };
@@ -87,6 +99,12 @@ export function parseWorkerResult(domain: Domain, raw: string, now = new Date().
87
99
  };
88
100
  }
89
101
 
102
+ function leftOffFields(sections: Map<string, string>): Pick<WorkerResult, "leftOff" | "moreTime"> {
103
+ const leftOff = findSection(sections, "left off")?.trim();
104
+ const more = findSection(sections, "more time")?.trim();
105
+ return { ...(leftOff ? { leftOff } : {}), ...(more ? { moreTime: parseMoreTime(more) } : {}) };
106
+ }
107
+
90
108
  /** Report contract deviations so the Master does not accept unverified work. */
91
109
  export function validateWorkerResult(result: WorkerResult): string[] {
92
110
  const issues: string[] = [];
@@ -68,6 +68,8 @@ export interface WorkflowConfig {
68
68
  maxParallelWorkers: number;
69
69
  /** The oracle starts each task with a clean context: its model is sent only the conversation since the task started, or since the last one ended. */
70
70
  freshContext: boolean;
71
+ /** Minutes of work time a new task gets unless it is started with its own (`--budget`); 0 = no budget. */
72
+ taskBudgetMinutes: number;
71
73
  }
72
74
 
73
75
  export interface KnowledgeConfig {
@@ -204,6 +206,7 @@ export const DEFAULT_CONFIG: BotLobbyConfig = {
204
206
  wrapUpAt: 0.75,
205
207
  maxParallelWorkers: 3,
206
208
  freshContext: true,
209
+ taskBudgetMinutes: 0,
207
210
  },
208
211
  knowledge: {
209
212
  compactionThreshold: 20000,
@@ -340,6 +343,7 @@ export function resolveConfig(partial: unknown): BotLobbyConfig {
340
343
  const src = (partial ?? {}) as Record<string, unknown>;
341
344
  const workflow = { ...DEFAULT_CONFIG.workflow, ...(src.workflow as Partial<WorkflowConfig> | undefined) };
342
345
  workflow.freshContext = workflow.freshContext !== false;
346
+ workflow.taskBudgetMinutes = typeof workflow.taskBudgetMinutes === "number" && workflow.taskBudgetMinutes > 0 ? Math.min(24 * 60, Math.round(workflow.taskBudgetMinutes)) : 0;
343
347
  const knowledge = { ...DEFAULT_CONFIG.knowledge, ...(src.knowledge as Partial<KnowledgeConfig> | undefined) };
344
348
  const srcAgents = (src.agents ?? {}) as Partial<BotLobbyConfig["agents"]>;
345
349
  return {
@@ -42,6 +42,10 @@ export interface WorkerResult {
42
42
  domain: Domain;
43
43
  role: "worker";
44
44
  completed: string;
45
+ /** Stopped for time: what it was doing and what is still to do. */
46
+ leftOff?: string;
47
+ /** Stopped for time: the minutes it asks for, and why. */
48
+ moreTime?: { minutes?: number; reason: string };
45
49
  filesChanged: FileChange[];
46
50
  verification: string;
47
51
  notes: string;
@@ -69,6 +73,8 @@ export interface ReviewResult {
69
73
  pushback?: Pushback;
70
74
  /** Set when the engine downgraded an unsupported PASS. */
71
75
  downgraded?: string;
76
+ /** Set when the engine passed a CHANGES_REQUIRED that named only minor or info findings. */
77
+ relaxed?: string;
72
78
  raw: string;
73
79
  }
74
80
 
@@ -137,4 +143,12 @@ export interface AgentRun {
137
143
  routedFrom?: string;
138
144
  /** Why and where it was routed (`trivial 0.88: p/big · medium → p/cheap · low`). */
139
145
  route?: string;
146
+ /** Files the run changed with `edit`/`write`, absolute, across its attempts. */
147
+ edited?: string[];
148
+ /** Under a task time budget: the time it was given, when that runs out (epoch ms), and any granted since. */
149
+ allotMs?: number;
150
+ endsAt?: number;
151
+ extendedMs?: number;
152
+ /** Stopped at its allotted time and not given more: its report says where it left off. */
153
+ timeUp?: boolean;
140
154
  }