@a-t-h-i/bot-lobby 0.6.49 → 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 (52) hide show
  1. package/README.md +2 -2
  2. package/THIRD_PARTY_NOTICES.md +4 -3
  3. package/package.json +9 -7
  4. package/src/classifier/classifier.ts +14 -2
  5. package/src/classifier/hosts.ts +12 -18
  6. package/src/classifier/instance.ts +7 -4
  7. package/src/execution/delegation.ts +2 -1
  8. package/src/execution/pi-runner.ts +11 -2
  9. package/src/lobby/host.ts +6 -0
  10. package/src/lobby/keys.ts +2 -1
  11. package/src/lobby/service.ts +71 -4
  12. package/src/lobby/sessions.ts +35 -0
  13. package/src/pi/auto-compaction.ts +46 -19
  14. package/src/pi/events.ts +2 -0
  15. package/src/pi/fresh-context.ts +10 -8
  16. package/src/pi/plan-checklist.ts +47 -15
  17. package/src/schemas/task.ts +14 -0
  18. package/src/state/persistence.ts +4 -1
  19. package/src/webui/api/index.ts +36 -2
  20. package/src/webui/api/lobby.ts +1 -1
  21. package/src/webui/api/plan-facts.ts +9 -4
  22. package/src/webui/api/sessions.ts +21 -0
  23. package/src/webui/api/tasks.ts +12 -1
  24. package/src/webui/api/terminal.ts +42 -0
  25. package/src/webui/dev/fake-service.ts +42 -1
  26. package/src/webui/protocol.ts +29 -2
  27. package/src/webui/server.ts +16 -1
  28. package/src/webui/terminal.ts +292 -0
  29. package/src/workflow/workflow.ts +3 -2
  30. package/webui/dist/assets/TerminalView-BrP-ENHg.css +1 -0
  31. package/webui/dist/assets/TerminalView-D_j85u4O.js +16 -0
  32. package/webui/dist/assets/dm-sans-latin-wght-normal-Xz1IZZA0.woff2 +0 -0
  33. package/webui/dist/assets/figtree-latin-wght-normal-D_ZTVpCC.woff2 +0 -0
  34. package/webui/dist/assets/fira-code-latin-wght-normal-CHoedHDv.woff2 +0 -0
  35. package/webui/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  36. package/webui/dist/assets/geist-mono-latin-wght-normal-XN7g48iV.woff2 +0 -0
  37. package/webui/dist/assets/ibm-plex-sans-latin-wght-normal-IvpUvPa2.woff2 +0 -0
  38. package/webui/dist/assets/index-BkM--P3x.js +115 -0
  39. package/webui/dist/assets/index-DrHXotS7.css +2 -0
  40. package/webui/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  41. package/webui/dist/assets/manrope-latin-wght-normal-DHIcAJRg.woff2 +0 -0
  42. package/webui/dist/assets/nunito-latin-wght-normal-BzFMHfZw.woff2 +0 -0
  43. package/webui/dist/assets/outfit-latin-wght-normal-Bc-8i84L.woff2 +0 -0
  44. package/webui/dist/assets/plus-jakarta-sans-latin-wght-normal-eXO_dkmS.woff2 +0 -0
  45. package/webui/dist/assets/roboto-mono-latin-wght-normal-CZtBPCCa.woff2 +0 -0
  46. package/webui/dist/assets/source-code-pro-latin-wght-normal-BP8Zz55n.woff2 +0 -0
  47. package/webui/dist/assets/source-sans-3-latin-wght-normal-Bo_j9asI.woff2 +0 -0
  48. package/webui/dist/assets/space-grotesk-latin-wght-normal-BhU9QXUp.woff2 +0 -0
  49. package/webui/dist/build.json +108 -92
  50. package/webui/dist/index.html +2 -2
  51. package/webui/dist/assets/index-C3p3zxoV.js +0 -114
  52. package/webui/dist/assets/index-DE9jHz-C.css +0 -2
