@botbuddy/cli 1.30.0 → 1.30.1

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/src/wait.mjs CHANGED
@@ -18,7 +18,9 @@ import { readAgentBinding, withPrincipalReceipt } from "./wait-profile.mjs";
18
18
  import { VERSION } from "./version.mjs";
19
19
  import { fileURLToPath } from "node:url";
20
20
  import { latestPublicCliCommand } from "./public-invocation.mjs";
21
- import { AGENT_KEY_RE, readAgentKeyEnv } from "./agent-key.mjs";
21
+ import { AGENT_KEY_RE, readAgentSessionTokenEnv } from "./agent-key.mjs";
22
+ import { clearAgentState, isRejectedCachedSession, resolveAgentSessionCredential, selfHealAgentSession } from "./agent-session.mjs";
23
+ import { touchAgentStateExpiry } from "./agent-state.mjs";
22
24
  import { SETUP_BLOCK } from "./setup-block.mjs";
23
25
  import { createWaitCheckpointStore, WaitCheckpointError } from "./wait-checkpoint.mjs";
24
26
  import { realpath } from "node:fs/promises";
@@ -28,7 +30,7 @@ import { realpath } from "node:fs/promises";
28
30
  // implementation gets a typed, safe upgrade instruction.
29
31
  // BOT-1554: protocol 2 makes --session-id mandatory for every non-timer wait (the
30
32
  // server keeps MINIMUM_WAIT_PROTOCOL=1 so pre-2 installs are not 426'd).
31
- // BOT-1572/1582: protocol 3 — a $BOTBUDDY_AGENT_KEY (bb_agent_) authenticates the
33
+ // BOT-1649: protocol 3 — a $BOTBUDDY_AGENT_SESSION_TOKEN (bb_sess_) authenticates the
32
34
  // wait on its own; no profile / --session-id / --token needed (the relay derives
33
35
  // agent, tenant, and session from the token). Falls back to protocol-2 behaviour
34
36
  // when no session token is present.
@@ -36,10 +38,14 @@ export const WAIT_PROTOCOL_VERSION = 3;
36
38
  const CLI_UPGRADE_COMMAND = latestPublicCliCommand("wait");
37
39
  const MIN_RECEIPT_MAX_BYTES = 512;
38
40
  const MAX_RECEIPT_MAX_BYTES = 64 * 1024;
39
- // BOT-1582: the server's session-token shape (bb_agent_ + 64 hex), plus the
40
- // BOT-1572 bb_sess_ legacy alias, both accepted for one release.
41
+ // BOT-1649: bb_sess_ is canonical; bb_agent_ remains accepted as a one-release
42
+ // alias (BOT-1582/1572 shape: bb_(agent|sess)_ + 64 hex).
41
43
  const SESSION_TOKEN_RE = AGENT_KEY_RE;
42
44
 
