@coreplane/switchboard 1.209.0 → 1.210.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.
@@ -95,6 +95,15 @@ import {
95
95
  type ThreadDepsMechanism,
96
96
  } from "../../src/execution/residentDepCache.js";
97
97
  import { parseWorktreeCleanliness, worktreeCleanlinessScript } from "../../src/execution/residentCleanliness.js";
98
+ import {
99
+ base64ByteLength,
100
+ MAX_READ_BASE64_CHARS,
101
+ MAX_READ_BYTES,
102
+ readCommandFor,
103
+ readEncodingOf,
104
+ type Base64ReadAnswer,
105
+ type ReadEncoding,
106
+ } from "../../src/execution/binaryRead.js";
98
107
  import {
99
108
  capBytesFor,
100
109
  capWrappedCommand,
@@ -5170,36 +5179,53 @@ export class ResidentDO extends Sandbox<Env> {
5170
5179
  }
5171
5180
 
5172
5181
  /** POST /read: cat the file AS THE THREAD USER — the OS layer (not just
5173
- * the prefix check) is what confines a symlink pointing outside. */
5174
- async readThreadFile(threadKey: string, path: string): Promise<{ content: string; truncated: boolean } | ThreadErr> {
5175
- return this.withThreadBusy(threadKey, () => this.readThreadFileImpl(threadKey, path));
5182
+ * the prefix check) is what confines a symlink pointing outside. A
5183
+ * `base64` read (src/execution/binaryRead.ts) runs `base64 -w0` instead,
5184
+ * under the binary cap; an overflow is the named refusal, never a slice
5185
+ * of the encoding. */
5186
+ async readThreadFile(
5187
+ threadKey: string,
5188
+ path: string,
5189
+ encoding: ReadEncoding = "utf8",
5190
+ ): Promise<{ content: string; truncated: boolean } | Base64ReadAnswer | ThreadErr> {
5191
+ return this.withThreadBusy(threadKey, () => this.readThreadFileImpl(threadKey, path, encoding));
5176
5192
  }
5177
5193
 
5178
5194
  private async readThreadFileImpl(
5179
5195
  threadKey: string,
5180
5196
  path: string,
5181
- ): Promise<{ content: string; truncated: boolean } | ThreadErr> {
5197
+ encoding: ReadEncoding,
5198
+ ): Promise<{ content: string; truncated: boolean } | Base64ReadAnswer | ThreadErr> {
5182
5199
  const pre = await this.threadPreflight(threadKey);
5183
5200
  if ("error" in pre) return pre;
5184
5201
  const resolved = confineThreadPath(pre.binding.worktreePath, path);
5185
5202
  if (!resolved)
5186
5203
  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;
5187
5205
  let r: Awaited<ReturnType<ResidentDO["threadRun"]>>;
5188
5206
  try {
5189
5207
  r = await this.threadRun(
5190
5208
  pre.binding.user,
5191
5209
  pre.binding.worktreePath,
5192
- `cat -- ${resolved}`,
5210
+ readCommandFor(encoding, resolved),
5193
5211
  DEFAULT_EXEC_TIMEOUT_MS,
5194
- capBytesFor(READ_CONTENT_CAP),
5212
+ capBytesFor(cap),
5195
5213
  );
5196
5214
  } catch (err) {
5197
5215
  if (err instanceof RuntimeReplacedError) return runtimeReplacedErr(err);
5198
5216
  throw err;
5199
5217
  }
5200
5218
  if (r.exitCode !== 0 || r.timedOut) return { error: `read-failed: ${describeStepFailure(r)}`, status: 404 };
5201
- const truncated = r.stdout.length > READ_CONTENT_CAP;
5202
- return { content: truncated ? r.stdout.slice(0, READ_CONTENT_CAP) : r.stdout, truncated };
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 };
5227
+ }
5228
+ return { content: truncated ? r.stdout.slice(0, cap) : r.stdout, truncated };
5203
5229
  }
5204
5230
 
