@scp-dev/core 0.2.0 → 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`,
@@ -1133,7 +1143,11 @@ interface MessageRow {
1133
1143
  expires_at: number;
1134
1144
  sender_tty: number | null;
1135
1145
  require_reply: number | null;
1146
+ link_parent: string | null;
1136
1147
  }
1148
+ type AdoptSend = (from: string, params: SendParams, origin: {
1149
+ link: "session" | "self";
1150
+ }) => Promise<SendResult>;
1137
1151
  interface CoreOptions {
1138
1152
  /** the local user's address part; `me` until a login names one */
1139
1153
  user?: string;
@@ -1384,16 +1398,23 @@ declare class RelayCore {
1384
1398
  * when given, renames it in place ({@link rename}'s rules). Nothing is kept when the rename is refused
1385
1399
  */
1386
1400
  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
- */
1401
+ /** the human links a local root directly; an agent asks it to consent through the normal send path */
1392
1402
  adopt(actor: ScopeActor, params: {
1393
1403
  endpoint: unknown;
1394
1404
  parent?: unknown;
1395
1405
  }, options?: {
1396
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;
1397
1418
  }): {
1398
1419
  endpoint: EndpointView;
1399
1420
  parent: string;
@@ -1459,6 +1480,7 @@ declare class RelayCore {
1459
1480
  send(from: string, params: SendParams, origin?: {
1460
1481
  tty?: boolean;
1461
1482
  unverified?: boolean;
1483
+ link?: "session" | "self";
1462
1484
  }): SendResult;
1463
1485
  /** a local agent recipient's {@link Reach} for a send from `sender`; none for a human, the relay or another machine's row */
1464
1486
  private reach;
@@ -2534,4 +2556,4 @@ declare function sha256Hex(text: string): string;
2534
2556
  /** the UTF-8 byte length of text, the unit every byte limit counts in */
2535
2557
  declare function utf8Bytes(text: string): number;
2536
2558
 
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 };
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)?-----";
@@ -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`);
@@ -2278,30 +2279,52 @@ var RelayCore = class {
2278
2279
  return this.whoami(row.id);
2279
2280
  });
2280
2281
  }
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
2282
  adopt(actor, params, options = {}) {
2287
- return this.scopeTx(() => {
2283
+ const prepared = this.scopeTx(() => {
2288
2284
  const me = this.actorRow(actor);
2289
2285
  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
- );
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");
2297
2288
  const target = this.resolve(params.endpoint);
2298
2289
  if (target.kind !== "agent" || !this.localRow(target)) throw new RelayError("invalid_params", "only a local agent endpoint can be linked");
2299
2290
  const parentAddress = params.parent ?? (actor.kind === "human" ? void 0 : this.address(me));
2300
2291
  if (parentAddress === void 0 || parentAddress === null || parentAddress === "")
2301
2292
  throw new RelayError("invalid_params", "the human names the parent to link under");
2302
2293
  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) };
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) };
2305
2328
  });
2306
2329
  }
2307
2330
  /**
@@ -2368,7 +2391,7 @@ var RelayCore = class {
2368
2391
  this.sql.all(`UPDATE endpoints SET parent_addr = ?, linked = ? WHERE id = ?`, key, this.now(), row.id);
2369
2392
  this.scopeMemo?.ancestors.clear();
2370
2393
  this.scopeMemo?.limited.clear();
2371
- this.audit("adopt", row.id, null, { ...by, target: this.address(row), parent: this.address(parent) });
2394
+ this.audit("adopt", row.id, by.request ?? null, { ...by, target: this.address(row), parent: this.address(parent) });
2372
2395
  return;
2373
2396
  }
2374
2397
  if (parent.kind !== "agent") throw new RelayError("invalid_params", "the parent is an agent endpoint");
@@ -2397,7 +2420,7 @@ var RelayCore = class {
2397
2420
  );
2398
2421
  this.scopeMemo?.ancestors.clear();
2399
2422
  this.scopeMemo?.limited.clear();
2400
- 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) });
2401
2424
  }
2402
2425
  /** a link of `row`, which already sits under a parent, to another one */
