@snowyroad/braid 0.78.0 → 0.79.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/dist/cli.js CHANGED
@@ -848,6 +848,10 @@ var RelayClient = class {
848
848
  cardPin = /* @__PURE__ */ new Map();
849
849
  // no-downgrade pin: name → pinned jkt
850
850
  signWarned = false;
851
+ /** BL-228 task 3: activity-derived member-channel inventory (resume-cursor keys +
852
+ * added - removed). Covers channels GET /channels' RLS may drop (e.g. a cross-org
853
+ * guest channel) since this is fed by the same push frames the bridge already gets. */
854
+ memberChannels = /* @__PURE__ */ new Set();
851
855
  /** Fail-open signing: any problem -> unsigned post + one warning per process. */
852
856
  trySign(f) {
853
857
  if (!this.cfg.signMessage) return void 0;
@@ -1399,12 +1403,14 @@ var RelayClient = class {
1399
1403
  this.seenByChannel.delete(ch);
1400
1404
  this.caughtUp.delete(ch);
1401
1405
  this.lastGapResumeAt.delete(ch);
1406
+ this.memberChannels.delete(ch);
1402
1407
  this.removedCb?.(ch);
1403
1408
  break;
1404
1409
  }
1405
1410
  case "added": {
1406
1411
  const ch = String(msg.channelId ?? "");
1407
1412
  if (!ch) break;
1413
+ this.memberChannels.add(ch);
1408
1414
  this.addedCb?.(ch);
1409
1415
  break;
1410
1416
  }
@@ -1432,6 +1438,7 @@ var RelayClient = class {
1432
1438
  const resume = msg?.resume;
1433
1439
  if (resume && typeof resume === "object") {
1434
1440
  for (const [ch, seqRaw] of Object.entries(resume)) {
1441
+ this.memberChannels.add(ch);
1435
1442
  const seq = Number(seqRaw);
1436
1443
  if (Number.isFinite(seq) && seq > this.cursorOf(ch)) this.cursors.set(ch, seq);
1437
1444
  const floor = this.cursorOf(ch);
@@ -1723,6 +1730,27 @@ var RelayClient = class {
1723
1730
  isScribeFor(channelId) {
1724
1731
  return this.selfScribeCache.get(channelId) ?? false;
1725
1732
  }
1733
+ /** BL-228 task 3: this agent's member channels from the relay (`GET /channels`, RLS-scoped
1734
+ * to the caller). Best-effort like fetchRoster/fetchChannelName: never throws, [] on any
1735
+ * failure (HTTP error, network failure, malformed body). Lets the handoff controller offer
1736
+ * COLD channels (member but no live session) alongside the live-session set. */
1737
+ async fetchMemberChannels() {
1738
+ const r = await this.httpJson(`/channels`, { requestClass: "relay.channels.list" });
1739
+ if (!r.ok) {
1740
+ console.warn("[braid] member channels HTTP", r.status ?? r.category);
1741
+ return [];
1742
+ }
1743
+ const body = r.json;
1744
+ const arr = Array.isArray(body?.channels) ? body.channels : [];
1745
+ return arr.filter((c) => typeof c?.id === "string" && c.id.length > 0).map((c) => ({ id: String(c.id), name: typeof c.name === "string" ? c.name : typeof c.title === "string" ? c.title : null }));
1746
+ }
1747
+ /** BL-228 task 3: synchronous in-memory member-channel set (resume-cursor keys at hello,
1748
+ * plus `added` minus `removed`), activity-derived from push frames — no network. Covers
1749
+ * a channel GET /channels' RLS may drop (e.g. a cross-org guest channel) that the bridge
1750
+ * still holds live membership of via the WS. Returns a snapshot copy, not a live view. */
1751
+ knownMemberChannelIds() {
1752
+ return new Set(this.memberChannels);
1753
+ }
1726
1754
  /** Spread optional usage into the outbound body. Omits undefined fields so a no-usage
1727
1755
  * call produces a body byte-identical to the pre-billing shape. */
1728
1756
  usageBody(u) {
@@ -2736,8 +2764,14 @@ ${fence(slackTaint ? "slack message" : "messages mentioning you", addressed)}
2736
2764
  hasPendingWork() {
2737
2765
  return this.pendingTurns.length > 0;
2738
2766
  }
2767
+ /** Open the inbound hold WITHOUT stopping the client (BL-228: start-held warm
2768
+ * for on-demand handoff). Idempotent: an already-open hold is preserved so
2769
+ * nothing queued during the warm is lost. */
2770
+ openHold() {
2771
+ if (!this.holdQueue) this.holdQueue = [];
2772
+ }
2739
2773
  async handoffPause() {
2740
- this.holdQueue = [];
2774
+ if (!this.holdQueue) this.holdQueue = [];
2741
2775
  await this.session?.handoffPause?.();
2742
2776
  }
2743
2777
  async handoffResume() {
@@ -4276,33 +4310,18 @@ function resolveHandoffBinary(name, install) {
4276
4310
  handoffBinaryAbs.set(name, found);
4277
4311
  return found;
4278
4312
  }
4313
+ var HANDOFF_RESUME_SPECS = {
4314
+ "claude-code": (sessionId, cwd) => ({ command: resolveHandoffBinary("claude", "Claude Code"), args: ["--resume", sessionId], cwd }),
4315
+ codex: (sessionId, cwd) => ({ command: resolveHandoffBinary("codex", "the Codex CLI"), args: ["resume", sessionId], cwd }),
4316
+ grok: (sessionId, cwd) => ({ command: resolveHandoffBinary("grok", "xAI's Grok CLI"), args: ["--resume", sessionId], cwd }),
4317
+ opencode: (sessionId, cwd) => ({ command: resolveHandoffBinary("opencode", "the opencode CLI"), args: ["--session", sessionId], cwd })
4318
+ };
4319
+ function interactiveHandoffSupported(agent) {
4320
+ return Object.prototype.hasOwnProperty.call(HANDOFF_RESUME_SPECS, agent);
4321
+ }
4279
4322
  function interactiveResumeSpecFor(agent, sessionId, cwd) {
4280
- switch (agent) {
4281
- case "claude-code":
4282
- return { command: resolveHandoffBinary("claude", "Claude Code"), args: ["--resume", sessionId], cwd };
4283
- case "codex":
4284
- return { command: resolveHandoffBinary("codex", "the Codex CLI"), args: ["resume", sessionId], cwd };
4285
- // grok (2026-07-02): 3-leg probe over `grok agent stdio` — session/new id ==
4286
- // `grok --resume <id>` id (cwd-keyed store; the bridge's canonical process.cwd()
4287
- // matches) AND session/load recalled the marker. NOTE: grok's session store is
4288
- // keyed by CANONICAL cwd; sessionId alone is not enough if cwd were a symlink.
4289
- case "grok":
4290
- return { command: resolveHandoffBinary("grok", "xAI's Grok CLI"), args: ["--resume", sessionId], cwd };
4291
- // opencode (2026-07-02): 3-leg probe over `opencode acp` — `ses_` id resumable by
4292
- // BOTH the TUI (`opencode --session <id>`) and headless `run --session` (marker
4293
- // recalled), AND session/load recalled the marker (shared store).
4294
- case "opencode":
4295
- return { command: resolveHandoffBinary("opencode", "the opencode CLI"), args: ["--session", sessionId], cwd };
4296
- // gemini: REFUTED for the handoff 2026-07-02 — native `--resume <uuid>` works, but
4297
- // ACP session/load returns success while restoring NO conversation context, so the
4298
- // hand-back leg would silently fork the thread. Do not enable until gemini-cli's
4299
- // ACP loadSession actually rehydrates context.
4300
- // cursor: REFUTED 2026-07-02 — `~/.cursor/acp-sessions/` and the native chat store
4301
- // are separate namespaces; `cursor-agent --resume <acp-id>` exits 0 and silently
4302
- // opens a FRESH thread (the exact failure mode this gate exists to prevent).
4303
- default:
4304
- return null;
4305
- }
4323
+ const build = HANDOFF_RESUME_SPECS[agent];
4324
+ return build ? build(sessionId, cwd) : null;
4306
4325
  }
4307
4326
  var defaultAcpClientFactory = (launch) => new AcpClient(launch);
4308
4327
  var MAX_CONSECUTIVE_RESTARTS = 3;
@@ -5808,7 +5827,7 @@ function startHandoffConversation(conn, ctl, initialChannelId, writeLine, log2)
5808
5827
  }
5809
5828
  channelId = id;
5810
5829
  phase = "offered";
5811
- writeLine({ handoff: "offer", channel: res.channel, provider: res.provider, pendingWork: res.pendingWork });
5830
+ writeLine({ handoff: "offer", channel: res.channel, provider: res.provider, pendingWork: res.pendingWork, warm: res.warm });
5812
5831
  };
5813
5832
  conn.on("close", () => {
5814
5833
  const p = phase;
@@ -6247,6 +6266,7 @@ function renderEvent(ev) {
6247
6266
  // src/observe/socketHandoff.ts
6248
6267
  function makeSocketHandoffController(deps) {
6249
6268
  const specFor = deps.specFor ?? interactiveResumeSpecFor;
6269
+ const handoffSupported = deps.handoffSupported ?? interactiveHandoffSupported;
6250
6270
  const scopedLaunchFn = deps.scopedLaunchFn ?? scopedLaunch;
6251
6271
  const log2 = deps.log ?? (() => {
6252
6272
  });
@@ -6262,57 +6282,91 @@ function makeSocketHandoffController(deps) {
6262
6282
  if (g.state === "orphaned") return { ok: false, error: "handoff_orphaned" };
6263
6283
  return null;
6264
6284
  };
6265
- const preflight = (channelId) => {
6285
+ const classify = (channelId) => {
6266
6286
  const blocked = gateBlocked();
6267
6287
  if (blocked) return blocked;
6268
6288
  const session = deps.sessions.get(channelId);
6269
- if (!session) return { ok: false, error: "unknown_channel" };
6270
- const sessionId = session.liveSessionId();
6271
- if (!sessionId) return { ok: false, error: "no_live_session" };
6272
- let raw;
6273
- try {
6274
- raw = specFor(deps.provider, sessionId, deps.cwdFor(channelId));
6275
- } catch (e) {
6276
- return { ok: false, error: sanitizeForTty(String(e?.message ?? e)) };
6289
+ if (session) {
6290
+ const sessionId = session.liveSessionId();
6291
+ if (!sessionId) return { ok: false, error: "no_live_session" };
6292
+ let raw;
6293
+ try {
6294
+ raw = specFor(deps.provider, sessionId, deps.cwdFor(channelId));
6295
+ } catch (e) {
6296
+ return { ok: false, error: sanitizeForTty(String(e?.message ?? e)) };
6297
+ }
6298
+ if (!raw) return { ok: false, error: "provider_unverified" };
6299
+ return { ok: true, kind: "live", session, spec: raw };
6300
+ }
6301
+ if (deps.memberChannelIds().has(channelId) && deps.warmSession) {
6302
+ if (!handoffSupported(deps.provider)) return { ok: false, error: "provider_unverified" };
6303
+ return { ok: true, kind: "cold" };
6277
6304
  }
6278
- if (!raw) return { ok: false, error: "provider_unverified" };
6279
- return { ok: true, session, sessionId, spec: raw };
6305
+ return { ok: false, error: "unknown_channel" };
6280
6306
  };
6281
6307
  return {
6282
6308
  listChannels() {
6283
- return [...deps.sessions.entries()].map(([id, s]) => ({
6284
- id,
6285
- label: label(id),
6286
- provider: deps.provider,
6287
- pendingWork: s.hasPendingWork?.() === true
6288
- }));
6309
+ const rows = [];
6310
+ const seen = /* @__PURE__ */ new Set();
6311
+ for (const [id, s] of deps.sessions.entries()) {
6312
+ seen.add(id);
6313
+ rows.push({ id, label: label(id), provider: deps.provider, pendingWork: s.hasPendingWork?.() === true, live: true });
6314
+ }
6315
+ for (const id of deps.memberChannelIds()) {
6316
+ if (seen.has(id)) continue;
6317
+ rows.push({ id, label: label(id), provider: deps.provider, pendingWork: false, live: false });
6318
+ }
6319
+ return rows;
6289
6320
  },
6290
6321
  offer(channelId) {
6291
- const pre = preflight(channelId);
6292
- if (!pre.ok) return pre;
6322
+ const c = classify(channelId);
6323
+ if (!c.ok) return c;
6293
6324
  return {
6294
6325
  ok: true,
6295
6326
  channel: { id: channelId, label: label(channelId) },
6296
6327
  provider: deps.provider,
6297
- pendingWork: pre.session.hasPendingWork?.() === true
6328
+ pendingWork: c.kind === "live" ? c.session.hasPendingWork?.() === true : false,
6329
+ warm: c.kind === "cold"
6298
6330
  };
6299
6331
  },
6300
6332
  async begin(channelId) {
6301
- const pre = preflight(channelId);
6302
- if (!pre.ok) return pre;
6333
+ const c = classify(channelId);
6334
+ if (!c.ok) return c;
6303
6335
  if (!deps.gate.tryAcquire(channelId, "socket")) return gateBlocked() ?? { ok: false, error: "handoff_active" };
6304
6336
  let pauseAttempted = false;
6337
+ let warmedCold = false;
6338
+ let session;
6305
6339
  try {
6306
- const scoped = await scopedLaunchFn({ command: pre.spec.command, args: pre.spec.args, cwd: pre.spec.cwd }, { scope: deps.scope }, deps.provider);
6340
+ let spec;
6341
+ if (c.kind === "cold") {
6342
+ session = await deps.warmSession(channelId);
6343
+ warmedCold = true;
6344
+ const sessionId = session.liveSessionId();
6345
+ if (!sessionId) throw new Error("warm produced no resumable session");
6346
+ const raw = specFor(deps.provider, sessionId, deps.cwdFor(channelId));
6347
+ if (!raw) throw new Error("provider_unverified");
6348
+ spec = raw;
6349
+ log2(`[braid] handoff: warmed ${label(channelId)} on demand for a socket client`);
6350
+ } else {
6351
+ session = c.session;
6352
+ spec = c.spec;
6353
+ }
6354
+ const scoped = await scopedLaunchFn({ command: spec.command, args: spec.args, cwd: spec.cwd }, { scope: deps.scope }, deps.provider);
6307
6355
  pauseAttempted = true;
6308
- await pre.session.handoffPause();
6309
- active = { channelId, session: pre.session };
6356
+ await session.handoffPause();
6357
+ active = { channelId, session };
6310
6358
  log2(`[braid] handoff: paused ${label(channelId)} for a socket client`);
6311
6359
  return { ok: true, spec: { command: scoped.command, args: scoped.args, cwd: scoped.cwd, env: scoped.env ?? null } };
6312
6360
  } catch (e) {
6313
- if (pauseAttempted) {
6361
+ if (pauseAttempted && session) {
6362
+ try {
6363
+ await session.handoffResume();
6364
+ } catch {
6365
+ }
6366
+ } else if (warmedCold && session) {
6314
6367
  try {
6315
- await pre.session.handoffResume();
6368
+ await session.handoffPause();
6369
+ await session.handoffResume();
6316
6370
  } catch {
6317
6371
  }
6318
6372
  }
@@ -6513,11 +6567,20 @@ async function startBridge(cfg, relay, deps) {
6513
6567
  console.warn("[braid] self card publish on connect failed:", sanitizeForTty(String(e)));
6514
6568
  }
6515
6569
  }
6516
- async function ensureSession(channelId) {
6570
+ async function ensureSession(channelId, opts) {
6517
6571
  const live = sessions.get(channelId);
6518
- if (live) return live;
6572
+ if (live) {
6573
+ if (opts?.startHeld) live.openHold();
6574
+ return live;
6575
+ }
6519
6576
  const inFlight = pending.get(channelId);
6520
- if (inFlight) return inFlight;
6577
+ if (inFlight) {
6578
+ if (opts?.startHeld) return inFlight.then((s) => {
6579
+ s.openHold();
6580
+ return s;
6581
+ });
6582
+ return inFlight;
6583
+ }
6521
6584
  const p = (async () => {
6522
6585
  console.log(`[braid] Warming up #${sanitizeForTty(channelId)}\u2026`);
6523
6586
  void (async () => {
@@ -6608,6 +6671,7 @@ async function startBridge(cfg, relay, deps) {
6608
6671
  clearInterval(warmupHeartbeat);
6609
6672
  relay.sendActivity(channelId, "stop");
6610
6673
  }
6674
+ if (opts?.startHeld) session.openHold();
6611
6675
  brokers.set(channelId, broker);
6612
6676
  sessions.set(channelId, session);
6613
6677
  pending.delete(channelId);
@@ -6703,6 +6767,18 @@ async function startBridge(cfg, relay, deps) {
6703
6767
  cwdFor: (id) => handoffCwdFor(cfg, id),
6704
6768
  scope: cfg.scope,
6705
6769
  gate: cs.handoffGate,
6770
+ // BL-228 task 4/8: the in-memory member set RelayClient tracks (Task 3) is
6771
+ // sufficient on its own — no union with sessions.keys() needed. listChannels()
6772
+ // already unions deps.sessions (live) with deps.memberChannelIds() (Task 4), so
6773
+ // any channel with a live session appears via the sessions map regardless of
6774
+ // whether it's also in the member set; unioning sessions.keys() into
6775
+ // memberChannelIds() here would be redundant belt-and-suspenders with no
6776
+ // behavioral effect, not a real gap closed.
6777
+ memberChannelIds: () => relay.knownMemberChannelIds(),
6778
+ // BL-228 task 8: warm a cold member channel on demand — the router's own
6779
+ // ensureSession, with startHeld so the socket handoff's begin() (task 5) never
6780
+ // races a headless turn between warm-complete and handoffPause.
6781
+ warmSession: (id) => ensureSession(id, { startHeld: true }),
6706
6782
  log: (line) => console.log(line)
6707
6783
  }) : void 0;
6708
6784
  try {
@@ -7770,7 +7846,7 @@ function validateSpec(line) {
7770
7846
  }
7771
7847
  async function pickChannel(channels, filter, promptFn, write) {
7772
7848
  if (channels.length === 0) {
7773
- write("[handoff] no channels are available to take over\n");
7849
+ write("[handoff] this agent is not a member of any channel - no channels to take over yet.\n");
7774
7850
  return null;
7775
7851
  }
7776
7852
  if (filter && filter.trim() !== "") {
@@ -7797,7 +7873,8 @@ async function pickChannel(channels, filter, promptFn, write) {
7797
7873
  }
7798
7874
  function listChannels(write, channels) {
7799
7875
  channels.forEach((c, i) => {
7800
- write(` [${i + 1}] ${sanitizeForTty(c.label)}${c.pendingWork ? " (working)" : ""}
7876
+ const mark = c.live === false ? " (cold - will start a session)" : c.pendingWork ? " (working)" : "";
7877
+ write(` [${i + 1}] ${sanitizeForTty(c.label)}${mark}
7801
7878
  `);
7802
7879
  });
7803
7880
  }
@@ -7805,19 +7882,25 @@ function renderOffer(write, offer) {
7805
7882
  const channel = offer["channel"] ?? {};
7806
7883
  const label = sanitizeForTty(String(channel.label ?? ""));
7807
7884
  const pendingWork = offer["pendingWork"] === true;
7885
+ const warm = offer["warm"] === true;
7808
7886
  write(
7809
7887
  banner("handoff", `TAKING CONTROL of ${label}`, [
7810
7888
  "pausing the bridge and opening the agent's REAL interactive session",
7811
7889
  "on this terminal. Quit it (Ctrl-C or /exit) to hand control back.",
7812
7890
  "",
7813
- "WARNING: changes you make inside PERSIST after you leave -",
7814
- "switching model or mode (or compacting/clearing) carries over into",
7815
- "how this agent serves the channel afterwards.",
7816
- ...pendingWork ? [
7817
- "",
7818
- "NOTE: a reply is being worked on right now; taking control interrupts",
7819
- "it. The interrupted message will re-run when you hand back."
7820
- ] : []
7891
+ ...warm ? [
7892
+ "NOTE: this channel has NO running session - taking control will START",
7893
+ "one now, then drop you into it."
7894
+ ] : [
7895
+ "WARNING: changes you make inside PERSIST after you leave -",
7896
+ "switching model or mode (or compacting/clearing) carries over into",
7897
+ "how this agent serves the channel afterwards.",
7898
+ ...pendingWork ? [
7899
+ "",
7900
+ "NOTE: a reply is being worked on right now; taking control interrupts",
7901
+ "it. The interrupted message will re-run when you hand back."
7902
+ ] : []
7903
+ ]
7821
7904
  ])
7822
7905
  );
7823
7906
  }
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@snowyroad/braid",
3
- "version": "0.78.0",
3
+ "version": "0.79.1",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@snowyroad/braid",
9
- "version": "0.78.0",
9
+ "version": "0.79.1",
10
10
  "license": "SEE LICENSE IN LICENSE.md",
11
11
  "dependencies": {
12
12
  "@agentclientprotocol/sdk": "0.25.1",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@snowyroad/braid",
3
- "version": "0.78.0",
3
+ "version": "0.79.1",
4
4
  "description": "Braid: connect your own coding agent (Claude Code, Codex, Gemini, Grok) to a Braid channel and collaborate with other agents and humans.",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "author": "SnowyRoad",