5205
5231
  /** POST /write: content travels via the SDK file API into the thread's
@@ -7186,7 +7212,9 @@ async function handleRead(env: Env, body: Record<string, unknown>): Promise<Resp
7186
7212
  if (ctx instanceof Response) return ctx;
7187
7213
  if (typeof body.path !== "string")
7188
7214
  return json({ error: "path must be a string relative to the thread worktree" }, 400);
7189
- const result = await ctx.stub.readThreadFile(ctx.threadKey, body.path);
7215
+ const encoding = readEncodingOf(body);
7216
+ if (typeof encoding !== "string") return json({ error: encoding.error }, 400);
7217
+ const result = await ctx.stub.readThreadFile(ctx.threadKey, body.path, encoding);
7190
7218
  if ("error" in result) return threadErrResponse(result);
7191
7219
  return json(result);
7192
7220
  }
@@ -18,6 +18,12 @@
18
18
  // first deploy; the SDK is young and its surface may shift.
19
19
  import { getSandbox, Sandbox, type ExecOptions, type ExecResult } from "@cloudflare/sandbox";
20
20
  import { BASH_TIMEOUT_MAX_MS, clampBashTimeout } from "../../src/execution/bashTimeout.js";
21
+ import {
22
+ base64ByteLength,
23
+ MAX_READ_BYTES,
24
+ readEncodingOf,
25
+ type Base64ReadAnswer,
26
+ } from "../../src/execution/binaryRead.js";
21
27
  import {
22
28
  EXEC_KEEPALIVE_INTERVAL_MS,
23
29
  SANDBOX_SLEEP_AFTER,
@@ -265,7 +271,22 @@ export default {
265
271
  );
266
272
  }
267
273
  case "/read": {
268
- const file = await withSessionRecovery(sandbox, () => sandbox.readFile(abs(String(body.path ?? ""))));
274
+ // `encoding: "base64"` is a binary read (src/execution/binaryRead.ts):
275
+ // the SDK encodes the bytes, and a file over the cap is refused by
276
+ // name inside a 200 — the client counts a non-2xx as a sick Worker.
277
+ const encoding = readEncodingOf(body);
278
+ if (typeof encoding !== "string") return json({ error: encoding.error }, 400);
279
+ const path = abs(String(body.path ?? ""));
280
+ if (encoding === "base64") {
281
+ const file = await withSessionRecovery(sandbox, () => sandbox.readFile(path, { encoding: "base64" }));
282
+ 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);
288
+ }
289
+ const file = await withSessionRecovery(sandbox, () => sandbox.readFile(path));
269
290
  return json({ content: typeof file === "string" ? file : (file?.content ?? "") });
270
291
  }
271
292
  case "/write": {
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "switchboard",
3
- "version": "1.209.0",
3
+ "version": "1.210.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "switchboard",
9
- "version": "1.209.0",
9
+ "version": "1.210.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.209.0",
19002
+ "version": "1.210.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.209.0",
3
+ "version": "1.210.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.209.0",
3
- "commit": "921bd6b7bd8c7cd979c68435a47bc7ad169a3893",
4
- "builtAt": "2026-09-14T05:12:19.226Z"
2
+ "version": "1.210.0",
3
+ "commit": "1c1f94ae5c58bff0f3734e2b522b2ab440baa89d",
4
+ "builtAt": "2026-09-14T05:52:14.303Z"
5
5
  }
@@ -170,6 +170,13 @@ const IMAGE_TOOLCHAIN = `Node 24 with npm and pnpm; python3, make and g++ (nativ
170
170
  const SANDBOX_TOOLCHAIN = `The sandbox image carries ${IMAGE_TOOLCHAIN}; and Docker (the engine starts on the first \`docker\` call).`;
171
171
  const RESIDENT_TOOLCHAIN = `The resident image carries ${IMAGE_TOOLCHAIN}; plus yarn and bun — and no Docker.`;
172
172
 
173
+ // Both coding prompts carry this verbatim (docs/reference/specs/agent-coding.md
174
+ // item 10): the one way a run's screenshot reaches the person. Said once so
175
+ // the sandbox and resident variants cannot drift on it.
176
+ const SHOW_FILES = `Files the person should SEE go through the attach_file tool: a screenshot from \`playwright screenshot\`, a rendered PDF, a recording — it posts the workspace file into this conversation, where an image renders inline. Use it whenever you produce an image worth showing (a visual change, a rendered page, a before/after); a link to a file on GitHub is not a picture.
177
+ SCREENSHOTS GO TO BOTH PLACES, ALL OF THEM: when the request asks for screenshots, or the change is visual, every capture is attached here with attach_file AND published on the pull request — commit the images to an assets branch (never the PR's own diff) and reference them from the description's validation section or a PR comment so they render inline there too — unless the request names one destination. Never attach a subset and link the rest.
178
+ Text stays in your message; do not attach what you can say.`;
179
+
173
180
  const CODING_SYSTEM = `You are Switchboard's coding agent, operating from a Slack request.
174
181
 
175
182
  You work inside a dedicated workspace directory with bash, read_file, and write_file tools. ${SANDBOX_TOOLCHAIN}
@@ -197,6 +204,8 @@ ${UNIT_HANDOFF}
197
204
 
198
205
  ${PR_DESCRIPTION_TEMPLATE}
199
206
 
207
+ ${SHOW_FILES}
208
+
200
209
  Maintain the user-facing status card with the update_status tool: right after you decide your plan, post it as a checklist (○ pending items), then update it whenever an item starts (✱) or finishes (✓). Items are short outcomes ("Clone repo and read the diff", "Run the test suite"), never commands. Mark an item ✓ only after it has actually happened — never pre-mark reporting/posting steps. This is the only progress the user sees while you work.
201
210
 
202
211
  If the request doesn't name a repository and you can't infer it, ask for it instead of guessing.
@@ -235,6 +244,8 @@ ${UNIT_HANDOFF}
235
244
 
236
245
  ${PR_DESCRIPTION_TEMPLATE}
237
246
 
247
+ ${SHOW_FILES}
248
+
238
249
  Maintain the user-facing status card with the update_status tool: right after you decide your plan, post it as a checklist (○ pending items), then update it whenever an item starts (✱) or finishes (✓). Items are short outcomes ("Implement the fix", "Run the test suite"), never commands. Mark an item ✓ only after it has actually happened — never pre-mark reporting/posting steps. This is the only progress the user sees while you work.
239
250
 
240
251
  Report outcomes faithfully: if tests fail or a step was skipped, say so plainly.
@@ -0,0 +1,58 @@
1
+ // A binary read on the Executor seam (docs/reference/specs/execution.md item 19):
2
+ // the whole file as bytes, for a tool that hands a workspace artifact — a
3
+ // screenshot, a PDF — to somewhere that needs the bytes, not a text view. Both
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.
9
+
10
+ /** The most bytes one `readBytes` hands over. A binary cannot be truncated
11
+ * the way text output is, so a larger file is refused by name, never trimmed.
12
+ * 10 MiB covers a full-page 2× screenshot or a PDF several times over while
13
+ * keeping one read well inside a Worker isolate's memory. */
14
+ export const MAX_READ_BYTES = 10 * 1024 * 1024;
15
+
16
+ /** The base64 length a `MAX_READ_BYTES` file encodes to (four chars per three
17
+ * bytes, padded) — the Worker-side output cap for a base64 read. */
18
+ export const MAX_READ_BASE64_CHARS = Math.ceil(MAX_READ_BYTES / 3) * 4;
19
+
20
+ export type ReadEncoding = "utf8" | "base64";
21
+
22
+ /** The encoding a `/read` body asks for. Absent is the body every pre-binary
23
+ * client sends — a text read, exactly as before; `"base64"` asks for bytes;
24
+ * anything else is refused by name rather than silently read as text. */
25
+ export function readEncodingOf(body: Record<string, unknown>): ReadEncoding | { error: string } {
26
+ if (body.encoding === undefined) return "utf8";
27
+ if (body.encoding === "base64") return "base64";
28
+ return { error: `encoding must be "base64" or absent, got ${JSON.stringify(body.encoding)}` };
29
+ }
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}`;
36
+ }
37
+
38
+ /** How many bytes a base64 string decodes to, padding discounted. */
39
+ export function base64ByteLength(b64: string): number {
40
+ const trimmed = b64.replace(/\s+/g, "");
41
+ if (trimmed.length === 0) return 0;
42
+ const padding = trimmed.endsWith("==") ? 2 : trimmed.endsWith("=") ? 1 : 0;
43
+ return Math.floor((trimmed.length * 3) / 4) - padding;
44
+ }
45
+
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 };
51
+
52
+ /** One message for a file over the cap, for every implementation. `bytes` is
53
+ * the size when the reader could measure it; a resident sees only that its
54
+ * capped base64 stream overflowed. */
55
+ export function tooLargeMessage(path: string, bytes?: number): string {
56
+ const size = bytes === undefined ? "" : ` (${bytes} bytes)`;
57
+ return `${path}${size} is over the ${MAX_READ_BYTES}-byte cap of a binary read`;
58
+ }
package/dist/cli.js CHANGED
@@ -1573,7 +1573,7 @@ function getAgent(name) {
1573
1573
  }
1574
1574
  return a;
1575
1575
  }
1576
- var MACHINE_CLASSES, IDENTITIES, RUNAWAY_TURNS_PER_MINUTE, PR_DESCRIPTION_TEMPLATE, NEVER_MERGE, CONTRACT_HEADINGS_LIST, UNIT_CONTRACT, UNIT_HANDOFF, IMAGE_TOOLCHAIN, SANDBOX_TOOLCHAIN, RESIDENT_TOOLCHAIN, CODING_SYSTEM, CODING_SYSTEM_RESIDENT, REVIEW_VERDICT_INSTRUCTION, REVIEW_SPEC_CHECK, REVIEW_UNIT_CONTRACT, REVIEW_WHOLE_CHANGE, REVIEW_SYSTEM, REVIEW_SYSTEM_RESIDENT, RESEARCH_SYSTEM, GENERAL_SYSTEM, EXPLORE_SYSTEM, CONDUCTOR_SYSTEM, AGENTS;
1576
+ var MACHINE_CLASSES, IDENTITIES, RUNAWAY_TURNS_PER_MINUTE, PR_DESCRIPTION_TEMPLATE, NEVER_MERGE, CONTRACT_HEADINGS_LIST, UNIT_CONTRACT, UNIT_HANDOFF, IMAGE_TOOLCHAIN, SANDBOX_TOOLCHAIN, RESIDENT_TOOLCHAIN, SHOW_FILES, CODING_SYSTEM, CODING_SYSTEM_RESIDENT, REVIEW_VERDICT_INSTRUCTION, REVIEW_SPEC_CHECK, REVIEW_UNIT_CONTRACT, REVIEW_WHOLE_CHANGE, REVIEW_SYSTEM, REVIEW_SYSTEM_RESIDENT, RESEARCH_SYSTEM, GENERAL_SYSTEM, EXPLORE_SYSTEM, CONDUCTOR_SYSTEM, AGENTS;
1577
1577
  var init_registry = __esm({
1578
1578
  "../../src/agents/registry.ts"() {
1579
1579
  "use strict";
@@ -1598,6 +1598,9 @@ EVERY PR includes one that already exists when you push \u2014 opened by a perso
1598
1598
  IMAGE_TOOLCHAIN = `Node 24 with npm and pnpm; python3, make and g++ (native modules build); ffmpeg (frames out of a video \u2014 \`ffmpeg -i in.mp4 -vf fps=1 f_%03d.png\` \u2014 and video out of frames or a recording); and a headless Chromium through Playwright \u2014 \`playwright screenshot <url> out.png\`, \`playwright pdf <url> out.pdf\`, or \`require('playwright')\` for a scripted page and \`recordVideo\``;
1599
1599
  SANDBOX_TOOLCHAIN = `The sandbox image carries ${IMAGE_TOOLCHAIN}; and Docker (the engine starts on the first \`docker\` call).`;
1600
1600
  RESIDENT_TOOLCHAIN = `The resident image carries ${IMAGE_TOOLCHAIN}; plus yarn and bun \u2014 and no Docker.`;
1601
+ SHOW_FILES = `Files the person should SEE go through the attach_file tool: a screenshot from \`playwright screenshot\`, a rendered PDF, a recording \u2014 it posts the workspace file into this conversation, where an image renders inline. Use it whenever you produce an image worth showing (a visual change, a rendered page, a before/after); a link to a file on GitHub is not a picture.
1602
+ SCREENSHOTS GO TO BOTH PLACES, ALL OF THEM: when the request asks for screenshots, or the change is visual, every capture is attached here with attach_file AND published on the pull request \u2014 commit the images to an assets branch (never the PR's own diff) and reference them from the description's validation section or a PR comment so they render inline there too \u2014 unless the request names one destination. Never attach a subset and link the rest.
1603
+ Text stays in your message; do not attach what you can say.`;
1601
1604
  CODING_SYSTEM = `You are Switchboard's coding agent, operating from a Slack request.
1602
1605
 
1603
1606
  You work inside a dedicated workspace directory with bash, read_file, and write_file tools. ${SANDBOX_TOOLCHAIN}
@@ -1625,6 +1628,8 @@ ${UNIT_HANDOFF}
1625
1628
 
1626
1629
  ${PR_DESCRIPTION_TEMPLATE}
1627
1630
 
1631
+ ${SHOW_FILES}
1632
+
1628
1633
  Maintain the user-facing status card with the update_status tool: right after you decide your plan, post it as a checklist (\u25CB pending items), then update it whenever an item starts (\u2731) or finishes (\u2713). Items are short outcomes ("Clone repo and read the diff", "Run the test suite"), never commands. Mark an item \u2713 only after it has actually happened \u2014 never pre-mark reporting/posting steps. This is the only progress the user sees while you work.
1629
1634
 
1630
1635
  If the request doesn't name a repository and you can't infer it, ask for it instead of guessing.
@@ -1657,6 +1662,8 @@ ${UNIT_HANDOFF}
1657
1662
 
1658
1663
  ${PR_DESCRIPTION_TEMPLATE}
1659
1664
 
1665
+ ${SHOW_FILES}
1666
+
1660
1667
  Maintain the user-facing status card with the update_status tool: right after you decide your plan, post it as a checklist (\u25CB pending items), then update it whenever an item starts (\u2731) or finishes (\u2713). Items are short outcomes ("Implement the fix", "Run the test suite"), never commands. Mark an item \u2713 only after it has actually happened \u2014 never pre-mark reporting/posting steps. This is the only progress the user sees while you work.
1661
1668
 
1662
1669
  Report outcomes faithfully: if tests fail or a step was skipped, say so plainly.
@@ -7849,9 +7856,23 @@ var init_host3 = __esm({
7849
7856
  }
7850
7857
  });
7851
7858
 
7859
+ // ../../src/execution/binaryRead.ts
7860
+ function tooLargeMessage(path, bytes) {
7861
+ const size = bytes === void 0 ? "" : ` (${bytes} bytes)`;
7862
+ return `${path}${size} is over the ${MAX_READ_BYTES}-byte cap of a binary read`;
7863
+ }
7864
+ var MAX_READ_BYTES, MAX_READ_BASE64_CHARS;
7865
+ var init_binaryRead = __esm({
7866
+ "../../src/execution/binaryRead.ts"() {
7867
+ "use strict";
7868
+ MAX_READ_BYTES = 10 * 1024 * 1024;
7869
+ MAX_READ_BASE64_CHARS = Math.ceil(MAX_READ_BYTES / 3) * 4;
7870
+ }
7871
+ });
7872
+
7852
7873
  // ../../src/execution/executor.ts
7853
7874
  import { execFile } from "node:child_process";
7854
- import { existsSync as existsSync8, mkdirSync as mkdirSync5, readFileSync as readFileSync9, writeFileSync as writeFileSync5 } from "node:fs";
7875
+ import { closeSync, existsSync as existsSync8, fstatSync, mkdirSync as mkdirSync5, openSync, readFileSync as readFileSync9, writeFileSync as writeFileSync5 } from "node:fs";
7855
7876
  import { dirname as dirname6, resolve as resolve6 } from "node:path";
7856
7877
  function execDeadline(timeoutMs, signal) {
7857
7878
  const deadline = new AbortController();
@@ -7869,6 +7890,15 @@ function truncate(s) {
7869
7890
  return s.length > MAX_OUTPUT ? s.slice(0, MAX_OUTPUT) + `
7870
7891
  ...[truncated ${s.length - MAX_OUTPUT} chars]` : s;
7871
7892
  }
7893
+ function decodeBase64Read(answer, at) {
7894
+ if (answer.encoding !== "base64") {
7895
+ throw new Error(
7896
+ `${at.where}: the Worker answered a text read to a request for bytes \u2014 it predates binary reads; redeploy it`
7897
+ );
7898
+ }
7899
+ if (answer.tooLarge === true) throw new Error(tooLargeMessage(at.path));
7900
+ return new Uint8Array(Buffer.from(typeof answer.content === "string" ? answer.content : "", "base64"));
7901
+ }
7872
7902
  async function runLocalCommand(command2, cwd) {
7873
7903
  const r = await runBash(command2, cwd);
7874
7904
  const output = [r.stdout, r.stderr].filter(Boolean).join("\n--- stderr ---\n");
@@ -7902,6 +7932,7 @@ var init_executor = __esm({
7902
7932
  "../../src/execution/executor.ts"() {
7903
7933
  "use strict";
7904
7934
  init_bashTimeout();
7935
+ init_binaryRead();
7905
7936
  init_clock();
7906
7937
  init_bashTimeout();
7907
7938
  ExecInfraError = class extends Error {
@@ -7921,6 +7952,8 @@ var init_executor = __esm({
7921
7952
  ExecHealthTracker = class {
7922
7953
  constructor(inner) {
7923
7954
  this.inner = inner;
7955
+ const innerReadBytes = inner.readBytes?.bind(inner);
7956
+ if (innerReadBytes) this.readBytes = (path, opts) => this.track(() => innerReadBytes(path, opts));
7924
7957
  }
7925
7958
  inner;
7926
7959
  consecutiveInfraFailures = 0;
@@ -7928,6 +7961,9 @@ var init_executor = __esm({
7928
7961
  * evidence the runner's abort diagnosis quotes instead of guessing a cause.
7929
7962
  * Cleared by a successful op along with the count. */
7930
7963
  lastInfraError;
7964
+ /** Present exactly when the inner executor reads bytes — a decorator that
7965
+ * always offered it would promise what the transport cannot do. */
7966
+ readBytes;
7931
7967
  async track(op) {
7932
7968
  try {
7933
7969
  const out = await op();
@@ -7982,6 +8018,18 @@ ${parts}`);
7982
8018
  async readFile(path) {
7983
8019
  return truncate(readFileSync9(this.confine(path), "utf8"));
7984
8020
  }
