@a-t-h-i/bot-lobby 0.6.2 → 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 (69) hide show
  1. package/README.md +112 -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 +9 -0
  19. package/src/lobby/ask.ts +10 -120
  20. package/src/lobby/feed.ts +76 -8
  21. package/src/lobby/layout.ts +38 -18
  22. package/src/lobby/markdown.ts +92 -19
  23. package/src/lobby/planner.ts +1 -1
  24. package/src/lobby/quickfix.ts +21 -0
  25. package/src/lobby/runtime.ts +51 -37
  26. package/src/lobby/session-files.ts +162 -25
  27. package/src/lobby/sessions.ts +21 -7
  28. package/src/lobby/tabs/home.ts +289 -67
  29. package/src/lobby/tabs/issues.ts +4 -3
  30. package/src/lobby/tabs/plan.ts +4 -4
  31. package/src/lobby/tabs/quickfix.ts +7 -1
  32. package/src/lobby/tabs/tasks.ts +14 -4
  33. package/src/lobby/theme.ts +30 -0
  34. package/src/lobby/view.ts +141 -25
  35. package/src/master/decisions.ts +1 -1
  36. package/src/master/master.ts +41 -4
  37. package/src/master/research.ts +5 -2
  38. package/src/pi/commands.ts +94 -10
  39. package/src/pi/events.ts +54 -12
  40. package/src/pi/fresh-context.ts +134 -0
  41. package/src/pi/owner.ts +19 -10
  42. package/src/pi/quiet.ts +22 -4
  43. package/src/pi/start-task.ts +11 -2
  44. package/src/pi/tools.ts +26 -8
  45. package/src/pi/ui.ts +9 -3
  46. package/src/pi/zen-large.ts +10 -10
  47. package/src/pi/zen-metrics.ts +13 -3
  48. package/src/pi/zen.ts +22 -15
  49. package/src/roles/reviewer.ts +23 -4
  50. package/src/roles/worker.ts +18 -0
  51. package/src/schemas/configuration.ts +8 -0
  52. package/src/schemas/findings.ts +14 -0
  53. package/src/schemas/task.ts +21 -0
  54. package/src/state/archive.ts +12 -3
  55. package/src/state/backlog.ts +13 -3
  56. package/src/state/budget.ts +274 -0
  57. package/src/state/changes.ts +231 -0
  58. package/src/state/file-cache.ts +62 -0
  59. package/src/state/metrics.ts +63 -13
  60. package/src/state/persistence.ts +36 -2
  61. package/src/text.ts +28 -2
  62. package/src/web/extract.ts +332 -0
  63. package/src/web/fetch.ts +232 -0
  64. package/src/web/html.ts +183 -0
  65. package/src/web/read.ts +113 -0
  66. package/src/web/search.ts +202 -0
  67. package/src/web/tools.ts +279 -0
  68. package/src/width.ts +102 -0
  69. package/src/workflow/workflow.ts +592 -42
@@ -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":
package/src/pi/events.ts CHANGED
@@ -9,19 +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
+ 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"]);
18
23
 
19
24
  /** States in which the triage still helps the Master shape the task; once it is planned, the hints are noise. */
20
25
  const SHAPING_STATES: ReadonlySet<TaskState> = new Set(["created", "clarifying", "scouting", "synthesizing", "awaiting_approval"]);
21
26
 
22
- /** The Master's workflow context: the task's state, plus the classifier's triage while the task is being shaped. */
23
- export function masterWorkflowContext(task: Task): string {
24
- 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(" ");
25
43
  }
26
44
 
