c8ctl-plugin-nano 1.27.0 → 1.28.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,42 @@
1
+ // Module-customization RESOLVE hook (Node `module.register`) — the single
2
+ // mechanism that makes `@nanobpm/urban-agent-client` loadable under stock Node.
3
+ //
4
+ // Why this exists (the C0 constraint, jwulf/c8ctl-plugin-nano#39):
5
+ // the published worker client funnels the S0 wire contract through
6
+ // `dist/protocol.js`, which imports `@nanobpm/agentic/source/protocol` — raw
7
+ // TypeScript — on the assumption the consumer runs under a type-stripping
8
+ // loader. This repo runs on stock Node (`node --test`, `nano work`), which
9
+ // REFUSES to type-strip `.ts` under `node_modules`
10
+ // (ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING), so a bare `import` of the
11
+ // client throws before any of C2's channel code can run.
12
+ //
13
+ // The redirect below rewrites every `@nanobpm/agentic/source/*` specifier to
14
+ // the package's COMPILED `@nanobpm/agentic/*` `dist` export. Source and dist are
15
+ // the same S0 contract — both are held to the one shared conformance corpus
16
+ // (`agentic-conformance.test.mjs`) — so this is a pure packaging redirect, not a
17
+ // behavioural change: the client ends up bound to the exact codec/grammar this
18
+ // repo already runs green. When the client is republished to import agentic's
19
+ // `dist` directly, this hook becomes a no-op and can be retired.
20
+ //
21
+ // Registered lazily from `loadAgenticClient()` in `agentic.mjs` (the single
22
+ // client swap point) so it is active before the client's module graph loads.
23
+
24
+ const SOURCE_PREFIX = '@nanobpm/agentic/source/';
25
+
26
+ /**
27
+ * Node ESM resolve hook. Redirects the client's raw-`.ts` source imports to the
28
+ * compiled dist subpath exports; passes everything else through untouched.
29
+ *
30
+ * @param {string} specifier the requested module specifier
31
+ * @param {import('node:module').ResolveHookContext} context resolution context
32
+ * @param {(s: string, c?: object) => unknown} next the next hook in the chain
33
+ */
34
+ export async function resolve(specifier, context, next) {
35
+ if (specifier === '@nanobpm/agentic/source') {
36
+ return next('@nanobpm/agentic', context);
37
+ }
38
+ if (specifier.startsWith(SOURCE_PREFIX)) {
39
+ return next(`@nanobpm/agentic/${specifier.slice(SOURCE_PREFIX.length)}`, context);
40
+ }
41
+ return next(specifier, context);
42
+ }
package/agentic.mjs CHANGED
@@ -15,6 +15,10 @@
15
15
  // this repo's consumption in lock-step with the hub — see
16
16
  // `agentic-conformance.test.mjs`.
17
17
 
18
+ // Node's module-customization registrar, used lazily by `loadAgenticClient()`
19
+ // to install the source→dist resolve hook before the worker client loads.
20
+ import { register as moduleRegister } from 'node:module';
21
+
18
22
  // ---------------------------------------------------------------------------
19
23
  // Wire contract — @nanobpm/agentic/protocol (S0, the single source of truth).
20
24
  // The codec, routing-token grammar, vocab schema and per-family payload
@@ -80,14 +84,36 @@ export * as transcript from '@nanobpm/agentic/transcript';
80
84
  // (ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING), so a *static* re-export of the
81
85
  // client would make this whole surface fail to load. To keep C0's surface
82
86
  // loadable everywhere while still routing all client consumption through one
83
- // swap point, the client is exposed behind a lazy async loader. The slice that
84
- // actually opens the channel (C2, #41) awaits it from the code path that runs
85
- // under the appropriate loader/build.
87
+ // swap point, the client is exposed behind a lazy async loader.
88
+ //
89
+ // C2 (#41) resolves that constraint HERE, at the single swap point: before the
90
+ // client's module graph loads, `loadAgenticClient()` registers a resolve hook
91
+ // (`agentic-loader-hook.mjs`) that redirects the client's raw-`.ts`
92
+ // `@nanobpm/agentic/source/*` imports to the compiled `@nanobpm/agentic/*`
93
+ // `dist` exports. Source and dist are the same S0 contract (one shared
94
+ // conformance corpus), so the client loads and runs under stock Node with no
95
+ // change in behaviour. When the client is republished to import agentic's dist
96
+ // directly, the redirect self-neutralises.
86
97
  //
