@a-t-h-i/bot-lobby 0.6.30 → 0.6.32

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
@@ -57,6 +57,11 @@ A task that is paused, or that no running session drives, shows **Resume** (`R`)
57
57
  It carries the task on without moving your window: a paused task is unpaused where it runs, and a
58
58
  stopped one carries on in a background session, which restarts its own session when its file is found.
59
59
 
60
+ The **Tasks** screen has **List** and **Board** subtabs. Board groups saved plans and new tasks
61
+ in **Backlog**, active work in **In progress**, and finished work in **Completed**. Abandoned
62
+ work stays labeled in Backlog until archived. Cards open the existing task details and actions;
63
+ archiving removes a card immediately, while the List view can still show the archive.
64
+
60
65
  | Agent | Does |
61
66
  | --- | --- |
62
67
  | Oracle | Your Pi session: questions, plan, delegation and approvals |
@@ -65,6 +70,16 @@ stopped one carries on in a background session, which restarts its own session w
65
70
  | QA | The final quality gate, at the depth the change's risk calls for |
66
71
  | RESEARCH | External facts, with sources |
67
72
 
73
+ ## Projects
74
+
75
+ Click the project name (or press `P`) to switch between running projects or choose **Open folder…**.
76
+ The folder browser lists directories on the machine running bot-lobby, not the browser's machine;
77
+ you can browse, go to a parent, or enter an absolute path. An existing project is reused; otherwise
78
+ a dedicated Pi session starts for the folder's detected project root. It loads this copy of bot-lobby
79
+ without other extensions and does not automatically trust project-local resources. Switching projects
80
+ reloads the page and discards unsent drafts. Projects opened this way stop when their hosting lobby
81
+ stops; opening a folder does not start a task or merge work.
82
+
68
83
  ## Knowledge
69
84
 
70
85
  Each agent keeps what it learns about your project in `.pi/bot-lobby/<Agent>/knowledge/`:
@@ -81,10 +96,10 @@ moved to Redis"). A note travels with its entry into every agent's prompt.
81
96
 