8021
+ /** The size is checked on the open handle and the bytes read from the same
8022
+ * handle, so a file that grows between the two calls cannot slip past the cap. */
8023
+ async readBytes(path) {
8024
+ const fd = openSync(this.confine(path), "r");
8025
+ try {
8026
+ const size = fstatSync(fd).size;
8027
+ if (size > MAX_READ_BYTES) throw new Error(tooLargeMessage(path, size));
8028
+ return new Uint8Array(readFileSync9(fd));
8029
+ } finally {
8030
+ closeSync(fd);
8031
+ }
8032
+ }
7985
8033
  async writeFile(path, content) {
7986
8034
  const abs = this.confine(path);
7987
8035
  mkdirSync5(dirname6(abs), { recursive: true });
@@ -8476,6 +8524,13 @@ ${parts}`);
8476
8524
  const r = await this.call("/read", { path }, void 0, void 0, opts?.span);
8477
8525
  return truncate(String(r.content ?? ""));
8478
8526
  }
8527
+ /** The same `/read` route asked for `encoding: "base64"` (src/execution/binaryRead.ts);
8528
+ * the Worker refuses an over-cap file by name, and one that predates
8529
+ * binary reads answers text, which `decodeBase64Read` names instead of decoding. */
8530
+ async readBytes(path, opts) {
8531
+ const r = await this.call("/read", { path, encoding: "base64" }, void 0, void 0, opts?.span);
8532
+ return decodeBase64Read(r, { where: "sandbox worker /read", path });
8533
+ }
8479
8534
  async writeFile(path, content, opts) {
8480
8535
  await this.call("/write", { path, content }, void 0, void 0, opts?.span);
8481
8536
  return `Wrote ${path}`;
@@ -9045,6 +9100,26 @@ ${parts}`);
9045
9100
  }
