@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
|
@@ -0,0 +1,655 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The lobby's backend, without the terminal: every host member the view
|
|
3
|
+
* needs that does not draw anything. `createLobbyService(state)` builds it
|
|
4
|
+
* from the same state the terminal lobby runs on, so a later web server can
|
|
5
|
+
* drive the lobby through this module without a TUI. Putting a question on
|
|
6
|
+
* screen stays with the terminal; the dialog data and its answer live here.
|
|
7
|
+
*/
|
|
8
|
+
import { SessionManager, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { TERMINAL_STATES, type Task } from "../schemas/task.ts";
|
|
10
|
+
import { loadTask, peekTasks } from "../state/persistence.ts";
|
|
11
|
+
import { loadConfig, saveConfig as writeConfig } from "../state/project.ts";
|
|
12
|
+
import { addPlanComment, readPlanComments } from "../state/comments.ts";
|
|
13
|
+
import { isAutoMode } from "../state/auto.ts";
|
|
14
|
+
import { sendToInbox, sendToSession as leaveForSession } from "../state/inbox.ts";
|
|
15
|
+
import { livePresence } from "../state/presence.ts";
|
|
16
|
+
import { archiveTask as archiveTaskOnDisk, deleteTask as deleteTaskOnDisk, listArchivedTasks, restoreTask as restoreTaskOnDisk } from "../state/archive.ts";
|
|
17
|
+
import { discardPlannedTask, listPlannedTasks, type PlannedTask } from "../state/backlog.ts";
|
|
18
|
+
import { readClassifierMetrics, readMetrics } from "../state/metrics.ts";
|
|
19
|
+
import { currentZenTask, isMinimized, taskSnapshot } from "../pi/ui.ts";
|
|
20
|
+
import { taskName } from "../text.ts";
|
|
21
|
+
import { describeWorkspace } from "../execution/workspace.ts";
|
|
22
|
+
import { stripStartFlags } from "../pi/start-flags.ts";
|
|
23
|
+
import { checkThinking, modelRef, resolveLobbyProfile, resolvePanelProfile, resolveReviewProfile, supportedThinking } from "../pi/model-support.ts";
|
|
24
|
+
import { modelLookup } from "../pi/tools.ts";
|
|
25
|
+
import { startPlannedTask } from "../pi/start-task.ts";
|
|
26
|
+
import { pendingRequest, startRequest } from "../pi/route.ts";
|
|
27
|
+
import { deliverComments, setAuto } from "../pi/owner.ts";
|
|
28
|
+
import { chatFromEntries, lobbyFeed, type ChatEntry } from "./feed.ts";
|
|
29
|
+
import { classifier, effortFor, hintsFor } from "../classifier/instance.ts";
|
|
30
|
+
import { launchPi, SessionRegistry, type BackgroundSession, type DialogAnswer, type SessionLauncher } from "./sessions.ts";
|
|
31
|
+
import { SessionChats } from "./session-files.ts";
|
|
32
|
+
import { answerMessage, askUser, questionnaires, settledQuestions, type Asker } from "./ask.ts";
|
|
33
|
+
import { PlanningSession, type PlannerSeed } from "./planner.ts";
|
|
34
|
+
import { lobbyTopics } from "./topics.ts";
|
|
35
|
+
import { promptHub } from "./prompt-hub.ts";
|
|
36
|
+
import { checkSession } from "../excalidraw/check.ts";
|
|
37
|
+
import type { LobbyAgentKind, PanelMember } from "../schemas/configuration.ts";
|
|
38
|
+
import type { LiveSession, LobbyHost, SwitchTarget } from "./view.ts";
|
|
39
|
+
import type { Runtime } from "./runtime.ts";
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The lobby without its terminal-only members (rows, theme, hide,
|
|
43
|
+
* requestRender, now, keys, panels, savePanels, editText, openSettings).
|
|
44
|
+
*/
|
|
45
|
+
export type LobbyService = Omit<LobbyHost, "rows" | "theme" | "hide" | "requestRender" | "now" | "keys" | "panels" | "savePanels" | "editText" | "openSettings">;
|
|
46
|
+
|
|
47
|
+
/** The state background callbacks report to; set while the lobby service runs. */
|
|
48
|
+
let serviceState: Runtime | undefined;
|
|
49
|
+
|
|
50
|
+
/** Remember which lobby state background callbacks report to. */
|
|
51
|
+
export function setServiceState(state: Runtime | undefined): void {
|
|
52
|
+
serviceState = state;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Repaints asked for by what happens in the background come at most this often. */
|
|
56
|
+
export const FRAME_MS = 40;
|
|
57
|
+
let lastFrame = 0;
|
|
58
|
+
let frameTimer: ReturnType<typeof setTimeout> | undefined;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Repaint the lobby for something that happened in the background. A reply
|
|
62
|
+
* streams dozens of events a second; they share frames, at most one per
|
|
63
|
+
* `FRAME_MS`, instead of each asking for its own. Keys repaint at once
|
|
64
|
+
* through pi.
|
|
65
|
+
*/
|
|
66
|
+
export function repaintLobby(): void {
|
|
67
|
+
const state = serviceState;
|
|
68
|
+
// Hidden, only the one-line status under the editor changes.
|
|
69
|
+
if (!state || (!state.visible && !miniShown(state)) || frameTimer) return;
|
|
70
|
+
const wait = lastFrame + FRAME_MS - Date.now();
|
|
71
|
+
const paint = () => {
|
|
72
|
+
frameTimer = undefined;
|
|
73
|
+
lastFrame = Date.now();
|
|
74
|
+
const current = serviceState;
|
|
75
|
+
if (current?.visible || (current && miniShown(current))) current.tui?.requestRender();
|
|
76
|
+
};
|
|
77
|
+
if (wait <= 0) return paint();
|
|
78
|
+
frameTimer = setTimeout(paint, wait);
|
|
79
|
+
frameTimer.unref?.();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The status line shows under the editor while the lobby is hidden (not while bot-lobby is minimized), unless settings turn it off. */
|
|
83
|
+
export function miniShown(state: Runtime): boolean {
|
|
84
|
+
return !state.visible && !isMinimized() && loadConfig().lobby.miniLine;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function sessionModel(ctx: ExtensionContext): string | undefined {
|
|
88
|
+
return ctx.model ? modelRef(ctx.model) : undefined;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function lobbyProfile(state: Runtime, kind: LobbyAgentKind) {
|
|
92
|
+
return resolveLobbyProfile(loadConfig(), kind, {
|
|
93
|
+
lookup: modelLookup(state.ctx),
|
|
94
|
+
sessionModel: sessionModel(state.ctx),
|
|
95
|
+
warn: (message) => state.ctx.ui.notify(message, "warning"),
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function addComment(state: Runtime, taskId: string, text: string): string {
|
|
100
|
+
const task = loadTask(state.root, state.configDir, taskId);
|
|
101
|
+
if (!task) return `no task ${taskId}`;
|
|
102
|
+
if (TERMINAL_STATES.includes(task.state)) return `${taskId} is ${task.state}; there is no plan left to change`;
|
|
103
|
+
const sessionId = state.ctx.sessionManager.getSessionId();
|
|
104
|
+
try {
|
|
105
|
+
addPlanComment(state.root, state.configDir, taskId, text, sessionId);
|
|
106
|
+
} catch (error) {
|
|
107
|
+
return (error as Error).message;
|
|
108
|
+
}
|
|
109
|
+
lobbyFeed.log("LOBBY", `comment on ${taskId}'s plan saved`, "info");
|
|
110
|
+
lobbyTopics.bump("plans");
|
|
111
|
+
if (task.ownerSessionId === sessionId) {
|
|
112
|
+
if (deliverComments() === 0) return `comment saved — it reaches the oracle once ${taskId} is resumed or restored`;
|
|
113
|
+
return `comment sent to the oracle — it will ${task.plan ? "amend the plan" : "revise the proposal"}`;
|
|
114
|
+
}
|
|
115
|
+
if (!task.ownerSessionId) return `comment saved — ${taskId} has no owning session; it is delivered once a session claims it`;
|
|
116
|
+
return `comment saved — the session driving ${taskId} passes it to its oracle`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function failed(state: Runtime, what: string, error: Error): void {
|
|
120
|
+
lobbyFeed.log("LOBBY", `${what} — ${error.message}`, "error");
|
|
121
|
+
lobbyTopics.bump("notices");
|
|
122
|
+
state.ctx.ui.notify(`bot-lobby: ${what} — ${error.message}`, "error");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function toOracle(state: Runtime, text: string): string | undefined {
|
|
126
|
+
const busy = !state.ctx.isIdle();
|
|
127
|
+
if (text.startsWith("/")) {
|
|
128
|
+
state.pi.sendUserMessage(text, { expandPromptTemplates: true, ...(busy ? { deliverAs: "followUp" as const } : {}) });
|
|
129
|
+
return `sent ${text.split(/\s+/)[0]} to pi (built-in commands need the lobby hidden: alt+l)`;
|
|
130
|
+
}
|
|
131
|
+
// While the oracle decides where a request goes, what the user types joins that conversation.
|
|
132
|
+
if (!currentZenTask() && !pendingRequest()) {
|
|
133
|
+
startRequest(state.pi, state.ctx, state.configDir, text).catch((error: Error) => failed(state, "could not start the task", error));
|
|
134
|
+
return "reading your request — a quick fix or a task, the oracle takes it from here";
|
|
135
|
+
}
|
|
136
|
+
state.pi.sendUserMessage(text, busy ? { deliverAs: "steer" } : undefined);
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function startPlanned(state: Runtime, plan: PlannedTask): string {
|
|
141
|
+
const current = currentZenTask();
|
|
142
|
+
if (current) return `this session already drives ${current.id}; finish or cancel it first, or start ${plan.id} from another session`;
|
|
143
|
+
startPlannedTask(state.pi, state.ctx, state.configDir, plan.id)
|
|
144
|
+
.then((task) => {
|
|
145
|
+
if (typeof task === "string") return failed(state, `could not start ${plan.id}`, new Error(task));
|
|
146
|
+
state.view?.setTab("lobby");
|
|
147
|
+
lobbyTopics.bump("tasks");
|
|
148
|
+
repaintLobby();
|
|
149
|
+
})
|
|
150
|
+
.catch((error: Error) => failed(state, `could not start ${plan.id}`, error));
|
|
151
|
+
return `starting ${plan.id} here — its agreed plan needs no approval…`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/* ------------------------------------------------- background sessions */
|
|
155
|
+
|
|
156
|
+
let launcher: SessionLauncher = launchPi;
|
|
157
|
+
let registry: SessionRegistry | undefined;
|
|
158
|
+
let exitHooked = false;
|
|
159
|
+
/** How many questions of each session were already announced, and which exits, so each is said once. */
|
|
160
|
+
const announced = new Map<string, number>();
|
|
161
|
+
const exitsSaid = new Set<string>();
|
|
162
|
+
/** Other sessions' conversations, read from their files. */
|
|
163
|
+
const chats = new SessionChats(async () => {
|
|
164
|
+
const state = serviceState;
|
|
165
|
+
if (!state) return [];
|
|
166
|
+
const dirs = new Map<string, string | undefined>([[state.ctx.cwd, state.ctx.sessionManager.getSessionDir()]]);
|
|
167
|
+
if (!dirs.has(state.root)) dirs.set(state.root, undefined);
|
|
168
|
+
const lists = await Promise.all([...dirs].map(([cwd, dir]) => SessionManager.list(cwd, dir).catch(() => [])));
|
|
169
|
+
return lists.flat();
|
|
170
|
+
}, () => {
|
|
171
|
+
lobbyTopics.bump("sessions");
|
|
172
|
+
repaintLobby();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
/** Launch background sessions with something else (tests); resets the registry. */
|
|
176
|
+
export function setSessionLauncher(next: SessionLauncher | undefined): void {
|
|
177
|
+
registry?.stopAll();
|
|
178
|
+
registry = undefined;
|
|
179
|
+
launcher = next ?? launchPi;
|
|
180
|
+
announced.clear();
|
|
181
|
+
exitsSaid.clear();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* The background sessions this window started. The registry belongs to the
|
|
186
|
+
* process, not to one pi session, so switching sessions here keeps them
|
|
187
|
+
* running; they stop when pi exits (each is a saved session `/resume` finds).
|
|
188
|
+
*/
|
|
189
|
+
function sessionRegistry(): SessionRegistry {
|
|
190
|
+
if (!registry) registry = new SessionRegistry(launcher, sessionsChanged);
|
|
191
|
+
if (!exitHooked) {
|
|
192
|
+
exitHooked = true;
|
|
193
|
+
process.once("exit", () => registry?.stopAll());
|
|
194
|
+
}
|
|
195
|
+
return registry;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function backgroundSessions(): readonly BackgroundSession[] {
|
|
199
|
+
return registry?.sessions ?? [];
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** A background session changed: redraw, and say so when one asks something or fails while the lobby is hidden. */
|
|
203
|
+
function sessionsChanged(): void {
|
|
204
|
+
lobbyTopics.bump("sessions");
|
|
205
|
+
const state = serviceState;
|
|
206
|
+
for (const session of registry?.sessions ?? []) {
|
|
207
|
+
if (session.sessionId && session.sessionFile) chats.remember(session.sessionId, session.sessionFile);
|
|
208
|
+
const seen = announced.get(session.key) ?? 0;
|
|
209
|
+
announced.set(session.key, session.dialogs.length);
|
|
210
|
+
if (!state || state.visible) continue;
|
|
211
|
+
if (session.dialogs.length > seen) {
|
|
212
|
+
lobbyTopics.bump("notices");
|
|
213
|
+
state.ctx.ui.notify(`bot-lobby: ${session.name} is waiting for you — alt+l opens the lobby`, "info");
|
|
214
|
+
}
|
|
215
|
+
if (!session.alive && !exitsSaid.has(session.key)) {
|
|
216
|
+
exitsSaid.add(session.key);
|
|
217
|
+
if (session.exitCode) {
|
|
218
|
+
lobbyTopics.bump("notices");
|
|
219
|
+
state.ctx.ui.notify(`bot-lobby: ${session.name} exited (${session.exitCode})${session.lastError() ? `: ${session.lastError()}` : ""}`, "warning");
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
repaintLobby();
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Start a task in its own new pi session, named after the task; the session, or why not. */
|
|
227
|
+
function startSession(state: Runtime, start: { request?: string; plan?: PlannedTask; auto?: boolean }): BackgroundSession | string {
|
|
228
|
+
const request = start.request?.trim();
|
|
229
|
+
const plan = start.plan;
|
|
230
|
+
if (!plan && !request) return "describe the task first";
|
|
231
|
+
if (!plan && request!.startsWith("/")) return "a new session starts from a task description, not a command";
|
|
232
|
+
if (plan) {
|
|
233
|
+
if (plan.status !== "pending") return `${plan.id} was already started${plan.startedTaskId ? ` as ${plan.startedTaskId}` : ""}`;
|
|
234
|
+
const starting = backgroundSessions().find((session) => session.alive && session.planId === plan.id);
|
|
235
|
+
if (starting) return `${plan.id} is already starting in ${starting.name}`;
|
|
236
|
+
}
|
|
237
|
+
// A request may lead with flags (`--worktree add login`); the session is named after what follows them.
|
|
238
|
+
const name = taskName(plan?.title ?? (stripStartFlags(request!) || request!));
|
|
239
|
+
try {
|
|
240
|
+
const session = sessionRegistry().start(state.ctx.cwd, { name, ...(plan ? { planId: plan.id } : { request: request! }), ...(start.auto ? { auto: true } : {}) }, sessionModel(state.ctx));
|
|
241
|
+
lobbyFeed.log("LOBBY", `started "${name}" in a new session`, "success");
|
|
242
|
+
return session;
|
|
243
|
+
} catch (error) {
|
|
244
|
+
return `could not start a new session — ${(error as Error).message}`;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** The oldest question a background session waits on, if any (data only; the terminal draws it). */
|
|
249
|
+
export function peekSessionDialog(session: BackgroundSession) {
|
|
250
|
+
return session.dialogs[0];
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Answer a background session's dialog (data only; the terminal draws it). */
|
|
254
|
+
export function answerSessionDialog(session: BackgroundSession, id: string, answer: DialogAnswer): void {
|
|
255
|
+
session.answer(id, answer);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Put the oldest question a background session waits on to the user, with pi's own dialogs; escape cancels it as it would there. */
|
|
259
|
+
async function answerDialog(state: Runtime, session: BackgroundSession): Promise<void> {
|
|
260
|
+
const dialog = peekSessionDialog(session);
|
|
261
|
+
if (!dialog) return;
|
|
262
|
+
const title = `${session.name} — ${dialog.title}`;
|
|
263
|
+
const ui = state.ctx.ui;
|
|
264
|
+
try {
|
|
265
|
+
await promptHub.run("sessionDialog", session.name, { dialog }, async () => {
|
|
266
|
+
if (dialog.method === "select") {
|
|
267
|
+
const value = await ui.select(title, dialog.options ?? []);
|
|
268
|
+
answerSessionDialog(session, dialog.id, value === undefined ? { cancelled: true } : { value });
|
|
269
|
+
} else if (dialog.method === "confirm") {
|
|
270
|
+
answerSessionDialog(session, dialog.id, { confirmed: await ui.confirm(title, dialog.message ?? "") });
|
|
271
|
+
} else {
|
|
272
|
+
const value = dialog.method === "input" ? await ui.input(title, dialog.placeholder) : await ui.editor(title, dialog.prefill);
|
|
273
|
+
answerSessionDialog(session, dialog.id, value === undefined ? { cancelled: true } : { value });
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
} catch (error) {
|
|
277
|
+
failed(state, `could not answer ${session.name}`, error as Error);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Leave a message for a task another terminal's session drives; its owner passes it to the oracle. */
|
|
282
|
+
function sendToTask(state: Runtime, taskId: string, text: string): string {
|
|
283
|
+
const task = loadTask(state.root, state.configDir, taskId);
|
|
284
|
+
if (!task) return `no task ${taskId}`;
|
|
285
|
+
if (TERMINAL_STATES.includes(task.state)) return `${taskId} is ${task.state}`;
|
|
286
|
+
try {
|
|
287
|
+
sendToInbox(state.root, state.configDir, taskId, text, state.ctx.sessionManager.getSessionId());
|
|
288
|
+
} catch (error) {
|
|
289
|
+
return (error as Error).message;
|
|
290
|
+
}
|
|
291
|
+
lobbyFeed.log("LOBBY", `message for ${taskId}'s oracle saved`, "info");
|
|
292
|
+
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`;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Auto mode as the lobby last read it, per task: every frame asks, the file is read at most once a second. */
|
|
296
|
+
const autoSeen = new Map<string, { at: number; on: boolean }>();
|
|
297
|
+
const AUTO_READ_MS = 1000;
|
|
298
|
+
|
|
299
|
+
function autoFor(state: Runtime, taskId: string): boolean {
|
|
300
|
+
const now = Date.now();
|
|
301
|
+
const seen = autoSeen.get(taskId);
|
|
302
|
+
if (seen && now - seen.at < AUTO_READ_MS) return seen.on;
|
|
303
|
+
const on = isAutoMode(state.root, state.configDir, taskId);
|
|
304
|
+
autoSeen.set(taskId, { at: now, on });
|
|
305
|
+
return on;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function switchAuto(state: Runtime, taskId: string, on: boolean): void {
|
|
309
|
+
setAuto(state.root, state.configDir, taskId, on, state.ctx.sessionManager.getSessionId());
|
|
310
|
+
lobbyTopics.bump("tasks");
|
|
311
|
+
autoSeen.set(taskId, { at: Date.now(), on });
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Heartbeats of the other sessions running in this project, reread at most once a second (every frame asks). */
|
|
315
|
+
let liveSeen: { at: number; root: string; sessions: LiveSession[] } | undefined;
|
|
316
|
+
const LIVE_READ_MS = 1000;
|
|
317
|
+
|
|
318
|
+
function liveSessions(state: Runtime): LiveSession[] {
|
|
319
|
+
const now = Date.now();
|
|
320
|
+
if (liveSeen && liveSeen.root === state.root && now - liveSeen.at < LIVE_READ_MS) return liveSeen.sessions;
|
|
321
|
+
const me = state.ctx.sessionManager.getSessionId();
|
|
322
|
+
let sessions: LiveSession[] = [];
|
|
323
|
+
try {
|
|
324
|
+
sessions = livePresence(state.root, state.configDir, now).filter((presence) => presence.sessionId !== me);
|
|
325
|
+
} catch {
|
|
326
|
+
// An unreadable folder only means no other sessions are listed.
|
|
327
|
+
}
|
|
328
|
+
liveSeen = { at: now, root: state.root, sessions };
|
|
329
|
+
return sessions;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* A session's whole conversation, oldest first: this window's from pi's
|
|
334
|
+
* branch (already in memory), another's from its session file. Loaded only
|
|
335
|
+
* while the lobby is scrolled back through it.
|
|
336
|
+
*/
|
|
337
|
+
function chatHistory(state: Runtime, sessionId?: string): ChatEntry[] {
|
|
338
|
+
if (sessionId) return chats.history(sessionId);
|
|
339
|
+
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 }));
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Leave a message for a session running in another terminal. */
|
|
343
|
+
function sendToSession(state: Runtime, sessionId: string, text: string): string {
|
|
344
|
+
try {
|
|
345
|
+
leaveForSession(state.root, state.configDir, sessionId, text, state.ctx.sessionManager.getSessionId());
|
|
346
|
+
} catch (error) {
|
|
347
|
+
return (error as Error).message;
|
|
348
|
+
}
|
|
349
|
+
lobbyFeed.log("LOBBY", "message for another session's oracle saved", "info");
|
|
350
|
+
return "sent — that session passes it to its oracle within a few seconds";
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** The lobby reopens on the session this window switches to (set just before asking pi to switch). */
|
|
354
|
+
let reopenAfterSwitch = false;
|
|
355
|
+
|
|
356
|
+
/** Whether a switch asked for from the lobby is still owed its reopen; clears the flag. */
|
|
357
|
+
export function takeReopenAfterSwitch(): boolean {
|
|
358
|
+
const value = reopenAfterSwitch;
|
|
359
|
+
reopenAfterSwitch = false;
|
|
360
|
+
return value;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Run another session in this window through `/bot-lobby switch`, which pi
|
|
365
|
+
* runs with a command context (the only one that may replace the session).
|
|
366
|
+
* A background session is stopped first, so one process writes its file; a
|
|
367
|
+
* task no session owns is claimed instead.
|
|
368
|
+
*/
|
|
369
|
+
async function switchTo(state: Runtime, target: SwitchTarget): Promise<string> {
|
|
370
|
+
if (!state.ctx.isIdle()) return "this window's oracle is working — esc stops it, then switch";
|
|
371
|
+
if (target.claimTaskId) {
|
|
372
|
+
reopenAfterSwitch = false;
|
|
373
|
+
state.pi.sendUserMessage(`/bot-lobby claim ${target.claimTaskId}`, { expandPromptTemplates: true });
|
|
374
|
+
return `taking ${target.claimTaskId} over in this window`;
|
|
375
|
+
}
|
|
376
|
+
let file = target.background?.sessionFile;
|
|
377
|
+
if (target.background) {
|
|
378
|
+
target.background.stop();
|
|
379
|
+
await target.background.whenExited();
|
|
380
|
+
}
|
|
381
|
+
file ??= target.sessionId ? await chats.locate(target.sessionId) : undefined;
|
|
382
|
+
if (!file) return `could not find ${target.name}'s session file — /resume lists every saved session`;
|
|
383
|
+
reopenAfterSwitch = true;
|
|
384
|
+
state.pi.sendUserMessage(`/bot-lobby switch ${file}`, { expandPromptTemplates: true });
|
|
385
|
+
return `switching this window to ${target.name}…`;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Why a task cannot be archived or deleted now: a running session drives it. */
|
|
389
|
+
function drivenElsewhere(state: Runtime, task: Task): string | undefined {
|
|
390
|
+
if (TERMINAL_STATES.includes(task.state) || !task.ownerSessionId) return undefined;
|
|
391
|
+
if (task.ownerSessionId === state.ctx.sessionManager.getSessionId()) return `this window drives ${task.id} — cancel it first (/bot-lobby cancel ${task.id})`;
|
|
392
|
+
const background = backgroundSessions().find((session) => session.alive && session.sessionId === task.ownerSessionId);
|
|
393
|
+
if (background) return `${background.name} is driving ${task.id} in the background — stop it first (x x)`;
|
|
394
|
+
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`;
|
|
395
|
+
return undefined;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function archiveTask(state: Runtime, taskId: string): string {
|
|
399
|
+
const task = loadTask(state.root, state.configDir, taskId);
|
|
400
|
+
if (!task) return `no task ${taskId}`;
|
|
401
|
+
const busy = drivenElsewhere(state, task);
|
|
402
|
+
if (busy) return busy;
|
|
403
|
+
try {
|
|
404
|
+
archiveTaskOnDisk(state.root, state.configDir, taskId);
|
|
405
|
+
} catch (error) {
|
|
406
|
+
return (error as Error).message;
|
|
407
|
+
}
|
|
408
|
+
lobbyFeed.log("LOBBY", `archived ${taskId}`, "info");
|
|
409
|
+
lobbyTopics.bump("tasks");
|
|
410
|
+
return `archived ${taskId}${TERMINAL_STATES.includes(task.state) ? "" : " (abandoned first)"} — v shows archived tasks, a restores one`;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function restoreTask(state: Runtime, taskId: string): string {
|
|
414
|
+
try {
|
|
415
|
+
restoreTaskOnDisk(state.root, state.configDir, taskId);
|
|
416
|
+
} catch (error) {
|
|
417
|
+
return (error as Error).message;
|
|
418
|
+
}
|
|
419
|
+
lobbyFeed.log("LOBBY", `restored ${taskId}`, "info");
|
|
420
|
+
lobbyTopics.bump("tasks");
|
|
421
|
+
return `restored ${taskId} to the task list`;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function deleteTask(state: Runtime, taskId: string, where: "list" | "archive"): string {
|
|
425
|
+
if (where === "list") {
|
|
426
|
+
const task = loadTask(state.root, state.configDir, taskId);
|
|
427
|
+
if (!task) return `no task ${taskId}`;
|
|
428
|
+
const busy = drivenElsewhere(state, task);
|
|
429
|
+
if (busy) return busy;
|
|
430
|
+
}
|
|
431
|
+
try {
|
|
432
|
+
deleteTaskOnDisk(state.root, state.configDir, taskId, where);
|
|
433
|
+
} catch (error) {
|
|
434
|
+
return (error as Error).message;
|
|
435
|
+
}
|
|
436
|
+
lobbyFeed.log("LOBBY", `deleted ${taskId}`, "warning");
|
|
437
|
+
lobbyTopics.bump("tasks");
|
|
438
|
+
return `deleted ${taskId} for good`;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** What a pull request review runs on: QA's model, thinking and time limit. */
|
|
442
|
+
export function reviewProfile(state: Runtime) {
|
|
443
|
+
return resolveReviewProfile(loadConfig(), {
|
|
444
|
+
lookup: modelLookup(state.ctx),
|
|
445
|
+
sessionModel: sessionModel(state.ctx),
|
|
446
|
+
warn: (message) => state.ctx.ui.notify(message, "warning"),
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
export function seatProfile(state: Runtime, member: PanelMember) {
|
|
451
|
+
return resolvePanelProfile(loadConfig(), member, {
|
|
452
|
+
lookup: modelLookup(state.ctx),
|
|
453
|
+
sessionModel: sessionModel(state.ctx),
|
|
454
|
+
warn: (message) => state.ctx.ui.notify(message, "warning"),
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function newPlanner(state: Runtime, seed?: PlannerSeed, seats?: readonly PanelMember[]): PlanningSession {
|
|
459
|
+
state.planner?.cancel();
|
|
460
|
+
const config = loadConfig();
|
|
461
|
+
const workflow = config.workflow;
|
|
462
|
+
state.planner = new PlanningSession({
|
|
463
|
+
cwd: state.ctx.cwd,
|
|
464
|
+
root: state.root,
|
|
465
|
+
configDir: state.configDir,
|
|
466
|
+
profile: () => lobbyProfile(state, "planner"),
|
|
467
|
+
memberProfile: (member) => seatProfile(state, member),
|
|
468
|
+
panel: seats ?? config.lobby.planningPanel,
|
|
469
|
+
maxRounds: () => loadConfig().lobby.maxPlanningRounds,
|
|
470
|
+
classifier: classifier(),
|
|
471
|
+
hints: hintsFor({ cwd: state.ctx.cwd, root: state.root, configDir: state.configDir }),
|
|
472
|
+
effort: effortFor((model, thinking) => checkThinking(modelLookup(state.ctx)(model), thinking).level),
|
|
473
|
+
stallTimeoutMs: workflow.stallTimeoutMs,
|
|
474
|
+
toolStallTimeoutMs: workflow.toolStallTimeoutMs,
|
|
475
|
+
feed: lobbyFeed,
|
|
476
|
+
onChange: () => {
|
|
477
|
+
lobbyTopics.bump("planner");
|
|
478
|
+
repaintLobby();
|
|
479
|
+
},
|
|
480
|
+
onRound: (session) => {
|
|
481
|
+
lobbyTopics.bump("planner");
|
|
482
|
+
// Put the questions to the user at once when they are looking at the Plan tab.
|
|
483
|
+
if (loadConfig().lobby.autoAsk && state.visible && state.view?.tab === "plan" && session.awaitingAnswers) void answerPanel(state);
|
|
484
|
+
},
|
|
485
|
+
}, seed);
|
|
486
|
+
return state.planner;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** The questionnaire (pi's own dialogs where it cannot be drawn); tests swap it. */
|
|
490
|
+
function panelAsker(state: Runtime): Asker {
|
|
491
|
+
return state.asker ?? askUser;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Save the plan as a pending task. A plan with many steps is first split by
|
|
496
|
+
* the oracle into up to five tasks, which the user takes, changes or declines
|
|
497
|
+
* in a questionnaire; the split's questions open over the lobby like the
|
|
498
|
+
* panel's. Returns a notice for the lobby.
|
|
499
|
+
*/
|
|
500
|
+
export async function savePlan(state: Runtime | undefined): Promise<string> {
|
|
501
|
+
const session = state?.planner;
|
|
502
|
+
if (!state || !session) return "no planning session";
|
|
503
|
+
if (state.asking) return "a questionnaire is already open";
|
|
504
|
+
state.asking = true;
|
|
505
|
+
try {
|
|
506
|
+
const asker = panelAsker(state);
|
|
507
|
+
return await session.saveWithSplit((questions, signal) => promptHub.run("questionnaire", "planner", { questions }, () => asker(questions, state.ctx, signal), { signal }), { splitAbove: loadConfig().lobby.splitPlanAbove });
|
|
508
|
+
} finally {
|
|
509
|
+
state.asking = false;
|
|
510
|
+
repaintLobby();
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* The oracle puts the round's questions to the user, one at a time; answered
|
|
516
|
+
* questionnaires are kept if the user stops, so the next call resumes there.
|
|
517
|
+
* Once every questionnaire is done, the answers (and any line comments) start
|
|
518
|
+
* the next round. Returns a notice for the lobby.
|
|
519
|
+
*/
|
|
520
|
+
export async function answerPanel(state: Runtime | undefined): Promise<string> {
|
|
521
|
+
const session = state?.planner;
|
|
522
|
+
if (!state || !session) return "no planning session";
|
|
523
|
+
if (session.busy) return "the panel is still thinking";
|
|
524
|
+
if (session.questions.length === 0) return "no open questions";
|
|
525
|
+
if (state.asking) return "the questions are already open";
|
|
526
|
+
state.asking = true;
|
|
527
|
+
try {
|
|
528
|
+
const asker = panelAsker(state);
|
|
529
|
+
const chunks = questionnaires(session.questions);
|
|
530
|
+
for (let index = session.answered.length; index < chunks.length; index++) {
|
|
531
|
+
const questions = chunks[index]!;
|
|
532
|
+
const result = await promptHub.run("questionnaire", "planner", { questions }, () => asker(questions, state.ctx));
|
|
533
|
+
if (result.cancelled) {
|
|
534
|
+
repaintLobby();
|
|
535
|
+
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";
|
|
536
|
+
}
|
|
537
|
+
session.answered = [...session.answered, result];
|
|
538
|
+
}
|
|
539
|
+
const message = answerMessage(session.questions, session.answered);
|
|
540
|
+
if (!message) {
|
|
541
|
+
session.answered = [];
|
|
542
|
+
return "nothing was answered — the questions stay open";
|
|
543
|
+
}
|
|
544
|
+
void session.send(message, settledQuestions(session.questions, session.answered));
|
|
545
|
+
return "answers sent — the panel is on the next round";
|
|
546
|
+
} catch (error) {
|
|
547
|
+
return `could not put the questions: ${(error as Error).message}`;
|
|
548
|
+
} finally {
|
|
549
|
+
state.asking = false;
|
|
550
|
+
repaintLobby();
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/** Read the repository name and branch again, off the render path; the title repaints when they changed. */
|
|
555
|
+
function refreshWorkspace(state: Runtime): void {
|
|
556
|
+
if (state.readingWorkspace) return;
|
|
557
|
+
state.readingWorkspace = true;
|
|
558
|
+
describeWorkspace(state.ctx.cwd)
|
|
559
|
+
.then((info) => {
|
|
560
|
+
if (serviceState !== state || (info.name === state.workspace.name && info.branch === state.workspace.branch)) return;
|
|
561
|
+
state.workspace = info;
|
|
562
|
+
lobbyTopics.bump("status");
|
|
563
|
+
repaintLobby();
|
|
564
|
+
})
|
|
565
|
+
.catch(() => {})
|
|
566
|
+
.finally(() => {
|
|
567
|
+
state.readingWorkspace = false;
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/** The lobby's backend for `state`: every host member that draws nothing. */
|
|
572
|
+
export function createLobbyService(state: Runtime): LobbyService {
|
|
573
|
+
return {
|
|
574
|
+
sessionId: () => state.ctx.sessionManager.getSessionId(),
|
|
575
|
+
zen: () => {
|
|
576
|
+
const snapshot = taskSnapshot();
|
|
577
|
+
return { ...(snapshot.task ? { task: snapshot.task } : {}), runs: snapshot.runs };
|
|
578
|
+
},
|
|
579
|
+
feed: lobbyFeed,
|
|
580
|
+
masterBusy: () => !state.ctx.isIdle(),
|
|
581
|
+
tasks: () => peekTasks(state.root, state.configDir),
|
|
582
|
+
plans: () => listPlannedTasks(state.root, state.configDir),
|
|
583
|
+
comments: (taskId) => readPlanComments(state.root, state.configDir, taskId),
|
|
584
|
+
metrics: () => readMetrics(state.root, state.configDir),
|
|
585
|
+
classifierMetrics: () => readClassifierMetrics(state.root, state.configDir),
|
|
586
|
+
toOracle: (text) => toOracle(state, text),
|
|
587
|
+
comment: (taskId, text) => addComment(state, taskId, text),
|
|
588
|
+
startPlanned: (plan) => startPlanned(state, plan),
|
|
589
|
+
discardPlan: (id) => {
|
|
590
|
+
discardPlannedTask(state.root, state.configDir, id);
|
|
591
|
+
lobbyTopics.bump("plans");
|
|
592
|
+
},
|
|
593
|
+
abortMaster: () => state.ctx.abort(),
|
|
594
|
+
quickfix: state.quickfix,
|
|
595
|
+
planner: () => state.planner,
|
|
596
|
+
newPlanner: (seed, seats) => newPlanner(state, seed, seats),
|
|
597
|
+
answerPanel: () => answerPanel(state),
|
|
598
|
+
savePlan: () => savePlan(state),
|
|
599
|
+
defaultPanel: () => loadConfig().lobby.planningPanel,
|
|
600
|
+
planningRounds: () => loadConfig().lobby.maxPlanningRounds,
|
|
601
|
+
config: () => loadConfig(),
|
|
602
|
+
saveConfig: (next) => writeConfig(next),
|
|
603
|
+
configChanged: () => state.view?.reloadConfig(),
|
|
604
|
+
models: () => {
|
|
605
|
+
try {
|
|
606
|
+
const scoped = state.ctx.scopedModels;
|
|
607
|
+
const usable = scoped.length > 0 ? scoped.map((entry) => entry.model) : state.ctx.modelRegistry.getAvailable();
|
|
608
|
+
return usable.map((model) => {
|
|
609
|
+
const id = modelRef(model);
|
|
610
|
+
return { id, label: model.name && model.name !== id ? model.name : id, thinkingLevels: supportedThinking(model) };
|
|
611
|
+
});
|
|
612
|
+
} catch {
|
|
613
|
+
// No model registry in this process (tests): the page falls back to custom ids.
|
|
614
|
+
return [];
|
|
615
|
+
}
|
|
616
|
+
},
|
|
617
|
+
issuesEnabled: () => loadConfig().lobby.issues,
|
|
618
|
+
seatLabel: (member) => {
|
|
619
|
+
const profile = seatProfile(state, member);
|
|
620
|
+
return `${profile.model ?? "session model"} · ${profile.thinking}`;
|
|
621
|
+
},
|
|
622
|
+
issues: state.issues,
|
|
623
|
+
pulls: state.pulls,
|
|
624
|
+
reviews: state.reviews,
|
|
625
|
+
knowledge: state.knowledge,
|
|
626
|
+
excalidraw: state.excalidraw,
|
|
627
|
+
checkExcalidraw: (session) => {
|
|
628
|
+
lobbyTopics.bump("excalidraw");
|
|
629
|
+
return checkSession(session.link, session.name);
|
|
630
|
+
},
|
|
631
|
+
profileLabel: (kind) => {
|
|
632
|
+
const profile = lobbyProfile(state, kind);
|
|
633
|
+
return `${profile.model ?? "session model"} · ${profile.thinking}`;
|
|
634
|
+
},
|
|
635
|
+
workspace: () => state.workspace,
|
|
636
|
+
refreshWorkspace: () => refreshWorkspace(state),
|
|
637
|
+
sessionName: () => state.pi.getSessionName(),
|
|
638
|
+
sessions: () => backgroundSessions(),
|
|
639
|
+
startSession: (start) => startSession(state, start),
|
|
640
|
+
answerDialog: (session) => answerDialog(state, session),
|
|
641
|
+
isAuto: (taskId) => autoFor(state, taskId),
|
|
642
|
+
setAuto: (taskId, on) => switchAuto(state, taskId, on),
|
|
643
|
+
sendToTask: (taskId, text) => sendToTask(state, taskId, text),
|
|
644
|
+
sendToSession: (sessionId, text) => sendToSession(state, sessionId, text),
|
|
645
|
+
liveSessions: () => liveSessions(state),
|
|
646
|
+
switchTo: (target) => switchTo(state, target),
|
|
647
|
+
archivedTasks: () => listArchivedTasks(state.root, state.configDir),
|
|
648
|
+
archiveTask: (taskId) => archiveTask(state, taskId),
|
|
649
|
+
restoreTask: (taskId) => restoreTask(state, taskId),
|
|
650
|
+
deleteTask: (taskId, where) => deleteTask(state, taskId, where),
|
|
651
|
+
sessionChat: (sessionId) => chats.chat(sessionId),
|
|
652
|
+
hasOlderChat: (sessionId) => chats.hasOlder(sessionId),
|
|
653
|
+
chatHistory: (sessionId) => chatHistory(state, sessionId),
|
|
654
|
+
};
|
|
655
|
+
}
|
package/src/lobby/tabs/home.ts
CHANGED
|
@@ -73,7 +73,7 @@ export function sourceColor(source: string): LobbyColor {
|
|
|
73
73
|
const SOURCE_WIDTH = 9;
|
|
74
74
|
|
|
75
75
|
/** One activity line: time, who, what; a running step spins, a finished one is marked. */
|
|
76
|
-
export function activityLine(entry: ActivityEntry,
|
|
76
|
+
export function activityLine(entry: ActivityEntry, _width: number, tick: number, theme?: LobbyTheme): string {
|
|
77
77
|
const who = paint(theme, sourceColor(entry.source), entry.source.padEnd(SOURCE_WIDTH).slice(0, SOURCE_WIDTH));
|
|
78
78
|
const mark = entry.pending
|
|
79
79
|
? paint(theme, "accent", spinner(tick))
|
|
@@ -11,6 +11,9 @@ import { tokens } from "../../pi/run-summary.ts";
|
|
|
11
11
|
import type { ClassifierSummary, GroupBy, MetricGroup, MetricRecord, SortKey, TaskStats, TaskTimeGroup } from "../../state/metrics.ts";
|
|
12
12
|
import { bar, beside, bold, box, fill, fit, meter, notePane, paint, selectRow, sparkline, stackedBar, windowStart, wrap, type LobbyTheme, type PaneLayout } from "../layout.ts";
|
|
13
13
|
import { sourceColor } from "./home.ts";
|
|
14
|
+
import { filterRecords } from "../models/metrics.ts";
|
|
15
|
+
|
|
16
|
+
export { filterRecords };
|
|
14
17
|
|
|
15
18
|
export interface MetricsTabInput {
|
|
16
19
|
groups: readonly MetricGroup[];
|
|
@@ -294,9 +297,4 @@ export function renderMetrics(input: MetricsTabInput, width: number, height: num
|
|
|
294
297
|
return fill(sections, height, width);
|
|
295
298
|
}
|
|
296
299
|
|
|
297
|
-
|
|
298
|
-
export function filterRecords(records: readonly MetricRecord[], query: string | undefined): MetricRecord[] {
|
|
299
|
-
const needle = query?.trim().toLowerCase();
|
|
300
|
-
if (!needle) return [...records];
|
|
301
|
-
return records.filter((record) => [record.agent, record.model ?? "", record.thinking ?? "", record.kind, record.status, record.taskId ?? ""].join(" ").toLowerCase().includes(needle));
|
|
302
|
-
}
|
|
300
|
+
|