@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
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";
@@ -16,7 +17,7 @@ import { isAutoMode } from "../state/auto.ts";
16
17
  import { triageContext } from "../classifier/triage.ts";
17
18
  import { qaStillDue } from "../workflow/track.ts";
18
19
  import { previousTaskNote } from "./fresh-context.ts";
19
- import { budgetLine, budgetState, pauseClocks, readBudget, resumeClocks, startClock, stopClocks } from "../state/budget.ts";
20
+ import { budgetLine, budgetState, clockTask, pauseClocks, readBudget, resumeClocks, startClock, stopClocks, turnStarted } from "../state/budget.ts";
20
21
 
21
22
  /** Tools that wait on the user: the task's clock waits with them. */
22
23
  const ASKING_TOOLS: ReadonlySet<string> = new Set(["ask_user_question"]);
@@ -70,15 +71,17 @@ 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
 
77
79
 
78
- // A task's time budget counts while the oracle works on it, not while it waits on the user.
80
+ // A task's work time (and its time budget) counts while the oracle works on it, not while it waits on the user.
79
81
  const asking = new Set<string>();
80
82
  pi.on("agent_start", (_event, ctx) => {
81
83
  if (isSubagentProcess()) return;
84
+ turnStarted();
82
85
  const root = detectProjectRoot(ctx.cwd, configDir);
83
86
  const task = activeTask(root, configDir, ctx.sessionManager.getSessionId());
84
87
  if (task) startClock(root, configDir, task.id);
@@ -90,9 +93,14 @@ export function registerLifecycle(pi: ExtensionAPI, configDir: string): void {
90
93
  pauseClocks();
91
94
  }
92
95
  });
