@larose/pi-web 0.3.0
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/LICENSE +235 -0
- package/README.md +50 -0
- package/THIRD_PARTY_LICENSES.md +40 -0
- package/dist/client/home.js +1619 -0
- package/dist/client/session.js +3703 -0
- package/dist/server/api.js +485 -0
- package/dist/server/cli.js +51 -0
- package/dist/server/directory-browser.js +104 -0
- package/dist/server/errors.js +10 -0
- package/dist/server/event-buffer.js +40 -0
- package/dist/server/extension-ui.js +245 -0
- package/dist/server/git-workspaces.js +559 -0
- package/dist/server/runtime-registry.js +703 -0
- package/dist/server/server.js +190 -0
- package/dist/server/session-repository.js +374 -0
- package/package.json +46 -0
- package/public/home.html +139 -0
- package/public/session.html +144 -0
- package/public/styles.css +2463 -0
- package/screenshots/home.png +0 -0
- package/screenshots/session.png +0 -0
- package/src/client/display-title.ts +36 -0
- package/src/client/event-stream.ts +194 -0
- package/src/client/home.ts +1575 -0
- package/src/client/markdown.ts +98 -0
- package/src/client/message-queue.ts +67 -0
- package/src/client/path-combobox.ts +271 -0
- package/src/client/session.ts +2174 -0
- package/src/client/shared.ts +99 -0
- package/src/client/slash-completion.ts +184 -0
- package/src/client/transcript-activity.ts +188 -0
- package/src/client/usage-format.ts +156 -0
- package/src/client/workspace-browser.ts +36 -0
- package/src/server/api.ts +652 -0
- package/src/server/cli.ts +63 -0
- package/src/server/directory-browser.ts +137 -0
- package/src/server/errors.ts +11 -0
- package/src/server/event-buffer.ts +59 -0
- package/src/server/extension-ui.ts +359 -0
- package/src/server/git-workspaces.ts +750 -0
- package/src/server/runtime-registry.ts +943 -0
- package/src/server/server.ts +248 -0
- package/src/server/session-repository.ts +488 -0
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
import { realpath, stat } from "node:fs/promises";
|
|
2
|
+
import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
3
|
+
import { AppError } from "./errors.js";
|
|
4
|
+
import { validateCwd } from "./session-repository.js";
|
|
5
|
+
const JSON_LIMIT = 64 * 1024;
|
|
6
|
+
function newestModified(sessions) {
|
|
7
|
+
return sessions.reduce((newest, session) => Math.max(newest, Date.parse(session.modified) || 0), 0);
|
|
8
|
+
}
|
|
9
|
+
function workspaceContextFromGit(context) {
|
|
10
|
+
return {
|
|
11
|
+
repositoryRoot: context.repositoryRoot,
|
|
12
|
+
worktreeRoot: context.worktreeRoot,
|
|
13
|
+
relativeCwd: context.relativeCwd,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
function pathIsWithin(parent, candidate) {
|
|
17
|
+
const child = relative(parent, candidate);
|
|
18
|
+
return child === "" || (child !== ".." && !child.startsWith(`..${sep}`) && !isAbsolute(child));
|
|
19
|
+
}
|
|
20
|
+
async function nearestExistingDirectory(path) {
|
|
21
|
+
if (!isAbsolute(path)) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
let candidate = resolve(path);
|
|
25
|
+
while (true) {
|
|
26
|
+
try {
|
|
27
|
+
const canonical = await realpath(candidate);
|
|
28
|
+
if ((await stat(canonical)).isDirectory()) {
|
|
29
|
+
return canonical;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
const code = error.code;
|
|
34
|
+
if (code !== "ENOENT" && code !== "ENOTDIR") {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const parent = dirname(candidate);
|
|
39
|
+
if (parent === candidate) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
candidate = parent;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function compareText(left, right) {
|
|
46
|
+
const folded = left.toLocaleLowerCase().localeCompare(right.toLocaleLowerCase());
|
|
47
|
+
return folded || left.localeCompare(right);
|
|
48
|
+
}
|
|
49
|
+
function compareSessionGroups(left, right) {
|
|
50
|
+
const recency = newestModified(right.sessions) - newestModified(left.sessions);
|
|
51
|
+
if (recency !== 0) {
|
|
52
|
+
return recency;
|
|
53
|
+
}
|
|
54
|
+
const name = compareText(basename(left.cwd), basename(right.cwd));
|
|
55
|
+
return name || compareText(left.cwd, right.cwd);
|
|
56
|
+
}
|
|
57
|
+
function compareWorktrees(group, left, right) {
|
|
58
|
+
if (left.isLinkedWorktree !== right.isLinkedWorktree) {
|
|
59
|
+
return left.isLinkedWorktree ? 1 : -1;
|
|
60
|
+
}
|
|
61
|
+
const sessionsFor = (worktreeRoot) => group.sessions.filter((session) => session.workspaceContext?.worktreeRoot === worktreeRoot);
|
|
62
|
+
const leftModified = newestModified(sessionsFor(left.worktreeRoot));
|
|
63
|
+
const rightModified = newestModified(sessionsFor(right.worktreeRoot));
|
|
64
|
+
if (leftModified !== rightModified) {
|
|
65
|
+
return rightModified - leftModified;
|
|
66
|
+
}
|
|
67
|
+
const leftHead = left.head.type === "branch" ? left.head.name : left.head.shortCommit;
|
|
68
|
+
const rightHead = right.head.type === "branch" ? right.head.name : right.head.shortCommit;
|
|
69
|
+
return compareText(leftHead, rightHead) || compareText(left.worktreeRoot, right.worktreeRoot);
|
|
70
|
+
}
|
|
71
|
+
async function homepageSessionListing(context) {
|
|
72
|
+
const listing = await context.repository.list();
|
|
73
|
+
const inspections = new Map();
|
|
74
|
+
const inspect = (cwd) => {
|
|
75
|
+
let inspection = inspections.get(cwd);
|
|
76
|
+
if (!inspection) {
|
|
77
|
+
inspection = context.gitWorkspaces.inspect(cwd).catch(() => null);
|
|
78
|
+
inspections.set(cwd, inspection);
|
|
79
|
+
}
|
|
80
|
+
return inspection;
|
|
81
|
+
};
|
|
82
|
+
const sessions = await Promise.all(listing.groups.flatMap((group) => group.sessions.map(async (session) => {
|
|
83
|
+
const gitContext = (await inspect(session.cwd))?.context ?? null;
|
|
84
|
+
let workspaceContext = gitContext ? workspaceContextFromGit(gitContext) : null;
|
|
85
|
+
if (!workspaceContext) {
|
|
86
|
+
const ancestor = await nearestExistingDirectory(session.cwd);
|
|
87
|
+
const ancestorContext = ancestor ? (await inspect(ancestor))?.context : null;
|
|
88
|
+
const requestedCwd = resolve(session.cwd);
|
|
89
|
+
if (ancestorContext && pathIsWithin(ancestorContext.worktreeRoot, requestedCwd)) {
|
|
90
|
+
workspaceContext = {
|
|
91
|
+
repositoryRoot: ancestorContext.repositoryRoot,
|
|
92
|
+
worktreeRoot: ancestorContext.worktreeRoot,
|
|
93
|
+
relativeCwd: relative(ancestorContext.worktreeRoot, requestedCwd) || ".",
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return { ...session, gitContext, workspaceContext };
|
|
98
|
+
})));
|
|
99
|
+
sessions.sort((left, right) => newestModified([right]) - newestModified([left]));
|
|
100
|
+
const repositories = new Map();
|
|
101
|
+
const standalone = new Map();
|
|
102
|
+
for (const session of sessions) {
|
|
103
|
+
const workspaceContext = session.workspaceContext;
|
|
104
|
+
if (!workspaceContext) {
|
|
105
|
+
const group = standalone.get(session.cwd) ?? { type: "standalone", cwd: session.cwd, sessions: [] };
|
|
106
|
+
group.sessions.push(session);
|
|
107
|
+
standalone.set(group.cwd, group);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
const group = repositories.get(workspaceContext.repositoryRoot) ?? {
|
|
111
|
+
type: "repository",
|
|
112
|
+
cwd: workspaceContext.repositoryRoot,
|
|
113
|
+
repositoryRoot: workspaceContext.repositoryRoot,
|
|
114
|
+
sessions: [],
|
|
115
|
+
worktrees: [],
|
|
116
|
+
};
|
|
117
|
+
group.sessions.push(session);
|
|
118
|
+
repositories.set(group.repositoryRoot, group);
|
|
119
|
+
}
|
|
120
|
+
await Promise.all([...repositories.values()].map(async (group) => {
|
|
121
|
+
const rootInspection = await inspect(group.repositoryRoot);
|
|
122
|
+
if (rootInspection?.context?.repositoryRoot === group.repositoryRoot) {
|
|
123
|
+
group.worktrees.push(...rootInspection.worktrees);
|
|
124
|
+
}
|
|
125
|
+
for (const session of group.sessions) {
|
|
126
|
+
const gitContext = session.gitContext;
|
|
127
|
+
if (!gitContext || group.worktrees.some((worktree) => worktree.worktreeRoot === gitContext.worktreeRoot)) {
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
group.worktrees.push({
|
|
131
|
+
worktreeRoot: gitContext.worktreeRoot,
|
|
132
|
+
cwd: gitContext.worktreeRoot,
|
|
133
|
+
head: gitContext.head,
|
|
134
|
+
isLinkedWorktree: gitContext.isLinkedWorktree,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
group.worktrees.sort((left, right) => compareWorktrees(group, left, right));
|
|
138
|
+
}));
|
|
139
|
+
return {
|
|
140
|
+
...listing,
|
|
141
|
+
groups: [...repositories.values(), ...standalone.values()].sort(compareSessionGroups),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
function writeJson(response, status, data) {
|
|
145
|
+
const body = JSON.stringify(data);
|
|
146
|
+
response.writeHead(status, {
|
|
147
|
+
"cache-control": "no-store",
|
|
148
|
+
"content-length": Buffer.byteLength(body),
|
|
149
|
+
"content-type": "application/json; charset=utf-8",
|
|
150
|
+
});
|
|
151
|
+
response.end(body);
|
|
152
|
+
}
|
|
153
|
+
async function readJson(request) {
|
|
154
|
+
const contentType = request.headers["content-type"]?.split(";", 1)[0]?.trim().toLowerCase();
|
|
155
|
+
if (contentType !== "application/json") {
|
|
156
|
+
throw new AppError("unsupported_media_type", "Expected Content-Type: application/json", 415);
|
|
157
|
+
}
|
|
158
|
+
const chunks = [];
|
|
159
|
+
let length = 0;
|
|
160
|
+
for await (const chunk of request) {
|
|
161
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
162
|
+
length += buffer.length;
|
|
163
|
+
if (length > JSON_LIMIT) {
|
|
164
|
+
throw new AppError("body_too_large", "Request body exceeds 64 KiB", 413);
|
|
165
|
+
}
|
|
166
|
+
chunks.push(buffer);
|
|
167
|
+
}
|
|
168
|
+
try {
|
|
169
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
throw new AppError("invalid_json", "Request body is not valid JSON");
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
function objectBody(value) {
|
|
176
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
177
|
+
throw new AppError("invalid_body", "Expected a JSON object");
|
|
178
|
+
}
|
|
179
|
+
return value;
|
|
180
|
+
}
|
|
181
|
+
function sessionName(value) {
|
|
182
|
+
if (value === null || value === "") {
|
|
183
|
+
return undefined;
|
|
184
|
+
}
|
|
185
|
+
if (typeof value !== "string") {
|
|
186
|
+
throw new AppError("invalid_session_name", "Session name must be a string or null");
|
|
187
|
+
}
|
|
188
|
+
const hasControlCharacter = Array.from(value).some((character) => {
|
|
189
|
+
const code = character.charCodeAt(0);
|
|
190
|
+
return code <= 31 || code === 127;
|
|
191
|
+
});
|
|
192
|
+
if (hasControlCharacter) {
|
|
193
|
+
throw new AppError("invalid_session_name", "Session name must be a single line without control characters");
|
|
194
|
+
}
|
|
195
|
+
const normalized = value.trim();
|
|
196
|
+
if (!normalized) {
|
|
197
|
+
return undefined;
|
|
198
|
+
}
|
|
199
|
+
if (Array.from(normalized).length > 120) {
|
|
200
|
+
throw new AppError("invalid_session_name", "Session name must be at most 120 characters");
|
|
201
|
+
}
|
|
202
|
+
return normalized;
|
|
203
|
+
}
|
|
204
|
+
function extensionUIResponse(value) {
|
|
205
|
+
const body = objectBody(value);
|
|
206
|
+
if (typeof body.id !== "string" || body.id === "") {
|
|
207
|
+
throw new AppError("invalid_ui_response", "Extension UI response id must be a string");
|
|
208
|
+
}
|
|
209
|
+
if (body.cancelled === true) {
|
|
210
|
+
return { id: body.id, cancelled: true };
|
|
211
|
+
}
|
|
212
|
+
if (typeof body.confirmed === "boolean") {
|
|
213
|
+
return { id: body.id, confirmed: body.confirmed };
|
|
214
|
+
}
|
|
215
|
+
if (typeof body.value === "string") {
|
|
216
|
+
return { id: body.id, value: body.value };
|
|
217
|
+
}
|
|
218
|
+
throw new AppError("invalid_ui_response", "Extension UI response requires value, confirmed, or cancelled");
|
|
219
|
+
}
|
|
220
|
+
function routeSession(pathname) {
|
|
221
|
+
const match = /^\/api\/sessions\/([^/]+)(?:\/(messages|abort|events|extension-ui|name|pending-steering))?$/.exec(pathname);
|
|
222
|
+
if (!match?.[1]) {
|
|
223
|
+
return undefined;
|
|
224
|
+
}
|
|
225
|
+
try {
|
|
226
|
+
return {
|
|
227
|
+
id: decodeURIComponent(match[1]),
|
|
228
|
+
...(match[2] ? { action: match[2] } : {}),
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
throw new AppError("invalid_path", "The session id is not valid", 400);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
function eventId(value, label) {
|
|
236
|
+
if (value === undefined || value === "") {
|
|
237
|
+
return undefined;
|
|
238
|
+
}
|
|
239
|
+
if (Array.isArray(value) || !/^\d+$/.test(value)) {
|
|
240
|
+
throw new AppError("invalid_event_id", `${label} must be a non-negative integer`);
|
|
241
|
+
}
|
|
242
|
+
const parsed = Number(value);
|
|
243
|
+
if (!Number.isSafeInteger(parsed)) {
|
|
244
|
+
throw new AppError("invalid_event_id", `${label} must be a non-negative safe integer`);
|
|
245
|
+
}
|
|
246
|
+
return parsed;
|
|
247
|
+
}
|
|
248
|
+
function parseLastEventId(request) {
|
|
249
|
+
const queryValues = new URL(request.url ?? "/", "http://127.0.0.1").searchParams.getAll("lastEventId");
|
|
250
|
+
if (queryValues.length > 1) {
|
|
251
|
+
throw new AppError("invalid_event_id", "lastEventId must be supplied at most once");
|
|
252
|
+
}
|
|
253
|
+
const headerId = eventId(request.headers["last-event-id"], "Last-Event-ID");
|
|
254
|
+
const queryId = eventId(queryValues[0], "lastEventId");
|
|
255
|
+
return headerId ?? queryId;
|
|
256
|
+
}
|
|
257
|
+
function writeSse(response, event) {
|
|
258
|
+
response.write(`id: ${event.id}\nevent: runtime\ndata: ${JSON.stringify(event.data)}\n\n`);
|
|
259
|
+
}
|
|
260
|
+
function writeHeartbeat(response) {
|
|
261
|
+
response.write("event: heartbeat\ndata: {}\n\n");
|
|
262
|
+
}
|
|
263
|
+
async function openEvents(request, response, id, context) {
|
|
264
|
+
const afterId = parseLastEventId(request);
|
|
265
|
+
const runtime = await context.runtimes.get(id);
|
|
266
|
+
response.writeHead(200, {
|
|
267
|
+
"cache-control": "no-cache, no-store",
|
|
268
|
+
connection: "keep-alive",
|
|
269
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
270
|
+
"x-accel-buffering": "no",
|
|
271
|
+
});
|
|
272
|
+
response.write("retry: 2000\n\n");
|
|
273
|
+
let closed = false;
|
|
274
|
+
let detach = () => undefined;
|
|
275
|
+
let heartbeat;
|
|
276
|
+
const close = () => {
|
|
277
|
+
if (closed) {
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
closed = true;
|
|
281
|
+
if (heartbeat) {
|
|
282
|
+
clearInterval(heartbeat);
|
|
283
|
+
}
|
|
284
|
+
detach();
|
|
285
|
+
if (!response.writableEnded && !response.destroyed) {
|
|
286
|
+
response.end();
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
detach = runtime.attachClient((event) => {
|
|
290
|
+
if (closed || response.writableEnded || response.destroyed) {
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
writeSse(response, event);
|
|
294
|
+
if (event.data.type === "runtime_disposed") {
|
|
295
|
+
close();
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
heartbeat = setInterval(() => {
|
|
299
|
+
if (!closed && !response.writableEnded && !response.destroyed) {
|
|
300
|
+
writeHeartbeat(response);
|
|
301
|
+
}
|
|
302
|
+
}, 15_000);
|
|
303
|
+
heartbeat.unref();
|
|
304
|
+
const replay = runtime.events.replay(afterId);
|
|
305
|
+
response.write(`event: ready\ndata: ${JSON.stringify({ gap: replay.gap, state: runtime.snapshot() })}\n\n`);
|
|
306
|
+
for (const event of replay.events) {
|
|
307
|
+
writeSse(response, event);
|
|
308
|
+
}
|
|
309
|
+
writeHeartbeat(response);
|
|
310
|
+
request.once("aborted", close);
|
|
311
|
+
request.once("close", close);
|
|
312
|
+
response.once("close", close);
|
|
313
|
+
response.once("error", close);
|
|
314
|
+
}
|
|
315
|
+
export async function handleApi(request, response, pathname, context) {
|
|
316
|
+
if (!pathname.startsWith("/api/")) {
|
|
317
|
+
return false;
|
|
318
|
+
}
|
|
319
|
+
if (pathname === "/api/directories") {
|
|
320
|
+
if (request.method !== "POST") {
|
|
321
|
+
throw new AppError("method_not_allowed", "Method not allowed", 405);
|
|
322
|
+
}
|
|
323
|
+
const body = objectBody(await readJson(request));
|
|
324
|
+
writeJson(response, 200, await context.directories.suggest(body.path));
|
|
325
|
+
return true;
|
|
326
|
+
}
|
|
327
|
+
if (pathname === "/api/git-workspaces/inspect") {
|
|
328
|
+
if (request.method !== "POST") {
|
|
329
|
+
throw new AppError("method_not_allowed", "Method not allowed", 405);
|
|
330
|
+
}
|
|
331
|
+
const body = objectBody(await readJson(request));
|
|
332
|
+
const cwd = await validateCwd(body.cwd);
|
|
333
|
+
const inspection = await context.gitWorkspaces.inspect(cwd);
|
|
334
|
+
writeJson(response, 200, { cwd, ...inspection });
|
|
335
|
+
return true;
|
|
336
|
+
}
|
|
337
|
+
if (pathname === "/api/git-workspaces/worktrees") {
|
|
338
|
+
if (request.method !== "DELETE") {
|
|
339
|
+
throw new AppError("method_not_allowed", "Method not allowed", 405);
|
|
340
|
+
}
|
|
341
|
+
const body = objectBody(await readJson(request));
|
|
342
|
+
const protectedCwds = [context.repository.startupCwd];
|
|
343
|
+
for (const id of context.runtimes.activeIds()) {
|
|
344
|
+
const cwd = context.runtimes.getActive(id)?.snapshot().cwd;
|
|
345
|
+
if (cwd) {
|
|
346
|
+
protectedCwds.push(cwd);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
const removed = await context.gitWorkspaces.removeWorktree(await validateCwd(body.cwd), body.worktreeRoot, protectedCwds);
|
|
350
|
+
writeJson(response, 200, { removed });
|
|
351
|
+
return true;
|
|
352
|
+
}
|
|
353
|
+
if (pathname === "/api/sessions") {
|
|
354
|
+
if (request.method === "GET") {
|
|
355
|
+
const listing = await homepageSessionListing(context);
|
|
356
|
+
const activeSessionIds = context.runtimes.activeIds();
|
|
357
|
+
const runningSessionIds = activeSessionIds.filter((id) => context.runtimes.getActive(id)?.snapshot().isWorking);
|
|
358
|
+
writeJson(response, 200, { ...listing, activeSessionIds, runningSessionIds });
|
|
359
|
+
return true;
|
|
360
|
+
}
|
|
361
|
+
if (request.method === "POST") {
|
|
362
|
+
const body = objectBody(await readJson(request));
|
|
363
|
+
const hasWorktreeName = Object.hasOwn(body, "worktreeName") && body.worktreeName !== undefined;
|
|
364
|
+
let preparation;
|
|
365
|
+
if (hasWorktreeName) {
|
|
366
|
+
preparation = await context.gitWorkspaces.prepareWorktree(await validateCwd(body.cwd), body.worktreeName);
|
|
367
|
+
}
|
|
368
|
+
let created;
|
|
369
|
+
try {
|
|
370
|
+
created = await context.repository.createSession(preparation?.cwd ?? body.cwd);
|
|
371
|
+
}
|
|
372
|
+
catch (error) {
|
|
373
|
+
if (!preparation) {
|
|
374
|
+
throw error;
|
|
375
|
+
}
|
|
376
|
+
try {
|
|
377
|
+
await preparation.rollback();
|
|
378
|
+
}
|
|
379
|
+
catch (cleanupError) {
|
|
380
|
+
const creationMessage = error instanceof AppError ? error.message : "Could not create the Pi session";
|
|
381
|
+
const cleanupMessage = cleanupError instanceof AppError ? cleanupError.message : "the new Git resources could not be removed";
|
|
382
|
+
throw new AppError("session_create_cleanup_failed", `${creationMessage}. Cleanup also failed: ${cleanupMessage}`, 500, { cause: error });
|
|
383
|
+
}
|
|
384
|
+
throw error;
|
|
385
|
+
}
|
|
386
|
+
const gitContext = (await context.gitWorkspaces.inspect(created.session.cwd).catch(() => undefined))?.context ?? null;
|
|
387
|
+
writeJson(response, 201, { session: { ...created.session, gitContext } });
|
|
388
|
+
return true;
|
|
389
|
+
}
|
|
390
|
+
throw new AppError("method_not_allowed", "Method not allowed", 405);
|
|
391
|
+
}
|
|
392
|
+
const route = routeSession(pathname);
|
|
393
|
+
if (!route) {
|
|
394
|
+
throw new AppError("not_found", "API route not found", 404);
|
|
395
|
+
}
|
|
396
|
+
if (!route.action && request.method === "GET") {
|
|
397
|
+
const session = await context.repository.get(route.id);
|
|
398
|
+
const runtime = context.runtimes.getActive(route.id);
|
|
399
|
+
const gitContext = (await context.gitWorkspaces.inspect(session.cwd).catch(() => undefined))?.context ?? null;
|
|
400
|
+
writeJson(response, 200, {
|
|
401
|
+
session: { ...session, gitContext },
|
|
402
|
+
runtime: runtime?.snapshot() ?? null,
|
|
403
|
+
});
|
|
404
|
+
return true;
|
|
405
|
+
}
|
|
406
|
+
if (!route.action && request.method === "DELETE") {
|
|
407
|
+
await context.runtimes.delete(route.id);
|
|
408
|
+
writeJson(response, 200, { deleted: true });
|
|
409
|
+
return true;
|
|
410
|
+
}
|
|
411
|
+
if (route.action === "messages" && request.method === "POST") {
|
|
412
|
+
const body = objectBody(await readJson(request));
|
|
413
|
+
if (typeof body.message !== "string") {
|
|
414
|
+
throw new AppError("invalid_message", "Message must be a string");
|
|
415
|
+
}
|
|
416
|
+
const runtime = await context.runtimes.get(route.id);
|
|
417
|
+
const result = await runtime.send(body.message);
|
|
418
|
+
writeJson(response, 202, { ...result, state: runtime.snapshot() });
|
|
419
|
+
return true;
|
|
420
|
+
}
|
|
421
|
+
if (route.action === "abort" && request.method === "POST") {
|
|
422
|
+
objectBody(await readJson(request));
|
|
423
|
+
await context.repository.get(route.id);
|
|
424
|
+
const runtime = context.runtimes.getActive(route.id);
|
|
425
|
+
const result = runtime ? await runtime.abort() : { restoredMessages: [] };
|
|
426
|
+
writeJson(response, 200, result);
|
|
427
|
+
return true;
|
|
428
|
+
}
|
|
429
|
+
if (route.action === "pending-steering" && request.method === "DELETE") {
|
|
430
|
+
const body = objectBody(await readJson(request));
|
|
431
|
+
if (!Number.isSafeInteger(body.index) || body.index < 0) {
|
|
432
|
+
throw new AppError("invalid_pending_message", "Pending message index must be a non-negative integer");
|
|
433
|
+
}
|
|
434
|
+
if (typeof body.message !== "string") {
|
|
435
|
+
throw new AppError("invalid_pending_message", "Pending message text must be a string");
|
|
436
|
+
}
|
|
437
|
+
if (!Array.isArray(body.queue) || body.queue.some((message) => typeof message !== "string")) {
|
|
438
|
+
throw new AppError("invalid_pending_message", "Pending message queue must be an array of strings");
|
|
439
|
+
}
|
|
440
|
+
const runtime = await context.runtimes.get(route.id);
|
|
441
|
+
await runtime.removePendingSteering(body.index, body.message, body.queue);
|
|
442
|
+
writeJson(response, 200, { state: runtime.snapshot() });
|
|
443
|
+
return true;
|
|
444
|
+
}
|
|
445
|
+
if (route.action === "name" && request.method === "PUT") {
|
|
446
|
+
const body = objectBody(await readJson(request));
|
|
447
|
+
if (!("name" in body)) {
|
|
448
|
+
throw new AppError("invalid_session_name", "Session name is required");
|
|
449
|
+
}
|
|
450
|
+
const runtime = await context.runtimes.get(route.id);
|
|
451
|
+
const name = runtime.setSessionName(sessionName(body.name));
|
|
452
|
+
writeJson(response, 200, { name: name ?? null, state: runtime.snapshot() });
|
|
453
|
+
return true;
|
|
454
|
+
}
|
|
455
|
+
if (route.action === "extension-ui" && request.method === "POST") {
|
|
456
|
+
const responseBody = extensionUIResponse(await readJson(request));
|
|
457
|
+
const runtime = await context.runtimes.get(route.id);
|
|
458
|
+
runtime.respondToExtensionUI(responseBody);
|
|
459
|
+
writeJson(response, 200, { accepted: true });
|
|
460
|
+
return true;
|
|
461
|
+
}
|
|
462
|
+
if (route.action === "events" && request.method === "GET") {
|
|
463
|
+
await openEvents(request, response, route.id, context);
|
|
464
|
+
return true;
|
|
465
|
+
}
|
|
466
|
+
throw new AppError("method_not_allowed", "Method not allowed", 405);
|
|
467
|
+
}
|
|
468
|
+
export function writeApiError(response, error) {
|
|
469
|
+
if (response.headersSent || response.writableEnded) {
|
|
470
|
+
if (!response.writableEnded) {
|
|
471
|
+
response.end();
|
|
472
|
+
}
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
if (error instanceof AppError) {
|
|
476
|
+
writeJson(response, error.status, { error: { code: error.code, message: error.message } });
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
writeJson(response, 500, {
|
|
480
|
+
error: {
|
|
481
|
+
code: "internal_error",
|
|
482
|
+
message: error instanceof Error ? error.message : "Internal server error",
|
|
483
|
+
},
|
|
484
|
+
});
|
|
485
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { startServer } from "./server.js";
|
|
3
|
+
function errorDetails(error) {
|
|
4
|
+
if (!(error instanceof Error)) {
|
|
5
|
+
return String(error);
|
|
6
|
+
}
|
|
7
|
+
const details = error.stack ?? error.message;
|
|
8
|
+
return error.cause === undefined ? details : `${details}\nCaused by: ${errorDetails(error.cause)}`;
|
|
9
|
+
}
|
|
10
|
+
process.on("uncaughtExceptionMonitor", (error, origin) => {
|
|
11
|
+
process.stderr.write(`[pi-web] Uncaught process error (${origin})\n${errorDetails(error)}\n`);
|
|
12
|
+
});
|
|
13
|
+
function readPort(value) {
|
|
14
|
+
if (!value) {
|
|
15
|
+
return 31_415;
|
|
16
|
+
}
|
|
17
|
+
const port = Number(value);
|
|
18
|
+
if (!Number.isInteger(port) || port < 0 || port > 65_535) {
|
|
19
|
+
throw new Error(`Invalid port: ${value}`);
|
|
20
|
+
}
|
|
21
|
+
return port;
|
|
22
|
+
}
|
|
23
|
+
async function main() {
|
|
24
|
+
const port = readPort(process.env.PI_WEB_PORT ?? process.env.PORT);
|
|
25
|
+
const running = await startServer({ port });
|
|
26
|
+
const url = `http://${running.hostname}:${running.port}`;
|
|
27
|
+
process.stdout.write(`Pi Web listening on ${url}\n`);
|
|
28
|
+
let stopping = false;
|
|
29
|
+
const stop = async (signal) => {
|
|
30
|
+
if (stopping) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
stopping = true;
|
|
34
|
+
process.stdout.write(`Pi Web stopping (${signal})\n`);
|
|
35
|
+
try {
|
|
36
|
+
await running.close();
|
|
37
|
+
process.stdout.write("Pi Web stopped\n");
|
|
38
|
+
process.exitCode = 0;
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
process.stderr.write(`[pi-web] Failed to stop cleanly\n${errorDetails(error)}\n`);
|
|
42
|
+
process.exitCode = 1;
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
process.once("SIGINT", () => void stop("SIGINT"));
|
|
46
|
+
process.once("SIGTERM", () => void stop("SIGTERM"));
|
|
47
|
+
}
|
|
48
|
+
main().catch((error) => {
|
|
49
|
+
process.stderr.write(`Pi Web failed to start\n${errorDetails(error)}\n`);
|
|
50
|
+
process.exitCode = 1;
|
|
51
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { opendir, realpath, stat } from "node:fs/promises";
|
|
2
|
+
import { basename, dirname, isAbsolute, resolve } from "node:path";
|
|
3
|
+
import { AppError } from "./errors.js";
|
|
4
|
+
const MAX_DIRECTORY_RESULTS = 50;
|
|
5
|
+
const MAX_SCANNED_ENTRIES = 2_000;
|
|
6
|
+
function unavailableDirectory(error) {
|
|
7
|
+
return new AppError("directory_unavailable", "The directory cannot be read by the server", 400, { cause: error });
|
|
8
|
+
}
|
|
9
|
+
async function canonicalDirectory(path) {
|
|
10
|
+
try {
|
|
11
|
+
const canonical = await realpath(path);
|
|
12
|
+
return (await stat(canonical)).isDirectory() ? canonical : null;
|
|
13
|
+
}
|
|
14
|
+
catch (error) {
|
|
15
|
+
const code = error.code;
|
|
16
|
+
if (code === "ENOENT" || code === "ENOTDIR") {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
throw unavailableDirectory(error);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
async function browseTarget(input) {
|
|
23
|
+
let candidate = resolve(input);
|
|
24
|
+
let prefix = "";
|
|
25
|
+
while (true) {
|
|
26
|
+
const canonical = await canonicalDirectory(candidate);
|
|
27
|
+
if (canonical) {
|
|
28
|
+
return { basePath: canonical, prefix };
|
|
29
|
+
}
|
|
30
|
+
const parent = dirname(candidate);
|
|
31
|
+
if (parent === candidate) {
|
|
32
|
+
throw new AppError("directory_unavailable", "No readable directory exists for this path", 400);
|
|
33
|
+
}
|
|
34
|
+
prefix = basename(candidate);
|
|
35
|
+
candidate = parent;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function compareSuggestions(left, right) {
|
|
39
|
+
const folded = left.name.toLocaleLowerCase().localeCompare(right.name.toLocaleLowerCase());
|
|
40
|
+
return folded || left.name.localeCompare(right.name) || left.path.localeCompare(right.path);
|
|
41
|
+
}
|
|
42
|
+
export class DirectoryBrowser {
|
|
43
|
+
async suggest(input) {
|
|
44
|
+
if (typeof input !== "string" || input.trim() === "") {
|
|
45
|
+
throw new AppError("invalid_directory_path", "An absolute directory path is required");
|
|
46
|
+
}
|
|
47
|
+
const requested = input.trim();
|
|
48
|
+
if (!isAbsolute(requested)) {
|
|
49
|
+
throw new AppError("invalid_directory_path", "The directory path must be absolute");
|
|
50
|
+
}
|
|
51
|
+
const target = await browseTarget(requested);
|
|
52
|
+
const foldedPrefix = target.prefix.toLocaleLowerCase();
|
|
53
|
+
const directories = [];
|
|
54
|
+
const canonicalPaths = new Set();
|
|
55
|
+
let scannedEntries = 0;
|
|
56
|
+
let truncated = false;
|
|
57
|
+
try {
|
|
58
|
+
const handle = await opendir(target.basePath);
|
|
59
|
+
for await (const entry of handle) {
|
|
60
|
+
scannedEntries += 1;
|
|
61
|
+
if (scannedEntries > MAX_SCANNED_ENTRIES) {
|
|
62
|
+
truncated = true;
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
if (!entry.name.toLocaleLowerCase().startsWith(foldedPrefix)) {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const entryPath = resolve(target.basePath, entry.name);
|
|
69
|
+
let canonical;
|
|
70
|
+
try {
|
|
71
|
+
if (!entry.isDirectory() && !entry.isSymbolicLink()) {
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
canonical = await realpath(entryPath);
|
|
75
|
+
if (!(await stat(canonical)).isDirectory()) {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (!canonicalPaths.has(canonical)) {
|
|
83
|
+
canonicalPaths.add(canonical);
|
|
84
|
+
directories.push({ name: entry.name, path: canonical });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
throw unavailableDirectory(error);
|
|
90
|
+
}
|
|
91
|
+
directories.sort(compareSuggestions);
|
|
92
|
+
if (directories.length > MAX_DIRECTORY_RESULTS) {
|
|
93
|
+
directories.length = MAX_DIRECTORY_RESULTS;
|
|
94
|
+
truncated = true;
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
input: requested,
|
|
98
|
+
basePath: target.basePath,
|
|
99
|
+
prefix: target.prefix,
|
|
100
|
+
directories,
|
|
101
|
+
truncated,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export class EventBuffer {
|
|
2
|
+
capacity;
|
|
3
|
+
events = [];
|
|
4
|
+
listeners = new Set();
|
|
5
|
+
nextId = 1;
|
|
6
|
+
constructor(capacity = 512) {
|
|
7
|
+
if (!Number.isInteger(capacity) || capacity < 1) {
|
|
8
|
+
throw new Error("Event capacity must be a positive integer");
|
|
9
|
+
}
|
|
10
|
+
this.capacity = capacity;
|
|
11
|
+
}
|
|
12
|
+
publish(data) {
|
|
13
|
+
const event = { id: this.nextId++, data };
|
|
14
|
+
this.events.push(event);
|
|
15
|
+
if (this.events.length > this.capacity) {
|
|
16
|
+
this.events.splice(0, this.events.length - this.capacity);
|
|
17
|
+
}
|
|
18
|
+
for (const listener of this.listeners) {
|
|
19
|
+
listener(event);
|
|
20
|
+
}
|
|
21
|
+
return event;
|
|
22
|
+
}
|
|
23
|
+
replay(afterId) {
|
|
24
|
+
if (afterId === undefined) {
|
|
25
|
+
return { events: [], gap: false };
|
|
26
|
+
}
|
|
27
|
+
const oldestId = this.events[0]?.id ?? this.nextId;
|
|
28
|
+
return {
|
|
29
|
+
events: this.events.filter((event) => event.id > afterId),
|
|
30
|
+
gap: afterId < oldestId - 1 || afterId >= this.nextId,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
subscribe(listener) {
|
|
34
|
+
this.listeners.add(listener);
|
|
35
|
+
return () => this.listeners.delete(listener);
|
|
36
|
+
}
|
|
37
|
+
get latestId() {
|
|
38
|
+
return this.nextId - 1;
|
|
39
|
+
}
|
|
40
|
+
}
|