c8ctl-plugin-nano 1.57.1 → 1.59.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,131 @@
1
+ // Producer-side ACP → transcript-chunk mapping that PRESERVES message boundaries
2
+ // (jwulf/c8ctl-plugin-nano#206), built on top of the published shared contract
3
+ // from nanobpm/nano-ide#566 (@nanobpm/agentic >= 0.14.0).
4
+ //
5
+ // The problem: streaming ACP output arrives as arbitrary `agent_message_chunk`
6
+ // deltas whose transport boundaries are NOT message boundaries. The canonical
7
+ // bridge `acpUpdateToTranscriptChunk` (nanobpm/nano-ide#534) folds an ACP
8
+ // `session/update` into the exact transcript-chunk bytes the cockpit decodes, but
9
+ // it drops the ACP `messageId` that the shared classifier already extracts — so a
10
+ // consumer folding those chunks through the shared ordered-display derivation
11
+ // (`deriveDisplay`, #566) cannot tell a continuing delta of ONE message from the
12
+ // first delta of a NEW same-speaker message. Two distinct assistant messages
13
+ // emitted back-to-back would wrongly coalesce into one block; a single message
14
+ // split across chunks reconstructs correctly either way.
15
+ //
16
+ // This module carries the AVAILABLE producer semantics — message identity
17
+ // (`messageId`), role/channel and delta/snapshot mode — into the canonical
18
+ // additive `MessageEvent` fields the shared contract added in #566, using the
19
+ // SHARED classifier (`classifyUpdate`) and the SHARED canonical encoder
20
+ // (`encodeTranscriptEvent`). It does NOT hand-roll a parallel wire grammar,
21
+ // grouping implementation or heuristic sentence splitter: the marker, version,
22
+ // kinds and additive fields all come from the package.
23
+ //
24
+ // Fidelity contract (the documented legacy fallback):
25
+ // - ACP `agent_message_chunk` / `agent_thought_chunk` / `user_message_chunk`
26
+ // text is an incremental DELTA (never a cumulative snapshot for the supported
27
+ // ACP providers), so a message event is tagged `mode: "delta"` — a cumulative
28
+ // snapshot is NEVER emitted as an additive delta (the #566 "never append a
29
+ // snapshot as a delta" rule).
30
+ // - Where the provider exposes a `messageId`, it is carried so the display fold
31
+ // groups a message's fragments and separates two distinct same-speaker
32
+ // messages even when their transport chunks are adjacent.
33
+ // - Where the provider omits `messageId` (a documented ACP fidelity gap), NO
34
+ // identity is fabricated and NO boundary is inferred from delays/punctuation:
35
+ // the chunk is emitted through the canonical bridge UNCHANGED (byte-identical
36
+ // to the pre-#206 wire), and the display fold's adjacent-same-speaker
37
+ // coalescing is the legacy fallback.
38
+ // - Tool-call / tool-result / permission / ignored updates are delegated to the
39
+ // canonical bridge untouched, so tool and permission events stay correctly
40
+ // ordered and paired and raw replay is unchanged.
41
+
42
+ import { sessionAcp as defaultSessionAcp, transcript as defaultTranscript } from './agentic.mjs';
43
+
44
+ // Map the shared classifier's message role to a canonical `TranscriptRole`. ACP's
45
+ // `reasoning` (an `agent_thought_chunk`) has no distinct transcript role, so — like
46
+ // the canonical bridge `acpUpdateToTranscriptChunk` — it folds to `assistant`,
47
+ // which `deriveDisplay` renders as a message block rather than dropping to raw
48
+ // bytes. This mirrors the package bridge exactly so the producer never diverges
49
+ // from the shared role mapping.
50
+ function transcriptRole(acpRole) {
51
+ return acpRole === 'user' ? 'user' : 'assistant';
52
+ }
53
+
54
+ // A non-empty string, else null. `messageId` is optional on the ACP update and the
55
+ // shared classifier already normalises it to `string | null`.
56
+ function nonBlankId(value) {
57
+ return typeof value === 'string' && value !== '' ? value : null;
58
+ }
59
+
60
+ /**
61
+ * Map one raw ACP `session/update` `update` object to the canonical transcript-chunk
62
+ * bytes a producer appends, carrying the available message identity / role / delta
63
+ * semantics into the shared additive `MessageEvent` contract (#566). Returns `null`
64
+ * for an update with no canonical meaning (an `ignored` classification), exactly like
65
+ * the underlying bridge, so a caller skips it.
66
+ *
67
+ * Pure and total: any classifier or encoder throw degrades to the canonical bridge,
68
+ * and a bridge throw is itself caught (yielding `null`), so the producer hot path
69
+ * never crashes on one malformed update.
70
+ *
71
+ * @param {unknown} update The raw ACP `session/update` `params.update` object.
72
+ * @param {object} [deps]
73
+ * @param {object} [deps.sessionAcp] The shared ACP surface (`classifyUpdate` +
74
+ * `acpUpdateToTranscriptChunk`); defaults to the package bridge.
75
+ * @param {object} [deps.transcript] The shared transcript surface
76
+ * (`encodeTranscriptEvent`); defaults to the package transcript module.
77
+ * @returns {string | null} The canonical transcript-chunk bytes, or `null`.
78
+ */
79
+ export function acpUpdateToDisplayChunk(update, deps = {}) {
80
+ const sessionAcp = deps.sessionAcp || defaultSessionAcp;
81
+ const transcript = deps.transcript || defaultTranscript;
82
+
83
+ const classify = typeof sessionAcp?.classifyUpdate === 'function' ? sessionAcp.classifyUpdate : null;
84
+ const encode = typeof transcript?.encodeTranscriptEvent === 'function' ? transcript.encodeTranscriptEvent : null;
85
+ const bridge = typeof sessionAcp?.acpUpdateToTranscriptChunk === 'function' ? sessionAcp.acpUpdateToTranscriptChunk : null;
86
+
87
+ // Fallback to the canonical bridge output for this update. Never throws.
88
+ const viaBridge = () => {
89
+ if (!bridge) return null;
90
+ try { return bridge(update); }
91
+ catch { return null; }
92
+ };
93
+
94
+ // Without the shared classifier + encoder we cannot enrich the message event, so
95
+ // the byte-identical canonical bridge output is the only correct behaviour.
96
+ if (!classify || !encode) return viaBridge();
97
+
98
+ let classified;
99
+ try { classified = classify(update); }
100
+ catch { return viaBridge(); }
101
+
102
+ // Only message chunks carry identity/boundary semantics worth enriching. Every
103
+ // other classification (tool-call, tool-result, ignored) is delegated to the
104
+ // canonical bridge UNCHANGED — tool/permission ordering and raw replay untouched.
105
+ if (!classified || classified.kind !== 'message') return viaBridge();
106
+
107
+ const messageId = nonBlankId(classified.messageId);
108
+
109
+ // No provider-supplied identity → do NOT fabricate one or infer a boundary.
110
+ // Emit through the canonical bridge unchanged (byte-identical to the pre-#206
111
+ // wire) and let the display fold's adjacent-same-speaker coalescing be the
112
+ // documented legacy fallback.
113
+ if (messageId === null) return viaBridge();
114
+
115
+ // Carry the available semantics into the additive `MessageEvent` fields: the
116
+ // producer identity (`messageId`) so the fold groups this message's fragments and
117
+ // separates distinct same-speaker messages, and `mode: "delta"` because ACP
118
+ // message chunks are incremental deltas — never a cumulative snapshot. No `offset`
119
+ // is supplied here; the real store offset is assigned on append (matching every
120
+ // other `encodeTranscriptEvent` call site).
121
+ const event = {
122
+ kind: 'message',
123
+ role: transcriptRole(classified.role),
124
+ text: classified.text,
125
+ messageId,
126
+ mode: 'delta',
127
+ };
128
+
129
+ try { return encode(event); }
130
+ catch { return viaBridge(); }
131
+ }
@@ -305,7 +305,10 @@ function buildRawEmitClient({ channelUrl, transportFactory, peerAdvertisement, l
305
305
  * `transportFactory` and never touches the real client.
306
306
  *
307
307
  * @param {object} opts
308
- * @param {string} opts.url the app's HTTP(S) base URL (channel served same-port at `/agentic`)
308
+ * @param {string} [opts.url] the app's HTTP(S) base URL (channel served same-port at
309
+ * `/agentic`). OPTIONAL in self-heal mode: when `resolveConfig` is supplied the base
310
+ * URL is not fixed, but a static `url` may still be passed to SEED the initial memo
311
+ * before the first re-discovery lands. Required only when `resolveConfig` is absent.
309
312
  * @param {string} [opts.token] ADR 0028 identity token (carried as `?token=`)
310
313
  * @param {string} [opts.credential] capability credential (carried as `?capability=`)
311
314
  * @param {import('@nanobpm/agentic/protocol').ProtocolAdvertisement} [opts.remoteAdvertisement]
@@ -314,23 +317,91 @@ function buildRawEmitClient({ channelUrl, transportFactory, peerAdvertisement, l
314
317
  * @param {(state: 'connected'|'disconnected') => void} [opts.onConnectionState] observer fired
315
318
  * when the single host connection opens/drops, so a caller can track its liveness
316
319
  * @param {{ warn?: Function, debug?: Function }} [opts.logger]
317
- * @returns {Promise<() => import('./supervisor.dist.js').RawEmitClient>} a synchronous `connect` factory
320
+ * @param {() => Promise<{url:string, token?:string, credential?:string}|null>} [opts.resolveConfig]
321
+ * OPTIONAL self-heal resolver (jwulf/c8ctl-plugin-nano#133). When present the
322
+ * base URL is NOT fixed: each (re)connect consults a background-refreshed,
323
+ * memoised config, and while none is known the synchronous factory THROWS —
324
+ * which `superviseAgentic` turns into its jittered, ≤30s-capped reconnect
325
+ * retry — and kicks a guarded async re-discovery, so a later retry finds the
326
+ * hub and connects (`advisory → connected` without a restart). A moved hub
327
+ * self-heals the same way. Fail-open: a throwing resolver is swallowed.
328
+ * @returns {Promise<() => import('./supervisor.dist.js').RawEmitClient>} a synchronous
329
+ * `connect` factory. In self-heal (`resolveConfig`) mode it may THROW when the
330
+ * hub is not yet discoverable — by contract that drives `superviseAgentic`'s retry.
318
331
  */
319
332
  export async function createRawEmitConnect(opts) {
320
- const { url, token, credential, remoteAdvertisement, transportFactory, logger, onConnectionState } = opts || {};
321
- if (typeof url !== 'string' || url.trim() === '') {
322
- throw new Error('createRawEmitConnect requires an agentic channel base url');
333
+ const { url, token, credential, remoteAdvertisement, transportFactory, logger, onConnectionState, resolveConfig } =
334
+ opts || {};
335
+ const hasResolver = typeof resolveConfig === 'function';
336
+ const hasStaticUrl = typeof url === 'string' && url.trim() !== '';
337
+ if (!hasResolver && !hasStaticUrl) {
338
+ throw new Error(
339
+ 'createRawEmitConnect requires an agentic channel base `url`, or a `resolveConfig` self-heal resolver to discover one',
340
+ );
323
341
  }
324
342
 
325
- const channelUrl = buildAgenticUrl(url, { token, credential });
326
343
  const factory = transportFactory ?? (await loadAgenticClient()).websocketTransport;
327
344
 
328
- return () =>
329
- buildRawEmitClient({
330
- channelUrl,
345
+ // Static mode: a fixed, known URL (explicit NANO_AGENTIC_URL or an
346
+ // already-discovered hub). The channel URL is built once; the emit client owns
347
+ // reconnect to it and `superviseAgentic` re-`connect`s to the SAME URL on a
348
+ // permanent close. Unchanged behaviour.
349
+ if (!hasResolver) {
350
+ const channelUrl = buildAgenticUrl(url, { token, credential });
351
+ return () =>
352
+ buildRawEmitClient({
353
+ channelUrl,
354
+ transportFactory: factory,
355
+ peerAdvertisement: remoteAdvertisement,
356
+ logger,
357
+ onConnectionState,
358
+ });
359
+ }
360
+
361
+ // Self-heal mode: re-resolve discovery on each (re)connect via a guarded,
362
+ // background-refreshed memoised config. While unresolved the factory throws so
363
+ // `superviseAgentic`'s ≤30s-capped retry doubles as periodic re-discovery.
364
+ let current = hasStaticUrl ? { url, token, credential } : null;
365
+ let refreshing = false;
366
+ const kickRefresh = () => {
367
+ if (refreshing) return;
368
+ refreshing = true;
369
+ Promise.resolve()
370
+ .then(() => resolveConfig())
371
+ .then((next) => {
372
+ if (next && typeof next.url === 'string' && next.url.trim() !== '') {
373
+ // Preserve the last memoised credentials when a later refresh omits
374
+ // them, falling back to the initial opts only when nothing was ever
375
+ // discovered — so a partial refresh never clears a known-good token.
376
+ current = {
377
+ url: next.url,
378
+ token: next.token ?? current?.token ?? token,
379
+ credential: next.credential ?? current?.credential ?? credential,
380
+ };
381
+ }
382
+ })
383
+ .catch((err) => {
384
+ logger?.debug?.(`agentic re-discovery attempt failed: ${err?.message || err}`);
385
+ })
386
+ .finally(() => {
387
+ refreshing = false;
388
+ });
389
+ };
390
+
391
+ return () => {
392
+ // Kick a background refresh on every (re)connect so a moved hub self-heals on
393
+ // the next attempt; it's a no-op while one is in flight, and only runs on a
394
+ // (re)connect (a steady socket never re-enters this factory).
395
+ kickRefresh();
396
+ if (!current) {
397
+ throw new Error('agentic hub not yet discoverable — retrying via supervised reconnect');
398
+ }
399
+ return buildRawEmitClient({
400
+ channelUrl: buildAgenticUrl(current.url, { token: current.token, credential: current.credential }),
331
401
  transportFactory: factory,
332
402
  peerAdvertisement: remoteAdvertisement,
333
403
  logger,
334
404
  onConnectionState,
335
405
  });
406
+ };
336
407
  }
package/c8ctl-plugin.js CHANGED
@@ -73,6 +73,13 @@ import { createLogRing, resolveLogMaxBytes } from './supervisor-log-ring.mjs';
73
73
  // raw ACP `session/update` to the exact transcript-chunk bytes the cockpit decodes,
74
74
  // replacing the plugin's former hand-rolled `nwfTranscriptEvent` envelope grammar.
75
75
  import { sessionAcp as agenticSessionAcp } from './agentic.mjs';
76
+ // Producer-side message-boundary preservation (jwulf/c8ctl-plugin-nano#206). Wraps
77
+ // the canonical bridge to carry the ACP `messageId` / role / delta semantics into
78
+ // the shared additive `MessageEvent` contract (nanobpm/nano-ide#566), so a consumer
79
+ // folding these chunks through `deriveDisplay` reconstructs transport-fragmented
80
+ // deltas into coherent blocks and keeps distinct same-speaker messages apart —
81
+ // falling back to byte-identical bridge output when the provider omits identity.
82
+ import { acpUpdateToDisplayChunk } from './acp-transcript-producer.mjs';
76
83
  // Engine-native AgentInstance / AgentHistory durable-transcript producer (issue
77
84
  // #194): mints an AgentInstance for an `external` agent job and appends each ACP
78
85
  // turn to the engine's append-only AgentHistory via the host SDK client.
@@ -5455,20 +5462,25 @@ function spawnCaptureAcp({ command, args = [], cwd, env, stdinData, timeoutMs, i
5455
5462
  }
5456
5463
  };
5457
5464
 
5458
- // #110 / nanobpm/nano-ide#534: map an ACP session/update to the CANONICAL
5459
- // transcript-chunk wire form via the shared `@nanobpm/agentic` bridge —
5460
- // `classifyUpdate` composed with `encodeTranscriptEvent` behind the single
5461
- // `acpUpdateToTranscriptChunk` helper. It returns the exact
5462
- // `{ nwfTranscriptEvent: 1, kind, }` bytes the cockpit's `parseTranscriptEvent`
5463
- // decodes and `deriveView` folds into messages / tool cards, or `null` for an
5465
+ // #110 / nanobpm/nano-ide#534 / jwulf/c8ctl-plugin-nano#206: map an ACP
5466
+ // session/update to the CANONICAL transcript-chunk wire form via the shared
5467
+ // `@nanobpm/agentic` seams. `acpUpdateToDisplayChunk` (this plugin's producer)
5468
+ // wraps the canonical bridge (`classifyUpdate` composed with
5469
+ // `encodeTranscriptEvent`) and additionally carries the ACP `messageId` / role /
5470
+ // delta semantics into the shared additive `MessageEvent` contract (#566) so a
5471
+ // consumer folding these chunks through `deriveDisplay` reconstructs
5472
+ // transport-fragmented deltas into coherent blocks and keeps distinct
5473
+ // same-speaker messages apart — degrading to byte-identical bridge output when
5474
+ // the provider omits identity. It returns the exact `{ nwfTranscriptEvent: 1,
5475
+ // kind, … }` bytes the cockpit's `parseTranscriptEvent` decodes, or `null` for an
5464
5476
  // update with no canonical meaning (an `ignored` classification: a plan, an
5465
5477
  // intermediate tool_call_update, a non-text chunk, or a malformed update). No
5466
- // envelope grammar or vocab is hand-rolled here anymore — the marker, version,
5467
- // kinds and fields all come from the package, so a producer and a consumer can
5468
- // never diverge on the wire again. `null` (and any bridge throw) falls through
5469
- // to the minimal human-text path below, so nothing is ever dropped.
5478
+ // envelope grammar or vocab is hand-rolled here — the marker, version, kinds and
5479
+ // additive fields all come from the package, so a producer and a consumer can
5480
+ // never diverge on the wire again. `null` (and any throw) falls through to the
5481
+ // minimal human-text path below, so nothing is ever dropped.
5470
5482
  const encodeTranscriptChunk = (update) => {
5471
- try { return agenticSessionAcp.acpUpdateToTranscriptChunk(update); }
5483
+ try { return acpUpdateToDisplayChunk(update, { sessionAcp: agenticSessionAcp }); }
5472
5484
  catch { return null; }
5473
5485
  };
5474
5486
 
@@ -6703,94 +6715,6 @@ async function resolveAgenticTarget({ camunda, cache, ...opts } = {}) {
6703
6715
  };
6704
6716
  }
6705
6717
 
6706
- /**
6707
- * A jittered backoff schedule (ms) for background agentic re-discovery (#133-A):
6708
- * ~2s → 4s → 8s → 16s → 30s (capped), each waited value randomised ±20%, spanning
6709
- * roughly `ceilingMs` (default ~5 minutes) of total elapsed retry time before the
6710
- * loop gives up. A worker that lost the cold-start discovery race walks this
6711
- * schedule to upgrade `advisory → connected` without a restart.
6712
- * @param {{ base?: number, cap?: number, ceilingMs?: number, rng?: () => number }} [opts]
6713
- * @returns {number[]} the ordered per-attempt wait durations
6714
- */
6715
- function defaultAgenticRediscoveryDelays({
6716
- base = 2_000,
6717
- cap = 30_000,
6718
- ceilingMs = 5 * 60 * 1_000,
6719
- rng = Math.random,
6720
- } = {}) {
6721
- const delays = [];
6722
- let d = base;
6723
- let total = 0;
6724
- while (total < ceilingMs) {
6725
- const jitter = Math.round((rng() - 0.5) * 0.4 * d); // ±20%
6726
- const wait = Math.max(500, d + jitter);
6727
- delays.push(wait);
6728
- total += wait;
6729
- d = Math.min(cap, d * 2);
6730
- }
6731
- return delays;
6732
- }
6733
-
6734
- /**
6735
- * Background self-heal loop for agentic discovery (#133-A). After an initial
6736
- * cold-start miss leaves a worker in `advisory`, re-run `resolveTarget` on a
6737
- * jittered backoff schedule and, on the FIRST attempt that yields a
6738
- * `status:'connect'` target, invoke `onConnect(target)` and stop — so the worker
6739
- * upgrades to `connected` without a restart. Fail-open: an attempt whose
6740
- * `resolveTarget` throws is swallowed and the loop continues; likewise, if
6741
- * `onConnect` itself throws (a transient channel-open failure), the loop keeps
6742
- * re-discovering rather than stopping, so retries proceed until a connect
6743
- * callback actually succeeds. The loop also stops early whenever
6744
- * `shouldContinue()` returns false (e.g. a channel already came up, or the worker
6745
- * is shutting down). Returns the connecting target, or `null` if the schedule was
6746
- * exhausted / cancelled without a hit. Timers and the resolver are injectable so
6747
- * this is unit-testable without real waits or sockets.
6748
- *
6749
- * @param {{
6750
- * resolveTarget: () => Promise<{status:string, config?:object}>,
6751
- * onConnect?: (target: {status:string, config?:object}) => (void|Promise<void>),
6752
- * delaysMs?: number[],
6753
- * sleep?: (ms:number) => Promise<void>,
6754
- * shouldContinue?: () => boolean,
6755
- * logger?: object,
6756
- * }} opts
6757
- * @returns {Promise<object|null>}
6758
- */
6759
- async function rediscoverAgenticUntilConnected({
6760
- resolveTarget,
6761
- onConnect,
6762
- delaysMs = defaultAgenticRediscoveryDelays(),
6763
- sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
6764
- shouldContinue = () => true,
6765
- logger = null,
6766
- } = {}) {
6767
- if (typeof resolveTarget !== 'function') return null;
6768
- for (const delay of delaysMs) {
6769
- if (!shouldContinue()) return null;
6770
- try { await sleep(delay); } catch { return null; }
6771
- if (!shouldContinue()) return null;
6772
- let target;
6773
- try {
6774
- target = await resolveTarget();
6775
- } catch (err) {
6776
- logger?.debug?.(`agentic re-discovery attempt failed: ${err?.message || err}`);
6777
- continue;
6778
- }
6779
- if (target && target.status === 'connect') {
6780
- try {
6781
- await onConnect?.(target);
6782
- } catch (err) {
6783
- // A transient channel-open failure must not prematurely stop self-heal:
6784
- // keep re-discovering until an onConnect callback actually succeeds.
6785
- logger?.debug?.(`agentic re-discovery onConnect failed, will retry: ${err?.message || err}`);
6786
- continue;
6787
- }
6788
- return target;
6789
- }
6790
- }
6791
- return null;
6792
- }
6793
-
6794
6718
  // Worker-side liveness watchdog defaults (jwulf/c8ctl-plugin-nano#144). A
6795
6719
  // previously-connected agentic channel that has been `disconnected` for longer
6796
6720
  // than the stale threshold — because the client lib's own reconnect never
@@ -7816,8 +7740,24 @@ async function workAgent(req, flags) {
7816
7740
  // worker joins with the well-known LOCAL token and no credential; SECURE mode
7817
7741
  // (NANO_AGENTIC_SECRET) sends a real per-peer shared secret as the identity;
7818
7742
  // NANO_AGENTIC=off disables it (see resolveAgenticConfig).
7819
- const agenticTarget = await resolveAgenticTarget({ camunda, logger });
7743
+ const agenticDiscoveryCache = new Map();
7744
+ const agenticTarget = await resolveAgenticTarget({ camunda, logger, cache: agenticDiscoveryCache });
7820
7745
  let agenticCfg = null;
7746
+ let agenticSelfHeal = false;
7747
+ // Self-heal resolver (jwulf/c8ctl-plugin-nano#133): re-run discovery on demand
7748
+ // and hand back a fresh connect config, or null while still undiscoverable. The
7749
+ // endpoint's connect factory calls this in the background so `superviseAgentic`'s
7750
+ // ≤30s-capped reconnect doubles as periodic re-discovery — an `advisory`-at-
7751
+ // startup worker (a cold-start discovery miss during an engine hiccup) upgrades
7752
+ // to `connected` without a restart when the hub reappears. The shared cache lets
7753
+ // a later transient miss reuse the last known-good hub (#133-C).
7754
+ const resolveAgenticConnectConfig = async () => {
7755
+ const t = await resolveAgenticTarget({ camunda, logger, cache: agenticDiscoveryCache });
7756
+ if (t && t.status === 'connect') {
7757
+ return { url: t.config.url, token: t.config.token, credential: t.config.credential };
7758
+ }
7759
+ return null;
7760
+ };
7821
7761
  // buildAgenticUrl can throw on a malformed/unsupported explicit NANO_AGENTIC_URL.
7822
7762
  // This is only the display URL for the activity marker, so compute it
7823
7763
  // defensively: a bad URL must be recorded as a channel failure (via the
@@ -7841,10 +7781,31 @@ async function workAgent(req, flags) {
7841
7781
  logger.error(` agentic visibility is ambiguous: ${agenticTarget.message}`);
7842
7782
  process.exit(1);
7843
7783
  break;
7844
- case 'advisory':
7845
- agenticState = agenticStateForTarget(agenticTarget);
7846
- logger.info(` agentic channel: ${agenticTarget.message}`);
7784
+ case 'advisory': {
7785
+ // Don't give up: build a SELF-HEALING endpoint whose connect re-discovers
7786
+ // on each of `superviseAgentic`'s ≤30s-capped reconnects, so a cold-start
7787
+ // discovery miss (e.g. the engine hiccuped just as the worker started)
7788
+ // upgrades to `connected` without a restart (jwulf/c8ctl-plugin-nano#133).
7789
+ agenticSelfHeal = true;
7790
+ // Derive one retry-aware message so the marker's `agentic.message` and the
7791
+ // log line agree: consumers of `supervisor status`/markers must see that the
7792
+ // worker WILL self-heal, not the raw "Continuing without it." advisory text.
7793
+ const retryMessage = agenticTarget.message.replace(
7794
+ 'Continuing without it.',
7795
+ 'Retrying discovery on each ≤30s reconnect until it self-heals.',
7796
+ );
7797
+ // Derive the auth mode from the resolved agentic config so `supervisor
7798
+ // status`/markers report SECURE (NANO_AGENTIC_SECRET) correctly while the
7799
+ // worker is still reconnecting, instead of hard-coding 'local'.
7800
+ agenticState = {
7801
+ status: 'connecting',
7802
+ mode: resolveAgenticConfig(camunda)?.secure ? 'secure' : 'local',
7803
+ url: null,
7804
+ message: retryMessage,
7805
+ };
7806
+ logger.info(` agentic channel: ${retryMessage}`);
7847
7807
  break;
7808
+ }
7848
7809
  case 'off':
7849
7810
  default:
7850
7811
  agenticState = agenticStateForTarget(agenticTarget);
@@ -7873,17 +7834,23 @@ async function workAgent(req, flags) {
7873
7834
  // the raw-JS wire (`agentic-endpoint.mjs` → the single agentic import surface)
7874
7835
  // with the bundle's Effect adapter; additive negotiation still degrades
7875
7836
  // claim/release/steer to a no-op against an older hub. Reconnect + resync (and
7876
- // teardown-on-interruption) are owned by the runtime's `superviseAgentic`, so the
7877
- // retired #133 self-heal loop and #144/#147 liveness watchdog are gone the
7878
- // marker's agentic status is driven by the endpoint's connection observer below.
7879
- // Constructing the endpoint opens NO socket (the runtime's supervision calls the
7880
- // connect factory), so this stays cheap and side-effect-free until `run` forks.
7881
- if (agenticCfg) {
7837
+ // teardown-on-interruption) are owned by the runtime's `superviseAgentic`; the
7838
+ // #133 self-heal is REINSTATED without a parallel loop by handing the `advisory`
7839
+ // case a re-discovering connect factory (`resolveConfig`), so `superviseAgentic`'s
7840
+ // own ≤30s-capped reconnect retry IS the periodic re-discovery (the #144/#147
7841
+ // liveness watchdog remains retired) the marker's agentic status is driven by
7842
+ // the endpoint's connection observer below. Constructing the endpoint opens NO
7843
+ // socket (the runtime's supervision calls the connect factory), so this stays
7844
+ // cheap and side-effect-free until `run` forks.
7845
+ if (agenticCfg || agenticSelfHeal) {
7882
7846
  try {
7883
7847
  agenticEndpoint = await createAgenticEndpoint({
7884
- url: agenticCfg.url,
7885
- token: agenticCfg.token,
7886
- credential: agenticCfg.credential,
7848
+ // Discovered/explicit connect: a fixed URL (static factory). Advisory
7849
+ // self-heal: no URL yet — the resolver supplies it (and heals a moved hub)
7850
+ // on each supervised reconnect.
7851
+ ...(agenticCfg
7852
+ ? { url: agenticCfg.url, token: agenticCfg.token, credential: agenticCfg.credential }
7853
+ : { resolveConfig: resolveAgenticConnectConfig }),
7887
7854
  // Keep the activity marker's agentic status honest across the single
7888
7855
  // connection's open/drop transitions (#99) — the direct analogue of the
7889
7856
  // retired createWorkChannel onConnect/onDisconnect marker wiring.
@@ -13732,8 +13699,6 @@ export {
13732
13699
  resolveProbeCandidates,
13733
13700
  raceProbeCandidates,
13734
13701
  isLinkLocalAddress,
13735
- rediscoverAgenticUntilConnected,
13736
- defaultAgenticRediscoveryDelays,
13737
13702
  agenticChannelIsStale,
13738
13703
  agenticPresenceIsStale,
13739
13704
  jitteredDelay,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "c8ctl-plugin-nano",
3
- "version": "1.57.1",
3
+ "version": "1.59.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",
@@ -22,6 +22,7 @@
22
22
  "files": [
23
23
  "c8ctl-plugin.js",
24
24
  "agent-instance.mjs",
25
+ "acp-transcript-producer.mjs",
25
26
  "platforms.mjs",
26
27
  "agentic.mjs",
27
28
  "agentic-loader-hook.mjs",
@@ -67,17 +68,17 @@
67
68
  "typescript": "^5.9.3"
68
69
  },
69
70
  "dependencies": {
70
- "@nanobpm/agentic": "^0.13.0",
71
- "@nanobpm/urban-agent-client": "^0.1.13"
71
+ "@nanobpm/agentic": "^0.14.0",
72
+ "@nanobpm/urban-agent-client": "^0.1.14"
72
73
  },
73
74
  "optionalDependencies": {
74
75
  "node-pty": "^1.0.0",
75
- "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.57.1",
76
- "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.57.1",
77
- "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.57.1",
78
- "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.57.1",
79
- "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.57.1",
80
- "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.57.1",
81
- "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.57.1"
76
+ "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.59.0",
77
+ "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.59.0",
78
+ "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.59.0",
79
+ "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.59.0",
80
+ "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.59.0",
81
+ "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.59.0",
82
+ "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.59.0"
82
83
  }
83
84
  }