@a-t-h-i/bot-lobby 0.6.26 → 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.
Files changed (48) hide show
  1. package/README.md +36 -11
  2. package/package.json +1 -1
  3. package/prompts/master.md +24 -3
  4. package/prompts/qa.md +139 -77
  5. package/prompts/reviewer.md +4 -0
  6. package/src/classifier/qa-risk.ts +537 -0
  7. package/src/lobby/host.ts +19 -0
  8. package/src/lobby/planner.ts +145 -0
  9. package/src/lobby/quickfix.ts +40 -1
  10. package/src/lobby/recovery.ts +415 -0
  11. package/src/lobby/resume.ts +66 -0
  12. package/src/lobby/runtime.ts +80 -7
  13. package/src/lobby/service.ts +196 -11
  14. package/src/lobby/sessions.ts +13 -3
  15. package/src/lobby/task-rows.ts +2 -0
  16. package/src/master/decisions.ts +5 -2
  17. package/src/master/master.ts +6 -0
  18. package/src/pi/commands.ts +38 -2
  19. package/src/pi/events.ts +10 -6
  20. package/src/pi/start-task.ts +31 -4
  21. package/src/pi/switch.ts +7 -2
  22. package/src/pi/tools.ts +3 -0
  23. package/src/pi/ui.ts +9 -2
  24. package/src/schemas/configuration.ts +2 -2
  25. package/src/schemas/task.ts +32 -0
  26. package/src/state/backlog.ts +19 -3
  27. package/src/state/plan-history.ts +142 -0
  28. package/src/state/recovery.ts +170 -0
  29. package/src/state/themes.ts +127 -0
  30. package/src/webui/api/index.ts +24 -2
  31. package/src/webui/api/planner.ts +47 -1
  32. package/src/webui/api/status.ts +2 -0
  33. package/src/webui/api/tasks.ts +36 -5
  34. package/src/webui/api/themes.ts +37 -0
  35. package/src/webui/command.ts +26 -1
  36. package/src/webui/dev/fake-service.ts +60 -0
  37. package/src/webui/dev/fixtures.ts +4 -1
  38. package/src/webui/palette-core.ts +260 -0
  39. package/src/webui/protocol.ts +59 -0
  40. package/src/webui/server.ts +10 -0
  41. package/src/workflow/track.ts +46 -7
  42. package/src/workflow/workflow.ts +196 -34
  43. package/webui/dist/assets/index-BzCxEueR.css +2 -0
  44. package/webui/dist/assets/index-qisGNoCh.js +108 -0
  45. package/webui/dist/build.json +51 -35
  46. package/webui/dist/index.html +2 -2
  47. package/webui/dist/assets/index-C1IwecNS.css +0 -2
  48. package/webui/dist/assets/index-C4Ww36TW.js +0 -105
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Resuming a task from the Tasks screen, without moving this window. A paused
3
+ * task is unpaused where it runs (this window, a background session, another
4
+ * terminal) and its oracle told to carry on. A task no running session drives
5
+ * carries on in a new background session: its old session started again from
6
+ * its file when there is one (the oracle keeps its conversation), else a fresh
7
+ * session that takes the task over (`/bot-lobby carry-on`).
8
+ */
9
+ import { TERMINAL_STATES, type Task } from "../schemas/task.ts";
10
+
11
+ /** The sessions that could be driving a task, as this window sees them. */
12
+ export interface Drivers {
13
+ /** This window's session. */
14
+ me?: string;
15
+ /** Sessions this window runs in the background, alive. */
16
+ background: ReadonlySet<string>;
17
+ /** Tasks a background session of this window was started to carry on (it may not have its id yet). */
18
+ carrying: ReadonlySet<string>;
19
+ /** Sessions running in other terminals. */
20
+ live: ReadonlySet<string>;
21
+ }
22
+
23
+ export type Where = "here" | "background" | "elsewhere";
24
+
25
+ export type ResumeRoute =
26
+ | { kind: "finished" }
27
+ /** Under way and not paused: nothing to resume. */
28
+ | { kind: "running"; where: Where }
29
+ | { kind: "paused"; where: Where }
30
+ /** No running session drives it. */
31
+ | { kind: "stopped" };
32
+
33
+ function drivenFrom(task: Task, drivers: Drivers): Where | undefined {
34
+ const owner = task.ownerSessionId;
35
+ if (owner && owner === drivers.me) return "here";
36
+ if ((owner && drivers.background.has(owner)) || drivers.carrying.has(task.id)) return "background";
37
+ if (owner && drivers.live.has(owner)) return "elsewhere";
38
+ return undefined;
39
+ }
40
+
41
+ export function resumeRoute(task: Task, drivers: Drivers): ResumeRoute {
42
+ if (TERMINAL_STATES.includes(task.state)) return { kind: "finished" };
43
+ const where = drivenFrom(task, drivers);
44
+ if (!where) return { kind: "stopped" };
45
+ return { kind: task.paused ? "paused" : "running", where };
46
+ }
47
+
48
+ /** Whether the Tasks screen offers Resume: the task is paused, or nothing runs it. */
49
+ export function resumable(task: Task, drivers: Drivers): boolean {
50
+ const { kind } = resumeRoute(task, drivers);
51
+ return kind === "paused" || kind === "stopped";
52
+ }
53
+
54
+ /** The decision a task records when it carries on in a new session. */
55
+ export const RESUMED_DECISION = "resumed from the Tasks screen in a background session; the session that drove it had stopped (steps that were running stopped with it)";
56
+
57
+ /** What the oracle is told when its task is resumed. */
58
+ export function carryOnMessage(task: Pick<Task, "id" | "state">, after: "paused" | "stopped"): string {
59
+ if (after === "paused") {
60
+ return `bot-lobby: the user resumed ${task.id} (${task.state}) from the Tasks screen. Carry on: read where it stands with the orchestrate tool (action=status) and go on from there.`;
61
+ }
62
+ return [
63
+ `bot-lobby: the user resumed ${task.id} (${task.state}) from the Tasks screen; the session that drove it had stopped.`,
64
+ "Pick up where it left off: read where the task stands with the orchestrate tool (action=status), run again any step that was cut off (its agent stopped with that session, and its edits may be half done), ask again any question that was not answered, and carry on.",
65
+ ].join(" ");
66
+ }
@@ -22,18 +22,19 @@ import { classifier, effortFor, hintsFor } from "../classifier/instance.ts";
22
22
  import { checkThinking } from "../pi/model-support.ts";
