@nettee/beacon 0.4.0 → 0.4.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.
@@ -38,5 +38,19 @@ export declare class PiRuntimeError extends Error {
38
38
  }
39
39
  /** Collect thinking / reasoning blocks from an assistant message content array. */
40
40
  export declare function collectThinking(content: unknown[]): string;
41
+ /**
42
+ * Read NDJSON frames from Pi stdout with a hard per-frame byte cap.
43
+ *
44
+ * Oversized frames (for example tool events carrying multi-MB stdout) are
45
+ * discarded through the next newline instead of failing the whole Run. Beacon
46
+ * only needs prompt responses, `message_end`, and `agent_settled`; intermediate
47
+ * tool frames are ignored anyway, and Pi already truncates tool output for the
48
+ * model. The cap still bounds parse/buffer memory — we never raise it here.
49
+ */
50
+ export declare function createCappedNdjsonReader(maxFrameBytes: number, onFrame: (line: string) => void): {
51
+ push: (chunk: Buffer) => void;
52
+ close: () => void;
53
+ readonly skippedOversizedFrames: number;
54
+ };
41
55
  export declare function runPiAgent(request: PiRunRequest, options?: PiRuntimeOptions): Promise<PiRunResult>;
42
56
  export {};
@@ -1,7 +1,6 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { chmod, mkdir } from "node:fs/promises";
3
3
  import { isAbsolute } from "node:path";
4
- import { createInterface } from "node:readline";
5
4
  import { fileURLToPath } from "node:url";
6
5
  import { assertRuntimeEnvironmentKey } from "../config/runtime-environment.js";
7
6
  export class PiRuntimeError extends Error {
@@ -155,6 +154,81 @@ async function stopProcess(child, terminateGraceMs) {
155
154
  });
156
155
  });
157
156
  }
157
+ /**
158
+ * Read NDJSON frames from Pi stdout with a hard per-frame byte cap.
159
+ *
160
+ * Oversized frames (for example tool events carrying multi-MB stdout) are
161
+ * discarded through the next newline instead of failing the whole Run. Beacon
162
+ * only needs prompt responses, `message_end`, and `agent_settled`; intermediate
163
+ * tool frames are ignored anyway, and Pi already truncates tool output for the
164
+ * model. The cap still bounds parse/buffer memory — we never raise it here.
165
+ */
166
+ export function createCappedNdjsonReader(maxFrameBytes, onFrame) {
167
+ if (!Number.isSafeInteger(maxFrameBytes) || maxFrameBytes <= 0) {
168
+ throw new Error("Pi RPC frame limit must be a positive integer");
169
+ }
170
+ const chunks = [];
171
+ let bufferedBytes = 0;
172
+ let discarding = false;
173
+ let closed = false;
174
+ let skippedOversizedFrames = 0;
175
+ const resetBuffer = () => {
176
+ chunks.length = 0;
177
+ bufferedBytes = 0;
178
+ };
179
+ const emitFrame = () => {
180
+ const line = Buffer.concat(chunks, bufferedBytes).toString("utf8");
181
+ resetBuffer();
182
+ onFrame(line);
183
+ };
184
+ const push = (chunk) => {
185
+ if (closed || chunk.length === 0)
186
+ return;
187
+ let offset = 0;
188
+ while (offset < chunk.length) {
189
+ if (discarding) {
190
+ const nl = chunk.indexOf(10, offset);
191
+ if (nl === -1)
192
+ return;
193
+ discarding = false;
194
+ offset = nl + 1;
195
+ continue;
196
+ }
197
+ const nl = chunk.indexOf(10, offset);
198
+ const end = nl === -1 ? chunk.length : nl;
199
+ const pieceLen = end - offset;
200
+ if (bufferedBytes + pieceLen > maxFrameBytes) {
201
+ skippedOversizedFrames += 1;
202
+ resetBuffer();
203
+ if (nl === -1) {
204
+ discarding = true;
205
+ return;
206
+ }
207
+ offset = nl + 1;
208
+ continue;
209
+ }
210
+ if (pieceLen > 0) {
211
+ chunks.push(chunk.subarray(offset, end));
212
+ bufferedBytes += pieceLen;
213
+ }
214
+ if (nl === -1)
215
+ return;
216
+ emitFrame();
217
+ offset = nl + 1;
218
+ }
219
+ };
220
+ return {
221
+ push,
222
+ close: () => {
223
+ closed = true;
224
+ resetBuffer();
225
+ discarding = false;
226
+ },
227
+ get skippedOversizedFrames() {
228
+ return skippedOversizedFrames;
229
+ },
230
+ };
231
+ }
158
232
  export async function runPiAgent(request, options = {}) {
159
233
  if (!request.prompt.trim())
160
234
  throw new Error("Pi Run prompt must not be empty");
@@ -199,11 +273,11 @@ export async function runPiAgent(request, options = {}) {
199
273
  env: buildPiEnvironment(request.outcome, options.environment, options.runtimeEnvironment),
200
274
  stdio: ["pipe", "pipe", "pipe"],
201
275
  });
