@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 +4 -0
- package/claude-plugin/bb-status/hooks/register.js +10 -2
- package/daemon/bin/darwin-arm64/botbuddy-daemon +0 -0
- package/daemon/bin/linux-x64/botbuddy-daemon +0 -0
- package/daemon-release/darwin-arm64.json +4 -4
- package/daemon-release/linux-x64.json +4 -4
- package/daemon-release/manifest.json +5 -5
- package/package.json +1 -1
- package/src/agent-session.mjs +30 -0
- package/src/api.mjs +1 -0
- package/src/session-bootstrap.mjs +69 -0
- package/src/status-band-read.mjs +51 -0
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
|
-
|
|
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.
|
|
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
|
}
|
|
Binary file
|
|
Binary file
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
|
-
"release_version": "1.69.
|
|
3
|
-
"source_commit": "
|
|
4
|
-
"source_sha256": "
|
|
2
|
+
"release_version": "1.69.4",
|
|
3
|
+
"source_commit": "fe786c6f3850ee404ab213075e8445a0af0ac2ec",
|
|
4
|
+
"source_sha256": "d26c4fbae9682e5da62ba9cfd5e0be1e8f580098e9439bcc6111e0a9d4b0d22b",
|
|
5
5
|
"target": "darwin-arm64",
|
|
6
|
-
"sha256": "
|
|
6
|
+
"sha256": "45eb395a0b0a1b7f4a5d69257475947a766c8875a51a97d7d523ecdb3dc95c22"
|
|
7
7
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
|
-
"release_version": "1.69.
|
|
3
|
-
"source_commit": "
|
|
4
|
-
"source_sha256": "
|
|
2
|
+
"release_version": "1.69.4",
|
|
3
|
+
"source_commit": "fe786c6f3850ee404ab213075e8445a0af0ac2ec",
|
|
4
|
+
"source_sha256": "d26c4fbae9682e5da62ba9cfd5e0be1e8f580098e9439bcc6111e0a9d4b0d22b",
|
|
5
5
|
"target": "linux-x64",
|
|
6
|
-
"sha256": "
|
|
6
|
+
"sha256": "cb1ce9a04c0a82d89e8fbe1fa4ced2f909905b7177b9dd384942041e09056955"
|
|
7
7
|
}
|
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schema_version": 1,
|
|
3
|
-
"release_version": "1.69.
|
|
4
|
-
"source_commit": "
|
|
5
|
-
"source_sha256": "
|
|
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": "
|
|
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": "
|
|
18
|
+
"sha256": "cb1ce9a04c0a82d89e8fbe1fa4ced2f909905b7177b9dd384942041e09056955",
|
|
19
19
|
"mode": "0755"
|
|
20
20
|
}
|
|
21
21
|
}
|
package/package.json
CHANGED
package/src/agent-session.mjs
CHANGED
|
@@ -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
|
@@ -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
|
package/src/status-band-read.mjs
CHANGED
|
@@ -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);
|