@botbuddy/cli 1.69.6 → 1.69.8

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.69.8
4
+
5
+ - The bb-status band's context row is simpler: just `Context 45%`, right-aligned, without the token counts. It turns amber above 30% context use and red above 50%, on every surface, so a session running low on context stands out. The colour is judged on the rounded percent the row shows. The band's plugin version moves to 1.1.1, so `bb update` re-installs it.
6
+
3
7
  ## 1.69.6
4
8
 
5
9
  - A new Claude Code session in a reused worktree binds its bb-status band even when the worktree's BotBuddy session was started by an earlier Claude session that has since gone. Before, the band said "BB identity belongs to another session" for as long as that BB session stayed live, and other sessions' `bb` commands in the worktree kept renewing it. The band now judges the holder by its Claude session: when the holder's transcript has been silent for 30 minutes, the band moves its cache aside to `.botbuddy/agent-state.superseded-<id>.json` and binds this session. The old BB session is not closed, so waits still running on it carry on, and it expires on its own. A holder whose Claude session is active is never replaced.
@@ -11,7 +11,7 @@
11
11
  "name": "bb-status",
12
12
  "source": "./bb-status",
13
13
  "description": "BB session status above the prompt: tickets, PRs with review counts, epic and staging, for BotBuddy and Supply Guard sessions",
14
- "version": "1.1.0"
14
+ "version": "1.1.1"
15
15
  }
16
16
  ]
17
17
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bb-status",
3
- "version": "1.1.0",
3
+ "version": "1.1.1",
4
4
  "description": "BB session status above the prompt: tickets, PRs with review counts, epic, staging and context use, live only while the session is on screen.",
5
5
  "author": {
6
6
  "name": "BotBuddy"
@@ -32,12 +32,15 @@ session. Once a surface draws the session again, the 5 s display tick does one
32
32
  read and re-arms the relay.
33
33
 
34
34
  **BOT-3211 context row.** The last row shows the session's context use as
35
- Claude Code's status line has it, for example `Context 62% · 124k of 200k`. The
35
+ Claude Code's status line has it, right-aligned, for example `Context 62%`
36
+ (BOT-3217 dropped the token counts). The
36
37
  mod reads it once with the free `$.session.usage()` (no `breakdown`) at
37
38
  session start and resume, then only from the `session.measure` event the
38
39
  engine pushes after each main-thread turn: no timer reads it. The row is hidden
39
40
  while the runtime has no fill figure (a fresh or just-compacted session before
40
- its next response), never shown as 0%.
41
+ its next response), never shown as 0%. It is dim up to 30%, amber (`warning`)
42
+ above 30% and red (`error`) above 50% (BOT-3217), judged on the rounded percent
43
+ it shows.
41
44
 
42
45
  ## Install
43
46
 
@@ -696,33 +696,31 @@ function bandLine(el, spec, widths, right) {
696
696
  // Top-aligned: with a wrapped title, the id and status sit on its first line.
697
697
  return el.Box({ key: spec.key, flexDirection: "row", alignItems: "flex-start", columnGap: 1, children });
698
698
  }
699
- // BOT-3211: 124000 → "124k", 1000000 → "1M".
700
- function tokens(n) {
701
- if (n >= 999500) return String(Math.round(n / 100000) / 10).replace(/\.0$/, "") + "M";
702
- if (n >= 1000) return Math.round(n / 1000) + "k";
703
- return String(n);
704
- }
705
- // The row's text, or null when the runtime has no fill figure (a fresh or
706
- // just-compacted session): hidden, never "0%".
699
+ // BOT-3211/BOT-3217: "Context 62%", or null when the runtime has no fill
700
+ // figure (a fresh or just-compacted session): hidden, never "0%".
707
701
  function contextText(c) {
708
- if (
709
- !c ||
710
- !Number.isFinite(c.percent) ||
711
- c.percent < 0 ||
712
- c.percent > 100 ||
713
- !Number.isSafeInteger(c.window) ||
714
- c.window <= 0
715
- )
716
- return null;
717
- const pct = "Context " + Math.round(c.percent) + "%";
718
- return Number.isSafeInteger(c.tokens) && c.tokens >= 0
719
- ? pct + " · " + tokens(c.tokens) + " of " + tokens(c.window)
720
- : pct + " of " + tokens(c.window);
702
+ if (!c || !Number.isFinite(c.percent) || c.percent < 0 || c.percent > 100) return null;
703
+ return "Context " + Math.round(c.percent) + "%";
704
+ }
705
+ // BOT-3217: amber above 30%, red above 50%, judged on the rounded percent the
706
+ // row shows, so the colour always matches the number.
707
+ function contextColor(c) {
708
+ const pct = Math.round(c.percent);
709
+ if (pct > 50) return "error";
710
+ if (pct > 30) return "warning";
711
+ return null;
721
712
  }