@@ -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
+ }
@@ -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(
@@ -104,7 +104,7 @@ export function saveTask(root: string, configDir: string, task: Task): void {
104
104
  rememberTiming(task);
105
105
  }
106
106
 
107
- /** Explicit user waivers and resolved approvals must survive an in-flight worker's snapshot. */
107
+ /** Explicit user waivers, skipped steps and resolved approvals must survive an in-flight worker's snapshot. */
108
108
  function preserveUserInterventions(task: Task, latest: Task): void {
109
109
  const newlyWaived = !task.qaWaiver && latest.qaWaiver;
110
110
  task.qaWaiver ??= latest.qaWaiver;
@@ -117,6 +117,9 @@ function preserveUserInterventions(task: Task, latest: Task): void {
117
117
  const resolved = latest.approvals.find((entry) => entry.id === approval.id && entry.status !== "pending");
118
118
  if (approval.status === "pending" && resolved) Object.assign(approval, resolved);
119
119
  }
120
+ const skipped = new Set((task.skippedSteps ?? []).map((entry) => entry.text));
121
+ const newSkips = (latest.skippedSteps ?? []).filter((entry) => !skipped.has(entry.text));
122
+ if (newSkips.length > 0) task.skippedSteps = [...(task.skippedSteps ?? []), ...newSkips];
120
123
  const keys = new Set(task.decisions.map((entry) => JSON.stringify(entry)));
121
124
  task.decisions.push(...latest.decisions.filter((entry) => !keys.has(JSON.stringify(entry))));
122
125
  }
@@ -15,13 +15,15 @@ import { lobbyAbort, lobbyHistory, lobbySend, lobbySnapshot } from "./lobby.ts";
15
15
  import { promptsAnswer, promptsDismiss, promptsList } from "./prompts.ts";
16
16
  import { plannerAnswer, plannerCommentLine, plannerGet, plannerNew, plannerRetry, plannerSend, plannerEditMessage, plannerToggleSeat, plannerSave, plannerPrevious, plannerPreviousArchive, plannerPreviousDelete, plannerPreviousGet, plannerPreviousOpen } from "./planner.ts";
17
17
  import { quickfixCancel, quickfixList, quickfixMovedToTask, quickfixRunAnyway, quickfixSubmit } from "./quickfix.ts";
18
- import { tasksArchive, tasksArchived, tasksAuto, tasksComment, tasksComments, tasksDelete, tasksEditComment, tasksGet, tasksList, tasksMessage, tasksRestore, tasksOpen, tasksResume, tasksDeliveryReview, tasksDeliveryDefer, tasksDeliver, tasksAccept, tasksForceClose, tasksResolveApproval } from "./tasks.ts";
18
+ import { tasksArchive, tasksArchived, tasksAuto, tasksComment, tasksComments, tasksDelete, tasksEditComment, tasksGet, tasksList, tasksMessage, tasksRestore, tasksOpen, tasksResume, tasksDeliveryReview, tasksDeliveryDefer, tasksDeliver, tasksAccept, tasksSkipStep, tasksForceClose, tasksResolveApproval } from "./tasks.ts";
19
19
  import { knowledgeAdd, knowledgeAsk, knowledgeComment, knowledgeEdit, knowledgeFiles, knowledgeOpen, knowledgeRemove, knowledgeReplaceFile, knowledgeUnnote } from "./knowledge.ts";
20
20
  import { plansDiscard, plansGet, plansStart } from "./plans.ts";
21
21
  import { gitCancelReview, gitJev, gitPull, gitPulls, gitReview } from "./git.ts";
22
22
  import { issuesCreate, issuesGet, issuesList } from "./issues.ts";
23
- import { sessionsAnswer, sessionsChat, sessionsList, sessionsMessage, sessionsStart, sessionsStop, sessionsSwitch } from "./sessions.ts";
23
+ import { sessionsAnswer, sessionsArchive, sessionsChat, sessionsList, sessionsMessage, sessionsRename, sessionsStart, sessionsStop, sessionsSwitch } from "./sessions.ts";
24
24
  import { settingsGet, settingsLinters, settingsSet } from "./settings.ts";
25
+ import { terminalClose, terminalInput, terminalList, terminalOpen, terminalResize } from "./terminal.ts";
26
+ import { MAX_INPUT_CHARS } from "../terminal.ts";
25
27
  import { themesChoose, themesGet, themesRemove, themesRename, themesSave } from "./themes.ts";
26
28
  import { noticeText, pushNotice } from "../notices.ts";
27
29
 
@@ -134,6 +136,10 @@ function buildRoutes(): Record<string, Route> {
134
136
  schema: Type.Object({ taskId: TaskId }, { additionalProperties: false }),
135
137
  run: (body, ctx) => tasksAccept(body as { taskId: string }, ctx),
136
138
  };
139
+ routes["tasks.skipStep"] = {
140
+ schema: Type.Object({ taskId: TaskId, step: Type.Integer({ minimum: 0, maximum: 49 }), text: Type.String({ minLength: 1, maxLength: 20_000 }), reason: Type.String({ minLength: 1, maxLength: 1000 }) }, { additionalProperties: false }),
141
+ run: (body, ctx) => tasksSkipStep(body as Parameters<typeof tasksSkipStep>[0], ctx),
142
+ };
137
143
  routes["tasks.forceClose"] = {
138
144
  schema: Type.Object({ taskId: TaskId, title: Type.String({ maxLength: 20_000 }) }, { additionalProperties: false }),
139
145
  run: (body, ctx) => tasksForceClose(body as { taskId: string; title: string }, ctx),
@@ -219,6 +225,34 @@ function buildRoutes(): Record<string, Route> {
219
225
  schema: Type.Object({ key: Type.Optional(TaskId), sessionId: Type.Optional(TaskId), claimTaskId: Type.Optional(TaskId) }, { additionalProperties: false }),
220
226
  run: (body, ctx) => sessionsSwitch(body as { key?: string; sessionId?: string; claimTaskId?: string }, ctx),
221
227
  };
228
+ const TerminalId = Type.String({ minLength: 1, maxLength: 40 });
229
+ const Cols = Type.Integer({ minimum: 2, maximum: 500 });
230
+ const Rows = Type.Integer({ minimum: 2, maximum: 300 });
231
+ routes["terminal.list"] = { schema: Empty, run: () => terminalList() };
232
+ routes["terminal.open"] = {
233
+ schema: Type.Object({ cols: Cols, rows: Rows }, { additionalProperties: false }),
234
+ run: (body, ctx) => terminalOpen(body as { cols: number; rows: number }, ctx),
235
+ };
236
+ routes["terminal.input"] = {
237
+ schema: Type.Object({ id: TerminalId, data: Type.String({ minLength: 1, maxLength: MAX_INPUT_CHARS }) }, { additionalProperties: false }),
238
+ run: (body) => terminalInput(body as { id: string; data: string }),
239
+ };
240
+ routes["terminal.resize"] = {
241
+ schema: Type.Object({ id: TerminalId, cols: Cols, rows: Rows }, { additionalProperties: false }),
242
+ run: (body) => terminalResize(body as { id: string; cols: number; rows: number }),
243
+ };
244
+ routes["terminal.close"] = {
245
+ schema: Type.Object({ id: TerminalId }, { additionalProperties: false }),
246
+ run: (body) => terminalClose(body as { id: string }),
247
+ };
248
+ routes["sessions.rename"] = {
249
+ schema: Type.Object({ key: Type.Optional(TaskId), name: Type.String({ minLength: 1, maxLength: 500 }) }, { additionalProperties: false }),
250
+ run: (body, ctx) => sessionsRename(body as { key?: string; name: string }, ctx),
251
+ };
252
+ routes["sessions.archive"] = {
253
+ schema: Type.Object({ key: TaskId, archived: Type.Optional(Type.Boolean()) }, { additionalProperties: false }),
254
+ run: (body, ctx) => sessionsArchive(body as { key: string; archived?: boolean }, ctx),
255
+ };
222
256
  routes["sessions.answer"] = {
223
257
  schema: Type.Object({ key: TaskId, dialogId: TaskId, answer: DialogAnswer }, { additionalProperties: false }),
224
258
  run: (body, ctx) => sessionsAnswer(body as { key: string; dialogId: string; answer: { value: string } | { confirmed: boolean } | { cancelled: true } }, ctx),
@@ -22,7 +22,7 @@ function planFacts(task: Task, work: WorkProjection | undefined): Pick<SnapshotT
22
22
  const active = steps.filter((step) => step.active);
23
23
  const current = steps.find((step) => step.status === "current");
24
24
  return {
25
- progress: { done: steps.filter((step) => step.status === "done").length, total: steps.length },
25
+ progress: { done: steps.filter((step) => step.status === "done" || step.status === "skipped").length, total: steps.length },
26
26
  ...(current ? { currentStep: current.text } : {}),
27
27
  ...(active.length > 0 ? { activeSteps: active.map((step) => ({ text: step.text, workedMs: step.workedMs, activeAgentCount: step.activeAgentCount, runIds: step.runIds })) } : {}),
28
28
  ...clock,
@@ -4,7 +4,7 @@
4
4
  * right now and the work time. Kept here (not via `persistedRuns`) so the
5
5
  * server never loads the TUI.
6
6
  */
7
- import { planChecklist, planSteps, stepWork, type PlanStep } from "../../pi/plan-checklist.ts";
7
+ import { planChecklist, planSteps, skippedIndexes, stepWork, type PlanStep } from "../../pi/plan-checklist.ts";
8
8
  import type { AgentRun } from "../../schemas/findings.ts";
9
9
  import type { Task } from "../../schemas/task.ts";
10
10
  import type { WorkProjection } from "../../state/work-time.ts";
@@ -44,6 +44,8 @@ export interface StepView {
44
44
  agents: StepAgent[];
45
45
  /** Worker time on the step, in ms. */
46
46
  workedMs: number;
47
+ /** The user skipped it: why, and when. */
48
+ skip?: { reason: string; at: string };
47
49
  }
48
50
 
49
51
  export interface StepAgent {
@@ -62,8 +64,10 @@ export interface StepAgent {
62
64
  export function stepViews(task: Task, work: WorkProjection | undefined, now = Date.now()): StepView[] {
63
65
  if (!task.plan) return [];
64
66
  const runs = [...persistedTaskRuns(task), ...activeRuns(task, work)];
65
- const checklist = planChecklist(task.plan, runs);
66
- const times = stepWork(planSteps(task.plan), runs, now);
67
+ const texts = planSteps(task.plan);
68
+ const checklist = planChecklist(task.plan, runs, task.skippedSteps);
69
+ const times = stepWork(texts, runs, now, skippedIndexes(texts, task.skippedSteps));
70
+ const skipOf = (text: string) => task.skippedSteps?.find((entry) => skippedIndexes([text], [entry]).size > 0);
67
71
  const byId = new Map(runs.map((run) => [run.runId, run]));
68
72
  return checklist.map((step, index) => {
69
73
  const time = times[index] ?? { ms: 0, active: false, activeAgentCount: 0, runIds: [] };
@@ -71,6 +75,7 @@ export function stepViews(task: Task, work: WorkProjection | undefined, now = Da
71
75
  const run = byId.get(runId);
72
76
  return run ? [{ runId, ...(run.parentRunId ? { parentRunId: run.parentRunId } : {}), domain: run.domain, instruction: (run.instruction ?? "").split("\n")[0]!.trim(), startedAt: run.startedAt }] : [];
73
77
  });
74
- return { text: step.text, status: time.active ? "current" : step.status, active: time.active, activeAgentCount: time.activeAgentCount, runIds: time.runIds, agents, workedMs: Math.round(time.ms) };
78
+ const skip = step.status === "skipped" ? skipOf(step.text) : undefined;
79
+ return { text: step.text, status: time.active && !skip ? "current" : step.status, active: time.active && !skip, activeAgentCount: time.activeAgentCount, runIds: time.runIds, agents, workedMs: Math.round(time.ms), ...(skip ? { skip: { reason: skip.reason, at: skip.at } } : {}) };
75
80
  });
76
81
  }
@@ -114,3 +114,24 @@ export function sessionsAnswer(body: { key: string; dialogId: string; answer: Di
114
114
  session!.answer(body.dialogId, body.answer);
115
115
  return { notice: "answer sent" };
116
116
  }
117
+
118
+ /** Rename this window's session (no key) or a background one. */
119
+ export function sessionsRename(body: { key?: string; name: string }, ctx: ApiContext): { notice: string } {
120
+ if (!ctx.service.renameSession) fail(501, "unsupported", "this lobby cannot rename sessions");
121
+ try {
122
+ return { notice: ctx.service.renameSession!(body.key, body.name) };
123
+ } catch (error) {
124
+ fail(/^no session/.test((error as Error).message) ? 404 : 400, /^no session/.test((error as Error).message) ? "not_found" : "bad_request", (error as Error).message);
125
+ }
126
+ }
127
+
128
+ /** Put an ended background session away from the list (`archived` true, the default), or bring it back. */
129
+ export function sessionsArchive(body: { key: string; archived?: boolean }, ctx: ApiContext): { notice: string } {
130
+ if (!ctx.service.archiveSession) fail(501, "unsupported", "this lobby cannot archive sessions");
131
+ try {
132
+ return { notice: ctx.service.archiveSession!(body.key, body.archived ?? true) };
133
+ } catch (error) {
134
+ const message = (error as Error).message;
135
+ fail(/^no /.test(message) ? 404 : 409, /^no /.test(message) ? "not_found" : "conflict", message);
136
+ }
137
+ }
@@ -70,7 +70,7 @@ function checkOf(task: Task): TaskRow["check"] {
70
70
  function progressOf(task: Task, work: WorkProjection | undefined, now: number): TaskRow["progress"] {
71
71
  const steps = stepViews(task, work, now);
72
72
  if (steps.length === 0) return undefined;
73
- return { done: steps.filter((step) => step.status === "done").length, total: steps.length };
73
+ return { done: steps.filter((step) => step.status === "done" || step.status === "skipped").length, total: steps.length };
74
74
  }
75
75
 
76
76
  /** The work clock as the page shows it; a finished task's no longer counts. */
@@ -302,6 +302,15 @@ export async function tasksAccept(body: { taskId: string }, ctx: ApiContext): Pr
302
302
  return ctx.service.acceptTask(body.taskId);
303
303
  }
304
304
 
305
+ /** Skip a plan step with the user's reason; refused once the plan changed under the page. */
306
+ export async function tasksSkipStep(body: { taskId: string; step: number; text: string; reason: string }, ctx: ApiContext): Promise<{ notice: string }> {
307
+ const task = interventionTask(body.taskId, ctx);
308
+ if (!task.plan) fail(400, "bad_request", "this task has no plan yet");
309
+ if (!body.reason.trim()) fail(400, "bad_request", "say why you are skipping this step");
310
+ if (!ctx.service.skipTaskStep) fail(400, "bad_request", "skipping steps is unavailable");
311
+ return ctx.service.skipTaskStep(body.taskId, body.step, body.text, body.reason);
312
+ }
313
+
305
314
  export async function tasksForceClose(body: { taskId: string; title: string }, ctx: ApiContext): Promise<{ notice: string }> {
306
315
  const task = interventionTask(body.taskId, ctx);
307
316
  if (body.title !== task.title) fail(400, "bad_request", "the title must match exactly");
@@ -337,6 +346,7 @@ export function tasksGet(body: { taskId: string }, ctx: ApiContext): TaskDetail
337
346
  const details = found.plan ? planDetails(found.plan) : undefined;
338
347
  return {
339
348
  canAccept: Boolean(active && ctx.service.acceptTask) && acceptable(found),
349
+ canSkip: Boolean(active && ctx.service.skipTaskStep && found.plan) && !TERMINAL_STATES.includes(found.state),
340
350
  canForceClose: Boolean(active && ctx.service.forceCloseTask) && !TERMINAL_STATES.includes(found.state) && (ctx.service.canForceCloseTask?.(found.id) ?? true),
341
351
  timing: projectPhaseTiming(found, new Date(now).toISOString()),
342
352
  ...(clock ? { work: clock } : {}),
@@ -348,6 +358,7 @@ export function tasksGet(body: { taskId: string }, ctx: ApiContext): TaskDetail
348
358
  steps: stepViews(found, work, now).map((step) => ({
349
359
  text: step.text,
350
360
  status: step.status === "pending" ? "open" : step.status,
361
+ ...(step.skip ? { skip: step.skip } : {}),
351
362
  ...(step.active ? { active: true, workedMs: step.workedMs, activeAgentCount: step.activeAgentCount, agents: step.agents } : {}),
352
363
  })),
353
364
  amendments: [...found.amendments],
@@ -0,0 +1,42 @@
1
+ /** `terminal.*`: shells in the project's folder for the page's terminal drawer (see `../terminal.ts`). */
2
+ import { fail, type ApiContext } from "./index.ts";
3
+ import { terminalBackend, terminals, TerminalError } from "../terminal.ts";
4
+ import type { TerminalInfo } from "../protocol.ts";
5
+
6
+ function guard<T>(run: () => T): T {
7
+ try {
8
+ return run();
9
+ } catch (error) {
10
+ if (error instanceof TerminalError) fail(error.missing ? 404 : 409, error.missing ? "not_found" : "conflict", error.message);
11
+ throw error;
12
+ }
13
+ }
14
+
15
+ export async function terminalList(): Promise<{ available: boolean; reason?: string; terminals: TerminalInfo[] }> {
16
+ const backend = await terminalBackend();
17
+ return { available: backend.kind !== "none", ...(backend.kind === "none" ? { reason: backend.reason } : {}), terminals: terminals.list() };
18
+ }
19
+
20
+ export async function terminalOpen(body: { cols: number; rows: number }, ctx: ApiContext): Promise<TerminalInfo> {
21
+ try {
22
+ return await terminals.open(ctx.service.projectRoot?.() ?? process.cwd(), body.cols, body.rows);
23
+ } catch (error) {
24
+ if (error instanceof TerminalError) fail(409, "conflict", error.message);
25
+ throw error;
26
+ }
27
+ }
28
+
29
+ export function terminalInput(body: { id: string; data: string }): Record<string, never> {
30
+ guard(() => terminals.input(body.id, body.data));
31
+ return {};
32
+ }
33
+
34
+ export function terminalResize(body: { id: string; cols: number; rows: number }): Record<string, never> {
35
+ guard(() => terminals.resize(body.id, body.cols, body.rows));
36
+ return {};
37
+ }
38
+
39
+ export function terminalClose(body: { id: string }): Record<string, never> {
40
+ guard(() => terminals.close(body.id));
41
+ return {};
42
+ }
@@ -9,6 +9,7 @@ import { LobbyFeed } from "../../lobby/feed.ts";
9
9
  import { promptHub } from "../../lobby/prompt-hub.ts";
10
10
  import { lobbyTopics } from "../../lobby/topics.ts";
11
11
  import type { BackgroundSession, DialogAnswer, SessionDialog } from "../../lobby/sessions.ts";
12
+ import { cleanSessionName } from "../../lobby/sessions.ts";
12
13
  import type { PlanComment } from "../../state/comments.ts";
13
14
  import type { LobbyService } from "../../lobby/host.ts";
14
15
  import { loadScenario, type ScenarioFixture } from "./fixtures.ts";
@@ -280,11 +281,16 @@ function fakeBackground(entry: NonNullable<ScenarioFixture["backgroundSessions"]
280
281
  stop() {
281
282
  status = "exited";
282
283
  dialogs.length = 0;
284
+ lobbyTopics.bump("sessions");
283
285
  },
284
286
  answer(id: string, _answer: DialogAnswer) {
285
287
  const index = dialogs.findIndex((dialog) => dialog.id === id);
286
288
  if (index >= 0) dialogs.splice(index, 1);
287
289
  },
290
+ rename(name: string) {
291
+ session.name = name;
292
+ lobbyTopics.bump("sessions");
293
+ },
288
294
  };
289
295
  return session as unknown as BackgroundSession;
290
296
  }
@@ -335,6 +341,8 @@ export function createFixtureService(name: string, feed = new LobbyFeed()): Lobb
335
341
  const auto = new Set(fixture.autoTasks ?? []);
336
342
  const comments = new Map<string, Array<Record<string, unknown>>>(Object.entries(fixture.taskComments ?? {}));
337
343
  const backgrounds = (fixture.backgroundSessions ?? []).map(fakeBackground);
344
+ const archivedBackgrounds: BackgroundSession[] = [];
345
+ let sessionName = fixture.status.sessionName;
338
346
  let planning = fakePlanner(fixture.mockPlanner ?? defaultPlanner());
339
347
  let config: BotLobbyConfig = resolveConfig(fixture.settings?.config ?? {});
340
348
  const models = fixture.settings?.models ?? DEFAULT_MODELS;
@@ -391,7 +399,7 @@ export function createFixtureService(name: string, feed = new LobbyFeed()): Lobb
391
399
  let commentSeq = 0;
392
400
  const service = {
393
401
  sessionId: () => fixture.status.sessionId,
394
- sessionName: () => fixture.status.sessionName,
402
+ sessionName: () => sessionName,
395
403
  masterBusy: () => fixture.status.busy,
396
404
  config: () => config,
397
405
  linters: () => fixture.settings?.linters ?? [{ tool: "ESLint", folder: "", installed: true }, { tool: "Ruff", folder: "services/api", installed: false }],
@@ -481,6 +489,15 @@ export function createFixtureService(name: string, feed = new LobbyFeed()): Lobb
481
489
  },
482
490
  sendToTask: (taskId: string, _text: string) => (tasks.some((task) => (task as { id: string }).id === taskId) ? `sent — the session driving ${taskId} passes it to its oracle` : `no task ${taskId}`),
483
491
  sendToSession: (_sessionId: string, _text: string) => "sent — that session passes it to its oracle within a few seconds",
492
+ skipTaskStep: async (taskId: string, step: number, text: string, reason: string) => {
493
+ const task = tasks.find((entry) => (entry as { id: string }).id === taskId) as { id: string; skippedSteps?: Array<{ text: string; reason: string; at: string }>; decisions?: Array<{ domain: string; text: string; createdAt: string }> } | undefined;
494
+ if (!task) throw new Error(`no task ${taskId}`);
495
+ const at = new Date().toISOString();
496
+ task.skippedSteps = [...(task.skippedSteps ?? []), { text, reason: reason.trim(), at }];
497
+ task.decisions = [...(task.decisions ?? []), { domain: "master", text: `The user skipped step ${step + 1} ("${text}") from the Tasks screen. Reason: ${reason.trim()}`, createdAt: at }];
498
+ lobbyTopics.bump("tasks");
499
+ return { notice: `step ${step + 1} skipped; the oracle has been told` };
500
+ },
484
501
  resumeTask: async (taskId: string) => {
485
502
  const task = tasks.find((entry) => (entry as { id: string }).id === taskId) as { id: string; title: string; paused?: boolean; ownerSessionId?: string } | undefined;
486
503
  if (!task) return { notice: `no task ${taskId}` };
@@ -500,6 +517,30 @@ export function createFixtureService(name: string, feed = new LobbyFeed()): Lobb
500
517
  return { notice: `resumed ${taskId} in a background session`, key: session.key };
501
518
  },
502
519
  sessions: () => [...backgrounds],
520
+ renameSession: (key: string | undefined, name: string) => {
521
+ const clean = cleanSessionName(name);
522
+ if (!key) {
523
+ sessionName = clean;
524
+ lobbyTopics.bump("status");
525
+ lobbyTopics.bump("sessions");
526
+ return `renamed this window to ${clean}`;
527
+ }
528
+ const session = backgrounds.find((entry) => entry.key === key);
529
+ if (!session) throw new Error(`no session ${key}`);
530
+ session.rename(clean);
531
+ return `renamed ${key} to ${clean}`;
532
+ },
533
+ archiveSession: (key: string, archived: boolean) => {
534
+ const from = archived ? backgrounds : archivedBackgrounds;
535
+ const index = from.findIndex((entry) => entry.key === key);
536
+ if (index < 0) throw new Error(archived ? `no session ${key} to archive` : `no archived session ${key}`);
537
+ const session = from[index]!;
538
+ if (session.alive) throw new Error(`${session.name} is still running: stop it before archiving it`);
539
+ from.splice(index, 1);
540
+ (archived ? archivedBackgrounds : backgrounds).push(session);
541
+ lobbyTopics.bump("sessions");
542
+ return archived ? `archived ${session.name}` : `brought back ${session.name}`;
543
+ },
503
544
  startSession: (start: { request?: string; plan?: { id: string; title: string }; auto?: boolean }) => {
504
545
  const key = `S${backgrounds.length + 1}`;
505
546
  const session = fakeBackground({ key, name: start.plan?.title ?? start.request?.trim().slice(0, 40) ?? "mock session", status: "starting", ...(start.plan ? { planId: start.plan.id } : {}) });
@@ -218,6 +218,23 @@ export type StreamEvent =
218
218
  | { type: "reply"; text: string }
219
219
  | { type: "notice"; text: string; level: NoticeLevel };
220
220
 
221
+ /** A terminal the server runs (`terminal.*`): a shell in the project's folder. */
222
+ export interface TerminalInfo {
223
+ id: string;
224
+ title: string;
225
+ cwd: string;
226
+ alive: boolean;
227
+ /** The shell's exit code, once it ended. */
228
+ exitCode?: number;
229
+ startedAt: number;
230
+ }
231
+
232
+ /** One message on a terminal's stream (`GET /api/terminal.stream?id=…`). */
233
+ export type TerminalStreamEvent =
234
+ | { type: "reset"; data: string; alive: boolean; code?: number }
235
+ | { type: "data"; data: string }
236
+ | { type: "exit"; code?: number };
237
+
221
238
  /** The planning session as `planner.get` describes it. */
222
239
  export interface PlannerSnapshot {
223
240
  /** Seats on the panel next round. */
@@ -324,6 +341,8 @@ export interface EntryRefInfo {
324
341
  /** `tasks.get`: one task read whole, in the terminal's wording. */
325
342
  export interface TaskDetail {
326
343
  canAccept: boolean;
344
+ /** Open steps can be skipped (the user gives a reason). */
345
+ canSkip: boolean;
327
346
  canForceClose: boolean;
328
347
  timing?: TimingProjection;
329
348
  delivery?: Delivery;
@@ -337,9 +356,9 @@ export interface TaskDetail {
337
356
  planDetails?: string;
338
357
  /**
339
358
  * The plan as a checklist, in order; `current` is the step under way, `active` the steps a worker is on right now,
340
- * each with its worker time (ms, as of the reply).
359
+ * each with its worker time (ms, as of the reply); `skip` carries the reason the user gave for skipping a step.
341
360
  */
342
- steps: Array<{ text: string; status: "done" | "current" | "open"; active?: boolean; workedMs?: number; activeAgentCount?: number; agents?: StepAgentInfo[] }>;
361
+ steps: Array<{ text: string; status: "done" | "current" | "open" | "skipped"; skip?: { reason: string; at: string }; active?: boolean; workedMs?: number; activeAgentCount?: number; agents?: StepAgentInfo[] }>;
343
362
  /** How long the task's agents have worked on it, idle time left out (ms, as of the reply); `running` while they work. */
344
363
  work?: WorkClock;
345
364
  amendments: string[];
@@ -520,6 +539,7 @@ export interface Api {
520
539
  "tasks.open": { request: { taskId: string }; result: { sessionId?: string; key?: string; notice?: string } };
521
540
  "tasks.resume": { request: { taskId: string }; result: { notice: string; key?: string } };
522
541
  "tasks.accept": { request: { taskId: string }; result: { notice: string } };
542
+ "tasks.skipStep": { request: { taskId: string; step: number; text: string; reason: string }; result: { notice: string } };
523
543
  "tasks.forceClose": { request: { taskId: string; title: string }; result: { notice: string } };
524
544
  "tasks.resolveApproval": { request: { taskId: string; approvalId: string; decision: "approved" | "rejected"; note?: string }; result: { notice: string } };
525
545
  "tasks.deliveryReview": { request: { taskId: string }; result: { delivery: Delivery } };
@@ -535,6 +555,13 @@ export interface Api {
535
555
  "sessions.message": { request: { key?: string; sessionId?: string; text: string; attachments?: string[] }; result: { notice?: string } };
536
556
  "sessions.switch": { request: { key?: string; sessionId?: string; claimTaskId?: string }; result: { notice: string } };
537
557
  "sessions.answer": { request: { key: string; dialogId: string; answer: DialogAnswer }; result: { notice: string } };
558
+ "sessions.rename": { request: { key?: string; name: string }; result: { notice: string } };
559
+ "sessions.archive": { request: { key: string; archived?: boolean }; result: { notice: string } };
560
+ "terminal.list": { request: Record<string, never>; result: { available: boolean; reason?: string; terminals: TerminalInfo[] } };
561
+ "terminal.open": { request: { cols: number; rows: number }; result: TerminalInfo };
562
+ "terminal.input": { request: { id: string; data: string }; result: Record<string, never> };
563
+ "terminal.resize": { request: { id: string; cols: number; rows: number }; result: Record<string, never> };
564
+ "terminal.close": { request: { id: string }; result: Record<string, never> };
538
565
  "prompts.list": { request: Record<string, never>; result: { prompts: WebPrompt[] } };
539
566
  "prompts.answer": { request: { id: string; answer: unknown }; result: { notice?: string } };
540
567
  "prompts.dismiss": { request: { id: string }; result: Record<string, never> };
@@ -27,7 +27,8 @@ import {
27
27
  sessionCookie,
28
28
  sessionValue,
29
29
  } from "./auth.ts";
30
- import { callName, HttpError, routeApiCall, sendError, sendJson } from "./api/index.ts";
30
+ import { applyBaseHeaders, callName, HttpError, routeApiCall, sendError, sendJson } from "./api/index.ts";
31
+ import { terminals } from "./terminal.ts";
31
32
  import { distDir, serveStatic } from "./static.ts";
32
33
  import { EventHub } from "./events.ts";
33
34
  import { servePreview } from "./files.ts";
@@ -292,6 +293,19 @@ async function route(req: IncomingMessage, res: ServerResponse, state: RouteStat
292
293
  openStream(req, res, url, state);
293
294
  return;
294
295
  }
296
+ if (url.pathname === "/api/terminal.stream") {
297
+ if (req.method !== "GET") {
298
+ sendError(res, "unsupported", "only GET", 405);
299
+ return;
300
+ }
301
+ if (!isSignedIn(req.headers.cookie, state.session)) {
302
+ sendError(res, "unauthorized", "open the link Pi printed", 401);
303
+ return;
304
+ }
305
+ applyBaseHeaders(res);
306
+ if (!terminals.stream(req, res, url.searchParams.get("id") ?? "")) sendError(res, "not_found", "no such terminal", 404);
307
+ return;
308
+ }
295
309
  await routeApi(req, res, url, state);
296
310
  }
297
311
 
@@ -388,6 +402,7 @@ export async function startWebServer(options: WebServerOptions): Promise<WebServ
388
402
  return `http://127.0.0.1:${state.port}/#token=${state.link}`;
389
403
  },
390
404
  close: async () => {
405
+ terminals.closeAll();
391
406
  state.opener!.close();
392
407
  state.registry!.close();
393
408
  hub.close();