c8ctl-plugin-nano 1.30.0 → 1.32.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.
Files changed (3) hide show
  1. package/README.md +95 -2
  2. package/c8ctl-plugin.js +403 -35
  3. package/package.json +8 -8
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
@@ -612,7 +696,7 @@ release onto a machine that already has nano installed:
612
696
 
613
697
  ```bash
614
698
  c8ctl nano update # check npm for a newer release and install it
615
- c8ctl nano update --check # only report whether an update is available
699
+ c8ctl nano update --check # report whether an update is available (no install)
616
700
  ```
617
701
 
618
702
  `update` compares the installed plugin version against the latest published on
@@ -622,6 +706,15 @@ with it. It only ever drives npm — it never touches the private upstream sourc
622
706
  so it works for any npm-installed user. After updating, restart any running
623
707
  cluster (`c8ctl nano restart`) so it picks up the new binary.
624
708
 
709
+ Whenever an update is available, `update` (and `update --check`) also prints a
710
+ **changelog of what changed since the installed release** — the per-version
711
+ "Features" / "Bug Fixes" notes pulled from the plugin's public
712
+ [GitHub Releases](https://github.com/jwulf/c8ctl-plugin-nano/releases) (where
713
+ semantic-release records them). This lookup is best-effort and non-blocking: if
714
+ GitHub is unreachable or rate-limited it degrades to a link to the releases page
715
+ and the update proceeds normally. Set `GH_TOKEN` (or `GITHUB_TOKEN`) to raise the
716
+ unauthenticated API rate limit.
717
+
625
718
  If the plugin is running from a local checkout rather than a global npm install,
626
719
  `update` prints the manual command instead of reinstalling in place.
627
720
 
