c8ctl-plugin-nano 1.54.1 → 1.55.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.
@@ -1,178 +1,78 @@
1
- // The concrete host-connection agentic endpoint (ADR 0056; issue #160).
1
+ // The concrete host-connection agentic endpoint (ADR 0056; issues #160, #186).
2
2
  //
3
3
  // #158 shipped the host-owned job-ownership protocol as an Effect *interface*
4
4
  // (`AgenticEndpoint` → `AgenticHandle` implementing `register`/`heartbeat`/
5
5
  // `deregister`/`claim`/`transcript`/`release`, identity explicit on every frame)
6
- // but no concrete transport the `0.10.0` `@nanobpm/agentic` had no `claim`/
7
- // `release` presence frames to send. `@nanobpm/agentic` is now `^0.11.0`, which
8
- // lands families 8/9 (`claim`/`release`), the `claim-release`/`multi-instance`
9
- // negotiable features, and the additive negotiation module. This module is the
10
- // concrete wire against that bump: ONE multiplexed WebSocket connection over
11
- // which N supervised workers' presence + ownership + transcript frames ride,
12
- // each frame carrying its `instance` EXPLICITLY (never the connection id — the
13
- // assumption #154 structurally breaks).
6
+ // but no concrete transport. #160 wired a bespoke one by hand. Since #546 (in
7
+ // `@nanobpm/agentic` 0.12.0) the package ships a high-level `@nanobpm/agentic/
8
+ // emit` `AgenticEmitClient` that already provides everything this file used to
9
+ // hand-roll: multiplexed multi-instance presence, register/heartbeat/deregister,
10
+ // idempotent claim/release, transcript emit over QoS lanes, additive
11
+ // negotiation, capability shaping, and critically reconnect-resync from its
12
+ // own write-through shadow of the presence + in-flight-claim maps. #186 retires
13
+ // the bespoke emit surface in favour of that client and its injective
14
+ // `composeStreamId`/`parseStreamId` stream-id codec.
14
15
  //
15
- // It produces a plain, Effect-free `RawEmitClient` (see `supervisor/src/emit.ts`)
16
- // that the supervisor bundle lifts into the Effect `AgenticHandle`. Everything on
17
- // the wire is CONSUMED through this plugin's single import surface (`agentic.mjs`
18
- // `@nanobpm/agentic` + `@nanobpm/urban-agent-client`); nothing is re-declared.
16
+ // This module is now a THIN adapter: it lifts one `AgenticEmitClient` into the
17
+ // plain, Effect-free `RawEmitClient` port (`supervisor/src/emit.ts`) the
18
+ // supervisor bundle lifts into the Effect `AgenticHandle`. Two seams remain the
19
+ // adapter's responsibility because the emit client is a pure emitter that holds
20
+ // no inbound sub-protocol:
19
21
  //
20
- // The supervisor's `superviseAgentic` owns reconnect (it calls the endpoint's
21
- // `connect` again on every drop) and the claim registry owns replay (the resync
22
- // re-`register`s + re-`claim`s before transcript resumes), so this client is thin
23
- // on purpose: one socket, encode-and-send, no buffering or reconnect of its own.
22
+ // 1. Inbound steer routing the client ignores inbound frames, so the
23
+ // EmitSocket adapter decodes each inbound relay DELIVERY frame and fans it
24
+ // to the installed steer route, keyed by the package `parseStreamId` codec
25
+ // (a foreign/blackboard stream is dropped, never misrouted).
26
+ // 2. Reconnect ownership — the emit client OWNS reconnect and re-emits presence
27
+ // + active claims from its shadow on every reconnect, so the RawEmitClient
28
+ // surfaces a mid-life drop only as a liveness transition (`onConnectionState`)
29
+ // and reserves its single-shot `onClose` for a permanent teardown. That
30
+ // retires the supervisor's manual re-register/re-claim resync (#186): the
31
+ // `ownership.ts` registry stays the authority and the single write path into
32
+ // the client, while the wire-level replay is the client's job.
33
+ //
34
+ // Everything on the wire is CONSUMED through this plugin's single import surface
35
+ // (`agentic.mjs` → `@nanobpm/agentic/emit` + `@nanobpm/urban-agent-client`);
36
+ // nothing is re-declared here.
24
37
 
