@a-t-h-i/bot-lobby 0.6.4 → 0.6.6

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 (44) hide show
  1. package/README.md +150 -12
  2. package/package.json +12 -3
  3. package/prompts/backend.md +3 -1
  4. package/prompts/designer.md +24 -1
  5. package/prompts/global.md +18 -0
  6. package/prompts/master.md +122 -20
  7. package/prompts/quickfix.md +16 -3
  8. package/prompts/worker.md +7 -2
  9. package/src/agents/backend.ts +2 -2
  10. package/src/agents/designer.ts +2 -2
  11. package/src/ask/dialog.ts +11 -1
  12. package/src/classifier/triage.ts +24 -7
  13. package/src/index.ts +4 -1
  14. package/src/lobby/feed.ts +7 -2
  15. package/src/lobby/keys.ts +0 -1
  16. package/src/lobby/quickfix.ts +29 -3
  17. package/src/lobby/runtime.ts +29 -26
  18. package/src/lobby/tabs/home.ts +6 -42
  19. package/src/lobby/tabs/quickfix.ts +1 -1
  20. package/src/lobby/tabs/tasks.ts +3 -2
  21. package/src/lobby/view.ts +18 -23
  22. package/src/master/decisions.ts +10 -2
  23. package/src/pi/activity.ts +1 -33
  24. package/src/pi/commands.ts +22 -11
  25. package/src/pi/events.ts +3 -17
  26. package/src/pi/plan-checklist.ts +305 -0
  27. package/src/pi/route.ts +180 -0
  28. package/src/pi/run-summary.ts +12 -3
  29. package/src/pi/settings-ui.ts +2 -2
  30. package/src/pi/start-task.ts +51 -5
  31. package/src/pi/tools.ts +13 -7
  32. package/src/pi/ui.ts +18 -284
  33. package/src/roles/worker.ts +11 -3
  34. package/src/schemas/configuration.ts +19 -6
  35. package/src/schemas/task.ts +31 -0
  36. package/src/workflow/brief.ts +59 -0
  37. package/src/workflow/track.ts +436 -0
  38. package/src/workflow/workflow.ts +151 -10
  39. package/src/pi/expressions.ts +0 -169
  40. package/src/pi/kaomoji.ts +0 -227
  41. package/src/pi/mascot-art.ts +0 -359
  42. package/src/pi/zen-large.ts +0 -699
  43. package/src/pi/zen-metrics.ts +0 -130
  44. package/src/pi/zen.ts +0 -659
@@ -48,6 +48,15 @@ function sizeQuestion(subject: string) {
48
48
  return score(`How big is the change ${subject} asks for, judged by what it would take to build and verify in this repository?`, SIZE_LEVELS);
49
49
  }
50
50
 
