@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 +124 -7
- package/dist/index.js +241 -17
- package/package.json +1 -1
- package/src/core.ts +279 -10
- package/src/index.ts +2 -0
- package/src/protocol.ts +45 -5
- package/src/render.ts +2 -1
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
|
-
|
|
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
|
-
/**
|
|
407
|
-
|
|
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
|
|
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
|
|
1571
|
-
const titled = this.takeSubagentTitle(root.id, subagent.id,
|
|
1572
|
-
const resumed = !subagent.stop && one(this.sql, `UPDATE endpoints SET status_state = 'working' WHERE id = ? AND status_state = 'done' RETURNING id`,
|
|
1573
|
-
if (!titled && !resumed) return { ...
|
|
1574
|
-
return { ...
|
|
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
|
-
|
|
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
|
-
|
|
2686
|
-
|
|
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
|
|
2892
|
-
const
|
|
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
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
|
|
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
|
-
|
|
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
|
-
|
|
2049
|
-
|
|
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
|
|
2275
|
-
const
|
|
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
|
-
/**
|
|
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
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
|
-
|
|
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
|
-
/**
|
|
115
|
-
|
|
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
|
|