@cortexkit/subc-client 0.1.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,1005 @@
1
+ import { Buffer } from "node:buffer";
2
+
3
+ import { AuthError, authenticateClient } from "./auth.js";
4
+ import { DEFAULT_RECONNECT_BACKOFF, type BindIdentity, type ReconnectBackoff, type RouteTarget } from "./client.js";
5
+ import { ConnectionFileError, readConnectionFile, type ConnectionInfo } from "./connection-file.js";
6
+ import {
7
+ buildFlags,
8
+ buildFrame,
9
+ buildFrameWithVersion,
10
+ decodeHeader,
11
+ encodeFrame,
12
+ FrameType,
13
+ HEADER_LEN,
14
+ Priority,
15
+ PROTOCOL_VERSION,
16
+ type Frame,
17
+ } from "./envelope.js";
18
+ import {
19
+ SocketClosedError,
20
+ SocketTimeoutError,
21
+ SocketWriteNotQueuedError,
22
+ SocketWriteQueuedError,
23
+ SubcSocket,
24
+ } from "./socket.js";
25
+
26
+ const DEFAULT_HANDSHAKE_TIMEOUT_MS = 10_000;
27
+ const BODY_READ_TIMEOUT_MS = 30_000;
28
+ const WRITE_TIMEOUT_MS = 30_000;
29
+ const DEFAULT_RESTORED_DEBOUNCE_MS = 250;
30
+ export const HELLO_CORR = 1n;
31
+
32
+ export type TrustTier = "first_party" | "reviewed" | "untrusted";
33
+ export type ExecutionMode = "pure" | "mutating" | "unfenceable";
34
+ export type Concurrency = "serial" | "module_managed" | "stateless_parallel";
35
+ export type IdentityScope = "session" | "project";
36
+ export type PipelineStageKind = "transform" | "codec" | "auth";
37
+ export type ManagementOperationKind = "query" | "mutate";
38
+ export type ObservabilityKind = "snapshot" | "stream";
39
+ export type InternalTransport = "bulk";
40
+ export type StorageKind = "sqlite";
41
+ export type StorageScope = "project";
42
+ export type LeaseScope = "project";
43
+
44
+ export interface ManifestInput {
45
+ module_id: string;
46
+ module_version: string;
47
+ protocol_ver: number;
48
+ trust_tier: TrustTier;
49
+ provides: ProviderRoleInput[];
50
+ consumes: ConsumerRoleInput[];
51
+ scheduled_tasks: ScheduledTaskInput[];
52
+ bindings: BindingsInput;
53
+ }
54
+
55
+ export type ProviderRoleInput =
56
+ | {
57
+ role: "tool_provider";
58
+ tools: ToolInput[];
59
+ identity_scope: IdentityScope[];
60
+ concurrency: Concurrency;
61
+ emits_push: boolean;
62
+ sub_supervises: boolean;
63
+ }
64
+ | {
65
+ role: "pipeline_stage";
66
+ stage: PipelineStageKind;
67
+ applies_to: PipelineAppliesToInput;
68
+ interface: string;
69
+ declares_frozen_floor: boolean;
70
+ needs_signals: string[];
71
+ conformance_class: string;
72
+ }
73
+ | {
74
+ role: "management_surface";
75
+ operations: ManagementOperationInput[];
76
+ config_schema: unknown;
77
+ observability: ObservabilitySurfaceInput[];
78
+ identity_scope: IdentityScope[];
79
+ }
80
+ | {
81
+ role: "internal_service";
82
+ service_id: string;
83
+ transport: InternalTransport;
84
+ agent_facing: boolean;
85
+ operations: string[];
86
+ };
87
+
88
+ export interface ToolInput {
89
+ name: string;
90
+ execution_mode: ExecutionMode;
91
+ schema: unknown;
92
+ }
93
+
94
+ export interface PipelineAppliesToInput {
95
+ provider: string;
96
+ model: string;
97
+ }
98
+
99
+ export interface ManagementOperationInput {
100
+ name: string;
101
+ kind: ManagementOperationKind;
102
+ }
103
+
104
+ export interface ObservabilitySurfaceInput {
105
+ name: string;
106
+ kind: ObservabilityKind;
107
+ }
108
+
109
+ export type ConsumerRoleInput =
110
+ | { role: "tool_client"; of: string[] }
111
+ | { role: "llm_client"; via: string; auth: string }
112
+ | { role: "service_client"; of: string[] };
113
+
114
+ export interface ScheduledTaskInput {
115
+ task_id: string;
116
+ eligibility: TaskEligibilityInput;
117
+ lease_scope: LeaseScope;
118
+ renews_during_calls: boolean;
119
+ toolset: string[];
120
+ model_policy: ModelPolicyInput;
121
+ step_cap: number;
122
+ circuit_breaker: CircuitBreakerInput;
123
+ }
124
+
125
+ export interface TaskEligibilityInput {
126
+ cooldown: string;
127
+ window: string;
128
+ }
129
+
130
+ export interface ModelPolicyInput {
131
+ tier: string;
132
+ fallback_chain: string[];
133
+ }
134
+
135
+ export interface CircuitBreakerInput {
136
+ identical_failures: number;
137
+ }
138
+
139
+ export interface BindingsInput {
140
+ storage: StorageBindingInput;
141
+ vault_grants: VaultGrantInput[];
142
+ identity: IdentityBindingInput;
143
+ }
144
+
145
+ export interface StorageBindingInput {
146
+ kind: StorageKind;
147
+ scope: StorageScope;
148
+ owns_schema: boolean;
149
+ }
150
+
151
+
152
+ export interface VaultGrantInput {
153
+ secret: string;
154
+ reason: string;
155
+ }
156
+
157
+ export interface IdentityBindingInput {
158
+ requires: IdentityScope[];
159
+ optional: IdentityScope[];
160
+ }
161
+
162
+ export interface ManagementSurfaceManifestOptions {
163
+ moduleId: string;
164
+ operations: Array<string | ManagementOperationInput>;
165
+ moduleVersion?: string;
166
+ }
167
+
168
+ /**
169
+ * Per-request context handed to a provider handler. A unary handler ignores it and
170
+ * just returns its response bytes; a streaming handler uses `emit` to push interim
171
+ * events and `signal` to learn when the consumer cancelled or the route went away.
172
+ */
173
+ export interface ProviderRequestContext {
174
+ /**
175
+ * Emit an interim event as a StreamData frame on this request's (channel, corr).
176
+ * The consumer receives it via its subscription `onEvent`. A no-op once the
177
+ * request has been aborted (cancelled or route-gone).
178
+ */
179
+ emit(body: Uint8Array): Promise<void>;
180
+ /** Aborts when the consumer sends Cancel for this request, or the route is torn down. */
181
+ signal: AbortSignal;
182
+ /**
183
+ * The provider's current transport connection epoch: 1 on the initial connection,
184
+ * +1 on each successful reconnect + re-registration. Read at the moment the handler
185
+ * calls it (so it reflects any reconnect that happened while the handler ran). A
186
+ * handler that reports connection liveness to its consumer (e.g. stamping the epoch
187
+ * on a response so the consumer can detect a reconnect) should read it from here —
188
+ * this is the single authoritative source of the transport epoch, so it can never
189
+ * drift from a separately-maintained counter.
190
+ */
191
+ currentEpoch(): number;
192
+ }
193
+
194
+ /**
195
+ * A request handler. Return a `Uint8Array` for a single Response (unary), or
196
+ * `void` to end a streaming subscription with a StreamEnd terminal (after emitting
197
+ * events via `ctx.emit`). Throwing produces an Error terminal.
198
+ */
199
+ export type ProviderHandler = (
200
+ routeChannel: number,
201
+ body: Uint8Array,
202
+ ctx: ProviderRequestContext,
203
+ ) => Promise<Uint8Array | void> | Uint8Array | void;
204
+
205
+ export interface RouteBindRequest {
206
+ route_channel: number;
207
+ target: RouteTarget;
208
+ identity: BindIdentity;
209
+ }
210
+
211
+ export type BindDecision =
212
+ | boolean
213
+ | {
214
+ accept: boolean;
215
+ code?: string;
216
+ message?: string;
217
+ };
218
+
219
+ export type ProviderConnectionState =
220
+ | { state: "connected"; epoch: number }
221
+ | { state: "down"; cause: Error }
222
+ | { state: "reconnecting"; attempt: number }
223
+ | { state: "restored"; epoch: number };
224
+
225
+ export interface SubcProviderConnectOptions {
226
+ connectionFile: string;
227
+ manifest: ManifestInput;
228
+ handler: ProviderHandler;
229
+ handshakeTimeoutMs?: number;
230
+ controlOps?: string[] | null;
231
+ onBind?: (request: RouteBindRequest) => Promise<BindDecision> | BindDecision;
232
+ onRouteGone?: (routeChannel: number) => void | Promise<void>;
233
+ /** Backoff for provider reconnect after an unexpected socket drop. */
234
+ reconnectBackoff?: ReconnectBackoff;
235
+ /** Injectable sleep for timer-free reconnect and debounce tests. */
236
+ sleep?: (ms: number) => Promise<void>;
237
+ /** Milliseconds to wait before emitting restored after the provider re-registers following a reconnect. */
238
+ restoredDebounceMs?: number;
239
+ /** Callback that receives ProviderConnectionState events one at a time and in order. */
240
+ onConnectionState?: (event: ProviderConnectionState) => void | Promise<void>;
241
+ /**
242
+ * The one-time launch nonce to echo in HELLO for a reserved module. Defaults to
243
+ * the `SUBC_LAUNCH_NONCE` environment variable subc injects on spawn; pass
244
+ * explicitly to override. Omitted from the wire when empty (non-reserved modules).
245
+ */
246
+ launchNonce?: string;
247
+ }
248
+
249
+ interface NormalizedSubcProviderConnectOptions {
250
+ connectionFile: string;
251
+ manifest: ManifestInput;
252
+ handler: ProviderHandler;
253
+ handshakeTimeoutMs?: number;
254
+ controlOps?: string[] | null;
255
+ onBind?: (request: RouteBindRequest) => Promise<BindDecision> | BindDecision;
256
+ onRouteGone?: (routeChannel: number) => void | Promise<void>;
257
+ reconnectBackoff: ReconnectBackoff;
258
+ sleep: (ms: number) => Promise<void>;
259
+ restoredDebounceMs: number;
260
+ onConnectionState?: (event: ProviderConnectionState) => void | Promise<void>;
261
+ launchNonce?: string;
262
+ }
263
+
264
+ interface OpenedProviderConnection {
265
+ sock: SubcSocket;
266
+ conn: ConnectionInfo;
267
+ ack: ModuleHelloAckBody;
268
+ }
269
+
270
+ export interface ModuleHelloAckBody {
271
+ negotiated_ver: number;
272
+ subc_ops: string[];
273
+ subc_capabilities: string[];
274
+ /**
275
+ * The module's resolved storage descriptor, present when the daemon's central
276
+ * config configures managed storage. Carried opaquely (the wire crate has no
277
+ * storage dependency); a module using managed storage reads this and hands it to
278
+ * the storage library. Absent when no storage is configured.
279
+ */
280
+ storage?: unknown;
281
+ }
282
+
283
+ export class SubcProviderError extends Error {
284
+ constructor(
285
+ message: string,
286
+ readonly code?: string,
287
+ ) {
288
+ super(message);
289
+ }
290
+ }
291
+
292
+ export function managementSurfaceManifest(opts: ManagementSurfaceManifestOptions): ManifestInput {
293
+ const operations = opts.operations.map((operation) =>
294
+ typeof operation === "string"
295
+ ? { name: operation, kind: "query" as const }
296
+ : { name: operation.name, kind: operation.kind },
297
+ );
298
+
299
+ return {
300
+ module_id: opts.moduleId,
301
+ module_version: opts.moduleVersion ?? "0.0.0",
302
+ protocol_ver: PROTOCOL_VERSION,
303
+ trust_tier: "first_party",
304
+ provides: [
305
+ {
306
+ role: "management_surface",
307
+ operations,
308
+ config_schema: { type: "object" },
309
+ observability: [],
310
+ identity_scope: [],
311
+ },
312
+ ],
313
+ consumes: [],
314
+ scheduled_tasks: [],
315
+ bindings: {
316
+ storage: {
317
+ kind: "sqlite",
318
+ scope: "project",
319
+ owns_schema: false,
320
+ },
321
+ vault_grants: [],
322
+ identity: {
323
+ requires: [],
324
+ optional: [],
325
+ },
326
+ },
327
+ };
328
+ }
329
+
330
+ export function jsonProviderHandler<Request = unknown, Response = unknown>(
331
+ handler: (routeChannel: number, request: Request) => Promise<Response> | Response,
332
+ ): ProviderHandler {
333
+ return async (routeChannel, body) => {
334
+ const request = JSON.parse(Buffer.from(body).toString("utf8")) as Request;
335
+ const response = await handler(routeChannel, request);
336
+ return encodeJson(response);
337
+ };
338
+ }
339
+
340
+ export class SubcProvider {
341
+ private readonly closed: Promise<void>;
342
+ private resolveClosed: () => void = () => undefined;
343
+ private closeStarted = false;
344
+ private closedErr: Error | null = null;
345
+ // In-flight data requests are keyed by generation, channel, and correlation id.
346
+ // A socket drop only makes the reply path stale; handlers may still finish their
347
+ // durable work, and their late sends are ignored by the generation guard.
348
+ private readonly inflight = new Map<string, AbortController>();
349
+ private reconnecting: Promise<void> | null = null;
350
+ private generation = 1;
351
+ private connectionEpoch = 1;
352
+ private stateQueue: ProviderConnectionState[] = [];
353
+ private drainingStateQueue = false;
354
+ private restoredDebounceToken = 0;
355
+
356
+ /**
357
+ * The resolved storage descriptor the daemon delivered in HELLO_ACK, or
358
+ * `undefined` when no managed storage is configured. A module that persists
359
+ * hands this to the storage library.
360
+ */
361
+ storage: unknown;
362
+
363
+ private constructor(
364
+ private sock: SubcSocket,
365
+ private currentConn: ConnectionInfo,
366
+ private readonly opts: NormalizedSubcProviderConnectOptions,
367
+ storage: unknown,
368
+ ) {
369
+ this.storage = storage;
370
+ this.closed = new Promise<void>((resolve) => {
371
+ this.resolveClosed = resolve;
372
+ });
373
+ void this.readLoop(sock, this.generation);
374
+ this.enqueueConnectionState({ state: "connected", epoch: this.connectionEpoch });
375
+ }
376
+
377
+ get conn(): ConnectionInfo {
378
+ return this.currentConn;
379
+ }
380
+
381
+ currentEpoch(): number {
382
+ return this.connectionEpoch;
383
+ }
384
+
385
+ /** Read the connection file, authenticate as a client, register the manifest with HELLO, and serve frames. */
386
+ static async connect(opts: SubcProviderConnectOptions): Promise<SubcProvider> {
387
+ if (opts.manifest.protocol_ver !== PROTOCOL_VERSION) {
388
+ throw new SubcProviderError(
389
+ `manifest protocol_ver ${opts.manifest.protocol_ver} does not match client protocol ${PROTOCOL_VERSION}`,
390
+ "invalid_manifest",
391
+ );
392
+ }
393
+
394
+ const normalized = normalizeProviderConnectOptions(opts);
395
+ const opened = await SubcProvider.openConnection(normalized);
396
+ return new SubcProvider(opened.sock, opened.conn, normalized, opened.ack.storage);
397
+ }
398
+
399
+ async close(): Promise<void> {
400
+ if (!this.closeStarted) {
401
+ this.closeStarted = true;
402
+ this.cancelRestoredDebounce();
403
+ const sock = this.sock;
404
+ try {
405
+ await sendFrame(sock, buildFrame(FrameType.Goodbye, controlFlags(), 0, 0n, new Uint8Array(0)));
406
+ } catch {
407
+ // The daemon may already have closed the connection; close() remains best-effort.
408
+ } finally {
409
+ sock.close();
410
+ this.finishClosed();
411
+ }
412
+ }
413
+ await this.closed;
414
+ }
415
+
416
+ private static async openConnection(opts: NormalizedSubcProviderConnectOptions): Promise<OpenedProviderConnection> {
417
+ const conn = await readConnectionFile(opts.connectionFile);
418
+ const deadline = Date.now() + (opts.handshakeTimeoutMs ?? DEFAULT_HANDSHAKE_TIMEOUT_MS);
419
+ const endpoint = conn.endpoints[0]!;
420
+ const sock = await SubcSocket.connect(endpoint.host, endpoint.port, deadline);
421
+ try {
422
+ await authenticateClient(sock, conn, deadline);
423
+ await sendFrame(sock, buildHelloFrame(opts));
424
+ const ack = await expectHelloAck(sock, deadline);
425
+ return { sock, conn, ack };
426
+ } catch (err) {
427
+ sock.close();
428
+ throw err;
429
+ }
430
+ }
431
+
432
+ private async readLoop(sock: SubcSocket, generation: number): Promise<void> {
433
+ try {
434
+ for (;;) {
435
+ // Header read waits indefinitely — idle time between frames is normal.
436
+ const headerBytes = await sock.readExact(HEADER_LEN, Number.POSITIVE_INFINITY);
437
+ const header = decodeHeader(headerBytes);
438
+ const body =
439
+ header.len === 0
440
+ ? new Uint8Array(0)
441
+ : await sock.readExact(header.len, Date.now() + BODY_READ_TIMEOUT_MS);
442
+ const keepGoing = await this.dispatch({ header, body }, sock, generation);
443
+ if (!keepGoing) {
444
+ if (this.sock === sock && this.generation === generation) this.closeStarted = true;
445
+ break;
446
+ }
447
+ }
448
+ } catch (err) {
449
+ if (this.sock === sock && this.generation === generation && !this.closeStarted) {
450
+ this.handleUnexpectedDrop(sock, generation, err instanceof Error ? err : new SubcProviderError(String(err)));
451
+ return;
452
+ }
453
+ } finally {
454
+ if (this.sock === sock && this.generation === generation) {
455
+ sock.close();
456
+ if (this.closeStarted) this.finishClosed();
457
+ }
458
+ }
459
+ }
460
+
461
+ private async dispatch(frame: Frame, sock: SubcSocket, generation: number): Promise<boolean> {
462
+ switch (frame.header.ty) {
463
+ case FrameType.Ping:
464
+ if (frame.header.channel === 0) {
465
+ await this.sendOn(
466
+ sock,
467
+ generation,
468
+ buildFrameWithVersion(
469
+ frame.header.ver,
470
+ FrameType.Pong,
471
+ frame.header.flags,
472
+ 0,
473
+ frame.header.corr,
474
+ new Uint8Array(0),
475
+ ),
476
+ );
477
+ }
478
+ return true;
479
+ case FrameType.Goodbye:
480
+ if (frame.header.channel === 0) return false;
481
+ // The route is gone: abort in-flight requests on that route so streaming
482
+ // handlers unwind, then notify the provider owner.
483
+ this.abortChannel(generation, frame.header.channel);
484
+ await this.opts.onRouteGone?.(frame.header.channel);
485
+ return true;
486
+ case FrameType.Cancel:
487
+ // The consumer cancelled one request: abort the matching handler. Its
488
+ // streaming handler observes ctx.signal and ends with a StreamEnd terminal.
489
+ this.inflight.get(routeKey(generation, frame.header.channel, frame.header.corr))?.abort();
490
+ return true;
491
+ case FrameType.Request:
492
+ if (frame.header.channel === 0) {
493
+ await this.handleControlRequest(frame, sock, generation);
494
+ } else {
495
+ void this.handleDataRequest(frame, sock, generation).catch((err) => {
496
+ if (!this.closeStarted && this.sock === sock && this.generation === generation) {
497
+ console.warn("SubcProvider handler failed after its request was dispatched", err);
498
+ }
499
+ });
500
+ }
501
+ return true;
502
+ default:
503
+ return true;
504
+ }
505
+ }
506
+
507
+ /** Abort every in-flight request on a route channel for the current socket generation. */
508
+ private abortChannel(generation: number, channel: number): void {
509
+ const prefix = `${generation}:${channel}:`;
510
+ for (const [key, controller] of this.inflight) {
511
+ if (key.startsWith(prefix)) controller.abort();
512
+ }
513
+ }
514
+
515
+ private abortGeneration(generation: number): void {
516
+ const prefix = `${generation}:`;
517
+ for (const [key, controller] of this.inflight) {
518
+ if (key.startsWith(prefix)) controller.abort();
519
+ }
520
+ }
521
+
522
+ private abortAllInflight(): void {
523
+ for (const controller of this.inflight.values()) controller.abort();
524
+ }
525
+
526
+ private async handleControlRequest(frame: Frame, sock: SubcSocket, generation: number): Promise<void> {
527
+ const request = parseJson(frame.body) as Partial<RouteBindRequest> & { op?: string };
528
+ if (request.op !== "route.bind") {
529
+ throw new SubcProviderError(`unsupported module control request ${request.op ?? "<missing op>"}`);
530
+ }
531
+
532
+ const bindRequest: RouteBindRequest = {
533
+ route_channel: numberField(request.route_channel, "route_channel"),
534
+ target: request.target as RouteTarget,
535
+ identity: request.identity as BindIdentity,
536
+ };
537
+
538
+ const decision = await this.opts.onBind?.(bindRequest);
539
+ const rejection = bindRejection(decision);
540
+ if (rejection) {
541
+ await this.sendError(frame, rejection.code, rejection.message, controlFlags(), sock, generation);
542
+ return;
543
+ }
544
+
545
+ await this.sendOn(
546
+ sock,
547
+ generation,
548
+ buildFrameWithVersion(
549
+ frame.header.ver,
550
+ FrameType.Response,
551
+ controlFlags(),
552
+ 0,
553
+ frame.header.corr,
554
+ encodeJson({ op: "route.bind" }),
555
+ ),
556
+ );
557
+ }
558
+
559
+ private async handleDataRequest(frame: Frame, sock: SubcSocket, generation: number): Promise<void> {
560
+ const { channel, corr, ver } = frame.header;
561
+ const key = routeKey(generation, channel, corr);
562
+ const controller = new AbortController();
563
+ this.inflight.set(key, controller);
564
+ const dataFlags = buildFlags(false, Priority.Interactive, false);
565
+ const ctx: ProviderRequestContext = {
566
+ signal: controller.signal,
567
+ currentEpoch: () => this.connectionEpoch,
568
+ emit: async (eventBody) => {
569
+ // Once aborted (cancel / route-gone / socket drop), drop further events silently.
570
+ if (controller.signal.aborted) return;
571
+ await this.sendOn(
572
+ sock,
573
+ generation,
574
+ buildFrameWithVersion(ver, FrameType.StreamData, dataFlags, channel, corr, eventBody),
575
+ );
576
+ },
577
+ };
578
+ try {
579
+ const body = await this.opts.handler(channel, frame.body, ctx);
580
+ if (body === undefined) {
581
+ // A streaming handler that ended: close the held-open request with a
582
+ // StreamEnd terminal (the consumer's subscription resolves).
583
+ await this.sendOn(
584
+ sock,
585
+ generation,
586
+ buildFrameWithVersion(ver, FrameType.StreamEnd, dataFlags, channel, corr, new Uint8Array(0)),
587
+ );
588
+ } else if (body instanceof Uint8Array) {
589
+ await this.sendOn(
590
+ sock,
591
+ generation,
592
+ buildFrameWithVersion(ver, FrameType.Response, dataFlags, channel, corr, body),
593
+ );
594
+ } else {
595
+ throw new SubcProviderError(
596
+ "provider handler must return a Uint8Array or void",
597
+ "invalid_handler_response",
598
+ );
599
+ }
600
+ } catch (err) {
601
+ await this.sendError(
602
+ frame,
603
+ err instanceof SubcProviderError && err.code ? err.code : "handler_error",
604
+ err instanceof Error ? err.message : String(err),
605
+ dataFlags,
606
+ sock,
607
+ generation,
608
+ );
609
+ } finally {
610
+ if (this.inflight.get(key) === controller) this.inflight.delete(key);
611
+ }
612
+ }
613
+
614
+ private async sendError(
615
+ frame: Frame,
616
+ code: string,
617
+ message: string,
618
+ flags: number,
619
+ sock: SubcSocket,
620
+ generation: number,
621
+ ): Promise<void> {
622
+ await this.sendOn(
623
+ sock,
624
+ generation,
625
+ buildFrameWithVersion(
626
+ frame.header.ver,
627
+ FrameType.Error,
628
+ flags,
629
+ frame.header.channel,
630
+ frame.header.corr,
631
+ encodeJson({ code, message }),
632
+ ),
633
+ );
634
+ }
635
+
636
+ private async sendOn(sock: SubcSocket, generation: number, frame: Frame): Promise<void> {
637
+ if (this.sock !== sock || this.generation !== generation || this.closeStarted || this.closedErr) return;
638
+ await sendFrame(sock, frame);
639
+ }
640
+
641
+ private handleUnexpectedDrop(sock: SubcSocket, generation: number, cause: Error): void {
642
+ this.cancelRestoredDebounce();
643
+ this.abortGeneration(generation);
644
+ this.generation += 1;
645
+ sock.close();
646
+ this.enqueueConnectionState({ state: "down", cause });
647
+ this.scheduleReconnectAfterDrop(cause);
648
+ }
649
+
650
+ private scheduleReconnectAfterDrop(trigger: unknown): void {
651
+ if (this.closeStarted || this.reconnecting) return;
652
+ const promise = this.reconnectWithRetry(trigger)
653
+ .catch((err) => {
654
+ if (!this.closeStarted) this.failFatal(err instanceof Error ? err : new SubcProviderError(String(err)));
655
+ })
656
+ .finally(() => {
657
+ if (this.reconnecting === promise) this.reconnecting = null;
658
+ });
659
+ this.reconnecting = promise;
660
+ }
661
+
662
+ private async reconnectWithRetry(_trigger: unknown): Promise<void> {
663
+ let attempt = 0;
664
+ let delay = this.opts.reconnectBackoff.baseMs;
665
+
666
+ for (;;) {
667
+ if (this.closeStarted) throw new SubcProviderError("provider closed");
668
+ attempt += 1;
669
+ this.enqueueConnectionState({ state: "reconnecting", attempt });
670
+ try {
671
+ const opened = await SubcProvider.openConnection(this.opts);
672
+ if (this.closeStarted) {
673
+ opened.sock.close();
674
+ throw new SubcProviderError("provider closed");
675
+ }
676
+ this.replaceConnection(opened);
677
+ return;
678
+ } catch (err) {
679
+ if (this.closeStarted) throw err;
680
+ if (!isProviderReconnectTransient(err)) throw err;
681
+ await this.opts.sleep(delay);
682
+ delay = Math.min(delay * 2, this.opts.reconnectBackoff.capMs);
683
+ }
684
+ }
685
+ }
686
+
687
+ private replaceConnection(opened: OpenedProviderConnection): void {
688
+ this.sock.close();
689
+ this.sock = opened.sock;
690
+ this.currentConn = opened.conn;
691
+ this.storage = opened.ack.storage;
692
+ this.closedErr = null;
693
+ this.connectionEpoch += 1;
694
+ const generation = this.generation;
695
+ void this.readLoop(opened.sock, generation);
696
+ this.scheduleRestored(generation, this.connectionEpoch);
697
+ }
698
+
699
+ private scheduleRestored(generation: number, epoch: number): void {
700
+ if (!this.opts.onConnectionState) return;
701
+ const token = ++this.restoredDebounceToken;
702
+ void this.opts
703
+ .sleep(this.opts.restoredDebounceMs)
704
+ .then(() => {
705
+ if (
706
+ token === this.restoredDebounceToken &&
707
+ !this.closeStarted &&
708
+ this.sock &&
709
+ this.generation === generation &&
710
+ this.connectionEpoch === epoch
711
+ ) {
712
+ this.enqueueConnectionState({ state: "restored", epoch });
713
+ }
714
+ })
715
+ .catch((err) => {
716
+ if (token === this.restoredDebounceToken && !this.closeStarted) {
717
+ console.warn("SubcProvider restored debounce timer failed", err);
718
+ }
719
+ });
720
+ }
721
+
722
+ private cancelRestoredDebounce(): void {
723
+ this.restoredDebounceToken += 1;
724
+ }
725
+
726
+ private enqueueConnectionState(event: ProviderConnectionState): void {
727
+ if (!this.opts.onConnectionState) return;
728
+ this.stateQueue.push(event);
729
+ if (!this.drainingStateQueue) void this.drainConnectionStateQueue();
730
+ }
731
+
732
+ private async drainConnectionStateQueue(): Promise<void> {
733
+ if (this.drainingStateQueue) return;
734
+ this.drainingStateQueue = true;
735
+ try {
736
+ while (this.stateQueue.length > 0) {
737
+ const event = this.stateQueue[0]!;
738
+ try {
739
+ await this.opts.onConnectionState?.(event);
740
+ this.stateQueue.shift();
741
+ } catch (err) {
742
+ if (event.state === "restored") {
743
+ console.warn("SubcProvider restored callback failed; retrying delivery", err);
744
+ await pauseBeforeStateRetry();
745
+ continue;
746
+ }
747
+ console.warn("SubcProvider connection-state callback failed", err);
748
+ this.stateQueue.shift();
749
+ }
750
+ }
751
+ } finally {
752
+ this.drainingStateQueue = false;
753
+ if (this.stateQueue.length > 0) void this.drainConnectionStateQueue();
754
+ }
755
+ }
756
+
757
+ private failFatal(err: Error): void {
758
+ if (!this.closedErr) this.closedErr = err;
759
+ this.closeStarted = true;
760
+ this.cancelRestoredDebounce();
761
+ this.abortAllInflight();
762
+ this.sock.close();
763
+ this.finishClosed();
764
+ }
765
+
766
+ private finishClosed(): void {
767
+ this.resolveClosed();
768
+ }
769
+ }
770
+
771
+ function routeKey(generation: number, channel: number, corr: bigint): string {
772
+ return `${generation}:${channel}:${corr}`;
773
+ }
774
+
775
+ function launchNonce(opts: SubcProviderConnectOptions): string | undefined {
776
+ const nonce = opts.launchNonce ?? process.env[SUBC_LAUNCH_NONCE_ENV];
777
+ return nonce && nonce.length > 0 ? nonce : undefined;
778
+ }
779
+
780
+ const SUBC_LAUNCH_NONCE_ENV = "SUBC_LAUNCH_NONCE";
781
+
782
+ function normalizeProviderConnectOptions(opts: SubcProviderConnectOptions): NormalizedSubcProviderConnectOptions {
783
+ return {
784
+ connectionFile: opts.connectionFile,
785
+ manifest: opts.manifest,
786
+ handler: opts.handler,
787
+ handshakeTimeoutMs: opts.handshakeTimeoutMs,
788
+ controlOps: opts.controlOps,
789
+ onBind: opts.onBind,
790
+ onRouteGone: opts.onRouteGone,
791
+ reconnectBackoff: opts.reconnectBackoff ?? DEFAULT_RECONNECT_BACKOFF,
792
+ sleep: opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))),
793
+ restoredDebounceMs: opts.restoredDebounceMs ?? DEFAULT_RESTORED_DEBOUNCE_MS,
794
+ onConnectionState: opts.onConnectionState,
795
+ launchNonce: opts.launchNonce,
796
+ };
797
+ }
798
+
799
+ function buildHelloFrame(opts: NormalizedSubcProviderConnectOptions): Frame {
800
+ const nonce = launchNonce(opts);
801
+ return buildFrame(
802
+ FrameType.Hello,
803
+ controlFlags(),
804
+ 0,
805
+ HELLO_CORR,
806
+ encodeJson({
807
+ manifest: normalizeManifest(opts.manifest),
808
+ protocol_ver: PROTOCOL_VERSION,
809
+ control_ops: opts.controlOps === undefined ? null : opts.controlOps,
810
+ // Echo the one-time launch nonce subc injects for a reserved module
811
+ // (SUBC_LAUNCH_NONCE), so only the daemon-spawned process can register a
812
+ // reserved module_id. Omitted when unset (non-reserved / self-connecting).
813
+ ...(nonce ? { launch_nonce: nonce } : {}),
814
+ }),
815
+ );
816
+ }
817
+
818
+
819
+ function isProviderReconnectTransient(err: unknown): boolean {
820
+ if (err instanceof SubcProviderError) return err.code === "duplicate_module_id";
821
+ if (err instanceof SocketClosedError || err instanceof SocketTimeoutError) return true;
822
+ if (err instanceof SocketWriteNotQueuedError || err instanceof SocketWriteQueuedError) return true;
823
+ if (err instanceof ConnectionFileError || err instanceof AuthError) return false;
824
+
825
+ const code = errorCode(err);
826
+ return code === "ECONNREFUSED" || code === "ECONNRESET" || code === "EPIPE" || code === "ETIMEDOUT" || code === "ENOENT";
827
+ }
828
+
829
+ function errorCode(err: unknown): string | undefined {
830
+ if (typeof err === "object" && err !== null && "code" in err) {
831
+ const code = (err as { code?: unknown }).code;
832
+ if (typeof code === "string") return code;
833
+ }
834
+ return undefined;
835
+ }
836
+
837
+ async function pauseBeforeStateRetry(): Promise<void> {
838
+ await new Promise((resolve) => setTimeout(resolve, 0));
839
+ }
840
+
841
+ function controlFlags(): number {
842
+ return buildFlags(false, Priority.Passive, false);
843
+ }
844
+
845
+ async function sendFrame(sock: SubcSocket, frame: Frame): Promise<void> {
846
+ await sock.write(encodeFrame(frame), Date.now() + WRITE_TIMEOUT_MS);
847
+ }
848
+
849
+ async function expectHelloAck(sock: SubcSocket, deadline: number): Promise<ModuleHelloAckBody> {
850
+ const header = decodeHeader(await sock.readExact(HEADER_LEN, deadline));
851
+ const body = header.len === 0 ? new Uint8Array(0) : await sock.readExact(header.len, deadline);
852
+ const frame = { header, body };
853
+ switch (header.ty) {
854
+ case FrameType.HelloAck:
855
+ return parseJson(body) as ModuleHelloAckBody;
856
+ case FrameType.Error: {
857
+ const error = parseJson(body) as { code?: string; message?: string };
858
+ throw new SubcProviderError(
859
+ `subc rejected HELLO: ${error.code ?? "unknown"} — ${error.message ?? "subc error"}`,
860
+ error.code,
861
+ );
862
+ }
863
+ default:
864
+ throw new SubcProviderError(`unexpected frame ${FrameType[frame.header.ty]} awaiting HELLO_ACK`);
865
+ }
866
+ }
867
+
868
+ function encodeJson(value: unknown): Uint8Array {
869
+ return new Uint8Array(Buffer.from(JSON.stringify(value), "utf8"));
870
+ }
871
+
872
+ function parseJson(bytes: Uint8Array): unknown {
873
+ return JSON.parse(Buffer.from(bytes).toString("utf8"));
874
+ }
875
+
876
+ function numberField(value: unknown, field: string): number {
877
+ if (typeof value !== "number" || !Number.isInteger(value)) {
878
+ throw new SubcProviderError(`route.bind ${field} must be an integer`);
879
+ }
880
+ return value;
881
+ }
882
+
883
+ function bindRejection(decision: BindDecision | undefined): { code: string; message: string } | null {
884
+ if (decision === undefined || decision === true) return null;
885
+ if (decision === false) {
886
+ return { code: "route_rejected", message: "route.bind rejected by provider" };
887
+ }
888
+ if (decision.accept) return null;
889
+ return {
890
+ code: decision.code ?? "route_rejected",
891
+ message: decision.message ?? "route.bind rejected by provider",
892
+ };
893
+ }
894
+
895
+ function normalizeManifest(manifest: ManifestInput): ManifestInput {
896
+ return {
897
+ module_id: manifest.module_id,
898
+ module_version: manifest.module_version,
899
+ protocol_ver: manifest.protocol_ver,
900
+ trust_tier: manifest.trust_tier,
901
+ provides: manifest.provides.map(normalizeProviderRole),
902
+ consumes: manifest.consumes.map(normalizeConsumerRole),
903
+ scheduled_tasks: manifest.scheduled_tasks.map(normalizeScheduledTask),
904
+ bindings: {
905
+ storage: {
906
+ kind: manifest.bindings.storage.kind,
907
+ scope: manifest.bindings.storage.scope,
908
+ owns_schema: manifest.bindings.storage.owns_schema,
909
+ },
910
+ vault_grants: manifest.bindings.vault_grants.map((grant) => ({
911
+ secret: grant.secret,
912
+ reason: grant.reason,
913
+ })),
914
+ identity: {
915
+ requires: [...manifest.bindings.identity.requires],
916
+ optional: [...manifest.bindings.identity.optional],
917
+ },
918
+ },
919
+ };
920
+ }
921
+
922
+ function normalizeProviderRole(role: ProviderRoleInput): ProviderRoleInput {
923
+ switch (role.role) {
924
+ case "tool_provider":
925
+ return {
926
+ role: "tool_provider",
927
+ tools: role.tools.map((tool) => ({
928
+ name: tool.name,
929
+ execution_mode: tool.execution_mode,
930
+ schema: tool.schema,
931
+ })),
932
+ identity_scope: [...role.identity_scope],
933
+ concurrency: role.concurrency,
934
+ emits_push: role.emits_push,
935
+ sub_supervises: role.sub_supervises,
936
+ };
937
+ case "pipeline_stage":
938
+ return {
939
+ role: "pipeline_stage",
940
+ stage: role.stage,
941
+ applies_to: {
942
+ provider: role.applies_to.provider,
943
+ model: role.applies_to.model,
944
+ },
945
+ interface: role.interface,
946
+ declares_frozen_floor: role.declares_frozen_floor,
947
+ needs_signals: [...role.needs_signals],
948
+ conformance_class: role.conformance_class,
949
+ };
950
+ case "management_surface":
951
+ return {
952
+ role: "management_surface",
953
+ operations: role.operations.map((operation) => ({
954
+ name: operation.name,
955
+ kind: operation.kind,
956
+ })),
957
+ config_schema: role.config_schema,
958
+ observability: role.observability.map((surface) => ({
959
+ name: surface.name,
960
+ kind: surface.kind,
961
+ })),
962
+ identity_scope: [...role.identity_scope],
963
+ };
964
+ case "internal_service":
965
+ return {
966
+ role: "internal_service",
967
+ service_id: role.service_id,
968
+ transport: role.transport,
969
+ agent_facing: role.agent_facing,
970
+ operations: [...role.operations],
971
+ };
972
+ }
973
+ }
974
+
975
+ function normalizeConsumerRole(role: ConsumerRoleInput): ConsumerRoleInput {
976
+ switch (role.role) {
977
+ case "tool_client":
978
+ return { role: "tool_client", of: [...role.of] };
979
+ case "llm_client":
980
+ return { role: "llm_client", via: role.via, auth: role.auth };
981
+ case "service_client":
982
+ return { role: "service_client", of: [...role.of] };
983
+ }
984
+ }
985
+
986
+ function normalizeScheduledTask(task: ScheduledTaskInput): ScheduledTaskInput {
987
+ return {
988
+ task_id: task.task_id,
989
+ eligibility: {
990
+ cooldown: task.eligibility.cooldown,
991
+ window: task.eligibility.window,
992
+ },
993
+ lease_scope: task.lease_scope,
994
+ renews_during_calls: task.renews_during_calls,
995
+ toolset: [...task.toolset],
996
+ model_policy: {
997
+ tier: task.model_policy.tier,
998
+ fallback_chain: [...task.model_policy.fallback_chain],
999
+ },
1000
+ step_cap: task.step_cap,
1001
+ circuit_breaker: {
1002
+ identical_failures: task.circuit_breaker.identical_failures,
1003
+ },
1004
+ };
1005
+ }