@a-t-h-i/bot-lobby 0.6.48 → 0.6.50

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 (61) hide show
  1. package/README.md +2 -2
  2. package/THIRD_PARTY_NOTICES.md +4 -3
  3. package/package.json +9 -7
  4. package/prompts/designer.md +15 -1
  5. package/prompts/master.md +4 -1
  6. package/src/ask/project-styles.ts +64 -0
  7. package/src/ask/tool.ts +23 -4
  8. package/src/ask/types.ts +1 -1
  9. package/src/ask/web.ts +27 -6
  10. package/src/classifier/classifier.ts +14 -2
  11. package/src/classifier/hosts.ts +12 -18
  12. package/src/classifier/instance.ts +7 -4
  13. package/src/execution/delegation.ts +2 -1
  14. package/src/execution/pi-runner.ts +11 -2
  15. package/src/lobby/host.ts +6 -0
  16. package/src/lobby/keys.ts +2 -1
  17. package/src/lobby/service.ts +75 -7
  18. package/src/lobby/session-questions.ts +49 -0
  19. package/src/lobby/sessions.ts +66 -0
  20. package/src/pi/auto-compaction.ts +46 -19
  21. package/src/pi/events.ts +2 -0
  22. package/src/pi/fresh-context.ts +10 -8
  23. package/src/pi/plan-checklist.ts +47 -15
  24. package/src/pi/tools.ts +2 -2
  25. package/src/schemas/task.ts +14 -0
  26. package/src/state/persistence.ts +4 -1
  27. package/src/webui/api/index.ts +36 -2
  28. package/src/webui/api/lobby.ts +1 -1
  29. package/src/webui/api/plan-facts.ts +9 -4
  30. package/src/webui/api/sessions.ts +21 -0
  31. package/src/webui/api/tasks.ts +12 -1
  32. package/src/webui/api/terminal.ts +42 -0
  33. package/src/webui/dev/fake-service.ts +42 -1
  34. package/src/webui/palette-core.ts +228 -228
  35. package/src/webui/protocol.ts +29 -2
  36. package/src/webui/server.ts +16 -1
  37. package/src/webui/terminal.ts +292 -0
  38. package/src/workflow/workflow.ts +3 -2
  39. package/webui/dist/assets/TerminalView-BrP-ENHg.css +1 -0
  40. package/webui/dist/assets/TerminalView-D_j85u4O.js +16 -0
  41. package/webui/dist/assets/dm-sans-latin-wght-normal-Xz1IZZA0.woff2 +0 -0
  42. package/webui/dist/assets/figtree-latin-wght-normal-D_ZTVpCC.woff2 +0 -0
  43. package/webui/dist/assets/fira-code-latin-wght-normal-CHoedHDv.woff2 +0 -0
  44. package/webui/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  45. package/webui/dist/assets/geist-mono-latin-wght-normal-XN7g48iV.woff2 +0 -0
  46. package/webui/dist/assets/ibm-plex-sans-latin-wght-normal-IvpUvPa2.woff2 +0 -0
  47. package/webui/dist/assets/index-BkM--P3x.js +115 -0
  48. package/webui/dist/assets/index-DrHXotS7.css +2 -0
  49. package/webui/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  50. package/webui/dist/assets/manrope-latin-wght-normal-DHIcAJRg.woff2 +0 -0
  51. package/webui/dist/assets/nunito-latin-wght-normal-BzFMHfZw.woff2 +0 -0
  52. package/webui/dist/assets/outfit-latin-wght-normal-Bc-8i84L.woff2 +0 -0
  53. package/webui/dist/assets/plus-jakarta-sans-latin-wght-normal-eXO_dkmS.woff2 +0 -0
  54. package/webui/dist/assets/roboto-mono-latin-wght-normal-CZtBPCCa.woff2 +0 -0
  55. package/webui/dist/assets/source-code-pro-latin-wght-normal-BP8Zz55n.woff2 +0 -0
  56. package/webui/dist/assets/source-sans-3-latin-wght-normal-Bo_j9asI.woff2 +0 -0
  57. package/webui/dist/assets/space-grotesk-latin-wght-normal-BhU9QXUp.woff2 +0 -0
  58. package/webui/dist/build.json +110 -94
  59. package/webui/dist/index.html +2 -2
  60. package/webui/dist/assets/index-B5AnxwRU.css +0 -2
  61. package/webui/dist/assets/index-CznnCkjK.js +0 -114
@@ -4,7 +4,7 @@
4
4
  * the prompt hub (see ../ask/web.ts).
5
5
  */
6
6
  import { SessionManager, type ExtensionContext } from "@earendil-works/pi-coding-agent";
7
- import { TERMINAL_STATES, type Task } from "../schemas/task.ts";
7
+ import { MAX_SKIP_REASON, TERMINAL_STATES, type Task } from "../schemas/task.ts";
8
8
  import { updateBlockingRequest } from "../state/blocking-requests.ts";
9
9
  import { randomUUID } from "node:crypto";
10
10
  import { loadTask, ownedTask, peekOwnedTask, peekTasks, saveTask } from "../state/persistence.ts";
@@ -36,7 +36,8 @@ import { pendingRequest, startRequest } from "../pi/route.ts";
36
36
  import { deliverComments, setAuto } from "../pi/owner.ts";
37
37
  import { chatFromEntries, lobbyFeed, type ChatEntry } from "./feed.ts";
38
38
  import { availableClassifiers, classifier, effortFor, hintsFor } from "../classifier/instance.ts";
39
- import { launchPi, SessionRegistry, type BackgroundSession, type SessionLauncher } from "./sessions.ts";
39
+ import { cleanSessionName, launchPi, SessionRegistry, type BackgroundSession, type SessionLauncher } from "./sessions.ts";
40
+ import { askForSession } from "./session-questions.ts";
40
41
  import { SessionChats } from "./session-files.ts";
41
42
  import { answerMessage, questionnaires, settledQuestions } from "./ask.ts";
42
43
  import { askUser } from "../ask/web.ts";
@@ -54,6 +55,8 @@ import type { Runtime } from "./runtime.ts";
54
55
  import { findLinters } from "../execution/lint.ts";
