@a-t-h-i/bot-lobby 0.6.0 → 0.6.2
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 +177 -799
- package/package.json +1 -1
- package/prompts/master.md +17 -0
- package/prompts/panel.md +3 -1
- package/prompts/planner.md +14 -0
- package/prompts/quickfix.md +3 -1
- package/prompts/scout.md +3 -0
- package/prompts/worker.md +3 -0
- package/src/classifier/answers.ts +110 -0
- package/src/classifier/classifier.ts +171 -0
- package/src/classifier/client.ts +239 -0
- package/src/classifier/effort.ts +143 -0
- package/src/classifier/files.ts +427 -0
- package/src/classifier/hosts.ts +157 -0
- package/src/classifier/instance.ts +98 -0
- package/src/classifier/limits.ts +37 -0
- package/src/classifier/seats.ts +102 -0
- package/src/classifier/tools.ts +58 -0
- package/src/classifier/triage.ts +203 -0
- package/src/execution/agent-runner.ts +5 -0
- package/src/index.ts +6 -0
- package/src/lobby/keys.ts +1 -1
- package/src/lobby/planner.ts +305 -27
- package/src/lobby/quickfix.ts +120 -8
- package/src/lobby/runtime.ts +136 -5
- package/src/lobby/session-files.ts +12 -0
- package/src/lobby/sessions.ts +28 -2
- package/src/lobby/tabs/home.ts +54 -26
- package/src/lobby/tabs/metrics.ts +28 -1
- package/src/lobby/tabs/plan.ts +27 -4
- package/src/lobby/tabs/quickfix.ts +5 -1
- package/src/lobby/tabs/tasks.ts +30 -14
- package/src/lobby/view.ts +334 -41
- package/src/master/master.ts +95 -29
- package/src/pi/commands.ts +24 -4
- package/src/pi/events.ts +11 -2
- package/src/pi/owner.ts +57 -8
- package/src/pi/run-summary.ts +3 -1
- package/src/pi/settings-ui.ts +138 -2
- package/src/pi/start-task.ts +4 -0
- package/src/pi/tools.ts +7 -2
- package/src/schemas/configuration.ts +125 -1
- package/src/schemas/findings.ts +4 -0
- package/src/schemas/task.ts +24 -0
- package/src/state/archive.ts +99 -0
- package/src/state/inbox.ts +49 -15
- package/src/state/metrics.ts +74 -1
- package/src/state/presence.ts +99 -0
- package/src/workflow/workflow.ts +49 -1
package/src/lobby/quickfix.ts
CHANGED
|
@@ -10,13 +10,18 @@ import { runPiAgent, spawnPiProcess, type PiStreamEvent, type ProcessRunner } fr
|
|
|
10
10
|
import { describeToolCall } from "../pi/activity.ts";
|
|
11
11
|
import { appendMetrics, type MetricRecord } from "../state/metrics.ts";
|
|
12
12
|
import type { LobbyFeed } from "./feed.ts";
|
|
13
|
+
import type { FileHinter } from "../classifier/files.ts";
|
|
14
|
+
import type { Classifier } from "../classifier/classifier.ts";
|
|
15
|
+
import { quickFixSize } from "../classifier/triage.ts";
|
|
16
|
+
import { fellShort, profileLabel, routeLabel, type EffortRoute, type EffortRouter } from "../classifier/effort.ts";
|
|
13
17
|
|
|
14
18
|
/** A quick fix edits code, so it gets the full coding tool set. */
|
|
15
19
|
export const QUICK_FIX_TOOLS: readonly string[] = ["read", "bash", "edit", "write", "grep", "find", "ls"];
|
|
16
20
|
|
|
17
21
|
export const QUICK_FIX_SOURCE = "QUICK FIX";
|
|
18
22
|
|
|
19
|
-
|
|
23
|
+
/** `held`: the classifier judged the request a task, not a quick fix; it waits for you (r runs it anyway, t makes it a task). */
|
|
24
|
+
export type QuickFixStatus = "queued" | "running" | "success" | "failed" | "cancelled" | "timeout" | "held";
|
|
20
25
|
|
|
21
26
|
export interface QuickFixStep {
|
|
22
27
|
at: number;
|
|
@@ -39,6 +44,13 @@ export interface QuickFixJob {
|
|
|
39
44
|
report?: string;
|
|
40
45
|
error?: string;
|
|
41
46
|
usage?: { input: number; output: number; cost: number; turns: number };
|
|
47
|
+
/** Why the job was held, or what became of it. */
|
|
48
|
+
note?: string;
|
|
49
|
+
/** Run even if the classifier judges it large (r on a held job). */
|
|
50
|
+
force?: boolean;
|
|
51
|
+
/** The classifier routed it down (`trivial 0.88: p/big · low → p/cheap · low`); cleared when it re-ran on the configured profile. */
|
|
52
|
+
route?: string;
|
|
53
|
+
routedFrom?: string;
|
|
42
54
|
}
|
|
43
55
|
|
|
44
56
|
export interface QuickFixProfile {
|
|
@@ -61,6 +73,12 @@ export interface QuickFixDeps {
|
|
|
61
73
|
/** Called after every change so the lobby can repaint. */
|
|
62
74
|
onChange?: () => void;
|
|
63
75
|
notify?: (message: string, level: "info" | "warning" | "error") => void;
|
|
76
|
+
/** Likely files for the prompt, and the lookup tool, while the classifier's file hints are on. */
|
|
77
|
+
hints?: FileHinter;
|
|
78
|
+
/** Holds a request it judges large (a task, not a quick fix) instead of spending a run on it. */
|
|
79
|
+
classifier?: Classifier;
|
|
80
|
+
/** Lowers thinking or the model for a request the classifier judges simple or trivial. */
|
|
81
|
+
effort?: EffortRouter;
|
|
64
82
|
}
|
|
65
83
|
|
|
66
84
|
export const MAX_JOBS = 30;
|
|
@@ -120,6 +138,30 @@ export class QuickFixQueue {
|
|
|
120
138
|
return true;
|
|
121
139
|
}
|
|
122
140
|
|
|
141
|
+
/** Run a held job anyway: it goes back in the queue and is not sized again. */
|
|
142
|
+
runAnyway(id: string): boolean {
|
|
143
|
+
const job = this.jobs.find((entry) => entry.id === id);
|
|
144
|
+
if (!job || job.status !== "held") return false;
|
|
145
|
+
job.status = "queued";
|
|
146
|
+
job.force = true;
|
|
147
|
+
delete job.note;
|
|
148
|
+
delete job.startedAt;
|
|
149
|
+
delete job.finishedAt;
|
|
150
|
+
this.changed();
|
|
151
|
+
this.pump();
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** A held job that became a task: it leaves the queue with a note. */
|
|
156
|
+
movedToTask(id: string): boolean {
|
|
157
|
+
const job = this.jobs.find((entry) => entry.id === id);
|
|
158
|
+
if (!job || job.status !== "held") return false;
|
|
159
|
+
job.status = "cancelled";
|
|
160
|
+
job.note = "started as a task in a new session";
|
|
161
|
+
this.changed();
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
|
|
123
165
|
/** Abort everything (session shutdown). */
|
|
124
166
|
cancelAll(): void {
|
|
125
167
|
for (const job of this.jobs) if (job.status === "queued") job.status = "cancelled";
|
|
@@ -162,17 +204,23 @@ export class QuickFixQueue {
|
|
|
162
204
|
job.startedAt = Date.now();
|
|
163
205
|
job.thinking = profile.thinking;
|
|
164
206
|
if (profile.model) job.model = profile.model;
|
|
165
|
-
this.deps.feed?.log(QUICK_FIX_SOURCE, `started: ${jobTitle(job)}`, "info", job.startedAt);
|
|
166
207
|
this.changed();
|
|
208
|
+
const held = job.force ? undefined : await this.tooLarge(job, controller.signal);
|
|
209
|
+
if (held) {
|
|
210
|
+
this.controllers.delete(job.id);
|
|
211
|
+
return this.hold(job, held);
|
|
212
|
+
}
|
|
213
|
+
this.deps.feed?.log(QUICK_FIX_SOURCE, `started: ${jobTitle(job)}`, "info", job.startedAt);
|
|
167
214
|
try {
|
|
168
|
-
const
|
|
215
|
+
const [likely, route] = await Promise.all([this.likely(job.prompt, controller.signal), this.route(job.prompt, profile, controller.signal)]);
|
|
216
|
+
const attempt = (model: string | undefined, thinking: string) => runPiAgent(
|
|
169
217
|
{
|
|
170
218
|
cwd: this.deps.cwd,
|
|
171
|
-
task: job.prompt,
|
|
219
|
+
task: likely ? `${job.prompt}\n\n${likely}` : job.prompt,
|
|
172
220
|
systemPrompt: quickFixPrompt(profile.instructions),
|
|
173
|
-
tools: QUICK_FIX_TOOLS,
|
|
174
|
-
model
|
|
175
|
-
thinking
|
|
221
|
+
tools: [...QUICK_FIX_TOOLS, ...(this.deps.hints?.tools() ?? [])],
|
|
222
|
+
model,
|
|
223
|
+
thinking,
|
|
176
224
|
timeoutMs: profile.timeoutMs,
|
|
177
225
|
signal: controller.signal,
|
|
178
226
|
stallTimeoutMs: this.deps.stallTimeoutMs,
|
|
@@ -181,6 +229,27 @@ export class QuickFixQueue {
|
|
|
181
229
|
},
|
|
182
230
|
this.deps.runProcess ?? spawnPiProcess,
|
|
183
231
|
);
|
|
232
|
+
let result;
|
|
233
|
+
if (route) {
|
|
234
|
+
job.route = routeLabel(route);
|
|
235
|
+
job.routedFrom = profileLabel(route.from);
|
|
236
|
+
job.thinking = route.thinking;
|
|
237
|
+
if (route.model) job.model = route.model;
|
|
238
|
+
this.changed();
|
|
239
|
+
result = await attempt(route.model, route.thinking);
|
|
240
|
+
if (fellShort(result) && !controller.signal.aborted) {
|
|
241
|
+
// The routed attempt counts in the metrics on its own; the job re-runs on the configured profile.
|
|
242
|
+
appendMetrics(this.deps.root, this.deps.configDir, [{ ...quickFixMetric({ ...job, status: result.status, finishedAt: Date.now(), usage: result.usage, ...(result.model ? { model: result.model } : {}) }), id: `${job.id}-routed-${job.startedAt}` }]);
|
|
243
|
+
this.addStep(job, "the routed attempt fell short; running again on the configured model and thinking", Date.now());
|
|
244
|
+
delete job.route;
|
|
245
|
+
delete job.routedFrom;
|
|
246
|
+
job.thinking = profile.thinking;
|
|
247
|
+
if (profile.model) job.model = profile.model;
|
|
248
|
+
result = await attempt(profile.model, profile.thinking);
|
|
249
|
+
}
|
|
250
|
+
} else {
|
|
251
|
+
result = await attempt(profile.model, profile.thinking);
|
|
252
|
+
}
|
|
184
253
|
job.status = result.status;
|
|
185
254
|
job.report = result.output.trim() || undefined;
|
|
186
255
|
job.error = result.error;
|
|
@@ -195,6 +264,48 @@ export class QuickFixQueue {
|
|
|
195
264
|
}
|
|
196
265
|
}
|
|
197
266
|
|
|
267
|
+
/** Where the classifier routes this request, or undefined. */
|
|
268
|
+
private async route(prompt: string, profile: QuickFixProfile, signal: AbortSignal): Promise<EffortRoute | undefined> {
|
|
269
|
+
try {
|
|
270
|
+
return await this.deps.effort?.route(prompt, { ...(profile.model ? { model: profile.model } : {}), thinking: profile.thinking }, { signal });
|
|
271
|
+
} catch {
|
|
272
|
+
return undefined;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Why a request is too large for a quick fix, when the classifier is sure; undefined runs it. */
|
|
277
|
+
private async tooLarge(job: QuickFixJob, signal: AbortSignal): Promise<string | undefined> {
|
|
278
|
+
const jev = this.deps.classifier;
|
|
279
|
+
if (!jev?.enabled("triage")) return undefined;
|
|
280
|
+
try {
|
|
281
|
+
const sized = await quickFixSize(jev, job.prompt, signal);
|
|
282
|
+
if (!sized || sized.size !== "large" || sized.confidence < jev.config.thresholds.quickFixLargeAt) return undefined;
|
|
283
|
+
return `looks like a task (large, ${sized.confidence.toFixed(2)})`;
|
|
284
|
+
} catch {
|
|
285
|
+
return undefined;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Hold a job instead of running it; nothing ran, so nothing lands in the metrics. */
|
|
290
|
+
private hold(job: QuickFixJob, note: string): void {
|
|
291
|
+
job.status = "held";
|
|
292
|
+
job.note = note;
|
|
293
|
+
job.finishedAt = Date.now();
|
|
294
|
+
this.deps.feed?.log(QUICK_FIX_SOURCE, `held: ${jobTitle(job)} — ${note}; r runs it anyway, t makes it a task`, "warning", job.finishedAt);
|
|
295
|
+
this.deps.notify?.(`bot-lobby quick fix held: ${jobTitle(job)} ${note}`, "warning");
|
|
296
|
+
this.changed();
|
|
297
|
+
this.pump();
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** The Likely files block for a prompt, or "" (no hints, nothing stands out, out of time). */
|
|
301
|
+
private async likely(prompt: string, signal: AbortSignal): Promise<string> {
|
|
302
|
+
try {
|
|
303
|
+
return (await this.deps.hints?.block(prompt, signal)) ?? "";
|
|
304
|
+
} catch {
|
|
305
|
+
return "";
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
198
309
|
private finish(job: QuickFixJob): void {
|
|
199
310
|
job.finishedAt = Date.now();
|
|
200
311
|
for (const step of job.steps) step.pending = false;
|
|
@@ -217,11 +328,12 @@ export function quickFixMetric(job: QuickFixJob): MetricRecord {
|
|
|
217
328
|
agent: QUICK_FIX_SOURCE,
|
|
218
329
|
...(job.model ? { model: job.model } : {}),
|
|
219
330
|
...(job.thinking ? { thinking: job.thinking } : {}),
|
|
220
|
-
status: job.status === "queued" || job.status === "running" ? "failed" : job.status,
|
|
331
|
+
status: job.status === "queued" || job.status === "running" ? "failed" : job.status === "held" ? "cancelled" : job.status,
|
|
221
332
|
startedAt: new Date(started).toISOString(),
|
|
222
333
|
durationMs: Math.max(0, (job.finishedAt ?? started) - started),
|
|
223
334
|
turns: job.usage?.turns || job.turns || undefined,
|
|
224
335
|
tools: job.tools,
|
|
225
336
|
...(job.usage ? { input: job.usage.input, output: job.usage.output, cost: job.usage.cost } : {}),
|
|
337
|
+
...(job.routedFrom ? { routedFrom: job.routedFrom } : {}),
|
|
226
338
|
};
|
|
227
339
|
}
|
package/src/lobby/runtime.ts
CHANGED
|
@@ -14,9 +14,11 @@ import { listTasks, loadTask } from "../state/persistence.ts";
|
|
|
14
14
|
import { detectProjectRoot, loadConfig, saveConfig } from "../state/project.ts";
|
|
15
15
|
import { addPlanComment, readPlanComments } from "../state/comments.ts";
|
|
16
16
|
import { isAutoMode } from "../state/auto.ts";
|
|
17
|
-
import { sendToInbox } from "../state/inbox.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";
|
|
18
20
|
import { discardPlannedTask, listPlannedTasks, type PlannedTask } from "../state/backlog.ts";
|
|
19
|
-
import { appendMetrics, readMetrics, type MetricStatus } from "../state/metrics.ts";
|
|
21
|
+
import { appendMetrics, readClassifierMetrics, readMetrics, type MetricStatus } from "../state/metrics.ts";
|
|
20
22
|
import { describeToolCall } from "../pi/activity.ts";
|
|
21
23
|
import { applyStatus, currentZenTask, isMinimized, onMinimizeChange, onRunUpdates, persistedRuns, setMinimized, setWidgetSuppressor, ZenScene, zenSnapshot } from "../pi/ui.ts";
|
|
22
24
|
import { panelLines } from "../pi/zen.ts";
|
|
@@ -27,13 +29,15 @@ import { modelLookup } from "../pi/tools.ts";
|
|
|
27
29
|
import { startPlannedTask, startTask } from "../pi/start-task.ts";
|
|
28
30
|
import type { LobbyAgentKind, LobbyPanel, PanelMember } from "../schemas/configuration.ts";
|
|
29
31
|
import { chatFromEntries, lobbyFeed, narrateEvent, type AgentEventLike } from "./feed.ts";
|
|
32
|
+
import { classifier, effortFor, hintsFor } from "../classifier/instance.ts";
|
|
33
|
+
import { checkThinking } from "../pi/model-support.ts";
|
|
30
34
|
import { launchPi, SessionRegistry, type BackgroundSession, type SessionLauncher } from "./sessions.ts";
|
|
31
35
|
import { SessionChats } from "./session-files.ts";
|
|
32
36
|
import { answerMessage, dialogAsker, loadAskTool, questionnaires, toolAsker, type Asker } from "./ask.ts";
|
|
33
37
|
import { QuickFixQueue } from "./quickfix.ts";
|
|
34
38
|
import { PlanningSession, type PlannerSeed } from "./planner.ts";
|
|
35
39
|
import { execCommand, IssuesState } from "./issues.ts";
|
|
36
|
-
import { LobbyView, type LobbyHost, type TabId } from "./view.ts";
|
|
40
|
+
import { LobbyView, type LiveSession, type LobbyHost, type SwitchTarget, type TabId } from "./view.ts";
|
|
37
41
|
import type { LobbyTheme } from "./layout.ts";
|
|
38
42
|
import { createMarkdownRenderer } from "./markdown.ts";
|
|
39
43
|
import { deliverComments, onOwnerEvent, setAuto } from "../pi/owner.ts";
|
|
@@ -282,6 +286,113 @@ function switchAuto(state: Runtime, taskId: string, on: boolean): void {
|
|
|
282
286
|
autoSeen.set(taskId, { at: Date.now(), on });
|
|
283
287
|
}
|
|
284
288
|
|
|
289
|
+
/** Heartbeats of the other sessions running in this project, reread at most once a second (every frame asks). */
|
|
290
|
+
let liveSeen: { at: number; root: string; sessions: LiveSession[] } | undefined;
|
|
291
|
+
const LIVE_READ_MS = 1000;
|
|
292
|
+
|
|
293
|
+
function liveSessions(state: Runtime): LiveSession[] {
|
|
294
|
+
const now = Date.now();
|
|
295
|
+
if (liveSeen && liveSeen.root === state.root && now - liveSeen.at < LIVE_READ_MS) return liveSeen.sessions;
|
|
296
|
+
const me = state.ctx.sessionManager.getSessionId();
|
|
297
|
+
let sessions: LiveSession[] = [];
|
|
298
|
+
try {
|
|
299
|
+
sessions = livePresence(state.root, state.configDir, now).filter((presence) => presence.sessionId !== me);
|
|
300
|
+
} catch {
|
|
301
|
+
// An unreadable folder only means no other sessions are listed.
|
|
302
|
+
}
|
|
303
|
+
liveSeen = { at: now, root: state.root, sessions };
|
|
304
|
+
return sessions;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Leave a message for a session running in another terminal. */
|
|
308
|
+
function sendToSession(state: Runtime, sessionId: string, text: string): string {
|
|
309
|
+
try {
|
|
310
|
+
leaveForSession(state.root, state.configDir, sessionId, text, state.ctx.sessionManager.getSessionId());
|
|
311
|
+
} catch (error) {
|
|
312
|
+
return (error as Error).message;
|
|
313
|
+
}
|
|
314
|
+
lobbyFeed.log("LOBBY", "message for another session's oracle saved", "info");
|
|
315
|
+
return "sent — that session passes it to its oracle within a few seconds";
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** The lobby reopens on the session this window switches to (set just before asking pi to switch). */
|
|
319
|
+
let reopenAfterSwitch = false;
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Run another session in this window through `/bot-lobby switch`, which pi
|
|
323
|
+
* runs with a command context (the only one that may replace the session).
|
|
324
|
+
* A background session is stopped first, so one process writes its file; a
|
|
325
|
+
* task no session owns is claimed instead.
|
|
326
|
+
*/
|
|
327
|
+
async function switchTo(state: Runtime, target: SwitchTarget): Promise<string> {
|
|
328
|
+
if (!state.ctx.isIdle()) return "this window's oracle is working — esc stops it, then switch";
|
|
329
|
+
if (target.claimTaskId) {
|
|
330
|
+
reopenAfterSwitch = false;
|
|
331
|
+
state.pi.sendUserMessage(`/bot-lobby claim ${target.claimTaskId}`, { expandPromptTemplates: true });
|
|
332
|
+
return `taking ${target.claimTaskId} over in this window`;
|
|
333
|
+
}
|
|
334
|
+
let file = target.background?.sessionFile;
|
|
335
|
+
if (target.background) {
|
|
336
|
+
target.background.stop();
|
|
337
|
+
await target.background.whenExited();
|
|
338
|
+
}
|
|
339
|
+
file ??= target.sessionId ? await chats.locate(target.sessionId) : undefined;
|
|
340
|
+
if (!file) return `could not find ${target.name}'s session file — /resume lists every saved session`;
|
|
341
|
+
reopenAfterSwitch = true;
|
|
342
|
+
state.pi.sendUserMessage(`/bot-lobby switch ${file}`, { expandPromptTemplates: true });
|
|
343
|
+
return `switching this window to ${target.name}…`;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** Why a task cannot be archived or deleted now: a running session drives it. */
|
|
347
|
+
function drivenElsewhere(state: Runtime, task: Task): string | undefined {
|
|
348
|
+
if (TERMINAL_STATES.includes(task.state) || !task.ownerSessionId) return undefined;
|
|
349
|
+
if (task.ownerSessionId === state.ctx.sessionManager.getSessionId()) return `this window drives ${task.id} — cancel it first (/bot-lobby cancel ${task.id})`;
|
|
350
|
+
const background = backgroundSessions().find((session) => session.alive && session.sessionId === task.ownerSessionId);
|
|
351
|
+
if (background) return `${background.name} is driving ${task.id} in the background — stop it first (x x)`;
|
|
352
|
+
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`;
|
|
353
|
+
return undefined;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function archiveTask(state: Runtime, taskId: string): string {
|
|
357
|
+
const task = loadTask(state.root, state.configDir, taskId);
|
|
358
|
+
if (!task) return `no task ${taskId}`;
|
|
359
|
+
const busy = drivenElsewhere(state, task);
|
|
360
|
+
if (busy) return busy;
|
|
361
|
+
try {
|
|
362
|
+
archiveTaskOnDisk(state.root, state.configDir, taskId);
|
|
363
|
+
} catch (error) {
|
|
364
|
+
return (error as Error).message;
|
|
365
|
+
}
|
|
366
|
+
lobbyFeed.log("LOBBY", `archived ${taskId}`, "info");
|
|
367
|
+
return `archived ${taskId}${TERMINAL_STATES.includes(task.state) ? "" : " (abandoned first)"} — v shows archived tasks, a restores one`;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function restoreTask(state: Runtime, taskId: string): string {
|
|
371
|
+
try {
|
|
372
|
+
restoreTaskOnDisk(state.root, state.configDir, taskId);
|
|
373
|
+
} catch (error) {
|
|
374
|
+
return (error as Error).message;
|
|
375
|
+
}
|
|
376
|
+
lobbyFeed.log("LOBBY", `restored ${taskId}`, "info");
|
|
377
|
+
return `restored ${taskId} to the task list`;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function deleteTask(state: Runtime, taskId: string, where: "list" | "archive"): string {
|
|
381
|
+
if (where === "list") {
|
|
382
|
+
const task = loadTask(state.root, state.configDir, taskId);
|
|
383
|
+
if (!task) return `no task ${taskId}`;
|
|
384
|
+
const busy = drivenElsewhere(state, task);
|
|
385
|
+
if (busy) return busy;
|
|
386
|
+
}
|
|
387
|
+
try {
|
|
388
|
+
deleteTaskOnDisk(state.root, state.configDir, taskId, where);
|
|
389
|
+
} catch (error) {
|
|
390
|
+
return (error as Error).message;
|
|
391
|
+
}
|
|
392
|
+
lobbyFeed.log("LOBBY", `deleted ${taskId}`, "warning");
|
|
393
|
+
return `deleted ${taskId} for good`;
|
|
394
|
+
}
|
|
395
|
+
|
|
285
396
|
/** A task's status box without animations, for a session other than this window's. */
|
|
286
397
|
function taskScene(state: Runtime, task: Task, width: number, height: number): string[] {
|
|
287
398
|
return panelLines(task, persistedRuns(task), Date.now(), false, { width, rows: Math.floor(height / 0.75), still: true, theme: state.ctx.ui.theme });
|
|
@@ -306,6 +417,10 @@ function newPlanner(state: Runtime, seed?: PlannerSeed, seats?: readonly PanelMe
|
|
|
306
417
|
profile: () => lobbyProfile(state, "planner"),
|
|
307
418
|
memberProfile: (member) => seatProfile(state, member),
|
|
308
419
|
panel: seats ?? config.lobby.planningPanel,
|
|
420
|
+
maxRounds: () => loadConfig().lobby.maxPlanningRounds,
|
|
421
|
+
classifier: classifier(),
|
|
422
|
+
hints: hintsFor({ cwd: state.ctx.cwd, root: state.root, configDir: state.configDir }),
|
|
423
|
+
effort: effortFor((model, thinking) => checkThinking(modelLookup(state.ctx)(model), thinking).level),
|
|
309
424
|
stallTimeoutMs: workflow.stallTimeoutMs,
|
|
310
425
|
toolStallTimeoutMs: workflow.toolStallTimeoutMs,
|
|
311
426
|
feed: lobbyFeed,
|
|
@@ -404,6 +519,7 @@ function host(state: Runtime, tui: TUI): LobbyHost {
|
|
|
404
519
|
plans: () => listPlannedTasks(state.root, state.configDir),
|
|
405
520
|
comments: (taskId) => readPlanComments(state.root, state.configDir, taskId),
|
|
406
521
|
metrics: () => readMetrics(state.root, state.configDir),
|
|
522
|
+
classifierMetrics: () => readClassifierMetrics(state.root, state.configDir),
|
|
407
523
|
toOracle: (text) => toOracle(state, text),
|
|
408
524
|
comment: (taskId, text) => addComment(state, taskId, text),
|
|
409
525
|
startPlanned: (plan) => startPlanned(state, plan),
|
|
@@ -415,6 +531,7 @@ function host(state: Runtime, tui: TUI): LobbyHost {
|
|
|
415
531
|
newPlanner: (seed, seats) => newPlanner(state, seed, seats),
|
|
416
532
|
answerPanel: () => answerPanel(state),
|
|
417
533
|
defaultPanel: () => loadConfig().lobby.planningPanel,
|
|
534
|
+
planningRounds: () => loadConfig().lobby.maxPlanningRounds,
|
|
418
535
|
issuesEnabled: () => loadConfig().lobby.issues,
|
|
419
536
|
panels: () => loadConfig().lobby.panels,
|
|
420
537
|
savePanels: (panels) => savePanels(panels),
|
|
@@ -436,6 +553,13 @@ function host(state: Runtime, tui: TUI): LobbyHost {
|
|
|
436
553
|
isAuto: (taskId) => autoFor(state, taskId),
|
|
437
554
|
setAuto: (taskId, on) => switchAuto(state, taskId, on),
|
|
438
555
|
sendToTask: (taskId, text) => sendToTask(state, taskId, text),
|
|
556
|
+
sendToSession: (sessionId, text) => sendToSession(state, sessionId, text),
|
|
557
|
+
liveSessions: () => liveSessions(state),
|
|
558
|
+
switchTo: (target) => switchTo(state, target),
|
|
559
|
+
archivedTasks: () => listArchivedTasks(state.root, state.configDir),
|
|
560
|
+
archiveTask: (taskId) => archiveTask(state, taskId),
|
|
561
|
+
restoreTask: (taskId) => restoreTask(state, taskId),
|
|
562
|
+
deleteTask: (taskId, where) => deleteTask(state, taskId, where),
|
|
439
563
|
sessionChat: (sessionId) => chats.chat(sessionId),
|
|
440
564
|
taskScene: (task, width, height) => taskScene(state, task, width, height),
|
|
441
565
|
requestRender: () => tui.requestRender(),
|
|
@@ -612,6 +736,9 @@ export function initLobby(pi: ExtensionAPI, ctx: ExtensionContext, configDir: st
|
|
|
612
736
|
notify: (message, level) => {
|
|
613
737
|
if (!state.visible) ctx.ui.notify(message, level);
|
|
614
738
|
},
|
|
739
|
+
hints: hintsFor({ cwd: ctx.cwd, root, configDir }),
|
|
740
|
+
classifier: classifier(),
|
|
741
|
+
effort: effortFor((model, thinking) => checkThinking(modelLookup(ctx)(model), thinking).level),
|
|
615
742
|
});
|
|
616
743
|
runtime = state;
|
|
617
744
|
lobbyFeed.clear();
|
|
@@ -630,7 +757,7 @@ export function initLobby(pi: ExtensionAPI, ctx: ExtensionContext, configDir: st
|
|
|
630
757
|
// The owner's clock (pi/owner.ts) delivers comments and messages and drives auto mode; the lobby logs what it did.
|
|
631
758
|
onOwnerEvent((event) => {
|
|
632
759
|
if (event.kind === "comments") lobbyFeed.log("LOBBY", `passed ${event.count} plan comment${event.count === 1 ? "" : "s"} on ${event.taskId} to the oracle`, "info");
|
|
633
|
-
else if (event.kind === "inbox") lobbyFeed.log("LOBBY", `passed ${event.count} message${event.count === 1 ? "" : "s"} from another session to the oracle`, "info");
|
|
760
|
+
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");
|
|
634
761
|
else if (event.kind === "auto") lobbyFeed.log("LOBBY", `auto mode ${event.on ? "on" : "off"} for ${event.taskId}`, event.on ? "success" : "info");
|
|
635
762
|
else if (event.kind === "nudge") lobbyFeed.log("LOBBY", `auto mode: keeping the oracle going on ${event.taskId}`, "info");
|
|
636
763
|
else lobbyFeed.log("LOBBY", `auto mode: no progress on ${event.taskId} — it needs you`, "warning");
|
|
@@ -673,7 +800,11 @@ export function registerLobbyEvents(pi: ExtensionAPI, configDir: string): void {
|
|
|
673
800
|
});
|
|
674
801
|
pi.on("session_start", (_event, ctx) => {
|
|
675
802
|
initLobby(pi, ctx, configDir);
|
|
676
|
-
|
|
803
|
+
// A switch asked for from the lobby lands back in the lobby, on the session it switched to.
|
|
804
|
+
if (reopenAfterSwitch) {
|
|
805
|
+
reopenAfterSwitch = false;
|
|
806
|
+
showLobby("lobby");
|
|
807
|
+
} else autoOpenLobby();
|
|
677
808
|
});
|
|
678
809
|
pi.on("session_shutdown", () => shutdown());
|
|
679
810
|
pi.on("ui_prompt_start", () => promptStarted());
|
|
@@ -78,6 +78,18 @@ export class SessionChats {
|
|
|
78
78
|
this.now = now;
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
/** A session's file, asking pi's list at once when it is not known yet. */
|
|
82
|
+
async locate(sessionId: string): Promise<string | undefined> {
|
|
83
|
+
const known = this.paths.get(sessionId);
|
|
84
|
+
if (known) return known;
|
|
85
|
+
try {
|
|
86
|
+
for (const session of await this.list()) this.paths.set(session.id, session.path);
|
|
87
|
+
} catch {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
return this.paths.get(sessionId);
|
|
91
|
+
}
|
|
92
|
+
|
|
81
93
|
/** Tell the cache where a session's file is (a background session reports it). */
|
|
82
94
|
remember(sessionId: string, path: string): void {
|
|
83
95
|
this.paths.set(sessionId, path);
|
package/src/lobby/sessions.ts
CHANGED
|
@@ -49,6 +49,9 @@ export interface SessionStart {
|
|
|
49
49
|
|
|
50
50
|
let counter = 0;
|
|
51
51
|
|
|
52
|
+
/** How long a stopped session gets to end before it is killed. */
|
|
53
|
+
export const STOP_GRACE_MS = 5000;
|
|
54
|
+
|
|
52
55
|
export class BackgroundSession {
|
|
53
56
|
readonly key: string;
|
|
54
57
|
readonly name: string;
|
|
@@ -69,6 +72,8 @@ export class BackgroundSession {
|
|
|
69
72
|
private stderr = "";
|
|
70
73
|
private nextId = 0;
|
|
71
74
|
private readonly onChange: () => void;
|
|
75
|
+
private readonly exitWaiters: Array<() => void> = [];
|
|
76
|
+
private killTimer?: ReturnType<typeof setTimeout>;
|
|
72
77
|
|
|
73
78
|
constructor(proc: SessionProcess, start: SessionStart, onChange: () => void = () => {}) {
|
|
74
79
|
counter += 1;
|
|
@@ -98,10 +103,18 @@ export class BackgroundSession {
|
|
|
98
103
|
this.status = "exited";
|
|
99
104
|
this.exitCode = code;
|
|
100
105
|
this.dialogs.length = 0;
|
|
106
|
+
if (this.killTimer) clearTimeout(this.killTimer);
|
|
101
107
|
this.feed.say("note", reason);
|
|
108
|
+
for (const resolve of this.exitWaiters.splice(0)) resolve();
|
|
102
109
|
this.onChange();
|
|
103
110
|
}
|
|
104
111
|
|
|
112
|
+
/** Resolves once the process has ended (at once if it already has). */
|
|
113
|
+
whenExited(): Promise<void> {
|
|
114
|
+
if (!this.alive) return Promise.resolve();
|
|
115
|
+
return new Promise((resolve) => this.exitWaiters.push(resolve));
|
|
116
|
+
}
|
|
117
|
+
|
|
105
118
|
get alive(): boolean {
|
|
106
119
|
return this.status !== "exited";
|
|
107
120
|
}
|
|
@@ -147,8 +160,11 @@ export class BackgroundSession {
|
|
|
147
160
|
this.onChange();
|
|
148
161
|
}
|
|
149
162
|
|
|
150
|
-
/**
|
|
151
|
-
|
|
163
|
+
/**
|
|
164
|
+
* Stop the session: its process ends (its task keeps its state and can be
|
|
165
|
+
* resumed or claimed later). One that does not end within `graceMs` is killed.
|
|
166
|
+
*/
|
|
167
|
+
stop(graceMs = STOP_GRACE_MS): void {
|
|
152
168
|
if (!this.alive) return;
|
|
153
169
|
try {
|
|
154
170
|
this.proc.stdin?.end();
|
|
@@ -160,6 +176,16 @@ export class BackgroundSession {
|
|
|
160
176
|
} catch {
|
|
161
177
|
// Already gone.
|
|
162
178
|
}
|
|
179
|
+
if (this.killTimer || graceMs <= 0) return;
|
|
180
|
+
this.killTimer = setTimeout(() => {
|
|
181
|
+
if (!this.alive) return;
|
|
182
|
+
try {
|
|
183
|
+
this.proc.kill("SIGKILL");
|
|
184
|
+
} catch {
|
|
185
|
+
// Already gone.
|
|
186
|
+
}
|
|
187
|
+
}, graceMs);
|
|
188
|
+
this.killTimer.unref?.();
|
|
163
189
|
}
|
|
164
190
|
|
|
165
191
|
/** Split stdout into JSON lines (LF only, as pi's RPC framing requires) and handle each. */
|
package/src/lobby/tabs/home.ts
CHANGED
|
@@ -94,45 +94,72 @@ export function activityLine(entry: ActivityEntry, width: number, tick: number,
|
|
|
94
94
|
return `${paint(theme, "dim", clock(entry.at))} ${who} ${mark} ${body}`;
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
-
/**
|
|
97
|
+
/**
|
|
98
|
+
* How each speaker is shown: a mark and a name in its colour, and the side of
|
|
99
|
+
* the conversation it speaks from — you on the right, the oracle on the left.
|
|
100
|
+
*/
|
|
98
101
|
export const SPEAKERS = {
|
|
99
|
-
you: { mark: "●", name: "You", color: "accent" },
|
|
100
|
-
oracle: { mark: "◆", name: "Oracle", color: "toolTitle" },
|
|
101
|
-
panel: { mark: "◆", name: "Panel", color: "toolTitle" },
|
|
102
|
-
} as const satisfies Record<string, { mark: string; name: string; color: LobbyColor }>;
|
|
102
|
+
you: { mark: "●", name: "You", color: "accent", side: "right" },
|
|
103
|
+
oracle: { mark: "◆", name: "Oracle", color: "toolTitle", side: "left" },
|
|
104
|
+
panel: { mark: "◆", name: "Panel", color: "toolTitle", side: "left" },
|
|
105
|
+
} as const satisfies Record<string, { mark: string; name: string; color: LobbyColor; side: "left" | "right" }>;
|
|
103
106
|
export type Speaker = keyof typeof SPEAKERS;
|
|
104
107
|
|
|
105
108
|
/** Messages from one speaker within this long of each other share a header. */
|
|
106
109
|
const GROUP_MS = 5 * 60_000;
|
|
107
|
-
/**
|
|
110
|
+
/** The oracle's replies sit under its name, past its mark. */
|
|
108
111
|
const BODY_INDENT = 2;
|
|
112
|
+
/** Your messages take at most this share of the pane, the oracle's this much, so the two sides read apart. */
|
|
113
|
+
const YOU_SHARE = 0.72;
|
|
114
|
+
const ORACLE_SHARE = 0.86;
|
|
115
|
+
/** Narrower panes give every message (nearly) the full width. */
|
|
116
|
+
const NARROW = 48;
|
|
117
|
+
|
|
118
|
+
/** The widest a message from one side may be in a pane `width` wide. */
|
|
119
|
+
function messageWidth(width: number, share: number, margin: number): number {
|
|
120
|
+
return Math.max(1, width < NARROW ? width - margin : Math.floor(width * share));
|
|
121
|
+
}
|
|
109
122
|
|
|
110
|
-
/**
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
123
|
+
/**
|
|
124
|
+
* Who speaks and when: `◆ Oracle ········ 12:04` on the left for the oracle,
|
|
125
|
+
* `12:04 You ●` on the right for you. `note` is the time, or what the
|
|
126
|
+
* speaker is doing (`⠋ writing`).
|
|
127
|
+
*/
|
|
128
|
+
export function speakerLine(speaker: Speaker, width: number, theme?: LobbyTheme, note = ""): string {
|
|
129
|
+
const { mark, name, color, side } = SPEAKERS[speaker];
|
|
130
|
+
const who = paint(theme, color, mark);
|
|
131
|
+
const label = bold(theme, paint(theme, color, name));
|
|
132
|
+
const dimmed = note ? paint(theme, "dim", note) : "";
|
|
133
|
+
if (side === "right") {
|
|
134
|
+
const head = dimmed ? `${dimmed} ${label} ${who}` : `${label} ${who}`;
|
|
135
|
+
return `${" ".repeat(Math.max(0, width - visibleWidth(head)))}${head}`;
|
|
136
|
+
}
|
|
137
|
+
const left = `${who} ${label}`;
|
|
138
|
+
const gap = width - visibleWidth(left) - visibleWidth(dimmed);
|
|
139
|
+
return gap >= 1 && dimmed ? `${left}${" ".repeat(gap)}${dimmed}` : left;
|
|
117
140
|
}
|
|
118
141
|
|
|
119
142
|
/**
|
|
120
|
-
* What you wrote
|
|
121
|
-
*
|
|
143
|
+
* What you wrote: a bubble on the right, only as wide as its longest line (up
|
|
144
|
+
* to `YOU_SHARE` of the pane), your words in the accent colour on pi's
|
|
145
|
+
* user-message background — or, where the theme has no background, closed
|
|
146
|
+
* by a bar in that colour.
|
|
122
147
|
*/
|
|
123
148
|
export function youLines(text: string, width: number, theme?: LobbyTheme): string[] {
|
|
124
|
-
const
|
|
125
|
-
const
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
149
|
+
const most = Math.max(1, messageWidth(width, YOU_SHARE, 4) - 2);
|
|
150
|
+
const lines = wrap(text, most);
|
|
151
|
+
const inner = Math.max(1, ...lines.map((line) => visibleWidth(line)));
|
|
152
|
+
const pad = " ".repeat(Math.max(0, width - inner - 2));
|
|
153
|
+
const bubble = theme?.bg
|
|
154
|
+
? (line: string) => theme.bg!("userMessageBg", ` ${fit(paint(theme, "accent", line), inner)} `)
|
|
155
|
+
: (line: string) => `${fit(paint(theme, "accent", line), inner)} ${paint(theme, "accent", "▐")}`;
|
|
156
|
+
return lines.map((line) => `${pad}${bubble(line)}`);
|
|
130
157
|
}
|
|
131
158
|
|
|
132
|
-
/** The oracle's reply as Markdown, under its name. */
|
|
159
|
+
/** The oracle's reply as Markdown on the left, under its name. */
|
|
133
160
|
function oracleLines(text: string, width: number, theme?: LobbyTheme): string[] {
|
|
134
161
|
const indent = " ".repeat(BODY_INDENT);
|
|
135
|
-
return markdownLines(text, Math.max(1, width - BODY_INDENT), theme).map((line) => (line ? `${indent}${line}` : ""));
|
|
162
|
+
return markdownLines(text, Math.max(1, messageWidth(width, ORACLE_SHARE, 0) - BODY_INDENT), theme).map((line) => (line ? `${indent}${line}` : ""));
|
|
136
163
|
}
|
|
137
164
|
|
|
138
165
|
/** An event in the conversation (a task starting, a comment sent) as a centred rule; failures stand out instead. */
|
|
@@ -146,9 +173,10 @@ export function eventLines(text: string, at: number, width: number, theme?: Lobb
|
|
|
146
173
|
}
|
|
147
174
|
|
|
148
175
|
/**
|
|
149
|
-
* The conversation, oldest first
|
|
150
|
-
*
|
|
151
|
-
*
|
|
176
|
+
* The conversation, oldest first, like a chat: your messages as bubbles on
|
|
177
|
+
* the right, the oracle's replies in Markdown on the left, each turn under a
|
|
178
|
+
* line naming its speaker and time, events as centred rules, and while the
|
|
179
|
+
* oracle works its header says so (streaming the reply under it).
|
|
152
180
|
*/
|
|
153
181
|
export function chatLines(chat: readonly ChatEntry[], width: number, theme?: LobbyTheme, live?: string, busy = false, tick = 0): string[] {
|
|
154
182
|
const lines: string[] = [];
|