@a-t-h-i/bot-lobby 0.6.16 → 0.6.18

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.
@@ -9,6 +9,7 @@ import { LobbyFeed } from "../../lobby/feed.ts";
9
9
  import { promptHub } from "../../lobby/prompt-hub.ts";
10
10
  import { lobbyTopics } from "../../lobby/topics.ts";
11
11
  import type { BackgroundSession, DialogAnswer, SessionDialog } from "../../lobby/sessions.ts";
12
+ import type { PlanComment } from "../../state/comments.ts";
12
13
  import type { LobbyService } from "../../lobby/host.ts";
13
14
  import { loadScenario, type ScenarioFixture } from "./fixtures.ts";
14
15
 
@@ -40,6 +41,13 @@ function fakePlanner(entry: NonNullable<ScenarioFixture["mockPlanner"]>): Record
40
41
  async send(text: string): Promise<void> {
41
42
  (session.messages as Array<Record<string, unknown>>).push({ role: "you", text, at: Date.now() });
42
43
  },
44
+ async editMessage(messageIndex: number, at: number, text: string): Promise<void> {
45
+ const message = session.messages[messageIndex];
46
+ if (session.busy || !message || message.at !== at) throw new Error("the message has changed");
47
+ if (message.role !== "you" || (Array.isArray(message.settled) && message.settled.length > 0)) throw new Error("only ordinary user messages can be edited");
48
+ if (!text.trim()) throw new Error("an edited message needs some text");
49
+ session.messages[messageIndex] = { ...message, text: text.trim(), editedAt: Date.now() };
50
+ },
43
51
  async retry(): Promise<void> {},
