@a-t-h-i/bot-lobby 0.6.1 → 0.6.3

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 (56) hide show
  1. package/README.md +187 -834
  2. package/package.json +1 -1
  3. package/prompts/master.md +17 -0
  4. package/prompts/panel.md +3 -1
  5. package/prompts/planner.md +14 -0
  6. package/prompts/quickfix.md +3 -1
  7. package/prompts/scout.md +3 -0
  8. package/prompts/worker.md +3 -0
  9. package/src/classifier/answers.ts +110 -0
  10. package/src/classifier/classifier.ts +171 -0
  11. package/src/classifier/client.ts +239 -0
  12. package/src/classifier/effort.ts +143 -0
  13. package/src/classifier/files.ts +427 -0
  14. package/src/classifier/hosts.ts +157 -0
  15. package/src/classifier/instance.ts +98 -0
  16. package/src/classifier/limits.ts +37 -0
  17. package/src/classifier/seats.ts +102 -0
  18. package/src/classifier/tools.ts +58 -0
  19. package/src/classifier/triage.ts +203 -0
  20. package/src/execution/agent-runner.ts +5 -0
  21. package/src/index.ts +9 -0
  22. package/src/lobby/feed.ts +76 -8
  23. package/src/lobby/layout.ts +14 -13
  24. package/src/lobby/markdown.ts +61 -18
  25. package/src/lobby/planner.ts +305 -27
  26. package/src/lobby/quickfix.ts +120 -8
  27. package/src/lobby/runtime.ts +57 -10
  28. package/src/lobby/session-files.ts +162 -25
  29. package/src/lobby/sessions.ts +21 -7
  30. package/src/lobby/tabs/home.ts +219 -61
  31. package/src/lobby/tabs/metrics.ts +28 -1
  32. package/src/lobby/tabs/plan.ts +27 -4
  33. package/src/lobby/tabs/quickfix.ts +5 -1
  34. package/src/lobby/view.ts +140 -22
  35. package/src/master/master.ts +95 -29
  36. package/src/pi/commands.ts +4 -2
  37. package/src/pi/events.ts +18 -4
  38. package/src/pi/fresh-context.ts +134 -0
  39. package/src/pi/owner.ts +19 -10
  40. package/src/pi/run-summary.ts +3 -1
  41. package/src/pi/settings-ui.ts +138 -2
  42. package/src/pi/start-task.ts +7 -0
  43. package/src/pi/tools.ts +7 -2
  44. package/src/pi/ui.ts +3 -2
  45. package/src/pi/zen-large.ts +10 -10
  46. package/src/pi/zen.ts +6 -6
  47. package/src/schemas/configuration.ts +129 -1
  48. package/src/schemas/findings.ts +4 -0
  49. package/src/schemas/task.ts +22 -0
  50. package/src/state/archive.ts +12 -3
  51. package/src/state/backlog.ts +13 -3
  52. package/src/state/file-cache.ts +62 -0
  53. package/src/state/metrics.ts +137 -14
  54. package/src/state/persistence.ts +36 -2
  55. package/src/width.ts +102 -0
  56. package/src/workflow/workflow.ts +49 -1
package/src/lobby/view.ts CHANGED
@@ -9,12 +9,13 @@
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";
16
17
  import type { PlanComment } from "../state/comments.ts";
17
- import { aggregateMetrics, collectMetrics, SORT_KEYS, sortGroups, taskStats, taskTimesByModel, type GroupBy, type MetricRecord, type SortKey } from "../state/metrics.ts";
18
+ import { aggregateMetrics, collectMetrics, SORT_KEYS, sortGroups, summarizeClassifier, taskStats, taskTimesByModel, type GroupBy, type MetricRecord, type SortKey } from "../state/metrics.ts";
18
19
  import { PANEL_MEMBERS, type LobbyAgentKind, type LobbyPanel, type PanelMember } from "../schemas/configuration.ts";
19
20
  import type { ChatEntry, LobbyFeed } from "./feed.ts";
20
21
  import type { BackgroundSession } from "./sessions.ts";
@@ -23,7 +24,7 @@ import { MEMBER_LABELS, ORACLE_LABEL, type PlannerSeed, type PlanningSession } f
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
26
  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";
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
 
