@coreplane/switchboard 1.208.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.208.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.208.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.208.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.208.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.208.0",
3
- "commit": "348d6f6ebbf7cf242f39951c0a81585527f49181",
4
- "builtAt": "2026-09-14T03:37:32.458Z"
2
+ "version": "1.210.0",
3
+ "commit": "1c1f94ae5c58bff0f3734e2b522b2ab440baa89d",
4
+ "builtAt": "2026-09-14T05:52:14.303Z"
5
5
  }
@@ -1,8 +1,8 @@
1
- // Agent definitions. An agent is a system prompt + toolset + machine class + turn budget.
1
+ // Agent definitions. An agent is a system prompt + toolset + machine class + wall-clock budget.
2
2
  import type { Effort } from "../effort.js";
3
3
  import type { CacheTtl } from "../providers/types.js";
4
4
  import { BASH_TIMEOUT_MAX_MS } from "../execution/bashTimeout.js";
5
- import { CONTRACT_HEADING, CONTRACT_SECTION_HEADINGS } from "../core/ship/contract.js";
5
+ import { CONTRACT_HEADING, CONTRACT_SECTION_HEADINGS, PR_TITLE_GUARD } from "../core/ship/contract.js";
6
6
  // Which model runs it is resolved separately by the config layers, so any
7
7
  // agent can run on any configured provider/model.
8
8
 