82
97
  ![The Knowledge tab: each agent's files, and one open with notes on its entries](.github/screenshots/knowledge.png)
83
98
 
84
- Ask project questions from **Knowledge** using the **Ask** target below. The oracle checks saved
85
- knowledge first, searches the codebase for missing information, and records verified findings in
86
- the relevant agent's knowledge. Answers appear in the tab's **Ask** view; **Files** returns to the
87
- knowledge tree. Questions do not create tasks.
99
+ Ask project questions directly from the **Knowledge** composer. The oracle checks saved knowledge
100
+ first, searches the codebase for missing information, and records verified findings in the relevant
101
+ agent's knowledge. Answers appear in the side pane; the **Chat / Docs** toggle inside the composer
102
+ switches back to the selected document. Questions do not create tasks.
88
103
 
89
104
  ## Keys
90
105
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a-t-h-i/bot-lobby",
3
- "version": "0.6.30",
3
+ "version": "0.6.32",
4
4
  "description": "Structured multi-agent software engineering orchestrator for Pi",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -286,9 +286,10 @@ function nudgeWhenIdle(state: Runtime, message: string): void {
286
286
  * Whether this pi process serves the web page: an interactive session. A
287
287
  * one-shot run has nobody to answer, and the background sessions the lobby
288
288
  * starts (RPC) are shown by the page of the window that started them.
289
+ * Explicitly opened project folders get their own managed RPC web server.
289
290
  */
290
291
  export function servesPage(ctx: ExtensionContext): boolean {
291
- return ctx.mode === "tui";
292
+ return ctx.mode === "tui" || (ctx.mode === "rpc" && process.env.BOT_LOBBY_WEB_PROJECT === "1");
292
293
  }
293
294
 
294
295
  interface MasterTurn {
@@ -294,10 +294,12 @@ export function sessionArgs(name: string, model?: string, argv: readonly string[
294
294
  const POSIX = process.platform !== "win32";
295
295
 
296
296
  /** The real launcher: the same pi build as this session, in its own process group, without subagent markers. */
297
- export const launchPi: SessionLauncher = (args, cwd) => {
297
+ export const launchPi = (args: string[], cwd: string, overrides: NodeJS.ProcessEnv = {}): SessionProcess => {
298
298
  const invocation = resolvePiInvocation(args);
299
299
  const env: NodeJS.ProcessEnv = { ...process.env, PI_SKIP_VERSION_CHECK: "1" };
300
300
  delete env.BOT_LOBBY_SUBAGENT;
301
+ delete env.BOT_LOBBY_WEB_PROJECT;
302
+ Object.assign(env, overrides);
301
303
  const proc: ChildProcess = spawn(invocation.command, invocation.args, { cwd, env, shell: false, detached: POSIX, windowsHide: true, stdio: ["pipe", "pipe", "pipe"] });
302
304
  return proc as unknown as SessionProcess;
303
305
  };
@@ -55,7 +55,7 @@ function resolveService(deps?: WebCommandDeps): LobbyService | undefined {
55
55
  }
56
56
 
57
57
  function openLink(url: string, deps?: WebCommandDeps): void {
58
- if (!loadConfig().lobby.web.openBrowser) return;
58
+ if (process.env.BOT_LOBBY_WEB_PROJECT === "1" || !loadConfig().lobby.web.openBrowser) return;
59
59
  try {
60
60
  (deps?.open ?? openBrowser)(url);
61
61
  } catch {
@@ -512,12 +512,14 @@ export function createFixtureService(name: string, feed = new LobbyFeed()): Lobb
512
512
  const index = tasks.findIndex((task) => (task as { id: string }).id === taskId);
513
513
  if (index < 0) return `no task ${taskId}`;
514
514
  archived.unshift(tasks.splice(index, 1)[0]!);
515
+ lobbyTopics.bump("tasks");
515
516
  return `archived ${taskId} — v shows archived tasks, a restores one`;
516
517
  },
517
518
  restoreTask: (taskId: string) => {
518
519
  const index = archived.findIndex((task) => (task as { id: string }).id === taskId);
519
520
  if (index < 0) return `no archived task ${taskId}`;
520
521
  tasks.unshift(archived.splice(index, 1)[0]!);
522
+ lobbyTopics.bump("tasks");
521
523
  return `restored ${taskId} to the task list`;
522
524
  },
523
525
  deleteTask: (taskId: string, where: "list" | "archive") => {
@@ -525,6 +527,7 @@ export function createFixtureService(name: string, feed = new LobbyFeed()): Lobb
525
527
  const index = list.findIndex((task) => (task as { id: string }).id === taskId);
526
528
  if (index < 0) return `no task ${taskId}`;
527
529
  list.splice(index, 1);
530
+ lobbyTopics.bump("tasks");
528
531
  return `deleted ${taskId} for good`;
529
532
  },
530
533
  isAuto: (taskId: string) => auto.has(taskId),
@@ -0,0 +1,53 @@
1
+ import { constants } from "node:fs";
2
+ import { access, opendir, realpath, stat } from "node:fs/promises";
3
+ import { dirname, isAbsolute, join } from "node:path";
4
+ import { HttpError } from "./api/index.ts";
5
+ import type { FolderListing } from "./protocol.ts";
6
+
7
+ export function folderPath(body: unknown): string {
8
+ if (!body || typeof body !== "object" || Array.isArray(body) || Object.keys(body).some((key) => key !== "path")) {
9
+ throw new HttpError(400, "bad_request", "send only a folder path");
10
+ }
11
+ const path = (body as { path?: unknown }).path;
12
+ if (typeof path !== "string" || path.length > 4096 || path.includes("\0") || !isAbsolute(path)) {
13
+ throw new HttpError(400, "bad_request", "choose an absolute folder path on the bot-lobby machine");
14
+ }
15
+ return path;
16
+ }
17
+
18
+ function folderError(error: unknown): never {
19
+ if (error instanceof HttpError) throw error;
20
+ const code = (error as NodeJS.ErrnoException).code;
21
+ if (code === "ENOENT") throw new HttpError(404, "not_found", "That folder does not exist.");
22
+ if (code === "ENOTDIR") throw new HttpError(400, "bad_request", "That path is not a folder.");
23
+ throw new HttpError(403, "forbidden", "That folder cannot be read. Check its permissions or choose another folder.");
24
+ }
25
+
26
+ export async function readableFolder(path: string): Promise<string> {
27
+ try {
28
+ const root = await realpath(path);
29
+ if (!(await stat(root)).isDirectory()) throw new HttpError(400, "bad_request", "That path is not a folder.");
30
+ await access(root, constants.R_OK | constants.X_OK);
31
+ return root;
32
+ } catch (error) { return folderError(error); }
33
+ }
34
+
35
+ async function directoryLink(path: string): Promise<boolean> {
36
+ try { return (await stat(path)).isDirectory(); } catch { return false; }
37
+ }
38
+
39
+ export async function browseFolders(path: string): Promise<FolderListing> {
40
+ const root = await readableFolder(path);
41
+ const folders: FolderListing["folders"] = [];
42
+ let seen = 0;
43
+ try {
44
+ for await (const entry of await opendir(root)) {
45
+ if (++seen > 2000) break;
46
+ if (entry.isDirectory() || (entry.isSymbolicLink() && await directoryLink(join(root, entry.name)))) {
47
+ folders.push({ name: entry.name, path: join(root, entry.name) });
48
+ }
49
+ }
50
+ } catch (error) { return folderError(error); }
51
+ folders.sort((a, b) => a.name.localeCompare(b.name));
52
+ return { path: root, parent: dirname(root) === root ? undefined : dirname(root), folders, truncated: seen > 2000 };
53
+ }
@@ -0,0 +1,73 @@
1
+ import { basename } from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+ import { setTimeout as delay } from "node:timers/promises";
4
+ import { BackgroundSession, launchPi, sessionArgs } from "../lobby/sessions.ts";
5
+ import { detectProjectRoot } from "../state/project.ts";
6
+ import { HttpError } from "./api/index.ts";
7
+ import { listProjects, type ProjectRegistry } from "./projects.ts";
8
+ import { readableFolder } from "./project-folders.ts";
9
+ import type { ProjectInfo } from "./protocol.ts";
10
+
11
+ type ProjectProcess = Pick<BackgroundSession, "alive" | "stop" | "whenExited">;
12
+ type LaunchProject = (root: string) => ProjectProcess;
13
+
14
+ function launchProject(root: string): ProjectProcess {
15
+ const name = basename(root) || root;
16
+ const extension = fileURLToPath(new URL("../index.ts", import.meta.url));
17
+ const args = [...sessionArgs(name, undefined, []), "--no-approve", "--no-extensions", "--extension", extension];
18
+ const proc = launchPi(args, root, { BOT_LOBBY_WEB_PROJECT: "1" });
19
+ return new BackgroundSession(proc, { name, projectRoot: root, message: "/bot-lobby on" });
20
+ }
21
+
22
+ export class ProjectOpener {
23
+ private readonly pending = new Map<string, Promise<ProjectInfo>>();
24
+ private readonly children = new Set<ProjectProcess>();
25
+ private closed = false;
26
+
27
+ private readonly registry: ProjectRegistry;
28
+ private readonly launch: LaunchProject;
29
+ private readonly timeoutMs: number;
30
+
31
+ constructor(registry: ProjectRegistry, launch: LaunchProject = launchProject, timeoutMs = 30_000) {
32
+ this.registry = registry;
33
+ this.launch = launch;
34
+ this.timeoutMs = timeoutMs;
35
+ }
36
+
37
+ async open(path: string, cookie?: string): Promise<ProjectInfo> {
38
+ const root = await readableFolder(detectProjectRoot(await readableFolder(path)));
39
+ if (this.closed) throw new HttpError(503, "failed", "The project host is closing.");
40
+ const pending = this.pending.get(root);
41
+ if (pending) return pending;
42
+ const opening = this.start(root, cookie).finally(() => this.pending.delete(root));
43
+ this.pending.set(root, opening);
44
+ return opening;
45
+ }
46
+
47
+ private async start(root: string, cookie?: string): Promise<ProjectInfo> {
48
+ const existing = (await listProjects(this.registry, cookie)).find((project) => project.cwd === root);
49
+ if (this.closed) throw new HttpError(503, "failed", "The project host is closing.");
50
+ if (existing) return existing;
51
+ if (this.children.size >= 8) throw new HttpError(409, "failed", "Eight project sessions are already open. Close a project session before opening another.");
52
+ const child = this.launch(root);
53
+ this.children.add(child);
54
+ void child.whenExited().then(() => this.children.delete(child));
55
+ try { return await this.waitForProject(root, child, cookie); }
56
+ catch (error) { child.stop(); throw error; }
57
+ }
58
+
59
+ private async waitForProject(root: string, child: ProjectProcess, cookie?: string): Promise<ProjectInfo> {
60
+ const deadline = Date.now() + this.timeoutMs;
61
+ while (!this.closed && child.alive && Date.now() < deadline) {
62
+ const project = (await listProjects(this.registry, cookie)).find((entry) => entry.cwd === root);
63
+ if (project && child.alive && !this.closed) return project;
64
+ await delay(100);
65
+ }
66
+ throw new HttpError(503, "failed", "The project did not start. Check that Pi and bot-lobby load successfully, then try again.");
67
+ }
68
+
69
+ close(): void {
70
+ this.closed = true;
71
+ for (const child of this.children) child.stop();
72
+ }
73
+ }
@@ -17,6 +17,7 @@ import type { MemberState, PanelNote, PanelQuestion, PlannerMessage } from "../l
17
17
  import type { QuickFixJob } from "../lobby/quickfix.ts";
18
18
 
19
19
  export interface ProjectInfo { id: string; name: string; cwd: string; port: number }
20
+ export interface FolderListing { path: string; parent?: string; folders: Array<{ name: string; path: string }>; truncated: boolean }
20
21
 
21
22
  export type { WebPrompt };
22
23
  export type { LobbyTopic };
@@ -634,6 +635,8 @@ export interface Api {
634
635
  "quickfix.runAnyway": { request: { id: string }; result: { notice: string } };
635
636
  "quickfix.movedToTask": { request: { id: string }; result: { notice: string; key?: string } };
636
637
  "metrics.get": { request: { groupBy: "model" | "model-kind"; query?: string }; result: MetricsData };
638
+ "projects.browse": { request: { path?: string }; result: FolderListing };
639
+ "projects.open": { request: { path: string }; result: { project: ProjectInfo } };
637
640
  "knowledge.files": { request: Record<string, never>; result: { files: KnowledgeFileInfo[]; models?: Partial<Record<KnowledgeAgentName, string>> } };
638
641
  "knowledge.ask": { request: { text: string; attachments?: string[] }; result: { notice: string } };
639
642
  "knowledge.open": { request: { agent: KnowledgeAgentName; file: string }; result: { view: KnowledgeViewData } };
@@ -10,6 +10,8 @@ import { detectProjectRoot, loadConfig } from "../state/project.ts";
10
10
  import { canonicalRoot } from "../state/previews.ts";
11
11
  import { ProjectRegistry, registryDirectory, projectInfo, listProjects, browserSession } from "./projects.ts";
12
12
  import { gateway } from "./gateway.ts";
13
+ import { browseFolders, folderPath } from "./project-folders.ts";
14
+ import { ProjectOpener } from "./project-open.ts";
13
15
  import { lobbyFeed } from "../lobby/feed.ts";
14
16
  import type { LobbyService } from "../lobby/host.ts";
15
17
  import {
@@ -166,6 +168,7 @@ interface RouteState {
166
168
  root: string;
167
169
  projectRoot: string;
168
170
  registry?: ProjectRegistry;
171
+ opener?: ProjectOpener;
169
172
  }
170
173
 
171
174
  async function login(req: IncomingMessage, res: ServerResponse, state: RouteState): Promise<void> {
@@ -191,6 +194,15 @@ function openStream(req: IncomingMessage, res: ServerResponse, url: URL, state:
191
194
  req.on("close", () => state.hub.remove(res));
192
195
  }
193
196
 
197
+ async function projectFolderRequest(req: IncomingMessage, res: ServerResponse, url: URL, state: RouteState): Promise<void> {
198
+ const body = await readJson(req, false);
199
+ const empty = body && typeof body === "object" && !Array.isArray(body) && Object.keys(body).length === 0;
200
+ const path = folderPath(url.pathname.endsWith(".browse") && empty ? { path: state.projectRoot } : body);
201
+ const result = url.pathname.endsWith(".browse") ? await browseFolders(path)
202
+ : { project: await state.opener!.open(path, browserSession(req.headers.cookie)) };
203
+ sendJson(res, 200, { ok: true, result });
204
+ }
205
+
194
206
  async function routeApi(req: IncomingMessage, res: ServerResponse, url: URL, state: RouteState): Promise<void> {
195
207
  if (req.method !== "POST") {
196
208
  sendError(res, "unsupported", "only POST", 405);
@@ -214,6 +226,7 @@ async function routeApi(req: IncomingMessage, res: ServerResponse, url: URL, sta
214
226
  sendJson(res, 200, { ok: true, result });
215
227
  return;
216
228
  }
229
+ if (url.pathname === "/api/projects.browse" || url.pathname === "/api/projects.open") return projectFolderRequest(req, res, url, state);
217
230
  const name = callName(url);
218
231
  if (name === undefined) {
219
232
  sendError(res, "not_found", `no call ${url.pathname.slice("/api/".length)}`, 404);
@@ -354,6 +367,7 @@ export async function startWebServer(options: WebServerOptions): Promise<WebServ
354
367
  state.port = bound;
355
368
  try { state.registry = new ProjectRegistry(options.registryDir ?? registryDirectory(), state.projectRoot, bound); }
356
369
  catch (error) { hub.close(); server.close(); throw error; }
370
+ state.opener = new ProjectOpener(state.registry);
357
371
  hookExit();
358
372
  const api: WebServer = {
359
373
  link: `http://127.0.0.1:${bound}/#token=${link}`,
@@ -374,6 +388,7 @@ export async function startWebServer(options: WebServerOptions): Promise<WebServ
374
388
  return `http://127.0.0.1:${state.port}/#token=${state.link}`;
375
389
  },
376
390
  close: async () => {
391
+ state.opener!.close();
377
392
  state.registry!.close();
378
393
  hub.close();
379
394
  await new Promise<void>((resolve) => {