@a-t-h-i/bot-lobby 0.6.17 → 0.6.19

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 (70) hide show
  1. package/README.md +53 -697
  2. package/package.json +1 -1
  3. package/src/ask/relay.ts +6 -4
  4. package/src/ask/tool.ts +18 -3
  5. package/src/ask/types.ts +12 -0
  6. package/src/ask/web.ts +2 -1
  7. package/src/delivery/checks.ts +37 -0
  8. package/src/delivery/lock.ts +78 -0
  9. package/src/delivery/merge.ts +44 -0
  10. package/src/delivery/operations.ts +134 -0
  11. package/src/delivery/pr.ts +40 -0
  12. package/src/delivery/review.ts +90 -0
  13. package/src/delivery/rules.ts +46 -0
  14. package/src/delivery/source.ts +39 -0
  15. package/src/delivery/transport.ts +36 -0
  16. package/src/delivery/types.ts +60 -0
  17. package/src/execution/agent-runner.ts +1 -23
  18. package/src/index.ts +5 -2
  19. package/src/lobby/feed.ts +0 -6
  20. package/src/lobby/host.ts +6 -0
  21. package/src/lobby/planner.ts +26 -7
  22. package/src/lobby/pr-review.ts +1 -7
  23. package/src/lobby/prompt-hub.ts +7 -1
  24. package/src/lobby/pulls.ts +44 -1
  25. package/src/lobby/quickfix.ts +3 -16
  26. package/src/lobby/service.ts +104 -6
  27. package/src/lobby/sessions.ts +12 -0
  28. package/src/lobby/task-rows.ts +5 -0
  29. package/src/master/master.ts +1 -1
  30. package/src/master/research.ts +0 -1
  31. package/src/pi/events.ts +0 -2
  32. package/src/pi/model-settings.ts +7 -26
  33. package/src/pi/model-support.ts +4 -19
  34. package/src/schemas/configuration.ts +8 -38
  35. package/src/schemas/findings.ts +0 -2
  36. package/src/schemas/task.ts +14 -0
  37. package/src/state/attachments.ts +20 -20
  38. package/src/state/blocking-requests.ts +23 -0
  39. package/src/state/comments.ts +25 -3
  40. package/src/state/persistence.ts +12 -1
  41. package/src/state/phase-timing.ts +53 -0
  42. package/src/state/previews.ts +15 -0
  43. package/src/state/task-state.ts +2 -0
  44. package/src/state/timing-merge.ts +25 -0
  45. package/src/webui/api/index.ts +26 -2
  46. package/src/webui/api/lobby.ts +1 -1
  47. package/src/webui/api/planner.ts +15 -2
  48. package/src/webui/api/prompts.ts +4 -4
  49. package/src/webui/api/quickfix.ts +1 -1
  50. package/src/webui/api/sessions.ts +2 -2
  51. package/src/webui/api/tasks.ts +72 -2
  52. package/src/webui/dev/fake-service.ts +19 -1
  53. package/src/webui/files.ts +15 -17
  54. package/src/webui/gateway.ts +64 -0
  55. package/src/webui/projects.ts +111 -0
  56. package/src/webui/protocol.ts +12 -0
  57. package/src/webui/server.ts +35 -6
  58. package/src/webui/uploads.ts +19 -9
  59. package/src/workflow/approvals.ts +3 -0
  60. package/src/workflow/workflow.ts +18 -3
  61. package/webui/dist/assets/index-BJI9lE5R.js +98 -0
  62. package/webui/dist/assets/index-Dg0tvkeY.css +2 -0
  63. package/webui/dist/build.json +131 -79
  64. package/webui/dist/index.html +3 -3
  65. package/webui/dist/sw.js +0 -1
  66. package/src/execution/fallback.ts +0 -75
  67. package/src/pi/master-fallback.ts +0 -61
  68. package/webui/dist/assets/index-BjUR1j07.css +0 -2
  69. package/webui/dist/assets/index-uDDAhAAQ.js +0 -98
  70. package/webui/dist/vite.svg +0 -1
@@ -5,10 +5,18 @@
5
5
  */
6
6
  import { SessionManager, type ExtensionContext } from "@earendil-works/pi-coding-agent";
7
7
  import { TERMINAL_STATES, type Task } from "../schemas/task.ts";
8
- import { loadTask, peekTasks } from "../state/persistence.ts";
8
+ import { updateBlockingRequest } from "../state/blocking-requests.ts";
9
+ import { randomUUID } from "node:crypto";
10
+ import { realpathSync } from "node:fs";
11
+ import { resolve } from "node:path";
12
+ import { loadTask, ownedTask, peekTasks, saveTask } from "../state/persistence.ts";
13
+ import { refreshReview, deferReview } from "../delivery/review.ts";
14
+ import { deliver, type DeliveryRequest, type DeliveryStore } from "../delivery/operations.ts";
15
+ import type { LockIdentity } from "../delivery/lock.ts";
16
+ import { execCommand } from "./issues.ts";
9
17
  import { releaseAttachments } from "../state/attachments.ts";
10
18
  import { loadConfig, saveConfig as writeConfig } from "../state/project.ts";
