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