@scp-dev/core 0.1.1 → 0.1.3
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 +103 -2
- package/dist/index.js +290 -13
- package/package.json +1 -1
- package/src/core.ts +313 -11
- package/src/index.ts +1 -0
- package/src/policy.ts +13 -1
- package/src/protocol.ts +7 -0
package/dist/index.d.ts
CHANGED
|
@@ -385,8 +385,10 @@ declare const LIMITS: {
|
|
|
385
385
|
readonly grantTtlS: number;
|
|
386
386
|
readonly groupIdleS: 86400;
|
|
387
387
|
readonly policyLoadsKept: 8;
|
|
388
|
+
/** the prompts between two refreshes of a title a hook inferred */
|
|
389
|
+
readonly titleRefreshTurns: 5;
|
|
388
390
|
};
|
|
389
|
-
type ErrorCode = "not_found" | "inbox_full" | "rate_limited" | "loop_guard" | "data_class_refused" | "secret_refused" | "policy_refused" | "too_large" | "invalid_params" | "unauthorized" | "method_not_found" | "scope_refused" | "unavailable" | "judge_refused";
|
|
391
|
+
type ErrorCode = "not_found" | "inbox_full" | "rate_limited" | "loop_guard" | "data_class_refused" | "secret_refused" | "policy_refused" | "too_large" | "invalid_params" | "unauthorized" | "method_not_found" | "scope_refused" | "unavailable" | "judge_refused" | "not_live";
|
|
390
392
|
declare class RelayError extends Error {
|
|
391
393
|
readonly code: ErrorCode;
|
|
392
394
|
readonly retryAfterS?: number | undefined;
|
|
@@ -695,6 +697,8 @@ interface SendParams {
|
|
|
695
697
|
mime?: string;
|
|
696
698
|
/** on a request only: the recipient's stop is held until it replies */
|
|
697
699
|
require_reply?: boolean;
|
|
700
|
+
/** refuse `not_live`, queueing nothing, when an agent recipient has no live session */
|
|
701
|
+
live?: boolean;
|
|
698
702
|
}
|
|
699
703
|
/** what ask and ask_wait answer: the reply; or pending, with `ended` once every recipient present at the send went offline without replying; or expired */
|
|
700
704
|
interface AskResult {
|
|
@@ -744,6 +748,8 @@ interface SendResult {
|
|
|
744
748
|
last_seen: string | null;
|
|
745
749
|
state?: Reach;
|
|
746
750
|
}[];
|
|
751
|
+
/** one line per agent recipient no live session holds: `queued: no live session holds <address>` */
|
|
752
|
+
warnings?: string[];
|
|
747
753
|
duplicate?: boolean;
|
|
748
754
|
held?: JudgeHeld;
|
|
749
755
|
redacted?: JudgeRedacted;
|
|
@@ -803,6 +809,10 @@ interface PolicyDefaults {
|
|
|
803
809
|
/** `inherit`: a child of a capped parent reaches only what the parent reaches; a preset caps every placed child without grant caps */
|
|
804
810
|
child_default?: ScopePreset | "inherit";
|
|
805
811
|
verify_lineage?: "spike" | boolean;
|
|
812
|
+
/** who may rename an agent besides the human at a terminal: `root` (default) only a root session itself, `self` any agent itself, spawned ones too */
|
|
813
|
+
rename?: "root" | "self";
|
|
814
|
+
/** who may unlink an agent from its parent besides the human at a terminal: `human` (default) nobody, `self` the agent itself */
|
|
815
|
+
unlink?: "human" | "self";
|
|
806
816
|
}
|
|
807
817
|
interface Policy {
|
|
808
818
|
rules: PolicyRule[];
|
|
@@ -1017,6 +1027,8 @@ interface Sql {
|
|
|
1017
1027
|
declare const one: <T>(sql: Sql, query: string, ...params: SqlValue[]) => T | undefined;
|
|
1018
1028
|
|
|
1019
1029
|
declare const HUMAN_HANDLE = "_human";
|
|
1030
|
+
/** an agent title's length cap */
|
|
1031
|
+
declare const TITLE_CHARS = 64;
|
|
1020
1032
|
declare const SYSTEM_HANDLE = "_relay";
|
|
1021
1033
|
declare const GONE_PREFIX = "_gone_";
|
|
1022
1034
|
interface EndpointRow {
|
|
@@ -1064,12 +1076,22 @@ interface EndpointRow {
|
|
|
1064
1076
|
/** JSON {@link ScopeCaps} */
|
|
1065
1077
|
caps: string | null;
|
|
1066
1078
|
title: string | null;
|
|
1079
|
+
/** the title a hook last inferred: the title is inferred while it equals this */
|
|
1080
|
+
title_auto: string | null;
|
|
1081
|
+
/** the prompt count when a summary last set the inferred title; null when the first prompt set it */
|
|
1082
|
+
title_turn: number | null;
|
|
1083
|
+
/** the prompt count when the status note last changed or a hook last reminded the agent to refresh it */
|
|
1084
|
+
tldr_turn: number | null;
|
|
1067
1085
|
/** when a new spawn last took the endpoint over: mail and messages from before it were the earlier holder's */
|
|
1068
1086
|
reset_at: number | null;
|
|
1069
1087
|
/** when a new spawn last took the endpoint over: with its id, names the holder another machine sees */
|
|
1070
1088
|
spawned_at: number | null;
|
|
1071
1089
|
/** who may peek: null is off */
|
|
1072
1090
|
peek: PeekLevel | null;
|
|
1091
|
+
/** when the session last set its handle itself (join or rename): a later claim with another handle does not rename it back */
|
|
1092
|
+
named: number | null;
|
|
1093
|
+
/** when the session itself or the human linked it under a parent by hand: a later root claim of it takes it back where it sits */
|
|
1094
|
+
linked: number | null;
|
|
1073
1095
|
}
|
|
1074
1096
|
interface MessageRow {
|
|
1075
1097
|
id: string;
|
|
@@ -1118,6 +1140,8 @@ declare class RelayCore {
|
|
|
1118
1140
|
private readonly capEverySender;
|
|
1119
1141
|
private readonly alive;
|
|
1120
1142
|
private readonly hostMachine;
|
|
1143
|
+
private readonly namedMachine;
|
|
1144
|
+
private readonly hostname;
|
|
1121
1145
|
private policy;
|
|
1122
1146
|
/** the identity each policy `group:<name>` naming no policy group bound to when the policy's text first loaded: that group's then, or `none` */
|
|
1123
1147
|
private policyBinds;
|
|
@@ -1126,6 +1150,8 @@ declare class RelayCore {
|
|
|
1126
1150
|
private scopeMemo;
|
|
1127
1151
|
private readonly deferred;
|
|
1128
1152
|
constructor(sql: Sql, options: CoreOptions);
|
|
1153
|
+
/** a `machine` label naming this host by its hostname, as clients default it, names it by its machine name when one is set */
|
|
1154
|
+
private machineLabel;
|
|
1129
1155
|
migrate(): void;
|
|
1130
1156
|
/** every stored list that is exactly `tree:self`, the tree preset's old list, narrows once to the preset's list */
|
|
1131
1157
|
private migrateTreePreset;
|
|
@@ -1279,6 +1305,75 @@ declare class RelayCore {
|
|
|
1279
1305
|
status(endpoint: string, state: unknown, note: unknown): void;
|
|
1280
1306
|
/** an agent's own title, shown in list and whoami; empty clears it, and no claim overwrites it */
|
|
1281
1307
|
title(endpoint: string, value: unknown): EndpointView;
|
|
1308
|
+
/**
|
|
1309
|
+
* a hook's guess at the session's title, from the harness's own summary or the first prompt's first line; whether it changed the title.
|
|
1310
|
+
* Never over a title set explicitly; the first prompt fills only an empty title, and a summary replaces an inferred one at most every
|
|
1311
|
+
* ${LIMITS.titleRefreshTurns} prompts, or at once over the first prompt's
|
|
1312
|
+
*/
|
|
1313
|
+
inferTitle(endpoint: string, params: {
|
|
1314
|
+
text?: unknown;
|
|
1315
|
+
from?: unknown;
|
|
1316
|
+
}, options?: {
|
|
1317
|
+
transient?: boolean;
|
|
1318
|
+
process?: string;
|
|
1319
|
+
}): boolean;
|
|
1320
|
+
/**
|
|
1321
|
+
* a session joins on demand: its hook claim registers or takes back its endpoint, marked seen now even right after a leave, and `handle`,
|
|
1322
|
+
* when given, renames it in place ({@link rename}'s rules). Nothing is kept when the rename is refused
|
|
1323
|
+
*/
|
|
1324
|
+
joinSession(claim: Claim, handle?: unknown, under?: unknown): EndpointView;
|
|
1325
|
+
/**
|
|
1326
|
+
* 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.
|
|
1327
|
+
* Only the human at a terminal links another session, or an agent from its own shell with a terminal attached, under itself by default.
|
|
1328
|
+
* A session links itself with {@link joinSession}'s `under`
|
|
1329
|
+
*/
|
|
1330
|
+
adopt(actor: ScopeActor, params: {
|
|
1331
|
+
endpoint: unknown;
|
|
1332
|
+
parent?: unknown;
|
|
1333
|
+
}, options?: {
|
|
1334
|
+
tty?: boolean;
|
|
1335
|
+
}): {
|
|
1336
|
+
endpoint: EndpointView;
|
|
1337
|
+
parent: string;
|
|
1338
|
+
};
|
|
1339
|
+
/**
|
|
1340
|
+
* a linked agent back to a root: its subtree moves along and the policy's child scope comes off. The human at a terminal unlinks any local
|
|
1341
|
+
* agent; an agent only itself, and only when the policy's `defaults.unlink` is `self`, since a worker detaching widens its own scope
|
|
1342
|
+
*/
|
|
1343
|
+
unlink(actor: ScopeActor, params?: {
|
|
1344
|
+
endpoint?: unknown;
|
|
1345
|
+
}): {
|
|
1346
|
+
endpoint: EndpointView;
|
|
1347
|
+
parent: string;
|
|
1348
|
+
};
|
|
1349
|
+
/** `row`, a root, and its subtree moved under `parent`, a local agent that is neither `row` nor one of its descendants */
|
|
1350
|
+
private link;
|
|
1351
|
+
/**
|
|
1352
|
+
* renames an agent in place: its id, mail, threads, status, title and relations stay. An agent renames only itself, and only a root unless
|
|
1353
|
+
* 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
|
|
1354
|
+
*/
|
|
1355
|
+
rename(actor: ScopeActor, params: {
|
|
1356
|
+
endpoint?: unknown;
|
|
1357
|
+
handle: unknown;
|
|
1358
|
+
}): {
|
|
1359
|
+
endpoint: EndpointView;
|
|
1360
|
+
from: string;
|
|
1361
|
+
session: string | null;
|
|
1362
|
+
};
|
|
1363
|
+
/** the endpoint's handle set to `value`, with the stored `ep:` and `fleet:` selectors that named it */
|
|
1364
|
+
private renameRow;
|
|
1365
|
+
/**
|
|
1366
|
+
* 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
|
|
1367
|
+
* itself, the human at a terminal any local root or linked agent. A spawned worker stays joined while it runs
|
|
1368
|
+
*/
|
|
1369
|
+
leaveSession(actor: ScopeActor, params?: {
|
|
1370
|
+
endpoint?: unknown;
|
|
1371
|
+
}): {
|
|
1372
|
+
endpoint: EndpointView;
|
|
1373
|
+
session: string | null;
|
|
1374
|
+
};
|
|
1375
|
+
/** the harness session key an endpoint holds, if any */
|
|
1376
|
+
sessionOf(endpoint: string): string | null;
|
|
1282
1377
|
/**
|
|
1283
1378
|
* `origin.tty` is recorded on messages the human inbox sends: whether its client said a terminal was attached. `origin.unverified`: the human
|
|
1284
1379
|
* sends without a terminal, so the scopes check it like any unplaced sender
|
|
@@ -1339,6 +1434,7 @@ declare class RelayCore {
|
|
|
1339
1434
|
* `directive_seqs`. Any call says `brief` once the session was shown the full preamble ({@link briefed}), and one that is no stop names in
|
|
1340
1435
|
* `intro` the spawner of an endpoint a `swarm spawn` grant placed, with the grant's steer key in `intro_key`, until a hook showed it
|
|
1341
1436
|
* ({@link introduced}). A hook of another process than the one holding the endpoint (a child that inherited the session key) gets nothing.
|
|
1437
|
+
* A wake also offers live workers' messages.
|
|
1342
1438
|
*/
|
|
1343
1439
|
inject(endpoint: string, options?: {
|
|
1344
1440
|
stop?: boolean;
|
|
@@ -1347,6 +1443,7 @@ declare class RelayCore {
|
|
|
1347
1443
|
directives?: boolean;
|
|
1348
1444
|
transient?: boolean;
|
|
1349
1445
|
process?: string;
|
|
1446
|
+
wake?: boolean;
|
|
1350
1447
|
}): {
|
|
1351
1448
|
messages: Envelope[];
|
|
1352
1449
|
offered: number[];
|
|
@@ -1415,6 +1512,8 @@ declare class RelayCore {
|
|
|
1415
1512
|
transient?: boolean;
|
|
1416
1513
|
process?: string;
|
|
1417
1514
|
}): boolean;
|
|
1515
|
+
/** whether a prompt should remind the agent to refresh its status note: `every` prompts passed since the note or the last reminder; counts the reminder */
|
|
1516
|
+
tldrDue(endpoint: string, every: number): boolean;
|
|
1418
1517
|
/** the background hooks' showings ({@link injected}) older than ${LIMITS.deferredSettleMs} ms reached the agent: inbox no longer lists them */
|
|
1419
1518
|
private settle;
|
|
1420
1519
|
/**
|
|
@@ -1544,6 +1643,8 @@ declare class RelayCore {
|
|
|
1544
1643
|
}): AuditRow[];
|
|
1545
1644
|
/** drops what expired: deliveries, message bodies past their grace, old guard state and old audit rows */
|
|
1546
1645
|
sweep(): void;
|
|
1646
|
+
/** ends the hook presence of local agent endpoints whose harness process exited without a session_end, as one would */
|
|
1647
|
+
endExited(now?: number): void;
|
|
1547
1648
|
/** an agent endpoint left offline drops a working or waiting status, which no one is left to keep current */
|
|
1548
1649
|
private settleOffline;
|
|
1549
1650
|
/**
|
|
@@ -2351,4 +2452,4 @@ declare function sha256Hex(text: string): string;
|
|
|
2351
2452
|
/** the UTF-8 byte length of text, the unit every byte limit counts in */
|
|
2352
2453
|
declare function utf8Bytes(text: string): number;
|
|
2353
2454
|
|
|
2354
|
-
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, 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, type ThreadMessage, type Verdict, VerdictCache, 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, hasScope, honestSender, isLeaving, joinRedacted, judgeBoundary, judgeDetailForAgents, judgeFellBack, judgeModel, judgePrompt, keepLast, leavesFleet, matches, mergeVerdicts, narrower, newId, newJudgeStats, one, parseJudgePolicy, parsePolicy, parseVerdict, parseXMessage, peekLevel, policyRefusal, prefilter, 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, widenText, wrapMessage, wrapPeek, xlimits };
|
|
2455
|
+
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, 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, 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, hasScope, honestSender, isLeaving, joinRedacted, judgeBoundary, judgeDetailForAgents, judgeFellBack, judgeModel, judgePrompt, keepLast, leavesFleet, matches, mergeVerdicts, narrower, newId, newJudgeStats, one, parseJudgePolicy, parsePolicy, parseVerdict, parseXMessage, peekLevel, policyRefusal, prefilter, 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, widenText, wrapMessage, wrapPeek, xlimits };
|
package/dist/index.js
CHANGED
|
@@ -164,7 +164,9 @@ var LIMITS = {
|
|
|
164
164
|
groupPerMinute: 60,
|
|
165
165
|
grantTtlS: 7 * 86400,
|
|
166
166
|
groupIdleS: 86400,
|
|
167
|
-
policyLoadsKept: 8
|
|
167
|
+
policyLoadsKept: 8,
|
|
168
|
+
/** the prompts between two refreshes of a title a hook inferred */
|
|
169
|
+
titleRefreshTurns: 5
|
|
168
170
|
};
|
|
169
171
|
var RelayError = class extends Error {
|
|
170
172
|
constructor(code, message, retryAfterS, detail) {
|
|
@@ -699,7 +701,7 @@ function parsePolicy(value, options = {}) {
|
|
|
699
701
|
}
|
|
700
702
|
if (defaults !== void 0) {
|
|
701
703
|
if (!isObject2(defaults)) throw new RelayError("invalid_params", "defaults is an object");
|
|
702
|
-
const { human_always, symmetric_presets, child_default, verify_lineage, ...extra } = defaults;
|
|
704
|
+
const { human_always, symmetric_presets, child_default, verify_lineage, rename, unlink, ...extra } = defaults;
|
|
703
705
|
if (Object.keys(extra).length) throw new RelayError("invalid_params", `unknown defaults: ${Object.keys(extra).join(", ")}`);
|
|
704
706
|
const parsed = {};
|
|
705
707
|
for (const [key, flag] of [
|
|
@@ -723,6 +725,14 @@ function parsePolicy(value, options = {}) {
|
|
|
723
725
|
throw new RelayError("invalid_params", "defaults.verify_lineage true needs the peer-pid check, which this relay does not have yet: use spike or false");
|
|
724
726
|
parsed.verify_lineage = verify_lineage;
|
|
725
727
|
}
|
|
728
|
+
if (rename !== void 0) {
|
|
729
|
+
if (rename !== "root" && rename !== "self") throw new RelayError("invalid_params", "defaults.rename is root or self");
|
|
730
|
+
parsed.rename = rename;
|
|
731
|
+
}
|
|
732
|
+
if (unlink !== void 0) {
|
|
733
|
+
if (unlink !== "human" && unlink !== "self") throw new RelayError("invalid_params", "defaults.unlink is human or self");
|
|
734
|
+
parsed.unlink = unlink;
|
|
735
|
+
}
|
|
726
736
|
policy.defaults = parsed;
|
|
727
737
|
}
|
|
728
738
|
if (judge !== void 0) policy.judge = parseJudgePolicy(judge, options.judgeProviders);
|
|
@@ -1065,6 +1075,7 @@ function utf8Bytes(text) {
|
|
|
1065
1075
|
|
|
1066
1076
|
// src/core.ts
|
|
1067
1077
|
var HUMAN_HANDLE = "_human";
|
|
1078
|
+
var TITLE_CHARS = 64;
|
|
1068
1079
|
var SYSTEM_HANDLE = "_relay";
|
|
1069
1080
|
var GONE_PREFIX = "_gone_";
|
|
1070
1081
|
var MIGRATION = [
|
|
@@ -1247,6 +1258,8 @@ var RelayCore = class {
|
|
|
1247
1258
|
this.capEverySender = options.capEverySender === true;
|
|
1248
1259
|
this.alive = options.alive ?? (() => true);
|
|
1249
1260
|
this.hostMachine = options.machine ?? options.hostname ?? null;
|
|
1261
|
+
this.namedMachine = options.machine ?? null;
|
|
1262
|
+
this.hostname = options.hostname ?? null;
|
|
1250
1263
|
}
|
|
1251
1264
|
sql;
|
|
1252
1265
|
user;
|
|
@@ -1256,6 +1269,8 @@ var RelayCore = class {
|
|
|
1256
1269
|
capEverySender;
|
|
1257
1270
|
alive;
|
|
1258
1271
|
hostMachine;
|
|
1272
|
+
namedMachine;
|
|
1273
|
+
hostname;
|
|
1259
1274
|
policy = null;
|
|
1260
1275
|
/** the identity each policy `group:<name>` naming no policy group bound to when the policy's text first loaded: that group's then, or `none` */
|
|
1261
1276
|
policyBinds = /* @__PURE__ */ new Map();
|
|
@@ -1263,6 +1278,11 @@ var RelayCore = class {
|
|
|
1263
1278
|
/** while a scope world derives a node: what it already derived of ancestor lists and limits */
|
|
1264
1279
|
scopeMemo = null;
|
|
1265
1280
|
deferred = /* @__PURE__ */ new WeakMap();
|
|
1281
|
+
/** a `machine` label naming this host by its hostname, as clients default it, names it by its machine name when one is set */
|
|
1282
|
+
machineLabel(labels) {
|
|
1283
|
+
if (this.namedMachine !== null && labels.machine !== void 0 && labels.machine === this.hostname) labels.machine = this.namedMachine;
|
|
1284
|
+
return labels;
|
|
1285
|
+
}
|
|
1266
1286
|
migrate() {
|
|
1267
1287
|
for (const statement of MIGRATION) this.sql.all(statement);
|
|
1268
1288
|
const columns = this.sql.all(`PRAGMA table_info(idempotency)`).map((c) => c.name);
|
|
@@ -1295,7 +1315,12 @@ var RelayCore = class {
|
|
|
1295
1315
|
["introduced", "INTEGER"],
|
|
1296
1316
|
["directive_holds", "INTEGER"],
|
|
1297
1317
|
["harness_session", "TEXT"],
|
|
1298
|
-
["harness_transcript", "TEXT"]
|
|
1318
|
+
["harness_transcript", "TEXT"],
|
|
1319
|
+
["title_auto", "TEXT"],
|
|
1320
|
+
["title_turn", "INTEGER"],
|
|
1321
|
+
["tldr_turn", "INTEGER"],
|
|
1322
|
+
["named", "INTEGER"],
|
|
1323
|
+
["linked", "INTEGER"]
|
|
1299
1324
|
]) {
|
|
1300
1325
|
if (!endpointColumns.includes(column)) this.sql.all(`ALTER TABLE endpoints ADD COLUMN ${column} ${type}`);
|
|
1301
1326
|
}
|
|
@@ -1452,7 +1477,7 @@ var RelayCore = class {
|
|
|
1452
1477
|
model: cleanText(claim.model, "model", 128),
|
|
1453
1478
|
cwd: cleanText(claim.cwd, "cwd", 1024),
|
|
1454
1479
|
parent: cleanText(claim.parent, "parent", 128),
|
|
1455
|
-
labels: JSON.stringify(cleanLabels(claim.labels)),
|
|
1480
|
+
labels: JSON.stringify(this.machineLabel(cleanLabels(claim.labels))),
|
|
1456
1481
|
session: cleanText(claim.session, "session", 128),
|
|
1457
1482
|
process: cleanText(claim.process, "process", 128)
|
|
1458
1483
|
};
|
|
@@ -1551,6 +1576,7 @@ var RelayCore = class {
|
|
|
1551
1576
|
}
|
|
1552
1577
|
claimRow(wanted, harness, fields, transient, scope = { handle: wanted, lineage: [], grant: null, rules: {}, groups: [], cwd: fields.cwd }) {
|
|
1553
1578
|
const now = this.now();
|
|
1579
|
+
this.endExited(now);
|
|
1554
1580
|
const bare = transient && fields.session !== null && BARE_PID_KEY.test(fields.session);
|
|
1555
1581
|
let row = fields.session ? one(
|
|
1556
1582
|
this.sql,
|
|
@@ -1574,7 +1600,7 @@ var RelayCore = class {
|
|
|
1574
1600
|
} else if (row && !transient && !sameProcess(row, fields.process)) {
|
|
1575
1601
|
row = void 0;
|
|
1576
1602
|
session = null;
|
|
1577
|
-
} else if (row && !transient && row.token_hash === null && row.handle !== wanted && !row.handle.startsWith(`${wanted.slice(0, 58)}-`)) {
|
|
1603
|
+
} else if (row && !transient && row.token_hash === null && row.named === null && row.handle !== wanted && !row.handle.startsWith(`${wanted.slice(0, 58)}-`)) {
|
|
1578
1604
|
const hooks = row;
|
|
1579
1605
|
let settled = false;
|
|
1580
1606
|
for (let n = 1; n < (scope.exact ? 2 : 1e3) && !settled; n++) {
|
|
@@ -1824,7 +1850,7 @@ var RelayCore = class {
|
|
|
1824
1850
|
*/
|
|
1825
1851
|
restarts(row, parent, process) {
|
|
1826
1852
|
if (parent?.id === row.id) return process !== null && row.process !== null && sameHarness(row.process, process);
|
|
1827
|
-
return (parent?.id ?? null) === row.parent_ep;
|
|
1853
|
+
return (parent?.id ?? null) === row.parent_ep || parent === null && row.linked !== null;
|
|
1828
1854
|
}
|
|
1829
1855
|
/**
|
|
1830
1856
|
* whether the claim re-claims `row` with the grant `row` spent: from `row`'s own process or one of its ancestors, or with no lineage from
|
|
@@ -2012,6 +2038,7 @@ var RelayCore = class {
|
|
|
2012
2038
|
*/
|
|
2013
2039
|
list(filters = {}) {
|
|
2014
2040
|
const now = this.now();
|
|
2041
|
+
this.endExited(now);
|
|
2015
2042
|
this.settleOffline(now);
|
|
2016
2043
|
const cutoff = now - LIMITS.listOfflineWithinS * 1e3;
|
|
2017
2044
|
const viewer = filters.viewer === void 0 ? void 0 : this.mustEndpoint(filters.viewer);
|
|
@@ -2065,10 +2092,12 @@ var RelayCore = class {
|
|
|
2065
2092
|
const text = oneLine(note, "note", LIMITS.noteChars);
|
|
2066
2093
|
if (text && findSecret(text)) throw this.refuse(endpoint, "secret_refused", "the status note looks like it carries a credential");
|
|
2067
2094
|
this.sql.all(
|
|
2068
|
-
`UPDATE endpoints SET status_state = COALESCE(?, status_state, 'working'), status_note = COALESCE(?, status_note, ''), last_seen =
|
|
2095
|
+
`UPDATE endpoints SET status_state = COALESCE(?, status_state, 'working'), status_note = COALESCE(?, status_note, ''), last_seen = ?,
|
|
2096
|
+
tldr_turn = CASE WHEN ? THEN COALESCE(prompts, 0) ELSE tldr_turn END WHERE id = ?`,
|
|
2069
2097
|
state ?? null,
|
|
2070
2098
|
text,
|
|
2071
2099
|
this.now(),
|
|
2100
|
+
text === null ? 0 : 1,
|
|
2072
2101
|
endpoint
|
|
2073
2102
|
);
|
|
2074
2103
|
}
|
|
@@ -2078,10 +2107,225 @@ var RelayCore = class {
|
|
|
2078
2107
|
if (row.kind !== "agent") throw new RelayError("invalid_params", "only an agent endpoint has a title");
|
|
2079
2108
|
const title = oneLine(value, "title", 64) || null;
|
|
2080
2109
|
if (title && findSecret(title)) throw this.refuse(endpoint, "secret_refused", "the title looks like it carries a credential");
|
|
2081
|
-
this.sql.all(`UPDATE endpoints SET title =
|
|
2110
|
+
this.sql.all(`UPDATE endpoints SET title = ?, title_auto = NULL, title_turn = NULL WHERE id = ?`, title, endpoint);
|
|
2082
2111
|
this.audit("title", endpoint, null, { title });
|
|
2083
2112
|
return this.whoami(endpoint);
|
|
2084
2113
|
}
|
|
2114
|
+
/**
|
|
2115
|
+
* a hook's guess at the session's title, from the harness's own summary or the first prompt's first line; whether it changed the title.
|
|
2116
|
+
* Never over a title set explicitly; the first prompt fills only an empty title, and a summary replaces an inferred one at most every
|
|
2117
|
+
* ${LIMITS.titleRefreshTurns} prompts, or at once over the first prompt's
|
|
2118
|
+
*/
|
|
2119
|
+
inferTitle(endpoint, params, options = {}) {
|
|
2120
|
+
if (params.from !== "summary" && params.from !== "prompt") throw new RelayError("invalid_params", "from is summary or prompt");
|
|
2121
|
+
if (typeof params.text !== "string") throw new RelayError("invalid_params", "text must be a string");
|
|
2122
|
+
const row = this.mustEndpoint(endpoint);
|
|
2123
|
+
if (row.kind !== "agent" || !this.holds(row, options)) return false;
|
|
2124
|
+
const title = stripControl(redactSecrets(params.text.slice(0, 4096).replace(/\s+/g, " ").trim())).slice(0, TITLE_CHARS).trim();
|
|
2125
|
+
if (!title || findSecret(title) || title === row.title) return false;
|
|
2126
|
+
if (row.title !== null && row.title !== row.title_auto) return false;
|
|
2127
|
+
const turn = row.prompts ?? 0;
|
|
2128
|
+
if (params.from === "prompt" && row.title !== null) return false;
|
|
2129
|
+
if (params.from === "summary" && row.title !== null && row.title_turn !== null && turn - row.title_turn < LIMITS.titleRefreshTurns) return false;
|
|
2130
|
+
this.sql.all(`UPDATE endpoints SET title = ?, title_auto = ?, title_turn = ? WHERE id = ?`, title, title, params.from === "summary" ? turn : null, endpoint);
|
|
2131
|
+
return true;
|
|
2132
|
+
}
|
|
2133
|
+
/**
|
|
2134
|
+
* a session joins on demand: its hook claim registers or takes back its endpoint, marked seen now even right after a leave, and `handle`,
|
|
2135
|
+
* when given, renames it in place ({@link rename}'s rules). Nothing is kept when the rename is refused
|
|
2136
|
+
*/
|
|
2137
|
+
joinSession(claim, handle, under) {
|
|
2138
|
+
return this.scopeTx(() => {
|
|
2139
|
+
const { endpoint } = this.claim(claim, { transient: true });
|
|
2140
|
+
const row = this.mustEndpoint(endpoint.id);
|
|
2141
|
+
if (handle !== void 0 && handle !== null && handle !== "") this.renameRow(row, handle, false);
|
|
2142
|
+
this.sql.all(`UPDATE endpoints SET hook_seen = ?, hook_ended = NULL WHERE id = ?`, this.now(), row.id);
|
|
2143
|
+
this.audit("join", row.id, null, { handle: this.byId(row.id).handle });
|
|
2144
|
+
if (under !== void 0 && under !== null && under !== "") {
|
|
2145
|
+
const me = this.byId(row.id);
|
|
2146
|
+
const parent = this.resolve(under);
|
|
2147
|
+
if (parent.id !== me.parent_ep && parent.kind === "agent" && !this.mayMessage(me, parent)) throw unreachable(under);
|
|
2148
|
+
this.link(me, parent, { by: this.address(me), auth: "session" });
|
|
2149
|
+
}
|
|
2150
|
+
return this.whoami(row.id);
|
|
2151
|
+
});
|
|
2152
|
+
}
|
|
2153
|
+
/**
|
|
2154
|
+
* 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.
|
|
2155
|
+
* Only the human at a terminal links another session, or an agent from its own shell with a terminal attached, under itself by default.
|
|
2156
|
+
* A session links itself with {@link joinSession}'s `under`
|
|
2157
|
+
*/
|
|
2158
|
+
adopt(actor, params, options = {}) {
|
|
2159
|
+
return this.scopeTx(() => {
|
|
2160
|
+
const me = this.actorRow(actor);
|
|
2161
|
+
if (actor.kind === "human") this.needTerminal(actor, me, params.endpoint);
|
|
2162
|
+
else if (options.tty !== true)
|
|
2163
|
+
throw this.scopeRefused(
|
|
2164
|
+
actor,
|
|
2165
|
+
me,
|
|
2166
|
+
{ target: String(params.endpoint), reason: "adopt" },
|
|
2167
|
+
`only the human at a terminal links another session under a parent; the session links itself with ${CLI_NAME} join --under`
|
|
2168
|
+
);
|
|
2169
|
+
const target = this.resolve(params.endpoint);
|
|
2170
|
+
if (target.kind !== "agent" || !this.localRow(target)) throw new RelayError("invalid_params", "only a local agent endpoint can be linked");
|
|
2171
|
+
const parentAddress = params.parent ?? (actor.kind === "human" ? void 0 : this.address(me));
|
|
2172
|
+
if (parentAddress === void 0 || parentAddress === null || parentAddress === "")
|
|
2173
|
+
throw new RelayError("invalid_params", "the human names the parent to link under");
|
|
2174
|
+
const parent = this.resolve(parentAddress);
|
|
2175
|
+
this.link(target, parent, this.actorDetail(actor, me));
|
|
2176
|
+
return { endpoint: this.whoami(target.id), parent: this.address(parent) };
|
|
2177
|
+
});
|
|
2178
|
+
}
|
|
2179
|
+
/**
|
|
2180
|
+
* a linked agent back to a root: its subtree moves along and the policy's child scope comes off. The human at a terminal unlinks any local
|
|
2181
|
+
* agent; an agent only itself, and only when the policy's `defaults.unlink` is `self`, since a worker detaching widens its own scope
|
|
2182
|
+
*/
|
|
2183
|
+
unlink(actor, params = {}) {
|
|
2184
|
+
return this.scopeTx(() => {
|
|
2185
|
+
const me = this.actorRow(actor);
|
|
2186
|
+
if (actor.kind === "human") this.needTerminal(actor, me, params.endpoint);
|
|
2187
|
+
else if (actor.auth === "lineage") throw new RelayError("scope_refused", "only the session itself unlinks it: run this from the session's own shell");
|
|
2188
|
+
const target = params.endpoint === void 0 ? me : this.resolve(params.endpoint);
|
|
2189
|
+
if (actor.kind !== "human" && target.id !== me.id) throw new RelayError("scope_refused", "an agent unlinks only itself");
|
|
2190
|
+
if (target.kind !== "agent" || !this.localRow(target)) throw new RelayError("invalid_params", "only a local agent endpoint can be unlinked");
|
|
2191
|
+
if (target.parent_ep === null) throw new RelayError("invalid_params", `${this.address(target)} is a root: it has no parent`);
|
|
2192
|
+
if (actor.kind !== "human" && this.policy?.defaults?.unlink !== "self")
|
|
2193
|
+
throw this.scopeRefused(
|
|
2194
|
+
actor,
|
|
2195
|
+
me,
|
|
2196
|
+
{ target: this.address(target), reason: "unlink" },
|
|
2197
|
+
"unlinking widens a worker's scope: only the human at a terminal unlinks it (policy defaults.unlink: self lets it)"
|
|
2198
|
+
);
|
|
2199
|
+
const parent = this.byId(target.parent_ep);
|
|
2200
|
+
const from = parent ? this.address(parent) : target.parent_ep;
|
|
2201
|
+
const shift = target.depth ?? 0;
|
|
2202
|
+
this.sql.all(
|
|
2203
|
+
`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)
|
|
2204
|
+
UPDATE endpoints SET tree = ?, depth = depth - ? WHERE id IN (SELECT id FROM below)`,
|
|
2205
|
+
target.id,
|
|
2206
|
+
target.id,
|
|
2207
|
+
shift
|
|
2208
|
+
);
|
|
2209
|
+
const caps = this.capsOf(target);
|
|
2210
|
+
const kept = caps.caps.filter((cap) => cap.by !== "policy");
|
|
2211
|
+
this.sql.all(
|
|
2212
|
+
`UPDATE endpoints SET tree = NULL, parent_ep = NULL, depth = 0, linked = NULL, caps = ? WHERE id = ?`,
|
|
2213
|
+
kept.length || Object.keys(caps.allow).length ? JSON.stringify({ ...caps, caps: kept }) : null,
|
|
2214
|
+
target.id
|
|
2215
|
+
);
|
|
2216
|
+
this.scopeMemo?.ancestors.clear();
|
|
2217
|
+
this.scopeMemo?.limited.clear();
|
|
2218
|
+
this.audit("unlink", target.id, null, { ...this.actorDetail(actor, me), target: this.address(target), parent: from });
|
|
2219
|
+
return { endpoint: this.whoami(target.id), parent: from };
|
|
2220
|
+
});
|
|
2221
|
+
}
|
|
2222
|
+
/** `row`, a root, and its subtree moved under `parent`, a local agent that is neither `row` nor one of its descendants */
|
|
2223
|
+
link(row, parent, by) {
|
|
2224
|
+
if (parent.kind !== "agent" || !this.localRow(parent)) throw new RelayError("invalid_params", "the parent is a local agent endpoint");
|
|
2225
|
+
if (row.parent_ep === parent.id) return;
|
|
2226
|
+
if (parent.id === row.id || this.ancestorsOf(parent).includes(row.id))
|
|
2227
|
+
throw new RelayError("scope_refused", `${this.address(parent)} sits under ${this.address(row)}: a session cannot go under its own descendant`);
|
|
2228
|
+
if (row.parent_ep !== null) {
|
|
2229
|
+
const now = this.byId(row.parent_ep);
|
|
2230
|
+
throw new RelayError("scope_refused", `${this.address(row)} already sits under ${now ? this.address(now) : "another agent"}; unlink it first`);
|
|
2231
|
+
}
|
|
2232
|
+
const depth = (parent.depth ?? 0) + 1;
|
|
2233
|
+
const deepest = one(this.sql, `SELECT MAX(depth) AS d FROM endpoints WHERE tree = ?`, row.id)?.d ?? 0;
|
|
2234
|
+
if (deepest + depth > LIMITS.depthMax)
|
|
2235
|
+
throw new RelayError("scope_refused", `${this.address(row)} would sit deeper than ${LIMITS.depthMax} under ${this.address(parent)}`);
|
|
2236
|
+
const tree = parent.tree ?? parent.id;
|
|
2237
|
+
this.sql.all(`UPDATE endpoints SET tree = ?, depth = depth + ? WHERE tree = ?`, tree, depth, row.id);
|
|
2238
|
+
const caps = this.capsOf(row);
|
|
2239
|
+
const childDefault = this.defaults().childDefault;
|
|
2240
|
+
if (childDefault !== "open" && childDefault !== "inherit" && !caps.caps.some((cap) => cap.by === "policy"))
|
|
2241
|
+
for (const dir of this.presetDirs()) caps.caps.push({ id: capId(), dir, by: "policy", sels: PRESET_LISTS[childDefault].map((sel) => ({ sel })) });
|
|
2242
|
+
this.sql.all(
|
|
2243
|
+
`UPDATE endpoints SET tree = ?, parent_ep = ?, depth = ?, linked = ?, caps = ? WHERE id = ?`,
|
|
2244
|
+
tree,
|
|
2245
|
+
parent.id,
|
|
2246
|
+
depth,
|
|
2247
|
+
this.now(),
|
|
2248
|
+
caps.caps.length || Object.keys(caps.allow).length ? JSON.stringify(caps) : null,
|
|
2249
|
+
row.id
|
|
2250
|
+
);
|
|
2251
|
+
this.scopeMemo?.ancestors.clear();
|
|
2252
|
+
this.scopeMemo?.limited.clear();
|
|
2253
|
+
this.audit("adopt", row.id, null, { ...by, target: this.address(row), parent: this.address(parent) });
|
|
2254
|
+
}
|
|
2255
|
+
/**
|
|
2256
|
+
* renames an agent in place: its id, mail, threads, status, title and relations stay. An agent renames only itself, and only a root unless
|
|
2257
|
+
* 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
|
|
2258
|
+
*/
|
|
2259
|
+
rename(actor, params) {
|
|
2260
|
+
return this.scopeTx(() => {
|
|
2261
|
+
const me = this.actorRow(actor);
|
|
2262
|
+
if (actor.kind === "human") this.needTerminal(actor, me, params.endpoint);
|
|
2263
|
+
else if (actor.auth === "lineage") throw new RelayError("scope_refused", "only the session itself renames it: run this from the session's own shell");
|
|
2264
|
+
const target = params.endpoint === void 0 ? me : this.resolve(params.endpoint);
|
|
2265
|
+
if (actor.kind !== "human" && target.id !== me.id) throw new RelayError("scope_refused", "an agent renames only itself");
|
|
2266
|
+
if (target.kind !== "agent" || !this.localRow(target)) throw new RelayError("invalid_params", "only a local agent endpoint can be renamed");
|
|
2267
|
+
this.renameRow(target, params.handle, actor.kind === "human");
|
|
2268
|
+
return { endpoint: this.whoami(target.id), from: target.handle, session: target.session };
|
|
2269
|
+
});
|
|
2270
|
+
}
|
|
2271
|
+
/** the endpoint's handle set to `value`, with the stored `ep:` and `fleet:` selectors that named it */
|
|
2272
|
+
renameRow(row, value, byHuman) {
|
|
2273
|
+
const handle = typeof value === "string" ? value.trim().replace(/^me\//, "") : "";
|
|
2274
|
+
if (!validHandle(handle)) throw new RelayError("invalid_params", `handle ${JSON.stringify(handle)} is not valid: lowercase letters, digits, . _ -`);
|
|
2275
|
+
if (findSecret(handle)) throw new RelayError("secret_refused", "the handle looks like it carries a credential");
|
|
2276
|
+
if (handle === row.handle) {
|
|
2277
|
+
this.sql.all(`UPDATE endpoints SET named = COALESCE(named, ?) WHERE id = ?`, this.now(), row.id);
|
|
2278
|
+
return;
|
|
2279
|
+
}
|
|
2280
|
+
if (!byHuman && row.parent_ep !== null && this.policy?.defaults?.rename !== "self") {
|
|
2281
|
+
const parent = this.byId(row.parent_ep);
|
|
2282
|
+
throw new RelayError("scope_refused", `spawned by ${parent ? this.address(parent) : "another agent"}; its name is set by the spawner`);
|
|
2283
|
+
}
|
|
2284
|
+
const held = this.byHandle(handle);
|
|
2285
|
+
if (held) {
|
|
2286
|
+
const how = held.kind !== "agent" ? "reserved" : presenceOf(held, this.now()) === "offline" ? "held by an offline endpoint" : "held by a live endpoint";
|
|
2287
|
+
throw new RelayError("invalid_params", `${handle} is ${how}: pick another name`);
|
|
2288
|
+
}
|
|
2289
|
+
const from = row.handle;
|
|
2290
|
+
this.sql.all(`UPDATE endpoints SET handle = ?, named = ? WHERE id = ?`, handle, this.now(), row.id);
|
|
2291
|
+
this.sql.all(`UPDATE grants SET name = ? WHERE used_by = ? AND name = ?`, handle, row.id, from);
|
|
2292
|
+
for (const prefix of row.parent_ep === null ? ["ep:", "fleet:"] : ["ep:"]) {
|
|
2293
|
+
const [a, b] = [JSON.stringify(`${prefix}${from}`), JSON.stringify(`${prefix}${handle}`)];
|
|
2294
|
+
for (const [table, column] of [
|
|
2295
|
+
["endpoints", "rules"],
|
|
2296
|
+
["endpoints", "caps"],
|
|
2297
|
+
["groups", "rules"],
|
|
2298
|
+
["grants", "caps"]
|
|
2299
|
+
])
|
|
2300
|
+
this.sql.all(`UPDATE ${table} SET ${column} = REPLACE(${column}, ?, ?) WHERE instr(${column}, ?) > 0`, a, b, a);
|
|
2301
|
+
}
|
|
2302
|
+
this.audit("rename", row.id, null, { from, to: handle });
|
|
2303
|
+
}
|
|
2304
|
+
/**
|
|
2305
|
+
* 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
|
|
2306
|
+
* itself, the human at a terminal any local root or linked agent. A spawned worker stays joined while it runs
|
|
2307
|
+
*/
|
|
2308
|
+
leaveSession(actor, params = {}) {
|
|
2309
|
+
return this.scopeTx(() => {
|
|
2310
|
+
const me = this.actorRow(actor);
|
|
2311
|
+
if (actor.kind === "human") this.needTerminal(actor, me, params.endpoint);
|
|
2312
|
+
else if (actor.auth === "lineage") throw new RelayError("scope_refused", "only the session itself leaves: run this from the session's own shell");
|
|
2313
|
+
const target = params.endpoint === void 0 ? me : this.resolve(params.endpoint);
|
|
2314
|
+
if (actor.kind !== "human" && target.id !== me.id) throw new RelayError("scope_refused", "an agent leaves only for itself");
|
|
2315
|
+
if (target.kind !== "agent" || !this.localRow(target)) throw new RelayError("invalid_params", "only a local agent endpoint can leave");
|
|
2316
|
+
if (target.parent_ep !== null && target.linked === null) {
|
|
2317
|
+
const parent = this.byId(target.parent_ep);
|
|
2318
|
+
throw new RelayError("scope_refused", `spawned by ${parent ? this.address(parent) : "another agent"}; a spawned worker stays joined while it runs`);
|
|
2319
|
+
}
|
|
2320
|
+
this.sql.all(`UPDATE endpoints SET session = NULL, token_hash = NULL, hook_seen = NULL, hook_ended = ? WHERE id = ?`, this.now(), target.id);
|
|
2321
|
+
this.audit("leave", target.id, null, { handle: target.handle });
|
|
2322
|
+
return { endpoint: this.whoami(target.id), session: target.session };
|
|
2323
|
+
});
|
|
2324
|
+
}
|
|
2325
|
+
/** the harness session key an endpoint holds, if any */
|
|
2326
|
+
sessionOf(endpoint) {
|
|
2327
|
+
return this.byId(endpoint)?.session ?? null;
|
|
2328
|
+
}
|
|
2085
2329
|
// messages
|
|
2086
2330
|
/**
|
|
2087
2331
|
* `origin.tty` is recorded on messages the human inbox sends: whether its client said a terminal was attached. `origin.unverified`: the human
|
|
@@ -2139,6 +2383,13 @@ var RelayCore = class {
|
|
|
2139
2383
|
resolved.push(row);
|
|
2140
2384
|
}
|
|
2141
2385
|
const recipients = [...new Map(resolved.map((row) => [row.id, row])).values()];
|
|
2386
|
+
if (params.live !== void 0 && typeof params.live !== "boolean") throw new RelayError("invalid_params", "live must be true or false");
|
|
2387
|
+
const dark = recipients.filter(
|
|
2388
|
+
(r) => r.kind === "agent" && presenceOf(r, now) === "offline" && (now - Math.max(r.last_seen ?? 0, r.hook_seen ?? 0) >= ASK_ENDED_GRACE_MS || r.session === null && r.token_hash === null && r.hook_ended !== null) && !this.unjoinedSpawn(sender, r.handle, now)
|
|
2389
|
+
);
|
|
2390
|
+
if (params.live === true && dark.length)
|
|
2391
|
+
throw new RelayError("not_live", `no live session holds ${dark.map((r) => this.address(r)).join(", ")}: nothing was queued`);
|
|
2392
|
+
const warnings = dark.map((r) => `queued: no live session holds ${this.address(r)}`);
|
|
2142
2393
|
const pending = [...unjoined.values()].map((grant) => ({ key: pendingKey(grant.token_hash), address: `${this.user}/${grant.name}` }));
|
|
2143
2394
|
const reached = recipients.length + pending.length;
|
|
2144
2395
|
if (reached > LIMITS.fanOutTotal) throw new RelayError("too_large", `the send reaches ${reached} endpoints, more than ${LIMITS.fanOutTotal}`);
|
|
@@ -2356,7 +2607,7 @@ var RelayCore = class {
|
|
|
2356
2607
|
deliveries.push({ endpoint: key, address, seq, offline: false, queued: true, last_seen: null, state: "starting" });
|
|
2357
2608
|
}
|
|
2358
2609
|
for (const name of fanOut) this.sql.all(`INSERT INTO tree_events (tree, at) VALUES (?, ?)`, `group:${name}`, now);
|
|
2359
|
-
const sent = { id, thread, hop, deliveries };
|
|
2610
|
+
const sent = { id, thread, hop, deliveries, ...warnings.length ? { warnings } : {} };
|
|
2360
2611
|
if (clientId) {
|
|
2361
2612
|
this.sql.all(
|
|
2362
2613
|
`INSERT OR REPLACE INTO idempotency (from_ep, client_id, payload_hash, result, created_at) VALUES (?, ?, ?, ?, ?)`,
|
|
@@ -2494,6 +2745,7 @@ var RelayCore = class {
|
|
|
2494
2745
|
* `directive_seqs`. Any call says `brief` once the session was shown the full preamble ({@link briefed}), and one that is no stop names in
|
|
2495
2746
|
* `intro` the spawner of an endpoint a `swarm spawn` grant placed, with the grant's steer key in `intro_key`, until a hook showed it
|
|
2496
2747
|
* ({@link introduced}). A hook of another process than the one holding the endpoint (a child that inherited the session key) gets nothing.
|
|
2748
|
+
* A wake also offers live workers' messages.
|
|
2497
2749
|
*/
|
|
2498
2750
|
inject(endpoint, options = {}) {
|
|
2499
2751
|
const now = this.now();
|
|
@@ -2561,7 +2813,7 @@ var RelayCore = class {
|
|
|
2561
2813
|
now,
|
|
2562
2814
|
LIMITS.inboxPage
|
|
2563
2815
|
);
|
|
2564
|
-
const { shown, offered } = this.budget(rows, LIMITS.injectBytes, () => this.pushable(me, now));
|
|
2816
|
+
const { shown, offered } = this.budget(rows, LIMITS.injectBytes, options.wake ? void 0 : () => this.pushable(me, now));
|
|
2565
2817
|
const hold = this.hold(offered, now);
|
|
2566
2818
|
this.received(endpoint, shown, now);
|
|
2567
2819
|
return { messages: shown.map((row) => this.envelope(row, row.seq)), offered, hold, more: waiting - shown.length, unread, block: false, ...brief, ...intro };
|
|
@@ -2731,6 +2983,15 @@ var RelayCore = class {
|
|
|
2731
2983
|
this.settle(endpoint);
|
|
2732
2984
|
return true;
|
|
2733
2985
|
}
|
|
2986
|
+
/** whether a prompt should remind the agent to refresh its status note: `every` prompts passed since the note or the last reminder; counts the reminder */
|
|
2987
|
+
tldrDue(endpoint, every) {
|
|
2988
|
+
if (!Number.isInteger(every) || every <= 0) return false;
|
|
2989
|
+
const row = this.mustEndpoint(endpoint);
|
|
2990
|
+
const turn = row.prompts ?? 0;
|
|
2991
|
+
if (row.kind !== "agent" || turn - (row.tldr_turn ?? 0) < every) return false;
|
|
2992
|
+
this.sql.all(`UPDATE endpoints SET tldr_turn = ? WHERE id = ?`, turn, endpoint);
|
|
2993
|
+
return true;
|
|
2994
|
+
}
|
|
2734
2995
|
/** the background hooks' showings ({@link injected}) older than ${LIMITS.deferredSettleMs} ms reached the agent: inbox no longer lists them */
|
|
2735
2996
|
settle(endpoint) {
|
|
2736
2997
|
this.sql.all(
|
|
@@ -3185,6 +3446,7 @@ var RelayCore = class {
|
|
|
3185
3446
|
this.sql.all(`DELETE FROM idempotency WHERE created_at <= ?`, now - LIMITS.idempotencyS * 1e3);
|
|
3186
3447
|
this.sql.all(`DELETE FROM pair_events WHERE at <= ?`, now - 3600 * 1e3);
|
|
3187
3448
|
this.sql.all(`DELETE FROM blocks WHERE until <= ?`, now);
|
|
3449
|
+
this.endExited(now);
|
|
3188
3450
|
this.settleOffline(now);
|
|
3189
3451
|
this.sql.all(
|
|
3190
3452
|
`DELETE FROM endpoints WHERE kind = 'agent' AND connections = 0 AND COALESCE(last_seen, created_at) <= ?
|
|
@@ -3210,6 +3472,16 @@ var RelayCore = class {
|
|
|
3210
3472
|
if (!root || root.handle.startsWith(GONE_PREFIX) || !seen) this.dropGroup(group.name);
|
|
3211
3473
|
}
|
|
3212
3474
|
}
|
|
3475
|
+
/** ends the hook presence of local agent endpoints whose harness process exited without a session_end, as one would */
|
|
3476
|
+
endExited(now = this.now()) {
|
|
3477
|
+
for (const row of this.sql.all(
|
|
3478
|
+
`SELECT * FROM endpoints WHERE kind = 'agent' AND connections = 0 AND hook_seen IS NOT NULL AND process IS NOT NULL`
|
|
3479
|
+
)) {
|
|
3480
|
+
const pid = Number(/^pid:(\d+)/.exec(row.process)?.[1]);
|
|
3481
|
+
if (!pid || !this.localRow(row) || this.alive(pid)) continue;
|
|
3482
|
+
this.sql.all(`UPDATE endpoints SET hook_seen = NULL, hook_ended = ?, session = NULL WHERE id = ? AND connections = 0`, now, row.id);
|
|
3483
|
+
}
|
|
3484
|
+
}
|
|
3213
3485
|
/** an agent endpoint left offline drops a working or waiting status, which no one is left to keep current */
|
|
3214
3486
|
settleOffline(now) {
|
|
3215
3487
|
for (const row of this.sql.all(
|
|
@@ -3263,15 +3535,19 @@ var RelayCore = class {
|
|
|
3263
3535
|
endpoint
|
|
3264
3536
|
);
|
|
3265
3537
|
} else if (params.kind === "session_start") {
|
|
3538
|
+
const fresh = own && summary !== "compact" && summary !== "resume";
|
|
3266
3539
|
this.sql.all(
|
|
3267
3540
|
`UPDATE endpoints SET hook_seen = COALESCE(?, hook_seen), hook_ended = NULL, stop_blocks = CASE WHEN ? THEN 0 ELSE stop_blocks END, pushes = CASE WHEN ? THEN 0 ELSE pushes END,
|
|
3268
|
-
briefed = CASE WHEN ? THEN NULL ELSE briefed END, introduced = CASE WHEN ? THEN NULL ELSE introduced END, directive_holds = CASE WHEN ? THEN NULL ELSE directive_holds END
|
|
3541
|
+
briefed = CASE WHEN ? THEN NULL ELSE briefed END, introduced = CASE WHEN ? THEN NULL ELSE introduced END, directive_holds = CASE WHEN ? THEN NULL ELSE directive_holds END,
|
|
3542
|
+
title = CASE WHEN ? AND title = title_auto THEN NULL ELSE title END, title_auto = CASE WHEN ? THEN NULL ELSE title_auto END WHERE id = ?`,
|
|
3269
3543
|
options.transient ? now : null,
|
|
3270
3544
|
own ? 1 : 0,
|
|
3271
3545
|
own ? 1 : 0,
|
|
3272
3546
|
own ? 1 : 0,
|
|
3273
3547
|
own ? 1 : 0,
|
|
3274
3548
|
own ? 1 : 0,
|
|
3549
|
+
fresh ? 1 : 0,
|
|
3550
|
+
fresh ? 1 : 0,
|
|
3275
3551
|
endpoint
|
|
3276
3552
|
);
|
|
3277
3553
|
} else if (options.transient) {
|
|
@@ -4985,7 +5261,7 @@ ${stderr}` : "";
|
|
|
4985
5261
|
}
|
|
4986
5262
|
/** the user parts an address of this core may carry */
|
|
4987
5263
|
addressUsers() {
|
|
4988
|
-
return [this.user, "me"];
|
|
5264
|
+
return [this.user, "me", "local"];
|
|
4989
5265
|
}
|
|
4990
5266
|
/** whether the row is this host's own, the only rows with a tree, groups, rules and caps here */
|
|
4991
5267
|
localRow(_row) {
|
|
@@ -4997,7 +5273,7 @@ ${stderr}` : "";
|
|
|
4997
5273
|
const slash = text.indexOf("/");
|
|
4998
5274
|
const user = slash === -1 ? null : text.slice(0, slash);
|
|
4999
5275
|
const handle = slash === -1 ? text : text.slice(slash + 1);
|
|
5000
|
-
if (user !== null && user !== "me" && user !== this.user) throw unreachable(text);
|
|
5276
|
+
if (user !== null && user !== "me" && user !== "local" && user !== this.user) throw unreachable(text);
|
|
5001
5277
|
const row = handle === "" ? this.byHandle(HUMAN_HANDLE) : validHandle(handle) ? this.byHandle(handle) : void 0;
|
|
5002
5278
|
if (!row) throw unreachable(text);
|
|
5003
5279
|
return row;
|
|
@@ -6054,6 +6330,7 @@ export {
|
|
|
6054
6330
|
SHORT_ID,
|
|
6055
6331
|
STATUS_STATES,
|
|
6056
6332
|
SYSTEM_HANDLE,
|
|
6333
|
+
TITLE_CHARS,
|
|
6057
6334
|
VerdictCache,
|
|
6058
6335
|
XCounters,
|
|
6059
6336
|
XLIMITS,
|
package/package.json
CHANGED
package/src/core.ts
CHANGED
|
@@ -69,6 +69,8 @@ import { one, type Sql, type SqlValue } from "./sql"
|
|
|
69
69
|
import { utf8Bytes } from "./utf8"
|
|
70
70
|
|
|
71
71
|
export const HUMAN_HANDLE = "_human"
|
|
72
|
+
/** an agent title's length cap */
|
|
73
|
+
export const TITLE_CHARS = 64
|
|
72
74
|
export const SYSTEM_HANDLE = "_relay"
|
|
73
75
|
export const GONE_PREFIX = "_gone_"
|
|
74
76
|
|
|
@@ -240,12 +242,22 @@ export interface EndpointRow {
|
|
|
240
242
|
/** JSON {@link ScopeCaps} */
|
|
241
243
|
caps: string | null
|
|
242
244
|
title: string | null
|
|
245
|
+
/** the title a hook last inferred: the title is inferred while it equals this */
|
|
246
|
+
title_auto: string | null
|
|
247
|
+
/** the prompt count when a summary last set the inferred title; null when the first prompt set it */
|
|
248
|
+
title_turn: number | null
|
|
249
|
+
/** the prompt count when the status note last changed or a hook last reminded the agent to refresh it */
|
|
250
|
+
tldr_turn: number | null
|
|
243
251
|
/** when a new spawn last took the endpoint over: mail and messages from before it were the earlier holder's */
|
|
244
252
|
reset_at: number | null
|
|
245
253
|
/** when a new spawn last took the endpoint over: with its id, names the holder another machine sees */
|
|
246
254
|
spawned_at: number | null
|
|
247
255
|
/** who may peek: null is off */
|
|
248
256
|
peek: PeekLevel | null
|
|
257
|
+
/** when the session last set its handle itself (join or rename): a later claim with another handle does not rename it back */
|
|
258
|
+
named: number | null
|
|
259
|
+
/** when the session itself or the human linked it under a parent by hand: a later root claim of it takes it back where it sits */
|
|
260
|
+
linked: number | null
|
|
249
261
|
}
|
|
250
262
|
|
|
251
263
|
export interface MessageRow {
|
|
@@ -442,6 +454,8 @@ export class RelayCore {
|
|
|
442
454
|
private readonly capEverySender: boolean
|
|
443
455
|
private readonly alive: (pid: number) => boolean
|
|
444
456
|
private readonly hostMachine: string | null
|
|
457
|
+
private readonly namedMachine: string | null
|
|
458
|
+
private readonly hostname: string | null
|
|
445
459
|
private policy: Policy | null = null
|
|
446
460
|
/** the identity each policy `group:<name>` naming no policy group bound to when the policy's text first loaded: that group's then, or `none` */
|
|
447
461
|
private policyBinds = new Map<string, string>()
|
|
@@ -461,6 +475,14 @@ export class RelayCore {
|
|
|
461
475
|
this.capEverySender = options.capEverySender === true
|
|
462
476
|
this.alive = options.alive ?? (() => true)
|
|
463
477
|
this.hostMachine = options.machine ?? options.hostname ?? null
|
|
478
|
+
this.namedMachine = options.machine ?? null
|
|
479
|
+
this.hostname = options.hostname ?? null
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** a `machine` label naming this host by its hostname, as clients default it, names it by its machine name when one is set */
|
|
483
|
+
private machineLabel(labels: Record<string, string>): Record<string, string> {
|
|
484
|
+
if (this.namedMachine !== null && labels.machine !== undefined && labels.machine === this.hostname) labels.machine = this.namedMachine
|
|
485
|
+
return labels
|
|
464
486
|
}
|
|
465
487
|
|
|
466
488
|
migrate(): void {
|
|
@@ -496,6 +518,11 @@ export class RelayCore {
|
|
|
496
518
|
["directive_holds", "INTEGER"],
|
|
497
519
|
["harness_session", "TEXT"],
|
|
498
520
|
["harness_transcript", "TEXT"],
|
|
521
|
+
["title_auto", "TEXT"],
|
|
522
|
+
["title_turn", "INTEGER"],
|
|
523
|
+
["tldr_turn", "INTEGER"],
|
|
524
|
+
["named", "INTEGER"],
|
|
525
|
+
["linked", "INTEGER"],
|
|
499
526
|
] as const) {
|
|
500
527
|
if (!endpointColumns.includes(column)) this.sql.all(`ALTER TABLE endpoints ADD COLUMN ${column} ${type}`)
|
|
501
528
|
}
|
|
@@ -658,7 +685,7 @@ export class RelayCore {
|
|
|
658
685
|
model: cleanText(claim.model, "model", 128),
|
|
659
686
|
cwd: cleanText(claim.cwd, "cwd", 1024),
|
|
660
687
|
parent: cleanText(claim.parent, "parent", 128),
|
|
661
|
-
labels: JSON.stringify(cleanLabels(claim.labels)),
|
|
688
|
+
labels: JSON.stringify(this.machineLabel(cleanLabels(claim.labels))),
|
|
662
689
|
session: cleanText(claim.session, "session", 128),
|
|
663
690
|
process: cleanText(claim.process, "process", 128),
|
|
664
691
|
}
|
|
@@ -773,6 +800,7 @@ export class RelayCore {
|
|
|
773
800
|
scope: ClaimScope = { handle: wanted, lineage: [], grant: null, rules: {}, groups: [], cwd: fields.cwd },
|
|
774
801
|
): { endpoint: EndpointView; token?: string; created?: true } {
|
|
775
802
|
const now = this.now()
|
|
803
|
+
this.endExited(now)
|
|
776
804
|
const bare = transient && fields.session !== null && BARE_PID_KEY.test(fields.session)
|
|
777
805
|
let row = fields.session
|
|
778
806
|
? one<EndpointRow>(
|
|
@@ -798,7 +826,14 @@ export class RelayCore {
|
|
|
798
826
|
} else if (row && !transient && !sameProcess(row, fields.process)) {
|
|
799
827
|
row = undefined
|
|
800
828
|
session = null
|
|
801
|
-
} else if (
|
|
829
|
+
} else if (
|
|
830
|
+
row &&
|
|
831
|
+
!transient &&
|
|
832
|
+
row.token_hash === null &&
|
|
833
|
+
row.named === null &&
|
|
834
|
+
row.handle !== wanted &&
|
|
835
|
+
!row.handle.startsWith(`${wanted.slice(0, 58)}-`)
|
|
836
|
+
) {
|
|
802
837
|
const hooks = row
|
|
803
838
|
let settled = false
|
|
804
839
|
for (let n = 1; n < (scope.exact ? 2 : 1000) && !settled; n++) {
|
|
@@ -1079,7 +1114,7 @@ export class RelayCore {
|
|
|
1079
1114
|
*/
|
|
1080
1115
|
private restarts(row: EndpointRow, parent: EndpointRow | null, process: string | null): boolean {
|
|
1081
1116
|
if (parent?.id === row.id) return process !== null && row.process !== null && sameHarness(row.process, process)
|
|
1082
|
-
return (parent?.id ?? null) === row.parent_ep
|
|
1117
|
+
return (parent?.id ?? null) === row.parent_ep || (parent === null && row.linked !== null)
|
|
1083
1118
|
}
|
|
1084
1119
|
|
|
1085
1120
|
/**
|
|
@@ -1297,6 +1332,7 @@ export class RelayCore {
|
|
|
1297
1332
|
} = {},
|
|
1298
1333
|
): EndpointView[] {
|
|
1299
1334
|
const now = this.now()
|
|
1335
|
+
this.endExited(now)
|
|
1300
1336
|
this.settleOffline(now)
|
|
1301
1337
|
const cutoff = now - LIMITS.listOfflineWithinS * 1000
|
|
1302
1338
|
const viewer = filters.viewer === undefined ? undefined : this.mustEndpoint(filters.viewer)
|
|
@@ -1364,10 +1400,12 @@ export class RelayCore {
|
|
|
1364
1400
|
const text = oneLine(note, "note", LIMITS.noteChars)
|
|
1365
1401
|
if (text && findSecret(text)) throw this.refuse(endpoint, "secret_refused", "the status note looks like it carries a credential")
|
|
1366
1402
|
this.sql.all(
|
|
1367
|
-
`UPDATE endpoints SET status_state = COALESCE(?, status_state, 'working'), status_note = COALESCE(?, status_note, ''), last_seen =
|
|
1403
|
+
`UPDATE endpoints SET status_state = COALESCE(?, status_state, 'working'), status_note = COALESCE(?, status_note, ''), last_seen = ?,
|
|
1404
|
+
tldr_turn = CASE WHEN ? THEN COALESCE(prompts, 0) ELSE tldr_turn END WHERE id = ?`,
|
|
1368
1405
|
state ?? null,
|
|
1369
1406
|
text,
|
|
1370
1407
|
this.now(),
|
|
1408
|
+
text === null ? 0 : 1,
|
|
1371
1409
|
endpoint,
|
|
1372
1410
|
)
|
|
1373
1411
|
}
|
|
@@ -1378,11 +1416,237 @@ export class RelayCore {
|
|
|
1378
1416
|
if (row.kind !== "agent") throw new RelayError("invalid_params", "only an agent endpoint has a title")
|
|
1379
1417
|
const title = oneLine(value, "title", 64) || null
|
|
1380
1418
|
if (title && findSecret(title)) throw this.refuse(endpoint, "secret_refused", "the title looks like it carries a credential")
|
|
1381
|
-
this.sql.all(`UPDATE endpoints SET title =
|
|
1419
|
+
this.sql.all(`UPDATE endpoints SET title = ?, title_auto = NULL, title_turn = NULL WHERE id = ?`, title, endpoint)
|
|
1382
1420
|
this.audit("title", endpoint, null, { title })
|
|
1383
1421
|
return this.whoami(endpoint)
|
|
1384
1422
|
}
|
|
1385
1423
|
|
|
1424
|
+
/**
|
|
1425
|
+
* a hook's guess at the session's title, from the harness's own summary or the first prompt's first line; whether it changed the title.
|
|
1426
|
+
* Never over a title set explicitly; the first prompt fills only an empty title, and a summary replaces an inferred one at most every
|
|
1427
|
+
* ${LIMITS.titleRefreshTurns} prompts, or at once over the first prompt's
|
|
1428
|
+
*/
|
|
1429
|
+
inferTitle(endpoint: string, params: { text?: unknown; from?: unknown }, options: { transient?: boolean; process?: string } = {}): boolean {
|
|
1430
|
+
if (params.from !== "summary" && params.from !== "prompt") throw new RelayError("invalid_params", "from is summary or prompt")
|
|
1431
|
+
if (typeof params.text !== "string") throw new RelayError("invalid_params", "text must be a string")
|
|
1432
|
+
const row = this.mustEndpoint(endpoint)
|
|
1433
|
+
if (row.kind !== "agent" || !this.holds(row, options)) return false
|
|
1434
|
+
const title = stripControl(redactSecrets(params.text.slice(0, 4096).replace(/\s+/g, " ").trim()))
|
|
1435
|
+
.slice(0, TITLE_CHARS)
|
|
1436
|
+
.trim()
|
|
1437
|
+
if (!title || findSecret(title) || title === row.title) return false
|
|
1438
|
+
if (row.title !== null && row.title !== row.title_auto) return false
|
|
1439
|
+
const turn = row.prompts ?? 0
|
|
1440
|
+
if (params.from === "prompt" && row.title !== null) return false
|
|
1441
|
+
if (params.from === "summary" && row.title !== null && row.title_turn !== null && turn - row.title_turn < LIMITS.titleRefreshTurns) return false
|
|
1442
|
+
this.sql.all(`UPDATE endpoints SET title = ?, title_auto = ?, title_turn = ? WHERE id = ?`, title, title, params.from === "summary" ? turn : null, endpoint)
|
|
1443
|
+
return true
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
/**
|
|
1447
|
+
* a session joins on demand: its hook claim registers or takes back its endpoint, marked seen now even right after a leave, and `handle`,
|
|
1448
|
+
* when given, renames it in place ({@link rename}'s rules). Nothing is kept when the rename is refused
|
|
1449
|
+
*/
|
|
1450
|
+
joinSession(claim: Claim, handle?: unknown, under?: unknown): EndpointView {
|
|
1451
|
+
return this.scopeTx(() => {
|
|
1452
|
+
const { endpoint } = this.claim(claim, { transient: true })
|
|
1453
|
+
const row = this.mustEndpoint(endpoint.id)
|
|
1454
|
+
if (handle !== undefined && handle !== null && handle !== "") this.renameRow(row, handle, false)
|
|
1455
|
+
this.sql.all(`UPDATE endpoints SET hook_seen = ?, hook_ended = NULL WHERE id = ?`, this.now(), row.id)
|
|
1456
|
+
this.audit("join", row.id, null, { handle: this.byId(row.id)!.handle })
|
|
1457
|
+
if (under !== undefined && under !== null && under !== "") {
|
|
1458
|
+
const me = this.byId(row.id)!
|
|
1459
|
+
const parent = this.resolve(under)
|
|
1460
|
+
if (parent.id !== me.parent_ep && parent.kind === "agent" && !this.mayMessage(me, parent)) throw unreachable(under)
|
|
1461
|
+
this.link(me, parent, { by: this.address(me), auth: "session" })
|
|
1462
|
+
}
|
|
1463
|
+
return this.whoami(row.id)
|
|
1464
|
+
})
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
/**
|
|
1468
|
+
* 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.
|
|
1469
|
+
* Only the human at a terminal links another session, or an agent from its own shell with a terminal attached, under itself by default.
|
|
1470
|
+
* A session links itself with {@link joinSession}'s `under`
|
|
1471
|
+
*/
|
|
1472
|
+
adopt(actor: ScopeActor, params: { endpoint: unknown; parent?: unknown }, options: { tty?: boolean } = {}): { endpoint: EndpointView; parent: string } {
|
|
1473
|
+
return this.scopeTx(() => {
|
|
1474
|
+
const me = this.actorRow(actor)
|
|
1475
|
+
if (actor.kind === "human") this.needTerminal(actor, me, params.endpoint)
|
|
1476
|
+
else if (options.tty !== true)
|
|
1477
|
+
throw this.scopeRefused(
|
|
1478
|
+
actor,
|
|
1479
|
+
me,
|
|
1480
|
+
{ target: String(params.endpoint), reason: "adopt" },
|
|
1481
|
+
`only the human at a terminal links another session under a parent; the session links itself with ${CLI_NAME} join --under`,
|
|
1482
|
+
)
|
|
1483
|
+
const target = this.resolve(params.endpoint)
|
|
1484
|
+
if (target.kind !== "agent" || !this.localRow(target)) throw new RelayError("invalid_params", "only a local agent endpoint can be linked")
|
|
1485
|
+
const parentAddress = params.parent ?? (actor.kind === "human" ? undefined : this.address(me))
|
|
1486
|
+
if (parentAddress === undefined || parentAddress === null || parentAddress === "")
|
|
1487
|
+
throw new RelayError("invalid_params", "the human names the parent to link under")
|
|
1488
|
+
const parent = this.resolve(parentAddress)
|
|
1489
|
+
this.link(target, parent, this.actorDetail(actor, me))
|
|
1490
|
+
return { endpoint: this.whoami(target.id), parent: this.address(parent) }
|
|
1491
|
+
})
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
/**
|
|
1495
|
+
* a linked agent back to a root: its subtree moves along and the policy's child scope comes off. The human at a terminal unlinks any local
|
|
1496
|
+
* agent; an agent only itself, and only when the policy's `defaults.unlink` is `self`, since a worker detaching widens its own scope
|
|
1497
|
+
*/
|
|
1498
|
+
unlink(actor: ScopeActor, params: { endpoint?: unknown } = {}): { endpoint: EndpointView; parent: string } {
|
|
1499
|
+
return this.scopeTx(() => {
|
|
1500
|
+
const me = this.actorRow(actor)
|
|
1501
|
+
if (actor.kind === "human") this.needTerminal(actor, me, params.endpoint)
|
|
1502
|
+
else if (actor.auth === "lineage") throw new RelayError("scope_refused", "only the session itself unlinks it: run this from the session's own shell")
|
|
1503
|
+
const target = params.endpoint === undefined ? me : this.resolve(params.endpoint)
|
|
1504
|
+
if (actor.kind !== "human" && target.id !== me.id) throw new RelayError("scope_refused", "an agent unlinks only itself")
|
|
1505
|
+
if (target.kind !== "agent" || !this.localRow(target)) throw new RelayError("invalid_params", "only a local agent endpoint can be unlinked")
|
|
1506
|
+
if (target.parent_ep === null) throw new RelayError("invalid_params", `${this.address(target)} is a root: it has no parent`)
|
|
1507
|
+
if (actor.kind !== "human" && this.policy?.defaults?.unlink !== "self")
|
|
1508
|
+
throw this.scopeRefused(
|
|
1509
|
+
actor,
|
|
1510
|
+
me,
|
|
1511
|
+
{ target: this.address(target), reason: "unlink" },
|
|
1512
|
+
"unlinking widens a worker's scope: only the human at a terminal unlinks it (policy defaults.unlink: self lets it)",
|
|
1513
|
+
)
|
|
1514
|
+
const parent = this.byId(target.parent_ep)
|
|
1515
|
+
const from = parent ? this.address(parent) : target.parent_ep
|
|
1516
|
+
const shift = target.depth ?? 0
|
|
1517
|
+
this.sql.all(
|
|
1518
|
+
`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)
|
|
1519
|
+
UPDATE endpoints SET tree = ?, depth = depth - ? WHERE id IN (SELECT id FROM below)`,
|
|
1520
|
+
target.id,
|
|
1521
|
+
target.id,
|
|
1522
|
+
shift,
|
|
1523
|
+
)
|
|
1524
|
+
const caps = this.capsOf(target)
|
|
1525
|
+
const kept = caps.caps.filter((cap) => cap.by !== "policy")
|
|
1526
|
+
this.sql.all(
|
|
1527
|
+
`UPDATE endpoints SET tree = NULL, parent_ep = NULL, depth = 0, linked = NULL, caps = ? WHERE id = ?`,
|
|
1528
|
+
kept.length || Object.keys(caps.allow).length ? JSON.stringify({ ...caps, caps: kept }) : null,
|
|
1529
|
+
target.id,
|
|
1530
|
+
)
|
|
1531
|
+
this.scopeMemo?.ancestors.clear()
|
|
1532
|
+
this.scopeMemo?.limited.clear()
|
|
1533
|
+
this.audit("unlink", target.id, null, { ...this.actorDetail(actor, me), target: this.address(target), parent: from })
|
|
1534
|
+
return { endpoint: this.whoami(target.id), parent: from }
|
|
1535
|
+
})
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
/** `row`, a root, and its subtree moved under `parent`, a local agent that is neither `row` nor one of its descendants */
|
|
1539
|
+
private link(row: EndpointRow, parent: EndpointRow, by: { by: string; auth: string }): void {
|
|
1540
|
+
if (parent.kind !== "agent" || !this.localRow(parent)) throw new RelayError("invalid_params", "the parent is a local agent endpoint")
|
|
1541
|
+
if (row.parent_ep === parent.id) return
|
|
1542
|
+
if (parent.id === row.id || this.ancestorsOf(parent).includes(row.id))
|
|
1543
|
+
throw new RelayError("scope_refused", `${this.address(parent)} sits under ${this.address(row)}: a session cannot go under its own descendant`)
|
|
1544
|
+
if (row.parent_ep !== null) {
|
|
1545
|
+
const now = this.byId(row.parent_ep)
|
|
1546
|
+
throw new RelayError("scope_refused", `${this.address(row)} already sits under ${now ? this.address(now) : "another agent"}; unlink it first`)
|
|
1547
|
+
}
|
|
1548
|
+
const depth = (parent.depth ?? 0) + 1
|
|
1549
|
+
const deepest = one<{ d: number | null }>(this.sql, `SELECT MAX(depth) AS d FROM endpoints WHERE tree = ?`, row.id)?.d ?? 0
|
|
1550
|
+
if (deepest + depth > LIMITS.depthMax)
|
|
1551
|
+
throw new RelayError("scope_refused", `${this.address(row)} would sit deeper than ${LIMITS.depthMax} under ${this.address(parent)}`)
|
|
1552
|
+
const tree = parent.tree ?? parent.id
|
|
1553
|
+
this.sql.all(`UPDATE endpoints SET tree = ?, depth = depth + ? WHERE tree = ?`, tree, depth, row.id)
|
|
1554
|
+
const caps = this.capsOf(row)
|
|
1555
|
+
const childDefault = this.defaults().childDefault
|
|
1556
|
+
if (childDefault !== "open" && childDefault !== "inherit" && !caps.caps.some((cap) => cap.by === "policy"))
|
|
1557
|
+
for (const dir of this.presetDirs()) caps.caps.push({ id: capId(), dir, by: "policy", sels: PRESET_LISTS[childDefault].map((sel) => ({ sel })) })
|
|
1558
|
+
this.sql.all(
|
|
1559
|
+
`UPDATE endpoints SET tree = ?, parent_ep = ?, depth = ?, linked = ?, caps = ? WHERE id = ?`,
|
|
1560
|
+
tree,
|
|
1561
|
+
parent.id,
|
|
1562
|
+
depth,
|
|
1563
|
+
this.now(),
|
|
1564
|
+
caps.caps.length || Object.keys(caps.allow).length ? JSON.stringify(caps) : null,
|
|
1565
|
+
row.id,
|
|
1566
|
+
)
|
|
1567
|
+
this.scopeMemo?.ancestors.clear()
|
|
1568
|
+
this.scopeMemo?.limited.clear()
|
|
1569
|
+
this.audit("adopt", row.id, null, { ...by, target: this.address(row), parent: this.address(parent) })
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
/**
|
|
1573
|
+
* renames an agent in place: its id, mail, threads, status, title and relations stay. An agent renames only itself, and only a root unless
|
|
1574
|
+
* 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
|
|
1575
|
+
*/
|
|
1576
|
+
rename(actor: ScopeActor, params: { endpoint?: unknown; handle: unknown }): { endpoint: EndpointView; from: string; session: string | null } {
|
|
1577
|
+
return this.scopeTx(() => {
|
|
1578
|
+
const me = this.actorRow(actor)
|
|
1579
|
+
if (actor.kind === "human") this.needTerminal(actor, me, params.endpoint)
|
|
1580
|
+
else if (actor.auth === "lineage") throw new RelayError("scope_refused", "only the session itself renames it: run this from the session's own shell")
|
|
1581
|
+
const target = params.endpoint === undefined ? me : this.resolve(params.endpoint)
|
|
1582
|
+
if (actor.kind !== "human" && target.id !== me.id) throw new RelayError("scope_refused", "an agent renames only itself")
|
|
1583
|
+
if (target.kind !== "agent" || !this.localRow(target)) throw new RelayError("invalid_params", "only a local agent endpoint can be renamed")
|
|
1584
|
+
this.renameRow(target, params.handle, actor.kind === "human")
|
|
1585
|
+
return { endpoint: this.whoami(target.id), from: target.handle, session: target.session }
|
|
1586
|
+
})
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
/** the endpoint's handle set to `value`, with the stored `ep:` and `fleet:` selectors that named it */
|
|
1590
|
+
private renameRow(row: EndpointRow, value: unknown, byHuman: boolean): void {
|
|
1591
|
+
const handle = typeof value === "string" ? value.trim().replace(/^me\//, "") : ""
|
|
1592
|
+
if (!validHandle(handle)) throw new RelayError("invalid_params", `handle ${JSON.stringify(handle)} is not valid: lowercase letters, digits, . _ -`)
|
|
1593
|
+
if (findSecret(handle)) throw new RelayError("secret_refused", "the handle looks like it carries a credential")
|
|
1594
|
+
if (handle === row.handle) {
|
|
1595
|
+
this.sql.all(`UPDATE endpoints SET named = COALESCE(named, ?) WHERE id = ?`, this.now(), row.id)
|
|
1596
|
+
return
|
|
1597
|
+
}
|
|
1598
|
+
if (!byHuman && row.parent_ep !== null && this.policy?.defaults?.rename !== "self") {
|
|
1599
|
+
const parent = this.byId(row.parent_ep)
|
|
1600
|
+
throw new RelayError("scope_refused", `spawned by ${parent ? this.address(parent) : "another agent"}; its name is set by the spawner`)
|
|
1601
|
+
}
|
|
1602
|
+
const held = this.byHandle(handle)
|
|
1603
|
+
if (held) {
|
|
1604
|
+
const how = held.kind !== "agent" ? "reserved" : presenceOf(held, this.now()) === "offline" ? "held by an offline endpoint" : "held by a live endpoint"
|
|
1605
|
+
throw new RelayError("invalid_params", `${handle} is ${how}: pick another name`)
|
|
1606
|
+
}
|
|
1607
|
+
const from = row.handle
|
|
1608
|
+
this.sql.all(`UPDATE endpoints SET handle = ?, named = ? WHERE id = ?`, handle, this.now(), row.id)
|
|
1609
|
+
this.sql.all(`UPDATE grants SET name = ? WHERE used_by = ? AND name = ?`, handle, row.id, from)
|
|
1610
|
+
for (const prefix of row.parent_ep === null ? ["ep:", "fleet:"] : ["ep:"]) {
|
|
1611
|
+
const [a, b] = [JSON.stringify(`${prefix}${from}`), JSON.stringify(`${prefix}${handle}`)]
|
|
1612
|
+
for (const [table, column] of [
|
|
1613
|
+
["endpoints", "rules"],
|
|
1614
|
+
["endpoints", "caps"],
|
|
1615
|
+
["groups", "rules"],
|
|
1616
|
+
["grants", "caps"],
|
|
1617
|
+
] as const)
|
|
1618
|
+
this.sql.all(`UPDATE ${table} SET ${column} = REPLACE(${column}, ?, ?) WHERE instr(${column}, ?) > 0`, a, b, a)
|
|
1619
|
+
}
|
|
1620
|
+
this.audit("rename", row.id, null, { from, to: handle })
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
/**
|
|
1624
|
+
* 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
|
|
1625
|
+
* itself, the human at a terminal any local root or linked agent. A spawned worker stays joined while it runs
|
|
1626
|
+
*/
|
|
1627
|
+
leaveSession(actor: ScopeActor, params: { endpoint?: unknown } = {}): { endpoint: EndpointView; session: string | null } {
|
|
1628
|
+
return this.scopeTx(() => {
|
|
1629
|
+
const me = this.actorRow(actor)
|
|
1630
|
+
if (actor.kind === "human") this.needTerminal(actor, me, params.endpoint)
|
|
1631
|
+
else if (actor.auth === "lineage") throw new RelayError("scope_refused", "only the session itself leaves: run this from the session's own shell")
|
|
1632
|
+
const target = params.endpoint === undefined ? me : this.resolve(params.endpoint)
|
|
1633
|
+
if (actor.kind !== "human" && target.id !== me.id) throw new RelayError("scope_refused", "an agent leaves only for itself")
|
|
1634
|
+
if (target.kind !== "agent" || !this.localRow(target)) throw new RelayError("invalid_params", "only a local agent endpoint can leave")
|
|
1635
|
+
if (target.parent_ep !== null && target.linked === null) {
|
|
1636
|
+
const parent = this.byId(target.parent_ep)
|
|
1637
|
+
throw new RelayError("scope_refused", `spawned by ${parent ? this.address(parent) : "another agent"}; a spawned worker stays joined while it runs`)
|
|
1638
|
+
}
|
|
1639
|
+
this.sql.all(`UPDATE endpoints SET session = NULL, token_hash = NULL, hook_seen = NULL, hook_ended = ? WHERE id = ?`, this.now(), target.id)
|
|
1640
|
+
this.audit("leave", target.id, null, { handle: target.handle })
|
|
1641
|
+
return { endpoint: this.whoami(target.id), session: target.session }
|
|
1642
|
+
})
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
/** the harness session key an endpoint holds, if any */
|
|
1646
|
+
sessionOf(endpoint: string): string | null {
|
|
1647
|
+
return this.byId(endpoint)?.session ?? null
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1386
1650
|
// messages
|
|
1387
1651
|
|
|
1388
1652
|
/**
|
|
@@ -1443,6 +1707,17 @@ export class RelayCore {
|
|
|
1443
1707
|
resolved.push(row)
|
|
1444
1708
|
}
|
|
1445
1709
|
const recipients = [...new Map(resolved.map((row) => [row.id, row])).values()]
|
|
1710
|
+
if (params.live !== undefined && typeof params.live !== "boolean") throw new RelayError("invalid_params", "live must be true or false")
|
|
1711
|
+
const dark = recipients.filter(
|
|
1712
|
+
(r) =>
|
|
1713
|
+
r.kind === "agent" &&
|
|
1714
|
+
presenceOf(r, now) === "offline" &&
|
|
1715
|
+
(now - Math.max(r.last_seen ?? 0, r.hook_seen ?? 0) >= ASK_ENDED_GRACE_MS || (r.session === null && r.token_hash === null && r.hook_ended !== null)) &&
|
|
1716
|
+
!this.unjoinedSpawn(sender, r.handle, now),
|
|
1717
|
+
)
|
|
1718
|
+
if (params.live === true && dark.length)
|
|
1719
|
+
throw new RelayError("not_live", `no live session holds ${dark.map((r) => this.address(r)).join(", ")}: nothing was queued`)
|
|
1720
|
+
const warnings = dark.map((r) => `queued: no live session holds ${this.address(r)}`)
|
|
1446
1721
|
const pending = [...unjoined.values()].map((grant) => ({ key: pendingKey(grant.token_hash), address: `${this.user}/${grant.name}` }))
|
|
1447
1722
|
const reached = recipients.length + pending.length
|
|
1448
1723
|
if (reached > LIMITS.fanOutTotal) throw new RelayError("too_large", `the send reaches ${reached} endpoints, more than ${LIMITS.fanOutTotal}`)
|
|
@@ -1672,7 +1947,7 @@ export class RelayCore {
|
|
|
1672
1947
|
deliveries.push({ endpoint: key, address, seq, offline: false, queued: true, last_seen: null, state: "starting" })
|
|
1673
1948
|
}
|
|
1674
1949
|
for (const name of fanOut) this.sql.all(`INSERT INTO tree_events (tree, at) VALUES (?, ?)`, `group:${name}`, now)
|
|
1675
|
-
const sent: SendResult = { id, thread, hop, deliveries }
|
|
1950
|
+
const sent: SendResult = { id, thread, hop, deliveries, ...(warnings.length ? { warnings } : {}) }
|
|
1676
1951
|
if (clientId) {
|
|
1677
1952
|
this.sql.all(
|
|
1678
1953
|
`INSERT OR REPLACE INTO idempotency (from_ep, client_id, payload_hash, result, created_at) VALUES (?, ?, ?, ?, ?)`,
|
|
@@ -1819,10 +2094,11 @@ export class RelayCore {
|
|
|
1819
2094
|
* `directive_seqs`. Any call says `brief` once the session was shown the full preamble ({@link briefed}), and one that is no stop names in
|
|
1820
2095
|
* `intro` the spawner of an endpoint a `swarm spawn` grant placed, with the grant's steer key in `intro_key`, until a hook showed it
|
|
1821
2096
|
* ({@link introduced}). A hook of another process than the one holding the endpoint (a child that inherited the session key) gets nothing.
|
|
2097
|
+
* A wake also offers live workers' messages.
|
|
1822
2098
|
*/
|
|
1823
2099
|
inject(
|
|
1824
2100
|
endpoint: string,
|
|
1825
|
-
options: { stop?: boolean; count?: boolean; holdUnread?: boolean; directives?: boolean; transient?: boolean; process?: string } = {},
|
|
2101
|
+
options: { stop?: boolean; count?: boolean; holdUnread?: boolean; directives?: boolean; transient?: boolean; process?: string; wake?: boolean } = {},
|
|
1826
2102
|
): {
|
|
1827
2103
|
messages: Envelope[]
|
|
1828
2104
|
offered: number[]
|
|
@@ -1912,7 +2188,7 @@ export class RelayCore {
|
|
|
1912
2188
|
now,
|
|
1913
2189
|
LIMITS.inboxPage,
|
|
1914
2190
|
)
|
|
1915
|
-
const { shown, offered } = this.budget(rows, LIMITS.injectBytes, () => this.pushable(me, now))
|
|
2191
|
+
const { shown, offered } = this.budget(rows, LIMITS.injectBytes, options.wake ? undefined : () => this.pushable(me, now))
|
|
1916
2192
|
const hold = this.hold(offered, now)
|
|
1917
2193
|
this.received(endpoint, shown, now)
|
|
1918
2194
|
return { messages: shown.map((row) => this.envelope(row, row.seq)), offered, hold, more: waiting - shown.length, unread, block: false, ...brief, ...intro }
|
|
@@ -2100,6 +2376,16 @@ export class RelayCore {
|
|
|
2100
2376
|
return true
|
|
2101
2377
|
}
|
|
2102
2378
|
|
|
2379
|
+
/** whether a prompt should remind the agent to refresh its status note: `every` prompts passed since the note or the last reminder; counts the reminder */
|
|
2380
|
+
tldrDue(endpoint: string, every: number): boolean {
|
|
2381
|
+
if (!Number.isInteger(every) || every <= 0) return false
|
|
2382
|
+
const row = this.mustEndpoint(endpoint)
|
|
2383
|
+
const turn = row.prompts ?? 0
|
|
2384
|
+
if (row.kind !== "agent" || turn - (row.tldr_turn ?? 0) < every) return false
|
|
2385
|
+
this.sql.all(`UPDATE endpoints SET tldr_turn = ? WHERE id = ?`, turn, endpoint)
|
|
2386
|
+
return true
|
|
2387
|
+
}
|
|
2388
|
+
|
|
2103
2389
|
/** the background hooks' showings ({@link injected}) older than ${LIMITS.deferredSettleMs} ms reached the agent: inbox no longer lists them */
|
|
2104
2390
|
private settle(endpoint: string): void {
|
|
2105
2391
|
this.sql.all(
|
|
@@ -2612,6 +2898,7 @@ export class RelayCore {
|
|
|
2612
2898
|
this.sql.all(`DELETE FROM idempotency WHERE created_at <= ?`, now - LIMITS.idempotencyS * 1000)
|
|
2613
2899
|
this.sql.all(`DELETE FROM pair_events WHERE at <= ?`, now - 3600 * 1000)
|
|
2614
2900
|
this.sql.all(`DELETE FROM blocks WHERE until <= ?`, now)
|
|
2901
|
+
this.endExited(now)
|
|
2615
2902
|
this.settleOffline(now)
|
|
2616
2903
|
this.sql.all(
|
|
2617
2904
|
`DELETE FROM endpoints WHERE kind = 'agent' AND connections = 0 AND COALESCE(last_seen, created_at) <= ?
|
|
@@ -2638,6 +2925,17 @@ export class RelayCore {
|
|
|
2638
2925
|
}
|
|
2639
2926
|
}
|
|
2640
2927
|
|
|
2928
|
+
/** ends the hook presence of local agent endpoints whose harness process exited without a session_end, as one would */
|
|
2929
|
+
endExited(now = this.now()): void {
|
|
2930
|
+
for (const row of this.sql.all<EndpointRow>(
|
|
2931
|
+
`SELECT * FROM endpoints WHERE kind = 'agent' AND connections = 0 AND hook_seen IS NOT NULL AND process IS NOT NULL`,
|
|
2932
|
+
)) {
|
|
2933
|
+
const pid = Number(/^pid:(\d+)/.exec(row.process!)?.[1])
|
|
2934
|
+
if (!pid || !this.localRow(row) || this.alive(pid)) continue
|
|
2935
|
+
this.sql.all(`UPDATE endpoints SET hook_seen = NULL, hook_ended = ?, session = NULL WHERE id = ? AND connections = 0`, now, row.id)
|
|
2936
|
+
}
|
|
2937
|
+
}
|
|
2938
|
+
|
|
2641
2939
|
/** an agent endpoint left offline drops a working or waiting status, which no one is left to keep current */
|
|
2642
2940
|
private settleOffline(now: number): void {
|
|
2643
2941
|
for (const row of this.sql.all<EndpointRow>(
|
|
@@ -2697,15 +2995,19 @@ export class RelayCore {
|
|
|
2697
2995
|
endpoint,
|
|
2698
2996
|
)
|
|
2699
2997
|
} else if (params.kind === "session_start") {
|
|
2998
|
+
const fresh = own && summary !== "compact" && summary !== "resume"
|
|
2700
2999
|
this.sql.all(
|
|
2701
3000
|
`UPDATE endpoints SET hook_seen = COALESCE(?, hook_seen), hook_ended = NULL, stop_blocks = CASE WHEN ? THEN 0 ELSE stop_blocks END, pushes = CASE WHEN ? THEN 0 ELSE pushes END,
|
|
2702
|
-
briefed = CASE WHEN ? THEN NULL ELSE briefed END, introduced = CASE WHEN ? THEN NULL ELSE introduced END, directive_holds = CASE WHEN ? THEN NULL ELSE directive_holds END
|
|
3001
|
+
briefed = CASE WHEN ? THEN NULL ELSE briefed END, introduced = CASE WHEN ? THEN NULL ELSE introduced END, directive_holds = CASE WHEN ? THEN NULL ELSE directive_holds END,
|
|
3002
|
+
title = CASE WHEN ? AND title = title_auto THEN NULL ELSE title END, title_auto = CASE WHEN ? THEN NULL ELSE title_auto END WHERE id = ?`,
|
|
2703
3003
|
options.transient ? now : null,
|
|
2704
3004
|
own ? 1 : 0,
|
|
2705
3005
|
own ? 1 : 0,
|
|
2706
3006
|
own ? 1 : 0,
|
|
2707
3007
|
own ? 1 : 0,
|
|
2708
3008
|
own ? 1 : 0,
|
|
3009
|
+
fresh ? 1 : 0,
|
|
3010
|
+
fresh ? 1 : 0,
|
|
2709
3011
|
endpoint,
|
|
2710
3012
|
)
|
|
2711
3013
|
} else if (options.transient) {
|
|
@@ -4657,7 +4959,7 @@ export class RelayCore {
|
|
|
4657
4959
|
|
|
4658
4960
|
/** the user parts an address of this core may carry */
|
|
4659
4961
|
protected addressUsers(): string[] {
|
|
4660
|
-
return [this.user, "me"]
|
|
4962
|
+
return [this.user, "me", "local"]
|
|
4661
4963
|
}
|
|
4662
4964
|
|
|
4663
4965
|
/** whether the row is this host's own, the only rows with a tree, groups, rules and caps here */
|
|
@@ -4671,7 +4973,7 @@ export class RelayCore {
|
|
|
4671
4973
|
const slash = text.indexOf("/")
|
|
4672
4974
|
const user = slash === -1 ? null : text.slice(0, slash)
|
|
4673
4975
|
const handle = slash === -1 ? text : text.slice(slash + 1)
|
|
4674
|
-
if (user !== null && user !== "me" && user !== this.user) throw unreachable(text)
|
|
4976
|
+
if (user !== null && user !== "me" && user !== "local" && user !== this.user) throw unreachable(text)
|
|
4675
4977
|
const row = handle === "" ? this.byHandle(HUMAN_HANDLE) : validHandle(handle) ? this.byHandle(handle) : undefined
|
|
4676
4978
|
if (!row) throw unreachable(text)
|
|
4677
4979
|
return row
|
package/src/index.ts
CHANGED
package/src/policy.ts
CHANGED
|
@@ -36,6 +36,10 @@ export interface PolicyDefaults {
|
|
|
36
36
|
/** `inherit`: a child of a capped parent reaches only what the parent reaches; a preset caps every placed child without grant caps */
|
|
37
37
|
child_default?: ScopePreset | "inherit"
|
|
38
38
|
verify_lineage?: "spike" | boolean
|
|
39
|
+
/** who may rename an agent besides the human at a terminal: `root` (default) only a root session itself, `self` any agent itself, spawned ones too */
|
|
40
|
+
rename?: "root" | "self"
|
|
41
|
+
/** who may unlink an agent from its parent besides the human at a terminal: `human` (default) nobody, `self` the agent itself */
|
|
42
|
+
unlink?: "human" | "self"
|
|
39
43
|
}
|
|
40
44
|
|
|
41
45
|
export interface Policy {
|
|
@@ -164,7 +168,7 @@ export function parsePolicy(value: unknown, options: { judgeProviders?: Readonly
|
|
|
164
168
|
}
|
|
165
169
|
if (defaults !== undefined) {
|
|
166
170
|
if (!isObject(defaults)) throw new RelayError("invalid_params", "defaults is an object")
|
|
167
|
-
const { human_always, symmetric_presets, child_default, verify_lineage, ...extra } = defaults
|
|
171
|
+
const { human_always, symmetric_presets, child_default, verify_lineage, rename, unlink, ...extra } = defaults
|
|
168
172
|
if (Object.keys(extra).length) throw new RelayError("invalid_params", `unknown defaults: ${Object.keys(extra).join(", ")}`)
|
|
169
173
|
const parsed: PolicyDefaults = {}
|
|
170
174
|
for (const [key, flag] of [
|
|
@@ -188,6 +192,14 @@ export function parsePolicy(value: unknown, options: { judgeProviders?: Readonly
|
|
|
188
192
|
throw new RelayError("invalid_params", "defaults.verify_lineage true needs the peer-pid check, which this relay does not have yet: use spike or false")
|
|
189
193
|
parsed.verify_lineage = verify_lineage
|
|
190
194
|
}
|
|
195
|
+
if (rename !== undefined) {
|
|
196
|
+
if (rename !== "root" && rename !== "self") throw new RelayError("invalid_params", "defaults.rename is root or self")
|
|
197
|
+
parsed.rename = rename
|
|
198
|
+
}
|
|
199
|
+
if (unlink !== undefined) {
|
|
200
|
+
if (unlink !== "human" && unlink !== "self") throw new RelayError("invalid_params", "defaults.unlink is human or self")
|
|
201
|
+
parsed.unlink = unlink
|
|
202
|
+
}
|
|
191
203
|
policy.defaults = parsed
|
|
192
204
|
}
|
|
193
205
|
if (judge !== undefined) policy.judge = parseJudgePolicy(judge, options.judgeProviders)
|
package/src/protocol.ts
CHANGED
|
@@ -73,6 +73,8 @@ export const LIMITS = {
|
|
|
73
73
|
grantTtlS: 7 * 86_400,
|
|
74
74
|
groupIdleS: 86_400,
|
|
75
75
|
policyLoadsKept: 8,
|
|
76
|
+
/** the prompts between two refreshes of a title a hook inferred */
|
|
77
|
+
titleRefreshTurns: 5,
|
|
76
78
|
} as const
|
|
77
79
|
|
|
78
80
|
export type ErrorCode =
|
|
@@ -90,6 +92,7 @@ export type ErrorCode =
|
|
|
90
92
|
| "scope_refused"
|
|
91
93
|
| "unavailable"
|
|
92
94
|
| "judge_refused"
|
|
95
|
+
| "not_live"
|
|
93
96
|
|
|
94
97
|
export class RelayError extends Error {
|
|
95
98
|
constructor(
|
|
@@ -386,6 +389,8 @@ export interface SendParams {
|
|
|
386
389
|
mime?: string
|
|
387
390
|
/** on a request only: the recipient's stop is held until it replies */
|
|
388
391
|
require_reply?: boolean
|
|
392
|
+
/** refuse `not_live`, queueing nothing, when an agent recipient has no live session */
|
|
393
|
+
live?: boolean
|
|
389
394
|
}
|
|
390
395
|
|
|
391
396
|
/** what ask and ask_wait answer: the reply; or pending, with `ended` once every recipient present at the send went offline without replying; or expired */
|
|
@@ -429,6 +434,8 @@ export interface SendResult {
|
|
|
429
434
|
* offline; `state`: an agent recipient's {@link Reach} on this machine (an older broker leaves it out)
|
|
430
435
|
*/
|
|
431
436
|
deliveries: { endpoint: string; address: string; seq: number; offline: boolean; queued?: boolean; last_seen: string | null; state?: Reach }[]
|
|
437
|
+
/** one line per agent recipient no live session holds: `queued: no live session holds <address>` */
|
|
438
|
+
warnings?: string[]
|
|
432
439
|
duplicate?: boolean
|
|
433
440
|
held?: JudgeHeld
|
|
434
441
|
redacted?: JudgeRedacted
|