87
98
  // @typedef {import('@nanobpm/urban-agent-client')} AgenticClientModule
88
99
  /** @type {Promise<AgenticClientModule> | undefined} */
89
100
  let clientModulePromise;
90
101
 
102
+ // Guards single registration of the source→dist resolve hook (idempotent).
103
+ let sourceRedirectRegistered = false;
104
+
105
+ /**
106
+ * Register the `@nanobpm/agentic/source/*` → `dist` resolve hook exactly once,
107
+ * so the worker client is importable under stock Node. Safe to call repeatedly;
108
+ * only the first call registers. Runs before any `import()` of the client so the
109
+ * hook is active for the client's whole module graph.
110
+ */
111
+ function ensureClientLoadable() {
112
+ if (sourceRedirectRegistered) return;
113
+ sourceRedirectRegistered = true;
114
+ moduleRegister('./agentic-loader-hook.mjs', import.meta.url);
115
+ }
116
+
91
117
  /**
92
118
  * Load the published worker-side agentic channel client
93
119
  * (`@nanobpm/urban-agent-client`). Memoised so repeated calls share one module
@@ -100,6 +126,7 @@ let clientModulePromise;
100
126
  */
101
127
  export function loadAgenticClient() {
102
128
  if (clientModulePromise === undefined) {
129
+ ensureClientLoadable();
103
130
  clientModulePromise = import('@nanobpm/urban-agent-client');
104
131
  }
105
132
  return clientModulePromise;
package/c8ctl-plugin.js CHANGED
@@ -57,6 +57,7 @@ import { fileURLToPath } from 'node:url';
57
57
  import { createInterface } from 'node:readline/promises';
58
58
  import { createInterface as createReadline } from 'node:readline';
59
59
  import { platformForHost } from './platforms.mjs';
60
+ import { createWorkChannel, redactAgenticUrl, buildAgenticUrl } from './work-channel.mjs';
60
61
 
61
62
  const requireFromHere = createRequire(import.meta.url);
62
63
  const pluginDir = dirname(fileURLToPath(import.meta.url));
@@ -3236,6 +3237,32 @@ function buildResultEnvelope(result, { sandbox, image, git, result: agentResult
3236
3237
  return env;
3237
3238
  }
3238
3239
 
3240
+ /**
3241
+ * Resolve the agentic-visibility channel connection target + credentials for a
3242
+ * worker (ADR 0056 — slice C2). The channel is served same-port on the app's own
3243
+ * HTTP base URL at `/agentic`; the identity token + capability credential follow
3244
+ * the blackboard's `?token=…` pattern.
3245
+ *
3246
+ * Env wins over persisted config; the base URL falls back to the configured nano
3247
+ * URL (the app's own port). A worker only connects when BOTH an identity token
3248
+ * and a capability credential are present (enrolment) — absent either, it runs
3249
+ * exactly as before, off the visibility page. Returns `null` when not enrolled.
3250
+ *
3251
+ * @returns {{ url: string, token: string, credential: string } | null}
3252
+ */
3253
+ function resolveAgenticConfig() {
3254
+ const cfg = readConfig();
3255
+ const url = process.env.NANO_AGENTIC_URL
3256
+ || cfg.agenticUrl
3257
+ || cfg.nanoUrl
3258
+ || process.env.NANO_BASE_URL
3259
+ || DEFAULT_NANO_URL;
3260
+ const token = process.env.NANO_AGENTIC_TOKEN || cfg.agenticToken || '';
3261
+ const credential = process.env.NANO_AGENTIC_CREDENTIAL || cfg.agenticCredential || '';
3262
+ if (!url || !token || !credential) return null;
3263
+ return { url, token, credential };
3264
+ }
3265
+
3239
3266
  /**
3240
3267
  * work — turn a hire profile into live Nano job workers (one per job-type in
3241
3268
  * the rank×capability matrix) and poll for work in the foreground until Ctrl-C.
@@ -3480,19 +3507,66 @@ async function workAgent(req, flags) {
3480
3507
  /* best effort — activity is advisory, never fail a job over it */
3481
3508
  }
3482
3509
  };
