@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.
@@ -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
- if (level !== "info") state.ctx.ui.notify(message, level);
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
- const config = loadConfig();
457
- const workflow = config.workflow;
458
- state.planner = new PlanningSession({
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 ?? config.lobby.planningPanel,
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: () => lobbyTopics.bump("planner"),
473
- onRound: () => lobbyTopics.bump("planner"),
474
- }, seed);
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),
@@ -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();
@@ -32,4 +32,6 @@ export interface TaskRow {
32
32
  issue?: number;
33
33
  /** Auto mode is on: the oracle drives it without asking. */
34
34
  auto?: boolean;
35
+ /** Resume can carry it on from the Tasks screen: it is paused, or no running session drives it. */
36
+ resumable?: boolean;
35
37
  }
@@ -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", (_event, ctx) => {
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
- // A new session starts with bot-lobby off unless settings or its own task say otherwise (switch.ts).
76
- setMinimized(!isSubagentProcess() && !startsOn(ctx, configDir));
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
- /** Whether a new session starts on: when settings say so, or it owns a task still under way. */
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
- /** Bot-lobby off for this session (plain pi): the Master prompt is hidden but ownership stays (see switch.ts). */
76
- let minimized = false;
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
+ }