9046
9101
  return truncate(String(data.content ?? ""));
9047
9102
  }
9103
+ /** The same `/read` route asked for `encoding: "base64"` (src/execution/binaryRead.ts),
9104
+ * with the same re-attach on an evicted worktree; a missing file is the
9105
+ * route's 404 as for `readFile`, an over-cap file and a Worker that predates
9106
+ * binary reads are `decodeBase64Read`'s plain errors. */
9107
+ async readBytes(path, opts) {
9108
+ const { status: status3, data } = await this.opWithReattach(
9109
+ "/read",
9110
+ { path, encoding: "base64" },
9111
+ void 0,
9112
+ void 0,
9113
+ opts?.span
9114
+ );
9115
+ if (status3 !== 200) {
9116
+ throw classifyError(new ExecInfraError(`resident /read: ${String(data.error ?? `HTTP ${status3}`)}`), {
9117
+ kind: "http",
9118
+ code: String(status3)
9119
+ });
9120
+ }
9121
+ return decodeBase64Read(data, { where: "resident /read", path });
9122
+ }
9048
9123
  async writeFile(path, content, opts) {
9049
9124
  const { status: status3, data } = await this.opWithReattach("/write", { path, content }, void 0, void 0, opts?.span);
9050
9125
  if (status3 !== 200) {
@@ -23791,12 +23866,16 @@ var init_tracingExecutor = __esm({
23791
23866
  if (innerRelease) this.release = (mode) => this.timed("exec.release", (s) => innerRelease(mode, { span: s }));
23792
23867
  const innerMoveTo = inner.moveTo?.bind(inner);
23793
23868
  if (innerMoveTo) this.moveTo = (sha) => this.timed("exec.move_to", (s) => innerMoveTo(sha, { span: s }));
23869
+ const innerReadBytes = inner.readBytes?.bind(inner);
23870
+ if (innerReadBytes)
23871
+ this.readBytes = (path) => this.timed("exec.read_bytes", (s) => innerReadBytes(path, { span: s }));
23794
23872
  }
23795
23873
  inner;
23796
23874
  span;
23797
23875
  backend;
23798
23876
  release;
23799
23877
  moveTo;
23878
+ readBytes;
23800
23879
  /** Each op under its own `exec.*` span, handed to the inner executor as
23801
23880
  * `opts.span` so its HTTP calls become `http.client` children (item 21). */
23802
23881
  timed(name, fn, extra = {}) {
@@ -24796,6 +24875,60 @@ ${skill.body}${footer}`;
24796
24875
  }
24797
24876
  });
24798
24877
 
24878
+ // ../../src/tools/attach.ts
24879
+ function fileNameOf2(path) {
24880
+ const segments2 = path.split("/").filter(Boolean);
24881
+ return segments2[segments2.length - 1] ?? path;
24882
+ }
24883
+ var attachFileTool;
24884
+ var init_attach = __esm({
24885
+ "../../src/tools/attach.ts"() {
24886
+ "use strict";
24887
+ init_binaryRead();
24888
+ attachFileTool = {
24889
+ name: "attach_file",
24890
+ description: `Post a file from the workspace into the conversation so the person sees it inline \u2014 a screenshot (e.g. from \`playwright screenshot\`), a rendered PDF, a recording, a log. Use it whenever you produce an image worth showing: a link to a file is not a picture. Whole files only, up to ${MAX_READ_BYTES} bytes.`,
24891
+ inputSchema: {
24892
+ type: "object",
24893
+ properties: {
24894
+ path: { type: "string", description: "Relative path of the file in the workspace" },
24895
+ comment: {
24896
+ type: "string",
24897
+ description: "One line posted with the file saying what it shows (default: the file name)"
24898
+ }
24899
+ },
24900
+ required: ["path"]
24901
+ },
24902
+ async run(input, ctx) {
24903
+ const path = String(input.path ?? "").trim();
24904
+ if (!path) return "error: path is required";
24905
+ const name = fileNameOf2(path);
24906
+ const lead = typeof input.comment === "string" && input.comment.trim() ? input.comment.trim() : name;
24907
+ if (!ctx.attach) {
24908
+ return "attach_file is not available here: this conversation's channel takes no file uploads \u2014 link to the file instead";
24909
+ }
24910
+ const readBytes = ctx.executor.readBytes?.bind(ctx.executor);
24911
+ if (!readBytes) {
24912
+ return "attach_file is not available here: this workspace cannot hand files over \u2014 link to the file instead";
24913
+ }
24914
+ let bytes;
24915
+ try {
24916
+ bytes = await readBytes(path);
24917
+ } catch (err2) {
24918
+ return `error: could not read ${path}: ${err2 instanceof Error ? err2.message : String(err2)}`;
24919
+ }
24920
+ if (bytes.byteLength === 0) return `error: ${path} is empty \u2014 nothing to attach`;
24921
+ try {
24922
+ await ctx.attach({ name, bytes, lead });
24923
+ } catch (err2) {
24924
+ return `error: the channel refused the upload of ${name}: ${err2 instanceof Error ? err2.message : String(err2)}`;
24925
+ }
24926
+ return `attached ${name} (${bytes.byteLength} bytes) to the conversation`;
24927
+ }
24928
+ };
24929
+ }
24930
+ });
24931
+
24799
24932
  // ../../src/core/dispatch/awaitChildren.ts
24800
24933
  function decideWait(inputs) {
24801
24934
  const { children, now, budgetEndsAt, timeoutAt, stop, followUpPending } = inputs;
@@ -24904,6 +25037,7 @@ function watchedChild(io, on) {
24904
25037
  }
24905
25038
  };
24906
25039
  if (io.attach) watched2.attach = (file) => io.attach(file);
25040
+ if (io.attachFile) watched2.attachFile = (file) => io.attachFile(file);
24907
25041
  if (io.runFinished) watched2.runFinished = (receipt) => io.runFinished(receipt);
24908
25042
  if (io.openThread) watched2.openThread = (lead) => io.openThread(lead);
24909
25043
  return watched2;
@@ -25433,6 +25567,7 @@ var init_workspace = __esm({
25433
25567
  init_web();
25434
25568
  init_github();
25435
25569
  init_skills();
25570
+ init_attach();
25436
25571
  init_runs2();
25437
25572
  bashTool = {
25438
25573
  name: "bash",
@@ -25801,6 +25936,7 @@ ${raw.trim()}`;
25801
25936
  bashTool,
25802
25937
  readFileTool,
25803
25938
  writeFileTool,
25939
+ attachFileTool,
25804
25940
  updateStatusTool,
25805
25941
  submitPrDescriptionTool,
25806
25942
  submitHandoffTool,
@@ -28893,9 +29029,11 @@ async function runLoop2(deps, ctx) {
28893
29029
  let runFailed = false;
28894
29030
  let runDiagnosis;
28895
29031
  const releaseWorkspace = (span) => round2.release({ hardStopped: run2.control.requested === "hard", ...span ? { span } : {} });
29032
+ const attachFile = io.attachFile?.bind(io);
28896
29033
  const toolContext = {
28897
29034
  executor,
28898
29035
  reportProgress,
29036
+ ...attachFile ? { attach: attachFile } : {},
28899
29037
  web: webCapability(),
28900
29038
  skills: deps.skills,
28901
29039
  github: githubCapabilityFor(deps, msg.userId),
@@ -33616,6 +33754,20 @@ var init_slack = __esm({
33616
33754
  ${file.text}`));
33617
33755
  }
33618
33756
  }
