@scp-dev/core 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -385,8 +385,10 @@ declare const LIMITS: {
385
385
  readonly grantTtlS: number;
386
386
  readonly groupIdleS: 86400;
387
387
  readonly policyLoadsKept: 8;
388
+ /** the prompts between two refreshes of a title a hook inferred */
389
+ readonly titleRefreshTurns: 5;
388
390
  };
389
- type ErrorCode = "not_found" | "inbox_full" | "rate_limited" | "loop_guard" | "data_class_refused" | "secret_refused" | "policy_refused" | "too_large" | "invalid_params" | "unauthorized" | "method_not_found" | "scope_refused" | "unavailable" | "judge_refused";
391
+ type ErrorCode = "not_found" | "inbox_full" | "rate_limited" | "loop_guard" | "data_class_refused" | "secret_refused" | "policy_refused" | "too_large" | "invalid_params" | "unauthorized" | "method_not_found" | "scope_refused" | "unavailable" | "judge_refused" | "not_live";
390
392
  declare class RelayError extends Error {
391
393
  readonly code: ErrorCode;
392
394
  readonly retryAfterS?: number | undefined;
@@ -695,6 +697,8 @@ interface SendParams {
695
697
  mime?: string;
696
698
  /** on a request only: the recipient's stop is held until it replies */
697
699
  require_reply?: boolean;
700
+ /** refuse `not_live`, queueing nothing, when an agent recipient has no live session */
701
+ live?: boolean;
698
702
  }
699
703
  /** what ask and ask_wait answer: the reply; or pending, with `ended` once every recipient present at the send went offline without replying; or expired */
700
704
  interface AskResult {
@@ -744,6 +748,8 @@ interface SendResult {
744
748
  last_seen: string | null;
745
749
  state?: Reach;
746
750
  }[];
751
+ /** one line per agent recipient no live session holds: `queued: no live session holds <address>` */
752
+ warnings?: string[];
747
753
  duplicate?: boolean;
748
754
  held?: JudgeHeld;
749
755
  redacted?: JudgeRedacted;
@@ -803,6 +809,8 @@ interface PolicyDefaults {
803
809
  /** `inherit`: a child of a capped parent reaches only what the parent reaches; a preset caps every placed child without grant caps */
804
810
  child_default?: ScopePreset | "inherit";
805
811
  verify_lineage?: "spike" | boolean;
812
+ /** who may rename an agent besides the human at a terminal: `root` (default) only a root session itself, `self` any agent itself, spawned ones too */
813
+ rename?: "root" | "self";
806
814
  }
807
815
  interface Policy {
808
816
  rules: PolicyRule[];
@@ -1017,6 +1025,8 @@ interface Sql {
1017
1025
  declare const one: <T>(sql: Sql, query: string, ...params: SqlValue[]) => T | undefined;
1018
1026
 
1019
1027
  declare const HUMAN_HANDLE = "_human";
1028
+ /** an agent title's length cap */
1029
+ declare const TITLE_CHARS = 64;
1020
1030
  declare const SYSTEM_HANDLE = "_relay";
1021
1031
  declare const GONE_PREFIX = "_gone_";
1022
1032
  interface EndpointRow {
@@ -1064,12 +1074,20 @@ interface EndpointRow {
1064
1074
  /** JSON {@link ScopeCaps} */
1065
1075
  caps: string | null;
1066
1076
  title: string | null;
1077
+ /** the title a hook last inferred: the title is inferred while it equals this */
1078
+ title_auto: string | null;
1079
+ /** the prompt count when a summary last set the inferred title; null when the first prompt set it */
1080
+ title_turn: number | null;
1081
+ /** the prompt count when the status note last changed or a hook last reminded the agent to refresh it */
1082
+ tldr_turn: number | null;
1067
1083
  /** when a new spawn last took the endpoint over: mail and messages from before it were the earlier holder's */
1068
1084
  reset_at: number | null;
1069
1085
  /** when a new spawn last took the endpoint over: with its id, names the holder another machine sees */
1070
1086
  spawned_at: number | null;
1071
1087
  /** who may peek: null is off */
1072
1088
  peek: PeekLevel | null;
1089
+ /** when the session last set its handle itself (join or rename): a later claim with another handle does not rename it back */
1090
+ named: number | null;
1073
1091
  }
1074
1092
  interface MessageRow {
1075
1093
  id: string;
@@ -1118,6 +1136,8 @@ declare class RelayCore {
1118
1136
  private readonly capEverySender;
1119
1137
  private readonly alive;
1120
1138
  private readonly hostMachine;
1139
+ private readonly namedMachine;
1140
+ private readonly hostname;
1121
1141
  private policy;
1122
1142
  /** the identity each policy `group:<name>` naming no policy group bound to when the policy's text first loaded: that group's then, or `none` */
1123
1143
  private policyBinds;
@@ -1126,6 +1146,8 @@ declare class RelayCore {
1126
1146
  private scopeMemo;
1127
1147
  private readonly deferred;
1128
1148
  constructor(sql: Sql, options: CoreOptions);
1149
+ /** a `machine` label naming this host by its hostname, as clients default it, names it by its machine name when one is set */
1150
+ private machineLabel;
1129
1151
  migrate(): void;
1130
1152
  /** every stored list that is exactly `tree:self`, the tree preset's old list, narrows once to the preset's list */
1131
1153
  private migrateTreePreset;
@@ -1279,6 +1301,49 @@ declare class RelayCore {
1279
1301
  status(endpoint: string, state: unknown, note: unknown): void;
1280
1302
  /** an agent's own title, shown in list and whoami; empty clears it, and no claim overwrites it */
1281
1303
  title(endpoint: string, value: unknown): EndpointView;
1304
+ /**
1305
+ * a hook's guess at the session's title, from the harness's own summary or the first prompt's first line; whether it changed the title.
1306
+ * Never over a title set explicitly; the first prompt fills only an empty title, and a summary replaces an inferred one at most every
1307
+ * ${LIMITS.titleRefreshTurns} prompts, or at once over the first prompt's
1308
+ */
1309
+ inferTitle(endpoint: string, params: {
1310
+ text?: unknown;
1311
+ from?: unknown;
1312
+ }, options?: {
1313
+ transient?: boolean;
1314
+ process?: string;
1315
+ }): boolean;
1316
+ /**
1317
+ * a session joins on demand: its hook claim registers or takes back its endpoint, marked seen now even right after a leave, and `handle`,
1318
+ * when given, renames it in place ({@link rename}'s rules). Nothing is kept when the rename is refused
1319
+ */
1320
+ joinSession(claim: Claim, handle?: unknown): EndpointView;
1321
+ /**
1322
+ * renames an agent in place: its id, mail, threads, status, title and relations stay. An agent renames only itself, and only a root unless
1323
+ * the policy's `defaults.rename` is `self`; the human at a terminal renames any local agent. The new handle is valid and held by no other endpoint
1324
+ */
1325
+ rename(actor: ScopeActor, params: {
1326
+ endpoint?: unknown;
1327
+ handle: unknown;
1328
+ }): {
1329
+ endpoint: EndpointView;
1330
+ from: string;
1331
+ session: string | null;
1332
+ };
1333
+ /** the endpoint's handle set to `value`, with the stored `ep:` and `fleet:` selectors that named it */
1334
+ private renameRow;
1335
+ /**
1336
+ * a session leaves: it lets go of its session and token and shows offline, its mail kept; a later join takes it back. An agent leaves only
1337
+ * itself, the human at a terminal any local root. A spawned worker stays joined while it runs
1338
+ */
1339
+ leaveSession(actor: ScopeActor, params?: {
1340
+ endpoint?: unknown;
1341
+ }): {
1342
+ endpoint: EndpointView;
1343
+ session: string | null;
1344
+ };
1345
+ /** the harness session key an endpoint holds, if any */
1346
+ sessionOf(endpoint: string): string | null;
1282
1347
  /**
1283
1348
  * `origin.tty` is recorded on messages the human inbox sends: whether its client said a terminal was attached. `origin.unverified`: the human
1284
1349
  * sends without a terminal, so the scopes check it like any unplaced sender
@@ -1339,6 +1404,7 @@ declare class RelayCore {
1339
1404
  * `directive_seqs`. Any call says `brief` once the session was shown the full preamble ({@link briefed}), and one that is no stop names in
1340
1405
  * `intro` the spawner of an endpoint a `swarm spawn` grant placed, with the grant's steer key in `intro_key`, until a hook showed it
1341
1406
  * ({@link introduced}). A hook of another process than the one holding the endpoint (a child that inherited the session key) gets nothing.
1407
+ * A wake also offers live workers' messages.
1342
1408
  */
1343
1409
  inject(endpoint: string, options?: {
1344
1410
  stop?: boolean;
@@ -1347,6 +1413,7 @@ declare class RelayCore {
1347
1413
  directives?: boolean;
1348
1414
  transient?: boolean;
1349
1415
  process?: string;
1416
+ wake?: boolean;
1350
1417
  }): {
1351
1418
  messages: Envelope[];
1352
1419
  offered: number[];
@@ -1415,6 +1482,8 @@ declare class RelayCore {
1415
1482
  transient?: boolean;
1416
1483
  process?: string;
1417
1484
  }): boolean;
1485
+ /** whether a prompt should remind the agent to refresh its status note: `every` prompts passed since the note or the last reminder; counts the reminder */
1486
+ tldrDue(endpoint: string, every: number): boolean;
1418
1487
  /** the background hooks' showings ({@link injected}) older than ${LIMITS.deferredSettleMs} ms reached the agent: inbox no longer lists them */
1419
1488
  private settle;
1420
1489
  /**
@@ -1544,6 +1613,8 @@ declare class RelayCore {
1544
1613
  }): AuditRow[];
1545
1614
  /** drops what expired: deliveries, message bodies past their grace, old guard state and old audit rows */
1546
1615
  sweep(): void;
1616
+ /** ends the hook presence of local agent endpoints whose harness process exited without a session_end, as one would */
1617
+ endExited(now?: number): void;
1547
1618
  /** an agent endpoint left offline drops a working or waiting status, which no one is left to keep current */
1548
1619
  private settleOffline;
1549
1620
  /**
@@ -2351,4 +2422,4 @@ declare function sha256Hex(text: string): string;
2351
2422
  /** the UTF-8 byte length of text, the unit every byte limit counts in */
2352
2423
  declare function utf8Bytes(text: string): number;
2353
2424
 
2354
- export { ACCOUNT_HANDLE, ACTIVITY_KINDS, type ActivityKind, type ActivityRow, type Admission, type AskResult, type Attachment, type AuditRow, BROKER_CAPABILITIES, type BreakerAdmission, CAPABILITIES, CLI_NAME, type Claim, type CoreOptions, type CrossAccountGate, type CrossAccountLink, DEFAULT_IMPLEMENTATION_PREFIXES, DEFAULT_JUDGE_BUDGET, DEFAULT_JUDGE_EFFORT, type DataClass, ERROR_CODES, type EndpointKind, type EndpointRow, type EndpointView, type Envelope, type ErrorCode, FakeJudgeClient, GONE_PREFIX, GROUP_NAME, type GateContext, type GateVerdict, HANDBACK_CUT, HUMAN_HANDLE, HttpJudgeClient, type HttpJudgeOptions, JUDGE_BREAKER_DEFAULTS, JUDGE_CATEGORIES, JUDGE_EFFORTS, JUDGE_FALLBACK_NOTE, JUDGE_MAX_OUTPUT_TOKENS, JUDGE_PROMPT_VERSION, JUDGE_PROVIDERS, JUDGE_VERDICTS, type JudgeAuditDetail, type JudgeAuditEvent, type JudgeBoundary, JudgeBreaker, type JudgeBreakerOptions, type JudgeBudget, type JudgeCategory, type JudgeClient, type JudgeContext, type JudgeEffort, type JudgeHeld, type JudgeModelConfig, type JudgePolicy, type JudgeProvider, JudgeProviderError, type JudgeRedacted, type JudgeSide, type JudgeStats, type JudgeVerdict, LIMITS, MESSAGE_KINDS, type MessageKind, type MessageReceipts, type MessageRow, OMITTED, PASS_GATE, PEEK, PREAMBLE, PREAMBLE_BRIEF, PRESET_LISTS, PROTOCOL, type PeekLevel, type PeekResult, type PeekSeen, type PeekState, type PeekTurn, type PeerView, type PiiHit, type Policy, type PolicyDefaults, type PolicyGroup, type PolicyRule, type PolicySubject, type Prefiltered, type Presence, REDACTION_KINDS, type Reach, type Redaction, RelayCore, RelayError, type RunJudgeInput, SCOPE_PRESETS, SERVER_FIELDS, SHORT_ID, STATUS_STATES, SYSTEM_HANDLE, type ScopeActor, type ScopeCap, type ScopeCaps, type ScopeChange, type ScopeDecision, type ScopeDirection, type ScopeEntry, type ScopeGroupIdentity, type ScopeGroupRules, type ScopeList, type ScopeNode, type ScopePolicyCap, type ScopePreset, type ScopeRules, type ScopeSubject, type ScopeView, type ScopeWorld, type SecretScanner, type Selector, type SendParams, type SendResult, type Sql, type SqlValue, type StatusState, type StopNudge, type StopWaitResult, type ThreadMessage, type Verdict, VerdictCache, type WhyResult, type XAllow, type XContact, XCounters, type XDelivered, type XEnvelopeFrom, XLIMITS, type XLimitConfig, type XLimits, type XListed, type XMessage, type XRelease, admits, allowlisted, applyRedactions, canonicalPolicy, cleanLabels, cleanText, clip, clipPasses, clipped, clock, collapseTools, createSecretScanner, crossFleet, dayStart, declaredLimits, deliveryText, digestText, dropQuoted, effectiveSelector, expandRules, findPii, findSecret, gateRefusal, groupCapKey, handbackOutput, hasScope, honestSender, isLeaving, joinRedacted, judgeBoundary, judgeDetailForAgents, judgeFellBack, judgeModel, judgePrompt, keepLast, leavesFleet, matches, mergeVerdicts, narrower, newId, newJudgeStats, one, parseJudgePolicy, parsePolicy, parseVerdict, parseXMessage, peekLevel, policyRefusal, prefilter, presetOf, randomHex, reachLines, readText, receiptLines, redactPii, redactSecrets, redactionKind, relation, relationText, replyGrant, runJudge, sameSecret, scopeDecision, scopeLists, seqList, setImplementationPrefixes, sha256Hex, shortId, steerNote, stripControl, stripRelaySpans, stripSteerNote, unreachable, untilTomorrow, utf8Bytes, validHandle, validHarness, validSelector, widenText, wrapMessage, wrapPeek, xlimits };
2425
+ export { ACCOUNT_HANDLE, ACTIVITY_KINDS, type ActivityKind, type ActivityRow, type Admission, type AskResult, type Attachment, type AuditRow, BROKER_CAPABILITIES, type BreakerAdmission, CAPABILITIES, CLI_NAME, type Claim, type CoreOptions, type CrossAccountGate, type CrossAccountLink, DEFAULT_IMPLEMENTATION_PREFIXES, DEFAULT_JUDGE_BUDGET, DEFAULT_JUDGE_EFFORT, type DataClass, ERROR_CODES, type EndpointKind, type EndpointRow, type EndpointView, type Envelope, type ErrorCode, FakeJudgeClient, GONE_PREFIX, GROUP_NAME, type GateContext, type GateVerdict, HANDBACK_CUT, HUMAN_HANDLE, HttpJudgeClient, type HttpJudgeOptions, JUDGE_BREAKER_DEFAULTS, JUDGE_CATEGORIES, JUDGE_EFFORTS, JUDGE_FALLBACK_NOTE, JUDGE_MAX_OUTPUT_TOKENS, JUDGE_PROMPT_VERSION, JUDGE_PROVIDERS, JUDGE_VERDICTS, type JudgeAuditDetail, type JudgeAuditEvent, type JudgeBoundary, JudgeBreaker, type JudgeBreakerOptions, type JudgeBudget, type JudgeCategory, type JudgeClient, type JudgeContext, type JudgeEffort, type JudgeHeld, type JudgeModelConfig, type JudgePolicy, type JudgeProvider, JudgeProviderError, type JudgeRedacted, type JudgeSide, type JudgeStats, type JudgeVerdict, LIMITS, MESSAGE_KINDS, type MessageKind, type MessageReceipts, type MessageRow, OMITTED, PASS_GATE, PEEK, PREAMBLE, PREAMBLE_BRIEF, PRESET_LISTS, PROTOCOL, type PeekLevel, type PeekResult, type PeekSeen, type PeekState, type PeekTurn, type PeerView, type PiiHit, type Policy, type PolicyDefaults, type PolicyGroup, type PolicyRule, type PolicySubject, type Prefiltered, type Presence, REDACTION_KINDS, type Reach, type Redaction, RelayCore, RelayError, type RunJudgeInput, SCOPE_PRESETS, SERVER_FIELDS, SHORT_ID, STATUS_STATES, SYSTEM_HANDLE, type ScopeActor, type ScopeCap, type ScopeCaps, type ScopeChange, type ScopeDecision, type ScopeDirection, type ScopeEntry, type ScopeGroupIdentity, type ScopeGroupRules, type ScopeList, type ScopeNode, type ScopePolicyCap, type ScopePreset, type ScopeRules, type ScopeSubject, type ScopeView, type ScopeWorld, type SecretScanner, type Selector, type SendParams, type SendResult, type Sql, type SqlValue, type StatusState, type StopNudge, type StopWaitResult, TITLE_CHARS, type ThreadMessage, type Verdict, VerdictCache, type WhyResult, type XAllow, type XContact, XCounters, type XDelivered, type XEnvelopeFrom, XLIMITS, type XLimitConfig, type XLimits, type XListed, type XMessage, type XRelease, admits, allowlisted, applyRedactions, canonicalPolicy, cleanLabels, cleanText, clip, clipPasses, clipped, clock, collapseTools, createSecretScanner, crossFleet, dayStart, declaredLimits, deliveryText, digestText, dropQuoted, effectiveSelector, expandRules, findPii, findSecret, gateRefusal, groupCapKey, handbackOutput, hasScope, honestSender, isLeaving, joinRedacted, judgeBoundary, judgeDetailForAgents, judgeFellBack, judgeModel, judgePrompt, keepLast, leavesFleet, matches, mergeVerdicts, narrower, newId, newJudgeStats, one, parseJudgePolicy, parsePolicy, parseVerdict, parseXMessage, peekLevel, policyRefusal, prefilter, presetOf, randomHex, reachLines, readText, receiptLines, redactPii, redactSecrets, redactionKind, relation, relationText, replyGrant, runJudge, sameSecret, scopeDecision, scopeLists, seqList, setImplementationPrefixes, sha256Hex, shortId, steerNote, stripControl, stripRelaySpans, stripSteerNote, unreachable, untilTomorrow, utf8Bytes, validHandle, validHarness, validSelector, widenText, wrapMessage, wrapPeek, xlimits };
package/dist/index.js CHANGED
@@ -164,7 +164,9 @@ var LIMITS = {
164
164
  groupPerMinute: 60,
165
165
  grantTtlS: 7 * 86400,
166
166
  groupIdleS: 86400,
167
- policyLoadsKept: 8
167
+ policyLoadsKept: 8,
168
+ /** the prompts between two refreshes of a title a hook inferred */
169
+ titleRefreshTurns: 5
168
170
  };
169
171
  var RelayError = class extends Error {
170
172
  constructor(code, message, retryAfterS, detail) {
@@ -699,7 +701,7 @@ function parsePolicy(value, options = {}) {
699
701
  }
700
702
  if (defaults !== void 0) {
701
703
  if (!isObject2(defaults)) throw new RelayError("invalid_params", "defaults is an object");
702
- const { human_always, symmetric_presets, child_default, verify_lineage, ...extra } = defaults;
704
+ const { human_always, symmetric_presets, child_default, verify_lineage, rename, ...extra } = defaults;
703
705
  if (Object.keys(extra).length) throw new RelayError("invalid_params", `unknown defaults: ${Object.keys(extra).join(", ")}`);
704
706
  const parsed = {};
705
707
  for (const [key, flag] of [
@@ -723,6 +725,10 @@ function parsePolicy(value, options = {}) {
723
725
  throw new RelayError("invalid_params", "defaults.verify_lineage true needs the peer-pid check, which this relay does not have yet: use spike or false");
724
726
  parsed.verify_lineage = verify_lineage;
725
727
  }
728
+ if (rename !== void 0) {
729
+ if (rename !== "root" && rename !== "self") throw new RelayError("invalid_params", "defaults.rename is root or self");
730
+ parsed.rename = rename;
731
+ }
726
732
  policy.defaults = parsed;
727
733
  }
728
734
  if (judge !== void 0) policy.judge = parseJudgePolicy(judge, options.judgeProviders);
@@ -1065,6 +1071,7 @@ function utf8Bytes(text) {
1065
1071
 
1066
1072
  // src/core.ts
1067
1073
  var HUMAN_HANDLE = "_human";
1074
+ var TITLE_CHARS = 64;
1068
1075
  var SYSTEM_HANDLE = "_relay";
1069
1076
  var GONE_PREFIX = "_gone_";
1070
1077
  var MIGRATION = [
@@ -1247,6 +1254,8 @@ var RelayCore = class {
1247
1254
  this.capEverySender = options.capEverySender === true;
1248
1255
  this.alive = options.alive ?? (() => true);
1249
1256
  this.hostMachine = options.machine ?? options.hostname ?? null;
1257
+ this.namedMachine = options.machine ?? null;
1258
+ this.hostname = options.hostname ?? null;
1250
1259
  }
1251
1260
  sql;
1252
1261
  user;
@@ -1256,6 +1265,8 @@ var RelayCore = class {
1256
1265
  capEverySender;
1257
1266
  alive;
1258
1267
  hostMachine;
1268
+ namedMachine;
1269
+ hostname;
1259
1270
  policy = null;
1260
1271
  /** the identity each policy `group:<name>` naming no policy group bound to when the policy's text first loaded: that group's then, or `none` */
1261
1272
  policyBinds = /* @__PURE__ */ new Map();
@@ -1263,6 +1274,11 @@ var RelayCore = class {
1263
1274
  /** while a scope world derives a node: what it already derived of ancestor lists and limits */
1264
1275
  scopeMemo = null;
1265
1276
  deferred = /* @__PURE__ */ new WeakMap();
1277
+ /** a `machine` label naming this host by its hostname, as clients default it, names it by its machine name when one is set */
1278
+ machineLabel(labels) {
1279
+ if (this.namedMachine !== null && labels.machine !== void 0 && labels.machine === this.hostname) labels.machine = this.namedMachine;
1280
+ return labels;
1281
+ }
1266
1282
  migrate() {
1267
1283
  for (const statement of MIGRATION) this.sql.all(statement);
1268
1284
  const columns = this.sql.all(`PRAGMA table_info(idempotency)`).map((c) => c.name);
@@ -1295,7 +1311,11 @@ var RelayCore = class {
1295
1311
  ["introduced", "INTEGER"],
1296
1312
  ["directive_holds", "INTEGER"],
1297
1313
  ["harness_session", "TEXT"],
1298
- ["harness_transcript", "TEXT"]
1314
+ ["harness_transcript", "TEXT"],
1315
+ ["title_auto", "TEXT"],
1316
+ ["title_turn", "INTEGER"],
1317
+ ["tldr_turn", "INTEGER"],
1318
+ ["named", "INTEGER"]
1299
1319
  ]) {
1300
1320
  if (!endpointColumns.includes(column)) this.sql.all(`ALTER TABLE endpoints ADD COLUMN ${column} ${type}`);
1301
1321
  }
@@ -1452,7 +1472,7 @@ var RelayCore = class {
1452
1472
  model: cleanText(claim.model, "model", 128),
1453
1473
  cwd: cleanText(claim.cwd, "cwd", 1024),
1454
1474
  parent: cleanText(claim.parent, "parent", 128),
1455
- labels: JSON.stringify(cleanLabels(claim.labels)),
1475
+ labels: JSON.stringify(this.machineLabel(cleanLabels(claim.labels))),
1456
1476
  session: cleanText(claim.session, "session", 128),
1457
1477
  process: cleanText(claim.process, "process", 128)
1458
1478
  };
@@ -1551,6 +1571,7 @@ var RelayCore = class {
1551
1571
  }
1552
1572
  claimRow(wanted, harness, fields, transient, scope = { handle: wanted, lineage: [], grant: null, rules: {}, groups: [], cwd: fields.cwd }) {
1553
1573
  const now = this.now();
1574
+ this.endExited(now);
1554
1575
  const bare = transient && fields.session !== null && BARE_PID_KEY.test(fields.session);
1555
1576
  let row = fields.session ? one(
1556
1577
  this.sql,
@@ -1574,7 +1595,7 @@ var RelayCore = class {
1574
1595
  } else if (row && !transient && !sameProcess(row, fields.process)) {
1575
1596
  row = void 0;
1576
1597
  session = null;
1577
- } else if (row && !transient && row.token_hash === null && row.handle !== wanted && !row.handle.startsWith(`${wanted.slice(0, 58)}-`)) {
1598
+ } else if (row && !transient && row.token_hash === null && row.named === null && row.handle !== wanted && !row.handle.startsWith(`${wanted.slice(0, 58)}-`)) {
1578
1599
  const hooks = row;
1579
1600
  let settled = false;
1580
1601
  for (let n = 1; n < (scope.exact ? 2 : 1e3) && !settled; n++) {
@@ -2012,6 +2033,7 @@ var RelayCore = class {
2012
2033
  */
2013
2034
  list(filters = {}) {
2014
2035
  const now = this.now();
2036
+ this.endExited(now);
2015
2037
  this.settleOffline(now);
2016
2038
  const cutoff = now - LIMITS.listOfflineWithinS * 1e3;
2017
2039
  const viewer = filters.viewer === void 0 ? void 0 : this.mustEndpoint(filters.viewer);
@@ -2065,10 +2087,12 @@ var RelayCore = class {
2065
2087
  const text = oneLine(note, "note", LIMITS.noteChars);
2066
2088
  if (text && findSecret(text)) throw this.refuse(endpoint, "secret_refused", "the status note looks like it carries a credential");
2067
2089
  this.sql.all(
2068
- `UPDATE endpoints SET status_state = COALESCE(?, status_state, 'working'), status_note = COALESCE(?, status_note, ''), last_seen = ? 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 = ?`,
2069
2092
  state ?? null,
2070
2093
  text,
2071
2094
  this.now(),
2095
+ text === null ? 0 : 1,
2072
2096
  endpoint
2073
2097
  );
2074
2098
  }
@@ -2078,10 +2102,117 @@ var RelayCore = class {
2078
2102
  if (row.kind !== "agent") throw new RelayError("invalid_params", "only an agent endpoint has a title");
2079
2103
  const title = oneLine(value, "title", 64) || null;
2080
2104
  if (title && findSecret(title)) throw this.refuse(endpoint, "secret_refused", "the title looks like it carries a credential");
2081
- this.sql.all(`UPDATE endpoints SET title = ? WHERE id = ?`, title, endpoint);
2105
+ this.sql.all(`UPDATE endpoints SET title = ?, title_auto = NULL, title_turn = NULL WHERE id = ?`, title, endpoint);
2082
2106
  this.audit("title", endpoint, null, { title });
2083
2107
  return this.whoami(endpoint);
2084
2108
  }
2109
+ /**
2110
+ * a hook's guess at the session's title, from the harness's own summary or the first prompt's first line; whether it changed the title.
2111
+ * Never over a title set explicitly; the first prompt fills only an empty title, and a summary replaces an inferred one at most every
2112
+ * ${LIMITS.titleRefreshTurns} prompts, or at once over the first prompt's
2113
+ */
2114
+ inferTitle(endpoint, params, options = {}) {
2115
+ if (params.from !== "summary" && params.from !== "prompt") throw new RelayError("invalid_params", "from is summary or prompt");
2116
+ if (typeof params.text !== "string") throw new RelayError("invalid_params", "text must be a string");
2117
+ const row = this.mustEndpoint(endpoint);
2118
+ if (row.kind !== "agent" || !this.holds(row, options)) return false;
2119
+ const title = stripControl(redactSecrets(params.text.slice(0, 4096).replace(/\s+/g, " ").trim())).slice(0, TITLE_CHARS).trim();
2120
+ if (!title || findSecret(title) || title === row.title) return false;
2121
+ if (row.title !== null && row.title !== row.title_auto) return false;
2122
+ const turn = row.prompts ?? 0;
2123
+ if (params.from === "prompt" && row.title !== null) return false;
2124
+ if (params.from === "summary" && row.title !== null && row.title_turn !== null && turn - row.title_turn < LIMITS.titleRefreshTurns) return false;
2125
+ this.sql.all(`UPDATE endpoints SET title = ?, title_auto = ?, title_turn = ? WHERE id = ?`, title, title, params.from === "summary" ? turn : null, endpoint);
2126
+ return true;
2127
+ }
2128
+ /**
2129
+ * a session joins on demand: its hook claim registers or takes back its endpoint, marked seen now even right after a leave, and `handle`,
2130
+ * when given, renames it in place ({@link rename}'s rules). Nothing is kept when the rename is refused
2131
+ */
2132
+ joinSession(claim, handle) {
2133
+ return this.scopeTx(() => {
2134
+ const { endpoint } = this.claim(claim, { transient: true });
2135
+ const row = this.mustEndpoint(endpoint.id);
2136
+ if (handle !== void 0 && handle !== null && handle !== "") this.renameRow(row, handle, false);
2137
+ this.sql.all(`UPDATE endpoints SET hook_seen = ?, hook_ended = NULL WHERE id = ?`, this.now(), row.id);
2138
+ this.audit("join", row.id, null, { handle: this.byId(row.id).handle });
2139
+ return this.whoami(row.id);
2140
+ });
2141
+ }
2142
+ /**
2143
+ * renames an agent in place: its id, mail, threads, status, title and relations stay. An agent renames only itself, and only a root unless
2144
+ * the policy's `defaults.rename` is `self`; the human at a terminal renames any local agent. The new handle is valid and held by no other endpoint
2145
+ */
2146
+ rename(actor, params) {
2147
+ return this.scopeTx(() => {
2148
+ const me = this.actorRow(actor);
2149
+ if (actor.kind === "human") this.needTerminal(actor, me, params.endpoint);
2150
+ else if (actor.auth === "lineage") throw new RelayError("scope_refused", "only the session itself renames it: run this from the session's own shell");
2151
+ const target = params.endpoint === void 0 ? me : this.resolve(params.endpoint);
2152
+ if (actor.kind !== "human" && target.id !== me.id) throw new RelayError("scope_refused", "an agent renames only itself");
2153
+ if (target.kind !== "agent" || !this.localRow(target)) throw new RelayError("invalid_params", "only a local agent endpoint can be renamed");
2154
+ this.renameRow(target, params.handle, actor.kind === "human");
2155
+ return { endpoint: this.whoami(target.id), from: target.handle, session: target.session };
2156
+ });
2157
+ }
2158
+ /** the endpoint's handle set to `value`, with the stored `ep:` and `fleet:` selectors that named it */
2159
+ renameRow(row, value, byHuman) {
2160
+ const handle = typeof value === "string" ? value.trim().replace(/^me\//, "") : "";
2161
+ if (!validHandle(handle)) throw new RelayError("invalid_params", `handle ${JSON.stringify(handle)} is not valid: lowercase letters, digits, . _ -`);
2162
+ if (findSecret(handle)) throw new RelayError("secret_refused", "the handle looks like it carries a credential");
2163
+ if (handle === row.handle) {
2164
+ this.sql.all(`UPDATE endpoints SET named = COALESCE(named, ?) WHERE id = ?`, this.now(), row.id);
2165
+ return;
2166
+ }
2167
+ if (!byHuman && row.parent_ep !== null && this.policy?.defaults?.rename !== "self") {
2168
+ const parent = this.byId(row.parent_ep);
2169
+ throw new RelayError("scope_refused", `spawned by ${parent ? this.address(parent) : "another agent"}; its name is set by the spawner`);
2170
+ }
2171
+ const held = this.byHandle(handle);
2172
+ if (held) {
2173
+ const how = held.kind !== "agent" ? "reserved" : presenceOf(held, this.now()) === "offline" ? "held by an offline endpoint" : "held by a live endpoint";
2174
+ throw new RelayError("invalid_params", `${handle} is ${how}: pick another name`);
2175
+ }
2176
+ const from = row.handle;
2177
+ this.sql.all(`UPDATE endpoints SET handle = ?, named = ? WHERE id = ?`, handle, this.now(), row.id);
2178
+ this.sql.all(`UPDATE grants SET name = ? WHERE used_by = ? AND name = ?`, handle, row.id, from);
2179
+ for (const prefix of row.parent_ep === null ? ["ep:", "fleet:"] : ["ep:"]) {
2180
+ const [a, b] = [JSON.stringify(`${prefix}${from}`), JSON.stringify(`${prefix}${handle}`)];
2181
+ for (const [table, column] of [
2182
+ ["endpoints", "rules"],
2183
+ ["endpoints", "caps"],
2184
+ ["groups", "rules"],
2185
+ ["grants", "caps"]
2186
+ ])
2187
+ this.sql.all(`UPDATE ${table} SET ${column} = REPLACE(${column}, ?, ?) WHERE instr(${column}, ?) > 0`, a, b, a);
2188
+ }
2189
+ this.audit("rename", row.id, null, { from, to: handle });
2190
+ }
2191
+ /**
2192
+ * a session leaves: it lets go of its session and token and shows offline, its mail kept; a later join takes it back. An agent leaves only
2193
+ * itself, the human at a terminal any local root. A spawned worker stays joined while it runs
2194
+ */
2195
+ leaveSession(actor, params = {}) {
2196
+ return this.scopeTx(() => {
2197
+ const me = this.actorRow(actor);
2198
+ if (actor.kind === "human") this.needTerminal(actor, me, params.endpoint);
2199
+ else if (actor.auth === "lineage") throw new RelayError("scope_refused", "only the session itself leaves: run this from the session's own shell");
2200
+ const target = params.endpoint === void 0 ? me : this.resolve(params.endpoint);
2201
+ if (actor.kind !== "human" && target.id !== me.id) throw new RelayError("scope_refused", "an agent leaves only for itself");
2202
+ if (target.kind !== "agent" || !this.localRow(target)) throw new RelayError("invalid_params", "only a local agent endpoint can leave");
2203
+ if (target.parent_ep !== null) {
2204
+ const parent = this.byId(target.parent_ep);
2205
+ throw new RelayError("scope_refused", `spawned by ${parent ? this.address(parent) : "another agent"}; a spawned worker stays joined while it runs`);
2206
+ }
2207
+ this.sql.all(`UPDATE endpoints SET session = NULL, token_hash = NULL, hook_seen = NULL, hook_ended = ? WHERE id = ?`, this.now(), target.id);
2208
+ this.audit("leave", target.id, null, { handle: target.handle });
2209
+ return { endpoint: this.whoami(target.id), session: target.session };
2210
+ });
2211
+ }
2212
+ /** the harness session key an endpoint holds, if any */
2213
+ sessionOf(endpoint) {
2214
+ return this.byId(endpoint)?.session ?? null;
2215
+ }
2085
2216
  // messages
2086
2217
  /**
2087
2218
  * `origin.tty` is recorded on messages the human inbox sends: whether its client said a terminal was attached. `origin.unverified`: the human
@@ -2139,6 +2270,13 @@ var RelayCore = class {
2139
2270
  resolved.push(row);
2140
2271
  }
2141
2272
  const recipients = [...new Map(resolved.map((row) => [row.id, row])).values()];
2273
+ if (params.live !== void 0 && typeof params.live !== "boolean") throw new RelayError("invalid_params", "live must be true or false");
2274
+ const dark = recipients.filter(
2275
+ (r) => r.kind === "agent" && presenceOf(r, now) === "offline" && (now - Math.max(r.last_seen ?? 0, r.hook_seen ?? 0) >= ASK_ENDED_GRACE_MS || r.session === null && r.token_hash === null && r.hook_ended !== null) && !this.unjoinedSpawn(sender, r.handle, now)
2276
+ );
2277
+ if (params.live === true && dark.length)
2278
+ throw new RelayError("not_live", `no live session holds ${dark.map((r) => this.address(r)).join(", ")}: nothing was queued`);
2279
+ const warnings = dark.map((r) => `queued: no live session holds ${this.address(r)}`);
2142
2280
  const pending = [...unjoined.values()].map((grant) => ({ key: pendingKey(grant.token_hash), address: `${this.user}/${grant.name}` }));
2143
2281
  const reached = recipients.length + pending.length;
2144
2282
  if (reached > LIMITS.fanOutTotal) throw new RelayError("too_large", `the send reaches ${reached} endpoints, more than ${LIMITS.fanOutTotal}`);
@@ -2356,7 +2494,7 @@ var RelayCore = class {
2356
2494
  deliveries.push({ endpoint: key, address, seq, offline: false, queued: true, last_seen: null, state: "starting" });
2357
2495
  }
2358
2496
  for (const name of fanOut) this.sql.all(`INSERT INTO tree_events (tree, at) VALUES (?, ?)`, `group:${name}`, now);
2359
- const sent = { id, thread, hop, deliveries };
2497
+ const sent = { id, thread, hop, deliveries, ...warnings.length ? { warnings } : {} };
2360
2498
  if (clientId) {
2361
2499
  this.sql.all(
2362
2500
  `INSERT OR REPLACE INTO idempotency (from_ep, client_id, payload_hash, result, created_at) VALUES (?, ?, ?, ?, ?)`,
@@ -2494,6 +2632,7 @@ var RelayCore = class {
2494
2632
  * `directive_seqs`. Any call says `brief` once the session was shown the full preamble ({@link briefed}), and one that is no stop names in
2495
2633
  * `intro` the spawner of an endpoint a `swarm spawn` grant placed, with the grant's steer key in `intro_key`, until a hook showed it
2496
2634
  * ({@link introduced}). A hook of another process than the one holding the endpoint (a child that inherited the session key) gets nothing.
2635
+ * A wake also offers live workers' messages.
2497
2636
  */
2498
2637
  inject(endpoint, options = {}) {
2499
2638
  const now = this.now();
@@ -2561,7 +2700,7 @@ var RelayCore = class {
2561
2700
  now,
2562
2701
  LIMITS.inboxPage
2563
2702
  );
2564
- const { shown, offered } = this.budget(rows, LIMITS.injectBytes, () => this.pushable(me, now));
2703
+ const { shown, offered } = this.budget(rows, LIMITS.injectBytes, options.wake ? void 0 : () => this.pushable(me, now));
2565
2704
  const hold = this.hold(offered, now);
2566
2705
  this.received(endpoint, shown, now);
2567
2706
  return { messages: shown.map((row) => this.envelope(row, row.seq)), offered, hold, more: waiting - shown.length, unread, block: false, ...brief, ...intro };
@@ -2731,6 +2870,15 @@ var RelayCore = class {
2731
2870
  this.settle(endpoint);
2732
2871
  return true;
2733
2872
  }
2873
+ /** whether a prompt should remind the agent to refresh its status note: `every` prompts passed since the note or the last reminder; counts the reminder */
2874
+ tldrDue(endpoint, every) {
2875
+ if (!Number.isInteger(every) || every <= 0) return false;
2876
+ const row = this.mustEndpoint(endpoint);
2877
+ const turn = row.prompts ?? 0;
2878
+ if (row.kind !== "agent" || turn - (row.tldr_turn ?? 0) < every) return false;
2879
+ this.sql.all(`UPDATE endpoints SET tldr_turn = ? WHERE id = ?`, turn, endpoint);
2880
+ return true;
2881
+ }
2734
2882
  /** the background hooks' showings ({@link injected}) older than ${LIMITS.deferredSettleMs} ms reached the agent: inbox no longer lists them */
2735
2883
  settle(endpoint) {
2736
2884
  this.sql.all(
@@ -3185,6 +3333,7 @@ var RelayCore = class {
3185
3333
  this.sql.all(`DELETE FROM idempotency WHERE created_at <= ?`, now - LIMITS.idempotencyS * 1e3);
3186
3334
  this.sql.all(`DELETE FROM pair_events WHERE at <= ?`, now - 3600 * 1e3);
3187
3335
  this.sql.all(`DELETE FROM blocks WHERE until <= ?`, now);
3336
+ this.endExited(now);
3188
3337
  this.settleOffline(now);
3189
3338
  this.sql.all(
3190
3339
  `DELETE FROM endpoints WHERE kind = 'agent' AND connections = 0 AND COALESCE(last_seen, created_at) <= ?
@@ -3210,6 +3359,16 @@ var RelayCore = class {
3210
3359
  if (!root || root.handle.startsWith(GONE_PREFIX) || !seen) this.dropGroup(group.name);
3211
3360
  }
3212
3361
  }
3362
+ /** ends the hook presence of local agent endpoints whose harness process exited without a session_end, as one would */
3363
+ endExited(now = this.now()) {
3364
+ for (const row of this.sql.all(
3365
+ `SELECT * FROM endpoints WHERE kind = 'agent' AND connections = 0 AND hook_seen IS NOT NULL AND process IS NOT NULL`
3366
+ )) {
3367
+ const pid = Number(/^pid:(\d+)/.exec(row.process)?.[1]);
3368
+ if (!pid || !this.localRow(row) || this.alive(pid)) continue;
3369
+ this.sql.all(`UPDATE endpoints SET hook_seen = NULL, hook_ended = ?, session = NULL WHERE id = ? AND connections = 0`, now, row.id);
3370
+ }
3371
+ }
3213
3372
  /** an agent endpoint left offline drops a working or waiting status, which no one is left to keep current */
3214
3373
  settleOffline(now) {
3215
3374
  for (const row of this.sql.all(
@@ -3263,15 +3422,19 @@ var RelayCore = class {
3263
3422
  endpoint
3264
3423
  );
3265
3424
  } else if (params.kind === "session_start") {
3425
+ const fresh = own && summary !== "compact" && summary !== "resume";
3266
3426
  this.sql.all(
3267
3427
  `UPDATE endpoints SET hook_seen = COALESCE(?, hook_seen), hook_ended = NULL, stop_blocks = CASE WHEN ? THEN 0 ELSE stop_blocks END, pushes = CASE WHEN ? THEN 0 ELSE pushes END,
3268
- briefed = CASE WHEN ? THEN NULL ELSE briefed END, introduced = CASE WHEN ? THEN NULL ELSE introduced END, directive_holds = CASE WHEN ? THEN NULL ELSE directive_holds END 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 = ?`,
3269
3430
  options.transient ? now : null,
3270
3431
  own ? 1 : 0,
3271
3432
  own ? 1 : 0,
3272
3433
  own ? 1 : 0,
3273
3434
  own ? 1 : 0,
3274
3435
  own ? 1 : 0,
3436
+ fresh ? 1 : 0,
3437
+ fresh ? 1 : 0,
3275
3438
  endpoint
3276
3439
  );
3277
3440
  } else if (options.transient) {
@@ -4985,7 +5148,7 @@ ${stderr}` : "";
4985
5148
  }
4986
5149
  /** the user parts an address of this core may carry */
4987
5150
  addressUsers() {
4988
- return [this.user, "me"];
5151
+ return [this.user, "me", "local"];
4989
5152
  }
4990
5153
  /** whether the row is this host's own, the only rows with a tree, groups, rules and caps here */
4991
5154
  localRow(_row) {
@@ -4997,7 +5160,7 @@ ${stderr}` : "";
4997
5160
  const slash = text.indexOf("/");
4998
5161
  const user = slash === -1 ? null : text.slice(0, slash);
4999
5162
  const handle = slash === -1 ? text : text.slice(slash + 1);
5000
- if (user !== null && user !== "me" && user !== this.user) throw unreachable(text);
5163
+ if (user !== null && user !== "me" && user !== "local" && user !== this.user) throw unreachable(text);
5001
5164
  const row = handle === "" ? this.byHandle(HUMAN_HANDLE) : validHandle(handle) ? this.byHandle(handle) : void 0;
5002
5165
  if (!row) throw unreachable(text);
5003
5166
  return row;
@@ -6054,6 +6217,7 @@ export {
6054
6217
  SHORT_ID,
6055
6218
  STATUS_STATES,
6056
6219
  SYSTEM_HANDLE,
6220
+ TITLE_CHARS,
6057
6221
  VerdictCache,
6058
6222
  XCounters,
6059
6223
  XLIMITS,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scp-dev/core",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Swarm Communication Protocol core: wire types, framing, scopes, policy, judge and the relay core.",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://scp.dev",
package/src/core.ts CHANGED
@@ -69,6 +69,8 @@ import { one, type Sql, type SqlValue } from "./sql"
69
69
  import { utf8Bytes } from "./utf8"
70
70
 
71
71
  export const HUMAN_HANDLE = "_human"
72
+ /** an agent title's length cap */
73
+ export const TITLE_CHARS = 64
72
74
  export const SYSTEM_HANDLE = "_relay"
73
75
  export const GONE_PREFIX = "_gone_"
74
76
 
@@ -240,12 +242,20 @@ export interface EndpointRow {
240
242
  /** JSON {@link ScopeCaps} */
241
243
  caps: string | null
242
244
  title: string | null
245
+ /** the title a hook last inferred: the title is inferred while it equals this */
246
+ title_auto: string | null
247
+ /** the prompt count when a summary last set the inferred title; null when the first prompt set it */
248
+ title_turn: number | null
249
+ /** the prompt count when the status note last changed or a hook last reminded the agent to refresh it */
250
+ tldr_turn: number | null
243
251
  /** when a new spawn last took the endpoint over: mail and messages from before it were the earlier holder's */
244
252
  reset_at: number | null
245
253
  /** when a new spawn last took the endpoint over: with its id, names the holder another machine sees */
246
254
  spawned_at: number | null
247
255
  /** who may peek: null is off */
248
256
  peek: PeekLevel | null
257
+ /** when the session last set its handle itself (join or rename): a later claim with another handle does not rename it back */
258
+ named: number | null
249
259
  }
250
260
 
251
261
  export interface MessageRow {
@@ -442,6 +452,8 @@ export class RelayCore {
442
452
  private readonly capEverySender: boolean
443
453
  private readonly alive: (pid: number) => boolean
444
454
  private readonly hostMachine: string | null
455
+ private readonly namedMachine: string | null
456
+ private readonly hostname: string | null
445
457
  private policy: Policy | null = null
446
458
  /** the identity each policy `group:<name>` naming no policy group bound to when the policy's text first loaded: that group's then, or `none` */
447
459
  private policyBinds = new Map<string, string>()
@@ -461,6 +473,14 @@ export class RelayCore {
461
473
  this.capEverySender = options.capEverySender === true
462
474
  this.alive = options.alive ?? (() => true)
463
475
  this.hostMachine = options.machine ?? options.hostname ?? null
476
+ this.namedMachine = options.machine ?? null
477
+ this.hostname = options.hostname ?? null
478
+ }
479
+
480
+ /** a `machine` label naming this host by its hostname, as clients default it, names it by its machine name when one is set */
481
+ private machineLabel(labels: Record<string, string>): Record<string, string> {
482
+ if (this.namedMachine !== null && labels.machine !== undefined && labels.machine === this.hostname) labels.machine = this.namedMachine
483
+ return labels
464
484
  }
465
485
 
466
486
  migrate(): void {
@@ -496,6 +516,10 @@ export class RelayCore {
496
516
  ["directive_holds", "INTEGER"],
497
517
  ["harness_session", "TEXT"],
498
518
  ["harness_transcript", "TEXT"],
519
+ ["title_auto", "TEXT"],
520
+ ["title_turn", "INTEGER"],
521
+ ["tldr_turn", "INTEGER"],
522
+ ["named", "INTEGER"],
499
523
  ] as const) {
500
524
  if (!endpointColumns.includes(column)) this.sql.all(`ALTER TABLE endpoints ADD COLUMN ${column} ${type}`)
501
525
  }
@@ -658,7 +682,7 @@ export class RelayCore {
658
682
  model: cleanText(claim.model, "model", 128),
659
683
  cwd: cleanText(claim.cwd, "cwd", 1024),
660
684
  parent: cleanText(claim.parent, "parent", 128),
661
- labels: JSON.stringify(cleanLabels(claim.labels)),
685
+ labels: JSON.stringify(this.machineLabel(cleanLabels(claim.labels))),
662
686
  session: cleanText(claim.session, "session", 128),
663
687
  process: cleanText(claim.process, "process", 128),
664
688
  }
@@ -773,6 +797,7 @@ export class RelayCore {
773
797
  scope: ClaimScope = { handle: wanted, lineage: [], grant: null, rules: {}, groups: [], cwd: fields.cwd },
774
798
  ): { endpoint: EndpointView; token?: string; created?: true } {
775
799
  const now = this.now()
800
+ this.endExited(now)
776
801
  const bare = transient && fields.session !== null && BARE_PID_KEY.test(fields.session)
777
802
  let row = fields.session
778
803
  ? one<EndpointRow>(
@@ -798,7 +823,14 @@ export class RelayCore {
798
823
  } else if (row && !transient && !sameProcess(row, fields.process)) {
799
824
  row = undefined
800
825
  session = null
801
- } else if (row && !transient && row.token_hash === null && row.handle !== wanted && !row.handle.startsWith(`${wanted.slice(0, 58)}-`)) {
826
+ } else if (
827
+ row &&
828
+ !transient &&
829
+ row.token_hash === null &&
830
+ row.named === null &&
831
+ row.handle !== wanted &&
832
+ !row.handle.startsWith(`${wanted.slice(0, 58)}-`)
833
+ ) {
802
834
  const hooks = row
803
835
  let settled = false
804
836
  for (let n = 1; n < (scope.exact ? 2 : 1000) && !settled; n++) {
@@ -1297,6 +1329,7 @@ export class RelayCore {
1297
1329
  } = {},
1298
1330
  ): EndpointView[] {
1299
1331
  const now = this.now()
1332
+ this.endExited(now)
1300
1333
  this.settleOffline(now)
1301
1334
  const cutoff = now - LIMITS.listOfflineWithinS * 1000
1302
1335
  const viewer = filters.viewer === undefined ? undefined : this.mustEndpoint(filters.viewer)
@@ -1364,10 +1397,12 @@ export class RelayCore {
1364
1397
  const text = oneLine(note, "note", LIMITS.noteChars)
1365
1398
  if (text && findSecret(text)) throw this.refuse(endpoint, "secret_refused", "the status note looks like it carries a credential")
1366
1399
  this.sql.all(
1367
- `UPDATE endpoints SET status_state = COALESCE(?, status_state, 'working'), status_note = COALESCE(?, status_note, ''), last_seen = ? WHERE id = ?`,
1400
+ `UPDATE endpoints SET status_state = COALESCE(?, status_state, 'working'), status_note = COALESCE(?, status_note, ''), last_seen = ?,
1401
+ tldr_turn = CASE WHEN ? THEN COALESCE(prompts, 0) ELSE tldr_turn END WHERE id = ?`,
1368
1402
  state ?? null,
1369
1403
  text,
1370
1404
  this.now(),
1405
+ text === null ? 0 : 1,
1371
1406
  endpoint,
1372
1407
  )
1373
1408
  }
@@ -1378,11 +1413,126 @@ export class RelayCore {
1378
1413
  if (row.kind !== "agent") throw new RelayError("invalid_params", "only an agent endpoint has a title")
1379
1414
  const title = oneLine(value, "title", 64) || null
1380
1415
  if (title && findSecret(title)) throw this.refuse(endpoint, "secret_refused", "the title looks like it carries a credential")
1381
- this.sql.all(`UPDATE endpoints SET title = ? WHERE id = ?`, title, endpoint)
1416
+ this.sql.all(`UPDATE endpoints SET title = ?, title_auto = NULL, title_turn = NULL WHERE id = ?`, title, endpoint)
1382
1417
  this.audit("title", endpoint, null, { title })
1383
1418
  return this.whoami(endpoint)
1384
1419
  }
1385
1420
 
1421
+ /**
1422
+ * a hook's guess at the session's title, from the harness's own summary or the first prompt's first line; whether it changed the title.
1423
+ * Never over a title set explicitly; the first prompt fills only an empty title, and a summary replaces an inferred one at most every
1424
+ * ${LIMITS.titleRefreshTurns} prompts, or at once over the first prompt's
1425
+ */
1426
+ inferTitle(endpoint: string, params: { text?: unknown; from?: unknown }, options: { transient?: boolean; process?: string } = {}): boolean {
1427
+ if (params.from !== "summary" && params.from !== "prompt") throw new RelayError("invalid_params", "from is summary or prompt")
1428
+ if (typeof params.text !== "string") throw new RelayError("invalid_params", "text must be a string")
1429
+ const row = this.mustEndpoint(endpoint)
1430
+ if (row.kind !== "agent" || !this.holds(row, options)) return false
1431
+ const title = stripControl(redactSecrets(params.text.slice(0, 4096).replace(/\s+/g, " ").trim()))
1432
+ .slice(0, TITLE_CHARS)
1433
+ .trim()
1434
+ if (!title || findSecret(title) || title === row.title) return false
1435
+ if (row.title !== null && row.title !== row.title_auto) return false
1436
+ const turn = row.prompts ?? 0
1437
+ if (params.from === "prompt" && row.title !== null) return false
1438
+ if (params.from === "summary" && row.title !== null && row.title_turn !== null && turn - row.title_turn < LIMITS.titleRefreshTurns) return false
1439
+ this.sql.all(`UPDATE endpoints SET title = ?, title_auto = ?, title_turn = ? WHERE id = ?`, title, title, params.from === "summary" ? turn : null, endpoint)
1440
+ return true
1441
+ }
1442
+
1443
+ /**
1444
+ * a session joins on demand: its hook claim registers or takes back its endpoint, marked seen now even right after a leave, and `handle`,
1445
+ * when given, renames it in place ({@link rename}'s rules). Nothing is kept when the rename is refused
1446
+ */
1447
+ joinSession(claim: Claim, handle?: unknown): EndpointView {
1448
+ return this.scopeTx(() => {
1449
+ const { endpoint } = this.claim(claim, { transient: true })
1450
+ const row = this.mustEndpoint(endpoint.id)
1451
+ if (handle !== undefined && handle !== null && handle !== "") this.renameRow(row, handle, false)
1452
+ this.sql.all(`UPDATE endpoints SET hook_seen = ?, hook_ended = NULL WHERE id = ?`, this.now(), row.id)
1453
+ this.audit("join", row.id, null, { handle: this.byId(row.id)!.handle })
1454
+ return this.whoami(row.id)
1455
+ })
1456
+ }
1457
+
1458
+ /**
1459
+ * renames an agent in place: its id, mail, threads, status, title and relations stay. An agent renames only itself, and only a root unless
1460
+ * the policy's `defaults.rename` is `self`; the human at a terminal renames any local agent. The new handle is valid and held by no other endpoint
1461
+ */
1462
+ rename(actor: ScopeActor, params: { endpoint?: unknown; handle: unknown }): { endpoint: EndpointView; from: string; session: string | null } {
1463
+ return this.scopeTx(() => {
1464
+ const me = this.actorRow(actor)
1465
+ if (actor.kind === "human") this.needTerminal(actor, me, params.endpoint)
1466
+ else if (actor.auth === "lineage") throw new RelayError("scope_refused", "only the session itself renames it: run this from the session's own shell")
1467
+ const target = params.endpoint === undefined ? me : this.resolve(params.endpoint)
1468
+ if (actor.kind !== "human" && target.id !== me.id) throw new RelayError("scope_refused", "an agent renames only itself")
1469
+ if (target.kind !== "agent" || !this.localRow(target)) throw new RelayError("invalid_params", "only a local agent endpoint can be renamed")
1470
+ this.renameRow(target, params.handle, actor.kind === "human")
1471
+ return { endpoint: this.whoami(target.id), from: target.handle, session: target.session }
1472
+ })
1473
+ }
1474
+
1475
+ /** the endpoint's handle set to `value`, with the stored `ep:` and `fleet:` selectors that named it */
1476
+ private renameRow(row: EndpointRow, value: unknown, byHuman: boolean): void {
1477
+ const handle = typeof value === "string" ? value.trim().replace(/^me\//, "") : ""
1478
+ if (!validHandle(handle)) throw new RelayError("invalid_params", `handle ${JSON.stringify(handle)} is not valid: lowercase letters, digits, . _ -`)
1479
+ if (findSecret(handle)) throw new RelayError("secret_refused", "the handle looks like it carries a credential")
1480
+ if (handle === row.handle) {
1481
+ this.sql.all(`UPDATE endpoints SET named = COALESCE(named, ?) WHERE id = ?`, this.now(), row.id)
1482
+ return
1483
+ }
1484
+ if (!byHuman && row.parent_ep !== null && this.policy?.defaults?.rename !== "self") {
1485
+ const parent = this.byId(row.parent_ep)
1486
+ throw new RelayError("scope_refused", `spawned by ${parent ? this.address(parent) : "another agent"}; its name is set by the spawner`)
1487
+ }
1488
+ const held = this.byHandle(handle)
1489
+ if (held) {
1490
+ const how = held.kind !== "agent" ? "reserved" : presenceOf(held, this.now()) === "offline" ? "held by an offline endpoint" : "held by a live endpoint"
1491
+ throw new RelayError("invalid_params", `${handle} is ${how}: pick another name`)
1492
+ }
1493
+ const from = row.handle
1494
+ this.sql.all(`UPDATE endpoints SET handle = ?, named = ? WHERE id = ?`, handle, this.now(), row.id)
1495
+ this.sql.all(`UPDATE grants SET name = ? WHERE used_by = ? AND name = ?`, handle, row.id, from)
1496
+ for (const prefix of row.parent_ep === null ? ["ep:", "fleet:"] : ["ep:"]) {
1497
+ const [a, b] = [JSON.stringify(`${prefix}${from}`), JSON.stringify(`${prefix}${handle}`)]
1498
+ for (const [table, column] of [
1499
+ ["endpoints", "rules"],
1500
+ ["endpoints", "caps"],
1501
+ ["groups", "rules"],
1502
+ ["grants", "caps"],
1503
+ ] as const)
1504
+ this.sql.all(`UPDATE ${table} SET ${column} = REPLACE(${column}, ?, ?) WHERE instr(${column}, ?) > 0`, a, b, a)
1505
+ }
1506
+ this.audit("rename", row.id, null, { from, to: handle })
1507
+ }
1508
+
1509
+ /**
1510
+ * a session leaves: it lets go of its session and token and shows offline, its mail kept; a later join takes it back. An agent leaves only
1511
+ * itself, the human at a terminal any local root. A spawned worker stays joined while it runs
1512
+ */
1513
+ leaveSession(actor: ScopeActor, params: { endpoint?: unknown } = {}): { endpoint: EndpointView; session: string | null } {
1514
+ return this.scopeTx(() => {
1515
+ const me = this.actorRow(actor)
1516
+ if (actor.kind === "human") this.needTerminal(actor, me, params.endpoint)
1517
+ else if (actor.auth === "lineage") throw new RelayError("scope_refused", "only the session itself leaves: run this from the session's own shell")
1518
+ const target = params.endpoint === undefined ? me : this.resolve(params.endpoint)
1519
+ if (actor.kind !== "human" && target.id !== me.id) throw new RelayError("scope_refused", "an agent leaves only for itself")
1520
+ if (target.kind !== "agent" || !this.localRow(target)) throw new RelayError("invalid_params", "only a local agent endpoint can leave")
1521
+ if (target.parent_ep !== null) {
1522
+ const parent = this.byId(target.parent_ep)
1523
+ throw new RelayError("scope_refused", `spawned by ${parent ? this.address(parent) : "another agent"}; a spawned worker stays joined while it runs`)
1524
+ }
1525
+ this.sql.all(`UPDATE endpoints SET session = NULL, token_hash = NULL, hook_seen = NULL, hook_ended = ? WHERE id = ?`, this.now(), target.id)
1526
+ this.audit("leave", target.id, null, { handle: target.handle })
1527
+ return { endpoint: this.whoami(target.id), session: target.session }
1528
+ })
1529
+ }
1530
+
1531
+ /** the harness session key an endpoint holds, if any */
1532
+ sessionOf(endpoint: string): string | null {
1533
+ return this.byId(endpoint)?.session ?? null
1534
+ }
1535
+
1386
1536
  // messages
1387
1537
 
1388
1538
  /**
@@ -1443,6 +1593,17 @@ export class RelayCore {
1443
1593
  resolved.push(row)
1444
1594
  }
1445
1595
  const recipients = [...new Map(resolved.map((row) => [row.id, row])).values()]
1596
+ if (params.live !== undefined && typeof params.live !== "boolean") throw new RelayError("invalid_params", "live must be true or false")
1597
+ const dark = recipients.filter(
1598
+ (r) =>
1599
+ r.kind === "agent" &&
1600
+ presenceOf(r, now) === "offline" &&
1601
+ (now - Math.max(r.last_seen ?? 0, r.hook_seen ?? 0) >= ASK_ENDED_GRACE_MS || (r.session === null && r.token_hash === null && r.hook_ended !== null)) &&
1602
+ !this.unjoinedSpawn(sender, r.handle, now),
1603
+ )
1604
+ if (params.live === true && dark.length)
1605
+ throw new RelayError("not_live", `no live session holds ${dark.map((r) => this.address(r)).join(", ")}: nothing was queued`)
1606
+ const warnings = dark.map((r) => `queued: no live session holds ${this.address(r)}`)
1446
1607
  const pending = [...unjoined.values()].map((grant) => ({ key: pendingKey(grant.token_hash), address: `${this.user}/${grant.name}` }))
1447
1608
  const reached = recipients.length + pending.length
1448
1609
  if (reached > LIMITS.fanOutTotal) throw new RelayError("too_large", `the send reaches ${reached} endpoints, more than ${LIMITS.fanOutTotal}`)
@@ -1672,7 +1833,7 @@ export class RelayCore {
1672
1833
  deliveries.push({ endpoint: key, address, seq, offline: false, queued: true, last_seen: null, state: "starting" })
1673
1834
  }
1674
1835
  for (const name of fanOut) this.sql.all(`INSERT INTO tree_events (tree, at) VALUES (?, ?)`, `group:${name}`, now)
1675
- const sent: SendResult = { id, thread, hop, deliveries }
1836
+ const sent: SendResult = { id, thread, hop, deliveries, ...(warnings.length ? { warnings } : {}) }
1676
1837
  if (clientId) {
1677
1838
  this.sql.all(
1678
1839
  `INSERT OR REPLACE INTO idempotency (from_ep, client_id, payload_hash, result, created_at) VALUES (?, ?, ?, ?, ?)`,
@@ -1819,10 +1980,11 @@ export class RelayCore {
1819
1980
  * `directive_seqs`. Any call says `brief` once the session was shown the full preamble ({@link briefed}), and one that is no stop names in
1820
1981
  * `intro` the spawner of an endpoint a `swarm spawn` grant placed, with the grant's steer key in `intro_key`, until a hook showed it
1821
1982
  * ({@link introduced}). A hook of another process than the one holding the endpoint (a child that inherited the session key) gets nothing.
1983
+ * A wake also offers live workers' messages.
1822
1984
  */
1823
1985
  inject(
1824
1986
  endpoint: string,
1825
- options: { stop?: boolean; count?: boolean; holdUnread?: boolean; directives?: boolean; transient?: boolean; process?: string } = {},
1987
+ options: { stop?: boolean; count?: boolean; holdUnread?: boolean; directives?: boolean; transient?: boolean; process?: string; wake?: boolean } = {},
1826
1988
  ): {
1827
1989
  messages: Envelope[]
1828
1990
  offered: number[]
@@ -1912,7 +2074,7 @@ export class RelayCore {
1912
2074
  now,
1913
2075
  LIMITS.inboxPage,
1914
2076
  )
1915
- const { shown, offered } = this.budget(rows, LIMITS.injectBytes, () => this.pushable(me, now))
2077
+ const { shown, offered } = this.budget(rows, LIMITS.injectBytes, options.wake ? undefined : () => this.pushable(me, now))
1916
2078
  const hold = this.hold(offered, now)
1917
2079
  this.received(endpoint, shown, now)
1918
2080
  return { messages: shown.map((row) => this.envelope(row, row.seq)), offered, hold, more: waiting - shown.length, unread, block: false, ...brief, ...intro }
@@ -2100,6 +2262,16 @@ export class RelayCore {
2100
2262
  return true
2101
2263
  }
2102
2264
 
2265
+ /** whether a prompt should remind the agent to refresh its status note: `every` prompts passed since the note or the last reminder; counts the reminder */
2266
+ tldrDue(endpoint: string, every: number): boolean {
2267
+ if (!Number.isInteger(every) || every <= 0) return false
2268
+ const row = this.mustEndpoint(endpoint)
2269
+ const turn = row.prompts ?? 0
2270
+ if (row.kind !== "agent" || turn - (row.tldr_turn ?? 0) < every) return false
2271
+ this.sql.all(`UPDATE endpoints SET tldr_turn = ? WHERE id = ?`, turn, endpoint)
2272
+ return true
2273
+ }
2274
+
2103
2275
  /** the background hooks' showings ({@link injected}) older than ${LIMITS.deferredSettleMs} ms reached the agent: inbox no longer lists them */
2104
2276
  private settle(endpoint: string): void {
2105
2277
  this.sql.all(
@@ -2612,6 +2784,7 @@ export class RelayCore {
2612
2784
  this.sql.all(`DELETE FROM idempotency WHERE created_at <= ?`, now - LIMITS.idempotencyS * 1000)
2613
2785
  this.sql.all(`DELETE FROM pair_events WHERE at <= ?`, now - 3600 * 1000)
2614
2786
  this.sql.all(`DELETE FROM blocks WHERE until <= ?`, now)
2787
+ this.endExited(now)
2615
2788
  this.settleOffline(now)
2616
2789
  this.sql.all(
2617
2790
  `DELETE FROM endpoints WHERE kind = 'agent' AND connections = 0 AND COALESCE(last_seen, created_at) <= ?
@@ -2638,6 +2811,17 @@ export class RelayCore {
2638
2811
  }
2639
2812
  }
2640
2813
 
2814
+ /** ends the hook presence of local agent endpoints whose harness process exited without a session_end, as one would */
2815
+ endExited(now = this.now()): void {
2816
+ for (const row of this.sql.all<EndpointRow>(
2817
+ `SELECT * FROM endpoints WHERE kind = 'agent' AND connections = 0 AND hook_seen IS NOT NULL AND process IS NOT NULL`,
2818
+ )) {
2819
+ const pid = Number(/^pid:(\d+)/.exec(row.process!)?.[1])
2820
+ if (!pid || !this.localRow(row) || this.alive(pid)) continue
2821
+ this.sql.all(`UPDATE endpoints SET hook_seen = NULL, hook_ended = ?, session = NULL WHERE id = ? AND connections = 0`, now, row.id)
2822
+ }
2823
+ }
2824
+
2641
2825
  /** an agent endpoint left offline drops a working or waiting status, which no one is left to keep current */
2642
2826
  private settleOffline(now: number): void {
2643
2827
  for (const row of this.sql.all<EndpointRow>(
@@ -2697,15 +2881,19 @@ export class RelayCore {
2697
2881
  endpoint,
2698
2882
  )
2699
2883
  } else if (params.kind === "session_start") {
2884
+ const fresh = own && summary !== "compact" && summary !== "resume"
2700
2885
  this.sql.all(
2701
2886
  `UPDATE endpoints SET hook_seen = COALESCE(?, hook_seen), hook_ended = NULL, stop_blocks = CASE WHEN ? THEN 0 ELSE stop_blocks END, pushes = CASE WHEN ? THEN 0 ELSE pushes END,
2702
- briefed = CASE WHEN ? THEN NULL ELSE briefed END, introduced = CASE WHEN ? THEN NULL ELSE introduced END, directive_holds = CASE WHEN ? THEN NULL ELSE directive_holds END WHERE id = ?`,
2887
+ briefed = CASE WHEN ? THEN NULL ELSE briefed END, introduced = CASE WHEN ? THEN NULL ELSE introduced END, directive_holds = CASE WHEN ? THEN NULL ELSE directive_holds END,
2888
+ title = CASE WHEN ? AND title = title_auto THEN NULL ELSE title END, title_auto = CASE WHEN ? THEN NULL ELSE title_auto END WHERE id = ?`,
2703
2889
  options.transient ? now : null,
2704
2890
  own ? 1 : 0,
2705
2891
  own ? 1 : 0,
2706
2892
  own ? 1 : 0,
2707
2893
  own ? 1 : 0,
2708
2894
  own ? 1 : 0,
2895
+ fresh ? 1 : 0,
2896
+ fresh ? 1 : 0,
2709
2897
  endpoint,
2710
2898
  )
2711
2899
  } else if (options.transient) {
@@ -4657,7 +4845,7 @@ export class RelayCore {
4657
4845
 
4658
4846
  /** the user parts an address of this core may carry */
4659
4847
  protected addressUsers(): string[] {
4660
- return [this.user, "me"]
4848
+ return [this.user, "me", "local"]
4661
4849
  }
4662
4850
 
4663
4851
  /** whether the row is this host's own, the only rows with a tree, groups, rules and caps here */
@@ -4671,7 +4859,7 @@ export class RelayCore {
4671
4859
  const slash = text.indexOf("/")
4672
4860
  const user = slash === -1 ? null : text.slice(0, slash)
4673
4861
  const handle = slash === -1 ? text : text.slice(slash + 1)
4674
- if (user !== null && user !== "me" && user !== this.user) throw unreachable(text)
4862
+ if (user !== null && user !== "me" && user !== "local" && user !== this.user) throw unreachable(text)
4675
4863
  const row = handle === "" ? this.byHandle(HUMAN_HANDLE) : validHandle(handle) ? this.byHandle(handle) : undefined
4676
4864
  if (!row) throw unreachable(text)
4677
4865
  return row
package/src/index.ts CHANGED
@@ -9,6 +9,7 @@ export {
9
9
  RelayCore,
10
10
  seqList,
11
11
  SYSTEM_HANDLE,
12
+ TITLE_CHARS,
12
13
  unreachable,
13
14
  type CoreOptions,
14
15
  type EndpointRow,
package/src/policy.ts CHANGED
@@ -36,6 +36,8 @@ export interface PolicyDefaults {
36
36
  /** `inherit`: a child of a capped parent reaches only what the parent reaches; a preset caps every placed child without grant caps */
37
37
  child_default?: ScopePreset | "inherit"
38
38
  verify_lineage?: "spike" | boolean
39
+ /** who may rename an agent besides the human at a terminal: `root` (default) only a root session itself, `self` any agent itself, spawned ones too */
40
+ rename?: "root" | "self"
39
41
  }
40
42
 
41
43
  export interface Policy {
@@ -164,7 +166,7 @@ export function parsePolicy(value: unknown, options: { judgeProviders?: Readonly
164
166
  }
165
167
  if (defaults !== undefined) {
166
168
  if (!isObject(defaults)) throw new RelayError("invalid_params", "defaults is an object")
167
- const { human_always, symmetric_presets, child_default, verify_lineage, ...extra } = defaults
169
+ const { human_always, symmetric_presets, child_default, verify_lineage, rename, ...extra } = defaults
168
170
  if (Object.keys(extra).length) throw new RelayError("invalid_params", `unknown defaults: ${Object.keys(extra).join(", ")}`)
169
171
  const parsed: PolicyDefaults = {}
170
172
  for (const [key, flag] of [
@@ -188,6 +190,10 @@ export function parsePolicy(value: unknown, options: { judgeProviders?: Readonly
188
190
  throw new RelayError("invalid_params", "defaults.verify_lineage true needs the peer-pid check, which this relay does not have yet: use spike or false")
189
191
  parsed.verify_lineage = verify_lineage
190
192
  }
193
+ if (rename !== undefined) {
194
+ if (rename !== "root" && rename !== "self") throw new RelayError("invalid_params", "defaults.rename is root or self")
195
+ parsed.rename = rename
196
+ }
191
197
  policy.defaults = parsed
192
198
  }
193
199
  if (judge !== undefined) policy.judge = parseJudgePolicy(judge, options.judgeProviders)
package/src/protocol.ts CHANGED
@@ -73,6 +73,8 @@ export const LIMITS = {
73
73
  grantTtlS: 7 * 86_400,
74
74
  groupIdleS: 86_400,
75
75
  policyLoadsKept: 8,
76
+ /** the prompts between two refreshes of a title a hook inferred */
77
+ titleRefreshTurns: 5,
76
78
  } as const
77
79
 
78
80
  export type ErrorCode =
@@ -90,6 +92,7 @@ export type ErrorCode =
90
92
  | "scope_refused"
91
93
  | "unavailable"
92
94
  | "judge_refused"
95
+ | "not_live"
93
96
 
94
97
  export class RelayError extends Error {
95
98
  constructor(
@@ -386,6 +389,8 @@ export interface SendParams {
386
389
  mime?: string
387
390
  /** on a request only: the recipient's stop is held until it replies */
388
391
  require_reply?: boolean
392
+ /** refuse `not_live`, queueing nothing, when an agent recipient has no live session */
393
+ live?: boolean
389
394
  }
390
395
 
391
396
  /** what ask and ask_wait answer: the reply; or pending, with `ended` once every recipient present at the send went offline without replying; or expired */
@@ -429,6 +434,8 @@ export interface SendResult {
429
434
  * offline; `state`: an agent recipient's {@link Reach} on this machine (an older broker leaves it out)
430
435
  */
431
436
  deliveries: { endpoint: string; address: string; seq: number; offline: boolean; queued?: boolean; last_seen: string | null; state?: Reach }[]
437
+ /** one line per agent recipient no live session holds: `queued: no live session holds <address>` */
438
+ warnings?: string[]
432
439
  duplicate?: boolean
433
440
  held?: JudgeHeld
434
441
  redacted?: JudgeRedacted