45
+ export function shouldRenewCachedSession(error, credentialSource, attempts) {
46
+ return isRejectedCachedSession(error, credentialSource, attempts);
47
+ }
48
+
43
49
  export const HELP = `botbuddy wait — one wait command instead of a polling loop (BOT-989)
44
50
 
45
51
  USAGE
@@ -122,25 +128,20 @@ OPTIONS
122
128
  --receipt-max-bytes <n> cap the receipt (512..65536; default 10240; payloads truncate to pointers)
123
129
  --heartbeat keep this agent session alive while waiting (so it is not reaped)
124
130
  --url <base> relay base URL (default $BOTBUDDY_RELAY_URL or https://api.bot-buddy.ai/functions/v1)
125
- --agent-key <token> the bb_agent_ session token to authenticate this wait a one-liner
126
- equivalent to exporting $BOTBUDDY_AGENT_KEY (which is the default).
127
- Only accepts a session token; a client/profile key is refused.
128
- (--session-token is the one-release legacy alias.)
129
- --session-id <uuid> attribute this wait to the arming session (the work-graph session id from register_agent); default $BOTBUDDY_SESSION_ID.
130
- Unnecessary when $BOTBUDDY_AGENT_KEY / --agent-key is set (the relay derives the session from the token).
131
+ --agent-session-token <token> advanced override for a managed runner; normal worktrees do not need it.
132
+ (--agent-key and --session-token are one-release aliases.)
133
+ --session-id <uuid> advanced attribution override; normal worktrees derive it automatically.
131
134
  --help show this help
132
135
  resume <local_wait_id> recover one explicitly named saved wait
133
136
  cancel <local_wait_id> explicitly stop one saved wait
134
137
  acknowledge <local_wait_id> mark one saved terminal receipt read
135
138
 
136
139
  AUTH
137
- A wait authenticates from the bb_agent_ session token (minted by register_agent):
138
- export $BOTBUDDY_AGENT_KEY, or pass it inline with --agent-key for a one-liner
139
- ($BOTBUDDY_SESSION_TOKEN / --session-token still accepted for one release).
140
- --token and --profile are RETIRED (BOT-1574): they were untyped and could carry a
141
- machine credential; use --agent-key, which only accepts a session token. The
142
- per-machine client key (botbuddy login) is a setup credential, never a wait
143
- credential.
140
+ In a configured project worktree, bb wait restores or renews its session automatically.
141
+ For first-time setup or repair, run bb setup once, then retry the same wait. No environment
142
+ variables, copied identifiers, or credential flags are needed for normal waits.
143
+ --token and --profile are RETIRED (BOT-1574); the session override above is for managed
144
+ runners only. The per-machine client key is a setup credential, never a wait credential.
144
145
 
145
146
  OUTPUT
146
147
  Exactly one JSON receipt line on stdout at exit, with client semver and
@@ -184,11 +185,10 @@ function parseArgv(argv) {
184
185
  // BOT-1467: attribute this wait to the arming session's agent (the id from
185
186
  // register_agent) instead of the tenant-bound profile agent. Env fallback.
186
187
  sessionId: process.env.BOTBUDDY_SESSION_ID || null,
187
- // BOT-1572/1582: the per-session token. When present it is the ONLY credential —
188
- // profile, --session-id, and --token are unnecessary. $BOTBUDDY_AGENT_KEY is
189
- // the norm ($BOTBUDDY_SESSION_TOKEN still accepted for one release); the flag
190
- // is for tests/overrides.
191
- sessionToken: readAgentKeyEnv(process.env),
188
+ // BOT-1649: the per-session token. When present it is the ONLY credential —
189
+ // profile, --session-id, and --token are unnecessary.
190
+ // $BOTBUDDY_AGENT_SESSION_TOKEN is canonical; aliases remain accepted.
191
+ sessionToken: readAgentSessionTokenEnv(process.env),
192
192
  help: false,
193
193
  deadlineAt: null,
194
194
  resumeLocalWait: null,
@@ -218,13 +218,13 @@ function parseArgv(argv) {
218
218
  // following value so it is not mis-parsed as a condition, then fail fast in
219
219
  // runWait with a migration hint (AC3).
220
220
  else if (a === "--token" || a === "--profile") { opts.retiredFlag ??= a; optionValue(); }
221
- // BOT-1574: --agent-key is the typed one-liner for the wait credential — it
222
- // carries the bb_agent_ session token (equivalent to exporting
223
- // $BOTBUDDY_AGENT_KEY), and NOTHING else: a client key / profile key is the
221
+ // BOT-1649: --agent-session-token is the canonical one-liner for the wait
222
+ // credential — it carries the bb_sess_ token (equivalent to exporting
223
+ // $BOTBUDDY_AGENT_SESSION_TOKEN), and NOTHING else: a client key / profile key is the
224
224
  // wrong shape and is refused invalid_session_token, so it can never smuggle a
225
225
  // machine credential onto a wait. --session-token is the one-release legacy
226
226
  // alias for the same value.
227
- else if (a === "--agent-key" || a === "--session-token") opts.sessionToken = optionValue();
227
+ else if (a === "--agent-session-token" || a === "--agent-key" || a === "--session-token") opts.sessionToken = optionValue();
228
228
  else if (a === "--session-id") opts.sessionId = optionValue();
229
229
  else if (a === "--resume-local-wait") opts.resumeLocalWait = optionValue();
230
230
  else if (a.startsWith("--")) opts.unknown = a;
@@ -259,8 +259,8 @@ async function reserveClaimQueueBoundary(opts, conditions) {
259
259
  }
260
260
 
261
261
  async function registerWait(opts, conditions, deadlineIso) {
262
- // BOT-1608: a relay wait authenticates from the per-session `bb_agent_` token
263
- // ($BOTBUDDY_AGENT_KEY) — the relay derives agent, tenant, and session from it.
262
+ // BOT-1649: a relay wait authenticates from the per-session `bb_sess_` token
263
+ // ($BOTBUDDY_AGENT_SESSION_TOKEN) — the relay derives agent, tenant, and session from it.
264
264
  // The retired protocol-2 profile register (profile + expected_tenant) is gone
265
265
  // with the profile bridge; runWait fails closed before here if no session token.
266
266
  const registerBody = {
@@ -492,6 +492,21 @@ async function finalizeWait(opts, waitSessionId, receipt, timeoutMs = 5000) {
492
492
  // parsed frames. Server emits `id: <seq>` per signal and replays `seq > since`
493
493
  // on connect, so a reconnect never loses an event.
494
494
  function makeConnect(opts) {
495
+ // BOT-1649: mirror the relay's keepalive expiry roll into the 0600 cache. The
496
+ // server bumps session_token_expires_at on connect and every keepalive, but
497
+ // the cache keeps its register-time deadline — so a wait near the 8 h cap goes
498
+ // locally stale ~5 min early while the server token is still valid, and a
499
+ // concurrent command would remint and reconcile away this running wait. Touch
500
+ // the cache on live-stream activity, throttled to at most one write / 4 min.
501
+ // touchAgentStateExpiry self-gates on a token match, so flag/env sources whose
502
+ // token isn't the cached one are a no-op.
503
+ let lastCacheTouch = 0;
504
+ const touchCache = () => {
505
+ const now = Date.now();
506
+ if (now - lastCacheTouch < 240_000) return;
507
+ lastCacheTouch = now;
508
+ void touchAgentStateExpiry(process.cwd(), opts.token).catch(() => {});
509
+ };
495
510
  return async function connect(since) {
496
511
  const sinceParam = normalizeSince(since);
497
512
  const url = new URL(`${opts.url.replace(/\/$/, "")}/event-stream`);
@@ -527,7 +542,7 @@ function makeConnect(opts) {
527
542
  if (res.status === 403) return errorStream("forbidden");
528
543
  if (!res.ok || !res.body) throw new Error(`relay responded ${res.status}`);
529
544
 
530
- return sseFrameStream(res.body, { onIdle: () => ac.abort() });
545
+ return sseFrameStream(res.body, { onIdle: () => ac.abort(), onActivity: touchCache });
531
546
  };
532
547
  }
533
548
 
@@ -605,7 +620,7 @@ function sseIdleTimeoutMs() {
605
620
 
606
621
  const SSE_IDLE = Symbol("sse_idle");
607
622
 
608
- export async function* sseFrameStream(body, { idleMs = sseIdleTimeoutMs(), onIdle = null } = {}) {
623
+ export async function* sseFrameStream(body, { idleMs = sseIdleTimeoutMs(), onIdle = null, onActivity = null } = {}) {
609
624
  const decoder = new TextDecoder();
610
625
  let buf = "";
611
626
  const iterator = body[Symbol.asyncIterator]();
@@ -633,6 +648,11 @@ export async function* sseFrameStream(body, { idleMs = sseIdleTimeoutMs(), onIdl
633
648
  }
634
649
  const { value: chunk, done } = result;
635
650
  if (done) return;
651
+ // A chunk (data frame OR a keepalive comment) means the relay is live and
652
+ // has just rolled the session's server-side expiry forward. Let the caller
653
+ // mirror that roll locally (BOT-1649) so a long wait's cache never goes
654
+ // stale ahead of the still-valid token.
655
+ if (onActivity) onActivity();
636
656
  buf += decoder.decode(chunk, { stream: true });
637
657
  const { frames, rest } = parseSseFrames(buf);
638
658
  buf = rest;
@@ -712,15 +732,14 @@ function emit(receipt, { versioned = false, maxBytes = null } = {}) {
712
732
  // renders. Keep each to one line and never embed a secret value — names, slots,
713
733
  // and env-var names only.
714
734
  export const RECOVERY = Object.freeze({
715
- // BOT-1582: $BOTBUDDY_AGENT_KEY (bb_agent_+64hex, renamed from
716
- // $BOTBUDDY_SESSION_TOKEN) is minted by register_agent; the harness exports it
717
- // at session start. The legacy var is still accepted for one release.
718
- sessionToken: "the tier-3 session token (bb_agent_) is minted by register_agent export BOTBUDDY_AGENT_KEY=<session_token> (and BOTBUDDY_SESSION_ID=<session_id>); the harness exports both at session start. See docs/agent-connectivity.md",
719
- // $BOTBUDDY_SESSION_ID is the work-graph session id returned by register_agent.
720
- sessionId: "register_agent → export BOTBUDDY_SESSION_ID=<session_id> (the work-graph session id from register_agent)",
735
+ // BOT-1649: normal waits restore or mint their per-worktree session themselves.
736
+ // Keep recovery as one concise command; credential plumbing is intentionally
737
+ // hidden from agents and operators.
738
+ sessionToken: "run `bb setup` from this project worktree, then retry the same wait (or `bb doctor --fix` for diagnosis). See docs/agent-connectivity.md",
739
+ sessionId: "run `bb setup` from this project worktree, then retry the same wait",
721
740
  // A --token / session-token contradiction: the session token is the whole
722
741
  // credential, so drop --token (never advise also setting a session id here).
723
- sessionTokenConflict: "unset --token — $BOTBUDDY_AGENT_KEY is the whole credential (protocol 3)",
742
+ sessionTokenConflict: "unset --token — the session credential is the whole credential (protocol 3)",
724
743
  // Grammar/condition errors point at the help and the canonical doc.
725
744
  conditions: "check the condition grammar: botbuddy wait --help / docs/agent-wait.md",
726
745
  });
@@ -745,7 +764,7 @@ async function reportSetupError(opts, { error, exitCode, conditions }) {
745
764
  // Never on the session-token path. A bb_agent_/bb_sess_ SESSION token cannot
746
765
  // authenticate at sensor-ingest (edgeAuth resolves only PATs / agent keys), so it
747
766
  // would only 401. Discriminate by the credential's SOURCE, not its shape: a
748
- // session token arrives only via $BOTBUDDY_AGENT_KEY / --session-token
767
+ // session token arrives only via $BOTBUDDY_AGENT_SESSION_TOKEN / --agent-session-token
749
768
  // (opts.sessionToken), whereas a resolved PROFILE credential may legitimately be a
750
769
  // legacy bb_agent_ CARRIER key that ingest CAN authenticate — the two share a shape
751
770
  // (BOT-1582), so a shape check would wrongly drop the carrier case (Codex P1).
@@ -928,42 +947,89 @@ export function restoreCapacityStaleDeadlines(conditions, checkpoint) {
928
947
  return conditions;
929
948
  }
930
949
 
931
- async function deliverRecoveryAction(checkpoint, action) {
932
- if (!checkpoint.cloud_wait_session_id) return { delivered: true };
933
- const token = readAgentKeyEnv(process.env);
934
- if (!token || !SESSION_TOKEN_RE.test(token)) {
935
- return { delivered: false, error: "session_token_required" };
936
- }
937
- const baseUrl = checkpoint.relay_url || process.env.BOTBUDDY_RELAY_URL || "https://api.bot-buddy.ai/functions/v1";
938
- const controller = new AbortController();
939
- const timer = setTimeout(() => controller.abort(), 5_000);
950
+ // BOT-1649 (Codex round-5 P1): recovery actions (cancel / finalize / acknowledge
951
+ // / lookup) must authenticate with the SAME managed credential the arm used —
952
+ // flag env → 0600 cache → self-heal — not env alone. After the variable-free
953
+ // `bb setup` flow the session token lives only in the worktree cache, so an
954
+ // env-only read left cancel/finalize permanently `*_pending` and could orphan a
955
+ // claim wait that later grabs its resource.
956
+ export async function resolveRecoveryToken(cwd = process.cwd(), env = process.env) {
940
957
  try {
941
- const res = await fetch(`${baseUrl.replace(/\/$/, "")}/event-stream`, {
942
- method: "POST",
943
- headers: {
944
- Authorization: `Bearer ${token}`,
945
- "x-agent-api-key": token,
946
- "Content-Type": "application/json",
947
- },
948
- body: JSON.stringify({ action, wait_session_id: checkpoint.cloud_wait_session_id }),
949
- signal: controller.signal,
950
- });
951
- const body = await res.json().catch(() => ({}));
952
- const resultKey = action === "acknowledge" ? "acknowledged" : "cancelled";
953
- // The recovery endpoint is deliberately idempotent and uses a 200 response
954
- // for a rejected state transition. Only its explicit boolean proves that
955
- // the cloud row changed; otherwise retain the local delivery outbox.
956
- if (!res.ok || body?.[resultKey] !== true) {
957
- return { delivered: false, error: body?.error || `${action}_not_applied` };
958
+ const credential = await resolveAgentSessionCredential({ argv: [], env, cwd });
959
+ const token = credential?.token ?? null;
960
+ return token && SESSION_TOKEN_RE.test(token) ? token : null;
961
+ } catch { return null; }
962
+ }
963
+
964
+ // BOT-1649 (Codex round-6 P1): every recovery call (cancel / finalize /
965
+ // acknowledge / lookup) POSTs to /event-stream and must survive a
966
+ // server-revoked CACHE token exactly like normal wait registration. Resolve the
967
+ // credential WITH its source, and on a 401 session-token rejection of a
968
+ // cache-sourced token, clear the 0600 cache, re-mint under the durable client
969
+ // key, and retry the request once. Without this a locally cancelled/finalized
970
+ // claim wait stays `*_pending` forever while its cloud row remains ACTIVE and
971
+ // can still acquire the resource. Flag/env tokens are caller-owned and never
972
+ // silently replaced; the re-mint source is "minted", so a second rejection
973
+ // cannot loop. Returns one of: {error} (no usable token), {transportError:
974
+ // "timeout"|"unavailable"}, or {ok, status, body}.
975
+ async function postRecovery(relayUrl, payload, { cwd = process.cwd(), env = process.env } = {}) {
976
+ let credential = null;
977
+ try { credential = await resolveAgentSessionCredential({ argv: [], env, cwd }); } catch { credential = null; }
978
+ let token = credential?.token ?? null;
979
+ let source = credential?.source ?? null;
980
+ if (!token || !SESSION_TOKEN_RE.test(token)) return { error: "session_token_required" };
981
+ const baseUrl = relayUrl || env.BOTBUDDY_RELAY_URL || "https://api.bot-buddy.ai/functions/v1";
982
+ const url = `${baseUrl.replace(/\/$/, "")}/event-stream`;
983
+ let attempts = 0;
984
+ for (;;) {
985
+ const controller = new AbortController();
986
+ const timer = setTimeout(() => controller.abort(), 5_000);
987
+ let res;
988
+ try {
989
+ res = await fetch(url, {
990
+ method: "POST",
991
+ headers: { Authorization: `Bearer ${token}`, "x-agent-api-key": token, "Content-Type": "application/json" },
992
+ body: JSON.stringify(payload),
993
+ signal: controller.signal,
994
+ });
995
+ } catch (error) {
996
+ clearTimeout(timer);
997
+ return { transportError: error?.name === "AbortError" ? "timeout" : "unavailable" };
958
998
  }
959
- return { delivered: true };
960
- } catch (error) {
961
- return { delivered: false, error: error?.name === "AbortError" ? `${action}_timeout` : `${action}_unavailable` };
962
- } finally {
963
999
  clearTimeout(timer);
1000
+ const body = await res.json().catch(() => ({}));
1001
+ // A revoked/expired session token returns a typed 401. Renew a cache token
1002
+ // once (isRejectedCachedSession gates on source==="cache" && attempts===0).
1003
+ if (res.status === 401 && isRejectedCachedSession({ code: body?.error }, source, attempts)) {
1004
+ attempts += 1;
1005
+ await clearAgentState(cwd);
1006
+ let renewed = null;
1007
+ try { renewed = await selfHealAgentSession({ cwd, env }); } catch { renewed = null; }
1008
+ if (renewed?.token && SESSION_TOKEN_RE.test(renewed.token)) {
1009
+ token = renewed.token;
1010
+ source = renewed.source;
1011
+ continue;
1012
+ }
1013
+ }
1014
+ return { ok: res.ok, status: res.status, body };
964
1015
  }
965
1016
  }
966
1017
 
1018
+ async function deliverRecoveryAction(checkpoint, action) {
1019
+ if (!checkpoint.cloud_wait_session_id) return { delivered: true };
1020
+ const result = await postRecovery(checkpoint.relay_url, { action, wait_session_id: checkpoint.cloud_wait_session_id });
1021
+ if (result.error) return { delivered: false, error: result.error };
1022
+ if (result.transportError) return { delivered: false, error: `${action}_${result.transportError}` };
1023
+ const resultKey = action === "acknowledge" ? "acknowledged" : "cancelled";
1024
+ // The recovery endpoint is deliberately idempotent and uses a 200 response
1025
+ // for a rejected state transition. Only its explicit boolean proves that
1026
+ // the cloud row changed; otherwise retain the local delivery outbox.
1027
+ if (!result.ok || result.body?.[resultKey] !== true) {
1028
+ return { delivered: false, error: result.body?.error || `${action}_not_applied` };
1029
+ }
1030
+ return { delivered: true };
1031
+ }
1032
+
967
1033
  function checkpointNeedsRelay(checkpoint) {
968
1034
  return checkpoint.conditions.some((condition) => condition.type !== "timer");
969
1035
  }
@@ -972,85 +1038,51 @@ async function resolveRecoveryWaitSession(checkpoint, { reserveMissing = false }
972
1038
  if (checkpoint.cloud_wait_session_id || !checkpointNeedsRelay(checkpoint)) {
973
1039
  return { registration: null };
974
1040
  }
975
- const token = readAgentKeyEnv(process.env);
976
- if (!token || !SESSION_TOKEN_RE.test(token)) {
977
- return { error: "session_token_required" };
1041
+ const result = await postRecovery(checkpoint.relay_url, {
1042
+ action: reserveMissing ? "recover_cancel_absent" : "recover_lookup",
1043
+ local_wait_id: checkpoint.local_wait_id,
1044
+ });
1045
+ if (result.error) return { error: result.error };
1046
+ if (result.transportError) {
1047
+ return { error: result.transportError === "timeout" ? "wait_recovery_lookup_timeout" : "wait_recovery_lookup_unavailable" };
978
1048
  }
979
- const url = checkpoint.relay_url || process.env.BOTBUDDY_RELAY_URL || "https://api.bot-buddy.ai/functions/v1";
980
- const controller = new AbortController();
981
- const timer = setTimeout(() => controller.abort(), 5_000);
982
- try {
983
- const res = await fetch(`${url.replace(/\/$/, "")}/event-stream`, {
984
- method: "POST",
985
- headers: {
986
- Authorization: `Bearer ${token}`,
987
- "x-agent-api-key": token,
988
- "Content-Type": "application/json",
989
- },
990
- body: JSON.stringify({ action: reserveMissing ? "recover_cancel_absent" : "recover_lookup", local_wait_id: checkpoint.local_wait_id }),
991
- signal: controller.signal,
992
- });
993
- const body = await res.json().catch(() => ({}));
994
- if (!res.ok) return { error: body?.error || "wait_recovery_lookup_failed" };
995
- if (body?.found !== true) return { missing: true };
996
- if (typeof body.wait_session_id !== "string" || !body.wait_session_id) {
997
- return { error: "wait_session_id_unavailable" };
998
- }
999
- return {
1000
- registration: {
1001
- waitSessionId: body.wait_session_id,
1002
- cursorStart: body.cursor_start ?? null,
1003
- sessionTenant: checkpoint.tenant_id ?? null,
1004
- agentId: checkpoint.agent_id ?? null,
1005
- sessionId: checkpoint.arming_session_id ?? null,
1006
- sessionAgentId: null,
1007
- status: typeof body.status === "string" ? body.status : "active",
1008
- terminalReceipt: body.terminal_receipt && typeof body.terminal_receipt === "object" ? body.terminal_receipt : null,
1009
- },
1010
- };
1011
- } catch (error) {
1012
- return { error: error?.name === "AbortError" ? "wait_recovery_lookup_timeout" : "wait_recovery_lookup_unavailable" };
1013
- } finally {
1014
- clearTimeout(timer);
1049
+ const body = result.body ?? {};
1050
+ if (!result.ok) return { error: body?.error || "wait_recovery_lookup_failed" };
1051
+ if (body?.found !== true) return { missing: true };
1052
+ if (typeof body.wait_session_id !== "string" || !body.wait_session_id) {
1053
+ return { error: "wait_session_id_unavailable" };
1015
1054
  }
1055
+ return {
1056
+ registration: {
1057
+ waitSessionId: body.wait_session_id,
1058
+ cursorStart: body.cursor_start ?? null,
1059
+ sessionTenant: checkpoint.tenant_id ?? null,
1060
+ agentId: checkpoint.agent_id ?? null,
1061
+ sessionId: checkpoint.arming_session_id ?? null,
1062
+ sessionAgentId: null,
1063
+ status: typeof body.status === "string" ? body.status : "active",
1064
+ terminalReceipt: body.terminal_receipt && typeof body.terminal_receipt === "object" ? body.terminal_receipt : null,
1065
+ },
1066
+ };
1016
1067
  }
1017
1068
 
1018
1069
  async function deliverTerminalFinalization(checkpoint) {
1019
1070
  if (!checkpoint.cloud_wait_session_id || !checkpoint.terminal_receipt) return { delivered: true };
1020
- const token = readAgentKeyEnv(process.env);
1021
- if (!token || !SESSION_TOKEN_RE.test(token)) return { delivered: false, error: "session_token_required" };
1022
- const baseUrl = checkpoint.relay_url || process.env.BOTBUDDY_RELAY_URL || "https://api.bot-buddy.ai/functions/v1";
1023
- const controller = new AbortController();
1024
- const timer = setTimeout(() => controller.abort(), 5_000);
1025
- try {
1026
- const receipt = checkpoint.terminal_receipt;
1027
- const res = await fetch(`${baseUrl.replace(/\/$/, "")}/event-stream`, {
1028
- method: "POST",
1029
- headers: {
1030
- Authorization: `Bearer ${token}`,
1031
- "x-agent-api-key": token,
1032
- "Content-Type": "application/json",
1033
- },
1034
- body: JSON.stringify({
1035
- action: "recover_finalize",
1036
- wait_session_id: checkpoint.cloud_wait_session_id,
1037
- status: receipt.outcome,
1038
- receipt,
1039
- reconnects: receipt.reconnects ?? 0,
1040
- degraded: receipt.degraded ?? [],
1041
- }),
1042
- signal: controller.signal,
1043
- });
1044
- const body = await res.json().catch(() => ({}));
1045
- if (!res.ok || body?.finalized !== true) {
1046
- return { delivered: false, error: body?.error || "finalize_not_applied" };
1047
- }
1048
- return { delivered: true };
1049
- } catch (error) {
1050
- return { delivered: false, error: error?.name === "AbortError" ? "finalize_timeout" : "finalize_unavailable" };
1051
- } finally {
1052
- clearTimeout(timer);
1071
+ const receipt = checkpoint.terminal_receipt;
1072
+ const result = await postRecovery(checkpoint.relay_url, {
1073
+ action: "recover_finalize",
1074
+ wait_session_id: checkpoint.cloud_wait_session_id,
1075
+ status: receipt.outcome,
1076
+ receipt,
1077
+ reconnects: receipt.reconnects ?? 0,
1078
+ degraded: receipt.degraded ?? [],
1079
+ });
1080
+ if (result.error) return { delivered: false, error: result.error };
1081
+ if (result.transportError) return { delivered: false, error: `finalize_${result.transportError}` };
1082
+ if (!result.ok || result.body?.finalized !== true) {
1083
+ return { delivered: false, error: result.body?.error || "finalize_not_applied" };
1053
1084
  }
1085
+ return { delivered: true };
1054
1086
  }
1055
1087
 
1056
1088
  async function flushPendingFinalization(store, checkpoint) {
@@ -1329,9 +1361,8 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
1329
1361
  // a typed `unknown_option` receipt, then exit 4 — before any relay call.
1330
1362
  if (opts.retiredFlag) {
1331
1363
  process.stderr.write(
1332
- `botbuddy wait: ${opts.retiredFlag} is retired (it could carry a machine credential) a wait authenticates from the bb_agent_ session token. `
1333
- + "Pass it typed with --agent-key <token>, or export $BOTBUDDY_AGENT_KEY (from register_agent), then: botbuddy wait '<condition>'. "
1334
- + "The per-machine client key (botbuddy login) is never a wait credential.\n",
1364
+ `botbuddy wait: ${opts.retiredFlag} is retired run bb wait from a configured worktree and it restores its session automatically. `
1365
+ + "If setup is needed, run bb setup, then retry the same wait.\n",
1335
1366
  );
1336
1367
  emitReceipt({
1337
1368
  schema_version: 1,
@@ -1472,20 +1503,40 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
1472
1503
  }
1473
1504
 
1474
1505
  const needsRelay = conditions.some((c) => c.type !== "timer");
1475
- // BOT-1572/1582/1608: a relay wait authenticates from the per-session
1476
- // $BOTBUDDY_AGENT_KEY (bb_agent_) — the relay derives agent, tenant, and session
1506
+ if (needsRelay) {
1507
+ try {
1508
+ const credential = await resolveAgentSessionCredential({ argv, env: process.env, cwd: process.cwd() });
1509
+ opts.sessionToken = credential.token;
1510
+ opts.sessionId = credential.sessionId ?? opts.sessionId;
1511
+ opts.credentialSource = credential.source;
1512
+ } catch (error) {
1513
+ const code = error?.code ?? "session_mint_failed";
1514
+ const recovery = code === "client_key_required"
1515
+ ? "run `bb setup` from this project worktree, then retry"
1516
+ : code === "tenant_binding_required"
1517
+ ? "run from a configured project worktree, then run `bb setup` and retry"
1518
+ : "run `bb setup` and retry";
1519
+ process.stderr.write(`botbuddy wait: ${error?.message ?? error}; ${recovery}\n`);
1520
+ emitReceipt({ schema_version: 1, outcome: "error", error: code, recovery });
1521
+ await reportSetupError(opts, { error: code, exitCode: EXIT.AUTH, conditions });
1522
+ process.exit(EXIT.AUTH);
1523
+ }
1524
+ }
1525
+ // BOT-1649: a relay wait authenticates from the per-session
1526
+ // $BOTBUDDY_AGENT_SESSION_TOKEN (bb_sess_; bb_agent_ remains a legacy alias)
1527
+ // — the relay derives agent, tenant, and session
1477
1528
  // from the token. The retired protocol-2 profile register (profile + --session-id
1478
1529
  // + a tenant-bound carrier from `profile setup`) is gone with the profile bridge,
1479
- // so a relay wait with no session token now fails closed with an actionable fix.
1530
+ // so an absent/stale cache self-heals before this validation.
1480
1531
  if (needsRelay && opts.sessionToken) {
1481
1532
  if (!SESSION_TOKEN_RE.test(opts.sessionToken)) {
1482
- process.stderr.write(`botbuddy wait: $BOTBUDDY_AGENT_KEY must match bb_agent_<64 hex>; ${RECOVERY.sessionToken}\n`);
1533
+ process.stderr.write(`botbuddy wait: BOTBUDDY_AGENT_SESSION_TOKEN must match bb_sess_<64 hex> (bb_agent_ is a legacy prefix); ${RECOVERY.sessionToken}\n`);
1483
1534
  emitReceipt({ schema_version: 1, outcome: "error", error: "invalid_session_token", recovery: RECOVERY.sessionToken });
1484
1535
  await reportSetupError(opts, { error: "invalid_session_token", exitCode: EXIT.INVALID, conditions });
1485
1536
  process.exit(EXIT.INVALID);
1486
1537
  }
1487
1538
  if (opts.token && opts.token !== opts.sessionToken) {
1488
- process.stderr.write(`botbuddy wait: --token conflicts with $BOTBUDDY_AGENT_KEY; ${RECOVERY.sessionTokenConflict}\n`);
1539
+ process.stderr.write(`botbuddy wait: --token conflicts with $BOTBUDDY_AGENT_SESSION_TOKEN; ${RECOVERY.sessionTokenConflict}\n`);
1489
1540
  emitReceipt({ schema_version: 1, outcome: "error", error: "session_token_conflict", recovery: RECOVERY.sessionTokenConflict });
1490
1541
  await reportSetupError(opts, { error: "session_token_conflict", exitCode: EXIT.INVALID, conditions });
1491
1542
  process.exit(EXIT.INVALID);
@@ -1495,7 +1546,7 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
1495
1546
  } else if (needsRelay) {
1496
1547
  // No session token → the wait has no credential. Fail fast (before any network
1497
1548
  // call) so a wait is never filed under whatever machine credential is lying
1498
- // around. register_agent mints the token and the harness exports it.
1549
+ // around. The normal recovery is the one-command worktree bootstrap.
1499
1550
  process.stderr.write(
1500
1551
  `botbuddy wait: a relay wait requires the per-session agent token — ${RECOVERY.sessionToken}, then: botbuddy wait '<condition>'\n`,
1501
1552
  );
@@ -1537,14 +1588,45 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
1537
1588
  let replayExpiredRecovery = false;
1538
1589
  if (needsRelay) {
1539
1590
  try {
1540
- // The reservation is made while this checkpoint is still live, on the
1541
- // database clock. An expired recovery with no reservation fails closed and
1542
- // cannot release a queue re-created by another logical wait.
1591
+ // BOT-1656: the claim-queue reservation is made while this checkpoint is
1592
+ // still live, on the database clock. An expired recovery with no
1593
+ // reservation fails closed and cannot release a queue re-created by another
1594
+ // logical wait. It happens once, before (and outside) the self-heal retry.
1543
1595
  if ((Date.now() < deadlineMs || recoveryLocalWaitId != null) &&
1544
1596
  conditions.some((condition) => condition.type === "lock" && condition.params?.claim === true)) {
1545
1597
  await reserveClaimQueueBoundary(opts, conditions);
1546
1598
  }
1547
- const reg = await registerWait(opts, conditions, new Date(deadlineMs).toISOString());
1599
+ // BOT-1649: a revoked/expired token from the 0600 cache is recoverable:
1600
+ // remove only that cache, re-adopt the public agent id under the client
1601
+ // key, and retry registration exactly once. Explicit flags/env are never
1602
+ // silently replaced because the caller owns those credentials. Re-register
1603
+ // is idempotent on the checkpoint's logical_wait_id (BOT-1656), so the
1604
+ // reservation above is not repeated.
1605
+ let attempts = 0;
1606
+ let reg;
1607
+ for (;;) {
1608
+ try {
1609
+ reg = await registerWait(opts, conditions, new Date(deadlineMs).toISOString());
1610
+ break;
1611
+ } catch (error) {
1612
+ if (!shouldRenewCachedSession(error, opts.credentialSource, attempts)) throw error;
1613
+ attempts += 1;
1614
+ await clearAgentState(process.cwd());
1615
+ const credential = await selfHealAgentSession({ cwd: process.cwd(), env: process.env });
1616
+ opts.sessionToken = credential.token;
1617
+ opts.sessionId = credential.sessionId ?? opts.sessionId;
1618
+ opts.credentialSource = credential.source;
1619
+ opts.token = credential.token;
1620
+ }
1621
+ }
1622
+ // BOT-1649 (Codex P1): the server just rolled this session's expiry forward
1623
+ // on register. Mirror that roll into the 0600 cache so a token in active use
1624
+ // is never judged locally stale and re-minted — a re-mint would revoke the
1625
+ // token a concurrent wait still holds. Touch only the cache we actually used
1626
+ // (source "cache"); a "minted" source already wrote a fresh deadline.
1627
+ if (opts.credentialSource === "cache") {
1628
+ await touchAgentStateExpiry(process.cwd(), opts.token).catch(() => {});
1629
+ }
1548
1630
  waitSessionId = reg.waitSessionId;
1549
1631
  opts.waitSessionId = waitSessionId;
1550
1632
  sessionTenant = reg.sessionTenant;
@@ -1679,12 +1761,11 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
1679
1761
  process.exit(EXIT.INVALID);
1680
1762
  }
1681
1763
  if (err && err.auth) {
1682
- // BOT-1574 (AC4): the relay refused a wait armed by the per-machine client
1683
- // key (or any owner/setup credential). The fix is never profile setup —
1684
- // register a work agent and export its session token.
1764
+ // A per-machine client key cannot arm a wait directly. Keep the recovery
1765
+ // to the normal worktree bootstrap rather than exposing session internals.
1685
1766
  if (err.errorCode === "client_key_cannot_wait") {
1686
1767
  process.stderr.write(
1687
- "botbuddy wait: a tier-1 client key (bb_cli_, botbuddy login) cannot arm a wait — you need the tier-3 session token: register_agent, export BOTBUDDY_AGENT_KEY=<session_token>, then re-run\n",
1768
+ "botbuddy wait: this worktree needs a session; run bb setup, then retry the same wait\n",
1688
1769
  );
1689
1770
  emitReceipt({
1690
1771
  schema_version: 1,
@@ -1694,11 +1775,8 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
1694
1775
  });
1695
1776
  process.exit(EXIT.AUTH);
1696
1777
  }
1697
- // BOT-1554: SESSION-IDENTITY failures the session token (or the session
1698
- // id it carries) is a shared service carrier, an ended/foreign session, or
1699
- // a wrong-tenant session. Retrying with the SAME identity just repeats the
1700
- // 403, so lead with the reliable fix: register a WORK agent and export its
1701
- // NEW session token/id.
1778
+ // Session-identity failures need a new worktree session. Do not make the
1779
+ // caller reconstruct it by hand.
1702
1780
  const SESSION_IDENTITY_ERRORS = new Set([
1703
1781
  "wait_actor_required", "session_agent_forbidden", "session_agent_tenant_mismatch",
1704
1782
  ]);
@@ -1709,29 +1787,28 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
1709
1787
  ? "the session token resolves to a different tenant than this wait"
1710
1788
  : "the session token is not a live agent session you own";
1711
1789
  process.stderr.write(
1712
- `botbuddy wait: ${cause}; register a work agent (register_agent) and export the returned $BOTBUDDY_AGENT_KEY (and $BOTBUDDY_SESSION_ID)\n`,
1790
+ `botbuddy wait: ${cause}; run bb setup, then retry the same wait\n`,
1713
1791
  );
1714
1792
  emitReceipt(withPrincipalReceipt({
1715
1793
  schema_version: 1,
1716
1794
  outcome: "error",
1717
1795
  error: err.errorCode,
1718
1796
  ...(err.carrierAgentId ? { carrier_agent_id: err.carrierAgentId } : {}),
1719
- recovery: "register_agent export BOTBUDDY_AGENT_KEY=<session_token>",
1797
+ recovery: "run `bb setup`, then retry the same wait",
1720
1798
  }, null, { sessionTenant, agentId: null }));
1721
1799
  await reportSetupError(opts, { error: err.errorCode, exitCode: EXIT.AUTH, conditions });
1722
1800
  process.exit(EXIT.AUTH);
1723
1801
  }
1724
1802
  // Any other relay auth refusal (revoked/expired/rotated token, or the relay
1725
- // refusing to honour it) is fixed by RE-REGISTERING and exporting the new
1726
- // session token — the profile bridge and its recoveries are gone (BOT-1608).
1803
+ // refusing to honour it) is repaired by the worktree bootstrap.
1727
1804
  process.stderr.write(
1728
- `botbuddy wait: session token rejected (${err.errorCode || "unauthorized"}) — re-register the agent and export the new $BOTBUDDY_AGENT_KEY\n`,
1805
+ `botbuddy wait: session rejected (${err.errorCode || "unauthorized"}) — run bb setup, then retry the same wait\n`,
1729
1806
  );
1730
1807
  emitReceipt(withPrincipalReceipt({
1731
1808
  schema_version: 1,
1732
1809
  outcome: "error",
1733
1810
  error: err.errorCode || "unauthorized",
1734
- recovery: "register_agent export BOTBUDDY_AGENT_KEY=<session_token>",
1811
+ recovery: "run `bb setup`, then retry the same wait",
1735
1812
  }, null, { sessionTenant, agentId: registeredAgentId, sessionId: registeredSessionId }));
1736
1813
  // Session-token path → reportSetupError self-skips (a bb_agent_ bearer
1737
1814
  // ingest cannot authenticate); called for allowlist symmetry.