@scp-dev/core 0.1.5 → 0.2.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/index.d.ts CHANGED
@@ -998,11 +998,12 @@ declare function relationText(peer: PolicySubject, me: PolicySubject): string;
998
998
  /**
999
999
  * The scope check for a message from `a` to `b`: `out(a)` admits `b` and `in(b)` admits `a`. Either side the account's human (with `humanAlways`)
1000
1000
  * or the system endpoint passes, except an `unverified` human sender (one without a terminal), which is checked like any unplaced sender; an
1001
- * ancestor of `b` (unless `strict`) and a solicited reply pass both.
1001
+ * ancestor of `b` (unless `strict`), a solicited reply and a parent and child `linked` across machines pass both.
1002
1002
  */
1003
1003
  declare function scopeDecision(world: ScopeWorld, a: ScopeNode, b: ScopeNode, options: {
1004
1004
  humanAlways: boolean;
1005
1005
  solicited?: boolean;
1006
+ linked?: boolean;
1006
1007
  unverified?: boolean;
1007
1008
  strict?: boolean;
1008
1009
  }): ScopeDecision;
@@ -1085,6 +1086,8 @@ interface EndpointRow {
1085
1086
  /** the root endpoint's id; null for a root */
1086
1087
  tree: string | null;
1087
1088
  parent_ep: string | null;
1089
+ /** a parent on another machine of the account, `<machine>/<handle>`: the row stays a root of its own tree */
1090
+ parent_addr: string | null;
1088
1091
  depth: number | null;
1089
1092
  /** JSON {@link ScopeRules} */
1090
1093
  rules: string | null;
@@ -1109,6 +1112,10 @@ interface EndpointRow {
1109
1112
  linked: number | null;
1110
1113
  /** how the session that holds the endpoint wakes it: a {@link Wake} other than none, or null */
1111
1114
  wake: string | null;
1115
+ /** the live connections that set the wake */
1116
+ wakers: number;
1117
+ /** when the last hook connection that set the wake closed */
1118
+ waker_left: number | null;
1112
1119
  }
1113
1120
  interface MessageRow {
1114
1121
  id: string;
@@ -1171,6 +1178,8 @@ declare class RelayCore {
1171
1178
  constructor(sql: Sql, options: CoreOptions);
1172
1179
  /** a `machine` label naming this host by its hostname, as clients default it, names it by its machine name when one is set */
1173
1180
  private machineLabel;
1181
+ /** this host's own agent rows labelled with another machine (its name before a rename, or its hostname) take its machine name */
1182
+ relabelMachine(): number;
1174
1183
  migrate(): void;
1175
1184
  /** every stored list that is exactly `tree:self`, the tree preset's old list, narrows once to the preset's list */
1176
1185
  private migrateTreePreset;
@@ -1256,6 +1265,8 @@ declare class RelayCore {
1256
1265
  * whether a claim placed under `parent` restarts `row`: it lands where `row` sits (under `row`'s parent, or a root with no placement), or on
1257
1266
  * `row` itself from `row`'s own process; never under `row` or elsewhere in its tree
1258
1267
  */
1268
+ /** a local row held only by connections whose harness process exited, as a shim that outlived its harness leaves it */
1269
+ private outlived;
1259
1270
  private restarts;
1260
1271
  /**
1261
1272
  * whether the claim re-claims `row` with the grant `row` spent: from `row`'s own process or one of its ancestors, or with no lineage from
@@ -1344,6 +1355,15 @@ declare class RelayCore {
1344
1355
  transient?: boolean;
1345
1356
  process?: string;
1346
1357
  }): boolean;
1358
+ /**
1359
+ * a connection that set the endpoint's wake starts (`live`) or stops counting as its waker. A hook's (`grace`) still counts for
1360
+ * LIMITS.restartGraceS after it closes, while its harness starts the next one
1361
+ */
1362
+ waker(endpoint: string, live: boolean, options?: {
1363
+ grace?: boolean;
1364
+ }): void;
1365
+ /** clears a wake no waker holds once a hook waker's grace ran out too: nothing is left to wake the endpoint */
1366
+ private lapseWake;
1347
1367
  /**
1348
1368
  * a title for the caller's in-process subagent `id` from the description its parent gave it; the subagent's endpoint id when it changed one.
1349
1369
  * It fills only a title nobody set, as an inferred one, and waits for the subagent's claim when it has no endpoint yet
@@ -1388,8 +1408,22 @@ declare class RelayCore {
1388
1408
  endpoint: EndpointView;
1389
1409
  parent: string;
1390
1410
  };
1391
- /** `row`, a root, and its subtree moved under `parent`, a local agent that is neither `row` nor one of its descendants */
1411
+ /**
1412
+ * `row`, a root, and its subtree moved under `parent`, a local agent that is neither `row` nor one of its descendants; a parent on another
1413
+ * machine goes to {@link linkAcross}
1414
+ */
1392
1415
  private link;
1416
+ /** a link of `row`, which already sits under a parent, to another one */
1417
+ private alreadyLinked;
1418
+ /**
1419
+ * the key `<machine>/<handle>` that links `row`, a local agent, under `parent`, an agent on another machine, once the parent is checked; no
1420
+ * inherited or child-default caps come with it. A relay of one machine has no such parent
1421
+ */
1422
+ protected linkAcross(_row: EndpointRow, _parent: EndpointRow): string;
1423
+ /** whether `a` and `b` are a parent and its child on different machines */
1424
+ protected linkedAcross(_a: EndpointRow, _b: EndpointRow): boolean;
1425
+ /** the address of `row`'s parent on another machine, null when it has none; with `shown`, null for a parent outside it */
1426
+ protected linkedParent(_row: EndpointRow, _shown?: Set<string>): string | null;
1393
1427
  /**
1394
1428
  * renames an agent in place: its id, mail, threads, status, title and relations stay. An agent renames only itself, and only a root unless
1395
1429
  * the policy's `defaults.rename` is `self`; the human at a terminal renames any local agent. The new handle is valid and held by no other endpoint
@@ -1404,6 +1438,8 @@ declare class RelayCore {
1404
1438
  };
1405
1439
  /** the endpoint's handle set to `value`, with the stored `ep:` and `fleet:` selectors that named it */
1406
1440
  private renameRow;
1441
+ /** a local agent's handle moved from `from` to `to`: a subclass updates what names it on another machine's rows */
1442
+ protected renamedHandle(_from: string, _to: string): void;
1407
1443
  /**
1408
1444
  * a session leaves: it lets go of its session and token and shows offline, its mail kept; a later join takes it back. An agent leaves only
1409
1445
  * itself, the human at a terminal any local root or linked agent. A spawned worker stays joined while it runs
@@ -1685,7 +1721,7 @@ declare class RelayCore {
1685
1721
  }): AuditRow[];
1686
1722
  /** drops what expired: deliveries, message bodies past their grace, old guard state and old audit rows */
1687
1723
  sweep(): void;
1688
- /** ends the hook presence of local agent endpoints whose harness process exited without a session_end, as one would */
1724
+ /** ends the hook presence of local agent endpoints whose harness process exited without a session_end, as one would, and lapses wakes whose hook waker never came back */
1689
1725
  endExited(now?: number): void;
1690
1726
  /** an agent endpoint left offline drops a working or waiting status, which no one is left to keep current */
1691
1727
  private settleOffline;
package/dist/index.js CHANGED
@@ -558,6 +558,7 @@ function scopeDecision(world, a, b, options) {
558
558
  if (options.humanAlways && (b.human || a.human && !options.unverified)) return { allowed: true, reasons: ["the human is always reachable"] };
559
559
  if (!options.strict && isAncestor(a, b)) return { allowed: true, reasons: [`${a.address} is an ancestor of ${b.address}: scopes never stop it`] };
560
560
  if (options.solicited) return { allowed: true, reasons: [`a reply to ${b.address}'s message in its thread`] };
561
+ if (options.linked) return { allowed: true, reasons: [`${a.address} and ${b.address} are parent and child across machines`] };
561
562
  const memo = /* @__PURE__ */ new Map();
562
563
  const out = admits(world, a, "out", b, memo);
563
564
  if (!out.ok) return { allowed: false, dir: "out", reasons: [out.why] };
@@ -1306,6 +1307,19 @@ var RelayCore = class {
1306
1307
  if (this.namedMachine !== null && labels.machine !== void 0 && labels.machine === this.hostname) labels.machine = this.namedMachine;
1307
1308
  return labels;
1308
1309
  }
1310
+ /** this host's own agent rows labelled with another machine (its name before a rename, or its hostname) take its machine name */
1311
+ relabelMachine() {
1312
+ const machine = this.namedMachine;
1313
+ if (machine === null) return 0;
1314
+ let changed = 0;
1315
+ for (const row of this.sql.all(`SELECT * FROM endpoints WHERE kind = 'agent' AND labels LIKE '%"machine"%'`)) {
1316
+ const labels = json(row.labels, {});
1317
+ if (labels.machine === void 0 || labels.machine === machine || !this.localRow(row)) continue;
1318
+ this.sql.all(`UPDATE endpoints SET labels = ? WHERE id = ?`, JSON.stringify({ ...labels, machine }), row.id);
1319
+ changed++;
1320
+ }
1321
+ return changed;
1322
+ }
1309
1323
  migrate() {
1310
1324
  for (const statement of MIGRATION) this.sql.all(statement);
1311
1325
  const columns = this.sql.all(`PRAGMA table_info(idempotency)`).map((c) => c.name);
@@ -1325,6 +1339,7 @@ var RelayCore = class {
1325
1339
  for (const [column, type] of [
1326
1340
  ["tree", "TEXT"],
1327
1341
  ["parent_ep", "TEXT"],
1342
+ ["parent_addr", "TEXT"],
1328
1343
  ["depth", "INTEGER"],
1329
1344
  ["rules", "TEXT"],
1330
1345
  ["caps", "TEXT"],
@@ -1344,7 +1359,9 @@ var RelayCore = class {
1344
1359
  ["tldr_turn", "INTEGER"],
1345
1360
  ["named", "INTEGER"],
1346
1361
  ["linked", "INTEGER"],
1347
- ["wake", "TEXT"]
1362
+ ["wake", "TEXT"],
1363
+ ["wakers", "INTEGER NOT NULL DEFAULT 0"],
1364
+ ["waker_left", "INTEGER"]
1348
1365
  ]) {
1349
1366
  if (!endpointColumns.includes(column)) this.sql.all(`ALTER TABLE endpoints ADD COLUMN ${column} ${type}`);
1350
1367
  }
@@ -1621,7 +1638,7 @@ var RelayCore = class {
1621
1638
  if (row && presenceOf(row, now) === "offline") {
1622
1639
  this.sql.all(`UPDATE endpoints SET session = NULL WHERE id = ?`, row.id);
1623
1640
  row = void 0;
1624
- } else if (row && !transient && !sameProcess(row, fields.process)) {
1641
+ } else if (row && !transient && !sameProcess(row, fields.process) && !(row.handle === wanted && reclaims(row, fields.process))) {
1625
1642
  row = void 0;
1626
1643
  session = null;
1627
1644
  } else if (row && !transient && row.token_hash === null && row.named === null && row.handle !== wanted && !row.handle.startsWith(`${wanted.slice(0, 58)}-`)) {
@@ -1663,7 +1680,7 @@ var RelayCore = class {
1663
1680
  this.sql.all(`INSERT INTO endpoints (id, handle, kind, harness, labels, created_at) VALUES (?, ?, 'agent', ?, '{}', ?)`, id, handle, harness, now);
1664
1681
  row = this.byId(id);
1665
1682
  created = true;
1666
- } else if (held.kind === "agent" && held.connections === 0 && presenceOf(held, now) === "offline" && harnessFits(held.harness, harness) && held.id !== scope.subagent?.root && !(scope.subagent && held.session?.startsWith("sub:") && held.session !== fields.session)) {
1683
+ } else if (held.kind === "agent" && (held.connections === 0 && presenceOf(held, now) === "offline" || !transient && this.outlived(held)) && harnessFits(held.harness, harness) && held.id !== scope.subagent?.root && !(scope.subagent && held.session?.startsWith("sub:") && held.session !== fields.session)) {
1667
1684
  own = transient && held.process !== null && fields.process !== null && sameHarness(held.process, fields.process);
1668
1685
  const ended = held.hook_ended !== null || (held.last_seen ?? held.created_at) < now - LIMITS.restartGraceS * 1e3;
1669
1686
  if ((!transient || own || ended) && (this.restarts(held, this.placement(scope, now).parent, fields.process) || this.redeems(held, scope, { harness, ...fields })))
@@ -1872,6 +1889,11 @@ var RelayCore = class {
1872
1889
  * whether a claim placed under `parent` restarts `row`: it lands where `row` sits (under `row`'s parent, or a root with no placement), or on
1873
1890
  * `row` itself from `row`'s own process; never under `row` or elsewhere in its tree
1874
1891
  */
1892
+ /** a local row held only by connections whose harness process exited, as a shim that outlived its harness leaves it */
1893
+ outlived(row) {
1894
+ const pid = Number(/^pid:(\d+):/.exec(row.process ?? "")?.[1]);
1895
+ return row.connections > 0 && pid > 0 && this.localRow(row) && !this.alive(pid);
1896
+ }
1875
1897
  restarts(row, parent, process) {
1876
1898
  if (parent?.id === row.id) return process !== null && row.process !== null && sameHarness(row.process, process);
1877
1899
  return (parent?.id ?? null) === row.parent_ep || parent === null && row.linked !== null;
@@ -1922,7 +1944,7 @@ var RelayCore = class {
1922
1944
  }
1923
1945
  caps.caps.push(...this.capsOf(row).caps);
1924
1946
  this.sql.all(
1925
- `UPDATE endpoints SET tree = ?, parent_ep = ?, depth = ?, rules = ?, caps = ? WHERE id = ?`,
1947
+ `UPDATE endpoints SET tree = ?, parent_ep = ?, parent_addr = NULL, depth = ?, rules = ?, caps = ? WHERE id = ?`,
1926
1948
  parent ? parent.tree ?? parent.id : null,
1927
1949
  parent?.id ?? null,
1928
1950
  depth,
@@ -2036,7 +2058,7 @@ var RelayCore = class {
2036
2058
  endpoint
2037
2059
  );
2038
2060
  const row = this.mustEndpoint(endpoint);
2039
- if (!options.settle || presenceOf(row, now) !== "offline" || row.status_state !== "working" && row.status_state !== "waiting") return false;
2061
+ if (!options.settle || shownPresence(row, now) !== "offline" || row.status_state !== "working" && row.status_state !== "waiting") return false;
2040
2062
  this.sql.all(`UPDATE endpoints SET status_state = NULL, status_note = NULL WHERE id = ?`, endpoint);
2041
2063
  return true;
2042
2064
  }
@@ -2051,6 +2073,7 @@ var RelayCore = class {
2051
2073
  `UPDATE endpoints SET connections = 0, hook_seen = CASE WHEN kind = 'agent' AND hook_ended IS NULL THEN MAX(COALESCE(hook_seen, 0), ?) ELSE hook_seen END WHERE connections > 0`,
2052
2074
  this.now() - (LIMITS.hookIdleS - LIMITS.restartGraceS) * 1e3
2053
2075
  );
2076
+ this.sql.all(`UPDATE endpoints SET wakers = 0, waker_left = ? WHERE wakers > 0`, this.now());
2054
2077
  }
2055
2078
  whoami(endpoint) {
2056
2079
  return this.view(this.mustEndpoint(endpoint));
@@ -2068,7 +2091,7 @@ var RelayCore = class {
2068
2091
  const viewer = filters.viewer === void 0 ? void 0 : this.mustEndpoint(filters.viewer);
2069
2092
  const shown = viewer ? new Set(this.visibleRows(viewer).map((row) => row.id)) : void 0;
2070
2093
  const peeker = viewer ?? (filters.peekable === true ? this.byHandle(HUMAN_HANDLE) : void 0);
2071
- return this.listed().filter((row) => filters.all || row.connections > 0 || row.kind === "human" || (row.last_seen ?? 0) >= cutoff).filter((row) => !filters.harness || row.harness === filters.harness).filter((row) => !filters.kind || row.kind === filters.kind).filter((row) => !viewer || row.id === viewer.id || this.mayMessage(viewer, row, viewer.kind === "human")).map((row) => {
2094
+ return this.listed().filter((row) => filters.all || row.connections > 0 || row.kind === "human" || (row.last_seen ?? 0) >= cutoff || waking(row, now)).filter((row) => !filters.harness || row.harness === filters.harness).filter((row) => !filters.kind || row.kind === filters.kind).filter((row) => !viewer || row.id === viewer.id || this.mayMessage(viewer, row, viewer.kind === "human")).map((row) => {
2072
2095
  const view = this.view(row, shown);
2073
2096
  return peeker && this.peekableBy(peeker, row, viewer?.kind === "human") ? { ...view, peekable: row.peek } : view;
2074
2097
  }).filter((view) => !filters.presence || view.presence === filters.presence);
@@ -2168,6 +2191,27 @@ var RelayCore = class {
2168
2191
  this.sql.all(`UPDATE endpoints SET wake = ? WHERE id = ?`, wake, endpoint);
2169
2192
  return true;
2170
2193
  }
2194
+ /**
2195
+ * a connection that set the endpoint's wake starts (`live`) or stops counting as its waker. A hook's (`grace`) still counts for
2196
+ * LIMITS.restartGraceS after it closes, while its harness starts the next one
2197
+ */
2198
+ waker(endpoint, live, options = {}) {
2199
+ if (live) this.sql.all(`UPDATE endpoints SET wakers = wakers + 1 WHERE id = ?`, endpoint);
2200
+ else {
2201
+ const now = this.now();
2202
+ this.sql.all(
2203
+ `UPDATE endpoints SET wakers = MAX(wakers - 1, 0), waker_left = CASE WHEN ? THEN ? ELSE waker_left END WHERE id = ?`,
2204
+ options.grace ? 1 : 0,
2205
+ now,
2206
+ endpoint
2207
+ );
2208
+ if (!options.grace) this.lapseWake(endpoint, now);
2209
+ }
2210
+ }
2211
+ /** clears a wake no waker holds once a hook waker's grace ran out too: nothing is left to wake the endpoint */
2212
+ lapseWake(endpoint, now) {
2213
+ this.sql.all(`UPDATE endpoints SET wake = NULL WHERE id = ? AND wakers = 0 AND COALESCE(waker_left, 0) < ?`, endpoint, now - LIMITS.restartGraceS * 1e3);
2214
+ }
2171
2215
  /**
2172
2216
  * a title for the caller's in-process subagent `id` from the description its parent gave it; the subagent's endpoint id when it changed one.
2173
2217
  * It fills only a title nobody set, as an inferred one, and waits for the subagent's claim when it has no endpoint yet
@@ -2272,7 +2316,8 @@ var RelayCore = class {
2272
2316
  const target = params.endpoint === void 0 ? me : this.resolve(params.endpoint);
2273
2317
  if (actor.kind !== "human" && target.id !== me.id) throw new RelayError("scope_refused", "an agent unlinks only itself");
2274
2318
  if (target.kind !== "agent" || !this.localRow(target)) throw new RelayError("invalid_params", "only a local agent endpoint can be unlinked");
2275
- if (target.parent_ep === null) throw new RelayError("invalid_params", `${this.address(target)} is a root: it has no parent`);
2319
+ if (target.parent_ep === null && target.parent_addr === null)
2320
+ throw new RelayError("invalid_params", `${this.address(target)} is a root: it has no parent`);
2276
2321
  if (actor.kind !== "human" && this.policy?.defaults?.unlink !== "self")
2277
2322
  throw this.scopeRefused(
2278
2323
  actor,
@@ -2280,6 +2325,14 @@ var RelayCore = class {
2280
2325
  { target: this.address(target), reason: "unlink" },
2281
2326
  "unlinking widens a worker's scope: only the human at a terminal unlinks it (policy defaults.unlink: self lets it)"
2282
2327
  );
2328
+ if (target.parent_ep === null) {
2329
+ const from2 = this.linkedParent(target) ?? target.parent_addr;
2330
+ this.sql.all(`UPDATE endpoints SET parent_addr = NULL, linked = NULL WHERE id = ?`, target.id);
2331
+ this.scopeMemo?.ancestors.clear();
2332
+ this.scopeMemo?.limited.clear();
2333
+ this.audit("unlink", target.id, null, { ...this.actorDetail(actor, me), target: this.address(target), parent: from2 });
2334
+ return { endpoint: this.whoami(target.id), parent: from2 };
2335
+ }
2283
2336
  const parent = this.byId(target.parent_ep);
2284
2337
  const from = parent ? this.address(parent) : target.parent_ep;
2285
2338
  const shift = target.depth ?? 0;
@@ -2303,16 +2356,26 @@ var RelayCore = class {
2303
2356
  return { endpoint: this.whoami(target.id), parent: from };
2304
2357
  });
2305
2358
  }
2306
- /** `row`, a root, and its subtree moved under `parent`, a local agent that is neither `row` nor one of its descendants */
2359
+ /**
2360
+ * `row`, a root, and its subtree moved under `parent`, a local agent that is neither `row` nor one of its descendants; a parent on another
2361
+ * machine goes to {@link linkAcross}
2362
+ */
2307
2363
  link(row, parent, by) {
2308
- if (parent.kind !== "agent" || !this.localRow(parent)) throw new RelayError("invalid_params", "the parent is a local agent endpoint");
2364
+ if (!this.localRow(parent)) {
2365
+ const key = this.linkAcross(row, parent);
2366
+ if (row.parent_addr === key) return;
2367
+ if (row.parent_ep !== null || row.parent_addr !== null) throw this.alreadyLinked(row);
2368
+ this.sql.all(`UPDATE endpoints SET parent_addr = ?, linked = ? WHERE id = ?`, key, this.now(), row.id);
2369
+ this.scopeMemo?.ancestors.clear();
2370
+ this.scopeMemo?.limited.clear();
2371
+ this.audit("adopt", row.id, null, { ...by, target: this.address(row), parent: this.address(parent) });
2372
+ return;
2373
+ }
2374
+ if (parent.kind !== "agent") throw new RelayError("invalid_params", "the parent is an agent endpoint");
2309
2375
  if (row.parent_ep === parent.id) return;
2310
2376
  if (parent.id === row.id || this.ancestorsOf(parent).includes(row.id))
2311
2377
  throw new RelayError("scope_refused", `${this.address(parent)} sits under ${this.address(row)}: a session cannot go under its own descendant`);
2312
- if (row.parent_ep !== null) {
2313
- const now = this.byId(row.parent_ep);
2314
- throw new RelayError("scope_refused", `${this.address(row)} already sits under ${now ? this.address(now) : "another agent"}; unlink it first`);
2315
- }
2378
+ if (row.parent_ep !== null || row.parent_addr !== null) throw this.alreadyLinked(row);
2316
2379
  const depth = (parent.depth ?? 0) + 1;
2317
2380
  const deepest = one(this.sql, `SELECT MAX(depth) AS d FROM endpoints WHERE tree = ?`, row.id)?.d ?? 0;
2318
2381
  if (deepest + depth > LIMITS.depthMax)
@@ -2336,6 +2399,27 @@ var RelayCore = class {
2336
2399
  this.scopeMemo?.limited.clear();
2337
2400
  this.audit("adopt", row.id, null, { ...by, target: this.address(row), parent: this.address(parent) });
2338
2401
  }
2402
+ /** a link of `row`, which already sits under a parent, to another one */
2403
+ alreadyLinked(row) {
2404
+ const local = row.parent_ep ? this.byId(row.parent_ep) : void 0;
2405
+ const now = local ? this.address(local) : row.parent_addr !== null ? this.linkedParent(row) ?? row.parent_addr : "another agent";
2406
+ return new RelayError("scope_refused", `${this.address(row)} already sits under ${now}; unlink it first`);
2407
+ }
2408
+ /**
2409
+ * the key `<machine>/<handle>` that links `row`, a local agent, under `parent`, an agent on another machine, once the parent is checked; no
2410
+ * inherited or child-default caps come with it. A relay of one machine has no such parent
2411
+ */
2412
+ linkAcross(_row, _parent) {
2413
+ throw new RelayError("invalid_params", "the parent is a local agent endpoint");
2414
+ }
2415
+ /** whether `a` and `b` are a parent and its child on different machines */
2416
+ linkedAcross(_a, _b) {
2417
+ return false;
2418
+ }
2419
+ /** the address of `row`'s parent on another machine, null when it has none; with `shown`, null for a parent outside it */
2420
+ linkedParent(_row, _shown) {
2421
+ return null;
2422
+ }
2339
2423
  /**
2340
2424
  * renames an agent in place: its id, mail, threads, status, title and relations stay. An agent renames only itself, and only a root unless
2341
2425
  * the policy's `defaults.rename` is `self`; the human at a terminal renames any local agent. The new handle is valid and held by no other endpoint
@@ -2383,8 +2467,12 @@ var RelayCore = class {
2383
2467
  ])
2384
2468
  this.sql.all(`UPDATE ${table} SET ${column} = REPLACE(${column}, ?, ?) WHERE instr(${column}, ?) > 0`, a, b, a);
2385
2469
  }
2470
+ this.renamedHandle(from, handle);
2386
2471
  this.audit("rename", row.id, null, { from, to: handle });
2387
2472
  }
2473
+ /** a local agent's handle moved from `from` to `to`: a subclass updates what names it on another machine's rows */
2474
+ renamedHandle(_from, _to) {
2475
+ }
2388
2476
  /**
2389
2477
  * a session leaves: it lets go of its session and token and shows offline, its mail kept; a later join takes it back. An agent leaves only
2390
2478
  * itself, the human at a terminal any local root or linked agent. A spawned worker stays joined while it runs
@@ -2469,7 +2557,7 @@ var RelayCore = class {
2469
2557
  const recipients = [...new Map(resolved.map((row) => [row.id, row])).values()];
2470
2558
  if (params.live !== void 0 && typeof params.live !== "boolean") throw new RelayError("invalid_params", "live must be true or false");
2471
2559
  const dark = recipients.filter(
2472
- (r) => r.kind === "agent" && presenceOf(r, now) === "offline" && (now - Math.max(r.last_seen ?? 0, r.hook_seen ?? 0) >= ASK_ENDED_GRACE_MS || r.session === null && r.token_hash === null && r.hook_ended !== null) && !this.unjoinedSpawn(sender, r.handle, now)
2560
+ (r) => r.kind === "agent" && shownPresence(r, now) === "offline" && (now - Math.max(r.last_seen ?? 0, r.hook_seen ?? 0) >= ASK_ENDED_GRACE_MS || r.session === null && r.token_hash === null && r.hook_ended !== null) && !this.unjoinedSpawn(sender, r.handle, now)
2473
2561
  );
2474
2562
  if (params.live === true && dark.length)
2475
2563
  throw new RelayError("not_live", `no live session holds ${dark.map((r) => this.address(r)).join(", ")}: nothing was queued`);
@@ -2714,7 +2802,7 @@ var RelayCore = class {
2714
2802
  reach(sender, recipient, offline, now) {
2715
2803
  if (recipient.kind !== "agent" || !this.localRow(recipient)) return void 0;
2716
2804
  const done = recipient.status_state === "done";
2717
- if ((done || presenceOf(recipient, now) === "offline") && this.unjoinedSpawn(sender, recipient.handle, now)) return "starting";
2805
+ if ((done || shownPresence(recipient, now) === "offline") && this.unjoinedSpawn(sender, recipient.handle, now)) return "starting";
2718
2806
  if (done) return "ended";
2719
2807
  return offline ? "offline" : "running";
2720
2808
  }
@@ -3113,7 +3201,7 @@ var RelayCore = class {
3113
3201
  continue;
3114
3202
  }
3115
3203
  const row = this.byId(grant.used_by);
3116
- if (!row || row.status_state === "done" || presenceOf(row, now) === "offline" && (grant.handback !== null || !pid || !this.alive(pid))) continue;
3204
+ if (!row || row.status_state === "done" || shownPresence(row, now) === "offline" && (grant.handback !== null || !pid || !this.alive(pid))) continue;
3117
3205
  workers.set(row.id, row);
3118
3206
  if (grant.pushed_at !== null && grant.pushed_at > now - LIMITS.pushGapS * 1e3) paced.add(row.id);
3119
3207
  }
@@ -3476,9 +3564,9 @@ var RelayCore = class {
3476
3564
  request,
3477
3565
  endpoint
3478
3566
  ).filter(
3479
- (row) => present ? present.includes(row.id) : presenceOf(row, now) !== "offline" || Math.max(row.last_seen ?? 0, row.hook_seen ?? 0, row.hook_ended ?? 0) >= asked.created_at
3567
+ (row) => present ? present.includes(row.id) : shownPresence(row, now) !== "offline" || Math.max(row.last_seen ?? 0, row.hook_seen ?? 0, row.hook_ended ?? 0) >= asked.created_at
3480
3568
  );
3481
- const gone = (row) => presenceOf(row, now) === "offline" && ((row.hook_ended ?? 0) >= asked.created_at || row.status_state === "done" || now - Math.max(row.last_seen ?? 0, row.hook_seen ?? 0) >= ASK_ENDED_GRACE_MS);
3569
+ const gone = (row) => shownPresence(row, now) === "offline" && ((row.hook_ended ?? 0) >= asked.created_at || row.status_state === "done" || now - Math.max(row.last_seen ?? 0, row.hook_seen ?? 0) >= ASK_ENDED_GRACE_MS);
3482
3570
  if (!rows.length || !rows.every(gone)) return null;
3483
3571
  return rows.map((row) => ({ address: this.address(row), state: row.status_state }));
3484
3572
  }
@@ -3558,22 +3646,31 @@ var RelayCore = class {
3558
3646
  if (!root || root.handle.startsWith(GONE_PREFIX) || !seen) this.dropGroup(group.name);
3559
3647
  }
3560
3648
  }
3561
- /** ends the hook presence of local agent endpoints whose harness process exited without a session_end, as one would */
3649
+ /** ends the hook presence of local agent endpoints whose harness process exited without a session_end, as one would, and lapses wakes whose hook waker never came back */
3562
3650
  endExited(now = this.now()) {
3563
3651
  for (const row of this.sql.all(
3564
3652
  `SELECT * FROM endpoints WHERE kind = 'agent' AND connections = 0 AND hook_seen IS NOT NULL AND process IS NOT NULL`
3565
3653
  )) {
3566
3654
  const pid = Number(/^pid:(\d+)/.exec(row.process)?.[1]);
3567
3655
  if (!pid || !this.localRow(row) || this.alive(pid)) continue;
3568
- this.sql.all(`UPDATE endpoints SET hook_seen = NULL, hook_ended = ?, session = NULL WHERE id = ? AND connections = 0`, now, row.id);
3656
+ this.sql.all(
3657
+ `UPDATE endpoints SET hook_seen = NULL, hook_ended = ?, session = NULL, wake = CASE WHEN wakers = 0 THEN NULL ELSE wake END WHERE id = ? AND connections = 0`,
3658
+ now,
3659
+ row.id
3660
+ );
3569
3661
  }
3662
+ for (const row of this.sql.all(
3663
+ `SELECT * FROM endpoints WHERE wake IS NOT NULL AND wakers = 0 AND waker_left < ?`,
3664
+ now - LIMITS.restartGraceS * 1e3
3665
+ ))
3666
+ if (this.localRow(row)) this.lapseWake(row.id, now);
3570
3667
  }
3571
3668
  /** an agent endpoint left offline drops a working or waiting status, which no one is left to keep current */
3572
3669
  settleOffline(now) {
3573
3670
  for (const row of this.sql.all(
3574
3671
  `SELECT * FROM endpoints WHERE kind = 'agent' AND connections = 0 AND status_state IN ('working', 'waiting')`
3575
3672
  )) {
3576
- if (this.localRow(row) && presenceOf(row, now) === "offline")
3673
+ if (this.localRow(row) && shownPresence(row, now) === "offline")
3577
3674
  this.sql.all(`UPDATE endpoints SET status_state = NULL, status_note = NULL WHERE id = ?`, row.id);
3578
3675
  }
3579
3676
  }
@@ -3616,7 +3713,8 @@ var RelayCore = class {
3616
3713
  if (params.kind === "session_end") {
3617
3714
  if (own)
3618
3715
  this.sql.all(
3619
- `UPDATE endpoints SET hook_seen = NULL, hook_ended = ?, session = CASE WHEN connections = 0 THEN NULL ELSE session END WHERE id = ?`,
3716
+ `UPDATE endpoints SET hook_seen = NULL, hook_ended = ?, session = CASE WHEN connections = 0 THEN NULL ELSE session END,
3717
+ wake = CASE WHEN wakers = 0 THEN NULL ELSE wake END WHERE id = ?`,
3620
3718
  now,
3621
3719
  endpoint
3622
3720
  );
@@ -4245,7 +4343,8 @@ var RelayCore = class {
4245
4343
  }
4246
4344
  const note = [`exited ${p.code}`, spender.status_note].filter(Boolean).join("; ").slice(0, LIMITS.noteChars);
4247
4345
  this.sql.all(
4248
- `UPDATE endpoints SET status_state = 'done', status_note = ?, hook_seen = NULL, hook_ended = ?, session = CASE WHEN connections = 0 THEN NULL ELSE session END WHERE id = ?`,
4346
+ `UPDATE endpoints SET status_state = 'done', status_note = ?, hook_seen = NULL, hook_ended = ?, session = CASE WHEN connections = 0 THEN NULL ELSE session END,
4347
+ wake = CASE WHEN wakers = 0 THEN NULL ELSE wake END WHERE id = ?`,
4249
4348
  note,
4250
4349
  now,
4251
4350
  spender.id
@@ -4670,6 +4769,7 @@ ${stderr}` : "";
4670
4769
  return scopeDecision(world, world.node(a.id) ?? this.scopeNode(a), world.node(b.id) ?? this.scopeNode(b), {
4671
4770
  humanAlways: this.defaults().humanAlways,
4672
4771
  solicited,
4772
+ linked: a.id !== b.id && this.linkedAcross(a, b),
4673
4773
  unverified,
4674
4774
  strict
4675
4775
  });
@@ -5382,7 +5482,7 @@ ${stderr}` : "";
5382
5482
  }
5383
5483
  /** with `shown`, the ids a viewer sees: a parent outside it is null */
5384
5484
  view(row, shown) {
5385
- const presence = presenceOf(row, this.now());
5485
+ const presence = shownPresence(row, this.now());
5386
5486
  return {
5387
5487
  id: row.id,
5388
5488
  address: this.address(row),
@@ -5400,7 +5500,8 @@ ${stderr}` : "";
5400
5500
  }
5401
5501
  placeView(row, shown) {
5402
5502
  const parent = row.parent_ep && (!shown || shown.has(row.parent_ep)) ? this.byId(row.parent_ep) : void 0;
5403
- return { parent: parent ? this.address(parent) : null, depth: row.depth ?? 0, scope: presetOf(this.scopeNode(row), this.defaults().symmetric) };
5503
+ const across = !parent && row.parent_ep === null && row.parent_addr ? this.linkedParent(row, shown) : null;
5504
+ return { parent: parent ? this.address(parent) : across, depth: row.depth ?? 0, scope: presetOf(this.scopeNode(row), this.defaults().symmetric) };
5404
5505
  }
5405
5506
  /** `recipient` names the delivery's endpoint when there is no `seq`: a send to several addresses shows each recipient only itself */
5406
5507
  envelope(row, seq, recipient) {
@@ -5575,6 +5676,9 @@ function unreachable(address) {
5575
5676
  var PROCESS_KEY = /^pid:\d+:[A-Za-z0-9:-]{1,64}$/;
5576
5677
  var BARE_PID_KEY = /^pid:\d+$/;
5577
5678
  var ANY_PROCESS_KEY = /^pid:\d+(:[A-Za-z0-9:-]{1,64})?$/;
5679
+ function reclaims(row, key) {
5680
+ return row.connections > 0 && key !== null && row.process === key && PROCESS_KEY.test(key);
5681
+ }
5578
5682
  function sameProcess(row, key) {
5579
5683
  if (row.connections > 0 || key === null || row.process === null) return false;
5580
5684
  return row.token_hash === null ? sameHarness(row.process, key) : row.process === key && PROCESS_KEY.test(key);
@@ -5615,6 +5719,13 @@ function presenceOf(row, now) {
5615
5719
  if (row.hook_seen >= now - LIMITS.hookOnlineS * 1e3) return "online";
5616
5720
  return row.hook_seen >= now - LIMITS.hookIdleS * 1e3 ? "idle" : "offline";
5617
5721
  }
5722
+ function waking(row, now) {
5723
+ return wakeOf(row) !== null && (row.wakers > 0 || (row.waker_left ?? 0) >= now - LIMITS.restartGraceS * 1e3);
5724
+ }
5725
+ function shownPresence(row, now) {
5726
+ const presence = presenceOf(row, now);
5727
+ return presence === "offline" && waking(row, now) ? "idle" : presence;
5728
+ }
5618
5729
  function pairKey(a, b) {
5619
5730
  return a < b ? `${a}|${b}` : `${b}|${a}`;
5620
5731
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scp-dev/core",
3
- "version": "0.1.5",
3
+ "version": "0.2.0",
4
4
  "description": "Swarm Communication Protocol core: wire types, framing, scopes, policy, judge and the relay core.",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://scp.dev",
package/src/core.ts CHANGED
@@ -243,6 +243,8 @@ export interface EndpointRow {
243
243
  /** the root endpoint's id; null for a root */
244
244
  tree: string | null
245
245
  parent_ep: string | null
246
+ /** a parent on another machine of the account, `<machine>/<handle>`: the row stays a root of its own tree */
247
+ parent_addr: string | null
246
248
  depth: number | null
247
249
  /** JSON {@link ScopeRules} */
248
250
  rules: string | null
@@ -267,6 +269,10 @@ export interface EndpointRow {
267
269
  linked: number | null
268
270
  /** how the session that holds the endpoint wakes it: a {@link Wake} other than none, or null */
269
271
  wake: string | null
272
+ /** the live connections that set the wake */
273
+ wakers: number
274
+ /** when the last hook connection that set the wake closed */
275
+ waker_left: number | null
270
276
  }
271
277
 
272
278
  export interface MessageRow {
@@ -496,6 +502,20 @@ export class RelayCore {
496
502
  return labels
497
503
  }
498
504
 
505
+ /** this host's own agent rows labelled with another machine (its name before a rename, or its hostname) take its machine name */
506
+ relabelMachine(): number {
507
+ const machine = this.namedMachine
508
+ if (machine === null) return 0
509
+ let changed = 0
510
+ for (const row of this.sql.all<EndpointRow>(`SELECT * FROM endpoints WHERE kind = 'agent' AND labels LIKE '%"machine"%'`)) {
511
+ const labels = json<Record<string, string>>(row.labels, {})
512
+ if (labels.machine === undefined || labels.machine === machine || !this.localRow(row)) continue
513
+ this.sql.all(`UPDATE endpoints SET labels = ? WHERE id = ?`, JSON.stringify({ ...labels, machine }), row.id)
514
+ changed++
515
+ }
516
+ return changed
517
+ }
518
+
499
519
  migrate(): void {
500
520
  for (const statement of MIGRATION) this.sql.all(statement)
501
521
  const columns = this.sql.all<{ name: string }>(`PRAGMA table_info(idempotency)`).map((c) => c.name)
@@ -515,6 +535,7 @@ export class RelayCore {
515
535
  for (const [column, type] of [
516
536
  ["tree", "TEXT"],
517
537
  ["parent_ep", "TEXT"],
538
+ ["parent_addr", "TEXT"],
518
539
  ["depth", "INTEGER"],
519
540
  ["rules", "TEXT"],
520
541
  ["caps", "TEXT"],
@@ -535,6 +556,8 @@ export class RelayCore {
535
556
  ["named", "INTEGER"],
536
557
  ["linked", "INTEGER"],
537
558
  ["wake", "TEXT"],
559
+ ["wakers", "INTEGER NOT NULL DEFAULT 0"],
560
+ ["waker_left", "INTEGER"],
538
561
  ] as const) {
539
562
  if (!endpointColumns.includes(column)) this.sql.all(`ALTER TABLE endpoints ADD COLUMN ${column} ${type}`)
540
563
  }
@@ -834,7 +857,7 @@ export class RelayCore {
834
857
  if (row && presenceOf(row, now) === "offline") {
835
858
  this.sql.all(`UPDATE endpoints SET session = NULL WHERE id = ?`, row.id)
836
859
  row = undefined
837
- } else if (row && !transient && !sameProcess(row, fields.process)) {
860
+ } else if (row && !transient && !sameProcess(row, fields.process) && !(row.handle === wanted && reclaims(row, fields.process))) {
838
861
  row = undefined
839
862
  session = null
840
863
  } else if (
@@ -884,8 +907,7 @@ export class RelayCore {
884
907
  created = true
885
908
  } else if (
886
909
  held.kind === "agent" &&
887
- held.connections === 0 &&
888
- presenceOf(held, now) === "offline" &&
910
+ ((held.connections === 0 && presenceOf(held, now) === "offline") || (!transient && this.outlived(held))) &&
889
911
  harnessFits(held.harness, harness) &&
890
912
  held.id !== scope.subagent?.root &&
891
913
  !(scope.subagent && held.session?.startsWith("sub:") && held.session !== fields.session)
@@ -1124,6 +1146,12 @@ export class RelayCore {
1124
1146
  * whether a claim placed under `parent` restarts `row`: it lands where `row` sits (under `row`'s parent, or a root with no placement), or on
1125
1147
  * `row` itself from `row`'s own process; never under `row` or elsewhere in its tree
1126
1148
  */
1149
+ /** a local row held only by connections whose harness process exited, as a shim that outlived its harness leaves it */
1150
+ private outlived(row: EndpointRow): boolean {
1151
+ const pid = Number(/^pid:(\d+):/.exec(row.process ?? "")?.[1])
1152
+ return row.connections > 0 && pid > 0 && this.localRow(row) && !this.alive(pid)
1153
+ }
1154
+
1127
1155
  private restarts(row: EndpointRow, parent: EndpointRow | null, process: string | null): boolean {
1128
1156
  if (parent?.id === row.id) return process !== null && row.process !== null && sameHarness(row.process, process)
1129
1157
  return (parent?.id ?? null) === row.parent_ep || (parent === null && row.linked !== null)
@@ -1178,7 +1206,7 @@ export class RelayCore {
1178
1206
  }
1179
1207
  caps.caps.push(...this.capsOf(row).caps)
1180
1208
  this.sql.all(
1181
- `UPDATE endpoints SET tree = ?, parent_ep = ?, depth = ?, rules = ?, caps = ? WHERE id = ?`,
1209
+ `UPDATE endpoints SET tree = ?, parent_ep = ?, parent_addr = NULL, depth = ?, rules = ?, caps = ? WHERE id = ?`,
1182
1210
  parent ? (parent.tree ?? parent.id) : null,
1183
1211
  parent?.id ?? null,
1184
1212
  depth,
@@ -1305,7 +1333,7 @@ export class RelayCore {
1305
1333
  endpoint,
1306
1334
  )
1307
1335
  const row = this.mustEndpoint(endpoint)
1308
- if (!options.settle || presenceOf(row, now) !== "offline" || (row.status_state !== "working" && row.status_state !== "waiting")) return false
1336
+ if (!options.settle || shownPresence(row, now) !== "offline" || (row.status_state !== "working" && row.status_state !== "waiting")) return false
1309
1337
  this.sql.all(`UPDATE endpoints SET status_state = NULL, status_note = NULL WHERE id = ?`, endpoint)
1310
1338
  return true
1311
1339
  }
@@ -1322,6 +1350,7 @@ export class RelayCore {
1322
1350
  `UPDATE endpoints SET connections = 0, hook_seen = CASE WHEN kind = 'agent' AND hook_ended IS NULL THEN MAX(COALESCE(hook_seen, 0), ?) ELSE hook_seen END WHERE connections > 0`,
1323
1351
  this.now() - (LIMITS.hookIdleS - LIMITS.restartGraceS) * 1000,
1324
1352
  )
1353
+ this.sql.all(`UPDATE endpoints SET wakers = 0, waker_left = ? WHERE wakers > 0`, this.now())
1325
1354
  }
1326
1355
 
1327
1356
  whoami(endpoint: string): EndpointView {
@@ -1351,7 +1380,7 @@ export class RelayCore {
1351
1380
  const shown = viewer ? new Set(this.visibleRows(viewer).map((row) => row.id)) : undefined
1352
1381
  const peeker = viewer ?? (filters.peekable === true ? this.byHandle(HUMAN_HANDLE)! : undefined)
1353
1382
  return this.listed()
1354
- .filter((row) => filters.all || row.connections > 0 || row.kind === "human" || (row.last_seen ?? 0) >= cutoff)
1383
+ .filter((row) => filters.all || row.connections > 0 || row.kind === "human" || (row.last_seen ?? 0) >= cutoff || waking(row, now))
1355
1384
  .filter((row) => !filters.harness || row.harness === filters.harness)
1356
1385
  .filter((row) => !filters.kind || row.kind === filters.kind)
1357
1386
  .filter((row) => !viewer || row.id === viewer.id || this.mayMessage(viewer, row, viewer.kind === "human"))
@@ -1468,6 +1497,29 @@ export class RelayCore {
1468
1497
  return true
1469
1498
  }
1470
1499
 
1500
+ /**
1501
+ * a connection that set the endpoint's wake starts (`live`) or stops counting as its waker. A hook's (`grace`) still counts for
1502
+ * LIMITS.restartGraceS after it closes, while its harness starts the next one
1503
+ */
1504
+ waker(endpoint: string, live: boolean, options: { grace?: boolean } = {}): void {
1505
+ if (live) this.sql.all(`UPDATE endpoints SET wakers = wakers + 1 WHERE id = ?`, endpoint)
1506
+ else {
1507
+ const now = this.now()
1508
+ this.sql.all(
1509
+ `UPDATE endpoints SET wakers = MAX(wakers - 1, 0), waker_left = CASE WHEN ? THEN ? ELSE waker_left END WHERE id = ?`,
1510
+ options.grace ? 1 : 0,
1511
+ now,
1512
+ endpoint,
1513
+ )
1514
+ if (!options.grace) this.lapseWake(endpoint, now)
1515
+ }
1516
+ }
1517
+
1518
+ /** clears a wake no waker holds once a hook waker's grace ran out too: nothing is left to wake the endpoint */
1519
+ private lapseWake(endpoint: string, now: number): void {
1520
+ this.sql.all(`UPDATE endpoints SET wake = NULL WHERE id = ? AND wakers = 0 AND COALESCE(waker_left, 0) < ?`, endpoint, now - LIMITS.restartGraceS * 1000)
1521
+ }
1522
+
1471
1523
  /**
1472
1524
  * a title for the caller's in-process subagent `id` from the description its parent gave it; the subagent's endpoint id when it changed one.
1473
1525
  * It fills only a title nobody set, as an inferred one, and waits for the subagent's claim when it has no endpoint yet
@@ -1579,7 +1631,8 @@ export class RelayCore {
1579
1631
  const target = params.endpoint === undefined ? me : this.resolve(params.endpoint)
1580
1632
  if (actor.kind !== "human" && target.id !== me.id) throw new RelayError("scope_refused", "an agent unlinks only itself")
1581
1633
  if (target.kind !== "agent" || !this.localRow(target)) throw new RelayError("invalid_params", "only a local agent endpoint can be unlinked")
1582
- if (target.parent_ep === null) throw new RelayError("invalid_params", `${this.address(target)} is a root: it has no parent`)
1634
+ if (target.parent_ep === null && target.parent_addr === null)
1635
+ throw new RelayError("invalid_params", `${this.address(target)} is a root: it has no parent`)
1583
1636
  if (actor.kind !== "human" && this.policy?.defaults?.unlink !== "self")
1584
1637
  throw this.scopeRefused(
1585
1638
  actor,
@@ -1587,6 +1640,14 @@ export class RelayCore {
1587
1640
  { target: this.address(target), reason: "unlink" },
1588
1641
  "unlinking widens a worker's scope: only the human at a terminal unlinks it (policy defaults.unlink: self lets it)",
1589
1642
  )
1643
+ if (target.parent_ep === null) {
1644
+ const from = this.linkedParent(target) ?? target.parent_addr!
1645
+ this.sql.all(`UPDATE endpoints SET parent_addr = NULL, linked = NULL WHERE id = ?`, target.id)
1646
+ this.scopeMemo?.ancestors.clear()
1647
+ this.scopeMemo?.limited.clear()
1648
+ this.audit("unlink", target.id, null, { ...this.actorDetail(actor, me), target: this.address(target), parent: from })
1649
+ return { endpoint: this.whoami(target.id), parent: from }
1650
+ }
1590
1651
  const parent = this.byId(target.parent_ep)
1591
1652
  const from = parent ? this.address(parent) : target.parent_ep
1592
1653
  const shift = target.depth ?? 0
@@ -1611,16 +1672,26 @@ export class RelayCore {
1611
1672
  })
1612
1673
  }
1613
1674
 
1614
- /** `row`, a root, and its subtree moved under `parent`, a local agent that is neither `row` nor one of its descendants */
1675
+ /**
1676
+ * `row`, a root, and its subtree moved under `parent`, a local agent that is neither `row` nor one of its descendants; a parent on another
1677
+ * machine goes to {@link linkAcross}
1678
+ */
1615
1679
  private link(row: EndpointRow, parent: EndpointRow, by: { by: string; auth: string }): void {
1616
- if (parent.kind !== "agent" || !this.localRow(parent)) throw new RelayError("invalid_params", "the parent is a local agent endpoint")
1680
+ if (!this.localRow(parent)) {
1681
+ const key = this.linkAcross(row, parent)
1682
+ if (row.parent_addr === key) return
1683
+ if (row.parent_ep !== null || row.parent_addr !== null) throw this.alreadyLinked(row)
1684
+ this.sql.all(`UPDATE endpoints SET parent_addr = ?, linked = ? WHERE id = ?`, key, this.now(), row.id)
1685
+ this.scopeMemo?.ancestors.clear()
1686
+ this.scopeMemo?.limited.clear()
1687
+ this.audit("adopt", row.id, null, { ...by, target: this.address(row), parent: this.address(parent) })
1688
+ return
1689
+ }
1690
+ if (parent.kind !== "agent") throw new RelayError("invalid_params", "the parent is an agent endpoint")
1617
1691
  if (row.parent_ep === parent.id) return
1618
1692
  if (parent.id === row.id || this.ancestorsOf(parent).includes(row.id))
1619
1693
  throw new RelayError("scope_refused", `${this.address(parent)} sits under ${this.address(row)}: a session cannot go under its own descendant`)
1620
- if (row.parent_ep !== null) {
1621
- const now = this.byId(row.parent_ep)
1622
- throw new RelayError("scope_refused", `${this.address(row)} already sits under ${now ? this.address(now) : "another agent"}; unlink it first`)
1623
- }
1694
+ if (row.parent_ep !== null || row.parent_addr !== null) throw this.alreadyLinked(row)
1624
1695
  const depth = (parent.depth ?? 0) + 1
1625
1696
  const deepest = one<{ d: number | null }>(this.sql, `SELECT MAX(depth) AS d FROM endpoints WHERE tree = ?`, row.id)?.d ?? 0
1626
1697
  if (deepest + depth > LIMITS.depthMax)
@@ -1645,6 +1716,31 @@ export class RelayCore {
1645
1716
  this.audit("adopt", row.id, null, { ...by, target: this.address(row), parent: this.address(parent) })
1646
1717
  }
1647
1718
 
1719
+ /** a link of `row`, which already sits under a parent, to another one */
1720
+ private alreadyLinked(row: EndpointRow): RelayError {
1721
+ const local = row.parent_ep ? this.byId(row.parent_ep) : undefined
1722
+ const now = local ? this.address(local) : row.parent_addr !== null ? (this.linkedParent(row) ?? row.parent_addr) : "another agent"
1723
+ return new RelayError("scope_refused", `${this.address(row)} already sits under ${now}; unlink it first`)
1724
+ }
1725
+
1726
+ /**
1727
+ * the key `<machine>/<handle>` that links `row`, a local agent, under `parent`, an agent on another machine, once the parent is checked; no
1728
+ * inherited or child-default caps come with it. A relay of one machine has no such parent
1729
+ */
1730
+ protected linkAcross(_row: EndpointRow, _parent: EndpointRow): string {
1731
+ throw new RelayError("invalid_params", "the parent is a local agent endpoint")
1732
+ }
1733
+
1734
+ /** whether `a` and `b` are a parent and its child on different machines */
1735
+ protected linkedAcross(_a: EndpointRow, _b: EndpointRow): boolean {
1736
+ return false
1737
+ }
1738
+
1739
+ /** the address of `row`'s parent on another machine, null when it has none; with `shown`, null for a parent outside it */
1740
+ protected linkedParent(_row: EndpointRow, _shown?: Set<string>): string | null {
1741
+ return null
1742
+ }
1743
+
1648
1744
  /**
1649
1745
  * renames an agent in place: its id, mail, threads, status, title and relations stay. An agent renames only itself, and only a root unless
1650
1746
  * the policy's `defaults.rename` is `self`; the human at a terminal renames any local agent. The new handle is valid and held by no other endpoint
@@ -1693,9 +1789,13 @@ export class RelayCore {
1693
1789
  ] as const)
1694
1790
  this.sql.all(`UPDATE ${table} SET ${column} = REPLACE(${column}, ?, ?) WHERE instr(${column}, ?) > 0`, a, b, a)
1695
1791
  }
1792
+ this.renamedHandle(from, handle)
1696
1793
  this.audit("rename", row.id, null, { from, to: handle })
1697
1794
  }
1698
1795
 
1796
+ /** a local agent's handle moved from `from` to `to`: a subclass updates what names it on another machine's rows */
1797
+ protected renamedHandle(_from: string, _to: string): void {}
1798
+
1699
1799
  /**
1700
1800
  * a session leaves: it lets go of its session and token and shows offline, its mail kept; a later join takes it back. An agent leaves only
1701
1801
  * itself, the human at a terminal any local root or linked agent. A spawned worker stays joined while it runs
@@ -1787,7 +1887,7 @@ export class RelayCore {
1787
1887
  const dark = recipients.filter(
1788
1888
  (r) =>
1789
1889
  r.kind === "agent" &&
1790
- presenceOf(r, now) === "offline" &&
1890
+ shownPresence(r, now) === "offline" &&
1791
1891
  (now - Math.max(r.last_seen ?? 0, r.hook_seen ?? 0) >= ASK_ENDED_GRACE_MS || (r.session === null && r.token_hash === null && r.hook_ended !== null)) &&
1792
1892
  !this.unjoinedSpawn(sender, r.handle, now),
1793
1893
  )
@@ -2047,7 +2147,7 @@ export class RelayCore {
2047
2147
  private reach(sender: EndpointRow, recipient: EndpointRow, offline: boolean, now: number): Reach | undefined {
2048
2148
  if (recipient.kind !== "agent" || !this.localRow(recipient)) return undefined
2049
2149
  const done = recipient.status_state === "done"
2050
- if ((done || presenceOf(recipient, now) === "offline") && this.unjoinedSpawn(sender, recipient.handle, now)) return "starting"
2150
+ if ((done || shownPresence(recipient, now) === "offline") && this.unjoinedSpawn(sender, recipient.handle, now)) return "starting"
2051
2151
  if (done) return "ended"
2052
2152
  return offline ? "offline" : "running"
2053
2153
  }
@@ -2500,7 +2600,7 @@ export class RelayCore {
2500
2600
  continue
2501
2601
  }
2502
2602
  const row = this.byId(grant.used_by)
2503
- if (!row || row.status_state === "done" || (presenceOf(row, now) === "offline" && (grant.handback !== null || !pid || !this.alive(pid)))) continue
2603
+ if (!row || row.status_state === "done" || (shownPresence(row, now) === "offline" && (grant.handback !== null || !pid || !this.alive(pid)))) continue
2504
2604
  workers.set(row.id, row)
2505
2605
  if (grant.pushed_at !== null && grant.pushed_at > now - LIMITS.pushGapS * 1000) paced.add(row.id)
2506
2606
  }
@@ -2902,10 +3002,10 @@ export class RelayCore {
2902
3002
  .filter((row) =>
2903
3003
  present
2904
3004
  ? present.includes(row.id)
2905
- : presenceOf(row, now) !== "offline" || Math.max(row.last_seen ?? 0, row.hook_seen ?? 0, row.hook_ended ?? 0) >= asked.created_at,
3005
+ : shownPresence(row, now) !== "offline" || Math.max(row.last_seen ?? 0, row.hook_seen ?? 0, row.hook_ended ?? 0) >= asked.created_at,
2906
3006
  )
2907
3007
  const gone = (row: EndpointRow): boolean =>
2908
- presenceOf(row, now) === "offline" &&
3008
+ shownPresence(row, now) === "offline" &&
2909
3009
  ((row.hook_ended ?? 0) >= asked.created_at || row.status_state === "done" || now - Math.max(row.last_seen ?? 0, row.hook_seen ?? 0) >= ASK_ENDED_GRACE_MS)
2910
3010
  if (!rows.length || !rows.every(gone)) return null
2911
3011
  return rows.map((row) => ({ address: this.address(row), state: row.status_state as StatusState | null }))
@@ -3003,15 +3103,24 @@ export class RelayCore {
3003
3103
  }
3004
3104
  }
3005
3105
 
3006
- /** ends the hook presence of local agent endpoints whose harness process exited without a session_end, as one would */
3106
+ /** ends the hook presence of local agent endpoints whose harness process exited without a session_end, as one would, and lapses wakes whose hook waker never came back */
3007
3107
  endExited(now = this.now()): void {
3008
3108
  for (const row of this.sql.all<EndpointRow>(
3009
3109
  `SELECT * FROM endpoints WHERE kind = 'agent' AND connections = 0 AND hook_seen IS NOT NULL AND process IS NOT NULL`,
3010
3110
  )) {
3011
3111
  const pid = Number(/^pid:(\d+)/.exec(row.process!)?.[1])
3012
3112
  if (!pid || !this.localRow(row) || this.alive(pid)) continue
3013
- this.sql.all(`UPDATE endpoints SET hook_seen = NULL, hook_ended = ?, session = NULL WHERE id = ? AND connections = 0`, now, row.id)
3113
+ this.sql.all(
3114
+ `UPDATE endpoints SET hook_seen = NULL, hook_ended = ?, session = NULL, wake = CASE WHEN wakers = 0 THEN NULL ELSE wake END WHERE id = ? AND connections = 0`,
3115
+ now,
3116
+ row.id,
3117
+ )
3014
3118
  }
3119
+ for (const row of this.sql.all<EndpointRow>(
3120
+ `SELECT * FROM endpoints WHERE wake IS NOT NULL AND wakers = 0 AND waker_left < ?`,
3121
+ now - LIMITS.restartGraceS * 1000,
3122
+ ))
3123
+ if (this.localRow(row)) this.lapseWake(row.id, now)
3015
3124
  }
3016
3125
 
3017
3126
  /** an agent endpoint left offline drops a working or waiting status, which no one is left to keep current */
@@ -3019,7 +3128,7 @@ export class RelayCore {
3019
3128
  for (const row of this.sql.all<EndpointRow>(
3020
3129
  `SELECT * FROM endpoints WHERE kind = 'agent' AND connections = 0 AND status_state IN ('working', 'waiting')`,
3021
3130
  )) {
3022
- if (this.localRow(row) && presenceOf(row, now) === "offline")
3131
+ if (this.localRow(row) && shownPresence(row, now) === "offline")
3023
3132
  this.sql.all(`UPDATE endpoints SET status_state = NULL, status_note = NULL WHERE id = ?`, row.id)
3024
3133
  }
3025
3134
  }
@@ -3068,7 +3177,8 @@ export class RelayCore {
3068
3177
  if (params.kind === "session_end") {
3069
3178
  if (own)
3070
3179
  this.sql.all(
3071
- `UPDATE endpoints SET hook_seen = NULL, hook_ended = ?, session = CASE WHEN connections = 0 THEN NULL ELSE session END WHERE id = ?`,
3180
+ `UPDATE endpoints SET hook_seen = NULL, hook_ended = ?, session = CASE WHEN connections = 0 THEN NULL ELSE session END,
3181
+ wake = CASE WHEN wakers = 0 THEN NULL ELSE wake END WHERE id = ?`,
3072
3182
  now,
3073
3183
  endpoint,
3074
3184
  )
@@ -3780,7 +3890,8 @@ export class RelayCore {
3780
3890
  }
3781
3891
  const note = [`exited ${p.code}`, spender.status_note].filter(Boolean).join("; ").slice(0, LIMITS.noteChars)
3782
3892
  this.sql.all(
3783
- `UPDATE endpoints SET status_state = 'done', status_note = ?, hook_seen = NULL, hook_ended = ?, session = CASE WHEN connections = 0 THEN NULL ELSE session END WHERE id = ?`,
3893
+ `UPDATE endpoints SET status_state = 'done', status_note = ?, hook_seen = NULL, hook_ended = ?, session = CASE WHEN connections = 0 THEN NULL ELSE session END,
3894
+ wake = CASE WHEN wakers = 0 THEN NULL ELSE wake END WHERE id = ?`,
3784
3895
  note,
3785
3896
  now,
3786
3897
  spender.id,
@@ -4269,6 +4380,7 @@ export class RelayCore {
4269
4380
  return scopeDecision(world, world.node(a.id) ?? this.scopeNode(a), world.node(b.id) ?? this.scopeNode(b), {
4270
4381
  humanAlways: this.defaults().humanAlways,
4271
4382
  solicited,
4383
+ linked: a.id !== b.id && this.linkedAcross(a, b),
4272
4384
  unverified,
4273
4385
  strict,
4274
4386
  })
@@ -5079,7 +5191,7 @@ export class RelayCore {
5079
5191
 
5080
5192
  /** with `shown`, the ids a viewer sees: a parent outside it is null */
5081
5193
  protected view(row: EndpointRow, shown?: Set<string>): EndpointView {
5082
- const presence = presenceOf(row, this.now())
5194
+ const presence = shownPresence(row, this.now())
5083
5195
  return {
5084
5196
  id: row.id,
5085
5197
  address: this.address(row),
@@ -5098,7 +5210,8 @@ export class RelayCore {
5098
5210
 
5099
5211
  private placeView(row: EndpointRow, shown?: Set<string>): Pick<EndpointView, "parent" | "depth" | "scope"> {
5100
5212
  const parent = row.parent_ep && (!shown || shown.has(row.parent_ep)) ? this.byId(row.parent_ep) : undefined
5101
- return { parent: parent ? this.address(parent) : null, depth: row.depth ?? 0, scope: presetOf(this.scopeNode(row), this.defaults().symmetric) }
5213
+ const across = !parent && row.parent_ep === null && row.parent_addr ? this.linkedParent(row, shown) : null
5214
+ return { parent: parent ? this.address(parent) : across, depth: row.depth ?? 0, scope: presetOf(this.scopeNode(row), this.defaults().symmetric) }
5102
5215
  }
5103
5216
 
5104
5217
  /** `recipient` names the delivery's endpoint when there is no `seq`: a send to several addresses shows each recipient only itself */
@@ -5315,6 +5428,11 @@ const ANY_PROCESS_KEY = /^pid:\d+(:[A-Za-z0-9:-]{1,64})?$/
5315
5428
  * whether a connecting claim from the harness process `key` may join a session endpoint: no connection holds it, the harness process that last
5316
5429
  * claimed it claims again, and, once a connection held it (a token was handed out), with the same full key, which a recycled pid cannot match
5317
5430
  */
5431
+ /** a claim from the very harness process that holds the live `row`, such as a session it restarted in process */
5432
+ function reclaims(row: EndpointRow, key: string | null): boolean {
5433
+ return row.connections > 0 && key !== null && row.process === key && PROCESS_KEY.test(key)
5434
+ }
5435
+
5318
5436
  function sameProcess(row: EndpointRow, key: string | null): boolean {
5319
5437
  if (row.connections > 0 || key === null || row.process === null) return false
5320
5438
  return row.token_hash === null ? sameHarness(row.process, key) : row.process === key && PROCESS_KEY.test(key)
@@ -5379,6 +5497,17 @@ function presenceOf(row: EndpointRow, now: number): Presence {
5379
5497
  return row.hook_seen >= now - LIMITS.hookIdleS * 1000 ? "idle" : "offline"
5380
5498
  }
5381
5499
 
5500
+ /** a wake with a live waker, or a hook waker that left within LIMITS.restartGraceS */
5501
+ function waking(row: EndpointRow, now: number): boolean {
5502
+ return wakeOf(row) !== null && (row.wakers > 0 || (row.waker_left ?? 0) >= now - LIMITS.restartGraceS * 1000)
5503
+ }
5504
+
5505
+ /** the presence an endpoint shows: one its waker can wake is at least idle, though its session's own presence decayed */
5506
+ function shownPresence(row: EndpointRow, now: number): Presence {
5507
+ const presence = presenceOf(row, now)
5508
+ return presence === "offline" && waking(row, now) ? "idle" : presence
5509
+ }
5510
+
5382
5511
  function pairKey(a: string, b: string): string {
5383
5512
  return a < b ? `${a}|${b}` : `${b}|${a}`
5384
5513
  }
package/src/scopes.ts CHANGED
@@ -293,19 +293,20 @@ export function relationText(peer: PolicySubject, me: PolicySubject): string {
293
293
  /**
294
294
  * The scope check for a message from `a` to `b`: `out(a)` admits `b` and `in(b)` admits `a`. Either side the account's human (with `humanAlways`)
295
295
  * or the system endpoint passes, except an `unverified` human sender (one without a terminal), which is checked like any unplaced sender; an
296
- * ancestor of `b` (unless `strict`) and a solicited reply pass both.
296
+ * ancestor of `b` (unless `strict`), a solicited reply and a parent and child `linked` across machines pass both.
297
297
  */
298
298
  export function scopeDecision(
299
299
  world: ScopeWorld,
300
300
  a: ScopeNode,
301
301
  b: ScopeNode,
302
- options: { humanAlways: boolean; solicited?: boolean; unverified?: boolean; strict?: boolean },
302
+ options: { humanAlways: boolean; solicited?: boolean; linked?: boolean; unverified?: boolean; strict?: boolean },
303
303
  ): ScopeDecision {
304
304
  const kinds = [a.subject.kind, b.subject.kind]
305
305
  if (kinds.includes("system")) return { allowed: true, reasons: ["the relay itself"] }
306
306
  if (options.humanAlways && (b.human || (a.human && !options.unverified))) return { allowed: true, reasons: ["the human is always reachable"] }
307
307
  if (!options.strict && isAncestor(a, b)) return { allowed: true, reasons: [`${a.address} is an ancestor of ${b.address}: scopes never stop it`] }
308
308
  if (options.solicited) return { allowed: true, reasons: [`a reply to ${b.address}'s message in its thread`] }
309
+ if (options.linked) return { allowed: true, reasons: [`${a.address} and ${b.address} are parent and child across machines`] }
309
310
  const memo: Memo = new Map()
310
311
  const out = admits(world, a, "out", b, memo)
311
312
  if (!out.ok) return { allowed: false, dir: "out", reasons: [out.why] }