@omercnet/paseo-omp 0.3.0 → 0.4.0-next.114.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 (43) hide show
  1. package/README.md +19 -5
  2. package/client/omp-config-surface.tsx +243 -24
  3. package/client/omp-config-views.ts +24 -0
  4. package/client/omp-model-picker-state.ts +145 -0
  5. package/client/omp-model-picker.tsx +282 -0
  6. package/client/omp-routing-editor.tsx +307 -0
  7. package/client/support-diagnostics-state.ts +45 -0
  8. package/index.server.ts +27 -2
  9. package/package.json +2 -8
  10. package/paseo-plugin.json +1 -1
  11. package/server/omp-models.ts +59 -0
  12. package/server/omp-settings.ts +30 -20
  13. package/server/operational-failure-diagnostics.ts +76 -0
  14. package/server/package-version.ts +2 -0
  15. package/server/protocol-violation-diagnostics.ts +169 -0
  16. package/server/provider/catalog.ts +39 -10
  17. package/server/provider/connection.ts +60 -8
  18. package/server/provider/host-tools.ts +284 -34
  19. package/server/provider/mcp-transport.ts +2 -1
  20. package/server/provider/omp-rpc.ts +1011 -107
  21. package/server/provider/profile-providers.ts +7 -2
  22. package/server/provider/registration.ts +12 -2
  23. package/server/provider/security.ts +8 -10
  24. package/server/provider/session-descriptors.ts +45 -11
  25. package/server/provider/session.ts +200 -58
  26. package/server/provider/subsessions.ts +311 -73
  27. package/server/provider/timeline-projector.ts +34 -11
  28. package/server/support-diagnostics.ts +284 -0
  29. package/shared/omp-models.ts +49 -0
  30. package/shared/omp-settings.ts +227 -3
  31. package/shared/support-diagnostics.ts +32 -0
  32. package/CHANGELOG.md +0 -113
  33. package/SUPPORT.md +0 -44
  34. package/TESTING.md +0 -150
  35. package/docs/alpha-release-checklist.md +0 -68
  36. package/docs/configuration.md +0 -126
  37. package/docs/core-provider-issue-audit.md +0 -109
  38. package/docs/images/mcp-authorization-compact.png +0 -0
  39. package/docs/images/mcp-controls-wide.png +0 -0
  40. package/docs/images/plugin-manager.png +0 -0
  41. package/docs/images/workspace-settings.png +0 -0
  42. package/docs/installation.md +0 -89
  43. package/tsconfig.json +0 -16
@@ -4,7 +4,13 @@ import { isAbsolute, join } from "node:path";
4
4
  import { z } from "zod";
5
5
  import { ompDataDir } from "../paths";
6
6
  import { isValidImagePayload } from "./image";