23
23
  import { QuickFixQueue } from "./quickfix.ts";
24
24
  import type { Asker } from "../ask/types.ts";
25
- import type { PlanningSession } from "./planner.ts";
26
25
  import { execCommand, IssuesState } from "./issues.ts";
27
26
  import { PullsState } from "./pulls.ts";
28
27
  import { PullReviews } from "./pr-review.ts";
29
28
  import { KnowledgeBook } from "./knowledge.ts";
30
29
  import { ExcalidrawBook } from "../excalidraw/sessions.ts";
31
30
  import type { LobbyService } from "./host.ts";
32
- import { createLobbyService, lobbyNotify, lobbyProfile, reviewProfile, seatProfile, setServiceState, savePlan as serviceSavePlan, answerPanel as serviceAnswerPanel } from "./service.ts";
31
+ import { backgroundSessions, createLobbyService, lobbyNotify, lobbyProfile, plannerDeps, resumeSession, reviewProfile, seatProfile, setServiceState, savePlan as serviceSavePlan, answerPanel as serviceAnswerPanel } from "./service.ts";
33
32
  import { lobbyTopics } from "./topics.ts";
34
33
  export { backgroundSessions, setSessionLauncher } from "./service.ts";
35
34
  import { deliverComments, onOwnerEvent } from "../pi/owner.ts";
36
35
  import { pushNotice } from "../webui/notices.ts";
36
+ import { listenForSignals, NUDGE_DELAY_MS, recover, stoppingOnSignal, WindowKeeper } from "./recovery.ts";
37
+ import { PlanningSession, type PlanningSnapshot } from "./planner.ts";
37
38
 
