c8ctl-plugin-nano 1.40.0 → 1.42.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 +83 -0
  2. package/c8ctl-plugin.js +685 -10
  3. package/package.json +8 -8
package/README.md CHANGED
@@ -157,6 +157,9 @@ c8ctl nano hire --name reviewer --rank senior --command copilot \
157
157
  # Give the harness command-line switches (e.g. run copilot with --allow-all)
158
158
  c8ctl nano hire --name coder --rank senior --command copilot --arg --allow-all
159
159
 
160
+ # Opt a role into the ACP harness mode (JSON-RPC/stdio) — see "ACP harness mode" below
161
+ c8ctl nano hire --name coder --rank senior --command copilot --protocol acp
162
+
160
163
  # List profiles
161
164
  c8ctl nano hire --list
162
165
  ```
@@ -409,6 +412,86 @@ bound is operator-tunable for a long expected outage; raise it with
409
412
  `NANO_AGENTIC_BUFFER_CAPACITY` (frames). When the bound is hit the worker warns
410
413
  rather than silently shedding.
411
414
 
415
+ ### ACP harness mode (opt-in)
416
+
417
+ Alongside the `pipe`/`pty` terminal choice above, a role can opt into driving its
418
+ harness over the **[Agent Client Protocol (ACP)](https://agentclientprotocol.com)**
419
+ — JSON-RPC 2.0 over stdio — instead of the default stdin/scrape **pipe**. ACP is
420
+ **additive, not a switch**: `pipe` stays the default floor, so every CLI harness
421
+ (and non-agent record-keepers) keeps working unchanged, and **YOLO stays the
422
+ default** via auto-approve. You turn ACP on per role, exactly like `--terminal`:
423
+
424
+ ```bash
425
+ # Drive a role's harness over ACP (JSON-RPC/stdio) instead of the pipe
426
+ c8ctl nano hire --name coder --rank senior --command copilot --protocol acp
427
+
428
+ # Override protocol/permission for a one-off worker without re-hiring
429
+ NANO_AGENTIC_PROTOCOL=pipe c8ctl nano work coder
430
+ NANO_AGENTIC_PERMISSION=escalate c8ctl nano work coder
431
+ ```
432
+
433
+ - `--protocol pipe|acp` (default `pipe`) selects the harness protocol.
434
+ `NANO_AGENTIC_PROTOCOL` overrides it at work time (mirroring
435
+ `NANO_AGENTIC_TERMINAL`).
436
+ - `--permission yolo|escalate|filter` (default `yolo`) selects the ACP permission
437
+ policy. `NANO_AGENTIC_PERMISSION` overrides it at work time.
438
+
439
+ **What ACP unlocks.** Because the harness speaks a structured protocol rather than
440
+ a scraped terminal, the ACP path gives you a **structured turn/tool event stream**
441
+ (today serialized to text chunks on the relay lane — a *minimal* mode, not yet
442
+ typed turn/tool envelopes), **native permission handling**, and **PTY-free
443
+ steering** — an operator's
444
+ steer text is delivered as a `session/prompt` and an interrupt as a
445
+ `session/cancel`, with no keystroke injection. The ACP path therefore does **not**
446
+ need the optional native `node-pty` dependency at all.
447
+
448
+ **Permission policies — mind the status.** Only `yolo` is enforced today:
449
+
450
+ - **`yolo`** *(default, the only enforced policy today)* — auto-allows every
451
+ permission request the agent raises: full speed, no human in the loop. This is
452
+ the same auto-approve posture the pipe/PTY paths already run with.
453
+ - **`escalate`** and **`filter`** are **RESERVED / not-yet-active** in this build.
454
+ They are **accepted and persisted** for forward-compatibility, but they are
455
+ **not yet enforced**: at work time they fall back to a **safe interim policy**
456
+ (currently auto-allow, like `yolo`) and the CLI **emits a warning** so an
457
+ operator is never misled into thinking destructive operations are gated. Their
458
+ intended future behavior — **`escalate`** blocking a permission request until a
459
+ human answers it, and **`filter`** auto-allowing reads/edits while escalating
460
+ destructive operations — is **not available yet**; it lands once the companion
461
+ permission-event + escalation bridge (nanobpm/nano-workforce#559) ships.
462
+
463
+ **Per-CLI hire examples.** Some CLIs speak ACP natively; others ride a thin
464
+ adapter binary. In every case the profile's command (plus any `--arg`s) assembles
465
+ the ACP invocation; a default `--acp` switch is appended only when the assembled
466
+ command line doesn't already select ACP, so a native/adapter invocation is never
467
+ doubled. All of these run with the enforced default `yolo` policy:
468
+
469
+ ```bash
470
+ # Copilot CLI — native ACP (assembles `copilot --acp`)
471
+ c8ctl nano hire --name coder --rank senior --command copilot --protocol acp --permission yolo
472
+
473
+ # OpenCode — native ACP server (assembles `opencode acp`)
474
+ c8ctl nano hire --name coder --rank senior --command opencode --arg acp --protocol acp --permission yolo
475
+
476
+ # Claude Code — via the `claude-agent-acp` adapter
477
+ c8ctl nano hire --name coder --rank senior --command claude-agent-acp --protocol acp --permission yolo
478
+
479
+ # Pi — via the `pi-acp` adapter
480
+ c8ctl nano hire --name coder --rank senior --command pi-acp --protocol acp --permission yolo
481
+ ```
482
+
483
+ > `--permission yolo` is the default, so you can omit it. You **may** hire with
484
+ > `--permission escalate` or `--permission filter` today — the value is persisted
485
+ > — but it is **reserved / not-yet-active** (pending nanobpm/nano-workforce#559)
486
+ > and currently behaves as the safe interim policy with a warning, so do **not**
487
+ > rely on it to gate destructive operations yet.
488
+
489
+ **Non-goals.** ACP does not replace anything: the **pipe/PTY** surface stays the
490
+ default floor and every existing harness keeps working unchanged (ACP is enforced
491
+ on the host executor; container sandboxes remain **pipe-only** for now). There is
492
+ **no change to the Camunda-8 worker⇄engine job protocol** — ACP governs only how a
493
+ worker drives its local agent harness, not how it talks to the engine.
494
+
412
495
  ### Live profile reload (no restart on `assign`)
413
496
 
414
497
  A running `c8ctl nano work <name>` **watches** the profile it is servicing. When
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';
@@ -2113,7 +2114,7 @@ async function hireWorker(req, flags) {
2113
2114
  if (profile.args.length > 0) logger.info(` args: ${profile.args.map(shQuote).join(' ')}`);
2114
2115
  logger.info(` sandbox: ${profile.sandbox}${CONTAINER_SANDBOXES.has(profile.sandbox) ? ` (image ${profile.image})` : ''}`);
2115
2116
  logger.info(` live terminal: ${profile.terminal}${profile.terminal === 'pty' ? ' (streamed + steerable on the relay lane)' : ''}`);
2116
- logger.info(` protocol: ${profile.protocol}${profile.protocol === 'acp' ? ' (Agent Client Protocol — JSON-RPC over stdio; RESERVED accepted/persisted but not yet active in this build; the harness still runs on the transport selected by --terminal (pipe or pty))' : ''}`);
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))' : ''}`);
2117
2118
  logger.info(` permission: ${profile.permission}${(profile.permission === 'escalate' || profile.permission === 'filter') ? ' (RESERVED — not yet enforced, pending nano-workforce#559)' : ''}`);
