@botbuddy/cli 1.61.4 → 1.61.6

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.
@@ -1,7 +1,7 @@
1
1
  {
2
- "release_version": "1.61.4",
3
- "source_commit": "b6e939df36d913c84893c35c4f35a74cb8fb9675",
4
- "source_sha256": "4b2c9b81b1e46f417dd3f1b58c19f6e03c4fbb6fa697a6b2ac2db0586551614b",
2
+ "release_version": "1.61.6",
3
+ "source_commit": "1e8d5f51d3f9be35c44e793551defaeaf8ae2d23",
4
+ "source_sha256": "0156e8ade4a62f2a6c4cb29d57fa0f5c23600ba264093198900c22e4c6fc0d68",
5
5
  "target": "darwin-arm64",
6
- "sha256": "24fe172b82abb3ac4e59574516d614f1a35ec19b1fb2789793e4d877f8b0e526"
6
+ "sha256": "8bc316711530d7900ff4186b4eb114cf48ce255344d5d3436962c8d6bee46810"
7
7
  }
@@ -1,7 +1,7 @@
1
1
  {
2
- "release_version": "1.61.4",
3
- "source_commit": "b6e939df36d913c84893c35c4f35a74cb8fb9675",
4
- "source_sha256": "4b2c9b81b1e46f417dd3f1b58c19f6e03c4fbb6fa697a6b2ac2db0586551614b",
2
+ "release_version": "1.61.6",
3
+ "source_commit": "1e8d5f51d3f9be35c44e793551defaeaf8ae2d23",
4
+ "source_sha256": "0156e8ade4a62f2a6c4cb29d57fa0f5c23600ba264093198900c22e4c6fc0d68",
5
5
  "target": "linux-x64",
6
- "sha256": "227d9145bedfb733e88e1456e2e8a9c989a12201b2c16427b2a7be2c27661bdf"
6
+ "sha256": "3bf5b7a6396da374f12abada7e42685ad8f783366c5c4de885da21d9749b257b"
7
7
  }
