@a-t-h-i/bot-lobby 0.6.32 → 0.6.34

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 (39) hide show
  1. package/README.md +30 -0
  2. package/package.json +7 -7
  3. package/src/desk/client-extension.ts +2 -2
  4. package/src/desk/ipc.ts +4 -3
  5. package/src/desk/session.ts +14 -1
  6. package/src/execution/agent-runner.ts +16 -92
  7. package/src/execution/delegation.ts +193 -0
  8. package/src/execution/live-run.ts +95 -0
  9. package/src/execution/pi-runner.ts +35 -9
  10. package/src/index.ts +5 -0
  11. package/src/lobby/planner.ts +9 -0
  12. package/src/lobby/pr-review.ts +9 -0
  13. package/src/lobby/quickfix.ts +16 -0
  14. package/src/master/master.ts +3 -1
  15. package/src/master/research.ts +1 -0
  16. package/src/pi/auto-compaction.ts +72 -0
  17. package/src/pi/delegation.ts +68 -0
  18. package/src/pi/model-support.ts +1 -1
  19. package/src/pi/plan-checklist.ts +18 -7
  20. package/src/pi/run-summary.ts +9 -0
  21. package/src/schemas/configuration.ts +19 -7
  22. package/src/schemas/findings.ts +4 -0
  23. package/src/schemas/task.ts +4 -0
  24. package/src/state/work-time.ts +4 -2
  25. package/src/webui/api/index.ts +2 -2
  26. package/src/webui/api/lobby.ts +1 -1
  27. package/src/webui/api/metrics.ts +41 -8
  28. package/src/webui/api/plan-facts.ts +4 -3
  29. package/src/webui/api/tasks.ts +1 -1
  30. package/src/webui/dev/fake-service.ts +1 -1
  31. package/src/webui/dev/fixtures.ts +1 -1
  32. package/src/webui/protocol.ts +4 -3
  33. package/src/workflow/workflow.ts +4 -1
  34. package/webui/dist/assets/index-B4zpEv7K.js +109 -0
  35. package/webui/dist/assets/index-C8hgfw6m.css +2 -0
  36. package/webui/dist/build.json +39 -27
  37. package/webui/dist/index.html +2 -2
  38. package/webui/dist/assets/index-BtKUZnAE.css +0 -2
  39. package/webui/dist/assets/index-CmNcHve-.js +0 -108
@@ -77,6 +77,10 @@ export const MAX_WORKER_RECORDS = 64;
77
77
  /** One finished subagent run of any role, kept for `/bot-lobby runs`. */
