c8ctl-plugin-nano 1.32.0 → 1.33.1

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›`
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`.
@@ -2727,9 +2853,55 @@ function ghUserIdentity() {
2727
2853
  }
2728
2854
  }
2729
2855
 
2856
+ // Reject a commit-author email that can't be attributed to a real account and
2857
+ // can't receive mail — a `*@nano.local` (or other non-routable) placeholder
2858
+ // injected by the launch environment. Such an address produces UNVERIFIED
2859
+ // commits that look like a person but map to no GitHub user, so the harness must
2860
+ // never stamp it onto a commit; it falls through to the next identity source
2861
+ // instead. An EMPTY email is NOT a placeholder — it is an absent field handled
2862
+ // by ordinary per-field fallthrough, so it does not invalidate its source.
2863
+ // Matching is trim + case-insensitive.
2864
+ function isPlaceholderEmail(email) {
2865
+ const e = String(email || '').trim().toLowerCase();
2866
+ if (!e) return false; // absent — handled by per-field fallthrough, not a placeholder
2867
+ const at = e.lastIndexOf('@');
2868
+ if (at < 0) return true; // no domain at all — not a routable address
2869
+ const local = e.slice(0, at);
2870
+ const domain = e.slice(at + 1);
2871
+ // Malformed addresses missing a local part (`@example.com`) or a domain
2872
+ // (`user@`) can't be routed or attributed either — reject them too.
2873
+ if (!local || !domain) return true;
2874
+ // Non-routable mDNS/host-local TLDs and the loopback host: unattributable and
2875
+ // undeliverable, so never a legitimate commit author.
2876
+ return domain === 'localhost'
2877
+ || domain.endsWith('.local')
2878
+ || domain.endsWith('.internal');
2879
+ }
2880
+
2881
+ // Coerce one identity source into a usable { name, email }. When the source's
2882
+ // email is a non-routable placeholder we discard the WHOLE candidate (both
2883
+ // fields) rather than just the email — otherwise a placeholder-derived name
2884
+ // (e.g. `trial-merge`) would be stitched onto a borrowed email from a lower
2885
+ // source, forging a Frankenstein author. An empty email is preserved as-is so
2886
+ // ordinary per-field fill still works (e.g. git supplies a name, gh the email).
2887
+ // Fields are trimmed so a whitespace-only/space-padded name or email behaves
2888
+ // like "absent" (empty) rather than a truthy value that would block per-field
2889
+ // fallthrough and get stamped as an invalid commit identity — this matches
2890
+ // isPlaceholderEmail, which already normalizes via trim().
2891
+ function sanitizeIdentity(id) {
2892
+ const name = String((id && id.name) || '').trim();
2893
+ const email = String((id && id.email) || '').trim();
2894
+ if (isPlaceholderEmail(email)) return { name: '', email: '' };
2895
+ return { name, email };
2896
+ }
2897
+
2730
2898
  // Resolve the committer identity the harness stamps onto the cloned workspace.
2731
- // Per-field precedence: explicit GIT_AUTHOR_* env → the operator's global git
2899
+ // Source precedence: explicit GIT_AUTHOR_* env → the operator's global git
2732
2900
  // config → the gh-authenticated GitHub user → the `nano-agent` fallback.
2901
+ // Precedence is per-field only for ABSENT fields (an empty name/email falls
2902
+ // through to the next source); a source whose email is a non-routable
2903
+ // placeholder is discarded WHOLE by sanitizeIdentity (name included), so in that
2904
+ // case its name does not participate in per-field fill (see sanitizeIdentity).
2733
2905
  // Preferring the operator's real identity means autonomous commits are authored
2734
2906
  // by the human running the fleet (who has signed any CLA) rather than an
2735
2907
  // anonymous bot that hasn't; the agent's own authorship is recorded as a PR
