@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.
- package/README.md +112 -11
- package/package.json +1 -4
- package/prompts/master.md +47 -1
- package/prompts/researcher.md +8 -2
- package/prompts/reviewer.md +42 -0
- package/prompts/worker.md +7 -0
- package/src/ask/dialog.ts +167 -0
- package/src/ask/image.ts +202 -0
- package/src/ask/png.ts +179 -0
- package/src/ask/relay.ts +89 -0
- package/src/ask/state.ts +160 -0
- package/src/ask/tool.ts +126 -0
- package/src/ask/types.ts +55 -0
- package/src/ask/view.ts +159 -0
- package/src/execution/agent-runner.ts +103 -11
- package/src/execution/git.ts +111 -14
- package/src/execution/pi-runner.ts +164 -11
- package/src/index.ts +9 -0
- package/src/lobby/ask.ts +10 -120
- package/src/lobby/feed.ts +76 -8
- package/src/lobby/layout.ts +38 -18
- package/src/lobby/markdown.ts +92 -19
- package/src/lobby/planner.ts +1 -1
- package/src/lobby/quickfix.ts +21 -0
- package/src/lobby/runtime.ts +51 -37
- package/src/lobby/session-files.ts +162 -25
- package/src/lobby/sessions.ts +21 -7
- package/src/lobby/tabs/home.ts +289 -67
- package/src/lobby/tabs/issues.ts +4 -3
- package/src/lobby/tabs/plan.ts +4 -4
- package/src/lobby/tabs/quickfix.ts +7 -1
- package/src/lobby/tabs/tasks.ts +14 -4
- package/src/lobby/theme.ts +30 -0
- package/src/lobby/view.ts +141 -25
- package/src/master/decisions.ts +1 -1
- package/src/master/master.ts +41 -4
- package/src/master/research.ts +5 -2
- package/src/pi/commands.ts +94 -10
- package/src/pi/events.ts +54 -12
- package/src/pi/fresh-context.ts +134 -0
- package/src/pi/owner.ts +19 -10
- package/src/pi/quiet.ts +22 -4
- package/src/pi/start-task.ts +11 -2
- package/src/pi/tools.ts +26 -8
- package/src/pi/ui.ts +9 -3
- package/src/pi/zen-large.ts +10 -10
- package/src/pi/zen-metrics.ts +13 -3
- package/src/pi/zen.ts +22 -15
- package/src/roles/reviewer.ts +23 -4
- package/src/roles/worker.ts +18 -0
- package/src/schemas/configuration.ts +8 -0
- package/src/schemas/findings.ts +14 -0
- package/src/schemas/task.ts +21 -0
- package/src/state/archive.ts +12 -3
- package/src/state/backlog.ts +13 -3
- package/src/state/budget.ts +274 -0
- package/src/state/changes.ts +231 -0
- package/src/state/file-cache.ts +62 -0
- package/src/state/metrics.ts +63 -13
- package/src/state/persistence.ts +36 -2
- package/src/text.ts +28 -2
- package/src/web/extract.ts +332 -0
- package/src/web/fetch.ts +232 -0
- package/src/web/html.ts +183 -0
- package/src/web/read.ts +113 -0
- package/src/web/search.ts +202 -0
- package/src/web/tools.ts +279 -0
- package/src/width.ts +102 -0
- package/src/workflow/workflow.ts +592 -42
package/src/pi/commands.ts
CHANGED
|
@@ -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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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 =
|
|
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.
|
|
4
|
-
* tools are removed from the master's active set so the
|
|
5
|
-
* running in its own process, stays the only web
|
|
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
|
-
/**
|
|
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";
|
package/src/pi/start-task.ts
CHANGED
|
@@ -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
|
|