@scp-dev/core 0.2.0 → 0.3.1

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[];
@@ -439,6 +439,8 @@ interface Claim {
439
439
  session?: string;
440
440
  /** the harness process the claim comes from, `pid:<pid>:<start time>`: a shim restarted in that process may rejoin its endpoint */
441
441
  process?: string;
442
+ /** the harness's own stable session id (Claude Code's): a resumed session in a new harness process takes back the offline endpoint of that id */
443
+ harness_session?: string;
442
444
  /** process keys of the claimant's harness process and its ancestors, nearest first: the nearest endpoint holding one places the claim under it */
443
445
  lineage?: string[];
444
446
  /** a `krt_` grant from `spawn-env`: places the claim at or below its lineage placement, with the grant's caps and groups */
@@ -658,6 +660,10 @@ interface Envelope {
658
660
  expires_at: string;
659
661
  /** a request whose sender holds the recipient's stop until it replies */
660
662
  require_reply?: true;
663
+ /** a broker-issued request to link under its sender */
664
+ link?: {
665
+ parent: string;
666
+ };
661
667
  /** 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
668
  shown_at?: string;
663
669
  /**
@@ -769,6 +775,12 @@ interface SendResult {
769
775
  held?: JudgeHeld;
770
776
  redacted?: JudgeRedacted;
771
777
  }
778
+ interface AdoptResult {
779
+ endpoint: EndpointView;
780
+ parent: string;
781
+ requested?: string;
782
+ held?: JudgeHeld;
783
+ }
772
784
  /**
773
785
  * 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
786
  * showing; the agent gets it with its next request), and when a hook or channel showed it: ISO times, or null
@@ -792,7 +804,7 @@ interface AuditRow {
792
804
  detail: Record<string, unknown>;
793
805
  }
794
806
  /** 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"];
807
+ declare const SERVER_FIELDS: readonly ["from", "id", "seq", "hop", "created_at", "auth", "expires_at", "relation", "link"];
796
808
 
797
809
  /**
798
810
  * `*`, `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`,
@@ -1133,7 +1145,11 @@ interface MessageRow {
1133
1145
  expires_at: number;
1134
1146
  sender_tty: number | null;
1135
1147
  require_reply: number | null;
1148
+ link_parent: string | null;
1136
1149
  }
1150
+ type AdoptSend = (from: string, params: SendParams, origin: {
1151
+ link: "session" | "self";
1152
+ }) => Promise<SendResult>;
1137
1153
  interface CoreOptions {
1138
1154
  /** the local user's address part; `me` until a login names one */
1139
1155
  user?: string;
@@ -1226,6 +1242,12 @@ declare class RelayCore {
1226
1242
  */
1227
1243
  private boundGrant;
1228
1244
  private claimRow;
1245
+ /**
1246
+ * the offline local row a claim of harness session `harnessSession` from a new harness process takes back, as a restart of that session:
1247
+ * of a fitting harness, no subagent's, and restarted where it sits or redeemed with its grant. A claim with a grant that row did not spend
1248
+ * is a new spawn and takes none. A named row first, then the latest seen
1249
+ */
1250
+ private resumable;
1229
1251
  /** a session's hook endpoint left for `to`: its unread mail moves there and it lets go of the session; the moved seqs and its stop budget */
1230
1252
  private leaveHooks;
1231
1253
  /**
@@ -1384,16 +1406,23 @@ declare class RelayCore {
1384
1406
  * when given, renames it in place ({@link rename}'s rules). Nothing is kept when the rename is refused
1385
1407
  */
1386
1408
  joinSession(claim: Claim, handle?: unknown, under?: unknown): EndpointView;
1387
- /**
1388
- * 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.
1389
- * Only the human at a terminal links another session, or an agent from its own shell with a terminal attached, under itself by default.
1390
- * A session links itself with {@link joinSession}'s `under`
1391
- */
1409
+ /** the human links a local root directly; an agent asks it to consent through the normal send path */
1392
1410
  adopt(actor: ScopeActor, params: {
1393
1411
  endpoint: unknown;
1394
1412
  parent?: unknown;
1395
1413
  }, options?: {
1396
1414
  tty?: boolean;
1415
+ }): AdoptResult;
1416
+ adopt(actor: ScopeActor, params: {
1417
+ endpoint: unknown;
1418
+ parent?: unknown;
1419
+ }, options: {
1420
+ tty?: boolean;
1421
+ send: AdoptSend;
1422
+ }): AdoptResult | Promise<AdoptResult>;
1423
+ /** accepts a broker-issued request delivered to the caller's own session */
1424
+ linkAccept(actor: ScopeActor, params: {
1425
+ id: unknown;
1397
1426
  }): {
1398
1427
  endpoint: EndpointView;
1399
1428
  parent: string;
@@ -1426,18 +1455,29 @@ declare class RelayCore {
1426
1455
  protected linkedParent(_row: EndpointRow, _shown?: Set<string>): string | null;
1427
1456
  /**
1428
1457
  * renames an agent in place: its id, mail, threads, status, title and relations stay. An agent renames only itself, and only a root unless
1429
- * the policy's `defaults.rename` is `self`; the human at a terminal renames any local agent. The new handle is valid and held by no other endpoint
1458
+ * 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,
1459
+ * except an offline one {@link renameRow} takes over
1430
1460
  */
1431
1461
  rename(actor: ScopeActor, params: {
1432
1462
  endpoint?: unknown;
1433
1463
  handle: unknown;
1464
+ take_over?: unknown;
1434
1465
  }): {
1435
1466
  endpoint: EndpointView;
1436
1467
  from: string;
1437
1468
  session: string | null;
1469
+ retired?: string;
1438
1470
  };
1439
- /** the endpoint's handle set to `value`, with the stored `ep:` and `fleet:` selectors that named it */
1471
+ /**
1472
+ * the endpoint's handle set to `value`, with the stored `ep:` and `fleet:` selectors that named it. A local offline agent holding the handle
1473
+ * is taken over when it held the same harness session, or with `takeOver` by the human at a terminal: the id of the row retired
1474
+ */
1440
1475
  private renameRow;
1476
+ /**
1477
+ * `row` takes over `held`, an offline local agent, and retires it: `held`'s subtree, unread mail, grants, groups and harness session move to
1478
+ * `row`, which takes `held`'s hand-made link when it is an unlinked root itself. Its handle is then free for `row`
1479
+ */
1480
+ private takeOver;
1441
1481
  /** a local agent's handle moved from `from` to `to`: a subclass updates what names it on another machine's rows */
1442
1482
  protected renamedHandle(_from: string, _to: string): void;
1443
1483
  /**
@@ -1459,6 +1499,7 @@ declare class RelayCore {
1459
1499
  send(from: string, params: SendParams, origin?: {
1460
1500
  tty?: boolean;
1461
1501
  unverified?: boolean;
1502
+ link?: "session" | "self";
1462
1503
  }): SendResult;
1463
1504
  /** a local agent recipient's {@link Reach} for a send from `sender`; none for a human, the relay or another machine's row */
1464
1505
  private reach;
@@ -2534,4 +2575,4 @@ declare function sha256Hex(text: string): string;
2534
2575
  /** the UTF-8 byte length of text, the unit every byte limit counts in */
2535
2576
  declare function utf8Bytes(text: string): number;
2536
2577
 
2537
- 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 };
2578
+ 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)?-----";
@@ -1327,6 +1327,7 @@ var RelayCore = class {
1327
1327
  const messageColumns = this.sql.all(`PRAGMA table_info(messages)`).map((c) => c.name);
1328
1328
  if (!messageColumns.includes("sender_tty")) this.sql.all(`ALTER TABLE messages ADD COLUMN sender_tty INTEGER`);
1329
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`);
1330
1331
  const endpointColumns = this.sql.all(`PRAGMA table_info(endpoints)`).map((c) => c.name);
1331
1332
  if (!endpointColumns.includes("session")) this.sql.all(`ALTER TABLE endpoints ADD COLUMN session TEXT`);
1332
1333
  if (!endpointColumns.includes("hook_seen")) this.sql.all(`ALTER TABLE endpoints ADD COLUMN hook_seen INTEGER`);
@@ -1520,10 +1521,13 @@ var RelayCore = class {
1520
1521
  parent: cleanText(claim.parent, "parent", 128),
1521
1522
  labels: JSON.stringify(this.machineLabel(cleanLabels(claim.labels))),
1522
1523
  session: cleanText(claim.session, "session", 128),
1523
- process: cleanText(claim.process, "process", 128)
1524
+ process: cleanText(claim.process, "process", 128),
1525
+ harness_session: cleanText(claim.harness_session, "harness_session", 128)
1524
1526
  };
1525
1527
  if (fields.process !== null && !ANY_PROCESS_KEY.test(fields.process)) throw new RelayError("invalid_params", "process is a key pid:<pid>[:<start time>]");
1526
- const leaked = [wanted, fields.model, fields.cwd, fields.parent, fields.labels, fields.session].map((v) => v ? findSecret(v) : null).find(Boolean);
1528
+ if (fields.harness_session !== null && !HARNESS_SESSION.test(fields.harness_session))
1529
+ throw new RelayError("invalid_params", "harness_session is a harness's session id: letters, digits, . _ : -");
1530
+ const leaked = [wanted, fields.model, fields.cwd, fields.parent, fields.labels, fields.session, fields.harness_session].map((v) => v ? findSecret(v) : null).find(Boolean);
1527
1531
  if (leaked) throw new RelayError("secret_refused", `the claim looks like it carries a credential (${leaked})`);
1528
1532
  if (claim.grant !== void 0 && (typeof claim.grant !== "string" || !GRANT_TOKEN.test(claim.grant)))
1529
1533
  throw new RelayError("invalid_params", "grant is a krt_ token");
@@ -1635,6 +1639,7 @@ var RelayCore = class {
1635
1639
  let stopBlocks = null;
1636
1640
  let moved = [];
1637
1641
  let exactFrom;
1642
+ const open = !row || presenceOf(row, now) === "offline";
1638
1643
  if (row && presenceOf(row, now) === "offline") {
1639
1644
  this.sql.all(`UPDATE endpoints SET session = NULL WHERE id = ?`, row.id);
1640
1645
  row = void 0;
@@ -1668,7 +1673,9 @@ var RelayCore = class {
1668
1673
  exactFrom = hooks;
1669
1674
  }
1670
1675
  } else if (row && transient) session = row.session;
1671
- let joined = row !== void 0;
1676
+ const resumed = !row && open && !scope.exact && !scope.subagent && fields.harness_session ? this.resumable(harness, fields.harness_session, scope, fields, now) : void 0;
1677
+ if (resumed) row = resumed;
1678
+ let joined = row !== void 0 && !resumed;
1672
1679
  let own = false;
1673
1680
  let live = false;
1674
1681
  let created = false;
@@ -1698,7 +1705,7 @@ var RelayCore = class {
1698
1705
  if (!row)
1699
1706
  throw scope.exact ? this.exactRefusal(this.byHandle(wanted), wanted, harness, now) : new RelayError("invalid_params", `no free handle for ${wanted}`);
1700
1707
  if (exactFrom && exactFrom.id !== row.id) ({ moved, stopBlocks } = this.leaveHooks(exactFrom, row.id));
1701
- const fresh = created || joined ? null : this.incarnates(row, scope, fields.process, now);
1708
+ const fresh = created || joined || resumed ? null : this.incarnates(row, scope, fields.process, now);
1702
1709
  const token = transient || live ? void 0 : `kre_${randomHex(32)}`;
1703
1710
  const field = (column) => transient ? `COALESCE(?, ${column})` : "?";
1704
1711
  if (session) this.sql.all(`UPDATE endpoints SET session = NULL WHERE session = ? AND harness = ? AND id != ?`, session, harness, row.id);
@@ -1738,7 +1745,14 @@ var RelayCore = class {
1738
1745
  if (!transient && fields.cwd !== null) this.sql.all(`UPDATE grants SET spent_cwd = ? WHERE used_by = ?`, fields.cwd, row.id);
1739
1746
  if (!(transient && joined)) {
1740
1747
  const sub = scope.subagent ? { subagent: scope.subagent.type ?? "agent", parent: this.addressOf(scope.subagent.root) } : {};
1741
- this.audit("claim", row.id, null, { handle: row.handle, harness, ...joined ? { joined: true } : {}, ...transient ? { transient: true } : {}, ...sub });
1748
+ this.audit("claim", row.id, null, {
1749
+ handle: row.handle,
1750
+ harness,
1751
+ ...joined ? { joined: true } : {},
1752
+ ...resumed ? { resumed: true } : {},
1753
+ ...transient ? { transient: true } : {},
1754
+ ...sub
1755
+ });
1742
1756
  }
1743
1757
  if (fresh) this.reset(row, scope, fresh, moved, now);
1744
1758
  if (created || fresh === "grant") this.place(this.byId(row.id), scope, now);
@@ -1746,6 +1760,23 @@ var RelayCore = class {
1746
1760
  if (scope.grant !== null) this.takePending(row.id);
1747
1761
  return { endpoint: this.view(this.byId(row.id)), ...token ? { token } : {}, ...created ? { created: true } : {} };
1748
1762
  }
1763
+ /**
1764
+ * the offline local row a claim of harness session `harnessSession` from a new harness process takes back, as a restart of that session:
1765
+ * of a fitting harness, no subagent's, and restarted where it sits or redeemed with its grant. A claim with a grant that row did not spend
1766
+ * is a new spawn and takes none. A named row first, then the latest seen
1767
+ */
1768
+ resumable(harness, harnessSession, scope, fields, now) {
1769
+ const rows = this.sql.all(
1770
+ `SELECT * FROM endpoints WHERE kind = 'agent' AND harness_session = ? AND connections = 0 AND (session IS NULL OR substr(session, 1, 4) != 'sub:')
1771
+ AND substr(handle, 1, ${GONE_PREFIX.length}) != '${GONE_PREFIX}' ORDER BY named IS NULL, COALESCE(last_seen, created_at) DESC`,
1772
+ harnessSession
1773
+ );
1774
+ const parent = rows.length ? this.placement(scope, now).parent : null;
1775
+ const spender = scope.grant === null ? null : this.spentGrant(scope)?.used_by ?? "";
1776
+ return rows.find(
1777
+ (row) => (spender === null || spender === row.id) && this.localRow(row) && harnessFits(row.harness, harness) && presenceOf(row, now) === "offline" && (this.restarts(row, parent, fields.process) || this.redeems(row, scope, { harness, ...fields }))
1778
+ );
1779
+ }
1749
1780
  /** a session's hook endpoint left for `to`: its unread mail moves there and it lets go of the session; the moved seqs and its stop budget */
1750
1781
  leaveHooks(hooks, to) {
1751
1782
  const moved = this.sql.all(`UPDATE deliveries SET endpoint = ? WHERE endpoint = ? AND acked_at IS NULL RETURNING seq`, to, hooks.id).map((d) => d.seq);
@@ -2278,30 +2309,52 @@ var RelayCore = class {
2278
2309
  return this.whoami(row.id);
2279
2310
  });
2280
2311
  }
2281
- /**
2282
- * 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.
2283
- * Only the human at a terminal links another session, or an agent from its own shell with a terminal attached, under itself by default.
2284
- * A session links itself with {@link joinSession}'s `under`
2285
- */
2286
2312
  adopt(actor, params, options = {}) {
2287
- return this.scopeTx(() => {
2313
+ const prepared = this.scopeTx(() => {
2288
2314
  const me = this.actorRow(actor);
2289
2315
  if (actor.kind === "human") this.needTerminal(actor, me, params.endpoint);
2290
- else if (options.tty !== true)
2291
- throw this.scopeRefused(
2292
- actor,
2293
- me,
2294
- { target: String(params.endpoint), reason: "adopt" },
2295
- `only the human at a terminal links another session under a parent; the session links itself with ${CLI_NAME} join --under`
2296
- );
2316
+ else if (actor.auth === "lineage")
2317
+ throw this.scopeRefused(actor, me, { target: String(params.endpoint), reason: "adopt" }, "only the session itself may request a link");
2297
2318
  const target = this.resolve(params.endpoint);
2298
2319
  if (target.kind !== "agent" || !this.localRow(target)) throw new RelayError("invalid_params", "only a local agent endpoint can be linked");
2299
2320
  const parentAddress = params.parent ?? (actor.kind === "human" ? void 0 : this.address(me));
2300
2321
  if (parentAddress === void 0 || parentAddress === null || parentAddress === "")
2301
2322
  throw new RelayError("invalid_params", "the human names the parent to link under");
2302
2323
  const parent = this.resolve(parentAddress);
2303
- this.link(target, parent, this.actorDetail(actor, me));
2304
- return { endpoint: this.whoami(target.id), parent: this.address(parent) };
2324
+ if (actor.kind !== "human" && parent.id !== me.id)
2325
+ throw this.scopeRefused(actor, me, { target: this.address(target), reason: "adopt" }, "an agent may request a link only under itself");
2326
+ const result2 = { endpoint: this.whoami(target.id), parent: this.address(parent) };
2327
+ if (actor.kind === "human" || options.tty === true) {
2328
+ this.link(target, parent, this.actorDetail(actor, me));
2329
+ return { result: { ...result2, endpoint: this.whoami(target.id) } };
2330
+ }
2331
+ if (target.parent_ep !== null || target.parent_addr !== null) throw this.alreadyLinked(target);
2332
+ const auth2 = actor.auth === "self" ? "self" : "session";
2333
+ return { result: result2, sender: { from: me.id, auth: auth2 } };
2334
+ });
2335
+ if (!prepared.sender) return prepared.result;
2336
+ const { result } = prepared;
2337
+ const { from, auth } = prepared.sender;
2338
+ const send = {
2339
+ to: result.endpoint.address,
2340
+ kind: "request",
2341
+ ttl_s: 86400,
2342
+ 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}"}).`
2343
+ };
2344
+ const requested = (sent) => ({ ...result, ...sent.held ? { held: sent.held } : { requested: sent.id } });
2345
+ return options.send ? options.send(from, send, { link: auth }).then(requested) : requested(this.send(from, send, { link: auth }));
2346
+ }
2347
+ /** accepts a broker-issued request delivered to the caller's own session */
2348
+ linkAccept(actor, params) {
2349
+ return this.scopeTx(() => {
2350
+ if (actor.kind !== "endpoint" || actor.auth === "lineage") throw new RelayError("scope_refused", "only the session itself accepts a link request");
2351
+ const me = this.actorRow(actor);
2352
+ const request = this.delivered(me.id, params.id);
2353
+ if (!request?.link || request.kind !== "request") throw new RelayError("not_found", "no link request delivered to this endpoint");
2354
+ const parent = this.mustEndpoint(request.from.endpoint);
2355
+ if (parent.kind !== "agent") throw new RelayError("invalid_params", "the requesting parent is no longer an agent");
2356
+ this.link(me, parent, { ...this.actorDetail(actor, me), request: request.id });
2357
+ return { endpoint: this.whoami(me.id), parent: this.address(parent) };
2305
2358
  });
2306
2359
  }
2307
2360
  /**
@@ -2368,7 +2421,7 @@ var RelayCore = class {
2368
2421
  this.sql.all(`UPDATE endpoints SET parent_addr = ?, linked = ? WHERE id = ?`, key, this.now(), row.id);
2369
2422
  this.scopeMemo?.ancestors.clear();
2370
2423
  this.scopeMemo?.limited.clear();
2371
- this.audit("adopt", row.id, null, { ...by, target: this.address(row), parent: this.address(parent) });
2424
+ this.audit("adopt", row.id, by.request ?? null, { ...by, target: this.address(row), parent: this.address(parent) });
2372
2425
  return;
2373
2426
  }
2374
2427
  if (parent.kind !== "agent") throw new RelayError("invalid_params", "the parent is an agent endpoint");
@@ -2397,7 +2450,7 @@ var RelayCore = class {
2397
2450
  );
2398
2451
  this.scopeMemo?.ancestors.clear();
2399
2452
  this.scopeMemo?.limited.clear();
2400
- this.audit("adopt", row.id, null, { ...by, target: this.address(row), parent: this.address(parent) });
2453
+ this.audit("adopt", row.id, by.request ?? null, { ...by, target: this.address(row), parent: this.address(parent) });
2401
2454
  }
2402
2455
  /** a link of `row`, which already sits under a parent, to another one */
2403
2456
  alreadyLinked(row) {
@@ -2422,9 +2475,11 @@ var RelayCore = class {
2422
2475
  }
2423
2476
  /**
2424
2477
  * renames an agent in place: its id, mail, threads, status, title and relations stay. An agent renames only itself, and only a root unless
2425
- * the policy's `defaults.rename` is `self`; the human at a terminal renames any local agent. The new handle is valid and held by no other endpoint
2478
+ * 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,
2479
+ * except an offline one {@link renameRow} takes over
2426
2480
  */
2427
2481
  rename(actor, params) {
2482
+ if (params.take_over !== void 0 && typeof params.take_over !== "boolean") throw new RelayError("invalid_params", "take_over is true or false");
2428
2483
  return this.scopeTx(() => {
2429
2484
  const me = this.actorRow(actor);
2430
2485
  if (actor.kind === "human") this.needTerminal(actor, me, params.endpoint);
@@ -2432,12 +2487,15 @@ var RelayCore = class {
2432
2487
  const target = params.endpoint === void 0 ? me : this.resolve(params.endpoint);
2433
2488
  if (actor.kind !== "human" && target.id !== me.id) throw new RelayError("scope_refused", "an agent renames only itself");
2434
2489
  if (target.kind !== "agent" || !this.localRow(target)) throw new RelayError("invalid_params", "only a local agent endpoint can be renamed");
2435
- this.renameRow(target, params.handle, actor.kind === "human");
2436
- return { endpoint: this.whoami(target.id), from: target.handle, session: target.session };
2490
+ const retired = this.renameRow(target, params.handle, actor.kind === "human", params.take_over === true);
2491
+ return { endpoint: this.whoami(target.id), from: target.handle, session: target.session, ...retired ? { retired } : {} };
2437
2492
  });
2438
2493
  }
2439
- /** the endpoint's handle set to `value`, with the stored `ep:` and `fleet:` selectors that named it */
2440
- renameRow(row, value, byHuman) {
2494
+ /**
2495
+ * the endpoint's handle set to `value`, with the stored `ep:` and `fleet:` selectors that named it. A local offline agent holding the handle
2496
+ * is taken over when it held the same harness session, or with `takeOver` by the human at a terminal: the id of the row retired
2497
+ */
2498
+ renameRow(row, value, byHuman, takeOver = false) {
2441
2499
  const handle = typeof value === "string" ? value.trim().replace(/^me\//, "") : "";
2442
2500
  if (!validHandle(handle)) throw new RelayError("invalid_params", `handle ${JSON.stringify(handle)} is not valid: lowercase letters, digits, . _ -`);
2443
2501
  if (findSecret(handle)) throw new RelayError("secret_refused", "the handle looks like it carries a credential");
@@ -2450,14 +2508,23 @@ var RelayCore = class {
2450
2508
  throw new RelayError("scope_refused", `spawned by ${parent ? this.address(parent) : "another agent"}; its name is set by the spawner`);
2451
2509
  }
2452
2510
  const held = this.byHandle(handle);
2511
+ let retired;
2453
2512
  if (held) {
2454
- const how = held.kind !== "agent" ? "reserved" : presenceOf(held, this.now()) === "offline" ? "held by an offline endpoint" : "held by a live endpoint";
2455
- throw new RelayError("invalid_params", `${handle} is ${how}: pick another name`);
2513
+ const offline = held.kind === "agent" && held.connections === 0 && presenceOf(held, this.now()) === "offline" && this.localRow(held);
2514
+ const same = held.harness_session !== null && held.harness_session === row.harness_session;
2515
+ if (takeOver && !byHuman && !same) throw new RelayError("scope_refused", "only the human at a terminal takes over another endpoint");
2516
+ if (takeOver && offline && !same && !harnessFits(held.harness, row.harness ?? "cli"))
2517
+ throw new RelayError("invalid_params", `${handle} is a ${held.harness} endpoint: only an endpoint of the same harness is taken over`);
2518
+ if (!offline || held.session?.startsWith("sub:") || !(same || takeOver && harnessFits(held.harness, row.harness ?? "cli"))) {
2519
+ const how = held.kind !== "agent" ? "reserved" : presenceOf(held, this.now()) === "offline" ? "held by an offline endpoint" : "held by a live endpoint";
2520
+ throw new RelayError("invalid_params", `${handle} is ${how}: pick another name${offline ? ", or take it over with --take-over from a terminal" : ""}`);
2521
+ }
2522
+ retired = this.takeOver(row, held, same ? "session" : "human");
2456
2523
  }
2457
2524
  const from = row.handle;
2458
2525
  this.sql.all(`UPDATE endpoints SET handle = ?, named = ? WHERE id = ?`, handle, this.now(), row.id);
2459
2526
  this.sql.all(`UPDATE grants SET name = ? WHERE used_by = ? AND name = ?`, handle, row.id, from);
2460
- for (const prefix of row.parent_ep === null ? ["ep:", "fleet:"] : ["ep:"]) {
2527
+ for (const prefix of this.byId(row.id).parent_ep === null ? ["ep:", "fleet:"] : ["ep:"]) {
2461
2528
  const [a, b] = [JSON.stringify(`${prefix}${from}`), JSON.stringify(`${prefix}${handle}`)];
2462
2529
  for (const [table, column] of [
2463
2530
  ["endpoints", "rules"],
@@ -2469,6 +2536,51 @@ var RelayCore = class {
2469
2536
  }
2470
2537
  this.renamedHandle(from, handle);
2471
2538
  this.audit("rename", row.id, null, { from, to: handle });
2539
+ return retired;
2540
+ }
2541
+ /**
2542
+ * `row` takes over `held`, an offline local agent, and retires it: `held`'s subtree, unread mail, grants, groups and harness session move to
2543
+ * `row`, which takes `held`'s hand-made link when it is an unlinked root itself. Its handle is then free for `row`
2544
+ */
2545
+ takeOver(row, held, by) {
2546
+ if (this.ancestorsOf(row).includes(held.id))
2547
+ throw new RelayError("scope_refused", `${this.address(row)} sits under ${this.address(held)}: it cannot take over its own ancestor`);
2548
+ if (row.parent_ep === null && row.parent_addr === null && held.linked !== null && held.parent_addr !== null)
2549
+ this.sql.all(`UPDATE endpoints SET parent_addr = ?, linked = ? WHERE id = ?`, held.parent_addr, held.linked, row.id);
2550
+ else if (row.parent_ep === null && row.parent_addr === null && held.linked !== null && held.parent_ep !== null) {
2551
+ const parent = this.byId(held.parent_ep);
2552
+ if (parent) this.link(row, parent, { by: this.address(row), auth: by });
2553
+ }
2554
+ const place = this.byId(row.id);
2555
+ const shift = (place.depth ?? 0) - (held.depth ?? 0);
2556
+ const below = `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)`;
2557
+ const deepest = one(this.sql, `${below} SELECT MAX(depth) AS d FROM endpoints WHERE id IN (SELECT id FROM below)`, held.id)?.d;
2558
+ if (deepest != null && deepest + shift > LIMITS.depthMax)
2559
+ throw new RelayError("scope_refused", `${this.address(held)}'s children would sit deeper than ${LIMITS.depthMax} under ${this.address(place)}`);
2560
+ this.sql.all(`${below} UPDATE endpoints SET tree = ?, depth = depth + ? WHERE id IN (SELECT id FROM below)`, held.id, place.tree ?? place.id, shift);
2561
+ const children = this.sql.all(`UPDATE endpoints SET parent_ep = ? WHERE parent_ep = ? RETURNING id`, row.id, held.id).length;
2562
+ this.sql.all(`UPDATE deliveries SET endpoint = ? WHERE endpoint = ? AND acked_at IS NULL`, row.id, held.id);
2563
+ this.sql.all(`UPDATE grants SET issuer = ? WHERE issuer = ?`, row.id, held.id);
2564
+ this.sql.all(`UPDATE grants SET under = ? WHERE under = ?`, row.id, held.id);
2565
+ this.sql.all(`UPDATE groups SET owner = ? WHERE owner = ?`, row.id, held.id);
2566
+ this.sql.all(
2567
+ `INSERT OR IGNORE INTO group_members (name, endpoint, added_by, at) SELECT name, ?, added_by, at FROM group_members WHERE endpoint = ?`,
2568
+ row.id,
2569
+ held.id
2570
+ );
2571
+ this.sql.all(`DELETE FROM group_members WHERE endpoint = ?`, held.id);
2572
+ this.sql.all(
2573
+ `UPDATE endpoints SET harness_session = ?, harness_transcript = ? WHERE id = ? AND harness_session IS NULL`,
2574
+ held.harness_session,
2575
+ held.harness_transcript,
2576
+ row.id
2577
+ );
2578
+ this.sql.all(`UPDATE endpoints SET handle = ?, session = NULL, token_hash = NULL WHERE id = ?`, `${GONE_PREFIX}${held.id}`, held.id);
2579
+ this.dropPeek(held.id);
2580
+ this.scopeMemo?.ancestors.clear();
2581
+ this.scopeMemo?.limited.clear();
2582
+ this.audit("take_over", row.id, null, { from: row.handle, to: held.handle, retired: held.id, children, by });
2583
+ return held.id;
2472
2584
  }
2473
2585
  /** a local agent's handle moved from `from` to `to`: a subclass updates what names it on another machine's rows */
2474
2586
  renamedHandle(_from, _to) {
@@ -2504,6 +2616,7 @@ var RelayCore = class {
2504
2616
  * sends without a terminal, so the scopes check it like any unplaced sender
2505
2617
  */
2506
2618
  send(from, params, origin = {}) {
2619
+ if ("link" in params) throw new RelayError("invalid_params", "link is set by the relay, not the sender");
2507
2620
  const sender = this.mustEndpoint(from);
2508
2621
  const senderTty = sender.kind === "human" ? origin.tty === true ? 1 : 0 : null;
2509
2622
  const unverified = sender.kind === "human" && origin.unverified === true;
@@ -2565,6 +2678,9 @@ var RelayCore = class {
2565
2678
  const pending = [...unjoined.values()].map((grant) => ({ key: pendingKey(grant.token_hash), address: `${this.user}/${grant.name}` }));
2566
2679
  const reached = recipients.length + pending.length;
2567
2680
  if (reached > LIMITS.fanOutTotal) throw new RelayError("too_large", `the send reaches ${reached} endpoints, more than ${LIMITS.fanOutTotal}`);
2681
+ if (origin.link && (sender.kind !== "agent" || kind !== "request" || recipients.length !== 1 || pending.length || recipients[0].kind !== "agent" || !this.localRow(recipients[0])))
2682
+ throw new RelayError("invalid_params", "a link request is from an agent to one local root agent");
2683
+ if (origin.link && (recipients[0].parent_ep !== null || recipients[0].parent_addr !== null)) throw this.alreadyLinked(recipients[0]);
2568
2684
  const requestedThread = this.threadRef(from, cleanText(params.thread, "thread", 64));
2569
2685
  const payloadHash = this.hash(
2570
2686
  JSON.stringify([
@@ -2577,7 +2693,8 @@ var RelayCore = class {
2577
2693
  attachments.map((a) => a.path).sort(),
2578
2694
  mime,
2579
2695
  ttl,
2580
- params.require_reply === true
2696
+ params.require_reply === true,
2697
+ ...origin.link ? [origin.link] : []
2581
2698
  ])
2582
2699
  );
2583
2700
  if (clientId) {
@@ -2735,8 +2852,8 @@ var RelayCore = class {
2735
2852
  const expires = now + ttl * 1e3;
2736
2853
  const to = targets.length === 1 ? String(targets[0]) : targets.join(",");
2737
2854
  this.sql.all(
2738
- `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)
2739
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
2855
+ `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)
2856
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
2740
2857
  id,
2741
2858
  thread,
2742
2859
  parent?.id ?? null,
@@ -2751,7 +2868,8 @@ var RelayCore = class {
2751
2868
  now,
2752
2869
  expires,
2753
2870
  senderTty,
2754
- params.require_reply === true ? 1 : null
2871
+ params.require_reply === true ? 1 : null,
2872
+ origin.link ? this.address(sender) : null
2755
2873
  );
2756
2874
  if (parent) this.sql.all(`UPDATE deliveries SET acked_at = ? WHERE message_id = ? AND endpoint = ? AND acked_at IS NULL`, now, parent.id, from);
2757
2875
  const deliveries = [];
@@ -2793,6 +2911,13 @@ var RelayCore = class {
2793
2911
  );
2794
2912
  }
2795
2913
  this.audit("send", from, id, { to: deliveries.map((d) => d.address), kind, bytes, hop, thread, data_class: dataClass });
2914
+ if (origin.link)
2915
+ this.audit("adopt_request", from, id, {
2916
+ by: this.address(sender),
2917
+ auth: origin.link,
2918
+ target: this.address(recipients[0]),
2919
+ parent: this.address(sender)
2920
+ });
2796
2921
  for (const recipient of recipients) this.detectPingPong(sender, recipient);
2797
2922
  return sent;
2798
2923
  });
@@ -5538,6 +5663,7 @@ ${stderr}` : "";
5538
5663
  created_at: iso(row.created_at),
5539
5664
  expires_at: iso(row.expires_at),
5540
5665
  ...row.require_reply === 1 ? { require_reply: true } : {},
5666
+ ...row.link_parent ? { link: { parent: row.link_parent } } : {},
5541
5667
  ...kin
5542
5668
  };
5543
5669
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scp-dev/core",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
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,
@@ -291,8 +292,11 @@ export interface MessageRow {
291
292
  expires_at: number
292
293
  sender_tty: number | null
293
294
  require_reply: number | null
295
+ link_parent: string | null
294
296
  }
295
297
 
298
+ type AdoptSend = (from: string, params: SendParams, origin: { link: "session" | "self" }) => Promise<SendResult>
299
+
296
300
  export interface CoreOptions {
297
301
  /** the local user's address part; `me` until a login names one */
298
302
  user?: string
@@ -523,6 +527,7 @@ export class RelayCore {
523
527
  const messageColumns = this.sql.all<{ name: string }>(`PRAGMA table_info(messages)`).map((c) => c.name)
524
528
  if (!messageColumns.includes("sender_tty")) this.sql.all(`ALTER TABLE messages ADD COLUMN sender_tty INTEGER`)
525
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`)
526
531
  const endpointColumns = this.sql.all<{ name: string }>(`PRAGMA table_info(endpoints)`).map((c) => c.name)
527
532
  if (!endpointColumns.includes("session")) this.sql.all(`ALTER TABLE endpoints ADD COLUMN session TEXT`)
528
533
  if (!endpointColumns.includes("hook_seen")) this.sql.all(`ALTER TABLE endpoints ADD COLUMN hook_seen INTEGER`)
@@ -723,9 +728,14 @@ export class RelayCore {
723
728
  labels: JSON.stringify(this.machineLabel(cleanLabels(claim.labels))),
724
729
  session: cleanText(claim.session, "session", 128),
725
730
  process: cleanText(claim.process, "process", 128),
731
+ harness_session: cleanText(claim.harness_session, "harness_session", 128),
726
732
  }
727
733
  if (fields.process !== null && !ANY_PROCESS_KEY.test(fields.process)) throw new RelayError("invalid_params", "process is a key pid:<pid>[:<start time>]")
728
- const leaked = [wanted, fields.model, fields.cwd, fields.parent, fields.labels, fields.session].map((v) => (v ? findSecret(v) : null)).find(Boolean)
734
+ if (fields.harness_session !== null && !HARNESS_SESSION.test(fields.harness_session))
735
+ throw new RelayError("invalid_params", "harness_session is a harness's session id: letters, digits, . _ : -")
736
+ const leaked = [wanted, fields.model, fields.cwd, fields.parent, fields.labels, fields.session, fields.harness_session]
737
+ .map((v) => (v ? findSecret(v) : null))
738
+ .find(Boolean)
729
739
  if (leaked) throw new RelayError("secret_refused", `the claim looks like it carries a credential (${leaked})`)
730
740
  if (claim.grant !== undefined && (typeof claim.grant !== "string" || !GRANT_TOKEN.test(claim.grant)))
731
741
  throw new RelayError("invalid_params", "grant is a krt_ token")
@@ -829,7 +839,15 @@ export class RelayCore {
829
839
  private claimRow(
830
840
  wanted: string,
831
841
  harness: string,
832
- fields: { model: string | null; cwd: string | null; parent: string | null; labels: string; session: string | null; process: string | null },
842
+ fields: {
843
+ model: string | null
844
+ cwd: string | null
845
+ parent: string | null
846
+ labels: string
847
+ session: string | null
848
+ process: string | null
849
+ harness_session?: string | null
850
+ },
833
851
  transient: boolean,
834
852
  scope: ClaimScope = { handle: wanted, lineage: [], grant: null, rules: {}, groups: [], cwd: fields.cwd },
835
853
  ): { endpoint: EndpointView; token?: string; created?: true } {
@@ -854,6 +872,7 @@ export class RelayCore {
854
872
  let stopBlocks: number | null = null
855
873
  let moved: number[] = []
856
874
  let exactFrom: EndpointRow | undefined
875
+ const open = !row || presenceOf(row, now) === "offline"
857
876
  if (row && presenceOf(row, now) === "offline") {
858
877
  this.sql.all(`UPDATE endpoints SET session = NULL WHERE id = ?`, row.id)
859
878
  row = undefined
@@ -893,7 +912,12 @@ export class RelayCore {
893
912
  exactFrom = hooks
894
913
  }
895
914
  } else if (row && transient) session = row.session
896
- let joined = row !== undefined
915
+ const resumed =
916
+ !row && open && !scope.exact && !scope.subagent && fields.harness_session
917
+ ? this.resumable(harness, fields.harness_session, scope, fields, now)
918
+ : undefined
919
+ if (resumed) row = resumed
920
+ let joined = row !== undefined && !resumed
897
921
  let own = false
898
922
  let live = false
899
923
  let created = false
@@ -939,7 +963,7 @@ export class RelayCore {
939
963
  if (!row)
940
964
  throw scope.exact ? this.exactRefusal(this.byHandle(wanted)!, wanted, harness, now) : new RelayError("invalid_params", `no free handle for ${wanted}`)
941
965
  if (exactFrom && exactFrom.id !== row.id) ({ moved, stopBlocks } = this.leaveHooks(exactFrom, row.id))
942
- const fresh = created || joined ? null : this.incarnates(row, scope, fields.process, now)
966
+ const fresh = created || joined || resumed ? null : this.incarnates(row, scope, fields.process, now)
943
967
  const token = transient || live ? undefined : `kre_${randomHex(32)}`
944
968
  const field = (column: string) => (transient ? `COALESCE(?, ${column})` : "?")
945
969
  if (session) this.sql.all(`UPDATE endpoints SET session = NULL WHERE session = ? AND harness = ? AND id != ?`, session, harness, row.id)
@@ -979,7 +1003,14 @@ export class RelayCore {
979
1003
  if (!transient && fields.cwd !== null) this.sql.all(`UPDATE grants SET spent_cwd = ? WHERE used_by = ?`, fields.cwd, row.id)
980
1004
  if (!(transient && joined)) {
981
1005
  const sub = scope.subagent ? { subagent: scope.subagent.type ?? "agent", parent: this.addressOf(scope.subagent.root) } : {}
982
- this.audit("claim", row.id, null, { handle: row.handle, harness, ...(joined ? { joined: true } : {}), ...(transient ? { transient: true } : {}), ...sub })
1006
+ this.audit("claim", row.id, null, {
1007
+ handle: row.handle,
1008
+ harness,
1009
+ ...(joined ? { joined: true } : {}),
1010
+ ...(resumed ? { resumed: true } : {}),
1011
+ ...(transient ? { transient: true } : {}),
1012
+ ...sub,
1013
+ })
983
1014
  }
984
1015
  if (fresh) this.reset(row, scope, fresh, moved, now)
985
1016
  if (created || fresh === "grant") this.place(this.byId(row.id)!, scope, now)
@@ -988,6 +1019,35 @@ export class RelayCore {
988
1019
  return { endpoint: this.view(this.byId(row.id)!), ...(token ? { token } : {}), ...(created ? { created: true as const } : {}) }
989
1020
  }
990
1021
 
1022
+ /**
1023
+ * the offline local row a claim of harness session `harnessSession` from a new harness process takes back, as a restart of that session:
1024
+ * of a fitting harness, no subagent's, and restarted where it sits or redeemed with its grant. A claim with a grant that row did not spend
1025
+ * is a new spawn and takes none. A named row first, then the latest seen
1026
+ */
1027
+ private resumable(
1028
+ harness: string,
1029
+ harnessSession: string,
1030
+ scope: ClaimScope,
1031
+ fields: { cwd: string | null; process: string | null },
1032
+ now: number,
1033
+ ): EndpointRow | undefined {
1034
+ const rows = this.sql.all<EndpointRow>(
1035
+ `SELECT * FROM endpoints WHERE kind = 'agent' AND harness_session = ? AND connections = 0 AND (session IS NULL OR substr(session, 1, 4) != 'sub:')
1036
+ AND substr(handle, 1, ${GONE_PREFIX.length}) != '${GONE_PREFIX}' ORDER BY named IS NULL, COALESCE(last_seen, created_at) DESC`,
1037
+ harnessSession,
1038
+ )
1039
+ const parent = rows.length ? this.placement(scope, now).parent : null
1040
+ const spender = scope.grant === null ? null : (this.spentGrant(scope)?.used_by ?? "")
1041
+ return rows.find(
1042
+ (row) =>
1043
+ (spender === null || spender === row.id) &&
1044
+ this.localRow(row) &&
1045
+ harnessFits(row.harness, harness) &&
1046
+ presenceOf(row, now) === "offline" &&
1047
+ (this.restarts(row, parent, fields.process) || this.redeems(row, scope, { harness, ...fields })),
1048
+ )
1049
+ }
1050
+
991
1051
  /** a session's hook endpoint left for `to`: its unread mail moves there and it lets go of the session; the moved seqs and its stop budget */
992
1052
  private leaveHooks(hooks: EndpointRow, to: string): { moved: number[]; stopBlocks: number } {
993
1053
  const moved = this.sql
@@ -1592,30 +1652,60 @@ export class RelayCore {
1592
1652
  })
1593
1653
  }
1594
1654
 
1595
- /**
1596
- * 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.
1597
- * Only the human at a terminal links another session, or an agent from its own shell with a terminal attached, under itself by default.
1598
- * A session links itself with {@link joinSession}'s `under`
1599
- */
1600
- adopt(actor: ScopeActor, params: { endpoint: unknown; parent?: unknown }, options: { tty?: boolean } = {}): { endpoint: EndpointView; parent: string } {
1601
- return this.scopeTx(() => {
1655
+ /** the human links a local root directly; an agent asks it to consent through the normal send path */
1656
+ adopt(actor: ScopeActor, params: { endpoint: unknown; parent?: unknown }, options?: { tty?: boolean }): AdoptResult
1657
+ adopt(actor: ScopeActor, params: { endpoint: unknown; parent?: unknown }, options: { tty?: boolean; send: AdoptSend }): AdoptResult | Promise<AdoptResult>
1658
+ adopt(
1659
+ actor: ScopeActor,
1660
+ params: { endpoint: unknown; parent?: unknown },
1661
+ options: { tty?: boolean; send?: AdoptSend } = {},
1662
+ ): AdoptResult | Promise<AdoptResult> {
1663
+ const prepared: { result: AdoptResult; sender?: { from: string; auth: "session" | "self" } } = this.scopeTx(() => {
1602
1664
  const me = this.actorRow(actor)
1603
1665
  if (actor.kind === "human") this.needTerminal(actor, me, params.endpoint)
1604
- else if (options.tty !== true)
1605
- throw this.scopeRefused(
1606
- actor,
1607
- me,
1608
- { target: String(params.endpoint), reason: "adopt" },
1609
- `only the human at a terminal links another session under a parent; the session links itself with ${CLI_NAME} join --under`,
1610
- )
1666
+ else if (actor.auth === "lineage")
1667
+ throw this.scopeRefused(actor, me, { target: String(params.endpoint), reason: "adopt" }, "only the session itself may request a link")
1611
1668
  const target = this.resolve(params.endpoint)
1612
1669
  if (target.kind !== "agent" || !this.localRow(target)) throw new RelayError("invalid_params", "only a local agent endpoint can be linked")
1613
1670
  const parentAddress = params.parent ?? (actor.kind === "human" ? undefined : this.address(me))
1614
1671
  if (parentAddress === undefined || parentAddress === null || parentAddress === "")
1615
1672
  throw new RelayError("invalid_params", "the human names the parent to link under")
1616
1673
  const parent = this.resolve(parentAddress)
1617
- this.link(target, parent, this.actorDetail(actor, me))
1618
- return { endpoint: this.whoami(target.id), parent: this.address(parent) }
1674
+ if (actor.kind !== "human" && parent.id !== me.id)
1675
+ throw this.scopeRefused(actor, me, { target: this.address(target), reason: "adopt" }, "an agent may request a link only under itself")
1676
+ const result: AdoptResult = { endpoint: this.whoami(target.id), parent: this.address(parent) }
1677
+ if (actor.kind === "human" || options.tty === true) {
1678
+ this.link(target, parent, this.actorDetail(actor, me))
1679
+ return { result: { ...result, endpoint: this.whoami(target.id) } }
1680
+ }
1681
+ if (target.parent_ep !== null || target.parent_addr !== null) throw this.alreadyLinked(target)
1682
+ const auth = actor.auth === "self" ? "self" : "session"
1683
+ return { result, sender: { from: me.id, auth } }
1684
+ })
1685
+ if (!prepared.sender) return prepared.result
1686
+ const { result } = prepared
1687
+ const { from, auth } = prepared.sender
1688
+ const send: SendParams = {
1689
+ to: result.endpoint.address,
1690
+ kind: "request",
1691
+ ttl_s: 86_400,
1692
+ 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}"}).`,
1693
+ }
1694
+ const requested = (sent: SendResult): AdoptResult => ({ ...result, ...(sent.held ? { held: sent.held } : { requested: sent.id }) })
1695
+ return options.send ? options.send(from, send, { link: auth }).then(requested) : requested(this.send(from, send, { link: auth }))
1696
+ }
1697
+
1698
+ /** accepts a broker-issued request delivered to the caller's own session */
1699
+ linkAccept(actor: ScopeActor, params: { id: unknown }): { endpoint: EndpointView; parent: string } {
1700
+ return this.scopeTx(() => {
1701
+ if (actor.kind !== "endpoint" || actor.auth === "lineage") throw new RelayError("scope_refused", "only the session itself accepts a link request")
1702
+ const me = this.actorRow(actor)
1703
+ const request = this.delivered(me.id, params.id)
1704
+ if (!request?.link || request.kind !== "request") throw new RelayError("not_found", "no link request delivered to this endpoint")
1705
+ const parent = this.mustEndpoint(request.from.endpoint)
1706
+ if (parent.kind !== "agent") throw new RelayError("invalid_params", "the requesting parent is no longer an agent")
1707
+ this.link(me, parent, { ...this.actorDetail(actor, me), request: request.id })
1708
+ return { endpoint: this.whoami(me.id), parent: this.address(parent) }
1619
1709
  })
1620
1710
  }
1621
1711
 
@@ -1676,7 +1766,7 @@ export class RelayCore {
1676
1766
  * `row`, a root, and its subtree moved under `parent`, a local agent that is neither `row` nor one of its descendants; a parent on another
1677
1767
  * machine goes to {@link linkAcross}
1678
1768
  */
1679
- private link(row: EndpointRow, parent: EndpointRow, by: { by: string; auth: string }): void {
1769
+ private link(row: EndpointRow, parent: EndpointRow, by: { by: string; auth: string; request?: string }): void {
1680
1770
  if (!this.localRow(parent)) {
1681
1771
  const key = this.linkAcross(row, parent)
1682
1772
  if (row.parent_addr === key) return
@@ -1684,7 +1774,7 @@ export class RelayCore {
1684
1774
  this.sql.all(`UPDATE endpoints SET parent_addr = ?, linked = ? WHERE id = ?`, key, this.now(), row.id)
1685
1775
  this.scopeMemo?.ancestors.clear()
1686
1776
  this.scopeMemo?.limited.clear()
1687
- this.audit("adopt", row.id, null, { ...by, target: this.address(row), parent: this.address(parent) })
1777
+ this.audit("adopt", row.id, by.request ?? null, { ...by, target: this.address(row), parent: this.address(parent) })
1688
1778
  return
1689
1779
  }
1690
1780
  if (parent.kind !== "agent") throw new RelayError("invalid_params", "the parent is an agent endpoint")
@@ -1713,7 +1803,7 @@ export class RelayCore {
1713
1803
  )
1714
1804
  this.scopeMemo?.ancestors.clear()
1715
1805
  this.scopeMemo?.limited.clear()
1716
- this.audit("adopt", row.id, null, { ...by, target: this.address(row), parent: this.address(parent) })
1806
+ this.audit("adopt", row.id, by.request ?? null, { ...by, target: this.address(row), parent: this.address(parent) })
1717
1807
  }
1718
1808
 
1719
1809
  /** a link of `row`, which already sits under a parent, to another one */
@@ -1743,9 +1833,14 @@ export class RelayCore {
1743
1833
 
1744
1834
  /**
1745
1835
  * renames an agent in place: its id, mail, threads, status, title and relations stay. An agent renames only itself, and only a root unless
1746
- * the policy's `defaults.rename` is `self`; the human at a terminal renames any local agent. The new handle is valid and held by no other endpoint
1836
+ * 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,
1837
+ * except an offline one {@link renameRow} takes over
1747
1838
  */
1748
- rename(actor: ScopeActor, params: { endpoint?: unknown; handle: unknown }): { endpoint: EndpointView; from: string; session: string | null } {
1839
+ rename(
1840
+ actor: ScopeActor,
1841
+ params: { endpoint?: unknown; handle: unknown; take_over?: unknown },
1842
+ ): { endpoint: EndpointView; from: string; session: string | null; retired?: string } {
1843
+ if (params.take_over !== undefined && typeof params.take_over !== "boolean") throw new RelayError("invalid_params", "take_over is true or false")
1749
1844
  return this.scopeTx(() => {
1750
1845
  const me = this.actorRow(actor)
1751
1846
  if (actor.kind === "human") this.needTerminal(actor, me, params.endpoint)
@@ -1753,13 +1848,16 @@ export class RelayCore {
1753
1848
  const target = params.endpoint === undefined ? me : this.resolve(params.endpoint)
1754
1849
  if (actor.kind !== "human" && target.id !== me.id) throw new RelayError("scope_refused", "an agent renames only itself")
1755
1850
  if (target.kind !== "agent" || !this.localRow(target)) throw new RelayError("invalid_params", "only a local agent endpoint can be renamed")
1756
- this.renameRow(target, params.handle, actor.kind === "human")
1757
- return { endpoint: this.whoami(target.id), from: target.handle, session: target.session }
1851
+ const retired = this.renameRow(target, params.handle, actor.kind === "human", params.take_over === true)
1852
+ return { endpoint: this.whoami(target.id), from: target.handle, session: target.session, ...(retired ? { retired } : {}) }
1758
1853
  })
1759
1854
  }
1760
1855
 
1761
- /** the endpoint's handle set to `value`, with the stored `ep:` and `fleet:` selectors that named it */
1762
- private renameRow(row: EndpointRow, value: unknown, byHuman: boolean): void {
1856
+ /**
1857
+ * the endpoint's handle set to `value`, with the stored `ep:` and `fleet:` selectors that named it. A local offline agent holding the handle
1858
+ * is taken over when it held the same harness session, or with `takeOver` by the human at a terminal: the id of the row retired
1859
+ */
1860
+ private renameRow(row: EndpointRow, value: unknown, byHuman: boolean, takeOver = false): string | undefined {
1763
1861
  const handle = typeof value === "string" ? value.trim().replace(/^me\//, "") : ""
1764
1862
  if (!validHandle(handle)) throw new RelayError("invalid_params", `handle ${JSON.stringify(handle)} is not valid: lowercase letters, digits, . _ -`)
1765
1863
  if (findSecret(handle)) throw new RelayError("secret_refused", "the handle looks like it carries a credential")
@@ -1772,14 +1870,23 @@ export class RelayCore {
1772
1870
  throw new RelayError("scope_refused", `spawned by ${parent ? this.address(parent) : "another agent"}; its name is set by the spawner`)
1773
1871
  }
1774
1872
  const held = this.byHandle(handle)
1873
+ let retired: string | undefined
1775
1874
  if (held) {
1776
- const how = held.kind !== "agent" ? "reserved" : presenceOf(held, this.now()) === "offline" ? "held by an offline endpoint" : "held by a live endpoint"
1777
- throw new RelayError("invalid_params", `${handle} is ${how}: pick another name`)
1875
+ const offline = held.kind === "agent" && held.connections === 0 && presenceOf(held, this.now()) === "offline" && this.localRow(held)
1876
+ const same = held.harness_session !== null && held.harness_session === row.harness_session
1877
+ if (takeOver && !byHuman && !same) throw new RelayError("scope_refused", "only the human at a terminal takes over another endpoint")
1878
+ if (takeOver && offline && !same && !harnessFits(held.harness, row.harness ?? "cli"))
1879
+ throw new RelayError("invalid_params", `${handle} is a ${held.harness} endpoint: only an endpoint of the same harness is taken over`)
1880
+ if (!offline || held.session?.startsWith("sub:") || !(same || (takeOver && harnessFits(held.harness, row.harness ?? "cli")))) {
1881
+ const how = held.kind !== "agent" ? "reserved" : presenceOf(held, this.now()) === "offline" ? "held by an offline endpoint" : "held by a live endpoint"
1882
+ throw new RelayError("invalid_params", `${handle} is ${how}: pick another name${offline ? ", or take it over with --take-over from a terminal" : ""}`)
1883
+ }
1884
+ retired = this.takeOver(row, held, same ? "session" : "human")
1778
1885
  }
1779
1886
  const from = row.handle
1780
1887
  this.sql.all(`UPDATE endpoints SET handle = ?, named = ? WHERE id = ?`, handle, this.now(), row.id)
1781
1888
  this.sql.all(`UPDATE grants SET name = ? WHERE used_by = ? AND name = ?`, handle, row.id, from)
1782
- for (const prefix of row.parent_ep === null ? ["ep:", "fleet:"] : ["ep:"]) {
1889
+ for (const prefix of this.byId(row.id)!.parent_ep === null ? ["ep:", "fleet:"] : ["ep:"]) {
1783
1890
  const [a, b] = [JSON.stringify(`${prefix}${from}`), JSON.stringify(`${prefix}${handle}`)]
1784
1891
  for (const [table, column] of [
1785
1892
  ["endpoints", "rules"],
@@ -1791,6 +1898,52 @@ export class RelayCore {
1791
1898
  }
1792
1899
  this.renamedHandle(from, handle)
1793
1900
  this.audit("rename", row.id, null, { from, to: handle })
1901
+ return retired
1902
+ }
1903
+
1904
+ /**
1905
+ * `row` takes over `held`, an offline local agent, and retires it: `held`'s subtree, unread mail, grants, groups and harness session move to
1906
+ * `row`, which takes `held`'s hand-made link when it is an unlinked root itself. Its handle is then free for `row`
1907
+ */
1908
+ private takeOver(row: EndpointRow, held: EndpointRow, by: "human" | "session"): string {
1909
+ if (this.ancestorsOf(row).includes(held.id))
1910
+ throw new RelayError("scope_refused", `${this.address(row)} sits under ${this.address(held)}: it cannot take over its own ancestor`)
1911
+ if (row.parent_ep === null && row.parent_addr === null && held.linked !== null && held.parent_addr !== null)
1912
+ this.sql.all(`UPDATE endpoints SET parent_addr = ?, linked = ? WHERE id = ?`, held.parent_addr, held.linked, row.id)
1913
+ else if (row.parent_ep === null && row.parent_addr === null && held.linked !== null && held.parent_ep !== null) {
1914
+ const parent = this.byId(held.parent_ep)
1915
+ if (parent) this.link(row, parent, { by: this.address(row), auth: by })
1916
+ }
1917
+ const place = this.byId(row.id)!
1918
+ const shift = (place.depth ?? 0) - (held.depth ?? 0)
1919
+ const below = `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)`
1920
+ const deepest = one<{ d: number | null }>(this.sql, `${below} SELECT MAX(depth) AS d FROM endpoints WHERE id IN (SELECT id FROM below)`, held.id)?.d
1921
+ if (deepest != null && deepest + shift > LIMITS.depthMax)
1922
+ throw new RelayError("scope_refused", `${this.address(held)}'s children would sit deeper than ${LIMITS.depthMax} under ${this.address(place)}`)
1923
+ this.sql.all(`${below} UPDATE endpoints SET tree = ?, depth = depth + ? WHERE id IN (SELECT id FROM below)`, held.id, place.tree ?? place.id, shift)
1924
+ const children = this.sql.all<{ id: string }>(`UPDATE endpoints SET parent_ep = ? WHERE parent_ep = ? RETURNING id`, row.id, held.id).length
1925
+ this.sql.all(`UPDATE deliveries SET endpoint = ? WHERE endpoint = ? AND acked_at IS NULL`, row.id, held.id)
1926
+ this.sql.all(`UPDATE grants SET issuer = ? WHERE issuer = ?`, row.id, held.id)
1927
+ this.sql.all(`UPDATE grants SET under = ? WHERE under = ?`, row.id, held.id)
1928
+ this.sql.all(`UPDATE groups SET owner = ? WHERE owner = ?`, row.id, held.id)
1929
+ this.sql.all(
1930
+ `INSERT OR IGNORE INTO group_members (name, endpoint, added_by, at) SELECT name, ?, added_by, at FROM group_members WHERE endpoint = ?`,
1931
+ row.id,
1932
+ held.id,
1933
+ )
1934
+ this.sql.all(`DELETE FROM group_members WHERE endpoint = ?`, held.id)
1935
+ this.sql.all(
1936
+ `UPDATE endpoints SET harness_session = ?, harness_transcript = ? WHERE id = ? AND harness_session IS NULL`,
1937
+ held.harness_session,
1938
+ held.harness_transcript,
1939
+ row.id,
1940
+ )
1941
+ this.sql.all(`UPDATE endpoints SET handle = ?, session = NULL, token_hash = NULL WHERE id = ?`, `${GONE_PREFIX}${held.id}`, held.id)
1942
+ this.dropPeek(held.id)
1943
+ this.scopeMemo?.ancestors.clear()
1944
+ this.scopeMemo?.limited.clear()
1945
+ this.audit("take_over", row.id, null, { from: row.handle, to: held.handle, retired: held.id, children, by })
1946
+ return held.id
1794
1947
  }
1795
1948
 
1796
1949
  /** a local agent's handle moved from `from` to `to`: a subclass updates what names it on another machine's rows */
@@ -1829,7 +1982,8 @@ export class RelayCore {
1829
1982
  * `origin.tty` is recorded on messages the human inbox sends: whether its client said a terminal was attached. `origin.unverified`: the human
1830
1983
  * sends without a terminal, so the scopes check it like any unplaced sender
1831
1984
  */
1832
- send(from: string, params: SendParams, origin: { tty?: boolean; unverified?: boolean } = {}): SendResult {
1985
+ send(from: string, params: SendParams, origin: { tty?: boolean; unverified?: boolean; link?: "session" | "self" } = {}): SendResult {
1986
+ if ("link" in params) throw new RelayError("invalid_params", "link is set by the relay, not the sender")
1833
1987
  const sender = this.mustEndpoint(from)
1834
1988
  const senderTty = sender.kind === "human" ? (origin.tty === true ? 1 : 0) : null
1835
1989
  const unverified = sender.kind === "human" && origin.unverified === true
@@ -1897,6 +2051,17 @@ export class RelayCore {
1897
2051
  const pending = [...unjoined.values()].map((grant) => ({ key: pendingKey(grant.token_hash), address: `${this.user}/${grant.name}` }))
1898
2052
  const reached = recipients.length + pending.length
1899
2053
  if (reached > LIMITS.fanOutTotal) throw new RelayError("too_large", `the send reaches ${reached} endpoints, more than ${LIMITS.fanOutTotal}`)
2054
+ if (
2055
+ origin.link &&
2056
+ (sender.kind !== "agent" ||
2057
+ kind !== "request" ||
2058
+ recipients.length !== 1 ||
2059
+ pending.length ||
2060
+ recipients[0]!.kind !== "agent" ||
2061
+ !this.localRow(recipients[0]!))
2062
+ )
2063
+ throw new RelayError("invalid_params", "a link request is from an agent to one local root agent")
2064
+ if (origin.link && (recipients[0]!.parent_ep !== null || recipients[0]!.parent_addr !== null)) throw this.alreadyLinked(recipients[0]!)
1900
2065
  const requestedThread = this.threadRef(from, cleanText(params.thread, "thread", 64))
1901
2066
  const payloadHash = this.hash(
1902
2067
  JSON.stringify([
@@ -1910,6 +2075,7 @@ export class RelayCore {
1910
2075
  mime,
1911
2076
  ttl,
1912
2077
  params.require_reply === true,
2078
+ ...(origin.link ? [origin.link] : []),
1913
2079
  ]),
1914
2080
  )
1915
2081
  if (clientId) {
@@ -2079,8 +2245,8 @@ export class RelayCore {
2079
2245
  const expires = now + ttl * 1000
2080
2246
  const to = targets.length === 1 ? String(targets[0]) : targets.join(",")
2081
2247
  this.sql.all(
2082
- `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)
2083
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
2248
+ `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)
2249
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
2084
2250
  id,
2085
2251
  thread,
2086
2252
  parent?.id ?? null,
@@ -2096,6 +2262,7 @@ export class RelayCore {
2096
2262
  expires,
2097
2263
  senderTty,
2098
2264
  params.require_reply === true ? 1 : null,
2265
+ origin.link ? this.address(sender) : null,
2099
2266
  )
2100
2267
  if (parent) this.sql.all(`UPDATE deliveries SET acked_at = ? WHERE message_id = ? AND endpoint = ? AND acked_at IS NULL`, now, parent.id, from)
2101
2268
  const deliveries: SendResult["deliveries"] = []
@@ -2137,6 +2304,13 @@ export class RelayCore {
2137
2304
  )
2138
2305
  }
2139
2306
  this.audit("send", from, id, { to: deliveries.map((d) => d.address), kind, bytes, hop, thread, data_class: dataClass })
2307
+ if (origin.link)
2308
+ this.audit("adopt_request", from, id, {
2309
+ by: this.address(sender),
2310
+ auth: origin.link,
2311
+ target: this.address(recipients[0]!),
2312
+ parent: this.address(sender),
2313
+ })
2140
2314
  for (const recipient of recipients) this.detectPingPong(sender, recipient)
2141
2315
  return sent
2142
2316
  })
@@ -5249,6 +5423,7 @@ export class RelayCore {
5249
5423
  created_at: iso(row.created_at)!,
5250
5424
  expires_at: iso(row.expires_at)!,
5251
5425
  ...(row.require_reply === 1 ? { require_reply: true as const } : {}),
5426
+ ...(row.link_parent ? { link: { parent: row.link_parent } } : {}),
5252
5427
  ...kin,
5253
5428
  }
5254
5429
  }
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"]
@@ -170,6 +170,8 @@ export interface Claim {
170
170
  session?: string
171
171
  /** the harness process the claim comes from, `pid:<pid>:<start time>`: a shim restarted in that process may rejoin its endpoint */
172
172
  process?: string
173
+ /** the harness's own stable session id (Claude Code's): a resumed session in a new harness process takes back the offline endpoint of that id */
174
+ harness_session?: string
173
175
  /** process keys of the claimant's harness process and its ancestors, nearest first: the nearest endpoint holding one places the claim under it */
174
176
  lineage?: string[]
175
177
  /** a `krt_` grant from `spawn-env`: places the claim at or below its lineage placement, with the grant's caps and groups */
@@ -358,6 +360,8 @@ export interface Envelope {
358
360
  expires_at: string
359
361
  /** a request whose sender holds the recipient's stop until it replies */
360
362
  require_reply?: true
363
+ /** a broker-issued request to link under its sender */
364
+ link?: { parent: string }
361
365
  /** 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
366
  shown_at?: string
363
367
  /**
@@ -473,6 +477,13 @@ export interface SendResult {
473
477
  redacted?: JudgeRedacted
474
478
  }
475
479
 
480
+ export interface AdoptResult {
481
+ endpoint: EndpointView
482
+ parent: string
483
+ requested?: string
484
+ held?: JudgeHeld
485
+ }
486
+
476
487
  /**
477
488
  * 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
489
  * showing; the agent gets it with its next request), and when a hook or channel showed it: ISO times, or null
@@ -493,4 +504,4 @@ export interface AuditRow {
493
504
  }
494
505
 
495
506
  /** 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
507
+ export const SERVER_FIELDS = ["from", "id", "seq", "hop", "created_at", "auth", "expires_at", "relation", "link"] as const