722
713
  function contextLine(el, key) {
723
714
  const text = contextText(context);
724
715
  if (!text) return null;
725
- return el.Box({ key, flexDirection: "row", children: [dim(el, text)] });
716
+ const color = contextColor(context);
717
+ // Right-aligned on its own row (Jono, BOT-3217).
718
+ return el.Box({
719
+ key,
720
+ flexDirection: "row",
721
+ justifyContent: "flex-end",
722
+ children: [color ? el.Text({ color, children: [text] }) : dim(el, text)],
723
+ });
726
724
  }
727
725
  async function readContext($) {
728
726
  try {
@@ -1,7 +1,7 @@
1
1
  {
2
- "release_version": "1.69.6",
3
- "source_commit": "e5336acb5374af61493aefb780b8b0a7b1406061",
4
- "source_sha256": "ea92e88589a6d17292724108fbd7b67e532c3b0792eac919a21156884dd094dd",
2
+ "release_version": "1.69.8",
3
+ "source_commit": "704da4fe10bcbc13f1ca3abc6b0d47d2260e2c62",
4
+ "source_sha256": "2465657c2fe4fa188d73f3b7efda0f608144afcfd8d7eba465ece55ccb1204f3",
5
5
  "target": "darwin-arm64",
6
- "sha256": "169410c528299ec39f546c7690b9496830ceab6eab4a8e4c5f86105b4da69cd2"
6
+ "sha256": "865f29aebd07e09de6c5b77fd5297db5a568c30843c4395e8ae4e927b0d4e7ad"
7
7
  }
@@ -1,7 +1,7 @@
1
1
  {
2
- "release_version": "1.69.6",
3
- "source_commit": "e5336acb5374af61493aefb780b8b0a7b1406061",
4
- "source_sha256": "ea92e88589a6d17292724108fbd7b67e532c3b0792eac919a21156884dd094dd",
2
+ "release_version": "1.69.8",
3
+ "source_commit": "704da4fe10bcbc13f1ca3abc6b0d47d2260e2c62",
4
+ "source_sha256": "2465657c2fe4fa188d73f3b7efda0f608144afcfd8d7eba465ece55ccb1204f3",
5
5
  "target": "linux-x64",
6
- "sha256": "51f601dcf602265d404836a0989faef813fbd310386cf0be0c31f3821e8e6f53"
6
+ "sha256": "6ad28e621587a5a697621c54c5a9b09f90c79b0687a4ae1f25f380ef02843d4b"
7
7
  }
@@ -1,21 +1,21 @@
1
1
  {
2
2
  "schema_version": 1,
3
- "release_version": "1.69.6",
4
- "source_commit": "e5336acb5374af61493aefb780b8b0a7b1406061",
5
- "source_sha256": "ea92e88589a6d17292724108fbd7b67e532c3b0792eac919a21156884dd094dd",
3
+ "release_version": "1.69.8",
4
+ "source_commit": "704da4fe10bcbc13f1ca3abc6b0d47d2260e2c62",
5
+ "source_sha256": "2465657c2fe4fa188d73f3b7efda0f608144afcfd8d7eba465ece55ccb1204f3",
6
6
  "targets": {
7
7
  "darwin-arm64": {
8
8
  "goos": "darwin",
9
9
  "goarch": "arm64",
10
10
  "binary": "daemon/bin/darwin-arm64/botbuddy-daemon",
11
- "sha256": "169410c528299ec39f546c7690b9496830ceab6eab4a8e4c5f86105b4da69cd2",
11
+ "sha256": "865f29aebd07e09de6c5b77fd5297db5a568c30843c4395e8ae4e927b0d4e7ad",
12
12
  "mode": "0755"
13
13
  },
14
14
  "linux-x64": {
15
15
  "goos": "linux",
16
16
  "goarch": "amd64",
17
17
  "binary": "daemon/bin/linux-x64/botbuddy-daemon",
18
- "sha256": "51f601dcf602265d404836a0989faef813fbd310386cf0be0c31f3821e8e6f53",
18
+ "sha256": "6ad28e621587a5a697621c54c5a9b09f90c79b0687a4ae1f25f380ef02843d4b",
19
19
  "mode": "0755"
20
20
  }
21
21
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botbuddy/cli",
3
- "version": "1.69.6",
3
+ "version": "1.69.8",
4
4
  "description": "BotBuddy — Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
package/src/wait-core.mjs CHANGED
@@ -39,6 +39,11 @@ export const EXIT = Object.freeze({
39
39
  // BOT-2783: a Codex review was still not complete at its intervention deadline. The
40
40
  // receipt's `error` carries the provenance (limit, its source, window, reason).
41
41
  REVIEW_TIMEOUT: 13,
42
+ // BOT-3189: a staging incident ended this wait early (the server marked it abandoned
43
+ // and emitted a sequenced `wait_preempted` signal for it). The condition did NOT
44
+ // fire: the receipt carries the incident and the caller's role; re-arm from
45
+ // next_cursor (the wait's cursor_start) after acting on the incident.
46
+ PREEMPTED: 14,
42
47
  });
43
48
 
44
49
  // BOT-2013: the uuid spellings Postgres accepts for a lease id (uuid_in), mirrored by the SQL
@@ -91,6 +96,11 @@ function managedAllocationFailureDetail(signal, condition) {
91
96
  // own-session terminal branch because its wait_session_id differs) would otherwise match
92
97
  // that raw event condition and exit an unrelated wait as an ordinary match (Codex P2).
93
98
  export const WAIT_SUPERSEDED_SIGNAL_TYPE = "wait_superseded";
99
+ // BOT-3189: the CONTROL signal a staging incident emits to end the suspect agent's armed
100
+ // waits early (exit 14). Like wait_superseded it targets one wait_session_id, is
101
+ // recipient-scoped at the relay, and is NOT in SIGNAL_TYPES, so it can never be armed
102
+ // through the raw `event:` escape hatch.
103
+ export const WAIT_PREEMPTED_SIGNAL_TYPE = "wait_preempted";
94
104
  const HOST_APPROVAL_HOST_KEY_RE = /^bbh-[0-9a-f]{24}$/;
95
105
 
96
106
  // The USER-ARMABLE spine signal_type allowlist, for the `event:` escape hatch. The full
@@ -1029,6 +1039,21 @@ function compactCleanupState(state) {
1029
1039
  * pointer (the `seq`, plus a `payload_truncated` marker); the caller re-fetches
1030
1040
  * detail with the existing get_* tools if it needs the body.
1031
1041
  */
1042
+ function compactPreemptedState(state) {
1043
+ const st = state && typeof state === "object" ? state : {};
1044
+ const inc = st.incident && typeof st.incident === "object" ? st.incident : {};
1045
+ return {
1046
+ preempted: true, reason: st.reason ?? null, event: st.event ?? null, role: st.role ?? null,
1047
+ rm_seat_open: st.rm_seat_open ?? null,
1048
+ incident: {
1049
+ id: inc.id ?? null,
1050
+ repo: typeof inc.repo === "string" ? inc.repo.slice(0, 100) : null,
1051
+ suspect_pr_number: inc.suspect_pr_number ?? null,
1052
+ },
1053
+ truncated: true,
1054
+ };
1055
+ }
1056
+
1032
1057
  export function truncateReceipt(receipt, maxBytes = 10240) {
1033
1058
  if (Buffer.byteLength(JSON.stringify(receipt)) <= maxBytes) return receipt;
1034
1059
  const clone = JSON.parse(JSON.stringify(receipt));
@@ -1043,8 +1068,11 @@ export function truncateReceipt(receipt, maxBytes = 10240) {
1043
1068
  // fields, so it is kept compact instead (BOT-2013, Codex #1017 P2).
1044
1069
  const leaseLostState = clone.outcome === "lease_lost" ? compactLeaseLostState(clone.state) : null;
1045
1070
  const cleanupState = ["matched", "error"].includes(clone.outcome) ? compactCleanupState(clone.state) : null;
1071
+ // BOT-3189: a preempted receipt's state IS the instruction (role, seat, which incident);
1072
+ // keep it compact rather than dropping it. next_cursor is never touched here.
1073
+ const preemptedState = clone.outcome === "preempted" ? compactPreemptedState(clone.state) : null;
1046
1074
  if (Buffer.byteLength(JSON.stringify(clone)) > maxBytes) {
1047
- clone.state = leaseLostState ?? cleanupState ?? { truncated: true };
1075
+ clone.state = leaseLostState ?? cleanupState ?? preemptedState ?? { truncated: true };
1048
1076
  }
1049
1077
  // A matched signal's subject key can be caller-controlled through the raw
1050
1078
  // event escape hatch. Keep the durable sequence pointer, but not an
@@ -1123,6 +1151,35 @@ export function truncateReceipt(receipt, maxBytes = 10240) {
1123
1151
  }
1124
1152
  return base;
1125
1153
  }
1154
+ if (Buffer.byteLength(JSON.stringify(clone)) > maxBytes && clone.outcome === "preempted") {
1155
+ // BOT-3189: the bounded fallback keeps what the agent must act on (role, seat, which
1156
+ // incident) and the re-arm cursor, which makes the re-armed wait loss-free; optional
1157
+ // metadata (principal, client, web_url, repo) is added back only while it fits.
1158
+ const st = clone.state && typeof clone.state === "object" ? clone.state : {};
1159
+ const inc = st.incident && typeof st.incident === "object" ? st.incident : {};
1160
+ const base = {
1161
+ schema_version: clone.schema_version,
1162
+ outcome: "preempted",
1163
+ matched: [],
1164
+ state: {
1165
+ preempted: true, event: st.event ?? null, role: st.role ?? null, rm_seat_open: st.rm_seat_open ?? null,
1166
+ incident: { id: inc.id ?? null, suspect_pr_number: inc.suspect_pr_number ?? null },
1167
+ truncated: true,
1168
+ },
1169
+ truncated: true,
1170
+ ...(clone.next_cursor != null ? { next_cursor: clone.next_cursor } : {}),
1171
+ };
1172
+ if (typeof inc.repo === "string") {
1173
+ const candidate = { ...base, state: { ...base.state, incident: { ...base.state.incident, repo: inc.repo.slice(0, 100) } } };
1174
+ if (Buffer.byteLength(JSON.stringify(candidate)) <= maxBytes) base.state = candidate.state;
1175
+ }
1176
+ for (const key of ["wait_session_id", "client", "web_url", "principal"]) {
1177
+ if (clone[key] == null) continue;
1178
+ const candidate = { ...base, [key]: clone[key] };
1179
+ if (Buffer.byteLength(JSON.stringify(candidate)) <= maxBytes) base[key] = clone[key];
1180
+ }
1181
+ return base;
1182
+ }
1126
1183
  if (Buffer.byteLength(JSON.stringify(clone)) > maxBytes && clone.outcome === "review_timeout" &&
1127
1184
  clone.error && typeof clone.error === "object") {
1128
1185
  // BOT-2783: keep the actionable provenance (limit, its source, why, which
@@ -1927,6 +1984,25 @@ export async function runWaitLoop({
1927
1984
  });
1928
1985
  };
1929
1986
 
1987
+ const finalizePreempted = async (signal) => {
1988
+ // Re-arm from cursor_start, exactly like a supersession: anything that matched this
1989
+ // wait's conditions after it armed is replayed by the re-armed wait.
1990
+ const p = signal.payload ?? {};
1991
+ const cs = p.cursor_start;
1992
+ if (cs != null && /^\d+$/.test(String(cs))) cursor = String(cs);
1993
+ return finalize("preempted", {
1994
+ exitCode: EXIT.PREEMPTED,
1995
+ state: {
1996
+ preempted: true,
1997
+ reason: typeof p.reason === "string" ? p.reason : null,
1998
+ event: typeof p.event === "string" ? p.event : null,
1999
+ role: typeof p.role === "string" ? p.role : null,
2000
+ rm_seat_open: typeof p.rm_seat_open === "boolean" ? p.rm_seat_open : null,
2001
+ incident: p.incident && typeof p.incident === "object" ? p.incident : null,
2002
+ },
2003
+ });
2004
+ };
2005
+
1930
2006
  // Fold the timer conditions and the --timeout cap into one earliest alarm.
1931
2007
  // A timer condition firing is a MATCH (exit 0); the --timeout cap is a
1932
2008
  // TIMEOUT (exit 2). With --any the earliest of the two always wins.
@@ -2233,6 +2309,13 @@ export async function runWaitLoop({
2233
2309
  // BOT-2360: the relay refuses a stream bound to a wait the server already ended
2234
2310
  // (prod a0cd7b11 parked 54 min on one). Never reconnect to it: exit typed, with the
2235
2311
  // confirmed cursor so a re-arm replays everything after it.
2312
+ // BOT-3189: the relay's rollout-compat frame for a preempted wait. Pre-1.69.1
2313
+ // clients exit 11 on it; this client acts on the seq-bearing wait_preempted signal
2314
+ // (exit 14, incident in the receipt) that the relay sends next, so ignore it.
2315
+ if (parsed.error === "wait_abandoned" && parsed.reason === "preempted" &&
2316
+ (parsed.wait_session_id == null || parsed.wait_session_id === waitSessionId)) {
2317
+ continue;
2318
+ }
2236
2319
  if (parsed.error === "wait_abandoned") {
2237
2320
  return finalize("abandoned", {
2238
2321
  exitCode: EXIT.ABANDONED,
@@ -2292,7 +2375,11 @@ export async function runWaitLoop({
2292
2375
  if (restoredAlarm != null && Number.isFinite(candidateAtMs) && restoredAlarm.atMs < candidateAtMs) {
2293
2376
  return fireAlarm(restoredAlarm);
2294
2377
  }
2295
- if (replayCandidate.kind === "superseded") return finalizeSuperseded(replayCandidate.signal);
2378
+ if (replayCandidate.kind === "superseded") {
2379
+ return replayCandidate.signal.signal_type === WAIT_PREEMPTED_SIGNAL_TYPE
2380
+ ? finalizePreempted(replayCandidate.signal)
2381
+ : finalizeSuperseded(replayCandidate.signal);
2382
+ }
2296
2383
  const terminal = await finalizeMatch(replayCandidate.entry, replayCandidate.signal);
2297
2384
  if (onFrame) {
2298
2385
  await onFrame(replayCandidate.signal, {
@@ -2348,7 +2435,7 @@ export async function runWaitLoop({
2348
2435
  // at the relay, so an agent's OTHER concurrent waits receive it too; this id check
2349
2436
  // is why only the intended one treats it as terminal.
2350
2437
  if (
2351
- signal.signal_type === WAIT_SUPERSEDED_SIGNAL_TYPE &&
2438
+ (signal.signal_type === WAIT_SUPERSEDED_SIGNAL_TYPE || signal.signal_type === WAIT_PREEMPTED_SIGNAL_TYPE) &&
2352
2439
  waitSessionId != null &&
2353
2440
  signal.payload && signal.payload.wait_session_id === waitSessionId
2354
2441
  ) {
@@ -2356,7 +2443,8 @@ export async function runWaitLoop({
2356
2443
  if (beforeReplayDeadline) pendingReplaySupersession ??= { signal, seqKey };
2357
2444
  continue;
2358
2445
  }
2359
- return finalizeSuperseded(signal);
2446
+ // BOT-3189: a staging-incident preemption ends the wait the same way (exit 14).
2447
+ return signal.signal_type === WAIT_PREEMPTED_SIGNAL_TYPE ? finalizePreempted(signal) : finalizeSuperseded(signal);
2360
2448
  }
2361
2449
 
2362
2450
  // BOT-1148/BOT-2100: every capacity frame for a pinned host participates in
@@ -12,7 +12,7 @@ const MAX_RECEIPT_BYTES = 64 * 1024;
12
12
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
13
13
  const COMMAND_STATUSES = new Set(["success", "failure", "cancelled", "timed_out", "owner_lost"]);
14
14
  // BOT-2783: review_timeout is terminal (exit 13, typed REVIEW_TIMEOUT provenance).
15
- const TERMINAL_OUTCOMES = new Set(["matched", "timeout", "error", "superseded", "lease_lost", "abandoned", "cancelled", "review_timeout"]);
15
+ const TERMINAL_OUTCOMES = new Set(["matched", "timeout", "error", "superseded", "lease_lost", "abandoned", "cancelled", "review_timeout", "preempted"]);
16
16
 
17
17
  // Persisted != delivered. Await the stream callback before exiting, including
18
18
  // when a bounded receipt is larger than the pipe's immediately writable space.
package/src/wait.mjs CHANGED
@@ -320,7 +320,10 @@ OUTPUT
320
320
  was parked — reaped after 180 s without a live stream,
321
321
  or cancelled; receipt outcome abandoned +
322
322
  state.status/reason — re-arm with --since <next_cursor>)
323
- · 12 named lease cleanup failed · 13 review timeout
323
+ · 12 named lease cleanup failed · 14 preempted (a
324
+ staging incident ended this wait; its condition did NOT
325
+ fire — act on the incident, then re-arm with the printed
326
+ command) · 13 review timeout
324
327
  (REVIEW_TIMEOUT: a Codex review still incomplete at its
325
328
  deadline; receipt error carries minutes/source/reason —
326
329
  use the fallback reviewer, never infer approval).
@@ -885,14 +888,78 @@ export function waitAbandonedNotice(receipt) {
885
888
  `reason=${st.reason ?? "unknown"}) — re-arm the same conditions with${since || " a fresh wait"} (exit ${EXIT.ABANDONED})`;
886
889
  }
887
890
 
888
- function writeLeaseLostNotice(receipt) {
889
- const notice = leaseLostNotice(receipt) ?? waitAbandonedNotice(receipt) ?? reviewTimeoutNotice(receipt);
891
+ /**
892
+ * BOT-3189: the stderr warning for a `preempted` receipt (null for any other outcome): what
893
+ * happened, the caller's role in the incident, and the exact command that re-arms the same
894
+ * conditions from the wait's arm cursor, so nothing that matched meanwhile is lost.
895
+ */
896
+ // BOT-3189: the original `bb wait` arguments to repeat in a re-arm command. Every option
897
+ // is kept as given (--url, --timeout, --allow-short-timeout, --heartbeat, --any, …) except
898
+ // secrets, the old --since, and internal recovery flags; the caller appends the new cursor.
899
+ const REARM_DROPPED_VALUE_FLAGS = new Set([
900
+ "--since", "--agent-session-token", "--agent-key", "--session-token", "--token", "--profile",
901
+ "--deadline-at", "--resume-local-wait",
902
+ ]);
903
+ export function rearmArgs(argv = []) {
904
+ const out = [];
905
+ for (let i = 0; i < argv.length; i += 1) {
906
+ const a = String(argv[i]);
907
+ const eq = a.indexOf("=");
908
+ const flag = a.startsWith("--") && eq > 0 ? a.slice(0, eq) : a;
909
+ if (REARM_DROPPED_VALUE_FLAGS.has(flag)) {
910
+ if (flag === a) i += 1; // skip its separate value
911
+ continue;
912
+ }
913
+ out.push(a);
914
+ }
915
+ return out;
916
+ }
917
+
918
+ export function waitPreemptedNotice(receipt, conditionArgs = [], options = {}) {
919
+ if (receipt?.outcome !== "preempted") return null;
920
+ const st = receipt.state ?? {};
921
+ const inc = st.incident ?? {};
922
+ const quote = (a) => `'${String(a).replace(/'/g, "'\\''")}'`;
923
+ const since = receipt.next_cursor != null ? ` --since ${receipt.next_cursor}` : "";
924
+ // Keep the non-secret wait options that change behaviour: --heartbeat keeps the agent
925
+ // (and a release-manager lock it may now hold) alive while it waits again.
926
+ const timeout = Number.isFinite(options.timeout) && options.timeout > 0 ? ` --timeout ${options.timeout}` : "";
927
+ const heartbeat = options.heartbeat ? " --heartbeat" : "";
928
+ // A cap under the floor only re-arms with the same documented exemption.
929
+ const shortReason = typeof options.shortTimeoutReason === "string" && options.shortTimeoutReason.trim()
930
+ ? ` --allow-short-timeout ${quote(options.shortTimeoutReason.trim())}`
931
+ : "";
932
+ const exemption = Number.isFinite(options.timeout) && options.timeout < MIN_WAIT_TIMEOUT_SEC ? shortReason : "";
933
+ // With the original argv (a live wait) repeat it verbatim minus secrets; a resumed wait
934
+ // only knows its condition specs, so rebuild the behavioural options it does know.
935
+ const rearm = Array.isArray(options.argv)
936
+ ? `bb wait ${rearmArgs(options.argv).map(quote).join(" ")}${since}`
937
+ : `bb wait ${conditionArgs.map(quote).join(" ")}${since}${timeout}${exemption}${heartbeat}`;
938
+ const what = `staging ${inc.repo ?? "?"} is red${inc.red_sha ? ` at ${String(inc.red_sha).slice(0, 10)}` : ""}` +
939
+ `${inc.suspect_pr_number != null ? `, suspect PR #${inc.suspect_pr_number}` : ""}` +
940
+ `${inc.run_url ? ` (${inc.run_url})` : ""}`;
941
+ let role;
942
+ if (st.role === "release_manager") {
943
+ role = "you are now the release manager — call get_staging_gate for the next steps";
944
+ } else if (st.rm_seat_open) {
945
+ role = "the release-manager seat is OPEN — call claim_release_manager, then get_staging_gate";
946
+ } else {
947
+ role = "call get_staging_gate for your role and the next steps";
948
+ }
949
+ return `bb-wait: this wait was ended by a staging incident; its condition did not fire — re-arm it.\n` +
950
+ `bb-wait: ${what}; ${role}.\n` +
951
+ `bb-wait: re-arm (exit ${EXIT.PREEMPTED}): ${rearm}`;
952
+ }
953
+
954
+ function writeLeaseLostNotice(receipt, conditionArgs = [], options = {}) {
955
+ const notice = leaseLostNotice(receipt) ?? waitAbandonedNotice(receipt) ?? reviewTimeoutNotice(receipt) ??
956
+ waitPreemptedNotice(receipt, conditionArgs, options);
890
957
  if (notice) process.stderr.write(`${notice}\n`);
891
958
  }
892
959
 
893
960
  // BOT-1228 / BOT-2360: outcomes whose wait_session the server already ended. The client
894
961
  // never finalizes them (finalize_wait_session only transitions from active).
895
- const SERVER_ENDED_OUTCOMES = new Set(["superseded", "abandoned"]);
962
+ const SERVER_ENDED_OUTCOMES = new Set(["superseded", "abandoned", "preempted"]);
896
963
 
897
964
  // Record the terminal outcome. Best-effort AND bounded: the receipt has already
898
965
  // been written to stdout before this runs, so a slow/failing finalize must never
@@ -1620,7 +1687,7 @@ async function resumeSavedWait(id) {
1620
1687
  } finally {
1621
1688
  await fence?.release();
1622
1689
  }
1623
- writeLeaseLostNotice(checkpoint.terminal_receipt);
1690
+ writeLeaseLostNotice(checkpoint.terminal_receipt, checkpoint.condition_specs ?? []);
1624
1691
  await writeWaitReceipt(checkpoint.terminal_receipt);
1625
1692
  process.exit(Number.isInteger(checkpoint.exit_code) ? checkpoint.exit_code : EXIT.INTERNAL);
1626
1693
  }
@@ -1693,6 +1760,7 @@ export function recoveredExitCode(receipt) {
1693
1760
  if (receipt?.outcome === "timeout") return EXIT.TIMEOUT;
1694
1761
  if (receipt?.outcome === "superseded") return EXIT.SUPERSEDED;
1695
1762
  if (receipt?.outcome === "abandoned") return EXIT.ABANDONED;
1763
+ if (receipt?.outcome === "preempted") return EXIT.PREEMPTED;
1696
1764
  if (receipt?.error === "cursor_expired") return EXIT.CURSOR_EXPIRED;
1697
1765
  if (receipt?.error === "unauthorized" || receipt?.error === "forbidden") return EXIT.AUTH;
1698
1766
  return EXIT.BACKEND;
@@ -1885,7 +1953,9 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
1885
1953
  // BOT-2013 (Codex #1017 P2): every receipt this command prints — live, recovered, or
1886
1954
  // replayed — carries the lease-lost stderr line when it is a lease_lost terminal.
1887
1955
  const emitReceipt = (receipt, options = {}) => {
1888
- writeLeaseLostNotice(receipt);
1956
+ writeLeaseLostNotice(receipt, opts.conditions, {
1957
+ heartbeat: opts.heartbeat, timeout: opts.timeout, shortTimeoutReason: opts.shortTimeoutReason, argv,
1958
+ });
1889
1959
  if (receipt?.outcome === "error") writeWaitFeedbackGuidance();
1890
1960
  return emit(receipt, { ...options, maxBytes: opts.receiptMaxBytes });
1891
1961
  };