@tt-a1i/openpi 0.3.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/README.md +184 -59
  2. package/SETUP.md +23 -7
  3. package/assets/openpi-launch-card-v1.webp +0 -0
  4. package/bin/openpi.js +145 -0
  5. package/extensions/ask-user/index.ts +30 -14
  6. package/extensions/background-terminals/index.ts +30 -2
  7. package/extensions/background-terminals/src/domain.ts +2 -0
  8. package/extensions/background-terminals/src/manager.ts +486 -106
  9. package/extensions/background-terminals/src/output.ts +33 -0
  10. package/extensions/background-terminals/src/prompt.ts +14 -6
  11. package/extensions/background-terminals/src/result-delivery.ts +4 -1
  12. package/extensions/background-terminals/src/ui/ps.ts +132 -129
  13. package/extensions/capabilities/index.ts +30 -42
  14. package/extensions/capabilities/src/ui.ts +93 -0
  15. package/extensions/clear-context/index.ts +83 -0
  16. package/extensions/context-pivot/index.ts +16 -6
  17. package/extensions/cron/schedule.ts +7 -1
  18. package/extensions/file-mutation-display/index.ts +34 -76
  19. package/extensions/file-mutation-display/render.ts +146 -87
  20. package/extensions/file-search/index.ts +8 -7
  21. package/extensions/file-search/src/binaries.ts +75 -59
  22. package/extensions/git-info/src/changed-files-view.ts +47 -14
  23. package/extensions/git-read/index.ts +328 -0
  24. package/extensions/git-read/src/args.ts +171 -0
  25. package/extensions/git-read/src/process.ts +81 -0
  26. package/extensions/git-read/src/prompt.ts +56 -0
  27. package/extensions/model-info/index.ts +21 -33
  28. package/extensions/model-info/session-metrics.ts +96 -0
  29. package/extensions/plan-mode/bash-policy.ts +54 -9
  30. package/extensions/plan-mode/index.ts +7 -2
  31. package/extensions/post-edit/index.ts +16 -6
  32. package/extensions/sessions/git-stats.ts +258 -72
  33. package/extensions/sessions/index.ts +222 -140
  34. package/extensions/sessions/preview-cache.ts +104 -0
  35. package/extensions/sessions/preview-loader.ts +856 -0
  36. package/extensions/sessions/sessions.ts +43 -4
  37. package/extensions/setup/index.ts +127 -131
  38. package/extensions/shared/activity-status.ts +36 -5
  39. package/extensions/shared/agent-session-page.ts +319 -0
  40. package/extensions/shared/agent-tool-renderer.ts +218 -0
  41. package/extensions/shared/agent-transcript.ts +524 -0
  42. package/extensions/shared/below-editor-navigation.ts +26 -0
  43. package/extensions/shared/capability-intent.ts +53 -0
  44. package/extensions/shared/child-session.ts +444 -22
  45. package/extensions/shared/result-budget.ts +134 -0
  46. package/extensions/shared/result-delivery.ts +34 -0
  47. package/extensions/shared/screen-chrome.ts +133 -0
  48. package/extensions/shared/setup-config.ts +97 -38
  49. package/extensions/shared/setup-episode-state.ts +1 -1
  50. package/extensions/shared/spinner.ts +28 -0
  51. package/extensions/shared/terminal-text.ts +110 -23
  52. package/extensions/shared/text-projection.ts +113 -0
  53. package/extensions/shared/tool-activity.ts +382 -0
  54. package/extensions/shared/tool-surface.ts +42 -8
  55. package/extensions/shared/transcript-viewport.ts +46 -0
  56. package/extensions/shared/web-observer-registry.ts +390 -0
  57. package/extensions/shared/worktree.ts +11 -0
  58. package/extensions/subagents/index.ts +461 -186
  59. package/extensions/subagents/navigation.ts +86 -28
  60. package/extensions/subagents/src/agent-types.ts +37 -15
  61. package/extensions/subagents/src/backend.ts +12 -1
  62. package/extensions/subagents/src/backends/pi.ts +375 -66
  63. package/extensions/subagents/src/domain.ts +5 -0
  64. package/extensions/subagents/src/id-sequence.ts +84 -0
  65. package/extensions/subagents/src/manager.ts +651 -536
  66. package/extensions/subagents/src/prompt.ts +185 -42
  67. package/extensions/subagents/src/result-artifact.ts +146 -0
  68. package/extensions/subagents/src/result-delivery.ts +7 -1
  69. package/extensions/subagents/src/runtime.ts +23 -6
  70. package/extensions/subagents/src/ui/takeover.ts +128 -337
  71. package/extensions/subagents/src/ui/transcript.ts +38 -501
  72. package/extensions/subagents/src/ui/wait-result.ts +103 -15
  73. package/extensions/suggestions/src/ui.ts +10 -4
  74. package/extensions/tasks/index.ts +0 -3
  75. package/extensions/tasks/ui.ts +79 -62
  76. package/extensions/ui-customization/footer.ts +7 -44
  77. package/extensions/ui-customization/index.ts +0 -4
  78. package/extensions/user-input-fold/index.ts +185 -0
  79. package/extensions/web/index.ts +234 -0
  80. package/extensions/workflows/artifacts.ts +147 -22
  81. package/extensions/workflows/completion-projection.ts +457 -0
  82. package/extensions/workflows/controller.ts +14 -2
  83. package/extensions/workflows/coordinator.ts +62 -0
  84. package/extensions/workflows/dashboard.ts +458 -339
  85. package/extensions/workflows/handoff.ts +121 -25
  86. package/extensions/workflows/index.ts +1042 -492
  87. package/extensions/workflows/journal.ts +148 -13
  88. package/extensions/workflows/model.ts +131 -19
  89. package/extensions/workflows/navigation.ts +61 -18
  90. package/extensions/workflows/progress-projection.ts +306 -0
  91. package/extensions/workflows/prompt.ts +166 -10
  92. package/extensions/workflows/replay-safety.ts +58 -27
  93. package/extensions/workflows/result-delivery.ts +253 -0
  94. package/extensions/workflows/retention.ts +593 -0
  95. package/extensions/workflows/runner.ts +388 -279
  96. package/extensions/workflows/sandbox-child.cjs +36 -3
  97. package/extensions/workflows/sandbox.ts +62 -8
  98. package/extensions/workflows/serialization.ts +325 -17
  99. package/extensions/workflows/tool-renderer.ts +22 -0
  100. package/extensions/workflows/transcript.ts +149 -0
  101. package/extensions/workspace-cleanup-guard/index.ts +54 -0
  102. package/extensions/workspace-cleanup-guard/workspace-provenance.ts +563 -0
  103. package/package.json +28 -8
  104. package/skills/subagents/REFERENCE.md +189 -0
  105. package/skills/subagents/SKILL.md +2 -2
  106. package/skills/workflows/REFERENCE.md +10 -5
  107. package/skills/workflows/SKILL.md +53 -10
  108. package/web/adapter/pi-adapter.ts +661 -0
  109. package/web/host/browser-launcher.ts +20 -0
  110. package/web/host/static-assets.ts +4 -0
  111. package/web/host/terminal-status.ts +38 -0
  112. package/web/host/web-host.ts +789 -0
  113. package/web/http-dispatcher.ts +125 -0
  114. package/web/protocol/types.ts +462 -0
  115. package/web/runtime/pi-runtime.ts +991 -0
  116. package/web/runtime/types.ts +71 -0
  117. package/web/runtime/web-host-lease.ts +497 -0
  118. package/web/trace.ts +18 -0
  119. package/web/ui/app.js +1398 -0
  120. package/web/ui/index.html +139 -0
  121. package/web/ui/styles.css +598 -0
  122. package/web/vite.config.mjs +34 -0
  123. package/extensions/execution-convergence/active-evidence.ts +0 -129
  124. package/extensions/execution-convergence/index.ts +0 -442
  125. package/extensions/execution-convergence/workspace-provenance.ts +0 -338
  126. package/extensions/setup/intercom-fs-helper.cjs +0 -130
  127. package/extensions/setup/intercom.ts +0 -603
  128. package/extensions/subagents/src/backends/stub.ts +0 -296
  129. package/extensions/subagents/src/format.ts +0 -48
