@scp-dev/broker 0.1.2 → 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,6 +221,8 @@ 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 */
@@ -423,4 +429,16 @@ interface Broker {
423
429
  }
424
430
  declare function startBroker(options: BrokerOptions): Promise<Broker>;
425
431
 
426
- 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
@@ -681,6 +681,7 @@ function startBridge(options) {
681
681
  const hello = await request("hello", { protocol: PROTOCOL2, machine, bridge: true });
682
682
  account = hello.account;
683
683
  core.setAliases([account]);
684
+ core.setOthers(account, Array.isArray(hello.others) ? hello.others.filter((name) => typeof name === "string") : []);
684
685
  ready = true;
685
686
  applyRemote(hello.endpoints, true);
686
687
  await Promise.all(fullMirror().map((params) => request("mirror", params)));
@@ -763,7 +764,7 @@ function startBridge(options) {
763
764
  function localAddress(address) {
764
765
  if (typeof address !== "string" || !account || !address.startsWith(`${account}/`)) return address;
765
766
  const rest = address.slice(account.length + 1);
766
- return rest.includes("/") ? `${core.user}/${rest}` : `${core.user}/${account}/${rest}`;
767
+ return rest.includes("/") ? core.remoteAddress(rest) : `${core.user}/${account}/${rest}`;
767
768
  }
768
769
  function cursor() {
769
770
  return inflight.size ? Math.min(...inflight) - 1 : ackedMax;
@@ -796,6 +797,7 @@ function startBridge(options) {
796
797
  }
797
798
  case "endpoints": {
798
799
  if (typeof params.machine !== "string" || params.machine === machine) return;
800
+ if (typeof params.own === "boolean") core.setOther(params.machine, !params.own);
799
801
  try {
800
802
  core.mirror(params.machine, params.endpoints, { full: params.full === true });
801
803
  } catch (error) {
@@ -987,6 +989,17 @@ function startBridge(options) {
987
989
  }
988
990
  if (mirrored(view)) fireAndForget("mirror", { endpoints: [outward(view)], full: false });
989
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
+ }
990
1003
  function resync() {
991
1004
  if (!ready) return;
992
1005
  let frames;
@@ -1034,15 +1047,15 @@ function startBridge(options) {
1034
1047
  if (socket && socket.readyState <= 1) socket.close(1e3, "the broker stopped");
1035
1048
  }
1036
1049
  connect();
1037
- return { forward, changed, resync, activity, contacts, status, relink, stop };
1050
+ return { forward, changed, resync, renamed, activity, contacts, status, relink, stop };
1038
1051
  }
1039
1052
 
1040
1053
  // src/server.ts
1041
- import { createHash as createHash2 } from "crypto";
1042
- 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";
1043
1056
  import { createServer } from "net";
1044
1057
  import { homedir as homedir2 } from "os";
1045
- import { dirname as dirname2, isAbsolute, join as join3, sep } from "path";
1058
+ import { dirname as dirname2, isAbsolute, join as join4, sep } from "path";
1046
1059
  import { setTimeout as sleep2 } from "timers/promises";
1047
1060
  import {
1048
1061
  BROKER_CAPABILITIES,
@@ -1514,6 +1527,34 @@ function heldResult(id, params) {
1514
1527
  return { id, thread: params.thread ?? "", hop: 0, deliveries: [], held: { hold_id: id, side: "local" } };
1515
1528
  }
1516
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
+
1517
1558
  // src/sqlite.ts
1518
1559
  import { createRequire } from "module";
1519
1560
  var STATEMENT_CACHE = 512;
@@ -1609,7 +1650,7 @@ var SCOPE_METHODS = /* @__PURE__ */ new Set([
1609
1650
  var CHANGES_SCOPE = /* @__PURE__ */ new Set(["rules_set", "rules_widen", "rules_lift", "group"]);
1610
1651
  var ASK_RECHECK_MS = 1e3;
1611
1652
  var STOP_RECHECK_MS = 1e3;
1612
- 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"]);
1613
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)`;
1614
1655
  function workerResults(result) {
1615
1656
  return result.messages.length - (result.others_shown ?? 0) + result.more;
@@ -1631,7 +1672,7 @@ function hookOf(s) {
1631
1672
  }
1632
1673
  async function startBroker(options) {
1633
1674
  const dir = privateDir(options.dir);
1634
- const lock = takeBrokerLock(join3(dir, "broker.lock"));
1675
+ const lock = takeBrokerLock(join4(dir, "broker.lock"));
1635
1676
  const socket = socketPath(dir);
1636
1677
  const connections = /* @__PURE__ */ new Set();
1637
1678
  const waiters = /* @__PURE__ */ new Map();
@@ -1659,16 +1700,16 @@ async function startBroker(options) {
1659
1700
  let listener;
1660
1701
  try {
1661
1702
  privateDir(dirname2(socket));
1662
- db = openDatabase(join3(dir, "relay.db"));
1703
+ db = openDatabase(join4(dir, "relay.db"));
1663
1704
  db.exec("PRAGMA journal_mode = WAL");
1664
1705
  db.exec("PRAGMA busy_timeout = 5000");
1665
- chmodSync2(join3(dir, "relay.db"), 384);
1706
+ chmodSync2(join4(dir, "relay.db"), 384);
1666
1707
  const sql = db;
1667
1708
  const machine = options.machine ?? options.bridge?.machine;
1668
1709
  core = new FederatedCore(sql, {
1669
1710
  ...compact({ user: options.user, machine, now: options.now }),
1670
1711
  hostname: shortHostname(),
1671
- hash: (value) => createHash2("sha256").update(value).digest("hex"),
1712
+ hash: (value) => createHash3("sha256").update(value).digest("hex"),
1672
1713
  transaction: (fn) => db.transaction(fn),
1673
1714
  alive: running
1674
1715
  });
@@ -1724,6 +1765,7 @@ async function startBroker(options) {
1724
1765
  const known = /* @__PURE__ */ new Map();
1725
1766
  for (const view of localViews()) known.set(view.id, view.presence);
1726
1767
  const presenceTimer = setInterval(() => {
1768
+ core.endExited();
1727
1769
  const live = /* @__PURE__ */ new Set();
1728
1770
  for (const view of localViews()) {
1729
1771
  live.add(view.id);
@@ -1763,7 +1805,7 @@ async function startBroker(options) {
1763
1805
  function loadPolicy() {
1764
1806
  let text;
1765
1807
  try {
1766
- text = readFileSync5(policyFile, "utf8");
1808
+ text = readFileSync6(policyFile, "utf8");
1767
1809
  } catch (error) {
1768
1810
  const code = error.code;
1769
1811
  const dangling = code === "ENOENT" && lstatSync2(policyFile, { throwIfNoEntry: false }) !== void 0;
@@ -1828,6 +1870,20 @@ async function startBroker(options) {
1828
1870
  notifyFleet({ type: "presence", endpoint: view.address, presence: view.presence, harness: view.harness, labels: view.labels });
1829
1871
  bridge?.changed(view.id);
1830
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
+ }
1831
1887
  function notifyFleet(event) {
1832
1888
  const at = new Date((options.now ?? Date.now)()).toISOString();
1833
1889
  let line;
@@ -2081,13 +2137,18 @@ async function startBroker(options) {
2081
2137
  if (params.human === true || params.observe === true || params.resume !== void 0) {
2082
2138
  throw new RelayError4("invalid_params", "a hook connection claims an agent endpoint: transient does not go with human, observe or resume");
2083
2139
  }
2084
- const before2 = new Map(core.list({ all: true }).map((view) => [view.id, view.presence]));
2085
- 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 };
2086
2145
  s.data.endpoint = endpoint2.id;
2087
2146
  s.data.transient = true;
2088
2147
  const key = params.claim?.process;
2089
2148
  if (typeof key === "string") s.data.process = key;
2090
- 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);
2091
2152
  return {
2092
2153
  endpoint: endpoint2,
2093
2154
  ...subagent ? { subagent, ...created ? { created } : {} } : {},
@@ -2226,6 +2287,22 @@ async function startBroker(options) {
2226
2287
  bridge?.changed(me);
2227
2288
  return view;
2228
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
+ }
2229
2306
  case "status": {
2230
2307
  core.status(me, params.state, params.note);
2231
2308
  const view = core.whoami(me);
@@ -2241,14 +2318,25 @@ async function startBroker(options) {
2241
2318
  if ((known.get(me) ?? "offline") !== after.presence) presenceChanged(after);
2242
2319
  return row;
2243
2320
  }
2244
- case "inject":
2245
- return core.inject(me, {
2321
+ case "inject": {
2322
+ const waitMs = waitSeconds(params.wait_s);
2323
+ const options2 = {
2246
2324
  stop: params.stop === true,
2247
2325
  count: params.count === true,
2248
2326
  holdUnread: params.hold_unread === true,
2249
2327
  directives: params.directives === true,
2328
+ wake: waitMs > 0 && params.stop !== true && params.count !== true,
2250
2329
  ...hookOf(s)
2251
- });
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
+ }
2252
2340
  case "stop_wait": {
2253
2341
  const waitMs = waitSeconds(params.wait_s);
2254
2342
  const deadline = Date.now() + waitMs;
@@ -2267,7 +2355,8 @@ async function startBroker(options) {
2267
2355
  case "prompted": {
2268
2356
  const prompted = core.prompted(me, hookOf(s));
2269
2357
  if (prompted) wake(me);
2270
- return { prompted };
2358
+ const tldr = prompted && typeof params.tldr_every === "number" && core.tldrDue(me, params.tldr_every);
2359
+ return { prompted, ...tldr ? { tldr } : {} };
2271
2360
  }
2272
2361
  case "injected":
2273
2362
  return {
@@ -2482,7 +2571,7 @@ async function startBroker(options) {
2482
2571
  unlinkSync2(socket);
2483
2572
  } catch {
2484
2573
  }
2485
- if (socket !== join3(dir, "relay.sock")) {
2574
+ if (socket !== join4(dir, "relay.sock")) {
2486
2575
  try {
2487
2576
  rmdirSync(dirname2(socket));
2488
2577
  } catch {
@@ -2527,11 +2616,13 @@ export {
2527
2616
  localHostName,
2528
2617
  policyPath,
2529
2618
  privateDir,
2619
+ readSessionMarker,
2530
2620
  relayDir,
2531
2621
  relaySocketUrl,
2532
2622
  shortHostname,
2533
2623
  socketPath,
2534
2624
  startBroker,
2535
2625
  systemHostname,
2536
- thisHostname
2626
+ thisHostname,
2627
+ writeSessionMarker
2537
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.2",
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,6 +73,8 @@ 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 */
@@ -257,9 +259,10 @@ export function startBridge(options: BridgeOptions): Bridge {
257
259
  }
258
260
 
259
261
  async function handshake(socket: WebSocket) {
260
- 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 })
261
263
  account = hello.account
262
264
  core.setAliases([account])
265
+ core.setOthers(account, Array.isArray(hello.others) ? hello.others.filter((name): name is string => typeof name === "string") : [])
263
266
  ready = true
264
267
  applyRemote(hello.endpoints, true)
265
268
  await Promise.all(fullMirror().map((params) => request("mirror", params)))
@@ -354,7 +357,7 @@ export function startBridge(options: BridgeOptions): Bridge {
354
357
  function localAddress(address: unknown): unknown {
355
358
  if (typeof address !== "string" || !account || !address.startsWith(`${account}/`)) return address
356
359
  const rest = address.slice(account.length + 1)
357
- return rest.includes("/") ? `${core.user}/${rest}` : `${core.user}/${account}/${rest}`
360
+ return rest.includes("/") ? core.remoteAddress(rest) : `${core.user}/${account}/${rest}`
358
361
  }
359
362
 
360
363
  /** the lowest seq a fresh subscribe must replay from: every delivery at or below it was acked */
@@ -391,6 +394,7 @@ export function startBridge(options: BridgeOptions): Bridge {
391
394
  }
392
395
  case "endpoints": {
393
396
  if (typeof params.machine !== "string" || params.machine === machine) return
397
+ if (typeof params.own === "boolean") core.setOther(params.machine, !params.own)
394
398
  try {
395
399
  core.mirror(params.machine, params.endpoints, { full: params.full === true })
396
400
  } catch (error) {
@@ -601,6 +605,18 @@ export function startBridge(options: BridgeOptions): Bridge {
601
605
  if (mirrored(view)) fireAndForget("mirror", { endpoints: [outward(view)], full: false })
602
606
  }
603
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
+
604
620
  function resync() {
605
621
  if (!ready) return
606
622
  let frames: ReturnType<typeof fullMirror>
@@ -654,5 +670,5 @@ export function startBridge(options: BridgeOptions): Bridge {
654
670
  }
655
671
 
656
672
  connect()
657
- return { forward, changed, resync, activity, contacts, status, relink, stop }
673
+ return { forward, changed, resync, renamed, activity, contacts, status, relink, stop }
658
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
 
@@ -134,7 +135,7 @@ const CHANGES_SCOPE = new Set(["rules_set", "rules_widen", "rules_lift", "group"
134
135
  const ASK_RECHECK_MS = 1000
135
136
  /** how often a waiting stop rechecks its workers, since a worker's exit or lapse wakes no one */
136
137
  const STOP_RECHECK_MS = 1000
137
- 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"])
138
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)`
139
140
 
140
141
  /** the workers' messages and handbacks a stop's push would show, and those of theirs left unread past it */
@@ -260,6 +261,7 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
260
261
  const known = new Map<string, Presence>()
261
262
  for (const view of localViews()) known.set(view.id, view.presence)
262
263
  const presenceTimer = setInterval(() => {
264
+ core.endExited()
263
265
  const live = new Set<string>()
264
266
  for (const view of localViews()) {
265
267
  live.add(view.id)
@@ -383,6 +385,22 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
383
385
  bridge?.changed(view.id)
384
386
  }
385
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
+
386
404
  function notifyFleet(event: Record<string, unknown>) {
387
405
  const at = new Date((options.now ?? Date.now)()).toISOString()
388
406
  let line: Uint8Array | undefined
@@ -677,13 +695,21 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
677
695
  if (params.human === true || params.observe === true || params.resume !== undefined) {
678
696
  throw new RelayError("invalid_params", "a hook connection claims an agent endpoint: transient does not go with human, observe or resume")
679
697
  }
680
- const before = new Map(core.list({ all: true }).map((view) => [view.id, view.presence]))
681
- 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 }
682
706
  s.data.endpoint = endpoint.id
683
707
  s.data.transient = true
684
708
  const key = (params.claim as Claim | undefined)?.process
685
709
  if (typeof key === "string") s.data.process = key
686
- 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)
687
713
  return {
688
714
  endpoint,
689
715
  ...(subagent ? { subagent, ...(created ? { created } : {}) } : {}),
@@ -822,6 +848,22 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
822
848
  bridge?.changed(me)
823
849
  return view
824
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
+ }
825
867
  case "status": {
826
868
  core.status(me, params.state, params.note)
827
869
  const view = core.whoami(me)
@@ -837,14 +879,25 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
837
879
  if ((known.get(me) ?? "offline") !== after.presence) presenceChanged(after)
838
880
  return row
839
881
  }
840
- case "inject":
841
- return core.inject(me, {
882
+ case "inject": {
883
+ const waitMs = waitSeconds(params.wait_s)
884
+ const options = {
842
885
  stop: params.stop === true,
843
886
  count: params.count === true,
844
887
  holdUnread: params.hold_unread === true,
845
888
  directives: params.directives === true,
889
+ wake: waitMs > 0 && params.stop !== true && params.count !== true,
846
890
  ...hookOf(s),
847
- })
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
+ }
848
901
  case "stop_wait": {
849
902
  const waitMs = waitSeconds(params.wait_s)
850
903
  const deadline = Date.now() + waitMs
@@ -863,7 +916,8 @@ export async function startBroker(options: BrokerOptions): Promise<Broker> {
863
916
  case "prompted": {
864
917
  const prompted = core.prompted(me, hookOf(s))
865
918
  if (prompted) wake(me)
866
- return { prompted }
919
+ const tldr = prompted && typeof params.tldr_every === "number" && core.tldrDue(me, params.tldr_every)
920
+ return { prompted, ...(tldr ? { tldr } : {}) }
867
921
  }
868
922
  case "injected":
869
923
  return {
@@ -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