@coreplane/switchboard 1.218.0 → 1.220.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.
@@ -71,6 +71,22 @@ RUN apt-get update \
71
71
  COPY --from=meat /go/bin/meat /usr/local/bin/meat
72
72
  RUN command -v meat >/dev/null && meat -h >/dev/null 2>&1
73
73
 
74
+ # pi (docs/reference/specs/harness-pi.md item 12): the harness for a preset
75
+ # without a workspace (general, research, conductor) runs pi as a child of
76
+ # the bot itself, since such a run has no execution container. The same layer
77
+ # as the execution images', on this image's Node (the same 24 tag), installed
78
+ # globally as root before the switch to the bot's user, so `switchboard` finds
79
+ # it on PATH and cannot alter it; at an EXACT pin held equal across the three
80
+ # images by src/deploy/imagePiHarness.test.ts (the pnpm lesson: a floating tag
81
+ # would move the harness's protocol with the build date). Proven by the layer,
82
+ # so the BUILD fails, not a run. Dark until a deployment sets
83
+ # `harness: { general: pi }`: nothing here starts pi on its own, and a run's
84
+ # pi on this host holds none of pi's own shell or file tools.
85
+ RUN npm install -g @earendil-works/pi-coding-agent@0.85.1 \
86
+ && npm cache clean --force \
87
+ && pi --version | grep -qx '0.85.1' \
88
+ && pi --help | grep -q -- '--mode <mode>'
89
+
74
90
  # Non-root user; agents run bash with this user's (container-scoped) permissions.
75
91
  RUN useradd -m -u 1001 switchboard
76
92
  WORKDIR /app
@@ -320,8 +320,10 @@ workspaceDir: ./workspaces
320
320
  # with the run's model-proxy bearer as its only key, every event bridged onto
321
321
  # the run's own record, our tools and tool rules as its extension. Every
322
322
  # preset declares `native` until the pi series moves it; a deployment moves a
323
- # preset early here. Only a preset with a workspace (coding, review, explore)
324
- # can run on pi. A preset the registry does not know, or a value that is
323
+ # preset early here. A preset with a workspace (coding, review, explore) runs
324
+ # pi in the run's container; a preset without one (general, research,
325
+ # conductor) runs it as a child of the bot, with none of pi's own shell or
326
+ # file tools. A preset the registry does not know, or a value that is
325
327
  # neither harness, fails the load by name.
326
328
  # harness:
327
329
  # coding: pi
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "switchboard",
3
- "version": "1.218.0",
3
+ "version": "1.220.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "switchboard",
9
- "version": "1.218.0",
9
+ "version": "1.220.0",
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.218.0",
16713
+ "version": "1.220.0",
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.218.0",
3
+ "version": "1.220.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.218.0",
3
- "commit": "2931369158bcc02d8e2cfd80eb661bae017188bd",
4
- "builtAt": "2026-09-15T00:34:28.652Z"
2
+ "version": "1.220.0",
3
+ "commit": "cf86e5df3d3256037bf4925a9e13de0b61483df3",
4
+ "builtAt": "2026-09-15T02:39:37.781Z"
5
5
  }
@@ -121,8 +121,9 @@ export interface AgentDef {
121
121
  residentSystem?: string;
122
122
  /** Which loop drives the preset's runs (`HARNESSES`): the native loop
123
123
  * unless declared, and whatever a deployment's `harness.<preset>` says
124
- * over that. Only a preset with a workspace can run on pi — pi is a process
125
- * in the run's execution container. */
124
+ * over that. A preset with a workspace runs pi in the run's execution
125
+ * container; a preset without one (machine class `none`) runs it as a
126
+ * child of the bot, with none of pi's own tools. */
126
127
  harness?: Harness;
127
128
  }
128
129
 
@@ -435,6 +436,8 @@ TIME. Your budget is up to two hours — less when a boundary or the request's \
435
436
 