package/c8ctl-plugin.js CHANGED
@@ -2072,6 +2072,15 @@ function killTree(child) {
2072
2072
  // envelope is written back on the job's completion variables.
2073
2073
  const AGENT_TASK_NS = 'io.nanobpm.agentTask';
2074
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';
2075
2084
  const TASK_ENVELOPE_SCHEMA_VERSION = 1;
2076
2085
  // The result-envelope version is intentionally independent of the task-envelope
2077
2086
  // version so the two contracts can evolve separately without silently coupling.
@@ -2236,7 +2245,13 @@ function collectEnvelopeFrom(source) {
2236
2245
 
2237
2246
  // Normalize the assembled envelope to schema v1, coercing header string values
2238
2247
  // (element templates write everything as strings) into bool/int as needed.
2239
- 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 = {}) {
2240
2255
  const raw = deepMerge(collectEnvelopeFrom(customHeaders), collectEnvelopeFrom(variables));
2241
2256
  const str = (v) => (v == null ? undefined : String(v));
2242
2257
  const env = {
@@ -2272,9 +2287,12 @@ function normalizeTaskEnvelope(customHeaders, variables) {
2272
2287
  };
2273
2288
 
2274
2289
  const task = isPlainObject(raw.task) ? raw.task : {};
2275
- // Base prompt: the reserved `task.prompt` (typically a model header filled at deploy time),
2276
- // else a plain `prompt`/`task` job variable (the pre-header delivery path).
2277
- 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);
2278
2296
  // Verbatim dynamic append: a header-delivered base prompt can't be composed in FEEL, so a task
2279
2297
  // may supply per-instance context (e.g. plan-revision feedback, a per-task brief) via the
2280
2298
  // reserved `task.appendPrompt`, or a plain `appendPrompt` variable. It is concatenated onto the
@@ -2297,7 +2315,133 @@ function normalizeTaskEnvelope(customHeaders, variables) {
2297
2315
  return env;
2298
2316
  }
2299
2317
 
2300
- // ---- 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
+
2301
2445
  // Secrets are referenced by NAME, never by value, in the model. A resolver maps
2302
2446
  // a name → value at run time. The wrapper injects them into the child ENV, so
2303
2447
  // values never appear in argv or `docker inspect`.
@@ -3416,7 +3560,7 @@ function runAgentJob(profile, job, opts = {}) {
3416
3560
 
3417
3561
  // Shape the io.nanobpm.agentResult output envelope. When a repository was
3418
3562
  // provisioned (increment 2a), the `git` block adds branch/commits/push/PR facts.
3419
- function buildResultEnvelope(result, { sandbox, image, git, result: agentResult } = {}) {
3563
+ function buildResultEnvelope(result, { sandbox, image, git, result: agentResult, promptResourceKey } = {}) {
3420
3564
  const status = result.ok ? 'completed' : (result.timedOut ? 'timedOut' : 'failed');
3421
3565
  const env = {
3422
3566
  schemaVersion: RESULT_ENVELOPE_SCHEMA_VERSION,
@@ -3430,6 +3574,10 @@ function buildResultEnvelope(result, { sandbox, image, git, result: agentResult
3430
3574
  signal: result.signal ?? null,
3431
3575
  error: result.error ?? null,
3432
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);
3433
3581
  // The agent's structured result (as returned via $AGENT_RESULT_FILE / sentinel),
3434
3582
  // preserved verbatim for auditability even when merged into the completion vars.
3435
3583
  if (isPlainObject(agentResult)) env.result = agentResult;
@@ -3685,6 +3833,10 @@ async function workAgent(req, flags) {
3685
3833
  const jobTypes = [...new Set([...matrix, ...extraJobTypes])];
3686
3834
  const camunda = globalThis.c8ctl.createClient();
3687
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
+
3688
3840
  logger.info(`Putting "${name}" [${profile.rank}] to work → ${buildAgentCommandLine(profile.command, effectiveArgs)}`);
3689
3841
  logger.info(` worker: ${workerName}`);
3690
3842
  logger.info(` model: ${profile.model || '(none)'}; capabilities: ${profile.capabilities.join(', ') || '(none)'}`);
@@ -3853,9 +4005,33 @@ async function workAgent(req, flags) {
3853
4005
  }
3854
4006
  }
3855
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
+
3856
4032
  // Assemble + normalize the task envelope from headers (defaults) and
3857
4033
  // variables (overrides), then resolve any secrets it references.
3858
- const envelope = normalizeTaskEnvelope(job.customHeaders ?? {}, job.variables ?? {});
4034
+ const envelope = normalizeTaskEnvelope(job.customHeaders ?? {}, job.variables ?? {}, { basePromptOverride });
3859
4035
  const { resolved, missing, names } = resolveJobSecrets(secretResolver, envelope);
3860
4036
  if (missing.length > 0) {
3861
4037
  const retries = Math.max(0, (Number(job.retries) || 1) - 1);
@@ -4000,7 +4176,7 @@ async function workAgent(req, flags) {
4000
4176
  if (resultDir) { try { rmSync(resultDir, { recursive: true, force: true }); } catch { /* best effort */ } liveRunDirs.delete(resultDir); }
4001
4177
  const resultVars = sanitizeResultVars(rawResult);
4002
4178
 
4003
- const resultEnvelope = buildResultEnvelope(result, { sandbox, image, git: gitResult, result: rawResult });
4179
+ const resultEnvelope = buildResultEnvelope(result, { sandbox, image, git: gitResult, result: rawResult, promptResourceKey });
4004
4180
  if (result.ok) {
4005
4181
  const gitNote = gitResult
4006
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}` : ''}]`
@@ -5613,6 +5789,183 @@ function compareSemver(a, b) {
5613
5789
  return 0;
5614
5790
  }
5615
5791
 
5792
+ // ---------------------------------------------------------------------------
5793
+ // Update changelog. `update --check` (and the pre-pull path of a real update)
5794
+ // shows what changed between the installed release and latest. The authoritative
5795
+ // source is this plugin's PUBLIC GitHub Releases — semantic-release records the
5796
+ // generated notes there (@semantic-release/github). The committed CHANGELOG.md
5797
+ // is deliberately NOT maintained (the release config dropped the changelog/git
5798
+ // plugins so it never pushes to the protected `main`) and isn't even in the npm
5799
+ // `files`, so it can't be the source. Every lookup here is best-effort and
5800
+ // non-blocking: any failure (offline, rate-limited, private) degrades to a link,
5801
+ // never to a failed `update`.
5802
+ // ---------------------------------------------------------------------------
5803
+
5804
+ /** `owner/repo` parsed from the plugin package's `repository` field (null if absent). */
5805
+ function githubRepoSlug() {
5806
+ try {
5807
+ const pkg = JSON.parse(readFileSync(join(pluginDir, 'package.json'), 'utf8'));
5808
+ const raw = pkg?.repository?.url ?? (typeof pkg?.repository === 'string' ? pkg.repository : '');
5809
+ const m = String(raw).match(/github\.com[/:]([^/\s]+\/[^/\s]+?)(?:\.git)?(?:[/#?].*)?$/i);
5810
+ return m ? m[1] : null;
5811
+ } catch {
5812
+ return null;
5813
+ }
5814
+ }
5815
+
5816
+ /**
5817
+ * Keep only the releases strictly newer than `currentVersion` and no newer than
5818
+ * `latestVersion` (when known), newest-first. Pure over its `releases` input (an
5819
+ * array of GitHub release objects) so it is unit-testable without a network call.
5820
+ */
5821
+ function filterReleasesSince(releases, currentVersion, latestVersion) {
5822
+ if (!Array.isArray(releases)) return [];
5823
+ const items = [];
5824
+ for (const r of releases) {
5825
+ if (!r || r.draft || r.prerelease) continue;
5826
+ const tag = r.tag_name || r.name || '';
5827
+ const norm = String(tag).replace(/^v/, '');
5828
+ // Require a plain vX.Y.Z tag: a prerelease/build suffix (e.g. -rc.1, +meta)
5829
+ // must exclude the release rather than be normalised away into the window.
5830
+ if (!/^\d+\.\d+\.\d+$/.test(norm)) continue;
5831
+ const ver = norm;
5832
+ if (!currentVersion || compareSemver(ver, currentVersion) <= 0) continue;
5833
+ if (latestVersion && compareSemver(ver, latestVersion) > 0) continue;
5834
+ items.push({ version: ver, tag, body: r.body || '', url: r.html_url || '' });
5835
+ }
5836
+ items.sort((a, b) => compareSemver(b.version, a.version));
5837
+ return items;
5838
+ }
5839
+
5840
+ /**
5841
+ * Render one semantic-release release body to tight terminal lines: drop the
5842
+ * redundant `# [x.y.z](…)` header, turn `### Features` into a `Features:` label,
5843
+ * flatten `* **scope:** subject ([abc](url))` bullets to `• scope: subject`
5844
+ * (stripping any `([label](url))` commit/PR link groups and inlining any
5845
+ * remaining `[text](url)` as its text). Returns an array of already-indented lines.
5846
+ */
5847
+ function renderReleaseBody(body) {
5848
+ const out = [];
5849
+ for (const line of String(body).split(/\r?\n/)) {
5850
+ if (/^#{1,2}\s+\[?\d+\.\d+\.\d+/.test(line)) continue; // redundant version header
5851
+ const heading = line.match(/^#{2,3}\s+(.*\S)\s*$/);
5852
+ if (heading) {
5853
+ out.push(` ${heading[1]}:`);
5854
+ continue;
5855
+ }
5856
+ const bullet = line.match(/^\s*[*-]\s+(.*)$/);
5857
+ if (bullet) {
5858
+ let text = bullet[1]
5859
+ .replace(/\s*\(\[[^\]]*\]\([^)]*\)\)/g, '') // ([label](url)) commit/PR link groups
5860
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') // inline [text](url) -> text
5861
+ .replace(/\*\*(.*?)\*\*/g, '$1') // **scope** -> scope
5862
+ .replace(/\s+/g, ' ')
5863
+ .trim();
5864
+ if (text) out.push(` \u2022 ${text}`);
5865
+ }
5866
+ }
5867
+ return out;
5868
+ }
5869
+
5870
+ /** Cap on release pages walked, so a repo with a huge history can never hang the walk. */
5871
+ const RELEASE_PAGE_LIMIT = 20;
5872
+
5873
+ /**
5874
+ * True once a page contains a published (non-draft/non-prerelease) vX.Y.Z release
5875
+ * at or below `current`. Because the releases API returns newest-first, everything
5876
+ * after that point is older than the installed version, so the walk can stop.
5877
+ */
5878
+ function reachedInstalledRelease(page, current) {
5879
+ if (!current || !Array.isArray(page)) return false;
5880
+ for (const r of page) {
5881
+ if (!r || r.draft || r.prerelease) continue;
5882
+ const norm = String(r.tag_name || r.name || '').replace(/^v/, '');
5883
+ if (!/^\d+\.\d+\.\d+$/.test(norm)) continue;
5884
+ if (compareSemver(norm, current) <= 0) return true;
5885
+ }
5886
+ return false;
5887
+ }
5888
+
5889
+ /**
5890
+ * Fetch this plugin's GitHub releases newer than `current` (best-effort; null on
5891
+ * any failure). Paginates newest-first, stopping as soon as it reaches the
5892
+ * installed release (or a bounded page cap), so the window stays accurate even
5893
+ * when the installed version is far behind and there are >100 releases since.
5894
+ */
5895
+ async function fetchReleaseNotesSince(slug, current, latest, timeoutMs = 5000) {
5896
+ if (!slug) return null;
5897
+ try {
5898
+ const ctrl = new AbortController();
5899
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
5900
+ const headers = {
5901
+ accept: 'application/vnd.github+json',
5902
+ 'user-agent': 'c8ctl-plugin-nano',
5903
+ 'x-github-api-version': '2022-11-28',
5904
+ };
5905
+ const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN;
5906
+ if (token) headers.authorization = `Bearer ${token}`;
5907
+ try {
5908
+ const all = [];
5909
+ for (let page = 1; page <= RELEASE_PAGE_LIMIT; page++) {
5910
+ const res = await fetch(
5911
+ `https://api.github.com/repos/${slug}/releases?per_page=100&page=${page}`,
5912
+ { headers, redirect: 'follow', signal: ctrl.signal },
5913
+ );
5914
+ if (!res.ok) return null;
5915
+ const arr = await res.json();
5916
+ if (!Array.isArray(arr) || arr.length === 0) break;
5917
+ all.push(...arr);
5918
+ // Newest-first: once we hit the installed release (or a short final
5919
+ // page), everything remaining is older than `current` — stop early.
5920
+ if (arr.length < 100 || reachedInstalledRelease(arr, current)) break;
5921
+ }
5922
+ return filterReleasesSince(all, current, latest);
5923
+ } finally {
5924
+ clearTimeout(timer);
5925
+ }
5926
+ } catch {
5927
+ return null;
5928
+ }
5929
+ }
5930
+
5931
+ /**
5932
+ * Print the changelog between the installed release and `latest`. Best-effort:
5933
+ * on any fetch failure it prints a single line pointing at the releases page and
5934
+ * returns, so it can never block or fail an `update`.
5935
+ */
5936
+ async function printChangelogSince(_name, current, latest) {
5937
+ const logger = getLogger();
5938
+ const slug = githubRepoSlug();
5939
+ const releasesUrl = slug ? `https://github.com/${slug}/releases` : null;
5940
+ const releases = await fetchReleaseNotesSince(slug, current, latest);
5941
+
5942
+ if (releases === null) {
5943
+ if (releasesUrl) logger.info(`See what changed: ${releasesUrl}`);
5944
+ logger.info('');
5945
+ return;
5946
+ }
5947
+ if (releases.length === 0) {
5948
+ // Nothing resolved between the two (only a build-metadata bump, or a
5949
+ // degraded resolution: current is null / tags don't match vX.Y.Z). Point
5950
+ // at the releases page so the best-effort feature still leaves a trail.
5951
+ if (releasesUrl) logger.info(`See what changed: ${releasesUrl}`);
5952
+ logger.info('');
5953
+ return;
5954
+ }
5955
+
5956
+ logger.info(`What's changed since v${current ?? '?'}:`);
5957
+ logger.info('');
5958
+ for (const rel of releases) {
5959
+ logger.info(` v${rel.version}`);
5960
+ const lines = renderReleaseBody(rel.body);
5961
+ if (lines.length === 0) logger.info(' (no notes)');
5962
+ else for (const l of lines) logger.info(l);
5963
+ logger.info('');
5964
+ }
5965
+ if (releasesUrl) logger.info(`Full release notes: ${releasesUrl}`);
5966
+ logger.info('');
5967
+ }
5968
+
5616
5969
  /**
5617
5970
  * Resolve how npm must be spawned on the given platform. Spawning `npm`
5618
5971
  * directly is not portable: on Windows npm is a `npm.cmd` shim, so bare
@@ -5745,7 +6098,8 @@ function manualUpdateCommand(name, info) {
5745
6098
  return ` npm install -g ${name}@latest`;
5746
6099
  }
5747
6100
 
5748
- function updatePlugin(req) {
6101
+ async function updatePlugin(req) {
6102
+ const logger = getLogger();
5749
6103
  const { name, version: current } = pluginPackage();
5750
6104
 
5751
6105
  // The nano server binary ships with the plugin as its platform package
@@ -5766,44 +6120,47 @@ function updatePlugin(req) {
5766
6120
  const info = pluginInstallInfo();
5767
6121
  const manual = manualUpdateCommand(name, info);
5768
6122
 
5769
- console.log(`Installed: ${name} v${current ?? '?'}${nanoNote}`);
6123
+ logger.info(`Installed: ${name} v${current ?? '?'}${nanoNote}`);
5770
6124
 
5771
6125
  let latest;
5772
6126
  try {
5773
6127
  latest = npmLatestVersion(name);
5774
6128
  } catch (err) {
5775
- console.log(`Could not check npm for updates: ${err.message}`);
5776
- console.log('Pull the latest release manually with:');
5777
- console.log(manual);
6129
+ logger.info(`Could not check npm for updates: ${err.message}`);
6130
+ logger.info('Pull the latest release manually with:');
6131
+ logger.info(manual);
5778
6132
  return;
5779
6133
  }
5780
- console.log(`Latest: ${name} v${latest} (npm)`);
5781
- console.log('');
6134
+ logger.info(`Latest: ${name} v${latest} (npm)`);
6135
+ logger.info('');
5782
6136
 
5783
6137
  if (current && compareSemver(current, latest) >= 0) {
5784
6138
  if (!nanoBin) {
5785
6139
  // Plugin is current but npm never fetched the matching server binary.
5786
- console.log('Plugin is current, but the nano server binary is not installed for this platform.');
5787
- console.log('Provision it by reinstalling the plugin so npm fetches the platform package:');
5788
- console.log(' c8ctl sync plugin');
6140
+ logger.info('Plugin is current, but the nano server binary is not installed for this platform.');
6141
+ logger.info('Provision it by reinstalling the plugin so npm fetches the platform package:');
6142
+ logger.info(' c8ctl sync plugin');
5789
6143
  return;
5790
6144
  }
5791
- console.log('Already on the latest release — nothing to do.');
6145
+ logger.info('Already on the latest release — nothing to do.');
5792
6146
  return;
5793
6147
  }
5794
6148
 
5795
- console.log(`Update available: v${current ?? '?'} -> v${latest}`);
6149
+ logger.info(`Update available: v${current ?? '?'} -> v${latest}`);
6150
+ logger.info('');
6151
+
6152
+ await printChangelogSince(name, current, latest);
5796
6153
 
5797
6154
  if (req.check) {
5798
- console.log('Run `c8ctl nano update` to pull it (or manually):');
5799
- console.log(manual);
6155
+ logger.info('Run `c8ctl nano update` to pull it (or manually):');
6156
+ logger.info(manual);
5800
6157
  return;
5801
6158
  }
5802
6159
 
5803
6160
  if (info.mode === 'local') {
5804
- console.log('This plugin runs from a local checkout, so it cannot self-update in place.');
5805
- console.log('Update it with:');
5806
- console.log(manual);
6161
+ logger.info('This plugin runs from a local checkout, so it cannot self-update in place.');
6162
+ logger.info('Update it with:');
6163
+ logger.info(manual);
5807
6164
  return;
5808
6165
  }
5809
6166
 
@@ -5812,8 +6169,8 @@ function updatePlugin(req) {
5812
6169
  ? ['install', `${name}@${latest}`, '--prefix', info.prefix]
5813
6170
  : ['install', '-g', `${name}@${latest}`];
5814
6171
  const where = info.mode === 'managed' ? 'the c8ctl plugin store' : "npm's global prefix";
5815
- console.log(`Pulling ${name}@${latest} into ${where}...`);
5816
- console.log('');
6172
+ logger.info(`Pulling ${name}@${latest} into ${where}...`);
6173
+ logger.info('');
5817
6174
  try {
5818
6175
  runNpm(installArgs, { stdio: 'inherit' });
5819
6176
  } catch (err) {
@@ -5830,14 +6187,14 @@ function updatePlugin(req) {
5830
6187
  `npm ${installArgs.join(' ')} failed${code}. ${hint}`,
5831
6188
  );
5832
6189
  }
5833
- console.log('');
6190
+ logger.info('');
5834
6191
  if (info.mode === 'managed') {
5835
- console.log(`Updated to v${latest}. The new plugin and bundled nano server load on your next c8ctl command.`);
6192
+ logger.info(`Updated to v${latest}. The new plugin and bundled nano server load on your next c8ctl command.`);
5836
6193
  } else {
5837
- console.log(`Updated to v${latest}.`);
6194
+ logger.info(`Updated to v${latest}.`);
5838
6195
  }
5839
- console.log('Restart any running cluster to use the new server binary:');
5840
- console.log(' c8ctl nano restart');
6196
+ logger.info('Restart any running cluster to use the new server binary:');
6197
+ logger.info(' c8ctl nano restart');
5841
6198
  }
5842
6199
 
5843
6200
  // ---------------------------------------------------------------------------
@@ -6885,6 +7242,7 @@ function parseProcessosRequest(args, flags) {
6885
7242
  export { resolveBinary, findBinary, launcherEnvMarkers };
6886
7243
  export { setConfig, unsetConfig, readConfig, writeConfig, getConfigFile, SETTING_ALIASES };
6887
7244
  export { buildNpmInvocation };
7245
+ export { compareSemver, githubRepoSlug, filterReleasesSince, renderReleaseBody };
6888
7246
  export {
6889
7247
  webConsoleUrl,
6890
7248
  consoleLinkLabel,
@@ -6894,6 +7252,12 @@ export {
6894
7252
  export {
6895
7253
  normalizeTaskEnvelope,
6896
7254
  collectEnvelopeFrom,
7255
+ parseLinkedResources,
7256
+ pickLinkedResource,
7257
+ resolveBrokerRestConfig,
7258
+ resourceContentUrl,
7259
+ fetchLinkedResourceContent,
7260
+ resolveLinkedPrompt,
6897
7261
  coerceBool,
6898
7262
  coerceInt,
6899
7263
  deepMerge,
@@ -6940,6 +7304,8 @@ export {
6940
7304
  derivePollTimeoutMs,
6941
7305
  AGENT_TASK_NS,
6942
7306
  AGENT_RESULT_KEY,
7307
+ LINKED_RESOURCES_HEADER,
7308
+ DEFAULT_PROMPT_LINK_NAME,
6943
7309
  RESULT_SENTINEL,
6944
7310
  RESERVED_RESULT_KEYS,
6945
7311
  SANDBOXES,
@@ -7006,16 +7372,18 @@ export const metadata = {
7006
7372
  { command: 'c8ctl nano set model-dir <path>', description: 'Set the workspace dir (models + workers)' },
7007
7373
  { command: 'c8ctl nano config', description: 'Show current plugin configuration and paths' },
7008
7374
  { command: 'c8ctl nano update', description: 'Pull the latest published nano release (re-installs via npm)' },
7009
- { command: 'c8ctl nano update --check', description: 'Check whether a newer nano release is available' },
7375
+ { command: 'c8ctl nano update --check', description: 'Check for a newer nano release and show the changelog since the installed version (no install)' },
7010
7376
  { command: 'c8ctl nano hire', description: 'Interactively create a CLI agent worker profile (name, rank, command, model, capabilities)' },
7011
7377
  { command: 'c8ctl nano hire --name reviewer --rank senior --command copilot --model gpt-5 --capabilities code-review,testing', description: 'Create a profile non-interactively' },
7012
7378
  { command: 'c8ctl nano hire --name coder --rank senior --command copilot --arg --allow-all', description: 'Hire copilot with a command-line switch (copilot --allow-all)' },
7013
7379
  { 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' },
7014
7380
  { command: 'c8ctl nano hire --list', description: 'List hired agent profiles' },
7015
7381
  { 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' },
7382
+ { 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)' },
7016
7383
  { 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' },
7017
7384
  { command: 'c8ctl nano work reviewer', description: 'Spawn Nano job workers for the "reviewer" profile and poll for work' },
7018
7385
  { command: 'c8ctl nano work coder --sandbox docker --image ghcr.io/acme/agent:1', description: 'Run jobs in isolated containers with disk-hygiene reaping' },
7386
+ { 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' },
7019
7387
  { command: 'c8ctl nano supervisor start --worker reviewer --worker coder', description: 'Start a detached supervisor managing several workers from one terminal' },
7020
7388
  { command: 'c8ctl nano supervisor', description: 'Attach an interactive console to the supervisor (detach with Ctrl-D, leaving it running)' },
7021
7389
  { command: 'c8ctl nano supervisor status', description: 'List supervised workers (pid, state, serviced job / idle, restarts, uptime) without the console' },
@@ -7061,7 +7429,7 @@ export const commands = {
7061
7429
  purge: { type: 'boolean', description: 'stop/restart: also delete per-node engine data' },
7062
7430
  force: { type: 'boolean', description: 'start: stop any existing cluster first' },
7063
7431
  workspace: { type: 'boolean', description: 'clean: also delete the workspace (models + workers)' },
7064
- check: { type: 'boolean', description: 'update: only report whether a new release is available; do not install' },
7432
+ check: { type: 'boolean', description: 'update: report whether a new release is available (with the changelog since the installed version); do not install' },
7065
7433
  binary: { type: 'string', description: 'Path to the nanobpmn server binary' },
7066
7434
  name: { type: 'string', description: 'work/supervisor add: worker name (auto ‹host›-‹profile›-‹random› if omitted); hire/assign: agent profile name' },
7067
7435
  rank: { type: 'string', description: 'hire: agent rank (principal|senior|junior|decider)' },
@@ -7140,7 +7508,7 @@ export const commands = {
7140
7508
  showConfig();
7141
7509
  break;
7142
7510
  case 'update':
7143
- updatePlugin(req);
7511
+ await updatePlugin(req);
7144
7512
  break;
7145
7513
  case 'hire':
7146
7514
  await hireWorker(req, flags);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "c8ctl-plugin-nano",
3
- "version": "1.30.0",
3
+ "version": "1.32.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.30.0",
61
- "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.30.0",
62
- "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.30.0",
63
- "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.30.0",
64
- "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.30.0",
65
- "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.30.0",
66
- "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.30.0"
60
+ "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.32.0",
61
+ "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.32.0",
62
+ "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.32.0",
63
+ "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.32.0",
64
+ "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.32.0",
65
+ "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.32.0",
66
+ "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.32.0"
67
67
  }
68
68
  }