@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/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";
@@ -54,8 +55,8 @@ const PROMPT_FIRST: ReadonlySet<TabId> = new Set(["lobby", "plan", "quickfix"]);
54
55
  export type LobbyMode = "type" | "browse";
55
56
 
56
57
  /** 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" };
58
+ const PANEL_NAMES: Record<LobbyPanel, string> = { conversation: "conversation", activity: "activity log", thinking: "thinking" };
59
+ const PANEL_ACTIONS: Record<LobbyPanel, LobbyAction> = { conversation: "toggleConversation", activity: "toggleActivity", thinking: "toggleThinking" };
59
60
 
60
61
  /**
61
62
  * Which session the Lobby tab shows and talks to: this window, one it started
@@ -125,12 +126,8 @@ export interface LobbyHost {
125
126
  rows(): number;
126
127
  theme(): LobbyTheme;
127
128
  sessionId(): string | undefined;
128
- /** This session's active task and its runs, as the zen widget sees them. */
129
+ /** This session's active task and its runs, as the lobby reads them. */
129
130
  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
131
  feed: LobbyFeed;
135
132
  masterBusy(): boolean;
136
133
  tasks(): Task[];
@@ -199,14 +196,12 @@ export interface LobbyHost {
199
196
  hasOlderChat(sessionId: string): boolean;
200
197
  /** A session's whole conversation, oldest first: this window's (no id) or another's; loaded only while scrolled back to it. */
201
198
  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
199
  profileLabel(kind: LobbyAgentKind): string;
205
200
  requestRender(): void;
206
201
  now?(): number;
207
202
  }
208
203
 
209
- /** Live work speeds the clock up so spinners and the scene move. */
204
+ /** Live work speeds the clock up so spinners move. */
210
205
  export const LIVE_MS = 250;
211
206
  export const IDLE_MS = 1000;
212
207
  /** How often task and metrics data is reread from disk while the lobby is open. */
@@ -424,16 +419,13 @@ export class LobbyView implements Component, Focusable {
424
419
  || Boolean(zen.task && !zen.task.paused && !TERMINAL_STATES.includes(zen.task.state));
425
420
  }
426
421
 
427
- /** One clock step: advance the scene, reread data when due, repaint. */
422
+ /** One clock step: reread data when due, repaint. */
428
423
  private step(): void {
429
424
  if (!this.running) return;
430
- const now = this.now();
431
425
  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
426
  this.refreshData(false);
435
427
  this.host.requestRender();
436
- this.schedule(delay);
428
+ this.schedule(this.isLive() ? LIVE_MS : IDLE_MS);
437
429
  }
438
430
 
439
431
  /** Reread tasks, plans, comments and metrics from disk (throttled unless forced). */
@@ -675,8 +667,6 @@ export class LobbyView implements Component, Focusable {
675
667
  return this.cycleTab(1);
676
668
  case "prevTab":
677
669
  return this.cycleTab(-1);
678
- case "toggleScene":
679
- return this.togglePanel("animations");
680
670
  case "toggleConversation":
681
671
  return this.togglePanel("conversation");
682
672
  case "toggleActivity":
@@ -827,7 +817,7 @@ export class LobbyView implements Component, Focusable {
827
817
  * end. Lists move their selection instead; the draft moves its cursor (the
828
818
  * wheel scrolls it and the cursor follows only when it would leave the screen).
829
819
  */
830
- private scrollPane(pane: string, delta: number, wheel = false): void {
820
+ private scrollPane(pane: string, delta: number, wheel = false, page = false): void {
831
821
  if (delta === 0) return;
832
822
  switch (pane) {
833
823
  case "conversation":
@@ -839,13 +829,13 @@ export class LobbyView implements Component, Focusable {
839
829
  this.planOffset = Math.max(0, this.planOffset - delta);
840
830
  break;
841
831
  case "draft":
842
- if (wheel) this.wheelDraft(delta);
832
+ if (wheel || page) this.wheelDraft(delta);
843
833
  else this.moveCursor(delta);
844
834
  break;
845
835
  case "list":
846
- 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)));
847
837
  else if (this.tab === "quickfix") {
848
- 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))));
849
839
  this.fixDetailOffset = 0;
850
840
  }
851
841
  break;
@@ -854,7 +844,7 @@ export class LobbyView implements Component, Focusable {
854
844
  else if (this.tab === "quickfix") this.fixDetailOffset = Math.max(0, this.fixDetailOffset + delta);
855
845
  break;
856
846
  case "table":
857
- 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));
858
848
  break;
859
849
  }
860
850
  this.clampScroll();
@@ -1651,6 +1641,15 @@ export class LobbyView implements Component, Focusable {
1651
1641
  if (this.tab === "lobby" && pane) this.homeFocus = pane as HomePane;
1652
1642
  if (this.tab === "tasks" && (pane === "list" || pane === "detail")) this.tasksFocus = pane;
1653
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
+ }
1654
1653
  if (this.tab !== "plan" || row < 0 || !this.host.planner()) return;
1655
1654
  const layout = this.planLayout;