25
38
  import {
26
- encodeFrame,
39
+ AgenticEmitClient,
40
+ parseStreamId,
27
41
  decodeFrame,
28
- MAX_SEQ,
29
- validatePayload,
30
- negotiate,
31
- LOCAL_ADVERTISEMENT,
32
42
  loadAgenticClient,
33
43
  } from './agentic.mjs';
34
44
  import { buildAgenticUrl } from './work-channel.mjs';
35
45
 
36
- // Presence + ownership frames are facts — they ride the CONTROL lane so a
37
- // transcript storm on the bulk lane can never delay a claim/release (the QoS
38
- // ordering contract). Transcript chunks ride BULK.
39
- const CONTROL_LANE = 'control';
40
- const BULK_LANE = 'bulk';
41
-
42
- // A transcript stream name that encodes BOTH the owning instance and the jobKey,
43
- // so two workers' (or two jobs') transcript streams over the one connection can
44
- // never collide. `encodeURIComponent` on each segment makes the join
45
- // unambiguous regardless of what characters an instance/jobKey contains.
46
- function composeTranscriptStream(instance, jobKey) {
47
- return `t/${encodeURIComponent(instance)}/${encodeURIComponent(jobKey)}`;
48
- }
49
-
50
- // Reverse of {@link composeTranscriptStream}. Returns null for a stream that is
51
- // not one of ours (a foreign/blackboard stream), so an inbound steer frame for
52
- // an unrecognised stream is dropped rather than misrouted.
53
- function parseTranscriptStream(stream) {
54
- if (typeof stream !== 'string') return null;
55
- const parts = stream.split('/');
56
- if (parts.length !== 3 || parts[0] !== 't') return null;
57
- try {
58
- return { instance: decodeURIComponent(parts[1]), jobKey: decodeURIComponent(parts[2]) };
59
- } catch {
60
- return null;
61
- }
62
- }
63
-
64
- // Drop undefined/empty attributes from a declared capability so the enrolment
65
- // attribute on `register` stays minimal (the S0 validator only requires
66
- // `capability` to be an object and tolerates extra fields).
67
- function cleanCapability(capability) {
68
- const out = {};
69
- if (capability && typeof capability === 'object') {
70
- for (const [k, v] of Object.entries(capability)) {
71
- if (v !== undefined && v !== null && v !== '') out[k] = v;
72
- }
73
- }
74
- return out;
75
- }
76
-
77
46
  const textDecoder = new TextDecoder();
78
47
  const textEncoder = new TextEncoder();
79
48
 
