c8ctl-plugin-nano 1.36.1 → 1.37.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.
package/README.md CHANGED
@@ -292,23 +292,28 @@ c8ctl nano work reviewer
292
292
 
293
293
  **Zero-config hub auto-discovery.** You usually don't even set `NANO_AGENTIC_URL`.
294
294
  When nwf runs **embedded**, the engine (`:8080`) serves the console but the
295
- `/agentic` channel is served by the **embedded app on its own loopback port**
296
- (e.g. `:3000`); the engine's console proxy deliberately refuses WebSocket
297
- upgrades (nanobpmn ADR 0057 §3 → `501`), so the channel is unreachable via the
298
- engine URL. With **no** agentic target configured, `work` therefore
299
- **auto-discovers** it: it reads `GET <engine>/console/api/projects` and, for each
300
- running app that advertises an agentic UI port (`appUi.enabled === true` and
301
- `appUi.port`), probes that app's direct `ws://127.0.0.1:<port>/agentic`. Discovery
302
- is **loopback-only** and **time-bounded** (≤2s) and never meaningfully delays job
303
- polling.
295
+ `/agentic` channel is served by the **embedded app on its own port** (e.g.
296
+ `:3000`); the engine's console proxy deliberately refuses WebSocket upgrades
297
+ (nanobpmn ADR 0057 §3 → `501`), so the channel is unreachable via the engine URL.
298
+ With **no** agentic target configured, `work` therefore **auto-discovers** it: it
299
+ reads `GET <engine>/console/api/projects` and, for each running app that
300
+ advertises an agentic UI port (`appUi.enabled === true` and `appUi.port`), probes
301
+ that app's direct `ws://<engine-host>:<port>/agentic`. Discovery runs **against
302
+ the engine's own host** a local engine keeps probing `127.0.0.1`, while a
303
+ remote/LAN engine (e.g. `merlin.local:8080`) steers the probe back at *itself*
304
+ (`merlin.local:<port>`), never at the worker's own loopback services. It is
305
+ **time-bounded** (≤2s) and never meaningfully delays job polling. The discovered
306
+ host and port are printed for debugging. (An IPv6 literal engine host is bracketed
307
+ in the URL authority, e.g. `ws://[2001:db8::1]:3000/agentic`.)
304
308
 
305
309
  - **Exactly one app →** the worker connects directly to
306
- `ws://127.0.0.1:<appUi.port>/agentic` (bypassing the WS-incapable console proxy)
307
- and appears live with **zero configuration**.
310
+ `ws://<engine-host>:<appUi.port>/agentic` (bypassing the WS-incapable console
311
+ proxy) and appears live with **zero configuration** — including cross-machine on
312
+ a trusted LAN.
308
313
  - **Two or more apps →** the worker **does not guess**: it prints an `ambiguous`
309
314
  error naming each discovered `project → :port` and **stops**. Pin the one you
310
- want and re-run: `export NANO_AGENTIC_URL=http://127.0.0.1:<port>` (or persist
311
- `agenticUrl`).
315
+ want and re-run: `export NANO_AGENTIC_URL=http://<engine-host>:<port>` (or
316
+ persist `agenticUrl`).
312
317
  - **Nothing discoverable (e.g. pointed at Camunda, or an API-only gateway) →** the
313
318
  worker prints a one-line advisory naming `NANO_AGENTIC_URL` and **continues
314
319
  doing real work** with the channel simply absent — discovery never fails the
@@ -319,6 +324,27 @@ used **verbatim** — so an explicit target always wins, and it's also how you
319
324
  disambiguate when several apps are running. `NANO_AGENTIC=off` disables the
320
325
  channel entirely and attempts **no** discovery.
321
326
 
