@a-t-h-i/bot-lobby 0.6.27 → 0.6.28
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 +30 -11
- package/package.json +1 -1
- package/src/lobby/host.ts +19 -0
- package/src/lobby/planner.ts +131 -0
- package/src/lobby/quickfix.ts +40 -1
- package/src/lobby/recovery.ts +415 -0
- package/src/lobby/resume.ts +66 -0
- package/src/lobby/runtime.ts +80 -7
- package/src/lobby/service.ts +196 -11
- package/src/lobby/sessions.ts +13 -3
- package/src/lobby/task-rows.ts +2 -0
- package/src/pi/commands.ts +38 -2
- package/src/pi/events.ts +6 -3
- package/src/pi/switch.ts +7 -2
- package/src/pi/ui.ts +9 -2
- package/src/state/plan-history.ts +142 -0
- package/src/state/recovery.ts +170 -0
- package/src/state/themes.ts +127 -0
- package/src/webui/api/index.ts +24 -2
- package/src/webui/api/planner.ts +47 -1
- package/src/webui/api/status.ts +2 -0
- package/src/webui/api/tasks.ts +35 -5
- package/src/webui/api/themes.ts +37 -0
- package/src/webui/command.ts +26 -1
- package/src/webui/dev/fake-service.ts +60 -0
- package/src/webui/dev/fixtures.ts +4 -1
- package/src/webui/palette-core.ts +260 -0
- package/src/webui/protocol.ts +56 -0
- package/src/webui/server.ts +10 -0
- package/webui/dist/assets/index-BzCxEueR.css +2 -0
- package/webui/dist/assets/index-qisGNoCh.js +108 -0
- package/webui/dist/build.json +36 -28
- package/webui/dist/index.html +2 -2
- package/webui/dist/assets/index-BUzOUrih.css +0 -2
- package/webui/dist/assets/index-myC4UiEg.js +0 -108
package/src/lobby/service.ts
CHANGED
|
@@ -24,7 +24,7 @@ import { livePresence } from "../state/presence.ts";
|
|
|
24
24
|
import { archiveTask as archiveTaskOnDisk, deleteTask as deleteTaskOnDisk, listArchivedTasks, restoreTask as restoreTaskOnDisk } from "../state/archive.ts";
|
|
25
25
|
import { discardPlannedTask, listPlannedTasks, type PlannedTask } from "../state/backlog.ts";
|
|
26
26
|
import { readClassifierMetrics, readMetrics } from "../state/metrics.ts";
|
|
27
|
-
import { currentZenTask, taskSnapshot } from "../pi/ui.ts";
|
|
27
|
+
import { applyStatus, currentZenTask, taskSnapshot } from "../pi/ui.ts";
|
|
28
28
|
import { taskName } from "../text.ts";
|
|
29
29
|
import { describeWorkspace } from "../execution/workspace.ts";
|
|
30
30
|
import { stripStartFlags } from "../pi/start-flags.ts";
|
|
@@ -40,7 +40,10 @@ import { SessionChats } from "./session-files.ts";
|
|
|
40
40
|
import { answerMessage, questionnaires, settledQuestions } from "./ask.ts";
|
|
41
41
|
import { askUser } from "../ask/web.ts";
|
|
42
42
|
import type { Asker } from "../ask/types.ts";
|
|
43
|
-
import { PlanningSession, type PlannerSeed } from "./planner.ts";
|
|
43
|
+
import { PlanningSession, type PlannerDeps, type PlannerSeed } from "./planner.ts";
|
|
44
|
+
import { settleInterrupted, type Relaunch } from "./recovery.ts";
|
|
45
|
+
import { carryOnMessage, resumeRoute, RESUMED_DECISION, type Drivers } from "./resume.ts";
|
|
46
|
+
import { archivePlan as archivePlanOnDisk, deletePlan as deletePlanOnDisk, isPlanId, previousPlans, readPlan, type PlanRecord, type PlanSummary } from "../state/plan-history.ts";
|
|
44
47
|
import { applyMasterModel } from "../pi/model-settings.ts";
|
|
45
48
|
import { lobbyTopics } from "./topics.ts";
|
|
46
49
|
import { checkSession } from "../excalidraw/check.ts";
|
|
@@ -93,7 +96,13 @@ export function setServiceState(state: Runtime | undefined): void {
|
|
|
93
96
|
export function lobbyNotify(state: Runtime, message: string, level: "info" | "warning" | "error" = "info"): void {
|
|
94
97
|
lobbyTopics.bump("notices");
|
|
95
98
|
pushNotice(message, level as NoticeLevel);
|
|
96
|
-
|
|
99
|
+
// Work the lobby started may finish after pi replaced the session: its terminal is the new session's then.
|
|
100
|
+
if (level === "info" || state !== serviceState) return;
|
|
101
|
+
try {
|
|
102
|
+
state.ctx.ui.notify(message, level);
|
|
103
|
+
} catch {
|
|
104
|
+
// A replaced session's terminal: the page has the notice.
|
|
105
|
+
}
|
|
97
106
|
}
|
|
98
107
|
|
|
99
108
|
function sessionModel(ctx: ExtensionContext): string | undefined {
|
|
@@ -235,6 +244,7 @@ export function backgroundSessions(): readonly BackgroundSession[] {
|
|
|
235
244
|
function sessionsChanged(): void {
|
|
236
245
|
lobbyTopics.bump("sessions");
|
|
237
246
|
const state = serviceState;
|
|
247
|
+
state?.keeper?.window.schedule();
|
|
238
248
|
for (const session of registry?.sessions ?? []) {
|
|
239
249
|
if (session.sessionId && session.sessionFile) chats.remember(session.sessionId, session.sessionFile);
|
|
240
250
|
const seen = announced.get(session.key) ?? 0;
|
|
@@ -284,6 +294,117 @@ function startSession(state: Runtime, start: { request?: string; plan?: PlannedT
|
|
|
284
294
|
}
|
|
285
295
|
}
|
|
286
296
|
|
|
297
|
+
/** Start again, in the background, a session a stopped window drove; the session, or why not. */
|
|
298
|
+
export function resumeSession(state: Runtime, start: Relaunch): BackgroundSession | string {
|
|
299
|
+
try {
|
|
300
|
+
const session = sessionRegistry().start(state.ctx.cwd, {
|
|
301
|
+
name: start.name,
|
|
302
|
+
projectRoot: state.root,
|
|
303
|
+
onDialog: dialogTracker(state),
|
|
304
|
+
sessionFile: start.sessionFile,
|
|
305
|
+
...(start.message ? { message: start.message } : {}),
|
|
306
|
+
});
|
|
307
|
+
lobbyFeed.log("LOBBY", `carried "${start.name}" (${start.taskId}) on in the background`, "success");
|
|
308
|
+
return session;
|
|
309
|
+
} catch (error) {
|
|
310
|
+
return `could not carry ${start.taskId} on — ${(error as Error).message}`;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** The sessions that could drive a task, as this window sees them. */
|
|
315
|
+
function taskDrivers(state: Runtime): Drivers {
|
|
316
|
+
const mine = backgroundSessions().filter((session) => session.alive && session.projectRoot === state.root);
|
|
317
|
+
return {
|
|
318
|
+
me: state.ctx.sessionManager.getSessionId(),
|
|
319
|
+
background: new Set(mine.flatMap((session) => (session.sessionId ? [session.sessionId] : []))),
|
|
320
|
+
carrying: new Set(mine.flatMap((session) => (session.taskId ? [session.taskId] : []))),
|
|
321
|
+
live: new Set(liveSessions(state).map((session) => session.sessionId)),
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Tasks between a Resume click and their background session starting, so a second click starts nothing. */
|
|
326
|
+
const resuming = new Set<string>();
|
|
327
|
+
|
|
328
|
+
function unpause(state: Runtime, taskId: string): void {
|
|
329
|
+
const task = loadTask(state.root, state.configDir, taskId);
|
|
330
|
+
if (!task?.paused) return;
|
|
331
|
+
task.paused = false;
|
|
332
|
+
task.updatedAt = new Date().toISOString();
|
|
333
|
+
saveTask(state.root, state.configDir, task);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Resume a task from the Tasks screen, leaving this window where it is. A
|
|
338
|
+
* paused task is unpaused where it runs and its oracle told to carry on; one
|
|
339
|
+
* no running session drives carries on in a background session: its own
|
|
340
|
+
* session started again from its file when there is one, else a fresh one
|
|
341
|
+
* that takes it over.
|
|
342
|
+
*/
|
|
343
|
+
async function resumeTask(state: Runtime, taskId: string): Promise<{ notice: string; key?: string }> {
|
|
344
|
+
const task = loadTask(state.root, state.configDir, taskId);
|
|
345
|
+
if (!task) return { notice: `no task ${taskId}` };
|
|
346
|
+
if (resuming.has(taskId)) return { notice: `${taskId} is already being resumed` };
|
|
347
|
+
const route = resumeRoute(task, taskDrivers(state));
|
|
348
|
+
const background = () => backgroundSessions().find((session) => session.alive && session.projectRoot === state.root && ((task.ownerSessionId && session.sessionId === task.ownerSessionId) || session.taskId === taskId));
|
|
349
|
+
switch (route.kind) {
|
|
350
|
+
case "finished":
|
|
351
|
+
return { notice: `${taskId} is ${task.state}; there is nothing to resume` };
|
|
352
|
+
case "running": {
|
|
353
|
+
const session = route.where === "background" ? background() : undefined;
|
|
354
|
+
const where = route.where === "here" ? "in this window" : session ? `in ${session.name}` : "in another terminal";
|
|
355
|
+
return { notice: `${taskId} is already under way ${where}`, ...(session ? { key: session.key } : {}) };
|
|
356
|
+
}
|
|
357
|
+
case "paused": {
|
|
358
|
+
unpause(state, taskId);
|
|
359
|
+
lobbyTopics.bump("tasks");
|
|
360
|
+
const message = carryOnMessage(task, "paused");
|
|
361
|
+
if (route.where === "here") {
|
|
362
|
+
state.pi.sendUserMessage(message, state.ctx.isIdle() ? undefined : { deliverAs: "followUp" });
|
|
363
|
+
try { applyStatus(state.ctx, state.root, state.configDir); } catch {}
|
|
364
|
+
lobbyFeed.log("LOBBY", `resumed ${taskId}`, "success");
|
|
365
|
+
return { notice: `resumed ${taskId} in this window` };
|
|
366
|
+
}
|
|
367
|
+
const session = route.where === "background" ? background() : undefined;
|
|
368
|
+
if (session) {
|
|
369
|
+
session.send(message);
|
|
370
|
+
lobbyFeed.log("LOBBY", `resumed ${taskId} in ${session.name}`, "success");
|
|
371
|
+
return { notice: `resumed ${taskId} in ${session.name}`, key: session.key };
|
|
372
|
+
}
|
|
373
|
+
try {
|
|
374
|
+
sendToInbox(state.root, state.configDir, taskId, message, state.ctx.sessionManager.getSessionId());
|
|
375
|
+
} catch (error) {
|
|
376
|
+
return { notice: `unpaused ${taskId}, but could not tell its oracle — ${(error as Error).message}` };
|
|
377
|
+
}
|
|
378
|
+
lobbyFeed.log("LOBBY", `resumed ${taskId} in another terminal`, "success");
|
|
379
|
+
return { notice: `resumed ${taskId} — the session driving it in another terminal carries on` };
|
|
380
|
+
}
|
|
381
|
+
case "stopped":
|
|
382
|
+
break;
|
|
383
|
+
}
|
|
384
|
+
resuming.add(taskId);
|
|
385
|
+
try {
|
|
386
|
+
const file = task.ownerSessionId ? await chats.locate(task.ownerSessionId) : undefined;
|
|
387
|
+
settleInterrupted(state.root, state.configDir, taskId, new Date(), RESUMED_DECISION);
|
|
388
|
+
// `/bot-lobby carry-on` unpauses it too; done here as well, the Tasks screen shows it at once.
|
|
389
|
+
unpause(state, taskId);
|
|
390
|
+
const session = sessionRegistry().start(state.ctx.cwd, {
|
|
391
|
+
name: task.title,
|
|
392
|
+
projectRoot: state.root,
|
|
393
|
+
onDialog: dialogTracker(state),
|
|
394
|
+
taskId,
|
|
395
|
+
...(file ? { sessionFile: file } : {}),
|
|
396
|
+
message: `/bot-lobby carry-on ${taskId}`,
|
|
397
|
+
}, file ? undefined : sessionModel(state.ctx));
|
|
398
|
+
lobbyFeed.log("LOBBY", `resumed "${task.title}" (${taskId}) in the background`, "success");
|
|
399
|
+
lobbyTopics.bump("tasks");
|
|
400
|
+
return { notice: `resumed ${taskId} in a background session`, key: session.key };
|
|
401
|
+
} catch (error) {
|
|
402
|
+
return { notice: `could not resume ${taskId} — ${(error as Error).message}` };
|
|
403
|
+
} finally {
|
|
404
|
+
resuming.delete(taskId);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
287
408
|
/** Leave a message for a task another terminal's session drives; its owner passes it to the oracle. */
|
|
288
409
|
function sendToTask(state: Runtime, taskId: string, text: string): string {
|
|
289
410
|
const task = loadTask(state.root, state.configDir, taskId);
|
|
@@ -452,16 +573,74 @@ export function seatProfile(state: Runtime, member: PanelMember) {
|
|
|
452
573
|
}
|
|
453
574
|
|
|
454
575
|
function newPlanner(state: Runtime, seed?: PlannerSeed, seats?: readonly PanelMember[]): PlanningSession {
|
|
576
|
+
// The session left behind stays among the project's previous plans, as it stood.
|
|
577
|
+
state.keeper?.planner.flush();
|
|
578
|
+
state.planner?.cancel();
|
|
579
|
+
state.planner = new PlanningSession(plannerDeps(state, seats ?? loadConfig().lobby.planningPanel), seed);
|
|
580
|
+
state.keeper?.planner.schedule();
|
|
581
|
+
return state.planner;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/** Previous plans: the project's planning sessions never saved as a task, other than the one on screen. */
|
|
585
|
+
function plansBefore(state: Runtime, archived: boolean): PlanSummary[] {
|
|
586
|
+
return previousPlans(state.root, state.configDir, { archived, except: state.planner?.id });
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/** A previous plan to read: never the one on screen, and never one saved as a task. */
|
|
590
|
+
function previousPlan(state: Runtime, id: string): PlanRecord {
|
|
591
|
+
const record = isPlanId(id) ? readPlan(state.root, state.configDir, id) : undefined;
|
|
592
|
+
if (!record) throw new Error(`no plan ${id}`);
|
|
593
|
+
if (record.savedAs?.length) throw new Error(`${id} was saved as ${record.savedAs.join(", ")}`);
|
|
594
|
+
return record;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function onScreen(state: Runtime, id: string): void {
|
|
598
|
+
if (state.planner?.id === id) throw new Error("that is the plan on screen — start a new plan first");
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function archivePrevious(state: Runtime, id: string, archived: boolean): string {
|
|
602
|
+
onScreen(state, id);
|
|
603
|
+
const record = archivePlanOnDisk(state.root, state.configDir, id, archived);
|
|
604
|
+
lobbyTopics.bump("planner");
|
|
605
|
+
return archived ? `archived "${record.title}" — show archived plans to bring it back` : `"${record.title}" is back among the previous plans`;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function deletePrevious(state: Runtime, id: string): string {
|
|
609
|
+
onScreen(state, id);
|
|
610
|
+
const record = previousPlan(state, id);
|
|
611
|
+
deletePlanOnDisk(state.root, state.configDir, id);
|
|
612
|
+
lobbyTopics.bump("planner");
|
|
613
|
+
return `deleted "${record.title}" for good`;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/** Carry a previous plan on: it becomes the plan on screen, and the one there joins the previous plans. */
|
|
617
|
+
function reopenPlan(state: Runtime, id: string): string {
|
|
618
|
+
if (state.planner?.busy) return "the panel is still thinking — wait for the round to finish";
|
|
619
|
+
onScreen(state, id);
|
|
620
|
+
const record = previousPlan(state, id);
|
|
621
|
+
state.keeper?.planner.flush();
|
|
455
622
|
state.planner?.cancel();
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
state.planner
|
|
623
|
+
state.planner = PlanningSession.restore(plannerDeps(state, record.snapshot.seats), { ...record.snapshot, id: record.id, running: false });
|
|
624
|
+
if (record.archivedAt) archivePlanOnDisk(state.root, state.configDir, id, false);
|
|
625
|
+
state.keeper?.planner.schedule();
|
|
626
|
+
lobbyTopics.bump("planner");
|
|
627
|
+
return `carrying on "${record.title}"`;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/** What a planning session in this window runs with; every change is kept on disk, so it carries on after a crash. */
|
|
631
|
+
export function plannerDeps(state: Runtime, seats: readonly PanelMember[]): PlannerDeps {
|
|
632
|
+
const workflow = loadConfig().workflow;
|
|
633
|
+
const changed = () => {
|
|
634
|
+
lobbyTopics.bump("planner");
|
|
635
|
+
state.keeper?.planner.schedule();
|
|
636
|
+
};
|
|
637
|
+
return {
|
|
459
638
|
cwd: state.ctx.cwd,
|
|
460
639
|
root: state.root,
|
|
461
640
|
configDir: state.configDir,
|
|
462
641
|
profile: () => lobbyProfile(state, "planner"),
|
|
463
642
|
memberProfile: (member) => seatProfile(state, member),
|
|
464
|
-
panel: seats
|
|
643
|
+
panel: seats,
|
|
465
644
|
maxRounds: () => loadConfig().lobby.maxPlanningRounds,
|
|
466
645
|
classifier: classifier(),
|
|
467
646
|
hints: hintsFor({ cwd: state.ctx.cwd, root: state.root, configDir: state.configDir }),
|
|
@@ -469,10 +648,9 @@ function newPlanner(state: Runtime, seed?: PlannerSeed, seats?: readonly PanelMe
|
|
|
469
648
|
stallTimeoutMs: workflow.stallTimeoutMs,
|
|
470
649
|
toolStallTimeoutMs: workflow.toolStallTimeoutMs,
|
|
471
650
|
feed: lobbyFeed,
|
|
472
|
-
onChange:
|
|
473
|
-
onRound:
|
|
474
|
-
}
|
|
475
|
-
return state.planner;
|
|
651
|
+
onChange: changed,
|
|
652
|
+
onRound: changed,
|
|
653
|
+
};
|
|
476
654
|
}
|
|
477
655
|
|
|
478
656
|
/** The questionnaire in the page; tests swap it. */
|
|
@@ -610,6 +788,11 @@ export function createLobbyService(state: Runtime): LobbyService {
|
|
|
610
788
|
quickfix: state.quickfix,
|
|
611
789
|
planner: () => state.planner,
|
|
612
790
|
newPlanner: (seed, seats) => newPlanner(state, seed, seats),
|
|
791
|
+
previousPlans: (archived) => plansBefore(state, archived),
|
|
792
|
+
previousPlan: (id) => previousPlan(state, id),
|
|
793
|
+
archivePlan: (id, archived) => archivePrevious(state, id, archived),
|
|
794
|
+
deletePlan: (id) => deletePrevious(state, id),
|
|
795
|
+
reopenPlan: (id) => reopenPlan(state, id),
|
|
613
796
|
answerPanel: () => answerPanel(state),
|
|
614
797
|
savePlan: () => savePlan(state),
|
|
615
798
|
defaultPanel: () => loadConfig().lobby.planningPanel,
|
|
@@ -638,6 +821,7 @@ export function createLobbyService(state: Runtime): LobbyService {
|
|
|
638
821
|
}
|
|
639
822
|
},
|
|
640
823
|
issuesEnabled: () => loadConfig().lobby.issues,
|
|
824
|
+
recovered: () => state.recovered,
|
|
641
825
|
seatLabel: (member) => {
|
|
642
826
|
const profile = seatProfile(state, member);
|
|
643
827
|
return `${profile.model ?? "session model"} · ${profile.thinking}`;
|
|
@@ -663,6 +847,7 @@ export function createLobbyService(state: Runtime): LobbyService {
|
|
|
663
847
|
isAuto: (taskId) => autoFor(state, taskId),
|
|
664
848
|
setAuto: (taskId, on) => switchAuto(state, taskId, on),
|
|
665
849
|
sendToTask: (taskId, text) => sendToTask(state, taskId, text),
|
|
850
|
+
resumeTask: (taskId) => resumeTask(state, taskId),
|
|
666
851
|
sendToSession: (sessionId, text) => sendToSession(state, sessionId, text),
|
|
667
852
|
liveSessions: () => liveSessions(state),
|
|
668
853
|
switchTo: (target) => switchTo(state, target),
|
package/src/lobby/sessions.ts
CHANGED
|
@@ -48,6 +48,12 @@ export interface SessionStart {
|
|
|
48
48
|
request?: string;
|
|
49
49
|
planId?: string;
|
|
50
50
|
auto?: boolean;
|
|
51
|
+
/** Carry on this saved session instead of starting a new one (after the window that drove it stopped). */
|
|
52
|
+
sessionFile?: string;
|
|
53
|
+
/** Sent once it is up, as the user's message (the nudge that picks an interrupted turn back up). */
|
|
54
|
+
message?: string;
|
|
55
|
+
/** The task it was started to carry on (resumed from the Tasks screen). */
|
|
56
|
+
taskId?: string;
|
|
51
57
|
}
|
|
52
58
|
|
|
53
59
|
let counter = 0;
|
|
@@ -61,6 +67,8 @@ export class BackgroundSession {
|
|
|
61
67
|
readonly name: string;
|
|
62
68
|
/** The planned task it was started from. */
|
|
63
69
|
readonly planId?: string;
|
|
70
|
+
/** The task it was started to carry on. */
|
|
71
|
+
readonly taskId?: string;
|
|
64
72
|
readonly feed = new LobbyFeed();
|
|
65
73
|
readonly startedAt = Date.now();
|
|
66
74
|
status: SessionStatus = "starting";
|
|
@@ -86,6 +94,7 @@ export class BackgroundSession {
|
|
|
86
94
|
this.name = start.name;
|
|
87
95
|
this.projectRoot = start.projectRoot;
|
|
88
96
|
if (start.planId) this.planId = start.planId;
|
|
97
|
+
if (start.taskId) this.taskId = start.taskId;
|
|
89
98
|
this.onDialog = start.onDialog ?? (() => {});
|
|
90
99
|
this.proc = proc;
|
|
91
100
|
this.onChange = onChange;
|
|
@@ -103,6 +112,7 @@ export class BackgroundSession {
|
|
|
103
112
|
this.write({ type: "get_state" });
|
|
104
113
|
if (start.planId) this.prompt(`/bot-lobby start-plan ${start.planId}${start.auto ? " auto" : ""}`);
|
|
105
114
|
else if (start.request) this.prompt(`/bot-lobby --task ${start.auto ? "--auto " : ""}${start.request}`);
|
|
115
|
+
else if (start.message) this.prompt(start.message);
|
|
106
116
|
}
|
|
107
117
|
|
|
108
118
|
private ended(code: number | null, reason: string): void {
|
|
@@ -277,8 +287,8 @@ export function extensionArgs(argv: readonly string[] = process.argv): string[]
|
|
|
277
287
|
return forwarded;
|
|
278
288
|
}
|
|
279
289
|
|
|
280
|
-
export function sessionArgs(name: string, model?: string, argv: readonly string[] = process.argv): string[] {
|
|
281
|
-
return ["--mode", "rpc", "--name", name, ...(model ? ["--model", model] : []), ...extensionArgs(argv)];
|
|
290
|
+
export function sessionArgs(name: string, model?: string, argv: readonly string[] = process.argv, sessionFile?: string): string[] {
|
|
291
|
+
return ["--mode", "rpc", ...(sessionFile ? ["--session", sessionFile] : []), "--name", name, ...(model ? ["--model", model] : []), ...extensionArgs(argv)];
|
|
282
292
|
}
|
|
283
293
|
|
|
284
294
|
const POSIX = process.platform !== "win32";
|
|
@@ -307,7 +317,7 @@ export class SessionRegistry {
|
|
|
307
317
|
}
|
|
308
318
|
|
|
309
319
|
start(cwd: string, start: SessionStart, model?: string): BackgroundSession {
|
|
310
|
-
const proc = this.launcher(sessionArgs(start.name, model), cwd);
|
|
320
|
+
const proc = this.launcher(sessionArgs(start.name, model, process.argv, start.sessionFile), cwd);
|
|
311
321
|
const session = new BackgroundSession(proc, start, this.onChange);
|
|
312
322
|
this.sessions.push(session);
|
|
313
323
|
this.prune();
|
package/src/lobby/task-rows.ts
CHANGED
package/src/pi/commands.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { hasScoutThinking, SCOUT_THINKING } from "../schemas/configuration.ts";
|
|
|
7
7
|
import {
|
|
8
8
|
activeTask,
|
|
9
9
|
claimTask,
|
|
10
|
+
loadTask,
|
|
10
11
|
ownedTask,
|
|
11
12
|
ownerlessTask,
|
|
12
13
|
saveTask,
|
|
@@ -33,6 +34,8 @@ import { budgetLine, budgetState, parseMinutes, readBudget, setBudget, startCloc
|
|
|
33
34
|
import { qaStillDue } from "../workflow/track.ts";
|
|
34
35
|
import type { GitIsolation } from "../schemas/configuration.ts";
|
|
35
36
|
import { START_FLAG } from "./start-flags.ts";
|
|
37
|
+
import { livePresence } from "../state/presence.ts";
|
|
38
|
+
import { carryOnMessage } from "../lobby/resume.ts";
|
|
36
39
|
|
|
37
40
|
const HELP = [
|
|
38
41
|
"/bot-lobby Open the web lobby in your browser: tasks, plan, quick fix, metrics",
|
|
@@ -54,6 +57,7 @@ const HELP = [
|
|
|
54
57
|
"/bot-lobby config Show effective configuration",
|
|
55
58
|
"/bot-lobby on|off Turn bot-lobby on or off for this session (ctrl+shift+m); a new session starts off",
|
|
56
59
|
"/bot-lobby claim <taskId> Take ownership of an orphaned task",
|
|
60
|
+
"/bot-lobby carry-on <taskId> Take over a task no running session drives, unpause it and have the oracle carry it on (the Tasks screen's Resume)",
|
|
57
61
|
"/bot-lobby auto [on|off] Auto mode: the oracle drives this session's task without asking (alt+g)",
|
|
58
62
|
"/bot-lobby start-plan PLAN-… [auto] Start a planned task here; its agreed plan needs no approval",
|
|
59
63
|
"/bot-lobby switch <session.jsonl> Run a saved session in this window (the web lobby's session browser uses it)",
|
|
@@ -62,10 +66,10 @@ const HELP = [
|
|
|
62
66
|
].join("\n");
|
|
63
67
|
|
|
64
68
|
/** Subcommands only win when no free-form text follows (so tasks still start). */
|
|
65
|
-
const SUBCOMMANDS = new Set(["lobby", "help", "status", "runs", "tasks", "pause", "resume", "cancel", "approve", "amend", "decline", "accept", "budget", "knowledge", "config", "settings", "on", "off", "minimize", "restore", "claim", "auto", "start-plan", "switch", "web"]);
|
|
69
|
+
const SUBCOMMANDS = new Set(["lobby", "help", "status", "runs", "tasks", "pause", "resume", "cancel", "approve", "amend", "decline", "accept", "budget", "knowledge", "config", "settings", "on", "off", "minimize", "restore", "claim", "carry-on", "auto", "start-plan", "switch", "web"]);
|
|
66
70
|
|
|
67
71
|
/** Subcommands that act on a task or open the lobby turn bot-lobby on first; the rest work while it is off. */
|
|
68
|
-
const TURNS_ON = new Set(["lobby", "settings", "resume", "approve", "amend", "decline", "accept", "claim", "auto", "start-plan", "switch", "web"]);
|
|
72
|
+
const TURNS_ON = new Set(["lobby", "settings", "resume", "approve", "amend", "decline", "accept", "claim", "carry-on", "auto", "start-plan", "switch", "web"]);
|
|
69
73
|
|
|
70
74
|
/** `Task-Change-Table-Font-27-09-2026`, or an older `TASK-add-login` id. */
|
|
71
75
|
function isTaskId(value: string | undefined): boolean {
|
|
@@ -209,6 +213,36 @@ function claimTaskCommand(ctx: ExtensionCommandContext, configDir: string, taskI
|
|
|
209
213
|
applyStatus(ctx, root, configDir);
|
|
210
214
|
ctx.ui.notify(`bot-lobby now owns ${claimed.id}.`, "info");
|
|
211
215
|
}
|
|
216
|
+
/**
|
|
217
|
+
* `/bot-lobby carry-on <taskId>`: take over a task no running session drives
|
|
218
|
+
* (or this session's own, when it is started again from its file), unpause it
|
|
219
|
+
* and have the oracle carry it on. A background session resumed from the
|
|
220
|
+
* Tasks screen runs it as its first message.
|
|
221
|
+
*/
|
|
222
|
+
function carryOnCommand(pi: ExtensionAPI, ctx: ExtensionCommandContext, configDir: string, taskId?: string): void {
|
|
223
|
+
if (!isTaskId(taskId)) return ctx.ui.notify("Usage: /bot-lobby carry-on <taskId>", "warning");
|
|
224
|
+
const root = detectProjectRoot(ctx.cwd, configDir);
|
|
225
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
226
|
+
const current = ownedTask(root, configDir, sessionId);
|
|
227
|
+
if (current && current.id !== taskId && !TERMINAL_STATES.includes(current.state)) {
|
|
228
|
+
return ctx.ui.notify(`bot-lobby ${current.id} is already active in this session; cancel it before carrying ${taskId} on.`, "warning");
|
|
229
|
+
}
|
|
230
|
+
const task = loadTask(root, configDir, taskId!);
|
|
231
|
+
if (!task) return ctx.ui.notify(`No task ${taskId}.`, "warning");
|
|
232
|
+
if (TERMINAL_STATES.includes(task.state)) return ctx.ui.notify(`${task.id} is already ${task.state}.`, "warning");
|
|
233
|
+
const owner = task.ownerSessionId;
|
|
234
|
+
if (owner && owner !== sessionId && livePresence(root, configDir).some((presence) => presence.sessionId === owner)) {
|
|
235
|
+
return ctx.ui.notify(`${task.id} is driven by a session that is still running; carry it on there.`, "warning");
|
|
236
|
+
}
|
|
237
|
+
task.ownerSessionId = sessionId;
|
|
238
|
+
task.paused = false;
|
|
239
|
+
task.updatedAt = new Date().toISOString();
|
|
240
|
+
saveTask(root, configDir, task);
|
|
241
|
+
applyStatus(ctx, root, configDir);
|
|
242
|
+
ctx.ui.notify(`bot-lobby carries ${task.id} on in this session.`, "info");
|
|
243
|
+
pi.sendUserMessage(carryOnMessage(task, "stopped"), ctx.isIdle() ? undefined : { deliverAs: "followUp" });
|
|
244
|
+
}
|
|
245
|
+
|
|
212
246
|
function answerProposal(
|
|
213
247
|
ctx: ExtensionCommandContext,
|
|
214
248
|
configDir: string,
|
|
@@ -391,6 +425,8 @@ export function registerCommands(pi: ExtensionAPI, configDir: string): void {
|
|
|
391
425
|
return turnOn(pi, ctx, configDir);
|
|
392
426
|
case "claim":
|
|
393
427
|
return claimTaskCommand(ctx, configDir, rest[0]);
|
|
428
|
+
case "carry-on":
|
|
429
|
+
return carryOnCommand(pi, ctx, configDir, rest[0]);
|
|
394
430
|
case "auto":
|
|
395
431
|
return autoCommand(ctx, configDir, rest[0]);
|
|
396
432
|
case "switch":
|
package/src/pi/events.ts
CHANGED
|
@@ -68,12 +68,15 @@ export function masterTaskContext(task: Task, comments: readonly PlanComment[] =
|
|
|
68
68
|
export function registerLifecycle(pi: ExtensionAPI, configDir: string): void {
|
|
69
69
|
// Web tools the oracle put away for the task in hand, given back when it ends.
|
|
70
70
|
let hiddenWeb: string[] = [];
|
|
71
|
-
pi.on("session_start", (
|
|
71
|
+
pi.on("session_start", (event, ctx) => {
|
|
72
72
|
// Master-only: subagents keep their own --tools allowlist (see quiet.ts).
|
|
73
73
|
if (!isSubagentProcess()) registerQuietTools(pi);
|
|
74
74
|
const root = detectProjectRoot(ctx.cwd, configDir);
|
|
75
|
-
//
|
|
76
|
-
|
|
75
|
+
// pi starting decides afresh: off unless settings or the session's own task say otherwise (switch.ts).
|
|
76
|
+
// A session replaced in this window (switched to, new, forked, reloaded) keeps bot-lobby as it was,
|
|
77
|
+
// so switching sessions from the lobby never leaves the page behind; it turns on for a task under way.
|
|
78
|
+
const fresh = !event?.reason || event.reason === "startup";
|
|
79
|
+
setMinimized(!isSubagentProcess() && (fresh || isMinimized()) && !startsOn(ctx, configDir));
|
|
77
80
|
applyStatus(ctx, root, configDir);
|
|
78
81
|
});
|
|
79
82
|
|
package/src/pi/switch.ts
CHANGED
|
@@ -12,6 +12,7 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
|
|
|
12
12
|
import { Key } from "@earendil-works/pi-tui";
|
|
13
13
|
import { currentLobbyService, lobbyWorkRunning, servesPage, startLobbyService, stopLobbyService } from "../lobby/runtime.ts";
|
|
14
14
|
import { activeTask } from "../state/persistence.ts";
|
|
15
|
+
import { workToCarryOn } from "../lobby/recovery.ts";
|
|
15
16
|
import { detectProjectRoot, loadConfig } from "../state/project.ts";
|
|
16
17
|
import { stopWebServer, webSessionStarted } from "../webui/command.ts";
|
|
17
18
|
import { isSubagentProcess } from "./quiet.ts";
|
|
@@ -23,11 +24,15 @@ export function isOn(): boolean {
|
|
|
23
24
|
return !isMinimized();
|
|
24
25
|
}
|
|
25
26
|
|
|
26
|
-
/**
|
|
27
|
+
/**
|
|
28
|
+
* Whether a new session starts on: when settings say so, when it owns a task
|
|
29
|
+
* still under way, or when a window in the project stopped unexpectedly and
|
|
30
|
+
* its work is waiting to carry on.
|
|
31
|
+
*/
|
|
27
32
|
export function startsOn(ctx: ExtensionContext, configDir: string): boolean {
|
|
28
33
|
if (loadConfig().lobby.startOn) return true;
|
|
29
34
|
const root = detectProjectRoot(ctx.cwd, configDir);
|
|
30
|
-
return Boolean(activeTask(root, configDir, ctx.sessionManager.getSessionId()));
|
|
35
|
+
return Boolean(activeTask(root, configDir, ctx.sessionManager.getSessionId())) || (servesPage(ctx) && workToCarryOn(root, configDir));
|
|
31
36
|
}
|
|
32
37
|
|
|
33
38
|
/** Turn bot-lobby on: the oracle's prompt comes back and the web lobby starts (opening the browser if settings say so). */
|
package/src/pi/ui.ts
CHANGED
|
@@ -72,8 +72,14 @@ function setZenState(task: Task | undefined, live: AgentRun[]): void {
|
|
|
72
72
|
zenState = { task, live, runs: mergeRuns(persistedRuns(task), live) };
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
/**
|
|
76
|
-
|
|
75
|
+
/**
|
|
76
|
+
* Bot-lobby off for this session (plain pi): the Master prompt is hidden but
|
|
77
|
+
* ownership stays (see switch.ts). Kept on the process, not the module: a
|
|
78
|
+
* reload imports the extension afresh, and on or off must outlive it.
|
|
79
|
+
*/
|
|
80
|
+
const SWITCH = Symbol.for("bot-lobby.minimized");
|
|
81
|
+
const processState = globalThis as { [SWITCH]?: boolean };
|
|
82
|
+
let minimized = processState[SWITCH] ?? false;
|
|
77
83
|
|
|
78
84
|
export function isMinimized(): boolean {
|
|
79
85
|
return minimized;
|
|
@@ -81,6 +87,7 @@ export function isMinimized(): boolean {
|
|
|
81
87
|
|
|
82
88
|
export function setMinimized(value: boolean): void {
|
|
83
89
|
minimized = value;
|
|
90
|
+
processState[SWITCH] = value;
|
|
84
91
|
}
|
|
85
92
|
|
|
86
93
|
/**
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every planning session in a project, kept as it goes (one file each under
|
|
3
|
+
* `planning/`): the conversation, the draft and the panel, so a plan the user
|
|
4
|
+
* walked away from (a new plan, a closed window) can be read again, carried
|
|
5
|
+
* on, archived or deleted. A session saved as a pending task is flagged with
|
|
6
|
+
* the tasks it became and leaves the previous plans; its record stays.
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { dataRoot } from "./project.ts";
|
|
11
|
+
import type { PlanningSnapshot } from "../lobby/planner.ts";
|
|
12
|
+
|
|
13
|
+
export interface PlanRecord {
|
|
14
|
+
id: string;
|
|
15
|
+
title: string;
|
|
16
|
+
createdAt: string;
|
|
17
|
+
updatedAt: string;
|
|
18
|
+
/** Put aside by the user: listed only with the archived plans. */
|
|
19
|
+
archivedAt?: string;
|
|
20
|
+
/** The pending tasks it was saved as: such a plan is no longer a previous plan. */
|
|
21
|
+
savedAs?: string[];
|
|
22
|
+
snapshot: PlanningSnapshot;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** What a list of previous plans shows of each. */
|
|
26
|
+
export interface PlanSummary {
|
|
27
|
+
id: string;
|
|
28
|
+
title: string;
|
|
29
|
+
createdAt: string;
|
|
30
|
+
updatedAt: string;
|
|
31
|
+
archivedAt?: string;
|
|
32
|
+
/** Messages in its conversation. */
|
|
33
|
+
messages: number;
|
|
34
|
+
rounds: number;
|
|
35
|
+
hasDraft: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const PLAN_ID = /^PS-[a-z0-9]{4,32}$/;
|
|
39
|
+
|
|
40
|
+
export function isPlanId(id: string): boolean {
|
|
41
|
+
return PLAN_ID.test(id);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function planningDir(root: string, configDir: string): string {
|
|
45
|
+
return join(dataRoot(root, configDir), "planning");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function recordPath(root: string, configDir: string, id: string): string {
|
|
49
|
+
if (!isPlanId(id)) throw new Error(`no plan ${id}`);
|
|
50
|
+
return join(planningDir(root, configDir), `${id}.json`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** A title for a session: the oracle's, else the start of the idea. */
|
|
54
|
+
export function planTitle(snapshot: PlanningSnapshot): string {
|
|
55
|
+
const words = snapshot.reply?.title ?? snapshot.seed?.issue.title ?? snapshot.messages.find((message) => message.role === "you")?.text ?? "";
|
|
56
|
+
const line = words.split("\n")[0]!.replace(/\s+/g, " ").trim();
|
|
57
|
+
return (line.length > 80 ? `${line.slice(0, 79)}…` : line) || "Untitled plan";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function readPlan(root: string, configDir: string, id: string): PlanRecord | undefined {
|
|
61
|
+
try {
|
|
62
|
+
const record = JSON.parse(readFileSync(recordPath(root, configDir, id), "utf8")) as PlanRecord;
|
|
63
|
+
return record && record.id === id && record.snapshot && Array.isArray(record.snapshot.messages) ? record : undefined;
|
|
64
|
+
} catch {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function write(root: string, configDir: string, record: PlanRecord): void {
|
|
70
|
+
const path = recordPath(root, configDir, record.id);
|
|
71
|
+
mkdirSync(planningDir(root, configDir), { recursive: true });
|
|
72
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
73
|
+
writeFileSync(tmp, JSON.stringify(record), "utf8");
|
|
74
|
+
renameSync(tmp, path);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Keep a session as it stands. One that has not started (no idea, no issue)
|
|
79
|
+
* is not kept; saving it as a task flags it, for good.
|
|
80
|
+
*/
|
|
81
|
+
export function keepPlan(root: string, configDir: string, id: string, createdAt: number, snapshot: PlanningSnapshot, now = new Date()): void {
|
|
82
|
+
if (!isPlanId(id) || (snapshot.messages.length === 0 && !snapshot.seed)) return;
|
|
83
|
+
const previous = readPlan(root, configDir, id);
|
|
84
|
+
const saved = [...new Set([...(previous?.savedAs ?? []), ...(snapshot.saved ? [snapshot.saved.id] : []), ...snapshot.savedParts.map((part) => part.id)])];
|
|
85
|
+
write(root, configDir, {
|
|
86
|
+
id,
|
|
87
|
+
title: planTitle(snapshot),
|
|
88
|
+
createdAt: previous?.createdAt ?? new Date(createdAt).toISOString(),
|
|
89
|
+
updatedAt: now.toISOString(),
|
|
90
|
+
...(previous?.archivedAt ? { archivedAt: previous.archivedAt } : {}),
|
|
91
|
+
...(saved.length > 0 ? { savedAs: saved } : {}),
|
|
92
|
+
snapshot: { ...snapshot, running: false },
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function summary(record: PlanRecord): PlanSummary {
|
|
97
|
+
return {
|
|
98
|
+
id: record.id,
|
|
99
|
+
title: record.title,
|
|
100
|
+
createdAt: record.createdAt,
|
|
101
|
+
updatedAt: record.updatedAt,
|
|
102
|
+
...(record.archivedAt ? { archivedAt: record.archivedAt } : {}),
|
|
103
|
+
messages: record.snapshot.messages.length,
|
|
104
|
+
rounds: record.snapshot.turns,
|
|
105
|
+
hasDraft: Boolean(record.snapshot.reply?.plan),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The previous plans: never saved as a task, newest first; the archived ones
|
|
111
|
+
* only when asked for, and the session on screen (`except`) never.
|
|
112
|
+
*/
|
|
113
|
+
export function previousPlans(root: string, configDir: string, options: { archived?: boolean; except?: string } = {}): PlanSummary[] {
|
|
114
|
+
const dir = planningDir(root, configDir);
|
|
115
|
+
if (!existsSync(dir)) return [];
|
|
116
|
+
const found: PlanSummary[] = [];
|
|
117
|
+
for (const file of readdirSync(dir)) {
|
|
118
|
+
const id = file.replace(/\.json$/, "");
|
|
119
|
+
if (!file.endsWith(".json") || !isPlanId(id) || id === options.except) continue;
|
|
120
|
+
const record = readPlan(root, configDir, id);
|
|
121
|
+
if (!record || record.savedAs?.length) continue;
|
|
122
|
+
if (Boolean(record.archivedAt) !== Boolean(options.archived)) continue;
|
|
123
|
+
found.push(summary(record));
|
|
124
|
+
}
|
|
125
|
+
return found.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Put a previous plan aside, or bring it back. */
|
|
129
|
+
export function archivePlan(root: string, configDir: string, id: string, archived: boolean, now = new Date()): PlanRecord {
|
|
130
|
+
const record = readPlan(root, configDir, id);
|
|
131
|
+
if (!record) throw new Error(`no plan ${id}`);
|
|
132
|
+
if (record.savedAs?.length) throw new Error(`${id} was saved as a task`);
|
|
133
|
+
const { archivedAt: _was, ...rest } = record;
|
|
134
|
+
const next: PlanRecord = archived ? { ...rest, archivedAt: now.toISOString() } : rest;
|
|
135
|
+
write(root, configDir, next);
|
|
136
|
+
return next;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function deletePlan(root: string, configDir: string, id: string): void {
|
|
140
|
+
if (!readPlan(root, configDir, id)) throw new Error(`no plan ${id}`);
|
|
141
|
+
rmSync(recordPath(root, configDir, id), { force: true });
|
|
142
|
+
}
|