c8ctl-plugin-nano 1.7.2 → 1.9.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
@@ -130,6 +130,73 @@ c8ctl nano set model-dir ~/bpmn-workspace
130
130
  This creates `~/bpmn-workspace/models/` and `~/bpmn-workspace/workers/`. Restart a
131
131
  running cluster for a workspace change to take effect.
132
132
 
133
+ ## CLI agent workers: `hire` / `work`
134
+
135
+ Beyond BPMN service-task workers (code in the workspace `workers/` dir), the
136
+ plugin can turn an interactive **CLI agent harness** (Copilot CLI, Claude CLI,
137
+ `pi`, "little coder", …) into a Nano job worker.
138
+
139
+ **`hire`** persists an agent *profile* — a name, a **rank**
140
+ (`principal|senior|junior|decider`), the **command** that starts the CLI, a
141
+ **model** name, and a list of **capabilities**:
142
+
143
+ ```bash
144
+ # Interactive
145
+ c8ctl nano hire
146
+
147
+ # Or non-interactively
148
+ c8ctl nano hire --name reviewer --rank senior --command copilot \
149
+ --model gpt-5 --capabilities code-review,testing
150
+
151
+ # List profiles
152
+ c8ctl nano hire --list
153
+ ```
154
+
155
+ **`work <name>`** loads the profile, connects with the c8ctl SDK client, and
156
+ registers one job worker per token in the **rank × capability matrix**, then
157
+ polls for work in the foreground until Ctrl-C. For rank `senior` and
158
+ capabilities `code-review, testing` the matrix is:
159
+
160
+ | Token | Meaning |
161
+ | --- | --- |
162
+ | `senior` | rank alone |
163
+ | `senior:code-review` | rank + one capability (spread) |
164
+ | `senior:testing` | rank + one capability (spread) |
165
+ | `senior:code-review+testing` | rank + all capabilities, sorted (combined) |
166
+
167
+ so a BPMN service task can target a worker at any granularity by setting its job
168
+ type to the matching token.
169
+
170
+ ```bash
171
+ c8ctl nano work reviewer # poll for work until Ctrl-C
172
+ c8ctl nano work reviewer --max-parallel 2 --job-timeout 600000
173
+ ```
174
+
175
+ Each activated job runs the profile's command **once** (one-shot): the job is
176
+ serialized to JSON and piped to the CLI's **stdin** —
177
+
178
+ ```json
179
+ { "jobKey": "...", "jobType": "senior:code-review", "processInstanceKey": "...",
180
+ "prompt": "<variables.prompt ?? variables.task>", "variables": {},
181
+ "profile": { "name": "reviewer", "rank": "senior", "model": "gpt-5",
182
+ "capabilities": ["code-review", "testing"] } }
183
+ ```
184
+
185
+ and the profile/model are also exported as `AGENT_PROFILE`, `AGENT_RANK`,
186
+ `AGENT_MODEL`, `AGENT_CAPABILITIES`, `AGENT_JOB_TYPE` env vars. On exit `0` the
187
+ job is **completed** with `{ output: <stdout>, exitCode: 0 }` (captured output is
188
+ capped at 1 MiB, with a `truncated` flag when exceeded); any other exit **fails**
189
+ the job with a decremented retry count, and a job that outlives `--job-timeout`
190
+ is killed. Profiles are stored in the plugin's `config.json` (see `c8ctl nano
191
+ config`).
192
+
193
+ > **Trust boundary.** The profile `command` is run through a shell so you can
194
+ > write a full invocation (args, pipes, multi-word commands). It is
195
+ > **operator-authored** — only what you put in your own `config.json` is
196
+ > shell-interpreted. Untrusted job data reaches the harness solely as stdin JSON
197
+ > and `AGENT_*` env vars, never interpolated into the command line, so process
198
+ > variables cannot inject shell commands.
199
+
133
200
  ## Cleaning up disk
134
201
 
135
202
  ```bash
@@ -169,6 +236,21 @@ and the history cap.
169
236
  > ⚠️ With `--in-memory`, restart recovers nothing, and Raft/replicated logs are
170
237
  > not persisted. Use it for stress/throughput testing, not durability testing.
171
238
 
239
+ ## Console profile (`--console` / `--profile`)
240
+
241
+ The server ships a browser console. Pick how much of it is exposed at runtime:
242
+
243
+ ```bash
244
+ c8ctl nano start # studio (default): full IDE + authoring API
245
+ c8ctl nano start --console observe # observability views only; authoring refused (403)
246
+ c8ctl nano start --console off # headless: no console router at all
247
+ ```
248
+
249
+ - Values: `studio` (default), `observe`, `off`. `--profile` is an alias for
250
+ `--console`, and an inherited `NANOBPMN_CONSOLE` env var is honored when neither
251
+ flag is passed. The plugin passes the choice through as `NANOBPMN_CONSOLE` on
252
+ every node.
253
+
172
254
  ## Configuration (`set` / `config`)
173
255
 
174
256
  Persistent settings are stored in `<state home>/config.json`:
package/c8ctl-plugin.js CHANGED
@@ -13,13 +13,15 @@
13
13
  * NANOBPMN_RF replication factor (1 = single-homed, no Raft)
14
14
  * NANOBPMN_RAFT set when RF > 1 to enable per-partition Raft
15
15
  * NANOBPMN_DATA_DIR this node's engine data directory
16
+ * NANOBPMN_CONSOLE runtime console profile (off | observe | studio)
17
+ * NANOBPMN_NODE_BIN Node path for the server's worker fallback runtime
16
18
  *
