@omercnet/paseo-omp 0.2.1

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 (63) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/LICENSE +21 -0
  3. package/README.md +110 -0
  4. package/SUPPORT.md +40 -0
  5. package/TESTING.md +147 -0
  6. package/client/hub-icon.tsx +12 -0
  7. package/client/hub-popover.tsx +132 -0
  8. package/client/hub-status.ts +29 -0
  9. package/client/memory-panel.tsx +71 -0
  10. package/client/memory-popover.tsx +70 -0
  11. package/client/omp-config-surface.tsx +1274 -0
  12. package/client/omp-doc-links.ts +117 -0
  13. package/client/omp-plugin-manager.tsx +833 -0
  14. package/client/provider-diagnostics-state.ts +250 -0
  15. package/client/provider-icon.tsx +27 -0
  16. package/client/provider-image.tsx +66 -0
  17. package/client/quota-popover.tsx +150 -0
  18. package/client/quota-state.ts +131 -0
  19. package/client/sessions-popover.tsx +73 -0
  20. package/docs/alpha-release-checklist.md +70 -0
  21. package/docs/configuration.md +122 -0
  22. package/docs/core-provider-issue-audit.md +108 -0
  23. package/docs/installation.md +73 -0
  24. package/index.client.tsx +272 -0
  25. package/index.server.ts +51 -0
  26. package/package.json +84 -0
  27. package/paseo-plugin.json +5 -0
  28. package/server/hub.ts +145 -0
  29. package/server/memory.ts +86 -0
  30. package/server/mutation-queue.ts +12 -0
  31. package/server/omp-config.ts +126 -0
  32. package/server/omp-plugins.ts +627 -0
  33. package/server/omp-settings.ts +291 -0
  34. package/server/paths.ts +64 -0
  35. package/server/provider/catalog.ts +173 -0
  36. package/server/provider/config-normalization.ts +148 -0
  37. package/server/provider/connection.ts +992 -0
  38. package/server/provider/host-tools.ts +706 -0
  39. package/server/provider/image.ts +143 -0
  40. package/server/provider/mcp-transport.ts +394 -0
  41. package/server/provider/omp-rpc.ts +2739 -0
  42. package/server/provider/omp.svg +5 -0
  43. package/server/provider/provider-options.ts +27 -0
  44. package/server/provider/registration.ts +151 -0
  45. package/server/provider/security.ts +317 -0
  46. package/server/provider/session-descriptors.ts +431 -0
  47. package/server/provider/session.ts +4451 -0
  48. package/server/provider/settings.ts +78 -0
  49. package/server/provider/subsessions.ts +847 -0
  50. package/server/provider/timeline-projector.ts +1764 -0
  51. package/server/provider-diagnostics.ts +1057 -0
  52. package/server/quota.ts +54 -0
  53. package/server/sessions.ts +58 -0
  54. package/shared/hub.ts +43 -0
  55. package/shared/memory.ts +23 -0
  56. package/shared/omp-config.ts +81 -0
  57. package/shared/omp-plugins.ts +223 -0
  58. package/shared/omp-settings.ts +207 -0
  59. package/shared/provider-diagnostics.ts +117 -0
  60. package/shared/provider-image.ts +160 -0
  61. package/shared/quota.ts +22 -0
  62. package/shared/sessions.ts +23 -0
  63. package/tsconfig.json +16 -0