3510
+ // The agentic-visibility channel, wired below. Declared here so the job
3511
+ // recorders can refresh presence with the live job set as jobs start/end.
3512
+ /** @type {import('./work-channel.mjs').WorkChannel | null} */
3513
+ let workChannel = null;
3514
+ // Maintain `activeJobs` unconditionally: it feeds both the supervisor activity
3515
+ // file (gated inside writeActivity) AND the agentic presence frame's live
3516
+ // jobKey set, so a standalone worker (no NANO_SUPERVISOR_ACTIVITY_FILE) still
3517
+ // reports its current jobs on the visibility page.
3483
3518
  const recordJobStart = (job, jobType) => {
3484
- if (!activityFile) return;
3485
3519
  activeJobs.set(String(job.jobKey), { type: jobType, since: Date.now() });
3486
3520
  writeActivity();
3521
+ workChannel?.refreshPresence();
3487
3522
  };
3488
3523
  const recordJobEnd = (job) => {
3489
- if (!activityFile) return;
3490
3524
  activeJobs.delete(String(job.jobKey));
3491
3525
  writeActivity();
3526
+ workChannel?.refreshPresence();
3492
3527
  };
3493
3528
  // Seed an initial idle marker so status reports 'idle' immediately after spawn.
3494
3529
  writeActivity();
3495
3530
 
3531
+ // ---- Agentic visibility channel (ADR 0056 — slice C2, #41) ----------------
3532
+ // Connect this worker to the app's same-port `/agentic` channel and announce
3533
+ // presence (identity, host, live jobs), heartbeat, and deregister on exit, so
3534
+ // it appears live on the Workforce visibility page. This is the SINGLE place
3535
+ // the connected+authenticated channel client is instantiated in `work`: the
3536
+ // sibling slices C3 (PTY relay, #42) and C4 (buffer, #43) attach to the
3537
+ // accessors on `workChannel` (relay-lane sink + connect/disconnect/reconnect
3538
+ // lifecycle events) rather than opening their own connection.
3539
+ //
3540
+ // Enrolment is opt-in: without an identity token + capability credential the
3541
+ // worker runs exactly as before, off the channel (see resolveAgenticConfig).
3542
+ const agenticCfg = resolveAgenticConfig();
3543
+ if (agenticCfg) {
3544
+ try {
3545
+ workChannel = await createWorkChannel({
3546
+ instance: workerName,
3547
+ host: hostname(),
3548
+ capability: {
3549
+ cognition: profile.rank,
3550
+ family: profile.model || undefined,
3551
+ host: hostname(),
3552
+ },
3553
+ listJobKeys: () => [...activeJobs.keys()],
3554
+ url: agenticCfg.url,
3555
+ token: agenticCfg.token,
3556
+ credential: agenticCfg.credential,
3557
+ logger,
3558
+ });
3559
+ const shown = redactAgenticUrl(buildAgenticUrl(agenticCfg.url, {}));
3560
+ logger.info(` agentic channel: announcing presence as ${workerName} on ${shown}`);
3561
+ } catch (err) {
3562
+ // Never let a channel failure stop the worker from doing its actual job.
3563
+ workChannel = null;
3564
+ logger.warn(` agentic channel unavailable (${err?.message || err}); continuing without visibility.`);
3565
+ }
3566
+ } else {
3567
+ logger.info(' agentic channel: not enrolled (set NANO_AGENTIC_URL + NANO_AGENTIC_TOKEN + NANO_AGENTIC_CREDENTIAL to appear on the visibility page).');
3568
+ }
3569
+
3496
3570
  // A per-job-type worker factory. Captures all the CLI-local + profile context
3497
3571
  // in closure scope so the profile watcher below can (re)spawn a poller for any
3498
3572
  // job type on demand without re-reading the flags.
@@ -3865,6 +3939,17 @@ async function workAgent(req, flags) {
3865
3939
  } else {
3866
3940
  logger.info('All workers stopped.');
3867
3941
  }
