@scp-dev/core 0.4.0 → 0.5.0

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
@@ -387,6 +387,14 @@ declare const LIMITS: {
387
387
  readonly policyLoadsKept: 8;
388
388
  /** the prompts between two refreshes of a title a hook inferred */
389
389
  readonly titleRefreshTurns: 5;
390
+ /** the least time between two wake hook runs for one hibernated endpoint */
391
+ readonly wakeDebounceS: 60;
392
+ /** the longest backoff after failed wake hook runs */
393
+ readonly wakeBackoffMaxS: 900;
394
+ /** the longest opaque wake reference a provider stores with a hibernation */
395
+ readonly wakeRefChars: 512;
396
+ /** the longest wake time a provider may announce with a hibernation */
397
+ readonly wakeEtaMaxS: 3600;
390
398
  };
391
399
  type ErrorCode = "not_found" | "inbox_full" | "rate_limited" | "loop_guard" | "data_class_refused" | "secret_refused" | "policy_refused" | "too_large" | "invalid_params" | "unauthorized" | "method_not_found" | "scope_refused" | "unavailable" | "judge_refused" | "not_live";
392
400
  declare class RelayError extends Error {
@@ -401,11 +409,21 @@ declare class RelayError extends Error {
401
409
  type EndpointKind = "agent" | "human" | "system";
402
410
  type MessageKind = "message" | "request" | "reply" | "status";
403
411
  type DataClass = "public" | "private";
404
- type Presence = "online" | "idle" | "offline";
412
+ /** `hibernated`: a provider suspended the session; a message runs its wake hook and the session drains its queue once it is back */
413
+ type Presence = "online" | "idle" | "offline" | "hibernated";
414
+ declare const PRESENCES: readonly Presence[];
405
415
  type StatusState = "working" | "waiting" | "blocked" | "done";
406
- /** how an idle agent is woken by a message: a channel push, the pi extension, the OpenCode plugin's waiter, codex app-server, the session inbox, a headless resume or muse serve; `none` reads at its next turn */
407
- type Wake = "channels" | "pi" | "opencode-plugin" | "codex-app-server" | "claude-inbox" | "agy-resume" | "grok-resume" | "muse-serve" | "none";
416
+ /**
417
+ * how an idle agent is woken by a message: a channel push, the pi extension, the OpenCode plugin's waiter, codex app-server, the session inbox,
418
+ * a headless resume or muse serve; `none` reads at its next turn; `hook:<provider>` (shown only, never set this way) a hibernated agent's provider wake hook
419
+ */
420
+ type Wake = "channels" | "pi" | "opencode-plugin" | "codex-app-server" | "claude-inbox" | "agy-resume" | "grok-resume" | "muse-serve" | "none" | `hook:${string}`;
408
421
  declare const WAKES: readonly Wake[];
422
+ /** why a provider declined to wake a hibernated agent, a short token it picks (e.g. `policy`, `budget`): not a failure, and not retried for the same message */
423
+ type WakeDecline = string;
424
+ declare const WAKE_DECLINE: RegExp;
425
+ /** `value` when it is a decline reason, else null */
426
+ declare function wakeDeclineOf(value: unknown): WakeDecline | null;
409
427
  type ActivityKind = "session_start" | "tool" | "stop" | "note" | "session_end";
410
428
  declare const ACTIVITY_KINDS: readonly ActivityKind[];
411
429
  interface ActivityRow {
@@ -462,6 +480,8 @@ interface Claim {
462
480
  in?: string[];
463
481
  out?: string[];
464
482
  groups?: string[];
483
+ /** an agent address a root claim links under, as a join under does; a refused link keeps the claim, audited */
484
+ under?: string;
465
485
  }
466
486
  type ScopeDirection = "in" | "out";
467
487
  type ScopePreset = "open" | "tree" | "parent" | "none";
@@ -581,6 +601,10 @@ interface EndpointView {
581
601
  peekable?: PeekLevel;
582
602
  /** an agent's: how an idle agent is woken by a message; null or `none`: it reads at its next turn */
583
603
  wake?: Wake | null;
604
+ /** a hibernated agent's last wake hook failure: the reason */
605
+ wake_failed?: string;
606
+ /** a hibernated agent's provider declined the last wake */
607
+ wake_declined?: WakeDecline;
584
608
  }
585
609
  type PeekLevel = "local";
586
610
  interface PeekTurn {
@@ -756,7 +780,8 @@ interface SendResult {
756
780
  /**
757
781
  * `queued`: the recipient is not connected now; `offline`: it has been gone past a short grace, so a client polling between waits is not
758
782
  * offline; `state`: an agent recipient's {@link Reach} on this machine (an older broker leaves it out); `wake`: how it is woken, only when
759
- * it can be; `idle`: its presence is idle
783
+ * it can be, `declined` when its provider declined the last wake; `idle`: its presence is idle; `hibernated`: it is hibernated and
784
+ * queued, its provider's wake hook resumes it; `wake_eta_s`: how long its provider said a wake takes
760
785
  */
761
786
  deliveries: {
762
787
  endpoint: string;
@@ -766,8 +791,10 @@ interface SendResult {
766
791
  queued?: boolean;
767
792
  last_seen: string | null;
768
793
  state?: Reach;
769
- wake?: Wake;
794
+ wake?: Wake | "declined";
770
795
  idle?: true;
796
+ hibernated?: true;
797
+ wake_eta_s?: number;
771
798
  }[];
772
799
  /** one line per agent recipient no live session holds: `queued: no live session holds <address>` */
773
800
  warnings?: string[];
@@ -1128,6 +1155,40 @@ interface EndpointRow {
1128
1155
  wakers: number;
1129
1156
  /** when the last hook connection that set the wake closed */
1130
1157
  waker_left: number | null;
1158
+ /** the provider whose wake hook resumes the hibernated session; null when it is not hibernated */
1159
+ hibernate: string | null;
1160
+ /** the provider's opaque reference to the suspended session, handed to its wake hook */
1161
+ wake_ref: string | null;
1162
+ hibernated_at: number | null;
1163
+ /** when a wake hook last ran for it */
1164
+ wake_tried: number | null;
1165
+ /** the wake hook runs that failed in a row */
1166
+ wake_fails: number;
1167
+ /** `wake_failed: <reason>` after a failed run */
1168
+ wake_note: string | null;
1169
+ /** the seconds the provider said a wake takes */
1170
+ wake_eta: number | null;
1171
+ /** the reason the provider declined a wake, a short token such as `policy` or `budget` */
1172
+ wake_declined: string | null;
1173
+ /** the message, and its sender, the decline was for */
1174
+ declined_message: string | null;
1175
+ declined_from: string | null;
1176
+ }
1177
+ /** a wake hook run that is due: the hibernated endpoint, its provider and ref, and the newest unread message's metadata (never a body) */
1178
+ interface WakeJob {
1179
+ endpoint: string;
1180
+ address: string;
1181
+ handle: string;
1182
+ provider: string;
1183
+ ref: string | null;
1184
+ message: {
1185
+ id: string;
1186
+ from: string | null;
1187
+ kind: MessageKind;
1188
+ thread: string;
1189
+ created_at: string;
1190
+ };
1191
+ unread: number;
1131
1192
  }
1132
1193
  interface MessageRow {
1133
1194
  id: string;
@@ -1167,6 +1228,8 @@ interface CoreOptions {
1167
1228
  /** the short hostname, matched by `machine:` selectors when `machine` is not set */
1168
1229
  hostname?: string;
1169
1230
  }
1231
+ /** a wake provider's name, as hibernate takes it and a waker is configured under */
1232
+ declare const WAKE_PROVIDER: RegExp;
1170
1233
  declare function randomHex(bytes: number): string;
1171
1234
  declare function newId(prefix: string, now?: number): string;
1172
1235
  declare function cleanText(value: unknown, field: string, max?: number): string | null;
@@ -1228,6 +1291,8 @@ declare class RelayCore {
1228
1291
  created?: true;
1229
1292
  subagent?: true;
1230
1293
  };
1294
+ /** links a claimed root under `under` as a join under does; a refusal keeps the claim and is audited `claim_under_refused` */
1295
+ private claimUnder;
1231
1296
  /** a claim's own rules per direction, as its `scope`, `allow`, `in` and `out` expand */
1232
1297
  protected claimRules(claim: Claim): ScopeRules;
1233
1298
  /**
@@ -1492,6 +1557,58 @@ declare class RelayCore {
1492
1557
  };
1493
1558
  /** the harness session key an endpoint holds, if any */
1494
1559
  sessionOf(endpoint: string): string | null;
1560
+ /**
1561
+ * marks a local agent hibernated by `wake`, the provider whose wake hook resumes it, with its opaque `ref` and the seconds a wake takes
1562
+ * (`eta_s`); `none` clears it. The agent's own session or the human (no terminal needed: a provider's daemon has none) naming the endpoint.
1563
+ * `known` is whether a waker is configured for a provider
1564
+ */
1565
+ hibernate(actor: ScopeActor, params: {
1566
+ endpoint?: unknown;
1567
+ wake?: unknown;
1568
+ ref?: unknown;
1569
+ eta_s?: unknown;
1570
+ }, options?: {
1571
+ known?: (provider: string) => boolean;
1572
+ }): {
1573
+ endpoint: EndpointView;
1574
+ };
1575
+ /** the human marks every live local agent but subagents hibernated by one provider, as before suspending the machine they run on */
1576
+ hibernateAll(actor: ScopeActor, params: {
1577
+ wake?: unknown;
1578
+ ref?: unknown;
1579
+ eta_s?: unknown;
1580
+ }, options?: {
1581
+ known?: (provider: string) => boolean;
1582
+ }): {
1583
+ endpoints: EndpointView[];
1584
+ };
1585
+ private hibernation;
1586
+ private setHibernation;
1587
+ /**
1588
+ * the wake hook run a delivery to a hibernated local agent is due, recorded as tried: none while it is not hibernated, has no unread
1589
+ * message, ran within LIMITS.wakeDebounceS (after failures, a doubling backoff up to LIMITS.wakeBackoffMaxS), or the provider declined a
1590
+ * wake for the newest unread message or for another from its sender. The newest unread message's metadata only, never its body
1591
+ */
1592
+ wakeDue(endpoint: string, now?: number): WakeJob | null;
1593
+ /**
1594
+ * how a wake hook run ended, audited without the ref: a failure counts toward the backoff and shows `wake_failed` while the message stays
1595
+ * queued; a decline (`declined`, for the message `message`) shows `wake_declined`, counts toward nothing and holds further runs for that
1596
+ * message and its sender until a newer message of another sender or a new hibernate; a success resets both (the hibernation clears once the
1597
+ * session claims its endpoint again). Whether the shown note changed
1598
+ */
1599
+ wakeResult(endpoint: string, result: {
1600
+ ok: boolean;
1601
+ code?: number | null;
1602
+ error?: string | null;
1603
+ ms: number;
1604
+ declined?: WakeDecline;
1605
+ message?: string;
1606
+ }): boolean;
1607
+ /** whether the row shows hibernated: a subclass also counts what another machine mirrored */
1608
+ protected hibernated(row: EndpointRow): boolean;
1609
+ /** the hibernated session is back, or gone: its hibernation clears */
1610
+ private wakeDone;
1611
+ private clearHibernation;
1495
1612
  /**
1496
1613
  * `origin.tty` is recorded on messages the human inbox sends: whether its client said a terminal was attached. `origin.unverified`: the human
1497
1614
  * sends without a terminal, so the scopes check it like any unplaced sender
@@ -2214,7 +2331,7 @@ type Delivery = Pick<SendResult["deliveries"][number], "address" | "offline" | "
2214
2331
  * when the broker named no state. With `late`, only those that will not see it at their next turn boundary
2215
2332
  */
2216
2333
  declare function reachLines(deliveries: Delivery[], late?: boolean): string[];
2217
- /** an endpoint's presence as a line shows it: an idle agent no message wakes reads at its next turn */
2334
+ /** an endpoint's presence as a line shows it: an idle agent no message wakes reads at its next turn; a hibernated one is wakeable */
2218
2335
  declare function presenceText(e: Pick<EndpointView, "kind" | "presence" | "wake">): string;
2219
2336
  /** an agent's wake as a column shows it, `turn` when it reads at its next turn; `-` for any other endpoint */
2220
2337
  declare function wakeText(e: Pick<EndpointView, "kind" | "wake">): string;
@@ -2575,4 +2692,4 @@ declare function sha256Hex(text: string): string;
2575
2692
  /** the UTF-8 byte length of text, the unit every byte limit counts in */
2576
2693
  declare function utf8Bytes(text: string): number;
2577
2694
 
2578
- export { ACCOUNT_HANDLE, ACTIVITY_KINDS, type ActivityKind, type ActivityRow, type Admission, type AdoptResult, 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 };
2695
+ export { ACCOUNT_HANDLE, ACTIVITY_KINDS, type ActivityKind, type ActivityRow, type Admission, type AdoptResult, 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, PRESENCES, 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, WAKE_DECLINE, WAKE_PROVIDER, type Wake, type WakeDecline, type WakeJob, 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, wakeDeclineOf, wakeText, widenText, wrapMessage, wrapPeek, xlimits };
package/dist/index.js CHANGED
@@ -166,7 +166,15 @@ var LIMITS = {
166
166
  groupIdleS: 86400,
167
167
  policyLoadsKept: 8,
168
168
  /** the prompts between two refreshes of a title a hook inferred */
169
- titleRefreshTurns: 5
169
+ titleRefreshTurns: 5,
170
+ /** the least time between two wake hook runs for one hibernated endpoint */
171
+ wakeDebounceS: 60,
172
+ /** the longest backoff after failed wake hook runs */
173
+ wakeBackoffMaxS: 900,
174
+ /** the longest opaque wake reference a provider stores with a hibernation */
175
+ wakeRefChars: 512,
176
+ /** the longest wake time a provider may announce with a hibernation */
177
+ wakeEtaMaxS: 3600
170
178
  };
171
179
  var RelayError = class extends Error {
172
180
  constructor(code, message, retryAfterS, detail) {
@@ -179,6 +187,7 @@ var RelayError = class extends Error {
179
187
  retryAfterS;
180
188
  detail;
181
189
  };
190
+ var PRESENCES = ["online", "idle", "offline", "hibernated"];
182
191
  var WAKES = [
183
192
  "channels",
184
193
  "pi",
@@ -190,6 +199,10 @@ var WAKES = [
190
199
  "muse-serve",
191
200
  "none"
192
201
  ];
202
+ var WAKE_DECLINE = /^[a-z][a-z0-9_-]{0,31}$/;
203
+ function wakeDeclineOf(value) {
204
+ return typeof value === "string" && WAKE_DECLINE.test(value) ? value : null;
205
+ }
193
206
  var ACTIVITY_KINDS = ["session_start", "tool", "stop", "note", "session_end"];
194
207
  var MESSAGE_KINDS = ["message", "request", "reply", "status"];
195
208
  var STATUS_STATES = ["working", "waiting", "blocked", "done"];
@@ -905,6 +918,7 @@ function reachLines(deliveries, late = false) {
905
918
  return deliveries.filter((d) => !late || d.state && d.state !== "running").map((d) => d.state ? `${d.address}: ${reachText({ ...d, state: d.state })}` : deliveryText(d));
906
919
  }
907
920
  function presenceText(e) {
921
+ if (e.presence === "hibernated") return "hibernated (wakeable)";
908
922
  return e.kind === "agent" && e.presence === "idle" && (!e.wake || e.wake === "none") ? "idle (next turn)" : e.presence;
909
923
  }
910
924
  function wakeText(e) {
@@ -1234,6 +1248,7 @@ var MIGRATION = [
1234
1248
  ];
1235
1249
  var GRANT_TOKEN = /^krt_[0-9a-f]{64}$/;
1236
1250
  var HARNESS_SESSION = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
1251
+ var WAKE_PROVIDER = /^[a-z0-9][a-z0-9-]{0,31}$/;
1237
1252
  var TRANSCRIPT_CHARS = 4096;
1238
1253
  function transcriptPath(path, session) {
1239
1254
  if (typeof path !== "string" || path.length > TRANSCRIPT_CHARS || !path.startsWith("/") || /[\u0000-\u001f\u007f]/.test(path)) return null;
@@ -1372,7 +1387,17 @@ var RelayCore = class {
1372
1387
  ["linked", "INTEGER"],
1373
1388
  ["wake", "TEXT"],
1374
1389
  ["wakers", "INTEGER NOT NULL DEFAULT 0"],
1375
- ["waker_left", "INTEGER"]
1390
+ ["waker_left", "INTEGER"],
1391
+ ["hibernate", "TEXT"],
1392
+ ["wake_ref", "TEXT"],
1393
+ ["hibernated_at", "INTEGER"],
1394
+ ["wake_tried", "INTEGER"],
1395
+ ["wake_fails", "INTEGER NOT NULL DEFAULT 0"],
1396
+ ["wake_note", "TEXT"],
1397
+ ["wake_eta", "INTEGER"],
1398
+ ["wake_declined", "TEXT"],
1399
+ ["declined_message", "TEXT"],
1400
+ ["declined_from", "TEXT"]
1376
1401
  ]) {
1377
1402
  if (!endpointColumns.includes(column)) this.sql.all(`ALTER TABLE endpoints ADD COLUMN ${column} ${type}`);
1378
1403
  }
@@ -1537,7 +1562,8 @@ var RelayCore = class {
1537
1562
  if (fields.process !== null && !ANY_PROCESS_KEY.test(fields.process)) throw new RelayError("invalid_params", "process is a key pid:<pid>[:<start time>]");
1538
1563
  if (fields.harness_session !== null && !HARNESS_SESSION.test(fields.harness_session))
1539
1564
  throw new RelayError("invalid_params", "harness_session is a harness's session id: letters, digits, . _ : -");
1540
- const leaked = [wanted, fields.model, fields.cwd, fields.parent, fields.labels, fields.session, fields.harness_session].map((v) => v ? findSecret(v) : null).find(Boolean);
1565
+ const under = cleanText(claim.under, "under", 128) || null;
1566
+ const leaked = [wanted, fields.model, fields.cwd, fields.parent, fields.labels, fields.session, fields.harness_session, under].map((v) => v ? findSecret(v) : null).find(Boolean);
1541
1567
  if (leaked) throw new RelayError("secret_refused", `the claim looks like it carries a credential (${leaked})`);
1542
1568
  if (claim.grant !== void 0 && (typeof claim.grant !== "string" || !GRANT_TOKEN.test(claim.grant)))
1543
1569
  throw new RelayError("invalid_params", "grant is a krt_ token");
@@ -1567,17 +1593,33 @@ var RelayCore = class {
1567
1593
  root.id
1568
1594
  );
1569
1595
  if (subagent.stop && !had) throw new RelayError("not_found", "that subagent has no endpoint: it never ran a tool");
1570
- const claimed = this.claimRow(scope.handle, harness, fields, true, scope);
1571
- const titled = this.takeSubagentTitle(root.id, subagent.id, claimed.endpoint.id);
1572
- const resumed = !subagent.stop && one(this.sql, `UPDATE endpoints SET status_state = 'working' WHERE id = ? AND status_state = 'done' RETURNING id`, claimed.endpoint.id);
1573
- if (!titled && !resumed) return { ...claimed, subagent: true };
1574
- return { ...claimed, endpoint: this.view(this.byId(claimed.endpoint.id)), subagent: true };
1596
+ const claimed2 = this.claimRow(scope.handle, harness, fields, true, scope);
1597
+ const titled = this.takeSubagentTitle(root.id, subagent.id, claimed2.endpoint.id);
1598
+ const resumed = !subagent.stop && one(this.sql, `UPDATE endpoints SET status_state = 'working' WHERE id = ? AND status_state = 'done' RETURNING id`, claimed2.endpoint.id);
1599
+ if (!titled && !resumed) return { ...claimed2, subagent: true };
1600
+ return { ...claimed2, endpoint: this.view(this.byId(claimed2.endpoint.id)), subagent: true };
1575
1601
  }
1576
1602
  const bound = scope.grant === null && !scope.exact ? this.boundGrant(scope, fields.process) : void 0;
1577
1603
  if (bound) Object.assign(scope, { grant: bound.token_hash, handle: bound.name ?? wanted });
1578
- return this.claimRow(scope.handle, harness, fields, options.transient === true, scope);
1604
+ const claimed = this.claimRow(scope.handle, harness, fields, options.transient === true, scope);
1605
+ return under === null ? claimed : this.claimUnder(claimed, under);
1579
1606
  });
1580
1607
  }
1608
+ /** links a claimed root under `under` as a join under does; a refusal keeps the claim and is audited `claim_under_refused` */
1609
+ claimUnder(claimed, under) {
1610
+ const me = this.mustEndpoint(claimed.endpoint.id);
1611
+ if (me.kind !== "agent" || !this.localRow(me) || me.parent_ep !== null || me.parent_addr !== null) return claimed;
1612
+ try {
1613
+ const parent = this.resolve(under);
1614
+ if (parent.kind === "agent" && !this.mayMessage(me, parent)) throw unreachable(under);
1615
+ this.link(me, parent, { by: this.address(me), auth: "session" });
1616
+ } catch (error) {
1617
+ if (!(error instanceof RelayError)) throw error;
1618
+ this.audit("claim_under_refused", me.id, null, { target: this.address(me), under, code: error.code });
1619
+ return claimed;
1620
+ }
1621
+ return { ...claimed, endpoint: this.view(this.byId(me.id)) };
1622
+ }
1581
1623
  /** a claim's own rules per direction, as its `scope`, `allow`, `in` and `out` expand */
1582
1624
  claimRules(claim) {
1583
1625
  const rules = {};
@@ -1768,6 +1810,7 @@ var RelayCore = class {
1768
1810
  if (created || fresh === "grant") this.place(this.byId(row.id), scope, now);
1769
1811
  else if (scope.grant !== null) this.redeem(this.byId(row.id), scope, now);
1770
1812
  if (scope.grant !== null) this.takePending(row.id);
1813
+ if (!transient) this.wakeDone(row.id, "claim");
1771
1814
  return { endpoint: this.view(this.byId(row.id)), ...token ? { token } : {}, ...created ? { created: true } : {} };
1772
1815
  }
1773
1816
  /**
@@ -1840,6 +1883,7 @@ var RelayCore = class {
1840
1883
  row.id
1841
1884
  );
1842
1885
  this.sql.all(`DELETE FROM peek_turns WHERE endpoint = ?`, row.id);
1886
+ this.wakeDone(row.id, "reset");
1843
1887
  let dropped = 0;
1844
1888
  if (kind === "grant") {
1845
1889
  const spawned = one(this.sql, `SELECT issuer, created_at FROM grants WHERE token_hash = ?`, scope.grant);
@@ -2132,7 +2176,7 @@ var RelayCore = class {
2132
2176
  const viewer = filters.viewer === void 0 ? void 0 : this.mustEndpoint(filters.viewer);
2133
2177
  const shown = viewer ? new Set(this.visibleRows(viewer).map((row) => row.id)) : void 0;
2134
2178
  const peeker = viewer ?? (filters.peekable === true ? this.byHandle(HUMAN_HANDLE) : void 0);
2135
- 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) => {
2179
+ return this.listed().filter((row) => filters.all || row.connections > 0 || row.kind === "human" || (row.last_seen ?? 0) >= cutoff || waking(row, now) || this.hibernated(row)).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) => {
2136
2180
  const view = this.view(row, shown);
2137
2181
  return peeker && this.peekableBy(peeker, row, viewer?.kind === "human") ? { ...view, peekable: row.peek } : view;
2138
2182
  }).filter((view) => !filters.presence || view.presence === filters.presence);
@@ -2309,6 +2353,7 @@ var RelayCore = class {
2309
2353
  const row = this.mustEndpoint(endpoint.id);
2310
2354
  if (handle !== void 0 && handle !== null && handle !== "") this.renameRow(row, handle, false);
2311
2355
  this.sql.all(`UPDATE endpoints SET hook_seen = ?, hook_ended = NULL WHERE id = ?`, this.now(), row.id);
2356
+ this.wakeDone(row.id, "join");
2312
2357
  this.audit("join", row.id, null, { handle: this.byId(row.id).handle });
2313
2358
  if (under !== void 0 && under !== null && under !== "") {
2314
2359
  const me = this.byId(row.id);
@@ -2586,6 +2631,7 @@ var RelayCore = class {
2586
2631
  row.id
2587
2632
  );
2588
2633
  this.sql.all(`UPDATE endpoints SET handle = ?, session = NULL, token_hash = NULL WHERE id = ?`, `${GONE_PREFIX}${held.id}`, held.id);
2634
+ this.wakeDone(held.id, "retired");
2589
2635
  this.dropPeek(held.id);
2590
2636
  this.scopeMemo?.ancestors.clear();
2591
2637
  this.scopeMemo?.limited.clear();
@@ -2612,6 +2658,7 @@ var RelayCore = class {
2612
2658
  throw new RelayError("scope_refused", `spawned by ${parent ? this.address(parent) : "another agent"}; a spawned worker stays joined while it runs`);
2613
2659
  }
2614
2660
  this.sql.all(`UPDATE endpoints SET session = NULL, token_hash = NULL, hook_seen = NULL, hook_ended = ?, wake = NULL WHERE id = ?`, this.now(), target.id);
2661
+ this.wakeDone(target.id, "leave");
2615
2662
  this.audit("leave", target.id, null, { handle: target.handle });
2616
2663
  return { endpoint: this.whoami(target.id), session: target.session };
2617
2664
  });
@@ -2620,6 +2667,171 @@ var RelayCore = class {
2620
2667
  sessionOf(endpoint) {
2621
2668
  return this.byId(endpoint)?.session ?? null;
2622
2669
  }
2670
+ /**
2671
+ * marks a local agent hibernated by `wake`, the provider whose wake hook resumes it, with its opaque `ref` and the seconds a wake takes
2672
+ * (`eta_s`); `none` clears it. The agent's own session or the human (no terminal needed: a provider's daemon has none) naming the endpoint.
2673
+ * `known` is whether a waker is configured for a provider
2674
+ */
2675
+ hibernate(actor, params, options = {}) {
2676
+ const me = this.actorRow(actor);
2677
+ const set = this.hibernation(params, options);
2678
+ if (actor.kind === "endpoint" && actor.auth === "lineage")
2679
+ throw new RelayError("scope_refused", "only the session itself hibernates: run this from the session's own shell");
2680
+ if (params.endpoint === void 0 && me.kind !== "agent") throw new RelayError("invalid_params", "the human names the endpoint");
2681
+ const target = params.endpoint === void 0 ? me : this.resolve(params.endpoint);
2682
+ if (actor.kind === "endpoint" && target.id !== me.id) throw new RelayError("scope_refused", "an agent hibernates only itself");
2683
+ if (target.kind !== "agent" || !this.localRow(target)) throw new RelayError("invalid_params", "only a local agent endpoint hibernates");
2684
+ return { endpoint: this.setHibernation(me, target, set) };
2685
+ }
2686
+ /** the human marks every live local agent but subagents hibernated by one provider, as before suspending the machine they run on */
2687
+ hibernateAll(actor, params, options = {}) {
2688
+ const me = this.actorRow(actor);
2689
+ if (actor.kind !== "human") throw new RelayError("scope_refused", "only the human hibernates every agent");
2690
+ const set = this.hibernation(params, options);
2691
+ const now = this.now();
2692
+ const targets = this.sql.all(
2693
+ `SELECT * FROM endpoints WHERE kind = 'agent' AND substr(handle, 1, ${GONE_PREFIX.length}) != '${GONE_PREFIX}'
2694
+ AND (session IS NULL OR substr(session, 1, 4) != 'sub:') ORDER BY handle`
2695
+ ).filter((row) => this.localRow(row) && (set.wake === "none" ? row.hibernate !== null : presenceOf(row, now) !== "offline"));
2696
+ return { endpoints: targets.map((row) => this.setHibernation(me, row, set)) };
2697
+ }
2698
+ hibernation(params, options) {
2699
+ const wake = params?.wake;
2700
+ if (typeof wake !== "string" || wake !== "none" && !WAKE_PROVIDER.test(wake))
2701
+ throw new RelayError("invalid_params", "wake is a provider name (lowercase letters, digits and hyphens, at most 32) or none");
2702
+ let ref = null;
2703
+ if (params.ref !== void 0 && params.ref !== null) {
2704
+ if (typeof params.ref !== "string" || params.ref.length > LIMITS.wakeRefChars || /[\u0000-\u001f\u007f]/.test(params.ref))
2705
+ throw new RelayError("invalid_params", `ref is a string of at most ${LIMITS.wakeRefChars} characters with no control characters`);
2706
+ if (findSecret(params.ref)) throw new RelayError("secret_refused", "the wake ref looks like it carries a credential");
2707
+ ref = params.ref;
2708
+ }
2709
+ let eta = null;
2710
+ if (params.eta_s !== void 0 && params.eta_s !== null) {
2711
+ if (typeof params.eta_s !== "number" || !Number.isInteger(params.eta_s) || params.eta_s < 1 || params.eta_s > LIMITS.wakeEtaMaxS)
2712
+ throw new RelayError("invalid_params", `eta_s is a whole number of seconds from 1 to ${LIMITS.wakeEtaMaxS}`);
2713
+ eta = params.eta_s;
2714
+ }
2715
+ if (wake !== "none" && options.known && !options.known(wake)) throw new RelayError("invalid_params", `no waker ${wake} is configured`);
2716
+ return { wake, ref, eta };
2717
+ }
2718
+ setHibernation(me, target, set) {
2719
+ const address = this.address(target);
2720
+ if (set.wake === "none") {
2721
+ if (target.hibernate !== null) {
2722
+ this.clearHibernation(target.id);
2723
+ this.audit("unhibernate", target.id, null, { by: this.address(me), target: address });
2724
+ }
2725
+ return this.view(this.byId(target.id));
2726
+ }
2727
+ this.clearHibernation(target.id);
2728
+ this.sql.all(
2729
+ `UPDATE endpoints SET hibernate = ?, wake_ref = ?, wake_eta = ?, hibernated_at = ? WHERE id = ?`,
2730
+ set.wake,
2731
+ set.ref,
2732
+ set.eta,
2733
+ this.now(),
2734
+ target.id
2735
+ );
2736
+ this.audit("hibernate", target.id, null, { by: this.address(me), target: address, provider: set.wake });
2737
+ return this.view(this.byId(target.id));
2738
+ }
2739
+ /**
2740
+ * the wake hook run a delivery to a hibernated local agent is due, recorded as tried: none while it is not hibernated, has no unread
2741
+ * message, ran within LIMITS.wakeDebounceS (after failures, a doubling backoff up to LIMITS.wakeBackoffMaxS), or the provider declined a
2742
+ * wake for the newest unread message or for another from its sender. The newest unread message's metadata only, never its body
2743
+ */
2744
+ wakeDue(endpoint, now = this.now()) {
2745
+ const row = this.byId(endpoint);
2746
+ if (!row?.hibernate || row.kind !== "agent" || !this.localRow(row)) return null;
2747
+ if (row.wake_tried !== null) {
2748
+ const waitS = row.wake_fails > 0 ? Math.min(LIMITS.wakeDebounceS * 2 ** row.wake_fails, LIMITS.wakeBackoffMaxS) : LIMITS.wakeDebounceS;
2749
+ if (now - row.wake_tried < waitS * 1e3) return null;
2750
+ }
2751
+ const newest = one(
2752
+ this.sql,
2753
+ `SELECT m.id, m.from_ep, m.kind, m.thread, m.created_at FROM deliveries d JOIN messages m ON m.id = d.message_id
2754
+ WHERE d.endpoint = ? AND d.acked_at IS NULL AND d.expires_at > ? ORDER BY d.seq DESC LIMIT 1`,
2755
+ endpoint,
2756
+ now
2757
+ );
2758
+ if (!newest) return null;
2759
+ if (row.wake_declined !== null && (newest.id === row.declined_message || newest.from_ep === row.declined_from)) return null;
2760
+ const unread = one(
2761
+ this.sql,
2762
+ `SELECT COUNT(*) AS n FROM deliveries WHERE endpoint = ? AND acked_at IS NULL AND expires_at > ?`,
2763
+ endpoint,
2764
+ now
2765
+ ).n;
2766
+ this.sql.all(`UPDATE endpoints SET wake_tried = ? WHERE id = ?`, now, endpoint);
2767
+ const sender = this.byId(newest.from_ep);
2768
+ return {
2769
+ endpoint,
2770
+ address: this.address(row),
2771
+ handle: row.handle,
2772
+ provider: row.hibernate,
2773
+ ref: row.wake_ref,
2774
+ message: { id: newest.id, from: sender ? this.address(sender) : null, kind: newest.kind, thread: newest.thread, created_at: iso(newest.created_at) },
2775
+ unread
2776
+ };
2777
+ }
2778
+ /**
2779
+ * how a wake hook run ended, audited without the ref: a failure counts toward the backoff and shows `wake_failed` while the message stays
2780
+ * queued; a decline (`declined`, for the message `message`) shows `wake_declined`, counts toward nothing and holds further runs for that
2781
+ * message and its sender until a newer message of another sender or a new hibernate; a success resets both (the hibernation clears once the
2782
+ * session claims its endpoint again). Whether the shown note changed
2783
+ */
2784
+ wakeResult(endpoint, result) {
2785
+ const row = this.byId(endpoint);
2786
+ if (!row) return false;
2787
+ const error = result.error ? redactSecrets(stripControl(result.error).replace(/\s+/g, " ").trim()).slice(0, 200) : null;
2788
+ const declined = result.ok ? null : wakeDeclineOf(result.declined);
2789
+ this.audit("wake", endpoint, null, {
2790
+ target: this.address(row),
2791
+ provider: row.hibernate,
2792
+ ok: result.ok,
2793
+ ...result.code !== void 0 && result.code !== null ? { code: result.code } : {},
2794
+ ms: Math.max(0, Math.round(result.ms)),
2795
+ ...declined ? { declined } : error ? { error } : {}
2796
+ });
2797
+ if (!row.hibernate) return false;
2798
+ if (declined) {
2799
+ const message = result.message ? one(this.sql, `SELECT from_ep FROM messages WHERE id = ?`, result.message) : void 0;
2800
+ this.sql.all(
2801
+ `UPDATE endpoints SET wake_declined = ?, declined_message = ?, declined_from = ?, wake_note = NULL, wake_tried = NULL WHERE id = ?`,
2802
+ declined,
2803
+ result.message ?? null,
2804
+ message?.from_ep ?? null,
2805
+ endpoint
2806
+ );
2807
+ return declined !== row.wake_declined || row.wake_note !== null;
2808
+ }
2809
+ const note = result.ok ? null : `wake_failed: ${(error ?? (result.code !== void 0 && result.code !== null ? `exit ${result.code}` : "failed")).slice(0, 120)}`;
2810
+ this.sql.all(
2811
+ `UPDATE endpoints SET wake_fails = ${result.ok ? "0" : "wake_fails + 1"}, wake_note = ?, wake_declined = NULL, declined_message = NULL, declined_from = NULL WHERE id = ?`,
2812
+ note,
2813
+ endpoint
2814
+ );
2815
+ return note !== row.wake_note || row.wake_declined !== null;
2816
+ }
2817
+ /** whether the row shows hibernated: a subclass also counts what another machine mirrored */
2818
+ hibernated(row) {
2819
+ return row.hibernate !== null;
2820
+ }
2821
+ /** the hibernated session is back, or gone: its hibernation clears */
2822
+ wakeDone(endpoint, how) {
2823
+ const row = this.byId(endpoint);
2824
+ if (!row?.hibernate) return;
2825
+ this.clearHibernation(endpoint);
2826
+ this.audit("wake_done", endpoint, null, { target: this.address(row), how });
2827
+ }
2828
+ clearHibernation(endpoint) {
2829
+ this.sql.all(
2830
+ `UPDATE endpoints SET hibernate = NULL, wake_ref = NULL, wake_eta = NULL, hibernated_at = NULL, wake_tried = NULL, wake_fails = 0, wake_note = NULL,
2831
+ wake_declined = NULL, declined_message = NULL, declined_from = NULL WHERE id = ?`,
2832
+ endpoint
2833
+ );
2834
+ }
2623
2835
  // messages
2624
2836
  /**
2625
2837
  * `origin.tty` is recorded on messages the human inbox sends: whether its client said a terminal was attached. `origin.unverified`: the human
@@ -2682,8 +2894,9 @@ var RelayCore = class {
2682
2894
  const dark = recipients.filter(
2683
2895
  (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)
2684
2896
  );
2685
- if (params.live === true && dark.length)
2686
- throw new RelayError("not_live", `no live session holds ${dark.map((r) => this.address(r)).join(", ")}: nothing was queued`);
2897
+ const asleep = params.live === true ? recipients.filter((r) => r.kind === "agent" && r.hibernate !== null) : [];
2898
+ if (params.live === true && (dark.length || asleep.length))
2899
+ throw new RelayError("not_live", `no live session holds ${[...dark, ...asleep].map((r) => this.address(r)).join(", ")}: nothing was queued`);
2687
2900
  const warnings = dark.map((r) => `queued: no live session holds ${this.address(r)}`);
2688
2901
  const pending = [...unjoined.values()].map((grant) => ({ key: pendingKey(grant.token_hash), address: `${this.user}/${grant.name}` }));
2689
2902
  const reached = recipients.length + pending.length;
@@ -2888,8 +3101,9 @@ var RelayCore = class {
2888
3101
  this.sql.all(`INSERT INTO pair_events (from_ep, to_ep, at) VALUES (?, ?, ?)`, from, recipient.id, now);
2889
3102
  if (treeSends && recipient.kind !== "human") this.sql.all(`INSERT INTO tree_events (tree, at) VALUES (?, ?)`, tree, now);
2890
3103
  const view = this.view(recipient);
2891
- const queued = view.presence === "offline";
2892
- const offline = queued && now - Math.max(recipient.last_seen ?? 0, recipient.hook_seen ?? 0) >= ASK_ENDED_GRACE_MS;
3104
+ const hibernated = view.presence === "hibernated";
3105
+ const queued = view.presence === "offline" || hibernated;
3106
+ const offline = view.presence === "offline" && now - Math.max(recipient.last_seen ?? 0, recipient.hook_seen ?? 0) >= ASK_ENDED_GRACE_MS;
2893
3107
  const state = this.reach(sender, recipient, offline, now);
2894
3108
  deliveries.push({
2895
3109
  endpoint: recipient.id,
@@ -2899,8 +3113,10 @@ var RelayCore = class {
2899
3113
  ...queued ? { queued } : {},
2900
3114
  last_seen: view.last_seen,
2901
3115
  ...state ? { state } : {},
2902
- ...view.wake ? { wake: view.wake } : {},
2903
- ...view.presence === "idle" ? { idle: true } : {}
3116
+ ...hibernated && recipient.wake_declined ? { wake: "declined" } : view.wake ? { wake: view.wake } : {},
3117
+ ...view.presence === "idle" ? { idle: true } : {},
3118
+ ...hibernated ? { hibernated: true } : {},
3119
+ ...hibernated && recipient.wake_eta ? { wake_eta_s: recipient.wake_eta } : {}
2904
3120
  });
2905
3121
  }
2906
3122
  for (const { key, address } of pending) {
@@ -3854,6 +4070,7 @@ var RelayCore = class {
3854
4070
  endpoint
3855
4071
  );
3856
4072
  } else if (params.kind === "session_start") {
4073
+ this.wakeDone(endpoint, "session_start");
3857
4074
  const fresh = own && summary !== "compact" && summary !== "resume";
3858
4075
  this.sql.all(
3859
4076
  `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,
@@ -5630,7 +5847,9 @@ ${stderr}` : "";
5630
5847
  presence,
5631
5848
  status: row.status_state ? { state: row.status_state, note: row.status_note ?? "" } : null,
5632
5849
  last_seen: iso(row.last_seen),
5633
- ...row.kind === "agent" ? { ...this.placeView(row, shown), wake: wakeOf(row) } : {}
5850
+ ...row.kind === "agent" ? { ...this.placeView(row, shown), wake: row.hibernate ? `hook:${row.hibernate}` : wakeOf(row) } : {},
5851
+ ...row.hibernate && row.wake_note ? { wake_failed: row.wake_note.replace(/^wake_failed: /, "") } : {},
5852
+ ...row.hibernate && row.wake_declined ? { wake_declined: row.wake_declined } : {}
5634
5853
  };
5635
5854
  }
5636
5855
  placeView(row, shown) {
@@ -5859,6 +6078,7 @@ function waking(row, now) {
5859
6078
  return wakeOf(row) !== null && (row.wakers > 0 || (row.waker_left ?? 0) >= now - LIMITS.restartGraceS * 1e3);
5860
6079
  }
5861
6080
  function shownPresence(row, now) {
6081
+ if (row.hibernate) return "hibernated";
5862
6082
  const presence = presenceOf(row, now);
5863
6083
  return presence === "offline" && waking(row, now) ? "idle" : presence;
5864
6084
  }
@@ -6666,6 +6886,7 @@ export {
6666
6886
  PEEK,
6667
6887
  PREAMBLE,
6668
6888
  PREAMBLE_BRIEF,
6889
+ PRESENCES,
6669
6890
  PRESET_LISTS,
6670
6891
  PROTOCOL,
6671
6892
  REDACTION_KINDS,
@@ -6679,6 +6900,8 @@ export {
6679
6900
  TITLE_CHARS,
6680
6901
  VerdictCache,
6681
6902
  WAKES,
6903
+ WAKE_DECLINE,
6904
+ WAKE_PROVIDER,
6682
6905
  XCounters,
6683
6906
  XLIMITS,
6684
6907
  admits,
@@ -6762,6 +6985,7 @@ export {
6762
6985
  validHandle,
6763
6986
  validHarness,
6764
6987
  validSelector,
6988
+ wakeDeclineOf,
6765
6989
  wakeText,
6766
6990
  widenText,
6767
6991
  wrapMessage,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scp-dev/core",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
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
@@ -45,6 +45,8 @@ import {
45
45
  validHandle,
46
46
  validHarness,
47
47
  type Wake,
48
+ type WakeDecline,
49
+ wakeDeclineOf,
48
50
  WAKES,
49
51
  type WhyResult,
50
52
  } from "./protocol"
@@ -274,6 +276,35 @@ export interface EndpointRow {
274
276
  wakers: number
275
277
  /** when the last hook connection that set the wake closed */
276
278
  waker_left: number | null
279
+ /** the provider whose wake hook resumes the hibernated session; null when it is not hibernated */
280
+ hibernate: string | null
281
+ /** the provider's opaque reference to the suspended session, handed to its wake hook */
282
+ wake_ref: string | null
283
+ hibernated_at: number | null
284
+ /** when a wake hook last ran for it */
285
+ wake_tried: number | null
286
+ /** the wake hook runs that failed in a row */
287
+ wake_fails: number
288
+ /** `wake_failed: <reason>` after a failed run */
289
+ wake_note: string | null
290
+ /** the seconds the provider said a wake takes */
291
+ wake_eta: number | null
292
+ /** the reason the provider declined a wake, a short token such as `policy` or `budget` */
293
+ wake_declined: string | null
294
+ /** the message, and its sender, the decline was for */
295
+ declined_message: string | null
296
+ declined_from: string | null
297
+ }
298
+
299
+ /** a wake hook run that is due: the hibernated endpoint, its provider and ref, and the newest unread message's metadata (never a body) */
300
+ export interface WakeJob {
301
+ endpoint: string
302
+ address: string
303
+ handle: string
304
+ provider: string
305
+ ref: string | null
306
+ message: { id: string; from: string | null; kind: MessageKind; thread: string; created_at: string }
307
+ unread: number
277
308
  }
278
309
 
279
310
  export interface MessageRow {
@@ -405,6 +436,8 @@ interface ClaimScope {
405
436
  const GRANT_TOKEN = /^krt_[0-9a-f]{64}$/
406
437
  /** a harness's session id as its hooks name it: a UUID or a similar token */
407
438
  const HARNESS_SESSION = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/
439
+ /** a wake provider's name, as hibernate takes it and a waker is configured under */
440
+ export const WAKE_PROVIDER = /^[a-z0-9][a-z0-9-]{0,31}$/
408
441
  const TRANSCRIPT_CHARS = 4096
409
442
 
410
443
  /** a hook's transcript path for harness session `session`: an absolute path without control characters whose file is `<session>.jsonl` */
@@ -563,6 +596,16 @@ export class RelayCore {
563
596
  ["wake", "TEXT"],
564
597
  ["wakers", "INTEGER NOT NULL DEFAULT 0"],
565
598
  ["waker_left", "INTEGER"],
599
+ ["hibernate", "TEXT"],
600
+ ["wake_ref", "TEXT"],
601
+ ["hibernated_at", "INTEGER"],
602
+ ["wake_tried", "INTEGER"],
603
+ ["wake_fails", "INTEGER NOT NULL DEFAULT 0"],
604
+ ["wake_note", "TEXT"],
605
+ ["wake_eta", "INTEGER"],
606
+ ["wake_declined", "TEXT"],
607
+ ["declined_message", "TEXT"],
608
+ ["declined_from", "TEXT"],
566
609
  ] as const) {
567
610
  if (!endpointColumns.includes(column)) this.sql.all(`ALTER TABLE endpoints ADD COLUMN ${column} ${type}`)
568
611
  }
@@ -733,7 +776,8 @@ export class RelayCore {
733
776
  if (fields.process !== null && !ANY_PROCESS_KEY.test(fields.process)) throw new RelayError("invalid_params", "process is a key pid:<pid>[:<start time>]")
734
777
  if (fields.harness_session !== null && !HARNESS_SESSION.test(fields.harness_session))
735
778
  throw new RelayError("invalid_params", "harness_session is a harness's session id: letters, digits, . _ : -")
736
- const leaked = [wanted, fields.model, fields.cwd, fields.parent, fields.labels, fields.session, fields.harness_session]
779
+ const under = cleanText(claim.under, "under", 128) || null
780
+ const leaked = [wanted, fields.model, fields.cwd, fields.parent, fields.labels, fields.session, fields.harness_session, under]
737
781
  .map((v) => (v ? findSecret(v) : null))
738
782
  .find(Boolean)
739
783
  if (leaked) throw new RelayError("secret_refused", `the claim looks like it carries a credential (${leaked})`)
@@ -775,10 +819,27 @@ export class RelayCore {
775
819
  }
776
820
  const bound = scope.grant === null && !scope.exact ? this.boundGrant(scope, fields.process) : undefined
777
821
  if (bound) Object.assign(scope, { grant: bound.token_hash, handle: bound.name ?? wanted })
778
- return this.claimRow(scope.handle, harness, fields, options.transient === true, scope)
822
+ const claimed = this.claimRow(scope.handle, harness, fields, options.transient === true, scope)
823
+ return under === null ? claimed : this.claimUnder(claimed, under)
779
824
  })
780
825
  }
781
826
 
827
+ /** links a claimed root under `under` as a join under does; a refusal keeps the claim and is audited `claim_under_refused` */
828
+ private claimUnder<T extends { endpoint: EndpointView }>(claimed: T, under: string): T {
829
+ const me = this.mustEndpoint(claimed.endpoint.id)
830
+ if (me.kind !== "agent" || !this.localRow(me) || me.parent_ep !== null || me.parent_addr !== null) return claimed
831
+ try {
832
+ const parent = this.resolve(under)
833
+ if (parent.kind === "agent" && !this.mayMessage(me, parent)) throw unreachable(under)
834
+ this.link(me, parent, { by: this.address(me), auth: "session" })
835
+ } catch (error) {
836
+ if (!(error instanceof RelayError)) throw error
837
+ this.audit("claim_under_refused", me.id, null, { target: this.address(me), under, code: error.code })
838
+ return claimed
839
+ }
840
+ return { ...claimed, endpoint: this.view(this.byId(me.id)!) }
841
+ }
842
+
782
843
  /** a claim's own rules per direction, as its `scope`, `allow`, `in` and `out` expand */
783
844
  protected claimRules(claim: Claim): ScopeRules {
784
845
  const rules: ScopeRules = {}
@@ -1016,6 +1077,7 @@ export class RelayCore {
1016
1077
  if (created || fresh === "grant") this.place(this.byId(row.id)!, scope, now)
1017
1078
  else if (scope.grant !== null) this.redeem(this.byId(row.id)!, scope, now)
1018
1079
  if (scope.grant !== null) this.takePending(row.id)
1080
+ if (!transient) this.wakeDone(row.id, "claim")
1019
1081
  return { endpoint: this.view(this.byId(row.id)!), ...(token ? { token } : {}), ...(created ? { created: true as const } : {}) }
1020
1082
  }
1021
1083
 
@@ -1108,6 +1170,7 @@ export class RelayCore {
1108
1170
  row.id,
1109
1171
  )
1110
1172
  this.sql.all(`DELETE FROM peek_turns WHERE endpoint = ?`, row.id)
1173
+ this.wakeDone(row.id, "reset")
1111
1174
  let dropped = 0
1112
1175
  if (kind === "grant") {
1113
1176
  const spawned = one<{ issuer: string; created_at: number }>(this.sql, `SELECT issuer, created_at FROM grants WHERE token_hash = ?`, scope.grant!)
@@ -1440,7 +1503,7 @@ export class RelayCore {
1440
1503
  const shown = viewer ? new Set(this.visibleRows(viewer).map((row) => row.id)) : undefined
1441
1504
  const peeker = viewer ?? (filters.peekable === true ? this.byHandle(HUMAN_HANDLE)! : undefined)
1442
1505
  return this.listed()
1443
- .filter((row) => filters.all || row.connections > 0 || row.kind === "human" || (row.last_seen ?? 0) >= cutoff || waking(row, now))
1506
+ .filter((row) => filters.all || row.connections > 0 || row.kind === "human" || (row.last_seen ?? 0) >= cutoff || waking(row, now) || this.hibernated(row))
1444
1507
  .filter((row) => !filters.harness || row.harness === filters.harness)
1445
1508
  .filter((row) => !filters.kind || row.kind === filters.kind)
1446
1509
  .filter((row) => !viewer || row.id === viewer.id || this.mayMessage(viewer, row, viewer.kind === "human"))
@@ -1641,6 +1704,7 @@ export class RelayCore {
1641
1704
  const row = this.mustEndpoint(endpoint.id)
1642
1705
  if (handle !== undefined && handle !== null && handle !== "") this.renameRow(row, handle, false)
1643
1706
  this.sql.all(`UPDATE endpoints SET hook_seen = ?, hook_ended = NULL WHERE id = ?`, this.now(), row.id)
1707
+ this.wakeDone(row.id, "join")
1644
1708
  this.audit("join", row.id, null, { handle: this.byId(row.id)!.handle })
1645
1709
  if (under !== undefined && under !== null && under !== "") {
1646
1710
  const me = this.byId(row.id)!
@@ -1939,6 +2003,7 @@ export class RelayCore {
1939
2003
  row.id,
1940
2004
  )
1941
2005
  this.sql.all(`UPDATE endpoints SET handle = ?, session = NULL, token_hash = NULL WHERE id = ?`, `${GONE_PREFIX}${held.id}`, held.id)
2006
+ this.wakeDone(held.id, "retired")
1942
2007
  this.dropPeek(held.id)
1943
2008
  this.scopeMemo?.ancestors.clear()
1944
2009
  this.scopeMemo?.limited.clear()
@@ -1966,6 +2031,7 @@ export class RelayCore {
1966
2031
  throw new RelayError("scope_refused", `spawned by ${parent ? this.address(parent) : "another agent"}; a spawned worker stays joined while it runs`)
1967
2032
  }
1968
2033
  this.sql.all(`UPDATE endpoints SET session = NULL, token_hash = NULL, hook_seen = NULL, hook_ended = ?, wake = NULL WHERE id = ?`, this.now(), target.id)
2034
+ this.wakeDone(target.id, "leave")
1969
2035
  this.audit("leave", target.id, null, { handle: target.handle })
1970
2036
  return { endpoint: this.whoami(target.id), session: target.session }
1971
2037
  })
@@ -1976,6 +2042,198 @@ export class RelayCore {
1976
2042
  return this.byId(endpoint)?.session ?? null
1977
2043
  }
1978
2044
 
2045
+ /**
2046
+ * marks a local agent hibernated by `wake`, the provider whose wake hook resumes it, with its opaque `ref` and the seconds a wake takes
2047
+ * (`eta_s`); `none` clears it. The agent's own session or the human (no terminal needed: a provider's daemon has none) naming the endpoint.
2048
+ * `known` is whether a waker is configured for a provider
2049
+ */
2050
+ hibernate(
2051
+ actor: ScopeActor,
2052
+ params: { endpoint?: unknown; wake?: unknown; ref?: unknown; eta_s?: unknown },
2053
+ options: { known?: (provider: string) => boolean } = {},
2054
+ ): { endpoint: EndpointView } {
2055
+ const me = this.actorRow(actor)
2056
+ const set = this.hibernation(params, options)
2057
+ if (actor.kind === "endpoint" && actor.auth === "lineage")
2058
+ throw new RelayError("scope_refused", "only the session itself hibernates: run this from the session's own shell")
2059
+ if (params.endpoint === undefined && me.kind !== "agent") throw new RelayError("invalid_params", "the human names the endpoint")
2060
+ const target = params.endpoint === undefined ? me : this.resolve(params.endpoint)
2061
+ if (actor.kind === "endpoint" && target.id !== me.id) throw new RelayError("scope_refused", "an agent hibernates only itself")
2062
+ if (target.kind !== "agent" || !this.localRow(target)) throw new RelayError("invalid_params", "only a local agent endpoint hibernates")
2063
+ return { endpoint: this.setHibernation(me, target, set) }
2064
+ }
2065
+
2066
+ /** the human marks every live local agent but subagents hibernated by one provider, as before suspending the machine they run on */
2067
+ hibernateAll(
2068
+ actor: ScopeActor,
2069
+ params: { wake?: unknown; ref?: unknown; eta_s?: unknown },
2070
+ options: { known?: (provider: string) => boolean } = {},
2071
+ ): { endpoints: EndpointView[] } {
2072
+ const me = this.actorRow(actor)
2073
+ if (actor.kind !== "human") throw new RelayError("scope_refused", "only the human hibernates every agent")
2074
+ const set = this.hibernation(params, options)
2075
+ const now = this.now()
2076
+ const targets = this.sql
2077
+ .all<EndpointRow>(
2078
+ `SELECT * FROM endpoints WHERE kind = 'agent' AND substr(handle, 1, ${GONE_PREFIX.length}) != '${GONE_PREFIX}'
2079
+ AND (session IS NULL OR substr(session, 1, 4) != 'sub:') ORDER BY handle`,
2080
+ )
2081
+ .filter((row) => this.localRow(row) && (set.wake === "none" ? row.hibernate !== null : presenceOf(row, now) !== "offline"))
2082
+ return { endpoints: targets.map((row) => this.setHibernation(me, row, set)) }
2083
+ }
2084
+
2085
+ private hibernation(
2086
+ params: { wake?: unknown; ref?: unknown; eta_s?: unknown },
2087
+ options: { known?: (provider: string) => boolean },
2088
+ ): { wake: string; ref: string | null; eta: number | null } {
2089
+ const wake = params?.wake
2090
+ if (typeof wake !== "string" || (wake !== "none" && !WAKE_PROVIDER.test(wake)))
2091
+ throw new RelayError("invalid_params", "wake is a provider name (lowercase letters, digits and hyphens, at most 32) or none")
2092
+ let ref: string | null = null
2093
+ if (params.ref !== undefined && params.ref !== null) {
2094
+ if (typeof params.ref !== "string" || params.ref.length > LIMITS.wakeRefChars || /[\u0000-\u001f\u007f]/.test(params.ref))
2095
+ throw new RelayError("invalid_params", `ref is a string of at most ${LIMITS.wakeRefChars} characters with no control characters`)
2096
+ if (findSecret(params.ref)) throw new RelayError("secret_refused", "the wake ref looks like it carries a credential")
2097
+ ref = params.ref
2098
+ }
2099
+ let eta: number | null = null
2100
+ if (params.eta_s !== undefined && params.eta_s !== null) {
2101
+ if (typeof params.eta_s !== "number" || !Number.isInteger(params.eta_s) || params.eta_s < 1 || params.eta_s > LIMITS.wakeEtaMaxS)
2102
+ throw new RelayError("invalid_params", `eta_s is a whole number of seconds from 1 to ${LIMITS.wakeEtaMaxS}`)
2103
+ eta = params.eta_s
2104
+ }
2105
+ if (wake !== "none" && options.known && !options.known(wake)) throw new RelayError("invalid_params", `no waker ${wake} is configured`)
2106
+ return { wake, ref, eta }
2107
+ }
2108
+
2109
+ private setHibernation(me: EndpointRow, target: EndpointRow, set: { wake: string; ref: string | null; eta: number | null }): EndpointView {
2110
+ const address = this.address(target)
2111
+ if (set.wake === "none") {
2112
+ if (target.hibernate !== null) {
2113
+ this.clearHibernation(target.id)
2114
+ this.audit("unhibernate", target.id, null, { by: this.address(me), target: address })
2115
+ }
2116
+ return this.view(this.byId(target.id)!)
2117
+ }
2118
+ this.clearHibernation(target.id)
2119
+ this.sql.all(
2120
+ `UPDATE endpoints SET hibernate = ?, wake_ref = ?, wake_eta = ?, hibernated_at = ? WHERE id = ?`,
2121
+ set.wake,
2122
+ set.ref,
2123
+ set.eta,
2124
+ this.now(),
2125
+ target.id,
2126
+ )
2127
+ this.audit("hibernate", target.id, null, { by: this.address(me), target: address, provider: set.wake })
2128
+ return this.view(this.byId(target.id)!)
2129
+ }
2130
+
2131
+ /**
2132
+ * the wake hook run a delivery to a hibernated local agent is due, recorded as tried: none while it is not hibernated, has no unread
2133
+ * message, ran within LIMITS.wakeDebounceS (after failures, a doubling backoff up to LIMITS.wakeBackoffMaxS), or the provider declined a
2134
+ * wake for the newest unread message or for another from its sender. The newest unread message's metadata only, never its body
2135
+ */
2136
+ wakeDue(endpoint: string, now = this.now()): WakeJob | null {
2137
+ const row = this.byId(endpoint)
2138
+ if (!row?.hibernate || row.kind !== "agent" || !this.localRow(row)) return null
2139
+ if (row.wake_tried !== null) {
2140
+ const waitS = row.wake_fails > 0 ? Math.min(LIMITS.wakeDebounceS * 2 ** row.wake_fails, LIMITS.wakeBackoffMaxS) : LIMITS.wakeDebounceS
2141
+ if (now - row.wake_tried < waitS * 1000) return null
2142
+ }
2143
+ const newest = one<{ id: string; from_ep: string; kind: MessageKind; thread: string; created_at: number }>(
2144
+ this.sql,
2145
+ `SELECT m.id, m.from_ep, m.kind, m.thread, m.created_at FROM deliveries d JOIN messages m ON m.id = d.message_id
2146
+ WHERE d.endpoint = ? AND d.acked_at IS NULL AND d.expires_at > ? ORDER BY d.seq DESC LIMIT 1`,
2147
+ endpoint,
2148
+ now,
2149
+ )
2150
+ if (!newest) return null
2151
+ if (row.wake_declined !== null && (newest.id === row.declined_message || newest.from_ep === row.declined_from)) return null
2152
+ const unread = one<{ n: number }>(
2153
+ this.sql,
2154
+ `SELECT COUNT(*) AS n FROM deliveries WHERE endpoint = ? AND acked_at IS NULL AND expires_at > ?`,
2155
+ endpoint,
2156
+ now,
2157
+ )!.n
2158
+ this.sql.all(`UPDATE endpoints SET wake_tried = ? WHERE id = ?`, now, endpoint)
2159
+ const sender = this.byId(newest.from_ep)
2160
+ return {
2161
+ endpoint,
2162
+ address: this.address(row),
2163
+ handle: row.handle,
2164
+ provider: row.hibernate,
2165
+ ref: row.wake_ref,
2166
+ message: { id: newest.id, from: sender ? this.address(sender) : null, kind: newest.kind, thread: newest.thread, created_at: iso(newest.created_at)! },
2167
+ unread,
2168
+ }
2169
+ }
2170
+
2171
+ /**
2172
+ * how a wake hook run ended, audited without the ref: a failure counts toward the backoff and shows `wake_failed` while the message stays
2173
+ * queued; a decline (`declined`, for the message `message`) shows `wake_declined`, counts toward nothing and holds further runs for that
2174
+ * message and its sender until a newer message of another sender or a new hibernate; a success resets both (the hibernation clears once the
2175
+ * session claims its endpoint again). Whether the shown note changed
2176
+ */
2177
+ wakeResult(
2178
+ endpoint: string,
2179
+ result: { ok: boolean; code?: number | null; error?: string | null; ms: number; declined?: WakeDecline; message?: string },
2180
+ ): boolean {
2181
+ const row = this.byId(endpoint)
2182
+ if (!row) return false
2183
+ const error = result.error ? redactSecrets(stripControl(result.error).replace(/\s+/g, " ").trim()).slice(0, 200) : null
2184
+ const declined = result.ok ? null : wakeDeclineOf(result.declined)
2185
+ this.audit("wake", endpoint, null, {
2186
+ target: this.address(row),
2187
+ provider: row.hibernate,
2188
+ ok: result.ok,
2189
+ ...(result.code !== undefined && result.code !== null ? { code: result.code } : {}),
2190
+ ms: Math.max(0, Math.round(result.ms)),
2191
+ ...(declined ? { declined } : error ? { error } : {}),
2192
+ })
2193
+ if (!row.hibernate) return false
2194
+ if (declined) {
2195
+ const message = result.message ? one<{ from_ep: string }>(this.sql, `SELECT from_ep FROM messages WHERE id = ?`, result.message) : undefined
2196
+ this.sql.all(
2197
+ `UPDATE endpoints SET wake_declined = ?, declined_message = ?, declined_from = ?, wake_note = NULL, wake_tried = NULL WHERE id = ?`,
2198
+ declined,
2199
+ result.message ?? null,
2200
+ message?.from_ep ?? null,
2201
+ endpoint,
2202
+ )
2203
+ return declined !== row.wake_declined || row.wake_note !== null
2204
+ }
2205
+ const note = result.ok
2206
+ ? null
2207
+ : `wake_failed: ${(error ?? (result.code !== undefined && result.code !== null ? `exit ${result.code}` : "failed")).slice(0, 120)}`
2208
+ this.sql.all(
2209
+ `UPDATE endpoints SET wake_fails = ${result.ok ? "0" : "wake_fails + 1"}, wake_note = ?, wake_declined = NULL, declined_message = NULL, declined_from = NULL WHERE id = ?`,
2210
+ note,
2211
+ endpoint,
2212
+ )
2213
+ return note !== row.wake_note || row.wake_declined !== null
2214
+ }
2215
+
2216
+ /** whether the row shows hibernated: a subclass also counts what another machine mirrored */
2217
+ protected hibernated(row: EndpointRow): boolean {
2218
+ return row.hibernate !== null
2219
+ }
2220
+
2221
+ /** the hibernated session is back, or gone: its hibernation clears */
2222
+ private wakeDone(endpoint: string, how: string): void {
2223
+ const row = this.byId(endpoint)
2224
+ if (!row?.hibernate) return
2225
+ this.clearHibernation(endpoint)
2226
+ this.audit("wake_done", endpoint, null, { target: this.address(row), how })
2227
+ }
2228
+
2229
+ private clearHibernation(endpoint: string): void {
2230
+ this.sql.all(
2231
+ `UPDATE endpoints SET hibernate = NULL, wake_ref = NULL, wake_eta = NULL, hibernated_at = NULL, wake_tried = NULL, wake_fails = 0, wake_note = NULL,
2232
+ wake_declined = NULL, declined_message = NULL, declined_from = NULL WHERE id = ?`,
2233
+ endpoint,
2234
+ )
2235
+ }
2236
+
1979
2237
  // messages
1980
2238
 
1981
2239
  /**
@@ -2045,8 +2303,9 @@ export class RelayCore {
2045
2303
  (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)) &&
2046
2304
  !this.unjoinedSpawn(sender, r.handle, now),
2047
2305
  )
2048
- if (params.live === true && dark.length)
2049
- throw new RelayError("not_live", `no live session holds ${dark.map((r) => this.address(r)).join(", ")}: nothing was queued`)
2306
+ const asleep = params.live === true ? recipients.filter((r) => r.kind === "agent" && r.hibernate !== null) : []
2307
+ if (params.live === true && (dark.length || asleep.length))
2308
+ throw new RelayError("not_live", `no live session holds ${[...dark, ...asleep].map((r) => this.address(r)).join(", ")}: nothing was queued`)
2050
2309
  const warnings = dark.map((r) => `queued: no live session holds ${this.address(r)}`)
2051
2310
  const pending = [...unjoined.values()].map((grant) => ({ key: pendingKey(grant.token_hash), address: `${this.user}/${grant.name}` }))
2052
2311
  const reached = recipients.length + pending.length
@@ -2271,8 +2530,9 @@ export class RelayCore {
2271
2530
  this.sql.all(`INSERT INTO pair_events (from_ep, to_ep, at) VALUES (?, ?, ?)`, from, recipient.id, now)
2272
2531
  if (treeSends && recipient.kind !== "human") this.sql.all(`INSERT INTO tree_events (tree, at) VALUES (?, ?)`, tree, now)
2273
2532
  const view = this.view(recipient)
2274
- const queued = view.presence === "offline"
2275
- const offline = queued && now - Math.max(recipient.last_seen ?? 0, recipient.hook_seen ?? 0) >= ASK_ENDED_GRACE_MS
2533
+ const hibernated = view.presence === "hibernated"
2534
+ const queued = view.presence === "offline" || hibernated
2535
+ const offline = view.presence === "offline" && now - Math.max(recipient.last_seen ?? 0, recipient.hook_seen ?? 0) >= ASK_ENDED_GRACE_MS
2276
2536
  const state = this.reach(sender, recipient, offline, now)
2277
2537
  deliveries.push({
2278
2538
  endpoint: recipient.id,
@@ -2282,8 +2542,10 @@ export class RelayCore {
2282
2542
  ...(queued ? { queued } : {}),
2283
2543
  last_seen: view.last_seen,
2284
2544
  ...(state ? { state } : {}),
2285
- ...(view.wake ? { wake: view.wake } : {}),
2545
+ ...(hibernated && recipient.wake_declined ? { wake: "declined" as const } : view.wake ? { wake: view.wake } : {}),
2286
2546
  ...(view.presence === "idle" ? { idle: true as const } : {}),
2547
+ ...(hibernated ? { hibernated: true as const } : {}),
2548
+ ...(hibernated && recipient.wake_eta ? { wake_eta_s: recipient.wake_eta } : {}),
2287
2549
  })
2288
2550
  }
2289
2551
  for (const { key, address } of pending) {
@@ -3357,6 +3619,7 @@ export class RelayCore {
3357
3619
  endpoint,
3358
3620
  )
3359
3621
  } else if (params.kind === "session_start") {
3622
+ this.wakeDone(endpoint, "session_start")
3360
3623
  const fresh = own && summary !== "compact" && summary !== "resume"
3361
3624
  this.sql.all(
3362
3625
  `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,
@@ -5378,7 +5641,9 @@ export class RelayCore {
5378
5641
  presence,
5379
5642
  status: row.status_state ? { state: row.status_state, note: row.status_note ?? "" } : null,
5380
5643
  last_seen: iso(row.last_seen),
5381
- ...(row.kind === "agent" ? { ...this.placeView(row, shown), wake: wakeOf(row) } : {}),
5644
+ ...(row.kind === "agent" ? { ...this.placeView(row, shown), wake: row.hibernate ? (`hook:${row.hibernate}` as const) : wakeOf(row) } : {}),
5645
+ ...(row.hibernate && row.wake_note ? { wake_failed: row.wake_note.replace(/^wake_failed: /, "") } : {}),
5646
+ ...(row.hibernate && row.wake_declined ? { wake_declined: row.wake_declined } : {}),
5382
5647
  }
5383
5648
  }
5384
5649
 
@@ -5677,8 +5942,12 @@ function waking(row: EndpointRow, now: number): boolean {
5677
5942
  return wakeOf(row) !== null && (row.wakers > 0 || (row.waker_left ?? 0) >= now - LIMITS.restartGraceS * 1000)
5678
5943
  }
5679
5944
 
5680
- /** the presence an endpoint shows: one its waker can wake is at least idle, though its session's own presence decayed */
5945
+ /**
5946
+ * the presence an endpoint shows: a provider's hibernated session is `hibernated` whatever its connections say; one its waker can wake is at
5947
+ * least idle, though its session's own presence decayed
5948
+ */
5681
5949
  function shownPresence(row: EndpointRow, now: number): Presence {
5950
+ if (row.hibernate) return "hibernated"
5682
5951
  const presence = presenceOf(row, now)
5683
5952
  return presence === "offline" && waking(row, now) ? "idle" : presence
5684
5953
  }
package/src/index.ts CHANGED
@@ -11,9 +11,11 @@ export {
11
11
  SYSTEM_HANDLE,
12
12
  TITLE_CHARS,
13
13
  unreachable,
14
+ WAKE_PROVIDER,
14
15
  type CoreOptions,
15
16
  type EndpointRow,
16
17
  type MessageRow,
18
+ type WakeJob,
17
19
  } from "./core"
18
20
  export * from "./protocol"
19
21
  export {
package/src/protocol.ts CHANGED
@@ -75,6 +75,14 @@ export const LIMITS = {
75
75
  policyLoadsKept: 8,
76
76
  /** the prompts between two refreshes of a title a hook inferred */
77
77
  titleRefreshTurns: 5,
78
+ /** the least time between two wake hook runs for one hibernated endpoint */
79
+ wakeDebounceS: 60,
80
+ /** the longest backoff after failed wake hook runs */
81
+ wakeBackoffMaxS: 900,
82
+ /** the longest opaque wake reference a provider stores with a hibernation */
83
+ wakeRefChars: 512,
84
+ /** the longest wake time a provider may announce with a hibernation */
85
+ wakeEtaMaxS: 3600,
78
86
  } as const
79
87
 
80
88
  export type ErrorCode =
@@ -109,10 +117,25 @@ export class RelayError extends Error {
109
117
  export type EndpointKind = "agent" | "human" | "system"
110
118
  export type MessageKind = "message" | "request" | "reply" | "status"
111
119
  export type DataClass = "public" | "private"
112
- export type Presence = "online" | "idle" | "offline"
120
+ /** `hibernated`: a provider suspended the session; a message runs its wake hook and the session drains its queue once it is back */
121
+ export type Presence = "online" | "idle" | "offline" | "hibernated"
122
+ export const PRESENCES: readonly Presence[] = ["online", "idle", "offline", "hibernated"]
113
123
  export type StatusState = "working" | "waiting" | "blocked" | "done"
114
- /** how an idle agent is woken by a message: a channel push, the pi extension, the OpenCode plugin's waiter, codex app-server, the session inbox, a headless resume or muse serve; `none` reads at its next turn */
115
- export type Wake = "channels" | "pi" | "opencode-plugin" | "codex-app-server" | "claude-inbox" | "agy-resume" | "grok-resume" | "muse-serve" | "none"
124
+ /**
125
+ * how an idle agent is woken by a message: a channel push, the pi extension, the OpenCode plugin's waiter, codex app-server, the session inbox,
126
+ * a headless resume or muse serve; `none` reads at its next turn; `hook:<provider>` (shown only, never set this way) a hibernated agent's provider wake hook
127
+ */
128
+ export type Wake =
129
+ | "channels"
130
+ | "pi"
131
+ | "opencode-plugin"
132
+ | "codex-app-server"
133
+ | "claude-inbox"
134
+ | "agy-resume"
135
+ | "grok-resume"
136
+ | "muse-serve"
137
+ | "none"
138
+ | `hook:${string}`
116
139
  export const WAKES: readonly Wake[] = [
117
140
  "channels",
118
141
  "pi",
@@ -124,6 +147,14 @@ export const WAKES: readonly Wake[] = [
124
147
  "muse-serve",
125
148
  "none",
126
149
  ]
150
+ /** why a provider declined to wake a hibernated agent, a short token it picks (e.g. `policy`, `budget`): not a failure, and not retried for the same message */
151
+ export type WakeDecline = string
152
+ export const WAKE_DECLINE = /^[a-z][a-z0-9_-]{0,31}$/
153
+
154
+ /** `value` when it is a decline reason, else null */
155
+ export function wakeDeclineOf(value: unknown): WakeDecline | null {
156
+ return typeof value === "string" && WAKE_DECLINE.test(value) ? value : null
157
+ }
127
158
 
128
159
  export type ActivityKind = "session_start" | "tool" | "stop" | "note" | "session_end"
129
160
  export const ACTIVITY_KINDS: readonly ActivityKind[] = ["session_start", "tool", "stop", "note", "session_end"]
@@ -199,6 +230,8 @@ export interface Claim {
199
230
  in?: string[]
200
231
  out?: string[]
201
232
  groups?: string[]
233
+ /** an agent address a root claim links under, as a join under does; a refused link keeps the claim, audited */
234
+ under?: string
202
235
  }
203
236
 
204
237
  export type ScopeDirection = "in" | "out"
@@ -290,6 +323,10 @@ export interface EndpointView {
290
323
  peekable?: PeekLevel
291
324
  /** an agent's: how an idle agent is woken by a message; null or `none`: it reads at its next turn */
292
325
  wake?: Wake | null
326
+ /** a hibernated agent's last wake hook failure: the reason */
327
+ wake_failed?: string
328
+ /** a hibernated agent's provider declined the last wake */
329
+ wake_declined?: WakeDecline
293
330
  }
294
331
 
295
332
  export type PeekLevel = "local"
@@ -467,7 +504,8 @@ export interface SendResult {
467
504
  /**
468
505
  * `queued`: the recipient is not connected now; `offline`: it has been gone past a short grace, so a client polling between waits is not
469
506
  * offline; `state`: an agent recipient's {@link Reach} on this machine (an older broker leaves it out); `wake`: how it is woken, only when
470
- * it can be; `idle`: its presence is idle
507
+ * it can be, `declined` when its provider declined the last wake; `idle`: its presence is idle; `hibernated`: it is hibernated and
508
+ * queued, its provider's wake hook resumes it; `wake_eta_s`: how long its provider said a wake takes
471
509
  */
472
510
  deliveries: {
473
511
  endpoint: string
@@ -477,8 +515,10 @@ export interface SendResult {
477
515
  queued?: boolean
478
516
  last_seen: string | null
479
517
  state?: Reach
480
- wake?: Wake
518
+ wake?: Wake | "declined"
481
519
  idle?: true
520
+ hibernated?: true
521
+ wake_eta_s?: number
482
522
  }[]
483
523
  /** one line per agent recipient no live session holds: `queued: no live session holds <address>` */
484
524
  warnings?: string[]
package/src/render.ts CHANGED
@@ -135,8 +135,9 @@ export function reachLines(deliveries: Delivery[], late = false): string[] {
135
135
  .map((d) => (d.state ? `${d.address}: ${reachText({ ...d, state: d.state })}` : deliveryText(d)))
136
136
  }
137
137
 
138
- /** an endpoint's presence as a line shows it: an idle agent no message wakes reads at its next turn */
138
+ /** an endpoint's presence as a line shows it: an idle agent no message wakes reads at its next turn; a hibernated one is wakeable */
139
139
  export function presenceText(e: Pick<EndpointView, "kind" | "presence" | "wake">): string {
140
+ if (e.presence === "hibernated") return "hibernated (wakeable)"
140
141
  return e.kind === "agent" && e.presence === "idle" && (!e.wake || e.wake === "none") ? "idle (next turn)" : e.presence
141
142
  }
142
143