55
56
  import { handleResolveApproval, lastQaAsks, waiveLint, waiveQa } from "../workflow/workflow.ts";
56
57
  import { recordDecision } from "../master/decisions.ts";
58
+ import { stepViews } from "../webui/api/plan-facts.ts";
59
+ import { truncate } from "../text.ts";
57
60
  import { fail } from "../webui/api/index.ts";
58
61
 
59
62
  /** The state background callbacks report to; set while the lobby service runs. */
@@ -217,6 +220,31 @@ export function backgroundSessions(): readonly BackgroundSession[] {
217
220
  return registry?.sessions ?? [];
218
221
  }
219
222
 
223
+ /** Rename this window's session (no key) or one of its background sessions. */
224
+ function renameSession(state: Runtime, key: string | undefined, name: string): string {
225
+ const clean = cleanSessionName(name);
226
+ if (!key) {
227
+ state.pi.setSessionName(clean);
228
+ lobbyTopics.bump("status");
229
+ lobbyTopics.bump("sessions");
230
+ return `renamed this window to ${clean}`;
231
+ }
232
+ const session = backgroundSessions().find((entry) => entry.key === key && entry.projectRoot === state.root);
233
+ if (!session) throw new Error(`no session ${key}`);
234
+ session.rename(clean);
235
+ return `renamed ${key} to ${clean}`;
236
+ }
237
+
238
+ /** Put an ended background session away from the sidebar, or bring it back. */
239
+ function archiveSession(state: Runtime, key: string, archived: boolean): string {
240
+ const list = registry ? (archived ? registry.sessions : registry.archived) : [];
241
+ const session = list.find((entry) => entry.key === key && entry.projectRoot === state.root);
242
+ if (!session) throw new Error(archived ? `no session ${key} to archive` : `no archived session ${key}`);
243
+ if (session.alive) throw new Error(`${session.name} is still running: stop it before archiving it`);
244
+ registry!.archive(key, archived);
245
+ return archived ? `archived ${session.name}` : `brought back ${session.name}`;
246
+ }
247
+
220
248
  /** A background session changed: say so when one asks something or fails. */
