@coreplane/switchboard 1.231.1 → 1.233.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.
@@ -5,6 +5,8 @@ SLACK_APP_TOKEN=xapp-...
5
5
  # Providers (referenced by apiKeyEnv in config.yaml)
6
6
  ANTHROPIC_API_KEY=sk-ant-...
7
7
  OPENAI_API_KEY=sk-...
8
+ # OpenRouter (optional): the key behind the example's `openrouter` provider block; `init --openrouter-key` fills it.
9
+ # OPENROUTER_API_KEY=sk-or-...
8
10
 
9
11
  # Sandboxed execution (execution.type: e2b in config.yaml)
10
12
  E2B_API_KEY=e2b_...
@@ -20,18 +20,20 @@ providers:
20
20
  # type: openai-compatible
21
21
  # baseUrl: https://api.groq.com/openai/v1
22
22
  # apiKeyEnv: GROQ_API_KEY
23
- # OpenRouter: one key, many vendors' models. Its model ids already carry the
24
- # vendor, so a model is named openrouter/anthropic/claude-sonnet-4 (the first
25
- # slash separates the provider; the rest is passed through). The compatible
26
- # adapter sends no effort tier, turns document inputs into a text note and
27
- # asks for no provider-side prompt caching. The request router and memory
28
- # reflection reach it through pi's model library instead (the same block,
29
- # the same key), which applies its own OpenRouter rules there, prompt-cache
30
- # markers included.
31
- # openrouter:
32
- # type: openai-compatible
33
- # baseUrl: https://openrouter.ai/api/v1
34
- # apiKeyEnv: OPENROUTER_API_KEY
23
+ # OpenRouter: one key, many vendors' models, on the same compatible adapter.
24
+ # Its model ids already carry the vendor, so any preset's model may be
25
+ # openrouter/<vendor>/<model> — e.g. openrouter/anthropic/claude-sonnet-4:
26
+ # the first slash names this block, the rest is the model id passed through.
27
+ # OPENROUTER_API_KEY is read at the first model call, never at load, so
28
+ # without it every other block runs as today and only a run on this one
29
+ # fails, by that name. Effort tiers, documents and prompt caching ride the
30
+ # compatible adapter as far as it carries them: pi applies its OpenRouter
31
+ # rules (prompt-cache markers included); a document becomes a text note.
32
+ # `switchboard init` keeps this block only with --openrouter-key.
33
+ openrouter:
34
+ type: openai-compatible
35
+ baseUrl: https://openrouter.ai/api/v1
36
+ apiKeyEnv: OPENROUTER_API_KEY
35
37
  # local:
36
38
  # type: openai-compatible
37
39
  # baseUrl: http://localhost:11434/v1 # Ollama
@@ -1110,9 +1110,12 @@ const NO_REF_HINT_REASON: RefHintReason = { ownPr: null, refByDefault: false };
1110
1110
  type RebindOutcome =
1111
1111
  | { kind: "none"; binding: ThreadBinding }
1112
1112
  | { kind: "refuse"; refused: RebindRefused }
1113
- /** `note`: the move was the record alone — a dirty tree already on the
1114
- * branch, no checkout run — in the verdict's words, for the log line. */
1115
- | { kind: "rebound"; moved: ThreadBinding; rebound: Rebound; note?: string };
1113
+ | { kind: "rebound"; moved: ThreadBinding; rebound: Rebound; keepTree: false }
1114
+ /** The move was the record alone — a dirty tree already on the branch, no
1115
+ * checkout run — with the verdict's `note` for the log line. The attach
1116
+ * that follows must keep that tree (`keepTree`, the worktree step's word
1117
+ * for it): the promise the note makes is the attach's to keep. */
1118
+ | { kind: "rebound"; moved: ThreadBinding; rebound: Rebound; keepTree: true; note: string };
1116
1119
 
1117
1120
  /** Result of one /op test/build execution. `ok` is the command's
1118
1121
  * verdict — a failing test run is a RESULT with ok:false, never an error. */
@@ -4570,6 +4573,7 @@ export class ResidentDO extends Sandbox<Env> {
4570
4573
  refHint,
4571
4574
  wantSha,
4572
4575
  reuse,
4576
+ keepTree: rebind.keepTree === true,
4573
4577
  slug,
4574
4578
  t0,
4575
4579
  facts,
@@ -4595,16 +4599,32 @@ export class ResidentDO extends Sandbox<Env> {
4595
4599
  * mirror mutex so no other attach of the thread touches the tree meanwhile.
4596
4600
  * The rest of the attach then runs on the moved ref: the mirror fetch for
4597
4601
  * the PR head (item 51) and item 17's stale check, which keeps a tree whose
4598
- * HEAD is the ref's tip or a descendant of it. The binding records the move
4599
- * (`rebound`); the answer carries it, or the named refusal, so the bot can
4600
- * say where the follow-up runs and why. A refusal never fails the attach. */
4602
+ * HEAD is the ref's tip or a descendant of it. When the verdict moved the
4603
+ * record alone — a dirty tree with the branch already checked out — the
4604
+ * answer says so (`keepTree`) and the worktree step keeps that tree, dirt
4605
+ * included, instead of item 17's dirty wipe: the promise not to touch it is
4606
+ * the whole attach's. The binding records the move (`rebound`); the answer
4607
+ * carries it, or the named refusal, so the bot can say where the follow-up
4608
+ * runs and why. A refusal never fails the attach. */
4601
4609
  private async rebindToOwnPr(
4602
4610
  prior: ThreadBinding | undefined,
4603
4611
  ownPr: OwnPr | null,
4604
4612
  reuse: boolean,
4605
4613
  defaultRef: string,
4606
4614
  slug: string,
4607
- ): Promise<{ binding: ThreadBinding | undefined; rebound?: Rebound; rebindRefused?: RebindRefused } | ThreadErr> {
4615
+ ): Promise<
4616
+ | {
4617
+ binding: ThreadBinding | undefined;
4618
+ rebound?: Rebound;
4619
+ rebindRefused?: RebindRefused;
4620
+ /** The move was the record alone — the tree is dirty with the branch
4621
+ * already checked out — and the attach must keep that tree as it
4622
+ * stands: the worktree step's item 17 wipe of a dirty tree would
4623
+ * destroy the very work the rebind declined to touch. */
4624
+ keepTree?: true;
4625
+ }
4626
+ | ThreadErr
4627
+ > {
4608
4628
  const plan = rebindPlan({ ownPr, reuse, binding: prior, defaultRef });
4609
4629
  if (plan.kind === "none" || prior === undefined) return { binding: prior };
4610
4630
  if (plan.kind === "refuse") return { binding: prior, rebindRefused: plan.refused };
@@ -4704,8 +4724,8 @@ export class ResidentDO extends Sandbox<Env> {
4704
4724
  const moved: ThreadBinding = { ...current, ref: again.to, rebound };
4705
4725
  await this.ctx.storage.put(key, moved);
4706
4726
  return verdict.checkout
4707
- ? { kind: "rebound", moved, rebound }
4708
- : { kind: "rebound", moved, rebound, note: verdict.note };
4727
+ ? { kind: "rebound", moved, rebound, keepTree: false }
4728
+ : { kind: "rebound", moved, rebound, keepTree: true, note: verdict.note };
4709
4729
  }, ATTACH_MUTEX_WAIT_MS)
4710
4730
  ).value;
4711
4731
  } catch (err) {
@@ -4723,10 +4743,16 @@ export class ResidentDO extends Sandbox<Env> {
4723
4743
  return { binding: prior, rebindRefused: outcome.refused };
4724
4744
  }
4725
4745
  const { moved, rebound } = outcome;
4746
+ if (!outcome.keepTree) {
4747
+ console.log(
4748
+ `attach ${prior.threadKey}: rebound ${rebound.from} → ${rebound.to} (the thread's own pull request #${rebound.pr})`,
4749
+ );
4750
+ return { binding: moved, rebound };
4751
+ }
4726
4752
  console.log(
4727
- `attach ${prior.threadKey}: rebound ${rebound.from} → ${rebound.to} (the thread's own pull request #${rebound.pr})${outcome.note ? ` — ${outcome.note}` : ""}`,
4753
+ `attach ${prior.threadKey}: rebound ${rebound.from} → ${rebound.to} (the thread's own pull request #${rebound.pr}) — ${outcome.note}`,
4728
4754
  );
4729
- return { binding: moved, rebound };
4755
+ return { binding: moved, rebound, keepTree: true };
4730
4756
  }
4731
4757
 
4732
4758
  /** The move when the thread's tree is gone (item 16): the binding's ref
@@ -4781,7 +4807,7 @@ export class ResidentDO extends Sandbox<Env> {
4781
4807
  };
4782
4808
  const moved: ThreadBinding = { ...current, ref: plan.to, rebound };
4783
4809
  await this.ctx.storage.put(threadBindingKey(current.threadKey), moved);
4784
- return { kind: "rebound", moved, rebound };
4810
+ return { kind: "rebound", moved, rebound, keepTree: false };
4785
4811
  }
4786
4812
 
4787
4813
  /** The release's word on what the run pushed (`/detach` `pushed`, item
@@ -4809,6 +4835,10 @@ export class ResidentDO extends Sandbox<Env> {
4809
4835
  refHint: string | null;
4810
4836
  wantSha: string | null;
4811
4837
  reuse: boolean;
4838
+ /** The rebind moved the record alone over a dirty tree already on the
4839
+ * branch (item 16) and promised not to touch it: the worktree step keeps
4840
+ * the tree as it stands instead of item 17's dirty wipe. */
4841
+ keepTree: boolean;
4812
4842
  slug: string;