@@ -1,21 +1,21 @@
1
1
  {
2
2
  "schema_version": 1,
3
- "release_version": "1.61.4",
4
- "source_commit": "b6e939df36d913c84893c35c4f35a74cb8fb9675",
5
- "source_sha256": "4b2c9b81b1e46f417dd3f1b58c19f6e03c4fbb6fa697a6b2ac2db0586551614b",
3
+ "release_version": "1.61.6",
4
+ "source_commit": "1e8d5f51d3f9be35c44e793551defaeaf8ae2d23",
5
+ "source_sha256": "0156e8ade4a62f2a6c4cb29d57fa0f5c23600ba264093198900c22e4c6fc0d68",
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": "24fe172b82abb3ac4e59574516d614f1a35ec19b1fb2789793e4d877f8b0e526",
11
+ "sha256": "8bc316711530d7900ff4186b4eb114cf48ce255344d5d3436962c8d6bee46810",
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": "227d9145bedfb733e88e1456e2e8a9c989a12201b2c16427b2a7be2c27661bdf",
18
+ "sha256": "3bf5b7a6396da374f12abada7e42685ad8f783366c5c4de885da21d9749b257b",
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.61.4",
3
+ "version": "1.61.6",
4
4
  "description": "BotBuddy \u2014 Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
@@ -4,15 +4,18 @@
4
4
  // no local approval polling loop: host policy wakes only through the pushed
5
5
  // BOT-2324 raw wait condition, then this flow re-registers server-side.
6
6
  import { createHash } from "node:crypto";
7
+ import { dirname } from "node:path";
7
8
 
8
9
  import { DAEMON_PRODUCTION_MCP_SERVER, enrollDaemonDeviceProfileFromBootstrapIdentity } from "./enrollment.mjs";
9
10
  import { installRemoteService, installedBinaryPath, stageRemoteDaemonRelease } from "./installer.mjs";
10
11
  import {
11
12
  createRemoteBootstrapIdentity,
12
13
  deliverDaemonProfileFromBootstrapIdentityOverSSH,
14
+ installDaemonRuntimeConfigOverSSH,
13
15
  inspectRemoteBootstrapIdentity,
14
16
  verifyRemoteServiceIdentity,
15
17
  } from "./profile-delivery.mjs";
18
+ import { buildRuntimeConfig } from "./runtime-config.mjs";
16
19
  import { validateDaemonRelease } from "./release.mjs";
17
20
  import { renderLinuxUnit, renderMacDaemon } from "./service.mjs";
18
21
 
@@ -45,7 +48,7 @@ function exactObject(value, keys) {
45
48
  function checkedSpec(spec) {
46
49
  const keys = [
47
50
  "mode", "serverUrl", "sshHost", "serviceUser", "daemonBinary", "profilePath", "requestingHost",
48
- "runtimeEngineId", "platform", "arch", "releaseRoot", "home", "config", "approvalTimeoutSeconds",
51
+ "runtimeEngineId", "platform", "arch", "releaseRoot", "home", "config", "hostPolicy", "approvalTimeoutSeconds",
49
52
  ];
50
53
  const optional = ["caller", "callerHarness", "callerTicket"];
51
54
  if (!spec || typeof spec !== "object" || Array.isArray(spec) ||
@@ -59,7 +62,11 @@ function checkedSpec(spec) {
59
62
  typeof spec.requestingHost !== "string" || !spec.requestingHost || Buffer.byteLength(spec.requestingHost, "utf8") > 80 ||
60
63
  typeof spec.runtimeEngineId !== "string" || !ENGINE_ID_RE.test(spec.runtimeEngineId) ||
61
64
  !["darwin", "linux"].includes(spec.platform) || typeof spec.arch !== "string" || !spec.arch ||
62
- !validPath(spec.releaseRoot) || !validPath(spec.home) || !validPath(spec.config) ||
65
+ !validPath(spec.releaseRoot) || !validPath(spec.home) || !validPath(spec.config) || !spec.config.endsWith("/daemon.json") ||
66
+ dirname(spec.config) !== dirname(spec.profilePath) ||
67
+ !spec.hostPolicy || typeof spec.hostPolicy !== "object" || Array.isArray(spec.hostPolicy) ||
68
+ Object.keys(spec.hostPolicy).length !== 4 ||
69
+ !["machineId", "dockerEndpoint", "supabaseBinary", "dockerCLI"].every((key) => typeof spec.hostPolicy[key] === "string" && spec.hostPolicy[key]) ||
63
70
  !Number.isSafeInteger(spec.approvalTimeoutSeconds) || spec.approvalTimeoutSeconds < 1 || spec.approvalTimeoutSeconds > 3_600 ||
64
71
  optional.some((key) => spec[key] !== undefined && (typeof spec[key] !== "string" || !spec[key]))
65
72
  ) fail("daemon_bootstrap_spec_invalid");
@@ -215,12 +222,14 @@ export async function bootstrapFreshDaemon(input, {
215
222
  enroll = enrollDaemonDeviceProfileFromBootstrapIdentity,
216
223
  deliver = deliverDaemonProfileFromBootstrapIdentityOverSSH,
217
224
  installRemote = installRemoteService,
225
+ installRuntimeConfig = installDaemonRuntimeConfigOverSSH,
218
226
  } = {}) {
219
227
  const spec = checkedSpec(input);
220
228
  if (typeof registerHost !== "function" || typeof verifyRemoteIdentity !== "function" ||
221
229
  typeof createIdentity !== "function" || typeof inspectIdentity !== "function" ||
222
230
  typeof validateRelease !== "function" || typeof stageRemote !== "function" || typeof enroll !== "function" ||
223
231
  typeof deliver !== "function" || typeof installRemote !== "function" ||
232
+ typeof installRuntimeConfig !== "function" ||
224
233
  (awaitApproval !== null && typeof awaitApproval !== "function") ||
225
234
  (onApprovalRequired !== null && typeof onApprovalRequired !== "function") ||
226
235
  (resumeStore !== null && (typeof resumeStore !== "object" || typeof resumeStore.save !== "function" ||
@@ -355,6 +364,42 @@ export async function bootstrapFreshDaemon(input, {
355
364
  }
356
365
  }
357
366
 
367
+ let runtimeConfig;
368
+ try {
369
+ runtimeConfig = buildRuntimeConfig({
370
+ host_key: publicIdentity.hostKey,
371
+ state_dir: dirname(spec.config),
372
+ docker_endpoint: spec.hostPolicy.dockerEndpoint,
373
+ expected_engine_id: spec.runtimeEngineId,
374
+ machine_id: spec.hostPolicy.machineId,
375
+ supabase_binary: spec.hostPolicy.supabaseBinary,
376
+ docker_cli: spec.hostPolicy.dockerCLI,
377
+ host_credential_profile: spec.profilePath,
378
+ device_consent: true,
379
+ runtime_version: `go-daemon/${release.releaseVersion}`,
380
+ runtime_git_sha: release.sourceCommit,
381
+ runtime_protocol_version: 2,
382
+ });
383
+ } catch {
384
+ fail("daemon_bootstrap_runtime_config_invalid");
385
+ }
386
+ try {
387
+ const installed = await installRuntimeConfig({
388
+ config: JSON.stringify(runtimeConfig),
389
+ configPath: spec.config,
390
+ sshHost: spec.sshHost,
391
+ serviceUser: spec.serviceUser,
392
+ daemonBinary: trustedDaemonBinary,
393
+ profilePath: spec.profilePath,
394
+ expectedHostKey: publicIdentity.hostKey,
395
+ directInstall: remote.directInstall,
396
+ });
397
+ if (!installed || installed.installed !== true) fail("daemon_bootstrap_runtime_config_failed");
398
+ } catch (error) {
399
+ if (error instanceof DaemonBootstrapError) throw error;
400
+ fail("daemon_bootstrap_runtime_config_failed");
401
+ }
402
+
358
403
  const serviceSpec = {
359
404
  user: spec.serviceUser,
360
405
  home: spec.home,
@@ -29,6 +29,7 @@ import {
29
29
  createRemoteBootstrapIdentity,
30
30
  deliverDaemonProfileFromBootstrapIdentityOverSSH,
31
31
  deliverDaemonProfileFromHelperIdentityOverSSH,
32
+ installDaemonRuntimeConfigOverSSH,
32
33
  inspectRemoteBootstrapIdentity,
33
34
  verifyRemoteHelperIdentity as verifyRemoteHelperIdentityOnTarget,
34
35
  verifyRemoteServiceIdentity,
@@ -285,6 +286,7 @@ export async function cmdDaemon(args, {
285
286
  verifyRuntime = checkRuntimeConfig,
286
287
  enroll = enrollDaemonDeviceProfileFromHelperIdentity,
287
288
  deliver = deliverDaemonProfileFromHelperIdentityOverSSH,
289
+ installRuntimeConfig = installDaemonRuntimeConfigOverSSH,
288
290
  verifyRemoteIdentity = verifyRemoteServiceIdentity,
289
291
  verifyRemoteHelperIdentity = verifyRemoteHelperIdentityOnTarget,
290
292
  bootstrap = bootstrapFreshDaemon,
@@ -332,6 +334,7 @@ export async function cmdDaemon(args, {
332
334
  },
333
335
  enroll: enrollBootstrap,
334
336
  deliver: deliverBootstrap,
337
+ installRuntimeConfig,
335
338
  installRemote,
336
339
  onApprovalRequired: ({ approval_url: approvalUrl, host_key: hostKey }) => {
337
340
  notice(`bb daemon bootstrap: owner approval required for ${hostKey}: ${approvalUrl}`);
@@ -334,3 +334,32 @@ export async function deliverDaemonProfileFromBootstrapIdentityOverSSH({
334
334
  installFlag: "--install-profile-from-bootstrap-identity",
335
335
  });
336
336
  }
337
+
338
+ // Runtime policy is public host metadata, never a credential. It is still
339
+ // installed through the daemon's descriptor-pinned private root so a caller
340
+ // cannot redirect it with a symlink or broaden a prior host's policy.
341
+ export async function installDaemonRuntimeConfigOverSSH({
342
+ config,
343
+ configPath,
344
+ sshHost,
345
+ serviceUser,
346
+ daemonBinary,
347
+ profilePath,
348
+ expectedHostKey,
349
+ sshBin = "ssh",
350
+ spawnImpl = realSpawn,
351
+ env = process.env,
352
+ timeoutMs = DEFAULT_TIMEOUT_MS,
353
+ directInstall = false,
354
+ } = {}) {
355
+ safeInputs({ sshHost, serviceUser, daemonBinary, profilePath, expectedHostKey, sshBin, spawnImpl, env, timeoutMs, directInstall });
356
+ if (typeof config !== "string" || config.length === 0 || Buffer.byteLength(config, "utf8") > 64 * 1024 ||
357
+ typeof configPath !== "string" || !SAFE_ABSOLUTE_PATH.test(configPath) || !configPath.endsWith("/daemon.json")) throw invalid();
358
+ const command = remoteDaemonCommand({
359
+ serviceUser, daemonBinary, directInstall,
360
+ args: ["--install-runtime-config", "--config", configPath, "--profile", profilePath, "--expected-host-key", expectedHostKey],
361
+ });
362
+ const stdout = await fixedSsh({ sshHost, sshBin, spawnImpl, env, timeoutMs, command, stdin: Buffer.from(config, "utf8") });
363
+ if (!stdout.equals(Buffer.from('{"installed":true}\n'))) throw failed();
364
+ return { installed: true };
365
+ }
@@ -0,0 +1,142 @@
1
+ // BOT-1688 — observation of an existing wait, independent of condition matching.
2
+ // No registration, subscription, finalization or acknowledgement belongs here.
3
+ import { constants } from "node:fs";
4
+ import { open, realpath } from "node:fs/promises";
5
+ import { isDeepStrictEqual } from "node:util";
6
+ import { harnessSessionIdFromEnv, readAgentState } from "./agent-state.mjs";
7
+ import { readAgentSessionTokenEnv } from "./agent-key.mjs";
8
+ import { WaitCheckpointError } from "./wait-checkpoint.mjs";
9
+
10
+ const MAX_ARTIFACT_BYTES = 256 * 1024;
11
+ const MAX_RECEIPT_BYTES = 64 * 1024;
12
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
13
+ const TERMINAL_OUTCOMES = new Set(["matched", "timeout", "error", "superseded", "lease_lost", "abandoned", "cancelled"]);
14
+
15
+ // Persisted != delivered. Await the stream callback before exiting, including
16
+ // when a bounded receipt is larger than the pipe's immediately writable space.
17
+ export function writeWaitReceipt(receipt, output = process.stdout) {
18
+ const line = `${JSON.stringify(receipt)}\n`;
19
+ return new Promise((resolve, reject) => {
20
+ const failed = (error) => reject(error);
21
+ output.once("error", failed);
22
+ try {
23
+ output.write(line, (error) => {
24
+ if (error) reject(error);
25
+ else { output.off("error", failed); resolve(); }
26
+ });
27
+ } catch (error) { output.off("error", failed); reject(error); }
28
+ });
29
+ }
30
+
31
+ export function waitObserverId(env = process.env) {
32
+ const id = harnessSessionIdFromEnv(env);
33
+ if (id) return id;
34
+ if (env.CLAUDE_CODE_CHILD_SESSION === "1") return null;
35
+ const thread = env.CODEX_THREAD_ID;
36
+ return typeof thread === "string" && UUID.test(thread) ? thread.toLowerCase() : null;
37
+ }
38
+
39
+ export function waitCompletionState(checkpoint) {
40
+ if (checkpoint.status === "acknowledged") return "consumed";
41
+ if (checkpoint.status === "cancelled") return "cancelled";
42
+ if (checkpoint.status === "terminal") return checkpoint.terminal_receipt ? "receipt_available" : "observation_gap";
43
+ return ["preparing", "armed", "interrupted"].includes(checkpoint.status) ? "waiting" : "invalid";
44
+ }
45
+
46
+ export async function assertWaitObserver(checkpoint, { cwd = process.cwd(), env = process.env, requirePrincipal = false } = {}) {
47
+ let same = false;
48
+ try { same = await realpath(checkpoint.worktree) === await realpath(cwd); } catch { /* fail closed */ }
49
+ if (!same) throw new WaitCheckpointError("wait_resume_identity_mismatch", "saved wait belongs to a different worktree");
50
+ const observer = waitObserverId(env);
51
+ if (checkpoint.observer_harness_id && checkpoint.observer_harness_id !== observer) {
52
+ throw new WaitCheckpointError("wait_resume_identity_mismatch", "saved wait belongs to a different harness task");
53
+ }
54
+ // Local files are OS-user protected. For an authenticated result, also require
55
+ // the same cached agent/tenant; never mint an identity to read a local receipt.
56
+ if (requirePrincipal && (checkpoint.agent_id || checkpoint.tenant_id)) {
57
+ const state = await readAgentState(cwd);
58
+ const token = readAgentSessionTokenEnv(env);
59
+ if (!state || (token && token !== state.agent_session_token) ||
60
+ state.agent_id !== checkpoint.agent_id || state.tenant !== checkpoint.tenant_id ||
61
+ (observer && state.harness_session_id && observer !== state.harness_session_id)) {
62
+ throw new WaitCheckpointError("wait_resume_identity_mismatch", "result requires this worktree's original agent and tenant; use named resume with fresh credentials for recovery");
63
+ }
64
+ }
65
+ }
66
+
67
+ async function receiptFromArtifact(path) {
68
+ let handle;
69
+ try {
70
+ handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
71
+ const stat = await handle.stat();
72
+ if (!stat.isFile() || stat.size > MAX_ARTIFACT_BYTES) throw new Error("unbounded artifact");
73
+ const buffer = Buffer.alloc(MAX_ARTIFACT_BYTES + 1);
74
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
75
+ if (bytesRead > MAX_ARTIFACT_BYTES) throw new Error("unbounded artifact");
76
+ const receipts = [];
77
+ for (const line of buffer.subarray(0, bytesRead).toString("utf8").split(/\r?\n/)) {
78
+ if (!line.trimStart().startsWith("{")) continue;
79
+ let value;
80
+ try { value = JSON.parse(line); } catch { continue; }
81
+ if (value?.schema_version === 1 && typeof value.outcome === "string") receipts.push(value);
82
+ }
83
+ if (receipts.length !== 1) throw new Error("expected exactly one receipt");
84
+ return receipts[0];
85
+ } catch {
86
+ // Never echo arbitrary artifact contents (which may include credentials).
87
+ throw new WaitCheckpointError("wait_observation_gap", "completion artifact must contain exactly one bounded terminal receipt");
88
+ } finally { await handle?.close(); }
89
+ }
90
+
91
+ export async function readWaitResult(store, id, { cwd, env, fromFile = null, exitCode = null } = {}) {
92
+ // An explicitly named artifact identifies a cloud wait, not "the newest"
93
+ // checkpoint. Read it once, only after the caller awaited the original exit.
94
+ const observed = fromFile == null ? null : await receiptFromArtifact(fromFile);
95
+ if (id == null) {
96
+ const cloudId = observed?.wait_session_id;
97
+ if (typeof cloudId !== "string" || !cloudId) {
98
+ throw new WaitCheckpointError("wait_observation_gap", "artifact has no cloud wait identity; select its exact local_wait_id explicitly");
99
+ }
100
+ const matches = [];
101
+ let offset = 0;
102
+ do {
103
+ const page = await store.list({ includeInactive: true, offset });
104
+ for (const item of page.items) if (item.cloud_wait_session_id === cloudId) matches.push(item.local_wait_id);
105
+ offset = page.next_offset;
106
+ } while (offset != null);
107
+ if (matches.length !== 1) {
108
+ throw new WaitCheckpointError("wait_observation_gap", "artifact must identify exactly one checkpoint in this worktree");
109
+ }
110
+ [id] = matches;
111
+ }
112
+ let checkpoint = await store.read(id);
113
+ await assertWaitObserver(checkpoint, { cwd, env, requirePrincipal: true });
114
+ const state = waitCompletionState(checkpoint);
115
+ if (state !== "receipt_available") {
116
+ throw new WaitCheckpointError(state === "consumed" ? "wait_result_consumed" : "wait_result_unavailable", `saved wait ${id}: ${state}; no new wait was started`);
117
+ }
118
+ // Frame-time persistence may precede the original owner's stdout and exit.
119
+ // Its process-instance fence, not a file or PID sighting, decides ownership.
120
+ const fence = await store.acquireFence(id);
121
+ try {
122
+ checkpoint = await store.read(id);
123
+ await assertWaitObserver(checkpoint, { cwd, env, requirePrincipal: true });
124
+ if (waitCompletionState(checkpoint) !== "receipt_available") {
125
+ throw new WaitCheckpointError("wait_result_unavailable", "saved wait changed before observation");
126
+ }
127
+ const receipt = checkpoint.terminal_receipt;
128
+ if (!receipt || Array.isArray(receipt) || receipt.schema_version !== 1 || !TERMINAL_OUTCOMES.has(receipt.outcome) ||
129
+ Buffer.byteLength(JSON.stringify(receipt)) > MAX_RECEIPT_BYTES ||
130
+ !Number.isInteger(checkpoint.exit_code) || checkpoint.exit_code < 0 || checkpoint.exit_code > 255 ||
131
+ (receipt.wait_session_id !== undefined && receipt.wait_session_id !== (checkpoint.cloud_wait_session_id ?? null)) ||
132
+ (receipt.principal && checkpoint.agent_id && receipt.principal.agent_id !== checkpoint.agent_id) ||
133
+ (receipt.principal && checkpoint.tenant_id && receipt.principal.tenant_id !== checkpoint.tenant_id)) {
134
+ throw new WaitCheckpointError("wait_observation_gap", "saved terminal receipt does not match its wait identity");
135
+ }
136
+ if (fromFile != null && (!Number.isInteger(exitCode) || exitCode !== checkpoint.exit_code ||
137
+ !isDeepStrictEqual(observed, receipt))) {
138
+ throw new WaitCheckpointError("wait_observation_gap", "original command exit or redirected receipt differs from the selected durable result");
139
+ }
140
+ return { receipt, exitCode: checkpoint.exit_code };
141
+ } finally { await fence.release(); }
142
+ }
package/src/wait.mjs CHANGED
@@ -4,8 +4,8 @@
4
4
  // Park on a set of conditions (a lock coming free, a chat reply, a build
5
5
  // finishing, a plain deadline) and wake EXACTLY ONCE with one machine-readable
6
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.
7
+ // process blocks on a pushed SSE stream. The owning harness retains its pending
8
+ // result; process exit is not proof that the agent consumed the receipt.
9
9
  //
10
10
  // Dependency-free (Node >= 20 stdlib only). All wait semantics live in
11
11
  // ./wait-core.mjs; this file is just argv + a real `connect()` + I/O.
@@ -26,6 +26,7 @@ import { SETUP_BLOCK } from "./setup-block.mjs";
26
26
  import { createWaitCheckpointStore, WaitCheckpointError } from "./wait-checkpoint.mjs";
27
27
  import { realpath } from "node:fs/promises";
28
28
  import { deepLinkNotice, safeWebUrl } from "./deep-link.mjs";
29
+ import { assertWaitObserver, readWaitResult, waitCompletionState, waitObserverId, writeWaitReceipt } from "./wait-result.mjs";
29
30
 
30
31
  // A protocol is deliberately distinct from package semver: compatible pinned
31
32
  // clients keep working until the server raises this minimum, while a stale
@@ -181,6 +182,9 @@ OPTIONS
181
182
  --session-id <uuid> advanced attribution override; normal worktrees derive it automatically.
182
183
  --help show this help
183
184
  resume <local_wait_id> recover one explicitly named saved wait
185
+ result [<local_wait_id>] read its terminal receipt without rearming or acknowledging
186
+ [--from-file <path> --exit-code <n>]
187
+ validate redirected output AFTER the original command exits
184
188
  cancel <local_wait_id> explicitly stop one saved wait
185
189
  acknowledge <local_wait_id> mark one saved terminal receipt read
186
190
 
@@ -933,7 +937,7 @@ function emit(receipt, { versioned = false, maxBytes = null } = {}) {
933
937
  const bounded = Number.isSafeInteger(maxBytes) && maxBytes >= MIN_RECEIPT_MAX_BYTES
934
938
  ? truncateReceipt(enriched, maxBytes)
935
939
  : enriched;
936
- process.stdout.write(JSON.stringify(bounded) + "\n");
940
+ return writeWaitReceipt(bounded);
937
941
  }
938
942
 
939
943
  // BOT-1590: canonical one-line recoveries for every setup/auth/parameter error.
@@ -1081,12 +1085,15 @@ function inventoryEntry(checkpoint) {
1081
1085
  const pendingAcknowledgement = checkpoint.status === "acknowledged" && checkpoint.cloud_ack_pending === true;
1082
1086
  return {
1083
1087
  local_wait_id: id,
1088
+ wait_session_id: checkpoint.cloud_wait_session_id ?? null,
1084
1089
  status: checkpoint.status,
1090
+ completion_state: waitCompletionState(checkpoint),
1085
1091
  conditions: checkpoint.conditions?.map((c) => ({ condition_id: c.id, type: c.type })) ?? [],
1086
1092
  ticket: checkpoint.ticket ?? null,
1087
1093
  created_at: checkpoint.created_at ?? null,
1088
1094
  deadline_at: checkpoint.deadline_at ?? null,
1089
1095
  receipt_available: terminal && checkpoint.terminal_receipt != null,
1096
+ result_argv: terminal ? ["wait", "result", id] : null,
1090
1097
  recovery_argv: pendingCancellation
1091
1098
  ? ["wait", "cancel", id]
1092
1099
  : pendingAcknowledgement ? ["wait", "acknowledge", id] : ["wait", "resume", id],
@@ -1105,13 +1112,13 @@ async function inspectSavedWaits(argv = []) {
1105
1112
  // Never let a non-interactive agent accidentally acquire an owner or consume
1106
1113
  // a result. The inventory deliberately omits receipt bodies and shell text.
1107
1114
  if (!process.stdin.isTTY || !process.stdout.isTTY) {
1108
- process.stdout.write(`${JSON.stringify({
1115
+ await writeWaitReceipt({
1109
1116
  schema_version: 1,
1110
1117
  outcome: "saved_waits",
1111
1118
  waits: inventory.items.map(inventoryEntry),
1112
1119
  has_more: inventory.has_more,
1113
1120
  ...(inventory.next_offset != null ? { next_offset: inventory.next_offset, next_page_argv: ["wait", "--offset", String(inventory.next_offset)] } : {}),
1114
- })}\n`);
1121
+ });
1115
1122
  process.exit(EXIT.MATCHED);
1116
1123
  }
1117
1124
  if (inventory.items.length === 0) {
@@ -1336,6 +1343,8 @@ async function resumeSavedWait(id) {
1336
1343
  let checkpoint;
1337
1344
  try { checkpoint = await store.read(id); }
1338
1345
  catch (error) { return emitSavedWaitError(error); }
1346
+ try { await assertWaitObserver(checkpoint); }
1347
+ catch (error) { return emitSavedWaitError(error); }
1339
1348
  if (!(await sameWorktree(checkpoint.worktree, process.cwd()))) {
1340
1349
  return emitSavedWaitError(new WaitCheckpointError("wait_resume_identity_mismatch", "saved wait belongs to a different worktree"));
1341
1350
  }
@@ -1355,7 +1364,7 @@ async function resumeSavedWait(id) {
1355
1364
  await fence?.release();
1356
1365
  }
1357
1366
  writeLeaseLostNotice(checkpoint.terminal_receipt);
1358
- process.stdout.write(`${JSON.stringify(checkpoint.terminal_receipt)}\n`);
1367
+ await writeWaitReceipt(checkpoint.terminal_receipt);
1359
1368
  process.exit(Number.isInteger(checkpoint.exit_code) ? checkpoint.exit_code : EXIT.INTERNAL);
1360
1369
  }
1361
1370
  if (["cancelled", "acknowledged"].includes(checkpoint.status)) {
@@ -1382,18 +1391,18 @@ async function sameWorktree(first, second) {
1382
1391
  catch { return false; }
1383
1392
  }
1384
1393
 
1385
- function emitSavedWaitError(error) {
1394
+ async function emitSavedWaitError(error) {
1386
1395
  const code = error?.code ?? "wait_checkpoint_invalid";
1387
1396
  const exitCode = code === "wait_checkpoint_write_failed" ? EXIT.INTERNAL : EXIT.INVALID;
1388
1397
  process.stderr.write(`bb-wait: ${error?.message ?? code}\n`);
1389
- process.stdout.write(`${JSON.stringify({
1398
+ await writeWaitReceipt({
1390
1399
  schema_version: 1,
1391
1400
  outcome: "error",
1392
1401
  error: code,
1393
1402
  ...(error?.localWaitId ? { local_wait_id: error.localWaitId } : {}),
1394
1403
  ...(Array.isArray(error?.recoveryArgv) ? { recovery_argv: error.recoveryArgv } : {}),
1395
1404
  ...(typeof error?.recovery === "string" ? { recovery: error.recovery } : {}),
1396
- })}\n`);
1405
+ });
1397
1406
  process.exit(exitCode);
1398
1407
  }
1399
1408
 
@@ -1413,6 +1422,7 @@ async function cancelSavedWait(id) {
1413
1422
  try {
1414
1423
  const store = checkpointStore();
1415
1424
  let current = await store.read(id);
1425
+ await assertWaitObserver(current);
1416
1426
  if (current.status === "terminal" || current.status === "acknowledged") {
1417
1427
  const conflict = new WaitCheckpointError(
1418
1428
  "wait_not_cancellable",
@@ -1518,10 +1528,10 @@ async function cancelSavedWait(id) {
1518
1528
  } finally {
1519
1529
  await fence.release();
1520
1530
  }
1521
- process.stdout.write(`${JSON.stringify({
1531
+ await writeWaitReceipt({
1522
1532
  schema_version: 1, outcome: "cancelled", local_wait_id: id,
1523
1533
  ...(current.cloud_cancel_pending ? { cloud_cancel_pending: true } : {}),
1524
- })}\n`);
1534
+ });
1525
1535
  process.exit(EXIT.MATCHED);
1526
1536
  } catch (error) { return emitSavedWaitError(error); }
1527
1537
  }
@@ -1535,6 +1545,7 @@ async function acknowledgeSavedWait(id) {
1535
1545
  // until the same process-instance fence used by resume proves ownership.
1536
1546
  fence = await store.acquireFence(id);
1537
1547
  let checkpoint = await store.read(id);
1548
+ await assertWaitObserver(checkpoint, { requirePrincipal: Boolean(checkpoint.observer_harness_id) });
1538
1549
  if (checkpoint.status === "terminal") checkpoint = await flushPendingFinalization(store, checkpoint);
1539
1550
  checkpoint = await store.acknowledge(id, {
1540
1551
  cloudAckPending: Boolean(checkpoint.cloud_wait_session_id),
@@ -1542,10 +1553,10 @@ async function acknowledgeSavedWait(id) {
1542
1553
  if (checkpoint.cloud_wait_session_id) {
1543
1554
  checkpoint = await flushPendingAcknowledgement(store, checkpoint);
1544
1555
  }
1545
- process.stdout.write(`${JSON.stringify({
1556
+ await writeWaitReceipt({
1546
1557
  schema_version: 1, outcome: "acknowledged", local_wait_id: id,
1547
1558
  ...(checkpoint.cloud_ack_pending ? { cloud_ack_pending: true } : {}),
1548
- })}\n`);
1559
+ });
1549
1560
  await fence.release();
1550
1561
  fence = null;
1551
1562
  process.exit(EXIT.MATCHED);
@@ -1557,6 +1568,22 @@ async function acknowledgeSavedWait(id) {
1557
1568
 
1558
1569
  export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
1559
1570
  if (argv.length === 0 || argv[0] === "--offset") return inspectSavedWaits(argv);
1571
+ if (argv[0] === "result") {
1572
+ try {
1573
+ const args = argv.slice(1);
1574
+ const id = args[0] && !args[0].startsWith("--") ? args.shift() : null;
1575
+ const rest = args;
1576
+ if ((!id && !rest.length) || (rest.length !== 0 && (rest.length !== 4 || rest[0] !== "--from-file" ||
1577
+ !rest[1] || rest[2] !== "--exit-code" || !/^\d{1,3}$/.test(rest[3]) || Number(rest[3]) > 255))) {
1578
+ throw new WaitCheckpointError("wait_observation_gap", "usage: bb wait result [<local_wait_id>] [--from-file <path> --exit-code <n>]; await the original command first");
1579
+ }
1580
+ const result = await readWaitResult(checkpointStore(), id, {
1581
+ fromFile: rest[1] ?? null, exitCode: rest.length ? Number(rest[3]) : null,
1582
+ });
1583
+ await writeWaitReceipt(result.receipt);
1584
+ process.exit(result.exitCode);
1585
+ } catch (error) { return emitSavedWaitError(error); }
1586
+ }
1560
1587
  // BOT-1701 (AC-6): one stderr nudge for a tier-3 legacy alias (flag, env, or a
1561
1588
  // bb_agent_ token value), emitted BEFORE any subcommand dispatch so the
1562
1589
  // credentialed recovery actions (resume/cancel/acknowledge) warn too (Codex R7).
@@ -1592,7 +1619,7 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
1592
1619
  `botbuddy wait: ${opts.retiredFlag} is retired — run bb wait from a configured worktree and it restores its session automatically. `
1593
1620
  + "If setup is needed, run bb setup, then retry the same wait.\n",
1594
1621
  );
1595
- emitReceipt({
1622
+ await emitReceipt({
1596
1623
  schema_version: 1,
1597
1624
  outcome: "error",
1598
1625
  error: "unknown_option",
@@ -1603,18 +1630,18 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
1603
1630
  }
1604
1631
  if (opts.unknown) {
1605
1632
  process.stderr.write(`bb-wait: unknown option ${opts.unknown}\n`);
1606
- emitReceipt({ schema_version: 1, outcome: "error", error: "invalid_arguments", option: opts.unknown });
1633
+ await emitReceipt({ schema_version: 1, outcome: "error", error: "invalid_arguments", option: opts.unknown });
1607
1634
  process.exit(EXIT.INVALID);
1608
1635
  }
1609
1636
  if (opts.missing) {
1610
1637
  process.stderr.write(`bb-wait: option ${opts.missing} requires a value\n`);
1611
- emitReceipt({ schema_version: 1, outcome: "error", error: "invalid_arguments", option: opts.missing });
1638
+ await emitReceipt({ schema_version: 1, outcome: "error", error: "invalid_arguments", option: opts.missing });
1612
1639
  process.exit(EXIT.INVALID);
1613
1640
  }
1614
1641
  if ((opts.resumeLocalWait != null || opts.deadlineAt != null) &&
1615
1642
  (recoveryLocalWaitId == null || opts.resumeLocalWait !== recoveryLocalWaitId)) {
1616
1643
  process.stderr.write("bb-wait: saved wait recovery options are internal; use bb wait resume <local_wait_id>\n");
1617
- emitReceipt({
1644
+ await emitReceipt({
1618
1645
  schema_version: 1,
1619
1646
  outcome: "error",
1620
1647
  error: "wait_not_resumable",
@@ -1624,7 +1651,7 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
1624
1651
  }
1625
1652
  if (opts.deadlineAt != null && !opts.resumeLocalWait) {
1626
1653
  process.stderr.write("bb-wait: --deadline-at is reserved for saved wait recovery\n");
1627
- emitReceipt({ schema_version: 1, outcome: "error", error: "invalid_arguments", option: "--deadline-at" });
1654
+ await emitReceipt({ schema_version: 1, outcome: "error", error: "invalid_arguments", option: "--deadline-at" });
1628
1655
  process.exit(EXIT.INVALID);
1629
1656
  }
1630
1657
 
@@ -1635,7 +1662,7 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
1635
1662
  const since = normalizeSince(opts.since);
1636
1663
  if (since.kind !== "ok") {
1637
1664
  process.stderr.write("bb-wait: --since must be a non-negative signal sequence <= 9007199254740991\n");
1638
- emitReceipt({ schema_version: 1, outcome: "error", error: "invalid_since", cursor: opts.since });
1665
+ await emitReceipt({ schema_version: 1, outcome: "error", error: "invalid_since", cursor: opts.since });
1639
1666
  process.exit(EXIT.INVALID);
1640
1667
  }
1641
1668
  opts.since = since.value;
@@ -1645,7 +1672,7 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
1645
1672
  if (errors.length > 0) {
1646
1673
  for (const e of errors) process.stderr.write(`bb-wait: invalid condition '${e.spec}': ${e.message}\n`);
1647
1674
  process.stderr.write(`bb-wait: ${RECOVERY.conditions}\n`);
1648
- emitReceipt({ schema_version: 1, outcome: "error", error: "invalid_conditions", errors, recovery: RECOVERY.conditions });
1675
+ await emitReceipt({ schema_version: 1, outcome: "error", error: "invalid_conditions", errors, recovery: RECOVERY.conditions });
1649
1676
  // BOT-1593: condition_types is [] on a grammar failure (the parsed set is
1650
1677
  // partial/empty and is caller data regardless). BOT-2467: instead, each failed
1651
1678
  // spec is described by its attempted TYPE and a parse_error category — never
@@ -1656,13 +1683,13 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
1656
1683
 
1657
1684
  if (!Number.isFinite(opts.timeout) || opts.timeout <= 0) {
1658
1685
  process.stderr.write("bb-wait: --timeout must be a positive number of seconds\n");
1659
- emitReceipt({ schema_version: 1, outcome: "error", error: "invalid_timeout" });
1686
+ await emitReceipt({ schema_version: 1, outcome: "error", error: "invalid_timeout" });
1660
1687
  process.exit(EXIT.INVALID);
1661
1688
  }
1662
1689
  if (!Number.isSafeInteger(opts.receiptMaxBytes) || opts.receiptMaxBytes < MIN_RECEIPT_MAX_BYTES ||
1663
1690
  opts.receiptMaxBytes > MAX_RECEIPT_MAX_BYTES) {
1664
1691
  process.stderr.write(`bb-wait: --receipt-max-bytes must be an integer from ${MIN_RECEIPT_MAX_BYTES} through ${MAX_RECEIPT_MAX_BYTES}\n`);
1665
- emitReceipt({
1692
+ await emitReceipt({
1666
1693
  schema_version: 1, outcome: "error", error: "invalid_receipt_max_bytes",
1667
1694
  minimum: MIN_RECEIPT_MAX_BYTES, maximum: MAX_RECEIPT_MAX_BYTES,
1668
1695
  });
@@ -1674,7 +1701,7 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
1674
1701
  });
1675
1702
  if (shortRefusal) {
1676
1703
  process.stderr.write(`bb-wait: --timeout ${opts.timeout} is under the ${MIN_WAIT_TIMEOUT_SEC} s floor — ${shortRefusal.recovery}\n`);
1677
- emitReceipt({ schema_version: 1, outcome: "error", ...shortRefusal });
1704
+ await emitReceipt({ schema_version: 1, outcome: "error", ...shortRefusal });
1678
1705
  process.exit(EXIT.INVALID);
1679
1706
  }
1680
1707
  if (opts.timeout < MIN_WAIT_TIMEOUT_SEC && !conditions.every((condition) => condition.type === "timer") && !opts.resumeLocalWait) {
@@ -1683,7 +1710,7 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
1683
1710
  const timeoutSec = Math.min(opts.timeout, MAX_WAIT_TIMEOUT_SEC); // BOT-2400: 72 h hard cap
1684
1711
  const deadlineMs = opts.deadlineAt == null ? Date.now() + timeoutSec * 1000 : Date.parse(opts.deadlineAt);
1685
1712
  if (!Number.isFinite(deadlineMs)) {
1686
- emitReceipt({ schema_version: 1, outcome: "error", error: "wait_checkpoint_invalid" });
1713
+ await emitReceipt({ schema_version: 1, outcome: "error", error: "wait_checkpoint_invalid" });
1687
1714
  process.exit(EXIT.INVALID);
1688
1715
  }
1689
1716
 
@@ -1735,6 +1762,7 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
1735
1762
  receipt_max_bytes: opts.receiptMaxBytes,
1736
1763
  relay_url: relayUrl,
1737
1764
  worktree: process.cwd(),
1765
+ observer_harness_id: waitObserverId(),
1738
1766
  });
1739
1767
  }
1740
1768
  // A live process retains this fence until exit. The file records a PID and
@@ -1780,7 +1808,7 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
1780
1808
  ? "run from a configured project worktree, then run `bb setup` and retry"
1781
1809
  : "run `bb setup` and retry";
1782
1810
  process.stderr.write(`botbuddy wait: ${error?.message ?? error}; ${recovery}\n`);
1783
- emitReceipt({ schema_version: 1, outcome: "error", error: code, recovery });
1811
+ await emitReceipt({ schema_version: 1, outcome: "error", error: code, recovery });
1784
1812
  await reportSetupError(opts, { error: code, exitCode: EXIT.AUTH, conditions });
1785
1813
  process.exit(EXIT.AUTH);
1786
1814
  }
@@ -1794,13 +1822,13 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
1794
1822
  if (needsRelay && opts.sessionToken) {
1795
1823
  if (!SESSION_TOKEN_RE.test(opts.sessionToken)) {
1796
1824
  process.stderr.write(`botbuddy wait: BOTBUDDY_AGENT_SESSION_TOKEN must match bb_sess_<64 hex> (bb_agent_ is a legacy prefix); ${RECOVERY.sessionToken}\n`);
1797
- emitReceipt({ schema_version: 1, outcome: "error", error: "invalid_session_token", recovery: RECOVERY.sessionToken });
1825
+ await emitReceipt({ schema_version: 1, outcome: "error", error: "invalid_session_token", recovery: RECOVERY.sessionToken });
1798
1826
  await reportSetupError(opts, { error: "invalid_session_token", exitCode: EXIT.INVALID, conditions });
1799
1827
  process.exit(EXIT.INVALID);
1800
1828
  }
1801
1829
  if (opts.token && opts.token !== opts.sessionToken) {
1802
1830
  process.stderr.write(`botbuddy wait: --token conflicts with $BOTBUDDY_AGENT_SESSION_TOKEN; ${RECOVERY.sessionTokenConflict}\n`);
1803
- emitReceipt({ schema_version: 1, outcome: "error", error: "session_token_conflict", recovery: RECOVERY.sessionTokenConflict });
1831
+ await emitReceipt({ schema_version: 1, outcome: "error", error: "session_token_conflict", recovery: RECOVERY.sessionTokenConflict });
1804
1832
  await reportSetupError(opts, { error: "session_token_conflict", exitCode: EXIT.INVALID, conditions });
1805
1833
  process.exit(EXIT.INVALID);
1806
1834
  }
@@ -1813,7 +1841,7 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
1813
1841
  process.stderr.write(
1814
1842
  `botbuddy wait: a relay wait requires the per-session agent token — ${RECOVERY.sessionToken}, then: botbuddy wait '<condition>'\n`,
1815
1843
  );
1816
- emitReceipt({
1844
+ await emitReceipt({
1817
1845
  schema_version: 1,
1818
1846
  outcome: "error",
1819
1847
  error: "session_token_required",
@@ -1950,7 +1978,7 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
1950
1978
  cloud_cancel_error: null,
1951
1979
  } : {}),
1952
1980
  });
1953
- emitReceipt(recovered, { versioned: true });
1981
+ await emitReceipt(recovered, { versioned: true });
1954
1982
  process.exit(exitForRecoveredOutcome(recovered));
1955
1983
  }
1956
1984
  // BOT-2468: the wait's dashboard deeplink, once, at arm time (and again on
@@ -2038,13 +2066,13 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
2038
2066
  });
2039
2067
  if (hasClaim && waitSessionId == null) {
2040
2068
  process.stderr.write("bb-wait: claim=true registration returned no wait_session_id; refusing to arm an unmatchable claim wait\n");
2041
- emitReceipt({ schema_version: 1, outcome: "error", error: "claim_registration_failed" });
2069
+ await emitReceipt({ schema_version: 1, outcome: "error", error: "claim_registration_failed" });
2042
2070
  process.exit(EXIT.INTERNAL);
2043
2071
  }
2044
2072
  } catch (err) {
2045
2073
  if (err && err.clientOutdated) {
2046
2074
  process.stderr.write(`botbuddy wait: client protocol is no longer supported — upgrade with: ${err.upgradeCommand}\n`);
2047
- emitReceipt({
2075
+ await emitReceipt({
2048
2076
  schema_version: 1,
2049
2077
  outcome: "error",
2050
2078
  error: err.errorCode,
@@ -2061,7 +2089,7 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
2061
2089
  process.stderr.write(
2062
2090
  "botbuddy wait: this worktree needs a session; run bb setup, then retry the same wait\n",
2063
2091
  );
2064
- emitReceipt({
2092
+ await emitReceipt({
2065
2093
  schema_version: 1,
2066
2094
  outcome: "error",
2067
2095
  error: "client_key_cannot_wait",
@@ -2083,7 +2111,7 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
2083
2111
  process.stderr.write(
2084
2112
  `botbuddy wait: ${cause}; run bb setup, then retry the same wait\n`,
2085
2113
  );
2086
- emitReceipt(withPrincipalReceipt({
2114
+ await emitReceipt(withPrincipalReceipt({
2087
2115
  schema_version: 1,
2088
2116
  outcome: "error",
2089
2117
  error: err.errorCode,
@@ -2098,7 +2126,7 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
2098
2126
  process.stderr.write(
2099
2127
  `botbuddy wait: session rejected (${err.errorCode || "unauthorized"}) — run bb setup, then retry the same wait\n`,
2100
2128
  );
2101
- emitReceipt(withPrincipalReceipt({
2129
+ await emitReceipt(withPrincipalReceipt({
2102
2130
  schema_version: 1,
2103
2131
  outcome: "error",
2104
2132
  error: err.errorCode || "unauthorized",
@@ -2111,7 +2139,7 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
2111
2139
  }
2112
2140
  if (err && err.cap) {
2113
2141
  process.stderr.write(`bb-wait: too many active waits for this agent — ${err.message}\n`);
2114
- emitReceipt({
2142
+ await emitReceipt({
2115
2143
  schema_version: 1,
2116
2144
  outcome: "error",
2117
2145
  error: err.errorCode || "wait_session_cap_exceeded",
@@ -2133,7 +2161,7 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
2133
2161
  ? "the Linear issue belongs to a different Linear workspace than this tenant"
2134
2162
  : "retry once Linear is reachable";
2135
2163
  process.stderr.write(`bb-wait: unblocked:${err.ticket ?? "?"} cannot register — ${reason}; ${fix}\n`);
2136
- emitReceipt({
2164
+ await emitReceipt({
2137
2165
  schema_version: 1,
2138
2166
  outcome: "error",
2139
2167
  error: err.errorCode,
@@ -2155,7 +2183,7 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
2155
2183
  }
2156
2184
  }
2157
2185
  process.stderr.write(`bb-wait: invalid condition (${err.errorCode}) — ${err.message}\n`);
2158
- emitReceipt({
2186
+ await emitReceipt({
2159
2187
  schema_version: 1,
2160
2188
  outcome: "error",
2161
2189
  error: err.errorCode,
@@ -2166,20 +2194,20 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
2166
2194
  }
2167
2195
  if (err && err.initialEval) {
2168
2196
  process.stderr.write(`bb-wait: initial wait evaluation failed (${err.errorCode}) — ${err.detail}\n`);
2169
- emitReceipt({ schema_version: 1, outcome: "error", error: err.errorCode, detail: String(err.detail) });
2197
+ await emitReceipt({ schema_version: 1, outcome: "error", error: err.errorCode, detail: String(err.detail) });
2170
2198
  process.exit(EXIT.INTERNAL);
2171
2199
  }
2172
2200
  if (err instanceof WaitCheckpointError) return emitSavedWaitError(err);
2173
2201
  if (hasClaim) {
2174
2202
  process.stderr.write(`bb-wait: claim=true wait-session registration failed (failing closed, not arming untracked): ${err && err.message || err}\n`);
2175
- emitReceipt({ schema_version: 1, outcome: "error", error: "claim_registration_failed" });
2203
+ await emitReceipt({ schema_version: 1, outcome: "error", error: "claim_registration_failed" });
2176
2204
  process.exit(EXIT.INTERNAL);
2177
2205
  }
2178
2206
  // BOT-1338/1608: every non-timer wait is a relay-tracked, session-token wait.
2179
2207
  // If registration did not succeed, neither the server nor this client has
2180
2208
  // verified the session's tenant, so there is no safe untracked fallback.
2181
2209
  process.stderr.write(`bb-wait: wait-session registration failed (failing closed, not arming untracked): ${err && err.message || err}\n`);
2182
- emitReceipt(withPrincipalReceipt(
2210
+ await emitReceipt(withPrincipalReceipt(
2183
2211
  { schema_version: 1, outcome: "error", error: "register_failed", detail: String(err && err.message || err) },
2184
2212
  null,
2185
2213
  { sessionTenant: sessionTenant ?? null, agentId: null },
@@ -2267,7 +2295,7 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
2267
2295
  status: "interrupted",
2268
2296
  safe_cursor: terminalReceipt.next_cursor ?? checkpoint.safe_cursor ?? null,
2269
2297
  });
2270
- emitReceipt(terminalReceipt, { versioned: true });
2298
+ await emitReceipt(terminalReceipt, { versioned: true });
2271
2299
  process.exit(exitCode);
2272
2300
  }
2273
2301
  // Receipt first, stdout second, cloud finalization last. A killed harness can
@@ -2281,7 +2309,7 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
2281
2309
  cloud_finalize_pending: Boolean(waitSessionId && !SERVER_ENDED_OUTCOMES.has(terminalReceipt.outcome)),
2282
2310
  });
2283
2311
  }
2284
- emitReceipt(terminalReceipt, { versioned: true });
2312
+ await emitReceipt(terminalReceipt, { versioned: true });
2285
2313
  // BOT-1228: a `superseded` outcome means an external actor already terminalized
2286
2314
  // this wait_session server-side (reconcile_waits_to_canonical → abandoned, with
2287
2315
  // ended_at/updated_at stamped). Skip the client finalize: the row is already
@@ -2314,7 +2342,7 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
2314
2342
  });
2315
2343
  process.stderr.write(`bb-wait: ${err && err.stack || err}\n`);
2316
2344
  // Receipt first, stdout second, cloud finalization last — as on the normal path.
2317
- emitReceipt(settled.receipt, { versioned: true });
2345
+ await emitReceipt(settled.receipt, { versioned: true });
2318
2346
  await settled.finalizeCloud();
2319
2347
  process.exit(settled.exitCode);
2320
2348
  }