@@ -133,6 +137,8 @@ export interface LobbyHost {
133
137
  plans(): PlannedTask[];
134
138
  comments(taskId: string): PlanComment[];
135
139
  metrics(): MetricRecord[];
140
+ /** Classifier calls (kind `classifier`), kept out of the agent tables. */
141
+ classifierMetrics?(): MetricRecord[];
136
142
  /** Send text to the oracle, or start a task when none is active; returns a notice. */
137
143
  toOracle(text: string): string | undefined;
138
144
  comment(taskId: string, text: string): string;
@@ -148,6 +154,8 @@ export interface LobbyHost {
148
154
  answerPanel(): Promise<string>;
149
155
  /** The seats a new session starts with, from settings. */
150
156
  defaultPanel(): readonly PanelMember[];
157
+ /** Planning rounds before the oracle finalizes alone (`lobby.maxPlanningRounds`); 0 = unlimited. */
158
+ planningRounds?(): number;
151
159
  /** `model · thinking` a panel seat runs on. */
152
160
  seatLabel(member: PanelMember): string;
153
161
  issues: IssuesState;
@@ -185,8 +193,12 @@ export interface LobbyHost {
185
193
  archiveTask(taskId: string): string;
186
194
  restoreTask(taskId: string): string;
187
195
  deleteTask(taskId: string, where: "list" | "archive"): string;
188
- /** Another session's conversation, read from its saved session file. */
196
+ /** Another session's conversation, read from its saved session file (its newest messages). */
189
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[];
190
202
  /** A task's status without animations, for a session other than this window's. */
191
203
  taskScene(task: Task, width: number, height: number): string[];
192
204
  profileLabel(kind: LobbyAgentKind): string;
@@ -212,7 +224,7 @@ class LobbyEditor extends Editor {
212
224
  if (!this.label) return super.renderTopBorder(width, hiddenLineCount);
213
225
  const label = ` ${this.label} `;
214
226
  const more = hiddenLineCount > 0 ? ` ↑${hiddenLineCount} ` : "";
215
- const rest = width - 2 - visibleWidth(label) - visibleWidth(more);
227
+ const rest = width - 2 - textWidth(label) - textWidth(more);
216
228
  if (rest < 1) return super.renderTopBorder(width, hiddenLineCount);
217
229
  return `${this.borderColor("──")}${this.paintLabel(label)}${this.borderColor("─".repeat(rest))}${more ? this.borderColor(more) : ""}`;
218
230
  }
@@ -336,12 +348,13 @@ export class LobbyView implements Component, Focusable {
336
348
 
337
349
  /** The Tasks tab lists archived tasks too (v). */
338
350
  showArchived = false;
339
- private data: { tasks: Task[]; plans: PlannedTask[]; archived: Task[]; comments: Map<string, PlanComment[]>; metrics: MetricRecord[]; at: number } = {
351
+ private data: { tasks: Task[]; plans: PlannedTask[]; archived: Task[]; comments: Map<string, PlanComment[]>; metrics: MetricRecord[]; classifierMetrics: MetricRecord[]; at: number } = {
340
352
  tasks: [],
341
353
  plans: [],
342
354
  archived: [],
343
355
  comments: new Map(),
344
356
  metrics: [],
357
+ classifierMetrics: [],
345
358
  at: 0,
346
359
  };
347
360
 
@@ -425,12 +438,14 @@ export class LobbyView implements Component, Focusable {
425
438
  refreshData(force: boolean): void {
426
439
  const now = this.now();
427
440
  if (!force && now - this.data.at < DATA_REFRESH_MS) return;
441
+ this.dataVersion += 1;
428
442
  this.data = {
429
443
  tasks: this.host.tasks(),
430
444
  plans: this.host.plans(),
431
445
  archived: this.showArchived ? this.host.archivedTasks() : [],
432
446
  comments: new Map(),
433
447
  metrics: this.tab === "metrics" ? this.host.metrics() : this.data.metrics,
448
+ classifierMetrics: this.tab === "metrics" ? this.host.classifierMetrics?.() ?? [] : this.data.classifierMetrics,
434
449
  at: now,
435
450
  };
436
451
  if (this.tab === "tasks") {
@@ -832,6 +847,7 @@ export class LobbyView implements Component, Focusable {
832
847
  break;
833
848
  }
834
849
  this.clampScroll();
850
+ if (pane === "conversation") this.followHistory();
835
851
  }
836
852
 
837
853
  /** Home and End: the oldest or first line of the focused pane, or back to its newest or last. */
@@ -855,6 +871,8 @@ export class LobbyView implements Component, Focusable {
855
871
  return;
856
872
  }
857
873
  this.scrollPane(pane, far);
874
+ // Home loads earlier messages at the top; go on to the very first of them.
875
+ if (start && pane === "conversation" && this.history) this.homeOffsets.conversation = Number.MAX_SAFE_INTEGER;
858
876
  }
859
877
 
860
878
  /** The wheel over the draft scrolls it; the cursor follows only when it would leave the screen. */
@@ -948,7 +966,9 @@ export class LobbyView implements Component, Focusable {
948
966
  const rows = this.taskRowList();
949
967
  this.tasksSelected = Math.max(0, Math.min(rows.length - 1, index));
950
968
  this.tasksDetailOffset = 0;
951
- this.refreshData(true);
969
+ // Only the newly selected task's comments: moving through the list must not reread every task from disk.
970
+ const row = rows[this.tasksSelected];
971
+ if (row?.kind === "task" && !this.data.comments.has(row.id)) this.data.comments.set(row.id, this.host.comments(row.id));
952
972
  }
953
973
 
954
974
  private async loadIssueDetail(): Promise<void> {
@@ -998,6 +1018,17 @@ export class LobbyView implements Component, Focusable {
998
1018
  return true;
999
1019
  }
1000
1020
  if (data === "m") return this.openSettings("quickfix"), true;
1021
+ if (data === "r" || data === "t") {
1022
+ const job = this.fixJobs()[this.fixSelected];
1023
+ // Only a held job takes r and t; otherwise the key resumes typing like any other.
1024
+ if (!job || job.status !== "held") return false;
1025
+ if (data === "r" && this.host.quickfix.runAnyway(job.id)) this.say(`running ${job.id} anyway`);
1026
+ if (data === "t") {
1027
+ this.startInNewSession({ request: job.prompt });
1028
+ this.host.quickfix.movedToTask(job.id);
1029
+ }
1030
+ return true;
1031
+ }
1001
1032
  return false;
1002
1033
  case "issues":
1003
1034
  return this.issuesCommand(data, enter, escape);
@@ -1188,6 +1219,50 @@ export class LobbyView implements Component, Focusable {
1188
1219
  void this.host.answerDialog(session).then(() => this.host.requestRender());
1189
1220
  }
1190
1221
 
1222
+ /* -------------------------------------------------------------- history */
1223
+
1224
+ /**
1225
+ * The whole conversation of the session in view, loaded when the
1226
+ * conversation pane is scrolled back past the messages kept in memory, and
1227
+ * let go once it is back at the newest.
1228
+ */
1229
+ private history?: { view: SessionView; entries: readonly ChatEntry[] };
1230
+
1231
+ /** The conversation to draw for the session in view, and whether earlier messages wait to be loaded. */
1232
+ private conversationFor(entry: SessionEntry): { chat: readonly ChatEntry[]; older: boolean } {
1233
+ if (this.history && sameView(this.history.view, entry.view)) return { chat: this.history.entries, older: false };
1234
+ if (entry.view.kind === "here") return { chat: this.host.feed.chat, older: this.host.feed.chatOlder };
1235
+ if (entry.view.kind === "background") {
1236
+ const session = this.viewedSessionFor(entry.view.key);
1237
+ return { chat: session?.feed.chat ?? [], older: Boolean(session?.feed.chatOlder && session.sessionId) };
1238
+ }
1239
+ return entry.sessionId ? { chat: this.host.sessionChat(entry.sessionId), older: this.host.hasOlderChat(entry.sessionId) } : { chat: [], older: false };
1240
+ }
1241
+
1242
+ /**
1243
+ * After the conversation pane scrolls: at its top, with earlier messages
1244
+ * waiting, load the whole conversation; back at its newest, let it go.
1245
+ * True when history was loaded now.
1246
+ */
1247
+ private followHistory(): boolean {
1248
+ if (this.tab !== "lobby") return false;
1249
+ if (this.homeOffsets.conversation === 0) {
1250
+ this.history = undefined;
1251
+ return false;
1252
+ }
1253
+ if (this.history && sameView(this.history.view, this.viewing)) return false;
1254
+ const pane = this.panes.get("conversation");
1255
+ if (!pane || this.homeOffsets.conversation < pane.total - pane.rows) return false;
1256
+ const entry = this.viewedEntry();
1257
+ if (!this.conversationFor(entry).older) return false;
1258
+ const sessionId = entry.view.kind === "here" ? undefined : entry.view.kind === "background" ? this.viewedSessionFor(entry.view.key)?.sessionId : entry.sessionId;
1259
+ if (entry.view.kind !== "here" && !sessionId) return false;
1260
+ const entries = this.host.chatHistory(sessionId);
1261
+ if (entries.length === 0) return false;
1262
+ this.history = { view: entry.view, entries };
1263
+ return true;
1264
+ }
1265
+
1191
1266
  /* ------------------------------------------------------------ sessions */
1192
1267
 
1193
1268
  /** Every session the lobby can show: this window, the background sessions it started, then tasks other terminals drive. */
@@ -1253,6 +1328,7 @@ export class LobbyView implements Component, Focusable {
1253
1328
  this.viewing = target;
1254
1329
  this.picking = false;
1255
1330
  this.newSession = false;
1331
+ this.history = undefined;
1256
1332
  for (const pane of HOME_PANES) this.homeOffsets[pane] = 0;
1257
1333
  if (this.tab !== "lobby") this.setTab("lobby");
1258
1334
  this.setMode("type");
@@ -1433,7 +1509,7 @@ export class LobbyView implements Component, Focusable {
1433
1509
  seated = !seats.delete(seat);
1434
1510
  if (seated) seats.add(seat);
1435
1511
  }
1436
- this.say(`${MEMBER_LABELS[seat]} ${seated ? "joins" : "leaves"} the panel from the next round`);
1512
+ this.say(`${MEMBER_LABELS[seat]} ${seated ? "joins the panel from the next round and sits every round" : "leaves the panel from the next round"}`);
1437
1513
  return true;
1438
1514
  }
1439
1515
  if (data === "x" || (escape && session?.busy)) {
@@ -1640,8 +1716,41 @@ export class LobbyView implements Component, Focusable {
1640
1716
 
1641
1717
  /* -------------------------------------------------------------- render */
1642
1718
 
1719
+ /** Bumped each time the data is reread. */
1720
+ private dataVersion = 0;
1721
+ /** Task rows as last built: several parts of a frame ask, and they only change with the data, the search or the clock. */
1722
+ private rowsMemo?: { key: string; rows: TaskRow[] };
1723
+
1724
+ /** The Metrics tab's figures as last computed: they change only with the data, the grouping, the order or the search. */
1725
+ private metricsMemo?: { key: string; figures: ReturnType<LobbyView["computeMetrics"]> };
1726
+
1727
+ private metricsFigures(query: string | undefined) {
1728
+ const key = `${this.dataVersion}|${this.metricsBy}|${this.metricsSort}|${query ?? ""}`;
1729
+ if (this.metricsMemo?.key !== key) this.metricsMemo = { key, figures: this.computeMetrics(query) };
1730
+ return this.metricsMemo.figures;
1731
+ }
1732
+
1733
+ private computeMetrics(query: string | undefined) {
1734
+ const records = filterRecords(collectMetrics(this.data.metrics, this.data.tasks), query);
1735
+ return {
1736
+ records,
1737
+ groups: sortGroups(aggregateMetrics(records, this.metricsBy), this.metricsSort),
1738
+ taskTimes: taskTimesByModel(this.data.tasks, records),
1739
+ stats: taskStats(this.data.tasks),
1740
+ jev: summarizeClassifier(this.data.classifierMetrics, records),
1741
+ };
1742
+ }
1743
+
1643
1744
  /** Task rows, narrowed by the Tasks tab's search. */
1644
1745
  private taskRowList(): TaskRow[] {
1746
+ const key = `${this.dataVersion}|${this.tick}|${this.showArchived}|${this.queries.tasks ?? ""}`;
1747
+ if (this.rowsMemo?.key === key) return this.rowsMemo.rows;
1748
+ const rows = this.buildTaskRows();
1749
+ this.rowsMemo = { key, rows };
1750
+ return rows;
1751
+ }
1752
+
1753
+ private buildTaskRows(): TaskRow[] {
1645
1754
  const names = new Map<string, string>();
1646
1755
  for (const session of this.host.sessions()) if (session.sessionId && session.alive) names.set(session.sessionId, session.name);
1647
1756
  const auto = new Set(this.data.tasks.filter((task) => !TERMINAL_STATES.includes(task.state) && this.host.isAuto(task.id)).map((task) => task.id));
@@ -1674,12 +1783,12 @@ export class LobbyView implements Component, Focusable {
1674
1783
  const badge = badges[tab] ? ` ${paint(theme, tab === "plan" && session?.awaitingAnswers ? "warning" : "accent", badges[tab]!)}` : "";
1675
1784
  let cell = ` ${number} ${name}${badge} `;
1676
1785
  if (tab === this.tab) cell = theme.bg ? theme.bg("selectedBg", cell) : `[${cell}]`;
1677
- const from = visibleWidth(left);
1786
+ const from = textWidth(left);
1678
1787
  left += cell;
1679
- spans.push({ tab, from, to: visibleWidth(left) });
1788
+ spans.push({ tab, from, to: textWidth(left) });
1680
1789
  }
1681
1790
  this.tabSpans = spans;
1682
- const room = width - visibleWidth(left) - 1;
1791
+ const room = width - textWidth(left) - 1;
1683
1792
  const entry = this.viewedEntry();
1684
1793
  const help = paint(theme, "dim", `${keyLabel(this.keys.help)} keys `);
1685
1794
  const auto = entry.auto ? ` ${bold(theme, paint(theme, "success", "⟳ AUTO"))}` : "";
@@ -1700,8 +1809,8 @@ export class LobbyView implements Component, Focusable {
1700
1809
  ? [`${dot} ${zen.task.id} ${state}${auto}${asking} ${help}`, `${dot} ${zen.task.id} ${state}${auto}${asking} `, `${dot} ${state}${auto} `, `${dot}${auto} `]
1701
1810
  : [`${paint(theme, "dim", "no task in this session")}${asking} ${help}`, `${paint(theme, "dim", "no task in this session")}${asking} `, help, ""];
1702
1811
  }
1703
- const status = choices.find((choice) => visibleWidth(choice) <= room) ?? "";
1704
- const gap = width - visibleWidth(left) - visibleWidth(status);
1812
+ const status = choices.find((choice) => textWidth(choice) <= room) ?? "";
1813
+ const gap = width - textWidth(left) - textWidth(status);
1705
1814
  return gap >= 1 ? `${left}${" ".repeat(gap)}${status}` : fit(left, width);
1706
1815
  }
1707
1816
 
@@ -1763,7 +1872,7 @@ export class LobbyView implements Component, Focusable {
1763
1872
  const tail = this.searching
1764
1873
  ? paint(theme, "dim", " enter keep · esc clear · ↑↓ browse results ")
1765
1874
  : paint(theme, "dim", ` ${keyLabel(this.keys.search)} or / edits · esc clears `);
1766
- const room = Math.max(4, width - visibleWidth(lead) - visibleWidth(tail));
1875
+ const room = Math.max(4, width - textWidth(lead) - textWidth(tail));
1767
1876
  const field = this.searching ? this.search.render(room)[0] ?? "" : bold(theme, fit(query ?? "", room));
1768
1877
  return [fit(`${lead}${fit(field, room)}${tail}`, width)];
1769
1878
  }
@@ -1901,6 +2010,7 @@ export class LobbyView implements Component, Focusable {
1901
2010
  break;
1902
2011
  case "quickfix":
1903
2012
  if (this.host.quickfix.jobs.length > 0) keys.push(["↑↓", "select"], ["x", "cancel"]);
2013
+ if (this.fixJobs()[this.fixSelected]?.status === "held") keys.push(["r", "run anyway"], ["t", "as a task"]);
1904
2014
  keys.push(["m", "model"]);
1905
2015
  break;
1906
2016
  case "issues":
@@ -1971,6 +2081,7 @@ export class LobbyView implements Component, Focusable {
1971
2081
  ...(session ? { session: this.planView(session) } : {}),
1972
2082
  profile: this.host.profileLabel("planner"),
1973
2083
  seats: this.seatViews(undefined),
2084
+ limit: this.host.planningRounds?.() ?? 0,
1974
2085
  offset: this.planOffset,
1975
2086
  focus: this.planFocus,
1976
2087
  draftOffset: this.planDraftOffset,
@@ -1999,11 +2110,9 @@ export class LobbyView implements Component, Focusable {
1999
2110
  }, width, height, theme);
2000
2111
  }
2001
2112
  case "metrics": {
2002
- const records = filterRecords(collectMetrics(this.data.metrics, this.data.tasks), query);
2003
- const groups = sortGroups(aggregateMetrics(records, this.metricsBy), this.metricsSort);
2113
+ const { records, groups, taskTimes, stats, jev } = this.metricsFigures(query);
2004
2114
  this.metricsSelected = Math.min(this.metricsSelected, Math.max(0, groups.length - 1));
2005
- const taskTimes = taskTimesByModel(this.data.tasks, records);
2006
- return renderMetrics({ groups, taskTimes, records, stats: taskStats(this.data.tasks), by: this.metricsBy, sort: this.metricsSort, selected: this.metricsSelected, ...(query ? { query } : {}), panes: this.panes }, width, height, theme);
2115
+ 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);
2007
2116
  }
2008
2117
  }
2009
2118
  }
@@ -2034,6 +2143,8 @@ export class LobbyView implements Component, Focusable {
2034
2143
  questions: session?.questions.filter((question) => question.from === MEMBER_LABELS[member]).length ?? 0,
2035
2144
  ready: state?.reply?.status === "ready",
2036
2145
  profile: this.host.seatLabel(member),
2146
+ ...(!state && session?.satOut.has(member) ? { satOut: session.satOut.get(member)! } : {}),
2147
+ ...(session?.pins.has(member) ? { pinned: true } : {}),
2037
2148
  };
2038
2149
  });
2039
2150
  return [oracle, ...members];
@@ -2050,6 +2161,8 @@ export class LobbyView implements Component, Focusable {
2050
2161
  ...(session.step ? { step: session.step } : {}),
2051
2162
  ...(session.error ? { error: session.error } : {}),
2052
2163
  turns: session.turns,
2164
+ limit: session.limit,
2165
+ nextMode: session.nextMode,
2053
2166
  ...(session.seed ? { seed: session.seed } : {}),
2054
2167
  ...(session.saved ? { saved: session.saved } : {}),
2055
2168
  ...(session.title ? { title: session.title } : {}),
@@ -2126,7 +2239,7 @@ export class LobbyView implements Component, Focusable {
2126
2239
  const room = height - lines.length - 2;
2127
2240
  if (room > 2) {
2128
2241
  lines.push("", rule(width, "Conversation", theme));
2129
- const talk = chat.length > 0 ? chatLines(chat, width, theme) : [paint(theme, "dim", "Nothing said yet.")];
2242
+ const talk = chat.length > 0 ? chatTail(chat, width, theme, room - 1).lines : [paint(theme, "dim", "Nothing said yet.")];
2130
2243
  lines.push(...talk.slice(-(room - 1)));
2131
2244
  }
2132
2245
  return lines;
@@ -2151,9 +2264,11 @@ export class LobbyView implements Component, Focusable {
2151
2264
  const query = this.query();
2152
2265
  const entry = this.viewedEntry();
2153
2266
  if (entry.view.kind !== "here") return this.otherSessionBody(entry, width, height, theme, now);
2267
+ const talk = this.conversationFor(entry);
2154
2268
  return renderHome({
2155
2269
  ...(zen.task ? { task: zen.task, scene: (w: number, h: number, animated: boolean) => this.host.scene(w, h, animated) } : {}),
2156
- chat: feed.chat,
2270
+ chat: talk.chat,
2271
+ ...(talk.older ? { olderNote: OLDER_NOTE } : {}),
2157
2272
  ...(feed.reply ? { liveReply: feed.reply } : {}),
2158
2273
  activity: feed.activity,
2159
2274
  thoughts: feed.thoughts,
@@ -2183,6 +2298,7 @@ export class LobbyView implements Component, Focusable {
2183
2298
  */
2184
2299
  private otherSessionBody(entry: SessionEntry, width: number, height: number, theme: LobbyTheme, now: number): string[] {
2185
2300
  const session = this.viewedSession();
2301
+ const talk = this.conversationFor(entry);
2186
2302
  const task = entry.task;
2187
2303
  const query = this.query();
2188
2304
  const common = {
@@ -2208,7 +2324,8 @@ export class LobbyView implements Component, Focusable {
2208
2324
  const emptyNote = session.status === "starting" ? `starting ${session.name}…` : !session.alive ? `${session.name} has ended.` : `${session.name} has not said anything yet.`;
2209
2325
  return renderHome({
2210
2326
  ...common,
2211
- chat: session.feed.chat,
2327
+ chat: talk.chat,
2328
+ ...(talk.older ? { olderNote: OLDER_NOTE } : {}),
2212
2329
  ...(session.feed.reply ? { liveReply: session.feed.reply } : {}),
2213
2330
  activity: session.feed.activity,
2214
2331
  thoughts: session.feed.thoughts,
@@ -2219,7 +2336,8 @@ export class LobbyView implements Component, Focusable {
2219
2336
  const running = entry.where === "other terminal";
2220
2337
  return renderHome({
2221
2338
  ...common,
2222
- chat: entry.sessionId ? this.host.sessionChat(entry.sessionId) : [],
2339
+ chat: talk.chat,
2340
+ ...(talk.older ? { olderNote: OLDER_NOTE } : {}),
2223
2341
  activity: [],
2224
2342
  thoughts: [],
2225
2343
  busy: false,
@@ -13,6 +13,8 @@ import { parseWorkerResult, validateWorkerResult } from "../roles/worker.ts";
13
13
  import { parseReviewResult, validateReviewResult } from "../roles/reviewer.ts";
14
14
  import { truncate } from "../text.ts";
15
15
  import { isScoutResultUsable, parseScoutResult, validateScoutResult } from "../roles/scout.ts";
16
+ import type { FileHinter } from "../classifier/files.ts";
17
+ import { fellShort, profileLabel, routeLabel, type EffortRoute, type EffortRouter } from "../classifier/effort.ts";
16
18
 
17
19
  export interface ScoutOutcome {
18
20
  result: ScoutResult;
@@ -36,6 +38,10 @@ export interface ScoutRequest {
36
38
  profile?: ProfileResolver;
37
39
  signal?: AbortSignal;
38
40
  onUpdate?: (run: AgentRun) => void;
41
+ /** Likely files for each scout's context, and the lookup tool, while the classifier's file hints are on. */
42
+ hints?: FileHinter;
43
+ /** Moves a trivial scout to the cheaper model, while the classifier's effort routing is on. */
44
+ effort?: EffortRouter;
39
45
  }
40
46
 
41
47
  /** Model, thinking and time limit for one run, from settings. */
@@ -53,16 +59,41 @@ function scoutInstruction(request: ScoutRequest, domain: Domain): string {
53
59
  ].join("\n");
54
60
  }
55
61
 
56
- function scoutContext(request: ScoutRequest, domain: Domain): AgentRequest["context"] {
62
+ function scoutContext(request: ScoutRequest, domain: Domain, likely = ""): AgentRequest["context"] {
57
63
  const slices = readAgentKnowledge(request.dataRoots, domain);
58
64
  return {
59
65
  task: request.taskText,
60
66
  ...selectKnowledge(`${request.taskText} ${domainSpec(domain).scoutFocus}`, slices),
61
67
  instructions: request.config.agents[domain].instructions,
62
- workflowContext: `Task state: scouting. Domain: ${domain}. Read-only reconnaissance; no implementation.`,
68
+ workflowContext: [`Task state: scouting. Domain: ${domain}. Read-only reconnaissance; no implementation.`, likely].filter(Boolean).join("\n\n"),
63
69
  };
64
70
  }
65
71
 
72
+ /** Where the classifier routes a run, or undefined (no router, no route, a failure). */
73
+ async function routeFor(effort: EffortRouter | undefined, instruction: string, profile: { model?: string; thinking: string }, options: { thinkingFixed?: boolean; context?: string; signal?: AbortSignal }): Promise<EffortRoute | undefined> {
74
+ if (!effort) return undefined;
75
+ try {
76
+ return await effort.route(instruction, profile, options);
77
+ } catch {
78
+ return undefined;
79
+ }
80
+ }
81
+
82
+ /** The run fields a route changes: model, thinking, no in-place retry (the fallback is the retry), and its label. */
83
+ function routedFields(route: EffortRoute): Pick<AgentRequest, "model" | "thinking" | "retries" | "routedFrom" | "route"> {
84
+ return { model: route.model, thinking: route.thinking, retries: 0, routedFrom: profileLabel(route.from), route: routeLabel(route) };
85
+ }
86
+
87
+ /** The Likely files block for one agent's step, or "" without hints. */
88
+ async function likelyFor(hints: FileHinter | undefined, query: string, signal: AbortSignal | undefined, context?: string): Promise<string> {
89
+ if (!hints) return "";
90
+ try {
91
+ return await hints.block(query, signal, context);
92
+ } catch {
93
+ return "";
94
+ }
95
+ }
96
+
66
97
  function toOutcome(run: AgentRun, domain: Domain): ScoutOutcome {
67
98
  if (run.status !== "success") {
68
99
  return {
@@ -78,20 +109,36 @@ function toOutcome(run: AgentRun, domain: Domain): ScoutOutcome {
78
109
 
79
110
  /** Run the selected domain scouts concurrently and persist their findings. */
80
111
  export async function runScouts(request: ScoutRequest, run: ProcessRunner = spawnPiProcess): Promise<ScoutOutcome[]> {
81
- const requests: AgentRequest[] = request.domains.map((domain) => ({
112
+ // Each scout gets the files most likely to answer its own instruction and focus.
113
+ const likely = await Promise.all(request.domains.map((domain) => likelyFor(request.hints, `${request.instruction}\n${domainSpec(domain).scoutFocus}`, request.signal, request.taskText)));
114
+ const extraTools = request.hints?.tools() ?? [];
115
+ const requests: AgentRequest[] = request.domains.map((domain, index) => ({
82
116
  taskId: request.taskId,
83
117
  domain,
84
118
  role: "scout",
85
119
  instruction: scoutInstruction(request, domain),
86
- context: scoutContext(request, domain),
120
+ context: scoutContext(request, domain, likely[index]),
121
+ ...(extraTools.length > 0 ? { extraTools } : {}),
87
122
  ...profileFields(request.config, request.profile, domain, "scout"),
88
123
  cwd: request.cwd,
89
124
  signal: request.signal,
90
125
  onUpdate: request.onUpdate,
91
126
  ...watchdogOptions(request.config.workflow),
92
127
  }));
93
- const runs = await runParallel(requests, request.config.workflow.maxParallelScouts, run);
94
- const outcomes = runs.map((agentRun) => toOutcome(agentRun, agentRun.domain));
128
+ // Scouts think at a fixed level; a trivial one may move to the cheaper model.
129
+ const routes = await Promise.all(requests.map((entry) => routeFor(request.effort, entry.instruction, { ...(entry.model ? { model: entry.model } : {}), thinking: entry.thinking ?? "low" }, { thinkingFixed: true, context: request.taskText, ...(request.signal ? { signal: request.signal } : {}) })));
130
+ const first = requests.map((entry, index) => (routes[index] ? { ...entry, ...routedFields(routes[index]!) } : entry));
131
+ const runs = await runParallel(first, request.config.workflow.maxParallelScouts, run);
132
+ let outcomes = runs.map((agentRun) => toOutcome(agentRun, agentRun.domain));
133
+ // A routed scout that came back unusable runs again on its configured model.
134
+ const again = outcomes.map((outcome, index) => (routes[index] && !outcome.usable && outcome.run.status !== "cancelled" && !request.signal?.aborted ? index : -1)).filter((index) => index >= 0);
135
+ if (again.length > 0) {
136
+ const reruns = await runParallel(again.map((index) => requests[index]!), request.config.workflow.maxParallelScouts, run);
137
+ outcomes = outcomes.map((outcome, index) => {
138
+ const position = again.indexOf(index);
139
+ return position >= 0 ? toOutcome(reruns[position]!, reruns[position]!.domain) : outcome;
140
+ });
141
+ }
95
142
  saveScoutResults(request.taskDir, outcomes);
96
143
  return outcomes;
97
144
  }
@@ -152,9 +199,13 @@ export interface WorkerRequest {
152
199
  profile?: ProfileResolver;
153
200
  signal?: AbortSignal;
154
201
  onUpdate?: (run: AgentRun) => void;
202
+ /** Likely files for the worker's step, and the lookup tool, while the classifier's file hints are on. */
203
+ hints?: FileHinter;
204
+ /** Lowers thinking (or the model) for a step the classifier judges simple or trivial. */
205
+ effort?: EffortRouter;
155
206
  }
156
207
 
157
- function workerWorkflowContext(request: WorkerRequest): string {
208
+ function workerWorkflowContext(request: WorkerRequest, likely = ""): string {
158
209
  const spec = domainSpec(request.domain);
159
210
  const own = request.scoutOutcomes.filter(
160
211
  (outcome) => outcome.result.domain === request.domain && outcome.usable,
@@ -166,7 +217,8 @@ function workerWorkflowContext(request: WorkerRequest): string {
166
217
  own.length > 0
167
218
  ? `Scout findings for your domain:\n${summarizeOutcomes(own, 1500)}`
168
219
  : "No scout findings were collected for your domain; verify the repository yourself.",
169
- ].join("\n\n");
220
+ likely,
221
+ ].filter(Boolean).join("\n\n");
170
222
  }
171
223
 
172
224
  /** Delegate one implementation step to a domain worker. */
@@ -176,28 +228,42 @@ export async function runWorker(
176
228
  ): Promise<WorkerOutcome> {
177
229
  const slices = readAgentKnowledge(request.dataRoots, request.domain);
178
230
  const selected = selectKnowledge(`${request.taskText} ${request.instruction}`, slices);
179
- const agentRun = await runAgent(
180
- {
181
- taskId: request.taskId,
182
- domain: request.domain,
183
- role: "worker",
184
- instruction: request.instruction,
185
- context: {
186
- task: request.taskText,
187
- ...selected,
188
- instructions: request.config.agents[request.domain].instructions,
189
- workflowContext: workerWorkflowContext(request),
190
- },
191
- ...profileFields(request.config, request.profile, request.domain, "worker"),
192
- cwd: request.cwd,
193
- signal: request.signal,
194
- onUpdate: request.onUpdate,
195
- ...watchdogOptions(request.config.workflow),
196
- ...request.agent,
231
+ const configured = profileFields(request.config, request.profile, request.domain, "worker");
232
+ const [likely, route] = await Promise.all([
233
+ likelyFor(request.hints, request.instruction, request.signal, request.taskText),
234
+ routeFor(request.effort, request.instruction, { ...(configured.model ? { model: configured.model } : {}), thinking: configured.thinking }, { context: request.taskText, ...(request.signal ? { signal: request.signal } : {}) }),
235
+ ]);
236
+ const hintTools = request.hints?.tools() ?? [];
237
+ const extraTools = [...(request.agent?.extraTools ?? []), ...hintTools];
238
+ const base: AgentRequest = {
239
+ taskId: request.taskId,
240
+ domain: request.domain,
241
+ role: "worker",
242
+ instruction: request.instruction,
243
+ context: {
244
+ task: request.taskText,
245
+ ...selected,
246
+ instructions: request.config.agents[request.domain].instructions,
247
+ workflowContext: workerWorkflowContext(request, likely),
197
248
  },
198
- run,
199
- );
200
- const result = parseWorkerResult(request.domain, agentRun.output);
249
+ ...configured,
250
+ cwd: request.cwd,
251
+ signal: request.signal,
252
+ onUpdate: request.onUpdate,
253
+ ...watchdogOptions(request.config.workflow),
254
+ ...request.agent,
255
+ ...(extraTools.length > 0 ? { extraTools } : {}),
256
+ };
257
+ let outcome = workerOutcome(request.domain, await runAgent(route ? { ...base, ...routedFields(route) } : base, run));
258
+ // A routed step that fell short runs again at the configured model and thinking.
259
+ if (route && fellShort(outcome.run, outcome.issues) && !request.signal?.aborted) {
260
+ outcome = workerOutcome(request.domain, await runAgent(base, run));
261
+ }
262
+ return outcome;
263
+ }
264
+
265
+ function workerOutcome(domain: Domain, agentRun: AgentRun): WorkerOutcome {
266
+ const result = parseWorkerResult(domain, agentRun.output);
201
267
  const issues =
202
268
  agentRun.status === "success"
203
269
  ? validateWorkerResult(result)
@@ -19,7 +19,8 @@ import { readFirstExisting } from "../knowledge/store.ts";
19
19
  import { overThreshold } from "../knowledge/compactor.ts";
20
20
  import { applyApprovalChoice, describeTask, describeOversizedKnowledge, type ApprovalChoice } from "../workflow/workflow.ts";
21
21
  import { applyStatus, registerRevealShortcut, setMinimized } from "./ui.ts";
22
- import { openSettings } from "./settings-ui.ts";
22
+ import { classifierSummary, openSettings } from "./settings-ui.ts";
23
+ import { keyStatus } from "../classifier/instance.ts";
23
24
  import { kickoff, startPlannedTask, startTask } from "./start-task.ts";
24
25
  import { setAuto, toggleOwnAuto } from "./owner.ts";
25
26
  import { autoOpenLobby, showLobby } from "../lobby/runtime.ts";
@@ -197,7 +198,8 @@ function showConfig(ctx: ExtensionCommandContext): void {
197
198
  const warnings = thinkingMismatches(config, modelLookup(ctx), ctx.model ? modelRef(ctx.model) : undefined);
198
199
  if (hasScoutThinking(readRawConfig())) warnings.push(`Scout: thinking is fixed at "${SCOUT_THINKING}"; the scout.thinking value in the file is ignored.`);
199
200
  const notes = warnings.length > 0 ? `\n\nWarnings:\n${warnings.map((line) => `- ${line}`).join("\n")}` : "";
200
- ctx.ui.notify(`${globalConfigPath()}\n${JSON.stringify(config, null, 2)}${notes}`, warnings.length > 0 ? "warning" : "info");
201
+ const jev = `Classifier: ${classifierSummary(config, keyStatus)}`;
202
+ ctx.ui.notify(`${globalConfigPath()}\n${jev}\n${JSON.stringify(config, null, 2)}${notes}`, warnings.length > 0 ? "warning" : "info");
201
203
  }
202
204
 
203
205
  /**
package/src/pi/events.ts CHANGED
@@ -11,9 +11,19 @@ import { applyStatus, clearStatus, isMinimized, setMinimized, setOracleActivity
11
11
  import { ORACLE_THINKING, oracleActivityWord } from "./activity.ts";
12
12
  import { isSubagentProcess, visibleTools } from "./quiet.ts";
13
13
  import { registerQuietTools } from "./tool-renderers.ts";
14
- import { taskRequest, type Task } from "../schemas/task.ts";
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
+ import { triageContext } from "../classifier/triage.ts";
18
+ import { previousTaskNote } from "./fresh-context.ts";
19
+
20
+ /** States in which the triage still helps the Master shape the task; once it is planned, the hints are noise. */
21
+ const SHAPING_STATES: ReadonlySet<TaskState> = new Set(["created", "clarifying", "scouting", "synthesizing", "awaiting_approval"]);
22
+
23
+ /** The Master's workflow context: the task's state, plus the classifier's triage while the task is being shaped. */
24
+ export function masterWorkflowContext(task: Task): string {
25
+ return [describeTask(task), SHAPING_STATES.has(task.state) ? triageContext(task.triage) : ""].filter(Boolean).join("\n\n");
26
+ }
17
27
 
18
28
  export function masterTaskContext(task: Task, comments: readonly PlanComment[] = [], auto = false): string {
19
29
  const open = pendingComments(comments);
@@ -83,9 +93,13 @@ export function registerLifecycle(pi: ExtensionAPI, configDir: string): void {
83
93
  pi.on("before_agent_start", (event, ctx) => {
84
94
  const root = detectProjectRoot(ctx.cwd, configDir);
85
95
  const sessionId = ctx.sessionManager.getSessionId();
86
- const task = isSubagentProcess() || isMinimized() ? undefined : activeTask(root, configDir, sessionId);
96
+ const hidden = isSubagentProcess() || isMinimized();
97
+ const task = hidden ? undefined : activeTask(root, configDir, sessionId);
87
98
  if (!task) {
88
- delete event.systemPromptOptions.sections["bot-lobby"];
99
+ // After a task ended and cleared the context, a line on where its record is, in case the user refers back to it.
100
+ const previous = hidden ? undefined : previousTaskNote(ctx, configDir);
101
+ if (previous) event.systemPromptOptions.sections["bot-lobby"] = previous;
102
+ else delete event.systemPromptOptions.sections["bot-lobby"];
89
103
  return;
90
104
  }
91
105
  const slices = readAgentKnowledge(readDataRoots(root, configDir), "master");
@@ -96,7 +110,7 @@ export function registerLifecycle(pi: ExtensionAPI, configDir: string): void {
96
110
  standards: selected.standards,
97
111
  knowledge: selected.knowledge,
98
112
  decisions: selected.decisions,
99
- workflowContext: describeTask(task),
113
+ workflowContext: masterWorkflowContext(task),
100
114
  instructions: loadConfig().master.instructions,
101
115
  });
102
116
  });