c8ctl-plugin-nano 1.39.2 → 1.41.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 (2) hide show
  1. package/c8ctl-plugin.js +667 -4
  2. package/package.json +8 -8
package/c8ctl-plugin.js CHANGED
@@ -57,6 +57,7 @@ import { join, isAbsolute, resolve as resolvePath, dirname, basename, sep } from
57
57
  import { createRequire } from 'node:module';
58
58
  import { fileURLToPath } from 'node:url';
59
59
  import { createInterface } from 'node:readline/promises';
60
+ import { StringDecoder } from 'node:string_decoder';
60
61
  import { createInterface as createReadline, cursorTo as rlCursorTo, moveCursor as rlMoveCursor, clearScreenDown as rlClearScreenDown } from 'node:readline';
61
62
  import { platformForHost } from './platforms.mjs';
62
63
  import { createWorkChannel, redactAgenticUrl, buildAgenticUrl } from './work-channel.mjs';
@@ -1549,6 +1550,33 @@ const RANKS = ['principal', 'senior', 'junior', 'decider'];
1549
1550
  // opt-in per role because a TTY changes the harness's I/O semantics.
1550
1551
  const TERMINAL_MODES = ['pipe', 'pty'];
1551
1552
 
1553
+ // #110: the harness protocol a role drives its agent over — a plain stdin/scrape
1554
+ // `pipe` (the default floor) or `acp` (Agent Client Protocol, JSON-RPC over
1555
+ // stdio). Default is `pipe`; `acp` is opt-in per role. The ACP executor lands in
1556
+ // a downstream task — this seam only carries the schema/plumbing.
1557
+ const PROTOCOLS = ['pipe', 'acp'];
1558
+
1559
+ // #110: the ACP permission policy for a role. Only `yolo` (auto-allow-all) is
1560
+ // enforced today; `escalate`/`filter` are RESERVED pending nano-workforce#559
1561
+ // (the permission-event + escalation bridge) and are not yet enforced — they are
1562
+ // accepted and persisted for forward-compatibility (never downgraded), but today
1563
+ // effectively behave like `yolo` (auto-allow). Default is `yolo`.
1564
+ const PERMISSION_MODES = ['yolo', 'escalate', 'filter'];
1565
+
1566
+ // #110: resolve a role's agentic setting (protocol/permission) with a uniform
1567
+ // env-override → profile → default precedence, tolerating invalid values at
1568
+ // every layer. A one-off worker env var wins if it names an allowed value; else
1569
+ // the persisted hire profile decides if it holds an allowed value; else the safe
1570
+ // default. Reserved-but-allowed values (e.g. escalate/filter) carry through
1571
+ // verbatim; unknown values are ignored and fall through to the next layer.
1572
+ function resolveAgenticSetting(envValue, profileValue, allowed, dflt) {
1573
+ const env = String(envValue || '').trim().toLowerCase();
1574
+ if (allowed.includes(env)) return env;
1575
+ const profile = String(profileValue || '').trim().toLowerCase();
1576
+ if (allowed.includes(profile)) return profile;
1577
+ return dflt;
1578
+ }
1579
+
1552
1580
  /** Normalize a capability list: trim, drop empties, de-dupe, sort (canonical). */
