@snowyroad/braid 0.78.1 → 0.79.2

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,14 +6266,14 @@ 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
  });
6253
6273
  let active = null;
6254
6274
  const label = (channelId) => {
6255
6275
  const name = deps.channelName(channelId);
6256
- const tag = channelTag(channelId);
6257
- return name ? `#${sanitizeForTty(name).slice(0, 40)} (${tag})` : `#${tag}`;
6276
+ return name ? `#${sanitizeForTty(name).slice(0, 40)}` : `#${channelTag(channelId)}`;
6258
6277
  };
6259
6278
  const gateBlocked = () => {
6260
6279
  const g = deps.gate.snapshot();
@@ -6262,57 +6281,91 @@ function makeSocketHandoffController(deps) {
6262
6281
  if (g.state === "orphaned") return { ok: false, error: "handoff_orphaned" };
6263
6282
  return null;
6264
6283
  };
6265
- const preflight = (channelId) => {
6284
+ const classify = (channelId) => {
6266
6285
  const blocked = gateBlocked();
6267
6286
  if (blocked) return blocked;
6268
6287
  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)) };
6288
+ if (session) {
6289
+ const sessionId = session.liveSessionId();
6290
+ if (!sessionId) return { ok: false, error: "no_live_session" };
6291
+ let raw;
6292
+ try {
6293
+ raw = specFor(deps.provider, sessionId, deps.cwdFor(channelId));
6294
+ } catch (e) {
6295
+ return { ok: false, error: sanitizeForTty(String(e?.message ?? e)) };
6296
+ }
6297
+ if (!raw) return { ok: false, error: "provider_unverified" };
6298
+ return { ok: true, kind: "live", session, spec: raw };
6299
+ }
6300
+ if (deps.memberChannelIds().has(channelId) && deps.warmSession) {
6301
+ if (!handoffSupported(deps.provider)) return { ok: false, error: "provider_unverified" };
6302
+ return { ok: true, kind: "cold" };
6277
6303
  }
6278
- if (!raw) return { ok: false, error: "provider_unverified" };
6279
- return { ok: true, session, sessionId, spec: raw };
6304
+ return { ok: false, error: "unknown_channel" };
6280
6305
  };
6281
6306
  return {
6282
6307
  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
- }));
6308
+ const rows = [];
6309
+ const seen = /* @__PURE__ */ new Set();
6310
+ for (const [id, s] of deps.sessions.entries()) {
6311
+ seen.add(id);
6312
+ rows.push({ id, label: label(id), provider: deps.provider, pendingWork: s.hasPendingWork?.() === true, live: true });
6313
+ }
6314
+ for (const id of deps.memberChannelIds()) {
6315
+ if (seen.has(id)) continue;
6316
+ rows.push({ id, label: label(id), provider: deps.provider, pendingWork: false, live: false });
6317
+ }
6318
+ return rows;
6289
6319
  },
6290
6320
  offer(channelId) {
6291
- const pre = preflight(channelId);
6292
- if (!pre.ok) return pre;
6321
+ const c = classify(channelId);
6322
+ if (!c.ok) return c;
6293
6323
  return {
6294
6324
  ok: true,
6295
6325
  channel: { id: channelId, label: label(channelId) },
6296
6326
  provider: deps.provider,
6297
- pendingWork: pre.session.hasPendingWork?.() === true
6327
+ pendingWork: c.kind === "live" ? c.session.hasPendingWork?.() === true : false,
6328
+ warm: c.kind === "cold"
6298
6329
  };
6299
6330
  },
