c8ctl-plugin-nano 1.40.0 → 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.
- package/c8ctl-plugin.js +577 -10
- 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';
|
|
@@ -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;
|
|
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,536 @@ 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 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
|
+
|
|
3962
4493
|
function buildAgentPayload(profile, job, envelope) {
|
|
3963
4494
|
const variables = job.variables && typeof job.variables === 'object' ? job.variables : {};
|
|
3964
4495
|
return {
|
|
@@ -4039,11 +4570,8 @@ function startLockExtender(job, windowMs, intervalMs, tag, logger) {
|
|
|
4039
4570
|
*/
|
|
4040
4571
|
function runAgentJob(profile, job, opts = {}) {
|
|
4041
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;
|
|
4042
|
-
// #110: `protocol`/`permission`
|
|
4043
|
-
//
|
|
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;
|
|
4573
|
+
// #110: `protocol`/`permission` drive the ACP executor branch below. The
|
|
4574
|
+
// pipe/PTY paths are unchanged, so `protocol === 'pipe'` behaviour is identical.
|
|
4047
4575
|
const payload = JSON.stringify(buildAgentPayload(profile, job, envelope));
|
|
4048
4576
|
const agentEnv = baseAgentEnv(profile, job);
|
|
4049
4577
|
// The harness command line: the profile command plus its structured switches
|
|
@@ -4078,6 +4606,35 @@ function runAgentJob(profile, job, opts = {}) {
|
|
|
4078
4606
|
const resultEnv = resultFile ? { [AGENT_RESULT_FILE_ENV]: resultFile } : {};
|
|
4079
4607
|
const harnessEnv = { ...process.env, ...staticEnv, ...secretEnv, ...agentEnv, ...extraEnv, ...resultEnv };
|
|
4080
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
|
+
|
|
4081
4638
|
// A role opted into a full PTY (`terminal: pty`) runs the harness on a real
|
|
4082
4639
|
// terminal when one can be allocated — so its live output streams as a true
|
|
4083
4640
|
// terminal and cockpit steer-in reaches it. Falls back to a pipe (still
|
|
@@ -4123,6 +4680,10 @@ function runAgentJob(profile, job, opts = {}) {
|
|
|
4123
4680
|
|
|
4124
4681
|
const engine = sandbox;
|
|
4125
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;
|
|
4126
4687
|
// Container: bind-mount the result file's directory read-write at a fixed
|
|
4127
4688
|
// in-container path and point AGENT_RESULT_FILE at the mounted file, so the
|
|
4128
4689
|
// agent writes it inside the sandbox and the harness reads it back on the host.
|
|
@@ -4197,6 +4758,10 @@ function buildResultEnvelope(result, { sandbox, image, git, result: agentResult,
|
|
|
4197
4758
|
signal: result.signal ?? null,
|
|
4198
4759
|
error: result.error ?? null,
|
|
4199
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;
|
|
4200
4765
|
// Audit (issue #63): record which linked-resource key supplied the base prompt.
|
|
4201
4766
|
// The engine only keeps `latest` per resourceId (no pinning), so recording the
|
|
4202
4767
|
// resolved key is the only reproducibility handle for which prompt version ran.
|
|
@@ -9076,6 +9641,8 @@ export {
|
|
|
9076
9641
|
containerEngineAvailable,
|
|
9077
9642
|
runAgentJob,
|
|
9078
9643
|
spawnCapturePty,
|
|
9644
|
+
spawnCaptureAcp,
|
|
9645
|
+
ensureAcpFlag,
|
|
9079
9646
|
startLockExtender,
|
|
9080
9647
|
provisionRepo,
|
|
9081
9648
|
finalizeGit,
|
|
@@ -9194,7 +9761,7 @@ export const metadata = {
|
|
|
9194
9761
|
{ command: 'c8ctl nano hire --list', description: 'List hired agent profiles' },
|
|
9195
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' },
|
|
9196
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)' },
|
|
9197
|
-
{ command: 'c8ctl nano hire --name coder --rank senior --command copilot --protocol acp --permission yolo', description: '
|
|
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)' },
|
|
9198
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' },
|
|
9199
9766
|
{ command: 'c8ctl nano work reviewer', description: 'Spawn Nano job workers for the "reviewer" profile and poll for work' },
|
|
9200
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)' },
|
|
@@ -9257,7 +9824,7 @@ export const commands = {
|
|
|
9257
9824
|
sandbox: { type: 'string', description: 'hire/work: execution sandbox none|docker|podman (default none). Containers isolate each job.' },
|
|
9258
9825
|
image: { type: 'string', description: 'hire/work: container image the agent runs in (required for --sandbox docker|podman)' },
|
|
9259
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.' },
|
|
9260
|
-
protocol: { type: 'string', description: 'hire: harness protocol pipe|acp (default pipe). acp
|
|
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.' },
|
|
9261
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.' },
|
|
9262
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.' },
|
|
9263
9830
|
'secret-resolver': { type: 'string', description: 'work: secret resolver for task secretRefs (host = process env; default host)' },
|
|
@@ -9476,7 +10043,7 @@ function printUsage() {
|
|
|
9476
10043
|
console.log(' --sandbox <s> hire/work: execution sandbox none|docker|podman (default none)');
|
|
9477
10044
|
console.log(' --image <ref> hire/work: container image the agent runs in (required for docker|podman)');
|
|
9478
10045
|
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
|
|
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');
|
|
9480
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');
|
|
9481
10048
|
console.log(' --env NAME=VALUE hire/work: static env var for the harness (repeatable); persisted on hire, work extends/overrides');
|
|
9482
10049
|
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.
|
|
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.
|
|
61
|
-
"@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.
|
|
62
|
-
"@nanobpm/c8ctl-plugin-nano-linux-x64": "1.
|
|
63
|
-
"@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.
|
|
64
|
-
"@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.
|
|
65
|
-
"@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.
|
|
66
|
-
"@nanobpm/c8ctl-plugin-nano-win32-x64": "1.
|
|
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
|
}
|