@omercnet/paseo-omp 0.2.1-next.90.1 → 0.3.0-next.100.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.0](https://github.com/omercnet/paseo-plugins/compare/paseo-omp-v0.2.1...paseo-omp-v0.3.0) (2026-09-18)
4
+
5
+
6
+ ### Features
7
+
8
+ * **paseo-omp:** add first-class MCP management ([#64](https://github.com/omercnet/paseo-plugins/issues/64)) ([5a8fefa](https://github.com/omercnet/paseo-plugins/commit/5a8fefa9148e8d01f0461aa4a90619ec6ae2c2fa))
9
+ * **paseo-omp:** add workspace-scoped OMP management ([#59](https://github.com/omercnet/paseo-plugins/issues/59)) ([2807aec](https://github.com/omercnet/paseo-plugins/commit/2807aec6217ed2d52f8b4666f0a41a688109f8f5))
10
+ * **paseo-omp:** make composer pills configurable ([#76](https://github.com/omercnet/paseo-plugins/issues/76)) ([28e7501](https://github.com/omercnet/paseo-plugins/commit/28e75011a06a11cdc963187aa4106dfc21d561e6))
11
+ * **release:** publish plugins to npm ([#80](https://github.com/omercnet/paseo-plugins/issues/80)) ([3c93048](https://github.com/omercnet/paseo-plugins/commit/3c93048cfefda97d8c2bc1631e3428fb64bdad09))
12
+
13
+
14
+ ### Bug Fixes
15
+
16
+ * **paseo-omp:** advertise nested subsession capability ([#62](https://github.com/omercnet/paseo-plugins/issues/62)) ([fcb4e82](https://github.com/omercnet/paseo-plugins/commit/fcb4e8243e9559d70f26ac6945e2f3c0bf05d575))
17
+ * **paseo-omp:** bound model catalogs and retain safe failure diagnostics ([#73](https://github.com/omercnet/paseo-plugins/issues/73)) ([e70afb7](https://github.com/omercnet/paseo-plugins/commit/e70afb72be782446e7ff585bad40940c7231bdc5))
18
+ * **paseo-omp:** correlate terminal events by request ([#81](https://github.com/omercnet/paseo-plugins/issues/81)) ([41f756e](https://github.com/omercnet/paseo-plugins/commit/41f756e9bebf5cbc275e5cbd3657c37d45a18997))
19
+ * **paseo-omp:** fail closed on unowned later-turn terminal events ([#69](https://github.com/omercnet/paseo-plugins/issues/69)) ([072cd09](https://github.com/omercnet/paseo-plugins/commit/072cd09e38e450376bcd3f4c7b5eae9a670b56b5))
20
+ * **paseo-omp:** preserve prompt scheduling error fidelity ([#67](https://github.com/omercnet/paseo-plugins/issues/67)) ([1c6d549](https://github.com/omercnet/paseo-plugins/commit/1c6d549fddca48be8fd86b4dd2c5faea526c8ac0))
21
+ * **paseo-omp:** reconcile incomplete terminal outcomes ([#68](https://github.com/omercnet/paseo-plugins/issues/68)) ([854f4e6](https://github.com/omercnet/paseo-plugins/commit/854f4e6daddf905c671bb39a94d06a2de0c6d51d))
22
+ * **paseo-omp:** replay failed turns from native transcript ([#79](https://github.com/omercnet/paseo-plugins/issues/79)) ([4a13b77](https://github.com/omercnet/paseo-plugins/commit/4a13b778a9ba413b61fe0cc85878d3c333c161d2))
23
+ * **paseo-omp:** scope providers and auxiliary state to named profiles ([#74](https://github.com/omercnet/paseo-plugins/issues/74)) ([b265ad3](https://github.com/omercnet/paseo-plugins/commit/b265ad317a47230ec76dba799197443664492729))
24
+ * **paseo-omp:** settle concurrent startup races ([#70](https://github.com/omercnet/paseo-plugins/issues/70)) ([9d4e6ef](https://github.com/omercnet/paseo-plugins/commit/9d4e6efb26041f9c88ab4ed6c02a9526520b83e9))
25
+ * **paseo-omp:** show immutable approval mode ([#66](https://github.com/omercnet/paseo-plugins/issues/66)) ([4c586dc](https://github.com/omercnet/paseo-plugins/commit/4c586dc7d012127ee7570077a0d12cc0d6415bd4))
26
+ * **paseo-omp:** support Paseo 0.9 beta ([#103](https://github.com/omercnet/paseo-plugins/issues/103)) ([0444d52](https://github.com/omercnet/paseo-plugins/commit/0444d52078a52befa62bb52f44545f22ab10e8c7))
27
+ * **paseo-omp:** support unkeyed terminal events ([#89](https://github.com/omercnet/paseo-plugins/issues/89)) ([a00a831](https://github.com/omercnet/paseo-plugins/commit/a00a83158993fdf648fc10e06f1d8f8ebc62fa6a))
28
+
3
29
  ## [0.2.1](https://github.com/omercnet/paseo-plugins/compare/paseo-omp-v0.2.0...paseo-omp-v0.2.1) (2026-09-15)
4
30
 
5
31
 
package/README.md CHANGED
@@ -6,7 +6,7 @@ Community OMP integration for Paseo. The plugin registers the distinct `omp-plug
6
6
 
7
7
  ## Quick start
8
8
 
9
- Requirements: Paseo `>=0.8.0 <0.10.0`, OMP `18.1.15` or newer, and OMP RPC protocol v2. Paseo `0.9.0-beta.1` is recommended for npm-managed installation and the complete client integration.
9
+ Requirements: Paseo `>=0.8.0 <0.10.0`, OMP `18.1.15` or newer, and OMP RPC protocol v2. Paseo `0.9.0-beta.1` or newer is required for nested provider-subagent ancestry and spawning-tool links, and is recommended for npm-managed installation and the complete client integration.
10
10
 
11
11
  ```bash
12
12
  paseo plugin install npm:@omercnet/paseo-omp@<version>
@@ -99,7 +99,7 @@ Existing agents whose provider is `omp` remain owned by the bundled provider. Ne
99
99
 
100
100
  OMP `18.1.15` is the oldest version tested end to end. The direct provider's hard compatibility gate is `rpc-ui` protocol v2: metadata-free legacy ready frames and v1-only runtimes are rejected before a provider session opens because they cannot support the advertised persistence and conversation-rewind capabilities. Typed tool approvals remain capability-gated and fall back as described above. For terminal completion, a matching `agent_end.requestId` is authoritative and mismatches are ignored before state changes. Released OMP 18.2.x builds that omit that field use an ordered fallback requiring a fresh correlated native user entry followed by current-turn assistant activity, an idle non-compacting runtime, and no conflicting permission, tool, steer, or child-session work. Ambiguous events while OMP is active are ignored; unresolved ambiguity after confirmed idle fails only the Paseo turn. This trades a bounded residual same-agent stale-event risk for compatibility with released OMP instead of terminating and lazily restarting the runtime after every later prompt. Paseo clients, including mobile clients inside reconnect grace, do not own or terminate the daemon-managed OMP session.
101
101
 
102
- Paseo 0.9.0-beta.1 is the canary target and provides npm-managed plugin sources, platform-owned external URL opening, and whole-item timeline transforms before Overview grouping. Paseo 0.8.x remains supported for provider operation and Git or directory installation; its clients do not provide the external URL opener and retain their older timeline presentation semantics.
102
+ Paseo 0.9.0-beta.1 is the verified canary target and the first release that preserves nested provider-subagent ancestry and spawning-tool links. The required full Docker canary passes there with OMP 18.1.15 and 18.2.0. Paseo 0.9 also provides npm-managed plugin sources, platform-owned external URL opening, and whole-item timeline transforms before Overview grouping. Paseo 0.8.x remains supported for other provider operations and Git or directory installation; its clients do not provide the external URL opener, retain their older timeline presentation semantics, and flatten nested provider subagents.
103
103
 
104
104
  Install, update, disable, or remove `paseo-omp` independently of the bundled provider. Verify the provider snapshot contains `omp-plugin` after installation; a bundled `omp` entry may remain present and is not modified by this plugin.
105
105
 
package/SUPPORT.md CHANGED
@@ -16,8 +16,8 @@ Do not put credentials, private repository paths, session transcripts, or unreda
16
16
 
17
17
  ## Supported versions
18
18
 
19
- - Paseo: `>=0.8.0 <0.10.0` on both the daemon and every app loading the client entry. The pinned SDK and controlled canary use `0.9.0-beta.1`; 0.8.x remains the compatibility line.
20
- - OMP: `18.1.15` is the oldest release in the required real-binary regression job. The hard runtime contract is `rpc-ui` protocol v2, not the version string alone.
19
+ - Paseo: `>=0.8.0 <0.10.0` on both the daemon and every app loading the client entry. The pinned SDK and controlled canary use `0.9.0-beta.1`, the first release that preserves nested provider-subagent ancestry and spawning-tool links. Paseo 0.8.x remains supported for other provider operations.
20
+ - OMP: `18.1.15` is the oldest supported release. The required full Docker canary passes with 18.1.15 and 18.2.0; the hard runtime contract remains `rpc-ui` protocol v2, not the version string alone.
21
21
  - Plugin release channel: alpha. Backward compatibility is best effort until stable `0.1.0`; every known migration requirement must be stated in the release notes.
22
22
  - Typed approvals: optional. When both peers negotiate `typedToolApprovals: 1`, the plugin uses typed tool permissions. Otherwise it retains the bounded generic extension-question flow.
23
23
 
package/TESTING.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # OMP provider parity audit
2
2
 
3
- Validated against the Paseo plugin SDK versions pinned in `package.json` and the real OMP compatibility matrix: high-use historical releases 17.2.15, 17.3.4, 18.0.11, and 18.1.10; minimum supported release 18.1.15; latest published 18.1 patch 18.1.22; and current release 18.2.0. Validation against official Paseo Docker image `0.9.0-beta.1@sha256:f75a0eb3547ad3cc6bbdeaa7277d2d50eb4d06a9dd669d480371d7adf1c911b5` is partial as recorded below.
3
+ Validated against the Paseo plugin SDK versions pinned in `package.json` and the real OMP compatibility matrix: high-use historical releases 17.2.15, 17.3.4, 18.0.11, and 18.1.10; minimum supported release 18.1.15; latest published 18.1 patch 18.1.22; and current release 18.2.0. On 2026-09-18, the full controlled canary passed against OMP 18.1.15 and 18.2.0 on official Paseo image `0.9.0-beta.1@sha256:f75a0eb3547ad3cc6bbdeaa7277d2d50eb4d06a9dd669d480371d7adf1c911b5`.
4
4
 
5
5
  Classifications:
6
6
 
@@ -101,11 +101,11 @@ Run the full local provider integration canary with:
101
101
  npm run test:integration:canary
102
102
  ```
103
103
 
104
- Set `PASEO_CANARY_OMP_VERSION` to exercise another pinned release. The local runner knows the SHA-256 values for every version in the compatibility matrix; an unlisted version requires explicit `PASEO_CANARY_OMP_SHA256_AMD64` and `PASEO_CANARY_OMP_SHA256_ARM64` values. It allocates isolated loopback ports, builds a version-specific image, waits for both services to become healthy, runs the deterministic provider scenarios, prints container logs on failure, and always removes its containers, volumes, and image namespace.
104
+ Set `PASEO_CANARY_OMP_VERSION` to exercise another pinned release. The local runner knows the SHA-256 values for every version in the compatibility matrix; an unlisted version requires explicit `PASEO_CANARY_OMP_SHA256_AMD64` and `PASEO_CANARY_OMP_SHA256_ARM64` values. It allocates isolated loopback ports, builds a version-specific image, waits for both services to become healthy, runs the deterministic provider scenarios, prints container logs on failure, and always removes its containers, volumes, and image namespace. CI runs this full canary for OMP 18.1.15 and 18.2.0 whenever `paseo-omp` changes; both matrix entries feed the required `CI sentinel` gate and must pass before the supported OMP floor changes.
105
105
 
106
106
  The Linux real-OMP CI matrix downloads checksummed `omp-linux-x64` assets for OMP 17.2.15, 17.3.4, 18.0.11, 18.1.10, 18.1.15, 18.1.22, and 18.2.0, verifies each binary's pinned GitHub release SHA-256, and runs `PASEO_OMP_REAL_E2E=1 PASEO_OMP_VERSION=<version> npm test -- tests/provider.real.e2e.test.ts`. The historical entries are the four pre-floor releases with more than 4,000 downloads shown by npm for the seven days ending 2026-09-15; they are compatibility regression probes, not a support commitment. The remaining entries cover the supported floor, newest patch in that minor line, and current release. The test uses the real OMP binary and a local deterministic OpenAI-compatible model endpoint, including three sequential prompts on one native session, a Bash tool, and oversized-image transport.
107
107
 
108
- The controlled canary in `canary/compose.yml` successfully built the plugin into that official Paseo image, started the daemon, loaded `paseo-omp`, and completed runtime health, catalog/mode discovery, initial text, model switching, `autocompact`, `follow-up`, and the documented compact/handoff error collection. It then failed on the first Bash scenario because the later turn ended without request-correlated terminal ownership: `OMP terminal ownership could not be confirmed`. The subsequent image, MCP, permission, steering, interruption, persistence, subagent, Hub, usage, rewind, browser, and optional Ollama scenarios were not reached and remain unverified on Paseo 0.9.0-beta.1.
108
+ The controlled canary passed end to end with OMP 18.1.15 and 18.2.0 on 2026-09-18. In addition to runtime health, catalog, prompts, tools, MCP, permissions, steering, interruption, persistence, Hub, usage, rewind, and plugin RPC coverage, it verified that a direct child has `parentSubagentId: null` and the root task's `toolCallId`, a nested child has its direct parent's public subagent ID and the nested task's `toolCallId`, both children finish as `completed`, and both timelines remain independently fetchable without cross-contamination.
109
109
 
110
110
  ## OMP RPC compatibility intake
111
111
 
@@ -145,6 +145,6 @@ The go/no-go criteria, manual acceptance boundary, and alpha limitation list are
145
145
  - `npm run test:integration:install`: npm package acquisition retains required production dependencies, and a fresh Git-style checkout runs frozen production-only preparation with lifecycle scripts disabled. Both installed trees resolve runtime dependencies, compile the client and server entries with Paseo's host compiler, and load the server contribution.
146
146
  - `npm run test:integration:docker`: verifies the host/container ownership boundary.
147
147
  - `npm run test:integration:wsl`: locally skips when `wsl.exe` is unavailable; Windows CI sets `PASEO_OMP_REQUIRE_WSL=1`, so this boundary remains required there.
148
- - `docker compose -f canary/compose.yml`: official Paseo 0.9.0-beta.1 image build, daemon startup, plugin load, and early deterministic scenarios passed; the smoke stopped at the first later-turn Bash scenario with `OMP terminal ownership could not be confirmed`, so the remaining scenarios are unverified.
148
+ - `npm run test:integration:canary`: passed against OMP 18.1.15 and 18.2.0 on official Paseo `0.9.0-beta.1`; direct and nested ancestry, spawning-tool links, terminal status, and independently addressable child timelines passed.
149
149
  - `zizmor .github/workflows`: no findings (offline audit; six repository-wide suppressions remain).
150
150
  - Release Please 17.1.2 `config.json` and `manifest.json` schema validation: passed for `release-please-config.json` and `.release-please-manifest.json`.
@@ -4,7 +4,7 @@ Paseo plugins are trusted, unsandboxed code. Review this plugin and its producti
4
4
 
5
5
  ## Requirements
6
6
 
7
- - Paseo daemon and apps: `>=0.8.0 <0.10.0`; `0.9.0-beta.1` is recommended
7
+ - Paseo daemon and apps: `>=0.8.0 <0.10.0`; use `0.9.0-beta.1` or newer for nested provider-subagent ancestry and spawning-tool links
8
8
  - OMP: `18.1.15` or newer is the supported floor
9
9
  - OMP RPC: protocol v2 must negotiate successfully
10
10
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omercnet/paseo-omp",
3
- "version": "0.2.1-next.90.1",
3
+ "version": "0.3.0-next.100.1",
4
4
  "type": "module",
5
5
  "description": "Paseo integration for OMP, including its direct provider and workspace tooling.",
6
6
  "license": "MIT",
@@ -58,6 +58,7 @@ const MAX_CONNECTION_SESSIONS = 32;
58
58
  const MAX_ACTIVE_OPERATIONS = 128;
59
59
  const MAX_PROVIDER_INPUT_BYTES = 2 * 1024 * 1024;
60
60
  const MAX_NESTED_OPTION_BYTES = 256 * 1024;
61
+ const MAX_ENV_ENTRIES = 256;
61
62
  const MAX_NATIVE_SESSION_RESERVATIONS = 256;
62
63
 
63
64
  function hasOwnEntries(value: unknown): boolean {
@@ -68,6 +69,19 @@ function hasOwnEntries(value: unknown): boolean {
68
69
  return false;
69
70
  }
70
71
 
72
+ function providerOptionsExceedPreflightLimits(value: unknown): boolean {
73
+ if (
74
+ boundedJsonBytes(value, MAX_NESTED_OPTION_BYTES, MAX_ENV_ENTRIES) === Number.POSITIVE_INFINITY
75
+ ) {
76
+ return true;
77
+ }
78
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
79
+ const unrelatedOptions = Object.fromEntries(
80
+ Object.entries(value).filter(([key]) => key !== "env" && key !== "inheritEnv"),
81
+ );
82
+ return boundedJsonBytes(unrelatedOptions, MAX_NESTED_OPTION_BYTES) === Number.POSITIVE_INFINITY;
83
+ }
84
+
71
85
  function preflightProviderInput(input: unknown): void {
72
86
  if (!input || typeof input !== "object") throw new OmpPublicError("Invalid provider request");
73
87
  const record = input as Record<string, unknown>;
@@ -82,7 +96,16 @@ function preflightProviderInput(input: unknown): void {
82
96
  throw new OmpPublicError("Session persistence input is too large");
83
97
  }
84
98
  const config = record.config as Record<string, unknown> | undefined;
85
- for (const value of [config?.mcpServers, config?.providerOptions, config?.settings]) {
99
+ if (
100
+ (config?.env !== undefined &&
101
+ boundedJsonBytes(config.env, MAX_NESTED_OPTION_BYTES, MAX_ENV_ENTRIES) ===
102
+ Number.POSITIVE_INFINITY) ||
103
+ (config?.providerOptions !== undefined &&
104
+ providerOptionsExceedPreflightLimits(config.providerOptions))
105
+ ) {
106
+ throw new OmpPublicError("Session configuration is too large");
107
+ }
108
+ for (const value of [config?.mcpServers, config?.settings]) {
86
109
  if (
87
110
  value !== undefined &&
88
111
  boundedJsonBytes(value, MAX_NESTED_OPTION_BYTES) === Number.POSITIVE_INFINITY
@@ -109,13 +132,17 @@ function preflightProviderInput(input: unknown): void {
109
132
  }
110
133
  }
111
134
  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
- }
135
+ if (
136
+ record.providerOptions !== undefined &&
137
+ providerOptionsExceedPreflightLimits(record.providerOptions)
138
+ ) {
139
+ throw new OmpPublicError("Provider configuration is too large");
140
+ }
141
+ if (
142
+ record.settings !== undefined &&
143
+ boundedJsonBytes(record.settings, MAX_NESTED_OPTION_BYTES) === Number.POSITIVE_INFINITY
144
+ ) {
145
+ throw new OmpPublicError("Provider configuration is too large");
119
146
  }
120
147
  }
121
148
  if (record.type === "session.prompt") {
@@ -3,6 +3,7 @@ import type {
3
3
  ProviderMcpServerConfig,
4
4
  ProviderSessionConfig,
5
5
  } from "@getpaseo/plugin/server/provider";
6
+ import { isValidImagePayload } from "./image";
6
7
  import {
7
8
  type ConnectedMcpClient,
8
9
  type ConnectedMcpTool,
@@ -37,7 +38,13 @@ const MAX_HOST_TOOL_DESCRIPTION_BYTES = 64 * 1024;
37
38
  const MAX_HOST_TOOL_SCHEMA_BYTES = 256 * 1024;
38
39
  const MAX_HOST_TOOL_CATALOG_BYTES = 768 * 1024;
39
40
  const MAX_HOST_TOOL_RESULT_BYTES = 12 * 1024 * 1024;
41
+ const MAX_HOST_TOOL_CONTENT_BLOCKS = 512;
42
+ const MAX_HOST_TOOL_TEXT_BYTES = 1024 * 1024;
43
+ const MAX_HOST_TOOL_IMAGE_DATA_BYTES = 8 * 1024 * 1024;
40
44
  const MAX_STRUCTURED_CONTENT_FALLBACK_BYTES = 1024 * 1024;
45
+ const CONTENT_TRUNCATED_NOTICE = "[MCP result content truncated: exceeded size limits]";
46
+ const DETAILS_OMITTED_NOTICE = "[MCP structured content omitted: exceeded size limits]";
47
+ const RESULT_TRUNCATED_NOTICE = "[MCP content truncated; details omitted]";
41
48
  const MAX_PENDING_HOST_TOOL_CALLS = 64;
42
49
  const MAX_PENDING_HOST_TOOL_BYTES = 8 * 1024 * 1024;
43
50
  const DEFAULT_INITIALIZATION_TIMEOUT_MS = 20_000;
@@ -272,42 +279,171 @@ async function discoverMcpTools(
272
279
  throw new OmpPublicError("MCP server tool-list pagination exceeds the supported limit");
273
280
  }
274
281
 
275
- function normalizeResult(result: unknown): OmpHostToolResult["result"] {
276
- if (
277
- !result ||
278
- typeof result !== "object" ||
282
+ type HostToolContentBlock = OmpHostToolResult["result"]["content"][number];
283
+
284
+ function hostToolContentIsBounded(content: readonly HostToolContentBlock[]): boolean {
285
+ return (
286
+ boundedJsonBytes(
287
+ content,
288
+ MAX_HOST_TOOL_RESULT_BYTES,
289
+ MAX_HOST_TOOL_CONTENT_BLOCKS,
290
+ MAX_HOST_TOOL_IMAGE_DATA_BYTES,
291
+ 4_096,
292
+ ) !== Number.POSITIVE_INFINITY
293
+ );
294
+ }
295
+
296
+ function noticeBlock(text: string): HostToolContentBlock {
297
+ return { type: "text", text };
298
+ }
299
+
300
+ function appendBoundedNotice(
301
+ content: readonly HostToolContentBlock[],
302
+ notice: string,
303
+ ): HostToolContentBlock[] {
304
+ const trailingNotice = content.at(-1)?.text;
305
+ const contentWasTruncated =
306
+ trailingNotice === CONTENT_TRUNCATED_NOTICE || trailingNotice === RESULT_TRUNCATED_NOTICE;
307
+ const prefix = (contentWasTruncated ? content.slice(0, -1) : content).slice(
308
+ 0,
309
+ MAX_HOST_TOOL_CONTENT_BLOCKS - 1,
310
+ );
311
+ const markerText =
312
+ notice === DETAILS_OMITTED_NOTICE && contentWasTruncated ? RESULT_TRUNCATED_NOTICE : notice;
313
+ const marker = noticeBlock(markerText);
314
+ while (prefix.length > 0 && !hostToolContentIsBounded([...prefix, marker])) prefix.pop();
315
+ return [...prefix, marker];
316
+ }
317
+
318
+ function boundedTextPrefix(
319
+ prefix: readonly HostToolContentBlock[],
320
+ block: HostToolContentBlock,
321
+ ): HostToolContentBlock | undefined {
322
+ if (block.type !== "text" || typeof block.text !== "string") return;
323
+ const marker = noticeBlock(CONTENT_TRUNCATED_NOTICE);
324
+ let low = 0;
325
+ let high = block.text.length;
326
+ let best = "";
327
+ while (low <= high) {
328
+ const middle = Math.floor((low + high) / 2);
329
+ let text = block.text.slice(0, middle);
330
+ if (/\p{Surrogate}$/u.test(text)) text = text.slice(0, -1);
331
+ const candidate = { type: "text", text } satisfies HostToolContentBlock;
332
+ if (hostToolContentIsBounded([...prefix, candidate, marker])) {
333
+ best = text;
334
+ low = middle + 1;
335
+ } else {
336
+ high = middle - 1;
337
+ }
338
+ }
339
+ return best ? { type: "text", text: best } : undefined;
340
+ }
341
+
342
+ function normalizeContent(content: readonly unknown[]): HostToolContentBlock[] {
343
+ const normalized: HostToolContentBlock[] = [];
344
+ for (const value of content) {
345
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
346
+ throw new Error("MCP tool returned invalid content");
347
+ }
348
+ const block = value as Record<string, unknown>;
349
+ if (typeof block.type !== "string" || !block.type || utf8Bytes(block.type) > 256) {
350
+ throw new Error("MCP tool returned invalid content");
351
+ }
352
+
353
+ let candidate: HostToolContentBlock;
354
+ let blockWasTruncated = false;
355
+ if (block.type === "text") {
356
+ if (typeof block.text !== "string") throw new Error("MCP tool returned invalid text content");
357
+ const text = truncateUtf8(block.text, MAX_HOST_TOOL_TEXT_BYTES);
358
+ candidate = { ...block, type: "text", text } as HostToolContentBlock;
359
+ blockWasTruncated = text !== block.text;
360
+ } else if (block.type === "image") {
361
+ if (typeof block.data !== "string" || typeof block.mimeType !== "string") {
362
+ throw new Error("MCP tool returned invalid image content");
363
+ }
364
+ if (block.data.length > MAX_HOST_TOOL_IMAGE_DATA_BYTES) {
365
+ return appendBoundedNotice(normalized, CONTENT_TRUNCATED_NOTICE);
366
+ }
367
+ if (!isValidImagePayload(block.data, block.mimeType, MAX_HOST_TOOL_IMAGE_DATA_BYTES)) {
368
+ throw new Error("MCP tool returned invalid image content");
369
+ }
370
+ candidate = {
371
+ ...block,
372
+ type: "image",
373
+ data: block.data,
374
+ mimeType: block.mimeType,
375
+ } as HostToolContentBlock;
376
+ } else {
377
+ candidate = block as HostToolContentBlock;
378
+ }
379
+
380
+ if (normalized.length >= MAX_HOST_TOOL_CONTENT_BLOCKS) {
381
+ return appendBoundedNotice(normalized, CONTENT_TRUNCATED_NOTICE);
382
+ }
383
+ if (!hostToolContentIsBounded([...normalized, candidate])) {
384
+ const prefix = boundedTextPrefix(normalized, candidate);
385
+ return appendBoundedNotice(
386
+ prefix ? [...normalized, prefix] : normalized,
387
+ CONTENT_TRUNCATED_NOTICE,
388
+ );
389
+ }
390
+ normalized.push(candidate);
391
+ if (blockWasTruncated) return appendBoundedNotice(normalized, CONTENT_TRUNCATED_NOTICE);
392
+ }
393
+ return normalized;
394
+ }
395
+
396
+ function structuredContentIsBounded(value: unknown): boolean {
397
+ return (
279
398
  boundedJsonBytes(
280
- result,
399
+ value,
281
400
  MAX_HOST_TOOL_RESULT_BYTES,
282
401
  1_024,
283
402
  MAX_HOST_TOOL_RESULT_BYTES,
284
403
  4_096,
285
- ) === Number.POSITIVE_INFINITY
286
- ) {
287
- throw new Error("MCP tool returned an invalid or oversized result");
404
+ ) !== Number.POSITIVE_INFINITY
405
+ );
406
+ }
407
+
408
+ function normalizeResult(result: unknown): OmpHostToolResult["result"] {
409
+ if (!result || typeof result !== "object" || Array.isArray(result)) {
410
+ throw new Error("MCP tool returned an invalid result");
288
411
  }
289
412
  const record = result as Record<string, unknown>;
290
413
  if (Array.isArray(record.content)) {
291
414
  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({
415
+ const detailsAreBounded = details === undefined || structuredContentIsBounded(details);
416
+ let content = normalizeContent(record.content);
417
+ if (content.length === 0 && details !== undefined && detailsAreBounded) {
418
+ content = [
419
+ {
420
+ type: "text",
421
+ text: truncateUtf8(JSON.stringify(details), MAX_STRUCTURED_CONTENT_FALLBACK_BYTES),
422
+ },
423
+ ];
424
+ }
425
+ if (!detailsAreBounded) content = appendBoundedNotice(content, DETAILS_OMITTED_NOTICE);
426
+
427
+ let normalized = parseOmpHostToolAgentResult({
302
428
  content,
303
- ...(details !== undefined ? { details } : {}),
429
+ ...(details !== undefined && detailsAreBounded ? { details } : {}),
304
430
  ...(typeof record.isError === "boolean" ? { isError: record.isError } : {}),
305
431
  });
432
+ if (!structuredContentIsBounded(normalized)) {
433
+ normalized = parseOmpHostToolAgentResult({
434
+ content: appendBoundedNotice(content, DETAILS_OMITTED_NOTICE),
435
+ ...(typeof record.isError === "boolean" ? { isError: record.isError } : {}),
436
+ });
437
+ }
438
+ return normalized;
306
439
  }
307
440
  if (Object.hasOwn(record, "toolResult")) {
441
+ const details = record.toolResult;
442
+ const detailsAreBounded = structuredContentIsBounded(details);
308
443
  return parseOmpHostToolAgentResult({
309
- content: [{ type: "text", text: "MCP tool completed" }],
310
- details: record.toolResult,
444
+ content: [noticeBlock(detailsAreBounded ? "MCP tool completed" : DETAILS_OMITTED_NOTICE)],
445
+ ...(detailsAreBounded ? { details } : {}),
446
+ ...(typeof record.isError === "boolean" ? { isError: record.isError } : {}),
311
447
  });
312
448
  }
313
449
  throw new Error("MCP tool returned an unsupported result");
@@ -704,12 +840,73 @@ export class OmpHostToolsBridge {
704
840
  result: OmpHostToolResult,
705
841
  ): OmpHostToolResult {
706
842
  const limit = runtime.maxHostToolFrameBytes ?? 1024 * 1024;
707
- try {
708
- if (Buffer.byteLength(`${JSON.stringify(result)}\n`) <= limit) return result;
709
- } catch {
710
- // Fall through to the bounded error result.
843
+ const fits = (candidate: OmpHostToolResult) => {
844
+ try {
845
+ return Buffer.byteLength(`${JSON.stringify(candidate)}\n`) <= limit;
846
+ } catch {
847
+ return false;
848
+ }
849
+ };
850
+ if (fits(result)) return result;
851
+
852
+ const withoutDetails: OmpHostToolResult = {
853
+ ...result,
854
+ result: {
855
+ content: appendBoundedNotice(result.result.content, DETAILS_OMITTED_NOTICE),
856
+ ...(result.result.isError !== undefined ? { isError: result.result.isError } : {}),
857
+ },
858
+ };
859
+ if (result.result.details !== undefined && fits(withoutDetails)) return withoutDetails;
860
+
861
+ const trailingNotice = result.result.content.at(-1)?.text;
862
+ const detailsWereOmitted =
863
+ trailingNotice === DETAILS_OMITTED_NOTICE || trailingNotice === RESULT_TRUNCATED_NOTICE;
864
+ const contentWasTruncated =
865
+ trailingNotice === CONTENT_TRUNCATED_NOTICE || trailingNotice === RESULT_TRUNCATED_NOTICE;
866
+ const marker = noticeBlock(
867
+ result.result.details !== undefined || detailsWereOmitted
868
+ ? RESULT_TRUNCATED_NOTICE
869
+ : CONTENT_TRUNCATED_NOTICE,
870
+ );
871
+ const sourceContent =
872
+ detailsWereOmitted || contentWasTruncated
873
+ ? result.result.content.slice(0, -1)
874
+ : result.result.content;
875
+ const boundedContent: HostToolContentBlock[] = [];
876
+ const terminalWith = (content: HostToolContentBlock[]): OmpHostToolResult => ({
877
+ ...result,
878
+ result: {
879
+ content,
880
+ ...(result.result.isError !== undefined ? { isError: result.result.isError } : {}),
881
+ },
882
+ });
883
+ for (const block of sourceContent) {
884
+ const candidate = terminalWith([...boundedContent, block, marker]);
885
+ if (fits(candidate)) {
886
+ boundedContent.push(block);
887
+ continue;
888
+ }
889
+ if (block.type === "text" && typeof block.text === "string") {
890
+ let low = 0;
891
+ let high = block.text.length;
892
+ let best = "";
893
+ while (low <= high) {
894
+ const middle = Math.floor((low + high) / 2);
895
+ let text = block.text.slice(0, middle);
896
+ if (/\p{Surrogate}$/u.test(text)) text = text.slice(0, -1);
897
+ if (fits(terminalWith([...boundedContent, { type: "text", text }, marker]))) {
898
+ best = text;
899
+ low = middle + 1;
900
+ } else {
901
+ high = middle - 1;
902
+ }
903
+ }
904
+ if (best) boundedContent.push({ type: "text", text: best });
905
+ }
906
+ break;
711
907
  }
712
- return errorResult(result.id, OMP_HOST_TOOL_FRAME_LIMIT_ERROR);
908
+ const bounded = terminalWith([...boundedContent, marker]);
909
+ return fits(bounded) ? bounded : errorResult(result.id, OMP_HOST_TOOL_FRAME_LIMIT_ERROR);
713
910
  }
714
911
 
715
912
  private sendTerminal(
@@ -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,14 @@ 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;
59
73
  // Tool-intensive OMP turns legitimately exceed 64 blocks; transport byte/node budgets remain the
60
74
  // primary resource bounds.
61
75
  export const OMP_MAX_CONTENT_PARTS = 4_096;
@@ -106,6 +120,76 @@ const IDENTIFIER = boundedString(MAX_ID_LENGTH, 1);
106
120
  const NAME = boundedString(MAX_NAME_LENGTH, 1);
107
121
  const OMP_PROVIDER_NAME = NAME.refine((provider) => !provider.includes("/"));
108
122
  const TEXT = boundedString(MAX_TEXT_LENGTH);
123
+ const RAW_DISPLAY_TEXT = boundedString(MAX_IMAGE_DATA_LENGTH);
124
+ const DISPLAY_TRUNCATION_MARKER = "<truncated>";
125
+
126
+ function boundRawDisplayContent(value: unknown): unknown {
127
+ if (typeof value === "string") {
128
+ return utf8Bytes(value) <= MAX_IMAGE_DATA_LENGTH ? value : DISPLAY_TRUNCATION_MARKER;
129
+ }
130
+ if (!Array.isArray(value)) return value;
131
+ let totalBytes = 0;
132
+ for (const part of value) {
133
+ if (!part || typeof part !== "object" || Array.isArray(part)) continue;
134
+ const record = part as Record<string, unknown>;
135
+ if (typeof record.text === "string") totalBytes += utf8Bytes(record.text);
136
+ if (typeof record.thinking === "string") totalBytes += utf8Bytes(record.thinking);
137
+ }
138
+ if (totalBytes <= MAX_IMAGE_DATA_LENGTH) return value;
139
+
140
+ let retainedMarker = false;
141
+ return value.map((part) => {
142
+ if (!part || typeof part !== "object" || Array.isArray(part)) return part;
143
+ const copy = { ...(part as Record<string, unknown>) };
144
+ for (const key of ["text", "thinking"] as const) {
145
+ if (typeof copy[key] !== "string") continue;
146
+ if (retainedMarker) delete copy[key];
147
+ else {
148
+ copy[key] = DISPLAY_TRUNCATION_MARKER;
149
+ retainedMarker = true;
150
+ }
151
+ }
152
+ return copy;
153
+ });
154
+ }
155
+
156
+ function sanitizeLiveMessageDisplay(value: unknown): unknown {
157
+ if (!value || typeof value !== "object" || Array.isArray(value)) return value;
158
+ const message = value as Record<string, unknown>;
159
+ if (message.role === "assistant") {
160
+ const content = boundRawDisplayContent(message.content);
161
+ return content === message.content ? value : { ...message, content };
162
+ }
163
+ if (
164
+ message.role === "bashExecution" &&
165
+ typeof message.output === "string" &&
166
+ utf8Bytes(message.output) > MAX_IMAGE_DATA_LENGTH
167
+ ) {
168
+ return { ...message, output: DISPLAY_TRUNCATION_MARKER };
169
+ }
170
+ return value;
171
+ }
172
+
173
+ function sanitizeLiveDisplayFrame(value: unknown): unknown {
174
+ if (!value || typeof value !== "object" || Array.isArray(value)) return value;
175
+ const frame = value as Record<string, unknown>;
176
+ if (
177
+ frame.type === "message_start" ||
178
+ frame.type === "message_update" ||
179
+ frame.type === "message_end"
180
+ ) {
181
+ const message = sanitizeLiveMessageDisplay(frame.message);
182
+ return message === frame.message ? value : { ...frame, message };
183
+ }
184
+ if (frame.type !== "agent_end" || !Array.isArray(frame.messages)) return value;
185
+ let changed = false;
186
+ const messages = frame.messages.map((message) => {
187
+ const sanitized = sanitizeLiveMessageDisplay(message);
188
+ if (sanitized !== message) changed = true;
189
+ return sanitized;
190
+ });
191
+ return changed ? { ...frame, messages } : value;
192
+ }
109
193
  const OmpThinkingLevelSchema = z.enum(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
110
194
 
111
195
  function isBoundedJson(
@@ -118,6 +202,148 @@ function isBoundedJson(
118
202
  boundedJsonBytes(value, maxBytes, maxItems, maxBytes, maxNodes) !== Number.POSITIVE_INFINITY
119
203
  );
120
204
  }
205
+ function optionalMetadataMetrics(value: unknown) {
206
+ return boundedJsonMetrics(
207
+ value,
208
+ MAX_OPTIONAL_METADATA_BYTES,
209
+ MAX_OPTIONAL_METADATA_ITEMS,
210
+ MAX_OPTIONAL_METADATA_BYTES,
211
+ MAX_OPTIONAL_METADATA_NODES,
212
+ );
213
+ }
214
+
215
+ function optionalMetadataIsBounded(value: unknown): boolean {
216
+ return optionalMetadataMetrics(value) !== undefined;
217
+ }
218
+
219
+ function omitUnsafeOptionalDetails(value: unknown): unknown {
220
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return value;
221
+ const record = value as Record<string, unknown>;
222
+ if (!Object.hasOwn(record, "details") || optionalMetadataIsBounded(record.details)) return value;
223
+ const { details: _details, ...safe } = record;
224
+ return safe;
225
+ }
226
+
227
+ const TASK_RESULT_STATUSES: Readonly<Record<string, true>> = {
228
+ pending: true,
229
+ running: true,
230
+ completed: true,
231
+ failed: true,
232
+ error: true,
233
+ aborted: true,
234
+ canceled: true,
235
+ cancelled: true,
236
+ };
237
+ const TASK_PROGRESS_STATUSES: Readonly<Record<string, true>> = {
238
+ pending: true,
239
+ running: true,
240
+ completed: true,
241
+ failed: true,
242
+ aborted: true,
243
+ };
244
+
245
+ function boundedTaskId(value: unknown): value is string {
246
+ return typeof value === "string" && value.length > 0 && utf8Bytes(value) <= MAX_ID_LENGTH;
247
+ }
248
+
249
+ function taskCorrelationDetails(value: unknown): unknown {
250
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return;
251
+ const details = value as Record<string, unknown>;
252
+ if (!Array.isArray(details.results) || details.results.length > MAX_TASK_CORRELATION_ITEMS)
253
+ return;
254
+ const results: Record<string, unknown>[] = [];
255
+ for (const value of details.results) {
256
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return;
257
+ const result = value as Record<string, unknown>;
258
+ if (!boundedTaskId(result.id)) return;
259
+ const safe: Record<string, unknown> = { id: result.id };
260
+ if (typeof result.status === "string" && Object.hasOwn(TASK_RESULT_STATUSES, result.status)) {
261
+ safe.status = result.status;
262
+ }
263
+ if (typeof result.aborted === "boolean") safe.aborted = result.aborted;
264
+ if (typeof result.exitCode === "number" && Number.isFinite(result.exitCode)) {
265
+ safe.exitCode = result.exitCode;
266
+ }
267
+ if (
268
+ result.error !== undefined &&
269
+ boundedJsonMetrics(result.error, 4_096, 32, 4_096, 64) !== undefined
270
+ ) {
271
+ safe.error = result.error;
272
+ }
273
+ results.push(safe);
274
+ }
275
+ let progress: Record<string, unknown>[] | undefined;
276
+ if (details.progress !== undefined) {
277
+ if (!Array.isArray(details.progress) || details.progress.length > MAX_TASK_CORRELATION_ITEMS) {
278
+ return;
279
+ }
280
+ progress = [];
281
+ for (const value of details.progress) {
282
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return;
283
+ const item = value as Record<string, unknown>;
284
+ if (
285
+ !boundedTaskId(item.id) ||
286
+ typeof item.index !== "number" ||
287
+ !Number.isInteger(item.index) ||
288
+ item.index < 0 ||
289
+ item.index >= MAX_TASK_CORRELATION_ITEMS ||
290
+ typeof item.status !== "string" ||
291
+ !Object.hasOwn(TASK_PROGRESS_STATUSES, item.status)
292
+ ) {
293
+ return;
294
+ }
295
+ progress.push({ id: item.id, index: item.index, status: item.status });
296
+ }
297
+ }
298
+ const correlation = { results, ...(progress ? { progress } : {}) };
299
+ return boundedJsonMetrics(
300
+ correlation,
301
+ MAX_TASK_CORRELATION_BYTES,
302
+ MAX_TASK_CORRELATION_ITEMS,
303
+ MAX_TASK_CORRELATION_BYTES,
304
+ MAX_TASK_CORRELATION_NODES,
305
+ )
306
+ ? correlation
307
+ : undefined;
308
+ }
309
+
310
+ function omitOptionalDetails(value: unknown, preserveTaskCorrelation = false): unknown {
311
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return value;
312
+ const record = value as Record<string, unknown>;
313
+ if (!Object.hasOwn(record, "details")) return value;
314
+ const { details: _details, ...structural } = record;
315
+ if (!preserveTaskCorrelation) return structural;
316
+ const correlation = taskCorrelationDetails(record.details);
317
+ return correlation === undefined ? structural : { ...structural, details: correlation };
318
+ }
319
+
320
+ function createOptionalMetadataSanitizer(): (value: unknown, taskResult?: boolean) => unknown {
321
+ let retainedBytes = 0;
322
+ let retainedNodes = 0;
323
+ return (value, taskResult = false) => {
324
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return value;
325
+ const record = value as Record<string, unknown>;
326
+ if (!Object.hasOwn(record, "details")) return value;
327
+ const metrics = optionalMetadataMetrics(record.details);
328
+ if (
329
+ metrics &&
330
+ retainedBytes + metrics.bytes <= MAX_OPTIONAL_METADATA_BYTES &&
331
+ retainedNodes + metrics.nodes <= MAX_OPTIONAL_METADATA_NODES
332
+ ) {
333
+ retainedBytes += metrics.bytes;
334
+ retainedNodes += metrics.nodes;
335
+ return value;
336
+ }
337
+ const correlation = taskResult ? taskCorrelationDetails(record.details) : undefined;
338
+ const { details: _details, ...safe } = record;
339
+ return correlation === undefined ? safe : { ...safe, details: correlation };
340
+ };
341
+ }
342
+
343
+ const OmpOptionalMetadataSchema = z.preprocess(
344
+ (value) => (optionalMetadataIsBounded(value) ? value : undefined),
345
+ z.unknown().optional(),
346
+ );
121
347
 
122
348
  const OmpContentPartSchema = z
123
349
  .object({
@@ -147,6 +373,14 @@ const OmpContentPartSchema = z
147
373
  context.addIssue({ code: "custom", message: "invalid image payload" });
148
374
  }
149
375
  });
376
+ const OmpAssistantContentPartSchema = OmpContentPartSchema.safeExtend({
377
+ text: RAW_DISPLAY_TEXT.optional(),
378
+ thinking: RAW_DISPLAY_TEXT.optional(),
379
+ });
380
+ const OmpAssistantDisplayContentSchema = z.preprocess(
381
+ boundRawDisplayContent,
382
+ z.union([RAW_DISPLAY_TEXT, z.array(OmpAssistantContentPartSchema).max(OMP_MAX_CONTENT_PARTS)]),
383
+ );
150
384
  const OmpDisplayContentSchema = z.union([
151
385
  TEXT,
152
386
  z.array(OmpContentPartSchema).max(OMP_MAX_CONTENT_PARTS),
@@ -165,10 +399,7 @@ const OmpMessageIdentityShape = {
165
399
  responseId: IDENTIFIER.optional(),
166
400
  images: OmpImageArraySchema.optional(),
167
401
  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(),
402
+ details: OmpOptionalMetadataSchema,
172
403
  };
173
404
  type OmpContentPart = z.infer<typeof OmpContentPartSchema>;
174
405
  type OmpMessageIdentity = {
@@ -218,7 +449,7 @@ export type OmpMessage = OmpMessageIdentity &
218
449
  const OmpMessageSchema: z.ZodType<OmpMessage> = z.union([
219
450
  z.object({
220
451
  role: z.literal("assistant"),
221
- content: OmpDisplayContentSchema.optional(),
452
+ content: OmpAssistantDisplayContentSchema.optional(),
222
453
  ...OmpMessageIdentityShape,
223
454
  errorMessage: boundedString(4_096).nullable().optional(),
224
455
  stopReason: boundedString(64).optional(),
@@ -241,7 +472,13 @@ const OmpMessageSchema: z.ZodType<OmpMessage> = z.union([
241
472
  z.object({
242
473
  role: z.literal("bashExecution"),
243
474
  command: TEXT,
244
- output: TEXT.optional(),
475
+ output: z.preprocess(
476
+ (value) =>
477
+ typeof value === "string" && utf8Bytes(value) > MAX_IMAGE_DATA_LENGTH
478
+ ? DISPLAY_TRUNCATION_MARKER
479
+ : value,
480
+ RAW_DISPLAY_TEXT.optional(),
481
+ ),
245
482
  exitCode: z.number().int().nullable().optional(),
246
483
  cancelled: z.boolean().optional(),
247
484
  truncated: z.boolean().optional(),
@@ -409,6 +646,16 @@ const JsonObjectSchema = z.record(z.string(), z.unknown());
409
646
  const BoundedToolPayloadSchema = z
410
647
  .unknown()
411
648
  .refine((value) => isBoundedJson(value, MAX_SEMANTIC_FRAME_BYTES, 1_024, 4_096));
649
+ const OmpToolResultPayloadSchema = z.preprocess(
650
+ omitUnsafeOptionalDetails,
651
+ z
652
+ .unknown()
653
+ .refine(
654
+ (value) =>
655
+ isBoundedJson(value, MAX_SEMANTIC_FRAME_BYTES, MAX_OPTIONAL_METADATA_ITEMS, 8_192) &&
656
+ isBoundedJson(omitOptionalDetails(value), MAX_SEMANTIC_FRAME_BYTES, 1_024, 4_096),
657
+ ),
658
+ );
412
659
  const OmpHostToolDefinitionSchema = z.object({
413
660
  name: NAME,
414
661
  label: NAME.optional(),
@@ -616,13 +863,13 @@ const OmpAgentSessionEventSchema = z.discriminatedUnion("type", [
616
863
  toolCallId: IDENTIFIER,
617
864
  toolName: NAME,
618
865
  args: BoundedToolPayloadSchema.optional(),
619
- partialResult: BoundedToolPayloadSchema,
866
+ partialResult: OmpToolResultPayloadSchema,
620
867
  }),
621
868
  z.object({
622
869
  type: z.literal("tool_execution_end"),
623
870
  toolCallId: IDENTIFIER,
624
871
  toolName: NAME,
625
- result: BoundedToolPayloadSchema,
872
+ result: OmpToolResultPayloadSchema,
626
873
  isError: z.boolean().optional(),
627
874
  }),
628
875
  OmpCompactionStartSchema,
@@ -873,6 +1120,102 @@ const OmpRuntimeEventSchema = z.discriminatedUnion("type", [
873
1120
  OmpToolApprovalCancelSchema,
874
1121
  z.object({ type: z.literal("advisor_yielded") }),
875
1122
  ]);
1123
+ type OptionalDetailsMapper = (value: unknown, taskResult?: boolean) => unknown;
1124
+
1125
+ function mapRecordField(
1126
+ record: Record<string, unknown>,
1127
+ key: string,
1128
+ map: OptionalDetailsMapper,
1129
+ taskResult = false,
1130
+ ): Record<string, unknown> {
1131
+ if (!Object.hasOwn(record, key)) return record;
1132
+ const next = map(record[key], taskResult);
1133
+ return next === record[key] ? record : { ...record, [key]: next };
1134
+ }
1135
+
1136
+ function mapMessageList(value: unknown, map: OptionalDetailsMapper): unknown {
1137
+ if (!Array.isArray(value)) return value;
1138
+ let changed = false;
1139
+ const messages = value.map((message) => {
1140
+ const taskResult =
1141
+ message !== null &&
1142
+ typeof message === "object" &&
1143
+ !Array.isArray(message) &&
1144
+ (message as Record<string, unknown>).role === "toolResult" &&
1145
+ (message as Record<string, unknown>).toolName === "task";
1146
+ const next = map(message, taskResult);
1147
+ changed ||= next !== message;
1148
+ return next;
1149
+ });
1150
+ return changed ? messages : value;
1151
+ }
1152
+
1153
+ function mapAgentEventDetails(
1154
+ frame: Record<string, unknown>,
1155
+ map: OptionalDetailsMapper,
1156
+ ): Record<string, unknown> {
1157
+ switch (frame.type) {
1158
+ case "message_start":
1159
+ case "message_update":
1160
+ case "message_end": {
1161
+ const message = frame.message;
1162
+ const taskResult =
1163
+ message !== null &&
1164
+ typeof message === "object" &&
1165
+ !Array.isArray(message) &&
1166
+ (message as Record<string, unknown>).role === "toolResult" &&
1167
+ (message as Record<string, unknown>).toolName === "task";
1168
+ return mapRecordField(frame, "message", map, taskResult);
1169
+ }
1170
+ case "tool_execution_update":
1171
+ return mapRecordField(frame, "partialResult", map, frame.toolName === "task");
1172
+ case "tool_execution_end":
1173
+ return mapRecordField(frame, "result", map, frame.toolName === "task");
1174
+ case "agent_end":
1175
+ return mapRecordField(frame, "messages", (messages) => mapMessageList(messages, map));
1176
+ default:
1177
+ return frame;
1178
+ }
1179
+ }
1180
+
1181
+ function mapRuntimeFrameDetails(
1182
+ frame: Record<string, unknown>,
1183
+ map: OptionalDetailsMapper,
1184
+ ): Record<string, unknown> {
1185
+ if (frame.type !== "subagent_event") return mapAgentEventDetails(frame, map);
1186
+ if (frame.payload === null || typeof frame.payload !== "object" || Array.isArray(frame.payload)) {
1187
+ return frame;
1188
+ }
1189
+ const payload = frame.payload as Record<string, unknown>;
1190
+ if (payload.event === null || typeof payload.event !== "object" || Array.isArray(payload.event)) {
1191
+ return frame;
1192
+ }
1193
+ const event = mapAgentEventDetails(payload.event as Record<string, unknown>, map);
1194
+ return event === payload.event ? frame : { ...frame, payload: { ...payload, event } };
1195
+ }
1196
+
1197
+ function sanitizeMessageListMetadata(value: unknown): unknown {
1198
+ return mapMessageList(value, createOptionalMetadataSanitizer());
1199
+ }
1200
+
1201
+ function sanitizeHistoryResponseData(value: unknown): unknown {
1202
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return value;
1203
+ return mapRecordField(value as Record<string, unknown>, "messages", sanitizeMessageListMetadata);
1204
+ }
1205
+
1206
+ function runtimeFrameCollectionLimit(frame: Record<string, unknown>): number {
1207
+ const type = frame.type;
1208
+ if (
1209
+ type === "message_start" ||
1210
+ type === "message_update" ||
1211
+ type === "message_end" ||
1212
+ type === "agent_end" ||
1213
+ type === "subagent_event"
1214
+ ) {
1215
+ return OMP_MAX_CONTENT_PARTS;
1216
+ }
1217
+ return 1_024;
1218
+ }
876
1219
  const OmpModelsResultSchema = z.object({
877
1220
  models: z.array(OmpModelSchema).min(1).max(256),
878
1221
  });
@@ -1988,21 +2331,7 @@ class OmpRpcProcess {
1988
2331
  this.recordProtocolViolation();
1989
2332
  return;
1990
2333
  }
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();
2003
- return;
2004
- }
2005
- this.receiveFrame(frame.data);
2334
+ this.receiveDecodedFrame(decoded, payload.byteLength);
2006
2335
  }
2007
2336
 
2008
2337
  private receiveChunk(frame: ChunkFrame): void {
@@ -2072,26 +2401,33 @@ class OmpRpcProcess {
2072
2401
  this.recordProtocolViolation();
2073
2402
  return;
2074
2403
  }
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();
2404
+ this.receiveDecodedFrame(decodedFrame, reassembled.byteLength);
2405
+ }
2406
+
2407
+ private receiveDecodedFrame(value: unknown, rawByteLength: number): void {
2408
+ if (value && typeof value === "object" && !Array.isArray(value)) {
2409
+ const frame = value as Record<string, unknown>;
2410
+ const pending = typeof frame.id === "string" ? this.pending.get(frame.id) : undefined;
2411
+ if (
2412
+ frame.type === "response" &&
2413
+ (pending?.command === "get_messages" || pending?.command === "get_subagent_messages")
2414
+ ) {
2415
+ this.receiveResponse(frame);
2416
+ return;
2417
+ }
2418
+ }
2419
+ if (rawByteLength > MAX_SEMANTIC_FRAME_BYTES) {
2420
+ this.fail(new Error("OMP RPC frame exceeds the semantic byte limit"));
2087
2421
  return;
2088
2422
  }
2089
- const frameObject = JsonObjectSchema.safeParse(decodedFrame);
2090
- if (!frameObject.success) {
2423
+ if (this.receiveKnownResponse(value)) return;
2424
+ const sanitized = sanitizeLiveDisplayFrame(value);
2425
+ const frame = JsonObjectSchema.safeParse(sanitized);
2426
+ if (!frame.success) {
2091
2427
  this.recordProtocolViolation();
2092
2428
  return;
2093
2429
  }
2094
- this.receiveFrame(frameObject.data);
2430
+ this.receiveFrame(frame.data);
2095
2431
  }
2096
2432
 
2097
2433
  private receiveKnownResponse(value: unknown): boolean {
@@ -2130,6 +2466,11 @@ class OmpRpcProcess {
2130
2466
  const isBranchHistory = pending.command === "get_branch_messages";
2131
2467
  const isHistory =
2132
2468
  pending.command === "get_messages" || pending.command === "get_subagent_messages";
2469
+ const responseData = isHistory
2470
+ ? sanitizeHistoryResponseData(response.data.data)
2471
+ : response.data.data;
2472
+ const boundedFrame =
2473
+ responseData === response.data.data ? frame : { ...frame, data: responseData };
2133
2474
  const responseItemLimit = isBranchHistory ? 1_024 : isHistory ? 100_000 : MAX_ARRAY_ITEMS;
2134
2475
  const responseByteLimit =
2135
2476
  isBranchHistory || isHistory
@@ -2146,7 +2487,7 @@ class OmpRpcProcess {
2146
2487
  : 2_048;
2147
2488
  if (
2148
2489
  boundedJsonBytes(
2149
- frame,
2490
+ boundedFrame,
2150
2491
  responseByteLimit,
2151
2492
  responseItemLimit,
2152
2493
  MAX_IMAGE_DATA_LENGTH,
@@ -2162,7 +2503,7 @@ class OmpRpcProcess {
2162
2503
  if (!settled) return;
2163
2504
  if (response.data.success) {
2164
2505
  try {
2165
- settled.beforeResolve?.(response.data.data);
2506
+ settled.beforeResolve?.(responseData);
2166
2507
  if (settled.command === "prompt") {
2167
2508
  if (this.acceptedPromptIds.size >= MAX_PENDING_REQUESTS) {
2168
2509
  const oldest = this.acceptedPromptIds.values().next().value;
@@ -2170,7 +2511,7 @@ class OmpRpcProcess {
2170
2511
  }
2171
2512
  this.acceptedPromptIds.add(response.data.id);
2172
2513
  }
2173
- settled.resolve(response.data.data);
2514
+ settled.resolve(responseData);
2174
2515
  } catch {
2175
2516
  settled.reject(new Error("OMP RPC response is invalid"));
2176
2517
  }
@@ -2219,21 +2560,27 @@ class OmpRpcProcess {
2219
2560
  this.fail(new Error("OMP emitted invalid terminal metadata"));
2220
2561
  return true;
2221
2562
  }
2563
+ const structuralFrame = mapRuntimeFrameDetails(frame, omitOptionalDetails);
2222
2564
  const messagesAreSafe =
2223
2565
  frame.messages === undefined ||
2224
2566
  (Array.isArray(frame.messages) &&
2225
2567
  frame.messages.length <= MAX_ARRAY_ITEMS &&
2226
2568
  boundedJsonBytes(
2227
- frame.messages,
2569
+ structuralFrame.messages as unknown[],
2228
2570
  MAX_SEMANTIC_FRAME_BYTES,
2229
- MAX_ARRAY_ITEMS,
2230
- MAX_TEXT_LENGTH,
2571
+ OMP_MAX_CONTENT_PARTS,
2572
+ MAX_IMAGE_DATA_LENGTH,
2231
2573
  4_096,
2232
2574
  ) !== Number.POSITIVE_INFINITY);
2233
2575
  const payloadIsSafe =
2234
2576
  messagesAreSafe &&
2235
- boundedJsonBytes(frame, MAX_SEMANTIC_FRAME_BYTES, 1_024, MAX_IMAGE_DATA_LENGTH, 4_096) !==
2236
- Number.POSITIVE_INFINITY;
2577
+ boundedJsonBytes(
2578
+ structuralFrame,
2579
+ MAX_SEMANTIC_FRAME_BYTES,
2580
+ OMP_MAX_CONTENT_PARTS,
2581
+ MAX_IMAGE_DATA_LENGTH,
2582
+ 4_096,
2583
+ ) !== Number.POSITIVE_INFINITY;
2237
2584
  if (onlyUnsafePayload && payloadIsSafe) return false;
2238
2585
  if (envelope.data.isTerminal === false) {
2239
2586
  this.fail(new Error("OMP emitted an invalid nonterminal agent_end payload"));
@@ -2260,16 +2607,22 @@ class OmpRpcProcess {
2260
2607
  this.recordProtocolViolation();
2261
2608
  return;
2262
2609
  }
2263
- if (this.receiveDegradedAgentEnd(frame, true)) return;
2610
+ const safeFrame = mapRuntimeFrameDetails(frame, createOptionalMetadataSanitizer());
2611
+ if (this.receiveDegradedAgentEnd(safeFrame, true)) return;
2264
2612
  if (
2265
- boundedJsonBytes(frame, MAX_SEMANTIC_FRAME_BYTES, 1_024, MAX_IMAGE_DATA_LENGTH, 4_096) ===
2266
- Number.POSITIVE_INFINITY
2613
+ boundedJsonBytes(
2614
+ mapRuntimeFrameDetails(safeFrame, omitOptionalDetails),
2615
+ MAX_SEMANTIC_FRAME_BYTES,
2616
+ runtimeFrameCollectionLimit(safeFrame),
2617
+ MAX_IMAGE_DATA_LENGTH,
2618
+ 4_096,
2619
+ ) === Number.POSITIVE_INFINITY
2267
2620
  ) {
2268
2621
  this.recordProtocolViolation();
2269
2622
  return;
2270
2623
  }
2271
2624
  if (type === "rpc_chunk") {
2272
- const chunk = OmpChunkFrameSchema.safeParse(frame);
2625
+ const chunk = OmpChunkFrameSchema.safeParse(safeFrame);
2273
2626
  if (!chunk.success) this.rejectChunk();
2274
2627
  else this.receiveChunk(chunk.data);
2275
2628
  return;
@@ -2287,7 +2640,7 @@ class OmpRpcProcess {
2287
2640
  this.recordProtocolViolation();
2288
2641
  return;
2289
2642
  }
2290
- const ready = OmpReadyFrameSchema.safeParse(frame);
2643
+ const ready = OmpReadyFrameSchema.safeParse(safeFrame);
2291
2644
  if (!ready.success) {
2292
2645
  this.recordProtocolViolation();
2293
2646
  } else {
@@ -2297,13 +2650,13 @@ class OmpRpcProcess {
2297
2650
  return;
2298
2651
  }
2299
2652
  if (type === "response") {
2300
- this.receiveResponse(frame);
2653
+ this.receiveResponse(safeFrame);
2301
2654
  return;
2302
2655
  }
2303
- const event = OmpRuntimeEventSchema.safeParse(frame);
2656
+ const event = OmpRuntimeEventSchema.safeParse(safeFrame);
2304
2657
  if (!event.success) {
2305
- this.rejectMatchingToolApproval(frame);
2306
- if (type === "agent_end" && this.receiveDegradedAgentEnd(frame, false)) return;
2658
+ this.rejectMatchingToolApproval(safeFrame);
2659
+ if (type === "agent_end" && this.receiveDegradedAgentEnd(safeFrame, false)) return;
2307
2660
  this.recordProtocolViolation();
2308
2661
  return;
2309
2662
  }
@@ -2728,7 +3081,10 @@ export class OmpRpcRuntime implements OmpRuntime {
2728
3081
  );
2729
3082
  return {
2730
3083
  ...transcript,
2731
- messages: z.array(OmpMessageSchema).max(100_000).parse(transcript.messages),
3084
+ messages: z
3085
+ .array(OmpMessageSchema)
3086
+ .max(100_000)
3087
+ .parse(sanitizeMessageListMetadata(transcript.messages)),
2732
3088
  };
2733
3089
  }
2734
3090
  async readPersistedSubagentTranscript(options: {
@@ -2745,7 +3101,10 @@ export class OmpRpcRuntime implements OmpRuntime {
2745
3101
  );
2746
3102
  return {
2747
3103
  ...transcript,
2748
- messages: z.array(OmpMessageSchema).max(100_000).parse(transcript.messages),
3104
+ messages: z
3105
+ .array(OmpMessageSchema)
3106
+ .max(100_000)
3107
+ .parse(sanitizeMessageListMetadata(transcript.messages)),
2749
3108
  };
2750
3109
  }
2751
3110
 
@@ -83,16 +83,14 @@ function truncateJsonString(value: string, maxBytes: number): string {
83
83
  export function truncateUtf8(value: string, maxBytes: number): string {
84
84
  if (utf8Bytes(value) <= maxBytes) return value;
85
85
  const suffix = "<truncated>";
86
- const budget = Math.max(0, maxBytes - utf8Bytes(suffix));
87
- let output = "";
88
- let bytes = 0;
89
- for (const character of value) {
90
- const characterBytes = utf8Bytes(character);
91
- if (bytes + characterBytes > budget) break;
92
- output += character;
93
- bytes += characterBytes;
94
- }
95
- return `${output}${suffix}`;
86
+ const suffixBytes = utf8Bytes(suffix);
87
+ if (maxBytes <= 0) return "";
88
+ if (maxBytes < suffixBytes) return suffix.slice(0, Math.floor(maxBytes));
89
+
90
+ const bytes = Buffer.from(value, "utf8");
91
+ let end = Math.min(maxBytes - suffixBytes, bytes.byteLength);
92
+ while (end > 0 && (bytes[end] & 0xc0) === 0x80) end -= 1;
93
+ return `${bytes.subarray(0, end).toString("utf8")}${suffix}`;
96
94
  }
97
95
 
98
96
  export interface BoundedJsonMetrics {
@@ -94,6 +94,18 @@ const TaskResultDetailsSchema = z.object({
94
94
  exitCode: z.number().optional(),
95
95
  error: z.unknown().optional(),
96
96
  aborted: z.boolean().optional(),
97
+ status: z
98
+ .enum([
99
+ "pending",
100
+ "running",
101
+ "completed",
102
+ "failed",
103
+ "error",
104
+ "aborted",
105
+ "canceled",
106
+ "cancelled",
107
+ ])
108
+ .optional(),
97
109
  }),
98
110
  )
99
111
  .max(MAX_CHILDREN),
@@ -192,8 +204,15 @@ function replayChildren(messages: readonly OmpMessage[]): ReplayChildRef[] {
192
204
  const details = taskResultDetails(message);
193
205
  const results = details?.results ?? [];
194
206
  for (const result of results) {
207
+ const canceled =
208
+ result.aborted === true ||
209
+ result.status === "aborted" ||
210
+ result.status === "canceled" ||
211
+ result.status === "cancelled";
195
212
  const failed =
196
213
  message.isError === true ||
214
+ result.status === "failed" ||
215
+ result.status === "error" ||
197
216
  Boolean(result.error) ||
198
217
  (typeof result.exitCode === "number" && result.exitCode !== 0);
199
218
  children.push({
@@ -201,7 +220,7 @@ function replayChildren(messages: readonly OmpMessage[]): ReplayChildRef[] {
201
220
  agent: result.agent ?? call?.title,
202
221
  description: call?.description,
203
222
  parentToolCallId: message.toolCallId,
204
- status: result.aborted === true ? "canceled" : failed ? "failed" : "completed",
223
+ status: canceled ? "canceled" : failed ? "failed" : "completed",
205
224
  });
206
225
  }
207
226
  const resultIds = new Set(results.map((result) => result.id));
@@ -555,6 +574,7 @@ export class OmpSubsessionProjector {
555
574
  type: "session.opened",
556
575
  sessionId,
557
576
  parentSessionId,
577
+ toolCallId: ref.parentToolCallId,
558
578
  capabilities: ["session.subsession"],
559
579
  restoration: "parent",
560
580
  cwd: this.cwd,
@@ -12,11 +12,14 @@ import {
12
12
  type JsonValue,
13
13
  OmpPublicDataSerializer,
14
14
  OmpPublicError,
15
+ truncateUtf8,
15
16
  utf8Bytes,
16
17
  } from "./security";
17
18
 
18
19
  const STREAM_FRAME_MS = 32;
19
20
  const MAX_STREAM_TEXT_LENGTH = 4 * 1024 * 1024;
21
+ const MAX_RAW_STREAM_TEXT_LENGTH = 8 * 1024 * 1024;
22
+ const MAX_REDACTED_STREAM_TEXT_LENGTH = MAX_RAW_STREAM_TEXT_LENGTH * 3;
20
23
  const MAX_ACTIVE_TOOLS = 64;
21
24
  const MAX_TODOS = 256;
22
25
  const MAX_TURN_NATIVE_IDENTITIES = 1_024;
@@ -908,10 +911,7 @@ export class OmpTimelineProjector {
908
911
  return;
909
912
  }
910
913
  if (message.role === "bashExecution") {
911
- const text = message.output
912
- ? `$ ${message.command}\n${message.output}`
913
- : `$ ${message.command}`;
914
- this.project({ type: "command_output", text }, this.replayTurnId);
914
+ this.publishCustomMessage(message);
915
915
  this.finishTurn(this.replayTurnId);
916
916
  this.replayTurnId = null;
917
917
  }
@@ -1007,13 +1007,28 @@ export class OmpTimelineProjector {
1007
1007
  if (!this.stream || this.closed || this.stream.dirtyBlocks.size === 0) return;
1008
1008
  const stream = this.stream;
1009
1009
  if (!stream.nativeIdentity && !finalizeFallback) return;
1010
- const indexes = [...stream.dirtyBlocks].sort((left, right) => left - right);
1010
+ const indexes = [...stream.blocks.keys()].sort((left, right) => left - right);
1011
+ let remainingTextBytes = MAX_STREAM_TEXT_LENGTH;
1011
1012
  stream.dirtyBlocks.clear();
1012
1013
  for (const contentIndex of indexes) {
1013
1014
  const block = stream.blocks.get(contentIndex);
1014
- if (!block?.text) continue;
1015
- const publicText = block.kind === "image" ? block.text : this.dataFilter.text(block.text);
1016
- if (!publicText || block.publishedText === publicText) continue;
1015
+ if (!block) continue;
1016
+ const publicText =
1017
+ block.kind === "image"
1018
+ ? block.text
1019
+ : truncateUtf8(
1020
+ this.dataFilter.text(block.text, MAX_REDACTED_STREAM_TEXT_LENGTH),
1021
+ remainingTextBytes,
1022
+ );
1023
+ if (block.kind !== "image") {
1024
+ remainingTextBytes = Math.max(0, remainingTextBytes - utf8Bytes(publicText));
1025
+ }
1026
+ if (
1027
+ block.publishedText === publicText ||
1028
+ (!publicText && block.publishedText === undefined)
1029
+ ) {
1030
+ continue;
1031
+ }
1017
1032
  const nextPublishedBytes = utf8Bytes(publicText);
1018
1033
  if (
1019
1034
  stream.retainedBytes + stream.publishedBytes + nextPublishedBytes >
@@ -1267,7 +1282,7 @@ export class OmpTimelineProjector {
1267
1282
  (snapshot.kind === "image" ? 0 : snapshotBytes);
1268
1283
  if (
1269
1284
  retainedBytes + stream.publishedBytes > MAX_STREAM_TOTAL_BYTES ||
1270
- textBytes > MAX_STREAM_TEXT_LENGTH
1285
+ textBytes > MAX_RAW_STREAM_TEXT_LENGTH
1271
1286
  ) {
1272
1287
  return;
1273
1288
  }
@@ -1291,7 +1306,7 @@ export class OmpTimelineProjector {
1291
1306
 
1292
1307
  private publishCommand(turnId: string): void {
1293
1308
  if (!this.commandText) return;
1294
- const publicText = this.dataFilter.text(this.commandText);
1309
+ const publicText = this.dataFilter.text(this.commandText, MAX_STREAM_TEXT_LENGTH);
1295
1310
  if (!publicText || publicText === this.commandPublishedText) return;
1296
1311
  this.commandPublishedText = publicText;
1297
1312
  this.publish({
@@ -1377,13 +1392,21 @@ export class OmpTimelineProjector {
1377
1392
  type: "shell",
1378
1393
  command,
1379
1394
  ...(firstString(details, "cwd") ? { cwd: firstString(details, "cwd") } : {}),
1380
- ...(output ? { output: this.dataFilter.text(output) } : {}),
1395
+ ...(output ? { output: this.dataFilter.text(output, MAX_STREAM_TEXT_LENGTH) } : {}),
1381
1396
  ...(typeof message.exitCode === "number" || message.exitCode === null
1382
1397
  ? { exitCode: message.exitCode }
1383
1398
  : typeof details?.exitCode === "number"
1384
1399
  ? { exitCode: details.exitCode }
1385
1400
  : {}),
1386
1401
  },
1402
+ ...(message.cancelled !== undefined || message.truncated !== undefined
1403
+ ? {
1404
+ metadata: {
1405
+ ...(message.cancelled !== undefined ? { cancelled: message.cancelled } : {}),
1406
+ ...(message.truncated !== undefined ? { truncated: message.truncated } : {}),
1407
+ },
1408
+ }
1409
+ : {}),
1387
1410
  status: message.cancelled ? "canceled" : "completed",
1388
1411
  error: null,
1389
1412
  });