c8ctl-plugin-nano 1.31.0 → 1.33.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
@@ -212,6 +212,54 @@ c8ctl nano work reviewer --max-parallel 2 --recovery-window 300000
212
212
  c8ctl nano work reviewer --name reviewer-eu # name this worker (else auto ‹host›-‹profile›-‹random›)
213
213
  ```
214
214
 
215
+ #### Zero-config enrolment: `--auto` (serve every deployed agent job type)
216
+
217
+ `--auto` is the **"Borland Delphi on your machine"** onboarding ramp: skip the
218
+ capability wiring entirely and subscribe the worker to **all deployed *agent*
219
+ job types**, read straight from the engine.
220
+
221
+ ```bash
222
+ c8ctl nano work coder --auto # serve every agent job type on the engine
223
+ c8ctl nano work coder --auto --auto-scope my-app # scope to one app/network (process-id prefix)
224
+ ```
225
+
226
+ How it works and why it needs no wiring:
227
+
228
+ - **Engine-read demand.** The worker already holds the engine (C8 REST) endpoint
229
+ from its c8ctl profile. `--auto` enumerates the deployed process definitions
230
+ (`process-definitions/search` → `/{key}/xml`) and scans their
231
+ `<zeebe:taskDefinition type>` leaves for the job types the engine matches. The
232
+ engine is the guaranteed shared rendezvous: if a worker can execute an app's
233
+ agent jobs at all, it and the app are already on the same engine, so *what
234
+ agent job types exist* is answerable from that engine alone — **no cross-machine
235
+ app discovery, no app enrol endpoint, no channel connection**.
236
+ - **Agent-task header filter.** Not every service task is an agent task —
237
+ connectors and record-keepers (e.g. `pr.record-plan`) are plain workers.
238
+ `--auto` keeps only leaves whose service task carries an
239
+ **`io.nanobpm.agentTask.`** task header (e.g. `senior:plan` carries
240
+ `io.nanobpm.agentTask.task.prompt`; a record-keeper does not).
241
+ - **One poller per agent job type, reconciled on change.** It opens one poller
242
+ per agent job type and re-reads the engine periodically, adding pollers for
243
+ newly deployed agent processes and draining pollers for undeployed ones — the
244
+ same in-place reconcile the profile watch uses, sourced from the engine instead
245
+ of the profile.
246
+ - **Raw job-type grammar.** The job type the engine matches (`senior:plan`) is
247
+ advertised **verbatim** — colon-named types are not forced through any
248
+ dot-grammar.
249
+
250
+ `--auto` is **mutually exclusive** with capability-resolved serving: it bypasses
251
+ the rank×capability matrix (any `--job-type` extras are still added). The prompt
252
+ a worker needs already rides the job header (`io.nanobpm.agentTask.task.prompt`)
253
+ plus per-instance context, so a generic `--auto` worker needs no baked
254
+ specialisation.
255
+
256
+ > **Trust.** Engine-read has **no capability gate** — an `--auto` worker will
257
+ > serve *any* deployed agent job on its engine. That is the accepted trade for the
258
+ > local/zero-config target; capability-gated serving is the specialised
259
+ > (capability-declared) enrolment path. Use `--auto-scope <process-id | prefix>`
260
+ > to narrow the blast radius to one app/network.
261
+
262
+
215
263
  The optional `--name` sets **this worker's name** — the `workerName` it
216
264
  registers under at the broker (`‹name›:‹jobType›`) and how it shows up in
217
265
  supervisor status/logs. Omit it and a distinct `‹host›-‹profile›-‹random›`
@@ -696,7 +744,7 @@ release onto a machine that already has nano installed:
696
744
 
697
745
  ```bash
698
746
  c8ctl nano update # check npm for a newer release and install it
