@snowyroad/braid 0.76.1 → 0.78.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/dist/cli.js CHANGED
@@ -66,7 +66,7 @@ import {
66
66
  validateReplacementToken,
67
67
  verifyCardSignature,
68
68
  wsToHttp
69
- } from "./chunk-IBUDDALN.js";
69
+ } from "./chunk-SKUYOAV5.js";
70
70
 
71
71
  // src/health.ts
72
72
  var CEILING_STRIKES_TO_DEAD = 3;
@@ -5030,9 +5030,7 @@ import {
5030
5030
  writeFileSync as writeFileSync2,
5031
5031
  renameSync as renameSync2,
5032
5032
  chmodSync as chmodSync2,
5033
- readdirSync as readdirSync2,
5034
- statSync as statSync2,
5035
- existsSync as existsSync2
5033
+ statSync as statSync2
5036
5034
  } from "fs";
5037
5035
  import { createHash } from "crypto";
5038
5036
  import { homedir as homedir3 } from "os";
@@ -5051,37 +5049,9 @@ function cardCacheReadPath(cfg) {
5051
5049
  ["card-cache", relayHostSegment(cfg.relayWsUrl), `${safe2(cfg.agentName)}.json`]
5052
5050
  );
5053
5051
  }
5054
- function collectSkillMdPaths(root) {
5055
- const out = [];
5056
- if (!existsSync2(root)) return out;
5057
- function walk(dir) {
5058
- let entries;
5059
- try {
5060
- entries = readdirSync2(dir);
5061
- } catch {
5062
- return;
5063
- }
5064
- for (const entry of entries) {
5065
- const full = join4(dir, entry);
5066
- let st;
5067
- try {
5068
- st = statSync2(full);
5069
- } catch {
5070
- continue;
5071
- }
5072
- if (st.isDirectory()) {
5073
- walk(full);
5074
- } else if (entry === "SKILL.md") {
5075
- out.push(full);
5076
- }
5077
- }
5078
- }
5079
- walk(root);
5080
- return out;
5081
- }
5082
5052
  function computeCapabilityFingerprint(cfg, skillsDir = process.env.BRAID_SKILLS_DIR ?? join4(homedir3(), ".claude", "skills"), pluginsDir = process.env.BRAID_PLUGINS_DIR ?? join4(homedir3(), ".claude", "plugins")) {
5083
- const paths1 = collectSkillMdPaths(skillsDir);
5084
- const paths2 = collectSkillMdPaths(pluginsDir);
5053
+ const paths1 = collectSkillMd(skillsDir);
5054
+ const paths2 = collectSkillMd(pluginsDir);
5085
5055
  const allPaths = [...paths1, ...paths2].sort();
5086
5056
  const pathEntries = allPaths.map((p) => {
5087
5057
  let mtimeMs = 0;
@@ -5143,6 +5113,67 @@ function saveCachedCard(cfg, fingerprint, unsignedCard) {
5143
5113
  }
5144
5114
  }
5145
5115
 
5116
+ // src/cardRefresh.ts
5117
+ var CARD_REFRESH_INTERVAL_MS = 3e5;
5118
+ var JITTER_RATIO = 0.2;
5119
+ function computeCardRefreshDelay(intervalMs, random) {
5120
+ return Math.round(intervalMs * (1 - JITTER_RATIO + 2 * JITTER_RATIO * random()));
5121
+ }
5122
+ function startCardRefreshLoop(initialFingerprint, deps) {
5123
+ const intervalMs = deps.intervalMs ?? CARD_REFRESH_INTERVAL_MS;
5124
+ const random = deps.random ?? Math.random;
5125
+ const log2 = deps.log ?? ((m) => console.warn(m));
5126
+ let lastPushed = initialFingerprint;
5127
+ let warnedEmptyFor = null;
5128
+ let stopped = false;
5129
+ let timer = null;
5130
+ const schedule = () => {
5131
+ if (stopped) return;
5132
+ timer = setTimeout(() => {
5133
+ void tick();
5134
+ }, computeCardRefreshDelay(intervalMs, random));
5135
+ timer.unref?.();
5136
+ };
5137
+ const tick = async () => {
5138
+ try {
5139
+ const fp = deps.computeFingerprint();
5140
+ if (fp && fp !== lastPushed) {
5141
+ const fields = deps.buildFromDisk();
5142
+ if (!fields) {
5143
+ if (warnedEmptyFor !== fp) {
5144
+ warnedEmptyFor = fp;
5145
+ log2("[braid] card refresh: skills changed but disk build yielded nothing \u2014 keeping the current card");
5146
+ }
5147
+ } else {
5148
+ const unsigned = buildPartialCard(deps.agentName, fields);
5149
+ let card = unsigned;
5150
+ if (deps.signCard) card = await deps.signCard(card);
5151
+ await deps.putAgentCard(card, "full");
5152
+ deps.setCard(unsigned);
5153
+ try {
5154
+ deps.cacheSave(fp, unsigned);
5155
+ } catch {
5156
+ }
5157
+ lastPushed = fp;
5158
+ warnedEmptyFor = null;
5159
+ }
5160
+ }
5161
+ } catch (e) {
5162
+ log2(`[braid] card refresh: tick failed (will retry): ${String(e)}`);
5163
+ } finally {
5164
+ schedule();
5165
+ }
5166
+ };
5167
+ schedule();
5168
+ return () => {
5169
+ stopped = true;
5170
+ if (timer) {
5171
+ clearTimeout(timer);
5172
+ timer = null;
5173
+ }
5174
+ };
5175
+ }
5176
+
5146
5177
  // src/shutdown.ts
5147
5178
  function restartHint(agentName) {
5148
5179
  const cmdName = isShellSafeName(agentName) ? agentName : "<agent-name>";
@@ -5217,11 +5248,11 @@ function installCrashGuard() {
5217
5248
  }
5218
5249
 
5219
5250
  // src/service/needsHumanMarker.ts
5220
- import { mkdirSync as mkdirSync5, chmodSync as chmodSync4, writeFileSync as writeFileSync5, readFileSync as readFileSync6, existsSync as existsSync6, unlinkSync as unlinkSync2 } from "fs";
5251
+ import { mkdirSync as mkdirSync5, chmodSync as chmodSync4, writeFileSync as writeFileSync5, readFileSync as readFileSync6, existsSync as existsSync5, unlinkSync as unlinkSync2 } from "fs";
5221
5252
  import { join as join7 } from "path";
5222
5253
 
5223
5254
  // src/service/install.ts
5224
- import { existsSync as existsSync5, readdirSync as readdirSync4, readFileSync as readFileSync5 } from "fs";
5255
+ import { existsSync as existsSync4, readdirSync as readdirSync3, readFileSync as readFileSync5 } from "fs";
5225
5256
  import { join as join6 } from "path";
5226
5257
 
5227
5258
  // src/service/runtime.ts
@@ -5230,13 +5261,13 @@ import {
5230
5261
  mkdirSync as mkdirSync3,
5231
5262
  readFileSync as readFileSync4,
5232
5263
  writeFileSync as writeFileSync3,
5233
- readdirSync as readdirSync3,
5264
+ readdirSync as readdirSync2,
5234
5265
  renameSync as renameSync3,
5235
5266
  rmSync,
5236
5267
  symlinkSync,
5237
5268
  readlinkSync,
5238
5269
  lstatSync as lstatSync2,
5239
- existsSync as existsSync3
5270
+ existsSync as existsSync2
5240
5271
  } from "fs";
5241
5272
  import { execFileSync } from "child_process";
5242
5273
  import { createHash as createHash2 } from "crypto";
@@ -5250,18 +5281,18 @@ function defaultBraidDir() {
5250
5281
  function runtimeRootForRead(braidDir, platform) {
5251
5282
  const primary = join5(braidDir, "runtime");
5252
5283
  const markerOf = (root) => platform === "win32" ? currentTxtPath(root) : join5(root, "current");
5253
- if (existsSync3(markerOf(primary))) return primary;
5284
+ if (existsSync2(markerOf(primary))) return primary;
5254
5285
  const override = process.env.BRAID_CONFIG_DIR?.trim();
5255
5286
  if (!override && braidDir === join5(homedir4(), ".braid")) {
5256
5287
  const legacy = join5(homedir4(), ".arp", "runtime");
5257
- if (existsSync3(markerOf(legacy))) return legacy;
5288
+ if (existsSync2(markerOf(legacy))) return legacy;
5258
5289
  }
5259
5290
  return primary;
5260
5291
  }
5261
5292
  function runningPackageRoot() {
5262
5293
  let dir = dirname4(fileURLToPath(import.meta.url));
5263
5294
  for (let i = 0; i < 6; i++) {
5264
- if (existsSync3(join5(dir, "package.json")) && existsSync3(join5(dir, "dist"))) return dir;
5295
+ if (existsSync2(join5(dir, "package.json")) && existsSync2(join5(dir, "dist"))) return dir;
5265
5296
  const parent = dirname4(dir);
5266
5297
  if (parent === dir) break;
5267
5298
  dir = parent;
@@ -5290,7 +5321,7 @@ function pinCurrentRuntime(opts = {}) {
5290
5321
  cpSync(join5(sourceDir, "dist"), join5(versionDir, "dist"), { recursive: true, force: true });
5291
5322
  cpSync(join5(sourceDir, "package.json"), join5(versionDir, "package.json"), { force: true });
5292
5323
  const shrinkwrapSrc = join5(sourceDir, "npm-shrinkwrap.json");
5293
- const hasShrinkwrap = existsSync3(shrinkwrapSrc);
5324
+ const hasShrinkwrap = existsSync2(shrinkwrapSrc);
5294
5325
  if (hasShrinkwrap) {
5295
5326
  cpSync(shrinkwrapSrc, join5(versionDir, "npm-shrinkwrap.json"), { force: true });
5296
5327
  }
@@ -5318,7 +5349,7 @@ function populateNodeModules(sourceDir, versionDir, prodDeps, hasShrinkwrap, exe
5318
5349
  if (plainTree) {
5319
5350
  rmSync(dest, { recursive: true, force: true });
5320
5351
  cpSync(plainTree, dest, { recursive: true, force: true });
5321
- if (existsSync3(join5(dest, marker))) return "copy";
5352
+ if (existsSync2(join5(dest, marker))) return "copy";
5322
5353
  rmSync(dest, { recursive: true, force: true });
5323
5354
  }
5324
5355
  return npmInstallProdDeps(versionDir, marker, hasShrinkwrap, exec);
@@ -5328,7 +5359,7 @@ function findCopyableNodeModules(sourceDir, marker) {
5328
5359
  for (let i = 0; i < 8; i++) {
5329
5360
  const nm = join5(dir, "node_modules");
5330
5361
  const markerPath = join5(nm, marker);
5331
- if (existsSync3(markerPath)) {
5362
+ if (existsSync2(markerPath)) {
5332
5363
  if (!lstatSync2(markerPath).isSymbolicLink()) return nm;
5333
5364
  return null;
5334
5365
  }
@@ -5356,7 +5387,7 @@ function npmInstallProdDeps(versionDir, marker, hasShrinkwrap, exec) {
5356
5387
  `pinned runtime at ${versionDir} could not obtain its production dependencies (\`npm ${args[0]}\` failed \u2014 npm missing, no network, or shrinkwrap/platform mismatch?). Fix and re-run, or the service will crash-loop with ERR_MODULE_NOT_FOUND. Underlying error: ${detail}`
5357
5388
  );
5358
5389
  }
5359
- if (!existsSync3(join5(versionDir, "node_modules", marker))) {
5390
+ if (!existsSync2(join5(versionDir, "node_modules", marker))) {
5360
5391
  throw new Error(
5361
5392
  `pinned runtime at ${versionDir} is missing dependency "${marker}" after \`npm ${args[0]}\`. Refusing to leave a runtime that will crash-loop with ERR_MODULE_NOT_FOUND.`
5362
5393
  );
@@ -5368,7 +5399,7 @@ function pruneOldVersions(runtimeRoot, currentVersion, previousVersion) {
5368
5399
  if (previousVersion && previousVersion !== currentVersion) keep.add(previousVersion);
5369
5400
  let entries;
5370
5401
  try {
5371
- entries = readdirSync3(runtimeRoot);
5402
+ entries = readdirSync2(runtimeRoot);
5372
5403
  } catch {
5373
5404
  return;
5374
5405
  }
@@ -5445,7 +5476,7 @@ function installedClosureRecord(q = {}) {
5445
5476
 
5446
5477
  // src/service/socket.ts
5447
5478
  import * as net from "net";
5448
- import { mkdirSync as mkdirSync4, chmodSync as chmodSync3, existsSync as existsSync4, unlinkSync, writeFileSync as writeFileSync4 } from "fs";
5479
+ import { mkdirSync as mkdirSync4, chmodSync as chmodSync3, existsSync as existsSync3, unlinkSync, writeFileSync as writeFileSync4 } from "fs";
5449
5480
  import { dirname as dirname5 } from "path";
5450
5481
  import { randomBytes as randomBytes2, timingSafeEqual } from "crypto";
5451
5482
  import { spawnSync as nodeSpawnSync2 } from "child_process";
@@ -5461,7 +5492,7 @@ function startControlSocket(opts) {
5461
5492
  chmodSync3(dir, 448);
5462
5493
  } catch {
5463
5494
  }
5464
- if (existsSync4(opts.sockPath)) {
5495
+ if (existsSync3(opts.sockPath)) {
5465
5496
  if (!opts.ownsLock()) {
5466
5497
  return Promise.reject(
5467
5498
  new Error(
@@ -5532,13 +5563,13 @@ function startControlSocket(opts) {
5532
5563
  server.close(() => {
5533
5564
  if (!isWin32) {
5534
5565
  try {
5535
- if (existsSync4(opts.sockPath)) unlinkSync(opts.sockPath);
5566
+ if (existsSync3(opts.sockPath)) unlinkSync(opts.sockPath);
5536
5567
  } catch {
5537
5568
  }
5538
5569
  }
5539
5570
  if (opts.tokenPath) {
5540
5571
  try {
5541
- if (existsSync4(opts.tokenPath)) unlinkSync(opts.tokenPath);
5572
+ if (existsSync3(opts.tokenPath)) unlinkSync(opts.tokenPath);
5542
5573
  } catch {
5543
5574
  }
5544
5575
  }
@@ -5600,7 +5631,7 @@ async function probeStatus(sockPath, timeoutMs = 1500, token) {
5600
5631
  }
5601
5632
  function socketLive(sockPath, platform = process.platform, timeoutMs = 1e3) {
5602
5633
  if (platform !== "win32") {
5603
- return Promise.resolve(existsSync4(sockPath));
5634
+ return Promise.resolve(existsSync3(sockPath));
5604
5635
  }
5605
5636
  return new Promise((resolve2) => {
5606
5637
  let settled = false;
@@ -5625,6 +5656,8 @@ function handleConnection(conn, opts, token, log2) {
5625
5656
  conn.setEncoding("utf8");
5626
5657
  let buf = "";
5627
5658
  let dispatched = false;
5659
+ let onLine = null;
5660
+ let ignoredBytes = 0;
5628
5661
  const writeLine = (obj) => {
5629
5662
  try {
5630
5663
  conn.write(JSON.stringify(obj) + "\n");
@@ -5637,19 +5670,7 @@ function handleConnection(conn, opts, token, log2) {
5637
5670
  };
5638
5671
  conn.on("error", () => {
5639
5672
  });
5640
- conn.on("data", (chunk) => {
5641
- if (dispatched) return;
5642
- buf += chunk;
5643
- const nl = buf.indexOf("\n");
5644
- if (nl < 0) {
5645
- if (buf.length > 64 * 1024) {
5646
- dispatched = true;
5647
- closeUnknown();
5648
- }
5649
- return;
5650
- }
5651
- dispatched = true;
5652
- const line = buf.slice(0, nl);
5673
+ const dispatchFirst = (line) => {
5653
5674
  let req;
5654
5675
  try {
5655
5676
  req = JSON.parse(line);
@@ -5676,6 +5697,10 @@ function handleConnection(conn, opts, token, log2) {
5676
5697
  conn.end();
5677
5698
  return;
5678
5699
  }
5700
+ if (opts.handoff) {
5701
+ const g = opts.handoff.gateSnapshot();
5702
+ if (g.state !== "idle") payload.handoff = g.state;
5703
+ }
5679
5704
  writeLine(payload);
5680
5705
  conn.end();
5681
5706
  return;
@@ -5685,7 +5710,68 @@ function handleConnection(conn, opts, token, log2) {
5685
5710
  startAttachStream(conn, opts.hub, channelId);
5686
5711
  return;
5687
5712
  }
5713
+ if (verb === "handoff") {
5714
+ if (!opts.handoff) {
5715
+ closeUnknown();
5716
+ return;
5717
+ }
5718
+ const channelId = typeof req.channelId === "string" ? req.channelId : void 0;
5719
+ onLine = startHandoffConversation(conn, opts.handoff, channelId, writeLine, log2);
5720
+ return;
5721
+ }
5722
+ if (verb === "handoff.recover") {
5723
+ if (!opts.handoff) {
5724
+ closeUnknown();
5725
+ return;
5726
+ }
5727
+ onLine = startRecoverConversation(conn, opts.handoff, writeLine, log2);
5728
+ return;
5729
+ }
5688
5730
  closeUnknown();
5731
+ };
5732
+ conn.on("data", (chunk) => {
5733
+ buf += chunk;
5734
+ if (dispatched && !onLine) {
5735
+ ignoredBytes += chunk.length;
5736
+ if (ignoredBytes > 64 * 1024) {
5737
+ conn.destroy();
5738
+ return;
5739
+ }
5740
+ buf = "";
5741
+ return;
5742
+ }
5743
+ let nl;
5744
+ while ((nl = buf.indexOf("\n")) >= 0) {
5745
+ const line = buf.slice(0, nl);
5746
+ buf = buf.slice(nl + 1);
5747
+ if (!dispatched) {
5748
+ dispatched = true;
5749
+ dispatchFirst(line);
5750
+ } else if (onLine) {
5751
+ let obj;
5752
+ try {
5753
+ obj = JSON.parse(line);
5754
+ } catch {
5755
+ conn.end();
5756
+ return;
5757
+ }
5758
+ onLine(obj);
5759
+ } else {
5760
+ buf = "";
5761
+ break;
5762
+ }
5763
+ }
5764
+ if (!dispatched) {
5765
+ if (buf.length > 64 * 1024) {
5766
+ dispatched = true;
5767
+ closeUnknown();
5768
+ }
5769
+ } else if (onLine) {
5770
+ if (buf.length > 64 * 1024) {
5771
+ conn.destroy();
5772
+ return;
5773
+ }
5774
+ }
5689
5775
  });
5690
5776
  }
5691
5777
  function startAttachStream(conn, hub, channelId) {
@@ -5706,6 +5792,116 @@ function startAttachStream(conn, hub, channelId) {
5706
5792
  conn.on("error", cleanup);
5707
5793
  conn.on("end", cleanup);
5708
5794
  }
5795
+ function startHandoffConversation(conn, ctl, initialChannelId, writeLine, log2) {
5796
+ let phase = "selecting";
5797
+ let channelId = null;
5798
+ const fail = (error) => {
5799
+ phase = "closed";
5800
+ writeLine({ error });
5801
+ conn.end();
5802
+ };
5803
+ const doOffer = (id) => {
5804
+ const res = ctl.offer(id);
5805
+ if (!res.ok) {
5806
+ fail(res.error);
5807
+ return;
5808
+ }
5809
+ channelId = id;
5810
+ phase = "offered";
5811
+ writeLine({ handoff: "offer", channel: res.channel, provider: res.provider, pendingWork: res.pendingWork });
5812
+ };
5813
+ conn.on("close", () => {
5814
+ const p = phase;
5815
+ phase = "closed";
5816
+ if (p === "spec_sent") {
5817
+ ctl.orphan();
5818
+ return;
5819
+ }
5820
+ if (p === "beginning") {
5821
+ return;
5822
+ }
5823
+ });
5824
+ if (initialChannelId !== void 0) doOffer(initialChannelId);
5825
+ else writeLine({ handoff: "channels", channels: ctl.listChannels() });
5826
+ return (obj) => {
5827
+ const msg = obj;
5828
+ if (phase === "selecting" && msg["handoff"] === "select" && typeof msg["channelId"] === "string") {
5829
+ doOffer(msg["channelId"]);
5830
+ return;
5831
+ }
5832
+ if (phase === "offered" && msg["handoff"] === "decline") {
5833
+ phase = "closed";
5834
+ conn.end();
5835
+ return;
5836
+ }
5837
+ if (phase === "offered" && msg["handoff"] === "proceed" && channelId) {
5838
+ phase = "beginning";
5839
+ void ctl.begin(channelId).then((res) => {
5840
+ if (phase === "closed") {
5841
+ void ctl.abortAfterBegin();
5842
+ return;
5843
+ }
5844
+ if (!res.ok) {
5845
+ fail(res.error);
5846
+ return;
5847
+ }
5848
+ phase = "spec_sent";
5849
+ writeLine({ handoff: "spec", ...res.spec });
5850
+ }).catch((e) => {
5851
+ log2(`[braid] handoff begin failed: ${e.message}`);
5852
+ if (phase !== "closed") fail("begin_failed");
5853
+ });
5854
+ return;
5855
+ }
5856
+ if (phase === "spec_sent" && msg["handoff"] === "done") {
5857
+ phase = "resuming";
5858
+ void ctl.complete().then(() => {
5859
+ writeLine({ handoff: "resumed" });
5860
+ phase = "closed";
5861
+ conn.end();
5862
+ }).catch((e) => {
5863
+ log2(`[braid] handoff resume failed: ${e.message}`);
5864
+ try {
5865
+ conn.end();
5866
+ } catch {
5867
+ }
5868
+ });
5869
+ return;
5870
+ }
5871
+ fail("protocol_error");
5872
+ };
5873
+ }
5874
+ function startRecoverConversation(conn, ctl, writeLine, log2) {
5875
+ let offered = false;
5876
+ const res = ctl.recoverOffer();
5877
+ if (!res.ok) {
5878
+ writeLine({ error: res.error });
5879
+ conn.end();
5880
+ return () => {
5881
+ };
5882
+ }
5883
+ offered = true;
5884
+ writeLine({ recover: "offer", channel: res.channel });
5885
+ return (obj) => {
5886
+ const msg = obj;
5887
+ if (offered && msg["recover"] === "proceed") {
5888
+ offered = false;
5889
+ void ctl.recoverProceed().then(() => {
5890
+ writeLine({ recover: "resumed" });
5891
+ conn.end();
5892
+ }).catch((e) => {
5893
+ log2(`[braid] handoff resume failed: ${e.message}`);
5894
+ try {
5895
+ conn.end();
5896
+ } catch {
5897
+ }
5898
+ });
5899
+ return;
5900
+ }
5901
+ writeLine({ error: "protocol_error" });
5902
+ conn.end();
5903
+ };
5904
+ }
5709
5905
 
5710
5906
  // src/service/install.ts
5711
5907
  function validateServiceKey(name) {
@@ -5817,18 +6013,18 @@ function dedupe(names) {
5817
6013
  function isLegacyFamilyService(platform, name, homeDir) {
5818
6014
  if (platform === "darwin") {
5819
6015
  const dir = join6(homeDir, "Library", "LaunchAgents");
5820
- return !existsSync5(join6(dir, `com.snowyroad.braid.${name}.plist`)) && existsSync5(join6(dir, `dev.arp.bridge.${name}.plist`));
6016
+ return !existsSync4(join6(dir, `com.snowyroad.braid.${name}.plist`)) && existsSync4(join6(dir, `dev.arp.bridge.${name}.plist`));
5821
6017
  }
5822
6018
  if (platform === "linux") {
5823
6019
  const dir = join6(homeDir, ".config", "systemd", "user");
5824
- return !existsSync5(join6(dir, `braid-${name}.service`)) && existsSync5(join6(dir, `arp-bridge-${name}.service`));
6020
+ return !existsSync4(join6(dir, `braid-${name}.service`)) && existsSync4(join6(dir, `arp-bridge-${name}.service`));
5825
6021
  }
5826
6022
  return false;
5827
6023
  }
5828
6024
  function globNames(dir, pattern) {
5829
6025
  let entries = [];
5830
6026
  try {
5831
- entries = readdirSync4(dir);
6027
+ entries = readdirSync3(dir);
5832
6028
  } catch {
5833
6029
  return [];
5834
6030
  }
@@ -5903,12 +6099,12 @@ function readNeedsHumanMarker(name, braidDir) {
5903
6099
  function clearNeedsHumanMarker(name, braidDir) {
5904
6100
  try {
5905
6101
  const p = needsHumanMarkerPath(name, braidDir);
5906
- if (existsSync6(p)) unlinkSync2(p);
6102
+ if (existsSync5(p)) unlinkSync2(p);
5907
6103
  } catch {
5908
6104
  }
5909
6105
  try {
5910
6106
  const legacy = legacyTwinPath(braidDir, ["run", `${name}.needs-human`]);
5911
- if (legacy && existsSync6(legacy)) unlinkSync2(legacy);
6107
+ if (legacy && existsSync5(legacy)) unlinkSync2(legacy);
5912
6108
  } catch {
5913
6109
  }
5914
6110
  }
@@ -6004,6 +6200,174 @@ var ObservationHub = class {
6004
6200
  import { decodeJwt as decodeJwt2 } from "jose";
6005
6201
  import { fileURLToPath as fileURLToPath2 } from "url";
6006
6202
  import path3 from "path";
6203
+
6204
+ // src/observe/render.ts
6205
+ function channelTag(channelId) {
6206
+ return sanitizeForTty(channelId).slice(0, 8);
6207
+ }
6208
+ function renderEvent(ev) {
6209
+ switch (ev.kind) {
6210
+ case "activity": {
6211
+ const a = ev.event;
6212
+ const kind = a.kind ? sanitizeForTty(a.kind) : "tool";
6213
+ const status = a.status ? sanitizeForTty(a.status) : "?";
6214
+ const title = a.title ? sanitizeForTty(a.title) : "(untitled)";
6215
+ const loc = a.locations && a.locations.length > 0 ? " " + a.locations.map((l) => sanitizeForTty(l.path) + (l.line != null ? `:${l.line}` : "")).join(", ") : "";
6216
+ return `
6217
+ \xB7 [${kind}/${status}] ${title}${loc}`;
6218
+ }
6219
+ case "plan": {
6220
+ const entries = ev.snapshot.entries;
6221
+ const done = entries.filter((e) => e.status === "completed").length;
6222
+ const total = entries.length;
6223
+ const current = entries.find((e) => e.status === "in_progress");
6224
+ const head = `
6225
+ \u25B8 plan ${done}/${total}`;
6226
+ return current ? `${head} - ${sanitizeForTty(current.content)}` : head;
6227
+ }
6228
+ case "message": {
6229
+ const delta = sanitizeForTty(ev.delta);
6230
+ return delta.length === 0 ? null : delta;
6231
+ }
6232
+ case "reply": {
6233
+ const u = ev.usage;
6234
+ const ctx = u?.contextUsed != null && u?.contextSize != null ? ` ctx ${u.contextUsed}/${u.contextSize}` : "";
6235
+ const cost = u?.costUsed != null && u.costUsed > 0 ? ` cost ${u.costUsed}${u.costCurrency ? " " + sanitizeForTty(u.costCurrency) : ""}` : "";
6236
+ const chars = sanitizeForTty(ev.text).length;
6237
+ return `
6238
+ \u2192 posted ${chars} chars${ctx}${cost}`;
6239
+ }
6240
+ default: {
6241
+ const _exhaustive = ev;
6242
+ return null;
6243
+ }
6244
+ }
6245
+ }
6246
+
6247
+ // src/observe/socketHandoff.ts
6248
+ function makeSocketHandoffController(deps) {
6249
+ const specFor = deps.specFor ?? interactiveResumeSpecFor;
6250
+ const scopedLaunchFn = deps.scopedLaunchFn ?? scopedLaunch;
6251
+ const log2 = deps.log ?? (() => {
6252
+ });
6253
+ let active = null;
6254
+ const label = (channelId) => {
6255
+ const name = deps.channelName(channelId);
6256
+ const tag = channelTag(channelId);
6257
+ return name ? `#${sanitizeForTty(name).slice(0, 40)} (${tag})` : `#${tag}`;
6258
+ };
6259
+ const gateBlocked = () => {
6260
+ const g = deps.gate.snapshot();
6261
+ if (g.state === "active") return { ok: false, error: "handoff_active" };
6262
+ if (g.state === "orphaned") return { ok: false, error: "handoff_orphaned" };
6263
+ return null;
6264
+ };
6265
+ const preflight = (channelId) => {
6266
+ const blocked = gateBlocked();
6267
+ if (blocked) return blocked;
6268
+ 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)) };
6277
+ }
6278
+ if (!raw) return { ok: false, error: "provider_unverified" };
6279
+ return { ok: true, session, sessionId, spec: raw };
6280
+ };
6281
+ return {
6282
+ 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
+ }));
6289
+ },
6290
+ offer(channelId) {
6291
+ const pre = preflight(channelId);
6292
+ if (!pre.ok) return pre;
6293
+ return {
6294
+ ok: true,
6295
+ channel: { id: channelId, label: label(channelId) },
6296
+ provider: deps.provider,
6297
+ pendingWork: pre.session.hasPendingWork?.() === true
6298
+ };
6299
+ },
6300
+ async begin(channelId) {
6301
+ const pre = preflight(channelId);
6302
+ if (!pre.ok) return pre;
6303
+ if (!deps.gate.tryAcquire(channelId, "socket")) return gateBlocked() ?? { ok: false, error: "handoff_active" };
6304
+ let pauseAttempted = false;
6305
+ try {
6306
+ const scoped = await scopedLaunchFn({ command: pre.spec.command, args: pre.spec.args, cwd: pre.spec.cwd }, { scope: deps.scope }, deps.provider);
6307
+ pauseAttempted = true;
6308
+ await pre.session.handoffPause();
6309
+ active = { channelId, session: pre.session };
6310
+ log2(`[braid] handoff: paused ${label(channelId)} for a socket client`);
6311
+ return { ok: true, spec: { command: scoped.command, args: scoped.args, cwd: scoped.cwd, env: scoped.env ?? null } };
6312
+ } catch (e) {
6313
+ if (pauseAttempted) {
6314
+ try {
6315
+ await pre.session.handoffResume();
6316
+ } catch {
6317
+ }
6318
+ }
6319
+ deps.gate.release();
6320
+ return { ok: false, error: sanitizeForTty(String(e?.message ?? e)) };
6321
+ }
6322
+ },
6323
+ async abortAfterBegin() {
6324
+ if (!active) return;
6325
+ const a = active;
6326
+ active = null;
6327
+ try {
6328
+ await a.session.handoffResume();
6329
+ } finally {
6330
+ deps.gate.release();
6331
+ }
6332
+ log2(`[braid] handoff: client vanished before the spec was delivered \u2014 resumed ${label(a.channelId)}`);
6333
+ },
6334
+ async complete() {
6335
+ if (!active) return;
6336
+ const a = active;
6337
+ active = null;
6338
+ try {
6339
+ await a.session.handoffResume();
6340
+ } finally {
6341
+ deps.gate.release();
6342
+ }
6343
+ log2(`[braid] handoff: owner handed back \u2014 resumed ${label(a.channelId)}`);
6344
+ },
6345
+ orphan() {
6346
+ if (!active) return;
6347
+ deps.gate.markOrphaned();
6348
+ log2(`[braid] HANDOFF ORPHANED: the client vanished after receiving the session spec for ${label(active.channelId)}. The channel STAYS PAUSED (cannot prove the native session is closed). Recover with: braid handoff --recover (or restart this bridge)`);
6349
+ },
6350
+ recoverOffer() {
6351
+ const g = deps.gate.snapshot();
6352
+ if (g.state !== "orphaned") return { ok: false, error: "not_orphaned" };
6353
+ return { ok: true, channel: { id: g.channelId, label: label(g.channelId) } };
6354
+ },
6355
+ async recoverProceed() {
6356
+ if (!active || deps.gate.snapshot().state !== "orphaned") return;
6357
+ const a = active;
6358
+ active = null;
6359
+ try {
6360
+ await a.session.handoffResume();
6361
+ } finally {
6362
+ deps.gate.release();
6363
+ }
6364
+ log2(`[braid] handoff: recovery attested \u2014 resumed ${label(a.channelId)}`);
6365
+ },
6366
+ gateSnapshot: () => deps.gate.snapshot()
6367
+ };
6368
+ }
6369
+
6370
+ // src/bridge.ts
6007
6371
  var REFRESH_MARGIN_MS = 24 * 60 * 60 * 1e3;
6008
6372
  var MIN_DELAY_MS = 60 * 1e3;
6009
6373
  var JOIN_HISTORY_LIMIT = 20;
@@ -6040,6 +6404,7 @@ async function startBridge(cfg, relay, deps) {
6040
6404
  const tornDownWhilePending = /* @__PURE__ */ new Set();
6041
6405
  let selfCardUnsigned = null;
6042
6406
  let cardRefreshTimer = null;
6407
+ let stopCardContentRefresh = null;
6043
6408
  const WARMUP_LABEL_MS = 1e4;
6044
6409
  const WARMUP_HEARTBEAT_MS = 25e3;
6045
6410
  function scheduleCardRefresh(att) {
@@ -6128,6 +6493,19 @@ async function startBridge(cfg, relay, deps) {
6128
6493
  });
6129
6494
  }
6130
6495
  }
6496
+ if (cfg.agent === "claude-code") {
6497
+ stopCardContentRefresh?.();
6498
+ stopCardContentRefresh = startCardRefreshLoop(fingerprint, {
6499
+ computeFingerprint: () => computeCapabilityFingerprint(cfg),
6500
+ buildFromDisk: () => buildCardFromDisk(),
6501
+ setCard: onUnsigned,
6502
+ cacheSave,
6503
+ signCard: cfg.signCard,
6504
+ putAgentCard: (c, scope) => relay.putAgentCard(c, scope),
6505
+ agentName: cfg.agentName,
6506
+ log: (m) => console.warn(sanitizeForTty(m))
6507
+ });
6508
+ }
6131
6509
  } else {
6132
6510
  await buildAndPublishSelfCard(cfg, relay, { converseLocal: async () => "" }, onUnsigned, onAtt);
6133
6511
  }
@@ -6318,6 +6696,15 @@ async function startBridge(cfg, relay, deps) {
6318
6696
  logsPath: cs.logsPath,
6319
6697
  http: snapshotHttpMetrics()
6320
6698
  });
6699
+ const handoffCtl = cs.handoffGate ? makeSocketHandoffController({
6700
+ sessions,
6701
+ channelName: (id) => channelNames.get(id),
6702
+ provider: cfg.agent,
6703
+ cwdFor: (id) => handoffCwdFor(cfg, id),
6704
+ scope: cfg.scope,
6705
+ gate: cs.handoffGate,
6706
+ log: (line) => console.log(line)
6707
+ }) : void 0;
6321
6708
  try {
6322
6709
  const handle = await startControlSocket({
6323
6710
  sockPath: cs.sockPath,
@@ -6325,7 +6712,8 @@ async function startBridge(cfg, relay, deps) {
6325
6712
  getStatus: buildStatus,
6326
6713
  ownsLock: cs.ownsLock,
6327
6714
  log: (line) => console.log(line),
6328
- tokenPath: cs.tokenPath
6715
+ tokenPath: cs.tokenPath,
6716
+ ...handoffCtl ? { handoff: handoffCtl } : {}
6329
6717
  });
6330
6718
  controlSocketClose = handle.close;
6331
6719
  } catch (e) {
@@ -6520,6 +6908,10 @@ Commands:
6520
6908
  service atomically.
6521
6909
  attach [name] Watch a running service's live activity over the local control
6522
6910
  socket (same machine only).
6911
+ handoff [<agent>] [--channel <id-or-name>]
6912
+ take over the agent's live session on THIS terminal
6913
+ (quit the native UI to hand control back)
6914
+ handoff --recover resume a bridge left paused by a vanished handoff client
6523
6915
  version Print the installed braid version (also --version, -v)
6524
6916
 
6525
6917
  Options for join/start (each also has an env twin; flag > env > file > default):
@@ -6540,6 +6932,7 @@ Options for join/start (each also has an env twin; flag > env > file > default):
6540
6932
  --allow-write <path> widen sandbox write access (repeatable)
6541
6933
  --allow-read <path> widen sandbox read access (repeatable)
6542
6934
  --allow-domain <host> widen sandbox network egress (repeatable)
6935
+ --no-socket do not open the local control socket (on by default)
6543
6936
  At join, explicit choices persist to the saved agent; at start they apply to
6544
6937
  that run only.`;
6545
6938
  var SERVICE_VERBS = /* @__PURE__ */ new Set([
@@ -6631,6 +7024,30 @@ function parseCliArgs(argv) {
6631
7024
  const agent = argv[1] && !argv[1].startsWith("-") ? argv[1].trim() : void 0;
6632
7025
  return { kind: "attach", agent };
6633
7026
  }
7027
+ if (first === "handoff") {
7028
+ let agent;
7029
+ let channel;
7030
+ let recover = false;
7031
+ const rest = argv.slice(1);
7032
+ for (let i = 0; i < rest.length; i++) {
7033
+ const a = rest[i];
7034
+ if (a === "--recover") {
7035
+ recover = true;
7036
+ continue;
7037
+ }
7038
+ if (a === "--channel") {
7039
+ const v = rest[i + 1]?.trim();
7040
+ if (v === void 0 || v === "" || v.startsWith("--")) return { kind: "usage", error: "Missing value for --channel" };
7041
+ channel = v;
7042
+ i++;
7043
+ continue;
7044
+ }
7045
+ if (a.startsWith("-")) return { kind: "usage", error: `Unknown flag for handoff: ${a}` };
7046
+ if (agent !== void 0) return { kind: "usage", error: "handoff takes at most one agent name" };
7047
+ agent = a.trim();
7048
+ }
7049
+ return { kind: "handoff", agent, channel, recover };
7050
+ }
6634
7051
  if (first === "version" || first === "--version" || first === "-v") {
6635
7052
  return { kind: "version" };
6636
7053
  }
@@ -6808,6 +7225,52 @@ async function runEnroll(opts, io, env) {
6808
7225
  }
6809
7226
  }
6810
7227
 
7228
+ // src/agentList.ts
7229
+ var EMPTY_MESSAGE = "No saved agents. Run: npx @snowyroad/braid join <code>";
7230
+ function bareHost(relayUrl) {
7231
+ return relayUrl.replace(/^wss?:\/\//, "");
7232
+ }
7233
+ function padRight(s, width) {
7234
+ return s.length >= width ? s : s + " ".repeat(width - s.length);
7235
+ }
7236
+ function formatAgentList(entries) {
7237
+ if (entries.length === 0) return EMPTY_MESSAGE;
7238
+ const groups = /* @__PURE__ */ new Map();
7239
+ let nameWidth = "NAME".length;
7240
+ let toolsWidth = "TOOLS".length;
7241
+ for (const e of entries) {
7242
+ const host = sanitizeForTty(bareHost(e.agent.relayUrl));
7243
+ const name = sanitizeForTty(e.agent.agentName);
7244
+ const tools = toolModeLabel(e.agent.toolMode ?? "readonly");
7245
+ const id = sanitizeForTty(e.agent.agentUuid).slice(0, 8);
7246
+ nameWidth = Math.max(nameWidth, name.length);
7247
+ toolsWidth = Math.max(toolsWidth, tools.length);
7248
+ const rows = groups.get(host);
7249
+ if (rows) {
7250
+ rows.push({ name, tools, id });
7251
+ } else {
7252
+ groups.set(host, [{ name, tools, id }]);
7253
+ }
7254
+ }
7255
+ const header = ` ${padRight("NAME", nameWidth)} ${padRight("TOOLS", toolsWidth)} ID`;
7256
+ const lines = [];
7257
+ let relayCount = 0;
7258
+ for (const [host, rows] of groups) {
7259
+ relayCount++;
7260
+ lines.push("");
7261
+ lines.push(host);
7262
+ lines.push(header);
7263
+ for (const row of rows) {
7264
+ lines.push(` ${padRight(row.name, nameWidth)} ${padRight(row.tools, toolsWidth)} ${row.id}`);
7265
+ }
7266
+ }
7267
+ lines.push("");
7268
+ const agentWord = entries.length === 1 ? "agent" : "agents";
7269
+ const relayWord = relayCount === 1 ? "relay" : "relays";
7270
+ lines.push(`${entries.length} ${agentWord} across ${relayCount} ${relayWord}.`);
7271
+ return lines.join("\n");
7272
+ }
7273
+
6811
7274
  // src/connectedBanner.ts
6812
7275
  function connectedBannerLines(opts) {
6813
7276
  const lines = [
@@ -6837,51 +7300,6 @@ function connectedBannerLines(opts) {
6837
7300
  // src/observe/attach.ts
6838
7301
  import * as readline from "readline";
6839
7302
  import * as net2 from "net";
6840
-
6841
- // src/observe/render.ts
6842
- function channelTag(channelId) {
6843
- return sanitizeForTty(channelId).slice(0, 8);
6844
- }
6845
- function renderEvent(ev) {
6846
- switch (ev.kind) {
6847
- case "activity": {
6848
- const a = ev.event;
6849
- const kind = a.kind ? sanitizeForTty(a.kind) : "tool";
6850
- const status = a.status ? sanitizeForTty(a.status) : "?";
6851
- const title = a.title ? sanitizeForTty(a.title) : "(untitled)";
6852
- const loc = a.locations && a.locations.length > 0 ? " " + a.locations.map((l) => sanitizeForTty(l.path) + (l.line != null ? `:${l.line}` : "")).join(", ") : "";
6853
- return `
6854
- \xB7 [${kind}/${status}] ${title}${loc}`;
6855
- }
6856
- case "plan": {
6857
- const entries = ev.snapshot.entries;
6858
- const done = entries.filter((e) => e.status === "completed").length;
6859
- const total = entries.length;
6860
- const current = entries.find((e) => e.status === "in_progress");
6861
- const head = `
6862
- \u25B8 plan ${done}/${total}`;
6863
- return current ? `${head} - ${sanitizeForTty(current.content)}` : head;
6864
- }
6865
- case "message": {
6866
- const delta = sanitizeForTty(ev.delta);
6867
- return delta.length === 0 ? null : delta;
6868
- }
6869
- case "reply": {
6870
- const u = ev.usage;
6871
- const ctx = u?.contextUsed != null && u?.contextSize != null ? ` ctx ${u.contextUsed}/${u.contextSize}` : "";
6872
- const cost = u?.costUsed != null && u.costUsed > 0 ? ` cost ${u.costUsed}${u.costCurrency ? " " + sanitizeForTty(u.costCurrency) : ""}` : "";
6873
- const chars = sanitizeForTty(ev.text).length;
6874
- return `
6875
- \u2192 posted ${chars} chars${ctx}${cost}`;
6876
- }
6877
- default: {
6878
- const _exhaustive = ev;
6879
- return null;
6880
- }
6881
- }
6882
- }
6883
-
6884
- // src/observe/attach.ts
6885
7303
  var MAX_PICKER = 9;
6886
7304
  function createAttachController(deps) {
6887
7305
  const now = deps.now ?? (() => Date.now());
@@ -7208,6 +7626,355 @@ async function runHandoff(opts) {
7208
7626
  }
7209
7627
  }
7210
7628
 
7629
+ // src/service/handoffClient.ts
7630
+ import * as net3 from "net";
7631
+ import { createInterface } from "readline/promises";
7632
+ import { spawn as nodeSpawn2 } from "child_process";
7633
+ var YES = /^\s*(y|yes)\s*$/i;
7634
+ async function runHandoffCommand(opts) {
7635
+ const env = opts.env ?? process.env;
7636
+ const stdin = opts.stdin ?? process.stdin;
7637
+ const stdout = opts.stdout ?? process.stdout;
7638
+ const write = (s) => stdout.write(s);
7639
+ const connectFn = opts.connectFn ?? ((p) => net3.createConnection(p));
7640
+ const spawnFn = opts.spawnFn ?? nodeSpawn2;
7641
+ const promptFn = opts.promptFn ?? ((q) => defaultPrompt(stdin, stdout, q));
7642
+ if (!stdin.isTTY || !stdout.isTTY) {
7643
+ write("[handoff] an interactive terminal is required (no --yes exists, by design)\n");
7644
+ return 1;
7645
+ }
7646
+ const braidDir = configDir(env);
7647
+ const names = listAgents(braidDir).map((e) => e.agent.agentName);
7648
+ let name;
7649
+ if (opts.agent && opts.agent.trim() !== "") {
7650
+ const want = opts.agent.trim();
7651
+ if (!names.includes(want)) {
7652
+ const enrolled = names.length ? names.map(sanitizeForTty).join(", ") : "(none)";
7653
+ write(`[handoff] no agent named "${sanitizeForTty(want)}" is enrolled. Enrolled: ${enrolled}
7654
+ `);
7655
+ return 1;
7656
+ }
7657
+ name = want;
7658
+ } else if (names.length === 1) {
7659
+ name = names[0];
7660
+ } else if (names.length === 0) {
7661
+ write("[handoff] no agents are enrolled - run: braid join <code>\n");
7662
+ return 1;
7663
+ } else {
7664
+ write(`[handoff] multiple agents enrolled (${names.map(sanitizeForTty).join(", ")}) - specify one: braid handoff <name>
7665
+ `);
7666
+ return 1;
7667
+ }
7668
+ const safeName = sanitizeForTty(name);
7669
+ const sockPath = await liveControlSocketPath(name, braidDir);
7670
+ if (!await socketLive(sockPath)) {
7671
+ write(`[handoff] ${safeName} isn't running - start it with: braid start ${safeName}
7672
+ `);
7673
+ return 1;
7674
+ }
7675
+ const token = readControlToken(name, braidDir);
7676
+ const socket = connectFn(sockPath);
7677
+ const lc = lineClient(socket);
7678
+ try {
7679
+ return opts.recover ? await runRecover(lc, { write, promptFn, token, name: safeName }) : await runTakeover(lc, { write, promptFn, token, name: safeName, env, spawnFn, channel: opts.channel });
7680
+ } catch (err) {
7681
+ write(`[handoff] control socket error: ${sanitizeForTty(String(err?.message ?? err))}
7682
+ `);
7683
+ return 1;
7684
+ } finally {
7685
+ try {
7686
+ socket.destroy();
7687
+ } catch {
7688
+ }
7689
+ }
7690
+ }
7691
+ async function runTakeover(lc, ctx) {
7692
+ const { write, promptFn, token, name, env, spawnFn, channel } = ctx;
7693
+ lc.send({ verb: "handoff", ...token ? { token } : {} });
7694
+ const first = await lc.next();
7695
+ if (typeof first["error"] === "string") return reportError(write, first["error"], name);
7696
+ const channels = Array.isArray(first["channels"]) ? first["channels"] : [];
7697
+ const picked = await pickChannel(channels, channel, promptFn, write);
7698
+ if (!picked) return 1;
7699
+ lc.send({ handoff: "select", channelId: picked.id });
7700
+ const offer = await lc.next();
7701
+ if (typeof offer["error"] === "string") return reportError(write, offer["error"], name);
7702
+ renderOffer(write, offer);
7703
+ const ans = await promptFn("take control now? [y/N] ");
7704
+ if (!YES.test(ans)) {
7705
+ lc.send({ handoff: "decline" });
7706
+ await lc.drained(500);
7707
+ write("[handoff] declined - the bridge stays in control\n");
7708
+ return 0;
7709
+ }
7710
+ lc.send({ handoff: "proceed" });
7711
+ const specLine = await lc.next();
7712
+ if (typeof specLine["error"] === "string") return reportError(write, specLine["error"], name);
7713
+ const sendDone = async () => {
7714
+ lc.send({ handoff: "done" });
7715
+ try {
7716
+ await lc.next();
7717
+ } catch {
7718
+ }
7719
+ };
7720
+ const spec = validateSpec(specLine);
7721
+ if (!spec.ok) {
7722
+ await sendDone();
7723
+ write(`[handoff] malformed session spec from the bridge: ${sanitizeForTty(spec.error)}
7724
+ `);
7725
+ return 1;
7726
+ }
7727
+ const outcome = await new Promise((resolve2) => {
7728
+ let settled = false;
7729
+ const settle = (o) => {
7730
+ if (!settled) {
7731
+ settled = true;
7732
+ resolve2(o);
7733
+ }
7734
+ };
7735
+ try {
7736
+ const child = spawnFn(spec.command, spec.args, {
7737
+ stdio: "inherit",
7738
+ cwd: spec.cwd,
7739
+ env: buildAcpEnv(env, spec.env ?? void 0)
7740
+ });
7741
+ child.on("exit", () => settle({}));
7742
+ child.on("error", (e) => settle({ error: e }));
7743
+ } catch (e) {
7744
+ settle({ error: e });
7745
+ }
7746
+ });
7747
+ await sendDone();
7748
+ if (outcome.error) {
7749
+ write(`[handoff] failed to open the session: ${sanitizeForTty(String(outcome.error.message ?? outcome.error))}
7750
+ `);
7751
+ return 1;
7752
+ }
7753
+ write("[handoff] control handed back - the bridge is driving again\n");
7754
+ return 0;
7755
+ }
7756
+ function validateSpec(line) {
7757
+ const command = line["command"];
7758
+ if (typeof command !== "string" || command.trim() === "") return { ok: false, error: "command is not a non-empty string" };
7759
+ const cwd = line["cwd"];
7760
+ if (typeof cwd !== "string") return { ok: false, error: "cwd is not a string" };
7761
+ const args = line["args"];
7762
+ if (!Array.isArray(args) || args.some((a) => typeof a !== "string")) return { ok: false, error: "args is not an array of strings" };
7763
+ const env = line["env"] ?? null;
7764
+ if (env !== null) {
7765
+ if (typeof env !== "object" || Array.isArray(env) || Object.values(env).some((v) => typeof v !== "string")) {
7766
+ return { ok: false, error: "env is not a string-valued record or null" };
7767
+ }
7768
+ }
7769
+ return { ok: true, command, args, cwd, env };
7770
+ }
7771
+ async function pickChannel(channels, filter, promptFn, write) {
7772
+ if (channels.length === 0) {
7773
+ write("[handoff] no channels are available to take over\n");
7774
+ return null;
7775
+ }
7776
+ if (filter && filter.trim() !== "") {
7777
+ const f = filter.trim();
7778
+ const exact = channels.filter((c) => c.id === f);
7779
+ if (exact.length === 1) return exact[0];
7780
+ const matches = channels.filter((c) => c.id.startsWith(f) || c.label.includes(f));
7781
+ if (matches.length === 1) return matches[0];
7782
+ write(`[handoff] "${sanitizeForTty(f)}" ${matches.length === 0 ? "matched no channel" : `is ambiguous (${matches.length} channels)`} - available:
7783
+ `);
7784
+ listChannels(write, channels);
7785
+ return null;
7786
+ }
7787
+ if (channels.length === 1) return channels[0];
7788
+ write("[handoff] which channel do you want to take over?\n");
7789
+ listChannels(write, channels);
7790
+ const ans = (await promptFn(`select a channel [1-${channels.length}]: `)).trim();
7791
+ const idx = Number.parseInt(ans, 10);
7792
+ if (!Number.isInteger(idx) || idx < 1 || idx > channels.length) {
7793
+ write("[handoff] not a valid selection\n");
7794
+ return null;
7795
+ }
7796
+ return channels[idx - 1];
7797
+ }
7798
+ function listChannels(write, channels) {
7799
+ channels.forEach((c, i) => {
7800
+ write(` [${i + 1}] ${sanitizeForTty(c.label)}${c.pendingWork ? " (working)" : ""}
7801
+ `);
7802
+ });
7803
+ }
7804
+ function renderOffer(write, offer) {
7805
+ const channel = offer["channel"] ?? {};
7806
+ const label = sanitizeForTty(String(channel.label ?? ""));
7807
+ const pendingWork = offer["pendingWork"] === true;
7808
+ write(
7809
+ banner("handoff", `TAKING CONTROL of ${label}`, [
7810
+ "pausing the bridge and opening the agent's REAL interactive session",
7811
+ "on this terminal. Quit it (Ctrl-C or /exit) to hand control back.",
7812
+ "",
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
+ ] : []
7821
+ ])
7822
+ );
7823
+ }
7824
+ async function runRecover(lc, ctx) {
7825
+ const { write, promptFn, token, name } = ctx;
7826
+ lc.send({ verb: "handoff.recover", ...token ? { token } : {} });
7827
+ const first = await lc.next();
7828
+ if (typeof first["error"] === "string") {
7829
+ if (first["error"] === "not_orphaned") {
7830
+ write("[handoff] nothing to recover - no orphaned handoff\n");
7831
+ return 0;
7832
+ }
7833
+ return reportError(write, first["error"], name);
7834
+ }
7835
+ const channel = first["channel"] ?? {};
7836
+ write(`[handoff] a previous handoff of ${sanitizeForTty(String(channel.label ?? ""))} was orphaned and the channel is PAUSED.
7837
+ `);
7838
+ const ans = await promptFn("Is the interactive session fully closed? [y/N] ");
7839
+ if (!YES.test(ans)) {
7840
+ write("[handoff] recovery aborted - nothing changed\n");
7841
+ return 0;
7842
+ }
7843
+ lc.send({ recover: "proceed" });
7844
+ try {
7845
+ await lc.next();
7846
+ } catch {
7847
+ }
7848
+ write("[handoff] recovery confirmed - the bridge is driving again\n");
7849
+ return 0;
7850
+ }
7851
+ function reportError(write, code, agent) {
7852
+ write(`[handoff] ${codeToMessage(code, agent)}
7853
+ `);
7854
+ return 1;
7855
+ }
7856
+ function codeToMessage(code, agent) {
7857
+ switch (code) {
7858
+ case "handoff_active":
7859
+ return "another handoff is already in progress";
7860
+ case "handoff_orphaned":
7861
+ return `a previous handoff was orphaned - run: braid handoff ${agent} --recover`;
7862
+ case "provider_unverified":
7863
+ return "not available for this provider yet (verified for Claude Code, Codex, Grok, and opencode so far)";
7864
+ case "no_live_session":
7865
+ return "no live session yet for that channel";
7866
+ case "unknown_channel":
7867
+ return "that channel has no active session";
7868
+ default:
7869
+ return sanitizeForTty(code);
7870
+ }
7871
+ }
7872
+ function lineClient(sock) {
7873
+ sock.setEncoding("utf8");
7874
+ let buf = "";
7875
+ const queue = [];
7876
+ let closed = false;
7877
+ let sockErr = null;
7878
+ let wake = null;
7879
+ const bump = () => {
7880
+ const w = wake;
7881
+ wake = null;
7882
+ w?.();
7883
+ };
7884
+ sock.on("data", (d) => {
7885
+ buf += d;
7886
+ let nl;
7887
+ while ((nl = buf.indexOf("\n")) >= 0) {
7888
+ queue.push(buf.slice(0, nl));
7889
+ buf = buf.slice(nl + 1);
7890
+ }
7891
+ bump();
7892
+ });
7893
+ let onClose = null;
7894
+ sock.on("close", () => {
7895
+ closed = true;
7896
+ onClose?.();
7897
+ bump();
7898
+ });
7899
+ sock.on("error", (e) => {
7900
+ sockErr = e;
7901
+ bump();
7902
+ });
7903
+ return {
7904
+ send(obj) {
7905
+ try {
7906
+ sock.write(JSON.stringify(obj) + "\n");
7907
+ } catch {
7908
+ }
7909
+ },
7910
+ drained(timeoutMs) {
7911
+ if (closed) return Promise.resolve();
7912
+ return new Promise((resolve2) => {
7913
+ const t = setTimeout(() => {
7914
+ onClose = null;
7915
+ resolve2();
7916
+ }, timeoutMs);
7917
+ if (typeof t.unref === "function") t.unref();
7918
+ onClose = () => {
7919
+ clearTimeout(t);
7920
+ resolve2();
7921
+ };
7922
+ });
7923
+ },
7924
+ async next() {
7925
+ for (; ; ) {
7926
+ if (queue.length > 0) {
7927
+ const line = queue.shift();
7928
+ try {
7929
+ return JSON.parse(line);
7930
+ } catch {
7931
+ throw new Error("control socket sent a malformed line");
7932
+ }
7933
+ }
7934
+ if (sockErr) throw sockErr;
7935
+ if (closed) throw new Error("control socket closed before a response");
7936
+ await new Promise((r) => {
7937
+ wake = r;
7938
+ });
7939
+ }
7940
+ }
7941
+ };
7942
+ }
7943
+ async function defaultPrompt(stdin, stdout, question) {
7944
+ const rl = createInterface({
7945
+ input: stdin,
7946
+ output: stdout
7947
+ });
7948
+ try {
7949
+ return await rl.question(question);
7950
+ } finally {
7951
+ rl.close();
7952
+ }
7953
+ }
7954
+
7955
+ // src/observe/handoffGate.ts
7956
+ var HandoffGate = class {
7957
+ st = { state: "idle" };
7958
+ /** Enter the critical section. False = another handoff is active or orphaned. */
7959
+ tryAcquire(channelId, via) {
7960
+ if (this.st.state !== "idle") return false;
7961
+ this.st = { state: "active", channelId, via };
7962
+ return true;
7963
+ }
7964
+ /** Leave the critical section (normal completion, pre-spec abort, or attested recovery). */
7965
+ release() {
7966
+ this.st = { state: "idle" };
7967
+ }
7968
+ /** Socket client vanished after the spec was sent: fail closed, stay paused. */
7969
+ markOrphaned() {
7970
+ if (this.st.state !== "active") return;
7971
+ this.st = { state: "orphaned", channelId: this.st.channelId };
7972
+ }
7973
+ snapshot() {
7974
+ return { ...this.st };
7975
+ }
7976
+ };
7977
+
7211
7978
  // src/scope/gate.ts
7212
7979
  function scopeStartupDecision(avail, toolMode, scopeOff, installerAvailable, platform = process.platform) {
7213
7980
  if (scopeOff) {
@@ -7269,8 +8036,8 @@ async function maybeOfferSandboxInstall(deps) {
7269
8036
  }
7270
8037
  function readlinePromptYesNo(input, output) {
7271
8038
  return import("readline").then(
7272
- ({ createInterface }) => new Promise((resolve2) => {
7273
- const rl = createInterface({ input, output });
8039
+ ({ createInterface: createInterface2 }) => new Promise((resolve2) => {
8040
+ const rl = createInterface2({ input, output });
7274
8041
  rl.once("line", (line) => {
7275
8042
  resolve2(line.trim().toLowerCase() === "y");
7276
8043
  rl.close();
@@ -7613,7 +8380,7 @@ import { execFile } from "child_process";
7613
8380
  import { homedir as homedir6 } from "os";
7614
8381
 
7615
8382
  // src/service/manager.ts
7616
- import { mkdirSync as mkdirSync7, writeFileSync as writeFileSync7, rmSync as rmSync2, existsSync as existsSync7 } from "fs";
8383
+ import { mkdirSync as mkdirSync7, writeFileSync as writeFileSync7, rmSync as rmSync2, existsSync as existsSync6 } from "fs";
7617
8384
  import { join as join9 } from "path";
7618
8385
 
7619
8386
  // src/service/templates.ts
@@ -7785,7 +8552,7 @@ function schtasksXml(p) {
7785
8552
  }
7786
8553
 
7787
8554
  // src/service/win32.ts
7788
- import { mkdirSync as mkdirSync6, writeFileSync as writeFileSync6, readdirSync as readdirSync5, readFileSync as readFileSync7, unlinkSync as unlinkSync3 } from "fs";
8555
+ import { mkdirSync as mkdirSync6, writeFileSync as writeFileSync6, readdirSync as readdirSync4, readFileSync as readFileSync7, unlinkSync as unlinkSync3 } from "fs";
7789
8556
  import { join as join8 } from "path";
7790
8557
  function win32LogDir(homeDir) {
7791
8558
  return join8(homeDir, "AppData", "Local", "braid", "logs");
@@ -7811,7 +8578,7 @@ function win32Manager(ctx) {
7811
8578
  const hostDirs = [];
7812
8579
  for (const agentsDir of agentDirs) {
7813
8580
  try {
7814
- for (const host of readdirSync5(agentsDir)) hostDirs.push(join8(agentsDir, host));
8581
+ for (const host of readdirSync4(agentsDir)) hostDirs.push(join8(agentsDir, host));
7815
8582
  } catch {
7816
8583
  }
7817
8584
  }
@@ -7944,7 +8711,7 @@ function darwinManager(ctx) {
7944
8711
  const legacyLogDir = join9(ctx.homeDir, "Library", "Logs", "arp-bridge");
7945
8712
  const plistPath = (name) => join9(launchAgentsDir, `${launchdLabel(name)}.plist`);
7946
8713
  const legacyPlistPath = (name) => join9(launchAgentsDir, `${legacyLaunchdLabel(name)}.plist`);
7947
- const legacyInstalled = (name) => !existsSync7(plistPath(name)) && existsSync7(legacyPlistPath(name));
8714
+ const legacyInstalled = (name) => !existsSync6(plistPath(name)) && existsSync6(legacyPlistPath(name));
7948
8715
  const domainTarget = `gui/${ctx.uid}`;
7949
8716
  const serviceTarget = (name) => `${domainTarget}/${legacyInstalled(name) ? legacyLaunchdLabel(name) : launchdLabel(name)}`;
7950
8717
  const write = (name) => {
@@ -7981,7 +8748,7 @@ function darwinManager(ctx) {
7981
8748
  }
7982
8749
  },
7983
8750
  async start(name) {
7984
- if (!existsSync7(plistPath(name)) && !existsSync7(legacyPlistPath(name))) write(name);
8751
+ if (!existsSync6(plistPath(name)) && !existsSync6(legacyPlistPath(name))) write(name);
7985
8752
  await ctx.exec("launchctl", [
7986
8753
  "bootstrap",
7987
8754
  domainTarget,
@@ -7995,7 +8762,7 @@ function darwinManager(ctx) {
7995
8762
  await ctx.exec("launchctl", ["kickstart", "-k", serviceTarget(name)]);
7996
8763
  },
7997
8764
  async status(name) {
7998
- const installed = existsSync7(plistPath(name)) || existsSync7(legacyPlistPath(name));
8765
+ const installed = existsSync6(plistPath(name)) || existsSync6(legacyPlistPath(name));
7999
8766
  if (!installed) return { installed: false, loaded: false, running: false };
8000
8767
  const res = await ctx.exec("launchctl", ["print", serviceTarget(name)]);
8001
8768
  const loaded = res.code === 0;
@@ -8004,7 +8771,7 @@ function darwinManager(ctx) {
8004
8771
  installed,
8005
8772
  loaded,
8006
8773
  running,
8007
- staleNodePath: !existsSync7(ctx.nodePath)
8774
+ staleNodePath: !existsSync6(ctx.nodePath)
8008
8775
  };
8009
8776
  },
8010
8777
  logsPath(name) {
@@ -8018,7 +8785,7 @@ function linuxManager(ctx) {
8018
8785
  const legacyUnitName = (name) => `arp-bridge-${name}.service`;
8019
8786
  const newUnitPath = (name) => join9(unitDir, newUnitName(name));
8020
8787
  const legacyUnitPath = (name) => join9(unitDir, legacyUnitName(name));
8021
- const legacyInstalled = (name) => !existsSync7(newUnitPath(name)) && existsSync7(legacyUnitPath(name));
8788
+ const legacyInstalled = (name) => !existsSync6(newUnitPath(name)) && existsSync6(legacyUnitPath(name));
8022
8789
  const unitName = (name) => legacyInstalled(name) ? legacyUnitName(name) : newUnitName(name);
8023
8790
  const unitPath = (name) => legacyInstalled(name) ? legacyUnitPath(name) : newUnitPath(name);
8024
8791
  const sc = (...args) => ctx.exec("systemctl", ["--user", ...args]);
@@ -8035,7 +8802,7 @@ function linuxManager(ctx) {
8035
8802
  async install(name) {
8036
8803
  write(name);
8037
8804
  await sc("daemon-reload");
8038
- if (existsSync7(legacyUnitPath(name))) {
8805
+ if (existsSync6(legacyUnitPath(name))) {
8039
8806
  await sc("disable", "--now", legacyUnitName(name));
8040
8807
  try {
8041
8808
  rmSync2(legacyUnitPath(name), { force: true });
@@ -8047,7 +8814,7 @@ function linuxManager(ctx) {
8047
8814
  },
8048
8815
  async uninstall(name) {
8049
8816
  await sc("disable", "--now", newUnitName(name));
8050
- if (existsSync7(legacyUnitPath(name))) {
8817
+ if (existsSync6(legacyUnitPath(name))) {
8051
8818
  await sc("disable", "--now", legacyUnitName(name));
8052
8819
  }
8053
8820
  try {
@@ -8061,7 +8828,7 @@ function linuxManager(ctx) {
8061
8828
  await sc("daemon-reload");
8062
8829
  },
8063
8830
  async start(name) {
8064
- if (!existsSync7(unitPath(name))) {
8831
+ if (!existsSync6(unitPath(name))) {
8065
8832
  write(name);
8066
8833
  await sc("daemon-reload");
8067
8834
  }
@@ -8074,7 +8841,7 @@ function linuxManager(ctx) {
8074
8841
  await sc("restart", unitName(name));
8075
8842
  },
8076
8843
  async status(name) {
8077
- const installed = existsSync7(unitPath(name));
8844
+ const installed = existsSync6(unitPath(name));
8078
8845
  if (!installed) return { installed: false, loaded: false, running: false };
8079
8846
  const active = await sc("is-active", unitName(name));
8080
8847
  const enabled = await sc("is-enabled", unitName(name));
@@ -8084,7 +8851,7 @@ function linuxManager(ctx) {
8084
8851
  installed,
8085
8852
  loaded,
8086
8853
  running,
8087
- staleNodePath: !existsSync7(ctx.nodePath)
8854
+ staleNodePath: !existsSync6(ctx.nodePath)
8088
8855
  };
8089
8856
  },
8090
8857
  logsPath(name) {
@@ -8303,7 +9070,7 @@ import { dirname as dirname6, join as join10 } from "path";
8303
9070
 
8304
9071
  // src/service/logRotation.ts
8305
9072
  import {
8306
- existsSync as existsSync8,
9073
+ existsSync as existsSync7,
8307
9074
  statSync as statSync3,
8308
9075
  renameSync as renameSync4,
8309
9076
  rmSync as rmSync3,
@@ -8317,7 +9084,7 @@ function rotateLogFileIfNeeded(logPath, opts = {}) {
8317
9084
  const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
8318
9085
  const keepCount = opts.keepCount ?? DEFAULT_KEEP_COUNT;
8319
9086
  try {
8320
- if (!existsSync8(logPath)) return;
9087
+ if (!existsSync7(logPath)) return;
8321
9088
  let size;
8322
9089
  try {
8323
9090
  size = statSync3(logPath).size;
@@ -8327,12 +9094,12 @@ function rotateLogFileIfNeeded(logPath, opts = {}) {
8327
9094
  if (size <= maxBytes) return;
8328
9095
  const gz = (n) => `${logPath}.${n}.gz`;
8329
9096
  try {
8330
- if (existsSync8(gz(keepCount))) rmSync3(gz(keepCount), { force: true });
9097
+ if (existsSync7(gz(keepCount))) rmSync3(gz(keepCount), { force: true });
8331
9098
  } catch {
8332
9099
  }
8333
9100
  for (let n = keepCount - 1; n >= 1; n--) {
8334
9101
  try {
8335
- if (existsSync8(gz(n))) renameSync4(gz(n), gz(n + 1));
9102
+ if (existsSync7(gz(n))) renameSync4(gz(n), gz(n + 1));
8336
9103
  } catch {
8337
9104
  }
8338
9105
  }
@@ -8442,16 +9209,7 @@ function installTee(outWriter, errWriter) {
8442
9209
  // src/cli.ts
8443
9210
  import { homedir as homedir7 } from "os";
8444
9211
  function printList() {
8445
- const entries = listAgents(configDir(process.env));
8446
- if (entries.length === 0) {
8447
- console.log("No saved agents. Run: npx @snowyroad/braid join <code>");
8448
- return;
8449
- }
8450
- for (const e of entries) {
8451
- console.log(
8452
- `${sanitizeForTty(e.agent.agentName)} relay=${e.agent.relayUrl} uuid=${sanitizeForTty(e.agent.agentUuid)} tools=${toolModeLabel(e.agent.toolMode ?? "readonly")}`
8453
- );
8454
- }
9212
+ console.log(formatAgentList(listAgents(configDir(process.env))));
8455
9213
  }
8456
9214
  function printStatus(agentName, json) {
8457
9215
  const dir = configDir(process.env);
@@ -8688,6 +9446,13 @@ async function main() {
8688
9446
  if (invocation.kind === "attach") {
8689
9447
  process.exit(await runAttach(invocation.agent));
8690
9448
  }
9449
+ if (invocation.kind === "handoff") {
9450
+ process.exit(await runHandoffCommand({
9451
+ agent: invocation.agent,
9452
+ channel: invocation.channel,
9453
+ recover: invocation.recover
9454
+ }));
9455
+ }
8691
9456
  const cfg = await resolveConfig(invocation.argv, process.env);
8692
9457
  resolvedAgentName = cfg.agentName ?? "";
8693
9458
  {
@@ -8767,8 +9532,8 @@ async function main() {
8767
9532
  // operator types at, so passing it to rl.question (rather than pre-writing it)
8768
9533
  // means readline keeps it on screen instead of clearing it (BL-178).
8769
9534
  ask: async (prompt) => {
8770
- const { createInterface } = await import("readline/promises");
8771
- const rl = createInterface({ input: process.stdin, output: process.stderr });
9535
+ const { createInterface: createInterface2 } = await import("readline/promises");
9536
+ const rl = createInterface2({ input: process.stdin, output: process.stderr });
8772
9537
  let cancelled = false;
8773
9538
  rl.on("SIGINT", () => {
8774
9539
  cancelled = true;
@@ -8879,7 +9644,8 @@ async function main() {
8879
9644
  console.log("[braid] connecting to the relay...");
8880
9645
  const keysLive = attachEligible(process.stdin, process.stdout, process.env);
8881
9646
  const serviceEligible = process.env.BRAID_SERVICE !== "1" && !!cfg.agentName;
8882
- const wantSocket = process.env.BRAID_SERVICE === "1" || invocation.argv.includes("--socket");
9647
+ const wantSocket = process.env.BRAID_SERVICE === "1" || !invocation.argv.includes("--no-socket");
9648
+ const handoffGate = new HandoffGate();
8883
9649
  let controlSocketCfg;
8884
9650
  if (wantSocket && cfg.agentName) {
8885
9651
  const braidDir = configDir(process.env);
@@ -8894,7 +9660,8 @@ async function main() {
8894
9660
  ownsLock: () => true,
8895
9661
  logsPath,
8896
9662
  blockedEndpoints: blockedEndpointHosts,
8897
- tokenPath: controlTokenPath(cfg.agentName, braidDir)
9663
+ tokenPath: controlTokenPath(cfg.agentName, braidDir),
9664
+ handoffGate
8898
9665
  };
8899
9666
  }
8900
9667
  const bridge = await createAndStartBridge(cfg, {
@@ -8955,7 +9722,17 @@ async function main() {
8955
9722
  out.write("\n[handoff] that session is no longer active\n");
8956
9723
  return;
8957
9724
  }
8958
- await runHandoff({ channelId, provider: cfg.agent, session, cwd: handoffCwdFor(cfg, channelId), out, channelLabel: channelLabel(channelId), scope: cfg.scope });
9725
+ if (!handoffGate.tryAcquire(channelId, "terminal")) {
9726
+ const g = handoffGate.snapshot();
9727
+ out.write(g.state === "orphaned" ? `
9728
+ [handoff] a previous handoff was orphaned - run: braid handoff ${cfg.agentName ?? ""} --recover`.trimEnd() + "\n" : "\n[handoff] another handoff is already in progress\n");
9729
+ return;
9730
+ }
9731
+ try {
9732
+ await runHandoff({ channelId, provider: cfg.agent, session, cwd: handoffCwdFor(cfg, channelId), out, channelLabel: channelLabel(channelId), scope: cfg.scope });
9733
+ } finally {
9734
+ handoffGate.release();
9735
+ }
8959
9736
  }
8960
9737
  });
8961
9738
  }