@scp-dev/core 0.1.6 → 0.3.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
@@ -404,7 +404,7 @@ type DataClass = "public" | "private";
404
404
  type Presence = "online" | "idle" | "offline";
405
405
  type StatusState = "working" | "waiting" | "blocked" | "done";
406
406
  /** how an idle agent is woken by a message: a channel push, the pi extension, the OpenCode plugin's waiter or codex app-server; `none` reads at its next turn */
407
- type Wake = "channels" | "pi" | "opencode-plugin" | "codex-app-server" | "none";
407
+ type Wake = "channels" | "pi" | "opencode-plugin" | "codex-app-server" | "claude-inbox" | "none";
408
408
  declare const WAKES: readonly Wake[];
409
409
  type ActivityKind = "session_start" | "tool" | "stop" | "note" | "session_end";
410
410
  declare const ACTIVITY_KINDS: readonly ActivityKind[];
@@ -658,6 +658,10 @@ interface Envelope {
658
658
  expires_at: string;
659
659
  /** a request whose sender holds the recipient's stop until it replies */
660
660
  require_reply?: true;
661
+ /** a broker-issued request to link under its sender */
662
+ link?: {
663
+ parent: string;
664
+ };
661
665
  /** set by inbox on a message a background hook already showed (and marked read), whose text may reach the agent only after this call */
662
666
  shown_at?: string;
663
667
  /**
@@ -769,6 +773,12 @@ interface SendResult {
769
773
  held?: JudgeHeld;
770
774
  redacted?: JudgeRedacted;
771
775
  }
776
+ interface AdoptResult {
777
+ endpoint: EndpointView;
778
+ parent: string;
779
+ requested?: string;
780
+ held?: JudgeHeld;
781
+ }
772
782
  /**
773
783
  * a message the caller sent and, per recipient, when it read the message (acked), or when a background hook delivered it (acked by that
774
784
  * showing; the agent gets it with its next request), and when a hook or channel showed it: ISO times, or null
@@ -792,7 +802,7 @@ interface AuditRow {
792
802
  detail: Record<string, unknown>;
793
803
  }
794
804
  /** fields only the server sets: a client that sends them gets invalid_params */
795
- declare const SERVER_FIELDS: readonly ["from", "id", "seq", "hop", "created_at", "auth", "expires_at", "relation"];
805
+ declare const SERVER_FIELDS: readonly ["from", "id", "seq", "hop", "created_at", "auth", "expires_at", "relation", "link"];
796
806
 
797
807
  /**
798
808
  * `*`, `ep:<handle>` (that one agent), `handle:<glob>`, `harness:<name>`, `kind:<agent|human>` or `label:<key>=<value>`, plus the scope selectors: `tree:self|root|parent|siblings|children`,
@@ -998,11 +1008,12 @@ declare function relationText(peer: PolicySubject, me: PolicySubject): string;
998
1008
  /**
999
1009
  * 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
1010
  * 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.
1011
+ * ancestor of `b` (unless `strict`), a solicited reply and a parent and child `linked` across machines pass both.
1002
1012
  */
1003
1013
  declare function scopeDecision(world: ScopeWorld, a: ScopeNode, b: ScopeNode, options: {
1004
1014
  humanAlways: boolean;
1005
1015
  solicited?: boolean;
1016
+ linked?: boolean;
1006
1017
  unverified?: boolean;
1007
1018
  strict?: boolean;
1008
1019
  }): ScopeDecision;
@@ -1085,6 +1096,8 @@ interface EndpointRow {
1085
1096
  /** the root endpoint's id; null for a root */
1086
1097
  tree: string | null;
1087
1098
  parent_ep: string | null;
1099
+ /** a parent on another machine of the account, `<machine>/<handle>`: the row stays a root of its own tree */
1100
+ parent_addr: string | null;
1088
1101
  depth: number | null;
1089
1102
  /** JSON {@link ScopeRules} */
1090
1103
  rules: string | null;
@@ -1130,7 +1143,11 @@ interface MessageRow {
1130
1143
  expires_at: number;
1131
1144
  sender_tty: number | null;
1132
1145
  require_reply: number | null;
1146
+ link_parent: string | null;
1133
1147
  }
1148
+ type AdoptSend = (from: string, params: SendParams, origin: {
1149
+ link: "session" | "self";
1150
+ }) => Promise<SendResult>;
1134
1151
  interface CoreOptions {
1135
1152
  /** the local user's address part; `me` until a login names one */
1136
1153
  user?: string;
@@ -1359,6 +1376,8 @@ declare class RelayCore {
1359
1376
  waker(endpoint: string, live: boolean, options?: {
1360
1377
  grace?: boolean;
1361
1378
  }): void;
1379
+ /** clears a wake no waker holds once a hook waker's grace ran out too: nothing is left to wake the endpoint */
1380
+ private lapseWake;
1362
1381
  /**
1363
1382
  * 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
1383
  * It fills only a title nobody set, as an inferred one, and waits for the subagent's claim when it has no endpoint yet
@@ -1379,16 +1398,23 @@ declare class RelayCore {
1379
1398
  * when given, renames it in place ({@link rename}'s rules). Nothing is kept when the rename is refused
1380
1399
  */
1381
1400
  joinSession(claim: Claim, handle?: unknown, under?: unknown): EndpointView;
1382
- /**
1383
- * 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.
1384
- * Only the human at a terminal links another session, or an agent from its own shell with a terminal attached, under itself by default.
1385
- * A session links itself with {@link joinSession}'s `under`
1386
- */
1401
+ /** the human links a local root directly; an agent asks it to consent through the normal send path */
1387
1402
  adopt(actor: ScopeActor, params: {
1388
1403
  endpoint: unknown;
1389
1404
  parent?: unknown;
1390
1405
  }, options?: {
1391
1406
  tty?: boolean;
1407
+ }): AdoptResult;
1408
+ adopt(actor: ScopeActor, params: {
1409
+ endpoint: unknown;
1410
+ parent?: unknown;
1411
+ }, options: {
1412
+ tty?: boolean;
1413
+ send: AdoptSend;
1414
+ }): AdoptResult | Promise<AdoptResult>;
1415
+ /** accepts a broker-issued request delivered to the caller's own session */
1416
+ linkAccept(actor: ScopeActor, params: {
1417
+ id: unknown;
1392
1418
  }): {
1393
1419
  endpoint: EndpointView;
1394
1420
  parent: string;
@@ -1403,8 +1429,22 @@ declare class RelayCore {
1403
1429
  endpoint: EndpointView;
1404
1430
  parent: string;
1405
1431
  };
1406
- /** `row`, a root, and its subtree moved under `parent`, a local agent that is neither `row` nor one of its descendants */
1432
+ /**
1433
+ * `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
1434
+ * machine goes to {@link linkAcross}
1435
+ */
1407
1436
  private link;
1437
+ /** a link of `row`, which already sits under a parent, to another one */
1438
+ private alreadyLinked;
1439
+ /**
1440
+ * the key `<machine>/<handle>` that links `row`, a local agent, under `parent`, an agent on another machine, once the parent is checked; no
1441
+ * inherited or child-default caps come with it. A relay of one machine has no such parent
1442
+ */
1443
+ protected linkAcross(_row: EndpointRow, _parent: EndpointRow): string;
1444
+ /** whether `a` and `b` are a parent and its child on different machines */
1445
+ protected linkedAcross(_a: EndpointRow, _b: EndpointRow): boolean;
1446
+ /** the address of `row`'s parent on another machine, null when it has none; with `shown`, null for a parent outside it */
1447
+ protected linkedParent(_row: EndpointRow, _shown?: Set<string>): string | null;
1408
1448
  /**
1409
1449
  * 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
1450
  * 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 +1459,8 @@ declare class RelayCore {
1419
1459
  };
1420
1460
  /** the endpoint's handle set to `value`, with the stored `ep:` and `fleet:` selectors that named it */
1421
1461
  private renameRow;
1462
+ /** a local agent's handle moved from `from` to `to`: a subclass updates what names it on another machine's rows */
1463
+ protected renamedHandle(_from: string, _to: string): void;
1422
1464
  /**
1423
1465
  * 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
1466
  * itself, the human at a terminal any local root or linked agent. A spawned worker stays joined while it runs
@@ -1438,6 +1480,7 @@ declare class RelayCore {
1438
1480
  send(from: string, params: SendParams, origin?: {
1439
1481
  tty?: boolean;
1440
1482
  unverified?: boolean;
1483
+ link?: "session" | "self";
1441
1484
  }): SendResult;
1442
1485
  /** a local agent recipient's {@link Reach} for a send from `sender`; none for a human, the relay or another machine's row */
1443
1486
  private reach;
@@ -1700,7 +1743,7 @@ declare class RelayCore {
1700
1743
  }): AuditRow[];
1701
1744
  /** drops what expired: deliveries, message bodies past their grace, old guard state and old audit rows */
1702
1745
  sweep(): void;
1703
- /** ends the hook presence of local agent endpoints whose harness process exited without a session_end, as one would */
1746
+ /** 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
1747
  endExited(now?: number): void;
1705
1748
  /** an agent endpoint left offline drops a working or waiting status, which no one is left to keep current */
1706
1749
  private settleOffline;
@@ -2513,4 +2556,4 @@ declare function sha256Hex(text: string): string;
2513
2556
  /** the UTF-8 byte length of text, the unit every byte limit counts in */
2514
2557
  declare function utf8Bytes(text: string): number;
2515
2558
 
2516
- export { ACCOUNT_HANDLE, ACTIVITY_KINDS, type ActivityKind, type ActivityRow, type Admission, type AskResult, type Attachment, type AuditRow, BROKER_CAPABILITIES, type BreakerAdmission, CAPABILITIES, CLI_NAME, type Claim, type CoreOptions, type CrossAccountGate, type CrossAccountLink, DEFAULT_IMPLEMENTATION_PREFIXES, DEFAULT_JUDGE_BUDGET, DEFAULT_JUDGE_EFFORT, type DataClass, ERROR_CODES, type EndpointKind, type EndpointRow, type EndpointView, type Envelope, type ErrorCode, FakeJudgeClient, GENERIC_HARNESSES, GONE_PREFIX, GROUP_NAME, type GateContext, type GateVerdict, HANDBACK_CUT, HUMAN_HANDLE, HttpJudgeClient, type HttpJudgeOptions, JUDGE_BREAKER_DEFAULTS, JUDGE_CATEGORIES, JUDGE_EFFORTS, JUDGE_FALLBACK_NOTE, JUDGE_MAX_OUTPUT_TOKENS, JUDGE_PROMPT_VERSION, JUDGE_PROVIDERS, JUDGE_VERDICTS, type JudgeAuditDetail, type JudgeAuditEvent, type JudgeBoundary, JudgeBreaker, type JudgeBreakerOptions, type JudgeBudget, type JudgeCategory, type JudgeClient, type JudgeContext, type JudgeEffort, type JudgeHeld, type JudgeModelConfig, type JudgePolicy, type JudgeProvider, JudgeProviderError, type JudgeRedacted, type JudgeSide, type JudgeStats, type JudgeVerdict, LIMITS, MESSAGE_KINDS, type MessageKind, type MessageReceipts, type MessageRow, OMITTED, PASS_GATE, PEEK, PREAMBLE, PREAMBLE_BRIEF, PRESET_LISTS, PROTOCOL, type PeekLevel, type PeekResult, type PeekSeen, type PeekState, type PeekTurn, type PeerView, type PiiHit, type Policy, type PolicyDefaults, type PolicyGroup, type PolicyRule, type PolicySubject, type Prefiltered, type Presence, REDACTION_KINDS, type Reach, type Redaction, RelayCore, RelayError, type RunJudgeInput, SCOPE_PRESETS, SERVER_FIELDS, SHORT_ID, STATUS_STATES, SYSTEM_HANDLE, type ScopeActor, type ScopeCap, type ScopeCaps, type ScopeChange, type ScopeDecision, type ScopeDirection, type ScopeEntry, type ScopeGroupIdentity, type ScopeGroupRules, type ScopeList, type ScopeNode, type ScopePolicyCap, type ScopePreset, type ScopeRules, type ScopeSubject, type ScopeView, type ScopeWorld, type SecretScanner, type Selector, type SendParams, type SendResult, type Sql, type SqlValue, type StatusState, type StopNudge, type StopWaitResult, TITLE_CHARS, type ThreadMessage, type Verdict, VerdictCache, WAKES, type Wake, type WhyResult, type XAllow, type XContact, XCounters, type XDelivered, type XEnvelopeFrom, XLIMITS, type XLimitConfig, type XLimits, type XListed, type XMessage, type XRelease, admits, allowlisted, applyRedactions, canonicalPolicy, cleanLabels, cleanText, clip, clipPasses, clipped, clock, collapseTools, createSecretScanner, crossFleet, dayStart, declaredLimits, deliveryText, digestText, dropQuoted, effectiveSelector, expandRules, findPii, findSecret, gateRefusal, groupCapKey, handbackOutput, harnessFits, hasScope, honestSender, isLeaving, joinRedacted, judgeBoundary, judgeDetailForAgents, judgeFellBack, judgeModel, judgePrompt, keepLast, keptHarness, leavesFleet, matches, mergeVerdicts, narrower, newId, newJudgeStats, one, parseJudgePolicy, parsePolicy, parseVerdict, parseXMessage, peekLevel, policyRefusal, prefilter, presenceText, presetOf, randomHex, reachLines, readText, receiptLines, redactPii, redactSecrets, redactionKind, relation, relationText, replyGrant, runJudge, sameSecret, scopeDecision, scopeLists, seqList, setImplementationPrefixes, sha256Hex, shortId, steerNote, stripControl, stripRelaySpans, stripSteerNote, unreachable, untilTomorrow, utf8Bytes, validHandle, validHarness, validSelector, wakeText, widenText, wrapMessage, wrapPeek, xlimits };
2559
+ export { ACCOUNT_HANDLE, ACTIVITY_KINDS, type ActivityKind, type ActivityRow, type Admission, type AdoptResult, type AskResult, type Attachment, type AuditRow, BROKER_CAPABILITIES, type BreakerAdmission, CAPABILITIES, CLI_NAME, type Claim, type CoreOptions, type CrossAccountGate, type CrossAccountLink, DEFAULT_IMPLEMENTATION_PREFIXES, DEFAULT_JUDGE_BUDGET, DEFAULT_JUDGE_EFFORT, type DataClass, ERROR_CODES, type EndpointKind, type EndpointRow, type EndpointView, type Envelope, type ErrorCode, FakeJudgeClient, GENERIC_HARNESSES, GONE_PREFIX, GROUP_NAME, type GateContext, type GateVerdict, HANDBACK_CUT, HUMAN_HANDLE, HttpJudgeClient, type HttpJudgeOptions, JUDGE_BREAKER_DEFAULTS, JUDGE_CATEGORIES, JUDGE_EFFORTS, JUDGE_FALLBACK_NOTE, JUDGE_MAX_OUTPUT_TOKENS, JUDGE_PROMPT_VERSION, JUDGE_PROVIDERS, JUDGE_VERDICTS, type JudgeAuditDetail, type JudgeAuditEvent, type JudgeBoundary, JudgeBreaker, type JudgeBreakerOptions, type JudgeBudget, type JudgeCategory, type JudgeClient, type JudgeContext, type JudgeEffort, type JudgeHeld, type JudgeModelConfig, type JudgePolicy, type JudgeProvider, JudgeProviderError, type JudgeRedacted, type JudgeSide, type JudgeStats, type JudgeVerdict, LIMITS, MESSAGE_KINDS, type MessageKind, type MessageReceipts, type MessageRow, OMITTED, PASS_GATE, PEEK, PREAMBLE, PREAMBLE_BRIEF, PRESET_LISTS, PROTOCOL, type PeekLevel, type PeekResult, type PeekSeen, type PeekState, type PeekTurn, type PeerView, type PiiHit, type Policy, type PolicyDefaults, type PolicyGroup, type PolicyRule, type PolicySubject, type Prefiltered, type Presence, REDACTION_KINDS, type Reach, type Redaction, RelayCore, RelayError, type RunJudgeInput, SCOPE_PRESETS, SERVER_FIELDS, SHORT_ID, STATUS_STATES, SYSTEM_HANDLE, type ScopeActor, type ScopeCap, type ScopeCaps, type ScopeChange, type ScopeDecision, type ScopeDirection, type ScopeEntry, type ScopeGroupIdentity, type ScopeGroupRules, type ScopeList, type ScopeNode, type ScopePolicyCap, type ScopePreset, type ScopeRules, type ScopeSubject, type ScopeView, type ScopeWorld, type SecretScanner, type Selector, type SendParams, type SendResult, type Sql, type SqlValue, type StatusState, type StopNudge, type StopWaitResult, TITLE_CHARS, type ThreadMessage, type Verdict, VerdictCache, WAKES, type Wake, type WhyResult, type XAllow, type XContact, XCounters, type XDelivered, type XEnvelopeFrom, XLIMITS, type XLimitConfig, type XLimits, type XListed, type XMessage, type XRelease, admits, allowlisted, applyRedactions, canonicalPolicy, cleanLabels, cleanText, clip, clipPasses, clipped, clock, collapseTools, createSecretScanner, crossFleet, dayStart, declaredLimits, deliveryText, digestText, dropQuoted, effectiveSelector, expandRules, findPii, findSecret, gateRefusal, groupCapKey, handbackOutput, harnessFits, hasScope, honestSender, isLeaving, joinRedacted, judgeBoundary, judgeDetailForAgents, judgeFellBack, judgeModel, judgePrompt, keepLast, keptHarness, leavesFleet, matches, mergeVerdicts, narrower, newId, newJudgeStats, one, parseJudgePolicy, parsePolicy, parseVerdict, parseXMessage, peekLevel, policyRefusal, prefilter, presenceText, presetOf, randomHex, reachLines, readText, receiptLines, redactPii, redactSecrets, redactionKind, relation, relationText, replyGrant, runJudge, sameSecret, scopeDecision, scopeLists, seqList, setImplementationPrefixes, sha256Hex, shortId, steerNote, stripControl, stripRelaySpans, stripSteerNote, unreachable, untilTomorrow, utf8Bytes, validHandle, validHarness, validSelector, wakeText, widenText, wrapMessage, wrapPeek, xlimits };
package/dist/index.js CHANGED
@@ -179,7 +179,7 @@ var RelayError = class extends Error {
179
179
  retryAfterS;
180
180
  detail;
181
181
  };
182
- var WAKES = ["channels", "pi", "opencode-plugin", "codex-app-server", "none"];
182
+ var WAKES = ["channels", "pi", "opencode-plugin", "codex-app-server", "claude-inbox", "none"];
183
183
  var ACTIVITY_KINDS = ["session_start", "tool", "stop", "note", "session_end"];
184
184
  var MESSAGE_KINDS = ["message", "request", "reply", "status"];
185
185
  var STATUS_STATES = ["working", "waiting", "blocked", "done"];
@@ -201,7 +201,7 @@ function keptHarness(held, claimed) {
201
201
  return generic(claimed) && !generic(held) ? held : claimed;
202
202
  }
203
203
  var SCOPE_PRESETS = ["open", "tree", "parent", "none"];
204
- var SERVER_FIELDS = ["from", "id", "seq", "hop", "created_at", "auth", "expires_at", "relation"];
204
+ var SERVER_FIELDS = ["from", "id", "seq", "hop", "created_at", "auth", "expires_at", "relation", "link"];
205
205
 
206
206
  // src/scrub.ts
207
207
  var KEY_KIND = "[A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----";
@@ -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] };
@@ -1326,6 +1327,7 @@ var RelayCore = class {
1326
1327
  const messageColumns = this.sql.all(`PRAGMA table_info(messages)`).map((c) => c.name);
1327
1328
  if (!messageColumns.includes("sender_tty")) this.sql.all(`ALTER TABLE messages ADD COLUMN sender_tty INTEGER`);
1328
1329
  if (!messageColumns.includes("require_reply")) this.sql.all(`ALTER TABLE messages ADD COLUMN require_reply INTEGER`);
1330
+ if (!messageColumns.includes("link_parent")) this.sql.all(`ALTER TABLE messages ADD COLUMN link_parent TEXT`);
1329
1331
  const endpointColumns = this.sql.all(`PRAGMA table_info(endpoints)`).map((c) => c.name);
1330
1332
  if (!endpointColumns.includes("session")) this.sql.all(`ALTER TABLE endpoints ADD COLUMN session TEXT`);
1331
1333
  if (!endpointColumns.includes("hook_seen")) this.sql.all(`ALTER TABLE endpoints ADD COLUMN hook_seen INTEGER`);
@@ -1338,6 +1340,7 @@ var RelayCore = class {
1338
1340
  for (const [column, type] of [
1339
1341
  ["tree", "TEXT"],
1340
1342
  ["parent_ep", "TEXT"],
1343
+ ["parent_addr", "TEXT"],
1341
1344
  ["depth", "INTEGER"],
1342
1345
  ["rules", "TEXT"],
1343
1346
  ["caps", "TEXT"],
@@ -1942,7 +1945,7 @@ var RelayCore = class {
1942
1945
  }
1943
1946
  caps.caps.push(...this.capsOf(row).caps);
1944
1947
  this.sql.all(
1945
- `UPDATE endpoints SET tree = ?, parent_ep = ?, depth = ?, rules = ?, caps = ? WHERE id = ?`,
1948
+ `UPDATE endpoints SET tree = ?, parent_ep = ?, parent_addr = NULL, depth = ?, rules = ?, caps = ? WHERE id = ?`,
1946
1949
  parent ? parent.tree ?? parent.id : null,
1947
1950
  parent?.id ?? null,
1948
1951
  depth,
@@ -2195,13 +2198,20 @@ var RelayCore = class {
2195
2198
  */
2196
2199
  waker(endpoint, live, options = {}) {
2197
2200
  if (live) this.sql.all(`UPDATE endpoints SET wakers = wakers + 1 WHERE id = ?`, endpoint);
2198
- else
2201
+ else {
2202
+ const now = this.now();
2199
2203
  this.sql.all(
2200
2204
  `UPDATE endpoints SET wakers = MAX(wakers - 1, 0), waker_left = CASE WHEN ? THEN ? ELSE waker_left END WHERE id = ?`,
2201
2205
  options.grace ? 1 : 0,
2202
- this.now(),
2206
+ now,
2203
2207
  endpoint
2204
2208
  );
2209
+ if (!options.grace) this.lapseWake(endpoint, now);
2210
+ }
2211
+ }
2212
+ /** clears a wake no waker holds once a hook waker's grace ran out too: nothing is left to wake the endpoint */
2213
+ lapseWake(endpoint, now) {
2214
+ this.sql.all(`UPDATE endpoints SET wake = NULL WHERE id = ? AND wakers = 0 AND COALESCE(waker_left, 0) < ?`, endpoint, now - LIMITS.restartGraceS * 1e3);
2205
2215
  }
2206
2216
  /**
2207
2217
  * 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.
@@ -2269,30 +2279,52 @@ var RelayCore = class {
2269
2279
  return this.whoami(row.id);
2270
2280
  });
2271
2281
  }
2272
- /**
2273
- * 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.
2274
- * Only the human at a terminal links another session, or an agent from its own shell with a terminal attached, under itself by default.
2275
- * A session links itself with {@link joinSession}'s `under`
2276
- */
2277
2282
  adopt(actor, params, options = {}) {
2278
- return this.scopeTx(() => {
2283
+ const prepared = this.scopeTx(() => {
2279
2284
  const me = this.actorRow(actor);
2280
2285
  if (actor.kind === "human") this.needTerminal(actor, me, params.endpoint);
2281
- else if (options.tty !== true)
2282
- throw this.scopeRefused(
2283
- actor,
2284
- me,
2285
- { target: String(params.endpoint), reason: "adopt" },
2286
- `only the human at a terminal links another session under a parent; the session links itself with ${CLI_NAME} join --under`
2287
- );
2286
+ else if (actor.auth === "lineage")
2287
+ throw this.scopeRefused(actor, me, { target: String(params.endpoint), reason: "adopt" }, "only the session itself may request a link");
2288
2288
  const target = this.resolve(params.endpoint);
2289
2289
  if (target.kind !== "agent" || !this.localRow(target)) throw new RelayError("invalid_params", "only a local agent endpoint can be linked");
2290
2290
  const parentAddress = params.parent ?? (actor.kind === "human" ? void 0 : this.address(me));
2291
2291
  if (parentAddress === void 0 || parentAddress === null || parentAddress === "")
2292
2292
  throw new RelayError("invalid_params", "the human names the parent to link under");
2293
2293
  const parent = this.resolve(parentAddress);
2294
- this.link(target, parent, this.actorDetail(actor, me));
2295
- return { endpoint: this.whoami(target.id), parent: this.address(parent) };
2294
+ if (actor.kind !== "human" && parent.id !== me.id)
2295
+ throw this.scopeRefused(actor, me, { target: this.address(target), reason: "adopt" }, "an agent may request a link only under itself");
2296
+ const result2 = { endpoint: this.whoami(target.id), parent: this.address(parent) };
2297
+ if (actor.kind === "human" || options.tty === true) {
2298
+ this.link(target, parent, this.actorDetail(actor, me));
2299
+ return { result: { ...result2, endpoint: this.whoami(target.id) } };
2300
+ }
2301
+ if (target.parent_ep !== null || target.parent_addr !== null) throw this.alreadyLinked(target);
2302
+ const auth2 = actor.auth === "self" ? "self" : "session";
2303
+ return { result: result2, sender: { from: me.id, auth: auth2 } };
2304
+ });
2305
+ if (!prepared.sender) return prepared.result;
2306
+ const { result } = prepared;
2307
+ const { from, auth } = prepared.sender;
2308
+ const send = {
2309
+ to: result.endpoint.address,
2310
+ kind: "request",
2311
+ ttl_s: 86400,
2312
+ body: `${result.parent} asks you to link under it as its worker. To accept, run ${CLI_NAME} link accept <id> with this message's id, or ${CLI_NAME} join --under ${result.parent} (relay_link_accept {id} or relay_join {under: "${result.parent}"}).`
2313
+ };
2314
+ const requested = (sent) => ({ ...result, ...sent.held ? { held: sent.held } : { requested: sent.id } });
2315
+ return options.send ? options.send(from, send, { link: auth }).then(requested) : requested(this.send(from, send, { link: auth }));
2316
+ }
2317
+ /** accepts a broker-issued request delivered to the caller's own session */
2318
+ linkAccept(actor, params) {
2319
+ return this.scopeTx(() => {
2320
+ if (actor.kind !== "endpoint" || actor.auth === "lineage") throw new RelayError("scope_refused", "only the session itself accepts a link request");
2321
+ const me = this.actorRow(actor);
2322
+ const request = this.delivered(me.id, params.id);
2323
+ if (!request?.link || request.kind !== "request") throw new RelayError("not_found", "no link request delivered to this endpoint");
2324
+ const parent = this.mustEndpoint(request.from.endpoint);
2325
+ if (parent.kind !== "agent") throw new RelayError("invalid_params", "the requesting parent is no longer an agent");
2326
+ this.link(me, parent, { ...this.actorDetail(actor, me), request: request.id });
2327
+ return { endpoint: this.whoami(me.id), parent: this.address(parent) };
2296
2328
  });
2297
2329
  }
2298
2330
  /**
@@ -2307,7 +2339,8 @@ var RelayCore = class {
2307
2339
  const target = params.endpoint === void 0 ? me : this.resolve(params.endpoint);
2308
2340
  if (actor.kind !== "human" && target.id !== me.id) throw new RelayError("scope_refused", "an agent unlinks only itself");
2309
2341
  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`);
2342
+ if (target.parent_ep === null && target.parent_addr === null)
2343
+ throw new RelayError("invalid_params", `${this.address(target)} is a root: it has no parent`);
2311
2344
  if (actor.kind !== "human" && this.policy?.defaults?.unlink !== "self")
2312
2345
  throw this.scopeRefused(
2313
2346
  actor,
@@ -2315,6 +2348,14 @@ var RelayCore = class {
2315
2348
  { target: this.address(target), reason: "unlink" },
2316
2349
  "unlinking widens a worker's scope: only the human at a terminal unlinks it (policy defaults.unlink: self lets it)"
2317
2350
  );
2351
+ if (target.parent_ep === null) {
2352
+ const from2 = this.linkedParent(target) ?? target.parent_addr;
2353
+ this.sql.all(`UPDATE endpoints SET parent_addr = NULL, linked = NULL WHERE id = ?`, target.id);
2354
+ this.scopeMemo?.ancestors.clear();
2355
+ this.scopeMemo?.limited.clear();
2356
+ this.audit("unlink", target.id, null, { ...this.actorDetail(actor, me), target: this.address(target), parent: from2 });
2357
+ return { endpoint: this.whoami(target.id), parent: from2 };
2358
+ }
2318
2359
  const parent = this.byId(target.parent_ep);
2319
2360
  const from = parent ? this.address(parent) : target.parent_ep;
2320
2361
  const shift = target.depth ?? 0;
@@ -2338,16 +2379,26 @@ var RelayCore = class {
2338
2379
  return { endpoint: this.whoami(target.id), parent: from };
2339
2380
  });
2340
2381
  }
2341
- /** `row`, a root, and its subtree moved under `parent`, a local agent that is neither `row` nor one of its descendants */
2382
+ /**
2383
+ * `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
2384
+ * machine goes to {@link linkAcross}
2385
+ */
2342
2386
  link(row, parent, by) {
2343
- if (parent.kind !== "agent" || !this.localRow(parent)) throw new RelayError("invalid_params", "the parent is a local agent endpoint");
2387
+ if (!this.localRow(parent)) {
2388
+ const key = this.linkAcross(row, parent);
2389
+ if (row.parent_addr === key) return;
2390
+ if (row.parent_ep !== null || row.parent_addr !== null) throw this.alreadyLinked(row);
2391
+ this.sql.all(`UPDATE endpoints SET parent_addr = ?, linked = ? WHERE id = ?`, key, this.now(), row.id);
2392
+ this.scopeMemo?.ancestors.clear();
2393
+ this.scopeMemo?.limited.clear();
2394
+ this.audit("adopt", row.id, by.request ?? null, { ...by, target: this.address(row), parent: this.address(parent) });
2395
+ return;
2396
+ }
2397
+ if (parent.kind !== "agent") throw new RelayError("invalid_params", "the parent is an agent endpoint");
2344
2398
  if (row.parent_ep === parent.id) return;
2345
2399
  if (parent.id === row.id || this.ancestorsOf(parent).includes(row.id))
2346
2400
  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
- }
2401
+ if (row.parent_ep !== null || row.parent_addr !== null) throw this.alreadyLinked(row);
2351
2402
  const depth = (parent.depth ?? 0) + 1;
2352
2403
  const deepest = one(this.sql, `SELECT MAX(depth) AS d FROM endpoints WHERE tree = ?`, row.id)?.d ?? 0;
2353
2404
  if (deepest + depth > LIMITS.depthMax)
@@ -2369,7 +2420,28 @@ var RelayCore = class {
2369
2420
  );
2370
2421
  this.scopeMemo?.ancestors.clear();
2371
2422
  this.scopeMemo?.limited.clear();
2372
- this.audit("adopt", row.id, null, { ...by, target: this.address(row), parent: this.address(parent) });
2423
+ this.audit("adopt", row.id, by.request ?? null, { ...by, target: this.address(row), parent: this.address(parent) });
2424
+ }
2425
+ /** a link of `row`, which already sits under a parent, to another one */
2426
+ alreadyLinked(row) {
2427
+ const local = row.parent_ep ? this.byId(row.parent_ep) : void 0;
2428
+ const now = local ? this.address(local) : row.parent_addr !== null ? this.linkedParent(row) ?? row.parent_addr : "another agent";
2429
+ return new RelayError("scope_refused", `${this.address(row)} already sits under ${now}; unlink it first`);
2430
+ }
2431
+ /**
2432
+ * the key `<machine>/<handle>` that links `row`, a local agent, under `parent`, an agent on another machine, once the parent is checked; no
2433
+ * inherited or child-default caps come with it. A relay of one machine has no such parent
2434
+ */
2435
+ linkAcross(_row, _parent) {
2436
+ throw new RelayError("invalid_params", "the parent is a local agent endpoint");
2437
+ }
2438
+ /** whether `a` and `b` are a parent and its child on different machines */
2439
+ linkedAcross(_a, _b) {
2440
+ return false;
2441
+ }
2442
+ /** the address of `row`'s parent on another machine, null when it has none; with `shown`, null for a parent outside it */
2443
+ linkedParent(_row, _shown) {
2444
+ return null;
2373
2445
  }
2374
2446
  /**
2375
2447
  * renames an agent in place: its id, mail, threads, status, title and relations stay. An agent renames only itself, and only a root unless
@@ -2418,8 +2490,12 @@ var RelayCore = class {
2418
2490
  ])
2419
2491
  this.sql.all(`UPDATE ${table} SET ${column} = REPLACE(${column}, ?, ?) WHERE instr(${column}, ?) > 0`, a, b, a);
2420
2492
  }
2493
+ this.renamedHandle(from, handle);
2421
2494
  this.audit("rename", row.id, null, { from, to: handle });
2422
2495
  }
2496
+ /** a local agent's handle moved from `from` to `to`: a subclass updates what names it on another machine's rows */
2497
+ renamedHandle(_from, _to) {
2498
+ }
2423
2499
  /**
2424
2500
  * 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
2501
  * itself, the human at a terminal any local root or linked agent. A spawned worker stays joined while it runs
@@ -2451,6 +2527,7 @@ var RelayCore = class {
2451
2527
  * sends without a terminal, so the scopes check it like any unplaced sender
2452
2528
  */
2453
2529
  send(from, params, origin = {}) {
2530
+ if ("link" in params) throw new RelayError("invalid_params", "link is set by the relay, not the sender");
2454
2531
  const sender = this.mustEndpoint(from);
2455
2532
  const senderTty = sender.kind === "human" ? origin.tty === true ? 1 : 0 : null;
2456
2533
  const unverified = sender.kind === "human" && origin.unverified === true;
@@ -2512,6 +2589,9 @@ var RelayCore = class {
2512
2589
  const pending = [...unjoined.values()].map((grant) => ({ key: pendingKey(grant.token_hash), address: `${this.user}/${grant.name}` }));
2513
2590
  const reached = recipients.length + pending.length;
2514
2591
  if (reached > LIMITS.fanOutTotal) throw new RelayError("too_large", `the send reaches ${reached} endpoints, more than ${LIMITS.fanOutTotal}`);
2592
+ if (origin.link && (sender.kind !== "agent" || kind !== "request" || recipients.length !== 1 || pending.length || recipients[0].kind !== "agent" || !this.localRow(recipients[0])))
2593
+ throw new RelayError("invalid_params", "a link request is from an agent to one local root agent");
2594
+ if (origin.link && (recipients[0].parent_ep !== null || recipients[0].parent_addr !== null)) throw this.alreadyLinked(recipients[0]);
2515
2595
  const requestedThread = this.threadRef(from, cleanText(params.thread, "thread", 64));
2516
2596
  const payloadHash = this.hash(
2517
2597
  JSON.stringify([
@@ -2524,7 +2604,8 @@ var RelayCore = class {
2524
2604
  attachments.map((a) => a.path).sort(),
2525
2605
  mime,
2526
2606
  ttl,
2527
- params.require_reply === true
2607
+ params.require_reply === true,
2608
+ ...origin.link ? [origin.link] : []
2528
2609
  ])
2529
2610
  );
2530
2611
  if (clientId) {
@@ -2682,8 +2763,8 @@ var RelayCore = class {
2682
2763
  const expires = now + ttl * 1e3;
2683
2764
  const to = targets.length === 1 ? String(targets[0]) : targets.join(",");
2684
2765
  this.sql.all(
2685
- `INSERT INTO messages (id, thread, reply_to, kind, from_ep, to_addr, body, mime, data_class, attachments, hop, created_at, expires_at, sender_tty, require_reply)
2686
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
2766
+ `INSERT INTO messages (id, thread, reply_to, kind, from_ep, to_addr, body, mime, data_class, attachments, hop, created_at, expires_at, sender_tty, require_reply, link_parent)
2767
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
2687
2768
  id,
2688
2769
  thread,
2689
2770
  parent?.id ?? null,
@@ -2698,7 +2779,8 @@ var RelayCore = class {
2698
2779
  now,
2699
2780
  expires,
2700
2781
  senderTty,
2701
- params.require_reply === true ? 1 : null
2782
+ params.require_reply === true ? 1 : null,
2783
+ origin.link ? this.address(sender) : null
2702
2784
  );
2703
2785
  if (parent) this.sql.all(`UPDATE deliveries SET acked_at = ? WHERE message_id = ? AND endpoint = ? AND acked_at IS NULL`, now, parent.id, from);
2704
2786
  const deliveries = [];
@@ -2740,6 +2822,13 @@ var RelayCore = class {
2740
2822
  );
2741
2823
  }
2742
2824
  this.audit("send", from, id, { to: deliveries.map((d) => d.address), kind, bytes, hop, thread, data_class: dataClass });
2825
+ if (origin.link)
2826
+ this.audit("adopt_request", from, id, {
2827
+ by: this.address(sender),
2828
+ auth: origin.link,
2829
+ target: this.address(recipients[0]),
2830
+ parent: this.address(sender)
2831
+ });
2743
2832
  for (const recipient of recipients) this.detectPingPong(sender, recipient);
2744
2833
  return sent;
2745
2834
  });
@@ -3593,15 +3682,24 @@ var RelayCore = class {
3593
3682
  if (!root || root.handle.startsWith(GONE_PREFIX) || !seen) this.dropGroup(group.name);
3594
3683
  }
3595
3684
  }
3596
- /** ends the hook presence of local agent endpoints whose harness process exited without a session_end, as one would */
3685
+ /** 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
3686
  endExited(now = this.now()) {
3598
3687
  for (const row of this.sql.all(
3599
3688
  `SELECT * FROM endpoints WHERE kind = 'agent' AND connections = 0 AND hook_seen IS NOT NULL AND process IS NOT NULL`
3600
3689
  )) {
3601
3690
  const pid = Number(/^pid:(\d+)/.exec(row.process)?.[1]);
3602
3691
  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);
3692
+ this.sql.all(
3693
+ `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`,
3694
+ now,
3695
+ row.id
3696
+ );
3604
3697
  }
3698
+ for (const row of this.sql.all(
3699
+ `SELECT * FROM endpoints WHERE wake IS NOT NULL AND wakers = 0 AND waker_left < ?`,
3700
+ now - LIMITS.restartGraceS * 1e3
3701
+ ))
3702
+ if (this.localRow(row)) this.lapseWake(row.id, now);
3605
3703
  }
3606
3704
  /** an agent endpoint left offline drops a working or waiting status, which no one is left to keep current */
3607
3705
  settleOffline(now) {
@@ -3651,7 +3749,8 @@ var RelayCore = class {
3651
3749
  if (params.kind === "session_end") {
3652
3750
  if (own)
3653
3751
  this.sql.all(
3654
- `UPDATE endpoints SET hook_seen = NULL, hook_ended = ?, session = CASE WHEN connections = 0 THEN NULL ELSE session END WHERE id = ?`,
3752
+ `UPDATE endpoints SET hook_seen = NULL, hook_ended = ?, session = CASE WHEN connections = 0 THEN NULL ELSE session END,
3753
+ wake = CASE WHEN wakers = 0 THEN NULL ELSE wake END WHERE id = ?`,
3655
3754
  now,
3656
3755
  endpoint
3657
3756
  );
@@ -4280,7 +4379,8 @@ var RelayCore = class {
4280
4379
  }
4281
4380
  const note = [`exited ${p.code}`, spender.status_note].filter(Boolean).join("; ").slice(0, LIMITS.noteChars);
4282
4381
  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 = ?`,
4382
+ `UPDATE endpoints SET status_state = 'done', status_note = ?, hook_seen = NULL, hook_ended = ?, session = CASE WHEN connections = 0 THEN NULL ELSE session END,
4383
+ wake = CASE WHEN wakers = 0 THEN NULL ELSE wake END WHERE id = ?`,
4284
4384
  note,
4285
4385
  now,
4286
4386
  spender.id
@@ -4705,6 +4805,7 @@ ${stderr}` : "";
4705
4805
  return scopeDecision(world, world.node(a.id) ?? this.scopeNode(a), world.node(b.id) ?? this.scopeNode(b), {
4706
4806
  humanAlways: this.defaults().humanAlways,
4707
4807
  solicited,
4808
+ linked: a.id !== b.id && this.linkedAcross(a, b),
4708
4809
  unverified,
4709
4810
  strict
4710
4811
  });
@@ -5435,7 +5536,8 @@ ${stderr}` : "";
5435
5536
  }
5436
5537
  placeView(row, shown) {
5437
5538
  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) };
5539
+ const across = !parent && row.parent_ep === null && row.parent_addr ? this.linkedParent(row, shown) : null;
5540
+ return { parent: parent ? this.address(parent) : across, depth: row.depth ?? 0, scope: presetOf(this.scopeNode(row), this.defaults().symmetric) };
5439
5541
  }
5440
5542
  /** `recipient` names the delivery's endpoint when there is no `seq`: a send to several addresses shows each recipient only itself */
5441
5543
  envelope(row, seq, recipient) {
@@ -5472,6 +5574,7 @@ ${stderr}` : "";
5472
5574
  created_at: iso(row.created_at),
5473
5575
  expires_at: iso(row.expires_at),
5474
5576
  ...row.require_reply === 1 ? { require_reply: true } : {},
5577
+ ...row.link_parent ? { link: { parent: row.link_parent } } : {},
5475
5578
  ...kin
5476
5579
  };
5477
5580
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scp-dev/core",
3
- "version": "0.1.6",
3
+ "version": "0.3.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
@@ -4,6 +4,7 @@ import {
4
4
  CLI_NAME,
5
5
  type ActivityKind,
6
6
  type ActivityRow,
7
+ type AdoptResult,
7
8
  type AuditRow,
8
9
  type Claim,
9
10
  type DataClass,
@@ -243,6 +244,8 @@ export interface EndpointRow {
243
244
  /** the root endpoint's id; null for a root */
244
245
  tree: string | null
245
246
  parent_ep: string | null
247
+ /** a parent on another machine of the account, `<machine>/<handle>`: the row stays a root of its own tree */
248
+ parent_addr: string | null
246
249
  depth: number | null
247
250
  /** JSON {@link ScopeRules} */
248
251
  rules: string | null
@@ -289,8 +292,11 @@ export interface MessageRow {
289
292
  expires_at: number
290
293
  sender_tty: number | null
291
294
  require_reply: number | null
295
+ link_parent: string | null
292
296
  }
293
297
 
298
+ type AdoptSend = (from: string, params: SendParams, origin: { link: "session" | "self" }) => Promise<SendResult>
299
+
294
300
  export interface CoreOptions {
295
301
  /** the local user's address part; `me` until a login names one */
296
302
  user?: string
@@ -521,6 +527,7 @@ export class RelayCore {
521
527
  const messageColumns = this.sql.all<{ name: string }>(`PRAGMA table_info(messages)`).map((c) => c.name)
522
528
  if (!messageColumns.includes("sender_tty")) this.sql.all(`ALTER TABLE messages ADD COLUMN sender_tty INTEGER`)
523
529
  if (!messageColumns.includes("require_reply")) this.sql.all(`ALTER TABLE messages ADD COLUMN require_reply INTEGER`)
530
+ if (!messageColumns.includes("link_parent")) this.sql.all(`ALTER TABLE messages ADD COLUMN link_parent TEXT`)
524
531
  const endpointColumns = this.sql.all<{ name: string }>(`PRAGMA table_info(endpoints)`).map((c) => c.name)
525
532
  if (!endpointColumns.includes("session")) this.sql.all(`ALTER TABLE endpoints ADD COLUMN session TEXT`)
526
533
  if (!endpointColumns.includes("hook_seen")) this.sql.all(`ALTER TABLE endpoints ADD COLUMN hook_seen INTEGER`)
@@ -533,6 +540,7 @@ export class RelayCore {
533
540
  for (const [column, type] of [
534
541
  ["tree", "TEXT"],
535
542
  ["parent_ep", "TEXT"],
543
+ ["parent_addr", "TEXT"],
536
544
  ["depth", "INTEGER"],
537
545
  ["rules", "TEXT"],
538
546
  ["caps", "TEXT"],
@@ -1203,7 +1211,7 @@ export class RelayCore {
1203
1211
  }
1204
1212
  caps.caps.push(...this.capsOf(row).caps)
1205
1213
  this.sql.all(
1206
- `UPDATE endpoints SET tree = ?, parent_ep = ?, depth = ?, rules = ?, caps = ? WHERE id = ?`,
1214
+ `UPDATE endpoints SET tree = ?, parent_ep = ?, parent_addr = NULL, depth = ?, rules = ?, caps = ? WHERE id = ?`,
1207
1215
  parent ? (parent.tree ?? parent.id) : null,
1208
1216
  parent?.id ?? null,
1209
1217
  depth,
@@ -1500,13 +1508,21 @@ export class RelayCore {
1500
1508
  */
1501
1509
  waker(endpoint: string, live: boolean, options: { grace?: boolean } = {}): void {
1502
1510
  if (live) this.sql.all(`UPDATE endpoints SET wakers = wakers + 1 WHERE id = ?`, endpoint)
1503
- else
1511
+ else {
1512
+ const now = this.now()
1504
1513
  this.sql.all(
1505
1514
  `UPDATE endpoints SET wakers = MAX(wakers - 1, 0), waker_left = CASE WHEN ? THEN ? ELSE waker_left END WHERE id = ?`,
1506
1515
  options.grace ? 1 : 0,
1507
- this.now(),
1516
+ now,
1508
1517
  endpoint,
1509
1518
  )
1519
+ if (!options.grace) this.lapseWake(endpoint, now)
1520
+ }
1521
+ }
1522
+
1523
+ /** clears a wake no waker holds once a hook waker's grace ran out too: nothing is left to wake the endpoint */
1524
+ private lapseWake(endpoint: string, now: number): void {
1525
+ this.sql.all(`UPDATE endpoints SET wake = NULL WHERE id = ? AND wakers = 0 AND COALESCE(waker_left, 0) < ?`, endpoint, now - LIMITS.restartGraceS * 1000)
1510
1526
  }
1511
1527
 
1512
1528
  /**
@@ -1581,30 +1597,60 @@ export class RelayCore {
1581
1597
  })
1582
1598
  }
1583
1599
 
1584
- /**
1585
- * 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.
1586
- * Only the human at a terminal links another session, or an agent from its own shell with a terminal attached, under itself by default.
1587
- * A session links itself with {@link joinSession}'s `under`
1588
- */
1589
- adopt(actor: ScopeActor, params: { endpoint: unknown; parent?: unknown }, options: { tty?: boolean } = {}): { endpoint: EndpointView; parent: string } {
1590
- return this.scopeTx(() => {
1600
+ /** the human links a local root directly; an agent asks it to consent through the normal send path */
1601
+ adopt(actor: ScopeActor, params: { endpoint: unknown; parent?: unknown }, options?: { tty?: boolean }): AdoptResult
1602
+ adopt(actor: ScopeActor, params: { endpoint: unknown; parent?: unknown }, options: { tty?: boolean; send: AdoptSend }): AdoptResult | Promise<AdoptResult>
1603
+ adopt(
1604
+ actor: ScopeActor,
1605
+ params: { endpoint: unknown; parent?: unknown },
1606
+ options: { tty?: boolean; send?: AdoptSend } = {},
1607
+ ): AdoptResult | Promise<AdoptResult> {
1608
+ const prepared: { result: AdoptResult; sender?: { from: string; auth: "session" | "self" } } = this.scopeTx(() => {
1591
1609
  const me = this.actorRow(actor)
1592
1610
  if (actor.kind === "human") this.needTerminal(actor, me, params.endpoint)
1593
- else if (options.tty !== true)
1594
- throw this.scopeRefused(
1595
- actor,
1596
- me,
1597
- { target: String(params.endpoint), reason: "adopt" },
1598
- `only the human at a terminal links another session under a parent; the session links itself with ${CLI_NAME} join --under`,
1599
- )
1611
+ else if (actor.auth === "lineage")
1612
+ throw this.scopeRefused(actor, me, { target: String(params.endpoint), reason: "adopt" }, "only the session itself may request a link")
1600
1613
  const target = this.resolve(params.endpoint)
1601
1614
  if (target.kind !== "agent" || !this.localRow(target)) throw new RelayError("invalid_params", "only a local agent endpoint can be linked")
1602
1615
  const parentAddress = params.parent ?? (actor.kind === "human" ? undefined : this.address(me))
1603
1616
  if (parentAddress === undefined || parentAddress === null || parentAddress === "")
1604
1617
  throw new RelayError("invalid_params", "the human names the parent to link under")
1605
1618
  const parent = this.resolve(parentAddress)
1606
- this.link(target, parent, this.actorDetail(actor, me))
1607
- return { endpoint: this.whoami(target.id), parent: this.address(parent) }
1619
+ if (actor.kind !== "human" && parent.id !== me.id)
1620
+ throw this.scopeRefused(actor, me, { target: this.address(target), reason: "adopt" }, "an agent may request a link only under itself")
1621
+ const result: AdoptResult = { endpoint: this.whoami(target.id), parent: this.address(parent) }
1622
+ if (actor.kind === "human" || options.tty === true) {
1623
+ this.link(target, parent, this.actorDetail(actor, me))
1624
+ return { result: { ...result, endpoint: this.whoami(target.id) } }
1625
+ }
1626
+ if (target.parent_ep !== null || target.parent_addr !== null) throw this.alreadyLinked(target)
1627
+ const auth = actor.auth === "self" ? "self" : "session"
1628
+ return { result, sender: { from: me.id, auth } }
1629
+ })
1630
+ if (!prepared.sender) return prepared.result
1631
+ const { result } = prepared
1632
+ const { from, auth } = prepared.sender
1633
+ const send: SendParams = {
1634
+ to: result.endpoint.address,
1635
+ kind: "request",
1636
+ ttl_s: 86_400,
1637
+ body: `${result.parent} asks you to link under it as its worker. To accept, run ${CLI_NAME} link accept <id> with this message's id, or ${CLI_NAME} join --under ${result.parent} (relay_link_accept {id} or relay_join {under: "${result.parent}"}).`,
1638
+ }
1639
+ const requested = (sent: SendResult): AdoptResult => ({ ...result, ...(sent.held ? { held: sent.held } : { requested: sent.id }) })
1640
+ return options.send ? options.send(from, send, { link: auth }).then(requested) : requested(this.send(from, send, { link: auth }))
1641
+ }
1642
+
1643
+ /** accepts a broker-issued request delivered to the caller's own session */
1644
+ linkAccept(actor: ScopeActor, params: { id: unknown }): { endpoint: EndpointView; parent: string } {
1645
+ return this.scopeTx(() => {
1646
+ if (actor.kind !== "endpoint" || actor.auth === "lineage") throw new RelayError("scope_refused", "only the session itself accepts a link request")
1647
+ const me = this.actorRow(actor)
1648
+ const request = this.delivered(me.id, params.id)
1649
+ if (!request?.link || request.kind !== "request") throw new RelayError("not_found", "no link request delivered to this endpoint")
1650
+ const parent = this.mustEndpoint(request.from.endpoint)
1651
+ if (parent.kind !== "agent") throw new RelayError("invalid_params", "the requesting parent is no longer an agent")
1652
+ this.link(me, parent, { ...this.actorDetail(actor, me), request: request.id })
1653
+ return { endpoint: this.whoami(me.id), parent: this.address(parent) }
1608
1654
  })
1609
1655
  }
1610
1656
 
@@ -1620,7 +1666,8 @@ export class RelayCore {
1620
1666
  const target = params.endpoint === undefined ? me : this.resolve(params.endpoint)
1621
1667
  if (actor.kind !== "human" && target.id !== me.id) throw new RelayError("scope_refused", "an agent unlinks only itself")
1622
1668
  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`)
1669
+ if (target.parent_ep === null && target.parent_addr === null)
1670
+ throw new RelayError("invalid_params", `${this.address(target)} is a root: it has no parent`)
1624
1671
  if (actor.kind !== "human" && this.policy?.defaults?.unlink !== "self")
1625
1672
  throw this.scopeRefused(
1626
1673
  actor,
@@ -1628,6 +1675,14 @@ export class RelayCore {
1628
1675
  { target: this.address(target), reason: "unlink" },
1629
1676
  "unlinking widens a worker's scope: only the human at a terminal unlinks it (policy defaults.unlink: self lets it)",
1630
1677
  )
1678
+ if (target.parent_ep === null) {
1679
+ const from = this.linkedParent(target) ?? target.parent_addr!
1680
+ this.sql.all(`UPDATE endpoints SET parent_addr = NULL, linked = NULL WHERE id = ?`, target.id)
1681
+ this.scopeMemo?.ancestors.clear()
1682
+ this.scopeMemo?.limited.clear()
1683
+ this.audit("unlink", target.id, null, { ...this.actorDetail(actor, me), target: this.address(target), parent: from })
1684
+ return { endpoint: this.whoami(target.id), parent: from }
1685
+ }
1631
1686
  const parent = this.byId(target.parent_ep)
1632
1687
  const from = parent ? this.address(parent) : target.parent_ep
1633
1688
  const shift = target.depth ?? 0
@@ -1652,16 +1707,26 @@ export class RelayCore {
1652
1707
  })
1653
1708
  }
1654
1709
 
1655
- /** `row`, a root, and its subtree moved under `parent`, a local agent that is neither `row` nor one of its descendants */
1656
- 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")
1710
+ /**
1711
+ * `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
1712
+ * machine goes to {@link linkAcross}
1713
+ */
1714
+ private link(row: EndpointRow, parent: EndpointRow, by: { by: string; auth: string; request?: string }): void {
1715
+ if (!this.localRow(parent)) {
1716
+ const key = this.linkAcross(row, parent)
1717
+ if (row.parent_addr === key) return
1718
+ if (row.parent_ep !== null || row.parent_addr !== null) throw this.alreadyLinked(row)
1719
+ this.sql.all(`UPDATE endpoints SET parent_addr = ?, linked = ? WHERE id = ?`, key, this.now(), row.id)
1720
+ this.scopeMemo?.ancestors.clear()
1721
+ this.scopeMemo?.limited.clear()
1722
+ this.audit("adopt", row.id, by.request ?? null, { ...by, target: this.address(row), parent: this.address(parent) })
1723
+ return
1724
+ }
1725
+ if (parent.kind !== "agent") throw new RelayError("invalid_params", "the parent is an agent endpoint")
1658
1726
  if (row.parent_ep === parent.id) return
1659
1727
  if (parent.id === row.id || this.ancestorsOf(parent).includes(row.id))
1660
1728
  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
- }
1729
+ if (row.parent_ep !== null || row.parent_addr !== null) throw this.alreadyLinked(row)
1665
1730
  const depth = (parent.depth ?? 0) + 1
1666
1731
  const deepest = one<{ d: number | null }>(this.sql, `SELECT MAX(depth) AS d FROM endpoints WHERE tree = ?`, row.id)?.d ?? 0
1667
1732
  if (deepest + depth > LIMITS.depthMax)
@@ -1683,7 +1748,32 @@ export class RelayCore {
1683
1748
  )
1684
1749
  this.scopeMemo?.ancestors.clear()
1685
1750
  this.scopeMemo?.limited.clear()
1686
- this.audit("adopt", row.id, null, { ...by, target: this.address(row), parent: this.address(parent) })
1751
+ this.audit("adopt", row.id, by.request ?? null, { ...by, target: this.address(row), parent: this.address(parent) })
1752
+ }
1753
+
1754
+ /** a link of `row`, which already sits under a parent, to another one */
1755
+ private alreadyLinked(row: EndpointRow): RelayError {
1756
+ const local = row.parent_ep ? this.byId(row.parent_ep) : undefined
1757
+ const now = local ? this.address(local) : row.parent_addr !== null ? (this.linkedParent(row) ?? row.parent_addr) : "another agent"
1758
+ return new RelayError("scope_refused", `${this.address(row)} already sits under ${now}; unlink it first`)
1759
+ }
1760
+
1761
+ /**
1762
+ * the key `<machine>/<handle>` that links `row`, a local agent, under `parent`, an agent on another machine, once the parent is checked; no
1763
+ * inherited or child-default caps come with it. A relay of one machine has no such parent
1764
+ */
1765
+ protected linkAcross(_row: EndpointRow, _parent: EndpointRow): string {
1766
+ throw new RelayError("invalid_params", "the parent is a local agent endpoint")
1767
+ }
1768
+
1769
+ /** whether `a` and `b` are a parent and its child on different machines */
1770
+ protected linkedAcross(_a: EndpointRow, _b: EndpointRow): boolean {
1771
+ return false
1772
+ }
1773
+
1774
+ /** the address of `row`'s parent on another machine, null when it has none; with `shown`, null for a parent outside it */
1775
+ protected linkedParent(_row: EndpointRow, _shown?: Set<string>): string | null {
1776
+ return null
1687
1777
  }
1688
1778
 
1689
1779
  /**
@@ -1734,9 +1824,13 @@ export class RelayCore {
1734
1824
  ] as const)
1735
1825
  this.sql.all(`UPDATE ${table} SET ${column} = REPLACE(${column}, ?, ?) WHERE instr(${column}, ?) > 0`, a, b, a)
1736
1826
  }
1827
+ this.renamedHandle(from, handle)
1737
1828
  this.audit("rename", row.id, null, { from, to: handle })
1738
1829
  }
1739
1830
 
1831
+ /** a local agent's handle moved from `from` to `to`: a subclass updates what names it on another machine's rows */
1832
+ protected renamedHandle(_from: string, _to: string): void {}
1833
+
1740
1834
  /**
1741
1835
  * 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
1836
  * itself, the human at a terminal any local root or linked agent. A spawned worker stays joined while it runs
@@ -1770,7 +1864,8 @@ export class RelayCore {
1770
1864
  * `origin.tty` is recorded on messages the human inbox sends: whether its client said a terminal was attached. `origin.unverified`: the human
1771
1865
  * sends without a terminal, so the scopes check it like any unplaced sender
1772
1866
  */
1773
- send(from: string, params: SendParams, origin: { tty?: boolean; unverified?: boolean } = {}): SendResult {
1867
+ send(from: string, params: SendParams, origin: { tty?: boolean; unverified?: boolean; link?: "session" | "self" } = {}): SendResult {
1868
+ if ("link" in params) throw new RelayError("invalid_params", "link is set by the relay, not the sender")
1774
1869
  const sender = this.mustEndpoint(from)
1775
1870
  const senderTty = sender.kind === "human" ? (origin.tty === true ? 1 : 0) : null
1776
1871
  const unverified = sender.kind === "human" && origin.unverified === true
@@ -1838,6 +1933,17 @@ export class RelayCore {
1838
1933
  const pending = [...unjoined.values()].map((grant) => ({ key: pendingKey(grant.token_hash), address: `${this.user}/${grant.name}` }))
1839
1934
  const reached = recipients.length + pending.length
1840
1935
  if (reached > LIMITS.fanOutTotal) throw new RelayError("too_large", `the send reaches ${reached} endpoints, more than ${LIMITS.fanOutTotal}`)
1936
+ if (
1937
+ origin.link &&
1938
+ (sender.kind !== "agent" ||
1939
+ kind !== "request" ||
1940
+ recipients.length !== 1 ||
1941
+ pending.length ||
1942
+ recipients[0]!.kind !== "agent" ||
1943
+ !this.localRow(recipients[0]!))
1944
+ )
1945
+ throw new RelayError("invalid_params", "a link request is from an agent to one local root agent")
1946
+ if (origin.link && (recipients[0]!.parent_ep !== null || recipients[0]!.parent_addr !== null)) throw this.alreadyLinked(recipients[0]!)
1841
1947
  const requestedThread = this.threadRef(from, cleanText(params.thread, "thread", 64))
1842
1948
  const payloadHash = this.hash(
1843
1949
  JSON.stringify([
@@ -1851,6 +1957,7 @@ export class RelayCore {
1851
1957
  mime,
1852
1958
  ttl,
1853
1959
  params.require_reply === true,
1960
+ ...(origin.link ? [origin.link] : []),
1854
1961
  ]),
1855
1962
  )
1856
1963
  if (clientId) {
@@ -2020,8 +2127,8 @@ export class RelayCore {
2020
2127
  const expires = now + ttl * 1000
2021
2128
  const to = targets.length === 1 ? String(targets[0]) : targets.join(",")
2022
2129
  this.sql.all(
2023
- `INSERT INTO messages (id, thread, reply_to, kind, from_ep, to_addr, body, mime, data_class, attachments, hop, created_at, expires_at, sender_tty, require_reply)
2024
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
2130
+ `INSERT INTO messages (id, thread, reply_to, kind, from_ep, to_addr, body, mime, data_class, attachments, hop, created_at, expires_at, sender_tty, require_reply, link_parent)
2131
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
2025
2132
  id,
2026
2133
  thread,
2027
2134
  parent?.id ?? null,
@@ -2037,6 +2144,7 @@ export class RelayCore {
2037
2144
  expires,
2038
2145
  senderTty,
2039
2146
  params.require_reply === true ? 1 : null,
2147
+ origin.link ? this.address(sender) : null,
2040
2148
  )
2041
2149
  if (parent) this.sql.all(`UPDATE deliveries SET acked_at = ? WHERE message_id = ? AND endpoint = ? AND acked_at IS NULL`, now, parent.id, from)
2042
2150
  const deliveries: SendResult["deliveries"] = []
@@ -2078,6 +2186,13 @@ export class RelayCore {
2078
2186
  )
2079
2187
  }
2080
2188
  this.audit("send", from, id, { to: deliveries.map((d) => d.address), kind, bytes, hop, thread, data_class: dataClass })
2189
+ if (origin.link)
2190
+ this.audit("adopt_request", from, id, {
2191
+ by: this.address(sender),
2192
+ auth: origin.link,
2193
+ target: this.address(recipients[0]!),
2194
+ parent: this.address(sender),
2195
+ })
2081
2196
  for (const recipient of recipients) this.detectPingPong(sender, recipient)
2082
2197
  return sent
2083
2198
  })
@@ -3044,15 +3159,24 @@ export class RelayCore {
3044
3159
  }
3045
3160
  }
3046
3161
 
3047
- /** ends the hook presence of local agent endpoints whose harness process exited without a session_end, as one would */
3162
+ /** 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
3163
  endExited(now = this.now()): void {
3049
3164
  for (const row of this.sql.all<EndpointRow>(
3050
3165
  `SELECT * FROM endpoints WHERE kind = 'agent' AND connections = 0 AND hook_seen IS NOT NULL AND process IS NOT NULL`,
3051
3166
  )) {
3052
3167
  const pid = Number(/^pid:(\d+)/.exec(row.process!)?.[1])
3053
3168
  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)
3169
+ this.sql.all(
3170
+ `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`,
3171
+ now,
3172
+ row.id,
3173
+ )
3055
3174
  }
3175
+ for (const row of this.sql.all<EndpointRow>(
3176
+ `SELECT * FROM endpoints WHERE wake IS NOT NULL AND wakers = 0 AND waker_left < ?`,
3177
+ now - LIMITS.restartGraceS * 1000,
3178
+ ))
3179
+ if (this.localRow(row)) this.lapseWake(row.id, now)
3056
3180
  }
3057
3181
 
3058
3182
  /** an agent endpoint left offline drops a working or waiting status, which no one is left to keep current */
@@ -3109,7 +3233,8 @@ export class RelayCore {
3109
3233
  if (params.kind === "session_end") {
3110
3234
  if (own)
3111
3235
  this.sql.all(
3112
- `UPDATE endpoints SET hook_seen = NULL, hook_ended = ?, session = CASE WHEN connections = 0 THEN NULL ELSE session END WHERE id = ?`,
3236
+ `UPDATE endpoints SET hook_seen = NULL, hook_ended = ?, session = CASE WHEN connections = 0 THEN NULL ELSE session END,
3237
+ wake = CASE WHEN wakers = 0 THEN NULL ELSE wake END WHERE id = ?`,
3113
3238
  now,
3114
3239
  endpoint,
3115
3240
  )
@@ -3821,7 +3946,8 @@ export class RelayCore {
3821
3946
  }
3822
3947
  const note = [`exited ${p.code}`, spender.status_note].filter(Boolean).join("; ").slice(0, LIMITS.noteChars)
3823
3948
  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 = ?`,
3949
+ `UPDATE endpoints SET status_state = 'done', status_note = ?, hook_seen = NULL, hook_ended = ?, session = CASE WHEN connections = 0 THEN NULL ELSE session END,
3950
+ wake = CASE WHEN wakers = 0 THEN NULL ELSE wake END WHERE id = ?`,
3825
3951
  note,
3826
3952
  now,
3827
3953
  spender.id,
@@ -4310,6 +4436,7 @@ export class RelayCore {
4310
4436
  return scopeDecision(world, world.node(a.id) ?? this.scopeNode(a), world.node(b.id) ?? this.scopeNode(b), {
4311
4437
  humanAlways: this.defaults().humanAlways,
4312
4438
  solicited,
4439
+ linked: a.id !== b.id && this.linkedAcross(a, b),
4313
4440
  unverified,
4314
4441
  strict,
4315
4442
  })
@@ -5139,7 +5266,8 @@ export class RelayCore {
5139
5266
 
5140
5267
  private placeView(row: EndpointRow, shown?: Set<string>): Pick<EndpointView, "parent" | "depth" | "scope"> {
5141
5268
  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) }
5269
+ const across = !parent && row.parent_ep === null && row.parent_addr ? this.linkedParent(row, shown) : null
5270
+ return { parent: parent ? this.address(parent) : across, depth: row.depth ?? 0, scope: presetOf(this.scopeNode(row), this.defaults().symmetric) }
5143
5271
  }
5144
5272
 
5145
5273
  /** `recipient` names the delivery's endpoint when there is no `seq`: a send to several addresses shows each recipient only itself */
@@ -5177,6 +5305,7 @@ export class RelayCore {
5177
5305
  created_at: iso(row.created_at)!,
5178
5306
  expires_at: iso(row.expires_at)!,
5179
5307
  ...(row.require_reply === 1 ? { require_reply: true as const } : {}),
5308
+ ...(row.link_parent ? { link: { parent: row.link_parent } } : {}),
5180
5309
  ...kin,
5181
5310
  }
5182
5311
  }
package/src/protocol.ts CHANGED
@@ -112,8 +112,8 @@ export type DataClass = "public" | "private"
112
112
  export type Presence = "online" | "idle" | "offline"
113
113
  export type StatusState = "working" | "waiting" | "blocked" | "done"
114
114
  /** how an idle agent is woken by a message: a channel push, the pi extension, the OpenCode plugin's waiter or codex app-server; `none` reads at its next turn */
115
- export type Wake = "channels" | "pi" | "opencode-plugin" | "codex-app-server" | "none"
116
- export const WAKES: readonly Wake[] = ["channels", "pi", "opencode-plugin", "codex-app-server", "none"]
115
+ export type Wake = "channels" | "pi" | "opencode-plugin" | "codex-app-server" | "claude-inbox" | "none"
116
+ export const WAKES: readonly Wake[] = ["channels", "pi", "opencode-plugin", "codex-app-server", "claude-inbox", "none"]
117
117
 
118
118
  export type ActivityKind = "session_start" | "tool" | "stop" | "note" | "session_end"
119
119
  export const ACTIVITY_KINDS: readonly ActivityKind[] = ["session_start", "tool", "stop", "note", "session_end"]
@@ -358,6 +358,8 @@ export interface Envelope {
358
358
  expires_at: string
359
359
  /** a request whose sender holds the recipient's stop until it replies */
360
360
  require_reply?: true
361
+ /** a broker-issued request to link under its sender */
362
+ link?: { parent: string }
361
363
  /** set by inbox on a message a background hook already showed (and marked read), whose text may reach the agent only after this call */
362
364
  shown_at?: string
363
365
  /**
@@ -473,6 +475,13 @@ export interface SendResult {
473
475
  redacted?: JudgeRedacted
474
476
  }
475
477
 
478
+ export interface AdoptResult {
479
+ endpoint: EndpointView
480
+ parent: string
481
+ requested?: string
482
+ held?: JudgeHeld
483
+ }
484
+
476
485
  /**
477
486
  * a message the caller sent and, per recipient, when it read the message (acked), or when a background hook delivered it (acked by that
478
487
  * showing; the agent gets it with its next request), and when a hook or channel showed it: ISO times, or null
@@ -493,4 +502,4 @@ export interface AuditRow {
493
502
  }
494
503
 
495
504
  /** fields only the server sets: a client that sends them gets invalid_params */
496
- export const SERVER_FIELDS = ["from", "id", "seq", "hop", "created_at", "auth", "expires_at", "relation"] as const
505
+ export const SERVER_FIELDS = ["from", "id", "seq", "hop", "created_at", "auth", "expires_at", "relation", "link"] as const
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] }