@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
@@ -0,0 +1,126 @@
1
+ /**
2
+ * `ask_user_question`: the model asks instead of guessing, with typed options
3
+ * the user picks from (or answers in their own words). Registered by
4
+ * bot-lobby in every pi session it loads in, the lobby's or not, so pi needs
5
+ * no separate questionnaire extension. A subagent has no terminal: it gets
6
+ * the tool only when the master lets it ask (the designer), and its questions
7
+ * are relayed through the master (see relay.ts).
8
+ */
9
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
10
+ import { Container, Text } from "@earendil-works/pi-tui";
11
+ import { Type } from "typebox";
12
+ import { isSubagentProcess } from "../pi/quiet.ts";
13
+ import { askUser, type Asker } from "./dialog.ts";
14
+ import { isImagePath } from "./image.ts";
15
+ import { relayAsker, relayEnabled } from "./relay.ts";
16
+ import { ASK_TOOL, MAX_HEADER, MAX_LABEL, MAX_OPTIONS, MAX_QUESTIONS, MIN_OPTIONS, RESERVED, type AskQuestion, type AskResult } from "./types.ts";
17
+
18
+ export { ASK_TOOL };
19
+
20
+ const OptionSchema = Type.Object({
21
+ label: Type.String({ maxLength: MAX_LABEL, description: `The option as the user sees and picks it: 1-5 words, at most ${MAX_LABEL} characters.` }),
22
+ description: Type.Optional(Type.String({ description: "What choosing it means: its trade-offs or consequences. Markdown." })),
23
+ preview: Type.Optional(Type.String({ description: "Markdown shown beside the options while this one is focused: a mockup, a code snippet, a diagram, a config. Only when seeing it helps the user compare." })),
24
+ image: Type.Optional(Type.String({ description: "Path to a PNG (or JPEG, GIF, WebP) shown with the preview: a screenshot or a rendered mockup of this option. Drawn as the image in terminals that can, as coloured blocks (PNG) elsewhere." })),
25
+ });
26
+
27
+ const QuestionSchema = Type.Object({
28
+ question: Type.String({ description: "The whole question, clear and specific, ending with a question mark. Markdown." }),
29
+ header: Type.String({ maxLength: MAX_HEADER, description: `A short chip naming the question (at most ${MAX_HEADER} characters), e.g. "Auth" or "Layout".` }),
30
+ options: Type.Array(OptionSchema, { minItems: MIN_OPTIONS, maxItems: MAX_OPTIONS, description: `${MIN_OPTIONS}-${MAX_OPTIONS} distinct options. Put the one you recommend first and end its label with "(Recommended)". The user can always answer in their own words instead.` }),
31
+ multiSelect: Type.Optional(Type.Boolean({ description: "True when several answers can apply together." })),
32
+ });
33
+
34
+ export const AskParams = Type.Object({
35
+ questions: Type.Array(QuestionSchema, { minItems: 1, maxItems: MAX_QUESTIONS, description: `1-${MAX_QUESTIONS} questions asked together.` }),
36
+ });
37
+
38
+ const DESCRIPTION = [
39
+ "Ask the user one to four questions with options to pick from, when the answer would change what you do and you would otherwise guess.",
40
+ "Each question has 2-4 options (the one you recommend first, its label ending in \"(Recommended)\"); the user can pick one (or several with multiSelect), or answer in their own words.",
41
+ "Questions, descriptions and previews are Markdown. Give options a `preview` when the user needs to see them to choose: a UI mockup, a layout sketch, a code snippet, a config; the focused option's preview shows beside the list. An option can also carry an `image` file (a screenshot, a rendered mockup).",
42
+ "Do not use it for yes/no confirmations of what you were already told to do, or for questions the conversation already answers.",
43
+ ].join(" ");
44
+
45
+ const RELAYED = "Your questions reach the user through the oracle, and your clock stops while they answer. Ask only what is theirs to decide (a visual direction, a layout, a trade-off they care about), all at once; decide the rest yourself.";
46
+
47
+ /** Why a set of questions cannot be asked as given, or undefined when it can. */
48
+ export function invalidQuestions(questions: readonly AskQuestion[]): string | undefined {
49
+ const asked = new Set<string>();
50
+ for (const [index, question] of questions.entries()) {
51
+ const at = `question ${index + 1}`;
52
+ if (!question.question.trim()) return `${at} is empty`;
53
+ if (asked.has(question.question.trim().toLowerCase())) return `${at} repeats an earlier question`;
54
+ asked.add(question.question.trim().toLowerCase());
55
+ const labels = new Set<string>();
56
+ for (const option of question.options) {
57
+ const label = option.label.trim().toLowerCase();
58
+ if (!label) return `${at} has an option without a label`;
59
+ if (RESERVED.has(label)) return `${at}: "${option.label}" is kept for the user's own answer; leave it out`;
60
+ if (labels.has(label)) return `${at} has two options labelled "${option.label}"`;
61
+ if (option.image?.trim() && !isImagePath(option.image)) return `${at}: the image for "${option.label}" must be a .png, .jpg, .gif or .webp file`;
62
+ labels.add(label);
63
+ }
64
+ }
65
+ return undefined;
66
+ }
67
+
68
+ /** What the model reads back: each question with its answer, or that it was skipped. */
69
+ export function answerSummary(questions: readonly AskQuestion[], result: AskResult): string {
70
+ if (result.cancelled && result.answers.length === 0) {
71
+ // A relay that could not ask anyone (auto mode) says why.
72
+ if (result.globalNote) return result.globalNote;
73
+ return "The user put the questions away without answering. Do not ask the same again right away: go on with your best judgement and say what you assumed, or ask something narrower.";
74
+ }
75
+ const lines = questions.map((question, index) => {
76
+ const answer = result.answers.find((entry) => entry.questionIndex === index);
77
+ const head = `${index + 1}. [${question.header}] ${question.question.replace(/\s+/g, " ").trim()}`;
78
+ if (!answer) return `${head}\n → (not answered)`;
79
+ const text = answer.kind === "multi" ? (answer.selected ?? []).join(", ") : answer.answer ?? "";
80
+ return `${head}\n → ${text}${answer.kind === "custom" ? " (in the user's own words)" : ""}${answer.notes ? `\n note: ${answer.notes}` : ""}`;
81
+ });
82
+ return [
83
+ result.cancelled ? "The user answered some questions, then put the rest away:" : "The user answered:",
84
+ ...lines,
85
+ ...(result.globalNote ? ["", `Note: ${result.globalNote}`] : []),
86
+ ].join("\n");
87
+ }
88
+
89
+ /**
90
+ * Register `ask_user_question` in this pi session; in a subagent only when
91
+ * the master relays its questions. `ask` is swappable for tests.
92
+ */
93
+ export function registerAskTool(pi: ExtensionAPI, ask?: Asker): void {
94
+ const subagent = isSubagentProcess();
95
+ if (subagent && !relayEnabled()) return;
96
+ const asker = ask ?? (subagent ? relayAsker : askUser);
97
+ pi.registerTool({
98
+ name: ASK_TOOL,
99
+ label: "Ask",
100
+ description: subagent ? `${DESCRIPTION} ${RELAYED}` : DESCRIPTION,
101
+ promptSnippet: "Ask the user structured questions with options (and previews) instead of guessing",
102
+ promptGuidelines: [
103
+ "Use ask_user_question when a real decision is the user's and the answer changes your work; batch related questions (at most four) into one call.",
104
+ ],
105
+ parameters: AskParams,
106
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
107
+ const questions = (params as { questions: AskQuestion[] }).questions;
108
+ const invalid = invalidQuestions(questions);
109
+ if (invalid) throw new Error(`${invalid}.`);
110
+ const result = await asker(questions, ctx, signal);
111
+ return { content: [{ type: "text", text: answerSummary(questions, result) }], details: result };
112
+ },
113
+ renderCall(args, theme) {
114
+ const questions = (args as { questions?: AskQuestion[] }).questions ?? [];
115
+ const heads = questions.map((question) => question.header).filter(Boolean).join(", ");
116
+ return new Text(`${theme.fg("toolTitle", theme.bold("ask"))} ${theme.fg("accent", `${questions.length} question${questions.length === 1 ? "" : "s"}`)}${heads ? ` ${theme.fg("muted", heads)}` : ""}`, 0, 0);
117
+ },
118
+ renderResult(result, _options, theme) {
119
+ const details = result.details as AskResult | undefined;
120
+ if (!details) return new Container();
121
+ if (details.cancelled && details.answers.length === 0) return new Text(theme.fg("warning", "put away without answering"), 0, 0);
122
+ const lines = details.answers.map((answer) => `${theme.fg("success", "✓")} ${theme.fg("muted", answer.question.replace(/\s+/g, " ").slice(0, 60))} ${theme.fg("dim", "→")} ${theme.fg("accent", answer.kind === "multi" ? (answer.selected ?? []).join(", ") : answer.answer ?? "")}`);
123
+ return new Text(lines.join("\n"), 0, 0);
124
+ },
125
+ });
126
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * The questionnaire's shape: what a model (or the planning panel) asks, and
3
+ * what comes back. The same shape `ask_user_question` has had in pi, so a
4
+ * model's habits carry over: 1-4 questions, 2-4 options each, a short header,
5
+ * multi-select, and a per-option preview (Markdown: a mockup, a snippet, a
6
+ * diagram) shown beside the options.
7
+ */
8
+
9
+ /** The questionnaire tool's name, in this session and in the agents that may ask. */
10
+ export const ASK_TOOL = "ask_user_question";
11
+
12
+ export const MAX_QUESTIONS = 4;
13
+ export const MIN_OPTIONS = 2;
14
+ export const MAX_OPTIONS = 4;
15
+ export const MAX_HEADER = 16;
16
+ export const MAX_LABEL = 60;
17
+
18
+ /** The row under every question's options where the user writes their own answer. */
19
+ export const OWN_ANSWER = "Type something.";
20
+
21
+ /** Labels the questionnaire keeps for its own rows. */
22
+ export const RESERVED = new Set(["other", OWN_ANSWER.toLowerCase(), "next"]);
23
+
24
+ export interface AskOption {
25
+ label: string;
26
+ description?: string;
27
+ /** Markdown shown beside the options while this one is focused. */
28
+ preview?: string;
29
+ /** An image file (PNG, JPEG, GIF or WebP) shown with the preview: a screenshot, a rendered mockup. */
30
+ image?: string;
31
+ }
32
+
33
+ export interface AskQuestion {
34
+ question: string;
35
+ /** A short chip naming the question (a topic, or who asks it). */
36
+ header: string;
37
+ options: AskOption[];
38
+ multiSelect?: boolean;
39
+ }
40
+
41
+ export interface AskAnswer {
42
+ questionIndex: number;
43
+ question: string;
44
+ /** A picked option, the user's own words, or several picked options. */
45
+ kind: "option" | "custom" | "multi";
46
+ answer: string | null;
47
+ selected?: string[];
48
+ notes?: string;
49
+ }
50
+
51
+ export interface AskResult {
52
+ answers: AskAnswer[];
53
+ cancelled: boolean;
54
+ globalNote?: string;
55
+ }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * The questionnaire drawn: a chip per question, the question in Markdown, its
3
+ * options (each with its description in Markdown), the row for your own
4
+ * answer, and — when options carry one — the focused option's preview in a
5
+ * box beside them (or under them in a narrow terminal). Pure: state in, lines
6
+ * out, exactly `width` columns and at most `height` rows.
7
+ */
8
+ import { basename } from "node:path";
9
+ import { textWidth } from "../width.ts";
10
+ import { beside, bold, box, fill, fit, markdownLines, paint, wrap, type LobbyTheme } from "../lobby/layout.ts";
11
+ import { imageLines, type ImagePreview } from "./image.ts";
12
+ import { isAnswered, type AskState } from "./state.ts";
13
+ import { OWN_ANSWER, type AskQuestion } from "./types.ts";
14
+
15
+ /** Terminals at least this wide show the preview beside the options. */
16
+ export const SIDE_BY_SIDE_MIN = 96;
17
+ /** Rows the preview box keeps at least, and at most. */
18
+ const PREVIEW_MIN = 6;
19
+ const PREVIEW_MAX = 40;
20
+ /** A preview box needs this many inner rows to draw an image in; smaller ones name the file. */
21
+ const IMAGE_MIN_ROWS = 6;
22
+ /** Option descriptions sit under their label, past the marker and number. */
23
+ const DESCRIPTION_INDENT = 7;
24
+
25
+ const RECOMMENDED = /\s*\(recommended\)\s*$/i;
26
+
27
+ function chips(state: AskState, theme?: LobbyTheme): string {
28
+ if (state.questions.length === 1) return paint(theme, "muted", state.questions[0]!.header);
29
+ return state.questions
30
+ .map((question, index) => {
31
+ const label = ` ${index + 1} ${question.header}${isAnswered(state, index) ? " ✓" : ""} `;
32
+ if (index === state.tab) return bold(theme, paint(theme, "accent", `[${label.trim()}]`));
33
+ return paint(theme, isAnswered(state, index) ? "success" : "muted", label.trim());
34
+ })
35
+ .join(paint(theme, "dim", " · "));
36
+ }
37
+
38
+ function optionLabel(label: string, focused: boolean, theme?: LobbyTheme): string {
39
+ const recommended = RECOMMENDED.test(label);
40
+ const plain = label.replace(RECOMMENDED, "");
41
+ const text = focused ? bold(theme, plain) : plain;
42
+ return recommended ? `${text} ${paint(theme, "success", "(Recommended)")}` : text;
43
+ }
44
+
45
+ /** The options and the own-answer row, each option with its description beneath. */
46
+ function optionLines(state: AskState, question: AskQuestion, width: number, theme?: LobbyTheme): string[] {
47
+ const cursor = state.cursor[state.tab] ?? 0;
48
+ const picked = state.picked[state.tab] ?? [];
49
+ const lines: string[] = [];
50
+ question.options.forEach((option, index) => {
51
+ const focused = cursor === index && !state.editing;
52
+ const pointer = focused ? paint(theme, "accent", "›") : " ";
53
+ const mark = question.multiSelect ? (picked.includes(index) ? "☑" : "☐") : picked.includes(index) ? "●" : "○";
54
+ const lead = `${pointer} ${paint(theme, picked.includes(index) ? "accent" : "dim", mark)} ${paint(theme, "dim", `${index + 1}.`)} `;
55
+ const labelLines = wrap(optionLabel(option.label, focused, theme), Math.max(1, width - textWidth(lead)));
56
+ lines.push(`${lead}${labelLines[0] ?? ""}`, ...labelLines.slice(1).map((line) => `${" ".repeat(textWidth(lead))}${line}`));
57
+ if (option.description?.trim()) {
58
+ const indent = " ".repeat(DESCRIPTION_INDENT);
59
+ for (const line of markdownLines(option.description.trim(), Math.max(1, width - DESCRIPTION_INDENT), theme)) lines.push(line ? `${indent}${paint(theme, "muted", line)}` : "");
60
+ }
61
+ });
62
+ const ownFocused = cursor === question.options.length;
63
+ const pointer = ownFocused || state.editing ? paint(theme, "accent", "›") : " ";
64
+ const own = state.typed[state.tab];
65
+ const lead = `${pointer} ${paint(theme, own ? "accent" : "dim", "✎")} `;
66
+ const room = Math.max(1, width - textWidth(lead));
67
+ if (state.editing) {
68
+ const draft = wrap(`${state.draft}▏`, room);
69
+ lines.push(`${lead}${paint(theme, "accent", draft[0] ?? "")}`, ...draft.slice(1).map((line) => `${" ".repeat(textWidth(lead))}${paint(theme, "accent", line)}`));
70
+ } else {
71
+ lines.push(`${lead}${own ? paint(theme, "accent", `“${own}”`) : paint(theme, ownFocused ? "text" : "dim", OWN_ANSWER)}`);
72
+ }
73
+ return lines;
74
+ }
75
+
76
+ interface Preview {
77
+ label: string;
78
+ text: string;
79
+ image?: string;
80
+ }
81
+
82
+ /** The preview to show: the focused option's, when any option in the question has one (Markdown or an image). */
83
+ function previewOf(state: AskState, question: AskQuestion): Preview | undefined {
84
+ if (!question.options.some((option) => option.preview?.trim() || option.image?.trim())) return undefined;
85
+ const cursor = state.cursor[state.tab] ?? 0;
86
+ const option = question.options[cursor];
87
+ if (!option) return { label: OWN_ANSWER, text: "" };
88
+ return { label: option.label.replace(RECOMMENDED, ""), text: option.preview?.trim() ?? "", ...(option.image?.trim() ? { image: option.image.trim() } : {}) };
89
+ }
90
+
91
+ /** The image part of a preview: drawn when the box has room, else named. It never takes the box's last rows from the text entirely. */
92
+ function imageBlock(preview: Preview, inner: number, innerRows: number, textRows: number, frame: AskFrame, theme?: LobbyTheme): string[] {
93
+ if (!preview.image) return [];
94
+ const loaded = frame.images?.get(preview.image);
95
+ const textRoom = textRows > 0 ? Math.min(textRows + 1, Math.max(2, Math.floor(innerRows / 3))) : 0;
96
+ const rows = innerRows - textRoom;
97
+ if (!loaded) return [paint(theme, "muted", `Image: ${preview.image}`)];
98
+ if (rows < IMAGE_MIN_ROWS) return [paint(theme, "muted", `Image: ${basename(preview.image)} (the terminal is too small to draw it)`)];
99
+ return imageLines(loaded, inner, rows, theme);
100
+ }
101
+
102
+ /** The preview in a box at most `rows` tall; an image keeps within `fits` rows, which the terminal surely shows. */
103
+ function previewBox(preview: Preview, width: number, rows: number, theme: LobbyTheme | undefined, frame: AskFrame, fits = rows): string[] {
104
+ const inner = Math.max(1, width - 4);
105
+ const text = preview.text ? markdownLines(preview.text, inner, theme) : [];
106
+ const image = imageBlock(preview, inner, Math.min(rows, fits) - 2, text.length, frame, theme);
107
+ const joined = [...image, ...(image.length > 0 && text.length > 0 ? [""] : []), ...text];
108
+ const body = joined.length > 0 ? joined : [paint(theme, "dim", "No preview for this one.")];
109
+ const height = Math.max(3, Math.min(rows, body.length + 2));
110
+ const more = body.length > height - 2 ? `${body.length - (height - 2)} more lines` : "";
111
+ return box(width, height, body, { title: `Preview · ${preview.label}`, ...(more ? { right: more } : {}), theme });
112
+ }
113
+
114
+ function hints(state: AskState, question: AskQuestion, width: number, theme?: LobbyTheme): string[] {
115
+ const parts = state.editing
116
+ ? ["enter keep it", "esc back to the options"]
117
+ : [
118
+ "↑↓ move",
119
+ question.multiSelect ? "space pick · enter next" : "enter choose",
120
+ `1-${question.options.length} pick`,
121
+ ...(state.questions.length > 1 ? ["←→ questions"] : []),
122
+ "esc put away",
123
+ ];
124
+ return wrap(paint(theme, "dim", parts.join(" · ")), width);
125
+ }
126
+
127
+ /** Around the questions: who is asking (an agent the oracle relays for), and the option images, read beforehand. */
128
+ export interface AskFrame {
129
+ from?: string;
130
+ images?: ReadonlyMap<string, ImagePreview>;
131
+ }
132
+
133
+ /** The whole questionnaire in a box, `width` wide and at most `height` rows. */
134
+ export function renderAsk(state: AskState, width: number, height: number, theme?: LobbyTheme, frame: AskFrame = {}): string[] {
135
+ const question = state.questions[state.tab];
136
+ if (!question || width < 20 || height < 6) return [];
137
+ const inner = width - 4;
138
+ const head = [chips(state, theme), "", ...markdownLines(question.question, inner, theme), ""];
139
+ const foot = ["", ...hints(state, question, inner, theme)];
140
+ const preview = previewOf(state, question);
141
+ let body: string[];
142
+ if (preview && inner >= SIDE_BY_SIDE_MIN) {
143
+ const left = Math.floor(inner * 0.42);
144
+ const options = optionLines(state, question, left, theme);
145
+ const rows = Math.max(PREVIEW_MIN, Math.min(PREVIEW_MAX, height - 2 - head.length - foot.length));
146
+ const side = previewBox(preview, inner - left - 2, Math.max(rows, Math.min(options.length, PREVIEW_MAX)), theme, frame, rows);
147
+ body = beside([fill(options, Math.max(options.length, side.length), left), side], " ");
148
+ } else {
149
+ const options = optionLines(state, question, inner, theme);
150
+ const room = height - 2 - head.length - foot.length - options.length - 1;
151
+ body = preview && room >= 3 ? [...options, "", ...previewBox(preview, inner, Math.min(PREVIEW_MAX, room), theme, frame)] : options;
152
+ }
153
+ const content = [...head, ...body, ...foot];
154
+ // Too tall for the terminal: the question and hints stay, the middle is cut.
155
+ const fitted = content.length > height - 2 ? [...content.slice(0, height - 2 - foot.length), ...foot] : content;
156
+ const count = state.questions.length > 1 ? `Question ${state.tab + 1} of ${state.questions.length}` : "Question";
157
+ const title = frame.from ? `${frame.from} asks · ${count}` : count;
158
+ return box(width, fitted.length + 2, fitted.map((line) => fit(line, inner)), { title, focused: true, theme });
159
+ }
@@ -4,7 +4,11 @@ import { roleSpec } from "../roles/registry.ts";
4
4
  import { compilePrompt } from "../prompts/compiler.ts";
