@scp-dev/core 0.1.0 → 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/README.md +40 -0
- package/dist/index.d.ts +138 -11
- package/dist/index.js +323 -37
- package/package.json +1 -1
- package/src/core.ts +330 -35
- package/src/handback.ts +7 -4
- package/src/index.ts +3 -0
- package/src/policy.ts +7 -1
- package/src/protocol.ts +29 -0
- package/src/render.ts +34 -6
- package/src/sql.ts +1 -1
package/dist/index.js
CHANGED
|
@@ -132,10 +132,18 @@ var LIMITS = {
|
|
|
132
132
|
pushes: 20,
|
|
133
133
|
/** how long a live worker's next message waits after a push showed one of its own */
|
|
134
134
|
pushGapS: 30,
|
|
135
|
+
/** with other workers still running, how long a stop's push waits for one more worker result before it goes */
|
|
136
|
+
pushQuietS: 3,
|
|
137
|
+
/** the longest a stop's push gathers worker results, from the first it has to show */
|
|
138
|
+
pushGraceS: 6,
|
|
135
139
|
stopBlocks: 3,
|
|
136
140
|
/** the stops a session's late parent directives may hold */
|
|
137
141
|
directiveHolds: 5,
|
|
138
142
|
injectHoldS: 10,
|
|
143
|
+
/** how long inbox still lists a message a background hook showed, whose text the harness may hand the agent only after its next tool call */
|
|
144
|
+
deferredShowS: 600,
|
|
145
|
+
/** a deferred showing older than this settles at the session's next tool event or prompt: the harness has handed it over by then */
|
|
146
|
+
deferredSettleMs: 2e3,
|
|
139
147
|
listOfflineWithinS: 86400,
|
|
140
148
|
handleBoundS: 30 * 86400,
|
|
141
149
|
idempotencyS: 86400,
|
|
@@ -156,7 +164,9 @@ var LIMITS = {
|
|
|
156
164
|
groupPerMinute: 60,
|
|
157
165
|
grantTtlS: 7 * 86400,
|
|
158
166
|
groupIdleS: 86400,
|
|
159
|
-
policyLoadsKept: 8
|
|
167
|
+
policyLoadsKept: 8,
|
|
168
|
+
/** the prompts between two refreshes of a title a hook inferred */
|
|
169
|
+
titleRefreshTurns: 5
|
|
160
170
|
};
|
|
161
171
|
var RelayError = class extends Error {
|
|
162
172
|
constructor(code, message, retryAfterS, detail) {
|
|
@@ -691,7 +701,7 @@ function parsePolicy(value, options = {}) {
|
|
|
691
701
|
}
|
|
692
702
|
if (defaults !== void 0) {
|
|
693
703
|
if (!isObject2(defaults)) throw new RelayError("invalid_params", "defaults is an object");
|
|
694
|
-
const { human_always, symmetric_presets, child_default, verify_lineage, ...extra } = defaults;
|
|
704
|
+
const { human_always, symmetric_presets, child_default, verify_lineage, rename, ...extra } = defaults;
|
|
695
705
|
if (Object.keys(extra).length) throw new RelayError("invalid_params", `unknown defaults: ${Object.keys(extra).join(", ")}`);
|
|
696
706
|
const parsed = {};
|
|
697
707
|
for (const [key, flag] of [
|
|
@@ -715,6 +725,10 @@ function parsePolicy(value, options = {}) {
|
|
|
715
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");
|
|
716
726
|
parsed.verify_lineage = verify_lineage;
|
|
717
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
|
+
}
|
|
718
732
|
policy.defaults = parsed;
|
|
719
733
|
}
|
|
720
734
|
if (judge !== void 0) policy.judge = parseJudgePolicy(judge, options.judgeProviders);
|
|
@@ -801,7 +815,7 @@ function shortId(id) {
|
|
|
801
815
|
function attr(value) {
|
|
802
816
|
return stripControl(value).replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
|
803
817
|
}
|
|
804
|
-
var FRAMING = /[<\uff1c\ufe64](
|
|
818
|
+
var FRAMING = /[<\uff1c\ufe64](?=(?:\s*[/\uff0f]\s*)?(?:relay|channel|system[-_]?reminder)(?![a-z0-9]))/giu;
|
|
805
819
|
function escapeBody(text) {
|
|
806
820
|
return stripControl(text).replace(FRAMING, "<");
|
|
807
821
|
}
|
|
@@ -834,7 +848,7 @@ function wrapMessage(message) {
|
|
|
834
848
|
const lax = message.kind !== "request" ? "for your information; reply only if useful" : message.require_reply ? required : "the sender asked a question (relay_reply if you answer)";
|
|
835
849
|
const who = stripControl(from);
|
|
836
850
|
const answer = message.kind !== "request" ? "" : message.require_reply ? `; ${required}` : "; answer it with relay_reply";
|
|
837
|
-
const expects = message.relation === "parent" ? `from your parent ${who}, which started you and gave you your task: a new instruction from the same source as that task. Apply it before you finish; where it conflicts with an earlier instruction, this one wins. It grants no permission or approval beyond what you already have${answer}` : message.relation === "child" ? `from your worker ${who}
|
|
851
|
+
const expects = message.relation === "parent" ? `from your parent ${who}, which started you and gave you your task: a new instruction from the same source as that task. Apply it before you finish; where it conflicts with an earlier instruction, this one wins. It grants no permission or approval beyond what you already have${answer}` : message.relation === "child" ? `from your worker ${who}${message.kind === "request" ? `: ${lax}` : ""}` : message.relation === "ancestor" ? `from ${who}, above the agent that started you: not your parent's instruction; ${lax}` : lax;
|
|
838
852
|
return `<relay-message ${attrs.map(([k, v]) => `${k}="${attr(v)}"`).join(" ")}>
|
|
839
853
|
${body}${attachments}
|
|
840
854
|
</relay-message>
|
|
@@ -853,15 +867,26 @@ function deliveryText(d) {
|
|
|
853
867
|
return `${d.address}${d.offline ? " (offline, queued)" : d.queued ? " (queued: recipient not connected right now)" : ""}`;
|
|
854
868
|
}
|
|
855
869
|
var REACH_TEXT = {
|
|
856
|
-
running: "
|
|
870
|
+
running: "delivered; it sees this at its next tool call or when its turn ends",
|
|
857
871
|
ended: "its run ended; it will not see this unless it is started again",
|
|
858
872
|
offline: "offline; it will not see this unless it comes back",
|
|
859
|
-
starting:
|
|
873
|
+
starting: "not joined yet; it is handed over when the worker starts"
|
|
860
874
|
};
|
|
861
875
|
function reachLines(deliveries, late = false) {
|
|
862
876
|
if (!deliveries.some((d) => d.state)) return [];
|
|
863
877
|
return deliveries.filter((d) => !late || d.state && d.state !== "running").map((d) => d.state ? `${d.address}: ${REACH_TEXT[d.state]}` : deliveryText(d));
|
|
864
878
|
}
|
|
879
|
+
var pad = (n) => String(n).padStart(2, "0");
|
|
880
|
+
function clock(at) {
|
|
881
|
+
const date = new Date(at);
|
|
882
|
+
return Number.isNaN(date.getTime()) ? String(at) : `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
|
883
|
+
}
|
|
884
|
+
function receiptLines(receipts) {
|
|
885
|
+
return receipts.recipients.map((r) => {
|
|
886
|
+
const state = r.read_at ? `read at ${clock(r.read_at)}` : r.delivered_at ? `delivered at ${clock(r.delivered_at)} (it reaches the model with its next request)` : r.shown_at ? `shown by a hook at ${clock(r.shown_at)}` : "unread";
|
|
887
|
+
return `${r.address}: ${state}`;
|
|
888
|
+
});
|
|
889
|
+
}
|
|
865
890
|
function widenText(change, endpoint) {
|
|
866
891
|
const lines = [`added: ${change.added.join(", ") || "nothing"}`];
|
|
867
892
|
if (change.clipped.length) lines.push(`clipped (beyond your own reach): ${change.clipped.join(", ")}`);
|
|
@@ -1015,9 +1040,11 @@ function keepLast(text, bytes, dropped = false) {
|
|
|
1015
1040
|
if (!text || !dropped && encoded.length <= bytes) return text;
|
|
1016
1041
|
const mark = bytes > HANDBACK_CUT.length + 1 ? `${HANDBACK_CUT}
|
|
1017
1042
|
` : "";
|
|
1018
|
-
|
|
1043
|
+
const reach = bytes - mark.length;
|
|
1044
|
+
let start = Math.max(0, encoded.length - reach);
|
|
1019
1045
|
const nl = start > 0 && encoded[start - 1] !== 10 ? encoded.indexOf(10, start) : -1;
|
|
1020
|
-
|
|
1046
|
+
const long = nl !== -1 && nl - (encoded.lastIndexOf(10, start - 1) + 1) > reach && encoded.length - (nl + 1) < reach / 2;
|
|
1047
|
+
if (nl !== -1 && nl + 1 < encoded.length && !long) start = nl + 1;
|
|
1021
1048
|
while (start > 0 && start < encoded.length - 1 && encoded[start] === 10) start++;
|
|
1022
1049
|
while (start < encoded.length && (encoded[start] & 192) === 128) start++;
|
|
1023
1050
|
return `${mark}${new TextDecoder().decode(encoded.subarray(start))}`;
|
|
@@ -1044,6 +1071,7 @@ function utf8Bytes(text) {
|
|
|
1044
1071
|
|
|
1045
1072
|
// src/core.ts
|
|
1046
1073
|
var HUMAN_HANDLE = "_human";
|
|
1074
|
+
var TITLE_CHARS = 64;
|
|
1047
1075
|
var SYSTEM_HANDLE = "_relay";
|
|
1048
1076
|
var GONE_PREFIX = "_gone_";
|
|
1049
1077
|
var MIGRATION = [
|
|
@@ -1100,7 +1128,9 @@ var MIGRATION = [
|
|
|
1100
1128
|
offered_until INTEGER,
|
|
1101
1129
|
offered_hold TEXT,
|
|
1102
1130
|
injected_at INTEGER,
|
|
1103
|
-
nudged_at INTEGER
|
|
1131
|
+
nudged_at INTEGER,
|
|
1132
|
+
deferred_at INTEGER,
|
|
1133
|
+
deferred_ack INTEGER
|
|
1104
1134
|
)`,
|
|
1105
1135
|
`CREATE INDEX IF NOT EXISTS deliveries_inbox ON deliveries (endpoint, acked_at, seq)`,
|
|
1106
1136
|
`CREATE INDEX IF NOT EXISTS deliveries_message ON deliveries (message_id)`,
|
|
@@ -1224,6 +1254,8 @@ var RelayCore = class {
|
|
|
1224
1254
|
this.capEverySender = options.capEverySender === true;
|
|
1225
1255
|
this.alive = options.alive ?? (() => true);
|
|
1226
1256
|
this.hostMachine = options.machine ?? options.hostname ?? null;
|
|
1257
|
+
this.namedMachine = options.machine ?? null;
|
|
1258
|
+
this.hostname = options.hostname ?? null;
|
|
1227
1259
|
}
|
|
1228
1260
|
sql;
|
|
1229
1261
|
user;
|
|
@@ -1233,6 +1265,8 @@ var RelayCore = class {
|
|
|
1233
1265
|
capEverySender;
|
|
1234
1266
|
alive;
|
|
1235
1267
|
hostMachine;
|
|
1268
|
+
namedMachine;
|
|
1269
|
+
hostname;
|
|
1236
1270
|
policy = null;
|
|
1237
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` */
|
|
1238
1272
|
policyBinds = /* @__PURE__ */ new Map();
|
|
@@ -1240,6 +1274,11 @@ var RelayCore = class {
|
|
|
1240
1274
|
/** while a scope world derives a node: what it already derived of ancestor lists and limits */
|
|
1241
1275
|
scopeMemo = null;
|
|
1242
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
|
+
}
|
|
1243
1282
|
migrate() {
|
|
1244
1283
|
for (const statement of MIGRATION) this.sql.all(statement);
|
|
1245
1284
|
const columns = this.sql.all(`PRAGMA table_info(idempotency)`).map((c) => c.name);
|
|
@@ -1272,7 +1311,11 @@ var RelayCore = class {
|
|
|
1272
1311
|
["introduced", "INTEGER"],
|
|
1273
1312
|
["directive_holds", "INTEGER"],
|
|
1274
1313
|
["harness_session", "TEXT"],
|
|
1275
|
-
["harness_transcript", "TEXT"]
|
|
1314
|
+
["harness_transcript", "TEXT"],
|
|
1315
|
+
["title_auto", "TEXT"],
|
|
1316
|
+
["title_turn", "INTEGER"],
|
|
1317
|
+
["tldr_turn", "INTEGER"],
|
|
1318
|
+
["named", "INTEGER"]
|
|
1276
1319
|
]) {
|
|
1277
1320
|
if (!endpointColumns.includes(column)) this.sql.all(`ALTER TABLE endpoints ADD COLUMN ${column} ${type}`);
|
|
1278
1321
|
}
|
|
@@ -1301,6 +1344,8 @@ var RelayCore = class {
|
|
|
1301
1344
|
if (!deliveryColumns.includes("injected_at")) this.sql.all(`ALTER TABLE deliveries ADD COLUMN injected_at INTEGER`);
|
|
1302
1345
|
if (!deliveryColumns.includes("nudged_at")) this.sql.all(`ALTER TABLE deliveries ADD COLUMN nudged_at INTEGER`);
|
|
1303
1346
|
if (!deliveryColumns.includes("restated_at")) this.sql.all(`ALTER TABLE deliveries ADD COLUMN restated_at INTEGER`);
|
|
1347
|
+
if (!deliveryColumns.includes("deferred_at")) this.sql.all(`ALTER TABLE deliveries ADD COLUMN deferred_at INTEGER`);
|
|
1348
|
+
if (!deliveryColumns.includes("deferred_ack")) this.sql.all(`ALTER TABLE deliveries ADD COLUMN deferred_ack INTEGER`);
|
|
1304
1349
|
const activityColumns = this.sql.all(`PRAGMA table_info(activity)`).map((c) => c.name);
|
|
1305
1350
|
if (!activityColumns.includes("agent")) this.sql.all(`ALTER TABLE activity ADD COLUMN agent TEXT`);
|
|
1306
1351
|
if (!this.byHandle(SYSTEM_HANDLE)) {
|
|
@@ -1427,7 +1472,7 @@ var RelayCore = class {
|
|
|
1427
1472
|
model: cleanText(claim.model, "model", 128),
|
|
1428
1473
|
cwd: cleanText(claim.cwd, "cwd", 1024),
|
|
1429
1474
|
parent: cleanText(claim.parent, "parent", 128),
|
|
1430
|
-
labels: JSON.stringify(cleanLabels(claim.labels)),
|
|
1475
|
+
labels: JSON.stringify(this.machineLabel(cleanLabels(claim.labels))),
|
|
1431
1476
|
session: cleanText(claim.session, "session", 128),
|
|
1432
1477
|
process: cleanText(claim.process, "process", 128)
|
|
1433
1478
|
};
|
|
@@ -1526,6 +1571,7 @@ var RelayCore = class {
|
|
|
1526
1571
|
}
|
|
1527
1572
|
claimRow(wanted, harness, fields, transient, scope = { handle: wanted, lineage: [], grant: null, rules: {}, groups: [], cwd: fields.cwd }) {
|
|
1528
1573
|
const now = this.now();
|
|
1574
|
+
this.endExited(now);
|
|
1529
1575
|
const bare = transient && fields.session !== null && BARE_PID_KEY.test(fields.session);
|
|
1530
1576
|
let row = fields.session ? one(
|
|
1531
1577
|
this.sql,
|
|
@@ -1549,7 +1595,7 @@ var RelayCore = class {
|
|
|
1549
1595
|
} else if (row && !transient && !sameProcess(row, fields.process)) {
|
|
1550
1596
|
row = void 0;
|
|
1551
1597
|
session = null;
|
|
1552
|
-
} 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)}-`)) {
|
|
1553
1599
|
const hooks = row;
|
|
1554
1600
|
let settled = false;
|
|
1555
1601
|
for (let n = 1; n < (scope.exact ? 2 : 1e3) && !settled; n++) {
|
|
@@ -1987,6 +2033,7 @@ var RelayCore = class {
|
|
|
1987
2033
|
*/
|
|
1988
2034
|
list(filters = {}) {
|
|
1989
2035
|
const now = this.now();
|
|
2036
|
+
this.endExited(now);
|
|
1990
2037
|
this.settleOffline(now);
|
|
1991
2038
|
const cutoff = now - LIMITS.listOfflineWithinS * 1e3;
|
|
1992
2039
|
const viewer = filters.viewer === void 0 ? void 0 : this.mustEndpoint(filters.viewer);
|
|
@@ -2040,10 +2087,12 @@ var RelayCore = class {
|
|
|
2040
2087
|
const text = oneLine(note, "note", LIMITS.noteChars);
|
|
2041
2088
|
if (text && findSecret(text)) throw this.refuse(endpoint, "secret_refused", "the status note looks like it carries a credential");
|
|
2042
2089
|
this.sql.all(
|
|
2043
|
-
`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 = ?`,
|
|
2044
2092
|
state ?? null,
|
|
2045
2093
|
text,
|
|
2046
2094
|
this.now(),
|
|
2095
|
+
text === null ? 0 : 1,
|
|
2047
2096
|
endpoint
|
|
2048
2097
|
);
|
|
2049
2098
|
}
|
|
@@ -2053,10 +2102,117 @@ var RelayCore = class {
|
|
|
2053
2102
|
if (row.kind !== "agent") throw new RelayError("invalid_params", "only an agent endpoint has a title");
|
|
2054
2103
|
const title = oneLine(value, "title", 64) || null;
|
|
2055
2104
|
if (title && findSecret(title)) throw this.refuse(endpoint, "secret_refused", "the title looks like it carries a credential");
|
|
2056
|
-
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);
|
|
2057
2106
|
this.audit("title", endpoint, null, { title });
|
|
2058
2107
|
return this.whoami(endpoint);
|
|
2059
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
|
+
}
|
|
2060
2216
|
// messages
|
|
2061
2217
|
/**
|
|
2062
2218
|
* `origin.tty` is recorded on messages the human inbox sends: whether its client said a terminal was attached. `origin.unverified`: the human
|
|
@@ -2114,6 +2270,13 @@ var RelayCore = class {
|
|
|
2114
2270
|
resolved.push(row);
|
|
2115
2271
|
}
|
|
2116
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)}`);
|
|
2117
2280
|
const pending = [...unjoined.values()].map((grant) => ({ key: pendingKey(grant.token_hash), address: `${this.user}/${grant.name}` }));
|
|
2118
2281
|
const reached = recipients.length + pending.length;
|
|
2119
2282
|
if (reached > LIMITS.fanOutTotal) throw new RelayError("too_large", `the send reaches ${reached} endpoints, more than ${LIMITS.fanOutTotal}`);
|
|
@@ -2331,7 +2494,7 @@ var RelayCore = class {
|
|
|
2331
2494
|
deliveries.push({ endpoint: key, address, seq, offline: false, queued: true, last_seen: null, state: "starting" });
|
|
2332
2495
|
}
|
|
2333
2496
|
for (const name of fanOut) this.sql.all(`INSERT INTO tree_events (tree, at) VALUES (?, ?)`, `group:${name}`, now);
|
|
2334
|
-
const sent = { id, thread, hop, deliveries };
|
|
2497
|
+
const sent = { id, thread, hop, deliveries, ...warnings.length ? { warnings } : {} };
|
|
2335
2498
|
if (clientId) {
|
|
2336
2499
|
this.sql.all(
|
|
2337
2500
|
`INSERT OR REPLACE INTO idempotency (from_ep, client_id, payload_hash, result, created_at) VALUES (?, ?, ?, ?, ?)`,
|
|
@@ -2406,21 +2569,29 @@ var RelayCore = class {
|
|
|
2406
2569
|
});
|
|
2407
2570
|
}
|
|
2408
2571
|
}
|
|
2409
|
-
/**
|
|
2572
|
+
/**
|
|
2573
|
+
* the oldest unacked deliveries after the cursor, and those a background hook showed in the last ${LIMITS.deferredShowS} s whose showing has
|
|
2574
|
+
* not settled ({@link injected}), with `shown_at`: the harness may hand the agent that hook's text only after this call; reading counts as
|
|
2575
|
+
* receiving for the causal hop
|
|
2576
|
+
*/
|
|
2410
2577
|
inbox(endpoint, options = {}) {
|
|
2411
2578
|
const now = this.now();
|
|
2412
2579
|
const limit = Math.min(Math.max(1, Math.floor(options.limit ?? LIMITS.inboxPage)), LIMITS.inboxPage);
|
|
2413
2580
|
const rows = this.sql.all(
|
|
2414
|
-
`SELECT m.*, d.seq AS seq FROM deliveries d JOIN messages m ON m.id = d.message_id
|
|
2415
|
-
WHERE d.endpoint = ? AND d.acked_at IS NULL AND d.expires_at > ? AND d.seq > ?
|
|
2581
|
+
`SELECT m.*, d.seq AS seq, d.deferred_at AS deferred_at, d.acked_at AS acked_at FROM deliveries d JOIN messages m ON m.id = d.message_id
|
|
2582
|
+
WHERE d.endpoint = ? AND (d.acked_at IS NULL OR d.deferred_at > ?) AND d.expires_at > ? AND d.seq > ?
|
|
2416
2583
|
ORDER BY d.seq LIMIT ?`,
|
|
2417
2584
|
endpoint,
|
|
2585
|
+
now - LIMITS.deferredShowS * 1e3,
|
|
2418
2586
|
now,
|
|
2419
2587
|
Math.max(0, Math.floor(options.after ?? 0)),
|
|
2420
2588
|
limit
|
|
2421
2589
|
);
|
|
2422
2590
|
this.received(endpoint, rows, now);
|
|
2423
|
-
return rows.map((row) =>
|
|
2591
|
+
return rows.map((row) => {
|
|
2592
|
+
const envelope = this.envelope(row, row.seq);
|
|
2593
|
+
return row.acked_at !== null && row.deferred_at !== null ? { ...envelope, shown_at: new Date(row.deferred_at).toISOString() } : envelope;
|
|
2594
|
+
});
|
|
2424
2595
|
}
|
|
2425
2596
|
/**
|
|
2426
2597
|
* A channel push's page: the oldest unacked deliveries no hook showed or holds, held from the hooks for ${LIMITS.injectHoldS} s under `hold`
|
|
@@ -2461,6 +2632,7 @@ var RelayCore = class {
|
|
|
2461
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
|
|
2462
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
|
|
2463
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.
|
|
2464
2636
|
*/
|
|
2465
2637
|
inject(endpoint, options = {}) {
|
|
2466
2638
|
const now = this.now();
|
|
@@ -2528,7 +2700,7 @@ var RelayCore = class {
|
|
|
2528
2700
|
now,
|
|
2529
2701
|
LIMITS.inboxPage
|
|
2530
2702
|
);
|
|
2531
|
-
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));
|
|
2532
2704
|
const hold = this.hold(offered, now);
|
|
2533
2705
|
this.received(endpoint, shown, now);
|
|
2534
2706
|
return { messages: shown.map((row) => this.envelope(row, row.seq)), offered, hold, more: waiting - shown.length, unread, block: false, ...brief, ...intro };
|
|
@@ -2602,13 +2774,15 @@ var RelayCore = class {
|
|
|
2602
2774
|
* messages no hook has shown or holds follow theirs ({@link pushOthers}), `others_shown` of them, held with them; `others` counts the other
|
|
2603
2775
|
* unread ones left. A live worker's messages wait ${LIMITS.pushGapS} s after a push showed one of its own ({@link pushed}), and with no
|
|
2604
2776
|
* push left since the last prompt nothing is shown. `prompted` says a prompt came since inject handed out the `prompts` mark. A hook of
|
|
2605
|
-
* another process than the one holding the endpoint gets nothing.
|
|
2777
|
+
* another process than the one holding the endpoint gets nothing. `peek`: the same answer, nothing held (`hold` null) or marked received.
|
|
2606
2778
|
*/
|
|
2607
2779
|
stopWait(endpoint, options = {}) {
|
|
2608
2780
|
const now = this.now();
|
|
2609
2781
|
const me = this.mustEndpoint(endpoint);
|
|
2610
2782
|
if (!this.holds(me, options)) return { messages: [], offered: [], hold: null, more: 0, workers: [], starting: 0, others: 0, pushes_left: 0 };
|
|
2611
2783
|
const { workers, starting, paced } = this.liveWorkers(me, now);
|
|
2784
|
+
const live = this.liveWorkers(me, now, true);
|
|
2785
|
+
const background = [...live.workers.map((row) => this.address(row)), ...live.unjoined.filter(Boolean).map((name) => `me/${name}`)];
|
|
2612
2786
|
const left = this.pushesLeft(me);
|
|
2613
2787
|
const { where, params } = this.workerMessages(me, now, true, paced);
|
|
2614
2788
|
const rows = left ? this.sql.all(`SELECT m.*, d.seq AS seq ${where} ORDER BY d.seq LIMIT ?`, ...params, LIMITS.inboxPage) : [];
|
|
@@ -2622,8 +2796,8 @@ var RelayCore = class {
|
|
|
2622
2796
|
const { shown, offered } = this.budget(rows, LIMITS.pushBytes);
|
|
2623
2797
|
const used = shown.reduce((sum, row) => sum + utf8Bytes(wrapMessage(this.envelope(row, row.seq))), 0);
|
|
2624
2798
|
const others = rows.length ? this.pushOthers(me, now, LIMITS.pushBytes - used) : [];
|
|
2625
|
-
const hold = this.hold([...offered, ...others.map((row) => row.seq)], now);
|
|
2626
|
-
this.received(endpoint, [...shown, ...others], now);
|
|
2799
|
+
const hold = options.peek ? null : this.hold([...offered, ...others.map((row) => row.seq)], now);
|
|
2800
|
+
if (!options.peek) this.received(endpoint, [...shown, ...others], now);
|
|
2627
2801
|
return {
|
|
2628
2802
|
messages: [...shown, ...others].map((row) => this.envelope(row, row.seq)),
|
|
2629
2803
|
offered: [...offered, ...others.map((row) => row.seq)],
|
|
@@ -2631,6 +2805,7 @@ var RelayCore = class {
|
|
|
2631
2805
|
more: waiting - shown.length,
|
|
2632
2806
|
workers: workers.map((row) => this.address(row)),
|
|
2633
2807
|
starting,
|
|
2808
|
+
...background.length ? { background } : {},
|
|
2634
2809
|
others: unread - this.workerUnread(me, now, false) - others.length,
|
|
2635
2810
|
...others.length ? { others_shown: others.length } : {},
|
|
2636
2811
|
pushes_left: left,
|
|
@@ -2692,8 +2867,26 @@ var RelayCore = class {
|
|
|
2692
2867
|
prompted(endpoint, options = {}) {
|
|
2693
2868
|
if (!this.holds(this.mustEndpoint(endpoint), options)) return false;
|
|
2694
2869
|
this.sql.all(`UPDATE endpoints SET prompts = COALESCE(prompts, 0) + 1, pushes = 0 WHERE id = ?`, endpoint);
|
|
2870
|
+
this.settle(endpoint);
|
|
2695
2871
|
return true;
|
|
2696
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
|
+
}
|
|
2882
|
+
/** the background hooks' showings ({@link injected}) older than ${LIMITS.deferredSettleMs} ms reached the agent: inbox no longer lists them */
|
|
2883
|
+
settle(endpoint) {
|
|
2884
|
+
this.sql.all(
|
|
2885
|
+
`UPDATE deliveries SET deferred_at = NULL WHERE endpoint = ? AND deferred_at IS NOT NULL AND deferred_at <= ?`,
|
|
2886
|
+
endpoint,
|
|
2887
|
+
this.now() - LIMITS.deferredSettleMs
|
|
2888
|
+
);
|
|
2889
|
+
}
|
|
2697
2890
|
/**
|
|
2698
2891
|
* The endpoint's live spawned workers: its unrevoked spawn grants (minted by `swarm spawn` without --no-push, never by spawn-env) from its
|
|
2699
2892
|
* current spawn that are either unspent and unexpired (`starting`: the harness has not joined yet) or spent by an endpoint whose status is not
|
|
@@ -2702,22 +2895,22 @@ var RelayCore = class {
|
|
|
2702
2895
|
* spender done and revokes an unspent grant, so a worker goes when its harness exits, else when its presence lapses or its grant expires; an
|
|
2703
2896
|
* unspent grant whose `swarm spawn` process is gone (killed before it could report the exit) is revoked here.
|
|
2704
2897
|
*/
|
|
2705
|
-
liveWorkers(me, now) {
|
|
2898
|
+
liveWorkers(me, now, noPush = false) {
|
|
2706
2899
|
const grants = this.sql.all(
|
|
2707
|
-
`SELECT token_hash, used_by, bound_process, pushed_at, handback FROM grants WHERE issuer = ? AND spawn = 1 AND no_push IS NULL AND revoked_at IS NULL AND created_at >= ? AND (used_by IS NOT NULL OR expires_at > ?) ORDER BY created_at`,
|
|
2900
|
+
`SELECT token_hash, used_by, bound_process, pushed_at, handback, name FROM grants WHERE issuer = ? AND spawn = 1 AND no_push IS ${noPush ? "NOT NULL" : "NULL"} AND revoked_at IS NULL AND created_at >= ? AND (used_by IS NOT NULL OR expires_at > ?) ORDER BY created_at`,
|
|
2708
2901
|
me.id,
|
|
2709
2902
|
me.spawned_at ?? 0,
|
|
2710
2903
|
now
|
|
2711
2904
|
);
|
|
2712
2905
|
const workers = /* @__PURE__ */ new Map();
|
|
2713
2906
|
const paced = /* @__PURE__ */ new Set();
|
|
2714
|
-
|
|
2907
|
+
const unjoined = [];
|
|
2715
2908
|
for (const grant of grants) {
|
|
2716
2909
|
const pid = Number(/^pid:(\d+):/.exec(grant.bound_process ?? "")?.[1]);
|
|
2717
2910
|
if (grant.used_by === null) {
|
|
2718
2911
|
if (pid && !this.alive(pid))
|
|
2719
2912
|
this.sql.all(`UPDATE grants SET revoked_at = ? WHERE token_hash = ? AND revoked_at IS NULL AND used_by IS NULL`, now, grant.token_hash);
|
|
2720
|
-
else
|
|
2913
|
+
else unjoined.push(grant.name ?? "");
|
|
2721
2914
|
continue;
|
|
2722
2915
|
}
|
|
2723
2916
|
const row = this.byId(grant.used_by);
|
|
@@ -2725,7 +2918,7 @@ var RelayCore = class {
|
|
|
2725
2918
|
workers.set(row.id, row);
|
|
2726
2919
|
if (grant.pushed_at !== null && grant.pushed_at > now - LIMITS.pushGapS * 1e3) paced.add(row.id);
|
|
2727
2920
|
}
|
|
2728
|
-
return { workers: [...workers.values()], starting, paced: [...paced] };
|
|
2921
|
+
return { workers: [...workers.values()], starting: unjoined.length, paced: [...paced], unjoined };
|
|
2729
2922
|
}
|
|
2730
2923
|
/**
|
|
2731
2924
|
* the unread deliveries to `me` from the endpoints that spent its spawn grants (while each held that grant) and the relay's `worker ` handbacks,
|
|
@@ -2816,7 +3009,9 @@ var RelayCore = class {
|
|
|
2816
3009
|
}
|
|
2817
3010
|
/**
|
|
2818
3011
|
* a hook or channel showed these deliveries, offered to it under `hold`: no hook offers them again; those of them it marked that are `read`
|
|
2819
|
-
* (printed in full) and delivered to `endpoint` are acked
|
|
3012
|
+
* (printed in full) and delivered to `endpoint` are acked, and with `deferred` (a background hook, whose text the harness hands the agent
|
|
3013
|
+
* later) still listed by {@link inbox} until that settles ({@link settle}) and marked delivered rather than read ({@link receipts}) until
|
|
3014
|
+
* the agent acks it itself; how many it marked
|
|
2820
3015
|
*/
|
|
2821
3016
|
injected(seqs, hold, options = {}) {
|
|
2822
3017
|
const list = seqList(seqs);
|
|
@@ -2833,13 +3028,17 @@ var RelayCore = class {
|
|
|
2833
3028
|
hold
|
|
2834
3029
|
).length;
|
|
2835
3030
|
marked += hit;
|
|
2836
|
-
if (hit && read.has(seq))
|
|
3031
|
+
if (hit && read.has(seq)) {
|
|
3032
|
+
const deferred = options.deferred === true ? now : null;
|
|
2837
3033
|
acked += this.sql.all(
|
|
2838
|
-
`UPDATE deliveries SET acked_at = ? WHERE seq = ? AND endpoint = ? AND acked_at IS NULL RETURNING seq`,
|
|
3034
|
+
`UPDATE deliveries SET acked_at = ?, deferred_at = ?, deferred_ack = ? WHERE seq = ? AND endpoint = ? AND acked_at IS NULL RETURNING seq`,
|
|
2839
3035
|
now,
|
|
3036
|
+
deferred,
|
|
3037
|
+
deferred && 1,
|
|
2840
3038
|
seq,
|
|
2841
3039
|
options.endpoint
|
|
2842
3040
|
).length;
|
|
3041
|
+
}
|
|
2843
3042
|
}
|
|
2844
3043
|
if (acked) this.audit("ack", options.endpoint, null, { count: acked, via: "inject" });
|
|
2845
3044
|
return marked;
|
|
@@ -2900,6 +3099,46 @@ var RelayCore = class {
|
|
|
2900
3099
|
throw new RelayError("invalid_params", `${id} is the seq of one message and the short id of another (${shortId(rows[0].id)}); give the full id`);
|
|
2901
3100
|
return envelope(seq ?? rows[0]);
|
|
2902
3101
|
}
|
|
3102
|
+
/**
|
|
3103
|
+
* a message `endpoint` sent, by its id, a unique suffix of at least ${MESSAGE_SUFFIX_MIN} characters such as its {@link shortId}, or `last`
|
|
3104
|
+
* (the newest it sent), with when each recipient read it (acked), or when a background hook delivered it (acked by that showing, the agent
|
|
3105
|
+
* gets it with its next request, `delivered_at` and no `read_at`), and when a hook or channel showed it; a recipient still waiting for a
|
|
3106
|
+
* `swarm spawn` to join is named by the spawn's address. not_found when it sent no such message; invalid_params when a suffix names more
|
|
3107
|
+
* than one
|
|
3108
|
+
*/
|
|
3109
|
+
receipts(endpoint, id) {
|
|
3110
|
+
if (typeof id !== "string" || !id.trim()) throw new RelayError("invalid_params", "id is a message id");
|
|
3111
|
+
const sent = `SELECT id, thread FROM messages WHERE from_ep = ?`;
|
|
3112
|
+
let rows = id === "last" ? this.sql.all(`${sent} ORDER BY created_at DESC, id DESC LIMIT 1`, endpoint) : this.sql.all(`${sent} AND id = ?`, endpoint, id);
|
|
3113
|
+
if (!rows.length && MESSAGE_SUFFIX.test(id))
|
|
3114
|
+
rows = this.sql.all(`${sent} AND substr(id, -?) = ? ORDER BY created_at DESC LIMIT 6`, endpoint, id.length, id);
|
|
3115
|
+
if (rows.length > 1) {
|
|
3116
|
+
const named = rows.slice(0, 5).map((row) => `${shortId(row.id)} (${row.id})`);
|
|
3117
|
+
throw new RelayError(
|
|
3118
|
+
"invalid_params",
|
|
3119
|
+
`${id} matches more than one message you sent, newest first: ${named.join(", ")}${rows.length > 5 ? ", ..." : ""}; give more of the id`
|
|
3120
|
+
);
|
|
3121
|
+
}
|
|
3122
|
+
const message = rows[0];
|
|
3123
|
+
if (!message) throw new RelayError("not_found", `no message ${id} that you sent`);
|
|
3124
|
+
const deliveries = this.sql.all(
|
|
3125
|
+
`SELECT endpoint, acked_at, injected_at, deferred_ack FROM deliveries WHERE message_id = ? ORDER BY seq`,
|
|
3126
|
+
message.id
|
|
3127
|
+
);
|
|
3128
|
+
const address = (key) => {
|
|
3129
|
+
const pending = key.startsWith(pendingKey("")) ? one(this.sql, `SELECT name FROM grants WHERE token_hash = ?`, key.slice(pendingKey("").length)) : void 0;
|
|
3130
|
+
return pending?.name ? `${this.user}/${pending.name}` : this.addressOf(key);
|
|
3131
|
+
};
|
|
3132
|
+
return {
|
|
3133
|
+
id: message.id,
|
|
3134
|
+
thread: message.thread,
|
|
3135
|
+
recipients: deliveries.map((d) => ({
|
|
3136
|
+
address: address(d.endpoint),
|
|
3137
|
+
...d.deferred_ack ? { read_at: null, delivered_at: iso(d.acked_at) } : { read_at: iso(d.acked_at) },
|
|
3138
|
+
shown_at: iso(d.injected_at)
|
|
3139
|
+
}))
|
|
3140
|
+
};
|
|
3141
|
+
}
|
|
2903
3142
|
/**
|
|
2904
3143
|
* another endpoint's message in a thread `endpoint` sent or got a message in, by its id or a unique suffix of at least ${MESSAGE_SUFFIX_MIN}
|
|
2905
3144
|
* characters: enough to reply to it, never its body. Null when there is none; invalid_params when the suffix names more than one
|
|
@@ -2982,6 +3221,7 @@ var RelayCore = class {
|
|
|
2982
3221
|
hasUnread(endpoint, after = 0) {
|
|
2983
3222
|
return one(this.sql, `SELECT 1 FROM deliveries WHERE endpoint = ? AND acked_at IS NULL AND expires_at > ? AND seq > ? LIMIT 1`, endpoint, this.now(), after) !== void 0;
|
|
2984
3223
|
}
|
|
3224
|
+
/** marks these deliveries to `endpoint` read, one a background hook's showing acked ({@link injected}) read now too; how many were unread */
|
|
2985
3225
|
ack(endpoint, seqs) {
|
|
2986
3226
|
let acked = 0;
|
|
2987
3227
|
for (const seq of seqList(seqs)) {
|
|
@@ -2991,6 +3231,13 @@ var RelayCore = class {
|
|
|
2991
3231
|
seq,
|
|
2992
3232
|
endpoint
|
|
2993
3233
|
).length;
|
|
3234
|
+
this.sql.all(
|
|
3235
|
+
`UPDATE deliveries SET acked_at = ?, deferred_ack = NULL WHERE seq = ? AND endpoint = ? AND deferred_ack IS NOT NULL`,
|
|
3236
|
+
this.now(),
|
|
3237
|
+
seq,
|
|
3238
|
+
endpoint
|
|
3239
|
+
);
|
|
3240
|
+
this.sql.all(`UPDATE deliveries SET deferred_at = NULL WHERE seq = ? AND endpoint = ? AND deferred_at IS NOT NULL`, seq, endpoint);
|
|
2994
3241
|
}
|
|
2995
3242
|
if (acked) this.audit("ack", endpoint, null, { count: acked });
|
|
2996
3243
|
return acked;
|
|
@@ -3086,6 +3333,7 @@ var RelayCore = class {
|
|
|
3086
3333
|
this.sql.all(`DELETE FROM idempotency WHERE created_at <= ?`, now - LIMITS.idempotencyS * 1e3);
|
|
3087
3334
|
this.sql.all(`DELETE FROM pair_events WHERE at <= ?`, now - 3600 * 1e3);
|
|
3088
3335
|
this.sql.all(`DELETE FROM blocks WHERE until <= ?`, now);
|
|
3336
|
+
this.endExited(now);
|
|
3089
3337
|
this.settleOffline(now);
|
|
3090
3338
|
this.sql.all(
|
|
3091
3339
|
`DELETE FROM endpoints WHERE kind = 'agent' AND connections = 0 AND COALESCE(last_seen, created_at) <= ?
|
|
@@ -3111,6 +3359,16 @@ var RelayCore = class {
|
|
|
3111
3359
|
if (!root || root.handle.startsWith(GONE_PREFIX) || !seen) this.dropGroup(group.name);
|
|
3112
3360
|
}
|
|
3113
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
|
+
}
|
|
3114
3372
|
/** an agent endpoint left offline drops a working or waiting status, which no one is left to keep current */
|
|
3115
3373
|
settleOffline(now) {
|
|
3116
3374
|
for (const row of this.sql.all(
|
|
@@ -3147,6 +3405,7 @@ var RelayCore = class {
|
|
|
3147
3405
|
);
|
|
3148
3406
|
this.sql.all(`DELETE FROM activity WHERE id <= ?`, row.id - LIMITS.activityKeep);
|
|
3149
3407
|
const own = this.holds(this.mustEndpoint(endpoint), options);
|
|
3408
|
+
if (own && !agent && params.kind === "tool") this.settle(endpoint);
|
|
3150
3409
|
if (own && !agent && typeof params.session_id === "string" && HARNESS_SESSION.test(params.session_id)) {
|
|
3151
3410
|
this.sql.all(
|
|
3152
3411
|
`UPDATE endpoints SET harness_session = ?, harness_transcript = ? WHERE id = ?`,
|
|
@@ -3163,15 +3422,19 @@ var RelayCore = class {
|
|
|
3163
3422
|
endpoint
|
|
3164
3423
|
);
|
|
3165
3424
|
} else if (params.kind === "session_start") {
|
|
3425
|
+
const fresh = own && summary !== "compact" && summary !== "resume";
|
|
3166
3426
|
this.sql.all(
|
|
3167
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,
|
|
3168
|
-
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 = ?`,
|
|
3169
3430
|
options.transient ? now : null,
|
|
3170
3431
|
own ? 1 : 0,
|
|
3171
3432
|
own ? 1 : 0,
|
|
3172
3433
|
own ? 1 : 0,
|
|
3173
3434
|
own ? 1 : 0,
|
|
3174
3435
|
own ? 1 : 0,
|
|
3436
|
+
fresh ? 1 : 0,
|
|
3437
|
+
fresh ? 1 : 0,
|
|
3175
3438
|
endpoint
|
|
3176
3439
|
);
|
|
3177
3440
|
} else if (options.transient) {
|
|
@@ -3502,6 +3765,19 @@ var RelayCore = class {
|
|
|
3502
3765
|
return change;
|
|
3503
3766
|
});
|
|
3504
3767
|
}
|
|
3768
|
+
/** the grant issuer's messages to the endpoint that spent it, sent since the grant, that it read, all but those among `unapplied`, oldest first */
|
|
3769
|
+
readSteers(grant, spender, unapplied) {
|
|
3770
|
+
return this.sql.all(
|
|
3771
|
+
`SELECT m.id, d.acked_at, d.deferred_ack FROM deliveries d JOIN messages m ON m.id = d.message_id
|
|
3772
|
+
WHERE d.endpoint = ? AND m.from_ep = ? AND m.created_at >= ? AND d.acked_at IS NOT NULL
|
|
3773
|
+
${unapplied.length ? `AND m.id NOT IN (${unapplied.map(() => "?").join(", ")})` : ""} ORDER BY d.seq LIMIT ?`,
|
|
3774
|
+
spender.id,
|
|
3775
|
+
grant.issuer,
|
|
3776
|
+
grant.created_at,
|
|
3777
|
+
...unapplied,
|
|
3778
|
+
LIMITS.inboxPage
|
|
3779
|
+
);
|
|
3780
|
+
}
|
|
3505
3781
|
/**
|
|
3506
3782
|
* a message needs both ends: for each `ep:` selector `widen` added on `target`, the other end's side is widened to admit `target` too when
|
|
3507
3783
|
* `me` has authority over it, audited as its own widening; when that side still refuses, the change names it and the command that would fix it.
|
|
@@ -3836,8 +4112,9 @@ var RelayCore = class {
|
|
|
3836
4112
|
/**
|
|
3837
4113
|
* a spawn's handback to its spawner `me`, the grant's issuer, from the worker (the relay when it never joined): past scopes, pair limits and
|
|
3838
4114
|
* the judge, since the spawner minted the grant and is the recipient. One per grant (null once sent), in the thread the two last wrote in,
|
|
3839
|
-
* else one derived from the grant, naming by short id the spawner's messages the worker
|
|
3840
|
-
*
|
|
4115
|
+
* else one derived from the grant, naming by short id the spawner's messages the worker read, with when (one a background hook showed only
|
|
4116
|
+
* delivered, unless the worker then exited 0: its model had the next request), and those it left unread; its hop follows the worker's
|
|
4117
|
+
* causal hop, capped, and a full thread drops it (null). A worker that never joined or exited non-zero gets its `stderr` last, labelled, its
|
|
3841
4118
|
* output cut from the front so both stay within ${LIMITS.handbackBytes} bytes
|
|
3842
4119
|
*/
|
|
3843
4120
|
handback(me, grant, spender, code, whole, taken, lost = 0, exits = [], stderr = "") {
|
|
@@ -3882,7 +4159,13 @@ ${stderr}` : "";
|
|
|
3882
4159
|
];
|
|
3883
4160
|
if (spender) {
|
|
3884
4161
|
const unapplied = this.steersOf(grant, spender, now, taken);
|
|
3885
|
-
|
|
4162
|
+
const steers = [
|
|
4163
|
+
...this.readSteers(grant, spender, taken).map(
|
|
4164
|
+
(row) => `your steer ${shortId(row.id)} was ${row.deferred_ack && code !== 0 ? "delivered" : "read"} at ${clock(row.acked_at)}`
|
|
4165
|
+
),
|
|
4166
|
+
...unapplied.length ? this.unappliedLines(spender, grant, unapplied, now, exits) : []
|
|
4167
|
+
];
|
|
4168
|
+
if (steers.length) body.push(steers.join("\n"));
|
|
3886
4169
|
}
|
|
3887
4170
|
const causal = spender && spender.last_received_at !== null && spender.last_received_at >= now - LIMITS.causalWindowS * 1e3;
|
|
3888
4171
|
const hop = causal ? Math.min((spender.last_received_hop ?? 0) + 1, LIMITS.hopMax) : 0;
|
|
@@ -4865,7 +5148,7 @@ ${stderr}` : "";
|
|
|
4865
5148
|
}
|
|
4866
5149
|
/** the user parts an address of this core may carry */
|
|
4867
5150
|
addressUsers() {
|
|
4868
|
-
return [this.user, "me"];
|
|
5151
|
+
return [this.user, "me", "local"];
|
|
4869
5152
|
}
|
|
4870
5153
|
/** whether the row is this host's own, the only rows with a tree, groups, rules and caps here */
|
|
4871
5154
|
localRow(_row) {
|
|
@@ -4877,7 +5160,7 @@ ${stderr}` : "";
|
|
|
4877
5160
|
const slash = text.indexOf("/");
|
|
4878
5161
|
const user = slash === -1 ? null : text.slice(0, slash);
|
|
4879
5162
|
const handle = slash === -1 ? text : text.slice(slash + 1);
|
|
4880
|
-
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);
|
|
4881
5164
|
const row = handle === "" ? this.byHandle(HUMAN_HANDLE) : validHandle(handle) ? this.byHandle(handle) : void 0;
|
|
4882
5165
|
if (!row) throw unreachable(text);
|
|
4883
5166
|
return row;
|
|
@@ -5934,6 +6217,7 @@ export {
|
|
|
5934
6217
|
SHORT_ID,
|
|
5935
6218
|
STATUS_STATES,
|
|
5936
6219
|
SYSTEM_HANDLE,
|
|
6220
|
+
TITLE_CHARS,
|
|
5937
6221
|
VerdictCache,
|
|
5938
6222
|
XCounters,
|
|
5939
6223
|
XLIMITS,
|
|
@@ -5946,6 +6230,7 @@ export {
|
|
|
5946
6230
|
clip,
|
|
5947
6231
|
clipPasses,
|
|
5948
6232
|
clipped,
|
|
6233
|
+
clock,
|
|
5949
6234
|
collapseTools,
|
|
5950
6235
|
createSecretScanner,
|
|
5951
6236
|
crossFleet,
|
|
@@ -5989,6 +6274,7 @@ export {
|
|
|
5989
6274
|
randomHex,
|
|
5990
6275
|
reachLines,
|
|
5991
6276
|
readText,
|
|
6277
|
+
receiptLines,
|
|
5992
6278
|
redactPii,
|
|
5993
6279
|
redactSecrets,
|
|
5994
6280
|
redactionKind,
|