@a-t-h-i/bot-lobby 0.5.1 → 0.6.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 +180 -44
- package/package.json +1 -1
- package/prompts/panel.md +6 -2
- package/prompts/planner.md +29 -16
- package/src/execution/pi-runner.ts +1 -1
- package/src/index.ts +4 -2
- package/src/lobby/feed.ts +65 -9
- package/src/lobby/keys.ts +5 -1
- package/src/lobby/layout.ts +17 -2
- package/src/lobby/planner.ts +16 -5
- package/src/lobby/runtime.ts +317 -61
- package/src/lobby/session-files.ts +138 -0
- package/src/lobby/sessions.ts +314 -0
- package/src/lobby/tabs/home.ts +145 -29
- package/src/lobby/tabs/plan.ts +25 -30
- package/src/lobby/tabs/quickfix.ts +5 -8
- package/src/lobby/tabs/tasks.ts +200 -51
- package/src/lobby/view.ts +763 -89
- package/src/pi/commands.ts +52 -6
- package/src/pi/events.ts +5 -2
- package/src/pi/notify.ts +18 -0
- package/src/pi/owner.ts +265 -0
- package/src/pi/settings-ui.ts +6 -2
- 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 +8 -3
- package/src/schemas/task.ts +4 -0
- package/src/state/archive.ts +99 -0
- package/src/state/auto.ts +40 -0
- package/src/state/inbox.ts +124 -0
- package/src/state/presence.ts +99 -0
- package/src/workflow/workflow.ts +27 -3
package/src/lobby/layout.ts
CHANGED
|
@@ -20,15 +20,18 @@ export type LobbyColor =
|
|
|
20
20
|
| "borderMuted"
|
|
21
21
|
| "toolTitle"
|
|
22
22
|
| "mdHeading"
|
|
23
|
-
| "mdCode"
|
|
23
|
+
| "mdCode"
|
|
24
|
+
| "userMessageText";
|
|
24
25
|
|
|
25
26
|
export interface LobbyTheme {
|
|
26
27
|
fg(color: LobbyColor, text: string): string;
|
|
27
28
|
bold(text: string): string;
|
|
28
29
|
italic?(text: string): string;
|
|
29
|
-
bg?(color: "selectedBg" | "searchMatchBg", text: string): string;
|
|
30
|
+
bg?(color: "selectedBg" | "searchMatchBg" | "userMessageBg", text: string): string;
|
|
30
31
|
/** Render Markdown to styled lines; plain wrapping without it (tests). */
|
|
31
32
|
markdown?(text: string, width: number): string[];
|
|
33
|
+
/** Strike text through (an abandoned task's title); plain without it (tests). */
|
|
34
|
+
strike?(text: string): string;
|
|
32
35
|
}
|
|
33
36
|
|
|
34
37
|
/** Paint only when a theme is present; tests without one get plain text. */
|
|
@@ -44,6 +47,18 @@ export function italic(theme: LobbyTheme | undefined, text: string): string {
|
|
|
44
47
|
return theme?.italic && text ? theme.italic(text) : text;
|
|
45
48
|
}
|
|
46
49
|
|
|
50
|
+
export function strike(theme: LobbyTheme | undefined, text: string): string {
|
|
51
|
+
return theme?.strike && text ? theme.strike(text) : text;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** `left` and `right` on one line of exactly `width` columns: the left side gives way first. */
|
|
55
|
+
export function spread(left: string, right: string, width: number): string {
|
|
56
|
+
if (!right) return fit(left, width);
|
|
57
|
+
const room = width - visibleWidth(right) - 1;
|
|
58
|
+
if (room < 4) return fit(left, width);
|
|
59
|
+
return `${fit(left, room)} ${right}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
47
62
|
/** Exactly `width` columns: truncated with an ellipsis, or padded with spaces. */
|
|
48
63
|
export function fit(text: string, width: number): string {
|
|
49
64
|
if (width <= 0) return "";
|
package/src/lobby/planner.ts
CHANGED
|
@@ -22,7 +22,7 @@ import { PANEL_MEMBERS, type PanelMember } from "../schemas/configuration.ts";
|
|
|
22
22
|
import { truncate } from "../text.ts";
|
|
23
23
|
import type { LobbyFeed } from "./feed.ts";
|
|
24
24
|
import type { QuickFixProfile } from "./quickfix.ts";
|
|
25
|
-
import type
|
|
25
|
+
import { MAX_QUESTIONS, type AskResult } from "./ask.ts";
|
|
26
26
|
|
|
27
27
|
/** Seats and the oracle read the repository to ask informed questions; they never edit. */
|
|
28
28
|
export const PLANNER_TOOLS: readonly string[] = ["read", "grep", "find", "ls"];
|
|
@@ -192,6 +192,17 @@ export function questionItems(body: string | undefined): AskedQuestion[] {
|
|
|
192
192
|
return items.map(inlineOptions);
|
|
193
193
|
}
|
|
194
194
|
|
|
195
|
+
/**
|
|
196
|
+
* The round's questions for the user. The oracle chooses them from its own
|
|
197
|
+
* and the seats' (tagged with the seat each serves) and decides the rest; only
|
|
198
|
+
* when its part failed do the seats' own questions go through. Never more than
|
|
199
|
+
* `MAX_QUESTIONS`, so a round is answered in one questionnaire.
|
|
200
|
+
*/
|
|
201
|
+
export function roundQuestions(reply: PlannerReply | undefined, seatQuestions: readonly PanelQuestion[]): PanelQuestion[] {
|
|
202
|
+
const chosen = reply ? reply.questions.map((question) => tagged(question, ORACLE_LABEL)) : [...seatQuestions];
|
|
203
|
+
return chosen.slice(0, MAX_QUESTIONS);
|
|
204
|
+
}
|
|
205
|
+
|
|
195
206
|
/** A leading `[SEAT]` tag names who asked; the oracle uses it when it relays a seat. */
|
|
196
207
|
function tagged(question: AskedQuestion, fallback: string): PanelQuestion {
|
|
197
208
|
const tag = /^\[(DEV|DESIGN|QA|RESEARCH|ORACLE)\]\s*/i.exec(question.text);
|
|
@@ -283,8 +294,8 @@ export function commentBlock(comments: readonly LineComment[]): string {
|
|
|
283
294
|
|
|
284
295
|
/** What the panel said in a round, as the conversation shows it. */
|
|
285
296
|
export function plannerSays(ready: boolean, questions: readonly PanelQuestion[]): string {
|
|
286
|
-
if (ready) return "The panel agrees the plan is clear.
|
|
287
|
-
if (questions.length === 0) return "No open questions this round.
|
|
297
|
+
if (ready) return "The panel agrees the plan is clear. Save it as a pending task, or keep refining.";
|
|
298
|
+
if (questions.length === 0) return "No open questions this round. Save the draft, or add detail.";
|
|
288
299
|
return questions.map(questionLine).join("\n");
|
|
289
300
|
}
|
|
290
301
|
|
|
@@ -532,11 +543,11 @@ export class PlanningSession {
|
|
|
532
543
|
if (!this.error && !this.busy && this.questions.length === 0 && this.lineComments.length > 0) await this.send("");
|
|
533
544
|
}
|
|
534
545
|
|
|
535
|
-
/**
|
|
546
|
+
/** The oracle's reply sets the round's questions (chosen from the seats' and its own), verdict and draft. */
|
|
536
547
|
private finishRound(outcomes: readonly MemberOutcome[], lead: RunOutcome): void {
|
|
537
548
|
const reply = lead.status === "success" ? parsePlannerReply(lead.output) : undefined;
|
|
538
549
|
const seatQuestions = outcomes.flatMap((outcome) => (outcome.reply?.questions ?? []).map((question) => ({ ...question, from: MEMBER_LABELS[outcome.member] })));
|
|
539
|
-
const questions =
|
|
550
|
+
const questions = roundQuestions(reply, seatQuestions);
|
|
540
551
|
const seatsReady = outcomes.every((outcome) => outcome.reply?.status === "ready");
|
|
541
552
|
const ready = Boolean(reply && reply.status === "ready" && seatsReady);
|
|
542
553
|
if (reply) {
|
package/src/lobby/runtime.ts
CHANGED
|
@@ -7,34 +7,42 @@
|
|
|
7
7
|
* records each Master turn in the metrics log.
|
|
8
8
|
*/
|
|
9
9
|
import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
10
|
-
import { getSelectListTheme } 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
14
|
import { detectProjectRoot, loadConfig, saveConfig } from "../state/project.ts";
|
|
15
|
-
import { addPlanComment,
|
|
16
|
-
import {
|
|
15
|
+
import { addPlanComment, readPlanComments } from "../state/comments.ts";
|
|
16
|
+
import { isAutoMode } from "../state/auto.ts";
|
|
17
|
+
import { sendToInbox, sendToSession as leaveForSession } from "../state/inbox.ts";
|
|
18
|
+
import { livePresence } from "../state/presence.ts";
|
|
19
|
+
import { archiveTask as archiveTaskOnDisk, deleteTask as deleteTaskOnDisk, listArchivedTasks, restoreTask as restoreTaskOnDisk } from "../state/archive.ts";
|
|
20
|
+
import { discardPlannedTask, listPlannedTasks, type PlannedTask } from "../state/backlog.ts";
|
|
17
21
|
import { appendMetrics, readMetrics, type MetricStatus } from "../state/metrics.ts";
|
|
18
22
|
import { describeToolCall } from "../pi/activity.ts";
|
|
19
|
-
import { applyStatus, currentZenTask, isMinimized, onMinimizeChange, onRunUpdates, setMinimized, setWidgetSuppressor, ZenScene, zenSnapshot } from "../pi/ui.ts";
|
|
23
|
+
import { applyStatus, currentZenTask, isMinimized, onMinimizeChange, onRunUpdates, persistedRuns, setMinimized, setWidgetSuppressor, ZenScene, zenSnapshot } from "../pi/ui.ts";
|
|
24
|
+
import { panelLines } from "../pi/zen.ts";
|
|
25
|
+
import { shortTitle } from "../text.ts";
|
|
20
26
|
import { isSubagentProcess } from "../pi/quiet.ts";
|
|
21
27
|
import { modelRef, resolveLobbyProfile, resolvePanelProfile } from "../pi/model-support.ts";
|
|
22
28
|
import { modelLookup } from "../pi/tools.ts";
|
|
23
|
-
import { startTask } from "../pi/start-task.ts";
|
|
29
|
+
import { startPlannedTask, startTask } from "../pi/start-task.ts";
|
|
24
30
|
import type { LobbyAgentKind, LobbyPanel, PanelMember } from "../schemas/configuration.ts";
|
|
25
|
-
import { chatFromEntries,
|
|
31
|
+
import { chatFromEntries, lobbyFeed, narrateEvent, type AgentEventLike } from "./feed.ts";
|
|
32
|
+
import { launchPi, SessionRegistry, type BackgroundSession, type SessionLauncher } from "./sessions.ts";
|
|
33
|
+
import { SessionChats } from "./session-files.ts";
|
|
26
34
|
import { answerMessage, dialogAsker, loadAskTool, questionnaires, toolAsker, type Asker } from "./ask.ts";
|
|
27
35
|
import { QuickFixQueue } from "./quickfix.ts";
|
|
28
36
|
import { PlanningSession, type PlannerSeed } from "./planner.ts";
|
|
29
37
|
import { execCommand, IssuesState } from "./issues.ts";
|
|
30
|
-
import { LobbyView, type LobbyHost, type TabId } from "./view.ts";
|
|
38
|
+
import { LobbyView, type LiveSession, type LobbyHost, type SwitchTarget, type TabId } from "./view.ts";
|
|
31
39
|
import type { LobbyTheme } from "./layout.ts";
|
|
32
40
|
import { createMarkdownRenderer } from "./markdown.ts";
|
|
41
|
+
import { deliverComments, onOwnerEvent, setAuto } from "../pi/owner.ts";
|
|
33
42
|
import { openEntrySettings, openSettings } from "../pi/settings-ui.ts";
|
|
34
43
|
|
|
35
44
|
export const ANCHOR_KEY = "bot-lobby-anchor";
|
|
36
|
-
|
|
37
|
-
export const COMMENT_POLL_MS = 3000;
|
|
45
|
+
export { deliverComments };
|
|
38
46
|
|
|
39
47
|
interface Runtime {
|
|
40
48
|
pi: ExtensionAPI;
|
|
@@ -51,7 +59,6 @@ interface Runtime {
|
|
|
51
59
|
quickfix: QuickFixQueue;
|
|
52
60
|
planner?: PlanningSession;
|
|
53
61
|
issues: IssuesState;
|
|
54
|
-
poll?: ReturnType<typeof setInterval>;
|
|
55
62
|
unsubscribeFeed?: () => void;
|
|
56
63
|
/** The ask-user-question tool (or pi's dialogs), loaded once on first use. */
|
|
57
64
|
asker?: Promise<Asker>;
|
|
@@ -69,6 +76,11 @@ export function isLobbyVisible(): boolean {
|
|
|
69
76
|
return runtime?.visible === true;
|
|
70
77
|
}
|
|
71
78
|
|
|
79
|
+
/** The lobby's view, once it has been shown (tests drive it through this). */
|
|
80
|
+
export function lobbyView(): LobbyView | undefined {
|
|
81
|
+
return runtime?.view;
|
|
82
|
+
}
|
|
83
|
+
|
|
72
84
|
function rerender(): void {
|
|
73
85
|
if (runtime?.visible) runtime.tui?.requestRender();
|
|
74
86
|
}
|
|
@@ -85,21 +97,6 @@ function lobbyProfile(state: Runtime, kind: LobbyAgentKind) {
|
|
|
85
97
|
});
|
|
86
98
|
}
|
|
87
99
|
|
|
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
100
|
function addComment(state: Runtime, taskId: string, text: string): string {
|
|
104
101
|
const task = loadTask(state.root, state.configDir, taskId);
|
|
105
102
|
if (!task) return `no task ${taskId}`;
|
|
@@ -141,15 +138,262 @@ function toOracle(state: Runtime, text: string): string | undefined {
|
|
|
141
138
|
function startPlanned(state: Runtime, plan: PlannedTask): string {
|
|
142
139
|
const current = currentZenTask();
|
|
143
140
|
if (current) return `this session already drives ${current.id}; finish or cancel it first, or start ${plan.id} from another session`;
|
|
144
|
-
|
|
141
|
+
startPlannedTask(state.pi, state.ctx, state.configDir, plan.id)
|
|
145
142
|
.then((task) => {
|
|
146
|
-
if (
|
|
147
|
-
markPlannedTaskStarted(state.root, state.configDir, plan.id, task.id);
|
|
143
|
+
if (typeof task === "string") return failed(state, `could not start ${plan.id}`, new Error(task));
|
|
148
144
|
state.view?.setTab("lobby");
|
|
149
145
|
rerender();
|
|
150
146
|
})
|
|
151
147
|
.catch((error: Error) => failed(state, `could not start ${plan.id}`, error));
|
|
152
|
-
return `starting ${plan.id}
|
|
148
|
+
return `starting ${plan.id} here — its agreed plan needs no approval…`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/* ------------------------------------------------- background sessions */
|
|
152
|
+
|
|
153
|
+
let launcher: SessionLauncher = launchPi;
|
|
154
|
+
let registry: SessionRegistry | undefined;
|
|
155
|
+
let exitHooked = false;
|
|
156
|
+
/** How many questions of each session were already announced, and which exits, so each is said once. */
|
|
157
|
+
const announced = new Map<string, number>();
|
|
158
|
+
const exitsSaid = new Set<string>();
|
|
159
|
+
/** Other sessions' conversations, read from their files. */
|
|
160
|
+
const chats = new SessionChats(async () => {
|
|
161
|
+
const state = runtime;
|
|
162
|
+
if (!state) return [];
|
|
163
|
+
const dirs = new Map<string, string | undefined>([[state.ctx.cwd, state.ctx.sessionManager.getSessionDir()]]);
|
|
164
|
+
if (!dirs.has(state.root)) dirs.set(state.root, undefined);
|
|
165
|
+
const lists = await Promise.all([...dirs].map(([cwd, dir]) => SessionManager.list(cwd, dir).catch(() => [])));
|
|
166
|
+
return lists.flat();
|
|
167
|
+
}, () => rerender());
|
|
168
|
+
|
|
169
|
+
/** Launch background sessions with something else (tests); resets the registry. */
|
|
170
|
+
export function setSessionLauncher(next: SessionLauncher | undefined): void {
|
|
171
|
+
registry?.stopAll();
|
|
172
|
+
registry = undefined;
|
|
173
|
+
launcher = next ?? launchPi;
|
|
174
|
+
announced.clear();
|
|
175
|
+
exitsSaid.clear();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The background sessions this window started. The registry belongs to the
|
|
180
|
+
* process, not to one pi session, so switching sessions here keeps them
|
|
181
|
+
* running; they stop when pi exits (each is a saved session `/resume` finds).
|
|
182
|
+
*/
|
|
183
|
+
function sessionRegistry(): SessionRegistry {
|
|
184
|
+
if (!registry) registry = new SessionRegistry(launcher, sessionsChanged);
|
|
185
|
+
if (!exitHooked) {
|
|
186
|
+
exitHooked = true;
|
|
187
|
+
process.once("exit", () => registry?.stopAll());
|
|
188
|
+
}
|
|
189
|
+
return registry;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function backgroundSessions(): readonly BackgroundSession[] {
|
|
193
|
+
return registry?.sessions ?? [];
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** A background session changed: redraw, and say so when one asks something or fails while the lobby is hidden. */
|
|
197
|
+
function sessionsChanged(): void {
|
|
198
|
+
const state = runtime;
|
|
199
|
+
for (const session of registry?.sessions ?? []) {
|
|
200
|
+
if (session.sessionId && session.sessionFile) chats.remember(session.sessionId, session.sessionFile);
|
|
201
|
+
const seen = announced.get(session.key) ?? 0;
|
|
202
|
+
announced.set(session.key, session.dialogs.length);
|
|
203
|
+
if (!state || state.visible) continue;
|
|
204
|
+
if (session.dialogs.length > seen) state.ctx.ui.notify(`bot-lobby: ${session.name} is waiting for you — alt+l opens the lobby`, "info");
|
|
205
|
+
if (!session.alive && !exitsSaid.has(session.key)) {
|
|
206
|
+
exitsSaid.add(session.key);
|
|
207
|
+
if (session.exitCode) state.ctx.ui.notify(`bot-lobby: ${session.name} exited (${session.exitCode})${session.lastError() ? `: ${session.lastError()}` : ""}`, "warning");
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
rerender();
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Start a task in its own new pi session, named after the task; the session, or why not. */
|
|
214
|
+
function startSession(state: Runtime, start: { request?: string; plan?: PlannedTask; auto?: boolean }): BackgroundSession | string {
|
|
215
|
+
const request = start.request?.trim();
|
|
216
|
+
const plan = start.plan;
|
|
217
|
+
if (!plan && !request) return "describe the task first";
|
|
218
|
+
if (!plan && request!.startsWith("/")) return "a new session starts from a task description, not a command";
|
|
219
|
+
if (plan) {
|
|
220
|
+
if (plan.status !== "pending") return `${plan.id} was already started${plan.startedTaskId ? ` as ${plan.startedTaskId}` : ""}`;
|
|
221
|
+
const starting = backgroundSessions().find((session) => session.alive && session.planId === plan.id);
|
|
222
|
+
if (starting) return `${plan.id} is already starting in ${starting.name}`;
|
|
223
|
+
}
|
|
224
|
+
const name = plan?.title ?? shortTitle(request!);
|
|
225
|
+
try {
|
|
226
|
+
const session = sessionRegistry().start(state.ctx.cwd, { name, ...(plan ? { planId: plan.id } : { request: request! }), ...(start.auto ? { auto: true } : {}) }, sessionModel(state.ctx));
|
|
227
|
+
lobbyFeed.log("LOBBY", `started "${name}" in a new session`, "success");
|
|
228
|
+
return session;
|
|
229
|
+
} catch (error) {
|
|
230
|
+
return `could not start a new session — ${(error as Error).message}`;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Put the oldest question a background session waits on to the user, with pi's own dialogs; escape cancels it as it would there. */
|
|
235
|
+
async function answerDialog(state: Runtime, session: BackgroundSession): Promise<void> {
|
|
236
|
+
const dialog = session.dialogs[0];
|
|
237
|
+
if (!dialog) return;
|
|
238
|
+
const title = `${session.name} — ${dialog.title}`;
|
|
239
|
+
const ui = state.ctx.ui;
|
|
240
|
+
try {
|
|
241
|
+
if (dialog.method === "select") {
|
|
242
|
+
const value = await ui.select(title, dialog.options ?? []);
|
|
243
|
+
session.answer(dialog.id, value === undefined ? { cancelled: true } : { value });
|
|
244
|
+
} else if (dialog.method === "confirm") {
|
|
245
|
+
session.answer(dialog.id, { confirmed: await ui.confirm(title, dialog.message ?? "") });
|
|
246
|
+
} else {
|
|
247
|
+
const value = dialog.method === "input" ? await ui.input(title, dialog.placeholder) : await ui.editor(title, dialog.prefill);
|
|
248
|
+
session.answer(dialog.id, value === undefined ? { cancelled: true } : { value });
|
|
249
|
+
}
|
|
250
|
+
} catch (error) {
|
|
251
|
+
failed(state, `could not answer ${session.name}`, error as Error);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Leave a message for a task another terminal's session drives; its owner passes it to the oracle. */
|
|
256
|
+
function sendToTask(state: Runtime, taskId: string, text: string): string {
|
|
257
|
+
const task = loadTask(state.root, state.configDir, taskId);
|
|
258
|
+
if (!task) return `no task ${taskId}`;
|
|
259
|
+
if (TERMINAL_STATES.includes(task.state)) return `${taskId} is ${task.state}`;
|
|
260
|
+
try {
|
|
261
|
+
sendToInbox(state.root, state.configDir, taskId, text, state.ctx.sessionManager.getSessionId());
|
|
262
|
+
} catch (error) {
|
|
263
|
+
return (error as Error).message;
|
|
264
|
+
}
|
|
265
|
+
lobbyFeed.log("LOBBY", `message for ${taskId}'s oracle saved`, "info");
|
|
266
|
+
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`;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Auto mode as the lobby last read it, per task: every frame asks, the file is read at most once a second. */
|
|
270
|
+
const autoSeen = new Map<string, { at: number; on: boolean }>();
|
|
271
|
+
const AUTO_READ_MS = 1000;
|
|
272
|
+
|
|
273
|
+
function autoFor(state: Runtime, taskId: string): boolean {
|
|
274
|
+
const now = Date.now();
|
|
275
|
+
const seen = autoSeen.get(taskId);
|
|
276
|
+
if (seen && now - seen.at < AUTO_READ_MS) return seen.on;
|
|
277
|
+
const on = isAutoMode(state.root, state.configDir, taskId);
|
|
278
|
+
autoSeen.set(taskId, { at: now, on });
|
|
279
|
+
return on;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function switchAuto(state: Runtime, taskId: string, on: boolean): void {
|
|
283
|
+
setAuto(state.root, state.configDir, taskId, on, state.ctx.sessionManager.getSessionId());
|
|
284
|
+
autoSeen.set(taskId, { at: Date.now(), on });
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Heartbeats of the other sessions running in this project, reread at most once a second (every frame asks). */
|
|
288
|
+
let liveSeen: { at: number; root: string; sessions: LiveSession[] } | undefined;
|
|
289
|
+
const LIVE_READ_MS = 1000;
|
|
290
|
+
|
|
291
|
+
function liveSessions(state: Runtime): LiveSession[] {
|
|
292
|
+
const now = Date.now();
|
|
293
|
+
if (liveSeen && liveSeen.root === state.root && now - liveSeen.at < LIVE_READ_MS) return liveSeen.sessions;
|
|
294
|
+
const me = state.ctx.sessionManager.getSessionId();
|
|
295
|
+
let sessions: LiveSession[] = [];
|
|
296
|
+
try {
|
|
297
|
+
sessions = livePresence(state.root, state.configDir, now).filter((presence) => presence.sessionId !== me);
|
|
298
|
+
} catch {
|
|
299
|
+
// An unreadable folder only means no other sessions are listed.
|
|
300
|
+
}
|
|
301
|
+
liveSeen = { at: now, root: state.root, sessions };
|
|
302
|
+
return sessions;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Leave a message for a session running in another terminal. */
|
|
306
|
+
function sendToSession(state: Runtime, sessionId: string, text: string): string {
|
|
307
|
+
try {
|
|
308
|
+
leaveForSession(state.root, state.configDir, sessionId, text, state.ctx.sessionManager.getSessionId());
|
|
309
|
+
} catch (error) {
|
|
310
|
+
return (error as Error).message;
|
|
311
|
+
}
|
|
312
|
+
lobbyFeed.log("LOBBY", "message for another session's oracle saved", "info");
|
|
313
|
+
return "sent — that session passes it to its oracle within a few seconds";
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** The lobby reopens on the session this window switches to (set just before asking pi to switch). */
|
|
317
|
+
let reopenAfterSwitch = false;
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Run another session in this window through `/bot-lobby switch`, which pi
|
|
321
|
+
* runs with a command context (the only one that may replace the session).
|
|
322
|
+
* A background session is stopped first, so one process writes its file; a
|
|
323
|
+
* task no session owns is claimed instead.
|
|
324
|
+
*/
|
|
325
|
+
async function switchTo(state: Runtime, target: SwitchTarget): Promise<string> {
|
|
326
|
+
if (!state.ctx.isIdle()) return "this window's oracle is working — esc stops it, then switch";
|
|
327
|
+
if (target.claimTaskId) {
|
|
328
|
+
reopenAfterSwitch = false;
|
|
329
|
+
state.pi.sendUserMessage(`/bot-lobby claim ${target.claimTaskId}`, { expandPromptTemplates: true });
|
|
330
|
+
return `taking ${target.claimTaskId} over in this window`;
|
|
331
|
+
}
|
|
332
|
+
let file = target.background?.sessionFile;
|
|
333
|
+
if (target.background) {
|
|
334
|
+
target.background.stop();
|
|
335
|
+
await target.background.whenExited();
|
|
336
|
+
}
|
|
337
|
+
file ??= target.sessionId ? await chats.locate(target.sessionId) : undefined;
|
|
338
|
+
if (!file) return `could not find ${target.name}'s session file — /resume lists every saved session`;
|
|
339
|
+
reopenAfterSwitch = true;
|
|
340
|
+
state.pi.sendUserMessage(`/bot-lobby switch ${file}`, { expandPromptTemplates: true });
|
|
341
|
+
return `switching this window to ${target.name}…`;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Why a task cannot be archived or deleted now: a running session drives it. */
|
|
345
|
+
function drivenElsewhere(state: Runtime, task: Task): string | undefined {
|
|
346
|
+
if (TERMINAL_STATES.includes(task.state) || !task.ownerSessionId) return undefined;
|
|
347
|
+
if (task.ownerSessionId === state.ctx.sessionManager.getSessionId()) return `this window drives ${task.id} — cancel it first (/bot-lobby cancel ${task.id})`;
|
|
348
|
+
const background = backgroundSessions().find((session) => session.alive && session.sessionId === task.ownerSessionId);
|
|
349
|
+
if (background) return `${background.name} is driving ${task.id} in the background — stop it first (x x)`;
|
|
350
|
+
if (liveSessions(state).some((session) => session.sessionId === task.ownerSessionId)) return `a session in another terminal is driving ${task.id} — finish or cancel it there first`;
|
|
351
|
+
return undefined;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function archiveTask(state: Runtime, taskId: string): string {
|
|
355
|
+
const task = loadTask(state.root, state.configDir, taskId);
|
|
356
|
+
if (!task) return `no task ${taskId}`;
|
|
357
|
+
const busy = drivenElsewhere(state, task);
|
|
358
|
+
if (busy) return busy;
|
|
359
|
+
try {
|
|
360
|
+
archiveTaskOnDisk(state.root, state.configDir, taskId);
|
|
361
|
+
} catch (error) {
|
|
362
|
+
return (error as Error).message;
|
|
363
|
+
}
|
|
364
|
+
lobbyFeed.log("LOBBY", `archived ${taskId}`, "info");
|
|
365
|
+
return `archived ${taskId}${TERMINAL_STATES.includes(task.state) ? "" : " (abandoned first)"} — v shows archived tasks, a restores one`;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function restoreTask(state: Runtime, taskId: string): string {
|
|
369
|
+
try {
|
|
370
|
+
restoreTaskOnDisk(state.root, state.configDir, taskId);
|
|
371
|
+
} catch (error) {
|
|
372
|
+
return (error as Error).message;
|
|
373
|
+
}
|
|
374
|
+
lobbyFeed.log("LOBBY", `restored ${taskId}`, "info");
|
|
375
|
+
return `restored ${taskId} to the task list`;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function deleteTask(state: Runtime, taskId: string, where: "list" | "archive"): string {
|
|
379
|
+
if (where === "list") {
|
|
380
|
+
const task = loadTask(state.root, state.configDir, taskId);
|
|
381
|
+
if (!task) return `no task ${taskId}`;
|
|
382
|
+
const busy = drivenElsewhere(state, task);
|
|
383
|
+
if (busy) return busy;
|
|
384
|
+
}
|
|
385
|
+
try {
|
|
386
|
+
deleteTaskOnDisk(state.root, state.configDir, taskId, where);
|
|
387
|
+
} catch (error) {
|
|
388
|
+
return (error as Error).message;
|
|
389
|
+
}
|
|
390
|
+
lobbyFeed.log("LOBBY", `deleted ${taskId}`, "warning");
|
|
391
|
+
return `deleted ${taskId} for good`;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** A task's status box without animations, for a session other than this window's. */
|
|
395
|
+
function taskScene(state: Runtime, task: Task, width: number, height: number): string[] {
|
|
396
|
+
return panelLines(task, persistedRuns(task), Date.now(), false, { width, rows: Math.floor(height / 0.75), still: true, theme: state.ctx.ui.theme });
|
|
153
397
|
}
|
|
154
398
|
|
|
155
399
|
function seatProfile(state: Runtime, member: PanelMember) {
|
|
@@ -241,6 +485,7 @@ function lobbyTheme(theme: Theme): LobbyTheme {
|
|
|
241
485
|
italic: (text) => theme.italic(text),
|
|
242
486
|
bg: (color, text) => theme.bg(color, text),
|
|
243
487
|
markdown: renderMarkdown,
|
|
488
|
+
strike: (text) => theme.strikethrough(text),
|
|
244
489
|
};
|
|
245
490
|
lobbyThemes.set(theme, wrapped);
|
|
246
491
|
}
|
|
@@ -257,7 +502,7 @@ function host(state: Runtime, tui: TUI): LobbyHost {
|
|
|
257
502
|
return { ...(snapshot.task ? { task: snapshot.task } : {}), runs: snapshot.runs };
|
|
258
503
|
},
|
|
259
504
|
// 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),
|
|
505
|
+
scene: (width, height, animated) => state.scene.lines(width, Math.floor(height / 0.75), state.ctx.ui.theme, Date.now(), !animated),
|
|
261
506
|
advanceScene: (now) => {
|
|
262
507
|
state.scene.advance(now);
|
|
263
508
|
return state.scene.delay(now);
|
|
@@ -293,6 +538,22 @@ function host(state: Runtime, tui: TUI): LobbyHost {
|
|
|
293
538
|
const profile = lobbyProfile(state, kind);
|
|
294
539
|
return `${profile.model ?? "session model"} · ${profile.thinking}`;
|
|
295
540
|
},
|
|
541
|
+
sessionName: () => state.pi.getSessionName(),
|
|
542
|
+
sessions: () => backgroundSessions(),
|
|
543
|
+
startSession: (start) => startSession(state, start),
|
|
544
|
+
answerDialog: (session) => answerDialog(state, session),
|
|
545
|
+
isAuto: (taskId) => autoFor(state, taskId),
|
|
546
|
+
setAuto: (taskId, on) => switchAuto(state, taskId, on),
|
|
547
|
+
sendToTask: (taskId, text) => sendToTask(state, taskId, text),
|
|
548
|
+
sendToSession: (sessionId, text) => sendToSession(state, sessionId, text),
|
|
549
|
+
liveSessions: () => liveSessions(state),
|
|
550
|
+
switchTo: (target) => switchTo(state, target),
|
|
551
|
+
archivedTasks: () => listArchivedTasks(state.root, state.configDir),
|
|
552
|
+
archiveTask: (taskId) => archiveTask(state, taskId),
|
|
553
|
+
restoreTask: (taskId) => restoreTask(state, taskId),
|
|
554
|
+
deleteTask: (taskId, where) => deleteTask(state, taskId, where),
|
|
555
|
+
sessionChat: (sessionId) => chats.chat(sessionId),
|
|
556
|
+
taskScene: (task, width, height) => taskScene(state, task, width, height),
|
|
296
557
|
requestRender: () => tui.requestRender(),
|
|
297
558
|
};
|
|
298
559
|
}
|
|
@@ -424,7 +685,6 @@ function shutdown(): void {
|
|
|
424
685
|
const state = runtime;
|
|
425
686
|
if (!state) return;
|
|
426
687
|
runtime = undefined;
|
|
427
|
-
if (state.poll) clearInterval(state.poll);
|
|
428
688
|
setMouse(state, false);
|
|
429
689
|
state.unsubscribeFeed?.();
|
|
430
690
|
state.quickfix.cancelAll();
|
|
@@ -483,8 +743,15 @@ export function initLobby(pi: ExtensionAPI, ctx: ExtensionContext, configDir: st
|
|
|
483
743
|
state.tui = tui;
|
|
484
744
|
return { render: () => [], invalidate: () => {} };
|
|
485
745
|
}, { placement: "belowEditor" });
|
|
486
|
-
|
|
487
|
-
|
|
746
|
+
// The owner's clock (pi/owner.ts) delivers comments and messages and drives auto mode; the lobby logs what it did.
|
|
747
|
+
onOwnerEvent((event) => {
|
|
748
|
+
if (event.kind === "comments") lobbyFeed.log("LOBBY", `passed ${event.count} plan comment${event.count === 1 ? "" : "s"} on ${event.taskId} to the oracle`, "info");
|
|
749
|
+
else if (event.kind === "inbox" || event.kind === "messages") lobbyFeed.log("LOBBY", `passed ${event.count} message${event.count === 1 ? "" : "s"} from another session to the oracle`, "info");
|
|
750
|
+
else if (event.kind === "auto") lobbyFeed.log("LOBBY", `auto mode ${event.on ? "on" : "off"} for ${event.taskId}`, event.on ? "success" : "info");
|
|
751
|
+
else if (event.kind === "nudge") lobbyFeed.log("LOBBY", `auto mode: keeping the oracle going on ${event.taskId}`, "info");
|
|
752
|
+
else lobbyFeed.log("LOBBY", `auto mode: no progress on ${event.taskId} — it needs you`, "warning");
|
|
753
|
+
rerender();
|
|
754
|
+
});
|
|
488
755
|
}
|
|
489
756
|
|
|
490
757
|
interface MasterTurn {
|
|
@@ -522,7 +789,11 @@ export function registerLobbyEvents(pi: ExtensionAPI, configDir: string): void {
|
|
|
522
789
|
});
|
|
523
790
|
pi.on("session_start", (_event, ctx) => {
|
|
524
791
|
initLobby(pi, ctx, configDir);
|
|
525
|
-
|
|
792
|
+
// A switch asked for from the lobby lands back in the lobby, on the session it switched to.
|
|
793
|
+
if (reopenAfterSwitch) {
|
|
794
|
+
reopenAfterSwitch = false;
|
|
795
|
+
showLobby("lobby");
|
|
796
|
+
} else autoOpenLobby();
|
|
526
797
|
});
|
|
527
798
|
pi.on("session_shutdown", () => shutdown());
|
|
528
799
|
pi.on("ui_prompt_start", () => promptStarted());
|
|
@@ -532,37 +803,22 @@ export function registerLobbyEvents(pi: ExtensionAPI, configDir: string): void {
|
|
|
532
803
|
const taskId = currentZenTask()?.id;
|
|
533
804
|
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
805
|
});
|
|
806
|
+
const narrate = (event: AgentEventLike) => narrateEvent(lobbyFeed, event, describeToolCall);
|
|
535
807
|
pi.on("tool_execution_start", (event) => {
|
|
536
808
|
if (turn) turn.tools += 1;
|
|
537
|
-
|
|
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);
|
|
809
|
+
narrate(event);
|
|
545
810
|
});
|
|
811
|
+
pi.on("tool_execution_end", (event) => narrate(event));
|
|
812
|
+
pi.on("message_update", (event) => narrate(event as AgentEventLike));
|
|
546
813
|
pi.on("message_end", (event) => {
|
|
547
|
-
const message = event.message as { role?: string;
|
|
548
|
-
if (message.role === "assistant") {
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
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
|
-
}
|
|
814
|
+
const message = event.message as { role?: string; usage?: { input?: number; output?: number; cost?: { total?: number } } };
|
|
815
|
+
if (message.role === "assistant" && turn) {
|
|
816
|
+
turn.turns += 1;
|
|
817
|
+
turn.input += message.usage?.input ?? 0;
|
|
818
|
+
turn.output += message.usage?.output ?? 0;
|
|
819
|
+
turn.cost += message.usage?.cost?.total ?? 0;
|
|
562
820
|
}
|
|
563
|
-
|
|
564
|
-
const line = chatText(message.role, textOf(message.content));
|
|
565
|
-
if (line) lobbyFeed.say(line.role, line.text);
|
|
821
|
+
narrate(event as AgentEventLike);
|
|
566
822
|
});
|
|
567
823
|
pi.on("agent_end", (event, ctx) => {
|
|
568
824
|
track(ctx);
|