@a-t-h-i/bot-lobby 0.6.24 → 0.6.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -3
- package/package.json +1 -5
- package/prompts/designer.md +14 -0
- package/prompts/master.md +30 -7
- package/prompts/panel.md +21 -1
- package/prompts/planner.md +22 -3
- package/prompts/qa.md +23 -3
- package/prompts/quickfix.md +4 -0
- package/prompts/reviewer.md +17 -0
- package/prompts/worker.md +21 -6
- package/src/ask/tool.ts +3 -3
- package/src/classifier/hosts.ts +0 -11
- package/src/classifier/instance.ts +0 -5
- package/src/delivery/review.ts +1 -1
- package/src/execution/agent-runner.ts +0 -18
- package/src/execution/git.ts +77 -0
- package/src/execution/lint.ts +435 -0
- package/src/knowledge/compactor.ts +0 -70
- package/src/lobby/ask.ts +4 -17
- package/src/lobby/feed.ts +39 -4
- package/src/lobby/host.ts +4 -0
- package/src/lobby/issues.ts +0 -8
- package/src/lobby/planner.ts +54 -5
- package/src/lobby/pr-review.ts +2 -1
- package/src/lobby/pulls.ts +1 -44
- package/src/lobby/quickfix.ts +34 -1
- package/src/lobby/runtime.ts +20 -3
- package/src/lobby/service.ts +5 -1
- package/src/lobby/session-files.ts +1 -45
- package/src/lobby/split.ts +33 -1
- package/src/lobby/task-rows.ts +2 -0
- package/src/master/master.ts +3 -0
- package/src/master/research.ts +0 -16
- package/src/pi/commands.ts +25 -10
- package/src/pi/events.ts +12 -4
- package/src/pi/model-settings.ts +0 -1
- package/src/pi/plan-checklist.ts +98 -8
- package/src/pi/route.ts +0 -1
- package/src/pi/run-summary.ts +0 -57
- package/src/pi/switch.ts +69 -0
- package/src/pi/tools.ts +7 -0
- package/src/pi/ui.ts +2 -14
- package/src/schemas/agent.ts +0 -4
- package/src/schemas/configuration.ts +51 -3
- package/src/schemas/lint.ts +55 -0
- package/src/schemas/task.ts +5 -0
- package/src/state/budget.ts +49 -22
- package/src/state/changes.ts +8 -2
- package/src/state/metrics.ts +0 -1
- package/src/state/persistence.ts +0 -5
- package/src/state/work-time.ts +123 -0
- package/src/webui/api/index.ts +2 -6
- package/src/webui/api/knowledge.ts +11 -3
- package/src/webui/api/lobby.ts +13 -6
- package/src/webui/api/plan-facts.ts +36 -6
- package/src/webui/api/settings.ts +6 -1
- package/src/webui/api/tasks.ts +27 -6
- package/src/webui/command.ts +14 -3
- package/src/webui/dev/fake-service.ts +7 -0
- package/src/webui/dev/fixtures.ts +4 -1
- package/src/webui/protocol.ts +30 -4
- package/src/workflow/lint.ts +221 -0
- package/src/workflow/track.ts +12 -4
- package/src/workflow/workflow.ts +105 -21
- package/webui/dist/assets/index-C1IwecNS.css +2 -0
- package/webui/dist/assets/index-C4Ww36TW.js +105 -0
- package/webui/dist/build.json +58 -62
- package/webui/dist/index.html +2 -2
- package/webui/dist/assets/index-BSewK1QO.css +0 -2
- package/webui/dist/assets/index-CHQYSZXA.js +0 -105
package/src/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
|
-
|
|
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;
|
package/src/pi/model-settings.ts
CHANGED
|
@@ -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 {
|
package/src/pi/plan-checklist.ts
CHANGED
|
@@ -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.
|
|
104
|
-
* `
|
|
105
|
-
*
|
|
106
|
-
*
|
|
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
|
|
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
|
|
249
|
-
const
|
|
250
|
-
|
|
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";
|
package/src/pi/run-summary.ts
CHANGED
|
@@ -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
|
-
}
|
package/src/pi/switch.ts
ADDED
|
@@ -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
|
|
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
|
-
/**
|
|
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 {
|
package/src/schemas/agent.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/schemas/task.ts
CHANGED
|
@@ -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(
|