@pinet/broker-core 0.2.4 → 0.2.7

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.
@@ -0,0 +1,20 @@
1
+ import { type AgentLifecycleStatus } from "./hibernation-status.js";
2
+ import type { BrokerDB } from "./schema.js";
3
+ import type { AgentInfo } from "./types.js";
4
+ /** True when an agent's lifecycle state is worth surfacing in operator reads. */
5
+ export declare function isHibernationRelevantAgent(agent: Pick<AgentInfo, "lifecycleState">): boolean;
6
+ export interface CollectLifecycleStatusesOptions {
7
+ now?: number;
8
+ /** Restrict to these agent ids (e.g. the currently visible set). */
9
+ agentIds?: string[];
10
+ /** Include live/active agents too. Defaults false (hibernation-relevant only). */
11
+ includeAll?: boolean;
12
+ /** Wake capacity ceilings; when provided, capacity counters are included. */
13
+ maxConcurrentWakes?: number;
14
+ maxConcurrentWakesPerRepo?: number;
15
+ }
16
+ /**
17
+ * Build lifecycle statuses from the broker DB. Pure read: performs no
18
+ * mutations and never activates hibernation.
19
+ */
20
+ export declare function collectAgentLifecycleStatuses(db: BrokerDB, options?: CollectLifecycleStatusesOptions): AgentLifecycleStatus[];
@@ -0,0 +1,60 @@
1
+ import { buildAgentLifecycleStatus } from "./hibernation-status.js";
2
+ /**
3
+ * Read-only lifecycle projection for the existing `agents`/`sessions` operator
4
+ * read paths. Assembles the sanitized {@link AgentLifecycleStatus} for agents
5
+ * that are in a hibernation-relevant lifecycle state, using only durable broker
6
+ * reads. Every field is redaction-by-construction safe (see
7
+ * {@link buildAgentLifecycleStatus} / redactRuntimeSpec): no argv, env values,
8
+ * message bodies, tokens, or filesystem/socket paths.
9
+ */
10
+ const HIBERNATION_RELEVANT_STATES = new Set([
11
+ "grace",
12
+ "idle",
13
+ "hibernating",
14
+ "hibernated",
15
+ "waking",
16
+ "reap-candidate",
17
+ ]);
18
+ /** True when an agent's lifecycle state is worth surfacing in operator reads. */
19
+ export function isHibernationRelevantAgent(agent) {
20
+ return HIBERNATION_RELEVANT_STATES.has(agent.lifecycleState ?? "live");
21
+ }
22
+ /**
23
+ * Build lifecycle statuses from the broker DB. Pure read: performs no
24
+ * mutations and never activates hibernation.
25
+ */
26
+ export function collectAgentLifecycleStatuses(db, options = {}) {
27
+ const now = options.now ?? Date.now();
28
+ const idFilter = options.agentIds ? new Set(options.agentIds) : null;
29
+ const orderedWakeQueue = [...db.listWakeQueue("queued")].sort((a, b) => a.priority - b.priority || a.enqueuedAt.localeCompare(b.enqueuedAt));
30
+ const recentEvents = db.getRecentAgentLifecycleEvents(undefined, 200);
31
+ const globalInflight = db.countInflightWakes();
32
+ const includeCapacity = options.maxConcurrentWakes != null && options.maxConcurrentWakesPerRepo != null;
33
+ const statuses = [];
34
+ for (const agent of db.getAllAgents()) {
35
+ if (idFilter && !idFilter.has(agent.id))
36
+ continue;
37
+ if (!options.includeAll && !isHibernationRelevantAgent(agent))
38
+ continue;
39
+ const repoRootRaw = agent.metadata?.repoRoot;
40
+ const repoRoot = typeof repoRootRaw === "string" ? repoRootRaw : null;
41
+ const capacity = includeCapacity
42
+ ? {
43
+ maxConcurrentWakes: options.maxConcurrentWakes,
44
+ inflightWakes: globalInflight,
45
+ maxConcurrentWakesPerRepo: options.maxConcurrentWakesPerRepo,
46
+ inflightWakesForRepo: db.countInflightWakes(repoRoot),
47
+ }
48
+ : undefined;
49
+ statuses.push(buildAgentLifecycleStatus({
50
+ agent,
51
+ now,
52
+ latestCheckpoint: db.getLatestAgentCheckpointReceipt(agent.id),
53
+ runtimeSpec: db.getAgentRuntimeSpec(agent.id),
54
+ orderedWakeQueue,
55
+ recentEvents,
56
+ capacity,
57
+ }));
58
+ }
59
+ return statuses;
60
+ }
@@ -0,0 +1,141 @@
1
+ import type { AgentCheckpointReceipt, AgentHibernatePolicy, AgentInfo, AgentLifecycleEvent, AgentLifecycleState, AgentRuntimeSpec, AgentWakeQueueEntry, RedactedAgentRuntimeSpec, WakeTriggerKind } from "./types.js";
2
+ /**
3
+ * Short, stable, non-reversible FNV-1a digest. Lets operators correlate an
4
+ * opaque identity (session ref, unresolved command target) across reads without
5
+ * exposing the underlying payload — which may be a filesystem path or secret.
6
+ */
7
+ export declare function fingerprintToken(value: string): string;
8
+ export declare function redactRuntimeSpec(spec: AgentRuntimeSpec): RedactedAgentRuntimeSpec;
9
+ export interface AgentLifecycleStatusCapacityInput {
10
+ maxConcurrentWakes: number;
11
+ inflightWakes: number;
12
+ maxConcurrentWakesPerRepo: number;
13
+ inflightWakesForRepo: number;
14
+ }
15
+ export interface AgentLifecycleStatusInput {
16
+ agent: Pick<AgentInfo, "id" | "lifecycleState" | "lifecycleVersion" | "runtimeGeneration" | "hibernatePolicy" | "hibernatedAt" | "graceUntil" | "idleEligibleAt" | "hibernateReason" | "lastWakeReason">;
17
+ /** Epoch ms used for age math. Defaults to Date.now(). */
18
+ now?: number;
19
+ latestCheckpoint?: AgentCheckpointReceipt | null;
20
+ runtimeSpec?: AgentRuntimeSpec | null;
21
+ /**
22
+ * Wake queue ordered exactly as the dispatcher consumes it (priority then
23
+ * oldest). Used to compute this agent's 1-based queue position.
24
+ */
25
+ orderedWakeQueue?: AgentWakeQueueEntry[];
26
+ capacity?: AgentLifecycleStatusCapacityInput;
27
+ /** Recent lifecycle events (any order); used to surface refusal/quarantine cause. */
28
+ recentEvents?: AgentLifecycleEvent[];
29
+ }
30
+ export interface AgentLifecycleStatus {
31
+ agentId: string;
32
+ state: AgentLifecycleState;
33
+ lifecycleVersion: number;
34
+ runtimeGeneration: number | null;
35
+ hibernatePolicy: AgentHibernatePolicy | null;
36
+ hibernatedAt: string | null;
37
+ hibernateReason: string | null;
38
+ lastWakeReason: string | null;
39
+ graceUntil: string | null;
40
+ idleEligibleAt: string | null;
41
+ quarantined: boolean;
42
+ checkpoint: {
43
+ present: boolean;
44
+ hibernateSafe: boolean | null;
45
+ ageMs: number | null;
46
+ pendingInboxCount: number | null;
47
+ runtimeGeneration: number | null;
48
+ };
49
+ /** Presence + redacted summary of the durable runtime spec; never raw argv/env. */
50
+ runtimeSpec: RedactedAgentRuntimeSpec | null;
51
+ wake: {
52
+ queued: boolean;
53
+ position: number | null;
54
+ triggerKind: WakeTriggerKind | null;
55
+ reason: string | null;
56
+ attempt: number | null;
57
+ };
58
+ capacity: {
59
+ global: {
60
+ inflight: number;
61
+ max: number;
62
+ atCapacity: boolean;
63
+ };
64
+ repo: {
65
+ inflight: number;
66
+ max: number;
67
+ atCapacity: boolean;
68
+ };
69
+ } | null;
70
+ /** Most recent non-accepted lifecycle outcome (refusal/stale fence/abort). */
71
+ refusal: {
72
+ reason: string;
73
+ outcome: string;
74
+ at: string;
75
+ } | null;
76
+ }
77
+ /**
78
+ * Bound and control-strip a free-form reason string for operator-safe display.
79
+ * Reasons can originate from operator input (hibernate/wake commands) and flow
80
+ * durably into lifecycle rows; this is the redaction-by-construction boundary
81
+ * that keeps such strings single-line, control-char free, and length-bounded
82
+ * before they reach any operator/status/JSON surface. Returns null for
83
+ * empty/whitespace-only input.
84
+ */
85
+ export declare function sanitizeOperatorReason(value: string | null | undefined): string | null;
86
+ /**
87
+ * Sanitize an operator-authored hibernation/wake TARGET before it reaches the
88
+ * confirmation-policy / prompt surface. Unlike a free-form reason, a target can
89
+ * be a durable stable id (`host:session:<ref>`, `host:cwd:<path>`) whose tail
90
+ * embeds the session-resume identity — the same value {@link redactRuntimeSpec}
91
+ * fingerprints — so it must never be echoed verbatim. Fail-closed by shape: only
92
+ * a plain broker-safe identifier slug (an agent name/id with no separators that
93
+ * could embed a session/path/secret identity) is passed through; ANY other shape
94
+ * (stable-id triple, session/cwd/worktree token, path, `KEY=value`, quotes,
95
+ * whitespace) collapses to an opaque, non-reversible `target:#<fingerprint>`
96
+ * (matching {@link unknownHibernationTarget}). The RAW target is unaffected — the
97
+ * broker still resolves it server-side; only the operator-facing echo is redacted.
98
+ */
99
+ export declare function sanitizeOperatorTarget(target: string | null | undefined): string;
100
+ /**
101
+ * Replace filesystem-path-like tokens with `<path>` so operator-authored
102
+ * free-form strings (reasons, echoed targets) can never surface private
103
+ * absolute paths, unix socket paths, or repo-relative paths in operator/JSON
104
+ * output. Conservative but fail-closed on ambiguous relative paths: any
105
+ * single-separator token that is not a known prose connective (see
106
+ * `PROSE_SLASH_TOKENS`) is redacted, so `accounts/acme` is caught while
107
+ * "and/or" survives. This is a redaction-by-construction boundary, not a
108
+ * security parser.
109
+ */
110
+ export declare function redactPathLikeTokens(value: string): string;
111
+ /**
112
+ * Redaction-by-construction for RUNTIME-authored checkpoint reasons. A checkpoint
113
+ * reason is authored by the worker runtime, so — unlike the trusted-operator
114
+ * reason path (which only redacts obvious path-like tokens) — it must NOT be able
115
+ * to smuggle argv, env assignments (`TOKEN=secret`), CLI flags (`--api-key x`),
116
+ * extensionless relative paths (`accounts/acme`), or other free prose onto any
117
+ * operator/telemetry/JSON surface. We therefore allowlist by *shape*: only a
118
+ * short single-token machine code (`active_port_lease`, `checkpoint_timeout`, …)
119
+ * is passed through; anything containing whitespace, separators, or exotic
120
+ * characters collapses to the static `unspecified` code. Diagnostic prose belongs
121
+ * in the worker's own logs, not the broker's operator surface.
122
+ */
123
+ export declare function sanitizeCheckpointReasonCode(value: string | null | undefined): string;
124
+ /**
125
+ * Compose an operator-safe, actionable per-agent lifecycle status from already
126
+ * sanitized inputs. Pure: it performs no IO and never emits raw argv, env
127
+ * values, or filesystem/socket paths.
128
+ */
129
+ export declare function buildAgentLifecycleStatus(input: AgentLifecycleStatusInput): AgentLifecycleStatus;
130
+ /**
131
+ * Render a single scannable, operator-safe lifecycle tag for inline use in the
132
+ * `agents`/`sessions` compact read paths. Sanitized: state, generation, queue
133
+ * position, checkpoint age/safety, and refusal/quarantine reason code only.
134
+ */
135
+ export declare function formatAgentLifecycleTag(status: AgentLifecycleStatus): string;
136
+ /**
137
+ * Render a compact, operator-safe status block. Contains only sanitized
138
+ * lifecycle facts — never prompts, message bodies, tokens, argv, env values, or
139
+ * filesystem paths.
140
+ */
141
+ export declare function formatAgentLifecycleStatus(status: AgentLifecycleStatus): string;
@@ -0,0 +1,390 @@
1
+ /**
2
+ * Short, stable, non-reversible FNV-1a digest. Lets operators correlate an
3
+ * opaque identity (session ref, unresolved command target) across reads without
4
+ * exposing the underlying payload — which may be a filesystem path or secret.
5
+ */
6
+ export function fingerprintToken(value) {
7
+ let hash = 0x811c9dc5;
8
+ for (let i = 0; i < value.length; i += 1) {
9
+ hash ^= value.charCodeAt(i);
10
+ hash = Math.imul(hash, 0x01000193);
11
+ }
12
+ return (hash >>> 0).toString(16).padStart(8, "0");
13
+ }
14
+ /**
15
+ * Redact a durable runtime spec into an operator-safe view: presence flags,
16
+ * counts, and opaque references only. Raw argv, environment values, filesystem
17
+ * paths, and private socket paths are never surfaced. This is the sanctioned
18
+ * redaction-by-construction boundary for any operator/status/inspect surface.
19
+ */
20
+ /**
21
+ * Fail-closed sanitizer for an otherwise-unconstrained runtime-spec facet
22
+ * (`configFingerprint`, `expectedHost`, `launchSource`). `AgentRuntimeSpec` puts
23
+ * no shape constraint on these adapter-authored strings, so a path, unix socket
24
+ * path, `KEY=value` assignment, quoted span, or other secret-bearing value could
25
+ * be persisted into one and then copied verbatim onto an operator/JSON surface,
26
+ * bypassing the redaction-by-construction boundary. Only a strict machine token
27
+ * (hostname / hash / short machine code: an alphanumeric-anchored run of
28
+ * `[A-Za-z0-9_.-]`, no separator/quote/assignment/whitespace) is passed through;
29
+ * ANY other shape collapses to an opaque, non-reversible `#<fingerprint>` so an
30
+ * operator can still correlate the value across reads without exposing it.
31
+ */
32
+ function sanitizeSpecFacet(value) {
33
+ const raw = (value ?? "").trim();
34
+ if (raw.length === 0)
35
+ return "";
36
+ if (/^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/.test(raw))
37
+ return raw;
38
+ return `#${fingerprintToken(raw)}`;
39
+ }
40
+ export function redactRuntimeSpec(spec) {
41
+ const rawRef = spec.sessionResumeRef;
42
+ const separatorIndex = rawRef.indexOf(":");
43
+ const kindHint = separatorIndex > 0 ? rawRef.slice(0, separatorIndex) : "";
44
+ const knownKinds = ["session", "leaf", "cwd", "broker"];
45
+ const kind = knownKinds.includes(kindHint)
46
+ ? kindHint
47
+ : "unknown";
48
+ // The session ref payload is NEVER surfaced verbatim: for `cwd`/`leaf` kinds
49
+ // (and unrecognized producers) it can be a filesystem path or otherwise
50
+ // sensitive. Instead emit `<kind>:#<fingerprint>` — a stable, short,
51
+ // non-reversible FNV-1a digest that lets operators correlate identity across
52
+ // reads without exposing the payload — and flag whether it looked path-like.
53
+ const payload = separatorIndex > 0 ? rawRef.slice(separatorIndex + 1) : rawRef;
54
+ const hasPath = /[\\/]/.test(payload) || payload.startsWith("~");
55
+ const ref = `${kind}:#${fingerprintToken(rawRef)}`;
56
+ // Repo is reported as a path-free basename so operators can group agents
57
+ // without exposing the worktree/repo-root filesystem path. Split on BOTH
58
+ // separators so a Windows repo root (e.g. `C:\\Users\\alice\\secret-repo`) is
59
+ // never emitted verbatim as its own "basename".
60
+ const repoSegments = spec.repoRoot.split(/[\\/]/).filter(Boolean);
61
+ const repo = repoSegments.length > 0 ? repoSegments[repoSegments.length - 1] : null;
62
+ // These adapter-authored facets are shape-unconstrained on the spec, so they
63
+ // are sanitized fail-closed before reaching any operator/JSON surface: a strict
64
+ // machine token passes through, anything else is fingerprinted (see
65
+ // `sanitizeSpecFacet`). Applies to the mirrored `session.host` too.
66
+ const expectedHost = sanitizeSpecFacet(spec.expectedHost);
67
+ return {
68
+ agentId: spec.agentId,
69
+ session: {
70
+ kind,
71
+ ref,
72
+ host: expectedHost,
73
+ hasPath,
74
+ },
75
+ repo,
76
+ hasWorktree: Boolean(spec.worktreePath),
77
+ runtimeKind: spec.runtimeKind,
78
+ hasTmuxSession: spec.runtimeKind === "tmux" && Boolean(spec.tmuxSession),
79
+ configFingerprint: sanitizeSpecFacet(spec.configFingerprint),
80
+ expectedHost,
81
+ launchSource: sanitizeSpecFacet(spec.launchSource),
82
+ envAllowlistCount: spec.envAllowlist.length,
83
+ updatedAt: spec.updatedAt,
84
+ };
85
+ }
86
+ const DURABLE_HIBERNATION_STATES = new Set(["hibernated", "waking"]);
87
+ /**
88
+ * Bound and control-strip a free-form reason string for operator-safe display.
89
+ * Reasons can originate from operator input (hibernate/wake commands) and flow
90
+ * durably into lifecycle rows; this is the redaction-by-construction boundary
91
+ * that keeps such strings single-line, control-char free, and length-bounded
92
+ * before they reach any operator/status/JSON surface. Returns null for
93
+ * empty/whitespace-only input.
94
+ */
95
+ export function sanitizeOperatorReason(value) {
96
+ if (value == null)
97
+ return null;
98
+ const cleaned = Array.from(value)
99
+ .map((ch) => {
100
+ const code = ch.codePointAt(0) ?? 0;
101
+ return code <= 0x1f || code === 0x7f ? " " : ch;
102
+ })
103
+ .join("")
104
+ .replace(/\s+/g, " ")
105
+ .trim();
106
+ if (cleaned.length === 0)
107
+ return null;
108
+ // Two composed passes: first strip secret-bearing shapes (env assignments and
109
+ // CLI flag values) that are not paths, then redact path-like tokens. Ordering
110
+ // matters — a flag's value that is itself a path is caught by the first pass.
111
+ const redacted = redactPathLikeTokens(redactSecretAssignments(cleaned));
112
+ return redacted.length > 120 ? `${redacted.slice(0, 117)}\u2026` : redacted;
113
+ }
114
+ /**
115
+ * Sanitize an operator-authored hibernation/wake TARGET before it reaches the
116
+ * confirmation-policy / prompt surface. Unlike a free-form reason, a target can
117
+ * be a durable stable id (`host:session:<ref>`, `host:cwd:<path>`) whose tail
118
+ * embeds the session-resume identity — the same value {@link redactRuntimeSpec}
119
+ * fingerprints — so it must never be echoed verbatim. Fail-closed by shape: only
120
+ * a plain broker-safe identifier slug (an agent name/id with no separators that
121
+ * could embed a session/path/secret identity) is passed through; ANY other shape
122
+ * (stable-id triple, session/cwd/worktree token, path, `KEY=value`, quotes,
123
+ * whitespace) collapses to an opaque, non-reversible `target:#<fingerprint>`
124
+ * (matching {@link unknownHibernationTarget}). The RAW target is unaffected — the
125
+ * broker still resolves it server-side; only the operator-facing echo is redacted.
126
+ */
127
+ export function sanitizeOperatorTarget(target) {
128
+ const raw = (target ?? "").trim();
129
+ if (raw.length === 0)
130
+ return "(unnamed)";
131
+ // Broker-safe plain identifier (agent name/id): a leading optional `@`, then an
132
+ // alphanumeric-anchored slug of `[A-Za-z0-9_.-]`. No colon, slash, backslash,
133
+ // `=`, quote, or whitespace — so it cannot carry a stable-id/session/path/secret
134
+ // identity and is safe to echo.
135
+ if (/^@?[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/.test(raw))
136
+ return raw;
137
+ return `target:#${fingerprintToken(raw)}`;
138
+ }
139
+ // Common prose that uses a single "/" and must NOT be treated as a path. Bare
140
+ // two-token connectives only; anything path-shaped (multi-segment, file-like,
141
+ // absolute, or relative-prefixed) is still redacted by `redactPathLikeTokens`.
142
+ const PROSE_SLASH_TOKENS = new Set([
143
+ "and/or",
144
+ "or/and",
145
+ "he/she",
146
+ "she/he",
147
+ "w/",
148
+ "w/o",
149
+ "n/a",
150
+ "i/o",
151
+ "km/h",
152
+ "tcp/ip",
153
+ "24/7",
154
+ ]);
155
+ /**
156
+ * Redact secret-bearing NON-path shapes from an operator free-form string:
157
+ * environment/CLI assignments (`TOKEN=deadbeef` → `TOKEN=<redacted>`) and CLI
158
+ * flag values (`--api-key secret` / `--api-key=secret` → `--api-key <redacted>`).
159
+ * The env var name / flag name is kept (it is not itself the secret) so the
160
+ * reason stays actionable, while the value can never reach an operator surface.
161
+ *
162
+ * Whole-string, quote- and punctuation-aware, and fail-closed. Earlier
163
+ * whitespace tokenization leaked on quoted (`TOKEN="dead beef"`), spaced
164
+ * (`TOKEN = deadbeef`), and punctuation-wrapped (`(--api-key sk-123)`) secrets,
165
+ * and a naive per-token pass leaked the tail of a spaced value after a flag
166
+ * (`--api-key "sk live 123`). Two ordered passes consume each secret VALUE
167
+ * through its end — a possibly-unterminated quoted span OR a non-space run:
168
+ * 1. `key=value` / `key = value` keeps the key name and redacts the value; the
169
+ * key matcher starts at the first identifier char, so a leading `--` or
170
+ * punctuation (`(--api-key=…`, `TOKEN = …`) is ignored and still caught.
171
+ * 2. A flag followed by a separate value (`--flag value` / `-f value`, with any
172
+ * leading punctuation) redacts the value — including a quoted span with
173
+ * internal spaces, even if the closing quote is missing — unless the value
174
+ * is itself another flag.
175
+ * A value is matched by `VALUE` below: a single/double/back-quoted span whose
176
+ * closing quote is optional (so an unterminated quote is consumed to the value's
177
+ * end, never leaking its spaced tail), otherwise a run of non-space characters.
178
+ */
179
+ // agent-standards-ignore prefer-inline-single-use-helper: distinct multi-pass
180
+ // secret-redaction (key=value assignments, flag values, quoted/unterminated
181
+ // spans); composed with the path-redaction pass in sanitizeOperatorReason and
182
+ // kept separate for readability of each fail-closed pass.
183
+ function redactSecretAssignments(value) {
184
+ // A secret value: a (possibly unterminated) quoted span, else a non-space run.
185
+ const VALUE = `(?:"(?:\\\\.|[^"])*"?|'(?:\\\\.|[^'])*'?|\`(?:\\\\.|[^\`])*\`?|\\S+)`;
186
+ let out = value;
187
+ // 1) key=value / key = value → keep the key name, redact the value.
188
+ out = out.replace(new RegExp(`([A-Za-z_][A-Za-z0-9_.-]*)\\s*=\\s*${VALUE}`, "g"), (_match, key) => `${key}=<redacted>`);
189
+ // 2) `--flag value` / `-f value` (any leading punctuation) → redact the value
190
+ // unless it is itself another flag.
191
+ out = out.replace(new RegExp(`(--?[A-Za-z][A-Za-z0-9_-]*)\\s+(?!--?[A-Za-z])${VALUE}`, "g"), (_match, flag) => `${flag} <redacted>`);
192
+ return out;
193
+ }
194
+ /**
195
+ * Replace filesystem-path-like tokens with `<path>` so operator-authored
196
+ * free-form strings (reasons, echoed targets) can never surface private
197
+ * absolute paths, unix socket paths, or repo-relative paths in operator/JSON
198
+ * output. Conservative but fail-closed on ambiguous relative paths: any
199
+ * single-separator token that is not a known prose connective (see
200
+ * `PROSE_SLASH_TOKENS`) is redacted, so `accounts/acme` is caught while
201
+ * "and/or" survives. This is a redaction-by-construction boundary, not a
202
+ * security parser.
203
+ */
204
+ export function redactPathLikeTokens(value) {
205
+ return value
206
+ .split(/(\s+)/)
207
+ .map((token) => {
208
+ if (token.length === 0 || /\s/.test(token))
209
+ return token;
210
+ const slashCount = (token.match(/[/\\]/g) ?? []).length;
211
+ // Strip trailing sentence punctuation before the prose-allowlist check so
212
+ // "and/or," is still recognized as prose.
213
+ const bare = token.replace(/[.,;:!?)\]]+$/, "").toLowerCase();
214
+ const isProse = slashCount === 1 && PROSE_SLASH_TOKENS.has(bare);
215
+ const pathLike = !isProse &&
216
+ (/^[~/]/.test(token) || // /abs or ~/home
217
+ /^\.\.?[/\\]/.test(token) || // ./rel or ../rel
218
+ /^[A-Za-z]:[\\/]/.test(token) || // C:\ or C:/ (Windows)
219
+ slashCount >= 1); // any relative/absolute separator (fail-closed)
220
+ return pathLike ? "<path>" : token;
221
+ })
222
+ .join("");
223
+ }
224
+ /**
225
+ * Redaction-by-construction for RUNTIME-authored checkpoint reasons. A checkpoint
226
+ * reason is authored by the worker runtime, so — unlike the trusted-operator
227
+ * reason path (which only redacts obvious path-like tokens) — it must NOT be able
228
+ * to smuggle argv, env assignments (`TOKEN=secret`), CLI flags (`--api-key x`),
229
+ * extensionless relative paths (`accounts/acme`), or other free prose onto any
230
+ * operator/telemetry/JSON surface. We therefore allowlist by *shape*: only a
231
+ * short single-token machine code (`active_port_lease`, `checkpoint_timeout`, …)
232
+ * is passed through; anything containing whitespace, separators, or exotic
233
+ * characters collapses to the static `unspecified` code. Diagnostic prose belongs
234
+ * in the worker's own logs, not the broker's operator surface.
235
+ */
236
+ export function sanitizeCheckpointReasonCode(value) {
237
+ if (value == null)
238
+ return "unspecified";
239
+ const trimmed = value.trim();
240
+ return /^[A-Za-z][A-Za-z0-9_]{0,47}$/.test(trimmed) ? trimmed.toLowerCase() : "unspecified";
241
+ }
242
+ /**
243
+ * Compose an operator-safe, actionable per-agent lifecycle status from already
244
+ * sanitized inputs. Pure: it performs no IO and never emits raw argv, env
245
+ * values, or filesystem/socket paths.
246
+ */
247
+ export function buildAgentLifecycleStatus(input) {
248
+ const now = input.now ?? Date.now();
249
+ const state = input.agent.lifecycleState ?? "live";
250
+ const checkpoint = input.latestCheckpoint;
251
+ const checkpointCreatedMs = checkpoint ? Date.parse(checkpoint.createdAt) : Number.NaN;
252
+ const checkpointAgeMs = checkpoint && Number.isFinite(checkpointCreatedMs)
253
+ ? Math.max(now - checkpointCreatedMs, 0)
254
+ : null;
255
+ let queuePosition = null;
256
+ let queuedEntry = null;
257
+ const orderedQueue = input.orderedWakeQueue ?? [];
258
+ for (let index = 0; index < orderedQueue.length; index += 1) {
259
+ const entry = orderedQueue[index];
260
+ if (entry.agentId === input.agent.id && entry.status === "queued") {
261
+ queuePosition = index + 1;
262
+ queuedEntry = entry;
263
+ break;
264
+ }
265
+ }
266
+ let refusal = null;
267
+ for (const event of input.recentEvents ?? []) {
268
+ if (event.agentId !== input.agent.id || event.outcome === "accepted")
269
+ continue;
270
+ if (refusal === null || event.createdAt > refusal.at) {
271
+ refusal = {
272
+ reason: sanitizeOperatorReason(event.errorCode ?? event.reason) ?? "unknown",
273
+ outcome: event.outcome,
274
+ at: event.createdAt,
275
+ };
276
+ }
277
+ }
278
+ const capacity = input.capacity
279
+ ? {
280
+ global: {
281
+ inflight: input.capacity.inflightWakes,
282
+ max: input.capacity.maxConcurrentWakes,
283
+ atCapacity: input.capacity.inflightWakes >= input.capacity.maxConcurrentWakes,
284
+ },
285
+ repo: {
286
+ inflight: input.capacity.inflightWakesForRepo,
287
+ max: input.capacity.maxConcurrentWakesPerRepo,
288
+ atCapacity: input.capacity.inflightWakesForRepo >= input.capacity.maxConcurrentWakesPerRepo,
289
+ },
290
+ }
291
+ : null;
292
+ return {
293
+ agentId: input.agent.id,
294
+ state,
295
+ lifecycleVersion: input.agent.lifecycleVersion ?? 0,
296
+ runtimeGeneration: input.agent.runtimeGeneration ?? null,
297
+ hibernatePolicy: input.agent.hibernatePolicy ?? null,
298
+ hibernatedAt: input.agent.hibernatedAt ?? null,
299
+ hibernateReason: sanitizeOperatorReason(input.agent.hibernateReason),
300
+ lastWakeReason: sanitizeOperatorReason(input.agent.lastWakeReason),
301
+ graceUntil: input.agent.graceUntil ?? null,
302
+ idleEligibleAt: input.agent.idleEligibleAt ?? null,
303
+ quarantined: state === "reap-candidate",
304
+ checkpoint: {
305
+ present: Boolean(checkpoint),
306
+ hibernateSafe: checkpoint ? checkpoint.hibernateSafe : null,
307
+ ageMs: checkpointAgeMs,
308
+ pendingInboxCount: checkpoint ? checkpoint.pendingInboxCount : null,
309
+ runtimeGeneration: checkpoint ? checkpoint.runtimeGeneration : null,
310
+ },
311
+ runtimeSpec: input.runtimeSpec ? redactRuntimeSpec(input.runtimeSpec) : null,
312
+ wake: {
313
+ queued: queuedEntry !== null,
314
+ position: queuePosition,
315
+ triggerKind: queuedEntry ? queuedEntry.triggerKind : null,
316
+ reason: queuedEntry ? sanitizeOperatorReason(queuedEntry.reason) : null,
317
+ attempt: queuedEntry ? queuedEntry.attempt : null,
318
+ },
319
+ capacity,
320
+ refusal,
321
+ };
322
+ }
323
+ /**
324
+ * Render a single scannable, operator-safe lifecycle tag for inline use in the
325
+ * `agents`/`sessions` compact read paths. Sanitized: state, generation, queue
326
+ * position, checkpoint age/safety, and refusal/quarantine reason code only.
327
+ */
328
+ export function formatAgentLifecycleTag(status) {
329
+ const parts = [status.state];
330
+ if (status.runtimeGeneration !== null)
331
+ parts.push(`gen${status.runtimeGeneration}`);
332
+ if (status.wake.queued && status.wake.position !== null)
333
+ parts.push(`q#${status.wake.position}`);
334
+ if (status.checkpoint.present) {
335
+ const age = status.checkpoint.ageMs === null ? "?" : `${Math.round(status.checkpoint.ageMs / 1000)}s`;
336
+ parts.push(`ckpt ${age}${status.checkpoint.hibernateSafe === false ? " unsafe" : ""}`);
337
+ }
338
+ if (status.quarantined) {
339
+ parts.push(`\u26a0${status.refusal ? ` ${status.refusal.reason}` : ""}`);
340
+ }
341
+ else if (status.refusal) {
342
+ parts.push(`refused:${status.refusal.reason}`);
343
+ }
344
+ return parts.join(" \u00b7 ");
345
+ }
346
+ /**
347
+ * Render a compact, operator-safe status block. Contains only sanitized
348
+ * lifecycle facts — never prompts, message bodies, tokens, argv, env values, or
349
+ * filesystem paths.
350
+ */
351
+ export function formatAgentLifecycleStatus(status) {
352
+ const lines = [];
353
+ const generation = status.runtimeGeneration === null ? "-" : String(status.runtimeGeneration);
354
+ lines.push(`${status.agentId}: ${status.state} (v${status.lifecycleVersion}, gen ${generation}, policy ${status.hibernatePolicy ?? "-"})`);
355
+ if (status.quarantined) {
356
+ lines.push(` \u26a0 quarantined as reap-candidate${status.hibernateReason ? ` — ${status.hibernateReason}` : ""}`);
357
+ }
358
+ if (status.checkpoint.present) {
359
+ const ageSeconds = status.checkpoint.ageMs === null ? "?" : Math.round(status.checkpoint.ageMs / 1000);
360
+ const safe = status.checkpoint.hibernateSafe === false ? "unsafe" : "safe";
361
+ lines.push(` checkpoint: ${safe}, age=${ageSeconds}s, pending_inbox=${status.checkpoint.pendingInboxCount ?? 0}, gen=${status.checkpoint.runtimeGeneration ?? "-"}`);
362
+ }
363
+ else if (DURABLE_HIBERNATION_STATES.has(status.state)) {
364
+ lines.push(" checkpoint: none recorded");
365
+ }
366
+ if (status.runtimeSpec) {
367
+ const spec = status.runtimeSpec;
368
+ lines.push(` runtime spec: present (repo=${spec.repo ?? "-"}, worktree=${spec.hasWorktree ? "yes" : "no"}, runtime=${spec.runtimeKind}, env_allow=${spec.envAllowlistCount}, fingerprint=${spec.configFingerprint})`);
369
+ }
370
+ else if (DURABLE_HIBERNATION_STATES.has(status.state)) {
371
+ lines.push(" runtime spec: MISSING");
372
+ }
373
+ if (status.wake.queued) {
374
+ const position = status.wake.position === null ? "?" : String(status.wake.position);
375
+ lines.push(` wake queued: position ${position}, trigger=${status.wake.triggerKind ?? "-"}, reason=${status.wake.reason ?? "-"}, attempt=${status.wake.attempt ?? 0}`);
376
+ }
377
+ if (status.capacity) {
378
+ const { global, repo } = status.capacity;
379
+ const globalMark = global.atCapacity ? " (at capacity)" : "";
380
+ const repoMark = repo.atCapacity ? " (at capacity)" : "";
381
+ lines.push(` wake capacity: global ${global.inflight}/${global.max}${globalMark}, repo ${repo.inflight}/${repo.max}${repoMark}`);
382
+ }
383
+ if (status.refusal) {
384
+ lines.push(` last refusal: ${status.refusal.reason} (${status.refusal.outcome}) at ${status.refusal.at}`);
385
+ }
386
+ if (status.lastWakeReason) {
387
+ lines.push(` last wake reason: ${status.lastWakeReason}`);
388
+ }
389
+ return lines.join("\n");
390
+ }
@@ -0,0 +1,54 @@
1
+ import type { AgentLifecycleEvent, AgentLifecycleRetentionInfo } from "./types.js";
2
+ /**
3
+ * Read-only, sanitized rollup of hibernation lifecycle telemetry.
4
+ *
5
+ * This is the operable status surface over the append-only
6
+ * `agent_lifecycle_events` table (see `getRecentAgentLifecycleEvents`). It is a
7
+ * pure derivation of already-sanitized events — it never reads prompts, message
8
+ * bodies, tokens, or environment values — so it is safe to render in a CLI,
9
+ * dashboard, or Slack status reply without touching the live broker.
10
+ *
11
+ * The counts mirror the documented dogfood report query so a fleet operator can
12
+ * reconcile this in-process summary against the raw SQL.
13
+ */
14
+ export interface HibernationTelemetrySummary {
15
+ /** Total events considered. */
16
+ totalEvents: number;
17
+ /** Accepted transitions into `hibernated`. */
18
+ hibernations: number;
19
+ /** Accepted `waking -> live` transitions. */
20
+ wakeSuccesses: number;
21
+ /** Events whose outcome was not `accepted` (refusals, stale fences, aborts). */
22
+ failures: number;
23
+ /** Mean accepted wake latency in ms, or null when no accepted wake is present. */
24
+ meanWakeMs: number | null;
25
+ /** Nearest-rank 95th percentile accepted wake latency in ms, or null. */
26
+ p95WakeMs: number | null;
27
+ /** Largest observed queue depth across events. */
28
+ maxQueueDepth: number;
29
+ /** Largest observed oldest-queued-message age in ms across events. */
30
+ maxOldestQueueAgeMs: number;
31
+ /** Sum of max(rssBefore - rssAfter, 0) over accepted hibernations, in bytes. */
32
+ recoveredRssBytes: number;
33
+ /** Non-accepted outcome reasons, most frequent first. */
34
+ refusalReasons: Array<{
35
+ reason: string;
36
+ count: number;
37
+ }>;
38
+ /** Distinct agents represented in the event window. */
39
+ agentCount: number;
40
+ /** Retained/pruned counters when retention info is provided. */
41
+ retainedCount: number | null;
42
+ prunedCount: number | null;
43
+ lastPrunedAt: string | null;
44
+ }
45
+ /**
46
+ * Summarize a window of lifecycle events into an operable status rollup. Pass
47
+ * the newest events (any order); retention info is optional passthrough.
48
+ */
49
+ export declare function summarizeHibernationTelemetry(events: AgentLifecycleEvent[], retention?: AgentLifecycleRetentionInfo): HibernationTelemetrySummary;
50
+ /**
51
+ * Render a compact, human-readable status block from a telemetry summary. Safe
52
+ * for CLI/Slack output: it contains only aggregate counters, never bodies.
53
+ */
54
+ export declare function formatHibernationTelemetry(summary: HibernationTelemetrySummary): string;