@omercnet/paseo-omp 0.2.1 → 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 (63) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +25 -13
  3. package/SUPPORT.md +6 -2
  4. package/TESTING.md +21 -18
  5. package/client/composer-pill-settings.tsx +157 -0
  6. package/client/external-url.ts +15 -0
  7. package/client/mcp-authorization.tsx +169 -0
  8. package/client/mcp-popover.tsx +155 -0
  9. package/client/memory-panel.tsx +8 -3
  10. package/client/memory-popover.tsx +8 -4
  11. package/client/omp-config-surface.tsx +189 -29
  12. package/client/omp-plugin-manager.tsx +302 -131
  13. package/client/omp-store-picker.tsx +89 -0
  14. package/client/omp-store-state.ts +45 -0
  15. package/client/paseo-types.ts +9 -0
  16. package/client/provider-diagnostics-state.ts +18 -7
  17. package/client/quota-popover.tsx +8 -3
  18. package/client/quota-state.ts +16 -7
  19. package/client/sessions-popover.tsx +8 -3
  20. package/docs/alpha-release-checklist.md +6 -8
  21. package/docs/configuration.md +8 -4
  22. package/docs/core-provider-issue-audit.md +3 -2
  23. package/docs/images/mcp-authorization-compact.png +0 -0
  24. package/docs/images/mcp-controls-wide.png +0 -0
  25. package/docs/images/plugin-manager.png +0 -0
  26. package/docs/images/workspace-settings.png +0 -0
  27. package/docs/installation.md +35 -19
  28. package/index.client.tsx +339 -123
  29. package/index.server.ts +44 -14
  30. package/package.json +7 -8
  31. package/paseo-plugin.json +2 -2
  32. package/scripts/prepare-dependencies.mjs +24 -0
  33. package/server/mcp-browser.ts +95 -0
  34. package/server/memory.ts +2 -2
  35. package/server/omp-config.ts +16 -7
  36. package/server/omp-plugins.ts +70 -21
  37. package/server/omp-settings.ts +232 -24
  38. package/server/paths.ts +128 -11
  39. package/server/provider/catalog.ts +3 -4
  40. package/server/provider/connection.ts +213 -9
  41. package/server/provider/host-tools.ts +71 -0
  42. package/server/provider/omp-rpc.ts +82 -15
  43. package/server/provider/profile-providers.ts +249 -0
  44. package/server/provider/registration.ts +11 -0
  45. package/server/provider/session-descriptors.ts +306 -1
  46. package/server/provider/session.ts +704 -249
  47. package/server/provider/subsessions.ts +4 -1
  48. package/server/provider/timeline-projector.ts +70 -33
  49. package/server/provider-diagnostics.ts +122 -36
  50. package/server/quota.ts +3 -2
  51. package/server/sessions.ts +2 -2
  52. package/shared/composer-pill-settings.ts +28 -0
  53. package/shared/external-url.ts +21 -0
  54. package/shared/hub.ts +3 -3
  55. package/shared/mcp.ts +47 -0
  56. package/shared/memory.ts +2 -1
  57. package/shared/omp-config.ts +5 -1
  58. package/shared/omp-plugins.ts +74 -33
  59. package/shared/omp-settings.ts +8 -1
  60. package/shared/omp-store.ts +58 -0
  61. package/shared/provider-diagnostics.ts +12 -3
  62. package/shared/quota.ts +2 -1
  63. package/shared/sessions.ts +2 -1
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { homedir } from "node:os";
2
3
  import { isAbsolute } from "node:path";
