@a-t-h-i/bot-lobby 0.6.5 → 0.6.7

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 +127 -6
  2. package/package.json +1 -1
  3. package/prompts/global.md +18 -0
  4. package/prompts/master.md +77 -2
  5. package/prompts/worker.md +7 -2
  6. package/src/ask/dialog.ts +15 -3
  7. package/src/ask/state.ts +10 -1
  8. package/src/ask/tool.ts +6 -1
  9. package/src/ask/view.ts +2 -1
  10. package/src/execution/agent-runner.ts +23 -1
  11. package/src/execution/fallback.ts +75 -0
  12. package/src/index.ts +1 -1
  13. package/src/lobby/feed.ts +8 -1
  14. package/src/lobby/keys.ts +0 -1
  15. package/src/lobby/layout.ts +22 -1
  16. package/src/lobby/mini.ts +159 -0
  17. package/src/lobby/planner.ts +10 -3
  18. package/src/lobby/quickfix.ts +17 -3
  19. package/src/lobby/runtime.ts +29 -26
  20. package/src/lobby/tabs/home.ts +6 -42
  21. package/src/lobby/tabs/tasks.ts +1 -1
  22. package/src/lobby/view.ts +31 -30
  23. package/src/master/master.ts +1 -1
  24. package/src/master/research.ts +1 -0
  25. package/src/pi/activity.ts +1 -33
  26. package/src/pi/events.ts +3 -16
  27. package/src/pi/master-fallback.ts +61 -0
  28. package/src/pi/model-support.ts +18 -3
  29. package/src/pi/plan-checklist.ts +305 -0
  30. package/src/pi/run-summary.ts +12 -3
  31. package/src/pi/settings-ui.ts +65 -12
  32. package/src/pi/tools.ts +5 -4
  33. package/src/pi/ui.ts +18 -284
  34. package/src/roles/worker.ts +1 -1
  35. package/src/schemas/configuration.ts +51 -15
  36. package/src/schemas/findings.ts +2 -0
  37. package/src/workflow/brief.ts +59 -0
  38. package/src/workflow/workflow.ts +15 -2
  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
package/src/pi/ui.ts CHANGED
@@ -1,32 +1,10 @@
1
- import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
2
- import { Key, type Component, type TUI } from "@earendil-works/pi-tui";
3
- import { clip } from "../width.ts";
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { Key } from "@earendil-works/pi-tui";
4
3
  import type { AgentRun } from "../schemas/findings.ts";
5
- import { TERMINAL_STATES, type Task } from "../schemas/task.ts";
4
+ import type { Task } from "../schemas/task.ts";
6
5
  import { activeTask } from "../state/persistence.ts";
7
6
  import { detectProjectRoot } from "../state/project.ts";
8
- import {
9
- advanceExpression,
10
- anyPlaying,
11
- createExpression,
12
- expressionPhase,
13
- FAST_TICK_MS,
14
- ORACLE_GAP,
15
- SLOT_GAP,
16
- talkFrame,
17
- triggerEmote,
18
- WORKING_BLINK_CHANCE,
19
- WORKING_GAP,
20
- type ExpressionGap,
21
- type ExpressionState,
22
- } from "./expressions.ts";
23
- import { ORACLE_THINKING } from "./activity.ts";
24
- import { SLOT_IDS, type SlotId } from "./mascot-art.ts";
25
- import { slotSituations } from "./zen-metrics.ts";
26
- import type { Situation } from "./kaomoji.ts";
27
- import { isQuiet, isSubagentProcess, toggleQuiet } from "./quiet.ts";
28
- import { panelLines, type ExpressionFrames, type OracleMotion } from "./zen.ts";
29
- import { budgetClock } from "../state/budget.ts";
7
+ import { isSubagentProcess, isQuiet, toggleQuiet } from "./quiet.ts";
30
8
 
31
9
  export const STATUS_KEY = "bot-lobby";
32
10
 
@@ -44,44 +22,14 @@ export function statusText(task: Task | undefined, minimized = false): string {
44
22
  return `bot-lobby ${task.id} · ${task.paused ? `${task.state} (paused)` : task.state} · ${mode}`;
45
23
  }
46
24
 
47
- let zenOn = false;
48
-
49
25
  /**
50
- * Widget state. `live` holds the runs reported in this session; `runs` is what the
51
- * panel draws: the task's persisted worker records overlaid by the live copies,
52
- * so the checklist replays after a reload. `version` bumps on every change and
53
- * keys the widget's render cache.
26
+ * The session's task state. `live` holds the runs reported in this session;
27
+ * `runs` is the task's persisted worker records overlaid by the live copies,
28
+ * so the checklist replays after a reload.
54
29
  */
55
30
  let zenState: { task: Task | undefined; live: AgentRun[]; runs: AgentRun[] } = { task: undefined, live: [], runs: [] };