4813
4843
  t0: number;
4814
4844
  facts: RepoFacts;
@@ -4820,7 +4850,7 @@ export class ResidentDO extends Sandbox<Env> {
4820
4850
  rebound?: Rebound;
4821
4851
  rebindRefused?: RebindRefused;
4822
4852
  }): Promise<AttachOk | ThreadErr> {
4823
- const { threadKey, refHint, wantSha, reuse, slug, t0, facts, record, binding, mode, rollback } = input;
4853
+ const { threadKey, refHint, wantSha, reuse, keepTree, slug, t0, facts, record, binding, mode, rollback } = input;
4824
4854
  const { rebound, rebindRefused } = input;
4825
4855
 
4826
4856
  // Command-level token mint — before the lock so mint latency
@@ -4899,6 +4929,7 @@ export class ResidentDO extends Sandbox<Env> {
4899
4929
  const recreated = await this.ensureThreadWorktree(binding, sha, mode.originUrl, mode.modeSwitch, {
4900
4930
  detached: target.kind === "sha",
4901
4931
  reuse,
4932
+ keepTree,
4902
4933
  });
4903
4934
  return { sha, threadLockKey, recreated };
4904
4935
  }, ATTACH_MUTEX_WAIT_MS);
@@ -5003,10 +5034,13 @@ export class ResidentDO extends Sandbox<Env> {
5003
5034
  * trees; a reusing attach (`opts.reuse`, item 66) keeps a readable tree
5004
5035
  * exactly as it stands (the dirt and the HEAD are the resumed run's own
5005
5036
  * work) and throws `ReuseRefusedError` for one it cannot keep, touching
5006
- * nothing. The decision is the pure `decideWorktree`; this method measures
5007
- * the facts and runs the verdict. Returns true when the tree was
5008
- * (re)created. MUST be called holding the mirror mutex: the clone reads
5009
- * the mirror.
5037
+ * nothing; a provisioning attach whose own rebind moved the record alone
5038
+ * over a dirty tree already on the branch (`opts.keepTree`, item 16) keeps
5039
+ * that tree as it stands too — the dirt is why the rebind declined to touch
5040
+ * it — and provisions one that turns out not to be there. The decision is
5041
+ * the pure `decideWorktree`; this method measures the facts and runs the
5042
+ * verdict. Returns true when the tree was (re)created. MUST be called
5043
+ * holding the mirror mutex: the clone reads the mirror.
5010
5044
  *
5011
5045
  * "Dirty" is tracked-file dirt (`status --porcelain -uno`): untracked
5012
5046
  * scratch files are the thread's own state and survive re-attach.
@@ -5029,8 +5063,11 @@ export class ResidentDO extends Sandbox<Env> {
5029
5063
  /** `detached`: the bound ref is gone from the mirror and `sha` is the
5030
5064
  * expected commit it still holds (item 51) — the tree is checked out at
5031
5065
  * that commit, detached, instead of at a branch. `reuse`: a resumed
5032
- * run's attach (item 66): keep the tree as it stands, or refuse. */
5033
- opts: { detached: boolean; reuse: boolean } = { detached: false, reuse: false },
5066
+ * run's attach (item 66): keep the tree as it stands, or refuse.
5067
+ * `keepTree`: this attach's rebind moved the record alone over a dirty
5068
+ * tree already on the branch (item 16): keep a readable tree as it
5069
+ * stands, provision one that is not there. */
5070
+ opts: { detached: boolean; reuse: boolean; keepTree: boolean } = { detached: false, reuse: false, keepTree: false },
5034
5071
  ): Promise<boolean> {
5035
5072
  const wt = binding.worktreePath;
5036
5073
  const threadDir = parentDir(wt);
@@ -5067,7 +5104,14 @@ export class ResidentDO extends Sandbox<Env> {
5067
5104
  }
5068
5105
  }
5069
5106
  }
5070
- const decision = decideWorktree({ reuse: opts.reuse, modeSwitch, sha, worktreePath: wt, facts });
5107
+ const decision = decideWorktree({
5108
+ reuse: opts.reuse,
5109
+ keepTree: opts.keepTree,
5110
+ modeSwitch,
5111
+ sha,
5112
+ worktreePath: wt,
5113
+ facts,
5114
+ });
5071
5115
  if (decision.kind === "refuse") throw new ReuseRefusedError(decision.why);
5072
5116
  if (decision.kind === "reuse") return false;
5073
5117
 
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "switchboard",
3
- "version": "1.231.1",
3
+ "version": "1.233.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "switchboard",
9
- "version": "1.231.1",
9
+ "version": "1.233.0",
10
10
  "license": "Apache-2.0",
11
11
  "workspaces": [
12
12
  "web",
@@ -20032,7 +20032,7 @@
20032
20032
  },