7
- import { boundedJsonBytes, OmpCleanupFailure, OmpPublicError, utf8Bytes } from "./security";
7
+ import {
8
+ boundedJsonBytes,
9
+ boundedJsonMetrics,
10
+ OmpCleanupFailure,
11
+ OmpPublicError,
12
+ utf8Bytes,
13
+ } from "./security";
8
14
  import {
9
15
  listOmpSessionDescriptors,
10
16
  type OmpSessionDescriptor,
@@ -56,6 +62,15 @@ const MAX_PENDING_ONE_WAY_WRITES = 256;
56
62
  const MAX_PENDING_WRITE_BYTES = 8 * 1024 * 1024;
57
63
  const MAX_LINE_PARTS = 4_096;
58
64
  const MAX_ARRAY_ITEMS = 512;
65
+ // OMP read metadata can contain one source entry per displayed line. Bound this optional,
66
+ // opaque field separately so over-budget metadata can be omitted without losing completion events.
67
+ const MAX_OPTIONAL_METADATA_BYTES = MAX_TOOL_PAYLOAD_LENGTH;
68
+ const MAX_OPTIONAL_METADATA_ITEMS = 2_048;
69
+ const MAX_OPTIONAL_METADATA_NODES = 4_096;
70
+ const MAX_TASK_CORRELATION_BYTES = 256 * 1024;
71
+ const MAX_TASK_CORRELATION_ITEMS = 1_024;
72
+ const MAX_TASK_CORRELATION_NODES = 4_096;
73
+ const MAX_MODEL_CATALOG_ITEMS = 4_096;
59
74
  // Tool-intensive OMP turns legitimately exceed 64 blocks; transport byte/node budgets remain the
60
75
  // primary resource bounds.
61
76
  export const OMP_MAX_CONTENT_PARTS = 4_096;
@@ -70,6 +85,298 @@ const MAX_COST_USD = 1_000_000_000;
70
85
  const MAX_RPC_ERROR_BYTES = 4_096;
71
86
  const MAX_RPC_ERROR_CODE_BYTES = 256;
72
87
  const PROMPT_SCHEDULING_FAILURE = "OMP prompt scheduling failed";
88
+ const PROTOCOL_VIOLATION_COALESCE_MS = 10_000;
89
+
90
+ export const OMP_PROTOCOL_VIOLATION_CATEGORIES = [
91
+ "duplicate-ready",
92
+ "frame-limit",
93
+ "incomplete-frame",
94
+ "interleaved-chunk",
95
+ "invalid-chunk",
96
+ "invalid-envelope",
97
+ "invalid-event",
98
+ "invalid-event-state",
99
+ "invalid-json",
100
+ "invalid-ready",
101
+ "invalid-response",
102
+ "remote-frame-error",
103
+ ] as const;
104
+ export type OmpProtocolViolationCategory = (typeof OMP_PROTOCOL_VIOLATION_CATEGORIES)[number];
105
+ export const OMP_PROTOCOL_VIOLATION_REASONS = [
106
+ "output-ended-mid-frame",
107
+ "physical-frame-limit",
108
+ "semantic-frame-limit",
109
+ "json-decode",
110
+ "chunk-json-decode",
111
+ "object-envelope",
112
+ "missing-frame-type",
113
+ "response-schema",
114
+ "chunk-schema",
115
+ "chunk-metadata",
116
+ "chunk-size",
117
+ "chunk-start-index",
118
+ "chunk-sequence",
119
+ "chunk-byte-count",
120
+ "chunk-timeout",
121
+ "frame-interleaved-with-chunk",
122
+ "remote-frame-error",
123
+ "ready-already-received",
124
+ "ready-schema",
125
+ "notice-level-type",
126
+ "notice-message-type",
127
+ "notice-schema",
128
+ "event-schema",
129
+ "message-event-schema",
130
+ "tool-event-schema",
131
+ "lifecycle-event-schema",
132
+ "subagent-event-schema",
133
+ "configuration-event-schema",
134
+ "extension-ui-schema",
135
+ "unknown-event-type",
136
+ "event-state-transition",
137
+ ] as const;
138
+ export type OmpProtocolViolationReason = (typeof OMP_PROTOCOL_VIOLATION_REASONS)[number];
139
+ export const OMP_PROTOCOL_DIAGNOSTIC_PHASES = [
140
+ "startup",
141
+ "negotiation",
142
+ "idle",
143
+ "active-turn",
144
+ "closing",
145
+ ] as const;
146
+ export type OmpProtocolDiagnosticPhase = (typeof OMP_PROTOCOL_DIAGNOSTIC_PHASES)[number];
147
+
148
+ export const OMP_PROTOCOL_EVENT_TYPES = [
149
+ "agent_start",
150
+ "agent_end",
151
+ "turn_start",
152
+ "turn_end",
153
+ "message_start",
154
+ "message_update",
155
+ "message_end",
156
+ "tool_execution_start",
157
+ "tool_execution_update",
158
+ "tool_execution_end",
159
+ "compaction_start",
160
+ "compaction_end",
161
+ "subagent_lifecycle",
162
+ "subagent_progress",
163
+ "subagent_event",
164
+ "todo_reminder",
165
+ "model_changed",
166
+ "thinking_level_changed",
167
+ "goal_updated",
168
+ "auto_retry_start",
169
+ "auto_retry_end",
170
+ "retry_fallback_applied",
171
+ "retry_fallback_succeeded",
172
+ "todo_auto_clear",
173
+ "auto_compaction_start",
174
+ "auto_compaction_end",
175
+ "available_commands_update",
176
+ "notice",
177
+ "command_output",
178
+ "extension_ui_request",
179
+ "prompt_result",
180
+ "host_tool_call",
181
+ "host_tool_cancel",
182
+ "tool_approval_request",
183
+ "tool_approval_cancel",
184
+ "advisor_yielded",
185
+ ] as const;
186
+ export type OmpProtocolEventType = (typeof OMP_PROTOCOL_EVENT_TYPES)[number];
187
+
188
+ export const OMP_PROTOCOL_DIAGNOSTIC_FIELDS = [
189
+ "frame",
190
+ "frame.byteLength",
191
+ "frame.type",
192
+ "response",
193
+ "ready",
194
+ "event",
195
+ "event.sequence",
196
+ "notice.level",
197
+ "notice.message",
198
+ "chunk",
199
+ "chunk.data",
200
+ "chunk.index",
201
+ "chunk.sequence",
202
+ "chunk.byteLength",
203
+ ] as const;
204
+ export type OmpProtocolDiagnosticField = (typeof OMP_PROTOCOL_DIAGNOSTIC_FIELDS)[number];
205
+
206
+ export const OMP_PROTOCOL_DIAGNOSTIC_EXPECTATIONS = [
207
+ "complete-json-line",
208
+ "within-byte-limit",
209
+ "valid-json",
210
+ "object-envelope",
211
+ "bounded-frame-type",
212
+ "valid-response-frame",
213
+ "valid-ready-frame",
214
+ "single-ready-frame",
215
+ "valid-event-frame",
216
+ "valid-event-state-transition",
217
+ "valid-message-event",
218
+ "valid-tool-event",
219
+ "valid-lifecycle-event",
220
+ "valid-subagent-event",
221
+ "valid-configuration-event",
222
+ "valid-extension-ui-event",
223
+ "known-event-type",
224
+ "notice-level-enum",
225
+ "notice-message-string",
226
+ "valid-chunk-frame",
227
+ "valid-base64-chunk",
228
+ "first-chunk-index-zero",
229
+ "contiguous-chunk-sequence",
230
+ "declared-chunk-byte-count",
231
+ "chunk-before-deadline",
232
+ "no-interleaved-frame",
233
+ "no-remote-frame-error",
234
+ ] as const;
235
+ export type OmpProtocolDiagnosticExpectation =
236
+ (typeof OMP_PROTOCOL_DIAGNOSTIC_EXPECTATIONS)[number];
237
+
238
+ export const OMP_PROTOCOL_DIAGNOSTIC_ACTUAL_TYPES = [
239
+ "missing",
240
+ "null",
241
+ "array",
242
+ "object",
243
+ "string",
244
+ "number",
245
+ "boolean",
246
+ "invalid-json",
247
+ "partial-frame",
248
+ "oversized",
249
+ "duplicate",
250
+ "interleaved",
251
+ "out-of-order",
252
+ "timeout",
253
+ "remote-error",
254
+ "mismatched",
255
+ ] as const;
256
+ export type OmpProtocolDiagnosticActualType = (typeof OMP_PROTOCOL_DIAGNOSTIC_ACTUAL_TYPES)[number];
257
+
258
+ export interface OmpProtocolViolationDiagnostic {
259
+ category: OmpProtocolViolationCategory;
260
+ reason: OmpProtocolViolationReason;
261
+ phase: OmpProtocolDiagnosticPhase;
262
+ occurrenceCount: number;
263
+ eventType?: OmpProtocolEventType;
264
+ frameType?: "ready" | "response" | "rpc_chunk" | "rpc_frame_error" | "notice";
265
+ field?: OmpProtocolDiagnosticField;
266
+ expected?: OmpProtocolDiagnosticExpectation;
267
+ actualType?: OmpProtocolDiagnosticActualType;
268
+ maxByteSize?: number;
269
+ limitBytes?: number;
270
+ }
271
+
272
+ function protocolDiagnosticActualType(value: unknown): OmpProtocolDiagnosticActualType {
273
+ if (value === undefined) return "missing";
274
+ if (value === null) return "null";
275
+ if (Array.isArray(value)) return "array";
276
+ if (typeof value === "object") return "object";
277
+ if (typeof value === "string") return "string";
278
+ if (typeof value === "number") return "number";
279
+ if (typeof value === "boolean") return "boolean";
280
+ return "object";
281
+ }
282
+
283
+ function isProtocolEventType(value: string): value is OmpProtocolEventType {
284
+ return (OMP_PROTOCOL_EVENT_TYPES as readonly string[]).includes(value);
285
+ }
286
+
287
+ function eventSchemaClassification(
288
+ eventType: OmpProtocolEventType,
289
+ ): Pick<OmpProtocolViolationDiagnostic, "reason" | "expected"> {
290
+ switch (eventType) {
291
+ case "message_start":
292
+ case "message_update":
293
+ case "message_end":
294
+ return { reason: "message-event-schema", expected: "valid-message-event" };
295
+ case "tool_execution_start":
296
+ case "tool_execution_update":
297
+ case "tool_execution_end":
298
+ case "host_tool_call":
299
+ case "host_tool_cancel":
300
+ case "tool_approval_request":
301
+ case "tool_approval_cancel":
302
+ return { reason: "tool-event-schema", expected: "valid-tool-event" };
303
+ case "subagent_lifecycle":
304
+ case "subagent_progress":
305
+ case "subagent_event":
306
+ return { reason: "subagent-event-schema", expected: "valid-subagent-event" };
307
+ case "model_changed":
308
+ case "thinking_level_changed":
309
+ case "goal_updated":
310
+ case "todo_reminder":
311
+ case "todo_auto_clear":
312
+ case "available_commands_update":
313
+ case "retry_fallback_applied":
314
+ case "retry_fallback_succeeded":
315
+ return { reason: "configuration-event-schema", expected: "valid-configuration-event" };
316
+ case "extension_ui_request":
317
+ return { reason: "extension-ui-schema", expected: "valid-extension-ui-event" };
318
+ default:
319
+ return { reason: "lifecycle-event-schema", expected: "valid-lifecycle-event" };
320
+ }
321
+ }
322
+
323
+ function invalidEventDiagnosticMetadata(
324
+ frame: Record<string, unknown>,
325
+ type: string,
326
+ ): Pick<
327
+ OmpProtocolViolationDiagnostic,
328
+ "reason" | "eventType" | "frameType" | "field" | "expected" | "actualType"
329
+ > {
330
+ if (!isProtocolEventType(type)) {
331
+ return {
332
+ reason: "unknown-event-type",
333
+ field: "frame.type",
334
+ expected: "known-event-type",
335
+ actualType: "string",
336
+ };
337
+ }
338
+ if (type !== "notice") {
339
+ return {
340
+ ...eventSchemaClassification(type),
341
+ eventType: type,
342
+ field: "event",
343
+ actualType: "object",
344
+ };
345
+ }
346
+ if (typeof frame.level !== "string") {
347
+ return {
348
+ reason: "notice-level-type",
349
+ eventType: "notice",
350
+ frameType: "notice",
351
+ field: "notice.level",
352
+ expected: "notice-level-enum",
353
+ actualType: protocolDiagnosticActualType(frame.level),
354
+ };
355
+ }
356
+ if (typeof frame.message !== "string") {
357
+ return {
358
+ reason: "notice-message-type",
359
+ eventType: "notice",
360
+ frameType: "notice",
361
+ field: "notice.message",
362
+ expected: "notice-message-string",
363
+ actualType: protocolDiagnosticActualType(frame.message),
364
+ };
365
+ }
366
+ return {
367
+ reason: "notice-schema",
368
+ eventType: "notice",
369
+ frameType: "notice",
370
+ field: "event",
371
+ expected: "valid-event-frame",
372
+ actualType: "object",
373
+ };
374
+ }
375
+
376
+ type PendingProtocolViolation = Omit<OmpProtocolViolationDiagnostic, "occurrenceCount"> & {
377
+ occurrenceCount: number;
378
+ };
379
+ type ProtocolViolationKey = `${OmpProtocolViolationCategory}:${OmpProtocolViolationReason}`;
73
380
  const MAX_CONTEXT_PERCENT = 1_000_000;
74
381
  function boundedJsonString(maxBytes: number, minBytes = 0) {
75
382
  return z.string().refine((value) => {
@@ -101,12 +408,94 @@ function boundedString(maxBytes: number, minBytes = 0) {
101
408
  return bytes >= minBytes && bytes <= maxBytes;
102
409
  });
103
410
  }
411
+ function boundedOptionalStringArray(maxItems: number, maxBytes: number) {
412
+ return z.unknown().transform((value): string[] | undefined => {
413
+ if (!Array.isArray(value)) return undefined;
414
+ return value
415
+ .filter(
416
+ (item): item is string =>
417
+ typeof item === "string" && item.length > 0 && utf8Bytes(item) <= maxBytes,
418
+ )
419
+ .slice(0, maxItems);
420
+ });
421
+ }
104
422
 
105
423
  const IDENTIFIER = boundedString(MAX_ID_LENGTH, 1);
106
424
  const NAME = boundedString(MAX_NAME_LENGTH, 1);
107
425
  const OMP_PROVIDER_NAME = NAME.refine((provider) => !provider.includes("/"));
108
426
  const TEXT = boundedString(MAX_TEXT_LENGTH);
427
+ const RAW_DISPLAY_TEXT = boundedString(MAX_IMAGE_DATA_LENGTH);
428
+ const DISPLAY_TRUNCATION_MARKER = "<truncated>";
429
+
430
+ function boundRawDisplayContent(value: unknown): unknown {
431
+ if (typeof value === "string") {
432
+ return utf8Bytes(value) <= MAX_IMAGE_DATA_LENGTH ? value : DISPLAY_TRUNCATION_MARKER;
433
+ }
434
+ if (!Array.isArray(value)) return value;
435
+ let totalBytes = 0;
436
+ for (const part of value) {
437
+ if (!part || typeof part !== "object" || Array.isArray(part)) continue;
438
+ const record = part as Record<string, unknown>;
439
+ if (typeof record.text === "string") totalBytes += utf8Bytes(record.text);
440
+ if (typeof record.thinking === "string") totalBytes += utf8Bytes(record.thinking);
441
+ }
442
+ if (totalBytes <= MAX_IMAGE_DATA_LENGTH) return value;
443
+
444
+ let retainedMarker = false;
445
+ return value.map((part) => {
446
+ if (!part || typeof part !== "object" || Array.isArray(part)) return part;
447
+ const copy = { ...(part as Record<string, unknown>) };
448
+ for (const key of ["text", "thinking"] as const) {
449
+ if (typeof copy[key] !== "string") continue;
450
+ if (retainedMarker) delete copy[key];
451
+ else {
452
+ copy[key] = DISPLAY_TRUNCATION_MARKER;
453
+ retainedMarker = true;
454
+ }
455
+ }
456
+ return copy;
457
+ });
458
+ }
459
+
460
+ function sanitizeLiveMessageDisplay(value: unknown): unknown {
461
+ if (!value || typeof value !== "object" || Array.isArray(value)) return value;
462
+ const message = value as Record<string, unknown>;
463
+ if (message.role === "assistant") {
464
+ const content = boundRawDisplayContent(message.content);
465
+ return content === message.content ? value : { ...message, content };
466
+ }
467
+ if (
468
+ message.role === "bashExecution" &&
469
+ typeof message.output === "string" &&
470
+ utf8Bytes(message.output) > MAX_IMAGE_DATA_LENGTH
471
+ ) {
472
+ return { ...message, output: DISPLAY_TRUNCATION_MARKER };
473
+ }
474
+ return value;
475
+ }
476
+
477
+ function sanitizeLiveDisplayFrame(value: unknown): unknown {
478
+ if (!value || typeof value !== "object" || Array.isArray(value)) return value;
479
+ const frame = value as Record<string, unknown>;
480
+ if (
481
+ frame.type === "message_start" ||
482
+ frame.type === "message_update" ||
483
+ frame.type === "message_end"
484
+ ) {
485
+ const message = sanitizeLiveMessageDisplay(frame.message);
486
+ return message === frame.message ? value : { ...frame, message };
487
+ }
488
+ if (frame.type !== "agent_end" || !Array.isArray(frame.messages)) return value;
489
+ let changed = false;
490
+ const messages = frame.messages.map((message) => {
491
+ const sanitized = sanitizeLiveMessageDisplay(message);
492
+ if (sanitized !== message) changed = true;
493
+ return sanitized;
494
+ });
495
+ return changed ? { ...frame, messages } : value;
496
+ }
109
497
  const OmpThinkingLevelSchema = z.enum(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
498
+ const OmpCurrentThinkingLevelSchema = boundedString(32).optional().catch(undefined);
110
499
 
111
500
  function isBoundedJson(
112
501
  value: unknown,
@@ -118,6 +507,148 @@ function isBoundedJson(
118
507
  boundedJsonBytes(value, maxBytes, maxItems, maxBytes, maxNodes) !== Number.POSITIVE_INFINITY
119
508
  );
120
509
  }
510
+ function optionalMetadataMetrics(value: unknown) {
511
+ return boundedJsonMetrics(
512
+ value,
513
+ MAX_OPTIONAL_METADATA_BYTES,
514
+ MAX_OPTIONAL_METADATA_ITEMS,
515
+ MAX_OPTIONAL_METADATA_BYTES,
516
+ MAX_OPTIONAL_METADATA_NODES,
517
+ );
518
+ }
519
+
520
+ function optionalMetadataIsBounded(value: unknown): boolean {
521
+ return optionalMetadataMetrics(value) !== undefined;
522
+ }
523
+
524
+ function omitUnsafeOptionalDetails(value: unknown): unknown {
525
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return value;
526
+ const record = value as Record<string, unknown>;
527
+ if (!Object.hasOwn(record, "details") || optionalMetadataIsBounded(record.details)) return value;
528
+ const { details: _details, ...safe } = record;
529
+ return safe;
530
+ }
531
+
532
+ const TASK_RESULT_STATUSES: Readonly<Record<string, true>> = {
533
+ pending: true,
534
+ running: true,
535
+ completed: true,
536
+ failed: true,
537
+ error: true,
538
+ aborted: true,
539
+ canceled: true,
540
+ cancelled: true,
541
+ };
542
+ const TASK_PROGRESS_STATUSES: Readonly<Record<string, true>> = {
543
+ pending: true,
544
+ running: true,
545
+ completed: true,
546
+ failed: true,
547
+ aborted: true,
548
+ };
549
+
550
+ function boundedTaskId(value: unknown): value is string {
551
+ return typeof value === "string" && value.length > 0 && utf8Bytes(value) <= MAX_ID_LENGTH;
552
+ }
553
+
554
+ function taskCorrelationDetails(value: unknown): unknown {
555
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return;
556
+ const details = value as Record<string, unknown>;
557
+ if (!Array.isArray(details.results) || details.results.length > MAX_TASK_CORRELATION_ITEMS)
558
+ return;
559
+ const results: Record<string, unknown>[] = [];
560
+ for (const value of details.results) {
561
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return;
562
+ const result = value as Record<string, unknown>;
563
+ if (!boundedTaskId(result.id)) return;
564
+ const safe: Record<string, unknown> = { id: result.id };
565
+ if (typeof result.status === "string" && Object.hasOwn(TASK_RESULT_STATUSES, result.status)) {
566
+ safe.status = result.status;
567
+ }
568
+ if (typeof result.aborted === "boolean") safe.aborted = result.aborted;
569
+ if (typeof result.exitCode === "number" && Number.isFinite(result.exitCode)) {
570
+ safe.exitCode = result.exitCode;
571
+ }
572
+ if (
573
+ result.error !== undefined &&
574
+ boundedJsonMetrics(result.error, 4_096, 32, 4_096, 64) !== undefined
575
+ ) {
576
+ safe.error = result.error;
577
+ }
578
+ results.push(safe);
579
+ }
580
+ let progress: Record<string, unknown>[] | undefined;
581
+ if (details.progress !== undefined) {
582
+ if (!Array.isArray(details.progress) || details.progress.length > MAX_TASK_CORRELATION_ITEMS) {
583
+ return;
584
+ }
585
+ progress = [];
586
+ for (const value of details.progress) {
587
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return;
588
+ const item = value as Record<string, unknown>;
589
+ if (
590
+ !boundedTaskId(item.id) ||
591
+ typeof item.index !== "number" ||
592
+ !Number.isInteger(item.index) ||
593
+ item.index < 0 ||
594
+ item.index >= MAX_TASK_CORRELATION_ITEMS ||
595
+ typeof item.status !== "string" ||
596
+ !Object.hasOwn(TASK_PROGRESS_STATUSES, item.status)
597
+ ) {
598
+ return;
599
+ }
600
+ progress.push({ id: item.id, index: item.index, status: item.status });
601
+ }
602
+ }
603
+ const correlation = { results, ...(progress ? { progress } : {}) };
604
+ return boundedJsonMetrics(
605
+ correlation,
606
+ MAX_TASK_CORRELATION_BYTES,
607
+ MAX_TASK_CORRELATION_ITEMS,
608
+ MAX_TASK_CORRELATION_BYTES,
609
+ MAX_TASK_CORRELATION_NODES,
610
+ )
611
+ ? correlation
612
+ : undefined;
613
+ }
614
+
615
+ function omitOptionalDetails(value: unknown, preserveTaskCorrelation = false): unknown {
616
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return value;
617
+ const record = value as Record<string, unknown>;
618
+ if (!Object.hasOwn(record, "details")) return value;
619
+ const { details: _details, ...structural } = record;
620
+ if (!preserveTaskCorrelation) return structural;
621
+ const correlation = taskCorrelationDetails(record.details);
622
+ return correlation === undefined ? structural : { ...structural, details: correlation };
623
+ }
624
+
625
+ function createOptionalMetadataSanitizer(): (value: unknown, taskResult?: boolean) => unknown {
626
+ let retainedBytes = 0;
627
+ let retainedNodes = 0;
628
+ return (value, taskResult = false) => {
629
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return value;
630
+ const record = value as Record<string, unknown>;
631
+ if (!Object.hasOwn(record, "details")) return value;
632
+ const metrics = optionalMetadataMetrics(record.details);
633
+ if (
634
+ metrics &&
635
+ retainedBytes + metrics.bytes <= MAX_OPTIONAL_METADATA_BYTES &&
636
+ retainedNodes + metrics.nodes <= MAX_OPTIONAL_METADATA_NODES
637
+ ) {
638
+ retainedBytes += metrics.bytes;
639
+ retainedNodes += metrics.nodes;
640
+ return value;
641
+ }
642
+ const correlation = taskResult ? taskCorrelationDetails(record.details) : undefined;
643
+ const { details: _details, ...safe } = record;
644
+ return correlation === undefined ? safe : { ...safe, details: correlation };
645
+ };
646
+ }
647
+
648
+ const OmpOptionalMetadataSchema = z.preprocess(
649
+ (value) => (optionalMetadataIsBounded(value) ? value : undefined),
650
+ z.unknown().optional(),
651
+ );
121
652
 
122
653
  const OmpContentPartSchema = z
123
654
  .object({
@@ -147,6 +678,14 @@ const OmpContentPartSchema = z
147
678
  context.addIssue({ code: "custom", message: "invalid image payload" });
148
679
  }
149
680
  });
681
+ const OmpAssistantContentPartSchema = OmpContentPartSchema.safeExtend({
682
+ text: RAW_DISPLAY_TEXT.optional(),
683
+ thinking: RAW_DISPLAY_TEXT.optional(),
684
+ });
685
+ const OmpAssistantDisplayContentSchema = z.preprocess(
686
+ boundRawDisplayContent,
687
+ z.union([RAW_DISPLAY_TEXT, z.array(OmpAssistantContentPartSchema).max(OMP_MAX_CONTENT_PARTS)]),
688
+ );
150
689
  const OmpDisplayContentSchema = z.union([
151
690
  TEXT,
152
691
  z.array(OmpContentPartSchema).max(OMP_MAX_CONTENT_PARTS),
@@ -165,10 +704,7 @@ const OmpMessageIdentityShape = {
165
704
  responseId: IDENTIFIER.optional(),
166
705
  images: OmpImageArraySchema.optional(),
167
706
  timestamp: z.number().finite().optional(),
168
- details: z
169
- .unknown()
170
- .refine((value) => isBoundedJson(value, MAX_SEMANTIC_FRAME_BYTES, 1_024, 4_096))
171
- .optional(),
707
+ details: OmpOptionalMetadataSchema,
172
708
  };
173
709
  type OmpContentPart = z.infer<typeof OmpContentPartSchema>;
174
710
  type OmpMessageIdentity = {
@@ -218,7 +754,7 @@ export type OmpMessage = OmpMessageIdentity &
218
754
  const OmpMessageSchema: z.ZodType<OmpMessage> = z.union([
219
755
  z.object({
220
756
  role: z.literal("assistant"),
221
- content: OmpDisplayContentSchema.optional(),
757
+ content: OmpAssistantDisplayContentSchema.optional(),
222
758
  ...OmpMessageIdentityShape,
223
759
  errorMessage: boundedString(4_096).nullable().optional(),
224
760
  stopReason: boundedString(64).optional(),
@@ -241,7 +777,13 @@ const OmpMessageSchema: z.ZodType<OmpMessage> = z.union([
241
777
  z.object({
242
778
  role: z.literal("bashExecution"),
243
779
  command: TEXT,
244
- output: TEXT.optional(),
780
+ output: z.preprocess(
781
+ (value) =>
782
+ typeof value === "string" && utf8Bytes(value) > MAX_IMAGE_DATA_LENGTH
783
+ ? DISPLAY_TRUNCATION_MARKER
784
+ : value,
785
+ RAW_DISPLAY_TEXT.optional(),
786
+ ),
245
787
  exitCode: z.number().int().nullable().optional(),
246
788
  cancelled: z.boolean().optional(),
247
789
  truncated: z.boolean().optional(),
@@ -309,19 +851,33 @@ const OmpAvailableCommandSchema = z.object({
309
851
  .optional(),
310
852
  source: boundedString(64).optional(),
311
853
  });
854
+ const OmpThinkingMetadataSchema = z
855
+ .unknown()
856
+ .transform((value): { efforts?: string[]; defaultLevel?: string } | undefined => {
857
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
858
+ const record = value as Record<string, unknown>;
859
+ const efforts = boundedOptionalStringArray(16, 32).parse(record.efforts);
860
+ const defaultLevel = boundedString(32).optional().catch(undefined).parse(record.defaultLevel);
861
+ return {
862
+ ...(efforts !== undefined ? { efforts } : {}),
863
+ ...(defaultLevel !== undefined ? { defaultLevel } : {}),
864
+ };
865
+ });
312
866
  const OmpModelSchema = z.object({
313
867
  provider: OMP_PROVIDER_NAME,
314
868
  id: NAME,
315
- name: boundedString(MAX_NAME_LENGTH).optional(),
316
- reasoning: z.boolean().optional(),
317
- thinking: z
318
- .object({
319
- efforts: z.array(boundedString(32)).max(16).optional(),
320
- defaultLevel: boundedString(32).optional(),
321
- })
322
- .optional(),
323
- input: z.array(NAME).max(16).optional(),
324
- contextWindow: z.number().int().nonnegative().max(100_000_000).nullable().optional(),
869
+ name: boundedString(MAX_NAME_LENGTH).optional().catch(undefined),
870
+ reasoning: z.boolean().optional().catch(undefined),
871
+ thinking: OmpThinkingMetadataSchema.optional(),
872
+ input: boundedOptionalStringArray(16, MAX_NAME_LENGTH).optional(),
873
+ contextWindow: z
874
+ .number()
875
+ .int()
876
+ .nonnegative()
877
+ .max(100_000_000)
878
+ .nullable()
879
+ .optional()
880
+ .catch(undefined),
325
881
  });
326
882
  const TokenCountSchema = z.number().int().nonnegative().max(MAX_TOKEN_COUNT);
327
883
  const OptionalTokenCountSchema = TokenCountSchema.nullable().optional();
@@ -373,13 +929,13 @@ const OmpCompactionResultSchema = z.object({
373
929
  });
374
930
  const OmpSessionStateSchema = z.object({
375
931
  model: OmpModelSchema.nullable().optional(),
376
- thinkingLevel: OmpThinkingLevelSchema.optional(),
932
+ thinkingLevel: OmpCurrentThinkingLevelSchema,
377
933
  isStreaming: z.boolean(),
378
934
  isCompacting: z.boolean(),
379
935
  sessionId: IDENTIFIER,
380
936
  autoCompactionEnabled: z.boolean().optional(),
381
- contextUsage: OmpContextUsageSchema.nullable().optional(),
382
- sessionFile: boundedString(MAX_PATH_LENGTH).optional(),
937
+ contextUsage: OmpContextUsageSchema.nullable().optional().catch(undefined),
938
+ sessionFile: boundedString(MAX_PATH_LENGTH).optional().catch(undefined),
383
939
  });
384
940
  const OmpReadyFrameSchema = z.object({
385
941
  type: z.literal("ready"),
@@ -409,6 +965,16 @@ const JsonObjectSchema = z.record(z.string(), z.unknown());
409
965
  const BoundedToolPayloadSchema = z
410
966
  .unknown()
411
967
  .refine((value) => isBoundedJson(value, MAX_SEMANTIC_FRAME_BYTES, 1_024, 4_096));
968
+ const OmpToolResultPayloadSchema = z.preprocess(
969
+ omitUnsafeOptionalDetails,
970
+ z
971
+ .unknown()
972
+ .refine(
973
+ (value) =>
974
+ isBoundedJson(value, MAX_SEMANTIC_FRAME_BYTES, MAX_OPTIONAL_METADATA_ITEMS, 8_192) &&
975
+ isBoundedJson(omitOptionalDetails(value), MAX_SEMANTIC_FRAME_BYTES, 1_024, 4_096),
976
+ ),
977
+ );
412
978
  const OmpHostToolDefinitionSchema = z.object({
413
979
  name: NAME,
414
980
  label: NAME.optional(),
@@ -616,13 +1182,13 @@ const OmpAgentSessionEventSchema = z.discriminatedUnion("type", [
616
1182
  toolCallId: IDENTIFIER,
617
1183
  toolName: NAME,
618
1184
  args: BoundedToolPayloadSchema.optional(),
619
- partialResult: BoundedToolPayloadSchema,
1185
+ partialResult: OmpToolResultPayloadSchema,
620
1186
  }),
621
1187
  z.object({
622
1188
  type: z.literal("tool_execution_end"),
623
1189
  toolCallId: IDENTIFIER,
624
1190
  toolName: NAME,
625
- result: BoundedToolPayloadSchema,
1191
+ result: OmpToolResultPayloadSchema,
626
1192
  isError: z.boolean().optional(),
627
1193
  }),
628
1194
  OmpCompactionStartSchema,
@@ -873,8 +1439,104 @@ const OmpRuntimeEventSchema = z.discriminatedUnion("type", [
873
1439
  OmpToolApprovalCancelSchema,
874
1440
  z.object({ type: z.literal("advisor_yielded") }),
875
1441
  ]);
1442
+ type OptionalDetailsMapper = (value: unknown, taskResult?: boolean) => unknown;
1443
+
1444
+ function mapRecordField(
1445
+ record: Record<string, unknown>,
1446
+ key: string,
1447
+ map: OptionalDetailsMapper,
1448
+ taskResult = false,
1449
+ ): Record<string, unknown> {
1450
+ if (!Object.hasOwn(record, key)) return record;
1451
+ const next = map(record[key], taskResult);
1452
+ return next === record[key] ? record : { ...record, [key]: next };
1453
+ }
1454
+
1455
+ function mapMessageList(value: unknown, map: OptionalDetailsMapper): unknown {
1456
+ if (!Array.isArray(value)) return value;
1457
+ let changed = false;
1458
+ const messages = value.map((message) => {
1459
+ const taskResult =
1460
+ message !== null &&
1461
+ typeof message === "object" &&
1462
+ !Array.isArray(message) &&
1463
+ (message as Record<string, unknown>).role === "toolResult" &&
1464
+ (message as Record<string, unknown>).toolName === "task";
1465
+ const next = map(message, taskResult);
1466
+ changed ||= next !== message;
1467
+ return next;
1468
+ });
1469
+ return changed ? messages : value;
1470
+ }
1471
+
1472
+ function mapAgentEventDetails(
1473
+ frame: Record<string, unknown>,
1474
+ map: OptionalDetailsMapper,
1475
+ ): Record<string, unknown> {
1476
+ switch (frame.type) {
1477
+ case "message_start":
1478
+ case "message_update":
1479
+ case "message_end": {
1480
+ const message = frame.message;
1481
+ const taskResult =
1482
+ message !== null &&
1483
+ typeof message === "object" &&
1484
+ !Array.isArray(message) &&
1485
+ (message as Record<string, unknown>).role === "toolResult" &&
1486
+ (message as Record<string, unknown>).toolName === "task";
1487
+ return mapRecordField(frame, "message", map, taskResult);
1488
+ }
1489
+ case "tool_execution_update":
1490
+ return mapRecordField(frame, "partialResult", map, frame.toolName === "task");
1491
+ case "tool_execution_end":
1492
+ return mapRecordField(frame, "result", map, frame.toolName === "task");
1493
+ case "agent_end":
1494
+ return mapRecordField(frame, "messages", (messages) => mapMessageList(messages, map));
1495
+ default:
1496
+ return frame;
1497
+ }
1498
+ }
1499
+
1500
+ function mapRuntimeFrameDetails(
1501
+ frame: Record<string, unknown>,
1502
+ map: OptionalDetailsMapper,
1503
+ ): Record<string, unknown> {
1504
+ if (frame.type !== "subagent_event") return mapAgentEventDetails(frame, map);
1505
+ if (frame.payload === null || typeof frame.payload !== "object" || Array.isArray(frame.payload)) {
1506
+ return frame;
1507
+ }
1508
+ const payload = frame.payload as Record<string, unknown>;
1509
+ if (payload.event === null || typeof payload.event !== "object" || Array.isArray(payload.event)) {
1510
+ return frame;
1511
+ }
1512
+ const event = mapAgentEventDetails(payload.event as Record<string, unknown>, map);
1513
+ return event === payload.event ? frame : { ...frame, payload: { ...payload, event } };
1514
+ }
1515
+
1516
+ function sanitizeMessageListMetadata(value: unknown): unknown {
1517
+ return mapMessageList(value, createOptionalMetadataSanitizer());
1518
+ }
1519
+
1520
+ function sanitizeHistoryResponseData(value: unknown): unknown {
1521
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return value;
1522
+ return mapRecordField(value as Record<string, unknown>, "messages", sanitizeMessageListMetadata);
1523
+ }
1524
+
1525
+ function runtimeFrameCollectionLimit(frame: Record<string, unknown>): number {
1526
+ const type = frame.type;
1527
+ if (
1528
+ type === "message_start" ||
1529
+ type === "message_update" ||
1530
+ type === "message_end" ||
1531
+ type === "agent_end" ||
1532
+ type === "subagent_event"
1533
+ ) {
1534
+ return OMP_MAX_CONTENT_PARTS;
1535
+ }
1536
+ return 1_024;
1537
+ }
876
1538
  const OmpModelsResultSchema = z.object({
877
- models: z.array(OmpModelSchema).min(1).max(256),
1539
+ models: z.array(OmpModelSchema).min(1).max(MAX_MODEL_CATALOG_ITEMS),
878
1540
  });
879
1541
  const OmpPromptAckSchema = z.object({ agentInvoked: z.boolean().optional() }).optional();
880
1542
  const OmpAvailableCommandsResultSchema = z.object({
@@ -919,6 +1581,7 @@ const ProtocolNegotiationResultSchema = z.object({
919
1581
  });
920
1582
 
921
1583
  export type OmpModel = z.infer<typeof OmpModelSchema>;
1584
+ export type OmpBranchResult = z.infer<typeof OmpBranchResultSchema>;
922
1585
  export type OmpSessionState = z.infer<typeof OmpSessionStateSchema>;
923
1586
  export type OmpSessionStats = z.infer<typeof OmpSessionStatsSchema>;
924
1587
  export type OmpCompactionResult = z.infer<typeof OmpCompactionResultSchema>;
@@ -960,6 +1623,7 @@ export interface OmpPersistedSessionMessages {
960
1623
  nativeSessionId: string;
961
1624
  byteLength: number;
962
1625
  messages: OmpMessage[];
1626
+ imageReplayWarning?: true;
963
1627
  }
964
1628
 
965
1629
  export interface OmpStartOptions {
@@ -1025,7 +1689,7 @@ export interface OmpRuntimeSession {
1025
1689
  respondToExtensionUi(response: OmpExtensionUiResponse): Promise<void>;
1026
1690
  respondToToolApproval(response: OmpToolApprovalResponse): Promise<void>;
1027
1691
  getBranchMessages(): Promise<Array<{ entryId: string; text: string }>>;
1028
- branch(entryId: string): Promise<{ text: string; cancelled: boolean }>;
1692
+ branch(entryId: string): Promise<OmpBranchResult>;
1029
1693
  readonly canReplayHistory: boolean;
1030
1694
  getMessages(): Promise<OmpMessage[]>;
1031
1695
  abort(): Promise<void>;
@@ -1067,11 +1731,19 @@ export interface OmpRpcRuntimeOptions {
1067
1731
  terminateProcessTree?: (pid: number) => Promise<boolean | "uncertain">;
1068
1732
  environment?: NodeJS.ProcessEnv;
1069
1733
  requestTimeoutMs?: number;
1734
+ reportProtocolViolation?: (diagnostic: OmpProtocolViolationDiagnostic) => void | Promise<void>;
1070
1735
  listSessions?: (
1071
1736
  options: OmpSessionListOptions,
1072
1737
  ) => OmpSessionDescriptor[] | Promise<OmpSessionDescriptor[]>;
1073
1738
  }
1074
1739
 
1740
+ export class OmpRpcRequestRejectedError extends Error {
1741
+ constructor() {
1742
+ super("OMP RPC request failed");
1743
+ this.name = "OmpRpcRequestRejectedError";
1744
+ }
1745
+ }
1746
+
1075
1747
  type PendingRequest = {
1076
1748
  resolve(value: unknown): void;
1077
1749
  reject(error: Error): void;
@@ -1591,12 +2263,21 @@ class OmpRpcProcess {
1591
2263
  private spawnFailedWithoutProcess = false;
1592
2264
  private readyReceived = false;
1593
2265
  private outputSettled = false;
2266
+ private turnActive = false;
2267
+ private readonly pendingProtocolViolations = new Map<
2268
+ ProtocolViolationKey,
2269
+ PendingProtocolViolation
2270
+ >();
2271
+ private protocolViolationTimer: TimerHandle | null = null;
1594
2272
 
1595
2273
  constructor(
1596
2274
  options: OmpStartOptions,
1597
2275
  spawnProcess?: OmpRpcRuntimeOptions["spawnProcess"],
1598
2276
  terminateProcessTree?: OmpRpcRuntimeOptions["terminateProcessTree"],
1599
2277
  private readonly requestTimeoutMs = REQUEST_TIMEOUT_MS,
2278
+ private readonly reportProtocolViolation: (
2279
+ diagnostic: OmpProtocolViolationDiagnostic,
2280
+ ) => void | Promise<void> = (diagnostic) => console.error("OMP protocol violation", diagnostic),
1600
2281
  ) {
1601
2282
  const ready = Promise.withResolvers<ReadyFrame>();
1602
2283
  this.rejectReady = ready.reject;
@@ -1685,7 +2366,15 @@ class OmpRpcProcess {
1685
2366
  private settleOutput(): void {
1686
2367
  if (this.outputSettled) return;
1687
2368
  this.outputSettled = true;
1688
- if (this.lineBytes > 0 || this.discardingLine) this.recordProtocolViolation();
2369
+ if (this.lineBytes > 0 || this.discardingLine) {
2370
+ this.recordProtocolViolation("incomplete-frame", {
2371
+ reason: "output-ended-mid-frame",
2372
+ field: "frame",
2373
+ expected: "complete-json-line",
2374
+ actualType: "partial-frame",
2375
+ maxByteSize: this.discardingLine ? this.discardedLineBytes : this.lineBytes,
2376
+ });
2377
+ }
1689
2378
  this.lineParts = [];
1690
2379
  this.lineBytes = 0;
1691
2380
  this.discardingLine = false;
@@ -1868,16 +2557,17 @@ class OmpRpcProcess {
1868
2557
 
1869
2558
  private async closeProcess(): Promise<void> {
1870
2559
  this.closed = true;
2560
+ this.flushProtocolViolations();
1871
2561
  this.clearChunk();
1872
2562
  this.failPending(new Error("OMP RPC process was closed"));
2563
+ const cleanupPromise = this.startTreeCleanup();
1873
2564
  if (!this.exited) {
1874
2565
  try {
1875
2566
  this.child.stdin.end();
1876
2567
  } catch {
1877
- // Continue to process-tree cleanup when the input channel is already closed.
2568
+ // Continue waiting for process-tree cleanup when the input channel is already closed.
1878
2569
  }
1879
2570
  }
1880
- const cleanupPromise = this.startTreeCleanup();
1881
2571
  const cleanup = await cleanupPromise;
1882
2572
  if (cleanup !== "verified") throw new Error("OMP RPC process tree cleanup failed");
1883
2573
  if (
@@ -1964,7 +2654,14 @@ class OmpRpcProcess {
1964
2654
  this.lineParts = [];
1965
2655
  this.lineBytes = 0;
1966
2656
  this.discardingLine = true;
1967
- this.recordProtocolViolation();
2657
+ this.recordProtocolViolation("frame-limit", {
2658
+ reason: "physical-frame-limit",
2659
+ field: "frame.byteLength",
2660
+ expected: "within-byte-limit",
2661
+ actualType: "oversized",
2662
+ maxByteSize: nextBytes,
2663
+ limitBytes: this.physicalFrameLimit,
2664
+ });
1968
2665
  return;
1969
2666
  }
1970
2667
  this.lineParts.push(part);
@@ -1985,24 +2682,16 @@ class OmpRpcProcess {
1985
2682
  try {
1986
2683
  decoded = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(payload));
1987
2684
  } catch {
1988
- this.recordProtocolViolation();
1989
- return;
1990
- }
1991
- if (this.receiveKnownResponse(decoded)) return;
1992
- if (this.receiveDegradedAgentEnd(decoded, true)) return;
1993
- if (
1994
- boundedJsonBytes(decoded, MAX_SEMANTIC_FRAME_BYTES, 1_024, MAX_IMAGE_DATA_LENGTH, 4_096) ===
1995
- Number.POSITIVE_INFINITY
1996
- ) {
1997
- this.recordProtocolViolation();
1998
- return;
1999
- }
2000
- const frame = JsonObjectSchema.safeParse(decoded);
2001
- if (!frame.success) {
2002
- this.recordProtocolViolation();
2685
+ this.recordProtocolViolation("invalid-json", {
2686
+ reason: "json-decode",
2687
+ field: "frame",
2688
+ expected: "valid-json",
2689
+ actualType: "invalid-json",
2690
+ maxByteSize: payload.byteLength,
2691
+ });
2003
2692
  return;
2004
2693
  }
2005
- this.receiveFrame(frame.data);
2694
+ this.receiveDecodedFrame(decoded, payload.byteLength);
2006
2695
  }
2007
2696
 
2008
2697
  private receiveChunk(frame: ChunkFrame): void {
@@ -2022,17 +2711,30 @@ class OmpRpcProcess {
2022
2711
  frame.data.length % 4 !== 0 ||
2023
2712
  !/^[A-Za-z0-9+/]*={0,2}$/u.test(frame.data)
2024
2713
  ) {
2025
- this.rejectChunk();
2714
+ this.rejectChunk("chunk-metadata", {
2715
+ field: "chunk",
2716
+ expected: "valid-chunk-frame",
2717
+ actualType: "object",
2718
+ });
2026
2719
  return;
2027
2720
  }
2028
2721
  const decoded = Buffer.from(frame.data, "base64");
2029
2722
  if (decoded.byteLength > MAX_CHUNK_BYTES) {
2030
- this.rejectChunk();
2723
+ this.rejectChunk("chunk-size", {
2724
+ field: "chunk.data",
2725
+ expected: "within-byte-limit",
2726
+ actualType: "oversized",
2727
+ limitBytes: MAX_CHUNK_BYTES,
2728
+ });
2031
2729
  return;
2032
2730
  }
2033
2731
  if (!this.chunk) {
2034
2732
  if (frame.index !== 0) {
2035
- this.rejectChunk();
2733
+ this.rejectChunk("chunk-start-index", {
2734
+ field: "chunk.index",
2735
+ expected: "first-chunk-index-zero",
2736
+ actualType: "out-of-order",
2737
+ });
2036
2738
  return;
2037
2739
  }
2038
2740
  this.chunk = {
@@ -2041,7 +2743,15 @@ class OmpRpcProcess {
2041
2743
  byteLength: frame.byteLength,
2042
2744
  parts: [],
2043
2745
  receivedBytes: 0,
2044
- timer: setTimeout(() => this.rejectChunk(), CHUNK_STALE_MS),
2746
+ timer: setTimeout(
2747
+ () =>
2748
+ this.rejectChunk("chunk-timeout", {
2749
+ field: "chunk.sequence",
2750
+ expected: "chunk-before-deadline",
2751
+ actualType: "timeout",
2752
+ }),
2753
+ CHUNK_STALE_MS,
2754
+ ),
2045
2755
  };
2046
2756
  }
2047
2757
  const chunk = this.chunk;
@@ -2053,14 +2763,22 @@ class OmpRpcProcess {
2053
2763
  chunk.receivedBytes + decoded.byteLength > chunk.byteLength ||
2054
2764
  chunk.receivedBytes + decoded.byteLength > this.reassembledFrameLimit
2055
2765
  ) {
2056
- this.rejectChunk();
2766
+ this.rejectChunk("chunk-sequence", {
2767
+ field: "chunk.sequence",
2768
+ expected: "contiguous-chunk-sequence",
2769
+ actualType: "out-of-order",
2770
+ });
2057
2771
  return;
2058
2772
  }
2059
2773
  chunk.parts.push(decoded);
2060
2774
  chunk.receivedBytes += decoded.byteLength;
2061
2775
  if (chunk.parts.length !== chunk.count) return;
2062
2776
  if (chunk.receivedBytes !== chunk.byteLength) {
2063
- this.rejectChunk();
2777
+ this.rejectChunk("chunk-byte-count", {
2778
+ field: "chunk.byteLength",
2779
+ expected: "declared-chunk-byte-count",
2780
+ actualType: "mismatched",
2781
+ });
2064
2782
  return;
2065
2783
  }
2066
2784
  const reassembled = Buffer.concat(chunk.parts, chunk.receivedBytes);
@@ -2069,29 +2787,48 @@ class OmpRpcProcess {
2069
2787
  try {
2070
2788
  decodedFrame = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(reassembled));
2071
2789
  } catch {
2072
- this.recordProtocolViolation();
2790
+ this.recordProtocolViolation("invalid-json", {
2791
+ reason: "chunk-json-decode",
2792
+ field: "chunk.data",
2793
+ expected: "valid-json",
2794
+ actualType: "invalid-json",
2795
+ maxByteSize: reassembled.byteLength,
2796
+ });
2073
2797
  return;
2074
2798
  }
2075
- if (this.receiveKnownResponse(decodedFrame)) return;
2076
- if (this.receiveDegradedAgentEnd(decodedFrame, true)) return;
2077
- if (
2078
- boundedJsonBytes(
2079
- decodedFrame,
2080
- MAX_SEMANTIC_FRAME_BYTES,
2081
- 1_024,
2082
- MAX_IMAGE_DATA_LENGTH,
2083
- 4_096,
2084
- ) === Number.POSITIVE_INFINITY
2085
- ) {
2086
- this.recordProtocolViolation();
2799
+ this.receiveDecodedFrame(decodedFrame, reassembled.byteLength);
2800
+ }
2801
+
2802
+ private receiveDecodedFrame(value: unknown, rawByteLength: number): void {
2803
+ if (value && typeof value === "object" && !Array.isArray(value)) {
2804
+ const frame = value as Record<string, unknown>;
2805
+ const pending = typeof frame.id === "string" ? this.pending.get(frame.id) : undefined;
2806
+ if (
2807
+ frame.type === "response" &&
2808
+ (pending?.command === "get_messages" || pending?.command === "get_subagent_messages")
2809
+ ) {
2810
+ this.receiveResponse(frame);
2811
+ return;
2812
+ }
2813
+ }
2814
+ if (rawByteLength > MAX_SEMANTIC_FRAME_BYTES) {
2815
+ this.fail(new Error("OMP RPC frame exceeds the semantic byte limit"));
2087
2816
  return;
2088
2817
  }
2089
- const frameObject = JsonObjectSchema.safeParse(decodedFrame);
2090
- if (!frameObject.success) {
2091
- this.recordProtocolViolation();
2818
+ if (this.receiveKnownResponse(value)) return;
2819
+ const sanitized = sanitizeLiveDisplayFrame(value);
2820
+ const frame = JsonObjectSchema.safeParse(sanitized);
2821
+ if (!frame.success) {
2822
+ this.recordProtocolViolation("invalid-envelope", {
2823
+ reason: "object-envelope",
2824
+ field: "frame",
2825
+ expected: "object-envelope",
2826
+ actualType: protocolDiagnosticActualType(sanitized),
2827
+ maxByteSize: rawByteLength,
2828
+ });
2092
2829
  return;
2093
2830
  }
2094
- this.receiveFrame(frameObject.data);
2831
+ this.receiveFrame(frame.data, rawByteLength);
2095
2832
  }
2096
2833
 
2097
2834
  private receiveKnownResponse(value: unknown): boolean {
@@ -2113,10 +2850,21 @@ class OmpRpcProcess {
2113
2850
  const knownPending = rawId ? this.pending.get(rawId) : undefined;
2114
2851
  const response = OmpResponseFrameSchema.safeParse(frame);
2115
2852
  if (!response.success) {
2853
+ const handledAcceptedFailure =
2854
+ rawId !== undefined && knownPending === undefined
2855
+ ? this.emitAcceptedPromptFailure(rawId, frame)
2856
+ : false;
2857
+ if (!handledAcceptedFailure) {
2858
+ this.recordProtocolViolation("invalid-response", {
2859
+ reason: "response-schema",
2860
+ frameType: "response",
2861
+ field: "response",
2862
+ expected: "valid-response-frame",
2863
+ actualType: "object",
2864
+ });
2865
+ }
2116
2866
  if (rawId && knownPending) {
2117
2867
  this.takePending(rawId)?.reject(new Error("OMP RPC response is invalid"));
2118
- } else if (!rawId || !this.emitAcceptedPromptFailure(rawId, frame)) {
2119
- this.recordProtocolViolation();
2120
2868
  }
2121
2869
  return;
2122
2870
  }
@@ -2130,13 +2878,24 @@ class OmpRpcProcess {
2130
2878
  const isBranchHistory = pending.command === "get_branch_messages";
2131
2879
  const isHistory =
2132
2880
  pending.command === "get_messages" || pending.command === "get_subagent_messages";
2133
- const responseItemLimit = isBranchHistory ? 1_024 : isHistory ? 100_000 : MAX_ARRAY_ITEMS;
2881
+ const responseData = isHistory
2882
+ ? sanitizeHistoryResponseData(response.data.data)
2883
+ : response.data.data;
2884
+ const boundedFrame =
2885
+ responseData === response.data.data ? frame : { ...frame, data: responseData };
2886
+ const responseItemLimit = isBranchHistory
2887
+ ? 1_024
2888
+ : isHistory
2889
+ ? 100_000
2890
+ : pending.command === "get_available_models"
2891
+ ? MAX_MODEL_CATALOG_ITEMS
2892
+ : MAX_ARRAY_ITEMS;
2134
2893
  const responseByteLimit =
2135
2894
  isBranchHistory || isHistory
2136
2895
  ? Math.min(MAX_REASSEMBLED_FRAME_BYTES, this.reassembledFrameLimit)
2137
2896
  : 2 * 1024 * 1024;
2138
- // A model catalog contains up to 256 structured models, so its aggregate
2139
- // node budget must exceed the small state/command-response budget.
2897
+ // Model catalogs are truncated before publication, while the transport still enforces
2898
+ // aggregate byte and node budgets over the complete response.
2140
2899
  const responseNodeLimit = isBranchHistory
2141
2900
  ? 4_096
2142
2901
  : isHistory
@@ -2146,7 +2905,7 @@ class OmpRpcProcess {
2146
2905
  : 2_048;
2147
2906
  if (
2148
2907
  boundedJsonBytes(
2149
- frame,
2908
+ boundedFrame,
2150
2909
  responseByteLimit,
2151
2910
  responseItemLimit,
2152
2911
  MAX_IMAGE_DATA_LENGTH,
@@ -2162,7 +2921,7 @@ class OmpRpcProcess {
2162
2921
  if (!settled) return;
2163
2922
  if (response.data.success) {
2164
2923
  try {
2165
- settled.beforeResolve?.(response.data.data);
2924
+ settled.beforeResolve?.(responseData);
2166
2925
  if (settled.command === "prompt") {
2167
2926
  if (this.acceptedPromptIds.size >= MAX_PENDING_REQUESTS) {
2168
2927
  const oldest = this.acceptedPromptIds.values().next().value;
@@ -2170,12 +2929,12 @@ class OmpRpcProcess {
2170
2929
  }
2171
2930
  this.acceptedPromptIds.add(response.data.id);
2172
2931
  }
2173
- settled.resolve(response.data.data);
2932
+ settled.resolve(responseData);
2174
2933
  } catch {
2175
2934
  settled.reject(new Error("OMP RPC response is invalid"));
2176
2935
  }
2177
2936
  } else {
2178
- settled.reject(new Error("OMP RPC request failed"));
2937
+ settled.reject(new OmpRpcRequestRejectedError());
2179
2938
  }
2180
2939
  }
2181
2940
 
@@ -2219,21 +2978,27 @@ class OmpRpcProcess {
2219
2978
  this.fail(new Error("OMP emitted invalid terminal metadata"));
2220
2979
  return true;
2221
2980
  }
2981
+ const structuralFrame = mapRuntimeFrameDetails(frame, omitOptionalDetails);
2222
2982
  const messagesAreSafe =
2223
2983
  frame.messages === undefined ||
2224
2984
  (Array.isArray(frame.messages) &&
2225
2985
  frame.messages.length <= MAX_ARRAY_ITEMS &&
2226
2986
  boundedJsonBytes(
2227
- frame.messages,
2987
+ structuralFrame.messages as unknown[],
2228
2988
  MAX_SEMANTIC_FRAME_BYTES,
2229
- MAX_ARRAY_ITEMS,
2230
- MAX_TEXT_LENGTH,
2989
+ OMP_MAX_CONTENT_PARTS,
2990
+ MAX_IMAGE_DATA_LENGTH,
2231
2991
  4_096,
2232
2992
  ) !== Number.POSITIVE_INFINITY);
2233
2993
  const payloadIsSafe =
2234
2994
  messagesAreSafe &&
2235
- boundedJsonBytes(frame, MAX_SEMANTIC_FRAME_BYTES, 1_024, MAX_IMAGE_DATA_LENGTH, 4_096) !==
2236
- Number.POSITIVE_INFINITY;
2995
+ boundedJsonBytes(
2996
+ structuralFrame,
2997
+ MAX_SEMANTIC_FRAME_BYTES,
2998
+ OMP_MAX_CONTENT_PARTS,
2999
+ MAX_IMAGE_DATA_LENGTH,
3000
+ 4_096,
3001
+ ) !== Number.POSITIVE_INFINITY;
2237
3002
  if (onlyUnsafePayload && payloadIsSafe) return false;
2238
3003
  if (envelope.data.isTerminal === false) {
2239
3004
  this.fail(new Error("OMP emitted an invalid nonterminal agent_end payload"));
@@ -2254,42 +3019,89 @@ class OmpRpcProcess {
2254
3019
  return true;
2255
3020
  }
2256
3021
 
2257
- private receiveFrame(frame: Record<string, unknown>): void {
3022
+ private receiveFrame(frame: Record<string, unknown>, rawByteLength: number): void {
2258
3023
  const type = typeof frame.type === "string" && frame.type.length <= 64 ? frame.type : null;
2259
3024
  if (!type) {
2260
- this.recordProtocolViolation();
3025
+ this.recordProtocolViolation("invalid-envelope", {
3026
+ reason: "missing-frame-type",
3027
+ field: "frame.type",
3028
+ expected: "bounded-frame-type",
3029
+ actualType: protocolDiagnosticActualType(frame.type),
3030
+ maxByteSize: rawByteLength,
3031
+ });
2261
3032
  return;
2262
3033
  }
2263
- if (this.receiveDegradedAgentEnd(frame, true)) return;
3034
+ const safeFrame = mapRuntimeFrameDetails(frame, createOptionalMetadataSanitizer());
3035
+ if (this.receiveDegradedAgentEnd(safeFrame, true)) return;
2264
3036
  if (
2265
- boundedJsonBytes(frame, MAX_SEMANTIC_FRAME_BYTES, 1_024, MAX_IMAGE_DATA_LENGTH, 4_096) ===
2266
- Number.POSITIVE_INFINITY
3037
+ boundedJsonBytes(
3038
+ mapRuntimeFrameDetails(safeFrame, omitOptionalDetails),
3039
+ MAX_SEMANTIC_FRAME_BYTES,
3040
+ runtimeFrameCollectionLimit(safeFrame),
3041
+ MAX_IMAGE_DATA_LENGTH,
3042
+ 4_096,
3043
+ ) === Number.POSITIVE_INFINITY
2267
3044
  ) {
2268
- this.recordProtocolViolation();
3045
+ this.recordProtocolViolation("frame-limit", {
3046
+ reason: "semantic-frame-limit",
3047
+ field: "frame.byteLength",
3048
+ expected: "within-byte-limit",
3049
+ actualType: "oversized",
3050
+ maxByteSize: rawByteLength,
3051
+ limitBytes: MAX_SEMANTIC_FRAME_BYTES,
3052
+ });
2269
3053
  return;
2270
3054
  }
2271
3055
  if (type === "rpc_chunk") {
2272
- const chunk = OmpChunkFrameSchema.safeParse(frame);
2273
- if (!chunk.success) this.rejectChunk();
2274
- else this.receiveChunk(chunk.data);
3056
+ const chunk = OmpChunkFrameSchema.safeParse(safeFrame);
3057
+ if (!chunk.success) {
3058
+ this.rejectChunk("chunk-schema", {
3059
+ field: "chunk",
3060
+ expected: "valid-chunk-frame",
3061
+ actualType: "object",
3062
+ });
3063
+ } else this.receiveChunk(chunk.data);
2275
3064
  return;
2276
3065
  }
2277
3066
  if (this.chunk) {
2278
3067
  this.clearChunk();
2279
- this.recordProtocolViolation();
3068
+ this.recordProtocolViolation("interleaved-chunk", {
3069
+ reason: "frame-interleaved-with-chunk",
3070
+ field: "chunk.sequence",
3071
+ expected: "no-interleaved-frame",
3072
+ actualType: "interleaved",
3073
+ });
2280
3074
  }
2281
3075
  if (type === "rpc_frame_error") {
2282
- this.recordProtocolViolation();
3076
+ this.recordProtocolViolation("remote-frame-error", {
3077
+ reason: "remote-frame-error",
3078
+ frameType: "rpc_frame_error",
3079
+ field: "frame",
3080
+ expected: "no-remote-frame-error",
3081
+ actualType: "remote-error",
3082
+ });
2283
3083
  return;
2284
3084
  }
2285
3085
  if (type === "ready") {
2286
3086
  if (this.readyReceived) {
2287
- this.recordProtocolViolation();
3087
+ this.recordProtocolViolation("duplicate-ready", {
3088
+ reason: "ready-already-received",
3089
+ frameType: "ready",
3090
+ field: "ready",
3091
+ expected: "single-ready-frame",
3092
+ actualType: "duplicate",
3093
+ });
2288
3094
  return;
2289
3095
  }
2290
- const ready = OmpReadyFrameSchema.safeParse(frame);
3096
+ const ready = OmpReadyFrameSchema.safeParse(safeFrame);
2291
3097
  if (!ready.success) {
2292
- this.recordProtocolViolation();
3098
+ this.recordProtocolViolation("invalid-ready", {
3099
+ reason: "ready-schema",
3100
+ frameType: "ready",
3101
+ field: "ready",
3102
+ expected: "valid-ready-frame",
3103
+ actualType: "object",
3104
+ });
2293
3105
  } else {
2294
3106
  this.readyReceived = true;
2295
3107
  this.resolveReady(ready.data);
@@ -2297,24 +3109,37 @@ class OmpRpcProcess {
2297
3109
  return;
2298
3110
  }
2299
3111
  if (type === "response") {
2300
- this.receiveResponse(frame);
3112
+ this.receiveResponse(safeFrame);
2301
3113
  return;
2302
3114
  }
2303
- const event = OmpRuntimeEventSchema.safeParse(frame);
3115
+ const event = OmpRuntimeEventSchema.safeParse(safeFrame);
2304
3116
  if (!event.success) {
2305
- this.rejectMatchingToolApproval(frame);
2306
- if (type === "agent_end" && this.receiveDegradedAgentEnd(frame, false)) return;
2307
- this.recordProtocolViolation();
3117
+ this.rejectMatchingToolApproval(safeFrame);
3118
+ if (type === "agent_end" && this.receiveDegradedAgentEnd(safeFrame, false)) return;
3119
+ this.recordProtocolViolation("invalid-event", {
3120
+ ...invalidEventDiagnosticMetadata(safeFrame, type),
3121
+ maxByteSize: rawByteLength,
3122
+ });
2308
3123
  return;
2309
3124
  }
2310
3125
  if (!this.acceptEventState(event.data)) {
2311
- this.recordProtocolViolation();
3126
+ this.recordProtocolViolation("invalid-event-state", {
3127
+ reason: "event-state-transition",
3128
+ eventType: event.data.type,
3129
+ field: "event.sequence",
3130
+ expected: "valid-event-state-transition",
3131
+ actualType: "out-of-order",
3132
+ maxByteSize: rawByteLength,
3133
+ });
2312
3134
  return;
2313
3135
  }
2314
3136
  this.emit(event.data);
2315
3137
  if (event.data.type === "prompt_result" && event.data.id) {
2316
3138
  this.acceptedPromptIds.delete(event.data.id);
2317
3139
  }
3140
+ if (event.data.type === "turn_end" || event.data.type === "agent_end") {
3141
+ this.turnActive = false;
3142
+ }
2318
3143
  if (
2319
3144
  event.data.type === "message_end" ||
2320
3145
  event.data.type === "turn_end" ||
@@ -2342,6 +3167,7 @@ class OmpRpcProcess {
2342
3167
 
2343
3168
  private acceptEventState(event: z.infer<typeof OmpRuntimeEventSchema>): boolean {
2344
3169
  if (event.type === "turn_start") {
3170
+ this.turnActive = true;
2345
3171
  this.streamedBlocks.clear();
2346
3172
  this.commandTextLength = 0;
2347
3173
  this.activeToolCallIds.clear();
@@ -2411,13 +3237,84 @@ class OmpRpcProcess {
2411
3237
  return true;
2412
3238
  }
2413
3239
 
2414
- private rejectChunk(): void {
3240
+ private protocolDiagnosticPhase(): OmpProtocolDiagnosticPhase {
3241
+ if (this.closed || this.exited || this.outputSettled) return "closing";
3242
+ if (!this.readyReceived) return "startup";
3243
+ for (const pending of this.pending.values()) {
3244
+ if (pending.command === "negotiate_protocol") return "negotiation";
3245
+ if (pending.command === "prompt") return "active-turn";
3246
+ }
3247
+ if (this.turnActive || this.acceptedPromptIds.size > 0) return "active-turn";
3248
+ return "idle";
3249
+ }
3250
+
3251
+ private rejectChunk(
3252
+ reason: OmpProtocolViolationReason,
3253
+ metadata: Omit<
3254
+ OmpProtocolViolationDiagnostic,
3255
+ "category" | "reason" | "occurrenceCount" | "phase" | "frameType"
3256
+ > = {},
3257
+ ): void {
2415
3258
  this.clearChunk();
2416
- this.recordProtocolViolation();
3259
+ this.recordProtocolViolation("invalid-chunk", {
3260
+ reason,
3261
+ frameType: "rpc_chunk",
3262
+ ...metadata,
3263
+ });
3264
+ }
3265
+
3266
+ private recordProtocolViolation(
3267
+ category: OmpProtocolViolationCategory,
3268
+ metadata: Omit<OmpProtocolViolationDiagnostic, "category" | "occurrenceCount" | "phase">,
3269
+ ): void {
3270
+ const phase = this.protocolDiagnosticPhase();
3271
+ if (!this.protocolViolationTimer) {
3272
+ this.emitProtocolViolation({ category, occurrenceCount: 1, phase, ...metadata });
3273
+ this.protocolViolationTimer = setTimeout(
3274
+ () => this.flushProtocolViolations(),
3275
+ PROTOCOL_VIOLATION_COALESCE_MS,
3276
+ );
3277
+ return;
3278
+ }
3279
+ const key = `${category}:${metadata.reason}` as ProtocolViolationKey;
3280
+ const pending = this.pendingProtocolViolations.get(key);
3281
+ if (!pending) {
3282
+ this.pendingProtocolViolations.set(key, {
3283
+ category,
3284
+ occurrenceCount: 1,
3285
+ phase,
3286
+ ...metadata,
3287
+ });
3288
+ return;
3289
+ }
3290
+ pending.occurrenceCount = Math.min(Number.MAX_SAFE_INTEGER, pending.occurrenceCount + 1);
3291
+ pending.maxByteSize =
3292
+ Math.max(pending.maxByteSize ?? 0, metadata.maxByteSize ?? 0) || undefined;
3293
+ pending.reason = metadata.reason;
3294
+ pending.phase = phase;
3295
+ pending.frameType = metadata.frameType;
3296
+ pending.field = metadata.field;
3297
+ pending.expected = metadata.expected;
3298
+ pending.actualType = metadata.actualType;
3299
+ pending.limitBytes = metadata.limitBytes;
2417
3300
  }
2418
3301
 
2419
- private recordProtocolViolation(): void {
2420
- // Malformed bounded frames are isolated so the following frame starts from clean state.
3302
+ private flushProtocolViolations(): void {
3303
+ if (this.protocolViolationTimer) clearTimeout(this.protocolViolationTimer);
3304
+ this.protocolViolationTimer = null;
3305
+ for (const diagnostic of this.pendingProtocolViolations.values()) {
3306
+ this.emitProtocolViolation(diagnostic);
3307
+ }
3308
+ this.pendingProtocolViolations.clear();
3309
+ }
3310
+
3311
+ private emitProtocolViolation(diagnostic: OmpProtocolViolationDiagnostic): void {
3312
+ try {
3313
+ const reporting = this.reportProtocolViolation(diagnostic);
3314
+ if (reporting) void reporting.catch(() => undefined);
3315
+ } catch {
3316
+ // Diagnostics must never alter transport flow or cleanup.
3317
+ }
2421
3318
  }
2422
3319
 
2423
3320
  private fail(error: Error): void {
@@ -2728,7 +3625,10 @@ export class OmpRpcRuntime implements OmpRuntime {
2728
3625
  );
2729
3626
  return {
2730
3627
  ...transcript,
2731
- messages: z.array(OmpMessageSchema).max(100_000).parse(transcript.messages),
3628
+ messages: z
3629
+ .array(OmpMessageSchema)
3630
+ .max(100_000)
3631
+ .parse(sanitizeMessageListMetadata(transcript.messages)),
2732
3632
  };
2733
3633
  }
2734
3634
  async readPersistedSubagentTranscript(options: {
@@ -2745,7 +3645,10 @@ export class OmpRpcRuntime implements OmpRuntime {
2745
3645
  );
2746
3646
  return {
2747
3647
  ...transcript,
2748
- messages: z.array(OmpMessageSchema).max(100_000).parse(transcript.messages),
3648
+ messages: z
3649
+ .array(OmpMessageSchema)
3650
+ .max(100_000)
3651
+ .parse(sanitizeMessageListMetadata(transcript.messages)),
2749
3652
  };
2750
3653
  }
2751
3654
 
@@ -2760,6 +3663,7 @@ export class OmpRpcRuntime implements OmpRuntime {
2760
3663
  this.options.spawnProcess,
2761
3664
  this.options.terminateProcessTree,
2762
3665
  options.requestTimeoutMs ?? this.options.requestTimeoutMs,
3666
+ this.options.reportProtocolViolation,
2763
3667
  );
2764
3668
  const abort = () => void process.close().catch(() => undefined);
2765
3669
  options.signal?.addEventListener("abort", abort, { once: true });