38
39
  export { deliverComments };
39
40
 
@@ -57,6 +58,10 @@ export interface Runtime {
57
58
  /** The repository (or folder) and branch the page's title shows, and whether git is being asked now. */
58
59
  workspace: WorkspaceInfo;
59
60
  readingWorkspace: boolean;
61
+ /** Keeps what this window does on disk, so the next window carries it on after a crash. */
62
+ keeper?: WindowKeeper;
63
+ /** What this window carried on from one that stopped, as the page announces it once. */
64
+ recovered?: { at: number; text: string };
60
65
  }
61
66
 
62
67
  let runtime: Runtime | undefined;
@@ -89,12 +94,18 @@ export function lobbyWorkRunning(): string | undefined {
89
94
 
90
95
  /** Stop the lobby's backend (bot-lobby turned off, or the session ended). */
91
96
  export function stopLobbyService(): void {
92
- shutdown();
97
+ shutdown(false);
93
98
  }
94
99
 
95
- function shutdown(): void {
100
+ /**
101
+ * Stop the lobby. `carryOn` (pi is stopping on a signal) keeps what it was
102
+ * doing on disk for the next window; any other stop records it as stopped.
103
+ */
104
+ function shutdown(carryOn: boolean): void {
96
105
  const state = runtime;
97
106
  if (!state) return;
107
+ // Before anything is cancelled, so what is kept is what was running.
108
+ state.keeper?.stop(carryOn);
98
109
  runtime = undefined;
99
110
  lobbyService = undefined;
100
111
  setServiceState(undefined);
@@ -129,7 +140,7 @@ function handToQuickFix(state: Runtime, request: string, builder: Domain | undef
129
140
  * every interactive master session, not a subagent.
130
141
  */
131
142
  export function startLobbyService(pi: ExtensionAPI, ctx: ExtensionContext, configDir: string): Runtime | undefined {
132
- shutdown();
143
+ shutdown(false);
133
144
  if (isSubagentProcess() || !servesPage(ctx)) return undefined;
134
145
  const root = detectProjectRoot(ctx.cwd, configDir);
135
146
  const workflow = loadConfig().workflow;
@@ -175,7 +186,10 @@ export function startLobbyService(pi: ExtensionAPI, ctx: ExtensionContext, confi
175
186
  stallTimeoutMs: workflow.stallTimeoutMs,
176
187
  toolStallTimeoutMs: workflow.toolStallTimeoutMs,
177
188
  feed: lobbyFeed,
178
- onChange: () => lobbyTopics.bump("quickfix"),
189
+ onChange: () => {
190
+ lobbyTopics.bump("quickfix");
191
+ state.keeper?.quickfix.schedule();
192
+ },
179
193
  notify: (message, level) => lobbyNotify(state, message, level),
180
194
  hints: hintsFor({ cwd: ctx.cwd, root, configDir }),
181
195
  classifier: classifier(),
@@ -196,6 +210,7 @@ export function startLobbyService(pi: ExtensionAPI, ctx: ExtensionContext, confi
196
210
  // Only the newest messages are kept; the feed learns whether earlier ones exist, and loads them when scrolled to.
197
211
  lobbyFeed.seedChat(chatFromEntries(ctx.sessionManager.getBranch(), Number.POSITIVE_INFINITY));
198
212
  state.unsubscribeFeed = lobbyFeed.onChange(() => lobbyTopics.bump("lobby"));
213
+ carryOn(state);
199
214
  onRunUpdates((runs) => {
200
215
  lobbyFeed.runs(runs);
201
216
  lobbyTopics.bump("tasks");
@@ -212,6 +227,61 @@ export function startLobbyService(pi: ExtensionAPI, ctx: ExtensionContext, confi
212
227
  return state;
213
228
  }
214
229
 
230
+ /**
231
+ * Keep this window's work on disk from now on, and carry on what a window
232
+ * that stopped unexpectedly was doing (src/lobby/recovery.ts).
233
+ */
234
+ function carryOn(state: Runtime): void {
235
+ listenForSignals();
236
+ const { root, configDir, ctx } = state;
237
+ state.keeper = new WindowKeeper({
238
+ root,
239
+ configDir,
240
+ sessionId: () => state.ctx.sessionManager.getSessionId(),
241
+ sessionFile: () => state.ctx.sessionManager.getSessionFile?.(),
242
+ working: () => !state.ctx.isIdle(),
243
+ planner: () => state.planner,
244
+ quickfix: () => state.quickfix.snapshot(),
245
+ background: () => backgroundSessions(),
246
+ });
247
+ let summary: string | undefined;
248
+ try {
249
+ summary = recover({
250
+ root,
251
+ configDir,
252
+ sessionId: ctx.sessionManager.getSessionId(),
253
+ relaunch: (start) => resumeSession(state, start),
254
+ restorePlanner: (snapshot) => restorePlanner(state, snapshot),
255
+ restoreQuickFixes: (jobs) => state.quickfix.restore(jobs).length,
256
+ nudge: (message) => nudgeWhenIdle(state, message),
257
+ });
258
+ } catch (error) {
259
+ lobbyFeed.log("LOBBY", `could not carry on what the stopped session was doing: ${(error as Error).message}`, "warning");
260
+ }
261
+ state.keeper.window.flush();
262
+ if (!summary) return;
263
+ state.recovered = { at: Date.now(), text: summary };
264
+ lobbyFeed.log("LOBBY", summary, "success");
265
+ state.ctx.ui.notify(`bot-lobby: ${summary}`, "info");
266
+ lobbyTopics.bump("status");
267
+ }
268
+
269
+ /** The planning panel as a kept snapshot left it; a round pi cut off runs again. */
270
+ function restorePlanner(state: Runtime, snapshot: PlanningSnapshot): void {
271
+ state.planner = PlanningSession.restore(plannerDeps(state, snapshot.seats), snapshot);
272
+ lobbyTopics.bump("planner");
273
+ void state.planner.resume().catch(() => {});
274
+ }
275
+
276
+ /** Pick this window's interrupted turn back up once pi has settled, unless the user has already started one. */
277
+ function nudgeWhenIdle(state: Runtime, message: string): void {
278
+ const timer = setTimeout(() => {
279
+ if (runtime !== state || !state.ctx.isIdle()) return;
280
+ state.pi.sendUserMessage(message);
281
+ }, NUDGE_DELAY_MS);
282
+ timer.unref?.();
283
+ }
284
+
215
285
  /**
216
286
  * Whether this pi process serves the web page: an interactive session. A
217
287
  * one-shot run has nobody to answer, and the background sessions the lobby
@@ -252,7 +322,8 @@ export function registerLobbyEvents(pi: ExtensionAPI, configDir: string): void {
252
322
  // Off (the default for a new session) runs no lobby until bot-lobby is turned on (see pi/switch.ts).
253
323
  if (!isMinimized()) startLobbyService(pi, ctx, configDir);
254
324
  });
255
- pi.on("session_shutdown", () => shutdown());
325
+ // A quit on a signal (a closed terminal, a kill) is not one the user asked for: its work carries on in the next window.
326
+ pi.on("session_shutdown", (event) => shutdown(event.reason === "quit" && stoppingOnSignal()));
256
327
  pi.on("ui_prompt_start", () => {
257
328
  lobbyTopics.bump("status");
258
329
  });
@@ -261,6 +332,7 @@ export function registerLobbyEvents(pi: ExtensionAPI, configDir: string): void {
261
332
  });
262
333
  pi.on("agent_start", (_event, ctx) => {
263
334
  track(ctx);
335
+ runtime?.keeper?.window.schedule();
264
336
  lobbyTopics.bump("status");
265
337
  const taskId = currentZenTask()?.id;
266
338
  turn = { startedAt: Date.now(), ...(taskId ? { taskId } : {}), ...(ctx.model ? { model: modelRef(ctx.model) } : {}), thinking: pi.getThinkingLevel(), tools: 0, turns: 0, input: 0, output: 0, cost: 0 };
@@ -284,6 +356,7 @@ export function registerLobbyEvents(pi: ExtensionAPI, configDir: string): void {
284
356
  });
285
357
  pi.on("agent_end", (event, ctx) => {
286
358
  track(ctx);
359
+ runtime?.keeper?.window.schedule();
287
360
  const finished = turn;
288
361
  turn = undefined;
289
362
  lobbyFeed.replyEnd();
@@ -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,14 +7,17 @@ import { builtSomething, onFastTrack, qaRequired, qaTookPart } from "../workflow
7
7
  /**
8
8
  * §19: everything the engine requires before completion may be declared. The
9
9
  * fast track asks for a finished worker step instead of a QA pass, and for
10
- * QA's part only when the change needs tests.
10
+ * QA's part only when the change needs tests; so does a planned task whose
11
+ * QA sat out the planning panel.
11
12
  */
12
13
  export function completionBlockers(task: Task, pendingCount: number): string[] {
13
14
  const blockers: string[] = [];
14
15
  if (!task.plan) blockers.push("no approved plan is recorded");
15
16
  if (onFastTrack(task)) {
16
17
  if (!builtSomething(task)) blockers.push("no worker step has finished yet");
17
- if (qaRequired(task) && !qaTookPart(task) && !task.qaWaiver) blockers.push("QA has not taken part (give qa the tests as the last step, or run the QA gate)");
18
+ if (qaRequired(task) && !qaTookPart(task) && !task.qaWaiver) blockers.push("QA has not taken part (run the QA gate: action=qa)");
19
+ } else if (!qaRequired(task)) {
20
+ if (!builtSomething(task)) blockers.push("no worker step has finished yet");
18
21
  } else if (task.qaVerdict !== "pass" && !task.qaWaiver) blockers.push(`QA gate is ${task.qaVerdict ?? "not run"}`);
19
22
  if (pendingCount > 0) blockers.push(`${pendingCount} unresolved approval request(s)`);
20
23
  if (task.blockers.length > 0) blockers.push(`${task.blockers.length} unresolved blocker(s)`);
@@ -238,6 +238,8 @@ export interface WorkerRequest {
238
238
  time?: AgentTime;
239
239
  /** The worker may ask the user (the designer): its questions are relayed, and its images saved in `previews`. */
240
240
  ask?: { onAsk: RelayAsk; previews: string };
241
+ /** A QA step's verification budget: Jev's QA risk read of the change so far. */
242
+ risk?: string;
241
243
  }
242
244
 
243
245
  /** What a worker that may ask the user is told about it. */
@@ -264,6 +266,7 @@ function workerWorkflowContext(request: WorkerRequest, likely = ""): string {
264
266
  ? `Scout findings for your domain:\n${summarizeOutcomes(own, 1500)}`
265
267
  : "No scout findings were collected for your domain; verify the repository yourself.",
266
268
  likely,
269
+ request.risk ?? "",
267
270
  request.time?.note ?? "",
268
271
  request.ask ? askGuidance(request.ask.previews) : "",
269
272
  ].filter(Boolean).join("\n\n");
@@ -344,6 +347,8 @@ export interface ReviewerRequest {
344
347
  previousRound?: string;
345
348
  /** The engine's lint result on the touched files and the suppressions the task added. */
346
349
  lint?: string;
350
+ /** Jev's QA risk read of the change: the gate's depth, test budget and focus. */
351
+ risk?: string;
347
352
  /** Under a task time budget: the gate's time. */
348
353
  time?: AgentTime;
349
354
  instruction?: string;
@@ -365,6 +370,7 @@ function reviewerContext(request: ReviewerRequest): string {
365
370
  return [
366
371
  `Task state: reviewing. Domain: ${request.domain}.`,
367
372
  "You may not modify implementation. Report required changes instead.",
373
+ request.risk ?? "",
368
374
  request.previousRound ?? "",
369
375
  request.workerSummary ? `Worker summary (each domain's newest entries):\n${truncate(request.workerSummary, 4800)}` : "No worker summary available.",
370
376
  owns.length > 0 ? `Scout findings:\n${summarizeOutcomes(owns, 1200)}` : "",