@scp-dev/broker 0.1.1 → 0.1.3

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/index.d.ts CHANGED
@@ -10,6 +10,10 @@ interface Hello {
10
10
  tty?: boolean;
11
11
  /** a hook's connection: it claims like an agent but never counts as connected, and posts only event and status */
12
12
  transient?: boolean;
13
+ /** with `transient`: the session joins the relay on demand, taking `handle` when given */
14
+ join?: {
15
+ handle?: string;
16
+ };
13
17
  /** a one-shot command: the broker announces no presence change when it connects or goes */
14
18
  brief?: boolean;
15
19
  /** no identity: list, status, watch, log and policy only */
@@ -217,11 +221,15 @@ interface Bridge {
217
221
  changed(endpoint: string): void;
218
222
  /** rules, caps, groups or a title changed, which can touch several endpoints: every mirrored endpoint is sent again */
219
223
  resync(): void;
224
+ /** a local endpoint took another handle: the hub renames its mirror in place, keeping what queued for it */
225
+ renamed(endpoint: string, was: string): void;
220
226
  /** a local endpoint's activity, posted to the hub */
221
227
  activity(endpoint: string, row: ActivityRow): void;
222
228
  /** the endpoints of other accounts this account's contacts let it reach */
223
229
  contacts(): Promise<XListed[]>;
224
230
  status(): BridgeStatus;
231
+ /** reads the credential again and reconnects now, also after the hub revoked or replaced this bridge */
232
+ relink(): void;
225
233
  stop(): Promise<void>;
226
234
  }
227
235
  /** a WebSocket constructor that takes request headers, as Bun's and Node's do */
@@ -401,6 +409,8 @@ interface BrokerOptions {
401
409
  machine?: string | undefined;
402
410
  /** the link to the hub; without it the broker never opens a network connection */
403
411
  bridge?: BridgeConfig | undefined;
412
+ /** reads the bridge configuration again for bridge_relink; one that appeared or names another hub or machine restarts the broker */
413
+ bridgeConfig?: (() => Promise<BridgeConfig | undefined>) | undefined;
404
414
  /** how often presence that decays without a request (a hook-only endpoint) is checked for changes (default 60 s) */
405
415
  presenceEveryMs?: number | undefined;
406
416
  /** where a judge model's key_env is read (default the broker's environment) */
@@ -419,4 +429,16 @@ interface Broker {
419
429
  }
420
430
  declare function startBroker(options: BrokerOptions): Promise<Broker>;
421
431
 
422
- export { type Bridge, type BridgeConfig, type BridgeStatus, type Broker, type BrokerInfo, type BrokerOptions, BrokerRunning, BrokerUnavailable, type GrantParams, type GroupParams, type Hello, type HelloResult, MAX_LINE, RelayClient, accountHome, connectBroker, localHostName, policyPath, privateDir, relayDir, relaySocketUrl, shortHostname, socketPath, startBroker, systemHostname, thisHostname };
432
+ /**
433
+ * What the broker last decided about a harness session, by its session key: whether it joined or left, and the handle it goes by. Hooks and
434
+ * the MCP shim read it before they claim, so a session that never joined (manual join mode) or left stays off the relay without a broker
435
+ * round trip, and a renamed session claims its own name. The broker writes it; the endpoint rows stay the source of truth.
436
+ */
437
+ interface SessionMarker {
438
+ joined: boolean;
439
+ handle?: string;
440
+ }
441
+ declare function readSessionMarker(dir: string, session: string): SessionMarker | undefined;
442
+ declare function writeSessionMarker(dir: string, session: string, marker: SessionMarker): void;
443
+
444
+ export { type Bridge, type BridgeConfig, type BridgeStatus, type Broker, type BrokerInfo, type BrokerOptions, BrokerRunning, BrokerUnavailable, type GrantParams, type GroupParams, type Hello, type HelloResult, MAX_LINE, RelayClient, type SessionMarker, accountHome, connectBroker, localHostName, policyPath, privateDir, readSessionMarker, relayDir, relaySocketUrl, shortHostname, socketPath, startBroker, systemHostname, thisHostname, writeSessionMarker };
package/dist/index.js CHANGED
@@ -563,6 +563,7 @@ function startBridge(options) {
563
563
  let connected = false;
564
564
  let ready = false;
565
565
  let stopped = false;
566
+ let shutDown = false;
566
567
  let lastError = options.token ? null : options.tokenError ?? "no hub credential; the broker runs local-only";
567
568
  let since = (/* @__PURE__ */ new Date()).toISOString();
568
569
  let attempt = 0;
@@ -603,7 +604,7 @@ function startBridge(options) {
603
604
  });
604
605
  }
605
606
  function connect() {
606
- if (stopped || !token) return;
607
+ if (stopped || !token || ws) return;
607
608
  let socket;
608
609
  try {
609
610
  socket = new Socket(relaySocketUrl(options.url), { headers: { Authorization: `Bearer ${token}` } });
@@ -680,6 +681,7 @@ function startBridge(options) {
680
681
  const hello = await request("hello", { protocol: PROTOCOL2, machine, bridge: true });
681
682
  account = hello.account;
682
683
  core.setAliases([account]);
684
+ core.setOthers(account, Array.isArray(hello.others) ? hello.others.filter((name) => typeof name === "string") : []);
683
685
  ready = true;
684
686
  applyRemote(hello.endpoints, true);
685
687
  await Promise.all(fullMirror().map((params) => request("mirror", params)));
@@ -762,7 +764,7 @@ function startBridge(options) {
762
764
  function localAddress(address) {
763
765
  if (typeof address !== "string" || !account || !address.startsWith(`${account}/`)) return address;
764
766
  const rest = address.slice(account.length + 1);
765
- return rest.includes("/") ? `${core.user}/${rest}` : `${core.user}/${account}/${rest}`;
767
+ return rest.includes("/") ? core.remoteAddress(rest) : `${core.user}/${account}/${rest}`;
766
768
  }
767
769
  function cursor() {
768
770
  return inflight.size ? Math.min(...inflight) - 1 : ackedMax;
@@ -795,6 +797,7 @@ function startBridge(options) {
795
797
  }
796
798
  case "endpoints": {
797
799
  if (typeof params.machine !== "string" || params.machine === machine) return;
800
+ if (typeof params.own === "boolean") core.setOther(params.machine, !params.own);
798
801
  try {
799
802
  core.mirror(params.machine, params.endpoints, { full: params.full === true });
800
803
  } catch (error) {
@@ -986,6 +989,17 @@ function startBridge(options) {
986
989
  }
987
990
  if (mirrored(view)) fireAndForget("mirror", { endpoints: [outward(view)], full: false });
988
991
  }
992
+ function renamed(endpoint, was) {
993
+ if (!ready) return;
994
+ let view;
995
+ try {
996
+ view = core.whoami(endpoint);
997
+ } catch {
998
+ return;
999
+ }
1000
+ if (mirrored(view)) fireAndForget("mirror", { endpoints: [{ ...outward(view), was }], full: false });
1001
+ else resync();
1002
+ }
989
1003
  function resync() {
990
1004
  if (!ready) return;
991
1005
  let frames;
@@ -1009,8 +1023,19 @@ function startBridge(options) {
1009
1023
  function status() {
1010
1024
  return { enabled: true, machine, url: options.url, connected, last_error: lastError, since };
1011
1025
  }
1026
+ function relink() {
1027
+ if (shutDown) return;
1028
+ stopped = false;
1029
+ attempt = 0;
1030
+ clearTimeout(retryTimer);
1031
+ if (ws) return;
1032
+ void reconnect().then(() => {
1033
+ if (!token && !ws) setState(false, "no hub credential; log in again");
1034
+ });
1035
+ }
1012
1036
  async function stop() {
1013
1037
  stopped = true;
1038
+ shutDown = true;
1014
1039
  clearTimeout(retryTimer);
1015
1040
  for (const fn of waitingForLink.splice(0)) fn();
1016
1041
  const socket = ws;
@@ -1022,15 +1047,15 @@ function startBridge(options) {
1022
1047
  if (socket && socket.readyState <= 1) socket.close(1e3, "the broker stopped");
1023
1048
  }
1024
1049
  connect();
1025
- return { forward, changed, resync, activity, contacts, status, stop };
1050
+ return { forward, changed, resync, renamed, activity, contacts, status, relink, stop };
1026
1051
  }
1027
1052
 
1028
1053
  // src/server.ts
1029
- import { createHash as createHash2 } from "crypto";
1030
- import { chmodSync as chmodSync2, existsSync as existsSync2, lstatSync as lstatSync2, readFileSync as readFileSync5, realpathSync, rmdirSync, unlinkSync as unlinkSync2 } from "fs";
1054
+ import { createHash as createHash3 } from "crypto";
1055
+ import { chmodSync as chmodSync2, existsSync as existsSync2, lstatSync as lstatSync2, readFileSync as readFileSync6, realpathSync, rmdirSync, unlinkSync as unlinkSync2 } from "fs";
1031
1056
  import { createServer } from "net";
1032
1057
  import { homedir as homedir2 } from "os";
1033
- import { dirname as dirname2, isAbsolute, join as join3, sep } from "path";
1058
+ import { dirname as dirname2, isAbsolute, join as join4, sep } from "path";
1034
1059
  import { setTimeout as sleep2 } from "timers/promises";
1035
1060
  import {
1036
1061
  BROKER_CAPABILITIES,
@@ -1502,6 +1527,34 @@ function heldResult(id, params) {
1502
1527
  return { id, thread: params.thread ?? "", hop: 0, deliveries: [], held: { hold_id: id, side: "local" } };
1503
1528
  }
1504
1529
 
1530
+ // src/sessions.ts
1531
+ import { createHash as createHash2, randomUUID } from "crypto";
1532
+ import { mkdirSync as mkdirSync2, readFileSync as readFileSync5, renameSync as renameSync2, rmSync, writeFileSync } from "fs";
1533
+ import { join as join3 } from "path";
1534
+ function markerPath(dir, session) {
1535
+ return join3(dir, "sessions", `${createHash2("sha256").update(session).digest("hex").slice(0, 32)}.json`);
1536
+ }
1537
+ function readSessionMarker(dir, session) {
1538
+ try {
1539
+ const value = JSON.parse(readFileSync5(markerPath(dir, session), "utf8"));
1540
+ if (typeof value?.joined !== "boolean") return void 0;
1541
+ return { joined: value.joined, ...typeof value.handle === "string" ? { handle: value.handle } : {} };
1542
+ } catch {
1543
+ return void 0;
1544
+ }
1545
+ }
1546
+ function writeSessionMarker(dir, session, marker) {
1547
+ const path = markerPath(dir, session);
1548
+ mkdirSync2(join3(dir, "sessions"), { recursive: true, mode: 448 });
1549
+ const temp = `${path}.${randomUUID()}.tmp`;
1550
+ try {
1551
+ writeFileSync(temp, JSON.stringify(marker), { mode: 384 });
1552
+ renameSync2(temp, path);
1553
+ } finally {
1554
+ rmSync(temp, { force: true });
1555
+ }
1556
+ }
1557
+
1505
1558
  // src/sqlite.ts
1506
1559
  import { createRequire } from "module";
1507
1560
  var STATEMENT_CACHE = 512;
@@ -1577,7 +1630,7 @@ function openDatabase(path) {
1577
1630
  // src/server.ts
1578
1631
  var SWEEP_MS = 10 * 6e4;
1579
1632
  var PRESENCE_MS = 6e4;
1580
- var OBSERVER_METHODS = /* @__PURE__ */ new Set(["list", "snapshot", "watch", "log", "policy", "policy_check", "cloud_status"]);
1633
+ var OBSERVER_METHODS = /* @__PURE__ */ new Set(["list", "snapshot", "watch", "log", "policy", "policy_check", "cloud_status", "bridge_relink"]);
1581
1634
  var SCOPE_METHODS = /* @__PURE__ */ new Set([
1582
1635
  "rules",
1583
1636
  "rules_set",
@@ -1597,7 +1650,7 @@ var SCOPE_METHODS = /* @__PURE__ */ new Set([
1597
1650
  var CHANGES_SCOPE = /* @__PURE__ */ new Set(["rules_set", "rules_widen", "rules_lift", "group"]);
1598
1651
  var ASK_RECHECK_MS = 1e3;
1599
1652
  var STOP_RECHECK_MS = 1e3;
1600
- var TRANSIENT_METHODS = /* @__PURE__ */ new Set(["event", "status", "whoami", "inject", "injected", "peek_turn", "stop_wait", "prompted"]);
1653
+ var TRANSIENT_METHODS = /* @__PURE__ */ new Set(["event", "status", "whoami", "inject", "injected", "peek_turn", "stop_wait", "prompted", "title_infer"]);
1601
1654
  var GRANT_UNBOUND = `a spawn-env grant binds only a new harness process: run the harness with it, or name the child (${CLI_NAME} spawn-env --name / --as)`;
1602
1655
  function workerResults(result) {
1603
1656
  return result.messages.length - (result.others_shown ?? 0) + result.more;
@@ -1619,7 +1672,7 @@ function hookOf(s) {
1619
1672
  }
1620
1673
  async function startBroker(options) {
1621
1674
  const dir = privateDir(options.dir);
1622
- const lock = takeBrokerLock(join3(dir, "broker.lock"));
1675
+ const lock = takeBrokerLock(join4(dir, "broker.lock"));
1623
1676
  const socket = socketPath(dir);
1624
1677
  const connections = /* @__PURE__ */ new Set();
1625
1678
  const waiters = /* @__PURE__ */ new Map();
@@ -1647,16 +1700,16 @@ async function startBroker(options) {
1647
1700
  let listener;
1648
1701
  try {
1649
1702
  privateDir(dirname2(socket));
1650
- db = openDatabase(join3(dir, "relay.db"));
1703
+ db = openDatabase(join4(dir, "relay.db"));
1651
1704
  db.exec("PRAGMA journal_mode = WAL");
1652
1705
  db.exec("PRAGMA busy_timeout = 5000");
1653
- chmodSync2(join3(dir, "relay.db"), 384);
1706
+ chmodSync2(join4(dir, "relay.db"), 384);
1654
1707
  const sql = db;
1655
1708
  const machine = options.machine ?? options.bridge?.machine;
1656
1709
  core = new FederatedCore(sql, {
1657
1710
  ...compact({ user: options.user, machine, now: options.now }),
1658
1711
  hostname: shortHostname(),
1659
- hash: (value) => createHash2("sha256").update(value).digest("hex"),
1712
+ hash: (value) => createHash3("sha256").update(value).digest("hex"),
1660
1713
  transaction: (fn) => db.transaction(fn),
1661
1714
  alive: running
1662
1715
  });
@@ -1712,6 +1765,7 @@ async function startBroker(options) {
1712
1765
  const known = /* @__PURE__ */ new Map();
1713
1766
  for (const view of localViews()) known.set(view.id, view.presence);
1714
1767
  const presenceTimer = setInterval(() => {
1768
+ core.endExited();
1715
1769
  const live = /* @__PURE__ */ new Set();
1716
1770
  for (const view of localViews()) {
1717
1771
  live.add(view.id);
@@ -1734,6 +1788,15 @@ async function startBroker(options) {
1734
1788
  }
1735
1789
  });
1736
1790
  }
1791
+ async function relink() {
1792
+ const config = options.bridgeConfig ? await options.bridgeConfig() : options.bridge;
1793
+ if (!config || bridge && config.url === options.bridge?.url && config.machine === options.bridge?.machine) {
1794
+ bridge?.relink();
1795
+ return bridge?.status() ?? { enabled: false };
1796
+ }
1797
+ setImmediate(() => void stop());
1798
+ return { enabled: false, restarting: true };
1799
+ }
1737
1800
  function activity() {
1738
1801
  if (idleTimer) clearTimeout(idleTimer);
1739
1802
  idleTimer = void 0;
@@ -1742,7 +1805,7 @@ async function startBroker(options) {
1742
1805
  function loadPolicy() {
1743
1806
  let text;
1744
1807
  try {
1745
- text = readFileSync5(policyFile, "utf8");
1808
+ text = readFileSync6(policyFile, "utf8");
1746
1809
  } catch (error) {
1747
1810
  const code = error.code;
1748
1811
  const dangling = code === "ENOENT" && lstatSync2(policyFile, { throwIfNoEntry: false }) !== void 0;
@@ -1807,6 +1870,20 @@ async function startBroker(options) {
1807
1870
  notifyFleet({ type: "presence", endpoint: view.address, presence: view.presence, harness: view.harness, labels: view.labels });
1808
1871
  bridge?.changed(view.id);
1809
1872
  }
1873
+ function membership(view, session, joined, was) {
1874
+ const handle = view.address.slice(view.address.lastIndexOf("/") + 1);
1875
+ if (session && !session.startsWith("sub:")) {
1876
+ try {
1877
+ writeSessionMarker(dir, session, { joined, handle });
1878
+ } catch (error) {
1879
+ log(`relay broker: the session marker was not written: ${error instanceof Error ? error.message : String(error)}`);
1880
+ }
1881
+ }
1882
+ known.set(view.id, view.presence);
1883
+ notifyFleet({ type: "presence", endpoint: view.address, presence: view.presence, harness: view.harness, labels: view.labels });
1884
+ if (was !== void 0 && was !== handle) bridge?.renamed(view.id, was);
1885
+ else bridge?.changed(view.id);
1886
+ }
1810
1887
  function notifyFleet(event) {
1811
1888
  const at = new Date((options.now ?? Date.now)()).toISOString();
1812
1889
  let line;
@@ -2060,13 +2137,18 @@ async function startBroker(options) {
2060
2137
  if (params.human === true || params.observe === true || params.resume !== void 0) {
2061
2138
  throw new RelayError4("invalid_params", "a hook connection claims an agent endpoint: transient does not go with human, observe or resume");
2062
2139
  }
2063
- const before2 = new Map(core.list({ all: true }).map((view) => [view.id, view.presence]));
2064
- const { endpoint: endpoint2, subagent, created } = core.claim(params.claim ?? {}, { transient: true });
2140
+ const join5 = params.join;
2141
+ if (join5 !== void 0 && (typeof join5 !== "object" || join5 === null || Array.isArray(join5)))
2142
+ throw new RelayError4("invalid_params", "join is {handle?}");
2143
+ const before2 = new Map(core.list({ all: true }).map((view) => [view.id, view]));
2144
+ const { endpoint: endpoint2, subagent, created } = join5 === void 0 ? core.claim(params.claim ?? {}, { transient: true }) : { endpoint: core.joinSession(params.claim ?? {}, join5.handle), subagent: void 0, created: void 0 };
2065
2145
  s.data.endpoint = endpoint2.id;
2066
2146
  s.data.transient = true;
2067
2147
  const key = params.claim?.process;
2068
2148
  if (typeof key === "string") s.data.process = key;
2069
- if ((before2.get(endpoint2.id) ?? "offline") !== endpoint2.presence) presenceChanged(endpoint2);
2149
+ const was2 = before2.get(endpoint2.id)?.address;
2150
+ if (join5 !== void 0) membership(endpoint2, core.sessionOf(endpoint2.id), true, was2?.slice(was2.lastIndexOf("/") + 1));
2151
+ else if ((before2.get(endpoint2.id)?.presence ?? "offline") !== endpoint2.presence) presenceChanged(endpoint2);
2070
2152
  return {
2071
2153
  endpoint: endpoint2,
2072
2154
  ...subagent ? { subagent, ...created ? { created } : {} } : {},
@@ -2205,6 +2287,22 @@ async function startBroker(options) {
2205
2287
  bridge?.changed(me);
2206
2288
  return view;
2207
2289
  }
2290
+ case "title_infer": {
2291
+ const changed = core.inferTitle(me, params, hookOf(s));
2292
+ if (changed) bridge?.changed(me);
2293
+ return { changed };
2294
+ }
2295
+ case "rename": {
2296
+ const renamed = core.rename(actorOf(s), { ...params.endpoint !== void 0 ? { endpoint: params.endpoint } : {}, handle: params.handle });
2297
+ membership(renamed.endpoint, renamed.session, true, renamed.from);
2298
+ return renamed.endpoint;
2299
+ }
2300
+ case "leave": {
2301
+ const left = core.leaveSession(actorOf(s), params.endpoint !== void 0 ? { endpoint: params.endpoint } : {});
2302
+ for (const c of connections) if (c.data.endpoint === left.endpoint.id && !c.data.transient && !c.data.lineage && c !== s) c.end();
2303
+ membership(left.endpoint, left.session, false);
2304
+ return left.endpoint;
2305
+ }
2208
2306
  case "status": {
2209
2307
  core.status(me, params.state, params.note);
2210
2308
  const view = core.whoami(me);
@@ -2220,14 +2318,25 @@ async function startBroker(options) {
2220
2318
  if ((known.get(me) ?? "offline") !== after.presence) presenceChanged(after);
2221
2319
  return row;
2222
2320
  }
2223
- case "inject":
2224
- return core.inject(me, {
2321
+ case "inject": {
2322
+ const waitMs = waitSeconds(params.wait_s);
2323
+ const options2 = {
2225
2324
  stop: params.stop === true,
2226
2325
  count: params.count === true,
2227
2326
  holdUnread: params.hold_unread === true,
2228
2327
  directives: params.directives === true,
2328
+ wake: waitMs > 0 && params.stop !== true && params.count !== true,
2229
2329
  ...hookOf(s)
2230
- });
2330
+ };
2331
+ const deadline = Date.now() + (options2.wake ? waitMs : 0);
2332
+ let result = core.inject(me, options2);
2333
+ while (!result.offered.length && !s.data.closed && Date.now() < deadline) {
2334
+ await waitFor(me, deadline - Date.now(), s);
2335
+ if (closing) throw new RelayError4("unauthorized", "the relay broker is stopping");
2336
+ result = core.inject(me, options2);
2337
+ }
2338
+ return result;
2339
+ }
2231
2340
  case "stop_wait": {
2232
2341
  const waitMs = waitSeconds(params.wait_s);
2233
2342
  const deadline = Date.now() + waitMs;
@@ -2246,7 +2355,8 @@ async function startBroker(options) {
2246
2355
  case "prompted": {
2247
2356
  const prompted = core.prompted(me, hookOf(s));
2248
2357
  if (prompted) wake(me);
2249
- return { prompted };
2358
+ const tldr = prompted && typeof params.tldr_every === "number" && core.tldrDue(me, params.tldr_every);
2359
+ return { prompted, ...tldr ? { tldr } : {} };
2250
2360
  }
2251
2361
  case "injected":
2252
2362
  return {
@@ -2335,6 +2445,8 @@ async function startBroker(options) {
2335
2445
  }
2336
2446
  case "cloud_status":
2337
2447
  return bridge?.status() ?? { enabled: false };
2448
+ case "bridge_relink":
2449
+ return relink();
2338
2450
  }
2339
2451
  throw new RelayError4("method_not_found", `no method ${method}`);
2340
2452
  }
@@ -2459,7 +2571,7 @@ async function startBroker(options) {
2459
2571
  unlinkSync2(socket);
2460
2572
  } catch {
2461
2573
  }
2462
- if (socket !== join3(dir, "relay.sock")) {
2574
+ if (socket !== join4(dir, "relay.sock")) {
2463
2575
  try {
2464
2576
  rmdirSync(dirname2(socket));
2465
2577
  } catch {
@@ -2504,11 +2616,13 @@ export {
2504
2616
  localHostName,
2505
2617
  policyPath,
2506
2618
  privateDir,
2619
+ readSessionMarker,
2507
2620
  relayDir,
2508
2621
  relaySocketUrl,
2509
2622
  shortHostname,
2510
2623
  socketPath,
2511
2624
  startBroker,
2512
2625
  systemHostname,
2513
- thisHostname
2626
+ thisHostname,
2627
+ writeSessionMarker
2514
2628
  };
package/dist/testing.d.ts CHANGED
@@ -4,6 +4,8 @@ interface FakeToken {
4
4
  tokenId: string;
5
5
  /** the account handle the token acts as */
6
6
  user: string;
7
+ /** the org member the token belongs to; none is the personal account's owner */
8
+ actor?: string;
7
9
  }
8
10
  interface FakeHubServer {
9
11
  /** the http origin, as a bridge's url takes it */
package/dist/testing.js CHANGED
@@ -50,7 +50,14 @@ function startFakeHubServer(options) {
50
50
  if (!token) return null;
51
51
  return {
52
52
  account: token.user,
53
- grant: { tokenId: token.tokenId, userId: `usr_${token.user}`, actorId: null, expiresAt: Date.now() + 36e5, scoped: false, role: null }
53
+ grant: {
54
+ tokenId: token.tokenId,
55
+ userId: `usr_${token.user}`,
56
+ actorId: token.actor ?? null,
57
+ expiresAt: Date.now() + 36e5,
58
+ scoped: false,
59
+ role: token.actor ? "member" : null
60
+ }
54
61
  };
55
62
  }
56
63
  const server = Bun.serve({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scp-dev/broker",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Swarm Communication Protocol local broker: a unix socket JSON-RPC daemon over the relay core, its client and the bridge to an account hub.",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://scp.dev",
@@ -37,8 +37,8 @@
37
37
  "typecheck": "tsc -p ."
38
38
  },
39
39
  "dependencies": {
40
- "@scp-dev/core": "0.1.1",
41
- "@scp-dev/hub": "0.1.1"
40
+ "@scp-dev/core": "0.1.2",
41
+ "@scp-dev/hub": "0.2.0"
42
42
  },
43
43
  "devDependencies": {
44
44
  "tsup": "^8.5.1"
package/src/bridge.ts CHANGED
@@ -73,11 +73,15 @@ export interface Bridge {
73
73
  changed(endpoint: string): void
74
74
  /** rules, caps, groups or a title changed, which can touch several endpoints: every mirrored endpoint is sent again */
75
75
  resync(): void
76
+ /** a local endpoint took another handle: the hub renames its mirror in place, keeping what queued for it */
77
+ renamed(endpoint: string, was: string): void
76
78
  /** a local endpoint's activity, posted to the hub */
77
79
  activity(endpoint: string, row: ActivityRow): void
78
80
  /** the endpoints of other accounts this account's contacts let it reach */
79
81
  contacts(): Promise<XListed[]>
80
82
  status(): BridgeStatus
83
+ /** reads the credential again and reconnects now, also after the hub revoked or replaced this bridge */
84
+ relink(): void
81
85
  stop(): Promise<void>
82
86
  }
83
87
 
@@ -132,7 +136,9 @@ export function startBridge(options: BridgeOptions): Bridge {
132
136
  let connected = false
133
137
  /** hello went through on the current socket: deltas may go out */
134
138
  let ready = false
139
+ /** the hub turned this bridge away, or the broker stopped it: no more retries until relink */
135
140
  let stopped = false
141
+ let shutDown = false
136
142
  let lastError: string | null = options.token ? null : (options.tokenError ?? "no hub credential; the broker runs local-only")
137
143
  let since = new Date().toISOString()
138
144
  let attempt = 0
@@ -177,7 +183,7 @@ export function startBridge(options: BridgeOptions): Bridge {
177
183
  }
178
184
 
179
185
  function connect() {
180
- if (stopped || !token) return
186
+ if (stopped || !token || ws) return
181
187
  let socket: WebSocket
182
188
  try {
183
189
  socket = new Socket(relaySocketUrl(options.url), { headers: { Authorization: `Bearer ${token}` } })
@@ -253,9 +259,10 @@ export function startBridge(options: BridgeOptions): Bridge {
253
259
  }
254
260
 
255
261
  async function handshake(socket: WebSocket) {
256
- const hello = await request<{ account: string; endpoints: RemoteView[] }>("hello", { protocol: PROTOCOL, machine, bridge: true })
262
+ const hello = await request<{ account: string; endpoints: RemoteView[]; others?: string[] }>("hello", { protocol: PROTOCOL, machine, bridge: true })
257
263
  account = hello.account
258
264
  core.setAliases([account])
265
+ core.setOthers(account, Array.isArray(hello.others) ? hello.others.filter((name): name is string => typeof name === "string") : [])
259
266
  ready = true
260
267
  applyRemote(hello.endpoints, true)
261
268
  await Promise.all(fullMirror().map((params) => request("mirror", params)))
@@ -350,7 +357,7 @@ export function startBridge(options: BridgeOptions): Bridge {
350
357
  function localAddress(address: unknown): unknown {
351
358
  if (typeof address !== "string" || !account || !address.startsWith(`${account}/`)) return address
352
359
  const rest = address.slice(account.length + 1)
353
- return rest.includes("/") ? `${core.user}/${rest}` : `${core.user}/${account}/${rest}`
360
+ return rest.includes("/") ? core.remoteAddress(rest) : `${core.user}/${account}/${rest}`
354
361
  }
355
362
 
356
363
  /** the lowest seq a fresh subscribe must replay from: every delivery at or below it was acked */
@@ -387,6 +394,7 @@ export function startBridge(options: BridgeOptions): Bridge {
387
394
  }
388
395
  case "endpoints": {
389
396
  if (typeof params.machine !== "string" || params.machine === machine) return
397
+ if (typeof params.own === "boolean") core.setOther(params.machine, !params.own)
390
398
  try {
391
399
  core.mirror(params.machine, params.endpoints, { full: params.full === true })
392
400
  } catch (error) {
@@ -597,6 +605,18 @@ export function startBridge(options: BridgeOptions): Bridge {
597
605
  if (mirrored(view)) fireAndForget("mirror", { endpoints: [outward(view)], full: false })
598
606
  }
599
607
 
608
+ function renamed(endpoint: string, was: string) {
609
+ if (!ready) return
610
+ let view: EndpointView
611
+ try {
612
+ view = core.whoami(endpoint)
613
+ } catch {
614
+ return
615
+ }
616
+ if (mirrored(view)) fireAndForget("mirror", { endpoints: [{ ...outward(view), was }], full: false })
617
+ else resync()
618
+ }
619
+
600
620
  function resync() {
601
621
  if (!ready) return
602
622
  let frames: ReturnType<typeof fullMirror>
@@ -624,8 +644,20 @@ export function startBridge(options: BridgeOptions): Bridge {
624
644
  return { enabled: true, machine, url: options.url, connected, last_error: lastError, since }
625
645
  }
626
646
 
647
+ function relink() {
648
+ if (shutDown) return
649
+ stopped = false
650
+ attempt = 0
651
+ clearTimeout(retryTimer)
652
+ if (ws) return
653
+ void reconnect().then(() => {
654
+ if (!token && !ws) setState(false, "no hub credential; log in again")
655
+ })
656
+ }
657
+
627
658
  async function stop() {
628
659
  stopped = true
660
+ shutDown = true
629
661
  clearTimeout(retryTimer)
630
662
  for (const fn of waitingForLink.splice(0)) fn()
631
663
  const socket = ws
@@ -638,5 +670,5 @@ export function startBridge(options: BridgeOptions): Bridge {
638
670
  }
639
671
 
640
672
  connect()
641
- return { forward, changed, resync, activity, contacts, status, stop }
673
+ return { forward, changed, resync, renamed, activity, contacts, status, relink, stop }
642
674
  }
package/src/client.ts CHANGED
@@ -31,6 +31,8 @@ export interface Hello {
31
31
  tty?: boolean
32
32
  /** a hook's connection: it claims like an agent but never counts as connected, and posts only event and status */
33
33
  transient?: boolean
34
+ /** with `transient`: the session joins the relay on demand, taking `handle` when given */
35
+ join?: { handle?: string }
34
36
  /** a one-shot command: the broker announces no presence change when it connects or goes */
35
37
  brief?: boolean
36
38
  /** no identity: list, status, watch, log and policy only */
package/src/index.ts CHANGED
@@ -4,3 +4,4 @@ export { BrokerRunning } from "./lock"
4
4
  export { MAX_LINE } from "./ndjson"
5
5
  export { accountHome, localHostName, policyPath, privateDir, relayDir, shortHostname, socketPath, systemHostname, thisHostname } from "./paths"
6
6
  export { startBroker, type Broker, type BrokerOptions } from "./server"
7
+ export { readSessionMarker, type SessionMarker, writeSessionMarker } from "./sessions"
package/src/server.ts CHANGED
@@ -31,6 +31,7 @@ import { type Deliver, type JudgeClientFactory, JudgeGate } from "./judge-gate"
31
31
  import { releaseBrokerLock, takeBrokerLock } from "./lock"
32
32
  import { encodeLine, LineReader, LineWriter, MAX_LINE } from "./ndjson"
33
33
  import { accountHome, accountHomeVerified, policyPath as defaultPolicyPath, privateDir, shortHostname, socketPath } from "./paths"
34
+ import { writeSessionMarker } from "./sessions"
34
35
  import { Conn } from "./socket"
35
36
  import { type Database, openDatabase } from "./sqlite"
36
37
 
@@ -57,6 +58,8 @@ export interface BrokerOptions {
57
58
  machine?: string | undefined
58
59
  /** the link to the hub; without it the broker never opens a network connection */
59
60
  bridge?: BridgeConfig | undefined
61
+ /** reads the bridge configuration again for bridge_relink; one that appeared or names another hub or machine restarts the broker */
62
+ bridgeConfig?: (() => Promise<BridgeConfig | undefined>) | undefined
60
63
  /** how often presence that decays without a request (a hook-only endpoint) is checked for changes (default 60 s) */
61
64
  presenceEveryMs?: number | undefined
62
65
  /** where a judge model's key_env is read (default the broker's environment) */
@@ -110,7 +113,7 @@ interface Connection {
110
113
 
111
114
  const SWEEP_MS = 10 * 60_000
112
115
  const PRESENCE_MS = 60_000
113
- const OBSERVER_METHODS = new Set(["list", "snapshot", "watch", "log", "policy", "policy_check", "cloud_status"])
116
+ const OBSERVER_METHODS = new Set(["list", "snapshot", "watch", "log", "policy", "policy_check", "cloud_status", "bridge_relink"])
114
117
  const SCOPE_METHODS = new Set([
115
118
  "rules",
116
119
  "rules_set",
@@ -132,7 +135,7 @@ const CHANGES_SCOPE = new Set(["rules_set", "rules_widen", "rules_lift", "group"
132
135
  const ASK_RECHECK_MS = 1000
133
136
  /** how often a waiting stop rechecks its workers, since a worker's exit or lapse wakes no one */
134
137
  const STOP_RECHECK_MS = 1000
135
- const TRANSIENT_METHODS = new Set(["event", "status", "whoami", "inject", "injected", "peek_turn", "stop_wait", "prompted"])
138
+ const TRANSIENT_METHODS = new Set(["event", "status", "whoami", "inject", "injected", "peek_turn", "stop_wait", "prompted", "title_infer"])
136
139
  const GRANT_UNBOUND = `a spawn-env grant binds only a new harness process: run the harness with it, or name the child (${CLI_NAME} spawn-env --name / --as)`
137
140
 
138
141
  /** the workers' messages and handbacks a stop's push would show, and those of theirs left unread past it */
@@ -258,6 +261,7 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
258
261
  const known = new Map<string, Presence>()
259
262
  for (const view of localViews()) known.set(view.id, view.presence)
260
263
  const presenceTimer = setInterval(() => {
264
+ core.endExited()
261
265
  const live = new Set<string>()
262
266
  for (const view of localViews()) {
263
267
  live.add(view.id)
@@ -281,6 +285,17 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
281
285
  })
282
286
  }
283
287
 
288
+ /** links the bridge again with the credential on disk; a configuration this broker cannot take without a restart stops it */
289
+ async function relink(): Promise<object> {
290
+ const config = options.bridgeConfig ? await options.bridgeConfig() : options.bridge
291
+ if (!config || (bridge && config.url === options.bridge?.url && config.machine === options.bridge?.machine)) {
292
+ bridge?.relink()
293
+ return bridge?.status() ?? { enabled: false }
294
+ }
295
+ setImmediate(() => void stop())
296
+ return { enabled: false, restarting: true }
297
+ }
298
+
284
299
  function activity() {
285
300
  if (idleTimer) clearTimeout(idleTimer)
286
301
  idleTimer = undefined
@@ -370,6 +385,22 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
370
385
  bridge?.changed(view.id)
371
386
  }
372
387
 
388
+ /** after a join, rename or leave: the session's marker, the fleet and the hub's mirror learn it; `was` is the handle before */
389
+ function membership(view: EndpointView, session: string | null, joined: boolean, was?: string) {
390
+ const handle = view.address.slice(view.address.lastIndexOf("/") + 1)
391
+ if (session && !session.startsWith("sub:")) {
392
+ try {
393
+ writeSessionMarker(dir, session, { joined, handle })
394
+ } catch (error) {
395
+ log(`relay broker: the session marker was not written: ${error instanceof Error ? error.message : String(error)}`)
396
+ }
397
+ }
398
+ known.set(view.id, view.presence)
399
+ notifyFleet({ type: "presence", endpoint: view.address, presence: view.presence, harness: view.harness, labels: view.labels })
400
+ if (was !== undefined && was !== handle) bridge?.renamed(view.id, was)
401
+ else bridge?.changed(view.id)
402
+ }
403
+
373
404
  function notifyFleet(event: Record<string, unknown>) {
374
405
  const at = new Date((options.now ?? Date.now)()).toISOString()
375
406
  let line: Uint8Array | undefined
@@ -664,13 +695,21 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
664
695
  if (params.human === true || params.observe === true || params.resume !== undefined) {
665
696
  throw new RelayError("invalid_params", "a hook connection claims an agent endpoint: transient does not go with human, observe or resume")
666
697
  }
667
- const before = new Map(core.list({ all: true }).map((view) => [view.id, view.presence]))
668
- const { endpoint, subagent, created } = core.claim((params.claim ?? {}) as Claim, { transient: true })
698
+ const join = params.join
699
+ if (join !== undefined && (typeof join !== "object" || join === null || Array.isArray(join)))
700
+ throw new RelayError("invalid_params", "join is {handle?}")
701
+ const before = new Map(core.list({ all: true }).map((view) => [view.id, view]))
702
+ const { endpoint, subagent, created } =
703
+ join === undefined
704
+ ? core.claim((params.claim ?? {}) as Claim, { transient: true })
705
+ : { endpoint: core.joinSession((params.claim ?? {}) as Claim, (join as { handle?: unknown }).handle), subagent: undefined, created: undefined }
669
706
  s.data.endpoint = endpoint.id
670
707
  s.data.transient = true
671
708
  const key = (params.claim as Claim | undefined)?.process
672
709
  if (typeof key === "string") s.data.process = key
673
- if ((before.get(endpoint.id) ?? "offline") !== endpoint.presence) presenceChanged(endpoint)
710
+ const was = before.get(endpoint.id)?.address
711
+ if (join !== undefined) membership(endpoint, core.sessionOf(endpoint.id), true, was?.slice(was.lastIndexOf("/") + 1))
712
+ else if ((before.get(endpoint.id)?.presence ?? "offline") !== endpoint.presence) presenceChanged(endpoint)
674
713
  return {
675
714
  endpoint,
676
715
  ...(subagent ? { subagent, ...(created ? { created } : {}) } : {}),
@@ -809,6 +848,22 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
809
848
  bridge?.changed(me)
810
849
  return view
811
850
  }
851
+ case "title_infer": {
852
+ const changed = core.inferTitle(me, params, hookOf(s))
853
+ if (changed) bridge?.changed(me)
854
+ return { changed }
855
+ }
856
+ case "rename": {
857
+ const renamed = core.rename(actorOf(s), { ...(params.endpoint !== undefined ? { endpoint: params.endpoint } : {}), handle: params.handle })
858
+ membership(renamed.endpoint, renamed.session, true, renamed.from)
859
+ return renamed.endpoint
860
+ }
861
+ case "leave": {
862
+ const left = core.leaveSession(actorOf(s), params.endpoint !== undefined ? { endpoint: params.endpoint } : {})
863
+ for (const c of connections) if (c.data.endpoint === left.endpoint.id && !c.data.transient && !c.data.lineage && c !== s) c.end()
864
+ membership(left.endpoint, left.session, false)
865
+ return left.endpoint
866
+ }
812
867
  case "status": {
813
868
  core.status(me, params.state, params.note)
814
869
  const view = core.whoami(me)
@@ -824,14 +879,25 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
824
879
  if ((known.get(me) ?? "offline") !== after.presence) presenceChanged(after)
825
880
  return row
826
881
  }
827
- case "inject":
828
- return core.inject(me, {
882
+ case "inject": {
883
+ const waitMs = waitSeconds(params.wait_s)
884
+ const options = {
829
885
  stop: params.stop === true,
830
886
  count: params.count === true,
831
887
  holdUnread: params.hold_unread === true,
832
888
  directives: params.directives === true,
889
+ wake: waitMs > 0 && params.stop !== true && params.count !== true,
833
890
  ...hookOf(s),
834
- })
891
+ }
892
+ const deadline = Date.now() + (options.wake ? waitMs : 0)
893
+ let result = core.inject(me, options)
894
+ while (!result.offered.length && !s.data.closed && Date.now() < deadline) {
895
+ await waitFor(me, deadline - Date.now(), s)
896
+ if (closing) throw new RelayError("unauthorized", "the relay broker is stopping")
897
+ result = core.inject(me, options)
898
+ }
899
+ return result
900
+ }
835
901
  case "stop_wait": {
836
902
  const waitMs = waitSeconds(params.wait_s)
837
903
  const deadline = Date.now() + waitMs
@@ -850,7 +916,8 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
850
916
  case "prompted": {
851
917
  const prompted = core.prompted(me, hookOf(s))
852
918
  if (prompted) wake(me)
853
- return { prompted }
919
+ const tldr = prompted && typeof params.tldr_every === "number" && core.tldrDue(me, params.tldr_every)
920
+ return { prompted, ...(tldr ? { tldr } : {}) }
854
921
  }
855
922
  case "injected":
856
923
  return {
@@ -948,6 +1015,8 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
948
1015
  }
949
1016
  case "cloud_status":
950
1017
  return bridge?.status() ?? { enabled: false }
1018
+ case "bridge_relink":
1019
+ return relink()
951
1020
  }
952
1021
  throw new RelayError("method_not_found", `no method ${method}`)
953
1022
  }
@@ -0,0 +1,39 @@
1
+ import { createHash, randomUUID } from "node:crypto"
2
+ import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"
3
+ import { join } from "node:path"
4
+
5
+ /**
6
+ * What the broker last decided about a harness session, by its session key: whether it joined or left, and the handle it goes by. Hooks and
7
+ * the MCP shim read it before they claim, so a session that never joined (manual join mode) or left stays off the relay without a broker
8
+ * round trip, and a renamed session claims its own name. The broker writes it; the endpoint rows stay the source of truth.
9
+ */
10
+ export interface SessionMarker {
11
+ joined: boolean
12
+ handle?: string
13
+ }
14
+
15
+ function markerPath(dir: string, session: string): string {
16
+ return join(dir, "sessions", `${createHash("sha256").update(session).digest("hex").slice(0, 32)}.json`)
17
+ }
18
+
19
+ export function readSessionMarker(dir: string, session: string): SessionMarker | undefined {
20
+ try {
21
+ const value = JSON.parse(readFileSync(markerPath(dir, session), "utf8")) as Partial<SessionMarker>
22
+ if (typeof value?.joined !== "boolean") return undefined
23
+ return { joined: value.joined, ...(typeof value.handle === "string" ? { handle: value.handle } : {}) }
24
+ } catch {
25
+ return undefined
26
+ }
27
+ }
28
+
29
+ export function writeSessionMarker(dir: string, session: string, marker: SessionMarker): void {
30
+ const path = markerPath(dir, session)
31
+ mkdirSync(join(dir, "sessions"), { recursive: true, mode: 0o700 })
32
+ const temp = `${path}.${randomUUID()}.tmp`
33
+ try {
34
+ writeFileSync(temp, JSON.stringify(marker), { mode: 0o600 })
35
+ renameSync(temp, path)
36
+ } finally {
37
+ rmSync(temp, { force: true })
38
+ }
39
+ }
package/src/testing.ts CHANGED
@@ -7,6 +7,8 @@ export interface FakeToken {
7
7
  tokenId: string
8
8
  /** the account handle the token acts as */
9
9
  user: string
10
+ /** the org member the token belongs to; none is the personal account's owner */
11
+ actor?: string
10
12
  }
11
13
 
12
14
  export interface FakeHubServer {
@@ -88,7 +90,14 @@ export function startFakeHubServer(options: {
88
90
  if (!token) return null
89
91
  return {
90
92
  account: token.user,
91
- grant: { tokenId: token.tokenId, userId: `usr_${token.user}`, actorId: null, expiresAt: Date.now() + 3_600_000, scoped: false, role: null },
93
+ grant: {
94
+ tokenId: token.tokenId,
95
+ userId: `usr_${token.user}`,
96
+ actorId: token.actor ?? null,
97
+ expiresAt: Date.now() + 3_600_000,
98
+ scoped: false,
99
+ role: token.actor ? "member" : null,
100
+ },
92
101
  }
93
102
  }
94
103