@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.
Files changed (43) hide show
  1. package/LICENSE +235 -0
  2. package/README.md +50 -0
  3. package/THIRD_PARTY_LICENSES.md +40 -0
  4. package/dist/client/home.js +1619 -0
  5. package/dist/client/session.js +3703 -0
  6. package/dist/server/api.js +485 -0
  7. package/dist/server/cli.js +51 -0
  8. package/dist/server/directory-browser.js +104 -0
  9. package/dist/server/errors.js +10 -0
  10. package/dist/server/event-buffer.js +40 -0
  11. package/dist/server/extension-ui.js +245 -0
  12. package/dist/server/git-workspaces.js +559 -0
  13. package/dist/server/runtime-registry.js +703 -0
  14. package/dist/server/server.js +190 -0
  15. package/dist/server/session-repository.js +374 -0
  16. package/package.json +46 -0
  17. package/public/home.html +139 -0
  18. package/public/session.html +144 -0
  19. package/public/styles.css +2463 -0
  20. package/screenshots/home.png +0 -0
  21. package/screenshots/session.png +0 -0
  22. package/src/client/display-title.ts +36 -0
  23. package/src/client/event-stream.ts +194 -0
  24. package/src/client/home.ts +1575 -0
  25. package/src/client/markdown.ts +98 -0
  26. package/src/client/message-queue.ts +67 -0
  27. package/src/client/path-combobox.ts +271 -0
  28. package/src/client/session.ts +2174 -0
  29. package/src/client/shared.ts +99 -0
  30. package/src/client/slash-completion.ts +184 -0
  31. package/src/client/transcript-activity.ts +188 -0
  32. package/src/client/usage-format.ts +156 -0
  33. package/src/client/workspace-browser.ts +36 -0
  34. package/src/server/api.ts +652 -0
  35. package/src/server/cli.ts +63 -0
  36. package/src/server/directory-browser.ts +137 -0
  37. package/src/server/errors.ts +11 -0
  38. package/src/server/event-buffer.ts +59 -0
  39. package/src/server/extension-ui.ts +359 -0
  40. package/src/server/git-workspaces.ts +750 -0
  41. package/src/server/runtime-registry.ts +943 -0
  42. package/src/server/server.ts +248 -0
  43. package/src/server/session-repository.ts +488 -0
