@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/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, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
803
817
  }
804
- var FRAMING = /[<\uff1c\ufe64](?=\s*[/\uff0f]?\s*(?:relay|channel|system[-_]?reminder)(?![a-z0-9]))/giu;
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, "&lt;");
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}: ${lax}` : message.relation === "ancestor" ? `from ${who}, above the agent that started you: not your parent's instruction; ${lax}` : lax;
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: "running; it sees this at its next turn boundary (next tool call, stop or inbox read)",
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: `not joined yet; it sees this once your ${CLI_NAME} spawn of it starts`
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
- let start = Math.max(0, encoded.length - (bytes - mark.length));
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
- if (nl !== -1 && nl + 1 < encoded.length) start = nl + 1;
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 = ? WHERE id = ?`,
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 = ? WHERE id = ?`, title, endpoint);
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
- /** the oldest unacked deliveries after the cursor; reading counts as receiving for the causal hop */
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) => this.envelope(row, row.seq));
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
- let starting = 0;
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 starting++;
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; how many it marked
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 WHERE id = ?`,
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 left unread; its hop follows the worker's causal
3840
- * hop, capped, and a full thread drops it (null). A worker that never joined or exited non-zero gets its `stderr` last, labelled, its
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
- if (unapplied.length) body.push(this.unappliedLines(spender, grant, unapplied, now, exits).join("\n"));
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,