78
78
  export interface RunLogEntry {
79
79
  runId: string;
80
+ parentRunId?: string;
81
+ depth?: number;
82
+ instruction?: string;
83
+ stepInstruction?: string;
80
84
  domain: Domain;
81
85
  role: Role;
82
86
  status: "running" | "success" | "failed" | "cancelled" | "timeout";
@@ -15,6 +15,8 @@ import { taskDirFor } from "./persistence.ts";
15
15
  /** A worker run under way: the step it works on is found from its instruction. */
16
16
  export interface ActiveRun {
17
17
  runId: string;
18
+ parentRunId?: string;
19
+ stepInstruction?: string;
18
20
  instruction: string;
19
21
  startedAt: string;
20
22
  }
@@ -97,7 +99,7 @@ export function taskWork(root: string, configDir: string, taskId: string, now =
97
99
  const noted = new Map<string, Set<string>>();
98
100
 
99
101
  /** 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 {
102
+ export function noteWorkerRun(root: string, configDir: string, taskId: string, run: { runId: string; parentRunId?: string; stepInstruction?: string; instruction?: string; startedAt: string; status: string }): void {
101
103
  const running = run.status === "running";
102
104
  const ids = noted.get(taskId) ?? new Set<string>();
103
105
  if (running === ids.has(run.runId)) return;
@@ -106,7 +108,7 @@ export function noteWorkerRun(root: string, configDir: string, taskId: string, r
106
108
  noted.set(taskId, ids);
107
109
  updateWork(root, configDir, taskId, (work) => {
108
110
  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 });
111
+ if (running) work.active.push({ runId: run.runId, instruction: run.instruction ?? "", startedAt: run.startedAt, ...(run.parentRunId ? { parentRunId: run.parentRunId } : {}), ...(run.stepInstruction ? { stepInstruction: run.stepInstruction } : {}) });
110
112
  });
111
113
  }
112
114
 
@@ -271,8 +271,8 @@ function buildRoutes(): Record<string, Route> {
271
271
  run: (body, ctx) => quickfixMovedToTask(body as { id: string }, ctx),
272
272
  };
273
273
  routes["metrics.get"] = {
274
- schema: Type.Object({ groupBy: Type.Union([Type.Literal("model"), Type.Literal("model-kind")]), query: Type.Optional(Type.String({ maxLength: 500 })) }, { additionalProperties: false }),
275
- run: (body, ctx) => metricsGet(body as { groupBy: "model" | "model-kind"; query?: string }, ctx),
274
+ schema: Type.Object({ groupBy: Type.Union([Type.Literal("model"), Type.Literal("model-kind")]), query: Type.Optional(Type.String({ maxLength: 500 })), from: Type.Optional(Type.String({ pattern: "^\\d{4}-\\d{2}-\\d{2}$" })), to: Type.Optional(Type.String({ pattern: "^\\d{4}-\\d{2}-\\d{2}$" })), timeZone: Type.Optional(Type.String({ maxLength: 100 })) }, { additionalProperties: false }),
275
+ run: (body, ctx) => metricsGet(body as Parameters<typeof metricsGet>[0], ctx),
276
276
  };
277
277
  routes["knowledge.files"] = { schema: Empty, run: (_body, ctx) => knowledgeFiles(ctx) };
278
278
  routes["knowledge.ask"] = {
@@ -24,7 +24,7 @@ function planFacts(task: Task, work: WorkProjection | undefined): Pick<SnapshotT
24
24
  return {
25
25
  progress: { done: steps.filter((step) => step.status === "done").length, total: steps.length },
26
26
  ...(current ? { currentStep: current.text } : {}),
27
- ...(active.length > 0 ? { activeSteps: active.map((step) => ({ text: step.text, workedMs: step.workedMs })) } : {}),
27
+ ...(active.length > 0 ? { activeSteps: active.map((step) => ({ text: step.text, workedMs: step.workedMs, activeAgentCount: step.activeAgentCount })) } : {}),
28
28
  ...clock,
29
29
  };
30
30
  }
@@ -3,10 +3,10 @@
3
3
  * time goes, and what the classifier did — the same figures the terminal
4
4
  * tab draws, as data. Search narrows runs exactly like the tab's `/`.
5
5
  */
6
- import { aggregateMetrics, collectMetrics, sortGroups, summarizeClassifier, taskStats, taskTimesByModel, type GroupBy, type MetricRecord } from "../../state/metrics.ts";
6
+ import { aggregateMetrics, collectMetrics, sortGroups, summarizeClassifier, taskStats, taskTimesByModel, type MetricRecord } from "../../state/metrics.ts";
7
7
  import { filterRecords } from "../../lobby/models/metrics.ts";
8
- import type { MetricsData } from "../protocol.ts";
9
- import type { ApiContext } from "./index.ts";
8
+ import type { Api, MetricsData } from "../protocol.ts";
9
+ import { fail, type ApiContext } from "./index.ts";
10
10
 
11
11
  function timed(records: readonly MetricRecord[]): MetricRecord[] {
12
12
  return records.filter((record) => record.status !== "cancelled" && record.durationMs > 0);
@@ -49,15 +49,48 @@ function shareOf(records: readonly MetricRecord[]): MetricsData["timeShare"]["by
49
49
  }
50
50
 
51
51
  /** The dashboard figures for one grouping and search. */
52
- export function metricsGet(body: { groupBy: GroupBy; query?: string }, ctx: ApiContext): MetricsData {
53
- const tasks = ctx.service.tasks();
54
- const records = filterRecords(collectMetrics(ctx.service.metrics(), tasks), body.query);
52
+ export function metricsGet(body: Api["metrics.get"]["request"], ctx: ApiContext): MetricsData {
53
+ const { from, to } = body;
54
+ const validDate = (value: string) => /^\d{4}-\d{2}-\d{2}$/.test(value) && Number.isFinite(Date.parse(value)) && new Date(value).toISOString().slice(0, 10) === value;
55
+ if ((from === undefined) !== (to === undefined) || (from !== undefined && (!validDate(from) || !validDate(to!) || from > to!))) {
56
+ fail(400, "bad_request", "Choose a valid start and end date, with the start on or before the end.");
57
+ }
58
+ let formatter: Intl.DateTimeFormat;
59
+ try {
60
+ formatter = new Intl.DateTimeFormat("en", { timeZone: body.timeZone ?? "UTC", year: "numeric", month: "2-digit", day: "2-digit" });
61
+ } catch {
62
+ fail(400, "bad_request", "Choose a valid time zone.");
63
+ }
64
+ const dayOf = (timestamp: string) => {
65
+ const date = new Date(timestamp);
66
+ if (!Number.isFinite(date.getTime())) return "";
67
+ const parts = formatter.formatToParts(date);
68
+ return ["year", "month", "day"].map((part) => parts.find((item) => item.type === part)!.value).join("-");
69
+ };
70
+ const inRange = (timestamp: string) => !from || (dayOf(timestamp) >= from && dayOf(timestamp) <= to!);
71
+ const allTasks = ctx.service.tasks();
72
+ const tasks = allTasks.filter((task) => inRange(task.state === "completed" ? task.updatedAt : task.createdAt));
73
+ const allRecords = filterRecords(collectMetrics(ctx.service.metrics(), allTasks), body.query);
74
+ const records = allRecords.filter((record) => inRange(record.startedAt));
55
75
  const stats = taskStats(tasks);
56
- const classifier = summarizeClassifier(ctx.service.classifierMetrics?.() ?? [], records);
76
+ const classifier = summarizeClassifier((ctx.service.classifierMetrics?.() ?? []).filter((record) => inRange(record.startedAt)), records);
77
+ const daily = new Map<string, MetricsData["daily"][number]>();
78
+ for (const record of records) {
79
+ const date = dayOf(record.startedAt);
80
+ if (!date) continue;
81
+ const entry = daily.get(date) ?? { date, runs: 0, successes: 0, cost: 0 };
82
+ entry.runs += 1;
83
+ entry.successes += Number(record.status === "success");
84
+ entry.cost += record.cost ?? 0;
85
+ daily.set(date, entry);
86
+ }
87
+ // Include range edges without allocating an entry for every empty day.
88
+ for (const date of [from, to]) if (date && !daily.has(date)) daily.set(date, { date, runs: 0, successes: 0, cost: 0 });
57
89
  return {
58
90
  tiles: tilesOf(records, stats),
91
+ daily: [...daily.values()].sort((a, b) => a.date.localeCompare(b.date)),
59
92
  groups: sortGroups(aggregateMetrics(records, body.groupBy), "runs"),
60
- timeShare: { byAgent: shareOf(records), taskTimes: taskTimesByModel(tasks, records) },
93
+ timeShare: { byAgent: shareOf(records), taskTimes: taskTimesByModel(tasks, allRecords) },
61
94
  ...(classifier ? { classifier } : {}),
62
95
  };
63
96
  }
@@ -30,7 +30,7 @@ function activeRuns(task: Task, work: WorkProjection | undefined): AgentRun[] {
30
30
  const finished = new Set((task.workerRuns ?? []).map((record) => record.runId));
31
31
  return (work?.active ?? [])
32
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 }));
33
+ .map((run) => ({ runId: run.runId, parentRunId: run.parentRunId, stepInstruction: run.stepInstruction, 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
34
  }
35
35
 
36
36
  /** One step as the lobby shows it: its state, and while a worker is on it, how long they have worked on it. */
@@ -38,6 +38,7 @@ export interface StepView {
38
38
  text: string;
39
39
  status: PlanStep["status"];
40
40
  active: boolean;
41
+ activeAgentCount: number;
41
42
  /** Worker time on the step, in ms. */
42
43
  workedMs: number;
43
44
  }
@@ -53,7 +54,7 @@ export function stepViews(task: Task, work: WorkProjection | undefined, now = Da
53
54
  const checklist = planChecklist(task.plan, runs);
54
55
  const times = stepWork(planSteps(task.plan), runs, now);
55
56
  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) };
57
+ const time = times[index] ?? { ms: 0, active: false, activeAgentCount: 0 };
58
+ return { text: step.text, status: time.active ? "current" : step.status, active: time.active, activeAgentCount: time.activeAgentCount, workedMs: Math.round(time.ms) };
58
59
  });
59
60
  }
