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

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 (48) hide show
  1. package/README.md +8 -2
  2. package/package.json +1 -1
  3. package/prompts/designer.md +14 -0
  4. package/prompts/master.md +18 -0
  5. package/prompts/panel.md +21 -1
  6. package/prompts/planner.md +17 -1
  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/delivery/review.ts +1 -1
  13. package/src/execution/git.ts +77 -0
  14. package/src/execution/lint.ts +435 -0
  15. package/src/lobby/ask.ts +4 -2
  16. package/src/lobby/feed.ts +39 -4
  17. package/src/lobby/host.ts +2 -0
  18. package/src/lobby/planner.ts +54 -5
  19. package/src/lobby/pr-review.ts +2 -1
  20. package/src/lobby/quickfix.ts +34 -1
  21. package/src/lobby/runtime.ts +19 -2
  22. package/src/lobby/service.ts +2 -0
  23. package/src/master/master.ts +3 -0
  24. package/src/pi/commands.ts +25 -10
  25. package/src/pi/events.ts +3 -1
  26. package/src/pi/switch.ts +69 -0
  27. package/src/pi/tools.ts +7 -0
  28. package/src/pi/ui.ts +2 -14
  29. package/src/schemas/configuration.ts +51 -1
  30. package/src/schemas/lint.ts +55 -0
  31. package/src/schemas/task.ts +5 -0
  32. package/src/state/changes.ts +8 -2
  33. package/src/webui/api/index.ts +2 -1
  34. package/src/webui/api/knowledge.ts +11 -3
  35. package/src/webui/api/settings.ts +6 -1
  36. package/src/webui/command.ts +14 -3
  37. package/src/webui/dev/fake-service.ts +1 -0
  38. package/src/webui/dev/fixtures.ts +2 -1
  39. package/src/webui/protocol.ts +10 -1
  40. package/src/workflow/lint.ts +221 -0
  41. package/src/workflow/track.ts +12 -4
  42. package/src/workflow/workflow.ts +70 -13
  43. package/webui/dist/assets/index-BRVbs5In.js +105 -0
  44. package/webui/dist/assets/index-BeRXVi_r.css +2 -0
  45. package/webui/dist/build.json +40 -32
  46. package/webui/dist/index.html +2 -2
  47. package/webui/dist/assets/index-BSewK1QO.css +0 -2
  48. package/webui/dist/assets/index-CHQYSZXA.js +0 -105