56
- /** Where the widget's task lives, so its time budget can be read. */
57
- let zenPaths: { root: string; configDir: string } | undefined;
58
- let zenVersion = 0;
59
-
60
- /** Latest master tool activity; the oracle's speech bubble shows it. */
61
- let oracleActivity: string | undefined;
62
-
63
- /** The mounted widget, so run and activity updates can repaint without waiting a tick. */
64
- let mountedWidget: { refresh(): void } | undefined;
65
-
66
- function touch(): void {
67
- zenVersion += 1;
68
- mountedWidget?.refresh();
69
- }
70
31
 
71
- /** Record the master's current activity word (see events.ts); undefined means it waits on the user. */
72
- export function setOracleActivity(activity: string | undefined, now = Date.now()): void {
73
- if (activity === oracleActivity) return;
74
- oracleActivity = activity;
75
- // The oracle speaks when it names something new, including handing you the turn;
76
- // quietly going back to "thinking" between tool calls is not worth a word.
77
- if (activity !== ORACLE_THINKING) oracleSpokeAt = now;
78
- touch();
79
- }
80
-
81
- /** When the oracle last said something new; its mouth lip-syncs for `TALK_MS` after. */
82
- let oracleSpokeAt: number | undefined;
83
-
84
- /** Upper bound on retained runs so a long task cannot grow the widget state without limit. */
32
+ /** Upper bound on retained runs so a long task cannot grow the task state without limit. */
85
33
  export const MAX_RETAINED_RUNS = 128;
86
34
 
87
35
  /**
@@ -123,10 +71,9 @@ function slim(runs: readonly AgentRun[]): AgentRun[] {
123
71
 
124
72
  function setZenState(task: Task | undefined, live: AgentRun[]): void {
125
73
  zenState = { task, live, runs: mergeRuns(persistedRuns(task), live) };
126
- touch();
127
74
  }
128
75
 
129
- /** Per-session standard-pi mode: the widget and Master prompt are hidden but ownership stays. */
76
+ /** Per-session standard-pi mode: the Master prompt is hidden but ownership stays. */
130
77
  let minimized = false;
131
78
 
132
79
  export function isMinimized(): boolean {
@@ -153,206 +100,8 @@ export function toggleMinimized(ctx: ExtensionContext, configDir: string): void
153
100
  ctx.ui.notify(minimized ? "bot-lobby minimized — ctrl+shift+m or /bot-lobby restore to return" : "bot-lobby restored", "info");
154
101
  }
155
102
 