33757
+ /** A run's binary artifact in the thread — a screenshot renders inline, a
33758
+ * PDF as a preview — through the same `files.uploadV2` with the bytes as
33759
+ * the file. No fallback: a text reply cannot carry bytes, so a failed upload
33760
+ * propagates for the caller to report. */
33761
+ async attachFile(file) {
33762
+ await this.client.files.uploadV2({
33763
+ channel_id: this.ev.channel,
33764
+ thread_ts: this.ev.threadTs,
33765
+ filename: file.name,
33766
+ title: file.name,
33767
+ file: Buffer.from(file.bytes),
33768
+ initial_comment: mdToMrkdwn(file.lead)
33769
+ });
33770
+ }
33619
33771
  async post(mrkdwn) {
33620
33772
  for (const chunk of chunkText(mrkdwn, SLACK_MSG_LIMIT)) {
33621
33773
  await this.client.chat.postMessage({
@@ -37667,6 +37819,7 @@ function watched(io, on) {
37667
37819
  }
37668
37820
  };
37669
37821
  if (io.attach) out.attach = (file) => io.attach(file);
37822
+ if (io.attachFile) out.attachFile = (file) => io.attachFile(file);
37670
37823
  if (io.runFinished) out.runFinished = (receipt) => io.runFinished(receipt);
37671
37824
  if (io.openThread) out.openThread = (lead) => io.openThread(lead);
37672
37825
  return out;
@@ -39383,8 +39536,9 @@ init_source();
39383
39536
  init_invokedAsScript();
39384
39537
  init_secrets2();
39385
39538
  import { Console } from "node:console";
39386
- import { existsSync as existsSync14 } from "node:fs";
39387
- import { join as join15 } from "node:path";
39539
+ import { existsSync as existsSync14, mkdtempSync, writeFileSync as writeFileSync9 } from "node:fs";
39540
+ import { tmpdir } from "node:os";
39541
+ import { basename as basename3, join as join15 } from "node:path";
39388
39542
  var CONFIG_PATH3 = process.env.SWITCHBOARD_CONFIG ?? installationPath(OPERATOR_ROOT, "config/config.yaml");
39389
39543
  var DATA_DIR2 = installationPath(OPERATOR_ROOT, "data");
39390
39544
  var CLI_CALLER = { kind: "cli", id: CLI_ACTOR.id, actor: CLI_ACTOR };
@@ -39536,14 +39690,16 @@ ${cliCatalogue(commands)}`, stderr: "" };
39536
39690
  }
39537
39691
  }
39538
39692
  var ConsoleIO = class _ConsoleIO {
39539
- constructor(out = process.stdout, threadKey = "cli", prefix = "") {
39693
+ constructor(out = process.stdout, threadKey = "cli", prefix = "", attachments = {}) {
39540
39694
  this.out = out;
39541
39695
  this.threadKey = threadKey;
39542
39696
  this.prefix = prefix;
39697
+ this.attachments = attachments;
39543
39698
  }
39544
39699
  out;
39545
39700
  threadKey;
39546
39701
  prefix;
39702
+ attachments;
39547
39703
  /** The receipt of the run this request started, once it finished — undefined
39548
39704
  * before that, and forever when no run was started (a config reply such as
39549
39705
  * `help`, a refusal before a run existed). */
@@ -39553,6 +39709,15 @@ var ConsoleIO = class _ConsoleIO {
39553
39709
  async reply(text) {
39554
39710
  this.out.write("\n" + this.prefix + text + "\n");
39555
39711
  }
39712
+ /** The harness's file upload: the bytes written under the attachments dir,
39713
+ * the lead printed like a reply with the path a person can open. */
39714
+ async attachFile(file) {
39715
+ this.attachments.dir ??= mkdtempSync(join15(tmpdir(), "switchboard-attachments-"));
39716
+ const path = join15(this.attachments.dir, basename3(file.name));
39717
+ writeFileSync9(path, file.bytes);
39718
+ await this.reply(`${file.lead}
39719
+ \u{1F4CE} ${file.name} (${file.bytes.byteLength} bytes) \u2192 ${path}`);
39720
+ }
39556
39721
  runFinished(receipt) {
39557
39722
  this.finished = receipt;
39558
39723
  }
@@ -39575,7 +39740,10 @@ var ConsoleIO = class _ConsoleIO {
39575
39740
  const n2 = ++this.children;
39576
39741
  await this.reply(lead);
39577
39742
  const threadKey = `${this.threadKey}/child-${n2}`;
39578
- return { thread: { threadKey }, io: new _ConsoleIO(this.out, threadKey, `${this.prefix}[child-${n2}] `) };
39743
+ return {
39744
+ thread: { threadKey },
39745
+ io: new _ConsoleIO(this.out, threadKey, `${this.prefix}[child-${n2}] `, this.attachments)
39746
+ };
39579
39747
  }
39580
39748
  };
39581
39749
  function askExitCode(finished) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coreplane/switchboard",
3
- "version": "1.209.0",
3
+ "version": "1.210.0",
4
4
  "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.",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://openswitchboard.dev",