2403
2426
  alreadyLinked(row) {
@@ -2504,6 +2527,7 @@ var RelayCore = class {
2504
2527
  * sends without a terminal, so the scopes check it like any unplaced sender
2505
2528
  */
2506
2529
  send(from, params, origin = {}) {
2530
+ if ("link" in params) throw new RelayError("invalid_params", "link is set by the relay, not the sender");
2507
2531
  const sender = this.mustEndpoint(from);
2508
2532
  const senderTty = sender.kind === "human" ? origin.tty === true ? 1 : 0 : null;
2509
2533
  const unverified = sender.kind === "human" && origin.unverified === true;
@@ -2565,6 +2589,9 @@ var RelayCore = class {
2565
2589
  const pending = [...unjoined.values()].map((grant) => ({ key: pendingKey(grant.token_hash), address: `${this.user}/${grant.name}` }));
2566
2590
  const reached = recipients.length + pending.length;
2567
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]);
2568
2595
  const requestedThread = this.threadRef(from, cleanText(params.thread, "thread", 64));
2569
2596
  const payloadHash = this.hash(
2570
2597
  JSON.stringify([
@@ -2577,7 +2604,8 @@ var RelayCore = class {
2577
2604
  attachments.map((a) => a.path).sort(),
2578
2605
  mime,
2579
2606
  ttl,
2580
- params.require_reply === true
2607
+ params.require_reply === true,
2608
+ ...origin.link ? [origin.link] : []
2581
2609
  ])
2582
2610
  );
2583
2611
  if (clientId) {
@@ -2735,8 +2763,8 @@ var RelayCore = class {
2735
2763
  const expires = now + ttl * 1e3;
2736
2764
  const to = targets.length === 1 ? String(targets[0]) : targets.join(",");
2737
2765
  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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
2740
2768
  id,
2741
2769
  thread,
2742
2770
  parent?.id ?? null,
@@ -2751,7 +2779,8 @@ var RelayCore = class {
2751
2779
  now,
2752
2780
  expires,
2753
2781
  senderTty,
2754
- params.require_reply === true ? 1 : null
2782
+ params.require_reply === true ? 1 : null,
2783
+ origin.link ? this.address(sender) : null
2755
2784
  );
2756
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);
2757
2786
  const deliveries = [];
@@ -2793,6 +2822,13 @@ var RelayCore = class {
2793
2822
  );
2794
2823
  }
2795
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
+ });
2796
2832
  for (const recipient of recipients) this.detectPingPong(sender, recipient);
2797
2833
  return sent;
2798
2834
  });
@@ -5538,6 +5574,7 @@ ${stderr}` : "";
5538
5574
  created_at: iso(row.created_at),
5539
5575
  expires_at: iso(row.expires_at),
5540
5576
  ...row.require_reply === 1 ? { require_reply: true } : {},
5577
+ ...row.link_parent ? { link: { parent: row.link_parent } } : {},
5541
5578
  ...kin
5542
5579
  };
5543
5580
  }
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.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,
@@ -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`)
@@ -1592,30 +1597,60 @@ export class RelayCore {
1592
1597
  })
1593
1598
  }
1594
1599
 
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(() => {
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(() => {
1602
1609
  const me = this.actorRow(actor)
1603
1610
  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
- )
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")
1611
1613
  const target = this.resolve(params.endpoint)
1612
1614
  if (target.kind !== "agent" || !this.localRow(target)) throw new RelayError("invalid_params", "only a local agent endpoint can be linked")
1613
1615
  const parentAddress = params.parent ?? (actor.kind === "human" ? undefined : this.address(me))
1614
1616
  if (parentAddress === undefined || parentAddress === null || parentAddress === "")
1615
1617
  throw new RelayError("invalid_params", "the human names the parent to link under")
1616
1618
  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) }
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) }
1619
1654
  })
1620
1655
  }
1621
1656
 
