@a-t-h-i/bot-lobby 0.6.6 → 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.
@@ -6,6 +6,7 @@
6
6
  * and the lobby feed, and every finished job lands in the metrics log.
7
7
  */
8
8
  import { loadPrompt } from "../prompts/loader.ts";
9
+ import { withFallback } from "../execution/fallback.ts";
9
10
  import { runPiAgent, spawnPiProcess, type PiStreamEvent, type ProcessRunner } from "../execution/pi-runner.ts";
10
11
  import { describeToolCall } from "../pi/activity.ts";
11
12
  import { appendMetrics, type MetricRecord } from "../state/metrics.ts";
@@ -74,6 +75,8 @@ export interface QuickFixProfile {
74
75
  thinking: string;
75
76
  timeoutMs: number;
76
77
  instructions?: string;
78
+ /** Where the job goes when its model runs out of usage or is unavailable. */
79
+ fallback?: { model: string; thinking: string };
77
80
  }
78
81
 
79
82
  export interface QuickFixDeps {
@@ -260,6 +263,17 @@ export class QuickFixQueue {
260
263
  },
261
264
  this.deps.runProcess ?? spawnPiProcess,
262
265
  );
266
+ // A model that is out of usage hands the job to the fallback the settings name.
267
+ const attemptOrFallback = async (model: string | undefined, thinking: string) => {
268
+ const { result: outcome, switchedFrom } = await withFallback(model, thinking, profile.fallback, attempt);
269
+ if (switchedFrom && profile.fallback) {
270
+ job.model = profile.fallback.model;
271
+ job.thinking = profile.fallback.thinking;
272
+ this.addStep(job, `${switchedFrom} is out of usage or unavailable; ran on ${profile.fallback.model} (${profile.fallback.thinking})`, Date.now());
273
+ this.changed();
274
+ }
275
+ return outcome;
276
+ };
263
277
  let result;