3942
+ // Deregister from the visibility channel LAST, so the worker disappears
3943
+ // from the page only once its jobs have drained. Best-effort — a channel
3944
+ // teardown must never hang shutdown.
3945
+ if (workChannel) {
3946
+ try {
3947
+ await workChannel.stop(`worker stopped (${signal})`);
3948
+ logger.info('Deregistered from the agentic visibility channel.');
3949
+ } catch (err) {
3950
+ logger.warn(`agentic channel deregister failed: ${err?.message || err}`);
3951
+ }
3952
+ }
3868
3953
  resolve();
3869
3954
  };
3870
3955
  process.once('SIGINT', () => { stop('SIGINT'); });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "c8ctl-plugin-nano",
3
- "version": "1.27.0",
3
+ "version": "1.28.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",
@@ -23,6 +23,8 @@
23
23
  "c8ctl-plugin.js",
24
24
  "platforms.mjs",
25
25
  "agentic.mjs",
26
+ "agentic-loader-hook.mjs",
27
+ "work-channel.mjs",
26
28
  "nanobpmn-binary.json",
27
29
  "README.md"
28
30
  ],
@@ -52,12 +54,12 @@
52
54
  "@nanobpm/urban-agent-client": "^0.1.0"
53
55
  },
54
56
  "optionalDependencies": {
55
- "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.27.0",
56
- "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.27.0",
57
- "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.27.0",
58
- "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.27.0",
59
- "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.27.0",
60
- "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.27.0",
61
- "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.27.0"
57
+ "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.28.0",
58
+ "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.28.0",
59
+ "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.28.0",
60
+ "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.28.0",
61
+ "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.28.0",
62
+ "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.28.0",
63
+ "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.28.0"
62
64
  }
63
65
  }
