@omercnet/paseo-omp 0.3.0-next.99.1 → 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 +25 -0
  18. package/server/provider/host-tools.ts +63 -10
  19. package/server/provider/mcp-transport.ts +2 -1
  20. package/server/provider/omp-rpc.ts +706 -63
  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 +290 -72
  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
@@ -70,6 +70,7 @@ const MAX_OPTIONAL_METADATA_NODES = 4_096;
70
70
  const MAX_TASK_CORRELATION_BYTES = 256 * 1024;
71
71
  const MAX_TASK_CORRELATION_ITEMS = 1_024;
72
72
  const MAX_TASK_CORRELATION_NODES = 4_096;
73
+ const MAX_MODEL_CATALOG_ITEMS = 4_096;
73
74
  // Tool-intensive OMP turns legitimately exceed 64 blocks; transport byte/node budgets remain the
74
75
  // primary resource bounds.
75
76
  export const OMP_MAX_CONTENT_PARTS = 4_096;
@@ -84,6 +85,298 @@ const MAX_COST_USD = 1_000_000_000;
84
85
  const MAX_RPC_ERROR_BYTES = 4_096;
85
86
  const MAX_RPC_ERROR_CODE_BYTES = 256;
86
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}`;
87
380
  const MAX_CONTEXT_PERCENT = 1_000_000;
88
381
  function boundedJsonString(maxBytes: number, minBytes = 0) {
89
382
  return z.string().refine((value) => {
@@ -115,12 +408,94 @@ function boundedString(maxBytes: number, minBytes = 0) {
115
408
  return bytes >= minBytes && bytes <= maxBytes;
116
409
  });
117
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
+ }
118
422
 
119
423
  const IDENTIFIER = boundedString(MAX_ID_LENGTH, 1);
120
424
  const NAME = boundedString(MAX_NAME_LENGTH, 1);
121
425
  const OMP_PROVIDER_NAME = NAME.refine((provider) => !provider.includes("/"));
122
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
+ }
123
497
  const OmpThinkingLevelSchema = z.enum(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
498
+ const OmpCurrentThinkingLevelSchema = boundedString(32).optional().catch(undefined);
124
499
 
125
500
  function isBoundedJson(
126
501
  value: unknown,
@@ -303,6 +678,14 @@ const OmpContentPartSchema = z
303
678
  context.addIssue({ code: "custom", message: "invalid image payload" });
304
679
  }
305
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
+ );
306
689
  const OmpDisplayContentSchema = z.union([
307
690
  TEXT,
308
691
  z.array(OmpContentPartSchema).max(OMP_MAX_CONTENT_PARTS),
@@ -371,7 +754,7 @@ export type OmpMessage = OmpMessageIdentity &
371
754
  const OmpMessageSchema: z.ZodType<OmpMessage> = z.union([
372
755
  z.object({
373
756
  role: z.literal("assistant"),
374
- content: OmpDisplayContentSchema.optional(),
757
+ content: OmpAssistantDisplayContentSchema.optional(),
375
758
  ...OmpMessageIdentityShape,
376
759
  errorMessage: boundedString(4_096).nullable().optional(),
377
760
  stopReason: boundedString(64).optional(),
@@ -394,7 +777,13 @@ const OmpMessageSchema: z.ZodType<OmpMessage> = z.union([
394
777
  z.object({
395
778
  role: z.literal("bashExecution"),
396
779
  command: TEXT,
397
- 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
+ ),
398
787
  exitCode: z.number().int().nullable().optional(),
399
788
  cancelled: z.boolean().optional(),
400
789
  truncated: z.boolean().optional(),
@@ -462,19 +851,33 @@ const OmpAvailableCommandSchema = z.object({
462
851
  .optional(),
463
852
  source: boundedString(64).optional(),
464
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
+ });
465
866
  const OmpModelSchema = z.object({
466
867
  provider: OMP_PROVIDER_NAME,
467
868
  id: NAME,
468
- name: boundedString(MAX_NAME_LENGTH).optional(),
469
- reasoning: z.boolean().optional(),
470
- thinking: z
471
- .object({
472
- efforts: z.array(boundedString(32)).max(16).optional(),
473
- defaultLevel: boundedString(32).optional(),
474
- })
475
- .optional(),
476
- input: z.array(NAME).max(16).optional(),
477
- 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),
478
881
  });
479
882
  const TokenCountSchema = z.number().int().nonnegative().max(MAX_TOKEN_COUNT);
480
883
  const OptionalTokenCountSchema = TokenCountSchema.nullable().optional();
@@ -526,13 +929,13 @@ const OmpCompactionResultSchema = z.object({
526
929
  });
527
930
  const OmpSessionStateSchema = z.object({
528
931
  model: OmpModelSchema.nullable().optional(),
529
- thinkingLevel: OmpThinkingLevelSchema.optional(),
932
+ thinkingLevel: OmpCurrentThinkingLevelSchema,
530
933
  isStreaming: z.boolean(),
531
934
  isCompacting: z.boolean(),
532
935
  sessionId: IDENTIFIER,
533
936
  autoCompactionEnabled: z.boolean().optional(),
534
- contextUsage: OmpContextUsageSchema.nullable().optional(),
535
- sessionFile: boundedString(MAX_PATH_LENGTH).optional(),
937
+ contextUsage: OmpContextUsageSchema.nullable().optional().catch(undefined),
938
+ sessionFile: boundedString(MAX_PATH_LENGTH).optional().catch(undefined),
536
939
  });
537
940
  const OmpReadyFrameSchema = z.object({
538
941
  type: z.literal("ready"),
@@ -1133,7 +1536,7 @@ function runtimeFrameCollectionLimit(frame: Record<string, unknown>): number {
1133
1536
  return 1_024;
1134
1537
  }
1135
1538
  const OmpModelsResultSchema = z.object({
1136
- models: z.array(OmpModelSchema).min(1).max(256),
1539
+ models: z.array(OmpModelSchema).min(1).max(MAX_MODEL_CATALOG_ITEMS),
1137
1540
  });
1138
1541
  const OmpPromptAckSchema = z.object({ agentInvoked: z.boolean().optional() }).optional();
1139
1542
  const OmpAvailableCommandsResultSchema = z.object({
@@ -1178,6 +1581,7 @@ const ProtocolNegotiationResultSchema = z.object({
1178
1581
  });
1179
1582
 
1180
1583
  export type OmpModel = z.infer<typeof OmpModelSchema>;
1584
+ export type OmpBranchResult = z.infer<typeof OmpBranchResultSchema>;
1181
1585
  export type OmpSessionState = z.infer<typeof OmpSessionStateSchema>;
1182
1586
  export type OmpSessionStats = z.infer<typeof OmpSessionStatsSchema>;
1183
1587
  export type OmpCompactionResult = z.infer<typeof OmpCompactionResultSchema>;
@@ -1219,6 +1623,7 @@ export interface OmpPersistedSessionMessages {
1219
1623
  nativeSessionId: string;
1220
1624
  byteLength: number;
1221
1625
  messages: OmpMessage[];
1626
+ imageReplayWarning?: true;
1222
1627
  }
1223
1628
 
1224
1629
  export interface OmpStartOptions {
@@ -1284,7 +1689,7 @@ export interface OmpRuntimeSession {
1284
1689
  respondToExtensionUi(response: OmpExtensionUiResponse): Promise<void>;
1285
1690
  respondToToolApproval(response: OmpToolApprovalResponse): Promise<void>;
1286
1691
  getBranchMessages(): Promise<Array<{ entryId: string; text: string }>>;
1287
- branch(entryId: string): Promise<{ text: string; cancelled: boolean }>;
1692
+ branch(entryId: string): Promise<OmpBranchResult>;
1288
1693
  readonly canReplayHistory: boolean;
1289
1694
  getMessages(): Promise<OmpMessage[]>;
1290
1695
  abort(): Promise<void>;
@@ -1326,11 +1731,19 @@ export interface OmpRpcRuntimeOptions {
1326
1731
  terminateProcessTree?: (pid: number) => Promise<boolean | "uncertain">;
1327
1732
  environment?: NodeJS.ProcessEnv;
1328
1733
  requestTimeoutMs?: number;
1734
+ reportProtocolViolation?: (diagnostic: OmpProtocolViolationDiagnostic) => void | Promise<void>;
1329
1735
  listSessions?: (
1330
1736
  options: OmpSessionListOptions,
1331
1737
  ) => OmpSessionDescriptor[] | Promise<OmpSessionDescriptor[]>;
1332
1738
  }
1333
1739
 
1740
+ export class OmpRpcRequestRejectedError extends Error {
1741
+ constructor() {
1742
+ super("OMP RPC request failed");
1743
+ this.name = "OmpRpcRequestRejectedError";
1744
+ }
1745
+ }
1746
+
1334
1747
  type PendingRequest = {
1335
1748
  resolve(value: unknown): void;
1336
1749
  reject(error: Error): void;
@@ -1850,12 +2263,21 @@ class OmpRpcProcess {
1850
2263
  private spawnFailedWithoutProcess = false;
1851
2264
  private readyReceived = false;
1852
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;
1853
2272
 
1854
2273
  constructor(
1855
2274
  options: OmpStartOptions,
1856
2275
  spawnProcess?: OmpRpcRuntimeOptions["spawnProcess"],
1857
2276
  terminateProcessTree?: OmpRpcRuntimeOptions["terminateProcessTree"],
1858
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),
1859
2281
  ) {
1860
2282
  const ready = Promise.withResolvers<ReadyFrame>();
1861
2283
  this.rejectReady = ready.reject;
@@ -1944,7 +2366,15 @@ class OmpRpcProcess {
1944
2366
  private settleOutput(): void {
1945
2367
  if (this.outputSettled) return;
1946
2368
  this.outputSettled = true;
1947
- 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
+ }
1948
2378
  this.lineParts = [];
1949
2379
  this.lineBytes = 0;
1950
2380
  this.discardingLine = false;
@@ -2127,16 +2557,17 @@ class OmpRpcProcess {
2127
2557
 
2128
2558
  private async closeProcess(): Promise<void> {
2129
2559
  this.closed = true;
2560
+ this.flushProtocolViolations();
2130
2561
  this.clearChunk();
2131
2562
  this.failPending(new Error("OMP RPC process was closed"));
2563
+ const cleanupPromise = this.startTreeCleanup();
2132
2564
  if (!this.exited) {
2133
2565
  try {
2134
2566
  this.child.stdin.end();
2135
2567
  } catch {
2136
- // 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.
2137
2569
  }
2138
2570
  }
2139
- const cleanupPromise = this.startTreeCleanup();
2140
2571
  const cleanup = await cleanupPromise;
2141
2572
  if (cleanup !== "verified") throw new Error("OMP RPC process tree cleanup failed");
2142
2573
  if (
@@ -2223,7 +2654,14 @@ class OmpRpcProcess {
2223
2654
  this.lineParts = [];
2224
2655
  this.lineBytes = 0;
2225
2656
  this.discardingLine = true;
2226
- 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
+ });
2227
2665
  return;
2228
2666
  }
2229
2667
  this.lineParts.push(part);
@@ -2244,16 +2682,16 @@ class OmpRpcProcess {
2244
2682
  try {
2245
2683
  decoded = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(payload));
2246
2684
  } catch {
2247
- this.recordProtocolViolation();
2248
- return;
2249
- }
2250
- if (this.receiveKnownResponse(decoded)) return;
2251
- const frame = JsonObjectSchema.safeParse(decoded);
2252
- if (!frame.success) {
2253
- 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
+ });
2254
2692
  return;
2255
2693
  }
2256
- this.receiveFrame(frame.data);
2694
+ this.receiveDecodedFrame(decoded, payload.byteLength);
2257
2695
  }
2258
2696
 
2259
2697
  private receiveChunk(frame: ChunkFrame): void {
@@ -2273,17 +2711,30 @@ class OmpRpcProcess {
2273
2711
  frame.data.length % 4 !== 0 ||
2274
2712
  !/^[A-Za-z0-9+/]*={0,2}$/u.test(frame.data)
2275
2713
  ) {
2276
- this.rejectChunk();
2714
+ this.rejectChunk("chunk-metadata", {
2715
+ field: "chunk",
2716
+ expected: "valid-chunk-frame",
2717
+ actualType: "object",
2718
+ });
2277
2719
  return;
2278
2720
  }
2279
2721
  const decoded = Buffer.from(frame.data, "base64");
2280
2722
  if (decoded.byteLength > MAX_CHUNK_BYTES) {
2281
- 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
+ });
2282
2729
  return;
2283
2730
  }
2284
2731
  if (!this.chunk) {
2285
2732
  if (frame.index !== 0) {
2286
- this.rejectChunk();
2733
+ this.rejectChunk("chunk-start-index", {
2734
+ field: "chunk.index",
2735
+ expected: "first-chunk-index-zero",
2736
+ actualType: "out-of-order",
2737
+ });
2287
2738
  return;
2288
2739
  }
2289
2740
  this.chunk = {
@@ -2292,7 +2743,15 @@ class OmpRpcProcess {
2292
2743
  byteLength: frame.byteLength,
2293
2744
  parts: [],
2294
2745
  receivedBytes: 0,
2295
- 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
+ ),
2296
2755
  };
2297
2756
  }
2298
2757
  const chunk = this.chunk;
@@ -2304,14 +2763,22 @@ class OmpRpcProcess {
2304
2763
  chunk.receivedBytes + decoded.byteLength > chunk.byteLength ||
2305
2764
  chunk.receivedBytes + decoded.byteLength > this.reassembledFrameLimit
2306
2765
  ) {
2307
- this.rejectChunk();
2766
+ this.rejectChunk("chunk-sequence", {
2767
+ field: "chunk.sequence",
2768
+ expected: "contiguous-chunk-sequence",
2769
+ actualType: "out-of-order",
2770
+ });
2308
2771
  return;
2309
2772
  }
2310
2773
  chunk.parts.push(decoded);
2311
2774
  chunk.receivedBytes += decoded.byteLength;
2312
2775
  if (chunk.parts.length !== chunk.count) return;
2313
2776
  if (chunk.receivedBytes !== chunk.byteLength) {
2314
- this.rejectChunk();
2777
+ this.rejectChunk("chunk-byte-count", {
2778
+ field: "chunk.byteLength",
2779
+ expected: "declared-chunk-byte-count",
2780
+ actualType: "mismatched",
2781
+ });
2315
2782
  return;
2316
2783
  }
2317
2784
  const reassembled = Buffer.concat(chunk.parts, chunk.receivedBytes);
@@ -2320,16 +2787,48 @@ class OmpRpcProcess {
2320
2787
  try {
2321
2788
  decodedFrame = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(reassembled));
2322
2789
  } catch {
2323
- 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
+ });
2797
+ return;
2798
+ }
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"));
2324
2816
  return;
2325
2817
  }
2326
- if (this.receiveKnownResponse(decodedFrame)) return;
2327
- const frameObject = JsonObjectSchema.safeParse(decodedFrame);
2328
- if (!frameObject.success) {
2329
- 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
+ });
2330
2829
  return;
2331
2830
  }
2332
- this.receiveFrame(frameObject.data);
2831
+ this.receiveFrame(frame.data, rawByteLength);
2333
2832
  }
2334
2833
 
2335
2834
  private receiveKnownResponse(value: unknown): boolean {
@@ -2351,10 +2850,21 @@ class OmpRpcProcess {
2351
2850
  const knownPending = rawId ? this.pending.get(rawId) : undefined;
2352
2851
  const response = OmpResponseFrameSchema.safeParse(frame);
2353
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
+ }
2354
2866
  if (rawId && knownPending) {
2355
2867
  this.takePending(rawId)?.reject(new Error("OMP RPC response is invalid"));
2356
- } else if (!rawId || !this.emitAcceptedPromptFailure(rawId, frame)) {
2357
- this.recordProtocolViolation();
2358
2868
  }
2359
2869
  return;
2360
2870
  }
@@ -2373,13 +2883,19 @@ class OmpRpcProcess {
2373
2883
  : response.data.data;
2374
2884
  const boundedFrame =
2375
2885
  responseData === response.data.data ? frame : { ...frame, data: responseData };
2376
- const responseItemLimit = isBranchHistory ? 1_024 : isHistory ? 100_000 : MAX_ARRAY_ITEMS;
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;
2377
2893
  const responseByteLimit =
2378
2894
  isBranchHistory || isHistory
2379
2895
  ? Math.min(MAX_REASSEMBLED_FRAME_BYTES, this.reassembledFrameLimit)
2380
2896
  : 2 * 1024 * 1024;
2381
- // A model catalog contains up to 256 structured models, so its aggregate
2382
- // 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.
2383
2899
  const responseNodeLimit = isBranchHistory
2384
2900
  ? 4_096
2385
2901
  : isHistory
@@ -2418,7 +2934,7 @@ class OmpRpcProcess {
2418
2934
  settled.reject(new Error("OMP RPC response is invalid"));
2419
2935
  }
2420
2936
  } else {
2421
- settled.reject(new Error("OMP RPC request failed"));
2937
+ settled.reject(new OmpRpcRequestRejectedError());
2422
2938
  }
2423
2939
  }
2424
2940
 
@@ -2471,7 +2987,7 @@ class OmpRpcProcess {
2471
2987
  structuralFrame.messages as unknown[],
2472
2988
  MAX_SEMANTIC_FRAME_BYTES,
2473
2989
  OMP_MAX_CONTENT_PARTS,
2474
- MAX_TEXT_LENGTH,
2990
+ MAX_IMAGE_DATA_LENGTH,
2475
2991
  4_096,
2476
2992
  ) !== Number.POSITIVE_INFINITY);
2477
2993
  const payloadIsSafe =
@@ -2503,10 +3019,16 @@ class OmpRpcProcess {
2503
3019
  return true;
2504
3020
  }
2505
3021
 
2506
- private receiveFrame(frame: Record<string, unknown>): void {
3022
+ private receiveFrame(frame: Record<string, unknown>, rawByteLength: number): void {
2507
3023
  const type = typeof frame.type === "string" && frame.type.length <= 64 ? frame.type : null;
2508
3024
  if (!type) {
2509
- 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
+ });
2510
3032
  return;
2511
3033
  }
2512
3034
  const safeFrame = mapRuntimeFrameDetails(frame, createOptionalMetadataSanitizer());
@@ -2520,31 +3042,66 @@ class OmpRpcProcess {
2520
3042
  4_096,
2521
3043
  ) === Number.POSITIVE_INFINITY
2522
3044
  ) {
2523
- 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
+ });
2524
3053
  return;
2525
3054
  }
2526
3055
  if (type === "rpc_chunk") {
2527
3056
  const chunk = OmpChunkFrameSchema.safeParse(safeFrame);
2528
- if (!chunk.success) this.rejectChunk();
2529
- else this.receiveChunk(chunk.data);
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);
2530
3064
  return;
2531
3065
  }
2532
3066
  if (this.chunk) {
2533
3067
  this.clearChunk();
2534
- 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
+ });
2535
3074
  }
2536
3075
  if (type === "rpc_frame_error") {
2537
- 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
+ });
2538
3083
  return;
2539
3084
  }
2540
3085
  if (type === "ready") {
2541
3086
  if (this.readyReceived) {
2542
- 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
+ });
2543
3094
  return;
2544
3095
  }
2545
3096
  const ready = OmpReadyFrameSchema.safeParse(safeFrame);
2546
3097
  if (!ready.success) {
2547
- 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
+ });
2548
3105
  } else {
2549
3106
  this.readyReceived = true;
2550
3107
  this.resolveReady(ready.data);
@@ -2559,17 +3116,30 @@ class OmpRpcProcess {
2559
3116
  if (!event.success) {
2560
3117
  this.rejectMatchingToolApproval(safeFrame);
2561
3118
  if (type === "agent_end" && this.receiveDegradedAgentEnd(safeFrame, false)) return;
2562
- this.recordProtocolViolation();
3119
+ this.recordProtocolViolation("invalid-event", {
3120
+ ...invalidEventDiagnosticMetadata(safeFrame, type),
3121
+ maxByteSize: rawByteLength,
3122
+ });
2563
3123
  return;
2564
3124
  }
2565
3125
  if (!this.acceptEventState(event.data)) {
2566
- 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
+ });
2567
3134
  return;
2568
3135
  }
2569
3136
  this.emit(event.data);
2570
3137
  if (event.data.type === "prompt_result" && event.data.id) {
2571
3138
  this.acceptedPromptIds.delete(event.data.id);
2572
3139
  }
3140
+ if (event.data.type === "turn_end" || event.data.type === "agent_end") {
3141
+ this.turnActive = false;
3142
+ }
2573
3143
  if (
2574
3144
  event.data.type === "message_end" ||
2575
3145
  event.data.type === "turn_end" ||
@@ -2597,6 +3167,7 @@ class OmpRpcProcess {
2597
3167
 
2598
3168
  private acceptEventState(event: z.infer<typeof OmpRuntimeEventSchema>): boolean {
2599
3169
  if (event.type === "turn_start") {
3170
+ this.turnActive = true;
2600
3171
  this.streamedBlocks.clear();
2601
3172
  this.commandTextLength = 0;
2602
3173
  this.activeToolCallIds.clear();
@@ -2666,13 +3237,84 @@ class OmpRpcProcess {
2666
3237
  return true;
2667
3238
  }
2668
3239
 
2669
- 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 {
2670
3258
  this.clearChunk();
2671
- 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;
3300
+ }
3301
+
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();
2672
3309
  }
2673
3310
 
2674
- private recordProtocolViolation(): void {
2675
- // Malformed bounded frames are isolated so the following frame starts from clean state.
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
+ }
2676
3318
  }
2677
3319
 
2678
3320
  private fail(error: Error): void {
@@ -3021,6 +3663,7 @@ export class OmpRpcRuntime implements OmpRuntime {
3021
3663
  this.options.spawnProcess,
3022
3664
  this.options.terminateProcessTree,
3023
3665
  options.requestTimeoutMs ?? this.options.requestTimeoutMs,
3666
+ this.options.reportProtocolViolation,
3024
3667
  );
3025
3668
  const abort = () => void process.close().catch(() => undefined);
3026
3669
  options.signal?.addEventListener("abort", abort, { once: true });