c8ctl-plugin-nano 1.8.0 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +129 -0
  2. package/c8ctl-plugin.js +971 -1
  3. package/package.json +9 -9
package/README.md CHANGED
@@ -130,6 +130,135 @@ 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
+
200
+ ### Task envelope, sandboxes & disk hygiene
201
+
202
+ For **agentic** jobs (an agent that clones a repo, works a task, pushes a
203
+ branch) the job carries a structured **task envelope** under the reserved
204
+ `io.nanobpm.agentTask` namespace. It is assembled from the job's static
205
+ `customHeaders` (model-authored defaults) deep-merged with per-instance
206
+ `variables` (**overrides win**), then normalized to schema v1 and included in the
207
+ stdin payload as `task`:
208
+
209
+ ```jsonc
210
+ {
211
+ "io.nanobpm.agentTask.repository.url": "https://github.com/o/r.git", // header
212
+ "io.nanobpm.agentTask.repository.ref": "main",
213
+ "io.nanobpm.agentTask.branch.push": "true",
214
+ "io.nanobpm.agentTask.task.allowPr": "false"
215
+ }
216
+ ```
217
+
218
+ Element templates emit flat dotpath header keys (strings); the plugin expands
219
+ them into a nested object and coerces `"true"/"false"` → bool and numeric
220
+ strings → int. The normalized shape is
221
+ `{ schemaVersion, repository{provider,url,ref,depth,submodules,authRef}, branch{base,create,push}, setup{commands,env,secretRefs}, task{prompt,promptFile,maxIterations,timeoutMs,allowPr,prBase} }`.
222
+ On completion the plugin writes an **output envelope** back under
223
+ `io.nanobpm.agentResult` (`{schemaVersion, status, sandbox, image, output, truncated, stderrTruncated, exitCode, signal, error}`).
224
+
225
+ **Sandbox.** By default the command runs on the host (`--sandbox none`). Pass
226
+ `--sandbox docker` (or `podman`) with an `--image` to run **each job in a
227
+ throwaway container** instead:
228
+
229
+ ```bash
230
+ c8ctl nano hire --name coder --rank senior --command "agent-harness" \
231
+ --sandbox docker --image ghcr.io/acme/agent:1
232
+ c8ctl nano work coder # uses the profile's sandbox/image
233
+ c8ctl nano work coder --sandbox docker --image ghcr.io/acme/agent:1 # or override
234
+ ```
235
+
236
+ Containers are labelled (`nano.managed=1`, `nano.worker`, `nano.jobKey`,
237
+ `nano.run=<uuid>`), log-capped (`max-size=10m max-file=3`), run with `--rm`, and
238
+ a run that outlives `--job-timeout` is force-removed. The envelope is piped on
239
+ the container's stdin exactly as on the host.
240
+
241
+ **Secrets.** Secrets are referenced by **name**, never value. `setup.secretRefs`
242
+ (and the repo/PR credential when `task.allowPr` is set — defaulting to
243
+ `GITHUB_TOKEN` for GitHub) are resolved via a pluggable `--secret-resolver`
244
+ (only `host`, reading `process.env`, is implemented) and forwarded into the
245
+ container by name (`-e NAME`) so values never appear in argv or `docker inspect`.
246
+ A missing required secret fails the job with a clear provisioning message.
247
+
248
+ **Disk hygiene.** Container sandboxes get automatic cleanup so leaked
249
+ containers can't fill the disk: a **label-scoped** reaper runs at worker startup
250
+ and on an interval (`--reap-interval`, **milliseconds**, default `300000` = 5m),
251
+ removing finished/`exited` containers older than `--reap-age` (**milliseconds**,
252
+ default `3600000` = 1h) while **skipping any run still in flight** — it never
253
+ touches containers it didn't create and never `system prune`s. A **disk-budget
254
+ admission shed** fails (retryable) new jobs when the engine data root has less
255
+ than `--min-free-mb` MB free (default `1024`).
256
+
257
+ > Git provisioning (clone/branch/push), agent-opened PRs, and the
258
+ > Vercel/Sandcastle provider are **increment 2** — the envelope names above are
259
+ > frozen so the [nano-ide element-template pack](https://github.com/jwulf/nano-ide/issues/37)
260
+ > can be built against this contract.
261
+
133
262
  ## Cleaning up disk
134
263
 
135
264
  ```bash
package/c8ctl-plugin.js CHANGED
@@ -40,11 +40,14 @@ import {
40
40
  chmodSync,
41
41
  renameSync,
42
42
  realpathSync,
43
+ statfsSync,
43
44
  } from 'node:fs';
45
+ import { randomUUID } from 'node:crypto';
44
46
  import { homedir, platform as osPlatform } from 'node:os';
45
47
  import { join, isAbsolute, resolve as resolvePath, dirname, sep } from 'node:path';
46
48
  import { createRequire } from 'node:module';
47
49
  import { fileURLToPath } from 'node:url';
50
+ import { createInterface } from 'node:readline/promises';
48
51
  import { platformForHost } from './platforms.mjs';
49
52
 
50
53
  const requireFromHere = createRequire(import.meta.url);
@@ -363,7 +366,7 @@ function launcherEnvMarkers(resolved) {
363
366
  // Argument parsing
364
367
  // ---------------------------------------------------------------------------
365
368
 
366
- const VALID_SUBCOMMANDS = ['start', 'stop', 'status', 'logs', 'log', 'restart', 'pause', 'resume', 'clean', 'set', 'config', 'update'];
369
+ const VALID_SUBCOMMANDS = ['start', 'stop', 'status', 'logs', 'log', 'restart', 'pause', 'resume', 'clean', 'set', 'config', 'update', 'hire', 'work'];
367
370
 
368
371
  /**
369
372
  * Parse positional args + flags into a normalized request.
@@ -1293,6 +1296,908 @@ function showConfig() {
1293
1296
  console.log(' Change with: c8ctl nano set bin <path> | c8ctl nano set model-dir <path>');
1294
1297
  }
1295
1298
 
1299
+ // ---------------------------------------------------------------------------
1300
+ // hire / work — CLI agent harness workers.
1301
+ //
1302
+ // A "hire" is a persisted agent profile (name, rank, CLI command, model,
1303
+ // capabilities). "work <name>" turns that profile into a set of Nano job
1304
+ // workers: one per job-type in the rank×capability matrix. When a job is
1305
+ // activated, the profile's CLI command is spawned fresh (one-shot), fed the job
1306
+ // as JSON on stdin, and its stdout is returned as the job's `output` variable.
1307
+ // ---------------------------------------------------------------------------
1308
+
1309
+ const RANKS = ['principal', 'senior', 'junior', 'decider'];
1310
+
1311
+ /** Normalize a capability list: trim, drop empties, de-dupe, sort (canonical). */
1312
+ function normalizeCapabilities(input) {
1313
+ const raw = Array.isArray(input)
1314
+ ? input
1315
+ : String(input || '').split(',');
1316
+ return [...new Set(raw.map((c) => String(c).trim().toLowerCase()).filter(Boolean))].sort();
1317
+ }
1318
+
1319
+ /** A profile name must be a safe, filesystem/token-friendly slug. */
1320
+ function isValidProfileName(name) {
1321
+ return typeof name === 'string' && /^[a-z0-9][a-z0-9._-]*$/i.test(name);
1322
+ }
1323
+
1324
+ /**
1325
+ * The job-type matrix a worker subscribes to, from a profile's rank
1326
+ * and sorted capabilities [c1, c2, ...]:
1327
+ * - `rank` (rank alone)
1328
+ * - `rank:c1`, `rank:c2` (rank + a single capability, "spread")
1329
+ * - `rank:c1+c2+...` (rank + all capabilities combined; only when >1 cap)
1330
+ * Delimiters: `:` separates rank from capabilities, `+` joins combined caps.
1331
+ * Capabilities are sorted so the combined token is canonical/predictable.
1332
+ */
1333
+ function jobTypeMatrix(rank, capabilities) {
1334
+ const caps = normalizeCapabilities(capabilities);
1335
+ const tokens = [rank];
1336
+ for (const c of caps) tokens.push(`${rank}:${c}`);
1337
+ if (caps.length > 1) tokens.push(`${rank}:${caps.join('+')}`);
1338
+ return [...new Set(tokens)];
1339
+ }
1340
+
1341
+ /** All persisted hire profiles, keyed by name. */
1342
+ function readHires() {
1343
+ const cfg = readConfig();
1344
+ // A JSON array is `typeof === 'object'` but drops string-keyed writes on
1345
+ // JSON.stringify, so treat only plain objects as a valid hires map.
1346
+ return cfg.hires && typeof cfg.hires === 'object' && !Array.isArray(cfg.hires) ? cfg.hires : {};
1347
+ }
1348
+
1349
+ /** Persist a single hire profile into config.json under `hires`. */
1350
+ function writeHire(profile) {
1351
+ const cfg = readConfig();
1352
+ if (!cfg.hires || typeof cfg.hires !== 'object' || Array.isArray(cfg.hires)) cfg.hires = {};
1353
+ cfg.hires[profile.name] = profile;
1354
+ writeConfig(cfg);
1355
+ }
1356
+
1357
+ /**
1358
+ * Validate and normalize a stored profile before use so a hand-edited or
1359
+ * version-skewed config.json can't produce undefined job types or an invalid
1360
+ * spawn. Returns the normalized profile, or a { error } describing the problem.
1361
+ */
1362
+ function normalizeStoredProfile(name, profile) {
1363
+ if (!profile || typeof profile !== 'object' || Array.isArray(profile)) {
1364
+ return { error: `profile "${name}" is not an object` };
1365
+ }
1366
+ const rank = String(profile.rank || '').trim().toLowerCase();
1367
+ if (!RANKS.includes(rank)) {
1368
+ return { error: `profile "${name}" has an invalid rank "${profile.rank}" (expected one of: ${RANKS.join(', ')})` };
1369
+ }
1370
+ const command = String(profile.command || '').trim();
1371
+ if (!command) {
1372
+ return { error: `profile "${name}" has no command to run` };
1373
+ }
1374
+ const sandbox = String(profile.sandbox || 'none').trim().toLowerCase();
1375
+ if (!SANDBOXES.includes(sandbox)) {
1376
+ return { error: `profile "${name}" has an invalid sandbox "${profile.sandbox}" (expected one of: ${SANDBOXES.join(', ')})` };
1377
+ }
1378
+ const image = typeof profile.image === 'string' ? profile.image.trim() : '';
1379
+ if (CONTAINER_SANDBOXES.has(sandbox) && !image) {
1380
+ return { error: `profile "${name}" uses sandbox "${sandbox}" but has no image` };
1381
+ }
1382
+ return {
1383
+ profile: {
1384
+ name,
1385
+ rank,
1386
+ command,
1387
+ model: typeof profile.model === 'string' ? profile.model.trim() : '',
1388
+ capabilities: normalizeCapabilities(profile.capabilities),
1389
+ sandbox,
1390
+ image,
1391
+ },
1392
+ };
1393
+ }
1394
+
1395
+ /**
1396
+ * hire — create (or overwrite) an agent profile. Interactive by default; every
1397
+ * field can also be supplied via a flag (--name/--rank/--command/--model/
1398
+ * --capabilities) for scripting. Prompts only for the fields still missing.
1399
+ * `--list` prints existing profiles instead.
1400
+ */
1401
+ async function hireWorker(req, flags) {
1402
+ const logger = getLogger();
1403
+
1404
+ if (flags?.list) {
1405
+ const hires = readHires();
1406
+ const names = Object.keys(hires);
1407
+ if (names.length === 0) {
1408
+ logger.info('No hires yet. Create one with: c8ctl nano hire');
1409
+ return;
1410
+ }
1411
+ logger.info('Hired agent profiles:');
1412
+ for (const name of names.sort()) {
1413
+ const p = hires[name];
1414
+ logger.info(` ${name} [${p.rank}] ${p.command} (model: ${p.model || '-'}; caps: ${normalizeCapabilities(p.capabilities).join(', ') || '-'})`);
1415
+ }
1416
+ logger.info('');
1417
+ logger.info('Put one to work with: c8ctl nano work <name>');
1418
+ return;
1419
+ }
1420
+
1421
+ // Seed from flags; prompt for anything still missing. Trim string flags so a
1422
+ // stray space can't be persisted into config.json or the spawned command.
1423
+ let name = flags?.name ? String(flags.name).trim() : req.positional[0];
1424
+ let rank = flags?.rank ? String(flags.rank).trim().toLowerCase() : undefined;
1425
+ let command = flags?.command !== undefined ? String(flags.command).trim() : undefined;
1426
+ let model = flags?.model !== undefined ? String(flags.model).trim() : undefined;
1427
+ let capabilities = flags?.capabilities !== undefined ? flags.capabilities : undefined;
1428
+ let sandbox = flags?.sandbox !== undefined ? String(flags.sandbox).trim().toLowerCase() : undefined;
1429
+ let image = flags?.image !== undefined ? String(flags.image).trim() : undefined;
1430
+
1431
+ const missingRequired = !name || !rank || !command;
1432
+ const missingOptional = model === undefined || capabilities === undefined;
1433
+ const interactive = process.stdin.isTTY && process.stdout.isTTY;
1434
+
1435
+ // Non-interactively only name/rank/command are required; model and
1436
+ // capabilities are optional (they default to empty), matching how the
1437
+ // interactive prompts label them.
1438
+ if (missingRequired && !interactive) {
1439
+ logger.error('Non-interactive: provide at least --name, --rank and --command.');
1440
+ logger.info('Example: c8ctl nano hire --name reviewer --rank senior --command copilot --model gpt-5 --capabilities code-review,testing');
1441
+ process.exit(1);
1442
+ }
1443
+
1444
+ if (interactive && (missingRequired || missingOptional)) {
1445
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
1446
+ try {
1447
+ console.log('Hire a CLI agent worker. Press Ctrl-C to cancel.');
1448
+ console.log('');
1449
+ while (!name) {
1450
+ const ans = (await rl.question('Profile name: ')).trim();
1451
+ if (isValidProfileName(ans)) { name = ans; break; }
1452
+ console.log(' Please use letters, digits, dot, dash or underscore.');
1453
+ }
1454
+ while (!rank) {
1455
+ const ans = (await rl.question(`Rank (${RANKS.join('|')}): `)).trim().toLowerCase();
1456
+ if (RANKS.includes(ans)) { rank = ans; break; }
1457
+ console.log(` Rank must be one of: ${RANKS.join(', ')}`);
1458
+ }
1459
+ while (!command) {
1460
+ const ans = (await rl.question('CLI command (e.g. copilot, claude, pi): ')).trim();
1461
+ if (ans) { command = ans; break; }
1462
+ console.log(' A command is required.');
1463
+ }
1464
+ if (model === undefined) {
1465
+ model = (await rl.question('Model name (optional): ')).trim();
1466
+ }
1467
+ if (capabilities === undefined) {
1468
+ capabilities = (await rl.question('Capabilities (comma-separated, optional): ')).trim();
1469
+ }
1470
+ } finally {
1471
+ rl.close();
1472
+ }
1473
+ }
1474
+
1475
+ // Optional fields default to empty when omitted (e.g. scripted invocations).
1476
+ if (model === undefined) model = '';
1477
+ if (capabilities === undefined) capabilities = '';
1478
+ if (sandbox === undefined || sandbox === '') sandbox = 'none';
1479
+ if (image === undefined) image = '';
1480
+
1481
+ if (!SANDBOXES.includes(sandbox)) {
1482
+ logger.error(`Invalid --sandbox "${sandbox}". Use one of: ${SANDBOXES.join(', ')}`);
1483
+ process.exit(1);
1484
+ }
1485
+ if (CONTAINER_SANDBOXES.has(sandbox) && !image) {
1486
+ logger.error(`--sandbox ${sandbox} requires --image <ref> (the container image the agent runs in).`);
1487
+ process.exit(1);
1488
+ }
1489
+
1490
+ if (!isValidProfileName(name)) {
1491
+ logger.error(`Invalid profile name "${name}". Use letters, digits, dot, dash or underscore.`);
1492
+ process.exit(1);
1493
+ }
1494
+ if (!RANKS.includes(rank)) {
1495
+ logger.error(`Invalid rank "${rank}". Must be one of: ${RANKS.join(', ')}`);
1496
+ process.exit(1);
1497
+ }
1498
+ if (!command) {
1499
+ logger.error('A CLI command is required.');
1500
+ process.exit(1);
1501
+ }
1502
+
1503
+ const existed = Boolean(readHires()[name]);
1504
+ const profile = {
1505
+ name,
1506
+ rank,
1507
+ command,
1508
+ model: model || '',
1509
+ capabilities: normalizeCapabilities(capabilities),
1510
+ sandbox,
1511
+ image: image || '',
1512
+ createdAt: new Date().toISOString(),
1513
+ };
1514
+ writeHire(profile);
1515
+
1516
+ const matrix = jobTypeMatrix(profile.rank, profile.capabilities);
1517
+ logger.info(`${existed ? 'Updated' : 'Hired'} "${name}" [${profile.rank}] → ${profile.command}`);
1518
+ logger.info(` model: ${profile.model || '(none)'}`);
1519
+ logger.info(` capabilities: ${profile.capabilities.join(', ') || '(none)'}`);
1520
+ logger.info(` sandbox: ${profile.sandbox}${CONTAINER_SANDBOXES.has(profile.sandbox) ? ` (image ${profile.image})` : ''}`);
1521
+ logger.info(` job types (${matrix.length}): ${matrix.join(' ')}`);
1522
+ logger.info(`Put it to work with: c8ctl nano work ${name}`);
1523
+ }
1524
+
1525
+ /**
1526
+ * Concatenate captured Buffer chunks into a UTF-8 string, dropping a trailing
1527
+ * incomplete multibyte sequence (which the byte cap may have split) so decoding
1528
+ * never emits a replacement char or pushes the string over the byte cap.
1529
+ */
1530
+ function joinCapped(chunks) {
1531
+ if (!chunks.length) return '';
1532
+ let buf = Buffer.concat(chunks);
1533
+ let i = buf.length - 1;
1534
+ let cont = 0;
1535
+ while (i >= 0 && (buf[i] & 0xc0) === 0x80 && cont < 3) { i -= 1; cont += 1; }
1536
+ if (i >= 0) {
1537
+ const lead = buf[i];
1538
+ let needed;
1539
+ if ((lead & 0x80) === 0x00) needed = 0;
1540
+ else if ((lead & 0xe0) === 0xc0) needed = 1;
1541
+ else if ((lead & 0xf0) === 0xe0) needed = 2;
1542
+ else if ((lead & 0xf8) === 0xf0) needed = 3;
1543
+ else needed = -1;
1544
+ if (needed > 0 && cont < needed) buf = buf.subarray(0, i);
1545
+ }
1546
+ return buf.toString('utf8');
1547
+ }
1548
+
1549
+ /**
1550
+ * Kill a spawned child and its whole process tree. With `detached: true` on
1551
+ * POSIX the child leads its own process group, so a negative PID signals every
1552
+ * process in that group (shell wrapper + the actual harness command). Falls
1553
+ * back to a plain child.kill() on Windows or if the group signal fails.
1554
+ */
1555
+ function killTree(child) {
1556
+ const pid = child.pid;
1557
+ if (process.platform !== 'win32' && typeof pid === 'number') {
1558
+ try { process.kill(-pid, 'SIGKILL'); return; } catch { /* fall through */ }
1559
+ }
1560
+ try { child.kill('SIGKILL'); } catch { /* already gone */ }
1561
+ }
1562
+
1563
+ // ===========================================================================
1564
+ // Agent task envelope + sandboxed execution (issue #8, increment 1)
1565
+ // ===========================================================================
1566
+
1567
+ // Reserved namespaces. The INPUT envelope is assembled from the job's static
1568
+ // customHeaders (model-authored defaults) deep-merged with per-instance
1569
+ // variables (overrides win), then normalized/coerced to schema v1. The OUTPUT
1570
+ // envelope is written back on the job's completion variables.
1571
+ const AGENT_TASK_NS = 'io.nanobpm.agentTask';
1572
+ const AGENT_RESULT_KEY = 'io.nanobpm.agentResult';
1573
+ const TASK_ENVELOPE_SCHEMA_VERSION = 1;
1574
+ // The result-envelope version is intentionally independent of the task-envelope
1575
+ // version so the two contracts can evolve separately without silently coupling.
1576
+ const RESULT_ENVELOPE_SCHEMA_VERSION = 1;
1577
+ const SANDBOXES = ['none', 'docker', 'podman'];
1578
+ // Only container-based sandboxes need an image / disk hygiene / a runtime bin.
1579
+ const CONTAINER_SANDBOXES = new Set(['docker', 'podman']);
1580
+
1581
+ function coerceBool(v, dflt = false) {
1582
+ if (typeof v === 'boolean') return v;
1583
+ if (v == null) return dflt;
1584
+ const s = String(v).trim().toLowerCase();
1585
+ if (['true', '1', 'yes', 'on'].includes(s)) return true;
1586
+ if (['false', '0', 'no', 'off', ''].includes(s)) return false;
1587
+ return dflt;
1588
+ }
1589
+
1590
+ function coerceInt(v, dflt) {
1591
+ if (v == null || v === '') return dflt;
1592
+ const n = Number.parseInt(String(v), 10);
1593
+ return Number.isFinite(n) ? n : dflt;
1594
+ }
1595
+
1596
+ function isPlainObject(v) {
1597
+ return v != null && typeof v === 'object' && !Array.isArray(v);
1598
+ }
1599
+
1600
+ function deepMerge(base, over) {
1601
+ if (!isPlainObject(base)) return isPlainObject(over) ? deepMerge({}, over) : over;
1602
+ const out = { ...base };
1603
+ if (!isPlainObject(over)) return out;
1604
+ for (const [k, v] of Object.entries(over)) {
1605
+ if (v === undefined) continue;
1606
+ out[k] = isPlainObject(v) && isPlainObject(out[k]) ? deepMerge(out[k], v) : v;
1607
+ }
1608
+ return out;
1609
+ }
1610
+
1611
+ function setPath(obj, path, value) {
1612
+ let cur = obj;
1613
+ for (let i = 0; i < path.length - 1; i++) {
1614
+ const k = path[i];
1615
+ if (!isPlainObject(cur[k])) cur[k] = {};
1616
+ cur = cur[k];
1617
+ }
1618
+ cur[path[path.length - 1]] = value;
1619
+ }
1620
+
1621
+ // Collect the reserved namespace out of a flat key→value map (customHeaders or
1622
+ // variables). Supports both a single `io.nanobpm.agentTask` key whose value is
1623
+ // a JSON string/object, AND flattened dotpath keys like
1624
+ // `io.nanobpm.agentTask.repository.ref` (element templates emit the latter).
1625
+ function collectEnvelopeFrom(source) {
1626
+ const out = {};
1627
+ if (!isPlainObject(source)) return out;
1628
+ const whole = source[AGENT_TASK_NS];
1629
+ if (whole != null) {
1630
+ let val = whole;
1631
+ if (typeof whole === 'string') {
1632
+ try { val = JSON.parse(whole); } catch { val = undefined; }
1633
+ }
1634
+ if (isPlainObject(val)) Object.assign(out, deepMerge(out, val));
1635
+ }
1636
+ const prefix = `${AGENT_TASK_NS}.`;
1637
+ for (const [key, value] of Object.entries(source)) {
1638
+ if (!key.startsWith(prefix)) continue;
1639
+ const rest = key.slice(prefix.length);
1640
+ if (!rest) continue;
1641
+ setPath(out, rest.split('.'), value);
1642
+ }
1643
+ return out;
1644
+ }
1645
+
1646
+ // Normalize the assembled envelope to schema v1, coercing header string values
1647
+ // (element templates write everything as strings) into bool/int as needed.
1648
+ function normalizeTaskEnvelope(customHeaders, variables) {
1649
+ const raw = deepMerge(collectEnvelopeFrom(customHeaders), collectEnvelopeFrom(variables));
1650
+ const str = (v) => (v == null ? undefined : String(v));
1651
+ const env = {
1652
+ // Normalization always emits the v1 shape, so the version is forced to v1
1653
+ // (the raw input version is only a hint about how the author authored it).
1654
+ schemaVersion: TASK_ENVELOPE_SCHEMA_VERSION,
1655
+ };
1656
+
1657
+ const repo = raw.repository;
1658
+ if (isPlainObject(repo) && str(repo.url)) {
1659
+ env.repository = {
1660
+ provider: str(repo.provider) || 'github',
1661
+ url: str(repo.url),
1662
+ ref: str(repo.ref),
1663
+ depth: coerceInt(repo.depth, undefined),
1664
+ submodules: coerceBool(repo.submodules, false),
1665
+ authRef: str(repo.authRef),
1666
+ };
1667
+ }
1668
+
1669
+ const branch = isPlainObject(raw.branch) ? raw.branch : {};
1670
+ env.branch = {
1671
+ base: str(branch.base),
1672
+ create: str(branch.create),
1673
+ push: coerceBool(branch.push, true),
1674
+ };
1675
+
1676
+ const setup = isPlainObject(raw.setup) ? raw.setup : {};
1677
+ env.setup = {
1678
+ commands: Array.isArray(setup.commands) ? setup.commands.map(String) : [],
1679
+ env: isPlainObject(setup.env) ? setup.env : {},
1680
+ secretRefs: Array.isArray(setup.secretRefs) ? setup.secretRefs.map(String) : [],
1681
+ };
1682
+
1683
+ const task = isPlainObject(raw.task) ? raw.task : {};
1684
+ env.task = {
1685
+ prompt: str(task.prompt) ?? str(variables?.prompt) ?? str(variables?.task),
1686
+ promptFile: str(task.promptFile),
1687
+ maxIterations: coerceInt(task.maxIterations, undefined),
1688
+ timeoutMs: coerceInt(task.timeoutMs, undefined),
1689
+ allowPr: coerceBool(task.allowPr, false),
1690
+ prBase: str(task.prBase),
1691
+ };
1692
+
1693
+ return env;
1694
+ }
1695
+
1696
+ // ---- Secret resolution (pluggable; only host-env implemented for now) ------
1697
+ // Secrets are referenced by NAME, never by value, in the model. A resolver maps
1698
+ // a name → value at run time. The wrapper injects them into the child ENV, so
1699
+ // values never appear in argv or `docker inspect`.
1700
+ const hostEnvSecretResolver = {
1701
+ kind: 'host',
1702
+ resolve(name) {
1703
+ const v = process.env[name];
1704
+ return v == null || v === '' ? undefined : v;
1705
+ },
1706
+ };
1707
+
1708
+ function makeSecretResolver(kind) {
1709
+ const k = (kind || 'host').trim().toLowerCase();
1710
+ if (k === 'host' || k === '') return hostEnvSecretResolver;
1711
+ return null; // unknown → caller reports a clear error
1712
+ }
1713
+
1714
+ // Resolve the names a job needs (setup.secretRefs, plus the repo/PR credential
1715
+ // when allowPr). Returns resolved values + a list of names that were missing so
1716
+ // the caller can fail the job with a clear provisioning error.
1717
+ function resolveJobSecrets(resolver, envelope) {
1718
+ const names = new Set();
1719
+ for (const n of envelope.setup?.secretRefs || []) if (n) names.add(n);
1720
+ if (envelope.task?.allowPr) {
1721
+ const provider = envelope.repository?.provider || 'github';
1722
+ const authRef = envelope.repository?.authRef || (provider === 'github' ? 'GITHUB_TOKEN' : undefined);
1723
+ if (authRef) names.add(authRef);
1724
+ }
1725
+ const resolved = {};
1726
+ const missing = [];
1727
+ for (const name of names) {
1728
+ const v = resolver.resolve(name);
1729
+ if (v === undefined) missing.push(name);
1730
+ else resolved[name] = v;
1731
+ }
1732
+ return { resolved, missing, names: [...names] };
1733
+ }
1734
+
1735
+ // ---- Disk hygiene (container sandboxes only) -------------------------------
1736
+ const CONTAINER_LABEL = 'nano.managed=1';
1737
+
1738
+ function containerEngineAvailable(engine) {
1739
+ try {
1740
+ const r = spawnSync(engine, ['version', '--format', '{{.Server.Version}}'], { encoding: 'utf8', timeout: 10_000 });
1741
+ return r.status === 0;
1742
+ } catch {
1743
+ return false;
1744
+ }
1745
+ }
1746
+
1747
+ // Resolve the container engine's data root. Returns null when it can't be
1748
+ // determined so the caller can fail OPEN (never fall back to an unrelated path
1749
+ // like the OS temp dir, which would shed on the wrong filesystem's free space).
1750
+ function dockerRootDir(engine) {
1751
+ try {
1752
+ const r = spawnSync(engine, ['info', '-f', '{{.DockerRootDir}}'], { encoding: 'utf8', timeout: 10_000 });
1753
+ const dir = (r.stdout || '').trim();
1754
+ if (r.status === 0 && dir) return dir;
1755
+ } catch { /* fall through */ }
1756
+ return null;
1757
+ }
1758
+
1759
+ // Fail-open disk-budget check: shed work when free space on the engine's data
1760
+ // root drops below the configured floor (mirrors nano's admission-shed pattern).
1761
+ function diskBudgetOk(engine, minFreeBytes) {
1762
+ if (!minFreeBytes || minFreeBytes <= 0) return { ok: true, free: null };
1763
+ try {
1764
+ if (typeof statfsSync !== 'function') return { ok: true, free: null };
1765
+ const root = dockerRootDir(engine);
1766
+ if (!root) return { ok: true, free: null }; // can't resolve the real root → fail open
1767
+ const st = statfsSync(root);
1768
+ const free = st.bavail * st.bsize;
1769
+ return { ok: free >= minFreeBytes, free };
1770
+ } catch {
1771
+ return { ok: true, free: null }; // never block work on a stat failure
1772
+ }
1773
+ }
1774
+
1775
+ // Reap our own leaked/finished containers. Label-scoped (never touches anything
1776
+ // we didn't create — safe on shared hosts, NEVER `system prune -a`), age-gated,
1777
+ // and skips any run id still in flight.
1778
+ function reapAgentContainers(engine, { maxAgeMs = 0, liveRunIds = new Set() } = {}) {
1779
+ let reaped = 0;
1780
+ try {
1781
+ const fmt = '{{.ID}}\t{{.Label "nano.run"}}\t{{.State}}\t{{.CreatedAt}}';
1782
+ const r = spawnSync(engine, ['ps', '-a', '--filter', `label=${CONTAINER_LABEL}`, '--format', fmt], { encoding: 'utf8', timeout: 15_000 });
1783
+ if (r.status !== 0) return { reaped, error: (r.stderr || '').trim() || 'ps failed' };
1784
+ const now = Date.now();
1785
+ for (const line of (r.stdout || '').split('\n')) {
1786
+ if (!line.trim()) continue;
1787
+ const [id, run, state, created] = line.split('\t');
1788
+ if (run && liveRunIds.has(run)) continue; // in-flight; leave it
1789
+ if (!/exited|dead|created/i.test(state || '')) continue; // only finished/stuck
1790
+ if (maxAgeMs > 0) {
1791
+ const createdMs = Date.parse(created || '');
1792
+ if (Number.isFinite(createdMs) && now - createdMs < maxAgeMs) continue;
1793
+ }
1794
+ const rm = spawnSync(engine, ['rm', '-f', id], { timeout: 15_000 });
1795
+ if (rm.status === 0) reaped++;
1796
+ }
1797
+ } catch (err) {
1798
+ return { reaped, error: err.message };
1799
+ }
1800
+ return { reaped };
1801
+ }
1802
+
1803
+ // ---- One-shot capture (shared by host + container executors) ---------------
1804
+ const MAX_CAPTURE_BYTES = 1_048_576; // 1 MiB per stream
1805
+
1806
+ // Spawn a child, pipe `stdinData`, capture byte-capped stdout/stderr, enforce a
1807
+ // timeout (invoking `onTimeout(child)` to tear the child down), and resolve to a
1808
+ // uniform result. Used by both the host and container executors.
1809
+ function spawnCaptureOneShot({ command, args = [], shell = false, detached = false, env, stdinData, timeoutMs, onTimeout }) {
1810
+ return new Promise((resolve) => {
1811
+ let child;
1812
+ const stdoutChunks = [];
1813
+ const stderrChunks = [];
1814
+ let stdoutBytes = 0;
1815
+ let stderrBytes = 0;
1816
+ let stdoutTruncated = false;
1817
+ let stderrTruncated = false;
1818
+ let settled = false;
1819
+ let timer = null;
1820
+
1821
+ const finish = (result) => {
1822
+ if (settled) return;
1823
+ settled = true;
1824
+ if (timer) clearTimeout(timer);
1825
+ resolve(result);
1826
+ };
1827
+
1828
+ try {
1829
+ child = spawn(command, args, { shell, detached, stdio: ['pipe', 'pipe', 'pipe'], env });
1830
+ } catch (err) {
1831
+ finish({ ok: false, exitCode: null, stdout: '', stderr: '', error: err.message, truncated: false, stderrTruncated: false });
1832
+ return;
1833
+ }
1834
+
1835
+ timer = timeoutMs && timeoutMs > 0
1836
+ ? setTimeout(() => {
1837
+ try { if (onTimeout) onTimeout(child); } catch { /* best effort */ }
1838
+ finish({ ok: false, exitCode: null, stdout: joinCapped(stdoutChunks), stderr: joinCapped(stderrChunks), error: `timed out after ${timeoutMs}ms`, timedOut: true, truncated: stdoutTruncated, stderrTruncated });
1839
+ }, timeoutMs)
1840
+ : null;
1841
+
1842
+ child.stdout.on('data', (d) => {
1843
+ const buf = Buffer.isBuffer(d) ? d : Buffer.from(d);
1844
+ const remaining = MAX_CAPTURE_BYTES - stdoutBytes;
1845
+ if (remaining <= 0) { stdoutTruncated = true; return; }
1846
+ if (buf.length > remaining) { stdoutChunks.push(buf.subarray(0, remaining)); stdoutBytes = MAX_CAPTURE_BYTES; stdoutTruncated = true; }
1847
+ else { stdoutChunks.push(buf); stdoutBytes += buf.length; }
1848
+ });
1849
+ child.stderr.on('data', (d) => {
1850
+ const buf = Buffer.isBuffer(d) ? d : Buffer.from(d);
1851
+ const remaining = MAX_CAPTURE_BYTES - stderrBytes;
1852
+ if (remaining <= 0) { stderrTruncated = true; return; }
1853
+ if (buf.length > remaining) { stderrChunks.push(buf.subarray(0, remaining)); stderrBytes = MAX_CAPTURE_BYTES; stderrTruncated = true; }
1854
+ else { stderrChunks.push(buf); stderrBytes += buf.length; }
1855
+ });
1856
+
1857
+ child.on('error', (err) => {
1858
+ finish({ ok: false, exitCode: null, stdout: joinCapped(stdoutChunks), stderr: joinCapped(stderrChunks), error: err.message, truncated: stdoutTruncated, stderrTruncated });
1859
+ });
1860
+ child.on('close', (code, signal) => {
1861
+ finish({ ok: code === 0, exitCode: code, signal: signal ?? null, stdout: joinCapped(stdoutChunks), stderr: joinCapped(stderrChunks), truncated: stdoutTruncated, stderrTruncated });
1862
+ });
1863
+
1864
+ child.stdin.on('error', () => {});
1865
+ try {
1866
+ if (stdinData != null) child.stdin.write(stdinData);
1867
+ child.stdin.end();
1868
+ } catch { /* 'error' handler resolves on failure */ }
1869
+ });
1870
+ }
1871
+
1872
+ function buildAgentPayload(profile, job, envelope) {
1873
+ const variables = job.variables && typeof job.variables === 'object' ? job.variables : {};
1874
+ return {
1875
+ jobKey: job.jobKey,
1876
+ jobType: job.type,
1877
+ processInstanceKey: job.processInstanceKey ?? null,
1878
+ elementInstanceKey: job.elementInstanceKey ?? null,
1879
+ elementId: job.elementId ?? null,
1880
+ bpmnProcessId: job.bpmnProcessId ?? job.processDefinitionId ?? null,
1881
+ prompt: envelope?.task?.prompt ?? variables.prompt ?? variables.task ?? null,
1882
+ task: envelope || null,
1883
+ variables,
1884
+ customHeaders: job.customHeaders ?? {},
1885
+ profile: {
1886
+ name: profile.name,
1887
+ rank: profile.rank,
1888
+ model: profile.model,
1889
+ capabilities: profile.capabilities,
1890
+ },
1891
+ };
1892
+ }
1893
+
1894
+ function baseAgentEnv(profile, job) {
1895
+ return {
1896
+ AGENT_PROFILE: profile.name,
1897
+ AGENT_RANK: profile.rank,
1898
+ AGENT_MODEL: profile.model || '',
1899
+ AGENT_CAPABILITIES: (profile.capabilities || []).join(','),
1900
+ AGENT_JOB_TYPE: String(job.type ?? ''),
1901
+ };
1902
+ }
1903
+
1904
+ /**
1905
+ * Run a single activated job through the profile's CLI command (one-shot),
1906
+ * dispatching on the profile's sandbox:
1907
+ * - none → spawn the command on the host (legacy behaviour).
1908
+ * - docker | podman → `run --rm` a labelled, log-capped container, piping the
1909
+ * task envelope on stdin; a run that outlives the timeout
1910
+ * is force-removed so it never leaks a slot or disk.
1911
+ * Both paths resolve to the same result contract.
1912
+ */
1913
+ function runAgentJob(profile, job, opts = {}) {
1914
+ const { timeoutMs, envelope, sandbox = 'none', image, runId, secretEnv = {}, passThroughSecretNames = [] } = opts;
1915
+ const payload = JSON.stringify(buildAgentPayload(profile, job, envelope));
1916
+ const agentEnv = baseAgentEnv(profile, job);
1917
+
1918
+ if (!CONTAINER_SANDBOXES.has(sandbox)) {
1919
+ return spawnCaptureOneShot({
1920
+ command: profile.command,
1921
+ shell: true,
1922
+ // Own process group so the timeout handler can kill the whole tree.
1923
+ detached: process.platform !== 'win32',
1924
+ env: { ...process.env, ...agentEnv, ...secretEnv },
1925
+ stdinData: payload,
1926
+ timeoutMs,
1927
+ onTimeout: (child) => killTree(child),
1928
+ });
1929
+ }
1930
+
1931
+ const engine = sandbox;
1932
+ const containerName = `nano-${runId}`;
1933
+ // Forward env by NAME only (`-e NAME`) so secret VALUES stay out of argv and
1934
+ // `docker inspect`; docker reads the value from our child's environment.
1935
+ const envArgs = [];
1936
+ for (const k of Object.keys(agentEnv)) envArgs.push('-e', k);
1937
+ for (const n of passThroughSecretNames) envArgs.push('-e', n);
1938
+ const setupEnv = isPlainObject(envelope?.setup?.env) ? envelope.setup.env : {};
1939
+ const setupEnvValues = {};
1940
+ for (const [k, v] of Object.entries(setupEnv)) { envArgs.push('-e', k); setupEnvValues[k] = String(v); }
1941
+
1942
+ const args = [
1943
+ 'run', '--rm', '-i',
1944
+ '--name', containerName,
1945
+ '--label', CONTAINER_LABEL,
1946
+ '--label', `nano.worker=${profile.name}`,
1947
+ '--label', `nano.jobKey=${job.jobKey}`,
1948
+ '--label', `nano.run=${runId}`,
1949
+ '--log-opt', 'max-size=10m',
1950
+ '--log-opt', 'max-file=3',
1951
+ ...envArgs,
1952
+ image,
1953
+ 'sh', '-c', profile.command,
1954
+ ];
1955
+
1956
+ return spawnCaptureOneShot({
1957
+ command: engine,
1958
+ args,
1959
+ shell: false,
1960
+ env: { ...process.env, ...agentEnv, ...secretEnv, ...setupEnvValues },
1961
+ stdinData: payload,
1962
+ timeoutMs,
1963
+ onTimeout: (child) => {
1964
+ try { spawnSync(engine, ['rm', '-f', containerName], { timeout: 15_000 }); } catch { /* best effort */ }
1965
+ try { killTree(child); } catch { /* best effort */ }
1966
+ },
1967
+ });
1968
+ }
1969
+
1970
+ // Shape the io.nanobpm.agentResult output envelope. branch/commits/pr are
1971
+ // reserved for increment 2 (git provisioning); increment 1 reports execution.
1972
+ function buildResultEnvelope(result, { sandbox, image }) {
1973
+ const status = result.ok ? 'completed' : (result.timedOut ? 'timedOut' : 'failed');
1974
+ return {
1975
+ schemaVersion: RESULT_ENVELOPE_SCHEMA_VERSION,
1976
+ status,
1977
+ sandbox,
1978
+ image: image || null,
1979
+ output: result.stdout ?? '',
1980
+ truncated: !!result.truncated,
1981
+ stderrTruncated: !!result.stderrTruncated,
1982
+ exitCode: result.exitCode ?? null,
1983
+ signal: result.signal ?? null,
1984
+ error: result.error ?? null,
1985
+ };
1986
+ }
1987
+
1988
+ /**
1989
+ * work — turn a hire profile into live Nano job workers (one per job-type in
1990
+ * the rank×capability matrix) and poll for work in the foreground until Ctrl-C.
1991
+ * Uses the c8ctl-provided SDK client (globalThis.c8ctl.createClient()).
1992
+ */
1993
+ async function workAgent(req, flags) {
1994
+ const logger = getLogger();
1995
+ const name = flags?.name ? String(flags.name).trim() : req.positional[0];
1996
+
1997
+ if (!name) {
1998
+ const hires = readHires();
1999
+ const names = Object.keys(hires).sort();
2000
+ logger.error('Usage: c8ctl nano work <profileName>');
2001
+ if (names.length > 0) logger.info(`Profiles: ${names.join(', ')}`);
2002
+ else logger.info('No hires yet. Create one with: c8ctl nano hire');
2003
+ process.exit(1);
2004
+ }
2005
+
2006
+ const stored = readHires()[name];
2007
+ if (!stored) {
2008
+ logger.error(`No hire named "${name}". List profiles with: c8ctl nano hire --list`);
2009
+ process.exit(1);
2010
+ }
2011
+ const normalized = normalizeStoredProfile(name, stored);
2012
+ if (normalized.error) {
2013
+ logger.error(`Cannot work "${name}": ${normalized.error}. Re-create it with: c8ctl nano hire`);
2014
+ process.exit(1);
2015
+ }
2016
+ const profile = normalized.profile;
2017
+
2018
+ if (!globalThis.c8ctl || typeof globalThis.c8ctl.createClient !== 'function') {
2019
+ logger.error('work requires the c8ctl runtime (createClient). Run it via the c8ctl CLI.');
2020
+ process.exit(1);
2021
+ }
2022
+
2023
+ const intFlag = (v, dflt) => {
2024
+ const n = Number.parseInt(String(v ?? ''), 10);
2025
+ return Number.isFinite(n) && n > 0 ? n : dflt;
2026
+ };
2027
+ const maxParallelJobs = intFlag(flags?.['max-parallel'], 1);
2028
+ const jobTimeoutMs = intFlag(flags?.['job-timeout'], 5 * 60_000);
2029
+
2030
+ // Sandbox: flag overrides the stored profile default. `none` runs on the host
2031
+ // (legacy); `docker`/`podman` run each job in a throwaway labelled container.
2032
+ const sandbox = String(flags?.sandbox ?? profile.sandbox ?? 'none').trim().toLowerCase();
2033
+ if (!SANDBOXES.includes(sandbox)) {
2034
+ logger.error(`Invalid --sandbox "${sandbox}". Use one of: ${SANDBOXES.join(', ')}`);
2035
+ process.exit(1);
2036
+ }
2037
+ const image = flags?.image ? String(flags.image).trim() : (profile.image || '');
2038
+ const isContainer = CONTAINER_SANDBOXES.has(sandbox);
2039
+ if (isContainer && !image) {
2040
+ logger.error(`--sandbox ${sandbox} requires an --image (or hire the profile with --image).`);
2041
+ process.exit(1);
2042
+ }
2043
+
2044
+ const secretResolver = makeSecretResolver(flags?.['secret-resolver']);
2045
+ if (!secretResolver) {
2046
+ logger.error(`Unknown --secret-resolver "${flags?.['secret-resolver']}". Only "host" is supported.`);
2047
+ process.exit(1);
2048
+ }
2049
+
2050
+ // Disk-hygiene knobs (container sandboxes only). Reaper age + interval and the
2051
+ // free-space admission floor mirror nano's own disk-budget/reaper patterns.
2052
+ const reapAgeMs = intFlag(flags?.['reap-age'], 60 * 60_000); // 1h
2053
+ const reapIntervalMs = intFlag(flags?.['reap-interval'], 5 * 60_000); // 5m
2054
+ const minFreeBytes = flags?.['min-free-mb'] != null
2055
+ ? Math.max(0, intFlag(flags['min-free-mb'], 0)) * 1_048_576
2056
+ : 1_073_741_824; // 1 GiB default floor
2057
+
2058
+ // Tracks run ids currently executing so the reaper never removes a live
2059
+ // container out from under an in-flight job.
2060
+ const liveRunIds = new Set();
2061
+ let reaperTimer = null;
2062
+
2063
+ if (isContainer) {
2064
+ if (!containerEngineAvailable(sandbox)) {
2065
+ logger.error(`--sandbox ${sandbox} selected but "${sandbox}" is not available/running on this host.`);
2066
+ process.exit(1);
2067
+ }
2068
+ // Age-gate the startup sweep too (not maxAgeMs:0): on a shared host other
2069
+ // worker processes may have just-created containers not yet in liveRunIds,
2070
+ // so only reap ones older than --reap-age, matching the interval reaper.
2071
+ const initial = reapAgentContainers(sandbox, { maxAgeMs: reapAgeMs, liveRunIds });
2072
+ if (initial.reaped > 0) logger.info(`Reaped ${initial.reaped} leftover agent container(s) at startup.`);
2073
+ if (initial.error) logger.warn(`Startup reap warning: ${initial.error}`);
2074
+ reaperTimer = setInterval(() => {
2075
+ const r = reapAgentContainers(sandbox, { maxAgeMs: reapAgeMs, liveRunIds });
2076
+ if (r.reaped > 0) logger.info(`Reaper removed ${r.reaped} finished agent container(s).`);
2077
+ }, reapIntervalMs);
2078
+ if (typeof reaperTimer.unref === 'function') reaperTimer.unref();
2079
+ }
2080
+
2081
+ const matrix = jobTypeMatrix(profile.rank, profile.capabilities);
2082
+ const camunda = globalThis.c8ctl.createClient();
2083
+
2084
+ logger.info(`Putting "${name}" [${profile.rank}] to work → ${profile.command}`);
2085
+ logger.info(` model: ${profile.model || '(none)'}; capabilities: ${profile.capabilities.join(', ') || '(none)'}`);
2086
+ logger.info(` sandbox: ${sandbox}${isContainer ? ` (image ${image})` : ''}`);
2087
+ logger.info(` listening on ${matrix.length} job type(s): ${matrix.join(' ')}`);
2088
+ logger.info(` max parallel: ${maxParallelJobs}; job timeout: ${jobTimeoutMs}ms`);
2089
+ logger.info('Polling for work — press Ctrl-C to stop.');
2090
+
2091
+ const workers = matrix.map((jobType) =>
2092
+ camunda.createJobWorker({
2093
+ jobType,
2094
+ workerName: `${name}:${jobType}`,
2095
+ maxParallelJobs,
2096
+ jobTimeoutMs,
2097
+ jobHandler: async (job) => {
2098
+ logger.info(`[${jobType}] job ${job.jobKey} (instance ${job.processInstanceKey ?? '-'}) → ${profile.command}`);
2099
+
2100
+ // Disk-budget admission shed: if the engine data root is below the free
2101
+ // floor, don't start a container — fail (retryable) so work sheds until
2102
+ // the reaper/host frees space.
2103
+ if (isContainer) {
2104
+ const budget = diskBudgetOk(sandbox, minFreeBytes);
2105
+ if (!budget.ok) {
2106
+ const freeMb = budget.free != null ? Math.round(budget.free / 1_048_576) : '?';
2107
+ const retries = Math.max(0, (Number(job.retries) || 1) - 1);
2108
+ logger.warn(`[${jobType}] job ${job.jobKey} shed — low disk (${freeMb}MB free); retries left ${retries}`);
2109
+ return job.fail({ errorMessage: `disk budget exceeded (only ${freeMb}MB free)`, retries, retryBackOff: 30_000 });
2110
+ }
2111
+ }
2112
+
2113
+ // Assemble + normalize the task envelope from headers (defaults) and
2114
+ // variables (overrides), then resolve any secrets it references.
2115
+ const envelope = normalizeTaskEnvelope(job.customHeaders ?? {}, job.variables ?? {});
2116
+ const { resolved, missing, names } = resolveJobSecrets(secretResolver, envelope);
2117
+ if (missing.length > 0) {
2118
+ const retries = Math.max(0, (Number(job.retries) || 1) - 1);
2119
+ const msg = `missing secret(s): ${missing.join(', ')} (resolver: ${secretResolver.kind})`;
2120
+ logger.warn(`[${jobType}] job ${job.jobKey} not provisioned — ${msg}; retries left ${retries}`);
2121
+ return job.fail({ errorMessage: msg, retries });
2122
+ }
2123
+
2124
+ const runId = randomUUID();
2125
+ if (isContainer) liveRunIds.add(runId);
2126
+ let result;
2127
+ try {
2128
+ result = await runAgentJob(profile, job, {
2129
+ timeoutMs: jobTimeoutMs,
2130
+ envelope,
2131
+ sandbox,
2132
+ image,
2133
+ runId,
2134
+ secretEnv: resolved,
2135
+ passThroughSecretNames: names,
2136
+ });
2137
+ } finally {
2138
+ if (isContainer) liveRunIds.delete(runId);
2139
+ }
2140
+
2141
+ const resultEnvelope = buildResultEnvelope(result, { sandbox, image });
2142
+ if (result.ok) {
2143
+ logger.info(`[${jobType}] job ${job.jobKey} complete (exit 0)${result.truncated ? ' [output truncated]' : ''}`);
2144
+ return job.complete({
2145
+ [AGENT_RESULT_KEY]: resultEnvelope,
2146
+ output: result.stdout,
2147
+ exitCode: 0,
2148
+ agent: profile.name,
2149
+ truncated: Boolean(result.truncated),
2150
+ });
2151
+ }
2152
+ const retries = Math.max(0, (Number(job.retries) || 1) - 1);
2153
+ const detail = result.error
2154
+ || (result.stderr || '').trim() + (result.stderrTruncated && (result.stderr || '').trim() ? ' [stderr truncated]' : '')
2155
+ || (result.signal ? `terminated by signal ${result.signal}` : `exit code ${result.exitCode}`);
2156
+ logger.warn(`[${jobType}] job ${job.jobKey} failed (${detail}); retries left ${retries}`);
2157
+ return job.fail({
2158
+ errorMessage: `agent "${profile.name}" failed: ${detail}`.slice(0, 2000),
2159
+ retries,
2160
+ variables: { [AGENT_RESULT_KEY]: resultEnvelope },
2161
+ });
2162
+ },
2163
+ }),
2164
+ );
2165
+
2166
+ // Keep the process alive until a stop signal, then drain gracefully.
2167
+ await new Promise((resolve) => {
2168
+ let stopping = false;
2169
+ const stop = async (signal) => {
2170
+ if (stopping) return;
2171
+ stopping = true;
2172
+ logger.info(`Received ${signal} — stopping ${workers.length} worker(s)...`);
2173
+ if (reaperTimer) clearInterval(reaperTimer);
2174
+ let stopFailures = 0;
2175
+ await Promise.all(
2176
+ workers.map(async (w) => {
2177
+ try {
2178
+ if (typeof w.stopGracefully === 'function') {
2179
+ await w.stopGracefully({ waitUpToMs: STOP_GRACE_MS });
2180
+ } else if (typeof w.stop === 'function') {
2181
+ await w.stop();
2182
+ }
2183
+ } catch {
2184
+ // best-effort: never let one worker's stop failure hang shutdown
2185
+ stopFailures += 1;
2186
+ }
2187
+ }),
2188
+ );
2189
+ if (stopFailures > 0) {
2190
+ logger.warn(`${stopFailures} of ${workers.length} worker(s) did not stop cleanly; some connections may still be open.`);
2191
+ } else {
2192
+ logger.info('All workers stopped.');
2193
+ }
2194
+ resolve();
2195
+ };
2196
+ process.once('SIGINT', () => { stop('SIGINT'); });
2197
+ process.once('SIGTERM', () => { stop('SIGTERM'); });
2198
+ });
2199
+ }
2200
+
1296
2201
  // ---------------------------------------------------------------------------
1297
2202
  // update — pull a new nanobpmn release onto a machine with an existing install.
1298
2203
  // The plugin (and the bundled server binary, shipped via the matching platform
@@ -2517,6 +3422,27 @@ function parseProcessosRequest(args, flags) {
2517
3422
  // Internal helpers exported for tests/tooling only. c8ctl consumes just
2518
3423
  // `metadata` and `commands`; these named exports are inert to it.
2519
3424
  export { resolveBinary, findBinary, launcherEnvMarkers };
3425
+ export {
3426
+ normalizeTaskEnvelope,
3427
+ collectEnvelopeFrom,
3428
+ coerceBool,
3429
+ coerceInt,
3430
+ deepMerge,
3431
+ resolveJobSecrets,
3432
+ makeSecretResolver,
3433
+ hostEnvSecretResolver,
3434
+ buildAgentPayload,
3435
+ buildResultEnvelope,
3436
+ reapAgentContainers,
3437
+ diskBudgetOk,
3438
+ containerEngineAvailable,
3439
+ runAgentJob,
3440
+ normalizeStoredProfile,
3441
+ jobTypeMatrix,
3442
+ AGENT_TASK_NS,
3443
+ AGENT_RESULT_KEY,
3444
+ SANDBOXES,
3445
+ };
2520
3446
 
2521
3447
  export const metadata = {
2522
3448
  name: 'c8ctl-plugin-nano',
@@ -2548,6 +3474,12 @@ export const metadata = {
2548
3474
  { command: 'c8ctl nano config', description: 'Show current plugin configuration and paths' },
2549
3475
  { command: 'c8ctl nano update', description: 'Pull the latest published nano release (re-installs via npm)' },
2550
3476
  { command: 'c8ctl nano update --check', description: 'Check whether a newer nano release is available' },
3477
+ { command: 'c8ctl nano hire', description: 'Interactively create a CLI agent worker profile (name, rank, command, model, capabilities)' },
3478
+ { command: 'c8ctl nano hire --name reviewer --rank senior --command copilot --model gpt-5 --capabilities code-review,testing', description: 'Create a profile non-interactively' },
3479
+ { command: 'c8ctl nano hire --list', description: 'List hired agent profiles' },
3480
+ { command: 'c8ctl nano hire --name coder --rank senior --command "agent-harness" --sandbox docker --image ghcr.io/acme/agent:1', description: 'Create a profile that runs each job in a throwaway Docker container' },
3481
+ { command: 'c8ctl nano work reviewer', description: 'Spawn Nano job workers for the "reviewer" profile and poll for work' },
3482
+ { command: 'c8ctl nano work coder --sandbox docker --image ghcr.io/acme/agent:1', description: 'Run jobs in isolated containers with disk-hygiene reaping' },
2551
3483
  ],
2552
3484
  },
2553
3485
  processos: {
@@ -2589,6 +3521,20 @@ export const commands = {
2589
3521
  workspace: { type: 'boolean', description: 'clean: also delete the workspace (models + workers)' },
2590
3522
  check: { type: 'boolean', description: 'update: only report whether a new release is available; do not install' },
2591
3523
  binary: { type: 'string', description: 'Path to the nanobpmn server binary' },
3524
+ name: { type: 'string', description: 'hire/work: agent profile name (alt to positional arg)' },
3525
+ rank: { type: 'string', description: 'hire: agent rank (principal|senior|junior|decider)' },
3526
+ command: { type: 'string', description: 'hire: CLI command that runs the agent harness (e.g. copilot, claude, pi)' },
3527
+ model: { type: 'string', description: 'hire: model name passed to the harness (AGENT_MODEL)' },
3528
+ capabilities: { type: 'string', description: 'hire: comma-separated capability list' },
3529
+ sandbox: { type: 'string', description: 'hire/work: execution sandbox none|docker|podman (default none). Containers isolate each job.' },
3530
+ image: { type: 'string', description: 'hire/work: container image the agent runs in (required for --sandbox docker|podman)' },
3531
+ 'secret-resolver': { type: 'string', description: 'work: secret resolver for task secretRefs (host = process env; default host)' },
3532
+ 'reap-age': { type: 'string', description: 'work: age in ms before a finished agent container is reaped (default 3600000)' },
3533
+ 'reap-interval': { type: 'string', description: 'work: how often to sweep finished agent containers in ms (default 300000)' },
3534
+ 'min-free-mb': { type: 'string', description: 'work: shed jobs when the engine data root has less than this many MB free (default 1024)' },
3535
+ list: { type: 'boolean', description: 'hire: list existing agent profiles instead of creating one' },
3536
+ 'max-parallel': { type: 'string', description: 'work: max concurrent jobs per worker (default 1)' },
3537
+ 'job-timeout': { type: 'string', description: 'work: max harness runtime per job in ms; the spawned process is killed past this (default 300000)' },
2592
3538
  },
2593
3539
  handler: async (args, flags) => {
2594
3540
  const logger = getLogger();
@@ -2637,6 +3583,12 @@ export const commands = {
2637
3583
  case 'update':
2638
3584
  updatePlugin(req);
2639
3585
  break;
3586
+ case 'hire':
3587
+ await hireWorker(req, flags);
3588
+ break;
3589
+ case 'work':
3590
+ await workAgent(req, flags);
3591
+ break;
2640
3592
  }
2641
3593
  } catch (error) {
2642
3594
  logger.error(`nano ${req.subcommand} failed: ${error instanceof Error ? error.message : error}`);
@@ -2725,6 +3677,8 @@ function printUsage() {
2725
3677
  console.log(' c8ctl nano set <bin|model-dir> <path>');
2726
3678
  console.log(' c8ctl nano config');
2727
3679
  console.log(' c8ctl nano update [--check]');
3680
+ console.log(' c8ctl nano hire [--name <n>] [--rank <r>] [--command <c>] [--model <m>] [--capabilities <a,b>] [--sandbox none|docker|podman] [--image <ref>] [--list]');
3681
+ console.log(' c8ctl nano work <profileName> [--max-parallel <n>] [--job-timeout <ms>] [--sandbox none|docker|podman] [--image <ref>] [--secret-resolver host] [--min-free-mb <n>]');
2728
3682
  console.log('');
2729
3683
  console.log('Subcommands:');
2730
3684
  console.log(' start Spawn an N-node local cluster wired to talk to each other on localhost');
@@ -2738,6 +3692,8 @@ function printUsage() {
2738
3692
  console.log(' set Persist a setting: "bin <path>" or "model-dir <path>"');
2739
3693
  console.log(' config Show current configuration and on-disk locations');
2740
3694
  console.log(' update Pull the latest published nano release (--check to only report)');
3695
+ console.log(' hire Create a CLI agent worker profile (rank + capabilities → job-type matrix)');
3696
+ console.log(' work Run a hired profile as Nano job workers, polling for work until Ctrl-C');
2741
3697
  console.log('');
2742
3698
  console.log('Options:');
2743
3699
  console.log(' <nodes> Number of nodes to start (default 1)');
@@ -2753,6 +3709,20 @@ function printUsage() {
2753
3709
  console.log(' --purge stop: also delete per-node engine data');
2754
3710
  console.log(' --force start: stop any existing cluster first');
2755
3711
  console.log(' --workspace clean: also delete the workspace (models + workers)');
3712
+ console.log(' --name <n> hire/work: agent profile name (alt to positional arg)');
3713
+ console.log(' --rank <r> hire: agent rank (principal|senior|junior|decider)');
3714
+ console.log(' --command <c> hire: CLI command that runs the agent harness');
3715
+ console.log(' --model <m> hire: model name passed to the harness (AGENT_MODEL)');
3716
+ console.log(' --capabilities <a,b> hire: comma-separated capability list');
3717
+ console.log(' --sandbox <s> hire/work: execution sandbox none|docker|podman (default none)');
3718
+ console.log(' --image <ref> hire/work: container image the agent runs in (required for docker|podman)');
3719
+ console.log(' --list hire: list existing agent profiles instead of creating one');
3720
+ console.log(' --max-parallel <n> work: max concurrent jobs per worker (default 1)');
3721
+ console.log(' --job-timeout <ms> work: max harness runtime per job in ms (default 300000)');
3722
+ console.log(' --secret-resolver <r> work: secret resolver for task secretRefs (host; default host)');
3723
+ console.log(' --reap-age <ms> work: age before a finished agent container is reaped (default 3600000)');
3724
+ console.log(' --reap-interval <ms> work: how often to sweep finished agent containers (default 300000)');
3725
+ console.log(' --min-free-mb <n> work: shed jobs when the engine data root has < this many MB free (default 1024)');
2756
3726
  console.log('');
2757
3727
  console.log('Persistent assets:');
2758
3728
  console.log(' Models and workers live in the workspace dir (NANOBPMN_WORKSPACE_DIR),');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "c8ctl-plugin-nano",
3
- "version": "1.8.0",
3
+ "version": "1.10.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",
@@ -27,7 +27,7 @@
27
27
  ],
28
28
  "scripts": {
29
29
  "lint": "node --check c8ctl-plugin.js",
30
- "test": "node --check c8ctl-plugin.js"
30
+ "test": "node --check c8ctl-plugin.js && node --test"
31
31
  },
32
32
  "license": "MIT",
33
33
  "engines": {
@@ -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.10.0",
53
+ "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.10.0",
54
+ "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.10.0",
55
+ "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.10.0",
56
+ "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.10.0",
57
+ "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.10.0",
58
+ "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.10.0"
59
59
  }
60
60
  }