@omercnet/paseo-omp 0.2.1-next.72.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.
- package/CHANGELOG.md +87 -0
- package/LICENSE +21 -0
- package/README.md +120 -0
- package/SUPPORT.md +42 -0
- package/TESTING.md +150 -0
- package/client/composer-pill-settings.tsx +157 -0
- package/client/hub-icon.tsx +12 -0
- package/client/hub-popover.tsx +132 -0
- package/client/hub-status.ts +29 -0
- package/client/mcp-authorization.tsx +168 -0
- package/client/mcp-popover.tsx +155 -0
- package/client/memory-panel.tsx +76 -0
- package/client/memory-popover.tsx +74 -0
- package/client/omp-config-surface.tsx +1433 -0
- package/client/omp-doc-links.ts +117 -0
- package/client/omp-plugin-manager.tsx +1004 -0
- package/client/omp-store-picker.tsx +89 -0
- package/client/omp-store-state.ts +45 -0
- package/client/provider-diagnostics-state.ts +262 -0
- package/client/provider-icon.tsx +27 -0
- package/client/provider-image.tsx +66 -0
- package/client/quota-popover.tsx +155 -0
- package/client/quota-state.ts +140 -0
- package/client/sessions-popover.tsx +78 -0
- package/docs/alpha-release-checklist.md +68 -0
- package/docs/configuration.md +126 -0
- package/docs/core-provider-issue-audit.md +108 -0
- package/docs/images/mcp-authorization-compact.png +0 -0
- package/docs/images/mcp-controls-wide.png +0 -0
- package/docs/images/plugin-manager.png +0 -0
- package/docs/images/workspace-settings.png +0 -0
- package/docs/installation.md +67 -0
- package/index.client.tsx +488 -0
- package/index.server.ts +81 -0
- package/package.json +84 -0
- package/paseo-plugin.json +5 -0
- package/scripts/prepare-dependencies.mjs +20 -0
- package/server/hub.ts +145 -0
- package/server/mcp-browser.ts +95 -0
- package/server/memory.ts +86 -0
- package/server/mutation-queue.ts +12 -0
- package/server/omp-config.ts +135 -0
- package/server/omp-plugins.ts +676 -0
- package/server/omp-settings.ts +499 -0
- package/server/paths.ts +181 -0
- package/server/provider/catalog.ts +172 -0
- package/server/provider/config-normalization.ts +148 -0
- package/server/provider/connection.ts +1196 -0
- package/server/provider/host-tools.ts +777 -0
- package/server/provider/image.ts +143 -0
- package/server/provider/mcp-transport.ts +394 -0
- package/server/provider/omp-rpc.ts +2806 -0
- package/server/provider/omp.svg +5 -0
- package/server/provider/profile-providers.ts +249 -0
- package/server/provider/provider-options.ts +27 -0
- package/server/provider/registration.ts +162 -0
- package/server/provider/security.ts +317 -0
- package/server/provider/session-descriptors.ts +736 -0
- package/server/provider/session.ts +4796 -0
- package/server/provider/settings.ts +78 -0
- package/server/provider/subsessions.ts +850 -0
- package/server/provider/timeline-projector.ts +1801 -0
- package/server/provider-diagnostics.ts +1143 -0
- package/server/quota.ts +55 -0
- package/server/sessions.ts +58 -0
- package/shared/composer-pill-settings.ts +28 -0
- package/shared/hub.ts +43 -0
- package/shared/mcp.ts +47 -0
- package/shared/memory.ts +24 -0
- package/shared/omp-config.ts +85 -0
- package/shared/omp-plugins.ts +264 -0
- package/shared/omp-settings.ts +214 -0
- package/shared/omp-store.ts +58 -0
- package/shared/provider-diagnostics.ts +126 -0
- package/shared/provider-image.ts +160 -0
- package/shared/quota.ts +23 -0
- package/shared/sessions.ts +24 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,736 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { constants, type Dir } from "node:fs";
|
|
3
|
+
import { type FileHandle, lstat, open, opendir, realpath } from "node:fs/promises";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path";
|
|
6
|
+
import { ompSessionDir } from "../paths";
|
|
7
|
+
|
|
8
|
+
const MAX_DESCRIPTOR_PREFIX_BYTES = 64 * 1024;
|
|
9
|
+
const MAX_DESCRIPTOR_SUFFIX_BYTES = 64 * 1024;
|
|
10
|
+
const MAX_PROMPT_PREVIEW_CHARS = 160;
|
|
11
|
+
const MAX_DIRECTORY_DEPTH = 8;
|
|
12
|
+
const MAX_SCAN_DIRECTORIES = 1_024;
|
|
13
|
+
const MAX_SCAN_FILES = 10_000;
|
|
14
|
+
const MAX_SCAN_BYTES = 16 * 1024 * 1024;
|
|
15
|
+
const MAX_SCAN_MS = 1_000;
|
|
16
|
+
const SCAN_YIELD_INTERVAL = 128;
|
|
17
|
+
const MAX_LIST_RESULTS = 500;
|
|
18
|
+
const MAX_CHILD_TRANSCRIPT_BYTES = 16 * 1024 * 1024;
|
|
19
|
+
const MAX_CHILD_TRANSCRIPT_MESSAGES = 100_000;
|
|
20
|
+
const MAX_SESSION_TRANSCRIPT_BYTES = 64 * 1024 * 1024;
|
|
21
|
+
const MAX_SESSION_TRANSCRIPT_ENTRIES = 200_000;
|
|
22
|
+
const MAX_SESSION_TRANSCRIPT_BLOB_BYTES = 16 * 1024 * 1024;
|
|
23
|
+
const MAX_SESSION_IMAGE_BLOB_BYTES = 6 * 1024 * 1024;
|
|
24
|
+
const FILE_READ_CHUNK_BYTES = 64 * 1024;
|
|
25
|
+
const BLOB_REFERENCE = /^blob:sha256:([a-f0-9]{64})$/u;
|
|
26
|
+
const CHILD_TRANSCRIPT_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,255}$/u;
|
|
27
|
+
const NATIVE_SESSION_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{7,127}$/u;
|
|
28
|
+
|
|
29
|
+
export interface OmpSessionDescriptor {
|
|
30
|
+
id: string;
|
|
31
|
+
cwd: string;
|
|
32
|
+
title?: string;
|
|
33
|
+
updatedAt?: string;
|
|
34
|
+
transcriptFile?: string;
|
|
35
|
+
firstPromptPreview?: string;
|
|
36
|
+
lastPromptPreview?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface OmpPersistedSubagentTranscript {
|
|
40
|
+
sessionFile: string;
|
|
41
|
+
nativeSessionId: string;
|
|
42
|
+
byteLength: number;
|
|
43
|
+
messages: unknown[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface OmpPersistedSessionTranscript {
|
|
47
|
+
sessionFile: string;
|
|
48
|
+
nativeSessionId: string;
|
|
49
|
+
byteLength: number;
|
|
50
|
+
messages: unknown[];
|
|
51
|
+
}
|
|
52
|
+
export interface OmpSessionListOptions {
|
|
53
|
+
cwd?: string;
|
|
54
|
+
query?: string;
|
|
55
|
+
limit?: number;
|
|
56
|
+
sessionId?: string;
|
|
57
|
+
sessionDir?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface ScanBudget {
|
|
61
|
+
startedAt: number;
|
|
62
|
+
directories: number;
|
|
63
|
+
files: number;
|
|
64
|
+
bytes: number;
|
|
65
|
+
entries: number;
|
|
66
|
+
exhausted: boolean;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function validateNativeSessionId(value: unknown): string {
|
|
70
|
+
if (typeof value !== "string" || !NATIVE_SESSION_ID.test(value)) {
|
|
71
|
+
throw new Error("Invalid OMP session identifier");
|
|
72
|
+
}
|
|
73
|
+
return value;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function safeText(value: unknown, maxBytes: number): string | undefined {
|
|
77
|
+
if (
|
|
78
|
+
typeof value !== "string" ||
|
|
79
|
+
value.includes("\0") ||
|
|
80
|
+
Buffer.byteLength(value, "utf8") > maxBytes
|
|
81
|
+
)
|
|
82
|
+
return;
|
|
83
|
+
let sanitized = "";
|
|
84
|
+
for (const character of value) {
|
|
85
|
+
const codePoint = character.codePointAt(0) ?? 0;
|
|
86
|
+
sanitized += codePoint < 32 || codePoint === 127 ? " " : character;
|
|
87
|
+
}
|
|
88
|
+
return sanitized.trim() || undefined;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function validatedCwd(value: unknown): string | undefined {
|
|
92
|
+
if (
|
|
93
|
+
typeof value !== "string" ||
|
|
94
|
+
value.length === 0 ||
|
|
95
|
+
value.includes("\0") ||
|
|
96
|
+
Buffer.byteLength(value, "utf8") > 4_096 ||
|
|
97
|
+
!isAbsolute(value)
|
|
98
|
+
) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
for (const character of value) {
|
|
102
|
+
const codePoint = character.codePointAt(0) ?? 0;
|
|
103
|
+
if (codePoint < 32 || codePoint === 127) return;
|
|
104
|
+
}
|
|
105
|
+
return value;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function completePrefixLines(buffer: Buffer): Buffer[] {
|
|
109
|
+
const lines: Buffer[] = [];
|
|
110
|
+
let start = 0;
|
|
111
|
+
for (let index = 0; index < buffer.length; index += 1) {
|
|
112
|
+
if (buffer[index] !== 10) continue;
|
|
113
|
+
const end = index > start && buffer[index - 1] === 13 ? index - 1 : index;
|
|
114
|
+
lines.push(buffer.subarray(start, end));
|
|
115
|
+
start = index + 1;
|
|
116
|
+
}
|
|
117
|
+
return lines;
|
|
118
|
+
}
|
|
119
|
+
function completeSuffixLines(buffer: Buffer): Buffer[] {
|
|
120
|
+
const firstNewline = buffer.indexOf(10);
|
|
121
|
+
if (firstNewline < 0) return [];
|
|
122
|
+
return completePrefixLines(buffer.subarray(firstNewline + 1));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function promptPreview(content: unknown): string | undefined {
|
|
126
|
+
const text =
|
|
127
|
+
typeof content === "string"
|
|
128
|
+
? content
|
|
129
|
+
: Array.isArray(content)
|
|
130
|
+
? content
|
|
131
|
+
.slice(0, 64)
|
|
132
|
+
.flatMap((part) =>
|
|
133
|
+
part && typeof part === "object" && typeof part.text === "string" ? [part.text] : [],
|
|
134
|
+
)
|
|
135
|
+
.join("\n")
|
|
136
|
+
: "";
|
|
137
|
+
const sanitized = safeText(text, MAX_DESCRIPTOR_PREFIX_BYTES)?.replace(/\s+/gu, " ").trim();
|
|
138
|
+
if (!sanitized) return;
|
|
139
|
+
const characters = Array.from(sanitized);
|
|
140
|
+
if (characters.length <= MAX_PROMPT_PREVIEW_CHARS) return sanitized;
|
|
141
|
+
return `${characters.slice(0, MAX_PROMPT_PREVIEW_CHARS - 1).join("")}…`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function budgetExceeded(budget: ScanBudget): boolean {
|
|
145
|
+
return (
|
|
146
|
+
budget.exhausted ||
|
|
147
|
+
budget.directories >= MAX_SCAN_DIRECTORIES ||
|
|
148
|
+
budget.files >= MAX_SCAN_FILES ||
|
|
149
|
+
budget.bytes >= MAX_SCAN_BYTES ||
|
|
150
|
+
Date.now() - budget.startedAt >= MAX_SCAN_MS
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function yieldToEventLoop(): Promise<void> {
|
|
155
|
+
const result = Promise.withResolvers<void>();
|
|
156
|
+
setImmediate(result.resolve);
|
|
157
|
+
await result.promise;
|
|
158
|
+
}
|
|
159
|
+
async function readStableFile(
|
|
160
|
+
handle: FileHandle,
|
|
161
|
+
byteLength: number,
|
|
162
|
+
signal: AbortSignal | undefined,
|
|
163
|
+
changedMessage: string,
|
|
164
|
+
): Promise<Buffer> {
|
|
165
|
+
const bytes = Buffer.allocUnsafe(byteLength);
|
|
166
|
+
let offset = 0;
|
|
167
|
+
while (offset < byteLength) {
|
|
168
|
+
signal?.throwIfAborted();
|
|
169
|
+
const { bytesRead } = await handle.read(
|
|
170
|
+
bytes,
|
|
171
|
+
offset,
|
|
172
|
+
Math.min(FILE_READ_CHUNK_BYTES, byteLength - offset),
|
|
173
|
+
offset,
|
|
174
|
+
);
|
|
175
|
+
if (bytesRead === 0) throw new Error(changedMessage);
|
|
176
|
+
offset += bytesRead;
|
|
177
|
+
}
|
|
178
|
+
signal?.throwIfAborted();
|
|
179
|
+
const extra = Buffer.allocUnsafe(1);
|
|
180
|
+
if ((await handle.read(extra, 0, 1, byteLength)).bytesRead !== 0) throw new Error(changedMessage);
|
|
181
|
+
return bytes;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function hydrateBlobImageData(
|
|
185
|
+
data: string,
|
|
186
|
+
blobDirectory: string,
|
|
187
|
+
budget: { bytes: number },
|
|
188
|
+
signal?: AbortSignal,
|
|
189
|
+
): Promise<string> {
|
|
190
|
+
const match = BLOB_REFERENCE.exec(data);
|
|
191
|
+
if (!match) return data;
|
|
192
|
+
const hash = match[1];
|
|
193
|
+
const blobFile = join(blobDirectory, hash);
|
|
194
|
+
let handle: FileHandle;
|
|
195
|
+
try {
|
|
196
|
+
handle = await open(
|
|
197
|
+
blobFile,
|
|
198
|
+
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0),
|
|
199
|
+
);
|
|
200
|
+
} catch {
|
|
201
|
+
throw new Error("OMP transcript image blob could not be opened");
|
|
202
|
+
}
|
|
203
|
+
try {
|
|
204
|
+
const [stat, pathStat] = await Promise.all([handle.stat(), lstat(blobFile)]);
|
|
205
|
+
if (
|
|
206
|
+
!stat.isFile() ||
|
|
207
|
+
!pathStat.isFile() ||
|
|
208
|
+
pathStat.isSymbolicLink() ||
|
|
209
|
+
stat.dev !== pathStat.dev ||
|
|
210
|
+
stat.ino !== pathStat.ino ||
|
|
211
|
+
stat.size > MAX_SESSION_IMAGE_BLOB_BYTES ||
|
|
212
|
+
budget.bytes + stat.size > MAX_SESSION_TRANSCRIPT_BLOB_BYTES
|
|
213
|
+
) {
|
|
214
|
+
throw new Error("OMP transcript image blob failed ownership or size validation");
|
|
215
|
+
}
|
|
216
|
+
const bytes = await readStableFile(
|
|
217
|
+
handle,
|
|
218
|
+
stat.size,
|
|
219
|
+
signal,
|
|
220
|
+
"OMP transcript image blob changed while reading",
|
|
221
|
+
);
|
|
222
|
+
if (createHash("sha256").update(bytes).digest("hex") !== hash) {
|
|
223
|
+
throw new Error("OMP transcript image blob failed integrity validation");
|
|
224
|
+
}
|
|
225
|
+
budget.bytes += bytes.byteLength;
|
|
226
|
+
return bytes.toString("base64");
|
|
227
|
+
} finally {
|
|
228
|
+
await handle.close().catch(() => undefined);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function hydrateImageParts(
|
|
233
|
+
value: unknown,
|
|
234
|
+
blobDirectory: string,
|
|
235
|
+
budget: { bytes: number },
|
|
236
|
+
signal?: AbortSignal,
|
|
237
|
+
): Promise<unknown> {
|
|
238
|
+
if (!Array.isArray(value)) return value;
|
|
239
|
+
let hydrated: unknown[] | undefined;
|
|
240
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
241
|
+
const part = value[index];
|
|
242
|
+
if (
|
|
243
|
+
!part ||
|
|
244
|
+
typeof part !== "object" ||
|
|
245
|
+
Array.isArray(part) ||
|
|
246
|
+
!("type" in part) ||
|
|
247
|
+
part.type !== "image" ||
|
|
248
|
+
!("data" in part) ||
|
|
249
|
+
typeof part.data !== "string" ||
|
|
250
|
+
!BLOB_REFERENCE.test(part.data)
|
|
251
|
+
) {
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
const data = await hydrateBlobImageData(part.data, blobDirectory, budget, signal);
|
|
255
|
+
hydrated ??= [...value];
|
|
256
|
+
hydrated[index] = { ...part, data };
|
|
257
|
+
}
|
|
258
|
+
return hydrated ?? value;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function hydratePersistedMessageImages(
|
|
262
|
+
message: unknown,
|
|
263
|
+
blobDirectory: string | undefined,
|
|
264
|
+
budget: { bytes: number },
|
|
265
|
+
signal?: AbortSignal,
|
|
266
|
+
): Promise<unknown> {
|
|
267
|
+
if (!blobDirectory || !message || typeof message !== "object" || Array.isArray(message)) {
|
|
268
|
+
return message;
|
|
269
|
+
}
|
|
270
|
+
const record = message as Record<string, unknown>;
|
|
271
|
+
let content = await hydrateImageParts(record.content, blobDirectory, budget, signal);
|
|
272
|
+
if (content && typeof content === "object" && !Array.isArray(content)) {
|
|
273
|
+
const contentRecord = content as Record<string, unknown>;
|
|
274
|
+
const nested = await hydrateImageParts(contentRecord.content, blobDirectory, budget, signal);
|
|
275
|
+
if (nested !== contentRecord.content) content = { ...contentRecord, content: nested };
|
|
276
|
+
}
|
|
277
|
+
const images = await hydrateImageParts(record.images, blobDirectory, budget, signal);
|
|
278
|
+
if (content === record.content && images === record.images) return message;
|
|
279
|
+
return { ...record, content, images };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async function parseDescriptor(
|
|
283
|
+
file: string,
|
|
284
|
+
budget: ScanBudget,
|
|
285
|
+
): Promise<OmpSessionDescriptor | undefined> {
|
|
286
|
+
let handle: FileHandle;
|
|
287
|
+
try {
|
|
288
|
+
handle = await open(
|
|
289
|
+
file,
|
|
290
|
+
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0),
|
|
291
|
+
);
|
|
292
|
+
} catch {
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
try {
|
|
296
|
+
const stat = await handle.stat();
|
|
297
|
+
if (!stat.isFile()) return;
|
|
298
|
+
const prefixBytes = Math.min(MAX_DESCRIPTOR_PREFIX_BYTES, stat.size);
|
|
299
|
+
const suffixBytes = Math.min(MAX_DESCRIPTOR_SUFFIX_BYTES, Math.max(0, stat.size - prefixBytes));
|
|
300
|
+
if (budget.bytes + prefixBytes + suffixBytes > MAX_SCAN_BYTES) {
|
|
301
|
+
budget.exhausted = true;
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
budget.bytes += prefixBytes + suffixBytes;
|
|
305
|
+
const prefix = Buffer.allocUnsafe(prefixBytes);
|
|
306
|
+
const { bytesRead: prefixRead } = await handle.read(prefix, 0, prefix.length, 0);
|
|
307
|
+
const suffix = Buffer.allocUnsafe(suffixBytes);
|
|
308
|
+
const { bytesRead: suffixRead } = suffixBytes
|
|
309
|
+
? await handle.read(suffix, 0, suffix.length, stat.size - suffixBytes)
|
|
310
|
+
: { bytesRead: 0 };
|
|
311
|
+
let title: string | undefined;
|
|
312
|
+
let id: string | undefined;
|
|
313
|
+
let cwd: string | undefined;
|
|
314
|
+
let firstPromptPreview: string | undefined;
|
|
315
|
+
let lastPromptPreview: string | undefined;
|
|
316
|
+
const lines = [
|
|
317
|
+
...completePrefixLines(prefix.subarray(0, prefixRead)),
|
|
318
|
+
...completeSuffixLines(suffix.subarray(0, suffixRead)),
|
|
319
|
+
];
|
|
320
|
+
for (const bytes of lines) {
|
|
321
|
+
if (bytes.length === 0) continue;
|
|
322
|
+
let value: unknown;
|
|
323
|
+
try {
|
|
324
|
+
value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
325
|
+
} catch {
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) continue;
|
|
329
|
+
const record = value as Record<string, unknown>;
|
|
330
|
+
if (record.type === "title") {
|
|
331
|
+
title = safeText(record.title, 512) ?? title;
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
if (record.type === "session_info") {
|
|
335
|
+
title ??= safeText(record.title ?? record.sessionName ?? record.name, 512);
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
if (record.type === "session") {
|
|
339
|
+
id = validateNativeSessionId(record.id);
|
|
340
|
+
cwd = validatedCwd(record.cwd);
|
|
341
|
+
if (!cwd) return;
|
|
342
|
+
title ??= safeText(record.title, 512);
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
if (record.type !== "message") continue;
|
|
346
|
+
const message = record.message;
|
|
347
|
+
if (!message || typeof message !== "object" || Array.isArray(message)) continue;
|
|
348
|
+
const messageRecord = message as Record<string, unknown>;
|
|
349
|
+
if (messageRecord.role !== "user") continue;
|
|
350
|
+
const preview = promptPreview(messageRecord.content);
|
|
351
|
+
if (!preview) continue;
|
|
352
|
+
firstPromptPreview ??= preview;
|
|
353
|
+
lastPromptPreview = preview;
|
|
354
|
+
}
|
|
355
|
+
if (!id || !cwd) return;
|
|
356
|
+
return {
|
|
357
|
+
id,
|
|
358
|
+
cwd,
|
|
359
|
+
transcriptFile: file,
|
|
360
|
+
...(title ? { title } : {}),
|
|
361
|
+
updatedAt: stat.mtime.toISOString(),
|
|
362
|
+
...(firstPromptPreview ? { firstPromptPreview } : {}),
|
|
363
|
+
...(lastPromptPreview ? { lastPromptPreview } : {}),
|
|
364
|
+
};
|
|
365
|
+
} catch {
|
|
366
|
+
return;
|
|
367
|
+
} finally {
|
|
368
|
+
await handle.close().catch(() => undefined);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function retainNewest(
|
|
373
|
+
descriptors: OmpSessionDescriptor[],
|
|
374
|
+
descriptor: OmpSessionDescriptor,
|
|
375
|
+
limit: number,
|
|
376
|
+
): void {
|
|
377
|
+
descriptors.push(descriptor);
|
|
378
|
+
descriptors.sort((left, right) => (right.updatedAt ?? "").localeCompare(left.updatedAt ?? ""));
|
|
379
|
+
if (descriptors.length > limit) descriptors.pop();
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
async function scanSessionFiles(
|
|
383
|
+
root: string,
|
|
384
|
+
budget: ScanBudget,
|
|
385
|
+
visit: (file: string) => Promise<boolean>,
|
|
386
|
+
): Promise<void> {
|
|
387
|
+
const pending: Array<{ path: string; depth: number }> = [{ path: root, depth: 0 }];
|
|
388
|
+
while (pending.length > 0 && !budgetExceeded(budget)) {
|
|
389
|
+
const current = pending.pop();
|
|
390
|
+
if (!current) return;
|
|
391
|
+
budget.directories += 1;
|
|
392
|
+
let directory: Dir;
|
|
393
|
+
try {
|
|
394
|
+
directory = await opendir(current.path);
|
|
395
|
+
} catch {
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
try {
|
|
399
|
+
for await (const entry of directory) {
|
|
400
|
+
budget.entries += 1;
|
|
401
|
+
if (budget.entries % SCAN_YIELD_INTERVAL === 0) await yieldToEventLoop();
|
|
402
|
+
if (budgetExceeded(budget)) return;
|
|
403
|
+
const path = join(current.path, entry.name);
|
|
404
|
+
if (entry.isDirectory() && current.depth < MAX_DIRECTORY_DEPTH) {
|
|
405
|
+
if (pending.length + budget.directories < MAX_SCAN_DIRECTORIES) {
|
|
406
|
+
pending.push({ path, depth: current.depth + 1 });
|
|
407
|
+
}
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
if (!entry.isFile()) continue;
|
|
411
|
+
budget.files += 1;
|
|
412
|
+
if (!entry.name.endsWith(".jsonl")) continue;
|
|
413
|
+
if (!(await visit(path))) return;
|
|
414
|
+
}
|
|
415
|
+
} finally {
|
|
416
|
+
await directory.close().catch(() => undefined);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
export async function listOmpSessionDescriptors(
|
|
422
|
+
options: OmpSessionListOptions,
|
|
423
|
+
environment: NodeJS.ProcessEnv = process.env,
|
|
424
|
+
): Promise<OmpSessionDescriptor[]> {
|
|
425
|
+
if (
|
|
426
|
+
options.cwd !== undefined &&
|
|
427
|
+
(!options.cwd || !isAbsolute(options.cwd) || options.cwd.includes("\0"))
|
|
428
|
+
) {
|
|
429
|
+
throw new Error("OMP session listing requires an absolute working directory when scoped");
|
|
430
|
+
}
|
|
431
|
+
const requestedId = options.sessionId ? validateNativeSessionId(options.sessionId) : undefined;
|
|
432
|
+
const limit = Math.min(Math.max(options.limit ?? 100, 1), MAX_LIST_RESULTS);
|
|
433
|
+
const retentionLimit = requestedId ? 2 : limit;
|
|
434
|
+
const query = options.query?.trim().toLowerCase();
|
|
435
|
+
const matches: OmpSessionDescriptor[] = [];
|
|
436
|
+
const budget: ScanBudget = {
|
|
437
|
+
startedAt: Date.now(),
|
|
438
|
+
directories: 0,
|
|
439
|
+
files: 0,
|
|
440
|
+
bytes: 0,
|
|
441
|
+
entries: 0,
|
|
442
|
+
exhausted: false,
|
|
443
|
+
};
|
|
444
|
+
const root = options.sessionDir
|
|
445
|
+
? options.sessionDir.startsWith("~/")
|
|
446
|
+
? join(environment.HOME ?? environment.USERPROFILE ?? homedir(), options.sessionDir.slice(2))
|
|
447
|
+
: resolve(options.cwd ?? homedir(), options.sessionDir)
|
|
448
|
+
: ompSessionDir(environment);
|
|
449
|
+
await scanSessionFiles(root, budget, async (file) => {
|
|
450
|
+
const fileName = basename(file);
|
|
451
|
+
const stem = fileName.slice(0, -".jsonl".length);
|
|
452
|
+
if (requestedId && !stem.endsWith(`_${requestedId}`)) return true;
|
|
453
|
+
const descriptor = await parseDescriptor(file, budget);
|
|
454
|
+
if (
|
|
455
|
+
!descriptor ||
|
|
456
|
+
!stem.endsWith(`_${descriptor.id}`) ||
|
|
457
|
+
(options.cwd !== undefined && descriptor.cwd !== options.cwd)
|
|
458
|
+
)
|
|
459
|
+
return !budgetExceeded(budget);
|
|
460
|
+
if (
|
|
461
|
+
query &&
|
|
462
|
+
!descriptor.id.toLowerCase().includes(query) &&
|
|
463
|
+
!descriptor.title?.toLowerCase().includes(query) &&
|
|
464
|
+
!descriptor.firstPromptPreview?.toLowerCase().includes(query) &&
|
|
465
|
+
!descriptor.lastPromptPreview?.toLowerCase().includes(query)
|
|
466
|
+
) {
|
|
467
|
+
return !budgetExceeded(budget);
|
|
468
|
+
}
|
|
469
|
+
retainNewest(matches, descriptor, retentionLimit);
|
|
470
|
+
return !requestedId || matches.length < 2;
|
|
471
|
+
});
|
|
472
|
+
return matches;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
interface PersistedTranscriptNode {
|
|
476
|
+
parentId: string | null;
|
|
477
|
+
message?: unknown;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function persistedTranscriptMessage(record: Record<string, unknown>, entryId: string): unknown {
|
|
481
|
+
if (record.type === "message") {
|
|
482
|
+
if (!record.message || typeof record.message !== "object" || Array.isArray(record.message))
|
|
483
|
+
return;
|
|
484
|
+
return { ...(record.message as Record<string, unknown>), entryId };
|
|
485
|
+
}
|
|
486
|
+
if (record.type !== "custom_message") return;
|
|
487
|
+
return {
|
|
488
|
+
role: "custom",
|
|
489
|
+
entryId,
|
|
490
|
+
customType: record.customType,
|
|
491
|
+
content: record.content,
|
|
492
|
+
display: record.display,
|
|
493
|
+
details: record.details,
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Reads the active root-to-leaf display history from an already authorized native transcript.
|
|
499
|
+
* OMP's get_messages endpoint exposes model-safe context and deliberately removes failed turns,
|
|
500
|
+
* so persisted replay must use the journal to preserve user-visible assistant and tool history.
|
|
501
|
+
*/
|
|
502
|
+
export async function readOmpPersistedSessionTranscript(
|
|
503
|
+
sessionFile: string,
|
|
504
|
+
sessionId: string,
|
|
505
|
+
cwd: string,
|
|
506
|
+
signal?: AbortSignal,
|
|
507
|
+
blobDirectory?: string,
|
|
508
|
+
): Promise<OmpPersistedSessionTranscript> {
|
|
509
|
+
signal?.throwIfAborted();
|
|
510
|
+
const expectedSessionId = validateNativeSessionId(sessionId);
|
|
511
|
+
if (
|
|
512
|
+
!isAbsolute(sessionFile) ||
|
|
513
|
+
!sessionFile.endsWith(".jsonl") ||
|
|
514
|
+
sessionFile.includes("\0") ||
|
|
515
|
+
validatedCwd(cwd) !== cwd
|
|
516
|
+
) {
|
|
517
|
+
throw new Error("Invalid OMP session transcript descriptor");
|
|
518
|
+
}
|
|
519
|
+
const expectedFile = resolve(sessionFile);
|
|
520
|
+
let handle: FileHandle;
|
|
521
|
+
try {
|
|
522
|
+
handle = await open(
|
|
523
|
+
expectedFile,
|
|
524
|
+
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0),
|
|
525
|
+
);
|
|
526
|
+
} catch {
|
|
527
|
+
throw new Error("OMP session transcript could not be opened");
|
|
528
|
+
}
|
|
529
|
+
try {
|
|
530
|
+
const [stat, pathStat, canonicalFile] = await Promise.all([
|
|
531
|
+
handle.stat(),
|
|
532
|
+
lstat(expectedFile),
|
|
533
|
+
realpath(expectedFile),
|
|
534
|
+
]);
|
|
535
|
+
if (
|
|
536
|
+
!stat.isFile() ||
|
|
537
|
+
!pathStat.isFile() ||
|
|
538
|
+
pathStat.isSymbolicLink() ||
|
|
539
|
+
stat.dev !== pathStat.dev ||
|
|
540
|
+
stat.ino !== pathStat.ino ||
|
|
541
|
+
stat.size > MAX_SESSION_TRANSCRIPT_BYTES
|
|
542
|
+
) {
|
|
543
|
+
throw new Error("OMP session transcript failed ownership validation");
|
|
544
|
+
}
|
|
545
|
+
const bytes = await readStableFile(
|
|
546
|
+
handle,
|
|
547
|
+
stat.size,
|
|
548
|
+
signal,
|
|
549
|
+
"OMP session transcript changed while reading",
|
|
550
|
+
);
|
|
551
|
+
const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
552
|
+
const nodes = new Map<string, PersistedTranscriptNode>();
|
|
553
|
+
let nativeSessionId: string | undefined;
|
|
554
|
+
let leafId: string | undefined;
|
|
555
|
+
let recordsRead = 0;
|
|
556
|
+
for (const line of text.split("\n")) {
|
|
557
|
+
signal?.throwIfAborted();
|
|
558
|
+
recordsRead += 1;
|
|
559
|
+
if (recordsRead % 1_024 === 0) await yieldToEventLoop();
|
|
560
|
+
if (!line.trim()) continue;
|
|
561
|
+
const value: unknown = JSON.parse(line);
|
|
562
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) continue;
|
|
563
|
+
const record = value as Record<string, unknown>;
|
|
564
|
+
if (record.type === "session") {
|
|
565
|
+
const candidateId = validateNativeSessionId(record.id);
|
|
566
|
+
if (candidateId !== expectedSessionId || validatedCwd(record.cwd) !== cwd) {
|
|
567
|
+
throw new Error("OMP session transcript identity does not match its descriptor");
|
|
568
|
+
}
|
|
569
|
+
nativeSessionId ??= candidateId;
|
|
570
|
+
if (nativeSessionId !== candidateId) {
|
|
571
|
+
throw new Error("OMP session transcript identity changed");
|
|
572
|
+
}
|
|
573
|
+
continue;
|
|
574
|
+
}
|
|
575
|
+
if (typeof record.id !== "string" || !CHILD_TRANSCRIPT_ID.test(record.id)) {
|
|
576
|
+
if (record.type === "message" || record.type === "custom_message") {
|
|
577
|
+
throw new Error("OMP session transcript contains an unlinked message");
|
|
578
|
+
}
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
const parentId = record.parentId;
|
|
582
|
+
if (
|
|
583
|
+
parentId !== null &&
|
|
584
|
+
(typeof parentId !== "string" || !CHILD_TRANSCRIPT_ID.test(parentId))
|
|
585
|
+
) {
|
|
586
|
+
throw new Error("OMP session transcript contains an invalid parent identity");
|
|
587
|
+
}
|
|
588
|
+
if (nodes.size >= MAX_SESSION_TRANSCRIPT_ENTRIES) {
|
|
589
|
+
throw new Error("OMP session transcript exceeds entry limits");
|
|
590
|
+
}
|
|
591
|
+
if (nodes.has(record.id))
|
|
592
|
+
throw new Error("OMP session transcript contains duplicate entries");
|
|
593
|
+
nodes.set(record.id, {
|
|
594
|
+
parentId,
|
|
595
|
+
message: persistedTranscriptMessage(record, record.id),
|
|
596
|
+
});
|
|
597
|
+
leafId = record.id;
|
|
598
|
+
}
|
|
599
|
+
if (!nativeSessionId) throw new Error("OMP session transcript is missing session identity");
|
|
600
|
+
|
|
601
|
+
const messages: unknown[] = [];
|
|
602
|
+
const seen = new Set<string>();
|
|
603
|
+
let currentId = leafId;
|
|
604
|
+
let pathEntriesRead = 0;
|
|
605
|
+
while (currentId) {
|
|
606
|
+
pathEntriesRead += 1;
|
|
607
|
+
if (pathEntriesRead % 1_024 === 0) {
|
|
608
|
+
await yieldToEventLoop();
|
|
609
|
+
signal?.throwIfAborted();
|
|
610
|
+
}
|
|
611
|
+
if (seen.has(currentId)) throw new Error("OMP session transcript contains a parent cycle");
|
|
612
|
+
seen.add(currentId);
|
|
613
|
+
const node = nodes.get(currentId);
|
|
614
|
+
if (!node) throw new Error("OMP session transcript contains an unresolved parent");
|
|
615
|
+
if (node.message !== undefined) messages.push(node.message);
|
|
616
|
+
currentId = node.parentId ?? undefined;
|
|
617
|
+
}
|
|
618
|
+
messages.reverse();
|
|
619
|
+
if (messages.length > MAX_CHILD_TRANSCRIPT_MESSAGES) {
|
|
620
|
+
throw new Error("OMP session transcript exceeds message limits");
|
|
621
|
+
}
|
|
622
|
+
const hydratedMessages: unknown[] = [];
|
|
623
|
+
const blobBudget = { bytes: 0 };
|
|
624
|
+
for (const message of messages) {
|
|
625
|
+
signal?.throwIfAborted();
|
|
626
|
+
hydratedMessages.push(
|
|
627
|
+
await hydratePersistedMessageImages(message, blobDirectory, blobBudget, signal),
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
return {
|
|
631
|
+
sessionFile: canonicalFile,
|
|
632
|
+
nativeSessionId,
|
|
633
|
+
byteLength: bytes.byteLength,
|
|
634
|
+
messages: hydratedMessages,
|
|
635
|
+
};
|
|
636
|
+
} catch (error) {
|
|
637
|
+
if (error instanceof Error) throw error;
|
|
638
|
+
throw new Error("OMP session transcript could not be decoded");
|
|
639
|
+
} finally {
|
|
640
|
+
await handle.close().catch(() => undefined);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
export async function readOmpPersistedSubagentTranscript(
|
|
645
|
+
parentSessionFile: string,
|
|
646
|
+
childTranscriptId: string,
|
|
647
|
+
cwd: string,
|
|
648
|
+
signal?: AbortSignal,
|
|
649
|
+
): Promise<OmpPersistedSubagentTranscript> {
|
|
650
|
+
signal?.throwIfAborted();
|
|
651
|
+
if (
|
|
652
|
+
!isAbsolute(parentSessionFile) ||
|
|
653
|
+
!parentSessionFile.endsWith(".jsonl") ||
|
|
654
|
+
parentSessionFile.includes("\0") ||
|
|
655
|
+
!CHILD_TRANSCRIPT_ID.test(childTranscriptId) ||
|
|
656
|
+
basename(childTranscriptId) !== childTranscriptId
|
|
657
|
+
) {
|
|
658
|
+
throw new Error("Invalid OMP child transcript descriptor");
|
|
659
|
+
}
|
|
660
|
+
let parentHandle: FileHandle;
|
|
661
|
+
try {
|
|
662
|
+
parentHandle = await open(
|
|
663
|
+
parentSessionFile,
|
|
664
|
+
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0),
|
|
665
|
+
);
|
|
666
|
+
} catch {
|
|
667
|
+
throw new Error("OMP parent transcript could not be opened");
|
|
668
|
+
}
|
|
669
|
+
try {
|
|
670
|
+
const stat = await parentHandle.stat();
|
|
671
|
+
if (!stat.isFile()) throw new Error("OMP parent transcript is not a file");
|
|
672
|
+
} finally {
|
|
673
|
+
await parentHandle.close().catch(() => undefined);
|
|
674
|
+
}
|
|
675
|
+
const canonicalParent = await realpath(parentSessionFile);
|
|
676
|
+
const parentExtension = extname(canonicalParent);
|
|
677
|
+
const expectedDirectory = canonicalParent.slice(0, -parentExtension.length);
|
|
678
|
+
const canonicalDirectory = await realpath(expectedDirectory).catch(() => undefined);
|
|
679
|
+
if (!canonicalDirectory || canonicalDirectory !== expectedDirectory) {
|
|
680
|
+
throw new Error("OMP child transcript directory is not canonically owned by its parent");
|
|
681
|
+
}
|
|
682
|
+
const sessionFile = join(canonicalDirectory, `${childTranscriptId}.jsonl`);
|
|
683
|
+
let handle: FileHandle;
|
|
684
|
+
try {
|
|
685
|
+
handle = await open(
|
|
686
|
+
sessionFile,
|
|
687
|
+
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0),
|
|
688
|
+
);
|
|
689
|
+
} catch {
|
|
690
|
+
throw new Error("OMP child transcript could not be opened");
|
|
691
|
+
}
|
|
692
|
+
try {
|
|
693
|
+
const [stat, canonicalChild] = await Promise.all([handle.stat(), realpath(sessionFile)]);
|
|
694
|
+
if (
|
|
695
|
+
!stat.isFile() ||
|
|
696
|
+
stat.size > MAX_CHILD_TRANSCRIPT_BYTES ||
|
|
697
|
+
dirname(canonicalChild) !== canonicalDirectory ||
|
|
698
|
+
canonicalChild !== sessionFile
|
|
699
|
+
) {
|
|
700
|
+
throw new Error("OMP child transcript failed ownership validation");
|
|
701
|
+
}
|
|
702
|
+
const bytes = await handle.readFile();
|
|
703
|
+
signal?.throwIfAborted();
|
|
704
|
+
const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
705
|
+
const messages: unknown[] = [];
|
|
706
|
+
let nativeSessionId: string | undefined;
|
|
707
|
+
for (const line of text.split("\n")) {
|
|
708
|
+
signal?.throwIfAborted();
|
|
709
|
+
if (!line.trim()) continue;
|
|
710
|
+
const value: unknown = JSON.parse(line);
|
|
711
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) continue;
|
|
712
|
+
const record = value as Record<string, unknown>;
|
|
713
|
+
if (record.type === "session") {
|
|
714
|
+
const candidateId = validateNativeSessionId(record.id);
|
|
715
|
+
const candidateCwd = validatedCwd(record.cwd);
|
|
716
|
+
if (candidateCwd !== cwd)
|
|
717
|
+
throw new Error("OMP child transcript belongs to another workspace");
|
|
718
|
+
nativeSessionId ??= candidateId;
|
|
719
|
+
if (nativeSessionId !== candidateId)
|
|
720
|
+
throw new Error("OMP child transcript identity changed");
|
|
721
|
+
} else if (record.type === "message" && record.message !== undefined) {
|
|
722
|
+
if (messages.length >= MAX_CHILD_TRANSCRIPT_MESSAGES) {
|
|
723
|
+
throw new Error("OMP child transcript exceeds message limits");
|
|
724
|
+
}
|
|
725
|
+
messages.push(record.message);
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
if (!nativeSessionId) throw new Error("OMP child transcript is missing session identity");
|
|
729
|
+
return { sessionFile, nativeSessionId, byteLength: bytes.byteLength, messages };
|
|
730
|
+
} catch (error) {
|
|
731
|
+
if (error instanceof Error) throw error;
|
|
732
|
+
throw new Error("OMP child transcript could not be decoded");
|
|
733
|
+
} finally {
|
|
734
|
+
await handle.close().catch(() => undefined);
|
|
735
|
+
}
|
|
736
|
+
}
|