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