c8ctl-plugin-nano 1.8.0 → 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
package/c8ctl-plugin.js CHANGED
@@ -45,6 +45,7 @@ import { homedir, platform as osPlatform } from 'node:os';
45
45
  import { join, isAbsolute, resolve as resolvePath, dirname, sep } from 'node:path';
46
46
  import { createRequire } from 'node:module';
47
47
  import { fileURLToPath } from 'node:url';
48
+ import { createInterface } from 'node:readline/promises';
48
49
  import { platformForHost } from './platforms.mjs';
49
50
 
50
51
  const requireFromHere = createRequire(import.meta.url);
@@ -363,7 +364,7 @@ function launcherEnvMarkers(resolved) {
363
364
  // Argument parsing
364
365
  // ---------------------------------------------------------------------------
365
366
 
366
- 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'];
367
368
 
368
369
  /**
369
370
  * Parse positional args + flags into a normalized request.
@@ -1293,6 +1294,461 @@ function showConfig() {
1293
1294
  console.log(' Change with: c8ctl nano set bin <path> | c8ctl nano set model-dir <path>');
1294
1295
  }
1295
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
+
1296
1752
  // ---------------------------------------------------------------------------
1297
1753
  // update — pull a new nanobpmn release onto a machine with an existing install.
1298
1754
  // The plugin (and the bundled server binary, shipped via the matching platform
@@ -2548,6 +3004,10 @@ export const metadata = {
2548
3004
  { command: 'c8ctl nano config', description: 'Show current plugin configuration and paths' },
2549
3005
  { command: 'c8ctl nano update', description: 'Pull the latest published nano release (re-installs via npm)' },
2550
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' },
2551
3011
  ],
2552
3012
  },
2553
3013
  processos: {
@@ -2589,6 +3049,14 @@ export const commands = {
2589
3049
  workspace: { type: 'boolean', description: 'clean: also delete the workspace (models + workers)' },
2590
3050
  check: { type: 'boolean', description: 'update: only report whether a new release is available; do not install' },
2591
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)' },
2592
3060
  },
2593
3061
  handler: async (args, flags) => {
2594
3062
  const logger = getLogger();
@@ -2637,6 +3105,12 @@ export const commands = {
2637
3105
  case 'update':
2638
3106
  updatePlugin(req);
2639
3107
  break;
3108
+ case 'hire':
3109
+ await hireWorker(req, flags);
3110
+ break;
3111
+ case 'work':
3112
+ await workAgent(req, flags);
3113
+ break;
2640
3114
  }
2641
3115
  } catch (error) {
2642
3116
  logger.error(`nano ${req.subcommand} failed: ${error instanceof Error ? error.message : error}`);
@@ -2725,6 +3199,8 @@ function printUsage() {
2725
3199
  console.log(' c8ctl nano set <bin|model-dir> <path>');
2726
3200
  console.log(' c8ctl nano config');
2727
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>]');
2728
3204
  console.log('');
2729
3205
  console.log('Subcommands:');
2730
3206
  console.log(' start Spawn an N-node local cluster wired to talk to each other on localhost');
@@ -2738,6 +3214,8 @@ function printUsage() {
2738
3214
  console.log(' set Persist a setting: "bin <path>" or "model-dir <path>"');
2739
3215
  console.log(' config Show current configuration and on-disk locations');
2740
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');
2741
3219
  console.log('');
2742
3220
  console.log('Options:');
2743
3221
  console.log(' <nodes> Number of nodes to start (default 1)');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "c8ctl-plugin-nano",
3
- "version": "1.8.0",
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.8.0",
53
- "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.8.0",
54
- "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.8.0",
55
- "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.8.0",
56
- "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.8.0",
57
- "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.8.0",
58
- "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.8.0"
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
  }