51
+ /** Whether one agent can just do it: the quick fix or the team. */
52
+ function soloQuestion(subject: string) {
53
+ return noul(
54
+ `Could one engineer do ${subject} alone, right away — in one file or one area of the code — with no contract to agree between frontend and backend, no unfamiliar codebase to survey first, no risky change (security, data, payments, migrations) and no decision the user must make first?`,
55
+ "Yes: one person can just do it now, even if it is a rich piece of work in one place.",
56
+ "No: it needs a team — several areas, a survey of the codebase, a plan to agree, or review before it lands.",
57
+ );
58
+ }
59
+
51
60
  export function triageRequest(input: TriageInput): SystemOneRequest {
52
61
  const questions: SystemOneRequest["questions"] = {
53
62
  size: sizeQuestion("`request`"),
@@ -62,6 +71,7 @@ export function triageRequest(input: TriageInput): SystemOneRequest {
62
71
  "No: a competent engineer could build it as written, deciding details from the codebase.",
63
72
  ),
64
73
  kind: choice("What kind of work does `request` ask for?", TRIAGE_KINDS),
74
+ solo: soloQuestion("`request`"),
65
75
  };
66
76
  for (const { domain, owns } of input.domains) {
67
77
  questions[`domain_${domain}`] = noul(`Does building \`request\` require changes in this area: ${owns}?`);
@@ -85,12 +95,14 @@ export async function triageTask(classifier: Classifier, input: TriageInput, sig
85
95
  if (probability !== undefined) domains[domain] = probability;
86
96
  }
87
97
  const kind = choiceOf(result.answers, "kind");
98
+ const solo = yesOf(result.answers, "solo");
88
99
  return {
89
100
  size: TRIAGE_SIZES[Math.max(0, Math.min(TRIAGE_SIZES.length - 1, size.level))]!,
90
101
  sizeConfidence: size.confidence,
91
102
  domains,
92
103
  research: yesOf(result.answers, "needs_research") ?? 0,
93
104
  ambiguous: yesOf(result.answers, "ambiguous") ?? 0,
105
+ ...(solo !== undefined ? { solo } : {}),
94
106
  ...(kind ? { kind: kind.choice, kindProbability: kind.probability } : {}),
95
107
  at: new Date().toISOString(),
96
108
  };
@@ -131,20 +143,25 @@ export async function triageWithContext(classifier: Classifier, scope: FileScope
131
143
  return files.length > 0 ? { ...triage, likelyFiles: files } : triage;
132
144
  }
133
145
 
134
- /** The size of a quick fix prompt, or undefined when the classifier is off or fails. */
135
- export async function quickFixSize(classifier: Classifier, prompt: string, signal?: AbortSignal): Promise<{ size: TriageSize; confidence: number } | undefined> {
146
+ /**
147
+ * The size of a quick fix prompt, and how likely one engineer can do it alone,
148
+ * or undefined when the classifier is off or fails. A large prompt is held as
149
+ * a task unless it is still one engineer's work (a rich page in one file).
150
+ */
151
+ export async function quickFixSize(classifier: Classifier, prompt: string, signal?: AbortSignal): Promise<{ size: TriageSize; confidence: number; solo?: number } | undefined> {
136
152
  if (!classifier.enabled("triage")) return undefined;
137
- const largeAt = classifier.config.thresholds.quickFixLargeAt;
138
- const result = await classifier.ask("triage", { state: { prompt: clip(prompt, 6000) }, questions: { size: sizeQuestion("`prompt`") } }, {
153
+ const { quickFixLargeAt: largeAt, quickFixAt } = classifier.config.thresholds;
154
+ const result = await classifier.ask("triage", { state: { prompt: clip(prompt, 6000) }, questions: { size: sizeQuestion("`prompt`"), solo: soloQuestion("`prompt`") } }, {
139
155
  ...(signal ? { signal } : {}),
140
156
  saved: (answers) => {
141
157
  const sized = scoreOf(answers, "size");
142
- return sized && sized.level >= TRIAGE_SIZES.length - 1 && sized.confidence >= largeAt ? 1 : 0;
158
+ return sized && sized.level >= TRIAGE_SIZES.length - 1 && sized.confidence >= largeAt && (yesOf(answers, "solo") ?? 0) < quickFixAt ? 1 : 0;
143
159
  },
144
160
  });
145
161
  const size = scoreOf(result?.answers, "size");
146
162
  if (!size) return undefined;
147
- return { size: TRIAGE_SIZES[Math.max(0, Math.min(TRIAGE_SIZES.length - 1, size.level))]!, confidence: size.confidence };
163
+ const solo = yesOf(result?.answers, "solo");
164
+ return { size: TRIAGE_SIZES[Math.max(0, Math.min(TRIAGE_SIZES.length - 1, size.level))]!, confidence: size.confidence, ...(solo !== undefined ? { solo } : {}) };
148
165
  }
149
166
 
150
167
  const HINT = 0.5;
@@ -155,7 +172,7 @@ export function suggestedPath(triage: TaskTriage): string {
155
172
  if (triage.ambiguous >= HINT) return "clarify first: the request as written probably misses a decision.";
156
173
  const smallish = (triage.size === "trivial" || triage.size === "small") && triage.sizeConfidence >= 0.6;
157
174
  const steps: string[] = [];
158
- if (smallish && touched.length === 1) steps.push(`single-domain shortcut (${touched[0]}): skip the scout round and the proposal ceremony, state the short plan and delegate`);
175
+ if (smallish && touched.length === 1) steps.push(`fast track (${touched[0]}): no scouts, proposal or plan; delegate straight away`);
159
176
  else if (touched.length > 0) steps.push(`scout only ${touched.join(", ")}`);
160
177
  if (triage.research >= HINT) steps.push("summon the researcher for the outside facts");
161
178
  return steps.length > 0 ? `${steps.join("; ")}.` : "no strong signal; decide from the request.";
package/src/index.ts CHANGED
@@ -2,6 +2,7 @@ import { CONFIG_DIR_NAME, type ExtensionAPI } from "@earendil-works/pi-coding-ag
2
2
  import { registerCommands } from "./pi/commands.ts";
3
3
  import { registerLifecycle } from "./pi/events.ts";
4
4
  import { registerOrchestrateTool } from "./pi/tools.ts";
5
+ import { registerRouteTool } from "./pi/route.ts";
5
6
  import { onTransition } from "./state/task-state.ts";
6
7
  import { pingTransition } from "./pi/notify.ts";
7
8
  import { isSubagentProcess } from "./pi/quiet.ts";
@@ -20,12 +21,14 @@ export default function (pi: ExtensionAPI): void {
20
21
  // Before the lifecycle, so a task that ended while the oracle was idle is closed before it builds the next turn's prompt.
21
22
  registerFreshContext(pi, CONFIG_DIR_NAME);
22
23
  registerLifecycle(pi, CONFIG_DIR_NAME);
23
- // After the lifecycle, so the lobby opens over a task the widget state already knows.
24
+ // After the lifecycle, so the lobby opens over a task the status already knows.
24
25
  registerLobbyEvents(pi, CONFIG_DIR_NAME);
25
26
  registerOwner(pi, CONFIG_DIR_NAME);
26
27
  onTransition((task) => pingTransition(task));
27
28
  registerCommands(pi, CONFIG_DIR_NAME);
28
29
  registerOrchestrateTool(pi, CONFIG_DIR_NAME);
30
+ // A new request one agent can do alone: the oracle confirms, and the lobby hands it to the quick-fix agent.
31
+ registerRouteTool(pi, CONFIG_DIR_NAME);
29
32
  // The questionnaire the oracle (and pi without a task) asks the user with; no other extension is needed for it.
30
33
  registerAskTool(pi);
31
34
  // The web tools, for the researcher and for pi without a task (the oracle leaves them to the researcher).
package/src/lobby/feed.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * What the lobby narrates: an activity log of plain-words tool steps from the
3
3
  * Master and every subagent, a single place for thoughts, and the conversation
4
4
  * with the Master stripped of tool rows and thinking. Bounded ring buffers in
5
- * one module-level store (like the zen widget state), so every event source can
5
+ * one module-level store (like the task state in ui.ts), so every event source can
6
6
  * write without a reference to the lobby, and the lobby repaints on `version`.
7
7
  */
8
8
  import type { AgentRun } from "../schemas/findings.ts";
@@ -284,8 +284,13 @@ export function chatText(role: "user" | "assistant", text: string): Array<{ role
284
284
  const kickoff = /^A bot-lobby task is active: (\S+)\nTitle: (.*)/.exec(body);
285
285
  if (kickoff) {
286
286
  const request = /\nRequest: ([\s\S]*?)(?:\nState: |$)/.exec(body)?.[1]?.trim();
287
- return [{ role: "note", text: `task started · ${kickoff[2]}` }, ...(request ? [{ role: "you" as const, text: request }] : [])];
287
+ // A request the oracle routed to the team is already in the conversation, under the routing note.
288
+ const shown = /\nRouted: /.test(body);
289
+ return [{ role: "note", text: `task started · ${kickoff[2]}` }, ...(request && !shown ? [{ role: "you" as const, text: request }] : [])];
288
290
  }
291
+ // A new request the classifier read as a quick fix, before the oracle confirms where it goes.
292
+ const routing = /^bot-lobby: a new request, not a task yet\.\nRequest: ([\s\S]*?)\nRead: /.exec(body);
293
+ if (routing) return [{ role: "you", text: routing[1]!.trim() }, { role: "note", text: "reads as a quick fix · the oracle confirms where it goes" }];
289
294
  const comment = /^The user left (?:a comment|\d+ comments) on (the approved plan|the proposal) of (\S+) from the lobby:/.exec(body);
290
295
  if (comment) return [{ role: "note", text: `your comment on ${comment[1]} went to the oracle` }];
291
296
  return [{ role: "you", text: body }];
package/src/lobby/keys.ts CHANGED
@@ -17,7 +17,6 @@ export const LOBBY_ACTIONS = {
17
17
  toggleAuto: { key: "alt+g", help: "auto mode: the oracle drives the task to completion without asking" },
18
18
  nextTab: { key: "tab", help: "next tab" },
19
19
  prevTab: { key: "shift+tab", help: "previous tab" },
20
- toggleScene: { key: "alt+z", help: "show or hide the oracle and agent animations" },
21
20
  toggleConversation: { key: "alt+c", help: "show or hide the conversation" },
22
21
  toggleActivity: { key: "alt+a", help: "show or hide the activity log" },
23
22
  toggleThinking: { key: "alt+k", help: "show or hide thinking" },
@@ -54,6 +54,19 @@ export interface QuickFixJob {
54
54
  routedFrom?: string;
55
55
  /** Files it changed with `edit`/`write`, as shown (relative to the project); the change ledger lets a running task's QA gate tell them from its own. */
56
56
  files?: string[];
57
+ /** Runs on this profile instead of the quick-fix settings: a quick feature the oracle routed here gets its builder's model, thinking and time. */
58
+ profile?: QuickFixProfile;
59
+ /** The oracle sent it here from a request typed to the lobby. */
60
+ routed?: boolean;
61
+ }
62
+
63
+ /** How a job enters the queue besides its prompt. */
64
+ export interface SubmitOptions {
65
+ /** Skip the classifier's "looks like a task" hold (the oracle already routed it here). */
66
+ force?: boolean;
67
+ profile?: QuickFixProfile;
68
+ /** Shown with the job: where it came from. */
69
+ note?: string;
57
70
  }
58
71
 
59
72
  export interface QuickFixProfile {
@@ -118,10 +131,21 @@ export class QuickFixQueue {
118
131
  }
119
132
 
120
133
  /** Queue a quick fix; it starts at once when nothing else is running. */
121
- submit(prompt: string, now = Date.now()): QuickFixJob {
134
+ submit(prompt: string, now = Date.now(), options: SubmitOptions = {}): QuickFixJob {
122
135
  const text = prompt.trim();
123
136
  if (!text) throw new Error("a quick fix needs a prompt");
124
- const job: QuickFixJob = { id: `QF-${++this.counter}`, prompt: text, status: "queued", createdAt: now, steps: [], tools: 0, turns: 0 };
137
+ const job: QuickFixJob = {
138
+ id: `QF-${++this.counter}`,
139
+ prompt: text,
140
+ status: "queued",
141
+ createdAt: now,
142
+ steps: [],
143
+ tools: 0,
144
+ turns: 0,
145
+ ...(options.force ? { force: true, routed: true } : {}),
146
+ ...(options.profile ? { profile: options.profile } : {}),
147
+ ...(options.note ? { note: options.note } : {}),
148
+ };
125
149
  this.jobs = [...this.jobs, job].filter((entry, index, all) => isActive(entry) || index >= all.length - MAX_JOBS);
126
150
  this.changed();
127
151
  this.pump();
@@ -203,7 +227,7 @@ export class QuickFixQueue {
203
227
  }
204
228
 
205
229
  private async run(job: QuickFixJob): Promise<void> {
206
- const profile = this.deps.profile();
230
+ const profile = job.profile ?? this.deps.profile();
207
231
  const controller = new AbortController();
208
232
  this.controllers.set(job.id, controller);
209
233
  job.status = "running";
@@ -287,6 +311,8 @@ export class QuickFixQueue {
287
311
  try {
288
312
  const sized = await quickFixSize(jev, job.prompt, signal);
289
313
  if (!sized || sized.size !== "large" || sized.confidence < jev.config.thresholds.quickFixLargeAt) return undefined;
314
+ // Large but still one engineer's work (a rich page in one file): it runs.
315
+ if ((sized.solo ?? 0) >= jev.config.thresholds.quickFixAt) return undefined;
290
316
  return `looks like a task (large, ${sized.confidence.toFixed(2)})`;
291
317
  } catch {
292
318
  return undefined;
@@ -20,13 +20,14 @@ import { archiveTask as archiveTaskOnDisk, deleteTask as deleteTaskOnDisk, listA
20
20
  import { discardPlannedTask, listPlannedTasks, type PlannedTask } from "../state/backlog.ts";
21
21
  import { appendMetrics, readClassifierMetrics, readMetrics, type MetricStatus } from "../state/metrics.ts";
22
22
  import { describeToolCall } from "../pi/activity.ts";
23
- import { applyStatus, currentZenTask, isMinimized, onMinimizeChange, onRunUpdates, persistedRuns, setMinimized, setWidgetSuppressor, ZenScene, zenSnapshot } from "../pi/ui.ts";
24
- import { panelLines } from "../pi/zen.ts";
23
+ import { applyStatus, currentZenTask, isMinimized, onMinimizeChange, onRunUpdates, setMinimized, taskSnapshot } from "../pi/ui.ts";
25
24
  import { shortTitle } from "../text.ts";
26
25
  import { isSubagentProcess } from "../pi/quiet.ts";
27
26
  import { modelRef, resolveLobbyProfile, resolvePanelProfile } from "../pi/model-support.ts";
28
27
  import { modelLookup } from "../pi/tools.ts";
29
- import { startPlannedTask, startTask } from "../pi/start-task.ts";
28
+ import { startPlannedTask } from "../pi/start-task.ts";
29
+ import { pendingRequest, setQuickFixHandoff, startRequest } from "../pi/route.ts";
30
+ import type { Domain } from "../schemas/agent.ts";
30
31
  import type { LobbyAgentKind, LobbyPanel, PanelMember } from "../schemas/configuration.ts";
31
32
  import { chatFromEntries, lobbyFeed, narrateEvent, type AgentEventLike, type ChatEntry } from "./feed.ts";
32
33
  import { classifier, effortFor, hintsFor } from "../classifier/instance.ts";
@@ -41,7 +42,6 @@ import { LobbyView, type LiveSession, type LobbyHost, type SwitchTarget, type Ta
41
42
  import { lobbyTheme } from "./theme.ts";
42
43
  import { deliverComments, onOwnerEvent, setAuto } from "../pi/owner.ts";
43
44
  import { openEntrySettings, openSettings } from "../pi/settings-ui.ts";
44
- import { budgetClock } from "../state/budget.ts";
45
45
 
46
46
  export const ANCHOR_KEY = "bot-lobby-anchor";
47
47
  export { deliverComments };
@@ -57,7 +57,6 @@ interface Runtime {
57
57
  visible: boolean;
58
58
  /** Stepped aside while pi shows a dialog. */
59
59
  asideForPrompt: boolean;
60
- scene: ZenScene;
61
60
  quickfix: QuickFixQueue;
62
61
  planner?: PlanningSession;
63
62
  issues: IssuesState;
@@ -149,9 +148,10 @@ function toOracle(state: Runtime, text: string): string | undefined {
149
148
  state.pi.sendUserMessage(text, { expandPromptTemplates: true, ...(busy ? { deliverAs: "followUp" as const } : {}) });
150
149
  return `sent ${text.split(/\s+/)[0]} to pi (built-in commands need the lobby hidden: alt+l)`;
151
150
  }
152
- if (!currentZenTask()) {
153
- startTask(state.pi, state.ctx, state.configDir, text).catch((error: Error) => failed(state, "could not start the task", error));
154
- return "starting a task — the oracle takes it from here";
151
+ // While the oracle decides where a request goes, what the user types joins that conversation.
152
+ if (!currentZenTask() && !pendingRequest()) {
153
+ startRequest(state.pi, state.ctx, state.configDir, text).catch((error: Error) => failed(state, "could not start the task", error));
154
+ return "reading your request — a quick fix or a task, the oracle takes it from here";
155
155
  }
156
156
  state.pi.sendUserMessage(text, busy ? { deliverAs: "steer" } : undefined);
157
157
  return undefined;
@@ -423,13 +423,6 @@ function deleteTask(state: Runtime, taskId: string, where: "list" | "archive"):
423
423
  return `deleted ${taskId} for good`;
424
424
  }
425
425
 
426
- /** A task's status box without animations, for a session other than this window's. */
427
- function taskScene(state: Runtime, task: Task, width: number, height: number): string[] {
428
- const now = Date.now();
429
- const time = budgetClock(state.root, state.configDir, task.id, now);
430
- return panelLines(task, persistedRuns(task), now, false, { width, rows: Math.floor(height / 0.75), still: true, theme: state.ctx.ui.theme, ...(time ? { time } : {}) });
431
- }
432
-
433
426
  function seatProfile(state: Runtime, member: PanelMember) {
434
427
  return resolvePanelProfile(loadConfig(), member, {
435
428
  lookup: modelLookup(state.ctx),
@@ -515,15 +508,9 @@ function host(state: Runtime, tui: TUI): LobbyHost {
515
508
  theme: () => lobbyTheme(state.ctx.ui.theme),
516
509
  sessionId: () => state.ctx.sessionManager.getSessionId(),
517
510
  zen: () => {
518
- const snapshot = zenSnapshot();
511
+ const snapshot = taskSnapshot();
519
512
  return { ...(snapshot.task ? { task: snapshot.task } : {}), runs: snapshot.runs };
520
513
  },
521
- // The scene's height budget is 3/4 of the rows it is given.
522
- scene: (width, height, animated) => state.scene.lines(width, Math.floor(height / 0.75), state.ctx.ui.theme, Date.now(), !animated),
523
- advanceScene: (now) => {
524
- state.scene.advance(now);
525
- return state.scene.delay(now);
526
- },
527
514
  feed: lobbyFeed,
528
515
  masterBusy: () => !state.ctx.isIdle(),
529
516
  tasks: () => peekTasks(state.root, state.configDir),
@@ -574,7 +561,6 @@ function host(state: Runtime, tui: TUI): LobbyHost {
574
561
  sessionChat: (sessionId) => chats.chat(sessionId),
575
562
  hasOlderChat: (sessionId) => chats.hasOlder(sessionId),
576
563
  chatHistory: (sessionId) => chatHistory(state, sessionId),
577
- taskScene: (task, width, height) => taskScene(state, task, width, height),
578
564
  requestRender: () => tui.requestRender(),
579
565
  };
580
566
  }
@@ -706,17 +692,35 @@ function shutdown(): void {
706
692
  const state = runtime;
707
693
  if (!state) return;
708
694
  runtime = undefined;
695
+ setQuickFixHandoff(undefined);
709
696
  setMouse(state, false);
710
697
  state.unsubscribeFeed?.();
711
698
  state.quickfix.cancelAll();
712
699
  state.planner?.cancel();
713
700
  state.view?.dispose();
714
701
  state.handle?.hide();
715
- setWidgetSuppressor(() => false);
716
702
  onRunUpdates(undefined);
717
703
  onMinimizeChange(undefined);
718
704
  }
719
705
 
706
+ /**
707
+ * The oracle routed a request to the quick-fix agent: queue it (no "looks like
708
+ * a task" hold, the oracle already decided), on its builder's settings when it
709
+ * is a quick feature, and show it on the Quick fix tab.
710
+ */
711
+ function handToQuickFix(state: Runtime, request: string, builder: Domain | undefined, reason: string): string {
712
+ const quick = lobbyProfile(state, "quickfix");
713
+ const profile = builder ? { ...seatProfile(state, builder), instructions: quick.instructions } : undefined;
714
+ const job = state.quickfix.submit(request, Date.now(), {
715
+ force: true,
716
+ ...(profile ? { profile } : {}),
717
+ note: `The oracle sent your request here: ${reason.replace(/[.\s]+$/, "")}.${builder ? ` A quick feature: it runs on ${builder === "designer" ? "DESIGN" : "DEV"}'s model, thinking and time limit.` : ""}`,
718
+ });
719
+ showLobby("quickfix");
720
+ state.view?.showQuickFix(job.id);
721
+ return job.id;
722
+ }
723
+
720
724
  /** Create the session's lobby runtime (interactive master sessions only). */
721
725
  export function initLobby(pi: ExtensionAPI, ctx: ExtensionContext, configDir: string): void {
722
726
  shutdown();
@@ -733,7 +737,6 @@ export function initLobby(pi: ExtensionAPI, ctx: ExtensionContext, configDir: st
733
737
  asking: false,
734
738
  mouse: false,
735
739
  inSettings: false,
736
- scene: new ZenScene(),
737
740
  quickfix: undefined as unknown as QuickFixQueue,
738
741
  issues: new IssuesState(execCommand, ctx.cwd, rerender),
739
742
  };
@@ -754,11 +757,11 @@ export function initLobby(pi: ExtensionAPI, ctx: ExtensionContext, configDir: st
754
757
  effort: effortFor((model, thinking) => checkThinking(modelLookup(ctx)(model), thinking).level),
755
758
  });
756
759
  runtime = state;
760
+ setQuickFixHandoff((request, builder, reason) => (runtime === state ? handToQuickFix(state, request, builder, reason) : undefined));
757
761
  lobbyFeed.clear();
758
762
  // Only the newest messages are kept; the feed learns whether earlier ones exist, and loads them when scrolled to.
759
763
  lobbyFeed.seedChat(chatFromEntries(ctx.sessionManager.getBranch(), Number.POSITIVE_INFINITY));
760
764
  state.unsubscribeFeed = lobbyFeed.onChange(rerender);
761
- setWidgetSuppressor(() => runtime?.visible === true);
762
765
  onRunUpdates((runs) => lobbyFeed.runs(runs));
763
766
  onMinimizeChange((value) => {
764
767
  if (value) hideLobby();
@@ -1,9 +1,8 @@
1
1
  /**
2
- * The Lobby tab: the zen scene of this session's task on top (the oracle
3
- * and agent animations, or just their status when animations are off), then the
2
+ * The Lobby tab: the status of this session's task on top, then the
4
3
  * conversation with the oracle (text only, no tool rows, no thinking), the
5
4
  * activity log of plain-words steps from every agent, and the one place where
6
- * thoughts show up. Pure: the scene arrives as a callback, the clock as `now`.
5
+ * thoughts show up. Pure: the clock arrives as `now`.
7
6
  */
8
7
  import { textWidth } from "../../width.ts";
9
8
  import type { Task } from "../../schemas/task.ts";
@@ -18,8 +17,6 @@ export type HomePane = (typeof HOME_PANES)[number];
18
17
 
19
18
  export interface HomeInput {
20
19
  task?: Task;
21
- /** Draws the zen scene into at most `height` lines, animated or still; absent when no task is active. */
22
- scene?: (width: number, height: number, animated: boolean) => string[];
23
20
  chat: readonly ChatEntry[];
24
21
  /** The oracle's reply while it streams. */
25
22
  liveReply?: string;
@@ -48,8 +45,6 @@ export interface HomeInput {
48
45
  keys?: Record<LobbyPanel, string>;
49
46
  /** Another session is shown: its name titles the conversation. */
50
47
  title?: string;
51
- /** The scene is another session's still status (no animations to toggle). */
52
- stillScene?: boolean;
53
48
  /** What the empty conversation says instead of the default (a session starting, say). */
54
49
  emptyNote?: string;
55
50
  /** What the empty activity log says instead of "No activity yet.". */
@@ -60,11 +55,6 @@ export interface HomeInput {
60
55
 
61
56
  /** Wide terminals put the conversation and the activity log side by side. */
62
57
  export const HOME_COLUMNS_MIN = 100;
63
- /** Most lines the scene may take, and its share of the body; the still status needs far fewer. */
64
- const SCENE_SHARE = 0.45;
65
- const MAX_SCENE = 36;
66
- const MAX_STILL = 10;
67
-
68
58
  const SOURCE_COLORS: Record<string, LobbyColor> = {
69
59
  MASTER: "accent",
70
60
  DEV: "success",
@@ -490,31 +480,6 @@ function thinkingContent(input: HomeInput, thoughts: readonly ThoughtEntry[], wi
490
480
  return whole([paint(theme, "dim", input.query ? `No thought matches "${input.query}".` : "Thoughts from the oracle and every agent appear here, and only here.")]);
491
481
  }
492
482
 
493
- /**
494
- * The scene: with animations off (the `animations` panel, off by default)
495
- * only the task's status box, what the agents are doing and the checklist;
496
- * with them on, the animated oracle and agents too. Its first line names the
497
- * key that toggles the animations.
498
- */
499
- function sceneLines(input: HomeInput, width: number, height: number, theme?: LobbyTheme): string[] {
500
- if (!input.task || !input.scene) return [];
501
- const animated = input.panels.animations && !input.stillScene;
502
- const budget = Math.min(animated ? MAX_SCENE : MAX_STILL, Math.floor(height * SCENE_SHARE));
503
- if (budget < (animated ? 6 : 4)) return [];
504
- const lines = input.scene(width, budget, animated).slice(0, budget);
505
- const note = input.keys && !input.stillScene ? `${input.keys.animations} ${animated ? "hides" : "shows"} animations` : undefined;
506
- return keyNote(lines, width, note, theme);
507
- }
508
-
509
- /** `note` at the right end of the first line, dimmed, when it fits beside what is there. */
510
- function keyNote(lines: readonly string[], width: number, note: string | undefined, theme?: LobbyTheme): string[] {
511
- const first = lines[0];
512
- if (!note || first === undefined) return [...lines];
513
- const gap = width - textWidth(first) - textWidth(note) - 1;
514
- if (gap < 2) return [...lines];
515
- return [`${first}${" ".repeat(gap)}${paint(theme, "dim", note)}`, ...lines.slice(1)];
516
- }
517
-
518
483
  function hiddenHint(input: HomeInput, width: number, height: number, theme?: LobbyTheme): string[] {
519
484
  const keys = input.keys;
520
485
  const text = keys
@@ -536,12 +501,11 @@ export function renderHome(input: HomeInput, width: number, height: number, them
536
501
  if (height <= 0) return [];
537
502
  const { panels } = input;
538
503
  const feed = filterFeed(input);
539
- const scene = sceneLines(input, width, height, theme);
540
- const rest = height - scene.length;
504
+ const rest = height;
541
505
  const showMain = panels.conversation || panels.activity;
542
506
  const thinkHeight = !panels.thinking ? 0 : !showMain ? rest : rest >= 18 ? Math.max(5, Math.floor(rest * 0.25)) : rest >= 10 ? 4 : 0;
543
507
  const main = rest - thinkHeight;
544
- if (!showMain && !panels.thinking) return fill([...scene, ...hiddenHint(input, width, rest, theme)], height, width);
508
+ if (!showMain && !panels.thinking) return fill(hiddenHint(input, width, rest, theme), height, width);
545
509
  const matches = (count: number) => `${count} match${count === 1 ? "" : "es"}`;
546
510
  /**
547
511
  * One scrollable pane: only the lines it shows are drawn — its newest, or
@@ -563,7 +527,7 @@ export function renderHome(input: HomeInput, width: number, height: number, them
563
527
  const thought = currentThought(feed.thoughts);
564
528
  const thinkingNote = thought ? `${thought.source} · ${thought.live ? "thinking" : since(input.now - thought.at)}` : input.keys?.thinking;
565
529
  const thinkingBox = pane("thinking", "Thinking", thinkingNote, (inner, need, anchor) => thinkingContent(input, feed.thoughts, inner, need, anchor, theme));
566
- const top = scene.length;
530
+ const top = 0;
567
531
  let body: string[] = [];
568
532
  if (main > 0 && panels.conversation && panels.activity) {
569
533
  if (width >= HOME_COLUMNS_MIN) {
@@ -576,5 +540,5 @@ export function renderHome(input: HomeInput, width: number, height: number, them
576
540
  } else if (main > 0 && panels.conversation) body = chatBox(top, 0, width, main);
577
541
  else if (main > 0 && panels.activity) body = activityBox(top, 0, width, main);
578
542
  const think = thinkHeight >= 3 ? thinkingBox(top + main, 0, width, thinkHeight) : [];
579
- return fill([...scene, ...fill(body, main), ...think], height, width);
543
+ return fill([...fill(body, main), ...think], height, width);
580
544
  }
@@ -65,7 +65,7 @@ export function jobDetailLines(job: QuickFixJob, width: number, tick: number, no
65
65
  const asked = theme?.markdown ? markdownLines(job.prompt, width, theme, true, "you") : wrap(bold(theme, job.prompt), width);
66
66
  const lines = [paint(theme, "dim", job.id), facts.join(paint(theme, "dim", " · ")), "", ...asked];
67
67
  if (job.route) lines.push("", ...wrap(paint(theme, "dim", `↓ routed ${job.route}`), width));
68
- 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));
68
+ if (job.note) lines.push("", ...wrap(paint(theme, job.status === "held" ? "warning" : job.routed ? "muted" : "warning", job.status === "held" ? `‖ Held: ${job.note}. r runs it anyway · t starts it as a task in a new session.` : job.note), width));
69
69
  lines.push("", rule(width, "Steps", theme));
70
70
  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."));
71
71
  for (const step of job.steps) {
@@ -7,7 +7,7 @@
7
7
  import { TERMINAL_STATES, taskRequest, type Task } from "../../schemas/task.ts";
8
8
  import type { PlannedTask } from "../../state/backlog.ts";
9
9
  import type { PlanComment } from "../../state/comments.ts";
10
- import { planChecklist } from "../../pi/zen.ts";
10
+ import { planChecklist } from "../../pi/plan-checklist.ts";
11
11
  import { persistedRuns } from "../../pi/ui.ts";
12
12
  import { describeRun, runFromLog } from "../../pi/run-summary.ts";
13
13
  import { pendingApprovals } from "../../workflow/approvals.ts";
@@ -268,7 +268,8 @@ export function taskDetailLines(task: Task, comments: readonly PlanComment[], se
268
268
  finished ? paint(theme, "muted", `${task.state === "abandoned" ? "dropped" : "done"} ${since(now - Date.parse(task.updatedAt))}`) : "",
269
269
  ].filter(Boolean);
270
270
  lines.push(...wrapHanging(" ", facts.join(dot), width));
271
- lines.push(...wrapHanging(" ", paint(theme, "dim", [task.id, task.domains.length > 0 ? task.domains.join(", ") : ""].filter(Boolean).join(" · ")), width));
271
+ const track = task.track ? (task.track.path === "fast" ? `fast track (${task.track.size})` : `full workflow (${task.track.size})`) : "";
272
+ lines.push(...wrapHanging(" ", paint(theme, "dim", [task.id, track, task.domains.length > 0 ? task.domains.join(", ") : ""].filter(Boolean).join(" · ")), width));
272
273
  const steps = task.plan ? planChecklist(task.plan, persistedRuns(task)) : [];
273
274
  const done = steps.filter((step) => step.status === "done").length;
274
275
  if (steps.length > 0) {
package/src/lobby/view.ts CHANGED
@@ -54,8 +54,8 @@ const PROMPT_FIRST: ReadonlySet<TabId> = new Set(["lobby", "plan", "quickfix"]);
54
54
  export type LobbyMode = "type" | "browse";
55
55
 
56
56
  /** What each pane toggle is called in notices and help. */
57
- const PANEL_NAMES: Record<LobbyPanel, string> = { animations: "oracle and agent animations", conversation: "conversation", activity: "activity log", thinking: "thinking" };
58
- const PANEL_ACTIONS: Record<LobbyPanel, LobbyAction> = { animations: "toggleScene", conversation: "toggleConversation", activity: "toggleActivity", thinking: "toggleThinking" };
57
+ const PANEL_NAMES: Record<LobbyPanel, string> = { conversation: "conversation", activity: "activity log", thinking: "thinking" };
58
+ const PANEL_ACTIONS: Record<LobbyPanel, LobbyAction> = { conversation: "toggleConversation", activity: "toggleActivity", thinking: "toggleThinking" };
59
59
 
60
60
  /**
61
61
  * Which session the Lobby tab shows and talks to: this window, one it started
@@ -125,12 +125,8 @@ export interface LobbyHost {
125
125
  rows(): number;
126
126
  theme(): LobbyTheme;
127
127
  sessionId(): string | undefined;
128
- /** This session's active task and its runs, as the zen widget sees them. */
128
+ /** This session's active task and its runs, as the lobby reads them. */
129
129
  zen(): { task?: Task; runs: readonly AgentRun[] };
130
- /** Draw the zen scene into at most `height` lines: animated, or only the task's status when `animated` is false. */
131
- scene(width: number, height: number, animated: boolean): string[];
132
- /** Advance the scene's clock; returns the delay it wants until the next step. */
133
- advanceScene(now: number): number;
134
130
  feed: LobbyFeed;
135
131
  masterBusy(): boolean;
136
132
  tasks(): Task[];
@@ -199,14 +195,12 @@ export interface LobbyHost {
199
195
  hasOlderChat(sessionId: string): boolean;
200
196
  /** A session's whole conversation, oldest first: this window's (no id) or another's; loaded only while scrolled back to it. */
201
197
  chatHistory(sessionId?: string): readonly ChatEntry[];
202
- /** A task's status without animations, for a session other than this window's. */
203
- taskScene(task: Task, width: number, height: number): string[];
204
198
  profileLabel(kind: LobbyAgentKind): string;
205
199
  requestRender(): void;
206
200
  now?(): number;
207
201
  }
208
202
 
209
- /** Live work speeds the clock up so spinners and the scene move. */
203
+ /** Live work speeds the clock up so spinners move. */
210
204
  export const LIVE_MS = 250;
211
205
  export const IDLE_MS = 1000;
212
206
  /** How often task and metrics data is reread from disk while the lobby is open. */
@@ -424,16 +418,13 @@ export class LobbyView implements Component, Focusable {
424
418
  || Boolean(zen.task && !zen.task.paused && !TERMINAL_STATES.includes(zen.task.state));
425
419
  }
426
420
 
427
- /** One clock step: advance the scene, reread data when due, repaint. */
421
+ /** One clock step: reread data when due, repaint. */
428
422
  private step(): void {
429
423
  if (!this.running) return;
430
- const now = this.now();
431
424
  this.tick += 1;
432
- let delay = this.isLive() ? LIVE_MS : IDLE_MS;
433
- if (this.tab === "lobby" && this.panels.animations && this.host.zen().task) delay = Math.min(delay, this.host.advanceScene(now));
434
425
  this.refreshData(false);
435
426
  this.host.requestRender();
436
- this.schedule(delay);
427
+ this.schedule(this.isLive() ? LIVE_MS : IDLE_MS);
437
428
  }
438
429
 
439
430
  /** Reread tasks, plans, comments and metrics from disk (throttled unless forced). */
@@ -471,6 +462,15 @@ export class LobbyView implements Component, Focusable {
471
462
  return visibleTabs(this.issuesOn);
472
463
  }
473
464
 
465
+ /** Open the Quick fix tab on a job the oracle routed there, with a line saying so. */
466
+ showQuickFix(id: string): void {
467
+ this.setTab("quickfix");
468
+ delete this.queries.quickfix;
469
+ this.fixSelected = Math.max(0, this.fixJobs().findIndex((job) => job.id === id));
470
+ this.fixDetailOffset = 0;
471
+ this.say(`${id} — the oracle sent your request to the quick-fix agent`);
472
+ }
473
+
474
474
  setTab(tab: TabId): void {
475
475
  if (tab === this.tab) return;
476
476
  if (!this.tabs().includes(tab)) return this.say(`the ${TAB_LABELS[tab]} tab is off — set lobby.${tab} to true in the config to bring it back`, "warning");
@@ -666,8 +666,6 @@ export class LobbyView implements Component, Focusable {
666
666
  return this.cycleTab(1);
667
667
  case "prevTab":
668
668
  return this.cycleTab(-1);
669
- case "toggleScene":
670
- return this.togglePanel("animations");
671
669
  case "toggleConversation":
672
670
  return this.togglePanel("conversation");
673
671
  case "toggleActivity":
@@ -2286,7 +2284,7 @@ export class LobbyView implements Component, Focusable {
2286
2284
  if (entry.view.kind !== "here") return this.otherSessionBody(entry, width, height, theme, now);
2287
2285
  const talk = this.conversationFor(entry);
2288
2286
  return renderHome({
2289
- ...(zen.task ? { task: zen.task, scene: (w: number, h: number, animated: boolean) => this.host.scene(w, h, animated) } : {}),
2287
+ ...(zen.task ? { task: zen.task } : {}),
2290
2288
  chat: talk.chat,
2291
2289
  ...(talk.older ? { olderNote: OLDER_NOTE } : {}),
2292
2290
  ...(feed.reply ? { liveReply: feed.reply } : {}),
@@ -2304,7 +2302,6 @@ export class LobbyView implements Component, Focusable {
2304
2302
  panels: this.panels,
2305
2303
  ...(query ? { query } : {}),
2306
2304
  keys: {
2307
- animations: keyLabel(this.keys.toggleScene),
2308
2305
  conversation: keyLabel(this.keys.toggleConversation),
2309
2306
  activity: keyLabel(this.keys.toggleActivity),
2310
2307
  thinking: keyLabel(this.keys.toggleThinking),
@@ -2314,8 +2311,7 @@ export class LobbyView implements Component, Focusable {
2314
2311
 
2315
2312
  /**
2316
2313
  * Another session on the Lobby tab: a background session's live feed, or an
2317
- * other terminal's conversation read from its session file; the task's
2318
- * status sits on top without animations.
2314
+ * other terminal's conversation read from its session file.
2319
2315
  */
2320
2316
  private otherSessionBody(entry: SessionEntry, width: number, height: number, theme: LobbyTheme, now: number): string[] {
2321
2317
  const session = this.viewedSession();
@@ -2323,7 +2319,7 @@ export class LobbyView implements Component, Focusable {
2323
2319
  const task = entry.task;
2324
2320
  const query = this.query();
2325
2321
  const common = {
2326
- ...(task ? { task, scene: (w: number, h: number) => this.host.taskScene(task, w, h), stillScene: true } : {}),
2322
+ ...(task ? { task } : {}),
2327
2323
  others: 0,
2328
2324
  pending: 0,
2329
2325
  offsets: this.homeOffsets,
@@ -2336,7 +2332,6 @@ export class LobbyView implements Component, Focusable {
2336
2332
  ...(query ? { query } : {}),
2337
2333
  title: entry.name,
2338
2334
  keys: {
2339
- animations: keyLabel(this.keys.toggleScene),
2340
2335
  conversation: keyLabel(this.keys.toggleConversation),
2341
2336
  activity: keyLabel(this.keys.toggleActivity),
2342
2337
  thinking: keyLabel(this.keys.toggleThinking),
@@ -2,12 +2,20 @@ import type { Decision, Task, Verdict } from "../schemas/task.ts";
2
2
  import type { Domain } from "../schemas/agent.ts";
3
3
  import type { ScoutOutcome } from "./master.ts";
4
4
  import { detectGaps, domainsInvolved } from "./synthesis.ts";
5
+ import { builtSomething, onFastTrack, qaRequired, qaTookPart } from "../workflow/track.ts";
5
6
 
6
- /** §19: everything the engine requires before completion may be declared. */
7
+ /**
8
+ * §19: everything the engine requires before completion may be declared. The
9
+ * fast track asks for a finished worker step instead of a QA pass, and for
10
+ * QA's part only when the change needs tests.
11
+ */
7
12
  export function completionBlockers(task: Task, pendingCount: number): string[] {
8
13
  const blockers: string[] = [];
9
14
  if (!task.plan) blockers.push("no approved plan is recorded");
10
- if (task.qaVerdict !== "pass" && !task.qaWaiver) blockers.push(`QA gate is ${task.qaVerdict ?? "not run"}`);
15
+ if (onFastTrack(task)) {
16
+ if (!builtSomething(task)) blockers.push("no worker step has finished yet");
17
+ if (qaRequired(task) && !qaTookPart(task) && !task.qaWaiver) blockers.push("QA has not taken part (give qa the tests as the last step, or run the QA gate)");
18
+ } else if (task.qaVerdict !== "pass" && !task.qaWaiver) blockers.push(`QA gate is ${task.qaVerdict ?? "not run"}`);
11
19
  if (pendingCount > 0) blockers.push(`${pendingCount} unresolved approval request(s)`);
12
20
  if (task.blockers.length > 0) blockers.push(`${task.blockers.length} unresolved blocker(s)`);
13
21
  return blockers;