264
278
  if (route) {
265
279
  job.route = routeLabel(route);
@@ -267,7 +281,7 @@ export class QuickFixQueue {
267
281
  job.thinking = route.thinking;
268
282
  if (route.model) job.model = route.model;
269
283
  this.changed();
270
- result = await attempt(route.model, route.thinking);
284
+ result = await attemptOrFallback(route.model, route.thinking);
271
285
  if (fellShort(result) && !controller.signal.aborted) {
272
286
  // The routed attempt counts in the metrics on its own; the job re-runs on the configured profile.
273
287
  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}` }]);
@@ -276,10 +290,10 @@ export class QuickFixQueue {
276
290
  delete job.routedFrom;
277
291
  job.thinking = profile.thinking;
278
292
  if (profile.model) job.model = profile.model;
279
- result = await attempt(profile.model, profile.thinking);
293
+ result = await attemptOrFallback(profile.model, profile.thinking);
280
294
  }
281
295
  } else {
282
- result = await attempt(profile.model, profile.thinking);
296
+ result = await attemptOrFallback(profile.model, profile.thinking);
283
297
  }
284
298
  job.status = result.status;
285
299
  job.report = result.output.trim() || undefined;
@@ -35,7 +35,8 @@ import { checkThinking } from "../pi/model-support.ts";
35
35
  import { launchPi, SessionRegistry, type BackgroundSession, type SessionLauncher } from "./sessions.ts";
36
36
  import { SessionChats } from "./session-files.ts";
37
37
  import { answerMessage, askUser, questionnaires, type Asker } from "./ask.ts";
38
- import { QuickFixQueue } from "./quickfix.ts";
38
+ import { jobTitle, QuickFixQueue } from "./quickfix.ts";
39
+ import { miniLine, type MiniInput } from "./mini.ts";
39
40
  import { PlanningSession, type PlannerSeed } from "./planner.ts";
40
41
  import { execCommand, IssuesState } from "./issues.ts";
41
42
  import { LobbyView, type LiveSession, type LobbyHost, type SwitchTarget, type TabId } from "./view.ts";
@@ -94,18 +95,39 @@ let frameTimer: ReturnType<typeof setTimeout> | undefined;
94
95
  * through pi.
95
96
  */
96
97
  function rerender(): void {
97
- if (!runtime?.visible || frameTimer) return;
98
+ // Hidden, only the one-line status under the editor changes.
99
+ if (!runtime || (!runtime.visible && !miniShown(runtime)) || frameTimer) return;
98
100
  const wait = lastFrame + FRAME_MS - Date.now();
99
101
  const paint = () => {
100
102
  frameTimer = undefined;
101
103
  lastFrame = Date.now();
102
- if (runtime?.visible) runtime.tui?.requestRender();
104
+ if (runtime?.visible || (runtime && miniShown(runtime))) runtime.tui?.requestRender();
103
105
  };
104
106
  if (wait <= 0) return paint();
105
107
  frameTimer = setTimeout(paint, wait);
106
108
  frameTimer.unref?.();
107
109
  }
108
110
 
111
+ /** The status line shows under the editor while the lobby is hidden (not while bot-lobby is minimized), unless settings turn it off. */
112
+ function miniShown(state: Runtime): boolean {
113
+ return !state.visible && !isMinimized() && loadConfig().lobby.miniLine;
114
+ }
115
+
116
+ /** What the status line shows: the task, the planning round, the quick fix in hand. */
117
+ function miniInput(state: Runtime): MiniInput {
118
+ const snapshot = taskSnapshot();
119
+ const planner = state.planner;
120
+ const job = state.quickfix.running ?? state.quickfix.jobs.find((entry) => entry.status === "queued");
121
+ const queued = state.quickfix.jobs.filter((entry) => entry.status === "queued" && entry !== job).length;
122
+ return {
123
+ ...(snapshot.task ? { task: snapshot.task } : {}),
124
+ runs: snapshot.runs,
125
+ ...(planner ? { planning: { busy: planner.busy, round: planner.turns, limit: loadConfig().lobby.maxPlanningRounds, questions: planner.awaitingAnswers ? planner.questions.length : 0, ready: planner.reply?.status === "ready", saved: Boolean(planner.saved) } } : {}),
126
+ ...(job ? { quickfix: { title: jobTitle(job), running: job.status === "running", queued } } : {}),
127
+ key: "Alt+L",
128
+ };
129
+ }
130
+
109
131
  function sessionModel(ctx: ExtensionContext): string | undefined {
110
132
  return ctx.model ? modelRef(ctx.model) : undefined;
111
133
  }
@@ -769,7 +791,8 @@ export function initLobby(pi: ExtensionAPI, ctx: ExtensionContext, configDir: st
769
791
  // Capture pi's TUI through a zero-line widget below the editor.
770
792
  ctx.ui.setWidget(ANCHOR_KEY, (tui) => {
771
793
  state.tui = tui;
772
- return { render: () => [], invalidate: () => {} };
794
+ // Zero lines while the lobby is up (or bot-lobby is minimized); one status line when it is hidden.
795
+ return { render: (width: number) => (miniShown(state) ? [miniLine(miniInput(state), width, lobbyTheme(state.ctx.ui.theme))] : []), invalidate: () => {} };
773
796
  }, { placement: "belowEditor" });
774
797
  // The owner's clock (pi/owner.ts) delivers comments and messages and drives auto mode; the lobby logs what it did.
775
798
  onOwnerEvent((event) => {
package/src/lobby/view.ts CHANGED
@@ -22,8 +22,9 @@ import type { BackgroundSession } from "./sessions.ts";
22
22
  import type { QuickFixQueue } from "./quickfix.ts";
23
23
  import { MEMBER_LABELS, ORACLE_LABEL, type PlannerSeed, type PlanningSession } from "./planner.ts";
24
24
  import { issueText, type IssuesState } from "./issues.ts";
25
+ import { agentsIndicator, workingAgents } from "./mini.ts";
25
26
  import { actionFor, keyLabel, keyMap, LOBBY_ACTIONS, type KeyMap, type LobbyAction } from "./keys.ts";
26
- import { beside, bold, box, fit, highlight, paint, rule, selectRow, spinner, spread, windowStart, wrap, wrapHanging, type LobbyTheme, type PaneBox, type PaneLayout, type PaneMark } from "./layout.ts";
27
+ import { beside, bold, box, fit, highlight, pagerButton, paint, rule, selectRow, spinner, spread, windowStart, wrap, wrapHanging, type LobbyTheme, type PaneBox, type PaneLayout, type PaneMark } from "./layout.ts";
27
28
  import { chatTail, HOME_PANES, renderHome, type HomePane } from "./tabs/home.ts";
28
29
  import { filterRows, pips, planDetailLines, renderTasks, taskDetailLines, taskProgress, taskRows, tasksWidths, type TaskRow } from "./tabs/tasks.ts";
29
30
  import { renderPlan, type PlanLayout, type PlanView, type SeatView } from "./tabs/plan.ts";
@@ -816,7 +817,7 @@ export class LobbyView implements Component, Focusable {
816
817
  * end. Lists move their selection instead; the draft moves its cursor (the
817
818
  * wheel scrolls it and the cursor follows only when it would leave the screen).
818
819
  */
819
- private scrollPane(pane: string, delta: number, wheel = false): void {
820
+ private scrollPane(pane: string, delta: number, wheel = false, page = false): void {
820
821
  if (delta === 0) return;
821
822
  switch (pane) {
822
823
  case "conversation":
@@ -828,13 +829,13 @@ export class LobbyView implements Component, Focusable {
828
829
  this.planOffset = Math.max(0, this.planOffset - delta);
829
830
  break;
830
831
  case "draft":
831
- if (wheel) this.wheelDraft(delta);
832
+ if (wheel || page) this.wheelDraft(delta);
832
833
  else this.moveCursor(delta);
833
834
  break;
834
835
  case "list":
835
- if (this.tab === "tasks") this.selectTask(this.tasksSelected + Math.sign(delta));
836
+ if (this.tab === "tasks") this.selectTask(this.tasksSelected + (page ? delta : Math.sign(delta)));
836
837
  else if (this.tab === "quickfix") {
837
- this.fixSelected = Math.max(0, Math.min(this.fixJobs().length - 1, this.fixSelected + Math.sign(delta)));
838
+ this.fixSelected = Math.max(0, Math.min(this.fixJobs().length - 1, this.fixSelected + (page ? delta : Math.sign(delta))));
838
839
  this.fixDetailOffset = 0;
839
840
  }
840
841
  break;
@@ -843,7 +844,7 @@ export class LobbyView implements Component, Focusable {
843
844
  else if (this.tab === "quickfix") this.fixDetailOffset = Math.max(0, this.fixDetailOffset + delta);
844
845
  break;
845
846
  case "table":
846
- this.metricsSelected = Math.max(0, this.metricsSelected + (wheel ? Math.sign(delta) : delta));
847
+ this.metricsSelected = Math.max(0, this.metricsSelected + (wheel && !page ? Math.sign(delta) : delta));
847
848
  break;
848
849
  }
849
850
  this.clampScroll();
@@ -1640,6 +1641,15 @@ export class LobbyView implements Component, Focusable {
1640
1641
  if (this.tab === "lobby" && pane) this.homeFocus = pane as HomePane;
1641
1642
  if (this.tab === "tasks" && (pane === "list" || pane === "detail")) this.tasksFocus = pane;
1642
1643
  if (this.tab === "quickfix" && (pane === "list" || pane === "detail")) this.fixFocus = pane;
1644
+ // The page buttons in a scrolling pane's bottom border scroll it a page or two.
1645
+ const paneBox = pane ? this.panes.get(pane) : undefined;
1646
+ if (pane && paneBox && paneBox.total > paneBox.rows && row === paneBox.top + paneBox.height - 1) {
1647
+ const button = pagerButton(paneBox.width, x - paneBox.left);
1648
+ if (button) {
1649
+ this.scrollPane(pane, button * Math.max(1, paneBox.rows - 1), false, true);
1650
+ return;
1651
+ }
1652
+ }
1643
1653
  if (this.tab !== "plan" || row < 0 || !this.host.planner()) return;
1644
1654
  const layout = this.planLayout;
1645
1655
  // The draft pane: its border sits one row above its first line and two columns left of its text.
@@ -1978,7 +1988,11 @@ export class LobbyView implements Component, Focusable {
1978
1988
  if (this.help) return fit(`${badge("KEYS", "accent")} ${this.chips([["any key", "closes"]], theme)}`, width);
1979
1989
  if (this.searching) return fit(`${badge("SEARCH", "warning")} ${this.chips([["enter", "keep"], ["esc", "clear"], ["↑↓", "results"], [k("nextTab"), "next tab"]], theme)}`, width);
1980
1990
  const mode = this.mode === "type" ? badge("TYPE", "accent") : badge("BROWSE", "muted");
1981
- return fit(`${mode} ${this.chips(this.hintChips(), theme)}`, width);
1991
+ const keys = `${mode} ${this.chips(this.hintChips(), theme)}`;
1992
+ // The subagents at work sit at the right end of the line, when the keys leave room.
1993
+ const working = this.viewing.kind === "here" ? agentsIndicator(workingAgents(this.host.zen().runs, this.host.quickfix.running), this.now(), spinner(this.tick), width - textWidth(keys) - 3, theme) : "";
1994
+ const gap = width - textWidth(keys) - textWidth(working) - 1;
1995
+ return working && gap >= 2 ? `${keys}${" ".repeat(gap)}${working} ` : fit(keys, width);
1982
1996
  }
1983
1997
 
1984
1998
  /**
@@ -2054,6 +2068,7 @@ export class LobbyView implements Component, Focusable {
2054
2068
  ...row("Ctrl+C", "clear the prompt, or hide the lobby", inner),
2055
2069
  ...row("click", "a tab to open it, a pane to give it the keys, a draft plan line to comment on it", inner),
2056
2070
  ...row("wheel", "scroll the pane under the pointer", inner),
2071
+ ...row("▲ ▼", "click the buttons on a pane's bottom edge: a page up or down (▲▲ ▼▼ two pages)", inner),
2057
2072
  ];
2058
2073
  const modes = (inner: number) => [
2059
2074
  section("Typing"),
@@ -49,7 +49,7 @@ export interface ScoutRequest {
49
49
  /** Model, thinking and time limit for one run, from settings. */
50
50
  function profileFields(config: BotLobbyConfig, resolver: ProfileResolver | undefined, domain: Domain, role: AgentRequest["role"]) {
51
51
  const profile = profileFor(config, resolver, domain, role);
52
- return { model: profile.model, thinking: profile.thinking, timeoutMs: profile.timeoutMs };
52
+ return { model: profile.model, thinking: profile.thinking, timeoutMs: profile.timeoutMs, ...(profile.fallback ? { fallback: profile.fallback } : {}) };
53
53
  }
54
54
 
55
55
  function scoutInstruction(request: ScoutRequest, domain: Domain): string {
@@ -88,6 +88,7 @@ export async function runResearch(request: ResearchRequest, run: ProcessRunner =
88
88
  model: profile.model,
89
89
  thinking: profile.thinking,
90
90
  timeoutMs: profile.timeoutMs,
91
+ ...(profile.fallback ? { fallback: profile.fallback } : {}),
91
92
  ...(request.time ? { time: request.time } : {}),
92
93
  cwd: request.cwd,
93
94
  signal: request.signal,
package/src/pi/events.ts CHANGED
@@ -7,6 +7,7 @@ import { selectKnowledge } from "../knowledge/selector.ts";
7
7
  import { cancelAllRuns } from "../execution/agent-runner.ts";
8
8
  import { describeTask } from "../workflow/workflow.ts";
9
9
  import { truncate } from "../text.ts";
10
+ import { registerMasterFallback } from "./master-fallback.ts";
10
11
  import { applyStatus, clearStatus, isMinimized, setMinimized } from "./ui.ts";
11
12
  import { isSubagentProcess, webToolsFor } from "./quiet.ts";
12
13
  import { registerQuietTools } from "./tool-renderers.ts";
@@ -64,6 +65,7 @@ export function masterTaskContext(task: Task, comments: readonly PlanComment[] =
64
65
  * section rather than replacing the whole prompt.
65
66
  */
66
67
  export function registerLifecycle(pi: ExtensionAPI, configDir: string): void {
68
+ registerMasterFallback(pi);
67
69
  // Web tools the oracle put away for the task in hand, given back when it ends.
68
70
  let hiddenWeb: string[] = [];
69
71
  pi.on("session_start", (_event, ctx) => {
@@ -0,0 +1,61 @@
1
+ /**
2
+ * The master runs in your own Pi session, on the session's model. When that
3
+ * model runs out of usage mid-task (a subscription limit, no credit) the turn
4
+ * ends with an error and the task would stall. With a fallback model set for
5
+ * the master, the session switches to it and the oracle carries on.
6
+ */
7
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
8
+ import { looksUnavailable, markUnavailable } from "../execution/fallback.ts";
9
+ import { fallbackOf } from "../schemas/configuration.ts";
10
+ import { loadConfig } from "../state/project.ts";
11
+ import { modelRef } from "./model-support.ts";
12
+ import { modelLookup } from "./tools.ts";
13
+ import { isSubagentProcess } from "./quiet.ts";
14
+
15
+ /** The provider's error when the last thing in the turn was a failed assistant message that says the model is out of usage or unavailable. */
16
+ export function usageFailure(messages: readonly unknown[]): string | undefined {
17
+ const last = messages.at(-1) as { role?: string; stopReason?: string; errorMessage?: string } | undefined;
18
+ if (last?.role !== "assistant" || last.stopReason !== "error") return undefined;
19
+ return looksUnavailable(last.errorMessage) ? last.errorMessage : undefined;
20
+ }
21
+
22
+ /** What the oracle is told after the switch. */
23
+ export function switchedMessage(from: string, to: string): string {
24
+ return `bot-lobby: your last turn failed because ${from} is out of usage or unavailable, so this session switched to ${to}. Carry on with the task from where you left off; do not repeat work that is already done.`;
25
+ }
26
+
27
+ export function registerMasterFallback(pi: ExtensionAPI): void {
28
+ if (isSubagentProcess()) return;
29
+ let failure: string | undefined;
30
+ pi.on("agent_end", (event) => {
31
+ failure = usageFailure(event.messages);
32
+ });
33
+ pi.on("agent_settled", async (_event, ctx: ExtensionContext) => {
34
+ if (!failure) return;
35
+ failure = undefined;
36
+ await switchMaster(pi, ctx);
37
+ });
38
+ }
39
+
40
+ async function switchMaster(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
41
+ const fallback = fallbackOf(loadConfig().master);
42
+ if (!fallback) return;
43
+ const current = ctx.model ? modelRef(ctx.model) : "the session model";
44
+ if (current === fallback.model) {
45
+ ctx.ui.notify(`bot-lobby: ${current} (the master's fallback) is out of usage too — pick another model or wait.`, "warning");
46
+ return;
47
+ }
48
+ const model = modelLookup(ctx)(fallback.model);
49
+ if (!model) {
50
+ ctx.ui.notify(`bot-lobby: the master's fallback model "${fallback.model}" is not known to Pi.`, "warning");
51
+ return;
52
+ }
53
+ if (!(await pi.setModel(model))) {
54
+ ctx.ui.notify(`bot-lobby: no auth for the master's fallback model ${fallback.model}.`, "warning");
55
+ return;
56
+ }
57
+ pi.setThinkingLevel(fallback.thinking as Parameters<ExtensionAPI["setThinkingLevel"]>[0]);
58
+ if (ctx.model) markUnavailable(current);
59
+ ctx.ui.notify(`bot-lobby: ${current} ran out of usage; the master now runs on ${fallback.model} (${fallback.thinking}). Switch back with /model when it is available again.`, "warning");
60
+ await pi.sendUserMessage(switchedMessage(current, fallback.model));
61
+ }
@@ -8,6 +8,7 @@ import {
8
8
  type PanelMember,
9
9
  THINKING_LEVELS,
10
10
  type BotLobbyConfig,
11
+ type FallbackProfile,
11
12
  type ProfileResolver,
12
13
  type SubagentKind,
13
14
  type ThinkingLevelName,
@@ -86,11 +87,25 @@ export function createProfileResolver(config: BotLobbyConfig, options: ResolverO
86
87
  options.warn?.(message);
87
88
  }
88
89
  }
89
- return { ...profile, model, thinking: check.level };
90
+ return { ...profile, model, thinking: check.level, ...clampedFallback(profile.fallback, kindLabel(profile.kind as SubagentKind), options) };
90
91
  };
