@botbuddy/cli 1.57.0 → 1.58.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botbuddy/cli",
3
- "version": "1.57.0",
3
+ "version": "1.58.0",
4
4
  "description": "BotBuddy \u2014 Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,24 @@
1
+ // BOT-2468 — the dashboard deeplink for what this command just created (a stack
2
+ // lease or a wait). The server answers `web_url` (built from its BOTBUDDY_WEB_URL
3
+ // origin, null when that is unset); the CLI prints it on ONE stderr line telling the
4
+ // agent to show it to the user, and carries it on the JSON receipt. A missing or
5
+ // unsafe value prints nothing: there is no client-side fallback host.
6
+
7
+ /** The server's `web_url` when it is a plain http(s) URL safe to print, else null.
8
+ * Whitespace and control characters are refused so a hostile value cannot inject
9
+ * terminal escapes or split the line. */
10
+ export function safeWebUrl(value) {
11
+ if (typeof value !== "string" || value.length === 0 || value.length > 2048) return null;
12
+ if (/[\s\u0000-\u001f\u007f]/.test(value)) return null;
13
+ let parsed;
14
+ try { parsed = new URL(value); } catch { return null; }
15
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return null;
16
+ return value;
17
+ }
18
+
19
+ /** `<prefix>: 🔗 <url> — show this link …`, or null when there is no safe URL. */
20
+ export function deepLinkNotice(prefix, webUrl) {
21
+ const url = safeWebUrl(webUrl);
22
+ if (!url) return null;
23
+ return `${prefix}: 🔗 ${url} — show this link to the user as a clickable Markdown link in your chat reply`;
24
+ }
package/src/stack.mjs CHANGED
@@ -36,6 +36,7 @@ import { basename, dirname, join, relative, isAbsolute } from "path";
36
36
  import { randomUUID } from "crypto";
37
37
  import { fileURLToPath } from "url";
38
38
  import { callToolJson, CALL_AUTH_ERROR_CODES } from "./api.mjs";
39
+ import { deepLinkNotice, safeWebUrl } from "./deep-link.mjs";
39
40
  import { SERVER_URL, getConfig } from "./config.mjs";
40
41
  import { resolveOwnerToken, resolveAgentKey } from "./cli-credentials.mjs";
41
42
  import { AGENT_KEY_RE, readAgentSessionTokenEnv } from "./agent-key.mjs";
