@scp-dev/core 0.1.4 → 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
@@ -346,7 +346,7 @@ declare const LIMITS: {
346
346
  readonly hookEndedGraceS: 30;
347
347
  readonly restartGraceS: 60;
348
348
  readonly injectMessages: 5;
349
- readonly injectBytes: 4096;
349
+ readonly injectBytes: 7168;
350
350
  readonly pushBytes: 20480;
351
351
  /** the stops a session's push may continue between two prompts */
352
352
  readonly pushes: 20;
@@ -422,6 +422,12 @@ declare const MESSAGE_KINDS: readonly MessageKind[];
422
422
  declare const STATUS_STATES: readonly StatusState[];
423
423
  declare function validHandle(handle: string): boolean;
424
424
  declare function validHarness(harness: string): boolean;
425
+ /** harness names of the path a claim came by (a bare CLI, hook or MCP shim), not of the agent behind it */
426
+ declare const GENERIC_HARNESSES: ReadonlySet<string>;
427
+ /** whether a claim of harness `claimed` may take a row of harness `held`: the same harness, or a generic row, which heals to the claim's */
428
+ declare function harnessFits(held: string | null, claimed: string): boolean;
429
+ /** the harness a claimed row keeps: the claim's, except that a generic claim never replaces a known harness */
430
+ declare function keptHarness(held: string | null, claimed: string): string;
425
431
  interface Claim {
426
432
  handle?: string;
427
433
  harness?: string;
@@ -1103,6 +1109,10 @@ interface EndpointRow {
1103
1109
  linked: number | null;
1104
1110
  /** how the session that holds the endpoint wakes it: a {@link Wake} other than none, or null */
1105
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;
1106
1116
  }
1107
1117
  interface MessageRow {
1108
1118
  id: string;
@@ -1165,6 +1175,8 @@ declare class RelayCore {
1165
1175
  constructor(sql: Sql, options: CoreOptions);
1166
1176
  /** a `machine` label naming this host by its hostname, as clients default it, names it by its machine name when one is set */
1167
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;
1168
1180
  migrate(): void;
1169
1181
  /** every stored list that is exactly `tree:self`, the tree preset's old list, narrows once to the preset's list */
1170
1182
  private migrateTreePreset;
@@ -1250,6 +1262,8 @@ declare class RelayCore {
1250
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
1251
1263
  * `row` itself from `row`'s own process; never under `row` or elsewhere in its tree
1252
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;
1253
1267
  private restarts;
1254
1268
  /**
1255
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
@@ -1338,6 +1352,13 @@ declare class RelayCore {
1338
1352
  transient?: boolean;
1339
1353
  process?: string;
1340
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;
1341
1362
  /**
1342
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.
1343
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
@@ -2492,4 +2513,4 @@ declare function sha256Hex(text: string): string;
2492
2513
  /** the UTF-8 byte length of text, the unit every byte limit counts in */
2493
2514
  declare function utf8Bytes(text: string): number;
2494
2515
 
2495
- 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, WAKES, type Wake, 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, presenceText, 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, wakeText, widenText, wrapMessage, wrapPeek, xlimits };
2516
+ 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, GENERIC_HARNESSES, 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, WAKES, type Wake, 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, harnessFits, hasScope, honestSender, isLeaving, joinRedacted, judgeBoundary, judgeDetailForAgents, judgeFellBack, judgeModel, judgePrompt, keepLast, keptHarness, leavesFleet, matches, mergeVerdicts, narrower, newId, newJudgeStats, one, parseJudgePolicy, parsePolicy, parseVerdict, parseXMessage, peekLevel, policyRefusal, prefilter, presenceText, 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, wakeText, widenText, wrapMessage, wrapPeek, xlimits };
package/dist/index.js CHANGED
@@ -126,7 +126,7 @@ var LIMITS = {
126
126
  hookEndedGraceS: 30,
127
127
  restartGraceS: 60,
128
128
  injectMessages: 5,
129
- injectBytes: 4096,
129
+ injectBytes: 7168,
130
130
  pushBytes: 20480,
131
131
  /** the stops a session's push may continue between two prompts */
132
132
  pushes: 20,
@@ -192,6 +192,14 @@ function validHandle(handle) {
192
192
  function validHarness(harness) {
193
193
  return HARNESS.test(harness);
194
194
  }
195
+ var GENERIC_HARNESSES = /* @__PURE__ */ new Set(["cli", "hook", "mcp", "shell"]);
196
+ var generic = (harness) => harness === null || GENERIC_HARNESSES.has(harness);
197
+ function harnessFits(held, claimed) {
198
+ return held === claimed || generic(held);
199
+ }
200
+ function keptHarness(held, claimed) {
201
+ return generic(claimed) && !generic(held) ? held : claimed;
202
+ }
195
203
  var SCOPE_PRESETS = ["open", "tree", "parent", "none"];
196
204
  var SERVER_FIELDS = ["from", "id", "seq", "hop", "created_at", "auth", "expires_at", "relation"];
197
205
 
@@ -1086,6 +1094,7 @@ function utf8Bytes(text) {
1086
1094
  }
1087
1095
 
1088
1096
  // src/core.ts
1097
+ var GENERIC_SQL = `harness IN (${[...GENERIC_HARNESSES].map((h) => `'${h}'`).join(", ")})`;
1089
1098
  var HUMAN_HANDLE = "_human";
1090
1099
  var TITLE_CHARS = 64;
1091
1100
  var SYSTEM_HANDLE = "_relay";
@@ -1297,6 +1306,19 @@ var RelayCore = class {
1297
1306
  if (this.namedMachine !== null && labels.machine !== void 0 && labels.machine === this.hostname) labels.machine = this.namedMachine;
1298
1307
  return labels;
1299
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
+ }
1300
1322
  migrate() {
1301
1323
  for (const statement of MIGRATION) this.sql.all(statement);
1302
1324
  const columns = this.sql.all(`PRAGMA table_info(idempotency)`).map((c) => c.name);
@@ -1335,7 +1357,9 @@ var RelayCore = class {
1335
1357
  ["tldr_turn", "INTEGER"],
1336
1358
  ["named", "INTEGER"],
1337
1359
  ["linked", "INTEGER"],
1338
- ["wake", "TEXT"]
1360
+ ["wake", "TEXT"],
1361
+ ["wakers", "INTEGER NOT NULL DEFAULT 0"],
1362
+ ["waker_left", "INTEGER"]
1339
1363
  ]) {
1340
1364
  if (!endpointColumns.includes(column)) this.sql.all(`ALTER TABLE endpoints ADD COLUMN ${column} ${type}`);
1341
1365
  }
@@ -1595,7 +1619,7 @@ var RelayCore = class {
1595
1619
  const bare = transient && fields.session !== null && BARE_PID_KEY.test(fields.session);
1596
1620
  let row = fields.session ? one(
1597
1621
  this.sql,
1598
- `SELECT * FROM endpoints WHERE kind = 'agent' AND harness = ?
1622
+ `SELECT * FROM endpoints WHERE kind = 'agent' AND (harness = ? OR ${GENERIC_SQL})
1599
1623
  AND (session = ? OR (? = 1 AND substr(session, 1, ?) = ? AND (connections = 0 OR hook_seen >= ?)))
1600
1624
  AND substr(handle, 1, ${GONE_PREFIX.length}) != '${GONE_PREFIX}' ORDER BY COALESCE(last_seen, created_at) DESC LIMIT 1`,
1601
1625
  harness,
@@ -1612,7 +1636,7 @@ var RelayCore = class {
1612
1636
  if (row && presenceOf(row, now) === "offline") {
1613
1637
  this.sql.all(`UPDATE endpoints SET session = NULL WHERE id = ?`, row.id);
1614
1638
  row = void 0;
1615
- } else if (row && !transient && !sameProcess(row, fields.process)) {
1639
+ } else if (row && !transient && !sameProcess(row, fields.process) && !(row.handle === wanted && reclaims(row, fields.process))) {
1616
1640
  row = void 0;
1617
1641
  session = null;
1618
1642
  } else if (row && !transient && row.token_hash === null && row.named === null && row.handle !== wanted && !row.handle.startsWith(`${wanted.slice(0, 58)}-`)) {
@@ -1624,7 +1648,7 @@ var RelayCore = class {
1624
1648
  if (!held) {
1625
1649
  this.sql.all(`UPDATE endpoints SET handle = ? WHERE id = ?`, handle, hooks.id);
1626
1650
  settled = true;
1627
- } else if (held.kind === "agent" && held.connections === 0 && presenceOf(held, now) === "offline" && held.harness === harness) {
1651
+ } else if (held.kind === "agent" && held.connections === 0 && presenceOf(held, now) === "offline" && harnessFits(held.harness, harness)) {
1628
1652
  if (this.restarts(held, this.placement(scope, now).parent, fields.process) || this.redeems(held, scope, { harness, ...fields })) {
1629
1653
  ;
1630
1654
  ({ moved, stopBlocks } = this.leaveHooks(hooks, held.id));
@@ -1654,12 +1678,12 @@ var RelayCore = class {
1654
1678
  this.sql.all(`INSERT INTO endpoints (id, handle, kind, harness, labels, created_at) VALUES (?, ?, 'agent', ?, '{}', ?)`, id, handle, harness, now);
1655
1679
  row = this.byId(id);
1656
1680
  created = true;
1657
- } else if (held.kind === "agent" && held.connections === 0 && presenceOf(held, now) === "offline" && 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)) {
1658
1682
  own = transient && held.process !== null && fields.process !== null && sameHarness(held.process, fields.process);
1659
1683
  const ended = held.hook_ended !== null || (held.last_seen ?? held.created_at) < now - LIMITS.restartGraceS * 1e3;
1660
1684
  if ((!transient || own || ended) && (this.restarts(held, this.placement(scope, now).parent, fields.process) || this.redeems(held, scope, { harness, ...fields })))
1661
1685
  row = held;
1662
- } else if (scope.exact && held.kind === "agent" && held.process === null && held.session === null && held.harness === harness && this.restarts(held, this.placement(scope, now).parent, null)) {
1686
+ } else if (scope.exact && held.kind === "agent" && held.process === null && held.session === null && harnessFits(held.harness, harness) && this.restarts(held, this.placement(scope, now).parent, null)) {
1663
1687
  row = held;
1664
1688
  joined = true;
1665
1689
  live = presenceOf(held, now) !== "offline";
@@ -1684,7 +1708,7 @@ var RelayCore = class {
1684
1708
  stop_blocks = CASE WHEN ? IS NOT NULL THEN ? WHEN ? IS NOT NULL AND session IS NOT ? THEN 0 ELSE stop_blocks END,
1685
1709
  briefed = CASE WHEN ? IS NOT NULL AND session IS NOT ? THEN NULL ELSE briefed END, introduced = CASE WHEN ? IS NOT NULL AND session IS NOT ? THEN NULL ELSE introduced END,
1686
1710
  directive_holds = CASE WHEN ? IS NOT NULL AND session IS NOT ? THEN NULL ELSE directive_holds END WHERE id = ?`,
1687
- harness,
1711
+ keptHarness(row.harness, harness),
1688
1712
  fields.model,
1689
1713
  fields.cwd,
1690
1714
  fields.parent,
@@ -1813,7 +1837,7 @@ var RelayCore = class {
1813
1837
  if ((held.session !== null || held.process !== null) && presenceOf(held, now) !== "offline") {
1814
1838
  return new RelayError("unauthorized", `${handle} is held by a live ${held.harness} session: act as it from that session's own shell, no --as needed`);
1815
1839
  }
1816
- if (held.harness !== harness) return new RelayError("unauthorized", `${handle} is a ${held.harness} endpoint: pass --harness ${held.harness}`);
1840
+ if (!harnessFits(held.harness, harness)) return new RelayError("unauthorized", `${handle} is a ${held.harness} endpoint: pass --harness ${held.harness}`);
1817
1841
  return new RelayError("unauthorized", `${handle} is placed in another tree or under another parent than this shell`);
1818
1842
  }
1819
1843
  /**
@@ -1863,6 +1887,11 @@ var RelayCore = class {
1863
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
1864
1888
  * `row` itself from `row`'s own process; never under `row` or elsewhere in its tree
1865
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
+ }
1866
1895
  restarts(row, parent, process) {
1867
1896
  if (parent?.id === row.id) return process !== null && row.process !== null && sameHarness(row.process, process);
1868
1897
  return (parent?.id ?? null) === row.parent_ep || parent === null && row.linked !== null;
@@ -1875,7 +1904,7 @@ var RelayCore = class {
1875
1904
  const spent = this.spentGrant(scope);
1876
1905
  if (spent?.used_by !== row.id) return false;
1877
1906
  const lineage = this.lineageParent(scope.lineage);
1878
- if (!lineage) return row.harness === claimant.harness && spent.spent_cwd !== null && spent.spent_cwd === claimant.cwd;
1907
+ if (!lineage) return harnessFits(row.harness, claimant.harness) && spent.spent_cwd !== null && spent.spent_cwd === claimant.cwd;
1879
1908
  return this.restarts(row, lineage, claimant.process) || this.ancestorsOf(row).includes(lineage.id);
1880
1909
  }
1881
1910
  /** the claim's grant, when an endpoint spent it */
@@ -2027,7 +2056,7 @@ var RelayCore = class {
2027
2056
  endpoint
2028
2057
  );
2029
2058
  const row = this.mustEndpoint(endpoint);
2030
- 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;
2031
2060
  this.sql.all(`UPDATE endpoints SET status_state = NULL, status_note = NULL WHERE id = ?`, endpoint);
2032
2061
  return true;
2033
2062
  }
@@ -2042,6 +2071,7 @@ var RelayCore = class {
2042
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`,
2043
2072
  this.now() - (LIMITS.hookIdleS - LIMITS.restartGraceS) * 1e3
2044
2073
  );
2074
+ this.sql.all(`UPDATE endpoints SET wakers = 0, waker_left = ? WHERE wakers > 0`, this.now());
2045
2075
  }
2046
2076
  whoami(endpoint) {
2047
2077
  return this.view(this.mustEndpoint(endpoint));
@@ -2059,7 +2089,7 @@ var RelayCore = class {
2059
2089
  const viewer = filters.viewer === void 0 ? void 0 : this.mustEndpoint(filters.viewer);
2060
2090
  const shown = viewer ? new Set(this.visibleRows(viewer).map((row) => row.id)) : void 0;
2061
2091
  const peeker = viewer ?? (filters.peekable === true ? this.byHandle(HUMAN_HANDLE) : void 0);
2062
- 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) => {
2063
2093
  const view = this.view(row, shown);
2064
2094
  return peeker && this.peekableBy(peeker, row, viewer?.kind === "human") ? { ...view, peekable: row.peek } : view;
2065
2095
  }).filter((view) => !filters.presence || view.presence === filters.presence);
@@ -2159,6 +2189,20 @@ var RelayCore = class {
2159
2189
  this.sql.all(`UPDATE endpoints SET wake = ? WHERE id = ?`, wake, endpoint);
2160
2190
  return true;
2161
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
+ }
2162
2206
  /**
2163
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.
2164
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
@@ -2460,7 +2504,7 @@ var RelayCore = class {
2460
2504
  const recipients = [...new Map(resolved.map((row) => [row.id, row])).values()];
2461
2505
  if (params.live !== void 0 && typeof params.live !== "boolean") throw new RelayError("invalid_params", "live must be true or false");
2462
2506
  const dark = recipients.filter(
2463
- (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)
2464
2508
  );
2465
2509
  if (params.live === true && dark.length)
2466
2510
  throw new RelayError("not_live", `no live session holds ${dark.map((r) => this.address(r)).join(", ")}: nothing was queued`);
@@ -2705,7 +2749,7 @@ var RelayCore = class {
2705
2749
  reach(sender, recipient, offline, now) {
2706
2750
  if (recipient.kind !== "agent" || !this.localRow(recipient)) return void 0;
2707
2751
  const done = recipient.status_state === "done";
2708
- 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";
2709
2753
  if (done) return "ended";
2710
2754
  return offline ? "offline" : "running";
2711
2755
  }
@@ -3104,7 +3148,7 @@ var RelayCore = class {
3104
3148
  continue;
3105
3149
  }
3106
3150
  const row = this.byId(grant.used_by);
3107
- 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;
3108
3152
  workers.set(row.id, row);
3109
3153
  if (grant.pushed_at !== null && grant.pushed_at > now - LIMITS.pushGapS * 1e3) paced.add(row.id);
3110
3154
  }
@@ -3467,9 +3511,9 @@ var RelayCore = class {
3467
3511
  request,
3468
3512
  endpoint
3469
3513
  ).filter(
3470
- (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
3471
3515
  );
3472
- 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);
3473
3517
  if (!rows.length || !rows.every(gone)) return null;
3474
3518
  return rows.map((row) => ({ address: this.address(row), state: row.status_state }));
3475
3519
  }
@@ -3564,7 +3608,7 @@ var RelayCore = class {
3564
3608
  for (const row of this.sql.all(
3565
3609
  `SELECT * FROM endpoints WHERE kind = 'agent' AND connections = 0 AND status_state IN ('working', 'waiting')`
3566
3610
  )) {
3567
- if (this.localRow(row) && presenceOf(row, now) === "offline")
3611
+ if (this.localRow(row) && shownPresence(row, now) === "offline")
3568
3612
  this.sql.all(`UPDATE endpoints SET status_state = NULL, status_note = NULL WHERE id = ?`, row.id);
3569
3613
  }
3570
3614
  }
@@ -5373,7 +5417,7 @@ ${stderr}` : "";
5373
5417
  }
5374
5418
  /** with `shown`, the ids a viewer sees: a parent outside it is null */
5375
5419
  view(row, shown) {
5376
- const presence = presenceOf(row, this.now());
5420
+ const presence = shownPresence(row, this.now());
5377
5421
  return {
5378
5422
  id: row.id,
5379
5423
  address: this.address(row),
@@ -5566,6 +5610,9 @@ function unreachable(address) {
5566
5610
  var PROCESS_KEY = /^pid:\d+:[A-Za-z0-9:-]{1,64}$/;
5567
5611
  var BARE_PID_KEY = /^pid:\d+$/;
5568
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
+ }
5569
5616
  function sameProcess(row, key) {
5570
5617
  if (row.connections > 0 || key === null || row.process === null) return false;
5571
5618
  return row.token_hash === null ? sameHarness(row.process, key) : row.process === key && PROCESS_KEY.test(key);
@@ -5606,6 +5653,13 @@ function presenceOf(row, now) {
5606
5653
  if (row.hook_seen >= now - LIMITS.hookOnlineS * 1e3) return "online";
5607
5654
  return row.hook_seen >= now - LIMITS.hookIdleS * 1e3 ? "idle" : "offline";
5608
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
+ }
5609
5663
  function pairKey(a, b) {
5610
5664
  return a < b ? `${a}|${b}` : `${b}|${a}`;
5611
5665
  }
@@ -6387,6 +6441,7 @@ export {
6387
6441
  DEFAULT_JUDGE_EFFORT,
6388
6442
  ERROR_CODES,
6389
6443
  FakeJudgeClient,
6444
+ GENERIC_HARNESSES,
6390
6445
  GONE_PREFIX,
6391
6446
  GROUP_NAME,
6392
6447
  HANDBACK_CUT,
@@ -6449,6 +6504,7 @@ export {
6449
6504
  gateRefusal,
6450
6505
  groupCapKey,
6451
6506
  handbackOutput,
6507
+ harnessFits,
6452
6508
  hasScope,
6453
6509
  honestSender,
6454
6510
  isLeaving,
@@ -6459,6 +6515,7 @@ export {
6459
6515
  judgeModel,
6460
6516
  judgePrompt,
6461
6517
  keepLast,
6518
+ keptHarness,
6462
6519
  leavesFleet,
6463
6520
  matches,
6464
6521
  mergeVerdicts,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scp-dev/core",
3
- "version": "0.1.4",
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
@@ -38,6 +38,9 @@ import {
38
38
  type StopNudge,
39
39
  type StopWaitResult,
40
40
  type ThreadMessage,
41
+ GENERIC_HARNESSES,
42
+ harnessFits,
43
+ keptHarness,
41
44
  validHandle,
42
45
  validHarness,
43
46
  type Wake,
@@ -70,6 +73,8 @@ import { findSecret, redactSecrets } from "./scrub"
70
73
  import { one, type Sql, type SqlValue } from "./sql"
71
74
  import { utf8Bytes } from "./utf8"
72
75
 
76
+ const GENERIC_SQL = `harness IN (${[...GENERIC_HARNESSES].map((h) => `'${h}'`).join(", ")})`
77
+
73
78
  export const HUMAN_HANDLE = "_human"
74
79
  /** an agent title's length cap */
75
80
  export const TITLE_CHARS = 64
@@ -262,6 +267,10 @@ export interface EndpointRow {
262
267
  linked: number | null
263
268
  /** how the session that holds the endpoint wakes it: a {@link Wake} other than none, or null */
264
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
265
274
  }
266
275
 
267
276
  export interface MessageRow {
@@ -491,6 +500,20 @@ export class RelayCore {
491
500
  return labels
492
501
  }
493
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
+
494
517
  migrate(): void {
495
518
  for (const statement of MIGRATION) this.sql.all(statement)
496
519
  const columns = this.sql.all<{ name: string }>(`PRAGMA table_info(idempotency)`).map((c) => c.name)
@@ -530,6 +553,8 @@ export class RelayCore {
530
553
  ["named", "INTEGER"],
531
554
  ["linked", "INTEGER"],
532
555
  ["wake", "TEXT"],
556
+ ["wakers", "INTEGER NOT NULL DEFAULT 0"],
557
+ ["waker_left", "INTEGER"],
533
558
  ] as const) {
534
559
  if (!endpointColumns.includes(column)) this.sql.all(`ALTER TABLE endpoints ADD COLUMN ${column} ${type}`)
535
560
  }
@@ -811,7 +836,7 @@ export class RelayCore {
811
836
  let row = fields.session
812
837
  ? one<EndpointRow>(
813
838
  this.sql,
814
- `SELECT * FROM endpoints WHERE kind = 'agent' AND harness = ?
839
+ `SELECT * FROM endpoints WHERE kind = 'agent' AND (harness = ? OR ${GENERIC_SQL})
815
840
  AND (session = ? OR (? = 1 AND substr(session, 1, ?) = ? AND (connections = 0 OR hook_seen >= ?)))
816
841
  AND substr(handle, 1, ${GONE_PREFIX.length}) != '${GONE_PREFIX}' ORDER BY COALESCE(last_seen, created_at) DESC LIMIT 1`,
817
842
  harness,
@@ -829,7 +854,7 @@ export class RelayCore {
829
854
  if (row && presenceOf(row, now) === "offline") {
830
855
  this.sql.all(`UPDATE endpoints SET session = NULL WHERE id = ?`, row.id)
831
856
  row = undefined
832
- } else if (row && !transient && !sameProcess(row, fields.process)) {
857
+ } else if (row && !transient && !sameProcess(row, fields.process) && !(row.handle === wanted && reclaims(row, fields.process))) {
833
858
  row = undefined
834
859
  session = null
835
860
  } else if (
@@ -848,7 +873,7 @@ export class RelayCore {
848
873
  if (!held) {
849
874
  this.sql.all(`UPDATE endpoints SET handle = ? WHERE id = ?`, handle, hooks.id)
850
875
  settled = true
851
- } else if (held.kind === "agent" && held.connections === 0 && presenceOf(held, now) === "offline" && held.harness === harness) {
876
+ } else if (held.kind === "agent" && held.connections === 0 && presenceOf(held, now) === "offline" && harnessFits(held.harness, harness)) {
852
877
  if (this.restarts(held, this.placement(scope, now).parent, fields.process) || this.redeems(held, scope, { harness, ...fields })) {
853
878
  ;({ moved, stopBlocks } = this.leaveHooks(hooks, held.id))
854
879
  row = undefined
@@ -879,9 +904,8 @@ export class RelayCore {
879
904
  created = true
880
905
  } else if (
881
906
  held.kind === "agent" &&
882
- held.connections === 0 &&
883
- presenceOf(held, now) === "offline" &&
884
- held.harness === harness &&
907
+ ((held.connections === 0 && presenceOf(held, now) === "offline") || (!transient && this.outlived(held))) &&
908
+ harnessFits(held.harness, harness) &&
885
909
  held.id !== scope.subagent?.root &&
886
910
  !(scope.subagent && held.session?.startsWith("sub:") && held.session !== fields.session)
887
911
  ) {
@@ -897,7 +921,7 @@ export class RelayCore {
897
921
  held.kind === "agent" &&
898
922
  held.process === null &&
899
923
  held.session === null &&
900
- held.harness === harness &&
924
+ harnessFits(held.harness, harness) &&
901
925
  this.restarts(held, this.placement(scope, now).parent, null)
902
926
  ) {
903
927
  row = held
@@ -924,7 +948,7 @@ export class RelayCore {
924
948
  stop_blocks = CASE WHEN ? IS NOT NULL THEN ? WHEN ? IS NOT NULL AND session IS NOT ? THEN 0 ELSE stop_blocks END,
925
949
  briefed = CASE WHEN ? IS NOT NULL AND session IS NOT ? THEN NULL ELSE briefed END, introduced = CASE WHEN ? IS NOT NULL AND session IS NOT ? THEN NULL ELSE introduced END,
926
950
  directive_holds = CASE WHEN ? IS NOT NULL AND session IS NOT ? THEN NULL ELSE directive_holds END WHERE id = ?`,
927
- harness,
951
+ keptHarness(row.harness, harness),
928
952
  fields.model,
929
953
  fields.cwd,
930
954
  fields.parent,
@@ -1062,7 +1086,7 @@ export class RelayCore {
1062
1086
  if ((held.session !== null || held.process !== null) && presenceOf(held, now) !== "offline") {
1063
1087
  return new RelayError("unauthorized", `${handle} is held by a live ${held.harness} session: act as it from that session's own shell, no --as needed`)
1064
1088
  }
1065
- if (held.harness !== harness) return new RelayError("unauthorized", `${handle} is a ${held.harness} endpoint: pass --harness ${held.harness}`)
1089
+ if (!harnessFits(held.harness, harness)) return new RelayError("unauthorized", `${handle} is a ${held.harness} endpoint: pass --harness ${held.harness}`)
1066
1090
  return new RelayError("unauthorized", `${handle} is placed in another tree or under another parent than this shell`)
1067
1091
  }
1068
1092
 
@@ -1119,6 +1143,12 @@ export class RelayCore {
1119
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
1120
1144
  * `row` itself from `row`'s own process; never under `row` or elsewhere in its tree
1121
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
+
1122
1152
  private restarts(row: EndpointRow, parent: EndpointRow | null, process: string | null): boolean {
1123
1153
  if (parent?.id === row.id) return process !== null && row.process !== null && sameHarness(row.process, process)
1124
1154
  return (parent?.id ?? null) === row.parent_ep || (parent === null && row.linked !== null)
@@ -1132,7 +1162,7 @@ export class RelayCore {
1132
1162
  const spent = this.spentGrant(scope)
1133
1163
  if (spent?.used_by !== row.id) return false
1134
1164
  const lineage = this.lineageParent(scope.lineage)
1135
- if (!lineage) return row.harness === claimant.harness && spent.spent_cwd !== null && spent.spent_cwd === claimant.cwd
1165
+ if (!lineage) return harnessFits(row.harness, claimant.harness) && spent.spent_cwd !== null && spent.spent_cwd === claimant.cwd
1136
1166
  return this.restarts(row, lineage, claimant.process) || this.ancestorsOf(row).includes(lineage.id)
1137
1167
  }
1138
1168
 
@@ -1300,7 +1330,7 @@ export class RelayCore {
1300
1330
  endpoint,
1301
1331
  )
1302
1332
  const row = this.mustEndpoint(endpoint)
1303
- 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
1304
1334
  this.sql.all(`UPDATE endpoints SET status_state = NULL, status_note = NULL WHERE id = ?`, endpoint)
1305
1335
  return true
1306
1336
  }
@@ -1317,6 +1347,7 @@ export class RelayCore {
1317
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`,
1318
1348
  this.now() - (LIMITS.hookIdleS - LIMITS.restartGraceS) * 1000,
1319
1349
  )
1350
+ this.sql.all(`UPDATE endpoints SET wakers = 0, waker_left = ? WHERE wakers > 0`, this.now())
1320
1351
  }
1321
1352
 
1322
1353
  whoami(endpoint: string): EndpointView {
@@ -1346,7 +1377,7 @@ export class RelayCore {
1346
1377
  const shown = viewer ? new Set(this.visibleRows(viewer).map((row) => row.id)) : undefined
1347
1378
  const peeker = viewer ?? (filters.peekable === true ? this.byHandle(HUMAN_HANDLE)! : undefined)
1348
1379
  return this.listed()
1349
- .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))
1350
1381
  .filter((row) => !filters.harness || row.harness === filters.harness)
1351
1382
  .filter((row) => !filters.kind || row.kind === filters.kind)
1352
1383
  .filter((row) => !viewer || row.id === viewer.id || this.mayMessage(viewer, row, viewer.kind === "human"))
@@ -1463,6 +1494,21 @@ export class RelayCore {
1463
1494
  return true
1464
1495
  }
1465
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
+
1466
1512
  /**
1467
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.
1468
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
@@ -1782,7 +1828,7 @@ export class RelayCore {
1782
1828
  const dark = recipients.filter(
1783
1829
  (r) =>
1784
1830
  r.kind === "agent" &&
1785
- presenceOf(r, now) === "offline" &&
1831
+ shownPresence(r, now) === "offline" &&
1786
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)) &&
1787
1833
  !this.unjoinedSpawn(sender, r.handle, now),
1788
1834
  )
@@ -2042,7 +2088,7 @@ export class RelayCore {
2042
2088
  private reach(sender: EndpointRow, recipient: EndpointRow, offline: boolean, now: number): Reach | undefined {
2043
2089
  if (recipient.kind !== "agent" || !this.localRow(recipient)) return undefined
2044
2090
  const done = recipient.status_state === "done"
2045
- 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"
2046
2092
  if (done) return "ended"
2047
2093
  return offline ? "offline" : "running"
2048
2094
  }
@@ -2495,7 +2541,7 @@ export class RelayCore {
2495
2541
  continue
2496
2542
  }
2497
2543
  const row = this.byId(grant.used_by)
2498
- 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
2499
2545
  workers.set(row.id, row)
2500
2546
  if (grant.pushed_at !== null && grant.pushed_at > now - LIMITS.pushGapS * 1000) paced.add(row.id)
2501
2547
  }
@@ -2897,10 +2943,10 @@ export class RelayCore {
2897
2943
  .filter((row) =>
2898
2944
  present
2899
2945
  ? present.includes(row.id)
2900
- : 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,
2901
2947
  )
2902
2948
  const gone = (row: EndpointRow): boolean =>
2903
- presenceOf(row, now) === "offline" &&
2949
+ shownPresence(row, now) === "offline" &&
2904
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)
2905
2951
  if (!rows.length || !rows.every(gone)) return null
2906
2952
  return rows.map((row) => ({ address: this.address(row), state: row.status_state as StatusState | null }))
@@ -3014,7 +3060,7 @@ export class RelayCore {
3014
3060
  for (const row of this.sql.all<EndpointRow>(
3015
3061
  `SELECT * FROM endpoints WHERE kind = 'agent' AND connections = 0 AND status_state IN ('working', 'waiting')`,
3016
3062
  )) {
3017
- if (this.localRow(row) && presenceOf(row, now) === "offline")
3063
+ if (this.localRow(row) && shownPresence(row, now) === "offline")
3018
3064
  this.sql.all(`UPDATE endpoints SET status_state = NULL, status_note = NULL WHERE id = ?`, row.id)
3019
3065
  }
3020
3066
  }
@@ -5074,7 +5120,7 @@ export class RelayCore {
5074
5120
 
5075
5121
  /** with `shown`, the ids a viewer sees: a parent outside it is null */
5076
5122
  protected view(row: EndpointRow, shown?: Set<string>): EndpointView {
5077
- const presence = presenceOf(row, this.now())
5123
+ const presence = shownPresence(row, this.now())
5078
5124
  return {
5079
5125
  id: row.id,
5080
5126
  address: this.address(row),
@@ -5310,6 +5356,11 @@ const ANY_PROCESS_KEY = /^pid:\d+(:[A-Za-z0-9:-]{1,64})?$/
5310
5356
  * whether a connecting claim from the harness process `key` may join a session endpoint: no connection holds it, the harness process that last
5311
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
5312
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
+
5313
5364
  function sameProcess(row: EndpointRow, key: string | null): boolean {
5314
5365
  if (row.connections > 0 || key === null || row.process === null) return false
5315
5366
  return row.token_hash === null ? sameHarness(row.process, key) : row.process === key && PROCESS_KEY.test(key)
@@ -5374,6 +5425,17 @@ function presenceOf(row: EndpointRow, now: number): Presence {
5374
5425
  return row.hook_seen >= now - LIMITS.hookIdleS * 1000 ? "idle" : "offline"
5375
5426
  }
5376
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
+
5377
5439
  function pairKey(a: string, b: string): string {
5378
5440
  return a < b ? `${a}|${b}` : `${b}|${a}`
5379
5441
  }
package/src/protocol.ts CHANGED
@@ -34,7 +34,7 @@ export const LIMITS = {
34
34
  hookEndedGraceS: 30,
35
35
  restartGraceS: 60,
36
36
  injectMessages: 5,
37
- injectBytes: 4096,
37
+ injectBytes: 7168,
38
38
  pushBytes: 20480,
39
39
  /** the stops a session's push may continue between two prompts */
40
40
  pushes: 20,
@@ -144,6 +144,21 @@ export function validHarness(harness: string): boolean {
144
144
  return HARNESS.test(harness)
145
145
  }
146
146
 
147
+ /** harness names of the path a claim came by (a bare CLI, hook or MCP shim), not of the agent behind it */
148
+ export const GENERIC_HARNESSES: ReadonlySet<string> = new Set(["cli", "hook", "mcp", "shell"])
149
+
150
+ const generic = (harness: string | null): boolean => harness === null || GENERIC_HARNESSES.has(harness)
151
+
152
+ /** whether a claim of harness `claimed` may take a row of harness `held`: the same harness, or a generic row, which heals to the claim's */
153
+ export function harnessFits(held: string | null, claimed: string): boolean {
154
+ return held === claimed || generic(held)
155
+ }
156
+
157
+ /** the harness a claimed row keeps: the claim's, except that a generic claim never replaces a known harness */
158
+ export function keptHarness(held: string | null, claimed: string): string {
159
+ return generic(claimed) && !generic(held) ? held! : claimed
160
+ }
161
+
147
162
  export interface Claim {
148
163
  handle?: string
149
164
  harness?: string