@scp-dev/core 0.1.5 → 0.1.6

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
@@ -1109,6 +1109,10 @@ interface EndpointRow {
1109
1109
  linked: number | null;
1110
1110
  /** how the session that holds the endpoint wakes it: a {@link Wake} other than none, or null */
1111
1111
  wake: string | null;
1112
+ /** the live connections that set the wake */
1113
+ wakers: number;
1114
+ /** when the last hook connection that set the wake closed */
1115
+ waker_left: number | null;
1112
1116
  }
1113
1117
  interface MessageRow {
1114
1118
  id: string;
@@ -1171,6 +1175,8 @@ declare class RelayCore {
1171
1175
  constructor(sql: Sql, options: CoreOptions);
1172
1176
  /** a `machine` label naming this host by its hostname, as clients default it, names it by its machine name when one is set */
1173
1177
  private machineLabel;
1178
+ /** this host's own agent rows labelled with another machine (its name before a rename, or its hostname) take its machine name */
1179
+ relabelMachine(): number;
1174
1180
  migrate(): void;
1175
1181
  /** every stored list that is exactly `tree:self`, the tree preset's old list, narrows once to the preset's list */
1176
1182
  private migrateTreePreset;
@@ -1256,6 +1262,8 @@ declare class RelayCore {
1256
1262
  * whether a claim placed under `parent` restarts `row`: it lands where `row` sits (under `row`'s parent, or a root with no placement), or on
1257
1263
  * `row` itself from `row`'s own process; never under `row` or elsewhere in its tree
1258
1264
  */
1265
+ /** a local row held only by connections whose harness process exited, as a shim that outlived its harness leaves it */
1266
+ private outlived;
1259
1267
  private restarts;
1260
1268
  /**
1261
1269
  * 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
@@ -1344,6 +1352,13 @@ declare class RelayCore {
1344
1352
  transient?: boolean;
1345
1353
  process?: string;
1346
1354
  }): boolean;
1355
+ /**
1356
+ * a connection that set the endpoint's wake starts (`live`) or stops counting as its waker. A hook's (`grace`) still counts for
1357
+ * LIMITS.restartGraceS after it closes, while its harness starts the next one
1358
+ */
1359
+ waker(endpoint: string, live: boolean, options?: {
1360
+ grace?: boolean;
1361
+ }): void;
1347
1362
  /**
1348
1363
  * a title for the caller's in-process subagent `id` from the description its parent gave it; the subagent's endpoint id when it changed one.
1349
1364
  * It fills only a title nobody set, as an inferred one, and waits for the subagent's claim when it has no endpoint yet
package/dist/index.js CHANGED
@@ -1306,6 +1306,19 @@ var RelayCore = class {
1306
1306
  if (this.namedMachine !== null && labels.machine !== void 0 && labels.machine === this.hostname) labels.machine = this.namedMachine;
1307
1307
  return labels;
1308
1308
  }
1309
+ /** this host's own agent rows labelled with another machine (its name before a rename, or its hostname) take its machine name */
1310
+ relabelMachine() {
1311
+ const machine = this.namedMachine;
1312
+ if (machine === null) return 0;
1313
+ let changed = 0;
1314
+ for (const row of this.sql.all(`SELECT * FROM endpoints WHERE kind = 'agent' AND labels LIKE '%"machine"%'`)) {
1315
+ const labels = json(row.labels, {});
1316
+ if (labels.machine === void 0 || labels.machine === machine || !this.localRow(row)) continue;
1317
+ this.sql.all(`UPDATE endpoints SET labels = ? WHERE id = ?`, JSON.stringify({ ...labels, machine }), row.id);
1318
+ changed++;
1319
+ }
1320
+ return changed;
1321
+ }
1309
1322
  migrate() {
1310
1323
  for (const statement of MIGRATION) this.sql.all(statement);
1311
1324
  const columns = this.sql.all(`PRAGMA table_info(idempotency)`).map((c) => c.name);
@@ -1344,7 +1357,9 @@ var RelayCore = class {
1344
1357
  ["tldr_turn", "INTEGER"],
1345
1358
  ["named", "INTEGER"],
1346
1359
  ["linked", "INTEGER"],
1347
- ["wake", "TEXT"]
1360
+ ["wake", "TEXT"],
1361
+ ["wakers", "INTEGER NOT NULL DEFAULT 0"],
1362
+ ["waker_left", "INTEGER"]
1348
1363
  ]) {
1349
1364
  if (!endpointColumns.includes(column)) this.sql.all(`ALTER TABLE endpoints ADD COLUMN ${column} ${type}`);
1350
1365
  }
@@ -1621,7 +1636,7 @@ var RelayCore = class {
1621
1636
  if (row && presenceOf(row, now) === "offline") {
1622
1637
  this.sql.all(`UPDATE endpoints SET session = NULL WHERE id = ?`, row.id);
1623
1638
  row = void 0;
1624
- } else if (row && !transient && !sameProcess(row, fields.process)) {
1639
+ } else if (row && !transient && !sameProcess(row, fields.process) && !(row.handle === wanted && reclaims(row, fields.process))) {
1625
1640
  row = void 0;
1626
1641
  session = null;
1627
1642
  } else if (row && !transient && row.token_hash === null && row.named === null && row.handle !== wanted && !row.handle.startsWith(`${wanted.slice(0, 58)}-`)) {
@@ -1663,7 +1678,7 @@ var RelayCore = class {
1663
1678
  this.sql.all(`INSERT INTO endpoints (id, handle, kind, harness, labels, created_at) VALUES (?, ?, 'agent', ?, '{}', ?)`, id, handle, harness, now);
1664
1679
  row = this.byId(id);
1665
1680
  created = true;
1666
- } else if (held.kind === "agent" && held.connections === 0 && presenceOf(held, now) === "offline" && harnessFits(held.harness, harness) && held.id !== scope.subagent?.root && !(scope.subagent && held.session?.startsWith("sub:") && held.session !== fields.session)) {
1681
+ } else if (held.kind === "agent" && (held.connections === 0 && presenceOf(held, now) === "offline" || !transient && this.outlived(held)) && harnessFits(held.harness, harness) && held.id !== scope.subagent?.root && !(scope.subagent && held.session?.startsWith("sub:") && held.session !== fields.session)) {
1667
1682
  own = transient && held.process !== null && fields.process !== null && sameHarness(held.process, fields.process);
1668
1683
  const ended = held.hook_ended !== null || (held.last_seen ?? held.created_at) < now - LIMITS.restartGraceS * 1e3;
1669
1684
  if ((!transient || own || ended) && (this.restarts(held, this.placement(scope, now).parent, fields.process) || this.redeems(held, scope, { harness, ...fields })))
@@ -1872,6 +1887,11 @@ var RelayCore = class {
1872
1887
  * whether a claim placed under `parent` restarts `row`: it lands where `row` sits (under `row`'s parent, or a root with no placement), or on
1873
1888
  * `row` itself from `row`'s own process; never under `row` or elsewhere in its tree
1874
1889
  */
1890
+ /** a local row held only by connections whose harness process exited, as a shim that outlived its harness leaves it */
1891
+ outlived(row) {
1892
+ const pid = Number(/^pid:(\d+):/.exec(row.process ?? "")?.[1]);
1893
+ return row.connections > 0 && pid > 0 && this.localRow(row) && !this.alive(pid);
1894
+ }
1875
1895
  restarts(row, parent, process) {
1876
1896
  if (parent?.id === row.id) return process !== null && row.process !== null && sameHarness(row.process, process);
1877
1897
  return (parent?.id ?? null) === row.parent_ep || parent === null && row.linked !== null;
@@ -2036,7 +2056,7 @@ var RelayCore = class {
2036
2056
  endpoint
2037
2057
  );
2038
2058
  const row = this.mustEndpoint(endpoint);
2039
- if (!options.settle || presenceOf(row, now) !== "offline" || row.status_state !== "working" && row.status_state !== "waiting") return false;
2059
+ if (!options.settle || shownPresence(row, now) !== "offline" || row.status_state !== "working" && row.status_state !== "waiting") return false;
2040
2060
  this.sql.all(`UPDATE endpoints SET status_state = NULL, status_note = NULL WHERE id = ?`, endpoint);
2041
2061
  return true;
2042
2062
  }
@@ -2051,6 +2071,7 @@ var RelayCore = class {
2051
2071
  `UPDATE endpoints SET connections = 0, hook_seen = CASE WHEN kind = 'agent' AND hook_ended IS NULL THEN MAX(COALESCE(hook_seen, 0), ?) ELSE hook_seen END WHERE connections > 0`,
2052
2072
  this.now() - (LIMITS.hookIdleS - LIMITS.restartGraceS) * 1e3
2053
2073
  );
2074
+ this.sql.all(`UPDATE endpoints SET wakers = 0, waker_left = ? WHERE wakers > 0`, this.now());
2054
2075
  }
2055
2076
  whoami(endpoint) {
2056
2077
  return this.view(this.mustEndpoint(endpoint));
@@ -2068,7 +2089,7 @@ var RelayCore = class {
2068
2089
  const viewer = filters.viewer === void 0 ? void 0 : this.mustEndpoint(filters.viewer);
2069
2090
  const shown = viewer ? new Set(this.visibleRows(viewer).map((row) => row.id)) : void 0;
2070
2091
  const peeker = viewer ?? (filters.peekable === true ? this.byHandle(HUMAN_HANDLE) : void 0);
2071
- return this.listed().filter((row) => filters.all || row.connections > 0 || row.kind === "human" || (row.last_seen ?? 0) >= cutoff).filter((row) => !filters.harness || row.harness === filters.harness).filter((row) => !filters.kind || row.kind === filters.kind).filter((row) => !viewer || row.id === viewer.id || this.mayMessage(viewer, row, viewer.kind === "human")).map((row) => {
2092
+ return this.listed().filter((row) => filters.all || row.connections > 0 || row.kind === "human" || (row.last_seen ?? 0) >= cutoff || waking(row, now)).filter((row) => !filters.harness || row.harness === filters.harness).filter((row) => !filters.kind || row.kind === filters.kind).filter((row) => !viewer || row.id === viewer.id || this.mayMessage(viewer, row, viewer.kind === "human")).map((row) => {
2072
2093
  const view = this.view(row, shown);
2073
2094
  return peeker && this.peekableBy(peeker, row, viewer?.kind === "human") ? { ...view, peekable: row.peek } : view;
2074
2095
  }).filter((view) => !filters.presence || view.presence === filters.presence);
@@ -2168,6 +2189,20 @@ var RelayCore = class {
2168
2189
  this.sql.all(`UPDATE endpoints SET wake = ? WHERE id = ?`, wake, endpoint);
2169
2190
  return true;
2170
2191
  }
2192
+ /**
2193
+ * a connection that set the endpoint's wake starts (`live`) or stops counting as its waker. A hook's (`grace`) still counts for
2194
+ * LIMITS.restartGraceS after it closes, while its harness starts the next one
2195
+ */
2196
+ waker(endpoint, live, options = {}) {
2197
+ if (live) this.sql.all(`UPDATE endpoints SET wakers = wakers + 1 WHERE id = ?`, endpoint);
2198
+ else
2199
+ this.sql.all(
2200
+ `UPDATE endpoints SET wakers = MAX(wakers - 1, 0), waker_left = CASE WHEN ? THEN ? ELSE waker_left END WHERE id = ?`,
2201
+ options.grace ? 1 : 0,
2202
+ this.now(),
2203
+ endpoint
2204
+ );
2205
+ }
2171
2206
  /**
2172
2207
  * a title for the caller's in-process subagent `id` from the description its parent gave it; the subagent's endpoint id when it changed one.
2173
2208
  * It fills only a title nobody set, as an inferred one, and waits for the subagent's claim when it has no endpoint yet
@@ -2469,7 +2504,7 @@ var RelayCore = class {
2469
2504
  const recipients = [...new Map(resolved.map((row) => [row.id, row])).values()];
2470
2505
  if (params.live !== void 0 && typeof params.live !== "boolean") throw new RelayError("invalid_params", "live must be true or false");
2471
2506
  const dark = recipients.filter(
2472
- (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)
2507
+ (r) => r.kind === "agent" && shownPresence(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)
2473
2508
  );
2474
2509
  if (params.live === true && dark.length)
2475
2510
  throw new RelayError("not_live", `no live session holds ${dark.map((r) => this.address(r)).join(", ")}: nothing was queued`);
@@ -2714,7 +2749,7 @@ var RelayCore = class {
2714
2749
  reach(sender, recipient, offline, now) {
2715
2750
  if (recipient.kind !== "agent" || !this.localRow(recipient)) return void 0;
2716
2751
  const done = recipient.status_state === "done";
2717
- if ((done || presenceOf(recipient, now) === "offline") && this.unjoinedSpawn(sender, recipient.handle, now)) return "starting";
2752
+ if ((done || shownPresence(recipient, now) === "offline") && this.unjoinedSpawn(sender, recipient.handle, now)) return "starting";
2718
2753
  if (done) return "ended";
2719
2754
  return offline ? "offline" : "running";
2720
2755
  }
@@ -3113,7 +3148,7 @@ var RelayCore = class {
3113
3148
  continue;
3114
3149
  }
3115
3150
  const row = this.byId(grant.used_by);
3116
- if (!row || row.status_state === "done" || presenceOf(row, now) === "offline" && (grant.handback !== null || !pid || !this.alive(pid))) continue;
3151
+ if (!row || row.status_state === "done" || shownPresence(row, now) === "offline" && (grant.handback !== null || !pid || !this.alive(pid))) continue;
3117
3152
  workers.set(row.id, row);
3118
3153
  if (grant.pushed_at !== null && grant.pushed_at > now - LIMITS.pushGapS * 1e3) paced.add(row.id);
3119
3154
  }
@@ -3476,9 +3511,9 @@ var RelayCore = class {
3476
3511
  request,
3477
3512
  endpoint
3478
3513
  ).filter(
3479
- (row) => present ? present.includes(row.id) : presenceOf(row, now) !== "offline" || Math.max(row.last_seen ?? 0, row.hook_seen ?? 0, row.hook_ended ?? 0) >= asked.created_at
3514
+ (row) => present ? present.includes(row.id) : shownPresence(row, now) !== "offline" || Math.max(row.last_seen ?? 0, row.hook_seen ?? 0, row.hook_ended ?? 0) >= asked.created_at
3480
3515
  );
3481
- const gone = (row) => presenceOf(row, now) === "offline" && ((row.hook_ended ?? 0) >= asked.created_at || row.status_state === "done" || now - Math.max(row.last_seen ?? 0, row.hook_seen ?? 0) >= ASK_ENDED_GRACE_MS);
3516
+ const gone = (row) => shownPresence(row, now) === "offline" && ((row.hook_ended ?? 0) >= asked.created_at || row.status_state === "done" || now - Math.max(row.last_seen ?? 0, row.hook_seen ?? 0) >= ASK_ENDED_GRACE_MS);
3482
3517
  if (!rows.length || !rows.every(gone)) return null;
3483
3518
  return rows.map((row) => ({ address: this.address(row), state: row.status_state }));
3484
3519
  }
@@ -3573,7 +3608,7 @@ var RelayCore = class {
3573
3608
  for (const row of this.sql.all(
3574
3609
  `SELECT * FROM endpoints WHERE kind = 'agent' AND connections = 0 AND status_state IN ('working', 'waiting')`
3575
3610
  )) {
3576
- if (this.localRow(row) && presenceOf(row, now) === "offline")
3611
+ if (this.localRow(row) && shownPresence(row, now) === "offline")
3577
3612
  this.sql.all(`UPDATE endpoints SET status_state = NULL, status_note = NULL WHERE id = ?`, row.id);
3578
3613
  }
3579
3614
  }
@@ -5382,7 +5417,7 @@ ${stderr}` : "";
5382
5417
  }
5383
5418
  /** with `shown`, the ids a viewer sees: a parent outside it is null */
5384
5419
  view(row, shown) {
5385
- const presence = presenceOf(row, this.now());
5420
+ const presence = shownPresence(row, this.now());
5386
5421
  return {
5387
5422
  id: row.id,
5388
5423
  address: this.address(row),
@@ -5575,6 +5610,9 @@ function unreachable(address) {
5575
5610
  var PROCESS_KEY = /^pid:\d+:[A-Za-z0-9:-]{1,64}$/;
5576
5611
  var BARE_PID_KEY = /^pid:\d+$/;
5577
5612
  var ANY_PROCESS_KEY = /^pid:\d+(:[A-Za-z0-9:-]{1,64})?$/;
5613
+ function reclaims(row, key) {
5614
+ return row.connections > 0 && key !== null && row.process === key && PROCESS_KEY.test(key);
5615
+ }
5578
5616
  function sameProcess(row, key) {
5579
5617
  if (row.connections > 0 || key === null || row.process === null) return false;
5580
5618
  return row.token_hash === null ? sameHarness(row.process, key) : row.process === key && PROCESS_KEY.test(key);
@@ -5615,6 +5653,13 @@ function presenceOf(row, now) {
5615
5653
  if (row.hook_seen >= now - LIMITS.hookOnlineS * 1e3) return "online";
5616
5654
  return row.hook_seen >= now - LIMITS.hookIdleS * 1e3 ? "idle" : "offline";
5617
5655
  }
5656
+ function waking(row, now) {
5657
+ return wakeOf(row) !== null && (row.wakers > 0 || (row.waker_left ?? 0) >= now - LIMITS.restartGraceS * 1e3);
5658
+ }
5659
+ function shownPresence(row, now) {
5660
+ const presence = presenceOf(row, now);
5661
+ return presence === "offline" && waking(row, now) ? "idle" : presence;
5662
+ }
5618
5663
  function pairKey(a, b) {
5619
5664
  return a < b ? `${a}|${b}` : `${b}|${a}`;
5620
5665
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scp-dev/core",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
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
@@ -267,6 +267,10 @@ export interface EndpointRow {
267
267
  linked: number | null
268
268
  /** how the session that holds the endpoint wakes it: a {@link Wake} other than none, or null */
269
269
  wake: string | null
270
+ /** the live connections that set the wake */
271
+ wakers: number
272
+ /** when the last hook connection that set the wake closed */
273
+ waker_left: number | null
270
274
  }
271
275
 
272
276
  export interface MessageRow {
@@ -496,6 +500,20 @@ export class RelayCore {
496
500
  return labels
497
501
  }
498
502
 
503
+ /** this host's own agent rows labelled with another machine (its name before a rename, or its hostname) take its machine name */
504
+ relabelMachine(): number {
505
+ const machine = this.namedMachine
506
+ if (machine === null) return 0
507
+ let changed = 0
508
+ for (const row of this.sql.all<EndpointRow>(`SELECT * FROM endpoints WHERE kind = 'agent' AND labels LIKE '%"machine"%'`)) {
509
+ const labels = json<Record<string, string>>(row.labels, {})
510
+ if (labels.machine === undefined || labels.machine === machine || !this.localRow(row)) continue
511
+ this.sql.all(`UPDATE endpoints SET labels = ? WHERE id = ?`, JSON.stringify({ ...labels, machine }), row.id)
512
+ changed++
513
+ }
514
+ return changed
515
+ }
516
+
499
517
  migrate(): void {
500
518
  for (const statement of MIGRATION) this.sql.all(statement)
501
519
  const columns = this.sql.all<{ name: string }>(`PRAGMA table_info(idempotency)`).map((c) => c.name)
@@ -535,6 +553,8 @@ export class RelayCore {
535
553
  ["named", "INTEGER"],
536
554
  ["linked", "INTEGER"],
537
555
  ["wake", "TEXT"],
556
+ ["wakers", "INTEGER NOT NULL DEFAULT 0"],
557
+ ["waker_left", "INTEGER"],
538
558
  ] as const) {
539
559
  if (!endpointColumns.includes(column)) this.sql.all(`ALTER TABLE endpoints ADD COLUMN ${column} ${type}`)
540
560
  }
@@ -834,7 +854,7 @@ export class RelayCore {
834
854
  if (row && presenceOf(row, now) === "offline") {
835
855
  this.sql.all(`UPDATE endpoints SET session = NULL WHERE id = ?`, row.id)
836
856
  row = undefined
837
- } else if (row && !transient && !sameProcess(row, fields.process)) {
857
+ } else if (row && !transient && !sameProcess(row, fields.process) && !(row.handle === wanted && reclaims(row, fields.process))) {
838
858
  row = undefined
839
859
  session = null
840
860
  } else if (
@@ -884,8 +904,7 @@ export class RelayCore {
884
904
  created = true
885
905
  } else if (
886
906
  held.kind === "agent" &&
887
- held.connections === 0 &&
888
- presenceOf(held, now) === "offline" &&
907
+ ((held.connections === 0 && presenceOf(held, now) === "offline") || (!transient && this.outlived(held))) &&
889
908
  harnessFits(held.harness, harness) &&
890
909
  held.id !== scope.subagent?.root &&
891
910
  !(scope.subagent && held.session?.startsWith("sub:") && held.session !== fields.session)
@@ -1124,6 +1143,12 @@ export class RelayCore {
1124
1143
  * whether a claim placed under `parent` restarts `row`: it lands where `row` sits (under `row`'s parent, or a root with no placement), or on
1125
1144
  * `row` itself from `row`'s own process; never under `row` or elsewhere in its tree
1126
1145
  */
1146
+ /** a local row held only by connections whose harness process exited, as a shim that outlived its harness leaves it */
1147
+ private outlived(row: EndpointRow): boolean {
1148
+ const pid = Number(/^pid:(\d+):/.exec(row.process ?? "")?.[1])
1149
+ return row.connections > 0 && pid > 0 && this.localRow(row) && !this.alive(pid)
1150
+ }
1151
+
1127
1152
  private restarts(row: EndpointRow, parent: EndpointRow | null, process: string | null): boolean {
1128
1153
  if (parent?.id === row.id) return process !== null && row.process !== null && sameHarness(row.process, process)
1129
1154
  return (parent?.id ?? null) === row.parent_ep || (parent === null && row.linked !== null)
@@ -1305,7 +1330,7 @@ export class RelayCore {
1305
1330
  endpoint,
1306
1331
  )
1307
1332
  const row = this.mustEndpoint(endpoint)
1308
- if (!options.settle || presenceOf(row, now) !== "offline" || (row.status_state !== "working" && row.status_state !== "waiting")) return false
1333
+ if (!options.settle || shownPresence(row, now) !== "offline" || (row.status_state !== "working" && row.status_state !== "waiting")) return false
1309
1334
  this.sql.all(`UPDATE endpoints SET status_state = NULL, status_note = NULL WHERE id = ?`, endpoint)
1310
1335
  return true
1311
1336
  }
@@ -1322,6 +1347,7 @@ export class RelayCore {
1322
1347
  `UPDATE endpoints SET connections = 0, hook_seen = CASE WHEN kind = 'agent' AND hook_ended IS NULL THEN MAX(COALESCE(hook_seen, 0), ?) ELSE hook_seen END WHERE connections > 0`,
1323
1348
  this.now() - (LIMITS.hookIdleS - LIMITS.restartGraceS) * 1000,
1324
1349
  )
1350
+ this.sql.all(`UPDATE endpoints SET wakers = 0, waker_left = ? WHERE wakers > 0`, this.now())
1325
1351
  }
1326
1352
 
1327
1353
  whoami(endpoint: string): EndpointView {
@@ -1351,7 +1377,7 @@ export class RelayCore {
1351
1377
  const shown = viewer ? new Set(this.visibleRows(viewer).map((row) => row.id)) : undefined
1352
1378
  const peeker = viewer ?? (filters.peekable === true ? this.byHandle(HUMAN_HANDLE)! : undefined)
1353
1379
  return this.listed()
1354
- .filter((row) => filters.all || row.connections > 0 || row.kind === "human" || (row.last_seen ?? 0) >= cutoff)
1380
+ .filter((row) => filters.all || row.connections > 0 || row.kind === "human" || (row.last_seen ?? 0) >= cutoff || waking(row, now))
1355
1381
  .filter((row) => !filters.harness || row.harness === filters.harness)
1356
1382
  .filter((row) => !filters.kind || row.kind === filters.kind)
1357
1383
  .filter((row) => !viewer || row.id === viewer.id || this.mayMessage(viewer, row, viewer.kind === "human"))
@@ -1468,6 +1494,21 @@ export class RelayCore {
1468
1494
  return true
1469
1495
  }
1470
1496
 
1497
+ /**
1498
+ * a connection that set the endpoint's wake starts (`live`) or stops counting as its waker. A hook's (`grace`) still counts for
1499
+ * LIMITS.restartGraceS after it closes, while its harness starts the next one
1500
+ */
1501
+ waker(endpoint: string, live: boolean, options: { grace?: boolean } = {}): void {
1502
+ if (live) this.sql.all(`UPDATE endpoints SET wakers = wakers + 1 WHERE id = ?`, endpoint)
1503
+ else
1504
+ this.sql.all(
1505
+ `UPDATE endpoints SET wakers = MAX(wakers - 1, 0), waker_left = CASE WHEN ? THEN ? ELSE waker_left END WHERE id = ?`,
1506
+ options.grace ? 1 : 0,
1507
+ this.now(),
1508
+ endpoint,
1509
+ )
1510
+ }
1511
+
1471
1512
  /**
1472
1513
  * a title for the caller's in-process subagent `id` from the description its parent gave it; the subagent's endpoint id when it changed one.
1473
1514
  * It fills only a title nobody set, as an inferred one, and waits for the subagent's claim when it has no endpoint yet
@@ -1787,7 +1828,7 @@ export class RelayCore {
1787
1828
  const dark = recipients.filter(
1788
1829
  (r) =>
1789
1830
  r.kind === "agent" &&
1790
- presenceOf(r, now) === "offline" &&
1831
+ shownPresence(r, now) === "offline" &&
1791
1832
  (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)) &&
1792
1833
  !this.unjoinedSpawn(sender, r.handle, now),
1793
1834
  )
@@ -2047,7 +2088,7 @@ export class RelayCore {
2047
2088
  private reach(sender: EndpointRow, recipient: EndpointRow, offline: boolean, now: number): Reach | undefined {
2048
2089
  if (recipient.kind !== "agent" || !this.localRow(recipient)) return undefined
2049
2090
  const done = recipient.status_state === "done"
2050
- if ((done || presenceOf(recipient, now) === "offline") && this.unjoinedSpawn(sender, recipient.handle, now)) return "starting"
2091
+ if ((done || shownPresence(recipient, now) === "offline") && this.unjoinedSpawn(sender, recipient.handle, now)) return "starting"
2051
2092
  if (done) return "ended"
2052
2093
  return offline ? "offline" : "running"
2053
2094
  }
@@ -2500,7 +2541,7 @@ export class RelayCore {
2500
2541
  continue
2501
2542
  }
2502
2543
  const row = this.byId(grant.used_by)
2503
- if (!row || row.status_state === "done" || (presenceOf(row, now) === "offline" && (grant.handback !== null || !pid || !this.alive(pid)))) continue
2544
+ if (!row || row.status_state === "done" || (shownPresence(row, now) === "offline" && (grant.handback !== null || !pid || !this.alive(pid)))) continue
2504
2545
  workers.set(row.id, row)
2505
2546
  if (grant.pushed_at !== null && grant.pushed_at > now - LIMITS.pushGapS * 1000) paced.add(row.id)
2506
2547
  }
@@ -2902,10 +2943,10 @@ export class RelayCore {
2902
2943
  .filter((row) =>
2903
2944
  present
2904
2945
  ? present.includes(row.id)
2905
- : presenceOf(row, now) !== "offline" || Math.max(row.last_seen ?? 0, row.hook_seen ?? 0, row.hook_ended ?? 0) >= asked.created_at,
2946
+ : shownPresence(row, now) !== "offline" || Math.max(row.last_seen ?? 0, row.hook_seen ?? 0, row.hook_ended ?? 0) >= asked.created_at,
2906
2947
  )
2907
2948
  const gone = (row: EndpointRow): boolean =>
2908
- presenceOf(row, now) === "offline" &&
2949
+ shownPresence(row, now) === "offline" &&
2909
2950
  ((row.hook_ended ?? 0) >= asked.created_at || row.status_state === "done" || now - Math.max(row.last_seen ?? 0, row.hook_seen ?? 0) >= ASK_ENDED_GRACE_MS)
2910
2951
  if (!rows.length || !rows.every(gone)) return null
2911
2952
  return rows.map((row) => ({ address: this.address(row), state: row.status_state as StatusState | null }))
@@ -3019,7 +3060,7 @@ export class RelayCore {
3019
3060
  for (const row of this.sql.all<EndpointRow>(
3020
3061
  `SELECT * FROM endpoints WHERE kind = 'agent' AND connections = 0 AND status_state IN ('working', 'waiting')`,
3021
3062
  )) {
3022
- if (this.localRow(row) && presenceOf(row, now) === "offline")
3063
+ if (this.localRow(row) && shownPresence(row, now) === "offline")
3023
3064
  this.sql.all(`UPDATE endpoints SET status_state = NULL, status_note = NULL WHERE id = ?`, row.id)
3024
3065
  }
3025
3066
  }
@@ -5079,7 +5120,7 @@ export class RelayCore {
5079
5120
 
5080
5121
  /** with `shown`, the ids a viewer sees: a parent outside it is null */
5081
5122
  protected view(row: EndpointRow, shown?: Set<string>): EndpointView {
5082
- const presence = presenceOf(row, this.now())
5123
+ const presence = shownPresence(row, this.now())
5083
5124
  return {
5084
5125
  id: row.id,
5085
5126
  address: this.address(row),
@@ -5315,6 +5356,11 @@ const ANY_PROCESS_KEY = /^pid:\d+(:[A-Za-z0-9:-]{1,64})?$/
5315
5356
  * whether a connecting claim from the harness process `key` may join a session endpoint: no connection holds it, the harness process that last
5316
5357
  * claimed it claims again, and, once a connection held it (a token was handed out), with the same full key, which a recycled pid cannot match
5317
5358
  */
5359
+ /** a claim from the very harness process that holds the live `row`, such as a session it restarted in process */
5360
+ function reclaims(row: EndpointRow, key: string | null): boolean {
5361
+ return row.connections > 0 && key !== null && row.process === key && PROCESS_KEY.test(key)
5362
+ }
5363
+
5318
5364
  function sameProcess(row: EndpointRow, key: string | null): boolean {
5319
5365
  if (row.connections > 0 || key === null || row.process === null) return false
5320
5366
  return row.token_hash === null ? sameHarness(row.process, key) : row.process === key && PROCESS_KEY.test(key)
@@ -5379,6 +5425,17 @@ function presenceOf(row: EndpointRow, now: number): Presence {
5379
5425
  return row.hook_seen >= now - LIMITS.hookIdleS * 1000 ? "idle" : "offline"
5380
5426
  }
5381
5427
 
5428
+ /** a wake with a live waker, or a hook waker that left within LIMITS.restartGraceS */
5429
+ function waking(row: EndpointRow, now: number): boolean {
5430
+ return wakeOf(row) !== null && (row.wakers > 0 || (row.waker_left ?? 0) >= now - LIMITS.restartGraceS * 1000)
5431
+ }
5432
+
5433
+ /** the presence an endpoint shows: one its waker can wake is at least idle, though its session's own presence decayed */
5434
+ function shownPresence(row: EndpointRow, now: number): Presence {
5435
+ const presence = presenceOf(row, now)
5436
+ return presence === "offline" && waking(row, now) ? "idle" : presence
5437
+ }
5438
+
5382
5439
  function pairKey(a: string, b: string): string {
5383
5440
  return a < b ? `${a}|${b}` : `${b}|${a}`
5384
5441
  }