c8ctl-plugin-nano 1.11.0 → 1.12.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
@@ -281,6 +281,14 @@ a run that outlives `--job-timeout` is force-removed. The envelope is piped on
281
281
  the container's stdin exactly as on the host. (Container-side git provisioning —
282
282
  strong isolation — is a later increment; container jobs don't clone yet.)
283
283
 
284
+ **Host workers inherit your credentials.** A host worker (`--sandbox none`, the
285
+ default) runs as your user and inherits your full environment and `$HOME`, so
286
+ your existing `gh` CLI login (from `gh auth login`) or a `GH_TOKEN`/`GITHUB_TOKEN`
287
+ env var is available to the harness with **no extra setup** — handy when the
288
+ agent command shells out to `gh`. A **container** sandbox is isolated and does
289
+ **not** inherit that host login; provide the token explicitly via
290
+ `setup.secretRefs` / `--secret-resolver host` (see **Secrets** below) instead.
291
+
284
292
  **Secrets.** Secrets are referenced by **name**, never value. `setup.secretRefs`
285
293
  (and the repo/PR credential when `task.allowPr` is set — defaulting to
286
294
  `GITHUB_TOKEN` for GitHub) are resolved via a pluggable `--secret-resolver`
package/c8ctl-plugin.js CHANGED
@@ -46,7 +46,7 @@ import {
46
46
  } from 'node:fs';
47
47
  import { randomUUID } from 'node:crypto';
48
48
  import { homedir, platform as osPlatform, devNull } from 'node:os';
49
- import { join, isAbsolute, resolve as resolvePath, dirname, sep } from 'node:path';
49
+ import { join, isAbsolute, resolve as resolvePath, dirname, basename, sep } from 'node:path';
50
50
  import { createRequire } from 'node:module';
51
51
  import { fileURLToPath } from 'node:url';
52
52
  import { createInterface } from 'node:readline/promises';
@@ -1639,6 +1639,95 @@ const TASK_ENVELOPE_SCHEMA_VERSION = 1;
1639
1639
  // The result-envelope version is intentionally independent of the task-envelope
1640
1640
  // version so the two contracts can evolve separately without silently coupling.
1641
1641
  const RESULT_ENVELOPE_SCHEMA_VERSION = 1;