93
- pi.on("tool_execution_end", (event) => {
96
+ pi.on("tool_execution_end", (event, ctx) => {
94
97
  if (isSubagentProcess()) return;
95
98
  if (asking.delete(event.toolCallId)) resumeClocks();
99
+ // A task started, finished or taken over within the turn: the clock follows the task the session drives now.
100
+ if (event.toolName === "orchestrate") {
101
+ const root = detectProjectRoot(ctx.cwd, configDir);
102
+ clockTask(root, configDir, activeTask(root, configDir, ctx.sessionManager.getSessionId())?.id);
103
+ }
96
104
  });
97
105
  pi.on("agent_end", () => {
98
106
  if (isSubagentProcess()) return;
@@ -18,7 +18,6 @@ import { describeKey, jevEndpoint, type StatusSource } from "../classifier/hosts
18
18
 
19
19
  /** One settings entry: the master or a subagent profile. */
20
20
  export type SettingsKind = "master" | SubagentKind;
21
- export const SETTINGS_KINDS: readonly SettingsKind[] = ["master", ...SUBAGENT_KINDS];
22
21
 
23
22
  /** The editable fields of one entry; scouts have no thinking and no instructions of their own. */
24
23
  export interface EntryView {
@@ -89,6 +89,19 @@ function stepSection(lines: readonly string[]): string[] | undefined {
89
89
  return body;
90
90
  }
91
91
 
92
+ /**
93
+ * The concise list a plan opens its steps with: a `Steps` section that comes
94
+ * before any `Step N` heading. The plan's detail then follows, often one
95
+ * `### Step N: …` section per step, and the list stays the checklist.
96
+ */
97
+ function leadingStepList(lines: readonly string[]): string[] {
98
+ const section = lines.findIndex((line) => HEADER_LINE.test(line) && STEP_SECTION.test(line));
99
+ if (section < 0) return [];
100
+ const heading = lines.findIndex((line) => STEP_HEADING.test(line));
101
+ if (heading >= 0 && heading < section) return [];
102
+ return collectSteps(stepSection(lines) ?? [], sectionItem);
103
+ }
104
+
92
105
  /** `Step N` headings, when the plan is structured as one heading per step. */
93
106
  function headingSteps(lines: readonly string[]): string[] {
94
107
  const found: string[] = [];
@@ -100,13 +113,17 @@ function headingSteps(lines: readonly string[]): string[] {
100
113
  }
101
114
 
102
115
  /**
103
- * Step texts from a free-form plan, capped. `Step N` headings win; then a
104
- * `sequence|steps|order` section; then numbered lines; when none exists,
105
- * top-level bullets are the last resort, so unrelated bullet lists under other
106
- * headers never leak into the checklist. Only the shallowest items count.
116
+ * Step texts from a free-form plan, capped. A steps list the plan opens with
117
+ * wins (its detail may follow under `Step N` headings); then `Step N`
118
+ * headings; then a `sequence|steps|order` section; then numbered lines; when
119
+ * none exists, top-level bullets are the last resort, so unrelated bullet
120
+ * lists under other headers never leak into the checklist. Only the
121
+ * shallowest items count.
107
122
  */
108
123
  export function planSteps(plan: string): string[] {
109
124
  const lines = plan.split("\n");
125
+ const leading = leadingStepList(lines);
126
+ if (leading.length > 0) return leading.slice(0, MAX_PLAN_STEPS);
110
127
  const headings = headingSteps(lines);
111
128
  if (headings.length > 0) return headings.slice(0, MAX_PLAN_STEPS);
112
129
  const section = stepSection(lines);
@@ -119,6 +136,26 @@ export function planSteps(plan: string): string[] {
119
136
  return collectSteps(lines, topBulletItem).slice(0, MAX_PLAN_STEPS);
120
137
  }
121
138
 
139
+ /**
140
+ * The plan without the steps list it opens with, for reading after the
141
+ * checklist (which already shows those steps); undefined when the checklist
142
+ * does not come from such a list, so the plan is read whole.
143
+ */
144
+ export function planDetails(plan: string): string | undefined {
145
+ const lines = plan.split("\n");
146
+ if (leadingStepList(lines).length === 0) return undefined;
147
+ const heading = lines.findIndex((line) => HEADER_LINE.test(line) && STEP_SECTION.test(line));
148
+ let to = lines.length;
149
+ for (let index = heading + 1; index < lines.length; index += 1) {
150
+ if (HEADER_LINE.test(lines[index]!)) {
151
+ to = index;
152
+ break;
153
+ }
154
+ }
155
+ const rest = [...lines.slice(0, heading), ...lines.slice(to)].join("\n").trim();
156
+ return rest || undefined;
157
+ }
158
+
122
159
  /** Where a plan's steps section sits, and each of its top-level steps whole (its nested lines included). */
123
160
  export interface StepBlocks {
124
161
  /** The section's body: from this line up to (not including) `to`. */
@@ -239,15 +276,23 @@ function stepScore(step: string, instruction: InstructionIndex): number {
239
276
  * named, so "building on step 1, now do step 3" does not jump back to step 1.
240
277
  */
241
278
  export function explicitStepIndex(instruction: string, count: number): number {
279
+ return explicitStepRange(instruction, count)?.[1] ?? -1;
280
+ }
281
+
282
+ /** The 0-based steps an instruction labels itself with, first and last ("Steps 2-4: ..." is 1 to 3); undefined when none. */
283
+ export function explicitStepRange(instruction: string, count: number): [number, number] | undefined {
242
284
  const refs = [...instruction.matchAll(STEP_REFERENCE)];
243
- if (refs.length === 0) return -1;
285
+ if (refs.length === 0) return undefined;
244
286
  const first = refs[0]!;
245
287
  const leading = LEADING_LABEL.test(instruction.slice(0, first.index ?? 0)) ? first : undefined;
246
288
  const distinct = new Set(refs.map((ref) => ref[0].toLowerCase().replace(/\s+/g, "")));
247
289
  const chosen = leading ?? (distinct.size === 1 ? refs[0] : undefined);
248
- if (!chosen) return -1;
249
- const last = Math.max(Number(chosen[1]), Number(chosen[2] ?? chosen[1]));
250
- return last >= 1 && last <= count ? last - 1 : -1;
290
+ if (!chosen) return undefined;
291
+ const a = Number(chosen[1]);
292
+ const b = Number(chosen[2] ?? chosen[1]);
293
+ const last = Math.max(a, b);
294
+ if (last < 1 || last > count) return undefined;
295
+ return [Math.max(1, Math.min(a, b)) - 1, last - 1];
251
296
  }
252
297
 
253
298
  /**
@@ -345,3 +390,48 @@ export function planChecklist(plan: string, runs: readonly AgentRun[]): PlanStep
345
390
  checklistMemo = { plan, runs, steps };
346
391
  return steps;
347
392
  }
393
+
394
+ /* -------------------------------------------------------------------------
395
+ * Step work: how long the workers have worked on each step, and which steps
396
+ * they are on right now, for the checklist's `active · 1m 12s` marks.
397
+ * ---------------------------------------------------------------------- */
398
+
399
+ export interface StepWork {
400
+ /** Worker time on the step so far, in ms (the running runs up to `now`). */
401
+ ms: number;
402
+ /** A worker is on it right now. */
403
+ active: boolean;
404
+ }
405
+
406
+ /** The steps a run works on: every step of a range it names, else the step it targets, else the next one still open. */
407
+ function runSteps(steps: readonly string[], instruction: string | undefined, completed: ReadonlySet<number>): number[] {
408
+ const range = instruction ? explicitStepRange(instruction, steps.length) : undefined;
409
+ if (range) return Array.from({ length: range[1] - range[0] + 1 }, (_value, offset) => range[0] + offset);
410
+ const target = targetStep(steps, instruction, completed);
411
+ const step = target >= 0 ? target : nextOpenStep(steps, completed);
412
+ return step >= 0 ? [step] : [];
413
+ }
414
+
415
+ /**
416
+ * Each step's worker time and whether a worker is on it now, replaying the
417
+ * runs in start order as the checklist does. A run counts in full toward each
418
+ * step it works on (a `Steps 2-4` run toward all three), and its success
419
+ * completes them, so a later run that names no step goes to the next one.
420
+ */
421
+ export function stepWork(steps: readonly string[], runs: readonly AgentRun[], now = Date.now()): StepWork[] {
422
+ const work = steps.map(() => ({ ms: 0, active: false }));
423
+ const completed = new Set<number>();
424
+ for (const run of byStart(runs)) {
425
+ if (run.role !== "worker") continue;
426
+ const covered = runSteps(steps, run.instruction, completed);
427
+ const start = Date.parse(run.startedAt);
428
+ const end = run.finishedAt ? Date.parse(run.finishedAt) : now;
429
+ const ms = Number.isFinite(start) && Number.isFinite(end) ? Math.max(0, end - start) : 0;
430
+ for (const index of covered) {
431
+ work[index]!.ms += ms;
432
+ if (run.status === "running") work[index]!.active = true;
433
+ }
434
+ if (run.status === "success" && covered.length > 0) markThrough(completed, covered[covered.length - 1]!);
435
+ }
436
+ return work;
437
+ }
package/src/pi/route.ts CHANGED
@@ -14,7 +14,6 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
14
14
  import { Container, Text } from "@earendil-works/pi-tui";
15
15
  import { Type } from "typebox";
16
16
  import type { Domain } from "../schemas/agent.ts";
17
- import type { TaskTriage } from "../schemas/task.ts";
18
17
  import { chooseRoute, type RequestRoute } from "../workflow/track.ts";
19
18
  import { triageFor } from "../classifier/instance.ts";
20
19
  import { ownedTask } from "../state/persistence.ts";
@@ -124,60 +124,3 @@ export function runFromLog(entry: RunLogEntry, taskId: string): AgentRun {
124
124
  ...(entry.error ? { error: entry.error } : {}),
125
125
  };
126
126
  }
127
-
128
- /** Silence after which a working agent reads as "quiet" in the scene. */
129
- export const QUIET_MS = 45_000;
130
-
131
- /** How long a running agent has produced nothing, or 0 when it is not running or has no clock yet. */
132
- export function quietFor(run: Pick<AgentRun, "status" | "lastEventAt" | "startedAt">, now: number): number {
133
- if (run.status !== "running") return 0;
134
- const last = run.lastEventAt ?? Date.parse(run.startedAt);
135
- const quiet = now - last;
136
- return Number.isFinite(quiet) && quiet > 0 ? quiet : 0;
137
- }
138
-
139
- export interface FeedLine {
140
- text: string;
141
- kind: "info" | "warning";
142
- }
143
-
144
- /** Ticks one working agent holds the feed before it rotates to the next (~3 s at the live clock). */
145
- export const FEED_ROTATE_TICKS = 12;
146
-
147
- /** What one working agent is doing right now, with a warning when something needs attention. */
148
- function liveFeed(run: AgentRun, now: number): FeedLine {
149
- const name = agentName(run);
150
- if (run.waitingFor) return { text: `⧗ ${name} waiting for ${run.waitingFor}${run.note ? ` · ${run.note}` : ""}`, kind: "warning" };
151
- const quiet = quietFor(run, now);
152
- if (quiet >= QUIET_MS) {
153
- const doing = run.activity ? ` (last: ${run.activity}${run.detail ? ` ${run.detail}` : ""})` : "";
154
- return { text: `! ${name} quiet for ${shortDuration(quiet)}${doing}`, kind: "warning" };
155
- }
156
- if (run.note && run.noteKind === "warning") return { text: `! ${name} ${run.note}`, kind: "warning" };
157
- const parts = [`▸ ${name} ${run.activity ?? "working"}${run.detail ? ` ${run.detail}` : ""}`];
158
- const turns = run.turns ?? run.usage?.turns;
159
- if (turns) parts.push(`turn ${turns}`);
160
- if (run.tools) parts.push(`${run.tools} tools`);
161
- if (run.usage && run.usage.input + run.usage.output > 0) parts.push(`${tokens(run.usage.input + run.usage.output)} tok`);
162
- if (run.note) parts.push(run.note);
163
- return { text: parts.join(" · "), kind: "info" };
164
- }
165
-
166
- /**
167
- * The scene's feed row: a working agent that needs attention first, otherwise
168
- * the working agents in rotation, otherwise the most recent finished run.
169
- * Undefined before anything has run.
170
- */
171
- export function feedLine(runs: readonly AgentRun[], now: number, tick: number): FeedLine | undefined {
172
- const running = runs.filter((run) => run.status === "running");
173
- if (running.length > 0) {
174
- const lines = running.map((run) => liveFeed(run, now));
175
- const warning = lines.find((line) => line.kind === "warning");
176
- if (warning) return warning;
177
- const index = Math.floor(Math.max(0, tick) / FEED_ROTATE_TICKS) % lines.length;
178
- return lines[index];
179
- }
180
- const last = [...runs].filter((run) => run.finishedAt).sort((a, b) => Date.parse(a.finishedAt!) - Date.parse(b.finishedAt!)).at(-1);
181
- if (!last) return undefined;
182
- return { text: describeRun(last, now), kind: last.status === "success" && !last.wrappedUp ? "info" : "warning" };
183
- }
@@ -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 {
@@ -33,7 +33,3 @@ export interface DomainSpec {
33
33
  export function isDomain(value: string): value is Domain {
34
34
  return (DOMAINS as readonly string[]).includes(value);
35
35
  }
36
-
37
- export function isRole(value: string): value is Role {
38
- return (ROLES as readonly string[]).includes(value);
39
- }
@@ -13,8 +13,6 @@ export type ThinkingLevelName = (typeof THINKING_LEVELS)[number];
13
13
  */
14
14
  const INHERIT = "inherit";
15
15
  export const INHERIT_MODEL = INHERIT;
16
- /** Legacy thinking sentinel; configs that still carry it migrate to `DEFAULT_THINKING`. */
17
- export const INHERIT_THINKING = INHERIT;
18
16
  /** Level used for a missing, legacy `inherit` or unknown thinking value. */
19
17
  export const DEFAULT_THINKING: ThinkingLevelName = "medium";
20
18
  /** Scouts are reconnaissance: always fast, never configurable. */
@@ -92,6 +90,29 @@ export interface WorkflowConfig {
92
90
  gitIsolation: GitIsolation;
93
91
  }
94
92
 
93
+ /**
94
+ * The lint gate: off; advise (the oracle and QA see what the project's own
95
+ * linter finds in the files the task's agents touched); or block (errors on
96
+ * the lines the task changed also hold completion until they are fixed or
97
+ * the user accepts the work as it is).
98
+ */
99
+ export const LINT_MODES = ["off", "advise", "block"] as const;
100
+ export type LintMode = (typeof LINT_MODES)[number];
101
+
102
+ export function isLintMode(value: unknown): value is LintMode {
103
+ return typeof value === "string" && (LINT_MODES as readonly string[]).includes(value);
104
+ }
105
+
106
+ export interface LintConfig {
107
+ mode: LintMode;
108
+ /** 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). */
109
+ command: string;
110
+ /** Which touched files the command is given, by extension (`.ts`, `.py`); empty gives it every one. */
111
+ extensions: string[];
112
+ /** Time limit for one linter run. */
113
+ timeoutMs: number;
114
+ }
115
+
95
116
  export interface KnowledgeConfig {
96
117
  compactionThreshold: number;
97
118
  backupCount: number;
@@ -128,6 +149,8 @@ export interface LobbyConfig {
128
149
  planningPanel: PanelMember[];
129
150
  /** Show the GitHub Issues tab (off for now). */
130
151
  issues: boolean;
152
+ /** Bot-lobby is on when pi starts; off (the default), pi starts as plain pi until ctrl+shift+m or /bot-lobby on. */
153
+ startOn: boolean;
131
154
  /** Which panes the Lobby tab shows. */
132
155
  panels: Record<LobbyPanel, boolean>;
133
156
  /** Key overrides by action name, e.g. `{ "search": "alt+f" }`. */
@@ -208,6 +231,8 @@ export interface BotLobbyConfig {
208
231
  /** The task planner that grills the user until a plan is clear; `timeoutMs` bounds one turn. */
209
232
  planner: AgentModelConfig;
210
233
  workflow: WorkflowConfig;
234
+ /** The engine lints the files a task's agents touched, after each step, before QA and at completion. */
235
+ lint: LintConfig;
211
236
  knowledge: KnowledgeConfig;
212
237
  lobby: LobbyConfig;
213
238
  classifier: ClassifierConfig;
@@ -243,6 +268,7 @@ export const DEFAULT_CONFIG: BotLobbyConfig = {
243
268
  routeQuickFixes: true,
244
269
  gitIsolation: "off",
245
270
  },
271
+ lint: { mode: "advise", command: "", extensions: [], timeoutMs: 2 * 60 * 1000 },
246
272
  knowledge: {
247
273
  compactionThreshold: 20000,
248
274
  backupCount: 1,
@@ -252,6 +278,7 @@ export const DEFAULT_CONFIG: BotLobbyConfig = {
252
278
  lobby: {
253
279
  planningPanel: [...PANEL_MEMBERS],
254
280
  issues: false,
281
+ startOn: false,
255
282
  panels: { conversation: true, activity: true, thinking: true },
256
283
  keys: {},
257
284
  maxPlanningRounds: 5,
@@ -315,7 +342,7 @@ function flag(value: unknown, fallback: boolean): boolean {
315
342
  }
316
343
 
317
344
  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;
345
+ const source = value as { planningPanel?: unknown; issues?: unknown; startOn?: unknown; panels?: unknown; keys?: unknown; maxPlanningRounds?: unknown; splitPlanAbove?: unknown; web?: unknown } | undefined;
319
346
  const defaults = DEFAULT_CONFIG.lobby;
320
347
  const panel = Array.isArray(source?.planningPanel)
321
348
  ? [...new Set(source.planningPanel.filter((entry): entry is PanelMember => typeof entry === "string" && isPanelMember(entry)))]
@@ -325,6 +352,7 @@ function normalizeLobby(value: unknown): LobbyConfig {
325
352
  return {
326
353
  planningPanel: PANEL_MEMBERS.filter((member) => panel.includes(member)),
327
354
  issues: flag(source?.issues, defaults.issues),
355
+ startOn: flag(source?.startOn, defaults.startOn),
328
356
  panels: Object.fromEntries(LOBBY_PANELS.map((name) => [name, flag(panels[name], defaults.panels[name])])) as Record<LobbyPanel, boolean>,
329
357
  keys: Object.fromEntries(Object.entries(keys).filter((entry): entry is [string, string] => typeof entry[1] === "string" && entry[1].trim().length > 0)),
330
358
  maxPlanningRounds: roundLimit(source?.maxPlanningRounds, defaults.maxPlanningRounds),
@@ -362,6 +390,25 @@ function text(value: unknown, fallback: string): string {
362
390
  return typeof value === "string" ? value.trim() : fallback;
363
391
  }
364
392
 
393
+ /** `.ts`, `TSX` and `*.py` all read as `.ts`, `.tsx`, `.py`. */
394
+ function extension(value: string): string {
395
+ const bare = value.trim().replace(/^\*/, "").replace(/^\./, "").toLowerCase();
396
+ return bare ? `.${bare}` : "";
397
+ }
398
+
399
+ function normalizeLint(value: unknown): LintConfig {
400
+ const source = (value && typeof value === "object" ? value : {}) as Record<string, unknown>;
401
+ const defaults = DEFAULT_CONFIG.lint;
402
+ const extensions = Array.isArray(source.extensions) ? source.extensions.filter((entry): entry is string => typeof entry === "string").map(extension).filter(Boolean) : [...defaults.extensions];
403
+ const timeout = positive(source.timeoutMs);
404
+ return {
405
+ mode: isLintMode(source.mode) ? source.mode : defaults.mode,
406
+ command: text(source.command, defaults.command),
407
+ extensions: [...new Set(extensions)],
408
+ timeoutMs: timeout ? Math.min(30 * 60 * 1000, Math.round(timeout)) : defaults.timeoutMs,
409
+ };
410
+ }
411
+
365
412
  function normalizeClassifier(value: unknown): ClassifierConfig {
366
413
  const source = (value && typeof value === "object" ? value : {}) as Record<string, unknown>;
367
414
  const defaults = DEFAULT_CONFIG.classifier;
@@ -412,6 +459,7 @@ export function resolveConfig(partial: unknown): BotLobbyConfig {
412
459
  quickFix: normalizeAgent(DEFAULT_CONFIG.quickFix, src.quickFix as Partial<AgentModelConfig> | undefined),
413
460
  planner: normalizeAgent(DEFAULT_CONFIG.planner, src.planner as Partial<AgentModelConfig> | undefined),
414
461
  workflow,
462
+ lint: normalizeLint(src.lint),
415
463
  knowledge,
416
464
  lobby: normalizeLobby(src.lobby),
417
465
  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(