@a-t-h-i/bot-lobby 0.6.24 → 0.6.26

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 +11 -3
  2. package/package.json +1 -5
  3. package/prompts/designer.md +14 -0
  4. package/prompts/master.md +30 -7
  5. package/prompts/panel.md +21 -1
  6. package/prompts/planner.md +22 -3
  7. package/prompts/qa.md +23 -3
  8. package/prompts/quickfix.md +4 -0
  9. package/prompts/reviewer.md +17 -0
  10. package/prompts/worker.md +21 -6
  11. package/src/ask/tool.ts +3 -3
  12. package/src/classifier/hosts.ts +0 -11
  13. package/src/classifier/instance.ts +0 -5
  14. package/src/delivery/review.ts +1 -1
  15. package/src/execution/agent-runner.ts +0 -18
  16. package/src/execution/git.ts +77 -0
  17. package/src/execution/lint.ts +435 -0
  18. package/src/knowledge/compactor.ts +0 -70
  19. package/src/lobby/ask.ts +4 -17
  20. package/src/lobby/feed.ts +39 -4
  21. package/src/lobby/host.ts +4 -0
  22. package/src/lobby/issues.ts +0 -8
  23. package/src/lobby/planner.ts +54 -5
  24. package/src/lobby/pr-review.ts +2 -1
  25. package/src/lobby/pulls.ts +1 -44
  26. package/src/lobby/quickfix.ts +34 -1
  27. package/src/lobby/runtime.ts +20 -3
  28. package/src/lobby/service.ts +5 -1
  29. package/src/lobby/session-files.ts +1 -45
  30. package/src/lobby/split.ts +33 -1
  31. package/src/lobby/task-rows.ts +2 -0
  32. package/src/master/master.ts +3 -0
  33. package/src/master/research.ts +0 -16
  34. package/src/pi/commands.ts +25 -10
  35. package/src/pi/events.ts +12 -4
  36. package/src/pi/model-settings.ts +0 -1
  37. package/src/pi/plan-checklist.ts +98 -8
  38. package/src/pi/route.ts +0 -1
  39. package/src/pi/run-summary.ts +0 -57
  40. package/src/pi/switch.ts +69 -0
  41. package/src/pi/tools.ts +7 -0
  42. package/src/pi/ui.ts +2 -14
  43. package/src/schemas/agent.ts +0 -4
  44. package/src/schemas/configuration.ts +51 -3
  45. package/src/schemas/lint.ts +55 -0
  46. package/src/schemas/task.ts +5 -0
  47. package/src/state/budget.ts +49 -22
  48. package/src/state/changes.ts +8 -2
  49. package/src/state/metrics.ts +0 -1
  50. package/src/state/persistence.ts +0 -5
  51. package/src/state/work-time.ts +123 -0
  52. package/src/webui/api/index.ts +2 -6
  53. package/src/webui/api/knowledge.ts +11 -3
  54. package/src/webui/api/lobby.ts +13 -6
  55. package/src/webui/api/plan-facts.ts +36 -6
  56. package/src/webui/api/settings.ts +6 -1
  57. package/src/webui/api/tasks.ts +27 -6
  58. package/src/webui/command.ts +14 -3
  59. package/src/webui/dev/fake-service.ts +7 -0
  60. package/src/webui/dev/fixtures.ts +4 -1
  61. package/src/webui/protocol.ts +30 -4
  62. package/src/workflow/lint.ts +221 -0
  63. package/src/workflow/track.ts +12 -4
  64. package/src/workflow/workflow.ts +105 -21
  65. package/webui/dist/assets/index-C1IwecNS.css +2 -0
  66. package/webui/dist/assets/index-C4Ww36TW.js +105 -0
  67. package/webui/dist/build.json +58 -62
  68. package/webui/dist/index.html +2 -2
  69. package/webui/dist/assets/index-BSewK1QO.css +0 -2
  70. package/webui/dist/assets/index-CHQYSZXA.js +0 -105
@@ -10,7 +10,7 @@
10
10
  import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
11
11
  import { dirname, join } from "node:path";
12
12
  import { taskDirFor } from "./persistence.ts";
13
- import { readJsonCached } from "./file-cache.ts";
13
+ import { clearStaleRuns, updateWork } from "./work-time.ts";
14
14
 
15
15
  /** What one delegation was given. */