699
- c8ctl nano update --check # only report whether an update is available
747
+ c8ctl nano update --check # report whether an update is available (no install)
700
748
  ```
701
749
 
702
750
  `update` compares the installed plugin version against the latest published on
@@ -706,6 +754,15 @@ with it. It only ever drives npm — it never touches the private upstream sourc
706
754
  so it works for any npm-installed user. After updating, restart any running
707
755
  cluster (`c8ctl nano restart`) so it picks up the new binary.
708
756
 
757
+ Whenever an update is available, `update` (and `update --check`) also prints a
758
+ **changelog of what changed since the installed release** — the per-version
759
+ "Features" / "Bug Fixes" notes pulled from the plugin's public
760
+ [GitHub Releases](https://github.com/jwulf/c8ctl-plugin-nano/releases) (where
761
+ semantic-release records them). This lookup is best-effort and non-blocking: if
762
+ GitHub is unreachable or rate-limited it degrades to a link to the releases page
763
+ and the update proceeds normally. Set `GH_TOKEN` (or `GITHUB_TOKEN`) to raise the
764
+ unauthenticated API rate limit.
765
+
709
766
  If the plugin is running from a local checkout rather than a global npm install,
710
767
  `update` prints the manual command instead of reinstalling in place.
711
768
 
package/agentic.mjs CHANGED
@@ -74,6 +74,19 @@ export * as presence from '@nanobpm/agentic/presence';
74
74
  export * as relay from '@nanobpm/agentic/relay';
75
75
  export * as transcript from '@nanobpm/agentic/transcript';
76
76
 
77
+ // ---------------------------------------------------------------------------
78
+ // Demand read — @nanobpm/agentic/demand.
79
+ //
80
+ // The read-only C8 REST mirror of the engine's deployed `taskDefinition` leaves
81
+ // (ADR 0056 S4). `nano work --auto` (jwulf/c8ctl-plugin-nano#66) consumes its
82
+ // `httpC8RestReader` to enumerate deployed process definitions and read each
83
+ // one's BPMN XML straight from the engine the worker already talks to — the
84
+ // zero-config enrolment source. The header-filter that narrows those leaves to
85
+ // *agent* job types lives in the plugin (`scanAgentTaskLeaves`), extending the
86
+ // package's type/element/process-only scanner with a `zeebe:taskHeaders` read.
87
+ // ---------------------------------------------------------------------------
88
+ export * as demand from '@nanobpm/agentic/demand';
89
+
77
90
  // ---------------------------------------------------------------------------
78
91
  // Worker-side channel client — @nanobpm/urban-agent-client.
79
92
  //
package/c8ctl-plugin.js CHANGED
@@ -125,6 +125,11 @@ const READINESS_TIMEOUT_MS = 60_000;
125
125
  const READINESS_POLL_MS = 500;
126
126
  const HEALTH_TIMEOUT_MS = 1_500;
127
127
  const STOP_GRACE_MS = 8_000;
128
+ // Upper bound on one `--auto` engine-read reconcile (enumerate deployed
129
+ // definitions + fetch each BPMN). A read that stalls past this is treated as a
130
+ // transient failure so the running poller set is KEPT and, crucially, shutdown
131
+ // — which awaits the in-flight reconcile — can never hang on a wedged engine.
132
+ const AUTO_ENGINE_READ_TIMEOUT_MS = 15_000;
128
133
  const PROCESSOS_STATE_FILE = 'processos.json';
129
134
  const SUPERVISOR_STATE_FILE = 'supervisor.json';
130
135
  const PROCESSOS_DEFAULT_PORT = 8090;
@@ -2390,6 +2395,127 @@ function resolveBrokerRestConfig(env = process.env) {
2390
2395
  return { baseUrl, token };
2391
2396
  }
2392
2397
 
2398
+ // ---------------------------------------------------------------------------
2399
+ // `nano work --auto`: zero-config engine-read enrolment (issue #66).
2400
+ //
2401
+ // Subscribe a generic worker to ALL deployed *agent* job types by reading the
2402
+ // demand straight from the engine (C8 REST) the worker already talks to — no
2403
+ // capability, no app enrol endpoint, no hub rendezvous. The engine is the
2404
+ // guaranteed shared rendezvous: if a worker can execute an app's agent jobs at
2405
+ // all, it and the app are already on the same engine, so "what agent job types
2406
+ // exist" is answerable from that engine alone.
2407
+ //
2408
+ // `@nanobpm/agentic/demand` already reads deployed `taskDefinition` leaves over
2409
+ // C8 REST (`process-definitions/search` → `/{key}/xml`), but its scanner reads
2410
+ // type/element/process only. Not every service task is an agent task — plain
2411
+ // connectors and record-keepers (e.g. `pr.record-plan`) are ordinary workers.
2412
+ // The demand scanner is therefore extended HERE to read `zeebe:taskHeaders` and
2413
+ // keep only leaves whose service task carries an `io.nanobpm.agentTask.` header
2414
+ // (e.g. `senior:plan` carries `io.nanobpm.agentTask.task.prompt`; a record-keeper
2415
+ // does not). Advertise the raw job-type string the engine matches (`senior:plan`)
2416
+ // verbatim — colon-named types are NOT forced through the agentic dot-grammar.
2417
+ // ---------------------------------------------------------------------------
2418
+
2419
+ // True iff a serviceTask body carries a `zeebe:taskHeader` under the agent-task
2420
+ // namespace — the marker that distinguishes an agent task from a plain
2421
+ // connector / record-keeper. Matches the exact `io.nanobpm.agentTask` key and
2422
+ // any flattened `io.nanobpm.agentTask.*` dotpath key (element templates emit the
2423
+ // latter, e.g. `io.nanobpm.agentTask.task.prompt`).
2424
+ function serviceTaskHasAgentHeader(body) {
2425
+ const headerRe = /<zeebe:header\b[^>]*\bkey\s*=\s*"([^"]*)"/g;
2426
+ let m;
2427
+ while ((m = headerRe.exec(body)) !== null) {
2428
+ const key = m[1];
2429
+ if (key === AGENT_TASK_NS || key.startsWith(`${AGENT_TASK_NS}.`)) return true;
2430
+ }
2431
+ return false;
2432
+ }
2433
+
2434
+ // Scan one deployed BPMN document for its *agent* task-definition leaves: every
2435
+ // `<bpmn:serviceTask>` carrying BOTH a non-empty `<zeebe:taskDefinition type>`
2436
+ // AND an `io.nanobpm.agentTask.` task header. Returns `{ taskType, process }`
2437
+ // leaves in first-occurrence order. This is the header-aware extension of the
2438
+ // demand package's `scanTaskDefinitions` (which reads type/element/process only).
2439
+ function scanAgentTaskLeaves(xml) {
2440
+ const source = String(xml || '');
2441
+ const procMatch = source.match(/<bpmn:process\b[^>]*\bid\s*=\s*"([^"]*)"/);
2442
+ const proc = procMatch ? procMatch[1] : '';
2443
+ const out = [];
2444
+ const blockRe = /<bpmn:serviceTask\b[^>]*>([\s\S]*?)<\/bpmn:serviceTask>/g;
2445
+ let block;
2446
+ while ((block = blockRe.exec(source)) !== null) {
2447
+ const body = block[1];
2448
+ const tdMatch = body.match(/<zeebe:taskDefinition\b[^>]*>/);
2449
+ if (!tdMatch) continue;
2450
+ const typeMatch = tdMatch[0].match(/\btype\s*=\s*"([^"]*)"/);
2451
+ const taskType = typeMatch ? typeMatch[1] : '';
2452
+ if (!taskType) continue;
2453
+ if (!serviceTaskHasAgentHeader(body)) continue;
2454
+ out.push({ taskType, process: proc });
2455
+ }
2456
+ return out;
2457
+ }
2458
+
2459
+ // Read the distinct deployed *agent* job types through a demand C8RestReader
2460
+ // seam: enumerate the deployed definitions, fetch each one's BPMN XML, scan the
2461
+ // agent leaves, and return the distinct job types in first-occurrence order. An
2462
+ // optional `scope` narrows to one app/network — kept only when the leaf's
2463
+ // `bpmn:process` id equals or is prefixed by the scope string.
2464
+ async function readDeployedAgentJobTypes(reader, { scope = '' } = {}) {
2465
+ const keys = await reader.searchProcessDefinitionKeys();
2466
+ const seen = new Set();
2467
+ const out = [];
2468
+ for (const key of keys) {
2469
+ const xml = await reader.getProcessDefinitionXml(key);
2470
+ for (const leaf of scanAgentTaskLeaves(xml)) {
2471
+ if (scope && !(leaf.process === scope || leaf.process.startsWith(scope))) continue;
2472
+ if (seen.has(leaf.taskType)) continue;
2473
+ seen.add(leaf.taskType);
2474
+ out.push(leaf.taskType);
2475
+ }
2476
+ }
2477
+ return out;
2478
+ }
2479
+
2480
+ // Build the live C8 v2 REST reader from the broker REST config. `httpC8RestReader`
2481
+ // appends `/process-definitions/...` to its `restAddress`, and the C8 v2 API is
2482
+ // mounted under `/v2` on the broker (same base the linked-resource fetch uses),
2483
+ // so the reader's address is `<baseUrl>/v2`. The demand module is imported lazily
2484
+ // through the single agentic surface (`agentic.mjs`) so the whole agentic module
2485
+ // graph only loads when `--auto` is actually used.
2486
+ async function defaultC8RestReader(restConfig) {
2487
+ const { demand } = await import('./agentic.mjs');
2488
+ const base = String(restConfig?.baseUrl || DEFAULT_NANO_URL).replace(/\/+$/, '');
2489
+ return demand.httpC8RestReader({
2490
+ restAddress: `${base}/v2`,
2491
+ token: restConfig?.token ? restConfig.token : undefined,
2492
+ });
2493
+ }
2494
+
2495
+ // Resolve the desired job-type set for `--auto`: all deployed agent job types
2496
+ // read from the engine, optionally scoped to one process-id/prefix. A test may
2497
+ // inject an in-memory `readerFactory` to drive it without a live engine. The
2498
+ // whole read is time-bounded (`timeoutMs`, 0 disables) and a timeout rejects
2499
+ // with a clear error, so a stalled engine read settles the awaited promise
2500
+ // (KEEP the running set) instead of wedging the reconcile — and, via shutdown's
2501
+ // `await inFlightReconcile`, wedging `Ctrl-C`/SIGTERM.
2502
+ async function resolveAutoJobTypes({ restConfig, scope = '', readerFactory, timeoutMs = AUTO_ENGINE_READ_TIMEOUT_MS } = {}) {
2503
+ const read = (async () => {
2504
+ const reader = readerFactory ? await readerFactory() : await defaultC8RestReader(restConfig);
2505
+ return readDeployedAgentJobTypes(reader, { scope });
2506
+ })();
2507
+ if (!(timeoutMs > 0)) return read;
2508
+ let timer;
2509
+ const timeout = new Promise((_resolve, reject) => {
2510
+ timer = setTimeout(() => reject(new Error(`engine read timed out after ${timeoutMs}ms`)), timeoutMs);
2511
+ });
2512
+ try {
2513
+ return await Promise.race([read, timeout]);
2514
+ } finally {
2515
+ clearTimeout(timer);
2516
+ }
2517
+ }
2518
+
2393
2519
  // Build the content endpoint. Per issue #63 / nano-bpm #759 the non-binary
2394
2520
  // `/content` variant is deprecated for non-RPA types (Markdown → 406), so the
2395
2521
  // worker always fetches `/content/binary`.
@@ -3830,19 +3956,59 @@ async function workAgent(req, flags) {
3830
3956
  logger.error(jobTypeErrors.join('; '));
3831
3957
  process.exit(1);
3832
3958
  }
3833
- const jobTypes = [...new Set([...matrix, ...extraJobTypes])];
3959
+ // Zero-config engine-read enrolment (issue #66). `--auto` subscribes this
3960
+ // worker to ALL deployed *agent* job types read straight from the engine —
3961
+ // no capability, no app enrol endpoint, no channel connection. It is the
3962
+ // mutually-exclusive counterpart to capability-resolved SERVE: in `--auto`
3963
+ // the rank×capability matrix is bypassed entirely (any deployed agent job is
3964
+ // served, gated only by the `io.nanobpm.agentTask.` task header), and the
3965
+ // desired set is reconciled by polling the engine rather than watching the
3966
+ // profile. `--auto-scope <process-id|prefix>` narrows the blast radius to one
3967
+ // app/network; without it, every agent job type on the engine is served.
3968
+ //
3969
+ // TRUST: engine-read has no capability gate — a `--auto` worker will serve any
3970
+ // deployed agent job on its engine. That is the accepted trade for the
3971
+ // local/zero-config target; capability-gated serving is the specialised path.
3972
+ const autoMode = coerceBool(flags?.auto, false);
3973
+ const autoScope = flags?.['auto-scope'] ? String(flags['auto-scope']).trim() : '';
3974
+ if (!autoMode && autoScope) {
3975
+ logger.error('--auto-scope requires --auto (it narrows the engine-read agent job types).');
3976
+ process.exit(1);
3977
+ }
3834
3978
  const camunda = globalThis.c8ctl.createClient();
3835
3979
 
3836
3980
  // Broker REST endpoint for live linked-resource prompts (issue #63) — the same
3837
- // nano endpoint this worker already talks to. Resolved once at startup.
3981
+ // nano endpoint this worker already talks to. Resolved once at startup, and
3982
+ // reused as the C8 REST source for `--auto`'s engine-read enrolment.
3838
3983
  const restConfig = resolveBrokerRestConfig();
3839
3984
 
3985
+ // The desired job-type set. In `--auto` it is engine-read (∪ any --job-type
3986
+ // extras); otherwise it is the rank×capability matrix (∪ extras). The initial
3987
+ // engine read is best-effort — a transient failure starts the worker with no
3988
+ // auto pollers and the poll reconcile below fills them in on the next pass,
3989
+ // rather than refusing to start.
3990
+ let jobTypes;
3991
+ if (autoMode) {
3992
+ try {
3993
+ const autoTypes = await resolveAutoJobTypes({ restConfig, scope: autoScope });
3994
+ jobTypes = [...new Set([...autoTypes, ...extraJobTypes])];
3995
+ } catch (err) {
3996
+ logger.warn(`--auto: initial engine read failed (${err?.message || err}); starting with no auto pollers — will retry on the next poll.`);
3997
+ jobTypes = [...new Set(extraJobTypes)];
3998
+ }
3999
+ } else {
4000
+ jobTypes = [...new Set([...matrix, ...extraJobTypes])];
4001
+ }
4002
+
3840
4003
  logger.info(`Putting "${name}" [${profile.rank}] to work → ${buildAgentCommandLine(profile.command, effectiveArgs)}`);
3841
4004
  logger.info(` worker: ${workerName}`);
3842
4005
  logger.info(` model: ${profile.model || '(none)'}; capabilities: ${profile.capabilities.join(', ') || '(none)'}`);
3843
4006
  logger.info(` sandbox: ${sandbox}${isContainer ? ` (image ${image})` : ''}`);
3844
4007
  const profileEnvKeys = Object.keys(profileEnv);
3845
4008
  if (profileEnvKeys.length > 0) logger.info(` harness env: ${profileEnvKeys.join(', ')}`);
4009
+ if (autoMode) {
4010
+ logger.info(` enrolment: --auto (zero-config engine read${autoScope ? `, scope "${autoScope}"` : ', all agent job types'}) — no capability gate; serves any deployed agent job on this engine.`);
4011
+ }
3846
4012
  const extraNote = extraJobTypes.length > 0 ? ` (${extraJobTypes.length} via --job-type)` : '';
3847
4013
  logger.info(` listening on ${jobTypes.length} job type(s)${extraNote}: ${jobTypes.join(' ')}`);
3848
4014
  logger.info(` max parallel: ${maxParallelJobs}; recovery window: ${recoveryWindowMs}ms; idle timeout: ${idleTimeoutMs}ms; hard cap: ${hardCapMs > 0 ? `${hardCapMs}ms` : 'off'}; poll timeout: ${pollTimeoutMs}ms`);
@@ -4248,12 +4414,20 @@ async function workAgent(req, flags) {
4248
4414
 
4249
4415
  for (const jobType of jobTypes) spawnJobType(jobType);
4250
4416
 
4251
- // ---- Live profile watch: reconcile the poller set when the watched profile's
4252
- // job types change (e.g. `c8ctl nano assign <name> …`) — start pollers for
4253
- // added types, gracefully drain pollers for removed types without a restart
4254
- // and without disturbing unchanged types' in-flight work. ----
4417
+ // ---- Live reconcile: keep the poller set in step with the desired job-type
4418
+ // set start pollers for added types, gracefully drain pollers for removed
4419
+ // types without a restart and without disturbing unchanged types' in-flight
4420
+ // work. The DESIRED set comes from one of two sources depending on mode:
4421
+ // - default: the watched profile's rank×capability matrix (∪ --job-type),
4422
+ // reconciled when the on-disk profile changes (e.g. `nano assign`);
4423
+ // - --auto: the engine's deployed *agent* job types, reconciled by polling
4424
+ // the engine (the deployed set changes as apps deploy/undeploy). ----
4255
4425
  const configFile = getConfigFile();
4256
4426
  const WATCH_INTERVAL_MS = 1500;
4427
+ // How often `--auto` re-reads the engine's deployed agent job types to pick up
4428
+ // newly deployed / undeployed agent processes. Deploys are occasional, so a
4429
+ // few seconds of latency is fine; the read is a couple of cheap C8 REST calls.
4430
+ const AUTO_POLL_INTERVAL_MS = 5000;
4257
4431
  let reconciling = false;
4258
4432
  // Set when a profile change arrives while a reconcile is already in flight, so
4259
4433
  // we run one more pass after the current drain completes instead of dropping
@@ -4263,10 +4437,21 @@ async function workAgent(req, flags) {
4263
4437
  // before snapshotting `workers` (avoids double-stops / missed drains).
4264
4438
  let inFlightReconcile = null;
4265
4439
 
4266
- // Desired job types from the CURRENT on-disk profile (matrix --job-type
4267
- // extras). Returns { skip } for a transient/torn read, a vanished profile, or
4268
- // an invalid edit callers must then KEEP the running set, never tear down.
4269
- const desiredJobTypes = () => {
4440
+ // Desired job types. In `--auto` this is the engine's deployed agent job types
4441
+ // (∪ --job-type extras), read fresh each pass; a transient engine-read failure
4442
+ // returns { skip } so the running set is KEPT, never torn down. Otherwise it is
4443
+ // the CURRENT on-disk profile's matrix (∪ extras), with { skip } for a
4444
+ // transient/torn read, a vanished profile, or an invalid edit — callers must
4445
+ // then KEEP the running set, never tear down.
4446
+ const desiredJobTypes = async () => {
4447
+ if (autoMode) {
4448
+ try {
4449
+ const autoTypes = await resolveAutoJobTypes({ restConfig, scope: autoScope });
4450
+ return { jobTypes: [...new Set([...autoTypes, ...extraJobTypes])] };
4451
+ } catch (err) {
4452
+ return { skip: `engine read failed: ${err?.message || err}` };
4453
+ }
4454
+ }
4270
4455
  let stored;
4271
4456
  try {
4272
4457
  stored = readHiresStrict()[name];
@@ -4309,9 +4494,11 @@ async function workAgent(req, flags) {
4309
4494
  };
4310
4495
 
4311
4496
  const runReconcilePass = async () => {
4312
- const desired = desiredJobTypes();
4497
+ const desired = await desiredJobTypes();
4313
4498
  if (desired.skip) {
4314
- if (desired.skip === 'deleted') {
4499
+ if (autoMode) {
4500
+ logger.warn(`--auto reconcile skipped — ${desired.skip}; keeping the current ${workers.size} worker(s) running.`);
4501
+ } else if (desired.skip === 'deleted') {
4315
4502
  logger.warn(`Profile "${name}" is gone from config — keeping the current ${workers.size} worker(s) running.`);
4316
4503
  } else {
4317
4504
  logger.warn(`Profile "${name}" reload skipped — ${desired.skip}; keeping current workers.`);
@@ -4320,7 +4507,8 @@ async function workAgent(req, flags) {
4320
4507
  }
4321
4508
  const { added, removed } = diffJobTypes([...workers.keys()], desired.jobTypes);
4322
4509
  if (added.length === 0 && removed.length === 0) return;
4323
- logger.info(`Profile "${name}" changed reconciling job types (+${added.length} / -${removed.length}).`);
4510
+ const source = autoMode ? 'engine deployed set' : `Profile "${name}"`;
4511
+ logger.info(`${source} changed — reconciling job types (+${added.length} / -${removed.length}).`);
4324
4512
  for (const jt of added) {
4325
4513
  spawnJobType(jt);
4326
4514
  logger.info(` + now listening on ${jt}`);
@@ -4344,37 +4532,58 @@ async function workAgent(req, flags) {
4344
4532
  logger.info(` now listening on ${workers.size} job type(s): ${[...workers.keys()].join(' ')}`);
4345
4533
  };
4346
4534
 
4347
- // `watchFile` (polling stat) is deliberate over `fs.watch`: it survives the
4348
- // atomic temp+rename that `writeConfig` does (fs.watch would rebind to the old
4349
- // inode and go silent), and it's uniform across platforms. Profile edits are
4350
- // rare + manual, so a ~1.5s poll latency is fine.
4351
- watchFile(configFile, { interval: WATCH_INTERVAL_MS }, (curr, prev) => {
4352
- // Fires each interval; act only on real changes. Compare mtime, ctime and
4353
- // size, not mtime alone: on filesystems with coarse mtime resolution (or two
4354
- // edits within one mtime tick) mtimeMs can be unchanged while size/ctimeMs
4355
- // differ, and an mtime-only guard would skip a genuine profile update.
4356
- if (
4357
- curr.mtimeMs === prev.mtimeMs &&
4358
- curr.ctimeMs === prev.ctimeMs &&
4359
- curr.size === prev.size
4360
- ) return;
4361
- // `reconcile()` owns the `inFlightReconcile` handle: a change arriving while
4362
- // a reconcile is already running coalesces into the current pass and returns
4363
- // that same in-flight promise, so shutdown always waits for the real one.
4364
- reconcile().catch((err) => logger.warn(`profile reload failed: ${err?.message || err}`));
4365
- });
4535
+ // Reconcile trigger. In `--auto` a periodic engine poll re-reads the deployed
4536
+ // agent job types; otherwise a profile-file watch fires on profile edits.
4537
+ let autoPollTimer = null;
4538
+ if (autoMode) {
4539
+ // Self-standing interval poll (not watchFile) since the desired set is
4540
+ // derived from the engine, not the on-disk profile. Skip a tick while a
4541
+ // reconcile is already in flight: calling reconcile() then would set
4542
+ // reconcileRequested and make the in-flight pass loop back-to-back, so an
4543
+ // engine read that consistently outlasts AUTO_POLL_INTERVAL_MS would run
4544
+ // reconciles as fast as the read completes and hammer the broker. Skipping
4545
+ // keeps polling rate-limited to the configured interval regardless of
4546
+ // engine-read latency; the next tick re-reads the latest engine state.
4547
+ autoPollTimer = setInterval(() => {
4548
+ if (inFlightReconcile) return;
4549
+ reconcile().catch((err) => logger.warn(`--auto reconcile failed: ${err?.message || err}`));
4550
+ }, AUTO_POLL_INTERVAL_MS);
4551
+ if (typeof autoPollTimer.unref === 'function') autoPollTimer.unref();
4552
+ } else {
4553
+ // `watchFile` (polling stat) is deliberate over `fs.watch`: it survives the
4554
+ // atomic temp+rename that `writeConfig` does (fs.watch would rebind to the old
4555
+ // inode and go silent), and it's uniform across platforms. Profile edits are
4556
+ // rare + manual, so a ~1.5s poll latency is fine.
4557
+ watchFile(configFile, { interval: WATCH_INTERVAL_MS }, (curr, prev) => {
4558
+ // Fires each interval; act only on real changes. Compare mtime, ctime and
4559
+ // size, not mtime alone: on filesystems with coarse mtime resolution (or two
4560
+ // edits within one mtime tick) mtimeMs can be unchanged while size/ctimeMs
4561
+ // differ, and an mtime-only guard would skip a genuine profile update.
4562
+ if (
4563
+ curr.mtimeMs === prev.mtimeMs &&
4564
+ curr.ctimeMs === prev.ctimeMs &&
4565
+ curr.size === prev.size
4566
+ ) return;
4567
+ // `reconcile()` owns the `inFlightReconcile` handle: a change arriving while
4568
+ // a reconcile is already running coalesces into the current pass and returns
4569
+ // that same in-flight promise, so shutdown always waits for the real one.
4570
+ reconcile().catch((err) => logger.warn(`profile reload failed: ${err?.message || err}`));
4571
+ });
4572
+ }
4366
4573
 
4367
4574
  // Keep the process alive until a stop signal, then drain gracefully.
4368
4575
  await new Promise((resolve) => {
4369
4576
  const stop = async (signal) => {
4370
4577
  if (draining) return;
4371
4578
  draining = true;
4372
- // Stop watching first so no new reconcile can be triggered, then wait for
4373
- // any in-flight reconcile to finish before snapshotting `workers` — this
4374
- // prevents double-stops, missed drains, or a wrong worker count on exit.
4375
- unwatchFile(configFile);
4579
+ // Stop the reconcile trigger first so no new reconcile can be triggered,
4580
+ // then wait for any in-flight reconcile to finish before snapshotting
4581
+ // `workers` — this prevents double-stops, missed drains, or a wrong worker
4582
+ // count on exit.
4583
+ if (autoPollTimer) clearInterval(autoPollTimer);
4584
+ else unwatchFile(configFile);
4376
4585
  if (inFlightReconcile) {
4377
- logger.info('Waiting for in-flight profile reconcile to finish before shutdown…');
4586
+ logger.info('Waiting for in-flight reconcile to finish before shutdown…');
4378
4587
  await inFlightReconcile;
4379
4588
  }
4380
4589
  const list = [...workers.values()];
@@ -4465,6 +4674,8 @@ const WORK_FORWARD_FLAGS = {
4465
4674
  'clone-timeout': 'value',
4466
4675
  'keep-runs': 'boolean',
4467
4676
  stream: 'boolean',
4677
+ auto: 'boolean',
4678
+ 'auto-scope': 'value',
4468
4679
  arg: 'list',
4469
4680
  env: 'list',
4470
4681
  'job-type': 'list',
@@ -4477,11 +4688,21 @@ const WORK_FORWARD_FLAGS = {
4477
4688
  function reconstructWorkArgs(flags) {
4478
4689
  const out = [];
4479
4690
  if (!flags || typeof flags !== 'object') return out;
4691
+ // `--auto-scope` is meaningless without `--auto` — `workAgent` exits fast with
4692
+ // "--auto-scope requires --auto". Forwarding the orphan flag to a supervised
4693
+ // worker would guarantee an immediate crash/restart loop, so drop it here at
4694
+ // the forwarding boundary when `--auto` is not truthy (mirrors that guard).
4695
+ // Parse booleans through `coerceBool()` so forwarding matches `workAgent`'s
4696
+ // parsing semantics — c8ctl may pass boolean flags as strings like `'1'`,
4697
+ // `'yes'` or `'on'`, and treating only `true`/`'true'` as enabled would
4698
+ // silently drop `--auto`/`--keep-runs`/`--stream` for supervised workers.
4699
+ const autoOn = coerceBool(flags.auto, false);
4480
4700
  for (const [name, kind] of Object.entries(WORK_FORWARD_FLAGS)) {
4701
+ if (name === 'auto-scope' && !autoOn) continue;
4481
4702
  const v = flags[name];
4482
4703
  if (v === undefined || v === null) continue;
4483
4704
  if (kind === 'boolean') {
4484
- if (v === true || v === 'true') out.push(`--${name}`);
4705
+ if (coerceBool(v, false)) out.push(`--${name}`);
4485
4706
  } else if (kind === 'list') {
4486
4707
  const items = Array.isArray(v) ? v : [v];
4487
4708
  for (const item of items) {
@@ -5789,6 +6010,183 @@ function compareSemver(a, b) {
5789
6010
  return 0;
5790
6011
  }
5791
6012
 
6013
+ // ---------------------------------------------------------------------------
6014
+ // Update changelog. `update --check` (and the pre-pull path of a real update)
6015
+ // shows what changed between the installed release and latest. The authoritative
6016
+ // source is this plugin's PUBLIC GitHub Releases — semantic-release records the
6017
+ // generated notes there (@semantic-release/github). The committed CHANGELOG.md
6018
+ // is deliberately NOT maintained (the release config dropped the changelog/git
6019
+ // plugins so it never pushes to the protected `main`) and isn't even in the npm
6020
+ // `files`, so it can't be the source. Every lookup here is best-effort and
6021
+ // non-blocking: any failure (offline, rate-limited, private) degrades to a link,
6022
+ // never to a failed `update`.
6023
+ // ---------------------------------------------------------------------------
6024
+
6025
+ /** `owner/repo` parsed from the plugin package's `repository` field (null if absent). */
6026
+ function githubRepoSlug() {
6027
+ try {
6028
+ const pkg = JSON.parse(readFileSync(join(pluginDir, 'package.json'), 'utf8'));
6029
+ const raw = pkg?.repository?.url ?? (typeof pkg?.repository === 'string' ? pkg.repository : '');
6030
+ const m = String(raw).match(/github\.com[/:]([^/\s]+\/[^/\s]+?)(?:\.git)?(?:[/#?].*)?$/i);
6031
+ return m ? m[1] : null;
6032
+ } catch {
6033
+ return null;
6034
+ }
6035
+ }
6036
+
6037
+ /**
6038
+ * Keep only the releases strictly newer than `currentVersion` and no newer than
6039
+ * `latestVersion` (when known), newest-first. Pure over its `releases` input (an
6040
+ * array of GitHub release objects) so it is unit-testable without a network call.
6041
+ */
6042
+ function filterReleasesSince(releases, currentVersion, latestVersion) {
6043
+ if (!Array.isArray(releases)) return [];
6044
+ const items = [];
6045
+ for (const r of releases) {
6046
+ if (!r || r.draft || r.prerelease) continue;
6047
+ const tag = r.tag_name || r.name || '';
6048
+ const norm = String(tag).replace(/^v/, '');
6049
+ // Require a plain vX.Y.Z tag: a prerelease/build suffix (e.g. -rc.1, +meta)
6050
+ // must exclude the release rather than be normalised away into the window.
6051
+ if (!/^\d+\.\d+\.\d+$/.test(norm)) continue;
6052
+ const ver = norm;
6053
+ if (!currentVersion || compareSemver(ver, currentVersion) <= 0) continue;
6054
+ if (latestVersion && compareSemver(ver, latestVersion) > 0) continue;
6055
+ items.push({ version: ver, tag, body: r.body || '', url: r.html_url || '' });
6056
+ }
6057
+ items.sort((a, b) => compareSemver(b.version, a.version));
6058
+ return items;
6059
+ }
6060
+
6061
+ /**
6062
+ * Render one semantic-release release body to tight terminal lines: drop the
6063
+ * redundant `# [x.y.z](…)` header, turn `### Features` into a `Features:` label,
6064
+ * flatten `* **scope:** subject ([abc](url))` bullets to `• scope: subject`
6065
+ * (stripping any `([label](url))` commit/PR link groups and inlining any
6066
+ * remaining `[text](url)` as its text). Returns an array of already-indented lines.
6067
+ */
6068
+ function renderReleaseBody(body) {
6069
+ const out = [];
6070
+ for (const line of String(body).split(/\r?\n/)) {
6071
+ if (/^#{1,2}\s+\[?\d+\.\d+\.\d+/.test(line)) continue; // redundant version header
6072
+ const heading = line.match(/^#{2,3}\s+(.*\S)\s*$/);
6073
+ if (heading) {
6074
+ out.push(` ${heading[1]}:`);
6075
+ continue;
6076
+ }
6077
+ const bullet = line.match(/^\s*[*-]\s+(.*)$/);
6078
+ if (bullet) {
6079
+ let text = bullet[1]
6080
+ .replace(/\s*\(\[[^\]]*\]\([^)]*\)\)/g, '') // ([label](url)) commit/PR link groups
6081
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') // inline [text](url) -> text
6082
+ .replace(/\*\*(.*?)\*\*/g, '$1') // **scope** -> scope
6083
+ .replace(/\s+/g, ' ')
6084
+ .trim();
6085
+ if (text) out.push(` \u2022 ${text}`);
6086
+ }
6087
+ }
6088
+ return out;
6089
+ }
6090
+
6091
+ /** Cap on release pages walked, so a repo with a huge history can never hang the walk. */
6092
+ const RELEASE_PAGE_LIMIT = 20;
6093
+
6094
+ /**
6095
+ * True once a page contains a published (non-draft/non-prerelease) vX.Y.Z release
6096
+ * at or below `current`. Because the releases API returns newest-first, everything
6097
+ * after that point is older than the installed version, so the walk can stop.
6098
+ */
6099
+ function reachedInstalledRelease(page, current) {
6100
+ if (!current || !Array.isArray(page)) return false;
6101
+ for (const r of page) {
6102
+ if (!r || r.draft || r.prerelease) continue;
6103
+ const norm = String(r.tag_name || r.name || '').replace(/^v/, '');
6104
+ if (!/^\d+\.\d+\.\d+$/.test(norm)) continue;
6105
+ if (compareSemver(norm, current) <= 0) return true;
6106
+ }
6107
+ return false;
6108
+ }
6109
+
6110
+ /**
6111
+ * Fetch this plugin's GitHub releases newer than `current` (best-effort; null on
6112
+ * any failure). Paginates newest-first, stopping as soon as it reaches the
6113
+ * installed release (or a bounded page cap), so the window stays accurate even
6114
+ * when the installed version is far behind and there are >100 releases since.
6115
+ */
6116
+ async function fetchReleaseNotesSince(slug, current, latest, timeoutMs = 5000) {
6117
+ if (!slug) return null;
6118
+ try {
6119
+ const ctrl = new AbortController();
6120
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
6121
+ const headers = {
6122
+ accept: 'application/vnd.github+json',
6123
+ 'user-agent': 'c8ctl-plugin-nano',
6124
+ 'x-github-api-version': '2022-11-28',
6125
+ };
6126
+ const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN;
6127
+ if (token) headers.authorization = `Bearer ${token}`;
6128
+ try {
6129
+ const all = [];
6130
+ for (let page = 1; page <= RELEASE_PAGE_LIMIT; page++) {
6131
+ const res = await fetch(
6132
+ `https://api.github.com/repos/${slug}/releases?per_page=100&page=${page}`,
6133
+ { headers, redirect: 'follow', signal: ctrl.signal },
6134
+ );
6135
+ if (!res.ok) return null;
6136
+ const arr = await res.json();
6137
+ if (!Array.isArray(arr) || arr.length === 0) break;
6138
+ all.push(...arr);
6139
+ // Newest-first: once we hit the installed release (or a short final
6140
+ // page), everything remaining is older than `current` — stop early.
6141
+ if (arr.length < 100 || reachedInstalledRelease(arr, current)) break;
6142
+ }
6143
+ return filterReleasesSince(all, current, latest);
6144
+ } finally {
6145
+ clearTimeout(timer);
6146
+ }
6147
+ } catch {
6148
+ return null;
6149
+ }
6150
+ }
6151
+
6152
+ /**
6153
+ * Print the changelog between the installed release and `latest`. Best-effort:
6154
+ * on any fetch failure it prints a single line pointing at the releases page and
6155
+ * returns, so it can never block or fail an `update`.
6156
+ */
6157
+ async function printChangelogSince(_name, current, latest) {
6158
+ const logger = getLogger();
6159
+ const slug = githubRepoSlug();
6160
+ const releasesUrl = slug ? `https://github.com/${slug}/releases` : null;
6161
+ const releases = await fetchReleaseNotesSince(slug, current, latest);
6162
+
6163
+ if (releases === null) {
6164
+ if (releasesUrl) logger.info(`See what changed: ${releasesUrl}`);
6165
+ logger.info('');
6166
+ return;
6167
+ }
6168
+ if (releases.length === 0) {
6169
+ // Nothing resolved between the two (only a build-metadata bump, or a
6170
+ // degraded resolution: current is null / tags don't match vX.Y.Z). Point
6171
+ // at the releases page so the best-effort feature still leaves a trail.
6172
+ if (releasesUrl) logger.info(`See what changed: ${releasesUrl}`);
6173
+ logger.info('');
6174
+ return;
6175
+ }
6176
+
6177
+ logger.info(`What's changed since v${current ?? '?'}:`);
6178
+ logger.info('');
6179
+ for (const rel of releases) {
6180
+ logger.info(` v${rel.version}`);
6181
+ const lines = renderReleaseBody(rel.body);
6182
+ if (lines.length === 0) logger.info(' (no notes)');
6183
+ else for (const l of lines) logger.info(l);
6184
+ logger.info('');
6185
+ }
6186
+ if (releasesUrl) logger.info(`Full release notes: ${releasesUrl}`);
6187
+ logger.info('');
6188
+ }
6189
+
5792
6190
  /**
5793
6191
  * Resolve how npm must be spawned on the given platform. Spawning `npm`
5794
6192
  * directly is not portable: on Windows npm is a `npm.cmd` shim, so bare
@@ -5921,7 +6319,8 @@ function manualUpdateCommand(name, info) {
5921
6319
  return ` npm install -g ${name}@latest`;
5922
6320
  }
5923
6321
 
5924
- function updatePlugin(req) {
6322
+ async function updatePlugin(req) {
6323
+ const logger = getLogger();
5925
6324
  const { name, version: current } = pluginPackage();
5926
6325
 
5927
6326
  // The nano server binary ships with the plugin as its platform package
@@ -5942,44 +6341,47 @@ function updatePlugin(req) {
5942
6341
  const info = pluginInstallInfo();
5943
6342
  const manual = manualUpdateCommand(name, info);
5944
6343
 
5945
- console.log(`Installed: ${name} v${current ?? '?'}${nanoNote}`);
6344
+ logger.info(`Installed: ${name} v${current ?? '?'}${nanoNote}`);
5946
6345
 
5947
6346
  let latest;
5948
6347
  try {
5949
6348
  latest = npmLatestVersion(name);
5950
6349
  } catch (err) {
5951
- console.log(`Could not check npm for updates: ${err.message}`);
5952
- console.log('Pull the latest release manually with:');
5953
- console.log(manual);
6350
+ logger.info(`Could not check npm for updates: ${err.message}`);
6351
+ logger.info('Pull the latest release manually with:');
6352
+ logger.info(manual);
5954
6353
  return;
5955
6354
  }
5956
- console.log(`Latest: ${name} v${latest} (npm)`);
5957
- console.log('');
6355
+ logger.info(`Latest: ${name} v${latest} (npm)`);
6356
+ logger.info('');
5958
6357
 
5959
6358
  if (current && compareSemver(current, latest) >= 0) {
5960
6359
  if (!nanoBin) {
5961
6360
  // Plugin is current but npm never fetched the matching server binary.
5962
- console.log('Plugin is current, but the nano server binary is not installed for this platform.');
5963
- console.log('Provision it by reinstalling the plugin so npm fetches the platform package:');
5964
- console.log(' c8ctl sync plugin');
6361
+ logger.info('Plugin is current, but the nano server binary is not installed for this platform.');
6362
+ logger.info('Provision it by reinstalling the plugin so npm fetches the platform package:');
6363
+ logger.info(' c8ctl sync plugin');
5965
6364
  return;
5966
6365
  }
5967
- console.log('Already on the latest release — nothing to do.');
6366
+ logger.info('Already on the latest release — nothing to do.');
5968
6367
  return;
5969
6368
  }
5970
6369
 
5971
- console.log(`Update available: v${current ?? '?'} -> v${latest}`);
6370
+ logger.info(`Update available: v${current ?? '?'} -> v${latest}`);
6371
+ logger.info('');
6372
+
6373
+ await printChangelogSince(name, current, latest);
5972
6374
 
5973
6375
  if (req.check) {
5974
- console.log('Run `c8ctl nano update` to pull it (or manually):');
5975
- console.log(manual);
6376
+ logger.info('Run `c8ctl nano update` to pull it (or manually):');
6377
+ logger.info(manual);
5976
6378
  return;
5977
6379
  }
5978
6380
 
5979
6381
  if (info.mode === 'local') {
5980
- console.log('This plugin runs from a local checkout, so it cannot self-update in place.');
5981
- console.log('Update it with:');
5982
- console.log(manual);
6382
+ logger.info('This plugin runs from a local checkout, so it cannot self-update in place.');
6383
+ logger.info('Update it with:');
6384
+ logger.info(manual);
5983
6385
  return;
5984
6386
  }
5985
6387
 
@@ -5988,8 +6390,8 @@ function updatePlugin(req) {
5988
6390
  ? ['install', `${name}@${latest}`, '--prefix', info.prefix]
5989
6391
  : ['install', '-g', `${name}@${latest}`];
5990
6392
  const where = info.mode === 'managed' ? 'the c8ctl plugin store' : "npm's global prefix";
5991
- console.log(`Pulling ${name}@${latest} into ${where}...`);
5992
- console.log('');
6393
+ logger.info(`Pulling ${name}@${latest} into ${where}...`);
6394
+ logger.info('');
5993
6395
  try {
5994
6396
  runNpm(installArgs, { stdio: 'inherit' });
5995
6397
  } catch (err) {
@@ -6006,14 +6408,14 @@ function updatePlugin(req) {
6006
6408
  `npm ${installArgs.join(' ')} failed${code}. ${hint}`,
6007
6409
  );
6008
6410
  }
6009
- console.log('');
6411
+ logger.info('');
6010
6412
  if (info.mode === 'managed') {
6011
- console.log(`Updated to v${latest}. The new plugin and bundled nano server load on your next c8ctl command.`);
6413
+ logger.info(`Updated to v${latest}. The new plugin and bundled nano server load on your next c8ctl command.`);
6012
6414
  } else {
6013
- console.log(`Updated to v${latest}.`);
6415
+ logger.info(`Updated to v${latest}.`);
6014
6416
  }
6015
- console.log('Restart any running cluster to use the new server binary:');
6016
- console.log(' c8ctl nano restart');
6417
+ logger.info('Restart any running cluster to use the new server binary:');
6418
+ logger.info(' c8ctl nano restart');
6017
6419
  }
6018
6420
 
6019
6421
  // ---------------------------------------------------------------------------
@@ -7061,6 +7463,7 @@ function parseProcessosRequest(args, flags) {
7061
7463
  export { resolveBinary, findBinary, launcherEnvMarkers };
7062
7464
  export { setConfig, unsetConfig, readConfig, writeConfig, getConfigFile, SETTING_ALIASES };
7063
7465
  export { buildNpmInvocation };
7466
+ export { compareSemver, githubRepoSlug, filterReleasesSince, renderReleaseBody };
7064
7467
  export {
7065
7468
  webConsoleUrl,
7066
7469
  consoleLinkLabel,
@@ -7119,6 +7522,10 @@ export {
7119
7522
  jobTypeMatrix,
7120
7523
  diffJobTypes,
7121
7524
  parseJobTypeFlags,
7525
+ serviceTaskHasAgentHeader,
7526
+ scanAgentTaskLeaves,
7527
+ readDeployedAgentJobTypes,
7528
+ resolveAutoJobTypes,
7122
7529
  derivePollTimeoutMs,
7123
7530
  AGENT_TASK_NS,
7124
7531
  AGENT_RESULT_KEY,
@@ -7190,7 +7597,7 @@ export const metadata = {
7190
7597
  { command: 'c8ctl nano set model-dir <path>', description: 'Set the workspace dir (models + workers)' },
7191
7598
  { command: 'c8ctl nano config', description: 'Show current plugin configuration and paths' },
7192
7599
  { command: 'c8ctl nano update', description: 'Pull the latest published nano release (re-installs via npm)' },
7193
- { command: 'c8ctl nano update --check', description: 'Check whether a newer nano release is available' },
7600
+ { command: 'c8ctl nano update --check', description: 'Check for a newer nano release and show the changelog since the installed version (no install)' },
7194
7601
  { command: 'c8ctl nano hire', description: 'Interactively create a CLI agent worker profile (name, rank, command, model, capabilities)' },
7195
7602
  { command: 'c8ctl nano hire --name reviewer --rank senior --command copilot --model gpt-5 --capabilities code-review,testing', description: 'Create a profile non-interactively' },
7196
7603
  { command: 'c8ctl nano hire --name coder --rank senior --command copilot --arg --allow-all', description: 'Hire copilot with a command-line switch (copilot --allow-all)' },
@@ -7200,6 +7607,8 @@ export const metadata = {
7200
7607
  { 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)' },
7201
7608
  { 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' },
7202
7609
  { command: 'c8ctl nano work reviewer', description: 'Spawn Nano job workers for the "reviewer" profile and poll for work' },
7610
+ { command: 'c8ctl nano work coder --auto', description: 'Zero-config: serve every deployed agent job type read straight from the engine — no capability, no wiring (great for a local single-tenant plane)' },
7611
+ { command: 'c8ctl nano work coder --auto --auto-scope my-app', description: 'Zero-config, scoped to one app: serve only agent job types deployed under process ids prefixed "my-app"' },
7203
7612
  { command: 'c8ctl nano work coder --sandbox docker --image ghcr.io/acme/agent:1', description: 'Run jobs in isolated containers with disk-hygiene reaping' },
7204
7613
  { 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' },
7205
7614
  { command: 'c8ctl nano supervisor start --worker reviewer --worker coder', description: 'Start a detached supervisor managing several workers from one terminal' },
@@ -7247,7 +7656,7 @@ export const commands = {
7247
7656
  purge: { type: 'boolean', description: 'stop/restart: also delete per-node engine data' },
7248
7657
  force: { type: 'boolean', description: 'start: stop any existing cluster first' },
7249
7658
  workspace: { type: 'boolean', description: 'clean: also delete the workspace (models + workers)' },
7250
- check: { type: 'boolean', description: 'update: only report whether a new release is available; do not install' },
7659
+ check: { type: 'boolean', description: 'update: report whether a new release is available (with the changelog since the installed version); do not install' },
7251
7660
  binary: { type: 'string', description: 'Path to the nanobpmn server binary' },
7252
7661
  name: { type: 'string', description: 'work/supervisor add: worker name (auto ‹host›-‹profile›-‹random› if omitted); hire/assign: agent profile name' },
7253
7662
  rank: { type: 'string', description: 'hire: agent rank (principal|senior|junior|decider)' },
@@ -7274,6 +7683,8 @@ export const commands = {
7274
7683
  'lock-grace': { type: 'string', description: 'work: DEPRECATED and ignored — the broker lock is now auto-managed via --recovery-window.' },
7275
7684
  'poll-timeout': { type: 'string', description: 'work: broker long-poll window in ms each activateJobs request is held open (fewer reconnects → fewer transient connect errors); default 30000, 0 = broker default, negative = return immediately' },
7276
7685
  'job-type': { type: 'string', multiple: true, description: 'work: extra job type to service alongside the rank×capability matrix (repeatable)' },
7686
+ auto: { type: 'boolean', description: 'work: zero-config enrolment — serve ALL deployed agent job types read straight from the engine (no capability, no app enrol endpoint, no channel). Mutually exclusive with capability-resolved serving; has NO capability gate (serves any deployed agent job on the engine).' },
7687
+ 'auto-scope': { type: 'string', description: 'work: with --auto, narrow the served agent job types to those whose bpmn:process id equals or is prefixed by this value (one app/network). Default: all agent job types on the engine.' },
7277
7688
  worker: { type: 'string', multiple: true, description: 'supervisor start: profile to launch as a supervised worker (repeatable)' },
7278
7689
  instances: { type: 'string', description: `supervisor add: spawn N distinct auto-named instances of the profile in one call (default 1, max ${MAX_ADD_INSTANCES}; cannot combine with --name)` },
7279
7690
  attach: { type: 'boolean', description: 'supervisor start: attach the interactive console after starting the daemon' },
@@ -7326,7 +7737,7 @@ export const commands = {
7326
7737
  showConfig();
7327
7738
  break;
7328
7739
  case 'update':
7329
- updatePlugin(req);
7740
+ await updatePlugin(req);
7330
7741
  break;
7331
7742
  case 'hire':
7332
7743
  await hireWorker(req, flags);
@@ -7431,7 +7842,7 @@ function printUsage() {
7431
7842
  console.log(' c8ctl nano update [--check]');
7432
7843
  console.log(' c8ctl nano hire [--name <n>] [--rank <r>] [--command <c>] [--arg <switch> ...] [--model <m>] [--capabilities <a,b>] [--sandbox none|docker|podman] [--image <ref>] [--terminal pty|pipe] [--env NAME=VALUE ...] [--list]');
7433
7844
  console.log(' c8ctl nano assign <profileName> <cap[,cap...]> [--name <n>] [--capabilities <a,b>]');
7434
- console.log(' c8ctl nano work <profileName> [--arg <switch> ...] [--max-parallel <n>] [--recovery-window <ms>] [--idle-timeout <ms>] [--job-timeout <ms>] [--poll-timeout <ms>] [--job-type <token> ...] [--sandbox none|docker|podman] [--image <ref>] [--env NAME=VALUE ...] [--secret-resolver host] [--min-free-mb <n>] [--clone-timeout <ms>] [--keep-runs] [--stream]');
7845
+ console.log(' c8ctl nano work <profileName> [--auto [--auto-scope <p>]] [--arg <switch> ...] [--max-parallel <n>] [--recovery-window <ms>] [--idle-timeout <ms>] [--job-timeout <ms>] [--poll-timeout <ms>] [--job-type <token> ...] [--sandbox none|docker|podman] [--image <ref>] [--env NAME=VALUE ...] [--secret-resolver host] [--min-free-mb <n>] [--clone-timeout <ms>] [--keep-runs] [--stream]');
7435
7846
  console.log(' c8ctl nano supervisor [start|status|add|remove|restart|stop|logs|attach] ... (manage many workers from one terminal)');
7436
7847
  console.log('');
7437
7848
  console.log('Subcommands:');
@@ -7478,6 +7889,8 @@ function printUsage() {
7478
7889
  console.log(' --list hire: list existing agent profiles instead of creating one');
7479
7890
  console.log(' --max-parallel <n> work: max concurrent jobs per worker (default 1)');
7480
7891
  console.log(' --job-type <token> work: extra job type to service alongside the rank×capability matrix (repeatable)');
7892
+ console.log(' --auto work: zero-config enrolment — serve ALL deployed agent job types read from the engine (no capability, no app enrol endpoint, no channel). NO capability gate: serves any deployed agent job on the engine.');
7893
+ console.log(' --auto-scope <p> work: with --auto, narrow to agent job types whose bpmn:process id equals or is prefixed by <p> (one app/network); default all');
7481
7894
  console.log(' --recovery-window <ms> work: broker activation-lock window, auto-refreshed while the agent runs; also the node-loss reclaim time (default 300000)');
7482
7895
  console.log(' --idle-timeout <ms> work: max silence (no agent stdout/stderr) before the harness is killed as wedged and the job reclaimed (default 300000)');
7483
7896
  console.log(' --job-timeout <ms> work: OPTIONAL absolute hard cap on total harness runtime; killed past this (default 0 = unlimited)');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "c8ctl-plugin-nano",
3
- "version": "1.31.0",
3
+ "version": "1.33.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.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"
60
+ "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.33.0",
61
+ "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.33.0",
62
+ "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.33.0",
63
+ "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.33.0",
64
+ "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.33.0",
65
+ "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.33.0",
66
+ "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.33.0"
67
67
  }
68
68
  }