@scp-dev/core 0.3.1 → 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 or codex app-server; `none` reads at its next turn */
407
- type Wake = "channels" | "pi" | "opencode-plugin" | "codex-app-server" | "claude-inbox" | "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,7 +187,22 @@ var RelayError = class extends Error {
179
187
  retryAfterS;
180
188
  detail;
181
189
  };
182
- var WAKES = ["channels", "pi", "opencode-plugin", "codex-app-server", "claude-inbox", "none"];
190
+ var PRESENCES = ["online", "idle", "offline", "hibernated"];
191
+ var WAKES = [
192
+ "channels",
193
+ "pi",
194
+ "opencode-plugin",
195
+ "codex-app-server",
196
+ "claude-inbox",
197
+ "agy-resume",
198
+ "grok-resume",
199
+ "muse-serve",
200
+ "none"
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
+ }
183
206
  var ACTIVITY_KINDS = ["session_start", "tool", "stop", "note", "session_end"];
184
207
  var MESSAGE_KINDS = ["message", "request", "reply", "status"];
185
208
  var STATUS_STATES = ["working", "waiting", "blocked", "done"];
@@ -895,6 +918,7 @@ function reachLines(deliveries, late = false) {
895
918
  return deliveries.filter((d) => !late || d.state && d.state !== "running").map((d) => d.state ? `${d.address}: ${reachText({ ...d, state: d.state })}` : deliveryText(d));
896
919
  }
897
920
  function presenceText(e) {
921
+ if (e.presence === "hibernated") return "hibernated (wakeable)";
898
922
  return e.kind === "agent" && e.presence === "idle" && (!e.wake || e.wake === "none") ? "idle (next turn)" : e.presence;
899
923
  }
900
924
  function wakeText(e) {
@@ -1224,6 +1248,7 @@ var MIGRATION = [
1224
1248
  ];
1225
1249
  var GRANT_TOKEN = /^krt_[0-9a-f]{64}$/;
1226
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}$/;
1227
1252
  var TRANSCRIPT_CHARS = 4096;
1228
1253
  function transcriptPath(path, session) {
1229
1254
  if (typeof path !== "string" || path.length > TRANSCRIPT_CHARS || !path.startsWith("/") || /[\u0000-\u001f\u007f]/.test(path)) return null;
@@ -1362,7 +1387,17 @@ var RelayCore = class {
1362
1387
  ["linked", "INTEGER"],
1363
1388
  ["wake", "TEXT"],
1364
1389
  ["wakers", "INTEGER NOT NULL DEFAULT 0"],
1365
- ["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"]
1366
1401
  ]) {
1367
1402
  if (!endpointColumns.includes(column)) this.sql.all(`ALTER TABLE endpoints ADD COLUMN ${column} ${type}`);
1368
1403
  }
@@ -1527,7 +1562,8 @@ var RelayCore = class {
1527
1562
  if (fields.process !== null && !ANY_PROCESS_KEY.test(fields.process)) throw new RelayError("invalid_params", "process is a key pid:<pid>[:<start time>]");
1528
1563
  if (fields.harness_session !== null && !HARNESS_SESSION.test(fields.harness_session))
1529
1564
  throw new RelayError("invalid_params", "harness_session is a harness's session id: letters, digits, . _ : -");
1530
- 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);
1531
1567
  if (leaked) throw new RelayError("secret_refused", `the claim looks like it carries a credential (${leaked})`);
1532
1568
  if (claim.grant !== void 0 && (typeof claim.grant !== "string" || !GRANT_TOKEN.test(claim.grant)))
1533
1569
  throw new RelayError("invalid_params", "grant is a krt_ token");
@@ -1557,17 +1593,33 @@ var RelayCore = class {
1557
1593
  root.id
1558
1594
  );
1559
1595
  if (subagent.stop && !had) throw new RelayError("not_found", "that subagent has no endpoint: it never ran a tool");
1560
- const claimed = this.claimRow(scope.handle, harness, fields, true, scope);
1561
- const titled = this.takeSubagentTitle(root.id, subagent.id, claimed.endpoint.id);
1562
- const resumed = !subagent.stop && one(this.sql, `UPDATE endpoints SET status_state = 'working' WHERE id = ? AND status_state = 'done' RETURNING id`, claimed.endpoint.id);
1563
- if (!titled && !resumed) return { ...claimed, subagent: true };
1564
- 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 };
1565
1601
  }
1566
1602
  const bound = scope.grant === null && !scope.exact ? this.boundGrant(scope, fields.process) : void 0;
1567
1603
  if (bound) Object.assign(scope, { grant: bound.token_hash, handle: bound.name ?? wanted });
1568
- 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);
1569
1606
  });
1570
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
+ }
1571
1623
  /** a claim's own rules per direction, as its `scope`, `allow`, `in` and `out` expand */