@@ -42,13 +42,37 @@ export function machineNeedsRepo(machine: MachineClass): boolean {
42
42
  export const IDENTITIES = ["none", "read", "write"] as const;
43
43
  export type Identity = (typeof IDENTITIES)[number];
44
44
 
45
+ /** The pace that marks a run as looping rather than working: a model turn
46
+ * every ten seconds, sustained for the whole wall clock. A busy run takes
47
+ * 20–40 s a turn (a model think plus a tool call), so a run that averages six
48
+ * a minute from start to end is re-issuing calls, not making progress — and
49
+ * its turn cap ends it before the wall clock would, with a write-up that
50
+ * says so (docs/reference/specs/run-loop.md item 1). */
51
+ export const RUNAWAY_TURNS_PER_MINUTE = 6;
52
+
53
+ /** The turn cap a wall clock implies: `maxMinutes × RUNAWAY_TURNS_PER_MINUTE`.
54
+ * Every preset that runs the loop derives its `maxTurns` from this, so the
55
+ * cap is never a number a good run reaches — the minutes are the budget. */
56
+ export function runawayTurnCap(maxMinutes: number): number {
57
+ return maxMinutes * RUNAWAY_TURNS_PER_MINUTE;
58
+ }
59
+
60
+ /** A loop-running preset's budget as one fact: the wall clock, and the runaway
61
+ * guard derived from it. */
62
+ function loopBudget(maxMinutes: number): Pick<AgentDef, "maxMinutes" | "maxTurns"> {
63
+ return { maxMinutes, maxTurns: runawayTurnCap(maxMinutes) };
64
+ }
65
+
45
66
  export interface AgentDef {
46
67
  name: string;
47
68
  description: string;
48
69
  system: string;
49
70
  /** key into TOOLSETS: "full" | "readonly" | "web" | "assistant" | "explore" | "conductor" | "none" */
50
71
  toolset: "full" | "readonly" | "web" | "assistant" | "explore" | "conductor" | "none";
51
- /** backstop only — the wall clock below is the real budget */
72
+ /** The runaway guard, not a budget: `runawayTurnCap(maxMinutes)` for every
73
+ * preset that runs the loop (`loopBudget`). The wall clock below is the
74
+ * budget; a run that reaches this cap first was pacing like a loop, and its
75
+ * write-up says so. The proxy refuses model calls past it too. */
52
76
  maxTurns: number;
53
77
  maxTokens: number;
54
78
  /** hard wall-clock budget for the tool loop; at the deadline the agent is
@@ -97,7 +121,7 @@ export interface AgentDef {
97
121
  // and rendered against the head sha at render time, so a repush is a
98
122
  // re-render by Switchboard — the agent only resubmits when the CONTENT (line
99
123
  // numbers included) changed.
100
- const PR_DESCRIPTION_TEMPLATE = `PR description — submit it with the submit_pr_description tool for EVERY PR (this is the default, not something to wait to be asked for). Switchboard renders the GitHub body from the object you submit, so never author PR-body markdown yourself. Content contract per field (each renders as its own section): prose is unwrapped — no hard line breaks inside a paragraph. Always hyperlink the triggering issue/request. Never fabricate validation — state exactly what you ran and the real result. Keep each field concise, not padded.
124
+ const PR_DESCRIPTION_TEMPLATE = `PR description — submit it with the submit_pr_description tool for EVERY PR (this is the default, not something to wait to be asked for). Switchboard renders the GitHub body from the object you submit, so never author PR-body markdown yourself. Before submitting, judge your title with the ${PR_TITLE_GUARD} gate — \`npm run check:pr-title -- "<title>"\` — and submit only a title it accepts; the same gate refuses the PR in CI. Content contract per field (each renders as its own section): prose is unwrapped — no hard line breaks inside a paragraph. Always hyperlink the triggering issue/request. Never fabricate validation — state exactly what you ran and the real result. Keep each field concise, not padded.
101
125
  EVERY PR includes one that already exists when you push — opened by a person, by dependabot, or by an earlier run. After EVERY push to such a PR: read its current title and body (\`github_issue_get\` with the PR number works for pull requests; \`gh pr view\` where gh exists), judge them against the change as it now stands at the pushed head, and submit the object that describes the PR as it is NOW — carry forward what the existing body says that is still true (a dependency bump's release notes belong in whatWhy), add what you changed, and anchor the Tour at the new head. Switchboard replaces the PR's title and body with your rendering. A description that describes an earlier state of its branch is a bug; "it is someone else's PR" is never a reason to leave it.
102
126
  - **title**: the PR title — one line naming the change, specific enough to pick out of a PR list.
103
127
  - **TL;DR** (\`tldr\`, rendered first): two sentences for a naive reader with zero context — what this PR does and why it matters.
@@ -146,6 +170,13 @@ const IMAGE_TOOLCHAIN = `Node 24 with npm and pnpm; python3, make and g++ (nativ
146
170
  const SANDBOX_TOOLCHAIN = `The sandbox image carries ${IMAGE_TOOLCHAIN}; and Docker (the engine starts on the first \`docker\` call).`;
147
171
  const RESIDENT_TOOLCHAIN = `The resident image carries ${IMAGE_TOOLCHAIN}; plus yarn and bun — and no Docker.`;
148
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
+
149
180
  const CODING_SYSTEM = `You are Switchboard's coding agent, operating from a Slack request.
150
181
 
151
182
  You work inside a dedicated workspace directory with bash, read_file, and write_file tools. ${SANDBOX_TOOLCHAIN}
@@ -173,6 +204,8 @@ ${UNIT_HANDOFF}
173
204
 
174
205
  ${PR_DESCRIPTION_TEMPLATE}
175
206
 
207
+ ${SHOW_FILES}
208
+
176
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.
177
210
 
178
211
  If the request doesn't name a repository and you can't infer it, ask for it instead of guessing.
@@ -211,6 +244,8 @@ ${UNIT_HANDOFF}
211
244
 
212
245
  ${PR_DESCRIPTION_TEMPLATE}
213
246
 
247
+ ${SHOW_FILES}
248
+
214
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.
215
250
 
216
251
  Report outcomes faithfully: if tests fail or a step was skipped, say so plainly.
@@ -401,9 +436,8 @@ export const AGENTS: Record<string, AgentDef> = {
401
436
  // and mints no credential of its own.
402
437
  machine: "none",
403
438
  identity: "none",
404
- maxTurns: 8, // a repo read is 2-3 calls (repos → tree → file); an issue action 1-2; still fast
405
439
  maxTokens: 16000,
406
- maxMinutes: 5,
440
+ ...loopBudget(5),
407
441
  },
408
442
  coding: {
409
443
  name: "coding",
@@ -411,9 +445,8 @@ export const AGENTS: Record<string, AgentDef> = {
411
445
  system: CODING_SYSTEM,
412
446
  residentSystem: CODING_SYSTEM_RESIDENT,
413
447
  toolset: "full",
414
- maxTurns: 60, // scoping is capped at ~5 calls by the prompt; this is implementation room
415
448
  maxTokens: 64000,
416
- maxMinutes: 45,
449
+ ...loopBudget(45),
417
450
  // Coding steps run long: a single model turn can take 5-6 minutes and
418
451
  // installs/tests add more — a 5m cache entry would expire between
419
452
  // requests, so the 2× write buys reads for the whole run.
@@ -431,9 +464,8 @@ export const AGENTS: Record<string, AgentDef> = {
431
464
  toolset: "readonly",
432
465
  machine: "repo-resident",
433
466
  identity: "read", // a read-scoped token and a read-only worktree: it cannot post or push from inside
434
- maxTurns: 30, // backstop only; wall clock is the real budget (12 bound at ~4 min in practice)
435
467
  maxTokens: 64000,
436
- maxMinutes: 25, // safety net, not the mechanism — typical reviews land in ~5
468
+ ...loopBudget(25), // a safety net — typical reviews land in ~5 minutes
437
469
  effort: "medium", // fast turns; one big-context pass does the deep work
438
470
  },
439
471
  ship: {
@@ -468,9 +500,8 @@ export const AGENTS: Record<string, AgentDef> = {
468
500
  toolset: "web",
469
501
  machine: "none", // web I/O only; no workspace is provisioned
470
502
  identity: "none",
471
- maxTurns: 12,
472
503
  maxTokens: 24000,
473
- maxMinutes: 8,
504
+ ...loopBudget(8),
474
505
  effort: "medium",
475
506
  },
476
507
  explore: {
@@ -483,9 +514,8 @@ export const AGENTS: Record<string, AgentDef> = {
483
514
  // review depends on: a two-hour job shares no container with anyone.
484
515
  machine: "repo-cold",
485
516
  identity: "read", // a read-scoped token: it can clone and read, never push — whatever the caller holds
486
- maxTurns: 150, // a backstop for a two-hour loop of batched checks; the wall clock is the budget
487
517
  maxTokens: 64000,
488
- maxMinutes: 120,
518
+ ...loopBudget(120),
489
519
  // A detached job polled across calls makes long steps: a 5m cache entry
490
520
  // would expire between them, so the 2× write buys reads for the whole run.
491
521
  cacheTtl: "1h",
@@ -501,9 +531,8 @@ export const AGENTS: Record<string, AgentDef> = {
501
531
  // dispatcher, the GitHub reads are REST in the bot process.
502
532
  machine: "none",
503
533
  identity: "none",
504
- maxTurns: 40, // a spawn, then a poll per child every few minutes; the wall clock is the budget
505
534
  maxTokens: 32000,
506
- maxMinutes: 120, // long enough to outlast a coding child; every child is capped by what remains of it
535
+ ...loopBudget(120), // long enough to outlast a coding child; every child is capped by what remains of it
507
536
  // No built-in effort: the deployment decides, as for coding.
508
537
  },
509
538
  };
@@ -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
@@ -1560,6 +1560,12 @@ var init_contract = __esm({
1560
1560
  function machineNeedsRepo(machine) {
1561
1561
  return machine === "repo-cold" || machine === "repo-resident";
1562
1562
  }
1563
+ function runawayTurnCap(maxMinutes) {
1564
+ return maxMinutes * RUNAWAY_TURNS_PER_MINUTE;
1565
+ }
1566
+ function loopBudget(maxMinutes) {
1567
+ return { maxMinutes, maxTurns: runawayTurnCap(maxMinutes) };
1568
+ }
1563
1569
  function getAgent(name) {
1564
1570
  const a = AGENTS[name];
1565
1571
  if (!a) {
@@ -1567,7 +1573,7 @@ function getAgent(name) {
1567
1573
  }
1568
1574
  return a;
1569
1575
  }
1570
- var MACHINE_CLASSES, IDENTITIES, 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;
1571
1577
  var init_registry = __esm({
1572
1578
  "../../src/agents/registry.ts"() {
1573
1579
  "use strict";
@@ -1575,7 +1581,8 @@ var init_registry = __esm({
1575
1581
  init_contract();
1576
1582
  MACHINE_CLASSES = ["none", "blank", "repo-cold", "repo-resident"];
1577
1583
  IDENTITIES = ["none", "read", "write"];
1578
- PR_DESCRIPTION_TEMPLATE = `PR description \u2014 submit it with the submit_pr_description tool for EVERY PR (this is the default, not something to wait to be asked for). Switchboard renders the GitHub body from the object you submit, so never author PR-body markdown yourself. Content contract per field (each renders as its own section): prose is unwrapped \u2014 no hard line breaks inside a paragraph. Always hyperlink the triggering issue/request. Never fabricate validation \u2014 state exactly what you ran and the real result. Keep each field concise, not padded.
1584
+ RUNAWAY_TURNS_PER_MINUTE = 6;
1585
+ PR_DESCRIPTION_TEMPLATE = `PR description \u2014 submit it with the submit_pr_description tool for EVERY PR (this is the default, not something to wait to be asked for). Switchboard renders the GitHub body from the object you submit, so never author PR-body markdown yourself. Before submitting, judge your title with the ${PR_TITLE_GUARD} gate \u2014 \`npm run check:pr-title -- "<title>"\` \u2014 and submit only a title it accepts; the same gate refuses the PR in CI. Content contract per field (each renders as its own section): prose is unwrapped \u2014 no hard line breaks inside a paragraph. Always hyperlink the triggering issue/request. Never fabricate validation \u2014 state exactly what you ran and the real result. Keep each field concise, not padded.
1579
1586
  EVERY PR includes one that already exists when you push \u2014 opened by a person, by dependabot, or by an earlier run. After EVERY push to such a PR: read its current title and body (\`github_issue_get\` with the PR number works for pull requests; \`gh pr view\` where gh exists), judge them against the change as it now stands at the pushed head, and submit the object that describes the PR as it is NOW \u2014 carry forward what the existing body says that is still true (a dependency bump's release notes belong in whatWhy), add what you changed, and anchor the Tour at the new head. Switchboard replaces the PR's title and body with your rendering. A description that describes an earlier state of its branch is a bug; "it is someone else's PR" is never a reason to leave it.
1580
1587
  - **title**: the PR title \u2014 one line naming the change, specific enough to pick out of a PR list.
1581
1588
  - **TL;DR** (\`tldr\`, rendered first): two sentences for a naive reader with zero context \u2014 what this PR does and why it matters.
@@ -1591,6 +1598,9 @@ EVERY PR includes one that already exists when you push \u2014 opened by a perso
1591
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\``;
1592
1599
  SANDBOX_TOOLCHAIN = `The sandbox image carries ${IMAGE_TOOLCHAIN}; and Docker (the engine starts on the first \`docker\` call).`;
1593
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.`;
1594
1604
  CODING_SYSTEM = `You are Switchboard's coding agent, operating from a Slack request.
1595
1605
 
1596
1606
  You work inside a dedicated workspace directory with bash, read_file, and write_file tools. ${SANDBOX_TOOLCHAIN}
@@ -1618,6 +1628,8 @@ ${UNIT_HANDOFF}
1618
1628
 
1619
1629
  ${PR_DESCRIPTION_TEMPLATE}
1620
1630
 
1631
+ ${SHOW_FILES}
1632
+
1621
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.
1622
1634
 
1623
1635
  If the request doesn't name a repository and you can't infer it, ask for it instead of guessing.
@@ -1650,6 +1662,8 @@ ${UNIT_HANDOFF}
1650
1662
 
1651
1663
  ${PR_DESCRIPTION_TEMPLATE}
1652
1664
 
1665
+ ${SHOW_FILES}
1666
+
1653
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.
1654
1668
 
1655
1669
  Report outcomes faithfully: if tests fail or a step was skipped, say so plainly.
@@ -1766,10 +1780,8 @@ Use Slack-friendly formatting (no markdown headers; *bold*, bullets, code blocks
1766
1780
  // and mints no credential of its own.
1767
1781
  machine: "none",
1768
1782
  identity: "none",
1769
- maxTurns: 8,
1770
- // a repo read is 2-3 calls (repos → tree → file); an issue action 1-2; still fast
1771
1783
  maxTokens: 16e3,
1772
- maxMinutes: 5
1784
+ ...loopBudget(5)
1773
1785
  },
1774
1786
  coding: {
1775
1787
  name: "coding",
@@ -1777,10 +1789,8 @@ Use Slack-friendly formatting (no markdown headers; *bold*, bullets, code blocks
1777
1789
  system: CODING_SYSTEM,
1778
1790
  residentSystem: CODING_SYSTEM_RESIDENT,
1779
1791
  toolset: "full",
1780
- maxTurns: 60,
1781
- // scoping is capped at ~5 calls by the prompt; this is implementation room
1782
1792
  maxTokens: 64e3,
1783
- maxMinutes: 45,
1793
+ ...loopBudget(45),
1784
1794
  // Coding steps run long: a single model turn can take 5-6 minutes and
1785
1795
  // installs/tests add more — a 5m cache entry would expire between
1786
1796
  // requests, so the 2× write buys reads for the whole run.
@@ -1800,11 +1810,9 @@ Use Slack-friendly formatting (no markdown headers; *bold*, bullets, code blocks
1800
1810
  machine: "repo-resident",
1801
1811
  identity: "read",
1802
1812
  // a read-scoped token and a read-only worktree: it cannot post or push from inside
1803
- maxTurns: 30,
1804
- // backstop only; wall clock is the real budget (12 bound at ~4 min in practice)
1805
1813
  maxTokens: 64e3,
1806
- maxMinutes: 25,
1807
- // safety net, not the mechanism — typical reviews land in ~5
1814
+ ...loopBudget(25),
1815
+ // a safety net — typical reviews land in ~5 minutes
1808
1816
  effort: "medium"
1809
1817
  // fast turns; one big-context pass does the deep work
1810
1818
  },
@@ -1838,9 +1846,8 @@ Use Slack-friendly formatting (no markdown headers; *bold*, bullets, code blocks
1838
1846
  machine: "none",
1839
1847
  // web I/O only; no workspace is provisioned
1840
1848
  identity: "none",
1841
- maxTurns: 12,
1842
1849
  maxTokens: 24e3,
1843
- maxMinutes: 8,
1850
+ ...loopBudget(8),
1844
1851
  effort: "medium"
1845
1852
  },
1846
1853
  explore: {
@@ -1853,10 +1860,8 @@ Use Slack-friendly formatting (no markdown headers; *bold*, bullets, code blocks
1853
1860
  machine: "repo-cold",
1854
1861
  identity: "read",
1855
1862
  // a read-scoped token: it can clone and read, never push — whatever the caller holds
1856
- maxTurns: 150,
1857
- // a backstop for a two-hour loop of batched checks; the wall clock is the budget
1858
1863
  maxTokens: 64e3,
1859
- maxMinutes: 120,
1864
+ ...loopBudget(120),
1860
1865
  // A detached job polled across calls makes long steps: a 5m cache entry
1861
1866
  // would expire between them, so the 2× write buys reads for the whole run.
1862
1867
  cacheTtl: "1h"
@@ -1871,10 +1876,8 @@ Use Slack-friendly formatting (no markdown headers; *bold*, bullets, code blocks
1871
1876
  // dispatcher, the GitHub reads are REST in the bot process.
1872
1877
  machine: "none",
1873
1878
  identity: "none",
1874
- maxTurns: 40,
1875
- // a spawn, then a poll per child every few minutes; the wall clock is the budget
1876
1879
  maxTokens: 32e3,
1877
- maxMinutes: 120
1880
+ ...loopBudget(120)
1878
1881
  // long enough to outlast a coding child; every child is capped by what remains of it
1879
1882
  // No built-in effort: the deployment decides, as for coding.
1880
1883
  }
@@ -7853,9 +7856,23 @@ var init_host3 = __esm({
7853
7856
  }
7854
7857
  });
7855
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
+
7856
7873
  // ../../src/execution/executor.ts
7857
7874
  import { execFile } from "node:child_process";
7858
- 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";
7859
7876
  import { dirname as dirname6, resolve as resolve6 } from "node:path";
7860
7877
  function execDeadline(timeoutMs, signal) {
7861
7878
  const deadline = new AbortController();
@@ -7873,6 +7890,15 @@ function truncate(s) {
7873
7890
  return s.length > MAX_OUTPUT ? s.slice(0, MAX_OUTPUT) + `
7874
7891
  ...[truncated ${s.length - MAX_OUTPUT} chars]` : s;
7875
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
+ }
7876
7902
  async function runLocalCommand(command2, cwd) {
7877
7903
  const r = await runBash(command2, cwd);
7878
7904
  const output = [r.stdout, r.stderr].filter(Boolean).join("\n--- stderr ---\n");
@@ -7906,6 +7932,7 @@ var init_executor = __esm({
7906
7932
  "../../src/execution/executor.ts"() {
7907
7933
  "use strict";
7908
7934
  init_bashTimeout();
7935
+ init_binaryRead();
7909
7936
  init_clock();
7910
7937
  init_bashTimeout();
7911
7938
  ExecInfraError = class extends Error {
@@ -7925,6 +7952,8 @@ var init_executor = __esm({
7925
7952
  ExecHealthTracker = class {
7926
7953
  constructor(inner) {
7927
7954
  this.inner = inner;
7955
+ const innerReadBytes = inner.readBytes?.bind(inner);
7956
+ if (innerReadBytes) this.readBytes = (path, opts) => this.track(() => innerReadBytes(path, opts));
7928
7957
  }
7929
7958
  inner;
7930
7959
  consecutiveInfraFailures = 0;
@@ -7932,6 +7961,9 @@ var init_executor = __esm({
7932
7961
  * evidence the runner's abort diagnosis quotes instead of guessing a cause.
7933
7962
  * Cleared by a successful op along with the count. */
7934
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;
7935
7967
  async track(op) {
7936
7968
  try {
7937
7969
  const out = await op();
@@ -7986,6 +8018,18 @@ ${parts}`);
7986
8018
  async readFile(path) {
7987
8019
  return truncate(readFileSync9(this.confine(path), "utf8"));
7988
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
+ }
7989
8033
  async writeFile(path, content) {
7990
8034
  const abs = this.confine(path);
7991
8035
  mkdirSync5(dirname6(abs), { recursive: true });
@@ -8480,6 +8524,13 @@ ${parts}`);
8480
8524
  const r = await this.call("/read", { path }, void 0, void 0, opts?.span);
8481
8525
  return truncate(String(r.content ?? ""));
8482
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
+ }
8483
8534
  async writeFile(path, content, opts) {
8484
8535
  await this.call("/write", { path, content }, void 0, void 0, opts?.span);
8485
8536
  return `Wrote ${path}`;
@@ -9049,6 +9100,26 @@ ${parts}`);
9049
9100
  }
9050
9101
  return truncate(String(data.content ?? ""));
9051
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
+ }
9052
9123
  async writeFile(path, content, opts) {
9053
9124
  const { status: status3, data } = await this.opWithReattach("/write", { path, content }, void 0, void 0, opts?.span);
9054
9125
  if (status3 !== 200) {
@@ -14526,7 +14597,7 @@ ${evidence}`);
14526
14597
  case "wrap_up":
14527
14598
  return `Runs keep reaching the wrap-up warning (${p.runIds.length} runs; ${formatDuration(p.durationMs, "report")} spent winding down). Either the affected agent's \`maxMinutes\` (\`src/agents/registry.ts\`) is too tight for this shape of work, or the prompt should push batching (fewer, larger tool calls) \u2014 the evidence rows say which agent and how close to the deadline each run got.`;
14528
14599
  case "budget_hit":
14529
- return p.signature === "turns" ? `Runs exhaust the TURN budget. Raise \`maxTurns\` for the affected agent (\`src/agents/registry.ts\`) or have its prompt batch tool calls (several commands per \`bash\` call) so the same work takes fewer turns.` : `Runs exhaust the TIME budget and are cut off mid-work. Raise \`maxMinutes\` for the affected agent (\`src/agents/registry.ts\`), or split the task shape that triggers it \u2014 a run that is forced to write up findings is a run whose work was wasted.`;
14600
+ return p.signature === "turns" ? `Runs outpace the runaway guard: the turn cap is \`RUNAWAY_TURNS_PER_MINUTE\` (${RUNAWAY_TURNS_PER_MINUTE} turns a minute) over the affected agent's wall clock, a pace a working run does not sustain \u2014 so a run that reaches it is looping, not working. Read the evidence rows for a retry loop (the same call re-issued turn after turn) and fix its cause where the agent reads before acting (the target repo's AGENTS.md, the resident command table), or have the prompt batch tool calls (several commands per \`bash\` call). The cap is derived from \`maxMinutes\` (\`src/agents/registry.ts\`), not a knob to turn.` : `Runs exhaust the TIME budget and are cut off mid-work. Raise \`maxMinutes\` for the affected agent (\`src/agents/registry.ts\`), or split the task shape that triggers it \u2014 a run that is forced to write up findings is a run whose work was wasted.`;
14530
14601
  case "infra_failure":
14531
14602
  if (p.signature === "sandbox_dead") {
14532
14603
  return `The sandbox died mid-run in ${p.runIds.length} runs. Check container sizing first (the \`deploy/cloudflare-*/wrangler.template.jsonc\` comments: the 1 GiB \`basic\` tier died running vitest; thread sandboxes are \`standard-4\` \u2014 4 vCPU / 12 GiB / 20 GB, the platform's largest \u2014 and residents a custom type of the same size), then the memory footprint of the failing command, then the sandbox/resident Worker logs (\`deploy/bin/cf-logs\`) around the affected runs.`;
@@ -14559,6 +14630,7 @@ var init_frictionProposals = __esm({
14559
14630
  "../../src/core/frictionProposals.ts"() {
14560
14631
  "use strict";
14561
14632
  init_runFriction();
14633
+ init_registry();
14562
14634
  init_formatDuration();
14563
14635
  DEFAULT_MIN_RUNS = 2;
14564
14636
  MAX_EXAMPLES = 5;
@@ -23794,12 +23866,16 @@ var init_tracingExecutor = __esm({
23794
23866
  if (innerRelease) this.release = (mode) => this.timed("exec.release", (s) => innerRelease(mode, { span: s }));
23795
23867
  const innerMoveTo = inner.moveTo?.bind(inner);
23796
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 }));
23797
23872
  }
23798
23873
  inner;
23799
23874
  span;
23800
23875
  backend;
23801
23876
  release;
23802
23877
  moveTo;
23878
+ readBytes;
23803
23879
  /** Each op under its own `exec.*` span, handed to the inner executor as
23804
23880
  * `opts.span` so its HTTP calls become `http.client` children (item 21). */
23805
23881
  timed(name, fn, extra = {}) {
@@ -24799,6 +24875,60 @@ ${skill.body}${footer}`;
24799
24875
  }
24800
24876
  });
24801
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
+
24802
24932
  // ../../src/core/dispatch/awaitChildren.ts
24803
24933
  function decideWait(inputs) {
24804
24934
  const { children, now, budgetEndsAt, timeoutAt, stop, followUpPending } = inputs;
@@ -24907,6 +25037,7 @@ function watchedChild(io, on) {
24907
25037
  }
24908
25038
  };
24909
25039
  if (io.attach) watched2.attach = (file) => io.attach(file);
25040
+ if (io.attachFile) watched2.attachFile = (file) => io.attachFile(file);
24910
25041
  if (io.runFinished) watched2.runFinished = (receipt) => io.runFinished(receipt);
24911
25042
  if (io.openThread) watched2.openThread = (lead) => io.openThread(lead);
24912
25043
  return watched2;
@@ -25436,6 +25567,7 @@ var init_workspace = __esm({
25436
25567
  init_web();
25437
25568
  init_github();
25438
25569
  init_skills();
25570
+ init_attach();
25439
25571
  init_runs2();
25440
25572
  bashTool = {
25441
25573
  name: "bash",
@@ -25804,6 +25936,7 @@ ${raw.trim()}`;
25804
25936
  bashTool,
25805
25937
  readFileTool,
25806
25938
  writeFileTool,
25939
+ attachFileTool,
25807
25940
  updateStatusTool,
25808
25941
  submitPrDescriptionTool,
25809
25942
  submitHandoffTool,
@@ -26248,21 +26381,33 @@ async function runLoop(opts, now, note, emit, agentSpan) {
26248
26381
  return await finishSandboxDead(complete2, opts, messages, system, diagnosis);
26249
26382
  }
26250
26383
  const wasTimeout = now() >= deadline;
26384
+ const elapsedMs = opts.agent.maxMinutes * 6e4 - (deadline - now());
26385
+ const pace = `${turn} model turn${turn === 1 ? "" : "s"} in ${elapsedMinutes(elapsedMs)}`;
26251
26386
  note(
26252
26387
  wasTimeout ? "time_budget_exhausted" : "turn_budget_exhausted",
26253
- `${wasTimeout ? "time" : "turn"} budget exhausted \u2014 writing up findings so far`
26388
+ wasTimeout ? "time budget exhausted \u2014 writing up findings so far" : `turn guard fired: ${pace}, a pace that looks like a loop \u2014 writing up findings so far`
26254
26389
  );
26390
+ const writeUp = "Write your final answer now from what you have learned so far: report your findings/results to date, then state plainly which parts of the task you did not get to and what a follow-up (in this thread, to reuse this workspace) should focus on.";
26255
26391
  const text = await runFinale(
26256
26392
  complete2,
26257
26393
  opts,
26258
26394
  messages,
26259
26395
  system,
26260
- "You have reached the turn budget and can make no more tool calls. Write your final answer now from what you have learned so far: report your findings/results to date, then state plainly which parts of the task you did not get to and what a follow-up (in this thread, to reuse this workspace) should focus on."
26396
+ wasTimeout ? `You have reached the time budget and can make no more tool calls. ${writeUp}` : `You have hit the run's turn guard \u2014 ${pace}, a pace that looks like a loop \u2014 and can make no more tool calls. ${writeUp}`
26261
26397
  );
26262
- const budgetLabel = wasTimeout ? `${opts.agent.maxMinutes}-minute` : `${opts.agent.maxTurns}-turn`;
26263
- return text ? `\u26A0\uFE0F _Hit the ${budgetLabel} budget before finishing \u2014 findings so far:_
26398
+ if (wasTimeout) {
26399
+ return text ? `\u26A0\uFE0F _Hit the ${opts.agent.maxMinutes}-minute budget before finishing \u2014 findings so far:_
26400
+
26401
+ ${text}` : `Stopped at the ${opts.agent.maxMinutes}-minute budget without finishing. Partial work may exist in the workspace \u2014 narrow the task and try again.`;
26402
+ }
26403
+ return text ? `\u26A0\uFE0F _Stopped after ${pace} \u2014 that pace looks like a loop; findings so far:_
26264
26404
 
26265
- ${text}` : `Stopped at the ${budgetLabel} budget without finishing. Partial work may exist in the workspace \u2014 narrow the task and try again.`;
26405
+ ${text}` : `Stopped after ${pace} \u2014 that pace looks like a loop \u2014 without finishing. Partial work may exist in the workspace \u2014 look for a retry loop in the run's events before trying again.`;
26406
+ }
26407
+ function elapsedMinutes(ms) {
26408
+ const minutes = Math.round(ms / 6e4);
26409
+ if (minutes < 1) return "under a minute";
26410
+ return `${minutes} minute${minutes === 1 ? "" : "s"}`;
26266
26411
  }
26267
26412
  async function runFinale(complete2, opts, messages, system, instruction) {
26268
26413
  messages.push({ role: "user", content: [{ type: "text", text: instruction }] });
@@ -28884,9 +29029,11 @@ async function runLoop2(deps, ctx) {
28884
29029
  let runFailed = false;
28885
29030
  let runDiagnosis;
28886
29031
  const releaseWorkspace = (span) => round2.release({ hardStopped: run2.control.requested === "hard", ...span ? { span } : {} });
29032
+ const attachFile = io.attachFile?.bind(io);
28887
29033
  const toolContext = {
28888
29034
  executor,
28889
29035
  reportProgress,
29036
+ ...attachFile ? { attach: attachFile } : {},
28890
29037
  web: webCapability(),
28891
29038
  skills: deps.skills,
28892
29039
  github: githubCapabilityFor(deps, msg.userId),
@@ -33607,6 +33754,20 @@ var init_slack = __esm({
33607
33754
  ${file.text}`));
33608
33755
  }
33609
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
+ }
33610
33771
  async post(mrkdwn) {
33611
33772
  for (const chunk of chunkText(mrkdwn, SLACK_MSG_LIMIT)) {
33612
33773
  await this.client.chat.postMessage({
@@ -37004,7 +37165,7 @@ async function handleAdmitted(door, req, deps) {
37004
37165
  grant2.publish({
37005
37166
  type: "run_note",
37006
37167
  kind: "turn_budget_exhausted",
37007
- summary: `model proxy refused a call past the ${turn.maxTurns}-turn budget (${used})`,
37168
+ summary: `model proxy refused a call past the run's ${turn.maxTurns}-turn guard (${used})`,
37008
37169
  at: deps.clock()
37009
37170
  });
37010
37171
  log(`[model-proxy] 403 turn_budget_exhausted run=${grant2.runId} turns=${turn.turns}/${turn.maxTurns}`);
@@ -37012,7 +37173,7 @@ async function handleAdmitted(door, req, deps) {
37012
37173
  shape,
37013
37174
  403,
37014
37175
  "turn_budget_exhausted",
37015
- `the run's ${turn.maxTurns}-turn budget is spent (${used})`
37176
+ `the run is past its ${turn.maxTurns}-turn guard (${used})`
37016
37177
  );
37017
37178
  }
37018
37179
  const payload = JSON.stringify(pinRequest(shape, body, grant2));
@@ -37658,6 +37819,7 @@ function watched(io, on) {
37658
37819
  }
37659
37820
  };
37660
37821
  if (io.attach) out.attach = (file) => io.attach(file);
37822
+ if (io.attachFile) out.attachFile = (file) => io.attachFile(file);
37661
37823
  if (io.runFinished) out.runFinished = (receipt) => io.runFinished(receipt);
37662
37824
  if (io.openThread) out.openThread = (lead) => io.openThread(lead);
37663
37825
  return out;
@@ -39374,8 +39536,9 @@ init_source();
39374
39536
  init_invokedAsScript();
39375
39537
  init_secrets2();
39376
39538
  import { Console } from "node:console";
39377
- import { existsSync as existsSync14 } from "node:fs";
39378
- 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";
39379
39542
  var CONFIG_PATH3 = process.env.SWITCHBOARD_CONFIG ?? installationPath(OPERATOR_ROOT, "config/config.yaml");
39380
39543
  var DATA_DIR2 = installationPath(OPERATOR_ROOT, "data");
39381
39544
  var CLI_CALLER = { kind: "cli", id: CLI_ACTOR.id, actor: CLI_ACTOR };
@@ -39527,14 +39690,16 @@ ${cliCatalogue(commands)}`, stderr: "" };
39527
39690
  }
39528
39691
  }
39529
39692
  var ConsoleIO = class _ConsoleIO {
39530
- constructor(out = process.stdout, threadKey = "cli", prefix = "") {
39693
+ constructor(out = process.stdout, threadKey = "cli", prefix = "", attachments = {}) {
39531
39694
  this.out = out;
39532
39695
  this.threadKey = threadKey;
39533
39696
  this.prefix = prefix;
39697
+ this.attachments = attachments;
39534
39698
  }
39535
39699
  out;
39536
39700
  threadKey;
39537
39701
  prefix;
39702
+ attachments;
39538
39703
  /** The receipt of the run this request started, once it finished — undefined
39539
39704
  * before that, and forever when no run was started (a config reply such as
39540
39705
  * `help`, a refusal before a run existed). */
@@ -39544,6 +39709,15 @@ var ConsoleIO = class _ConsoleIO {
39544
39709
  async reply(text) {
39545
39710
  this.out.write("\n" + this.prefix + text + "\n");
39546
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
+ }
39547
39721
  runFinished(receipt) {
39548
39722
  this.finished = receipt;
39549
39723
  }
@@ -39566,7 +39740,10 @@ var ConsoleIO = class _ConsoleIO {
39566
39740
  const n2 = ++this.children;
39567
39741
  await this.reply(lead);
39568
39742
  const threadKey = `${this.threadKey}/child-${n2}`;
39569
- 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
+ };
39570
39747
  }
39571
39748
  };
39572
39749
  function askExitCode(finished) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coreplane/switchboard",
3
- "version": "1.208.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",