@@ -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}`,
@@ -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";
@@ -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", () => {
@@ -47,6 +47,7 @@ import type { LobbyAgentKind, PanelMember } from "../schemas/configuration.ts";
47
47
  import type { LiveSession, LobbyService, SwitchTarget } from "./host.ts";
48
48
  import { pushNotice, type NoticeLevel } from "../webui/notices.ts";
49
49
  import type { Runtime } from "./runtime.ts";
50
+ import { findLinters } from "../execution/lint.ts";
50
51
 
51
52
  function deliveryTask(state: Runtime, taskId: string): Task {
52
53
  if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{0,199}$/.test(taskId) || taskId === "." || taskId === "..") throw new Error("Invalid task ID.");
@@ -562,6 +563,7 @@ export function createLobbyService(state: Runtime): LobbyService {
562
563
  let appliedMaster = masterKey();
563
564
  return {
564
565
  projectRoot: () => state.root,
566
+ linters: () => findLinters(state.root),
565
567
  sessionId: () => state.ctx.sessionManager.getSessionId(),
566
568
  zen: () => {
567
569
  const snapshot = taskSnapshot();
@@ -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
  ]
@@ -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
  }
package/src/pi/events.ts CHANGED
@@ -8,6 +8,7 @@ import { cancelAllRuns } from "../execution/agent-runner.ts";
8
8
  import { describeTask } from "../workflow/workflow.ts";
9
9
  import { truncate } from "../text.ts";
10
10
  import { applyStatus, clearStatus, isMinimized, setMinimized } from "./ui.ts";
11
+ import { startsOn } from "./switch.ts";
11
12
  import { isSubagentProcess, webToolsFor } from "./quiet.ts";
12
13
  import { registerQuietTools } from "./tool-renderers.ts";
13
14
  import { taskRequest, type Task, type TaskState } from "../schemas/task.ts";
@@ -70,7 +71,8 @@ export function registerLifecycle(pi: ExtensionAPI, configDir: string): void {
70
71
  // Master-only: subagents keep their own --tools allowlist (see quiet.ts).
71
72
  if (!isSubagentProcess()) registerQuietTools(pi);
72
73
  const root = detectProjectRoot(ctx.cwd, configDir);
73
- setMinimized(false);
74
+ // A new session starts with bot-lobby off unless settings or its own task say otherwise (switch.ts).
75
+ setMinimized(!isSubagentProcess() && !startsOn(ctx, configDir));
74
76
  applyStatus(ctx, root, configDir);
75
77
  });
76
78
 
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Bot-lobby on or off for this pi session. A session starts off, so pi is
3
+ * plain pi: no oracle prompt, no web lobby, no browser opening. It starts on
4
+ * only when `lobby.startOn` says so, or when the session already drives a task
5
+ * that is still under way (a resumed session picks its task back up).
6
+ * Ctrl+Shift+M and `/bot-lobby on|off` switch it; starting a task, opening the
7
+ * web lobby or answering a task's proposal turns it on. Off stops the web
8
+ * lobby too, and anything it runs (a quick fix, a review, a planning round)
9
+ * stops with it, so the user is asked first when something is running.
10
+ */
11
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
12
+ import { Key } from "@earendil-works/pi-tui";
13
+ import { currentLobbyService, lobbyWorkRunning, servesPage, startLobbyService, stopLobbyService } from "../lobby/runtime.ts";
14
+ import { activeTask } from "../state/persistence.ts";
15
+ import { detectProjectRoot, loadConfig } from "../state/project.ts";
16
+ import { stopWebServer, webSessionStarted } from "../webui/command.ts";
17
+ import { isSubagentProcess } from "./quiet.ts";
18
+ import { applyStatus, isMinimized, setMinimized } from "./ui.ts";
19
+
20
+ export const SWITCH_KEY = "ctrl+shift+m";
21
+
22
+ export function isOn(): boolean {
23
+ return !isMinimized();
24
+ }
25
+
26
+ /** Whether a new session starts on: when settings say so, or it owns a task still under way. */
27
+ export function startsOn(ctx: ExtensionContext, configDir: string): boolean {
28
+ if (loadConfig().lobby.startOn) return true;
29
+ const root = detectProjectRoot(ctx.cwd, configDir);
30
+ return Boolean(activeTask(root, configDir, ctx.sessionManager.getSessionId()));
31
+ }
32
+
33
+ /** Turn bot-lobby on: the oracle's prompt comes back and the web lobby starts (opening the browser if settings say so). */
34
+ export function turnOn(pi: ExtensionAPI, ctx: ExtensionContext, configDir: string, quiet = false): void {
35
+ const was = isOn();
36
+ setMinimized(false);
37
+ if (!isSubagentProcess() && servesPage(ctx) && !currentLobbyService()) {
38
+ startLobbyService(pi, ctx, configDir);
39
+ webSessionStarted(ctx);
40
+ }
41
+ applyStatus(ctx, detectProjectRoot(ctx.cwd, configDir), configDir);
42
+ if (!was && !quiet) ctx.ui.notify(`bot-lobby on — ${SWITCH_KEY} or /bot-lobby off turns it off`, "info");
43
+ }
44
+
45
+ /** Turn bot-lobby off: pi is plain pi until it is turned on again. Returns false when the user kept it on. */
46
+ export async function turnOff(ctx: ExtensionContext, configDir: string): Promise<boolean> {
47
+ const running = lobbyWorkRunning();
48
+ if (running && ctx.hasUI && !(await ctx.ui.confirm("Turn bot-lobby off?", `${running} is running in the lobby and stops with it.`))) return false;
49
+ setMinimized(true);
50
+ stopLobbyService();
51
+ await stopWebServer(ctx);
52
+ applyStatus(ctx, detectProjectRoot(ctx.cwd, configDir), configDir);
53
+ ctx.ui.notify(`bot-lobby off — pi is plain pi; ${SWITCH_KEY} or /bot-lobby on turns it on`, "info");
54
+ return true;
55
+ }
56
+
57
+ export async function toggleBotLobby(pi: ExtensionAPI, ctx: ExtensionContext, configDir: string): Promise<void> {
58
+ if (isOn()) await turnOff(ctx, configDir);
59
+ else turnOn(pi, ctx, configDir);
60
+ }
61
+
62
+ /** Ctrl+Shift+M switches bot-lobby on and off for this session. */
63
+ export function registerSwitch(pi: ExtensionAPI, configDir: string): void {
64
+ if (isSubagentProcess()) return;
65
+ pi.registerShortcut(Key.ctrlShift("m"), {
66
+ description: "bot-lobby: turn bot-lobby on or off for this session",
67
+ handler: (ctx) => toggleBotLobby(pi, ctx, configDir),
68
+ });
69
+ }
package/src/pi/tools.ts CHANGED
@@ -15,6 +15,8 @@ import { unescapeBreaks } from "../lobby/blocks.ts";
15
15
  import { isQuiet } from "./quiet.ts";
16
16
  import { checkThinking, createProfileResolver, modelRef, type ModelLookup } from "./model-support.ts";
17
17
  import { agentName, describeRun } from "./run-summary.ts";
18
+ import { lintFeed } from "../workflow/lint.ts";
19
+ import { lobbyFeed } from "../lobby/feed.ts";
18
20
  import {
19
21
  ORCHESTRATE_ACTIONS,
20
22
  runWorkflowAction,
@@ -133,6 +135,11 @@ export function workflowDeps(
133
135
  notify: (message, level = "info") => {
134
136
  if (hasUI) ctx.ui.notify(message, level);
135
137
  },
138
+ // Each new lint result on a task's touched files is a line in the lobby's activity log.
139
+ onLint: (taskId, report) => {
140
+ const line = lintFeed(taskId, report);
141
+ lobbyFeed.log("LINT", line.text, line.kind);
142
+ },
136
143
  };
137
144
  }
138
145
 
package/src/pi/ui.ts CHANGED
@@ -1,5 +1,4 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import { Key } from "@earendil-works/pi-tui";
3
2
  import type { AgentRun } from "../schemas/findings.ts";
4
3
  import type { Task } from "../schemas/task.ts";
5
4
  import { activeTask } from "../state/persistence.ts";
@@ -17,7 +16,7 @@ export function summarizeRun(run: AgentRun): string {
17
16
  /** One-line footer text, always carrying the quiet-mode hint. */
18
17
  export function statusText(task: Task | undefined, minimized = false): string {
19
18
  const mode = isQuiet() ? "tools hidden (alt+t)" : "tools shown";
20
- if (minimized) return `bot-lobby minimized (ctrl+shift+m) · ${mode}`;
19
+ if (minimized) return `bot-lobby off (ctrl+shift+m) · ${mode}`;
21
20
  if (!task) return `bot-lobby · ${mode}`;
22
21
  return `bot-lobby ${task.id} · ${task.paused ? `${task.state} (paused)` : task.state} · ${mode}`;
23
22
  }
@@ -73,7 +72,7 @@ function setZenState(task: Task | undefined, live: AgentRun[]): void {
73
72
  zenState = { task, live, runs: mergeRuns(persistedRuns(task), live) };
74
73
  }
75
74
 
76
- /** Per-session standard-pi mode: the Master prompt is hidden but ownership stays. */
75
+ /** Bot-lobby off for this session (plain pi): the Master prompt is hidden but ownership stays (see switch.ts). */
77
76
  let minimized = false;
78
77
 
79
78
  export function isMinimized(): boolean {
@@ -84,13 +83,6 @@ export function setMinimized(value: boolean): void {
84
83
  minimized = value;
85
84
  }
86
85
 
87
- /** Flip minimize/restore and refresh the footer; the session is unchanged. */
88
- export function toggleMinimized(ctx: ExtensionContext, configDir: string): void {
89
- setMinimized(!minimized);
90
- applyStatus(ctx, detectProjectRoot(ctx.cwd, configDir), configDir, zenState.live);
91
- ctx.ui.notify(minimized ? "bot-lobby minimized — ctrl+shift+m or /bot-lobby restore to return" : "bot-lobby restored", "info");
92
- }
93
-
94
86
  /**
95
87
  * Refresh the footer to match the task on disk. Runs are merged into the
96
88
  * retained set for the same task because each `orchestrate` call reports only its
@@ -144,10 +136,6 @@ export function registerRevealShortcut(pi: ExtensionAPI, configDir: string): voi
144
136
  description: "bot-lobby: reveal or hide built-in tool rows",
145
137
  handler: (ctx) => revealTools(ctx, configDir),
146
138
  });
147
- pi.registerShortcut(Key.ctrlShift("m"), {
148
- description: "bot-lobby: minimize or restore bot-lobby for this session",
149
- handler: (ctx) => toggleMinimized(ctx, configDir),
150
- });
151
139
  }
152
140
 
153
141
  function revealTools(ctx: ExtensionContext, configDir: string): void {
@@ -92,6 +92,29 @@ export interface WorkflowConfig {
92
92
  gitIsolation: GitIsolation;
93
93
  }
94
94
 
95
+ /**
96
+ * The lint gate: off; advise (the oracle and QA see what the project's own
97
+ * linter finds in the files the task's agents touched); or block (errors on
98
+ * the lines the task changed also hold completion until they are fixed or
99
+ * the user accepts the work as it is).
100
+ */
101
+ export const LINT_MODES = ["off", "advise", "block"] as const;
102
+ export type LintMode = (typeof LINT_MODES)[number];
103
+
104
+ export function isLintMode(value: unknown): value is LintMode {
105
+ return typeof value === "string" && (LINT_MODES as readonly string[]).includes(value);
106
+ }
107
+
108
+ export interface LintConfig {
109
+ mode: LintMode;
110
+ /** A command of your own, run without a shell from the repository's top; `{files}` stands for the touched files (they are appended when it is absent). Empty: the project's own linters are found (ESLint, Biome, Oxlint, Ruff). */
111
+ command: string;
112
+ /** Which touched files the command is given, by extension (`.ts`, `.py`); empty gives it every one. */
113
+ extensions: string[];
114
+ /** Time limit for one linter run. */
115
+ timeoutMs: number;
116
+ }
117
+
95
118
  export interface KnowledgeConfig {
96
119
  compactionThreshold: number;
97
120
  backupCount: number;
@@ -128,6 +151,8 @@ export interface LobbyConfig {
128
151
  planningPanel: PanelMember[];
129
152
  /** Show the GitHub Issues tab (off for now). */
130
153
  issues: boolean;
154
+ /** Bot-lobby is on when pi starts; off (the default), pi starts as plain pi until ctrl+shift+m or /bot-lobby on. */
155
+ startOn: boolean;
131
156
  /** Which panes the Lobby tab shows. */
132
157
  panels: Record<LobbyPanel, boolean>;
133
158
  /** Key overrides by action name, e.g. `{ "search": "alt+f" }`. */
@@ -208,6 +233,8 @@ export interface BotLobbyConfig {
208
233
  /** The task planner that grills the user until a plan is clear; `timeoutMs` bounds one turn. */
209
234
  planner: AgentModelConfig;
210
235
  workflow: WorkflowConfig;
236
+ /** The engine lints the files a task's agents touched, after each step, before QA and at completion. */
237
+ lint: LintConfig;
211
238
  knowledge: KnowledgeConfig;
212
239
  lobby: LobbyConfig;
213
240
  classifier: ClassifierConfig;
@@ -243,6 +270,7 @@ export const DEFAULT_CONFIG: BotLobbyConfig = {
243
270
  routeQuickFixes: true,
244
271
  gitIsolation: "off",
245
272
  },
273
+ lint: { mode: "advise", command: "", extensions: [], timeoutMs: 2 * 60 * 1000 },
246
274
  knowledge: {
247
275
  compactionThreshold: 20000,
248
276
  backupCount: 1,
@@ -252,6 +280,7 @@ export const DEFAULT_CONFIG: BotLobbyConfig = {
252
280
  lobby: {
253
281
  planningPanel: [...PANEL_MEMBERS],
254
282
  issues: false,
283
+ startOn: false,
255
284
  panels: { conversation: true, activity: true, thinking: true },
256
285
  keys: {},
257
286
  maxPlanningRounds: 5,
@@ -315,7 +344,7 @@ function flag(value: unknown, fallback: boolean): boolean {
315
344
  }
316
345
 
317
346
  function normalizeLobby(value: unknown): LobbyConfig {
318
- const source = value as { planningPanel?: unknown; issues?: unknown; panels?: unknown; keys?: unknown; maxPlanningRounds?: unknown; splitPlanAbove?: unknown; web?: unknown } | undefined;
347
+ const source = value as { planningPanel?: unknown; issues?: unknown; startOn?: unknown; panels?: unknown; keys?: unknown; maxPlanningRounds?: unknown; splitPlanAbove?: unknown; web?: unknown } | undefined;
319
348
  const defaults = DEFAULT_CONFIG.lobby;
320
349
  const panel = Array.isArray(source?.planningPanel)
321
350
  ? [...new Set(source.planningPanel.filter((entry): entry is PanelMember => typeof entry === "string" && isPanelMember(entry)))]
@@ -325,6 +354,7 @@ function normalizeLobby(value: unknown): LobbyConfig {
325
354
  return {
326
355
  planningPanel: PANEL_MEMBERS.filter((member) => panel.includes(member)),
327
356
  issues: flag(source?.issues, defaults.issues),
357
+ startOn: flag(source?.startOn, defaults.startOn),
328
358
  panels: Object.fromEntries(LOBBY_PANELS.map((name) => [name, flag(panels[name], defaults.panels[name])])) as Record<LobbyPanel, boolean>,
329
359
  keys: Object.fromEntries(Object.entries(keys).filter((entry): entry is [string, string] => typeof entry[1] === "string" && entry[1].trim().length > 0)),
330
360
  maxPlanningRounds: roundLimit(source?.maxPlanningRounds, defaults.maxPlanningRounds),
@@ -362,6 +392,25 @@ function text(value: unknown, fallback: string): string {
362
392
  return typeof value === "string" ? value.trim() : fallback;
363
393
  }
364
394
 
395
+ /** `.ts`, `TSX` and `*.py` all read as `.ts`, `.tsx`, `.py`. */
396
+ function extension(value: string): string {
397
+ const bare = value.trim().replace(/^\*/, "").replace(/^\./, "").toLowerCase();
398
+ return bare ? `.${bare}` : "";
399
+ }
400
+
401
+ function normalizeLint(value: unknown): LintConfig {
402
+ const source = (value && typeof value === "object" ? value : {}) as Record<string, unknown>;
403
+ const defaults = DEFAULT_CONFIG.lint;
404
+ const extensions = Array.isArray(source.extensions) ? source.extensions.filter((entry): entry is string => typeof entry === "string").map(extension).filter(Boolean) : [...defaults.extensions];
405
+ const timeout = positive(source.timeoutMs);
406
+ return {
407
+ mode: isLintMode(source.mode) ? source.mode : defaults.mode,
408
+ command: text(source.command, defaults.command),
409
+ extensions: [...new Set(extensions)],
410
+ timeoutMs: timeout ? Math.min(30 * 60 * 1000, Math.round(timeout)) : defaults.timeoutMs,
411
+ };
412
+ }
413
+
365
414
  function normalizeClassifier(value: unknown): ClassifierConfig {
366
415
  const source = (value && typeof value === "object" ? value : {}) as Record<string, unknown>;
367
416
  const defaults = DEFAULT_CONFIG.classifier;
@@ -412,6 +461,7 @@ export function resolveConfig(partial: unknown): BotLobbyConfig {
412
461
  quickFix: normalizeAgent(DEFAULT_CONFIG.quickFix, src.quickFix as Partial<AgentModelConfig> | undefined),
413
462
  planner: normalizeAgent(DEFAULT_CONFIG.planner, src.planner as Partial<AgentModelConfig> | undefined),
414
463
  workflow,
464
+ lint: normalizeLint(src.lint),
415
465
  knowledge,
416
466
  lobby: normalizeLobby(src.lobby),
417
467
  classifier: normalizeClassifier(src.classifier),
@@ -0,0 +1,55 @@
1
+ /**
2
+ * What the lint gate found: per linter run, and for the task as a whole. The
3
+ * engine records the last report on the task, so completion, QA and the
4
+ * lobby read the same result, and an unchanged tree is not linted twice.
5
+ */
6
+
7
+ export type LintState = "passing" | "failing" | "unavailable" | "skipped";
8
+
9
+ export interface LintFinding {
10
+ /** Repository-relative, with forward slashes. */
11
+ file: string;
12
+ line?: number;
13
+ column?: number;
14
+ rule?: string;
15
+ message: string;
16
+ severity: "error" | "warning";
17
+ /** On a line the task changed (or in a file it created); false for what was already there. */
18
+ fresh?: boolean;
19
+ }
20
+
21
+ export interface LintRun {
22
+ /** ESLint, Biome, Oxlint, Ruff, or the command from Settings. */
23
+ tool: string;
24
+ /** The folder it ran in, repository-relative ("" at the top). */
25
+ folder: string;
26
+ files: string[];
27
+ state: "passing" | "failing" | "unavailable";
28
+ errors: number;
29
+ warnings: number;
30
+ /** At most `MAX_FINDINGS` of them, those on changed lines first; the counts stay whole. */
31
+ findings: LintFinding[];
32
+ /** The tail of what it printed when that could not be read as findings, or why it could not run. */
33
+ output?: string;
34
+ ms: number;
35
+ }
36
+
37
+ export interface LintReport {
38
+ at: string;
39
+ /** failing: errors on lines the task changed; passing: none (what was already there never fails it). */
40
+ state: LintState;
41
+ /** The touched files it covered, repository-relative. */
42
+ files: string[];
43
+ runs: LintRun[];
44
+ errors: number;
45
+ warnings: number;
46
+ /** Of those, on lines the task changed or in files it created. */
47
+ freshErrors: number;
48
+ freshWarnings: number;
49
+ /** Lint suppressions and lint-config changes the task added, one line each, for QA to judge. */
50
+ suppressions: string[];
51
+ /** Why nothing ran, when nothing did. */
52
+ note?: string;
53
+ /** The touched files as they stood and the settings: an equal key means an equal result. */
54
+ fingerprint: string;
55
+ }
@@ -1,3 +1,4 @@
1
+ import type { LintReport } from "./lint.ts";
1
2
  import type { Domain, Role } from "./agent.ts";
2
3
  import type { Delivery } from "../delivery/types.ts";
3
4
 
@@ -224,6 +225,10 @@ export interface Task {
224
225
  qaWaiver?: QaWaiver;
225
226
  /** The git branch (or worktree) made for this task, when isolation was on as it started. */
226
227
  git?: TaskGit;
228
+ /** The lint gate's last report on the files this task's agents touched. */
229
+ lint?: LintReport;
230
+ /** Set only by the user: the work is accepted with the lint errors it has, so the task may complete. */
231
+ lintWaiver?: { at: string };
227
232
  }
228
233
 
229
234
  export function createTask(
@@ -142,6 +142,8 @@ export interface FileProvenance {
142
142
  notes: string[];
143
143
  /** The quick fixes that edited it. */
144
144
  quickFixes: string[];
145
+ /** This task's domains whose workers edited it. */
146
+ domains?: Domain[];
145
147
  }
146
148
 
147
149
  const DOMAIN_LABELS: Record<string, string> = { backend: "DEV", designer: "DESIGN", qa: "QA" };
@@ -180,6 +182,7 @@ export function explainChanges(task: Pick<Task, "id" | "baseline">, changed: rea
180
182
  const kinds: Provenance[] = [];
181
183
  const notes: string[] = [];
182
184
  const quickFixes: string[] = [];
185
+ const domains: Domain[] = [];
183
186
  const add = (kind: Provenance, note: string) => {
184
187
  if (!kinds.includes(kind)) kinds.push(kind);
185
188
  if (!notes.includes(note)) notes.push(note);
@@ -188,12 +191,15 @@ export function explainChanges(task: Pick<Task, "id" | "baseline">, changed: rea
188
191
  if (record.source === "quickfix") {
189
192
  add("quickfix", `quick fix ${record.id}${clock(record.finishedAt)}, asked by the user: "${clip(record.what)}"`);
190
193
  if (!quickFixes.includes(record.id)) quickFixes.push(record.id);
191
- } else if (record.taskId === task.id) add("planned", `planned: ${DOMAIN_LABELS[record.domain ?? ""] ?? record.domain ?? "worker"} — ${clip(record.what)}`);
194
+ } else if (record.taskId === task.id) {
195
+ add("planned", `planned: ${DOMAIN_LABELS[record.domain ?? ""] ?? record.domain ?? "worker"} — ${clip(record.what)}`);
196
+ if (record.domain && !domains.includes(record.domain)) domains.push(record.domain);
197
+ }
192
198
  else add("other-task", `another task's worker (${record.taskId ?? "unknown task"}): ${clip(record.what)}`);
193
199
  }
194
200
  if (before.has(path)) add("pre-existing", "pre-existing: it already had uncommitted changes before this task's agents started");
195
201
  if (kinds.length === 0) add("unattributed", "unattributed: no bot-lobby agent recorded editing it");
196
- return { path, kinds, notes, quickFixes };
202
+ return { path, kinds, notes, quickFixes, ...(domains.length ? { domains } : {}) };
197
203
  });
198
204
  }
199
205