@tt-a1i/openpi 0.1.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 (114) hide show
  1. package/README.md +643 -0
  2. package/SETUP.md +74 -0
  3. package/THIRD_PARTY_NOTICES.md +16 -0
  4. package/assets/openpi-package.png +0 -0
  5. package/assets/readme-hero-mobile.svg +72 -0
  6. package/assets/readme-hero.svg +118 -0
  7. package/assets/readme-runtime-mobile.svg +91 -0
  8. package/assets/readme-runtime.svg +111 -0
  9. package/extensions/ask-user/handoff.ts +205 -0
  10. package/extensions/ask-user/index.ts +1110 -0
  11. package/extensions/ask-user/limits.ts +89 -0
  12. package/extensions/ask-user/prompt.ts +76 -0
  13. package/extensions/background-terminals/index.ts +653 -0
  14. package/extensions/background-terminals/src/domain.ts +99 -0
  15. package/extensions/background-terminals/src/manager.ts +989 -0
  16. package/extensions/background-terminals/src/output.ts +84 -0
  17. package/extensions/background-terminals/src/prompt.ts +195 -0
  18. package/extensions/background-terminals/src/result-delivery.ts +43 -0
  19. package/extensions/background-terminals/src/runtime.ts +36 -0
  20. package/extensions/background-terminals/src/ui/output-view.ts +55 -0
  21. package/extensions/background-terminals/src/ui/ps.ts +642 -0
  22. package/extensions/background-terminals/src/ui/tool-result.ts +146 -0
  23. package/extensions/background-terminals/src/watch.ts +192 -0
  24. package/extensions/context-pivot/index.ts +222 -0
  25. package/extensions/copy-all/index.ts +65 -0
  26. package/extensions/cron/index.ts +173 -0
  27. package/extensions/cron/schedule.ts +127 -0
  28. package/extensions/file-mutation-display/index.ts +105 -0
  29. package/extensions/file-mutation-display/render.ts +107 -0
  30. package/extensions/file-search/index.ts +515 -0
  31. package/extensions/file-search/src/args.ts +129 -0
  32. package/extensions/file-search/src/binaries.ts +419 -0
  33. package/extensions/file-search/src/output.ts +142 -0
  34. package/extensions/file-search/src/process.ts +309 -0
  35. package/extensions/file-search/src/prompt.ts +53 -0
  36. package/extensions/git-info/index.ts +272 -0
  37. package/extensions/git-info/src/changed-files-view.ts +414 -0
  38. package/extensions/git-info/src/process.ts +107 -0
  39. package/extensions/git-info/src/refresh-coordinator.ts +13 -0
  40. package/extensions/git-info/src/runtime.ts +28 -0
  41. package/extensions/goal/controller.ts +794 -0
  42. package/extensions/goal/index.ts +521 -0
  43. package/extensions/goal/prompts.ts +122 -0
  44. package/extensions/goal/state.ts +763 -0
  45. package/extensions/goal/ui.ts +158 -0
  46. package/extensions/model-info/index.ts +234 -0
  47. package/extensions/plan-mode/bash-policy.ts +313 -0
  48. package/extensions/plan-mode/index.ts +539 -0
  49. package/extensions/post-edit/index.ts +129 -0
  50. package/extensions/sessions/LICENSE.upstream +21 -0
  51. package/extensions/sessions/git-stats.ts +226 -0
  52. package/extensions/sessions/index.ts +1092 -0
  53. package/extensions/sessions/sessions.ts +385 -0
  54. package/extensions/setup/index.ts +408 -0
  55. package/extensions/shared/activity-status.ts +65 -0
  56. package/extensions/shared/below-editor-navigation.ts +343 -0
  57. package/extensions/shared/child-session.ts +352 -0
  58. package/extensions/shared/context-utilization.ts +47 -0
  59. package/extensions/shared/dashboard-state.ts +102 -0
  60. package/extensions/shared/plan-mode-state.ts +65 -0
  61. package/extensions/shared/setup-config.ts +971 -0
  62. package/extensions/shared/subagent-roles.ts +22 -0
  63. package/extensions/shared/terminal-text.ts +38 -0
  64. package/extensions/shared/tool-call-timeout.ts +104 -0
  65. package/extensions/shared/worktree.ts +526 -0
  66. package/extensions/subagents/index.ts +1225 -0
  67. package/extensions/subagents/navigation.ts +121 -0
  68. package/extensions/subagents/src/agent-types.ts +543 -0
  69. package/extensions/subagents/src/backend.ts +63 -0
  70. package/extensions/subagents/src/backends/pi.ts +493 -0
  71. package/extensions/subagents/src/backends/stub.ts +296 -0
  72. package/extensions/subagents/src/by-the-way.ts +21 -0
  73. package/extensions/subagents/src/domain.ts +271 -0
  74. package/extensions/subagents/src/format.ts +48 -0
  75. package/extensions/subagents/src/manager.ts +769 -0
  76. package/extensions/subagents/src/prompt.ts +190 -0
  77. package/extensions/subagents/src/result-delivery.ts +20 -0
  78. package/extensions/subagents/src/runtime.ts +51 -0
  79. package/extensions/subagents/src/ui/takeover.ts +615 -0
  80. package/extensions/subagents/src/ui/transcript.ts +293 -0
  81. package/extensions/subagents/src/ui/wait-result.ts +89 -0
  82. package/extensions/suggestions/index.ts +172 -0
  83. package/extensions/suggestions/src/config.ts +12 -0
  84. package/extensions/suggestions/src/predictor.ts +147 -0
  85. package/extensions/suggestions/src/prompt.ts +20 -0
  86. package/extensions/suggestions/src/transcript.ts +233 -0
  87. package/extensions/suggestions/src/ui.ts +224 -0
  88. package/extensions/tasks/index.ts +512 -0
  89. package/extensions/tasks/tasks.ts +649 -0
  90. package/extensions/tasks/ui.ts +421 -0
  91. package/extensions/turn-time/index.ts +61 -0
  92. package/extensions/ui-customization/footer.ts +512 -0
  93. package/extensions/ui-customization/index.ts +217 -0
  94. package/extensions/workflows/acceptance.ts +298 -0
  95. package/extensions/workflows/artifacts.ts +225 -0
  96. package/extensions/workflows/controller.ts +210 -0
  97. package/extensions/workflows/dashboard.ts +1226 -0
  98. package/extensions/workflows/index.ts +1884 -0
  99. package/extensions/workflows/journal.ts +188 -0
  100. package/extensions/workflows/meta.ts +250 -0
  101. package/extensions/workflows/model.ts +423 -0
  102. package/extensions/workflows/navigation.ts +93 -0
  103. package/extensions/workflows/prompt.ts +212 -0
  104. package/extensions/workflows/replay-safety.ts +577 -0
  105. package/extensions/workflows/runner.ts +786 -0
  106. package/extensions/workflows/sandbox-child.cjs +402 -0
  107. package/extensions/workflows/sandbox.ts +397 -0
  108. package/extensions/workflows/serialization.ts +162 -0
  109. package/extensions/workflows/worktree-handoff.ts +216 -0
  110. package/package.json +87 -0
  111. package/scripts/prepare-effect-tsgo.mjs +16 -0
  112. package/skills/background-terminals/SKILL.md +30 -0
  113. package/skills/subagents/SKILL.md +15 -0
  114. package/themes/github-dark-default.json +89 -0
