@botbuddy/cli 1.69.2 → 1.69.4

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.3
4
+
5
+ - The bb-status band no longer goes dead with "BB session expired" when a Claude Code session outlives its ~8-hour BB session. Within two hours of expiry `bb status-band read` heartbeats the same session; after expiry it renews that same session in place (`renew_agent_session`), so its tickets and PRs stay on the band. `bb wait` and other CLI self-heals renew the session in place before minting a new one. A closed session is never revived: the band then names `bb doctor --fix`.
6
+
3
7
  ## 1.69.2
4
8
 
5
9
  - The bb-status band shows the epic for a ticket that is in a Linear project again. 1.69.1 fixed tickets with no project, but for a ticket in a project the epic read still timed out on the epic's cost totals, which the band never shows. The band now asks `get_epic_health` for the epic without cost (`include_cost: false`).
@@ -37,7 +37,15 @@ const ERRORS = {
37
37
  "BB identity belongs to another session. Connect BB for this Claude session.",
38
38
  session_changed: "BB session changed during refresh. Refresh again.",
39
39
  session_invalid: "BB session is invalid. Reconnect BB in this worktree.",
40
- session_expired: "BB session expired. Reconnect BB in this worktree.",
40
+ // BOT-3206: the band renews its own session; these name the exact command
41
+ // when it cannot (a closed session is never revived).
42
+ session_expired: "BB session expired. Run `bb doctor --fix` in this worktree, then Refresh.",
43
+ session_closed: "BB session was closed. Run `bb doctor --fix` in this worktree, then Refresh.",
44
+ renewal_unauthorized:
45
+ "BB refused to renew the session. Run `bb doctor --fix` (or `bb mcp setup` if the MCP key is missing).",
46
+ session_identity_mismatch:
47
+ "BB session belongs to another agent. Run `bb doctor --fix` in this worktree.",
48
+ session_renewal_failed: "BB session renewal failed. Refresh to retry, or run `bb doctor --fix`.",
41
49
  session_file_unsafe: "BB session file must be private (mode 0600).",
42
50
  tenant_mismatch: "This repo is bound to a tenant BB status does not support.",
43
51
  agent_mismatch:
@@ -502,7 +510,7 @@ function stale() {
502
510
  }
503
511
  function staleText() {
504
512
  if (relayFault === "session_expired")
505
- return "STALE · BB session expired. Reconnect BB in this worktree, then Refresh.";
513
+ return "STALE · BB session expired. Run `bb doctor --fix` in this worktree, then Refresh.";
506
514
  if (relayFault) return "STALE · Live updates stopped. Refresh to check current status.";
507
515
  return "STALE · Refresh to check current status";
508
516
  }
@@ -1,7 +1,7 @@
1
1
  {
2
- "release_version": "1.69.2",
3
- "source_commit": "678a0f569a975e17b60a4b4b7823e52e0419703a",
4
- "source_sha256": "d5ef72c9c1f86bfdc374907b15b0b86f429270d57adbb18a3c07530a50702983",
2
+ "release_version": "1.69.4",
3
+ "source_commit": "fe786c6f3850ee404ab213075e8445a0af0ac2ec",
4
+ "source_sha256": "d26c4fbae9682e5da62ba9cfd5e0be1e8f580098e9439bcc6111e0a9d4b0d22b",
5
5
  "target": "darwin-arm64",
6
- "sha256": "fb43e38890d45d1b59a13b8a02da4daeaa7caaf7f18c7c9f8447bf6e8268ddad"
6
+ "sha256": "45eb395a0b0a1b7f4a5d69257475947a766c8875a51a97d7d523ecdb3dc95c22"
7
7
  }
@@ -1,7 +1,7 @@
1
1
  {
2
- "release_version": "1.69.2",
3
- "source_commit": "678a0f569a975e17b60a4b4b7823e52e0419703a",
4
- "source_sha256": "d5ef72c9c1f86bfdc374907b15b0b86f429270d57adbb18a3c07530a50702983",
2
+ "release_version": "1.69.4",
3
+ "source_commit": "fe786c6f3850ee404ab213075e8445a0af0ac2ec",
4
+ "source_sha256": "d26c4fbae9682e5da62ba9cfd5e0be1e8f580098e9439bcc6111e0a9d4b0d22b",
5
5
  "target": "linux-x64",
6
- "sha256": "b7f92d406a550252d34903215d332dcd0f6471462649bc44d974a08eddc80aec"
6
+ "sha256": "cb1ce9a04c0a82d89e8fbe1fa4ced2f909905b7177b9dd384942041e09056955"
7
7
  }
@@ -1,21 +1,21 @@
1
1
  {
2
2
  "schema_version": 1,
3
- "release_version": "1.69.2",
4
- "source_commit": "678a0f569a975e17b60a4b4b7823e52e0419703a",
5
- "source_sha256": "d5ef72c9c1f86bfdc374907b15b0b86f429270d57adbb18a3c07530a50702983",
3
+ "release_version": "1.69.4",
4
+ "source_commit": "fe786c6f3850ee404ab213075e8445a0af0ac2ec",
5
+ "source_sha256": "d26c4fbae9682e5da62ba9cfd5e0be1e8f580098e9439bcc6111e0a9d4b0d22b",
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": "fb43e38890d45d1b59a13b8a02da4daeaa7caaf7f18c7c9f8447bf6e8268ddad",
11
+ "sha256": "45eb395a0b0a1b7f4a5d69257475947a766c8875a51a97d7d523ecdb3dc95c22",
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": "b7f92d406a550252d34903215d332dcd0f6471462649bc44d974a08eddc80aec",
18
+ "sha256": "cb1ce9a04c0a82d89e8fbe1fa4ced2f909905b7177b9dd384942041e09056955",
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.2",
3
+ "version": "1.69.4",
4
4
  "description": "BotBuddy — Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
@@ -315,6 +315,36 @@ export async function selfHealAgentSession({
315
315
  // The tier-2 key is sent the way every MCP-key call is (resolveCallAuth's
316
316
  // `mcp_key` source): x-agent-api-key, pinned to the binding's tenant.
317
317
  const auth = mcpKey ? { "x-agent-api-key": mcpKey } : { Authorization: `Bearer ${owner.token}` };
318
+ // BOT-3206: this harness session's own lapsed session is renewed IN PLACE
319
+ // (same session_id, so its ticket/PR edges keep feeding the bb-status band)
320
+ // before falling back to a new mint, which would start an unlinked session.
321
+ // A closed session, another harness's cache, or any renewal failure keeps the
322
+ // existing mint path. `force` (doctor --fix) still re-registers to re-attest.
323
+ if (!force && current && ownCache && current.session_id && current.agent_id && (renewal || !isFresh(current, now()))) {
324
+ const { renewHarnessSession } = await import("./session-bootstrap.mjs");
325
+ const renewedState = await renewHarnessSession({
326
+ cwd,
327
+ harnessSessionId: current.harness_session_id,
328
+ call,
329
+ callOptions: { auth },
330
+ now: () => now().getTime(),
331
+ readState,
332
+ writeState,
333
+ // Already inside this worktree's state lock.
334
+ transaction: (_options, operation) => operation(cwd),
335
+ }).catch(() => null);
336
+ if (renewedState?.agent_session_token && (!renewal || renewedState.agent_session_token !== rejectedCredential.token)) {
337
+ return withIdentity({
338
+ token: renewedState.agent_session_token,
339
+ sessionId: sessionId ?? renewedState.session_id,
340
+ agentId: renewedState.agent_id,
341
+ source: "renewed",
342
+ mintedWith,
343
+ ticketSignals: null,
344
+ stackLeaseCapability: null,
345
+ }, renewedState);
346
+ }
347
+ }
318
348
  const currentBranch = await branch();
319
349
  const resolvedMachineId = await machineId();
320
350
  const resolvedHardwareId = await hardwareId();
package/src/api.mjs CHANGED
@@ -373,6 +373,7 @@ export async function resolveCallAuth({
373
373
  export const SESSION_TOKEN_DENIED_TOOLS = new Set([
374
374
  "register_agent",
375
375
  "bootstrap_agent_session",
376
+ "renew_agent_session",
376
377
  "create_service_account",
377
378
  "mint_mcp_key",
378
379
  "revoke_mcp_key",
@@ -4,6 +4,7 @@
4
4
  import os from "node:os";
5
5
  import { callToolJson } from "./api.mjs";
6
6
  import { readAgentState, touchAgentStateExpiry, withStateLock, worktreeRoot, writeAgentState } from "./agent-state.mjs";
7
+ import { AGENT_KEY_RE } from "./agent-key.mjs";
7
8
  import { clearAgentSessionIfMatches, drainPendingCleanup, recordPendingCleanup } from "./agent-cleanup.mjs";
8
9
  import { readAgentBinding } from "./wait-profile.mjs";
9
10
  import { resolveAgentKey, resolveOwnerToken } from "./cli-credentials.mjs";
@@ -160,6 +161,74 @@ export async function heartbeatHarnessSession({
160
161
  return response.data;
161
162
  }
162
163
 
164
+ // BOT-3206: renew THIS harness session's own lapsed BB session in place. The
165
+ // server re-issues a bearer for the exact session row the cache names, so the
166
+ // session_id and its ticket/PR edges survive; a bootstrap or self-heal mint
167
+ // would start a new, unlinked session instead. Only the cache's own harness may
168
+ // renew it, a closed session is refused (`session_closed`), and the durable
169
+ // credential (client key or tenant MCP key) is the only authority: a missing or
170
+ // revoked one fails closed as `renewal_unauthorized`.
171
+ function renewalError(code, message) {
172
+ return Object.assign(new Error(code), { code, detail: message });
173
+ }
174
+
175
+ export async function renewHarnessSession({
176
+ cwd = process.cwd(),
177
+ harnessSessionId,
178
+ call = callToolJson,
179
+ callOptions = {},
180
+ now = () => Date.now(),
181
+ readState = readAgentState,
182
+ writeState = writeAgentState,
183
+ transaction = withHarnessBootstrapLock,
184
+ } = {}) {
185
+ if (typeof harnessSessionId !== "string" || !harnessSessionId) {
186
+ throw renewalError("session_identity_mismatch", "renewal requires this harness session's id");
187
+ }
188
+ const harness = harnessSessionId.toLowerCase();
189
+ return transaction({ cwd }, async (root) => {
190
+ // Re-read inside the lock: another process may already have renewed it.
191
+ const cached = await readState(root);
192
+ if (!cached?.session_id || !cached?.agent_id) throw renewalError("session_identity_mismatch", "no cached session to renew");
193
+ if (String(cached.harness_session_id ?? "").toLowerCase() !== harness) {
194
+ throw renewalError("session_identity_mismatch", "the cached session belongs to another harness session");
195
+ }
196
+ if (Date.parse(cached.expires_at) > now()) return cached;
197
+ const response = await call("renew_agent_session", {
198
+ session_id: cached.session_id,
199
+ agent_id: cached.agent_id,
200
+ harness_session_id: harness,
201
+ }, { ...callOptions, ...(cached.tenant ? { tenant: cached.tenant } : {}) });
202
+ if (!response?.ok || response.isError) {
203
+ if (response?.auth) throw renewalError("renewal_unauthorized", "run `bb doctor --fix`, or `bb mcp setup` if the MCP key is missing");
204
+ const code = response?.code ?? response?.data?.code ?? response?.data?.error;
205
+ throw renewalError(
206
+ ["session_closed", "session_identity_mismatch"].includes(code) ? code : "session_renewal_failed",
207
+ response?.error ?? response?.data?.message ?? "renew_agent_session failed",
208
+ );
209
+ }
210
+ const data = response.data ?? {};
211
+ // The renewal must be the SAME session, agent and tenant, with a usable bearer.
212
+ if (data.session_id !== cached.session_id || data.agent_id !== cached.agent_id
213
+ || (cached.tenant && data.tenant_id !== cached.tenant)
214
+ || typeof data.session_token !== "string" || !AGENT_KEY_RE.test(data.session_token)
215
+ || !(Date.parse(data.expires_at) > now())) {
216
+ throw renewalError("session_renewal_failed", "renew_agent_session returned a different or unusable session");
217
+ }
218
+ const state = {
219
+ ...cached,
220
+ agent_session_token: data.session_token,
221
+ minted_at: new Date(now()).toISOString(),
222
+ expires_at: new Date(Date.parse(data.expires_at)).toISOString(),
223
+ ...(typeof data.session_token_generation === "string" && data.session_token_generation
224
+ ? { session_token_generation: data.session_token_generation }
225
+ : {}),
226
+ };
227
+ await writeState(root, state);
228
+ return state;
229
+ });
230
+ }
231
+
163
232
  // BOT-1945: a worktree cache that this bootstrap path did not create — written by
164
233
  // the self-heal / `bb setup` / MCP registration path (no `harness_origin` marker),
165
234
  // or a legacy bootstrap cache with no stored generation — is not a harness
@@ -511,12 +511,62 @@ async function bootstrapBand({ cwd, harnessSessionId, branch }) {
511
511
  });
512
512
  }
513
513
 
514
+ // BOT-3206: the band keeps its OWN BB session alive. Inside this window before
515
+ // `expires_at` it heartbeats the session (the server rolls the bearer's expiry
516
+ // and the cache mirrors it); past expiry it renews the same session row with the
517
+ // durable credential. Neither path bootstraps: a bootstrap mints a new session
518
+ // and the session's tickets and PRs stop feeding the band.
519
+ const RENEW_WINDOW_MS = 2 * 60 * 60 * 1000;
520
+ // Renewal refusals the band names itself; anything else is a failed renewal.
521
+ const RENEW_CODES = new Set(["session_closed", "renewal_unauthorized", "session_identity_mismatch", "session_renewal_failed"]);
522
+ async function heartbeatBand({ session, cwd }) {
523
+ const { heartbeatHarnessSession } = await import("./session-bootstrap.mjs");
524
+ await heartbeatHarnessSession({ session, cwd });
525
+ }
526
+ async function renewBand({ cwd, harnessSessionId }) {
527
+ const { renewHarnessSession } = await import("./session-bootstrap.mjs");
528
+ await renewHarnessSession({ cwd, harnessSessionId });
529
+ }
530
+ // Reads the cache through readSession (mode and size checked) only to route:
531
+ // readStatus re-validates every field before a snapshot is built.
532
+ async function keepSessionAlive({ cwd, harnessSessionId, now, heartbeat, renew }) {
533
+ let state;
534
+ try {
535
+ // The validated reader: an unsafe (group/world-readable) cache is refused
536
+ // here too, so its bearer is never sent or extended (readStatus reports it).
537
+ state = await readSession(await realpath(cwd));
538
+ } catch {
539
+ return;
540
+ }
541
+ // Another harness session's cache, or an unbound one, is never renewed or
542
+ // taken over here (BOT-3152 AC-5 owns those).
543
+ if (typeof state?.harness_session_id !== "string" || state.harness_session_id.toLowerCase() !== harnessSessionId.toLowerCase()) return;
544
+ const expiresAt = Date.parse(state.expires_at);
545
+ if (!Number.isFinite(expiresAt) || expiresAt - now() > RENEW_WINDOW_MS) return;
546
+ if (expiresAt > now()) {
547
+ // Best-effort: a refused heartbeat still leaves a valid bearer to read with,
548
+ // and the post-expiry renewal below covers a bearer that then lapses.
549
+ if (UUID.test(state.session_id ?? "") && typeof state.agent_session_token === "string") {
550
+ await heartbeat({ session: { session_id: state.session_id, session_token: state.agent_session_token }, cwd }).catch(() => {});
551
+ }
552
+ return;
553
+ }
554
+ try {
555
+ await renew({ cwd, harnessSessionId });
556
+ } catch (error) {
557
+ const code = error?.code ?? error?.message;
558
+ refuse(RENEW_CODES.has(code) ? code : "session_renewal_failed");
559
+ }
560
+ }
561
+
514
562
  export async function main(args, {
515
563
  out = process.stdout,
516
564
  cwd = process.cwd(),
517
565
  env = process.env,
518
566
  read = readStatus,
519
567
  bootstrap = bootstrapBand,
568
+ heartbeat = heartbeatBand,
569
+ renew = renewBand,
520
570
  now = () => Date.now(),
521
571
  } = {}) {
522
572
  try {
@@ -530,6 +580,7 @@ export async function main(args, {
530
580
  )
531
581
  refuse("invalid_arguments");
532
582
  const request = { cwd, harnessSessionId: args[1], ticketId: args[3], linearKey: env.LINEAR_BB_API_KEY, skipEpic };
583
+ if (UUID.test(args[1])) await keepSessionAlive({ cwd, harnessSessionId: args[1], now, heartbeat, renew });
533
584
  let snapshot;
534
585
  try {
535
586
  snapshot = await read(request);