@scp-dev/core 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/core.ts CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  LIMITS,
14
14
  MESSAGE_KINDS,
15
15
  type MessageKind,
16
+ type MessageReceipts,
16
17
  type PeekLevel,
17
18
  type PeekResult,
18
19
  type PeekSeen,
@@ -42,7 +43,7 @@ import {
42
43
  type WhyResult,
43
44
  } from "./protocol"
44
45
  import { digestText, PEEK, peekLevel, readText } from "./peek"
45
- import { shortId, stripControl, wrapMessage } from "./render"
46
+ import { clock, shortId, stripControl, wrapMessage } from "./render"
46
47
  import {
47
48
  clipped,
48
49
  expandRules,
@@ -68,6 +69,8 @@ import { one, type Sql, type SqlValue } from "./sql"
68
69
  import { utf8Bytes } from "./utf8"
69
70
 
70
71
  export const HUMAN_HANDLE = "_human"
72
+ /** an agent title's length cap */
73
+ export const TITLE_CHARS = 64
71
74
  export const SYSTEM_HANDLE = "_relay"
72
75
  export const GONE_PREFIX = "_gone_"
73
76
 
@@ -125,7 +128,9 @@ const MIGRATION = [
125
128
  offered_until INTEGER,
126
129
  offered_hold TEXT,
127
130
  injected_at INTEGER,
128
- nudged_at INTEGER
131
+ nudged_at INTEGER,
132
+ deferred_at INTEGER,
133
+ deferred_ack INTEGER
129
134
  )`,
130
135
  `CREATE INDEX IF NOT EXISTS deliveries_inbox ON deliveries (endpoint, acked_at, seq)`,
131
136
  `CREATE INDEX IF NOT EXISTS deliveries_message ON deliveries (message_id)`,
@@ -237,12 +242,20 @@ export interface EndpointRow {
237
242
  /** JSON {@link ScopeCaps} */
238
243
  caps: string | null
239
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
240
251
  /** when a new spawn last took the endpoint over: mail and messages from before it were the earlier holder's */
241
252
  reset_at: number | null
242
253
  /** when a new spawn last took the endpoint over: with its id, names the holder another machine sees */
243
254
  spawned_at: number | null
244
255
  /** who may peek: null is off */
245
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
246
259
  }
247
260
 
248
261
  export interface MessageRow {
@@ -269,7 +282,7 @@ export interface CoreOptions {
269
282
  now?: () => number
270
283
  /** a synchronous hash for resume tokens (sha256 hex) */
271
284
  hash: (value: string) => string
272
- /** runs `fn` atomically: bun:sqlite's db.transaction, a Durable Object's transactionSync */
285
+ /** runs `fn` atomically: a local SQLite transaction, a Durable Object's transactionSync */
273
286
  transaction?: <T>(fn: () => T) => T
274
287
  /** count every sender but the relay itself, humans included, against LIMITS.userPerDay, and trust no client's terminal claim for a hop reset (default: agents and non-terminal humans count) */
275
288
  capEverySender?: boolean
@@ -439,6 +452,8 @@ export class RelayCore {
439
452
  private readonly capEverySender: boolean
440
453
  private readonly alive: (pid: number) => boolean
441
454
  private readonly hostMachine: string | null
455
+ private readonly namedMachine: string | null
456
+ private readonly hostname: string | null
442
457
  private policy: Policy | null = null
443
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` */
444
459
  private policyBinds = new Map<string, string>()
@@ -458,6 +473,14 @@ export class RelayCore {
458
473
  this.capEverySender = options.capEverySender === true
459
474
  this.alive = options.alive ?? (() => true)
460
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
461
484
  }
462
485
 
463
486
  migrate(): void {
@@ -493,6 +516,10 @@ export class RelayCore {
493
516
  ["directive_holds", "INTEGER"],
494
517
  ["harness_session", "TEXT"],
495
518
  ["harness_transcript", "TEXT"],
519
+ ["title_auto", "TEXT"],
520
+ ["title_turn", "INTEGER"],
521
+ ["tldr_turn", "INTEGER"],
522
+ ["named", "INTEGER"],
496
523
  ] as const) {
497
524
  if (!endpointColumns.includes(column)) this.sql.all(`ALTER TABLE endpoints ADD COLUMN ${column} ${type}`)
498
525
  }
@@ -521,6 +548,8 @@ export class RelayCore {
521
548
  if (!deliveryColumns.includes("injected_at")) this.sql.all(`ALTER TABLE deliveries ADD COLUMN injected_at INTEGER`)
522
549
  if (!deliveryColumns.includes("nudged_at")) this.sql.all(`ALTER TABLE deliveries ADD COLUMN nudged_at INTEGER`)
523
550
  if (!deliveryColumns.includes("restated_at")) this.sql.all(`ALTER TABLE deliveries ADD COLUMN restated_at INTEGER`)
551
+ if (!deliveryColumns.includes("deferred_at")) this.sql.all(`ALTER TABLE deliveries ADD COLUMN deferred_at INTEGER`)
552
+ if (!deliveryColumns.includes("deferred_ack")) this.sql.all(`ALTER TABLE deliveries ADD COLUMN deferred_ack INTEGER`)
524
553
  const activityColumns = this.sql.all<{ name: string }>(`PRAGMA table_info(activity)`).map((c) => c.name)
525
554
  if (!activityColumns.includes("agent")) this.sql.all(`ALTER TABLE activity ADD COLUMN agent TEXT`)
526
555
  if (!this.byHandle(SYSTEM_HANDLE)) {
@@ -653,7 +682,7 @@ export class RelayCore {
653
682
  model: cleanText(claim.model, "model", 128),
654
683
  cwd: cleanText(claim.cwd, "cwd", 1024),
655
684
  parent: cleanText(claim.parent, "parent", 128),
656
- labels: JSON.stringify(cleanLabels(claim.labels)),
685
+ labels: JSON.stringify(this.machineLabel(cleanLabels(claim.labels))),
657
686
  session: cleanText(claim.session, "session", 128),
658
687
  process: cleanText(claim.process, "process", 128),
659
688
  }
@@ -768,6 +797,7 @@ export class RelayCore {
768
797
  scope: ClaimScope = { handle: wanted, lineage: [], grant: null, rules: {}, groups: [], cwd: fields.cwd },
769
798
  ): { endpoint: EndpointView; token?: string; created?: true } {
770
799
  const now = this.now()
800
+ this.endExited(now)
771
801
  const bare = transient && fields.session !== null && BARE_PID_KEY.test(fields.session)
772
802
  let row = fields.session
773
803
  ? one<EndpointRow>(
@@ -793,7 +823,14 @@ export class RelayCore {
793
823
  } else if (row && !transient && !sameProcess(row, fields.process)) {
794
824
  row = undefined
795
825
  session = null
796
- } 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
+ ) {
797
834
  const hooks = row
798
835
  let settled = false
799
836
  for (let n = 1; n < (scope.exact ? 2 : 1000) && !settled; n++) {
@@ -1292,6 +1329,7 @@ export class RelayCore {
1292
1329
  } = {},
1293
1330
  ): EndpointView[] {
1294
1331
  const now = this.now()
1332
+ this.endExited(now)
1295
1333
  this.settleOffline(now)
1296
1334
  const cutoff = now - LIMITS.listOfflineWithinS * 1000
1297
1335
  const viewer = filters.viewer === undefined ? undefined : this.mustEndpoint(filters.viewer)
@@ -1359,10 +1397,12 @@ export class RelayCore {
1359
1397
  const text = oneLine(note, "note", LIMITS.noteChars)
1360
1398
  if (text && findSecret(text)) throw this.refuse(endpoint, "secret_refused", "the status note looks like it carries a credential")
1361
1399
  this.sql.all(
1362
- `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 = ?`,
1363
1402
  state ?? null,
1364
1403
  text,
1365
1404
  this.now(),
1405
+ text === null ? 0 : 1,
1366
1406
  endpoint,
1367
1407
  )
1368
1408
  }
@@ -1373,11 +1413,126 @@ export class RelayCore {
1373
1413
  if (row.kind !== "agent") throw new RelayError("invalid_params", "only an agent endpoint has a title")
1374
1414
  const title = oneLine(value, "title", 64) || null
1375
1415
  if (title && findSecret(title)) throw this.refuse(endpoint, "secret_refused", "the title looks like it carries a credential")
1376
- 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)
1377
1417
  this.audit("title", endpoint, null, { title })
1378
1418
  return this.whoami(endpoint)
1379
1419
  }
1380
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
+
1381
1536
  // messages
1382
1537
 
1383
1538
  /**
@@ -1438,6 +1593,17 @@ export class RelayCore {
1438
1593
  resolved.push(row)
1439
1594
  }
1440
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)}`)
1441
1607
  const pending = [...unjoined.values()].map((grant) => ({ key: pendingKey(grant.token_hash), address: `${this.user}/${grant.name}` }))
1442
1608
  const reached = recipients.length + pending.length
1443
1609
  if (reached > LIMITS.fanOutTotal) throw new RelayError("too_large", `the send reaches ${reached} endpoints, more than ${LIMITS.fanOutTotal}`)
@@ -1667,7 +1833,7 @@ export class RelayCore {
1667
1833
  deliveries.push({ endpoint: key, address, seq, offline: false, queued: true, last_seen: null, state: "starting" })
1668
1834
  }
1669
1835
  for (const name of fanOut) this.sql.all(`INSERT INTO tree_events (tree, at) VALUES (?, ?)`, `group:${name}`, now)
1670
- const sent: SendResult = { id, thread, hop, deliveries }
1836
+ const sent: SendResult = { id, thread, hop, deliveries, ...(warnings.length ? { warnings } : {}) }
1671
1837
  if (clientId) {
1672
1838
  this.sql.all(
1673
1839
  `INSERT OR REPLACE INTO idempotency (from_ep, client_id, payload_hash, result, created_at) VALUES (?, ?, ?, ?, ?)`,
@@ -1749,21 +1915,29 @@ export class RelayCore {
1749
1915
  }
1750
1916
  }
1751
1917
 
1752
- /** the oldest unacked deliveries after the cursor; reading counts as receiving for the causal hop */
1918
+ /**
1919
+ * the oldest unacked deliveries after the cursor, and those a background hook showed in the last ${LIMITS.deferredShowS} s whose showing has
1920
+ * not settled ({@link injected}), with `shown_at`: the harness may hand the agent that hook's text only after this call; reading counts as
1921
+ * receiving for the causal hop
1922
+ */
1753
1923
  inbox(endpoint: string, options: { after?: number | undefined; limit?: number | undefined } = {}): Envelope[] {
1754
1924
  const now = this.now()
1755
1925
  const limit = Math.min(Math.max(1, Math.floor(options.limit ?? LIMITS.inboxPage)), LIMITS.inboxPage)
1756
- const rows = this.sql.all<MessageRow & { seq: number }>(
1757
- `SELECT m.*, d.seq AS seq FROM deliveries d JOIN messages m ON m.id = d.message_id
1758
- WHERE d.endpoint = ? AND d.acked_at IS NULL AND d.expires_at > ? AND d.seq > ?
1926
+ const rows = this.sql.all<MessageRow & { seq: number; deferred_at: number | null; acked_at: number | null }>(
1927
+ `SELECT m.*, d.seq AS seq, d.deferred_at AS deferred_at, d.acked_at AS acked_at FROM deliveries d JOIN messages m ON m.id = d.message_id
1928
+ WHERE d.endpoint = ? AND (d.acked_at IS NULL OR d.deferred_at > ?) AND d.expires_at > ? AND d.seq > ?
1759
1929
  ORDER BY d.seq LIMIT ?`,
1760
1930
  endpoint,
1931
+ now - LIMITS.deferredShowS * 1000,
1761
1932
  now,
1762
1933
  Math.max(0, Math.floor(options.after ?? 0)),
1763
1934
  limit,
1764
1935
  )
1765
1936
  this.received(endpoint, rows, now)
1766
- return rows.map((row) => this.envelope(row, row.seq))
1937
+ return rows.map((row) => {
1938
+ const envelope = this.envelope(row, row.seq)
1939
+ return row.acked_at !== null && row.deferred_at !== null ? { ...envelope, shown_at: new Date(row.deferred_at).toISOString() } : envelope
1940
+ })
1767
1941
  }
1768
1942
 
1769
1943
  /**
@@ -1806,10 +1980,11 @@ export class RelayCore {
1806
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
1807
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
1808
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.
1809
1984
  */
1810
1985
  inject(
1811
1986
  endpoint: string,
1812
- 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 } = {},
1813
1988
  ): {
1814
1989
  messages: Envelope[]
1815
1990
  offered: number[]
@@ -1899,7 +2074,7 @@ export class RelayCore {
1899
2074
  now,
1900
2075
  LIMITS.inboxPage,
1901
2076
  )
1902
- 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))
1903
2078
  const hold = this.hold(offered, now)
1904
2079
  this.received(endpoint, shown, now)
1905
2080
  return { messages: shown.map((row) => this.envelope(row, row.seq)), offered, hold, more: waiting - shown.length, unread, block: false, ...brief, ...intro }
@@ -1984,13 +2159,15 @@ export class RelayCore {
1984
2159
  * messages no hook has shown or holds follow theirs ({@link pushOthers}), `others_shown` of them, held with them; `others` counts the other
1985
2160
  * unread ones left. A live worker's messages wait ${LIMITS.pushGapS} s after a push showed one of its own ({@link pushed}), and with no
1986
2161
  * push left since the last prompt nothing is shown. `prompted` says a prompt came since inject handed out the `prompts` mark. A hook of
1987
- * another process than the one holding the endpoint gets nothing.
2162
+ * another process than the one holding the endpoint gets nothing. `peek`: the same answer, nothing held (`hold` null) or marked received.
1988
2163
  */
1989
- stopWait(endpoint: string, options: { transient?: boolean; process?: string; prompts?: number } = {}): StopWaitResult {
2164
+ stopWait(endpoint: string, options: { transient?: boolean; process?: string; prompts?: number; peek?: boolean } = {}): StopWaitResult {
1990
2165
  const now = this.now()
1991
2166
  const me = this.mustEndpoint(endpoint)
1992
2167
  if (!this.holds(me, options)) return { messages: [], offered: [], hold: null, more: 0, workers: [], starting: 0, others: 0, pushes_left: 0 }
1993
2168
  const { workers, starting, paced } = this.liveWorkers(me, now)
2169
+ const live = this.liveWorkers(me, now, true)
2170
+ const background = [...live.workers.map((row) => this.address(row)), ...live.unjoined.filter(Boolean).map((name) => `me/${name}`)]
1994
2171
  const left = this.pushesLeft(me)
1995
2172
  const { where, params } = this.workerMessages(me, now, true, paced)
1996
2173
  const rows = left ? this.sql.all<MessageRow & { seq: number }>(`SELECT m.*, d.seq AS seq ${where} ORDER BY d.seq LIMIT ?`, ...params, LIMITS.inboxPage) : []
@@ -2004,8 +2181,8 @@ export class RelayCore {
2004
2181
  const { shown, offered } = this.budget(rows, LIMITS.pushBytes)
2005
2182
  const used = shown.reduce((sum, row) => sum + utf8Bytes(wrapMessage(this.envelope(row, row.seq))), 0)
2006
2183
  const others = rows.length ? this.pushOthers(me, now, LIMITS.pushBytes - used) : []
2007
- const hold = this.hold([...offered, ...others.map((row) => row.seq)], now)
2008
- this.received(endpoint, [...shown, ...others], now)
2184
+ const hold = options.peek ? null : this.hold([...offered, ...others.map((row) => row.seq)], now)
2185
+ if (!options.peek) this.received(endpoint, [...shown, ...others], now)
2009
2186
  return {
2010
2187
  messages: [...shown, ...others].map((row) => this.envelope(row, row.seq)),
2011
2188
  offered: [...offered, ...others.map((row) => row.seq)],
@@ -2013,6 +2190,7 @@ export class RelayCore {
2013
2190
  more: waiting - shown.length,
2014
2191
  workers: workers.map((row) => this.address(row)),
2015
2192
  starting,
2193
+ ...(background.length ? { background } : {}),
2016
2194
  others: unread - this.workerUnread(me, now, false) - others.length,
2017
2195
  ...(others.length ? { others_shown: others.length } : {}),
2018
2196
  pushes_left: left,
@@ -2080,9 +2258,29 @@ export class RelayCore {
2080
2258
  prompted(endpoint: string, options: { transient?: boolean; process?: string } = {}): boolean {
2081
2259
  if (!this.holds(this.mustEndpoint(endpoint), options)) return false
2082
2260
  this.sql.all(`UPDATE endpoints SET prompts = COALESCE(prompts, 0) + 1, pushes = 0 WHERE id = ?`, endpoint)
2261
+ this.settle(endpoint)
2083
2262
  return true
2084
2263
  }
2085
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
+
2275
+ /** the background hooks' showings ({@link injected}) older than ${LIMITS.deferredSettleMs} ms reached the agent: inbox no longer lists them */
2276
+ private settle(endpoint: string): void {
2277
+ this.sql.all(
2278
+ `UPDATE deliveries SET deferred_at = NULL WHERE endpoint = ? AND deferred_at IS NOT NULL AND deferred_at <= ?`,
2279
+ endpoint,
2280
+ this.now() - LIMITS.deferredSettleMs,
2281
+ )
2282
+ }
2283
+
2086
2284
  /**
2087
2285
  * The endpoint's live spawned workers: its unrevoked spawn grants (minted by `swarm spawn` without --no-push, never by spawn-env) from its
2088
2286
  * current spawn that are either unspent and unexpired (`starting`: the harness has not joined yet) or spent by an endpoint whose status is not
@@ -2091,22 +2289,22 @@ export class RelayCore {
2091
2289
  * spender done and revokes an unspent grant, so a worker goes when its harness exits, else when its presence lapses or its grant expires; an
2092
2290
  * unspent grant whose `swarm spawn` process is gone (killed before it could report the exit) is revoked here.
2093
2291
  */
2094
- private liveWorkers(me: EndpointRow, now: number): { workers: EndpointRow[]; starting: number; paced: string[] } {
2095
- const grants = this.sql.all<Pick<GrantRow, "token_hash" | "used_by" | "bound_process" | "pushed_at" | "handback">>(
2096
- `SELECT token_hash, used_by, bound_process, pushed_at, handback FROM grants WHERE issuer = ? AND spawn = 1 AND no_push IS NULL AND revoked_at IS NULL AND created_at >= ? AND (used_by IS NOT NULL OR expires_at > ?) ORDER BY created_at`,
2292
+ private liveWorkers(me: EndpointRow, now: number, noPush = false): { workers: EndpointRow[]; starting: number; paced: string[]; unjoined: string[] } {
2293
+ const grants = this.sql.all<Pick<GrantRow, "token_hash" | "used_by" | "bound_process" | "pushed_at" | "handback" | "name">>(
2294
+ `SELECT token_hash, used_by, bound_process, pushed_at, handback, name FROM grants WHERE issuer = ? AND spawn = 1 AND no_push IS ${noPush ? "NOT NULL" : "NULL"} AND revoked_at IS NULL AND created_at >= ? AND (used_by IS NOT NULL OR expires_at > ?) ORDER BY created_at`,
2097
2295
  me.id,
2098
2296
  me.spawned_at ?? 0,
2099
2297
  now,
2100
2298
  )
2101
2299
  const workers = new Map<string, EndpointRow>()
2102
2300
  const paced = new Set<string>()
2103
- let starting = 0
2301
+ const unjoined: string[] = []
2104
2302
  for (const grant of grants) {
2105
2303
  const pid = Number(/^pid:(\d+):/.exec(grant.bound_process ?? "")?.[1])
2106
2304
  if (grant.used_by === null) {
2107
2305
  if (pid && !this.alive(pid))
2108
2306
  this.sql.all(`UPDATE grants SET revoked_at = ? WHERE token_hash = ? AND revoked_at IS NULL AND used_by IS NULL`, now, grant.token_hash)
2109
- else starting++
2307
+ else unjoined.push(grant.name ?? "")
2110
2308
  continue
2111
2309
  }
2112
2310
  const row = this.byId(grant.used_by)
@@ -2114,7 +2312,7 @@ export class RelayCore {
2114
2312
  workers.set(row.id, row)
2115
2313
  if (grant.pushed_at !== null && grant.pushed_at > now - LIMITS.pushGapS * 1000) paced.add(row.id)
2116
2314
  }
2117
- return { workers: [...workers.values()], starting, paced: [...paced] }
2315
+ return { workers: [...workers.values()], starting: unjoined.length, paced: [...paced], unjoined }
2118
2316
  }
2119
2317
 
2120
2318
  /**
@@ -2216,9 +2414,11 @@ export class RelayCore {
2216
2414
 
2217
2415
  /**
2218
2416
  * a hook or channel showed these deliveries, offered to it under `hold`: no hook offers them again; those of them it marked that are `read`
2219
- * (printed in full) and delivered to `endpoint` are acked; how many it marked
2417
+ * (printed in full) and delivered to `endpoint` are acked, and with `deferred` (a background hook, whose text the harness hands the agent
2418
+ * later) still listed by {@link inbox} until that settles ({@link settle}) and marked delivered rather than read ({@link receipts}) until
2419
+ * the agent acks it itself; how many it marked
2220
2420
  */
2221
- injected(seqs: unknown, hold: unknown, options: { endpoint?: string; read?: unknown } = {}): number {
2421
+ injected(seqs: unknown, hold: unknown, options: { endpoint?: string; read?: unknown; deferred?: boolean } = {}): number {
2222
2422
  const list = seqList(seqs)
2223
2423
  if (typeof hold !== "string") return 0
2224
2424
  const read = new Set(options.read === undefined || options.endpoint === undefined ? [] : seqList(options.read))
@@ -2233,13 +2433,17 @@ export class RelayCore {
2233
2433
  hold,
2234
2434
  ).length
2235
2435
  marked += hit
2236
- if (hit && read.has(seq))
2436
+ if (hit && read.has(seq)) {
2437
+ const deferred = options.deferred === true ? now : null
2237
2438
  acked += this.sql.all(
2238
- `UPDATE deliveries SET acked_at = ? WHERE seq = ? AND endpoint = ? AND acked_at IS NULL RETURNING seq`,
2439
+ `UPDATE deliveries SET acked_at = ?, deferred_at = ?, deferred_ack = ? WHERE seq = ? AND endpoint = ? AND acked_at IS NULL RETURNING seq`,
2239
2440
  now,
2441
+ deferred,
2442
+ deferred && 1,
2240
2443
  seq,
2241
2444
  options.endpoint!,
2242
2445
  ).length
2446
+ }
2243
2447
  }
2244
2448
  if (acked) this.audit("ack", options.endpoint!, null, { count: acked, via: "inject" })
2245
2449
  return marked
@@ -2305,6 +2509,51 @@ export class RelayCore {
2305
2509
  return envelope(seq ?? rows[0])
2306
2510
  }
2307
2511
 
2512
+ /**
2513
+ * a message `endpoint` sent, by its id, a unique suffix of at least ${MESSAGE_SUFFIX_MIN} characters such as its {@link shortId}, or `last`
2514
+ * (the newest it sent), with when each recipient read it (acked), or when a background hook delivered it (acked by that showing, the agent
2515
+ * gets it with its next request, `delivered_at` and no `read_at`), and when a hook or channel showed it; a recipient still waiting for a
2516
+ * `swarm spawn` to join is named by the spawn's address. not_found when it sent no such message; invalid_params when a suffix names more
2517
+ * than one
2518
+ */
2519
+ receipts(endpoint: string, id: unknown): MessageReceipts {
2520
+ if (typeof id !== "string" || !id.trim()) throw new RelayError("invalid_params", "id is a message id")
2521
+ type Row = { id: string; thread: string }
2522
+ const sent = `SELECT id, thread FROM messages WHERE from_ep = ?`
2523
+ let rows =
2524
+ id === "last" ? this.sql.all<Row>(`${sent} ORDER BY created_at DESC, id DESC LIMIT 1`, endpoint) : this.sql.all<Row>(`${sent} AND id = ?`, endpoint, id)
2525
+ if (!rows.length && MESSAGE_SUFFIX.test(id))
2526
+ rows = this.sql.all<Row>(`${sent} AND substr(id, -?) = ? ORDER BY created_at DESC LIMIT 6`, endpoint, id.length, id)
2527
+ if (rows.length > 1) {
2528
+ const named = rows.slice(0, 5).map((row) => `${shortId(row.id)} (${row.id})`)
2529
+ throw new RelayError(
2530
+ "invalid_params",
2531
+ `${id} matches more than one message you sent, newest first: ${named.join(", ")}${rows.length > 5 ? ", ..." : ""}; give more of the id`,
2532
+ )
2533
+ }
2534
+ const message = rows[0]
2535
+ if (!message) throw new RelayError("not_found", `no message ${id} that you sent`)
2536
+ const deliveries = this.sql.all<{ endpoint: string; acked_at: number | null; injected_at: number | null; deferred_ack: number | null }>(
2537
+ `SELECT endpoint, acked_at, injected_at, deferred_ack FROM deliveries WHERE message_id = ? ORDER BY seq`,
2538
+ message.id,
2539
+ )
2540
+ const address = (key: string) => {
2541
+ const pending = key.startsWith(pendingKey(""))
2542
+ ? one<{ name: string | null }>(this.sql, `SELECT name FROM grants WHERE token_hash = ?`, key.slice(pendingKey("").length))
2543
+ : undefined
2544
+ return pending?.name ? `${this.user}/${pending.name}` : this.addressOf(key)
2545
+ }
2546
+ return {
2547
+ id: message.id,
2548
+ thread: message.thread,
2549
+ recipients: deliveries.map((d) => ({
2550
+ address: address(d.endpoint),
2551
+ ...(d.deferred_ack ? { read_at: null, delivered_at: iso(d.acked_at) } : { read_at: iso(d.acked_at) }),
2552
+ shown_at: iso(d.injected_at),
2553
+ })),
2554
+ }
2555
+ }
2556
+
2308
2557
  /**
2309
2558
  * another endpoint's message in a thread `endpoint` sent or got a message in, by its id or a unique suffix of at least ${MESSAGE_SUFFIX_MIN}
2310
2559
  * characters: enough to reply to it, never its body. Null when there is none; invalid_params when the suffix names more than one
@@ -2399,6 +2648,7 @@ export class RelayCore {
2399
2648
  )
2400
2649
  }
2401
2650
 
2651
+ /** marks these deliveries to `endpoint` read, one a background hook's showing acked ({@link injected}) read now too; how many were unread */
2402
2652
  ack(endpoint: string, seqs: unknown): number {
2403
2653
  let acked = 0
2404
2654
  for (const seq of seqList(seqs)) {
@@ -2408,6 +2658,13 @@ export class RelayCore {
2408
2658
  seq,
2409
2659
  endpoint,
2410
2660
  ).length
2661
+ this.sql.all(
2662
+ `UPDATE deliveries SET acked_at = ?, deferred_ack = NULL WHERE seq = ? AND endpoint = ? AND deferred_ack IS NOT NULL`,
2663
+ this.now(),
2664
+ seq,
2665
+ endpoint,
2666
+ )
2667
+ this.sql.all(`UPDATE deliveries SET deferred_at = NULL WHERE seq = ? AND endpoint = ? AND deferred_at IS NOT NULL`, seq, endpoint)
2411
2668
  }
2412
2669
  if (acked) this.audit("ack", endpoint, null, { count: acked })
2413
2670
  return acked
@@ -2527,6 +2784,7 @@ export class RelayCore {
2527
2784
  this.sql.all(`DELETE FROM idempotency WHERE created_at <= ?`, now - LIMITS.idempotencyS * 1000)
2528
2785
  this.sql.all(`DELETE FROM pair_events WHERE at <= ?`, now - 3600 * 1000)
2529
2786
  this.sql.all(`DELETE FROM blocks WHERE until <= ?`, now)
2787
+ this.endExited(now)
2530
2788
  this.settleOffline(now)
2531
2789
  this.sql.all(
2532
2790
  `DELETE FROM endpoints WHERE kind = 'agent' AND connections = 0 AND COALESCE(last_seen, created_at) <= ?
@@ -2553,6 +2811,17 @@ export class RelayCore {
2553
2811
  }
2554
2812
  }
2555
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
+
2556
2825
  /** an agent endpoint left offline drops a working or waiting status, which no one is left to keep current */
2557
2826
  private settleOffline(now: number): void {
2558
2827
  for (const row of this.sql.all<EndpointRow>(
@@ -2595,6 +2864,7 @@ export class RelayCore {
2595
2864
  )!
2596
2865
  this.sql.all(`DELETE FROM activity WHERE id <= ?`, row.id - LIMITS.activityKeep)
2597
2866
  const own = this.holds(this.mustEndpoint(endpoint), options)
2867
+ if (own && !agent && params.kind === "tool") this.settle(endpoint)
2598
2868
  if (own && !agent && typeof params.session_id === "string" && HARNESS_SESSION.test(params.session_id)) {
2599
2869
  this.sql.all(
2600
2870
  `UPDATE endpoints SET harness_session = ?, harness_transcript = ? WHERE id = ?`,
@@ -2611,15 +2881,19 @@ export class RelayCore {
2611
2881
  endpoint,
2612
2882
  )
2613
2883
  } else if (params.kind === "session_start") {
2884
+ const fresh = own && summary !== "compact" && summary !== "resume"
2614
2885
  this.sql.all(
2615
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,
2616
- 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 = ?`,
2617
2889
  options.transient ? now : null,
2618
2890
  own ? 1 : 0,
2619
2891
  own ? 1 : 0,
2620
2892
  own ? 1 : 0,
2621
2893
  own ? 1 : 0,
2622
2894
  own ? 1 : 0,
2895
+ fresh ? 1 : 0,
2896
+ fresh ? 1 : 0,
2623
2897
  endpoint,
2624
2898
  )
2625
2899
  } else if (options.transient) {
@@ -2997,6 +3271,20 @@ export class RelayCore {
2997
3271
  })
2998
3272
  }
2999
3273
 
3274
+ /** the grant issuer's messages to the endpoint that spent it, sent since the grant, that it read, all but those among `unapplied`, oldest first */
3275
+ private readSteers(grant: GrantRow, spender: EndpointRow, unapplied: string[]): { id: string; acked_at: number; deferred_ack: number | null }[] {
3276
+ return this.sql.all<{ id: string; acked_at: number; deferred_ack: number | null }>(
3277
+ `SELECT m.id, d.acked_at, d.deferred_ack FROM deliveries d JOIN messages m ON m.id = d.message_id
3278
+ WHERE d.endpoint = ? AND m.from_ep = ? AND m.created_at >= ? AND d.acked_at IS NOT NULL
3279
+ ${unapplied.length ? `AND m.id NOT IN (${unapplied.map(() => "?").join(", ")})` : ""} ORDER BY d.seq LIMIT ?`,
3280
+ spender.id,
3281
+ grant.issuer,
3282
+ grant.created_at,
3283
+ ...unapplied,
3284
+ LIMITS.inboxPage,
3285
+ )
3286
+ }
3287
+
3000
3288
  /**
3001
3289
  * a message needs both ends: for each `ep:` selector `widen` added on `target`, the other end's side is widened to admit `target` too when
3002
3290
  * `me` has authority over it, audited as its own widening; when that side still refuses, the change names it and the command that would fix it.
@@ -3370,8 +3658,9 @@ export class RelayCore {
3370
3658
  /**
3371
3659
  * a spawn's handback to its spawner `me`, the grant's issuer, from the worker (the relay when it never joined): past scopes, pair limits and
3372
3660
  * the judge, since the spawner minted the grant and is the recipient. One per grant (null once sent), in the thread the two last wrote in,
3373
- * else one derived from the grant, naming by short id the spawner's messages the worker left unread; its hop follows the worker's causal
3374
- * hop, capped, and a full thread drops it (null). A worker that never joined or exited non-zero gets its `stderr` last, labelled, its
3661
+ * else one derived from the grant, naming by short id the spawner's messages the worker read, with when (one a background hook showed only
3662
+ * delivered, unless the worker then exited 0: its model had the next request), and those it left unread; its hop follows the worker's
3663
+ * causal hop, capped, and a full thread drops it (null). A worker that never joined or exited non-zero gets its `stderr` last, labelled, its
3375
3664
  * output cut from the front so both stay within ${LIMITS.handbackBytes} bytes
3376
3665
  */
3377
3666
  private handback(
@@ -3435,7 +3724,13 @@ export class RelayCore {
3435
3724
  ]
3436
3725
  if (spender) {
3437
3726
  const unapplied = this.steersOf(grant, spender, now, taken)
3438
- if (unapplied.length) body.push(this.unappliedLines(spender, grant, unapplied, now, exits).join("\n"))
3727
+ const steers = [
3728
+ ...this.readSteers(grant, spender, taken).map(
3729
+ (row) => `your steer ${shortId(row.id)} was ${row.deferred_ack && code !== 0 ? "delivered" : "read"} at ${clock(row.acked_at)}`,
3730
+ ),
3731
+ ...(unapplied.length ? this.unappliedLines(spender, grant, unapplied, now, exits) : []),
3732
+ ]
3733
+ if (steers.length) body.push(steers.join("\n"))
3439
3734
  }
3440
3735
  const causal = spender && spender.last_received_at !== null && spender.last_received_at >= now - LIMITS.causalWindowS * 1000
3441
3736
  const hop = causal ? Math.min((spender.last_received_hop ?? 0) + 1, LIMITS.hopMax) : 0
@@ -4550,7 +4845,7 @@ export class RelayCore {
4550
4845
 
4551
4846
  /** the user parts an address of this core may carry */
4552
4847
  protected addressUsers(): string[] {
4553
- return [this.user, "me"]
4848
+ return [this.user, "me", "local"]
4554
4849
  }
4555
4850
 
4556
4851
  /** whether the row is this host's own, the only rows with a tree, groups, rules and caps here */
@@ -4564,7 +4859,7 @@ export class RelayCore {
4564
4859
  const slash = text.indexOf("/")
4565
4860
  const user = slash === -1 ? null : text.slice(0, slash)
4566
4861
  const handle = slash === -1 ? text : text.slice(slash + 1)
4567
- 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)
4568
4863
  const row = handle === "" ? this.byHandle(HUMAN_HANDLE) : validHandle(handle) ? this.byHandle(handle) : undefined
4569
4864
  if (!row) throw unreachable(text)
4570
4865
  return row