@@ -0,0 +1,397 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { spawn, type ChildProcess } from "node:child_process";
3
+ import * as path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { MAX_WORKFLOW_AGENT_CALLS } from "../shared/setup-config.ts";
6
+ import { safeStringify, toSerializable } from "./serialization.ts";
7
+
8
+ const MAX_SOURCE_BYTES = 512 * 1024;
9
+ const MAX_ARGS_BYTES = 256 * 1024;
10
+ const MAX_RESULT_BYTES = 1024 * 1024;
11
+ const MAX_AGENT_MESSAGE_BYTES = 512 * 1024;
12
+ /** A narrator line is one terminal row; the host bounds it again on arrival. */
13
+ const MAX_LOG_MESSAGE_BYTES = 8 * 1024;
14
+ /**
15
+ * The sandbox's hard agent-request cap sits this many calls ABOVE the
16
+ * controller's graceful budget, so the controller rejects the (budget+1)th
17
+ * agent() into the script (as { ok:false }) before the sandbox would fatally
18
+ * kill the run. Bounded so the backstop still stops a runaway child that
19
+ * bypasses the controller entirely.
20
+ */
21
+ export const AGENT_CALL_BACKSTOP_MARGIN = 8;
22
+
23
+ export interface SandboxAgentOptions {
24
+ agent_type?: unknown;
25
+ label?: unknown;
26
+ phase?: unknown;
27
+ schema?: unknown;
28
+ acceptance?: unknown;
29
+ model?: unknown;
30
+ provider?: unknown;
31
+ effort?: unknown;
32
+ isolation?: unknown;
33
+ }
34
+
35
+ export interface SandboxAgentResult {
36
+ ok: boolean;
37
+ output: string;
38
+ structured?: unknown;
39
+ error?: string;
40
+ }
41
+
42
+ export interface RunWorkflowSandboxOptions {
43
+ source: string;
44
+ args: unknown;
45
+ cwd: string;
46
+ signal: AbortSignal;
47
+ onAgent: (
48
+ prompt: string,
49
+ options: SandboxAgentOptions,
50
+ signal: AbortSignal,
51
+ ) => Promise<SandboxAgentResult>;
52
+ onPhase: (title: string) => void;
53
+ onLog: (text: string) => void;
54
+ /**
55
+ * Cumulative run usage, read at send time so the child's `usage()` reflects
56
+ * the agent that just settled rather than a value captured at launch.
57
+ */
58
+ usageSnapshot: () => unknown;
59
+ maxConcurrency: number;
60
+ /** Same budget the controller enforces; the sandbox is the outer guard. */
61
+ maxAgentCalls: number;
62
+ /**
63
+ * Replayable results available to this run. A replayed call costs no
64
+ * controller budget but still sends one agent IPC message, so without this
65
+ * the backstop fires long before the controller does and kills the child
66
+ * mid-run — losing the aggregate that resuming exists to preserve.
67
+ */
68
+ extraAgentRequests?: number;
69
+ }
70
+
71
+ function byteLength(value: string) {
72
+ return Buffer.byteLength(value, "utf8");
73
+ }
74
+
75
+ function isRecord(value: unknown): value is Record<string, unknown> {
76
+ return typeof value === "object" && value !== null && !Array.isArray(value);
77
+ }
78
+
79
+ function errorText(error: unknown) {
80
+ return error instanceof Error ? error.message : String(error);
81
+ }
82
+
83
+ function terminateChild(child: ChildProcess) {
84
+ if (child.exitCode !== null || child.signalCode !== null) return;
85
+ child.kill("SIGTERM");
86
+ const force = setTimeout(() => {
87
+ if (child.exitCode === null && child.signalCode === null)
88
+ child.kill("SIGKILL");
89
+ }, 1_000);
90
+ force.unref?.();
91
+ }
92
+
93
+ function sanitizeAgentOptions(value: unknown): SandboxAgentOptions {
94
+ if (!isRecord(value)) return {};
95
+ return {
96
+ ...(value.agent_type !== undefined ? { agent_type: value.agent_type } : {}),
97
+ ...(value.label !== undefined ? { label: value.label } : {}),
98
+ ...(value.phase !== undefined ? { phase: value.phase } : {}),
99
+ ...(value.schema !== undefined ? { schema: value.schema } : {}),
100
+ ...(value.acceptance !== undefined ? { acceptance: value.acceptance } : {}),
101
+ ...(value.model !== undefined ? { model: value.model } : {}),
102
+ ...(value.provider !== undefined ? { provider: value.provider } : {}),
103
+ ...(value.effort !== undefined ? { effort: value.effort } : {}),
104
+ ...(value.isolation !== undefined ? { isolation: value.isolation } : {}),
105
+ };
106
+ }
107
+
108
+ /**
109
+ * Execute orchestration code in a separate, permission-restricted Node process.
110
+ * The child can only invoke the narrow agent/phase IPC protocol and is always
111
+ * terminated on completion, cancellation, or protocol failure. The workflow
112
+ * itself and its agent requests have no wall-clock deadline. Active requests
113
+ * are aborted only when the workflow is cancelled or the sandbox is cleaned up.
114
+ */
115
+ export function runWorkflowSandbox(options: RunWorkflowSandboxOptions) {
116
+ if (!process.allowedNodeEnvironmentFlags.has("--permission")) {
117
+ return Promise.reject(
118
+ new Error("This Node runtime cannot enforce workflow child permissions"),
119
+ );
120
+ }
121
+ if (byteLength(options.source) > MAX_SOURCE_BYTES) {
122
+ return Promise.reject(
123
+ new Error(`Workflow script exceeds the ${MAX_SOURCE_BYTES} byte limit`),
124
+ );
125
+ }
126
+
127
+ // See AGENT_CALL_BACKSTOP_MARGIN: the sandbox hard cap sits above the
128
+ // controller's graceful budget so the controller rejects first and the
129
+ // script can still return its aggregate.
130
+ const controllerBudget = Math.max(
131
+ 1,
132
+ Math.min(MAX_WORKFLOW_AGENT_CALLS, Math.floor(options.maxAgentCalls)),
133
+ );
134
+ const maxAgentRequests =
135
+ controllerBudget +
136
+ AGENT_CALL_BACKSTOP_MARGIN +
137
+ Math.max(
138
+ 0,
139
+ Math.min(
140
+ MAX_WORKFLOW_AGENT_CALLS,
141
+ Math.floor(options.extraAgentRequests ?? 0),
142
+ ),
143
+ );
144
+
145
+ const argsJson = safeStringify(
146
+ { defined: options.args !== undefined, value: options.args },
147
+ { maxBytes: MAX_ARGS_BYTES, maxDepth: 16, maxNodes: 10_000 },
148
+ );
149
+ if (byteLength(argsJson) > MAX_ARGS_BYTES) {
150
+ return Promise.reject(new Error("Workflow args exceed the IPC limit"));
151
+ }
152
+
153
+ return new Promise<unknown>((resolve, reject) => {
154
+ const workerPath = fileURLToPath(
155
+ new URL("./sandbox-child.cjs", import.meta.url),
156
+ );
157
+ const child = spawn(
158
+ process.execPath,
159
+ [
160
+ "--permission",
161
+ `--allow-fs-read=${path.dirname(workerPath)}`,
162
+ "--max-old-space-size=128",
163
+ "--stack-size=2048",
164
+ workerPath,
165
+ ],
166
+ {
167
+ cwd: options.cwd,
168
+ env: {
169
+ PATH: process.env.PATH ?? "",
170
+ NODE_NO_WARNINGS: "1",
171
+ },
172
+ stdio: ["ignore", "ignore", "ignore", "ipc"],
173
+ },
174
+ );
175
+ const token = randomBytes(24).toString("hex");
176
+ const requestIds = new Set<number>();
177
+ const activeAgentRequests = new Map<number, AbortController>();
178
+ let requestCount = 0;
179
+ let finished = false;
180
+
181
+ // The child parses this and falls back to zeros if it is ever unusable, so
182
+ // a broken snapshot degrades `usage()` to a zero reading instead of
183
+ // failing the run.
184
+ const usageJson = () => {
185
+ try {
186
+ return JSON.stringify(options.usageSnapshot()) ?? "{}";
187
+ } catch {
188
+ return "{}";
189
+ }
190
+ };
191
+
192
+ const cleanup = () => {
193
+ for (const abortController of activeAgentRequests.values()) {
194
+ abortController.abort(new Error("Workflow stopped"));
195
+ }
196
+ activeAgentRequests.clear();
197
+ options.signal.removeEventListener("abort", onAbort);
198
+ child.removeAllListeners("message");
199
+ child.removeAllListeners("error");
200
+ child.removeAllListeners("exit");
201
+ terminateChild(child);
202
+ };
203
+ const finish = (error?: Error, value?: unknown) => {
204
+ if (finished) return;
205
+ finished = true;
206
+ cleanup();
207
+ if (error) reject(error);
208
+ else resolve(value);
209
+ };
210
+ const onAbort = () => finish(new Error("Workflow was aborted"));
211
+
212
+ options.signal.addEventListener("abort", onAbort, { once: true });
213
+ if (options.signal.aborted) {
214
+ onAbort();
215
+ return;
216
+ }
217
+
218
+ child.on("error", (error) => finish(error));
219
+ child.on("exit", (code, exitSignal) => {
220
+ if (!finished) {
221
+ finish(
222
+ new Error(
223
+ `Workflow sandbox exited before completion (${exitSignal ?? code ?? "unknown"})`,
224
+ ),
225
+ );
226
+ }
227
+ });
228
+ child.on("message", (raw: unknown) => {
229
+ if (
230
+ !isRecord(raw) ||
231
+ raw.token !== token ||
232
+ typeof raw.kind !== "string"
233
+ ) {
234
+ finish(new Error("Workflow sandbox sent an invalid IPC message"));
235
+ return;
236
+ }
237
+ if (raw.kind === "phase") {
238
+ // Same clip-not-kill rule as log below: a phase title is display text,
239
+ // and an oversized one must not cost the run its agent results.
240
+ if (
241
+ typeof raw.payloadJson !== "string" ||
242
+ byteLength(raw.payloadJson) > MAX_LOG_MESSAGE_BYTES
243
+ ) {
244
+ return;
245
+ }
246
+ try {
247
+ const payload: unknown = JSON.parse(raw.payloadJson);
248
+ if (!isRecord(payload) || typeof payload.title !== "string") {
249
+ throw new Error("invalid title");
250
+ }
251
+ options.onPhase(payload.title.slice(0, 160));
252
+ } catch {
253
+ return;
254
+ }
255
+ return;
256
+ }
257
+ if (raw.kind === "log") {
258
+ if (
259
+ typeof raw.payloadJson !== "string" ||
260
+ byteLength(raw.payloadJson) > MAX_LOG_MESSAGE_BYTES
261
+ ) {
262
+ // Dropped, never fatal. A narrator line is the least important thing
263
+ // in a run; killing the child over one discards every completed
264
+ // agent's output. The byte ceiling is protocol abuse protection, and
265
+ // it is generous precisely so that ordinary oversized narration
266
+ // (a JSON.stringify of 400 paths, 3000 emoji) is clipped by
267
+ // appendLog rather than losing the run.
268
+ return;
269
+ }
270
+ try {
271
+ const payload: unknown = JSON.parse(raw.payloadJson);
272
+ if (!isRecord(payload) || typeof payload.text !== "string") {
273
+ throw new Error("invalid text");
274
+ }
275
+ options.onLog(payload.text);
276
+ } catch {
277
+ finish(new Error("Workflow sandbox sent an invalid log line"));
278
+ }
279
+ return;
280
+ }
281
+ if (raw.kind === "agent") {
282
+ if (
283
+ typeof raw.payloadJson !== "string" ||
284
+ byteLength(raw.payloadJson) > MAX_AGENT_MESSAGE_BYTES
285
+ ) {
286
+ finish(new Error("Workflow sandbox sent an oversized agent request"));
287
+ return;
288
+ }
289
+ let payload: unknown;
290
+ try {
291
+ payload = JSON.parse(raw.payloadJson);
292
+ } catch {
293
+ finish(new Error("Workflow sandbox sent malformed agent JSON"));
294
+ return;
295
+ }
296
+ if (
297
+ !isRecord(payload) ||
298
+ !Number.isSafeInteger(payload.id) ||
299
+ typeof payload.id !== "number" ||
300
+ payload.id < 1 ||
301
+ typeof payload.prompt !== "string" ||
302
+ payload.prompt.length > 100_000 ||
303
+ !isRecord(payload.options)
304
+ ) {
305
+ finish(new Error("Workflow sandbox sent an invalid agent request"));
306
+ return;
307
+ }
308
+ if (requestIds.has(payload.id) || ++requestCount > maxAgentRequests) {
309
+ finish(
310
+ new Error("Workflow sandbox exceeded its agent request budget"),
311
+ );
312
+ return;
313
+ }
314
+ requestIds.add(payload.id);
315
+ const id = payload.id;
316
+ const abortController = new AbortController();
317
+ const sendResult = (result: SandboxAgentResult) => {
318
+ if (!activeAgentRequests.delete(id)) return;
319
+ if (finished || !child.connected) return;
320
+ const normalized = toSerializable(result, {
321
+ maxDepth: 16,
322
+ maxNodes: 10_000,
323
+ maxStringBytes: 128 * 1024,
324
+ });
325
+ let resultJson = JSON.stringify(normalized);
326
+ if (byteLength(resultJson) > MAX_AGENT_MESSAGE_BYTES) {
327
+ resultJson = JSON.stringify({
328
+ ok: false,
329
+ output: "",
330
+ error: "Agent result exceeded the workflow IPC output limit",
331
+ });
332
+ }
333
+ child.send({
334
+ token,
335
+ kind: "agentResult",
336
+ id,
337
+ resultJson,
338
+ usageJson: usageJson(),
339
+ });
340
+ };
341
+ activeAgentRequests.set(id, abortController);
342
+ let agentOperation: Promise<SandboxAgentResult>;
343
+ try {
344
+ agentOperation = options.onAgent(
345
+ payload.prompt,
346
+ sanitizeAgentOptions(payload.options),
347
+ abortController.signal,
348
+ );
349
+ } catch (error) {
350
+ sendResult({ ok: false, output: "", error: errorText(error) });
351
+ return;
352
+ }
353
+ void agentOperation.then(sendResult, (error) =>
354
+ sendResult({ ok: false, output: "", error: errorText(error) }),
355
+ );
356
+ return;
357
+ }
358
+ if (raw.kind === "result") {
359
+ if (
360
+ typeof raw.resultJson !== "string" ||
361
+ byteLength(raw.resultJson) > MAX_RESULT_BYTES
362
+ ) {
363
+ finish(new Error("Workflow result exceeded the IPC limit"));
364
+ return;
365
+ }
366
+ try {
367
+ const normalized = toSerializable(JSON.parse(raw.resultJson));
368
+ finish(undefined, JSON.parse(JSON.stringify(normalized)));
369
+ } catch (error) {
370
+ finish(
371
+ new Error(`Workflow returned invalid JSON: ${errorText(error)}`),
372
+ );
373
+ }
374
+ return;
375
+ }
376
+ if (raw.kind === "error" && typeof raw.error === "string") {
377
+ finish(new Error(raw.error.slice(0, 16 * 1024)));
378
+ return;
379
+ }
380
+ finish(new Error("Workflow sandbox sent an unknown IPC message"));
381
+ });
382
+
383
+ child.send(
384
+ {
385
+ kind: "init",
386
+ token,
387
+ source: options.source,
388
+ argsJson,
389
+ maxConcurrency: options.maxConcurrency,
390
+ usageJson: usageJson(),
391
+ },
392
+ (error) => {
393
+ if (error) finish(error);
394
+ },
395
+ );
396
+ });
397
+ }
@@ -0,0 +1,162 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+
4
+ export interface SerializationOptions {
5
+ maxBytes?: number;
6
+ maxDepth?: number;
7
+ maxNodes?: number;
8
+ maxStringBytes?: number;
9
+ }
10
+
11
+ const DEFAULT_MAX_BYTES = 1024 * 1024;
12
+ const DEFAULT_MAX_DEPTH = 16;
13
+ const DEFAULT_MAX_NODES = 20_000;
14
+ const DEFAULT_MAX_STRING_BYTES = 64 * 1024;
15
+
16
+ function byteLength(value: string) {
17
+ return Buffer.byteLength(value, "utf8");
18
+ }
19
+
20
+ export function truncateUtf8(value: string, maxBytes: number) {
21
+ if (maxBytes <= 0) return "";
22
+ if (byteLength(value) <= maxBytes) return value;
23
+ const buffer = Buffer.from(value, "utf8");
24
+ let end = Math.min(maxBytes, buffer.length);
25
+ while (end > 0 && (buffer[end] & 0xc0) === 0x80) end--;
26
+ return buffer.subarray(0, end).toString("utf8");
27
+ }
28
+
29
+ /**
30
+ * Normalize arbitrary values to inert JSON data. Cycles, bigint, non-finite
31
+ * numbers, deep trees, throwing properties, and very large strings are all
32
+ * represented explicitly instead of making artifact persistence fail.
33
+ */
34
+ export function toSerializable(
35
+ value: unknown,
36
+ options: SerializationOptions = {},
37
+ ): unknown {
38
+ const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
39
+ const maxNodes = options.maxNodes ?? DEFAULT_MAX_NODES;
40
+ const maxStringBytes = options.maxStringBytes ?? DEFAULT_MAX_STRING_BYTES;
41
+ const seen = new WeakMap<object, string>();
42
+ let nodes = 0;
43
+
44
+ const visit = (
45
+ current: unknown,
46
+ depth: number,
47
+ location: string,
48
+ ): unknown => {
49
+ nodes++;
50
+ if (nodes > maxNodes) return "[truncated: node limit]";
51
+ if (depth > maxDepth) return "[truncated: depth limit]";
52
+ if (current === null || typeof current === "boolean") return current;
53
+ if (typeof current === "string") {
54
+ if (byteLength(current) <= maxStringBytes) return current;
55
+ return `${truncateUtf8(current, maxStringBytes)}\n[truncated: string limit]`;
56
+ }
57
+ if (typeof current === "number") {
58
+ return Number.isFinite(current)
59
+ ? current
60
+ : `[number: ${String(current)}]`;
61
+ }
62
+ if (typeof current === "bigint") return `${current.toString()}n`;
63
+ if (typeof current === "undefined") return "[undefined]";
64
+ if (typeof current === "symbol")
65
+ return `[symbol: ${current.description ?? ""}]`;
66
+ if (typeof current === "function")
67
+ return `[function: ${current.name || "anonymous"}]`;
68
+ if (typeof current !== "object") return String(current);
69
+
70
+ const prior = seen.get(current);
71
+ if (prior) return `[circular: ${prior}]`;
72
+ seen.set(current, location);
73
+
74
+ if (Array.isArray(current)) {
75
+ return current.map((item, index) =>
76
+ visit(item, depth + 1, `${location}[${index}]`),
77
+ );
78
+ }
79
+
80
+ if (current instanceof Date) {
81
+ return Number.isNaN(current.getTime())
82
+ ? "[date: invalid]"
83
+ : current.toISOString();
84
+ }
85
+ if (current instanceof Error) {
86
+ return {
87
+ name: current.name,
88
+ message: current.message,
89
+ ...(current.stack
90
+ ? { stack: truncateUtf8(current.stack, 16 * 1024) }
91
+ : {}),
92
+ };
93
+ }
94
+
95
+ const result: Record<string, unknown> = Object.create(null);
96
+ let keys: string[];
97
+ try {
98
+ keys = Object.keys(current);
99
+ } catch (error) {
100
+ return `[unreadable object: ${error instanceof Error ? error.message : String(error)}]`;
101
+ }
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
+ return result;
115
+ };
116
+
117
+ return visit(value, 0, "$root");
118
+ }
119
+
120
+ /** Serialize to valid JSON no larger than the requested cap. */
121
+ export function safeStringify(
122
+ value: unknown,
123
+ options: SerializationOptions = {},
124
+ ) {
125
+ const maxBytes = Math.max(256, options.maxBytes ?? DEFAULT_MAX_BYTES);
126
+ const normalized = toSerializable(value, options);
127
+ const serialized = JSON.stringify(normalized, null, 2) ?? "null";
128
+ if (byteLength(serialized) <= maxBytes) return serialized;
129
+
130
+ let previewBytes = Math.max(32, Math.floor(maxBytes / 3));
131
+ while (previewBytes > 0) {
132
+ const fallback = JSON.stringify(
133
+ {
134
+ truncated: true,
135
+ reason: `serialized value exceeded ${maxBytes} bytes`,
136
+ preview: truncateUtf8(serialized, previewBytes),
137
+ },
138
+ null,
139
+ 2,
140
+ );
141
+ if (byteLength(fallback) <= maxBytes) return fallback;
142
+ previewBytes = Math.floor(previewBytes / 2);
143
+ }
144
+ return JSON.stringify({ truncated: true });
145
+ }
146
+
147
+ /** Durable same-directory replace: readers see either the old or new file. */
148
+ export function writeFileAtomic(filePath: string, content: string) {
149
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
150
+ const temporary = `${filePath}.${process.pid}.${Date.now()}.tmp`;
151
+ try {
152
+ fs.writeFileSync(temporary, content, { encoding: "utf8", mode: 0o600 });
153
+ fs.renameSync(temporary, filePath);
154
+ } catch (error) {
155
+ try {
156
+ fs.unlinkSync(temporary);
157
+ } catch {
158
+ // The original write error is more useful.
159
+ }
160
+ throw error;
161
+ }
162
+ }