@a-t-h-i/bot-lobby 0.6.27 → 0.6.28

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  # Bot-Lobby
2
2
 
3
- A [Pi](https://pi.dev) extension for multi-agent software development. Describe a change, agree a plan
4
- with the oracle, and follow the agents in a local browser lobby. Approvals, domain boundaries and QA
3
+ A [Pi](https://pi.dev) extension for multi-agent software development. Describe a change, agree on a
4
+ plan with the oracle, and follow the agents in a local browser lobby. Approvals, domain boundaries and QA
5
5
  are enforced before anything is called done.
6
6
 
7
7
  ![The Lobby: the conversation, the agents' activity and the Thinking orb](.github/screenshots/lobby.png)
@@ -18,12 +18,18 @@ The questionnaire and web tools are built in, so don't also install extensions w
18
18
  ## Use
19
19
 
20
20
  1. In Pi, run `/bot-lobby add a login page`. Bot-lobby turns on and the lobby opens in your browser.
21
- A new pi session starts with bot-lobby off, as plain pi: `Ctrl+Shift+M` or `/bot-lobby on|off` switches it.
22
- 2. Answer the oracle's questions and approve its plan, or agree one with the panel in **Plan** first.
21
+ A new Pi session starts with bot-lobby off, as plain Pi: `Ctrl+Shift+M` or `/bot-lobby on|off` switches it.
22
+ 2. Answer the oracle's questions and approve its plan, or agree on one with the panel in **Plan** first.
23
23
  A plan opens with a short list of steps; the detail of each follows for whoever wants it.
24
+ Plans you leave without saving them as a task wait under **Plan → Previous**. You can read, carry on,
25
+ archive or delete them there. A plan saved as a task leaves that list.
24
26
  3. Follow the agents in **Lobby**: the step being worked on reads `active · 1m 12s`, and each task shows
25
27
  how long its agents have worked on it (idle time left out). Models, effort and time limits are in **Settings**.
26
28
 
29
+ **Issues** lists the repository's open issues (with `lobby.issues` on), and **Plan it** starts a
30
+ planning session from one. **Git** lists open pull requests; an agent reviews one on request,
31
+ read-only, and nothing is posted to GitHub. Both read GitHub through `gh`.
32
+
27
33
  Small changes go to **Quick fix**. `--task` forces a full task, `--fast` / `--full` picks the workflow,
28
34
  and `--branch` / `--worktree` isolates it in git. A finished isolated task pushes its branch and tries
29
35
  to open a PR through `gh`; nothing merges on its own.
@@ -37,7 +43,17 @@ QA is sized to what was built, not to the request. Once the work is in, Jev (the
37
43
  reads the diff and sets the QA risk. A change with nothing that runs (docs, styles, comments) passes
38
44
  on the engine's checks with no QA agent, and a small one gets a light check. One that touches
39
45
  security, data, concurrency or orchestration gets a deep, adversarial review. The test budget is a
40
- ceiling, never a quota, and the Tasks tab shows the read and why. With Jev off, the engine's rules decide.
46
+ ceiling, never a quota, and the Tasks tab shows Jev's read and why. With Jev off, the engine's rules decide.
47
+
48
+ If Pi crashes, is killed or loses its terminal, the lobby page keeps everything on screen and
49
+ reconnects when Pi is back. The next Pi started in the project carries on what was running. The
50
+ planning session returns as it was, and a round that was cut off runs again. Queued quick fixes
51
+ run, and every task's session starts again from its saved file, picking up where it stopped. A
52
+ quit on purpose stops this work, and `pi -c` brings back the planning session.
53
+
54
+ A task that is paused, or that no running session drives, shows **Resume** (`R`) in **Tasks**.
55
+ It carries the task on without moving your window: a paused task is unpaused where it runs, and a
56
+ stopped one carries on in a background session, which restarts its own session when its file is found.
41
57
 
42
58
  | Agent | Does |
43
59
  | --- | --- |
@@ -56,14 +72,17 @@ rebound with `lobby.keys`. In Pi, `Alt+G` toggles auto mode and `Ctrl+Shift+M` t
56
72
 
57
73
  ## Screenshots
58
74
 
59
- | Plan | Tasks |
75
+ | Planning | Tasks |
60
76
  | --- | --- |
61
- | ![The Plan start page: the panel as seats](.github/screenshots/plan.png) | ![A task with its action bar](.github/screenshots/tasks.png) |
62
- | **Thinking** | **Excalidraw** |
63
- | ![Each agent's latest thought, in steps](.github/screenshots/thinking.png) | ![A shared Excalidraw session](.github/screenshots/excalidraw.png) |
77
+ | ![A planning session: the panel's questions with mockups, and the draft plan](.github/screenshots/plan.png) | ![The task list across sessions, with one task's steps](.github/screenshots/tasks.png) |
78
+ | **Thinking** | **Questionnaire** |
79
+ | ![The Thinking pop-up: each agent's thoughts, in steps](.github/screenshots/thinking.png) | ![A question with a mockup for each option](.github/screenshots/questionnaire.png) |
80
+ | **Issues** | **Git** |
81
+ | ![An open issue, ready to plan with the panel](.github/screenshots/issues.png) | ![A pull request with an agent's review](.github/screenshots/git.png) |
64
82
 
65
83
  ## Configuration
66
84
 
67
85
  Settings live in `~/.pi/bot-lobby/config.json` (`BOT_LOBBY_CONFIG_DIR` moves them), and
68
- `/bot-lobby config` shows what is in effect. Tasks and project knowledge stay in `.pi/bot-lobby/`
69
- inside your project.
86
+ `/bot-lobby config` shows what is in effect. Colour themes you import, under a name you choose,
87
+ are kept in `themes.json` beside it, so every Pi session and project shows them. Tasks and
88
+ project knowledge stay in `.pi/bot-lobby/` inside your project.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a-t-h-i/bot-lobby",
3
- "version": "0.6.27",
3
+ "version": "0.6.28",
4
4
  "description": "Structured multi-agent software engineering orchestrator for Pi",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
package/src/lobby/host.ts CHANGED
@@ -13,6 +13,7 @@ import type { ChatEntry, LobbyFeed } from "./feed.ts";
13
13
  import type { BackgroundSession } from "./sessions.ts";
14
14
  import type { QuickFixQueue } from "./quickfix.ts";
15
15
  import type { PlannerSeed, PlanningSession } from "./planner.ts";
16
+ import type { PlanRecord, PlanSummary } from "../state/plan-history.ts";
16
17
  import type { IssuesState } from "./issues.ts";
17
18
  import type { PullsState } from "./pulls.ts";
18
19
  import type { PullReviews } from "./pr-review.ts";
@@ -115,6 +116,16 @@ export interface LobbyService {
115
116
  abortMaster(): void;
116
117
  quickfix: QuickFixQueue;
117
118
  planner(): PlanningSession | undefined;
119
+ /** The project's previous plans (never saved as a task), newest first; the archived ones when asked. */
120
+ previousPlans(archived: boolean): PlanSummary[];
121
+ /** One previous plan, whole; throws when there is none (or it was saved as a task). */
122
+ previousPlan(id: string): PlanRecord;
123
+ /** Archive a previous plan, or bring it back; returns a notice (throws when it cannot). */
124
+ archivePlan(id: string, archived: boolean): string;
125
+ /** Delete a previous plan for good; returns a notice (throws when it cannot). */
126
+ deletePlan(id: string): string;
127
+ /** Carry a previous plan on as the plan on screen; returns a notice. */
128
+ reopenPlan(id: string): string;
118
129
  /** Start a planning session (replacing any other) with these seats on the panel. */
119
130
  newPlanner(seed?: PlannerSeed, seats?: readonly PanelMember[]): PlanningSession;
120
131
  /** The oracle puts the panel's open questions to the user, one questionnaire at a time; returns a notice. */
@@ -150,6 +161,8 @@ export interface LobbyService {
150
161
  sessionModel?(): string | undefined;
151
162
  /** The Issues tab is switched on (`lobby.issues`). */
152
163
  issuesEnabled(): boolean;
164
+ /** What this window carried on from one that stopped unexpectedly, when it did. */
165
+ recovered?(): { at: number; text: string } | undefined;
153
166
  /** This window's pi session name, when it has one. */
154
167
  sessionName(): string | undefined;
155
168
  /** Background sessions this window started, oldest first. */
@@ -161,6 +174,12 @@ export interface LobbyService {
161
174
  setAuto(taskId: string, on: boolean): void;
162
175
  /** Leave a message for a task's oracle, delivered by whichever session drives it (now or once one resumes it); returns a notice. */
163
176
  sendToTask(taskId: string, text: string): string;
177
+ /**
178
+ * Resume a task from the Tasks screen, leaving this window where it is: a
179
+ * paused task is unpaused where it runs, one nothing runs carries on in a
180
+ * background session. A notice, and the background session's key.
181
+ */
182
+ resumeTask?(taskId: string): Promise<{ notice: string; key?: string }>;
164
183
  /** Leave a message for a session running in another terminal; it delivers it within seconds. Returns a notice. */
165
184
  sendToSession(sessionId: string, text: string): string;
166
185
  /** Sessions running in this project, other than this window's, from their heartbeats. */
@@ -522,6 +522,49 @@ export function memberPrompt(member: PanelMember, instructions?: string): string
522
522
  return [base, loadPrompt("panel.md"), `## Your seat\n\n${MEMBER_SEATS[member]}`].join("\n\n---\n\n");
523
523
  }
524
524
 
525
+ /** A planning session as JSON: what `PlanningSession.snapshot` writes and `restore` reads. */
526
+ export interface PlanningSnapshot {
527
+ version: 1;
528
+ /** Absent on snapshots kept before sessions had ids: a restore gives them one. */
529
+ id?: string;
530
+ createdAt?: number;
531
+ messages: PlannerMessage[];
532
+ seed?: PlannerSeed;
533
+ reply?: PlannerReply;
534
+ questions: PanelQuestion[];
535
+ notes: PanelNote[];
536
+ members: MemberState[];
537
+ seats: PanelMember[];
538
+ pins: PanelMember[];
539
+ satOut: Array<[PanelMember, number]>;
540
+ lastStatus: Array<[PanelMember, "ready" | "open"]>;
541
+ sat: PanelMember[];
542
+ memberNotes: Array<[PanelMember, string[]]>;
543
+ turns: number;
544
+ mode: RoundMode;
545
+ answered: AskResult[];
546
+ lineComments: LineComment[];
547
+ saved?: PlannedTask;
548
+ savedParts: PlannedTask[];
549
+ error?: string;
550
+ autoContinued: boolean;
551
+ attempts: number;
552
+ /** A round was running when it was taken. */
553
+ running: boolean;
554
+ }
555
+
556
+ /** What a seat that was thinking when pi stopped shows until its round runs again. */
557
+ export const STOPPED_WITH_PI = "pi stopped during the round";
558
+
559
+ /** Whether a parsed file is a snapshot this version reads; a torn or foreign file is not. */
560
+ export function isPlanningSnapshot(value: unknown): value is PlanningSnapshot {
561
+ const snapshot = value as PlanningSnapshot;
562
+ return Boolean(snapshot) && snapshot.version === 1 && Array.isArray(snapshot.messages) && Array.isArray(snapshot.questions) && Array.isArray(snapshot.seats)
563
+ && Array.isArray(snapshot.members) && Array.isArray(snapshot.pins) && Array.isArray(snapshot.satOut) && Array.isArray(snapshot.lastStatus)
564
+ && Array.isArray(snapshot.sat) && Array.isArray(snapshot.memberNotes) && Array.isArray(snapshot.answered) && Array.isArray(snapshot.lineComments)
565
+ && Array.isArray(snapshot.savedParts) && Array.isArray(snapshot.notes) && typeof snapshot.turns === "number";
566
+ }
567
+
525
568
  interface RunOutcome {
526
569
  status: MetricRecord["status"];
527
570
  output: string;
@@ -530,7 +573,15 @@ interface RunOutcome {
530
573
  usage: { input: number; output: number; cost: number; turns: number };
531
574
  }
532
575
 
576
+ /** A new planning session's id: `PS-` and the time, so ids sort by when they began. */
577
+ export function planningId(now = Date.now()): string {
578
+ return `PS-${now.toString(36)}${Math.random().toString(36).slice(2, 6)}`;
579
+ }
580
+
533
581
  export class PlanningSession {
582
+ /** Names the session among the project's plans (src/state/plan-history.ts). */
583
+ id = planningId();
584
+ createdAt = Date.now();
534
585
  messages: PlannerMessage[] = [];
535
586
  seed?: PlannerSeed;
536
587
  /** The oracle's latest reply; its plan is the current draft. */
@@ -718,6 +769,86 @@ export class PlanningSession {
718
769
  return PANEL_MEMBERS.filter((member) => this.sat.has(member) && this.seats.has(member));
719
770
  }
720
771
 
772
+ /** Everything the session needs to carry on in another pi process, as JSON. */
773
+ snapshot(): PlanningSnapshot {
774
+ return {
775
+ version: 1,
776
+ id: this.id,
777
+ createdAt: this.createdAt,
778
+ messages: this.messages,
779
+ ...(this.seed ? { seed: this.seed } : {}),
780
+ ...(this.reply ? { reply: this.reply } : {}),
781
+ questions: this.questions,
782
+ notes: this.notes,
783
+ members: this.members,
784
+ seats: [...this.seats],
785
+ pins: [...this.pins],
786
+ satOut: [...this.satOut],
787
+ lastStatus: [...this.lastStatus],
788
+ sat: [...this.sat],
789
+ memberNotes: [...this.memberNotes],
790
+ turns: this.turns,
791
+ mode: this.mode,
792
+ answered: this.answered,
793
+ lineComments: this.lineComments,
794
+ ...(this.saved ? { saved: this.saved } : {}),
795
+ savedParts: this.savedParts,
796
+ ...(this.error ? { error: this.error } : {}),
797
+ autoContinued: this.autoContinued,
798
+ attempts: this.attempts,
799
+ running: this.busy,
800
+ };
801
+ }
802
+
803
+ /**
804
+ * A session as a snapshot left it. A round that was running when pi
805
+ * stopped is marked interrupted: `resume` runs it again.
806
+ */
807
+ static restore(deps: PlannerDeps, snapshot: PlanningSnapshot): PlanningSession {
808
+ const session = new PlanningSession({ ...deps, panel: snapshot.seats }, snapshot.seed);
809
+ if (snapshot.id) session.id = snapshot.id;
810
+ if (typeof snapshot.createdAt === "number") session.createdAt = snapshot.createdAt;
811
+ session.messages = snapshot.messages;
812
+ if (snapshot.reply) session.reply = snapshot.reply;
813
+ session.questions = snapshot.questions;
814
+ session.notes = snapshot.notes;
815
+ session.members = snapshot.members.map((member) => member.status === "thinking" ? { ...member, status: "failed", error: STOPPED_WITH_PI, step: undefined } : member);
816
+ for (const member of snapshot.pins) session.pins.add(member);
817
+ session.satOut = new Map(snapshot.satOut);
818
+ for (const [member, status] of snapshot.lastStatus) session.lastStatus.set(member, status);
819
+ for (const member of snapshot.sat) session.sat.add(member);
820
+ for (const [member, notes] of snapshot.memberNotes) session.memberNotes.set(member, notes);
821
+ session.turns = snapshot.turns;
822
+ session.mode = snapshot.mode;
823
+ session.answered = snapshot.answered;
824
+ session.lineComments = snapshot.lineComments;
825
+ if (snapshot.saved) session.saved = snapshot.saved;
826
+ session.savedParts = snapshot.savedParts;
827
+ if (snapshot.error) session.error = snapshot.error;
828
+ session.autoContinued = snapshot.autoContinued;
829
+ session.attempts = snapshot.attempts;
830
+ session.interrupted = snapshot.running;
831
+ return session;
832
+ }
833
+
834
+ /** A round was running when pi stopped, and has not run again yet. */
835
+ interrupted = false;
836
+
837
+ /** Run again the round pi stopped in the middle of; nothing when none was. */
838
+ async resume(): Promise<void> {
839
+ if (!this.interrupted || this.busy) return;
840
+ this.interrupted = false;
841
+ const last = this.messages.at(-1);
842
+ if (last?.role === "you") return this.retry();
843
+ // The seed alone had started the first round.
844
+ if (this.messages.length === 0 && this.seed) {
845
+ this.turns = Math.max(0, this.turns - 1);
846
+ return this.open();
847
+ }
848
+ // The round had already written its reply: only its last checks were cut off.
849
+ this.deps.onChange?.();
850
+ }
851
+
721
852
  /** Save the latest draft as a pending task. */
722
853
  save(now = new Date()): PlannedTask {
723
854
  const plan = this.reply?.plan;
@@ -65,6 +65,8 @@ export interface QuickFixJob {
65
65
  profile?: QuickFixProfile;
66
66
  /** The oracle sent it here from a request typed to the lobby. */
67
67
  routed?: boolean;
68
+ /** An earlier run was cut off when pi stopped: some of its edits may already be in the tree. */
69
+ interrupted?: boolean;
68
70
  }
69
71
 
70
72
  /** How a job enters the queue besides its prompt. */
@@ -111,6 +113,10 @@ export interface QuickFixDeps {
111
113
 
112
114
  export const MAX_JOBS = 30;
113
115
  export const MAX_STEPS = 200;
116
+ /** Steps a snapshot keeps per job: enough to read what it did. */
117
+ const MAX_KEPT_STEPS = 40;
118
+ /** What a job pi stopped in the middle of is told when it runs again. */
119
+ export const INTERRUPTED_NOTE = "An earlier run of this request was cut off when pi stopped, so some of its edits may already be in the tree: look at the files before you change them, and finish the request.";
114
120
 
115
121
  function isActive(job: QuickFixJob): boolean {
116
122
  return job.status === "queued" || job.status === "running";
@@ -202,6 +208,39 @@ export class QuickFixQueue {
202
208
  return true;
203
209
  }
204
210
 
211
+ /** The queue as JSON, for the next pi process to carry on from. */
212
+ snapshot(): QuickFixJob[] {
213
+ return this.jobs.map((job) => ({ ...job, steps: job.steps.slice(-MAX_KEPT_STEPS) }));
214
+ }
215
+
216
+ /**
217
+ * Take up a queue an earlier pi process left: finished jobs stay as they
218
+ * ended, and the one that was running when pi stopped goes back to the front
219
+ * of the queue, told that it was cut off. Returns the jobs that will run.
220
+ */
221
+ restore(jobs: readonly QuickFixJob[]): QuickFixJob[] {
222
+ const kept = jobs.map((job): QuickFixJob => job.status !== "running" ? { ...job } : {
223
+ ...job,
224
+ status: "queued",
225
+ interrupted: true,
226
+ note: "pi stopped while this ran; it runs again from where the tree is",
227
+ steps: job.steps.map((step) => ({ ...step, pending: false })),
228
+ });
229
+ // Queues from more than one stopped window may share ids: a repeated one is numbered on.
230
+ const number = (job: QuickFixJob) => Number(/^QF-(\d+)$/.exec(job.id)?.[1] ?? 0);
231
+ this.counter = Math.max(this.counter, ...this.jobs.map(number), ...kept.map(number));
232
+ const taken = new Set(this.jobs.map((job) => job.id));
233
+ for (const job of kept) {
234
+ if (taken.has(job.id)) job.id = `QF-${++this.counter}`;
235
+ taken.add(job.id);
236
+ }
237
+ const resumed = kept.filter((job) => job.interrupted && job.status === "queued");
238
+ this.jobs = [...resumed, ...kept.filter((job) => !resumed.includes(job)), ...this.jobs];
239
+ this.changed();
240
+ this.pump();
241
+ return this.jobs.filter(isActive);
242
+ }
243
+
205
244
  /** Abort everything (session shutdown). */
206
245
  cancelAll(): void {
207
246
  for (const job of this.jobs) if (job.status === "queued") job.status = "cancelled";
@@ -259,7 +298,7 @@ export class QuickFixQueue {
259
298
  const attempt = (model: string | undefined, thinking: string) => runPiAgent(
260
299
  {
261
300
  cwd: this.deps.cwd,
262
- task: likely ? `${job.prompt}\n\n${likely}` : job.prompt,
301
+ task: [job.prompt, job.interrupted ? INTERRUPTED_NOTE : "", likely].filter(Boolean).join("\n\n"),
263
302
  systemPrompt: quickFixPrompt(profile.instructions),
264
303
  tools: [...QUICK_FIX_TOOLS, ...(this.deps.hints?.tools() ?? [])],
265
304
  ...grantOption("quickfix"),