16
16
  export interface Allotment {
@@ -74,18 +74,6 @@ function writeBudget(root: string, configDir: string, taskId: string, budget: Ta
74
74
  writeFileSync(path, `${JSON.stringify({ ...budget, allotments: budget.allotments.slice(-MAX_ALLOTMENTS) }, null, 2)}\n`, "utf8");
75
75
  }
76
76
 
77
- /** A task's budget as a display reads it every frame: parsed again only when the file changes. */
78
- export function peekBudget(root: string, configDir: string, taskId: string): TaskBudget | undefined {
79
- const value = readJsonCached(budgetPath(root, configDir, taskId), isBudget);
80
- return value ? { minutes: value.minutes, granted: value.granted ?? 0, usedMs: value.usedMs, allotments: Array.isArray(value.allotments) ? value.allotments : [] } : undefined;
81
- }
82
-
83
- /** Work time spent of the whole, for the lobby's status box; undefined without a budget. */
84
- export function budgetClock(root: string, configDir: string, taskId: string, now = Date.now()): { usedMs: number; totalMs: number } | undefined {
85
- const budget = peekBudget(root, configDir, taskId);
86
- return budget ? { usedMs: usedMs(taskId, budget, now), totalMs: (budget.minutes + budget.granted) * 60_000 } : undefined;
87
- }
88
-
89
77
  /** Change a task's budget in place (read afresh, written back); undefined when it has none. */
90
78
  export function updateBudget(root: string, configDir: string, taskId: string, change: (budget: TaskBudget) => void): TaskBudget | undefined {
91
79
  const budget = readBudget(root, configDir, taskId);
@@ -127,6 +115,11 @@ export function formatMinutes(ms: number): string {
127
115
  }
128
116
 
129
117
  /* ------------------------------------------------------------ work clock */
118
+ /*
119
+ * Every task the session drives has a work clock, budget or not: it keeps the
120
+ * task's work time (work.json, what the lobby shows as "worked") and, when the
121
+ * task has a budget, the time spent of it.
122
+ */
130
123
 
131
124
  interface Clock {
132
125
  root: string;
@@ -141,41 +134,74 @@ interface Clock {
141
134
 
142
135
  /** Work clocks of the tasks this process drives. */
143
136
  const clocks = new Map<string, Clock>();
144
- /** How often a counting clock is saved, so other windows see time pass. */
137
+ /** How often a running clock is saved, so other windows see time pass (and see it is still kept). */
145
138
  export const CLOCK_FLUSH_MS = 30_000;
146
139
  let flusher: ReturnType<typeof setInterval> | undefined;
140
+ /** The oracle's turn is running in this process. */
141
+ let turn = false;
147
142
 
148
- /** Fold the stretch that counted into the saved total, and start a new one if it still counts. */
143
+ /** Fold the stretch that counted into the saved totals, and start a new one if it still counts. */
149
144
  function settle(taskId: string, clock: Clock, now = Date.now()): void {
145
+ let spent = 0;
150
146
  if (clock.since !== undefined) {
151
- const spent = Math.max(0, now - clock.since);
147
+ spent = Math.max(0, now - clock.since);
152
148
  clock.since = undefined;
153
149
  if (spent > 0) updateBudget(clock.root, clock.configDir, taskId, (budget) => { budget.usedMs += spent; });
154
150
  }
155
151
  if (clock.running && clock.waits === 0) clock.since = now;
152
+ const since = clock.since;
153
+ updateWork(clock.root, clock.configDir, taskId, (work) => {
154
+ work.workedMs += spent;
155
+ if (since !== undefined) work.runningSince = new Date(since).toISOString();
156
+ else delete work.runningSince;
157
+ }, new Date(now));
156
158
  }
157
159
 
158
160
  function ensureFlusher(): void {
159
161
  if (flusher) return;
160
162
  flusher = setInterval(() => {
161
- for (const [taskId, clock] of clocks) if (clock.since !== undefined) settle(taskId, clock);
163
+ for (const [taskId, clock] of clocks) if (clock.running) settle(taskId, clock);
162
164
  }, CLOCK_FLUSH_MS);
163
165
  flusher.unref();
164
166
  }
165
167
 
166
- /** The oracle started a turn on a task: its clock runs, if the task has a budget. */
168
+ /** The oracle's turn started: a task it drives, or starts within the turn, is being worked on. */
169
+ export function turnStarted(): void {
170
+ turn = true;
171
+ }
172
+
173
+ /** The oracle started a turn on a task (or took one up within it): its clock runs. */
167
174
  export function startClock(root: string, configDir: string, taskId: string): void {
168
- if (!readBudget(root, configDir, taskId)) return;
169
- const clock = clocks.get(taskId) ?? { root, configDir, running: false, waits: 0 };
170
- clocks.set(taskId, clock);
175
+ let clock = clocks.get(taskId);
176
+ if (!clock) {
177
+ clock = { root, configDir, running: false, waits: 0 };
178
+ clocks.set(taskId, clock);
179
+ clearStaleRuns(root, configDir, taskId);
180
+ }
181
+ if (clock.running) return;
171
182
  clock.running = true;
172
183
  settle(taskId, clock);
173
184
  ensureFlusher();
174
185
  }
175
186
 
176
- /** The oracle's turn ended: the clock stops until the next one. */
187
+ /**
188
+ * The task the session drives now, after a step that may have started, ended or taken over one: while
189
+ * a turn runs, its clock runs and any other task's stops.
190
+ */
191
+ export function clockTask(root: string, configDir: string, taskId: string | undefined): void {
192
+ for (const [id, clock] of clocks) {
193
+ if (id === taskId || !clock.running) continue;
194
+ clock.running = false;
195
+ settle(id, clock);
196
+ }
197
+ if (taskId && turn) startClock(root, configDir, taskId);
198
+ }
199
+
200
+ /** The oracle's turn ended: the clocks stop until the next one. */
177
201
  export function stopClocks(): void {
202
+ turn = false;
178
203
  for (const [taskId, clock] of clocks) {
204
+ if (!clock.running && clock.since === undefined) continue;
179
205
  clock.running = false;
180
206
  settle(taskId, clock);
181
207
  }
@@ -215,6 +241,7 @@ export function usedMs(taskId: string, budget: TaskBudget, now = Date.now()): nu
215
241
  /** Forget the clocks (tests; a session that ends). */
216
242
  export function resetClocks(): void {
217
243
  clocks.clear();
244
+ turn = false;
218
245
  if (flusher) clearInterval(flusher);
219
246
  flusher = undefined;
220
247
  }
@@ -142,6 +142,8 @@ export interface FileProvenance {
142
142
  notes: string[];
143
143
  /** The quick fixes that edited it. */
144
144
  quickFixes: string[];
145
+ /** This task's domains whose workers edited it. */
146
+ domains?: Domain[];
145
147
  }
146
148
 
147
149
  const DOMAIN_LABELS: Record<string, string> = { backend: "DEV", designer: "DESIGN", qa: "QA" };
@@ -180,6 +182,7 @@ export function explainChanges(task: Pick<Task, "id" | "baseline">, changed: rea
180
182
  const kinds: Provenance[] = [];
181
183
  const notes: string[] = [];
182
184
  const quickFixes: string[] = [];
185
+ const domains: Domain[] = [];
183
186
  const add = (kind: Provenance, note: string) => {
184
187
  if (!kinds.includes(kind)) kinds.push(kind);
185
188
  if (!notes.includes(note)) notes.push(note);
@@ -188,12 +191,15 @@ export function explainChanges(task: Pick<Task, "id" | "baseline">, changed: rea
188
191
  if (record.source === "quickfix") {
189
192
  add("quickfix", `quick fix ${record.id}${clock(record.finishedAt)}, asked by the user: "${clip(record.what)}"`);
190
193
  if (!quickFixes.includes(record.id)) quickFixes.push(record.id);
191
- } else if (record.taskId === task.id) add("planned", `planned: ${DOMAIN_LABELS[record.domain ?? ""] ?? record.domain ?? "worker"} — ${clip(record.what)}`);
194
+ } else if (record.taskId === task.id) {
195
+ add("planned", `planned: ${DOMAIN_LABELS[record.domain ?? ""] ?? record.domain ?? "worker"} — ${clip(record.what)}`);
196
+ if (record.domain && !domains.includes(record.domain)) domains.push(record.domain);
197
+ }
192
198
  else add("other-task", `another task's worker (${record.taskId ?? "unknown task"}): ${clip(record.what)}`);
193
199
  }
194
200
  if (before.has(path)) add("pre-existing", "pre-existing: it already had uncommitted changes before this task's agents started");
195
201
  if (kinds.length === 0) add("unattributed", "unattributed: no bot-lobby agent recorded editing it");
196
- return { path, kinds, notes, quickFixes };
202
+ return { path, kinds, notes, quickFixes, ...(domains.length ? { domains } : {}) };
197
203
  });
198
204
  }
199
205
 
@@ -356,7 +356,6 @@ export function aggregateMetrics(records: readonly MetricRecord[], by: GroupBy =
356
356
  }
357
357
 
358
358
  export type SortKey = "runs" | "avg" | "success" | "cost";
359
- export const SORT_KEYS: readonly SortKey[] = ["runs", "avg", "success", "cost"];
360
359
 
361
360
  export function sortGroups(groups: readonly MetricGroup[], key: SortKey): MetricGroup[] {
362
361
  const score = (group: MetricGroup): number => {
@@ -270,8 +270,3 @@ export function removeTaskScratchpads(root: string, configDir: string, taskId: s
270
270
  if (file.startsWith("scout-")) rmSync(join(dir, file), { force: true });
271
271
  }
272
272
  }
273
-
274
- /** Delete the temporary task dir after knowledge has been distilled. */
275
- export function cleanupTaskDir(root: string, configDir: string, taskId: string): void {
276
- rmSync(taskDir(dataRoot(root, configDir), taskId), { recursive: true, force: true });
277
- }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * How long a task's agents have actually worked on it, and which worker runs
3
+ * are under way. Work time is the oracle's turns on the task — which include
4
+ * every agent it runs — and not the time it waits on the user (between turns,
5
+ * and while a dialog is open); the work clock in budget.ts measures it. The
6
+ * session that owns the task keeps it beside the task's state as work.json,
7
+ * refreshed while it works, so every window can show the time ticking and
8
+ * mark the steps being worked on. A file its owner stopped refreshing (the
9
+ * session ended or crashed) reads as stopped.
10
+ */
11
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
12
+ import { dirname, join } from "node:path";
13
+ import { taskDirFor } from "./persistence.ts";
14
+
15
+ /** A worker run under way: the step it works on is found from its instruction. */
16
+ export interface ActiveRun {
17
+ runId: string;
18
+ instruction: string;
19
+ startedAt: string;
20
+ }
21
+
22
+ export interface WorkTime {
23
+ /** Work time folded in so far, in ms. */
24
+ workedMs: number;
25
+ /** While the agents work: since when the current stretch counts. */
26
+ runningSince?: string;
27
+ active: ActiveRun[];
28
+ /** When the owning session last wrote it. */
29
+ updatedAt: string;
30
+ }
31
+
32
+ /** The clock as a reader sees it at one moment. */
33
+ export interface WorkProjection {
34
+ workedMs: number;
35
+ /** Still counting: the reader adds the time that passes. */
36
+ running: boolean;
37
+ active: ActiveRun[];
38
+ }
39
+
40
+ /** A file older than this is no longer being kept (the owner refreshes it every 30s while it works). */
41
+ export const WORK_FRESH_MS = 75_000;
42
+
43
+ export function workPath(root: string, configDir: string, taskId: string): string {
44
+ return join(taskDirFor(root, configDir, taskId), "work.json");
45
+ }
46
+
47
+ function isWork(value: unknown): value is WorkTime {
48
+ const work = value as Partial<WorkTime> | undefined;
49
+ return Boolean(work && typeof work.workedMs === "number" && typeof work.updatedAt === "string");
50
+ }
51
+
52
+ export function readWork(root: string, configDir: string, taskId: string): WorkTime | undefined {
53
+ try {
54
+ const value: unknown = JSON.parse(readFileSync(workPath(root, configDir, taskId), "utf8"));
55
+ if (!isWork(value)) return undefined;
56
+ return { workedMs: value.workedMs, ...(value.runningSince ? { runningSince: value.runningSince } : {}), active: Array.isArray(value.active) ? value.active : [], updatedAt: value.updatedAt };
57
+ } catch {
58
+ return undefined;
59
+ }
60
+ }
61
+
62
+ /** Change a task's work time in place (read afresh, written back atomically), starting it when there is none. */
63
+ export function updateWork(root: string, configDir: string, taskId: string, change: (work: WorkTime) => void, now = new Date()): void {
64
+ const work = readWork(root, configDir, taskId) ?? { workedMs: 0, active: [], updatedAt: now.toISOString() };
65
+ change(work);
66
+ work.updatedAt = now.toISOString();
67
+ try {
68
+ const path = workPath(root, configDir, taskId);
69
+ mkdirSync(dirname(path), { recursive: true });
70
+ const tmp = `${path}.${process.pid}.tmp`;
71
+ writeFileSync(tmp, `${JSON.stringify(work, null, 2)}\n`, "utf8");
72
+ renameSync(tmp, path);
73
+ } catch {
74
+ // A read-only tree must never fail the workflow; the time just stops being saved.
75
+ }
76
+ }
77
+
78
+ /** What a reader shows now: a stale file counts up to its last refresh and nothing in it is running. */
79
+ export function projectWork(work: WorkTime | undefined, now = Date.now()): WorkProjection | undefined {
80
+ if (!work) return undefined;
81
+ const updated = Date.parse(work.updatedAt);
82
+ const fresh = Number.isFinite(updated) && now - updated <= WORK_FRESH_MS;
83
+ const since = work.runningSince ? Date.parse(work.runningSince) : Number.NaN;
84
+ const end = fresh ? now : updated;
85
+ const stretch = Number.isFinite(since) && Number.isFinite(end) ? Math.max(0, end - since) : 0;
86
+ return { workedMs: Math.max(0, work.workedMs) + stretch, running: fresh && Number.isFinite(since), active: fresh ? [...work.active] : [] };
87
+ }
88
+
89
+ /** A task's work time as a reader sees it now; undefined for a task with none (one from before it was kept). */
90
+ export function taskWork(root: string, configDir: string, taskId: string, now = Date.now()): WorkProjection | undefined {
91
+ return projectWork(readWork(root, configDir, taskId), now);
92
+ }
93
+
94
+ /* ------------------------------------------------------ worker runs */
95
+
96
+ /** Worker runs this process has written as under way, per task: the file changes only when a run starts or ends. */
97
+ const noted = new Map<string, Set<string>>();
98
+
99
+ /** A worker run of the task started or ended: the steps it works on read as active while it runs. */
100
+ export function noteWorkerRun(root: string, configDir: string, taskId: string, run: { runId: string; instruction?: string; startedAt: string; status: string }): void {
101
+ const running = run.status === "running";
102
+ const ids = noted.get(taskId) ?? new Set<string>();
103
+ if (running === ids.has(run.runId)) return;
104
+ if (running) ids.add(run.runId);
105
+ else ids.delete(run.runId);
106
+ noted.set(taskId, ids);
107
+ updateWork(root, configDir, taskId, (work) => {
108
+ work.active = work.active.filter((entry) => entry.runId !== run.runId);
109
+ if (running) work.active.push({ runId: run.runId, instruction: run.instruction ?? "", startedAt: run.startedAt });
110
+ });
111
+ }
112
+
113
+ /** This process starts keeping a task's time: runs a crashed session left marked as under way are not. */
114
+ export function clearStaleRuns(root: string, configDir: string, taskId: string): void {
115
+ if ((noted.get(taskId)?.size ?? 0) > 0) return;
116
+ const work = readWork(root, configDir, taskId);
117
+ if (work && work.active.length > 0) updateWork(root, configDir, taskId, (next) => { next.active = []; });
118
+ }
119
+
120
+ /** Forget the runs noted (tests). */
121
+ export function resetNotedRuns(): void {
122
+ noted.clear();
123
+ }
@@ -23,7 +23,7 @@ import { plansDiscard, plansGet, plansStart } from "./plans.ts";
23
23
  import { gitCancelReview, gitJev, gitPull, gitPulls, gitReview } from "./git.ts";
24
24
  import { issuesCreate, issuesGet, issuesList } from "./issues.ts";
25
25
  import { sessionsAnswer, sessionsChat, sessionsList, sessionsMessage, sessionsStart, sessionsStop, sessionsSwitch } from "./sessions.ts";
26
- import { settingsGet, settingsSet } from "./settings.ts";
26
+ import { settingsGet, settingsLinters, settingsSet } from "./settings.ts";
27
27
  import { noticeText, pushNotice } from "../notices.ts";
28
28
 
29
29
  /** What a handler reads besides the request body. */
@@ -367,16 +367,12 @@ function buildRoutes(): Record<string, Route> {
367
367
  schema: Type.Object({ patch: Type.Object({}, { additionalProperties: true }) }, { additionalProperties: false }),
368
368
  run: (body, ctx) => settingsSet(body as { patch: Record<string, unknown> }, ctx),
369
369
  };
370
+ routes["settings.linters"] = { schema: Empty, run: (_body, ctx) => settingsLinters(ctx) };
370
371
  return routes;
371
372
  }
372
373
 
373
374
  const ROUTES = buildRoutes();
374
375
 
375
- /** Whether the router knows a call (defined names only; never inherited ones). */
376
- export function isKnownCall(name: string): boolean {
377
- return Object.hasOwn(ROUTES, name);
378
- }
379
-
380
376
  /**
381
377
  * Run `POST /api/<name>` with an already-parsed body. Throws `HttpError` for
382
378
  * refusals (unknown call, a body that is no object, a schema mismatch, or a
@@ -7,7 +7,9 @@
7
7
  */
8
8
  import type { KnowledgeAgent } from "../../knowledge/paths.ts";
9
9
  import type { EntryRef } from "../../lobby/knowledge.ts";
10
- import type { KnowledgeFileInfo, KnowledgeViewData } from "../protocol.ts";
10
+ import type { KnowledgeAgentName, KnowledgeFileInfo, KnowledgeViewData } from "../protocol.ts";
11
+ import { INHERIT_MODEL, type BotLobbyConfig } from "../../schemas/configuration.ts";
12
+ import { loadConfig } from "../../state/project.ts";
11
13
  import type { ApiContext } from "./index.ts";
12
14
  import { fail } from "./index.ts";
13
15
 
@@ -31,8 +33,14 @@ interface RefBody extends FileBody {
31
33
  }
32
34
 
33
35
  /** Every agent's files with their size and note counts, in tab order. */
34
- export function knowledgeFiles(ctx: ApiContext): { files: KnowledgeFileInfo[] } {
35
- return { files: ctx.service.knowledge.files() };
36
+ export function knowledgeFiles(ctx: ApiContext): { files: KnowledgeFileInfo[]; models: Record<KnowledgeAgentName, string> } {
37
+ return { files: ctx.service.knowledge.files(), models: agentModels(ctx.service.config?.() ?? loadConfig()) };
38
+ }
39
+
40
+ /** The model each knowledge-keeping agent runs on, as the tree names it ("session model" when none is pinned). */
41
+ export function agentModels(config: BotLobbyConfig): Record<KnowledgeAgentName, string> {
42
+ const named = (model: string) => (model === INHERIT_MODEL || !model.trim() ? "session model" : model);
43
+ return { master: named(config.master.model), designer: named(config.agents.designer.model), backend: named(config.agents.backend.model), qa: named(config.agents.qa.model) };
36
44
  }
37
45
 
38
46
  /** One file as entries with their notes. */
@@ -9,23 +9,30 @@ import type { Task } from "../../schemas/task.ts";
9
9
  import type { ApiContext } from "./index.ts";
10
10
  import { withAttachments } from "../uploads.ts";
11
11
  import { fail } from "./index.ts";
12
- import { taskSteps } from "./plan-facts.ts";
12
+ import { stepViews } from "./plan-facts.ts";
13
+ import { TERMINAL_STATES } from "../../schemas/task.ts";
14
+ import type { WorkProjection } from "../../state/work-time.ts";
13
15
  import type { LobbySnapshot, SnapshotTask } from "../protocol.ts";
14
16
 
15
- /** Plan facts the header shows: step progress plus the step under way, if any. */
16
- function planFacts(task: Task): Pick<SnapshotTask, "progress" | "currentStep"> {
17
- const steps = taskSteps(task);
18
- if (steps.length === 0) return {};
17
+ /** Plan facts the header shows: step progress, the steps a worker is on (with their time) or else the next one, and the work time. */
18
+ function planFacts(task: Task, work: WorkProjection | undefined): Pick<SnapshotTask, "progress" | "currentStep" | "activeSteps" | "work"> {
19
+ const clock = work ? { work: { workedMs: Math.round(work.workedMs), running: work.running && !TERMINAL_STATES.includes(task.state) } } : {};
20
+ const steps = stepViews(task, work);
21
+ if (steps.length === 0) return clock;
22
+ const active = steps.filter((step) => step.active);
19
23
  const current = steps.find((step) => step.status === "current");
20
24
  return {
21
25
  progress: { done: steps.filter((step) => step.status === "done").length, total: steps.length },
22
26
  ...(current ? { currentStep: current.text } : {}),
27
+ ...(active.length > 0 ? { activeSteps: active.map((step) => ({ text: step.text, workedMs: step.workedMs })) } : {}),
28
+ ...clock,
23
29
  };
24
30
  }
25
31
 
26
32
  function taskOf(ctx: ApiContext): LobbySnapshot["task"] {
27
33
  const task = ctx.service.zen().task;
28
34
  if (!task) return undefined;
35
+ const work = ctx.service.work?.(task.id);
29
36
  return {
30
37
  id: task.id,
31
38
  title: task.title,
@@ -33,7 +40,7 @@ function taskOf(ctx: ApiContext): LobbySnapshot["task"] {
33
40
  ...(task.track ? { track: { path: task.track.path, size: task.track.size } } : {}),
34
41
  domains: [...(task.domains ?? [])],
35
42
  ...(task.git ? { git: { branch: task.git.branch, ...(task.git.from ? { from: task.git.from } : {}) } } : {}),
36
- ...planFacts(task),
43
+ ...planFacts(task, work),
37
44
  };
38
45
  }
39
46
 
@@ -1,11 +1,13 @@
1
1
  /**
2
2
  * What the Lobby header and the Tasks calls both read of a task's plan: the
3
- * worker records as runs, and the plan's checklist. Kept here (not via
4
- * `persistedRuns`) so the server never loads the TUI.
3
+ * worker records as runs, the plan's checklist, the steps being worked on
4
+ * right now and the work time. Kept here (not via `persistedRuns`) so the
5
+ * server never loads the TUI.
5
6
  */
6
- import { planChecklist, type PlanStep } from "../../pi/plan-checklist.ts";
7
+ import { planChecklist, planSteps, stepWork, type PlanStep } from "../../pi/plan-checklist.ts";
7
8
  import type { AgentRun } from "../../schemas/findings.ts";
8
9
  import type { Task } from "../../schemas/task.ts";
10
+ import type { WorkProjection } from "../../state/work-time.ts";
9
11
 
10
12
  /** Worker records as runs, the same projection the terminal checklist reads. */
11
13
  export function persistedTaskRuns(task: Task): AgentRun[] {
@@ -23,7 +25,35 @@ export function persistedTaskRuns(task: Task): AgentRun[] {
23
25
  }));
24
26
  }
25
27
 
26
- /** The plan's steps with their state; none when the task has no plan. */
27
- export function taskSteps(task: Task): PlanStep[] {
28
- return task.plan ? planChecklist(task.plan, persistedTaskRuns(task)) : [];
28
+ /** The worker runs under way (from the owner's work file) that have not finished yet, as running runs. */
29
+ function activeRuns(task: Task, work: WorkProjection | undefined): AgentRun[] {
30
+ const finished = new Set((task.workerRuns ?? []).map((record) => record.runId));
31
+ return (work?.active ?? [])
32
+ .filter((run) => !finished.has(run.runId))
33
+ .map((run) => ({ runId: run.runId, taskId: task.id, domain: "backend" as const, role: "worker" as const, status: "running" as const, instruction: run.instruction, output: "", attempts: 1, startedAt: run.startedAt }));
34
+ }
35
+
36
+ /** One step as the lobby shows it: its state, and while a worker is on it, how long they have worked on it. */
37
+ export interface StepView {
38
+ text: string;
39
+ status: PlanStep["status"];
40
+ active: boolean;
41
+ /** Worker time on the step, in ms. */
42
+ workedMs: number;
43
+ }
44
+
45
+ /**
46
+ * The plan's steps with their state, which ones a worker is on right now and
47
+ * the worker time on each. A step being worked on is never done, so steps run
48
+ * side by side all read active.
49
+ */
50
+ export function stepViews(task: Task, work: WorkProjection | undefined, now = Date.now()): StepView[] {
51
+ if (!task.plan) return [];
52
+ const runs = [...persistedTaskRuns(task), ...activeRuns(task, work)];
53
+ const checklist = planChecklist(task.plan, runs);
54
+ const times = stepWork(planSteps(task.plan), runs, now);
55
+ return checklist.map((step, index) => {
56
+ const time = times[index] ?? { ms: 0, active: false };
57
+ return { text: step.text, status: time.active ? "current" : step.status, active: time.active, workedMs: Math.round(time.ms) };
58
+ });
29
59
  }
@@ -10,7 +10,7 @@ import { lobbyTopics } from "../../lobby/topics.ts";
10
10
  import { prefillModels } from "../../pi/model-settings.ts";
11
11
  import { kindLabel } from "../../pi/model-support.ts";
12
12
  import { pushNotice } from "../notices.ts";
13
- import type { SettingsInfo } from "../protocol.ts";
13
+ import type { SettingsInfo, SettingsLinter } from "../protocol.ts";
14
14
  import type { ApiContext } from "./index.ts";
15
15
  import { fail } from "./index.ts";
16
16
 
@@ -93,6 +93,11 @@ export function settingsGet(ctx: ApiContext): SettingsInfo {
93
93
  return { config: stripSecrets(config), models: ctx.service.models?.() ?? [] };
94
94
  }
95
95
 
96
+ /** The linters the project configures (its top and a few folders below), and whether each is installed. */
97
+ export function settingsLinters(ctx: ApiContext): { linters: SettingsLinter[] } {
98
+ return { linters: ctx.service.linters?.() ?? [] };
99
+ }
100
+
96
101
  /** Merge, normalise and save the patch; the lobby rereads the config. */
97
102
  export function settingsSet(body: { patch: Record<string, unknown> }, ctx: ApiContext): { config: BotLobbyConfig } {
98
103
  checkPort(body.patch);
@@ -13,7 +13,9 @@ import type { TaskDetail } from "../protocol.ts";
13
13
  import type { ApiContext } from "./index.ts";
14
14
  import { withAttachments } from "../uploads.ts";
15
15
  import { fail } from "./index.ts";
16
- import { taskSteps } from "./plan-facts.ts";
16
+ import { stepViews } from "./plan-facts.ts";
17
+ import { planDetails } from "../../pi/plan-checklist.ts";
18
+ import type { WorkProjection } from "../../state/work-time.ts";
17
19
  import type { TaskSection } from "../../lobby/task-rows.ts";
18
20
  import { projectPhaseTiming } from "../../state/phase-timing.ts";
19
21
  import type { DeliveryRequest } from "../../delivery/operations.ts";
@@ -51,6 +53,7 @@ interface RowContext {
51
53
  names: ReadonlyMap<string, string>;
52
54
  auto: ReadonlySet<string>;
53
55
  live: ReadonlySet<string>;
56
+ work: (taskId: string) => WorkProjection | undefined;
54
57
  }
55
58
 
56
59
  function checkOf(task: Task): TaskRow["check"] {
@@ -59,12 +62,18 @@ function checkOf(task: Task): TaskRow["check"] {
59
62
  return "open";
60
63
  }
61
64
 
62
- function progressOf(task: Task): TaskRow["progress"] {
63
- const steps = taskSteps(task);
65
+ function progressOf(task: Task, work: WorkProjection | undefined, now: number): TaskRow["progress"] {
66
+ const steps = stepViews(task, work, now);
64
67
  if (steps.length === 0) return undefined;
65
68
  return { done: steps.filter((step) => step.status === "done").length, total: steps.length };
66
69
  }
67
70
 
71
+ /** The work clock as the page shows it; a finished task's no longer counts. */
72
+ function clockOf(task: Task, work: WorkProjection | undefined): TaskRow["work"] {
73
+ if (!work) return undefined;
74
+ return { workedMs: Math.round(work.workedMs), running: work.running && !TERMINAL_STATES.includes(task.state) };
75
+ }
76
+
68
77
  /** How long ago, as `3h` (mirrors the lobby layout's `ago`). */
69
78
  function ago(ms: number): string {
70
79
  if (!Number.isFinite(ms) || ms < 5_000) return "now";
@@ -86,7 +95,9 @@ function ownerOf(task: Task, sessionId: string | undefined, ctx: RowContext): st
86
95
  }
87
96
 
88
97
  function rowOf(task: Task, section: TaskSection, ctx: RowContext, sessionId: string | undefined, now: number): TaskRow {
89
- const progress = section === "recent" ? undefined : progressOf(task);
98
+ const work = ctx.work(task.id);
99
+ const progress = section === "recent" ? undefined : progressOf(task, work, now);
100
+ const clock = clockOf(task, work);
90
101
  return {
91
102
  kind: "task",
92
103
  id: task.id,
@@ -98,6 +109,7 @@ function rowOf(task: Task, section: TaskSection, ctx: RowContext, sessionId: str
98
109
  ...(task.paused && section !== "recent" ? { paused: true } : {}),
99
110
  check: checkOf(task),
100
111
  ...(progress ? { progress } : {}),
112
+ ...(clock ? { work: clock } : {}),
101
113
  ...(section === "others" ? { owner: ownerOf(task, sessionId, ctx) } : {}),
102
114
  ...(section === "recent" ? { age: ago(now - Date.parse(task.updatedAt)) } : {}),
103
115
  ...(section !== "recent" && ctx.auto.has(task.id) ? { auto: true } : {}),
@@ -141,7 +153,7 @@ function contextOf(ctx: ApiContext, tasks: readonly Task[]): RowContext {
141
153
  if (session.sessionId && session.alive) names.set(session.sessionId, session.name);
142
154
  }
143
155
  const auto = new Set(tasks.filter((task) => !TERMINAL_STATES.includes(task.state) && ctx.service.isAuto(task.id)).map((task) => task.id));
144
- return { names, auto, live: new Set(ctx.service.liveSessions().map((session) => session.sessionId)) };
156
+ return { names, auto, live: new Set(ctx.service.liveSessions().map((session) => session.sessionId)), work: (taskId) => ctx.service.work?.(taskId) };
145
157
  }
146
158
 
147
159
  /** Every task, plan and recent row, oldest groups first like the terminal. */
@@ -255,13 +267,22 @@ export function tasksGet(body: { taskId: string }, ctx: ApiContext): TaskDetail
255
267
  const found = task as Task;
256
268
  const request = taskRequest(found);
257
269
  const now = Date.now();
270
+ const work = ctx.service.work?.(found.id);
271
+ const clock = clockOf(found, work);
272
+ const details = found.plan ? planDetails(found.plan) : undefined;
258
273
  return {
259
274
  timing: projectPhaseTiming(found, new Date(now).toISOString()),
275
+ ...(clock ? { work: clock } : {}),
260
276
  ...(found.delivery ? { delivery: found.delivery } : {}),
261
277
  ...(request !== found.title ? { request } : {}),
262
278
  ...(!found.plan && found.proposal ? { proposal: found.proposal } : {}),
263
279
  ...(found.plan ? { plan: found.plan } : {}),
264
- steps: taskSteps(found).map((step) => ({ text: step.text, status: step.status === "pending" ? "open" : step.status })),
280
+ ...(details ? { planDetails: details } : {}),
281
+ steps: stepViews(found, work, now).map((step) => ({
282
+ text: step.text,
283
+ status: step.status === "pending" ? "open" : step.status,
284
+ ...(step.active ? { active: true, workedMs: step.workedMs } : {}),
285
+ })),
265
286
  amendments: [...found.amendments],
266
287
  waiting: waitingOf(found),
267
288
  blockers: found.blockers.map((blocker) => ({ reason: blocker.reason, need: blocker.need })),
@@ -119,6 +119,16 @@ function resetCommand(ctx: ExtensionCommandContext): void {
119
119
  ctx.ui.notify(`bot-lobby web: the link changed: ${link}`, "info");
120
120
  }
121
121
 
122
+ /** Close the page's server when bot-lobby is turned off; quiet, and nothing when it is not running. */
123
+ export async function stopWebServer(ctx: ExtensionContext): Promise<void> {
124
+ const running = currentWebServer();
125
+ if (!running) return;
126
+ await running.close();
127
+ latestLink = undefined;
128
+ heartbeat();
129
+ showAddress(ctx, undefined);
130
+ }
131
+
122
132
  /** Run one `web` subcommand; a failure is a notice, never a throw. */
123
133
  export async function webCommand(ctx: ExtensionCommandContext, word: string | undefined, deps?: WebCommandDeps): Promise<void> {
124
134
  const action = webAction(word);
@@ -137,9 +147,10 @@ export async function webCommand(ctx: ExtensionCommandContext, word: string | un
137
147
  }
138
148
 
139
149
  /**
140
- * On `session_start`: follow the fresh lobby service, and start the server
141
- * (the first time) with the browser opening on it. The page is always on in
142
- * an interactive session; a subagent or a one-shot run serves nothing.
150
+ * On `session_start` (and when bot-lobby is turned on): follow the fresh
151
+ * lobby service, and start the server (the first time) with the browser
152
+ * opening on it. The page runs while bot-lobby is on in an interactive
153
+ * session; off, a subagent or a one-shot run serves nothing.
143
154
  */
144
155
  export function webSessionStarted(ctx: ExtensionContext, deps?: WebCommandDeps): void {
145
156
  if (isSubagentProcess() || !servesPage(ctx)) return;
@@ -447,6 +447,7 @@ export function createFixtureService(name: string, feed = new LobbyFeed()): Lobb
447
447
  sessionName: () => fixture.status.sessionName,
448
448
  masterBusy: () => fixture.status.busy,
449
449
  config: () => config,
450
+ linters: () => fixture.settings?.linters ?? [{ tool: "ESLint", folder: "", installed: true }, { tool: "Ruff", folder: "services/api", installed: false }],
450
451
  saveConfig: (next: BotLobbyConfig) => {
451
452
  config = next;
452
453
  },
@@ -467,6 +468,12 @@ export function createFixtureService(name: string, feed = new LobbyFeed()): Lobb
467
468
  },
468
469
  chatHistory: () => [...history],
469
470
  tasks: () => [...tasks],
471
+ work: (taskId: string) => {
472
+ const work = fixture.mockWork?.[taskId];
473
+ if (!work) return undefined;
474
+ const now = Date.now();
475
+ return { workedMs: work.workedMs, running: work.running, active: (work.active ?? []).map((run) => ({ runId: run.runId, instruction: run.instruction, startedAt: new Date(now - run.startedAgoMs).toISOString() })) };
476
+ },
470
477
  plans: () => [...plans],
471
478
  metrics: () => metrics.map((record) => ({ ...record })),
472
479
  classifierMetrics: () => classifierMetrics.map((record) => ({ ...record })),