80
- // Compute the negotiated protocol against the peer's advertisement. There is no
81
- // wired advertisement-exchange frame in the `0.11.0` protocol yet, so a caller
82
- // that has not learned the peer's support passes nothing and we assume the peer
83
- // matches this build (full support) — additive negotiation still degrades
84
- // correctly the moment a real remote advertisement is supplied (an old hub that
85
- // never learned `claim`/`release` yields a negotiation without them, so those
86
- // methods report unsupported and the adapter omits them).
87
- function negotiatedSupport(remoteAdvertisement) {
88
- const negotiated = negotiate(LOCAL_ADVERTISEMENT, remoteAdvertisement ?? LOCAL_ADVERTISEMENT);
89
- return {
90
- claimRelease:
91
- negotiated.supportsFeature('claim-release') &&
92
- negotiated.supportsFamily('claim') &&
93
- negotiated.supportsFamily('release'),
94
- // Inbound steer rides the relay lane as a delivery frame keyed by our
95
- // transcript-stream naming; it is installable whenever relay is negotiated.
96
- steer: negotiated.supportsFamily('relay'),
97
- };
98
- }
99
-
100
49
  /**
101
- * Open one multiplexed host connection and return a {@link RawEmitClient}.
50
+ * Adapt one `@nanobpm/urban-agent-client` websocket transport (the `{ send,
51
+ * close }` value + `{ onOpen, onFrame, onClose, onError }` hook shape) to the
52
+ * `EmitSocket` the {@link AgenticEmitClient} drives (`send`/`close` +
53
+ * `onMessage`/`onOpen`/`onClose` registrations). Opens a fresh transport per
54
+ * call — the emit client invokes its `connect` factory once per (re)connect.
102
55
  *
103
- * @param {object} params
104
- * @param {string} params.url the resolved `ws(s)://…/agentic?token=…` channel URL
105
- * @param {import('@nanobpm/urban-agent-client').TransportFactory} params.transportFactory
106
- * @param {number} params.incarnation producer generation stamped on transcript
107
- * frames (strictly higher on each successive connection so a reconnected
108
- * producer fences its stale predecessor on the hub's incarnation ring)
109
- * @param {{ claimRelease: boolean, steer: boolean }} params.support negotiated capabilities
110
- * @param {{ warn?: Function, debug?: Function }} [params.logger]
111
- * @param {(state: 'connected' | 'disconnected') => void} [params.onConnectionState]
112
- * optional observer invoked on connection-state transitions: `'connected'`
113
- * once the transport opens and `'disconnected'` when it drops (fired at most
114
- * once per drop). A throwing observer is caught and logged, never propagated.
115
- * @returns {import('./supervisor.dist.js').RawEmitClient}
56
+ * Inbound relay DELIVERY frames are decoded HERE and fanned to the current steer
57
+ * route (the emit client ignores inbound frames), keyed by {@link parseStreamId}
58
+ * so a frame for a foreign stream is dropped rather than misrouted.
59
+ *
60
+ * @param {string} url the resolved `ws(s)://…/agentic?token=…` channel URL
61
+ * @param {import('@nanobpm/urban-agent-client').TransportFactory} transportFactory
62
+ * @param {() => (((instance: string, jobKey: string, chunk: Uint8Array) => void) | null)} getSteerRoute
63
+ * @param {{ warn?: Function, debug?: Function }} log
64
+ * @returns {import('@nanobpm/agentic/emit').EmitSocket}
116
65
  */
