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