6300
6331
  async begin(channelId) {
6301
- const pre = preflight(channelId);
6302
- if (!pre.ok) return pre;
6332
+ const c = classify(channelId);
6333
+ if (!c.ok) return c;
6303
6334
  if (!deps.gate.tryAcquire(channelId, "socket")) return gateBlocked() ?? { ok: false, error: "handoff_active" };
6304
6335
  let pauseAttempted = false;
6336
+ let warmedCold = false;
6337
+ let session;
6305
6338
  try {
6306
- const scoped = await scopedLaunchFn({ command: pre.spec.command, args: pre.spec.args, cwd: pre.spec.cwd }, { scope: deps.scope }, deps.provider);
6339
+ let spec;
6340
+ if (c.kind === "cold") {
6341
+ session = await deps.warmSession(channelId);
6342
+ warmedCold = true;
6343
+ const sessionId = session.liveSessionId();
6344
+ if (!sessionId) throw new Error("warm produced no resumable session");
6345
+ const raw = specFor(deps.provider, sessionId, deps.cwdFor(channelId));
6346
+ if (!raw) throw new Error("provider_unverified");
6347
+ spec = raw;
6348
+ log2(`[braid] handoff: warmed ${label(channelId)} on demand for a socket client`);
6349
+ } else {
6350
+ session = c.session;
6351
+ spec = c.spec;
6352
+ }
6353
+ const scoped = await scopedLaunchFn({ command: spec.command, args: spec.args, cwd: spec.cwd }, { scope: deps.scope }, deps.provider);
6307
6354
  pauseAttempted = true;
6308
- await pre.session.handoffPause();
6309
- active = { channelId, session: pre.session };
6355
+ await session.handoffPause();
6356
+ active = { channelId, session };
6310
6357
  log2(`[braid] handoff: paused ${label(channelId)} for a socket client`);
6311
6358
  return { ok: true, spec: { command: scoped.command, args: scoped.args, cwd: scoped.cwd, env: scoped.env ?? null } };
6312
6359
  } catch (e) {
6313
- if (pauseAttempted) {
6360
+ if (pauseAttempted && session) {
6361
+ try {
6362
+ await session.handoffResume();
6363
+ } catch {
6364
+ }
6365
+ } else if (warmedCold && session) {
6314
6366
  try {
6315
- await pre.session.handoffResume();
6367
+ await session.handoffPause();
6368
+ await session.handoffResume();
6316
6369
  } catch {
6317
6370
  }
6318
6371
  }
@@ -6396,6 +6449,12 @@ async function startBridge(cfg, relay, deps) {
6396
6449
  relay.onConnect?.(() => {
6397
6450
  relay.sendAgentHealth({ ...health.current(), service, runtimeVersion });
6398
6451
  relay.sendDaemonStatus(blockedEndpoints, daemonPostureDetail(blockedEndpoints));
6452
+ void (async () => {
6453
+ const fetchMembers = relay.fetchMemberChannels;
6454
+ if (!fetchMembers) return;
6455
+ const members = await fetchMembers.call(relay).catch(() => []);
6456
+ for (const c of members) if (c.name && !channelNames.has(c.id)) channelNames.set(c.id, c.name);
6457
+ })();
6399
6458
  });
6400
6459
  const brokers = /* @__PURE__ */ new Map();
6401
6460
  const mcpServerPath = path3.join(path3.dirname(fileURLToPath2(import.meta.url)), "mcp", "server.js");
@@ -6513,11 +6572,20 @@ async function startBridge(cfg, relay, deps) {
6513
6572
  console.warn("[braid] self card publish on connect failed:", sanitizeForTty(String(e)));
6514
6573
  }
6515
6574
  }
6516
- async function ensureSession(channelId) {
6575
+ async function ensureSession(channelId, opts) {
6517
6576
  const live = sessions.get(channelId);
6518
- if (live) return live;
6577
+ if (live) {
6578
+ if (opts?.startHeld) live.openHold();
6579
+ return live;
6580
+ }
6519
6581
  const inFlight = pending.get(channelId);
6520
- if (inFlight) return inFlight;
6582
+ if (inFlight) {
6583
+ if (opts?.startHeld) return inFlight.then((s) => {
6584
+ s.openHold();
6585
+ return s;
6586
+ });
6587
+ return inFlight;
6588
+ }
6521
6589
  const p = (async () => {
6522
6590
  console.log(`[braid] Warming up #${sanitizeForTty(channelId)}\u2026`);
6523
6591
  void (async () => {
@@ -6608,6 +6676,7 @@ async function startBridge(cfg, relay, deps) {
6608
6676
  clearInterval(warmupHeartbeat);
6609
6677
  relay.sendActivity(channelId, "stop");
6610
6678
  }
6679
+ if (opts?.startHeld) session.openHold();
6611
6680
  brokers.set(channelId, broker);
6612
6681
  sessions.set(channelId, session);
6613
6682
  pending.delete(channelId);
@@ -6703,6 +6772,18 @@ async function startBridge(cfg, relay, deps) {
6703
6772
  cwdFor: (id) => handoffCwdFor(cfg, id),
6704
6773
  scope: cfg.scope,
6705
6774
  gate: cs.handoffGate,
6775
+ // BL-228 task 4/8: the in-memory member set RelayClient tracks (Task 3) is
6776
+ // sufficient on its own — no union with sessions.keys() needed. listChannels()
6777
+ // already unions deps.sessions (live) with deps.memberChannelIds() (Task 4), so
6778
+ // any channel with a live session appears via the sessions map regardless of
6779
+ // whether it's also in the member set; unioning sessions.keys() into
6780
+ // memberChannelIds() here would be redundant belt-and-suspenders with no
6781
+ // behavioral effect, not a real gap closed.
6782
+ memberChannelIds: () => relay.knownMemberChannelIds(),
6783
+ // BL-228 task 8: warm a cold member channel on demand — the router's own
6784
+ // ensureSession, with startHeld so the socket handoff's begin() (task 5) never
6785
+ // races a headless turn between warm-complete and handoffPause.
6786
+ warmSession: (id) => ensureSession(id, { startHeld: true }),
6706
6787
  log: (line) => console.log(line)
6707
6788
  }) : void 0;
6708
6789
  try {
@@ -7770,9 +7851,7 @@ function validateSpec(line) {
7770
7851
  }
7771
7852
  async function pickChannel(channels, filter, promptFn, write) {
7772
7853
  if (channels.length === 0) {
7773
- write(
7774
- "[handoff] no active session to take over yet.\n[handoff] handoff drops you into a session the agent is ALREADY running - one exists\n[handoff] only once the agent is working in a channel (a message, a reply, a flow turn).\n[handoff] send the agent a message in a channel and let it reply, then run this again.\n"
7775
- );
7854
+ write("[handoff] this agent is not a member of any channel - no channels to take over yet.\n");
7776
7855
  return null;
7777
7856
  }
7778
7857
  if (filter && filter.trim() !== "") {
@@ -7799,7 +7878,8 @@ async function pickChannel(channels, filter, promptFn, write) {
7799
7878
  }
7800
7879
  function listChannels(write, channels) {
7801
7880
  channels.forEach((c, i) => {
7802
- write(` [${i + 1}] ${sanitizeForTty(c.label)}${c.pendingWork ? " (working)" : ""}
7881
+ const mark = c.live === false ? " (cold - will start a session)" : c.pendingWork ? " (working)" : "";
7882
+ write(` [${i + 1}] ${sanitizeForTty(c.label)}${mark}
7803
7883
  `);
7804
7884
  });
7805
7885
  }
@@ -7807,19 +7887,25 @@ function renderOffer(write, offer) {
7807
7887
  const channel = offer["channel"] ?? {};
7808
7888
  const label = sanitizeForTty(String(channel.label ?? ""));
7809
7889
  const pendingWork = offer["pendingWork"] === true;
7890
+ const warm = offer["warm"] === true;
7810
7891
  write(
7811
7892
  banner("handoff", `TAKING CONTROL of ${label}`, [
7812
7893
  "pausing the bridge and opening the agent's REAL interactive session",
7813
7894
  "on this terminal. Quit it (Ctrl-C or /exit) to hand control back.",
7814
7895
  "",
7815
- "WARNING: changes you make inside PERSIST after you leave -",
7816
- "switching model or mode (or compacting/clearing) carries over into",
7817
- "how this agent serves the channel afterwards.",
7818
- ...pendingWork ? [
7819
- "",
7820
- "NOTE: a reply is being worked on right now; taking control interrupts",
7821
- "it. The interrupted message will re-run when you hand back."
7822
- ] : []
7896
+ ...warm ? [
7897
+ "NOTE: this channel has NO running session - taking control will START",
7898
+ "one now, then drop you into it."
7899
+ ] : [
7900
+ "WARNING: changes you make inside PERSIST after you leave -",
7901
+ "switching model or mode (or compacting/clearing) carries over into",
7902
+ "how this agent serves the channel afterwards.",
7903
+ ...pendingWork ? [
7904
+ "",
7905
+ "NOTE: a reply is being worked on right now; taking control interrupts",
7906
+ "it. The interrupted message will re-run when you hand back."
7907
+ ] : []
7908
+ ]
7823
7909
  ])
7824
7910
  );
7825
7911
  }
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@snowyroad/braid",
3
- "version": "0.78.1",
3
+ "version": "0.79.2",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@snowyroad/braid",
9
- "version": "0.78.1",
9
+ "version": "0.79.2",
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.1",
3
+ "version": "0.79.2",
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",