@scp-dev/core 0.1.6 → 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;
@@ -1359,6 +1362,8 @@ declare class RelayCore {
1359
1362
  waker(endpoint: string, live: boolean, options?: {
1360
1363
  grace?: boolean;
1361
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;
1362
1367
  /**
1363
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.
1364
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
@@ -1403,8 +1408,22 @@ declare class RelayCore {
1403
1408
  endpoint: EndpointView;
1404
1409
  parent: string;
1405
1410
  };
1406
- /** `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
+ */
1407
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;
1408
1427
  /**
1409
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
1410
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
@@ -1419,6 +1438,8 @@ declare class RelayCore {
1419
1438
  };
1420
1439
  /** the endpoint's handle set to `value`, with the stored `ep:` and `fleet:` selectors that named it */
1421
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;
1422
1443
  /**
1423
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
1424
1445
  * itself, the human at a terminal any local root or linked agent. A spawned worker stays joined while it runs
@@ -1700,7 +1721,7 @@ declare class RelayCore {
1700
1721
  }): AuditRow[];
1701
1722
  /** drops what expired: deliveries, message bodies past their grace, old guard state and old audit rows */
1702
1723
  sweep(): void;
1703
- /** 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 */
1704
1725
  endExited(now?: number): void;
1705
1726
  /** an agent endpoint left offline drops a working or waiting status, which no one is left to keep current */
1706
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] };
@@ -1338,6 +1339,7 @@ var RelayCore = class {
1338
1339
  for (const [column, type] of [
1339
1340
  ["tree", "TEXT"],
1340
1341
  ["parent_ep", "TEXT"],
1342
+ ["parent_addr", "TEXT"],
1341
1343
  ["depth", "INTEGER"],
1342
1344
  ["rules", "TEXT"],
1343
1345
  ["caps", "TEXT"],
@@ -1942,7 +1944,7 @@ var RelayCore = class {
1942
1944
  }
1943
1945
  caps.caps.push(...this.capsOf(row).caps);
1944
1946
  this.sql.all(
1945
- `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 = ?`,
1946
1948
  parent ? parent.tree ?? parent.id : null,
1947
1949
  parent?.id ?? null,
1948
1950
  depth,
@@ -2195,13 +2197,20 @@ var RelayCore = class {
2195
2197
  */
2196
2198
  waker(endpoint, live, options = {}) {
2197
2199
  if (live) this.sql.all(`UPDATE endpoints SET wakers = wakers + 1 WHERE id = ?`, endpoint);
2198
- else
2200
+ else {
2201
+ const now = this.now();
2199
2202
  this.sql.all(
2200
2203
  `UPDATE endpoints SET wakers = MAX(wakers - 1, 0), waker_left = CASE WHEN ? THEN ? ELSE waker_left END WHERE id = ?`,
2201
2204
  options.grace ? 1 : 0,
2202
- this.now(),
2205
+ now,
2203
2206
  endpoint
2204
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);
2205
2214
  }
2206
2215
  /**
2207
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.
@@ -2307,7 +2316,8 @@ var RelayCore = class {
2307
2316
  const target = params.endpoint === void 0 ? me : this.resolve(params.endpoint);
2308
2317
  if (actor.kind !== "human" && target.id !== me.id) throw new RelayError("scope_refused", "an agent unlinks only itself");
2309
2318
  if (target.kind !== "agent" || !this.localRow(target)) throw new RelayError("invalid_params", "only a local agent endpoint can be unlinked");
2310
- 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`);
2311
2321
  if (actor.kind !== "human" && this.policy?.defaults?.unlink !== "self")
2312
2322
  throw this.scopeRefused(
2313
2323
  actor,
@@ -2315,6 +2325,14 @@ var RelayCore = class {
2315
2325
  { target: this.address(target), reason: "unlink" },
2316
2326
  "unlinking widens a worker's scope: only the human at a terminal unlinks it (policy defaults.unlink: self lets it)"
2317
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
+ }
2318
2336
  const parent = this.byId(target.parent_ep);
2319
2337
  const from = parent ? this.address(parent) : target.parent_ep;
2320
2338
  const shift = target.depth ?? 0;
@@ -2338,16 +2356,26 @@ var RelayCore = class {
2338
2356
  return { endpoint: this.whoami(target.id), parent: from };
2339
2357
  });
2340
2358
  }
2341
- /** `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
+ */
2342
2363
  link(row, parent, by) {
2343
- 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");
2344
2375
  if (row.parent_ep === parent.id) return;
2345
2376
  if (parent.id === row.id || this.ancestorsOf(parent).includes(row.id))
2346
2377
  throw new RelayError("scope_refused", `${this.address(parent)} sits under ${this.address(row)}: a session cannot go under its own descendant`);
2347
- if (row.parent_ep !== null) {
2348
- const now = this.byId(row.parent_ep);
2349
- throw new RelayError("scope_refused", `${this.address(row)} already sits under ${now ? this.address(now) : "another agent"}; unlink it first`);
2350
- }
2378
+ if (row.parent_ep !== null || row.parent_addr !== null) throw this.alreadyLinked(row);
2351
2379
  const depth = (parent.depth ?? 0) + 1;
2352
2380
  const deepest = one(this.sql, `SELECT MAX(depth) AS d FROM endpoints WHERE tree = ?`, row.id)?.d ?? 0;
2353
2381
  if (deepest + depth > LIMITS.depthMax)
@@ -2371,6 +2399,27 @@ var RelayCore = class {
2371
2399
  this.scopeMemo?.limited.clear();
2372
2400
  this.audit("adopt", row.id, null, { ...by, target: this.address(row), parent: this.address(parent) });
2373
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
+ }
2374
2423
  /**
2375
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
2376
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
@@ -2418,8 +2467,12 @@ var RelayCore = class {
2418
2467
  ])
2419
2468
  this.sql.all(`UPDATE ${table} SET ${column} = REPLACE(${column}, ?, ?) WHERE instr(${column}, ?) > 0`, a, b, a);
2420
2469
  }
2470
+ this.renamedHandle(from, handle);
2421
2471
  this.audit("rename", row.id, null, { from, to: handle });
2422
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
+ }
2423
2476
  /**
2424
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
2425
2478
  * itself, the human at a terminal any local root or linked agent. A spawned worker stays joined while it runs
@@ -3593,15 +3646,24 @@ var RelayCore = class {
3593
3646
  if (!root || root.handle.startsWith(GONE_PREFIX) || !seen) this.dropGroup(group.name);
3594
3647
  }
3595
3648
  }
3596
- /** 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 */
3597
3650
  endExited(now = this.now()) {
3598
3651
  for (const row of this.sql.all(
3599
3652
  `SELECT * FROM endpoints WHERE kind = 'agent' AND connections = 0 AND hook_seen IS NOT NULL AND process IS NOT NULL`
3600
3653
  )) {
3601
3654
  const pid = Number(/^pid:(\d+)/.exec(row.process)?.[1]);
3602
3655
  if (!pid || !this.localRow(row) || this.alive(pid)) continue;
3603
- 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
+ );
3604
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);
3605
3667
  }
3606
3668
  /** an agent endpoint left offline drops a working or waiting status, which no one is left to keep current */
3607
3669
  settleOffline(now) {
@@ -3651,7 +3713,8 @@ var RelayCore = class {
3651
3713
  if (params.kind === "session_end") {
3652
3714
  if (own)
3653
3715
  this.sql.all(
3654
- `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 = ?`,
3655
3718
  now,
3656
3719
  endpoint
3657
3720
  );
@@ -4280,7 +4343,8 @@ var RelayCore = class {
4280
4343
  }
4281
4344
  const note = [`exited ${p.code}`, spender.status_note].filter(Boolean).join("; ").slice(0, LIMITS.noteChars);
4282
4345
  this.sql.all(
4283
- `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 = ?`,
4284
4348
  note,
4285
4349
  now,
4286
4350
  spender.id
@@ -4705,6 +4769,7 @@ ${stderr}` : "";
4705
4769
  return scopeDecision(world, world.node(a.id) ?? this.scopeNode(a), world.node(b.id) ?? this.scopeNode(b), {
4706
4770
  humanAlways: this.defaults().humanAlways,
4707
4771
  solicited,
4772
+ linked: a.id !== b.id && this.linkedAcross(a, b),
4708
4773
  unverified,
4709
4774
  strict
4710
4775
  });
@@ -5435,7 +5500,8 @@ ${stderr}` : "";
5435
5500
  }
5436
5501
  placeView(row, shown) {
5437
5502
  const parent = row.parent_ep && (!shown || shown.has(row.parent_ep)) ? this.byId(row.parent_ep) : void 0;
5438
- 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) };
5439
5505
  }
5440
5506
  /** `recipient` names the delivery's endpoint when there is no `seq`: a send to several addresses shows each recipient only itself */
5441
5507
  envelope(row, seq, recipient) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scp-dev/core",
3
- "version": "0.1.6",
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
@@ -533,6 +535,7 @@ export class RelayCore {
533
535
  for (const [column, type] of [
534
536
  ["tree", "TEXT"],
535
537
  ["parent_ep", "TEXT"],
538
+ ["parent_addr", "TEXT"],
536
539
  ["depth", "INTEGER"],
537
540
  ["rules", "TEXT"],
538
541
  ["caps", "TEXT"],
@@ -1203,7 +1206,7 @@ export class RelayCore {
1203
1206
  }
1204
1207
  caps.caps.push(...this.capsOf(row).caps)
1205
1208
  this.sql.all(
1206
- `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 = ?`,
1207
1210
  parent ? (parent.tree ?? parent.id) : null,
1208
1211
  parent?.id ?? null,
1209
1212
  depth,
@@ -1500,13 +1503,21 @@ export class RelayCore {
1500
1503
  */
1501
1504
  waker(endpoint: string, live: boolean, options: { grace?: boolean } = {}): void {
1502
1505
  if (live) this.sql.all(`UPDATE endpoints SET wakers = wakers + 1 WHERE id = ?`, endpoint)
1503
- else
1506
+ else {
1507
+ const now = this.now()
1504
1508
  this.sql.all(
1505
1509
  `UPDATE endpoints SET wakers = MAX(wakers - 1, 0), waker_left = CASE WHEN ? THEN ? ELSE waker_left END WHERE id = ?`,
1506
1510
  options.grace ? 1 : 0,
1507
- this.now(),
1511
+ now,
1508
1512
  endpoint,
1509
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)
1510
1521
  }
1511
1522
 
1512
1523
  /**
@@ -1620,7 +1631,8 @@ export class RelayCore {
1620
1631
  const target = params.endpoint === undefined ? me : this.resolve(params.endpoint)
1621
1632
  if (actor.kind !== "human" && target.id !== me.id) throw new RelayError("scope_refused", "an agent unlinks only itself")
1622
1633
  if (target.kind !== "agent" || !this.localRow(target)) throw new RelayError("invalid_params", "only a local agent endpoint can be unlinked")
1623
- 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`)
1624
1636
  if (actor.kind !== "human" && this.policy?.defaults?.unlink !== "self")
1625
1637
  throw this.scopeRefused(
1626
1638
  actor,
@@ -1628,6 +1640,14 @@ export class RelayCore {
1628
1640
  { target: this.address(target), reason: "unlink" },
1629
1641
  "unlinking widens a worker's scope: only the human at a terminal unlinks it (policy defaults.unlink: self lets it)",
1630
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
+ }
1631
1651
  const parent = this.byId(target.parent_ep)
1632
1652
  const from = parent ? this.address(parent) : target.parent_ep
1633
1653
  const shift = target.depth ?? 0
@@ -1652,16 +1672,26 @@ export class RelayCore {
1652
1672
  })
1653
1673
  }
1654
1674
 
1655
- /** `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
+ */
1656
1679
  private link(row: EndpointRow, parent: EndpointRow, by: { by: string; auth: string }): void {
1657
- 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")
1658
1691
  if (row.parent_ep === parent.id) return
1659
1692
  if (parent.id === row.id || this.ancestorsOf(parent).includes(row.id))
1660
1693
  throw new RelayError("scope_refused", `${this.address(parent)} sits under ${this.address(row)}: a session cannot go under its own descendant`)
1661
- if (row.parent_ep !== null) {
1662
- const now = this.byId(row.parent_ep)
1663
- throw new RelayError("scope_refused", `${this.address(row)} already sits under ${now ? this.address(now) : "another agent"}; unlink it first`)
1664
- }
1694
+ if (row.parent_ep !== null || row.parent_addr !== null) throw this.alreadyLinked(row)
1665
1695
  const depth = (parent.depth ?? 0) + 1
1666
1696
  const deepest = one<{ d: number | null }>(this.sql, `SELECT MAX(depth) AS d FROM endpoints WHERE tree = ?`, row.id)?.d ?? 0
1667
1697
  if (deepest + depth > LIMITS.depthMax)
@@ -1686,6 +1716,31 @@ export class RelayCore {
1686
1716
  this.audit("adopt", row.id, null, { ...by, target: this.address(row), parent: this.address(parent) })
1687
1717
  }
1688
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
+
1689
1744
  /**
1690
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
1691
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
@@ -1734,9 +1789,13 @@ export class RelayCore {
1734
1789
  ] as const)
1735
1790
  this.sql.all(`UPDATE ${table} SET ${column} = REPLACE(${column}, ?, ?) WHERE instr(${column}, ?) > 0`, a, b, a)
1736
1791
  }
1792
+ this.renamedHandle(from, handle)
1737
1793
  this.audit("rename", row.id, null, { from, to: handle })
1738
1794
  }
1739
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
+
1740
1799
  /**
1741
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
1742
1801
  * itself, the human at a terminal any local root or linked agent. A spawned worker stays joined while it runs
@@ -3044,15 +3103,24 @@ export class RelayCore {
3044
3103
  }
3045
3104
  }
3046
3105
 
3047
- /** 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 */
3048
3107
  endExited(now = this.now()): void {
3049
3108
  for (const row of this.sql.all<EndpointRow>(
3050
3109
  `SELECT * FROM endpoints WHERE kind = 'agent' AND connections = 0 AND hook_seen IS NOT NULL AND process IS NOT NULL`,
3051
3110
  )) {
3052
3111
  const pid = Number(/^pid:(\d+)/.exec(row.process!)?.[1])
3053
3112
  if (!pid || !this.localRow(row) || this.alive(pid)) continue
3054
- 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
+ )
3055
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)
3056
3124
  }
3057
3125
 
3058
3126
  /** an agent endpoint left offline drops a working or waiting status, which no one is left to keep current */
@@ -3109,7 +3177,8 @@ export class RelayCore {
3109
3177
  if (params.kind === "session_end") {
3110
3178
  if (own)
3111
3179
  this.sql.all(
3112
- `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 = ?`,
3113
3182
  now,
3114
3183
  endpoint,
3115
3184
  )
@@ -3821,7 +3890,8 @@ export class RelayCore {
3821
3890
  }
3822
3891
  const note = [`exited ${p.code}`, spender.status_note].filter(Boolean).join("; ").slice(0, LIMITS.noteChars)
3823
3892
  this.sql.all(
3824
- `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 = ?`,
3825
3895
  note,
3826
3896
  now,
3827
3897
  spender.id,
@@ -4310,6 +4380,7 @@ export class RelayCore {
4310
4380
  return scopeDecision(world, world.node(a.id) ?? this.scopeNode(a), world.node(b.id) ?? this.scopeNode(b), {
4311
4381
  humanAlways: this.defaults().humanAlways,
4312
4382
  solicited,
4383
+ linked: a.id !== b.id && this.linkedAcross(a, b),
4313
4384
  unverified,
4314
4385
  strict,
4315
4386
  })
@@ -5139,7 +5210,8 @@ export class RelayCore {
5139
5210
 
5140
5211
  private placeView(row: EndpointRow, shown?: Set<string>): Pick<EndpointView, "parent" | "depth" | "scope"> {
5141
5212
  const parent = row.parent_ep && (!shown || shown.has(row.parent_ep)) ? this.byId(row.parent_ep) : undefined
5142
- 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) }
5143
5215
  }
5144
5216
 
5145
5217
  /** `recipient` names the delivery's endpoint when there is no `seq`: a send to several addresses shows each recipient only itself */
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] }