@mono-agent/agent-runtime 0.11.5 → 0.12.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.
package/ARCHITECTURE.md CHANGED
@@ -40,7 +40,7 @@ flowchart TB
40
40
  Registry --> PiSDK["Pi SDK bridge<br/>OpenAI, Codex, Gemini, OpenRouter,<br/>Ollama, custom providers"]
41
41
  Registry --> CodexApp["Codex app-server CLI bridge"]
42
42
 
43
- AgentKernel --> Builtins["Read / Write / Edit / Glob / Grep / Bash<br/>WebFetch / WebSearch"]
43
+ AgentKernel --> Builtins["Read / Write / Edit / Glob / Grep / Bash<br/>NodeRepl / WebFetch / WebSearch"]
44
44
  AgentKernel --> MCP["MCP stdio / SSE / HTTP tools"]
45
45
  AgentKernel --> Sandbox["Sandbox policy<br/>path/network checks + stdio command wrapping"]
46
46
  AgentKernel --> Artifacts["Tool-output bloat guard<br/>host artifact persistence"]
@@ -180,8 +180,9 @@ Key responsibilities by subsystem:
180
180
  Real hosts inject `@mono-agent/runtime-adapter`'s sandbox implementation.
181
181
  - `agent/compaction.js`: pure helpers consumed by the pi bridge —
182
182
  `resolveAgentCompactionPolicy` (derives the context-window compaction trigger +
183
- tool-output payload limits from `agent_compaction_*` settings and the running
184
- model), `estimateFixedOverheadTokens` (the proactive fixed-overhead correction:
183
+ adaptive budgets and tool-output payload limits from the typed compaction
184
+ policy and running model; deprecated `agent_compaction_*` settings remain a
185
+ compatibility input), `estimateFixedOverheadTokens` (the proactive fixed-overhead correction:
185
186
  system prompt + tool schemas + per-turn message), `isLikelyContextTermination`
186
187
  (classifies a context-pressure error), and the typed-policy/`settings`-bag shim
187
188
  helpers (`resolveRuntimePolicyInputs`, `deprecatedSettingsWarning`) that let a
@@ -255,12 +256,12 @@ provider exposes queue-after-turn), not durability/cost:
255
256
  The pi runtime is built on pi-agent-core's native `AgentHarness` (the hand-rolled
256
257
  bridge was removed once native reached parity); it owns the session and
257
258
  pi-ai-managed retry. `AgentHarness` itself has **no** automatic compaction, so
258
- the pi bridge drives it directly: before each turn it estimates the running
259
- model's context usage and calls `AgentHarness.compact()` when near the window
260
- (proactive), and if a turn still overflows it compacts once and re-prompts
261
- (reactive recovery). Runs report `context_compaction_applied` as `true` (a
259
+ the pi bridge drives it through a one-shot `session_before_compact` hook: before
260
+ each turn it compares the full request estimate with an adaptive trigger, and if
261
+ a turn still overflows it retries exactly once only after a preview verifies a
262
+ positive reduction. Runs report `context_compaction_applied` as `true` (a
262
263
  compaction fired), `false` (enabled but not needed), or `null` (disabled via
263
- `agent_compaction_enabled: false`).
264
+ `runtime.compaction.enabled: false`).
264
265
 
265
266
  | Provider | Warm session | Resume across turns | Survives process restart |
266
267
  |---|---|---|---|
package/MIGRATION.md CHANGED
@@ -54,14 +54,16 @@ These were Pi-bridge knobs the native path does not consume.
54
54
  overflows the bridge compacts once and re-prompts (reactive recovery).
55
55
  - Runs report **`capabilitiesUsed.context_compaction_applied`** as `true` (a
56
56
  compaction fired), `false` (enabled but not needed), or `null` (disabled via
57
- `agent_compaction_enabled: false`). If you assert on this value, expect this
57
+ `runtime.compaction.enabled: false`). If you assert on this value, expect this
58
58
  tristate on the Pi path.
59
59
  - The host **`onCompactionRecorded`** callback now **fires on each automatic
60
60
  compaction** on the Pi path (previously inert).
61
- - The trigger window auto-tracks the model actually serving the request
62
- (`harness.getModel()`) and self-corrects from any real ceiling stated in an
63
- overflow error, so a wrong/default `context_window` no longer defeats it.
64
- `resolveAgentCompactionPolicy` (`agent_compaction_*` settings) remains exported.
61
+ - The trigger and omitted budgets adapt to the model actually serving the
62
+ request (`harness.getModel()`). Numeric overflow limits and generic failed
63
+ request estimates lower a learned process-local ceiling; use
64
+ `runtime.compaction.contextWindowOverride` for a persistent metadata
65
+ correction. Deprecated programmatic `agent_compaction_*` settings and
66
+ `resolveAgentCompactionPolicy` remain compatibility surfaces.
65
67
 