91
92
  }
92
93
 
93
- type RunProfile = { model?: string; thinking: string; timeoutMs: number; instructions?: string };
94
+ /** The fallback with its thinking level clamped to what the fallback model supports. */
95
+ function clampedFallback(fallback: FallbackProfile | undefined, label: string, options: ResolverOptions): { fallback?: FallbackProfile } {
96
+ if (!fallback) return {};
97
+ const check = checkThinking(options.lookup(fallback.model), fallback.thinking);
98
+ if (check.warning) {
99
+ const message = `bot-lobby: ${label} fallback — ${check.warning}. Change it in /bot-lobby settings.`;
100
+ if (!warned.has(message)) {
101
+ warned.add(message);
102
+ options.warn?.(message);
103
+ }
104
+ }
105
+ return { fallback: { model: fallback.model, thinking: check.level } };
106
+ }
107
+
108
+ type RunProfile = { model?: string; thinking: string; timeoutMs: number; instructions?: string; fallback?: FallbackProfile };
94
109
 
95
110
  /** Unset models run on the session's; thinking is clamped to the model, with a one-time warning. */
96
111
  function resolveRunProfile(profile: RunProfile, label: string, options: ResolverOptions): RunProfile {
@@ -103,7 +118,7 @@ function resolveRunProfile(profile: RunProfile, label: string, options: Resolver
103
118
  options.warn?.(message);
104
119
  }
105
120
  }
