@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.
- package/README.md +2 -2
- package/THIRD_PARTY_NOTICES.md +4 -3
- package/package.json +9 -7
- package/prompts/designer.md +15 -1
- package/prompts/master.md +4 -1
- package/src/ask/project-styles.ts +64 -0
- package/src/ask/tool.ts +23 -4
- package/src/ask/types.ts +1 -1
- package/src/ask/web.ts +27 -6
- package/src/classifier/classifier.ts +14 -2
- package/src/classifier/hosts.ts +12 -18
- package/src/classifier/instance.ts +7 -4
- package/src/execution/delegation.ts +2 -1
- package/src/execution/pi-runner.ts +11 -2
- package/src/lobby/host.ts +6 -0
- package/src/lobby/keys.ts +2 -1
- package/src/lobby/service.ts +75 -7
- package/src/lobby/session-questions.ts +49 -0
- package/src/lobby/sessions.ts +66 -0
- package/src/pi/auto-compaction.ts +46 -19
- package/src/pi/events.ts +2 -0
- package/src/pi/fresh-context.ts +10 -8
- package/src/pi/plan-checklist.ts +47 -15
- package/src/pi/tools.ts +2 -2
- package/src/schemas/task.ts +14 -0
- package/src/state/persistence.ts +4 -1
- package/src/webui/api/index.ts +36 -2
- package/src/webui/api/lobby.ts +1 -1
- package/src/webui/api/plan-facts.ts +9 -4
- package/src/webui/api/sessions.ts +21 -0
- package/src/webui/api/tasks.ts +12 -1
- package/src/webui/api/terminal.ts +42 -0
- package/src/webui/dev/fake-service.ts +42 -1
- package/src/webui/palette-core.ts +228 -228
- package/src/webui/protocol.ts +29 -2
- package/src/webui/server.ts +16 -1
- package/src/webui/terminal.ts +292 -0
- package/src/workflow/workflow.ts +3 -2
- package/webui/dist/assets/TerminalView-BrP-ENHg.css +1 -0
- package/webui/dist/assets/TerminalView-D_j85u4O.js +16 -0
- package/webui/dist/assets/dm-sans-latin-wght-normal-Xz1IZZA0.woff2 +0 -0
- package/webui/dist/assets/figtree-latin-wght-normal-D_ZTVpCC.woff2 +0 -0
- package/webui/dist/assets/fira-code-latin-wght-normal-CHoedHDv.woff2 +0 -0
- package/webui/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- package/webui/dist/assets/geist-mono-latin-wght-normal-XN7g48iV.woff2 +0 -0
- package/webui/dist/assets/ibm-plex-sans-latin-wght-normal-IvpUvPa2.woff2 +0 -0
- package/webui/dist/assets/index-BkM--P3x.js +115 -0
- package/webui/dist/assets/index-DrHXotS7.css +2 -0
- package/webui/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
- package/webui/dist/assets/manrope-latin-wght-normal-DHIcAJRg.woff2 +0 -0
- package/webui/dist/assets/nunito-latin-wght-normal-BzFMHfZw.woff2 +0 -0
- package/webui/dist/assets/outfit-latin-wght-normal-Bc-8i84L.woff2 +0 -0
- package/webui/dist/assets/plus-jakarta-sans-latin-wght-normal-eXO_dkmS.woff2 +0 -0
- package/webui/dist/assets/roboto-mono-latin-wght-normal-CZtBPCCa.woff2 +0 -0
- package/webui/dist/assets/source-code-pro-latin-wght-normal-BP8Zz55n.woff2 +0 -0
- package/webui/dist/assets/source-sans-3-latin-wght-normal-Bo_j9asI.woff2 +0 -0
- package/webui/dist/assets/space-grotesk-latin-wght-normal-BhU9QXUp.woff2 +0 -0
- package/webui/dist/build.json +110 -94
- package/webui/dist/index.html +2 -2
- package/webui/dist/assets/index-B5AnxwRU.css +0 -2
- package/webui/dist/assets/index-CznnCkjK.js +0 -114
package/src/lobby/service.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
+
}
|
package/src/lobby/sessions.ts
CHANGED
|
@@ -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
|
|
38
|
-
*
|
|
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
|
|
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
|
-
|
|
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)" : ""}.` : ""}` : "",
|
package/src/pi/fresh-context.ts
CHANGED
|
@@ -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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
}
|
package/src/pi/plan-checklist.ts
CHANGED
|
@@ -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
|
|
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
|
|
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.
|
|
382
|
-
* and
|
|
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
|
|
388
|
-
const
|
|
389
|
-
const
|
|
390
|
-
|
|
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") => {
|
package/src/schemas/task.ts
CHANGED
|
@@ -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(
|