1656
1655
  // The draft pane: its border sits one row above its first line and two columns left of its text.
@@ -1989,7 +1988,11 @@ export class LobbyView implements Component, Focusable {
1989
1988
  if (this.help) return fit(`${badge("KEYS", "accent")} ${this.chips([["any key", "closes"]], theme)}`, width);
1990
1989
  if (this.searching) return fit(`${badge("SEARCH", "warning")} ${this.chips([["enter", "keep"], ["esc", "clear"], ["↑↓", "results"], [k("nextTab"), "next tab"]], theme)}`, width);
1991
1990
  const mode = this.mode === "type" ? badge("TYPE", "accent") : badge("BROWSE", "muted");
1992
- 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);
1993
1996
  }
1994
1997
 
1995
1998
  /**
@@ -2065,6 +2068,7 @@ export class LobbyView implements Component, Focusable {
2065
2068
  ...row("Ctrl+C", "clear the prompt, or hide the lobby", inner),
2066
2069
  ...row("click", "a tab to open it, a pane to give it the keys, a draft plan line to comment on it", inner),
2067
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),
2068
2072
  ];
2069
2073
  const modes = (inner: number) => [
2070
2074
  section("Typing"),
@@ -2295,7 +2299,7 @@ export class LobbyView implements Component, Focusable {
2295
2299
  if (entry.view.kind !== "here") return this.otherSessionBody(entry, width, height, theme, now);
2296
2300
  const talk = this.conversationFor(entry);
2297
2301
  return renderHome({
2298
- ...(zen.task ? { task: zen.task, scene: (w: number, h: number, animated: boolean) => this.host.scene(w, h, animated) } : {}),
2302
+ ...(zen.task ? { task: zen.task } : {}),
2299
2303
  chat: talk.chat,
2300
2304
  ...(talk.older ? { olderNote: OLDER_NOTE } : {}),
2301
2305
  ...(feed.reply ? { liveReply: feed.reply } : {}),
@@ -2313,7 +2317,6 @@ export class LobbyView implements Component, Focusable {
2313
2317
  panels: this.panels,
2314
2318
  ...(query ? { query } : {}),
2315
2319
  keys: {
2316
- animations: keyLabel(this.keys.toggleScene),
2317
2320
  conversation: keyLabel(this.keys.toggleConversation),
2318
2321
  activity: keyLabel(this.keys.toggleActivity),
2319
2322
  thinking: keyLabel(this.keys.toggleThinking),
@@ -2323,8 +2326,7 @@ export class LobbyView implements Component, Focusable {
2323
2326
 
2324
2327
  /**
2325
2328
  * Another session on the Lobby tab: a background session's live feed, or an
2326
- * other terminal's conversation read from its session file; the task's
2327
- * status sits on top without animations.
2329
+ * other terminal's conversation read from its session file.
2328
2330
  */
