@scp-dev/core 0.1.1 → 0.1.2
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 +73 -2
- package/dist/index.js +176 -12
- package/package.json +1 -1
- package/src/core.ts +198 -10
- package/src/index.ts +1 -0
- package/src/policy.ts +7 -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,8 @@ 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";
|
|
806
814
|
}
|
|
807
815
|
interface Policy {
|
|
808
816
|
rules: PolicyRule[];
|
|
@@ -1017,6 +1025,8 @@ interface Sql {
|
|
|
1017
1025
|
declare const one: <T>(sql: Sql, query: string, ...params: SqlValue[]) => T | undefined;
|
|
1018
1026
|
|
|
1019
1027
|
declare const HUMAN_HANDLE = "_human";
|
|
1028
|
+
/** an agent title's length cap */
|
|
1029
|
+
declare const TITLE_CHARS = 64;
|
|
1020
1030
|
declare const SYSTEM_HANDLE = "_relay";
|
|
1021
1031
|
declare const GONE_PREFIX = "_gone_";
|
|
1022
1032
|
interface EndpointRow {
|
|
@@ -1064,12 +1074,20 @@ interface EndpointRow {
|
|
|
1064
1074
|
/** JSON {@link ScopeCaps} */
|
|
1065
1075
|
caps: string | null;
|
|
1066
1076
|
title: string | null;
|
|
1077
|
+
/** the title a hook last inferred: the title is inferred while it equals this */
|
|
1078
|
+
title_auto: string | null;
|
|
1079
|
+
/** the prompt count when a summary last set the inferred title; null when the first prompt set it */
|
|
1080
|
+
title_turn: number | null;
|
|
1081
|
+
/** the prompt count when the status note last changed or a hook last reminded the agent to refresh it */
|
|
1082
|
+
tldr_turn: number | null;
|
|
1067
1083
|
/** when a new spawn last took the endpoint over: mail and messages from before it were the earlier holder's */
|
|
1068
1084
|
reset_at: number | null;
|
|
1069
1085
|
/** when a new spawn last took the endpoint over: with its id, names the holder another machine sees */
|
|
1070
1086
|
spawned_at: number | null;
|
|
1071
1087
|
/** who may peek: null is off */
|
|
1072
1088
|
peek: PeekLevel | null;
|
|
1089
|
+
/** when the session last set its handle itself (join or rename): a later claim with another handle does not rename it back */
|
|
1090
|
+
named: number | null;
|
|
1073
1091
|
}
|
|
1074
1092
|
interface MessageRow {
|
|
1075
1093
|
id: string;
|
|
@@ -1118,6 +1136,8 @@ declare class RelayCore {
|
|
|
1118
1136
|
private readonly capEverySender;
|
|
1119
1137
|
private readonly alive;
|
|
1120
1138
|
private readonly hostMachine;
|
|
1139
|
+
private readonly namedMachine;
|
|
1140
|
+
private readonly hostname;
|
|
1121
1141
|
private policy;
|
|
1122
1142
|
/** 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
1143
|
private policyBinds;
|
|
@@ -1126,6 +1146,8 @@ declare class RelayCore {
|
|
|
1126
1146
|
private scopeMemo;
|
|
1127
1147
|
private readonly deferred;
|
|
1128
1148
|
constructor(sql: Sql, options: CoreOptions);
|
|
1149
|
+
/** a `machine` label naming this host by its hostname, as clients default it, names it by its machine name when one is set */
|
|
1150
|
+
private machineLabel;
|
|
1129
1151
|
migrate(): void;
|
|
1130
1152
|
/** every stored list that is exactly `tree:self`, the tree preset's old list, narrows once to the preset's list */
|
|
1131
1153
|
private migrateTreePreset;
|
|
@@ -1279,6 +1301,49 @@ declare class RelayCore {
|
|
|
1279
1301
|
status(endpoint: string, state: unknown, note: unknown): void;
|
|
1280
1302
|
/** an agent's own title, shown in list and whoami; empty clears it, and no claim overwrites it */
|
|
1281
1303
|
title(endpoint: string, value: unknown): EndpointView;
|
|
1304
|
+
/**
|
|
1305
|
+
* 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.
|
|
1306
|
+
* Never over a title set explicitly; the first prompt fills only an empty title, and a summary replaces an inferred one at most every
|
|
1307
|
+
* ${LIMITS.titleRefreshTurns} prompts, or at once over the first prompt's
|
|
1308
|
+
*/
|
|
1309
|
+
inferTitle(endpoint: string, params: {
|
|
1310
|
+
text?: unknown;
|
|
1311
|
+
from?: unknown;
|
|
1312
|
+
}, options?: {
|
|
1313
|
+
transient?: boolean;
|
|
1314
|
+
process?: string;
|
|
1315
|
+
}): boolean;
|
|
1316
|
+
/**
|
|
1317
|
+
* a session joins on demand: its hook claim registers or takes back its endpoint, marked seen now even right after a leave, and `handle`,
|
|
1318
|
+
* when given, renames it in place ({@link rename}'s rules). Nothing is kept when the rename is refused
|
|
1319
|
+
*/
|
|
1320
|
+
joinSession(claim: Claim, handle?: unknown): EndpointView;
|
|
1321
|
+
/**
|
|
1322
|
+
* renames an agent in place: its id, mail, threads, status, title and relations stay. An agent renames only itself, and only a root unless
|
|
1323
|
+
* 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
|
|
1324
|
+
*/
|
|
1325
|
+
rename(actor: ScopeActor, params: {
|
|
1326
|
+
endpoint?: unknown;
|
|
1327
|
+
handle: unknown;
|
|
1328
|
+
}): {
|
|
1329
|
+
endpoint: EndpointView;
|
|
1330
|
+
from: string;
|
|
1331
|
+
session: string | null;
|
|
1332
|
+
};
|
|
1333
|
+
/** the endpoint's handle set to `value`, with the stored `ep:` and `fleet:` selectors that named it */
|
|
1334
|
+
private renameRow;
|
|
1335
|
+
/**
|
|
1336
|
+
* 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
|
|
1337
|
+
* itself, the human at a terminal any local root. A spawned worker stays joined while it runs
|
|
1338
|
+
*/
|
|
1339
|
+
leaveSession(actor: ScopeActor, params?: {
|
|
1340
|
+
endpoint?: unknown;
|
|
1341
|
+
}): {
|
|
1342
|
+
endpoint: EndpointView;
|
|
1343
|
+
session: string | null;
|
|
1344
|
+
};
|
|
1345
|
+
/** the harness session key an endpoint holds, if any */
|
|
1346
|
+
sessionOf(endpoint: string): string | null;
|
|
1282
1347
|
/**
|
|
1283
1348
|
* `origin.tty` is recorded on messages the human inbox sends: whether its client said a terminal was attached. `origin.unverified`: the human
|
|
1284
1349
|
* sends without a terminal, so the scopes check it like any unplaced sender
|
|
@@ -1339,6 +1404,7 @@ declare class RelayCore {
|
|
|
1339
1404
|
* `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
1405
|
* `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
1406
|
* ({@link introduced}). A hook of another process than the one holding the endpoint (a child that inherited the session key) gets nothing.
|
|
1407
|
+
* A wake also offers live workers' messages.
|
|
1342
1408
|
*/
|
|
1343
1409
|
inject(endpoint: string, options?: {
|
|
1344
1410
|
stop?: boolean;
|
|
@@ -1347,6 +1413,7 @@ declare class RelayCore {
|
|
|
1347
1413
|
directives?: boolean;
|
|
1348
1414
|
transient?: boolean;
|
|
1349
1415
|
process?: string;
|
|
1416
|
+
wake?: boolean;
|
|
1350
1417
|
}): {
|
|
1351
1418
|
messages: Envelope[];
|
|
1352
1419
|
offered: number[];
|
|
@@ -1415,6 +1482,8 @@ declare class RelayCore {
|
|
|
1415
1482
|
transient?: boolean;
|
|
1416
1483
|
process?: string;
|
|
1417
1484
|
}): boolean;
|
|
1485
|
+
/** 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 */
|
|
1486
|
+
tldrDue(endpoint: string, every: number): boolean;
|
|
1418
1487
|
/** the background hooks' showings ({@link injected}) older than ${LIMITS.deferredSettleMs} ms reached the agent: inbox no longer lists them */
|
|
1419
1488
|
private settle;
|
|
1420
1489
|
/**
|
|
@@ -1544,6 +1613,8 @@ declare class RelayCore {
|
|
|
1544
1613
|
}): AuditRow[];
|
|
1545
1614
|
/** drops what expired: deliveries, message bodies past their grace, old guard state and old audit rows */
|
|
1546
1615
|
sweep(): void;
|
|
1616
|
+
/** ends the hook presence of local agent endpoints whose harness process exited without a session_end, as one would */
|
|
1617
|
+
endExited(now?: number): void;
|
|
1547
1618
|
/** an agent endpoint left offline drops a working or waiting status, which no one is left to keep current */
|
|
1548
1619
|
private settleOffline;
|
|
1549
1620
|
/**
|
|
@@ -2351,4 +2422,4 @@ declare function sha256Hex(text: string): string;
|
|
|
2351
2422
|
/** the UTF-8 byte length of text, the unit every byte limit counts in */
|
|
2352
2423
|
declare function utf8Bytes(text: string): number;
|
|
2353
2424
|
|
|
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 };
|
|
2425
|
+
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, ...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,10 @@ 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
|
+
}
|
|
726
732
|
policy.defaults = parsed;
|
|
727
733
|
}
|
|
728
734
|
if (judge !== void 0) policy.judge = parseJudgePolicy(judge, options.judgeProviders);
|
|
@@ -1065,6 +1071,7 @@ function utf8Bytes(text) {
|
|
|
1065
1071
|
|
|
1066
1072
|
// src/core.ts
|
|
1067
1073
|
var HUMAN_HANDLE = "_human";
|
|
1074
|
+
var TITLE_CHARS = 64;
|
|
1068
1075
|
var SYSTEM_HANDLE = "_relay";
|
|
1069
1076
|
var GONE_PREFIX = "_gone_";
|
|
1070
1077
|
var MIGRATION = [
|
|
@@ -1247,6 +1254,8 @@ var RelayCore = class {
|
|
|
1247
1254
|
this.capEverySender = options.capEverySender === true;
|
|
1248
1255
|
this.alive = options.alive ?? (() => true);
|
|
1249
1256
|
this.hostMachine = options.machine ?? options.hostname ?? null;
|
|
1257
|
+
this.namedMachine = options.machine ?? null;
|
|
1258
|
+
this.hostname = options.hostname ?? null;
|
|
1250
1259
|
}
|
|
1251
1260
|
sql;
|
|
1252
1261
|
user;
|
|
@@ -1256,6 +1265,8 @@ var RelayCore = class {
|
|
|
1256
1265
|
capEverySender;
|
|
1257
1266
|
alive;
|
|
1258
1267
|
hostMachine;
|
|
1268
|
+
namedMachine;
|
|
1269
|
+
hostname;
|
|
1259
1270
|
policy = null;
|
|
1260
1271
|
/** 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
1272
|
policyBinds = /* @__PURE__ */ new Map();
|
|
@@ -1263,6 +1274,11 @@ var RelayCore = class {
|
|
|
1263
1274
|
/** while a scope world derives a node: what it already derived of ancestor lists and limits */
|
|
1264
1275
|
scopeMemo = null;
|
|
1265
1276
|
deferred = /* @__PURE__ */ new WeakMap();
|
|
1277
|
+
/** a `machine` label naming this host by its hostname, as clients default it, names it by its machine name when one is set */
|
|
1278
|
+
machineLabel(labels) {
|
|
1279
|
+
if (this.namedMachine !== null && labels.machine !== void 0 && labels.machine === this.hostname) labels.machine = this.namedMachine;
|
|
1280
|
+
return labels;
|
|
1281
|
+
}
|
|
1266
1282
|
migrate() {
|
|
1267
1283
|
for (const statement of MIGRATION) this.sql.all(statement);
|
|
1268
1284
|
const columns = this.sql.all(`PRAGMA table_info(idempotency)`).map((c) => c.name);
|
|
@@ -1295,7 +1311,11 @@ var RelayCore = class {
|
|
|
1295
1311
|
["introduced", "INTEGER"],
|
|
1296
1312
|
["directive_holds", "INTEGER"],
|
|
1297
1313
|
["harness_session", "TEXT"],
|
|
1298
|
-
["harness_transcript", "TEXT"]
|
|
1314
|
+
["harness_transcript", "TEXT"],
|
|
1315
|
+
["title_auto", "TEXT"],
|
|
1316
|
+
["title_turn", "INTEGER"],
|
|
1317
|
+
["tldr_turn", "INTEGER"],
|
|
1318
|
+
["named", "INTEGER"]
|
|
1299
1319
|
]) {
|
|
1300
1320
|
if (!endpointColumns.includes(column)) this.sql.all(`ALTER TABLE endpoints ADD COLUMN ${column} ${type}`);
|
|
1301
1321
|
}
|
|
@@ -1452,7 +1472,7 @@ var RelayCore = class {
|
|
|
1452
1472
|
model: cleanText(claim.model, "model", 128),
|
|
1453
1473
|
cwd: cleanText(claim.cwd, "cwd", 1024),
|
|
1454
1474
|
parent: cleanText(claim.parent, "parent", 128),
|
|
1455
|
-
labels: JSON.stringify(cleanLabels(claim.labels)),
|
|
1475
|
+
labels: JSON.stringify(this.machineLabel(cleanLabels(claim.labels))),
|
|
1456
1476
|
session: cleanText(claim.session, "session", 128),
|
|
1457
1477
|
process: cleanText(claim.process, "process", 128)
|
|
1458
1478
|
};
|
|
@@ -1551,6 +1571,7 @@ var RelayCore = class {
|
|
|
1551
1571
|
}
|
|
1552
1572
|
claimRow(wanted, harness, fields, transient, scope = { handle: wanted, lineage: [], grant: null, rules: {}, groups: [], cwd: fields.cwd }) {
|
|
1553
1573
|
const now = this.now();
|
|
1574
|
+
this.endExited(now);
|
|
1554
1575
|
const bare = transient && fields.session !== null && BARE_PID_KEY.test(fields.session);
|
|
1555
1576
|
let row = fields.session ? one(
|
|
1556
1577
|
this.sql,
|
|
@@ -1574,7 +1595,7 @@ var RelayCore = class {
|
|
|
1574
1595
|
} else if (row && !transient && !sameProcess(row, fields.process)) {
|
|
1575
1596
|
row = void 0;
|
|
1576
1597
|
session = null;
|
|
1577
|
-
} else if (row && !transient && row.token_hash === null && row.handle !== wanted && !row.handle.startsWith(`${wanted.slice(0, 58)}-`)) {
|
|
1598
|
+
} else if (row && !transient && row.token_hash === null && row.named === null && row.handle !== wanted && !row.handle.startsWith(`${wanted.slice(0, 58)}-`)) {
|
|
1578
1599
|
const hooks = row;
|
|
1579
1600
|
let settled = false;
|
|
1580
1601
|
for (let n = 1; n < (scope.exact ? 2 : 1e3) && !settled; n++) {
|
|
@@ -2012,6 +2033,7 @@ var RelayCore = class {
|
|
|
2012
2033
|
*/
|
|
2013
2034
|
list(filters = {}) {
|
|
2014
2035
|
const now = this.now();
|
|
2036
|
+
this.endExited(now);
|
|
2015
2037
|
this.settleOffline(now);
|
|
2016
2038
|
const cutoff = now - LIMITS.listOfflineWithinS * 1e3;
|
|
2017
2039
|
const viewer = filters.viewer === void 0 ? void 0 : this.mustEndpoint(filters.viewer);
|
|
@@ -2065,10 +2087,12 @@ var RelayCore = class {
|
|
|
2065
2087
|
const text = oneLine(note, "note", LIMITS.noteChars);
|
|
2066
2088
|
if (text && findSecret(text)) throw this.refuse(endpoint, "secret_refused", "the status note looks like it carries a credential");
|
|
2067
2089
|
this.sql.all(
|
|
2068
|
-
`UPDATE endpoints SET status_state = COALESCE(?, status_state, 'working'), status_note = COALESCE(?, status_note, ''), last_seen =
|
|
2090
|
+
`UPDATE endpoints SET status_state = COALESCE(?, status_state, 'working'), status_note = COALESCE(?, status_note, ''), last_seen = ?,
|
|
2091
|
+
tldr_turn = CASE WHEN ? THEN COALESCE(prompts, 0) ELSE tldr_turn END WHERE id = ?`,
|
|
2069
2092
|
state ?? null,
|
|
2070
2093
|
text,
|
|
2071
2094
|
this.now(),
|
|
2095
|
+
text === null ? 0 : 1,
|
|
2072
2096
|
endpoint
|
|
2073
2097
|
);
|
|
2074
2098
|
}
|
|
@@ -2078,10 +2102,117 @@ var RelayCore = class {
|
|
|
2078
2102
|
if (row.kind !== "agent") throw new RelayError("invalid_params", "only an agent endpoint has a title");
|
|
2079
2103
|
const title = oneLine(value, "title", 64) || null;
|
|
2080
2104
|
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 =
|
|
2105
|
+
this.sql.all(`UPDATE endpoints SET title = ?, title_auto = NULL, title_turn = NULL WHERE id = ?`, title, endpoint);
|
|
2082
2106
|
this.audit("title", endpoint, null, { title });
|
|
2083
2107
|
return this.whoami(endpoint);
|
|
2084
2108
|
}
|
|
2109
|
+
/**
|
|
2110
|
+
* 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.
|
|
2111
|
+
* Never over a title set explicitly; the first prompt fills only an empty title, and a summary replaces an inferred one at most every
|
|
2112
|
+
* ${LIMITS.titleRefreshTurns} prompts, or at once over the first prompt's
|
|
2113
|
+
*/
|
|
2114
|
+
inferTitle(endpoint, params, options = {}) {
|
|
2115
|
+
if (params.from !== "summary" && params.from !== "prompt") throw new RelayError("invalid_params", "from is summary or prompt");
|
|
2116
|
+
if (typeof params.text !== "string") throw new RelayError("invalid_params", "text must be a string");
|
|
2117
|
+
const row = this.mustEndpoint(endpoint);
|
|
2118
|
+
if (row.kind !== "agent" || !this.holds(row, options)) return false;
|
|
2119
|
+
const title = stripControl(redactSecrets(params.text.slice(0, 4096).replace(/\s+/g, " ").trim())).slice(0, TITLE_CHARS).trim();
|
|
2120
|
+
if (!title || findSecret(title) || title === row.title) return false;
|
|
2121
|
+
if (row.title !== null && row.title !== row.title_auto) return false;
|
|
2122
|
+
const turn = row.prompts ?? 0;
|
|
2123
|
+
if (params.from === "prompt" && row.title !== null) return false;
|
|
2124
|
+
if (params.from === "summary" && row.title !== null && row.title_turn !== null && turn - row.title_turn < LIMITS.titleRefreshTurns) return false;
|
|
2125
|
+
this.sql.all(`UPDATE endpoints SET title = ?, title_auto = ?, title_turn = ? WHERE id = ?`, title, title, params.from === "summary" ? turn : null, endpoint);
|
|
2126
|
+
return true;
|
|
2127
|
+
}
|
|
2128
|
+
/**
|
|
2129
|
+
* a session joins on demand: its hook claim registers or takes back its endpoint, marked seen now even right after a leave, and `handle`,
|
|
2130
|
+
* when given, renames it in place ({@link rename}'s rules). Nothing is kept when the rename is refused
|
|
2131
|
+
*/
|
|
2132
|
+
joinSession(claim, handle) {
|
|
2133
|
+
return this.scopeTx(() => {
|
|
2134
|
+
const { endpoint } = this.claim(claim, { transient: true });
|
|
2135
|
+
const row = this.mustEndpoint(endpoint.id);
|
|
2136
|
+
if (handle !== void 0 && handle !== null && handle !== "") this.renameRow(row, handle, false);
|
|
2137
|
+
this.sql.all(`UPDATE endpoints SET hook_seen = ?, hook_ended = NULL WHERE id = ?`, this.now(), row.id);
|
|
2138
|
+
this.audit("join", row.id, null, { handle: this.byId(row.id).handle });
|
|
2139
|
+
return this.whoami(row.id);
|
|
2140
|
+
});
|
|
2141
|
+
}
|
|
2142
|
+
/**
|
|
2143
|
+
* renames an agent in place: its id, mail, threads, status, title and relations stay. An agent renames only itself, and only a root unless
|
|
2144
|
+
* 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
|
|
2145
|
+
*/
|
|
2146
|
+
rename(actor, params) {
|
|
2147
|
+
return this.scopeTx(() => {
|
|
2148
|
+
const me = this.actorRow(actor);
|
|
2149
|
+
if (actor.kind === "human") this.needTerminal(actor, me, params.endpoint);
|
|
2150
|
+
else if (actor.auth === "lineage") throw new RelayError("scope_refused", "only the session itself renames it: run this from the session's own shell");
|
|
2151
|
+
const target = params.endpoint === void 0 ? me : this.resolve(params.endpoint);
|
|
2152
|
+
if (actor.kind !== "human" && target.id !== me.id) throw new RelayError("scope_refused", "an agent renames only itself");
|
|
2153
|
+
if (target.kind !== "agent" || !this.localRow(target)) throw new RelayError("invalid_params", "only a local agent endpoint can be renamed");
|
|
2154
|
+
this.renameRow(target, params.handle, actor.kind === "human");
|
|
2155
|
+
return { endpoint: this.whoami(target.id), from: target.handle, session: target.session };
|
|
2156
|
+
});
|
|
2157
|
+
}
|
|
2158
|
+
/** the endpoint's handle set to `value`, with the stored `ep:` and `fleet:` selectors that named it */
|
|
2159
|
+
renameRow(row, value, byHuman) {
|
|
2160
|
+
const handle = typeof value === "string" ? value.trim().replace(/^me\//, "") : "";
|
|
2161
|
+
if (!validHandle(handle)) throw new RelayError("invalid_params", `handle ${JSON.stringify(handle)} is not valid: lowercase letters, digits, . _ -`);
|
|
2162
|
+
if (findSecret(handle)) throw new RelayError("secret_refused", "the handle looks like it carries a credential");
|
|
2163
|
+
if (handle === row.handle) {
|
|
2164
|
+
this.sql.all(`UPDATE endpoints SET named = COALESCE(named, ?) WHERE id = ?`, this.now(), row.id);
|
|
2165
|
+
return;
|
|
2166
|
+
}
|
|
2167
|
+
if (!byHuman && row.parent_ep !== null && this.policy?.defaults?.rename !== "self") {
|
|
2168
|
+
const parent = this.byId(row.parent_ep);
|
|
2169
|
+
throw new RelayError("scope_refused", `spawned by ${parent ? this.address(parent) : "another agent"}; its name is set by the spawner`);
|
|
2170
|
+
}
|
|
2171
|
+
const held = this.byHandle(handle);
|
|
2172
|
+
if (held) {
|
|
2173
|
+
const how = held.kind !== "agent" ? "reserved" : presenceOf(held, this.now()) === "offline" ? "held by an offline endpoint" : "held by a live endpoint";
|
|
2174
|
+
throw new RelayError("invalid_params", `${handle} is ${how}: pick another name`);
|
|
2175
|
+
}
|
|
2176
|
+
const from = row.handle;
|
|
2177
|
+
this.sql.all(`UPDATE endpoints SET handle = ?, named = ? WHERE id = ?`, handle, this.now(), row.id);
|
|
2178
|
+
this.sql.all(`UPDATE grants SET name = ? WHERE used_by = ? AND name = ?`, handle, row.id, from);
|
|
2179
|
+
for (const prefix of row.parent_ep === null ? ["ep:", "fleet:"] : ["ep:"]) {
|
|
2180
|
+
const [a, b] = [JSON.stringify(`${prefix}${from}`), JSON.stringify(`${prefix}${handle}`)];
|
|
2181
|
+
for (const [table, column] of [
|
|
2182
|
+
["endpoints", "rules"],
|
|
2183
|
+
["endpoints", "caps"],
|
|
2184
|
+
["groups", "rules"],
|
|
2185
|
+
["grants", "caps"]
|
|
2186
|
+
])
|
|
2187
|
+
this.sql.all(`UPDATE ${table} SET ${column} = REPLACE(${column}, ?, ?) WHERE instr(${column}, ?) > 0`, a, b, a);
|
|
2188
|
+
}
|
|
2189
|
+
this.audit("rename", row.id, null, { from, to: handle });
|
|
2190
|
+
}
|
|
2191
|
+
/**
|
|
2192
|
+
* 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
|
|
2193
|
+
* itself, the human at a terminal any local root. A spawned worker stays joined while it runs
|
|
2194
|
+
*/
|
|
2195
|
+
leaveSession(actor, params = {}) {
|
|
2196
|
+
return this.scopeTx(() => {
|
|
2197
|
+
const me = this.actorRow(actor);
|
|
2198
|
+
if (actor.kind === "human") this.needTerminal(actor, me, params.endpoint);
|
|
2199
|
+
else if (actor.auth === "lineage") throw new RelayError("scope_refused", "only the session itself leaves: run this from the session's own shell");
|
|
2200
|
+
const target = params.endpoint === void 0 ? me : this.resolve(params.endpoint);
|
|
2201
|
+
if (actor.kind !== "human" && target.id !== me.id) throw new RelayError("scope_refused", "an agent leaves only for itself");
|
|
2202
|
+
if (target.kind !== "agent" || !this.localRow(target)) throw new RelayError("invalid_params", "only a local agent endpoint can leave");
|
|
2203
|
+
if (target.parent_ep !== null) {
|
|
2204
|
+
const parent = this.byId(target.parent_ep);
|
|
2205
|
+
throw new RelayError("scope_refused", `spawned by ${parent ? this.address(parent) : "another agent"}; a spawned worker stays joined while it runs`);
|
|
2206
|
+
}
|
|
2207
|
+
this.sql.all(`UPDATE endpoints SET session = NULL, token_hash = NULL, hook_seen = NULL, hook_ended = ? WHERE id = ?`, this.now(), target.id);
|
|
2208
|
+
this.audit("leave", target.id, null, { handle: target.handle });
|
|
2209
|
+
return { endpoint: this.whoami(target.id), session: target.session };
|
|
2210
|
+
});
|
|
2211
|
+
}
|
|
2212
|
+
/** the harness session key an endpoint holds, if any */
|
|
2213
|
+
sessionOf(endpoint) {
|
|
2214
|
+
return this.byId(endpoint)?.session ?? null;
|
|
2215
|
+
}
|
|
2085
2216
|
// messages
|
|
2086
2217
|
/**
|
|
2087
2218
|
* `origin.tty` is recorded on messages the human inbox sends: whether its client said a terminal was attached. `origin.unverified`: the human
|
|
@@ -2139,6 +2270,13 @@ var RelayCore = class {
|
|
|
2139
2270
|
resolved.push(row);
|
|
2140
2271
|
}
|
|
2141
2272
|
const recipients = [...new Map(resolved.map((row) => [row.id, row])).values()];
|
|
2273
|
+
if (params.live !== void 0 && typeof params.live !== "boolean") throw new RelayError("invalid_params", "live must be true or false");
|
|
2274
|
+
const dark = recipients.filter(
|
|
2275
|
+
(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)
|
|
2276
|
+
);
|
|
2277
|
+
if (params.live === true && dark.length)
|
|
2278
|
+
throw new RelayError("not_live", `no live session holds ${dark.map((r) => this.address(r)).join(", ")}: nothing was queued`);
|
|
2279
|
+
const warnings = dark.map((r) => `queued: no live session holds ${this.address(r)}`);
|
|
2142
2280
|
const pending = [...unjoined.values()].map((grant) => ({ key: pendingKey(grant.token_hash), address: `${this.user}/${grant.name}` }));
|
|
2143
2281
|
const reached = recipients.length + pending.length;
|
|
2144
2282
|
if (reached > LIMITS.fanOutTotal) throw new RelayError("too_large", `the send reaches ${reached} endpoints, more than ${LIMITS.fanOutTotal}`);
|
|
@@ -2356,7 +2494,7 @@ var RelayCore = class {
|
|
|
2356
2494
|
deliveries.push({ endpoint: key, address, seq, offline: false, queued: true, last_seen: null, state: "starting" });
|
|
2357
2495
|
}
|
|
2358
2496
|
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 };
|
|
2497
|
+
const sent = { id, thread, hop, deliveries, ...warnings.length ? { warnings } : {} };
|
|
2360
2498
|
if (clientId) {
|
|
2361
2499
|
this.sql.all(
|
|
2362
2500
|
`INSERT OR REPLACE INTO idempotency (from_ep, client_id, payload_hash, result, created_at) VALUES (?, ?, ?, ?, ?)`,
|
|
@@ -2494,6 +2632,7 @@ var RelayCore = class {
|
|
|
2494
2632
|
* `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
2633
|
* `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
2634
|
* ({@link introduced}). A hook of another process than the one holding the endpoint (a child that inherited the session key) gets nothing.
|
|
2635
|
+
* A wake also offers live workers' messages.
|
|
2497
2636
|
*/
|
|
2498
2637
|
inject(endpoint, options = {}) {
|
|
2499
2638
|
const now = this.now();
|
|
@@ -2561,7 +2700,7 @@ var RelayCore = class {
|
|
|
2561
2700
|
now,
|
|
2562
2701
|
LIMITS.inboxPage
|
|
2563
2702
|
);
|
|
2564
|
-
const { shown, offered } = this.budget(rows, LIMITS.injectBytes, () => this.pushable(me, now));
|
|
2703
|
+
const { shown, offered } = this.budget(rows, LIMITS.injectBytes, options.wake ? void 0 : () => this.pushable(me, now));
|
|
2565
2704
|
const hold = this.hold(offered, now);
|
|
2566
2705
|
this.received(endpoint, shown, now);
|
|
2567
2706
|
return { messages: shown.map((row) => this.envelope(row, row.seq)), offered, hold, more: waiting - shown.length, unread, block: false, ...brief, ...intro };
|
|
@@ -2731,6 +2870,15 @@ var RelayCore = class {
|
|
|
2731
2870
|
this.settle(endpoint);
|
|
2732
2871
|
return true;
|
|
2733
2872
|
}
|
|
2873
|
+
/** 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 */
|
|
2874
|
+
tldrDue(endpoint, every) {
|
|
2875
|
+
if (!Number.isInteger(every) || every <= 0) return false;
|
|
2876
|
+
const row = this.mustEndpoint(endpoint);
|
|
2877
|
+
const turn = row.prompts ?? 0;
|
|
2878
|
+
if (row.kind !== "agent" || turn - (row.tldr_turn ?? 0) < every) return false;
|
|
2879
|
+
this.sql.all(`UPDATE endpoints SET tldr_turn = ? WHERE id = ?`, turn, endpoint);
|
|
2880
|
+
return true;
|
|
2881
|
+
}
|
|
2734
2882
|
/** the background hooks' showings ({@link injected}) older than ${LIMITS.deferredSettleMs} ms reached the agent: inbox no longer lists them */
|
|
2735
2883
|
settle(endpoint) {
|
|
2736
2884
|
this.sql.all(
|
|
@@ -3185,6 +3333,7 @@ var RelayCore = class {
|
|
|
3185
3333
|
this.sql.all(`DELETE FROM idempotency WHERE created_at <= ?`, now - LIMITS.idempotencyS * 1e3);
|
|
3186
3334
|
this.sql.all(`DELETE FROM pair_events WHERE at <= ?`, now - 3600 * 1e3);
|
|
3187
3335
|
this.sql.all(`DELETE FROM blocks WHERE until <= ?`, now);
|
|
3336
|
+
this.endExited(now);
|
|
3188
3337
|
this.settleOffline(now);
|
|
3189
3338
|
this.sql.all(
|
|
3190
3339
|
`DELETE FROM endpoints WHERE kind = 'agent' AND connections = 0 AND COALESCE(last_seen, created_at) <= ?
|
|
@@ -3210,6 +3359,16 @@ var RelayCore = class {
|
|
|
3210
3359
|
if (!root || root.handle.startsWith(GONE_PREFIX) || !seen) this.dropGroup(group.name);
|
|
3211
3360
|
}
|
|
3212
3361
|
}
|
|
3362
|
+
/** ends the hook presence of local agent endpoints whose harness process exited without a session_end, as one would */
|
|
3363
|
+
endExited(now = this.now()) {
|
|
3364
|
+
for (const row of this.sql.all(
|
|
3365
|
+
`SELECT * FROM endpoints WHERE kind = 'agent' AND connections = 0 AND hook_seen IS NOT NULL AND process IS NOT NULL`
|
|
3366
|
+
)) {
|
|
3367
|
+
const pid = Number(/^pid:(\d+)/.exec(row.process)?.[1]);
|
|
3368
|
+
if (!pid || !this.localRow(row) || this.alive(pid)) continue;
|
|
3369
|
+
this.sql.all(`UPDATE endpoints SET hook_seen = NULL, hook_ended = ?, session = NULL WHERE id = ? AND connections = 0`, now, row.id);
|
|
3370
|
+
}
|
|
3371
|
+
}
|
|
3213
3372
|
/** an agent endpoint left offline drops a working or waiting status, which no one is left to keep current */
|
|
3214
3373
|
settleOffline(now) {
|
|
3215
3374
|
for (const row of this.sql.all(
|
|
@@ -3263,15 +3422,19 @@ var RelayCore = class {
|
|
|
3263
3422
|
endpoint
|
|
3264
3423
|
);
|
|
3265
3424
|
} else if (params.kind === "session_start") {
|
|
3425
|
+
const fresh = own && summary !== "compact" && summary !== "resume";
|
|
3266
3426
|
this.sql.all(
|
|
3267
3427
|
`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
|
|
3428
|
+
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,
|
|
3429
|
+
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
3430
|
options.transient ? now : null,
|
|
3270
3431
|
own ? 1 : 0,
|
|
3271
3432
|
own ? 1 : 0,
|
|
3272
3433
|
own ? 1 : 0,
|
|
3273
3434
|
own ? 1 : 0,
|
|
3274
3435
|
own ? 1 : 0,
|
|
3436
|
+
fresh ? 1 : 0,
|
|
3437
|
+
fresh ? 1 : 0,
|
|
3275
3438
|
endpoint
|
|
3276
3439
|
);
|
|
3277
3440
|
} else if (options.transient) {
|
|
@@ -4985,7 +5148,7 @@ ${stderr}` : "";
|
|
|
4985
5148
|
}
|
|
4986
5149
|
/** the user parts an address of this core may carry */
|
|
4987
5150
|
addressUsers() {
|
|
4988
|
-
return [this.user, "me"];
|
|
5151
|
+
return [this.user, "me", "local"];
|
|
4989
5152
|
}
|
|
4990
5153
|
/** whether the row is this host's own, the only rows with a tree, groups, rules and caps here */
|
|
4991
5154
|
localRow(_row) {
|
|
@@ -4997,7 +5160,7 @@ ${stderr}` : "";
|
|
|
4997
5160
|
const slash = text.indexOf("/");
|
|
4998
5161
|
const user = slash === -1 ? null : text.slice(0, slash);
|
|
4999
5162
|
const handle = slash === -1 ? text : text.slice(slash + 1);
|
|
5000
|
-
if (user !== null && user !== "me" && user !== this.user) throw unreachable(text);
|
|
5163
|
+
if (user !== null && user !== "me" && user !== "local" && user !== this.user) throw unreachable(text);
|
|
5001
5164
|
const row = handle === "" ? this.byHandle(HUMAN_HANDLE) : validHandle(handle) ? this.byHandle(handle) : void 0;
|
|
5002
5165
|
if (!row) throw unreachable(text);
|
|
5003
5166
|
return row;
|
|
@@ -6054,6 +6217,7 @@ export {
|
|
|
6054
6217
|
SHORT_ID,
|
|
6055
6218
|
STATUS_STATES,
|
|
6056
6219
|
SYSTEM_HANDLE,
|
|
6220
|
+
TITLE_CHARS,
|
|
6057
6221
|
VerdictCache,
|
|
6058
6222
|
XCounters,
|
|
6059
6223
|
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,20 @@ 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
|
|
249
259
|
}
|
|
250
260
|
|
|
251
261
|
export interface MessageRow {
|
|
@@ -442,6 +452,8 @@ export class RelayCore {
|
|
|
442
452
|
private readonly capEverySender: boolean
|
|
443
453
|
private readonly alive: (pid: number) => boolean
|
|
444
454
|
private readonly hostMachine: string | null
|
|
455
|
+
private readonly namedMachine: string | null
|
|
456
|
+
private readonly hostname: string | null
|
|
445
457
|
private policy: Policy | null = null
|
|
446
458
|
/** 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
459
|
private policyBinds = new Map<string, string>()
|
|
@@ -461,6 +473,14 @@ export class RelayCore {
|
|
|
461
473
|
this.capEverySender = options.capEverySender === true
|
|
462
474
|
this.alive = options.alive ?? (() => true)
|
|
463
475
|
this.hostMachine = options.machine ?? options.hostname ?? null
|
|
476
|
+
this.namedMachine = options.machine ?? null
|
|
477
|
+
this.hostname = options.hostname ?? null
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** a `machine` label naming this host by its hostname, as clients default it, names it by its machine name when one is set */
|
|
481
|
+
private machineLabel(labels: Record<string, string>): Record<string, string> {
|
|
482
|
+
if (this.namedMachine !== null && labels.machine !== undefined && labels.machine === this.hostname) labels.machine = this.namedMachine
|
|
483
|
+
return labels
|
|
464
484
|
}
|
|
465
485
|
|
|
466
486
|
migrate(): void {
|
|
@@ -496,6 +516,10 @@ export class RelayCore {
|
|
|
496
516
|
["directive_holds", "INTEGER"],
|
|
497
517
|
["harness_session", "TEXT"],
|
|
498
518
|
["harness_transcript", "TEXT"],
|
|
519
|
+
["title_auto", "TEXT"],
|
|
520
|
+
["title_turn", "INTEGER"],
|
|
521
|
+
["tldr_turn", "INTEGER"],
|
|
522
|
+
["named", "INTEGER"],
|
|
499
523
|
] as const) {
|
|
500
524
|
if (!endpointColumns.includes(column)) this.sql.all(`ALTER TABLE endpoints ADD COLUMN ${column} ${type}`)
|
|
501
525
|
}
|
|
@@ -658,7 +682,7 @@ export class RelayCore {
|
|
|
658
682
|
model: cleanText(claim.model, "model", 128),
|
|
659
683
|
cwd: cleanText(claim.cwd, "cwd", 1024),
|
|
660
684
|
parent: cleanText(claim.parent, "parent", 128),
|
|
661
|
-
labels: JSON.stringify(cleanLabels(claim.labels)),
|
|
685
|
+
labels: JSON.stringify(this.machineLabel(cleanLabels(claim.labels))),
|
|
662
686
|
session: cleanText(claim.session, "session", 128),
|
|
663
687
|
process: cleanText(claim.process, "process", 128),
|
|
664
688
|
}
|
|
@@ -773,6 +797,7 @@ export class RelayCore {
|
|
|
773
797
|
scope: ClaimScope = { handle: wanted, lineage: [], grant: null, rules: {}, groups: [], cwd: fields.cwd },
|
|
774
798
|
): { endpoint: EndpointView; token?: string; created?: true } {
|
|
775
799
|
const now = this.now()
|
|
800
|
+
this.endExited(now)
|
|
776
801
|
const bare = transient && fields.session !== null && BARE_PID_KEY.test(fields.session)
|
|
777
802
|
let row = fields.session
|
|
778
803
|
? one<EndpointRow>(
|
|
@@ -798,7 +823,14 @@ export class RelayCore {
|
|
|
798
823
|
} else if (row && !transient && !sameProcess(row, fields.process)) {
|
|
799
824
|
row = undefined
|
|
800
825
|
session = null
|
|
801
|
-
} else if (
|
|
826
|
+
} else if (
|
|
827
|
+
row &&
|
|
828
|
+
!transient &&
|
|
829
|
+
row.token_hash === null &&
|
|
830
|
+
row.named === null &&
|
|
831
|
+
row.handle !== wanted &&
|
|
832
|
+
!row.handle.startsWith(`${wanted.slice(0, 58)}-`)
|
|
833
|
+
) {
|
|
802
834
|
const hooks = row
|
|
803
835
|
let settled = false
|
|
804
836
|
for (let n = 1; n < (scope.exact ? 2 : 1000) && !settled; n++) {
|
|
@@ -1297,6 +1329,7 @@ export class RelayCore {
|
|
|
1297
1329
|
} = {},
|
|
1298
1330
|
): EndpointView[] {
|
|
1299
1331
|
const now = this.now()
|
|
1332
|
+
this.endExited(now)
|
|
1300
1333
|
this.settleOffline(now)
|
|
1301
1334
|
const cutoff = now - LIMITS.listOfflineWithinS * 1000
|
|
1302
1335
|
const viewer = filters.viewer === undefined ? undefined : this.mustEndpoint(filters.viewer)
|
|
@@ -1364,10 +1397,12 @@ export class RelayCore {
|
|
|
1364
1397
|
const text = oneLine(note, "note", LIMITS.noteChars)
|
|
1365
1398
|
if (text && findSecret(text)) throw this.refuse(endpoint, "secret_refused", "the status note looks like it carries a credential")
|
|
1366
1399
|
this.sql.all(
|
|
1367
|
-
`UPDATE endpoints SET status_state = COALESCE(?, status_state, 'working'), status_note = COALESCE(?, status_note, ''), last_seen =
|
|
1400
|
+
`UPDATE endpoints SET status_state = COALESCE(?, status_state, 'working'), status_note = COALESCE(?, status_note, ''), last_seen = ?,
|
|
1401
|
+
tldr_turn = CASE WHEN ? THEN COALESCE(prompts, 0) ELSE tldr_turn END WHERE id = ?`,
|
|
1368
1402
|
state ?? null,
|
|
1369
1403
|
text,
|
|
1370
1404
|
this.now(),
|
|
1405
|
+
text === null ? 0 : 1,
|
|
1371
1406
|
endpoint,
|
|
1372
1407
|
)
|
|
1373
1408
|
}
|
|
@@ -1378,11 +1413,126 @@ export class RelayCore {
|
|
|
1378
1413
|
if (row.kind !== "agent") throw new RelayError("invalid_params", "only an agent endpoint has a title")
|
|
1379
1414
|
const title = oneLine(value, "title", 64) || null
|
|
1380
1415
|
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 =
|
|
1416
|
+
this.sql.all(`UPDATE endpoints SET title = ?, title_auto = NULL, title_turn = NULL WHERE id = ?`, title, endpoint)
|
|
1382
1417
|
this.audit("title", endpoint, null, { title })
|
|
1383
1418
|
return this.whoami(endpoint)
|
|
1384
1419
|
}
|
|
1385
1420
|
|
|
1421
|
+
/**
|
|
1422
|
+
* 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.
|
|
1423
|
+
* Never over a title set explicitly; the first prompt fills only an empty title, and a summary replaces an inferred one at most every
|
|
1424
|
+
* ${LIMITS.titleRefreshTurns} prompts, or at once over the first prompt's
|
|
1425
|
+
*/
|
|
1426
|
+
inferTitle(endpoint: string, params: { text?: unknown; from?: unknown }, options: { transient?: boolean; process?: string } = {}): boolean {
|
|
1427
|
+
if (params.from !== "summary" && params.from !== "prompt") throw new RelayError("invalid_params", "from is summary or prompt")
|
|
1428
|
+
if (typeof params.text !== "string") throw new RelayError("invalid_params", "text must be a string")
|
|
1429
|
+
const row = this.mustEndpoint(endpoint)
|
|
1430
|
+
if (row.kind !== "agent" || !this.holds(row, options)) return false
|
|
1431
|
+
const title = stripControl(redactSecrets(params.text.slice(0, 4096).replace(/\s+/g, " ").trim()))
|
|
1432
|
+
.slice(0, TITLE_CHARS)
|
|
1433
|
+
.trim()
|
|
1434
|
+
if (!title || findSecret(title) || title === row.title) return false
|
|
1435
|
+
if (row.title !== null && row.title !== row.title_auto) return false
|
|
1436
|
+
const turn = row.prompts ?? 0
|
|
1437
|
+
if (params.from === "prompt" && row.title !== null) return false
|
|
1438
|
+
if (params.from === "summary" && row.title !== null && row.title_turn !== null && turn - row.title_turn < LIMITS.titleRefreshTurns) return false
|
|
1439
|
+
this.sql.all(`UPDATE endpoints SET title = ?, title_auto = ?, title_turn = ? WHERE id = ?`, title, title, params.from === "summary" ? turn : null, endpoint)
|
|
1440
|
+
return true
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
/**
|
|
1444
|
+
* a session joins on demand: its hook claim registers or takes back its endpoint, marked seen now even right after a leave, and `handle`,
|
|
1445
|
+
* when given, renames it in place ({@link rename}'s rules). Nothing is kept when the rename is refused
|
|
1446
|
+
*/
|
|
1447
|
+
joinSession(claim: Claim, handle?: unknown): EndpointView {
|
|
1448
|
+
return this.scopeTx(() => {
|
|
1449
|
+
const { endpoint } = this.claim(claim, { transient: true })
|
|
1450
|
+
const row = this.mustEndpoint(endpoint.id)
|
|
1451
|
+
if (handle !== undefined && handle !== null && handle !== "") this.renameRow(row, handle, false)
|
|
1452
|
+
this.sql.all(`UPDATE endpoints SET hook_seen = ?, hook_ended = NULL WHERE id = ?`, this.now(), row.id)
|
|
1453
|
+
this.audit("join", row.id, null, { handle: this.byId(row.id)!.handle })
|
|
1454
|
+
return this.whoami(row.id)
|
|
1455
|
+
})
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
/**
|
|
1459
|
+
* renames an agent in place: its id, mail, threads, status, title and relations stay. An agent renames only itself, and only a root unless
|
|
1460
|
+
* 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
|
|
1461
|
+
*/
|
|
1462
|
+
rename(actor: ScopeActor, params: { endpoint?: unknown; handle: unknown }): { endpoint: EndpointView; from: string; session: string | null } {
|
|
1463
|
+
return this.scopeTx(() => {
|
|
1464
|
+
const me = this.actorRow(actor)
|
|
1465
|
+
if (actor.kind === "human") this.needTerminal(actor, me, params.endpoint)
|
|
1466
|
+
else if (actor.auth === "lineage") throw new RelayError("scope_refused", "only the session itself renames it: run this from the session's own shell")
|
|
1467
|
+
const target = params.endpoint === undefined ? me : this.resolve(params.endpoint)
|
|
1468
|
+
if (actor.kind !== "human" && target.id !== me.id) throw new RelayError("scope_refused", "an agent renames only itself")
|
|
1469
|
+
if (target.kind !== "agent" || !this.localRow(target)) throw new RelayError("invalid_params", "only a local agent endpoint can be renamed")
|
|
1470
|
+
this.renameRow(target, params.handle, actor.kind === "human")
|
|
1471
|
+
return { endpoint: this.whoami(target.id), from: target.handle, session: target.session }
|
|
1472
|
+
})
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
/** the endpoint's handle set to `value`, with the stored `ep:` and `fleet:` selectors that named it */
|
|
1476
|
+
private renameRow(row: EndpointRow, value: unknown, byHuman: boolean): void {
|
|
1477
|
+
const handle = typeof value === "string" ? value.trim().replace(/^me\//, "") : ""
|
|
1478
|
+
if (!validHandle(handle)) throw new RelayError("invalid_params", `handle ${JSON.stringify(handle)} is not valid: lowercase letters, digits, . _ -`)
|
|
1479
|
+
if (findSecret(handle)) throw new RelayError("secret_refused", "the handle looks like it carries a credential")
|
|
1480
|
+
if (handle === row.handle) {
|
|
1481
|
+
this.sql.all(`UPDATE endpoints SET named = COALESCE(named, ?) WHERE id = ?`, this.now(), row.id)
|
|
1482
|
+
return
|
|
1483
|
+
}
|
|
1484
|
+
if (!byHuman && row.parent_ep !== null && this.policy?.defaults?.rename !== "self") {
|
|
1485
|
+
const parent = this.byId(row.parent_ep)
|
|
1486
|
+
throw new RelayError("scope_refused", `spawned by ${parent ? this.address(parent) : "another agent"}; its name is set by the spawner`)
|
|
1487
|
+
}
|
|
1488
|
+
const held = this.byHandle(handle)
|
|
1489
|
+
if (held) {
|
|
1490
|
+
const how = held.kind !== "agent" ? "reserved" : presenceOf(held, this.now()) === "offline" ? "held by an offline endpoint" : "held by a live endpoint"
|
|
1491
|
+
throw new RelayError("invalid_params", `${handle} is ${how}: pick another name`)
|
|
1492
|
+
}
|
|
1493
|
+
const from = row.handle
|
|
1494
|
+
this.sql.all(`UPDATE endpoints SET handle = ?, named = ? WHERE id = ?`, handle, this.now(), row.id)
|
|
1495
|
+
this.sql.all(`UPDATE grants SET name = ? WHERE used_by = ? AND name = ?`, handle, row.id, from)
|
|
1496
|
+
for (const prefix of row.parent_ep === null ? ["ep:", "fleet:"] : ["ep:"]) {
|
|
1497
|
+
const [a, b] = [JSON.stringify(`${prefix}${from}`), JSON.stringify(`${prefix}${handle}`)]
|
|
1498
|
+
for (const [table, column] of [
|
|
1499
|
+
["endpoints", "rules"],
|
|
1500
|
+
["endpoints", "caps"],
|
|
1501
|
+
["groups", "rules"],
|
|
1502
|
+
["grants", "caps"],
|
|
1503
|
+
] as const)
|
|
1504
|
+
this.sql.all(`UPDATE ${table} SET ${column} = REPLACE(${column}, ?, ?) WHERE instr(${column}, ?) > 0`, a, b, a)
|
|
1505
|
+
}
|
|
1506
|
+
this.audit("rename", row.id, null, { from, to: handle })
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
/**
|
|
1510
|
+
* 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
|
|
1511
|
+
* itself, the human at a terminal any local root. A spawned worker stays joined while it runs
|
|
1512
|
+
*/
|
|
1513
|
+
leaveSession(actor: ScopeActor, params: { endpoint?: unknown } = {}): { endpoint: EndpointView; session: string | null } {
|
|
1514
|
+
return this.scopeTx(() => {
|
|
1515
|
+
const me = this.actorRow(actor)
|
|
1516
|
+
if (actor.kind === "human") this.needTerminal(actor, me, params.endpoint)
|
|
1517
|
+
else if (actor.auth === "lineage") throw new RelayError("scope_refused", "only the session itself leaves: run this from the session's own shell")
|
|
1518
|
+
const target = params.endpoint === undefined ? me : this.resolve(params.endpoint)
|
|
1519
|
+
if (actor.kind !== "human" && target.id !== me.id) throw new RelayError("scope_refused", "an agent leaves only for itself")
|
|
1520
|
+
if (target.kind !== "agent" || !this.localRow(target)) throw new RelayError("invalid_params", "only a local agent endpoint can leave")
|
|
1521
|
+
if (target.parent_ep !== null) {
|
|
1522
|
+
const parent = this.byId(target.parent_ep)
|
|
1523
|
+
throw new RelayError("scope_refused", `spawned by ${parent ? this.address(parent) : "another agent"}; a spawned worker stays joined while it runs`)
|
|
1524
|
+
}
|
|
1525
|
+
this.sql.all(`UPDATE endpoints SET session = NULL, token_hash = NULL, hook_seen = NULL, hook_ended = ? WHERE id = ?`, this.now(), target.id)
|
|
1526
|
+
this.audit("leave", target.id, null, { handle: target.handle })
|
|
1527
|
+
return { endpoint: this.whoami(target.id), session: target.session }
|
|
1528
|
+
})
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
/** the harness session key an endpoint holds, if any */
|
|
1532
|
+
sessionOf(endpoint: string): string | null {
|
|
1533
|
+
return this.byId(endpoint)?.session ?? null
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1386
1536
|
// messages
|
|
1387
1537
|
|
|
1388
1538
|
/**
|
|
@@ -1443,6 +1593,17 @@ export class RelayCore {
|
|
|
1443
1593
|
resolved.push(row)
|
|
1444
1594
|
}
|
|
1445
1595
|
const recipients = [...new Map(resolved.map((row) => [row.id, row])).values()]
|
|
1596
|
+
if (params.live !== undefined && typeof params.live !== "boolean") throw new RelayError("invalid_params", "live must be true or false")
|
|
1597
|
+
const dark = recipients.filter(
|
|
1598
|
+
(r) =>
|
|
1599
|
+
r.kind === "agent" &&
|
|
1600
|
+
presenceOf(r, now) === "offline" &&
|
|
1601
|
+
(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)) &&
|
|
1602
|
+
!this.unjoinedSpawn(sender, r.handle, now),
|
|
1603
|
+
)
|
|
1604
|
+
if (params.live === true && dark.length)
|
|
1605
|
+
throw new RelayError("not_live", `no live session holds ${dark.map((r) => this.address(r)).join(", ")}: nothing was queued`)
|
|
1606
|
+
const warnings = dark.map((r) => `queued: no live session holds ${this.address(r)}`)
|
|
1446
1607
|
const pending = [...unjoined.values()].map((grant) => ({ key: pendingKey(grant.token_hash), address: `${this.user}/${grant.name}` }))
|
|
1447
1608
|
const reached = recipients.length + pending.length
|
|
1448
1609
|
if (reached > LIMITS.fanOutTotal) throw new RelayError("too_large", `the send reaches ${reached} endpoints, more than ${LIMITS.fanOutTotal}`)
|
|
@@ -1672,7 +1833,7 @@ export class RelayCore {
|
|
|
1672
1833
|
deliveries.push({ endpoint: key, address, seq, offline: false, queued: true, last_seen: null, state: "starting" })
|
|
1673
1834
|
}
|
|
1674
1835
|
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 }
|
|
1836
|
+
const sent: SendResult = { id, thread, hop, deliveries, ...(warnings.length ? { warnings } : {}) }
|
|
1676
1837
|
if (clientId) {
|
|
1677
1838
|
this.sql.all(
|
|
1678
1839
|
`INSERT OR REPLACE INTO idempotency (from_ep, client_id, payload_hash, result, created_at) VALUES (?, ?, ?, ?, ?)`,
|
|
@@ -1819,10 +1980,11 @@ export class RelayCore {
|
|
|
1819
1980
|
* `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
1981
|
* `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
1982
|
* ({@link introduced}). A hook of another process than the one holding the endpoint (a child that inherited the session key) gets nothing.
|
|
1983
|
+
* A wake also offers live workers' messages.
|
|
1822
1984
|
*/
|
|
1823
1985
|
inject(
|
|
1824
1986
|
endpoint: string,
|
|
1825
|
-
options: { stop?: boolean; count?: boolean; holdUnread?: boolean; directives?: boolean; transient?: boolean; process?: string } = {},
|
|
1987
|
+
options: { stop?: boolean; count?: boolean; holdUnread?: boolean; directives?: boolean; transient?: boolean; process?: string; wake?: boolean } = {},
|
|
1826
1988
|
): {
|
|
1827
1989
|
messages: Envelope[]
|
|
1828
1990
|
offered: number[]
|
|
@@ -1912,7 +2074,7 @@ export class RelayCore {
|
|
|
1912
2074
|
now,
|
|
1913
2075
|
LIMITS.inboxPage,
|
|
1914
2076
|
)
|
|
1915
|
-
const { shown, offered } = this.budget(rows, LIMITS.injectBytes, () => this.pushable(me, now))
|
|
2077
|
+
const { shown, offered } = this.budget(rows, LIMITS.injectBytes, options.wake ? undefined : () => this.pushable(me, now))
|
|
1916
2078
|
const hold = this.hold(offered, now)
|
|
1917
2079
|
this.received(endpoint, shown, now)
|
|
1918
2080
|
return { messages: shown.map((row) => this.envelope(row, row.seq)), offered, hold, more: waiting - shown.length, unread, block: false, ...brief, ...intro }
|
|
@@ -2100,6 +2262,16 @@ export class RelayCore {
|
|
|
2100
2262
|
return true
|
|
2101
2263
|
}
|
|
2102
2264
|
|
|
2265
|
+
/** 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 */
|
|
2266
|
+
tldrDue(endpoint: string, every: number): boolean {
|
|
2267
|
+
if (!Number.isInteger(every) || every <= 0) return false
|
|
2268
|
+
const row = this.mustEndpoint(endpoint)
|
|
2269
|
+
const turn = row.prompts ?? 0
|
|
2270
|
+
if (row.kind !== "agent" || turn - (row.tldr_turn ?? 0) < every) return false
|
|
2271
|
+
this.sql.all(`UPDATE endpoints SET tldr_turn = ? WHERE id = ?`, turn, endpoint)
|
|
2272
|
+
return true
|
|
2273
|
+
}
|
|
2274
|
+
|
|
2103
2275
|
/** the background hooks' showings ({@link injected}) older than ${LIMITS.deferredSettleMs} ms reached the agent: inbox no longer lists them */
|
|
2104
2276
|
private settle(endpoint: string): void {
|
|
2105
2277
|
this.sql.all(
|
|
@@ -2612,6 +2784,7 @@ export class RelayCore {
|
|
|
2612
2784
|
this.sql.all(`DELETE FROM idempotency WHERE created_at <= ?`, now - LIMITS.idempotencyS * 1000)
|
|
2613
2785
|
this.sql.all(`DELETE FROM pair_events WHERE at <= ?`, now - 3600 * 1000)
|
|
2614
2786
|
this.sql.all(`DELETE FROM blocks WHERE until <= ?`, now)
|
|
2787
|
+
this.endExited(now)
|
|
2615
2788
|
this.settleOffline(now)
|
|
2616
2789
|
this.sql.all(
|
|
2617
2790
|
`DELETE FROM endpoints WHERE kind = 'agent' AND connections = 0 AND COALESCE(last_seen, created_at) <= ?
|
|
@@ -2638,6 +2811,17 @@ export class RelayCore {
|
|
|
2638
2811
|
}
|
|
2639
2812
|
}
|
|
2640
2813
|
|
|
2814
|
+
/** ends the hook presence of local agent endpoints whose harness process exited without a session_end, as one would */
|
|
2815
|
+
endExited(now = this.now()): void {
|
|
2816
|
+
for (const row of this.sql.all<EndpointRow>(
|
|
2817
|
+
`SELECT * FROM endpoints WHERE kind = 'agent' AND connections = 0 AND hook_seen IS NOT NULL AND process IS NOT NULL`,
|
|
2818
|
+
)) {
|
|
2819
|
+
const pid = Number(/^pid:(\d+)/.exec(row.process!)?.[1])
|
|
2820
|
+
if (!pid || !this.localRow(row) || this.alive(pid)) continue
|
|
2821
|
+
this.sql.all(`UPDATE endpoints SET hook_seen = NULL, hook_ended = ?, session = NULL WHERE id = ? AND connections = 0`, now, row.id)
|
|
2822
|
+
}
|
|
2823
|
+
}
|
|
2824
|
+
|
|
2641
2825
|
/** an agent endpoint left offline drops a working or waiting status, which no one is left to keep current */
|
|
2642
2826
|
private settleOffline(now: number): void {
|
|
2643
2827
|
for (const row of this.sql.all<EndpointRow>(
|
|
@@ -2697,15 +2881,19 @@ export class RelayCore {
|
|
|
2697
2881
|
endpoint,
|
|
2698
2882
|
)
|
|
2699
2883
|
} else if (params.kind === "session_start") {
|
|
2884
|
+
const fresh = own && summary !== "compact" && summary !== "resume"
|
|
2700
2885
|
this.sql.all(
|
|
2701
2886
|
`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
|
|
2887
|
+
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,
|
|
2888
|
+
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
2889
|
options.transient ? now : null,
|
|
2704
2890
|
own ? 1 : 0,
|
|
2705
2891
|
own ? 1 : 0,
|
|
2706
2892
|
own ? 1 : 0,
|
|
2707
2893
|
own ? 1 : 0,
|
|
2708
2894
|
own ? 1 : 0,
|
|
2895
|
+
fresh ? 1 : 0,
|
|
2896
|
+
fresh ? 1 : 0,
|
|
2709
2897
|
endpoint,
|
|
2710
2898
|
)
|
|
2711
2899
|
} else if (options.transient) {
|
|
@@ -4657,7 +4845,7 @@ export class RelayCore {
|
|
|
4657
4845
|
|
|
4658
4846
|
/** the user parts an address of this core may carry */
|
|
4659
4847
|
protected addressUsers(): string[] {
|
|
4660
|
-
return [this.user, "me"]
|
|
4848
|
+
return [this.user, "me", "local"]
|
|
4661
4849
|
}
|
|
4662
4850
|
|
|
4663
4851
|
/** whether the row is this host's own, the only rows with a tree, groups, rules and caps here */
|
|
@@ -4671,7 +4859,7 @@ export class RelayCore {
|
|
|
4671
4859
|
const slash = text.indexOf("/")
|
|
4672
4860
|
const user = slash === -1 ? null : text.slice(0, slash)
|
|
4673
4861
|
const handle = slash === -1 ? text : text.slice(slash + 1)
|
|
4674
|
-
if (user !== null && user !== "me" && user !== this.user) throw unreachable(text)
|
|
4862
|
+
if (user !== null && user !== "me" && user !== "local" && user !== this.user) throw unreachable(text)
|
|
4675
4863
|
const row = handle === "" ? this.byHandle(HUMAN_HANDLE) : validHandle(handle) ? this.byHandle(handle) : undefined
|
|
4676
4864
|
if (!row) throw unreachable(text)
|
|
4677
4865
|
return row
|
package/src/index.ts
CHANGED
package/src/policy.ts
CHANGED
|
@@ -36,6 +36,8 @@ 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"
|
|
39
41
|
}
|
|
40
42
|
|
|
41
43
|
export interface Policy {
|
|
@@ -164,7 +166,7 @@ export function parsePolicy(value: unknown, options: { judgeProviders?: Readonly
|
|
|
164
166
|
}
|
|
165
167
|
if (defaults !== undefined) {
|
|
166
168
|
if (!isObject(defaults)) throw new RelayError("invalid_params", "defaults is an object")
|
|
167
|
-
const { human_always, symmetric_presets, child_default, verify_lineage, ...extra } = defaults
|
|
169
|
+
const { human_always, symmetric_presets, child_default, verify_lineage, rename, ...extra } = defaults
|
|
168
170
|
if (Object.keys(extra).length) throw new RelayError("invalid_params", `unknown defaults: ${Object.keys(extra).join(", ")}`)
|
|
169
171
|
const parsed: PolicyDefaults = {}
|
|
170
172
|
for (const [key, flag] of [
|
|
@@ -188,6 +190,10 @@ export function parsePolicy(value: unknown, options: { judgeProviders?: Readonly
|
|
|
188
190
|
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
191
|
parsed.verify_lineage = verify_lineage
|
|
190
192
|
}
|
|
193
|
+
if (rename !== undefined) {
|
|
194
|
+
if (rename !== "root" && rename !== "self") throw new RelayError("invalid_params", "defaults.rename is root or self")
|
|
195
|
+
parsed.rename = rename
|
|
196
|
+
}
|
|
191
197
|
policy.defaults = parsed
|
|
192
198
|
}
|
|
193
199
|
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
|