@a-t-h-i/bot-lobby 0.5.0 → 0.6.0
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 +264 -68
- package/package.json +5 -2
- package/prompts/panel.md +13 -4
- package/prompts/planner.md +31 -13
- package/src/execution/pi-runner.ts +1 -1
- package/src/index.ts +4 -2
- package/src/lobby/ask.ts +226 -0
- package/src/lobby/feed.ts +65 -9
- package/src/lobby/keys.ts +55 -0
- package/src/lobby/layout.ts +208 -4
- package/src/lobby/markdown.ts +48 -0
- package/src/lobby/planner.ts +135 -17
- package/src/lobby/runtime.ts +353 -66
- package/src/lobby/session-files.ts +126 -0
- package/src/lobby/sessions.ts +288 -0
- package/src/lobby/tabs/home.ts +225 -59
- package/src/lobby/tabs/metrics.ts +155 -42
- package/src/lobby/tabs/plan.ts +128 -51
- package/src/lobby/tabs/quickfix.ts +43 -18
- package/src/lobby/tabs/tasks.ts +221 -54
- package/src/lobby/view.ts +1303 -143
- package/src/pi/commands.ts +35 -7
- package/src/pi/events.ts +5 -2
- package/src/pi/notify.ts +18 -0
- package/src/pi/owner.ts +216 -0
- package/src/pi/settings-ui.ts +69 -1
- package/src/pi/start-task.ts +33 -2
- package/src/pi/ui.ts +3 -3
- package/src/pi/zen-large.ts +9 -1
- package/src/pi/zen.ts +7 -3
- package/src/schemas/configuration.ts +43 -4
- package/src/schemas/task.ts +2 -0
- package/src/state/auto.ts +40 -0
- package/src/state/inbox.ts +90 -0
- package/src/workflow/workflow.ts +27 -3
package/src/lobby/runtime.ts
CHANGED
|
@@ -6,32 +6,41 @@
|
|
|
6
6
|
* turn into the feed, passes plan comments to the Master that owns a task and
|
|
7
7
|
* records each Master turn in the metrics log.
|
|
8
8
|
*/
|
|
9
|
-
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
10
|
-
import { getSelectListTheme } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { getSelectListTheme, SessionManager } from "@earendil-works/pi-coding-agent";
|
|
11
11
|
import { Key, type OverlayHandle, type TUI } from "@earendil-works/pi-tui";
|
|
12
|
-
import { TERMINAL_STATES } from "../schemas/task.ts";
|
|
12
|
+
import { TERMINAL_STATES, type Task } from "../schemas/task.ts";
|
|
13
13
|
import { listTasks, loadTask } from "../state/persistence.ts";
|
|
14
|
-
import { detectProjectRoot, loadConfig } from "../state/project.ts";
|
|
15
|
-
import { addPlanComment,
|
|
16
|
-
import {
|
|
14
|
+
import { detectProjectRoot, loadConfig, saveConfig } from "../state/project.ts";
|
|
15
|
+
import { addPlanComment, readPlanComments } from "../state/comments.ts";
|
|
16
|
+
import { isAutoMode } from "../state/auto.ts";
|
|
17
|
+
import { sendToInbox } from "../state/inbox.ts";
|
|
18
|
+
import { discardPlannedTask, listPlannedTasks, type PlannedTask } from "../state/backlog.ts";
|
|
17
19
|
import { appendMetrics, readMetrics, type MetricStatus } from "../state/metrics.ts";
|
|
18
20
|
import { describeToolCall } from "../pi/activity.ts";
|
|
19
|
-
import { applyStatus, currentZenTask, isMinimized, onMinimizeChange, onRunUpdates, setMinimized, setWidgetSuppressor, ZenScene, zenSnapshot } from "../pi/ui.ts";
|
|
21
|
+
import { applyStatus, currentZenTask, isMinimized, onMinimizeChange, onRunUpdates, persistedRuns, setMinimized, setWidgetSuppressor, ZenScene, zenSnapshot } from "../pi/ui.ts";
|
|
22
|
+
import { panelLines } from "../pi/zen.ts";
|
|
23
|
+
import { shortTitle } from "../text.ts";
|
|
20
24
|
import { isSubagentProcess } from "../pi/quiet.ts";
|
|
21
25
|
import { modelRef, resolveLobbyProfile, resolvePanelProfile } from "../pi/model-support.ts";
|
|
22
26
|
import { modelLookup } from "../pi/tools.ts";
|
|
23
|
-
import { startTask } from "../pi/start-task.ts";
|
|
24
|
-
import type { LobbyAgentKind, PanelMember } from "../schemas/configuration.ts";
|
|
25
|
-
import { chatFromEntries,
|
|
27
|
+
import { startPlannedTask, startTask } from "../pi/start-task.ts";
|
|
28
|
+
import type { LobbyAgentKind, LobbyPanel, PanelMember } from "../schemas/configuration.ts";
|
|
29
|
+
import { chatFromEntries, lobbyFeed, narrateEvent, type AgentEventLike } from "./feed.ts";
|
|
30
|
+
import { launchPi, SessionRegistry, type BackgroundSession, type SessionLauncher } from "./sessions.ts";
|
|
31
|
+
import { SessionChats } from "./session-files.ts";
|
|
32
|
+
import { answerMessage, dialogAsker, loadAskTool, questionnaires, toolAsker, type Asker } from "./ask.ts";
|
|
26
33
|
import { QuickFixQueue } from "./quickfix.ts";
|
|
27
34
|
import { PlanningSession, type PlannerSeed } from "./planner.ts";
|
|
28
35
|
import { execCommand, IssuesState } from "./issues.ts";
|
|
29
36
|
import { LobbyView, type LobbyHost, type TabId } from "./view.ts";
|
|
30
37
|
import type { LobbyTheme } from "./layout.ts";
|
|
38
|
+
import { createMarkdownRenderer } from "./markdown.ts";
|
|
39
|
+
import { deliverComments, onOwnerEvent, setAuto } from "../pi/owner.ts";
|
|
40
|
+
import { openEntrySettings, openSettings } from "../pi/settings-ui.ts";
|
|
31
41
|
|
|
32
42
|
export const ANCHOR_KEY = "bot-lobby-anchor";
|
|
33
|
-
|
|
34
|
-
export const COMMENT_POLL_MS = 3000;
|
|
43
|
+
export { deliverComments };
|
|
35
44
|
|
|
36
45
|
interface Runtime {
|
|
37
46
|
pi: ExtensionAPI;
|
|
@@ -48,8 +57,15 @@ interface Runtime {
|
|
|
48
57
|
quickfix: QuickFixQueue;
|
|
49
58
|
planner?: PlanningSession;
|
|
50
59
|
issues: IssuesState;
|
|
51
|
-
poll?: ReturnType<typeof setInterval>;
|
|
52
60
|
unsubscribeFeed?: () => void;
|
|
61
|
+
/** The ask-user-question tool (or pi's dialogs), loaded once on first use. */
|
|
62
|
+
asker?: Promise<Asker>;
|
|
63
|
+
/** A questionnaire is on screen. */
|
|
64
|
+
asking: boolean;
|
|
65
|
+
/** The lobby turned the terminal's mouse reporting on (pi's regular screen only). */
|
|
66
|
+
mouse: boolean;
|
|
67
|
+
/** Settings opened from the lobby are on screen; the lobby stays aside until they close. */
|
|
68
|
+
inSettings: boolean;
|
|
53
69
|
}
|
|
54
70
|
|
|
55
71
|
let runtime: Runtime | undefined;
|
|
@@ -58,6 +74,11 @@ export function isLobbyVisible(): boolean {
|
|
|
58
74
|
return runtime?.visible === true;
|
|
59
75
|
}
|
|
60
76
|
|
|
77
|
+
/** The lobby's view, once it has been shown (tests drive it through this). */
|
|
78
|
+
export function lobbyView(): LobbyView | undefined {
|
|
79
|
+
return runtime?.view;
|
|
80
|
+
}
|
|
81
|
+
|
|
61
82
|
function rerender(): void {
|
|
62
83
|
if (runtime?.visible) runtime.tui?.requestRender();
|
|
63
84
|
}
|
|
@@ -74,21 +95,6 @@ function lobbyProfile(state: Runtime, kind: LobbyAgentKind) {
|
|
|
74
95
|
});
|
|
75
96
|
}
|
|
76
97
|
|
|
77
|
-
/** Pass new comments on this session's task to its Master (held while minimized or paused). */
|
|
78
|
-
export function deliverComments(): number {
|
|
79
|
-
const state = runtime;
|
|
80
|
-
if (!state || isMinimized()) return 0;
|
|
81
|
-
const task = currentZenTask();
|
|
82
|
-
if (!task || task.paused || TERMINAL_STATES.includes(task.state)) return 0;
|
|
83
|
-
const fresh = undeliveredComments(readPlanComments(state.root, state.configDir, task.id));
|
|
84
|
-
if (fresh.length === 0) return 0;
|
|
85
|
-
const current = loadTask(state.root, state.configDir, task.id) ?? task;
|
|
86
|
-
state.pi.sendUserMessage(commentMessage(task.id, fresh, Boolean(current.plan)), state.ctx.isIdle() ? undefined : { deliverAs: "steer" });
|
|
87
|
-
markCommentsDelivered(state.root, state.configDir, task.id, fresh.map((comment) => comment.id));
|
|
88
|
-
lobbyFeed.log("LOBBY", `passed ${fresh.length} plan comment${fresh.length === 1 ? "" : "s"} on ${task.id} to the oracle`, "info");
|
|
89
|
-
return fresh.length;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
98
|
function addComment(state: Runtime, taskId: string, text: string): string {
|
|
93
99
|
const task = loadTask(state.root, state.configDir, taskId);
|
|
94
100
|
if (!task) return `no task ${taskId}`;
|
|
@@ -130,15 +136,155 @@ function toOracle(state: Runtime, text: string): string | undefined {
|
|
|
130
136
|
function startPlanned(state: Runtime, plan: PlannedTask): string {
|
|
131
137
|
const current = currentZenTask();
|
|
132
138
|
if (current) return `this session already drives ${current.id}; finish or cancel it first, or start ${plan.id} from another session`;
|
|
133
|
-
|
|
139
|
+
startPlannedTask(state.pi, state.ctx, state.configDir, plan.id)
|
|
134
140
|
.then((task) => {
|
|
135
|
-
if (
|
|
136
|
-
markPlannedTaskStarted(state.root, state.configDir, plan.id, task.id);
|
|
141
|
+
if (typeof task === "string") return failed(state, `could not start ${plan.id}`, new Error(task));
|
|
137
142
|
state.view?.setTab("lobby");
|
|
138
143
|
rerender();
|
|
139
144
|
})
|
|
140
145
|
.catch((error: Error) => failed(state, `could not start ${plan.id}`, error));
|
|
141
|
-
return `starting ${plan.id}
|
|
146
|
+
return `starting ${plan.id} here — its agreed plan needs no approval…`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/* ------------------------------------------------- background sessions */
|
|
150
|
+
|
|
151
|
+
let launcher: SessionLauncher = launchPi;
|
|
152
|
+
let registry: SessionRegistry | undefined;
|
|
153
|
+
let exitHooked = false;
|
|
154
|
+
/** How many questions of each session were already announced, and which exits, so each is said once. */
|
|
155
|
+
const announced = new Map<string, number>();
|
|
156
|
+
const exitsSaid = new Set<string>();
|
|
157
|
+
/** Other sessions' conversations, read from their files. */
|
|
158
|
+
const chats = new SessionChats(async () => {
|
|
159
|
+
const state = runtime;
|
|
160
|
+
if (!state) return [];
|
|
161
|
+
const dirs = new Map<string, string | undefined>([[state.ctx.cwd, state.ctx.sessionManager.getSessionDir()]]);
|
|
162
|
+
if (!dirs.has(state.root)) dirs.set(state.root, undefined);
|
|
163
|
+
const lists = await Promise.all([...dirs].map(([cwd, dir]) => SessionManager.list(cwd, dir).catch(() => [])));
|
|
164
|
+
return lists.flat();
|
|
165
|
+
}, () => rerender());
|
|
166
|
+
|
|
167
|
+
/** Launch background sessions with something else (tests); resets the registry. */
|
|
168
|
+
export function setSessionLauncher(next: SessionLauncher | undefined): void {
|
|
169
|
+
registry?.stopAll();
|
|
170
|
+
registry = undefined;
|
|
171
|
+
launcher = next ?? launchPi;
|
|
172
|
+
announced.clear();
|
|
173
|
+
exitsSaid.clear();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* The background sessions this window started. The registry belongs to the
|
|
178
|
+
* process, not to one pi session, so switching sessions here keeps them
|
|
179
|
+
* running; they stop when pi exits (each is a saved session `/resume` finds).
|
|
180
|
+
*/
|
|
181
|
+
function sessionRegistry(): SessionRegistry {
|
|
182
|
+
if (!registry) registry = new SessionRegistry(launcher, sessionsChanged);
|
|
183
|
+
if (!exitHooked) {
|
|
184
|
+
exitHooked = true;
|
|
185
|
+
process.once("exit", () => registry?.stopAll());
|
|
186
|
+
}
|
|
187
|
+
return registry;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function backgroundSessions(): readonly BackgroundSession[] {
|
|
191
|
+
return registry?.sessions ?? [];
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** A background session changed: redraw, and say so when one asks something or fails while the lobby is hidden. */
|
|
195
|
+
function sessionsChanged(): void {
|
|
196
|
+
const state = runtime;
|
|
197
|
+
for (const session of registry?.sessions ?? []) {
|
|
198
|
+
if (session.sessionId && session.sessionFile) chats.remember(session.sessionId, session.sessionFile);
|
|
199
|
+
const seen = announced.get(session.key) ?? 0;
|
|
200
|
+
announced.set(session.key, session.dialogs.length);
|
|
201
|
+
if (!state || state.visible) continue;
|
|
202
|
+
if (session.dialogs.length > seen) state.ctx.ui.notify(`bot-lobby: ${session.name} is waiting for you — alt+l opens the lobby`, "info");
|
|
203
|
+
if (!session.alive && !exitsSaid.has(session.key)) {
|
|
204
|
+
exitsSaid.add(session.key);
|
|
205
|
+
if (session.exitCode) state.ctx.ui.notify(`bot-lobby: ${session.name} exited (${session.exitCode})${session.lastError() ? `: ${session.lastError()}` : ""}`, "warning");
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
rerender();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Start a task in its own new pi session, named after the task; the session, or why not. */
|
|
212
|
+
function startSession(state: Runtime, start: { request?: string; plan?: PlannedTask; auto?: boolean }): BackgroundSession | string {
|
|
213
|
+
const request = start.request?.trim();
|
|
214
|
+
const plan = start.plan;
|
|
215
|
+
if (!plan && !request) return "describe the task first";
|
|
216
|
+
if (!plan && request!.startsWith("/")) return "a new session starts from a task description, not a command";
|
|
217
|
+
if (plan) {
|
|
218
|
+
if (plan.status !== "pending") return `${plan.id} was already started${plan.startedTaskId ? ` as ${plan.startedTaskId}` : ""}`;
|
|
219
|
+
const starting = backgroundSessions().find((session) => session.alive && session.planId === plan.id);
|
|
220
|
+
if (starting) return `${plan.id} is already starting in ${starting.name}`;
|
|
221
|
+
}
|
|
222
|
+
const name = plan?.title ?? shortTitle(request!);
|
|
223
|
+
try {
|
|
224
|
+
const session = sessionRegistry().start(state.ctx.cwd, { name, ...(plan ? { planId: plan.id } : { request: request! }), ...(start.auto ? { auto: true } : {}) }, sessionModel(state.ctx));
|
|
225
|
+
lobbyFeed.log("LOBBY", `started "${name}" in a new session`, "success");
|
|
226
|
+
return session;
|
|
227
|
+
} catch (error) {
|
|
228
|
+
return `could not start a new session — ${(error as Error).message}`;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Put the oldest question a background session waits on to the user, with pi's own dialogs; escape cancels it as it would there. */
|
|
233
|
+
async function answerDialog(state: Runtime, session: BackgroundSession): Promise<void> {
|
|
234
|
+
const dialog = session.dialogs[0];
|
|
235
|
+
if (!dialog) return;
|
|
236
|
+
const title = `${session.name} — ${dialog.title}`;
|
|
237
|
+
const ui = state.ctx.ui;
|
|
238
|
+
try {
|
|
239
|
+
if (dialog.method === "select") {
|
|
240
|
+
const value = await ui.select(title, dialog.options ?? []);
|
|
241
|
+
session.answer(dialog.id, value === undefined ? { cancelled: true } : { value });
|
|
242
|
+
} else if (dialog.method === "confirm") {
|
|
243
|
+
session.answer(dialog.id, { confirmed: await ui.confirm(title, dialog.message ?? "") });
|
|
244
|
+
} else {
|
|
245
|
+
const value = dialog.method === "input" ? await ui.input(title, dialog.placeholder) : await ui.editor(title, dialog.prefill);
|
|
246
|
+
session.answer(dialog.id, value === undefined ? { cancelled: true } : { value });
|
|
247
|
+
}
|
|
248
|
+
} catch (error) {
|
|
249
|
+
failed(state, `could not answer ${session.name}`, error as Error);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Leave a message for a task another terminal's session drives; its owner passes it to the oracle. */
|
|
254
|
+
function sendToTask(state: Runtime, taskId: string, text: string): string {
|
|
255
|
+
const task = loadTask(state.root, state.configDir, taskId);
|
|
256
|
+
if (!task) return `no task ${taskId}`;
|
|
257
|
+
if (TERMINAL_STATES.includes(task.state)) return `${taskId} is ${task.state}`;
|
|
258
|
+
try {
|
|
259
|
+
sendToInbox(state.root, state.configDir, taskId, text, state.ctx.sessionManager.getSessionId());
|
|
260
|
+
} catch (error) {
|
|
261
|
+
return (error as Error).message;
|
|
262
|
+
}
|
|
263
|
+
lobbyFeed.log("LOBBY", `message for ${taskId}'s oracle saved`, "info");
|
|
264
|
+
return task.ownerSessionId ? `sent — the session driving ${taskId} passes it to its oracle` : `saved — ${taskId} has no owning session; its oracle gets it once a session claims it`;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Auto mode as the lobby last read it, per task: every frame asks, the file is read at most once a second. */
|
|
268
|
+
const autoSeen = new Map<string, { at: number; on: boolean }>();
|
|
269
|
+
const AUTO_READ_MS = 1000;
|
|
270
|
+
|
|
271
|
+
function autoFor(state: Runtime, taskId: string): boolean {
|
|
272
|
+
const now = Date.now();
|
|
273
|
+
const seen = autoSeen.get(taskId);
|
|
274
|
+
if (seen && now - seen.at < AUTO_READ_MS) return seen.on;
|
|
275
|
+
const on = isAutoMode(state.root, state.configDir, taskId);
|
|
276
|
+
autoSeen.set(taskId, { at: now, on });
|
|
277
|
+
return on;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function switchAuto(state: Runtime, taskId: string, on: boolean): void {
|
|
281
|
+
setAuto(state.root, state.configDir, taskId, on, state.ctx.sessionManager.getSessionId());
|
|
282
|
+
autoSeen.set(taskId, { at: Date.now(), on });
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** A task's status box without animations, for a session other than this window's. */
|
|
286
|
+
function taskScene(state: Runtime, task: Task, width: number, height: number): string[] {
|
|
287
|
+
return panelLines(task, persistedRuns(task), Date.now(), false, { width, rows: Math.floor(height / 0.75), still: true, theme: state.ctx.ui.theme });
|
|
142
288
|
}
|
|
143
289
|
|
|
144
290
|
function seatProfile(state: Runtime, member: PanelMember) {
|
|
@@ -164,21 +310,90 @@ function newPlanner(state: Runtime, seed?: PlannerSeed, seats?: readonly PanelMe
|
|
|
164
310
|
toolStallTimeoutMs: workflow.toolStallTimeoutMs,
|
|
165
311
|
feed: lobbyFeed,
|
|
166
312
|
onChange: rerender,
|
|
313
|
+
onRound: (session) => {
|
|
314
|
+
// Put the questions to the user at once when they are looking at the Plan tab.
|
|
315
|
+
if (loadConfig().lobby.autoAsk && state.visible && state.view?.tab === "plan" && session.awaitingAnswers) void answerPanel(state);
|
|
316
|
+
},
|
|
167
317
|
}, seed);
|
|
168
318
|
return state.planner;
|
|
169
319
|
}
|
|
170
320
|
|
|
321
|
+
/** The library's questionnaire when it loads, pi's own dialogs otherwise. */
|
|
322
|
+
function panelAsker(state: Runtime): Promise<Asker> {
|
|
323
|
+
state.asker ??= loadAskTool(state.pi).then((tool) => (tool ? toolAsker(tool) : dialogAsker()));
|
|
324
|
+
return state.asker;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* The oracle puts the round's questions to the user, one at a time; answered
|
|
329
|
+
* questionnaires are kept if the user stops, so the next call resumes there.
|
|
330
|
+
* Once every questionnaire is done, the answers (and any line comments) start
|
|
331
|
+
* the next round. Returns a notice for the lobby.
|
|
332
|
+
*/
|
|
333
|
+
export async function answerPanel(state: Runtime | undefined = runtime): Promise<string> {
|
|
334
|
+
const session = state?.planner;
|
|
335
|
+
if (!state || !session) return "no planning session";
|
|
336
|
+
if (session.busy) return "the panel is still thinking";
|
|
337
|
+
if (session.questions.length === 0) return "no open questions";
|
|
338
|
+
if (state.asking) return "the questions are already open";
|
|
339
|
+
state.asking = true;
|
|
340
|
+
try {
|
|
341
|
+
const asker = await panelAsker(state);
|
|
342
|
+
const chunks = questionnaires(session.questions);
|
|
343
|
+
for (let index = session.answered.length; index < chunks.length; index++) {
|
|
344
|
+
const result = await asker(chunks[index]!, state.ctx);
|
|
345
|
+
if (result.cancelled) {
|
|
346
|
+
rerender();
|
|
347
|
+
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";
|
|
348
|
+
}
|
|
349
|
+
session.answered = [...session.answered, result];
|
|
350
|
+
}
|
|
351
|
+
const message = answerMessage(session.questions, session.answered);
|
|
352
|
+
if (!message) {
|
|
353
|
+
session.answered = [];
|
|
354
|
+
return "nothing was answered — the questions stay open";
|
|
355
|
+
}
|
|
356
|
+
void session.send(message);
|
|
357
|
+
return "answers sent — the panel is on the next round";
|
|
358
|
+
} catch (error) {
|
|
359
|
+
return `could not put the questions: ${(error as Error).message}`;
|
|
360
|
+
} finally {
|
|
361
|
+
state.asking = false;
|
|
362
|
+
rerender();
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const renderMarkdown = createMarkdownRenderer();
|
|
367
|
+
const lobbyThemes = new WeakMap<Theme, LobbyTheme>();
|
|
368
|
+
|
|
369
|
+
/** pi's theme as the lobby draws with it, plus Markdown rendering; one wrapper per theme so caches stay warm. */
|
|
370
|
+
function lobbyTheme(theme: Theme): LobbyTheme {
|
|
371
|
+
let wrapped = lobbyThemes.get(theme);
|
|
372
|
+
if (!wrapped) {
|
|
373
|
+
wrapped = {
|
|
374
|
+
fg: (color, text) => theme.fg(color, text),
|
|
375
|
+
bold: (text) => theme.bold(text),
|
|
376
|
+
italic: (text) => theme.italic(text),
|
|
377
|
+
bg: (color, text) => theme.bg(color, text),
|
|
378
|
+
markdown: renderMarkdown,
|
|
379
|
+
strike: (text) => theme.strikethrough(text),
|
|
380
|
+
};
|
|
381
|
+
lobbyThemes.set(theme, wrapped);
|
|
382
|
+
}
|
|
383
|
+
return wrapped;
|
|
384
|
+
}
|
|
385
|
+
|
|
171
386
|
function host(state: Runtime, tui: TUI): LobbyHost {
|
|
172
387
|
return {
|
|
173
388
|
rows: () => tui.terminal.rows,
|
|
174
|
-
theme: () => state.ctx.ui.theme
|
|
389
|
+
theme: () => lobbyTheme(state.ctx.ui.theme),
|
|
175
390
|
sessionId: () => state.ctx.sessionManager.getSessionId(),
|
|
176
391
|
zen: () => {
|
|
177
392
|
const snapshot = zenSnapshot();
|
|
178
393
|
return { ...(snapshot.task ? { task: snapshot.task } : {}), runs: snapshot.runs };
|
|
179
394
|
},
|
|
180
395
|
// The scene's height budget is 3/4 of the rows it is given.
|
|
181
|
-
scene: (width, height) => state.scene.lines(width, Math.floor(height / 0.75), state.ctx.ui.theme),
|
|
396
|
+
scene: (width, height, animated) => state.scene.lines(width, Math.floor(height / 0.75), state.ctx.ui.theme, Date.now(), !animated),
|
|
182
397
|
advanceScene: (now) => {
|
|
183
398
|
state.scene.advance(now);
|
|
184
399
|
return state.scene.delay(now);
|
|
@@ -198,7 +413,13 @@ function host(state: Runtime, tui: TUI): LobbyHost {
|
|
|
198
413
|
quickfix: state.quickfix,
|
|
199
414
|
planner: () => state.planner,
|
|
200
415
|
newPlanner: (seed, seats) => newPlanner(state, seed, seats),
|
|
416
|
+
answerPanel: () => answerPanel(state),
|
|
201
417
|
defaultPanel: () => loadConfig().lobby.planningPanel,
|
|
418
|
+
issuesEnabled: () => loadConfig().lobby.issues,
|
|
419
|
+
panels: () => loadConfig().lobby.panels,
|
|
420
|
+
savePanels: (panels) => savePanels(panels),
|
|
421
|
+
keys: () => loadConfig().lobby.keys,
|
|
422
|
+
openSettings: (entry) => lobbySettings(state, entry),
|
|
202
423
|
seatLabel: (member) => {
|
|
203
424
|
const profile = seatProfile(state, member);
|
|
204
425
|
return `${profile.model ?? "session model"} · ${profile.thinking}`;
|
|
@@ -208,10 +429,76 @@ function host(state: Runtime, tui: TUI): LobbyHost {
|
|
|
208
429
|
const profile = lobbyProfile(state, kind);
|
|
209
430
|
return `${profile.model ?? "session model"} · ${profile.thinking}`;
|
|
210
431
|
},
|
|
432
|
+
sessionName: () => state.pi.getSessionName(),
|
|
433
|
+
sessions: () => backgroundSessions(),
|
|
434
|
+
startSession: (start) => startSession(state, start),
|
|
435
|
+
answerDialog: (session) => answerDialog(state, session),
|
|
436
|
+
isAuto: (taskId) => autoFor(state, taskId),
|
|
437
|
+
setAuto: (taskId, on) => switchAuto(state, taskId, on),
|
|
438
|
+
sendToTask: (taskId, text) => sendToTask(state, taskId, text),
|
|
439
|
+
sessionChat: (sessionId) => chats.chat(sessionId),
|
|
440
|
+
taskScene: (task, width, height) => taskScene(state, task, width, height),
|
|
211
441
|
requestRender: () => tui.requestRender(),
|
|
212
442
|
};
|
|
213
443
|
}
|
|
214
444
|
|
|
445
|
+
/**
|
|
446
|
+
* bot-lobby's settings (or one agent's entry) from inside the lobby. The
|
|
447
|
+
* lobby stays aside for the whole visit, not only for each menu, so it does
|
|
448
|
+
* not flash between them, and rereads the config when it comes back.
|
|
449
|
+
*/
|
|
450
|
+
async function lobbySettings(state: Runtime, entry?: "quickfix" | "planner"): Promise<void> {
|
|
451
|
+
if (state.inSettings) return;
|
|
452
|
+
state.inSettings = true;
|
|
453
|
+
setMouse(state, false);
|
|
454
|
+
state.handle?.setHidden(true);
|
|
455
|
+
try {
|
|
456
|
+
if (entry) await openEntrySettings(state.pi, state.ctx, entry);
|
|
457
|
+
else await openSettings(state.pi, state.ctx);
|
|
458
|
+
} catch (error) {
|
|
459
|
+
state.ctx.ui.notify(`bot-lobby: settings failed — ${(error as Error).message}`, "warning");
|
|
460
|
+
} finally {
|
|
461
|
+
state.inSettings = false;
|
|
462
|
+
state.asideForPrompt = false;
|
|
463
|
+
if (state.visible && state.handle) {
|
|
464
|
+
state.handle.setHidden(false);
|
|
465
|
+
state.handle.focus();
|
|
466
|
+
setMouse(state, true);
|
|
467
|
+
}
|
|
468
|
+
state.view?.reloadConfig();
|
|
469
|
+
rerender();
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** Remember which panes show, keeping every other setting as the file has it now. */
|
|
474
|
+
function savePanels(panels: Record<LobbyPanel, boolean>): void {
|
|
475
|
+
try {
|
|
476
|
+
const config = loadConfig();
|
|
477
|
+
saveConfig({ ...config, lobby: { ...config.lobby, panels: { ...panels } } });
|
|
478
|
+
} catch {
|
|
479
|
+
// A read-only config only means the choice lasts for this session.
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/** SGR mouse reporting: presses, releases and the wheel, in cell coordinates. */
|
|
484
|
+
const MOUSE_ON = "\x1b[?1000h\x1b[?1006h";
|
|
485
|
+
const MOUSE_OFF = "\x1b[?1006l\x1b[?1000l";
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* In pi's regular screen nothing reports the mouse, so the lobby turns
|
|
489
|
+
* reporting on while it is up (clicks pick tabs and draft lines, the wheel
|
|
490
|
+
* scrolls) and off whenever it steps aside. Full-screen pi reports the mouse
|
|
491
|
+
* itself and hands the events to the lobby.
|
|
492
|
+
*/
|
|
493
|
+
function setMouse(state: Runtime, on: boolean): void {
|
|
494
|
+
const tui = state.tui;
|
|
495
|
+
if (!tui || tui.mode === "fullscreen") return;
|
|
496
|
+
const want = on && loadConfig().lobby.mouse;
|
|
497
|
+
if (want === state.mouse) return;
|
|
498
|
+
state.mouse = want;
|
|
499
|
+
tui.terminal.write(want ? MOUSE_ON : MOUSE_OFF);
|
|
500
|
+
}
|
|
501
|
+
|
|
215
502
|
/** Show the lobby (optionally on `tab`); false when there is no interactive TUI. */
|
|
216
503
|
export function showLobby(tab?: TabId): boolean {
|
|
217
504
|
const state = runtime;
|
|
@@ -229,6 +516,7 @@ export function showLobby(tab?: TabId): boolean {
|
|
|
229
516
|
state.asideForPrompt = false;
|
|
230
517
|
if (tab) state.view.setTab(tab);
|
|
231
518
|
state.view.start();
|
|
519
|
+
setMouse(state, true);
|
|
232
520
|
applyStatus(state.ctx, state.root, state.configDir);
|
|
233
521
|
state.tui.requestRender();
|
|
234
522
|
return true;
|
|
@@ -239,6 +527,7 @@ export function hideLobby(): void {
|
|
|
239
527
|
if (!state?.view || !state.visible) return;
|
|
240
528
|
state.visible = false;
|
|
241
529
|
state.asideForPrompt = false;
|
|
530
|
+
setMouse(state, false);
|
|
242
531
|
state.view.stop();
|
|
243
532
|
state.handle?.setHidden(true);
|
|
244
533
|
applyStatus(state.ctx, state.root, state.configDir);
|
|
@@ -261,23 +550,26 @@ export function autoOpenLobby(): void {
|
|
|
261
550
|
/** Step aside while pi shows a dialog (an approval, a question), and come back after. */
|
|
262
551
|
function promptStarted(): void {
|
|
263
552
|
const state = runtime;
|
|
264
|
-
if (!state?.visible || !state.handle) return;
|
|
553
|
+
if (!state?.visible || !state.handle || state.inSettings) return;
|
|
265
554
|
state.asideForPrompt = true;
|
|
555
|
+
setMouse(state, false);
|
|
266
556
|
state.handle.setHidden(true);
|
|
267
557
|
}
|
|
268
558
|
|
|
269
559
|
function promptEnded(): void {
|
|
270
560
|
const state = runtime;
|
|
271
|
-
if (!state?.asideForPrompt || !state.handle) return;
|
|
561
|
+
if (!state?.asideForPrompt || !state.handle || state.inSettings) return;
|
|
272
562
|
state.asideForPrompt = false;
|
|
273
|
-
if (state.visible)
|
|
563
|
+
if (!state.visible) return;
|
|
564
|
+
state.handle.setHidden(false);
|
|
565
|
+
setMouse(state, true);
|
|
274
566
|
}
|
|
275
567
|
|
|
276
568
|
function shutdown(): void {
|
|
277
569
|
const state = runtime;
|
|
278
570
|
if (!state) return;
|
|
279
571
|
runtime = undefined;
|
|
280
|
-
|
|
572
|
+
setMouse(state, false);
|
|
281
573
|
state.unsubscribeFeed?.();
|
|
282
574
|
state.quickfix.cancelAll();
|
|
283
575
|
state.planner?.cancel();
|
|
@@ -301,6 +593,9 @@ export function initLobby(pi: ExtensionAPI, ctx: ExtensionContext, configDir: st
|
|
|
301
593
|
root,
|
|
302
594
|
visible: false,
|
|
303
595
|
asideForPrompt: false,
|
|
596
|
+
asking: false,
|
|
597
|
+
mouse: false,
|
|
598
|
+
inSettings: false,
|
|
304
599
|
scene: new ZenScene(),
|
|
305
600
|
quickfix: undefined as unknown as QuickFixQueue,
|
|
306
601
|
issues: new IssuesState(execCommand, ctx.cwd, rerender),
|
|
@@ -332,8 +627,15 @@ export function initLobby(pi: ExtensionAPI, ctx: ExtensionContext, configDir: st
|
|
|
332
627
|
state.tui = tui;
|
|
333
628
|
return { render: () => [], invalidate: () => {} };
|
|
334
629
|
}, { placement: "belowEditor" });
|
|
335
|
-
|
|
336
|
-
|
|
630
|
+
// The owner's clock (pi/owner.ts) delivers comments and messages and drives auto mode; the lobby logs what it did.
|
|
631
|
+
onOwnerEvent((event) => {
|
|
632
|
+
if (event.kind === "comments") lobbyFeed.log("LOBBY", `passed ${event.count} plan comment${event.count === 1 ? "" : "s"} on ${event.taskId} to the oracle`, "info");
|
|
633
|
+
else if (event.kind === "inbox") lobbyFeed.log("LOBBY", `passed ${event.count} message${event.count === 1 ? "" : "s"} from another session to the oracle`, "info");
|
|
634
|
+
else if (event.kind === "auto") lobbyFeed.log("LOBBY", `auto mode ${event.on ? "on" : "off"} for ${event.taskId}`, event.on ? "success" : "info");
|
|
635
|
+
else if (event.kind === "nudge") lobbyFeed.log("LOBBY", `auto mode: keeping the oracle going on ${event.taskId}`, "info");
|
|
636
|
+
else lobbyFeed.log("LOBBY", `auto mode: no progress on ${event.taskId} — it needs you`, "warning");
|
|
637
|
+
rerender();
|
|
638
|
+
});
|
|
337
639
|
}
|
|
338
640
|
|
|
339
641
|
interface MasterTurn {
|
|
@@ -381,37 +683,22 @@ export function registerLobbyEvents(pi: ExtensionAPI, configDir: string): void {
|
|
|
381
683
|
const taskId = currentZenTask()?.id;
|
|
382
684
|
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 };
|
|
383
685
|
});
|
|
686
|
+
const narrate = (event: AgentEventLike) => narrateEvent(lobbyFeed, event, describeToolCall);
|
|
384
687
|
pi.on("tool_execution_start", (event) => {
|
|
385
688
|
if (turn) turn.tools += 1;
|
|
386
|
-
|
|
387
|
-
});
|
|
388
|
-
pi.on("tool_execution_end", (event) => lobbyFeed.end(event.toolCallId, event.isError));
|
|
389
|
-
pi.on("message_update", (event) => {
|
|
390
|
-
const update = event.assistantMessageEvent;
|
|
391
|
-
if (update.type === "thinking_delta") lobbyFeed.thinkDelta("MASTER", update.delta);
|
|
392
|
-
else if (update.type === "thinking_end") lobbyFeed.thinkEnd("MASTER", update.content);
|
|
393
|
-
else if (update.type === "text_delta") lobbyFeed.replyDelta(update.delta);
|
|
689
|
+
narrate(event);
|
|
394
690
|
});
|
|
691
|
+
pi.on("tool_execution_end", (event) => narrate(event));
|
|
692
|
+
pi.on("message_update", (event) => narrate(event as AgentEventLike));
|
|
395
693
|
pi.on("message_end", (event) => {
|
|
396
|
-
const message = event.message as { role?: string;
|
|
397
|
-
if (message.role === "assistant") {
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
lobbyFeed.say("note", `✗ the oracle's turn failed: ${error}`);
|
|
403
|
-
lobbyFeed.log("MASTER", `turn failed — ${error}`, "error");
|
|
404
|
-
}
|
|
405
|
-
if (turn) {
|
|
406
|
-
turn.turns += 1;
|
|
407
|
-
turn.input += message.usage?.input ?? 0;
|
|
408
|
-
turn.output += message.usage?.output ?? 0;
|
|
409
|
-
turn.cost += message.usage?.cost?.total ?? 0;
|
|
410
|
-
}
|
|
694
|
+
const message = event.message as { role?: string; usage?: { input?: number; output?: number; cost?: { total?: number } } };
|
|
695
|
+
if (message.role === "assistant" && turn) {
|
|
696
|
+
turn.turns += 1;
|
|
697
|
+
turn.input += message.usage?.input ?? 0;
|
|
698
|
+
turn.output += message.usage?.output ?? 0;
|
|
699
|
+
turn.cost += message.usage?.cost?.total ?? 0;
|
|
411
700
|
}
|
|
412
|
-
|
|
413
|
-
const line = chatText(message.role, textOf(message.content));
|
|
414
|
-
if (line) lobbyFeed.say(line.role, line.text);
|
|
701
|
+
narrate(event as AgentEventLike);
|
|
415
702
|
});
|
|
416
703
|
pi.on("agent_end", (event, ctx) => {
|
|
417
704
|
track(ctx);
|