156
- export const LIVE_TICK_MS = 250;
157
- export const IDLE_TICK_MS = 1000;
158
-
159
- /** Fast frames while an agent works or the task is live; slow frames when it is quiet. */
160
- function isLive(): boolean {
161
- if (zenState.runs.some((run) => run.status === "running")) return true;
162
- const task = zenState.task;
163
- return Boolean(task && !TERMINAL_STATES.includes(task.state) && !task.paused);
164
- }
165
-
166
- function liveTickDelay(): number {
167
- return isLive() ? LIVE_TICK_MS : IDLE_TICK_MS;
168
- }
169
-
170
- /** Tick delay for the zen clock: fastest while an expression plays or the oracle talks, so no step is skipped. */
171
- export function expressionTickDelay(states: readonly ExpressionState[], now: number, live: boolean, talking = false): number {
172
- if (talking || anyPlaying(states, now)) return FAST_TICK_MS;
173
- return live ? LIVE_TICK_MS : IDLE_TICK_MS;
174
- }
175
-
176
- /** Every sprite with its own expression schedule. */
177
- type ExpressionKey = keyof ExpressionFrames;
178
- const EXPRESSION_KEYS: readonly ExpressionKey[] = [...SLOT_IDS, "oracle"];
179
-
180
- /** The oracle keeps its own schedule; working agents are livelier than idle ones. */
181
- function gapFor(key: ExpressionKey, situations?: Record<SlotId, Situation>): ExpressionGap {
182
- if (key === "oracle") return ORACLE_GAP;
183
- return situations?.[key].status === "working" ? WORKING_GAP : SLOT_GAP;
184
- }
185
-
186
- function blinkChanceFor(key: ExpressionKey, situations?: Record<SlotId, Situation>): number | undefined {
187
- return key !== "oracle" && situations?.[key].status === "working" ? WORKING_BLINK_CHANCE : undefined;
188
- }
189
-
190
- /** A compact fingerprint of what a slot is going through; a change may earn a reaction. */
191
- export function situationKey(situation: Situation): string {
192
- return [situation.status, situation.flag ?? "", situation.handover ? "handover" : "", situation.wrappedUp ? "wrapped" : ""].join("|");
193
- }
194
-
195
- /**
196
- * Whether a slot's change of situation deserves an immediate emote: it started
197
- * work, finished, failed, got flagged (waiting, quiet, retrying) or received a
198
- * file. Going idle, or a flag clearing, passes quietly.
199
- */
200
- export function isReaction(previous: string | undefined, next: Situation): boolean {
201
- if (previous === undefined || previous === situationKey(next)) return false;
202
- if (next.status === "idle") return false;
203
- if (next.status !== "working") return true;
204
- return !previous.startsWith("working|") || next.flag !== undefined || next.handover === true;
205
- }
206
-
207
- /**
208
- * The animated zen scene: the tick, each sprite's expression schedule and the
209
- * reactions to what the agents go through. The widget and the lobby's home tab
210
- * each own one and advance it from their own clock.
211
- */
212
- export class ZenScene {
213
- tick = 0;
214
- private readonly expressions: Record<ExpressionKey, ExpressionState>;
215
- private readonly situations: Partial<Record<SlotId, string>> = {};
216
- private readonly rng: () => number;
217
- private cache: { key: string; theme: Theme | undefined; lines: string[] } | undefined;
218
-
219
- constructor(rng: () => number = Math.random, now = Date.now()) {
220
- this.rng = rng;
221
- const entries = EXPRESSION_KEYS.map((key) => [key, createExpression(now, rng, gapFor(key))] as const);
222
- this.expressions = Object.fromEntries(entries) as Record<ExpressionKey, ExpressionState>;
223
- }
224
-
225
- /** One clock step: expressions advance and agents react to their new situations. */
226
- advance(now = Date.now()): void {
227
- this.tick += 1;
228
- const situations = slotSituations(zenState.runs, now);
229
- for (const key of EXPRESSION_KEYS) {
230
- this.expressions[key] = advanceExpression(this.expressions[key], now, this.rng, gapFor(key, situations), blinkChanceFor(key, situations));
231
- }
232
- for (const id of SLOT_IDS) {
233
- const situation = situations[id];
234
- if (isReaction(this.situations[id], situation)) this.expressions[id] = triggerEmote(now, this.rng, gapFor(id, situations));
235
- this.situations[id] = situationKey(situation);
236
- }
237
- }
238
-
239
- /** The delay until the next step: fast while an expression plays or the oracle talks. */
240
- delay(now = Date.now()): number {
241
- return expressionTickDelay(Object.values(this.expressions), now, isLive(), talkFrame(oracleSpokeAt, now) !== undefined);
242
- }
243
-
244
- private motion(now: number): OracleMotion {
245
- return { phase: expressionPhase(this.expressions.oracle, now), talk: talkFrame(oracleSpokeAt, now) };
246
- }
247
-
248
- /**
249
- * The scene lines for the current zen state. They only change with the tick,
250
- * an expression frame, the widget state or the elapsed second, so any other
251
- * repaint (typing, streaming output) reuses the last lines.
252
- */
253
- lines(width: number, rows: number, theme: Theme | undefined, now = Date.now(), still = false): string[] {
254
- const expressions = Object.fromEntries(EXPRESSION_KEYS.map((key) => [key, this.expressions[key].frame])) as Partial<Record<ExpressionKey, number>>;
255
- const variants = Object.fromEntries(SLOT_IDS.map((id) => [id, this.expressions[id].variant])) as Partial<Record<SlotId, number>>;
256
- const quiet = isQuiet();
257
- const frameKey = [...EXPRESSION_KEYS.map((key) => expressions[key] ?? 0), ...SLOT_IDS.map((id) => variants[id] ?? 0)].join(",");
258
- const motion = this.motion(now);
259
- const key = `${width}|${rows}|${this.tick}|${frameKey}|${motion.phase}|${motion.talk}|${zenVersion}|${Math.floor(now / 1000)}|${quiet}|${still}`;
260
- if (this.cache && this.cache.key === key && this.cache.theme === theme) return this.cache.lines;
261
- const time = zenState.task && zenPaths ? budgetClock(zenPaths.root, zenPaths.configDir, zenState.task.id, now) : undefined;
262
- const opts = { width, rows, tick: this.tick, theme, expressions, variants, oracleActivity, oracleMotion: motion, still, ...(time ? { time } : {}) };
263
- const lines = panelLines(zenState.task, zenState.runs, now, quiet, opts).map((line) => clip(line, width));
264
- this.cache = { key, theme, lines };
265
- return lines;
266
- }
267
-
268
- invalidate(): void {
269
- this.cache = undefined;
270
- }
271
- }
272
-
273
- /** The zen state as the lobby reads it: the session's active task and its runs. */
274
- export function zenSnapshot(): { task: Task | undefined; runs: readonly AgentRun[]; version: number; oracleActivity: string | undefined } {
275
- return { task: zenState.task, runs: zenState.runs, version: zenVersion, oracleActivity };
276
- }
277
-
278
- /** Zen scene + plan checklist shown above the editor while a task is active and the lobby is closed. */
279
- class ZenWidget implements Component {
280
- private readonly scene: ZenScene;
281
- private delay = liveTickDelay();
282
- private timer: ReturnType<typeof setInterval>;
283
- private disposed = false;
284
- private readonly tui: TUI;
285
- private readonly theme: () => Theme;
286
-
287
- constructor(tui: TUI, theme: () => Theme, rng: () => number = Math.random) {
288
- this.tui = tui;
289
- this.theme = theme;
290
- this.scene = new ZenScene(rng);
291
- this.timer = setInterval(() => this.advance(), this.delay);
292
- mountedWidget = this;
293
- }
294
-
295
- /** Repaint now: state changed between ticks. */
296
- refresh(): void {
297
- if (!this.disposed) this.tui.requestRender();
298
- }
299
-
300
- private advance(): void {
301
- if (this.disposed) return;
302
- const now = Date.now();
303
- this.scene.advance(now);
304
- this.retime(now);
305
- this.tui.requestRender();
306
- }
307
-
308
- /** One interval, retimed when work starts or stops or an expression plays. */
309
- private retime(now: number): void {
310
- const delay = this.scene.delay(now);
311
- if (delay === this.delay) return;
312
- this.delay = delay;
313
- clearInterval(this.timer);
314
- this.timer = setInterval(() => this.advance(), delay);
315
- }
316
-
317
- render(width: number): string[] {
318
- return this.scene.lines(width, this.tui.terminal.rows, this.theme());
319
- }
320
-
321
- invalidate(): void {
322
- this.scene.invalidate();
323
- }
324
-
325
- dispose(): void {
326
- this.disposed = true;
327
- clearInterval(this.timer);
328
- if (mountedWidget === this) mountedWidget = undefined;
329
- }
330
- }
331
-
332
- /** True while the full-screen lobby is showing; the small widget then stays unmounted. */
333
- let widgetSuppressed: () => boolean = () => false;
334
- let widgetMounted = false;
335
-
336
- /** The lobby registers how to tell whether it covers the screen. */
337
- export function setWidgetSuppressor(check: () => boolean): void {
338
- widgetSuppressed = check;
339
- }
340
-
341
- function unmountWidget(ctx: ExtensionContext): void {
342
- if (!widgetMounted) return;
343
- widgetMounted = false;
344
- ctx.ui.setWidget(STATUS_KEY, undefined);
345
- }
346
-
347
- function leaveZen(ctx: ExtensionContext): void {
348
- if (!zenOn) return;
349
- zenOn = false;
350
- ctx.ui.setWorkingVisible(true);
351
- ctx.ui.setWorkingIndicator();
352
- }
353
-
354
103
  /**
355
- * Refresh the footer + widget to match the task on disk. Runs are merged into the
104
+ * Refresh the footer to match the task on disk. Runs are merged into the
356
105
  * retained set for the same task because each `orchestrate` call reports only its
357
106
  * own agents: without retention the qa/reviewer call that follows the workers would
358
107
  * evict their successes and the checklist would reset. A new task starts clean.
@@ -360,28 +109,12 @@ function leaveZen(ctx: ExtensionContext): void {
360
109
  export function applyStatus(ctx: ExtensionContext, root: string, configDir: string, runs: AgentRun[] = []): void {
361
110
  const sessionId = ctx.sessionManager.getSessionId();
362
111
  const task = isSubagentProcess() || minimized ? undefined : activeTask(root, configDir, sessionId);
363
- zenPaths = { root, configDir };
364
112
  const sameTask = zenState.task?.id === task?.id;
365
113
  setZenState(task, mergeRuns(sameTask ? zenState.live : [], slim(runs)));
366
114
  ctx.ui.setStatus(STATUS_KEY, statusText(task, minimized));
367
- const active = Boolean(task && !TERMINAL_STATES.includes(task.state));
368
- if (!active) {
369
- leaveZen(ctx);
370
- widgetMounted = false;
371
- ctx.ui.setWidget(STATUS_KEY, undefined);
372
- return;
373
- }
374
- zenOn = true;
375
- ctx.ui.setWorkingVisible(false);
376
- ctx.ui.setWorkingIndicator({ frames: [] });
377
- if (widgetSuppressed()) return unmountWidget(ctx);
378
- if (!widgetMounted) {
379
- widgetMounted = true;
380
- ctx.ui.setWidget(STATUS_KEY, (tui) => new ZenWidget(tui, () => ctx.ui.theme));
381
- }
382
115
  }
383
116
 
384
- /** The session's active task as the widget last loaded it (undefined when none or minimized). */
117
+ /** The session's active task as last loaded (undefined when none or minimized). */
385
118
  export function currentZenTask(): Task | undefined {
386
119
  return zenState.task;
387
120
  }
