@scp-dev/core 0.1.1 → 0.1.3

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