1642
+
1643
+ // Structured result channel (agent → harness). A coding CLI streams a lot of
1644
+ // noisy prose/tool output on stdout, so scraping it for the job's structured
1645
+ // result is fragile. Instead the harness hands the agent a private file path in
1646
+ // `AGENT_RESULT_FILE`; the agent writes a JSON object of *job result variables*
1647
+ // there (e.g. `{ "status": "needs_input", "question": "…" }`). The harness reads
1648
+ // it after the run and merges those variables into the job's completion, so the
1649
+ // model sees them as first-class outputs. A `::nano:result:: {json}` stdout
1650
+ // sentinel (or a trailing ```json fence) is honoured as a fallback for agents
1651
+ // that cannot write the file. The harness stays app-agnostic: it merges whatever
1652
+ // object the agent returns; the *app's prompt* owns the field vocabulary.
1653
+ const AGENT_RESULT_FILE_ENV = 'AGENT_RESULT_FILE';
1654
+ const RESULT_SENTINEL = '::nano:result::';
1655
+ // Completion keys the harness owns — an agent's returned result can never
1656
+ // overwrite these (nor anything in the reserved `io.nanobpm.*` namespace), so a
1657
+ // stray `output`/`exitCode`/git field in the agent's JSON can't corrupt the
1658
+ // audit envelope or process bookkeeping.
1659
+ const RESERVED_RESULT_KEYS = new Set([
1660
+ AGENT_RESULT_KEY, 'output', 'exitCode', 'agent', 'truncated',
1661
+ 'branch', 'commits', 'pushed', 'pullRequest',
1662
+ ]);
1663
+
1664
+ // Parse `text` as a JSON object, returning it only when it is a plain object.
1665
+ // Never throws — malformed agent output degrades to `null`.
1666
+ function parseAgentResultObject(text) {
1667
+ if (typeof text !== 'string' || !text.trim()) return null;
1668
+ try {
1669
+ const v = JSON.parse(text);
1670
+ return isPlainObject(v) ? v : null;
1671
+ } catch { return null; }
1672
+ }
1673
+
1674
+ // Read + parse the agent's result file, if it wrote one. Best-effort; a missing
1675
+ // or malformed file is treated as "no structured result". The file is
1676
+ // agent-controlled, so guard against a symlink or an oversized payload (DoS):
1677
+ // only a regular file no larger than the cap is read. A well-formed result is a
1678
+ // tiny JSON object, so the cap is generous.
1679
+ const MAX_RESULT_FILE_BYTES = 1_048_576; // 1 MiB
1680
+ function readAgentResultFile(path) {
1681
+ if (!path) return null;
1682
+ try {
1683
+ const st = lstatSync(path); // lstat: never follow a symlink the agent planted
1684
+ if (!st.isFile() || st.size > MAX_RESULT_FILE_BYTES) return null;
1685
+ return parseAgentResultObject(readFileSync(path, 'utf8'));
1686
+ } catch { return null; }
1687
+ }
1688
+
1689
+ // Fallback extraction from stdout, robust to the surrounding transcript: prefer
1690
+ // the LAST `::nano:result:: {json}` sentinel line (cheapest + most explicit),
1691
+ // else the LAST ```json fenced block. "Last wins" so a re-stated result
1692
+ // supersedes an earlier draft.
1693
+ function parseResultFromStdout(stdout) {
1694
+ if (typeof stdout !== 'string' || !stdout) return null;
1695
+ const lines = stdout.split(/\r?\n/);
1696
+ for (let i = lines.length - 1; i >= 0; i--) {
1697
+ const idx = lines[i].indexOf(RESULT_SENTINEL);
1698
+ if (idx === -1) continue;
1699
+ const obj = parseAgentResultObject(lines[i].slice(idx + RESULT_SENTINEL.length).trim());
1700
+ if (obj) return obj;
1701
+ }
1702
+ // Match the opening fence tolerantly (optional language tag, CRLF or LF) so a
1703
+ // Windows agent's `\r\n` output still parses.
1704
+ const fences = [...stdout.matchAll(/```[^\n]*\r?\n([\s\S]*?)```/g)];
1705
+ for (let i = fences.length - 1; i >= 0; i--) {
1706
+ const obj = parseAgentResultObject(fences[i][1].trim());
1707
+ if (obj) return obj;
1708
+ }
1709
+ return null;
1710
+ }
1711
+
1712
+ // The domain result variables an agent may return: the parsed object with the
1713
+ // harness-reserved keys (and the `io.nanobpm.*` namespace) stripped, so it can
1714
+ // never clobber the audit envelope, transcript, or git facts. The agent's output
1715
+ // is untrusted, so build the result on a null-prototype object and drop the
1716
+ // prototype-pollution keys — a merged `__proto__`/`constructor`/`prototype`
1717
+ // must never mutate object prototypes when spread into the job completion.
1718
+ const PROTO_POLLUTION_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
1719
+ function sanitizeResultVars(obj) {
1720
+ if (!isPlainObject(obj)) return {};
1721
+ const out = Object.create(null);
1722
+ for (const [k, v] of Object.entries(obj)) {
1723
+ if (RESERVED_RESULT_KEYS.has(k)) continue;
1724
+ if (PROTO_POLLUTION_KEYS.has(k)) continue;
1725
+ if (k.startsWith('io.nanobpm.')) continue;
1726
+ out[k] = v;
1727
+ }
1728
+ return out;
1729
+ }
1730
+
1642
1731
  const SANDBOXES = ['none', 'docker', 'podman'];
1643
1732
  // Only container-based sandboxes need an image / disk hygiene / a runtime bin.
1644
1733
  const CONTAINER_SANDBOXES = new Set(['docker', 'podman']);
