@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 +124 -7
- package/dist/index.js +252 -18
- package/package.json +1 -1
- package/src/core.ts +279 -10
- package/src/index.ts +2 -0
- package/src/protocol.ts +56 -6
- 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,7 +187,22 @@ var RelayError = class extends Error {
|
|
|
179
187
|
retryAfterS;
|
|
180
188
|
detail;
|
|
181
189
|
};
|
|
182
|
-
var
|
|
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
|
|
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
|
|
1561
|
-
const titled = this.takeSubagentTitle(root.id, subagent.id,
|
|
1562
|
-
const resumed = !subagent.stop && one(this.sql, `UPDATE endpoints SET status_state = 'working' WHERE id = ? AND status_state = 'done' RETURNING id`,
|
|
1563
|
-
if (!titled && !resumed) return { ...
|
|
1564
|
-
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 };
|
|
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
|
-
|
|
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
|
-
|
|
2676
|
-
|
|
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
|
|
2882
|
-
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;
|
|
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
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,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
|
-
|
|
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
|
-
|
|
116
|
-
|
|
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
|
|