@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.
package/README.md CHANGED
@@ -273,6 +273,25 @@ Rebind any key under `lobby.keys` in the config.
273
273
  Pi session. The Lobby tab can show any session, and your prompt steers it;
274
274
  `● waiting` in the tab bar means one has a question for you.
275
275
 
276
+ **Agents at work.** The bottom line of the lobby shows the subagents running
277
+ right now at its right end (`◐ DESIGN editing 2m · DEV 40s`, a running quick
278
+ fix too), shrinking to names and then a count when the keys leave little room.
279
+
280
+ **Paging.** A pane with more lines than rows shows page buttons on its bottom
281
+ edge, `▲▲ ▲ ▼ ▼▼`: click one to scroll a page up or down (`▲▲` and `▼▼` go two
282
+ pages). The wheel, the arrows and PageUp/PageDown still work. It applies to
283
+ every scrolling pane: the conversation, activity and thinking, the plan draft,
284
+ the Tasks and Quick fix lists and details, and the metrics table.
285
+
286
+ **Status line when hidden.** With the lobby hidden (`alt+l`), one line under
287
+ Pi's editor shows where things stand: a bar of the task's plan steps (or its
288
+ stage before there is a plan) with who is working, the planning round and the
289
+ questions waiting for you, the quick fix in hand, or `idle`. It costs nothing
290
+ while nothing changes. Turn it off with `lobby.miniLine: false` (or in
291
+ `/bot-lobby settings` → Lobby).
292
+
293
+ ![The status line under the editor while the lobby is hidden: a task, planning, idle](https://raw.githubusercontent.com/a-t-h-i/bot-lobby/main/docs/lobby-status-line.png)
294
+
276
295
  The conversation keeps its newest 100 messages in memory; scroll to the top
277
296
  to load the rest.
278
297
 
@@ -307,7 +326,10 @@ can be compared by looking at them.
307
326
 
308
327
  `↑↓` move · `enter` choose · `space` pick several (multi-select) · `1`–`4`
309
328
  pick · `←→` between questions · the last row takes an answer in your own words
310
- · `esc` puts the questions away (what you answered is kept). Editor hosts that
329
+ · `esc` asks whether to leave (a second `enter`
330
+ leaves, anything else keeps you answering), so a stray press does nothing.
331
+ Questions you leave are never answered for you: the oracle waits and asks again
332
+ when you next write, and the designer asks again before it may decide. Editor hosts that
311
333
  run Pi in RPC mode get the same questions through Pi's own dialogs.
312
334
 
313
335
  **Images.** An option can also carry an `image`: a PNG, JPEG, GIF or WebP
@@ -413,9 +435,42 @@ the result.
413
435
  `off, minimal, low, medium, high, xhigh, max`, limited to what the model
414
436
  supports. Scouts always think at `low`.
415
437
  - `instructions` adds your own text to an agent's built-in prompt.
438
+ - `fallbackModel` and `fallbackThinking` on any agent (and the master): see
439
+ [Fallback models](#fallback-models).
416
440
  - Classifier thresholds and limits (`classifier.thresholds`,
417
441
  `classifier.fileHints`) are edited in the file.
418
442
 
443
+ ## Fallback models
444
+
445
+ Running the oracle on a subscription model and the agents on another provider
446
+ means one of them can run out of usage mid-task. Give each agent class a
447
+ **fallback model** and the **thinking level** to run it at (`/bot-lobby
448
+ settings` → the agent → *Fallback model* / *Fallback thinking*). When a run
449
+ fails because its model is out of usage, rate-limited, out of credit or
450
+ unavailable, it runs again on the fallback instead of failing the task.
451
+
452
+ - Works for the master, DESIGN, DEV, QA, the researcher, scouts (their fallback
453
+ thinks at `low` too), quick fixes and the planner and its panel seats.
454
+ - The exhausted model is skipped for 20 minutes, so the next agents go straight
455
+ to their fallback instead of each spending a failed run finding out.
456
+ - **The master** is your own Pi session: on a usage failure the session
457
+ switches to its fallback model and thinking level, tells you, and the oracle
458
+ carries on from where it stopped. Switch back with `/model` when your usage
459
+ returns.
460
+ - Only usage, limit and availability errors switch model; an ordinary failure
461
+ still retries on the same model. If the fallback fails the same way, the run
462
+ fails: it does not chain to a third model.
463
+ - The activity log says when an agent switched, and the run's receipts and the
464
+ Metrics tab show the model that actually ran.
465
+
466
+ ```json
467
+ {
468
+ "master": { "model": "anthropic/claude-fable-5-1", "thinking": "high", "fallbackModel": "deepseek/deepseek-v3", "fallbackThinking": "medium" },
469
+ "agents": { "backend": { "model": "zai/glm-4.6", "thinking": "medium", "fallbackModel": "deepseek/deepseek-v3", "fallbackThinking": "low" } },
470
+ "scout": { "model": "zai/glm-4.6", "fallbackModel": "deepseek/deepseek-v3" }
471
+ }
472
+ ```
473
+
419
474
  ## What the engine enforces
420
475
 
421
476
  | Rule | How |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a-t-h-i/bot-lobby",
3
- "version": "0.6.6",
3
+ "version": "0.6.7",
4
4
  "description": "Structured multi-agent software engineering orchestrator for Pi",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
package/prompts/master.md CHANGED
@@ -68,6 +68,12 @@ more), skip the researcher when research is not needed, clarify only when it
68
68
  reads the request as ambiguous — and overrule it whenever the repository says
69
69
  otherwise. It is a hint, never a rule.
70
70
 
71
+ When the user leaves your questions unanswered (they put them away, or
72
+ `ask_user_question` says so), the decision is still theirs: never assume the
73
+ answers, never fall back on the recommended options, and never carry on with
74
+ work that depends on them. Say in one short line that the questions are
75
+ waiting, end your turn, and ask again when they next write.
76
+
71
77
  When you `clarify` with options, put your recommended option first and mark
72
78
  it `(Recommended)`. When the request already makes it clearly right, the
73
79
  classifier answers for you: the reply says so, the decision is recorded, and
package/src/ask/dialog.ts CHANGED
@@ -9,7 +9,7 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
9
9
  import { Key, matchesKey, type Component, type TUI } from "@earendil-works/pi-tui";
10
10
  import type { LobbyTheme } from "../lobby/layout.ts";
11
11
  import { lobbyTheme } from "../lobby/theme.ts";
12
- import { initialState, step, type AskKey, type AskState } from "./state.ts";
12
+ import { initialState, putAway, step, type AskKey, type AskState } from "./state.ts";
13
13
  import { renderAsk, type AskFrame } from "./view.ts";
14
14
  import { loadImages } from "./image.ts";
15
15
  import { isAbsolute, resolve } from "node:path";
@@ -68,7 +68,9 @@ export class AskDialog implements Component {
68
68
 
69
69
  /** Put the questions away from outside (the turn was aborted). */
70
70
  cancel(): void {
71
- if (!this.state.result) this.handleInput("\x1b");
71
+ if (this.state.result) return;
72
+ this.state = putAway(this.state);
73
+ this.done(this.state.result!);
72
74
  }
73
75
 
74
76
  render(width: number): string[] {
package/src/ask/state.ts CHANGED
@@ -38,10 +38,17 @@ export interface AskState {
38
38
  /** Writing the own answer of the question in view. */
39
39
  editing: boolean;
40
40
  draft: string;
41
+ /** Esc was pressed: asked whether to leave without answering; enter leaves, any other key keeps answering. */
42
+ leaving?: boolean;
41
43
  /** Set once the user submits or puts the questions away. */
42
44
  result?: AskResult;
43
45
  }
44
46
 
47
+ /** The questions put away, with what was answered so far (also when something outside ends them). */
48
+ export function putAway(state: AskState): AskState {
49
+ return { ...state, editing: false, draft: "", leaving: false, result: { answers: answersOf(state), cancelled: true } };
50
+ }
51
+
45
52
  export function initialState(questions: readonly AskQuestion[]): AskState {
46
53
  return {
47
54
  questions,
@@ -127,6 +134,8 @@ function typing(state: AskState, key: AskKey): AskState {
127
134
  /** One key press. */
128
135
  export function step(state: AskState, key: AskKey): AskState {
129
136
  if (state.result) return state;
137
+ // Esc asks first: leaving the questions unanswered lets the oracle carry on without you, so it takes a second key.
138
+ if (state.leaving) return key.type === "enter" || (key.type === "text" && key.value.toLowerCase() === "y") ? putAway(state) : { ...state, leaving: false };
130
139
  if (state.editing) return typing(state, key);
131
140
  const question = state.questions[state.tab];
132
141
  if (!question) return { ...state, result: { answers: [], cancelled: false } };
@@ -153,7 +162,7 @@ export function step(state: AskState, key: AskKey): AskState {
153
162
  return advance(state);
154
163
  }
155
164
  case "escape":
156
- return { ...state, result: { answers: answersOf(state), cancelled: true } };
165
+ return { ...state, leaving: true };
157
166
  default:
158
167
  return state;
159
168
  }
package/src/ask/tool.ts CHANGED
@@ -65,12 +65,16 @@ export function invalidQuestions(questions: readonly AskQuestion[]): string | un
65
65
  return undefined;
66
66
  }
67
67
 
68
+ /** What the model does with questions the user left unanswered: wait, never fill them in. */
69
+ const STILL_OPEN = "Do not assume answers, do not pick the recommended options, and do not go on with work that depends on them. Say in one short line that the questions are waiting, end your turn, and put them to the user again when they next write.";
70
+ const NOT_ANSWERED = `The user left the questions without answering. ${STILL_OPEN}`;
71
+
68
72
  /** What the model reads back: each question with its answer, or that it was skipped. */
69
73
  export function answerSummary(questions: readonly AskQuestion[], result: AskResult): string {
70
74
  if (result.cancelled && result.answers.length === 0) {
71
75
  // A relay that could not ask anyone (auto mode) says why.
72
76
  if (result.globalNote) return result.globalNote;
73
- return "The user put the questions away without answering. Do not ask the same again right away: go on with your best judgement and say what you assumed, or ask something narrower.";
77
+ return NOT_ANSWERED;
74
78
  }
75
79
  const lines = questions.map((question, index) => {
76
80
  const answer = result.answers.find((entry) => entry.questionIndex === index);
@@ -82,6 +86,7 @@ export function answerSummary(questions: readonly AskQuestion[], result: AskResu
82
86
  return [
83
87
  result.cancelled ? "The user answered some questions, then put the rest away:" : "The user answered:",
84
88
  ...lines,
89
+ ...(result.cancelled ? ["", `The questions marked "(not answered)" are still open. ${STILL_OPEN}`] : []),
85
90
  ...(result.globalNote ? ["", `Note: ${result.globalNote}`] : []),
86
91
  ].join("\n");
87
92
  }
package/src/ask/view.ts CHANGED
@@ -112,6 +112,7 @@ function previewBox(preview: Preview, width: number, rows: number, theme: LobbyT
112
112
  }
113
113
 
114
114
  function hints(state: AskState, question: AskQuestion, width: number, theme?: LobbyTheme): string[] {
115
+ if (state.leaving) return wrap(paint(theme, "warning", "Leave without answering? The oracle will not guess for you. enter leaves · any other key keeps answering"), width);
115
116
  const parts = state.editing
116
117
  ? ["enter keep it", "esc back to the options"]
117
118
  : [
@@ -119,7 +120,7 @@ function hints(state: AskState, question: AskQuestion, width: number, theme?: Lo
119
120
  question.multiSelect ? "space pick · enter next" : "enter choose",
120
121
  `1-${question.options.length} pick`,
121
122
  ...(state.questions.length > 1 ? ["←→ questions"] : []),
122
- "esc put away",
123
+ "esc leave",
123
124
  ];
124
125
  return wrap(paint(theme, "dim", parts.join(" · ")), width);
125
126
  }
@@ -7,6 +7,7 @@ import { shortDuration, truncate } from "../text.ts";
7
7
  import { EditLog } from "../state/changes.ts";
8
8
  import { formatMinutes, REPORT_GRACE_MS } from "../state/budget.ts";
9
9
  import { runPiAgent, spawnPiProcess, type PiStreamEvent, type ProcessRunner, type RelayAsk } from "./pi-runner.ts";
10
+ import { isUnavailable, looksUnavailable, markUnavailable, usableFallback } from "./fallback.ts";
10
11
  import { ASK_ENV } from "../ask/relay.ts";
11
12
  import { ASK_TOOL } from "../ask/types.ts";
12
13
 
@@ -40,6 +41,10 @@ export interface AgentRequest {
40
41
  context: AgentContext;
41
42
  model?: string;
42
43
  thinking?: string;
44
+ /** Where the run goes when its model runs out of usage or is unavailable. */
45
+ fallback?: { model: string; thinking: string };
46
+ /** Set by the runner once it has switched: the model that ran out. */
47
+ fellBackFrom?: string;
43
48
  timeoutMs: number;
44
49
  cwd: string;
45
50
  signal?: AbortSignal;
@@ -135,13 +140,24 @@ function retryable(run: AgentRun): boolean {
135
140
  */
136
141
  export async function runAgent(request: AgentRequest, run: ProcessRunner = spawnPiProcess): Promise<AgentRun> {
137
142
  const startedAt = new Date().toISOString();
138
- const attempts = Math.max(1, (request.retries ?? 0) + 1);
143
+ let attempts = Math.max(1, (request.retries ?? 0) + 1);
139
144
  // A failed attempt may have edited files before the retry: the run owns every edit.
140
145
  const edits = new EditLog(request.cwd);
141
146
  let last: AgentRun | undefined;
147
+ // A model already known to be out of usage goes straight to the fallback.
148
+ const other = usableFallback(request.fallback, request.model);
149
+ if (other && isUnavailable(request.model)) request = switchToFallback(request, other, request.model ?? "the session model");
142
150
  for (let attempt = 1; attempt <= attempts; attempt++) {
143
151
  last = await runAgentOnce(request, run, attempt, startedAt, edits);
144
152
  request.onAttemptEnd?.(last);
153
+ // Out of usage (or the model unavailable): the same model would fail again, so the fallback takes the retry.
154
+ const fallback = usableFallback(request.fallback, request.model);
155
+ if (fallback && !request.fellBackFrom && last.status === "failed" && looksUnavailable(last.error)) {
156
+ if (request.model) markUnavailable(request.model);
157
+ request = switchToFallback(request, fallback, request.model ?? "the session model");
158
+ attempts += 1;
159
+ continue;
160
+ }
145
161
  if (!retryable(last)) break;
146
162
  // Under a budget a retry only uses what is left of the allotment.
147
163
  if (request.time && request.time.endsAt - Date.now() < MIN_ATTEMPT_MS) break;
@@ -150,6 +166,11 @@ export async function runAgent(request: AgentRequest, run: ProcessRunner = spawn
150
166
  return edited.length > 0 ? { ...last!, edited } : last!;
151
167
  }
152
168
 
169
+ /** The request moved to its fallback model and thinking level. */
170
+ function switchToFallback(request: AgentRequest, fallback: { model: string; thinking?: string }, from: string): AgentRequest {
171
+ return { ...request, model: fallback.model, thinking: fallback.thinking ?? request.thinking, fellBackFrom: from };
172
+ }
173
+
153
174
  /** Abort every in-flight subagent (session shutdown, user cancel). */
154
175
  export function cancelAllRuns(): void {
155
176
  for (const controller of activeControllers) controller.abort();
@@ -168,6 +189,7 @@ function baseRun(request: AgentRequest, runId: string, startedAt: string, attemp
168
189
  attempts,
169
190
  startedAt,
170
191
  ...(request.thinking ? { thinking: request.thinking } : {}),
192
+ ...(request.fellBackFrom ? { fellBackFrom: request.fellBackFrom } : {}),
171
193
  ...(request.routedFrom ? { routedFrom: request.routedFrom } : {}),
172
194
  ...(request.route ? { route: request.route } : {}),
173
195
  ...(request.time ? { allotMs: request.time.allotMs, endsAt: request.time.endsAt } : {}),
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Fallback models. A subscription or key that runs out mid-task (a usage
3
+ * limit, a rate limit, no credit, the provider down or refusing the key)
4
+ * fails every run on that model the same way, so the run goes to the agent's
5
+ * configured fallback model instead, and the exhausted model is skipped for a
6
+ * while so the next agents do not each spend a failed run finding out.
7
+ */
8
+
9
+ /** What a provider says when a model cannot be used right now. Deliberately narrow: an ordinary failure is not a reason to switch model. */
10
+ const UNAVAILABLE = [
11
+ /usage limit/i, /rate.?limit/i, /\bquota\b/i, /limit (?:reached|exceeded|will reset)/i, /exceeded (?:your|the|current)/i,
12
+ /out of (?:usage|credits?|extra usage|tokens)/i, /(?:insufficient|no) (?:credits?|funds|balance|quota)/i, /credit balance/i,
13
+ /billing/i, /payment required/i, /\b402\b/, /\b429\b/, /too many requests/i, /resource.?exhausted/i,
14
+ /overloaded/i, /\b529\b/, /(?:at|over) capacity/i, /resets? (?:at|in|on)/i, /subscription/i,
15
+ /unauthori[sz]ed/i, /\b401\b/, /invalid (?:api )?key/i, /no api key/i, /authentication (?:failed|error)/i,
16
+ /model (?:is )?(?:not found|not available|unavailable)/i, /unknown model/i, /does not have access/i,
17
+ ];
18
+
19
+ /** True when a failed run's error says its model is out of usage or unavailable, so another model may succeed. */
20
+ export function looksUnavailable(error: string | undefined): boolean {
21
+ return Boolean(error && UNAVAILABLE.some((pattern) => pattern.test(error)));
22
+ }
23
+
24
+ /** How long a model that ran out is skipped before it is tried again. */
25
+ export const COOLDOWN_MS = 20 * 60 * 1000;
26
+
27
+ const exhausted = new Map<string, number>();
28
+
29
+ /** Remember that `model` cannot be used until the cooldown passes. */
30
+ export function markUnavailable(model: string, now = Date.now()): void {
31
+ exhausted.set(model, now + COOLDOWN_MS);
32
+ }
33
+
34
+ /** Whether `model` ran out recently enough to skip. */
35
+ export function isUnavailable(model: string | undefined, now = Date.now()): boolean {
36
+ if (!model) return false;
37
+ const until = exhausted.get(model);
38
+ if (until === undefined) return false;
39
+ if (until <= now) {
40
+ exhausted.delete(model);
41
+ return false;
42
+ }
43
+ return true;
44
+ }
45
+
46
+ /** Forget every exhausted model (tests). */
47
+ export function resetUnavailable(): void {
48
+ exhausted.clear();
49
+ }
50
+
51
+ /** A fallback worth switching to: a model other than the one that failed. */
52
+ export function usableFallback<F extends { model: string }>(fallback: F | undefined, current: string | undefined): F | undefined {
53
+ return fallback && fallback.model !== current ? fallback : undefined;
54
+ }
55
+
56
+ /**
57
+ * Run `attempt` on `model`; when it fails because that model is out of usage
58
+ * or unavailable, run it again on the fallback. A model already known to be
59
+ * out goes straight to the fallback. `switched` says which model took over.
60
+ */
61
+ export async function withFallback<T extends { status: string; error?: string }>(
62
+ model: string | undefined,
63
+ thinking: string,
64
+ fallback: { model: string; thinking: string } | undefined,
65
+ attempt: (model: string | undefined, thinking: string) => Promise<T>,
66
+ ): Promise<{ result: T; switchedFrom?: string }> {
67
+ const other = usableFallback(fallback, model);
68
+ if (other && isUnavailable(model)) return { result: await attempt(other.model, other.thinking), switchedFrom: model ?? "the session model" };
69
+ const result = await attempt(model, thinking);
70
+ if (other && result.status === "failed" && looksUnavailable(result.error)) {
71
+ if (model) markUnavailable(model);
72
+ return { result: await attempt(other.model, other.thinking), switchedFrom: model ?? "the session model" };
73
+ }
74
+ return { result };
75
+ }
package/src/lobby/feed.ts CHANGED
@@ -70,6 +70,8 @@ export class LobbyFeed {
70
70
  private readonly runSteps = new Map<string, string>();
71
71
  private readonly runThoughts = new Map<string, string>();
72
72
  private readonly runStatus = new Map<string, AgentRun["status"]>();
73
+ /** Runs already reported as switched to their fallback model. */
74
+ private readonly fellBack = new Set<string>();
73
75
 
74
76
  onChange(listener: () => void): () => void {
75
77
  this.listeners.add(listener);
@@ -200,6 +202,10 @@ export class LobbyFeed {
200
202
  const before = this.runStatus.get(key);
201
203
  this.runStatus.set(key, run.status);
202
204
  if (before === undefined) this.log(source, `started as ${run.role}`, "info", at);
205
+ if (run.fellBackFrom && !this.fellBack.has(key)) {
206
+ this.fellBack.add(key);
207
+ this.log(source, `${run.fellBackFrom} is out of usage or unavailable: running on the fallback model`, "warning", at);
208
+ }
203
209
  if (run.step && this.runSteps.get(key) !== run.step) {
204
210
  this.runSteps.set(key, run.step);
205
211
  this.step(source, run.step, key, at);
@@ -225,6 +231,7 @@ export class LobbyFeed {
225
231
  this.runSteps.clear();
226
232
  this.runThoughts.clear();
227
233
  this.runStatus.clear();
234
+ this.fellBack.clear();
228
235
  this.touch();
229
236
  }
230
237
  }
@@ -268,6 +268,24 @@ export function scrollThumb(total: number, rows: number, start: number): { from:
268
268
  return { from, to: from + size };
269
269
  }
270
270
 
271
+ /** Page buttons in the bottom border of a pane that scrolls: two pages up, one up, one down, two down. */
272
+ export const PAGER = "▲▲ ▲ ▼ ▼▼";
273
+ /** Columns the pager takes with a space on each side. */
274
+ export const PAGER_WIDTH = PAGER.length + 2;
275
+ /** Narrowest box that has room for the pager beside its corners. */
276
+ const PAGER_MIN_WIDTH = PAGER_WIDTH + 6;
277
+
278
+ /** Which page button a column of a scrollable pane's bottom border is: -2, -1, 1 or 2 pages (negative up), or undefined. */
279
+ export function pagerButton(width: number, column: number): -2 | -1 | 1 | 2 | undefined {
280
+ if (width < PAGER_MIN_WIDTH) return undefined;
281
+ const from = column - width;
282
+ if (from >= -12 && from <= -10) return -2;
283
+ if (from >= -9 && from <= -7) return -1;
284
+ if (from >= -6 && from <= -5) return 1;
285
+ if (from >= -4 && from <= -2) return 2;
286
+ return undefined;
287
+ }
288
+
271
289
  /**
272
290
  * A rounded panel exactly `width` × `height`: the title set into the top
273
291
  * border, `content` inside with one column of padding, extra lines cut and
@@ -291,7 +309,10 @@ export function box(width: number, height: number, content: readonly string[], o
291
309
  const thumb = options.scroll ? scrollThumb(options.scroll.total, height - 2, options.scroll.start) : undefined;
292
310
  const rightEdge = (row: number) => (thumb && row >= thumb.from && row < thumb.to ? paint(theme, focused ? "accent" : "muted", "┃") : edge("│"));
293
311
  const rows = fill(content, height - 2).map((line, row) => `${edge("│")} ${fit(line, inner)} ${rightEdge(row)}`);
294
- return [top, ...rows, `${edge("╰")}${edge("─".repeat(width - 2))}${edge("╯")}`];
312
+ // A pane with more lines than rows carries page buttons in its bottom border; `pagerButton` finds them again for clicks.
313
+ const pager = thumb && width >= PAGER_MIN_WIDTH ? ` ${PAGER} ` : "";
314
+ const bottom = `${edge("╰")}${edge("─".repeat(width - 2 - textWidth(pager)))}${pager ? paint(theme, focused ? "accent" : "muted", pager) : ""}${edge("╯")}`;
315
+ return [top, ...rows, bottom];
295
316
  }
296
317
 
297
318
  /** Lay boxes out side by side, each already exactly its width and the same height. */
@@ -0,0 +1,159 @@
1
+ /**
2
+ * The one-line status shown under pi's editor while the lobby is hidden: a
3
+ * small graphic of where the work stands — the task's plan steps (or its stage
4
+ * before there is a plan), the planning rounds, the quick fix in hand — or a
5
+ * quiet "idle". Pure: everything it shows arrives as input.
6
+ */
7
+ import { truncateToWidth } from "@earendil-works/pi-tui";
8
+ import type { AgentRun } from "../schemas/findings.ts";
9
+ import type { Task, TaskState } from "../schemas/task.ts";
10
+ import { planChecklist } from "../pi/plan-checklist.ts";
11
+ import { agentName } from "../pi/run-summary.ts";
12
+ import { paint, type LobbyTheme } from "./layout.ts";
13
+
14
+ export interface MiniPlanning {
15
+ /** A round is running. */
16
+ busy: boolean;
17
+ /** The round in hand, and the limit (0 = unlimited). */
18
+ round: number;
19
+ limit: number;
20
+ /** Questions waiting for the user. */
21
+ questions: number;
22
+ /** The oracle's draft is ready to save. */
23
+ ready: boolean;
24
+ /** The plan was saved as a pending task. */
25
+ saved: boolean;
26
+ }
27
+
28
+ export interface MiniQuickFix {
29
+ title: string;
30
+ running: boolean;
31
+ queued: number;
32
+ }
33
+
34
+ export interface MiniInput {
35
+ task?: Task;
36
+ runs: readonly AgentRun[];
37
+ planning?: MiniPlanning;
38
+ quickfix?: MiniQuickFix;
39
+ /** The key that opens the lobby, as shown. */
40
+ key: string;
41
+ }
42
+
43
+ /** A full-workflow task's stages, in order, for the strip shown before its plan has steps. */
44
+ const STAGES: readonly TaskState[] = ["clarifying", "scouting", "synthesizing", "awaiting_approval", "planning", "implementing", "reviewing"];
45
+
46
+ /** Widest step bar; longer plans share its cells. */
47
+ const MAX_CELLS = 12;
48
+
49
+ /** `■■◐□□`: the plan's steps done, current and to do, squeezed into at most `MAX_CELLS` cells. */
50
+ export function stepBar(statuses: readonly ("done" | "current" | "pending")[]): string {
51
+ if (statuses.length === 0) return "";
52
+ const cells = Math.min(statuses.length, MAX_CELLS);
53
+ const out: string[] = [];
54
+ for (let cell = 0; cell < cells; cell += 1) {
55
+ const from = Math.floor((cell * statuses.length) / cells);
56
+ const to = Math.max(from + 1, Math.floor(((cell + 1) * statuses.length) / cells));
57
+ const slice = statuses.slice(from, to);
58
+ out.push(slice.every((status) => status === "done") ? "■" : slice.some((status) => status === "current") ? "◐" : slice.some((status) => status === "done") ? "◐" : "□");
59
+ }
60
+ return out.join("");
61
+ }
62
+
63
+ /** `●●◐○○○○`: how far along the workflow a task without a plan yet is. */
64
+ export function stageBar(state: TaskState): string {
65
+ const at = STAGES.indexOf(state);
66
+ if (at < 0) return "";
67
+ return STAGES.map((_stage, index) => (index < at ? "●" : index === at ? "◐" : "○")).join("");
68
+ }
69
+
70
+ /** `■■□□□` for `round` of `limit`, or empty without a limit. */
71
+ function roundBar(round: number, limit: number): string {
72
+ if (limit <= 0) return "";
73
+ const done = Math.min(limit, Math.max(0, round));
74
+ return "■".repeat(done) + "□".repeat(limit - done);
75
+ }
76
+
77
+ const STATE_WORDS: Partial<Record<TaskState, string>> = {
78
+ awaiting_approval: "awaiting your approval",
79
+ };
80
+
81
+ function taskSegment(input: MiniInput, theme?: LobbyTheme): string | undefined {
82
+ const task = input.task;
83
+ if (!task || task.state === "completed" || task.state === "abandoned") return undefined;
84
+ const steps = task.plan ? planChecklist(task.plan, input.runs) : [];
85
+ const done = steps.filter((step) => step.status === "done").length;
86
+ const bar = steps.length > 0 ? `${stepBar(steps.map((step) => step.status))} ${done}/${steps.length}` : stageBar(task.state);
87
+ const state = task.paused ? `${task.state} (paused)` : STATE_WORDS[task.state] ?? task.state;
88
+ const running = [...input.runs].reverse().find((run) => run.status === "running");
89
+ const doing = running ? ` · ${agentName(running)}${running.activity ? ` ${running.activity}` : ""}` : "";
90
+ const alert = task.state === "awaiting_approval" || task.paused;
91
+ return `${paint(theme, "accent", task.id)} ${paint(theme, "success", bar)} ${paint(theme, alert ? "warning" : "muted", state)}${paint(theme, "dim", doing)}`;
92
+ }
93
+
94
+ function planningSegment(planning: MiniPlanning | undefined, theme?: LobbyTheme): string | undefined {
95
+ if (!planning || planning.saved) return undefined;
96
+ const rounds = roundBar(planning.round, planning.limit);
97
+ const head = `${paint(theme, "accent", "planning")} ${paint(theme, "success", rounds || `round ${planning.round}`)}${rounds ? paint(theme, "muted", ` ${planning.round}/${planning.limit}`) : ""}`;
98
+ if (planning.questions > 0) return `${head} ${paint(theme, "warning", `● ${planning.questions} question${planning.questions === 1 ? "" : "s"} for you`)}`;
99
+ if (planning.busy) return `${head} ${paint(theme, "muted", "the panel is thinking")}`;
100
+ if (planning.ready) return `${head} ${paint(theme, "success", "plan ready to save")}`;
101
+ return head;
102
+ }
103
+
104
+ function quickFixSegment(quickfix: MiniQuickFix | undefined, theme?: LobbyTheme): string | undefined {
105
+ if (!quickfix) return undefined;
106
+ const queued = quickfix.queued > 0 ? ` +${quickfix.queued} queued` : "";
107
+ return `${paint(theme, "accent", "quick fix")} ${paint(theme, "success", quickfix.running ? "◐" : "○")} ${paint(theme, "muted", quickfix.title)}${paint(theme, "dim", queued)}`;
108
+ }
109
+
110
+ /** The status line, at most `width` columns. */
111
+ export function miniLine(input: MiniInput, width: number, theme?: LobbyTheme): string {
112
+ if (width < 12) return "";
113
+ const segments = [taskSegment(input, theme), planningSegment(input.planning, theme), quickFixSegment(input.quickfix, theme)].filter((segment): segment is string => Boolean(segment));
114
+ const body = segments.length > 0 ? segments.join(paint(theme, "dim", " │ ")) : paint(theme, "dim", "idle");
115
+ const hint = paint(theme, "dim", ` ${input.key} opens`);
116
+ const line = ` ${paint(theme, "accent", "◆ bot-lobby")} ${body}`;
117
+ return truncateToWidth(`${line}${hint}`, width, "…");
118
+ }
119
+
120
+ /** An agent at work right now, for the lobby's bottom-line indicator. */
121
+ export interface WorkingAgent {
122
+ name: string;
123
+ activity?: string;
124
+ /** Epoch ms it started, for its elapsed time. */
125
+ since?: number;
126
+ }
127
+
128
+ /** The subagents running now: task runs still going, and the quick fix in hand. */
129
+ export function workingAgents(runs: readonly AgentRun[], quickFix?: { startedAt?: number }): WorkingAgent[] {
130
+ const agents: WorkingAgent[] = [];
131
+ for (const run of runs) {
132
+ if (run.status !== "running") continue;
133
+ const since = Date.parse(run.startedAt);
134
+ agents.push({ name: agentName(run), ...(run.activity ? { activity: run.activity } : {}), ...(Number.isFinite(since) ? { since } : {}) });
135
+ }
136
+ if (quickFix) agents.push({ name: "QUICK FIX", ...(quickFix.startedAt ? { since: quickFix.startedAt } : {}) });
137
+ return agents;
138
+ }
139
+
140
+ function elapsed(ms: number): string {
141
+ const seconds = Math.max(0, Math.floor(ms / 1000));
142
+ return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m`;
143
+ }
144
+
145
+ /**
146
+ * `◐ DESIGN editing 2m · DEV running 40s`, at most `width` columns: it gives up
147
+ * the activity and time, then the names, then shows only a count, and nothing
148
+ * when even that does not fit or nothing is working. `spin` is the spinner glyph.
149
+ */
150
+ export function agentsIndicator(agents: readonly WorkingAgent[], now: number, spin: string, width: number, theme?: LobbyTheme): string {
151
+ if (agents.length === 0) return "";
152
+ const full = agents.map((agent) => `${agent.name}${agent.activity ? ` ${agent.activity}` : ""}${agent.since ? ` ${elapsed(now - agent.since)}` : ""}`).join(" · ");
153
+ const names = [...new Set(agents.map((agent) => agent.name))].join(" · ");
154
+ const count = `${agents.length} agent${agents.length === 1 ? "" : "s"} working`;
155
+ for (const text of [full, names, count]) {
156
+ if (text.length + 2 <= width) return `${paint(theme, "accent", spin)} ${paint(theme, "muted", text)}`;
157
+ }
158
+ return "";
159
+ }
@@ -20,6 +20,7 @@
20
20
  */
21
21
  import { loadPrompt } from "../prompts/loader.ts";
22
22
  import { compilePrompt } from "../prompts/compiler.ts";
23
+ import { withFallback } from "../execution/fallback.ts";
23
24
  import { runPiAgent, spawnPiProcess, type PiStreamEvent, type ProcessRunner } from "../execution/pi-runner.ts";
24
25
  import { describeToolCall } from "../pi/activity.ts";
25
26
  import { appendMetrics, type MetricRecord } from "../state/metrics.ts";
@@ -593,12 +594,12 @@ export class PlanningSession {
593
594
  const startedAt = Date.now();
594
595
  let outcome: RunOutcome;
595
596
  try {
596
- const result = await runPiAgent(
597
+ const attempt = (model: string | undefined, thinking: string) => runPiAgent(
597
598
  {
598
599
  cwd: this.deps.cwd,
599
600
  ...request,
600
- model: profile.model,
601
- thinking: profile.thinking,
601
+ model,
602
+ thinking,
602
603
  timeoutMs: profile.timeoutMs,
603
604
  signal,
604
605
  stallTimeoutMs: this.deps.stallTimeoutMs,
@@ -607,6 +608,12 @@ export class PlanningSession {
607
608
  },
608
609
  this.deps.runProcess ?? spawnPiProcess,
609
610
  );
611
+ // A model that is out of usage hands the turn to the fallback the settings name.
612
+ const { result, switchedFrom } = await withFallback(profile.model, profile.thinking, profile.fallback, attempt);
613
+ if (switchedFrom && profile.fallback) {
614
+ this.deps.feed?.log(label, `${switchedFrom} is out of usage or unavailable; ran on ${profile.fallback.model}`, "warning");
615
+ profile = { ...profile, model: profile.fallback.model, thinking: profile.fallback.thinking };
616
+ }
610
617
  outcome = { status: result.status, output: result.output, ...(result.error ? { error: result.error } : {}), ...(result.model ? { model: result.model } : {}), usage: result.usage };
611
618
  } catch (error) {
612
619
  outcome = { status: "failed", output: "", error: (error as Error).message, usage: { input: 0, output: 0, cost: 0, turns: 0 } };