@@ -0,0 +1,488 @@
1
+ import { type FileHandle, open, realpath, stat, unlink } from "node:fs/promises";
2
+ import { isAbsolute, resolve } from "node:path";
3
+
4
+ import {
5
+ CURRENT_SESSION_VERSION,
6
+ SessionManager,
7
+ sessionEntryToContextMessages,
8
+ type SessionHeader,
9
+ type SessionInfo,
10
+ } from "@earendil-works/pi-coding-agent";
11
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
12
+
13
+ import { AppError } from "./errors.js";
14
+
15
+ export interface SessionSummary {
16
+ id: string;
17
+ cwd: string;
18
+ name?: string;
19
+ created: string;
20
+ modified: string;
21
+ messageCount: number;
22
+ firstMessage: string;
23
+ }
24
+
25
+ export interface PersistedMessage {
26
+ entryId: string;
27
+ message: AgentMessage;
28
+ }
29
+
30
+ export interface PersistedCustomEntry {
31
+ entryId: string;
32
+ customType: string;
33
+ data: unknown;
34
+ }
35
+
36
+ export type PersistedTranscriptEntry =
37
+ | ({ kind: "message" } & PersistedMessage)
38
+ | ({ kind: "custom" } & PersistedCustomEntry);
39
+
40
+ export interface PersistedSession extends SessionSummary {
41
+ transcriptEntries: PersistedTranscriptEntry[];
42
+ model: { provider: string; modelId: string } | null;
43
+ thinkingLevel: string;
44
+ }
45
+
46
+ export interface SessionListing {
47
+ groups: Array<{ cwd: string; sessions: SessionSummary[] }>;
48
+ knownCwds: string[];
49
+ }
50
+
51
+ export interface SessionRepositoryOptions {
52
+ sessionDir?: string;
53
+ }
54
+
55
+ const MAX_SESSION_HEADER_BYTES = 1024 * 1024;
56
+
57
+ function isCurrentSessionHeader(value: unknown): value is SessionHeader & { version: number } {
58
+ if (!value || typeof value !== "object") {
59
+ return false;
60
+ }
61
+
62
+ const header = value as Record<string, unknown>;
63
+ return (
64
+ header.type === "session" &&
65
+ header.version === CURRENT_SESSION_VERSION &&
66
+ typeof header.id === "string" &&
67
+ header.id.trim() !== "" &&
68
+ typeof header.timestamp === "string" &&
69
+ header.timestamp.trim() !== "" &&
70
+ !Number.isNaN(new Date(header.timestamp).getTime()) &&
71
+ typeof header.cwd === "string" &&
72
+ header.cwd.trim() !== "" &&
73
+ (header.parentSession === undefined || typeof header.parentSession === "string")
74
+ );
75
+ }
76
+
77
+ async function readCurrentSessionHeader(path: string): Promise<SessionHeader | null> {
78
+ let file: FileHandle | undefined;
79
+
80
+ try {
81
+ file = await open(path, "r");
82
+ const buffer = Buffer.allocUnsafe(MAX_SESSION_HEADER_BYTES + 1);
83
+ let bytesRead = 0;
84
+ let newline = -1;
85
+
86
+ while (bytesRead < buffer.length && newline === -1) {
87
+ const result = await file.read(buffer, bytesRead, buffer.length - bytesRead, bytesRead);
88
+ if (result.bytesRead === 0) {
89
+ break;
90
+ }
91
+
92
+ newline = buffer.indexOf(0x0a, bytesRead);
93
+ bytesRead += result.bytesRead;
94
+ if (newline >= bytesRead) {
95
+ newline = -1;
96
+ }
97
+ }
98
+
99
+ const bounded = buffer.subarray(0, Math.min(bytesRead, MAX_SESSION_HEADER_BYTES));
100
+ if (newline === -1 && bytesRead > MAX_SESSION_HEADER_BYTES) {
101
+ return null;
102
+ }
103
+
104
+ const line = bounded.subarray(0, newline === -1 ? bounded.length : newline).toString("utf8");
105
+ const parsed: unknown = JSON.parse(line);
106
+ return isCurrentSessionHeader(parsed) ? parsed : null;
107
+ } catch {
108
+ return null;
109
+ } finally {
110
+ if (file) {
111
+ await file.close().catch(() => undefined);
112
+ }
113
+ }
114
+ }
115
+
116
+ function toSummary(info: SessionInfo, header: SessionHeader): SessionSummary {
117
+ return {
118
+ id: header.id,
119
+ cwd: header.cwd,
120
+ ...(info.name ? { name: info.name } : {}),
121
+ created: header.timestamp,
122
+ modified: info.modified.toISOString(),
123
+ messageCount: info.messageCount,
124
+ firstMessage: info.firstMessage,
125
+ };
126
+ }
127
+
128
+ async function canonicalFile(path: string): Promise<string> {
129
+ try {
130
+ const file = await stat(path);
131
+ if (!file.isFile()) {
132
+ throw new AppError("stale_session", "The session path is no longer a file", 410);
133
+ }
134
+
135
+ return await realpath(path);
136
+ } catch (error) {
137
+ if (error instanceof AppError) {
138
+ throw error;
139
+ }
140
+
141
+ throw new AppError("stale_session", "The session file no longer exists or is inaccessible", 410, {
142
+ cause: error,
143
+ });
144
+ }
145
+ }
146
+
147
+ export async function validateCwd(input: unknown): Promise<string> {
148
+ if (typeof input !== "string" || input.trim() === "") {
149
+ throw new AppError("invalid_cwd", "A working directory is required");
150
+ }
151
+
152
+ const candidate = input.trim();
153
+ if (!isAbsolute(candidate)) {
154
+ throw new AppError("invalid_cwd", "The working directory must be an absolute path");
155
+ }
156
+
157
+ try {
158
+ const canonical = await realpath(resolve(candidate));
159
+ const directory = await stat(canonical);
160
+ if (!directory.isDirectory()) {
161
+ throw new AppError("invalid_cwd", "The working directory is not a directory");
162
+ }
163
+
164
+ return canonical;
165
+ } catch (error) {
166
+ if (error instanceof AppError) {
167
+ throw error;
168
+ }
169
+
170
+ throw new AppError("invalid_cwd", "The working directory does not exist or is inaccessible", 400, {
171
+ cause: error,
172
+ });
173
+ }
174
+ }
175
+
176
+ export class SessionRepository {
177
+ readonly startupCwd: string;
178
+
179
+ private readonly sessionDir: string | undefined;
180
+ private pathsById = new Map<string, string>();
181
+ private readonly pendingManagers = new Map<string, SessionManager>();
182
+
183
+ private constructor(startupCwd: string, sessionDir: string | undefined) {
184
+ this.startupCwd = startupCwd;
185
+ this.sessionDir = sessionDir;
186
+ }
187
+
188
+ static async create(startupCwd: string, options: SessionRepositoryOptions = {}): Promise<SessionRepository> {
189
+ const configuredSessionDir = options.sessionDir ?? process.env.PI_CODING_AGENT_SESSION_DIR;
190
+ const sessionDir = configuredSessionDir ? resolve(configuredSessionDir) : undefined;
191
+
192
+ return new SessionRepository(await validateCwd(startupCwd), sessionDir);
193
+ }
194
+
195
+ async list(): Promise<SessionListing> {
196
+ const infos = this.sessionDir ? await SessionManager.listAll(this.sessionDir) : await SessionManager.listAll();
197
+ const nextPaths = new Map<string, string>();
198
+ const groups = new Map<string, SessionSummary[]>();
199
+
200
+ for (const info of infos) {
201
+ const header = await readCurrentSessionHeader(info.path);
202
+ if (!header || header.id !== info.id || nextPaths.has(header.id)) {
203
+ continue;
204
+ }
205
+
206
+ nextPaths.set(header.id, info.path);
207
+ const sessions = groups.get(header.cwd) ?? [];
208
+ sessions.push(toSummary(info, header));
209
+ groups.set(header.cwd, sessions);
210
+ }
211
+
212
+ for (const [id, pending] of [...this.pendingManagers]) {
213
+ if (nextPaths.has(id)) {
214
+ continue;
215
+ }
216
+
217
+ let session: PersistedSession;
218
+ try {
219
+ session = await this.get(id);
220
+ } catch (error) {
221
+ if (!(error instanceof AppError) || error.code !== "invalid_session") {
222
+ throw error;
223
+ }
224
+
225
+ this.pendingManagers.delete(id);
226
+ this.pathsById.delete(id);
227
+ continue;
228
+ }
229
+
230
+ const sessions = groups.get(session.cwd) ?? [];
231
+ sessions.unshift({
232
+ id: session.id,
233
+ cwd: session.cwd,
234
+ ...(session.name ? { name: session.name } : {}),
235
+ created: session.created,
236
+ modified: session.modified,
237
+ messageCount: session.messageCount,
238
+ firstMessage: session.firstMessage,
239
+ });
240
+ groups.set(session.cwd, sessions);
241
+
242
+ const file = pending.getSessionFile();
243
+ if (file) {
244
+ nextPaths.set(id, file);
245
+ }
246
+ }
247
+
248
+ this.pathsById = nextPaths;
249
+
250
+ const knownCwds = [this.startupCwd];
251
+ for (const sessions of groups.values()) {
252
+ const cwd = sessions[0]?.cwd;
253
+ if (cwd && !knownCwds.includes(cwd)) {
254
+ knownCwds.push(cwd);
255
+ }
256
+ }
257
+
258
+ return {
259
+ groups: [...groups].map(([cwd, sessions]) => ({ cwd, sessions })),
260
+ knownCwds,
261
+ };
262
+ }
263
+
264
+ async resolvePath(id: string): Promise<string> {
265
+ if (!id) {
266
+ throw new AppError("unknown_session", "A session id is required", 404);
267
+ }
268
+
269
+ let path = this.pathsById.get(id);
270
+ if (!path) {
271
+ await this.list();
272
+ path = this.pathsById.get(id);
273
+ }
274
+ if (!path) {
275
+ throw new AppError("unknown_session", `Unknown session: ${id}`, 404);
276
+ }
277
+
278
+ return canonicalFile(path);
279
+ }
280
+
281
+ async delete(id: string): Promise<void> {
282
+ if (!id) {
283
+ throw new AppError("unknown_session", "A session id is required", 404);
284
+ }
285
+
286
+ const pending = this.pendingManagers.get(id);
287
+ let path: string;
288
+
289
+ if (pending) {
290
+ const pendingFile = pending.getSessionFile();
291
+ if (!pendingFile) {
292
+ throw new AppError("invalid_session", "The session is not persisted", 422);
293
+ }
294
+
295
+ try {
296
+ await stat(pendingFile);
297
+ } catch (error) {
298
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
299
+ throw new AppError("session_delete_failed", "Could not inspect the session file", 500, {
300
+ cause: error,
301
+ });
302
+ }
303
+
304
+ this.pendingManagers.delete(id);
305
+ this.pathsById.delete(id);
306
+ return;
307
+ }
308
+
309
+ path = await canonicalFile(pendingFile);
310
+ } else {
311
+ path = await this.resolvePath(id);
312
+ }
313
+
314
+ const header = await readCurrentSessionHeader(path);
315
+ if (!header || header.id !== id) {
316
+ throw new AppError("invalid_session", "The session file is not in the current format", 422);
317
+ }
318
+
319
+ try {
320
+ await unlink(path);
321
+ } catch (error) {
322
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
323
+ throw new AppError("stale_session", "The session file no longer exists", 410, { cause: error });
324
+ }
325
+
326
+ throw new AppError("session_delete_failed", "Could not delete the session file", 500, {
327
+ cause: error,
328
+ });
329
+ }
330
+
331
+ this.pendingManagers.delete(id);
332
+ this.pathsById.delete(id);
333
+ }
334
+
335
+ adoptManager(manager: SessionManager): void {
336
+ const id = manager.getSessionId();
337
+ this.pendingManagers.set(id, manager);
338
+
339
+ const file = manager.getSessionFile();
340
+ if (file) {
341
+ this.pathsById.set(id, file);
342
+ }
343
+ }
344
+
345
+ async openManager(id: string): Promise<SessionManager> {
346
+ const pending = this.pendingManagers.get(id);
347
+ if (pending) {
348
+ const pendingFile = pending.getSessionFile();
349
+ if (!pendingFile) {
350
+ return pending;
351
+ }
352
+
353
+ try {
354
+ await stat(pendingFile);
355
+ } catch {
356
+ return pending;
357
+ }
358
+
359
+ const header = await readCurrentSessionHeader(pendingFile);
360
+ if (!header || header.id !== id) {
361
+ throw new AppError("invalid_session", "The session file is not in the current format", 422);
362
+ }
363
+
364
+ this.pendingManagers.delete(id);
365
+ this.pathsById.set(id, pendingFile);
366
+ }
367
+
368
+ const path = await this.resolvePath(id);
369
+ const header = await readCurrentSessionHeader(path);
370
+ if (!header || header.id !== id) {
371
+ throw new AppError("invalid_session", "The session file is not in the current format", 422);
372
+ }
373
+
374
+ try {
375
+ const manager = SessionManager.open(path);
376
+ if (manager.getSessionId() !== id) {
377
+ throw new AppError("stale_session", "The session file id no longer matches", 410);
378
+ }
379
+
380
+ await validateCwd(manager.getCwd());
381
+ return manager;
382
+ } catch (error) {
383
+ if (error instanceof AppError) {
384
+ throw error;
385
+ }
386
+
387
+ throw new AppError("invalid_session", "The session file could not be opened", 422, { cause: error });
388
+ }
389
+ }
390
+
391
+ async get(id: string): Promise<PersistedSession> {
392
+ const manager = await this.openManager(id);
393
+ const context = manager.buildSessionContext();
394
+ const branch = manager.getBranch();
395
+ const transcriptEntries: PersistedTranscriptEntry[] = [];
396
+
397
+ for (const entry of branch) {
398
+ if (entry.type === "message") {
399
+ const persisted = { entryId: entry.id, message: entry.message };
400
+ transcriptEntries.push({ kind: "message", ...persisted });
401
+ continue;
402
+ }
403
+
404
+ if (entry.type === "custom_message") {
405
+ for (const message of sessionEntryToContextMessages(entry)) {
406
+ const persisted = { entryId: entry.id, message };
407
+ transcriptEntries.push({ kind: "message", ...persisted });
408
+ }
409
+ continue;
410
+ }
411
+
412
+ if (entry.type === "custom") {
413
+ const persisted = { entryId: entry.id, customType: entry.customType, data: entry.data };
414
+ transcriptEntries.push({ kind: "custom", ...persisted });
415
+ }
416
+ }
417
+
418
+ const file = manager.getSessionFile();
419
+ if (!file) {
420
+ throw new AppError("invalid_session", "The session is not persisted", 422);
421
+ }
422
+
423
+ const details = await stat(file).catch(() => undefined);
424
+ const header = manager.getHeader();
425
+ if (!isCurrentSessionHeader(header)) {
426
+ throw new AppError("invalid_session", "The session file is not in the current format", 422);
427
+ }
428
+
429
+ const name = manager.getSessionName();
430
+ const created = header.timestamp;
431
+
432
+ return {
433
+ id: manager.getSessionId(),
434
+ cwd: manager.getCwd(),
435
+ ...(name ? { name } : {}),
436
+ created,
437
+ modified: details?.mtime.toISOString() ?? created,
438
+ messageCount: transcriptEntries.reduce((count, entry) => count + (entry.kind === "message" ? 1 : 0), 0),
439
+ firstMessage: this.firstUserText(transcriptEntries),
440
+ transcriptEntries,
441
+ model: context.model,
442
+ thinkingLevel: context.thinkingLevel,
443
+ };
444
+ }
445
+
446
+ async createSession(cwdInput: unknown): Promise<{ manager: SessionManager; session: PersistedSession }> {
447
+ const cwd = await validateCwd(cwdInput);
448
+
449
+ let manager: SessionManager;
450
+ try {
451
+ manager = SessionManager.create(cwd, this.sessionDir);
452
+ } catch (error) {
453
+ throw new AppError("session_create_failed", "Could not create the session file", 500, { cause: error });
454
+ }
455
+
456
+ const file = manager.getSessionFile();
457
+ if (!file) {
458
+ throw new AppError("session_create_failed", "Pi did not create a persistent session", 500);
459
+ }
460
+
461
+ this.adoptManager(manager);
462
+
463
+ return {
464
+ manager,
465
+ session: await this.get(manager.getSessionId()),
466
+ };
467
+ }
468
+
469
+ private firstUserText(entries: PersistedTranscriptEntry[]): string {
470
+ for (const entry of entries) {
471
+ if (entry.kind !== "message" || entry.message.role !== "user") {
472
+ continue;
473
+ }
474
+
475
+ const { message } = entry;
476
+ if (typeof message.content === "string") {
477
+ return message.content;
478
+ }
479
+
480
+ return message.content
481
+ .filter((part) => part.type === "text")
482
+ .map((part) => part.text)
483
+ .join("");
484
+ }
485
+
486
+ return "";
487
+ }
488
+ }