11
- import { addPlanComment, readPlanComments } from "../state/comments.ts";
19
+ import { addPlanComment, editComment, readPlanComments, type PlanComment } from "../state/comments.ts";
12
20
  import { isAutoMode } from "../state/auto.ts";
13
21
  import { sendToInbox, sendToSession as leaveForSession } from "../state/inbox.ts";
14
22
  import { livePresence } from "../state/presence.ts";
@@ -40,6 +48,34 @@ import type { LiveSession, LobbyService, SwitchTarget } from "./host.ts";
40
48
  import { pushNotice, type NoticeLevel } from "../webui/notices.ts";
41
49
  import type { Runtime } from "./runtime.ts";
42
50
 
51
+ function deliveryTask(state: Runtime, taskId: string): Task {
52
+ if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{0,199}$/.test(taskId) || taskId === "." || taskId === "..") throw new Error("Invalid task ID.");
53
+ const task = loadTask(state.root, state.configDir, taskId);
54
+ if (!task || task.state !== "completed" || !task.delivery) throw new Error("Completed task delivery review is unavailable in this project.");
55
+ return task;
56
+ }
57
+
58
+ function recoveryDeliveryStore(state: Runtime, identity: LockIdentity): DeliveryStore {
59
+ if (identity.configDir !== resolve(state.configDir) || realpathSync(identity.projectRoot) !== identity.projectRoot) throw new Error("Interrupted owner has an untrusted project/config identity.");
60
+ return deliveryStore({ ...state, root: identity.projectRoot }, identity.taskId);
61
+ }
62
+ function deliveryStore(state: Runtime, taskId: string): DeliveryStore {
63
+ let baseline: string | undefined;
64
+ return {
65
+ identity: { projectRoot: realpathSync(state.root), configDir: resolve(state.configDir) },
66
+ recoveryStore: (identity) => recoveryDeliveryStore(state, identity),
67
+ load: () => { const task = deliveryTask(state, taskId); baseline = JSON.stringify(task.delivery); return task; },
68
+ save: (task) => {
69
+ const latest = deliveryTask(state, taskId);
70
+ if (JSON.stringify(latest.delivery) !== baseline) throw new Error("Delivery changed during operation; reconcile before retrying.");
71
+ latest.delivery = structuredClone(task.delivery);
72
+ saveTask(state.root, state.configDir, latest);
73
+ baseline = JSON.stringify(latest.delivery);
74
+ lobbyTopics.bump("tasks");
75
+ },
76
+ };
77
+ }
78
+
43
79
  /** The state background callbacks report to; set while the lobby service runs. */
44
80
  let serviceState: Runtime | undefined;
45
81
 
@@ -82,6 +118,12 @@ function addComment(state: Runtime, taskId: string, text: string): string {
82
118
  }
83
119
  lobbyFeed.log("LOBBY", `comment on ${taskId}'s plan saved`, "info");
84
120
  lobbyTopics.bump("plans");