436
437
  READ-ONLY: NEVER open a pull request, and never commit or push — no branch, no \`gh pr create\`, no PR or issue write of any kind. You hold a read credential and your job is to find out, not to change. If the investigation shows a change is needed, say exactly what and where in your write-up and point the user at \`agent:coding\`.
437
438
 
439
+ You cannot attach or post files: your whole answer is text. Never say a file is attached or below — name its path in the workspace and describe it (what it shows, its size) instead; a person who needs the file itself asks \`agent:coding\`, which can attach.
440
+
438
441
  Maintain the user-facing status card with the update_status tool: post your plan as a checklist (○ pending) once you have it, and update items as they start (✱) and finish (✓ — only after they actually happened). Items are short outcomes ("Clone and install", "Time the full suite"), never commands.
439
442
 
440
443
  ${NOTEPAD}
@@ -600,7 +603,7 @@ const WORK_PRESETS = {
600
603
  explore: {
601
604
  name: "explore",
602
605
  description:
603
- "Long, read-only investigation of a repository in a cold sandbox: runs builds, suites and pipelines, searches the web, and reports a claim table with commands and numbers. Never opens a PR. Up to two hours.",
606
+ "Read-only investigation of a repository in a cold sandbox: runs builds, suites and pipelines, searches the web, and reports a claim table with commands and numbers. Never opens a PR; cannot attach or post files.",
604
607
  system: EXPLORE_SYSTEM,
605
608
  toolset: "explore",
606
609
  // Always a cold per-thread sandbox with the checkout, never the resident a
@@ -71,6 +71,11 @@ export type RunNoteKind =
71
71
  * executor's bounded wait (docs/reference/specs/execution.md item 14). Capacity, not a
72
72
  * dead sandbox: the run goes on and the model is told to retry or finish. */
73
73
  | "fleet_busy"
74
+ /** The sandbox restarted under the run and came back (docs/reference/specs/
75
+ * resident-repos.md item 65): the executor waited for the resident's wake
76
+ * and re-attached; the interrupted call was settled with a synthetic
77
+ * result (run-loop.md item 19) and the run goes on. */
78
+ | "sandbox_restarted"
74
79
  | "stop_requested"
75
80
  | "stopped"
76
81
  /** Setup spans the request's stream sink had to drop before this run was
@@ -154,6 +159,7 @@ export const RUN_NOTE_KINDS = [
154
159
  "turn_budget_exhausted",
155
160
  "sandbox_dead",
156
161
  "fleet_busy",
162
+ "sandbox_restarted",
157
163
  "stop_requested",
158
164
  "stopped",
159
165
  "spans_dropped",
@@ -38,7 +38,8 @@ export type FrictionCategory =
38
38
  | "setup_install"
39
39
  | "wrap_up"
40
40
  | "budget_hit"
41
- | "infra_failure";
41
+ | "infra_failure"
42
+ | "unkept_promise";
42
43
 
43
44
  export const FRICTION_CATEGORIES: readonly FrictionCategory[] = [
44
45
  "slow_tool",
@@ -49,6 +50,7 @@ export const FRICTION_CATEGORIES: readonly FrictionCategory[] = [
49
50
  "wrap_up",
50
51
  "budget_hit",
51
52
  "infra_failure",
53
+ "unkept_promise",
52
54
  ];
53
55
 
54
56
  /** Human labels — the verdict line, the report's table and its finding lines. */
@@ -61,6 +63,7 @@ export const CATEGORY_LABEL: Record<FrictionCategory, string> = {
61
63
  wrap_up: "agent wind-down",
62
64
  budget_hit: "budget hits",
63
65
  infra_failure: "infra failures",
66
+ unkept_promise: "unkept promises",
64
67
  };
65
68
 
66
69
  /** What a category's time is a share OF: tool time for the categories whose
@@ -75,8 +78,17 @@ export const DENOMINATOR_OF: Record<FrictionCategory, "tool" | "run"> = {
75
78
  wrap_up: "run",
76
79
  budget_hit: "run",
77
80
  infra_failure: "run",
81
+ unkept_promise: "run",
78
82
  };
79
83
 
84
+ /** The ways a reply tells the person a file is attached — the promise `unkept_promise` holds a run to.
85
+ * One deliberate expression, case-insensitive: "attached below/here/above", "see (the) attached",
86
+ * "I've/I have attached", "is/are attached", "attachment(s) (is/are) below/here". A path such as
87
+ * `./attachments/1-clip.mp4` or the bare word "attachments/" names a place, not a promise, and
88
+ * does not match; nor does prose about attaching in an `assistant` turn — only the `answer` counts. */
89
+ export const ATTACHMENT_PROMISE =
90
+ /\b(?:attached (?:below|here|above)|see (?:the )?attached|i(?:'ve| have) attached|(?:is|are) attached\b|attachments? (?:(?:is|are) )?(?:below|here))/i;
91
+
80
92
  export type FrictionSeverity = "low" | "medium" | "high";
81
93
 
82
94
  export interface FrictionFinding {
@@ -382,6 +394,9 @@ export function analyzeRunFriction(events: readonly RunEvent[], opts: FrictionOp
382
394
  let sideFactEvents = 0; // skill_use / review_artifact / pr_description / pr_opened / review_posted / ship_round / route: facts about the run, not steps
383
395
  let spanEvents = 0; // span_start / span_end (docs/reference/specs/tracing.md): timing records, not steps
384
396
  let wrapUp: { index: number; at?: number } | undefined;
397
+ // The reply and the files the run sent out: what `unkept_promise` compares after the loop.
398
+ let answer: { index: number; text: string } | undefined;
399
+ let outboundArtifacts = 0;
385
400
  events.forEach((ev, index) => {
386
401
  flushTurns(index);
387
402
  if (isSpanRecord(ev)) {
@@ -390,8 +405,10 @@ export function analyzeRunFriction(events: readonly RunEvent[], opts: FrictionOp
390
405
  }
391
406
  if (isNarrative(ev)) {
392
407
  narrativeEvents++; // the narrative and `run_meta` are neither steps nor findings
408
+ if (ev.type === "answer") answer = { index, text: ev.text };
393
409
  return;
394
410
  }
411
+ if (ev.type === "artifact" && ev.direction === "out") outboundArtifacts++;
395
412
  // Side facts about the run, not steps: skill_use rides beside a use_skill
396
413
  // call that already produced its own tool pair, and artifact beside the
397
414
  // attach_file call (or the dispatcher's staging) that moved the file;
@@ -545,6 +562,17 @@ export function analyzeRunFriction(events: readonly RunEvent[], opts: FrictionOp
545
562
  eventIndex: index,
546
563
  });
547
564
  return;
565
+ case "sandbox_restarted":
566
+ // The container rolled under the run and came back: the run went on,
567
+ // but the minutes spent waiting for the wake are friction the deploy
568
+ // window owns.
569
+ findings.push({
570
+ category: "infra_failure",
571
+ severity: "medium",
572
+ summary: `sandbox restarted: ${ev.summary}`,
573
+ eventIndex: index,
574
+ });
575
+ return;
548
576
  }
549
577
  });
550
578
  // A slow turn whose produced event is past the stream (a turn that ended the run).
@@ -582,6 +610,24 @@ export function analyzeRunFriction(events: readonly RunEvent[], opts: FrictionOp
582
610
  f.interval = { start: at, end: Math.max(at, windowEnd) };
583
611
  }
584
612
  }
613
+ // The reply promised a file the run never produced: no tool failed (a preset without
614
+ // attach_file makes no call), so the answer's own words are the only witness. Once per
615
+ // run, anchored to the answer, no extent; the line that made the promise is quoted.
616
+ if (answer !== undefined && outboundArtifacts === 0) {
617
+ const promised = ATTACHMENT_PROMISE.exec(answer.text);
618
+ if (promised) {
619
+ const line =
620
+ answer.text.slice(0, promised.index).split("\n").pop()! + answer.text.slice(promised.index).split("\n")[0]!;
621
+ const trimmed = line.trim();
622
+ const quoted = trimmed.length > 120 ? `…${trimmed.slice(trimmed.length - 119)}` : trimmed;
623
+ findings.push({
624
+ category: "unkept_promise",
625
+ severity: "low",
626
+ summary: `unkept promise: the reply says a file is attached but the run produced none — "${quoted}"`,
627
+ eventIndex: answer.index,
628
+ });
629
+ }
630
+ }
585
631
 
586
632
  // ---- totals ----------------------------------------------------------------
587
633
  let toolTimeMs: number | undefined;
@@ -158,6 +158,39 @@ export interface RunRecord {
158
158
  * on records written before the session log existed and on runs without
159
159
  * a conversation of their own. */
160
160
  session?: RunSession;
161
+ /** The pull request the run's post-step opened or edited (item 2): the last
162
+ * `pr_opened` event, folded at the assembly. Present only on a run that
163
+ * reached one. A follow-up in the thread continues on its head branch
164
+ * (docs/reference/specs/resident-repos.md item 29) — read off the record,
165
+ * never off the reply's text. */
166
+ pr?: RunPullRequest;
167
+ }
168
+
169
+ /** A pull request as the record names it (item 2): its number and its GitHub
170
+ * URL, both from the `pr_opened` event the coding post-step published. */
171
+ export interface RunPullRequest {
172
+ number: number;
173
+ url: string;
174
+ }
175
+
176
+ /** The pull request a run's events say it opened or edited — the last
177
+ * `pr_opened` wins, as an edit after an open names the same PR — or nothing. */
178
+ export function prOfEvents(events: readonly RunEvent[]): RunPullRequest | undefined {
179
+ let pr: RunPullRequest | undefined;
180
+ for (const e of events) if (e.type === "pr_opened") pr = { number: e.number, url: e.url };
181
+ return pr;
182
+ }
183
+
184
+ function isRunPullRequestShape(v: unknown): v is RunPullRequest {
185
+ if (typeof v !== "object" || v === null) return false;
186
+ const p = v as Record<string, unknown>;
187
+ return (
188
+ typeof p.number === "number" &&
189
+ Number.isInteger(p.number) &&
190
+ p.number > 0 &&
191
+ typeof p.url === "string" &&
192
+ p.url.length > 0
193
+ );
161
194
  }
162
195
 
163
196
  /** The three places a run's conversation can start (item 52): the thread's
@@ -574,6 +607,8 @@ export function isRunRecord(v: unknown): v is RunRecord {
574
607
  if (r.dispositions !== undefined && !isFindingDispositionsShape(r.dispositions)) return false;
575
608
  if (r.reviewPost !== undefined && !isReviewPostShape(r.reviewPost)) return false;
576
609
  if (r.profile !== undefined && !isRunProfileRecord(r.profile)) return false;
610
+ // The pull request the run reached (item 2): a positive integer and a URL, or absent.
611
+ if (r.pr !== undefined && !isRunPullRequestShape(r.pr)) return false;
577
612
  // A parent is named by a run id (item 46): the same shape as the record's own.
578
613
  if (r.parentRunId !== undefined && (typeof r.parentRunId !== "string" || !RUN_ID_PATTERN.test(r.parentRunId)))
579
614
  return false;