@coreplane/switchboard 1.220.0 → 1.220.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "switchboard",
3
- "version": "1.220.0",
3
+ "version": "1.220.1",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "switchboard",
9
- "version": "1.220.0",
9
+ "version": "1.220.1",
10
10
  "license": "Apache-2.0",
11
11
  "workspaces": [
12
12
  "web",
@@ -16710,7 +16710,7 @@
16710
16710
  },
16711
16711
  "packages/switchboard": {
16712
16712
  "name": "@coreplane/switchboard",
16713
- "version": "1.220.0",
16713
+ "version": "1.220.1",
16714
16714
  "license": "Apache-2.0",
16715
16715
  "dependencies": {
16716
16716
  "@anthropic-ai/sdk": "^0.124.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchboard",
3
- "version": "1.220.0",
3
+ "version": "1.220.1",
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.220.0",
3
- "commit": "cf86e5df3d3256037bf4925a9e13de0b61483df3",
4
- "builtAt": "2026-09-15T02:39:37.781Z"
2
+ "version": "1.220.1",
3
+ "commit": "b9b9146c723243aee57baf92e710d8454fa6b36d",
4
+ "builtAt": "2026-09-15T05:12:47.232Z"
5
5
  }
@@ -622,6 +622,19 @@ export function parseExitPrefix(output: string): { failed: boolean; exitCode?: n
622
622
  return /^\d+$/.test(m[1]) ? { failed: true, exitCode: Number(m[1]) } : { failed: true };
623
623
  }
624
624
 
625
+ /** A tool that could not do what it was asked answers the model with a text
626
+ * that opens `error:` (`attach_file`, the `submit_*` tools, the run tools —
627
+ * each declares `failsInText` on its `RunnableTool`) instead of throwing, so
628
+ * the model can read the reason and go on. The record must call that result
629
+ * what the model reads it as: `ok:false`. Both loops (the native runner and
630
+ * the pi bridge) derive `ok` for such a tool through this one reader; bash
631
+ * keeps `parseExitPrefix`; a tool relaying content it did not write is never
632
+ * read this way. Ordinary output that merely contains the word later on is a
633
+ * success. */
634
+ export function toolTextFailed(output: string): boolean {
635
+ return /^\s*error:/i.test(stripAnsi(output));
636
+ }
637
+
625
638
  /** Upper bound on a `tool_result.output` — large enough for a test run or a
626
639
  * diff to read in full on the run page, small enough that a run of ordinary
627
640
  * length fits the registry's backlog whole (the backlog is bounded by count AND
package/dist/cli.js CHANGED
@@ -408,8 +408,11 @@ var init_store = __esm({
408
408
  const res = await this.fetchImpl(signed);
409
409
  if (res.status === 404) return null;
410
410
  if (!res.ok) throw new Error(`artifact store: HEAD ${key} answered HTTP ${res.status}`);
411
- const size = Number(res.headers.get("content-length"));
412
- if (!Number.isFinite(size) || size < 0) throw new Error(`artifact store: HEAD ${key} answered without a length`);
411
+ const length = res.headers.get("content-length");
412
+ const size = length === null ? Number.NaN : Number(length);
413
+ if (!Number.isFinite(size) || size < 0) {
414
+ throw new Error(`artifact store: HEAD ${key} answered HTTP ${res.status} without a length`);
415
+ }
413
416
  return { size, contentType: res.headers.get("content-type") ?? "application/octet-stream" };
414
417
  }
415
418
  async get(key) {
@@ -7993,6 +7996,9 @@ function parseExitPrefix(output) {
7993
7996
  if (!m) return { failed: false, exitCode: 0 };
7994
7997
  return /^\d+$/.test(m[1]) ? { failed: true, exitCode: Number(m[1]) } : { failed: true };
7995
7998
  }
7999
+ function toolTextFailed(output) {
8000
+ return /^\s*error:/i.test(stripAnsi(output));
8001
+ }
7996
8002
  function capClean(clean) {
7997
8003
  const text = clean.trim();
7998
8004
  if (text.length <= TOOL_OUTPUT_CAP) return text;
@@ -25933,7 +25939,11 @@ function fileNameOf2(path) {
25933
25939
  return segments2[segments2.length - 1] ?? path;
25934
25940
  }
25935
25941
  function putCommandFor(path, contentType, url) {
25936
- return `curl -fsS -T ${shellQuote(path)} -H ${shellQuote(`Content-Type: ${contentType}`)} ${shellQuote(url)}`;
25942
+ return `curl -fsS -T ${shellQuote(path)} -H ${shellQuote(`Content-Type: ${contentType}`)} -o /dev/null -w ${shellQuote(PUT_REPORT_FORMAT)} ${shellQuote(url)}`;
25943
+ }
25944
+ function parsePutReport(output) {
25945
+ const m = /(\d{3}) (\d{1,15})\s*$/.exec(output);
25946
+ return m ? { httpCode: Number(m[1]), sentBytes: Number(m[2]) } : null;
25937
25947
  }
25938
25948
  function postCommandFor(path, url) {
25939
25949
  return `curl -fsS --upload-file ${shellQuote(path)} -X POST ${shellQuote(url)}`;
@@ -25968,11 +25978,14 @@ async function attachThroughStore(path, name, lead, artifacts, ctx) {
25968
25978
  if (parseExitPrefix(putOut).failed) {
25969
25979
  return `error: the upload of ${name} to the artifact store failed: ${putOut.trim()}`;
25970
25980
  }
25981
+ const report2 = parsePutReport(putOut);
25982
+ const curlSaid = report2 ? `; curl reported HTTP ${report2.httpCode} after sending ${report2.sentBytes} bytes` : "";
25971
25983
  const head = await artifacts.store.head(key);
25972
- if (!head)
25973
- return `error: the artifact store holds nothing under ${key} after the upload of ${name} \u2014 nothing was posted`;
25984
+ if (!head) {
25985
+ return `error: the artifact store holds nothing under ${key} after the upload of ${name}${curlSaid} \u2014 nothing was posted`;
25986
+ }
25974
25987
  if (head.size !== size) {
25975
- return `error: the artifact store holds ${head.size} bytes for ${name}, not the ${size} measured \u2014 nothing was posted`;
25988
+ return `error: the artifact store holds ${head.size} bytes for ${name}, not the ${size} measured${curlSaid} \u2014 nothing was posted`;
25976
25989
  }
25977
25990
  ctx.publish?.({ type: "artifact", direction: "out", key, name, size, contentType });
25978
25991
  if (!ctx.uploadTicket) {
@@ -26000,7 +26013,7 @@ async function attachThroughStore(path, name, lead, artifacts, ctx) {
26000
26013
  }
26001
26014
  return `attached ${name} (${size} bytes) to the conversation and the run page`;
26002
26015
  }
26003
- var MAX_ARTIFACT_BYTES, TRANSFER_FLOOR_BYTES_PER_MS, TRANSFER_SETUP_MS, STAT_TIMEOUT_MS, describe2, secs, attachFileTool;
26016
+ var MAX_ARTIFACT_BYTES, TRANSFER_FLOOR_BYTES_PER_MS, TRANSFER_SETUP_MS, STAT_TIMEOUT_MS, describe2, secs, PUT_REPORT_FORMAT, attachFileTool;
26004
26017
  var init_attach = __esm({
26005
26018
  "../../src/tools/attach.ts"() {
26006
26019
  "use strict";
@@ -26016,8 +26029,10 @@ var init_attach = __esm({
26016
26029
  STAT_TIMEOUT_MS = 3e4;
26017
26030
  describe2 = (err2) => err2 instanceof Error ? err2.message : String(err2);
26018
26031
  secs = (ms) => Math.max(0, Math.round(ms / 1e3));
26032
+ PUT_REPORT_FORMAT = "\\n%{http_code} %{size_upload}";
26019
26033
  attachFileTool = {
26020
26034
  name: "attach_file",
26035
+ failsInText: true,
26021
26036
  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_ARTIFACT_BYTES} bytes (1 GiB) where the artifact store is configured, ${MAX_READ_BYTES} bytes otherwise \u2014 the result says which.`,
26022
26037
  inputSchema: {
26023
26038
  type: "object",
@@ -26557,6 +26572,7 @@ var init_runs2 = __esm({
26557
26572
  CHILDREN_PAGES_MAX = 25;
26558
26573
  spawnRunTool = {
26559
26574
  name: "spawn_run",
26575
+ failsInText: true,
26560
26576
  description: `Start a child run as the person who asked you: an ordinary Switchboard run of the named preset, in a thread of its own in this channel, under their permissions \u2014 what they could start by hand with \`agent:<preset>\`. The child is a reader of this conversation: it starts from this conversation's text so far (every user and assistant turn, never your tool calls or their results) plus \`prompt\`, its one new turn \u2014 so say what it should do. Presets a child can run: ${CHILD_PRESETS().join(", ")}; a preset that writes (${WRITE_PRESETS().join(", ")}) is refused \`spawn_identity\`. \`repo\` (owner/name) for a preset that works in a repository (${REPO_PRESETS().join(", ")}); \`budget\` narrows the child's wall clock in whole minutes (at least 2; it is also capped by what is left of yours). Returns the child's run id, its thread and a link, or a refusal by name: a preset or repository the requester may not use, a boundary the child's profile exceeds, the fan-out cap, or a channel that cannot open a thread. A child cannot spawn children.`,
26561
26577
  inputSchema: {
26562
26578
  type: "object",
@@ -26599,6 +26615,7 @@ var init_runs2 = __esm({
26599
26615
  };
26600
26616
  sendToRunTool = {
26601
26617
  name: "send_to_run",
26618
+ failsInText: true,
26602
26619
  description: "Steer one of your live children: `text` reaches it as a follow-up at its next step, the way a reply in its thread would \u2014 sent as the person who asked you, and recorded on the child's run page as coming from this run. Use it when the request changed or a child is heading the wrong way. Refused by name: `not_found` (an unknown id, or a run the requester may not read), `not_child` (a run you did not spawn), `not_live` (it already ended \u2014 its status is given, nothing is sent; get_run_status has its reply), or the child's agent's allowlist. A child that finishes before its next step never reads it.",
26603
26620
  inputSchema: {
26604
26621
  type: "object",
@@ -26638,6 +26655,7 @@ var init_runs2 = __esm({
26638
26655
  AWAIT_IDS_MAX = 50;
26639
26656
  awaitRunsTool = {
26640
26657
  name: "await_runs",
26658
+ failsInText: true,
26641
26659
  description: "Wait for runs \u2014 normally your children \u2014 to end, and get each one's end as data: its terminal status (completed, failed, refused, stopped_soft, stopped_hard, interrupted) with its final reply wrapped as untrusted content; `running` for one still live when the wait was cut; `not_found` for an unknown id or one the requester may not read. The wait ends at the first of: every named run ended; `timeoutMinutes` (optional, whole minutes); the edge of your own budget (a minute before your clock runs out \u2014 write up what came back and name what is still running, which keeps running); a stop; a follow-up landing in this thread (it rides your next turn). `ended` says which. An interrupted child is reported, never restarted. A child is its thread: when a person's reply in a child's thread started a later run there, the child's row follows that run \u2014 `continuedBy` names it, and `status` and `finalReply` are its \u2014 never the child's first reply alone.",
26642
26660
  inputSchema: {
26643
26661
  type: "object",
@@ -27037,6 +27055,7 @@ ${raw.trim()}`;
27037
27055
  };
27038
27056
  submitVerdictTool = {
27039
27057
  name: "submit_verdict",
27058
+ failsInText: true,
27040
27059
  description: "Record your review verdict. REQUIRED before your final message when reviewing a PR: `approve` when there are no blocking issues (nits alone are not blocking), `request_changes` otherwise. Switchboard writes the verdict as the first line of the GitHub comment itself (`LGTM:` only for approve); a review with no submitted verdict is posted as NOT approving. Call it once, after your analysis; a later call replaces the earlier one. `head` is the commit you reviewed \u2014 run `git rev-parse HEAD` in the checkout you read and tested and pass its output; Switchboard posts to the PR only if that commit IS the PR's head, so a review of the wrong branch can never land on a PR. Enumerate EVERY issue you report in `findings` with STABLE ids assigned in order (F1, F2, \u2026) \u2014 a fix round references findings by these ids, so never renumber them. Severity is exactly one of blocking|major|minor|nit; the entry carries the file (plus line when it points at one) and a one-line title, while the full explanation stays in your review text keyed by the same ids. An `approve` carrying a `blocking` finding is downgraded to `request_changes` \u2014 approve only when nothing blocking remains.",
27041
27060
  inputSchema: {
27042
27061
  type: "object",
@@ -27087,6 +27106,7 @@ ${raw.trim()}`;
27087
27106
  };
27088
27107
  submitDispositionsTool = {
27089
27108
  name: "submit_dispositions",
27109
+ failsInText: true,
27090
27110
  description: "Record one disposition per review finding after addressing them: `fixed` (the finding is addressed in your pushed code) or `declined` (deliberately not doing it \u2014 the note says why). `findingId` is the finding's stable id from the review (F1, F2, \u2026) \u2014 use exactly those ids; an id the review never issued answers nothing and is dropped when the plan runner reads your record. Every finding gets exactly one entry, every severity included (nits too). Call it once with the complete set after your last push; a later call replaces the earlier one. The set rides this run's record, where the plan runner reads it for the re-review.",
27091
27111
  inputSchema: {
27092
27112
  type: "object",
@@ -27118,6 +27138,7 @@ ${raw.trim()}`;
27118
27138
  };
27119
27139
  submitPrDescriptionTool = {
27120
27140
  name: "submit_pr_description",
27141
+ failsInText: true,
27121
27142
  description: "Submit the PR description as a typed object. REQUIRED after pushing your branch: Switchboard renders the GitHub PR body from this object at the pushed head and opens (or updates) the pull request itself \u2014 never open a PR yourself. Fields map 1:1 to the rendered sections; `title` becomes the PR's title; tour anchors are (path, from, to) line ranges at your pushed head. Call it after your last push; if you push again afterwards, call it again \u2014 the last valid call wins. An invalid object returns an error naming the field to fix; correct it and resubmit.",
27122
27143
  inputSchema: {
27123
27144
  type: "object",
@@ -27211,6 +27232,7 @@ ${raw.trim()}`;
27211
27232
  });
27212
27233
  submitHandoffTool = {
27213
27234
  name: "submit_handoff",
27235
+ failsInText: true,
27214
27236
  description: "Submit the unit handoff as a typed object \u2014 REQUIRED when your first user turn carries a `## Contract` block: once, after submit_pr_description and before your final message. `deviations`: where you departed from the unit as written (from, to, why); `followUps`: what you found and did not do, and where it belongs (what, where); `unproven`: which of the unit's test scenarios or criteria you could not prove, and why (criterion, why). Switchboard records it on the run and posts it to the unit's board issue, where a person decides each row. Submit empty lists when there is nothing to say \u2014 never skip it. A later call replaces the earlier one; an invalid object returns an error naming the field to fix.",
27215
27237
  inputSchema: {
27216
27238
  type: "object",
@@ -27994,17 +28016,17 @@ ${stagedLine}` : prompt }];
27994
28016
  try {
27995
28017
  const output = await untilHardStop(tool.run(tu.input ?? {}, ctx));
27996
28018
  const text2 = toolResultText(output);
27997
- const exit = tu.name === "bash" ? parseExitPrefix(text2) : void 0;
28019
+ const exit = tu.name === "bash" ? parseExitPrefix(text2) : { failed: tool.failsInText === true && toolTextFailed(text2) };
27998
28020
  emit({
27999
28021
  type: "tool_result",
28000
28022
  tool: tu.name,
28001
- ok: !exit?.failed,
28023
+ ok: !exit.failed,
28002
28024
  callId: tu.id,
28003
- ...exit?.exitCode !== void 0 ? { exitCode: exit.exitCode } : {},
28025
+ ...exit.exitCode !== void 0 ? { exitCode: exit.exitCode } : {},
28004
28026
  ...prepareToolResult(text2),
28005
28027
  ...spanId
28006
28028
  });
28007
- settle2(!exit?.failed, exit?.exitCode !== void 0 ? { exitCode: exit.exitCode } : {});
28029
+ settle2(!exit.failed, exit.exitCode !== void 0 ? { exitCode: exit.exitCode } : {});
28008
28030
  return { type: "tool_result", toolUseId: tu.id, content: capToolResultContent(output) };
28009
28031
  } catch (err2) {
28010
28032
  if (err2 instanceof HardStopError) throw err2;
@@ -31289,7 +31311,7 @@ var init_bridge2 = __esm({
31289
31311
  const tool = open2?.tool ?? str5(event.toolName);
31290
31312
  const isError = event.isError === true;
31291
31313
  const text = piResultText(event.result);
31292
- const exit = tool === "bash" ? piBashExit(text, isError) : { failed: isError, ...isError ? {} : {} };
31314
+ const exit = tool === "bash" ? piBashExit(text, isError) : { failed: isError || this.deps.textFailing?.has(tool) === true && toolTextFailed(text) };
31293
31315
  const ok2 = !exit.failed;
31294
31316
  this.emit({
31295
31317
  type: "tool_result",
@@ -31739,7 +31761,13 @@ async function runPiHarness(deps, run2) {
31739
31761
  run2.onProgress?.(summary);
31740
31762
  emit({ type: "run_note", kind, summary, ...mode ? { mode } : {} });
31741
31763
  };
31742
- const bridge = new PiBridge({ emit, onProgress: run2.onProgress, agentSpan, clock: clock3 });
31764
+ const bridge = new PiBridge({
31765
+ emit,
31766
+ onProgress: run2.onProgress,
31767
+ agentSpan,
31768
+ clock: clock3,
31769
+ textFailing: new Set(run2.tools.filter((t) => t.failsInText).map((t) => t.name))
31770
+ });
31743
31771
  let paths;
31744
31772
  const remainingMs = run2.resume?.remainingMs ?? run2.agent.maxMinutes * 6e4;
31745
31773
  const deadline = now() + remainingMs;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coreplane/switchboard",
3
- "version": "1.220.0",
3
+ "version": "1.220.1",
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",