c8ctl-plugin-nano 1.29.0 → 1.31.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
@@ -219,6 +219,66 @@ name is generated, so two `work reviewer` processes never collide at the
219
219
  broker. (`--name` names the worker; the profile to run is always the
220
220
  positional argument.)
221
221
 
222
+ ### Live agent visibility: the `/agentic` channel + live terminals
223
+
224
+ A running worker can appear **live on the Workforce visibility page** and stream
225
+ its agents' terminals to an operator's cockpit. This rides the app's agentic
226
+ channel (ADR 0056), served **same-port** on the app's own HTTP base URL at path
227
+ **`/agentic`** — not a sidecar, so there's no extra port to open.
228
+
229
+ **Connecting.** Enrolment is opt-in: a worker only connects when it has both an
230
+ ADR 0028 **identity token** and a **capability credential** (the same `?token=…`
231
+ pattern the blackboard uses). Point it at the app and hand it the two secrets via
232
+ env (or persisted config); the channel URL defaults to the configured nano URL:
233
+
234
+ ```bash
235
+ # Enrol this worker on the app's same-port /agentic channel
236
+ export NANO_AGENTIC_URL=http://localhost:8080 # app base URL; channel is served at /agentic
237
+ export NANO_AGENTIC_TOKEN=<identity-token> # ADR 0028 identity
238
+ export NANO_AGENTIC_CREDENTIAL=<capability-cred> # capability credential
239
+ c8ctl nano work reviewer
240
+ # agentic channel: announcing presence as ‹worker› on ws://localhost:8080/agentic
241
+ ```
242
+
243
+ Without both secrets the worker runs **exactly as before, off the channel** — no
244
+ visibility, no relay, nothing else changes. A valid identity + capability
245
+ connects; an invalid identity is rejected (unauthorized) and a missing capability
246
+ is rejected (forbidden).
247
+
248
+ **How presence appears.** On connect the worker **announces** its identity, its
249
+ `host`, and the set of `jobKeys` it is currently running, then **heartbeats** to
250
+ stay live and **re-announces** after a reconnect so its row survives a hub
251
+ restart. When `work` stops it **deregisters cleanly**, so the worker disappears
252
+ from the visibility page on exit.
253
+
254
+ **Opting a role into a live terminal (PTY vs pipe).** Each role chooses whether
255
+ its agent harness runs on a full **PTY** (a real terminal — streamed on the relay
256
+ lane and **steerable**: an operator's keystrokes reach the running agent) or a
257
+ plain **pipe** (streamed, not interactive). It's a **per-role opt-in**, set on
258
+ the hire profile and defaulting to `pipe`:
259
+
260
+ ```bash
261
+ # Hire a role whose harness runs on a full, steerable PTY
262
+ c8ctl nano hire --name coder --rank senior --command copilot --terminal pty
263
+
264
+ # Override the mode for a one-off worker without re-hiring
265
+ NANO_AGENTIC_TERMINAL=pipe c8ctl nano work coder
266
+ ```
267
+
268
+ A PTY needs the optional native `node-pty` dependency; when it is
269
+ **unavailable** (not installed, or Windows) a `pty` role **gracefully falls
270
+ back to a pipe** that still relays. (If `node-pty` is present but a PTY can't be
271
+ spawned at runtime, that job fails rather than falling back.) Each job's terminal
272
+ streams on its own relay stream
273
+ named `job:‹jobKey›`, so output and steer-in are routed from the `jobKey` alone.
274
+
275
+ **Surviving a hub outage.** A worker that starts **before** the app, or survives
276
+ a **hub restart**, buffers its outbound frames in a **bounded** local ring and
277
+ **drains them in order** on reconnect (no loss or reorder within the bound). The
278
+ bound is operator-tunable for a long expected outage; raise it with
279
+ `NANO_AGENTIC_BUFFER_CAPACITY` (frames). When the bound is hit the worker warns
280
+ rather than silently shedding.
281
+
222
282
  ### Live profile reload (no restart on `assign`)
223
283
 
224
284
  A running `c8ctl nano work <name>` **watches** the profile it is servicing. When
@@ -332,8 +392,32 @@ composed in FEEL, a task may supply per-instance context via **`task.appendPromp
332
392
  separator/preamble), so a null/empty append leaves the base untouched. This lets the
333
393
  static prompt live in a model header/side-car while the dynamic tail (e.g. plan-revision
334
394
  feedback, a per-task brief) is built per instance.
395
+
396
+ **Live prompts via linked resources.** A service task can declare a Zeebe/Camunda-parity
397
+ **linked resource** for its prompt instead of baking it into a model header:
398
+
399
+ ```xml
400
+ <zeebe:linkedResources>
401
+ <zeebe:linkedResource resourceId="plan.md" bindingType="latest" linkName="prompt"/>
402
+ </zeebe:linkedResources>
403
+ ```
404
+
405
+ At job activation the engine resolves the `resourceId` to the **latest** deployed key and
406
+ delivers a `linkedResources` custom header (`[{resourceKey, resourceType, linkName}]`). The
407
+ header carries the **key, not the content** — the worker fetches the bytes over the broker
408
+ REST API (`GET /v2/resources/{resourceKey}/content/binary`, reusing the same nano endpoint
409
+ the worker already talks to; override with `NANO_REST_URL`/`NANO_REST_TOKEN`) and uses the
410
+ UTF-8 content as the **base prompt**. The entry whose `linkName` is `prompt` wins over the
411
+ header-baked `task.prompt` chain; `appendPrompt` still composes onto it. Redeploying just
412
+ the resource updates the prompt for the **next activation** — no process redeploy, no
413
+ worker restart. Jobs without `linkedResources` behave exactly as before (fallback chain).
414
+ A declared prompt resource that can't be fetched **fails the job** (retryable provisioning
415
+ error) rather than running an agent with an empty prompt, and the resolved `resourceKey` is
416
+ logged and echoed on the output envelope as `promptResourceKey` for audit (the engine keeps
417
+ only `latest`, so the key is the reproducibility handle).
418
+
335
419
  On completion the plugin writes an **output envelope** back under
336
- `io.nanobpm.agentResult` (`{schemaVersion, status, sandbox, image, output, truncated, stderrTruncated, exitCode, signal, error}`). When a repository was
420
+ `io.nanobpm.agentResult` (`{schemaVersion, status, sandbox, image, output, truncated, stderrTruncated, exitCode, signal, error, promptResourceKey?}`). When a repository was
337
421
  provisioned (below) it also carries `{repository, branch, baseSha, headSha, commits[], pushed, pushError?, gitError?, pr?}`.
338
422
 
339
423
  **Git provisioning (host).** When `--sandbox none` (the default) and the envelope
package/c8ctl-plugin.js CHANGED
@@ -59,6 +59,7 @@ import { createInterface as createReadline } from 'node:readline';
59
59
  import { platformForHost } from './platforms.mjs';
60
60
  import { createWorkChannel, redactAgenticUrl, buildAgenticUrl } from './work-channel.mjs';
61
61
  import { createRelaySession, roleTerminalMode } from './work-relay.mjs';
62
+ import { createBufferMonitor, resolveBufferCapacity } from './work-buffer.mjs';
62
63
 
63
64
  const requireFromHere = createRequire(import.meta.url);
64
65
  const pluginDir = dirname(fileURLToPath(import.meta.url));
@@ -2071,6 +2072,15 @@ function killTree(child) {
2071
2072
  // envelope is written back on the job's completion variables.
2072
2073
  const AGENT_TASK_NS = 'io.nanobpm.agentTask';
2073
2074
  const AGENT_RESULT_KEY = 'io.nanobpm.agentResult';
2075
+ // Zeebe/Camunda-parity linked resources (issue #63). At job activation the
2076
+ // engine resolves each declared `<zeebe:linkedResource resourceId … linkName>`
2077
+ // to the LATEST deployed key and delivers this custom header: a JSON array of
2078
+ // `{ resourceKey, resourceType, linkName }`. The header carries the key, not the
2079
+ // content — the worker fetches the bytes over the broker REST API. The entry
2080
+ // whose `linkName` matches DEFAULT_PROMPT_LINK_NAME supplies the agent's base
2081
+ // prompt (live-updatable by redeploying just the resource).
2082
+ const LINKED_RESOURCES_HEADER = 'linkedResources';
2083
+ const DEFAULT_PROMPT_LINK_NAME = 'prompt';
2074
2084
  const TASK_ENVELOPE_SCHEMA_VERSION = 1;
2075
2085
  // The result-envelope version is intentionally independent of the task-envelope
2076
2086
  // version so the two contracts can evolve separately without silently coupling.
@@ -2235,7 +2245,13 @@ function collectEnvelopeFrom(source) {
2235
2245
 
2236
2246
  // Normalize the assembled envelope to schema v1, coercing header string values
2237
2247
  // (element templates write everything as strings) into bool/int as needed.
2238
- function normalizeTaskEnvelope(customHeaders, variables) {
2248
+ //
2249
+ // `opts.basePromptOverride` (issue #63): when a `linkName: prompt` linked
2250
+ // resource resolves, its fetched content is passed here and WINS over the
2251
+ // header-baked `task.prompt` / `variables.prompt` / `variables.task` chain. The
2252
+ // FEEL-composed `appendPrompt` concatenation is unchanged — it still appends to
2253
+ // whatever base was resolved.
2254
+ function normalizeTaskEnvelope(customHeaders, variables, opts = {}) {
2239
2255
  const raw = deepMerge(collectEnvelopeFrom(customHeaders), collectEnvelopeFrom(variables));
2240
2256
  const str = (v) => (v == null ? undefined : String(v));
2241
2257
  const env = {
@@ -2271,9 +2287,12 @@ function normalizeTaskEnvelope(customHeaders, variables) {
2271
2287
  };
2272
2288
 
2273
2289
  const task = isPlainObject(raw.task) ? raw.task : {};
2274
- // Base prompt: the reserved `task.prompt` (typically a model header filled at deploy time),
2275
- // else a plain `prompt`/`task` job variable (the pre-header delivery path).
2276
- const basePrompt = str(task.prompt) ?? str(variables?.prompt) ?? str(variables?.task);
2290
+ // Base prompt: an override supplied by a resolved `linkName: prompt` linked
2291
+ // resource (issue #63) wins; else the reserved `task.prompt` (typically a model
2292
+ // header filled at deploy time), else a plain `prompt`/`task` job variable (the
2293
+ // pre-header delivery path).
2294
+ const overrideBase = opts && opts.basePromptOverride != null ? String(opts.basePromptOverride) : undefined;
2295
+ const basePrompt = overrideBase ?? str(task.prompt) ?? str(variables?.prompt) ?? str(variables?.task);
2277
2296
  // Verbatim dynamic append: a header-delivered base prompt can't be composed in FEEL, so a task
2278
2297
  // may supply per-instance context (e.g. plan-revision feedback, a per-task brief) via the
2279
2298
  // reserved `task.appendPrompt`, or a plain `appendPrompt` variable. It is concatenated onto the
@@ -2296,7 +2315,133 @@ function normalizeTaskEnvelope(customHeaders, variables) {
2296
2315
  return env;
2297
2316
  }
2298
2317
 
2299
- // ---- Secret resolution (pluggable; only host-env implemented for now) ------
2318
+ // ---- Linked resources live agent prompt (issue #63) ----------------------
2319
+ // A job's `linkedResources` activation header carries KEYS (not content); the
2320
+ // worker resolves each to the LATEST deployed bytes over the broker REST API, so
2321
+ // an agent prompt can be updated by redeploying one Markdown resource — no
2322
+ // process redeploy, no harness restart.
2323
+
2324
+ // Parse the `linkedResources` custom header off an activated job. The engine
2325
+ // delivers a JSON array; element-template/header transport stringifies it. Also
2326
+ // tolerate an already-parsed array. Anything malformed/absent → [] (fallback
2327
+ // path). Never throws.
2328
+ function parseLinkedResources(customHeaders) {
2329
+ if (!isPlainObject(customHeaders)) return [];
2330
+ const raw = customHeaders[LINKED_RESOURCES_HEADER];
2331
+ if (raw == null) return [];
2332
+ let val = raw;
2333
+ if (typeof raw === 'string') {
2334
+ if (!raw.trim()) return [];
2335
+ try { val = JSON.parse(raw); } catch { return []; }
2336
+ }
2337
+ if (!Array.isArray(val)) return [];
2338
+ return val.filter((e) => isPlainObject(e) && e.resourceKey != null);
2339
+ }
2340
+
2341
+ // Select the linked resource that supplies the base prompt: the first entry
2342
+ // whose `linkName` matches (default `prompt`). Returns the entry or null.
2343
+ function pickLinkedResource(linkedResources, linkName = DEFAULT_PROMPT_LINK_NAME) {
2344
+ if (!Array.isArray(linkedResources)) return null;
2345
+ return linkedResources.find((e) => isPlainObject(e) && String(e.linkName) === String(linkName)) || null;
2346
+ }
2347
+
2348
+ // The broker REST base URL + optional bearer the harness uses to fetch resource
2349
+ // content — the SAME nano endpoint the worker already talks to (env wins over
2350
+ // persisted config, falling back to the default localhost port). A local nano
2351
+ // cluster is unauthenticated, so the token is optional; when set (e.g. against a
2352
+ // secured gateway) it is sent as a Bearer credential.
2353
+ // Same-origin test for two URLs (protocol + host + port). Returns false if
2354
+ // either string is missing or unparseable, so a token is never forwarded to an
2355
+ // endpoint we can't positively confirm matches.
2356
+ function sameOrigin(a, b) {
2357
+ if (!a || !b) return false;
2358
+ try {
2359
+ return new URL(a).origin === new URL(b).origin;
2360
+ } catch {
2361
+ return false;
2362
+ }
2363
+ }
2364
+
2365
+ function resolveBrokerRestConfig(env = process.env) {
2366
+ // readConfig() swallows parse/IO errors and never throws (returns {}), so no
2367
+ // local try/catch is needed here.
2368
+ const cfg = readConfig() || {};
2369
+ const baseUrl =
2370
+ env.NANO_REST_URL ||
2371
+ env.NANO_BASE_URL ||
2372
+ cfg.nanoUrl ||
2373
+ DEFAULT_NANO_URL;
2374
+ // An explicit REST token always wins. The agentic identity token is only a
2375
+ // fallback for single-token deployments where the broker REST endpoint IS the
2376
+ // agentic endpoint — so only forward it when the REST base URL is same-origin
2377
+ // as the agentic URL. This prevents leaking the identity token to a different
2378
+ // NANO_REST_URL host when no REST token is set (see resolveAgenticConfig).
2379
+ let token = env.NANO_REST_TOKEN || '';
2380
+ if (!token) {
2381
+ const agenticToken = env.NANO_AGENTIC_TOKEN || cfg.agenticToken || '';
2382
+ const agenticUrl =
2383
+ env.NANO_AGENTIC_URL ||
2384
+ cfg.agenticUrl ||
2385
+ cfg.nanoUrl ||
2386
+ env.NANO_BASE_URL ||
2387
+ DEFAULT_NANO_URL;
2388
+ if (agenticToken && sameOrigin(baseUrl, agenticUrl)) token = agenticToken;
2389
+ }
2390
+ return { baseUrl, token };
2391
+ }
2392
+
2393
+ // Build the content endpoint. Per issue #63 / nano-bpm #759 the non-binary
2394
+ // `/content` variant is deprecated for non-RPA types (Markdown → 406), so the
2395
+ // worker always fetches `/content/binary`.
2396
+ function resourceContentUrl(baseUrl, resourceKey) {
2397
+ const base = String(baseUrl || '').replace(/\/+$/, '');
2398
+ return `${base}/v2/resources/${encodeURIComponent(String(resourceKey))}/content/binary`;
2399
+ }
2400
+
2401
+ // Fetch a linked resource's bytes and decode UTF-8. A non-2xx or network error
2402
+ // is surfaced as a ProvisionError so the caller fails the job with a clear
2403
+ // provisioning message (never runs an agent with a silently-empty prompt).
2404
+ async function fetchLinkedResourceContent(resourceKey, opts = {}) {
2405
+ const { baseUrl, token, fetchImpl = fetch, timeoutMs = 15_000 } = opts;
2406
+ const url = resourceContentUrl(baseUrl, resourceKey);
2407
+ const controller = new AbortController();
2408
+ const timer = timeoutMs > 0 ? setTimeout(() => controller.abort(), timeoutMs) : null;
2409
+ let res;
2410
+ try {
2411
+ const headers = { Accept: 'application/octet-stream' };
2412
+ if (token) headers.Authorization = `Bearer ${token}`;
2413
+ res = await fetchImpl(url, { method: 'GET', headers, signal: controller.signal });
2414
+ } catch (err) {
2415
+ throw new ProvisionError(`prompt resource ${resourceKey} fetch failed: ${err && err.message ? err.message : String(err)}`);
2416
+ } finally {
2417
+ if (timer) clearTimeout(timer);
2418
+ }
2419
+ if (!res || !res.ok) {
2420
+ const status = res ? res.status : '?';
2421
+ throw new ProvisionError(`prompt resource ${resourceKey} fetch failed: HTTP ${status} from ${url}`);
2422
+ }
2423
+ try {
2424
+ return await res.text();
2425
+ } catch (err) {
2426
+ throw new ProvisionError(`prompt resource ${resourceKey} decode failed: ${err && err.message ? err.message : String(err)}`);
2427
+ }
2428
+ }
2429
+
2430
+ // Resolve the base prompt from a `linkName: prompt` linked resource, if the job
2431
+ // declares one. Returns `{ basePrompt, resourceKey }` when a prompt resource is
2432
+ // present and fetched, or null when no such entry exists (→ fall back to the
2433
+ // header-baked task.prompt chain). Throws a ProvisionError when a declared
2434
+ // prompt resource cannot be fetched — a provisioning failure, not a silent
2435
+ // empty-prompt run.
2436
+ async function resolveLinkedPrompt(customHeaders, opts = {}) {
2437
+ const { linkName = DEFAULT_PROMPT_LINK_NAME, baseUrl, token, fetchImpl, timeoutMs } = opts;
2438
+ const entry = pickLinkedResource(parseLinkedResources(customHeaders), linkName);
2439
+ if (!entry) return null;
2440
+ const resourceKey = entry.resourceKey;
2441
+ const basePrompt = await fetchLinkedResourceContent(resourceKey, { baseUrl, token, fetchImpl, timeoutMs });
2442
+ return { basePrompt, resourceKey, resourceType: entry.resourceType ?? null, linkName: String(linkName) };
2443
+ }
2444
+
2300
2445
  // Secrets are referenced by NAME, never by value, in the model. A resolver maps
2301
2446
  // a name → value at run time. The wrapper injects them into the child ENV, so
2302
2447
  // values never appear in argv or `docker inspect`.
@@ -3415,7 +3560,7 @@ function runAgentJob(profile, job, opts = {}) {
3415
3560
 
3416
3561
  // Shape the io.nanobpm.agentResult output envelope. When a repository was
3417
3562
  // provisioned (increment 2a), the `git` block adds branch/commits/push/PR facts.
3418
- function buildResultEnvelope(result, { sandbox, image, git, result: agentResult } = {}) {
3563
+ function buildResultEnvelope(result, { sandbox, image, git, result: agentResult, promptResourceKey } = {}) {
3419
3564
  const status = result.ok ? 'completed' : (result.timedOut ? 'timedOut' : 'failed');
3420
3565
  const env = {
3421
3566
  schemaVersion: RESULT_ENVELOPE_SCHEMA_VERSION,
@@ -3429,6 +3574,10 @@ function buildResultEnvelope(result, { sandbox, image, git, result: agentResult
3429
3574
  signal: result.signal ?? null,
3430
3575
  error: result.error ?? null,
3431
3576
  };
3577
+ // Audit (issue #63): record which linked-resource key supplied the base prompt.
3578
+ // The engine only keeps `latest` per resourceId (no pinning), so recording the
3579
+ // resolved key is the only reproducibility handle for which prompt version ran.
3580
+ if (promptResourceKey != null) env.promptResourceKey = String(promptResourceKey);
3432
3581
  // The agent's structured result (as returned via $AGENT_RESULT_FILE / sentinel),
3433
3582
  // preserved verbatim for auditability even when merged into the completion vars.
3434
3583
  if (isPlainObject(agentResult)) env.result = agentResult;
@@ -3457,7 +3606,7 @@ function buildResultEnvelope(result, { sandbox, image, git, result: agentResult
3457
3606
  * and a capability credential are present (enrolment) — absent either, it runs
3458
3607
  * exactly as before, off the visibility page. Returns `null` when not enrolled.
3459
3608
  *
3460
- * @returns {{ url: string, token: string, credential: string } | null}
3609
+ * @returns {{ url: string, token: string, credential: string, bufferCapacity: number } | null}
3461
3610
  */
3462
3611
  function resolveAgenticConfig() {
3463
3612
  const cfg = readConfig();
@@ -3469,7 +3618,13 @@ function resolveAgenticConfig() {
3469
3618
  const token = process.env.NANO_AGENTIC_TOKEN || cfg.agenticToken || '';
3470
3619
  const credential = process.env.NANO_AGENTIC_CREDENTIAL || cfg.agenticCredential || '';
3471
3620
  if (!url || !token || !credential) return null;
3472
- return { url, token, credential };
3621
+ // Outbound hub-down buffer bound (frames). Operator-tunable (C4, #43) so a
3622
+ // long expected outage can be given more headroom; resolveBufferCapacity
3623
+ // validates it to a positive integer and falls back to the client default.
3624
+ const bufferCapacity = resolveBufferCapacity(
3625
+ process.env.NANO_AGENTIC_BUFFER_CAPACITY ?? cfg.agenticBufferCapacity,
3626
+ );
3627
+ return { url, token, credential, bufferCapacity };
3473
3628
  }
3474
3629
 
3475
3630
  /**
@@ -3678,6 +3833,10 @@ async function workAgent(req, flags) {
3678
3833
  const jobTypes = [...new Set([...matrix, ...extraJobTypes])];
3679
3834
  const camunda = globalThis.c8ctl.createClient();
3680
3835
 
3836
+ // Broker REST endpoint for live linked-resource prompts (issue #63) — the same
3837
+ // nano endpoint this worker already talks to. Resolved once at startup.
3838
+ const restConfig = resolveBrokerRestConfig();
3839
+
3681
3840
  logger.info(`Putting "${name}" [${profile.rank}] to work → ${buildAgentCommandLine(profile.command, effectiveArgs)}`);
3682
3841
  logger.info(` worker: ${workerName}`);
3683
3842
  logger.info(` model: ${profile.model || '(none)'}; capabilities: ${profile.capabilities.join(', ') || '(none)'}`);
@@ -3720,6 +3879,8 @@ async function workAgent(req, flags) {
3720
3879
  // recorders can refresh presence with the live job set as jobs start/end.
3721
3880
  /** @type {import('./work-channel.mjs').WorkChannel | null} */
3722
3881
  let workChannel = null;
3882
+ /** @type {import('./work-buffer.mjs').BufferMonitor | null} */
3883
+ let bufferMonitor = null;
3723
3884
  // Maintain `activeJobs` unconditionally: it feeds both the supervisor activity
3724
3885
  // file (gated inside writeActivity) AND the agentic presence frame's live
3725
3886
  // jobKey set, so a standalone worker (no NANO_SUPERVISOR_ACTIVITY_FILE) still
@@ -3763,6 +3924,7 @@ async function workAgent(req, flags) {
3763
3924
  url: agenticCfg.url,
3764
3925
  token: agenticCfg.token,
3765
3926
  credential: agenticCfg.credential,
3927
+ bufferCapacity: agenticCfg.bufferCapacity,
3766
3928
  logger,
3767
3929
  });
3768
3930
  const shown = redactAgenticUrl(buildAgenticUrl(agenticCfg.url, {}));
@@ -3772,6 +3934,23 @@ async function workAgent(req, flags) {
3772
3934
  workChannel = null;
3773
3935
  logger.warn(` agentic channel unavailable (${err?.message || err}); continuing without visibility.`);
3774
3936
  }
3937
+ // C4 (#43): observe the client's built-in outbound buffer across the
3938
+ // channel lifecycle — surface a high-water mark and warn when the bound
3939
+ // is hit so a hub outage that starts shedding frames is never silent. The
3940
+ // monitor is observability-only, so keep it OUTSIDE the channel try/catch:
3941
+ // a monitor failure must never null out a healthy channel and take down
3942
+ // presence/visibility.
3943
+ if (workChannel) {
3944
+ try {
3945
+ bufferMonitor = createBufferMonitor(workChannel, {
3946
+ capacity: agenticCfg.bufferCapacity,
3947
+ logger,
3948
+ });
3949
+ } catch (err) {
3950
+ bufferMonitor = null;
3951
+ logger.warn(` agentic buffer monitor unavailable (${err?.message || err}); channel presence still active.`);
3952
+ }
3953
+ }
3775
3954
  } else {
3776
3955
  logger.info(' agentic channel: not enrolled (set NANO_AGENTIC_URL + NANO_AGENTIC_TOKEN + NANO_AGENTIC_CREDENTIAL to appear on the visibility page).');
3777
3956
  }
@@ -3826,9 +4005,33 @@ async function workAgent(req, flags) {
3826
4005
  }
3827
4006
  }
3828
4007
 
4008
+ // Live agent prompt (issue #63): if the job declares a `linkName: prompt`
4009
+ // linked resource, fetch its LATEST deployed content and use it as the
4010
+ // base prompt (it wins over the header-baked task.prompt). A declared
4011
+ // prompt resource that can't be fetched is a provisioning failure — fail
4012
+ // (retryable) rather than run an agent with an empty prompt.
4013
+ let promptResourceKey = null;
4014
+ let basePromptOverride;
4015
+ try {
4016
+ const linked = await resolveLinkedPrompt(job.customHeaders ?? {}, {
4017
+ baseUrl: restConfig.baseUrl,
4018
+ token: restConfig.token,
4019
+ });
4020
+ if (linked) {
4021
+ basePromptOverride = linked.basePrompt;
4022
+ promptResourceKey = linked.resourceKey;
4023
+ logger.info(`[${jobType}] job ${job.jobKey} base prompt from linked resource key ${promptResourceKey} (linkName=${linked.linkName}, ${Buffer.byteLength(String(basePromptOverride), 'utf8')} bytes)`);
4024
+ }
4025
+ } catch (err) {
4026
+ const retries = Math.max(0, (Number(job.retries) || 1) - 1);
4027
+ const msg = err instanceof ProvisionError ? err.message : `prompt resource fetch failed: ${err.message}`;
4028
+ logger.warn(`[${jobType}] job ${job.jobKey} not provisioned — ${msg}; retries left ${retries}`);
4029
+ return job.fail({ errorMessage: msg.slice(0, 2000), retries, retryBackOff: 15_000 });
4030
+ }
4031
+
3829
4032
  // Assemble + normalize the task envelope from headers (defaults) and
3830
4033
  // variables (overrides), then resolve any secrets it references.
3831
- const envelope = normalizeTaskEnvelope(job.customHeaders ?? {}, job.variables ?? {});
4034
+ const envelope = normalizeTaskEnvelope(job.customHeaders ?? {}, job.variables ?? {}, { basePromptOverride });
3832
4035
  const { resolved, missing, names } = resolveJobSecrets(secretResolver, envelope);
3833
4036
  if (missing.length > 0) {
3834
4037
  const retries = Math.max(0, (Number(job.retries) || 1) - 1);
@@ -3973,7 +4176,7 @@ async function workAgent(req, flags) {
3973
4176
  if (resultDir) { try { rmSync(resultDir, { recursive: true, force: true }); } catch { /* best effort */ } liveRunDirs.delete(resultDir); }
3974
4177
  const resultVars = sanitizeResultVars(rawResult);
3975
4178
 
3976
- const resultEnvelope = buildResultEnvelope(result, { sandbox, image, git: gitResult, result: rawResult });
4179
+ const resultEnvelope = buildResultEnvelope(result, { sandbox, image, git: gitResult, result: rawResult, promptResourceKey });
3977
4180
  if (result.ok) {
3978
4181
  const gitNote = gitResult
3979
4182
  ? ` [${gitResult.branch ? `branch ${gitResult.branch}` : 'detached HEAD'}: ${gitResult.commits.length} commit(s), ${gitResult.branch ? (gitResult.pushed ? 'pushed' : (gitResult.pushError ? 'push FAILED' : 'not pushed')) : 'no branch to push'}${gitResult.pr?.found ? `, PR #${gitResult.pr.number}` : ''}]`
@@ -4189,6 +4392,10 @@ async function workAgent(req, flags) {
4189
4392
  // from the page only once its jobs have drained. Best-effort — a channel
4190
4393
  // teardown must never hang shutdown.
4191
4394
  if (workChannel) {
4395
+ // Stop the buffer monitor first so its sampler can't fire mid-teardown.
4396
+ try {
4397
+ bufferMonitor?.stop();
4398
+ } catch { /* best effort */ }
4192
4399
  try {
4193
4400
  await workChannel.stop(`worker stopped (${signal})`);
4194
4401
  logger.info('Deregistered from the agentic visibility channel.');
@@ -6863,6 +7070,12 @@ export {
6863
7070
  export {
6864
7071
  normalizeTaskEnvelope,
6865
7072
  collectEnvelopeFrom,
7073
+ parseLinkedResources,
7074
+ pickLinkedResource,
7075
+ resolveBrokerRestConfig,
7076
+ resourceContentUrl,
7077
+ fetchLinkedResourceContent,
7078
+ resolveLinkedPrompt,
6866
7079
  coerceBool,
6867
7080
  coerceInt,
6868
7081
  deepMerge,
@@ -6909,6 +7122,8 @@ export {
6909
7122
  derivePollTimeoutMs,
6910
7123
  AGENT_TASK_NS,
6911
7124
  AGENT_RESULT_KEY,
7125
+ LINKED_RESOURCES_HEADER,
7126
+ DEFAULT_PROMPT_LINK_NAME,
6912
7127
  RESULT_SENTINEL,
6913
7128
  RESERVED_RESULT_KEYS,
6914
7129
  SANDBOXES,
@@ -6982,9 +7197,11 @@ export const metadata = {
6982
7197
  { command: 'c8ctl nano hire --name coder --rank senior --command copilot --env COPILOT_ENABLE_ALL_TOOLS=1', description: 'Persist a harness startup env var (e.g. permissions) on the profile' },
6983
7198
  { command: 'c8ctl nano hire --list', description: 'List hired agent profiles' },
6984
7199
  { command: 'c8ctl nano hire --name coder --rank senior --command "agent-harness" --sandbox docker --image ghcr.io/acme/agent:1', description: 'Create a profile that runs each job in a throwaway Docker container' },
7200
+ { command: 'c8ctl nano hire --name coder --rank senior --command copilot --terminal pty', description: 'Opt this role into a full, steerable live terminal (PTY) streamed on the agentic relay lane (default: pipe)' },
6985
7201
  { command: 'c8ctl nano assign reviewer code-review,testing', description: 'Grant more capabilities (comma-separated, like hire) to an existing hire — additive; running workers hot-reload it' },
6986
7202
  { command: 'c8ctl nano work reviewer', description: 'Spawn Nano job workers for the "reviewer" profile and poll for work' },
6987
7203
  { command: 'c8ctl nano work coder --sandbox docker --image ghcr.io/acme/agent:1', description: 'Run jobs in isolated containers with disk-hygiene reaping' },
7204
+ { command: 'NANO_AGENTIC_URL=http://localhost:8080 NANO_AGENTIC_TOKEN=<identity-token> NANO_AGENTIC_CREDENTIAL=<capability-cred> c8ctl nano work reviewer', description: 'Enrol the worker on the app\'s same-port /agentic channel so it appears live (presence + relay terminals) on the Workforce visibility page' },
6988
7205
  { command: 'c8ctl nano supervisor start --worker reviewer --worker coder', description: 'Start a detached supervisor managing several workers from one terminal' },
6989
7206
  { command: 'c8ctl nano supervisor', description: 'Attach an interactive console to the supervisor (detach with Ctrl-D, leaving it running)' },
6990
7207
  { command: 'c8ctl nano supervisor status', description: 'List supervised workers (pid, state, serviced job / idle, restarts, uptime) without the console' },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "c8ctl-plugin-nano",
3
- "version": "1.29.0",
3
+ "version": "1.31.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",
@@ -26,6 +26,7 @@
26
26
  "agentic-loader-hook.mjs",
27
27
  "work-channel.mjs",
28
28
  "work-relay.mjs",
29
+ "work-buffer.mjs",
29
30
  "nanobpmn-binary.json",
30
31
  "README.md"
31
32
  ],
@@ -56,12 +57,12 @@
56
57
  },
57
58
  "optionalDependencies": {
58
59
  "node-pty": "^1.0.0",
59
- "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.29.0",
60
- "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.29.0",
61
- "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.29.0",
62
- "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.29.0",
63
- "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.29.0",
64
- "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.29.0",
65
- "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.29.0"
60
+ "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.31.0",
61
+ "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.31.0",
62
+ "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.31.0",
63
+ "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.31.0",
64
+ "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.31.0",
65
+ "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.31.0",
66
+ "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.31.0"
66
67
  }
67
68
  }
@@ -0,0 +1,331 @@
1
+ // The `work` command's hub-down buffer observability + policy layer
2
+ // (ADR 0056 — slice C4, jwulf/c8ctl-plugin-nano#43).
3
+ //
4
+ // C4 makes a running worker survive hub disconnects: while the app hub is
5
+ // unreachable (the worker started before the app, or the hub restarted) the
6
+ // worker keeps producing frames, and they drain — bounded and in order — when
7
+ // the channel comes back.
8
+ //
9
+ // DERIVATION OVER DUPLICATION. The bounded local buffer this slice is about
10
+ // ALREADY exists as the connected client's built-in `OutboundRing` (in
11
+ // `@nanobpm/urban-agent-client`): a QoS-aware, capacity-bounded ring that holds
12
+ // every outbound frame while the socket is down and drains in strict lane
13
+ // priority (control → interactive → bulk, FIFO within a lane) on reconnect,
14
+ // shedding the single least-important frame on overflow. It sits at the
15
+ // TRANSPORT seam — below the lanes — so it captures any lane's frames and is
16
+ // therefore independent of C3's relay producer. We do NOT re-declare a second
17
+ // ring here (that would be a parallel, drift-prone buffer over the same
18
+ // frames); we CONSUME the canonical one through C2's `WorkChannel` seam.
19
+ //
20
+ // What this slice actually adds over C2's client is the two things the built-in
21
+ // ring leaves implicit:
22
+ //
23
+ // 1. The bound is OPERATOR-CONFIGURABLE, not a buried literal — see
24
+ // {@link resolveBufferCapacity} (wired to `NANO_AGENTIC_BUFFER_CAPACITY`
25
+ // in `resolveAgenticConfig`), so a long expected outage can be given more
26
+ // headroom without a code change.
27
+ // 2. The drop/backpressure policy is OBSERVABLE. The client sheds overflow
28
+ // frames silently (`relay()` returns void; the evicted frame is dropped
29
+ // inside the ring). {@link createBufferMonitor} turns that silent bound
30
+ // into a visible signal: it watches the buffer depth across C2's
31
+ // connect / disconnect / reconnect lifecycle, records a high-water mark
32
+ // and each outage→flush, and warns when the bound is hit so a hit bound is
33
+ // never silent data loss.
34
+ //
35
+ // The monitor is driven ENTIRELY by C2's lifecycle events + the client's
36
+ // buffer-drained event; it never opens, authenticates, or re-instantiates the
37
+ // channel, and it never produces frames of its own. It is pure observation over
38
+ // the one connected client.
39
+
40
+ // The outbound-ring bound (frames) the client buffers while the hub is
41
+ // unreachable. Single-sourced from the transport seam (`work-channel.mjs`),
42
+ // which applies it to the client, so the "falls back to the client default"
43
+ // contract stays accurate from one edit — no drift-prone second literal here.
44
+ import { DEFAULT_BUFFER_CAPACITY } from './work-channel.mjs';
45
+
46
+ const DEFAULT_SAMPLE_INTERVAL_MS = 1_000;
47
+
48
+ export { DEFAULT_BUFFER_CAPACITY };
49
+
50
+ /**
51
+ * Resolve the outbound-buffer bound (in frames) from an operator-supplied value
52
+ * with a sane fallback. The bound MUST be a positive integer — the client's
53
+ * `OutboundRing` throws on a non-positive capacity, so we validate here and fall
54
+ * back rather than let a typo wedge enrolment. Accepts a number or a numeric
55
+ * string (env vars arrive as strings).
56
+ *
57
+ * @param {unknown} raw the operator value (e.g. `process.env.NANO_AGENTIC_BUFFER_CAPACITY`)
58
+ * @param {number} [fallback] the default when `raw` is absent/invalid
59
+ * @returns {number} a positive-integer frame bound
60
+ */
61
+ export function resolveBufferCapacity(raw, fallback = DEFAULT_BUFFER_CAPACITY) {
62
+ const base = Number.isInteger(fallback) && fallback > 0 ? fallback : DEFAULT_BUFFER_CAPACITY;
63
+ if (raw === undefined || raw === null || raw === '') return base;
64
+ const n = typeof raw === 'number' ? raw : Number(String(raw).trim());
65
+ if (!Number.isInteger(n) || n < 1) return base;
66
+ return n;
67
+ }
68
+
69
+ /**
70
+ * @typedef {object} BufferHealth
71
+ * @property {number} capacity the configured frame bound
72
+ * @property {number} buffered frames currently held awaiting a live channel
73
+ * @property {boolean} connected whether the channel is currently open
74
+ * @property {number} highWaterMark the deepest buffer depth observed
75
+ * @property {number} outages number of times the channel went from up→down (buffering began)
76
+ * @property {number} reconnects number of times the channel recovered (up again after a drop)
77
+ * @property {number} flushes number of outage backlogs that fully drained on (re)connect
78
+ * @property {number} lastFlushFrames backlog size captured at the (re)connect that drove the last flush
79
+ * @property {number|null} lastFlushAt timestamp (ms) the last flush completed, or null
80
+ * @property {number} atCapacityEvents times a sample found the buffer at/over its bound (overflow shedding)
81
+ * @property {boolean} atCapacity whether the last sample was at/over the bound
82
+ */
83
+
84
+ /**
85
+ * @typedef {object} BufferMonitor
86
+ * @property {() => BufferHealth} health snapshot of the buffer's current health/metrics
87
+ * @property {() => number} sample take a depth sample now (updates high-water / at-capacity); returns the depth
88
+ * @property {() => void} stop detach all listeners and stop sampling (idempotent)
89
+ */
90
+
91
+ /**
92
+ * Observe the connected client's built-in outbound buffer across C2's channel
93
+ * lifecycle and surface its health + the (otherwise silent) drop policy.
94
+ *
95
+ * The monitor:
96
+ * - reads live depth via `channel.buffered()` (the client's `OutboundRing`
97
+ * size) — it does not hold its own buffer;
98
+ * - on the FIRST connect and every RECONNECT, captures the backlog about to
99
+ * flush (the client fires `onOpen`/our lifecycle listeners BEFORE it pumps
100
+ * the ring, so the depth read here is the pre-drain backlog) and, when the
101
+ * client's `onDrain` then fires (ring emptied after sending), records the
102
+ * completed flush;
103
+ * - on DISCONNECT (or immediately, if the worker starts before the app and is
104
+ * not connected yet), enters an "outage" and samples depth periodically so a
105
+ * growing backlog that hits the bound is noticed and warned about;
106
+ * - keeps a high-water mark and an at-capacity counter, warning (once per
107
+ * transition into the at-capacity state, to avoid log spam) so operators see
108
+ * that the bound is shedding frames.
109
+ *
110
+ * @param {import('./work-channel.mjs').WorkChannel} channel the C2 seam holder
111
+ * @param {object} [opts]
112
+ * @param {number} [opts.capacity] the configured bound (for health/at-capacity); defaults to DEFAULT_BUFFER_CAPACITY
113
+ * @param {number} [opts.sampleIntervalMs] periodic depth-sample cadence while in an outage; <=0 disables the timer
114
+ * @param {{ warn?: Function, info?: Function, debug?: Function }} [opts.logger] optional logger
115
+ * @param {() => number} [opts.now] injectable clock (tests); defaults to Date.now
116
+ * @param {{ setInterval: Function, clearInterval: Function }} [opts.timers] injectable timers (tests)
117
+ * @returns {BufferMonitor}
118
+ */
119
+ export function createBufferMonitor(channel, opts = {}) {
120
+ if (!channel || typeof channel.buffered !== 'function') {
121
+ throw new Error('createBufferMonitor requires a WorkChannel with a buffered() accessor');
122
+ }
123
+ const capacity = resolveBufferCapacity(opts.capacity, DEFAULT_BUFFER_CAPACITY);
124
+ const sampleIntervalMs = Number.isFinite(opts.sampleIntervalMs)
125
+ ? opts.sampleIntervalMs
126
+ : DEFAULT_SAMPLE_INTERVAL_MS;
127
+ const log = opts.logger || {};
128
+ const now = typeof opts.now === 'function' ? opts.now : () => Date.now();
129
+ const timers = opts.timers || { setInterval, clearInterval };
130
+
131
+ const state = {
132
+ highWaterMark: 0,
133
+ outages: 0,
134
+ reconnects: 0,
135
+ flushes: 0,
136
+ lastFlushFrames: 0,
137
+ lastFlushAt: /** @type {number|null} */ (null),
138
+ atCapacityEvents: 0,
139
+ atCapacity: false,
140
+ };
141
+
142
+ // `buffering` is true while we believe frames are queued for a hub that is
143
+ // down — set at disconnect (and at creation if we start disconnected), and
144
+ // cleared when the outage's backlog finishes flushing (the client's next
145
+ // onDrain) or when a (re)connect finds nothing was buffered. The client fires
146
+ // our lifecycle connect/reconnect listeners BEFORE it pumps the ring (and thus
147
+ // before its buffer-drained event), so settleOnOpen captures the pre-drain
148
+ // backlog into `outageBacklogPeak` and the subsequent onDrain records it.
149
+ // `outageBacklogPeak` is the deepest the buffer got during the current
150
+ // outage — what we report as the flushed frame count.
151
+ let buffering = false;
152
+ let outageBacklogPeak = 0;
153
+ let sampleTimer = null;
154
+ let stopped = false;
155
+
156
+ const depth = () => {
157
+ try {
158
+ return Number(channel.buffered()) || 0;
159
+ } catch {
160
+ return 0;
161
+ }
162
+ };
163
+
164
+ /** Take a depth sample: update the high-water mark, the per-outage peak, and
165
+ * the at-capacity signal. */
166
+ const sample = () => {
167
+ const d = depth();
168
+ if (d > state.highWaterMark) state.highWaterMark = d;
169
+ if (buffering && d > outageBacklogPeak) outageBacklogPeak = d;
170
+ const atCap = d >= capacity;
171
+ if (atCap) {
172
+ state.atCapacityEvents += 1;
173
+ if (!state.atCapacity) {
174
+ // Transition into the at-capacity state — warn ONCE so the operator sees
175
+ // the bound is full and further low-priority frames may be dropped
176
+ // (bulk relay before interactive before control), but we don't spam
177
+ // every sample. We only observe depth, so we don't assert a drop has
178
+ // already happened: depth can reach capacity before any overflow.
179
+ try {
180
+ log.warn?.(
181
+ `agentic outbound buffer full (${d}/${capacity} frames): the hub is unreachable and further low-priority frames may be dropped until it reconnects. Raise NANO_AGENTIC_BUFFER_CAPACITY for a longer expected outage.`,
182
+ );
183
+ } catch {
184
+ /* a logger failure must never break sampling */
185
+ }
186
+ }
187
+ }
188
+ state.atCapacity = atCap;
189
+ return d;
190
+ };
191
+
192
+ const startSampler = () => {
193
+ if (stopped || sampleTimer !== null || sampleIntervalMs <= 0) return;
194
+ sampleTimer = timers.setInterval(() => sample(), sampleIntervalMs);
195
+ // Don't keep the event loop alive just to sample a buffer.
196
+ if (sampleTimer && typeof sampleTimer.unref === 'function') sampleTimer.unref();
197
+ };
198
+ const stopSampler = () => {
199
+ if (sampleTimer !== null) {
200
+ timers.clearInterval(sampleTimer);
201
+ sampleTimer = null;
202
+ }
203
+ };
204
+
205
+ // Enter an outage: begin (or continue) buffering and start watching depth.
206
+ const beginOutage = () => {
207
+ buffering = true;
208
+ outageBacklogPeak = 0;
209
+ state.atCapacity = false;
210
+ startSampler();
211
+ sample();
212
+ };
213
+
214
+ // A (re)connect happened. The client fires this listener BEFORE it pumps the
215
+ // ring, so depth() here is the pre-drain backlog (captured below); the
216
+ // client's onDrain then fires and records the completed flush. If we are still
217
+ // buffering and the buffer is already empty, the outage carried nothing to
218
+ // flush — just clear it.
219
+ const settleOnOpen = () => {
220
+ stopSampler();
221
+ // Capture the pre-drain backlog. The client fires our connect/reconnect
222
+ // listeners BEFORE it pumps the ring (see the module header), so depth()
223
+ // here is the backlog about to flush. Recording it into the outage peak
224
+ // makes the flush count (onDrain) reflect the real drained depth even when
225
+ // no periodic sample happened to catch the peak — under production
226
+ // timer-based sampling a short outage would otherwise leave the peak at 0
227
+ // and fall back to 1.
228
+ const d = depth();
229
+ if (d > state.highWaterMark) state.highWaterMark = d;
230
+ if (buffering && d > outageBacklogPeak) outageBacklogPeak = d;
231
+ if (buffering && d === 0) {
232
+ buffering = false;
233
+ outageBacklogPeak = 0;
234
+ }
235
+ state.atCapacity = false;
236
+ };
237
+
238
+ const unsub = [];
239
+
240
+ // First connect (worker-before-app: the pre-app backlog flushes here too).
241
+ unsub.push(
242
+ channel.onConnect(() => {
243
+ settleOnOpen();
244
+ }),
245
+ );
246
+ // Every recovery after a drop (hub restart / transient outage).
247
+ unsub.push(
248
+ channel.onReconnect(() => {
249
+ state.reconnects += 1;
250
+ settleOnOpen();
251
+ }),
252
+ );
253
+ // The channel went down: begin (or continue) buffering; sample the backlog as
254
+ // it grows so a bound hit is noticed even during a long outage.
255
+ unsub.push(
256
+ channel.onDisconnect(() => {
257
+ state.outages += 1;
258
+ beginOutage();
259
+ }),
260
+ );
261
+ // The client's outbound ring emptied after sending: if we were flushing an
262
+ // outage backlog, the drain is now complete.
263
+ if (channel.client && typeof channel.client.onDrain === 'function') {
264
+ unsub.push(
265
+ channel.client.onDrain(() => {
266
+ if (buffering) {
267
+ state.flushes += 1;
268
+ state.lastFlushFrames = Math.max(outageBacklogPeak, 1);
269
+ state.lastFlushAt = now();
270
+ buffering = false;
271
+ outageBacklogPeak = 0;
272
+ stopSampler();
273
+ }
274
+ }),
275
+ );
276
+ }
277
+
278
+ // Worker-before-app: if we're created while the channel is still down, we are
279
+ // already buffering — start sampling immediately so a pre-connect bound hit is
280
+ // observed and the first-connect drain is recorded as a flush.
281
+ let connectedNow = false;
282
+ try {
283
+ connectedNow = typeof channel.connected === 'function' ? Boolean(channel.connected()) : false;
284
+ } catch {
285
+ connectedNow = false;
286
+ }
287
+ if (!connectedNow) {
288
+ buffering = true;
289
+ outageBacklogPeak = 0;
290
+ startSampler();
291
+ sample();
292
+ }
293
+
294
+ return {
295
+ health() {
296
+ return {
297
+ capacity,
298
+ buffered: depth(),
299
+ connected: (() => {
300
+ try {
301
+ return typeof channel.connected === 'function' ? Boolean(channel.connected()) : false;
302
+ } catch {
303
+ return false;
304
+ }
305
+ })(),
306
+ highWaterMark: state.highWaterMark,
307
+ outages: state.outages,
308
+ reconnects: state.reconnects,
309
+ flushes: state.flushes,
310
+ lastFlushFrames: state.lastFlushFrames,
311
+ lastFlushAt: state.lastFlushAt,
312
+ atCapacityEvents: state.atCapacityEvents,
313
+ atCapacity: state.atCapacity,
314
+ };
315
+ },
316
+ sample,
317
+ stop() {
318
+ if (stopped) return;
319
+ stopped = true;
320
+ stopSampler();
321
+ for (const off of unsub) {
322
+ try {
323
+ off?.();
324
+ } catch {
325
+ /* best effort */
326
+ }
327
+ }
328
+ unsub.length = 0;
329
+ },
330
+ };
331
+ }
package/work-channel.mjs CHANGED
@@ -36,6 +36,7 @@ const DEFAULT_HEARTBEAT_MS = 10_000;
36
36
  // before the app from losing its early presence/relay frames.
37
37
  const DEFAULT_BUFFER_CAPACITY = 1024;
38
38
 
39
+ export { DEFAULT_BUFFER_CAPACITY };
39
40
  /**
40
41
  * Build the worker's agentic-channel WebSocket URL from the app's HTTP base URL
41
42
  * plus the ADR 0028 identity token and capability credential, carried as query