27
45
  export function masterTaskContext(task: Task, comments: readonly PlanComment[] = [], auto = false): string {
@@ -46,12 +64,11 @@ export function masterTaskContext(task: Task, comments: readonly PlanComment[] =
46
64
  * section rather than replacing the whole prompt.
47
65
  */
48
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[] = [];
49
69
  pi.on("session_start", (_event, ctx) => {
50
70
  // Master-only: subagents keep their own --tools allowlist (see quiet.ts).
51
- if (!isSubagentProcess()) {
52
- registerQuietTools(pi);
53
- pi.setActiveTools(visibleTools(pi.getActiveTools()));
54
- }
71
+ if (!isSubagentProcess()) registerQuietTools(pi);
55
72
  const root = detectProjectRoot(ctx.cwd, configDir);
56
73
  setMinimized(false);
57
74
  applyStatus(ctx, root, configDir);
@@ -64,25 +81,38 @@ export function registerLifecycle(pi: ExtensionAPI, configDir: string): void {
64
81
  // Parallel tool calls: the newest still-running call keeps the word.
65
82
  const inFlight = new Map<string, string>();
66
83
  const showOracle = () => setOracleActivity([...inFlight.values()].at(-1) ?? ORACLE_THINKING);
67
- 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) => {
68
87
  if (isSubagentProcess()) return;
69
88
  inFlight.clear();
70
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);
71
93
  });
72
94
  pi.on("tool_execution_start", (event) => {
73
95
  if (isSubagentProcess()) return;
74
96
  inFlight.set(event.toolCallId, oracleActivityWord(event.toolName, event.args));
75
97
  showOracle();
98
+ if (ASKING_TOOLS.has(event.toolName) && !asking.has(event.toolCallId)) {
99
+ asking.add(event.toolCallId);
100
+ pauseClocks();
101
+ }
76
102
  });
77
103
  pi.on("tool_execution_end", (event) => {
78
104
  if (isSubagentProcess()) return;
79
105
  inFlight.delete(event.toolCallId);
80
106
  showOracle();
107
+ if (asking.delete(event.toolCallId)) resumeClocks();
81
108
  });
82
109
  pi.on("agent_end", () => {
83
110
  if (isSubagentProcess()) return;
84
111
  inFlight.clear();
85
112
  setOracleActivity(undefined);
113
+ for (const _call of asking) resumeClocks();
114
+ asking.clear();
115
+ stopClocks();
86
116
  });
87
117
  pi.on("session_shutdown", (_event, ctx) => {
88
118
  cancelAllRuns();
@@ -92,9 +122,21 @@ export function registerLifecycle(pi: ExtensionAPI, configDir: string): void {
92
122
  pi.on("before_agent_start", (event, ctx) => {
93
123
  const root = detectProjectRoot(ctx.cwd, configDir);
94
124
  const sessionId = ctx.sessionManager.getSessionId();
95
- const task = isSubagentProcess() || isMinimized() ? undefined : activeTask(root, configDir, sessionId);
125
+ const hidden = isSubagentProcess() || isMinimized();
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
+ }
96
135
  if (!task) {
97
- delete event.systemPromptOptions.sections["bot-lobby"];
136
+ // After a task ended and cleared the context, a line on where its record is, in case the user refers back to it.
137
+ const previous = hidden ? undefined : previousTaskNote(ctx, configDir);
138
+ if (previous) event.systemPromptOptions.sections["bot-lobby"] = previous;
139
+ else delete event.systemPromptOptions.sections["bot-lobby"];
98
140
  return;
99
141
  }
100
142
  const slices = readAgentKnowledge(readDataRoots(root, configDir), "master");
@@ -105,7 +147,7 @@ export function registerLifecycle(pi: ExtensionAPI, configDir: string): void {
105
147
  standards: selected.standards,
106
148
  knowledge: selected.knowledge,
107
149
  decisions: selected.decisions,
108
- workflowContext: masterWorkflowContext(task),
150
+ workflowContext: masterWorkflowContext(task, budgetContext(root, configDir, task)),
109
151
  instructions: loadConfig().master.instructions,
110
152
  });
111
153
  });
@@ -0,0 +1,134 @@
1
+ /**
2
+ * A clean slate for the oracle at every task. The session keeps its whole
3
+ * conversation (the lobby, /tree and /resume still show all of it), but the
4
+ * model is sent only what came after the latest task boundary: a task
5
+ * starting in this session, or this session's task ending. So one long
6
+ * session no longer carries every past task into every call; the task in
7
+ * hand reaches the oracle through bot-lobby's system prompt section and its
8
+ * task folder, which do not depend on the conversation.
9
+ *
10
+ * A boundary is a custom entry in the session file, which is never sent to
11
+ * the model: it survives /resume and belongs to the branch it was made on.
12
+ * Subagents (scouts, workers, reviewers, the planning panel, quick fixes)
13
+ * already run as pi processes of their own, each with its own context, and
14
+ * are not touched here.
15
+ */
16
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
17
+ import { relative } from "node:path";
18
+ import { TERMINAL_STATES, type Task } from "../schemas/task.ts";
19
+ import { peekTasks, taskDirFor } from "../state/persistence.ts";
20
+ import { detectProjectRoot, loadConfig } from "../state/project.ts";
21
+ import { CLEARED_NOTE, CONTEXT_MARK, lobbyFeed } from "../lobby/feed.ts";
22
+ import { isSubagentProcess } from "./quiet.ts";
23
+
24
+ export { CLEARED_NOTE, CONTEXT_MARK };
25
+
26
+ export interface ContextMark {
27
+ kind: "start" | "end";
28
+ taskId: string;
29
+ /** Messages from this time on (ms) are what the model is sent. */
30
+ at: number;
31
+ }
32
+
33
+ /** The boundaries on a session branch, oldest first. */
34
+ export function contextMarks(entries: readonly unknown[]): ContextMark[] {
35
+ const marks: ContextMark[] = [];
36
+ for (const entry of entries) {
37
+ const mark = asMark(entry);
38
+ if (mark) marks.push(mark);
39
+ }
40
+ return marks;
41
+ }
42
+
43
+ /** A session entry as a boundary, when it is one. */
44
+ export function asMark(entry: unknown): ContextMark | undefined {
45
+ const record = entry as { type?: string; customType?: string; data?: Partial<ContextMark> } | undefined;
46
+ if (record?.type !== "custom" || record.customType !== CONTEXT_MARK) return undefined;
47
+ const data = record.data;
48
+ if (!data || typeof data.at !== "number" || typeof data.taskId !== "string" || (data.kind !== "start" && data.kind !== "end")) return undefined;
49
+ return { kind: data.kind, taskId: data.taskId, at: data.at };
50
+ }
51
+
52
+ /** Replies and tool results answer what came before them, so what the model is sent never opens with one. */
53
+ const ANSWERS = new Set(["assistant", "toolResult"]);
54
+
55
+ /**
56
+ * The messages from the boundary on: from the first one at or after it that
57
+ * can open a conversation (a request, a summary, an extension's message).
58
+ * All of them when nothing has come since the boundary (a task that ended
59
+ * mid-run still gets its closing turn).
60
+ */
61
+ export function fromBoundary<T extends { role: string; timestamp?: number }>(messages: T[], boundary: number | undefined): T[] {
62
+ if (boundary === undefined) return messages;
63
+ const start = messages.findIndex((message) => typeof message.timestamp === "number" && message.timestamp >= boundary && !ANSWERS.has(message.role));
64
+ return start <= 0 ? messages : messages.slice(start);
65
+ }
66
+
67
+ /** Whether the oracle starts clean at each task (`workflow.freshContext`, on by default). */
68
+ export function freshContextOn(): boolean {
69
+ return loadConfig().workflow.freshContext;
70
+ }
71
+
72
+ /** Record a boundary in this session. */
73
+ export function markContext(pi: ExtensionAPI, mark: ContextMark): void {
74
+ pi.appendEntry<ContextMark>(CONTEXT_MARK, mark);
75
+ if (mark.kind === "end") lobbyFeed.say("note", CLEARED_NOTE, mark.at);
76
+ }
77
+
78
+ /** The session's newest task: the one its conversation was last about. */
79
+ function lastTask(ctx: ExtensionContext, configDir: string): Task | undefined {
80
+ const sessionId = ctx.sessionManager.getSessionId();
81
+ return peekTasks(detectProjectRoot(ctx.cwd, configDir), configDir).find((task) => task.ownerSessionId === sessionId);
82
+ }
83
+
84
+ /** Close the session's task once it has ended (completed or abandoned): the next request starts clean. */
85
+ export function closeEndedTask(pi: ExtensionAPI, ctx: ExtensionContext, configDir: string, now = Date.now()): boolean {
86
+ if (isSubagentProcess() || !freshContextOn()) return false;
87
+ const task = lastTask(ctx, configDir);
88
+ if (!task || !TERMINAL_STATES.includes(task.state)) return false;
89
+ if (contextMarks(ctx.sessionManager.getBranch()).some((mark) => mark.kind === "end" && mark.taskId === task.id)) return false;
90
+ markContext(pi, { kind: "end", taskId: task.id, at: now });
91
+ return true;
92
+ }
93
+
94
+ /**
95
+ * The note bot-lobby's system prompt section carries after a task ended and
96
+ * cleared the context: which task it was and where its record is, for when
97
+ * the user refers back to it.
98
+ */
99
+ export function previousTaskNote(ctx: ExtensionContext, configDir: string): string | undefined {
100
+ if (!freshContextOn()) return undefined;
101
+ const last = contextMarks(ctx.sessionManager.getBranch()).at(-1);
102
+ if (last?.kind !== "end") return undefined;
103
+ const root = detectProjectRoot(ctx.cwd, configDir);
104
+ const task = peekTasks(root, configDir).find((entry) => entry.id === last.taskId);
105
+ if (!task) return undefined;
106
+ const record = relative(ctx.cwd, taskDirFor(root, configDir, task.id)) || ".";
107
+ return [
108
+ `Your previous task, ${task.id} "${task.title}", ended (${task.state}). Its conversation was cleared from your context so the next piece of work starts fresh.`,
109
+ `Its record (request, proposal, plan, decisions, runs) is in ${record}/. Read it only if the user refers back to that task.`,
110
+ ].join("\n");
111
+ }
112
+
113
+ /** Trim what the oracle is sent to its latest boundary, and close tasks as they end. */
114
+ export function registerFreshContext(pi: ExtensionAPI, configDir: string): void {
115
+ if (isSubagentProcess()) return;
116
+ pi.on("context", (event, ctx) => {
117
+ if (!freshContextOn()) return undefined;
118
+ const boundary = contextMarks(ctx.sessionManager.getBranch()).at(-1)?.at;
119
+ const messages = fromBoundary(event.messages, boundary);
120
+ return messages.length === event.messages.length ? undefined : { messages };
121
+ });
122
+ // A task's last turn is over: whatever is asked next starts clean.
123
+ pi.on("agent_end", (_event, ctx) => void closeEndedTask(pi, ctx, configDir));
124
+ // It may have ended while the oracle was idle (abandoned or archived from the lobby, finished in another window).
125
+ pi.on("before_agent_start", (_event, ctx) => void closeEndedTask(pi, ctx, configDir));
126
+ // Compacting a conversation the next request drops anyway would only cost a model call.
127
+ pi.on("session_before_compact", (event) => {
128
+ if (event.reason === "manual" || !freshContextOn()) return undefined;
129
+ const last = contextMarks(event.branchEntries).at(-1);
130
+ if (!last) return undefined;
131
+ const since = event.branchEntries.some((entry) => entry.type === "message" && Date.parse(entry.timestamp) >= last.at);
132
+ return since ? undefined : { cancel: true };
133
+ });
134
+ }
package/src/pi/owner.ts CHANGED
@@ -10,7 +10,7 @@
10
10
  import type { KeyId } from "@earendil-works/pi-tui";
11
11
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
12
12
  import { TERMINAL_STATES, type Task } from "../schemas/task.ts";
13
- import { activeTask, loadTask } from "../state/persistence.ts";
13
+ import { peekOwnedTask } from "../state/persistence.ts";
14
14
  import { detectProjectRoot, loadConfig } from "../state/project.ts";
15
15
  import { commentMessage, markCommentsDelivered, readPlanComments, undeliveredComments } from "../state/comments.ts";
16
16
  import { inboxMessage, markInboxDelivered, markSessionInboxDelivered, readInbox, readSessionInbox } from "../state/inbox.ts";
@@ -42,6 +42,8 @@ interface Owner {
42
42
  configDir: string;
43
43
  timer?: ReturnType<typeof setInterval>;
44
44
  auto?: AutoTrack;
45
+ /** The session's task as this tick read it: one read serves the whole tick. */
46
+ tick?: { task: Task | undefined };
45
47
  }
46
48
 
47
49
  let owner: Owner | undefined;
@@ -53,7 +55,8 @@ export function onOwnerEvent(fn: ((event: OwnerEvent) => void) | undefined): voi
53
55
  }
54
56
 
55
57
  function ownTask(state: Owner): Task | undefined {
56
- return activeTask(state.root, state.configDir, state.ctx.sessionManager.getSessionId());
58
+ if (state.tick) return state.tick.task;
59
+ return peekOwnedTask(state.root, state.configDir, state.ctx.sessionManager.getSessionId());
57
60
  }
58
61
 
59
62
  function live(task: Task | undefined): task is Task {
@@ -68,8 +71,7 @@ export function deliverComments(): number {
68
71
  if (!live(task) || task.paused) return 0;
69
72
  const fresh = undeliveredComments(readPlanComments(state.root, state.configDir, task.id));
70
73
  if (fresh.length === 0) return 0;
71
- const current = loadTask(state.root, state.configDir, task.id) ?? task;
72
- state.pi.sendUserMessage(commentMessage(task.id, fresh, Boolean(current.plan)), state.ctx.isIdle() ? undefined : { deliverAs: "steer" });
74
+ state.pi.sendUserMessage(commentMessage(task.id, fresh, Boolean(task.plan)), state.ctx.isIdle() ? undefined : { deliverAs: "steer" });
73
75
  markCommentsDelivered(state.root, state.configDir, task.id, fresh.map((comment) => comment.id));
74
76
  listener?.({ kind: "comments", taskId: task.id, count: fresh.length });
75
77
  return fresh.length;
@@ -211,11 +213,18 @@ export function toggleOwnAuto(): string {
211
213
 
212
214
  /** One tick of the owner's clock. */
213
215
  export function ownerTick(): void {
214
- heartbeat();
215
- deliverComments();
216
- deliverInbox();
217
- deliverSessionInbox();
218
- driveAuto();
216
+ const state = owner;
217
+ if (!state) return;
218
+ state.tick = { task: ownTask(state) };
219
+ try {
220
+ heartbeat();
221
+ deliverComments();
222
+ deliverInbox();
223
+ deliverSessionInbox();
224
+ driveAuto();
225
+ } finally {
226
+ state.tick = undefined;
227
+ }
219
228
  }
220
229
 
221
230
  function stop(): void {
@@ -254,7 +263,7 @@ export function registerOwner(pi: ExtensionAPI, configDir: string): void {
254
263
  // Auto mode has nobody to answer a question: tell the oracle to decide instead.
255
264
  pi.on("tool_call", (event, ctx) => {
256
265
  if (event.toolName !== "ask_user_question" || !owner) return undefined;
257
- const task = activeTask(owner.root, owner.configDir, ctx.sessionManager.getSessionId());
266
+ const task = peekOwnedTask(owner.root, owner.configDir, ctx.sessionManager.getSessionId());
258
267
  if (!live(task) || !isAutoMode(owner.root, owner.configDir, task.id)) return undefined;
259
268
  return { block: true, reason: `Auto mode is on for ${task.id}: nobody will answer. Decide this yourself from the request, the plan and your reconnaissance, record the decision, and continue.` };
260
269
  });
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";
@@ -15,6 +15,8 @@ import { applyMasterModel } from "./settings-ui.ts";
15
15
  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
+ import { freshContextOn, markContext } from "./fresh-context.ts";
19
+ import { setBudget } from "../state/budget.ts";
18
20
 
19
21
  function uniqueTaskId(root: string, configDir: string, request: string): string {
20
22
  const base = nextTaskId(request);
@@ -24,12 +26,13 @@ function uniqueTaskId(root: string, configDir: string, request: string): string
24
26
  return id;
25
27
  }
26
28
 
27
- export function kickoff(task: Task): string {
29
+ export function kickoff(task: Task, budgetMinutes = 0): string {
28
30
  return [
29
31
  `A bot-lobby task is active: ${task.id}`,
30
32
  `Title: ${task.title}`,
31
33
  `Request: ${taskRequest(task)}`,
32
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).`] : []),
33
36
  "",
34
37
  "Drive it with the orchestrate tool:",
35
38
  "1. clarify if the request is genuinely ambiguous,",
@@ -51,6 +54,8 @@ export interface StartOptions {
51
54
  auto?: boolean;
52
55
  /** The task's short title (a planned task's own title); derived from the request otherwise. */
53
56
  title?: string;
57
+ /** Minutes of work time the task gets; `workflow.taskBudgetMinutes` when absent. */
58
+ budget?: number;
54
59
  }
55
60
 
56
61
  export async function startTask(pi: ExtensionAPI, ctx: ExtensionContext, configDir: string, request: string, options: StartOptions = {}): Promise<Task | undefined> {
@@ -70,14 +75,18 @@ export async function startTask(pi: ExtensionAPI, ctx: ExtensionContext, configD
70
75
  const triage = await triageFor({ cwd: ctx.cwd, root, configDir }, request);
71
76
  if (triage) task.triage = triage;
72
77
  saveTask(root, configDir, task);
78
+ const budgetMinutes = options.budget ?? loadConfig().workflow.taskBudgetMinutes;
79
+ if (budgetMinutes > 0) setBudget(root, configDir, task.id, budgetMinutes);
73
80
  if (options.auto) setAutoMode(root, configDir, task.id, true, sessionId);
74
81
  // A session that starts a task is named after it, so /resume and the lobby list it by name.
75
82
  if (!pi.getSessionName()) pi.setSessionName(task.title);
76
83
  applyStatus(ctx, root, configDir);
77
84
  await applyMasterModel(pi, ctx, loadConfig());
78
85
  ctx.ui.notify(`bot-lobby ${task.id} started`, "info");
86
+ // The oracle takes the task on with a clean context: nothing said before the kickoff is sent to its model.
87
+ if (freshContextOn()) markContext(pi, { kind: "start", taskId: task.id, at: Date.now() });
79
88
  // A kickoff while pi is still busy (another turn) queues behind it instead of throwing.
80
- pi.sendUserMessage(kickoff(task), ctx.isIdle() ? undefined : { deliverAs: "followUp" });
89
+ pi.sendUserMessage(kickoff(task, budgetMinutes), ctx.isIdle() ? undefined : { deliverAs: "followUp" });
81
90
  return task;
82
91
  }
83
92