20033
20033
  "packages/switchboard": {
20034
20034
  "name": "@coreplane/switchboard",
20035
- "version": "1.231.1",
20035
+ "version": "1.233.0",
20036
20036
  "license": "Apache-2.0",
20037
20037
  "dependencies": {
20038
20038
  "@earendil-works/pi-ai": "0.85.1",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchboard",
3
- "version": "1.231.1",
3
+ "version": "1.233.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.231.1",
3
- "commit": "a22b34899cfe2b3f7d343e46b38c50ea75ebfa3e",
4
- "builtAt": "2026-09-16T00:35:27.645Z"
2
+ "version": "1.233.0",
3
+ "commit": "4c96f69d476e76e2bc768a9eb6d505b683fddd36",
4
+ "builtAt": "2026-09-16T15:18:25.322Z"
5
5
  }
@@ -44,6 +44,7 @@ import {
44
44
  settleUnit,
45
45
  startUnit,
46
46
  type ChildFacts,
47
+ type MergeReadyFacts,
47
48
  type CoordinatorAction,
48
49
  type PlanGraph,
49
50
  type PlanUnitNode,
@@ -278,7 +279,13 @@ function prCheckReturn(step: string, a: BotAnswer): StepReturn {
278
279
  return {
279
280
  type: "pr-check",
280
281
  step,
281
- pr: { state: "open", prNumber, url, ...(typeof headSha === "string" ? { headSha } : {}) },
282
+ pr: {
283
+ state: "open",
284
+ prNumber,
285
+ url,
286
+ ...(typeof headSha === "string" ? { headSha } : {}),
287
+ ...(typeof a.body.autoMergeEnabled === "boolean" ? { autoMergeEnabled: a.body.autoMergeEnabled } : {}),
288
+ },
282
289
  at,
283
290
  };
284
291
  // A merged pull request is read whole or not at all: the merge commit and
@@ -448,12 +455,36 @@ async function runUnit(
448
455
  const body = { ...tag, index: note.index, agent: note.agent, outcome: note.outcome };
449
456
  await step.do(`${unit}/note/${++notes}`, STEP_CONFIG, () => call(bot, "round", body));
450
457
  } else {
458
+ // A merge_ready ending names the pull request as it is at the APPROVED
459
+ // head (agent-ship item 9): one more pr-check reads the facts fresh —
460
+ // auto-merge may have been switched on since the round's check, or it
461
+ // may already have fired, in which case the answer is `merged` and the
462
+ // report says so rather than naming a gate that has passed. An
463
+ // unreadable answer just leaves the facts out.
464
+ let endFacts: MergeReadyFacts | undefined;
465
+ if (note.ending.kind === "merge_ready") {
466
+ try {
467
+ const check = prCheckReturn(
468
+ `${unit}/end/pr-facts`,
469
+ answerOf(
470
+ "pr-check",
471
+ await step.do(`${unit}/end/pr-facts`, STEP_CONFIG, () => call(bot, "pr-check", tag)),
472
+ ),
473
+ );
474
+ if (check.type === "pr-check" && check.pr.state === "merged")
475
+ endFacts = { merged: { sha: check.pr.sha, mergedAt: check.pr.mergedAt } };
476
+ else if (check.type === "pr-check" && check.pr.state === "open" && check.pr.autoMergeEnabled !== undefined)
477
+ endFacts = { autoMergeEnabled: check.pr.autoMergeEnabled };
478
+ } catch {
479
+ // the report simply omits the fact
480
+ }
481
+ }
451
482
  // The last coding child's run is named so the bot can put its handoff
452
483
  // — the deviations it recorded — on the unit's board issue beside the
453
484
  // ending (agent-ship item 14).
454
485
  const body = {
455
486
  ...tag,
456
- ending: { kind: note.ending.kind, report: renderUnitReport(state) },
487
+ ending: { kind: note.ending.kind, report: renderUnitReport(state, endFacts) },
457
488
  ...(state.pr !== undefined ? { pr: state.pr } : {}),
458
489
  ...(state.lastCodingRunId !== undefined ? { codingRunId: state.lastCodingRunId } : {}),
459
490
  };
@@ -443,7 +443,7 @@ export type ChildFacts =
443
443
  * with no open one — a merged one, `sha` the merge commit on the base. */
444
444
  export type PrCheck =
445
445
  | { state: "none" }
446
- | { state: "open"; prNumber: number; url: string; headSha?: string }
446
+ | { state: "open"; prNumber: number; url: string; headSha?: string; autoMergeEnabled?: boolean }
447
447
  | { state: "merged"; prNumber: number; url: string; sha: string; mergedAt: string };
448
448
 
449
449
  /** What a step answered. Every bot answer carries `at`, the bot's clock — the machine's time. */
@@ -1159,7 +1159,17 @@ function splitReport(s: UnitPipelineState): string {
1159
1159
 
1160
1160
  /** The thread's report for a unit's ending — the ship pipeline's own words
1161
1161
  * for the endings it has, and the merge's for the ones it gains. */
1162
- export function renderUnitReport(s: UnitPipelineState): string {
1162
+ /** What the driver read at the approved head when it composed a `merge_ready`
1163
+ * ending (agent-ship item 9): the pull request's own auto-merge fact, or the
1164
+ * merge that already happened — auto-merge or a person can merge between the
1165
+ * approval and the ending, and the report must describe the pull request as
1166
+ * it is, never a gate that has already passed. */
1167
+ export interface MergeReadyFacts {
1168
+ autoMergeEnabled?: boolean;
1169
+ merged?: { sha: string; mergedAt: string };
1170
+ }
1171
+
1172
+ export function renderUnitReport(s: UnitPipelineState, facts?: MergeReadyFacts): string {
1163
1173
  const e = s.ending;
1164
1174
  if (!e) return "";
1165
1175
  const rounds = `${e.reviewRounds} review round${e.reviewRounds === 1 ? "" : "s"}`;
@@ -1189,7 +1199,14 @@ export function renderUnitReport(s: UnitPipelineState): string {
1189
1199
  `✅ Merge-ready after ${rounds}: ${e.pr.url}`,
1190
1200
  verdictLine,
1191
1201
  declinedLine,
1192
- "Remaining gate: a person's merge — the runner merges only when the instance's `merge` field says runner, and ship never approves.",
1202
+ // What the driver read at the approved head when it composed this
1203
+ // ending (agent-ship item 9): a merge that already happened is named
1204
+ // as such, else the pull request's own auto-merge fact, else the gate.
1205
+ facts?.merged
1206
+ ? `Already merged: ${e.pr.url} (merge commit \`${facts.merged.sha.slice(0, 7)}\`, merged ${facts.merged.mergedAt}) — auto-merge or a person merged it after the approval; the runner merged nothing.`
1207
+ : facts?.autoMergeEnabled
1208
+ ? "Auto-merge is on for this pull request: the approval merges it once checks pass."
1209
+ : "Remaining gate: a person's merge — the runner merges only when the instance's `merge` field says runner, and ship never approves.",
1193
1210
  ].join("\n");
1194
1211
  case "merge_refused":
1195
1212
  return join([
@@ -16,7 +16,11 @@
16
16
  * PROVISIONS (a fresh one). A reusing attach keeps a readable tree exactly
17
17
  * as it stands, dirt and stale HEAD included, and refuses by name a tree it
18
18
  * cannot keep (gone, unreadable, built for the other mode) without touching
19
- * it. A provisioning attach keeps the dirty/stale discipline byte for byte. */
19
+ * it. A provisioning attach keeps the dirty/stale discipline byte for byte —
20
+ * with one exception it is told about (`keepTree`): when its own rebind onto
21
+ * the thread's pull request branch found the tree dirty with that branch
22
+ * already checked out and moved the record alone, the dirt is the thread's
23
+ * own work and the tree is kept as it stands. */
20
24
 
21
25
  export type ParsedReuse = { reuse: boolean } | { error: string };
22
26
 
@@ -58,6 +62,15 @@ export type WorktreeDecision =
58
62
  export function decideWorktree(input: {
59
63
  /** True for a resumed run's attach: keep the tree, never wipe it. */
60
64
  reuse: boolean;
65
+ /** True when this attach's rebind moved the binding onto the branch the
66
+ * tree already had checked out, dirty, and promised not to touch it
67
+ * (residentRebind.ts, the `checkout: false` verdict): the dirt is the
68
+ * thread's own uncommitted work on its own pull request branch, so a
69
+ * readable tree is kept as it stands — dirt and HEAD included, like a
70
+ * resumed run's — while a tree that turns out missing, unreadable or built
71
+ * for the other mode has nothing to keep and is provisioned like any fresh
72
+ * attach's. Absent on every attach that did not make that move. */
73
+ keepTree?: boolean;
61
74
  /** The tree was built for the other mode (read-only against writable). */
62
75
  modeSwitch: boolean;
63
76
  /** The commit a provisioning attach checks out: the ref's tip, or the expected head. */
@@ -65,7 +78,7 @@ export function decideWorktree(input: {
65
78
  worktreePath: string;
66
79
  facts: WorktreeFacts;
67
80
  }): WorktreeDecision {
68
- const { reuse, modeSwitch, sha, worktreePath, facts } = input;
81
+ const { reuse, keepTree = false, modeSwitch, sha, worktreePath, facts } = input;
69
82
  if (modeSwitch) {
70
83
  return reuse
71
84
  ? {
@@ -92,7 +105,9 @@ export function decideWorktree(input: {
92
105
  }
93
106
  // A reusing attach judges nothing past readability: the dirt and the HEAD are the run's own state.
94
107
  if (reuse) return { kind: "reuse" };
95
- if (facts.dirty) return { kind: "recreate", why: "dirty" };
108
+ // The rebind promised a dirty tree on its own branch would not be touched;
109
+ // the dirt is the reason it made that promise, so it is not a reason to wipe.
110
+ if (facts.dirty) return keepTree ? { kind: "reuse" } : { kind: "recreate", why: "dirty" };
96
111
  if (facts.head !== sha && facts.descendsFromTip !== true) return { kind: "recreate", why: "stale" };
97
112
  return { kind: "reuse" };
98
113
  }
package/dist/cli.js CHANGED
@@ -9369,6 +9369,13 @@ function wakeWaitBudget(commandBudgetMs) {
9369
9369
  if (commandBudgetMs === void 0 || !Number.isFinite(commandBudgetMs)) return WAKE_WAIT_MAX_MS;
9370
9370
  return Math.max(0, Math.min(WAKE_WAIT_MAX_MS, Math.trunc(commandBudgetMs)));
9371
9371
  }
9372
+ function saysContainerGone(data) {
9373
+ if (data.reason === "runtime-replaced") return true;
9374
+ return data.needs === "attach" && typeof data.error === "string" && data.error.startsWith("worktree-missing:");
9375
+ }
9376
+ function containerGoneMessage(error) {
9377
+ return `${error.trim()}; the command was not run again`;
9378
+ }
9372
9379
  function sandboxRestartedMessage(woke) {
9373
9380
  const secs2 = Math.round(woke.waitedMs / 1e3);
9374
9381
  return `the resident container exited under a rollout and woke from its snapshot after ${secs2}s; the worktree was recreated at ${woke.ref}@${woke.sha.slice(0, 7)}, so uncommitted changes and unpushed commits from earlier in this run are gone`;
@@ -9629,10 +9636,11 @@ var init_resident = __esm({
9629
9636
  this.opts = opts;
9630
9637
  }
9631
9638
  opts;
9632
- /** Consecutive `runtime-replaced` outcomes with no successful op between
9633
- * them. One is a deploy that swapped the resident isolate under a command
9634
- * (routine, recoverable); two in a row is a flapping resident and becomes
9635
- * infra so the runner's fail-fast still has teeth. */
9639
+ /** Consecutive `runtime-replaced` answers on the idempotent routes (/read,
9640
+ * /write) with no successful op between them. One is a deploy that swapped
9641
+ * the resident isolate under a call (routine: re-attach and re-issue); two
9642
+ * in a row is a flapping resident and becomes infra. /exec never counts
9643
+ * here: its `runtime-replaced` is the typed restart (`opWithReattach`). */
9636
9644
  runtimeReplacedStreak = 0;
9637
9645
  lastBinding;
9638
9646
  /** The thread's binding as the resident answered it on the most recent
@@ -9807,15 +9815,20 @@ var init_resident = __esm({
9807
9815
  return { released: false, reason: err2 instanceof Error ? err2.message : String(err2) };
9808
9816
  }
9809
9817
  }
9810
- /** Run a route; on needs:"attach" (evicted/recycled worktree, in-body for
9818
+ /** Run a route; on needs:"attach" for an evicted worktree (in-body for
9811
9819
  * /exec, 409 for /read //write) re-attach ONCE and retry, then fail legibly.
9812
9820
  *
9813
- * A `reason:"runtime-replaced"` answer (the resident isolate was swapped by
9814
- * a deploy while the op ran) also re-attaches once — proving the new isolate
9815
- * serves this thread — but the op is re-issued only for the idempotent
9816
- * routes (/read, /write). /exec is handed back to the caller as-is: the
9817
- * command may have started, so it is never blind-retried. Two such answers
9818
- * with no success between them are infra (a flapping resident).
9821
+ * An answer saying the container under the thread is gone
9822
+ * (`saysContainerGone`: `reason:"runtime-replaced"`, the isolate was swapped
9823
+ * by a deploy while the op ran; `worktree-missing`, the container disk was
9824
+ * recycled since the last attach) is, on /exec, the typed
9825
+ * `ExecSandboxRestartedError` at once — the word the pi harness keys on
9826
+ * (harness-pi item 16) — before any recovery, so nothing the recovery
9827
+ * meets can hide it; the command may have started and is never re-issued.
9828
+ * On the idempotent routes (/read, /write) a `runtime-replaced` re-attaches
9829
+ * once — proving the new isolate serves this thread — and re-issues the op;
9830
+ * two such answers with no success between them are infra (a flapping
9831
+ * resident).
9819
9832
  *
9820
9833
  * A refusal naming a container that just exited (item 65) waits for the
9821
9834
  * wake first (`awaitWake`): the refusal came before the command started, so
@@ -9834,6 +9847,10 @@ var init_resident = __esm({
9834
9847
  ),
9835
9848
  { kind: "infra", code: "attach" }
9836
9849
  );
9850
+ const goneUnderThread = (data) => {
9851
+ if (route3 === "/exec" && saysContainerGone(data))
9852
+ throw new ExecSandboxRestartedError(containerGoneMessage(String(data.error)), 0);
9853
+ };
9837
9854
  let r = await this.call(route3, body, callTimeoutMs, signal2, span);
9838
9855
  if (isContainerRolling(r.data.error)) {
9839
9856
  const woke = await this.awaitWake(route3, String(r.data.error), { signal: signal2, budgetMs: opts.waitBudgetMs, span });
@@ -9848,19 +9865,19 @@ var init_resident = __esm({
9848
9865
  );
9849
9866
  }
9850
9867
  }
9868
+ goneUnderThread(r.data);
9851
9869
  if (r.data.needs === "attach") {
9852
9870
  await this.attach(span);
9853
9871
  r = await this.call(route3, body, callTimeoutMs, signal2, span);
9872
+ goneUnderThread(r.data);
9854
9873
  if (r.data.needs === "attach") throw stillGone(r.data);
9855
9874
  }
9856
9875
  if (r.data.reason === "runtime-replaced") {
9857
9876
  this.noteRuntimeReplaced(route3, r.data);
9858
9877
  await this.attach(span);
9859
- if (route3 === "/read" || route3 === "/write") {
9860
- r = await this.call(route3, body, callTimeoutMs, signal2, span);
9861
- if (r.data.reason === "runtime-replaced") this.noteRuntimeReplaced(route3, r.data);
9862
- if (r.data.needs === "attach") throw stillGone(r.data);
9863
- }
9878
+ r = await this.call(route3, body, callTimeoutMs, signal2, span);
9879
+ if (r.data.reason === "runtime-replaced") this.noteRuntimeReplaced(route3, r.data);
9880
+ if (r.data.needs === "attach") throw stillGone(r.data);
9864
9881
  }
9865
9882
  if (typeof r.data.error !== "string" || !r.data.error) this.runtimeReplacedStreak = 0;
9866
9883
  return r;
@@ -9944,10 +9961,6 @@ var init_resident = __esm({
9944
9961
  if (status3 === 400 && typeof data.error === "string" && data.error && data.needs === void 0) {
9945
9962
  throw new Error(`resident /exec: ${data.error}`);
9946
9963
  }
9947
- if (data.reason === "runtime-replaced") {
9948
- return `${String(data.error)}
9949
- The command may have started; re-check its effects (e.g. git status, the files it writes) before re-running it.`;
9950
- }
9951
9964
  if (typeof data.error === "string" && data.error) {
9952
9965
  throw classifyError(new ExecInfraError(`resident /exec: ${data.error}`), { kind: "infra" });
9953
9966
  }
@@ -22466,6 +22479,10 @@ function answerProblems(a) {
22466
22479
  problems.push(
22467
22480
  "a model provider is needed: --anthropic-key <key>, or --openai-compatible <baseUrl> --model <name> [--model-key <key>]"
22468
22481
  );
22482
+ if (!anthropic && !endpoint3 && a.openrouterKey !== void 0)
22483
+ problems.push(
22484
+ "--openrouter-key keeps the example's openrouter block beside the provider the default models run on; alone it names no default model \u2014 add --anthropic-key <key>, or run every agent on OpenRouter with --openai-compatible https://openrouter.ai/api/v1 --model <vendor>/<model> --model-key <key>"
22485
+ );
22469
22486
  if (endpoint3 && !anthropic && a.model === void 0)
22470
22487
  problems.push("--openai-compatible needs --model <name>: the model every agent runs on at that endpoint");
22471
22488
  if (!endpoint3 && a.model !== void 0) problems.push("--model only means something with --openai-compatible");
@@ -22489,6 +22506,7 @@ function envFile(a, template) {
22489
22506
  if (a.slackAppToken !== void 0) values.SLACK_APP_TOKEN = a.slackAppToken;
22490
22507
  if (a.anthropicKey !== void 0) values.ANTHROPIC_API_KEY = a.anthropicKey;
22491
22508
  if (a.modelKey !== void 0) values[OPENAI_KEY_ENV] = a.modelKey;
22509
+ if (a.openrouterKey !== void 0) values[OPENROUTER_KEY_ENV] = a.openrouterKey;
22492
22510
  if (a.githubAppId !== void 0) values.GITHUB_APP_ID = a.githubAppId;
22493
22511
  if (a.githubInstallationId !== void 0) values.GITHUB_APP_INSTALLATION_ID = a.githubInstallationId;
22494
22512
  if (a.githubPrivateKey !== void 0) values.GITHUB_APP_PRIVATE_KEY = a.githubPrivateKey;
@@ -22532,6 +22550,7 @@ function configFile(a, template) {
22532
22550
  const doc = YAML2.parseDocument(template);
22533
22551
  doc.set("organization", a.organization);
22534
22552
  if (a.anthropicKey === void 0) doc.deleteIn(["providers", "anthropic"]);
22553
+ if (a.openrouterKey === void 0) doc.deleteIn(["providers", OPENROUTER_PROVIDER]);
22535
22554
  if (a.openaiCompatible === void 0) doc.deleteIn(["providers", OPENAI_PROVIDER]);
22536
22555
  else {
22537
22556
  doc.setIn(["providers", OPENAI_PROVIDER, "baseUrl"], a.openaiCompatible);
@@ -22598,7 +22617,7 @@ function nextCommands(world, profile) {
22598
22617
  function definedOnly(env) {
22599
22618
  return Object.fromEntries(Object.entries(env).filter((e) => e[1] !== void 0));
22600
22619
  }
22601
- var ENV_PATH, CONFIG_PATH, PROFILE_PATH2, SECRET_ENV_NAMES, OPENAI_PROVIDER, OPENAI_KEY_ENV, DEFAULT_MODEL_AGENTS, ENV_LINE;
22620
+ var ENV_PATH, CONFIG_PATH, PROFILE_PATH2, SECRET_ENV_NAMES, OPENAI_PROVIDER, OPENAI_KEY_ENV, OPENROUTER_PROVIDER, OPENROUTER_KEY_ENV, DEFAULT_MODEL_AGENTS, ENV_LINE;
22602
22621
  var init_plan2 = __esm({
22603
22622
  "../../src/setup/plan.ts"() {
22604
22623
  "use strict";
@@ -22614,10 +22633,13 @@ var init_plan2 = __esm({
22614
22633
  "SLACK_APP_TOKEN",
22615
22634
  "ANTHROPIC_API_KEY",
22616
22635
  "OPENAI_API_KEY",
22636
+ "OPENROUTER_API_KEY",
22617
22637
  "GITHUB_APP_PRIVATE_KEY"
22618
22638
  ];
22619
22639
  OPENAI_PROVIDER = "openai";
22620
22640
  OPENAI_KEY_ENV = "OPENAI_API_KEY";
22641
+ OPENROUTER_PROVIDER = "openrouter";
22642
+ OPENROUTER_KEY_ENV = "OPENROUTER_API_KEY";
22621
22643
  DEFAULT_MODEL_AGENTS = ["general", "coding", "review", "explore"];
22622
22644
  ENV_LINE = /^(#\s*)?([A-Z][A-Z0-9_]*)=(.*)$/;
22623
22645
  }
@@ -22666,6 +22688,7 @@ async function gatherAnswers(o, setup) {
22666
22688
  ...given(openaiCompatible) ? { openaiCompatible } : {},
22667
22689
  ...given(model) ? { model } : {},
22668
22690
  ...given(modelKey) ? { modelKey } : {},
22691
+ ...given(o.openrouterKey) ? { openrouterKey: o.openrouterKey } : {},
22669
22692
  ...given(slackAppToken) ? { slackAppToken } : {},
22670
22693
  ...given(slackBotToken) ? { slackBotToken } : {},
22671
22694
  ...given(o.githubAppId) ? { githubAppId: o.githubAppId } : {},
@@ -22716,6 +22739,9 @@ var init_setup = __esm({
22716
22739
  ),
22717
22740
  model: z19.string().min(1).optional().describe("with --openai-compatible: the model every agent runs on at that endpoint (openai/<model>)"),
22718
22741
  modelKey: secret.optional().describe("with --openai-compatible: the endpoint's key \u2192 OPENAI_API_KEY (a local endpoint needs none)"),
22742
+ openrouterKey: secret.optional().describe(
22743
+ "an OpenRouter API key \u2192 OPENROUTER_API_KEY; keeps the example's `openrouter` provider beside the one the default models run on (a preset's model is then openrouter/<vendor>/<model>)"
22744
+ ),
22719
22745
  slackAppToken: secret.optional().describe("the Slack app-level token (xapp-\u2026) \u2192 SLACK_APP_TOKEN"),
22720
22746
  slackBotToken: secret.optional().describe("the Slack bot token (xoxb-\u2026) \u2192 SLACK_BOT_TOKEN"),
22721
22747
  githubAppId: digits.optional().describe("the GitHub App's id \u2192 GITHUB_APP_ID"),
@@ -25072,7 +25098,8 @@ async function findOpenPrByHead(repo, branch2) {
25072
25098
  return {
25073
25099
  number: first.number,
25074
25100
  htmlUrl: first.html_url,
25075
- ...first.head?.sha ? { headSha: first.head.sha } : {}
25101
+ ...first.head?.sha ? { headSha: first.head.sha } : {},
25102
+ ..."auto_merge" in first ? { autoMergeEnabled: first.auto_merge !== null } : {}
25076
25103
  };
25077
25104
  }
25078
25105
  async function findMergedPrByHead(repo, branch2) {
@@ -25182,7 +25209,6 @@ async function fetchRepoShipInfo(repo) {
25182
25209
  const data = await res.json().catch(() => null);
25183
25210
  if (!data || typeof data !== "object") return void 0;
25184
25211
  return {
25185
- ...typeof data.allow_auto_merge === "boolean" ? { allowAutoMerge: data.allow_auto_merge } : {},
25186
25212
  ...typeof data.default_branch === "string" && data.default_branch ? { defaultBranch: data.default_branch } : {}
25187
25213
  };
25188
25214
  }
@@ -25247,7 +25273,8 @@ async function fetchPullRequestFacts(pr) {
25247
25273
  sameRepoHead,
25248
25274
  ...typeof data.base?.ref === "string" && data.base.ref ? { baseRef: data.base.ref } : {},
25249
25275
  ...typeof data.html_url === "string" ? { htmlUrl: data.html_url } : {},
25250
- ...typeof data.title === "string" && data.title ? { title: data.title } : {}
25276
+ ...typeof data.title === "string" && data.title ? { title: data.title } : {},
25277
+ ..."auto_merge" in data ? { autoMergeEnabled: data.auto_merge !== null } : {}
25251
25278
  };
25252
25279
  }
25253
25280
  async function mergePullRequest(pr, opts) {
@@ -25584,7 +25611,7 @@ async function resolveRepoContext(msg, history = [], probe, residentSlugs, recor
25584
25611
  const inherited = inheritedPr(thread, records, repo);
25585
25612
  if (inherited) {
25586
25613
  const head = await openPrHeadSha(inherited);
25587
- if ("sha" in head) {
25614
+ if (!("reason" in head)) {
25588
25615
  out.pr = inherited.number;
25589
25616
  out.headSha = head.sha;
25590
25617
  if (head.base) out.baseRef = head.base;
@@ -25599,6 +25626,9 @@ async function resolveRepoContext(msg, history = [], probe, residentSlugs, recor
25599
25626
  }
25600
25627
  } else {
25601
25628
  out.prUnpostable = { number: inherited.number, reason: head.reason };
25629
+ if (inherited.source === "record" && head.reason === "closed" && head.sha !== void 0) {
25630
+ out.closedRecordPr = { number: inherited.number, headSha: head.sha, merged: head.merged };
25631
+ }
25602
25632
  }
25603
25633
  }
25604
25634
  }
@@ -25609,8 +25639,14 @@ function ownPrOf(ctx) {
25609
25639
  return { number: ctx.pr, ref: ctx.ref };
25610
25640
  }
25611
25641
  function recordPrOf(ctx) {
25612
- if (!ctx.prFromRecord || ctx.pr === void 0 || ctx.headSha === void 0) return void 0;
25613
- return { number: ctx.pr, headSha: ctx.headSha };
25642
+ if (ctx.prFromRecord && ctx.pr !== void 0 && ctx.headSha !== void 0) {
25643
+ return { number: ctx.pr, headSha: ctx.headSha, state: "open" };
25644
+ }
25645
+ if (ctx.closedRecordPr !== void 0) {
25646
+ const { number, headSha, merged } = ctx.closedRecordPr;
25647
+ return { number, headSha, state: merged ? "merged" : "closed" };
25648
+ }
25649
+ return void 0;
25614
25650
  }
25615
25651
  function inheritedPr(thread, records, repo) {
25616
25652
  const turn = thread.pr && thread.pr.repo === repo ? { repo, number: thread.pr.number, source: "turn", at: thread.prAt } : void 0;
@@ -25620,7 +25656,8 @@ function inheritedPr(thread, records, repo) {
25620
25656
  }
25621
25657
  async function openPrHeadSha(pr) {
25622
25658
  const head = await prHead(pr).catch(() => void 0);
25623
- if (head?.state === "closed") return { reason: "closed" };
25659
+ if (head?.state === "closed")
25660
+ return { reason: "closed", ...head.sha ? { sha: head.sha } : {}, merged: head.merged };
25624
25661
  if (head?.state === "open" && head.sha) {
25625
25662
  return {
25626
25663
  sha: head.sha,
@@ -25686,9 +25723,17 @@ async function prHead(pr) {
25686
25723
  const headRepo = data.head?.repo?.full_name?.toLowerCase();
25687
25724
  const ref = data.head?.ref && headRepo === pr.repo ? validRef(data.head.ref) : void 0;
25688
25725
  const state = data.state === "open" || data.state === "closed" ? data.state : void 0;
25689
- const sha = ref !== void 0 ? preferRefTip(`${pr.repo}#${pr.number}`, prSha, await headRefTipSha(pr.repo, ref, headers), ref) : prSha;
25726
+ const sha = ref !== void 0 && state !== "closed" ? preferRefTip(`${pr.repo}#${pr.number}`, prSha, await headRefTipSha(pr.repo, ref, headers), ref) : prSha;
25690
25727
  const size = sha === prSha ? prSizeOf(data) : void 0;
25691
- return { ref, sha, base, ...size ? { size } : {}, state, ...facts2 ? { facts: facts2 } : {} };
25728
+ return {
25729
+ ref,
25730
+ sha,
25731
+ base,
25732
+ ...size ? { size } : {},
25733
+ state,
25734
+ merged: data.merged === true,
25735
+ ...facts2 ? { facts: facts2 } : {}
25736
+ };
25692
25737
  }
25693
25738
  function prSizeOf(data) {
25694
25739
  const n2 = (x) => typeof x === "number" && Number.isInteger(x) && x >= 0;
@@ -32445,13 +32490,13 @@ function removeScript(dir) {
32445
32490
  return `rm -rf ${shellQuote(dir)}`;
32446
32491
  }
32447
32492
  function stdoutOf(operation, out) {
32448
- const exit = parseExitPrefix(out);
32449
- if (exit.failed || RUNTIME_WORD.test(out)) throw new PiContainerError(operation, out);
32493
+ if (RUNTIME_WORD.test(out)) throw new PiContainerRuntimeReplacedError(operation, out);
32494
+ if (parseExitPrefix(out).failed) throw new PiContainerError(operation, out);
32450
32495
  const cut = out.indexOf(STDERR_MARK);
32451
32496
  const stdout = cut >= 0 ? out.slice(0, cut) : out;
32452
32497
  return stdout === "(no output)" ? "" : stdout;
32453
32498
  }
32454
- var PiContainerError, WRITE_CHUNK_CHARS, INLINE_LINE_CHARS, LOG_READ_BYTES, MESSAGE_UPDATE_MARK, LOG_FILTER, IDENTITY_WORD, STDERR_MARK, RUNTIME_WORD, OP_TIMEOUT_MS, ExecPiContainer;
32499
+ var PiContainerError, WRITE_CHUNK_CHARS, INLINE_LINE_CHARS, LOG_READ_BYTES, MESSAGE_UPDATE_MARK, LOG_FILTER, IDENTITY_WORD, STDERR_MARK, RUNTIME_WORD, PiContainerRuntimeReplacedError, OP_TIMEOUT_MS, ExecPiContainer;
32455
32500
  var init_container = __esm({
32456
32501
  "../../src/core/harness/pi/container.ts"() {
32457
32502
  "use strict";
@@ -32473,7 +32518,13 @@ var init_container = __esm({
32473
32518
  LOG_FILTER = `grep --line-buffered -v ${shellQuote(MESSAGE_UPDATE_MARK)}`;
32474
32519
  IDENTITY_WORD = /^[A-Za-z0-9-]{1,64}$/;
32475
32520
  STDERR_MARK = "\n--- stderr ---\n";
32476
- RUNTIME_WORD = /^(?:runtime-unreachable:|runtime-replaced)/;
32521
+ RUNTIME_WORD = /\bruntime-(?:unreachable|replaced)\b/;
32522
+ PiContainerRuntimeReplacedError = class extends PiContainerError {
32523
+ constructor(operation, detail) {
32524
+ super(operation, detail);
32525
+ this.name = "PiContainerRuntimeReplacedError";
32526
+ }
32527
+ };
32477
32528
  OP_TIMEOUT_MS = 6e4;
32478
32529
  ExecPiContainer = class {
32479
32530
  constructor(executor) {
@@ -33614,10 +33665,8 @@ function settlementAnswer(s) {
33614
33665
  return { content: [{ type: "text", text: settlementText(s) }], isError: true };
33615
33666
  }
33616
33667
  function saysContainerReplaced(err2) {
33617
- if (err2 instanceof ExecSandboxRestartedError) return true;
33618
- if (!(err2 instanceof Error)) return false;
33619
- const text = err2.message.replace(/^pi container: [a-z]+ failed — /, "").replace(/^exit \d+: /, "");
33620
- return /^(?:runtime-unreachable:|runtime-replaced)/.test(text);
33668
+ if (err2 instanceof ExecSandboxRestartedError || err2 instanceof PiContainerRuntimeReplacedError) return true;
33669
+ return err2 instanceof Error && RUNTIME_WORD.test(err2.message);
33621
33670
  }
33622
33671
  function replacedCallNote(tool) {
33623
33672
  return `The container running pi was replaced while this ${tool} call was in flight; its result was lost \u2014 re-check its effects before re-running it.`;
@@ -34254,6 +34303,7 @@ var init_harness = __esm({
34254
34303
  init_runEvents();
34255
34304
  init_threadAdmission();
34256
34305
  init_bridge2();
34306
+ init_container();
34257
34307
  init_mirror();
34258
34308
  init_process();
34259
34309
  init_protocol();
@@ -34719,14 +34769,30 @@ async function runCodingPrPostStep(input) {
34719
34769
  const candidates = [target.baseRef, target.bindingRef, target.resolvedRef];
34720
34770
  const base = prDescription ? await resolveBaseRefLazy(candidates, repo, input.fetchRepoInfo) : resolveBaseRef(candidates, void 0);
34721
34771
  const pushedBranch = branch2 !== void 0 && branch2 !== base && pushed;
34722
- if (prDescription && branch2 !== void 0 && branch2 === base && target.ownPr !== void 0) {
34723
- const { number, headSha: prHead2 } = target.ownPr;
34772
+ const ownPr = target.ownPr;
34773
+ const pushedPastOwnPr = ownPr !== void 0 && pushed && headSha !== void 0 && !sameCommit2(headSha, ownPr.headSha) ? headSha : void 0;
34774
+ if (prDescription && ownPr !== void 0 && ownPr.state !== "open" && pushedPastOwnPr !== void 0 && branch2 !== void 0) {
34775
+ const url = `https://github.com/${repo}/pull/${ownPr.number}`;
34776
+ console.log(
34777
+ `[pr-post] ${logKey} skipped: ${branchLog} was pushed past the thread's ${ownPr.state} ${repo}#${ownPr.number} (its head ${ownPr.headSha.slice(0, 7)}, the workspace at ${pushedPastOwnPr.slice(0, 7)}) \u2014 a new PR is needed`
34778
+ );
34779
+ input.publish({
34780
+ type: "run_note",
34781
+ kind: "pr_not_opened",
34782
+ summary: `no PR opened: ${branch2} was pushed past the thread's ${ownPr.state} pull request #${ownPr.number}; the new commits need a new pull request`,
34783
+ at: systemClock()
34784
+ });
34785
+ return `\u26A0\uFE0F A PR description was submitted and \`${branch2}\`${branchNote} was pushed to \`${pushedPastOwnPr.slice(0, 7)}\`, but this thread's pull request ${url} is ${ownPr.state} and that branch is its head \u2014 the new commits need a new pull request, so nothing was edited: compare & open manually: ${compareUrl}`;
34786
+ }
34787
+ if (prDescription && ownPr !== void 0 && (branch2 === base || ownPr.state !== "open")) {
34788
+ const { number, headSha: prHead2, state } = ownPr;
34724
34789
  const url = `https://github.com/${repo}/pull/${number}`;
34790
+ const standing = state === "open" ? "" : ` (${state})`;
34725
34791
  try {
34726
34792
  const body = renderPrDescriptionMarkdown(prDescription, { repo, headSha: prHead2 });
34727
34793
  await input.updatePullRequest(repo, number, { title: prDescription.title, body });
34728
34794
  console.log(
34729
- `[pr-post] ${logKey} updated ${repo}#${number} \u2014 the thread's own pull request, nothing pushed (workspace on ${branchLog}; rendered at its head ${prHead2.slice(0, 7)})`
34795
+ `[pr-post] ${logKey} updated ${repo}#${number} \u2014 the thread's own pull request${standing}, nothing pushed past its head (workspace on ${branchLog}; rendered at its head ${prHead2.slice(0, 7)})`
34730
34796
  );
34731
34797
  input.publish({ type: "pr_opened", url, number, created: false, at: systemClock() });
34732
34798
  input.publish({
@@ -34734,7 +34800,7 @@ async function runCodingPrPostStep(input) {
34734
34800
  ...submittedPrDescriptionArtifact(prDescription, { repo, pr: number, headSha: prHead2, body }),
34735
34801
  at: systemClock()
34736
34802
  });
34737
- return `\u{1F500} PR updated: ${url} \u2014 body re-rendered at \`${prHead2.slice(0, 7)}\``;
34803
+ return state === "open" ? `\u{1F500} PR updated: ${url} \u2014 body re-rendered at \`${prHead2.slice(0, 7)}\`` : `\u{1F500} PR updated: ${url} \u2014 body re-rendered at \`${prHead2.slice(0, 7)}\` (the pull request is ${state}; its description was edited in place)`;
34738
34804
  } catch (err2) {
34739
34805
  const reason2 = err2 instanceof Error ? err2.message : String(err2);
34740
34806
  console.error(`[pr-post] ${logKey} update failed for ${repo}#${number}: ${reason2}`);
@@ -35677,151 +35743,6 @@ var init_runLoop = __esm({
35677
35743
  }
35678
35744
  });
35679
35745
 
35680
- // ../../src/core/ship/preflight.ts
35681
- function shipTaskText(requestText, repo) {
35682
- let t = requestText.replace(/<((?:https?):\/\/[^|>\s]+)(?:\|[^>]*)?>/gi, " $1 ");
35683
- t = t.replace(/https?:\/\/\S+/gi, " ");
35684
- const slug = repo.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
35685
- t = t.replace(new RegExp(`\\bin\\s+${slug}\\s*:?`, "gi"), " ");
35686
- t = t.replace(new RegExp(`\\b${slug}(#\\d+)?\\b`, "gi"), " ");
35687
- t = t.replace(/\b[a-z0-9][\w.-]*\/[\w.-]+#\d+\b/gi, " ");
35688
- return t.replace(/\s+/g, " ").trim().replace(/^[:,\-—.\s]+/, "").trim();
35689
- }
35690
- async function shipPreflight(input) {
35691
- const { repoCtx } = input;
35692
- if (!input.channelId.startsWith("slack:") && !input.channelId.startsWith("cli:")) {
35693
- const base = input.runsBase?.trim();
35694
- const page2 = base ? `${base.replace(/\/+$/, "")}/runs` : "the bot's /runs page";
35695
- return refuse2(
35696
- "channel",
35697
- "not started (Slack/CLI only)",
35698
- `\u{1F6AB} \`agent:ship\` runs only from Slack or the CLI \u2014 this adapter is single-shot and cannot hold a pipeline-length run. Start it there instead, and watch pipelines on the run page (${page2}).`
35699
- );
35700
- }
35701
- const missing = ["ship", "coding", "review"].filter((a) => !input.gates.canRunAgent(a));
35702
- if (missing.length > 0) {
35703
- return refuse2(
35704
- `permission (${missing.join(", ")})`,
35705
- "not started (permissions)",
35706
- `\u{1F6AB} Running \`ship\` drives \`coding\` and \`review\` child rounds, and you're not on the allowlist for ${missing.map((a) => `\`${a}\``).join(", ")}. Ask ${input.gates.adminsHint()} for access.`
35707
- );
35708
- }
35709
- const repo = repoCtx.repo;
35710
- if (!repo) {
35711
- return refuse2(
35712
- "no repo",
35713
- "not started (no repository)",
35714
- "\u{1F6AB} `agent:ship` needs a target repository \u2014 name it in the request, e.g. `agent:ship in owner/repo: <task>`."
35715
- );
35716
- }
35717
- const info = await input.repoInfo(repo).catch(() => void 0);
35718
- if (info?.allowAutoMerge !== false) {
35719
- const reason2 = info?.allowAutoMerge === true ? `auto-merge is enabled on \`${repo}\` \u2014 ship's approving LGTM triggers the org auto-approve workflow, and with auto-merge on the PR would merge with no human. Disable auto-merge on the repo to use ship.` : `could not verify that auto-merge is disabled on \`${repo}\` (the repository lookup failed or answered without the setting) \u2014 refusing fail-closed.`;
35720
- return refuse2("auto-merge", "not started (auto-merge)", `\u{1F6AB} ${reason2}`);
35721
- }
35722
- const task = shipTaskText(input.requestText, repo);
35723
- if (repoCtx.prUnpostable?.reason === "unreachable") {
35724
- return refuse2(
35725
- "thread PR unreachable",
35726
- "not started (PR unverifiable)",
35727
- `\u{1F6AB} This thread names PR ${repo}#${repoCtx.prUnpostable.number} but it could not be fetched to run ship's entry checks \u2014 refusing fail-closed. Retry in a moment, or check the PR on GitHub.`
35728
- );
35729
- }
35730
- if (repoCtx.pr !== void 0) {
35731
- const where = `${repo}#${repoCtx.pr}`;
35732
- const facts2 = await input.prFacts({ repo, number: repoCtx.pr }).catch(() => void 0);
35733
- const fallThrough = !facts2 && task !== "" && repoCtx.prFromMessage === true;
35734
- if (!facts2 && !fallThrough) {
35735
- return refuse2(
35736
- "PR facts unavailable",
35737
- "not started (PR unverifiable)",
35738
- `\u{1F6AB} Could not fetch ${where} to run ship's entry checks (open? bot-authored? same-repo head?) \u2014 refusing fail-closed. Retry in a moment.`
35739
- );
35740
- }
35741
- if (facts2) {
35742
- const self = facts2.state === "open" ? await input.selfIdentity().catch(() => void 0) : void 0;
35743
- if (facts2.state === "open" && !self) {
35744
- return refuse2(
35745
- "own identity unavailable",
35746
- "not started (identity unverifiable)",
35747
- `\u{1F6AB} Could not resolve the GitHub identity this bot acts as, so whether ${where} is ship's to drive cannot be judged \u2014 refusing fail-closed. Retry in a moment.`
35748
- );
35749
- }
35750
- const author = facts2.author;
35751
- const shipAuthored = self !== void 0 && author?.login === self.login && author?.id === self.id;
35752
- if (facts2.state === "open" && !(task && !shipAuthored)) {
35753
- if (task) {
35754
- return refuse2(
35755
- "new task over open PR",
35756
- "not started (open PR)",
35757
- `\u{1F6AB} This thread's PR ${where} is still open \u2014 a new task over it is refused. Re-issue \`agent:ship\` with only the PR URL to resume its review loop, or finish/close ${where} and start the new task in a fresh thread.`
35758
- );
35759
- }
35760
- if (!shipAuthored) {
35761
- return refuse2(
35762
- "human-authored PR",
35763
- "not started (not ship's PR)",
35764
- `\u{1F6AB} ${where} was not authored by \`${self.login}\` (this bot) \u2014 it is not ship's to drive. Use \`agent:review\` for a one-off review, or drive the loop manually.`
35765
- );
35766
- }
35767
- if (!facts2.sameRepoHead) {
35768
- return refuse2(
35769
- "fork-head PR",
35770
- "not started (fork head)",
35771
- `\u{1F6AB} ${where}'s head branch lives on a fork, not on \`${repo}\` \u2014 ship cannot drive it.`
35772
- );
35773
- }
35774
- const branch2 = facts2.headRef ?? repoCtx.ref;
35775
- if (!branch2) {
35776
- return refuse2(
35777
- "head branch unknown",
35778
- "not started (head branch unknown)",
35779
- `\u{1F6AB} Could not determine ${where}'s head branch, so ship cannot bind the thread's worktree to it \u2014 refusing fail-closed.`
35780
- );
35781
- }
35782
- return {
35783
- ok: true,
35784
- entry: {
35785
- repo,
35786
- branch: branch2,
35787
- // The PR's OWN base wins on resume: a ship PR opened against a
35788
- // non-default base must not run its re-reviews (or re-open a closed
35789
- // PR) against the default branch. repoCtx.baseRef carries the same
35790
- // fact when the thread context resolved the PR; the default branch
35791
- // is the last resort.
35792
- base: resolveBaseRef([facts2.baseRef, repoCtx.baseRef], info.defaultBranch),
35793
- resume: {
35794
- pr: repoCtx.pr,
35795
- headSha: facts2.headSha ?? repoCtx.headSha,
35796
- ...facts2.htmlUrl !== void 0 ? { url: facts2.htmlUrl } : {}
35797
- }
35798
- }
35799
- };
35800
- }
35801
- }
35802
- }
35803
- if (!task) {
35804
- return refuse2(
35805
- "no task",
35806
- "not started (no task)",
35807
- `\u{1F6AB} Nothing to ship: give ship a task (\`agent:ship in ${repo}: <task>\`), or name an open ship PR by URL to resume its review loop.`
35808
- );
35809
- }
35810
- const ref = repoCtx.ref && !repoCtx.refFromPr && repoCtx.ref.toLowerCase() !== repo.toLowerCase() ? repoCtx.ref : void 0;
35811
- return {
35812
- ok: true,
35813
- entry: { repo, base: resolveBaseRef([ref], info.defaultBranch) }
35814
- };
35815
- }
35816
- var refuse2;
35817
- var init_preflight = __esm({
35818
- "../../src/core/ship/preflight.ts"() {
35819
- "use strict";
35820
- init_githubPulls();
35821
- refuse2 = (where, card, reply) => ({ ok: false, where, card, reply });
35822
- }
35823
- });
35824
-
35825
35746
  // ../../src/core/ship/coordinator.ts
35826
35747
  function shipInterruptedNote(prUrl2) {
35827
35748
  const stands = prUrl2 ? `Its work stands on GitHub: ${prUrl2}.` : "Whatever it pushed stands on its pipeline branch; no PR was opened yet.";
@@ -36072,6 +35993,142 @@ var init_coordinator = __esm({
36072
35993
  }
36073
35994
  });
36074
35995
 
35996
+ // ../../src/core/ship/preflight.ts
35997
+ function shipTaskText(requestText, repo) {
35998
+ let t = requestText.replace(/<((?:https?):\/\/[^|>\s]+)(?:\|[^>]*)?>/gi, " $1 ");
35999
+ t = t.replace(/https?:\/\/\S+/gi, " ");
36000
+ const slug = repo.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
36001
+ t = t.replace(new RegExp(`\\bin\\s+${slug}\\s*:?`, "gi"), " ");
36002
+ t = t.replace(new RegExp(`\\b${slug}(#\\d+)?\\b`, "gi"), " ");
36003
+ t = t.replace(/\b[a-z0-9][\w.-]*\/[\w.-]+#\d+\b/gi, " ");
36004
+ return t.replace(/\s+/g, " ").trim().replace(/^[:,\-—.\s]+/, "").trim();
36005
+ }
36006
+ async function shipPreflight(input) {
36007
+ const { repoCtx } = input;
36008
+ if (!input.channelId.startsWith("slack:") && !input.channelId.startsWith("cli:")) {
36009
+ const base = input.runsBase?.trim();
36010
+ const page2 = base ? `${base.replace(/\/+$/, "")}/runs` : "the bot's /runs page";
36011
+ return refuse2(
36012
+ "channel",
36013
+ "not started (Slack/CLI only)",
36014
+ `\u{1F6AB} \`agent:ship\` runs only from Slack or the CLI \u2014 this adapter is single-shot and cannot hold a pipeline-length run. Start it there instead, and watch pipelines on the run page (${page2}).`
36015
+ );
36016
+ }
36017
+ const missing = ["ship", "coding", "review"].filter((a) => !input.gates.canRunAgent(a));
36018
+ if (missing.length > 0) {
36019
+ return refuse2(
36020
+ `permission (${missing.join(", ")})`,
36021
+ "not started (permissions)",
36022
+ `\u{1F6AB} Running \`ship\` drives \`coding\` and \`review\` child rounds, and you're not on the allowlist for ${missing.map((a) => `\`${a}\``).join(", ")}. Ask ${input.gates.adminsHint()} for access.`
36023
+ );
36024
+ }
36025
+ const repo = repoCtx.repo;
36026
+ if (!repo) {
36027
+ return refuse2(
36028
+ "no repo",
36029
+ "not started (no repository)",
36030
+ "\u{1F6AB} `agent:ship` needs a target repository \u2014 name it in the request, e.g. `agent:ship in owner/repo: <task>`."
36031
+ );
36032
+ }
36033
+ const info = await input.repoInfo(repo).catch(() => void 0);
36034
+ const task = shipTaskText(input.requestText, repo);
36035
+ const seeded = parseShipPlanRequest(task) !== void 0;
36036
+ if (!seeded && repoCtx.prUnpostable?.reason === "unreachable") {
36037
+ return refuse2(
36038
+ "thread PR unreachable",
36039
+ "not started (PR unverifiable)",
36040
+ `\u{1F6AB} This thread names PR ${repo}#${repoCtx.prUnpostable.number} but it could not be fetched to run ship's entry checks \u2014 refusing fail-closed. Retry in a moment, or check the PR on GitHub.`
36041
+ );
36042
+ }
36043
+ if (repoCtx.pr !== void 0 && !seeded) {
36044
+ const where = `${repo}#${repoCtx.pr}`;
36045
+ const contextCase = task !== "" && repoCtx.prFromMessage === true;
36046
+ if (!contextCase) {
36047
+ const facts2 = await input.prFacts({ repo, number: repoCtx.pr }).catch(() => void 0);
36048
+ if (!facts2) {
36049
+ return refuse2(
36050
+ "PR facts unavailable",
36051
+ "not started (PR unverifiable)",
36052
+ `\u{1F6AB} Could not fetch ${where} to run ship's entry checks (open? same-repo head?) \u2014 refusing fail-closed. Retry in a moment.`
36053
+ );
36054
+ }
36055
+ if (facts2.state === "open") {
36056
+ if (!facts2.sameRepoHead) {
36057
+ return refuse2(
36058
+ "fork-head PR",
36059
+ "not started (fork head)",
36060
+ `\u{1F6AB} ${where}'s head branch lives on a fork, not on \`${repo}\` \u2014 ship cannot drive it.`
36061
+ );
36062
+ }
36063
+ const branch2 = facts2.headRef ?? repoCtx.ref;
36064
+ if (!branch2) {
36065
+ return refuse2(
36066
+ "head branch unknown",
36067
+ "not started (head branch unknown)",
36068
+ `\u{1F6AB} Could not determine ${where}'s head branch, so ship cannot bind the thread's worktree to it \u2014 refusing fail-closed.`
36069
+ );
36070
+ }
36071
+ const base = resolveBaseRef([facts2.baseRef, repoCtx.baseRef], info?.defaultBranch);
36072
+ const autoMerge = facts2.autoMergeEnabled !== void 0 ? { autoMergeEnabled: facts2.autoMergeEnabled } : {};
36073
+ if (task) {
36074
+ return {
36075
+ ok: true,
36076
+ entry: {
36077
+ repo,
36078
+ branch: branch2,
36079
+ base,
36080
+ adopt: { pr: repoCtx.pr, ...facts2.htmlUrl !== void 0 ? { url: facts2.htmlUrl } : {} },
36081
+ ...autoMerge
36082
+ }
36083
+ };
36084
+ }
36085
+ return {
36086
+ ok: true,
36087
+ entry: {
36088
+ repo,
36089
+ branch: branch2,
36090
+ base,
36091
+ resume: {
36092
+ pr: repoCtx.pr,
36093
+ headSha: facts2.headSha ?? repoCtx.headSha,
36094
+ ...facts2.htmlUrl !== void 0 ? { url: facts2.htmlUrl } : {}
36095
+ },
36096
+ ...autoMerge
36097
+ }
36098
+ };
36099
+ }
36100
+ if (!task) {
36101
+ return refuse2(
36102
+ "closed resume target",
36103
+ "not started (PR closed)",
36104
+ `\u{1F6AB} ${where} is closed \u2014 there is no review loop to resume. Give ship a task to start fresh work.`
36105
+ );
36106
+ }
36107
+ }
36108
+ }
36109
+ if (!task) {
36110
+ return refuse2(
36111
+ "no task",
36112
+ "not started (no task)",
36113
+ `\u{1F6AB} Nothing to ship: give ship a task (\`agent:ship in ${repo}: <task>\`), or name an open ship PR by URL to resume its review loop.`
36114
+ );
36115
+ }
36116
+ const ref = repoCtx.ref && !repoCtx.refFromPr && repoCtx.ref.toLowerCase() !== repo.toLowerCase() ? repoCtx.ref : void 0;
36117
+ return {
36118
+ ok: true,
36119
+ entry: { repo, base: resolveBaseRef([ref], info?.defaultBranch) }
36120
+ };
36121
+ }
36122
+ var refuse2;
36123
+ var init_preflight = __esm({
36124
+ "../../src/core/ship/preflight.ts"() {
36125
+ "use strict";
36126
+ init_githubPulls();
36127
+ init_coordinator();
36128
+ refuse2 = (where, card, reply) => ({ ok: false, where, card, reply });
36129
+ }
36130
+ });
36131
+
36075
36132
  // ../../src/core/coordinator/handOff.ts
36076
36133
  async function plan(deps, input) {
36077
36134
  const { entry, msg } = input;
@@ -36116,7 +36173,9 @@ async function plan(deps, input) {
36116
36173
  identity: identity2,
36117
36174
  merge: "person",
36118
36175
  ...entry.resume !== void 0 ? { resume: entry.resume } : {},
36119
- ...entry.branch !== void 0 ? { entryBranch: entry.branch } : {}
36176
+ ...entry.adopt !== void 0 ? { adopt: entry.adopt } : {},
36177
+ ...entry.branch !== void 0 ? { entryBranch: entry.branch } : {},
36178
+ ...entry.autoMergeEnabled !== void 0 ? { autoMergeEnabled: entry.autoMergeEnabled } : {}
36120
36179
  }
36121
36180
  };
36122
36181
  }
@@ -36173,9 +36232,10 @@ function planWhere(p, units, mergedBefore) {
36173
36232
  if (p.path !== void 0)
36174
36233
  return `${head} Each unit runs in a thread of its own in this channel under your grants; this card follows the plan and its summary lands in this thread.`;
36175
36234
  const branch2 = units[0]?.branch ?? "";
36176
- const prUrl2 = p.resume?.url ?? (p.resume !== void 0 ? `https://github.com/${p.identity.repo}/pull/${p.resume.pr}` : void 0);
36177
- const runs = p.resume !== void 0 ? `the review loop of ${prUrl2} resumes at its next review round on \`${branch2}\` in this thread under your grants \u2014 no new coding round first` : `the unit runs on \`${branch2}\` in this thread under your grants`;
36178
- return `${head} ${runs}; this card follows it and the report lands here.`;
36235
+ const url = (of) => of?.url ?? (of !== void 0 ? `https://github.com/${p.identity.repo}/pull/${of.pr}` : void 0);
36236
+ const runs = p.resume !== void 0 ? `the review loop of ${url(p.resume)} resumes at its next review round on \`${branch2}\` in this thread under your grants \u2014 no new coding round first` : p.adopt !== void 0 ? `the unit adopts ${url(p.adopt)}: it runs on \`${branch2}\` \u2014 the pull request's own head \u2014 in this thread under your grants, no new branch` : `the unit runs on \`${branch2}\` in this thread under your grants`;
36237
+ const autoMerge = p.autoMergeEnabled ? " Auto-merge is on for this pull request: the approval merges it once checks pass." : "";
36238
+ return `${head} ${runs}; this card follows it and the report lands here.${autoMerge}`;
36179
36239
  }
36180
36240
  async function handOffToCoordinator(deps, input) {
36181
36241
  const log = deps.log ?? console.log;
@@ -36452,7 +36512,6 @@ async function runShipBranch(deps, msg, io, ctx) {
36452
36512
  gates: { canRunAgent: (a) => deps.config.canRunAgent(msg.userId, a), adminsHint: () => deps.config.adminsHint() },
36453
36513
  repoInfo: deps.fetchRepoShipInfo ?? fetchRepoShipInfo,
36454
36514
  prFacts: deps.fetchPrFacts ?? fetchPullRequestFacts,
36455
- selfIdentity: deps.fetchSelfIdentity ?? resolveGithubIdentity,
36456
36515
  runsBase: process.env.PUBLIC_BASE_URL
36457
36516
  })
36458
36517
  );
@@ -36682,7 +36741,6 @@ var init_ship = __esm({
36682
36741
  "use strict";
36683
36742
  init_trace();
36684
36743
  init_githubPulls();
36685
- init_githubApp();
36686
36744
  init_secrets2();
36687
36745
  init_handOff();
36688
36746
  init_instancesClient();
@@ -45417,6 +45475,9 @@ async function prCheck(body, deps) {
45417
45475
  prNumber: open2.number,
45418
45476
  url: open2.htmlUrl,
45419
45477
  ...open2.headSha !== void 0 ? { headSha: open2.headSha } : {},
45478
+ // The pull request's own auto-merge fact (agent-ship item 9), so a
45479
+ // merge_ready ending can name it at the approved head.
45480
+ ...open2.autoMergeEnabled !== void 0 ? { autoMergeEnabled: open2.autoMergeEnabled } : {},
45420
45481
  at
45421
45482
  });
45422
45483
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coreplane/switchboard",
3
- "version": "1.231.1",
3
+ "version": "1.233.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",