121
+ lobbyTopics.bump("tasks");
122
+ return deliverComment(task, sessionId);
123
+ }
124
+
125
+ function deliverComment(task: Task, sessionId: string): string {
126
+ const taskId = task.id;
85
127
  if (task.ownerSessionId === sessionId) {
86
128
  if (deliverComments() === 0) return `comment saved — it reaches the oracle once ${taskId} is resumed or restored`;
87
129
  return `comment sent to the oracle — it will ${task.plan ? "amend the plan" : "revise the proposal"}`;
@@ -90,6 +132,26 @@ function addComment(state: Runtime, taskId: string, text: string): string {
90
132
  return `comment saved — the session driving ${taskId} passes it to its oracle`;
91
133
  }
92
134
 
135
+ /**
136
+ * Correct a comment this session sent. Ownership is checked again here, not
137
+ * only at the API boundary: the session that wrote the comment is the only one
138
+ * that may change its words.
139
+ */
140
+ function editOwnComment(state: Runtime, taskId: string, commentId: string, text: string): PlanComment {
141
+ const task = loadTask(state.root, state.configDir, taskId);
142
+ if (!task) throw new Error(`no task ${taskId}`);
143
+ const sessionId = state.ctx.sessionManager.getSessionId();
144
+ const comment = readPlanComments(state.root, state.configDir, taskId).find((entry) => entry.id === commentId);
145
+ if (!comment) throw new Error(`no comment ${commentId} on ${taskId}`);
146
+ if (!comment.by || comment.by !== sessionId) throw new Error(`only the session that wrote ${commentId} can edit it`);
147
+ const edited = editComment(state.root, state.configDir, taskId, commentId, text);
148
+ lobbyFeed.log("LOBBY", `comment ${commentId} on ${taskId}'s plan edited`, "info");
149
+ lobbyTopics.bump("plans");
150
+ lobbyTopics.bump("tasks");
151
+ deliverComment(task, sessionId);
152
+ return edited;
153
+ }
154
+
93
155
  function failed(state: Runtime, what: string, error: Error): void {
94
156
  lobbyFeed.log("LOBBY", `${what} — ${error.message}`, "error");
95
157
  lobbyNotify(state, `bot-lobby: ${what} — ${error.message}`, "error");
@@ -184,6 +246,20 @@ function sessionsChanged(): void {
184
246
  }
185
247
  }
186
248
 
249
+ function dialogTracker(state: Runtime): (id: string, pending: boolean, sessionId?: string) => void {
250
+ const requests = new Map<string, { taskId: string; sessionId: string; requestId: string }>();
251
+ const prefix = `dialog:${randomUUID()}`;
252
+ return (id, pending, sessionId) => {
253
+ if (!sessionId) return;
254
+ const task = pending ? ownedTask(state.root, state.configDir, sessionId) : undefined;
255
+ if (task && !requests.has(id)) requests.set(id, { taskId: task.id, sessionId, requestId: `${prefix}:${id}` });
256
+ const request = requests.get(id);
257
+ if (!request) return;
258
+ updateBlockingRequest({ root: state.root, configDir: state.configDir, ...request }, request.requestId, pending);
259
+ if (!pending) requests.delete(id);
260
+ };
261
+ }
262
+
187
263
  /** Start a task in its own new pi session, named after the task; the session, or why not. */
188
264
  function startSession(state: Runtime, start: { request?: string; plan?: PlannedTask; auto?: boolean }): BackgroundSession | string {
189
265
  const request = start.request?.trim();
@@ -198,7 +274,7 @@ function startSession(state: Runtime, start: { request?: string; plan?: PlannedT
198
274
  // A request may lead with flags (`--worktree add login`); the session is named after what follows them.
199
275
  const name = taskName(plan?.title ?? (stripStartFlags(request!) || request!));
200
276
  try {
201
- const session = sessionRegistry().start(state.ctx.cwd, { name, ...(plan ? { planId: plan.id } : { request: request! }), ...(start.auto ? { auto: true } : {}) }, sessionModel(state.ctx));
277
+ 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));
202
278
  lobbyFeed.log("LOBBY", `started "${name}" in a new session`, "success");
203
279
  return session;
204
280
  } catch (error) {
@@ -318,7 +394,7 @@ function archiveTask(state: Runtime, taskId: string): string {
318
394
  if (busy) return busy;
319
395
  try {
320
396
  archiveTaskOnDisk(state.root, state.configDir, taskId);
321
- releaseAttachments(taskId);
397
+ releaseAttachments(taskId, state.root);
322
398
  } catch (error) {
323
399
  return (error as Error).message;
324
400
  }
@@ -347,7 +423,7 @@ function deleteTask(state: Runtime, taskId: string, where: "list" | "archive"):
347
423
  }
348
424
  try {
349
425
  deleteTaskOnDisk(state.root, state.configDir, taskId, where);
350
- releaseAttachments(taskId);
426
+ releaseAttachments(taskId, state.root);
351
427
  } catch (error) {
352
428
  return (error as Error).message;
353
429
  }
@@ -485,6 +561,7 @@ export function createLobbyService(state: Runtime): LobbyService {
485
561
  };
486
562
  let appliedMaster = masterKey();
487
563
  return {
564
+ projectRoot: () => state.root,
488
565
  sessionId: () => state.ctx.sessionManager.getSessionId(),
489
566
  zen: () => {
490
567
  const snapshot = taskSnapshot();
@@ -493,12 +570,33 @@ export function createLobbyService(state: Runtime): LobbyService {
493
570
  feed: lobbyFeed,
494
571
  masterBusy: () => !state.ctx.isIdle(),
495
572
  tasks: () => peekTasks(state.root, state.configDir),
573
+ deliveryDeliver: (taskId, request: DeliveryRequest) => deliver(request, { cwd: state.root, exec: execCommand }, deliveryStore(state, taskId)),
574
+ deliveryReview: async (taskId) => {
575
+ const task = deliveryTask(state, taskId);
576
+ const baseline = JSON.stringify(task.delivery);
577
+ const delivery = await refreshReview(task, { cwd: state.root, exec: execCommand });
578
+ // An async lookup must not overwrite intervening deferrals or operations.
579
+ const latest = deliveryTask(state, taskId);
580
+ if (JSON.stringify(latest.delivery) !== baseline) throw new Error("Review changed during lookup; refresh again.");
581
+ latest.delivery = delivery;
582
+ saveTask(state.root, state.configDir, latest);
583
+ lobbyTopics.bump("tasks");
584
+ return delivery;
585
+ },
586
+ deliveryDefer: (taskId, reviewId) => {
587
+ const task = deliveryTask(state, taskId);
588
+ const delivery = deferReview(task, reviewId);
589
+ saveTask(state.root, state.configDir, task);
590
+ lobbyTopics.bump("tasks");
591
+ return delivery;
592
+ },
496
593
  plans: () => listPlannedTasks(state.root, state.configDir),
497
594
  comments: (taskId) => readPlanComments(state.root, state.configDir, taskId),
498
595
  metrics: () => readMetrics(state.root, state.configDir),
499
596
  classifierMetrics: () => readClassifierMetrics(state.root, state.configDir),
500
597
  toOracle: (text) => toOracle(state, text),
501
598
  comment: (taskId, text) => addComment(state, taskId, text),
599
+ editComment: (taskId, commentId, text) => editOwnComment(state, taskId, commentId, text),
502
600
  startPlanned: (plan) => startPlanned(state, plan),
503
601
  discardPlan: (id) => {
504
602
  discardPlannedTask(state.root, state.configDir, id);
@@ -556,7 +654,7 @@ export function createLobbyService(state: Runtime): LobbyService {
556
654
  workspace: () => state.workspace,
557
655
  refreshWorkspace: () => refreshWorkspace(state),
558
656
  sessionName: () => state.pi.getSessionName(),
559
- sessions: () => backgroundSessions(),
657
+ sessions: () => backgroundSessions().filter((session) => session.projectRoot === state.root),
560
658
  startSession: (start) => startSession(state, start),
561
659
  isAuto: (taskId) => autoFor(state, taskId),
562
660
  setAuto: (taskId, on) => switchAuto(state, taskId, on),
@@ -39,6 +39,9 @@ export interface SessionProcess {
39
39
  export type SessionStatus = "starting" | "idle" | "working" | "exited";
40
40
 
41
41
  export interface SessionStart {
42
+ /** Server-owned task association; never supplied by a browser payload. */
43
+ onDialog?: (id: string, pending: boolean, sessionId?: string) => void;
44
+ projectRoot?: string;
42
45
  /** The display name: the task's title. */
43
46
  name: string;
44
47
  /** What to send once it is up: the request, or a planned task to start. */
@@ -54,6 +57,7 @@ export const STOP_GRACE_MS = 5000;
54
57
 
55
58
  export class BackgroundSession {
56
59
  readonly key: string;
60
+ readonly projectRoot?: string;
57
61
  readonly name: string;
58
62
  /** The planned task it was started from. */
59
63
  readonly planId?: string;
@@ -67,6 +71,7 @@ export class BackgroundSession {
67
71
  exitCode?: number | null;
68
72
  /** Dialogs the session waits on, oldest first. */
69
73
  readonly dialogs: SessionDialog[] = [];
74
+ private readonly onDialog: NonNullable<SessionStart["onDialog"]>;
70
75
  private readonly proc: SessionProcess;
71
76
  private buffer = "";
72
77
  private stderr = "";
@@ -79,7 +84,9 @@ export class BackgroundSession {
79
84
  counter += 1;
80
85
  this.key = `S${counter}`;
81
86
  this.name = start.name;
87
+ this.projectRoot = start.projectRoot;
82
88
  if (start.planId) this.planId = start.planId;
89
+ this.onDialog = start.onDialog ?? (() => {});
83
90
  this.proc = proc;
84
91
  this.onChange = onChange;
85
92
  proc.stdout?.on("data", (chunk) => this.receive(String(chunk)));
@@ -102,6 +109,7 @@ export class BackgroundSession {
102
109
  if (this.status === "exited") return;
103
110
  this.status = "exited";
104
111
  this.exitCode = code;
112
+ for (const dialog of this.dialogs) this.onDialog(dialog.id, false, this.sessionId);
105
113
  this.dialogs.length = 0;
106
114
  if (this.killTimer) clearTimeout(this.killTimer);
107
115
  this.feed.say("note", reason);
@@ -148,6 +156,7 @@ export class BackgroundSession {
148
156
 
149
157
  /** Stop the oracle's running turn (the session stays up). */
150
158
  abort(): void {
159
+ for (const dialog of [...this.dialogs]) this.answer(dialog.id, { cancelled: true });
151
160
  if (this.busy) this.write({ type: "abort" });
152
161
  }
153
162
 
@@ -156,6 +165,7 @@ export class BackgroundSession {
156
165
  const index = this.dialogs.findIndex((dialog) => dialog.id === id);
157
166
  if (index < 0) return;
158
167
  this.dialogs.splice(index, 1);
168
+ this.onDialog(id, false, this.sessionId);
159
169
  if (this.alive && this.proc.stdin) this.proc.stdin.write(`${JSON.stringify({ type: "extension_ui_response", id, ...answer })}\n`);
160
170
  this.onChange();
161
171
  }
@@ -213,6 +223,7 @@ export class BackgroundSession {
213
223
  if (event.command === "get_state" && data) {
214
224
  this.sessionId = data.sessionId;
215
225
  this.sessionFile = data.sessionFile;
226
+ for (const dialog of this.dialogs) this.onDialog(dialog.id, true, this.sessionId);
216
227
  if (this.status === "starting") this.status = "idle";
217
228
  } else if (event.success === false && typeof event.error === "string") {
218
229
  this.feed.log("LOBBY", `the session refused a request: ${event.error}`, "warning");
@@ -240,6 +251,7 @@ export class BackgroundSession {
240
251
  ...(typeof event.placeholder === "string" ? { placeholder: event.placeholder } : {}),
241
252
  ...(typeof event.prefill === "string" ? { prefill: event.prefill } : {}),
242
253
  });
254
+ this.onDialog(id, true, this.sessionId);
243
255
  this.feed.log("LOBBY", `waiting for you: ${String(event.title ?? "a question").split("\n")[0]}`, "warning");
244
256
  } else if (method === "notify") {
245
257
  const level = event.notifyType === "error" ? "error" : event.notifyType === "warning" ? "warning" : "info";
@@ -1,11 +1,16 @@
1
1
  /** The shapes of the Tasks tab's rows, shared by the web API and the page's words. */
2
2
 
3
+ import type { TimingProjection } from "../state/phase-timing.ts";
4
+ import type { Delivery } from "../delivery/types.ts";
5
+
3
6
  export type TaskSection = "mine" | "others" | "pending" | "recent" | "archived";
4
7
 
5
8
  /** How a row is ticked off: still to do (a plan, or a task under way), completed, or abandoned. */
6
9
  export type CheckState = "open" | "done" | "dropped";
7
10
 
8
11
  export interface TaskRow {
12
+ timing?: TimingProjection;
13
+ delivery?: Pick<Delivery, "status" | "reviewId">;
9
14
  /** A task on the list, a saved plan, or a task in the archive. */
10
15
  kind: "task" | "plan" | "archived";
11
16
  id: string;
@@ -52,7 +52,7 @@ export interface ScoutRequest {
52
52
  /** Model, thinking and time limit for one run, from settings. */
53
53
  function profileFields(config: BotLobbyConfig, resolver: ProfileResolver | undefined, domain: Domain, role: AgentRequest["role"]) {
54
54
  const profile = profileFor(config, resolver, domain, role);
55
- return { model: profile.model, thinking: profile.thinking, timeoutMs: profile.timeoutMs, ...(profile.fallback ? { fallback: profile.fallback } : {}) };
55
+ return { model: profile.model, thinking: profile.thinking, timeoutMs: profile.timeoutMs };
56
56
  }
57
57
 
58
58
  function scoutInstruction(request: ScoutRequest, domain: Domain): string {
@@ -88,7 +88,6 @@ export async function runResearch(request: ResearchRequest, run: ProcessRunner =
88
88
  model: profile.model,
89
89
  thinking: profile.thinking,
90
90
  timeoutMs: profile.timeoutMs,
91
- ...(profile.fallback ? { fallback: profile.fallback } : {}),
92
91
  ...(request.time ? { time: request.time } : {}),
93
92
  cwd: request.cwd,
94
93
  signal: request.signal,
package/src/pi/events.ts CHANGED
@@ -7,7 +7,6 @@ import { selectKnowledge } from "../knowledge/selector.ts";
7
7
  import { cancelAllRuns } from "../execution/agent-runner.ts";
8
8
  import { describeTask } from "../workflow/workflow.ts";
9
9
  import { truncate } from "../text.ts";
10
- import { registerMasterFallback } from "./master-fallback.ts";
11
10
  import { applyStatus, clearStatus, isMinimized, setMinimized } from "./ui.ts";
12
11
  import { isSubagentProcess, webToolsFor } from "./quiet.ts";
13
12
  import { registerQuietTools } from "./tool-renderers.ts";
@@ -65,7 +64,6 @@ export function masterTaskContext(task: Task, comments: readonly PlanComment[] =
65
64
  * section rather than replacing the whole prompt.
66
65
  */
67
66
  export function registerLifecycle(pi: ExtensionAPI, configDir: string): void {
68
- registerMasterFallback(pi);
69
67
  // Web tools the oracle put away for the task in hand, given back when it ends.
70
68
  let hiddenWeb: string[] = [];
71
69
  pi.on("session_start", (_event, ctx) => {
@@ -26,14 +26,11 @@ export interface EntryView {
26
26
  thinking?: string;
27
27
  instructions?: string;
28
28
  timeoutMs?: number;
29
- /** The model this agent switches to when its own runs out of usage; unset = none. */
30
- fallbackModel?: string;
31
- fallbackThinking?: string;
32
29
  }
33
30
 
34
31
  export function entryView(config: BotLobbyConfig, kind: SettingsKind): EntryView {
35
32
  if (kind === "master") return config.master;
36
- if (kind === "scout") return { model: config.scout.model, timeoutMs: config.scout.timeoutMs, ...(config.scout.fallbackModel ? { fallbackModel: config.scout.fallbackModel } : {}) };
33
+ if (kind === "scout") return { model: config.scout.model, timeoutMs: config.scout.timeoutMs };
37
34
  if (kind === "researcher") return config.researcher;
38
35
  if (kind === "quickfix") return config.quickFix;
39
36
  if (kind === "planner") return config.planner;
@@ -45,33 +42,17 @@ interface EntryPatch {
45
42
  thinking?: string;
46
43
  instructions?: string;
47
44
  timeoutMs?: number;
48
- /** `inherit` clears the fallback (its thinking level with it). */
49
- fallbackModel?: string;
50
- fallbackThinking?: string;
51
- }
52
-
53
- /** A settings entry with the fallback patch applied: `inherit` removes the fallback model and its thinking. */
54
- function withFallbackPatch<T extends { fallbackModel?: string; fallbackThinking?: string }>(entry: T): T {
55
- if (entry.fallbackModel !== INHERIT_MODEL) return entry;
56
- const { fallbackModel: _model, fallbackThinking: _thinking, ...rest } = entry;
57
- return rest as T;
58
45
  }
59
46
 
60
47
  /** Apply a patch to one entry in a config copy; scouts ignore thinking and instructions. */
61
48
  export function patchEntry(config: BotLobbyConfig, kind: SettingsKind, patch: EntryPatch): BotLobbyConfig {
62
49
  const next: BotLobbyConfig = { ...config, agents: { ...config.agents } };
63
- if (kind === "master") next.master = withFallbackPatch({ ...config.master, ...patch } as AgentModelConfig);
64
- else if (kind === "scout") {
65
- const fallbackModel = patch.fallbackModel ?? config.scout.fallbackModel;
66
- next.scout = withFallbackPatch({
67
- model: patch.model ?? config.scout.model,
68
- timeoutMs: patch.timeoutMs ?? config.scout.timeoutMs,
69
- ...(fallbackModel ? { fallbackModel } : {}),
70
- });
71
- } else if (kind === "researcher") next.researcher = withFallbackPatch({ ...config.researcher, ...patch } as AgentModelConfig);
72
- else if (kind === "quickfix") next.quickFix = withFallbackPatch({ ...config.quickFix, ...patch } as AgentModelConfig);
73
- else if (kind === "planner") next.planner = withFallbackPatch({ ...config.planner, ...patch } as AgentModelConfig);
74
- else next.agents[kind] = withFallbackPatch({ ...config.agents[kind], ...patch } as AgentModelConfig);
50
+ if (kind === "master") next.master = { ...config.master, ...patch } as AgentModelConfig;
51
+ else if (kind === "scout") next.scout = { model: patch.model ?? config.scout.model, timeoutMs: patch.timeoutMs ?? config.scout.timeoutMs };
52
+ else if (kind === "researcher") next.researcher = { ...config.researcher, ...patch } as AgentModelConfig;
53
+ else if (kind === "quickfix") next.quickFix = { ...config.quickFix, ...patch } as AgentModelConfig;
54
+ else if (kind === "planner") next.planner = { ...config.planner, ...patch } as AgentModelConfig;
55
+ else next.agents[kind] = { ...config.agents[kind], ...patch } as AgentModelConfig;
75
56
  return next;
76
57
  }
77
58
 
@@ -8,7 +8,6 @@ import {
8
8
  type PanelMember,
9
9
  THINKING_LEVELS,
10
10
  type BotLobbyConfig,
11
- type FallbackProfile,
12
11
  type ProfileResolver,
13
12
  type SubagentKind,
14
13
  type ThinkingLevelName,
@@ -87,25 +86,11 @@ export function createProfileResolver(config: BotLobbyConfig, options: ResolverO
87
86
  options.warn?.(message);
88
87
  }
89
88
  }
90
- return { ...profile, model, thinking: check.level, ...clampedFallback(profile.fallback, kindLabel(profile.kind as SubagentKind), options) };
89
+ return { ...profile, model, thinking: check.level };
91
90
  };
92
91
  }
93
92
 
94
- /** The fallback with its thinking level clamped to what the fallback model supports. */
95
- function clampedFallback(fallback: FallbackProfile | undefined, label: string, options: ResolverOptions): { fallback?: FallbackProfile } {
96
- if (!fallback) return {};
97
- const check = checkThinking(options.lookup(fallback.model), fallback.thinking);
98
- if (check.warning) {
99
- const message = `bot-lobby: ${label} fallback — ${check.warning}. Change it in /bot-lobby settings.`;
100
- if (!warned.has(message)) {
101
- warned.add(message);
102
- options.warn?.(message);
103
- }
104
- }
105
- return { fallback: { model: fallback.model, thinking: check.level } };
106
- }
107
-
108
- type RunProfile = { model?: string; thinking: string; timeoutMs: number; instructions?: string; fallback?: FallbackProfile };
93
+ type RunProfile = { model?: string; thinking: string; timeoutMs: number; instructions?: string };
109
94
 
110
95
  /** Unset models run on the session's; thinking is clamped to the model, with a one-time warning. */
111
96
  function resolveRunProfile(profile: RunProfile, label: string, options: ResolverOptions): RunProfile {
@@ -118,7 +103,7 @@ function resolveRunProfile(profile: RunProfile, label: string, options: Resolver
118
103
  options.warn?.(message);
119
104
  }
120
105
  }
121
- return { ...profile, model, thinking: check.level, ...clampedFallback(profile.fallback, label, options) };
106
+ return { ...profile, model, thinking: check.level };
122
107
  }
123
108
 
124
109
  /** A lobby agent's model, thinking and time limit. */
@@ -137,7 +122,7 @@ export function resolvePanelProfile(config: BotLobbyConfig, member: PanelMember,
137
122
  */
138
123
  export function resolveReviewProfile(config: BotLobbyConfig, options: ResolverOptions): RunProfile {
139
124
  const profile = agentProfile(config, "qa", "reviewer");
140
- return resolveRunProfile({ model: profile.model, thinking: profile.thinking, timeoutMs: profile.timeoutMs, ...(profile.instructions ? { instructions: profile.instructions } : {}), ...(profile.fallback ? { fallback: profile.fallback } : {}) }, kindLabel("qa"), options);
125
+ return resolveRunProfile({ model: profile.model, thinking: profile.thinking, timeoutMs: profile.timeoutMs, ...(profile.instructions ? { instructions: profile.instructions } : {}) }, kindLabel("qa"), options);
141
126
  }
142
127
 
143
128
  /** Every configured subagent whose thinking level its model does not support, for `/bot-lobby config`. */
@@ -31,18 +31,12 @@ export interface AgentModelConfig {
31
31
  instructions?: string;
32
32
  /** Time limit per run; falls back to `workflow.agentTimeoutMs`. */
33
33
  timeoutMs?: number;
34
- /** Model this agent switches to when its own model runs out of usage or is unavailable; unset or `inherit` = none. */
35
- fallbackModel?: ModelRef;
36
- /** Thinking level on the fallback model; unset uses this agent's own `thinking`. */
37
- fallbackThinking?: string;
38
34
  }
39
35
 
40
36
  /** Scouts pick a model and a time limit only; their thinking is fixed at `SCOUT_THINKING`. */
41
37
  export interface ScoutConfig {
42
38
  model: ModelRef;
43
39
  timeoutMs: number;
44
- /** Model scouts switch to when theirs runs out of usage or is unavailable (they think at `SCOUT_THINKING` on it too). */
45
- fallbackModel?: ModelRef;
46
40
  }
47
41
 
48
42
  /** Subagent kinds with their own settings entry. */
@@ -301,22 +295,18 @@ function positive(value: unknown): number | undefined {
301
295
 
302
296
  /** Merge one agent's override over its default; legacy `inherit` or unknown thinking falls back to the default level. */
303
297
  function normalizeAgent(base: AgentModelConfig, override: Partial<AgentModelConfig> | undefined): AgentModelConfig {
304
- const { fallbackModel: rawModel, fallbackThinking: rawThinking, ...merged } = { ...base, ...(override ?? {}) };
298
+ const merged = { ...base, ...(override ?? {}) };
305
299
  const thinking = isThinkingLevel(merged.thinking) ? merged.thinking : base.thinking;
306
300
  const timeoutMs = positive(merged.timeoutMs) ?? base.timeoutMs;
307
- const fallbackModel = typeof rawModel === "string" && rawModel.trim() && rawModel !== INHERIT_MODEL ? rawModel.trim() : undefined;
308
- const fallbackThinking = typeof rawThinking === "string" && isThinkingLevel(rawThinking) ? rawThinking : undefined;
309
- return { ...merged, thinking, ...(timeoutMs ? { timeoutMs } : {}), ...(fallbackModel ? { fallbackModel, ...(fallbackThinking ? { fallbackThinking } : {}) } : {}) };
301
+ return { model: merged.model, thinking, ...(typeof merged.instructions === "string" ? { instructions: merged.instructions } : {}), ...(timeoutMs ? { timeoutMs } : {}) };
310
302
  }
311
303
 
312
304
  /** Scouts keep a model and a time limit; any thinking value in the file is dropped. */
313
305
  function normalizeScout(override: Partial<ScoutConfig> | undefined): ScoutConfig {
314
306
  const base = DEFAULT_CONFIG.scout;
315
- const fallback = override?.fallbackModel;
316
307
  return {
317
308
  model: typeof override?.model === "string" && override.model.trim() ? override.model : base.model,
318
309
  timeoutMs: positive(override?.timeoutMs) ?? base.timeoutMs,
319
- ...(typeof fallback === "string" && fallback.trim() && fallback !== INHERIT_MODEL ? { fallbackModel: fallback.trim() } : {}),
320
310
  };
321
311
  }
322
312
 
@@ -441,22 +431,6 @@ export interface AgentProfile {
441
431
  thinking: string;
442
432
  timeoutMs: number;
443
433
  instructions?: string;
444
- /** Where the run goes when its model runs out of usage or is unavailable; absent = it just fails. */
445
- fallback?: FallbackProfile;
446
- }
447
-
448
- /** The fallback model and the thinking level to run it at. */
449
- export interface FallbackProfile {
450
- model: string;
451
- thinking: string;
452
- }
453
-
454
- /** The fallback of a settings entry: its model and thinking (the entry's own level when none is set). */
455
- export function fallbackOf(entry: { fallbackModel?: string; fallbackThinking?: string; thinking?: string }): FallbackProfile | undefined {
456
- const model = modelOf(entry.fallbackModel ?? INHERIT_MODEL);
457
- if (!model) return undefined;
458
- const own = entry.thinking && isThinkingLevel(entry.thinking) ? entry.thinking : DEFAULT_THINKING;
459
- return { model, thinking: entry.fallbackThinking && isThinkingLevel(entry.fallbackThinking) ? entry.fallbackThinking : own };
460
434
  }
461
435
 
462
436
  /** The settings entry a domain/role run draws from. */
@@ -481,12 +455,10 @@ export function agentProfile(config: BotLobbyConfig, domain: Domain, role: Role)
481
455
  const instructions = config.agents[domain].instructions;
482
456
  const fallback = config.workflow.agentTimeoutMs;
483
457
  if (kind === "scout") {
484
- const scoutFallback = fallbackOf({ fallbackModel: config.scout.fallbackModel, thinking: SCOUT_THINKING });
485
- return { kind, model: modelOf(config.scout.model), thinking: SCOUT_THINKING, timeoutMs: config.scout.timeoutMs || fallback, instructions, ...(scoutFallback ? { fallback: scoutFallback } : {}) };
458
+ return { kind, model: modelOf(config.scout.model), thinking: SCOUT_THINKING, timeoutMs: config.scout.timeoutMs || fallback, instructions };
486
459
  }
487
460
  const entry = kind === "researcher" ? config.researcher : config.agents[kind];
488
- const entryFallback = fallbackOf(entry);
489
- return { kind, model: modelOf(entry.model), thinking: entry.thinking, timeoutMs: entry.timeoutMs ?? fallback, instructions, ...(entryFallback ? { fallback: entryFallback } : {}) };
461
+ return { kind, model: modelOf(entry.model), thinking: entry.thinking, timeoutMs: entry.timeoutMs ?? fallback, instructions };
490
462
  }
491
463
 
492
464
  /** The settings entry of a lobby agent (quick fix or planner). */
@@ -495,21 +467,19 @@ export function lobbyAgentConfig(config: BotLobbyConfig, kind: LobbyAgentKind):
495
467
  }
496
468
 
497
469
  /** Profile for a lobby agent run, from settings alone; `model` is undefined while unset. */
498
- export function lobbyAgentProfile(config: BotLobbyConfig, kind: LobbyAgentKind): { model?: string; thinking: string; timeoutMs: number; instructions?: string; fallback?: FallbackProfile } {
470
+ export function lobbyAgentProfile(config: BotLobbyConfig, kind: LobbyAgentKind): { model?: string; thinking: string; timeoutMs: number; instructions?: string } {
499
471
  const entry = lobbyAgentConfig(config, kind);
500
- const fallback = fallbackOf(entry);
501
- return { model: modelOf(entry.model), thinking: entry.thinking, timeoutMs: entry.timeoutMs ?? config.workflow.agentTimeoutMs, instructions: entry.instructions, ...(fallback ? { fallback } : {}) };
472
+ return { model: modelOf(entry.model), thinking: entry.thinking, timeoutMs: entry.timeoutMs ?? config.workflow.agentTimeoutMs, instructions: entry.instructions };
502
473
  }
503
474
 
504
475
  /**
505
476
  * A planning panel seat's profile: the domain's (or the researcher's) model,
506
477
  * thinking and custom instructions, bounded by the planner's per-turn limit.
507
478
  */
508
- export function panelMemberProfile(config: BotLobbyConfig, member: PanelMember): { model?: string; thinking: string; timeoutMs: number; instructions?: string; fallback?: FallbackProfile } {
479
+ export function panelMemberProfile(config: BotLobbyConfig, member: PanelMember): { model?: string; thinking: string; timeoutMs: number; instructions?: string } {
509
480
  const entry = member === "researcher" ? config.researcher : config.agents[member];
510
481
  const timeoutMs = config.planner.timeoutMs ?? config.workflow.agentTimeoutMs;
511
- const fallback = fallbackOf(entry);
512
- return { model: modelOf(entry.model), thinking: entry.thinking, timeoutMs, instructions: entry.instructions, ...(fallback ? { fallback } : {}) };
482
+ return { model: modelOf(entry.model), thinking: entry.thinking, timeoutMs, instructions: entry.instructions };
513
483
  }
514
484
 
515
485
  /** Resolves the model, thinking and time limit one subagent run uses. */
@@ -139,8 +139,6 @@ export interface AgentRun {
139
139
  wrappedUp?: boolean;
140
140
  /** File this worker is queued for at the file desk. */
141
141
  waitingFor?: string;
142
- /** The run switched to its fallback model because this one ran out of usage or was unavailable. */
143
- fellBackFrom?: string;
144
142
  /** The classifier routed this run down: the configured profile it came from (`p/big · medium`). */
145
143
  routedFrom?: string;
146
144
  /** Why and where it was routed (`trivial 0.88: p/big · medium → p/cheap · low`). */
@@ -1,4 +1,5 @@
1
1
  import type { Domain, Role } from "./agent.ts";
2
+ import type { Delivery } from "../delivery/types.ts";
2
3
 
3
4
  export const TASK_STATES = [
4
5
  "created",
@@ -171,7 +172,18 @@ export interface TaskGit {
171
172
  path?: string;
172
173
  }
173
174
 
175
+ export interface PhaseTiming {
176
+ phase: TaskState;
177
+ elapsedMs: number;
178
+ runningSince?: string;
179
+ blockingRequestIds: string[];
180
+ }
181
+
174
182
  export interface Task {
183
+ /** Canonical waits, including legacy tasks without a reliable clock. */
184
+ blockingRequestIds?: string[];
185
+ phaseTiming?: PhaseTiming;
186
+ delivery?: Delivery;
175
187
  id: string;
176
188
  title: string;
177
189
  /** The user's original request, kept in full while `title` stays a short label. */
@@ -226,6 +238,8 @@ export function createTask(
226
238
  title,
227
239
  request,
228
240
  state: "created",
241
+ blockingRequestIds: [],
242
+ phaseTiming: { phase: "created", elapsedMs: 0, runningSince: now, blockingRequestIds: [] },
229
243
  domains: [],
230
244
  amendments: [],
231
245
  paused: false,