@@ -0,0 +1,290 @@
1
+ // The `work` command's agentic-visibility channel seam (ADR 0056 — slice C2,
2
+ // jwulf/c8ctl-plugin-nano#41).
3
+ //
4
+ // This module owns the SINGLE connected + authenticated channel client a
5
+ // running worker (`c8ctl nano work <profile>`) uses to appear on the Workforce
6
+ // visibility page. It is the wave-1 scaffold the sibling slices build on:
7
+ //
8
+ // - C3 (#42, PTY relay) publishes framed terminal output through the
9
+ // relay-lane sink exposed by {@link WorkChannel.relayLane}.
10
+ // - C4 (#43, buffer) subscribes to the connect / disconnect / reconnect
11
+ // lifecycle events exposed by {@link WorkChannel.onConnect} /
12
+ // {@link WorkChannel.onDisconnect} / {@link WorkChannel.onReconnect} to
13
+ // drive its buffer flush at the transport seam.
14
+ //
15
+ // Both siblings EXTEND this holder; neither opens, authenticates, or
16
+ // re-instantiates the channel. The connected client is created in exactly one
17
+ // place — {@link createWorkChannel} — alongside the worker's existing
18
+ // `camunda.createClient()` wiring.
19
+ //
20
+ // Everything on the wire (frame codec, lanes, presence payloads) is CONSUMED
21
+ // through this plugin's single import surface (`./agentic.mjs`), which in turn
22
+ // consumes `@nanobpm/agentic` + `@nanobpm/urban-agent-client`. Nothing is
23
+ // re-declared here.
24
+ //
25
+ // SCOPE (C2): presence + the shared seam only. Capability→SERVE-token
26
+ // resolution (REGISTER/SERVE enrolment) is the separate epic #58 and is NOT
27
+ // done here — the worker announces presence (identity, host, live jobs),
28
+ // heartbeats, and deregisters, and the SERVE handshake is deliberately left
29
+ // disabled (`serveTimeoutMs: 0`, the announce is fire-and-forget).
30
+
31
+ import { loadAgenticClient } from './agentic.mjs';
32
+
33
+ const DEFAULT_HEARTBEAT_MS = 10_000;
34
+ // Outbound ring size (frames) the client buffers while the hub is unreachable.
35
+ // C4 (#43) tunes/uses this seam; a sensible default keeps a worker that starts
36
+ // before the app from losing its early presence/relay frames.
37
+ const DEFAULT_BUFFER_CAPACITY = 1024;
38
+
39
+ /**
40
+ * Build the worker's agentic-channel WebSocket URL from the app's HTTP base URL
41
+ * plus the ADR 0028 identity token and capability credential, carried as query
42
+ * params — the same `?token=…` pattern the blackboard hook uses (and exactly
43
+ * what `sharedSecretAuthenticator` reads on the hub side). `http`→`ws`,
44
+ * `https`→`wss`; the channel is served same-port at `/agentic`.
45
+ *
46
+ * @param {string} baseUrl the app's own HTTP(S) base URL, e.g. `http://localhost:8080`
47
+ * @param {{ token: string, credential: string, path?: string }} auth
48
+ * @returns {string} the `ws(s)://…/agentic?token=…&capability=…` URL
49
+ */
50
+ export function buildAgenticUrl(baseUrl, { token, credential, path = '/agentic' } = {}) {
51
+ if (typeof baseUrl !== 'string' || baseUrl.trim() === '') {
52
+ throw new Error('buildAgenticUrl requires a non-empty base URL');
53
+ }
54
+ const u = new URL(baseUrl);
55
+ if (u.protocol === 'http:') u.protocol = 'ws:';
56
+ else if (u.protocol === 'https:') u.protocol = 'wss:';
57
+ else if (u.protocol !== 'ws:' && u.protocol !== 'wss:') {
58
+ throw new Error(`Unsupported agentic base URL protocol "${u.protocol}" (expected http/https/ws/wss)`);
59
+ }
60
+ // Preserve any base path, then append the same-port channel path.
61
+ const basePath = u.pathname.replace(/\/+$/, '');
62
+ u.pathname = `${basePath}${path}`;
63
+ if (token !== undefined && token !== null && token !== '') u.searchParams.set('token', String(token));
64
+ if (credential !== undefined && credential !== null && credential !== '') {
65
+ u.searchParams.set('capability', String(credential));
66
+ }
67
+ return u.toString();
68
+ }
69
+
70
+ /**
71
+ * Redact the token/capability query params from a channel URL for logging.
72
+ * @param {string} url
73
+ * @returns {string}
74
+ */
75
+ export function redactAgenticUrl(url) {
76
+ try {
77
+ const u = new URL(url);
78
+ if (u.searchParams.has('token')) u.searchParams.set('token', '***');
79
+ if (u.searchParams.has('capability')) u.searchParams.set('capability', '***');
80
+ return u.toString();
81
+ } catch {
82
+ return url;
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Normalise a capability object for the `register` presence frame. Drops
88
+ * undefined/empty attributes so the enrolment attribute stays minimal, and
89
+ * carries the worker's live `jobs` (jobKeys) as a forward-compatible nested
90
+ * field — the S0 register validator only requires `capability` to be an object
91
+ * and ignores extra fields ("a later slice may enrich a payload without
92
+ * breaking older peers"), so the visibility page can surface `capability.jobs`
93
+ * without any wire-contract change.
94
+ *
95
+ * @param {{ cognition?: string, weight?: number, family?: string, host?: string }} capability
96
+ * @param {readonly string[]} jobs
97
+ * @returns {object}
98
+ */
99
+ function presenceCapability(capability, jobs) {
100
+ const out = {};
101
+ if (capability) {
102
+ if (typeof capability.cognition === 'string' && capability.cognition !== '') out.cognition = capability.cognition;
103
+ if (typeof capability.weight === 'number' && Number.isFinite(capability.weight)) out.weight = capability.weight;
104
+ if (typeof capability.family === 'string' && capability.family !== '') out.family = capability.family;
105
+ if (typeof capability.host === 'string' && capability.host !== '') out.host = capability.host;
106
+ }
107
+ out.jobs = Array.isArray(jobs) ? jobs.map(String) : [];
108
+ return out;
109
+ }
110
+
111
+ /**
112
+ * @typedef {object} WorkChannel
113
+ * @property {import('@nanobpm/urban-agent-client').AgenticClient} client the one connected channel client
114
+ * @property {() => void} refreshPresence re-announce presence (call when the live job set changes)
115
+ * @property {() => { relay: (stream: string, chunk: string) => void }} relayLane C3's relay-lane sink accessor
116
+ * @property {(fn: () => void) => () => void} onConnect subscribe to the first successful connect
117
+ * @property {(fn: (info: object) => void) => () => void} onDisconnect subscribe to channel close
118
+ * @property {(fn: () => void) => () => void} onReconnect subscribe to reconnects (every open after the first)
119
+ * @property {() => boolean} connected whether the channel is currently open
120
+ * @property {() => number} buffered outbound frames currently buffered awaiting the channel
121
+ * @property {(reason?: string) => Promise<void>} stop deregister + close cleanly
122
+ */
123
+
124
+ /**
125
+ * Create the worker's single connected + authenticated agentic channel client
126
+ * and announce presence. The connection begins immediately; because the client
127
+ * buffers outbound frames, presence is announced (and relay is usable) even
128
+ * before the socket is open — it drains on connect.
129
+ *
130
+ * This is the ONLY place the channel client is instantiated in `work`. Sibling
131
+ * slices consume the accessors on the returned holder; they do not connect.
132
+ *
133
+ * @param {object} opts
134
+ * @param {string} opts.instance stable worker instance id (the worker name) carried on every presence frame
135
+ * @param {string} opts.host the worker's host label
136
+ * @param {{ cognition?: string, weight?: number, family?: string, host?: string }} [opts.capability] declared enrolment capability
137
+ * @param {() => readonly string[]} [opts.listJobKeys] reads the live jobKey set from the worker's activeJobs map
138
+ * @param {string} opts.url the app's HTTP(S) base URL (the channel is served same-port at `/agentic`)
139
+ * @param {string} opts.token ADR 0028 identity token
140
+ * @param {string} opts.credential capability credential
141
+ * @param {number} [opts.heartbeatIntervalMs] presence heartbeat cadence (ms)
142
+ * @param {number} [opts.bufferCapacity] outbound ring size in frames
143
+ * @param {import('@nanobpm/urban-agent-client').TransportFactory} [opts.transport] injectable transport (tests)
144
+ * @param {import('@nanobpm/urban-agent-client').ReconnectOptions} [opts.reconnect] reconnect/backoff policy passthrough
145
+ * @param {(fn: () => void, ms: number) => void} [opts.schedule] injectable backoff scheduler (tests)
146
+ * @param {{ info?: Function, warn?: Function, debug?: Function }} [opts.logger] optional logger
147
+ * @returns {Promise<WorkChannel>}
148
+ */
149
+ export async function createWorkChannel(opts) {
150
+ const {
151
+ instance,
152
+ host,
153
+ capability,
154
+ listJobKeys = () => [],
155
+ url,
156
+ token,
157
+ credential,
158
+ heartbeatIntervalMs = DEFAULT_HEARTBEAT_MS,
159
+ bufferCapacity = DEFAULT_BUFFER_CAPACITY,
160
+ transport,
161
+ reconnect,
162
+ schedule,
163
+ logger,
164
+ } = opts || {};
165
+
166
+ if (typeof instance !== 'string' || instance.trim() === '') {
167
+ throw new Error('createWorkChannel requires a non-empty instance id');
168
+ }
169
+ if (typeof url !== 'string' || url.trim() === '') {
170
+ throw new Error('createWorkChannel requires an agentic channel base url');
171
+ }
172
+
173
+ const channelUrl = buildAgenticUrl(url, { token, credential });
174
+ const declaredCapability = { ...(capability || {}) };
175
+ if (typeof host === 'string' && host !== '' && !declaredCapability.host) {
176
+ declaredCapability.host = host;
177
+ }
178
+
179
+ const { connectAgenticChannel } = await loadAgenticClient();
180
+
181
+ // The single connected client. serveTimeoutMs:0 disables the SERVE handshake
182
+ // wait — SERVE-token resolution is the enrolment epic (#58), out of scope for
183
+ // C2; we only need presence to land, which the REGISTER frame does on its own.
184
+ const client = connectAgenticChannel({
185
+ url: channelUrl,
186
+ instance,
187
+ heartbeatIntervalMs,
188
+ serveTimeoutMs: 0,
189
+ bufferCapacity,
190
+ ...(transport ? { transport } : {}),
191
+ ...(reconnect ? { reconnect } : {}),
192
+ ...(schedule ? { schedule } : {}),
193
+ });
194
+
195
+ const log = logger || {};
196
+
197
+ /** (Re)announce presence with the current live job set. Fire-and-forget: the
198
+ * REGISTER frame is what makes the worker appear; the returned promise only
199
+ * resolves on a SERVE (disabled here), so we never await it and swallow its
200
+ * rejection so a missing SERVE is not an unhandled rejection. */
201
+ const refreshPresence = () => {
202
+ let jobs = [];
203
+ try {
204
+ jobs = listJobKeys() || [];
205
+ } catch {
206
+ jobs = [];
207
+ }
208
+ const cap = presenceCapability(declaredCapability, jobs);
209
+ // register() enqueues the frame even while the channel is down (it drains on
210
+ // connect); catch guards the deliberately-never-resolving SERVE promise.
211
+ Promise.resolve(client.register({ capability: cap })).catch(() => {});
212
+ };
213
+
214
+ // Lifecycle fan-out: the client fires onOpen on the first connect AND on every
215
+ // reconnect. Split that into a one-shot "connect" and a repeated "reconnect"
216
+ // so C4 can distinguish the initial attach from a recovery flush.
217
+ let hasConnected = false;
218
+ const connectListeners = new Set();
219
+ const reconnectListeners = new Set();
220
+ const disconnectListeners = new Set();
221
+ const fan = (set, arg) => {
222
+ for (const fn of set) {
223
+ try {
224
+ fn(arg);
225
+ } catch (err) {
226
+ try {
227
+ log.warn?.(`work-channel listener threw: ${err?.message || err}`);
228
+ } catch {
229
+ /* never let a listener failure escape the lifecycle dispatch */
230
+ }
231
+ }
232
+ }
233
+ };
234
+
235
+ client.onOpen(() => {
236
+ if (!hasConnected) {
237
+ hasConnected = true;
238
+ fan(connectListeners);
239
+ // The presence announce buffered before connect drains on this first open,
240
+ // so no re-announce is needed here — avoid a redundant duplicate register.
241
+ } else {
242
+ fan(reconnectListeners);
243
+ // Re-announce presence on RECONNECT so the durable presence row reflects
244
+ // this worker's current identity/host/jobs after a hub restart/outage.
245
+ refreshPresence();
246
+ }
247
+ });
248
+ client.onClose((info) => {
249
+ fan(disconnectListeners, info);
250
+ });
251
+
252
+ // Announce presence immediately; the frame buffers and drains on connect.
253
+ refreshPresence();
254
+
255
+ const subscribe = (set) => (fn) => {
256
+ if (typeof fn !== 'function') return () => {};
257
+ set.add(fn);
258
+ return () => set.delete(fn);
259
+ };
260
+
261
+ /** @type {WorkChannel} */
262
+ const channel = {
263
+ client,
264
+ refreshPresence,
265
+ // C3 (#42): the relay-lane sink. Delegates to the one connected client so
266
+ // relay frames ride the shared, buffered, QoS-ordered outbound path.
267
+ relayLane: () => ({
268
+ relay: (stream, chunk) => client.relay(stream, chunk),
269
+ }),
270
+ onConnect: subscribe(connectListeners),
271
+ onReconnect: subscribe(reconnectListeners),
272
+ onDisconnect: subscribe(disconnectListeners),
273
+ connected: () => client.connected,
274
+ buffered: () => client.buffered,
275
+ async stop(reason = 'worker stopped') {
276
+ try {
277
+ client.deregister(reason);
278
+ } catch (err) {
279
+ try {
280
+ log.warn?.(`agentic deregister failed: ${err?.message || err}`);
281
+ client.close();
282
+ } catch {
283
+ /* best effort — never let shutdown hang on the channel */
284
+ }
285
+ }
286
+ },
287
+ };
288
+
289
+ return channel;
290
+ }