@a-t-h-i/bot-lobby 0.6.11 → 0.6.13
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 +26 -3
- package/package.json +31 -4
- package/src/ask/tool.ts +2 -1
- package/src/lobby/keys.ts +14 -1
- package/src/lobby/mini.ts +9 -1
- package/src/lobby/models/metrics.ts +14 -0
- package/src/lobby/planner.ts +1 -1
- package/src/lobby/prompt-hub.ts +161 -0
- package/src/lobby/prompts.ts +35 -0
- package/src/lobby/runtime.ts +115 -566
- package/src/lobby/service.ts +655 -0
- package/src/lobby/tabs/home.ts +1 -1
- package/src/lobby/tabs/metrics.ts +4 -6
- package/src/lobby/topics.ts +78 -0
- package/src/lobby/view.ts +29 -24
- package/src/pi/commands.ts +8 -2
- package/src/pi/owner.ts +2 -0
- package/src/pi/settings-ui.ts +52 -1
- package/src/pi/tools.ts +6 -4
- package/src/roles/reviewer.ts +9 -1
- package/src/schemas/configuration.ts +33 -1
- package/src/state/changes.ts +1 -1
- package/src/state/persistence.ts +1 -1
- package/src/state/presence.ts +2 -0
- package/src/webui/api/excalidraw.ts +105 -0
- package/src/webui/api/gh-error.ts +17 -0
- package/src/webui/api/git.ts +98 -0
- package/src/webui/api/index.ts +379 -0
- package/src/webui/api/issues.ts +50 -0
- package/src/webui/api/knowledge.ts +79 -0
- package/src/webui/api/lobby.ts +91 -0
- package/src/webui/api/metrics.ts +63 -0
- package/src/webui/api/plan-facts.ts +29 -0
- package/src/webui/api/planner.ts +98 -0
- package/src/webui/api/plans.ts +44 -0
- package/src/webui/api/prompts.ts +35 -0
- package/src/webui/api/quickfix.ts +48 -0
- package/src/webui/api/sessions.ts +114 -0
- package/src/webui/api/settings.ts +93 -0
- package/src/webui/api/status.ts +55 -0
- package/src/webui/api/tasks.ts +204 -0
- package/src/webui/auth.ts +153 -0
- package/src/webui/command.ts +152 -0
- package/src/webui/dev/fake-service.ts +546 -0
- package/src/webui/dev/fixtures.ts +180 -0
- package/src/webui/dev/serve.ts +54 -0
- package/src/webui/events.ts +207 -0
- package/src/webui/files.ts +88 -0
- package/src/webui/notices.ts +61 -0
- package/src/webui/open.ts +35 -0
- package/src/webui/protocol.ts +565 -0
- package/src/webui/server.ts +315 -0
- package/src/webui/static.ts +125 -0
- package/src/workflow/workflow.ts +22 -6
- package/webui/dist/assets/index-Bk-lpAGm.css +2 -0
- package/webui/dist/assets/index-E6MPcbdC.js +80 -0
- package/webui/dist/build.json +494 -0
- package/webui/dist/icon-192.png +0 -0
- package/webui/dist/icon-512.png +0 -0
- package/webui/dist/index.html +18 -0
- package/webui/dist/manifest.webmanifest +36 -0
- package/webui/dist/offline.html +57 -0
- package/webui/dist/sw.js +88 -0
- package/webui/dist/vite.svg +1 -0
package/src/lobby/runtime.ts
CHANGED
|
@@ -7,55 +7,46 @@
|
|
|
7
7
|
* records each Master turn in the metrics log.
|
|
8
8
|
*/
|
|
9
9
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
10
|
-
import { getSelectListTheme
|
|
10
|
+
import { getSelectListTheme } from "@earendil-works/pi-coding-agent";
|
|
11
11
|
import { Key, type OverlayHandle, type TUI } from "@earendil-works/pi-tui";
|
|
12
|
-
import { TERMINAL_STATES, type Task } from "../schemas/task.ts";
|
|
13
|
-
import { loadTask, peekTasks } from "../state/persistence.ts";
|
|
14
12
|
import { detectProjectRoot, loadConfig, saveConfig } from "../state/project.ts";
|
|
15
|
-
import {
|
|
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";
|
|
21
|
-
import { appendMetrics, readClassifierMetrics, readMetrics, type MetricStatus } from "../state/metrics.ts";
|
|
13
|
+
import { appendMetrics, type MetricStatus } from "../state/metrics.ts";
|
|
22
14
|
import { describeToolCall } from "../pi/activity.ts";
|
|
23
15
|
import { applyStatus, currentZenTask, isMinimized, onMinimizeChange, onRunUpdates, setMinimized, taskSnapshot } from "../pi/ui.ts";
|
|
24
|
-
import {
|
|
25
|
-
import { describeWorkspace, type WorkspaceInfo } from "../execution/workspace.ts";
|
|
16
|
+
import type { WorkspaceInfo } from "../execution/workspace.ts";
|
|
26
17
|
import { basename } from "node:path";
|
|
27
|
-
import { stripStartFlags } from "../pi/start-flags.ts";
|
|
28
18
|
import { isSubagentProcess } from "../pi/quiet.ts";
|
|
29
|
-
import { modelRef
|
|
19
|
+
import { modelRef } from "../pi/model-support.ts";
|
|
30
20
|
import { modelLookup } from "../pi/tools.ts";
|
|
31
|
-
import {
|
|
32
|
-
import {
|
|
21
|
+
import { setQuickFixHandoff } from "../pi/route.ts";
|
|
22
|
+
import { currentWebServer } from "../webui/server.ts";
|
|
33
23
|
import type { Domain } from "../schemas/agent.ts";
|
|
34
|
-
import type {
|
|
35
|
-
import { chatFromEntries, lobbyFeed, narrateEvent, type AgentEventLike
|
|
24
|
+
import type { LobbyPanel } from "../schemas/configuration.ts";
|
|
25
|
+
import { chatFromEntries, lobbyFeed, narrateEvent, type AgentEventLike } from "./feed.ts";
|
|
36
26
|
import { classifier, effortFor, hintsFor } from "../classifier/instance.ts";
|
|
37
27
|
import { checkThinking } from "../pi/model-support.ts";
|
|
38
|
-
import { launchPi, SessionRegistry, type BackgroundSession, type SessionLauncher } from "./sessions.ts";
|
|
39
|
-
import { SessionChats } from "./session-files.ts";
|
|
40
|
-
import { answerMessage, askUser, questionnaires, settledQuestions, type Asker } from "./ask.ts";
|
|
41
28
|
import { jobTitle, QuickFixQueue } from "./quickfix.ts";
|
|
29
|
+
import type { Asker } from "./ask.ts";
|
|
42
30
|
import { miniLine, type MiniInput } from "./mini.ts";
|
|
43
|
-
import { PlanningSession
|
|
31
|
+
import type { PlanningSession } from "./planner.ts";
|
|
44
32
|
import { execCommand, IssuesState } from "./issues.ts";
|
|
45
33
|
import { PullsState } from "./pulls.ts";
|
|
46
34
|
import { PullReviews } from "./pr-review.ts";
|
|
47
35
|
import { KnowledgeBook } from "./knowledge.ts";
|
|
48
36
|
import { ExcalidrawBook } from "../excalidraw/sessions.ts";
|
|
49
|
-
import {
|
|
50
|
-
import {
|
|
37
|
+
import { LobbyView, type LobbyHost, type TabId } from "./view.ts";
|
|
38
|
+
import { createLobbyService, lobbyProfile, miniShown, repaintLobby, reviewProfile, seatProfile, setServiceState, takeReopenAfterSwitch, savePlan as serviceSavePlan, answerPanel as serviceAnswerPanel, type LobbyService } from "./service.ts";
|
|
39
|
+
import { lobbyTopics } from "./topics.ts";
|
|
40
|
+
import { promptHub } from "./prompt-hub.ts";
|
|
41
|
+
export { backgroundSessions, FRAME_MS, setSessionLauncher } from "./service.ts";
|
|
51
42
|
import { lobbyTheme } from "./theme.ts";
|
|
52
|
-
import { deliverComments, onOwnerEvent
|
|
43
|
+
import { deliverComments, onOwnerEvent } from "../pi/owner.ts";
|
|
53
44
|
import { openEntrySettings, openSettings } from "../pi/settings-ui.ts";
|
|
54
45
|
|
|
55
46
|
export const ANCHOR_KEY = "bot-lobby-anchor";
|
|
56
47
|
export { deliverComments };
|
|
57
48
|
|
|
58
|
-
interface Runtime {
|
|
49
|
+
export interface Runtime {
|
|
59
50
|
pi: ExtensionAPI;
|
|
60
51
|
configDir: string;
|
|
61
52
|
ctx: ExtensionContext;
|
|
@@ -74,6 +65,10 @@ interface Runtime {
|
|
|
74
65
|
knowledge: KnowledgeBook;
|
|
75
66
|
excalidraw: ExcalidrawBook;
|
|
76
67
|
unsubscribeFeed?: () => void;
|
|
68
|
+
/** The terminal's subscription to every topic (repaints, as before). */
|
|
69
|
+
unsubscribeTopics?: () => void;
|
|
70
|
+
/** The terminal's registration as the hub's only prompt surface. */
|
|
71
|
+
unregisterTerminalSurface?: () => void;
|
|
77
72
|
/** Puts the panel's questions to the user: the questionnaire unless a test sets another. */
|
|
78
73
|
asker?: Asker;
|
|
79
74
|
/** A questionnaire is on screen. */
|
|
@@ -88,6 +83,12 @@ interface Runtime {
|
|
|
88
83
|
}
|
|
89
84
|
|
|
90
85
|
let runtime: Runtime | undefined;
|
|
86
|
+
let lobbyService: LobbyService | undefined;
|
|
87
|
+
|
|
88
|
+
/** The lobby's backend, once its service has started (terminal and web alike). */
|
|
89
|
+
export function currentLobbyService(): LobbyService | undefined {
|
|
90
|
+
return lobbyService;
|
|
91
|
+
}
|
|
91
92
|
|
|
92
93
|
export function isLobbyVisible(): boolean {
|
|
93
94
|
return runtime?.visible === true;
|
|
@@ -98,34 +99,9 @@ export function lobbyView(): LobbyView | undefined {
|
|
|
98
99
|
return runtime?.view;
|
|
99
100
|
}
|
|
100
101
|
|
|
101
|
-
/**
|
|
102
|
-
export const FRAME_MS = 40;
|
|
103
|
-
let lastFrame = 0;
|
|
104
|
-
let frameTimer: ReturnType<typeof setTimeout> | undefined;
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* Repaint the lobby for something that happened in the background. A reply
|
|
108
|
-
* streams dozens of events a second; they share frames, at most one per
|
|
109
|
-
* `FRAME_MS`, instead of each asking for its own. Keys repaint at once
|
|
110
|
-
* through pi.
|
|
111
|
-
*/
|
|
102
|
+
/** Repaint through the lobby service's frame throttle (see service.ts). */
|
|
112
103
|
function rerender(): void {
|
|
113
|
-
|
|
114
|
-
if (!runtime || (!runtime.visible && !miniShown(runtime)) || frameTimer) return;
|
|
115
|
-
const wait = lastFrame + FRAME_MS - Date.now();
|
|
116
|
-
const paint = () => {
|
|
117
|
-
frameTimer = undefined;
|
|
118
|
-
lastFrame = Date.now();
|
|
119
|
-
if (runtime?.visible || (runtime && miniShown(runtime))) runtime.tui?.requestRender();
|
|
120
|
-
};
|
|
121
|
-
if (wait <= 0) return paint();
|
|
122
|
-
frameTimer = setTimeout(paint, wait);
|
|
123
|
-
frameTimer.unref?.();
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/** The status line shows under the editor while the lobby is hidden (not while bot-lobby is minimized), unless settings turn it off. */
|
|
127
|
-
function miniShown(state: Runtime): boolean {
|
|
128
|
-
return !state.visible && !isMinimized() && loadConfig().lobby.miniLine;
|
|
104
|
+
repaintLobby();
|
|
129
105
|
}
|
|
130
106
|
|
|
131
107
|
/** What the status line shows: the task, the planning round, the quick fix in hand. */
|
|
@@ -134,525 +110,38 @@ function miniInput(state: Runtime): MiniInput {
|
|
|
134
110
|
const planner = state.planner;
|
|
135
111
|
const job = state.quickfix.running ?? state.quickfix.jobs.find((entry) => entry.status === "queued");
|
|
136
112
|
const queued = state.quickfix.jobs.filter((entry) => entry.status === "queued" && entry !== job).length;
|
|
113
|
+
const webLink = currentWebServer()?.link;
|
|
137
114
|
return {
|
|
138
115
|
...(snapshot.task ? { task: snapshot.task } : {}),
|
|
139
116
|
runs: snapshot.runs,
|
|
140
117
|
...(planner ? { planning: { busy: planner.busy, round: planner.turns, limit: loadConfig().lobby.maxPlanningRounds, questions: planner.awaitingAnswers ? planner.questions.length : 0, ready: planner.reply?.status === "ready", saved: Boolean(planner.saved) } } : {}),
|
|
141
118
|
...(job ? { quickfix: { title: jobTitle(job), running: job.status === "running", queued } } : {}),
|
|
119
|
+
...(webLink ? { webLink } : {}),
|
|
142
120
|
key: "Alt+L",
|
|
143
121
|
};
|
|
144
122
|
}
|
|
145
123
|
|
|
146
|
-
|
|
147
|
-
return ctx.model ? modelRef(ctx.model) : undefined;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
function lobbyProfile(state: Runtime, kind: LobbyAgentKind) {
|
|
151
|
-
return resolveLobbyProfile(loadConfig(), kind, {
|
|
152
|
-
lookup: modelLookup(state.ctx),
|
|
153
|
-
sessionModel: sessionModel(state.ctx),
|
|
154
|
-
warn: (message) => state.ctx.ui.notify(message, "warning"),
|
|
155
|
-
});
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
function addComment(state: Runtime, taskId: string, text: string): string {
|
|
159
|
-
const task = loadTask(state.root, state.configDir, taskId);
|
|
160
|
-
if (!task) return `no task ${taskId}`;
|
|
161
|
-
if (TERMINAL_STATES.includes(task.state)) return `${taskId} is ${task.state}; there is no plan left to change`;
|
|
162
|
-
const sessionId = state.ctx.sessionManager.getSessionId();
|
|
163
|
-
try {
|
|
164
|
-
addPlanComment(state.root, state.configDir, taskId, text, sessionId);
|
|
165
|
-
} catch (error) {
|
|
166
|
-
return (error as Error).message;
|
|
167
|
-
}
|
|
168
|
-
lobbyFeed.log("LOBBY", `comment on ${taskId}'s plan saved`, "info");
|
|
169
|
-
if (task.ownerSessionId === sessionId) {
|
|
170
|
-
if (deliverComments() === 0) return `comment saved — it reaches the oracle once ${taskId} is resumed or restored`;
|
|
171
|
-
return `comment sent to the oracle — it will ${task.plan ? "amend the plan" : "revise the proposal"}`;
|
|
172
|
-
}
|
|
173
|
-
if (!task.ownerSessionId) return `comment saved — ${taskId} has no owning session; it is delivered once a session claims it`;
|
|
174
|
-
return `comment saved — the session driving ${taskId} passes it to its oracle`;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
function failed(state: Runtime, what: string, error: Error): void {
|
|
178
|
-
lobbyFeed.log("LOBBY", `${what} — ${error.message}`, "error");
|
|
179
|
-
state.ctx.ui.notify(`bot-lobby: ${what} — ${error.message}`, "error");
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
function toOracle(state: Runtime, text: string): string | undefined {
|
|
183
|
-
const busy = !state.ctx.isIdle();
|
|
184
|
-
if (text.startsWith("/")) {
|
|
185
|
-
state.pi.sendUserMessage(text, { expandPromptTemplates: true, ...(busy ? { deliverAs: "followUp" as const } : {}) });
|
|
186
|
-
return `sent ${text.split(/\s+/)[0]} to pi (built-in commands need the lobby hidden: alt+l)`;
|
|
187
|
-
}
|
|
188
|
-
// While the oracle decides where a request goes, what the user types joins that conversation.
|
|
189
|
-
if (!currentZenTask() && !pendingRequest()) {
|
|
190
|
-
startRequest(state.pi, state.ctx, state.configDir, text).catch((error: Error) => failed(state, "could not start the task", error));
|
|
191
|
-
return "reading your request — a quick fix or a task, the oracle takes it from here";
|
|
192
|
-
}
|
|
193
|
-
state.pi.sendUserMessage(text, busy ? { deliverAs: "steer" } : undefined);
|
|
194
|
-
return undefined;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
function startPlanned(state: Runtime, plan: PlannedTask): string {
|
|
198
|
-
const current = currentZenTask();
|
|
199
|
-
if (current) return `this session already drives ${current.id}; finish or cancel it first, or start ${plan.id} from another session`;
|
|
200
|
-
startPlannedTask(state.pi, state.ctx, state.configDir, plan.id)
|
|
201
|
-
.then((task) => {
|
|
202
|
-
if (typeof task === "string") return failed(state, `could not start ${plan.id}`, new Error(task));
|
|
203
|
-
state.view?.setTab("lobby");
|
|
204
|
-
rerender();
|
|
205
|
-
})
|
|
206
|
-
.catch((error: Error) => failed(state, `could not start ${plan.id}`, error));
|
|
207
|
-
return `starting ${plan.id} here — its agreed plan needs no approval…`;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
/* ------------------------------------------------- background sessions */
|
|
211
|
-
|
|
212
|
-
let launcher: SessionLauncher = launchPi;
|
|
213
|
-
let registry: SessionRegistry | undefined;
|
|
214
|
-
let exitHooked = false;
|
|
215
|
-
/** How many questions of each session were already announced, and which exits, so each is said once. */
|
|
216
|
-
const announced = new Map<string, number>();
|
|
217
|
-
const exitsSaid = new Set<string>();
|
|
218
|
-
/** Other sessions' conversations, read from their files. */
|
|
219
|
-
const chats = new SessionChats(async () => {
|
|
220
|
-
const state = runtime;
|
|
221
|
-
if (!state) return [];
|
|
222
|
-
const dirs = new Map<string, string | undefined>([[state.ctx.cwd, state.ctx.sessionManager.getSessionDir()]]);
|
|
223
|
-
if (!dirs.has(state.root)) dirs.set(state.root, undefined);
|
|
224
|
-
const lists = await Promise.all([...dirs].map(([cwd, dir]) => SessionManager.list(cwd, dir).catch(() => [])));
|
|
225
|
-
return lists.flat();
|
|
226
|
-
}, () => rerender());
|
|
227
|
-
|
|
228
|
-
/** Launch background sessions with something else (tests); resets the registry. */
|
|
229
|
-
export function setSessionLauncher(next: SessionLauncher | undefined): void {
|
|
230
|
-
registry?.stopAll();
|
|
231
|
-
registry = undefined;
|
|
232
|
-
launcher = next ?? launchPi;
|
|
233
|
-
announced.clear();
|
|
234
|
-
exitsSaid.clear();
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
/**
|
|
238
|
-
* The background sessions this window started. The registry belongs to the
|
|
239
|
-
* process, not to one pi session, so switching sessions here keeps them
|
|
240
|
-
* running; they stop when pi exits (each is a saved session `/resume` finds).
|
|
241
|
-
*/
|
|
242
|
-
function sessionRegistry(): SessionRegistry {
|
|
243
|
-
if (!registry) registry = new SessionRegistry(launcher, sessionsChanged);
|
|
244
|
-
if (!exitHooked) {
|
|
245
|
-
exitHooked = true;
|
|
246
|
-
process.once("exit", () => registry?.stopAll());
|
|
247
|
-
}
|
|
248
|
-
return registry;
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
export function backgroundSessions(): readonly BackgroundSession[] {
|
|
252
|
-
return registry?.sessions ?? [];
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
/** A background session changed: redraw, and say so when one asks something or fails while the lobby is hidden. */
|
|
256
|
-
function sessionsChanged(): void {
|
|
257
|
-
const state = runtime;
|
|
258
|
-
for (const session of registry?.sessions ?? []) {
|
|
259
|
-
if (session.sessionId && session.sessionFile) chats.remember(session.sessionId, session.sessionFile);
|
|
260
|
-
const seen = announced.get(session.key) ?? 0;
|
|
261
|
-
announced.set(session.key, session.dialogs.length);
|
|
262
|
-
if (!state || state.visible) continue;
|
|
263
|
-
if (session.dialogs.length > seen) state.ctx.ui.notify(`bot-lobby: ${session.name} is waiting for you — alt+l opens the lobby`, "info");
|
|
264
|
-
if (!session.alive && !exitsSaid.has(session.key)) {
|
|
265
|
-
exitsSaid.add(session.key);
|
|
266
|
-
if (session.exitCode) state.ctx.ui.notify(`bot-lobby: ${session.name} exited (${session.exitCode})${session.lastError() ? `: ${session.lastError()}` : ""}`, "warning");
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
rerender();
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
/** Start a task in its own new pi session, named after the task; the session, or why not. */
|
|
273
|
-
function startSession(state: Runtime, start: { request?: string; plan?: PlannedTask; auto?: boolean }): BackgroundSession | string {
|
|
274
|
-
const request = start.request?.trim();
|
|
275
|
-
const plan = start.plan;
|
|
276
|
-
if (!plan && !request) return "describe the task first";
|
|
277
|
-
if (!plan && request!.startsWith("/")) return "a new session starts from a task description, not a command";
|
|
278
|
-
if (plan) {
|
|
279
|
-
if (plan.status !== "pending") return `${plan.id} was already started${plan.startedTaskId ? ` as ${plan.startedTaskId}` : ""}`;
|
|
280
|
-
const starting = backgroundSessions().find((session) => session.alive && session.planId === plan.id);
|
|
281
|
-
if (starting) return `${plan.id} is already starting in ${starting.name}`;
|
|
282
|
-
}
|
|
283
|
-
// A request may lead with flags (`--worktree add login`); the session is named after what follows them.
|
|
284
|
-
const name = taskName(plan?.title ?? (stripStartFlags(request!) || request!));
|
|
285
|
-
try {
|
|
286
|
-
const session = sessionRegistry().start(state.ctx.cwd, { name, ...(plan ? { planId: plan.id } : { request: request! }), ...(start.auto ? { auto: true } : {}) }, sessionModel(state.ctx));
|
|
287
|
-
lobbyFeed.log("LOBBY", `started "${name}" in a new session`, "success");
|
|
288
|
-
return session;
|
|
289
|
-
} catch (error) {
|
|
290
|
-
return `could not start a new session — ${(error as Error).message}`;
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
/** Put the oldest question a background session waits on to the user, with pi's own dialogs; escape cancels it as it would there. */
|
|
295
|
-
async function answerDialog(state: Runtime, session: BackgroundSession): Promise<void> {
|
|
296
|
-
const dialog = session.dialogs[0];
|
|
297
|
-
if (!dialog) return;
|
|
298
|
-
const title = `${session.name} — ${dialog.title}`;
|
|
299
|
-
const ui = state.ctx.ui;
|
|
300
|
-
try {
|
|
301
|
-
if (dialog.method === "select") {
|
|
302
|
-
const value = await ui.select(title, dialog.options ?? []);
|
|
303
|
-
session.answer(dialog.id, value === undefined ? { cancelled: true } : { value });
|
|
304
|
-
} else if (dialog.method === "confirm") {
|
|
305
|
-
session.answer(dialog.id, { confirmed: await ui.confirm(title, dialog.message ?? "") });
|
|
306
|
-
} else {
|
|
307
|
-
const value = dialog.method === "input" ? await ui.input(title, dialog.placeholder) : await ui.editor(title, dialog.prefill);
|
|
308
|
-
session.answer(dialog.id, value === undefined ? { cancelled: true } : { value });
|
|
309
|
-
}
|
|
310
|
-
} catch (error) {
|
|
311
|
-
failed(state, `could not answer ${session.name}`, error as Error);
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
/** Leave a message for a task another terminal's session drives; its owner passes it to the oracle. */
|
|
316
|
-
function sendToTask(state: Runtime, taskId: string, text: string): string {
|
|
317
|
-
const task = loadTask(state.root, state.configDir, taskId);
|
|
318
|
-
if (!task) return `no task ${taskId}`;
|
|
319
|
-
if (TERMINAL_STATES.includes(task.state)) return `${taskId} is ${task.state}`;
|
|
320
|
-
try {
|
|
321
|
-
sendToInbox(state.root, state.configDir, taskId, text, state.ctx.sessionManager.getSessionId());
|
|
322
|
-
} catch (error) {
|
|
323
|
-
return (error as Error).message;
|
|
324
|
-
}
|
|
325
|
-
lobbyFeed.log("LOBBY", `message for ${taskId}'s oracle saved`, "info");
|
|
326
|
-
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`;
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
/** Auto mode as the lobby last read it, per task: every frame asks, the file is read at most once a second. */
|
|
330
|
-
const autoSeen = new Map<string, { at: number; on: boolean }>();
|
|
331
|
-
const AUTO_READ_MS = 1000;
|
|
332
|
-
|
|
333
|
-
function autoFor(state: Runtime, taskId: string): boolean {
|
|
334
|
-
const now = Date.now();
|
|
335
|
-
const seen = autoSeen.get(taskId);
|
|
336
|
-
if (seen && now - seen.at < AUTO_READ_MS) return seen.on;
|
|
337
|
-
const on = isAutoMode(state.root, state.configDir, taskId);
|
|
338
|
-
autoSeen.set(taskId, { at: now, on });
|
|
339
|
-
return on;
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
function switchAuto(state: Runtime, taskId: string, on: boolean): void {
|
|
343
|
-
setAuto(state.root, state.configDir, taskId, on, state.ctx.sessionManager.getSessionId());
|
|
344
|
-
autoSeen.set(taskId, { at: Date.now(), on });
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
/** Heartbeats of the other sessions running in this project, reread at most once a second (every frame asks). */
|
|
348
|
-
let liveSeen: { at: number; root: string; sessions: LiveSession[] } | undefined;
|
|
349
|
-
const LIVE_READ_MS = 1000;
|
|
350
|
-
|
|
351
|
-
function liveSessions(state: Runtime): LiveSession[] {
|
|
352
|
-
const now = Date.now();
|
|
353
|
-
if (liveSeen && liveSeen.root === state.root && now - liveSeen.at < LIVE_READ_MS) return liveSeen.sessions;
|
|
354
|
-
const me = state.ctx.sessionManager.getSessionId();
|
|
355
|
-
let sessions: LiveSession[] = [];
|
|
356
|
-
try {
|
|
357
|
-
sessions = livePresence(state.root, state.configDir, now).filter((presence) => presence.sessionId !== me);
|
|
358
|
-
} catch {
|
|
359
|
-
// An unreadable folder only means no other sessions are listed.
|
|
360
|
-
}
|
|
361
|
-
liveSeen = { at: now, root: state.root, sessions };
|
|
362
|
-
return sessions;
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
/**
|
|
366
|
-
* A session's whole conversation, oldest first: this window's from pi's
|
|
367
|
-
* branch (already in memory), another's from its session file. Loaded only
|
|
368
|
-
* while the lobby is scrolled back through it.
|
|
369
|
-
*/
|
|
370
|
-
function chatHistory(state: Runtime, sessionId?: string): ChatEntry[] {
|
|
371
|
-
if (sessionId) return chats.history(sessionId);
|
|
372
|
-
return chatFromEntries(state.ctx.sessionManager.getBranch(), Number.POSITIVE_INFINITY).map((line, index) => ({ id: -(index + 1), at: line.at ?? 0, role: line.role, text: line.text }));
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
/** Leave a message for a session running in another terminal. */
|
|
376
|
-
function sendToSession(state: Runtime, sessionId: string, text: string): string {
|
|
377
|
-
try {
|
|
378
|
-
leaveForSession(state.root, state.configDir, sessionId, text, state.ctx.sessionManager.getSessionId());
|
|
379
|
-
} catch (error) {
|
|
380
|
-
return (error as Error).message;
|
|
381
|
-
}
|
|
382
|
-
lobbyFeed.log("LOBBY", "message for another session's oracle saved", "info");
|
|
383
|
-
return "sent — that session passes it to its oracle within a few seconds";
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
/** The lobby reopens on the session this window switches to (set just before asking pi to switch). */
|
|
387
|
-
let reopenAfterSwitch = false;
|
|
388
|
-
|
|
389
|
-
/**
|
|
390
|
-
* Run another session in this window through `/bot-lobby switch`, which pi
|
|
391
|
-
* runs with a command context (the only one that may replace the session).
|
|
392
|
-
* A background session is stopped first, so one process writes its file; a
|
|
393
|
-
* task no session owns is claimed instead.
|
|
394
|
-
*/
|
|
395
|
-
async function switchTo(state: Runtime, target: SwitchTarget): Promise<string> {
|
|
396
|
-
if (!state.ctx.isIdle()) return "this window's oracle is working — esc stops it, then switch";
|
|
397
|
-
if (target.claimTaskId) {
|
|
398
|
-
reopenAfterSwitch = false;
|
|
399
|
-
state.pi.sendUserMessage(`/bot-lobby claim ${target.claimTaskId}`, { expandPromptTemplates: true });
|
|
400
|
-
return `taking ${target.claimTaskId} over in this window`;
|
|
401
|
-
}
|
|
402
|
-
let file = target.background?.sessionFile;
|
|
403
|
-
if (target.background) {
|
|
404
|
-
target.background.stop();
|
|
405
|
-
await target.background.whenExited();
|
|
406
|
-
}
|
|
407
|
-
file ??= target.sessionId ? await chats.locate(target.sessionId) : undefined;
|
|
408
|
-
if (!file) return `could not find ${target.name}'s session file — /resume lists every saved session`;
|
|
409
|
-
reopenAfterSwitch = true;
|
|
410
|
-
state.pi.sendUserMessage(`/bot-lobby switch ${file}`, { expandPromptTemplates: true });
|
|
411
|
-
return `switching this window to ${target.name}…`;
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
/** Why a task cannot be archived or deleted now: a running session drives it. */
|
|
415
|
-
function drivenElsewhere(state: Runtime, task: Task): string | undefined {
|
|
416
|
-
if (TERMINAL_STATES.includes(task.state) || !task.ownerSessionId) return undefined;
|
|
417
|
-
if (task.ownerSessionId === state.ctx.sessionManager.getSessionId()) return `this window drives ${task.id} — cancel it first (/bot-lobby cancel ${task.id})`;
|
|
418
|
-
const background = backgroundSessions().find((session) => session.alive && session.sessionId === task.ownerSessionId);
|
|
419
|
-
if (background) return `${background.name} is driving ${task.id} in the background — stop it first (x x)`;
|
|
420
|
-
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`;
|
|
421
|
-
return undefined;
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
function archiveTask(state: Runtime, taskId: string): string {
|
|
425
|
-
const task = loadTask(state.root, state.configDir, taskId);
|
|
426
|
-
if (!task) return `no task ${taskId}`;
|
|
427
|
-
const busy = drivenElsewhere(state, task);
|
|
428
|
-
if (busy) return busy;
|
|
429
|
-
try {
|
|
430
|
-
archiveTaskOnDisk(state.root, state.configDir, taskId);
|
|
431
|
-
} catch (error) {
|
|
432
|
-
return (error as Error).message;
|
|
433
|
-
}
|
|
434
|
-
lobbyFeed.log("LOBBY", `archived ${taskId}`, "info");
|
|
435
|
-
return `archived ${taskId}${TERMINAL_STATES.includes(task.state) ? "" : " (abandoned first)"} — v shows archived tasks, a restores one`;
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
function restoreTask(state: Runtime, taskId: string): string {
|
|
439
|
-
try {
|
|
440
|
-
restoreTaskOnDisk(state.root, state.configDir, taskId);
|
|
441
|
-
} catch (error) {
|
|
442
|
-
return (error as Error).message;
|
|
443
|
-
}
|
|
444
|
-
lobbyFeed.log("LOBBY", `restored ${taskId}`, "info");
|
|
445
|
-
return `restored ${taskId} to the task list`;
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
function deleteTask(state: Runtime, taskId: string, where: "list" | "archive"): string {
|
|
449
|
-
if (where === "list") {
|
|
450
|
-
const task = loadTask(state.root, state.configDir, taskId);
|
|
451
|
-
if (!task) return `no task ${taskId}`;
|
|
452
|
-
const busy = drivenElsewhere(state, task);
|
|
453
|
-
if (busy) return busy;
|
|
454
|
-
}
|
|
455
|
-
try {
|
|
456
|
-
deleteTaskOnDisk(state.root, state.configDir, taskId, where);
|
|
457
|
-
} catch (error) {
|
|
458
|
-
return (error as Error).message;
|
|
459
|
-
}
|
|
460
|
-
lobbyFeed.log("LOBBY", `deleted ${taskId}`, "warning");
|
|
461
|
-
return `deleted ${taskId} for good`;
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
/** What a pull request review runs on: QA's model, thinking and time limit. */
|
|
465
|
-
function reviewProfile(state: Runtime) {
|
|
466
|
-
return resolveReviewProfile(loadConfig(), {
|
|
467
|
-
lookup: modelLookup(state.ctx),
|
|
468
|
-
sessionModel: sessionModel(state.ctx),
|
|
469
|
-
warn: (message) => state.ctx.ui.notify(message, "warning"),
|
|
470
|
-
});
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
function seatProfile(state: Runtime, member: PanelMember) {
|
|
474
|
-
return resolvePanelProfile(loadConfig(), member, {
|
|
475
|
-
lookup: modelLookup(state.ctx),
|
|
476
|
-
sessionModel: sessionModel(state.ctx),
|
|
477
|
-
warn: (message) => state.ctx.ui.notify(message, "warning"),
|
|
478
|
-
});
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
function newPlanner(state: Runtime, seed?: PlannerSeed, seats?: readonly PanelMember[]): PlanningSession {
|
|
482
|
-
state.planner?.cancel();
|
|
483
|
-
const config = loadConfig();
|
|
484
|
-
const workflow = config.workflow;
|
|
485
|
-
state.planner = new PlanningSession({
|
|
486
|
-
cwd: state.ctx.cwd,
|
|
487
|
-
root: state.root,
|
|
488
|
-
configDir: state.configDir,
|
|
489
|
-
profile: () => lobbyProfile(state, "planner"),
|
|
490
|
-
memberProfile: (member) => seatProfile(state, member),
|
|
491
|
-
panel: seats ?? config.lobby.planningPanel,
|
|
492
|
-
maxRounds: () => loadConfig().lobby.maxPlanningRounds,
|
|
493
|
-
classifier: classifier(),
|
|
494
|
-
hints: hintsFor({ cwd: state.ctx.cwd, root: state.root, configDir: state.configDir }),
|
|
495
|
-
effort: effortFor((model, thinking) => checkThinking(modelLookup(state.ctx)(model), thinking).level),
|
|
496
|
-
stallTimeoutMs: workflow.stallTimeoutMs,
|
|
497
|
-
toolStallTimeoutMs: workflow.toolStallTimeoutMs,
|
|
498
|
-
feed: lobbyFeed,
|
|
499
|
-
onChange: rerender,
|
|
500
|
-
onRound: (session) => {
|
|
501
|
-
// Put the questions to the user at once when they are looking at the Plan tab.
|
|
502
|
-
if (loadConfig().lobby.autoAsk && state.visible && state.view?.tab === "plan" && session.awaitingAnswers) void answerPanel(state);
|
|
503
|
-
},
|
|
504
|
-
}, seed);
|
|
505
|
-
return state.planner;
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
/** The questionnaire (pi's own dialogs where it cannot be drawn); tests swap it. */
|
|
509
|
-
function panelAsker(state: Runtime): Asker {
|
|
510
|
-
return state.asker ?? askUser;
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
/**
|
|
514
|
-
* Save the plan as a pending task. A plan with many steps is first split by
|
|
515
|
-
* the oracle into up to five tasks, which the user takes, changes or declines
|
|
516
|
-
* in a questionnaire; the split's questions open over the lobby like the
|
|
517
|
-
* panel's. Returns a notice for the lobby.
|
|
518
|
-
*/
|
|
124
|
+
/** Save the plan as a pending task; the service holds the implementation. */
|
|
519
125
|
export async function savePlan(state: Runtime | undefined = runtime): Promise<string> {
|
|
520
|
-
|
|
521
|
-
if (!state || !session) return "no planning session";
|
|
522
|
-
if (state.asking) return "a questionnaire is already open";
|
|
523
|
-
state.asking = true;
|
|
524
|
-
try {
|
|
525
|
-
const asker = panelAsker(state);
|
|
526
|
-
return await session.saveWithSplit((questions, signal) => asker(questions, state.ctx, signal), { splitAbove: loadConfig().lobby.splitPlanAbove });
|
|
527
|
-
} finally {
|
|
528
|
-
state.asking = false;
|
|
529
|
-
rerender();
|
|
530
|
-
}
|
|
126
|
+
return serviceSavePlan(state);
|
|
531
127
|
}
|
|
532
128
|
|
|
533
|
-
/**
|
|
534
|
-
* The oracle puts the round's questions to the user, one at a time; answered
|
|
535
|
-
* questionnaires are kept if the user stops, so the next call resumes there.
|
|
536
|
-
* Once every questionnaire is done, the answers (and any line comments) start
|
|
537
|
-
* the next round. Returns a notice for the lobby.
|
|
538
|
-
*/
|
|
129
|
+
/** Put the panel's open questions to the user; the service holds the implementation. */
|
|
539
130
|
export async function answerPanel(state: Runtime | undefined = runtime): Promise<string> {
|
|
540
|
-
|
|
541
|
-
if (!state || !session) return "no planning session";
|
|
542
|
-
if (session.busy) return "the panel is still thinking";
|
|
543
|
-
if (session.questions.length === 0) return "no open questions";
|
|
544
|
-
if (state.asking) return "the questions are already open";
|
|
545
|
-
state.asking = true;
|
|
546
|
-
try {
|
|
547
|
-
const asker = panelAsker(state);
|
|
548
|
-
const chunks = questionnaires(session.questions);
|
|
549
|
-
for (let index = session.answered.length; index < chunks.length; index++) {
|
|
550
|
-
const result = await asker(chunks[index]!, state.ctx);
|
|
551
|
-
if (result.cancelled) {
|
|
552
|
-
rerender();
|
|
553
|
-
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";
|
|
554
|
-
}
|
|
555
|
-
session.answered = [...session.answered, result];
|
|
556
|
-
}
|
|
557
|
-
const message = answerMessage(session.questions, session.answered);
|
|
558
|
-
if (!message) {
|
|
559
|
-
session.answered = [];
|
|
560
|
-
return "nothing was answered — the questions stay open";
|
|
561
|
-
}
|
|
562
|
-
void session.send(message, settledQuestions(session.questions, session.answered));
|
|
563
|
-
return "answers sent — the panel is on the next round";
|
|
564
|
-
} catch (error) {
|
|
565
|
-
return `could not put the questions: ${(error as Error).message}`;
|
|
566
|
-
} finally {
|
|
567
|
-
state.asking = false;
|
|
568
|
-
rerender();
|
|
569
|
-
}
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
/** Read the repository name and branch again, off the render path; the title repaints when they changed. */
|
|
573
|
-
function refreshWorkspace(state: Runtime): void {
|
|
574
|
-
if (state.readingWorkspace) return;
|
|
575
|
-
state.readingWorkspace = true;
|
|
576
|
-
describeWorkspace(state.ctx.cwd)
|
|
577
|
-
.then((info) => {
|
|
578
|
-
if (runtime !== state || (info.name === state.workspace.name && info.branch === state.workspace.branch)) return;
|
|
579
|
-
state.workspace = info;
|
|
580
|
-
rerender();
|
|
581
|
-
})
|
|
582
|
-
.catch(() => {})
|
|
583
|
-
.finally(() => {
|
|
584
|
-
state.readingWorkspace = false;
|
|
585
|
-
});
|
|
131
|
+
return serviceAnswerPanel(state);
|
|
586
132
|
}
|
|
587
133
|
|
|
588
134
|
function host(state: Runtime, tui: TUI): LobbyHost {
|
|
589
135
|
return {
|
|
136
|
+
...createLobbyService(state),
|
|
590
137
|
rows: () => tui.terminal.rows,
|
|
591
138
|
theme: () => lobbyTheme(state.ctx.ui.theme),
|
|
592
|
-
sessionId: () => state.ctx.sessionManager.getSessionId(),
|
|
593
|
-
zen: () => {
|
|
594
|
-
const snapshot = taskSnapshot();
|
|
595
|
-
return { ...(snapshot.task ? { task: snapshot.task } : {}), runs: snapshot.runs };
|
|
596
|
-
},
|
|
597
|
-
feed: lobbyFeed,
|
|
598
|
-
masterBusy: () => !state.ctx.isIdle(),
|
|
599
|
-
tasks: () => peekTasks(state.root, state.configDir),
|
|
600
|
-
plans: () => listPlannedTasks(state.root, state.configDir),
|
|
601
|
-
comments: (taskId) => readPlanComments(state.root, state.configDir, taskId),
|
|
602
|
-
metrics: () => readMetrics(state.root, state.configDir),
|
|
603
|
-
classifierMetrics: () => readClassifierMetrics(state.root, state.configDir),
|
|
604
|
-
toOracle: (text) => toOracle(state, text),
|
|
605
|
-
comment: (taskId, text) => addComment(state, taskId, text),
|
|
606
|
-
startPlanned: (plan) => startPlanned(state, plan),
|
|
607
|
-
discardPlan: (id) => discardPlannedTask(state.root, state.configDir, id),
|
|
608
|
-
abortMaster: () => state.ctx.abort(),
|
|
609
139
|
hide: () => hideLobby(),
|
|
610
|
-
quickfix: state.quickfix,
|
|
611
|
-
planner: () => state.planner,
|
|
612
|
-
newPlanner: (seed, seats) => newPlanner(state, seed, seats),
|
|
613
|
-
answerPanel: () => answerPanel(state),
|
|
614
|
-
savePlan: () => savePlan(state),
|
|
615
|
-
defaultPanel: () => loadConfig().lobby.planningPanel,
|
|
616
|
-
planningRounds: () => loadConfig().lobby.maxPlanningRounds,
|
|
617
|
-
issuesEnabled: () => loadConfig().lobby.issues,
|
|
618
140
|
panels: () => loadConfig().lobby.panels,
|
|
619
141
|
savePanels: (panels) => savePanels(panels),
|
|
620
142
|
keys: () => loadConfig().lobby.keys,
|
|
621
143
|
openSettings: (entry) => lobbySettings(state, entry),
|
|
622
|
-
seatLabel: (member) => {
|
|
623
|
-
const profile = seatProfile(state, member);
|
|
624
|
-
return `${profile.model ?? "session model"} · ${profile.thinking}`;
|
|
625
|
-
},
|
|
626
|
-
issues: state.issues,
|
|
627
|
-
pulls: state.pulls,
|
|
628
|
-
reviews: state.reviews,
|
|
629
|
-
knowledge: state.knowledge,
|
|
630
|
-
excalidraw: state.excalidraw,
|
|
631
|
-
checkExcalidraw: (session) => checkSession(session.link, session.name),
|
|
632
144
|
editText: (title, text) => editText(state, title, text),
|
|
633
|
-
profileLabel: (kind) => {
|
|
634
|
-
const profile = lobbyProfile(state, kind);
|
|
635
|
-
return `${profile.model ?? "session model"} · ${profile.thinking}`;
|
|
636
|
-
},
|
|
637
|
-
workspace: () => state.workspace,
|
|
638
|
-
refreshWorkspace: () => refreshWorkspace(state),
|
|
639
|
-
sessionName: () => state.pi.getSessionName(),
|
|
640
|
-
sessions: () => backgroundSessions(),
|
|
641
|
-
startSession: (start) => startSession(state, start),
|
|
642
|
-
answerDialog: (session) => answerDialog(state, session),
|
|
643
|
-
isAuto: (taskId) => autoFor(state, taskId),
|
|
644
|
-
setAuto: (taskId, on) => switchAuto(state, taskId, on),
|
|
645
|
-
sendToTask: (taskId, text) => sendToTask(state, taskId, text),
|
|
646
|
-
sendToSession: (sessionId, text) => sendToSession(state, sessionId, text),
|
|
647
|
-
liveSessions: () => liveSessions(state),
|
|
648
|
-
switchTo: (target) => switchTo(state, target),
|
|
649
|
-
archivedTasks: () => listArchivedTasks(state.root, state.configDir),
|
|
650
|
-
archiveTask: (taskId) => archiveTask(state, taskId),
|
|
651
|
-
restoreTask: (taskId) => restoreTask(state, taskId),
|
|
652
|
-
deleteTask: (taskId, where) => deleteTask(state, taskId, where),
|
|
653
|
-
sessionChat: (sessionId) => chats.chat(sessionId),
|
|
654
|
-
hasOlderChat: (sessionId) => chats.hasOlder(sessionId),
|
|
655
|
-
chatHistory: (sessionId) => chatHistory(state, sessionId),
|
|
656
145
|
requestRender: () => tui.requestRender(),
|
|
657
146
|
};
|
|
658
147
|
}
|
|
@@ -774,6 +263,7 @@ export function autoOpenLobby(): void {
|
|
|
774
263
|
|
|
775
264
|
/** Step aside while pi shows a dialog (an approval, a question), and come back after. */
|
|
776
265
|
function promptStarted(): void {
|
|
266
|
+
lobbyTopics.bump("status");
|
|
777
267
|
const state = runtime;
|
|
778
268
|
if (!state?.visible || !state.handle || state.inSettings) return;
|
|
779
269
|
state.asideForPrompt = true;
|
|
@@ -782,6 +272,7 @@ function promptStarted(): void {
|
|
|
782
272
|
}
|
|
783
273
|
|
|
784
274
|
function promptEnded(): void {
|
|
275
|
+
lobbyTopics.bump("status");
|
|
785
276
|
const state = runtime;
|
|
786
277
|
if (!state?.asideForPrompt || !state.handle || state.inSettings) return;
|
|
787
278
|
state.asideForPrompt = false;
|
|
@@ -794,9 +285,13 @@ function shutdown(): void {
|
|
|
794
285
|
const state = runtime;
|
|
795
286
|
if (!state) return;
|
|
796
287
|
runtime = undefined;
|
|
288
|
+
lobbyService = undefined;
|
|
289
|
+
setServiceState(undefined);
|
|
797
290
|
setQuickFixHandoff(undefined);
|
|
798
291
|
setMouse(state, false);
|
|
799
292
|
state.unsubscribeFeed?.();
|
|
293
|
+
state.unsubscribeTopics?.();
|
|
294
|
+
state.unregisterTerminalSurface?.();
|
|
800
295
|
state.quickfix.cancelAll();
|
|
801
296
|
state.reviews.cancelAll();
|
|
802
297
|
state.planner?.cancel();
|
|
@@ -820,14 +315,23 @@ function handToQuickFix(state: Runtime, request: string, builder: Domain | undef
|
|
|
820
315
|
note: `The oracle sent your request here: ${reason.replace(/[.\s]+$/, "")}.${builder ? ` A quick feature: it runs on ${builder === "designer" ? "DESIGN" : "DEV"}'s model, thinking and time limit.` : ""}`,
|
|
821
316
|
});
|
|
822
317
|
showLobby("quickfix");
|
|
318
|
+
lobbyTopics.bump("quickfix");
|
|
823
319
|
state.view?.showQuickFix(job.id);
|
|
824
320
|
return job.id;
|
|
825
321
|
}
|
|
826
322
|
|
|
827
|
-
/**
|
|
828
|
-
|
|
323
|
+
/** Whether the web server wants the lobby service without the terminal (a later step flips this on). */
|
|
324
|
+
function webServerOn(): boolean {
|
|
325
|
+
return false;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Start the lobby's backend: state, service and background wiring. Runs in
|
|
330
|
+
* the terminal and, once the web server exists, whenever it is on.
|
|
331
|
+
*/
|
|
332
|
+
export function startLobbyService(pi: ExtensionAPI, ctx: ExtensionContext, configDir: string): Runtime | undefined {
|
|
829
333
|
shutdown();
|
|
830
|
-
if (isSubagentProcess() || ctx.mode !== "tui") return;
|
|
334
|
+
if (isSubagentProcess() || (ctx.mode !== "tui" && !webServerOn())) return undefined;
|
|
831
335
|
const root = detectProjectRoot(ctx.cwd, configDir);
|
|
832
336
|
const workflow = loadConfig().workflow;
|
|
833
337
|
const state: Runtime = {
|
|
@@ -843,8 +347,14 @@ export function initLobby(pi: ExtensionAPI, ctx: ExtensionContext, configDir: st
|
|
|
843
347
|
workspace: { name: basename(ctx.cwd) || ctx.cwd },
|
|
844
348
|
readingWorkspace: false,
|
|
845
349
|
quickfix: undefined as unknown as QuickFixQueue,
|
|
846
|
-
issues: new IssuesState(execCommand, ctx.cwd,
|
|
847
|
-
|
|
350
|
+
issues: new IssuesState(execCommand, ctx.cwd, () => {
|
|
351
|
+
lobbyTopics.bump("issues");
|
|
352
|
+
rerender();
|
|
353
|
+
}),
|
|
354
|
+
pulls: new PullsState(execCommand, ctx.cwd, () => {
|
|
355
|
+
lobbyTopics.bump("git");
|
|
356
|
+
rerender();
|
|
357
|
+
}),
|
|
848
358
|
reviews: undefined as unknown as PullReviews,
|
|
849
359
|
knowledge: new KnowledgeBook({
|
|
850
360
|
root,
|
|
@@ -864,8 +374,12 @@ export function initLobby(pi: ExtensionAPI, ctx: ExtensionContext, configDir: st
|
|
|
864
374
|
stallTimeoutMs: workflow.stallTimeoutMs,
|
|
865
375
|
toolStallTimeoutMs: workflow.toolStallTimeoutMs,
|
|
866
376
|
feed: lobbyFeed,
|
|
867
|
-
onChange:
|
|
377
|
+
onChange: () => {
|
|
378
|
+
lobbyTopics.bump("git");
|
|
379
|
+
rerender();
|
|
380
|
+
},
|
|
868
381
|
notify: (message, level) => {
|
|
382
|
+
lobbyTopics.bump("notices");
|
|
869
383
|
if (!state.visible) ctx.ui.notify(message, level);
|
|
870
384
|
},
|
|
871
385
|
classifier: classifier(),
|
|
@@ -878,8 +392,12 @@ export function initLobby(pi: ExtensionAPI, ctx: ExtensionContext, configDir: st
|
|
|
878
392
|
stallTimeoutMs: workflow.stallTimeoutMs,
|
|
879
393
|
toolStallTimeoutMs: workflow.toolStallTimeoutMs,
|
|
880
394
|
feed: lobbyFeed,
|
|
881
|
-
onChange:
|
|
395
|
+
onChange: () => {
|
|
396
|
+
lobbyTopics.bump("quickfix");
|
|
397
|
+
rerender();
|
|
398
|
+
},
|
|
882
399
|
notify: (message, level) => {
|
|
400
|
+
lobbyTopics.bump("notices");
|
|
883
401
|
if (!state.visible) ctx.ui.notify(message, level);
|
|
884
402
|
},
|
|
885
403
|
hints: hintsFor({ cwd: ctx.cwd, root, configDir }),
|
|
@@ -887,23 +405,35 @@ export function initLobby(pi: ExtensionAPI, ctx: ExtensionContext, configDir: st
|
|
|
887
405
|
effort: effortFor((model, thinking) => checkThinking(modelLookup(ctx)(model), thinking).level),
|
|
888
406
|
});
|
|
889
407
|
runtime = state;
|
|
408
|
+
lobbyService = createLobbyService(state);
|
|
409
|
+
setServiceState(state);
|
|
410
|
+
// Topic routing (src/lobby/topics.ts): the terminal hears every topic and
|
|
411
|
+
// repaints, as before. Sources bump on their way to rerender — lobbyFeed
|
|
412
|
+
// (lobby), run updates (tasks), quick fix (quickfix), planner (planner),
|
|
413
|
+
// background sessions (sessions), metrics (metrics), pulls/reviews (git),
|
|
414
|
+
// issues (issues), the prompt hub (prompts), owner/notify events (notices),
|
|
415
|
+
// dialogs/busy/workspace (status), link checks (excalidraw). File polling
|
|
416
|
+
// for tasks/plans/knowledge starts once a non-terminal listener attaches.
|
|
417
|
+
state.unsubscribeTopics = lobbyTopics.onChange(rerender);
|
|
418
|
+
state.unregisterTerminalSurface = promptHub.registerSurface({ name: "terminal", show() {}, withdraw() {} });
|
|
890
419
|
setQuickFixHandoff((request, builder, reason) => (runtime === state ? handToQuickFix(state, request, builder, reason) : undefined));
|
|
891
420
|
lobbyFeed.clear();
|
|
892
421
|
// Only the newest messages are kept; the feed learns whether earlier ones exist, and loads them when scrolled to.
|
|
893
422
|
lobbyFeed.seedChat(chatFromEntries(ctx.sessionManager.getBranch(), Number.POSITIVE_INFINITY));
|
|
894
|
-
state.unsubscribeFeed = lobbyFeed.onChange(
|
|
895
|
-
|
|
423
|
+
state.unsubscribeFeed = lobbyFeed.onChange(() => {
|
|
424
|
+
lobbyTopics.bump("lobby");
|
|
425
|
+
rerender();
|
|
426
|
+
});
|
|
427
|
+
onRunUpdates((runs) => {
|
|
428
|
+
lobbyFeed.runs(runs);
|
|
429
|
+
lobbyTopics.bump("tasks");
|
|
430
|
+
});
|
|
896
431
|
onMinimizeChange((value) => {
|
|
897
432
|
if (value) hideLobby();
|
|
898
433
|
});
|
|
899
|
-
// Capture pi's TUI through a zero-line widget below the editor.
|
|
900
|
-
ctx.ui.setWidget(ANCHOR_KEY, (tui) => {
|
|
901
|
-
state.tui = tui;
|
|
902
|
-
// Zero lines while the lobby is up (or bot-lobby is minimized); one status line when it is hidden.
|
|
903
|
-
return { render: (width: number) => (miniShown(state) ? [miniLine(miniInput(state), width, lobbyTheme(state.ctx.ui.theme))] : []), invalidate: () => {} };
|
|
904
|
-
}, { placement: "belowEditor" });
|
|
905
434
|
// The owner's clock (pi/owner.ts) delivers comments and messages and drives auto mode; the lobby logs what it did.
|
|
906
435
|
onOwnerEvent((event) => {
|
|
436
|
+
lobbyTopics.bump("notices");
|
|
907
437
|
if (event.kind === "comments") lobbyFeed.log("LOBBY", `passed ${event.count} plan comment${event.count === 1 ? "" : "s"} on ${event.taskId} to the oracle`, "info");
|
|
908
438
|
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");
|
|
909
439
|
else if (event.kind === "auto") lobbyFeed.log("LOBBY", `auto mode ${event.on ? "on" : "off"} for ${event.taskId}`, event.on ? "success" : "info");
|
|
@@ -911,6 +441,24 @@ export function initLobby(pi: ExtensionAPI, ctx: ExtensionContext, configDir: st
|
|
|
911
441
|
else lobbyFeed.log("LOBBY", `auto mode: no progress on ${event.taskId} — it needs you`, "warning");
|
|
912
442
|
rerender();
|
|
913
443
|
});
|
|
444
|
+
return state;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** Mount the terminal overlay: the zero-line anchor widget that captures pi's TUI. Runs only in the terminal. */
|
|
448
|
+
function mountTerminalLobby(state: Runtime): void {
|
|
449
|
+
const ctx = state.ctx;
|
|
450
|
+
// Capture pi's TUI through a zero-line widget below the editor.
|
|
451
|
+
ctx.ui.setWidget(ANCHOR_KEY, (tui) => {
|
|
452
|
+
state.tui = tui;
|
|
453
|
+
// Zero lines while the lobby is up (or bot-lobby is minimized); one status line when it is hidden.
|
|
454
|
+
return { render: (width: number) => (miniShown(state) ? [miniLine(miniInput(state), width, lobbyTheme(state.ctx.ui.theme))] : []), invalidate: () => {} };
|
|
455
|
+
}, { placement: "belowEditor" });
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** Create the session's lobby runtime (interactive master sessions only). */
|
|
459
|
+
export function initLobby(pi: ExtensionAPI, ctx: ExtensionContext, configDir: string): void {
|
|
460
|
+
const state = startLobbyService(pi, ctx, configDir);
|
|
461
|
+
if (state && ctx.mode === "tui") mountTerminalLobby(state);
|
|
914
462
|
}
|
|
915
463
|
|
|
916
464
|
interface MasterTurn {
|
|
@@ -949,16 +497,15 @@ export function registerLobbyEvents(pi: ExtensionAPI, configDir: string): void {
|
|
|
949
497
|
pi.on("session_start", (_event, ctx) => {
|
|
950
498
|
initLobby(pi, ctx, configDir);
|
|
951
499
|
// A switch asked for from the lobby lands back in the lobby, on the session it switched to.
|
|
952
|
-
if (
|
|
953
|
-
|
|
954
|
-
showLobby("lobby");
|
|
955
|
-
} else autoOpenLobby();
|
|
500
|
+
if (takeReopenAfterSwitch()) showLobby("lobby");
|
|
501
|
+
else autoOpenLobby();
|
|
956
502
|
});
|
|
957
503
|
pi.on("session_shutdown", () => shutdown());
|
|
958
504
|
pi.on("ui_prompt_start", () => promptStarted());
|
|
959
505
|
pi.on("ui_prompt_end", () => promptEnded());
|
|
960
506
|
pi.on("agent_start", (_event, ctx) => {
|
|
961
507
|
track(ctx);
|
|
508
|
+
lobbyTopics.bump("status");
|
|
962
509
|
const taskId = currentZenTask()?.id;
|
|
963
510
|
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 };
|
|
964
511
|
});
|
|
@@ -1002,5 +549,7 @@ export function registerLobbyEvents(pi: ExtensionAPI, configDir: string): void {
|
|
|
1002
549
|
cost: finished.cost,
|
|
1003
550
|
taskId,
|
|
1004
551
|
}]);
|
|
552
|
+
lobbyTopics.bump("metrics");
|
|
553
|
+
lobbyTopics.bump("status");
|
|
1005
554
|
});
|
|
1006
555
|
}
|