@@ -0,0 +1,431 @@
1
+ import { constants, type Dir } from "node:fs";
2
+ import { type FileHandle, open, opendir, realpath } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path";
5
+ import { ompSessionDir } from "../paths";
6
+
7
+ const MAX_DESCRIPTOR_PREFIX_BYTES = 64 * 1024;
8
+ const MAX_DESCRIPTOR_SUFFIX_BYTES = 64 * 1024;
9
+ const MAX_PROMPT_PREVIEW_CHARS = 160;
10
+ const MAX_DIRECTORY_DEPTH = 8;
11
+ const MAX_SCAN_DIRECTORIES = 1_024;
12
+ const MAX_SCAN_FILES = 10_000;
13
+ const MAX_SCAN_BYTES = 16 * 1024 * 1024;
14
+ const MAX_SCAN_MS = 1_000;
15
+ const SCAN_YIELD_INTERVAL = 128;
16
+ const MAX_LIST_RESULTS = 500;
17
+ const MAX_CHILD_TRANSCRIPT_BYTES = 16 * 1024 * 1024;
18
+ const MAX_CHILD_TRANSCRIPT_MESSAGES = 100_000;
19
+ const CHILD_TRANSCRIPT_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,255}$/u;
20
+ const NATIVE_SESSION_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{7,127}$/u;
21
+
22
+ export interface OmpSessionDescriptor {
23
+ id: string;
24
+ cwd: string;
25
+ title?: string;
26
+ updatedAt?: string;
27
+ transcriptFile?: string;
28
+ firstPromptPreview?: string;
29
+ lastPromptPreview?: string;
30
+ }
31
+
32
+ export interface OmpPersistedSubagentTranscript {
33
+ sessionFile: string;
34
+ nativeSessionId: string;
35
+ byteLength: number;
36
+ messages: unknown[];
37
+ }
38
+ export interface OmpSessionListOptions {
39
+ cwd?: string;
40
+ query?: string;
41
+ limit?: number;
42
+ sessionId?: string;
43
+ sessionDir?: string;
44
+ }
45
+
46
+ interface ScanBudget {
47
+ startedAt: number;
48
+ directories: number;
49
+ files: number;
50
+ bytes: number;
51
+ entries: number;
52
+ exhausted: boolean;
53
+ }
54
+
55
+ export function validateNativeSessionId(value: unknown): string {
56
+ if (typeof value !== "string" || !NATIVE_SESSION_ID.test(value)) {
57
+ throw new Error("Invalid OMP session identifier");
58
+ }
59
+ return value;
60
+ }
61
+
62
+ function safeText(value: unknown, maxBytes: number): string | undefined {
63
+ if (
64
+ typeof value !== "string" ||
65
+ value.includes("\0") ||
66
+ Buffer.byteLength(value, "utf8") > maxBytes
67
+ )
68
+ return;
69
+ let sanitized = "";
70
+ for (const character of value) {
71
+ const codePoint = character.codePointAt(0) ?? 0;
72
+ sanitized += codePoint < 32 || codePoint === 127 ? " " : character;
73
+ }
74
+ return sanitized.trim() || undefined;
75
+ }
76
+
77
+ function validatedCwd(value: unknown): string | undefined {
78
+ if (
79
+ typeof value !== "string" ||
80
+ value.length === 0 ||
81
+ value.includes("\0") ||
82
+ Buffer.byteLength(value, "utf8") > 4_096 ||
83
+ !isAbsolute(value)
84
+ ) {
85
+ return;
86
+ }
87
+ for (const character of value) {
88
+ const codePoint = character.codePointAt(0) ?? 0;
89
+ if (codePoint < 32 || codePoint === 127) return;
90
+ }
91
+ return value;
92
+ }
93
+
94
+ function completePrefixLines(buffer: Buffer): Buffer[] {
95
+ const lines: Buffer[] = [];
96
+ let start = 0;
97
+ for (let index = 0; index < buffer.length; index += 1) {
98
+ if (buffer[index] !== 10) continue;
99
+ const end = index > start && buffer[index - 1] === 13 ? index - 1 : index;
100
+ lines.push(buffer.subarray(start, end));
101
+ start = index + 1;
102
+ }
103
+ return lines;
104
+ }
105
+ function completeSuffixLines(buffer: Buffer): Buffer[] {
106
+ const firstNewline = buffer.indexOf(10);
107
+ if (firstNewline < 0) return [];
108
+ return completePrefixLines(buffer.subarray(firstNewline + 1));
109
+ }
110
+
111
+ function promptPreview(content: unknown): string | undefined {
112
+ const text =
113
+ typeof content === "string"
114
+ ? content
115
+ : Array.isArray(content)
116
+ ? content
117
+ .slice(0, 64)
118
+ .flatMap((part) =>
119
+ part && typeof part === "object" && typeof part.text === "string" ? [part.text] : [],
120
+ )
121
+ .join("\n")
122
+ : "";
123
+ const sanitized = safeText(text, MAX_DESCRIPTOR_PREFIX_BYTES)?.replace(/\s+/gu, " ").trim();
124
+ if (!sanitized) return;
125
+ const characters = Array.from(sanitized);
126
+ if (characters.length <= MAX_PROMPT_PREVIEW_CHARS) return sanitized;
127
+ return `${characters.slice(0, MAX_PROMPT_PREVIEW_CHARS - 1).join("")}…`;
128
+ }
129
+
130
+ function budgetExceeded(budget: ScanBudget): boolean {
131
+ return (
132
+ budget.exhausted ||
133
+ budget.directories >= MAX_SCAN_DIRECTORIES ||
134
+ budget.files >= MAX_SCAN_FILES ||
135
+ budget.bytes >= MAX_SCAN_BYTES ||
136
+ Date.now() - budget.startedAt >= MAX_SCAN_MS
137
+ );
138
+ }
139
+
140
+ async function yieldToEventLoop(): Promise<void> {
141
+ const result = Promise.withResolvers<void>();
142
+ setImmediate(result.resolve);
143
+ await result.promise;
144
+ }
145
+
146
+ async function parseDescriptor(
147
+ file: string,
148
+ budget: ScanBudget,
149
+ ): Promise<OmpSessionDescriptor | undefined> {
150
+ let handle: FileHandle;
151
+ try {
152
+ handle = await open(
153
+ file,
154
+ constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0),
155
+ );
156
+ } catch {
157
+ return;
158
+ }
159
+ try {
160
+ const stat = await handle.stat();
161
+ if (!stat.isFile()) return;
162
+ const prefixBytes = Math.min(MAX_DESCRIPTOR_PREFIX_BYTES, stat.size);
163
+ const suffixBytes = Math.min(MAX_DESCRIPTOR_SUFFIX_BYTES, Math.max(0, stat.size - prefixBytes));
164
+ if (budget.bytes + prefixBytes + suffixBytes > MAX_SCAN_BYTES) {
165
+ budget.exhausted = true;
166
+ return;
167
+ }
168
+ budget.bytes += prefixBytes + suffixBytes;
169
+ const prefix = Buffer.allocUnsafe(prefixBytes);
170
+ const { bytesRead: prefixRead } = await handle.read(prefix, 0, prefix.length, 0);
171
+ const suffix = Buffer.allocUnsafe(suffixBytes);
172
+ const { bytesRead: suffixRead } = suffixBytes
173
+ ? await handle.read(suffix, 0, suffix.length, stat.size - suffixBytes)
174
+ : { bytesRead: 0 };
175
+ let title: string | undefined;
176
+ let id: string | undefined;
177
+ let cwd: string | undefined;
178
+ let firstPromptPreview: string | undefined;
179
+ let lastPromptPreview: string | undefined;
180
+ const lines = [
181
+ ...completePrefixLines(prefix.subarray(0, prefixRead)),
182
+ ...completeSuffixLines(suffix.subarray(0, suffixRead)),
183
+ ];
184
+ for (const bytes of lines) {
185
+ if (bytes.length === 0) continue;
186
+ let value: unknown;
187
+ try {
188
+ value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
189
+ } catch {
190
+ continue;
191
+ }
192
+ if (!value || typeof value !== "object" || Array.isArray(value)) continue;
193
+ const record = value as Record<string, unknown>;
194
+ if (record.type === "title") {
195
+ title = safeText(record.title, 512) ?? title;
196
+ continue;
197
+ }
198
+ if (record.type === "session_info") {
199
+ title ??= safeText(record.title ?? record.sessionName ?? record.name, 512);
200
+ continue;
201
+ }
202
+ if (record.type === "session") {
203
+ id = validateNativeSessionId(record.id);
204
+ cwd = validatedCwd(record.cwd);
205
+ if (!cwd) return;
206
+ title ??= safeText(record.title, 512);
207
+ continue;
208
+ }
209
+ if (record.type !== "message") continue;
210
+ const message = record.message;
211
+ if (!message || typeof message !== "object" || Array.isArray(message)) continue;
212
+ const messageRecord = message as Record<string, unknown>;
213
+ if (messageRecord.role !== "user") continue;
214
+ const preview = promptPreview(messageRecord.content);
215
+ if (!preview) continue;
216
+ firstPromptPreview ??= preview;
217
+ lastPromptPreview = preview;
218
+ }
219
+ if (!id || !cwd) return;
220
+ return {
221
+ id,
222
+ cwd,
223
+ transcriptFile: file,
224
+ ...(title ? { title } : {}),
225
+ updatedAt: stat.mtime.toISOString(),
226
+ ...(firstPromptPreview ? { firstPromptPreview } : {}),
227
+ ...(lastPromptPreview ? { lastPromptPreview } : {}),
228
+ };
229
+ } catch {
230
+ return;
231
+ } finally {
232
+ await handle.close().catch(() => undefined);
233
+ }
234
+ }
235
+
236
+ function retainNewest(
237
+ descriptors: OmpSessionDescriptor[],
238
+ descriptor: OmpSessionDescriptor,
239
+ limit: number,
240
+ ): void {
241
+ descriptors.push(descriptor);
242
+ descriptors.sort((left, right) => (right.updatedAt ?? "").localeCompare(left.updatedAt ?? ""));
243
+ if (descriptors.length > limit) descriptors.pop();
244
+ }
245
+
246
+ async function scanSessionFiles(
247
+ root: string,
248
+ budget: ScanBudget,
249
+ visit: (file: string) => Promise<boolean>,
250
+ ): Promise<void> {
251
+ const pending: Array<{ path: string; depth: number }> = [{ path: root, depth: 0 }];
252
+ while (pending.length > 0 && !budgetExceeded(budget)) {
253
+ const current = pending.pop();
254
+ if (!current) return;
255
+ budget.directories += 1;
256
+ let directory: Dir;
257
+ try {
258
+ directory = await opendir(current.path);
259
+ } catch {
260
+ continue;
261
+ }
262
+ try {
263
+ for await (const entry of directory) {
264
+ budget.entries += 1;
265
+ if (budget.entries % SCAN_YIELD_INTERVAL === 0) await yieldToEventLoop();
266
+ if (budgetExceeded(budget)) return;
267
+ const path = join(current.path, entry.name);
268
+ if (entry.isDirectory() && current.depth < MAX_DIRECTORY_DEPTH) {
269
+ if (pending.length + budget.directories < MAX_SCAN_DIRECTORIES) {
270
+ pending.push({ path, depth: current.depth + 1 });
271
+ }
272
+ continue;
273
+ }
274
+ if (!entry.isFile()) continue;
275
+ budget.files += 1;
276
+ if (!entry.name.endsWith(".jsonl")) continue;
277
+ if (!(await visit(path))) return;
278
+ }
279
+ } finally {
280
+ await directory.close().catch(() => undefined);
281
+ }
282
+ }
283
+ }
284
+
285
+ export async function listOmpSessionDescriptors(
286
+ options: OmpSessionListOptions,
287
+ environment: NodeJS.ProcessEnv = process.env,
288
+ ): Promise<OmpSessionDescriptor[]> {
289
+ if (
290
+ options.cwd !== undefined &&
291
+ (!options.cwd || !isAbsolute(options.cwd) || options.cwd.includes("\0"))
292
+ ) {
293
+ throw new Error("OMP session listing requires an absolute working directory when scoped");
294
+ }
295
+ const requestedId = options.sessionId ? validateNativeSessionId(options.sessionId) : undefined;
296
+ const limit = Math.min(Math.max(options.limit ?? 100, 1), MAX_LIST_RESULTS);
297
+ const retentionLimit = requestedId ? 2 : limit;
298
+ const query = options.query?.trim().toLowerCase();
299
+ const matches: OmpSessionDescriptor[] = [];
300
+ const budget: ScanBudget = {
301
+ startedAt: Date.now(),
302
+ directories: 0,
303
+ files: 0,
304
+ bytes: 0,
305
+ entries: 0,
306
+ exhausted: false,
307
+ };
308
+ const root = options.sessionDir
309
+ ? options.sessionDir.startsWith("~/")
310
+ ? join(environment.HOME ?? environment.USERPROFILE ?? homedir(), options.sessionDir.slice(2))
311
+ : resolve(options.cwd ?? homedir(), options.sessionDir)
312
+ : ompSessionDir(environment);
313
+ await scanSessionFiles(root, budget, async (file) => {
314
+ const fileName = basename(file);
315
+ const stem = fileName.slice(0, -".jsonl".length);
316
+ if (requestedId && !stem.endsWith(`_${requestedId}`)) return true;
317
+ const descriptor = await parseDescriptor(file, budget);
318
+ if (
319
+ !descriptor ||
320
+ !stem.endsWith(`_${descriptor.id}`) ||
321
+ (options.cwd !== undefined && descriptor.cwd !== options.cwd)
322
+ )
323
+ return !budgetExceeded(budget);
324
+ if (
325
+ query &&
326
+ !descriptor.id.toLowerCase().includes(query) &&
327
+ !descriptor.title?.toLowerCase().includes(query) &&
328
+ !descriptor.firstPromptPreview?.toLowerCase().includes(query) &&
329
+ !descriptor.lastPromptPreview?.toLowerCase().includes(query)
330
+ ) {
331
+ return !budgetExceeded(budget);
332
+ }
333
+ retainNewest(matches, descriptor, retentionLimit);
334
+ return !requestedId || matches.length < 2;
335
+ });
336
+ return matches;
337
+ }
338
+
339
+ export async function readOmpPersistedSubagentTranscript(
340
+ parentSessionFile: string,
341
+ childTranscriptId: string,
342
+ cwd: string,
343
+ signal?: AbortSignal,
344
+ ): Promise<OmpPersistedSubagentTranscript> {
345
+ signal?.throwIfAborted();
346
+ if (
347
+ !isAbsolute(parentSessionFile) ||
348
+ !parentSessionFile.endsWith(".jsonl") ||
349
+ parentSessionFile.includes("\0") ||
350
+ !CHILD_TRANSCRIPT_ID.test(childTranscriptId) ||
351
+ basename(childTranscriptId) !== childTranscriptId
352
+ ) {
353
+ throw new Error("Invalid OMP child transcript descriptor");
354
+ }
355
+ let parentHandle: FileHandle;
356
+ try {
357
+ parentHandle = await open(
358
+ parentSessionFile,
359
+ constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0),
360
+ );
361
+ } catch {
362
+ throw new Error("OMP parent transcript could not be opened");
363
+ }
364
+ try {
365
+ const stat = await parentHandle.stat();
366
+ if (!stat.isFile()) throw new Error("OMP parent transcript is not a file");
367
+ } finally {
368
+ await parentHandle.close().catch(() => undefined);
369
+ }
370
+ const canonicalParent = await realpath(parentSessionFile);
371
+ const parentExtension = extname(canonicalParent);
372
+ const expectedDirectory = canonicalParent.slice(0, -parentExtension.length);
373
+ const canonicalDirectory = await realpath(expectedDirectory).catch(() => undefined);
374
+ if (!canonicalDirectory || canonicalDirectory !== expectedDirectory) {
375
+ throw new Error("OMP child transcript directory is not canonically owned by its parent");
376
+ }
377
+ const sessionFile = join(canonicalDirectory, `${childTranscriptId}.jsonl`);
378
+ let handle: FileHandle;
379
+ try {
380
+ handle = await open(
381
+ sessionFile,
382
+ constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0),
383
+ );
384
+ } catch {
385
+ throw new Error("OMP child transcript could not be opened");
386
+ }
387
+ try {
388
+ const [stat, canonicalChild] = await Promise.all([handle.stat(), realpath(sessionFile)]);
389
+ if (
390
+ !stat.isFile() ||
391
+ stat.size > MAX_CHILD_TRANSCRIPT_BYTES ||
392
+ dirname(canonicalChild) !== canonicalDirectory ||
393
+ canonicalChild !== sessionFile
394
+ ) {
395
+ throw new Error("OMP child transcript failed ownership validation");
396
+ }
397
+ const bytes = await handle.readFile();
398
+ signal?.throwIfAborted();
399
+ const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
400
+ const messages: unknown[] = [];
401
+ let nativeSessionId: string | undefined;
402
+ for (const line of text.split("\n")) {
403
+ signal?.throwIfAborted();
404
+ if (!line.trim()) continue;
405
+ const value: unknown = JSON.parse(line);
406
+ if (!value || typeof value !== "object" || Array.isArray(value)) continue;
407
+ const record = value as Record<string, unknown>;
408
+ if (record.type === "session") {
409
+ const candidateId = validateNativeSessionId(record.id);
410
+ const candidateCwd = validatedCwd(record.cwd);
411
+ if (candidateCwd !== cwd)
412
+ throw new Error("OMP child transcript belongs to another workspace");
413
+ nativeSessionId ??= candidateId;
414
+ if (nativeSessionId !== candidateId)
415
+ throw new Error("OMP child transcript identity changed");
416
+ } else if (record.type === "message" && record.message !== undefined) {
417
+ if (messages.length >= MAX_CHILD_TRANSCRIPT_MESSAGES) {
418
+ throw new Error("OMP child transcript exceeds message limits");
419
+ }
420
+ messages.push(record.message);
421
+ }
422
+ }
423
+ if (!nativeSessionId) throw new Error("OMP child transcript is missing session identity");
424
+ return { sessionFile, nativeSessionId, byteLength: bytes.byteLength, messages };
425
+ } catch (error) {
426
+ if (error instanceof Error) throw error;
427
+ throw new Error("OMP child transcript could not be decoded");
428
+ } finally {
429
+ await handle.close().catch(() => undefined);
430
+ }
431
+ }