@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.
- package/README.md +11 -3
- package/package.json +1 -5
- package/prompts/designer.md +14 -0
- package/prompts/master.md +30 -7
- package/prompts/panel.md +21 -1
- package/prompts/planner.md +22 -3
- package/prompts/qa.md +23 -3
- package/prompts/quickfix.md +4 -0
- package/prompts/reviewer.md +17 -0
- package/prompts/worker.md +21 -6
- package/src/ask/tool.ts +3 -3
- package/src/classifier/hosts.ts +0 -11
- package/src/classifier/instance.ts +0 -5
- package/src/delivery/review.ts +1 -1
- package/src/execution/agent-runner.ts +0 -18
- package/src/execution/git.ts +77 -0
- package/src/execution/lint.ts +435 -0
- package/src/knowledge/compactor.ts +0 -70
- package/src/lobby/ask.ts +4 -17
- package/src/lobby/feed.ts +39 -4
- package/src/lobby/host.ts +4 -0
- package/src/lobby/issues.ts +0 -8
- package/src/lobby/planner.ts +54 -5
- package/src/lobby/pr-review.ts +2 -1
- package/src/lobby/pulls.ts +1 -44
- package/src/lobby/quickfix.ts +34 -1
- package/src/lobby/runtime.ts +20 -3
- package/src/lobby/service.ts +5 -1
- package/src/lobby/session-files.ts +1 -45
- package/src/lobby/split.ts +33 -1
- package/src/lobby/task-rows.ts +2 -0
- package/src/master/master.ts +3 -0
- package/src/master/research.ts +0 -16
- package/src/pi/commands.ts +25 -10
- package/src/pi/events.ts +12 -4
- package/src/pi/model-settings.ts +0 -1
- package/src/pi/plan-checklist.ts +98 -8
- package/src/pi/route.ts +0 -1
- package/src/pi/run-summary.ts +0 -57
- package/src/pi/switch.ts +69 -0
- package/src/pi/tools.ts +7 -0
- package/src/pi/ui.ts +2 -14
- package/src/schemas/agent.ts +0 -4
- package/src/schemas/configuration.ts +51 -3
- package/src/schemas/lint.ts +55 -0
- package/src/schemas/task.ts +5 -0
- package/src/state/budget.ts +49 -22
- package/src/state/changes.ts +8 -2
- package/src/state/metrics.ts +0 -1
- package/src/state/persistence.ts +0 -5
- package/src/state/work-time.ts +123 -0
- package/src/webui/api/index.ts +2 -6
- package/src/webui/api/knowledge.ts +11 -3
- package/src/webui/api/lobby.ts +13 -6
- package/src/webui/api/plan-facts.ts +36 -6
- package/src/webui/api/settings.ts +6 -1
- package/src/webui/api/tasks.ts +27 -6
- package/src/webui/command.ts +14 -3
- package/src/webui/dev/fake-service.ts +7 -0
- package/src/webui/dev/fixtures.ts +4 -1
- package/src/webui/protocol.ts +30 -4
- package/src/workflow/lint.ts +221 -0
- package/src/workflow/track.ts +12 -4
- package/src/workflow/workflow.ts +105 -21
- package/webui/dist/assets/index-C1IwecNS.css +2 -0
- package/webui/dist/assets/index-C4Ww36TW.js +105 -0
- package/webui/dist/build.json +58 -62
- package/webui/dist/index.html +2 -2
- package/webui/dist/assets/index-BSewK1QO.css +0 -2
- package/webui/dist/assets/index-CHQYSZXA.js +0 -105
package/src/state/budget.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
/**
|
|
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
|
}
|
package/src/state/changes.ts
CHANGED
|
@@ -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)
|
|
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
|
|
package/src/state/metrics.ts
CHANGED
|
@@ -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 => {
|
package/src/state/persistence.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/webui/api/index.ts
CHANGED
|
@@ -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. */
|
package/src/webui/api/lobby.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
16
|
-
function planFacts(task: Task): Pick<SnapshotTask, "progress" | "currentStep"> {
|
|
17
|
-
const
|
|
18
|
-
|
|
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,
|
|
4
|
-
* `persistedRuns`) so the
|
|
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
|
|
27
|
-
|
|
28
|
-
|
|
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);
|
package/src/webui/api/tasks.ts
CHANGED
|
@@ -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 {
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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 })),
|
package/src/webui/command.ts
CHANGED
|
@@ -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
|
|
141
|
-
* (the first time) with the browser
|
|
142
|
-
*
|
|
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 })),
|