@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 +56 -1
- package/package.json +1 -1
- package/prompts/master.md +6 -0
- package/src/ask/dialog.ts +4 -2
- package/src/ask/state.ts +10 -1
- package/src/ask/tool.ts +6 -1
- package/src/ask/view.ts +2 -1
- package/src/execution/agent-runner.ts +23 -1
- package/src/execution/fallback.ts +75 -0
- package/src/lobby/feed.ts +7 -0
- package/src/lobby/layout.ts +22 -1
- package/src/lobby/mini.ts +159 -0
- package/src/lobby/planner.ts +10 -3
- package/src/lobby/quickfix.ts +17 -3
- package/src/lobby/runtime.ts +27 -4
- package/src/lobby/view.ts +22 -7
- package/src/master/master.ts +1 -1
- package/src/master/research.ts +1 -0
- package/src/pi/events.ts +2 -0
- package/src/pi/master-fallback.ts +61 -0
- package/src/pi/model-support.ts +18 -3
- package/src/pi/settings-ui.ts +63 -10
- package/src/schemas/configuration.ts +43 -9
- package/src/schemas/findings.ts +2 -0
- package/src/workflow/workflow.ts +12 -2
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
|
+

|
|
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`
|
|
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
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 (
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/src/lobby/layout.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/src/lobby/planner.ts
CHANGED
|
@@ -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
|
|
597
|
+
const attempt = (model: string | undefined, thinking: string) => runPiAgent(
|
|
597
598
|
{
|
|
598
599
|
cwd: this.deps.cwd,
|
|
599
600
|
...request,
|
|
600
|
-
model
|
|
601
|
-
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 } };
|