@@ -397,7 +130,7 @@ export function onRunUpdates(listener: ((runs: readonly AgentRun[]) => void) | u
397
130
  * Streamed run updates (start, every activity change, finish) from an in-flight
398
131
  * `orchestrate` call. The task on disk does not change mid-call, so this only
399
132
  * merges the runs and repaints; `applyStatus` rereads the task once the call ends.
400
- * Before any task is loaded it falls back to `applyStatus` so the widget appears.
133
+ * Before any task is loaded it falls back to `applyStatus` so the status appears.
401
134
  */
402
135
  export function reportRuns(ctx: ExtensionContext, root: string, configDir: string, runs: AgentRun[]): void {
403
136
  runListener?.(runs);
@@ -409,12 +142,8 @@ export function reportRuns(ctx: ExtensionContext, root: string, configDir: strin
409
142
  }
410
143
 
411
144
  export function clearStatus(ctx: ExtensionContext): void {
412
- leaveZen(ctx);
413
- widgetMounted = false;
414
- oracleActivity = undefined;
415
145
  zenState = { task: undefined, live: [], runs: [] };
416
146
  ctx.ui.setStatus(STATUS_KEY, undefined);
417
- ctx.ui.setWidget(STATUS_KEY, undefined);
418
147
  }
419
148
 
420
149
  /** Register `alt+t`, the reveal-on-demand toggle for built-in tool rows. */
@@ -425,7 +154,7 @@ export function registerRevealShortcut(pi: ExtensionAPI, configDir: string): voi
425
154
  handler: (ctx) => revealTools(ctx, configDir),
426
155
  });