202
- const lines = createInterface({ input: child.stdout, crlfDelay: Infinity });
203
276
  let stderr = "";
204
277
  let finalMessage;
205
278
  let promptAccepted = false;
206
279
  let finished = false;
280
+ let frames;
207
281
  child.stderr.setEncoding("utf8");
208
282
  child.stderr.on("data", (chunk) => {
209
283
  stderr = (stderr + chunk).slice(-64 * 1024);
@@ -218,27 +292,7 @@ export async function runPiAgent(request, options = {}) {
218
292
  reject(error);
219
293
  };
220
294
  const timer = setTimeout(() => fail(new PiRuntimeError("runtime_timeout", `Pi Run timed out after ${timeoutMs}ms${stderr.trim() ? `: ${stderr.trim()}` : ""}`)), timeoutMs);
221
- let frameBytes = 0;
222
- child.stdout.on("data", (chunk) => {
223
- for (const byte of chunk) {
224
- if (byte === 10)
225
- frameBytes = 0;
226
- else
227
- frameBytes += 1;
228
- if (frameBytes > maxFrameBytes) {
229
- fail(new PiRuntimeError("runtime_protocol_error", `Pi RPC frame exceeds ${maxFrameBytes} bytes`));
230
- return;
231
- }
232
- }
233
- });
234
- child.once("error", (error) => fail(new PiRuntimeError("runtime_spawn_failed", `Failed to start Pi: ${error.message}`, {
235
- cause: error,
236
- })));
237
- child.once("exit", (code, signal) => {
238
- if (!finished)
239
- fail(describeExit(code, signal, stderr));
240
- });
241
- lines.on("line", (line) => {
295
+ const handleFrame = (line) => {
242
296
  if (finished)
243
297
  return;
244
298
  let value;
@@ -295,13 +349,24 @@ export async function runPiAgent(request, options = {}) {
295
349
  catch (error) {
296
350
  fail(error instanceof Error ? error : new Error(String(error)));
297
351
  }
352
+ };
353
+ frames = createCappedNdjsonReader(maxFrameBytes, handleFrame);
354
+ child.stdout.on("data", (chunk) => {
355
+ frames?.push(chunk);
356
+ });
357
+ child.once("error", (error) => fail(new PiRuntimeError("runtime_spawn_failed", `Failed to start Pi: ${error.message}`, {
358
+ cause: error,
359
+ })));
360
+ child.once("exit", (code, signal) => {
361
+ if (!finished)
362
+ fail(describeExit(code, signal, stderr));
298
363
  });
299
364
  child.stdin.on("error", (error) => fail(new PiRuntimeError("runtime_protocol_error", `Failed to write Pi RPC command: ${error.message}`, { cause: error })));
300
365
  child.stdin.write(`${JSON.stringify({ id: "run-prompt", type: "prompt", message: request.prompt })}\n`);
301
366
  });
302
367
  }
303
368
  finally {
304
- lines.close();
369
+ frames?.close();
305
370
  await stopProcess(child, terminateGraceMs);
306
371
  }
307
372
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nettee/beacon",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "A single-host bridge from messaging and schedules to isolated agent runs",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {