@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
package/src/lobby/view.ts CHANGED
@@ -9,7 +9,8 @@
9
9
  * pane toggles, paging) work in both. Everything bot-lobby-specific arrives
10
10
  * through `LobbyHost`, so the view renders and reacts the same in tests.
11
11
  */
12
- import { decodeKittyPrintable, Editor, Input, Key, matchesKey, visibleWidth, type Component, type EditorTheme, type Focusable, type TUI, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui";
12
+ import { decodeKittyPrintable, Editor, Input, Key, matchesKey, type Component, type EditorTheme, type Focusable, type TUI, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui";
13
+ import { textWidth } from "../width.ts";
13
14
  import { TERMINAL_STATES, type Task } from "../schemas/task.ts";
14
15
  import type { AgentRun } from "../schemas/findings.ts";
15
16
  import type { PlannedTask } from "../state/backlog.ts";
@@ -22,8 +23,8 @@ import type { QuickFixQueue } from "./quickfix.ts";
22
23
  import { MEMBER_LABELS, ORACLE_LABEL, type PlannerSeed, type PlanningSession } from "./planner.ts";
23
24
  import { issueText, type IssuesState } from "./issues.ts";
24
25
  import { actionFor, keyLabel, keyMap, LOBBY_ACTIONS, type KeyMap, type LobbyAction } from "./keys.ts";
25
- import { beside, bold, box, fit, highlight, paint, rule, selectRow, spinner, spread, windowStart, wrap, wrapHanging, type LobbyTheme, type PaneLayout } from "./layout.ts";
26
- import { chatLines, HOME_PANES, renderHome, type HomePane } from "./tabs/home.ts";
26
+ import { beside, bold, box, fit, highlight, paint, rule, selectRow, spinner, spread, windowStart, wrap, wrapHanging, type LobbyTheme, type PaneBox, type PaneLayout, type PaneMark } from "./layout.ts";
27
+ import { chatTail, HOME_PANES, renderHome, type HomePane } from "./tabs/home.ts";
27
28
  import { filterRows, pips, planDetailLines, renderTasks, taskDetailLines, taskProgress, taskRows, tasksWidths, type TaskRow } from "./tabs/tasks.ts";
28
29
  import { renderPlan, type PlanLayout, type PlanView, type SeatView } from "./tabs/plan.ts";
29
30
  import { filterJobs, newestFirst, renderQuickFix } from "./tabs/quickfix.ts";
@@ -113,6 +114,9 @@ function sameView(a: SessionView, b: SessionView): boolean {
113
114
  return true;
114
115
  }
115
116
 
117
+ /** Tops a conversation whose earlier messages are not in memory. */
118
+ export const OLDER_NOTE = "↑ earlier messages load as you scroll up";
119
+
116
120
  const SECTION_OF: Record<SessionWhere, string> = { "this window": "THIS WINDOW", background: "BACKGROUND", "other terminal": "OTHER TERMINALS", "not running": "NOT RUNNING" };
117
121
  const WHERE_MARKS: Record<SessionWhere, string> = { "this window": "●", background: "◆", "other terminal": "◇", "not running": "○" };
118
122
 
@@ -189,8 +193,12 @@ export interface LobbyHost {
189
193
  archiveTask(taskId: string): string;
190
194
  restoreTask(taskId: string): string;
191
195
  deleteTask(taskId: string, where: "list" | "archive"): string;
192
- /** Another session's conversation, read from its saved session file. */
196
+ /** Another session's conversation, read from its saved session file (its newest messages). */
193
197
  sessionChat(sessionId: string): ChatEntry[];
198
+ /** Whether another session has messages earlier than `sessionChat` returns. */
199
+ hasOlderChat(sessionId: string): boolean;
200
+ /** A session's whole conversation, oldest first: this window's (no id) or another's; loaded only while scrolled back to it. */
201
+ chatHistory(sessionId?: string): readonly ChatEntry[];
194
202
  /** A task's status without animations, for a session other than this window's. */
195
203
  taskScene(task: Task, width: number, height: number): string[];
196
204
  profileLabel(kind: LobbyAgentKind): string;
@@ -216,7 +224,7 @@ class LobbyEditor extends Editor {
216
224
  if (!this.label) return super.renderTopBorder(width, hiddenLineCount);
217
225
  const label = ` ${this.label} `;
218
226
  const more = hiddenLineCount > 0 ? ` ↑${hiddenLineCount} ` : "";
219
- const rest = width - 2 - visibleWidth(label) - visibleWidth(more);
227
+ const rest = width - 2 - textWidth(label) - textWidth(more);
220
228
  if (rest < 1) return super.renderTopBorder(width, hiddenLineCount);
221
229
  return `${this.borderColor("──")}${this.paintLabel(label)}${this.borderColor("─".repeat(rest))}${more ? this.borderColor(more) : ""}`;
222
230
  }
@@ -312,6 +320,8 @@ export class LobbyView implements Component, Focusable {
312
320
  homeFocus: HomePane = "conversation";
313
321
  /** How many lines each pane held last frame, so a pane scrolled back stays on what you are reading as lines arrive. */
314
322
  private readonly seenTotals = new Map<string, number>();
323
+ /** Each Lobby pane's newest entry last frame: those panes measure what arrived below it, as their totals are estimates. */
324
+ private readonly marks = new Map<string, PaneMark>();
315
325
  /** Where the current tab's scrollable panes landed in the last frame. */
316
326
  private readonly panes: PaneLayout = new Map();
317
327
  private tasksSelected = 0;
@@ -430,6 +440,7 @@ export class LobbyView implements Component, Focusable {
430
440
  refreshData(force: boolean): void {
431
441
  const now = this.now();
432
442
  if (!force && now - this.data.at < DATA_REFRESH_MS) return;
443
+ this.dataVersion += 1;
433
444
  this.data = {
434
445
  tasks: this.host.tasks(),
435
446
  plans: this.host.plans(),
@@ -838,6 +849,7 @@ export class LobbyView implements Component, Focusable {
838
849
  break;
839
850
  }
840
851
  this.clampScroll();
852
+ if (pane === "conversation") this.followHistory();
841
853
  }
842
854
 
843
855
  /** Home and End: the oldest or first line of the focused pane, or back to its newest or last. */
@@ -861,6 +873,8 @@ export class LobbyView implements Component, Focusable {
861
873
  return;
862
874
  }
863
875
  this.scrollPane(pane, far);
876
+ // Home loads earlier messages at the top; go on to the very first of them.
877
+ if (start && pane === "conversation" && this.history) this.homeOffsets.conversation = Number.MAX_SAFE_INTEGER;
864
878
  }
865
879
 
866
880
  /** The wheel over the draft scrolls it; the cursor follows only when it would leave the screen. */
@@ -882,11 +896,10 @@ export class LobbyView implements Component, Focusable {
882
896
  const limit = (name: string, offset: number, anchored: boolean): number => {
883
897
  const pane = this.panes.get(name);
884
898
  if (!pane) return offset;
885
- const seen = this.seenTotals.get(name);
886
- this.seenTotals.set(name, pane.total);
899
+ const grew = this.grewBelow(name, pane);
887
900
  let next = offset;
888
- if (anchored && next > 0 && seen !== undefined && pane.total > seen) {
889
- next += pane.total - seen;
901
+ if (anchored && next > 0 && grew > 0) {
902
+ next += grew;
890
903
  moved = true;
891
904
  }
892
905
  return Math.min(next, Math.max(0, pane.total - pane.rows));
@@ -910,6 +923,25 @@ export class LobbyView implements Component, Focusable {
910
923
  return moved;
911
924
  }
912
925
 
926
+ /**
927
+ * Lines that arrived below a pane since it was last looked at, counted once.
928
+ * A pane that measures them from its newest entry says so; for the others
929
+ * (their totals exact) the total's growth stands in. An estimated total also
930
+ * moves as scrolling draws more or fewer of the entries it estimates, and
931
+ * following that pinned a pane scrolled to its top there.
932
+ */
933
+ private grewBelow(name: string, pane: PaneBox): number {
934
+ const seen = this.seenTotals.get(name);
935
+ this.seenTotals.set(name, pane.total);
936
+ const follow = pane.follow;
937
+ if (!follow) return seen === undefined ? 0 : pane.total - seen;
938
+ if (follow.mark) this.marks.set(name, follow.mark);
939
+ else this.marks.delete(name);
940
+ const grew = follow.grew;
941
+ follow.grew = 0;
942
+ return grew;
943
+ }
944
+
913
945
  /** Move the draft cursor by `delta` lines, skipping blank ones, and keep it on screen. */
914
946
  private moveCursor(delta: number): void {
915
947
  const lines = this.planLayout.draftText;
@@ -954,7 +986,9 @@ export class LobbyView implements Component, Focusable {
954
986
  const rows = this.taskRowList();
955
987
  this.tasksSelected = Math.max(0, Math.min(rows.length - 1, index));
956
988
  this.tasksDetailOffset = 0;
957
- this.refreshData(true);
989
+ // Only the newly selected task's comments: moving through the list must not reread every task from disk.
990
+ const row = rows[this.tasksSelected];
991
+ if (row?.kind === "task" && !this.data.comments.has(row.id)) this.data.comments.set(row.id, this.host.comments(row.id));
958
992
  }
959
993
 
960
994
  private async loadIssueDetail(): Promise<void> {
@@ -1205,6 +1239,50 @@ export class LobbyView implements Component, Focusable {
1205
1239
  void this.host.answerDialog(session).then(() => this.host.requestRender());
1206
1240
  }
1207
1241
 
1242
+ /* -------------------------------------------------------------- history */
1243
+
1244
+ /**
1245
+ * The whole conversation of the session in view, loaded when the
1246
+ * conversation pane is scrolled back past the messages kept in memory, and
1247
+ * let go once it is back at the newest.
1248
+ */
1249
+ private history?: { view: SessionView; entries: readonly ChatEntry[] };
1250
+
1251
+ /** The conversation to draw for the session in view, and whether earlier messages wait to be loaded. */
1252
+ private conversationFor(entry: SessionEntry): { chat: readonly ChatEntry[]; older: boolean } {
1253
+ if (this.history && sameView(this.history.view, entry.view)) return { chat: this.history.entries, older: false };
1254
+ if (entry.view.kind === "here") return { chat: this.host.feed.chat, older: this.host.feed.chatOlder };
1255
+ if (entry.view.kind === "background") {
1256
+ const session = this.viewedSessionFor(entry.view.key);
1257
+ return { chat: session?.feed.chat ?? [], older: Boolean(session?.feed.chatOlder && session.sessionId) };
1258
+ }
1259
+ return entry.sessionId ? { chat: this.host.sessionChat(entry.sessionId), older: this.host.hasOlderChat(entry.sessionId) } : { chat: [], older: false };
1260
+ }
1261
+
1262
+ /**
1263
+ * After the conversation pane scrolls: at its top, with earlier messages
1264
+ * waiting, load the whole conversation; back at its newest, let it go.
1265
+ * True when history was loaded now.
1266
+ */
1267
+ private followHistory(): boolean {
1268
+ if (this.tab !== "lobby") return false;
1269
+ if (this.homeOffsets.conversation === 0) {
1270
+ this.history = undefined;
1271
+ return false;
1272
+ }
1273
+ if (this.history && sameView(this.history.view, this.viewing)) return false;
1274
+ const pane = this.panes.get("conversation");
1275
+ if (!pane || this.homeOffsets.conversation < pane.total - pane.rows) return false;
1276
+ const entry = this.viewedEntry();
1277
+ if (!this.conversationFor(entry).older) return false;
1278
+ const sessionId = entry.view.kind === "here" ? undefined : entry.view.kind === "background" ? this.viewedSessionFor(entry.view.key)?.sessionId : entry.sessionId;
1279
+ if (entry.view.kind !== "here" && !sessionId) return false;
1280
+ const entries = this.host.chatHistory(sessionId);
1281
+ if (entries.length === 0) return false;
1282
+ this.history = { view: entry.view, entries };
1283
+ return true;
1284
+ }
1285
+
1208
1286
  /* ------------------------------------------------------------ sessions */
1209
1287
 
1210
1288
  /** Every session the lobby can show: this window, the background sessions it started, then tasks other terminals drive. */
@@ -1270,6 +1348,7 @@ export class LobbyView implements Component, Focusable {
1270
1348
  this.viewing = target;
1271
1349
  this.picking = false;
1272
1350
  this.newSession = false;
1351
+ this.history = undefined;
1273
1352
  for (const pane of HOME_PANES) this.homeOffsets[pane] = 0;
1274
1353
  if (this.tab !== "lobby") this.setTab("lobby");
1275
1354
  this.setMode("type");
@@ -1657,8 +1736,41 @@ export class LobbyView implements Component, Focusable {
1657
1736
 
1658
1737
  /* -------------------------------------------------------------- render */
1659
1738
 
1739
+ /** Bumped each time the data is reread. */
1740
+ private dataVersion = 0;
1741
+ /** Task rows as last built: several parts of a frame ask, and they only change with the data, the search or the clock. */
1742
+ private rowsMemo?: { key: string; rows: TaskRow[] };
1743
+
1744
+ /** The Metrics tab's figures as last computed: they change only with the data, the grouping, the order or the search. */
1745
+ private metricsMemo?: { key: string; figures: ReturnType<LobbyView["computeMetrics"]> };
1746
+
1747
+ private metricsFigures(query: string | undefined) {
1748
+ const key = `${this.dataVersion}|${this.metricsBy}|${this.metricsSort}|${query ?? ""}`;
1749
+ if (this.metricsMemo?.key !== key) this.metricsMemo = { key, figures: this.computeMetrics(query) };
1750
+ return this.metricsMemo.figures;
1751
+ }
1752
+
1753
+ private computeMetrics(query: string | undefined) {
1754
+ const records = filterRecords(collectMetrics(this.data.metrics, this.data.tasks), query);
1755
+ return {
1756
+ records,
1757
+ groups: sortGroups(aggregateMetrics(records, this.metricsBy), this.metricsSort),
1758
+ taskTimes: taskTimesByModel(this.data.tasks, records),
1759
+ stats: taskStats(this.data.tasks),
1760
+ jev: summarizeClassifier(this.data.classifierMetrics, records),
1761
+ };
1762
+ }
1763
+
1660
1764
  /** Task rows, narrowed by the Tasks tab's search. */
1661
1765
  private taskRowList(): TaskRow[] {
1766
+ const key = `${this.dataVersion}|${this.tick}|${this.showArchived}|${this.queries.tasks ?? ""}`;
1767
+ if (this.rowsMemo?.key === key) return this.rowsMemo.rows;
1768
+ const rows = this.buildTaskRows();
1769
+ this.rowsMemo = { key, rows };
1770
+ return rows;
1771
+ }
1772
+
1773
+ private buildTaskRows(): TaskRow[] {
1662
1774
  const names = new Map<string, string>();
1663
1775
  for (const session of this.host.sessions()) if (session.sessionId && session.alive) names.set(session.sessionId, session.name);
1664
1776
  const auto = new Set(this.data.tasks.filter((task) => !TERMINAL_STATES.includes(task.state) && this.host.isAuto(task.id)).map((task) => task.id));
@@ -1691,12 +1803,12 @@ export class LobbyView implements Component, Focusable {
1691
1803
  const badge = badges[tab] ? ` ${paint(theme, tab === "plan" && session?.awaitingAnswers ? "warning" : "accent", badges[tab]!)}` : "";
1692
1804
  let cell = ` ${number} ${name}${badge} `;
1693
1805
  if (tab === this.tab) cell = theme.bg ? theme.bg("selectedBg", cell) : `[${cell}]`;
1694
- const from = visibleWidth(left);
1806
+ const from = textWidth(left);
1695
1807
  left += cell;
1696
- spans.push({ tab, from, to: visibleWidth(left) });
1808
+ spans.push({ tab, from, to: textWidth(left) });
1697
1809
  }
1698
1810
  this.tabSpans = spans;
1699
- const room = width - visibleWidth(left) - 1;
1811
+ const room = width - textWidth(left) - 1;
1700
1812
  const entry = this.viewedEntry();
1701
1813
  const help = paint(theme, "dim", `${keyLabel(this.keys.help)} keys `);
1702
1814
  const auto = entry.auto ? ` ${bold(theme, paint(theme, "success", "⟳ AUTO"))}` : "";
@@ -1717,8 +1829,8 @@ export class LobbyView implements Component, Focusable {
1717
1829
  ? [`${dot} ${zen.task.id} ${state}${auto}${asking} ${help}`, `${dot} ${zen.task.id} ${state}${auto}${asking} `, `${dot} ${state}${auto} `, `${dot}${auto} `]
1718
1830
  : [`${paint(theme, "dim", "no task in this session")}${asking} ${help}`, `${paint(theme, "dim", "no task in this session")}${asking} `, help, ""];
1719
1831
  }
1720
- const status = choices.find((choice) => visibleWidth(choice) <= room) ?? "";
1721
- const gap = width - visibleWidth(left) - visibleWidth(status);
1832
+ const status = choices.find((choice) => textWidth(choice) <= room) ?? "";
1833
+ const gap = width - textWidth(left) - textWidth(status);
1722
1834
  return gap >= 1 ? `${left}${" ".repeat(gap)}${status}` : fit(left, width);
1723
1835
  }
1724
1836
 
@@ -1780,7 +1892,7 @@ export class LobbyView implements Component, Focusable {
1780
1892
  const tail = this.searching
1781
1893
  ? paint(theme, "dim", " enter keep · esc clear · ↑↓ browse results ")
1782
1894
  : paint(theme, "dim", ` ${keyLabel(this.keys.search)} or / edits · esc clears `);
1783
- const room = Math.max(4, width - visibleWidth(lead) - visibleWidth(tail));
1895
+ const room = Math.max(4, width - textWidth(lead) - textWidth(tail));
1784
1896
  const field = this.searching ? this.search.render(room)[0] ?? "" : bold(theme, fit(query ?? "", room));
1785
1897
  return [fit(`${lead}${fit(field, room)}${tail}`, width)];
1786
1898
  }
@@ -2018,12 +2130,9 @@ export class LobbyView implements Component, Focusable {
2018
2130
  }, width, height, theme);
2019
2131
  }
2020
2132
  case "metrics": {
2021
- const records = filterRecords(collectMetrics(this.data.metrics, this.data.tasks), query);
2022
- const groups = sortGroups(aggregateMetrics(records, this.metricsBy), this.metricsSort);
2133
+ const { records, groups, taskTimes, stats, jev } = this.metricsFigures(query);
2023
2134
  this.metricsSelected = Math.min(this.metricsSelected, Math.max(0, groups.length - 1));
2024
- const taskTimes = taskTimesByModel(this.data.tasks, records);
2025
- const jev = summarizeClassifier(this.data.classifierMetrics, records);
2026
- return renderMetrics({ groups, taskTimes, records, stats: taskStats(this.data.tasks), by: this.metricsBy, sort: this.metricsSort, selected: this.metricsSelected, ...(query ? { query } : {}), panes: this.panes, ...(jev ? { classifier: jev } : {}) }, width, height, theme);
2135
+ return renderMetrics({ groups, taskTimes, records, stats, by: this.metricsBy, sort: this.metricsSort, selected: this.metricsSelected, ...(query ? { query } : {}), panes: this.panes, ...(jev ? { classifier: jev } : {}) }, width, height, theme);
2027
2136
  }
2028
2137
  }
2029
2138
  }
@@ -2150,7 +2259,7 @@ export class LobbyView implements Component, Focusable {
2150
2259
  const room = height - lines.length - 2;
2151
2260
  if (room > 2) {
2152
2261
  lines.push("", rule(width, "Conversation", theme));
2153
- const talk = chat.length > 0 ? chatLines(chat, width, theme) : [paint(theme, "dim", "Nothing said yet.")];
2262
+ const talk = chat.length > 0 ? chatTail(chat, width, theme, room - 1).lines : [paint(theme, "dim", "Nothing said yet.")];
2154
2263
  lines.push(...talk.slice(-(room - 1)));
2155
2264
  }
2156
2265
  return lines;
@@ -2175,9 +2284,11 @@ export class LobbyView implements Component, Focusable {
2175
2284
  const query = this.query();
2176
2285
  const entry = this.viewedEntry();
2177
2286
  if (entry.view.kind !== "here") return this.otherSessionBody(entry, width, height, theme, now);
2287
+ const talk = this.conversationFor(entry);
2178
2288
  return renderHome({
2179
2289
  ...(zen.task ? { task: zen.task, scene: (w: number, h: number, animated: boolean) => this.host.scene(w, h, animated) } : {}),
2180
- chat: feed.chat,
2290
+ chat: talk.chat,
2291
+ ...(talk.older ? { olderNote: OLDER_NOTE } : {}),
2181
2292
  ...(feed.reply ? { liveReply: feed.reply } : {}),
2182
2293
  activity: feed.activity,
2183
2294
  thoughts: feed.thoughts,
@@ -2185,6 +2296,7 @@ export class LobbyView implements Component, Focusable {
2185
2296
  others,
2186
2297
  pending: this.data.plans.filter((plan) => plan.status === "pending").length,
2187
2298
  offsets: this.homeOffsets,
2299
+ anchors: this.marks,
2188
2300
  ...(this.mode === "browse" ? { focus: this.homeFocus } : {}),
2189
2301
  panes: this.panes,
2190
2302
  tick: this.tick,
@@ -2207,6 +2319,7 @@ export class LobbyView implements Component, Focusable {
2207
2319
  */
2208
2320
  private otherSessionBody(entry: SessionEntry, width: number, height: number, theme: LobbyTheme, now: number): string[] {
2209
2321
  const session = this.viewedSession();
2322
+ const talk = this.conversationFor(entry);
2210
2323
  const task = entry.task;
2211
2324
  const query = this.query();
2212
2325
  const common = {
@@ -2214,6 +2327,7 @@ export class LobbyView implements Component, Focusable {
2214
2327
  others: 0,
2215
2328
  pending: 0,
2216
2329
  offsets: this.homeOffsets,
2330
+ anchors: this.marks,
2217
2331
  ...(this.mode === "browse" ? { focus: this.homeFocus } : {}),
2218
2332
  panes: this.panes,
2219
2333
  tick: this.tick,
@@ -2232,7 +2346,8 @@ export class LobbyView implements Component, Focusable {
2232
2346
  const emptyNote = session.status === "starting" ? `starting ${session.name}…` : !session.alive ? `${session.name} has ended.` : `${session.name} has not said anything yet.`;
2233
2347
  return renderHome({
2234
2348
  ...common,
2235
- chat: session.feed.chat,
2349
+ chat: talk.chat,
2350
+ ...(talk.older ? { olderNote: OLDER_NOTE } : {}),
2236
2351
  ...(session.feed.reply ? { liveReply: session.feed.reply } : {}),
2237
2352
  activity: session.feed.activity,
2238
2353
  thoughts: session.feed.thoughts,
@@ -2243,7 +2358,8 @@ export class LobbyView implements Component, Focusable {
2243
2358
  const running = entry.where === "other terminal";
2244
2359
  return renderHome({
2245
2360
  ...common,
2246
- chat: entry.sessionId ? this.host.sessionChat(entry.sessionId) : [],
2361
+ chat: talk.chat,
2362
+ ...(talk.older ? { olderNote: OLDER_NOTE } : {}),
2247
2363
  activity: [],
2248
2364
  thoughts: [],
2249
2365
  busy: false,
@@ -7,7 +7,7 @@ import { detectGaps, domainsInvolved } from "./synthesis.ts";
7
7
  export function completionBlockers(task: Task, pendingCount: number): string[] {
8
8
  const blockers: string[] = [];
9
9
  if (!task.plan) blockers.push("no approved plan is recorded");
10
- if (task.qaVerdict !== "pass") blockers.push(`QA gate is ${task.qaVerdict ?? "not run"}`);
10
+ if (task.qaVerdict !== "pass" && !task.qaWaiver) blockers.push(`QA gate is ${task.qaVerdict ?? "not run"}`);
11
11
  if (pendingCount > 0) blockers.push(`${pendingCount} unresolved approval request(s)`);
12
12
  if (task.blockers.length > 0) blockers.push(`${task.blockers.length} unresolved blocker(s)`);
13
13
  return blockers;
@@ -4,8 +4,8 @@ import { profileFor, type BotLobbyConfig, type ProfileResolver } from "../schema
4
4
  import type { Domain } from "../schemas/agent.ts";
5
5
  import type { AgentRun, ReviewResult, ScoutResult, WorkerResult } from "../schemas/findings.ts";
6
6
  import { domainSpec } from "../agents/registry.ts";
7
- import { runAgent, runParallel, watchdogOptions, type AgentRequest } from "../execution/agent-runner.ts";
8
- import { spawnPiProcess, type ProcessRunner } from "../execution/pi-runner.ts";
7
+ import { runAgent, runParallel, watchdogOptions, type AgentRequest, type AgentTime } from "../execution/agent-runner.ts";
8
+ import { spawnPiProcess, type ProcessRunner, type RelayAsk } from "../execution/pi-runner.ts";
9
9
  import { readAgentKnowledge, writeFileEnsured } from "../knowledge/store.ts";
10
10
  import { selectKnowledge, type KnowledgeSelection } from "../knowledge/selector.ts";
11
11
  import { summarizeOutcomes } from "./synthesis.ts";
@@ -24,6 +24,8 @@ export interface ScoutOutcome {
24
24
  }
25
25
 
26
26
  export interface ScoutRequest {
27
+ /** Under a task time budget: the batch's time (every scout runs within it). */
28
+ time?: AgentTime;
27
29
  taskId: string;
28
30
  /** Requirements/objective text used for context selection. */
29
31
  taskText: string;
@@ -65,7 +67,7 @@ function scoutContext(request: ScoutRequest, domain: Domain, likely = ""): Agent
65
67
  task: request.taskText,
66
68
  ...selectKnowledge(`${request.taskText} ${domainSpec(domain).scoutFocus}`, slices),
67
69
  instructions: request.config.agents[domain].instructions,
68
- workflowContext: [`Task state: scouting. Domain: ${domain}. Read-only reconnaissance; no implementation.`, likely].filter(Boolean).join("\n\n"),
70
+ workflowContext: [`Task state: scouting. Domain: ${domain}. Read-only reconnaissance; no implementation.`, likely, request.time?.note ?? ""].filter(Boolean).join("\n\n"),
69
71
  };
70
72
  }
71
73
 
@@ -120,6 +122,7 @@ export async function runScouts(request: ScoutRequest, run: ProcessRunner = spaw
120
122
  context: scoutContext(request, domain, likely[index]),
121
123
  ...(extraTools.length > 0 ? { extraTools } : {}),
122
124
  ...profileFields(request.config, request.profile, domain, "scout"),
125
+ ...(request.time ? { time: { ...request.time } } : {}),
123
126
  cwd: request.cwd,
124
127
  signal: request.signal,
125
128
  onUpdate: request.onUpdate,
@@ -203,6 +206,21 @@ export interface WorkerRequest {
203
206
  hints?: FileHinter;
204
207
  /** Lowers thinking (or the model) for a step the classifier judges simple or trivial. */
205
208
  effort?: EffortRouter;
209
+ /** Under a task time budget: the step's time, and who decides on more when it runs out. */
210
+ time?: AgentTime;
211
+ /** The worker may ask the user (the designer): its questions are relayed, and its images saved in `previews`. */
212
+ ask?: { onAsk: RelayAsk; previews: string };
213
+ }
214
+
215
+ /** What a worker that may ask the user is told about it. */
216
+ function askGuidance(previews: string): string {
217
+ return [
218
+ "You can ask the user with ask_user_question: the oracle relays it, and your clock stops while they answer.",
219
+ "Ask when a design decision is genuinely theirs (a visual direction, a layout, a style), once, with all such questions together, before you build it; decide everything else yourself.",
220
+ "Show each option. Give it a `preview`: a Markdown wireframe (a fenced block drawn with box characters) or a short snippet of the component.",
221
+ `When you can render an option (a screenshot from the project's own tooling, a rendered mockup), save it as a PNG under ${previews} (never in the repository) and give its path as the option's \`image\`.`,
222
+ "Put the option you recommend first. The answers are recorded as the task's decisions: build what the user chose.",
223
+ ].join(" ");
206
224
  }
207
225
 
208
226
  function workerWorkflowContext(request: WorkerRequest, likely = ""): string {
@@ -218,6 +236,8 @@ function workerWorkflowContext(request: WorkerRequest, likely = ""): string {
218
236
  ? `Scout findings for your domain:\n${summarizeOutcomes(own, 1500)}`
219
237
  : "No scout findings were collected for your domain; verify the repository yourself.",
220
238
  likely,
239
+ request.time?.note ?? "",
240
+ request.ask ? askGuidance(request.ask.previews) : "",
221
241
  ].filter(Boolean).join("\n\n");
222
242
  }
223
243
 
@@ -253,11 +273,18 @@ export async function runWorker(
253
273
  ...watchdogOptions(request.config.workflow),
254
274
  ...request.agent,
255
275
  ...(extraTools.length > 0 ? { extraTools } : {}),
276
+ // One allotment for the step: a routed attempt that falls short re-runs on what is left of it.
277
+ ...(request.time ? { time: request.time } : {}),
278
+ ...(request.ask ? { onAsk: request.ask.onAsk } : {}),
256
279
  };
257
280
  let outcome = workerOutcome(request.domain, await runAgent(route ? { ...base, ...routedFields(route) } : base, run));
258
281
  // A routed step that fell short runs again at the configured model and thinking.
259
282
  if (route && fellShort(outcome.run, outcome.issues) && !request.signal?.aborted) {
283
+ const routed = outcome.run.edited ?? [];
260
284
  outcome = workerOutcome(request.domain, await runAgent(base, run));
285
+ // The step owns what the routed attempt edited too.
286
+ const edited = [...new Set([...routed, ...(outcome.run.edited ?? [])])];
287
+ if (edited.length > 0) outcome = { ...outcome, run: { ...outcome.run, edited } };
261
288
  }
262
289
  return outcome;
263
290
  }
@@ -284,6 +311,12 @@ export interface ReviewerRequest {
284
311
  workerSummary: string;
285
312
  scoutOutcomes: ScoutOutcome[];
286
313
  diff: string;
314
+ /** Who changed each changed file (planned, quick fix, pre-existing, …), one line each. */
315
+ provenance?: string;
316
+ /** What the previous QA round asked for, when there was one: this round verifies it. */
317
+ previousRound?: string;
318
+ /** Under a task time budget: the gate's time. */
319
+ time?: AgentTime;
287
320
  instruction?: string;
288
321
  cwd: string;
289
322
  dataRoots: readonly string[];
@@ -301,8 +334,11 @@ function reviewerContext(request: ReviewerRequest): string {
301
334
  return [
302
335
  `Task state: reviewing. Domain: ${request.domain}.`,
303
336
  "You may not modify implementation. Report required changes instead.",
304
- request.workerSummary ? `Worker summary:\n${truncate(request.workerSummary, 2000)}` : "No worker summary available.",
337
+ request.previousRound ?? "",
338
+ request.workerSummary ? `Worker summary (each domain's newest entries):\n${truncate(request.workerSummary, 4800)}` : "No worker summary available.",
305
339
  owns.length > 0 ? `Scout findings:\n${summarizeOutcomes(owns, 1200)}` : "",
340
+ request.provenance ? `Who changed each file (bot-lobby's record of every agent's edit and write calls; judge each as your role's Change provenance says):\n${request.provenance}` : "",
341
+ request.time?.note ?? "",
306
342
  `Repository changes:\n${request.diff}`,
307
343
  ]
308
344
  .filter((line) => line.length > 0)
@@ -334,6 +370,7 @@ function reviewerAgentRequest(request: ReviewerRequest, selected: KnowledgeSelec
334
370
  workflowContext: reviewerContext(request),
335
371
  },
336
372
  ...profileFields(request.config, request.profile, request.domain, "reviewer"),
373
+ ...(request.time ? { time: request.time } : {}),
337
374
  cwd: request.cwd,
338
375
  signal: request.signal,
339
376
  onUpdate: request.onUpdate,
@@ -3,7 +3,7 @@ import { join } from "node:path";
3
3
  import type { Domain } from "../schemas/agent.ts";
4
4
  import { profileFor, type BotLobbyConfig, type ProfileResolver } from "../schemas/configuration.ts";
5
5
  import type { AgentRun, ResearchResult } from "../schemas/findings.ts";
6
- import { runAgent, watchdogOptions, type AgentRequest } from "../execution/agent-runner.ts";
6
+ import { runAgent, watchdogOptions, type AgentRequest, type AgentTime } from "../execution/agent-runner.ts";
7
7
  import { spawnPiProcess, type ProcessRunner } from "../execution/pi-runner.ts";
8
8
  import { writeFileEnsured } from "../knowledge/store.ts";
9
9
  import { isResearchResultUsable, parseResearchResult, validateResearchResult } from "../roles/researcher.ts";
@@ -27,6 +27,8 @@ export interface ResearchRequest {
27
27
  profile?: ProfileResolver;
28
28
  signal?: AbortSignal;
29
29
  onUpdate?: (run: AgentRun) => void;
30
+ /** Under a task time budget: the researcher's time. */
31
+ time?: AgentTime;
30
32
  }
31
33
 
32
34
  export function researchResultPath(taskDir: string, domain: Domain): string {
@@ -56,7 +58,7 @@ function researchContext(request: ResearchRequest): AgentRequest["context"] {
56
58
  return {
57
59
  task: request.instruction,
58
60
  instructions: request.config.agents[request.domain].instructions,
59
- workflowContext: `Task state: research requested by the Master. Domain: ${request.domain}. Read-only; report cited findings only.`,
61
+ workflowContext: [`Task state: research requested by the Master. Domain: ${request.domain}. Read-only; report cited findings only.`, request.time?.note ?? ""].filter(Boolean).join("\n\n"),
60
62
  };
61
63
  }
62
64
 
@@ -86,6 +88,7 @@ export async function runResearch(request: ResearchRequest, run: ProcessRunner =
86
88
  model: profile.model,
87
89
  thinking: profile.thinking,
88
90
  timeoutMs: profile.timeoutMs,
91
+ ...(request.time ? { time: request.time } : {}),
89
92
  cwd: request.cwd,
90
93
  signal: request.signal,
91
94
  onUpdate: request.onUpdate,