327
+ **Seeing it work — `supervisor status`.** Supervised workers report both the
328
+ engine they poll and their agentic-channel state to `c8ctl nano supervisor
329
+ status` (and the interactive console), so you don't have to read raw worker logs
330
+ to tell whether presence actually reached the Workforce hub. The table gains an
331
+ `ENGINE` column (the engine's `host:port`) and an `AGENTIC` column whose value is
332
+ one of:
333
+
334
+ | AGENTIC | meaning |
335
+ | --- | --- |
336
+ | `starting` | transient: the worker just spawned and hasn't resolved its channel target yet (pre-`connecting`) |
337
+ | `connected` | presence is live on the hub — you should see this worker in the Cockpit |
338
+ | `connecting` | resolved a hub, socket not open yet (or the hub is unreachable) |
339
+ | `disconnected` | an established channel dropped (hub restart/outage) — it auto-reconnects; also set if the channel failed to start (bad URL/refused socket), in which case it stays disconnected until the worker restarts |
340
+ | `advisory` | nothing discoverable at the engine — **not** in the Cockpit; set `NANO_AGENTIC_URL` |
341
+ | `off` | visibility disabled (`NANO_AGENTIC=off`) |
342
+ | `?` | a live worker not yet reporting, or an older build predating these fields |
343
+
344
+ If workers show `advisory` (or stay `connecting`) while jobs still run, that's the
345
+ "connected to the engine but empty Cockpit" case: point them at the app with
346
+ `export NANO_AGENTIC_URL=http://<engine-host>:<appUi.port>` (e.g. `:3000`).
347
+
322
348
  **Secure mode (opt-in).** For a deployment where you want the visibility channel
323
349
  authenticated (rather than open on the LAN), start the server **and** every worker
324
350
  box with the **same** `NANO_AGENTIC_SECRET` — same env-var name, same value on both
package/c8ctl-plugin.js CHANGED
@@ -4170,6 +4170,21 @@ function isLoopbackHost(hostname) {
4170
4170
  return /^127(?:\.\d{1,3}){3}$/.test(h);
4171
4171
  }
4172
4172
 
4173
+ /**
4174
+ * Format a hostname for the authority component of a `ws://`/`http://` URL:
4175
+ * a bare IPv6 literal (contains `:`, not already bracketed) is wrapped in `[…]`,
4176
+ * everything else is used verbatim. Idempotent — an already-bracketed host is
4177
+ * left as-is. Guards against building an invalid `ws://::1:3000/…` when a raw or
4178
+ * normalized IPv6 host (e.g. the `::1` constant) has not been bracketed.
4179
+ *
4180
+ * @param {string} host a hostname from `URL.hostname` or a normalized loopback
4181
+ * @returns {string}
4182
+ */
4183
+ function wsHostPart(host) {
4184
+ const h = String(host || '');
4185
+ return h.includes(':') && !h.startsWith('[') ? `[${h}]` : h;
4186
+ }
4187
+
4173
4188
  /**
4174
4189
  * Normalise the engine's `GET /console/api/projects` payload into the running
4175
4190
  * embedded apps that advertise an agentic UI port. Accepts the shapes the
@@ -4207,24 +4222,26 @@ function normalizeProjectApps(projects) {
4207
4222
  }
4208
4223
 
4209
4224
  /**
4210
- * Probe whether an embedded app's own loopback `/agentic` endpoint answers a
4211
- * WebSocket upgrade. Connects to `ws://127.0.0.1:<port>/agentic?token=…` and
4225
+ * Probe whether an embedded app's `/agentic` endpoint answers a WebSocket
4226
+ * upgrade. Connects to `ws://<host>:<port>/agentic?token=…` (host defaults to
4227
+ * `127.0.0.1`; a bare IPv6 literal is bracketed for the URL authority) and
4212
4228
  * resolves `true` only if the socket opens within `timeoutMs`; a refused
4213
4229
  * connection, the console proxy's deliberate `501`, a `404`, or a timeout all
4214
- * resolve `false`. Loopback-only and self-cleaning — the probe socket is closed
4215
- * as soon as the outcome is known. Never throws.
4230
+ * resolve `false`. Self-cleaning — the probe socket is closed as soon as the
4231
+ * outcome is known. Never throws.
4216
4232
  *
4217
- * @param {number} port the app's direct loopback port (`appUi.port`)
4218
- * @param {{ token?: string, WebSocketImpl?: Function, timeoutMs?: number }} [opts]
4233
+ * @param {number} port the app's direct agentic port (`appUi.port`)
4234
+ * @param {{ host?: string, token?: string, WebSocketImpl?: Function, timeoutMs?: number }} [opts]
4219
4235
  * @returns {Promise<boolean>}
4220
4236
  */
4221
4237
  function probeAgenticChannel(port, {
4238
+ host = '127.0.0.1',
4222
4239
  token = LOCAL_AGENTIC_TOKEN,
4223
4240
  WebSocketImpl = globalThis.WebSocket,
4224
4241
  timeoutMs = AGENTIC_DISCOVERY_TIMEOUT_MS,
4225
4242
  } = {}) {
4226
4243
  if (typeof WebSocketImpl !== 'function') return Promise.resolve(false);
4227
- const url = `ws://127.0.0.1:${port}/agentic?token=${encodeURIComponent(token)}`;
4244
+ const url = `ws://${wsHostPart(host)}:${port}/agentic?token=${encodeURIComponent(token)}`;
4228
4245
  return new Promise((resolve) => {
4229
4246
  let done = false;
4230
4247
  let ws;
@@ -4249,19 +4266,21 @@ function probeAgenticChannel(port, {
4249
4266
 
4250
4267
  /**
4251
4268
  * Auto-discover the embedded nwf agentic hub(s) reachable from an engine base
4252
- * URL (#75). Reads `GET <engine>/console/api/projects`, keeps the apps that
4253
- * advertise an agentic UI port, and WS-probes each app's direct loopback
4254
- * `/agentic` to confirm the channel is actually served there (bypassing the
4255
- * WS-incapable console proxy). Loopback-only (the engine host itself must be
4256
- * loopback, since the response steers a local port probe), enforces a single
4257
- * shared time budget across the fetch + probes, and is fail-open: any error
4258
- * not a nano engine (Camunda), a non-loopback engine, network failure,
4259
- * malformed body, or an overall timeout degrades to `[]` so the worker's real
4260
- * job is never blocked.
4269
+ * URL (#75, #96). Reads `GET <engine>/console/api/projects`, keeps the apps that
4270
+ * advertise an agentic UI port, and WS-probes each app's `/agentic` **on the
4271
+ * engine's own host** to confirm the channel is actually served there (bypassing
4272
+ * the WS-incapable console proxy). Works cross-machine on a trusted LAN: a
4273
+ * loopback engine probes `127.0.0.1`, a remote engine (e.g. `merlin.local`)
4274
+ * probes that same host the port is taken from the projects API but the host is
4275
+ * always the engine's, so a rogue projects API can never steer a probe at the
4276
+ * worker's own loopback (#76). Enforces a single shared time budget across the
4277
+ * fetch + probes, and is fail-open: any error — not a nano engine (Camunda),
4278
+ * network failure, malformed body, or an overall timeout — degrades to `[]` so
4279
+ * the worker's real job is never blocked.
4261
4280
  *
4262
- * @param {string} engineBaseUrl the engine base URL (e.g. `http://localhost:8080`)
4281
+ * @param {string} engineBaseUrl the engine base URL (e.g. `http://merlin.local:8080`)
4263
4282
  * @param {{ token?: string, fetchImpl?: Function, wsProbe?: Function, timeoutMs?: number }} [opts]
4264
- * @returns {Promise<Array<{ project: string, port: number, label?: string }>>}
4283
+ * @returns {Promise<Array<{ project: string, port: number, label?: string, host: string }>>}
4265
4284
  */
4266
4285
  async function discoverAgenticHubs(engineBaseUrl, {
4267
4286
  token = LOCAL_AGENTIC_TOKEN,
@@ -4273,16 +4292,21 @@ async function discoverAgenticHubs(engineBaseUrl, {
4273
4292
  return [];
4274
4293
  }
4275
4294
  const base = engineBaseUrl.replace(/\/+$/, '');
4276
- // Loopback-only: discovery probes 127.0.0.1:<port> using a port advertised by
4277
- // the engine's projects API, so a non-loopback (remote) engine could steer a
4278
- // local port probe. Refuse discovery unless the engine host is loopback (#76).
4295
+ // Discover against the ENGINE's own host the app is embedded in the engine,
4296
+ // so its /agentic port lives on the same host the worker already trusts as its
4297
+ // engine (that's where it pulls jobs from). A loopback engine keeps probing
4298
+ // 127.0.0.1 (unchanged local behaviour); a remote/LAN engine (e.g.
4299
+ // merlin.local) steers the probe back to ITSELF, never at the worker's own
4300
+ // loopback services — which was the actual #76 concern (a rogue projects API
4301
+ // making the worker probe its own localhost). So the port comes from the
4302
+ // engine's projects API, but the HOST is always the engine's, never guessed.
4279
4303
  let host;
4280
4304
  try {
4281
4305
  host = new URL(base).hostname;
4282
4306
  } catch {
4283
4307
  return [];
4284
4308
  }
4285
- if (!isLoopbackHost(host)) return [];
4309
+ const probeHost = isLoopbackHost(host) ? '127.0.0.1' : host;
4286
4310
  // Single discovery budget: the projects fetch and the WS probes share ONE
4287
4311
  // deadline, so total discovery can't approach 2× timeoutMs (the fetch could
4288
4312
  // consume ~timeoutMs and then each probe was previously given a fresh full
@@ -4304,10 +4328,13 @@ async function discoverAgenticHubs(engineBaseUrl, {
4304
4328
  if (apps.length === 0) return [];
4305
4329
  const remainingMs = deadline - Date.now();
4306
4330
  if (remainingMs <= 0) return [];
4307
- // Probe candidate ports concurrently within the remaining shared budget.
4331
+ // Probe candidate ports concurrently within the remaining shared budget. Each
4332
+ // surviving hub carries the engine host so the caller builds the right URL.
4308
4333
  const settled = await Promise.all(apps.map(async (app) => {
4309
4334
  try {
4310
- return (await wsProbe(app.port, { token, timeoutMs: remainingMs })) ? app : null;
4335
+ return (await wsProbe(app.port, { host: probeHost, token, timeoutMs: remainingMs }))
4336
+ ? { ...app, host: probeHost }
4337
+ : null;
4311
4338
  } catch {
4312
4339
  return null;
4313
4340
  }
@@ -4324,7 +4351,8 @@ async function discoverAgenticHubs(engineBaseUrl, {
4324
4351
  * half-configured. No discovery attempted.
4325
4352
  * - `{ status: 'connect', config }` — a target to connect to. Either the
4326
4353
  * explicit `NANO_AGENTIC_URL`/`agenticUrl` verbatim (no discovery), or the
4327
- * single discovered app's direct `ws://127.0.0.1:<port>/agentic` loopback.
4354
+ * single discovered app's `ws://<engineHost>:<port>/agentic` (loopback for a
4355
+ * local engine, the engine's LAN host for a remote one).
4328
4356
  * - `{ status: 'ambiguous', message, candidates }` — two+ apps expose a
4329
4357
  * channel. Hard stop for the worker: it must not silently pick one.
4330
4358
  * - `{ status: 'advisory', message }` — nothing discoverable (zero matches,
@@ -4342,11 +4370,24 @@ async function resolveAgenticTarget(opts = {}) {
4342
4370
 
4343
4371
  const hubs = await discoverAgenticHubs(base.url, { token: base.token, ...opts });
4344
4372
 
4373
+ // The host to suggest in operator-facing messages: the engine's own host
4374
+ // (bracketed if an IPv6 literal, so the suggested URL authority is valid), so a
4375
+ // remote-engine advisory names the reachable LAN host rather than 127.0.0.1.
4376
+ let suggestHost = '127.0.0.1';
4377
+ try {
4378
+ const h = new URL(base.url).hostname;
4379
+ suggestHost = wsHostPart(isLoopbackHost(h) ? '127.0.0.1' : h);
4380
+ } catch { /* keep the loopback default */ }
4381
+
4345
4382
  if (hubs.length === 1) {
4346
- const { project, port } = hubs[0];
4383
+ const { project, port, host } = hubs[0];
4347
4384
  return {
4348
4385
  status: 'connect',
4349
- config: { ...base, url: `http://127.0.0.1:${port}`, discovered: { project, port } },
4386
+ config: {
4387
+ ...base,
4388
+ url: `http://${wsHostPart(host)}:${port}`,
4389
+ discovered: { project, port, host },
4390
+ },
4350
4391
  };
4351
4392
  }
4352
4393
  if (hubs.length > 1) {
@@ -4355,14 +4396,101 @@ async function resolveAgenticTarget(opts = {}) {
4355
4396
  status: 'ambiguous',
4356
4397
  candidates: hubs,
4357
4398
  message: `multiple embedded apps expose an agentic channel (${list}); refusing to guess. `
4358
- + 'Disambiguate by setting NANO_AGENTIC_URL=http://127.0.0.1:<port> (or persisted agenticUrl) to the one you want.',
4399
+ + `Disambiguate by setting NANO_AGENTIC_URL=http://${suggestHost}:<port> (or persisted agenticUrl) to the one you want.`,
4359
4400
  };
4360
4401
  }
4361
4402
  return {
4362
4403
  status: 'advisory',
4363
4404
  message: `agentic visibility was not discoverable at ${base.url} — the embedded app port could `
4364
4405
  + 'not be found (not a nano engine, or its console projects API is absent). Set '
4365
- + 'NANO_AGENTIC_URL=http://127.0.0.1:<appUi.port> to enable the visibility channel. Continuing without it.',
4406
+ + `NANO_AGENTIC_URL=http://${suggestHost}:<appUi.port> to enable the visibility channel. Continuing without it.`,
4407
+ };
4408
+ }
4409
+
4410
+ /**
4411
+ * Collapse an agentic disconnect/failure detail into the single short string the
4412
+ * marker's `agentic.message` field carries (#99 contract). Accepts the close
4413
+ * `info` the work channel's onDisconnect passes (transport-dependent shape, e.g.
4414
+ * `{ code, reason, local }`), a thrown Error, or a bare string.
4415
+ * @param {unknown} x diagnostic input (close info, Error, or string)
4416
+ * @returns {string|null} a human-readable reason, or null when nothing useful
4417
+ */
4418
+ function normalizeAgenticMessage(x) {
4419
+ // Collapse an agentic disconnect/failure detail into the single short string
4420
+ // the marker's `agentic.message` field carries (#99 contract). Accepts the
4421
+ // close `info` the work channel's onDisconnect passes (transport-dependent
4422
+ // shape, e.g. `{ code, reason, local }`), a thrown Error, or a bare string,
4423
+ // and returns a human-readable reason or null when there is nothing useful.
4424
+ if (x == null) return null;
4425
+ if (typeof x === 'string') return x.trim() || null;
4426
+ if (x instanceof Error) return x.message ? String(x.message) : String(x);
4427
+ if (typeof x === 'object') {
4428
+ const reason = typeof x.reason === 'string' ? x.reason.trim() : '';
4429
+ const code = x.code != null && x.code !== '' ? String(x.code) : '';
4430
+ if (reason && code) return `${reason} (code ${code})`;
4431
+ if (reason) return reason;
4432
+ if (code) return `close code ${code}`;
4433
+ if (x.message) return String(x.message);
4434
+ if (x.local === true) return 'closed locally';
4435
+ if (x.local === false) return 'connection dropped';
4436
+ return null;
4437
+ }
4438
+ return String(x);
4439
+ }
4440
+
4441
+ /**
4442
+ * Map a resolved `resolveAgenticTarget` result to the INITIAL agentic-channel
4443
+ * state persisted on the supervisor activity marker (#99). Pure so the marker
4444
+ * producer's state transitions are unit-testable without a live broker/SDK
4445
+ * client — a regression here would leave every supervised worker stuck at
4446
+ * `?`/`starting`, which the reader/renderer tests can't catch. `connected`/
4447
+ * `disconnected` are layered on top of this base by the channel lifecycle
4448
+ * (a `{ ...state, status }` merge). The `ambiguous` status is a hard-stop
4449
+ * handled by the caller (never reaches the marker), so it degrades to `off`
4450
+ * here. `safeUrl` mirrors the caller's defensive display-URL builder.
4451
+ * @param {{ status?: string, config?: any, message?: string }} target
4452
+ * @param {(u: string) => (string|null)} [safeUrl]
4453
+ */
4454
+ function agenticStateForTarget(target, safeUrl = (u) => u) {
4455
+ switch (target?.status) {
4456
+ case 'connect': {
4457
+ const cfg = target.config || {};
4458
+ return {
4459
+ status: 'connecting',
4460
+ mode: cfg.secure ? 'secure' : 'local',
4461
+ url: safeUrl(cfg.url),
4462
+ discovered: cfg.discovered || null,
4463
+ };
4464
+ }
4465
+ case 'advisory':
4466
+ // Retain the discovery diagnostic so the supervisor can distinguish a
4467
+ // missing projects API, a timeout, or a non-Nano endpoint (#99).
4468
+ return { status: 'advisory', message: target.message || null };
4469
+ case 'off':
4470
+ default:
4471
+ return { status: 'off' };
4472
+ }
4473
+ }
4474
+
4475
+ /**
4476
+ * Build the supervisor activity-marker payload the worker atomically writes for
4477
+ * `supervisor status`. Pure so the producer's field set is unit-testable without
4478
+ * spawning a worker: the reader/renderer tests exercise a hand-written marker and
4479
+ * `agenticStateForTarget` in isolation, so a regression that dropped `engine` or
4480
+ * `agentic` from THIS payload — leaving every supervised worker's Engine/Agentic
4481
+ * column stuck at `?` — would otherwise slip through. `jobs` is the live active-job
4482
+ * list; `busy` is derived so callers can't desync it from `jobs`.
4483
+ * @param {{ pid:number, updatedAt:number, jobs:Array<{key:string,type:string,since:number}>, engine:(string|null), agentic:object }} fields
4484
+ */
4485
+ function buildActivityPayload({ pid, updatedAt, jobs, engine, agentic }) {
4486
+ const jobList = Array.isArray(jobs) ? jobs : [];
4487
+ return {
4488
+ pid,
4489
+ updatedAt,
4490
+ busy: jobList.length > 0,
4491
+ jobs: jobList,
4492
+ engine: engine ?? null,
4493
+ agentic,
4366
4494
  };
4367
4495
  }
4368
4496
 
@@ -4654,10 +4782,29 @@ async function workAgent(req, flags) {
4654
4782
  installParentDeathWatchdog({ parentPid: Number.isInteger(daemonPid) ? daemonPid : undefined });
4655
4783
  }
4656
4784
  const activeJobs = new Map(); // jobKey -> { type, since (ms epoch) }
4785
+ // Which engine this worker polls jobs from + the live agentic-visibility
4786
+ // channel status, both surfaced to `supervisor status` via the activity
4787
+ // marker (#99). `agenticState` starts 'starting' and is updated once the
4788
+ // channel target is resolved and again on each connect/disconnect below.
4789
+ // The engine must name the ACTUAL polling authority: jobs are activated by
4790
+ // `camunda.createJobWorker()` against the active c8ctl profile engine
4791
+ // (`camunda.getConfig().restAddress`), whereas `restConfig` can honor the
4792
+ // auxiliary NANO_REST_URL/NANO_BASE_URL/nanoUrl overrides (for `--auto`
4793
+ // reads). Derive from the profile engine, using restConfig only as a
4794
+ // fallback, so the column can't advertise an override host jobs aren't
4795
+ // polled from.
4796
+ const workerEngine = (() => {
4797
+ try {
4798
+ const profileBase = normalizeRestBase(camunda?.getConfig?.()?.restAddress);
4799
+ if (profileBase) return profileBase;
4800
+ } catch { /* degrade to the auxiliary REST config below */ }
4801
+ return restConfig?.baseUrl || null;
4802
+ })();
4803
+ let agenticState = { status: 'starting' };
4657
4804
  const writeActivity = () => {
4658
4805
  if (!activityFile) return;
4659
4806
  const jobs = [...activeJobs.entries()].map(([key, v]) => ({ key, type: v.type, since: v.since }));
4660
- const payload = { pid: process.pid, updatedAt: Date.now(), busy: jobs.length > 0, jobs };
4807
+ const payload = buildActivityPayload({ pid: process.pid, updatedAt: Date.now(), jobs, engine: workerEngine, agentic: agenticState });
4661
4808
  const tmp = `${activityFile}.${process.pid}.tmp`;
4662
4809
  try {
4663
4810
  mkdirSync(dirname(activityFile), { recursive: true });
@@ -4706,9 +4853,22 @@ async function workAgent(req, flags) {
4706
4853
  // NANO_AGENTIC=off disables it (see resolveAgenticConfig).
4707
4854
  const agenticTarget = await resolveAgenticTarget({ logger });
4708
4855
  let agenticCfg = null;
4856
+ // buildAgenticUrl can throw on a malformed/unsupported explicit NANO_AGENTIC_URL.
4857
+ // This is only the display URL for the activity marker, so compute it
4858
+ // defensively: a bad URL must be recorded as a channel failure (via the
4859
+ // createWorkChannel try/catch below), never crash the worker before it — which
4860
+ // would violate the best-effort channel contract and cause a restart loop.
4861
+ const safeAgenticDisplayUrl = (u) => {
4862
+ try { return redactAgenticUrl(buildAgenticUrl(u, {})); }
4863
+ catch { return null; }
4864
+ };
4709
4865
  switch (agenticTarget.status) {
4710
4866
  case 'connect':
4711
4867
  agenticCfg = agenticTarget.config;
4868
+ // 'connecting' until the socket actually opens (wired on the channel
4869
+ // lifecycle below). Carry the resolved mode/target/discovery so the
4870
+ // supervisor can show WHERE presence is being announced (#99).
4871
+ agenticState = agenticStateForTarget(agenticTarget, safeAgenticDisplayUrl);
4712
4872
  break;
4713
4873
  case 'ambiguous':
4714
4874
  // The operator ran with visibility on-by-default but the hub is
@@ -4717,13 +4877,19 @@ async function workAgent(req, flags) {
4717
4877
  process.exit(1);
4718
4878
  break;
4719
4879
  case 'advisory':
4880
+ agenticState = agenticStateForTarget(agenticTarget);
4720
4881
  logger.info(` agentic channel: ${agenticTarget.message}`);
4721
4882
  break;
4722
4883
  case 'off':
4723
4884
  default:
4885
+ agenticState = agenticStateForTarget(agenticTarget);
4724
4886
  logger.info(' agentic channel: disabled — the off-switch is set (NANO_AGENTIC=off or persisted agentic:false). Clear it to use default LOCAL visibility.');
4725
4887
  break;
4726
4888
  }
4889
+ // Persist the resolved channel state to the activity marker now, so
4890
+ // `supervisor status` reflects connecting/advisory/off immediately, before
4891
+ // the socket opens (or without a channel at all).
4892
+ writeActivity();
4727
4893
  if (agenticCfg) {
4728
4894
  try {
4729
4895
  workChannel = await createWorkChannel({
@@ -4744,12 +4910,35 @@ async function workAgent(req, flags) {
4744
4910
  const shown = redactAgenticUrl(buildAgenticUrl(agenticCfg.url, {}));
4745
4911
  const mode = agenticCfg.secure ? 'secure' : 'local';
4746
4912
  if (agenticCfg.discovered) {
4747
- logger.info(` agentic channel: auto-discovered ${agenticCfg.discovered.project} on the embedded app port :${agenticCfg.discovered.port} (bypassing the WS-incapable console proxy).`);
4913
+ const d = agenticCfg.discovered;
4914
+ logger.info(` agentic channel: auto-discovered ${d.project} on the app's /agentic port ${wsHostPart(d.host)}:${d.port} (bypassing the WS-incapable console proxy).`);
4748
4915
  }
4749
4916
  logger.info(` agentic channel (${mode}): announcing presence as ${workerName} on ${shown}`);
4917
+ // Track the live connection state on the activity marker so the
4918
+ // supervisor shows connected↔disconnected transitions (#99). onConnect
4919
+ // fires only for listeners present at first open, so also reconcile the
4920
+ // already-open case synchronously via connected(). If the socket opened
4921
+ // and then dropped inside the createWorkChannel() await window (before
4922
+ // these listeners existed), connected() is false but everConnected() is
4923
+ // true — record that as `disconnected` rather than leaving it stuck at
4924
+ // `connecting`. A close carries a normalized diagnostic under the contract
4925
+ // `agentic.message` field (not `reason`) so a hub drop explains WHY; a
4926
+ // fresh (re)connect clears any stale message.
4927
+ const markAgentic = (status, message = null) => { agenticState = { ...agenticState, status, message }; writeActivity(); };
4928
+ workChannel.onConnect(() => markAgentic('connected'));
4929
+ workChannel.onReconnect(() => markAgentic('connected'));
4930
+ workChannel.onDisconnect((info) => markAgentic('disconnected', normalizeAgenticMessage(info)));
4931
+ if (workChannel.connected()) markAgentic('connected');
4932
+ else if (workChannel.everConnected()) markAgentic('disconnected');
4750
4933
  } catch (err) {
4751
4934
  // Never let a channel failure stop the worker from doing its actual job.
4752
4935
  workChannel = null;
4936
+ // Retain the failure reason on the marker so the supervisor can show WHY
4937
+ // presence dropped (bad URL, refused socket, …), not just `disconnected`.
4938
+ // The contract diagnostic field is `agentic.message` (#99), matching the
4939
+ // live-disconnect path above — keep the key consistent, not `reason`.
4940
+ agenticState = { ...agenticState, status: 'disconnected', message: normalizeAgenticMessage(err) };
4941
+ writeActivity();
4753
4942
  logger.warn(` agentic channel unavailable (${err?.message || err}); continuing without visibility.`);
4754
4943
  }
4755
4944
  // C4 (#43): observe the client's built-in outbound buffer across the
@@ -5614,6 +5803,8 @@ function summarizeSupervisorWorker(w, now = Date.now()) {
5614
5803
  // Per-job activity (supervised workers only). Guard on pid so a stale marker
5615
5804
  // left by a previous incarnation can't show a dead job as in-flight.
5616
5805
  let activity = null; // { state: 'busy'|'idle', jobs: [{ key, type, sinceMs, sinceEpochMs }] }
5806
+ let engine = null; // job-polling engine base URL this worker reported (#99)
5807
+ let agentic = null; // { status, mode, url, discovered } agentic-channel state (#99)
5617
5808
  if (alive) {
5618
5809
  const act = readWorkerActivity(w.id);
5619
5810
  if (act && act.pid === w.pid) {
@@ -5628,6 +5819,10 @@ function summarizeSupervisorWorker(w, now = Date.now()) {
5628
5819
  }))
5629
5820
  : [];
5630
5821
  activity = { state: jobs.length > 0 ? 'busy' : 'idle', jobs };
5822
+ // Engine + agentic-channel status ride the same pid-guarded marker, so a
5823
+ // stale incarnation can't show a dead worker as connected to a hub.
5824
+ engine = typeof act.engine === 'string' && act.engine ? act.engine : null;
5825
+ agentic = act.agentic && typeof act.agentic === 'object' ? act.agentic : null;
5631
5826
  }
5632
5827
  // No marker (or a stale-pid one): leave activity null → rendered as unknown.
5633
5828
  }
@@ -5642,6 +5837,8 @@ function summarizeSupervisorWorker(w, now = Date.now()) {
5642
5837
  lastExit: w.lastExit ?? null,
5643
5838
  args: Array.isArray(w.args) ? w.args : [],
5644
5839
  activity,
5840
+ engine,
5841
+ agentic,
5645
5842
  };
5646
5843
  }
5647
5844
 
@@ -5668,6 +5865,10 @@ function supervisorStatusSignature(workers) {
5668
5865
  w.activity
5669
5866
  ? w.activity.jobs.map((j) => `${j.key}\u0000${j.type ?? ''}`).sort()
5670
5867
  : null,
5868
+ // Engine + agentic-channel status: a connect/disconnect or an engine
5869
+ // change is a real transition that must repaint attached consoles (#99).
5870
+ w.engine ?? '',
5871
+ w.agentic ? (w.agentic.status ?? '') : null,
5671
5872
  ]),
5672
5873
  );
5673
5874
  }
@@ -5684,6 +5885,37 @@ function supervisorJobCell(w) {
5684
5885
  return `${first.key}${more}${dur}`;
5685
5886
  }
5686
5887
 
5888
+ /**
5889
+ * ENGINE cell: the authority (host:port) of the engine this worker polls jobs
5890
+ * from, so an operator can see cross-machine fleets at a glance. `-` for a
5891
+ * down/stopping worker, `?` for a live worker not (yet) reporting or on an
5892
+ * older build whose marker predates this field. A non-URL engine string falls
5893
+ * back to the raw value.
5894
+ */
5895
+ function supervisorEngineCell(w) {
5896
+ if (w.state !== 'running') return '-';
5897
+ if (!w.activity) return '?'; // alive but not reporting
5898
+ if (!w.engine) return '?'; // reporting, but marker predates the engine field
5899
+ try {
5900
+ const host = new URL(w.engine).host;
5901
+ return host || String(w.engine); // a scheme-less string parses host-empty
5902
+ } catch { return String(w.engine); }
5903
+ }
5904
+
5905
+ /**
5906
+ * AGENTIC cell: the visibility-channel status word
5907
+ * (`connected`/`connecting`/`disconnected`/`advisory`/`off`/`starting`), so an
5908
+ * operator can tell whether presence actually reached the Workforce hub. `-`
5909
+ * for a down/stopping worker, `?` for a live worker not (yet) reporting or on an
5910
+ * older build whose marker predates this field.
5911
+ */
5912
+ function supervisorAgenticCell(w) {
5913
+ if (w.state !== 'running') return '-';
5914
+ if (!w.activity) return '?'; // alive but not reporting
5915
+ if (!w.agentic || !w.agentic.status) return '?'; // marker predates the agentic field
5916
+ return String(w.agentic.status);
5917
+ }
5918
+
5687
5919
  /**
5688
5920
  * Re-age a supervisor status snapshot to `now`, recomputing the ticking
5689
5921
  * durations (`uptimeMs`, per-job `sinceMs`) from the absolute base epochs the
@@ -5829,14 +6061,19 @@ function formatSupervisorStatus(status) {
5829
6061
  id: String(w.id),
5830
6062
  profile: String(w.profile),
5831
6063
  state: String(w.state),
6064
+ engine: supervisorEngineCell(w),
6065
+ agentic: supervisorAgenticCell(w),
5832
6066
  job: supervisorJobCell(w),
5833
6067
  pid: w.pid ? String(w.pid) : '-',
5834
6068
  restarts: String(w.restarts),
5835
6069
  uptime: w.state === 'running' ? formatDuration(w.uptimeMs) : '-',
5836
6070
  last: w.lastExit ? String(w.lastExit) : '-',
5837
6071
  }));
5838
- const head = { id: 'ID', profile: 'PROFILE', state: 'STATE', job: 'JOB', pid: 'PID', restarts: 'RESTARTS', uptime: 'UPTIME', last: 'LAST EXIT' };
5839
- const cols = ['id', 'profile', 'state', 'job', 'pid', 'restarts', 'uptime', 'last'];
6072
+ // ENGINE + AGENTIC sit early (just after STATE) so the pinned live view's
6073
+ // width clamp (which trims from the right) drops the least-critical columns
6074
+ // (LAST EXIT, UPTIME) first and keeps the visibility diagnostics visible.
6075
+ const head = { id: 'ID', profile: 'PROFILE', state: 'STATE', engine: 'ENGINE', agentic: 'AGENTIC', job: 'JOB', pid: 'PID', restarts: 'RESTARTS', uptime: 'UPTIME', last: 'LAST EXIT' };
6076
+ const cols = ['id', 'profile', 'state', 'engine', 'agentic', 'job', 'pid', 'restarts', 'uptime', 'last'];
5840
6077
  const width = {};
5841
6078
  for (const c of cols) width[c] = Math.max(head[c].length, ...rows.map((r) => r[c].length));
5842
6079
  const fmt = (r) => ' ' + cols.map((c) => r[c].padEnd(width[c])).join(' ');
@@ -8681,6 +8918,11 @@ export {
8681
8918
  printSupervisorStatus,
8682
8919
  supervisorStatusSignature,
8683
8920
  supervisorJobCell,
8921
+ supervisorEngineCell,
8922
+ supervisorAgenticCell,
8923
+ agenticStateForTarget,
8924
+ normalizeAgenticMessage,
8925
+ buildActivityPayload,
8684
8926
  supervisorWorkerActivityFile,
8685
8927
  WORK_FORWARD_FLAGS,
8686
8928
  installParentDeathWatchdog,
@@ -8741,7 +8983,7 @@ export const metadata = {
8741
8983
  { command: 'NANO_AGENTIC_URL=http://localhost:8080 NANO_AGENTIC_SECRET=<shared-secret> c8ctl nano work reviewer', description: 'Enrol the worker on the app\'s same-port /agentic channel in SECURE mode (same NANO_AGENTIC_SECRET as the server) so it appears live (presence + relay terminals) on the Workforce visibility page' },
8742
8984
  { command: 'c8ctl nano supervisor start --worker reviewer --worker coder', description: 'Start a detached supervisor managing several workers from one terminal' },
8743
8985
  { command: 'c8ctl nano supervisor', description: 'Attach an interactive console to the supervisor (detach with Ctrl-D, leaving it running)' },
8744
- { command: 'c8ctl nano supervisor status', description: 'List supervised workers (pid, state, serviced job / idle, restarts, uptime) without the console' },
8986
+ { command: 'c8ctl nano supervisor status', description: 'List supervised workers (state, ENGINE + AGENTIC visibility diagnostics, serviced job / idle, pid, restarts, uptime) without the console' },
8745
8987
  { command: 'c8ctl nano supervisor add decider --max-parallel 2', description: 'Add a supervised worker (forwarding work flags) to the running supervisor' },
8746
8988
  { command: 'c8ctl nano supervisor add reviewer --instances 3', description: 'Add 3 distinct auto-named instances of a profile in one call' },
8747
8989
  { command: 'c8ctl nano supervisor restart reviewer', description: 'Restart a supervised worker by id or profile' },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "c8ctl-plugin-nano",
3
- "version": "1.36.1",
3
+ "version": "1.37.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",
@@ -57,12 +57,12 @@
57
57
  },
58
58
  "optionalDependencies": {
59
59
  "node-pty": "^1.0.0",
60
- "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.36.1",
61
- "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.36.1",
62
- "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.36.1",
63
- "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.36.1",
64
- "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.36.1",
65
- "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.36.1",
66
- "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.36.1"
60
+ "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.37.0",
61
+ "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.37.0",
62
+ "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.37.0",
63
+ "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.37.0",
64
+ "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.37.0",
65
+ "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.37.0",
66
+ "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.37.0"
67
67
  }
68
68
  }
package/work-channel.mjs CHANGED
@@ -118,6 +118,7 @@ function presenceCapability(capability, jobs) {
118
118
  * @property {(fn: (info: object) => void) => () => void} onDisconnect subscribe to channel close
119
119
  * @property {(fn: () => void) => () => void} onReconnect subscribe to reconnects (every open after the first)
120
120
  * @property {() => boolean} connected whether the channel is currently open
121
+ * @property {() => boolean} everConnected whether the channel has ever opened (stays true after a later close)
121
122
  * @property {() => number} buffered outbound frames currently buffered awaiting the channel
122
123
  * @property {(reason?: string) => Promise<void>} stop deregister + close cleanly
123
124
  */
@@ -272,6 +273,12 @@ export async function createWorkChannel(opts) {
272
273
  onReconnect: subscribe(reconnectListeners),
273
274
  onDisconnect: subscribe(disconnectListeners),
274
275
  connected: () => client.connected,
276
+ // Whether the channel has ever opened (even if it has since closed). Lets a
277
+ // late subscriber tell "still connecting, never opened" (false) apart from
278
+ // "opened then dropped before I subscribed" (true), so an initial close that
279
+ // fires inside the createWorkChannel() await window is reconciled to
280
+ // `disconnected` rather than left stuck at `connecting`.
281
+ everConnected: () => hasConnected,
275
282
  buffered: () => client.buffered,
276
283
  async stop(reason = 'worker stopped') {
277
284
  try {