17
19
  * This plugin spawns N detached node processes wired to talk to each other on
18
20
  * localhost, tracks them in a state file, and stops them on request.
19
21
  *
20
22
  * Usage:
21
23
  * c8ctl nano start [<nodes>] [--port <basePort>] [--partitions <n>] [--rf <n>]
22
- * [--in-memory] [--history-max <n>]
24
+ * [--in-memory] [--history-max <n>] [--console <profile>]
23
25
  * c8ctl nano status
24
26
  * c8ctl nano stop [--purge]
25
27
  * c8ctl nano logs [<nodeId>] [--follow]
@@ -43,6 +45,7 @@ import { homedir, platform as osPlatform } from 'node:os';
43
45
  import { join, isAbsolute, resolve as resolvePath, dirname, sep } from 'node:path';
44
46
  import { createRequire } from 'node:module';
45
47
  import { fileURLToPath } from 'node:url';
48
+ import { createInterface } from 'node:readline/promises';
46
49
  import { platformForHost } from './platforms.mjs';
47
50
 
48
51
  const requireFromHere = createRequire(import.meta.url);
@@ -334,6 +337,18 @@ function findBinary(flags) {
334
337
  */
335
338
  function launcherEnvMarkers(resolved) {
336
339
  const markers = { NANOBPMN_LAUNCHER: 'c8ctl-plugin-nano' };
340
+
341
+ // This launcher IS a Node runtime, so hand the server a known-good Node path
342
+ // for its worker fallback (Deno-preferred, Node >= 22.6). Avoid pinning an
343
+ // older Node runtime (the plugin supports Node >=18) so the server can still
344
+ // fall back to a newer Node on PATH when available.
345
+ const [nodeMajor, nodeMinor, nodePatch] = process.versions.node
346
+ .split('.')
347
+ .map((n) => Number.parseInt(n, 10));
348
+ const nodeOk =
349
+ nodeMajor > 22 ||
350
+ (nodeMajor === 22 && (nodeMinor > 6 || (nodeMinor === 6 && nodePatch >= 0)));
351
+ if (nodeOk) markers.NANOBPMN_NODE_BIN = process.execPath;
337
352
  const { version } = pluginPackage();
338
353
  // The plugin version is the update unit's "current" in the npm channel's
339
354
  // version space (same space as `npm view <plugin> version` -> latest), so the
@@ -349,7 +364,7 @@ function launcherEnvMarkers(resolved) {
349
364
  // Argument parsing
350
365
  // ---------------------------------------------------------------------------
351
366
 
352
- const VALID_SUBCOMMANDS = ['start', 'stop', 'status', 'logs', 'log', 'restart', 'pause', 'resume', 'clean', 'set', 'config', 'update'];
367
+ const VALID_SUBCOMMANDS = ['start', 'stop', 'status', 'logs', 'log', 'restart', 'pause', 'resume', 'clean', 'set', 'config', 'update', 'hire', 'work'];
353
368
 
354
369
  /**
355
370
  * Parse positional args + flags into a normalized request.
@@ -381,6 +396,7 @@ function parseRequest(args, flags) {
381
396
  capture: Boolean(flags?.capture),
382
397
  inMemory: Boolean(flags?.['in-memory'] || flags?.['no-journal']),
383
398
  historyMax: intFlag('history-max'),
399
+ console: flags?.console ?? flags?.profile,
384
400
  workspace: Boolean(flags?.workspace),
385
401
  check: Boolean(flags?.check),
386
402
  binary: flags?.binary,
@@ -529,6 +545,26 @@ async function waitForHealthy(url, timeoutMs = READINESS_TIMEOUT_MS) { const st
529
545
  // start
530
546
  // ---------------------------------------------------------------------------
531
547
 
548
+ /** Runtime console profiles the server understands (nano-bpm ADR 0035 §C). */
549
+ const CONSOLE_PROFILES = ['off', 'observe', 'studio'];
550
+
551
+ /**
552
+ * Resolves the runtime console profile to pass through as NANOBPMN_CONSOLE.
553
+ * Precedence: --console/--profile flag > inherited NANOBPMN_CONSOLE env >
554
+ * 'studio' (the full IDE, our default). Unknown values are rejected so a typo
555
+ * fails fast here rather than silently degrading the console in the server.
556
+ */
557
+ function resolveConsoleProfile(reqConsole) {
558
+ const raw = reqConsole ?? process.env.NANOBPMN_CONSOLE ?? 'studio';
559
+ const profile = String(raw).trim().toLowerCase();
560
+ if (!CONSOLE_PROFILES.includes(profile)) {
561
+ throw new Error(
562
+ `invalid console profile "${raw}" (use one of: ${CONSOLE_PROFILES.join(', ')})`,
563
+ );
564
+ }
565
+ return profile;
566
+ }
567
+
532
568
  async function startCluster(req) {
533
569
  const logger = getLogger();
534
570
 
@@ -557,6 +593,7 @@ async function startCluster(req) {
557
593
  const capture = Boolean(req.capture);
558
594
  const inMemory = Boolean(req.inMemory);
559
595
  const historyMax = req.historyMax;
596
+ const consoleProfile = resolveConsoleProfile(req.console);
560
597
 
561
598
  if (partitions < nodeCount) {
562
599
  logger.warn(
@@ -630,7 +667,8 @@ async function startCluster(req) {
630
667
  `Starting Nano BPM cluster: ${nodeCount} node(s), ${partitions} partition(s), ` +
631
668
  `RF=${rf}${raft ? ', Raft on' : ''}${capture ? ', trace capture on' : ''}` +
632
669
  `${inMemory ? ', in-memory (no disk)' : ''}` +
633
- `${historyMax !== undefined ? `, history-max=${historyMax}` : ''}`,
670
+ `${historyMax !== undefined ? `, history-max=${historyMax}` : ''}` +
671
+ `${consoleProfile !== 'studio' ? `, console=${consoleProfile}` : ''}`,
634
672
  );
635
673
  logger.info(`Binary: ${binary}`);
636
674
  logger.info(`Workspace: ${workspaceDir} (models/, workers/)`);
@@ -667,6 +705,10 @@ async function startCluster(req) {
667
705
  // Shared, persistent authoring workspace (models + workers). Lives
668
706
  // outside the per-node data dir so "nano clean" never wipes it.
669
707
  NANOBPMN_WORKSPACE_DIR: workspaceDir,
708
+ // Runtime console profile (off | observe | studio). Default studio (full
709
+ // IDE); pass-through so --console/--profile or an inherited NANOBPMN_CONSOLE
710
+ // picks the observability-only or headless surface. See nano-bpm ADR 0035 §C.
711
+ NANOBPMN_CONSOLE: consoleProfile,
670
712
  };
671
713
  // Storage axis: an on-disk journal + read-model under the per-node data dir
672
714
  // (default), or a fully in-memory engine (in-memory journal + :memory: read
@@ -1252,6 +1294,461 @@ function showConfig() {
1252
1294
  console.log(' Change with: c8ctl nano set bin <path> | c8ctl nano set model-dir <path>');
1253
1295
  }
1254
1296
 
1297
+ // ---------------------------------------------------------------------------
1298
+ // hire / work — CLI agent harness workers.
1299
+ //
1300
+ // A "hire" is a persisted agent profile (name, rank, CLI command, model,
1301
+ // capabilities). "work <name>" turns that profile into a set of Nano job
1302
+ // workers: one per job-type in the rank×capability matrix. When a job is
1303
+ // activated, the profile's CLI command is spawned fresh (one-shot), fed the job
1304
+ // as JSON on stdin, and its stdout is returned as the job's `output` variable.
1305
+ // ---------------------------------------------------------------------------
1306
+
1307
+ const RANKS = ['principal', 'senior', 'junior', 'decider'];
1308
+
1309
+ /** Normalize a capability list: trim, drop empties, de-dupe, sort (canonical). */
1310
+ function normalizeCapabilities(input) {
1311
+ const raw = Array.isArray(input)
1312
+ ? input
1313
+ : String(input || '').split(',');
1314
+ return [...new Set(raw.map((c) => String(c).trim().toLowerCase()).filter(Boolean))].sort();
1315
+ }
1316
+
1317
+ /** A profile name must be a safe, filesystem/token-friendly slug. */
1318
+ function isValidProfileName(name) {
1319
+ return typeof name === 'string' && /^[a-z0-9][a-z0-9._-]*$/i.test(name);
1320
+ }
1321
+
1322
+ /**
1323
+ * The job-type matrix a worker subscribes to, from a profile's rank
1324
+ * and sorted capabilities [c1, c2, ...]:
1325
+ * - `rank` (rank alone)
1326
+ * - `rank:c1`, `rank:c2` (rank + a single capability, "spread")
1327
+ * - `rank:c1+c2+...` (rank + all capabilities combined; only when >1 cap)
1328
+ * Delimiters: `:` separates rank from capabilities, `+` joins combined caps.
1329
+ * Capabilities are sorted so the combined token is canonical/predictable.
1330
+ */
1331
+ function jobTypeMatrix(rank, capabilities) {
1332
+ const caps = normalizeCapabilities(capabilities);
1333
+ const tokens = [rank];
1334
+ for (const c of caps) tokens.push(`${rank}:${c}`);
1335
+ if (caps.length > 1) tokens.push(`${rank}:${caps.join('+')}`);
1336
+ return [...new Set(tokens)];
1337
+ }
1338
+
1339
+ /** All persisted hire profiles, keyed by name. */
1340
+ function readHires() {
1341
+ const cfg = readConfig();
1342
+ // A JSON array is `typeof === 'object'` but drops string-keyed writes on
1343
+ // JSON.stringify, so treat only plain objects as a valid hires map.
1344
+ return cfg.hires && typeof cfg.hires === 'object' && !Array.isArray(cfg.hires) ? cfg.hires : {};
1345
+ }
1346
+
1347
+ /** Persist a single hire profile into config.json under `hires`. */
1348
+ function writeHire(profile) {
1349
+ const cfg = readConfig();
1350
+ if (!cfg.hires || typeof cfg.hires !== 'object' || Array.isArray(cfg.hires)) cfg.hires = {};
1351
+ cfg.hires[profile.name] = profile;
1352
+ writeConfig(cfg);
1353
+ }
1354
+
1355
+ /**
1356
+ * Validate and normalize a stored profile before use so a hand-edited or
1357
+ * version-skewed config.json can't produce undefined job types or an invalid
1358
+ * spawn. Returns the normalized profile, or a { error } describing the problem.
1359
+ */
1360
+ function normalizeStoredProfile(name, profile) {
1361
+ if (!profile || typeof profile !== 'object' || Array.isArray(profile)) {
1362
+ return { error: `profile "${name}" is not an object` };
1363
+ }
1364
+ const rank = String(profile.rank || '').trim().toLowerCase();
1365
+ if (!RANKS.includes(rank)) {
1366
+ return { error: `profile "${name}" has an invalid rank "${profile.rank}" (expected one of: ${RANKS.join(', ')})` };
1367
+ }
1368
+ const command = String(profile.command || '').trim();
1369
+ if (!command) {
1370
+ return { error: `profile "${name}" has no command to run` };
1371
+ }
1372
+ return {
1373
+ profile: {
1374
+ name,
1375
+ rank,
1376
+ command,
1377
+ model: typeof profile.model === 'string' ? profile.model.trim() : '',
1378
+ capabilities: normalizeCapabilities(profile.capabilities),
1379
+ },
1380
+ };
1381
+ }
1382
+
1383
+ /**
1384
+ * hire — create (or overwrite) an agent profile. Interactive by default; every
1385
+ * field can also be supplied via a flag (--name/--rank/--command/--model/
1386
+ * --capabilities) for scripting. Prompts only for the fields still missing.
1387
+ * `--list` prints existing profiles instead.
1388
+ */
1389
+ async function hireWorker(req, flags) {
1390
+ const logger = getLogger();
1391
+
1392
+ if (flags?.list) {
1393
+ const hires = readHires();
1394
+ const names = Object.keys(hires);
1395
+ if (names.length === 0) {
1396
+ logger.info('No hires yet. Create one with: c8ctl nano hire');
1397
+ return;
1398
+ }
1399
+ logger.info('Hired agent profiles:');
1400
+ for (const name of names.sort()) {
1401
+ const p = hires[name];
1402
+ logger.info(` ${name} [${p.rank}] ${p.command} (model: ${p.model || '-'}; caps: ${normalizeCapabilities(p.capabilities).join(', ') || '-'})`);
1403
+ }
1404
+ logger.info('');
1405
+ logger.info('Put one to work with: c8ctl nano work <name>');
1406
+ return;
1407
+ }
1408
+
1409
+ // Seed from flags; prompt for anything still missing. Trim string flags so a
1410
+ // stray space can't be persisted into config.json or the spawned command.
1411
+ let name = flags?.name ? String(flags.name).trim() : req.positional[0];
1412
+ let rank = flags?.rank ? String(flags.rank).trim().toLowerCase() : undefined;
1413
+ let command = flags?.command !== undefined ? String(flags.command).trim() : undefined;
1414
+ let model = flags?.model !== undefined ? String(flags.model).trim() : undefined;
1415
+ let capabilities = flags?.capabilities !== undefined ? flags.capabilities : undefined;
1416
+
1417
+ const missingRequired = !name || !rank || !command;
1418
+ const missingOptional = model === undefined || capabilities === undefined;
1419
+ const interactive = process.stdin.isTTY && process.stdout.isTTY;
1420
+
1421
+ // Non-interactively only name/rank/command are required; model and
1422
+ // capabilities are optional (they default to empty), matching how the
1423
+ // interactive prompts label them.
1424
+ if (missingRequired && !interactive) {
1425
+ logger.error('Non-interactive: provide at least --name, --rank and --command.');
1426
+ logger.info('Example: c8ctl nano hire --name reviewer --rank senior --command copilot --model gpt-5 --capabilities code-review,testing');
1427
+ process.exit(1);
1428
+ }
1429
+
1430
+ if (interactive && (missingRequired || missingOptional)) {
1431
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
1432
+ try {
1433
+ console.log('Hire a CLI agent worker. Press Ctrl-C to cancel.');
1434
+ console.log('');
1435
+ while (!name) {
1436
+ const ans = (await rl.question('Profile name: ')).trim();
1437
+ if (isValidProfileName(ans)) { name = ans; break; }
1438
+ console.log(' Please use letters, digits, dot, dash or underscore.');
1439
+ }
1440
+ while (!rank) {
1441
+ const ans = (await rl.question(`Rank (${RANKS.join('|')}): `)).trim().toLowerCase();
1442
+ if (RANKS.includes(ans)) { rank = ans; break; }
1443
+ console.log(` Rank must be one of: ${RANKS.join(', ')}`);
1444
+ }
1445
+ while (!command) {
1446
+ const ans = (await rl.question('CLI command (e.g. copilot, claude, pi): ')).trim();
1447
+ if (ans) { command = ans; break; }
1448
+ console.log(' A command is required.');
1449
+ }
1450
+ if (model === undefined) {
1451
+ model = (await rl.question('Model name (optional): ')).trim();
1452
+ }
1453
+ if (capabilities === undefined) {
1454
+ capabilities = (await rl.question('Capabilities (comma-separated, optional): ')).trim();
1455
+ }
1456
+ } finally {
1457
+ rl.close();
1458
+ }
1459
+ }
1460
+
1461
+ // Optional fields default to empty when omitted (e.g. scripted invocations).
1462
+ if (model === undefined) model = '';
1463
+ if (capabilities === undefined) capabilities = '';
1464
+
1465
+ if (!isValidProfileName(name)) {
1466
+ logger.error(`Invalid profile name "${name}". Use letters, digits, dot, dash or underscore.`);
1467
+ process.exit(1);
1468
+ }
1469
+ if (!RANKS.includes(rank)) {
1470
+ logger.error(`Invalid rank "${rank}". Must be one of: ${RANKS.join(', ')}`);
1471
+ process.exit(1);
1472
+ }
1473
+ if (!command) {
1474
+ logger.error('A CLI command is required.');
1475
+ process.exit(1);
1476
+ }
1477
+
1478
+ const existed = Boolean(readHires()[name]);
1479
+ const profile = {
1480
+ name,
1481
+ rank,
1482
+ command,
1483
+ model: model || '',
1484
+ capabilities: normalizeCapabilities(capabilities),
1485
+ createdAt: new Date().toISOString(),
1486
+ };
1487
+ writeHire(profile);
1488
+
1489
+ const matrix = jobTypeMatrix(profile.rank, profile.capabilities);
1490
+ logger.info(`${existed ? 'Updated' : 'Hired'} "${name}" [${profile.rank}] → ${profile.command}`);
1491
+ logger.info(` model: ${profile.model || '(none)'}`);
1492
+ logger.info(` capabilities: ${profile.capabilities.join(', ') || '(none)'}`);
1493
+ logger.info(` job types (${matrix.length}): ${matrix.join(' ')}`);
1494
+ logger.info(`Put it to work with: c8ctl nano work ${name}`);
1495
+ }
1496
+
1497
+ /**
1498
+ * Concatenate captured Buffer chunks into a UTF-8 string, dropping a trailing
1499
+ * incomplete multibyte sequence (which the byte cap may have split) so decoding
1500
+ * never emits a replacement char or pushes the string over the byte cap.
1501
+ */
1502
+ function joinCapped(chunks) {
1503
+ if (!chunks.length) return '';
1504
+ let buf = Buffer.concat(chunks);
1505
+ let i = buf.length - 1;
1506
+ let cont = 0;
1507
+ while (i >= 0 && (buf[i] & 0xc0) === 0x80 && cont < 3) { i -= 1; cont += 1; }
1508
+ if (i >= 0) {
1509
+ const lead = buf[i];
1510
+ let needed;
1511
+ if ((lead & 0x80) === 0x00) needed = 0;
1512
+ else if ((lead & 0xe0) === 0xc0) needed = 1;
1513
+ else if ((lead & 0xf0) === 0xe0) needed = 2;
1514
+ else if ((lead & 0xf8) === 0xf0) needed = 3;
1515
+ else needed = -1;
1516
+ if (needed > 0 && cont < needed) buf = buf.subarray(0, i);
1517
+ }
1518
+ return buf.toString('utf8');
1519
+ }
1520
+
1521
+ /**
1522
+ * Kill a spawned child and its whole process tree. With `detached: true` on
1523
+ * POSIX the child leads its own process group, so a negative PID signals every
1524
+ * process in that group (shell wrapper + the actual harness command). Falls
1525
+ * back to a plain child.kill() on Windows or if the group signal fails.
1526
+ */
1527
+ function killTree(child) {
1528
+ const pid = child.pid;
1529
+ if (process.platform !== 'win32' && typeof pid === 'number') {
1530
+ try { process.kill(-pid, 'SIGKILL'); return; } catch { /* fall through */ }
1531
+ }
1532
+ try { child.kill('SIGKILL'); } catch { /* already gone */ }
1533
+ }
1534
+
1535
+ /**
1536
+ * Run a single activated job through the profile's CLI command (one-shot):
1537
+ * spawn the command fresh, pipe the job as JSON on stdin, capture stdout, and
1538
+ * resolve to a job action. Exit 0 → complete with { output, exitCode }; any
1539
+ * other exit (or spawn failure) → fail with a decremented retry count.
1540
+ * A child that outlives `timeoutMs` is killed and reported as a failure so it
1541
+ * never leaks a worker slot.
1542
+ */
1543
+ function runAgentJob(profile, job, timeoutMs) {
1544
+ return new Promise((resolve) => {
1545
+ const variables = job.variables && typeof job.variables === 'object' ? job.variables : {};
1546
+ const payload = {
1547
+ jobKey: job.jobKey,
1548
+ jobType: job.type,
1549
+ processInstanceKey: job.processInstanceKey ?? null,
1550
+ elementInstanceKey: job.elementInstanceKey ?? null,
1551
+ elementId: job.elementId ?? null,
1552
+ bpmnProcessId: job.bpmnProcessId ?? job.processDefinitionId ?? null,
1553
+ prompt: variables.prompt ?? variables.task ?? null,
1554
+ variables,
1555
+ customHeaders: job.customHeaders ?? {},
1556
+ profile: {
1557
+ name: profile.name,
1558
+ rank: profile.rank,
1559
+ model: profile.model,
1560
+ capabilities: profile.capabilities,
1561
+ },
1562
+ };
1563
+
1564
+ const child = spawn(profile.command, {
1565
+ shell: true,
1566
+ // Run the shell in its own process group so the timeout handler can kill
1567
+ // the whole tree (shell + harness), not just the shell wrapper PID.
1568
+ detached: process.platform !== 'win32',
1569
+ stdio: ['pipe', 'pipe', 'pipe'],
1570
+ env: {
1571
+ ...process.env,
1572
+ AGENT_PROFILE: profile.name,
1573
+ AGENT_RANK: profile.rank,
1574
+ AGENT_MODEL: profile.model || '',
1575
+ AGENT_CAPABILITIES: (profile.capabilities || []).join(','),
1576
+ AGENT_JOB_TYPE: String(job.type ?? ''),
1577
+ },
1578
+ });
1579
+
1580
+ const stdoutChunks = [];
1581
+ const stderrChunks = [];
1582
+ let stdoutBytes = 0;
1583
+ let stderrBytes = 0;
1584
+ let stdoutTruncated = false;
1585
+ let stderrTruncated = false;
1586
+ let settled = false;
1587
+ // Bound captured output (by BYTES, not string length) so a noisy/runaway
1588
+ // harness can't grow memory without limit and crash the worker.
1589
+ const MAX_CAPTURE_BYTES = 1_048_576; // 1 MiB per stream
1590
+ const finish = (result) => {
1591
+ if (settled) return;
1592
+ settled = true;
1593
+ if (timer) clearTimeout(timer);
1594
+ resolve(result);
1595
+ };
1596
+
1597
+ // Kill (and fail) a child that runs longer than the job's timeout so it can
1598
+ // never permanently hold a worker slot or leak the process.
1599
+ const timer = timeoutMs && timeoutMs > 0
1600
+ ? setTimeout(() => {
1601
+ killTree(child);
1602
+ finish({ ok: false, exitCode: null, stdout: joinCapped(stdoutChunks), stderr: joinCapped(stderrChunks), error: `timed out after ${timeoutMs}ms`, truncated: stdoutTruncated, stderrTruncated });
1603
+ }, timeoutMs)
1604
+ : null;
1605
+
1606
+ child.stdout.on('data', (d) => {
1607
+ const buf = Buffer.isBuffer(d) ? d : Buffer.from(d);
1608
+ const remaining = MAX_CAPTURE_BYTES - stdoutBytes;
1609
+ if (remaining <= 0) { stdoutTruncated = true; return; }
1610
+ if (buf.length > remaining) { stdoutChunks.push(buf.subarray(0, remaining)); stdoutBytes = MAX_CAPTURE_BYTES; stdoutTruncated = true; }
1611
+ else { stdoutChunks.push(buf); stdoutBytes += buf.length; }
1612
+ });
1613
+ child.stderr.on('data', (d) => {
1614
+ const buf = Buffer.isBuffer(d) ? d : Buffer.from(d);
1615
+ const remaining = MAX_CAPTURE_BYTES - stderrBytes;
1616
+ if (remaining <= 0) { stderrTruncated = true; return; }
1617
+ if (buf.length > remaining) { stderrChunks.push(buf.subarray(0, remaining)); stderrBytes = MAX_CAPTURE_BYTES; stderrTruncated = true; }
1618
+ else { stderrChunks.push(buf); stderrBytes += buf.length; }
1619
+ });
1620
+
1621
+ child.on('error', (err) => {
1622
+ finish({ ok: false, exitCode: null, stdout: joinCapped(stdoutChunks), stderr: joinCapped(stderrChunks), error: err.message, truncated: stdoutTruncated, stderrTruncated });
1623
+ });
1624
+ child.on('close', (code, signal) => {
1625
+ finish({ ok: code === 0, exitCode: code, signal: signal ?? null, stdout: joinCapped(stdoutChunks), stderr: joinCapped(stderrChunks), truncated: stdoutTruncated, stderrTruncated });
1626
+ });
1627
+
1628
+ // The child may exit before reading stdin; swallow the async EPIPE so it
1629
+ // doesn't crash the whole worker process (only the `child` close/error
1630
+ // handlers above decide the job outcome).
1631
+ child.stdin.on('error', () => {});
1632
+ try {
1633
+ child.stdin.write(JSON.stringify(payload));
1634
+ child.stdin.end();
1635
+ } catch {
1636
+ // 'error' handler above resolves the promise on spawn failure.
1637
+ }
1638
+ });
1639
+ }
1640
+
1641
+ /**
1642
+ * work — turn a hire profile into live Nano job workers (one per job-type in
1643
+ * the rank×capability matrix) and poll for work in the foreground until Ctrl-C.
1644
+ * Uses the c8ctl-provided SDK client (globalThis.c8ctl.createClient()).
1645
+ */
1646
+ async function workAgent(req, flags) {
1647
+ const logger = getLogger();
1648
+ const name = flags?.name ? String(flags.name).trim() : req.positional[0];
1649
+
1650
+ if (!name) {
1651
+ const hires = readHires();
1652
+ const names = Object.keys(hires).sort();
1653
+ logger.error('Usage: c8ctl nano work <profileName>');
1654
+ if (names.length > 0) logger.info(`Profiles: ${names.join(', ')}`);
1655
+ else logger.info('No hires yet. Create one with: c8ctl nano hire');
1656
+ process.exit(1);
1657
+ }
1658
+
1659
+ const stored = readHires()[name];
1660
+ if (!stored) {
1661
+ logger.error(`No hire named "${name}". List profiles with: c8ctl nano hire --list`);
1662
+ process.exit(1);
1663
+ }
1664
+ const normalized = normalizeStoredProfile(name, stored);
1665
+ if (normalized.error) {
1666
+ logger.error(`Cannot work "${name}": ${normalized.error}. Re-create it with: c8ctl nano hire`);
1667
+ process.exit(1);
1668
+ }
1669
+ const profile = normalized.profile;
1670
+
1671
+ if (!globalThis.c8ctl || typeof globalThis.c8ctl.createClient !== 'function') {
1672
+ logger.error('work requires the c8ctl runtime (createClient). Run it via the c8ctl CLI.');
1673
+ process.exit(1);
1674
+ }
1675
+
1676
+ const intFlag = (v, dflt) => {
1677
+ const n = Number.parseInt(String(v ?? ''), 10);
1678
+ return Number.isFinite(n) && n > 0 ? n : dflt;
1679
+ };
1680
+ const maxParallelJobs = intFlag(flags?.['max-parallel'], 1);
1681
+ const jobTimeoutMs = intFlag(flags?.['job-timeout'], 5 * 60_000);
1682
+
1683
+ const matrix = jobTypeMatrix(profile.rank, profile.capabilities);
1684
+ const camunda = globalThis.c8ctl.createClient();
1685
+
1686
+ logger.info(`Putting "${name}" [${profile.rank}] to work → ${profile.command}`);
1687
+ logger.info(` model: ${profile.model || '(none)'}; capabilities: ${profile.capabilities.join(', ') || '(none)'}`);
1688
+ logger.info(` listening on ${matrix.length} job type(s): ${matrix.join(' ')}`);
1689
+ logger.info(` max parallel: ${maxParallelJobs}; job timeout: ${jobTimeoutMs}ms`);
1690
+ logger.info('Polling for work — press Ctrl-C to stop.');
1691
+
1692
+ const workers = matrix.map((jobType) =>
1693
+ camunda.createJobWorker({
1694
+ jobType,
1695
+ workerName: `${name}:${jobType}`,
1696
+ maxParallelJobs,
1697
+ jobTimeoutMs,
1698
+ jobHandler: async (job) => {
1699
+ logger.info(`[${jobType}] job ${job.jobKey} (instance ${job.processInstanceKey ?? '-'}) → ${profile.command}`);
1700
+ const result = await runAgentJob(profile, job, jobTimeoutMs);
1701
+ if (result.ok) {
1702
+ logger.info(`[${jobType}] job ${job.jobKey} complete (exit 0)${result.truncated ? ' [output truncated]' : ''}`);
1703
+ return job.complete({ output: result.stdout, exitCode: 0, agent: profile.name, truncated: Boolean(result.truncated) });
1704
+ }
1705
+ const retries = Math.max(0, (Number(job.retries) || 1) - 1);
1706
+ const detail = result.error
1707
+ || (result.stderr || '').trim() + (result.stderrTruncated && (result.stderr || '').trim() ? ' [stderr truncated]' : '')
1708
+ || (result.signal ? `terminated by signal ${result.signal}` : `exit code ${result.exitCode}`);
1709
+ logger.warn(`[${jobType}] job ${job.jobKey} failed (${detail}); retries left ${retries}`);
1710
+ return job.fail({
1711
+ errorMessage: `agent "${profile.name}" failed: ${detail}`.slice(0, 2000),
1712
+ retries,
1713
+ });
1714
+ },
1715
+ }),
1716
+ );
1717
+
1718
+ // Keep the process alive until a stop signal, then drain gracefully.
1719
+ await new Promise((resolve) => {
1720
+ let stopping = false;
1721
+ const stop = async (signal) => {
1722
+ if (stopping) return;
1723
+ stopping = true;
1724
+ logger.info(`Received ${signal} — stopping ${workers.length} worker(s)...`);
1725
+ let stopFailures = 0;
1726
+ await Promise.all(
1727
+ workers.map(async (w) => {
1728
+ try {
1729
+ if (typeof w.stopGracefully === 'function') {
1730
+ await w.stopGracefully({ waitUpToMs: STOP_GRACE_MS });
1731
+ } else if (typeof w.stop === 'function') {
1732
+ await w.stop();
1733
+ }
1734
+ } catch {
1735
+ // best-effort: never let one worker's stop failure hang shutdown
1736
+ stopFailures += 1;
1737
+ }
1738
+ }),
1739
+ );
1740
+ if (stopFailures > 0) {
1741
+ logger.warn(`${stopFailures} of ${workers.length} worker(s) did not stop cleanly; some connections may still be open.`);
1742
+ } else {
1743
+ logger.info('All workers stopped.');
1744
+ }
1745
+ resolve();
1746
+ };
1747
+ process.once('SIGINT', () => { stop('SIGINT'); });
1748
+ process.once('SIGTERM', () => { stop('SIGTERM'); });
1749
+ });
1750
+ }
1751
+
1255
1752
  // ---------------------------------------------------------------------------
1256
1753
  // update — pull a new nanobpmn release onto a machine with an existing install.
1257
1754
  // The plugin (and the bundled server binary, shipped via the matching platform
@@ -2507,6 +3004,10 @@ export const metadata = {
2507
3004
  { command: 'c8ctl nano config', description: 'Show current plugin configuration and paths' },
2508
3005
  { command: 'c8ctl nano update', description: 'Pull the latest published nano release (re-installs via npm)' },
2509
3006
  { command: 'c8ctl nano update --check', description: 'Check whether a newer nano release is available' },
3007
+ { command: 'c8ctl nano hire', description: 'Interactively create a CLI agent worker profile (name, rank, command, model, capabilities)' },
3008
+ { command: 'c8ctl nano hire --name reviewer --rank senior --command copilot --model gpt-5 --capabilities code-review,testing', description: 'Create a profile non-interactively' },
3009
+ { command: 'c8ctl nano hire --list', description: 'List hired agent profiles' },
3010
+ { command: 'c8ctl nano work reviewer', description: 'Spawn Nano job workers for the "reviewer" profile and poll for work' },
2510
3011
  ],
2511
3012
  },
2512
3013
  processos: {
@@ -2540,12 +3041,22 @@ export const commands = {
2540
3041
  'in-memory': { type: 'boolean', description: 'start: run with NO on-disk journal/read-model (in-memory engine; state lost on restart). Alias: --no-journal' },
2541
3042
  'no-journal': { type: 'boolean', description: 'start: alias for --in-memory' },
2542
3043
  'history-max': { type: 'string', description: 'start: cap retained terminal instances in the read model (NANOBPMN_HISTORY_MAX_INSTANCES; 0/unset = unbounded)' },
3044
+ console: { type: 'string', description: 'start: runtime console profile off|observe|studio (NANOBPMN_CONSOLE; default studio). Alias: --profile' },
3045
+ profile: { type: 'string', description: 'start: alias for --console (off|observe|studio; default studio)' },
2543
3046
  follow: { type: 'boolean', description: 'logs: stream output (tail -F)', short: 'f' },
2544
3047
  purge: { type: 'boolean', description: 'stop/restart: also delete per-node engine data' },
2545
3048
  force: { type: 'boolean', description: 'start: stop any existing cluster first' },
2546
3049
  workspace: { type: 'boolean', description: 'clean: also delete the workspace (models + workers)' },
2547
3050
  check: { type: 'boolean', description: 'update: only report whether a new release is available; do not install' },
2548
3051
  binary: { type: 'string', description: 'Path to the nanobpmn server binary' },
3052
+ name: { type: 'string', description: 'hire/work: agent profile name (alt to positional arg)' },
3053
+ rank: { type: 'string', description: 'hire: agent rank (principal|senior|junior|decider)' },
3054
+ command: { type: 'string', description: 'hire: CLI command that runs the agent harness (e.g. copilot, claude, pi)' },
3055
+ model: { type: 'string', description: 'hire: model name passed to the harness (AGENT_MODEL)' },
3056
+ capabilities: { type: 'string', description: 'hire: comma-separated capability list' },
3057
+ list: { type: 'boolean', description: 'hire: list existing agent profiles instead of creating one' },
3058
+ 'max-parallel': { type: 'string', description: 'work: max concurrent jobs per worker (default 1)' },
3059
+ 'job-timeout': { type: 'string', description: 'work: max harness runtime per job in ms; the spawned process is killed past this (default 300000)' },
2549
3060
  },
2550
3061
  handler: async (args, flags) => {
2551
3062
  const logger = getLogger();
@@ -2594,6 +3105,12 @@ export const commands = {
2594
3105
  case 'update':
2595
3106
  updatePlugin(req);
2596
3107
  break;
3108
+ case 'hire':
3109
+ await hireWorker(req, flags);
3110
+ break;
3111
+ case 'work':
3112
+ await workAgent(req, flags);
3113
+ break;
2597
3114
  }
2598
3115
  } catch (error) {
2599
3116
  logger.error(`nano ${req.subcommand} failed: ${error instanceof Error ? error.message : error}`);
@@ -2671,7 +3188,7 @@ export const commands = {
2671
3188
 
2672
3189
  function printUsage() {
2673
3190
  console.log('Usage:');
2674
- console.log(' c8ctl nano start [<nodes>] [--port <basePort>] [--partitions <n>] [--rf <n>] [--raft] [--capture] [--in-memory] [--history-max <n>] [--binary <path>]');
3191
+ console.log(' c8ctl nano start [<nodes>] [--port <basePort>] [--partitions <n>] [--rf <n>] [--raft] [--capture] [--in-memory] [--history-max <n>] [--console <profile>] [--binary <path>]');
2675
3192
  console.log(' c8ctl nano status [--port <port>]');
2676
3193
  console.log(' c8ctl nano stop [--purge]');
2677
3194
  console.log(' c8ctl nano logs [<nodeId>] [--follow]');
@@ -2682,6 +3199,8 @@ function printUsage() {
2682
3199
  console.log(' c8ctl nano set <bin|model-dir> <path>');
2683
3200
  console.log(' c8ctl nano config');
2684
3201
  console.log(' c8ctl nano update [--check]');
3202
+ console.log(' c8ctl nano hire [--name <n>] [--rank <r>] [--command <c>] [--model <m>] [--capabilities <a,b>] [--list]');
3203
+ console.log(' c8ctl nano work <profileName> [--max-parallel <n>] [--job-timeout <ms>]');
2685
3204
  console.log('');
2686
3205
  console.log('Subcommands:');
2687
3206
  console.log(' start Spawn an N-node local cluster wired to talk to each other on localhost');
@@ -2695,6 +3214,8 @@ function printUsage() {
2695
3214
  console.log(' set Persist a setting: "bin <path>" or "model-dir <path>"');
2696
3215
  console.log(' config Show current configuration and on-disk locations');
2697
3216
  console.log(' update Pull the latest published nano release (--check to only report)');
3217
+ console.log(' hire Create a CLI agent worker profile (rank + capabilities → job-type matrix)');
3218
+ console.log(' work Run a hired profile as Nano job workers, polling for work until Ctrl-C');
2698
3219
  console.log('');
2699
3220
  console.log('Options:');
2700
3221
  console.log(' <nodes> Number of nodes to start (default 1)');
@@ -2705,6 +3226,7 @@ function printUsage() {
2705
3226
  console.log(' --capture start: enable trace capture (recorded-input replay) on every node');
2706
3227
  console.log(' --in-memory start: run with NO on-disk journal/read-model (alias --no-journal; state lost on restart)');
2707
3228
  console.log(' --history-max <n> start: cap retained terminal instances in the read model (0/unset = unbounded)');
3229
+ console.log(' --console <profile> start: runtime console profile off|observe|studio (alias --profile; default studio)');
2708
3230
  console.log(' --binary <path> Path to the nanobpmn server binary (overrides "set bin")');
2709
3231
  console.log(' --purge stop: also delete per-node engine data');
2710
3232
  console.log(' --force start: stop any existing cluster first');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "c8ctl-plugin-nano",
3
- "version": "1.7.2",
3
+ "version": "1.9.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",
@@ -49,12 +49,12 @@
49
49
  "semantic-release": "^25.0.3"
50
50
  },
51
51
  "optionalDependencies": {
52
- "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.7.2",
53
- "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.7.2",
54
- "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.7.2",
55
- "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.7.2",
56
- "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.7.2",
57
- "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.7.2",
58
- "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.7.2"
52
+ "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.9.0",
53
+ "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.9.0",
54
+ "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.9.0",
55
+ "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.9.0",
56
+ "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.9.0",
57
+ "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.9.0",
58
+ "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.9.0"
59
59
  }
60
60
  }