2329
2331
  private otherSessionBody(entry: SessionEntry, width: number, height: number, theme: LobbyTheme, now: number): string[] {
2330
2332
  const session = this.viewedSession();
@@ -2332,7 +2334,7 @@ export class LobbyView implements Component, Focusable {
2332
2334
  const task = entry.task;
2333
2335
  const query = this.query();
2334
2336
  const common = {
2335
- ...(task ? { task, scene: (w: number, h: number) => this.host.taskScene(task, w, h), stillScene: true } : {}),
2337
+ ...(task ? { task } : {}),
2336
2338
  others: 0,
2337
2339
  pending: 0,
2338
2340
  offsets: this.homeOffsets,
@@ -2345,7 +2347,6 @@ export class LobbyView implements Component, Focusable {
2345
2347
  ...(query ? { query } : {}),
2346
2348
  title: entry.name,
2347
2349
  keys: {
2348
- animations: keyLabel(this.keys.toggleScene),
2349
2350
  conversation: keyLabel(this.keys.toggleConversation),
2350
2351
  activity: keyLabel(this.keys.toggleActivity),
2351
2352
  thinking: keyLabel(this.keys.toggleThinking),
@@ -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,
@@ -1,8 +1,6 @@
1
1
  /**
2
2
  * One-word descriptions of the tool an agent is running: subagents show it as
3
- * `[spinner] word - elapsed`, and the oracle names the master's `orchestrate`
4
- * action. Pure lookup:
5
- * no clock, environment or I/O reads.
3
+ * `[spinner] word - elapsed`. Pure lookup: no clock, environment or I/O reads.
6
4
  */
7
5
  const ACTIVITY_WORDS: Record<string, string> = {
8
6
  read: "reading",
@@ -22,46 +20,16 @@ const ACTIVITY_WORDS: Record<string, string> = {
22
20
 
23
21
  const FALLBACK_WORD = "working";
24
22
 
25
- /** What the oracle says between its own tool calls while a master turn runs. */
26
- export const ORACLE_THINKING = "thinking";
27
-
28
23
  export function activityWord(toolName: string): string {
29
24
  return ACTIVITY_WORDS[toolName.trim().toLowerCase()] ?? FALLBACK_WORD;
30
25
  }
31
26
 
32
- /** Master `orchestrate` actions -> the word the oracle shows instead of the generic "orchestrating". */
33
- const ORACLE_ACTION_WORDS: Record<string, string> = {
34
- clarify: "asking",
35
- scout: "scouting",
36
- research: "researching",
37
- propose: "proposing",
38
- plan: "planning",
39
- implement: "delegating",
40
- qa: "reviewing",
41
- knowledge: "recording",
42
- compact: "recording",
43
- decide: "deciding",
44
- resolve_approval: "deciding",
45
- complete: "wrapping up",
46
- status: "checking",
47
- block: "blocking",
48
- resume: "resuming",
49
- cancel: "cancelling",
50
- };
51
-
52
27
  function orchestrateAction(args: unknown): string | undefined {
53
28
  if (!args || typeof args !== "object" || !("action" in args)) return undefined;
54
29
  const action = (args as { action?: unknown }).action;
55
30
  return typeof action === "string" ? action.trim().toLowerCase() : undefined;
56
31
  }
57
32
 
58
- /** One word for what the oracle (master) is doing; `orchestrate` maps by its action. */
59
- export function oracleActivityWord(toolName: string, args?: unknown): string {
60
- if (toolName.trim().toLowerCase() !== "orchestrate") return activityWord(toolName);
61
- const action = orchestrateAction(args);
62
- return (action && ORACLE_ACTION_WORDS[action]) || FALLBACK_WORD;
63
- }
64
-
65
33
  const DETAIL_CHARS = 28;
66
34
 
67
35
  function clip(text: string): string {
package/src/pi/events.ts CHANGED
@@ -7,8 +7,8 @@ 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 { applyStatus, clearStatus, isMinimized, setMinimized, setOracleActivity } from "./ui.ts";
11
- import { ORACLE_THINKING, oracleActivityWord } from "./activity.ts";
10
+ import { registerMasterFallback } from "./master-fallback.ts";
11
+ import { applyStatus, clearStatus, isMinimized, setMinimized } from "./ui.ts";
12
12
  import { isSubagentProcess, webToolsFor } from "./quiet.ts";
13
13
  import { registerQuietTools } from "./tool-renderers.ts";
14
14
  import { taskRequest, type Task, type TaskState } from "../schemas/task.ts";
@@ -65,6 +65,7 @@ export function masterTaskContext(task: Task, comments: readonly PlanComment[] =
65
65
  * section rather than replacing the whole prompt.
66
66
  */
67
67
  export function registerLifecycle(pi: ExtensionAPI, configDir: string): void {
68
+ registerMasterFallback(pi);
68
69
  // Web tools the oracle put away for the task in hand, given back when it ends.
69
70
  let hiddenWeb: string[] = [];
70
71
  pi.on("session_start", (_event, ctx) => {
@@ -76,26 +77,16 @@ export function registerLifecycle(pi: ExtensionAPI, configDir: string): void {
76
77
  });
77
78
 
78
79
 
79
- // The oracle's speech bubble mirrors the master's own turn: thinking between
80
- // tool calls, the tool's word during one, and silent (your turn) once it ends.
81
- // Subagents report into runs instead.
82
- // Parallel tool calls: the newest still-running call keeps the word.
83
- const inFlight = new Map<string, string>();
84
- const showOracle = () => setOracleActivity([...inFlight.values()].at(-1) ?? ORACLE_THINKING);
85
80
  // A task's time budget counts while the oracle works on it, not while it waits on the user.
86
81
  const asking = new Set<string>();
87
82
  pi.on("agent_start", (_event, ctx) => {
88
83
  if (isSubagentProcess()) return;
89
- inFlight.clear();
90
- showOracle();
91
84
  const root = detectProjectRoot(ctx.cwd, configDir);
92
85
  const task = activeTask(root, configDir, ctx.sessionManager.getSessionId());
93
86
  if (task) startClock(root, configDir, task.id);
94
87
  });
95
88
  pi.on("tool_execution_start", (event) => {
96
89
  if (isSubagentProcess()) return;
97
- inFlight.set(event.toolCallId, oracleActivityWord(event.toolName, event.args));
98
- showOracle();
99
90
  if (ASKING_TOOLS.has(event.toolName) && !asking.has(event.toolCallId)) {
100
91
  asking.add(event.toolCallId);
101
92
  pauseClocks();
@@ -103,14 +94,10 @@ export function registerLifecycle(pi: ExtensionAPI, configDir: string): void {
103
94
  });
104
95
  pi.on("tool_execution_end", (event) => {
105
96
  if (isSubagentProcess()) return;
106
- inFlight.delete(event.toolCallId);
107
- showOracle();
108
97
  if (asking.delete(event.toolCallId)) resumeClocks();
109
98
  });
110
99
  pi.on("agent_end", () => {
111
100
  if (isSubagentProcess()) return;
112
- inFlight.clear();
113
- setOracleActivity(undefined);
114
101
  for (const _call of asking) resumeClocks();
115
102
  asking.clear();
116
103
  stopClocks();
@@ -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. */