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

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 (40) hide show
  1. package/README.md +177 -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 +6 -0
  22. package/src/lobby/planner.ts +305 -27
  23. package/src/lobby/quickfix.ts +120 -8
  24. package/src/lobby/runtime.ts +12 -1
  25. package/src/lobby/tabs/metrics.ts +28 -1
  26. package/src/lobby/tabs/plan.ts +27 -4
  27. package/src/lobby/tabs/quickfix.ts +5 -1
  28. package/src/lobby/view.ts +28 -4
  29. package/src/master/master.ts +95 -29
  30. package/src/pi/commands.ts +4 -2
  31. package/src/pi/events.ts +11 -2
  32. package/src/pi/run-summary.ts +3 -1
  33. package/src/pi/settings-ui.ts +138 -2
  34. package/src/pi/start-task.ts +4 -0
  35. package/src/pi/tools.ts +7 -2
  36. package/src/schemas/configuration.ts +125 -1
  37. package/src/schemas/findings.ts +4 -0
  38. package/src/schemas/task.ts +22 -0
  39. package/src/state/metrics.ts +74 -1
  40. package/src/workflow/workflow.ts +49 -1
@@ -10,13 +10,18 @@ import { runPiAgent, spawnPiProcess, type PiStreamEvent, type ProcessRunner } fr
10
10
  import { describeToolCall } from "../pi/activity.ts";
11
11
  import { appendMetrics, type MetricRecord } from "../state/metrics.ts";
12
12
  import type { LobbyFeed } from "./feed.ts";
13
+ import type { FileHinter } from "../classifier/files.ts";
14
+ import type { Classifier } from "../classifier/classifier.ts";
15
+ import { quickFixSize } from "../classifier/triage.ts";
16
+ import { fellShort, profileLabel, routeLabel, type EffortRoute, type EffortRouter } from "../classifier/effort.ts";
13
17
 
14
18
  /** A quick fix edits code, so it gets the full coding tool set. */
15
19
  export const QUICK_FIX_TOOLS: readonly string[] = ["read", "bash", "edit", "write", "grep", "find", "ls"];
16
20
 
17
21
  export const QUICK_FIX_SOURCE = "QUICK FIX";
18
22
 
19
- export type QuickFixStatus = "queued" | "running" | "success" | "failed" | "cancelled" | "timeout";
23
+ /** `held`: the classifier judged the request a task, not a quick fix; it waits for you (r runs it anyway, t makes it a task). */
24
+ export type QuickFixStatus = "queued" | "running" | "success" | "failed" | "cancelled" | "timeout" | "held";
20
25
 