5
5
  import { activityDetail, activityWord, describeToolCall } from "../pi/activity.ts";
6
6
  import { shortDuration, truncate } from "../text.ts";
7
- import { runPiAgent, spawnPiProcess, type PiStreamEvent, type ProcessRunner } from "./pi-runner.ts";
7
+ import { EditLog } from "../state/changes.ts";
8
+ import { formatMinutes, REPORT_GRACE_MS } from "../state/budget.ts";
9
+ import { runPiAgent, spawnPiProcess, type PiStreamEvent, type ProcessRunner, type RelayAsk } from "./pi-runner.ts";
10
+ import { ASK_ENV } from "../ask/relay.ts";
11
+ import { ASK_TOOL } from "../ask/types.ts";
8
12
 
9
13
  export interface AgentContext {
10
14
  task: string;
@@ -58,8 +62,49 @@ export interface AgentRequest {
58
62
  /** The classifier routed this run down from this configured profile (receipts and metrics show it). */
59
63
  routedFrom?: string;
60
64
  route?: string;
65
+ /** Under a task time budget: the run's allotted time, which replaces `timeoutMs`. */
66
+ time?: AgentTime;
67
+ /** Lets the agent ask the user (ask_user_question, relayed through the master); its clocks wait for the answers. */
68
+ onAsk?: RelayAsk;
61
69
  }
62
70
 
71
+ /**
72
+ * A run under a task time budget. The agent is told its minutes up front,
73
+ * warned at 75%, and asked to stop and report when they run out. A worker
74
+ * then reports where it left off and how much more it needs; `onTimeUp`
75
+ * (the engine asking the user) grants more, and the same agent carries on.
76
+ */
77
+ export interface AgentTime {
78
+ /** When the delegation's time runs out (epoch ms); moved out when more is granted, shared by retries and re-runs. */
79
+ endsAt: number;
80
+ /** The allotment, as the agent and the lobby see it. */
81
+ allotMs: number;
82
+ /** What the agent is told about its time, in its context. */
83
+ note?: string;
84
+ /** Workers only: decide on more time once it has reported where it left off; resolve with ms, 0 to stop it. */
85
+ onTimeUp?: (run: AgentRun, report: string) => Promise<number>;
86
+ }
87
+
88
+ /** The warning at 75% of an allotment. */
89
+ export function headsUpMessage(leftMs: number, allotMs: number): string {
90
+ return `bot-lobby: about ${formatMinutes(leftMs)} of your ${formatMinutes(allotMs)} for this step are left. Finish what you have started and make sure it works; leave nice-to-haves.`;
91
+ }
92
+
93
+ /** The steer when an allotment is spent: a worker says where it left off and what more it needs; any other role reports what it has. */
94
+ export function timeUpMessage(allotMs: number, canAskForMore: boolean): string {
95
+ return canAskForMore
96
+ ? `bot-lobby: your ${formatMinutes(allotMs)} for this step are up. Stop here: finish or revert the edit in progress so every file is consistent, start nothing new, and write your report now in the required format, adding \`## Left Off\` (what you were doing and what is still to do) and \`## More Time\` (how many more minutes you need, and why). The user decides whether you get more time to finish.`
97
+ : `bot-lobby: your ${formatMinutes(allotMs)} are up. Stop now and write your report in the required format with what you have.`;
98
+ }
99
+
100
+ /** The follow-up that sends an agent on once the user gave it more time. */
101
+ export function carryOnMessage(extraMs: number): string {
102
+ return `bot-lobby: the user gave you ${formatMinutes(extraMs)} more. Carry on from where you left off and finish the step, then write your final report in the required format (leave out Left Off and More Time if you finish).`;
103
+ }
104
+
105
+ /** Least time an attempt starts with; a retry with less than this left is not started. */
106
+ const MIN_ATTEMPT_MS = 60_000;
107
+
63
108
  /** The watchdog fields of an AgentRequest, taken from the workflow config. */
64
109
  export function watchdogOptions(workflow: {
65
110
  maxAgentRetries: number;
@@ -91,13 +136,18 @@ function retryable(run: AgentRun): boolean {
91
136
  export async function runAgent(request: AgentRequest, run: ProcessRunner = spawnPiProcess): Promise<AgentRun> {
92
137
  const startedAt = new Date().toISOString();
93
138
  const attempts = Math.max(1, (request.retries ?? 0) + 1);
139
+ // A failed attempt may have edited files before the retry: the run owns every edit.
140
+ const edits = new EditLog(request.cwd);
94
141
  let last: AgentRun | undefined;
95
142
  for (let attempt = 1; attempt <= attempts; attempt++) {
96
- last = await runAgentOnce(request, run, attempt, startedAt);
143
+ last = await runAgentOnce(request, run, attempt, startedAt, edits);
97
144
  request.onAttemptEnd?.(last);
98
145
  if (!retryable(last)) break;
146
+ // Under a budget a retry only uses what is left of the allotment.
147
+ if (request.time && request.time.endsAt - Date.now() < MIN_ATTEMPT_MS) break;
99
148
  }
100
- return last!;
149
+ const edited = edits.list();
150
+ return edited.length > 0 ? { ...last!, edited } : last!;
101
151
  }
102
152
 
103
153
  /** Abort every in-flight subagent (session shutdown, user cancel). */
@@ -120,31 +170,36 @@ function baseRun(request: AgentRequest, runId: string, startedAt: string, attemp
120
170
  ...(request.thinking ? { thinking: request.thinking } : {}),
121
171
  ...(request.routedFrom ? { routedFrom: request.routedFrom } : {}),
122
172
  ...(request.route ? { route: request.route } : {}),
173
+ ...(request.time ? { allotMs: request.time.allotMs, endsAt: request.time.endsAt } : {}),
123
174
  ...(attempts > 1 ? { note: `retry ${attempts - 1} of ${(request.retries ?? 0)}`, noteKind: "warning" as const } : {}),
124
175
  };
125
176
  }
126
177
 
127
178
  function toolsFor(request: AgentRequest): readonly string[] | undefined {
128
179
  const tools = roleSpec(request.role).tools;
129
- if (!request.extraTools?.length) return tools;
130
- return [...(tools ?? []), ...request.extraTools];
180
+ const extra = [...(request.extraTools ?? []), ...(request.onAsk ? [ASK_TOOL] : [])];
181
+ if (extra.length === 0) return tools;
182
+ return [...(tools ?? []), ...extra];
131
183
  }
132
184
 
133
185
  /**
134
186
  * Run one domain/role agent in an isolated pi process. The role's tool
135
187
  * allowlist comes from its spec, so read-only roles cannot modify anything.
136
188
  */
137
- async function runAgentOnce(request: AgentRequest, run: ProcessRunner, attempt: number, startedAt: string): Promise<AgentRun> {
189
+ async function runAgentOnce(request: AgentRequest, run: ProcessRunner, attempt: number, startedAt: string, edits: EditLog): Promise<AgentRun> {
138
190
  const base = baseRun(request, `${request.taskId}:${request.domain}:${request.role}:${Date.now().toString(36)}`, startedAt, attempt);
139
191
  request.onUpdate?.(base);
140
- const live = createLiveRun(base, request);
192
+ const live = createLiveRun(base, request, edits);
141
193
 
142
194
  const controller = new AbortController();
143
195
  activeControllers.add(controller);
144
196
  const signal = request.signal ? AbortSignal.any([request.signal, controller.signal]) : controller.signal;
145
197
  try {
146
198
  const systemPrompt = compilePrompt({ domain: request.domain, role: request.role, ...request.context });
147
- const wrapUp = request.wrapUpAt && request.wrapUpAt > 0 && request.wrapUpAt < 1 ? Math.floor(request.timeoutMs * request.wrapUpAt) : 0;
199
+ const time = request.time;
200
+ const left = time ? Math.max(MIN_ATTEMPT_MS, time.endsAt - Date.now()) : 0;
201
+ const wrapUp = time ? 0 : request.wrapUpAt && request.wrapUpAt > 0 && request.wrapUpAt < 1 ? Math.floor(request.timeoutMs * request.wrapUpAt) : 0;
202
+ const onTimeUp = time?.onTimeUp;
148
203
  const result = await runPiAgent({
149
204
  cwd: request.cwd,
150
205
  task: request.instruction,
@@ -152,9 +207,29 @@ async function runAgentOnce(request: AgentRequest, run: ProcessRunner, attempt:
152
207
  tools: toolsFor(request),
153
208
  model: request.model,
154
209
  thinking: request.thinking,
155
- timeoutMs: request.timeoutMs,
210
+ timeoutMs: time ? left + REPORT_GRACE_MS : request.timeoutMs,
211
+ ...(time ? {
212
+ time: {
213
+ upAtMs: left,
214
+ headsUpAtMs: left >= 4 * 60_000 ? Math.floor(left * 0.75) : 0,
215
+ headsUpMessage: headsUpMessage(left - Math.floor(left * 0.75), time.allotMs),
216
+ upMessage: timeUpMessage(time.allotMs, Boolean(onTimeUp)),
217
+ graceMs: REPORT_GRACE_MS,
218
+ ...(onTimeUp ? {
219
+ onTimeUp: async (report: string) => {
220
+ const extraMs = await onTimeUp(live.current(), report);
221
+ if (extraMs > 0) {
222
+ time.endsAt = Date.now() + extraMs;
223
+ live.annotate({ note: `given ${formatMinutes(extraMs)} more`, noteKind: "info" });
224
+ }
225
+ return { extraMs, message: carryOnMessage(extraMs) };
226
+ },
227
+ } : {}),
228
+ },
229
+ } : {}),
156
230
  signal,
157
- env: request.env,
231
+ ...(request.onAsk ? { onAsk: request.onAsk } : {}),
232
+ env: request.onAsk ? { ...request.env, [ASK_ENV]: "1" } : request.env,
158
233
  stallTimeoutMs: request.stallTimeoutMs,
159
234
  toolStallTimeoutMs: request.toolStallTimeoutMs,
160
235
  wrapUpAtMs: wrapUp,
@@ -174,6 +249,8 @@ async function runAgentOnce(request: AgentRequest, run: ProcessRunner, attempt:
174
249
  model: result.model ?? current.model,
175
250
  ...(result.stalled ? { stalled: true } : {}),
176
251
  ...(result.wrappedUp ? { wrappedUp: true } : {}),
252
+ ...(result.timeUp ? { timeUp: true } : {}),
253
+ ...(result.extendedMs ? { extendedMs: result.extendedMs } : {}),
177
254
  note: undefined,
178
255
  noteKind: undefined,
179
256
  };
@@ -198,7 +275,7 @@ const THROTTLE_MS = 2000;
198
275
  * `onUpdate`: activity changes, retries and notes immediately, counters and
199
276
  * heartbeats at most every couple of seconds.
200
277
  */
201
- function createLiveRun(base: AgentRun, request: AgentRequest) {
278
+ function createLiveRun(base: AgentRun, request: AgentRequest, edits: EditLog) {
202
279
  let state: AgentRun = base;
203
280
  let lastEmit = 0;
204
281
  const emit = (patch: Partial<AgentRun>, force: boolean) => {
@@ -212,6 +289,7 @@ function createLiveRun(base: AgentRun, request: AgentRequest) {
212
289
  const onEvent = (event: PiStreamEvent) => {
213
290
  switch (event.type) {
214
291
  case "tool_execution_start": {
292
+ edits.note(event.toolName, event.args);
215
293
  const activity = activityWord(event.toolName);
216
294
  const detail = activityDetail(event.toolName, event.args);
217
295
  const step = describeToolCall(event.toolName, event.args);
@@ -240,6 +318,20 @@ function createLiveRun(base: AgentRun, request: AgentRequest) {
240
318
  case "wrap_up":
241
319
  emit({ note: `asked to wrap up (${shortDuration(request.timeoutMs)} limit)`, noteKind: "warning", wrappedUp: true }, true);
242
320
  return;
321
+ case "time_up":
322
+ emit({ note: request.time?.onTimeUp ? "out of time: reporting where it left off" : "out of time: reporting", noteKind: "warning" }, true);
323
+ return;
324
+ case "extended":
325
+ emit({ endsAt: request.time?.endsAt, extendedMs: (state.extendedMs ?? 0) + event.ms }, true);
326
+ return;
327
+ case "asking":
328
+ emit({ note: `waiting on you: ${event.questions} question${event.questions === 1 ? "" : "s"}`, noteKind: "info" }, true);
329
+ return;
330
+ case "answered":
331
+ // Its time waited with it.
332
+ if (request.time) request.time.endsAt += event.waitedMs;
333
+ emit({ note: "you answered; carrying on", noteKind: "info", ...(request.time ? { endsAt: request.time.endsAt } : {}) }, true);
334
+ return;
243
335
  case "usage": {
244
336
  const usage = state.usage ?? { input: 0, output: 0, cost: 0, turns: 0 };
245
337
  const next = { input: usage.input + event.input, output: usage.output + event.output, cost: usage.cost + event.cost, turns: usage.turns + 1 };