117
- function openHostConnection({ url, transportFactory, incarnation, support, logger, onConnectionState }) {
118
- const log = logger || {};
119
- const notifyState = (state) => {
120
- if (typeof onConnectionState !== 'function') return;
121
- try { onConnectionState(state); } catch (err) { log.debug?.(`agentic onConnectionState threw — ${err?.message || err}`); }
122
- };
123
- const openCbs = [];
124
- const closeCbs = [];
125
- let steerRoute = null;
126
- let open = false;
127
- let hasClosed = false;
128
- let seq = 0;
129
-
130
- // Monotonic uint32 sequence with wraparound — the relay resume-from-offset
131
- // counter; mirrors the client lib's own seq handling.
132
- const nextSeq = () => {
133
- const s = seq;
134
- seq = seq >= MAX_SEQ ? 0 : seq + 1;
135
- return s;
136
- };
137
-
138
- let transport = null;
139
- let closedFired = false;
140
-
141
- // Notify registered onClose subscribers at most once, regardless of whether a
142
- // caller-initiated close() or the transport's own onClose fires first. Higher
143
- // layers (AgenticHandle.closed) must observe the drop even if the underlying
144
- // transport delays or omits its close callback.
145
- const fireClosed = () => {
146
- if (closedFired) return;
147
- closedFired = true;
148
- open = false;
149
- hasClosed = true;
150
- // Guard each subscriber: a throwing onClose callback must not abort the loop
151
- // or prevent notifyState('disconnected') from firing (which would strand the
152
- // connection-state observer and could crash the worker/supervisor on a drop).
153
- for (const cb of closeCbs) {
154
- try { cb(); } catch (err) { log.debug?.(`agentic onClose subscriber threw — ${err?.message || err}`); }
155
- }
156
- notifyState('disconnected');
157
- };
158
-
159
- const send = (lane, family, payload) => {
160
- if (!open || transport === null) {
161
- // Contract: a not-open transport throws synchronously so the adapter can
162
- // surface a SupervisorError the best-effort caller swallows (the next
163
- // resync replays it). Guard here too so we never encode into a dead socket.
164
- throw new Error(`agentic transport not open (cannot send ${family})`);
165
- }
166
- const check = validatePayload(family, payload);
167
- if (!check.ok) {
168
- const detail = check.errors.map((e) => `${e.code}:${e.message}`).join(', ');
169
- throw new Error(`invalid ${family} payload — ${detail}`);
170
- }
171
- transport.send(encodeFrame({ lane, family, seq: nextSeq(), payload }));
172
- };
66
+ function makeEmitSocket(url, transportFactory, getSteerRoute, log) {
67
+ let onMessageCb = () => {};
68
+ let onOpenCb = () => {};
69
+ let onCloseCb = () => {};
70
+ let opened = false;
71
+ let closed = false;
173
72
 
174
- const handleInbound = (bytes) => {
175
- if (steerRoute === null) return;
73
+ const routeSteer = (bytes) => {
74
+ const route = getSteerRoute();
75
+ if (typeof route !== 'function') return;
176
76
  let frame;
177
77
  try {
178
78
  frame = decodeFrame(bytes);
@@ -182,122 +82,222 @@ function openHostConnection({ url, transportFactory, incarnation, support, logge
182
82
  }
183
83
  // Inbound steer rides the relay family as a DELIVERY chunk ({ stream, offset,
184
84
  // chunk }, no `op`) whose stream is one of our transcript streams; the stream
185
- // carries the target instance + jobKey. A frame for a foreign stream is
85
+ // id carries the target instance + jobKey. A frame for a foreign stream is
186
86
  // dropped, never misrouted.
187
87
  if (frame.family !== 'relay') return;
188
88
  const payload = frame.payload;
189
89
  if (!payload || typeof payload !== 'object' || 'op' in payload) return;
190
- const target = parseTranscriptStream(payload.stream);
191
- if (target === null) return;
90
+ const target = parseStreamId(payload.stream);
91
+ if (target === undefined) return;
192
92
  const chunk = typeof payload.chunk === 'string' ? textEncoder.encode(payload.chunk) : new Uint8Array(0);
193
93
  try {
194
- steerRoute(target.instance, target.jobKey, chunk);
94
+ // `target.stream` is `String(jobKey)` by the transcript convention.
95
+ route(target.instance, target.stream, chunk);
195
96
  } catch (err) {
196
97
  log.debug?.(`agentic: steer route threw — ${err?.message || err}`);
197
98
  }
198
99
  };
199
100
 
200
- transport = transportFactory(url, {
101
+ const transport = transportFactory(url, {
201
102
  onOpen() {
202
- open = true;
203
- // Guard each subscriber: a throwing onOpen callback must not abort the loop
204
- // or prevent notifyState('connected') from firing.
205
- for (const cb of openCbs) {
206
- try { cb(); } catch (err) { log.debug?.(`agentic onOpen subscriber threw — ${err?.message || err}`); }
207
- }
208
- notifyState('connected');
103
+ opened = true;
104
+ onOpenCb();
209
105
  },
210
106
  onFrame(bytes) {
211
- handleInbound(bytes);
107
+ routeSteer(bytes);
108
+ onMessageCb(bytes);
212
109
  },
213
110
  onClose() {
214
- fireClosed();
111
+ closed = true;
112
+ onCloseCb();
215
113
  },
216
114
  onError(err) {
217
- // Non-fatal on its own; a close follows and drives the reconnect. Surface
218
- // it for diagnosis only.
115
+ // Non-fatal on its own; a close follows and drives the client's reconnect.
219
116
  log.debug?.(`agentic transport error — ${err?.message || err}`);
220
117
  },
221
118
  });
222
119
 
120
+ return {
121
+ send(bytes) {
122
+ // Drop sends outside the open window: a pre-open send (the client can
123
+ // emit before the transport's onOpen fires) or a post-close send would
124
+ // otherwise hit a transport that throws while connecting/torn down,
125
+ // producing noisy onError logs. The emit client replays presence + active
126
+ // claims on the next open anyway, so a dropped pre-open frame is not lost.
127
+ if (!opened || closed) return;
128
+ transport.send(bytes);
129
+ },
130
+ close() {
131
+ try {
132
+ transport.close();
133
+ } catch {
134
+ /* idempotent best-effort teardown — never throw on close */
135
+ }
136
+ },
137
+ onMessage(listener) {
138
+ if (typeof listener === 'function') onMessageCb = listener;
139
+ },
140
+ onOpen(listener) {
141
+ if (typeof listener !== 'function') return;
142
+ onOpenCb = listener;
143
+ // If the transport already opened before this registered (synchronous
144
+ // injected factories can), fire immediately.
145
+ if (opened) listener();
146
+ },
147
+ onClose(listener) {
148
+ if (typeof listener !== 'function') return;
149
+ onCloseCb = listener;
150
+ // Mirror onOpen: a close-before-registration is observable immediately.
151
+ if (closed) listener();
152
+ },
153
+ };
154
+ }
155
+
156
+ /**
157
+ * Build a {@link import('./supervisor.dist.js').RawEmitClient} backed by ONE
158
+ * {@link AgenticEmitClient}. The client owns reconnect + resync internally; this
159
+ * adapter maps its emitter surface onto the port and installs the inbound-steer
160
+ * seam the client does not carry.
161
+ */
162
+ function buildRawEmitClient({ channelUrl, transportFactory, peerAdvertisement, logger, onConnectionState }) {
163
+ const log = logger || {};
164
+ // The inbound-steer route, shared with every socket the client opens across
165
+ // reconnects (each fresh EmitSocket reads it, so steer survives a flap without
166
+ // a per-reconnect re-install).
167
+ const steerHolder = { route: null };
168
+
169
+ let firstOpenFired = false;
170
+ let permanentlyClosed = false;
171
+ const openSubs = [];
172
+ const closeSubs = [];
173
+
174
+ const notifyState = (state) => {
175
+ if (typeof onConnectionState !== 'function') return;
176
+ try {
177
+ onConnectionState(state);
178
+ } catch (err) {
179
+ log.debug?.(`agentic onConnectionState threw — ${err?.message || err}`);
180
+ }
181
+ };
182
+
183
+ const client = new AgenticEmitClient({
184
+ connect: () => makeEmitSocket(channelUrl, transportFactory, () => steerHolder.route, log),
185
+ peerAdvertisement,
186
+ onOpen() {
187
+ // Fires AFTER the client's resync on every (re)connect. Track liveness on
188
+ // each, but resolve the single-shot RawEmitClient `onOpen` (which drives
189
+ // the Effect connect) only once, on the first open.
190
+ notifyState('connected');
191
+ if (firstOpenFired) return;
192
+ firstOpenFired = true;
193
+ for (const cb of openSubs.splice(0)) {
194
+ try {
195
+ cb();
196
+ } catch (err) {
197
+ log.debug?.(`agentic onOpen subscriber threw — ${err?.message || err}`);
198
+ }
199
+ }
200
+ },
201
+ onClose() {
202
+ // A transient mid-life drop: the client reconnects itself and replays
203
+ // presence + claims from its write-through shadow. Surface liveness only —
204
+ // the RawEmitClient's single-shot `onClose` is reserved for a permanent
205
+ // teardown so `superviseAgentic` never double-reconnects underneath it.
206
+ if (permanentlyClosed) return;
207
+ notifyState('disconnected');
208
+ },
209
+ onError(err) {
210
+ log.debug?.(`agentic emit error — ${err?.message || err}`);
211
+ },
212
+ });
213
+
214
+ // Negotiated protocol is fixed at construction (additive): a legacy hub that
215
+ // never learned families 8/9 yields a negotiation without claim/release, so the
216
+ // adapter reports them unsupported and the supervisor degrades to a no-op.
217
+ const negotiated = client.protocol;
218
+ const supportsClaimRelease =
219
+ negotiated.supportsFeature('claim-release') &&
220
+ negotiated.supportsFamily('claim') &&
221
+ negotiated.supportsFamily('release');
222
+ const supportsSteer = negotiated.supportsFamily('relay');
223
+
224
+ // Open the first socket; the client drives every subsequent reconnect.
225
+ client.open();
226
+
223
227
  return {
224
228
  register(instance, capability) {
225
- send(CONTROL_LANE, 'register', { instance, capability: cleanCapability(capability) });
229
+ client.register(instance, capability || {});
226
230
  },
227
231
  heartbeat(instance) {
228
- send(CONTROL_LANE, 'heartbeat', { instance });
232
+ client.heartbeat(instance);
229
233
  },
230
234
  deregister(instance, reason) {
231
- send(CONTROL_LANE, 'deregister', reason ? { instance, reason } : { instance });
235
+ client.deregister(instance, reason);
232
236
  },
233
237
  claim(instance, jobKey) {
234
- send(CONTROL_LANE, 'claim', { instance, jobKey });
238
+ client.claim(instance, jobKey);
235
239
  },
236
240
  release(instance, jobKey) {
237
- send(CONTROL_LANE, 'release', { instance, jobKey });
241
+ client.release(instance, jobKey);
238
242
  },
239
243
  transcript(instance, jobKey, chunk) {
240
- send(BULK_LANE, 'relay', {
241
- op: 'produce',
242
- stream: composeTranscriptStream(instance, jobKey),
243
- incarnation,
244
- chunk: textDecoder.decode(chunk),
245
- });
244
+ // The transcript convention: stream = String(jobKey); the client composes
245
+ // the injective per-instance stream id via composeStreamId.
246
+ client.transcript({ instance, stream: String(jobKey) }, textDecoder.decode(chunk));
246
247
  },
247
248
  onSteer(route) {
248
- steerRoute = route;
249
+ steerHolder.route = route;
249
250
  },
250
251
  onOpen(cb) {
251
- // If the transport is already open (injected/synchronous factories can
252
- // open before this registers), fire immediately otherwise the queued
253
- // callback never runs and connect() hangs waiting for `opened`.
254
- if (open) {
252
+ // If already open (the client can open before this registers), fire now —
253
+ // otherwise the queued callback never runs and connect() hangs.
254
+ if (typeof cb !== 'function') return;
255
+ if (firstOpenFired) {
255
256
  cb();
256
257
  return;
257
258
  }
258
- openCbs.push(cb);
259
+ openSubs.push(cb);
259
260
  },
260
261
  onClose(cb) {
261
- // Mirror onOpen: if the transport already closed (injected/synchronous
262
- // factories can close before this registers), fire immediately —
263
- // otherwise a close-before-open is never observable and connect() hangs
264
- // waiting for `opened` (neither onOpen nor onClose would ever run).
265
- if (hasClosed) {
262
+ // Mirror onOpen: if already permanently closed, fire immediately so a
263
+ // post-close subscriber still observes the drop.
264
+ if (typeof cb !== 'function') return;
265
+ if (permanentlyClosed) {
266
266
  cb();
267
267
  return;
268
268
  }
269
- closeCbs.push(cb);
269
+ closeSubs.push(cb);
270
270
  },
271
271
  close() {
272
- // Mark closed and drop the transport reference locally so post-close
273
- // send() reliably throws (the not-open contract) even if the underlying
274
- // transport delays or omits its close callback.
275
- open = false;
276
- hasClosed = true;
277
- const t = transport;
278
- transport = null;
272
+ if (permanentlyClosed) return;
273
+ // Mark closed BEFORE teardown so the client's transient onClose observer
274
+ // no-ops and only this permanent path notifies subscribers + liveness.
275
+ permanentlyClosed = true;
279
276
  try {
280
- t?.close();
277
+ client.close();
281
278
  } catch {
282
279
  /* idempotent best-effort teardown — never throw on close */
283
280
  }
284
- // Notify subscribers ourselves never rely on the transport re-entering
285
- // onClose after a caller-initiated teardown (it may delay or omit it),
286
- // which would leave AgenticHandle.closed pending indefinitely. Idempotent:
287
- // a subsequent transport onClose is a no-op.
288
- fireClosed();
281
+ for (const cb of closeSubs.splice(0)) {
282
+ try {
283
+ cb();
284
+ } catch (err) {
285
+ log.debug?.(`agentic onClose subscriber threw — ${err?.message || err}`);
286
+ }
287
+ }
288
+ notifyState('disconnected');
289
289
  },
290
- supportsClaimRelease: support.claimRelease,
291
- supportsSteer: support.steer,
290
+ supportsClaimRelease,
291
+ supportsSteer,
292
292
  };
293
293
  }
294
294
 
295
295
  /**
296
296
  * Build the `RawEmitConnect` factory the supervisor's `makeAgenticEndpoint`
297
- * lifts into `deps.agenticEndpoint`. Each returned `connect()` opens ONE fresh
298
- * multiplexed host connection (a reconnect calls it again), stamped with a
299
- * strictly-increasing incarnation so a reconnected transcript producer fences
300
- * its stale predecessor.
297
+ * lifts into `deps.agenticEndpoint`. Each returned `connect()` constructs ONE
298
+ * {@link AgenticEmitClient} over a single multiplexed host connection; the client
299
+ * owns its own reconnect + resync, so unlike the retired bespoke client —
300
+ * `superviseAgentic` does not re-`connect` or re-emit per drop.
301
301
  *
302
302
  * The WebSocket transport is loaded once through the plugin's single import
303
303
  * surface (`loadAgenticClient()` — which installs the source→dist resolve hook so
@@ -309,40 +309,28 @@ function openHostConnection({ url, transportFactory, incarnation, support, logge
309
309
  * @param {string} [opts.token] ADR 0028 identity token (carried as `?token=`)
310
310
  * @param {string} [opts.credential] capability credential (carried as `?capability=`)
311
311
  * @param {import('@nanobpm/agentic/protocol').ProtocolAdvertisement} [opts.remoteAdvertisement]
312
- * the peer's advertised support; omit to assume full support (see {@link negotiatedSupport})
313
- * @param {number} [opts.incarnationBase] first transcript incarnation (default `Date.now()`)
312
+ * the peer's advertised support; omit to assume full support
314
313
  * @param {import('@nanobpm/urban-agent-client').TransportFactory} [opts.transportFactory] injectable transport (tests)
315
314
  * @param {(state: 'connected'|'disconnected') => void} [opts.onConnectionState] observer fired
316
- * when a connection opens/drops, so a caller can track the single host connection's
317
- * liveness (e.g. the supervisor activity marker's agentic status)
315
+ * when the single host connection opens/drops, so a caller can track its liveness
318
316
  * @param {{ warn?: Function, debug?: Function }} [opts.logger]
319
317
  * @returns {Promise<() => import('./supervisor.dist.js').RawEmitClient>} a synchronous `connect` factory
320
318
  */
321
319
  export async function createRawEmitConnect(opts) {
322
- const { url, token, credential, remoteAdvertisement, incarnationBase, transportFactory, logger, onConnectionState } = opts || {};
320
+ const { url, token, credential, remoteAdvertisement, transportFactory, logger, onConnectionState } = opts || {};
323
321
  if (typeof url !== 'string' || url.trim() === '') {
324
322
  throw new Error('createRawEmitConnect requires an agentic channel base url');
325
323
  }
326
324
 
327
325
  const channelUrl = buildAgenticUrl(url, { token, credential });
328
326
  const factory = transportFactory ?? (await loadAgenticClient()).websocketTransport;
329
- const support = negotiatedSupport(remoteAdvertisement);
330
-
331
- // Strictly-increasing per-connection incarnation so successive reconnects fence
332
- // their predecessor on the hub's transcript ring (a monotonic takeover counter,
333
- // seeded from the clock so a later-started process starts ahead).
334
- let generation = Number.isInteger(incarnationBase) && incarnationBase >= 0 ? incarnationBase : Date.now();
335
327
 
336
328
  return () =>
337
- openHostConnection({
338
- url: channelUrl,
329
+ buildRawEmitClient({
330
+ channelUrl,
339
331
  transportFactory: factory,
340
- incarnation: generation++,
341
- support,
332
+ peerAdvertisement: remoteAdvertisement,
342
333
  logger,
343
334
  onConnectionState,
344
335
  });
345
336
  }
346
-
347
- // Exposed for unit tests / reuse.
348
- export { composeTranscriptStream, parseTranscriptStream, negotiatedSupport, cleanCapability };
package/agentic.mjs CHANGED
@@ -102,6 +102,24 @@ export * as sessionAcp from '@nanobpm/agentic/session/acp';
102
102
  // ---------------------------------------------------------------------------
103
103
  export * as demand from '@nanobpm/agentic/demand';
104
104
 
105
+ // ---------------------------------------------------------------------------
106
+ // Ownership/presence EMIT client — @nanobpm/agentic/emit (nano-ide#557).
107
+ //
108
+ // The blessed client-side emitter: ONE multiplexed host connection that N
109
+ // instances share, emitting register/heartbeat/deregister/claim/release and the
110
+ // relay transcript sink with an EXPLICIT `instance` per frame, owning its own
111
+ // reconnect resync and additive version negotiation. `agentic-endpoint.mjs`
112
+ // builds the plugin's concrete `RawEmitClient` on this instead of hand-rolling a
113
+ // parallel client-ownership layer. `composeStreamId`/`parseStreamId` are the one
114
+ // injective transcript-stream-id codec both this producer (routing inbound steer
115
+ // back to `{instance, jobKey}`) and the nano-workforce consumer derive from.
116
+ // ---------------------------------------------------------------------------
117
+ export {
118
+ AgenticEmitClient,
119
+ composeStreamId,
120
+ parseStreamId,
121
+ } from '@nanobpm/agentic/emit';
122
+
105
123
  // ---------------------------------------------------------------------------
106
124
  // Worker-side channel client — @nanobpm/urban-agent-client.
107
125
  //
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "c8ctl-plugin-nano",
3
- "version": "1.54.1",
3
+ "version": "1.55.0",
4
4
  "type": "module",
5
5
  "description": "c8ctl plugin to start, inspect, and stop a local Nano BPM (nanobpmn) cluster",
6
6
  "main": "c8ctl-plugin.js",
@@ -66,17 +66,17 @@
66
66
  "typescript": "^5.9.3"
67
67
  },
68
68
  "dependencies": {
69
- "@nanobpm/agentic": "^0.12.0",
70
- "@nanobpm/urban-agent-client": "^0.1.12"
69
+ "@nanobpm/agentic": "^0.13.0",
70
+ "@nanobpm/urban-agent-client": "^0.1.13"
71
71
  },
72
72
  "optionalDependencies": {
73
73
  "node-pty": "^1.0.0",
74
- "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.54.1",
75
- "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.54.1",
76
- "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.54.1",
77
- "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.54.1",
78
- "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.54.1",
79
- "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.54.1",
80
- "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.54.1"
74
+ "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.55.0",
75
+ "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.55.0",
76
+ "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.55.0",
77
+ "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.55.0",
78
+ "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.55.0",
79
+ "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.55.0",
80
+ "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.55.0"
81
81
  }
82
82
  }