221
249
  function sessionsChanged(): void {
222
250
  lobbyTopics.bump("sessions");
@@ -270,7 +298,7 @@ function startSession(state: Runtime, start: { request?: string; plan?: PlannedT
270
298
  // A request may lead with flags (`--worktree add login`); the session is named after what follows them.
271
299
  const name = taskName(plan?.title ?? (stripStartFlags(request!) || request!));
272
300
  try {
273
- const session = sessionRegistry().start(state.ctx.cwd, { name, projectRoot: state.root, onDialog: dialogTracker(state), ...(plan ? { planId: plan.id } : { request: request! }), ...(start.auto ? { auto: true } : {}) }, sessionModel(state.ctx));
301
+ const session = sessionRegistry().start(state.ctx.cwd, { name, projectRoot: state.root, onDialog: dialogTracker(state), ask: askForSession, ...(plan ? { planId: plan.id } : { request: request! }), ...(start.auto ? { auto: true } : {}) }, sessionModel(state.ctx));
274
302
  lobbyFeed.log("LOBBY", `started "${name}" in a new session`, "success");
275
303
  return session;
276
304
  } catch (error) {
@@ -284,7 +312,7 @@ export function resumeSession(state: Runtime, start: Relaunch): BackgroundSessio
284
312
  const session = sessionRegistry().start(state.ctx.cwd, {
285
313
  name: start.name,
286
314
  projectRoot: state.root,
287
- onDialog: dialogTracker(state),
315
+ onDialog: dialogTracker(state), ask: askForSession,
288
316
  sessionFile: start.sessionFile,
289
317
  ...(start.message ? { message: start.message } : {}),
290
318
  });
@@ -374,7 +402,7 @@ async function resumeTask(state: Runtime, taskId: string): Promise<{ notice: str
374
402
  const session = sessionRegistry().start(state.ctx.cwd, {
375
403
  name: task.title,
376
404
  projectRoot: state.root,
377
- onDialog: dialogTracker(state),
405
+ onDialog: dialogTracker(state), ask: askForSession,
378
406
  taskId,
379
407
  ...(file ? { sessionFile: file } : {}),
380
408
  message: `/bot-lobby carry-on ${taskId}`,
@@ -398,9 +426,19 @@ function mutableTask(state: Runtime, taskId: string): Task {
398
426
  }
399
427
 
400
428
  async function wakeTask(state: Runtime, task: Task, message: string): Promise<string> {
429
+ if (!tellOracle(state, task, message)) return (await resumeTask(state, task.id)).notice;
430
+ return "the owning oracle has been notified";
431
+ }
432
+
433
+ /**
434
+ * Leave a message in the task's inbox and hand it to its oracle now when a
435
+ * session here runs the task; false when the task is paused or stopped, so the
436
+ * oracle reads it once the task carries on.
437
+ */
438
+ function tellOracle(state: Runtime, task: Task, message: string): boolean {
401
439
  const inbox = sendToInbox(state.root, state.configDir, task.id, message, state.ctx.sessionManager.getSessionId());
402
440
  const route = resumeRoute(task, taskDrivers(state));
403
- if (route.kind === "stopped" || route.kind === "paused") return (await resumeTask(state, task.id)).notice;
441
+ if (route.kind === "stopped" || route.kind === "paused") return false;
404
442
  if (task.ownerSessionId === state.ctx.sessionManager.getSessionId()) {
405
443
  state.pi.sendUserMessage(message, state.ctx.isIdle() ? undefined : { deliverAs: "followUp" });
406
444
  markInboxDelivered(state.root, state.configDir, task.id, [inbox.id]);
@@ -411,7 +449,7 @@ async function wakeTask(state: Runtime, task: Task, message: string): Promise<st
411
449
  markInboxDelivered(state.root, state.configDir, task.id, [inbox.id]);
412
450
  }
413
451
  }
414
- return "the owning oracle has been notified";
452
+ return true;
415
453
  }
416
454
 
417
455
  function canForceCloseTask(state: Runtime, taskId: string): boolean {
@@ -445,6 +483,33 @@ async function acceptTask(state: Runtime, taskId: string): Promise<{ notice: str
445
483
  return { notice: `${task.id} accepted as it is; ${wake}` };
446
484
  }
447
485
 
486
+ /**
487
+ * Skip a plan step on the user's word, with their reason: kept on the task
488
+ * (the checklist shows it skipped, the reason under it), recorded as a
489
+ * decision, and passed to the task's oracle so it carries on without it. A
490
+ * paused or stopped task is not resumed: its oracle reads it when it carries on.
491
+ */
492
+ async function skipTaskStep(state: Runtime, taskId: string, step: number, text: string, reason: string): Promise<{ notice: string }> {
493
+ const task = mutableTask(state, taskId);
494
+ const why = reason.trim().replace(/\s+/g, " ");
495
+ if (!why) fail(400, "bad_request", "say why you are skipping this step");
496
+ if (why.length > MAX_SKIP_REASON) fail(400, "bad_request", `keep the reason under ${MAX_SKIP_REASON} characters`);
497
+ if (!task.plan) fail(400, "bad_request", "this task has no plan yet");
498
+ const steps = stepViews(task, taskWork(state.root, state.configDir, task.id));
499
+ const found = steps[step];
500
+ if (!found || found.text !== text) fail(409, "conflict", "the plan changed; refresh the task");
501
+ if (found.status === "done") fail(409, "conflict", "that step is already done");
502
+ if (found.status === "skipped") fail(409, "conflict", "that step is already skipped");
503
+ task.skippedSteps = [...(task.skippedSteps ?? []), { text, reason: why, at: new Date().toISOString() }];
504
+ recordDecision(task, `The user skipped step ${step + 1} ("${truncate(text, 200)}") from the Tasks screen. Reason: ${why}`);
505
+ saveTask(state.root, state.configDir, task);
506
+ lobbyTopics.bump("tasks");
507
+ lobbyTopics.bump("lobby");
508
+ const message = `The user skipped step ${step + 1} of ${task.id}'s plan: "${text}". Their reason: ${why}. Do not delegate, build or review that step; if an agent is on it now, let it stop there or tell it to. Carry on with the next open step.`;
509
+ if (!tellOracle(state, task, message)) return { notice: `step ${step + 1} skipped; the oracle reads why when the task carries on` };
510
+ return { notice: `step ${step + 1} skipped; the oracle has been told` };
511
+ }
512
+
448
513
  async function resolveTaskApproval(state: Runtime, taskId: string, approvalId: string, decision: "approved" | "rejected", note?: string): Promise<{ notice: string }> {
449
514
  const task = mutableTask(state, taskId);
450
515
  if (!task.approvals.some((entry) => entry.id === approvalId && entry.status === "pending")) fail(409, "conflict", "approval is no longer pending");
@@ -893,12 +958,15 @@ export function createLobbyService(state: Runtime): LobbyService {
893
958
  refreshWorkspace: () => refreshWorkspace(state),
894
959
  sessionName: () => state.pi.getSessionName(),
895
960
  sessions: () => backgroundSessions().filter((session) => session.projectRoot === state.root),
961
+ renameSession: (key, name) => renameSession(state, key, name),
962
+ archiveSession: (key, archived) => archiveSession(state, key, archived),
896
963
  startSession: (start) => startSession(state, start),
897
964
  isAuto: (taskId) => autoFor(state, taskId),
898
965
  setAuto: (taskId, on) => switchAuto(state, taskId, on),
899
966
  sendToTask: (taskId, text) => sendToTask(state, taskId, text),
900
967
  resumeTask: (taskId) => resumeTask(state, taskId),
901
968
  acceptTask: (taskId) => acceptTask(state, taskId),
969
+ skipTaskStep: (taskId, step, text, reason) => skipTaskStep(state, taskId, step, text, reason),
902
970
  forceCloseTask: (taskId, title) => forceCloseTask(state, taskId, title),
903
971
  canForceCloseTask: (taskId) => canForceCloseTask(state, taskId),
904
972
  resolveTaskApproval: (taskId, approvalId, decision, note) => resolveTaskApproval(state, taskId, approvalId, decision, note),
@@ -0,0 +1,49 @@
1
+ /**
2
+ * A background session's questions, asked wherever the user is. A session
3
+ * started "in a new session" runs headless, so every question it asks (its
4
+ * oracle's questionnaire, a pick, a line of text, a yes/no) reaches this
5
+ * window as an RPC dialog. Each one is put in this window's question queue,
6
+ * the same pop-up the user's own session asks in, named after the session it
7
+ * comes from, so nobody has to switch sessions to answer. The answer goes
8
+ * back to the session; a dialog answered some other way (or a session that
9
+ * ends) takes its question back out of the queue.
10
+ */
11
+ import { readRelayRequest } from "../ask/relay.ts";
12
+ import { readResult } from "../ask/web.ts";
13
+ import { promptHub, type PromptHub } from "./prompt-hub.ts";
14
+ import type { DialogAnswer, SessionDialog } from "./sessions.ts";
15
+
16
+ export interface SessionQuestion {
17
+ /** The session's name, shown as who asks. */
18
+ from: string;
19
+ dialog: SessionDialog;
20
+ signal: AbortSignal;
21
+ }
22
+
23
+ /** Put a session's dialog to the user in the question pop-up; undefined when it was taken back first. */
24
+ export async function askForSession({ from, dialog, signal }: SessionQuestion, hub: PromptHub = promptHub): Promise<DialogAnswer | undefined> {
25
+ const options = { signal };
26
+ const relayed = dialog.method === "editor" ? readRelayRequest(dialog.title, dialog.prefill) : undefined;
27
+ if (relayed) {
28
+ const outcome = await hub.ask("questionnaire", from, { questions: relayed }, options);
29
+ if (outcome.how === "cancelled") return undefined;
30
+ const result = outcome.how === "answered" ? readResult(outcome.value) : undefined;
31
+ return { value: JSON.stringify(result ?? { answers: [], cancelled: true }) };
32
+ }
33
+ if (dialog.method === "select") {
34
+ const outcome = await hub.ask("choose", from, { title: dialog.title, options: dialog.options ?? [] }, options);
35
+ return settled(outcome, (value) => (typeof value === "string" ? { value } : undefined));
36
+ }
37
+ if (dialog.method === "confirm") {
38
+ const outcome = await hub.ask("confirm", from, { question: dialog.title, ...(dialog.message ? { detail: dialog.message } : {}) }, options);
39
+ return settled(outcome, (value) => (typeof value === "boolean" ? { confirmed: value } : undefined));
40
+ }
41
+ const outcome = await hub.ask("text", from, { question: dialog.title, ...(dialog.placeholder ? { placeholder: dialog.placeholder } : {}) }, options);
42
+ return settled(outcome, (value) => (typeof value === "string" ? { value } : undefined));
43
+ }
44
+
45
+ function settled(outcome: { how: "answered" | "dismissed" | "cancelled"; value: unknown }, read: (value: unknown) => DialogAnswer | undefined): DialogAnswer | undefined {
46
+ if (outcome.how === "cancelled") return undefined;
47
+ if (outcome.how === "dismissed") return { cancelled: true };
48
+ return read(outcome.value) ?? { cancelled: true };
49
+ }
@@ -54,6 +54,11 @@ export interface SessionStart {
54
54
  message?: string;
55
55
  /** The task it was started to carry on (resumed from the Tasks screen). */
56
56
  taskId?: string;
57
+ /**
58
+ * Put a dialog to the user (this window's question pop-up); resolves with the answer, or undefined when the
59
+ * dialog was settled some other way first (`signal` aborts then).
60
+ */
61
+ ask?: (question: { from: string; dialog: SessionDialog; signal: AbortSignal }) => Promise<DialogAnswer | undefined>;
57
62
  }
58
63
 
59
64
  let counter = 0;
@@ -80,6 +85,9 @@ export class BackgroundSession {
80
85
  /** Dialogs the session waits on, oldest first. */
81
86
  readonly dialogs: SessionDialog[] = [];
82
87
  private readonly onDialog: NonNullable<SessionStart["onDialog"]>;
88
+ private readonly ask?: SessionStart["ask"];
89
+ /** The dialogs being asked in the question pop-up, to take back when they are settled another way. */
90
+ private readonly asking = new Map<string, AbortController>();
83
91
  private readonly proc: SessionProcess;
84
92
  private buffer = "";
85
93
  private stderr = "";
@@ -96,6 +104,7 @@ export class BackgroundSession {
96
104
  if (start.planId) this.planId = start.planId;
97
105
  if (start.taskId) this.taskId = start.taskId;
98
106
  this.onDialog = start.onDialog ?? (() => {});
107
+ if (start.ask) this.ask = start.ask;
99
108
  this.proc = proc;
100
109
  this.onChange = onChange;
101
110
  proc.stdout?.on("data", (chunk) => this.receive(String(chunk)));
@@ -121,6 +130,8 @@ export class BackgroundSession {
121
130
  this.exitCode = code;
122
131
  for (const dialog of this.dialogs) this.onDialog(dialog.id, false, this.sessionId);
123
132
  this.dialogs.length = 0;
133
+ for (const controller of this.asking.values()) controller.abort();
134
+ this.asking.clear();
124
135
  if (this.killTimer) clearTimeout(this.killTimer);
125
136
  this.feed.say("note", reason);
126
137
  for (const resolve of this.exitWaiters.splice(0)) resolve();
@@ -175,6 +186,8 @@ export class BackgroundSession {
175
186
  const index = this.dialogs.findIndex((dialog) => dialog.id === id);
176
187
  if (index < 0) return;
177
188
  this.dialogs.splice(index, 1);
189
+ this.asking.get(id)?.abort();
190
+ this.asking.delete(id);
178
191
  this.onDialog(id, false, this.sessionId);
179
192
  if (this.alive && this.proc.stdin) this.proc.stdin.write(`${JSON.stringify({ type: "extension_ui_response", id, ...answer })}\n`);
180
193
  this.onChange();
@@ -184,6 +197,13 @@ export class BackgroundSession {
184
197
  * Stop the session: its process ends (its task keeps its state and can be
185
198
  * resumed or claimed later). One that does not end within `graceMs` is killed.
186
199
  */
200
+ /** Give the session a new name, here and in its pi (its saved session takes it too). */
201
+ rename(name: string): void {
202
+ this.name = name;
203
+ if (this.alive) this.write({ type: "set_session_name", name });
204
+ this.onChange();
205
+ }
206
+
187
207
  stop(graceMs = STOP_GRACE_MS): void {
188
208
  if (!this.alive) return;
189
209
  try {
@@ -248,6 +268,21 @@ export class BackgroundSession {
248
268
  this.onChange();
249
269
  }
250
270
 
271
+ /** Ask the dialog in the question pop-up, wherever the user is; its answer goes back to the session. */
272
+ private putToUser(dialog: SessionDialog): void {
273
+ if (!this.ask) return;
274
+ const controller = new AbortController();
275
+ this.asking.set(dialog.id, controller);
276
+ this.ask({ from: this.name, dialog, signal: controller.signal }).then(
277
+ (answer) => {
278
+ if (this.asking.get(dialog.id) !== controller) return;
279
+ this.asking.delete(dialog.id);
280
+ if (answer) this.answer(dialog.id, answer);
281
+ },
282
+ () => this.asking.delete(dialog.id),
283
+ );
284
+ }
285
+
251
286
  private uiRequest(event: Record<string, unknown>): void {
252
287
  const id = String(event.id ?? "");
253
288
  const method = String(event.method ?? "");
@@ -262,6 +297,7 @@ export class BackgroundSession {
262
297
  ...(typeof event.prefill === "string" ? { prefill: event.prefill } : {}),
263
298
  });
264
299
  this.onDialog(id, true, this.sessionId);
300
+ this.putToUser(this.dialogs.at(-1)!);
265
301
  this.feed.log("LOBBY", `waiting for you: ${String(event.title ?? "a question").split("\n")[0]}`, "warning");
266
302
  } else if (method === "notify") {
267
303
  const level = event.notifyType === "error" ? "error" : event.notifyType === "warning" ? "warning" : "info";
@@ -299,6 +335,8 @@ export const launchPi = (args: string[], cwd: string, overrides: NodeJS.ProcessE
299
335
  const env: NodeJS.ProcessEnv = { ...process.env, PI_SKIP_VERSION_CHECK: "1" };
300
336
  delete env.BOT_LOBBY_SUBAGENT;
301
337
  delete env.BOT_LOBBY_WEB_PROJECT;
338
+ // It has no page of its own: its questions come to this window (see ask/web.ts).
339
+ env.BOT_LOBBY_BACKGROUND = "1";
302
340
  Object.assign(env, overrides);
303
341
  const proc: ChildProcess = spawn(invocation.command, invocation.args, { cwd, env, shell: false, detached: POSIX, windowsHide: true, stdio: ["pipe", "pipe", "pipe"] });
304
342
  return proc as unknown as SessionProcess;
@@ -306,6 +344,19 @@ export const launchPi = (args: string[], cwd: string, overrides: NodeJS.ProcessE
306
344
 
307
345
  /** Ended background sessions kept in the list (with their feeds); older ones are let go. */
308
346
  export const MAX_ENDED = 6;
347
+ /** Archived sessions kept so an archive can be undone; older ones are let go. */
348
+ export const MAX_ARCHIVED = 20;
349
+ /** Longest session name the lobby sets. */
350
+ export const MAX_SESSION_NAME = 120;
351
+
352
+ /** A session name as the lobby keeps it: one line, spaces collapsed, not too long. */
353
+ export function cleanSessionName(name: string): string {
354
+ const clean = name.replace(/\s+/g, " ").trim();
355
+ if (!clean) throw new Error("a session needs a name");
356
+ if (clean.length > MAX_SESSION_NAME) throw new Error(`keep the name under ${MAX_SESSION_NAME} characters`);
357
+ return clean;
358
+ }
359
+
309
360
 
310
361
  /** Every background session this window started, oldest first (ended ones beyond the newest few let go). */
311
362
  export class SessionRegistry {
@@ -341,6 +392,21 @@ export class SessionRegistry {
341
392
  return this.sessions.find((session) => session.key === key);
342
393
  }
343
394
 
395
+ /** Ended sessions put away from the list, newest last; one can come back while this pi runs. */
396
+ readonly archived: BackgroundSession[] = [];
397
+
398
+ /** Put an ended session away (true), or bring an archived one back (false); false when there was no such session. */
399
+ archive(key: string, archived = true): boolean {
400
+ const from = archived ? this.sessions : this.archived;
401
+ const index = from.findIndex((session) => session.key === key && !session.alive);
402
+ if (index < 0) return false;
403
+ const [session] = from.splice(index, 1);
404
+ (archived ? this.archived : this.sessions).push(session!);
405
+ if (this.archived.length > MAX_ARCHIVED) this.archived.splice(0, this.archived.length - MAX_ARCHIVED);
406
+ this.onChange();
407
+ return true;
408
+ }
409
+
344
410
  /** The background session running a pi session id, if this window started it. */
345
411
  bySessionId(sessionId: string | undefined): BackgroundSession | undefined {
346
412
  return sessionId ? this.sessions.find((session) => session.sessionId === sessionId) : undefined;
@@ -1,5 +1,5 @@
1
1
  import { compact, CONFIG_DIR_NAME, DEFAULT_COMPACTION_SETTINGS, estimateTokens, findCutPoint, type ExtensionAPI, type ExtensionContext, type TurnEndEvent } from "@earendil-works/pi-coding-agent";
2
- import { contextMarks, freshContextOn } from "./fresh-context.ts";
2
+ import { contextMarks, freshContextOn, staleCompaction } from "./fresh-context.ts";
3
3
  import { AUTO_COMPACT_ENV, isSubagentProcess } from "./quiet.ts";
4
4
  import { dataRoot, detectProjectRoot, loadConfig } from "../state/project.ts";
5
5
  import { describeShared, DIGEST_INSTRUCTION, parseDigest, shareDigest, type DigestItem } from "../knowledge/oracle-digest.ts";
@@ -34,8 +34,9 @@ export function compactionThreshold(window: number, reserve = DEFAULT_COMPACTION
34
34
  }
35
35
 
36
36
  /**
37
- * Compact at a settled tool boundary; ctx.compact() would abort the turn we need to continue. Each agent's settings
38
- * can turn it off. The oracle's checkpoint also lists what it learned that is worth keeping, tagged by agent, and
37
+ * Compact at a settled tool boundary, below pi's own threshold (pi 1.3 still sets that only per model in settings, and
38
+ * ctx.compact() would abort the turn we need to continue). Each agent's settings can turn it off. Compactions pi
39
+ * starts itself get the same checkpoint through `session_before_compact`. The oracle's checkpoint also lists what it learned that is worth keeping, tagged by agent, and
39
40
  * that goes into the knowledge base: its own, and the designer's, backend's and QA's where it concerns them.
40
41
  */
41
42
  export function registerAutoCompaction(pi: ExtensionAPI, summarize: typeof compact = compact, options: AutoCompactionOptions = {}): void {
@@ -88,26 +89,52 @@ export function registerAutoCompaction(pi: ExtensionAPI, summarize: typeof compa
88
89
  busy = true;
89
90
  ctx.ui.notify("Compacting agent context", "info");
90
91
  try {
91
- const auth = await ctx.modelRegistry.getApiKeyAndHeaders(ctx.model);
92
- if (!auth.ok) throw new Error(auth.error);
93
- const headers = auth.headers ? Object.fromEntries(Object.entries(auth.headers).filter((entry): entry is [string, string] => entry[1] !== null)) : undefined;
94
- const retry = pi.getSettings().retry;
95
- const retryPolicy = { enabled: retry?.enabled ?? true, maxRetries: retry?.maxRetries ?? 3, baseDelayMs: retry?.baseDelayMs ?? 2_000, maxAgentDelayMs: retry?.maxAgentDelayMs };
96
- const result = await summarize({ firstKeptEntryId, messagesToSummarize: messages, turnPrefixMessages: [], isSplitTurn: false, tokensBefore: estimate, previousSummary: previous?.type === "compaction" ? previous.summary : undefined, fileOps, settings: { enabled: true, reserveTokens: reserve, keepRecentTokens } }, auth.baseUrl ? { ...ctx.model, baseUrl: auth.baseUrl } : ctx.model, auth.apiKey, headers, oracle ? `${CHECKPOINT} ${DIGEST_INSTRUCTION}` : CHECKPOINT, ctx.signal, undefined, undefined, auth.env, retryPolicy);
97
- ctx.signal?.throwIfAborted();
92
+ const result = await checkpoint({ firstKeptEntryId, messagesToSummarize: messages, turnPrefixMessages: [], isSplitTurn: false, tokensBefore: estimate, previousSummary: previous?.type === "compaction" ? previous.summary : undefined, fileOps, settings: { enabled: true, reserveTokens: reserve, keepRecentTokens } }, ctx, ctx.signal);
98
93
  ctx.ui.notify("Agent context compacted", "info");
99
- if (oracle) {
100
- try {
101
- const items = parseDigest(result.summary);
102
- const shared = items.length ? share(items, ctx) : "";
103
- if (shared) ctx.ui.notify(`Knowledge updated: ${shared}`, "info");
104
- } catch (error) {
105
- ctx.ui.notify(`Could not update knowledge: ${(error as Error).message}`, "warning");
106
- }
107
- }
94
+ learn(result.summary, ctx);
108
95
  return { entries: [...event.entries, { type: "compaction" as const, summary: result.summary, firstKeptEntryId, usage: result.usage, details: result.details }] };
109
96
  } catch (error) {
110
97
  ctx.ui.notify(`Context compaction failed: ${(error as Error).message}`, "warning");
111
98
  } finally { busy = false; }
112
99
  });
100
+ // pi's own compactions (its context threshold, overflow recovery, /compact) write the same checkpoint from pi's
101
+ // preparation, so every summary an agent continues from has the same shape and the oracle's still feeds knowledge.
102
+ pi.on("session_before_compact", async (event, ctx) => {
103
+ if (staleCompaction(event) || !ctx.model) return undefined;
104
+ try {
105
+ const compaction = await checkpoint(event.preparation, ctx, event.signal, event.customInstructions);
106
+ learn(compaction.summary, ctx);
107
+ return { compaction };
108
+ } catch (error) {
109
+ if (event.signal.aborted) throw error;
110
+ ctx.ui.notify(`Checkpoint failed, using pi's summary: ${(error as Error).message}`, "warning");
111
+ return undefined;
112
+ }
113
+ });
114
+
115
+ /** Summarize with the checkpoint prompt (and, for the oracle, its knowledge digest) on the session's model. */
116
+ async function checkpoint(preparation: Parameters<typeof compact>[0], ctx: ExtensionContext, signal: AbortSignal | undefined, extra?: string) {
117
+ const model = ctx.model!;
118
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
119
+ if (!auth.ok) throw new Error(auth.error);
120
+ const headers = auth.headers ? Object.fromEntries(Object.entries(auth.headers).filter((entry): entry is [string, string] => entry[1] !== null)) : undefined;
121
+ const retry = pi.getSettings().retry;
122
+ const retryPolicy = { enabled: retry?.enabled ?? true, maxRetries: retry?.maxRetries ?? 3, baseDelayMs: retry?.baseDelayMs ?? 2_000, maxAgentDelayMs: retry?.maxAgentDelayMs };
123
+ const instructions = [oracle ? `${CHECKPOINT} ${DIGEST_INSTRUCTION}` : CHECKPOINT, extra].filter(Boolean).join("\n\n");
124
+ const result = await summarize(preparation, auth.baseUrl ? { ...model, baseUrl: auth.baseUrl } : model, auth.apiKey, headers, instructions, signal, undefined, undefined, auth.env, retryPolicy);
125
+ signal?.throwIfAborted();
126
+ return result;
127
+ }
128
+
129
+ /** The oracle's digest from a checkpoint, shared into the knowledge base. */
130
+ function learn(summary: string, ctx: ExtensionContext): void {
131
+ if (!oracle) return;
132
+ try {
133
+ const items = parseDigest(summary);
134
+ const shared = items.length ? share(items, ctx) : "";
135
+ if (shared) ctx.ui.notify(`Knowledge updated: ${shared}`, "info");
136
+ } catch (error) {
137
+ ctx.ui.notify(`Could not update knowledge: ${(error as Error).message}`, "warning");
138
+ }
139
+ }
113
140
  }
package/src/pi/events.ts CHANGED
@@ -19,6 +19,7 @@ import { isAutoMode } from "../state/auto.ts";
19
19
  import { triageContext } from "../classifier/triage.ts";
20
20
  import { qaStillDue, rosterWords } from "../workflow/track.ts";
21
21
  import { previousTaskNote } from "./fresh-context.ts";
22
+ import { skippedStepsNote } from "./plan-checklist.ts";
22
23
  import { budgetLine, budgetState, clockTask, pauseClocks, readBudget, resumeClocks, startClock, stopClocks, turnStarted } from "../state/budget.ts";
23
24
 
24
25
  /** Tools that wait on the user: the task's clock waits with them. */
@@ -52,6 +53,7 @@ export function masterTaskContext(task: Task, comments: readonly PlanComment[] =
52
53
  `Request: ${truncate(task.approvedPlan && task.plan ? requestBesidePlan(taskRequest(task)) : taskRequest(task), 2000)}`,
53
54
  task.proposal ? `Current proposal:\n${truncate(task.proposal, 2000)}` : "",
54
55
  task.plan ? `Approved plan:\n${truncate(task.plan, 3000)}` : "",
56
+ skippedStepsNote(task.plan, task.skippedSteps),
55
57
  task.amendments.length > 0 ? `User amendments:\n${task.amendments.map((entry) => `- ${entry}`).join("\n")}` : "",
56
58
  open.length > 0 ? `Open plan comments (from the lobby):\n${open.map((comment) => `- ${truncate(comment.text, 600)}`).join("\n")}` : "",
57
59
  task.approvedPlan ? `The user agreed this task's plan in the planning panel (${task.approvedPlan}), and it is the task's plan: delegate its steps without clarifying, scouting or proposing, and answer open questions from the plan.${task.track?.satOut?.length ? ` ${rosterWords(task.track.satOut)} sat out the panel: do not give ${task.track.satOut.length === 1 ? "it" : "them"} work${task.track.satOut.includes("qa") ? ", and there is no QA gate (complete once every step is done)" : ""}.` : ""}` : "",
@@ -13,7 +13,7 @@
13
13
  * already run as pi processes of their own, each with its own context, and
14
14
  * are not touched here.
15
15
  */
16
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
16
+ import type { ExtensionAPI, ExtensionContext, SessionBeforeCompactEvent } from "@earendil-works/pi-coding-agent";
17
17
  import { relative } from "node:path";
18
18
  import { TERMINAL_STATES, type Task } from "../schemas/task.ts";
19
19
  import { peekTasks, taskDirFor } from "../state/persistence.ts";
@@ -124,11 +124,13 @@ export function registerFreshContext(pi: ExtensionAPI, configDir: string): void
124
124
  // It may have ended while the oracle was idle (abandoned or archived from the lobby, finished in another window).
125
125
  pi.on("before_agent_start", (_event, ctx) => void closeEndedTask(pi, ctx, configDir));
126
126
  // Compacting a conversation the next request drops anyway would only cost a model call.
127
- pi.on("session_before_compact", (event) => {
128
- if (event.reason === "manual" || !freshContextOn()) return undefined;
129
- const last = contextMarks(event.branchEntries).at(-1);
130
- if (!last) return undefined;
131
- const since = event.branchEntries.some((entry) => entry.type === "message" && Date.parse(entry.timestamp) >= last.at);
132
- return since ? undefined : { cancel: true };
133
- });
127
+ pi.on("session_before_compact", (event) => (staleCompaction(event) ? { cancel: true } : undefined));
128
+ }
129
+
130
+ /** An automatic compaction of the oracle's conversation from before its latest boundary, which the next request drops anyway. */
131
+ export function staleCompaction(event: Pick<SessionBeforeCompactEvent, "reason" | "branchEntries">): boolean {
132
+ if (isSubagentProcess() || event.reason === "manual" || !freshContextOn()) return false;
133
+ const last = contextMarks(event.branchEntries).at(-1);
134
+ if (!last) return false;
135
+ return !event.branchEntries.some((entry) => entry.type === "message" && Date.parse(entry.timestamp) >= last.at);
134
136
  }
@@ -18,7 +18,7 @@ const TOP_LEVEL_BULLET = /^()([*-])\s+(.*\S)\s*$/;
18
18
  const STEP_HEADING = /^\s*(?:#{1,6}\s+)?(?:\*\*)?\s*(?:step|phase|stage)\s+#?\d+\s*(?:\*\*)?\s*[:.)\u2014\u2013-]\s*(?:\*\*)?\s*(.*?)\s*(?:\*\*)?\s*$/i;
19
19
  const MIN_STEP_HEADINGS = 2;
20
20
 
21
- export type PlanStepStatus = "done" | "current" | "pending";
21
+ export type PlanStepStatus = "done" | "current" | "pending" | "skipped";
22
22
 
23
23
  export interface PlanStep {
24
24
  text: string;
@@ -355,8 +355,8 @@ function byStart(runs: readonly AgentRun[]): AgentRun[] {
355
355
  * grows and a failure can never tick one off. The latest worker run's own target
356
356
  * is reported separately so a running or failed step reads current.
357
357
  */
358
- function replaySteps(steps: readonly string[], runs: readonly AgentRun[]): { completed: number; latest: number } {
359
- const completed = new Set<number>();
358
+ function replaySteps(steps: readonly string[], runs: readonly AgentRun[], skipped: ReadonlySet<number>): { completed: Set<number>; latest: number } {
359
+ const completed = new Set<number>(skipped);
360
360
  const latestRun = latestWorkerRun(runs);
361
361
  let latest = -1;
362
362
  for (const run of byStart(runs)) {
@@ -367,27 +367,43 @@ function replaySteps(steps: readonly string[], runs: readonly AgentRun[]): { com
367
367
  const target = matched >= 0 ? matched : nextOpenStep(steps, completed);
368
368
  if (target >= 0) markThrough(completed, target);
369
369
  }
370
- return { completed: completed.size, latest };
370
+ return { completed, latest };
371
+ }
372
+
373
+ /**
374
+ * The plan steps the user skipped, as indexes: each skip names its step by
375
+ * text, so a skip whose step a revised plan no longer lists drops out.
376
+ */
377
+ export function skippedIndexes(steps: readonly string[], skipped: readonly { text: string }[] | undefined): Set<number> {
378
+ const found = new Set<number>();
379
+ if (!skipped?.length) return found;
380
+ const wanted = new Set(skipped.map((entry) => normalize(entry.text)));
381
+ steps.forEach((text, index) => { if (wanted.has(normalize(text))) found.add(index); });
382
+ return found;
371
383
  }
372
384
 
373
385
  /** One-entry memo: the panel asks for the same checklist several times per frame. */
374
- let checklistMemo: { plan: string; runs: readonly AgentRun[]; steps: PlanStep[] } | undefined;
386
+ let checklistMemo: { plan: string; runs: readonly AgentRun[]; skipped: readonly { text: string }[] | undefined; steps: PlanStep[] } | undefined;
375
387
 
376
388
  /**
377
389
  * Done/current/pending per plan step. A succeeded run completes its step, so the
378
390
  * next step becomes current (and the last step reads done); running, failed,
379
391
  * cancelled and timeout runs keep it current. Progress is monotonic: steps
380
392
  * already completed by successful worker runs stay done, and a later call that
381
- * names an earlier step can never tick it back. Memoized on the exact plan text
382
- * and runs array, so callers must not mutate either.
393
+ * names an earlier step can never tick it back. A step the user skipped reads
394
+ * skipped and counts as behind the work: the next open step becomes current.
395
+ * Memoized on the exact plan text, runs and skips, so callers must not mutate them.
383
396
  */
384
- export function planChecklist(plan: string, runs: readonly AgentRun[]): PlanStep[] {
385
- if (checklistMemo && checklistMemo.plan === plan && checklistMemo.runs === runs) return checklistMemo.steps;
397
+ export function planChecklist(plan: string, runs: readonly AgentRun[], skips?: readonly { text: string }[]): PlanStep[] {
398
+ if (checklistMemo && checklistMemo.plan === plan && checklistMemo.runs === runs && checklistMemo.skipped === skips) return checklistMemo.steps;
386
399
  const texts = planSteps(plan);
387
- const replay = replaySteps(texts, runs);
388
- const current = Math.max(replay.completed, replay.latest);
389
- const steps = texts.map((text, index) => ({ text, status: stepStatus(index, current) }));
390
- checklistMemo = { plan, runs, steps };
400
+ const skipped = skippedIndexes(texts, skips);
401
+ const replay = replaySteps(texts, runs, skipped);
402
+ const open = nextOpenStep(texts, replay.completed);
403
+ let current = Math.max(open < 0 ? texts.length : open, replay.latest);
404
+ while (skipped.has(current)) current += 1;
405
+ const steps = texts.map((text, index) => ({ text, status: skipped.has(index) ? "skipped" as const : stepStatus(index, current) }));
406
+ checklistMemo = { plan, runs, skipped: skips, steps };
391
407
  return steps;
392
408
  }
393
409
 
@@ -420,11 +436,11 @@ export function runSteps(steps: readonly string[], instruction: string | undefin
420
436
  * step it works on (a `Steps 2-4` run toward all three), and its success
421
437
  * completes them, so a later run that names no step goes to the next one.
422
438
  */
423
- export function stepWork(steps: readonly string[], runs: readonly AgentRun[], now = Date.now()): StepWork[] {
439
+ export function stepWork(steps: readonly string[], runs: readonly AgentRun[], now = Date.now(), skipped: ReadonlySet<number> = new Set()): StepWork[] {
424
440
  const work: StepWork[] = steps.map(() => ({ ms: 0, active: false, activeAgentCount: 0, runIds: [] }));
425
441
  const assigned = new Map<string, number[]>();
426
442
  const counted = new Set<string>();
427
- const completed = new Set<number>();
443
+ const completed = new Set<number>(skipped);
428
444
  for (const run of byStart(runs)) {
429
445
  if (run.role !== "worker") continue;
430
446
  if (counted.has(run.runId)) continue;
@@ -449,3 +465,19 @@ export function stepWork(steps: readonly string[], runs: readonly AgentRun[], no
449
465
  }
450
466
  return work;
451
467
  }
468
+
469
+ /**
470
+ * What the oracle reads of the steps the user skipped: each by its number in
471
+ * the plan, with the user's reason, and that none of them is to be built.
472
+ * Empty when nothing is skipped (or a revised plan no longer lists the step).
473
+ */
474
+ export function skippedStepsNote(plan: string | undefined, skips: readonly { text: string; reason: string }[] | undefined): string {
475
+ if (!plan || !skips?.length) return "";
476
+ const steps = planSteps(plan);
477
+ const lines = skips.flatMap((skip) => {
478
+ const index = steps.findIndex((text) => normalize(text) === normalize(skip.text));
479
+ return index < 0 ? [] : [`- Step ${index + 1}: ${skip.text} (reason: ${skip.reason})`];
480
+ });
481
+ if (lines.length === 0) return "";
482
+ return `Steps the user skipped (do not delegate, build or review them; the task may complete without them, and the next open step carries on):\n${lines.join("\n")}`;
483
+ }
package/src/pi/tools.ts CHANGED
@@ -132,8 +132,8 @@ export function workflowDeps(
132
132
  effort: effortFor((model, thinking) => checkThinking(modelLookup(ctx)(model), thinking).level),
133
133
  // Time spent waiting on the user is not the task's: its budget clock waits too.
134
134
  // The questions go to the web page, where a free-text answer may run to several lines.
135
- ask: async (question) => (canAsk() ? whileAsking(() => askText(question)) : undefined),
136
- choose: async (title, options) => (canAsk() ? whileAsking(() => askChoice(title, options)) : undefined),
135
+ ask: async (question) => (canAsk() ? whileAsking(() => askText(question, undefined, ctx)) : undefined),
136
+ choose: async (title, options) => (canAsk() ? whileAsking(() => askChoice(title, options, undefined, ctx)) : undefined),
137
137
  // An agent's own questions (the designer's), relayed as the questionnaire.
138
138
  ...(canAsk() ? { askQuestions: (questions: AskQuestion[], from: string, askSignal?: AbortSignal) => whileAsking(() => askUser(questions, ctx, askSignal, from)) } : {}),
139
139
  notify: (message, level = "info") => {
@@ -207,6 +207,18 @@ export interface TaskGit {
207
207
  path?: string;
208
208
  }
209
209
 
210
+ /** A plan step the user chose to skip, with the reason they gave; matched to the plan by its text. */
211
+ export interface SkippedStep {
212
+ /** The step's text as the plan lists it. */
213
+ text: string;
214
+ /** Why the user skipped it, in their words (required). */
215
+ reason: string;
216
+ at: string;
217
+ }
218
+
219
+ /** Longest skip reason kept. */
220
+ export const MAX_SKIP_REASON = 1000;
221
+
210
222
  export interface PhaseTiming {
211
223
  phase: TaskState;
212
224
  elapsedMs: number;
@@ -265,6 +277,8 @@ export interface Task {
265
277
  qaRisk?: QaRiskAssessment;
266
278
  /** Set only by the user: the work is accepted with the lint errors it has, so the task may complete. */
267
279
  lintWaiver?: { at: string };
280
+ /** Plan steps the user skipped, each with their reason; the oracle is told not to work on them. */
281
+ skippedSteps?: SkippedStep[];
268
282
  }
269
283
 
270
284
  export function createTask(