66
68
  ## 4. Durable Pi session resume: create-on-miss semantics
67
69
 
@@ -160,6 +162,9 @@ pass it, so this is a no-op there).
160
162
  `resolveAgentCompactionPolicy(settings, model)` stays exported (the canonical
161
163
  clamp/mapper both paths route through), and `@mono-agent/runtime-adapter` exposes
162
164
  `resolveRuntimePolicies(settings)` to map a legacy bag to the typed objects.
165
+ The migration helper preserves omitted legacy compaction values so adaptive
166
+ defaults are resolved later against the live model rather than frozen at the
167
+ mapper's fallback window.
163
168
  **Action:** migrate `settings` → `toolLimits` / `compaction`; until then the shim
164
169
  keeps working with one deprecation warning per run.
165
170
 
package/README.md CHANGED
@@ -275,6 +275,7 @@ PROVIDER_ABORT_RE
275
275
  RetryableProviderFailureInfo
276
276
  classifyFailure
277
277
  createStderrTail
278
+ isContextLimitFailureText
278
279
  isProviderAuthFailureText
279
280
  retryableProviderFailureInfo
280
281
  ```
@@ -478,7 +479,7 @@ console.log(result.text);
478
479
  `@mono-agent/agent-runtime` is purpose-built for **autonomous, long-running agent work** with provider portability and operational resilience as first-class concerns. It is *not* a streaming-chat UI kit. Where each peer fits:
479
480
 
480
481
  - **Vercel AI SDK** — best when you're building a chat / generative-UI experience inside a React or Next.js app. `useChat`, `useCompletion`, streaming server components, and edge-runtime compatibility are their strengths. Their provider list is curated (Anthropic, OpenAI, Google, etc., via `@ai-sdk/*` packages); there's no Pi gateway, no Claude Code CLI, no Codex CLI app-server, and no per-call provider fallback. If you're rendering a streaming chat into a browser, use them. If you're orchestrating multi-turn autonomous work that must survive a rate-limited primary provider, use us.
481
- - **Claude Agent SDK** (`@anthropic-ai/claude-agent-sdk`) — first-party Anthropic SDK. Tight integration with Claude features (canUseTool, sub-agents, hooks, MCP). We *wrap* it as one of our five bridges and add transcript-resume across provider drops, a 22-kind failure taxonomy, a tool-bloat guard with artifact persistence, and a provider fallback router. Context/window handling stays with the provider the runtime does not run its own in-loop summarization pass. Reach for the bare Anthropic SDK when you only ever talk to Claude and don't need cross-provider portability or resume.
482
+ - **Claude Agent SDK** (`@anthropic-ai/claude-agent-sdk`) — first-party Anthropic SDK. Tight integration with Claude features (canUseTool, sub-agents, hooks, MCP). We *wrap* it as one of our five bridges and add transcript-resume across provider drops, a structured failure taxonomy, a tool-bloat guard with artifact persistence, and a provider fallback router. Context/window handling remains bridge-specific; the pi-native bridge drives its own compaction recovery. Reach for the bare Anthropic SDK when you only ever talk to Claude and don't need cross-provider portability or resume.
482
483
  - **Mastra** — a workflow engine + memory + RAG stack. Different category: it's the layer *above* a runtime. You can layer Mastra workflows on top of `@mono-agent/agent-runtime` if you want both.
483
484
  - **OpenAI Agents SDK** — first-party OpenAI SDK. Same trade-off as the Claude Agent SDK: tight integration with OpenAI, no other providers. Pi providers in our runtime cover OpenAI plus a dozen others through a single API.
484
485
  - **LangChain.js** — kitchen sink with deep abstraction stacks. We're deliberately lean; if you want chains, agents, vector stores, and parsers under one umbrella, LangChain is built for that. If you want a focused runtime kernel, use us.
@@ -674,12 +675,14 @@ Returns:
674
675
 
675
676
  ## Built-in tools
676
677
 
677
- The agent kernel ships with: `Read`, `Write`, `Edit`, `Glob`, `Grep`, `Bash`, `WebFetch`, `WebSearch`. You select via `allowedTools`. Tool implementations honor:
678
+ The agent kernel's managed tools are `Read`, `Write`, `Edit`, `Glob`, `Grep`, `Bash`, `NodeRepl`, `WebFetch`, and `WebSearch`. `NodeRepl({ code })` is backed by one lazily started Node.js REPL child per run. You select them via `allowedTools`. Tool implementations honor:
678
679
 
679
680
  - `cwd` (required for path-based tools)
680
681
  - The runtime context's `workspace` / `repoRoot` allow-list (paths outside both, plus `/tmp` and `process.cwd()`, are rejected)
681
682
  - Output truncation with optional artifact persistence (`{toolArtifactDir}/tool-output/{runId}/...` when `toolArtifactDir` is configured)
682
683
 
684
+ `NodeRepl` uses Node's default `node:repl` evaluator, so variables, `_`, `_error`, and loaded modules persist across calls in the same run. It supports multiline input and top-level `await`, resolves workspace-installed packages, and is closed with the run. Its child is prepared through the same sandbox seam as `Bash`; abort, the fixed 120-second timeout, child exit, or hard output overflow resets the session. It deliberately has no session ids, persistent history, terminal commands, or package-install surface.
685
+
683
686
  Override or extend the tool surface by passing `mcpServers` for MCP-backed tools.
684
687
 
685
688
  ## Structured output
@@ -713,6 +716,7 @@ Behaviour:
713
716
 
714
717
  - Successful run on entry N → returns the result with `failoverHistory` set to attempts 0..N-1.
715
718
  - Retryable provider failure → emits `provider_failover_started`, builds a transcript snapshot, and retries on the next entry.
719
+ - Context-window failure after bridge compaction recovery → preserves `failureKind: "context_limit"` in `failoverHistory` and tries the next entry; quota/output/max-turn `usage_limit` remains terminal.
716
720
  - Provider auth failure → retries the next chain entry and preserves `failureKind: "provider_auth"` in `failoverHistory` for the failed attempt.
717
721
  - Malformed request/config/billing-type non-retryable failure → returns immediately with `failoverHistory` containing the one attempt.
718
722
  - Cancellation → returns immediately.
@@ -823,17 +827,34 @@ Hosts that don't supply `persistArtifact` get the truncation summary but no on-d
823
827
  The sole pi bridge runs on pi-agent-core's native `AgentHarness`. pi performs **no**
824
828
  automatic in-loop compaction, so the bridge drives it: before each turn it estimates the
825
829
  running model's context usage and calls `AgentHarness.compact()` when near the window
826
- (proactive), and if a turn still overflows it compacts once and re-prompts (reactive
827
- recovery). The context window auto-tracks the model actually serving the request
828
- (`harness.getModel()`), self-correcting from any real ceiling stated in an overflow error.
830
+ (proactive), and if a turn still overflows it compacts once and re-prompts exactly once
831
+ only after a rebuilt-context preview proves positive reduction (reactive recovery).
832
+ The bridge installs a one-shot `session_before_compact` hook around the public harness
833
+ operation, using Pi's public `prepareCompaction()` and `compact()` primitives so the
834
+ harness still owns phase changes, persistence, and events. Non-reducing previews and
835
+ proactive savings below policy are cancelled before persistence.
836
+
837
+ The context window auto-tracks the model actually serving the request
838
+ (`harness.getModel()`). Numeric provider limits become learned ceilings; generic
839
+ overflow temporarily lowers the process-local ceiling to 90% of the failed request
840
+ estimate. A configured `contextWindowOverride` provides a persistent metadata
841
+ correction, while learned evidence may still lower it.
829
842
  Runs report `context_compaction_applied: true` (fired), `false` (enabled but not needed),
830
- or `null` (disabled). The other backends manage their windows per their own behavior.
843
+ or `null` (disabled), plus request-estimate, fixed-overhead, reactive-attempted,
844
+ tokens-after, and reduced diagnostics.
845
+ Persistent overflow is classified as `context_limit`, allowing the fallback router to
846
+ try the next configured model. The other backends manage their windows per their own behavior.
831
847
  (`docs/reference/feature-registry.md` is the source of truth for this row.)
832
848
 
833
- `resolveAgentCompactionPolicy(...)` (the `agent_compaction_*` settings: trigger ratio,
834
- keep-recent, enable/disable) and the `onCompactionRecorded(record)` host callback (fired
835
- on each automatic compaction) are exported from
836
- `@mono-agent/agent-runtime/agent/compaction.js` and consumed by the pi bridge.
849
+ Hosts pass the typed `RuntimeCompactionPolicy` through `runOptions.compaction`; the
850
+ deprecated programmatic `agent_compaction_*` settings remain a compatibility fallback.
851
+ The config-first host exposes this as `runtime.compaction.*` plus matching
852
+ `MONO_AGENT_COMPACTION_*` variables. Omitted values resolve against effective window
853
+ `W`: trigger ratio `0.70`; safety headroom `clamp(floor(W × 0.25), 16000, 96000)`;
854
+ retained context and minimum proactive savings `clamp(floor(W × 0.10), 4000, 20000)`;
855
+ summary output `clamp(floor(W × 0.04), 2000, 12000)`. Explicit values retain their
856
+ scalar validation bounds. `onCompactionRecorded(record)` fires only for accepted,
857
+ persisted automatic compactions.
837
858
 
838
859
  ## Advanced exports
839
860
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mono-agent/agent-runtime",
3
- "version": "0.11.5",
3
+ "version": "0.12.0",
4
4
  "description": "Agent runtime supporting Claude SDK/CLI, Codex, OpenCode, and Pi SDK bridges out of the box",
5
5
  "type": "module",
6
6
  "license": "GPL-3.0-only",
@@ -35,10 +35,7 @@
35
35
  */