44
52
  commentOnLine(line: string, text: string): boolean {
45
53
  session.lineComments.push({ line, text });
@@ -464,10 +472,20 @@ export function createFixtureService(name: string, feed = new LobbyFeed()): Lobb
464
472
  checkExcalidraw: async () => ({ ok: true, text: "reached the server; nobody has this session open yet — open the link in Excalidraw, and agents can read and draw" }),
465
473
  comments: (taskId: string) => [...(comments.get(taskId) ?? [])],
466
474
  comment: (taskId: string, text: string) => {
467
- const entry = { id: `C-mock-${(commentSeq += 1)}`, taskId, text, createdAt: new Date().toISOString(), status: "open" };
475
+ const entry = { id: `C-mock-${(commentSeq += 1)}`, taskId, text, by: fixture.status.sessionId, createdAt: new Date().toISOString(), status: "open" };
468
476
  comments.set(taskId, [...(comments.get(taskId) ?? []), entry]);
469
477
  return "comment sent to the oracle — it will amend the plan";
470
478
  },
479
+ editComment: (taskId: string, commentId: string, text: string) => {
480
+ const existing = (comments.get(taskId) ?? []).find((entry) => entry.id === commentId);
481
+ if (!existing) throw new Error(`no comment ${commentId}`);
482
+ if (!existing.by || existing.by !== fixture.status.sessionId) throw new Error("only your own comments can be edited");
483
+ const entry: Record<string, unknown> = { ...existing, text: text.trim(), editedAt: new Date().toISOString(), status: "open" };
484
+ delete entry.deliveredAt;
485
+ delete entry.addressedAt;
486
+ comments.set(taskId, [...(comments.get(taskId) ?? []).map((other) => (other.id === commentId ? entry : other))]);
487
+ return entry as unknown as PlanComment;
488
+ },
471
489
  startPlanned: (plan: { id: string }) => `starting ${plan.id} here — its agreed plan needs no approval…`,
472
490
  discardPlan: (id: string) => {
473
491
  const index = plans.findIndex((plan) => plan.id === id);
@@ -7,7 +7,7 @@
7
7
  import type { ServerResponse } from "node:http";
8
8
  import { readFile, realpath } from "node:fs/promises";
9
9
  import { extname, join, relative, resolve, sep } from "node:path";
10
- import { tmpdir } from "node:os";
10
+ import { previewRoot } from "../state/previews.ts";
11
11
  import { previewDir } from "../ask/relay.ts";
12
12
  import { applyBaseHeaders, HttpError, sendError } from "./api/index.ts";
13
13
 
@@ -19,20 +19,16 @@ const PREVIEW_TYPES: Record<string, string> = {
19
19
  ".webp": "image/webp",
20
20
  };
21
21
 
22
- function previewsRoot(): string {
23
- return join(tmpdir(), "bot-lobby-previews");
24
- }
25
-
26
22
  /** Whether `task`/`name` name a file without reaching outside their folder. */
27
23
  export function isPreviewName(task: string, name: string): boolean {
28
- if (!/^[\w.-]+$/.test(task) || !/^[\w.-]+$/.test(name)) return false;
24
+ if (!/^[\w.-]+$/.test(task) || task === "." || task === ".." || !/^[\w.-]+$/.test(name)) return false;
29
25
  const ext = extname(name).toLowerCase();
30
26
  return PREVIEW_TYPES[ext] !== undefined;
31
27
  }
32
28
 
33
29
  /** An absolute image path under the previews root as its preview URL, if it is one. */
34
- export function previewUrlFor(absPath: string): string | undefined {
35
- const root = previewsRoot();
30
+ export function previewUrlFor(absPath: string, projectRoot?: string): string | undefined {
31
+ const root = previewRoot(projectRoot);
36
32
  const resolved = resolve(absPath);
37
33
  if (resolved !== root && !resolved.startsWith(root + sep)) return undefined;
38
34
  const rel = relative(root, resolved);
@@ -42,27 +38,29 @@ export function previewUrlFor(absPath: string): string | undefined {
42
38
  }
43
39
 
44
40
  /** Every absolute preview path in a prompt payload as its preview URL. */
45
- export function rewritePreviewImages(payload: unknown): unknown {
46
- if (typeof payload === "string") return previewUrlFor(payload) ?? payload;
47
- if (Array.isArray(payload)) return payload.map(rewritePreviewImages);
41
+ export function rewritePreviewImages(payload: unknown, root?: string): unknown {
42
+ if (typeof payload === "string") return previewUrlFor(payload, root) ?? payload;
43
+ if (Array.isArray(payload)) return payload.map((value) => rewritePreviewImages(value, root));
48
44
  if (payload && typeof payload === "object") {
49
- return Object.fromEntries(Object.entries(payload as Record<string, unknown>).map(([key, value]) => [key, rewritePreviewImages(value)]));
45
+ return Object.fromEntries(Object.entries(payload as Record<string, unknown>).map(([key, value]) => [key, rewritePreviewImages(value, root)]));
50
46
  }
51
47
  return payload;
52
48
  }
53
49
 
54
50
  /** The preview file's bytes, jailed to its task's preview dir, or undefined when refused. */
55
- export async function readPreview(task: string, name: string): Promise<{ body: Buffer; type: string } | undefined> {
51
+ export async function readPreview(task: string, name: string, root?: string): Promise<{ body: Buffer; type: string } | undefined> {
56
52
  if (!isPreviewName(task, name)) return undefined;
57
53
  let real: string;
58
54
  try {
59
- real = await realpath(join(previewDir(task), name));
55
+ real = await realpath(join(previewDir(task, root), name));
60
56
  } catch {
61
57
  return undefined;
62
58
  }
63
59
  let base: string;
64
60
  try {
65
- base = await realpath(previewDir(task));
61
+ base = await realpath(previewDir(task, root));
62
+ const jail = await realpath(previewRoot(root));
63
+ if (!base.startsWith(jail + sep)) return undefined;
66
64
  } catch {
67
65
  return undefined;
68
66
  }
@@ -75,12 +73,12 @@ export async function readPreview(task: string, name: string): Promise<{ body: B
75
73
  }
76
74
 
77
75
  /** Serve one preview; 401 without the cookie, 404 for anything jailed away. */
78
- export async function servePreview(res: ServerResponse, task: string, name: string, signedIn: boolean): Promise<void> {
76
+ export async function servePreview(res: ServerResponse, task: string, name: string, signedIn: boolean, root?: string): Promise<void> {
79
77
  if (!signedIn) {
80
78
  sendError(res, "unauthorized", "open the link Pi printed", 401);
81
79
  return;
82
80
  }
83
- const found = await readPreview(task, name);
81
+ const found = await readPreview(task, name, root);
84
82
  if (!found) throw new HttpError(404, "not_found", "no such preview");
85
83
  applyBaseHeaders(res);
86
84
  res.writeHead(200, { "Content-Type": found.type, "Cache-Control": "no-store" });
@@ -0,0 +1,64 @@
1
+ import { request, type IncomingMessage, type ServerResponse } from "node:http";
2
+ import { applyBaseHeaders, HttpError, sendError } from "./api/index.ts";
3
+ import { browserSession, probeProject, scanProjects, type ProjectRegistry, type ProjectRecord } from "./projects.ts";
4
+
5
+ /** Only API and preview paths are project-owned; static files stay at entry. */
6
+ export function projectRoute(path: string): { id: string; path: string } | undefined {
7
+ const match = /^\/projects\/([a-f0-9]{32})(\/(?:api\/[^/]+|files\/preview\/[^/]+\/[^/]+))$/.exec(path);
8
+ if (!match) return undefined;
9
+ return { id: match[1]!, path: match[2]! };
10
+ }
11
+
12
+ export async function gateway(req: IncomingMessage, res: ServerResponse, url: URL, registry: ProjectRegistry): Promise<URL | undefined> {
13
+ const target = projectRoute(url.pathname);
14
+ if (!target) throw new HttpError(404, "not_found", "no such project route");
15
+ if (/^\/api\/(auth\.login|projects\.)/.test(target.path)) throw new HttpError(403, "forbidden", "entry-only call");
16
+ if (target.id === registry.record.id) { url.pathname = target.path; return url; }
17
+ const record = scanProjects(registry.dir).find((r) => r.id === target.id);
18
+ if (!record) throw new HttpError(404, "not_found", "no such running project");
19
+ const cookie = browserSession(req.headers.cookie);
20
+ const health = await probeProject(record, cookie);
21
+ if (health === "unauthorized") { sendError(res, "unauthorized", "open the link Pi printed", 401); return; }
22
+ if (health !== "healthy") throw new HttpError(503, "failed", "the project is offline or changed");
23
+ await proxy(req, res, record, target.path + url.search, cookie);
24
+ }
25
+
26
+ function requestHeaders(req: IncomingMessage, port: number, cookie?: string): Record<string, string> {
27
+ const headers: Record<string, string> = { Host: `127.0.0.1:${port}`, Origin: `http://127.0.0.1:${port}` };
28
+ for (const name of ["content-type", "content-length", "accept", "last-event-id"]) {
29
+ const value = req.headers[name];
30
+ if (typeof value === "string") headers[name] = value;
31
+ }
32
+ if (cookie) headers.Cookie = cookie;
33
+ return headers;
34
+ }
35
+
36
+ function responseHeaders(res: IncomingMessage): Record<string, string> {
37
+ const headers: Record<string, string> = {};
38
+ const hop = String(res.headers.connection ?? "").toLowerCase().split(",").map((name) => name.trim());
39
+ for (const name of ["content-type", "content-length", "cache-control", "x-content-type-options"]) {
40
+ const value = res.headers[name];
41
+ if (typeof value === "string" && !hop.includes(name)) headers[name] = value;
42
+ }
43
+ return headers;
44
+ }
45
+
46
+ function proxy(req: IncomingMessage, res: ServerResponse, record: ProjectRecord, path: string, cookie?: string): Promise<void> {
47
+ return new Promise((resolve, reject) => {
48
+ const upstream = request({ hostname: "127.0.0.1", port: record.port, path, method: req.method,
49
+ headers: requestHeaders(req, record.port, cookie) }, (response) => {
50
+ if (response.headers["content-type"]?.startsWith("text/event-stream")) clearTimeout(timer);
51
+ applyBaseHeaders(res);
52
+ res.writeHead(response.statusCode ?? 503, responseHeaders(response));
53
+ response.pipe(res);
54
+ response.on("end", () => { clearTimeout(timer); resolve(); });
55
+ response.on("error", fail);
56
+ });
57
+ const fail = () => { clearTimeout(timer); upstream.destroy(); reject(new HttpError(503, "failed", "the project is offline")); };
58
+ const timer = setTimeout(fail, 5000);
59
+ upstream.on("error", fail);
60
+ res.on("close", () => { clearTimeout(timer); upstream.destroy(); resolve(); });
61
+ req.on("aborted", () => upstream.destroy());
62
+ req.pipe(upstream);
63
+ });
64
+ }
@@ -0,0 +1,111 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { chmodSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
3
+ import { request } from "node:http";
4
+ import { basename, join } from "node:path";
5
+ import { globalConfigDir } from "../state/project.ts";
6
+ import { canonicalRoot } from "../state/previews.ts";
7
+ import type { ProjectInfo } from "./protocol.ts";
8
+
9
+ export interface ProjectRecord extends ProjectInfo { pid: number; startedAt: number }
10
+ export const PROJECT_ID = /^[a-f0-9]{32}$/;
11
+ export const registryDirectory = () => join(globalConfigDir(), "web-projects");
12
+ export const projectInfo = ({ id, name, cwd, port }: ProjectInfo): ProjectInfo => ({ id, name, cwd, port });
13
+
14
+ export function livePid(pid: number): boolean {
15
+ try { process.kill(pid, 0); return true; } catch { return false; }
16
+ }
17
+
18
+ function validRecord(r: ProjectRecord): boolean {
19
+ return !!r && PROJECT_ID.test(r.id) && typeof r.name === "string" && typeof r.cwd === "string"
20
+ && r.cwd === canonicalRoot(r.cwd) && Number.isInteger(r.port) && r.port > 0 && r.port <= 65535
21
+ && Number.isInteger(r.pid) && r.pid > 0 && Number.isFinite(r.startedAt) && livePid(r.pid);
22
+ }
23
+
24
+ export function scanProjects(dir: string): ProjectRecord[] {
25
+ let names: string[];
26
+ try { names = readdirSync(dir).sort().slice(0, 128); } catch { return []; }
27
+ return names.flatMap((name) => readRecord(dir, name) ?? []);
28
+ }
29
+
30
+ function readRecord(dir: string, name: string): ProjectRecord | undefined {
31
+ if (!/^[a-f0-9]{32}\.json$/.test(name)) return undefined;
32
+ try {
33
+ const path = join(dir, name);
34
+ const stat = lstatSync(path);
35
+ if (!stat.isFile() || stat.size > 16384) return undefined;
36
+ const record = JSON.parse(readFileSync(path, "utf8")) as ProjectRecord;
37
+ return validRecord(record) && name === `${record.id}.json` ? record : undefined;
38
+ } catch { return undefined; }
39
+ }
40
+
41
+ export class ProjectRegistry {
42
+ record: ProjectRecord;
43
+ readonly dir: string;
44
+ constructor(dir: string, root: string, port: number) {
45
+ this.dir = dir;
46
+ this.record = this.make(root, port);
47
+ this.save();
48
+ }
49
+ private make(root: string, port: number): ProjectRecord {
50
+ const cwd = canonicalRoot(root);
51
+ return { id: randomBytes(16).toString("hex"), name: basename(cwd) || cwd, cwd, port, pid: process.pid, startedAt: Date.now() };
52
+ }
53
+ private save(): void {
54
+ mkdirSync(this.dir, { recursive: true, mode: 0o700 });
55
+ chmodSync(this.dir, 0o700);
56
+ writeFileSync(join(this.dir, `${this.record.id}.json`), JSON.stringify(this.record), { mode: 0o600, flag: "wx" });
57
+ }
58
+ rebind(root: string): void {
59
+ const cwd = canonicalRoot(root);
60
+ const previous = this.record;
61
+ this.close();
62
+ this.record = cwd === previous.cwd ? { ...previous, name: basename(cwd) || cwd } : this.make(cwd, previous.port);
63
+ this.save();
64
+ }
65
+ close(): void { rmSync(join(this.dir, `${this.record.id}.json`), { force: true }); }
66
+ }
67
+
68
+ /** The only browser credential we relay; never forward arbitrary cookies. */
69
+ export function browserSession(cookie?: string): string | undefined {
70
+ return cookie?.split(";").map((s) => s.trim()).find((s) => /^bl_session=[^;\s]+$/.test(s));
71
+ }
72
+
73
+ export function probeProject(record: ProjectRecord, cookie?: string): Promise<"healthy" | "unauthorized" | "offline"> {
74
+ return new Promise((resolve) => {
75
+ const req = request({ hostname: "127.0.0.1", port: record.port, path: "/api/projects.self", method: "POST",
76
+ headers: { "Content-Type": "application/json", ...(cookie ? { Cookie: cookie } : {}) } }, (res) => {
77
+ const chunks: Buffer[] = []; let size = 0;
78
+ res.on("data", (chunk: Buffer) => { size += chunk.length; if (size > 16384) { resolve("offline"); req.destroy(); } else chunks.push(chunk); });
79
+ res.on("end", () => resolve(probeResult(res.statusCode, Buffer.concat(chunks), record)));
80
+ res.on("error", () => resolve("offline"));
81
+ });
82
+ const timer = setTimeout(() => { resolve("offline"); req.destroy(); }, 1000);
83
+ req.on("close", () => clearTimeout(timer));
84
+ req.on("error", () => resolve("offline"));
85
+ req.end("{}");
86
+ });
87
+ }
88
+
89
+ function probeResult(status: number | undefined, bytes: Buffer, record: ProjectRecord): "healthy" | "unauthorized" | "offline" {
90
+ if (status === 401) return "unauthorized";
91
+ try {
92
+ const reply = JSON.parse(bytes.toString());
93
+ const p = reply.result?.project;
94
+ return status === 200 && reply.ok === true && p?.id === record.id && p.cwd === record.cwd && p.port === record.port ? "healthy" : "offline";
95
+ } catch { return "offline"; }
96
+ }
97
+
98
+ export function selectProjects(records: ProjectRecord[], local: ProjectRecord): ProjectInfo[] {
99
+ const grouped = new Map<string, ProjectRecord>();
100
+ for (const r of records.sort((a, b) => b.startedAt - a.startedAt || b.id.localeCompare(a.id))) {
101
+ if (!grouped.has(r.cwd)) grouped.set(r.cwd, r);
102
+ }
103
+ grouped.set(local.cwd, local);
104
+ return [...grouped.values()].map(projectInfo).sort((a, b) => a.name.localeCompare(b.name) || a.cwd.localeCompare(b.cwd));
105
+ }
106
+
107
+ export async function listProjects(registry: ProjectRegistry, cookie?: string): Promise<ProjectInfo[]> {
108
+ const records = scanProjects(registry.dir);
109
+ const healthy = await Promise.all(records.map(async (r) => await probeProject(r, cookie) === "healthy" ? r : undefined));
110
+ return selectProjects(healthy.filter((r): r is ProjectRecord => !!r), registry.record);
111
+ }
@@ -13,6 +13,8 @@ import type { BotLobbyConfig, PanelMember } from "../schemas/configuration.ts";
13
13
  import type { MemberState, PanelNote, PanelQuestion, PlannerMessage } from "../lobby/planner.ts";
14
14
  import type { QuickFixJob } from "../lobby/quickfix.ts";
15
15
 
16
+ export interface ProjectInfo { id: string; name: string; cwd: string; port: number }
17
+
16
18
  export type { WebPrompt };
17
19
  export type { LobbyTopic };
18
20
  export type { DialogAnswer, SessionDialog, TaskRow, PlanComment };
@@ -510,6 +512,7 @@ export interface Api {
510
512
  "tasks.archived": { request: Record<string, never>; result: { rows: TaskRow[] } };
511
513
  "tasks.comments": { request: { taskId: string }; result: { comments: PlanComment[] } };
512
514
  "tasks.comment": { request: { taskId: string; text: string; attachments?: string[] }; result: { notice?: string } };
515
+ "tasks.editComment": { request: { taskId: string; commentId: string; text: string }; result: { comment: PlanComment } };
513
516
  "tasks.archive": { request: { taskId: string }; result: { notice: string } };
514
517
  "tasks.restore": { request: { taskId: string }; result: { notice: string } };
515
518
  "tasks.delete": { request: { taskId: string; where: "list" | "archive" }; result: { notice: string } };
@@ -532,6 +535,7 @@ export interface Api {
532
535
  "planner.get": { request: Record<string, never>; result: PlannerSnapshot };
533
536
  "planner.new": { request: { seed?: PlannerSeedInfo; seats?: PanelMember[] }; result: { notice: string } };
534
537
  "planner.send": { request: { text: string; attachments?: string[] }; result: { notice: string } };
538
+ "planner.editMessage": { request: { messageIndex: number; at: number; text: string }; result: { message: PlannerMessage; notice: string } };
535
539
  "planner.toggleSeat": { request: { member: PanelMember }; result: { notice: string; seated: boolean } };
536
540
  "planner.retry": { request: Record<string, never>; result: { notice: string } };
537
541
  "planner.commentLine": { request: { line: string; text: string }; result: { notice: string } };
@@ -6,7 +6,10 @@
6
6
  */
7
7
  import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
8
8
  import type { AddressInfo } from "node:net";
9
- import { loadConfig } from "../state/project.ts";
9
+ import { detectProjectRoot, loadConfig } from "../state/project.ts";
10
+ import { canonicalRoot } from "../state/previews.ts";
11
+ import { ProjectRegistry, registryDirectory, projectInfo, listProjects, browserSession } from "./projects.ts";
12
+ import { gateway } from "./gateway.ts";
10
13
  import { lobbyFeed } from "../lobby/feed.ts";
11
14
  import type { LobbyService } from "../lobby/host.ts";
12
15
  import {
@@ -35,6 +38,8 @@ export const PORT_RANGE = 20;
35
38
 
36
39
  export interface WebServerOptions {
37
40
  service: LobbyService;
41
+ projectRoot?: string;
42
+ registryDir?: string;
38
43
  /** Base port (config `lobby.web.port` once a later step adds it). */
39
44
  port?: number;
40
45
  /** The built page; defaults to `webui/dist`. */
@@ -82,7 +87,7 @@ function hookExit(): void {
82
87
  }
83
88
 
84
89
  /** Read a JSON body, capped at a megabyte (413 past it, 400 when it is no JSON). */
85
- async function readJson(req: IncomingMessage): Promise<unknown> {
90
+ async function readJson(req: IncomingMessage, allowEmpty = true): Promise<unknown> {
86
91
  const chunks: Buffer[] = [];
87
92
  let size = 0;
88
93
  for await (const chunk of req) {
@@ -92,7 +97,7 @@ async function readJson(req: IncomingMessage): Promise<unknown> {
92
97
  chunks.push(buffer);
93
98
  }
94
99
  const text = Buffer.concat(chunks).toString("utf8");
95
- if (!text.trim()) return {};
100
+ if (!text.trim() && allowEmpty) return {};
96
101
  try {
97
102
  return JSON.parse(text);
98
103
  } catch {
@@ -133,7 +138,7 @@ async function upload(req: IncomingMessage, res: ServerResponse, url: URL, state
133
138
  sendError(res, "bad_request", "the file is empty");
134
139
  return;
135
140
  }
136
- sendJson(res, 200, { ok: true, result: saveUpload(name, url.searchParams.get("type") ?? "", bytes) });
141
+ sendJson(res, 200, { ok: true, result: saveUpload(name, url.searchParams.get("type") ?? "", bytes, state.projectRoot) });
137
142
  }
138
143
 
139
144
  function numParam(url: URL, name: string): number | undefined {
@@ -151,6 +156,8 @@ interface RouteState {
151
156
  guard: LoginGuard;
152
157
  hub: EventHub;
153
158
  root: string;
159
+ projectRoot: string;
160
+ registry?: ProjectRegistry;
154
161
  }
155
162
 
156
163
  async function login(req: IncomingMessage, res: ServerResponse, state: RouteState): Promise<void> {
@@ -190,6 +197,15 @@ async function routeApi(req: IncomingMessage, res: ServerResponse, url: URL, sta
190
197
  sendError(res, "unauthorized", "open the link Pi printed", 401);
191
198
  return;
192
199
  }
200
+ if (url.pathname === "/api/projects.self" || url.pathname === "/api/projects.list") {
201
+ const body = await readJson(req, false);
202
+ if (!body || typeof body !== "object" || Array.isArray(body) || Object.keys(body).length) throw new HttpError(400, "bad_request", "send exactly {}");
203
+ const registry = state.registry!;
204
+ const result = url.pathname.endsWith(".self") ? { project: projectInfo(registry.record) }
205
+ : { projects: await listProjects(registry, browserSession(req.headers.cookie)), currentId: registry.record.id };
206
+ sendJson(res, 200, { ok: true, result });
207
+ return;
208
+ }
193
209
  const name = callName(url);
194
210
  if (name === undefined) {
195
211
  sendError(res, "not_found", `no call ${url.pathname.slice("/api/".length)}`, 404);
@@ -210,7 +226,14 @@ async function route(req: IncomingMessage, res: ServerResponse, state: RouteStat
210
226
  sendError(res, "forbidden", "unknown host", 403);
211
227
  return;
212
228
  }
213
- const url = new URL(req.url ?? "/", "http://127.0.0.1");
229
+ let url = new URL(req.url ?? "/", "http://127.0.0.1");
230
+ if (url.pathname.startsWith("/projects/")) {
231
+ if (isCrossSite(req.headers.origin, req.headers["sec-fetch-site"] as string | undefined, state.port)) throw new HttpError(403, "forbidden", "cross-site request");
232
+ if (!isSignedIn(req.headers.cookie, state.session)) throw new HttpError(401, "unauthorized", "open the link Pi printed");
233
+ const local = await gateway(req, res, url, state.registry!);
234
+ if (!local) return;
235
+ url = local;
236
+ }
214
237
  if (url.pathname.startsWith("/files/preview/")) {
215
238
  if (req.method !== "GET") {
216
239
  sendError(res, "unsupported", "only GET", 405);
@@ -221,7 +244,7 @@ async function route(req: IncomingMessage, res: ServerResponse, state: RouteStat
221
244
  sendError(res, "not_found", "no such preview", 404);
222
245
  return;
223
246
  }
224
- await servePreview(res, names.task, names.name, isSignedIn(req.headers.cookie, state.session));
247
+ await servePreview(res, names.task, names.name, isSignedIn(req.headers.cookie, state.session), state.projectRoot);
225
248
  return;
226
249
  }
227
250
  if (!url.pathname.startsWith("/api/")) {
@@ -288,6 +311,7 @@ export async function startWebServer(options: WebServerOptions): Promise<WebServ
288
311
  guard: new LoginGuard(),
289
312
  hub,
290
313
  root: distDir(options.dist),
314
+ projectRoot: canonicalRoot(options.service.projectRoot?.() ?? options.projectRoot ?? detectProjectRoot(process.cwd())),
291
315
  };
292
316
  const server: Server = createServer((req, res) => {
293
317
  route(req, res, state).catch((error: Error) => {
@@ -318,6 +342,8 @@ export async function startWebServer(options: WebServerOptions): Promise<WebServ
318
342
  throw new Error(`no free port from ${base} to ${base + PORT_RANGE}`);
319
343
  }
320
344
  state.port = bound;
345
+ try { state.registry = new ProjectRegistry(options.registryDir ?? registryDirectory(), state.projectRoot, bound); }
346
+ catch (error) { hub.close(); server.close(); throw error; }
321
347
  hookExit();
322
348
  const api: WebServer = {
323
349
  link: `http://127.0.0.1:${bound}/#token=${link}`,
@@ -325,6 +351,8 @@ export async function startWebServer(options: WebServerOptions): Promise<WebServ
325
351
  clients: () => hub.clients(),
326
352
  rebind: (service) => {
327
353
  state.service = service;
354
+ state.projectRoot = canonicalRoot(service.projectRoot?.() ?? options.projectRoot ?? detectProjectRoot(process.cwd()));
355
+ state.registry!.rebind(state.projectRoot);
328
356
  hub.attach(service);
329
357
  hub.hello();
330
358
  },
@@ -336,6 +364,7 @@ export async function startWebServer(options: WebServerOptions): Promise<WebServ
336
364
  return `http://127.0.0.1:${state.port}/#token=${state.link}`;
337
365
  },
338
366
  close: async () => {
367
+ state.registry!.close();
339
368
  hub.close();
340
369
  await new Promise<void>((resolve) => {
341
370
  server.close(() => resolve());
@@ -6,8 +6,9 @@
6
6
  * them. Images are also served back to the page through the preview route.
7
7
  */
8
8
  import { randomBytes } from "node:crypto";
9
- import { existsSync, mkdirSync, statSync, writeFileSync } from "node:fs";
10
- import { extname, join } from "node:path";
9
+ import { existsSync, mkdirSync, realpathSync, statSync, writeFileSync } from "node:fs";
10
+ import { extname, join, sep } from "node:path";
11
+ import { previewRoot } from "../state/previews.ts";
11
12
  import { previewDir } from "../ask/relay.ts";
12
13
  import { claimAttachments, sweepAttachments } from "../state/attachments.ts";
13
14
  import { ATTACHMENTS_MARK } from "../lobby/prompts.ts";
@@ -44,9 +45,9 @@ function kindOf(mime: string): UploadKind {
44
45
  }
45
46
 
46
47
  /** Save one file; the id is its name in the attachments folder. */
47
- export function saveUpload(name: string, claimedType: string, bytes: Buffer): Upload {
48
- sweepAttachments(STALE_MS);
49
- const dir = previewDir(ATTACHMENT_BUCKET);
48
+ export function saveUpload(name: string, claimedType: string, bytes: Buffer, root?: string): Upload {
49
+ sweepAttachments(STALE_MS, Date.now(), root);
50
+ const dir = previewDir(ATTACHMENT_BUCKET, root);
50
51
  mkdirSync(dir, { recursive: true, mode: 0o700 });
51
52
  const id = `${Date.now().toString(36)}${randomBytes(3).toString("hex")}-${safeName(name)}`;
52
53
  writeFileSync(join(dir, id), bytes, { mode: 0o600 });
@@ -55,14 +56,23 @@ export function saveUpload(name: string, claimedType: string, bytes: Buffer): Up
55
56
  return { id, name: safeName(name), mime, size: bytes.length, kind, ...(kind === "image" ? { url: `/files/preview/${ATTACHMENT_BUCKET}/${id}` } : {}) };
56
57
  }
57
58
 
59
+ function attachmentExists(dir: string, id: string, root?: string): boolean {
60
+ if (!/^[\w.-]+$/.test(id) || !existsSync(join(dir, id))) return false;
61
+ try {
62
+ const base = realpathSync(dir);
63
+ return base.startsWith(realpathSync(previewRoot(root)) + sep)
64
+ && realpathSync(join(dir, id)).startsWith(base + sep) && statSync(join(dir, id)).isFile();
65
+ } catch { return false; }
66
+ }
67
+
58
68
  /** `text`, then the files it carries by path, the way the agents read them; they belong to `taskId` and go with it. */
59
- export function withAttachments(text: string, ids: readonly string[] | undefined, taskId?: string): string {
69
+ export function withAttachments(text: string, ids: readonly string[] | undefined, taskId?: string, root?: string): string {
60
70
  if (!ids || ids.length === 0) return text;
61
- const dir = previewDir(ATTACHMENT_BUCKET);
71
+ const dir = previewDir(ATTACHMENT_BUCKET, root);
62
72
  const lines = ids.slice(0, MAX_ATTACHMENTS).map((id) => {
63
- if (!/^[\w.-]+$/.test(id) || !existsSync(join(dir, id)) || !statSync(join(dir, id)).isFile()) fail(400, "bad_request", `the attachment ${id} is not there; attach it again`);
73
+ if (!attachmentExists(dir, id, root)) fail(400, "bad_request", `the attachment ${id} is not there; attach it again`);
64
74
  return `- ${join(dir, id)} (${mimeOf(id, "")})`;
65
75
  });
66
- if (taskId) claimAttachments(ids.slice(0, MAX_ATTACHMENTS), taskId);
76
+ if (taskId) claimAttachments(ids.slice(0, MAX_ATTACHMENTS), taskId, root);
67
77
  return `${text.trim()}${text.trim() ? "\n\n" : ""}${ATTACHMENTS_MARK}\n${lines.join("\n")}`;
68
78
  }
@@ -64,6 +64,8 @@ import { appendMetrics, metricFromRun } from "../state/metrics.ts";
64
64
  import { markCommentsAddressed, pendingComments, readPlanComments } from "../state/comments.ts";
65
65
  import { fastNext, onFastTrack, parseRoster, qaRequired, qaStillDue, qaTookPart, rosterWords, trackSummary } from "./track.ts";
66
66
  import { missingWorktree, taskCwd } from "../execution/workspace.ts";
67
+ import { createPullRequest } from "../lobby/pulls.ts";
68
+ import type { Exec } from "../lobby/issues.ts";
67
69
 
68
70
  export const ORCHESTRATE_ACTIONS = [
69
71
  "clarify",
@@ -144,6 +146,8 @@ export interface WorkflowDeps {
144
146
  askQuestions?: (questions: AskQuestion[], from: string, signal?: AbortSignal) => Promise<AskResult>;
145
147
  notify: (message: string, level?: "info" | "warning" | "error") => void;
146
148
  runProcess?: ProcessRunner;
149
+ /** Command seam for completion's bounded git/gh calls. */
150
+ exec?: Exec;
147
151
  /** Likely files for scouts and workers, while the classifier's file hints are on. */
148
152
  hints?: FileHinter;
149
153
  /** Picks the relevant sections of long knowledge files for scouts, workers and the QA gate, while the classifier's knowledge picks are on. */
@@ -708,7 +712,7 @@ function askRelay(task: Task, deps: WorkflowDeps, domain: Domain): { onAsk: Rela
708
712
  if (!ASKING_DOMAINS.has(domain) || !deps.askQuestions || isAutoMode(deps.root, deps.configDir, task.id)) return undefined;
709
713
  const askQuestions = deps.askQuestions;
710
714
  const label = AGENT_LABELS[domain];
711
- const previews = previewDir(task.id);
715
+ const previews = previewDir(task.id, deps.root);
712
716
  try {
713
717
  mkdirSync(previews, { recursive: true });
714
718
  } catch {
@@ -1300,13 +1304,43 @@ async function handleComplete(task: Task, params: OrchestrateParams, deps: Workf
1300
1304
  const oversized = overThreshold(readDataRoots(deps.root, deps.configDir), deps.config.knowledge.compactionThreshold);
1301
1305
  if (oversized.length > 0) throw new Error(`cannot complete: knowledge files over the compaction threshold: ${oversized.map((entry) => `${entry.agent}/${entry.file} (${entry.chars})`).join(", ")}. First record domain-relevant facts where they belong with action=knowledge (domain=designer|backend|qa), then rewrite each oversized file with action=compact, then call complete again.`);
1302
1306
  const summary = params.text?.trim() || task.proposal || task.title;
1303
- flushDecisions(deps, task);
1304
1307
  recordCompletion(deps, task, summary);
1305
1308
  removeTaskScratchpads(deps.root, deps.configDir, task.id);
1306
1309
  task.blockers = [];
1307
1310
  if (task.state === "implementing") transition(task, "reviewing");
1308
1311
  transition(task, "completed");
1309
- return `Task ${task.id} completed. History recorded and temporary scratchpads removed.`;
1312
+ const opened = await openPullRequest(task, deps);
1313
+ flushDecisions(deps, task);
1314
+ return `Task ${task.id} completed. History recorded and temporary scratchpads removed.${opened}`;
1315
+ }
1316
+
1317
+ /** Longest pull request body `gh` takes without complaint. */
1318
+ const PR_BODY_CHARS = 60_000;
1319
+
1320
+ /**
1321
+ * Open the task's pull request once it completes, so its branch lands in
1322
+ * review by itself. Every reason it cannot (no isolation, no remote, `gh` not
1323
+ * signed in) is recorded as a note and the completion stands either way.
1324
+ */
1325
+ async function openPullRequest(task: Task, deps: WorkflowDeps): Promise<string> {
1326
+ const git = task.git;
1327
+ if (!git || (git.mode !== "branch" && git.mode !== "worktree")) return pullNote(task, "No pull request opened — task has no git isolation");
1328
+ const base = git.from?.trim();
1329
+ if (!base || /^detached at /i.test(base) || /^[a-f0-9]{40,64}$/i.test(base)) return pullNote(task, "No pull request opened — task has no valid base branch");
1330
+ const body = [task.proposal?.trim() || task.plan?.trim() || task.title, task.qaVerdict ? `QA verdict: ${task.qaVerdict}` : ""].filter(Boolean).join("\n\n").slice(0, PR_BODY_CHARS);
1331
+ try {
1332
+ const pull = await createPullRequest({ exec: deps.exec, cwd: taskCwd(task, deps.cwd), branch: git.branch, base, title: task.title, body });
1333
+ recordDecision(task, `Opened pull request #${pull.number} for ${git.branch}: ${pull.url}`);
1334
+ deps.notify(`Pull request #${pull.number} is open: ${pull.url}`, "info");
1335
+ return ` Pull request #${pull.number} is open: ${pull.url}.`;
1336
+ } catch (error) {
1337
+ return pullNote(task, `No pull request opened for ${git.branch} — ${(error as Error).message}`);
1338
+ }
1339
+ }
1340
+
1341
+ function pullNote(task: Task, note: string): string {
1342
+ recordDecision(task, note);
1343
+ return ` ${note}.`;
1310
1344
  }
1311
1345
 
1312
1346
  /** Asked when the oracle completes a task QA has not passed (the user told it to finish, say). */