@@ -2738,20 +2910,22 @@ function ghUserIdentity() {
2738
2910
  // when a higher-precedence source didn't already supply the field — so explicit
2739
2911
  // GIT_AUTHOR_* env fully short-circuits them (no `git config`/`gh` spawns, hence
2740
2912
  // no added latency or failure modes when the override is present).
2741
- // `gitIdentity`/`ghIdentity` are injectable for testing.
2913
+ // Every candidate source is passed through sanitizeIdentity, so a non-routable
2914
+ // `*@nano.local` placeholder from ANY source (env, git-global) is discarded and
2915
+ // falls through to the gh identity / marked bot fallback — never stamped onto a
2916
+ // commit. `gitIdentity`/`ghIdentity` are injectable for testing.
2742
2917
  function resolveCommitterIdentity({ gitIdentity = hostGitIdentity, ghIdentity = ghUserIdentity } = {}) {
2743
- const envName = process.env.GIT_AUTHOR_NAME || '';
2744
- const envEmail = process.env.GIT_AUTHOR_EMAIL || '';
2918
+ const env = sanitizeIdentity({ name: process.env.GIT_AUTHOR_NAME || '', email: process.env.GIT_AUTHOR_EMAIL || '' });
2745
2919
  let g = null;
2746
- const gitOnce = () => (g ??= (gitIdentity() || { name: '', email: '' }));
2920
+ const gitOnce = () => (g ??= sanitizeIdentity(gitIdentity() || {}));
2747
2921
  let gh = null;
2748
- const ghOnce = () => (gh ??= (ghIdentity() || { name: '', email: '' }));
2922
+ const ghOnce = () => (gh ??= sanitizeIdentity(ghIdentity() || {}));
2749
2923
 
2750
- const name = envName || gitOnce().name || ghOnce().name || 'nano-agent';
2751
- const email = envEmail || gitOnce().email || ghOnce().email || 'nano-agent@users.noreply.github.com';
2924
+ const name = env.name || gitOnce().name || ghOnce().name || 'nano-agent';
2925
+ const email = env.email || gitOnce().email || ghOnce().email || 'nano-agent@users.noreply.github.com';
2752
2926
 
2753
2927
  const source =
2754
- (envName || envEmail) ? 'env'
2928
+ (env.name || env.email) ? 'env'
2755
2929
  : (g && (g.name || g.email)) ? 'git-global'
2756
2930
  : (gh && (gh.name || gh.email)) ? 'gh'
2757
2931
  : 'fallback';
@@ -2857,6 +3031,20 @@ function provisionRepo({ envelope, token, runDir, timeoutMs = 120_000 }) {
2857
3031
  const committer = resolveCommitterIdentity();
2858
3032
  runGit(['config', 'user.name', committer.name], { cwd: workspaceDir, env: gitEnv });
2859
3033
  runGit(['config', 'user.email', committer.email], { cwd: workspaceDir, env: gitEnv });
3034
+ // Config alone is not enough: git honours GIT_AUTHOR_*/GIT_COMMITTER_* OVER
3035
+ // user.name/user.email config, so a placeholder GIT_AUTHOR_EMAIL inherited from
3036
+ // the launch environment (e.g. `trial-merge@nano.local`) would still be stamped
3037
+ // onto commits even though we just wrote a clean identity into config. Pin all
3038
+ // four env vars to the resolved (already placeholder-sanitized) identity so
3039
+ // EVERY commit — finalizeGit's own rebase commits (which run with gitEnv) and
3040
+ // the harness's commits (extraEnv below carries these into harnessEnv) — uses
3041
+ // it deterministically, and a non-routable `*@nano.local` author can never be
3042
+ // written. When GIT_AUTHOR_* already held a real identity, resolveCommitterIdentity
3043
+ // returned it verbatim, so this is a no-op in that case.
3044
+ gitEnv.GIT_AUTHOR_NAME = committer.name;
3045
+ gitEnv.GIT_AUTHOR_EMAIL = committer.email;
3046
+ gitEnv.GIT_COMMITTER_NAME = committer.name;
3047
+ gitEnv.GIT_COMMITTER_EMAIL = committer.email;
2860
3048
 
2861
3049
  // Determine the working branch. With branch.create we make a real branch.
2862
3050
  // Otherwise we're on whatever the clone checked out: a branch only if
@@ -2877,7 +3065,7 @@ function provisionRepo({ envelope, token, runDir, timeoutMs = 120_000 }) {
2877
3065
  // `git rev-parse HEAD` on an unborn branch (freshly cloned empty repo) exits
2878
3066
  // non-zero and echoes the literal "HEAD" on stdout — treat that as "no base
2879
3067
  // commit" (empty startSha) rather than a bogus revision.
2880
- return { workspaceDir, gitEnv, startSha: sha.status === 0 ? (sha.stdout || '').trim() : '', workingBranch, detached: !workingBranch, ref: target || '', remote: redactToken(repo.url, token) };
3068
+ return { workspaceDir, gitEnv, committer, startSha: sha.status === 0 ? (sha.stdout || '').trim() : '', workingBranch, detached: !workingBranch, ref: target || '', remote: redactToken(repo.url, token) };
2881
3069
  }
2882
3070
 
2883
3071
  // Look up a PR for this branch (2a does NOT open it — the harness does, driven
@@ -3830,19 +4018,59 @@ async function workAgent(req, flags) {
3830
4018
  logger.error(jobTypeErrors.join('; '));
3831
4019
  process.exit(1);
3832
4020
  }
3833
- const jobTypes = [...new Set([...matrix, ...extraJobTypes])];
4021
+ // Zero-config engine-read enrolment (issue #66). `--auto` subscribes this
4022
+ // worker to ALL deployed *agent* job types read straight from the engine —
4023
+ // no capability, no app enrol endpoint, no channel connection. It is the
4024
+ // mutually-exclusive counterpart to capability-resolved SERVE: in `--auto`
4025
+ // the rank×capability matrix is bypassed entirely (any deployed agent job is
4026
+ // served, gated only by the `io.nanobpm.agentTask.` task header), and the
4027
+ // desired set is reconciled by polling the engine rather than watching the
4028
+ // profile. `--auto-scope <process-id|prefix>` narrows the blast radius to one
4029
+ // app/network; without it, every agent job type on the engine is served.
4030
+ //
4031
+ // TRUST: engine-read has no capability gate — a `--auto` worker will serve any
4032
+ // deployed agent job on its engine. That is the accepted trade for the
4033
+ // local/zero-config target; capability-gated serving is the specialised path.
4034
+ const autoMode = coerceBool(flags?.auto, false);
4035
+ const autoScope = flags?.['auto-scope'] ? String(flags['auto-scope']).trim() : '';
4036
+ if (!autoMode && autoScope) {
4037
+ logger.error('--auto-scope requires --auto (it narrows the engine-read agent job types).');
4038
+ process.exit(1);
4039
+ }
3834
4040
  const camunda = globalThis.c8ctl.createClient();
3835
4041
 
3836
4042
  // Broker REST endpoint for live linked-resource prompts (issue #63) — the same
3837
- // nano endpoint this worker already talks to. Resolved once at startup.
4043
+ // nano endpoint this worker already talks to. Resolved once at startup, and
4044
+ // reused as the C8 REST source for `--auto`'s engine-read enrolment.
3838
4045
  const restConfig = resolveBrokerRestConfig();
3839
4046
 
4047
+ // The desired job-type set. In `--auto` it is engine-read (∪ any --job-type
4048
+ // extras); otherwise it is the rank×capability matrix (∪ extras). The initial
4049
+ // engine read is best-effort — a transient failure starts the worker with no
4050
+ // auto pollers and the poll reconcile below fills them in on the next pass,
4051
+ // rather than refusing to start.
4052
+ let jobTypes;
4053
+ if (autoMode) {
4054
+ try {
4055
+ const autoTypes = await resolveAutoJobTypes({ restConfig, scope: autoScope });
4056
+ jobTypes = [...new Set([...autoTypes, ...extraJobTypes])];
4057
+ } catch (err) {
4058
+ logger.warn(`--auto: initial engine read failed (${err?.message || err}); starting with no auto pollers — will retry on the next poll.`);
4059
+ jobTypes = [...new Set(extraJobTypes)];
4060
+ }
4061
+ } else {
4062
+ jobTypes = [...new Set([...matrix, ...extraJobTypes])];
4063
+ }
4064
+
3840
4065
  logger.info(`Putting "${name}" [${profile.rank}] to work → ${buildAgentCommandLine(profile.command, effectiveArgs)}`);
3841
4066
  logger.info(` worker: ${workerName}`);
3842
4067
  logger.info(` model: ${profile.model || '(none)'}; capabilities: ${profile.capabilities.join(', ') || '(none)'}`);
3843
4068
  logger.info(` sandbox: ${sandbox}${isContainer ? ` (image ${image})` : ''}`);
3844
4069
  const profileEnvKeys = Object.keys(profileEnv);
3845
4070
  if (profileEnvKeys.length > 0) logger.info(` harness env: ${profileEnvKeys.join(', ')}`);
4071
+ if (autoMode) {
4072
+ 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.`);
4073
+ }
3846
4074
  const extraNote = extraJobTypes.length > 0 ? ` (${extraJobTypes.length} via --job-type)` : '';
3847
4075
  logger.info(` listening on ${jobTypes.length} job type(s)${extraNote}: ${jobTypes.join(' ')}`);
3848
4076
  logger.info(` max parallel: ${maxParallelJobs}; recovery window: ${recoveryWindowMs}ms; idle timeout: ${idleTimeoutMs}ms; hard cap: ${hardCapMs > 0 ? `${hardCapMs}ms` : 'off'}; poll timeout: ${pollTimeoutMs}ms`);
@@ -4073,6 +4301,15 @@ async function workAgent(req, flags) {
4073
4301
  AGENT_REPO_URL: provisioned.remote,
4074
4302
  AGENT_REPO_BRANCH: provisioned.workingBranch || '',
4075
4303
  AGENT_REPO_REF: provisioned.ref || '',
4304
+ // Pin the harness's commit identity to the resolved (placeholder-
4305
+ // sanitized) committer so the agent's own `git commit` can't be
4306
+ // hijacked by a placeholder GIT_AUTHOR_* inherited from process.env
4307
+ // (git honours these over user.name/user.email config). Layered via
4308
+ // extraEnv so they override any inherited placeholder in harnessEnv.
4309
+ GIT_AUTHOR_NAME: provisioned.committer.name,
4310
+ GIT_AUTHOR_EMAIL: provisioned.committer.email,
4311
+ GIT_COMMITTER_NAME: provisioned.committer.name,
4312
+ GIT_COMMITTER_EMAIL: provisioned.committer.email,
4076
4313
  };
4077
4314
  } catch (err) {
4078
4315
  if (runDir) { try { rmSync(runDir, { recursive: true, force: true }); } catch { /* best effort */ } liveRunDirs.delete(runDir); }
@@ -4248,12 +4485,20 @@ async function workAgent(req, flags) {
4248
4485
 
4249
4486
  for (const jobType of jobTypes) spawnJobType(jobType);
4250
4487
 
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. ----
4488
+ // ---- Live reconcile: keep the poller set in step with the desired job-type
4489
+ // set start pollers for added types, gracefully drain pollers for removed
4490
+ // types without a restart and without disturbing unchanged types' in-flight
4491
+ // work. The DESIRED set comes from one of two sources depending on mode:
4492
+ // - default: the watched profile's rank×capability matrix (∪ --job-type),
4493
+ // reconciled when the on-disk profile changes (e.g. `nano assign`);
4494
+ // - --auto: the engine's deployed *agent* job types, reconciled by polling
4495
+ // the engine (the deployed set changes as apps deploy/undeploy). ----
4255
4496
  const configFile = getConfigFile();
4256
4497
  const WATCH_INTERVAL_MS = 1500;
4498
+ // How often `--auto` re-reads the engine's deployed agent job types to pick up
4499
+ // newly deployed / undeployed agent processes. Deploys are occasional, so a
4500
+ // few seconds of latency is fine; the read is a couple of cheap C8 REST calls.
4501
+ const AUTO_POLL_INTERVAL_MS = 5000;
4257
4502
  let reconciling = false;
4258
4503
  // Set when a profile change arrives while a reconcile is already in flight, so
4259
4504
  // we run one more pass after the current drain completes instead of dropping
@@ -4263,10 +4508,21 @@ async function workAgent(req, flags) {
4263
4508
  // before snapshotting `workers` (avoids double-stops / missed drains).
4264
4509
  let inFlightReconcile = null;
4265
4510
 
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 = () => {
4511
+ // Desired job types. In `--auto` this is the engine's deployed agent job types
4512
+ // (∪ --job-type extras), read fresh each pass; a transient engine-read failure
4513
+ // returns { skip } so the running set is KEPT, never torn down. Otherwise it is
4514
+ // the CURRENT on-disk profile's matrix (∪ extras), with { skip } for a
4515
+ // transient/torn read, a vanished profile, or an invalid edit — callers must
4516
+ // then KEEP the running set, never tear down.
4517
+ const desiredJobTypes = async () => {
4518
+ if (autoMode) {
4519
+ try {
4520
+ const autoTypes = await resolveAutoJobTypes({ restConfig, scope: autoScope });
4521
+ return { jobTypes: [...new Set([...autoTypes, ...extraJobTypes])] };
4522
+ } catch (err) {
4523
+ return { skip: `engine read failed: ${err?.message || err}` };
4524
+ }
4525
+ }
4270
4526
  let stored;
4271
4527
  try {
4272
4528
  stored = readHiresStrict()[name];
@@ -4309,9 +4565,11 @@ async function workAgent(req, flags) {
4309
4565
  };
4310
4566
 
4311
4567
  const runReconcilePass = async () => {
4312
- const desired = desiredJobTypes();
4568
+ const desired = await desiredJobTypes();
4313
4569
  if (desired.skip) {
4314
- if (desired.skip === 'deleted') {
4570
+ if (autoMode) {
4571
+ logger.warn(`--auto reconcile skipped — ${desired.skip}; keeping the current ${workers.size} worker(s) running.`);
4572
+ } else if (desired.skip === 'deleted') {
4315
4573
  logger.warn(`Profile "${name}" is gone from config — keeping the current ${workers.size} worker(s) running.`);
4316
4574
  } else {
4317
4575
  logger.warn(`Profile "${name}" reload skipped — ${desired.skip}; keeping current workers.`);
@@ -4320,7 +4578,8 @@ async function workAgent(req, flags) {
4320
4578
  }
4321
4579
  const { added, removed } = diffJobTypes([...workers.keys()], desired.jobTypes);
4322
4580
  if (added.length === 0 && removed.length === 0) return;
4323
- logger.info(`Profile "${name}" changed reconciling job types (+${added.length} / -${removed.length}).`);
4581
+ const source = autoMode ? 'engine deployed set' : `Profile "${name}"`;
4582
+ logger.info(`${source} changed — reconciling job types (+${added.length} / -${removed.length}).`);
4324
4583
  for (const jt of added) {
4325
4584
  spawnJobType(jt);
4326
4585
  logger.info(` + now listening on ${jt}`);
@@ -4344,37 +4603,58 @@ async function workAgent(req, flags) {
4344
4603
  logger.info(` now listening on ${workers.size} job type(s): ${[...workers.keys()].join(' ')}`);
4345
4604
  };
4346
4605
 
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
- });
4606
+ // Reconcile trigger. In `--auto` a periodic engine poll re-reads the deployed
4607
+ // agent job types; otherwise a profile-file watch fires on profile edits.
4608
+ let autoPollTimer = null;
4609
+ if (autoMode) {
4610
+ // Self-standing interval poll (not watchFile) since the desired set is
4611
+ // derived from the engine, not the on-disk profile. Skip a tick while a
4612
+ // reconcile is already in flight: calling reconcile() then would set
4613
+ // reconcileRequested and make the in-flight pass loop back-to-back, so an
4614
+ // engine read that consistently outlasts AUTO_POLL_INTERVAL_MS would run
4615
+ // reconciles as fast as the read completes and hammer the broker. Skipping
4616
+ // keeps polling rate-limited to the configured interval regardless of
4617
+ // engine-read latency; the next tick re-reads the latest engine state.
4618
+ autoPollTimer = setInterval(() => {
4619
+ if (inFlightReconcile) return;
4620
+ reconcile().catch((err) => logger.warn(`--auto reconcile failed: ${err?.message || err}`));
4621
+ }, AUTO_POLL_INTERVAL_MS);
4622
+ if (typeof autoPollTimer.unref === 'function') autoPollTimer.unref();
4623
+ } else {
4624
+ // `watchFile` (polling stat) is deliberate over `fs.watch`: it survives the
4625
+ // atomic temp+rename that `writeConfig` does (fs.watch would rebind to the old
4626
+ // inode and go silent), and it's uniform across platforms. Profile edits are
4627
+ // rare + manual, so a ~1.5s poll latency is fine.
4628
+ watchFile(configFile, { interval: WATCH_INTERVAL_MS }, (curr, prev) => {
4629
+ // Fires each interval; act only on real changes. Compare mtime, ctime and
4630
+ // size, not mtime alone: on filesystems with coarse mtime resolution (or two
4631
+ // edits within one mtime tick) mtimeMs can be unchanged while size/ctimeMs
4632
+ // differ, and an mtime-only guard would skip a genuine profile update.
4633
+ if (
4634
+ curr.mtimeMs === prev.mtimeMs &&
4635
+ curr.ctimeMs === prev.ctimeMs &&
4636
+ curr.size === prev.size
4637
+ ) return;
4638
+ // `reconcile()` owns the `inFlightReconcile` handle: a change arriving while
4639
+ // a reconcile is already running coalesces into the current pass and returns
4640
+ // that same in-flight promise, so shutdown always waits for the real one.
4641
+ reconcile().catch((err) => logger.warn(`profile reload failed: ${err?.message || err}`));
4642
+ });
4643
+ }
4366
4644
 
4367
4645
  // Keep the process alive until a stop signal, then drain gracefully.
4368
4646
  await new Promise((resolve) => {
4369
4647
  const stop = async (signal) => {
4370
4648
  if (draining) return;
4371
4649
  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);
4650
+ // Stop the reconcile trigger first so no new reconcile can be triggered,
4651
+ // then wait for any in-flight reconcile to finish before snapshotting
4652
+ // `workers` — this prevents double-stops, missed drains, or a wrong worker
4653
+ // count on exit.
4654
+ if (autoPollTimer) clearInterval(autoPollTimer);
4655
+ else unwatchFile(configFile);
4376
4656
  if (inFlightReconcile) {
4377
- logger.info('Waiting for in-flight profile reconcile to finish before shutdown…');
4657
+ logger.info('Waiting for in-flight reconcile to finish before shutdown…');
4378
4658
  await inFlightReconcile;
4379
4659
  }
4380
4660
  const list = [...workers.values()];
@@ -4465,6 +4745,8 @@ const WORK_FORWARD_FLAGS = {
4465
4745
  'clone-timeout': 'value',
4466
4746
  'keep-runs': 'boolean',
4467
4747
  stream: 'boolean',
4748
+ auto: 'boolean',
4749
+ 'auto-scope': 'value',
4468
4750
  arg: 'list',
4469
4751
  env: 'list',
4470
4752
  'job-type': 'list',
@@ -4477,11 +4759,21 @@ const WORK_FORWARD_FLAGS = {
4477
4759
  function reconstructWorkArgs(flags) {
4478
4760
  const out = [];
4479
4761
  if (!flags || typeof flags !== 'object') return out;
4762
+ // `--auto-scope` is meaningless without `--auto` — `workAgent` exits fast with
4763
+ // "--auto-scope requires --auto". Forwarding the orphan flag to a supervised
4764
+ // worker would guarantee an immediate crash/restart loop, so drop it here at
4765
+ // the forwarding boundary when `--auto` is not truthy (mirrors that guard).
4766
+ // Parse booleans through `coerceBool()` so forwarding matches `workAgent`'s
4767
+ // parsing semantics — c8ctl may pass boolean flags as strings like `'1'`,
4768
+ // `'yes'` or `'on'`, and treating only `true`/`'true'` as enabled would
4769
+ // silently drop `--auto`/`--keep-runs`/`--stream` for supervised workers.
4770
+ const autoOn = coerceBool(flags.auto, false);
4480
4771
  for (const [name, kind] of Object.entries(WORK_FORWARD_FLAGS)) {
4772
+ if (name === 'auto-scope' && !autoOn) continue;
4481
4773
  const v = flags[name];
4482
4774
  if (v === undefined || v === null) continue;
4483
4775
  if (kind === 'boolean') {
4484
- if (v === true || v === 'true') out.push(`--${name}`);
4776
+ if (coerceBool(v, false)) out.push(`--${name}`);
4485
4777
  } else if (kind === 'list') {
4486
4778
  const items = Array.isArray(v) ? v : [v];
4487
4779
  for (const item of items) {
@@ -7285,6 +7577,7 @@ export {
7285
7577
  finalizeGit,
7286
7578
  reconcileAgentPr,
7287
7579
  resolveCommitterIdentity,
7580
+ isPlaceholderEmail,
7288
7581
  postAgentAttribution,
7289
7582
  reapAgentRunDirs,
7290
7583
  authUrl,
@@ -7301,6 +7594,10 @@ export {
7301
7594
  jobTypeMatrix,
7302
7595
  diffJobTypes,
7303
7596
  parseJobTypeFlags,
7597
+ serviceTaskHasAgentHeader,
7598
+ scanAgentTaskLeaves,
7599
+ readDeployedAgentJobTypes,
7600
+ resolveAutoJobTypes,
7304
7601
  derivePollTimeoutMs,
7305
7602
  AGENT_TASK_NS,
7306
7603
  AGENT_RESULT_KEY,
@@ -7382,6 +7679,8 @@ export const metadata = {
7382
7679
  { 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)' },
7383
7680
  { 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' },
7384
7681
  { command: 'c8ctl nano work reviewer', description: 'Spawn Nano job workers for the "reviewer" profile and poll for work' },
7682
+ { 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)' },
7683
+ { 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"' },
7385
7684
  { command: 'c8ctl nano work coder --sandbox docker --image ghcr.io/acme/agent:1', description: 'Run jobs in isolated containers with disk-hygiene reaping' },
7386
7685
  { 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' },
7387
7686
  { command: 'c8ctl nano supervisor start --worker reviewer --worker coder', description: 'Start a detached supervisor managing several workers from one terminal' },
@@ -7456,6 +7755,8 @@ export const commands = {
7456
7755
  'lock-grace': { type: 'string', description: 'work: DEPRECATED and ignored — the broker lock is now auto-managed via --recovery-window.' },
7457
7756
  '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' },
7458
7757
  'job-type': { type: 'string', multiple: true, description: 'work: extra job type to service alongside the rank×capability matrix (repeatable)' },
7758
+ 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).' },
7759
+ '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.' },
7459
7760
  worker: { type: 'string', multiple: true, description: 'supervisor start: profile to launch as a supervised worker (repeatable)' },
7460
7761
  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)` },
7461
7762
  attach: { type: 'boolean', description: 'supervisor start: attach the interactive console after starting the daemon' },
@@ -7613,7 +7914,7 @@ function printUsage() {
7613
7914
  console.log(' c8ctl nano update [--check]');
7614
7915
  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]');
7615
7916
  console.log(' c8ctl nano assign <profileName> <cap[,cap...]> [--name <n>] [--capabilities <a,b>]');
7616
- 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]');
7917
+ 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]');
7617
7918
  console.log(' c8ctl nano supervisor [start|status|add|remove|restart|stop|logs|attach] ... (manage many workers from one terminal)');
7618
7919
  console.log('');
7619
7920
  console.log('Subcommands:');
@@ -7660,6 +7961,8 @@ function printUsage() {
7660
7961
  console.log(' --list hire: list existing agent profiles instead of creating one');
7661
7962
  console.log(' --max-parallel <n> work: max concurrent jobs per worker (default 1)');
7662
7963
  console.log(' --job-type <token> work: extra job type to service alongside the rank×capability matrix (repeatable)');
7964
+ 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.');
7965
+ 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');
7663
7966
  console.log(' --recovery-window <ms> work: broker activation-lock window, auto-refreshed while the agent runs; also the node-loss reclaim time (default 300000)');
7664
7967
  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)');
7665
7968
  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.32.0",
3
+ "version": "1.33.1",
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.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"
60
+ "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.33.1",
61
+ "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.33.1",
62
+ "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.33.1",
63
+ "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.33.1",
64
+ "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.33.1",
65
+ "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.33.1",
66
+ "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.33.1"
67
67
  }
68
68
  }