@@ -164,7 +164,17 @@ const BOOTSTRAP = String.raw`
164
164
  }
165
165
  return value;
166
166
  } catch (error) {
167
- log("pipeline: item " + index + " dropped — " + ((error && error.message) || String(error)));
167
+ callHost(
168
+ "log",
169
+ JSON.stringify({
170
+ text:
171
+ "pipeline: item " +
172
+ index +
173
+ " dropped — " +
174
+ ((error && error.message) || String(error)),
175
+ kind: "pipeline-drop",
176
+ }),
177
+ );
168
178
  return null;
169
179
  }
170
180
  });
@@ -207,6 +217,11 @@ const BOOTSTRAP = String.raw`
207
217
  const value = raw && typeof raw === "object" ? raw[key] : undefined;
208
218
  return typeof value === "number" && Number.isFinite(value) ? value : 0;
209
219
  };
220
+ const readLimit = (key) => {
221
+ const limits = raw && typeof raw === "object" ? raw.limits : undefined;
222
+ const value = limits && typeof limits === "object" ? limits[key] : undefined;
223
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
224
+ };
210
225
  // Every field is coerced, so a missing or malformed snapshot reads as zero
211
226
  // rather than undefined: a script doing arithmetic on it gets 0, not NaN.
212
227
  return deepFreeze({
@@ -217,6 +232,12 @@ const BOOTSTRAP = String.raw`
217
232
  total: read("total"),
218
233
  cost: read("cost"),
219
234
  agents: read("agents"),
235
+ limits: {
236
+ concurrency: readLimit("concurrency"),
237
+ maxAgentCalls: readLimit("maxAgentCalls"),
238
+ callsUsed: readLimit("callsUsed"),
239
+ callsRemaining: readLimit("callsRemaining"),
240
+ },
220
241
  });
221
242
  }
222
243
 
@@ -298,8 +319,9 @@ process.on("message", (message) => {
298
319
  }
299
320
  initialized = true;
300
321
  token = message.token;
322
+ const seq = typeof message.seq === "number" ? message.seq : undefined;
301
323
  if (typeof message.usageJson === "string") usageJson = message.usageJson;
302
- run(message.source, message.argsJson, message.maxConcurrency);
324
+ run(message.source, message.argsJson, message.maxConcurrency, seq);
303
325
  return;
304
326
  }
305
327
  if (message.token !== token || message.kind !== "agentResult") return;
@@ -307,6 +329,7 @@ process.on("message", (message) => {
307
329
  const pending = pendingAgents.get(message.id);
308
330
  if (!pending) return;
309
331
  pendingAgents.delete(message.id);
332
+ const seq = typeof message.seq === "number" ? message.seq : undefined;
310
333
  if (typeof message.resultJson === "string")
311
334
  pending.resolve(message.resultJson);
312
335
  else
@@ -315,9 +338,14 @@ process.on("message", (message) => {
315
338
  typeof message.error === "string" ? message.error : "Agent IPC failed",
316
339
  ),
317
340
  );
341
+ if (seq !== undefined) {
342
+ setImmediate(() => {
343
+ send({ kind: "idle", seq });
344
+ });
345
+ }
318
346
  });
319
347
 
320
- function run(source, argsJson, maxConcurrency) {
348
+ function run(source, argsJson, maxConcurrency, initSeq) {
321
349
  try {
322
350
  const sandbox = Object.create(null);
323
351
  sandbox.__argsJson = argsJson;
@@ -389,6 +417,11 @@ function run(source, argsJson, maxConcurrency) {
389
417
  context,
390
418
  { timeout: 1000 },
391
419
  );
420
+ if (initSeq !== undefined) {
421
+ setImmediate(() => {
422
+ send({ kind: "idle", seq: initSeq });
423
+ });
424
+ }
392
425
  Promise.resolve(context.__workflowPromise)
393
426
  .then((resultJson) => {
394
427
  if (typeof resultJson !== "string")
@@ -1,9 +1,9 @@
1
+ import { type ChildProcess, spawn } from "node:child_process";
1
2
  import { randomBytes } from "node:crypto";
2
- import { spawn, type ChildProcess } from "node:child_process";
3
3
  import * as path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { MAX_WORKFLOW_AGENT_CALLS } from "../shared/setup-config.ts";
6
- import { safeStringify, toSerializable } from "./serialization.ts";
6
+ import { encodeCompleteJson, toSerializable } from "./serialization.ts";
7
7
 
8
8
  const MAX_SOURCE_BYTES = 512 * 1024;
9
9
  const MAX_ARGS_BYTES = 256 * 1024;
@@ -19,6 +19,11 @@ const MAX_LOG_MESSAGE_BYTES = 8 * 1024;
19
19
  * bypasses the controller entirely.
20
20
  */
21
21
  export const AGENT_CALL_BACKSTOP_MARGIN = 8;
22
+ /**
23
+ * Maximum synchronous execution time allowed between async agent boundaries.
24
+ * Non-yielding code (e.g. while(true){}) after await is terminated by this timeout.
25
+ */
26
+ const SANDBOX_SYNC_TIMEOUT_MS = 1_000;
22
27
 
23
28
  export interface SandboxAgentOptions {
24
29
  agent_type?: unknown;
@@ -53,7 +58,7 @@ export interface RunWorkflowSandboxOptions {
53
58
  signal: AbortSignal,
54
59
  ) => Promise<SandboxAgentResult>;
55
60
  onPhase: (title: string) => void;
56
- onLog: (text: string) => void;
61
+ onLog: (text: string, kind?: "pipeline-drop") => void;
57
62
  /**
58
63
  * Cumulative run usage, read at send time so the child's `usage()` reflects
59
64
  * the agent that just settled rather than a value captured at launch.
@@ -147,13 +152,23 @@ export function runWorkflowSandbox(options: RunWorkflowSandboxOptions) {
147
152
  ),
148
153
  );
149
154
 
150
- const argsJson = safeStringify(
155
+ const encodedArgs = encodeCompleteJson(
151
156
  { defined: options.args !== undefined, value: options.args },
152
- { maxBytes: MAX_ARGS_BYTES, maxDepth: 16, maxNodes: 10_000 },
157
+ {
158
+ maxBytes: MAX_ARGS_BYTES,
159
+ maxDepth: 16,
160
+ maxNodes: 10_000,
161
+ maxStringBytes: MAX_ARGS_BYTES,
162
+ },
153
163
  );
154
- if (byteLength(argsJson) > MAX_ARGS_BYTES) {
155
- return Promise.reject(new Error("Workflow args exceed the IPC limit"));
164
+ if (!encodedArgs.ok) {
165
+ return Promise.reject(
166
+ new Error(
167
+ `Workflow args exceed the ${MAX_ARGS_BYTES}-byte IPC limit (${encodedArgs.limit} limit at ${encodedArgs.path})`,
168
+ ),
169
+ );
156
170
  }
171
+ const argsJson = encodedArgs.json;
157
172
 
158
173
  return new Promise<unknown>((resolve, reject) => {
159
174
  const workerPath = fileURLToPath(
@@ -182,6 +197,28 @@ export function runWorkflowSandbox(options: RunWorkflowSandboxOptions) {
182
197
  const activeAgentRequests = new Map<number, AbortController>();
183
198
  let requestCount = 0;
184
199
  let finished = false;
200
+ let executionSeq = 0;
201
+ let executionWatchdog: NodeJS.Timeout | undefined;
202
+
203
+ const armWatchdog = (timeoutMs = SANDBOX_SYNC_TIMEOUT_MS) => {
204
+ if (finished) return 0;
205
+ if (executionWatchdog) clearTimeout(executionWatchdog);
206
+ const currentSeq = ++executionSeq;
207
+ executionWatchdog = setTimeout(() => {
208
+ if (finished || currentSeq !== executionSeq) return;
209
+ finish(new Error("Script execution timed out"));
210
+ }, timeoutMs);
211
+ executionWatchdog.unref?.();
212
+ return currentSeq;
213
+ };
214
+
215
+ const disarmWatchdog = (seq?: number) => {
216
+ if (seq !== undefined && seq !== executionSeq) return;
217
+ if (executionWatchdog) {
218
+ clearTimeout(executionWatchdog);
219
+ executionWatchdog = undefined;
220
+ }
221
+ };
185
222
 
186
223
  // The child parses this and falls back to zeros if it is ever unusable, so
187
224
  // a broken snapshot degrades `usage()` to a zero reading instead of
@@ -195,6 +232,7 @@ export function runWorkflowSandbox(options: RunWorkflowSandboxOptions) {
195
232
  };
196
233
 
197
234
  const cleanup = () => {
235
+ disarmWatchdog();
198
236
  for (const abortController of activeAgentRequests.values()) {
199
237
  abortController.abort(new Error("Workflow stopped"));
200
238
  }
@@ -277,12 +315,21 @@ export function runWorkflowSandbox(options: RunWorkflowSandboxOptions) {
277
315
  if (!isRecord(payload) || typeof payload.text !== "string") {
278
316
  throw new Error("invalid text");
279
317
  }
280
- options.onLog(payload.text);
318
+ if (payload.kind !== undefined && payload.kind !== "pipeline-drop") {
319
+ throw new Error("invalid log kind");
320
+ }
321
+ options.onLog(payload.text, payload.kind);
281
322
  } catch {
282
323
  finish(new Error("Workflow sandbox sent an invalid log line"));
283
324
  }
284
325
  return;
285
326
  }
327
+ if (raw.kind === "idle") {
328
+ if (typeof raw.seq === "number") {
329
+ disarmWatchdog(raw.seq);
330
+ }
331
+ return;
332
+ }
286
333
  if (raw.kind === "agent") {
287
334
  if (
288
335
  typeof raw.payloadJson !== "string" ||
@@ -335,10 +382,12 @@ export function runWorkflowSandbox(options: RunWorkflowSandboxOptions) {
335
382
  error: "Agent result exceeded the workflow IPC output limit",
336
383
  });
337
384
  }
385
+ const seq = armWatchdog();
338
386
  child.send({
339
387
  token,
340
388
  kind: "agentResult",
341
389
  id,
390
+ seq,
342
391
  resultJson,
343
392
  usageJson: usageJson(),
344
393
  });
@@ -385,6 +434,10 @@ export function runWorkflowSandbox(options: RunWorkflowSandboxOptions) {
385
434
  finish(new Error("Workflow sandbox sent an unknown IPC message"));
386
435
  });
387
436
 
437
+ // Arm the synchronous execution watchdog for the initial script invocation
438
+ // (covers non-yielding code before and after initial microtask yields).
439
+ const initSeq = armWatchdog();
440
+
388
441
  child.send(
389
442
  {
390
443
  kind: "init",
@@ -393,6 +446,7 @@ export function runWorkflowSandbox(options: RunWorkflowSandboxOptions) {
393
446
  argsJson,
394
447
  maxConcurrency: options.maxConcurrency,
395
448
  usageJson: usageJson(),
449
+ seq: initSeq,
396
450
  },
397
451
  (error) => {
398
452
  if (error) finish(error);
@@ -8,6 +8,24 @@ export interface SerializationOptions {
8
8
  maxStringBytes?: number;
9
9
  }
10
10
 
11
+ export type CompleteJsonLimit = "bytes" | "depth" | "nodes" | "string";
12
+
13
+ export type CompleteJsonEncoding =
14
+ | {
15
+ readonly ok: true;
16
+ readonly json: string;
17
+ readonly bytes: number;
18
+ readonly nodes: number;
19
+ }
20
+ | {
21
+ readonly ok: false;
22
+ readonly limit: CompleteJsonLimit;
23
+ readonly path: string;
24
+ readonly maximum: number;
25
+ readonly observedAtLeast: number;
26
+ readonly nodes: number;
27
+ };
28
+
11
29
  const DEFAULT_MAX_BYTES = 1024 * 1024;
12
30
  const DEFAULT_MAX_DEPTH = 16;
13
31
  const DEFAULT_MAX_NODES = 20_000;
@@ -17,6 +35,269 @@ function byteLength(value: string) {
17
35
  return Buffer.byteLength(value, "utf8");
18
36
  }
19
37
 
38
+ function boundedPath(parent: string, child: string) {
39
+ const childPreview =
40
+ child.length <= 128 ? child : `${child.slice(0, 128)}...[key truncated]`;
41
+ const path = `${parent}.${childPreview}`;
42
+ return byteLength(path) <= 256
43
+ ? path
44
+ : `${truncateUtf8(path, 240)}...[path truncated]`;
45
+ }
46
+
47
+ function configuredBudget(name: string, value: number, minimum: number) {
48
+ if (!Number.isSafeInteger(value) || !Number.isFinite(value)) {
49
+ throw new RangeError(`${name} must be a finite integer`);
50
+ }
51
+ if (value < minimum) {
52
+ throw new RangeError(`${name} must be at least ${minimum}`);
53
+ }
54
+ return value;
55
+ }
56
+
57
+ /**
58
+ * Encode a complete inert JSON value while bounding work and output during
59
+ * traversal. Unlike `safeStringify`, this interface never returns a truncated
60
+ * value: callers that persist authoritative data can fail closed instead.
61
+ */
62
+ export function encodeCompleteJson(
63
+ value: unknown,
64
+ options: Required<SerializationOptions>,
65
+ ): CompleteJsonEncoding {
66
+ const maxBytes = configuredBudget("maxBytes", options.maxBytes, 1);
67
+ const maxDepth = configuredBudget("maxDepth", options.maxDepth, 0);
68
+ const maxNodes = configuredBudget("maxNodes", options.maxNodes, 1);
69
+ const maxStringBytes = configuredBudget(
70
+ "maxStringBytes",
71
+ options.maxStringBytes,
72
+ 0,
73
+ );
74
+ const chunks: string[] = [];
75
+ const seen = new WeakMap<object, string>();
76
+ let bytes = 0;
77
+ let nodes = 0;
78
+ let failure: Exclude<CompleteJsonEncoding, { ok: true }> | undefined;
79
+
80
+ const stop = (
81
+ limit: CompleteJsonLimit,
82
+ path: string,
83
+ maximum: number,
84
+ observedAtLeast: number,
85
+ ) => {
86
+ failure ??= {
87
+ ok: false,
88
+ limit,
89
+ path: truncateUtf8(path, 256),
90
+ maximum,
91
+ observedAtLeast,
92
+ nodes,
93
+ };
94
+ return false;
95
+ };
96
+
97
+ const append = (text: string, path: string) => {
98
+ const addition = byteLength(text);
99
+ if (bytes + addition > maxBytes) {
100
+ return stop("bytes", path, maxBytes, bytes + addition);
101
+ }
102
+ chunks.push(text);
103
+ bytes += addition;
104
+ return true;
105
+ };
106
+
107
+ const encodedString = (text: string, path: string) => {
108
+ const sourceBytes = byteLength(text);
109
+ if (sourceBytes > maxStringBytes) {
110
+ stop("string", path, maxStringBytes, sourceBytes);
111
+ return undefined;
112
+ }
113
+ return JSON.stringify(text);
114
+ };
115
+
116
+ const safeErrorText = (error: unknown) => {
117
+ try {
118
+ return error instanceof Error ? error.message : String(error);
119
+ } catch {
120
+ return "unknown error";
121
+ }
122
+ };
123
+
124
+ const visit = (current: unknown, depth: number, path: string): boolean => {
125
+ if (failure) return false;
126
+ if (nodes >= maxNodes) {
127
+ return stop("nodes", path, maxNodes, nodes + 1);
128
+ }
129
+ nodes++;
130
+ if (depth > maxDepth) {
131
+ return stop("depth", path, maxDepth, depth);
132
+ }
133
+
134
+ if (current === null) return append("null", path);
135
+ if (typeof current === "boolean")
136
+ return append(current ? "true" : "false", path);
137
+ if (typeof current === "string") {
138
+ const encoded = encodedString(current, path);
139
+ return encoded === undefined ? false : append(encoded, path);
140
+ }
141
+ if (typeof current === "number") {
142
+ return append(
143
+ Number.isFinite(current)
144
+ ? JSON.stringify(current)
145
+ : JSON.stringify(`[number: ${String(current)}]`),
146
+ path,
147
+ );
148
+ }
149
+ if (typeof current === "bigint")
150
+ return append(JSON.stringify(`${current.toString()}n`), path);
151
+ if (typeof current === "undefined")
152
+ return append(JSON.stringify("[undefined]"), path);
153
+ if (typeof current === "symbol")
154
+ return append(
155
+ JSON.stringify(`[symbol: ${current.description ?? ""}]`),
156
+ path,
157
+ );
158
+ if (typeof current === "function")
159
+ return append(
160
+ JSON.stringify(`[function: ${current.name || "anonymous"}]`),
161
+ path,
162
+ );
163
+ if (typeof current !== "object")
164
+ return append(JSON.stringify(String(current)), path);
165
+
166
+ const prior = seen.get(current);
167
+ if (prior) return append(JSON.stringify(`[circular: ${prior}]`), path);
168
+ seen.set(current, path);
169
+
170
+ if (current instanceof Date) {
171
+ try {
172
+ return append(
173
+ JSON.stringify(
174
+ Number.isNaN(current.getTime())
175
+ ? "[date: invalid]"
176
+ : current.toISOString(),
177
+ ),
178
+ path,
179
+ );
180
+ } catch (error) {
181
+ return append(
182
+ JSON.stringify(`[unreadable date: ${safeErrorText(error)}]`),
183
+ path,
184
+ );
185
+ }
186
+ }
187
+
188
+ if (current instanceof Error) {
189
+ let name = "Error";
190
+ let message = "";
191
+ let stack: string | undefined;
192
+ try {
193
+ name = current.name;
194
+ } catch {}
195
+ try {
196
+ message = current.message;
197
+ } catch {}
198
+ try {
199
+ stack = current.stack;
200
+ } catch {}
201
+ return visit(
202
+ {
203
+ name,
204
+ message,
205
+ ...(stack ? { stack: truncateUtf8(stack, 16 * 1024) } : {}),
206
+ },
207
+ depth,
208
+ path,
209
+ );
210
+ }
211
+
212
+ if (Array.isArray(current)) {
213
+ let length: number;
214
+ try {
215
+ length = current.length;
216
+ } catch (error) {
217
+ return visit(
218
+ `[unreadable array: ${safeErrorText(error)}]`,
219
+ depth + 1,
220
+ path,
221
+ );
222
+ }
223
+ if (!append("[", path)) return false;
224
+ for (let index = 0; index < length; index++) {
225
+ if (nodes >= maxNodes) {
226
+ return stop(
227
+ "nodes",
228
+ boundedPath(path, String(index)),
229
+ maxNodes,
230
+ nodes + 1,
231
+ );
232
+ }
233
+ if (index > 0 && !append(",", path)) return false;
234
+ if (!(index in current)) {
235
+ nodes++;
236
+ if (!append("null", boundedPath(path, String(index)))) return false;
237
+ continue;
238
+ }
239
+ let item: unknown;
240
+ try {
241
+ item = current[index];
242
+ } catch (error) {
243
+ item = `[unreadable property: ${safeErrorText(error)}]`;
244
+ }
245
+ if (!visit(item, depth + 1, boundedPath(path, String(index)))) {
246
+ return false;
247
+ }
248
+ }
249
+ return append("]", path);
250
+ }
251
+
252
+ const objectChunkStart = chunks.length;
253
+ const objectByteStart = bytes;
254
+ if (!append("{", path)) return false;
255
+ let count = 0;
256
+ try {
257
+ // JavaScript has no interruptible own-key iterator: a Proxy's ownKeys
258
+ // trap may allocate before iteration begins. Once keys are available,
259
+ // the budgets below stop before reading any further property values.
260
+ for (const key in current) {
261
+ if (!Object.prototype.hasOwnProperty.call(current, key)) continue;
262
+ if (nodes >= maxNodes) {
263
+ return stop("nodes", boundedPath(path, key), maxNodes, nodes + 1);
264
+ }
265
+ const keyBytes = byteLength(key);
266
+ const childPath = boundedPath(path, key);
267
+ if (keyBytes > maxStringBytes) {
268
+ return stop("string", childPath, maxStringBytes, keyBytes);
269
+ }
270
+ const keyJson = JSON.stringify(key);
271
+ if (count > 0 && !append(",", path)) return false;
272
+ if (!append(keyJson, childPath) || !append(":", childPath)) {
273
+ return false;
274
+ }
275
+ let child: unknown;
276
+ try {
277
+ child = (current as Record<string, unknown>)[key];
278
+ } catch (error) {
279
+ child = `[unreadable property: ${safeErrorText(error)}]`;
280
+ }
281
+ if (!visit(child, depth + 1, childPath)) return false;
282
+ count++;
283
+ }
284
+ } catch (error) {
285
+ chunks.length = objectChunkStart;
286
+ bytes = objectByteStart;
287
+ return visit(
288
+ `[unreadable object: ${safeErrorText(error)}]`,
289
+ depth + 1,
290
+ path,
291
+ );
292
+ }
293
+ return append("}", path);
294
+ };
295
+
296
+ visit(value, 0, "$root");
297
+ if (failure) return failure;
298
+ return { ok: true, json: chunks.join(""), bytes, nodes };
299
+ }
300
+
20
301
  export function truncateUtf8(value: string, maxBytes: number) {
21
302
  if (maxBytes <= 0) return "";
22
303
  if (byteLength(value) <= maxBytes) return value;
@@ -72,9 +353,29 @@ export function toSerializable(
72
353
  seen.set(current, location);
73
354
 
74
355
  if (Array.isArray(current)) {
75
- return current.map((item, index) =>
76
- visit(item, depth + 1, `${location}[${index}]`),
77
- );
356
+ const result: unknown[] = [];
357
+ const length = current.length;
358
+ for (let index = 0; index < length; index++) {
359
+ if (nodes >= maxNodes) {
360
+ result.push("[truncated: node limit]");
361
+ break;
362
+ }
363
+ if (!(index in current)) {
364
+ nodes++;
365
+ result.length++;
366
+ continue;
367
+ }
368
+ try {
369
+ result.push(
370
+ visit(current[index], depth + 1, `${location}[${index}]`),
371
+ );
372
+ } catch (error) {
373
+ result.push(
374
+ `[unreadable property: ${error instanceof Error ? error.message : String(error)}]`,
375
+ );
376
+ }
377
+ }
378
+ return result;
78
379
  }
79
380
 
80
381
  if (current instanceof Date) {
@@ -93,24 +394,31 @@ export function toSerializable(
93
394
  }
94
395
 
95
396
  const result: Record<string, unknown> = Object.create(null);
96
- let keys: string[];
97
397
  try {
98
- keys = Object.keys(current);
398
+ for (const key in current) {
399
+ if (!Object.prototype.hasOwnProperty.call(current, key)) continue;
400
+ if (nodes >= maxNodes) {
401
+ let marker = "[truncated: node limit]";
402
+ while (Object.prototype.hasOwnProperty.call(result, marker)) {
403
+ marker += ".";
404
+ }
405
+ result[marker] = true;
406
+ break;
407
+ }
408
+ try {
409
+ result[key] = visit(
410
+ (current as Record<string, unknown>)[key],
411
+ depth + 1,
412
+ `${location}.${key}`,
413
+ );
414
+ } catch (error) {
415
+ result[key] =
416
+ `[unreadable property: ${error instanceof Error ? error.message : String(error)}]`;
417
+ }
418
+ }
99
419
  } catch (error) {
100
420
  return `[unreadable object: ${error instanceof Error ? error.message : String(error)}]`;
101
421
  }
102
- for (const key of keys) {
103
- try {
104
- result[key] = visit(
105
- (current as Record<string, unknown>)[key],
106
- depth + 1,
107
- `${location}.${key}`,
108
- );
109
- } catch (error) {
110
- result[key] =
111
- `[unreadable property: ${error instanceof Error ? error.message : String(error)}]`;
112
- }
113
- }
114
422
  return result;
115
423
  };
116
424
 
@@ -0,0 +1,22 @@
1
+ /** In-memory association between a workflow transcript projection and its child renderer. */
2
+
3
+ import type { AgentToolRenderer } from "../shared/agent-tool-renderer.ts";
4
+ import type { TranscriptEntry } from "./model.ts";
5
+
6
+ const renderers = new WeakMap<
7
+ ReadonlyArray<TranscriptEntry>,
8
+ AgentToolRenderer
9
+ >();
10
+
11
+ export function bindWorkflowToolRenderer<
12
+ T extends ReadonlyArray<TranscriptEntry>,
13
+ >(transcript: T, renderer: AgentToolRenderer): T {
14
+ renderers.set(transcript, renderer);
15
+ return transcript;
16
+ }
17
+
18
+ export function workflowToolRenderer(
19
+ transcript: ReadonlyArray<TranscriptEntry>,
20
+ ) {
21
+ return renderers.get(transcript);
22
+ }