1553
1581
  function normalizeCapabilities(input) {
1554
1582
  const raw = Array.isArray(input)
@@ -1771,6 +1799,15 @@ function normalizeStoredProfile(name, profile) {
1771
1799
  // to the safe `pipe` default rather than failing the whole profile.
1772
1800
  const terminalRaw = typeof profile.terminal === 'string' ? profile.terminal.trim().toLowerCase() : '';
1773
1801
  const terminal = TERMINAL_MODES.includes(terminalRaw) ? terminalRaw : 'pipe';
1802
+ // #110: harness protocol + ACP permission policy. Tolerant like `terminal` —
1803
+ // an unknown/legacy/missing value falls back to the safe defaults ('pipe' /
1804
+ // 'yolo') rather than failing the whole profile. A persisted escalate/filter
1805
+ // is preserved verbatim (it is enforced by a downstream task pending
1806
+ // nano-workforce#559), never downgraded.
1807
+ const protocolRaw = typeof profile.protocol === 'string' ? profile.protocol.trim().toLowerCase() : '';
1808
+ const protocol = PROTOCOLS.includes(protocolRaw) ? protocolRaw : 'pipe';
1809
+ const permissionRaw = typeof profile.permission === 'string' ? profile.permission.trim().toLowerCase() : '';
1810
+ const permission = PERMISSION_MODES.includes(permissionRaw) ? permissionRaw : 'yolo';
1774
1811
  return {
1775
1812
  profile: {
1776
1813
  name,
@@ -1782,6 +1819,8 @@ function normalizeStoredProfile(name, profile) {
1782
1819
  sandbox,
1783
1820
  image,
1784
1821
  terminal,
1822
+ protocol,
1823
+ permission,
1785
1824
  env: normalizeEnvMap(profile.env),
1786
1825
  },
1787
1826
  };
@@ -1900,7 +1939,15 @@ async function hireWorker(req, flags) {
1900
1939
  for (const name of names.sort()) {
1901
1940
  const p = hires[name];
1902
1941
  const term = String(p.terminal || '').trim().toLowerCase() === 'pty' ? '; terminal: pty' : '';
1903
- logger.info(` ${name} [${p.rank}] ${buildAgentCommandLine(p.command, p.args)} (model: ${p.model || '-'}; caps: ${normalizeCapabilities(p.capabilities).join(', ') || '-'}${term})`);
1942
+ const proto = String(p.protocol || '').trim().toLowerCase() === 'acp' ? '; protocol: acp' : '';
1943
+ const perm = (() => {
1944
+ const v = String(p.permission || '').trim().toLowerCase();
1945
+ // Only surface recognized non-default modes; normalizeStoredProfile
1946
+ // coerces unknown/legacy values back to yolo at runtime, so showing them
1947
+ // here would make --list disagree with actual behavior.
1948
+ return v && v !== 'yolo' && PERMISSION_MODES.includes(v) ? `; permission: ${v}` : '';
1949
+ })();
1950
+ logger.info(` ${name} [${p.rank}] ${buildAgentCommandLine(p.command, p.args)} (model: ${p.model || '-'}; caps: ${normalizeCapabilities(p.capabilities).join(', ') || '-'}${term}${proto}${perm})`);
1904
1951
  }
1905
1952
  logger.info('');
1906
1953
  logger.info('Put one to work with: c8ctl nano work <name>');
@@ -1917,6 +1964,8 @@ async function hireWorker(req, flags) {
1917
1964
  let sandbox = flags?.sandbox !== undefined ? String(flags.sandbox).trim().toLowerCase() : undefined;
1918
1965
  let image = flags?.image !== undefined ? String(flags.image).trim() : undefined;
1919
1966
  let terminal = flags?.terminal !== undefined ? String(flags.terminal).trim().toLowerCase() : undefined;
1967
+ let protocol = flags?.protocol !== undefined ? String(flags.protocol).trim().toLowerCase() : undefined;
1968
+ let permission = flags?.permission !== undefined ? String(flags.permission).trim().toLowerCase() : undefined;
1920
1969
  // Structured command-line switches appended to the command when spawned, e.g.
1921
1970
  // `--arg --allow-all` for `copilot`. Repeatable; each --arg is one argv token.
1922
1971
  const commandArgs = normalizeArgList(flags?.arg);
@@ -1995,6 +2044,8 @@ async function hireWorker(req, flags) {
1995
2044
  if (sandbox === undefined || sandbox === '') sandbox = 'none';
1996
2045
  if (image === undefined) image = '';
1997
2046
  if (terminal === undefined || terminal === '') terminal = 'pipe';
2047
+ if (protocol === undefined || protocol === '') protocol = 'pipe';
2048
+ if (permission === undefined || permission === '') permission = 'yolo';
1998
2049
 
1999
2050
  if (!SANDBOXES.includes(sandbox)) {
2000
2051
  logger.error(`Invalid --sandbox "${sandbox}". Use one of: ${SANDBOXES.join(', ')}`);
@@ -2004,6 +2055,21 @@ async function hireWorker(req, flags) {
2004
2055
  logger.error(`Invalid --terminal "${terminal}". Use one of: ${TERMINAL_MODES.join(', ')}`);
2005
2056
  process.exit(1);
2006
2057
  }
2058
+ if (!PROTOCOLS.includes(protocol)) {
2059
+ logger.error(`Invalid --protocol "${protocol}". Use one of: ${PROTOCOLS.join(', ')}`);
2060
+ process.exit(1);
2061
+ }
2062
+ if (!PERMISSION_MODES.includes(permission)) {
2063
+ logger.error(`Invalid --permission "${permission}". Use one of: ${PERMISSION_MODES.join(', ')}`);
2064
+ process.exit(1);
2065
+ }
2066
+ // #110: escalate/filter are accepted and persisted for forward-compatibility,
2067
+ // but not yet enforced (pending nano-workforce#559). Warn the operator so a
2068
+ // hire is never misread as gating destructive ops today — the value is kept as
2069
+ // given (never downgraded to yolo).
2070
+ if (permission === 'escalate' || permission === 'filter') {
2071
+ logger.warn(`Permission policy "${permission}" is RESERVED and NOT enforced in this build (pending nano-workforce#559): it does not gate anything today and effectively behaves like yolo (auto-allow all permission requests). The value is persisted as-is for forward-compatibility.`);
2072
+ }
2007
2073
 
2008
2074
  if (CONTAINER_SANDBOXES.has(sandbox) && !image) {
2009
2075
  logger.error(`--sandbox ${sandbox} requires --image <ref> (the container image the agent runs in).`);
@@ -2034,6 +2100,8 @@ async function hireWorker(req, flags) {
2034
2100
  sandbox,
2035
2101
  image: image || '',
2036
2102
  terminal,
2103
+ protocol,
2104
+ permission,
2037
2105
  env: profileEnv,
2038
2106
  createdAt: new Date().toISOString(),
2039
2107
  };
@@ -2046,6 +2114,8 @@ async function hireWorker(req, flags) {
2046
2114
  if (profile.args.length > 0) logger.info(` args: ${profile.args.map(shQuote).join(' ')}`);
2047
2115
  logger.info(` sandbox: ${profile.sandbox}${CONTAINER_SANDBOXES.has(profile.sandbox) ? ` (image ${profile.image})` : ''}`);
2048
2116
  logger.info(` live terminal: ${profile.terminal}${profile.terminal === 'pty' ? ' (streamed + steerable on the relay lane)' : ''}`);
2117
+ logger.info(` protocol: ${profile.protocol}${profile.protocol === 'acp' ? ' (Agent Client Protocol — JSON-RPC over stdio; ACTIVE on the host executor (sandbox=none): the harness is driven over ACP. Container sandboxes (docker/podman) do NOT yet run ACP and are pipe-only today (--terminal pty is host-only))' : ''}`);
2118
+ logger.info(` permission: ${profile.permission}${(profile.permission === 'escalate' || profile.permission === 'filter') ? ' (RESERVED — not yet enforced, pending nano-workforce#559)' : ''}`);
2049
2119
  const envKeys = Object.keys(profile.env);
2050
2120
  if (envKeys.length > 0) logger.info(` env: ${envKeys.join(', ')}`);
2051
2121
  logger.info(` job types (${matrix.length}): ${matrix.join(' ')}`);
@@ -2132,7 +2202,7 @@ const RESULT_SENTINEL = '::nano:result::';
2132
2202
  // audit envelope or process bookkeeping.
2133
2203
  const RESERVED_RESULT_KEYS = new Set([
2134
2204
  AGENT_RESULT_KEY, 'output', 'exitCode', 'agent', 'truncated',
2135
- 'branch', 'commits', 'pushed', 'pullRequest',
2205
+ 'branch', 'commits', 'pushed', 'pullRequest', 'forcedReap',
2136
2206
  ]);
2137
2207
 
2138
2208
  // Parse `text` as a JSON object, returning it only when it is a plain object.
@@ -3890,6 +3960,536 @@ function spawnCapturePty({ command, args = [], cwd, env, stdinData, timeoutMs, i
3890
3960
  });
3891
3961
  }
3892
3962
 
3963
+ // ---- ACP capture (C3 #110 — the third harness path, "minimal mode") ---------
3964
+ // Some ACP agents are native (`copilot --acp`, `opencode acp`), some ride an
3965
+ // adapter (`claude-agent-acp`, `pi-acp`). In every case the profile's command +
3966
+ // `--arg`s already assemble the ACP invocation; we only append a default `--acp`
3967
+ // switch when the assembled line doesn't already select ACP, so a native/adapter
3968
+ // invocation is never doubled. This mirrors how the pipe path spawns the line
3969
+ // under a shell.
3970
+ function ensureAcpFlag(commandLine) {
3971
+ // Detection must survive buildAgentCommandLine()'s POSIX single-quoting: a
3972
+ // structured `--arg acp` (or `--arg --acp`) lands here as the quoted token
3973
+ // 'acp' / '--acp', so a naive `\bacp\b` on the raw line would miss it and
3974
+ // wrongly append a second --acp. Tokenise the line, strip the shell quoting
3975
+ // (both POSIX single-quotes from buildAgentCommandLine() AND double-quotes a
3976
+ // legacy `profile.command` may bake in, e.g. `copilot "--acp"`), and match:
3977
+ // - a native ACP selector `acp`/`-acp`/`--acp` (subcommand or switch) as a
3978
+ // WHOLE token, in ANY position (it may be the command or an argument), or
3979
+ // - an adapter command whose basename ends in `-acp` (claude-agent-acp,
3980
+ // pi-acp) — but ONLY the command token (first token), since an *argument*
3981
+ // that merely ends in `-acp` (e.g. `--model foo-acp`) is not an ACP
3982
+ // selector. Matching whole tokens/basenames (not a substring) also avoids
3983
+ // the false positive of a path that merely contains `/acp/`.
3984
+ const tokens = commandLine.match(/'(?:[^']|'\\'')*'|"(?:[^"\\]|\\.)*"|\S+/g) || [];
3985
+ for (let i = 0; i < tokens.length; i++) {
3986
+ let tok = tokens[i];
3987
+ if (tok.length >= 2 && tok.startsWith("'") && tok.endsWith("'")) {
3988
+ tok = tok.slice(1, -1).replace(/'\\''/g, "'");
3989
+ } else if (tok.length >= 2 && tok.startsWith('"') && tok.endsWith('"')) {
3990
+ tok = tok.slice(1, -1).replace(/\\(["\\$`])/g, '$1');
3991
+ }
3992
+ const base = tok.replace(/^.*[\\/]/, ''); // basename, for path-form commands
3993
+ if (/^-{0,2}acp$/i.test(base)) return commandLine;
3994
+ if (i === 0 && /-acp$/i.test(base)) return commandLine;
3995
+ }
3996
+ return `${commandLine} --acp`;
3997
+ }
3998
+
3999
+ // The steer control byte the cockpit sends to interrupt a live ACP turn: ETX
4000
+ // (Ctrl-C, 0x03), matching terminal semantics. Any other inbound steer text is
4001
+ // treated as a mid-turn steer prompt (a fresh `session/prompt` on the live
4002
+ // session). This keeps the ACP steer surface consistent with the PTY path
4003
+ // (where Ctrl-C already interrupts) without needing a PTY.
4004
+ const ACP_INTERRUPT_BYTE = '\x03';
4005
+
4006
+ // After the main session/prompt turn resolves we wait for the child's `close`
4007
+ // event (so $AGENT_RESULT_FILE is fully flushed before the caller reads it). A
4008
+ // well-behaved agent exits promptly once stdin closes; this bounds how long a
4009
+ // finished-but-lingering agent may hold the turn open before it is force-reaped,
4010
+ // so a completed turn is never held hostage to the full run timeout. Resolved at
4011
+ // call time (see acpPostTurnGraceMs) so tests can shrink it and exercise the
4012
+ // force-reap path without a 10s wait.
4013
+ const ACP_POST_TURN_GRACE_DEFAULT_MS = 10_000;
4014
+ function acpPostTurnGraceMs() {
4015
+ const v = Number(process.env.NANO_ACP_POST_TURN_GRACE_MS);
4016
+ return Number.isFinite(v) && v >= 0 ? v : ACP_POST_TURN_GRACE_DEFAULT_MS;
4017
+ }
4018
+
4019
+ // Hard cap on a single un-terminated JSON-RPC frame. ACP frames are one compact
4020
+ // JSON object per `\n`-terminated line; a conformant agent never emits a line
4021
+ // this large. Without a cap a peer that streams bytes without a newline would
4022
+ // grow `rxBuf` without bound (and keep re-arming the idle timer), risking memory
4023
+ // exhaustion — so once the pending (newline-free) tail exceeds this we treat it
4024
+ // as a framing violation and fail the run rather than buffer forever.
4025
+ const ACP_MAX_LINE_BYTES = 8 * 1024 * 1024; // 8 MiB
4026
+
4027
+ // Drive an ACP (Agent Client Protocol) agent over JSON-RPC 2.0 on stdio.
4028
+ //
4029
+ // This is the "minimal mode" executor (#110, step 1): it proves ACP end-to-end
4030
+ // with ZERO downstream changes by serialising each `session/update` to a short
4031
+ // human-readable TEXT chunk and feeding it to the SAME relay/tee lane the pipe
4032
+ // path uses (`relayTap.onData`) — it never tee's the raw JSON-RPC. Typed
4033
+ // transcript envelopes are a separate downstream task.
4034
+ //
4035
+ // Framing: ACP frames are newline-delimited JSON-RPC 2.0 messages on stdio (one
4036
+ // compact JSON object per line, `\n`-terminated). We implement a tiny inline
4037
+ // framer/parser rather than pull a dependency.
4038
+ //
4039
+ // Sequence: initialize → session/new { cwd } → session/prompt { prompt } →
4040
+ // consume session/update notifications until the prompt request resolves
4041
+ // (end-of-turn) → end stdin for a clean shutdown → wait for the child's `close`
4042
+ // to settle the promise. Settling on the real exit (rather than the instant the
4043
+ // turn resolves) avoids racing the caller's result-file read and surfaces a late
4044
+ // non-zero/early exit as a failure. The result-file merge is unchanged: the
4045
+ // agent writes `$AGENT_RESULT_FILE` (already set in `env`) and the caller reads
4046
+ // it exactly as in pipe mode.
4047
+ //
4048
+ // Permission: inbound `session/request_permission` requests are answered by the
4049
+ // `permission` policy switch — see below. `yolo` auto-allow-always is the only
4050
+ // policy enforced today; `escalate`/`filter` fall back to a warned safe interim
4051
+ // policy pending nano-workforce#559.
4052
+ //
4053
+ // Same result contract as spawnCaptureOneShot/spawnCapturePty so buildResultEnvelope
4054
+ // and every caller work unchanged. Because the raw stream is JSON-RPC (not human
4055
+ // output), `stdout` here is the accumulated human-readable transcript text (what
4056
+ // we relay), and `stderr` is the child's real stderr (agent diagnostics).
4057
+ function spawnCaptureAcp({ command, args = [], cwd, env, stdinData, timeoutMs, idleTimeoutMs, relayTap = null, stream = false, streamPrefix = '', onStreamOut, onStreamErr, permission = 'yolo', shell = false }) {
4058
+ return new Promise((resolve) => {
4059
+ const logger = getLogger();
4060
+ const humanChunks = [];
4061
+ let humanBytes = 0;
4062
+ let humanTruncated = false;
4063
+ const stderrChunks = [];
4064
+ let stderrBytes = 0;
4065
+ let stderrTruncated = false;
4066
+ let settled = false;
4067
+ let timer = null;
4068
+ let idleTimer = null;
4069
+ let detachSteer = null;
4070
+ let child;
4071
+ let sessionId = null;
4072
+ let nextId = 1;
4073
+ const pending = new Map();
4074
+ let childClosed = null; // { code, signal } once the child exits
4075
+ let promptResolved = false; // the main session/prompt turn sent + resolved
4076
+ let settleTimer = null; // post-turn grace before force-reaping a lingering agent
4077
+ // One-time warning latch for the reserved escalate/filter policies so the
4078
+ // deferral is observable (not silent) but never spams a warning per request.
4079
+ let interimWarned = false;
4080
+
4081
+ // Live "spy" tee (--stream), line-buffered, mirroring the other paths.
4082
+ // Separate line buffers per lane (stdout-human vs stderr) so a partial line
4083
+ // on one lane never interleaves mid-line with the other — matching pipe/PTY.
4084
+ // stderr routes through its own sink (`onStreamErr`) so it keeps its warn/
4085
+ // error severity instead of being flattened onto the stdout sink; it falls
4086
+ // back to the stdout sink (then process.stdout) when no error sink is wired.
4087
+ const STREAM_TEE_LINE_CAP = 64 * 1024;
4088
+ const defaultTeeOut = (line) => process.stdout.write(`${line}\n`);
4089
+ const outSink = stream ? (onStreamOut || defaultTeeOut) : null;
4090
+ const errSink = stream ? (onStreamErr || onStreamOut || defaultTeeOut) : null;
4091
+ const teeSink = outSink; // truthy iff --stream is on (shared streaming guard)
4092
+ const makeTee = (sink) => {
4093
+ let partial = '';
4094
+ return (text, final) => {
4095
+ if (!sink) return;
4096
+ partial += text;
4097
+ let nl;
4098
+ while ((nl = partial.indexOf('\n')) !== -1) {
4099
+ sink(`${streamPrefix}${partial.slice(0, nl)}`);
4100
+ partial = partial.slice(nl + 1);
4101
+ }
4102
+ while (partial.length >= STREAM_TEE_LINE_CAP) {
4103
+ sink(`${streamPrefix}${partial.slice(0, STREAM_TEE_LINE_CAP)}`);
4104
+ partial = partial.slice(STREAM_TEE_LINE_CAP);
4105
+ }
4106
+ if (final && partial) { sink(`${streamPrefix}${partial}`); partial = ''; }
4107
+ };
4108
+ };
4109
+ const tee = makeTee(outSink);
4110
+ const teeErr = makeTee(errSink);
4111
+
4112
+ const humanStdout = () => joinCapped(humanChunks);
4113
+
4114
+ // Emit a human-meaningful text chunk on the SAME lanes the pipe/pty paths
4115
+ // use: the relay tap (framed + jobKey-tagged by the caller) and the local
4116
+ // --stream spy tee. Byte-capped like the raw captures.
4117
+ const emitHuman = (text) => {
4118
+ if (!text) return;
4119
+ const buf = Buffer.from(text, 'utf8');
4120
+ if (teeSink) tee(text, false);
4121
+ if (relayTap && typeof relayTap.onData === 'function') {
4122
+ try { relayTap.onData(text); } catch { /* relay best-effort */ }
4123
+ }
4124
+ const remaining = MAX_CAPTURE_BYTES - humanBytes;
4125
+ if (remaining <= 0) { humanTruncated = true; return; }
4126
+ if (buf.length > remaining) { humanChunks.push(buf.subarray(0, remaining)); humanBytes = MAX_CAPTURE_BYTES; humanTruncated = true; }
4127
+ else { humanChunks.push(buf); humanBytes += buf.length; }
4128
+ };
4129
+
4130
+ const finish = (result) => {
4131
+ if (settled) return;
4132
+ settled = true;
4133
+ if (timer) clearTimeout(timer);
4134
+ if (idleTimer) clearTimeout(idleTimer);
4135
+ if (settleTimer) { clearTimeout(settleTimer); settleTimer = null; }
4136
+ if (detachSteer) { try { detachSteer(); } catch { /* best effort */ } detachSteer = null; }
4137
+ if (teeSink) { tee('', true); teeErr('', true); }
4138
+ // Reap the child if it is still alive (turn resolved but agent lingering).
4139
+ try { if (child && childClosed === null) killTree(child); } catch { /* best effort */ }
4140
+ resolve(result);
4141
+ };
4142
+
4143
+ // --- JSON-RPC 2.0 plumbing (newline-delimited framing) -------------------
4144
+ const send = (obj) => {
4145
+ try { child.stdin.write(`${JSON.stringify(obj)}\n`); } catch { /* child gone; close handler settles */ }
4146
+ };
4147
+ const request = (method, params) => new Promise((res, rej) => {
4148
+ const id = nextId++;
4149
+ pending.set(id, { res, rej });
4150
+ send({ jsonrpc: '2.0', id, method, params });
4151
+ });
4152
+ const notify = (method, params) => send({ jsonrpc: '2.0', method, params });
4153
+ const respond = (id, result) => send({ jsonrpc: '2.0', id, result });
4154
+ const respondError = (id, code, message) => send({ jsonrpc: '2.0', id, error: { code, message } });
4155
+
4156
+ // Pick the "allow-always" option (yolo / safe-interim structural path). ACP
4157
+ // permission options carry a `kind` (allow_always|allow_once|reject_*). We
4158
+ // prefer allow_always, then allow_once, then the first option; this is the
4159
+ // conservative structural default the interim policy also uses.
4160
+ const pickAllowOption = (options) => {
4161
+ const list = Array.isArray(options) ? options : [];
4162
+ return list.find((o) => o && o.kind === 'allow_always')
4163
+ || list.find((o) => o && o.kind === 'allow_once')
4164
+ || list[0]
4165
+ || null;
4166
+ };
4167
+
4168
+ const handlePermission = (id, params) => {
4169
+ const options = params?.options;
4170
+ const allow = pickAllowOption(options);
4171
+ const grant = () => {
4172
+ if (allow && allow.optionId != null) {
4173
+ respond(id, { outcome: { outcome: 'selected', optionId: allow.optionId } });
4174
+ } else {
4175
+ // No option to select (malformed request) — cancel rather than hang.
4176
+ respond(id, { outcome: { outcome: 'cancelled' } });
4177
+ }
4178
+ };
4179
+ switch (permission) {
4180
+ case 'yolo':
4181
+ // The ONLY fully-enforced policy: auto-allow-always, no human, sub-ms
4182
+ // local round-trip.
4183
+ grant();
4184
+ break;
4185
+ // TODO(#559): implement escalate/filter — the real permission-event +
4186
+ // escalation bridge (block-until-answered for escalate; auto-allow
4187
+ // reads/edits + escalate destructive ops for filter) lands with the
4188
+ // companion nano-workforce#559 task. Until then these reserved policies
4189
+ // must NOT masquerade as enforced: warn once, then fall through to the
4190
+ // safe interim structural policy (same allow-always grant as yolo).
4191
+ case 'escalate':
4192
+ case 'filter':
4193
+ default:
4194
+ if (!interimWarned) {
4195
+ interimWarned = true;
4196
+ logger.warn?.(`ACP permission policy '${permission}' is not yet enforced in this build; requests handled by interim policy pending nano-workforce#559`);
4197
+ }
4198
+ grant();
4199
+ break;
4200
+ }
4201
+ };
4202
+
4203
+ // Serialise an ACP session/update into a short human-readable line. Minimal
4204
+ // mode: this is TEXT for the existing cockpit lane, not a typed envelope.
4205
+ const describeUpdate = (update) => {
4206
+ if (!update || typeof update !== 'object') return '';
4207
+ const kind = update.sessionUpdate || update.type || 'update';
4208
+ const textOf = (content) => {
4209
+ if (content == null) return '';
4210
+ if (typeof content === 'string') return content;
4211
+ if (Array.isArray(content)) return content.map(textOf).join('');
4212
+ if (typeof content === 'object') return typeof content.text === 'string' ? content.text : '';
4213
+ return '';
4214
+ };
4215
+ switch (kind) {
4216
+ case 'agent_message_chunk':
4217
+ return textOf(update.content);
4218
+ case 'agent_thought_chunk':
4219
+ return `\u{1F4AD} ${textOf(update.content)}`;
4220
+ case 'user_message_chunk':
4221
+ return textOf(update.content);
4222
+ case 'tool_call': {
4223
+ const title = update.title || update.toolCallId || 'tool';
4224
+ return `\u2699 [tool: ${title}${update.status ? ` — ${update.status}` : ''}]\n`;
4225
+ }
4226
+ case 'tool_call_update': {
4227
+ const title = update.title || update.toolCallId || 'tool';
4228
+ return `\u2699 [tool: ${title}${update.status ? ` — ${update.status}` : ''}]\n`;
4229
+ }
4230
+ case 'plan':
4231
+ return `\u{1F4CB} [plan updated]\n`;
4232
+ default:
4233
+ return `[${kind}]\n`;
4234
+ }
4235
+ };
4236
+
4237
+ const handleMessage = (msg) => {
4238
+ if (!msg || typeof msg !== 'object') return;
4239
+ // A response to one of OUR requests.
4240
+ if (msg.id !== undefined && msg.method === undefined && (msg.result !== undefined || msg.error !== undefined)) {
4241
+ const p = pending.get(msg.id);
4242
+ if (p) {
4243
+ pending.delete(msg.id);
4244
+ if (msg.error) p.rej(new Error(msg.error.message || `rpc error ${msg.error.code}`));
4245
+ else p.res(msg.result);
4246
+ }
4247
+ return;
4248
+ }
4249
+ // A request or notification FROM the agent.
4250
+ if (typeof msg.method === 'string') {
4251
+ if (msg.method === 'session/update') { emitHuman(describeUpdate(msg.params?.update)); return; }
4252
+ if (msg.method === 'session/request_permission') {
4253
+ if (msg.id !== undefined) handlePermission(msg.id, msg.params);
4254
+ return;
4255
+ }
4256
+ // Unknown request → method-not-found; unknown notification → ignore.
4257
+ if (msg.id !== undefined) respondError(msg.id, -32601, `method not found: ${msg.method}`);
4258
+ }
4259
+ };
4260
+
4261
+ // --- spawn ---------------------------------------------------------------
4262
+ try {
4263
+ child = spawn(command, args, { cwd, stdio: ['pipe', 'pipe', 'pipe'], env, detached: process.platform !== 'win32', shell });
4264
+ } catch (err) {
4265
+ finish({ ok: false, exitCode: null, stdout: '', stderr: '', error: err.message, truncated: false, stderrTruncated: false });
4266
+ return;
4267
+ }
4268
+
4269
+ timer = timeoutMs && timeoutMs > 0
4270
+ ? setTimeout(() => {
4271
+ try { killTree(child); } catch { /* best effort */ }
4272
+ finish({ ok: false, exitCode: null, stdout: humanStdout(), stderr: joinCapped(stderrChunks), error: `timed out after ${timeoutMs}ms`, timedOut: true, truncated: humanTruncated, stderrTruncated });
4273
+ }, timeoutMs)
4274
+ : null;
4275
+
4276
+ const armIdle = () => {
4277
+ if (settled) return;
4278
+ if (!(idleTimeoutMs && idleTimeoutMs > 0)) return;
4279
+ if (idleTimer) clearTimeout(idleTimer);
4280
+ idleTimer = setTimeout(() => {
4281
+ try { killTree(child); } catch { /* best effort */ }
4282
+ finish({ ok: false, exitCode: null, stdout: humanStdout(), stderr: joinCapped(stderrChunks), error: `no output for ${idleTimeoutMs}ms (idle)`, timedOut: true, idle: true, truncated: humanTruncated, stderrTruncated });
4283
+ }, idleTimeoutMs);
4284
+ };
4285
+ armIdle();
4286
+
4287
+ // Newline-delimited JSON-RPC parser over stdout. Progress on stdout re-arms
4288
+ // the idle liveness timer (every frame counts as progress). A StringDecoder
4289
+ // buffers any multibyte UTF-8 sequence split across chunk boundaries so the
4290
+ // assembled JSON text is never corrupted (a partial code point is held back
4291
+ // until the continuation byte arrives, rather than emitting U+FFFD).
4292
+ let rxBuf = '';
4293
+ const rxDecoder = new StringDecoder('utf8');
4294
+ child.stdout.on('data', (d) => {
4295
+ armIdle();
4296
+ rxBuf += rxDecoder.write(Buffer.isBuffer(d) ? d : Buffer.from(d));
4297
+ let nl;
4298
+ while ((nl = rxBuf.indexOf('\n')) !== -1) {
4299
+ const line = rxBuf.slice(0, nl).trim();
4300
+ rxBuf = rxBuf.slice(nl + 1);
4301
+ if (!line) continue;
4302
+ let msg;
4303
+ try {
4304
+ msg = JSON.parse(line);
4305
+ } catch {
4306
+ // stdout is a pure newline-delimited JSON-RPC stream; a line that
4307
+ // isn't JSON is a framing/protocol violation, not noise. Silently
4308
+ // skipping it would mask a misconfigured agent as an opaque idle
4309
+ // timeout (and keep re-arming the idle timer on garbage). Fail fast
4310
+ // with an explicit error, mirroring the un-terminated-frame cap below.
4311
+ const preview = line.length > 200 ? `${line.slice(0, 200)}…` : line;
4312
+ rxBuf = '';
4313
+ try { killTree(child); } catch { /* best effort */ }
4314
+ finish({ ok: false, exitCode: null, stdout: humanStdout(), stderr: joinCapped(stderrChunks), error: `ACP framing violation: non-JSON line on stdout: ${preview}`, truncated: humanTruncated, stderrTruncated });
4315
+ return;
4316
+ }
4317
+ try { handleMessage(msg); } catch { /* one bad frame must not wedge the loop */ }
4318
+ }
4319
+ // No newline in the (now line-free) tail past the cap → the peer is
4320
+ // streaming an unbounded frame. Fail rather than buffer to exhaustion.
4321
+ if (Buffer.byteLength(rxBuf, 'utf8') > ACP_MAX_LINE_BYTES) {
4322
+ rxBuf = '';
4323
+ try { killTree(child); } catch { /* best effort */ }
4324
+ finish({ ok: false, exitCode: null, stdout: humanStdout(), stderr: joinCapped(stderrChunks), error: `ACP framing violation: un-terminated JSON-RPC frame exceeded ${ACP_MAX_LINE_BYTES} bytes`, truncated: humanTruncated, stderrTruncated });
4325
+ }
4326
+ });
4327
+
4328
+ child.stderr.on('data', (d) => {
4329
+ armIdle();
4330
+ const buf = Buffer.isBuffer(d) ? d : Buffer.from(d);
4331
+ // Forward stderr to the SAME live lanes the pipe/PTY paths use — the
4332
+ // --stream spy tee and the relay tap — so agent diagnostics are visible
4333
+ // during execution rather than only after it finishes. This is safe
4334
+ // precisely because stdout is the pure JSON-RPC channel here: stderr never
4335
+ // carries protocol frames, so teeing/relaying it can't corrupt the
4336
+ // relayed human stream.
4337
+ const text = buf.toString('utf8');
4338
+ if (teeSink) teeErr(text, false);
4339
+ if (relayTap && typeof relayTap.onData === 'function') {
4340
+ try { relayTap.onData(text); } catch { /* relay best-effort */ }
4341
+ }
4342
+ const remaining = MAX_CAPTURE_BYTES - stderrBytes;
4343
+ if (remaining <= 0) { stderrTruncated = true; return; }
4344
+ if (buf.length > remaining) { stderrChunks.push(buf.subarray(0, remaining)); stderrBytes = MAX_CAPTURE_BYTES; stderrTruncated = true; }
4345
+ else { stderrChunks.push(buf); stderrBytes += buf.length; }
4346
+ });
4347
+
4348
+ child.stdin.on('error', () => { /* peer may close first; close handler settles */ });
4349
+
4350
+ child.on('error', (err) => {
4351
+ finish({ ok: false, exitCode: null, stdout: humanStdout(), stderr: joinCapped(stderrChunks), error: err.message, truncated: humanTruncated, stderrTruncated });
4352
+ });
4353
+ child.on('close', (code, signal) => {
4354
+ childClosed = { code, signal: signal ?? null };
4355
+ if (settleTimer) { clearTimeout(settleTimer); settleTimer = null; }
4356
+ // Flush the UTF-8 decoder's held-back bytes (an incomplete multibyte
4357
+ // sequence at EOF) and drain any now-complete newline-delimited frames,
4358
+ // so a final frame that arrives in the same read as EOF isn't dropped.
4359
+ // Whatever remains after that is an un-terminated tail on what is supposed
4360
+ // to be a pure newline-delimited JSON-RPC stream — a framing violation we
4361
+ // must NOT let masquerade as success.
4362
+ let unterminatedTail = '';
4363
+ let framingViolation = '';
4364
+ try {
4365
+ rxBuf += rxDecoder.end();
4366
+ let nl;
4367
+ while ((nl = rxBuf.indexOf('\n')) !== -1) {
4368
+ const line = rxBuf.slice(0, nl).trim();
4369
+ rxBuf = rxBuf.slice(nl + 1);
4370
+ if (!line) continue;
4371
+ let msg;
4372
+ try {
4373
+ msg = JSON.parse(line);
4374
+ } catch {
4375
+ // A final newline-delimited frame that isn't JSON is a framing/
4376
+ // protocol violation on what must be a pure JSON-RPC stream — the
4377
+ // same rule the `data` handler enforces. Silently swallowing it here
4378
+ // would let a malformed shutdown masquerade as a clean success, so
4379
+ // record it and fail below instead of ignoring the parse error.
4380
+ framingViolation = line;
4381
+ break;
4382
+ }
4383
+ try { handleMessage(msg); } catch { /* one bad frame must not wedge shutdown */ }
4384
+ }
4385
+ if (!framingViolation) unterminatedTail = rxBuf.trim();
4386
+ } catch { /* decoder flush best effort */ }
4387
+ rxBuf = '';
4388
+ // Settle on the child's ACTUAL exit — this is what avoids racing the
4389
+ // caller's $AGENT_RESULT_FILE read: the file's write/flush is guaranteed
4390
+ // complete once the process is gone. Success requires BOTH the main
4391
+ // session/prompt turn to have resolved AND a clean exit AND no leftover
4392
+ // un-terminated frame — an early exit (e.g. code 0 during the handshake,
4393
+ // before the turn completes), any non-zero exit, or a dangling tail is a
4394
+ // failure, never a false success.
4395
+ const ok = promptResolved && code === 0 && !unterminatedTail && !framingViolation;
4396
+ // On failure, populate an explicit `error` so callers/logs explain WHY —
4397
+ // otherwise an early exit (code 0 before the turn resolved) surfaces as a
4398
+ // confusing bare "exit code 0" with no detail.
4399
+ let error;
4400
+ if (!ok) {
4401
+ if (framingViolation && promptResolved && code === 0) {
4402
+ const preview = framingViolation.length > 200 ? `${framingViolation.slice(0, 200)}…` : framingViolation;
4403
+ error = `ACP framing violation: non-JSON line on stdout at exit: ${preview}`;
4404
+ } else if (unterminatedTail && promptResolved && code === 0) {
4405
+ const preview = unterminatedTail.length > 200 ? `${unterminatedTail.slice(0, 200)}…` : unterminatedTail;
4406
+ error = `ACP framing violation: un-terminated JSON-RPC frame on stdout at exit: ${preview}`;
4407
+ } else {
4408
+ const how = signal ? `signal ${signal}` : `code ${code}`;
4409
+ error = promptResolved
4410
+ ? `ACP agent exited with ${how} (session/prompt completed)`
4411
+ : `ACP agent exited with ${how} before the session/prompt turn completed`;
4412
+ }
4413
+ }
4414
+ finish({ ok, exitCode: code, signal: signal ?? null, stdout: humanStdout(), stderr: joinCapped(stderrChunks), ...(error ? { error } : {}), truncated: humanTruncated, stderrTruncated });
4415
+ });
4416
+
4417
+ // Steer + cancel via the relay tap — NO PTY. Wired once the session exists.
4418
+ const attachSteerIfAny = () => {
4419
+ if (!relayTap || typeof relayTap.attachSteer !== 'function') return;
4420
+ detachSteer = relayTap.attachSteer((data) => {
4421
+ const text = typeof data === 'string' ? data : Buffer.from(data).toString('utf8');
4422
+ if (text.includes(ACP_INTERRUPT_BYTE)) {
4423
+ // Ctrl-C / ETX → interrupt the live turn.
4424
+ if (sessionId != null) notify('session/cancel', { sessionId });
4425
+ return;
4426
+ }
4427
+ const steer = text.replace(/[\r\n]+$/, '');
4428
+ if (!steer) return;
4429
+ // Mid-turn steer → a fresh prompt on the live session (fire-and-forget;
4430
+ // its own resolution is not part of the main turn sequence).
4431
+ if (sessionId != null) {
4432
+ request('session/prompt', { sessionId, prompt: [{ type: 'text', text: steer }] }).catch(() => {});
4433
+ }
4434
+ });
4435
+ };
4436
+
4437
+ // --- drive the handshake + turn -----------------------------------------
4438
+ (async () => {
4439
+ await request('initialize', {
4440
+ protocolVersion: 1,
4441
+ clientCapabilities: { fs: { readTextFile: false, writeTextFile: false } },
4442
+ });
4443
+ const created = await request('session/new', { cwd: cwd || process.cwd(), mcpServers: [] });
4444
+ sessionId = created?.sessionId ?? null;
4445
+ attachSteerIfAny();
4446
+ // Deliver the task envelope as the prompt (from stdinData, matching the
4447
+ // pipe/pty paths which write the same payload to stdin).
4448
+ await request('session/prompt', {
4449
+ sessionId,
4450
+ prompt: [{ type: 'text', text: String(stdinData ?? '') }],
4451
+ });
4452
+ // End-of-turn: the main session/prompt request resolved. Mark it so the
4453
+ // `close` handler can tell a completed turn from an early/handshake exit.
4454
+ promptResolved = true;
4455
+ // The agent has written $AGENT_RESULT_FILE; close stdin so it can flush and
4456
+ // exit, then let the child's `close` event settle the promise. Settling on
4457
+ // the real exit (not here) avoids racing the caller's result-file read and
4458
+ // surfaces a late non-zero exit as a failure instead of a false success. A
4459
+ // well-behaved agent exits promptly once stdin closes; force-reap a
4460
+ // lingering one after a short grace so a finished turn is never held hostage
4461
+ // to the full timeout.
4462
+ try { child.stdin.end(); } catch { /* already gone */ }
4463
+ if (childClosed === null) {
4464
+ settleTimer = setTimeout(() => {
4465
+ settleTimer = null;
4466
+ try { if (child && childClosed === null) killTree(child); } catch { /* best effort */ }
4467
+ // The turn completed and the result file is already written, so this
4468
+ // is still a success — but the child did NOT exit on its own; we just
4469
+ // force-reaped it. Report that honestly instead of a fabricated clean
4470
+ // exit (code 0 / signal null): killTree sends SIGKILL, so surface the
4471
+ // real signal (or the child's actual exit if it slipped in) plus a
4472
+ // `forcedReap` flag so audits can spot agents that consistently hang
4473
+ // on shutdown rather than seeing a misleading exitCode: 0.
4474
+ finish({
4475
+ ok: true,
4476
+ exitCode: childClosed ? childClosed.code : null,
4477
+ signal: childClosed ? childClosed.signal : 'SIGKILL',
4478
+ forcedReap: childClosed === null,
4479
+ stdout: humanStdout(),
4480
+ stderr: joinCapped(stderrChunks),
4481
+ truncated: humanTruncated,
4482
+ stderrTruncated,
4483
+ });
4484
+ }, acpPostTurnGraceMs());
4485
+ if (typeof settleTimer.unref === 'function') settleTimer.unref();
4486
+ }
4487
+ })().catch((err) => {
4488
+ finish({ ok: false, exitCode: null, stdout: humanStdout(), stderr: joinCapped(stderrChunks), error: `acp: ${err?.message || err}`, truncated: humanTruncated, stderrTruncated });
4489
+ });
4490
+ });
4491
+ }
4492
+
3893
4493
  function buildAgentPayload(profile, job, envelope) {
3894
4494
  const variables = job.variables && typeof job.variables === 'object' ? job.variables : {};
3895
4495
  return {
@@ -3969,7 +4569,9 @@ function startLockExtender(job, windowMs, intervalMs, tag, logger) {
3969
4569
  * Both paths resolve to the same result contract.
3970
4570
  */
3971
4571
  function runAgentJob(profile, job, opts = {}) {
3972
- const { timeoutMs, idleTimeoutMs, envelope, sandbox = 'none', image, runId, secretEnv = {}, passThroughSecretNames = [], cwd, extraEnv = {}, profileEnv = {}, resultFile, stream = false, streamPrefix = '', onStreamOut, onStreamErr, args: commandArgs, terminal = 'pipe', relaySession = null, ptyFactory } = opts;
4572
+ const { timeoutMs, idleTimeoutMs, envelope, sandbox = 'none', image, runId, secretEnv = {}, passThroughSecretNames = [], cwd, extraEnv = {}, profileEnv = {}, resultFile, stream = false, streamPrefix = '', onStreamOut, onStreamErr, args: commandArgs, terminal = 'pipe', protocol = 'pipe', permission = 'yolo', relaySession = null, ptyFactory } = opts;
4573
+ // #110: `protocol`/`permission` drive the ACP executor branch below. The
4574
+ // pipe/PTY paths are unchanged, so `protocol === 'pipe'` behaviour is identical.
3973
4575
  const payload = JSON.stringify(buildAgentPayload(profile, job, envelope));
3974
4576
  const agentEnv = baseAgentEnv(profile, job);
3975
4577
  // The harness command line: the profile command plus its structured switches
@@ -4004,6 +4606,35 @@ function runAgentJob(profile, job, opts = {}) {
4004
4606
  const resultEnv = resultFile ? { [AGENT_RESULT_FILE_ENV]: resultFile } : {};
4005
4607
  const harnessEnv = { ...process.env, ...staticEnv, ...secretEnv, ...agentEnv, ...extraEnv, ...resultEnv };
4006
4608
 
4609
+ // A role opted into ACP (`protocol: acp`) drives its harness over the Agent
4610
+ // Client Protocol (JSON-RPC 2.0 over stdio) instead of the stdin/scrape pipe
4611
+ // or a PTY. Checked BEFORE the PTY branch: ACP owns the process when selected
4612
+ // and needs no node-pty at all (steer/cancel ride JSON-RPC, not terminal
4613
+ // writes). The ACP switch is appended to the assembled command line only when
4614
+ // it isn't already present. `permission` selects the request_permission
4615
+ // policy (yolo enforced; escalate/filter warned interim, pending #559).
4616
+ if (protocol === 'acp') {
4617
+ return spawnCaptureAcp({
4618
+ // Route the assembled line through the platform shell (cmd.exe on
4619
+ // Windows, /bin/sh elsewhere) exactly like the pipe path, rather than
4620
+ // hard-coding `sh -c` which does not exist on Windows hosts. The
4621
+ // Windows `--arg` restriction is already enforced by the guard above.
4622
+ command: ensureAcpFlag(commandLine),
4623
+ shell: true,
4624
+ cwd,
4625
+ env: harnessEnv,
4626
+ stdinData: payload,
4627
+ timeoutMs,
4628
+ idleTimeoutMs,
4629
+ relayTap,
4630
+ stream,
4631
+ streamPrefix,
4632
+ onStreamOut,
4633
+ onStreamErr,
4634
+ permission,
4635
+ });
4636
+ }
4637
+
4007
4638
  // A role opted into a full PTY (`terminal: pty`) runs the harness on a real
4008
4639
  // terminal when one can be allocated — so its live output streams as a true
4009
4640
  // terminal and cockpit steer-in reaches it. Falls back to a pipe (still
@@ -4049,6 +4680,10 @@ function runAgentJob(profile, job, opts = {}) {
4049
4680
 
4050
4681
  const engine = sandbox;
4051
4682
  const containerName = `nano-${runId}`;
4683
+ // #110: ACP-in-container is deferred for this slice — a container sandbox runs
4684
+ // the harness over the pipe path below regardless of `protocol`, so container
4685
+ // pipe mode is never regressed. Host ACP (above) is the minimal-mode surface.
4686
+ void protocol;
4052
4687
  // Container: bind-mount the result file's directory read-write at a fixed
4053
4688
  // in-container path and point AGENT_RESULT_FILE at the mounted file, so the
4054
4689
  // agent writes it inside the sandbox and the harness reads it back on the host.
@@ -4123,6 +4758,10 @@ function buildResultEnvelope(result, { sandbox, image, git, result: agentResult,
4123
4758
  signal: result.signal ?? null,
4124
4759
  error: result.error ?? null,
4125
4760
  };
4761
+ // Audit: a turn that completed but whose child had to be force-reaped on
4762
+ // shutdown (didn't exit on its own within the post-turn grace) — surfaced so
4763
+ // consistently-hanging agents are visible rather than hidden behind a success.
4764
+ if (result.forcedReap) env.forcedReap = true;
4126
4765
  // Audit (issue #63): record which linked-resource key supplied the base prompt.
4127
4766
  // The engine only keeps `latest` per resourceId (no pinning), so recording the
4128
4767
  // resolved key is the only reproducibility handle for which prompt version ran.
@@ -5069,6 +5708,17 @@ async function workAgent(req, flags) {
5069
5708
  logger.info(` live terminal: ${roleTerminal === 'pty' ? 'PTY (streamed + steerable)' : 'pipe (streamed)'} on the relay lane.`);
5070
5709
  }
5071
5710
 
5711
+ // #110: the role's harness protocol (pipe|acp) and ACP permission policy
5712
+ // (yolo|escalate|filter), resolved with the same env-override-then-profile
5713
+ // precedence as terminal. `NANO_AGENTIC_PROTOCOL`/`NANO_AGENTIC_PERMISSION`
5714
+ // override a one-off worker; otherwise the hire profile decides; else the safe
5715
+ // defaults (pipe/yolo). escalate/filter are carried through verbatim — the
5716
+ // acp-executor task enforces yolo and interim-handles the reserved policies.
5717
+ const envProtocol = (process.env.NANO_AGENTIC_PROTOCOL || '').trim().toLowerCase();
5718
+ const roleProtocol = resolveAgenticSetting(envProtocol, profile.protocol, PROTOCOLS, 'pipe');
5719
+ const envPermission = (process.env.NANO_AGENTIC_PERMISSION || '').trim().toLowerCase();
5720
+ const rolePermission = resolveAgenticSetting(envPermission, profile.permission, PERMISSION_MODES, 'yolo');
5721
+
5072
5722
  // A per-job-type worker factory. Captures all the CLI-local + profile context
5073
5723
  // in closure scope so the profile watcher below can (re)spawn a poller for any
5074
5724
  // job type on demand without re-reading the flags.
@@ -5254,6 +5904,11 @@ async function workAgent(req, flags) {
5254
5904
  // stream on the relay lane when a relay session exists (skipped when
5255
5905
  // relaySession is null); only a PTY is interactively steerable.
5256
5906
  terminal: roleTerminal,
5907
+ // #110: harness protocol + ACP permission policy threaded to
5908
+ // runAgentJob. Inert in this seam task (pipe/pty dispatch unchanged);
5909
+ // the acp-executor task acts on them.
5910
+ protocol: roleProtocol,
5911
+ permission: rolePermission,
5257
5912
  relaySession,
5258
5913
  // Route the --stream tee through c8ctl's output-mode-aware logger so
5259
5914
  // spying never corrupts a structured/JSON output mode.
@@ -8940,6 +9595,7 @@ export {
8940
9595
  export { setConfig, unsetConfig, readConfig, writeConfig, getConfigFile, SETTING_ALIASES };
8941
9596
  export { buildNpmInvocation };
8942
9597
  export { resolveAgenticConfig, LOCAL_AGENTIC_TOKEN };
9598
+ export { resolveAgenticSetting, PROTOCOLS, PERMISSION_MODES };
8943
9599
  export { resolveAgenticTarget, discoverAgenticHubs, probeAgenticChannel, normalizeProjectApps, isLoopbackHost };
8944
9600
  export { compareSemver, githubRepoSlug, filterReleasesSince, renderReleaseBody };
8945
9601
  export {
@@ -8985,6 +9641,8 @@ export {
8985
9641
  containerEngineAvailable,
8986
9642
  runAgentJob,
8987
9643
  spawnCapturePty,
9644
+ spawnCaptureAcp,
9645
+ ensureAcpFlag,
8988
9646
  startLockExtender,
8989
9647
  provisionRepo,
8990
9648
  finalizeGit,
@@ -9103,6 +9761,7 @@ export const metadata = {
9103
9761
  { command: 'c8ctl nano hire --list', description: 'List hired agent profiles' },
9104
9762
  { 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' },
9105
9763
  { command: 'c8ctl nano hire --name coder --rank senior --command copilot --terminal pty', description: 'Opt this role into a full, steerable live terminal (PTY) streamed on the agentic relay lane (default: pipe)' },
9764
+ { command: 'c8ctl nano hire --name coder --rank senior --command copilot --protocol acp --permission yolo', description: 'Drive this role over ACP (JSON-RPC/stdio) — ACTIVE on the host executor (sandbox=none); container sandboxes do not yet run ACP (pipe-only today; --terminal pty is host-only). permission yolo is enforced; escalate/filter are reserved (persisted, warned, behave like yolo)' },
9106
9765
  { command: 'c8ctl nano assign reviewer code-review,testing', description: 'Grant more capabilities (comma-separated, like hire) to an existing hire — additive; running workers hot-reload it' },
9107
9766
  { command: 'c8ctl nano work reviewer', description: 'Spawn Nano job workers for the "reviewer" profile and poll for work' },
9108
9767
  { command: 'c8ctl nano work coder --auto', description: 'Zero-config: serve every deployed agent job type read straight from the engine — no capability, no wiring (great for a local single-tenant plane)' },
@@ -9165,6 +9824,8 @@ export const commands = {
9165
9824
  sandbox: { type: 'string', description: 'hire/work: execution sandbox none|docker|podman (default none). Containers isolate each job.' },
9166
9825
  image: { type: 'string', description: 'hire/work: container image the agent runs in (required for --sandbox docker|podman)' },
9167
9826
  terminal: { type: 'string', description: 'hire: live-terminal mode for this role — pty (full terminal, streamed + steerable on the relay lane) or pipe (default). NANO_AGENTIC_TERMINAL overrides at work time.' },
9827
+ protocol: { type: 'string', description: 'hire: harness protocol pipe|acp (default pipe). acp drives the harness over ACP (JSON-RPC 2.0 over stdio) on the host executor (sandbox=none); container sandboxes (docker/podman) do NOT yet run ACP and are pipe-only today (--terminal pty is host-only; container ACP lands downstream). NANO_AGENTIC_PROTOCOL overrides at work time.' },
9828
+ permission: { type: 'string', description: 'hire: ACP permission policy (default yolo). yolo auto-allows all permission requests. escalate|filter are RESERVED/not-yet-active in this build (pending nano-workforce#559): they are persisted but not enforced and effectively behave like yolo (auto-allow). NANO_AGENTIC_PERMISSION overrides at work time.' },
9168
9829
  env: { type: 'string', multiple: true, description: 'hire/work: static env var for the harness as NAME=VALUE (repeatable); persisted on hire, work extends/overrides. E.g. permission toggles.' },
9169
9830
  'secret-resolver': { type: 'string', description: 'work: secret resolver for task secretRefs (host = process env; default host)' },
9170
9831
  'reap-age': { type: 'string', description: 'work: age in ms before a finished agent container or job workspace is reaped (default 3600000)' },
@@ -9337,7 +9998,7 @@ function printUsage() {
9337
9998
  console.log(' c8ctl nano unset <bin|model-dir>');
9338
9999
  console.log(' c8ctl nano config');
9339
10000
  console.log(' c8ctl nano update [--check]');
9340
- console.log(' c8ctl nano hire [--name <n>] [--rank <r>] [--command <c>] [--arg <switch> ...] [--model <m>] [--capabilities <a,b>] [--sandbox none|docker|podman] [--image <ref>] [--terminal pty|pipe] [--env NAME=VALUE ...] [--list]');
10001
+ console.log(' c8ctl nano hire [--name <n>] [--rank <r>] [--command <c>] [--arg <switch> ...] [--model <m>] [--capabilities <a,b>] [--sandbox none|docker|podman] [--image <ref>] [--terminal pty|pipe] [--protocol pipe|acp] [--permission yolo|escalate|filter] [--env NAME=VALUE ...] [--list]');
9341
10002
  console.log(' c8ctl nano assign <profileName> <cap[,cap...]> [--name <n>] [--capabilities <a,b>]');
9342
10003
  console.log(' c8ctl nano work <profileName> [--auto [--auto-scope <p>]] [--arg <switch> ...] [--recovery-window <ms>] [--idle-timeout <ms>] [--job-timeout <ms>] [--poll-timeout <ms>] [--job-type <token> ...] [--sandbox none|docker|podman] [--image <ref>] [--env NAME=VALUE ...] [--secret-resolver host] [--min-free-mb <n>] [--clone-timeout <ms>] [--keep-runs] [--stream]');
9343
10004
  console.log(' c8ctl nano supervisor [start|status|add|remove|restart|stop|logs|attach] ... (manage many workers from one terminal)');
@@ -9382,6 +10043,8 @@ function printUsage() {
9382
10043
  console.log(' --sandbox <s> hire/work: execution sandbox none|docker|podman (default none)');
9383
10044
  console.log(' --image <ref> hire/work: container image the agent runs in (required for docker|podman)');
9384
10045
  console.log(' --terminal <m> hire: live-terminal mode pty|pipe (default pipe); pty streams a steerable terminal on the relay lane');
10046
+ console.log(' --protocol <p> hire: harness protocol pipe|acp (default pipe); acp drives the harness over ACP (JSON-RPC/stdio) on the host executor (sandbox=none) — container sandboxes do not yet run ACP (pipe-only today; --terminal pty is host-only). NANO_AGENTIC_PROTOCOL overrides at work time');
10047
+ console.log(' --permission <p> hire: ACP permission policy yolo|escalate|filter (default yolo); yolo auto-allows all requests. escalate|filter are RESERVED/not-yet-active (pending nano-workforce#559) — persisted but not enforced, effectively behave like yolo (auto-allow). NANO_AGENTIC_PERMISSION overrides at work time');
9385
10048
  console.log(' --env NAME=VALUE hire/work: static env var for the harness (repeatable); persisted on hire, work extends/overrides');
9386
10049
  console.log(' --list hire: list existing agent profiles instead of creating one');
9387
10050
  console.log(' --job-type <token> work: extra job type to service alongside the rank×capability matrix (repeatable)');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "c8ctl-plugin-nano",
3
- "version": "1.39.2",
3
+ "version": "1.41.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",
@@ -57,12 +57,12 @@
57
57
  },
58
58
  "optionalDependencies": {
59
59
  "node-pty": "^1.0.0",
60
- "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.39.2",
61
- "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.39.2",
62
- "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.39.2",
63
- "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.39.2",
64
- "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.39.2",
65
- "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.39.2",
66
- "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.39.2"
60
+ "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.41.0",
61
+ "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.41.0",
62
+ "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.41.0",
63
+ "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.41.0",
64
+ "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.41.0",
65
+ "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.41.0",
66
+ "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.41.0"
67
67
  }
68
68
  }