@scp-dev/core 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -811,6 +811,8 @@ interface PolicyDefaults {
811
811
  verify_lineage?: "spike" | boolean;
812
812
  /** who may rename an agent besides the human at a terminal: `root` (default) only a root session itself, `self` any agent itself, spawned ones too */
813
813
  rename?: "root" | "self";
814
+ /** who may unlink an agent from its parent besides the human at a terminal: `human` (default) nobody, `self` the agent itself */
815
+ unlink?: "human" | "self";
814
816
  }
815
817
  interface Policy {
816
818
  rules: PolicyRule[];
@@ -1088,6 +1090,8 @@ interface EndpointRow {
1088
1090
  peek: PeekLevel | null;
1089
1091
  /** when the session last set its handle itself (join or rename): a later claim with another handle does not rename it back */
1090
1092
  named: number | null;
1093
+ /** when the session itself or the human linked it under a parent by hand: a later root claim of it takes it back where it sits */
1094
+ linked: number | null;
1091
1095
  }
1092
1096
  interface MessageRow {
1093
1097
  id: string;
@@ -1317,7 +1321,33 @@ declare class RelayCore {
1317
1321
  * a session joins on demand: its hook claim registers or takes back its endpoint, marked seen now even right after a leave, and `handle`,
1318
1322
  * when given, renames it in place ({@link rename}'s rules). Nothing is kept when the rename is refused
1319
1323
  */
1320
- joinSession(claim: Claim, handle?: unknown): EndpointView;
1324
+ joinSession(claim: Claim, handle?: unknown, under?: unknown): EndpointView;
1325
+ /**
1326
+ * links a local root agent under `parent`, as a spawned worker sits: in the parent's tree one level down, under the policy's child scope.
1327
+ * Only the human at a terminal links another session, or an agent from its own shell with a terminal attached, under itself by default.
1328
+ * A session links itself with {@link joinSession}'s `under`
1329
+ */
1330
+ adopt(actor: ScopeActor, params: {
1331
+ endpoint: unknown;
1332
+ parent?: unknown;
1333
+ }, options?: {
1334
+ tty?: boolean;
1335
+ }): {
1336
+ endpoint: EndpointView;
1337
+ parent: string;
1338
+ };
1339
+ /**
1340
+ * a linked agent back to a root: its subtree moves along and the policy's child scope comes off. The human at a terminal unlinks any local
1341
+ * agent; an agent only itself, and only when the policy's `defaults.unlink` is `self`, since a worker detaching widens its own scope
1342
+ */
1343
+ unlink(actor: ScopeActor, params?: {
1344
+ endpoint?: unknown;
1345
+ }): {
1346
+ endpoint: EndpointView;
1347
+ parent: string;
1348
+ };
1349
+ /** `row`, a root, and its subtree moved under `parent`, a local agent that is neither `row` nor one of its descendants */
1350
+ private link;
1321
1351
  /**
1322
1352
  * renames an agent in place: its id, mail, threads, status, title and relations stay. An agent renames only itself, and only a root unless
1323
1353
  * 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
@@ -1334,7 +1364,7 @@ declare class RelayCore {
1334
1364
  private renameRow;
1335
1365
  /**
1336
1366
  * 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
1337
- * itself, the human at a terminal any local root. A spawned worker stays joined while it runs
1367
+ * itself, the human at a terminal any local root or linked agent. A spawned worker stays joined while it runs
1338
1368
  */
1339
1369
  leaveSession(actor: ScopeActor, params?: {
1340
1370
  endpoint?: unknown;
package/dist/index.js CHANGED
@@ -701,7 +701,7 @@ function parsePolicy(value, options = {}) {
701
701
  }
702
702
  if (defaults !== void 0) {
703
703
  if (!isObject2(defaults)) throw new RelayError("invalid_params", "defaults is an object");
704
- const { human_always, symmetric_presets, child_default, verify_lineage, rename, ...extra } = defaults;
704
+ const { human_always, symmetric_presets, child_default, verify_lineage, rename, unlink, ...extra } = defaults;
705
705
  if (Object.keys(extra).length) throw new RelayError("invalid_params", `unknown defaults: ${Object.keys(extra).join(", ")}`);
706
706
  const parsed = {};
707
707
  for (const [key, flag] of [
@@ -729,6 +729,10 @@ function parsePolicy(value, options = {}) {
729
729
  if (rename !== "root" && rename !== "self") throw new RelayError("invalid_params", "defaults.rename is root or self");
730
730
  parsed.rename = rename;
731
731
  }
732
+ if (unlink !== void 0) {
733
+ if (unlink !== "human" && unlink !== "self") throw new RelayError("invalid_params", "defaults.unlink is human or self");
734
+ parsed.unlink = unlink;
735
+ }
732
736
  policy.defaults = parsed;
733
737
  }
734
738
  if (judge !== void 0) policy.judge = parseJudgePolicy(judge, options.judgeProviders);
@@ -1315,7 +1319,8 @@ var RelayCore = class {
1315
1319
  ["title_auto", "TEXT"],
1316
1320
  ["title_turn", "INTEGER"],
1317
1321
  ["tldr_turn", "INTEGER"],
1318
- ["named", "INTEGER"]
1322
+ ["named", "INTEGER"],
1323
+ ["linked", "INTEGER"]
1319
1324
  ]) {
1320
1325
  if (!endpointColumns.includes(column)) this.sql.all(`ALTER TABLE endpoints ADD COLUMN ${column} ${type}`);
1321
1326
  }
@@ -1845,7 +1850,7 @@ var RelayCore = class {
1845
1850
  */
1846
1851
  restarts(row, parent, process) {
1847
1852
  if (parent?.id === row.id) return process !== null && row.process !== null && sameHarness(row.process, process);
1848
- return (parent?.id ?? null) === row.parent_ep;
1853
+ return (parent?.id ?? null) === row.parent_ep || parent === null && row.linked !== null;
1849
1854
  }
1850
1855
  /**
1851
1856
  * 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
@@ -2129,16 +2134,124 @@ var RelayCore = class {
2129
2134
  * a session joins on demand: its hook claim registers or takes back its endpoint, marked seen now even right after a leave, and `handle`,
2130
2135
  * when given, renames it in place ({@link rename}'s rules). Nothing is kept when the rename is refused
2131
2136
  */
2132
- joinSession(claim, handle) {
2137
+ joinSession(claim, handle, under) {
2133
2138
  return this.scopeTx(() => {
2134
2139
  const { endpoint } = this.claim(claim, { transient: true });
2135
2140
  const row = this.mustEndpoint(endpoint.id);
2136
2141
  if (handle !== void 0 && handle !== null && handle !== "") this.renameRow(row, handle, false);
2137
2142
  this.sql.all(`UPDATE endpoints SET hook_seen = ?, hook_ended = NULL WHERE id = ?`, this.now(), row.id);
2138
2143
  this.audit("join", row.id, null, { handle: this.byId(row.id).handle });
2144
+ if (under !== void 0 && under !== null && under !== "") {
2145
+ const me = this.byId(row.id);
2146
+ const parent = this.resolve(under);
2147
+ if (parent.id !== me.parent_ep && parent.kind === "agent" && !this.mayMessage(me, parent)) throw unreachable(under);
2148
+ this.link(me, parent, { by: this.address(me), auth: "session" });
2149
+ }
2139
2150
  return this.whoami(row.id);
2140
2151
  });
2141
2152
  }
2153
+ /**
2154
+ * links a local root agent under `parent`, as a spawned worker sits: in the parent's tree one level down, under the policy's child scope.
2155
+ * Only the human at a terminal links another session, or an agent from its own shell with a terminal attached, under itself by default.
2156
+ * A session links itself with {@link joinSession}'s `under`
2157
+ */
2158
+ adopt(actor, params, options = {}) {
2159
+ return this.scopeTx(() => {
2160
+ const me = this.actorRow(actor);
2161
+ if (actor.kind === "human") this.needTerminal(actor, me, params.endpoint);
2162
+ else if (options.tty !== true)
2163
+ throw this.scopeRefused(
2164
+ actor,
2165
+ me,
2166
+ { target: String(params.endpoint), reason: "adopt" },
2167
+ `only the human at a terminal links another session under a parent; the session links itself with ${CLI_NAME} join --under`
2168
+ );
2169
+ const target = this.resolve(params.endpoint);
2170
+ if (target.kind !== "agent" || !this.localRow(target)) throw new RelayError("invalid_params", "only a local agent endpoint can be linked");
2171
+ const parentAddress = params.parent ?? (actor.kind === "human" ? void 0 : this.address(me));
2172
+ if (parentAddress === void 0 || parentAddress === null || parentAddress === "")
2173
+ throw new RelayError("invalid_params", "the human names the parent to link under");
2174
+ const parent = this.resolve(parentAddress);
2175
+ this.link(target, parent, this.actorDetail(actor, me));
2176
+ return { endpoint: this.whoami(target.id), parent: this.address(parent) };
2177
+ });
2178
+ }
2179
+ /**
2180
+ * a linked agent back to a root: its subtree moves along and the policy's child scope comes off. The human at a terminal unlinks any local
2181
+ * agent; an agent only itself, and only when the policy's `defaults.unlink` is `self`, since a worker detaching widens its own scope
2182
+ */
2183
+ unlink(actor, params = {}) {
2184
+ return this.scopeTx(() => {
2185
+ const me = this.actorRow(actor);
2186
+ if (actor.kind === "human") this.needTerminal(actor, me, params.endpoint);
2187
+ else if (actor.auth === "lineage") throw new RelayError("scope_refused", "only the session itself unlinks it: run this from the session's own shell");
2188
+ const target = params.endpoint === void 0 ? me : this.resolve(params.endpoint);
2189
+ if (actor.kind !== "human" && target.id !== me.id) throw new RelayError("scope_refused", "an agent unlinks only itself");
2190
+ if (target.kind !== "agent" || !this.localRow(target)) throw new RelayError("invalid_params", "only a local agent endpoint can be unlinked");
2191
+ if (target.parent_ep === null) throw new RelayError("invalid_params", `${this.address(target)} is a root: it has no parent`);
2192
+ if (actor.kind !== "human" && this.policy?.defaults?.unlink !== "self")
2193
+ throw this.scopeRefused(
2194
+ actor,
2195
+ me,
2196
+ { target: this.address(target), reason: "unlink" },
2197
+ "unlinking widens a worker's scope: only the human at a terminal unlinks it (policy defaults.unlink: self lets it)"
2198
+ );
2199
+ const parent = this.byId(target.parent_ep);
2200
+ const from = parent ? this.address(parent) : target.parent_ep;
2201
+ const shift = target.depth ?? 0;
2202
+ this.sql.all(
2203
+ `WITH RECURSIVE below(id) AS (SELECT id FROM endpoints WHERE parent_ep = ? UNION SELECT e.id FROM endpoints e JOIN below b ON e.parent_ep = b.id)
2204
+ UPDATE endpoints SET tree = ?, depth = depth - ? WHERE id IN (SELECT id FROM below)`,
2205
+ target.id,
2206
+ target.id,
2207
+ shift
2208
+ );
2209
+ const caps = this.capsOf(target);
2210
+ const kept = caps.caps.filter((cap) => cap.by !== "policy");
2211
+ this.sql.all(
2212
+ `UPDATE endpoints SET tree = NULL, parent_ep = NULL, depth = 0, linked = NULL, caps = ? WHERE id = ?`,
2213
+ kept.length || Object.keys(caps.allow).length ? JSON.stringify({ ...caps, caps: kept }) : null,
2214
+ target.id
2215
+ );
2216
+ this.scopeMemo?.ancestors.clear();
2217
+ this.scopeMemo?.limited.clear();
2218
+ this.audit("unlink", target.id, null, { ...this.actorDetail(actor, me), target: this.address(target), parent: from });
2219
+ return { endpoint: this.whoami(target.id), parent: from };
2220
+ });
2221
+ }
2222
+ /** `row`, a root, and its subtree moved under `parent`, a local agent that is neither `row` nor one of its descendants */
2223
+ link(row, parent, by) {
2224
+ if (parent.kind !== "agent" || !this.localRow(parent)) throw new RelayError("invalid_params", "the parent is a local agent endpoint");
2225
+ if (row.parent_ep === parent.id) return;
2226
+ if (parent.id === row.id || this.ancestorsOf(parent).includes(row.id))
2227
+ throw new RelayError("scope_refused", `${this.address(parent)} sits under ${this.address(row)}: a session cannot go under its own descendant`);
2228
+ if (row.parent_ep !== null) {
2229
+ const now = this.byId(row.parent_ep);
2230
+ throw new RelayError("scope_refused", `${this.address(row)} already sits under ${now ? this.address(now) : "another agent"}; unlink it first`);
2231
+ }
2232
+ const depth = (parent.depth ?? 0) + 1;
2233
+ const deepest = one(this.sql, `SELECT MAX(depth) AS d FROM endpoints WHERE tree = ?`, row.id)?.d ?? 0;
2234
+ if (deepest + depth > LIMITS.depthMax)
2235
+ throw new RelayError("scope_refused", `${this.address(row)} would sit deeper than ${LIMITS.depthMax} under ${this.address(parent)}`);
2236
+ const tree = parent.tree ?? parent.id;
2237
+ this.sql.all(`UPDATE endpoints SET tree = ?, depth = depth + ? WHERE tree = ?`, tree, depth, row.id);
2238
+ const caps = this.capsOf(row);
2239
+ const childDefault = this.defaults().childDefault;
2240
+ if (childDefault !== "open" && childDefault !== "inherit" && !caps.caps.some((cap) => cap.by === "policy"))
2241
+ for (const dir of this.presetDirs()) caps.caps.push({ id: capId(), dir, by: "policy", sels: PRESET_LISTS[childDefault].map((sel) => ({ sel })) });
2242
+ this.sql.all(
2243
+ `UPDATE endpoints SET tree = ?, parent_ep = ?, depth = ?, linked = ?, caps = ? WHERE id = ?`,
2244
+ tree,
2245
+ parent.id,
2246
+ depth,
2247
+ this.now(),
2248
+ caps.caps.length || Object.keys(caps.allow).length ? JSON.stringify(caps) : null,
2249
+ row.id
2250
+ );
2251
+ this.scopeMemo?.ancestors.clear();
2252
+ this.scopeMemo?.limited.clear();
2253
+ this.audit("adopt", row.id, null, { ...by, target: this.address(row), parent: this.address(parent) });
2254
+ }
2142
2255
  /**
2143
2256
  * renames an agent in place: its id, mail, threads, status, title and relations stay. An agent renames only itself, and only a root unless
2144
2257
  * 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
@@ -2190,7 +2303,7 @@ var RelayCore = class {
2190
2303
  }
2191
2304
  /**
2192
2305
  * 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
2193
- * itself, the human at a terminal any local root. A spawned worker stays joined while it runs
2306
+ * itself, the human at a terminal any local root or linked agent. A spawned worker stays joined while it runs
2194
2307
  */
2195
2308
  leaveSession(actor, params = {}) {
2196
2309
  return this.scopeTx(() => {
@@ -2200,7 +2313,7 @@ var RelayCore = class {
2200
2313
  const target = params.endpoint === void 0 ? me : this.resolve(params.endpoint);
2201
2314
  if (actor.kind !== "human" && target.id !== me.id) throw new RelayError("scope_refused", "an agent leaves only for itself");
2202
2315
  if (target.kind !== "agent" || !this.localRow(target)) throw new RelayError("invalid_params", "only a local agent endpoint can leave");
2203
- if (target.parent_ep !== null) {
2316
+ if (target.parent_ep !== null && target.linked === null) {
2204
2317
  const parent = this.byId(target.parent_ep);
2205
2318
  throw new RelayError("scope_refused", `spawned by ${parent ? this.address(parent) : "another agent"}; a spawned worker stays joined while it runs`);
2206
2319
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scp-dev/core",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
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
@@ -256,6 +256,8 @@ export interface EndpointRow {
256
256
  peek: PeekLevel | null
257
257
  /** when the session last set its handle itself (join or rename): a later claim with another handle does not rename it back */
258
258
  named: number | null
259
+ /** when the session itself or the human linked it under a parent by hand: a later root claim of it takes it back where it sits */
260
+ linked: number | null
259
261
  }
260
262
 
261
263
  export interface MessageRow {
@@ -520,6 +522,7 @@ export class RelayCore {
520
522
  ["title_turn", "INTEGER"],
521
523
  ["tldr_turn", "INTEGER"],
522
524
  ["named", "INTEGER"],
525
+ ["linked", "INTEGER"],
523
526
  ] as const) {
524
527
  if (!endpointColumns.includes(column)) this.sql.all(`ALTER TABLE endpoints ADD COLUMN ${column} ${type}`)
525
528
  }
@@ -1111,7 +1114,7 @@ export class RelayCore {
1111
1114
  */
1112
1115
  private restarts(row: EndpointRow, parent: EndpointRow | null, process: string | null): boolean {
1113
1116
  if (parent?.id === row.id) return process !== null && row.process !== null && sameHarness(row.process, process)
1114
- return (parent?.id ?? null) === row.parent_ep
1117
+ return (parent?.id ?? null) === row.parent_ep || (parent === null && row.linked !== null)
1115
1118
  }
1116
1119
 
1117
1120
  /**
@@ -1444,17 +1447,128 @@ export class RelayCore {
1444
1447
  * a session joins on demand: its hook claim registers or takes back its endpoint, marked seen now even right after a leave, and `handle`,
1445
1448
  * when given, renames it in place ({@link rename}'s rules). Nothing is kept when the rename is refused
1446
1449
  */
1447
- joinSession(claim: Claim, handle?: unknown): EndpointView {
1450
+ joinSession(claim: Claim, handle?: unknown, under?: unknown): EndpointView {
1448
1451
  return this.scopeTx(() => {
1449
1452
  const { endpoint } = this.claim(claim, { transient: true })
1450
1453
  const row = this.mustEndpoint(endpoint.id)
1451
1454
  if (handle !== undefined && handle !== null && handle !== "") this.renameRow(row, handle, false)
1452
1455
  this.sql.all(`UPDATE endpoints SET hook_seen = ?, hook_ended = NULL WHERE id = ?`, this.now(), row.id)
1453
1456
  this.audit("join", row.id, null, { handle: this.byId(row.id)!.handle })
1457
+ if (under !== undefined && under !== null && under !== "") {
1458
+ const me = this.byId(row.id)!
1459
+ const parent = this.resolve(under)
1460
+ if (parent.id !== me.parent_ep && parent.kind === "agent" && !this.mayMessage(me, parent)) throw unreachable(under)
1461
+ this.link(me, parent, { by: this.address(me), auth: "session" })
1462
+ }
1454
1463
  return this.whoami(row.id)
1455
1464
  })
1456
1465
  }
1457
1466
 
1467
+ /**
1468
+ * links a local root agent under `parent`, as a spawned worker sits: in the parent's tree one level down, under the policy's child scope.
1469
+ * Only the human at a terminal links another session, or an agent from its own shell with a terminal attached, under itself by default.
1470
+ * A session links itself with {@link joinSession}'s `under`
1471
+ */
1472
+ adopt(actor: ScopeActor, params: { endpoint: unknown; parent?: unknown }, options: { tty?: boolean } = {}): { endpoint: EndpointView; parent: string } {
1473
+ return this.scopeTx(() => {
1474
+ const me = this.actorRow(actor)
1475
+ if (actor.kind === "human") this.needTerminal(actor, me, params.endpoint)
1476
+ else if (options.tty !== true)
1477
+ throw this.scopeRefused(
1478
+ actor,
1479
+ me,
1480
+ { target: String(params.endpoint), reason: "adopt" },
1481
+ `only the human at a terminal links another session under a parent; the session links itself with ${CLI_NAME} join --under`,
1482
+ )
1483
+ const target = this.resolve(params.endpoint)
1484
+ if (target.kind !== "agent" || !this.localRow(target)) throw new RelayError("invalid_params", "only a local agent endpoint can be linked")
1485
+ const parentAddress = params.parent ?? (actor.kind === "human" ? undefined : this.address(me))
1486
+ if (parentAddress === undefined || parentAddress === null || parentAddress === "")
1487
+ throw new RelayError("invalid_params", "the human names the parent to link under")
1488
+ const parent = this.resolve(parentAddress)
1489
+ this.link(target, parent, this.actorDetail(actor, me))
1490
+ return { endpoint: this.whoami(target.id), parent: this.address(parent) }
1491
+ })
1492
+ }
1493
+
1494
+ /**
1495
+ * a linked agent back to a root: its subtree moves along and the policy's child scope comes off. The human at a terminal unlinks any local
1496
+ * agent; an agent only itself, and only when the policy's `defaults.unlink` is `self`, since a worker detaching widens its own scope
1497
+ */
1498
+ unlink(actor: ScopeActor, params: { endpoint?: unknown } = {}): { endpoint: EndpointView; parent: string } {
1499
+ return this.scopeTx(() => {
1500
+ const me = this.actorRow(actor)
1501
+ if (actor.kind === "human") this.needTerminal(actor, me, params.endpoint)
1502
+ else if (actor.auth === "lineage") throw new RelayError("scope_refused", "only the session itself unlinks it: run this from the session's own shell")
1503
+ const target = params.endpoint === undefined ? me : this.resolve(params.endpoint)
1504
+ if (actor.kind !== "human" && target.id !== me.id) throw new RelayError("scope_refused", "an agent unlinks only itself")
1505
+ if (target.kind !== "agent" || !this.localRow(target)) throw new RelayError("invalid_params", "only a local agent endpoint can be unlinked")
1506
+ if (target.parent_ep === null) throw new RelayError("invalid_params", `${this.address(target)} is a root: it has no parent`)
1507
+ if (actor.kind !== "human" && this.policy?.defaults?.unlink !== "self")
1508
+ throw this.scopeRefused(
1509
+ actor,
1510
+ me,
1511
+ { target: this.address(target), reason: "unlink" },
1512
+ "unlinking widens a worker's scope: only the human at a terminal unlinks it (policy defaults.unlink: self lets it)",
1513
+ )
1514
+ const parent = this.byId(target.parent_ep)
1515
+ const from = parent ? this.address(parent) : target.parent_ep
1516
+ const shift = target.depth ?? 0
1517
+ this.sql.all(
1518
+ `WITH RECURSIVE below(id) AS (SELECT id FROM endpoints WHERE parent_ep = ? UNION SELECT e.id FROM endpoints e JOIN below b ON e.parent_ep = b.id)
1519
+ UPDATE endpoints SET tree = ?, depth = depth - ? WHERE id IN (SELECT id FROM below)`,
1520
+ target.id,
1521
+ target.id,
1522
+ shift,
1523
+ )
1524
+ const caps = this.capsOf(target)
1525
+ const kept = caps.caps.filter((cap) => cap.by !== "policy")
1526
+ this.sql.all(
1527
+ `UPDATE endpoints SET tree = NULL, parent_ep = NULL, depth = 0, linked = NULL, caps = ? WHERE id = ?`,
1528
+ kept.length || Object.keys(caps.allow).length ? JSON.stringify({ ...caps, caps: kept }) : null,
1529
+ target.id,
1530
+ )
1531
+ this.scopeMemo?.ancestors.clear()
1532
+ this.scopeMemo?.limited.clear()
1533
+ this.audit("unlink", target.id, null, { ...this.actorDetail(actor, me), target: this.address(target), parent: from })
1534
+ return { endpoint: this.whoami(target.id), parent: from }
1535
+ })
1536
+ }
1537
+
1538
+ /** `row`, a root, and its subtree moved under `parent`, a local agent that is neither `row` nor one of its descendants */
1539
+ private link(row: EndpointRow, parent: EndpointRow, by: { by: string; auth: string }): void {
1540
+ if (parent.kind !== "agent" || !this.localRow(parent)) throw new RelayError("invalid_params", "the parent is a local agent endpoint")
1541
+ if (row.parent_ep === parent.id) return
1542
+ if (parent.id === row.id || this.ancestorsOf(parent).includes(row.id))
1543
+ throw new RelayError("scope_refused", `${this.address(parent)} sits under ${this.address(row)}: a session cannot go under its own descendant`)
1544
+ if (row.parent_ep !== null) {
1545
+ const now = this.byId(row.parent_ep)
1546
+ throw new RelayError("scope_refused", `${this.address(row)} already sits under ${now ? this.address(now) : "another agent"}; unlink it first`)
1547
+ }
1548
+ const depth = (parent.depth ?? 0) + 1
1549
+ const deepest = one<{ d: number | null }>(this.sql, `SELECT MAX(depth) AS d FROM endpoints WHERE tree = ?`, row.id)?.d ?? 0
1550
+ if (deepest + depth > LIMITS.depthMax)
1551
+ throw new RelayError("scope_refused", `${this.address(row)} would sit deeper than ${LIMITS.depthMax} under ${this.address(parent)}`)
1552
+ const tree = parent.tree ?? parent.id
1553
+ this.sql.all(`UPDATE endpoints SET tree = ?, depth = depth + ? WHERE tree = ?`, tree, depth, row.id)
1554
+ const caps = this.capsOf(row)
1555
+ const childDefault = this.defaults().childDefault
1556
+ if (childDefault !== "open" && childDefault !== "inherit" && !caps.caps.some((cap) => cap.by === "policy"))
1557
+ for (const dir of this.presetDirs()) caps.caps.push({ id: capId(), dir, by: "policy", sels: PRESET_LISTS[childDefault].map((sel) => ({ sel })) })
1558
+ this.sql.all(
1559
+ `UPDATE endpoints SET tree = ?, parent_ep = ?, depth = ?, linked = ?, caps = ? WHERE id = ?`,
1560
+ tree,
1561
+ parent.id,
1562
+ depth,
1563
+ this.now(),
1564
+ caps.caps.length || Object.keys(caps.allow).length ? JSON.stringify(caps) : null,
1565
+ row.id,
1566
+ )
1567
+ this.scopeMemo?.ancestors.clear()
1568
+ this.scopeMemo?.limited.clear()
1569
+ this.audit("adopt", row.id, null, { ...by, target: this.address(row), parent: this.address(parent) })
1570
+ }
1571
+
1458
1572
  /**
1459
1573
  * renames an agent in place: its id, mail, threads, status, title and relations stay. An agent renames only itself, and only a root unless
1460
1574
  * 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
@@ -1508,7 +1622,7 @@ export class RelayCore {
1508
1622
 
1509
1623
  /**
1510
1624
  * 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
1511
- * itself, the human at a terminal any local root. A spawned worker stays joined while it runs
1625
+ * itself, the human at a terminal any local root or linked agent. A spawned worker stays joined while it runs
1512
1626
  */
1513
1627
  leaveSession(actor: ScopeActor, params: { endpoint?: unknown } = {}): { endpoint: EndpointView; session: string | null } {
1514
1628
  return this.scopeTx(() => {
@@ -1518,7 +1632,7 @@ export class RelayCore {
1518
1632
  const target = params.endpoint === undefined ? me : this.resolve(params.endpoint)
1519
1633
  if (actor.kind !== "human" && target.id !== me.id) throw new RelayError("scope_refused", "an agent leaves only for itself")
1520
1634
  if (target.kind !== "agent" || !this.localRow(target)) throw new RelayError("invalid_params", "only a local agent endpoint can leave")
1521
- if (target.parent_ep !== null) {
1635
+ if (target.parent_ep !== null && target.linked === null) {
1522
1636
  const parent = this.byId(target.parent_ep)
1523
1637
  throw new RelayError("scope_refused", `spawned by ${parent ? this.address(parent) : "another agent"}; a spawned worker stays joined while it runs`)
1524
1638
  }
package/src/policy.ts CHANGED
@@ -38,6 +38,8 @@ export interface PolicyDefaults {
38
38
  verify_lineage?: "spike" | boolean
39
39
  /** who may rename an agent besides the human at a terminal: `root` (default) only a root session itself, `self` any agent itself, spawned ones too */
40
40
  rename?: "root" | "self"
41
+ /** who may unlink an agent from its parent besides the human at a terminal: `human` (default) nobody, `self` the agent itself */
42
+ unlink?: "human" | "self"
41
43
  }
42
44
 
43
45
  export interface Policy {
@@ -166,7 +168,7 @@ export function parsePolicy(value: unknown, options: { judgeProviders?: Readonly
166
168
  }
167
169
  if (defaults !== undefined) {
168
170
  if (!isObject(defaults)) throw new RelayError("invalid_params", "defaults is an object")
169
- const { human_always, symmetric_presets, child_default, verify_lineage, rename, ...extra } = defaults
171
+ const { human_always, symmetric_presets, child_default, verify_lineage, rename, unlink, ...extra } = defaults
170
172
  if (Object.keys(extra).length) throw new RelayError("invalid_params", `unknown defaults: ${Object.keys(extra).join(", ")}`)
171
173
  const parsed: PolicyDefaults = {}
172
174
  for (const [key, flag] of [
@@ -194,6 +196,10 @@ export function parsePolicy(value: unknown, options: { judgeProviders?: Readonly
194
196
  if (rename !== "root" && rename !== "self") throw new RelayError("invalid_params", "defaults.rename is root or self")
195
197
  parsed.rename = rename
196
198
  }
199
+ if (unlink !== undefined) {
200
+ if (unlink !== "human" && unlink !== "self") throw new RelayError("invalid_params", "defaults.unlink is human or self")
201
+ parsed.unlink = unlink
202
+ }
197
203
  policy.defaults = parsed
198
204
  }
199
205
  if (judge !== undefined) policy.judge = parseJudgePolicy(judge, options.judgeProviders)