21
26
  export interface QuickFixStep {
22
27
  at: number;
@@ -39,6 +44,13 @@ export interface QuickFixJob {
39
44
  report?: string;
40
45
  error?: string;
41
46
  usage?: { input: number; output: number; cost: number; turns: number };
47
+ /** Why the job was held, or what became of it. */
48
+ note?: string;
49
+ /** Run even if the classifier judges it large (r on a held job). */
50
+ force?: boolean;
51
+ /** The classifier routed it down (`trivial 0.88: p/big · low → p/cheap · low`); cleared when it re-ran on the configured profile. */
52
+ route?: string;
53
+ routedFrom?: string;
42
54
  }
43
55
 
44
56
  export interface QuickFixProfile {
@@ -61,6 +73,12 @@ export interface QuickFixDeps {
61
73
  /** Called after every change so the lobby can repaint. */
62
74
  onChange?: () => void;
63
75
  notify?: (message: string, level: "info" | "warning" | "error") => void;
76
+ /** Likely files for the prompt, and the lookup tool, while the classifier's file hints are on. */
77
+ hints?: FileHinter;
78
+ /** Holds a request it judges large (a task, not a quick fix) instead of spending a run on it. */
79
+ classifier?: Classifier;
80
+ /** Lowers thinking or the model for a request the classifier judges simple or trivial. */
81
+ effort?: EffortRouter;
64
82
  }
65
83
 
66
84
  export const MAX_JOBS = 30;
@@ -120,6 +138,30 @@ export class QuickFixQueue {
120
138
  return true;
121
139
  }
122
140
 
141
+ /** Run a held job anyway: it goes back in the queue and is not sized again. */
142
+ runAnyway(id: string): boolean {
143
+ const job = this.jobs.find((entry) => entry.id === id);
144
+ if (!job || job.status !== "held") return false;
145
+ job.status = "queued";
146
+ job.force = true;
147
+ delete job.note;
148
+ delete job.startedAt;
149
+ delete job.finishedAt;
150
+ this.changed();
151
+ this.pump();
152
+ return true;
153
+ }
154
+
155
+ /** A held job that became a task: it leaves the queue with a note. */
156
+ movedToTask(id: string): boolean {
157
+ const job = this.jobs.find((entry) => entry.id === id);
158
+ if (!job || job.status !== "held") return false;
159
+ job.status = "cancelled";
160
+ job.note = "started as a task in a new session";
161
+ this.changed();
162
+ return true;
163
+ }
164
+
123
165
  /** Abort everything (session shutdown). */
124
166
  cancelAll(): void {
125
167
  for (const job of this.jobs) if (job.status === "queued") job.status = "cancelled";
@@ -162,17 +204,23 @@ export class QuickFixQueue {
162
204
  job.startedAt = Date.now();
163
205
  job.thinking = profile.thinking;
164
206
  if (profile.model) job.model = profile.model;
165
- this.deps.feed?.log(QUICK_FIX_SOURCE, `started: ${jobTitle(job)}`, "info", job.startedAt);
166
207
  this.changed();
208
+ const held = job.force ? undefined : await this.tooLarge(job, controller.signal);
209
+ if (held) {
210
+ this.controllers.delete(job.id);
211
+ return this.hold(job, held);
212
+ }
213
+ this.deps.feed?.log(QUICK_FIX_SOURCE, `started: ${jobTitle(job)}`, "info", job.startedAt);
167
214
  try {
168
- const result = await runPiAgent(
215
+ const [likely, route] = await Promise.all([this.likely(job.prompt, controller.signal), this.route(job.prompt, profile, controller.signal)]);
216
+ const attempt = (model: string | undefined, thinking: string) => runPiAgent(
169
217
  {
170
218
  cwd: this.deps.cwd,
171
- task: job.prompt,
219
+ task: likely ? `${job.prompt}\n\n${likely}` : job.prompt,
172
220
  systemPrompt: quickFixPrompt(profile.instructions),
173
- tools: QUICK_FIX_TOOLS,
174
- model: profile.model,
175
- thinking: profile.thinking,
221
+ tools: [...QUICK_FIX_TOOLS, ...(this.deps.hints?.tools() ?? [])],
222
+ model,
223
+ thinking,
176
224
  timeoutMs: profile.timeoutMs,
177
225
  signal: controller.signal,
178
226
  stallTimeoutMs: this.deps.stallTimeoutMs,
@@ -181,6 +229,27 @@ export class QuickFixQueue {
181
229
  },
182
230
  this.deps.runProcess ?? spawnPiProcess,
183
231
  );
232
+ let result;
233
+ if (route) {
234
+ job.route = routeLabel(route);
235
+ job.routedFrom = profileLabel(route.from);
236
+ job.thinking = route.thinking;
237
+ if (route.model) job.model = route.model;
238
+ this.changed();
239
+ result = await attempt(route.model, route.thinking);
240
+ if (fellShort(result) && !controller.signal.aborted) {
241
+ // The routed attempt counts in the metrics on its own; the job re-runs on the configured profile.
242
+ appendMetrics(this.deps.root, this.deps.configDir, [{ ...quickFixMetric({ ...job, status: result.status, finishedAt: Date.now(), usage: result.usage, ...(result.model ? { model: result.model } : {}) }), id: `${job.id}-routed-${job.startedAt}` }]);
243
+ this.addStep(job, "the routed attempt fell short; running again on the configured model and thinking", Date.now());
244
+ delete job.route;
245
+ delete job.routedFrom;
246
+ job.thinking = profile.thinking;
247
+ if (profile.model) job.model = profile.model;
248
+ result = await attempt(profile.model, profile.thinking);
249
+ }
250
+ } else {
251
+ result = await attempt(profile.model, profile.thinking);
252
+ }
184
253
  job.status = result.status;
185
254
  job.report = result.output.trim() || undefined;
186
255
  job.error = result.error;
@@ -195,6 +264,48 @@ export class QuickFixQueue {
195
264
  }
196
265
  }
197
266
 
267
+ /** Where the classifier routes this request, or undefined. */
268
+ private async route(prompt: string, profile: QuickFixProfile, signal: AbortSignal): Promise<EffortRoute | undefined> {
269
+ try {
270
+ return await this.deps.effort?.route(prompt, { ...(profile.model ? { model: profile.model } : {}), thinking: profile.thinking }, { signal });
271
+ } catch {
272
+ return undefined;
273
+ }
274
+ }
275
+
276
+ /** Why a request is too large for a quick fix, when the classifier is sure; undefined runs it. */
277
+ private async tooLarge(job: QuickFixJob, signal: AbortSignal): Promise<string | undefined> {
278
+ const jev = this.deps.classifier;
279
+ if (!jev?.enabled("triage")) return undefined;
280
+ try {
281
+ const sized = await quickFixSize(jev, job.prompt, signal);
282
+ if (!sized || sized.size !== "large" || sized.confidence < jev.config.thresholds.quickFixLargeAt) return undefined;
283
+ return `looks like a task (large, ${sized.confidence.toFixed(2)})`;
284
+ } catch {
285
+ return undefined;
286
+ }
287
+ }
288
+
289
+ /** Hold a job instead of running it; nothing ran, so nothing lands in the metrics. */
290
+ private hold(job: QuickFixJob, note: string): void {
291
+ job.status = "held";
292
+ job.note = note;
293
+ job.finishedAt = Date.now();
294
+ this.deps.feed?.log(QUICK_FIX_SOURCE, `held: ${jobTitle(job)} — ${note}; r runs it anyway, t makes it a task`, "warning", job.finishedAt);
295
+ this.deps.notify?.(`bot-lobby quick fix held: ${jobTitle(job)} ${note}`, "warning");
296
+ this.changed();
297
+ this.pump();
298
+ }
299
+
300
+ /** The Likely files block for a prompt, or "" (no hints, nothing stands out, out of time). */
301
+ private async likely(prompt: string, signal: AbortSignal): Promise<string> {
302
+ try {
303
+ return (await this.deps.hints?.block(prompt, signal)) ?? "";
304
+ } catch {
305
+ return "";
306
+ }
307
+ }
308
+
198
309
  private finish(job: QuickFixJob): void {
199
310
  job.finishedAt = Date.now();
200
311
  for (const step of job.steps) step.pending = false;
@@ -217,11 +328,12 @@ export function quickFixMetric(job: QuickFixJob): MetricRecord {
217
328
  agent: QUICK_FIX_SOURCE,
218
329
  ...(job.model ? { model: job.model } : {}),
219
330
  ...(job.thinking ? { thinking: job.thinking } : {}),
220
- status: job.status === "queued" || job.status === "running" ? "failed" : job.status,
331
+ status: job.status === "queued" || job.status === "running" ? "failed" : job.status === "held" ? "cancelled" : job.status,
221
332
  startedAt: new Date(started).toISOString(),
222
333
  durationMs: Math.max(0, (job.finishedAt ?? started) - started),
223
334
  turns: job.usage?.turns || job.turns || undefined,
224
335
  tools: job.tools,
225
336
  ...(job.usage ? { input: job.usage.input, output: job.usage.output, cost: job.usage.cost } : {}),
337
+ ...(job.routedFrom ? { routedFrom: job.routedFrom } : {}),
226
338
  };
227
339
  }
@@ -18,7 +18,7 @@ import { sendToInbox, sendToSession as leaveForSession } from "../state/inbox.ts
18
18
  import { livePresence } from "../state/presence.ts";
19
19
  import { archiveTask as archiveTaskOnDisk, deleteTask as deleteTaskOnDisk, listArchivedTasks, restoreTask as restoreTaskOnDisk } from "../state/archive.ts";
20
20
  import { discardPlannedTask, listPlannedTasks, type PlannedTask } from "../state/backlog.ts";
21
- import { appendMetrics, readMetrics, type MetricStatus } from "../state/metrics.ts";
21
+ import { appendMetrics, readClassifierMetrics, readMetrics, type MetricStatus } from "../state/metrics.ts";
22
22
  import { describeToolCall } from "../pi/activity.ts";
23
23
  import { applyStatus, currentZenTask, isMinimized, onMinimizeChange, onRunUpdates, persistedRuns, setMinimized, setWidgetSuppressor, ZenScene, zenSnapshot } from "../pi/ui.ts";
24
24
  import { panelLines } from "../pi/zen.ts";
@@ -29,6 +29,8 @@ import { modelLookup } from "../pi/tools.ts";
29
29
  import { startPlannedTask, startTask } from "../pi/start-task.ts";
30
30
  import type { LobbyAgentKind, LobbyPanel, PanelMember } from "../schemas/configuration.ts";
31
31
  import { chatFromEntries, lobbyFeed, narrateEvent, type AgentEventLike } from "./feed.ts";
32
+ import { classifier, effortFor, hintsFor } from "../classifier/instance.ts";
33
+ import { checkThinking } from "../pi/model-support.ts";
32
34
  import { launchPi, SessionRegistry, type BackgroundSession, type SessionLauncher } from "./sessions.ts";
33
35
  import { SessionChats } from "./session-files.ts";
34
36
  import { answerMessage, dialogAsker, loadAskTool, questionnaires, toolAsker, type Asker } from "./ask.ts";
@@ -415,6 +417,10 @@ function newPlanner(state: Runtime, seed?: PlannerSeed, seats?: readonly PanelMe
415
417
  profile: () => lobbyProfile(state, "planner"),
416
418
  memberProfile: (member) => seatProfile(state, member),
417
419
  panel: seats ?? config.lobby.planningPanel,
420
+ maxRounds: () => loadConfig().lobby.maxPlanningRounds,
421
+ classifier: classifier(),
422
+ hints: hintsFor({ cwd: state.ctx.cwd, root: state.root, configDir: state.configDir }),
423
+ effort: effortFor((model, thinking) => checkThinking(modelLookup(state.ctx)(model), thinking).level),
418
424
  stallTimeoutMs: workflow.stallTimeoutMs,
419
425
  toolStallTimeoutMs: workflow.toolStallTimeoutMs,
420
426
  feed: lobbyFeed,
@@ -513,6 +519,7 @@ function host(state: Runtime, tui: TUI): LobbyHost {
513
519
  plans: () => listPlannedTasks(state.root, state.configDir),
514
520
  comments: (taskId) => readPlanComments(state.root, state.configDir, taskId),
515
521
  metrics: () => readMetrics(state.root, state.configDir),
522
+ classifierMetrics: () => readClassifierMetrics(state.root, state.configDir),
516
523
  toOracle: (text) => toOracle(state, text),
517
524
  comment: (taskId, text) => addComment(state, taskId, text),
518
525
  startPlanned: (plan) => startPlanned(state, plan),
@@ -524,6 +531,7 @@ function host(state: Runtime, tui: TUI): LobbyHost {
524
531
  newPlanner: (seed, seats) => newPlanner(state, seed, seats),
525
532
  answerPanel: () => answerPanel(state),
526
533
  defaultPanel: () => loadConfig().lobby.planningPanel,
534
+ planningRounds: () => loadConfig().lobby.maxPlanningRounds,
527
535
  issuesEnabled: () => loadConfig().lobby.issues,
528
536
  panels: () => loadConfig().lobby.panels,
529
537
  savePanels: (panels) => savePanels(panels),
@@ -728,6 +736,9 @@ export function initLobby(pi: ExtensionAPI, ctx: ExtensionContext, configDir: st
728
736
  notify: (message, level) => {
729
737
  if (!state.visible) ctx.ui.notify(message, level);
730
738
  },
739
+ hints: hintsFor({ cwd: ctx.cwd, root, configDir }),
740
+ classifier: classifier(),
741
+ effort: effortFor((model, thinking) => checkThinking(modelLookup(ctx)(model), thinking).level),
731
742
  });
732
743
  runtime = state;
733
744
  lobbyFeed.clear();
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import { shortDuration } from "../../text.ts";
10
10
  import { tokens } from "../../pi/run-summary.ts";
11
- import type { GroupBy, MetricGroup, MetricRecord, SortKey, TaskStats, TaskTimeGroup } from "../../state/metrics.ts";
11
+ import type { ClassifierSummary, GroupBy, MetricGroup, MetricRecord, SortKey, TaskStats, TaskTimeGroup } from "../../state/metrics.ts";
12
12
  import { bar, beside, bold, box, fill, fit, meter, notePane, paint, selectRow, sparkline, stackedBar, windowStart, wrap, type LobbyTheme, type PaneLayout } from "../layout.ts";
13
13
  import { sourceColor } from "./home.ts";
14
14
 
@@ -25,6 +25,8 @@ export interface MetricsTabInput {
25
25
  query?: string;
26
26
  /** Filled with where the table landed, for the wheel. */
27
27
  panes?: PaneLayout;
28
+ /** What the classifier did and spared, when it has run. */
29
+ classifier?: ClassifierSummary;
28
30
  }
29
31
 
30
32
  interface Column {
@@ -230,6 +232,27 @@ export function timeShare(records: readonly MetricRecord[], taskTimes: readonly
230
232
  }
231
233
 
232
234
  /** The metrics dashboard: KPI tiles, the charts, and the full table filling what is left. */
235
+ /** Milliseconds as `180 ms` or `1.2s`: classifier calls are short. */
236
+ function millis(ms: number): string {
237
+ return ms < 1000 ? `${Math.round(ms)} ms` : `${(ms / 1000).toFixed(1)}s`;
238
+ }
239
+
240
+ /** The classifier's box: its calls and speed, then what its decisions spared. */
241
+ export function classifierLines(summary: ClassifierSummary, width: number, theme?: LobbyTheme): string[] {
242
+ const inner = width - 4;
243
+ const purposes = Object.entries(summary.byPurpose).sort((a, b) => b[1] - a[1]).map(([purpose, count]) => `${purpose} ${count}`).join(" · ");
244
+ const calls = summary.calls > 0
245
+ ? [`${summary.calls} call${summary.calls === 1 ? "" : "s"}`, `${percent(summary.ok, summary.calls)} ok`, `p50 ${millis(summary.p50Ms)}`, `p90 ${millis(summary.p90Ms)}`, summary.input > 0 ? `${tokens(summary.input)} tokens read` : "", purposes].filter(Boolean).join(paint(theme, "dim", " · "))
246
+ : paint(theme, "dim", "no calls recorded");
247
+ const spared = [
248
+ `${summary.seatRunsSkipped} seat run${summary.seatRunsSkipped === 1 ? "" : "s"} skipped`,
249
+ `${summary.questionsAnswered} question${summary.questionsAnswered === 1 ? "" : "s"} answered`,
250
+ `${summary.quickFixesHeld} quick fix${summary.quickFixesHeld === 1 ? "" : "es"} held`,
251
+ summary.routed > 0 ? `${summary.routed} run${summary.routed === 1 ? "" : "s"} routed down, ${percent(summary.routedOk, summary.routed)} ok` : "no runs routed",
252
+ ].join(paint(theme, "dim", " · "));
253
+ return box(width, 4, [fit(calls, inner), fit(`${paint(theme, "success", "spared")} ${spared}`, inner)], { title: "Classifier (Jev)", theme });
254
+ }
255
+
233
256
  export function renderMetrics(input: MetricsTabInput, width: number, height: number, theme?: LobbyTheme): string[] {
234
257
  if (height <= 0) return [];
235
258
  if (input.records.length === 0) {
@@ -242,6 +265,10 @@ export function renderMetrics(input: MetricsTabInput, width: number, height: num
242
265
  sections.push(...tileRow(input, width, theme));
243
266
  room -= 4;
244
267
  }
268
+ if (input.classifier && room >= 14) {
269
+ sections.push(...classifierLines(input.classifier, width, theme));
270
+ room -= 4;
271
+ }
245
272
  const chartHeight = Math.min(input.groups.length + 2, 8);
246
273
  if (room - chartHeight >= 9) {
247
274
  if (width >= 100) {
@@ -7,7 +7,7 @@
7
7
  * said the plan must respect.
8
8
  */
9
9
  import { stripTerminalSequences } from "@earendil-works/pi-tui";
10
- import { ORACLE_LABEL, type LineComment, type PanelNote, type PanelQuestion, type PlannerMessage, type PlannerReply, type PlannerSeed } from "../planner.ts";
10
+ import { ORACLE_LABEL, type LineComment, type PanelNote, type PanelQuestion, type PlannerMessage, type PlannerReply, type PlannerSeed, type RoundMode } from "../planner.ts";
11
11
  import type { PlannedTask } from "../../state/backlog.ts";
12
12
  import { beside, bold, box, clock, fill, fit, italic, markdownLines, notePane, paint, selectRow, spinner, wrap, wrapHanging, type LobbyTheme, type PaneLayout } from "../layout.ts";
13
13
  import { sourceColor, speakerLine, tailWindow, youLines } from "./home.ts";
@@ -23,6 +23,10 @@ export interface SeatView {
23
23
  ready: boolean;
24
24
  /** `provider/model · thinking` the seat runs on. */
25
25
  profile?: string;
26
+ /** The classifier left it out of the latest round: how likely it judged the seat needed. */
27
+ satOut?: number;
28
+ /** Seated with a key press: it sits every round, whatever the classifier says. */
29
+ pinned?: boolean;
26
30
  }
27
31
 
28
32
  export interface PlanView {
@@ -35,6 +39,10 @@ export interface PlanView {
35
39
  step?: string;
36
40
  error?: string;
37
41
  turns: number;
42
+ /** Rounds before the oracle finalizes alone; 0 = unlimited. */
43
+ limit?: number;
44
+ /** How the next round will run under the limit. */
45
+ nextMode?: RoundMode;
38
46
  seed?: PlannerSeed;
39
47
  saved?: PlannedTask;
40
48
  title?: string;
@@ -79,6 +87,8 @@ export interface PlanTabInput {
79
87
  panes?: PaneLayout;
80
88
  /** The key that saves the plan, as the status line names it. */
81
89
  saveKey?: string;
90
+ /** The planning round limit before a session exists; 0 = unlimited. */
91
+ limit?: number;
82
92
  }
83
93
 
84
94
  export const PLAN_COLUMNS_MIN = 100;
@@ -91,19 +101,28 @@ function intro(input: PlanTabInput, width: number, theme?: LobbyTheme): string[]
91
101
  const model = seat.label === ORACLE_LABEL ? input.profile : seat.seated ? seat.profile ?? "" : "not seated";
92
102
  return fit(`${name}${paint(theme, seat.seated ? "muted" : "dim", model)}`, width);
93
103
  });
94
- return [...wrap(bold(theme, "Describe a task below and the panel questions you until the plan is clear."), width), "", ...rows];
104
+ const bound = input.limit && input.limit > 0 ? ` (at most ${input.limit} rounds; the last one the oracle settles alone)` : "";
105
+ return [...wrap(bold(theme, `Describe a task below and the panel questions you until the plan is clear${bound}.`), width), "", ...rows];
106
+ }
107
+
108
+ /** `round 3/5`, `final round 5/5` or `round 6 · revising` under a limit; `round 3` without one. */
109
+ export function roundLabel(turns: number, limit = 0): string {
110
+ if (limit <= 0) return `round ${turns}`;
111
+ if (turns > limit) return `round ${turns} · past the limit, revising`;
112
+ return turns === limit ? `final round ${turns}/${limit}` : `round ${turns}/${limit}`;
95
113
  }
96
114
 
97
115
  function statusLine(view: PlanView, input: PlanTabInput, theme?: LobbyTheme): string {
98
116
  const parts: string[] = [];
99
117
  const count = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`;
100
- if (view.busy) parts.push(`${paint(theme, "accent", spinner(input.tick))} round ${view.turns}`);
118
+ if (view.busy) parts.push(`${paint(theme, "accent", spinner(input.tick))} ${roundLabel(view.turns, view.limit)}`);
101
119
  else if (view.error) parts.push(paint(theme, "error", `✗ ${view.error.split("\n")[0]} — r retries`));
102
120
  else if (view.reply?.status === "ready") parts.push(paint(theme, "success", `✓ ready — ${input.saveKey ?? "Ctrl+S"} saves it`));
103
121
  else if (view.awaitingAnswers) parts.push(paint(theme, "warning", `● ${count(view.questions.length, "question")} — enter ${view.answeredChunks > 0 ? "resumes" : "answers them"}`));
104
122
  if (view.lineComments.length > 0) parts.push(paint(theme, "accent", `◆ ${count(view.lineComments.length, "comment")} to send`));
105
123
  if (view.saved) parts.push(paint(theme, "success", `saved as ${view.saved.id}`));
106
- if (!view.busy && view.turns > 0) parts.push(paint(theme, "dim", `round ${view.turns}`));
124
+ if (!view.busy && view.turns > 0) parts.push(paint(theme, "dim", roundLabel(view.turns, view.limit)));
125
+ if (!view.busy && view.nextMode === "final") parts.push(paint(theme, "warning", "the next round is the last: the oracle settles the rest"));
107
126
  return parts.join(paint(theme, "dim", " · "));
108
127
  }
109
128
 
@@ -111,6 +130,7 @@ function statusLine(view: PlanView, input: PlanTabInput, theme?: LobbyTheme): st
111
130
  function seatCell(seat: SeatView, tick: number, theme?: LobbyTheme): string {
112
131
  const name = paint(theme, seat.seated ? sourceColor(seat.label) : "dim", seat.label);
113
132
  if (!seat.seated) return `${name} ${paint(theme, "dim", "off")}`;
133
+ if (seat.satOut !== undefined && seat.status !== "thinking") return `${name} ${paint(theme, "dim", `sat out · ${seat.satOut.toFixed(2)}`)}`;
114
134
  if (seat.status === "thinking") return `${name} ${paint(theme, "accent", spinner(tick))} ${paint(theme, "muted", seat.step ?? "thinking")}`;
115
135
  if (seat.status === "failed") return `${name} ${paint(theme, "error", "✗ failed — r retries")}`;
116
136
  if (seat.status === "idle") return `${name} ${paint(theme, "dim", "·")}`;
@@ -159,6 +179,9 @@ export function conversationLines(view: PlanView, width: number, theme?: LobbyTh
159
179
  }
160
180
  lines.push(speakerLine("panel", width, theme, time));
161
181
  lines.push(...(questions.length > 0 ? questionLines(questions, width, theme) : wrap(message.text, width).map((line) => (line ? ` ${line}` : ""))));
182
+ for (const entry of message.decided ?? []) {
183
+ lines.push(...wrapHanging(` ${paint(theme, "success", "✓")} `, `${paint(theme, sourceColor(entry.from), `[${entry.from}]`)} ${entry.question} → ${bold(theme, entry.answer)} ${paint(theme, "dim", `· decided by the classifier (${entry.probability.toFixed(2)}); comment on the plan to overrule`)}`, width));
184
+ }
162
185
  }
163
186
  if (query && lines.length === 0) return wrap(paint(theme, "dim", `Nothing in the conversation matches "${query}".`), width);
164
187
  return lines;
@@ -31,6 +31,7 @@ const STATUS_COLORS: Record<QuickFixJob["status"], "accent" | "success" | "error
31
31
  failed: "error",
32
32
  timeout: "error",
33
33
  cancelled: "warning",
34
+ held: "warning",
34
35
  };
35
36
 
36
37
  function mark(job: QuickFixJob, tick: number, theme?: LobbyTheme): string {
@@ -38,6 +39,7 @@ function mark(job: QuickFixJob, tick: number, theme?: LobbyTheme): string {
38
39
  if (job.status === "queued") return paint(theme, "muted", "…");
39
40
  if (job.status === "success") return paint(theme, "success", "✓");
40
41
  if (job.status === "cancelled") return paint(theme, "warning", "·");
42
+ if (job.status === "held") return paint(theme, "warning", "‖");
41
43
  return paint(theme, "error", "✗");
42
44
  }
43
45
 
@@ -60,8 +62,10 @@ export function jobDetailLines(job: QuickFixJob, width: number, tick: number, no
60
62
  job.usage?.cost ? `$${job.usage.cost.toFixed(2)}` : "",
61
63
  ].filter(Boolean);
62
64
  const lines = [paint(theme, "dim", job.id), facts.join(paint(theme, "dim", " · ")), "", ...wrap(bold(theme, job.prompt), width)];
65
+ if (job.route) lines.push("", ...wrap(paint(theme, "dim", `↓ routed ${job.route}`), width));
66
+ if (job.note) lines.push("", ...wrap(paint(theme, "warning", job.status === "held" ? `‖ Held: ${job.note}. r runs it anyway · t starts it as a task in a new session.` : job.note), width));
63
67
  lines.push("", rule(width, "Steps", theme));
64
- if (job.steps.length === 0) lines.push(paint(theme, "dim", job.status === "queued" ? "Waiting for the quick fix ahead of it." : job.status === "running" ? `${spinner(tick)} starting…` : "No tool calls."));
68
+ if (job.steps.length === 0) lines.push(paint(theme, "dim", job.status === "queued" ? "Waiting for the quick fix ahead of it." : job.status === "running" ? `${spinner(tick)} starting…` : job.status === "held" ? "Not started." : "No tool calls."));
65
69
  for (const step of job.steps) {
66
70
  const lead = `${paint(theme, "dim", clock(step.at))} ${step.pending && job.status === "running" ? paint(theme, "accent", spinner(tick)) : paint(theme, "dim", "·")} `;
67
71
  lines.push(...wrapHanging(lead, step.pending && job.status === "running" ? `${step.text}…` : paint(theme, "muted", step.text), width));
package/src/lobby/view.ts CHANGED
@@ -14,7 +14,7 @@ import { TERMINAL_STATES, type Task } from "../schemas/task.ts";
14
14
  import type { AgentRun } from "../schemas/findings.ts";
15
15
  import type { PlannedTask } from "../state/backlog.ts";
16
16
  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";
17
+ import { aggregateMetrics, collectMetrics, SORT_KEYS, sortGroups, summarizeClassifier, taskStats, taskTimesByModel, type GroupBy, type MetricRecord, type SortKey } from "../state/metrics.ts";
18
18
  import { PANEL_MEMBERS, type LobbyAgentKind, type LobbyPanel, type PanelMember } from "../schemas/configuration.ts";
19
19
  import type { ChatEntry, LobbyFeed } from "./feed.ts";
20
20
  import type { BackgroundSession } from "./sessions.ts";
@@ -133,6 +133,8 @@ export interface LobbyHost {
133
133
  plans(): PlannedTask[];
134
134
  comments(taskId: string): PlanComment[];
135
135
  metrics(): MetricRecord[];
136
+ /** Classifier calls (kind `classifier`), kept out of the agent tables. */
137
+ classifierMetrics?(): MetricRecord[];
136
138
  /** Send text to the oracle, or start a task when none is active; returns a notice. */
137
139
  toOracle(text: string): string | undefined;
138
140
  comment(taskId: string, text: string): string;
@@ -148,6 +150,8 @@ export interface LobbyHost {
148
150
  answerPanel(): Promise<string>;
149
151
  /** The seats a new session starts with, from settings. */
150
152
  defaultPanel(): readonly PanelMember[];
153
+ /** Planning rounds before the oracle finalizes alone (`lobby.maxPlanningRounds`); 0 = unlimited. */
154
+ planningRounds?(): number;
151
155
  /** `model · thinking` a panel seat runs on. */
152
156
  seatLabel(member: PanelMember): string;
153
157
  issues: IssuesState;
@@ -336,12 +340,13 @@ export class LobbyView implements Component, Focusable {
336
340
 
337
341
  /** The Tasks tab lists archived tasks too (v). */
338
342
  showArchived = false;
339
- private data: { tasks: Task[]; plans: PlannedTask[]; archived: Task[]; comments: Map<string, PlanComment[]>; metrics: MetricRecord[]; at: number } = {
343
+ private data: { tasks: Task[]; plans: PlannedTask[]; archived: Task[]; comments: Map<string, PlanComment[]>; metrics: MetricRecord[]; classifierMetrics: MetricRecord[]; at: number } = {
340
344
  tasks: [],
341
345
  plans: [],
342
346
  archived: [],
343
347
  comments: new Map(),
344
348
  metrics: [],
349
+ classifierMetrics: [],
345
350
  at: 0,
346
351
  };
347
352
 
@@ -431,6 +436,7 @@ export class LobbyView implements Component, Focusable {
431
436
  archived: this.showArchived ? this.host.archivedTasks() : [],
432
437
  comments: new Map(),
433
438
  metrics: this.tab === "metrics" ? this.host.metrics() : this.data.metrics,
439
+ classifierMetrics: this.tab === "metrics" ? this.host.classifierMetrics?.() ?? [] : this.data.classifierMetrics,
434
440
  at: now,
435
441
  };
436
442
  if (this.tab === "tasks") {
@@ -998,6 +1004,17 @@ export class LobbyView implements Component, Focusable {
998
1004
  return true;
999
1005
  }
1000
1006
  if (data === "m") return this.openSettings("quickfix"), true;
1007
+ if (data === "r" || data === "t") {
1008
+ const job = this.fixJobs()[this.fixSelected];
1009
+ // Only a held job takes r and t; otherwise the key resumes typing like any other.
1010
+ if (!job || job.status !== "held") return false;
1011
+ if (data === "r" && this.host.quickfix.runAnyway(job.id)) this.say(`running ${job.id} anyway`);
1012
+ if (data === "t") {
1013
+ this.startInNewSession({ request: job.prompt });
1014
+ this.host.quickfix.movedToTask(job.id);
1015
+ }
1016
+ return true;
1017
+ }
1001
1018
  return false;
1002
1019
  case "issues":
1003
1020
  return this.issuesCommand(data, enter, escape);
@@ -1433,7 +1450,7 @@ export class LobbyView implements Component, Focusable {
1433
1450
  seated = !seats.delete(seat);
1434
1451
  if (seated) seats.add(seat);
1435
1452
  }
1436
- this.say(`${MEMBER_LABELS[seat]} ${seated ? "joins" : "leaves"} the panel from the next round`);
1453
+ 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
1454
  return true;
1438
1455
  }
1439
1456
  if (data === "x" || (escape && session?.busy)) {
@@ -1901,6 +1918,7 @@ export class LobbyView implements Component, Focusable {
1901
1918
  break;
1902
1919
  case "quickfix":
1903
1920
  if (this.host.quickfix.jobs.length > 0) keys.push(["↑↓", "select"], ["x", "cancel"]);
1921
+ if (this.fixJobs()[this.fixSelected]?.status === "held") keys.push(["r", "run anyway"], ["t", "as a task"]);
1904
1922
  keys.push(["m", "model"]);
1905
1923
  break;
1906
1924
  case "issues":
@@ -1971,6 +1989,7 @@ export class LobbyView implements Component, Focusable {
1971
1989
  ...(session ? { session: this.planView(session) } : {}),
1972
1990
  profile: this.host.profileLabel("planner"),
1973
1991
  seats: this.seatViews(undefined),
1992
+ limit: this.host.planningRounds?.() ?? 0,
1974
1993
  offset: this.planOffset,
1975
1994
  focus: this.planFocus,
1976
1995
  draftOffset: this.planDraftOffset,
@@ -2003,7 +2022,8 @@ export class LobbyView implements Component, Focusable {
2003
2022
  const groups = sortGroups(aggregateMetrics(records, this.metricsBy), this.metricsSort);
2004
2023
  this.metricsSelected = Math.min(this.metricsSelected, Math.max(0, groups.length - 1));
2005
2024
  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);
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);
2007
2027
  }
2008
2028
  }
2009
2029
  }
@@ -2034,6 +2054,8 @@ export class LobbyView implements Component, Focusable {
2034
2054
  questions: session?.questions.filter((question) => question.from === MEMBER_LABELS[member]).length ?? 0,
2035
2055
  ready: state?.reply?.status === "ready",
2036
2056
  profile: this.host.seatLabel(member),
2057
+ ...(!state && session?.satOut.has(member) ? { satOut: session.satOut.get(member)! } : {}),
2058
+ ...(session?.pins.has(member) ? { pinned: true } : {}),
2037
2059
  };
2038
2060
  });
2039
2061
  return [oracle, ...members];
@@ -2050,6 +2072,8 @@ export class LobbyView implements Component, Focusable {
2050
2072
  ...(session.step ? { step: session.step } : {}),
2051
2073
  ...(session.error ? { error: session.error } : {}),
2052
2074
  turns: session.turns,
2075
+ limit: session.limit,
2076
+ nextMode: session.nextMode,
2053
2077
  ...(session.seed ? { seed: session.seed } : {}),
2054
2078
  ...(session.saved ? { saved: session.saved } : {}),
2055
2079
  ...(session.title ? { title: session.title } : {}),