@coreplane/switchboard 1.211.0 → 1.212.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.
@@ -96,11 +96,14 @@ import {
96
96
  } from "../../src/execution/residentDepCache.js";
97
97
  import { parseWorktreeCleanliness, worktreeCleanlinessScript } from "../../src/execution/residentCleanliness.js";
98
98
  import {
99
- base64ByteLength,
100
- MAX_READ_BASE64_CHARS,
99
+ base64LengthOf,
100
+ chunkPlan,
101
101
  MAX_READ_BYTES,
102
+ parseByteSize,
103
+ readChunkCommandFor,
102
104
  readCommandFor,
103
105
  readEncodingOf,
106
+ statCommandFor,
104
107
  type Base64ReadAnswer,
105
108
  type ReadEncoding,
106
109
  } from "../../src/execution/binaryRead.js";
@@ -1692,7 +1695,7 @@ export class ResidentDO extends Sandbox<Env> {
1692
1695
  private async run(
1693
1696
  argv: readonly string[],
1694
1697
  opts: { cwd?: string; timeoutMs?: number; env?: Record<string, string> } = {},
1695
- ): Promise<{ stdout: string; stderr: string; exitCode: number; timedOut: boolean }> {
1698
+ ): Promise<{ stdout: string; stderr: string; exitCode: number; timedOut: boolean; truncated?: boolean }> {
1696
1699
  const timeout = opts.timeoutMs ?? DEFAULT_EXEC_TIMEOUT_MS;
1697
1700
  const launch = {
1698
1701
  ...(opts.cwd ? { cwd: opts.cwd } : {}),
@@ -1727,7 +1730,15 @@ export class ResidentDO extends Sandbox<Env> {
1727
1730
  }
1728
1731
  try {
1729
1732
  const out = await proc.output({ encoding: "utf8", timeout: timeout + 30_000 });
1730
- return { stdout: out.stdout, stderr: out.stderr, exitCode: out.exitCode, timedOut: out.timedOut };
1733
+ // `truncated` is the SDK saying the process log stream was cut past its
1734
+ // own retention — the output here is a prefix, whatever our caps say.
1735
+ return {
1736
+ stdout: out.stdout,
1737
+ stderr: out.stderr,
1738
+ exitCode: out.exitCode,
1739
+ timedOut: out.timedOut,
1740
+ truncated: out.truncated,
1741
+ };
1731
1742
  } catch (err) {
1732
1743
  if (isRuntimeReplacement(err)) {
1733
1744
  this.swapIncarnation(); // the container this incarnation's memos described is gone
@@ -3931,7 +3942,7 @@ export class ResidentDO extends Sandbox<Env> {
3931
3942
  timeoutMs: number,
3932
3943
  capBytes?: number,
3933
3944
  capFiles?: { out: string; err: string },
3934
- ): Promise<{ stdout: string; stderr: string; exitCode: number; timedOut: boolean }> {
3945
+ ): Promise<{ stdout: string; stderr: string; exitCode: number; timedOut: boolean; truncated?: boolean }> {
3935
3946
  const injected = { GIT_TERMINAL_PROMPT: "0" };
3936
3947
  validateEnvNames(injected);
3937
3948
  const body = capBytes
@@ -3954,7 +3965,7 @@ export class ResidentDO extends Sandbox<Env> {
3954
3965
  command: string,
3955
3966
  timeoutMs: number,
3956
3967
  charCap: number,
3957
- ): Promise<{ stdout: string; stderr: string; exitCode: number; timedOut: boolean }> {
3968
+ ): Promise<{ stdout: string; stderr: string; exitCode: number; timedOut: boolean; truncated?: boolean }> {
3958
3969
  const capBytes = capBytesFor(charCap);
3959
3970
  const files = execCapFiles();
3960
3971
  const r = await this.threadRun(user, worktreePath, command, timeoutMs, capBytes, files);
@@ -5161,7 +5172,7 @@ export class ResidentDO extends Sandbox<Env> {
5161
5172
  if (err instanceof RuntimeReplacedError) return runtimeReplacedErr(err);
5162
5173
  throw err;
5163
5174
  }
5164
- const truncated = r.stdout.length > EXEC_OUTPUT_CAP || r.stderr.length > EXEC_OUTPUT_CAP;
5175
+ const truncated = r.stdout.length > EXEC_OUTPUT_CAP || r.stderr.length > EXEC_OUTPUT_CAP || r.truncated === true;
5165
5176
  const notes: string[] = [];
5166
5177
  if (r.timedOut)
5167
5178
  notes.push(
@@ -5201,31 +5212,68 @@ export class ResidentDO extends Sandbox<Env> {
5201
5212
  const resolved = confineThreadPath(pre.binding.worktreePath, path);
5202
5213
  if (!resolved)
5203
5214
  return { error: `path-escape: ${JSON.stringify(path)} does not stay inside the thread worktree`, status: 400 };
5204
- const cap = encoding === "base64" ? MAX_READ_BASE64_CHARS : READ_CONTENT_CAP;
5215
+ if (encoding === "base64") return this.readThreadBytes(pre.binding, resolved);
5205
5216
  let r: Awaited<ReturnType<ResidentDO["threadRun"]>>;
5206
5217
  try {
5207
5218
  r = await this.threadRun(
5208
5219
  pre.binding.user,
5209
5220
  pre.binding.worktreePath,
5210
- readCommandFor(encoding, resolved),
5221
+ readCommandFor(resolved),
5211
5222
  DEFAULT_EXEC_TIMEOUT_MS,
5212
- capBytesFor(cap),
5223
+ capBytesFor(READ_CONTENT_CAP),
5213
5224
  );
5214
5225
  } catch (err) {
5215
5226
  if (err instanceof RuntimeReplacedError) return runtimeReplacedErr(err);
5216
5227
  throw err;
5217
5228
  }
5218
5229
  if (r.exitCode !== 0 || r.timedOut) return { error: `read-failed: ${describeStepFailure(r)}`, status: 404 };
5219
- const truncated = r.stdout.length > cap;
5220
- if (encoding === "base64") {
5221
- // Padding hides up to two bytes inside the cap's char count, so the
5222
- // decoded size is checked too — the cap is bytes, not characters.
5223
- const content = r.stdout.trimEnd();
5224
- return truncated || base64ByteLength(content) > MAX_READ_BYTES
5225
- ? { encoding, tooLarge: true }
5226
- : { encoding, content };
5230
+ const truncated = r.stdout.length > READ_CONTENT_CAP || r.truncated === true;
5231
+ return { content: truncated ? r.stdout.slice(0, READ_CONTENT_CAP) : r.stdout, truncated };
5232
+ }
5233
+
5234
+ /** The bytes of a confined file as base64, as the thread user, in chunks
5235
+ * (src/execution/binaryRead.ts): one command's stdout crosses the SDK's
5236
+ * process log stream, which cuts a stream past a retention limit far below
5237
+ * the binary cap and says so only through `truncated` — a 12 MB file once
5238
+ * came back as 1.7 MB and was handed on as complete. So: `stat` first (the
5239
+ * cap is judged on the size, before any read), then chunks small enough
5240
+ * that no stream is ever cut; a chunk the SDK still flags, or one whose
5241
+ * length is not what the size promised, fails the read by name. The answer
5242
+ * carries the size for the client to check the decoded bytes against. */
5243
+ private async readThreadBytes(
5244
+ binding: { user: string; worktreePath: string },
5245
+ resolved: string,
5246
+ ): Promise<Base64ReadAnswer | ThreadErr> {
5247
+ const run = (command: string, capChars: number) =>
5248
+ this.threadRun(binding.user, binding.worktreePath, command, DEFAULT_EXEC_TIMEOUT_MS, capBytesFor(capChars));
5249
+ try {
5250
+ const stat = await run(statCommandFor(resolved), 64);
5251
+ if (stat.exitCode !== 0 || stat.timedOut)
5252
+ return { error: `read-failed: ${describeStepFailure(stat)}`, status: 404 };
5253
+ const size = parseByteSize(stat.stdout);
5254
+ if (size === null)
5255
+ return { error: `read-failed: stat answered ${JSON.stringify(stat.stdout.slice(0, 64))}`, status: 500 };
5256
+ if (size > MAX_READ_BYTES) return { encoding: "base64", tooLarge: true };
5257
+ const chunks = chunkPlan(size);
5258
+ const parts: string[] = [];
5259
+ for (const [i, chunk] of chunks.entries()) {
5260
+ const expected = base64LengthOf(chunk.length);
5261
+ const r = await run(readChunkCommandFor(resolved, chunk), expected);
5262
+ if (r.exitCode !== 0 || r.timedOut) return { error: `read-failed: ${describeStepFailure(r)}`, status: 404 };
5263
+ const piece = r.stdout.trimEnd();
5264
+ if (r.truncated === true || piece.length !== expected) {
5265
+ return {
5266
+ error: `read-inconsistent: chunk ${i + 1} of ${chunks.length} arrived as ${piece.length} of ${expected} base64 chars${r.truncated ? " (the SDK cut the output stream)" : ""}`,
5267
+ status: 409,
5268
+ };
5269
+ }
5270
+ parts.push(piece);
5271
+ }
5272
+ return { encoding: "base64", content: parts.join(""), size };
5273
+ } catch (err) {
5274
+ if (err instanceof RuntimeReplacedError) return runtimeReplacedErr(err);
5275
+ throw err;
5227
5276
  }
5228
- return { content: truncated ? r.stdout.slice(0, cap) : r.stdout, truncated };
5229
5277
  }
5230
5278
 
5231
5279
  /** POST /write: content travels via the SDK file API into the thread's
@@ -5652,7 +5700,7 @@ export class ResidentDO extends Sandbox<Env> {
5652
5700
  timedOut: r.timedOut,
5653
5701
  });
5654
5702
  const ok = r.exitCode === 0 && !r.timedOut;
5655
- const truncated = r.stdout.length > EXEC_OUTPUT_CAP || r.stderr.length > EXEC_OUTPUT_CAP;
5703
+ const truncated = r.stdout.length > EXEC_OUTPUT_CAP || r.stderr.length > EXEC_OUTPUT_CAP || r.truncated === true;
5656
5704
  const notes: string[] = [];
5657
5705
  if (r.timedOut) notes.push(`command timed out after ${OP_EXEC_TIMEOUT_MS}ms`);
5658
5706
  if (truncated) notes.push(`output truncated to ${EXEC_OUTPUT_CAP} chars per stream`);
@@ -21,7 +21,9 @@ import { BASH_TIMEOUT_MAX_MS, clampBashTimeout } from "../../src/execution/bashT
21
21
  import {
22
22
  base64ByteLength,
23
23
  MAX_READ_BYTES,
24
+ parseByteSize,
24
25
  readEncodingOf,
26
+ statCommandFor,
25
27
  type Base64ReadAnswer,
26
28
  } from "../../src/execution/binaryRead.js";
27
29
  import {
@@ -278,13 +280,25 @@ export default {
278
280
  if (typeof encoding !== "string") return json({ error: encoding.error }, 400);
279
281
  const path = abs(String(body.path ?? ""));
280
282
  if (encoding === "base64") {
283
+ // The size first, from `stat`, so the cap is judged before any
284
+ // read and the client can hold the decoded bytes to it — an SDK
285
+ // read that came back short would otherwise pass as the file.
286
+ const stat = await withSessionRecovery(sandbox, () => sandbox.exec(statCommandFor(path)));
287
+ if ((stat.exitCode ?? 0) !== 0) {
288
+ return json({ error: `read-failed: ${String(stat.stderr ?? stat.stdout ?? "").trim()}` }, 404);
289
+ }
290
+ const size = parseByteSize(String(stat.stdout ?? ""));
291
+ if (size === null) return json({ error: `read-failed: stat answered ${JSON.stringify(stat.stdout)}` }, 500);
292
+ if (size > MAX_READ_BYTES) return json({ encoding: "base64", tooLarge: true } satisfies Base64ReadAnswer);
281
293
  const file = await withSessionRecovery(sandbox, () => sandbox.readFile(path, { encoding: "base64" }));
282
294
  const content = typeof file === "string" ? file : (file?.content ?? "");
283
- const answer: Base64ReadAnswer =
284
- base64ByteLength(content) > MAX_READ_BYTES
285
- ? { encoding: "base64", tooLarge: true }
286
- : { encoding: "base64", content };
287
- return json(answer);
295
+ const got = base64ByteLength(content);
296
+ if (got !== size) {
297
+ // 409, not 5xx: the client retries a 5xx twice over 30 s, and a
298
+ // short read is answered by the caller re-reading, not by waiting.
299
+ return json({ error: `read-inconsistent: ${path} is ${size} bytes but the read returned ${got}` }, 409);
300
+ }
301
+ return json({ encoding: "base64", content, size } satisfies Base64ReadAnswer);
288
302
  }
289
303
  const file = await withSessionRecovery(sandbox, () => sandbox.readFile(path));
290
304
  return json({ content: typeof file === "string" ? file : (file?.content ?? "") });
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "switchboard",
3
- "version": "1.211.0",
3
+ "version": "1.212.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "switchboard",
9
- "version": "1.211.0",
9
+ "version": "1.212.0",
10
10
  "license": "Apache-2.0",
11
11
  "workspaces": [
12
12
  "web",
@@ -18999,7 +18999,7 @@
18999
18999
  },
19000
19000
  "packages/switchboard": {
19001
19001
  "name": "@coreplane/switchboard",
19002
- "version": "1.211.0",
19002
+ "version": "1.212.0",
19003
19003
  "license": "Apache-2.0",
19004
19004
  "dependencies": {
19005
19005
  "@anthropic-ai/sdk": "^0.124.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchboard",
3
- "version": "1.211.0",
3
+ "version": "1.212.0",
4
4
  "private": true,
5
5
  "description": "Mention it in Slack and an agent reviews the PR, ships the fix, or answers the question — on the model you choose, with its tools running where you decide.",
6
6
  "license": "Apache-2.0",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.211.0",
3
- "commit": "1cee216b3cca53760bd183254c56904d4b990aa9",
4
- "builtAt": "2026-09-14T06:10:14.818Z"
2
+ "version": "1.212.0",
3
+ "commit": "c3e94f963fc37fc7c8308cb34268f868413b76b3",
4
+ "builtAt": "2026-09-14T06:44:27.746Z"
5
5
  }
@@ -99,10 +99,11 @@ export interface AgentDef {
99
99
  /** Whether the request router (docs/reference/specs/routing-and-config.md
100
100
  * item 21) may pick this preset for a plain message. Absent means yes: the
101
101
  * router's table is rendered from this registry. `false` keeps a preset
102
- * for a typed directive alone — structurally: it is absent from the table
103
- * the model is shown and refused by the allowlist even if the model names
104
- * it. `ship` (it holds the merge grant) and `conductor` (it starts other
105
- * runs) opt out. */
102
+ * out of the table — structurally: it is absent from the table the model
103
+ * is shown and refused as a single route even if the model names it.
104
+ * `ship` (it holds the merge grant) opts out for good; `conductor` (it
105
+ * starts other runs) opts out of the table and is reached through the
106
+ * router's compound form alone, with its parts named. */
106
107
  routable?: false;
107
108
  /** System prompt variant for resident-repo runs (docs/reference/specs/resident-repos.md):
108
109
  * the workspace is a ready worktree — no cloning, no installs, no repo
@@ -426,6 +427,8 @@ WHAT A CHILD IS. A child is an ordinary Switchboard run started as the person wh
426
427
 
427
428
  THE PRESETS a child can run: \`research\` (a question the web or our repositories answer), \`coding\` (implement a change and open a pull request; needs the repository), \`review\` (review a pull request; needs its URL), \`explore\` (a long, read-only investigation with a shell; needs the repository), \`general\` (a quick answer with the GitHub tools), \`ship\` (coding, review and fixes until a pull request is merge-ready; needs the repository).
428
429
 
430
+ ROUTED COMPOUNDS. A request may arrive already split: the router found independent parts, and the message ends with the line "Routed as a compound request: N independent parts" followed by a numbered list, one part per line as \`<preset>\`: <text>. Spawn exactly those children — one \`spawn_run\` per line, the preset as listed, the line's text as the child's prompt (it already stands alone; add the repository where the preset needs one) — then \`await_runs\` them all and compile. Never merge, drop or add a part; a part whose spawn is refused is reported as refused, by the gate's name.
431
+
429
432
  HOW TO WORK. Fan out, await, compile. Read the request and split it into children only where the parts are independent; a request one preset answers is one child. Spawn each child with a self-contained prompt — everything it needs, since it sees none of this thread — and the repository where the preset needs one. Then call \`await_runs\` once with every child's id: it returns when all of them have ended, or earlier — at the edge of your own budget, at a stop, or when a follow-up lands in this thread — and \`ended\` says which; a child still running at the cut keeps running (name it in your answer, or await again after a follow-up). Steer a child with \`send_to_run\` when the request changes or a child is heading the wrong way. A child that ended — finished, failed, interrupted by a restart — is reported as it ended and never restarted; spawn a new child if the work still matters. Then compile: one answer from the write-ups \`await_runs\` returned. Never do a child's job yourself, and never claim a child finished or found something you did not read from \`await_runs\` or \`get_run_status\`.
430
433
 
431
434
  Maintain the user-facing status card with the update_status tool: one item per child (○ pending, ✱ running, ✓ finished — only once await_runs or get_run_status said so).
@@ -543,9 +546,12 @@ export const AGENTS: Record<string, AgentDef> = {
543
546
  // dispatcher, the GitHub reads are REST in the bot process.
544
547
  machine: "none",
545
548
  identity: "none",
546
- // Never offered to the router: its children are runs under the requester's
547
- // permissions with a directive of their own, so a routed conductor could
548
- // fan a plain message out into runs nobody named — a person names it.
549
+ // Never a row of the router's table: a plain message is never routed to a
550
+ // conductor that decides the split itself. The compound form is its one
551
+ // door (docs/reference/specs/routing-and-config.md item 21): the router
552
+ // names the parts and their presets, each checked against the same table
553
+ // and the requester's allowlist, and the brief tells the conductor to
554
+ // spawn exactly those — so no child runs that the record did not name.
549
555
  routable: false,
550
556
  maxTokens: 32000,
551
557
  ...loopBudget(120), // long enough to outlast a coding child; every child is capped by what remains of it
@@ -489,11 +489,25 @@ export type RunEvent =
489
489
  /** The request router's decision (docs/reference/specs/routing-and-config.md
490
490
  * item 21): the preset a plain message was routed to, the one-line reason
491
491
  * the router gave (redacted, capped — the same text the card's `routed:`
492
- * line carries) and the model that decided. Published by the dispatcher
493
- * straight to the registry right after `run_meta`, once per routed run;
494
- * absent on every run a directive, a sticky preset or a scope chose. Head
495
- * material, like `run_meta`. Additive: unknown → ignored. */
496
- | { type: "route"; preset: string; reason: string; model: string; seq?: number; at?: number }
492
+ * line carries) and the model that decided. A compound route is `preset:
493
+ * "conductor"` with `parts` — one per child the conductor was told to
494
+ * spawn: its preset and its text, the child's whole prompt. A compound the
495
+ * parse refused is recorded too, on the run that fell to the default:
496
+ * `preset` is `defaults.agent` and `reason` reads `compound_rejected:
497
+ * <why>` (the run's `run_meta.agentSource` stays `default`). Published by
498
+ * the dispatcher straight to the registry right after `run_meta`, once per
499
+ * run the router answered; absent on every run a directive, a sticky
500
+ * preset or a scope chose. Head material, like `run_meta`. Additive:
501
+ * unknown → ignored. */
502
+ | {
503
+ type: "route";
504
+ preset: string;
505
+ reason: string;
506
+ model: string;
507
+ parts?: ReadonlyArray<{ preset: string; text: string }>;
508
+ seq?: number;
509
+ at?: number;
510
+ }
497
511
  /** The span records (docs/reference/specs/tracing.md): published, counted and stored like
498
512
  * every other event, read as timing and never as content. */
499
513
  | SpanStartEvent
@@ -2,10 +2,10 @@
2
2
  // the whole file as bytes, for a tool that hands a workspace artifact — a
3
3
  // screenshot, a PDF — to somewhere that needs the bytes, not a text view. Both
4
4
  // remote executors ask their Worker's `/read` route for `encoding: "base64"`;
5
- // the Worker answers `{ content, encoding: "base64" }`. This module is the
6
- // contract both ends share: the caps, the request/answer shape, the resident's
7
- // read command. It is bundled into the Workers too, so it stays free of Node
8
- // imports.
5
+ // the Worker answers `{ encoding: "base64", content, size }`. This module is
6
+ // the contract both ends share: the caps, the request/answer shape, the
7
+ // resident's stat and chunk commands. It is bundled into the Workers too, so it
8
+ // stays free of Node imports.
9
9
 
10
10
  /** The most bytes one `readBytes` hands over. A binary cannot be truncated
11
11
  * the way text output is, so a larger file is refused by name, never trimmed.
@@ -28,11 +28,10 @@ export function readEncodingOf(body: Record<string, unknown>): ReadEncoding | {
28
28
  return { error: `encoding must be "base64" or absent, got ${JSON.stringify(body.encoding)}` };
29
29
  }
30
30
 
31
- /** The command a resident runs for a read of an already-confined path: `cat`
32
- * for text; `base64 -w0` for bytes — one unwrapped line, so the Durable
33
- * Object's character cap slices a plain string and nothing else. */
34
- export function readCommandFor(encoding: ReadEncoding, resolvedPath: string): string {
35
- return encoding === "base64" ? `base64 -w0 -- ${resolvedPath}` : `cat -- ${resolvedPath}`;
31
+ /** The command a resident runs for a text read of an already-confined path.
32
+ * Bytes never go through one command: see `chunkPlan`. */
33
+ export function readCommandFor(resolvedPath: string): string {
34
+ return `cat -- ${resolvedPath}`;
36
35
  }
37
36
 
38
37
  /** How many bytes a base64 string decodes to, padding discounted. */
@@ -43,11 +42,54 @@ export function base64ByteLength(b64: string): number {
43
42
  return Math.floor((trimmed.length * 3) / 4) - padding;
44
43
  }
45
44
 
46
- /** A Worker's answer to a base64 read: the bytes, or the named refusal of a
47
- * file over the cap — HTTP 200 either way. The clients classify a non-2xx or
48
- * an in-body `error` as a sick Worker (fail-fast counts it); a large file is
49
- * the model's mistake, not infrastructure, so it travels as a plain field. */
50
- export type Base64ReadAnswer = { encoding: "base64"; content: string } | { encoding: "base64"; tooLarge: true };
45
+ /** A Worker's answer to a base64 read: the bytes with the file's size, or the
46
+ * named refusal of a file over the cap — HTTP 200 either way. The clients
47
+ * classify a non-2xx or an in-body `error` as a sick Worker (fail-fast counts
48
+ * it); a large file is the model's mistake, not infrastructure, so it travels
49
+ * as a plain field. `size` is the file's byte count from a `stat` taken before
50
+ * the read: the client refuses an answer whose decoded length differs, so a
51
+ * stream cut in transit can never pass as the file. */
52
+ export type Base64ReadAnswer =
53
+ { encoding: "base64"; content: string; size: number } | { encoding: "base64"; tooLarge: true };
54
+
55
+ /** The command that measures a confined file, as the thread user: one number. */
56
+ export function statCommandFor(resolvedPath: string): string {
57
+ return `stat -c %s -- ${resolvedPath}`;
58
+ }
59
+
60
+ /** `stat -c %s`'s output as a byte count; null for anything that is not one. */
61
+ export function parseByteSize(stdout: string): number | null {
62
+ const m = /^\s*(\d{1,15})\s*$/.exec(stdout);
63
+ return m ? Number(m[1]) : null;
64
+ }
65
+
66
+ /** Bytes per chunk of a resident's base64 read. A command's stdout crosses the
67
+ * sandbox SDK's process log stream, which cuts a stream past a retention
68
+ * limit the typings do not name (observed: about 2.3 MB) and reports the cut
69
+ * only as a `truncated` flag — so no single command may carry the whole file.
70
+ * One MiB less one: a multiple of 3, so each chunk's base64 has no padding and
71
+ * the pieces concatenate into the encoding of the whole. */
72
+ export const READ_CHUNK_BYTES = 1_048_575;
73
+
74
+ /** The chunks that cover a `size`-byte file, in order; none for an empty file. */
75
+ export function chunkPlan(size: number, chunkBytes = READ_CHUNK_BYTES): Array<{ offset: number; length: number }> {
76
+ const chunks: Array<{ offset: number; length: number }> = [];
77
+ for (let offset = 0; offset < size; offset += chunkBytes) {
78
+ chunks.push({ offset, length: Math.min(chunkBytes, size - offset) });
79
+ }
80
+ return chunks;
81
+ }
82
+
83
+ /** One chunk of a confined file as unwrapped base64: `tail -c +N` seeks on a
84
+ * regular file, `head -c` bounds the piece. */
85
+ export function readChunkCommandFor(resolvedPath: string, chunk: { offset: number; length: number }): string {
86
+ return `tail -c +${chunk.offset + 1} -- ${resolvedPath} | head -c ${chunk.length} | base64 -w0`;
87
+ }
88
+
89
+ /** The base64 length `bytes` encode to (four chars per three bytes, padded). */
90
+ export function base64LengthOf(bytes: number): number {
91
+ return Math.ceil(bytes / 3) * 4;
92
+ }
51
93
 
52
94
  /** One message for a file over the cap, for every implementation. `bytes` is
53
95
  * the size when the reader could measure it; a resident sees only that its
@@ -24,6 +24,10 @@ export interface StepResult {
24
24
  stderr: string;
25
25
  exitCode: number;
26
26
  timedOut: boolean;
27
+ /** The sandbox SDK cut the process's output stream past its own retention
28
+ * limit (one its typings do not name; about 2.3 MB observed): what is here
29
+ * is a prefix. Distinct from the DO's char caps, which slice what arrived. */
30
+ truncated?: boolean;
27
31
  }
28
32
 
29
33
  /** Chars kept per stream in the STORED reason — it travels into DO storage,