36
36
 
37
37
  const DEFAULT_CONTEXT_WINDOW = 128000;
38
- const DEFAULT_TRIGGER_RATIO = 0.85;
39
- const DEFAULT_KEEP_RECENT_TOKENS = 24000;
40
- const DEFAULT_SUMMARY_MAX_TOKENS = 16000;
41
- const DEFAULT_MIN_SAVINGS_TOKENS = 20000;
38
+ const DEFAULT_TRIGGER_RATIO = 0.70;
42
39
  const DEFAULT_TOOL_PAYLOAD_COMPACTION_TRIGGER_CHARS = 0;
43
40
  const DEFAULT_TOOL_PRUNE_TRIGGER_TOKENS = 40000;
44
41
  // intelligence-ramp Phase 3: lifted from 16K/20K/12K. Mid-task tool reads
@@ -100,22 +97,43 @@ export function resolveAgentCompactionPolicy(settings = {}, model = {}) {
100
97
  0.2,
101
98
  0.95,
102
99
  );
103
- const reserveTokens = Math.max(16000, Math.min(64000, Math.floor(contextWindow * 0.25)));
104
- const ratioTrigger = Math.floor(contextWindow * triggerRatio);
105
- const reserveTrigger = Math.max(1, contextWindow - reserveTokens);
100
+ const safetyHeadroom = clampInteger(contextWindow * 0.25, 16000, 16000, 96000);
101
+ // Add a tiny scale-aware epsilon before flooring so decimal ratios such as
102
+ // 0.70 do not lose a token to IEEE-754 representation (372000 * 0.70 is
103
+ // otherwise 260399.99999999997 in JavaScript).
104
+ const ratioTrigger = Math.floor((contextWindow * triggerRatio) + (Number.EPSILON * contextWindow));
105
+ const reserveTrigger = Math.max(1, contextWindow - safetyHeadroom);
106
+ const adaptiveKeepRecentTokens = clampInteger(contextWindow * 0.10, 4000, 4000, 20000);
107
+ const adaptiveSummaryMaxTokens = clampInteger(contextWindow * 0.04, 2000, 2000, 12000);
108
+ const adaptiveMinSavingsTokens = clampInteger(contextWindow * 0.10, 4000, 4000, 20000);
106
109
  return {
107
110
  enabled: settings.agent_compaction_enabled !== false,
108
111
  contextWindow,
109
112
  triggerRatio,
110
113
  triggerTokens: Math.min(ratioTrigger, reserveTrigger),
111
- keepRecentTokens: clampInteger(settings.agent_compaction_keep_recent_tokens, DEFAULT_KEEP_RECENT_TOKENS, 4000, 200000),
112
- summaryMaxTokens: clampInteger(settings.agent_compaction_summary_max_tokens, DEFAULT_SUMMARY_MAX_TOKENS, 1000, 64000),
114
+ keepRecentTokens: clampInteger(
115
+ settings.agent_compaction_keep_recent_tokens,
116
+ adaptiveKeepRecentTokens,
117
+ 4000,
118
+ 200000,
119
+ ),
120
+ summaryMaxTokens: clampInteger(
121
+ settings.agent_compaction_summary_max_tokens,
122
+ adaptiveSummaryMaxTokens,
123
+ 1000,
124
+ 64000,
125
+ ),
113
126
  // ON by default; the proactive fixed-overhead correction (system prompt +
114
127
  // tool schemas + per-turn message) is disabled only when explicitly false.
115
128
  // Read by the compaction driver off the resolved policy so it never has to
116
129
  // re-sniff the raw settings/policy inputs.
117
130
  fixedOverheadEnabled: settings.agent_compaction_fixed_overhead_enabled !== false,
118
- compactionMinSavingsTokens: clampInteger(settings.agent_compaction_min_savings_tokens, DEFAULT_MIN_SAVINGS_TOKENS, 0, 500000),
131
+ compactionMinSavingsTokens: clampInteger(
132
+ settings.agent_compaction_min_savings_tokens,
133
+ adaptiveMinSavingsTokens,
134
+ 0,
135
+ 500000,
136
+ ),
119
137
  toolPayloadCompactionTriggerChars: clampInteger(
120
138
  settings.agent_tool_payload_compaction_trigger_chars,
121
139
  DEFAULT_TOOL_PAYLOAD_COMPACTION_TRIGGER_CHARS,
@@ -0,0 +1,406 @@
1
+ // @ts-check
2
+
3
+ import { spawn } from "node:child_process";
4
+ import { resolve } from "node:path";
5
+ import { passthroughSandbox } from "../sandbox-seam.js";
6
+ import { capChars } from "./shared/output-truncation.js";
7
+ import { readToolRuntime } from "./shared/runtime-context.js";
8
+ import { resolveSandboxPolicy } from "./shared/tool-context.js";
9
+
10
+ const DEFAULT_NODE_REPL_TIMEOUT_MS = 120_000;
11
+ const NODE_REPL_MAX_BUFFER_BYTES = 8 * 1024 * 1024;
12
+ const KILL_GRACE_MS = 1_000;
13
+
14
+ // Kept self-contained so the sandboxed child can start from `node --eval`
15
+ // without needing read access to agent-runtime's installed package directory.
16
+ function nodeReplWorkerMain() {
17
+ const repl = require("node:repl");
18
+ const { PassThrough } = require("node:stream");
19
+ const MAX_BUFFER_BYTES = 8 * 1024 * 1024;
20
+ const input = new PassThrough();
21
+ const output = new PassThrough();
22
+ const server = repl.start({
23
+ input,
24
+ output,
25
+ prompt: "",
26
+ terminal: false,
27
+ useGlobal: false,
28
+ });
29
+ const replServer = /** @type {any} */ (server);
30
+ let active = null;
31
+
32
+ function send(message) {
33
+ try {
34
+ process.send?.(message);
35
+ } catch {
36
+ process.exit(1);
37
+ }
38
+ }
39
+
40
+ function errorText(error) {
41
+ const cause = error?.err ?? error;
42
+ return String(cause?.stack || cause?.message || cause || "Node REPL evaluation failed.");
43
+ }
44
+
45
+ function finish(ok, text, reset = false) {
46
+ const request = active;
47
+ if (!request) return;
48
+ active = null;
49
+ const value = String(text || "");
50
+ const responseBytes = Buffer.byteLength(value, "utf8")
51
+ + Buffer.byteLength(request.stdout, "utf8")
52
+ + Buffer.byteLength(request.stderr, "utf8");
53
+ if (responseBytes > MAX_BUFFER_BYTES) {
54
+ send({
55
+ type: "result",
56
+ id: request.id,
57
+ ok: false,
58
+ reset: true,
59
+ text: `Node REPL output exceeded ${MAX_BUFFER_BYTES} bytes.`,
60
+ stdout: request.stdout,
61
+ stderr: request.stderr,
62
+ });
63
+ setImmediate(() => process.exit(1));
64
+ return;
65
+ }
66
+ send({
67
+ type: "result",
68
+ id: request.id,
69
+ ok,
70
+ reset,
71
+ text: value,
72
+ stdout: request.stdout,
73
+ stderr: request.stderr,
74
+ });
75
+ }
76
+
77
+ function captureProcessWrite(field, originalWrite) {
78
+ return function capturedWrite(chunk, encoding, callback) {
79
+ if (!active) return originalWrite(chunk, encoding, callback);
80
+ const resolvedEncoding = typeof encoding === "string" ? encoding : "utf8";
81
+ const resolvedCallback = typeof encoding === "function" ? encoding : callback;
82
+ const buffer = typeof chunk === "string"
83
+ ? Buffer.from(chunk, /** @type {any} */ (resolvedEncoding))
84
+ : Buffer.from(/** @type {any} */ (chunk));
85
+ active.bytes += buffer.length;
86
+ if (active.bytes > MAX_BUFFER_BYTES) {
87
+ finish(false, `Node REPL output exceeded ${MAX_BUFFER_BYTES} bytes.`, true);
88
+ setImmediate(() => process.exit(1));
89
+ return false;
90
+ }
91
+ active[field] += buffer.toString("utf8");
92
+ if (typeof resolvedCallback === "function") queueMicrotask(resolvedCallback);
93
+ return true;
94
+ };
95
+ }
96
+
97
+ process.stdout.write = captureProcessWrite("stdout", process.stdout.write.bind(process.stdout));
98
+ process.stderr.write = captureProcessWrite("stderr", process.stderr.write.bind(process.stderr));
99
+
100
+ output.on("data", (chunk) => {
101
+ if (!active) return;
102
+ active.bytes += chunk.length;
103
+ if (active.bytes > MAX_BUFFER_BYTES) {
104
+ finish(false, `Node REPL output exceeded ${MAX_BUFFER_BYTES} bytes.`, true);
105
+ setImmediate(() => process.exit(1));
106
+ return;
107
+ }
108
+ active.output += chunk.toString("utf8");
109
+ });
110
+
111
+ // Runtime exceptions from the default evaluator are printed through the
112
+ // REPL output stream and completed by displayPrompt(), rather than passed to
113
+ // eval's callback. Intercept that public completion point while retaining the
114
+ // default evaluator's `_error` behavior.
115
+ replServer.displayPrompt = () => {
116
+ if (!active) return;
117
+ finish(false, active.output.trimEnd() || "Node REPL evaluation failed.");
118
+ };
119
+
120
+ process.on("message", (message) => {
121
+ const requestMessage = /** @type {any} */ (message);
122
+ if (!requestMessage || requestMessage.type !== "evaluate") return;
123
+ if (active) {
124
+ send({ type: "result", id: requestMessage.id, ok: false, text: "Node REPL is already evaluating code." });
125
+ return;
126
+ }
127
+ if (typeof requestMessage.code !== "string" || requestMessage.code.trim().length === 0) {
128
+ send({ type: "result", id: requestMessage.id, ok: false, text: "Node REPL code must not be empty." });
129
+ return;
130
+ }
131
+ active = { id: requestMessage.id, output: "", stdout: "", stderr: "", bytes: 0 };
132
+ try {
133
+ replServer.eval(requestMessage.code, replServer.context, "<mono-agent-node-repl>", (error, value) => {
134
+ if (!active || active.id !== requestMessage.id) return;
135
+ if (error) {
136
+ if (!replServer.underscoreErrAssigned) replServer.lastError = error;
137
+ finish(false, [active.output.trimEnd(), errorText(error)].filter(Boolean).join("\n"));
138
+ return;
139
+ }
140
+ if (!replServer.underscoreAssigned) replServer.last = value;
141
+ let rendered;
142
+ try {
143
+ rendered = replServer.writer(value);
144
+ } catch (writerError) {
145
+ finish(false, [active.output.trimEnd(), errorText(writerError)].filter(Boolean).join("\n"));
146
+ return;
147
+ }
148
+ finish(true, `${active.output}${rendered}`.trimEnd());
149
+ });
150
+ } catch (error) {
151
+ finish(false, [active.output.trimEnd(), errorText(error)].filter(Boolean).join("\n"));
152
+ }
153
+ });
154
+
155
+ process.on("disconnect", () => {
156
+ server.close();
157
+ process.exit(0);
158
+ });
159
+ }
160
+
161
+ const NODE_REPL_WORKER_SOURCE = `(${nodeReplWorkerMain.toString()})();`;
162
+
163
+ function killProcessGroup(child, signal) {
164
+ if (!child?.pid) return;
165
+ try {
166
+ process.kill(process.platform === "win32" ? child.pid : -child.pid, signal);
167
+ } catch {
168
+ try { process.kill(child.pid, signal); } catch { /* already gone */ }
169
+ }
170
+ }
171
+
172
+ function errorMessage(error) {
173
+ return error instanceof Error ? error.message : String(error);
174
+ }
175
+
176
+ function appendChunk(record, target, chunk) {
177
+ record.directOutputBytes += chunk.length;
178
+ if (record.directOutputBytes > NODE_REPL_MAX_BUFFER_BYTES) {
179
+ record.failureReason = `Node REPL output exceeded ${NODE_REPL_MAX_BUFFER_BYTES} bytes.`;
180
+ void terminateRecord(record);
181
+ return;
182
+ }
183
+ target.push(chunk);
184
+ }
185
+
186
+ function directOutput(record, capturedStdout = "", capturedStderr = "") {
187
+ const sections = [];
188
+ const stdout = `${capturedStdout}${Buffer.concat(record.stdout).toString("utf8")}`.trimEnd();
189
+ const stderr = `${capturedStderr}${Buffer.concat(record.stderr).toString("utf8")}`.trimEnd();
190
+ if (stdout) sections.push(`STDOUT:\n${stdout}`);
191
+ if (stderr) sections.push(`STDERR:\n${stderr}`);
192
+ return sections.join("\n");
193
+ }
194
+
195
+ function clearRequestOutput(record) {
196
+ record.stdout = [];
197
+ record.stderr = [];
198
+ record.directOutputBytes = 0;
199
+ record.failureReason = null;
200
+ }
201
+
202
+ function resultText(record, text, stdout, stderr) {
203
+ return [directOutput(record, stdout, stderr), String(text || "").trimEnd()].filter(Boolean).join("\n");
204
+ }
205
+
206
+ async function cleanupPrepared(record) {
207
+ if (record.cleaned) return;
208
+ record.cleaned = true;
209
+ try { await record.prepared.cleanup?.(); } catch { /* best-effort teardown */ }
210
+ }
211
+
212
+ async function terminateRecord(record) {
213
+ if (record.closed) {
214
+ await record.done;
215
+ return;
216
+ }
217
+ killProcessGroup(record.child, "SIGTERM");
218
+ if (!record.killTimer) {
219
+ record.killTimer = setTimeout(() => killProcessGroup(record.child, "SIGKILL"), KILL_GRACE_MS);
220
+ record.killTimer.unref?.();
221
+ }
222
+ await record.done;
223
+ }
224
+
225
+ /**
226
+ * One lazy Node REPL process owned by a single Pi run.
227
+ * @param {{cwd?: string, maxOutputChars?: number, sandboxPolicy?: any, sandboxEngine?: any, ctx?: any}} [options]
228
+ */
229
+ export function createNodeReplController({
230
+ cwd,
231
+ maxOutputChars,
232
+ sandboxPolicy,
233
+ sandboxEngine,
234
+ ctx,
235
+ } = {}) {
236
+ const resolvedCtx = ctx ?? readToolRuntime();
237
+ const sandbox = resolvedCtx.sandbox ?? passthroughSandbox;
238
+ const policy = resolveSandboxPolicy(resolvedCtx, sandboxPolicy);
239
+ const workdir = resolve(cwd || resolvedCtx.workspace || process.cwd());
240
+ let current = null;
241
+ let starting = null;
242
+ let permanentlyClosed = false;
243
+ let nextRequestId = 0;
244
+
245
+ async function startChild() {
246
+ const prepared = await sandbox.prepareCommand({
247
+ policy,
248
+ engine: sandboxEngine ?? resolvedCtx.sandboxEngine ?? undefined,
249
+ command: {
250
+ command: process.execPath,
251
+ args: ["--eval", NODE_REPL_WORKER_SOURCE],
252
+ cwd: workdir,
253
+ },
254
+ });
255
+ if (permanentlyClosed) {
256
+ await prepared.cleanup?.();
257
+ throw new Error("Node REPL run has already ended.");
258
+ }
259
+
260
+ let child;
261
+ try {
262
+ child = spawn(prepared.command, prepared.args || [], {
263
+ cwd: prepared.cwd,
264
+ detached: true,
265
+ env: prepared.env ? { ...process.env, ...prepared.env } : process.env,
266
+ stdio: ["ignore", "pipe", "pipe", "ipc"],
267
+ });
268
+ } catch (error) {
269
+ await prepared.cleanup?.();
270
+ throw error;
271
+ }
272
+
273
+ let resolveDone = () => {};
274
+ const done = new Promise((resolveDonePromise) => { resolveDone = () => resolveDonePromise(); });
275
+ const record = {
276
+ child,
277
+ prepared,
278
+ done,
279
+ resolveDone,
280
+ closed: false,
281
+ cleaned: false,
282
+ killTimer: null,
283
+ spawnError: null,
284
+ failureReason: null,
285
+ pending: null,
286
+ stdout: [],
287
+ stderr: [],
288
+ directOutputBytes: 0,
289
+ };
290
+ current = record;
291
+
292
+ child.stdout?.on("data", (chunk) => appendChunk(record, record.stdout, chunk));
293
+ child.stderr?.on("data", (chunk) => appendChunk(record, record.stderr, chunk));
294
+ child.once("error", (error) => { record.spawnError = error; });
295
+ child.on("message", (message) => {
296
+ const result = /** @type {any} */ (message);
297
+ const pending = record.pending;
298
+ if (!pending || !result || result.type !== "result" || result.id !== pending.id) return;
299
+ record.pending = null;
300
+ clearTimeout(pending.timeoutTimer);
301
+ pending.signal?.removeEventListener?.("abort", pending.onAbort);
302
+ setImmediate(async () => {
303
+ const text = resultText(record, result.text, result.stdout, result.stderr);
304
+ if (result.reset) await terminateRecord(record);
305
+ if (result.ok) {
306
+ pending.resolve(capChars(text || "(no output)", {
307
+ label: "NodeRepl",
308
+ maxChars: maxOutputChars,
309
+ strategy: "head_tail",
310
+ ctx: resolvedCtx,
311
+ }));
312
+ } else {
313
+ pending.reject(new Error(text || "Node REPL evaluation failed."));
314
+ }
315
+ });
316
+ });
317
+ child.once("close", (code, closeSignal) => {
318
+ record.closed = true;
319
+ if (record.killTimer) clearTimeout(record.killTimer);
320
+ if (current === record) current = null;
321
+ const pending = record.pending;
322
+ record.pending = null;
323
+ if (pending) {
324
+ clearTimeout(pending.timeoutTimer);
325
+ pending.signal?.removeEventListener?.("abort", pending.onAbort);
326
+ const reason = record.failureReason
327
+ || (record.spawnError ? errorMessage(record.spawnError) : null)
328
+ || `Node REPL process exited before evaluation completed${closeSignal ? ` (${closeSignal})` : ` (code ${code ?? "unknown"})`}.`;
329
+ pending.reject(new Error(`${reason} Session state was reset.`));
330
+ }
331
+ void cleanupPrepared(record).finally(() => record.resolveDone());
332
+ });
333
+ return record;
334
+ }
335
+
336
+ async function ensureChild() {
337
+ if (permanentlyClosed) throw new Error("Node REPL run has already ended.");
338
+ if (current && !current.closed) return current;
339
+ starting ??= startChild().finally(() => { starting = null; });
340
+ return await starting;
341
+ }
342
+
343
+ async function resetForFailure(record, pending, message) {
344
+ if (record.pending === pending) record.pending = null;
345
+ clearTimeout(pending.timeoutTimer);
346
+ pending.signal?.removeEventListener?.("abort", pending.onAbort);
347
+ await terminateRecord(record);
348
+ pending.reject(new Error(`${message} Session state was reset.`));
349
+ }
350
+
351
+ return {
352
+ /** @param {{code: string}} params @param {{signal?: AbortSignal}} [execution] */
353
+ async execute({ code }, { signal } = {}) {
354
+ if (typeof code !== "string" || code.trim().length === 0) {
355
+ throw new Error("Node REPL code must not be empty.");
356
+ }
357
+ if (signal?.aborted) throw new Error("Node REPL execution aborted.");
358
+ const record = await ensureChild();
359
+ if (signal?.aborted) {
360
+ await terminateRecord(record);
361
+ throw new Error("Node REPL execution aborted. Session state was reset.");
362
+ }
363
+ if (record.pending) throw new Error("Node REPL is already evaluating code.");
364
+ clearRequestOutput(record);
365
+ const id = `node-repl-${++nextRequestId}`;
366
+
367
+ return await new Promise((resolveResult, rejectResult) => {
368
+ const pending = {
369
+ id,
370
+ resolve: resolveResult,
371
+ reject: rejectResult,
372
+ signal,
373
+ onAbort: null,
374
+ timeoutTimer: null,
375
+ };
376
+ pending.onAbort = () => {
377
+ void resetForFailure(record, pending, "Node REPL execution aborted.");
378
+ };
379
+ pending.timeoutTimer = setTimeout(() => {
380
+ void resetForFailure(
381
+ record,
382
+ pending,
383
+ `Node REPL execution timed out after ${DEFAULT_NODE_REPL_TIMEOUT_MS}ms.`,
384
+ );
385
+ }, DEFAULT_NODE_REPL_TIMEOUT_MS);
386
+ pending.timeoutTimer.unref?.();
387
+ record.pending = pending;
388
+ signal?.addEventListener?.("abort", pending.onAbort, { once: true });
389
+ record.child.send({ type: "evaluate", id, code }, (error) => {
390
+ if (error && record.pending === pending) {
391
+ void resetForFailure(record, pending, `Node REPL IPC failed: ${errorMessage(error)}.`);
392
+ }
393
+ });
394
+ });
395
+ },
396
+
397
+ async close() {
398
+ if (permanentlyClosed) return;
399
+ permanentlyClosed = true;
400
+ if (starting) {
401
+ try { await starting; } catch { /* start failure already surfaced */ }
402
+ }
403
+ if (current) await terminateRecord(current);
404
+ },
405
+ };
406
+ }