@@ -1676,7 +1711,7 @@ export class RelayCore {
1676
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
1677
1712
  * machine goes to {@link linkAcross}
1678
1713
  */
1679
- private link(row: EndpointRow, parent: EndpointRow, by: { by: string; auth: string }): void {
1714
+ private link(row: EndpointRow, parent: EndpointRow, by: { by: string; auth: string; request?: string }): void {
1680
1715
  if (!this.localRow(parent)) {
1681
1716
  const key = this.linkAcross(row, parent)
1682
1717
  if (row.parent_addr === key) return
@@ -1684,7 +1719,7 @@ export class RelayCore {
1684
1719
  this.sql.all(`UPDATE endpoints SET parent_addr = ?, linked = ? WHERE id = ?`, key, this.now(), row.id)
1685
1720
  this.scopeMemo?.ancestors.clear()
1686
1721
  this.scopeMemo?.limited.clear()
1687
- this.audit("adopt", row.id, null, { ...by, target: this.address(row), parent: this.address(parent) })
1722
+ this.audit("adopt", row.id, by.request ?? null, { ...by, target: this.address(row), parent: this.address(parent) })
1688
1723
  return
1689
1724
  }
1690
1725
  if (parent.kind !== "agent") throw new RelayError("invalid_params", "the parent is an agent endpoint")
@@ -1713,7 +1748,7 @@ export class RelayCore {
1713
1748
  )
1714
1749
  this.scopeMemo?.ancestors.clear()
1715
1750
  this.scopeMemo?.limited.clear()
1716
- 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) })
1717
1752
  }
1718
1753
 
1719
1754
  /** a link of `row`, which already sits under a parent, to another one */
@@ -1829,7 +1864,8 @@ export class RelayCore {
1829
1864
  * `origin.tty` is recorded on messages the human inbox sends: whether its client said a terminal was attached. `origin.unverified`: the human
1830
1865
  * sends without a terminal, so the scopes check it like any unplaced sender
1831
1866
  */
1832
- 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")
1833
1869
  const sender = this.mustEndpoint(from)
1834
1870
  const senderTty = sender.kind === "human" ? (origin.tty === true ? 1 : 0) : null
1835
1871
  const unverified = sender.kind === "human" && origin.unverified === true
@@ -1897,6 +1933,17 @@ export class RelayCore {
1897
1933
  const pending = [...unjoined.values()].map((grant) => ({ key: pendingKey(grant.token_hash), address: `${this.user}/${grant.name}` }))
1898
1934
  const reached = recipients.length + pending.length
1899
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]!)
1900
1947
  const requestedThread = this.threadRef(from, cleanText(params.thread, "thread", 64))
1901
1948
  const payloadHash = this.hash(
1902
1949
  JSON.stringify([
@@ -1910,6 +1957,7 @@ export class RelayCore {
1910
1957
  mime,
1911
1958
  ttl,
1912
1959
  params.require_reply === true,
1960
+ ...(origin.link ? [origin.link] : []),
1913
1961
  ]),
1914
1962
  )
1915
1963
  if (clientId) {
@@ -2079,8 +2127,8 @@ export class RelayCore {
2079
2127
  const expires = now + ttl * 1000
2080
2128
  const to = targets.length === 1 ? String(targets[0]) : targets.join(",")
2081
2129
  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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
2084
2132
  id,
2085
2133
  thread,
2086
2134
  parent?.id ?? null,
@@ -2096,6 +2144,7 @@ export class RelayCore {
2096
2144
  expires,
2097
2145
  senderTty,
2098
2146
  params.require_reply === true ? 1 : null,
2147
+ origin.link ? this.address(sender) : null,
2099
2148
  )
2100
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)
2101
2150
  const deliveries: SendResult["deliveries"] = []
@@ -2137,6 +2186,13 @@ export class RelayCore {
2137
2186
  )
2138
2187
  }
2139
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
+ })
2140
2196
  for (const recipient of recipients) this.detectPingPong(sender, recipient)
2141
2197
  return sent
2142
2198
  })
@@ -5249,6 +5305,7 @@ export class RelayCore {
5249
5305
  created_at: iso(row.created_at)!,
5250
5306
  expires_at: iso(row.expires_at)!,
5251
5307
  ...(row.require_reply === 1 ? { require_reply: true as const } : {}),
5308
+ ...(row.link_parent ? { link: { parent: row.link_parent } } : {}),
5252
5309
  ...kin,
5253
5310
  }
5254
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