3
4
  import {
@@ -7,6 +8,7 @@ import {
7
8
  ProviderInputSchema,
8
9
  requireProviderCapabilities,
9
10
  } from "@getpaseo/plugin/server/provider";
11
+ import type { OmpBrowserAuthorizationRegistry } from "../mcp-browser";
10
12
  import { discoverOmpCatalog } from "./catalog";
11
13
  import { normalizeOmpCatalogOptions } from "./config-normalization";
12
14
  import type { OmpMcpConnector } from "./host-tools";
@@ -39,6 +41,7 @@ const SUPPORTED_CAPABILITIES: Readonly<Record<string, true>> = {
39
41
  "session.subsession": true,
40
42
  "session.revert.conversation": true,
41
43
  permission: true,
44
+ "timeline.plugin": true,
42
45
  };
43
46
  const SUPPORTED_INPUTS: Readonly<Record<string, true>> = {
44
47
  catalog: true,
@@ -264,8 +267,164 @@ function validateInputEnvelope(input: unknown): asserts input is ProviderInput {
264
267
  }
265
268
  }
266
269
 
267
- function errorDetails(error: unknown, fallback: string): { message: string } {
268
- return { message: isOmpPublicError(error) ? error.message : fallback };
270
+ export interface OmpConnectionDiagnostic {
271
+ diagnosticId: string;
272
+ operation: string;
273
+ errorClass: "TypeError" | "RangeError" | "SyntaxError" | "ReferenceError" | "Error" | "NonError";
274
+ classification:
275
+ | "rpc-response-limit"
276
+ | "rpc-invalid-response"
277
+ | "rpc-timeout"
278
+ | "rpc-closed"
279
+ | "rpc-input-failed"
280
+ | "rpc-exit"
281
+ | "spawn-not-found"
282
+ | "spawn-not-runnable"
283
+ | "spawn-failed"
284
+ | "system-error"
285
+ | "database-error"
286
+ | "catalog-empty"
287
+ | "unexpected";
288
+ stage?: "spawn" | "rpc" | "storage";
289
+ code?: (typeof SYSTEM_ERROR_CODES)[number] | (typeof DATABASE_ERROR_CODES)[number];
290
+ exitCode?: number;
291
+ signal?: (typeof EXIT_SIGNALS)[number];
292
+ }
293
+
294
+ // Match complete, locally authored messages only. Never log an arbitrary message, error name,
295
+ // stack, cause, request payload, or environment: each can contain credentials or prompt text.
296
+ const KNOWN_FAILURES = new Map<string, Pick<OmpConnectionDiagnostic, "classification" | "stage">>([
297
+ [
298
+ "OMP RPC response exceeded command limits",
299
+ { classification: "rpc-response-limit", stage: "rpc" },
300
+ ],
301
+ ["OMP RPC response is invalid", { classification: "rpc-invalid-response", stage: "rpc" }],
302
+ ["OMP RPC request timed out", { classification: "rpc-timeout", stage: "rpc" }],
303
+ ["OMP RPC process is closed", { classification: "rpc-closed", stage: "rpc" }],
304
+ ["OMP RPC process was closed", { classification: "rpc-closed", stage: "rpc" }],
305
+ ["OMP RPC output channel closed", { classification: "rpc-closed", stage: "rpc" }],
306
+ ["OMP RPC input channel failed", { classification: "rpc-input-failed", stage: "rpc" }],
307
+ ["OMP reported no available models", { classification: "catalog-empty", stage: "rpc" }],
308
+ ["OMP executable was not found", { classification: "spawn-not-found", stage: "spawn" }],
309
+ ["OMP executable is not runnable", { classification: "spawn-not-runnable", stage: "spawn" }],
310
+ ["OMP process could not be launched", { classification: "spawn-failed", stage: "spawn" }],
311
+ ]);
312
+
313
+ // These are diagnostic vocabulary, not patterns: an unrecognized code or signal is never emitted.
314
+ const SYSTEM_ERROR_CODES = [
315
+ "ENOENT",
316
+ "EACCES",
317
+ "EPERM",
318
+ "ENOTDIR",
319
+ "EISDIR",
320
+ "ENOSPC",
321
+ "EMFILE",
322
+ "ENFILE",
323
+ "ETIMEDOUT",
324
+ "ECONNRESET",
325
+ "ECONNREFUSED",
326
+ "EPIPE",
327
+ "EIO",
328
+ ] as const;
329
+ const DATABASE_ERROR_CODES = [
330
+ "SQLITE_ERROR",
331
+ "SQLITE_BUSY",
332
+ "SQLITE_LOCKED",
333
+ "SQLITE_CANTOPEN",
334
+ "SQLITE_CORRUPT",
335
+ "SQLITE_NOTADB",
336
+ "SQLITE_READONLY",
337
+ "SQLITE_FULL",
338
+ "SQLITE_IOERR",
339
+ "ERR_SQLITE_ERROR",
340
+ ] as const;
341
+ const EXIT_SIGNALS = [
342
+ "SIGABRT",
343
+ "SIGALRM",
344
+ "SIGBUS",
345
+ "SIGCHLD",
346
+ "SIGCONT",
347
+ "SIGFPE",
348
+ "SIGHUP",
349
+ "SIGILL",
350
+ "SIGINT",
351
+ "SIGIO",
352
+ "SIGIOT",
353
+ "SIGKILL",
354
+ "SIGPIPE",
355
+ "SIGPOLL",
356
+ "SIGPROF",
357
+ "SIGPWR",
358
+ "SIGQUIT",
359
+ "SIGSEGV",
360
+ "SIGSTKFLT",
361
+ "SIGSTOP",
362
+ "SIGSYS",
363
+ "SIGTERM",
364
+ "SIGTRAP",
365
+ "SIGTSTP",
366
+ "SIGTTIN",
367
+ "SIGTTOU",
368
+ "SIGUNUSED",
369
+ "SIGURG",
370
+ "SIGUSR1",
371
+ "SIGUSR2",
372
+ "SIGVTALRM",
373
+ "SIGWINCH",
374
+ "SIGXCPU",
375
+ "SIGXFSZ",
376
+ "SIGBREAK",
377
+ "SIGLOST",
378
+ "SIGINFO",
379
+ "unknown",
380
+ ] as const;
381
+
382
+ function classifyFailure(
383
+ error: unknown,
384
+ ): Omit<OmpConnectionDiagnostic, "diagnosticId" | "operation"> {
385
+ const result: Omit<OmpConnectionDiagnostic, "diagnosticId" | "operation"> = {
386
+ errorClass:
387
+ error instanceof TypeError
388
+ ? "TypeError"
389
+ : error instanceof RangeError
390
+ ? "RangeError"
391
+ : error instanceof SyntaxError
392
+ ? "SyntaxError"
393
+ : error instanceof ReferenceError
394
+ ? "ReferenceError"
395
+ : error instanceof Error
396
+ ? "Error"
397
+ : "NonError",
398
+ classification: "unexpected",
399
+ };
400
+ const message = error instanceof Error ? error.message : undefined;
401
+ if (typeof message === "string") {
402
+ const known = KNOWN_FAILURES.get(message);
403
+ if (known) return { ...result, ...known };
404
+ const exit = /^OMP RPC process exited \(code (-?(?:0|[1-9]\d{0,9}))\)$/.exec(message);
405
+ if (exit && exit[0] === message) {
406
+ const exitCode = Number(exit[1]);
407
+ if (exitCode >= -2147483648 && exitCode <= 4294967295) {
408
+ return { ...result, classification: "rpc-exit", stage: "rpc", exitCode };
409
+ }
410
+ }
411
+ const signal = EXIT_SIGNALS.find(
412
+ (value) => message === `OMP RPC process exited (signal ${value})`,
413
+ );
414
+ if (signal) return { ...result, classification: "rpc-exit", stage: "rpc", signal };
415
+ }
416
+ // Inspect data properties only; error-code getters may execute arbitrary application code.
417
+ const code =
418
+ error && typeof error === "object"
419
+ ? Object.getOwnPropertyDescriptor(error, "code")?.value
420
+ : undefined;
421
+ const systemCode = SYSTEM_ERROR_CODES.find((value) => value === code);
422
+ if (systemCode) return { ...result, classification: "system-error", code: systemCode };
423
+ const databaseCode = DATABASE_ERROR_CODES.find((value) => value === code);
424
+ if (databaseCode) {
425
+ return { ...result, classification: "database-error", stage: "storage", code: databaseCode };
426
+ }
427
+ return result;
269
428
  }
270
429
 
271
430
  type NativeReservation = { owner: symbol; quarantined: boolean };
@@ -494,7 +653,22 @@ export function createOmpConnection(
494
653
  mcpConnector?: OmpMcpConnector,
495
654
  mcpInitializationTimeoutMs?: number,
496
655
  replayTimeoutMs?: number,
656
+ browserAuthorizationRegistry?: OmpBrowserAuthorizationRegistry,
657
+ reportDiagnostic: (diagnostic: OmpConnectionDiagnostic) => void = (diagnostic) =>
658
+ console.error("OMP provider failure", diagnostic),
497
659
  ): ProviderConnection {
660
+ const errorDetails = (error: unknown, fallback: string): { message: string } => {
661
+ if (isOmpPublicError(error)) return { message: error.message };
662
+ // The generated ID is the only correlation value we log. It also travels in the public
663
+ // request failure, avoiding any assumption that a caller-supplied request ID is value-safe.
664
+ const diagnosticId = randomUUID();
665
+ try {
666
+ reportDiagnostic({ diagnosticId, operation: fallback, ...classifyFailure(error) });
667
+ } catch {
668
+ // A diagnostic sink must never prevent the request from settling or its cleanup.
669
+ }
670
+ return { message: `${fallback} (diagnostic ${diagnosticId})` };
671
+ };
498
672
  const safeCapabilities = [...new Set(capabilities)].filter(
499
673
  (capability) =>
500
674
  SUPPORTED_CAPABILITIES[capability] &&
@@ -504,7 +678,12 @@ export function createOmpConnection(
504
678
  const listeners = new Set<(event: ProviderEvent) => void>();
505
679
  const sessions = new Map<
506
680
  string,
507
- { token: symbol; session: OmpProviderSession; nativeSessionId?: string }
681
+ {
682
+ token: symbol;
683
+ session: OmpProviderSession;
684
+ nativeSessionId?: string;
685
+ removeBrowserAuthorization?: () => void;
686
+ }
508
687
  >();
509
688
  const opening = new Map<
510
689
  string,
@@ -525,6 +704,13 @@ export function createOmpConnection(
525
704
  if (closed) return;
526
705
  for (const listener of listeners) listener(event);
527
706
  };
707
+ const deleteSession = (sessionId: string, token: symbol): boolean => {
708
+ const slot = sessions.get(sessionId);
709
+ if (slot?.token !== token) return false;
710
+ sessions.delete(sessionId);
711
+ slot.removeBrowserAuthorization?.();
712
+ return true;
713
+ };
528
714
 
529
715
  const requestFailure = (
530
716
  requestId: string,
@@ -696,7 +882,7 @@ export function createOmpConnection(
696
882
  );
697
883
  };
698
884
  const retireRewindSession = () => {
699
- if (sessions.get(input.sessionId)?.token === token) sessions.delete(input.sessionId);
885
+ deleteSession(input.sessionId, token);
700
886
  };
701
887
  session = await OmpProviderSession.open(
702
888
  input,
@@ -735,19 +921,36 @@ export function createOmpConnection(
735
921
  nativeReservations.release(nativeSessionId, token);
736
922
  return;
737
923
  }
738
- sessions.set(input.sessionId, { token, session, nativeSessionId });
924
+ const browserAgentId = input.config.env.PASEO_AGENT_ID?.trim() || input.sessionId;
925
+ const browserAuthorization = browserAuthorizationRegistry?.register(
926
+ browserAgentId,
927
+ session.openPaseoBrowser.bind(session),
928
+ );
929
+ session.setBrowserAuthorizationIssuer(browserAuthorization?.issue ?? null);
930
+ const removeBrowserAuthorization = browserAuthorization
931
+ ? () => {
932
+ session?.setBrowserAuthorizationIssuer(null);
933
+ browserAuthorization.remove();
934
+ }
935
+ : undefined;
936
+ sessions.set(input.sessionId, {
937
+ token,
938
+ session,
939
+ nativeSessionId,
940
+ ...(removeBrowserAuthorization ? { removeBrowserAuthorization } : {}),
941
+ });
739
942
  await session.publishOpened(input.requestId);
740
943
  if (
741
944
  closing ||
742
945
  controller.signal.aborted ||
743
946
  opening.get(input.sessionId)?.token !== token
744
947
  ) {
745
- if (sessions.get(input.sessionId)?.token === token) sessions.delete(input.sessionId);
948
+ deleteSession(input.sessionId, token);
746
949
  await session.close();
747
950
  nativeReservations.release(nativeSessionId, token);
748
951
  }
749
952
  } catch (error) {
750
- if (sessions.get(input.sessionId)?.token === token) sessions.delete(input.sessionId);
953
+ deleteSession(input.sessionId, token);
751
954
  let cleanupError: unknown;
752
955
  if (session) {
753
956
  try {
@@ -841,11 +1044,11 @@ export function createOmpConnection(
841
1044
  try {
842
1045
  await slot.session.close();
843
1046
  } catch (error) {
844
- if (sessions.get(input.sessionId)?.token === slot.token) sessions.delete(input.sessionId);
1047
+ deleteSession(input.sessionId, slot.token);
845
1048
  quarantineFailedCleanup(input.sessionId, slot.token, error, slot.nativeSessionId);
846
1049
  throw new OmpPublicError("OMP session close failed");
847
1050
  }
848
- if (sessions.get(input.sessionId)?.token === slot.token) sessions.delete(input.sessionId);
1051
+ deleteSession(input.sessionId, slot.token);
849
1052
  nativeReservations.release(slot.nativeSessionId, slot.token);
850
1053
  emit({ type: "request.completed", requestId: input.requestId });
851
1054
  return;
@@ -862,6 +1065,7 @@ export function createOmpConnection(
862
1065
  shutdown.abort(shutdownReason);
863
1066
  for (const { controller } of opening.values()) controller.abort(shutdownReason);
864
1067
  for (const { session } of sessions.values()) session.beginConnectionShutdown();
1068
+ for (const slot of sessions.values()) slot.removeBrowserAuthorization?.();
865
1069
  const failures: unknown[] = [];
866
1070
  const operationBatch = [...activeOperations];
867
1071
  const operationResults = await Promise.allSettled(operationBatch);
@@ -42,6 +42,8 @@ const MAX_PENDING_HOST_TOOL_CALLS = 64;
42
42
  const MAX_PENDING_HOST_TOOL_BYTES = 8 * 1024 * 1024;
43
43
  const DEFAULT_INITIALIZATION_TIMEOUT_MS = 20_000;
44
44
  const DEFAULT_MCP_CALL_LIFETIME_MS = 5 * 60 * 1000;
45
+ const MAX_DIRECT_BROWSER_CALLS = 4;
46
+ const BROWSER_CALL_TIMEOUT_MS = 20_000;
45
47
 
46
48
  export type OmpMcpTool = ConnectedMcpTool;
47
49
  export type OmpMcpToolPage = ConnectedMcpToolPage;
@@ -331,6 +333,17 @@ async function settleCleanup(promises: readonly Promise<void>[]): Promise<void>
331
333
  .map((result) => result.reason);
332
334
  if (failures.length > 0) throw new AggregateError(failures, "OMP MCP cleanup failed");
333
335
  }
336
+ function paseoBrowserFailure(error: unknown): OmpPublicError {
337
+ const detail = error instanceof Error ? error.message : String(error);
338
+ if (/browser_no_host/iu.test(detail)) {
339
+ return new OmpPublicError("No Paseo desktop browser host is connected");
340
+ }
341
+ if (/browser_disabled/iu.test(detail)) {
342
+ return new OmpPublicError("Paseo browser tools are disabled on this host");
343
+ }
344
+ return new OmpPublicError("Paseo could not open the MCP authorization browser tab");
345
+ }
346
+
334
347
  export class OmpHostToolsBridge {
335
348
  private runtime: OmpRuntimeSession | null = null;
336
349
  private readonly pending = new Map<string, PendingCall>();
@@ -338,6 +351,7 @@ export class OmpHostToolsBridge {
338
351
  private generation = 0;
339
352
  private closePromise: Promise<void> | null = null;
340
353
  private fatalHandler: ((error: Error) => void) | null = null;
354
+ private activeDirectBrowserCalls = 0;
341
355
 
342
356
  readonly labels: ReadonlyMap<string, string>;
343
357
  private constructor(
@@ -515,6 +529,63 @@ export class OmpHostToolsBridge {
515
529
  onFatal(handler: (error: Error) => void): void {
516
530
  this.fatalHandler = handler;
517
531
  }
532
+ async openPaseoBrowser(url: string): Promise<void> {
533
+ let parsed: URL;
534
+ try {
535
+ parsed = new URL(url);
536
+ } catch {
537
+ throw new OmpPublicError("MCP authorization URL is invalid");
538
+ }
539
+ if (
540
+ (parsed.protocol !== "http:" && parsed.protocol !== "https:") ||
541
+ parsed.username ||
542
+ parsed.password
543
+ ) {
544
+ throw new OmpPublicError("MCP authorization URL is not safe to open");
545
+ }
546
+ if (!this.runtime || this.closePromise) {
547
+ throw new OmpPublicError("The OMP session is not ready for browser authorization");
548
+ }
549
+ const target = this.targets.get("browser_new_tab");
550
+ if (!target) {
551
+ throw new OmpPublicError("Paseo browser tools are unavailable in this OMP session");
552
+ }
553
+ if (this.activeDirectBrowserCalls >= MAX_DIRECT_BROWSER_CALLS) {
554
+ throw new OmpPublicError("Too many Paseo browser requests are already running");
555
+ }
556
+
557
+ this.activeDirectBrowserCalls += 1;
558
+ const controller = new AbortController();
559
+ const deadline = this.callScheduler.set(
560
+ () => controller.abort(new Error("Paseo browser request timed out")),
561
+ BROWSER_CALL_TIMEOUT_MS,
562
+ );
563
+ try {
564
+ const result = normalizeResult(
565
+ await target.connection.callTool(
566
+ target.toolName,
567
+ { i: "Opening MCP authorization", url },
568
+ {
569
+ signal: controller.signal,
570
+ maxTotalTimeoutMs: BROWSER_CALL_TIMEOUT_MS,
571
+ onProgress() {},
572
+ },
573
+ ),
574
+ );
575
+ if (result.isError) {
576
+ const detail = result.content
577
+ .flatMap((part) => (part.type === "text" && part.text ? [part.text] : []))
578
+ .join("\n");
579
+ throw paseoBrowserFailure(new Error(detail));
580
+ }
581
+ } catch (error) {
582
+ if (error instanceof OmpPublicError) throw error;
583
+ throw paseoBrowserFailure(error);
584
+ } finally {
585
+ this.callScheduler.clear(deadline);
586
+ this.activeDirectBrowserCalls -= 1;
587
+ }
588
+ }
518
589
 
519
590
  handle(
520
591
  event: OmpHostToolCall | { type: "host_tool_cancel"; id: string; targetId: string },
@@ -2,12 +2,14 @@ import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { isAbsolute, join } from "node:path";
4
4
  import { z } from "zod";
5
+ import { ompDataDir } from "../paths";
5
6
  import { isValidImagePayload } from "./image";
6
7
  import { boundedJsonBytes, OmpCleanupFailure, OmpPublicError, utf8Bytes } from "./security";
7
8
  import {
8
9
  listOmpSessionDescriptors,
9
10
  type OmpSessionDescriptor,
10
11
  type OmpSessionListOptions,
12
+ readOmpPersistedSessionTranscript,
11
13
  readOmpPersistedSubagentTranscript,
12
14
  validateNativeSessionId,
13
15
  } from "./session-descriptors";
@@ -54,7 +56,9 @@ const MAX_PENDING_ONE_WAY_WRITES = 256;
54
56
  const MAX_PENDING_WRITE_BYTES = 8 * 1024 * 1024;
55
57
  const MAX_LINE_PARTS = 4_096;
56
58
  const MAX_ARRAY_ITEMS = 512;
57
- const MAX_CONTENT_PARTS = 64;
59
+ // Tool-intensive OMP turns legitimately exceed 64 blocks; transport byte/node budgets remain the
60
+ // primary resource bounds.
61
+ export const OMP_MAX_CONTENT_PARTS = 4_096;
58
62
  const MAX_TODOS = 256;
59
63
  const MAX_ENV_ENTRIES = 256;
60
64
  const MAX_ENV_VALUE_LENGTH = 64 * 1024;
@@ -63,6 +67,9 @@ const MAX_PATH_LENGTH = 4_096;
63
67
  const WINDOWS_DEFAULT_SYSTEM_ROOT = "C:\\Windows";
64
68
  const MAX_TOKEN_COUNT = Number.MAX_SAFE_INTEGER;
65
69
  const MAX_COST_USD = 1_000_000_000;
70
+ const MAX_RPC_ERROR_BYTES = 4_096;
71
+ const MAX_RPC_ERROR_CODE_BYTES = 256;
72
+ const PROMPT_SCHEDULING_FAILURE = "OMP prompt scheduling failed";
66
73
  const MAX_CONTEXT_PERCENT = 1_000_000;
67
74
  function boundedJsonString(maxBytes: number, minBytes = 0) {
68
75
  return z.string().refine((value) => {
@@ -142,11 +149,11 @@ const OmpContentPartSchema = z
142
149
  });
143
150
  const OmpDisplayContentSchema = z.union([
144
151
  TEXT,
145
- z.array(OmpContentPartSchema).max(MAX_CONTENT_PARTS),
152
+ z.array(OmpContentPartSchema).max(OMP_MAX_CONTENT_PARTS),
146
153
  ]);
147
154
  const OmpImageArraySchema = z
148
155
  .array(OmpContentPartSchema)
149
- .max(MAX_CONTENT_PARTS)
156
+ .max(OMP_MAX_CONTENT_PARTS)
150
157
  .superRefine((parts, context) => {
151
158
  if (parts.some((part) => part.type !== "image")) {
152
159
  context.addIssue({ code: "custom", message: "invalid image collection" });
@@ -259,7 +266,7 @@ const OmpAssistantMessageEventSchema = z
259
266
  .number()
260
267
  .int()
261
268
  .nonnegative()
262
- .max(MAX_CONTENT_PARTS - 1)
269
+ .max(OMP_MAX_CONTENT_PARTS - 1)
263
270
  .optional(),
264
271
  delta: TEXT.optional(),
265
272
  content: z
@@ -387,7 +394,8 @@ const OmpResponseFrameSchema = z.object({
387
394
  id: IDENTIFIER,
388
395
  success: z.boolean(),
389
396
  data: z.unknown().optional(),
390
- error: boundedString(4_096).optional(),
397
+ error: boundedString(MAX_RPC_ERROR_BYTES).optional(),
398
+ code: boundedString(MAX_RPC_ERROR_CODE_BYTES, 1).optional(),
391
399
  });
392
400
  const OmpChunkFrameSchema = z.object({
393
401
  type: z.literal("rpc_chunk"),
@@ -562,6 +570,7 @@ const OmpToolApprovalResponseSchema = z.union([
562
570
  ]);
563
571
  const OmpAgentEndEnvelopeSchema = z.object({
564
572
  type: z.literal("agent_end"),
573
+ requestId: IDENTIFIER.optional(),
565
574
  messageCount: z.number().int().nonnegative().optional(),
566
575
  isTerminal: z.boolean().optional(),
567
576
  });
@@ -582,6 +591,7 @@ const OmpAgentSessionEventSchema = z.discriminatedUnion("type", [
582
591
  z.object({ type: z.literal("agent_start") }),
583
592
  z.object({
584
593
  type: z.literal("agent_end"),
594
+ requestId: IDENTIFIER.optional(),
585
595
  messages: z.array(OmpMessageSchema).max(MAX_ARRAY_ITEMS).optional(),
586
596
  messageCount: z.number().int().nonnegative().optional(),
587
597
  isTerminal: z.boolean().optional(),
@@ -924,7 +934,7 @@ export function parseOmpHostToolAgentResult(value: unknown): OmpHostToolResult["
924
934
  }
925
935
  export type OmpRpcEvent =
926
936
  | z.infer<typeof OmpRuntimeEventSchema>
927
- | { type: "prompt_error"; id: string; error: string }
937
+ | { type: "prompt_error"; id: string; error: string; code?: string }
928
938
  | { type: "process_exit"; error: string };
929
939
  export type OmpAgentSessionEvent = z.infer<typeof OmpAgentSessionEventSchema>;
930
940
  export type OmpSubagentSnapshot = z.infer<typeof OmpSubagentsResultSchema>["subagents"][number];
@@ -945,6 +955,12 @@ export interface OmpPersistedSubagentMessages {
945
955
  byteLength: number;
946
956
  messages: OmpMessage[];
947
957
  }
958
+ export interface OmpPersistedSessionMessages {
959
+ sessionFile: string;
960
+ nativeSessionId: string;
961
+ byteLength: number;
962
+ messages: OmpMessage[];
963
+ }
948
964
 
949
965
  export interface OmpStartOptions {
950
966
  cwd: string;
@@ -997,6 +1013,7 @@ export interface OmpRuntimeSession {
997
1013
  message: string,
998
1014
  images?: readonly OmpImage[],
999
1015
  onAccepted?: () => void,
1016
+ onRequested?: (requestId: string) => void,
1000
1017
  ): Promise<{ requestId: string; agentInvoked?: boolean }>;
1001
1018
  compact(customInstructions?: string): Promise<OmpCompactionResult>;
1002
1019
  setAutoCompaction(enabled: boolean): Promise<void>;
@@ -1022,6 +1039,12 @@ export interface OmpRuntime {
1022
1039
  readonly supportsPersistence: boolean;
1023
1040
  startSession(options: OmpStartOptions): Promise<OmpRuntimeSession>;
1024
1041
  listSessions(options: OmpSessionListOptions): Promise<OmpSessionDescriptor[]>;
1042
+ readPersistedSessionTranscript?(options: {
1043
+ sessionFile: string;
1044
+ sessionId: string;
1045
+ cwd: string;
1046
+ signal?: AbortSignal;
1047
+ }): Promise<OmpPersistedSessionMessages>;
1025
1048
  readPersistedSubagentTranscript(options: {
1026
1049
  parentSessionFile: string;
1027
1050
  childTranscriptId: string;
@@ -2074,7 +2097,11 @@ class OmpRpcProcess {
2074
2097
  private receiveKnownResponse(value: unknown): boolean {
2075
2098
  if (!value || typeof value !== "object" || Array.isArray(value)) return false;
2076
2099
  const frame = value as Record<string, unknown>;
2077
- if (frame.type !== "response" || typeof frame.id !== "string" || !this.pending.has(frame.id)) {
2100
+ if (
2101
+ frame.type !== "response" ||
2102
+ typeof frame.id !== "string" ||
2103
+ (!this.pending.has(frame.id) && !this.acceptedPromptIds.has(frame.id))
2104
+ ) {
2078
2105
  return false;
2079
2106
  }
2080
2107
  this.receiveResponse(frame);
@@ -2088,19 +2115,15 @@ class OmpRpcProcess {
2088
2115
  if (!response.success) {
2089
2116
  if (rawId && knownPending) {
2090
2117
  this.takePending(rawId)?.reject(new Error("OMP RPC response is invalid"));
2091
- } else {
2118
+ } else if (!rawId || !this.emitAcceptedPromptFailure(rawId, frame)) {
2092
2119
  this.recordProtocolViolation();
2093
2120
  }
2094
2121
  return;
2095
2122
  }
2096
2123
  const pending = this.pending.get(response.data.id);
2097
2124
  if (!pending) {
2098
- if (!response.data.success && this.acceptedPromptIds.delete(response.data.id)) {
2099
- this.emit({
2100
- type: "prompt_error",
2101
- id: response.data.id,
2102
- error: "OMP prompt scheduling failed",
2103
- });
2125
+ if (!response.data.success) {
2126
+ this.emitAcceptedPromptFailure(response.data.id, response.data);
2104
2127
  }
2105
2128
  return;
2106
2129
  }
@@ -2112,7 +2135,15 @@ class OmpRpcProcess {
2112
2135
  isBranchHistory || isHistory
2113
2136
  ? Math.min(MAX_REASSEMBLED_FRAME_BYTES, this.reassembledFrameLimit)
2114
2137
  : 2 * 1024 * 1024;
2115
- const responseNodeLimit = isBranchHistory ? 4_096 : isHistory ? 400_000 : 2_048;
2138
+ // A model catalog contains up to 256 structured models, so its aggregate
2139
+ // node budget must exceed the small state/command-response budget.
2140
+ const responseNodeLimit = isBranchHistory
2141
+ ? 4_096
2142
+ : isHistory
2143
+ ? 400_000
2144
+ : pending.command === "get_available_models"
2145
+ ? 16_384
2146
+ : 2_048;
2116
2147
  if (
2117
2148
  boundedJsonBytes(
2118
2149
  frame,
@@ -2148,6 +2179,22 @@ class OmpRpcProcess {
2148
2179
  }
2149
2180
  }
2150
2181
 
2182
+ private emitAcceptedPromptFailure(id: string, frame: Record<string, unknown>): boolean {
2183
+ if (frame.success !== false || !this.acceptedPromptIds.delete(id)) return false;
2184
+ const error = typeof frame.error === "string" ? frame.error : undefined;
2185
+ const nativeError =
2186
+ error && utf8Bytes(error) <= MAX_RPC_ERROR_BYTES ? error : PROMPT_SCHEDULING_FAILURE;
2187
+ const code = typeof frame.code === "string" ? frame.code : undefined;
2188
+ const nativeCode = code && utf8Bytes(code) <= MAX_RPC_ERROR_CODE_BYTES ? code : undefined;
2189
+ this.emit({
2190
+ type: "prompt_error",
2191
+ id,
2192
+ error: nativeError,
2193
+ ...(nativeCode ? { code: nativeCode } : {}),
2194
+ });
2195
+ return true;
2196
+ }
2197
+
2151
2198
  private takePending(id: string): PendingRequest | undefined {
2152
2199
  const pending = this.pending.get(id);
2153
2200
  if (!pending) return undefined;
@@ -2595,6 +2642,7 @@ class OmpRpcSession implements OmpRuntimeSession {
2595
2642
  message: string,
2596
2643
  images: readonly OmpImage[] = [],
2597
2644
  onAccepted?: () => void,
2645
+ onRequested?: (requestId: string) => void,
2598
2646
  ): Promise<{ requestId: string; agentInvoked?: boolean }> {
2599
2647
  const safeMessage = validateBoundedText(message, "prompt", MAX_TEXT_LENGTH);
2600
2648
  let acknowledgement: z.infer<typeof OmpPromptAckSchema> | undefined;
@@ -2606,6 +2654,7 @@ class OmpRpcSession implements OmpRuntimeSession {
2606
2654
  onAccepted?.();
2607
2655
  },
2608
2656
  );
2657
+ onRequested?.(request.id);
2609
2658
  await request.promise;
2610
2659
  return { requestId: request.id, ...acknowledgement };
2611
2660
  }
@@ -2664,6 +2713,24 @@ export class OmpRpcRuntime implements OmpRuntime {
2664
2713
  listOmpSessionDescriptors(options, this.options.environment ?? process.env),
2665
2714
  );
2666
2715
  }
2716
+ async readPersistedSessionTranscript(options: {
2717
+ sessionFile: string;
2718
+ sessionId: string;
2719
+ cwd: string;
2720
+ signal?: AbortSignal;
2721
+ }): Promise<OmpPersistedSessionMessages> {
2722
+ const transcript = await readOmpPersistedSessionTranscript(
2723
+ options.sessionFile,
2724
+ options.sessionId,
2725
+ options.cwd,
2726
+ options.signal,
2727
+ join(ompDataDir(this.options.environment ?? process.env), "blobs"),
2728
+ );
2729
+ return {
2730
+ ...transcript,
2731
+ messages: z.array(OmpMessageSchema).max(100_000).parse(transcript.messages),
2732
+ };
2733
+ }
2667
2734
  async readPersistedSubagentTranscript(options: {
2668
2735
  parentSessionFile: string;
2669
2736
  childTranscriptId: string;