@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 +19 -4
- package/package.json +1 -1
- package/src/lobby/runtime.ts +2 -1
- package/src/lobby/sessions.ts +3 -1
- package/src/webui/command.ts +1 -1
- package/src/webui/dev/fake-service.ts +3 -0
- package/src/webui/project-folders.ts +53 -0
- package/src/webui/project-open.ts +73 -0
- package/src/webui/protocol.ts +3 -0
- package/src/webui/server.ts +15 -0
- package/webui/dist/assets/index-BtKUZnAE.css +2 -0
- package/webui/dist/assets/index-CmNcHve-.js +108 -0
- package/webui/dist/build.json +60 -24
- package/webui/dist/index.html +2 -2
- package/webui/dist/licenses/obsidian.txt +21 -0
- package/webui/dist/licenses/reui.txt +9 -0
- package/webui/dist/assets/index-CaSemZv6.js +0 -108
- package/webui/dist/assets/index-J551U6af.css +0 -2
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
|

|
|
83
98
|
|
|
84
|
-
Ask project questions from
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
package/src/lobby/runtime.ts
CHANGED
|
@@ -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 {
|
package/src/lobby/sessions.ts
CHANGED
|
@@ -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
|
|
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
|
};
|
package/src/webui/command.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/webui/protocol.ts
CHANGED
|
@@ -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 } };
|
package/src/webui/server.ts
CHANGED
|
@@ -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) => {
|