106
- return { ...profile, model, thinking: check.level };
121
+ return { ...profile, model, thinking: check.level, ...clampedFallback(profile.fallback, label, options) };
107
122
  }
108
123
 
109
124
  /** A lobby agent's model, thinking and time limit. */
@@ -40,11 +40,14 @@ interface EntryView {
40
40
  thinking?: string;
41
41
  instructions?: string;
42
42
  timeoutMs?: number;
43
+ /** The model this agent switches to when its own runs out of usage; unset = none. */
44
+ fallbackModel?: string;
45
+ fallbackThinking?: string;
43
46
  }
44
47
 
45
48
  function entryView(config: BotLobbyConfig, kind: SettingsKind): EntryView {
46
49
  if (kind === "master") return config.master;
47
- if (kind === "scout") return { model: config.scout.model, timeoutMs: config.scout.timeoutMs };
50
+ if (kind === "scout") return { model: config.scout.model, timeoutMs: config.scout.timeoutMs, ...(config.scout.fallbackModel ? { fallbackModel: config.scout.fallbackModel } : {}) };
48
51
  if (kind === "researcher") return config.researcher;
49
52
  if (kind === "quickfix") return config.quickFix;
50
53
  if (kind === "planner") return config.planner;
@@ -56,21 +59,33 @@ interface EntryPatch {
56
59
  thinking?: string;
57
60
  instructions?: string;
58
61
  timeoutMs?: number;
62
+ /** `inherit` clears the fallback (its thinking level with it). */
63
+ fallbackModel?: string;
64
+ fallbackThinking?: string;
65
+ }
66
+
67
+ /** A settings entry with the fallback patch applied: `inherit` removes the fallback model and its thinking. */
68
+ function withFallbackPatch<T extends { fallbackModel?: string; fallbackThinking?: string }>(entry: T): T {
69
+ if (entry.fallbackModel !== INHERIT_MODEL) return entry;
70
+ const { fallbackModel: _model, fallbackThinking: _thinking, ...rest } = entry;
71
+ return rest as T;
59
72
  }
60
73
 
61
74
  /** Apply a patch to one entry in a config copy; scouts ignore thinking and instructions. */
62
75
  export function patchEntry(config: BotLobbyConfig, kind: SettingsKind, patch: EntryPatch): BotLobbyConfig {
63
76
  const next: BotLobbyConfig = { ...config, agents: { ...config.agents } };
64
- if (kind === "master") next.master = { ...config.master, ...patch } as AgentModelConfig;
77
+ if (kind === "master") next.master = withFallbackPatch({ ...config.master, ...patch } as AgentModelConfig);
65
78
  else if (kind === "scout") {
66
- next.scout = {
79
+ const fallbackModel = patch.fallbackModel ?? config.scout.fallbackModel;
80
+ next.scout = withFallbackPatch({
67
81
  model: patch.model ?? config.scout.model,
68
82
  timeoutMs: patch.timeoutMs ?? config.scout.timeoutMs,
69
- };
70
- } else if (kind === "researcher") next.researcher = { ...config.researcher, ...patch } as AgentModelConfig;
71
- else if (kind === "quickfix") next.quickFix = { ...config.quickFix, ...patch } as AgentModelConfig;
72
- else if (kind === "planner") next.planner = { ...config.planner, ...patch } as AgentModelConfig;
73
- else next.agents[kind] = { ...config.agents[kind], ...patch } as AgentModelConfig;
83
+ ...(fallbackModel ? { fallbackModel } : {}),
84
+ });
85
+ } else if (kind === "researcher") next.researcher = withFallbackPatch({ ...config.researcher, ...patch } as AgentModelConfig);
86
+ else if (kind === "quickfix") next.quickFix = withFallbackPatch({ ...config.quickFix, ...patch } as AgentModelConfig);
87
+ else if (kind === "planner") next.planner = withFallbackPatch({ ...config.planner, ...patch } as AgentModelConfig);
88
+ else next.agents[kind] = withFallbackPatch({ ...config.agents[kind], ...patch } as AgentModelConfig);
74
89
  return next;
75
90
  }