1572
1624
  claimRules(claim) {
1573
1625
  const rules = {};
@@ -1758,6 +1810,7 @@ var RelayCore = class {
1758
1810
  if (created || fresh === "grant") this.place(this.byId(row.id), scope, now);
1759
1811
  else if (scope.grant !== null) this.redeem(this.byId(row.id), scope, now);
1760
1812
  if (scope.grant !== null) this.takePending(row.id);
1813
+ if (!transient) this.wakeDone(row.id, "claim");
1761
1814
  return { endpoint: this.view(this.byId(row.id)), ...token ? { token } : {}, ...created ? { created: true } : {} };
1762
1815
  }
1763
1816
  /**
@@ -1830,6 +1883,7 @@ var RelayCore = class {
1830
1883
  row.id
1831
1884
  );
1832
1885
  this.sql.all(`DELETE FROM peek_turns WHERE endpoint = ?`, row.id);
1886
+ this.wakeDone(row.id, "reset");
1833
1887
  let dropped = 0;
1834
1888
  if (kind === "grant") {
1835
1889
  const spawned = one(this.sql, `SELECT issuer, created_at FROM grants WHERE token_hash = ?`, scope.grant);
@@ -2122,7 +2176,7 @@ var RelayCore = class {
2122
2176
  const viewer = filters.viewer === void 0 ? void 0 : this.mustEndpoint(filters.viewer);
2123
2177
  const shown = viewer ? new Set(this.visibleRows(viewer).map((row) => row.id)) : void 0;
2124
2178
  const peeker = viewer ?? (filters.peekable === true ? this.byHandle(HUMAN_HANDLE) : void 0);
2125
- 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) => {
2126
2180
  const view = this.view(row, shown);
2127
2181
  return peeker && this.peekableBy(peeker, row, viewer?.kind === "human") ? { ...view, peekable: row.peek } : view;
2128
2182
  }).filter((view) => !filters.presence || view.presence === filters.presence);
@@ -2299,6 +2353,7 @@ var RelayCore = class {
2299
2353
  const row = this.mustEndpoint(endpoint.id);
2300
2354
  if (handle !== void 0 && handle !== null && handle !== "") this.renameRow(row, handle, false);
2301
2355
  this.sql.all(`UPDATE endpoints SET hook_seen = ?, hook_ended = NULL WHERE id = ?`, this.now(), row.id);
2356
+ this.wakeDone(row.id, "join");
2302
2357
  this.audit("join", row.id, null, { handle: this.byId(row.id).handle });
2303
2358
  if (under !== void 0 && under !== null && under !== "") {
2304
2359
  const me = this.byId(row.id);
@@ -2576,6 +2631,7 @@ var RelayCore = class {
2576
2631
  row.id
2577
2632
  );
2578
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");
2579
2635
  this.dropPeek(held.id);
2580
2636
  this.scopeMemo?.ancestors.clear();
2581
2637
  this.scopeMemo?.limited.clear();
@@ -2602,6 +2658,7 @@ var RelayCore = class {
2602
2658
  throw new RelayError("scope_refused", `spawned by ${parent ? this.address(parent) : "another agent"}; a spawned worker stays joined while it runs`);
2603
2659
  }
2604
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");
2605
2662
  this.audit("leave", target.id, null, { handle: target.handle });
2606
2663
  return { endpoint: this.whoami(target.id), session: target.session };
2607
2664
  });
@@ -2610,6 +2667,171 @@ var RelayCore = class {
2610
2667
  sessionOf(endpoint) {
2611
2668
  return this.byId(endpoint)?.session ?? null;
2612
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
+ }
2613
2835
  // messages
2614
2836
  /**
2615
2837
  * `origin.tty` is recorded on messages the human inbox sends: whether its client said a terminal was attached. `origin.unverified`: the human
@@ -2672,8 +2894,9 @@ var RelayCore = class {
2672
2894
  const dark = recipients.filter(
2673
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)
2674
2896
  );
2675
- if (params.live === true && dark.length)
2676
- 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`);
2677
2900
  const warnings = dark.map((r) => `queued: no live session holds ${this.address(r)}`);
2678
2901
  const pending = [...unjoined.values()].map((grant) => ({ key: pendingKey(grant.token_hash), address: `${this.user}/${grant.name}` }));
2679
2902
  const reached = recipients.length + pending.length;
@@ -2878,8 +3101,9 @@ var RelayCore = class {
2878
3101
  this.sql.all(`INSERT INTO pair_events (from_ep, to_ep, at) VALUES (?, ?, ?)`, from, recipient.id, now);
2879
3102
  if (treeSends && recipient.kind !== "human") this.sql.all(`INSERT INTO tree_events (tree, at) VALUES (?, ?)`, tree, now);
2880
3103
  const view = this.view(recipient);
2881
- const queued = view.presence === "offline";
2882
- 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;
2883
3107
  const state = this.reach(sender, recipient, offline, now);
2884
3108
  deliveries.push({
2885
3109
  endpoint: recipient.id,
@@ -2889,8 +3113,10 @@ var RelayCore = class {
2889
3113
  ...queued ? { queued } : {},
2890
3114
  last_seen: view.last_seen,
2891
3115
  ...state ? { state } : {},
2892
- ...view.wake ? { wake: view.wake } : {},
2893
- ...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 } : {}
2894
3120
  });
2895
3121
  }
2896
3122
  for (const { key, address } of pending) {
@@ -3844,6 +4070,7 @@ var RelayCore = class {
3844
4070
  endpoint
3845
4071
  );
3846
4072
  } else if (params.kind === "session_start") {
4073
+ this.wakeDone(endpoint, "session_start");
3847
4074
  const fresh = own && summary !== "compact" && summary !== "resume";
3848
4075
  this.sql.all(
3849
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,
@@ -5620,7 +5847,9 @@ ${stderr}` : "";
5620
5847
  presence,
5621
5848
  status: row.status_state ? { state: row.status_state, note: row.status_note ?? "" } : null,
5622
5849
  last_seen: iso(row.last_seen),
5623
- ...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 } : {}
5624
5853
  };
5625
5854
  }
5626
5855
  placeView(row, shown) {
@@ -5849,6 +6078,7 @@ function waking(row, now) {
5849
6078
  return wakeOf(row) !== null && (row.wakers > 0 || (row.waker_left ?? 0) >= now - LIMITS.restartGraceS * 1e3);
5850
6079
  }
5851
6080
  function shownPresence(row, now) {
6081
+ if (row.hibernate) return "hibernated";
5852
6082
  const presence = presenceOf(row, now);
5853
6083
  return presence === "offline" && waking(row, now) ? "idle" : presence;
5854
6084
  }
@@ -6656,6 +6886,7 @@ export {
6656
6886
  PEEK,
6657
6887
  PREAMBLE,
6658
6888
  PREAMBLE_BRIEF,
6889
+ PRESENCES,
6659
6890
  PRESET_LISTS,
6660
6891
  PROTOCOL,
6661
6892
  REDACTION_KINDS,
@@ -6669,6 +6900,8 @@ export {
6669
6900
  TITLE_CHARS,
6670
6901
  VerdictCache,
6671
6902
  WAKES,
6903
+ WAKE_DECLINE,
6904
+ WAKE_PROVIDER,
6672
6905
  XCounters,
6673
6906
  XLIMITS,
6674
6907
  admits,
@@ -6752,6 +6985,7 @@ export {
6752
6985
  validHandle,
6753
6986
  validHarness,
6754
6987
  validSelector,
6988
+ wakeDeclineOf,
6755
6989
  wakeText,
6756
6990
  widenText,
6757
6991
  wrapMessage,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scp-dev/core",
3
- "version": "0.3.1",
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,11 +117,44 @@ 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 or codex app-server; `none` reads at its next turn */
115
- export type Wake = "channels" | "pi" | "opencode-plugin" | "codex-app-server" | "claude-inbox" | "none"
116
- export const WAKES: readonly Wake[] = ["channels", "pi", "opencode-plugin", "codex-app-server", "claude-inbox", "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}`
139
+ export const WAKES: readonly Wake[] = [
140
+ "channels",
141
+ "pi",
142
+ "opencode-plugin",
143
+ "codex-app-server",
144
+ "claude-inbox",
145
+ "agy-resume",
146
+ "grok-resume",
147
+ "muse-serve",
148
+ "none",
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
+ }
117
158
 
118
159
  export type ActivityKind = "session_start" | "tool" | "stop" | "note" | "session_end"
119
160
  export const ACTIVITY_KINDS: readonly ActivityKind[] = ["session_start", "tool", "stop", "note", "session_end"]
@@ -189,6 +230,8 @@ export interface Claim {
189
230
  in?: string[]
190
231
  out?: string[]
191
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
192
235
  }
193
236
 
194
237
  export type ScopeDirection = "in" | "out"
@@ -280,6 +323,10 @@ export interface EndpointView {
280
323
  peekable?: PeekLevel
281
324
  /** an agent's: how an idle agent is woken by a message; null or `none`: it reads at its next turn */
282
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
283
330
  }
284
331
 
285
332
  export type PeekLevel = "local"
@@ -457,7 +504,8 @@ export interface SendResult {
457
504
  /**
458
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
459
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
460
- * 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
461
509
  */
462
510
  deliveries: {
463
511
  endpoint: string
@@ -467,8 +515,10 @@ export interface SendResult {
467
515
  queued?: boolean
468
516
  last_seen: string | null
469
517
  state?: Reach
470
- wake?: Wake
518
+ wake?: Wake | "declined"
471
519
  idle?: true
520
+ hibernated?: true
521
+ wake_eta_s?: number
472
522
  }[]
473
523
  /** one line per agent recipient no live session holds: `queued: no live session holds <address>` */
474
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