@@ -311,7 +311,7 @@ export function tasksGet(body: { taskId: string }, ctx: ApiContext): TaskDetail
311
311
  steps: stepViews(found, work, now).map((step) => ({
312
312
  text: step.text,
313
313
  status: step.status === "pending" ? "open" : step.status,
314
- ...(step.active ? { active: true, workedMs: step.workedMs } : {}),
314
+ ...(step.active ? { active: true, workedMs: step.workedMs, activeAgentCount: step.activeAgentCount } : {}),
315
315
  })),
316
316
  amendments: [...found.amendments],
317
317
  waiting: waitingOf(found),
@@ -478,7 +478,7 @@ export function createFixtureService(name: string, feed = new LobbyFeed()): Lobb
478
478
  const work = fixture.mockWork?.[taskId];
479
479
  if (!work) return undefined;
480
480
  const now = Date.now();
481
- 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() })) };
481
+ return { workedMs: work.workedMs, running: work.running, active: (work.active ?? []).map((run) => ({ runId: run.runId, parentRunId: run.parentRunId, stepInstruction: run.stepInstruction, instruction: run.instruction, startedAt: new Date(now - run.startedAgoMs).toISOString() })) };
482
482
  },
483
483
  plans: () => [...plans],
484
484
  metrics: () => metrics.map((record) => ({ ...record })),
