@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
@@ -9,6 +9,10 @@ import {
9
9
  requireProviderCapabilities,
10
10
  } from "@getpaseo/plugin/server/provider";
11
11
  import type { OmpBrowserAuthorizationRegistry } from "../mcp-browser";
12
+ import type {
13
+ OmpOperationalFailure,
14
+ OmpOperationalFailureReporter,
15
+ } from "../operational-failure-diagnostics";
12
16
  import { discoverOmpCatalog } from "./catalog";
13
17
  import { normalizeOmpCatalogOptions } from "./config-normalization";
14
18
  import type { OmpMcpConnector } from "./host-tools";
@@ -58,6 +62,7 @@ const MAX_CONNECTION_SESSIONS = 32;
58
62
  const MAX_ACTIVE_OPERATIONS = 128;
59
63
  const MAX_PROVIDER_INPUT_BYTES = 2 * 1024 * 1024;
60
64
  const MAX_NESTED_OPTION_BYTES = 256 * 1024;
65
+ const MAX_ENV_ENTRIES = 256;
61
66
  const MAX_NATIVE_SESSION_RESERVATIONS = 256;
62
67
 
63
68
  function hasOwnEntries(value: unknown): boolean {
@@ -68,6 +73,19 @@ function hasOwnEntries(value: unknown): boolean {
68
73
  return false;
69
74
  }
70
75
 
76
+ function providerOptionsExceedPreflightLimits(value: unknown): boolean {
77
+ if (
78
+ boundedJsonBytes(value, MAX_NESTED_OPTION_BYTES, MAX_ENV_ENTRIES) === Number.POSITIVE_INFINITY
79
+ ) {
80
+ return true;
81
+ }
82
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
83
+ const unrelatedOptions = Object.fromEntries(
84
+ Object.entries(value).filter(([key]) => key !== "env" && key !== "inheritEnv"),
85
+ );
86
+ return boundedJsonBytes(unrelatedOptions, MAX_NESTED_OPTION_BYTES) === Number.POSITIVE_INFINITY;
87
+ }
88
+
71
89
  function preflightProviderInput(input: unknown): void {
72
90
  if (!input || typeof input !== "object") throw new OmpPublicError("Invalid provider request");
73
91
  const record = input as Record<string, unknown>;
@@ -82,7 +100,16 @@ function preflightProviderInput(input: unknown): void {
82
100
  throw new OmpPublicError("Session persistence input is too large");
83
101
  }
84
102
  const config = record.config as Record<string, unknown> | undefined;
85
- for (const value of [config?.mcpServers, config?.providerOptions, config?.settings]) {
103
+ if (
104
+ (config?.env !== undefined &&
105
+ boundedJsonBytes(config.env, MAX_NESTED_OPTION_BYTES, MAX_ENV_ENTRIES) ===
106
+ Number.POSITIVE_INFINITY) ||
107
+ (config?.providerOptions !== undefined &&
108
+ providerOptionsExceedPreflightLimits(config.providerOptions))
109
+ ) {
110
+ throw new OmpPublicError("Session configuration is too large");
111
+ }
112
+ for (const value of [config?.mcpServers, config?.settings]) {
86
113
  if (
87
114
  value !== undefined &&
88
115
  boundedJsonBytes(value, MAX_NESTED_OPTION_BYTES) === Number.POSITIVE_INFINITY
@@ -109,13 +136,17 @@ function preflightProviderInput(input: unknown): void {
109
136
  }
110
137
  }
111
138
  if (record.type === "catalog" || record.type === "sessions") {
112
- for (const value of [record.providerOptions, record.settings]) {
113
- if (
114
- value !== undefined &&
115
- boundedJsonBytes(value, MAX_NESTED_OPTION_BYTES) === Number.POSITIVE_INFINITY
116
- ) {
117
- throw new OmpPublicError("Provider configuration is too large");
118
- }
139
+ if (
140
+ record.providerOptions !== undefined &&
141
+ providerOptionsExceedPreflightLimits(record.providerOptions)
142
+ ) {
143
+ throw new OmpPublicError("Provider configuration is too large");
144
+ }
145
+ if (
146
+ record.settings !== undefined &&
147
+ boundedJsonBytes(record.settings, MAX_NESTED_OPTION_BYTES) === Number.POSITIVE_INFINITY
148
+ ) {
149
+ throw new OmpPublicError("Provider configuration is too large");
119
150
  }
120
151
  }
121
152
  if (record.type === "session.prompt") {
@@ -656,6 +687,7 @@ export function createOmpConnection(
656
687
  browserAuthorizationRegistry?: OmpBrowserAuthorizationRegistry,
657
688
  reportDiagnostic: (diagnostic: OmpConnectionDiagnostic) => void = (diagnostic) =>
658
689
  console.error("OMP provider failure", diagnostic),
690
+ reportOperationalFailure: OmpOperationalFailureReporter = () => {},
659
691
  ): ProviderConnection {
660
692
  const errorDetails = (error: unknown, fallback: string): { message: string } => {
661
693
  if (isOmpPublicError(error)) return { message: error.message };
@@ -669,6 +701,13 @@ export function createOmpConnection(
669
701
  }
670
702
  return { message: `${fallback} (diagnostic ${diagnosticId})` };
671
703
  };
704
+ const recordOperationalFailure = (failure: OmpOperationalFailure) => {
705
+ try {
706
+ reportOperationalFailure(failure);
707
+ } catch {
708
+ // Diagnostics must never affect provider requests or cleanup.
709
+ }
710
+ };
672
711
  const safeCapabilities = [...new Set(capabilities)].filter(
673
712
  (capability) =>
674
713
  SUPPORTED_CAPABILITIES[capability] &&
@@ -775,6 +814,9 @@ export function createOmpConnection(
775
814
  },
776
815
  });
777
816
  } catch (error) {
817
+ if (!closing) {
818
+ recordOperationalFailure({ category: "session-open", stage: "catalog" });
819
+ }
778
820
  if (isOmpCleanupFailure(error)) catalogCleanup = error.cleanup;
779
821
  if (!closing) requestFailure(input.requestId, error, "OMP catalog discovery failed");
780
822
  }
@@ -898,6 +940,7 @@ export function createOmpConnection(
898
940
  environment,
899
941
  mcpConnector,
900
942
  mcpInitializationTimeoutMs,
943
+ reportOperationalFailure,
901
944
  );
902
945
  const discoveredNativeSessionId = session.persistenceSessionId;
903
946
  if (discoveredNativeSessionId) nativeSessionId = discoveredNativeSessionId;
@@ -950,6 +993,15 @@ export function createOmpConnection(
950
993
  nativeReservations.release(nativeSessionId, token);
951
994
  }
952
995
  } catch (error) {
996
+ const openingCancelled =
997
+ closing || controller.signal.aborted || opening.get(input.sessionId)?.token !== token;
998
+ if (!openingCancelled) {
999
+ recordOperationalFailure(
1000
+ input.history === "replay" || nativeSessionId
1001
+ ? { category: "replay-recovery", stage: "persisted-replay" }
1002
+ : { category: "session-open", stage: "startup" },
1003
+ );
1004
+ }
953
1005
  deleteSession(input.sessionId, token);
954
1006
  let cleanupError: unknown;
955
1007
  if (session) {
@@ -3,6 +3,11 @@ import type {
3
3
  ProviderMcpServerConfig,
4
4
  ProviderSessionConfig,
5
5
  } from "@getpaseo/plugin/server/provider";
6
+ import type {
7
+ OmpOperationalFailure,
8
+ OmpOperationalFailureReporter,
9
+ } from "../operational-failure-diagnostics";
10
+ import { isValidImagePayload } from "./image";
6
11
  import {
7
12
  type ConnectedMcpClient,
8
13
  type ConnectedMcpTool,
@@ -25,6 +30,11 @@ import {
25
30
  utf8Bytes,
26
31
  } from "./security";
27
32
 
33
+ type HostToolFailureStage = Extract<
34
+ OmpOperationalFailure,
35
+ { category: "tool-projector"; stage: `host-tool-${string}` }
36
+ >["stage"];
37
+
28
38
  const INTERNAL_PASEO_MCP_PATH = "/mcp/agents";
29
39
  const RESERVED_PASEO_NAMESPACE = "paseo";
30
40
  const MAX_MCP_SERVERS = 32;
@@ -37,7 +47,13 @@ const MAX_HOST_TOOL_DESCRIPTION_BYTES = 64 * 1024;
37
47
  const MAX_HOST_TOOL_SCHEMA_BYTES = 256 * 1024;
38
48
  const MAX_HOST_TOOL_CATALOG_BYTES = 768 * 1024;
39
49
  const MAX_HOST_TOOL_RESULT_BYTES = 12 * 1024 * 1024;
50
+ const MAX_HOST_TOOL_CONTENT_BLOCKS = 512;
51
+ const MAX_HOST_TOOL_TEXT_BYTES = 1024 * 1024;
52
+ const MAX_HOST_TOOL_IMAGE_DATA_BYTES = 8 * 1024 * 1024;
40
53
  const MAX_STRUCTURED_CONTENT_FALLBACK_BYTES = 1024 * 1024;
54
+ const CONTENT_TRUNCATED_NOTICE = "[MCP result content truncated: exceeded size limits]";
55
+ const DETAILS_OMITTED_NOTICE = "[MCP structured content omitted: exceeded size limits]";
56
+ const RESULT_TRUNCATED_NOTICE = "[MCP content truncated; details omitted]";
41
57
  const MAX_PENDING_HOST_TOOL_CALLS = 64;
42
58
  const MAX_PENDING_HOST_TOOL_BYTES = 8 * 1024 * 1024;
43
59
  const DEFAULT_INITIALIZATION_TIMEOUT_MS = 20_000;
@@ -72,6 +88,7 @@ export interface OmpHostToolsOpenOptions {
72
88
  initializationTimeoutMs?: number;
73
89
  callTimeoutMs?: number;
74
90
  callScheduler?: OmpHostToolScheduler;
91
+ reportOperationalFailure?: OmpOperationalFailureReporter;
75
92
  }
76
93
 
77
94
  type ClassifiedServer = {
@@ -272,42 +289,171 @@ async function discoverMcpTools(
272
289
  throw new OmpPublicError("MCP server tool-list pagination exceeds the supported limit");
273
290
  }
274
291
 
275
- function normalizeResult(result: unknown): OmpHostToolResult["result"] {
276
- if (
277
- !result ||
278
- typeof result !== "object" ||
292
+ type HostToolContentBlock = OmpHostToolResult["result"]["content"][number];
293
+
294
+ function hostToolContentIsBounded(content: readonly HostToolContentBlock[]): boolean {
295
+ return (
279
296
  boundedJsonBytes(
280
- result,
297
+ content,
298
+ MAX_HOST_TOOL_RESULT_BYTES,
299
+ MAX_HOST_TOOL_CONTENT_BLOCKS,
300
+ MAX_HOST_TOOL_IMAGE_DATA_BYTES,
301
+ 4_096,
302
+ ) !== Number.POSITIVE_INFINITY
303
+ );
304
+ }
305
+
306
+ function noticeBlock(text: string): HostToolContentBlock {
307
+ return { type: "text", text };
308
+ }
309
+
310
+ function appendBoundedNotice(
311
+ content: readonly HostToolContentBlock[],
312
+ notice: string,
313
+ ): HostToolContentBlock[] {
314
+ const trailingNotice = content.at(-1)?.text;
315
+ const contentWasTruncated =
316
+ trailingNotice === CONTENT_TRUNCATED_NOTICE || trailingNotice === RESULT_TRUNCATED_NOTICE;
317
+ const prefix = (contentWasTruncated ? content.slice(0, -1) : content).slice(
318
+ 0,
319
+ MAX_HOST_TOOL_CONTENT_BLOCKS - 1,
320
+ );
321
+ const markerText =
322
+ notice === DETAILS_OMITTED_NOTICE && contentWasTruncated ? RESULT_TRUNCATED_NOTICE : notice;
323
+ const marker = noticeBlock(markerText);
324
+ while (prefix.length > 0 && !hostToolContentIsBounded([...prefix, marker])) prefix.pop();
325
+ return [...prefix, marker];
326
+ }
327
+
328
+ function boundedTextPrefix(
329
+ prefix: readonly HostToolContentBlock[],
330
+ block: HostToolContentBlock,
331
+ ): HostToolContentBlock | undefined {
332
+ if (block.type !== "text" || typeof block.text !== "string") return;
333
+ const marker = noticeBlock(CONTENT_TRUNCATED_NOTICE);
334
+ let low = 0;
335
+ let high = block.text.length;
336
+ let best = "";
337
+ while (low <= high) {
338
+ const middle = Math.floor((low + high) / 2);
339
+ let text = block.text.slice(0, middle);
340
+ if (/\p{Surrogate}$/u.test(text)) text = text.slice(0, -1);
341
+ const candidate = { type: "text", text } satisfies HostToolContentBlock;
342
+ if (hostToolContentIsBounded([...prefix, candidate, marker])) {
343
+ best = text;
344
+ low = middle + 1;
345
+ } else {
346
+ high = middle - 1;
347
+ }
348
+ }
349
+ return best ? { type: "text", text: best } : undefined;
350
+ }
351
+
352
+ function normalizeContent(content: readonly unknown[]): HostToolContentBlock[] {
353
+ const normalized: HostToolContentBlock[] = [];
354
+ for (const value of content) {
355
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
356
+ throw new Error("MCP tool returned invalid content");
357
+ }
358
+ const block = value as Record<string, unknown>;
359
+ if (typeof block.type !== "string" || !block.type || utf8Bytes(block.type) > 256) {
360
+ throw new Error("MCP tool returned invalid content");
361
+ }
362
+
363
+ let candidate: HostToolContentBlock;
364
+ let blockWasTruncated = false;
365
+ if (block.type === "text") {
366
+ if (typeof block.text !== "string") throw new Error("MCP tool returned invalid text content");
367
+ const text = truncateUtf8(block.text, MAX_HOST_TOOL_TEXT_BYTES);
368
+ candidate = { ...block, type: "text", text } as HostToolContentBlock;
369
+ blockWasTruncated = text !== block.text;
370
+ } else if (block.type === "image") {
371
+ if (typeof block.data !== "string" || typeof block.mimeType !== "string") {
372
+ throw new Error("MCP tool returned invalid image content");
373
+ }
374
+ if (block.data.length > MAX_HOST_TOOL_IMAGE_DATA_BYTES) {
375
+ return appendBoundedNotice(normalized, CONTENT_TRUNCATED_NOTICE);
376
+ }
377
+ if (!isValidImagePayload(block.data, block.mimeType, MAX_HOST_TOOL_IMAGE_DATA_BYTES)) {
378
+ throw new Error("MCP tool returned invalid image content");
379
+ }
380
+ candidate = {
381
+ ...block,
382
+ type: "image",
383
+ data: block.data,
384
+ mimeType: block.mimeType,
385
+ } as HostToolContentBlock;
386
+ } else {
387
+ candidate = block as HostToolContentBlock;
388
+ }
389
+
390
+ if (normalized.length >= MAX_HOST_TOOL_CONTENT_BLOCKS) {
391
+ return appendBoundedNotice(normalized, CONTENT_TRUNCATED_NOTICE);
392
+ }
393
+ if (!hostToolContentIsBounded([...normalized, candidate])) {
394
+ const prefix = boundedTextPrefix(normalized, candidate);
395
+ return appendBoundedNotice(
396
+ prefix ? [...normalized, prefix] : normalized,
397
+ CONTENT_TRUNCATED_NOTICE,
398
+ );
399
+ }
400
+ normalized.push(candidate);
401
+ if (blockWasTruncated) return appendBoundedNotice(normalized, CONTENT_TRUNCATED_NOTICE);
402
+ }
403
+ return normalized;
404
+ }
405
+
406
+ function structuredContentIsBounded(value: unknown): boolean {
407
+ return (
408
+ boundedJsonBytes(
409
+ value,
281
410
  MAX_HOST_TOOL_RESULT_BYTES,
282
411
  1_024,
283
412
  MAX_HOST_TOOL_RESULT_BYTES,
284
413
  4_096,
285
- ) === Number.POSITIVE_INFINITY
286
- ) {
287
- throw new Error("MCP tool returned an invalid or oversized result");
414
+ ) !== Number.POSITIVE_INFINITY
415
+ );
416
+ }
417
+
418
+ function normalizeResult(result: unknown): OmpHostToolResult["result"] {
419
+ if (!result || typeof result !== "object" || Array.isArray(result)) {
420
+ throw new Error("MCP tool returned an invalid result");
288
421
  }
289
422
  const record = result as Record<string, unknown>;
290
423
  if (Array.isArray(record.content)) {
291
424
  const details = record.structuredContent;
292
- const content =
293
- record.content.length === 0 && details !== undefined
294
- ? [
295
- {
296
- type: "text",
297
- text: truncateUtf8(JSON.stringify(details), MAX_STRUCTURED_CONTENT_FALLBACK_BYTES),
298
- },
299
- ]
300
- : record.content;
301
- return parseOmpHostToolAgentResult({
425
+ const detailsAreBounded = details === undefined || structuredContentIsBounded(details);
426
+ let content = normalizeContent(record.content);
427
+ if (content.length === 0 && details !== undefined && detailsAreBounded) {
428
+ content = [
429
+ {
430
+ type: "text",
431
+ text: truncateUtf8(JSON.stringify(details), MAX_STRUCTURED_CONTENT_FALLBACK_BYTES),
432
+ },
433
+ ];
434
+ }
435
+ if (!detailsAreBounded) content = appendBoundedNotice(content, DETAILS_OMITTED_NOTICE);
436
+
437
+ let normalized = parseOmpHostToolAgentResult({
302
438
  content,
303
- ...(details !== undefined ? { details } : {}),
439
+ ...(details !== undefined && detailsAreBounded ? { details } : {}),
304
440
  ...(typeof record.isError === "boolean" ? { isError: record.isError } : {}),
305
441
  });
442
+ if (!structuredContentIsBounded(normalized)) {
443
+ normalized = parseOmpHostToolAgentResult({
444
+ content: appendBoundedNotice(content, DETAILS_OMITTED_NOTICE),
445
+ ...(typeof record.isError === "boolean" ? { isError: record.isError } : {}),
446
+ });
447
+ }
448
+ return normalized;
306
449
  }
307
450
  if (Object.hasOwn(record, "toolResult")) {
451
+ const details = record.toolResult;
452
+ const detailsAreBounded = structuredContentIsBounded(details);
308
453
  return parseOmpHostToolAgentResult({
309
- content: [{ type: "text", text: "MCP tool completed" }],
310
- details: record.toolResult,
454
+ content: [noticeBlock(detailsAreBounded ? "MCP tool completed" : DETAILS_OMITTED_NOTICE)],
455
+ ...(detailsAreBounded ? { details } : {}),
456
+ ...(typeof record.isError === "boolean" ? { isError: record.isError } : {}),
311
457
  });
312
458
  }
313
459
  throw new Error("MCP tool returned an unsupported result");
@@ -360,6 +506,7 @@ export class OmpHostToolsBridge {
360
506
  private readonly targets: ReadonlyMap<string, ToolTarget>,
361
507
  private readonly callTimeoutMs: number,
362
508
  private readonly callScheduler: OmpHostToolScheduler,
509
+ private readonly reportOperationalFailure: OmpOperationalFailureReporter,
363
510
  ) {
364
511
  this.labels = new Map(definitions.map(({ name, label }) => [name, label ?? name]));
365
512
  }
@@ -476,6 +623,7 @@ export class OmpHostToolsBridge {
476
623
  targets,
477
624
  callTimeoutMs,
478
625
  callScheduler,
626
+ options.reportOperationalFailure ?? (() => {}),
479
627
  );
480
628
  } catch (error) {
481
629
  const cleanupTasks = [
@@ -604,7 +752,11 @@ export class OmpHostToolsBridge {
604
752
  if (!runtime) return true;
605
753
  const target = this.targets.get(event.toolName);
606
754
  if (!target) {
607
- this.sendTerminal(runtime, errorResult(event.id, "Unknown OMP host tool"));
755
+ this.sendOperationalError(
756
+ "host-tool-unknown",
757
+ runtime,
758
+ errorResult(event.id, "Unknown OMP host tool"),
759
+ );
608
760
  return true;
609
761
  }
610
762
  const retainedBytes = boundedJsonBytes(
@@ -620,7 +772,11 @@ export class OmpHostToolsBridge {
620
772
  retainedBytes === Number.POSITIVE_INFINITY ||
621
773
  this.pendingBytes + retainedBytes > MAX_PENDING_HOST_TOOL_BYTES
622
774
  ) {
623
- this.sendTerminal(runtime, errorResult(event.id, "OMP host tool bridge is at capacity"));
775
+ this.sendOperationalError(
776
+ "host-tool-capacity",
777
+ runtime,
778
+ errorResult(event.id, "OMP host tool bridge is at capacity"),
779
+ );
624
780
  return true;
625
781
  }
626
782
  const pending: PendingCall = {
@@ -658,23 +814,30 @@ export class OmpHostToolsBridge {
658
814
  })
659
815
  .then((result) => {
660
816
  if (!this.isCurrent(event.id, pending)) return;
661
- let terminal: OmpHostToolResult;
662
817
  try {
663
818
  const normalized = normalizeResult(result);
664
- terminal = {
819
+ const terminal: OmpHostToolResult = {
665
820
  type: "host_tool_result",
666
821
  id: event.id,
667
822
  result: normalized,
668
823
  ...(normalized.isError !== undefined ? { isError: normalized.isError } : {}),
669
824
  };
825
+ if (normalized.isError) {
826
+ this.sendOperationalError("host-tool-call", runtime, terminal, pending);
827
+ } else this.sendTerminal(runtime, terminal, pending);
670
828
  } catch {
671
- terminal = errorResult(event.id, "MCP host tool execution failed");
829
+ this.sendOperationalError(
830
+ "host-tool-normalization",
831
+ runtime,
832
+ errorResult(event.id, "MCP host tool execution failed"),
833
+ pending,
834
+ );
672
835
  }
673
- this.sendTerminal(runtime, terminal, pending);
674
836
  })
675
837
  .catch(() => {
676
838
  if (!this.isCurrent(event.id, pending)) return;
677
- this.sendTerminal(
839
+ this.sendOperationalError(
840
+ "host-tool-call",
678
841
  runtime,
679
842
  errorResult(event.id, "MCP host tool execution failed"),
680
843
  pending,
@@ -704,30 +867,113 @@ export class OmpHostToolsBridge {
704
867
  result: OmpHostToolResult,
705
868
  ): OmpHostToolResult {
706
869
  const limit = runtime.maxHostToolFrameBytes ?? 1024 * 1024;
870
+ const fits = (candidate: OmpHostToolResult) => {
871
+ try {
872
+ return Buffer.byteLength(`${JSON.stringify(candidate)}\n`) <= limit;
873
+ } catch {
874
+ return false;
875
+ }
876
+ };
877
+ if (fits(result)) return result;
878
+
879
+ const withoutDetails: OmpHostToolResult = {
880
+ ...result,
881
+ result: {
882
+ content: appendBoundedNotice(result.result.content, DETAILS_OMITTED_NOTICE),
883
+ ...(result.result.isError !== undefined ? { isError: result.result.isError } : {}),
884
+ },
885
+ };
886
+ if (result.result.details !== undefined && fits(withoutDetails)) return withoutDetails;
887
+
888
+ const trailingNotice = result.result.content.at(-1)?.text;
889
+ const detailsWereOmitted =
890
+ trailingNotice === DETAILS_OMITTED_NOTICE || trailingNotice === RESULT_TRUNCATED_NOTICE;
891
+ const contentWasTruncated =
892
+ trailingNotice === CONTENT_TRUNCATED_NOTICE || trailingNotice === RESULT_TRUNCATED_NOTICE;
893
+ const marker = noticeBlock(
894
+ result.result.details !== undefined || detailsWereOmitted
895
+ ? RESULT_TRUNCATED_NOTICE
896
+ : CONTENT_TRUNCATED_NOTICE,
897
+ );
898
+ const sourceContent =
899
+ detailsWereOmitted || contentWasTruncated
900
+ ? result.result.content.slice(0, -1)
901
+ : result.result.content;
902
+ const boundedContent: HostToolContentBlock[] = [];
903
+ const terminalWith = (content: HostToolContentBlock[]): OmpHostToolResult => ({
904
+ ...result,
905
+ result: {
906
+ content,
907
+ ...(result.result.isError !== undefined ? { isError: result.result.isError } : {}),
908
+ },
909
+ });
910
+ for (const block of sourceContent) {
911
+ const candidate = terminalWith([...boundedContent, block, marker]);
912
+ if (fits(candidate)) {
913
+ boundedContent.push(block);
914
+ continue;
915
+ }
916
+ if (block.type === "text" && typeof block.text === "string") {
917
+ let low = 0;
918
+ let high = block.text.length;
919
+ let best = "";
920
+ while (low <= high) {
921
+ const middle = Math.floor((low + high) / 2);
922
+ let text = block.text.slice(0, middle);
923
+ if (/\p{Surrogate}$/u.test(text)) text = text.slice(0, -1);
924
+ if (fits(terminalWith([...boundedContent, { type: "text", text }, marker]))) {
925
+ best = text;
926
+ low = middle + 1;
927
+ } else {
928
+ high = middle - 1;
929
+ }
930
+ }
931
+ if (best) boundedContent.push({ type: "text", text: best });
932
+ }
933
+ break;
934
+ }
935
+ const bounded = terminalWith([...boundedContent, marker]);
936
+ return fits(bounded) ? bounded : errorResult(result.id, OMP_HOST_TOOL_FRAME_LIMIT_ERROR);
937
+ }
938
+
939
+ private recordOperationalFailure(failure: OmpOperationalFailure): void {
707
940
  try {
708
- if (Buffer.byteLength(`${JSON.stringify(result)}\n`) <= limit) return result;
941
+ this.reportOperationalFailure(failure);
709
942
  } catch {
710
- // Fall through to the bounded error result.
943
+ // Diagnostics must never affect host tool execution or result delivery.
711
944
  }
712
- return errorResult(result.id, OMP_HOST_TOOL_FRAME_LIMIT_ERROR);
713
945
  }
714
946
 
715
- private sendTerminal(
947
+ private sendOperationalError(
948
+ stage: HostToolFailureStage,
716
949
  runtime: OmpRuntimeSession,
717
950
  result: OmpHostToolResult,
718
951
  pending?: PendingCall,
719
952
  ): void {
953
+ if (this.sendTerminal(runtime, result, pending)) {
954
+ this.recordOperationalFailure({ category: "tool-projector", stage });
955
+ }
956
+ }
957
+
958
+ private sendTerminal(
959
+ runtime: OmpRuntimeSession,
960
+ result: OmpHostToolResult,
961
+ pending?: PendingCall,
962
+ ): boolean {
720
963
  const bounded = this.boundedTerminal(runtime, result);
721
- if (pending && !this.isCurrent(bounded.id, pending)) return;
964
+ if (pending && !this.isCurrent(bounded.id, pending)) return false;
722
965
  try {
723
966
  runtime.sendHostToolResult(bounded);
967
+ return true;
724
968
  } catch (error) {
725
969
  this.failRuntime(runtime, error);
970
+ return false;
726
971
  }
727
972
  }
728
973
 
729
974
  private failRuntime(runtime: OmpRuntimeSession, error: unknown): void {
730
975
  if (this.runtime !== runtime) return;
976
+ this.recordOperationalFailure({ category: "tool-projector", stage: "host-tool-delivery" });
731
977
  const failure =
732
978
  error instanceof Error ? error : new Error("OMP host tool result delivery failed");
733
979
  this.detach();
@@ -748,7 +994,11 @@ export class OmpHostToolsBridge {
748
994
  if (!this.isCurrent(id, pending)) return;
749
995
  this.releasePending(id, pending);
750
996
  pending.controller.abort(new Error("OMP MCP host tool call timed out"));
751
- this.sendTerminal(pending.runtime, errorResult(id, "OMP MCP host tool call timed out"));
997
+ this.sendOperationalError(
998
+ "host-tool-timeout",
999
+ pending.runtime,
1000
+ errorResult(id, "OMP MCP host tool call timed out"),
1001
+ );
752
1002
  }
753
1003
 
754
1004
  private releasePending(id: string, pending: PendingCall): void {
@@ -224,6 +224,7 @@ export class SupervisedStdioClientTransport implements Transport {
224
224
  this.notifyClose();
225
225
  return;
226
226
  }
227
+ const treeCleanup = this.startTreeCleanup();
227
228
  if (!this.exited) {
228
229
  try {
229
230
  child.stdin.end();
@@ -231,7 +232,7 @@ export class SupervisedStdioClientTransport implements Transport {
231
232
  // Process-tree cleanup remains authoritative when stdin is already closed.
232
233
  }
233
234
  }
234
- const terminated = await this.startTreeCleanup();
235
+ const terminated = await treeCleanup;
235
236
  const exited =
236
237
  this.spawnFailedWithoutProcess ||
237
238
  this.exited ||