@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
@@ -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 } : {}) });