76
91
 
@@ -275,6 +290,37 @@ async function editThinking(pi: ExtensionAPI, ctx: ExtensionContext, kind: Setti
275
290
  await commit(pi, ctx, kind, { thinking: level }, `thinking → ${level}`);
276
291
  }
277
292
 
293
+ /** The model an entry falls back to when its own runs out of usage; `none` removes it. */
294
+ async function editFallbackModel(pi: ExtensionAPI, ctx: ExtensionContext, kind: SettingsKind): Promise<void> {
295
+ const view = entryView(loadConfig(), kind);
296
+ const current = view.fallbackModel ?? INHERIT_MODEL;
297
+ const none: SelectItem = { value: INHERIT_MODEL, label: current === INHERIT_MODEL ? "none ✓" : "none", description: "No fallback: when its model runs out of usage the run fails" };
298
+ const choice = await pick(ctx, `Fallback model — ${kindLabel(kind)}`, [none, ...modelItems(ctx, current, false)], { search: true });
299
+ if (choice === undefined) return;
300
+ const typed = choice === CUSTOM_MODEL ? (await ctx.ui.input("Model id", "provider/model"))?.trim() : choice;
301
+ if (!typed) return;
302
+ await commit(pi, ctx, kind, { fallbackModel: typed }, typed === INHERIT_MODEL ? "fallback removed" : `fallback model → ${typed}`);
303
+ if (typed === INHERIT_MODEL || kind === "scout") return;
304
+ // Keep the fallback's thinking level one its model can run.
305
+ const level = entryView(loadConfig(), kind).fallbackThinking;
306
+ const check = level ? checkThinking(modelLookup(ctx)(typed), level) : undefined;
307
+ if (check?.warning) {
308
+ updateEntry(kind, { fallbackThinking: check.level });
309
+ ctx.ui.notify(`bot-lobby: ${kindLabel(kind)} fallback — ${check.warning}.`, "warning");
310
+ }
311
+ }
312
+
313
+ /** The thinking level on the fallback model, limited to what that model supports. */
314
+ async function editFallbackThinking(pi: ExtensionAPI, ctx: ExtensionContext, kind: SettingsKind): Promise<void> {
315
+ const view = entryView(loadConfig(), kind);
316
+ if (!view.fallbackModel) return;
317
+ const model = modelLookup(ctx)(view.fallbackModel);
318
+ const title = `Fallback thinking — ${kindLabel(kind)} (${view.fallbackModel})`;
319
+ const level = await pick(ctx, title, thinkingItems(supportedThinking(model), view.fallbackThinking ?? view.thinking));
320
+ if (!level || !isThinkingLevel(level)) return;
321
+ await commit(pi, ctx, kind, { fallbackThinking: level }, `fallback thinking → ${level}`);
322
+ }
323
+
278
324
  async function editTimeout(pi: ExtensionAPI, ctx: ExtensionContext, kind: SettingsKind): Promise<void> {
279
325
  const current = entryView(loadConfig(), kind).timeoutMs ?? loadConfig().workflow.agentTimeoutMs;
280
326
  const typed = (await ctx.ui.input(`Time limit (minutes) — ${kindLabel(kind)}`, String(Math.round(current / 60_000))))?.trim();
@@ -303,6 +349,8 @@ export function entryItems(kind: SettingsKind, view: EntryView): SelectItem[] {
303
349
  const items: SelectItem[] = [{ value: "model", label: "Model", description: view.model }];
304
350
  if (kind === "scout") items.push({ value: "fixed", label: "Thinking", description: `${SCOUT_THINKING} (fixed for scouts)` });
305
351
  else items.push({ value: "thinking", label: "Thinking", description: view.thinking ?? "" });
352
+ items.push({ value: "fallback", label: "Fallback model", description: view.fallbackModel ?? "none · used when this model runs out of usage" });
353
+ if (kind !== "scout" && view.fallbackModel) items.push({ value: "fallbackThinking", label: "Fallback thinking", description: view.fallbackThinking ?? view.thinking ?? "" });
306
354
  if (kind !== "master") items.push({ value: "timeout", label: "Time limit", description: minutes(view.timeoutMs) });
307
355
  if (kind !== "scout" && kind !== "researcher") {
308
356
  items.push({ value: "instructions", label: "Instructions", description: view.instructions ? `${view.instructions.length} chars` : "(none)" });
@@ -317,6 +365,8 @@ async function editEntry(pi: ExtensionAPI, ctx: ExtensionContext, kind: Settings
317
365
  if (!action || action === "back") return;
318
366
  if (action === "model") await editModel(pi, ctx, kind);
319
367
  else if (action === "thinking") await editThinking(pi, ctx, kind);
368
+ else if (action === "fallback") await editFallbackModel(pi, ctx, kind);
369
+ else if (action === "fallbackThinking") await editFallbackThinking(pi, ctx, kind);
320
370
  else if (action === "timeout") await editTimeout(pi, ctx, kind);
321
371
  else if (action === "instructions") await editInstructions(pi, ctx, kind);
322
372
  }
@@ -352,11 +402,12 @@ function entryDescription(kind: SettingsKind, view: EntryView): string {
352
402
  const thinking = kind === "scout" ? SCOUT_THINKING : view.thinking;
353
403
  const custom = view.instructions ? " · custom" : "";
354
404
  const limit = kind === "master" ? "" : ` · ${minutes(view.timeoutMs)}`;
355
- return `${view.model} · ${thinking}${limit}${custom}`;
405
+ const fallback = view.fallbackModel ? ` · fallback ${view.fallbackModel}` : "";
406
+ return `${view.model} · ${thinking}${limit}${custom}${fallback}`;
356
407
  }
357
408
 
358
409
  /** The lobby's on/off settings as the settings menu lists them; `panel:*` are the Lobby tab's panes. */
359
- export type LobbySwitch = "autoOpen" | "autoAsk" | "mouse" | "issues" | `panel:${LobbyPanel}`;
410
+ export type LobbySwitch = "autoOpen" | "autoAsk" | "mouse" | "miniLine" | "issues" | `panel:${LobbyPanel}`;
360
411
 
361
412
  const PANEL_SWITCH_LABELS: Record<LobbyPanel, string> = { conversation: "Conversation pane", activity: "Activity log pane", thinking: "Thinking pane" };
362
413
 
@@ -364,6 +415,7 @@ export const LOBBY_SWITCHES: ReadonlyArray<{ id: LobbySwitch; label: string; hel
364
415
  { id: "autoOpen", label: "Open with a task", help: "open the lobby when this session starts or resumes a task" },
365
416
  { id: "autoAsk", label: "Ask at once", help: "put the panel's questions to you as soon as a round ends, while the Plan tab is open" },
366
417
  { id: "mouse", label: "Mouse", help: "click tabs and draft lines, scroll with the wheel (shift+drag still selects text)" },
418
+ { id: "miniLine", label: "Status line when hidden", help: "one line under the editor while the lobby is hidden: task steps, the planning round, a quick fix, or idle" },
367
419
  { id: "issues", label: "Issues tab", help: "the GitHub Issues tab" },
368
420
  ...LOBBY_PANELS.map((panel) => ({
369
421
  id: `panel:${panel}` as const,
@@ -419,6 +471,7 @@ function lobbySummary(config: BotLobbyConfig): string {
419
471
  config.lobby.autoOpen ? "opens with a task" : "opens on alt+l",
420
472
  config.lobby.autoAsk ? "asks at once" : "asks on enter",
421
473
  config.lobby.mouse ? "mouse" : "no mouse",
474
+ ...(config.lobby.miniLine ? [] : ["no status line"]),
422
475
  `planning: ${roundLimitLabel(config.lobby.maxPlanningRounds)}`,
423
476
  ...(config.lobby.issues ? ["issues tab"] : []),
424
477
  ...(hidden.length > 0 ? [`hidden: ${hidden.join(", ")}`] : []),
@@ -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. */
@@ -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
  /**
@@ -231,6 +239,7 @@ export const DEFAULT_CONFIG: BotLobbyConfig = {
231
239
  panels: { conversation: true, activity: true, thinking: true },
232
240
  keys: {},
233
241
  mouse: true,
242
+ miniLine: true,
234
243
  maxPlanningRounds: 5,
235
244
  },
236
245
  classifier: {
@@ -266,18 +275,22 @@ function positive(value: unknown): number | undefined {
266
275
 
267
276
  /** Merge one agent's override over its default; legacy `inherit` or unknown thinking falls back to the default level. */
268
277
  function normalizeAgent(base: AgentModelConfig, override: Partial<AgentModelConfig> | undefined): AgentModelConfig {
269
- const merged = { ...base, ...(override ?? {}) };
278
+ const { fallbackModel: rawModel, fallbackThinking: rawThinking, ...merged } = { ...base, ...(override ?? {}) };
270
279
  const thinking = isThinkingLevel(merged.thinking) ? merged.thinking : base.thinking;
271
280
  const timeoutMs = positive(merged.timeoutMs) ?? base.timeoutMs;
272
- 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 } : {}) } : {}) };
273
284
  }
274
285
 
275
286
  /** Scouts keep a model and a time limit; any thinking value in the file is dropped. */
276
287
  function normalizeScout(override: Partial<ScoutConfig> | undefined): ScoutConfig {
277
288
  const base = DEFAULT_CONFIG.scout;
289
+ const fallback = override?.fallbackModel;
278
290
  return {
279
291
  model: typeof override?.model === "string" && override.model.trim() ? override.model : base.model,
280
292
  timeoutMs: positive(override?.timeoutMs) ?? base.timeoutMs,
293
+ ...(typeof fallback === "string" && fallback.trim() && fallback !== INHERIT_MODEL ? { fallbackModel: fallback.trim() } : {}),
281
294
  };
282
295
  }
283
296
 
@@ -286,7 +299,7 @@ function flag(value: unknown, fallback: boolean): boolean {
286
299
  }
287
300
 
288
301
  function normalizeLobby(value: unknown): LobbyConfig {
289
- 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;
290
303
  const defaults = DEFAULT_CONFIG.lobby;
291
304
  const panel = Array.isArray(source?.planningPanel)
292
305
  ? [...new Set(source.planningPanel.filter((entry): entry is PanelMember => typeof entry === "string" && isPanelMember(entry)))]
@@ -301,6 +314,7 @@ function normalizeLobby(value: unknown): LobbyConfig {
301
314
  panels: Object.fromEntries(LOBBY_PANELS.map((name) => [name, flag(panels[name], defaults.panels[name])])) as Record<LobbyPanel, boolean>,
302
315
  keys: Object.fromEntries(Object.entries(keys).filter((entry): entry is [string, string] => typeof entry[1] === "string" && entry[1].trim().length > 0)),
303
316
  mouse: flag(source?.mouse, defaults.mouse),
317
+ miniLine: flag(source?.miniLine, defaults.miniLine),
304
318
  maxPlanningRounds: roundLimit(source?.maxPlanningRounds, defaults.maxPlanningRounds),
305
319
  };
306
320
  }
@@ -390,6 +404,22 @@ export interface AgentProfile {
390
404
  thinking: string;
391
405
  timeoutMs: number;
392
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 };
393
423
  }
394
424
 
395
425
  /** The settings entry a domain/role run draws from. */
@@ -414,10 +444,12 @@ export function agentProfile(config: BotLobbyConfig, domain: Domain, role: Role)
414
444
  const instructions = config.agents[domain].instructions;
415
445
  const fallback = config.workflow.agentTimeoutMs;
416
446
  if (kind === "scout") {
417
- 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 } : {}) };
418
449
  }
419
450
  const entry = kind === "researcher" ? config.researcher : config.agents[kind];
420
- 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 } : {}) };
421
453
  }
422
454
 
423
455
  /** The settings entry of a lobby agent (quick fix or planner). */
@@ -426,19 +458,21 @@ export function lobbyAgentConfig(config: BotLobbyConfig, kind: LobbyAgentKind):
426
458
  }
427
459
 
428
460
  /** Profile for a lobby agent run, from settings alone; `model` is undefined while unset. */
429
- 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 } {
430
462
  const entry = lobbyAgentConfig(config, kind);
431
- 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 } : {}) };
432
465
  }
433
466
 
434
467
  /**
435
468
  * A planning panel seat's profile: the domain's (or the researcher's) model,
436
469
  * thinking and custom instructions, bounded by the planner's per-turn limit.
437
470
  */
438
- 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 } {
439
472
  const entry = member === "researcher" ? config.researcher : config.agents[member];
440
473
  const timeoutMs = config.planner.timeoutMs ?? config.workflow.agentTimeoutMs;
441
- 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 } : {}) };
442
476
  }
443
477
 
444
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`). */