@@ -2298,6 +2299,11 @@ export async function cmdUp(opts, {
2298
2299
  }
2299
2300
  let leaseId = d.lease_id;
2300
2301
  let state = d.state;
2302
+ // BOT-2468: the lease's /docker?lease= deeplink, printed once as soon as the id is known
2303
+ // and carried on every receipt that names this lease (null from a server without one).
2304
+ const webUrl = safeWebUrl(d.web_url);
2305
+ const linkNotice = deepLinkNotice("bb stack", webUrl);
2306
+ if (linkNotice) process.stderr.write(`${linkNotice}\n`);
2301
2307
  // BOT-2099: every lease wait in `stack up` (queue park, Helper provision, Helper takeover)
2302
2308
  // survives auth/availability blips (parkOnLease) and adopts the re-minted credential.
2303
2309
  const resilientWait = async (isDone, isFailed) => {
@@ -2330,7 +2336,10 @@ export async function cmdUp(opts, {
2330
2336
  error: `${r.error}; its server state is unknown to this command — inspect it with \`bb stack status ${leaseId}\` after \`bb doctor --fix\``,
2331
2337
  }), opts, EXIT.AUTH);
2332
2338
  const emitPlain = emitResult;
2333
- emitResult = (receipt, o, code) => emitPlain(upIncident && !receipt.platform_incident ? { ...receipt, platform_incident: upIncident } : receipt, o, code);
2339
+ emitResult = (receipt, o, code) => {
2340
+ const withIncident = upIncident && !receipt.platform_incident ? { ...receipt, platform_incident: upIncident } : receipt;
2341
+ return emitPlain(withIncident.lease_id === leaseId && !("web_url" in withIncident) ? { ...withIncident, web_url: webUrl } : withIncident, o, code);
2342
+ };
2334
2343
  // BOT-1711 (Codex R13): request_stack_lease returned an EXISTING active lease (reused),
2335
2344
  // rather than a freshly minted queued/provisioning one. A reused lease was not provisioned
2336
2345
  // in this invocation, so its recorded metadata cannot be trusted for a local-exec target.
@@ -3041,7 +3050,7 @@ export async function runStackLifecycle(opts, childArgv, adapters = {}) {
3041
3050
  // A receipt always says what happened to a port-block claim. Before allocation
3042
3051
  // no claim exists, which is a safe, explicit disposition rather than omission.
3043
3052
  // BOT-1803: whether this run re-adopted / re-provisioned an idle lease, and its stack digest.
3044
- const incidentBox = { incident: null, execution: null, idleLease: null, portBlock: { released: false, reason: "no port-block claim was created before stack allocation" } };
3053
+ const incidentBox = { incident: null, execution: null, idleLease: null, webUrl: null, portBlock: { released: false, reason: "no port-block claim was created before stack allocation" } };
3045
3054
  let result;
3046
3055
  try {
3047
3056
  result = await runStackLifecycleOnce(opts, childArgv, adapters, incidentBox);
@@ -3051,7 +3060,8 @@ export async function runStackLifecycle(opts, childArgv, adapters = {}) {
3051
3060
  const withIncident = incidentBox.incident && !result?.incident ? { ...result, incident: incidentBox.incident } : result;
3052
3061
  const withExecution = incidentBox.execution && !withIncident?.execution ? { ...withIncident, execution: incidentBox.execution } : withIncident;
3053
3062
  const withIdle = incidentBox.idleLease && !withExecution?.idleLease ? { ...withExecution, idleLease: incidentBox.idleLease } : withExecution;
3054
- return incidentBox.portBlock && !withIdle?.portBlock ? { ...withIdle, portBlock: incidentBox.portBlock } : withIdle;
3063
+ const withPortBlock = incidentBox.portBlock && !withIdle?.portBlock ? { ...withIdle, portBlock: incidentBox.portBlock } : withIdle;
3064
+ return incidentBox.webUrl && withPortBlock && !withPortBlock.webUrl ? { ...withPortBlock, webUrl: incidentBox.webUrl } : withPortBlock;
3055
3065
  }
3056
3066
 
3057
3067
  async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
@@ -3514,6 +3524,10 @@ async function runStackLifecycleOnce(opts, childArgv, adapters, incidentBox) {
3514
3524
  };
3515
3525
  }
3516
3526
  leaseId = request.data.lease_id;
3527
+ // BOT-2468: the lease's /docker?lease= deeplink, printed once as soon as the id is known.
3528
+ incidentBox.webUrl = safeWebUrl(request.data.web_url);
3529
+ const linkNotice = deepLinkNotice("bb stack", incidentBox.webUrl);
3530
+ if (linkNotice) stderrLine(linkNotice);
3517
3531
  // BOT-1803: `reused_idle` — the slot's IDLE lease was re-adopted: it is active with its
3518
3532
  // stored connection and nothing is provisioned, so the run goes straight on to the
3519
3533
  // post-activation flow. Any other reuse is somebody's LIVE batch, refused as before.
@@ -3918,6 +3932,8 @@ export function runReceiptFields(result) {
3918
3932
  // BOT-1797: carry the server's typed refusal code (e.g. stack_path_root_refused)
3919
3933
  // so automation can branch on it instead of matching the message text.
3920
3934
  command: "run", outcome: result.outcome, code: result.code ?? null, lease_id: result.leaseId ?? null,
3935
+ // BOT-2468: the lease's /docker?lease= deeplink (null when the server sent none).
3936
+ web_url: result.webUrl ?? null,
3921
3937
  // BOT-2024: a stale-capacity refusal carries its phase + armable wait on the receipt.
3922
3938
  ...capacityStaleReceiptFields(result),
3923
3939
  ...(result.wait_exit_code != null ? { wait_exit_code: result.wait_exit_code, wait_receipt: result.wait_receipt ?? null } : {}),
package/src/wait.mjs CHANGED
@@ -25,6 +25,7 @@ import { touchAgentStateExpiry } from "./agent-state.mjs";
25
25
  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
+ import { deepLinkNotice, safeWebUrl } from "./deep-link.mjs";
28
29
 
29
30
  // A protocol is deliberately distinct from package semver: compatible pinned
30
31
  // clients keep working until the server raises this minimum, while a stale
@@ -537,6 +538,9 @@ async function registerWait(opts, conditions, deadlineIso) {
537
538
  // unmatchable alias registration.
538
539
  recoveryError: typeof body.recovery_error === "string" ? body.recovery_error : null,
539
540
  terminalReceipt: body.terminal_receipt && typeof body.terminal_receipt === "object" ? body.terminal_receipt : null,
541
+ // BOT-2468: the wait's /waits?wait= deeplink (null from a relay without BOTBUDDY_WEB_URL;
542
+ // absent from a pre-BOT-2468 relay — both print no link line).
543
+ webUrl: safeWebUrl(body.web_url),
540
544
  };
541
545
  }
542
546
 
@@ -843,8 +847,11 @@ function publicWaitReceipt(receipt, {
843
847
  sessionAgentId = null,
844
848
  sessionId = null,
845
849
  maxBytes = 10240,
850
+ // BOT-2468: the registered wait's deeplink; undefined (no relay registration) omits the field.
851
+ webUrl = undefined,
846
852
  } = {}) {
847
- return truncateReceipt(withClientIdentity(withPrincipalReceipt(receipt, null, {
853
+ const linked = webUrl === undefined || (receipt && "web_url" in receipt) ? receipt : { ...receipt, web_url: webUrl };
854
+ return truncateReceipt(withClientIdentity(withPrincipalReceipt(linked, null, {
848
855
  sessionTenant,
849
856
  agentId,
850
857
  sessionAgentId,
@@ -1760,6 +1767,9 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
1760
1767
  // register and reused as the timeout receipt's error. Null when there is
1761
1768
  // nothing to report (no ci scope=next+branch condition, or no run yet).
1762
1769
  let ciArmNotice = null;
1770
+ // BOT-2468: the wait's /waits?wait= deeplink from register; stays undefined when the
1771
+ // wait never registered (live-only/offline), so its receipt carries no web_url key.
1772
+ let registeredWebUrl;
1763
1773
  let replayMatchedClaim = false;
1764
1774
  let replayExpiredRecovery = false;
1765
1775
  if (needsRelay) {
@@ -1809,6 +1819,7 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
1809
1819
  registeredAgentId = reg.agentId;
1810
1820
  registeredSessionAgentId = reg.sessionAgentId;
1811
1821
  registeredSessionId = reg.sessionId;
1822
+ registeredWebUrl = reg.webUrl ?? null;
1812
1823
  // A freshly minted session token may represent the same immutable agent,
1813
1824
  // but never a different tenant/agent or worktree. Check before exposing a
1814
1825
  // result or opening the SSE stream.
@@ -1841,7 +1852,7 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
1841
1852
  if (reg.terminalReceipt) {
1842
1853
  const recovered = preserveCheckpointCursor(publicWaitReceipt(reg.terminalReceipt, {
1843
1854
  sessionTenant, agentId: registeredAgentId, sessionAgentId: registeredSessionAgentId,
1844
- sessionId: registeredSessionId, maxBytes: opts.receiptMaxBytes,
1855
+ sessionId: registeredSessionId, maxBytes: opts.receiptMaxBytes, webUrl: registeredWebUrl,
1845
1856
  }), checkpoint.safe_cursor, checkpoint.created_at);
1846
1857
  const recoveredCancellation = recovered.outcome === "cancelled";
1847
1858
  await store.update(checkpoint.local_wait_id, {
@@ -1858,6 +1869,11 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
1858
1869
  emitReceipt(recovered, { versioned: true });
1859
1870
  process.exit(exitForRecoveredOutcome(recovered));
1860
1871
  }
1872
+ // BOT-2468: the wait's dashboard deeplink, once, at arm time (and again on
1873
+ // `bb wait resume`, which re-registers). An agent that armed this in the
1874
+ // background reads this first 🔗 line once and posts the link in its chat.
1875
+ const linkNotice = deepLinkNotice("bb-wait", registeredWebUrl);
1876
+ if (linkNotice) process.stderr.write(`${linkNotice}\n`);
1861
1877
  // BOT-1539: if any pr-review/pr-state target already has an outstanding
1862
1878
  // review, tell the operator now (stderr, one line per PR). Purely
1863
1879
  // informational — it does not change arming or exit semantics.
@@ -2104,6 +2120,7 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
2104
2120
  sessionAgentId: registeredSessionAgentId,
2105
2121
  sessionId: registeredSessionId,
2106
2122
  maxBytes: opts.receiptMaxBytes,
2123
+ webUrl: registeredWebUrl,
2107
2124
  });
2108
2125
  let terminalPersistedFromFrame = false;
2109
2126