@crewhaus/gateway-protocol 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Codegen for `crewhaus.control.v1` — the ONE place the control-plane wiring
3
+ * that lands in an emitted daemon is written.
4
+ *
5
+ * Five targets emit a daemon (channel, managed, batch, crew, voice). If each
6
+ * of them spelled the control wiring itself, the five would drift the first
7
+ * time one of them was touched, and a supervisor would face five subtly
8
+ * different control planes. So the emitters describe their lanes and
9
+ * side-channels declaratively and this module renders the text; the runtime
10
+ * behind it lives in `./control`, which the emitted daemon imports.
11
+ *
12
+ * Every renderer takes an `indent` and applies it to every line it produces,
13
+ * so a caller can splice a block at any nesting depth without reflowing it.
14
+ */
15
+ /** Package specifier the emitted daemon imports the control runtime from. */
16
+ export declare const CONTROL_RUNTIME_MODULE: "@crewhaus/gateway-protocol/control";
17
+ export type ControlPlaneEmit = {
18
+ /** Spec name — reported by `/control/v1/healthz` and `/status`. */
19
+ readonly name: string;
20
+ /** Target discriminator (`channel` | `managed` | `batch` | `crew` | `voice`). */
21
+ readonly target: string;
22
+ /** Identifier the plane is bound to in the emitted daemon. Default `__control`. */
23
+ readonly varName?: string;
24
+ /** JS expression evaluating to `string[]` — the channels this daemon serves. */
25
+ readonly channelsExpr?: string;
26
+ /** JS expression evaluating to `number | Promise<number>` — parked approvals. */
27
+ readonly pendingApprovalsExpr?: string;
28
+ /**
29
+ * JS expression evaluating to the harness's `AuditLog` (or `undefined`).
30
+ * Every control call appends a `gateway_request` record through it.
31
+ */
32
+ readonly auditLogExpr?: string;
33
+ readonly indent?: string;
34
+ };
35
+ /**
36
+ * The import line for the control runtime. Emitted unconditionally by every
37
+ * daemon-shape target — control.v1 is the lowest common denominator all
38
+ * daemon shapes share, so a bundle either has it or is a pre-0.5.0 bundle.
39
+ */
40
+ export declare function renderControlImports(opts?: {
41
+ readonly pendingApprovals?: boolean;
42
+ /** Pull in {@link runDrainSweep} — a drain step that sweeps housekeeping. */
43
+ readonly drainSweep?: boolean;
44
+ /** Pull in {@link sanitizeControlText} — a daemon that logs untrusted text. */
45
+ readonly logSafe?: boolean;
46
+ }): string;
47
+ /**
48
+ * `const __control = createControlPlane({ … });`
49
+ *
50
+ * Binding is deferred to {@link renderControlStart} so a daemon can register
51
+ * its lanes and drain steps (which close over the server/janitor it creates
52
+ * later) before the socket accepts a single request.
53
+ */
54
+ export declare function renderControlPlaneBoot(opts: ControlPlaneEmit): string;
55
+ export type ControlLaneEmit = {
56
+ /** The lane an operator can poke. */
57
+ readonly lane: "heartbeat" | "schedule";
58
+ /** Identifier the lane handle binds to (e.g. `__heartbeatLane`). */
59
+ readonly varName: string;
60
+ /** Human cadence for `/status` — e.g. `every 60000ms`. */
61
+ readonly cadence: string;
62
+ /** Fixed interval, when the lane has one; projects `nextDueAt`. */
63
+ readonly everyMs?: number;
64
+ /** JS expression returning `string | undefined` — overrides the projection. */
65
+ readonly nextDueAtExpr?: string;
66
+ /**
67
+ * `false` for a lane whose body does NOT thread `__tick.sessionId` into a
68
+ * session of its own (the managed fan-out, the batch producer). Such a lane
69
+ * writes no wake marker and its 202 omits `sessionId` instead of handing the
70
+ * operator an id that names nothing — see `ControlLaneOptions.ownsSession`.
71
+ */
72
+ readonly ownsSession?: boolean;
73
+ /**
74
+ * The tick body. Rendered inside `async (__tick) => { … }`, so it can read
75
+ * `__tick.sessionId` (minted by the lane, identical for timer + operator
76
+ * fires) and `__tick.synthetic`.
77
+ */
78
+ readonly body: string;
79
+ readonly planeVar?: string;
80
+ readonly indent?: string;
81
+ };
82
+ /**
83
+ * Register a pokeable lane. The returned handle's `tick()` is what the timer
84
+ * calls and `wake()` is what `POST /control/v1/wake` calls — ONE body, so the
85
+ * operator poke can never diverge from the organic fire, and the lane's
86
+ * never-overlap guard covers both (a wake during an in-flight tick 409s).
87
+ */
88
+ export declare function renderControlLane(opts: ControlLaneEmit): string;
89
+ /**
90
+ * A read-only timer row for `/control/v1/status` (the janitor and dream lanes
91
+ * are observed, not pokeable). `expr` is a JS expression evaluating to a
92
+ * `ControlTimerReport`.
93
+ */
94
+ export declare function renderControlTimer(opts: {
95
+ readonly expr: string;
96
+ readonly planeVar?: string;
97
+ readonly indent?: string;
98
+ }): string;
99
+ /**
100
+ * Register a drain step. `POST /control/v1/drain` stops intake first (the
101
+ * public gate starts answering 503 + Retry-After the moment the request lands),
102
+ * waits out in-flight lane ticks, then runs these steps in order and exits 0.
103
+ */
104
+ export declare function renderControlDrain(opts: {
105
+ readonly body: string;
106
+ readonly planeVar?: string;
107
+ readonly indent?: string;
108
+ }): string;
109
+ /** `await __control.start();` — binds the dedicated port when one is configured. */
110
+ export declare function renderControlStart(opts: {
111
+ readonly planeVar?: string;
112
+ readonly indent?: string;
113
+ }): string;
114
+ /**
115
+ * The PUBLIC-port wrapper: a bare unauthenticated `GET /healthz` (liveness
116
+ * only, no state — this is what deployment scaffolds' health checks have been
117
+ * declaring against nothing) plus the 503 + `Retry-After` intake shed once the
118
+ * daemon is draining. Everything else falls through to `innerExpr`.
119
+ *
120
+ * Renders an inline arrow suitable as a `Bun.serve({ fetch })` value.
121
+ */
122
+ export declare function renderPublicGateFetch(opts: {
123
+ readonly innerExpr: string;
124
+ readonly planeVar?: string;
125
+ }): string;
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Codegen for `crewhaus.control.v1` — the ONE place the control-plane wiring
3
+ * that lands in an emitted daemon is written.
4
+ *
5
+ * Five targets emit a daemon (channel, managed, batch, crew, voice). If each
6
+ * of them spelled the control wiring itself, the five would drift the first
7
+ * time one of them was touched, and a supervisor would face five subtly
8
+ * different control planes. So the emitters describe their lanes and
9
+ * side-channels declaratively and this module renders the text; the runtime
10
+ * behind it lives in `./control`, which the emitted daemon imports.
11
+ *
12
+ * Every renderer takes an `indent` and applies it to every line it produces,
13
+ * so a caller can splice a block at any nesting depth without reflowing it.
14
+ */
15
+ /** Package specifier the emitted daemon imports the control runtime from. */
16
+ export const CONTROL_RUNTIME_MODULE = "@crewhaus/gateway-protocol/control";
17
+ function indentBlock(text, indent) {
18
+ if (indent === "")
19
+ return text;
20
+ return text
21
+ .split("\n")
22
+ .map((line) => (line === "" ? "" : `${indent}${line}`))
23
+ .join("\n");
24
+ }
25
+ /** JSON-quote a string for embedding in emitted source. */
26
+ function q(value) {
27
+ return JSON.stringify(value);
28
+ }
29
+ /**
30
+ * The import line for the control runtime. Emitted unconditionally by every
31
+ * daemon-shape target — control.v1 is the lowest common denominator all
32
+ * daemon shapes share, so a bundle either has it or is a pre-0.5.0 bundle.
33
+ */
34
+ export function renderControlImports(opts = {}) {
35
+ const names = [
36
+ ...(opts.pendingApprovals === true ? ["countPendingApprovals"] : []),
37
+ "createControlPlane",
38
+ ...(opts.drainSweep === true ? ["runDrainSweep"] : []),
39
+ ...(opts.logSafe === true ? ["sanitizeControlText"] : []),
40
+ ].join(", ");
41
+ return `import { ${names} } from ${q(CONTROL_RUNTIME_MODULE)};\n`;
42
+ }
43
+ /**
44
+ * `const __control = createControlPlane({ … });`
45
+ *
46
+ * Binding is deferred to {@link renderControlStart} so a daemon can register
47
+ * its lanes and drain steps (which close over the server/janitor it creates
48
+ * later) before the socket accepts a single request.
49
+ */
50
+ export function renderControlPlaneBoot(opts) {
51
+ const v = opts.varName ?? "__control";
52
+ const fields = [
53
+ `name: ${q(opts.name)},`,
54
+ `target: ${q(opts.target)},`,
55
+ ...(opts.channelsExpr !== undefined ? [`channels: () => ${opts.channelsExpr},`] : []),
56
+ ...(opts.pendingApprovalsExpr !== undefined
57
+ ? [`pendingApprovals: async () => ${opts.pendingApprovalsExpr},`]
58
+ : []),
59
+ ...(opts.auditLogExpr !== undefined
60
+ ? [
61
+ "audit: async (__rec) => {",
62
+ ` const __log = ${opts.auditLogExpr};`,
63
+ " if (__log !== undefined) await __log.append(__rec);",
64
+ "},",
65
+ ]
66
+ : []),
67
+ ];
68
+ const body = [
69
+ "// crewhaus.control.v1 — the supervisor-facing control plane (§ dedicated",
70
+ "// port from CREWHAUS_CONTROL_PORT, bearer from CREWHAUS_CONTROL_TOKEN or a",
71
+ "// boot-minted .crewhaus/run/control-token). Unset port ⇒ no socket opens.",
72
+ `const ${v} = createControlPlane({`,
73
+ ...fields.map((f) => ` ${f}`),
74
+ "});",
75
+ ].join("\n");
76
+ return `${indentBlock(body, opts.indent ?? "")}\n`;
77
+ }
78
+ /**
79
+ * Register a pokeable lane. The returned handle's `tick()` is what the timer
80
+ * calls and `wake()` is what `POST /control/v1/wake` calls — ONE body, so the
81
+ * operator poke can never diverge from the organic fire, and the lane's
82
+ * never-overlap guard covers both (a wake during an in-flight tick 409s).
83
+ */
84
+ export function renderControlLane(opts) {
85
+ const plane = opts.planeVar ?? "__control";
86
+ const body = [
87
+ `const ${opts.varName} = ${plane}.lane({`,
88
+ ` lane: ${q(opts.lane)},`,
89
+ ` cadence: ${q(opts.cadence)},`,
90
+ ...(opts.everyMs !== undefined ? [` everyMs: ${opts.everyMs},`] : []),
91
+ ...(opts.nextDueAtExpr !== undefined ? [` nextDueAt: () => ${opts.nextDueAtExpr},`] : []),
92
+ ...(opts.ownsSession === false ? [" ownsSession: false,"] : []),
93
+ " run: async (__tick) => {",
94
+ indentBlock(opts.body, " "),
95
+ " },",
96
+ "});",
97
+ ].join("\n");
98
+ return `${indentBlock(body, opts.indent ?? "")}\n`;
99
+ }
100
+ /**
101
+ * A read-only timer row for `/control/v1/status` (the janitor and dream lanes
102
+ * are observed, not pokeable). `expr` is a JS expression evaluating to a
103
+ * `ControlTimerReport`.
104
+ */
105
+ export function renderControlTimer(opts) {
106
+ const plane = opts.planeVar ?? "__control";
107
+ return `${indentBlock(`${plane}.timer(() => (${opts.expr}));`, opts.indent ?? "")}\n`;
108
+ }
109
+ /**
110
+ * Register a drain step. `POST /control/v1/drain` stops intake first (the
111
+ * public gate starts answering 503 + Retry-After the moment the request lands),
112
+ * waits out in-flight lane ticks, then runs these steps in order and exits 0.
113
+ */
114
+ export function renderControlDrain(opts) {
115
+ const plane = opts.planeVar ?? "__control";
116
+ const body = [`${plane}.onDrain(async () => {`, indentBlock(opts.body, " "), "});"].join("\n");
117
+ return `${indentBlock(body, opts.indent ?? "")}\n`;
118
+ }
119
+ /** `await __control.start();` — binds the dedicated port when one is configured. */
120
+ export function renderControlStart(opts) {
121
+ const plane = opts.planeVar ?? "__control";
122
+ return `${indentBlock(`await ${plane}.start();`, opts.indent ?? "")}\n`;
123
+ }
124
+ /**
125
+ * The PUBLIC-port wrapper: a bare unauthenticated `GET /healthz` (liveness
126
+ * only, no state — this is what deployment scaffolds' health checks have been
127
+ * declaring against nothing) plus the 503 + `Retry-After` intake shed once the
128
+ * daemon is draining. Everything else falls through to `innerExpr`.
129
+ *
130
+ * Renders an inline arrow suitable as a `Bun.serve({ fetch })` value.
131
+ */
132
+ export function renderPublicGateFetch(opts) {
133
+ const plane = opts.planeVar ?? "__control";
134
+ return `(__req) => ${plane}.publicGate(__req) ?? (${opts.innerExpr})(__req)`;
135
+ }
@@ -0,0 +1,329 @@
1
+ /**
2
+ * `crewhaus.control.v1` — the daemon control plane every daemon-shape bundle
3
+ * serves.
4
+ *
5
+ * WHY THIS EXISTS. A compiled daemon's schedulers are in-process
6
+ * (`setInterval` for `heartbeat:`, `armSchedule` for `schedule:`), so from
7
+ * the outside there is no way to ask "when does the next heartbeat fire?" and
8
+ * no way to make one fire now: the phase of a heartbeat is knowable ONLY
9
+ * inside the process that armed it. A supervisor that wants to drive — not
10
+ * just watch — a fleet of daemons needs a uniform, signal-free surface. This
11
+ * module is that surface, written ONCE and consumed by every daemon-emitting
12
+ * target (channel, managed, batch, crew, voice) so the five shapes can never
13
+ * drift apart.
14
+ *
15
+ * SHAPE OF THE CONTRACT.
16
+ * - A DEDICATED control port, separate from any public webhook/gateway
17
+ * port. Default bind `127.0.0.1`; the port comes from
18
+ * `CREWHAUS_CONTROL_PORT` (`0` asks the kernel for an ephemeral port,
19
+ * which is then reported on stdout). Unset ⇒ no control socket at all,
20
+ * so upgrading a bundle never opens a listener nobody asked for.
21
+ * Exposing it on a PaaS is an explicit opt-in: set the bind + token as
22
+ * provider secrets at deploy time.
23
+ * - Bearer auth against `CREWHAUS_CONTROL_TOKEN`, or a token minted at boot
24
+ * into `<cwd>/.crewhaus/run/control-token` (0600) so a local manager can
25
+ * read it off disk. Compared in constant time; never logged, never
26
+ * echoed, never written to an audit payload.
27
+ * - `GET /control/v1/healthz` → `{ok, name, target}`
28
+ * - `GET /control/v1/status` → counters + per-lane timers + channels +
29
+ * pending approvals
30
+ * - `POST /control/v1/wake` → one synthetic tick down the IDENTICAL code
31
+ * path as the timer fire
32
+ * - `POST /control/v1/drain` → stop intake, finish in-flight work, exit 0
33
+ * - Every call appends a `gateway_request` record to the harness's
34
+ * hash-chained audit log when one is wired.
35
+ *
36
+ * Separately — and INDEPENDENT of whether the control port is bound — a bare,
37
+ * unauthenticated `GET /healthz` is served on the daemon's PUBLIC port when it
38
+ * has one ({@link ControlPlane.publicGate}). Deployment scaffolds declare that
39
+ * health check but no daemon served it; the liveness answer carries no state,
40
+ * so closing that gap never exposes control.
41
+ *
42
+ * TESTABILITY. `fetch` is a plain `Request → Response` function, so the whole
43
+ * router (auth, wake, 409-while-in-flight, drain) is exercisable with no
44
+ * socket, no timers and no daemon. `start()` is the only part that touches
45
+ * `Bun.serve`.
46
+ */
47
+ /** Wire identifier for this control protocol. Bumping it is a breaking change. */
48
+ export declare const CONTROL_PROTOCOL: "crewhaus.control.v1";
49
+ /** Every control route lives under this prefix. */
50
+ export declare const CONTROL_PATH_PREFIX: "/control/v1";
51
+ /** Harness-local ops directory. All daemon-written control state lives here. */
52
+ export declare const CONTROL_RUN_DIR: ".crewhaus/run";
53
+ /** File the boot-minted bearer token lands in, mode 0600. */
54
+ export declare const CONTROL_TOKEN_FILENAME: "control-token";
55
+ export declare const CONTROL_BIND_ENV: "CREWHAUS_CONTROL_BIND";
56
+ export declare const CONTROL_PORT_ENV: "CREWHAUS_CONTROL_PORT";
57
+ export declare const CONTROL_TOKEN_ENV: "CREWHAUS_CONTROL_TOKEN";
58
+ /** Loopback-only unless the operator explicitly widens it. */
59
+ export declare const DEFAULT_CONTROL_BIND: "127.0.0.1";
60
+ /**
61
+ * Longest free-text a control-plane field (`reason`, `by`) may carry into a
62
+ * daemon's prose log, and the default cap {@link sanitizeControlText} applies.
63
+ */
64
+ export declare const CONTROL_TEXT_MAX = 200;
65
+ /**
66
+ * Budget (ms) for a best-effort housekeeping step run during a drain, and the
67
+ * env var that widens it. `0` skips the step entirely.
68
+ */
69
+ export declare const DRAIN_SWEEP_BUDGET_ENV: "CREWHAUS_DRAIN_SWEEP_MS";
70
+ export declare const DEFAULT_DRAIN_SWEEP_MS = 5000;
71
+ /** `Retry-After` (seconds) on the 503 a draining daemon answers intake with. */
72
+ export declare const DRAIN_RETRY_AFTER_SECONDS = 15;
73
+ /** The two lanes an operator can poke. Both are in-process timers. */
74
+ export type ControlLane = "heartbeat" | "schedule";
75
+ export type ControlOutcome = "ok" | "error";
76
+ /** Counters `/control/v1/status` reports. Mutated in place by the daemon. */
77
+ export type ControlCounters = {
78
+ turns: number;
79
+ heartbeatTicks: number;
80
+ scheduleWakes: number;
81
+ janitorRuns: number;
82
+ };
83
+ export type ControlTimerReport = {
84
+ readonly lane: string;
85
+ /** Human cadence, e.g. `every 60000ms` or `cron "0 * * * *" UTC`. */
86
+ readonly cadence: string;
87
+ readonly lastFiredAt?: string;
88
+ readonly lastOutcome?: ControlOutcome;
89
+ readonly nextDueAt?: string;
90
+ };
91
+ /** What a lane's tick body receives. `synthetic` is set only for operator pokes. */
92
+ export type ControlTickContext = {
93
+ readonly sessionId: string;
94
+ readonly synthetic?: {
95
+ readonly reason: string;
96
+ readonly by: string;
97
+ };
98
+ };
99
+ export type ControlLaneOptions = {
100
+ readonly lane: ControlLane;
101
+ readonly cadence: string;
102
+ /**
103
+ * Interval in ms, when the lane has a fixed one. Used to project
104
+ * `nextDueAt` from the last fire — the number no offline reader can
105
+ * compute for a heartbeat, which is the entire reason `/status` exists.
106
+ */
107
+ readonly everyMs?: number;
108
+ /** Overrides the `everyMs` projection (cron lanes supply their own). */
109
+ readonly nextDueAt?: () => string | undefined;
110
+ /**
111
+ * Does this lane's body actually THREAD `ctx.sessionId` into a session?
112
+ *
113
+ * On the channel shape it does — the tick calls `agent.runTurn({sessionId})`,
114
+ * so the id the 202 hands back names a transcript the operator can open, and
115
+ * writing the poke marker into that session's event log is what attributes
116
+ * the turn to the operator who asked for it.
117
+ *
118
+ * On the multi-tenant (managed) and producer (batch) shapes it does NOT: one
119
+ * tick fans out to a turn per tenant, each in its own session, or enqueues a
120
+ * job whose handler mints its own. Advertising the lane's id there promised a
121
+ * transcript that never existed, and the marker it left behind was a `.jsonl`
122
+ * with no `.json` beside it — a file `sweepExpired`, `crewhaus retention` and
123
+ * the janitor's TTL eviction all skip BY DESIGN, so it outlived every
124
+ * retention policy the harness has. Such a lane declares `ownsSession: false`:
125
+ * no marker is written, and the 202 omits `sessionId` rather than lying. The
126
+ * poke stays evidenced through the control plane's `gateway_request` audit
127
+ * record, which carries the same `reason`/`by` and IS covered by retention.
128
+ *
129
+ * Default `true` (the channel shape's behaviour).
130
+ */
131
+ readonly ownsSession?: boolean;
132
+ /**
133
+ * The tick body. The SAME function backs the timer fire and the operator
134
+ * wake — there is deliberately no second code path to drift.
135
+ */
136
+ readonly run: (ctx: ControlTickContext) => Promise<unknown>;
137
+ };
138
+ export interface ControlLaneHandle {
139
+ readonly lane: ControlLane;
140
+ /** True while a tick for this lane is executing. Ticks never overlap. */
141
+ busy(): boolean;
142
+ /**
143
+ * ORGANIC fire (the timer's own call). Awaits the tick to completion so
144
+ * `armSchedule`'s re-arm-after-resolve rule keeps holding. A fire while the
145
+ * previous tick is still running is dropped, not queued.
146
+ */
147
+ tick(): Promise<void>;
148
+ /**
149
+ * SYNTHETIC fire (an operator poke). Records the marker, starts the tick and
150
+ * returns immediately — the caller answers 202 without waiting for a model
151
+ * round-trip. `sessionId` is present only when the lane owns the session it
152
+ * was minted for (see {@link ControlLaneOptions.ownsSession}); a lane that
153
+ * does not returns none rather than a dangling id.
154
+ */
155
+ wake(args: {
156
+ readonly reason: string;
157
+ readonly by: string;
158
+ }): Promise<{
159
+ readonly accepted: boolean;
160
+ readonly sessionId?: string;
161
+ }>;
162
+ /** Resolves once no tick is in flight (used by drain). */
163
+ settled(): Promise<void>;
164
+ report(): ControlTimerReport;
165
+ }
166
+ export type ControlAuditRecord = {
167
+ readonly kind: "gateway_request";
168
+ readonly payload: Record<string, unknown>;
169
+ };
170
+ export type ControlPlaneOptions = {
171
+ readonly name: string;
172
+ readonly target: string;
173
+ /** Harness root. Defaults to `process.cwd()` — never the bundle dir. */
174
+ readonly cwd?: string;
175
+ readonly env?: Readonly<Record<string, string | undefined>>;
176
+ /** Channel ids this daemon serves (channel shape); empty elsewhere. */
177
+ readonly channels?: () => readonly string[];
178
+ /** Count of parked approvals. Read-only — must never evict or mutate. */
179
+ readonly pendingApprovals?: () => number | Promise<number>;
180
+ /**
181
+ * The harness's existing hash-chained audit log. Every control call appends
182
+ * a `gateway_request` record through it. Absent ⇒ control still works, just
183
+ * un-evidenced (a daemon booted with `CREWHAUS_SECURITY_AUDIT=0`).
184
+ */
185
+ readonly audit?: (record: ControlAuditRecord) => Promise<unknown> | unknown;
186
+ /** Session root for the synthetic-wake marker. Defaults to event-log's. */
187
+ readonly sessionRootDir?: string;
188
+ readonly now?: () => number;
189
+ readonly pid?: number;
190
+ readonly stdout?: (line: string) => void;
191
+ readonly stderr?: (line: string) => void;
192
+ /** Injected for tests; defaults to `process.exit`. */
193
+ readonly exit?: (code: number) => void;
194
+ /**
195
+ * Delay between answering a drain request and running the drain sequence,
196
+ * so the 202 is on the wire before the process starts tearing down.
197
+ */
198
+ readonly drainSettleMs?: number;
199
+ };
200
+ export interface ControlPlane {
201
+ readonly counters: ControlCounters;
202
+ /** Register a pokeable lane. Returns the handle the timer body fires. */
203
+ lane(opts: ControlLaneOptions): ControlLaneHandle;
204
+ /** Register a read-only timer row (janitor, dream) for `/status`. */
205
+ timer(report: () => ControlTimerReport): void;
206
+ /** Register a drain step. Steps run in registration order. */
207
+ onDrain(step: () => Promise<void> | void): void;
208
+ draining(): boolean;
209
+ /** The whole router, socket-free. */
210
+ fetch(req: Request): Promise<Response>;
211
+ /**
212
+ * Bind the dedicated control port when `CREWHAUS_CONTROL_PORT` is set.
213
+ * Returns undefined when control is not configured (the default).
214
+ */
215
+ start(): Promise<{
216
+ readonly port: number;
217
+ readonly url: string;
218
+ } | undefined>;
219
+ stop(): Promise<void>;
220
+ /**
221
+ * The PUBLIC-port gate: answers the bare `GET /healthz` liveness check and,
222
+ * once draining, sheds every other request with `503` + `Retry-After`.
223
+ * Returns undefined when the request should fall through to the daemon's
224
+ * real handler.
225
+ */
226
+ publicGate(req: Request): Response | undefined;
227
+ /** Where the bearer came from. Never returns the token itself. */
228
+ tokenSource(): {
229
+ readonly source: "env" | "file";
230
+ readonly path?: string;
231
+ } | undefined;
232
+ }
233
+ /**
234
+ * Flatten untrusted text into ONE printable log-safe line.
235
+ *
236
+ * WHY THIS EXISTS. A daemon's stdout/stderr is not just for humans: the
237
+ * manager PARSES it — the `[control] crewhaus.control.v1 listening on
238
+ * http://host:port` announcement is the only way it learns a kernel-assigned
239
+ * control port. Every prose line a daemon prints that interpolates text the
240
+ * daemon did not author is therefore a log-injection surface: an operator's
241
+ * `reason`, and — strictly worse, because it needs no operator at all — the
242
+ * AGENT's own turn output, which a channel message can steer. A single
243
+ * newline in either would let that text START a line, and a forged
244
+ * announcement line is enough to repoint the manager's control calls (bearer
245
+ * included) at an attacker-chosen port.
246
+ *
247
+ * So: every control character (C0, DEL + C1) and every Unicode line separator
248
+ * is replaced with a space, whitespace runs collapse, and the result is capped
249
+ * — one line, bounded, no matter what went in. Anchoring the manager's own
250
+ * announcement regex to a line start is the other half of the same fix; this
251
+ * half is what makes the anchor hold, because it guarantees untrusted text can
252
+ * never begin a line.
253
+ */
254
+ export declare function sanitizeControlText(value: string, maxLen?: number): string;
255
+ export type DrainSweepOutcome = "done" | "timeout" | "failed" | "skipped";
256
+ /**
257
+ * Run a BEST-EFFORT housekeeping step during a drain, under its own budget.
258
+ *
259
+ * WHY THIS EXISTS. A drain's contract is "stop intake, finish in-flight work,
260
+ * exit 0", and a supervisor holds it to a deadline. A janitor sweep is not
261
+ * in-flight work — it is housekeeping the next boot repeats — so letting it
262
+ * sit inside that deadline spends the operator's whole drain budget on a step
263
+ * nothing depends on, and the turn the drain existed to finish gets SIGTERM'd
264
+ * anyway. Emitted drain steps therefore close their listeners FIRST and run
265
+ * the sweep through here, last and time-boxed: a slow or wedged sweep can
266
+ * cost at most `budgetMs`, and its failure is reported, never thrown.
267
+ */
268
+ export declare function runDrainSweep(step: () => Promise<unknown> | unknown, opts?: {
269
+ readonly budgetMs?: number;
270
+ readonly env?: Readonly<Record<string, string | undefined>>;
271
+ readonly onOutcome?: (outcome: DrainSweepOutcome, detail?: string) => void;
272
+ }): Promise<DrainSweepOutcome>;
273
+ /**
274
+ * Constant-time string compare. Both sides are hashed first so the comparison
275
+ * is over fixed-length buffers — `timingSafeEqual` throws on length mismatch,
276
+ * and branching on length would itself leak the token's length.
277
+ */
278
+ export declare function constantTimeEquals(a: string, b: string): boolean;
279
+ export type ResolvedControlToken = {
280
+ readonly token: string;
281
+ readonly source: "env" | "file";
282
+ readonly path?: string;
283
+ };
284
+ /**
285
+ * Resolve the control bearer. `CREWHAUS_CONTROL_TOKEN` wins; otherwise a fresh
286
+ * 32-byte token is minted into `<cwd>/.crewhaus/run/control-token` at 0600.
287
+ *
288
+ * Minting FRESH each boot is deliberate: a token left behind by a dead daemon
289
+ * must not authenticate against its replacement, and the manager reads the
290
+ * file after it spawns the process, so there is nothing to preserve.
291
+ */
292
+ export declare function resolveControlToken(opts: {
293
+ readonly cwd: string;
294
+ readonly env?: Readonly<Record<string, string | undefined>>;
295
+ }): ResolvedControlToken;
296
+ /**
297
+ * Append the operator-poke marker to the tick's session log.
298
+ *
299
+ * It is written as a `user_message` carrying `synthetic: true` — the
300
+ * established convention for runtime-injected turns. Every turn-deriving
301
+ * reader in the stack (feedback distill, the eval-judge transcript digest, the
302
+ * session summarizer, the advise rules) already skips `synthetic: true` user
303
+ * messages, so an operator poke can never inflate a turn count or land in a
304
+ * training set as if a human had typed it. The `control` sub-object is what
305
+ * lets evals and watch-me positively IDENTIFY the poke and tell it apart from
306
+ * an organic wake.
307
+ */
308
+ export declare function recordSyntheticWake(args: {
309
+ readonly sessionId: string;
310
+ readonly lane: ControlLane;
311
+ readonly reason: string;
312
+ readonly by: string;
313
+ readonly sessionRootDir?: string;
314
+ }): Promise<void>;
315
+ /** Default filename `@crewhaus/session-store`'s approval store writes. */
316
+ export declare const APPROVALS_FILENAME: "approvals.jsonl";
317
+ /**
318
+ * Count parked approvals WITHOUT calling `PendingApprovalStore.list()`.
319
+ *
320
+ * `list()` compacts the backing file as a side-effect (it drops expired and
321
+ * superseded lines), exactly like `SessionStore.list()`'s TTL eviction. A
322
+ * status endpoint is a read: polling it must never rewrite an operator's
323
+ * approvals ledger. So this folds the JSONL itself — last-wins by `id`, the
324
+ * same upsert rule `persist` documents — and counts the records still awaiting
325
+ * a human. A missing file, a torn tail line, or an unreadable record counts as
326
+ * nothing rather than failing the whole status call.
327
+ */
328
+ export declare function countPendingApprovals(filePath: string): number;
329
+ export declare function createControlPlane(opts: ControlPlaneOptions): ControlPlane;