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