2118
2119
  const envKeys = Object.keys(profile.env);
2119
2120
  if (envKeys.length > 0) logger.info(` env: ${envKeys.join(', ')}`);
@@ -2201,7 +2202,7 @@ const RESULT_SENTINEL = '::nano:result::';
2201
2202
  // audit envelope or process bookkeeping.
2202
2203
  const RESERVED_RESULT_KEYS = new Set([
2203
2204
  AGENT_RESULT_KEY, 'output', 'exitCode', 'agent', 'truncated',
2204
- 'branch', 'commits', 'pushed', 'pullRequest',
2205
+ 'branch', 'commits', 'pushed', 'pullRequest', 'forcedReap',
2205
2206
  ]);
2206
2207
 
2207
2208
  // Parse `text` as a JSON object, returning it only when it is a plain object.
@@ -3959,6 +3960,632 @@ function spawnCapturePty({ command, args = [], cwd, env, stdinData, timeoutMs, i
3959
3960
  });
3960
3961
  }
3961
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 executor drives ACP end-to-end. Each `session/update` is mapped to a
4030
+ // typed `nwfTranscriptEvent` envelope (#110, step 2) and published on the relay
4031
+ // session's typed publish seam (`relayTap.relayEnvelope`) — the rich cockpit
4032
+ // format its derive+render consumes. The raw relay TRANSPORT is untouched (still
4033
+ // `relaySession.relay(text)`); only the payload shape on the lane changes. When
4034
+ // an update has no typed mapping, or the relay exposes no typed seam (minimal
4035
+ // mode / a plain tap), it falls back to the step-1 human-TEXT chunk on the same
4036
+ // lane (`relayTap.onData`) so nothing is dropped. The raw JSON-RPC is never
4037
+ // tee'd either way.
4038
+ //
4039
+ // Framing: ACP frames are newline-delimited JSON-RPC 2.0 messages on stdio (one
4040
+ // compact JSON object per line, `\n`-terminated). We implement a tiny inline
4041
+ // framer/parser rather than pull a dependency.
4042
+ //
4043
+ // Sequence: initialize → session/new { cwd } → session/prompt { prompt } →
4044
+ // consume session/update notifications until the prompt request resolves
4045
+ // (end-of-turn) → end stdin for a clean shutdown → wait for the child's `close`
4046
+ // to settle the promise. Settling on the real exit (rather than the instant the
4047
+ // turn resolves) avoids racing the caller's result-file read and surfaces a late
4048
+ // non-zero/early exit as a failure. The result-file merge is unchanged: the
4049
+ // agent writes `$AGENT_RESULT_FILE` (already set in `env`) and the caller reads
4050
+ // it exactly as in pipe mode.
4051
+ //
4052
+ // Permission: inbound `session/request_permission` requests are answered by the
4053
+ // `permission` policy switch — see below. `yolo` auto-allow-always is the only
4054
+ // policy enforced today; `escalate`/`filter` fall back to a warned safe interim
4055
+ // policy pending nano-workforce#559.
4056
+ //
4057
+ // Same result contract as spawnCaptureOneShot/spawnCapturePty so buildResultEnvelope
4058
+ // and every caller work unchanged. Because the raw stream is JSON-RPC (not human
4059
+ // output), `stdout` here is the accumulated human-readable transcript text (what
4060
+ // we relay), and `stderr` is the child's real stderr (agent diagnostics).
4061
+ function spawnCaptureAcp({ command, args = [], cwd, env, stdinData, timeoutMs, idleTimeoutMs, relayTap = null, stream = false, streamPrefix = '', onStreamOut, onStreamErr, permission = 'yolo', shell = false }) {
4062
+ return new Promise((resolve) => {
4063
+ const logger = getLogger();
4064
+ const humanChunks = [];
4065
+ let humanBytes = 0;
4066
+ let humanTruncated = false;
4067
+ const stderrChunks = [];
4068
+ let stderrBytes = 0;
4069
+ let stderrTruncated = false;
4070
+ let settled = false;
4071
+ let timer = null;
4072
+ let idleTimer = null;
4073
+ let detachSteer = null;
4074
+ let child;
4075
+ let sessionId = null;
4076
+ let nextId = 1;
4077
+ const pending = new Map();
4078
+ let childClosed = null; // { code, signal } once the child exits
4079
+ let promptResolved = false; // the main session/prompt turn sent + resolved
4080
+ let settleTimer = null; // post-turn grace before force-reaping a lingering agent
4081
+ // One-time warning latch for the reserved escalate/filter policies so the
4082
+ // deferral is observable (not silent) but never spams a warning per request.
4083
+ let interimWarned = false;
4084
+
4085
+ // Live "spy" tee (--stream), line-buffered, mirroring the other paths.
4086
+ // Separate line buffers per lane (stdout-human vs stderr) so a partial line
4087
+ // on one lane never interleaves mid-line with the other — matching pipe/PTY.
4088
+ // stderr routes through its own sink (`onStreamErr`) so it keeps its warn/
4089
+ // error severity instead of being flattened onto the stdout sink; it falls
4090
+ // back to the stdout sink (then process.stdout) when no error sink is wired.
4091
+ const STREAM_TEE_LINE_CAP = 64 * 1024;
4092
+ const defaultTeeOut = (line) => process.stdout.write(`${line}\n`);
4093
+ const outSink = stream ? (onStreamOut || defaultTeeOut) : null;
4094
+ const errSink = stream ? (onStreamErr || onStreamOut || defaultTeeOut) : null;
4095
+ const teeSink = outSink; // truthy iff --stream is on (shared streaming guard)
4096
+ const makeTee = (sink) => {
4097
+ let partial = '';
4098
+ return (text, final) => {
4099
+ if (!sink) return;
4100
+ partial += text;
4101
+ let nl;
4102
+ while ((nl = partial.indexOf('\n')) !== -1) {
4103
+ sink(`${streamPrefix}${partial.slice(0, nl)}`);
4104
+ partial = partial.slice(nl + 1);
4105
+ }
4106
+ while (partial.length >= STREAM_TEE_LINE_CAP) {
4107
+ sink(`${streamPrefix}${partial.slice(0, STREAM_TEE_LINE_CAP)}`);
4108
+ partial = partial.slice(STREAM_TEE_LINE_CAP);
4109
+ }
4110
+ if (final && partial) { sink(`${streamPrefix}${partial}`); partial = ''; }
4111
+ };
4112
+ };
4113
+ const tee = makeTee(outSink);
4114
+ const teeErr = makeTee(errSink);
4115
+
4116
+ const humanStdout = () => joinCapped(humanChunks);
4117
+
4118
+ // Local mirrors of a human text chunk: the --stream spy tee and the byte-
4119
+ // capped stdout capture (what the result envelope carries). Deliberately does
4120
+ // NOT touch the relay lane, so a typed-transcript update can mirror its human
4121
+ // text locally (for the result + spy) WITHOUT also re-emitting raw text onto
4122
+ // the relay lane — which, in step 2, carries the typed envelope instead.
4123
+ const captureHuman = (text) => {
4124
+ if (!text) return;
4125
+ const buf = Buffer.from(text, 'utf8');
4126
+ if (teeSink) tee(text, false);
4127
+ const remaining = MAX_CAPTURE_BYTES - humanBytes;
4128
+ if (remaining <= 0) { humanTruncated = true; return; }
4129
+ if (buf.length > remaining) { humanChunks.push(buf.subarray(0, remaining)); humanBytes = MAX_CAPTURE_BYTES; humanTruncated = true; }
4130
+ else { humanChunks.push(buf); humanBytes += buf.length; }
4131
+ };
4132
+
4133
+ // Emit a human-meaningful text chunk on the SAME lanes the pipe/pty paths
4134
+ // use: the relay tap (framed + jobKey-tagged by the caller) and the local
4135
+ // --stream spy tee. Byte-capped like the raw captures. This is the minimal-
4136
+ // mode text path — used for stderr, and as the fallback for any session/update
4137
+ // that has no typed nwfTranscriptEvent mapping (or when the relay exposes no
4138
+ // typed publish seam).
4139
+ const emitHuman = (text) => {
4140
+ if (!text) return;
4141
+ if (relayTap && typeof relayTap.onData === 'function') {
4142
+ try { relayTap.onData(text); } catch { /* relay best-effort */ }
4143
+ }
4144
+ captureHuman(text);
4145
+ };
4146
+
4147
+ const finish = (result) => {
4148
+ if (settled) return;
4149
+ settled = true;
4150
+ if (timer) clearTimeout(timer);
4151
+ if (idleTimer) clearTimeout(idleTimer);
4152
+ if (settleTimer) { clearTimeout(settleTimer); settleTimer = null; }
4153
+ if (detachSteer) { try { detachSteer(); } catch { /* best effort */ } detachSteer = null; }
4154
+ if (teeSink) { tee('', true); teeErr('', true); }
4155
+ // Reap the child if it is still alive (turn resolved but agent lingering).
4156
+ try { if (child && childClosed === null) killTree(child); } catch { /* best effort */ }
4157
+ resolve(result);
4158
+ };
4159
+
4160
+ // --- JSON-RPC 2.0 plumbing (newline-delimited framing) -------------------
4161
+ const send = (obj) => {
4162
+ try { child.stdin.write(`${JSON.stringify(obj)}\n`); } catch { /* child gone; close handler settles */ }
4163
+ };
4164
+ const request = (method, params) => new Promise((res, rej) => {
4165
+ const id = nextId++;
4166
+ pending.set(id, { res, rej });
4167
+ send({ jsonrpc: '2.0', id, method, params });
4168
+ });
4169
+ const notify = (method, params) => send({ jsonrpc: '2.0', method, params });
4170
+ const respond = (id, result) => send({ jsonrpc: '2.0', id, result });
4171
+ const respondError = (id, code, message) => send({ jsonrpc: '2.0', id, error: { code, message } });
4172
+
4173
+ // Pick the "allow-always" option (yolo / safe-interim structural path). ACP
4174
+ // permission options carry a `kind` (allow_always|allow_once|reject_*). We
4175
+ // prefer allow_always, then allow_once, then the first option; this is the
4176
+ // conservative structural default the interim policy also uses.
4177
+ const pickAllowOption = (options) => {
4178
+ const list = Array.isArray(options) ? options : [];
4179
+ return list.find((o) => o && o.kind === 'allow_always')
4180
+ || list.find((o) => o && o.kind === 'allow_once')
4181
+ || list[0]
4182
+ || null;
4183
+ };
4184
+
4185
+ const handlePermission = (id, params) => {
4186
+ const options = params?.options;
4187
+ const allow = pickAllowOption(options);
4188
+ const grant = () => {
4189
+ if (allow && allow.optionId != null) {
4190
+ respond(id, { outcome: { outcome: 'selected', optionId: allow.optionId } });
4191
+ } else {
4192
+ // No option to select (malformed request) — cancel rather than hang.
4193
+ respond(id, { outcome: { outcome: 'cancelled' } });
4194
+ }
4195
+ };
4196
+ switch (permission) {
4197
+ case 'yolo':
4198
+ // The ONLY fully-enforced policy: auto-allow-always, no human, sub-ms
4199
+ // local round-trip.
4200
+ grant();
4201
+ break;
4202
+ // TODO(#559): implement escalate/filter — the real permission-event +
4203
+ // escalation bridge (block-until-answered for escalate; auto-allow
4204
+ // reads/edits + escalate destructive ops for filter) lands with the
4205
+ // companion nano-workforce#559 task. Until then these reserved policies
4206
+ // must NOT masquerade as enforced: warn once, then fall through to the
4207
+ // safe interim structural policy (same allow-always grant as yolo).
4208
+ case 'escalate':
4209
+ case 'filter':
4210
+ default:
4211
+ if (!interimWarned) {
4212
+ interimWarned = true;
4213
+ logger.warn?.(`ACP permission policy '${permission}' is not yet enforced in this build; requests handled by interim policy pending nano-workforce#559`);
4214
+ }
4215
+ grant();
4216
+ break;
4217
+ }
4218
+ };
4219
+
4220
+ // Extract plain text from an ACP content value (string, {type,text}, or an
4221
+ // array of content blocks). Shared by the typed-envelope mapper and the
4222
+ // human-text describer so both agree on what the "text" of an update is.
4223
+ const acpTextOf = (content) => {
4224
+ if (content == null) return '';
4225
+ if (typeof content === 'string') return content;
4226
+ if (Array.isArray(content)) return content.map(acpTextOf).join('');
4227
+ if (typeof content === 'object') return typeof content.text === 'string' ? content.text : '';
4228
+ return '';
4229
+ };
4230
+
4231
+ // Serialise an ACP session/update into a short human-readable line. This is
4232
+ // the minimal-mode TEXT for the existing cockpit lane (the fallback), not a
4233
+ // typed envelope.
4234
+ const describeUpdate = (update) => {
4235
+ if (!update || typeof update !== 'object') return '';
4236
+ const kind = update.sessionUpdate || update.type || 'update';
4237
+ switch (kind) {
4238
+ case 'agent_message_chunk':
4239
+ return acpTextOf(update.content);
4240
+ case 'agent_thought_chunk':
4241
+ return `\u{1F4AD} ${acpTextOf(update.content)}`;
4242
+ case 'user_message_chunk':
4243
+ return acpTextOf(update.content);
4244
+ case 'tool_call': {
4245
+ const title = update.title || update.toolCallId || 'tool';
4246
+ return `\u2699 [tool: ${title}${update.status ? ` — ${update.status}` : ''}]\n`;
4247
+ }
4248
+ case 'tool_call_update': {
4249
+ const title = update.title || update.toolCallId || 'tool';
4250
+ return `\u2699 [tool: ${title}${update.status ? ` — ${update.status}` : ''}]\n`;
4251
+ }
4252
+ case 'plan':
4253
+ return `\u{1F4CB} [plan updated]\n`;
4254
+ default:
4255
+ return `[${kind}]\n`;
4256
+ }
4257
+ };
4258
+
4259
+ // #110 step 2: map an ACP session/update to a typed `nwfTranscriptEvent`
4260
+ // envelope — the rich cockpit wire format (the existing downstream
4261
+ // derive+render consumes it). Returns null for an update kind we don't model,
4262
+ // so the caller falls back to the minimal human-text path (nothing dropped,
4263
+ // no regression vs step 1). The `text` field carries the same plain text the
4264
+ // fallback would relay, so a lightweight consumer can still render it.
4265
+ const TRANSCRIPT_EVENT_TYPE = 'nwfTranscriptEvent';
4266
+ const TRANSCRIPT_EVENT_VERSION = 1;
4267
+ const mapTranscriptEnvelope = (update) => {
4268
+ if (!update || typeof update !== 'object') return null;
4269
+ const kind = update.sessionUpdate || update.type;
4270
+ if (!kind) return null;
4271
+ const base = { type: TRANSCRIPT_EVENT_TYPE, v: TRANSCRIPT_EVENT_VERSION, ts: Date.now() };
4272
+ // Optional fields stay `undefined` (JSON encoding omits them) rather than
4273
+ // becoming explicit `null`s, and `??` preserves empty strings — so
4274
+ // consumers see omitted/optional strings, not coerced nulls. `status`
4275
+ // falls through to the kind's default only when genuinely absent.
4276
+ const toolOf = (u, defaultStatus) => ({
4277
+ id: u.toolCallId ?? undefined,
4278
+ title: u.title ?? undefined,
4279
+ status: u.status ?? defaultStatus ?? undefined,
4280
+ kind: u.kind ?? undefined,
4281
+ });
4282
+ switch (kind) {
4283
+ case 'agent_message_chunk':
4284
+ return { ...base, kind: 'message', role: 'agent', text: acpTextOf(update.content) };
4285
+ case 'agent_thought_chunk':
4286
+ return { ...base, kind: 'thought', role: 'agent', text: acpTextOf(update.content) };
4287
+ case 'user_message_chunk':
4288
+ return { ...base, kind: 'message', role: 'user', text: acpTextOf(update.content) };
4289
+ case 'tool_call':
4290
+ // `text` mirrors the fallback's plain text so the typed envelope stays
4291
+ // self-contained for lightweight renderers (matches the stated contract).
4292
+ return { ...base, kind: 'tool_call', text: describeUpdate(update), tool: toolOf(update, 'pending') };
4293
+ case 'tool_call_update':
4294
+ return { ...base, kind: 'tool_call_update', text: describeUpdate(update), tool: toolOf(update, undefined) };
4295
+ case 'plan':
4296
+ // Carry the actual plan entries (rich cockpit renders them), not a
4297
+ // count — an absent/malformed payload stays `undefined` (omitted).
4298
+ return { ...base, kind: 'plan', entries: Array.isArray(update.entries) ? update.entries : undefined };
4299
+ default:
4300
+ // Unmodelled kind → no typed envelope; caller uses the text fallback.
4301
+ return null;
4302
+ }
4303
+ };
4304
+
4305
+ // Publish a session/update: prefer the typed nwfTranscriptEvent envelope on
4306
+ // the relay's typed publish seam; fall back to the minimal human-text path
4307
+ // when the update has no typed mapping OR the relay exposes no typed seam, so
4308
+ // nothing is ever dropped (no regression vs minimal mode).
4309
+ const emitTranscript = (update) => {
4310
+ const env = mapTranscriptEnvelope(update);
4311
+ if (env && relayTap && typeof relayTap.relayEnvelope === 'function') {
4312
+ // Only skip the text fallback when the typed publish ACTUALLY succeeded.
4313
+ // If the seam throws (a downstream tap implementation, not just the
4314
+ // built-in best-effort stringify guard), the envelope never reached the
4315
+ // relay lane — so we must fall through to the text path or that update
4316
+ // would be silently dropped, breaking the "nothing is ever dropped"
4317
+ // guarantee.
4318
+ let published = false;
4319
+ try { relayTap.relayEnvelope(env); published = true; } catch { /* relay best-effort */ }
4320
+ if (published) {
4321
+ // Mirror the human text locally (spy tee + captured stdout) so the
4322
+ // result envelope and --stream spy are unchanged — without re-emitting
4323
+ // raw text onto the relay lane, which now carries the typed envelope.
4324
+ captureHuman(describeUpdate(update));
4325
+ return;
4326
+ }
4327
+ // Typed publish threw → fall through to the text lane below.
4328
+ }
4329
+ // Fallback: minimal text-chunk path (relay text + spy tee + capture).
4330
+ emitHuman(describeUpdate(update));
4331
+ };
4332
+
4333
+ const handleMessage = (msg) => {
4334
+ if (!msg || typeof msg !== 'object') return;
4335
+ // A response to one of OUR requests.
4336
+ if (msg.id !== undefined && msg.method === undefined && (msg.result !== undefined || msg.error !== undefined)) {
4337
+ const p = pending.get(msg.id);
4338
+ if (p) {
4339
+ pending.delete(msg.id);
4340
+ if (msg.error) p.rej(new Error(msg.error.message || `rpc error ${msg.error.code}`));
4341
+ else p.res(msg.result);
4342
+ }
4343
+ return;
4344
+ }
4345
+ // A request or notification FROM the agent.
4346
+ if (typeof msg.method === 'string') {
4347
+ if (msg.method === 'session/update') { emitTranscript(msg.params?.update); return; }
4348
+ if (msg.method === 'session/request_permission') {
4349
+ if (msg.id !== undefined) handlePermission(msg.id, msg.params);
4350
+ return;
4351
+ }
4352
+ // Unknown request → method-not-found; unknown notification → ignore.
4353
+ if (msg.id !== undefined) respondError(msg.id, -32601, `method not found: ${msg.method}`);
4354
+ }
4355
+ };
4356
+
4357
+ // --- spawn ---------------------------------------------------------------
4358
+ try {
4359
+ child = spawn(command, args, { cwd, stdio: ['pipe', 'pipe', 'pipe'], env, detached: process.platform !== 'win32', shell });
4360
+ } catch (err) {
4361
+ finish({ ok: false, exitCode: null, stdout: '', stderr: '', error: err.message, truncated: false, stderrTruncated: false });
4362
+ return;
4363
+ }
4364
+
4365
+ timer = timeoutMs && timeoutMs > 0
4366
+ ? setTimeout(() => {
4367
+ try { killTree(child); } catch { /* best effort */ }
4368
+ finish({ ok: false, exitCode: null, stdout: humanStdout(), stderr: joinCapped(stderrChunks), error: `timed out after ${timeoutMs}ms`, timedOut: true, truncated: humanTruncated, stderrTruncated });
4369
+ }, timeoutMs)
4370
+ : null;
4371
+
4372
+ const armIdle = () => {
4373
+ if (settled) return;
4374
+ if (!(idleTimeoutMs && idleTimeoutMs > 0)) return;
4375
+ if (idleTimer) clearTimeout(idleTimer);
4376
+ idleTimer = setTimeout(() => {
4377
+ try { killTree(child); } catch { /* best effort */ }
4378
+ finish({ ok: false, exitCode: null, stdout: humanStdout(), stderr: joinCapped(stderrChunks), error: `no output for ${idleTimeoutMs}ms (idle)`, timedOut: true, idle: true, truncated: humanTruncated, stderrTruncated });
4379
+ }, idleTimeoutMs);
4380
+ };
4381
+ armIdle();
4382
+
4383
+ // Newline-delimited JSON-RPC parser over stdout. Progress on stdout re-arms
4384
+ // the idle liveness timer (every frame counts as progress). A StringDecoder
4385
+ // buffers any multibyte UTF-8 sequence split across chunk boundaries so the
4386
+ // assembled JSON text is never corrupted (a partial code point is held back
4387
+ // until the continuation byte arrives, rather than emitting U+FFFD).
4388
+ let rxBuf = '';
4389
+ const rxDecoder = new StringDecoder('utf8');
4390
+ child.stdout.on('data', (d) => {
4391
+ armIdle();
4392
+ rxBuf += rxDecoder.write(Buffer.isBuffer(d) ? d : Buffer.from(d));
4393
+ let nl;
4394
+ while ((nl = rxBuf.indexOf('\n')) !== -1) {
4395
+ const line = rxBuf.slice(0, nl).trim();
4396
+ rxBuf = rxBuf.slice(nl + 1);
4397
+ if (!line) continue;
4398
+ let msg;
4399
+ try {
4400
+ msg = JSON.parse(line);
4401
+ } catch {
4402
+ // stdout is a pure newline-delimited JSON-RPC stream; a line that
4403
+ // isn't JSON is a framing/protocol violation, not noise. Silently
4404
+ // skipping it would mask a misconfigured agent as an opaque idle
4405
+ // timeout (and keep re-arming the idle timer on garbage). Fail fast
4406
+ // with an explicit error, mirroring the un-terminated-frame cap below.
4407
+ const preview = line.length > 200 ? `${line.slice(0, 200)}…` : line;
4408
+ rxBuf = '';
4409
+ try { killTree(child); } catch { /* best effort */ }
4410
+ finish({ ok: false, exitCode: null, stdout: humanStdout(), stderr: joinCapped(stderrChunks), error: `ACP framing violation: non-JSON line on stdout: ${preview}`, truncated: humanTruncated, stderrTruncated });
4411
+ return;
4412
+ }
4413
+ try { handleMessage(msg); } catch { /* one bad frame must not wedge the loop */ }
4414
+ }
4415
+ // No newline in the (now line-free) tail past the cap → the peer is
4416
+ // streaming an unbounded frame. Fail rather than buffer to exhaustion.
4417
+ if (Buffer.byteLength(rxBuf, 'utf8') > ACP_MAX_LINE_BYTES) {
4418
+ rxBuf = '';
4419
+ try { killTree(child); } catch { /* best effort */ }
4420
+ 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 });
4421
+ }
4422
+ });
4423
+
4424
+ child.stderr.on('data', (d) => {
4425
+ armIdle();
4426
+ const buf = Buffer.isBuffer(d) ? d : Buffer.from(d);
4427
+ // Forward stderr to the SAME live lanes the pipe/PTY paths use — the
4428
+ // --stream spy tee and the relay tap — so agent diagnostics are visible
4429
+ // during execution rather than only after it finishes. This is safe
4430
+ // precisely because stdout is the pure JSON-RPC channel here: stderr never
4431
+ // carries protocol frames, so teeing/relaying it can't corrupt the
4432
+ // relayed human stream.
4433
+ const text = buf.toString('utf8');
4434
+ if (teeSink) teeErr(text, false);
4435
+ if (relayTap && typeof relayTap.onData === 'function') {
4436
+ try { relayTap.onData(text); } catch { /* relay best-effort */ }
4437
+ }
4438
+ const remaining = MAX_CAPTURE_BYTES - stderrBytes;
4439
+ if (remaining <= 0) { stderrTruncated = true; return; }
4440
+ if (buf.length > remaining) { stderrChunks.push(buf.subarray(0, remaining)); stderrBytes = MAX_CAPTURE_BYTES; stderrTruncated = true; }
4441
+ else { stderrChunks.push(buf); stderrBytes += buf.length; }
4442
+ });
4443
+
4444
+ child.stdin.on('error', () => { /* peer may close first; close handler settles */ });
4445
+
4446
+ child.on('error', (err) => {
4447
+ finish({ ok: false, exitCode: null, stdout: humanStdout(), stderr: joinCapped(stderrChunks), error: err.message, truncated: humanTruncated, stderrTruncated });
4448
+ });
4449
+ child.on('close', (code, signal) => {
4450
+ childClosed = { code, signal: signal ?? null };
4451
+ if (settleTimer) { clearTimeout(settleTimer); settleTimer = null; }
4452
+ // Flush the UTF-8 decoder's held-back bytes (an incomplete multibyte
4453
+ // sequence at EOF) and drain any now-complete newline-delimited frames,
4454
+ // so a final frame that arrives in the same read as EOF isn't dropped.
4455
+ // Whatever remains after that is an un-terminated tail on what is supposed
4456
+ // to be a pure newline-delimited JSON-RPC stream — a framing violation we
4457
+ // must NOT let masquerade as success.
4458
+ let unterminatedTail = '';
4459
+ let framingViolation = '';
4460
+ try {
4461
+ rxBuf += rxDecoder.end();
4462
+ let nl;
4463
+ while ((nl = rxBuf.indexOf('\n')) !== -1) {
4464
+ const line = rxBuf.slice(0, nl).trim();
4465
+ rxBuf = rxBuf.slice(nl + 1);
4466
+ if (!line) continue;
4467
+ let msg;
4468
+ try {
4469
+ msg = JSON.parse(line);
4470
+ } catch {
4471
+ // A final newline-delimited frame that isn't JSON is a framing/
4472
+ // protocol violation on what must be a pure JSON-RPC stream — the
4473
+ // same rule the `data` handler enforces. Silently swallowing it here
4474
+ // would let a malformed shutdown masquerade as a clean success, so
4475
+ // record it and fail below instead of ignoring the parse error.
4476
+ framingViolation = line;
4477
+ break;
4478
+ }
4479
+ try { handleMessage(msg); } catch { /* one bad frame must not wedge shutdown */ }
4480
+ }
4481
+ if (!framingViolation) unterminatedTail = rxBuf.trim();
4482
+ } catch { /* decoder flush best effort */ }
4483
+ rxBuf = '';
4484
+ // Settle on the child's ACTUAL exit — this is what avoids racing the
4485
+ // caller's $AGENT_RESULT_FILE read: the file's write/flush is guaranteed
4486
+ // complete once the process is gone. Success requires BOTH the main
4487
+ // session/prompt turn to have resolved AND a clean exit AND no leftover
4488
+ // un-terminated frame — an early exit (e.g. code 0 during the handshake,
4489
+ // before the turn completes), any non-zero exit, or a dangling tail is a
4490
+ // failure, never a false success.
4491
+ const ok = promptResolved && code === 0 && !unterminatedTail && !framingViolation;
4492
+ // On failure, populate an explicit `error` so callers/logs explain WHY —
4493
+ // otherwise an early exit (code 0 before the turn resolved) surfaces as a
4494
+ // confusing bare "exit code 0" with no detail.
4495
+ let error;
4496
+ if (!ok) {
4497
+ if (framingViolation && promptResolved && code === 0) {
4498
+ const preview = framingViolation.length > 200 ? `${framingViolation.slice(0, 200)}…` : framingViolation;
4499
+ error = `ACP framing violation: non-JSON line on stdout at exit: ${preview}`;
4500
+ } else if (unterminatedTail && promptResolved && code === 0) {
4501
+ const preview = unterminatedTail.length > 200 ? `${unterminatedTail.slice(0, 200)}…` : unterminatedTail;
4502
+ error = `ACP framing violation: un-terminated JSON-RPC frame on stdout at exit: ${preview}`;
4503
+ } else {
4504
+ const how = signal ? `signal ${signal}` : `code ${code}`;
4505
+ error = promptResolved
4506
+ ? `ACP agent exited with ${how} (session/prompt completed)`
4507
+ : `ACP agent exited with ${how} before the session/prompt turn completed`;
4508
+ }
4509
+ }
4510
+ finish({ ok, exitCode: code, signal: signal ?? null, stdout: humanStdout(), stderr: joinCapped(stderrChunks), ...(error ? { error } : {}), truncated: humanTruncated, stderrTruncated });
4511
+ });
4512
+
4513
+ // Steer + cancel via the relay tap — NO PTY. Wired once the session exists.
4514
+ const attachSteerIfAny = () => {
4515
+ if (!relayTap || typeof relayTap.attachSteer !== 'function') return;
4516
+ detachSteer = relayTap.attachSteer((data) => {
4517
+ const text = typeof data === 'string' ? data : Buffer.from(data).toString('utf8');
4518
+ if (text.includes(ACP_INTERRUPT_BYTE)) {
4519
+ // Ctrl-C / ETX → interrupt the live turn.
4520
+ if (sessionId != null) notify('session/cancel', { sessionId });
4521
+ return;
4522
+ }
4523
+ const steer = text.replace(/[\r\n]+$/, '');
4524
+ if (!steer) return;
4525
+ // Mid-turn steer → a fresh prompt on the live session (fire-and-forget;
4526
+ // its own resolution is not part of the main turn sequence).
4527
+ if (sessionId != null) {
4528
+ request('session/prompt', { sessionId, prompt: [{ type: 'text', text: steer }] }).catch(() => {});
4529
+ }
4530
+ });
4531
+ };
4532
+
4533
+ // --- drive the handshake + turn -----------------------------------------
4534
+ (async () => {
4535
+ await request('initialize', {
4536
+ protocolVersion: 1,
4537
+ clientCapabilities: { fs: { readTextFile: false, writeTextFile: false } },
4538
+ });
4539
+ const created = await request('session/new', { cwd: cwd || process.cwd(), mcpServers: [] });
4540
+ sessionId = created?.sessionId ?? null;
4541
+ attachSteerIfAny();
4542
+ // Deliver the task envelope as the prompt (from stdinData, matching the
4543
+ // pipe/pty paths which write the same payload to stdin).
4544
+ await request('session/prompt', {
4545
+ sessionId,
4546
+ prompt: [{ type: 'text', text: String(stdinData ?? '') }],
4547
+ });
4548
+ // End-of-turn: the main session/prompt request resolved. Mark it so the
4549
+ // `close` handler can tell a completed turn from an early/handshake exit.
4550
+ promptResolved = true;
4551
+ // The agent has written $AGENT_RESULT_FILE; close stdin so it can flush and
4552
+ // exit, then let the child's `close` event settle the promise. Settling on
4553
+ // the real exit (not here) avoids racing the caller's result-file read and
4554
+ // surfaces a late non-zero exit as a failure instead of a false success. A
4555
+ // well-behaved agent exits promptly once stdin closes; force-reap a
4556
+ // lingering one after a short grace so a finished turn is never held hostage
4557
+ // to the full timeout.
4558
+ try { child.stdin.end(); } catch { /* already gone */ }
4559
+ if (childClosed === null) {
4560
+ settleTimer = setTimeout(() => {
4561
+ settleTimer = null;
4562
+ try { if (child && childClosed === null) killTree(child); } catch { /* best effort */ }
4563
+ // The turn completed and the result file is already written, so this
4564
+ // is still a success — but the child did NOT exit on its own; we just
4565
+ // force-reaped it. Report that honestly instead of a fabricated clean
4566
+ // exit (code 0 / signal null): killTree sends SIGKILL, so surface the
4567
+ // real signal (or the child's actual exit if it slipped in) plus a
4568
+ // `forcedReap` flag so audits can spot agents that consistently hang
4569
+ // on shutdown rather than seeing a misleading exitCode: 0.
4570
+ finish({
4571
+ ok: true,
4572
+ exitCode: childClosed ? childClosed.code : null,
4573
+ signal: childClosed ? childClosed.signal : 'SIGKILL',
4574
+ forcedReap: childClosed === null,
4575
+ stdout: humanStdout(),
4576
+ stderr: joinCapped(stderrChunks),
4577
+ truncated: humanTruncated,
4578
+ stderrTruncated,
4579
+ });
4580
+ }, acpPostTurnGraceMs());
4581
+ if (typeof settleTimer.unref === 'function') settleTimer.unref();
4582
+ }
4583
+ })().catch((err) => {
4584
+ finish({ ok: false, exitCode: null, stdout: humanStdout(), stderr: joinCapped(stderrChunks), error: `acp: ${err?.message || err}`, truncated: humanTruncated, stderrTruncated });
4585
+ });
4586
+ });
4587
+ }
4588
+
3962
4589
  function buildAgentPayload(profile, job, envelope) {
3963
4590
  const variables = job.variables && typeof job.variables === 'object' ? job.variables : {};
3964
4591
  return {
@@ -4039,11 +4666,8 @@ function startLockExtender(job, windowMs, intervalMs, tag, logger) {
4039
4666
  */
4040
4667
  function runAgentJob(profile, job, opts = {}) {
4041
4668
  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;
4042
- // #110: `protocol`/`permission` are accepted here so the seam exists for the
4043
- // downstream ACP executor. This task does NOT dispatch on them — the pipe/PTY
4044
- // paths below are unchanged, so `protocol === 'pipe'` behavior is identical.
4045
- // TODO(#110): the acp dispatch branch (spawnCaptureAcp) lands in a later task.
4046
- void protocol; void permission;
4669
+ // #110: `protocol`/`permission` drive the ACP executor branch below. The
4670
+ // pipe/PTY paths are unchanged, so `protocol === 'pipe'` behaviour is identical.
4047
4671
  const payload = JSON.stringify(buildAgentPayload(profile, job, envelope));
4048
4672
  const agentEnv = baseAgentEnv(profile, job);
4049
4673
  // The harness command line: the profile command plus its structured switches
@@ -4062,6 +4686,18 @@ function runAgentJob(profile, job, opts = {}) {
4062
4686
  const relayTap = relaySession
4063
4687
  ? {
4064
4688
  onData: (buf) => relaySession.relay(buf),
4689
+ // #110 step 2: typed transcript publish seam. The ACP producer maps each
4690
+ // session/update to an `nwfTranscriptEvent` envelope and publishes it
4691
+ // here; we JSON-encode it (newline-delimited) onto the SAME relay lane —
4692
+ // the raw relay TRANSPORT (ring/QoS/offsets/jobKey routing) is unchanged,
4693
+ // still `relaySession.relay(text)`. Consumers (cockpit derive+render)
4694
+ // parse the envelope; unmapped updates fall back to the `onData` text path
4695
+ // so nothing is dropped (no regression vs the minimal-mode floor).
4696
+ // Best-effort: a bad envelope (circular refs / BigInt making
4697
+ // JSON.stringify throw) must never crash the worker, so swallow here.
4698
+ relayEnvelope: (env) => {
4699
+ try { relaySession.relay(`${JSON.stringify(env)}\n`); } catch { /* relay best-effort */ }
4700
+ },
4065
4701
  attachSteer: (write) => relaySession.attachSteer(write),
4066
4702
  }
4067
4703
  : null;
@@ -4078,6 +4714,35 @@ function runAgentJob(profile, job, opts = {}) {
4078
4714
  const resultEnv = resultFile ? { [AGENT_RESULT_FILE_ENV]: resultFile } : {};
4079
4715
  const harnessEnv = { ...process.env, ...staticEnv, ...secretEnv, ...agentEnv, ...extraEnv, ...resultEnv };
4080
4716
 
4717
+ // A role opted into ACP (`protocol: acp`) drives its harness over the Agent
4718
+ // Client Protocol (JSON-RPC 2.0 over stdio) instead of the stdin/scrape pipe
4719
+ // or a PTY. Checked BEFORE the PTY branch: ACP owns the process when selected
4720
+ // and needs no node-pty at all (steer/cancel ride JSON-RPC, not terminal
4721
+ // writes). The ACP switch is appended to the assembled command line only when
4722
+ // it isn't already present. `permission` selects the request_permission
4723
+ // policy (yolo enforced; escalate/filter warned interim, pending #559).
4724
+ if (protocol === 'acp') {
4725
+ return spawnCaptureAcp({
4726
+ // Route the assembled line through the platform shell (cmd.exe on
4727
+ // Windows, /bin/sh elsewhere) exactly like the pipe path, rather than
4728
+ // hard-coding `sh -c` which does not exist on Windows hosts. The
4729
+ // Windows `--arg` restriction is already enforced by the guard above.
4730
+ command: ensureAcpFlag(commandLine),
4731
+ shell: true,
4732
+ cwd,
4733
+ env: harnessEnv,
4734
+ stdinData: payload,
4735
+ timeoutMs,
4736
+ idleTimeoutMs,
4737
+ relayTap,
4738
+ stream,
4739
+ streamPrefix,
4740
+ onStreamOut,
4741
+ onStreamErr,
4742
+ permission,
4743
+ });
4744
+ }
4745
+
4081
4746
  // A role opted into a full PTY (`terminal: pty`) runs the harness on a real
4082
4747
  // terminal when one can be allocated — so its live output streams as a true
4083
4748
  // terminal and cockpit steer-in reaches it. Falls back to a pipe (still
@@ -4123,6 +4788,10 @@ function runAgentJob(profile, job, opts = {}) {
4123
4788
 
4124
4789
  const engine = sandbox;
4125
4790
  const containerName = `nano-${runId}`;
4791
+ // #110: ACP-in-container is deferred for this slice — a container sandbox runs
4792
+ // the harness over the pipe path below regardless of `protocol`, so container
4793
+ // pipe mode is never regressed. Host ACP (above) is the minimal-mode surface.
4794
+ void protocol;
4126
4795
  // Container: bind-mount the result file's directory read-write at a fixed
4127
4796
  // in-container path and point AGENT_RESULT_FILE at the mounted file, so the
4128
4797
  // agent writes it inside the sandbox and the harness reads it back on the host.
@@ -4197,6 +4866,10 @@ function buildResultEnvelope(result, { sandbox, image, git, result: agentResult,
4197
4866
  signal: result.signal ?? null,
4198
4867
  error: result.error ?? null,
4199
4868
  };
4869
+ // Audit: a turn that completed but whose child had to be force-reaped on
4870
+ // shutdown (didn't exit on its own within the post-turn grace) — surfaced so
4871
+ // consistently-hanging agents are visible rather than hidden behind a success.
4872
+ if (result.forcedReap) env.forcedReap = true;
4200
4873
  // Audit (issue #63): record which linked-resource key supplied the base prompt.
4201
4874
  // The engine only keeps `latest` per resourceId (no pinning), so recording the
4202
4875
  // resolved key is the only reproducibility handle for which prompt version ran.
@@ -9076,6 +9749,8 @@ export {
9076
9749
  containerEngineAvailable,
9077
9750
  runAgentJob,
9078
9751
  spawnCapturePty,
9752
+ spawnCaptureAcp,
9753
+ ensureAcpFlag,
9079
9754
  startLockExtender,
9080
9755
  provisionRepo,
9081
9756
  finalizeGit,
@@ -9194,7 +9869,7 @@ export const metadata = {
9194
9869
  { command: 'c8ctl nano hire --list', description: 'List hired agent profiles' },
9195
9870
  { 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' },
9196
9871
  { 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)' },
9197
- { command: 'c8ctl nano hire --name coder --rank senior --command copilot --protocol acp --permission yolo', description: 'Accept/persist ACP (JSON-RPC/stdio) for this role RESERVED, not yet active in this build (acp is inert; the harness still runs on the transport selected by --terminal, pipe or pty); escalate/filter permission modes are likewise reserved/not yet active' },
9872
+ { 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)' },
9198
9873
  { 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' },
9199
9874
  { command: 'c8ctl nano work reviewer', description: 'Spawn Nano job workers for the "reviewer" profile and poll for work' },
9200
9875
  { 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)' },
@@ -9257,7 +9932,7 @@ export const commands = {
9257
9932
  sandbox: { type: 'string', description: 'hire/work: execution sandbox none|docker|podman (default none). Containers isolate each job.' },
9258
9933
  image: { type: 'string', description: 'hire/work: container image the agent runs in (required for --sandbox docker|podman)' },
9259
9934
  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.' },
9260
- protocol: { type: 'string', description: 'hire: harness protocol pipe|acp (default pipe). acp is RESERVED accepted and persisted for forward-compatibility but not yet implemented in this build (inert: the harness still runs on the transport selected by --terminal, pipe or pty; the ACP JSON-RPC-over-stdio executor lands downstream). NANO_AGENTIC_PROTOCOL overrides at work time.' },
9935
+ 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.' },
9261
9936
  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.' },
9262
9937
  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.' },
9263
9938
  'secret-resolver': { type: 'string', description: 'work: secret resolver for task secretRefs (host = process env; default host)' },
@@ -9476,7 +10151,7 @@ function printUsage() {
9476
10151
  console.log(' --sandbox <s> hire/work: execution sandbox none|docker|podman (default none)');
9477
10152
  console.log(' --image <ref> hire/work: container image the agent runs in (required for docker|podman)');
9478
10153
  console.log(' --terminal <m> hire: live-terminal mode pty|pipe (default pipe); pty streams a steerable terminal on the relay lane');
9479
- console.log(' --protocol <p> hire: harness protocol pipe|acp (default pipe); acp is RESERVED accepted/persisted but not yet implemented in this build (inert: the harness still runs on the transport selected by --terminal, pipe or pty). NANO_AGENTIC_PROTOCOL overrides at work time');
10154
+ 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');
9480
10155
  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');
9481
10156
  console.log(' --env NAME=VALUE hire/work: static env var for the harness (repeatable); persisted on hire, work extends/overrides');
9482
10157
  console.log(' --list hire: list existing agent profiles instead of creating one');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "c8ctl-plugin-nano",
3
- "version": "1.40.0",
3
+ "version": "1.42.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.40.0",
61
- "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.40.0",
62
- "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.40.0",
63
- "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.40.0",
64
- "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.40.0",
65
- "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.40.0",
66
- "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.40.0"
60
+ "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.42.0",
61
+ "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.42.0",
62
+ "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.42.0",
63
+ "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.42.0",
64
+ "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.42.0",
65
+ "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.42.0",
66
+ "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.42.0"
67
67
  }
68
68
  }