@botbuddy/cli 1.2.3 → 1.4.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 ADDED
@@ -0,0 +1,727 @@
1
+ #!/usr/bin/env node
2
+ // BOT-989 / BOT-1344 — `botbuddy wait`: the public aggregated agent wait interface.
3
+ //
4
+ // Park on a set of conditions (a lock coming free, a chat reply, a build
5
+ // finishing, a plain deadline) and wake EXACTLY ONCE with one machine-readable
6
+ // JSON receipt on stdout. Zero polling, zero tokens spent while waiting: the
7
+ // process blocks on a pushed SSE stream and the agent harness (Claude Code
8
+ // `run_in_background`, Codex background exec) is re-invoked when it exits.
9
+ //
10
+ // Dependency-free (Node >= 20 stdlib only). All wait semantics live in
11
+ // ./wait-core.mjs; this file is just argv + a real `connect()` + I/O.
12
+ //
13
+ // Usage: botbuddy wait [--any] <condition>... [options]
14
+ // Run botbuddy wait --help for the condition grammar.
15
+
16
+ import { EXIT, parseConditions, parseSseFrames, runWaitLoop, normalizeSince, truncateReceipt } from "./wait-core.mjs";
17
+ import { resolveAgentProfile, withPrincipalReceipt } from "./wait-profile.mjs";
18
+ import { VERSION } from "./version.mjs";
19
+ import { fileURLToPath } from "node:url";
20
+
21
+ // A protocol is deliberately distinct from package semver: compatible pinned
22
+ // clients keep working until the server raises this minimum, while a stale
23
+ // implementation gets a typed, safe upgrade instruction.
24
+ export const WAIT_PROTOCOL_VERSION = 1;
25
+ const CLI_UPGRADE_COMMAND = "npx --yes @botbuddy/cli@latest wait";
26
+ const MIN_RECEIPT_MAX_BYTES = 512;
27
+
28
+ const HELP = `botbuddy wait — one wait command instead of a polling loop (BOT-989)
29
+
30
+ USAGE
31
+ botbuddy wait [--any] <condition>... [options]
32
+
33
+ CONDITIONS (TYPE:key=val,key=val — repeat for several; --any wakes on the first)
34
+ timer:duration=<seconds> wake after N seconds (DB-clocked server-side)
35
+ timer:deadline=<ISO-8601> wake at an absolute time
36
+ chat:channel=<id>|* new Direct Chat message (or * = any channel you are in;
37
+ you are never woken by your own messages)
38
+ lock:subtype=<t>,host=<h>[,slot=<s>][,claim=true]
39
+ a resource lock becomes available
40
+ subtype ∈ playwright_lane|vite_port|backend_port|supabase_local
41
+ claim=true (needs slot=): ATOMIC claim-on-grant — queue for the
42
+ lock (acquire_lock while it's held), then wait; the server hands
43
+ you the lock and wakes you already HOLDING it (no acquire race).
44
+ Must be the ONLY condition (use --timeout to bound it); can't be
45
+ raced against other conditions. Omit claim to just wait for
46
+ availability and acquire it yourself.
47
+ pr-state:repo=<owner/repo>[,pr=<n>] a PR state transition (open/draft/merged/closed,
48
+ checks, review decision, unresolved threads, mergeable).
49
+ Omit pr= to wake on ANY of your PRs in the repo;
50
+ or pass pr=<owner/repo#n> in one value. A conflict/clean
51
+ flip sets payload.mergeable_changed (no new condition).
52
+ pr-review:repo=<owner/repo>[,pr=<n>]
53
+ a new review comment, or a thread resolved/re-opened
54
+ (payload carries the unresolved-thread count)
55
+ test-run:id=<run_id> a test run reaching a terminal status (owner-scoped)
56
+ lease:id=<lease_id> a batch stack lease (BOT-1218) leaving the queue /
57
+ becoming active — the zero-poll wake for a parked
58
+ \`botbuddy stack up\` (recipient-scoped to the owner).
59
+ Level-triggered: an already-granted lease wakes at once.
60
+ guidance:request=<post_id> a command-post guidance request being answered
61
+ (scoped to the asking agent; fetch the body with
62
+ get_guidance_response)
63
+ linear:issue=<KEY> a BOT-* issue changing state, a new comment, or an
64
+ assignee change (e.g. issue=BOT-123; tenant-scoped).
65
+ Flags linear_feed_lagging if the ingest feed has stalled.
66
+ unblocked:<KEY> a ticket's LAST open blocker clearing (open-blocker
67
+ count >0 → 0): a blocker moving to Done/Canceled/Duplicate
68
+ or the final blocks relation removed. Level-triggered —
69
+ already-unblocked (or never-blocked) at registration
70
+ grants immediately (payload.reason=already_unblocked).
71
+ Tenant-scoped. Also accepts unblocked:ticket=<KEY>.
72
+ capacity:policy=<p>,slots>=<N>[,host=<h>][,stale_grace=<sec>]
73
+ a managed-container host reaching >= N free slots.
74
+ Host-shared; a silently-dead host is detected server-side
75
+ (capacity_source_stale) via a host beacon — the client
76
+ grace (default 900s) is a backstop, no longer the only guard.
77
+ ci:repo=<owner/repo>,{scope=latest,pr=<n> | run_id=<id> | sha=<sha>}
78
+ a PR's CI reaching a terminal conclusion (owner-scoped);
79
+ GitHub Actions or an external check_suite provider.
80
+ A repo with no CI feed is rejected (no_signal_source).
81
+ event:type=<signal_type>[,subject=<key>]
82
+ raw signal-spine match (escape hatch)
83
+
84
+ OPTIONS
85
+ --any wake on the first matching condition (default; only mode in v1)
86
+ --timeout <seconds> max wait (default 7200; hard cap 28800). Timeout is exit 2, not an error.
87
+ --since <seq> resume from a prior receipt's next_cursor (replay what you missed)
88
+ --receipt-max-bytes <n> cap the receipt (minimum 512; default 10240; payloads truncate to pointers)
89
+ --heartbeat keep this agent session alive while waiting (so it is not reaped)
90
+ --url <base> relay base URL (default $BOTBUDDY_RELAY_URL or https://api.bot-buddy.ai/functions/v1)
91
+ --profile <name> tenant-bound machine profile (normally read from .botbuddy-agent.json)
92
+ --token <key> explicit agent key override; otherwise the profile-specific env is used
93
+ --help show this help
94
+
95
+ OUTPUT
96
+ Exactly one JSON receipt line on stdout at exit, with client semver and
97
+ protocol identity. Diagnostics go to stderr.
98
+ Exit codes: 0 matched · 2 timeout · 3 auth · 4 invalid conditions ·
99
+ 5 backend unavailable · 6 cursor expired · 7 internal ·
100
+ 8 superseded (wait replaced server-side). If the receipt has
101
+ reacquire_lock (claim=true), acquire_lock again FIRST and re-run
102
+ only on a queued/busy result (granted = you hold it — just resume);
103
+ otherwise re-run from the receipt's next_cursor. docs/agent-wait.md.
104
+
105
+ EXAMPLES
106
+ # wait for a Playwright lane to free up, then acquire it yourself
107
+ botbuddy wait 'lock:subtype=playwright_lane,host=Jonos-MBP' --timeout 1800
108
+
109
+ # queue for a specific lane and be HANDED it when it frees (no acquire race):
110
+ # acquire_lock (gets queued) → bb-wait claim=true → wake already holding lane 3
111
+ botbuddy wait 'lock:subtype=playwright_lane,host=Jonos-MBP,slot=3,claim=true' --timeout 1800 --heartbeat
112
+
113
+ # wait for a chat reply on any channel, or 20 minutes, whichever first
114
+ botbuddy wait --any 'chat:channel=*' 'timer:duration=1200' --heartbeat
115
+
116
+ # after pushing review fixes, wait for the next review comment or a state change
117
+ botbuddy wait --any 'pr-review:repo=bot-buddy/botbuddy-webapp,pr=425' \\
118
+ 'pr-state:repo=bot-buddy/botbuddy-webapp,pr=425' --timeout 3600
119
+ `;
120
+
121
+ function parseArgv(argv) {
122
+ const opts = {
123
+ conditions: [],
124
+ mode: "any",
125
+ timeout: 7200,
126
+ since: null,
127
+ receiptMaxBytes: 10240,
128
+ heartbeat: false,
129
+ url: process.env.BOTBUDDY_RELAY_URL || "https://api.bot-buddy.ai/functions/v1",
130
+ token: null,
131
+ profile: null,
132
+ help: false,
133
+ };
134
+ for (let i = 0; i < argv.length; i++) {
135
+ const a = argv[i];
136
+ const optionValue = () => {
137
+ const value = argv[i + 1];
138
+ if (value === undefined || value.startsWith("--")) {
139
+ opts.missing ??= a;
140
+ return null;
141
+ }
142
+ i += 1;
143
+ return value;
144
+ };
145
+ if (a === "--help" || a === "-h") opts.help = true;
146
+ else if (a === "--any") opts.mode = "any";
147
+ else if (a === "--heartbeat") opts.heartbeat = true;
148
+ else if (a === "--timeout") opts.timeout = Number(optionValue());
149
+ else if (a === "--since") opts.since = optionValue();
150
+ else if (a === "--receipt-max-bytes") opts.receiptMaxBytes = Number(optionValue());
151
+ else if (a === "--url") opts.url = optionValue();
152
+ else if (a === "--token") opts.token = optionValue();
153
+ else if (a === "--profile") opts.profile = optionValue();
154
+ else if (a.startsWith("--")) opts.unknown = a;
155
+ else opts.conditions.push(a);
156
+ }
157
+ return opts;
158
+ }
159
+
160
+ // Register a wait_session on the relay (BOT-989 M2). Returns
161
+ // {waitSessionId, cursorStart} on success. Profiled waits fail closed unless the
162
+ // relay authenticates and attests their machine principal.
163
+ async function registerWait(opts, conditions, deadlineIso) {
164
+ const res = await fetch(`${opts.url.replace(/\/$/, "")}/event-stream`, {
165
+ method: "POST",
166
+ headers: {
167
+ Authorization: `Bearer ${opts.token}`,
168
+ "x-agent-api-key": opts.token || "",
169
+ "Content-Type": "application/json",
170
+ },
171
+ body: JSON.stringify({
172
+ action: "register",
173
+ profile: opts.agentProfile.name,
174
+ expected_tenant: opts.agentProfile.tenant,
175
+ client_version: VERSION,
176
+ wait_protocol_version: WAIT_PROTOCOL_VERSION,
177
+ conditions,
178
+ deadline: deadlineIso,
179
+ mode: opts.mode,
180
+ }),
181
+ });
182
+ if (res.status === 429) {
183
+ const body = await res.json().catch(() => ({}));
184
+ const err = new Error(body.detail || "wait_session_cap_exceeded");
185
+ err.cap = true;
186
+ err.errorCode = body.error || "wait_session_cap_exceeded";
187
+ throw err;
188
+ }
189
+ if (res.status === 401) {
190
+ const body = await res.json().catch(() => ({}));
191
+ const err = new Error(body.detail || body.message || body.error || "unauthorized agent key");
192
+ err.auth = true;
193
+ err.errorCode = body.error || "unauthorized";
194
+ throw err;
195
+ }
196
+ if (res.status === 400) {
197
+ // Only a CALLER-ACTIONABLE condition rejection is a hard stop — arming an untracked
198
+ // live-only wait can never recover it, and it would skip the server's initial
199
+ // evaluation (silently missing an already-satisfied condition). These are the
200
+ // conditions the server refuses by design: BOT-1066 `no_signal_source` (uncovered
201
+ // CI repo) and BOT-1247 `unblocked_cross_tenant` / `unblocked_requires_tenant`
202
+ // (an unblocked:<TICKET> a single tenant-scoped wait session can't serve).
203
+ // A TRANSIENT backend 400 (e.g. `register_failed` from the RPC) is NOT
204
+ // caller-actionable — fall through to the existing live-only fallback so a valid
205
+ // wait still arms (Codex round-7 P2).
206
+ const body = await res.json().catch(() => ({}));
207
+ const INVALID_CONDITION_CODES = new Set([
208
+ "no_signal_source",
209
+ "unblocked_cross_tenant",
210
+ "unblocked_requires_tenant",
211
+ "wait_tenant_unresolved",
212
+ "wait_cross_tenant",
213
+ "wait_tenant_mismatch",
214
+ // BOT-1259: a `linear` condition set spanning multiple workspaces can't bind one
215
+ // tenant; the server rejects it (register one wait per workspace) — a hard stop, not
216
+ // a transient error, so don't fall back to an untracked live-only wait.
217
+ "linear_cross_tenant",
218
+ // BOT-1259: a multi-workspace caller arming a workspace-scoped condition the server
219
+ // can't bind to one workspace (ci / pr-review / bare tenant-primary event). A hard
220
+ // stop — arming it untracked would only ever park to timeout.
221
+ "tenant_ambiguous",
222
+ ]);
223
+ if (INVALID_CONDITION_CODES.has(body.error)) {
224
+ const err = new Error(body.detail || body.error);
225
+ err.invalidCondition = true;
226
+ err.errorCode = body.error;
227
+ throw err;
228
+ }
229
+ }
230
+ if (res.status === 403) {
231
+ const body = await res.json().catch(() => ({}));
232
+ if (new Set(["wait_tenant_unresolved", "wait_cross_tenant", "wait_tenant_mismatch"]).has(body.error)) {
233
+ const err = new Error(body.detail || body.error);
234
+ err.invalidCondition = true;
235
+ err.errorCode = body.error;
236
+ throw err;
237
+ }
238
+ const err = new Error(body.detail || body.message || body.error || "forbidden");
239
+ err.auth = true;
240
+ err.errorCode = body.error || "forbidden";
241
+ throw err;
242
+ }
243
+ if (res.status === 426) {
244
+ const body = await res.json().catch(() => ({}));
245
+ const err = new Error(body.detail || "this wait client is no longer supported");
246
+ err.clientOutdated = true;
247
+ err.errorCode = body.error || "client_outdated";
248
+ err.upgradeCommand = body.upgrade_command || CLI_UPGRADE_COMMAND;
249
+ err.minimumProtocol = body.minimum_wait_protocol ?? null;
250
+ throw err;
251
+ }
252
+ if (!res.ok) {
253
+ const body = await res.json().catch(() => ({}));
254
+ const err = new Error(body.detail || body.message || body.error || `register responded ${res.status}`);
255
+ if (body.error === "initial_eval_failed") {
256
+ err.initialEval = true;
257
+ err.errorCode = body.error;
258
+ err.detail = body.detail || "initial evaluation failed";
259
+ }
260
+ throw err;
261
+ }
262
+ const body = await res.json();
263
+ if (body.profile !== opts.agentProfile.name || typeof body.agent_id !== "string" || !body.agent_id) {
264
+ const err = new Error("relay did not attest the tenant-bound agent profile");
265
+ err.auth = true;
266
+ err.errorCode = "profile_not_enforced";
267
+ throw err;
268
+ }
269
+ if (body.session_tenant !== opts.agentProfile.tenant) {
270
+ const err = new Error(
271
+ `profile expects ${opts.agentProfile.tenant} but registration resolved ${body.session_tenant ?? "no tenant"}`,
272
+ );
273
+ err.auth = true;
274
+ err.errorCode = "profile_tenant_mismatch";
275
+ throw err;
276
+ }
277
+ return {
278
+ waitSessionId: body.wait_session_id ?? null,
279
+ cursorStart: body.cursor_start ?? null,
280
+ // BOT-1184: the server canonicalizes lock-condition hosts on register and
281
+ // echoes them here so the client matches the canonical subject_key.
282
+ conditions: Array.isArray(body.conditions) ? body.conditions : null,
283
+ // BOT-1259: the wait session's server-resolved tenant. The client threads this
284
+ // into the central tenant guard so a tenanted signal only wakes a session resolved
285
+ // to that same workspace. `session_tenant: null` is a genuinely tenantless session
286
+ // (matches only tenantless signals); an ABSENT field (a pre-BOT-1259 relay) collapses
287
+ // to null too — fail closed on tenanted signals until the wait re-arms against the
288
+ // new relay (the deploy-skew case, acceptable because waits are short-lived).
289
+ sessionTenant: body.session_tenant ?? null,
290
+ agentId: body.agent_id ?? null,
291
+ };
292
+ }
293
+
294
+ // Record the terminal outcome. Best-effort AND bounded: the receipt has already
295
+ // been written to stdout before this runs, so a slow/failing finalize must never
296
+ // delay the agent's wake or change the exit path. A short abort timeout caps how
297
+ // long we linger, and a non-2xx response is reported (not silently dropped) so a
298
+ // session left `active` shows up in diagnostics rather than as a phantom wait.
299
+ async function finalizeWait(opts, waitSessionId, receipt, timeoutMs = 5000) {
300
+ const ac = new AbortController();
301
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
302
+ try {
303
+ const res = await fetch(`${opts.url.replace(/\/$/, "")}/event-stream`, {
304
+ method: "POST",
305
+ headers: {
306
+ Authorization: `Bearer ${opts.token}`,
307
+ "x-agent-api-key": opts.token || "",
308
+ "Content-Type": "application/json",
309
+ },
310
+ body: JSON.stringify({
311
+ action: "finalize",
312
+ wait_session_id: waitSessionId,
313
+ status: receipt.outcome,
314
+ receipt,
315
+ reconnects: receipt.reconnects ?? 0,
316
+ degraded: receipt.degraded ?? [],
317
+ }),
318
+ signal: ac.signal,
319
+ });
320
+ if (!res.ok) {
321
+ process.stderr.write(`bb-wait: finalize (non-fatal) returned ${res.status}\n`);
322
+ }
323
+ } catch (err) {
324
+ process.stderr.write(`bb-wait: finalize (non-fatal) failed: ${err && err.message || err}\n`);
325
+ } finally {
326
+ clearTimeout(timer);
327
+ }
328
+ }
329
+
330
+ // A real SSE connection to the relay's spine tail. Returns an async iterable of
331
+ // parsed frames. Server emits `id: <seq>` per signal and replays `seq > since`
332
+ // on connect, so a reconnect never loses an event.
333
+ function makeConnect(opts) {
334
+ return async function connect(since) {
335
+ const sinceParam = normalizeSince(since);
336
+ const url = new URL(`${opts.url.replace(/\/$/, "")}/event-stream`);
337
+ url.searchParams.set("tables", "agent_signal_events");
338
+ if (sinceParam.kind === "ok") url.searchParams.set("since", sinceParam.value);
339
+ // Bind the SSE to the registered wait_session so the relay bumps its
340
+ // last_seen_at on connect + keepalive (abandoned detection).
341
+ if (opts.waitSessionId) url.searchParams.set("wait_session", opts.waitSessionId);
342
+ // --heartbeat rides the live SSE connection itself: event-stream bumps
343
+ // agents.last_heartbeat on connect and every keepalive when heartbeat=1, so
344
+ // the agent (and its locks) aren't reaped during a long wait.
345
+ if (opts.heartbeat) url.searchParams.set("heartbeat", "1");
346
+
347
+ const res = await fetch(url, {
348
+ headers: {
349
+ Authorization: `Bearer ${opts.token}`,
350
+ "x-agent-api-key": opts.token || "",
351
+ Accept: "text/event-stream",
352
+ // BOT-741: never let a proxy gzip-buffer an SSE stream.
353
+ "Accept-Encoding": "identity",
354
+ },
355
+ });
356
+ if (res.status === 401) return errorStream("unauthorized");
357
+ if (res.status === 403) return errorStream("forbidden");
358
+ if (!res.ok || !res.body) throw new Error(`relay responded ${res.status}`);
359
+
360
+ return sseFrameStream(res.body);
361
+ };
362
+ }
363
+
364
+ // BOT-1147 AC-3: the real Linear-feed freshness probe. Asks the relay (which
365
+ // runs the global bb_wait_linear_feed_lagging RPC) whether the Linear ingest
366
+ // feed is stale. Any failure -> false (non-fatal: the wait keeps its last known
367
+ // degraded state rather than flapping on a probe blip).
368
+ const FEED_LAG_PROBE_TIMEOUT_MS = 10_000;
369
+
370
+ function makeFeedLagProbe(opts) {
371
+ // `issues` = the armed `linear` conditions' target ticket keys. The relay
372
+ // resolves them to their owning workspace(s) (within the caller's tenant scope)
373
+ // so freshness reflects the targeted tenant, not the caller's membership union
374
+ // (Codex round-22 P2). Omitted/empty -> the relay falls back to the caller's
375
+ // tenant scope, then to the global feed.
376
+ return async function feedLagProbe(issues = []) {
377
+ // Return null (INDETERMINATE) on any failure — a non-2xx, invalid JSON, a
378
+ // transport error, or the relay's own {lagging:false, probe_error:true} RPC-error
379
+ // response — so foldFeedLag leaves the last known lag state untouched. Returning
380
+ // false here would clear a known linear_feed_lagging marker on a transient outage
381
+ // and make a stale feed look healthy right before a terminal receipt (Codex P2).
382
+ //
383
+ // BOUNDED (Codex P2): the wait loop awaits this before connecting AND on every
384
+ // terminal finalize, OUTSIDE the deadline race, so an unbounded probe that accepts
385
+ // the connection but stalls before responding would hang even an already-matched
386
+ // signal or an expired --timeout forever. A 10s abort caps it; expiry is treated as
387
+ // the same indeterminate result (leave the last known lag state).
388
+ const ac = new AbortController();
389
+ const timer = setTimeout(() => ac.abort(), FEED_LAG_PROBE_TIMEOUT_MS);
390
+ try {
391
+ const res = await fetch(`${opts.url.replace(/\/$/, "")}/event-stream`, {
392
+ method: "POST",
393
+ headers: {
394
+ Authorization: `Bearer ${opts.token}`,
395
+ "x-agent-api-key": opts.token || "",
396
+ "Content-Type": "application/json",
397
+ },
398
+ body: JSON.stringify({
399
+ action: "linear_feed_status",
400
+ ...(Array.isArray(issues) && issues.length > 0 ? { issues } : {}),
401
+ }),
402
+ signal: ac.signal,
403
+ });
404
+ if (!res.ok) return null;
405
+ const body = await res.json();
406
+ if (body?.probe_error) return null;
407
+ return body?.lagging === true;
408
+ } catch {
409
+ return null; // transport error OR AbortError on the 10s timeout → indeterminate
410
+ } finally {
411
+ clearTimeout(timer);
412
+ }
413
+ };
414
+ }
415
+
416
+ async function* sseFrameStream(body) {
417
+ const decoder = new TextDecoder();
418
+ let buf = "";
419
+ for await (const chunk of body) {
420
+ buf += decoder.decode(chunk, { stream: true });
421
+ const { frames, rest } = parseSseFrames(buf);
422
+ buf = rest;
423
+ for (const f of frames) yield f;
424
+ }
425
+ }
426
+
427
+ function errorStream(error) {
428
+ return {
429
+ async *[Symbol.asyncIterator]() {
430
+ yield { id: "", event: "error", data: JSON.stringify({ error }) };
431
+ },
432
+ };
433
+ }
434
+
435
+ // timer-only waits need no network; return a stream that never yields so the
436
+ // deadline is the only terminal.
437
+ function offlineStream() {
438
+ return { async *[Symbol.asyncIterator]() { await new Promise(() => {}); } };
439
+ }
440
+
441
+ function withClientIdentity(receipt) {
442
+ return {
443
+ ...receipt,
444
+ client: { package: "@botbuddy/cli", version: VERSION, wait_protocol: WAIT_PROTOCOL_VERSION },
445
+ };
446
+ }
447
+
448
+ function emit(receipt, { versioned = false, maxBytes = null } = {}) {
449
+ const enriched = versioned ? receipt : withClientIdentity(receipt);
450
+ const bounded = Number.isSafeInteger(maxBytes) && maxBytes >= MIN_RECEIPT_MAX_BYTES
451
+ ? truncateReceipt(enriched, maxBytes)
452
+ : enriched;
453
+ process.stdout.write(JSON.stringify(bounded) + "\n");
454
+ }
455
+
456
+ function profileRecovery(profile) {
457
+ return `npx --yes @botbuddy/cli@latest profile setup ${profile.name}`;
458
+ }
459
+
460
+ function typedProfileError(errorCode) {
461
+ switch (String(errorCode ?? "").toLowerCase()) {
462
+ case "agent_key_required": return "profile_agent_required";
463
+ case "agent_tenant_required": return "profile_credential_unbound";
464
+ case "profile_tenant_mismatch": return "profile_credential_wrong_tenant";
465
+ case "unauthorized": return "profile_credential_revoked";
466
+ default: return errorCode;
467
+ }
468
+ }
469
+
470
+ function profileErrorReceipt(profile, error) {
471
+ return withPrincipalReceipt({
472
+ schema_version: 1,
473
+ outcome: "error",
474
+ error,
475
+ recovery: profileRecovery(profile),
476
+ }, profile, { sessionTenant: profile.tenant, agentId: null });
477
+ }
478
+
479
+ export async function runWait(argv) {
480
+ const opts = parseArgv(argv);
481
+ const emitReceipt = (receipt, options = {}) => emit(receipt, {
482
+ ...options,
483
+ maxBytes: opts.receiptMaxBytes,
484
+ });
485
+ if (opts.help) {
486
+ process.stdout.write(HELP);
487
+ process.exit(0);
488
+ }
489
+ if (opts.unknown) {
490
+ process.stderr.write(`bb-wait: unknown option ${opts.unknown}\n`);
491
+ emitReceipt({ schema_version: 1, outcome: "error", error: "invalid_arguments", option: opts.unknown });
492
+ process.exit(EXIT.INVALID);
493
+ }
494
+ if (opts.missing) {
495
+ process.stderr.write(`bb-wait: option ${opts.missing} requires a value\n`);
496
+ emitReceipt({ schema_version: 1, outcome: "error", error: "invalid_arguments", option: opts.missing });
497
+ process.exit(EXIT.INVALID);
498
+ }
499
+
500
+ const { conditions, errors } = parseConditions(opts.conditions);
501
+ if (errors.length > 0) {
502
+ for (const e of errors) process.stderr.write(`bb-wait: invalid condition '${e.spec}': ${e.message}\n`);
503
+ emitReceipt({ schema_version: 1, outcome: "error", error: "invalid_conditions", errors });
504
+ process.exit(EXIT.INVALID);
505
+ }
506
+
507
+ if (!Number.isFinite(opts.timeout) || opts.timeout <= 0) {
508
+ process.stderr.write("bb-wait: --timeout must be a positive number of seconds\n");
509
+ emitReceipt({ schema_version: 1, outcome: "error", error: "invalid_timeout" });
510
+ process.exit(EXIT.INVALID);
511
+ }
512
+ if (!Number.isSafeInteger(opts.receiptMaxBytes) || opts.receiptMaxBytes < MIN_RECEIPT_MAX_BYTES) {
513
+ process.stderr.write(`bb-wait: --receipt-max-bytes must be an integer of at least ${MIN_RECEIPT_MAX_BYTES}\n`);
514
+ emitReceipt({ schema_version: 1, outcome: "error", error: "invalid_receipt_max_bytes", minimum: MIN_RECEIPT_MAX_BYTES });
515
+ process.exit(EXIT.INVALID);
516
+ }
517
+ const timeoutSec = Math.min(opts.timeout, 28800); // 8h hard cap (session-budget gate)
518
+ const deadlineMs = Date.now() + timeoutSec * 1000;
519
+
520
+ const needsRelay = conditions.some((c) => c.type !== "timer");
521
+ if (needsRelay) {
522
+ try {
523
+ opts.agentProfile = await resolveAgentProfile({
524
+ explicitProfile: opts.profile,
525
+ explicitToken: opts.token,
526
+ });
527
+ } catch (err) {
528
+ process.stderr.write(`bb-wait: ${err.message}\n`);
529
+ emitReceipt({ schema_version: 1, outcome: "error", error: err.code || "invalid_profile" });
530
+ process.exit(EXIT.INVALID);
531
+ }
532
+ opts.token = opts.agentProfile.token;
533
+ if (!opts.token) {
534
+ process.stderr.write(
535
+ `botbuddy wait: profile '${opts.agentProfile.name}' has no tenant-bound agent credential; run '${profileRecovery(opts.agentProfile)}'\n`,
536
+ );
537
+ emitReceipt(profileErrorReceipt(opts.agentProfile, "profile_required"));
538
+ process.exit(EXIT.AUTH);
539
+ }
540
+ }
541
+
542
+ // A claim=true wait is meaningless without a tracked registration: the server
543
+ // only grants a lock to an agent with an ACTIVE matching wait_session, and the
544
+ // client only accepts a grant whose wait_session_id equals THIS registration's.
545
+ // So a null id could never match a grant — it could only time out — while the
546
+ // server may have registered and granted. Claim waits therefore fail CLOSED on
547
+ // any registration failure rather than using the untracked-wait fallback below.
548
+ const hasClaim = conditions.some((c) => c.type === "lock" && c.params.claim);
549
+
550
+ // Register the wait_session before connecting (BOT-989 M2). This records the
551
+ // wait for telemetry/abandoned-detection and returns the spine high-water mark
552
+ // to arm `since` from, closing the register-after-emit race. A cap rejection is
553
+ // a hard stop; for a non-claim wait, any other registration failure degrades to
554
+ // an untracked (but still functional) wait. Timer-only waits never touch the relay.
555
+ let waitSessionId = null;
556
+ let effectiveSince = opts.since;
557
+ // BOT-1259: the server-resolved tenant for this wait session, threaded into the
558
+ // central tenant guard (runWaitLoop). Undefined until a successful register echo;
559
+ // an untracked live-only fallback (no register) leaves the guard OFF, matching its
560
+ // pre-BOT-1259 behaviour for a wait the server never scoped.
561
+ let sessionTenant;
562
+ let registeredAgentId = null;
563
+ if (needsRelay) {
564
+ try {
565
+ const reg = await registerWait(opts, conditions, new Date(deadlineMs).toISOString());
566
+ waitSessionId = reg.waitSessionId;
567
+ opts.waitSessionId = waitSessionId;
568
+ sessionTenant = reg.sessionTenant;
569
+ registeredAgentId = reg.agentId;
570
+ // BOT-1184: adopt the server's canonical host for each lock condition so the
571
+ // local matcher builds the same subject_key the availability/claim-grant
572
+ // signals carry (armed under an alias like 'jono-mac', the signal uses the
573
+ // canonical 'jonos-mbp'). Patch host by condition id — preserving every
574
+ // other client-side param type — rather than wholesale-replacing the parsed
575
+ // conditions. Only 'lock' conditions are canonicalized server-side.
576
+ if (Array.isArray(reg.conditions)) {
577
+ const canonHost = new Map();
578
+ // BOT-1247: the server stamps the resolved workspace (round-9 P1) and the stable
579
+ // Linear issue UUID (round-10 P2) onto each `unblocked` condition; carry both onto
580
+ // the local condition so the matcher can require the signal's tenant to match (no
581
+ // waking on another workspace's identically-keyed unblock) and key off the
582
+ // immutable issue id (surviving an external_id rename mid-wait).
583
+ const unblockedScope = new Map();
584
+ // BOT-1260: the server stamps the stable Linear issue UUID onto each `linear`
585
+ // condition whose key is mirrored in `tickets`; carry it onto the local condition so
586
+ // the matcher keys off the immutable id (surviving an external_id rename mid-wait,
587
+ // and refusing a recycled key). No `tenant` is stamped for `linear` — its cross-tenant
588
+ // isolation rides on the central guard's sessionTenant (BOT-1259), not a per-type param.
589
+ const linearScope = new Map();
590
+ for (const rc of reg.conditions) {
591
+ if (rc && rc.type === "lock" && rc.params && typeof rc.params.host === "string") {
592
+ canonHost.set(rc.id, rc.params.host);
593
+ }
594
+ if (rc && rc.type === "unblocked" && rc.params && typeof rc.params.tenant === "string") {
595
+ unblockedScope.set(rc.id, { tenant: rc.params.tenant, linear_issue_id: rc.params.linear_issue_id ?? null });
596
+ }
597
+ if (rc && rc.type === "linear" && rc.params && typeof rc.params.linear_issue_id === "string") {
598
+ linearScope.set(rc.id, rc.params.linear_issue_id);
599
+ }
600
+ }
601
+ for (const c of conditions) {
602
+ if (c.type === "lock" && canonHost.has(c.id)) c.params.host = canonHost.get(c.id);
603
+ if (c.type === "unblocked" && unblockedScope.has(c.id)) {
604
+ const s = unblockedScope.get(c.id);
605
+ c.params.tenant = s.tenant;
606
+ if (s.linear_issue_id) c.params.linear_issue_id = s.linear_issue_id;
607
+ }
608
+ if (c.type === "linear" && linearScope.has(c.id)) c.params.linear_issue_id = linearScope.get(c.id);
609
+ }
610
+ }
611
+ // Arm from the registration high-water mark unless the caller pinned an
612
+ // explicit --since (resume). cursor_start replays the register→connect gap.
613
+ if (opts.since == null && reg.cursorStart != null) effectiveSince = String(reg.cursorStart);
614
+ if (hasClaim && waitSessionId == null) {
615
+ process.stderr.write("bb-wait: claim=true registration returned no wait_session_id; refusing to arm an unmatchable claim wait\n");
616
+ emitReceipt({ schema_version: 1, outcome: "error", error: "claim_registration_failed" });
617
+ process.exit(EXIT.INTERNAL);
618
+ }
619
+ } catch (err) {
620
+ if (err && err.clientOutdated) {
621
+ process.stderr.write(`botbuddy wait: client protocol is no longer supported — upgrade with: ${err.upgradeCommand}\n`);
622
+ emitReceipt({
623
+ schema_version: 1,
624
+ outcome: "error",
625
+ error: err.errorCode,
626
+ detail: String(err.message),
627
+ minimum_wait_protocol: err.minimumProtocol,
628
+ upgrade_command: err.upgradeCommand,
629
+ });
630
+ process.exit(EXIT.INVALID);
631
+ }
632
+ if (err && err.auth) {
633
+ const error = typedProfileError(err.errorCode);
634
+ process.stderr.write(`botbuddy wait: profile authentication failed (${error}); run '${profileRecovery(opts.agentProfile)}'\n`);
635
+ emitReceipt(profileErrorReceipt(opts.agentProfile, error));
636
+ process.exit(EXIT.AUTH);
637
+ }
638
+ if (err && err.cap) {
639
+ process.stderr.write(`bb-wait: too many active waits for this agent — ${err.message}\n`);
640
+ emitReceipt({
641
+ schema_version: 1,
642
+ outcome: "error",
643
+ error: err.errorCode || "wait_session_cap_exceeded",
644
+ detail: String(err.message),
645
+ });
646
+ process.exit(EXIT.INVALID);
647
+ }
648
+ if (err && err.invalidCondition) {
649
+ // A rejected condition set is a configuration error, not a wait — fail closed
650
+ // rather than arming an untracked wait that skips the server's initial
651
+ // evaluation (BOT-1066 no_signal_source, BOT-1247 unblocked_cross_tenant, …).
652
+ process.stderr.write(`bb-wait: invalid condition (${err.errorCode}) — ${err.message}\n`);
653
+ emitReceipt({ schema_version: 1, outcome: "error", error: err.errorCode, detail: String(err.message) });
654
+ process.exit(EXIT.INVALID);
655
+ }
656
+ if (err && err.initialEval) {
657
+ process.stderr.write(`bb-wait: initial wait evaluation failed (${err.errorCode}) — ${err.detail}\n`);
658
+ emitReceipt({ schema_version: 1, outcome: "error", error: err.errorCode, detail: String(err.detail) });
659
+ process.exit(EXIT.INTERNAL);
660
+ }
661
+ if (hasClaim) {
662
+ process.stderr.write(`bb-wait: claim=true wait-session registration failed (failing closed, not arming untracked): ${err && err.message || err}\n`);
663
+ emitReceipt({ schema_version: 1, outcome: "error", error: "claim_registration_failed" });
664
+ process.exit(EXIT.INTERNAL);
665
+ }
666
+ // BOT-1338: every non-timer wait is now a tenant-bound machine profile. If
667
+ // registration did not succeed, neither the server nor this client has verified
668
+ // that profile's tenant, so there is no safe untracked fallback for ANY condition.
669
+ process.stderr.write(`bb-wait: profiled wait-session registration failed (failing closed, not arming untracked): ${err && err.message || err}\n`);
670
+ emitReceipt(withPrincipalReceipt(
671
+ { schema_version: 1, outcome: "error", error: "register_failed", detail: String(err && err.message || err) },
672
+ opts.agentProfile,
673
+ { sessionTenant: opts.agentProfile.tenant, agentId: null },
674
+ ));
675
+ process.exit(EXIT.INTERNAL);
676
+ }
677
+ }
678
+
679
+ const connect = needsRelay ? makeConnect(opts) : async () => offlineStream();
680
+ // BOT-1147 AC-3: wire the feed-freshness probe only when a linear condition is
681
+ // armed against the relay (runWaitLoop no-ops the probe for other wait types).
682
+ const feedLagProbe = needsRelay && conditions.some((c) => c.type === "linear")
683
+ ? makeFeedLagProbe(opts)
684
+ : null;
685
+
686
+ try {
687
+ const { receipt, exitCode } = await runWaitLoop({
688
+ waitSessionId,
689
+ conditions,
690
+ deadlineMs,
691
+ since: effectiveSince,
692
+ connect,
693
+ feedLagProbe,
694
+ receiptMaxBytes: opts.receiptMaxBytes,
695
+ // BOT-1259: fail-closed tenant scope for the central guard, plus a debug sink
696
+ // (opt-in via BB_WAIT_DEBUG) that surfaces each fail-closed drop without turning
697
+ // it into an error.
698
+ sessionTenant,
699
+ debug: process.env.BB_WAIT_DEBUG ? (msg) => process.stderr.write(`${msg}\n`) : null,
700
+ });
701
+ // Emit the wake receipt FIRST — it is the terminal result the harness reads.
702
+ // Finalization is best-effort telemetry and must never gate or delay it.
703
+ const terminalReceipt = truncateReceipt(
704
+ withClientIdentity(withPrincipalReceipt(receipt, opts.agentProfile, {
705
+ sessionTenant,
706
+ agentId: registeredAgentId,
707
+ })),
708
+ opts.receiptMaxBytes,
709
+ );
710
+ emitReceipt(terminalReceipt, { versioned: true });
711
+ // BOT-1228: a `superseded` outcome means an external actor already terminalized
712
+ // this wait_session server-side (reconcile_waits_to_canonical → abandoned, with
713
+ // ended_at/updated_at stamped). Skip the client finalize: the row is already
714
+ // terminal (finalize_wait_session only transitions from active, so it'd be a
715
+ // no-op) and `superseded` isn't a wait_session_status enum value anyway.
716
+ if (waitSessionId && receipt.outcome !== "superseded") await finalizeWait(opts, waitSessionId, terminalReceipt);
717
+ process.exit(exitCode);
718
+ } catch (err) {
719
+ process.stderr.write(`bb-wait: ${err && err.stack || err}\n`);
720
+ emitReceipt({ schema_version: 1, outcome: "error", error: "internal" });
721
+ process.exit(EXIT.INTERNAL);
722
+ }
723
+ }
724
+
725
+ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
726
+ runWait(process.argv.slice(2));
727
+ }