@@ -65,7 +65,7 @@ export interface ScenarioFixture {
65
65
  /** Tasks backing `tasks.list` (absent means none). */
66
66
  mockTasks?: Array<Record<string, unknown>>;
67
67
  /** Work clocks by task id (`work.json` as the owner keeps it); a run's start is given as how long ago, so it reads live. */
68
- mockWork?: Record<string, { workedMs: number; running: boolean; active?: Array<{ runId: string; instruction: string; startedAgoMs: number }> }>;
68
+ mockWork?: Record<string, { workedMs: number; running: boolean; active?: Array<{ runId: string; parentRunId?: string; stepInstruction?: string; instruction: string; startedAgoMs: number }> }>;
69
69
  /** Saved plans backing `plans.start`/`plans.discard` and the pending rows. */
70
70
  mockPlans?: Array<{ id: string; title: string; brief: string }>;
71
71
  /** Archived tasks backing `tasks.archived`. */
@@ -131,7 +131,7 @@ export interface SnapshotTask {
131
131
  progress?: { done: number; total: number };
132
132
  currentStep?: string;
133
133
  /** The steps a worker is on right now, with the worker time on each (ms, as of the snapshot). */
134
- activeSteps?: Array<{ text: string; workedMs: number }>;
134
+ activeSteps?: Array<{ text: string; workedMs: number; activeAgentCount?: number }>;
135
135
  /** How long the task's agents have worked on it, idle time left out. */
136
136
  work?: WorkClock;
137
137
  }
@@ -335,6 +335,7 @@ export interface MetricsClassifier {
335
335
  export interface MetricsData {
336
336
  tiles: MetricsTiles;
337
337
  groups: MetricGroupInfo[];
338
+ daily: Array<{ date: string; runs: number; successes: number; cost: number }>;
338
339
  timeShare: { byAgent: MetricsAgentShare[]; taskTimes: MetricsTaskTime[] };
339
340
  classifier?: MetricsClassifier;
340
341
  }
@@ -419,7 +420,7 @@ export interface TaskDetail {
419
420
  * The plan as a checklist, in order; `current` is the step under way, `active` the steps a worker is on right now,
420
421
  * each with its worker time (ms, as of the reply).
421
422
  */
422
- steps: Array<{ text: string; status: "done" | "current" | "open"; active?: boolean; workedMs?: number }>;
423
+ steps: Array<{ text: string; status: "done" | "current" | "open"; active?: boolean; workedMs?: number; activeAgentCount?: number }>;
423
424
  /** How long the task's agents have worked on it, idle time left out (ms, as of the reply); `running` while they work. */
424
425
  work?: WorkClock;
425
426
  amendments: string[];
@@ -634,7 +635,7 @@ export interface Api {
634
635
  "quickfix.cancel": { request: { id: string }; result: { notice: string } };
635
636
  "quickfix.runAnyway": { request: { id: string }; result: { notice: string } };
636
637
  "quickfix.movedToTask": { request: { id: string }; result: { notice: string; key?: string } };
637
- "metrics.get": { request: { groupBy: "model" | "model-kind"; query?: string }; result: MetricsData };
638
+ "metrics.get": { request: { groupBy: "model" | "model-kind"; query?: string; from?: string; to?: string; timeZone?: string }; result: MetricsData };
638
639
  "projects.browse": { request: { path?: string }; result: FolderListing };
639
640
  "projects.open": { request: { path: string }; result: { project: ProjectInfo } };
640
641
  "knowledge.files": { request: Record<string, never>; result: { files: KnowledgeFileInfo[]; models?: Partial<Record<KnowledgeAgentName, string>> } };
@@ -2012,7 +2012,10 @@ export async function runWorkflowAction(params: OrchestrateParams, deps: Workflo
2012
2012
  // Every agent, diff and desk of a task with its own worktree works there.
2013
2013
  cwd: taskCwd(task, deps.cwd),
2014
2014
  onUpdate: (run) => {
2015
- if (run.status !== "running") finished.set(run.runId, run);
2015
+ if (run.status !== "running") {
2016
+ finished.set(run.runId, run);
2017
+ if (run.parentRunId) writeFileEnsured(join(taskDirFor(deps.root, deps.configDir, task.id), "children", `${run.runId}.json`), `${JSON.stringify(run)}\n`);
2018
+ }
2016
2019
  // Every window marks the steps a worker is on while it runs.
2017
2020
  if (run.role === "worker") {
2018
2021
  if (run.status === "running") working.add(run.runId);