@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
@@ -61,8 +61,13 @@ const MEMBER_SEATS: Record<PanelMember, string> = {
61
61
  export interface PanelOption {
62
62
  label: string;
63
63
  description: string;
64
+ /** A static HTML/CSS mockup of the option (a fenced `mockup` block under it), shown as a thumbnail the user expands. */
65
+ mockup?: { html: string; css?: string };
64
66
  }
65
67
 
68
+ /** Longest mockup kept per option; a longer one is dropped rather than cut mid-tag. */
69
+ export const MAX_MOCKUP_CHARS = 30_000;
70
+
66
71
  /** A question as a seat or the oracle wrote it, with the options it offers (recommended first). */
67
72
  export interface AskedQuestion {
68
73
  text: string;
@@ -273,7 +278,22 @@ export function questionItems(body: string | undefined): AskedQuestion[] {
273
278
  if (!body) return [];
274
279
  const items: AskedQuestion[] = [];
275
280
  let current: AskedQuestion | undefined;
281
+ // A fenced `mockup` (or `html`) block under an option is that option's mockup, never more question text.
282
+ let fence: { option?: PanelOption | undefined; lines: string[]; marker: string } | undefined;
276
283
  for (const line of body.split("\n")) {
284
+ if (fence) {
285
+ if (line.trim() === fence.marker) {
286
+ const html = dedent(fence.lines).trim();
287
+ if (fence.option && html && html.length <= MAX_MOCKUP_CHARS) fence.option.mockup = { html };
288
+ fence = undefined;
289
+ } else fence.lines.push(line);
290
+ continue;
291
+ }
292
+ const opening = /^\s*(`{3,}|~{3,})\s*(?:mockup|html)\s*$/i.exec(line);
293
+ if (opening) {
294
+ fence = { lines: [], marker: opening[1]!, ...(current?.options.length ? { option: current.options.at(-1) } : {}) };
295
+ continue;
296
+ }
277
297
  const match = /^(\s*)(?:\d+[.)]|[-*])\s+(.*\S)\s*$/.exec(line);
278
298
  if (match && (match[1]!.length === 0 || !current)) {
279
299
  current = { text: match[2]!.replace(/\*\*/g, ""), options: [] };
@@ -289,6 +309,34 @@ export function questionItems(body: string | undefined): AskedQuestion[] {
289
309
  return items.map(inlineOptions);
290
310
  }
291
311
 
312
+ /** Lines with their shared indentation removed. */
313
+ function dedent(lines: readonly string[]): string {
314
+ const indents = lines.filter((line) => line.trim()).map((line) => /^\s*/.exec(line)![0].length);
315
+ const cut = indents.length ? Math.min(...indents) : 0;
316
+ return lines.map((line) => line.slice(cut)).join("\n");
317
+ }
318
+
319
+ const plainOption = (label: string): string => optionLabel({ label, description: "" }).toLowerCase();
320
+
321
+ /**
322
+ * The oracle relays a seat's question in its own words, without repeating the
323
+ * seat's mockups: an option it keeps by label keeps the seat's mockup (from
324
+ * the seat it tags first, then any seat).
325
+ */
326
+ export function carryMockups(questions: readonly PanelQuestion[], seatQuestions: readonly PanelQuestion[]): PanelQuestion[] {
327
+ const drawn = seatQuestions.flatMap((question) => question.options.filter((option) => option.mockup).map((option) => ({ from: question.from, label: plainOption(option.label), mockup: option.mockup! })));
328
+ if (drawn.length === 0) return [...questions];
329
+ return questions.map((question) => ({
330
+ ...question,
331
+ options: question.options.map((option) => {
332
+ if (option.mockup) return option;
333
+ const label = plainOption(option.label);
334
+ const match = drawn.find((entry) => entry.label === label && entry.from === question.from) ?? drawn.find((entry) => entry.label === label);
335
+ return match ? { ...option, mockup: match.mockup } : option;
336
+ }),
337
+ }));
338
+ }
339
+
292
340
  /**
293
341
  * The round's questions for the user. The oracle chooses them from its own
294
342
  * and the seats' (tagged with the seat each serves) and decides the rest; only
@@ -296,7 +344,7 @@ export function questionItems(body: string | undefined): AskedQuestion[] {
296
344
  * `MAX_QUESTIONS`, so a round is answered in one questionnaire.
297
345
  */
298
346
  export function roundQuestions(reply: PlannerReply | undefined, seatQuestions: readonly PanelQuestion[]): PanelQuestion[] {
299
- const chosen = reply ? reply.questions.map((question) => tagged(question, ORACLE_LABEL)) : [...seatQuestions];
347
+ const chosen = reply ? carryMockups(reply.questions.map((question) => tagged(question, ORACLE_LABEL)), seatQuestions) : [...seatQuestions];
300
348
  return chosen.slice(0, MAX_QUESTIONS);
301
349
  }
302
350
 
@@ -330,8 +378,8 @@ export function parseMemberReply(text: string): MemberReply {
330
378
  return { status: ready ? "ready" : "open", questions, notes: listItems(parts.get("notes")) };
331
379
  }
332
380
 
333
- function optionLines(options: readonly PanelOption[]): string[] {
334
- return options.map((option) => ` - ${option.label}${option.description ? ` — ${option.description}` : ""}`);
381
+ function optionLines(options: readonly PanelOption[], marks = false): string[] {
382
+ return options.map((option) => ` - ${option.label}${option.description ? ` — ${option.description}` : ""}${marks && option.mockup ? " [mockup]" : ""}`);
335
383
  }
336
384
 
337
385
  function questionLine(question: PanelQuestion, index: number): string {
@@ -429,7 +477,7 @@ export function panelSection(outcomes: readonly MemberOutcome[]): string {
429
477
  const { status, questions, notes } = outcome.reply;
430
478
  return [
431
479
  `### ${label} — ${status === "ready" ? "READY" : "OPEN"}`,
432
- questions.length > 0 ? `Questions for the user:\n${questions.map((question) => [`- ${question.text}`, ...optionLines(question.options)].join("\n")).join("\n")}` : "",
480
+ questions.length > 0 ? `Questions for the user:\n${questions.map((question) => [`- ${question.text}`, ...optionLines(question.options, true)].join("\n")).join("\n")}` : "",
433
481
  notes.length > 0 ? `Notes:\n${notes.map((note) => `- ${note}`).join("\n")}` : "",
434
482
  ].filter(Boolean).join("\n");
435
483
  });
@@ -798,7 +846,7 @@ export class PlanningSession {
798
846
  const step = describeToolCall(event.toolName, event.args);
799
847
  setStep(step);
800
848
  this.deps.feed?.step(label, step, this.stepKey(label));
801
- } else if (event.type === "thought") this.deps.feed?.thought(label, event.text);
849
+ } else if (event.type === "thought") this.deps.feed?.liveThought(label, event.text, this.stepKey(label));
802
850
  else if (event.type === "thinking" || event.type === "writing") setStep(event.type);
803
851
  else return;
804
852
  this.deps.onChange?.();
@@ -828,6 +876,7 @@ export class PlanningSession {
828
876
  outcome = { status: "failed", output: "", error: (error as Error).message, usage: { input: 0, output: 0, cost: 0, turns: 0 } };
829
877
  }
830
878
  this.deps.feed?.end(this.stepKey(label), outcome.status !== "success");
879
+ this.deps.feed?.settleThought(this.stepKey(label));
831
880
  const metric = planningMetric(kind, label, this.turns, startedAt, outcome.status, profile, outcome.model, outcome.usage);
832
881
  appendMetrics(this.deps.root, this.deps.configDir, [routedFrom ? { ...metric, routedFrom } : metric]);
833
882
  return outcome;
@@ -258,7 +258,7 @@ export class PullReviews {
258
258
 
259
259
  private onEvent(review: PullReview, event: PiStreamEvent): void {
260
260
  if (event.type === "tool_execution_start") this.step(review, describeToolCall(event.toolName, event.args));
261
- else if (event.type === "thought") this.deps.feed?.thought(REVIEW_SOURCE, event.text);
261
+ else if (event.type === "thought") this.deps.feed?.liveThought(REVIEW_SOURCE, event.text, `pr-${review.number}-${review.startedAt}`);
262
262
  else if (event.type === "retry") this.step(review, `provider retry ${event.attempt}/${event.maxAttempts}`);
263
263
  }
264
264
 
@@ -299,6 +299,7 @@ export class PullReviews {
299
299
  const ok = review.status === "done";
300
300
  const outcome = ok ? `reviewed #${review.number}${review.verdict ? `: ${review.verdict === "changes" ? "request changes" : review.verdict}` : ""}` : `review of #${review.number} ${review.status}${review.error ? ` — ${review.error.split("\n")[0]}` : ""}`;
301
301
  this.deps.feed?.end(`pr-${review.number}-${review.startedAt}`, !ok);
302
+ this.deps.feed?.settleThought(`pr-${review.number}-${review.startedAt}`);
302
303
  this.deps.feed?.log(REVIEW_SOURCE, outcome, ok ? "success" : review.status === "cancelled" ? "warning" : "error", review.finishedAt);
303
304
  appendMetrics(this.deps.root, this.deps.configDir, [{
304
305
  id: `review-${review.number}-${review.startedAt}`,
@@ -4,7 +4,7 @@
4
4
  * Like issues, `gh` owns authentication and repository detection; every
5
5
  * failure becomes one readable line.
6
6
  */
7
- import { execCommand, gh, type Exec } from "./issues.ts";
7
+ import { gh, type Exec } from "./issues.ts";
8
8
 
9
9
  export type ChecksState = "passing" | "failing" | "pending";
10
10
 
@@ -193,49 +193,6 @@ export async function pullDiff(exec: Exec, cwd: string, number: number, limit =
193
193
  return stdout.length > limit ? `${stdout.slice(0, limit)}\n[…the diff continues: ${stdout.length - limit} more characters]` : stdout;
194
194
  }
195
195
 
196
- /** A pull request opened for a completed task's branch. */
197
- export interface CreatedPull {
198
- number: number;
199
- url: string;
200
- }
201
-
202
- const PUSH_TIMEOUT_MS = 60_000;
203
-
204
- async function run(exec: Exec, cwd: string, command: string, args: string[]): Promise<string> {
205
- let result: Awaited<ReturnType<Exec>>;
206
- try {
207
- result = await exec(command, args, { cwd, timeout: PUSH_TIMEOUT_MS });
208
- } catch (error) {
209
- throw new Error(`${command} could not run — ${(error as Error).message}`);
210
- }
211
- if (result.code !== 0) throw new Error(result.stderr.trim() || `${command} exited with code ${result.code}`);
212
- return result.stdout;
213
- }
214
-
215
- /** `gh pr create` ends with the URL on its own last line; the number is its tail. */
216
- function parseCreated(stdout: string): CreatedPull {
217
- const url = stdout.trim().split("\n").map((line) => line.trim()).filter((line) => /^https?:\/\/\S+$/.test(line)).pop();
218
- if (!url) throw new Error("gh pr create printed no pull request URL");
219
- const match = /\/pull\/([1-9]\d*)$/.exec(url);
220
- const number = Number(match?.[1]);
221
- if (!Number.isSafeInteger(number) || number <= 0) throw new Error(`gh pr create printed an unreadable URL: ${url}`);
222
- return { number, url };
223
- }
224
-
225
- /**
226
- * Push a finished task's branch and open its pull request, so the work lands
227
- * in review without the oracle having to remember a second step.
228
- */
229
- export async function createPullRequest(options: { exec?: Exec; cwd: string; branch: string; base: string; title: string; body: string }): Promise<CreatedPull> {
230
- for (const ref of [options.branch, options.base]) {
231
- if (!ref || ref.startsWith("-") || /[\s~^:?*\[\\]|\.\.|@\{|\/\//.test(ref) || /[/.]$/.test(ref)) throw new Error("invalid pull request branch");
232
- }
233
- const exec = options.exec ?? execCommand;
234
- await run(exec, options.cwd, "git", ["push", "-u", "origin", options.branch]);
235
- const stdout = await gh(exec, options.cwd, ["pr", "create", "--base", options.base, "--head", options.branch, "--title", options.title, "--body", options.body.slice(0, 60_000)]);
236
- return parseCreated(stdout);
237
- }
238
-
239
196
  /** Lobby state for the Git tab: the list, the open pull request, and what is loading. */
240
197
  export class PullsState {
241
198
  pulls: PullSummary[] = [];
@@ -16,6 +16,12 @@ import type { FileHinter } from "../classifier/files.ts";
16
16
  import type { Classifier } from "../classifier/classifier.ts";
17
17
  import { quickFixSize } from "../classifier/triage.ts";
18
18
  import { fellShort, profileLabel, routeLabel, type EffortRoute, type EffortRouter } from "../classifier/effort.ts";
19
+ import { existsSync } from "node:fs";
20
+ import { isAbsolute, join, relative } from "node:path";
21
+ import type { LintConfig } from "../schemas/configuration.ts";
22
+ import { repositoryTop } from "../execution/git.ts";
23
+ import type { LintExec } from "../execution/lint.ts";
24
+ import { lintFeed, runLintGate } from "../workflow/lint.ts";
19
25
 
20
26
  /** A quick fix edits code, so it gets the full coding tool set. */
21
27
  export const QUICK_FIX_TOOLS: readonly string[] = ["read", "bash", "edit", "write", "grep", "find", "ls"];
@@ -97,6 +103,10 @@ export interface QuickFixDeps {
97
103
  classifier?: Classifier;
98
104
  /** Lowers thinking or the model for a request the classifier judges simple or trivial. */
99
105
  effort?: EffortRouter;
106
+ /** The lint settings, read as each job finishes: a finished job's touched files are linted, for the activity log only. */
107
+ lint?: () => LintConfig;
108
+ /** Runs a linter (tests); the real one otherwise. */
109
+ lintExec?: LintExec;
100
110
  }
101
111
 
102
112
  export const MAX_JOBS = 30;
@@ -222,7 +232,7 @@ export class QuickFixQueue {
222
232
  if (edits?.note(event.toolName, event.args)) job.files = edits.shown();
223
233
  this.addStep(job, describeToolCall(event.toolName, event.args), now);
224
234
  } else if (event.type === "turn_start") job.turns += 1;
225
- else if (event.type === "thought") this.deps.feed?.thought(QUICK_FIX_SOURCE, event.text, now);
235
+ else if (event.type === "thought") this.deps.feed?.liveThought(QUICK_FIX_SOURCE, event.text, job.id, now);
226
236
  else if (event.type === "retry") this.addStep(job, `provider retry ${event.attempt}/${event.maxAttempts}`, now);
227
237
  else return;
228
238
  this.changed();
@@ -360,14 +370,37 @@ export class QuickFixQueue {
360
370
  });
361
371
  }
362
372
  this.deps.feed?.end(job.id, job.status !== "success");
373
+ this.deps.feed?.settleThought(job.id);
363
374
  const ok = job.status === "success";
364
375
  const outcome = ok ? "done" : `${job.status}${job.error ? ` — ${job.error.split("\n")[0]}` : ""}`;
365
376
  this.deps.feed?.log(QUICK_FIX_SOURCE, `${outcome}: ${jobTitle(job)}`, ok ? "success" : job.status === "cancelled" ? "warning" : "error", job.finishedAt);
366
377
  appendMetrics(this.deps.root, this.deps.configDir, [quickFixMetric(job)]);
367
378
  this.deps.notify?.(`bot-lobby quick fix ${outcome}: ${jobTitle(job)}`, ok ? "info" : "warning");
379
+ if (ok && edited.length > 0) void this.lintAfter(job, edited);
368
380
  this.changed();
369
381
  this.pump();
370
382
  }
383
+
384
+ /**
385
+ * The lint gate's pass over what a quick fix touched (only problems on the
386
+ * lines it changed are its own): a line in the activity log, never a hold,
387
+ * as a quick fix has no gate. Lint failing to run says nothing.
388
+ */
389
+ private async lintAfter(job: QuickFixJob, edited: readonly string[]): Promise<void> {
390
+ const config = this.deps.lint?.();
391
+ if (!config || config.mode === "off") return;
392
+ try {
393
+ const top = await repositoryTop(this.deps.cwd);
394
+ if (!top) return;
395
+ const files = edited.map((file) => relative(top, file).split("\\").join("/")).filter((file) => file && !file.startsWith("..") && !isAbsolute(file) && existsSync(join(top, file)));
396
+ const report = await runLintGate({ top, files, config, ...(this.deps.lintExec ? { exec: this.deps.lintExec } : {}) });
397
+ if (report.state === "skipped") return;
398
+ const line = lintFeed(job.id, report);
399
+ this.deps.feed?.log("LINT", line.text, line.kind);
400
+ } catch {
401
+ // Advisory: a quick fix is never held or failed for lint.
402
+ }
403
+ }
371
404
  }
372
405
 
373
406
  export function quickFixMetric(job: QuickFixJob): MetricRecord {
@@ -9,7 +9,7 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
9
9
  import { detectProjectRoot, loadConfig } from "../state/project.ts";
10
10
  import { appendMetrics, type MetricStatus } from "../state/metrics.ts";
11
11
  import { describeToolCall } from "../pi/activity.ts";
12
- import { currentZenTask, onRunUpdates } from "../pi/ui.ts";
12
+ import { currentZenTask, isMinimized, onRunUpdates } from "../pi/ui.ts";
13
13
  import type { WorkspaceInfo } from "../execution/workspace.ts";
14
14
  import { basename } from "node:path";
15
15
  import { isSubagentProcess } from "../pi/quiet.ts";
@@ -20,7 +20,7 @@ import type { Domain } from "../schemas/agent.ts";
20
20
  import { chatFromEntries, lobbyFeed, narrateEvent, type AgentEventLike } from "./feed.ts";
21
21
  import { classifier, effortFor, hintsFor } from "../classifier/instance.ts";
22
22
  import { checkThinking } from "../pi/model-support.ts";
23
- import { jobTitle, QuickFixQueue } from "./quickfix.ts";
23
+ import { QuickFixQueue } from "./quickfix.ts";
24
24
  import type { Asker } from "../ask/types.ts";
25
25
  import type { PlanningSession } from "./planner.ts";
26
26
  import { execCommand, IssuesState } from "./issues.ts";
@@ -77,6 +77,21 @@ export async function answerPanel(state: Runtime | undefined = runtime): Promise
77
77
  return serviceAnswerPanel(state);
78
78
  }
79
79
 
80
+ /** What the lobby is running now (a quick fix, a review, a planning round), in words, or undefined. */
81
+ export function lobbyWorkRunning(): string | undefined {
82
+ const state = runtime;
83
+ if (!state) return undefined;
84
+ if (state.quickfix.running) return "A quick fix";
85
+ if (state.planner?.busy) return "A planning round";
86
+ if (state.reviews.busy) return "A pull request review";
87
+ return undefined;
88
+ }
89
+
90
+ /** Stop the lobby's backend (bot-lobby turned off, or the session ended). */
91
+ export function stopLobbyService(): void {
92
+ shutdown();
93
+ }
94
+
80
95
  function shutdown(): void {
81
96
  const state = runtime;
82
97
  if (!state) return;
@@ -165,6 +180,7 @@ export function startLobbyService(pi: ExtensionAPI, ctx: ExtensionContext, confi
165
180
  hints: hintsFor({ cwd: ctx.cwd, root, configDir }),
166
181
  classifier: classifier(),
167
182
  effort: effortFor((model, thinking) => checkThinking(modelLookup(ctx)(model), thinking).level),
183
+ lint: () => loadConfig().lint,
168
184
  });
169
185
  runtime = state;
170
186
  lobbyService = createLobbyService(state);
@@ -233,7 +249,8 @@ export function registerLobbyEvents(pi: ExtensionAPI, configDir: string): void {
233
249
  if (runtime) runtime.ctx = ctx;
234
250
  };
235
251
  pi.on("session_start", (_event, ctx) => {
236
- startLobbyService(pi, ctx, configDir);
252
+ // Off (the default for a new session) runs no lobby until bot-lobby is turned on (see pi/switch.ts).
253
+ if (!isMinimized()) startLobbyService(pi, ctx, configDir);
237
254
  });
238
255
  pi.on("session_shutdown", () => shutdown());
239
256
  pi.on("ui_prompt_start", () => {
@@ -10,6 +10,7 @@ import { randomUUID } from "node:crypto";
10
10
  import { realpathSync } from "node:fs";
11
11
  import { resolve } from "node:path";
12
12
  import { loadTask, ownedTask, peekTasks, saveTask } from "../state/persistence.ts";
13
+ import { taskWork } from "../state/work-time.ts";
13
14
  import { refreshReview, deferReview } from "../delivery/review.ts";
14
15
  import { deliver, type DeliveryRequest, type DeliveryStore } from "../delivery/operations.ts";
15
16
  import type { LockIdentity } from "../delivery/lock.ts";
@@ -34,7 +35,7 @@ import { pendingRequest, startRequest } from "../pi/route.ts";
34
35
  import { deliverComments, setAuto } from "../pi/owner.ts";
35
36
  import { chatFromEntries, lobbyFeed, type ChatEntry } from "./feed.ts";
36
37
  import { classifier, effortFor, hintsFor } from "../classifier/instance.ts";
37
- import { launchPi, SessionRegistry, type BackgroundSession, type DialogAnswer, type SessionLauncher } from "./sessions.ts";
38
+ import { launchPi, SessionRegistry, type BackgroundSession, type SessionLauncher } from "./sessions.ts";
38
39
  import { SessionChats } from "./session-files.ts";
39
40
  import { answerMessage, questionnaires, settledQuestions } from "./ask.ts";
40
41
  import { askUser } from "../ask/web.ts";
@@ -47,6 +48,7 @@ import type { LobbyAgentKind, PanelMember } from "../schemas/configuration.ts";
47
48
  import type { LiveSession, LobbyService, SwitchTarget } from "./host.ts";
48
49
  import { pushNotice, type NoticeLevel } from "../webui/notices.ts";
49
50
  import type { Runtime } from "./runtime.ts";
51
+ import { findLinters } from "../execution/lint.ts";
50
52
 
51
53
  function deliveryTask(state: Runtime, taskId: string): Task {
52
54
  if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{0,199}$/.test(taskId) || taskId === "." || taskId === "..") throw new Error("Invalid task ID.");
@@ -562,6 +564,7 @@ export function createLobbyService(state: Runtime): LobbyService {
562
564
  let appliedMaster = masterKey();
563
565
  return {
564
566
  projectRoot: () => state.root,
567
+ linters: () => findLinters(state.root),
565
568
  sessionId: () => state.ctx.sessionManager.getSessionId(),
566
569
  zen: () => {
567
570
  const snapshot = taskSnapshot();
@@ -570,6 +573,7 @@ export function createLobbyService(state: Runtime): LobbyService {
570
573
  feed: lobbyFeed,
571
574
  masterBusy: () => !state.ctx.isIdle(),
572
575
  tasks: () => peekTasks(state.root, state.configDir),
576
+ work: (taskId) => taskWork(state.root, state.configDir, taskId),
573
577
  deliveryDeliver: (taskId, request: DeliveryRequest) => deliver(request, { cwd: state.root, exec: execCommand }, deliveryStore(state, taskId)),
574
578
  deliveryReview: async (taskId) => {
575
579
  const task = deliveryTask(state, taskId);
@@ -7,51 +7,7 @@
7
7
  * parent, and the text of user and assistant messages (never tool output).
8
8
  */
9
9
  import { closeSync, openSync, readSync, statSync } from "node:fs";
10
- import { chatFromEntries, chatText, CLEARED_NOTE, isClearedMark, MAX_CHAT, textOf, type ChatEntry } from "./feed.ts";
11
-
12
- interface EntryLike {
13
- type?: string;
14
- id?: string;
15
- parentId?: string | null;
16
- }
17
-
18
- /** The entries on the branch that ends at the newest entry, oldest first (a session file is a tree). */
19
- export function currentBranch(entries: readonly unknown[]): unknown[] {
20
- const byId = new Map<string, EntryLike>();
21
- let leaf: EntryLike | undefined;
22
- for (const entry of entries) {
23
- const record = entry as EntryLike;
24
- if (!record || typeof record.id !== "string") continue;
25
- byId.set(record.id, record);
26
- leaf = record;
27
- }
28
- const branch: unknown[] = [];
29
- const seen = new Set<string>();
30
- for (let entry = leaf; entry?.id && !seen.has(entry.id); entry = entry.parentId ? byId.get(entry.parentId) : undefined) {
31
- seen.add(entry.id);
32
- branch.push(entry);
33
- }
34
- return branch.reverse();
35
- }
36
-
37
- /** Parse a session file's JSON lines, skipping torn ones. */
38
- export function parseEntries(text: string): unknown[] {
39
- const entries: unknown[] = [];
40
- for (const line of text.split("\n")) {
41
- if (!line.trim()) continue;
42
- try {
43
- entries.push(JSON.parse(line));
44
- } catch {
45
- // A line being written right now; the next read has it.
46
- }
47
- }
48
- return entries;
49
- }
50
-
51
- /** The conversation a session file holds, as the lobby's chat entries. */
52
- export function chatFromFile(text: string, max = MAX_CHAT): ChatEntry[] {
53
- return chatFromEntries(currentBranch(parseEntries(text)), max).map((line, index) => ({ id: index + 1, at: line.at ?? 0, role: line.role, text: line.text }));
54
- }
10
+ import { chatText, CLEARED_NOTE, isClearedMark, MAX_CHAT, textOf, type ChatEntry } from "./feed.ts";
55
11
 
56
12
  /** What a session log keeps of one entry: its parent, and its conversation lines if it is a message. */
57
13
  interface LogNode {
@@ -202,7 +202,39 @@ function planWithSteps(plan: string, covers: readonly number[], stepCount: numbe
202
202
  const lines = plan.split("\n");
203
203
  const kept = covers.map((step, index) => found.blocks[step - 1]!.replace(/^(\s*)\d+([.)])/, (_all, indent: string, mark: string) => `${indent}${index + 1}${mark}`));
204
204
  const spacer = found.to > found.from && !(lines[found.to - 1] ?? "").trim() ? [""] : [];
205
- return [...lines.slice(0, found.from), ...kept.flatMap((block) => block.split("\n")), ...spacer, ...lines.slice(found.to)].join("\n");
205
+ return [...lines.slice(0, found.from), ...kept.flatMap((block) => block.split("\n")), ...spacer, ...stepDetails(lines.slice(found.to), covers)].join("\n");
206
+ }
207
+
208
+ /** `### Step 3: …` (or `**Step 3 — …**`): the heading of one step's detail. */
209
+ const DETAIL_HEADING = /^(\s*(?:#{1,6}\s+)?(?:\*\*)?\s*step\s+#?)(\d+)(?!\d)/i;
210
+ const ANY_HEADING = /^\s*(?:(#{1,6})\s+\S.*|\*\*[^*]+\*\*.*)$/;
211
+
212
+ /** A heading's depth: `#` is 1, a bold line deeper than any `#`. */
213
+ const depth = (line: string): number => ANY_HEADING.exec(line)?.[1]?.length ?? 7;
214
+
215
+ /**
216
+ * The lines after a plan's steps list with only the detail of the steps in `covers`, renumbered as
217
+ * the list was: a step's detail runs from its `Step N` heading to the next heading as deep or deeper.
218
+ */
219
+ function stepDetails(lines: readonly string[], covers: readonly number[]): string[] {
220
+ const out: string[] = [];
221
+ let skipping: number | undefined;
222
+ for (const line of lines) {
223
+ const heading = ANY_HEADING.test(line) ? depth(line) : undefined;
224
+ if (skipping !== undefined && heading !== undefined && heading <= skipping) skipping = undefined;
225
+ const detail = heading !== undefined ? DETAIL_HEADING.exec(line) : null;
226
+ if (detail) {
227
+ const at = covers.indexOf(Number(detail[2]));
228
+ if (at < 0) {
229
+ skipping = heading;
230
+ continue;
231
+ }
232
+ out.push(line.replace(DETAIL_HEADING, (_all, lead: string) => `${lead}${at + 1}`));
233
+ continue;
234
+ }
235
+ if (skipping === undefined) out.push(line);
236
+ }
237
+ return out;
206
238
  }
207
239
 
208
240
  /** One part's brief: where it sits, what it delivers, then the plan as written with only its own steps. */
@@ -22,6 +22,8 @@ export interface TaskRow {
22
22
  check: CheckState;
23
23
  /** Plan steps done, once the task has a plan. */
24
24
  progress?: { done: number; total: number };
25
+ /** How long the task's agents have worked on it, idle time left out (ms, as of the reply). */
26
+ work?: { workedMs: number; running: boolean };
25
27
  /** Who drives it, for tasks other sessions own. */
26
28
  owner?: string;
27
29
  /** How long ago it finished (finished tasks), was saved (plans) or was archived, as `3h`. */
@@ -342,6 +342,8 @@ export interface ReviewerRequest {
342
342
  provenance?: string;
343
343
  /** What the previous QA round asked for, when there was one: this round verifies it. */
344
344
  previousRound?: string;
345
+ /** The engine's lint result on the touched files and the suppressions the task added. */
346
+ lint?: string;
345
347
  /** Under a task time budget: the gate's time. */
346
348
  time?: AgentTime;
347
349
  instruction?: string;
@@ -367,6 +369,7 @@ function reviewerContext(request: ReviewerRequest): string {
367
369
  request.workerSummary ? `Worker summary (each domain's newest entries):\n${truncate(request.workerSummary, 4800)}` : "No worker summary available.",
368
370
  owns.length > 0 ? `Scout findings:\n${summarizeOutcomes(owns, 1200)}` : "",
369
371
  request.provenance ? `Who changed each file (bot-lobby's record of every agent's edit and write calls; judge each as your role's Change provenance says):\n${request.provenance}` : "",
372
+ request.lint ?? "",
370
373
  request.time?.note ?? "",
371
374
  `Repository changes:\n${request.diff}`,
372
375
  ]
@@ -1,4 +1,3 @@
1
- import { existsSync, readFileSync } from "node:fs";
2
1
  import { join } from "node:path";
3
2
  import type { Domain } from "../schemas/agent.ts";
4
3
  import { profileFor, type BotLobbyConfig, type ProfileResolver } from "../schemas/configuration.ts";
@@ -39,21 +38,6 @@ export function saveResearchResult(taskDir: string, outcome: ResearchOutcome): v
39
38
  writeFileEnsured(researchResultPath(taskDir, outcome.result.domain), JSON.stringify(outcome, null, 2));
40
39
  }
41
40
 
42
- /** Re-read persisted research reports for the given domains. */
43
- export function loadResearchResults(taskDir: string, domains: Domain[]): ResearchOutcome[] {
44
- const outcomes: ResearchOutcome[] = [];
45
- for (const domain of domains) {
46
- const path = researchResultPath(taskDir, domain);
47
- if (!existsSync(path)) continue;
48
- try {
49
- outcomes.push(JSON.parse(readFileSync(path, "utf8")) as ResearchOutcome);
50
- } catch {
51
- // Corrupted research artifact: skip it rather than failing the workflow.
52
- }
53
- }
54
- return outcomes;
55
- }
56
-
57
41
  function researchContext(request: ResearchRequest): AgentRequest["context"] {
58
42
  return {
59
43
  task: request.instruction,
@@ -17,8 +17,9 @@ import { transition } from "../state/task-state.ts";
17
17
  import { AGENT_DIR_NAMES, KNOWLEDGE_FILES, knowledgeDir, type KnowledgeAgent } from "../knowledge/paths.ts";
18
18
  import { readFirstExisting } from "../knowledge/store.ts";
19
19
  import { overThreshold } from "../knowledge/compactor.ts";
20
- import { applyApprovalChoice, describeTask, describeOversizedKnowledge, lastQaAsks, waiveQa, type ApprovalChoice } from "../workflow/workflow.ts";
21
- import { applyStatus, registerRevealShortcut, setMinimized } from "./ui.ts";
20
+ import { applyApprovalChoice, describeTask, describeOversizedKnowledge, lastQaAsks, waiveLint, waiveQa, type ApprovalChoice } from "../workflow/workflow.ts";
21
+ import { applyStatus, registerRevealShortcut } from "./ui.ts";
22
+ import { isOn, registerSwitch, turnOff, turnOn } from "./switch.ts";
22
23
  import { classifierSummary } from "./model-settings.ts";
23
24
  import { keyStatus } from "../classifier/instance.ts";
24
25
  import { kickoff, startPlannedTask } from "./start-task.ts";
@@ -51,7 +52,7 @@ const HELP = [
51
52
  "/bot-lobby runs [taskId] Recent subagent runs: time, turns, tokens, cost, model",
52
53
  "/bot-lobby settings Open the web lobby's Settings: per-agent model, effort and instructions",
53
54
  "/bot-lobby config Show effective configuration",
54
- "/bot-lobby minimize|restore Hide or restore bot-lobby for this session (ctrl+shift+m)",
55
+ "/bot-lobby on|off Turn bot-lobby on or off for this session (ctrl+shift+m); a new session starts off",
55
56
  "/bot-lobby claim <taskId> Take ownership of an orphaned task",
56
57
  "/bot-lobby auto [on|off] Auto mode: the oracle drives this session's task without asking (alt+g)",
57
58
  "/bot-lobby start-plan PLAN-… [auto] Start a planned task here; its agreed plan needs no approval",
@@ -61,7 +62,10 @@ const HELP = [
61
62
  ].join("\n");
62
63
 
63
64
  /** Subcommands only win when no free-form text follows (so tasks still start). */
64
- const SUBCOMMANDS = new Set(["lobby", "help", "status", "runs", "tasks", "pause", "resume", "cancel", "approve", "amend", "decline", "accept", "budget", "knowledge", "config", "settings", "minimize", "restore", "claim", "auto", "start-plan", "switch", "web"]);
65
+ const SUBCOMMANDS = new Set(["lobby", "help", "status", "runs", "tasks", "pause", "resume", "cancel", "approve", "amend", "decline", "accept", "budget", "knowledge", "config", "settings", "on", "off", "minimize", "restore", "claim", "auto", "start-plan", "switch", "web"]);
66
+
67
+ /** Subcommands that act on a task or open the lobby turn bot-lobby on first; the rest work while it is off. */
68
+ const TURNS_ON = new Set(["lobby", "settings", "resume", "approve", "amend", "decline", "accept", "claim", "auto", "start-plan", "switch", "web"]);
65
69
 
66
70
  /** `Task-Change-Table-Font-27-09-2026`, or an older `TASK-add-login` id. */
67
71
  function isTaskId(value: string | undefined): boolean {
@@ -241,10 +245,12 @@ function acceptWork(pi: ExtensionAPI, ctx: ExtensionCommandContext, configDir: s
241
245
  return ctx.ui.notify(`bot-lobby: ${task.id} is still ${task.state}; there is no work to accept yet.`, "warning");
242
246
  }
243
247
  if (task.qaVerdict !== "pass" && !task.qaWaiver) waiveQa(task, lastQaAsks(task), "with /bot-lobby accept");
248
+ if (task.lint?.state === "failing") waiveLint(task, "with /bot-lobby accept");
244
249
  saveTask(root, configDir, task);
245
250
  applyStatus(ctx, root, configDir);
246
251
  ctx.ui.notify(`bot-lobby: ${task.id} accepted as it is; the oracle will complete it.`, "info");
247
- const message = `The user accepted ${task.id}'s work as it is (the QA gate is waived). Call orchestrate action=complete now with a short summary that names anything QA still asked for.`;
252
+ const waived = [task.qaWaiver ? "the QA gate" : "", task.lintWaiver ? "the lint gate" : ""].filter(Boolean);
253
+ const message = `The user accepted ${task.id}'s work as it is${waived.length ? ` (${waived.join(" and ")} ${waived.length === 1 ? "is" : "are"} waived)` : ""}. Call orchestrate action=complete now with a short summary that names anything QA or lint still asked for.`;
248
254
  pi.sendUserMessage(message, ctx.isIdle() ? undefined : { deliverAs: "followUp" });
249
255
  }
250
256
 
@@ -323,6 +329,7 @@ function autoCommand(ctx: ExtensionCommandContext, configDir: string, value: str
323
329
 
324
330
  export function registerCommands(pi: ExtensionAPI, configDir: string): void {
325
331
  registerRevealShortcut(pi, configDir);
332
+ registerSwitch(pi, configDir);
326
333
  registerWebServer(pi);
327
334
  pi.registerCommand("bot-lobby", {
328
335
  description: "Structured multi-agent engineering orchestrator",
@@ -334,6 +341,8 @@ export function registerCommands(pi: ExtensionAPI, configDir: string): void {
334
341
  handler: async (args, ctx) => {
335
342
  const { sub, rest, restText, auto, budget, budgetError, track, task, isolation } = parseCommand(args ?? "");
336
343
  if (budgetError) return ctx.ui.notify(`bot-lobby: ${budgetError}`, "warning");
344
+ // A request, the bare command or anything that drives a task needs bot-lobby on (`web stop` does not).
345
+ if (!sub || (TURNS_ON.has(sub) && !(sub === "web" && rest[0] === "stop"))) turnOn(pi, ctx, configDir);
337
346
  if (!sub) {
338
347
  if (!restText) return webCommand(ctx, undefined);
339
348
  // A request one agent can do alone may go to the quick-fix agent once the oracle confirms; the rest start as tasks.
@@ -371,12 +380,15 @@ export function registerCommands(pi: ExtensionAPI, configDir: string): void {
371
380
  return showKnowledge(ctx, configDir);
372
381
  case "settings":
373
382
  return webCommand(ctx, undefined);
383
+ case "off":
374
384
  case "minimize":
375
- setMinimized(true);
376
- return applyStatus(ctx, detectProjectRoot(ctx.cwd, configDir), configDir);
385
+ if (!isOn()) return ctx.ui.notify("bot-lobby is already off.", "info");
386
+ await turnOff(ctx, configDir);
387
+ return;
388
+ case "on":
377
389
  case "restore":
378
- setMinimized(false);
379
- return applyStatus(ctx, detectProjectRoot(ctx.cwd, configDir), configDir);
390
+ if (isOn()) return ctx.ui.notify("bot-lobby is already on.", "info");
391
+ return turnOn(pi, ctx, configDir);
380
392
  case "claim":
381
393
  return claimTaskCommand(ctx, configDir, rest[0]);
382
394
  case "auto":
@@ -398,6 +410,9 @@ export function registerCommands(pi: ExtensionAPI, configDir: string): void {
398
410
 
399
411
  pi.registerCommand("bot-lobby-settings", {
400
412
  description: "Open the web lobby's settings: per-agent model, effort, and instructions",
401
- handler: async (_args, ctx) => webCommand(ctx, undefined),
413
+ handler: async (_args, ctx) => {
414
+ turnOn(pi, ctx, configDir);
415
+ return webCommand(ctx, undefined);
416
+ },
402
417
  });
403
418
  }