@nanobpm/agentic 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.
Files changed (228) hide show
  1. package/README.md +22 -0
  2. package/dist/blackboard/family.d.ts +40 -0
  3. package/dist/blackboard/family.js +151 -0
  4. package/dist/blackboard/index.d.ts +21 -0
  5. package/dist/blackboard/index.js +19 -0
  6. package/dist/blackboard/schema.d.ts +30 -0
  7. package/dist/blackboard/schema.js +42 -0
  8. package/dist/blackboard/store.d.ts +138 -0
  9. package/dist/blackboard/store.js +216 -0
  10. package/dist/blackboard/test-db.d.ts +5 -0
  11. package/dist/blackboard/test-db.js +42 -0
  12. package/dist/channel/auth.d.ts +41 -0
  13. package/dist/channel/auth.js +67 -0
  14. package/dist/channel/clock.d.ts +11 -0
  15. package/dist/channel/clock.js +4 -0
  16. package/dist/channel/connection.d.ts +75 -0
  17. package/dist/channel/connection.js +14 -0
  18. package/dist/channel/dispatch.d.ts +46 -0
  19. package/dist/channel/dispatch.js +86 -0
  20. package/dist/channel/hub.d.ts +78 -0
  21. package/dist/channel/hub.js +157 -0
  22. package/dist/channel/index.d.ts +27 -0
  23. package/dist/channel/index.js +20 -0
  24. package/dist/channel/registry.d.ts +68 -0
  25. package/dist/channel/registry.js +84 -0
  26. package/dist/channel/ws-transport.d.ts +23 -0
  27. package/dist/channel/ws-transport.js +178 -0
  28. package/dist/cockpit/boot.d.ts +68 -0
  29. package/dist/cockpit/boot.js +202 -0
  30. package/dist/cockpit/fake-dom.d.ts +37 -0
  31. package/dist/cockpit/fake-dom.js +73 -0
  32. package/dist/cockpit/index.d.ts +27 -0
  33. package/dist/cockpit/index.js +27 -0
  34. package/dist/cockpit/relay-client.d.ts +52 -0
  35. package/dist/cockpit/relay-client.js +192 -0
  36. package/dist/cockpit/render.d.ts +58 -0
  37. package/dist/cockpit/render.js +122 -0
  38. package/dist/cockpit/terminal-session.d.ts +95 -0
  39. package/dist/cockpit/terminal-session.js +123 -0
  40. package/dist/cockpit/view.d.ts +79 -0
  41. package/dist/cockpit/view.js +58 -0
  42. package/dist/demand/c8-rest.d.ts +77 -0
  43. package/dist/demand/c8-rest.js +123 -0
  44. package/dist/demand/index.d.ts +24 -0
  45. package/dist/demand/index.js +24 -0
  46. package/dist/demand/model.d.ts +68 -0
  47. package/dist/demand/model.js +118 -0
  48. package/dist/demand/taskdef.d.ts +40 -0
  49. package/dist/demand/taskdef.js +67 -0
  50. package/dist/index.d.ts +17 -0
  51. package/dist/index.js +17 -0
  52. package/dist/presence/family.d.ts +40 -0
  53. package/dist/presence/family.js +166 -0
  54. package/dist/presence/index.d.ts +19 -0
  55. package/dist/presence/index.js +17 -0
  56. package/dist/presence/schema.d.ts +20 -0
  57. package/dist/presence/schema.js +32 -0
  58. package/dist/presence/store.d.ts +130 -0
  59. package/dist/presence/store.js +191 -0
  60. package/dist/presence/test-db.d.ts +5 -0
  61. package/dist/presence/test-db.js +42 -0
  62. package/dist/protocol/conformance/frames.d.ts +24 -0
  63. package/dist/protocol/conformance/frames.js +116 -0
  64. package/dist/protocol/conformance/index.d.ts +13 -0
  65. package/dist/protocol/conformance/index.js +13 -0
  66. package/dist/protocol/conformance/malformed.d.ts +14 -0
  67. package/dist/protocol/conformance/malformed.js +44 -0
  68. package/dist/protocol/conformance/tokens.d.ts +19 -0
  69. package/dist/protocol/conformance/tokens.js +49 -0
  70. package/dist/protocol/conformance/vocab.d.ts +23 -0
  71. package/dist/protocol/conformance/vocab.js +97 -0
  72. package/dist/protocol/families.d.ts +32 -0
  73. package/dist/protocol/families.js +45 -0
  74. package/dist/protocol/frame.d.ts +45 -0
  75. package/dist/protocol/frame.js +114 -0
  76. package/dist/protocol/hex.d.ts +7 -0
  77. package/dist/protocol/hex.js +26 -0
  78. package/dist/protocol/index.d.ts +23 -0
  79. package/dist/protocol/index.js +23 -0
  80. package/dist/protocol/lanes.d.ts +36 -0
  81. package/dist/protocol/lanes.js +40 -0
  82. package/dist/protocol/payloads.d.ts +64 -0
  83. package/dist/protocol/payloads.js +122 -0
  84. package/dist/protocol/token.d.ts +34 -0
  85. package/dist/protocol/token.js +81 -0
  86. package/dist/protocol/vocab/schema.d.ts +51 -0
  87. package/dist/protocol/vocab/schema.js +218 -0
  88. package/dist/relay/incarnation.d.ts +17 -0
  89. package/dist/relay/incarnation.js +50 -0
  90. package/dist/relay/index.d.ts +25 -0
  91. package/dist/relay/index.js +22 -0
  92. package/dist/relay/relay-family.d.ts +68 -0
  93. package/dist/relay/relay-family.js +272 -0
  94. package/dist/relay/ring.d.ts +49 -0
  95. package/dist/relay/ring.js +105 -0
  96. package/dist/relay/scheduler.d.ts +72 -0
  97. package/dist/relay/scheduler.js +180 -0
  98. package/dist/relay/validate.d.ts +29 -0
  99. package/dist/relay/validate.js +39 -0
  100. package/dist/transcript/index.d.ts +18 -0
  101. package/dist/transcript/index.js +17 -0
  102. package/dist/transcript/schema.d.ts +32 -0
  103. package/dist/transcript/schema.js +48 -0
  104. package/dist/transcript/store.d.ts +192 -0
  105. package/dist/transcript/store.js +347 -0
  106. package/dist/transcript/test-db.d.ts +5 -0
  107. package/dist/transcript/test-db.js +41 -0
  108. package/dist/vocab/core-vocab.d.ts +26 -0
  109. package/dist/vocab/core-vocab.js +67 -0
  110. package/dist/vocab/diversity.d.ts +78 -0
  111. package/dist/vocab/diversity.js +89 -0
  112. package/dist/vocab/index.d.ts +22 -0
  113. package/dist/vocab/index.js +22 -0
  114. package/dist/vocab/merge.d.ts +9 -0
  115. package/dist/vocab/merge.js +104 -0
  116. package/dist/vocab/requires.d.ts +49 -0
  117. package/dist/vocab/requires.js +107 -0
  118. package/dist/vocab/resolver.d.ts +62 -0
  119. package/dist/vocab/resolver.js +149 -0
  120. package/dist/vocab/serve.d.ts +39 -0
  121. package/dist/vocab/serve.js +36 -0
  122. package/package.json +108 -0
  123. package/page/cockpit.css +114 -0
  124. package/page/cockpit.page.json +33 -0
  125. package/page/embed.html +40 -0
  126. package/page/mount.js +78 -0
  127. package/page/standalone.html +43 -0
  128. package/src/blackboard/family.test.ts +280 -0
  129. package/src/blackboard/family.ts +208 -0
  130. package/src/blackboard/index.ts +42 -0
  131. package/src/blackboard/schema.test.ts +60 -0
  132. package/src/blackboard/schema.ts +44 -0
  133. package/src/blackboard/store.test.ts +189 -0
  134. package/src/blackboard/store.ts +331 -0
  135. package/src/blackboard/test-db.ts +47 -0
  136. package/src/channel/auth.test.ts +64 -0
  137. package/src/channel/auth.ts +101 -0
  138. package/src/channel/clock.ts +14 -0
  139. package/src/channel/connection.ts +77 -0
  140. package/src/channel/dispatch.test.ts +83 -0
  141. package/src/channel/dispatch.ts +102 -0
  142. package/src/channel/hub.test.ts +335 -0
  143. package/src/channel/hub.ts +222 -0
  144. package/src/channel/index.ts +55 -0
  145. package/src/channel/registry.test.ts +73 -0
  146. package/src/channel/registry.ts +137 -0
  147. package/src/channel/ws-transport.test.ts +234 -0
  148. package/src/channel/ws-transport.ts +212 -0
  149. package/src/cockpit/boot.test.ts +374 -0
  150. package/src/cockpit/boot.ts +280 -0
  151. package/src/cockpit/fake-dom.ts +90 -0
  152. package/src/cockpit/index.ts +63 -0
  153. package/src/cockpit/relay-client.test.ts +359 -0
  154. package/src/cockpit/relay-client.ts +234 -0
  155. package/src/cockpit/render.test.ts +149 -0
  156. package/src/cockpit/render.ts +194 -0
  157. package/src/cockpit/terminal-session.test.ts +252 -0
  158. package/src/cockpit/terminal-session.ts +194 -0
  159. package/src/cockpit/view.test.ts +117 -0
  160. package/src/cockpit/view.ts +140 -0
  161. package/src/demand/c8-rest.test.ts +140 -0
  162. package/src/demand/c8-rest.ts +167 -0
  163. package/src/demand/index.ts +42 -0
  164. package/src/demand/model.test.ts +197 -0
  165. package/src/demand/model.ts +183 -0
  166. package/src/demand/taskdef.test.ts +85 -0
  167. package/src/demand/taskdef.ts +78 -0
  168. package/src/index.ts +17 -0
  169. package/src/presence/family.test.ts +252 -0
  170. package/src/presence/family.ts +205 -0
  171. package/src/presence/index.ts +26 -0
  172. package/src/presence/schema.test.ts +53 -0
  173. package/src/presence/schema.ts +34 -0
  174. package/src/presence/store.test.ts +190 -0
  175. package/src/presence/store.ts +287 -0
  176. package/src/presence/test-db.test.ts +57 -0
  177. package/src/presence/test-db.ts +47 -0
  178. package/src/protocol/conformance/corpus.test.ts +66 -0
  179. package/src/protocol/conformance/frames.ts +142 -0
  180. package/src/protocol/conformance/index.ts +29 -0
  181. package/src/protocol/conformance/malformed.ts +59 -0
  182. package/src/protocol/conformance/tokens.ts +70 -0
  183. package/src/protocol/conformance/vocab.ts +122 -0
  184. package/src/protocol/families.ts +54 -0
  185. package/src/protocol/frame.test.ts +116 -0
  186. package/src/protocol/frame.ts +171 -0
  187. package/src/protocol/hex.ts +28 -0
  188. package/src/protocol/index.ts +84 -0
  189. package/src/protocol/lanes.test.ts +82 -0
  190. package/src/protocol/lanes.ts +54 -0
  191. package/src/protocol/payloads.test.ts +91 -0
  192. package/src/protocol/payloads.ts +201 -0
  193. package/src/protocol/token.test.ts +57 -0
  194. package/src/protocol/token.ts +123 -0
  195. package/src/protocol/vocab/schema.test.ts +67 -0
  196. package/src/protocol/vocab/schema.ts +281 -0
  197. package/src/relay/incarnation.test.ts +53 -0
  198. package/src/relay/incarnation.ts +54 -0
  199. package/src/relay/index.ts +34 -0
  200. package/src/relay/integration.test.ts +135 -0
  201. package/src/relay/relay-family.test.ts +236 -0
  202. package/src/relay/relay-family.ts +336 -0
  203. package/src/relay/ring.test.ts +138 -0
  204. package/src/relay/ring.ts +136 -0
  205. package/src/relay/scheduler.test.ts +233 -0
  206. package/src/relay/scheduler.ts +208 -0
  207. package/src/relay/validate.test.ts +43 -0
  208. package/src/relay/validate.ts +44 -0
  209. package/src/transcript/index.ts +33 -0
  210. package/src/transcript/integration.test.ts +108 -0
  211. package/src/transcript/schema.test.ts +69 -0
  212. package/src/transcript/schema.ts +51 -0
  213. package/src/transcript/store.test.ts +285 -0
  214. package/src/transcript/store.ts +530 -0
  215. package/src/transcript/test-db.ts +46 -0
  216. package/src/vocab/core-vocab.test.ts +34 -0
  217. package/src/vocab/core-vocab.ts +88 -0
  218. package/src/vocab/diversity.test.ts +153 -0
  219. package/src/vocab/diversity.ts +169 -0
  220. package/src/vocab/index.ts +55 -0
  221. package/src/vocab/merge.test.ts +73 -0
  222. package/src/vocab/merge.ts +117 -0
  223. package/src/vocab/requires.test.ts +69 -0
  224. package/src/vocab/requires.ts +155 -0
  225. package/src/vocab/resolver.test.ts +118 -0
  226. package/src/vocab/resolver.ts +187 -0
  227. package/src/vocab/serve.test.ts +64 -0
  228. package/src/vocab/serve.ts +66 -0
