@a-t-h-i/bot-lobby 0.4.0 → 0.5.1
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 +238 -10
- package/package.json +5 -2
- package/prompts/master.md +12 -0
- package/prompts/panel.md +44 -0
- package/prompts/planner.md +69 -0
- package/prompts/quickfix.md +41 -0
- package/src/execution/agent-runner.ts +7 -2
- package/src/execution/pi-runner.ts +25 -0
- package/src/index.ts +3 -0
- package/src/lobby/ask.ts +226 -0
- package/src/lobby/feed.ts +253 -0
- package/src/lobby/issues.ts +227 -0
- package/src/lobby/keys.ts +51 -0
- package/src/lobby/layout.ts +363 -0
- package/src/lobby/markdown.ts +48 -0
- package/src/lobby/planner.ts +581 -0
- package/src/lobby/quickfix.ts +227 -0
- package/src/lobby/runtime.ts +591 -0
- package/src/lobby/tabs/home.ts +242 -0
- package/src/lobby/tabs/issues.ts +72 -0
- package/src/lobby/tabs/metrics.ts +275 -0
- package/src/lobby/tabs/plan.ts +242 -0
- package/src/lobby/tabs/quickfix.ts +129 -0
- package/src/lobby/tabs/tasks.ts +243 -0
- package/src/lobby/view.ts +1610 -0
- package/src/pi/activity.ts +120 -0
- package/src/pi/commands.ts +19 -57
- package/src/pi/events.ts +5 -2
- package/src/pi/model-support.ts +34 -0
- package/src/pi/run-summary.ts +2 -0
- package/src/pi/settings-ui.ts +69 -1
- package/src/pi/start-task.ts +63 -0
- package/src/pi/tools.ts +2 -2
- package/src/pi/ui.ts +116 -55
- package/src/schemas/configuration.ts +102 -3
- package/src/schemas/findings.ts +6 -0
- package/src/schemas/task.ts +1 -0
- package/src/state/backlog.ts +106 -0
- package/src/state/comments.ts +136 -0
- package/src/state/metrics.ts +305 -0
- package/src/workflow/workflow.ts +39 -7
|
@@ -0,0 +1,591 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The lobby inside pi: one runtime per interactive session. It mounts the
|
|
3
|
+
* full-screen view as an overlay on pi's own TUI (captured through a zero-line
|
|
4
|
+
* anchor widget, so pi does not treat the lobby as a blocking dialog), steps
|
|
5
|
+
* aside while a real dialog asks the user something, narrates the Master's own
|
|
6
|
+
* turn into the feed, passes plan comments to the Master that owns a task and
|
|
7
|
+
* records each Master turn in the metrics log.
|
|
8
|
+
*/
|
|
9
|
+
import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { getSelectListTheme } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import { Key, type OverlayHandle, type TUI } from "@earendil-works/pi-tui";
|
|
12
|
+
import { TERMINAL_STATES } from "../schemas/task.ts";
|
|
13
|
+
import { listTasks, loadTask } from "../state/persistence.ts";
|
|
14
|
+
import { detectProjectRoot, loadConfig, saveConfig } from "../state/project.ts";
|
|
15
|
+
import { addPlanComment, commentMessage, markCommentsDelivered, readPlanComments, undeliveredComments } from "../state/comments.ts";
|
|
16
|
+
import { discardPlannedTask, listPlannedTasks, markPlannedTaskStarted, plannedTaskRequest, type PlannedTask } from "../state/backlog.ts";
|
|
17
|
+
import { appendMetrics, readMetrics, type MetricStatus } from "../state/metrics.ts";
|
|
18
|
+
import { describeToolCall } from "../pi/activity.ts";
|
|
19
|
+
import { applyStatus, currentZenTask, isMinimized, onMinimizeChange, onRunUpdates, setMinimized, setWidgetSuppressor, ZenScene, zenSnapshot } from "../pi/ui.ts";
|
|
20
|
+
import { isSubagentProcess } from "../pi/quiet.ts";
|
|
21
|
+
import { modelRef, resolveLobbyProfile, resolvePanelProfile } from "../pi/model-support.ts";
|
|
22
|
+
import { modelLookup } from "../pi/tools.ts";
|
|
23
|
+
import { startTask } from "../pi/start-task.ts";
|
|
24
|
+
import type { LobbyAgentKind, LobbyPanel, PanelMember } from "../schemas/configuration.ts";
|
|
25
|
+
import { chatFromEntries, chatText, lobbyFeed, textOf } from "./feed.ts";
|
|
26
|
+
import { answerMessage, dialogAsker, loadAskTool, questionnaires, toolAsker, type Asker } from "./ask.ts";
|
|
27
|
+
import { QuickFixQueue } from "./quickfix.ts";
|
|
28
|
+
import { PlanningSession, type PlannerSeed } from "./planner.ts";
|
|
29
|
+
import { execCommand, IssuesState } from "./issues.ts";
|
|
30
|
+
import { LobbyView, type LobbyHost, type TabId } from "./view.ts";
|
|
31
|
+
import type { LobbyTheme } from "./layout.ts";
|
|
32
|
+
import { createMarkdownRenderer } from "./markdown.ts";
|
|
33
|
+
import { openEntrySettings, openSettings } from "../pi/settings-ui.ts";
|
|
34
|
+
|
|
35
|
+
export const ANCHOR_KEY = "bot-lobby-anchor";
|
|
36
|
+
/** How often the owning session looks for new plan comments. */
|
|
37
|
+
export const COMMENT_POLL_MS = 3000;
|
|
38
|
+
|
|
39
|
+
interface Runtime {
|
|
40
|
+
pi: ExtensionAPI;
|
|
41
|
+
configDir: string;
|
|
42
|
+
ctx: ExtensionContext;
|
|
43
|
+
root: string;
|
|
44
|
+
tui?: TUI;
|
|
45
|
+
view?: LobbyView;
|
|
46
|
+
handle?: OverlayHandle;
|
|
47
|
+
visible: boolean;
|
|
48
|
+
/** Stepped aside while pi shows a dialog. */
|
|
49
|
+
asideForPrompt: boolean;
|
|
50
|
+
scene: ZenScene;
|
|
51
|
+
quickfix: QuickFixQueue;
|
|
52
|
+
planner?: PlanningSession;
|
|
53
|
+
issues: IssuesState;
|
|
54
|
+
poll?: ReturnType<typeof setInterval>;
|
|
55
|
+
unsubscribeFeed?: () => void;
|
|
56
|
+
/** The ask-user-question tool (or pi's dialogs), loaded once on first use. */
|
|
57
|
+
asker?: Promise<Asker>;
|
|
58
|
+
/** A questionnaire is on screen. */
|
|
59
|
+
asking: boolean;
|
|
60
|
+
/** The lobby turned the terminal's mouse reporting on (pi's regular screen only). */
|
|
61
|
+
mouse: boolean;
|
|
62
|
+
/** Settings opened from the lobby are on screen; the lobby stays aside until they close. */
|
|
63
|
+
inSettings: boolean;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let runtime: Runtime | undefined;
|
|
67
|
+
|
|
68
|
+
export function isLobbyVisible(): boolean {
|
|
69
|
+
return runtime?.visible === true;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function rerender(): void {
|
|
73
|
+
if (runtime?.visible) runtime.tui?.requestRender();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function sessionModel(ctx: ExtensionContext): string | undefined {
|
|
77
|
+
return ctx.model ? modelRef(ctx.model) : undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function lobbyProfile(state: Runtime, kind: LobbyAgentKind) {
|
|
81
|
+
return resolveLobbyProfile(loadConfig(), kind, {
|
|
82
|
+
lookup: modelLookup(state.ctx),
|
|
83
|
+
sessionModel: sessionModel(state.ctx),
|
|
84
|
+
warn: (message) => state.ctx.ui.notify(message, "warning"),
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Pass new comments on this session's task to its Master (held while minimized or paused). */
|
|
89
|
+
export function deliverComments(): number {
|
|
90
|
+
const state = runtime;
|
|
91
|
+
if (!state || isMinimized()) return 0;
|
|
92
|
+
const task = currentZenTask();
|
|
93
|
+
if (!task || task.paused || TERMINAL_STATES.includes(task.state)) return 0;
|
|
94
|
+
const fresh = undeliveredComments(readPlanComments(state.root, state.configDir, task.id));
|
|
95
|
+
if (fresh.length === 0) return 0;
|
|
96
|
+
const current = loadTask(state.root, state.configDir, task.id) ?? task;
|
|
97
|
+
state.pi.sendUserMessage(commentMessage(task.id, fresh, Boolean(current.plan)), state.ctx.isIdle() ? undefined : { deliverAs: "steer" });
|
|
98
|
+
markCommentsDelivered(state.root, state.configDir, task.id, fresh.map((comment) => comment.id));
|
|
99
|
+
lobbyFeed.log("LOBBY", `passed ${fresh.length} plan comment${fresh.length === 1 ? "" : "s"} on ${task.id} to the oracle`, "info");
|
|
100
|
+
return fresh.length;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function addComment(state: Runtime, taskId: string, text: string): string {
|
|
104
|
+
const task = loadTask(state.root, state.configDir, taskId);
|
|
105
|
+
if (!task) return `no task ${taskId}`;
|
|
106
|
+
if (TERMINAL_STATES.includes(task.state)) return `${taskId} is ${task.state}; there is no plan left to change`;
|
|
107
|
+
const sessionId = state.ctx.sessionManager.getSessionId();
|
|
108
|
+
try {
|
|
109
|
+
addPlanComment(state.root, state.configDir, taskId, text, sessionId);
|
|
110
|
+
} catch (error) {
|
|
111
|
+
return (error as Error).message;
|
|
112
|
+
}
|
|
113
|
+
lobbyFeed.log("LOBBY", `comment on ${taskId}'s plan saved`, "info");
|
|
114
|
+
if (task.ownerSessionId === sessionId) {
|
|
115
|
+
if (deliverComments() === 0) return `comment saved — it reaches the oracle once ${taskId} is resumed or restored`;
|
|
116
|
+
return `comment sent to the oracle — it will ${task.plan ? "amend the plan" : "revise the proposal"}`;
|
|
117
|
+
}
|
|
118
|
+
if (!task.ownerSessionId) return `comment saved — ${taskId} has no owning session; it is delivered once a session claims it`;
|
|
119
|
+
return `comment saved — the session driving ${taskId} passes it to its oracle`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function failed(state: Runtime, what: string, error: Error): void {
|
|
123
|
+
lobbyFeed.log("LOBBY", `${what} — ${error.message}`, "error");
|
|
124
|
+
state.ctx.ui.notify(`bot-lobby: ${what} — ${error.message}`, "error");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function toOracle(state: Runtime, text: string): string | undefined {
|
|
128
|
+
const busy = !state.ctx.isIdle();
|
|
129
|
+
if (text.startsWith("/")) {
|
|
130
|
+
state.pi.sendUserMessage(text, { expandPromptTemplates: true, ...(busy ? { deliverAs: "followUp" as const } : {}) });
|
|
131
|
+
return `sent ${text.split(/\s+/)[0]} to pi (built-in commands need the lobby hidden: alt+l)`;
|
|
132
|
+
}
|
|
133
|
+
if (!currentZenTask()) {
|
|
134
|
+
startTask(state.pi, state.ctx, state.configDir, text).catch((error: Error) => failed(state, "could not start the task", error));
|
|
135
|
+
return "starting a task — the oracle takes it from here";
|
|
136
|
+
}
|
|
137
|
+
state.pi.sendUserMessage(text, busy ? { deliverAs: "steer" } : undefined);
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function startPlanned(state: Runtime, plan: PlannedTask): string {
|
|
142
|
+
const current = currentZenTask();
|
|
143
|
+
if (current) return `this session already drives ${current.id}; finish or cancel it first, or start ${plan.id} from another session`;
|
|
144
|
+
startTask(state.pi, state.ctx, state.configDir, plannedTaskRequest(plan))
|
|
145
|
+
.then((task) => {
|
|
146
|
+
if (!task) return;
|
|
147
|
+
markPlannedTaskStarted(state.root, state.configDir, plan.id, task.id);
|
|
148
|
+
state.view?.setTab("lobby");
|
|
149
|
+
rerender();
|
|
150
|
+
})
|
|
151
|
+
.catch((error: Error) => failed(state, `could not start ${plan.id}`, error));
|
|
152
|
+
return `starting ${plan.id} as a task…`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function seatProfile(state: Runtime, member: PanelMember) {
|
|
156
|
+
return resolvePanelProfile(loadConfig(), member, {
|
|
157
|
+
lookup: modelLookup(state.ctx),
|
|
158
|
+
sessionModel: sessionModel(state.ctx),
|
|
159
|
+
warn: (message) => state.ctx.ui.notify(message, "warning"),
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function newPlanner(state: Runtime, seed?: PlannerSeed, seats?: readonly PanelMember[]): PlanningSession {
|
|
164
|
+
state.planner?.cancel();
|
|
165
|
+
const config = loadConfig();
|
|
166
|
+
const workflow = config.workflow;
|
|
167
|
+
state.planner = new PlanningSession({
|
|
168
|
+
cwd: state.ctx.cwd,
|
|
169
|
+
root: state.root,
|
|
170
|
+
configDir: state.configDir,
|
|
171
|
+
profile: () => lobbyProfile(state, "planner"),
|
|
172
|
+
memberProfile: (member) => seatProfile(state, member),
|
|
173
|
+
panel: seats ?? config.lobby.planningPanel,
|
|
174
|
+
stallTimeoutMs: workflow.stallTimeoutMs,
|
|
175
|
+
toolStallTimeoutMs: workflow.toolStallTimeoutMs,
|
|
176
|
+
feed: lobbyFeed,
|
|
177
|
+
onChange: rerender,
|
|
178
|
+
onRound: (session) => {
|
|
179
|
+
// Put the questions to the user at once when they are looking at the Plan tab.
|
|
180
|
+
if (loadConfig().lobby.autoAsk && state.visible && state.view?.tab === "plan" && session.awaitingAnswers) void answerPanel(state);
|
|
181
|
+
},
|
|
182
|
+
}, seed);
|
|
183
|
+
return state.planner;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** The library's questionnaire when it loads, pi's own dialogs otherwise. */
|
|
187
|
+
function panelAsker(state: Runtime): Promise<Asker> {
|
|
188
|
+
state.asker ??= loadAskTool(state.pi).then((tool) => (tool ? toolAsker(tool) : dialogAsker()));
|
|
189
|
+
return state.asker;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* The oracle puts the round's questions to the user, one at a time; answered
|
|
194
|
+
* questionnaires are kept if the user stops, so the next call resumes there.
|
|
195
|
+
* Once every questionnaire is done, the answers (and any line comments) start
|
|
196
|
+
* the next round. Returns a notice for the lobby.
|
|
197
|
+
*/
|
|
198
|
+
export async function answerPanel(state: Runtime | undefined = runtime): Promise<string> {
|
|
199
|
+
const session = state?.planner;
|
|
200
|
+
if (!state || !session) return "no planning session";
|
|
201
|
+
if (session.busy) return "the panel is still thinking";
|
|
202
|
+
if (session.questions.length === 0) return "no open questions";
|
|
203
|
+
if (state.asking) return "the questions are already open";
|
|
204
|
+
state.asking = true;
|
|
205
|
+
try {
|
|
206
|
+
const asker = await panelAsker(state);
|
|
207
|
+
const chunks = questionnaires(session.questions);
|
|
208
|
+
for (let index = session.answered.length; index < chunks.length; index++) {
|
|
209
|
+
const result = await asker(chunks[index]!, state.ctx);
|
|
210
|
+
if (result.cancelled) {
|
|
211
|
+
rerender();
|
|
212
|
+
return session.answered.length > 0 ? `answers kept — ${chunks.length - session.answered.length} questionnaire${chunks.length - session.answered.length === 1 ? "" : "s"} left; enter resumes` : "questions put away — enter brings them back";
|
|
213
|
+
}
|
|
214
|
+
session.answered = [...session.answered, result];
|
|
215
|
+
}
|
|
216
|
+
const message = answerMessage(session.questions, session.answered);
|
|
217
|
+
if (!message) {
|
|
218
|
+
session.answered = [];
|
|
219
|
+
return "nothing was answered — the questions stay open";
|
|
220
|
+
}
|
|
221
|
+
void session.send(message);
|
|
222
|
+
return "answers sent — the panel is on the next round";
|
|
223
|
+
} catch (error) {
|
|
224
|
+
return `could not put the questions: ${(error as Error).message}`;
|
|
225
|
+
} finally {
|
|
226
|
+
state.asking = false;
|
|
227
|
+
rerender();
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const renderMarkdown = createMarkdownRenderer();
|
|
232
|
+
const lobbyThemes = new WeakMap<Theme, LobbyTheme>();
|
|
233
|
+
|
|
234
|
+
/** pi's theme as the lobby draws with it, plus Markdown rendering; one wrapper per theme so caches stay warm. */
|
|
235
|
+
function lobbyTheme(theme: Theme): LobbyTheme {
|
|
236
|
+
let wrapped = lobbyThemes.get(theme);
|
|
237
|
+
if (!wrapped) {
|
|
238
|
+
wrapped = {
|
|
239
|
+
fg: (color, text) => theme.fg(color, text),
|
|
240
|
+
bold: (text) => theme.bold(text),
|
|
241
|
+
italic: (text) => theme.italic(text),
|
|
242
|
+
bg: (color, text) => theme.bg(color, text),
|
|
243
|
+
markdown: renderMarkdown,
|
|
244
|
+
};
|
|
245
|
+
lobbyThemes.set(theme, wrapped);
|
|
246
|
+
}
|
|
247
|
+
return wrapped;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function host(state: Runtime, tui: TUI): LobbyHost {
|
|
251
|
+
return {
|
|
252
|
+
rows: () => tui.terminal.rows,
|
|
253
|
+
theme: () => lobbyTheme(state.ctx.ui.theme),
|
|
254
|
+
sessionId: () => state.ctx.sessionManager.getSessionId(),
|
|
255
|
+
zen: () => {
|
|
256
|
+
const snapshot = zenSnapshot();
|
|
257
|
+
return { ...(snapshot.task ? { task: snapshot.task } : {}), runs: snapshot.runs };
|
|
258
|
+
},
|
|
259
|
+
// The scene's height budget is 3/4 of the rows it is given.
|
|
260
|
+
scene: (width, height) => state.scene.lines(width, Math.floor(height / 0.75), state.ctx.ui.theme),
|
|
261
|
+
advanceScene: (now) => {
|
|
262
|
+
state.scene.advance(now);
|
|
263
|
+
return state.scene.delay(now);
|
|
264
|
+
},
|
|
265
|
+
feed: lobbyFeed,
|
|
266
|
+
masterBusy: () => !state.ctx.isIdle(),
|
|
267
|
+
tasks: () => listTasks(state.root, state.configDir),
|
|
268
|
+
plans: () => listPlannedTasks(state.root, state.configDir),
|
|
269
|
+
comments: (taskId) => readPlanComments(state.root, state.configDir, taskId),
|
|
270
|
+
metrics: () => readMetrics(state.root, state.configDir),
|
|
271
|
+
toOracle: (text) => toOracle(state, text),
|
|
272
|
+
comment: (taskId, text) => addComment(state, taskId, text),
|
|
273
|
+
startPlanned: (plan) => startPlanned(state, plan),
|
|
274
|
+
discardPlan: (id) => discardPlannedTask(state.root, state.configDir, id),
|
|
275
|
+
abortMaster: () => state.ctx.abort(),
|
|
276
|
+
hide: () => hideLobby(),
|
|
277
|
+
quickfix: state.quickfix,
|
|
278
|
+
planner: () => state.planner,
|
|
279
|
+
newPlanner: (seed, seats) => newPlanner(state, seed, seats),
|
|
280
|
+
answerPanel: () => answerPanel(state),
|
|
281
|
+
defaultPanel: () => loadConfig().lobby.planningPanel,
|
|
282
|
+
issuesEnabled: () => loadConfig().lobby.issues,
|
|
283
|
+
panels: () => loadConfig().lobby.panels,
|
|
284
|
+
savePanels: (panels) => savePanels(panels),
|
|
285
|
+
keys: () => loadConfig().lobby.keys,
|
|
286
|
+
openSettings: (entry) => lobbySettings(state, entry),
|
|
287
|
+
seatLabel: (member) => {
|
|
288
|
+
const profile = seatProfile(state, member);
|
|
289
|
+
return `${profile.model ?? "session model"} · ${profile.thinking}`;
|
|
290
|
+
},
|
|
291
|
+
issues: state.issues,
|
|
292
|
+
profileLabel: (kind) => {
|
|
293
|
+
const profile = lobbyProfile(state, kind);
|
|
294
|
+
return `${profile.model ?? "session model"} · ${profile.thinking}`;
|
|
295
|
+
},
|
|
296
|
+
requestRender: () => tui.requestRender(),
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* bot-lobby's settings (or one agent's entry) from inside the lobby. The
|
|
302
|
+
* lobby stays aside for the whole visit, not only for each menu, so it does
|
|
303
|
+
* not flash between them, and rereads the config when it comes back.
|
|
304
|
+
*/
|
|
305
|
+
async function lobbySettings(state: Runtime, entry?: "quickfix" | "planner"): Promise<void> {
|
|
306
|
+
if (state.inSettings) return;
|
|
307
|
+
state.inSettings = true;
|
|
308
|
+
setMouse(state, false);
|
|
309
|
+
state.handle?.setHidden(true);
|
|
310
|
+
try {
|
|
311
|
+
if (entry) await openEntrySettings(state.pi, state.ctx, entry);
|
|
312
|
+
else await openSettings(state.pi, state.ctx);
|
|
313
|
+
} catch (error) {
|
|
314
|
+
state.ctx.ui.notify(`bot-lobby: settings failed — ${(error as Error).message}`, "warning");
|
|
315
|
+
} finally {
|
|
316
|
+
state.inSettings = false;
|
|
317
|
+
state.asideForPrompt = false;
|
|
318
|
+
if (state.visible && state.handle) {
|
|
319
|
+
state.handle.setHidden(false);
|
|
320
|
+
state.handle.focus();
|
|
321
|
+
setMouse(state, true);
|
|
322
|
+
}
|
|
323
|
+
state.view?.reloadConfig();
|
|
324
|
+
rerender();
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** Remember which panes show, keeping every other setting as the file has it now. */
|
|
329
|
+
function savePanels(panels: Record<LobbyPanel, boolean>): void {
|
|
330
|
+
try {
|
|
331
|
+
const config = loadConfig();
|
|
332
|
+
saveConfig({ ...config, lobby: { ...config.lobby, panels: { ...panels } } });
|
|
333
|
+
} catch {
|
|
334
|
+
// A read-only config only means the choice lasts for this session.
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** SGR mouse reporting: presses, releases and the wheel, in cell coordinates. */
|
|
339
|
+
const MOUSE_ON = "\x1b[?1000h\x1b[?1006h";
|
|
340
|
+
const MOUSE_OFF = "\x1b[?1006l\x1b[?1000l";
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* In pi's regular screen nothing reports the mouse, so the lobby turns
|
|
344
|
+
* reporting on while it is up (clicks pick tabs and draft lines, the wheel
|
|
345
|
+
* scrolls) and off whenever it steps aside. Full-screen pi reports the mouse
|
|
346
|
+
* itself and hands the events to the lobby.
|
|
347
|
+
*/
|
|
348
|
+
function setMouse(state: Runtime, on: boolean): void {
|
|
349
|
+
const tui = state.tui;
|
|
350
|
+
if (!tui || tui.mode === "fullscreen") return;
|
|
351
|
+
const want = on && loadConfig().lobby.mouse;
|
|
352
|
+
if (want === state.mouse) return;
|
|
353
|
+
state.mouse = want;
|
|
354
|
+
tui.terminal.write(want ? MOUSE_ON : MOUSE_OFF);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Show the lobby (optionally on `tab`); false when there is no interactive TUI. */
|
|
358
|
+
export function showLobby(tab?: TabId): boolean {
|
|
359
|
+
const state = runtime;
|
|
360
|
+
if (!state?.tui) return false;
|
|
361
|
+
if (isMinimized()) setMinimized(false);
|
|
362
|
+
if (!state.view || !state.handle) {
|
|
363
|
+
const theme = state.ctx.ui.theme;
|
|
364
|
+
state.view = new LobbyView(state.tui, host(state, state.tui), { borderColor: (text) => theme.fg("border", text), selectList: getSelectListTheme() });
|
|
365
|
+
state.handle = state.tui.showOverlay(state.view, { width: "100%", maxHeight: "100%", anchor: "top-left" });
|
|
366
|
+
} else {
|
|
367
|
+
state.handle.setHidden(false);
|
|
368
|
+
state.handle.focus();
|
|
369
|
+
}
|
|
370
|
+
state.visible = true;
|
|
371
|
+
state.asideForPrompt = false;
|
|
372
|
+
if (tab) state.view.setTab(tab);
|
|
373
|
+
state.view.start();
|
|
374
|
+
setMouse(state, true);
|
|
375
|
+
applyStatus(state.ctx, state.root, state.configDir);
|
|
376
|
+
state.tui.requestRender();
|
|
377
|
+
return true;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
export function hideLobby(): void {
|
|
381
|
+
const state = runtime;
|
|
382
|
+
if (!state?.view || !state.visible) return;
|
|
383
|
+
state.visible = false;
|
|
384
|
+
state.asideForPrompt = false;
|
|
385
|
+
setMouse(state, false);
|
|
386
|
+
state.view.stop();
|
|
387
|
+
state.handle?.setHidden(true);
|
|
388
|
+
applyStatus(state.ctx, state.root, state.configDir);
|
|
389
|
+
state.tui?.requestRender();
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
export function toggleLobby(): boolean {
|
|
393
|
+
if (isLobbyVisible()) {
|
|
394
|
+
hideLobby();
|
|
395
|
+
return true;
|
|
396
|
+
}
|
|
397
|
+
return showLobby();
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** Open the lobby when this session drives a task and the config asks for it. */
|
|
401
|
+
export function autoOpenLobby(): void {
|
|
402
|
+
if (runtime && loadConfig().lobby.autoOpen && currentZenTask()) showLobby("lobby");
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** Step aside while pi shows a dialog (an approval, a question), and come back after. */
|
|
406
|
+
function promptStarted(): void {
|
|
407
|
+
const state = runtime;
|
|
408
|
+
if (!state?.visible || !state.handle || state.inSettings) return;
|
|
409
|
+
state.asideForPrompt = true;
|
|
410
|
+
setMouse(state, false);
|
|
411
|
+
state.handle.setHidden(true);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function promptEnded(): void {
|
|
415
|
+
const state = runtime;
|
|
416
|
+
if (!state?.asideForPrompt || !state.handle || state.inSettings) return;
|
|
417
|
+
state.asideForPrompt = false;
|
|
418
|
+
if (!state.visible) return;
|
|
419
|
+
state.handle.setHidden(false);
|
|
420
|
+
setMouse(state, true);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function shutdown(): void {
|
|
424
|
+
const state = runtime;
|
|
425
|
+
if (!state) return;
|
|
426
|
+
runtime = undefined;
|
|
427
|
+
if (state.poll) clearInterval(state.poll);
|
|
428
|
+
setMouse(state, false);
|
|
429
|
+
state.unsubscribeFeed?.();
|
|
430
|
+
state.quickfix.cancelAll();
|
|
431
|
+
state.planner?.cancel();
|
|
432
|
+
state.view?.dispose();
|
|
433
|
+
state.handle?.hide();
|
|
434
|
+
setWidgetSuppressor(() => false);
|
|
435
|
+
onRunUpdates(undefined);
|
|
436
|
+
onMinimizeChange(undefined);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** Create the session's lobby runtime (interactive master sessions only). */
|
|
440
|
+
export function initLobby(pi: ExtensionAPI, ctx: ExtensionContext, configDir: string): void {
|
|
441
|
+
shutdown();
|
|
442
|
+
if (isSubagentProcess() || ctx.mode !== "tui") return;
|
|
443
|
+
const root = detectProjectRoot(ctx.cwd, configDir);
|
|
444
|
+
const workflow = loadConfig().workflow;
|
|
445
|
+
const state: Runtime = {
|
|
446
|
+
pi,
|
|
447
|
+
configDir,
|
|
448
|
+
ctx,
|
|
449
|
+
root,
|
|
450
|
+
visible: false,
|
|
451
|
+
asideForPrompt: false,
|
|
452
|
+
asking: false,
|
|
453
|
+
mouse: false,
|
|
454
|
+
inSettings: false,
|
|
455
|
+
scene: new ZenScene(),
|
|
456
|
+
quickfix: undefined as unknown as QuickFixQueue,
|
|
457
|
+
issues: new IssuesState(execCommand, ctx.cwd, rerender),
|
|
458
|
+
};
|
|
459
|
+
state.quickfix = new QuickFixQueue({
|
|
460
|
+
cwd: ctx.cwd,
|
|
461
|
+
root,
|
|
462
|
+
configDir,
|
|
463
|
+
profile: () => lobbyProfile(state, "quickfix"),
|
|
464
|
+
stallTimeoutMs: workflow.stallTimeoutMs,
|
|
465
|
+
toolStallTimeoutMs: workflow.toolStallTimeoutMs,
|
|
466
|
+
feed: lobbyFeed,
|
|
467
|
+
onChange: rerender,
|
|
468
|
+
notify: (message, level) => {
|
|
469
|
+
if (!state.visible) ctx.ui.notify(message, level);
|
|
470
|
+
},
|
|
471
|
+
});
|
|
472
|
+
runtime = state;
|
|
473
|
+
lobbyFeed.clear();
|
|
474
|
+
lobbyFeed.seedChat(chatFromEntries(ctx.sessionManager.getBranch()));
|
|
475
|
+
state.unsubscribeFeed = lobbyFeed.onChange(rerender);
|
|
476
|
+
setWidgetSuppressor(() => runtime?.visible === true);
|
|
477
|
+
onRunUpdates((runs) => lobbyFeed.runs(runs));
|
|
478
|
+
onMinimizeChange((value) => {
|
|
479
|
+
if (value) hideLobby();
|
|
480
|
+
});
|
|
481
|
+
// Capture pi's TUI through a zero-line widget below the editor.
|
|
482
|
+
ctx.ui.setWidget(ANCHOR_KEY, (tui) => {
|
|
483
|
+
state.tui = tui;
|
|
484
|
+
return { render: () => [], invalidate: () => {} };
|
|
485
|
+
}, { placement: "belowEditor" });
|
|
486
|
+
state.poll = setInterval(() => deliverComments(), COMMENT_POLL_MS);
|
|
487
|
+
state.poll.unref?.();
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
interface MasterTurn {
|
|
491
|
+
startedAt: number;
|
|
492
|
+
/** The task the turn worked on; kept from the start so the turn that completes it still counts. */
|
|
493
|
+
taskId?: string;
|
|
494
|
+
model?: string;
|
|
495
|
+
thinking: string;
|
|
496
|
+
tools: number;
|
|
497
|
+
turns: number;
|
|
498
|
+
input: number;
|
|
499
|
+
output: number;
|
|
500
|
+
cost: number;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function turnStatus(messages: readonly unknown[]): MetricStatus {
|
|
504
|
+
const last = [...messages].reverse().find((message) => (message as { role?: string })?.role === "assistant") as { stopReason?: string } | undefined;
|
|
505
|
+
if (last?.stopReason === "aborted") return "cancelled";
|
|
506
|
+
if (last?.stopReason === "error") return "failed";
|
|
507
|
+
return "success";
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/** Narrate the Master's own turn into the feed and record it as a metric; the lobby follows pi's dialogs. */
|
|
511
|
+
export function registerLobbyEvents(pi: ExtensionAPI, configDir: string): void {
|
|
512
|
+
if (isSubagentProcess()) return;
|
|
513
|
+
let turn: MasterTurn | undefined;
|
|
514
|
+
const track = (ctx: ExtensionContext) => {
|
|
515
|
+
if (runtime) runtime.ctx = ctx;
|
|
516
|
+
};
|
|
517
|
+
pi.registerShortcut(Key.alt("l"), {
|
|
518
|
+
description: "bot-lobby: open or hide the lobby",
|
|
519
|
+
handler: (ctx) => {
|
|
520
|
+
if (!toggleLobby()) ctx.ui.notify("The lobby needs pi's interactive terminal UI.", "warning");
|
|
521
|
+
},
|
|
522
|
+
});
|
|
523
|
+
pi.on("session_start", (_event, ctx) => {
|
|
524
|
+
initLobby(pi, ctx, configDir);
|
|
525
|
+
autoOpenLobby();
|
|
526
|
+
});
|
|
527
|
+
pi.on("session_shutdown", () => shutdown());
|
|
528
|
+
pi.on("ui_prompt_start", () => promptStarted());
|
|
529
|
+
pi.on("ui_prompt_end", () => promptEnded());
|
|
530
|
+
pi.on("agent_start", (_event, ctx) => {
|
|
531
|
+
track(ctx);
|
|
532
|
+
const taskId = currentZenTask()?.id;
|
|
533
|
+
turn = { startedAt: Date.now(), ...(taskId ? { taskId } : {}), ...(ctx.model ? { model: modelRef(ctx.model) } : {}), thinking: pi.getThinkingLevel(), tools: 0, turns: 0, input: 0, output: 0, cost: 0 };
|
|
534
|
+
});
|
|
535
|
+
pi.on("tool_execution_start", (event) => {
|
|
536
|
+
if (turn) turn.tools += 1;
|
|
537
|
+
lobbyFeed.begin("MASTER", describeToolCall(event.toolName, event.args), event.toolCallId);
|
|
538
|
+
});
|
|
539
|
+
pi.on("tool_execution_end", (event) => lobbyFeed.end(event.toolCallId, event.isError));
|
|
540
|
+
pi.on("message_update", (event) => {
|
|
541
|
+
const update = event.assistantMessageEvent;
|
|
542
|
+
if (update.type === "thinking_delta") lobbyFeed.thinkDelta("MASTER", update.delta);
|
|
543
|
+
else if (update.type === "thinking_end") lobbyFeed.thinkEnd("MASTER", update.content);
|
|
544
|
+
else if (update.type === "text_delta") lobbyFeed.replyDelta(update.delta);
|
|
545
|
+
});
|
|
546
|
+
pi.on("message_end", (event) => {
|
|
547
|
+
const message = event.message as { role?: string; content?: unknown; stopReason?: string; errorMessage?: string; usage?: { input?: number; output?: number; cost?: { total?: number } } };
|
|
548
|
+
if (message.role === "assistant") {
|
|
549
|
+
lobbyFeed.replyEnd();
|
|
550
|
+
lobbyFeed.thinkEnd("MASTER");
|
|
551
|
+
if (message.stopReason === "error") {
|
|
552
|
+
const error = (message.errorMessage ?? "the model call failed").split("\n")[0]!;
|
|
553
|
+
lobbyFeed.say("note", `✗ the oracle's turn failed: ${error}`);
|
|
554
|
+
lobbyFeed.log("MASTER", `turn failed — ${error}`, "error");
|
|
555
|
+
}
|
|
556
|
+
if (turn) {
|
|
557
|
+
turn.turns += 1;
|
|
558
|
+
turn.input += message.usage?.input ?? 0;
|
|
559
|
+
turn.output += message.usage?.output ?? 0;
|
|
560
|
+
turn.cost += message.usage?.cost?.total ?? 0;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
if (message.role !== "user" && message.role !== "assistant") return;
|
|
564
|
+
const line = chatText(message.role, textOf(message.content));
|
|
565
|
+
if (line) lobbyFeed.say(line.role, line.text);
|
|
566
|
+
});
|
|
567
|
+
pi.on("agent_end", (event, ctx) => {
|
|
568
|
+
track(ctx);
|
|
569
|
+
const finished = turn;
|
|
570
|
+
turn = undefined;
|
|
571
|
+
lobbyFeed.replyEnd();
|
|
572
|
+
const taskId = finished?.taskId ?? currentZenTask()?.id;
|
|
573
|
+
if (!finished || !taskId || !runtime) return;
|
|
574
|
+
appendMetrics(runtime.root, configDir, [{
|
|
575
|
+
id: `master-${finished.startedAt}`,
|
|
576
|
+
kind: "master",
|
|
577
|
+
agent: "MASTER",
|
|
578
|
+
...(finished.model ? { model: finished.model } : {}),
|
|
579
|
+
thinking: finished.thinking,
|
|
580
|
+
status: turnStatus(event.messages),
|
|
581
|
+
startedAt: new Date(finished.startedAt).toISOString(),
|
|
582
|
+
durationMs: Date.now() - finished.startedAt,
|
|
583
|
+
...(finished.turns ? { turns: finished.turns } : {}),
|
|
584
|
+
tools: finished.tools,
|
|
585
|
+
input: finished.input,
|
|
586
|
+
output: finished.output,
|
|
587
|
+
cost: finished.cost,
|
|
588
|
+
taskId,
|
|
589
|
+
}]);
|
|
590
|
+
});
|
|
591
|
+
}
|