@@ -2102,10 +2191,11 @@ function reapAgentRunDirs({ maxAgeMs = 0, liveRunDirs = new Set() } = {}) {
2102
2191
  if (!existsSync(root)) return { reaped };
2103
2192
  const now = Date.now();
2104
2193
  for (const name of readdirSync(root)) {
2105
- // Only reap the `run-*` workspaces this worker creates (see the
2106
- // `mkdtempSync(join(agentRunsRoot(), 'run-'))` in workAgent). Never touch
2107
- // unrelated files/dirs an operator may have placed under agent-runs.
2108
- if (!name.startsWith('run-')) continue;
2194
+ // Only reap the throwaway dirs this worker creates under agent-runs: the
2195
+ // `run-*` job workspaces and the `res-*` structured-result dirs (see the
2196
+ // `mkdtempSync(...)` calls in workAgent). Never touch unrelated files/dirs
2197
+ // an operator may have placed under agent-runs.
2198
+ if (!name.startsWith('run-') && !name.startsWith('res-')) continue;
2109
2199
  const p = join(root, name);
2110
2200
  if (liveRunDirs.has(p)) continue;
2111
2201
  try {
@@ -2128,7 +2218,7 @@ const MAX_CAPTURE_BYTES = 1_048_576; // 1 MiB per stream
2128
2218
  // Spawn a child, pipe `stdinData`, capture byte-capped stdout/stderr, enforce a
2129
2219
  // timeout (invoking `onTimeout(child)` to tear the child down), and resolve to a
2130
2220
  // uniform result. Used by both the host and container executors.
2131
- function spawnCaptureOneShot({ command, args = [], shell = false, detached = false, cwd, env, stdinData, timeoutMs, onTimeout }) {
2221
+ function spawnCaptureOneShot({ command, args = [], shell = false, detached = false, cwd, env, stdinData, timeoutMs, onTimeout, stream = false, streamPrefix = '', onStreamOut, onStreamErr }) {
2132
2222
  return new Promise((resolve) => {
2133
2223
  let child;
2134
2224
  const stdoutChunks = [];
@@ -2140,10 +2230,43 @@ function spawnCaptureOneShot({ command, args = [], shell = false, detached = fal
2140
2230
  let settled = false;
2141
2231
  let timer = null;
2142
2232
 
2233
+ // Live "spy" tee (--stream): mirror the child's output line-by-line to a
2234
+ // caller-supplied emitter (the worker routes these through c8ctl's
2235
+ // output-mode-aware logger so streaming never corrupts a structured/JSON
2236
+ // output mode; falling back to a raw console write only when none is given).
2237
+ // Each complete line is tagged with the job prefix so interleaved jobs stay
2238
+ // legible. A per-stream buffer holds partial lines across chunk boundaries;
2239
+ // it is force-flushed once it exceeds STREAM_TEE_LINE_CAP so a newline-less
2240
+ // torrent (progress bars, binary output) can't grow it without bound.
2241
+ const STREAM_TEE_LINE_CAP = 64 * 1024;
2242
+ const makeTee = (emit) => {
2243
+ if (!stream) return null;
2244
+ const sink = emit || ((line) => process.stdout.write(`${line}\n`));
2245
+ let partial = '';
2246
+ const flush = (text, final) => {
2247
+ partial += text;
2248
+ let nl;
2249
+ while ((nl = partial.indexOf('\n')) !== -1) {
2250
+ sink(`${streamPrefix}${partial.slice(0, nl)}`);
2251
+ partial = partial.slice(nl + 1);
2252
+ }
2253
+ while (partial.length >= STREAM_TEE_LINE_CAP) {
2254
+ sink(`${streamPrefix}${partial.slice(0, STREAM_TEE_LINE_CAP)}`);
2255
+ partial = partial.slice(STREAM_TEE_LINE_CAP);
2256
+ }
2257
+ if (final && partial) { sink(`${streamPrefix}${partial}`); partial = ''; }
2258
+ };
2259
+ return flush;
2260
+ };
2261
+ const teeOut = makeTee(onStreamOut);
2262
+ const teeErr = makeTee(onStreamErr || onStreamOut);
2263
+
2143
2264
  const finish = (result) => {
2144
2265
  if (settled) return;
2145
2266
  settled = true;
2146
2267
  if (timer) clearTimeout(timer);
2268
+ if (teeOut) teeOut('', true);
2269
+ if (teeErr) teeErr('', true);
2147
2270
  resolve(result);
2148
2271
  };
2149
2272
 
@@ -2163,6 +2286,7 @@ function spawnCaptureOneShot({ command, args = [], shell = false, detached = fal
2163
2286
 
2164
2287
  child.stdout.on('data', (d) => {
2165
2288
  const buf = Buffer.isBuffer(d) ? d : Buffer.from(d);
2289
+ if (teeOut) teeOut(buf.toString('utf8'), false);
2166
2290
  const remaining = MAX_CAPTURE_BYTES - stdoutBytes;
2167
2291
  if (remaining <= 0) { stdoutTruncated = true; return; }
2168
2292
  if (buf.length > remaining) { stdoutChunks.push(buf.subarray(0, remaining)); stdoutBytes = MAX_CAPTURE_BYTES; stdoutTruncated = true; }
@@ -2170,6 +2294,7 @@ function spawnCaptureOneShot({ command, args = [], shell = false, detached = fal
2170
2294
  });
2171
2295
  child.stderr.on('data', (d) => {
2172
2296
  const buf = Buffer.isBuffer(d) ? d : Buffer.from(d);
2297
+ if (teeErr) teeErr(buf.toString('utf8'), false);
2173
2298
  const remaining = MAX_CAPTURE_BYTES - stderrBytes;
2174
2299
  if (remaining <= 0) { stderrTruncated = true; return; }
2175
2300
  if (buf.length > remaining) { stderrChunks.push(buf.subarray(0, remaining)); stderrBytes = MAX_CAPTURE_BYTES; stderrTruncated = true; }
@@ -2233,7 +2358,7 @@ function baseAgentEnv(profile, job) {
2233
2358
  * Both paths resolve to the same result contract.
2234
2359
  */
2235
2360
  function runAgentJob(profile, job, opts = {}) {
2236
- const { timeoutMs, envelope, sandbox = 'none', image, runId, secretEnv = {}, passThroughSecretNames = [], cwd, extraEnv = {}, profileEnv = {} } = opts;
2361
+ const { timeoutMs, envelope, sandbox = 'none', image, runId, secretEnv = {}, passThroughSecretNames = [], cwd, extraEnv = {}, profileEnv = {}, resultFile, stream = false, streamPrefix = '', onStreamOut, onStreamErr } = opts;
2237
2362
  const payload = JSON.stringify(buildAgentPayload(profile, job, envelope));
2238
2363
  const agentEnv = baseAgentEnv(profile, job);
2239
2364
  // Static, non-secret env for the harness: the worker/profile's env (e.g. a
@@ -2243,6 +2368,8 @@ function runAgentJob(profile, job, opts = {}) {
2243
2368
  const staticEnv = { ...normalizeEnvMap(profileEnv), ...normalizeEnvMap(envelope?.setup?.env) };
2244
2369
 
2245
2370
  if (!CONTAINER_SANDBOXES.has(sandbox)) {
2371
+ // Host: hand the agent the result file by its real path.
2372
+ const resultEnv = resultFile ? { [AGENT_RESULT_FILE_ENV]: resultFile } : {};
2246
2373
  return spawnCaptureOneShot({
2247
2374
  command: profile.command,
2248
2375
  shell: true,
@@ -2250,20 +2377,38 @@ function runAgentJob(profile, job, opts = {}) {
2250
2377
  detached: process.platform !== 'win32',
2251
2378
  // When a repository was provisioned, run the harness IN the workspace.
2252
2379
  cwd,
2253
- env: { ...process.env, ...staticEnv, ...agentEnv, ...extraEnv, ...secretEnv },
2380
+ // Reserved harness env (AGENT_* + the result-file path) is layered AFTER
2381
+ // resolved secrets so a task-supplied secret NAME can never shadow it.
2382
+ env: { ...process.env, ...staticEnv, ...secretEnv, ...agentEnv, ...extraEnv, ...resultEnv },
2254
2383
  stdinData: payload,
2255
2384
  timeoutMs,
2256
2385
  onTimeout: (child) => killTree(child),
2386
+ stream,
2387
+ streamPrefix,
2388
+ onStreamOut,
2389
+ onStreamErr,
2257
2390
  });
2258
2391
  }
2259
2392
 
2260
2393
  const engine = sandbox;
2261
2394
  const containerName = `nano-${runId}`;
2395
+ // Container: bind-mount the result file's directory read-write at a fixed
2396
+ // in-container path and point AGENT_RESULT_FILE at the mounted file, so the
2397
+ // agent writes it inside the sandbox and the harness reads it back on the host.
2398
+ let resultEnv = {};
2399
+ const mountArgs = [];
2400
+ if (resultFile) {
2401
+ const hostDir = dirname(resultFile);
2402
+ const containerPath = `/nano-agent/${basename(resultFile)}`;
2403
+ mountArgs.push('-v', `${hostDir}:/nano-agent`);
2404
+ resultEnv = { [AGENT_RESULT_FILE_ENV]: containerPath };
2405
+ }
2262
2406
  // Forward env by NAME only (`-e NAME`) so secret VALUES stay out of argv and
2263
2407
  // `docker inspect`; docker reads the value from our child's environment.
2264
2408
  const envArgs = [];
2265
2409
  for (const k of Object.keys(agentEnv)) envArgs.push('-e', k);
2266
2410
  for (const k of Object.keys(extraEnv)) envArgs.push('-e', k);
2411
+ for (const k of Object.keys(resultEnv)) envArgs.push('-e', k);
2267
2412
  for (const n of passThroughSecretNames) envArgs.push('-e', n);
2268
2413
  for (const k of Object.keys(staticEnv)) envArgs.push('-e', k);
2269
2414
 
@@ -2276,6 +2421,7 @@ function runAgentJob(profile, job, opts = {}) {
2276
2421
  '--label', `nano.run=${runId}`,
2277
2422
  '--log-opt', 'max-size=10m',
2278
2423
  '--log-opt', 'max-file=3',
2424
+ ...mountArgs,
2279
2425
  ...envArgs,
2280
2426
  image,
2281
2427
  'sh', '-c', profile.command,
@@ -2285,9 +2431,16 @@ function runAgentJob(profile, job, opts = {}) {
2285
2431
  command: engine,
2286
2432
  args,
2287
2433
  shell: false,
2288
- env: { ...process.env, ...staticEnv, ...agentEnv, ...extraEnv, ...secretEnv },
2434
+ // Reserved harness env (AGENT_* + the result-file path) is layered AFTER
2435
+ // resolved secrets so a task-supplied secret NAME can never shadow it. In
2436
+ // container mode docker reads these values from our child env by NAME.
2437
+ env: { ...process.env, ...staticEnv, ...secretEnv, ...agentEnv, ...extraEnv, ...resultEnv },
2289
2438
  stdinData: payload,
2290
2439
  timeoutMs,
2440
+ stream,
2441
+ streamPrefix,
2442
+ onStreamOut,
2443
+ onStreamErr,
2291
2444
  onTimeout: (child) => {
2292
2445
  try { spawnSync(engine, ['rm', '-f', containerName], { timeout: 15_000 }); } catch { /* best effort */ }
2293
2446
  try { killTree(child); } catch { /* best effort */ }
@@ -2297,7 +2450,7 @@ function runAgentJob(profile, job, opts = {}) {
2297
2450
 
2298
2451
  // Shape the io.nanobpm.agentResult output envelope. When a repository was
2299
2452
  // provisioned (increment 2a), the `git` block adds branch/commits/push/PR facts.
2300
- function buildResultEnvelope(result, { sandbox, image, git } = {}) {
2453
+ function buildResultEnvelope(result, { sandbox, image, git, result: agentResult } = {}) {
2301
2454
  const status = result.ok ? 'completed' : (result.timedOut ? 'timedOut' : 'failed');
2302
2455
  const env = {
2303
2456
  schemaVersion: RESULT_ENVELOPE_SCHEMA_VERSION,
@@ -2311,6 +2464,9 @@ function buildResultEnvelope(result, { sandbox, image, git } = {}) {
2311
2464
  signal: result.signal ?? null,
2312
2465
  error: result.error ?? null,
2313
2466
  };
2467
+ // The agent's structured result (as returned via $AGENT_RESULT_FILE / sentinel),
2468
+ // preserved verbatim for auditability even when merged into the completion vars.
2469
+ if (isPlainObject(agentResult)) env.result = agentResult;
2314
2470
  if (git) {
2315
2471
  env.repository = git.remote ?? null;
2316
2472
  env.branch = git.branch ?? null;
@@ -2408,6 +2564,9 @@ async function workAgent(req, flags) {
2408
2564
  // Git provisioning knobs (increment 2a — host harness with a repository).
2409
2565
  const cloneTimeoutMs = intFlag(flags?.['clone-timeout'], 120_000);
2410
2566
  const keepRuns = coerceBool(flags?.['keep-runs'], false);
2567
+ // --stream: tee each agent job's live stdout/stderr to this console (spy/debug),
2568
+ // in addition to the existing byte-capped capture used for the result envelope.
2569
+ const stream = coerceBool(flags?.stream, false);
2411
2570
 
2412
2571
  // Tracks run ids currently executing so the reaper never removes a live
2413
2572
  // container out from under an in-flight job.
@@ -2534,7 +2693,20 @@ async function workAgent(req, flags) {
2534
2693
 
2535
2694
  let result;
2536
2695
  let gitResult = null;
2696
+ // Private structured-result channel: hand the agent a file (outside any
2697
+ // repo clone so it can't be `git add`ed) to write its job-result vars to.
2698
+ let resultDir = null;
2699
+ let resultFile = null;
2537
2700
  try {
2701
+ try {
2702
+ mkdirSync(agentRunsRoot(), { recursive: true });
2703
+ resultDir = mkdtempSync(join(agentRunsRoot(), 'res-'));
2704
+ resultFile = join(resultDir, 'result.json');
2705
+ // Track it so the run-dir reaper skips it while in-flight and reaps it
2706
+ // (as a `res-*` dir) if this worker crashes before the cleanup below.
2707
+ liveRunDirs.add(resultDir);
2708
+ } catch { resultDir = null; resultFile = null; }
2709
+
2538
2710
  result = await runAgentJob(profile, job, {
2539
2711
  timeoutMs: jobTimeoutMs,
2540
2712
  envelope,
@@ -2546,6 +2718,13 @@ async function workAgent(req, flags) {
2546
2718
  cwd,
2547
2719
  extraEnv,
2548
2720
  profileEnv,
2721
+ resultFile,
2722
+ stream,
2723
+ streamPrefix: `[${jobType} ${job.jobKey}] `,
2724
+ // Route the --stream tee through c8ctl's output-mode-aware logger so
2725
+ // spying never corrupts a structured/JSON output mode.
2726
+ onStreamOut: stream ? (line) => logger.info(line) : undefined,
2727
+ onStreamErr: stream ? (line) => logger.warn(line) : undefined,
2549
2728
  });
2550
2729
 
2551
2730
  // Finalize git only when the harness succeeded — never push a
@@ -2572,14 +2751,33 @@ async function workAgent(req, flags) {
2572
2751
  if (runDir) liveRunDirs.delete(runDir);
2573
2752
  }
2574
2753
 
2575
- const resultEnvelope = buildResultEnvelope(result, { sandbox, image, git: gitResult });
2754
+ // Read the agent's structured result: the file it wrote, else a stdout
2755
+ // sentinel/`json fence fallback. The raw object is attached to the audit
2756
+ // envelope; the sanitized (reserved-key-stripped) vars are merged into the
2757
+ // job completion so the model sees `status`/`summary`/… as first-class
2758
+ // outputs. Read before deleting the temp dir.
2759
+ const rawResult = readAgentResultFile(resultFile) ?? parseResultFromStdout(result.stdout);
2760
+ if (resultDir) { try { rmSync(resultDir, { recursive: true, force: true }); } catch { /* best effort */ } liveRunDirs.delete(resultDir); }
2761
+ const resultVars = sanitizeResultVars(rawResult);
2762
+
2763
+ const resultEnvelope = buildResultEnvelope(result, { sandbox, image, git: gitResult, result: rawResult });
2576
2764
  if (result.ok) {
2577
2765
  const gitNote = gitResult
2578
2766
  ? ` [${gitResult.branch ? `branch ${gitResult.branch}` : 'detached HEAD'}: ${gitResult.commits.length} commit(s), ${gitResult.branch ? (gitResult.pushed ? 'pushed' : (gitResult.pushError ? 'push FAILED' : 'not pushed')) : 'no branch to push'}${gitResult.pr?.found ? `, PR #${gitResult.pr.number}` : ''}]`
2579
2767
  : '';
2580
2768
  logger.info(`[${jobType}] job ${job.jobKey} complete (exit 0)${result.truncated ? ' [output truncated]' : ''}${gitNote}`);
2581
2769
  if (gitResult?.pushError) logger.warn(`[${jobType}] job ${job.jobKey}: branch push failed — ${gitResult.pushError}`);
2770
+ // Guard the operator against silent empty escalations: a success that
2771
+ // yields no *effective* result vars (no file/sentinel at all, an empty
2772
+ // `{}`, or only reserved keys that were sanitized away) means the
2773
+ // model's status/decision vars stay unset and any status gateway will
2774
+ // fall through to its default. Warn on the merged-vars emptiness, not
2775
+ // just a missing rawResult.
2776
+ const resultKeys = Object.keys(resultVars);
2777
+ if (resultKeys.length === 0) logger.warn(`[${jobType}] job ${job.jobKey}: agent returned no usable result vars — write a JSON object of result variables to $AGENT_RESULT_FILE (or print a "${RESULT_SENTINEL} {…}" line) so downstream gateways see status/summary/etc.`);
2778
+ else logger.info(`[${jobType}] job ${job.jobKey}: merged agent result vars [${resultKeys.join(', ')}]`);
2582
2779
  return job.complete({
2780
+ ...resultVars,
2583
2781
  [AGENT_RESULT_KEY]: resultEnvelope,
2584
2782
  output: result.stdout,
2585
2783
  exitCode: 0,
@@ -3875,6 +4073,10 @@ export {
3875
4073
  hostEnvSecretResolver,
3876
4074
  buildAgentPayload,
3877
4075
  buildResultEnvelope,
4076
+ parseAgentResultObject,
4077
+ readAgentResultFile,
4078
+ parseResultFromStdout,
4079
+ sanitizeResultVars,
3878
4080
  parseEnvPairs,
3879
4081
  normalizeEnvMap,
3880
4082
  reapAgentContainers,
@@ -3893,6 +4095,8 @@ export {
3893
4095
  jobTypeMatrix,
3894
4096
  AGENT_TASK_NS,
3895
4097
  AGENT_RESULT_KEY,
4098
+ RESULT_SENTINEL,
4099
+ RESERVED_RESULT_KEYS,
3896
4100
  SANDBOXES,
3897
4101
  };
3898
4102
 
@@ -3987,6 +4191,7 @@ export const commands = {
3987
4191
  'min-free-mb': { type: 'string', description: 'work: shed jobs when the engine data root has less than this many MB free (default 1024)' },
3988
4192
  'clone-timeout': { type: 'string', description: 'work: max time in ms for cloning a task repository on the host (default 120000)' },
3989
4193
  'keep-runs': { type: 'boolean', description: 'work: keep per-job workspaces under <state>/agent-runs instead of deleting them after each job (debug)' },
4194
+ stream: { type: 'boolean', description: 'work: tee each agent job\'s live stdout/stderr to this console, prefixed with the job type + key (spy/debug)' },
3990
4195
  list: { type: 'boolean', description: 'hire: list existing agent profiles instead of creating one' },
3991
4196
  'max-parallel': { type: 'string', description: 'work: max concurrent jobs per worker (default 1)' },
3992
4197
  'job-timeout': { type: 'string', description: 'work: max harness runtime per job in ms; the spawned process is killed past this (default 300000)' },
@@ -4133,7 +4338,7 @@ function printUsage() {
4133
4338
  console.log(' c8ctl nano config');
4134
4339
  console.log(' c8ctl nano update [--check]');
4135
4340
  console.log(' c8ctl nano hire [--name <n>] [--rank <r>] [--command <c>] [--model <m>] [--capabilities <a,b>] [--sandbox none|docker|podman] [--image <ref>] [--env NAME=VALUE ...] [--list]');
4136
- console.log(' c8ctl nano work <profileName> [--max-parallel <n>] [--job-timeout <ms>] [--sandbox none|docker|podman] [--image <ref>] [--env NAME=VALUE ...] [--secret-resolver host] [--min-free-mb <n>] [--clone-timeout <ms>] [--keep-runs]');
4341
+ console.log(' c8ctl nano work <profileName> [--max-parallel <n>] [--job-timeout <ms>] [--sandbox none|docker|podman] [--image <ref>] [--env NAME=VALUE ...] [--secret-resolver host] [--min-free-mb <n>] [--clone-timeout <ms>] [--keep-runs] [--stream]');
4137
4342
  console.log('');
4138
4343
  console.log('Subcommands:');
4139
4344
  console.log(' start Spawn an N-node local cluster wired to talk to each other on localhost');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "c8ctl-plugin-nano",
3
- "version": "1.11.0",
3
+ "version": "1.12.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.11.0",
53
- "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.11.0",
54
- "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.11.0",
55
- "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.11.0",
56
- "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.11.0",
57
- "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.11.0",
58
- "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.11.0"
52
+ "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.12.0",
53
+ "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.12.0",
54
+ "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.12.0",
55
+ "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.12.0",
56
+ "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.12.0",
57
+ "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.12.0",
58
+ "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.12.0"
59
59
  }
60
60
  }