@@ -0,0 +1,78 @@
1
+ import type { Frame } from "../protocol/index.ts";
2
+ import type { Authenticator } from "./auth.ts";
3
+ import type { Clock } from "./clock.ts";
4
+ import type { ChannelTransport, CloseCode, HandshakeRequest } from "./connection.ts";
5
+ import { FamilyRouter } from "./dispatch.ts";
6
+ import { ConnectionRegistry } from "./registry.ts";
7
+ /** Application close code used when a connection ages out on the liveness TTL. */
8
+ export declare const LIVENESS_TIMEOUT = 4408;
9
+ /**
10
+ * The per-connection context threaded to every family handler. A handler
11
+ * (S2/S5/S7) uses it to reply on the same connection and to attach presence in
12
+ * the shared {@link ConnectionRegistry}.
13
+ */
14
+ export interface HubConnection {
15
+ /** The connection id. */
16
+ readonly id: string;
17
+ /** The authenticated principal (ADR 0028 identity). */
18
+ readonly identity: string;
19
+ /** What the transport captured at connect time. */
20
+ readonly handshake: HandshakeRequest;
21
+ /** The shared registry — handlers attach presence via `registry.setPresence(id, …)`. */
22
+ readonly registry: ConnectionRegistry;
23
+ /** Encode and send one frame back on this connection. */
24
+ send(frame: Frame): void;
25
+ /** Close this connection. */
26
+ close(code?: CloseCode, reason?: string): void;
27
+ }
28
+ export interface AgenticHubOptions {
29
+ /** The listening transport bound to the app's own port. */
30
+ transport: ChannelTransport;
31
+ /** How connections authenticate. */
32
+ authenticator: Authenticator;
33
+ /** A pre-populated router, or omit to let the hub create an empty one. */
34
+ router?: FamilyRouter<HubConnection>;
35
+ /** A shared registry, or omit to let the hub create one. */
36
+ registry?: ConnectionRegistry;
37
+ /**
38
+ * Injectable clock for the registry's liveness time. Ignored when a `registry`
39
+ * is supplied — that registry owns time (it is the single source of truth for
40
+ * liveness), so pass the clock to the {@link ConnectionRegistry} instead.
41
+ */
42
+ clock?: Clock;
43
+ /**
44
+ * How often to sweep for aged-out connections, in ms. Default: a third of the
45
+ * registry TTL. Pass 0 to disable the internal timer (tests call
46
+ * {@link AgenticHub.sweepNow} explicitly).
47
+ */
48
+ sweepIntervalMs?: number;
49
+ /** Notified of a decode/handler error; the offending connection is kept. */
50
+ onError?: (err: unknown, connectionId?: string) => void;
51
+ }
52
+ export declare class AgenticHub {
53
+ #private;
54
+ readonly router: FamilyRouter<HubConnection>;
55
+ readonly registry: ConnectionRegistry;
56
+ constructor(options: AgenticHubOptions);
57
+ /**
58
+ * Attach the handler that owns a message family. Convenience delegate to the
59
+ * router's registration seam — this is the canonical extension point every
60
+ * family module (S2/S5/S7) uses.
61
+ */
62
+ registerFamilyHandler(...args: Parameters<FamilyRouter<HubConnection>["registerFamilyHandler"]>): void;
63
+ /** The bound address of the underlying transport once listening. */
64
+ get address(): {
65
+ readonly port: number;
66
+ } | null;
67
+ /** The number of currently tracked connections. */
68
+ get connectionCount(): number;
69
+ /**
70
+ * Age out connections past the liveness TTL and close their sockets. Called
71
+ * automatically by the internal timer; exposed for deterministic tests. Reads
72
+ * "now" from the registry's own clock so liveness time has a single source of
73
+ * truth — the hub keeps no separate clock to drift against `lastSeen`.
74
+ */
75
+ sweepNow(): void;
76
+ /** Stop the liveness sweep and release the transport's port. */
77
+ close(): Promise<void>;
78
+ }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * The app-tier agentic hub.
3
+ *
4
+ * Stands up over a {@link ChannelTransport} bound to the app's OWN port (the
5
+ * Camunda-8 engine transport is a separate connection and untouched). For each
6
+ * accepted connection it:
7
+ * 1. authenticates the handshake (ADR 0028 identity + capability credential),
8
+ * 2. tracks it in the {@link ConnectionRegistry} with liveness,
9
+ * 3. decodes inbound frames with the S0 codec and routes each to its family
10
+ * handler via the {@link FamilyRouter} registration seam — never a switch,
11
+ * 4. ages out connections that stop sending (TTL sweep).
12
+ *
13
+ * Family behaviour (presence, relay, blackboard, …) is NOT baked in here: each
14
+ * wave-2 slice attaches its family as its own module through
15
+ * {@link AgenticHub.registerFamilyHandler}.
16
+ */
17
+ import { decodeFrame, encodeFrame, FrameDecodeError } from "../protocol/index.js";
18
+ import { FamilyRouter } from "./dispatch.js";
19
+ import { ConnectionRegistry } from "./registry.js";
20
+ /** Application close code used when a connection ages out on the liveness TTL. */
21
+ export const LIVENESS_TIMEOUT = 4408;
22
+ export class AgenticHub {
23
+ router;
24
+ registry;
25
+ #transport;
26
+ #authenticator;
27
+ #onError;
28
+ #conns = new Map();
29
+ #sweepTimer;
30
+ constructor(options) {
31
+ this.#transport = options.transport;
32
+ this.#authenticator = options.authenticator;
33
+ this.router = options.router ?? new FamilyRouter();
34
+ this.registry = options.registry ?? new ConnectionRegistry({ clock: options.clock });
35
+ this.#onError = options.onError ?? (() => { });
36
+ this.#transport.onConnection((conn) => {
37
+ void this.#accept(conn);
38
+ });
39
+ const sweepInterval = options.sweepIntervalMs ?? Math.max(1, Math.floor(this.registry.ttlMs / 3));
40
+ if (sweepInterval > 0) {
41
+ this.#sweepTimer = setInterval(() => this.sweepNow(), sweepInterval);
42
+ // Do not keep the process alive solely for the liveness sweep.
43
+ this.#sweepTimer.unref?.();
44
+ }
45
+ }
46
+ /**
47
+ * Attach the handler that owns a message family. Convenience delegate to the
48
+ * router's registration seam — this is the canonical extension point every
49
+ * family module (S2/S5/S7) uses.
50
+ */
51
+ registerFamilyHandler(...args) {
52
+ this.router.registerFamilyHandler(...args);
53
+ }
54
+ /** The bound address of the underlying transport once listening. */
55
+ get address() {
56
+ return this.#transport.address;
57
+ }
58
+ /** The number of currently tracked connections. */
59
+ get connectionCount() {
60
+ return this.registry.size;
61
+ }
62
+ async #accept(conn) {
63
+ // Register the close listener BEFORE awaiting auth. The authenticator may be
64
+ // async, and a peer can disconnect while it is in flight; the connection
65
+ // contract permits only a single close listener, so this one handler covers
66
+ // both the mid-auth race and the connection's normal post-registration life.
67
+ let closed = false;
68
+ conn.onClose(() => {
69
+ closed = true;
70
+ this.registry.remove(conn.id);
71
+ this.#conns.delete(conn.id);
72
+ });
73
+ let auth;
74
+ try {
75
+ auth = await this.#authenticator(conn.handshake);
76
+ }
77
+ catch (err) {
78
+ this.#onError(err, conn.id);
79
+ conn.close(AUTH_INTERNAL, "authentication error");
80
+ return;
81
+ }
82
+ if (!auth.ok) {
83
+ conn.close(auth.code, auth.reason);
84
+ return;
85
+ }
86
+ // The peer vanished while auth was in flight — never track a dead socket.
87
+ if (closed) {
88
+ return;
89
+ }
90
+ this.#conns.set(conn.id, conn);
91
+ this.registry.add(conn.id, auth.grant.identity);
92
+ const hubConn = {
93
+ id: conn.id,
94
+ identity: auth.grant.identity,
95
+ handshake: conn.handshake,
96
+ registry: this.registry,
97
+ send: (frame) => conn.send(encodeFrame(frame)),
98
+ close: (code, reason) => conn.close(code, reason),
99
+ };
100
+ conn.onMessage((bytes) => this.#onMessage(conn.id, hubConn, bytes));
101
+ conn.onPong?.(() => this.registry.touch(conn.id));
102
+ conn.onPing?.(() => this.registry.touch(conn.id));
103
+ }
104
+ #onMessage(id, hubConn, bytes) {
105
+ // Any inbound bytes are proof of life at the transport level — refresh
106
+ // liveness before decoding so an actively-transmitting peer is never swept
107
+ // as "silent", even if the bytes fail to decode into a well-formed frame.
108
+ this.registry.touch(id);
109
+ let frame;
110
+ try {
111
+ frame = decodeFrame(bytes);
112
+ }
113
+ catch (err) {
114
+ // A malformed frame is a per-message fault, not a connection fault: report
115
+ // it and keep the connection (and its liveness) intact.
116
+ if (err instanceof FrameDecodeError) {
117
+ this.#onError(err, id);
118
+ return;
119
+ }
120
+ throw err;
121
+ }
122
+ this.router.route(frame, hubConn).catch((err) => this.#onError(err, id));
123
+ }
124
+ /**
125
+ * Age out connections past the liveness TTL and close their sockets. Called
126
+ * automatically by the internal timer; exposed for deterministic tests. Reads
127
+ * "now" from the registry's own clock so liveness time has a single source of
128
+ * truth — the hub keeps no separate clock to drift against `lastSeen`.
129
+ */
130
+ sweepNow() {
131
+ const stale = this.registry.sweep();
132
+ for (const entry of stale) {
133
+ const conn = this.#conns.get(entry.id);
134
+ this.#conns.delete(entry.id);
135
+ conn?.close(LIVENESS_TIMEOUT, "liveness timeout");
136
+ }
137
+ }
138
+ /** Stop the liveness sweep and release the transport's port. */
139
+ async close() {
140
+ if (this.#sweepTimer !== undefined) {
141
+ clearInterval(this.#sweepTimer);
142
+ this.#sweepTimer = undefined;
143
+ }
144
+ // Actively close tracked connections so shutdown is deterministic across
145
+ // transports: ChannelTransport.close() is only specified to stop accepting
146
+ // and release the port, not to terminate live connections. Each close()
147
+ // fires the connection's onClose handler, which clears it from the registry
148
+ // and #conns; snapshot first to avoid mutating the map mid-iteration.
149
+ for (const conn of [...this.#conns.values()]) {
150
+ conn.close();
151
+ }
152
+ this.#conns.clear();
153
+ await this.#transport.close();
154
+ }
155
+ }
156
+ /** Application close code for an internal authentication error. */
157
+ const AUTH_INTERNAL = 4500;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * @nanobpm/agentic-channel — the app-tier channel & hub for the Nano agentic
3
+ * protocol (ADR 0056, slice S1).
4
+ *
5
+ * Stands up a WebSocket server on the app's OWN bound port, authenticates each
6
+ * peer (ADR 0028 identity + capability credential), tracks it in a connection
7
+ * registry with liveness, and routes inbound frames to per-family handlers via
8
+ * the {@link FamilyRouter} registration seam — the canonical extension point
9
+ * every family module (S2 presence, S5 relay, S7 blackboard) attaches to.
10
+ *
11
+ * The wire contract itself lives in `@nanobpm/agentic-protocol`; this package
12
+ * builds on it. The Camunda-8 engine transport is a separate connection and is
13
+ * never touched.
14
+ */
15
+ export { AgenticHub, LIVENESS_TIMEOUT } from "./hub.ts";
16
+ export type { AgenticHubOptions, HubConnection } from "./hub.ts";
17
+ export { FamilyRouter, UnknownFamilyError, DuplicateFamilyHandlerError, } from "./dispatch.ts";
18
+ export type { FamilyHandler } from "./dispatch.ts";
19
+ export { ConnectionRegistry } from "./registry.ts";
20
+ export type { ConnectionRegistryOptions, Presence, RegisteredConnection, } from "./registry.ts";
21
+ export { sharedSecretAuthenticator, AUTH_UNAUTHORIZED, AUTH_FORBIDDEN, } from "./auth.ts";
22
+ export type { Authenticator, AuthGrant, AuthResult, SharedSecretAuthOptions, } from "./auth.ts";
23
+ export { WebSocketChannelTransport } from "./ws-transport.ts";
24
+ export type { WebSocketChannelTransportOptions } from "./ws-transport.ts";
25
+ export { systemClock } from "./clock.ts";
26
+ export type { Clock } from "./clock.ts";
27
+ export type { ChannelConnection, ChannelTransport, CloseCode, HandshakeRequest, } from "./connection.ts";
@@ -0,0 +1,20 @@
1
+ /**
2
+ * @nanobpm/agentic-channel — the app-tier channel & hub for the Nano agentic
3
+ * protocol (ADR 0056, slice S1).
4
+ *
5
+ * Stands up a WebSocket server on the app's OWN bound port, authenticates each
6
+ * peer (ADR 0028 identity + capability credential), tracks it in a connection
7
+ * registry with liveness, and routes inbound frames to per-family handlers via
8
+ * the {@link FamilyRouter} registration seam — the canonical extension point
9
+ * every family module (S2 presence, S5 relay, S7 blackboard) attaches to.
10
+ *
11
+ * The wire contract itself lives in `@nanobpm/agentic-protocol`; this package
12
+ * builds on it. The Camunda-8 engine transport is a separate connection and is
13
+ * never touched.
14
+ */
15
+ export { AgenticHub, LIVENESS_TIMEOUT } from "./hub.js";
16
+ export { FamilyRouter, UnknownFamilyError, DuplicateFamilyHandlerError, } from "./dispatch.js";
17
+ export { ConnectionRegistry } from "./registry.js";
18
+ export { sharedSecretAuthenticator, AUTH_UNAUTHORIZED, AUTH_FORBIDDEN, } from "./auth.js";
19
+ export { WebSocketChannelTransport } from "./ws-transport.js";
20
+ export { systemClock } from "./clock.js";
@@ -0,0 +1,68 @@
1
+ /**
2
+ * The connection registry with liveness.
3
+ *
4
+ * S1 tracks every authenticated connection and its last-seen time; a connection
5
+ * not seen within the liveness TTL ages out on {@link ConnectionRegistry.sweep}.
6
+ * Presence detail (instance / declared capability / host / family) is left blank
7
+ * by S1 and filled in by the S2 register/heartbeat family module through
8
+ * {@link ConnectionRegistry.setPresence} — S1 owns liveness, S2 owns presence.
9
+ */
10
+ import type { Capability } from "../protocol/index.ts";
11
+ import type { Clock } from "./clock.ts";
12
+ /** Presence attributes a family module (S2) attaches to a live connection. */
13
+ export interface Presence {
14
+ /** The worker instance id declared on `register`. */
15
+ instance?: string;
16
+ /** The enrolment capability declared on `register` (never a routing token). */
17
+ capability?: Capability;
18
+ }
19
+ /** A tracked, authenticated connection. */
20
+ export interface RegisteredConnection {
21
+ /** The connection id (matches {@link ChannelConnection.id}). */
22
+ readonly id: string;
23
+ /** The authenticated principal (ADR 0028 identity) from the handshake. */
24
+ readonly identity: string;
25
+ /** When the connection was accepted, in epoch ms. */
26
+ readonly connectedAt: number;
27
+ /** Last time any inbound frame or keepalive pong was seen, in epoch ms. */
28
+ lastSeen: number;
29
+ /** Presence detail, populated by the S2 family module (blank until then). */
30
+ presence: Presence;
31
+ }
32
+ export interface ConnectionRegistryOptions {
33
+ /** Liveness TTL in ms; a connection unseen for longer ages out. Default 30000. */
34
+ ttlMs?: number;
35
+ /** Injectable clock (deterministic tests). Default {@link systemClock}. */
36
+ clock?: Clock;
37
+ }
38
+ export declare class ConnectionRegistry {
39
+ #private;
40
+ constructor(options?: ConnectionRegistryOptions);
41
+ /** The liveness TTL in ms. */
42
+ get ttlMs(): number;
43
+ /** Number of tracked connections. */
44
+ get size(): number;
45
+ /**
46
+ * Start tracking a newly authenticated connection. `connectedAt` and
47
+ * `lastSeen` are set to now; presence starts blank.
48
+ */
49
+ add(id: string, identity: string): RegisteredConnection;
50
+ /** Update a connection's liveness to now. No-op if unknown. */
51
+ touch(id: string): void;
52
+ /** Merge presence detail onto a tracked connection (used by S2). No-op if unknown. */
53
+ setPresence(id: string, patch: Presence): void;
54
+ /** Look up a tracked connection. */
55
+ get(id: string): RegisteredConnection | undefined;
56
+ /** Whether a connection is tracked. */
57
+ has(id: string): boolean;
58
+ /** Stop tracking a connection, returning the removed entry if present. */
59
+ remove(id: string): RegisteredConnection | undefined;
60
+ /** All tracked connections, in insertion order. */
61
+ list(): RegisteredConnection[];
62
+ /**
63
+ * Age out every connection whose last-seen time is older than the TTL and
64
+ * return the removed entries, so the hub can close their sockets. `now`
65
+ * defaults to the clock; pass an explicit value for deterministic tests.
66
+ */
67
+ sweep(now?: number): RegisteredConnection[];
68
+ }
@@ -0,0 +1,84 @@
1
+ import { systemClock } from "./clock.js";
2
+ const DEFAULT_TTL_MS = 30_000;
3
+ export class ConnectionRegistry {
4
+ #byId = new Map();
5
+ #ttlMs;
6
+ #clock;
7
+ constructor(options = {}) {
8
+ this.#ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
9
+ this.#clock = options.clock ?? systemClock;
10
+ }
11
+ /** The liveness TTL in ms. */
12
+ get ttlMs() {
13
+ return this.#ttlMs;
14
+ }
15
+ /** Number of tracked connections. */
16
+ get size() {
17
+ return this.#byId.size;
18
+ }
19
+ /**
20
+ * Start tracking a newly authenticated connection. `connectedAt` and
21
+ * `lastSeen` are set to now; presence starts blank.
22
+ */
23
+ add(id, identity) {
24
+ const now = this.#clock.now();
25
+ const entry = {
26
+ id,
27
+ identity,
28
+ connectedAt: now,
29
+ lastSeen: now,
30
+ presence: {},
31
+ };
32
+ this.#byId.set(id, entry);
33
+ return entry;
34
+ }
35
+ /** Update a connection's liveness to now. No-op if unknown. */
36
+ touch(id) {
37
+ const entry = this.#byId.get(id);
38
+ if (entry !== undefined) {
39
+ entry.lastSeen = this.#clock.now();
40
+ }
41
+ }
42
+ /** Merge presence detail onto a tracked connection (used by S2). No-op if unknown. */
43
+ setPresence(id, patch) {
44
+ const entry = this.#byId.get(id);
45
+ if (entry !== undefined) {
46
+ entry.presence = { ...entry.presence, ...patch };
47
+ }
48
+ }
49
+ /** Look up a tracked connection. */
50
+ get(id) {
51
+ return this.#byId.get(id);
52
+ }
53
+ /** Whether a connection is tracked. */
54
+ has(id) {
55
+ return this.#byId.has(id);
56
+ }
57
+ /** Stop tracking a connection, returning the removed entry if present. */
58
+ remove(id) {
59
+ const entry = this.#byId.get(id);
60
+ this.#byId.delete(id);
61
+ return entry;
62
+ }
63
+ /** All tracked connections, in insertion order. */
64
+ list() {
65
+ return [...this.#byId.values()];
66
+ }
67
+ /**
68
+ * Age out every connection whose last-seen time is older than the TTL and
69
+ * return the removed entries, so the hub can close their sockets. `now`
70
+ * defaults to the clock; pass an explicit value for deterministic tests.
71
+ */
72
+ sweep(now = this.#clock.now()) {
73
+ const stale = [];
74
+ for (const entry of this.#byId.values()) {
75
+ if (now - entry.lastSeen > this.#ttlMs) {
76
+ stale.push(entry);
77
+ }
78
+ }
79
+ for (const entry of stale) {
80
+ this.#byId.delete(entry.id);
81
+ }
82
+ return stale;
83
+ }
84
+ }
@@ -0,0 +1,23 @@
1
+ import type { Server as HttpServer } from "node:http";
2
+ import type { ChannelConnection, ChannelTransport } from "./connection.ts";
3
+ export interface WebSocketChannelTransportOptions {
4
+ /** Port to bind (the app's own port). Use 0 for an ephemeral port. Ignored if `server` is set. */
5
+ port?: number;
6
+ /** Host/interface to bind. Ignored if `server` is set. */
7
+ host?: string;
8
+ /** Path the channel is served on. Default `/agentic`. */
9
+ path?: string;
10
+ /** Attach to an existing app HTTP server (share the app's port) instead of binding a new one. */
11
+ server?: HttpServer;
12
+ }
13
+ export declare class WebSocketChannelTransport implements ChannelTransport {
14
+ #private;
15
+ constructor(options?: WebSocketChannelTransportOptions);
16
+ onConnection(listener: (conn: ChannelConnection) => void): void;
17
+ /** Resolve once the server is listening on its port. */
18
+ ready(): Promise<void>;
19
+ get address(): {
20
+ readonly port: number;
21
+ } | null;
22
+ close(): Promise<void>;
23
+ }
@@ -0,0 +1,178 @@
1
+ /**
2
+ * The production WebSocket transport for the agentic hub.
3
+ *
4
+ * A thin adapter over the `ws` server that binds the app's OWN port (or attaches
5
+ * to an existing app HTTP server so it shares that port), captures the ADR 0028
6
+ * identity token + capability credential from the upgrade request, and hands the
7
+ * hub a transport-agnostic {@link ChannelConnection} per peer. The Camunda-8
8
+ * engine transport is a separate connection and is never touched here.
9
+ */
10
+ import { randomUUID } from "node:crypto";
11
+ import { WebSocketServer } from "ws";
12
+ const DEFAULT_PATH = "/agentic";
13
+ /** RFC 6455 close code for a frame whose type the endpoint cannot accept. */
14
+ const CLOSE_UNSUPPORTED_DATA = 1003;
15
+ /**
16
+ * Grace period for the shutdown close handshake before a still-open peer is
17
+ * force-terminated, so `close()` stays bounded even if a peer stalls.
18
+ */
19
+ const GRACEFUL_CLOSE_TIMEOUT_MS = 250;
20
+ /** Normalise Node's header map to a flat, lower-cased string record. */
21
+ function flattenHeaders(req) {
22
+ const headers = {};
23
+ for (const [key, value] of Object.entries(req.headers)) {
24
+ const name = key.toLowerCase();
25
+ if (typeof value === "string") {
26
+ headers[name] = value;
27
+ }
28
+ else if (Array.isArray(value)) {
29
+ headers[name] = value.join(", ");
30
+ }
31
+ }
32
+ return headers;
33
+ }
34
+ /** Parse the upgrade request into the handshake the authenticator reads. */
35
+ function handshakeFrom(req) {
36
+ const url = new URL(req.url ?? "/", "http://localhost");
37
+ const query = {};
38
+ for (const [key, value] of url.searchParams) {
39
+ query[key] = value;
40
+ }
41
+ const headers = flattenHeaders(req);
42
+ return {
43
+ token: query.token,
44
+ credential: query.capability ?? headers["x-capability-credential"],
45
+ remote: req.socket.remoteAddress ?? undefined,
46
+ headers,
47
+ query,
48
+ };
49
+ }
50
+ /** Coerce `ws` RawData into a single contiguous byte view for the codec. */
51
+ function toBytes(data) {
52
+ if (Array.isArray(data)) {
53
+ return Buffer.concat(data);
54
+ }
55
+ if (data instanceof ArrayBuffer) {
56
+ return new Uint8Array(data);
57
+ }
58
+ return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
59
+ }
60
+ /** One `ws` socket wrapped as a {@link ChannelConnection}. */
61
+ class WsConnection {
62
+ id;
63
+ handshake;
64
+ #ws;
65
+ constructor(id, ws, handshake) {
66
+ this.id = id;
67
+ this.#ws = ws;
68
+ this.handshake = handshake;
69
+ }
70
+ send(bytes) {
71
+ if (this.#ws.readyState === this.#ws.OPEN) {
72
+ this.#ws.send(bytes, { binary: true });
73
+ }
74
+ }
75
+ close(code, reason) {
76
+ if (code === undefined) {
77
+ this.#ws.close();
78
+ }
79
+ else {
80
+ this.#ws.close(code, reason);
81
+ }
82
+ }
83
+ onMessage(listener) {
84
+ this.#ws.on("message", (data, isBinary) => {
85
+ // The channel protocol is binary-only. A text frame (`isBinary === false`,
86
+ // which `ws` may deliver as a `string` with no `.buffer`) is a protocol
87
+ // violation: reject it with 1003 rather than letting `toBytes` throw.
88
+ if (!isBinary) {
89
+ this.#ws.close(CLOSE_UNSUPPORTED_DATA, "binary frames only");
90
+ return;
91
+ }
92
+ listener(toBytes(data));
93
+ });
94
+ }
95
+ onClose(listener) {
96
+ this.#ws.on("close", (code, reason) => listener(code, reason.toString()));
97
+ }
98
+ onPong(listener) {
99
+ this.#ws.on("pong", () => listener());
100
+ }
101
+ onPing(listener) {
102
+ this.#ws.on("ping", () => listener());
103
+ }
104
+ ping() {
105
+ if (this.#ws.readyState === this.#ws.OPEN) {
106
+ this.#ws.ping();
107
+ }
108
+ }
109
+ }
110
+ export class WebSocketChannelTransport {
111
+ #wss;
112
+ #server;
113
+ #listener;
114
+ constructor(options = {}) {
115
+ const path = options.path ?? DEFAULT_PATH;
116
+ this.#server = options.server;
117
+ this.#wss = options.server
118
+ ? new WebSocketServer({ server: options.server, path })
119
+ : new WebSocketServer({ port: options.port ?? 0, host: options.host, path });
120
+ this.#wss.on("connection", (ws, req) => {
121
+ const conn = new WsConnection(randomUUID(), ws, handshakeFrom(req));
122
+ this.#listener?.(conn);
123
+ });
124
+ }
125
+ onConnection(listener) {
126
+ this.#listener = listener;
127
+ }
128
+ /** Resolve once the server is listening on its port. */
129
+ ready() {
130
+ // In shared-port mode the app owns the HTTP server: `ws` never emits
131
+ // `listening` on the WebSocketServer, so wait on the HTTP server instead
132
+ // (or resolve at once if it is already listening) to avoid hanging forever.
133
+ const source = this.#server ?? this.#wss;
134
+ if (this.#server ? this.#server.listening : this.#wss.address() !== null) {
135
+ return Promise.resolve();
136
+ }
137
+ return new Promise((resolve, reject) => {
138
+ const onListening = () => {
139
+ source.off("error", onError);
140
+ resolve();
141
+ };
142
+ const onError = (err) => {
143
+ source.off("listening", onListening);
144
+ reject(err);
145
+ };
146
+ source.once("listening", onListening);
147
+ source.once("error", onError);
148
+ });
149
+ }
150
+ get address() {
151
+ const addr = this.#wss.address();
152
+ if (addr !== null && typeof addr === "object" && "port" in addr) {
153
+ return { port: addr.port };
154
+ }
155
+ return null;
156
+ }
157
+ close() {
158
+ return new Promise((resolve, reject) => {
159
+ // Prefer a graceful close handshake so peers observe a normal closure
160
+ // (with any application close code/reason) instead of an abnormal 1006.
161
+ // Fall back to terminate() for any peer that doesn't complete the
162
+ // handshake promptly, keeping shutdown bounded and deterministic.
163
+ for (const client of this.#wss.clients) {
164
+ client.close();
165
+ }
166
+ const fallback = setTimeout(() => {
167
+ for (const client of this.#wss.clients) {
168
+ client.terminate();
169
+ }
170
+ }, GRACEFUL_CLOSE_TIMEOUT_MS);
171
+ fallback.unref?.();
172
+ this.#wss.close((err) => {
173
+ clearTimeout(fallback);
174
+ err ? reject(err) : resolve();
175
+ });
176
+ });
177
+ }
178
+ }