427
156
  pi.registerShortcut(Key.ctrlShift("m"), {
428
- description: "bot-lobby: minimize or restore the widget for this session",
157
+ description: "bot-lobby: minimize or restore bot-lobby for this session",
429
158
  handler: (ctx) => toggleMinimized(ctx, configDir),
430
159
  });
431
160
  }
@@ -443,3 +172,8 @@ function revealTools(ctx: ExtensionContext, configDir: string): void {
443
172
  "info",
444
173
  );
445
174
  }
175
+
176
+ /** The session's active task and its runs, as the lobby reads them. */
177
+ export function taskSnapshot(): { task: Task | undefined; runs: readonly AgentRun[] } {
178
+ return { task: zenState.task, runs: zenState.runs };
179
+ }
@@ -16,7 +16,7 @@ export const workerSpec: RoleSpec = {
16
16
  contract: [
17
17
  "### Output contract",
18
18
  "Respond with exactly these sections and nothing else: `## Completed`, `## Files Changed`,",
19
- "`## Verification`, `## Notes`, `## Blockers`, `## Dependencies Needed`, `## Architecture Changes`,",
19
+ "`## Verification`, `## Brief Check` (one `- criterion — met|not met — evidence` line per \"Done when\" item of your brief), `## Notes`, `## Blockers`, `## Dependencies Needed`, `## Architecture Changes`,",
20
20
  "`## Knowledge Proposals` (`- knowledge: ...`, `- standard: ...`, or `- decision: ...`).",
21
21
  "`## Files Changed` entries are `- \\`path\\` — change`.",
22
22
  "`## Verification` entries are `- command — result`.",
@@ -31,12 +31,18 @@ export interface AgentModelConfig {
31
31
  instructions?: string;
32
32
  /** Time limit per run; falls back to `workflow.agentTimeoutMs`. */
33
33
  timeoutMs?: number;
34
+ /** Model this agent switches to when its own model runs out of usage or is unavailable; unset or `inherit` = none. */
35
+ fallbackModel?: ModelRef;
36
+ /** Thinking level on the fallback model; unset uses this agent's own `thinking`. */
37
+ fallbackThinking?: string;
34
38
  }
35
39
 
36
40
  /** Scouts pick a model and a time limit only; their thinking is fixed at `SCOUT_THINKING`. */
37
41
  export interface ScoutConfig {
38
42
  model: ModelRef;
39
43
  timeoutMs: number;
44
+ /** Model scouts switch to when theirs runs out of usage or is unavailable (they think at `SCOUT_THINKING` on it too). */
45
+ fallbackModel?: ModelRef;
40
46
  }
41
47
 
42
48
  /** Subagent kinds with their own settings entry. */
@@ -72,6 +78,8 @@ export interface WorkflowConfig {
72
78
  taskBudgetMinutes: number;
73
79
  /** Small, clear, low-risk requests take the fast track (no scouts, proposal or plan; QA only when tests are needed); false puts every task on the full workflow. */
74
80
  fastTrack: boolean;
81
+ /** A delegation that names nothing concrete, or is long without done criteria, is sent back to the oracle once for a fuller brief (sending it again unchanged goes through). */
82
+ briefCheck: boolean;
75
83
  /** A new request that one agent can do alone goes to the quick-fix agent once the oracle confirms; false makes every request a task. */
76
84
  routeQuickFixes: boolean;
77
85
  }
@@ -92,12 +100,10 @@ export function isPanelMember(value: string): value is PanelMember {
92
100
  }
93
101
 
94
102
  /**
95
- * Lobby panes that can be shown or hidden: the Lobby tab's animated oracle and
96
- * agents (off by default: the lobby keeps only the task's status, and the
97
- * animations show above pi's editor while the lobby is hidden), the
98
- * conversation, the activity log and thinking.
103
+ * Lobby panes that can be shown or hidden: the conversation, the activity log
104
+ * and thinking.
99
105
  */
100
- export const LOBBY_PANELS = ["animations", "conversation", "activity", "thinking"] as const;
106
+ export const LOBBY_PANELS = ["conversation", "activity", "thinking"] as const;
101
107
  export type LobbyPanel = (typeof LOBBY_PANELS)[number];
102
108
 
103
109
  /** The full-screen lobby. */
@@ -114,6 +120,8 @@ export interface LobbyConfig {
114
120
  panels: Record<LobbyPanel, boolean>;
115
121
  /** Key overrides by action name, e.g. `{ "toggleThinking": "alt+t" }`. */
116
122
  keys: Record<string, string>;
123
+ /** A one-line status (task steps, planning round, quick fix, or idle) under the editor while the lobby is hidden. */
124
+ miniLine: boolean;
117
125
  /** Clicks and the wheel work in the lobby (click a draft line to comment on it); shift+drag still selects text. */
118
126
  mouse: boolean;
119
127
  /**
@@ -214,6 +222,7 @@ export const DEFAULT_CONFIG: BotLobbyConfig = {
214
222
  freshContext: true,
215
223
  taskBudgetMinutes: 0,
216
224
  fastTrack: true,
225
+ briefCheck: true,
217
226
  routeQuickFixes: true,
218
227
  },
219
228
  knowledge: {
@@ -227,9 +236,10 @@ export const DEFAULT_CONFIG: BotLobbyConfig = {
227
236
  planningPanel: [...PANEL_MEMBERS],
228
237
  autoAsk: true,
229
238
  issues: false,
230
- panels: { animations: false, conversation: true, activity: true, thinking: true },
239
+ panels: { conversation: true, activity: true, thinking: true },
231
240
  keys: {},
232
241
  mouse: true,
242
+ miniLine: true,
233
243
  maxPlanningRounds: 5,
234
244
  },
235
245
  classifier: {
@@ -265,18 +275,22 @@ function positive(value: unknown): number | undefined {
265
275
 
266
276
  /** Merge one agent's override over its default; legacy `inherit` or unknown thinking falls back to the default level. */
267
277
  function normalizeAgent(base: AgentModelConfig, override: Partial<AgentModelConfig> | undefined): AgentModelConfig {
268
- const merged = { ...base, ...(override ?? {}) };
278
+ const { fallbackModel: rawModel, fallbackThinking: rawThinking, ...merged } = { ...base, ...(override ?? {}) };
269
279
  const thinking = isThinkingLevel(merged.thinking) ? merged.thinking : base.thinking;
270
280
  const timeoutMs = positive(merged.timeoutMs) ?? base.timeoutMs;
271
- return { ...merged, thinking, ...(timeoutMs ? { timeoutMs } : {}) };
281
+ const fallbackModel = typeof rawModel === "string" && rawModel.trim() && rawModel !== INHERIT_MODEL ? rawModel.trim() : undefined;
282
+ const fallbackThinking = typeof rawThinking === "string" && isThinkingLevel(rawThinking) ? rawThinking : undefined;
283
+ return { ...merged, thinking, ...(timeoutMs ? { timeoutMs } : {}), ...(fallbackModel ? { fallbackModel, ...(fallbackThinking ? { fallbackThinking } : {}) } : {}) };
272
284
  }
273
285
 
274
286
  /** Scouts keep a model and a time limit; any thinking value in the file is dropped. */
275
287
  function normalizeScout(override: Partial<ScoutConfig> | undefined): ScoutConfig {
276
288
  const base = DEFAULT_CONFIG.scout;
289
+ const fallback = override?.fallbackModel;
277
290
  return {
278
291
  model: typeof override?.model === "string" && override.model.trim() ? override.model : base.model,
279
292
  timeoutMs: positive(override?.timeoutMs) ?? base.timeoutMs,
293
+ ...(typeof fallback === "string" && fallback.trim() && fallback !== INHERIT_MODEL ? { fallbackModel: fallback.trim() } : {}),
280
294
  };
281
295
  }
282
296
 
@@ -285,7 +299,7 @@ function flag(value: unknown, fallback: boolean): boolean {
285
299
  }
286
300
 
287
301
  function normalizeLobby(value: unknown): LobbyConfig {
288
- const source = value as { autoOpen?: unknown; planningPanel?: unknown; autoAsk?: unknown; issues?: unknown; panels?: unknown; keys?: unknown; mouse?: unknown; maxPlanningRounds?: unknown } | undefined;
302
+ const source = value as { autoOpen?: unknown; planningPanel?: unknown; autoAsk?: unknown; issues?: unknown; panels?: unknown; keys?: unknown; mouse?: unknown; miniLine?: unknown; maxPlanningRounds?: unknown } | undefined;
289
303
  const defaults = DEFAULT_CONFIG.lobby;
290
304
  const panel = Array.isArray(source?.planningPanel)
291
305
  ? [...new Set(source.planningPanel.filter((entry): entry is PanelMember => typeof entry === "string" && isPanelMember(entry)))]
@@ -300,6 +314,7 @@ function normalizeLobby(value: unknown): LobbyConfig {
300
314
  panels: Object.fromEntries(LOBBY_PANELS.map((name) => [name, flag(panels[name], defaults.panels[name])])) as Record<LobbyPanel, boolean>,
301
315
  keys: Object.fromEntries(Object.entries(keys).filter((entry): entry is [string, string] => typeof entry[1] === "string" && entry[1].trim().length > 0)),
302
316
  mouse: flag(source?.mouse, defaults.mouse),
317
+ miniLine: flag(source?.miniLine, defaults.miniLine),
303
318
  maxPlanningRounds: roundLimit(source?.maxPlanningRounds, defaults.maxPlanningRounds),
304
319
  };
305
320
  }
@@ -353,6 +368,7 @@ export function resolveConfig(partial: unknown): BotLobbyConfig {
353
368
  const workflow = { ...DEFAULT_CONFIG.workflow, ...(src.workflow as Partial<WorkflowConfig> | undefined) };
354
369
  workflow.freshContext = workflow.freshContext !== false;
355
370
  workflow.fastTrack = workflow.fastTrack !== false;
371
+ workflow.briefCheck = workflow.briefCheck !== false;
356
372
  workflow.routeQuickFixes = workflow.routeQuickFixes !== false;
357
373
  workflow.taskBudgetMinutes = typeof workflow.taskBudgetMinutes === "number" && workflow.taskBudgetMinutes > 0 ? Math.min(24 * 60, Math.round(workflow.taskBudgetMinutes)) : 0;
358
374
  const knowledge = { ...DEFAULT_CONFIG.knowledge, ...(src.knowledge as Partial<KnowledgeConfig> | undefined) };
@@ -388,6 +404,22 @@ export interface AgentProfile {
388
404
  thinking: string;
389
405
  timeoutMs: number;
390
406
  instructions?: string;
407
+ /** Where the run goes when its model runs out of usage or is unavailable; absent = it just fails. */
408
+ fallback?: FallbackProfile;
409
+ }
410
+
411
+ /** The fallback model and the thinking level to run it at. */
412
+ export interface FallbackProfile {
413
+ model: string;
414
+ thinking: string;
415
+ }
416
+
417
+ /** The fallback of a settings entry: its model and thinking (the entry's own level when none is set). */
418
+ export function fallbackOf(entry: { fallbackModel?: string; fallbackThinking?: string; thinking?: string }): FallbackProfile | undefined {
419
+ const model = modelOf(entry.fallbackModel ?? INHERIT_MODEL);
420
+ if (!model) return undefined;
421
+ const own = entry.thinking && isThinkingLevel(entry.thinking) ? entry.thinking : DEFAULT_THINKING;
422
+ return { model, thinking: entry.fallbackThinking && isThinkingLevel(entry.fallbackThinking) ? entry.fallbackThinking : own };
391
423
  }
392
424
 
393
425
  /** The settings entry a domain/role run draws from. */
@@ -412,10 +444,12 @@ export function agentProfile(config: BotLobbyConfig, domain: Domain, role: Role)
412
444
  const instructions = config.agents[domain].instructions;
413
445
  const fallback = config.workflow.agentTimeoutMs;
414
446
  if (kind === "scout") {
415
- return { kind, model: modelOf(config.scout.model), thinking: SCOUT_THINKING, timeoutMs: config.scout.timeoutMs || fallback, instructions };
447
+ const scoutFallback = fallbackOf({ fallbackModel: config.scout.fallbackModel, thinking: SCOUT_THINKING });
448
+ return { kind, model: modelOf(config.scout.model), thinking: SCOUT_THINKING, timeoutMs: config.scout.timeoutMs || fallback, instructions, ...(scoutFallback ? { fallback: scoutFallback } : {}) };
416
449
  }
417
450
  const entry = kind === "researcher" ? config.researcher : config.agents[kind];
418
- return { kind, model: modelOf(entry.model), thinking: entry.thinking, timeoutMs: entry.timeoutMs ?? fallback, instructions };
451
+ const entryFallback = fallbackOf(entry);
452
+ return { kind, model: modelOf(entry.model), thinking: entry.thinking, timeoutMs: entry.timeoutMs ?? fallback, instructions, ...(entryFallback ? { fallback: entryFallback } : {}) };
419
453
  }
420
454
 
421
455
  /** The settings entry of a lobby agent (quick fix or planner). */
@@ -424,19 +458,21 @@ export function lobbyAgentConfig(config: BotLobbyConfig, kind: LobbyAgentKind):
424
458
  }
425
459
 
426
460
  /** Profile for a lobby agent run, from settings alone; `model` is undefined while unset. */
427
- export function lobbyAgentProfile(config: BotLobbyConfig, kind: LobbyAgentKind): { model?: string; thinking: string; timeoutMs: number; instructions?: string } {
461
+ export function lobbyAgentProfile(config: BotLobbyConfig, kind: LobbyAgentKind): { model?: string; thinking: string; timeoutMs: number; instructions?: string; fallback?: FallbackProfile } {
428
462
  const entry = lobbyAgentConfig(config, kind);
429
- return { model: modelOf(entry.model), thinking: entry.thinking, timeoutMs: entry.timeoutMs ?? config.workflow.agentTimeoutMs, instructions: entry.instructions };
463
+ const fallback = fallbackOf(entry);
464
+ return { model: modelOf(entry.model), thinking: entry.thinking, timeoutMs: entry.timeoutMs ?? config.workflow.agentTimeoutMs, instructions: entry.instructions, ...(fallback ? { fallback } : {}) };
430
465
  }
431
466
 
432
467
  /**
433
468
  * A planning panel seat's profile: the domain's (or the researcher's) model,
434
469
  * thinking and custom instructions, bounded by the planner's per-turn limit.
435
470
  */
436
- export function panelMemberProfile(config: BotLobbyConfig, member: PanelMember): { model?: string; thinking: string; timeoutMs: number; instructions?: string } {
471
+ export function panelMemberProfile(config: BotLobbyConfig, member: PanelMember): { model?: string; thinking: string; timeoutMs: number; instructions?: string; fallback?: FallbackProfile } {
437
472
  const entry = member === "researcher" ? config.researcher : config.agents[member];
438
473
  const timeoutMs = config.planner.timeoutMs ?? config.workflow.agentTimeoutMs;
439
- return { model: modelOf(entry.model), thinking: entry.thinking, timeoutMs, instructions: entry.instructions };
474
+ const fallback = fallbackOf(entry);
475
+ return { model: modelOf(entry.model), thinking: entry.thinking, timeoutMs, instructions: entry.instructions, ...(fallback ? { fallback } : {}) };
440
476
  }
441
477
 
442
478
  /** Resolves the model, thinking and time limit one subagent run uses. */
@@ -139,6 +139,8 @@ export interface AgentRun {
139
139
  wrappedUp?: boolean;
140
140
  /** File this worker is queued for at the file desk. */
141
141
  waitingFor?: string;
142
+ /** The run switched to its fallback model because this one ran out of usage or was unavailable. */
143
+ fellBackFrom?: string;
142
144
  /** The classifier routed this run down: the configured profile it came from (`p/big · medium`). */
143
145
  routedFrom?: string;
144
146
  /** Why and where it was routed (`trivial 0.88: p/big · medium → p/cheap · low`). */
@@ -0,0 +1,59 @@
1
+ /**
2
+ * A cheap check that a delegation is a brief a small model can act on: it
3
+ * points at something concrete, and a longer one says when it is done. It only
4
+ * ever asks the master to add detail once: repeating the same instruction
5
+ * unchanged is accepted, so a short task that really is complete never gets
6
+ * stuck behind it. Pure apart from that one-entry-per-step memory.
7
+ */
8
+
9
+ /** Longest instruction that only has to point at something concrete. */
10
+ export const SHORT_BRIEF_CHARS = 200;
11
+
12
+ /** A path, a file name with an extension, a `code` span, a quoted string or a "Files" label. */
13
+ const ANCHOR = /(?:[\w.-]+\/[\w./-]+)|(?:\b[\w-]+\.[a-z]{1,5}\b)|`[^`\n]+`|"[^"\n]{2,}"|\*\*files?\*\*|\bfiles?:/i;
14
+ /** An identifier in camelCase, PascalCase or snake_case (case matters, so it is kept apart). */
15
+ const IDENTIFIER = /\b[a-z]+[A-Z]\w*\b|\b[A-Z][a-z]+[A-Z]\w*\b|\b[a-z]+_[a-z_]+\b/;
16
+ /** Wording that states when the step is finished or how it is checked. */
17
+ const CRITERIA = /done when|definition of done|acceptance|success criteri|verif|must (?:pass|print|return|show|exist|render|display|work)|should (?:pass|print|return|show|exist|render|display|work)|expect|check that|ensure/i;
18
+
19
+ /** What a delegation is missing, one sentence each; empty when it is fine. */
20
+ export function briefProblems(instruction: string): string[] {
21
+ const text = instruction.replace(/\s+/g, " ").trim();
22
+ const problems: string[] = [];
23
+ if (!ANCHOR.test(text) && !IDENTIFIER.test(text)) problems.push("it names no file, path, function, identifier or exact string, so the agent must guess where and what to change");
24
+ if (text.length > SHORT_BRIEF_CHARS && !CRITERIA.test(text)) problems.push("it has no \"Done when\" criteria (observable checks, or commands and what they should show), so the agent cannot tell when it has finished");
25
+ return problems;
26
+ }
27
+
28
+ const lastRejected = new Map<string, string>();
29
+
30
+ const normalize = (text: string) => text.replace(/\s+/g, " ").trim().toLowerCase();
31
+
32
+ /**
33
+ * The error to answer a delegation with, or undefined to let it through. The
34
+ * first time a step's brief falls short it is sent back; sending the same
35
+ * text again means the master has judged it complete.
36
+ */
37
+ export function briefRejection(taskId: string, domain: string, instruction: string): string | undefined {
38
+ const key = `${taskId}:${domain}`;
39
+ const problems = briefProblems(instruction);
40
+ if (problems.length === 0) {
41
+ lastRejected.delete(key);
42
+ return undefined;
43
+ }
44
+ if (lastRejected.get(key) === normalize(instruction)) {
45
+ lastRejected.delete(key);
46
+ return undefined;
47
+ }
48
+ lastRejected.set(key, normalize(instruction));
49
+ return [
50
+ `The ${domain} brief was not sent: ${problems.join("; ")}.`,
51
+ "The agent may be a smaller, literal model that assumes nothing. Rewrite the brief as: Goal, Files, numbered What to do (exact names, shapes, values), Contracts, Constraints, Done when, If stuck.",
52
+ "If it really is complete as written, send the same instruction again and it goes through.",
53
+ ].join(" ");
54
+ }
55
+
56
+ /** Forget what was rejected (tests). */
57
+ export function resetBriefs(): void {
58
+ lastRejected.clear();
59
+ }