c8ctl-plugin-nano 1.28.0 → 1.30.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 +287 -7
- package/package.json +11 -8
- package/work-buffer.mjs +331 -0
- package/work-channel.mjs +1 -0
- package/work-relay.mjs +193 -0
package/c8ctl-plugin.js
CHANGED
|
@@ -58,6 +58,8 @@ import { createInterface } from 'node:readline/promises';
|
|
|
58
58
|
import { createInterface as createReadline } from 'node:readline';
|
|
59
59
|
import { platformForHost } from './platforms.mjs';
|
|
60
60
|
import { createWorkChannel, redactAgenticUrl, buildAgenticUrl } from './work-channel.mjs';
|
|
61
|
+
import { createRelaySession, roleTerminalMode } from './work-relay.mjs';
|
|
62
|
+
import { createBufferMonitor, resolveBufferCapacity } from './work-buffer.mjs';
|
|
61
63
|
|
|
62
64
|
const requireFromHere = createRequire(import.meta.url);
|
|
63
65
|
const pluginDir = dirname(fileURLToPath(import.meta.url));
|
|
@@ -1514,6 +1516,11 @@ function showConfig() {
|
|
|
1514
1516
|
|
|
1515
1517
|
const RANKS = ['principal', 'senior', 'junior', 'decider'];
|
|
1516
1518
|
|
|
1519
|
+
// C3 (#42): a role's live-terminal mode — a full PTY (streamed + steerable) or a
|
|
1520
|
+
// plain pipe. Default is `pipe` (the safe non-interactive default); `pty` is
|
|
1521
|
+
// opt-in per role because a TTY changes the harness's I/O semantics.
|
|
1522
|
+
const TERMINAL_MODES = ['pipe', 'pty'];
|
|
1523
|
+
|
|
1517
1524
|
/** Normalize a capability list: trim, drop empties, de-dupe, sort (canonical). */
|
|
1518
1525
|
function normalizeCapabilities(input) {
|
|
1519
1526
|
const raw = Array.isArray(input)
|
|
@@ -1732,6 +1739,10 @@ function normalizeStoredProfile(name, profile) {
|
|
|
1732
1739
|
if (CONTAINER_SANDBOXES.has(sandbox) && !image) {
|
|
1733
1740
|
return { error: `profile "${name}" uses sandbox "${sandbox}" but has no image` };
|
|
1734
1741
|
}
|
|
1742
|
+
// C3 (#42): live-terminal mode. Tolerant — an unknown/legacy value falls back
|
|
1743
|
+
// to the safe `pipe` default rather than failing the whole profile.
|
|
1744
|
+
const terminalRaw = typeof profile.terminal === 'string' ? profile.terminal.trim().toLowerCase() : '';
|
|
1745
|
+
const terminal = TERMINAL_MODES.includes(terminalRaw) ? terminalRaw : 'pipe';
|
|
1735
1746
|
return {
|
|
1736
1747
|
profile: {
|
|
1737
1748
|
name,
|
|
@@ -1742,6 +1753,7 @@ function normalizeStoredProfile(name, profile) {
|
|
|
1742
1753
|
capabilities: normalizeCapabilities(profile.capabilities),
|
|
1743
1754
|
sandbox,
|
|
1744
1755
|
image,
|
|
1756
|
+
terminal,
|
|
1745
1757
|
env: normalizeEnvMap(profile.env),
|
|
1746
1758
|
},
|
|
1747
1759
|
};
|
|
@@ -1859,7 +1871,8 @@ async function hireWorker(req, flags) {
|
|
|
1859
1871
|
logger.info('Hired agent profiles:');
|
|
1860
1872
|
for (const name of names.sort()) {
|
|
1861
1873
|
const p = hires[name];
|
|
1862
|
-
|
|
1874
|
+
const term = String(p.terminal || '').trim().toLowerCase() === 'pty' ? '; terminal: pty' : '';
|
|
1875
|
+
logger.info(` ${name} [${p.rank}] ${buildAgentCommandLine(p.command, p.args)} (model: ${p.model || '-'}; caps: ${normalizeCapabilities(p.capabilities).join(', ') || '-'}${term})`);
|
|
1863
1876
|
}
|
|
1864
1877
|
logger.info('');
|
|
1865
1878
|
logger.info('Put one to work with: c8ctl nano work <name>');
|
|
@@ -1875,6 +1888,7 @@ async function hireWorker(req, flags) {
|
|
|
1875
1888
|
let capabilities = flags?.capabilities !== undefined ? flags.capabilities : undefined;
|
|
1876
1889
|
let sandbox = flags?.sandbox !== undefined ? String(flags.sandbox).trim().toLowerCase() : undefined;
|
|
1877
1890
|
let image = flags?.image !== undefined ? String(flags.image).trim() : undefined;
|
|
1891
|
+
let terminal = flags?.terminal !== undefined ? String(flags.terminal).trim().toLowerCase() : undefined;
|
|
1878
1892
|
// Structured command-line switches appended to the command when spawned, e.g.
|
|
1879
1893
|
// `--arg --allow-all` for `copilot`. Repeatable; each --arg is one argv token.
|
|
1880
1894
|
const commandArgs = normalizeArgList(flags?.arg);
|
|
@@ -1952,11 +1966,17 @@ async function hireWorker(req, flags) {
|
|
|
1952
1966
|
if (capabilities === undefined) capabilities = '';
|
|
1953
1967
|
if (sandbox === undefined || sandbox === '') sandbox = 'none';
|
|
1954
1968
|
if (image === undefined) image = '';
|
|
1969
|
+
if (terminal === undefined || terminal === '') terminal = 'pipe';
|
|
1955
1970
|
|
|
1956
1971
|
if (!SANDBOXES.includes(sandbox)) {
|
|
1957
1972
|
logger.error(`Invalid --sandbox "${sandbox}". Use one of: ${SANDBOXES.join(', ')}`);
|
|
1958
1973
|
process.exit(1);
|
|
1959
1974
|
}
|
|
1975
|
+
if (!TERMINAL_MODES.includes(terminal)) {
|
|
1976
|
+
logger.error(`Invalid --terminal "${terminal}". Use one of: ${TERMINAL_MODES.join(', ')}`);
|
|
1977
|
+
process.exit(1);
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1960
1980
|
if (CONTAINER_SANDBOXES.has(sandbox) && !image) {
|
|
1961
1981
|
logger.error(`--sandbox ${sandbox} requires --image <ref> (the container image the agent runs in).`);
|
|
1962
1982
|
process.exit(1);
|
|
@@ -1985,6 +2005,7 @@ async function hireWorker(req, flags) {
|
|
|
1985
2005
|
capabilities: normalizeCapabilities(capabilities),
|
|
1986
2006
|
sandbox,
|
|
1987
2007
|
image: image || '',
|
|
2008
|
+
terminal,
|
|
1988
2009
|
env: profileEnv,
|
|
1989
2010
|
createdAt: new Date().toISOString(),
|
|
1990
2011
|
};
|
|
@@ -1996,6 +2017,7 @@ async function hireWorker(req, flags) {
|
|
|
1996
2017
|
logger.info(` capabilities: ${profile.capabilities.join(', ') || '(none)'}`);
|
|
1997
2018
|
if (profile.args.length > 0) logger.info(` args: ${profile.args.map(shQuote).join(' ')}`);
|
|
1998
2019
|
logger.info(` sandbox: ${profile.sandbox}${CONTAINER_SANDBOXES.has(profile.sandbox) ? ` (image ${profile.image})` : ''}`);
|
|
2020
|
+
logger.info(` live terminal: ${profile.terminal}${profile.terminal === 'pty' ? ' (streamed + steerable on the relay lane)' : ''}`);
|
|
1999
2021
|
const envKeys = Object.keys(profile.env);
|
|
2000
2022
|
if (envKeys.length > 0) logger.info(` env: ${envKeys.join(', ')}`);
|
|
2001
2023
|
logger.info(` job types (${matrix.length}): ${matrix.join(' ')}`);
|
|
@@ -2903,7 +2925,7 @@ const MAX_CAPTURE_BYTES = 1_048_576; // 1 MiB per stream
|
|
|
2903
2925
|
// Spawn a child, pipe `stdinData`, capture byte-capped stdout/stderr, enforce a
|
|
2904
2926
|
// timeout (invoking `onTimeout(child)` to tear the child down), and resolve to a
|
|
2905
2927
|
// uniform result. Used by both the host and container executors.
|
|
2906
|
-
function spawnCaptureOneShot({ command, args = [], shell = false, detached = false, cwd, env, stdinData, timeoutMs, idleTimeoutMs, onTimeout, stream = false, streamPrefix = '', onStreamOut, onStreamErr }) {
|
|
2928
|
+
function spawnCaptureOneShot({ command, args = [], shell = false, detached = false, cwd, env, stdinData, timeoutMs, idleTimeoutMs, onTimeout, stream = false, streamPrefix = '', onStreamOut, onStreamErr, relayTap = null }) {
|
|
2907
2929
|
return new Promise((resolve) => {
|
|
2908
2930
|
let child;
|
|
2909
2931
|
const stdoutChunks = [];
|
|
@@ -2992,6 +3014,7 @@ function spawnCaptureOneShot({ command, args = [], shell = false, detached = fal
|
|
|
2992
3014
|
armIdle();
|
|
2993
3015
|
const buf = Buffer.isBuffer(d) ? d : Buffer.from(d);
|
|
2994
3016
|
if (teeOut) teeOut(buf.toString('utf8'), false);
|
|
3017
|
+
if (relayTap && typeof relayTap.onData === 'function') relayTap.onData(buf);
|
|
2995
3018
|
const remaining = MAX_CAPTURE_BYTES - stdoutBytes;
|
|
2996
3019
|
if (remaining <= 0) { stdoutTruncated = true; return; }
|
|
2997
3020
|
if (buf.length > remaining) { stdoutChunks.push(buf.subarray(0, remaining)); stdoutBytes = MAX_CAPTURE_BYTES; stdoutTruncated = true; }
|
|
@@ -3001,6 +3024,7 @@ function spawnCaptureOneShot({ command, args = [], shell = false, detached = fal
|
|
|
3001
3024
|
armIdle();
|
|
3002
3025
|
const buf = Buffer.isBuffer(d) ? d : Buffer.from(d);
|
|
3003
3026
|
if (teeErr) teeErr(buf.toString('utf8'), false);
|
|
3027
|
+
if (relayTap && typeof relayTap.onData === 'function') relayTap.onData(buf);
|
|
3004
3028
|
const remaining = MAX_CAPTURE_BYTES - stderrBytes;
|
|
3005
3029
|
if (remaining <= 0) { stderrTruncated = true; return; }
|
|
3006
3030
|
if (buf.length > remaining) { stderrChunks.push(buf.subarray(0, remaining)); stderrBytes = MAX_CAPTURE_BYTES; stderrTruncated = true; }
|
|
@@ -3015,6 +3039,11 @@ function spawnCaptureOneShot({ command, args = [], shell = false, detached = fal
|
|
|
3015
3039
|
});
|
|
3016
3040
|
|
|
3017
3041
|
child.stdin.on('error', () => {});
|
|
3042
|
+
// C3 (#42): pipe mode is one-shot — the job is written to stdin which is then
|
|
3043
|
+
// closed (below), so there is no open channel to feed later steer-in frames
|
|
3044
|
+
// into. We therefore do NOT attach steer-in here: steer-in requires a PTY
|
|
3045
|
+
// (see spawnCapturePty), where stdin stays open for the life of the child.
|
|
3046
|
+
// Pipe-mode roles still stream their output on the relay lane via the tee.
|
|
3018
3047
|
try {
|
|
3019
3048
|
if (stdinData != null) child.stdin.write(stdinData);
|
|
3020
3049
|
child.stdin.end();
|
|
@@ -3022,6 +3051,152 @@ function spawnCaptureOneShot({ command, args = [], shell = false, detached = fal
|
|
|
3022
3051
|
});
|
|
3023
3052
|
}
|
|
3024
3053
|
|
|
3054
|
+
// ---- PTY capture (C3 #42 — full terminal for roles opted into `terminal: pty`)
|
|
3055
|
+
// node-pty is a NATIVE, OPTIONAL dependency: a role that runs its harness on a
|
|
3056
|
+
// real PTY needs it, but the vast majority of workers run on plain pipes, and we
|
|
3057
|
+
// must never let a missing/failed native build break `npm install` or the test
|
|
3058
|
+
// suite on stock Node. It is therefore an optionalDependency, lazily required
|
|
3059
|
+
// only when a PTY role actually runs, and memoized. Returns null when it is not
|
|
3060
|
+
// installed so the caller can fall back to a pipe.
|
|
3061
|
+
let ptyModuleCache; // undefined = not tried; null = unavailable; object = loaded
|
|
3062
|
+
function loadPtyModule() {
|
|
3063
|
+
if (ptyModuleCache !== undefined) return ptyModuleCache;
|
|
3064
|
+
try {
|
|
3065
|
+
ptyModuleCache = requireFromHere('node-pty');
|
|
3066
|
+
} catch {
|
|
3067
|
+
ptyModuleCache = null;
|
|
3068
|
+
}
|
|
3069
|
+
return ptyModuleCache;
|
|
3070
|
+
}
|
|
3071
|
+
|
|
3072
|
+
/**
|
|
3073
|
+
* Whether a real PTY can be allocated on this host: node-pty is installed AND we
|
|
3074
|
+
* are on a POSIX platform (the PTY path spawns `sh -c <commandLine>`, mirroring
|
|
3075
|
+
* the container executor; Windows conpty is out of scope for this slice).
|
|
3076
|
+
*/
|
|
3077
|
+
function ptyAvailable(ptyFactory) {
|
|
3078
|
+
if (process.platform === 'win32') return false;
|
|
3079
|
+
// An injected factory only counts if it actually looks like a node-pty
|
|
3080
|
+
// factory (has a spawn()); a bad injection degrades to the pipe fallback
|
|
3081
|
+
// rather than routing to the PTY path and failing the job.
|
|
3082
|
+
if (ptyFactory) return typeof ptyFactory.spawn === 'function';
|
|
3083
|
+
return loadPtyModule() != null;
|
|
3084
|
+
}
|
|
3085
|
+
|
|
3086
|
+
// Spawn the harness on a PTY, capture byte-capped output for the job result,
|
|
3087
|
+
// tee every chunk to the relay tap (framed + jobKey-tagged by the caller), and
|
|
3088
|
+
// feed steer-in bytes back into the PTY. Same result contract as
|
|
3089
|
+
// spawnCaptureOneShot. A PTY merges stdout+stderr into one stream, so stderr is
|
|
3090
|
+
// always '' here; that is expected for a live terminal. `ptyFactory` is
|
|
3091
|
+
// injectable for tests (defaults to node-pty).
|
|
3092
|
+
function spawnCapturePty({ command, args = [], cwd, env, stdinData, timeoutMs, idleTimeoutMs, cols = 120, rows = 30, ptyFactory, relayTap = null, stream = false, streamPrefix = '', onStreamOut }) {
|
|
3093
|
+
return new Promise((resolve) => {
|
|
3094
|
+
const factory = ptyFactory || loadPtyModule();
|
|
3095
|
+
if (!factory || typeof factory.spawn !== 'function') {
|
|
3096
|
+
resolve({ ok: false, exitCode: null, stdout: '', stderr: '', error: 'node-pty is not available; cannot allocate a PTY (install node-pty or use terminal: pipe)', truncated: false, stderrTruncated: false });
|
|
3097
|
+
return;
|
|
3098
|
+
}
|
|
3099
|
+
|
|
3100
|
+
const chunks = [];
|
|
3101
|
+
let bytes = 0;
|
|
3102
|
+
let truncated = false;
|
|
3103
|
+
let settled = false;
|
|
3104
|
+
let timer = null;
|
|
3105
|
+
let idleTimer = null;
|
|
3106
|
+
let detachSteer = null;
|
|
3107
|
+
let term;
|
|
3108
|
+
|
|
3109
|
+
// Live "spy" tee (--stream), line-buffered, mirroring spawnCaptureOneShot.
|
|
3110
|
+
const STREAM_TEE_LINE_CAP = 64 * 1024;
|
|
3111
|
+
let teePartial = '';
|
|
3112
|
+
const teeSink = stream ? (onStreamOut || ((line) => process.stdout.write(`${line}\n`))) : null;
|
|
3113
|
+
const tee = (text, final) => {
|
|
3114
|
+
if (!teeSink) return;
|
|
3115
|
+
teePartial += text;
|
|
3116
|
+
let nl;
|
|
3117
|
+
while ((nl = teePartial.indexOf('\n')) !== -1) {
|
|
3118
|
+
teeSink(`${streamPrefix}${teePartial.slice(0, nl)}`);
|
|
3119
|
+
teePartial = teePartial.slice(nl + 1);
|
|
3120
|
+
}
|
|
3121
|
+
while (teePartial.length >= STREAM_TEE_LINE_CAP) {
|
|
3122
|
+
teeSink(`${streamPrefix}${teePartial.slice(0, STREAM_TEE_LINE_CAP)}`);
|
|
3123
|
+
teePartial = teePartial.slice(STREAM_TEE_LINE_CAP);
|
|
3124
|
+
}
|
|
3125
|
+
if (final && teePartial) { teeSink(`${streamPrefix}${teePartial}`); teePartial = ''; }
|
|
3126
|
+
};
|
|
3127
|
+
|
|
3128
|
+
const killTerm = () => {
|
|
3129
|
+
try { term?.kill(); } catch { /* already gone */ }
|
|
3130
|
+
};
|
|
3131
|
+
|
|
3132
|
+
const finish = (result) => {
|
|
3133
|
+
if (settled) return;
|
|
3134
|
+
settled = true;
|
|
3135
|
+
if (timer) clearTimeout(timer);
|
|
3136
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
3137
|
+
if (detachSteer) { try { detachSteer(); } catch { /* best effort */ } detachSteer = null; }
|
|
3138
|
+
if (teeSink) tee('', true);
|
|
3139
|
+
resolve(result);
|
|
3140
|
+
};
|
|
3141
|
+
|
|
3142
|
+
try {
|
|
3143
|
+
term = factory.spawn(command, args, { name: 'xterm-256color', cols, rows, cwd, env });
|
|
3144
|
+
} catch (err) {
|
|
3145
|
+
finish({ ok: false, exitCode: null, stdout: '', stderr: '', error: `pty spawn failed: ${err?.message || err}`, truncated: false, stderrTruncated: false });
|
|
3146
|
+
return;
|
|
3147
|
+
}
|
|
3148
|
+
|
|
3149
|
+
timer = timeoutMs && timeoutMs > 0
|
|
3150
|
+
? setTimeout(() => {
|
|
3151
|
+
killTerm();
|
|
3152
|
+
finish({ ok: false, exitCode: null, stdout: joinCapped(chunks), stderr: '', error: `timed out after ${timeoutMs}ms`, timedOut: true, truncated, stderrTruncated: false });
|
|
3153
|
+
}, timeoutMs)
|
|
3154
|
+
: null;
|
|
3155
|
+
|
|
3156
|
+
const armIdle = () => {
|
|
3157
|
+
if (settled) return;
|
|
3158
|
+
if (!(idleTimeoutMs && idleTimeoutMs > 0)) return;
|
|
3159
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
3160
|
+
idleTimer = setTimeout(() => {
|
|
3161
|
+
killTerm();
|
|
3162
|
+
finish({ ok: false, exitCode: null, stdout: joinCapped(chunks), stderr: '', error: `no output for ${idleTimeoutMs}ms (idle)`, timedOut: true, idle: true, truncated, stderrTruncated: false });
|
|
3163
|
+
}, idleTimeoutMs);
|
|
3164
|
+
};
|
|
3165
|
+
armIdle();
|
|
3166
|
+
|
|
3167
|
+
term.onData((d) => {
|
|
3168
|
+
armIdle();
|
|
3169
|
+
const buf = Buffer.isBuffer(d) ? d : Buffer.from(String(d), 'utf8');
|
|
3170
|
+
if (teeSink) tee(buf.toString('utf8'), false);
|
|
3171
|
+
if (relayTap && typeof relayTap.onData === 'function') relayTap.onData(buf);
|
|
3172
|
+
const remaining = MAX_CAPTURE_BYTES - bytes;
|
|
3173
|
+
if (remaining <= 0) { truncated = true; return; }
|
|
3174
|
+
if (buf.length > remaining) { chunks.push(buf.subarray(0, remaining)); bytes = MAX_CAPTURE_BYTES; truncated = true; }
|
|
3175
|
+
else { chunks.push(buf); bytes += buf.length; }
|
|
3176
|
+
});
|
|
3177
|
+
|
|
3178
|
+
term.onExit(({ exitCode, signal }) => {
|
|
3179
|
+
finish({ ok: exitCode === 0, exitCode: typeof exitCode === 'number' ? exitCode : null, signal: signal || null, stdout: joinCapped(chunks), stderr: '', truncated, stderrTruncated: false });
|
|
3180
|
+
});
|
|
3181
|
+
|
|
3182
|
+
// Steer-in: write cockpit bytes straight into the PTY so an operator can
|
|
3183
|
+
// drive the running agent.
|
|
3184
|
+
if (relayTap && typeof relayTap.attachSteer === 'function') {
|
|
3185
|
+
detachSteer = relayTap.attachSteer((data) => {
|
|
3186
|
+
try { term.write(typeof data === 'string' ? data : Buffer.from(data).toString('utf8')); } catch { /* term gone */ }
|
|
3187
|
+
});
|
|
3188
|
+
}
|
|
3189
|
+
|
|
3190
|
+
// Deliver the task envelope on the PTY, then an EOT (Ctrl-D) so a harness
|
|
3191
|
+
// that reads its payload from stdin sees an end-of-input, while the PTY
|
|
3192
|
+
// itself stays open for interactive steer-in.
|
|
3193
|
+
try {
|
|
3194
|
+
if (stdinData != null) term.write(String(stdinData));
|
|
3195
|
+
term.write('\x04');
|
|
3196
|
+
} catch { /* onExit resolves on failure */ }
|
|
3197
|
+
});
|
|
3198
|
+
}
|
|
3199
|
+
|
|
3025
3200
|
function buildAgentPayload(profile, job, envelope) {
|
|
3026
3201
|
const variables = job.variables && typeof job.variables === 'object' ? job.variables : {};
|
|
3027
3202
|
return {
|
|
@@ -3101,7 +3276,7 @@ function startLockExtender(job, windowMs, intervalMs, tag, logger) {
|
|
|
3101
3276
|
* Both paths resolve to the same result contract.
|
|
3102
3277
|
*/
|
|
3103
3278
|
function runAgentJob(profile, job, opts = {}) {
|
|
3104
|
-
const { timeoutMs, idleTimeoutMs, envelope, sandbox = 'none', image, runId, secretEnv = {}, passThroughSecretNames = [], cwd, extraEnv = {}, profileEnv = {}, resultFile, stream = false, streamPrefix = '', onStreamOut, onStreamErr, args: commandArgs } = opts;
|
|
3279
|
+
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;
|
|
3105
3280
|
const payload = JSON.stringify(buildAgentPayload(profile, job, envelope));
|
|
3106
3281
|
const agentEnv = baseAgentEnv(profile, job);
|
|
3107
3282
|
// The harness command line: the profile command plus its structured switches
|
|
@@ -3114,6 +3289,16 @@ function runAgentJob(profile, job, opts = {}) {
|
|
|
3114
3289
|
// resolved secrets are layered on top so user env can never shadow them.
|
|
3115
3290
|
const staticEnv = { ...normalizeEnvMap(profileEnv), ...normalizeEnvMap(envelope?.setup?.env) };
|
|
3116
3291
|
|
|
3292
|
+
// C3 (#42): when a relay session is present, tap the harness terminal onto the
|
|
3293
|
+
// relay lane (framed + tagged with this job's jobKey) and accept steer-in. The
|
|
3294
|
+
// tap is inert when there is no session, preserving legacy behaviour exactly.
|
|
3295
|
+
const relayTap = relaySession
|
|
3296
|
+
? {
|
|
3297
|
+
onData: (buf) => relaySession.relay(buf),
|
|
3298
|
+
attachSteer: (write) => relaySession.attachSteer(write),
|
|
3299
|
+
}
|
|
3300
|
+
: null;
|
|
3301
|
+
|
|
3117
3302
|
if (!CONTAINER_SANDBOXES.has(sandbox)) {
|
|
3118
3303
|
// Host: hand the agent the result file by its real path.
|
|
3119
3304
|
// Defense in depth: --arg tokens are POSIX single-quoted, which cmd.exe on
|
|
@@ -3124,6 +3309,29 @@ function runAgentJob(profile, job, opts = {}) {
|
|
|
3124
3309
|
return Promise.resolve({ ok: false, exitCode: null, stdout: '', stderr: '', error: 'command-line args (--arg) are not supported for host execution on Windows; use a container sandbox or bake switches into the command', truncated: false, stderrTruncated: false });
|
|
3125
3310
|
}
|
|
3126
3311
|
const resultEnv = resultFile ? { [AGENT_RESULT_FILE_ENV]: resultFile } : {};
|
|
3312
|
+
const harnessEnv = { ...process.env, ...staticEnv, ...secretEnv, ...agentEnv, ...extraEnv, ...resultEnv };
|
|
3313
|
+
|
|
3314
|
+
// A role opted into a full PTY (`terminal: pty`) runs the harness on a real
|
|
3315
|
+
// terminal when one can be allocated — so its live output streams as a true
|
|
3316
|
+
// terminal and cockpit steer-in reaches it. Falls back to a pipe (still
|
|
3317
|
+
// relayed) when node-pty is unavailable or on Windows.
|
|
3318
|
+
if (terminal === 'pty' && ptyAvailable(ptyFactory)) {
|
|
3319
|
+
return spawnCapturePty({
|
|
3320
|
+
command: 'sh',
|
|
3321
|
+
args: ['-c', commandLine],
|
|
3322
|
+
cwd,
|
|
3323
|
+
env: harnessEnv,
|
|
3324
|
+
stdinData: payload,
|
|
3325
|
+
timeoutMs,
|
|
3326
|
+
idleTimeoutMs,
|
|
3327
|
+
ptyFactory,
|
|
3328
|
+
relayTap,
|
|
3329
|
+
stream,
|
|
3330
|
+
streamPrefix,
|
|
3331
|
+
onStreamOut,
|
|
3332
|
+
});
|
|
3333
|
+
}
|
|
3334
|
+
|
|
3127
3335
|
return spawnCaptureOneShot({
|
|
3128
3336
|
command: commandLine,
|
|
3129
3337
|
shell: true,
|
|
@@ -3133,7 +3341,7 @@ function runAgentJob(profile, job, opts = {}) {
|
|
|
3133
3341
|
cwd,
|
|
3134
3342
|
// Reserved harness env (AGENT_* + the result-file path) is layered AFTER
|
|
3135
3343
|
// resolved secrets so a task-supplied secret NAME can never shadow it.
|
|
3136
|
-
env:
|
|
3344
|
+
env: harnessEnv,
|
|
3137
3345
|
stdinData: payload,
|
|
3138
3346
|
timeoutMs,
|
|
3139
3347
|
idleTimeoutMs,
|
|
@@ -3142,6 +3350,7 @@ function runAgentJob(profile, job, opts = {}) {
|
|
|
3142
3350
|
streamPrefix,
|
|
3143
3351
|
onStreamOut,
|
|
3144
3352
|
onStreamErr,
|
|
3353
|
+
relayTap,
|
|
3145
3354
|
});
|
|
3146
3355
|
}
|
|
3147
3356
|
|
|
@@ -3197,6 +3406,7 @@ function runAgentJob(profile, job, opts = {}) {
|
|
|
3197
3406
|
streamPrefix,
|
|
3198
3407
|
onStreamOut,
|
|
3199
3408
|
onStreamErr,
|
|
3409
|
+
relayTap,
|
|
3200
3410
|
onTimeout: (child) => {
|
|
3201
3411
|
try { spawnSync(engine, ['rm', '-f', containerName], { timeout: 15_000 }); } catch { /* best effort */ }
|
|
3202
3412
|
try { killTree(child); } catch { /* best effort */ }
|
|
@@ -3248,7 +3458,7 @@ function buildResultEnvelope(result, { sandbox, image, git, result: agentResult
|
|
|
3248
3458
|
* and a capability credential are present (enrolment) — absent either, it runs
|
|
3249
3459
|
* exactly as before, off the visibility page. Returns `null` when not enrolled.
|
|
3250
3460
|
*
|
|
3251
|
-
* @returns {{ url: string, token: string, credential: string } | null}
|
|
3461
|
+
* @returns {{ url: string, token: string, credential: string, bufferCapacity: number } | null}
|
|
3252
3462
|
*/
|
|
3253
3463
|
function resolveAgenticConfig() {
|
|
3254
3464
|
const cfg = readConfig();
|
|
@@ -3260,7 +3470,13 @@ function resolveAgenticConfig() {
|
|
|
3260
3470
|
const token = process.env.NANO_AGENTIC_TOKEN || cfg.agenticToken || '';
|
|
3261
3471
|
const credential = process.env.NANO_AGENTIC_CREDENTIAL || cfg.agenticCredential || '';
|
|
3262
3472
|
if (!url || !token || !credential) return null;
|
|
3263
|
-
|
|
3473
|
+
// Outbound hub-down buffer bound (frames). Operator-tunable (C4, #43) so a
|
|
3474
|
+
// long expected outage can be given more headroom; resolveBufferCapacity
|
|
3475
|
+
// validates it to a positive integer and falls back to the client default.
|
|
3476
|
+
const bufferCapacity = resolveBufferCapacity(
|
|
3477
|
+
process.env.NANO_AGENTIC_BUFFER_CAPACITY ?? cfg.agenticBufferCapacity,
|
|
3478
|
+
);
|
|
3479
|
+
return { url, token, credential, bufferCapacity };
|
|
3264
3480
|
}
|
|
3265
3481
|
|
|
3266
3482
|
/**
|
|
@@ -3511,6 +3727,8 @@ async function workAgent(req, flags) {
|
|
|
3511
3727
|
// recorders can refresh presence with the live job set as jobs start/end.
|
|
3512
3728
|
/** @type {import('./work-channel.mjs').WorkChannel | null} */
|
|
3513
3729
|
let workChannel = null;
|
|
3730
|
+
/** @type {import('./work-buffer.mjs').BufferMonitor | null} */
|
|
3731
|
+
let bufferMonitor = null;
|
|
3514
3732
|
// Maintain `activeJobs` unconditionally: it feeds both the supervisor activity
|
|
3515
3733
|
// file (gated inside writeActivity) AND the agentic presence frame's live
|
|
3516
3734
|
// jobKey set, so a standalone worker (no NANO_SUPERVISOR_ACTIVITY_FILE) still
|
|
@@ -3554,6 +3772,7 @@ async function workAgent(req, flags) {
|
|
|
3554
3772
|
url: agenticCfg.url,
|
|
3555
3773
|
token: agenticCfg.token,
|
|
3556
3774
|
credential: agenticCfg.credential,
|
|
3775
|
+
bufferCapacity: agenticCfg.bufferCapacity,
|
|
3557
3776
|
logger,
|
|
3558
3777
|
});
|
|
3559
3778
|
const shown = redactAgenticUrl(buildAgenticUrl(agenticCfg.url, {}));
|
|
@@ -3563,10 +3782,43 @@ async function workAgent(req, flags) {
|
|
|
3563
3782
|
workChannel = null;
|
|
3564
3783
|
logger.warn(` agentic channel unavailable (${err?.message || err}); continuing without visibility.`);
|
|
3565
3784
|
}
|
|
3785
|
+
// C4 (#43): observe the client's built-in outbound buffer across the
|
|
3786
|
+
// channel lifecycle — surface a high-water mark and warn when the bound
|
|
3787
|
+
// is hit so a hub outage that starts shedding frames is never silent. The
|
|
3788
|
+
// monitor is observability-only, so keep it OUTSIDE the channel try/catch:
|
|
3789
|
+
// a monitor failure must never null out a healthy channel and take down
|
|
3790
|
+
// presence/visibility.
|
|
3791
|
+
if (workChannel) {
|
|
3792
|
+
try {
|
|
3793
|
+
bufferMonitor = createBufferMonitor(workChannel, {
|
|
3794
|
+
capacity: agenticCfg.bufferCapacity,
|
|
3795
|
+
logger,
|
|
3796
|
+
});
|
|
3797
|
+
} catch (err) {
|
|
3798
|
+
bufferMonitor = null;
|
|
3799
|
+
logger.warn(` agentic buffer monitor unavailable (${err?.message || err}); channel presence still active.`);
|
|
3800
|
+
}
|
|
3801
|
+
}
|
|
3566
3802
|
} else {
|
|
3567
3803
|
logger.info(' agentic channel: not enrolled (set NANO_AGENTIC_URL + NANO_AGENTIC_TOKEN + NANO_AGENTIC_CREDENTIAL to appear on the visibility page).');
|
|
3568
3804
|
}
|
|
3569
3805
|
|
|
3806
|
+
// C3 (#42): the role's live-terminal mode — a full PTY (streamed on the relay
|
|
3807
|
+
// lane when a relay session exists, steerable) or a plain pipe. Honors the
|
|
3808
|
+
// vocab's per-role opt-in read off the hire profile (`terminal: pty|pipe`),
|
|
3809
|
+
// with an env override for a one-off worker (`NANO_AGENTIC_TERMINAL`). The PTY
|
|
3810
|
+
// itself is allocated locally regardless of enrollment; relay streaming (and
|
|
3811
|
+
// steer-in) only engages when the worker is enrolled on the channel, so
|
|
3812
|
+
// without the channel there's simply no relay tap — the harness still runs on
|
|
3813
|
+
// the chosen local transport.
|
|
3814
|
+
const envTerminal = (process.env.NANO_AGENTIC_TERMINAL || '').trim().toLowerCase();
|
|
3815
|
+
const roleTerminal = (envTerminal === 'pty' || envTerminal === 'pipe')
|
|
3816
|
+
? envTerminal
|
|
3817
|
+
: roleTerminalMode(profile);
|
|
3818
|
+
if (workChannel) {
|
|
3819
|
+
logger.info(` live terminal: ${roleTerminal === 'pty' ? 'PTY (streamed + steerable)' : 'pipe (streamed)'} on the relay lane.`);
|
|
3820
|
+
}
|
|
3821
|
+
|
|
3570
3822
|
// A per-job-type worker factory. Captures all the CLI-local + profile context
|
|
3571
3823
|
// in closure scope so the profile watcher below can (re)spawn a poller for any
|
|
3572
3824
|
// job type on demand without re-reading the flags.
|
|
@@ -3658,6 +3910,19 @@ async function workAgent(req, flags) {
|
|
|
3658
3910
|
|
|
3659
3911
|
let result;
|
|
3660
3912
|
let gitResult = null;
|
|
3913
|
+
// C3 (#42): the per-job live-terminal relay session. Streams this job's
|
|
3914
|
+
// harness terminal on the relay lane tagged with its jobKey, and accepts
|
|
3915
|
+
// steer-in. Only when the worker is enrolled on the channel; closed in
|
|
3916
|
+
// the finally so its inbound-frame subscription never leaks across jobs.
|
|
3917
|
+
let relaySession = null;
|
|
3918
|
+
if (workChannel) {
|
|
3919
|
+
try {
|
|
3920
|
+
relaySession = createRelaySession({ channel: workChannel, jobKey: job.jobKey, logger });
|
|
3921
|
+
} catch (err) {
|
|
3922
|
+
relaySession = null;
|
|
3923
|
+
logger.warn(`[${jobType}] job ${job.jobKey}: relay session unavailable (${err?.message || err}); continuing without live terminal.`);
|
|
3924
|
+
}
|
|
3925
|
+
}
|
|
3661
3926
|
// Private structured-result channel: hand the agent a file (outside any
|
|
3662
3927
|
// repo clone so it can't be `git add`ed) to write its job-result vars to.
|
|
3663
3928
|
let resultDir = null;
|
|
@@ -3688,6 +3953,11 @@ async function workAgent(req, flags) {
|
|
|
3688
3953
|
stream,
|
|
3689
3954
|
streamPrefix: `[${jobType} ${job.jobKey}] `,
|
|
3690
3955
|
args: effectiveArgs,
|
|
3956
|
+
// C3 (#42): a full PTY for a role that opted in, else a pipe. Both
|
|
3957
|
+
// stream on the relay lane when a relay session exists (skipped when
|
|
3958
|
+
// relaySession is null); only a PTY is interactively steerable.
|
|
3959
|
+
terminal: roleTerminal,
|
|
3960
|
+
relaySession,
|
|
3691
3961
|
// Route the --stream tee through c8ctl's output-mode-aware logger so
|
|
3692
3962
|
// spying never corrupts a structured/JSON output mode.
|
|
3693
3963
|
onStreamOut: stream ? (line) => logger.info(line) : undefined,
|
|
@@ -3716,6 +3986,9 @@ async function workAgent(req, flags) {
|
|
|
3716
3986
|
if (isContainer) liveRunIds.delete(runId);
|
|
3717
3987
|
if (runDir && !keepRuns) { try { rmSync(runDir, { recursive: true, force: true }); } catch { /* best effort */ } }
|
|
3718
3988
|
if (runDir) liveRunDirs.delete(runDir);
|
|
3989
|
+
// Detach the relay session's inbound-frame subscription so it never
|
|
3990
|
+
// outlives the job or leaks a steer listener across jobs.
|
|
3991
|
+
if (relaySession) { try { relaySession.close(); } catch { /* best effort */ } }
|
|
3719
3992
|
}
|
|
3720
3993
|
|
|
3721
3994
|
// Read the agent's structured result: the file it wrote, else a stdout
|
|
@@ -3943,6 +4216,10 @@ async function workAgent(req, flags) {
|
|
|
3943
4216
|
// from the page only once its jobs have drained. Best-effort — a channel
|
|
3944
4217
|
// teardown must never hang shutdown.
|
|
3945
4218
|
if (workChannel) {
|
|
4219
|
+
// Stop the buffer monitor first so its sampler can't fire mid-teardown.
|
|
4220
|
+
try {
|
|
4221
|
+
bufferMonitor?.stop();
|
|
4222
|
+
} catch { /* best effort */ }
|
|
3946
4223
|
try {
|
|
3947
4224
|
await workChannel.stop(`worker stopped (${signal})`);
|
|
3948
4225
|
logger.info('Deregistered from the agentic visibility channel.');
|
|
@@ -6638,6 +6915,7 @@ export {
|
|
|
6638
6915
|
diskBudgetOk,
|
|
6639
6916
|
containerEngineAvailable,
|
|
6640
6917
|
runAgentJob,
|
|
6918
|
+
spawnCapturePty,
|
|
6641
6919
|
startLockExtender,
|
|
6642
6920
|
provisionRepo,
|
|
6643
6921
|
finalizeGit,
|
|
@@ -6793,6 +7071,7 @@ export const commands = {
|
|
|
6793
7071
|
capabilities: { type: 'string', description: 'hire/assign: comma-separated capability list' },
|
|
6794
7072
|
sandbox: { type: 'string', description: 'hire/work: execution sandbox none|docker|podman (default none). Containers isolate each job.' },
|
|
6795
7073
|
image: { type: 'string', description: 'hire/work: container image the agent runs in (required for --sandbox docker|podman)' },
|
|
7074
|
+
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.' },
|
|
6796
7075
|
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.' },
|
|
6797
7076
|
'secret-resolver': { type: 'string', description: 'work: secret resolver for task secretRefs (host = process env; default host)' },
|
|
6798
7077
|
'reap-age': { type: 'string', description: 'work: age in ms before a finished agent container or job workspace is reaped (default 3600000)' },
|
|
@@ -6964,7 +7243,7 @@ function printUsage() {
|
|
|
6964
7243
|
console.log(' c8ctl nano unset <bin|model-dir>');
|
|
6965
7244
|
console.log(' c8ctl nano config');
|
|
6966
7245
|
console.log(' c8ctl nano update [--check]');
|
|
6967
|
-
console.log(' c8ctl nano hire [--name <n>] [--rank <r>] [--command <c>] [--arg <switch> ...] [--model <m>] [--capabilities <a,b>] [--sandbox none|docker|podman] [--image <ref>] [--env NAME=VALUE ...] [--list]');
|
|
7246
|
+
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]');
|
|
6968
7247
|
console.log(' c8ctl nano assign <profileName> <cap[,cap...]> [--name <n>] [--capabilities <a,b>]');
|
|
6969
7248
|
console.log(' c8ctl nano work <profileName> [--arg <switch> ...] [--max-parallel <n>] [--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]');
|
|
6970
7249
|
console.log(' c8ctl nano supervisor [start|status|add|remove|restart|stop|logs|attach] ... (manage many workers from one terminal)');
|
|
@@ -7008,6 +7287,7 @@ function printUsage() {
|
|
|
7008
7287
|
console.log(' --capabilities <a,b> hire/assign: comma-separated capability list');
|
|
7009
7288
|
console.log(' --sandbox <s> hire/work: execution sandbox none|docker|podman (default none)');
|
|
7010
7289
|
console.log(' --image <ref> hire/work: container image the agent runs in (required for docker|podman)');
|
|
7290
|
+
console.log(' --terminal <m> hire: live-terminal mode pty|pipe (default pipe); pty streams a steerable terminal on the relay lane');
|
|
7011
7291
|
console.log(' --env NAME=VALUE hire/work: static env var for the harness (repeatable); persisted on hire, work extends/overrides');
|
|
7012
7292
|
console.log(' --list hire: list existing agent profiles instead of creating one');
|
|
7013
7293
|
console.log(' --max-parallel <n> work: max concurrent jobs per worker (default 1)');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "c8ctl-plugin-nano",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.30.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",
|
|
@@ -25,6 +25,8 @@
|
|
|
25
25
|
"agentic.mjs",
|
|
26
26
|
"agentic-loader-hook.mjs",
|
|
27
27
|
"work-channel.mjs",
|
|
28
|
+
"work-relay.mjs",
|
|
29
|
+
"work-buffer.mjs",
|
|
28
30
|
"nanobpmn-binary.json",
|
|
29
31
|
"README.md"
|
|
30
32
|
],
|
|
@@ -54,12 +56,13 @@
|
|
|
54
56
|
"@nanobpm/urban-agent-client": "^0.1.0"
|
|
55
57
|
},
|
|
56
58
|
"optionalDependencies": {
|
|
57
|
-
"
|
|
58
|
-
"@nanobpm/c8ctl-plugin-nano-darwin-
|
|
59
|
-
"@nanobpm/c8ctl-plugin-nano-
|
|
60
|
-
"@nanobpm/c8ctl-plugin-nano-linux-
|
|
61
|
-
"@nanobpm/c8ctl-plugin-nano-linux-
|
|
62
|
-
"@nanobpm/c8ctl-plugin-nano-linux-
|
|
63
|
-
"@nanobpm/c8ctl-plugin-nano-
|
|
59
|
+
"node-pty": "^1.0.0",
|
|
60
|
+
"@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.30.0",
|
|
61
|
+
"@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.30.0",
|
|
62
|
+
"@nanobpm/c8ctl-plugin-nano-linux-x64": "1.30.0",
|
|
63
|
+
"@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.30.0",
|
|
64
|
+
"@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.30.0",
|
|
65
|
+
"@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.30.0",
|
|
66
|
+
"@nanobpm/c8ctl-plugin-nano-win32-x64": "1.30.0"
|
|
64
67
|
}
|
|
65
68
|
}
|
package/work-buffer.mjs
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
// The `work` command's hub-down buffer observability + policy layer
|
|
2
|
+
// (ADR 0056 — slice C4, jwulf/c8ctl-plugin-nano#43).
|
|
3
|
+
//
|
|
4
|
+
// C4 makes a running worker survive hub disconnects: while the app hub is
|
|
5
|
+
// unreachable (the worker started before the app, or the hub restarted) the
|
|
6
|
+
// worker keeps producing frames, and they drain — bounded and in order — when
|
|
7
|
+
// the channel comes back.
|
|
8
|
+
//
|
|
9
|
+
// DERIVATION OVER DUPLICATION. The bounded local buffer this slice is about
|
|
10
|
+
// ALREADY exists as the connected client's built-in `OutboundRing` (in
|
|
11
|
+
// `@nanobpm/urban-agent-client`): a QoS-aware, capacity-bounded ring that holds
|
|
12
|
+
// every outbound frame while the socket is down and drains in strict lane
|
|
13
|
+
// priority (control → interactive → bulk, FIFO within a lane) on reconnect,
|
|
14
|
+
// shedding the single least-important frame on overflow. It sits at the
|
|
15
|
+
// TRANSPORT seam — below the lanes — so it captures any lane's frames and is
|
|
16
|
+
// therefore independent of C3's relay producer. We do NOT re-declare a second
|
|
17
|
+
// ring here (that would be a parallel, drift-prone buffer over the same
|
|
18
|
+
// frames); we CONSUME the canonical one through C2's `WorkChannel` seam.
|
|
19
|
+
//
|
|
20
|
+
// What this slice actually adds over C2's client is the two things the built-in
|
|
21
|
+
// ring leaves implicit:
|
|
22
|
+
//
|
|
23
|
+
// 1. The bound is OPERATOR-CONFIGURABLE, not a buried literal — see
|
|
24
|
+
// {@link resolveBufferCapacity} (wired to `NANO_AGENTIC_BUFFER_CAPACITY`
|
|
25
|
+
// in `resolveAgenticConfig`), so a long expected outage can be given more
|
|
26
|
+
// headroom without a code change.
|
|
27
|
+
// 2. The drop/backpressure policy is OBSERVABLE. The client sheds overflow
|
|
28
|
+
// frames silently (`relay()` returns void; the evicted frame is dropped
|
|
29
|
+
// inside the ring). {@link createBufferMonitor} turns that silent bound
|
|
30
|
+
// into a visible signal: it watches the buffer depth across C2's
|
|
31
|
+
// connect / disconnect / reconnect lifecycle, records a high-water mark
|
|
32
|
+
// and each outage→flush, and warns when the bound is hit so a hit bound is
|
|
33
|
+
// never silent data loss.
|
|
34
|
+
//
|
|
35
|
+
// The monitor is driven ENTIRELY by C2's lifecycle events + the client's
|
|
36
|
+
// buffer-drained event; it never opens, authenticates, or re-instantiates the
|
|
37
|
+
// channel, and it never produces frames of its own. It is pure observation over
|
|
38
|
+
// the one connected client.
|
|
39
|
+
|
|
40
|
+
// The outbound-ring bound (frames) the client buffers while the hub is
|
|
41
|
+
// unreachable. Single-sourced from the transport seam (`work-channel.mjs`),
|
|
42
|
+
// which applies it to the client, so the "falls back to the client default"
|
|
43
|
+
// contract stays accurate from one edit — no drift-prone second literal here.
|
|
44
|
+
import { DEFAULT_BUFFER_CAPACITY } from './work-channel.mjs';
|
|
45
|
+
|
|
46
|
+
const DEFAULT_SAMPLE_INTERVAL_MS = 1_000;
|
|
47
|
+
|
|
48
|
+
export { DEFAULT_BUFFER_CAPACITY };
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Resolve the outbound-buffer bound (in frames) from an operator-supplied value
|
|
52
|
+
* with a sane fallback. The bound MUST be a positive integer — the client's
|
|
53
|
+
* `OutboundRing` throws on a non-positive capacity, so we validate here and fall
|
|
54
|
+
* back rather than let a typo wedge enrolment. Accepts a number or a numeric
|
|
55
|
+
* string (env vars arrive as strings).
|
|
56
|
+
*
|
|
57
|
+
* @param {unknown} raw the operator value (e.g. `process.env.NANO_AGENTIC_BUFFER_CAPACITY`)
|
|
58
|
+
* @param {number} [fallback] the default when `raw` is absent/invalid
|
|
59
|
+
* @returns {number} a positive-integer frame bound
|
|
60
|
+
*/
|
|
61
|
+
export function resolveBufferCapacity(raw, fallback = DEFAULT_BUFFER_CAPACITY) {
|
|
62
|
+
const base = Number.isInteger(fallback) && fallback > 0 ? fallback : DEFAULT_BUFFER_CAPACITY;
|
|
63
|
+
if (raw === undefined || raw === null || raw === '') return base;
|
|
64
|
+
const n = typeof raw === 'number' ? raw : Number(String(raw).trim());
|
|
65
|
+
if (!Number.isInteger(n) || n < 1) return base;
|
|
66
|
+
return n;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* @typedef {object} BufferHealth
|
|
71
|
+
* @property {number} capacity the configured frame bound
|
|
72
|
+
* @property {number} buffered frames currently held awaiting a live channel
|
|
73
|
+
* @property {boolean} connected whether the channel is currently open
|
|
74
|
+
* @property {number} highWaterMark the deepest buffer depth observed
|
|
75
|
+
* @property {number} outages number of times the channel went from up→down (buffering began)
|
|
76
|
+
* @property {number} reconnects number of times the channel recovered (up again after a drop)
|
|
77
|
+
* @property {number} flushes number of outage backlogs that fully drained on (re)connect
|
|
78
|
+
* @property {number} lastFlushFrames backlog size captured at the (re)connect that drove the last flush
|
|
79
|
+
* @property {number|null} lastFlushAt timestamp (ms) the last flush completed, or null
|
|
80
|
+
* @property {number} atCapacityEvents times a sample found the buffer at/over its bound (overflow shedding)
|
|
81
|
+
* @property {boolean} atCapacity whether the last sample was at/over the bound
|
|
82
|
+
*/
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* @typedef {object} BufferMonitor
|
|
86
|
+
* @property {() => BufferHealth} health snapshot of the buffer's current health/metrics
|
|
87
|
+
* @property {() => number} sample take a depth sample now (updates high-water / at-capacity); returns the depth
|
|
88
|
+
* @property {() => void} stop detach all listeners and stop sampling (idempotent)
|
|
89
|
+
*/
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Observe the connected client's built-in outbound buffer across C2's channel
|
|
93
|
+
* lifecycle and surface its health + the (otherwise silent) drop policy.
|
|
94
|
+
*
|
|
95
|
+
* The monitor:
|
|
96
|
+
* - reads live depth via `channel.buffered()` (the client's `OutboundRing`
|
|
97
|
+
* size) — it does not hold its own buffer;
|
|
98
|
+
* - on the FIRST connect and every RECONNECT, captures the backlog about to
|
|
99
|
+
* flush (the client fires `onOpen`/our lifecycle listeners BEFORE it pumps
|
|
100
|
+
* the ring, so the depth read here is the pre-drain backlog) and, when the
|
|
101
|
+
* client's `onDrain` then fires (ring emptied after sending), records the
|
|
102
|
+
* completed flush;
|
|
103
|
+
* - on DISCONNECT (or immediately, if the worker starts before the app and is
|
|
104
|
+
* not connected yet), enters an "outage" and samples depth periodically so a
|
|
105
|
+
* growing backlog that hits the bound is noticed and warned about;
|
|
106
|
+
* - keeps a high-water mark and an at-capacity counter, warning (once per
|
|
107
|
+
* transition into the at-capacity state, to avoid log spam) so operators see
|
|
108
|
+
* that the bound is shedding frames.
|
|
109
|
+
*
|
|
110
|
+
* @param {import('./work-channel.mjs').WorkChannel} channel the C2 seam holder
|
|
111
|
+
* @param {object} [opts]
|
|
112
|
+
* @param {number} [opts.capacity] the configured bound (for health/at-capacity); defaults to DEFAULT_BUFFER_CAPACITY
|
|
113
|
+
* @param {number} [opts.sampleIntervalMs] periodic depth-sample cadence while in an outage; <=0 disables the timer
|
|
114
|
+
* @param {{ warn?: Function, info?: Function, debug?: Function }} [opts.logger] optional logger
|
|
115
|
+
* @param {() => number} [opts.now] injectable clock (tests); defaults to Date.now
|
|
116
|
+
* @param {{ setInterval: Function, clearInterval: Function }} [opts.timers] injectable timers (tests)
|
|
117
|
+
* @returns {BufferMonitor}
|
|
118
|
+
*/
|
|
119
|
+
export function createBufferMonitor(channel, opts = {}) {
|
|
120
|
+
if (!channel || typeof channel.buffered !== 'function') {
|
|
121
|
+
throw new Error('createBufferMonitor requires a WorkChannel with a buffered() accessor');
|
|
122
|
+
}
|
|
123
|
+
const capacity = resolveBufferCapacity(opts.capacity, DEFAULT_BUFFER_CAPACITY);
|
|
124
|
+
const sampleIntervalMs = Number.isFinite(opts.sampleIntervalMs)
|
|
125
|
+
? opts.sampleIntervalMs
|
|
126
|
+
: DEFAULT_SAMPLE_INTERVAL_MS;
|
|
127
|
+
const log = opts.logger || {};
|
|
128
|
+
const now = typeof opts.now === 'function' ? opts.now : () => Date.now();
|
|
129
|
+
const timers = opts.timers || { setInterval, clearInterval };
|
|
130
|
+
|
|
131
|
+
const state = {
|
|
132
|
+
highWaterMark: 0,
|
|
133
|
+
outages: 0,
|
|
134
|
+
reconnects: 0,
|
|
135
|
+
flushes: 0,
|
|
136
|
+
lastFlushFrames: 0,
|
|
137
|
+
lastFlushAt: /** @type {number|null} */ (null),
|
|
138
|
+
atCapacityEvents: 0,
|
|
139
|
+
atCapacity: false,
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
// `buffering` is true while we believe frames are queued for a hub that is
|
|
143
|
+
// down — set at disconnect (and at creation if we start disconnected), and
|
|
144
|
+
// cleared when the outage's backlog finishes flushing (the client's next
|
|
145
|
+
// onDrain) or when a (re)connect finds nothing was buffered. The client fires
|
|
146
|
+
// our lifecycle connect/reconnect listeners BEFORE it pumps the ring (and thus
|
|
147
|
+
// before its buffer-drained event), so settleOnOpen captures the pre-drain
|
|
148
|
+
// backlog into `outageBacklogPeak` and the subsequent onDrain records it.
|
|
149
|
+
// `outageBacklogPeak` is the deepest the buffer got during the current
|
|
150
|
+
// outage — what we report as the flushed frame count.
|
|
151
|
+
let buffering = false;
|
|
152
|
+
let outageBacklogPeak = 0;
|
|
153
|
+
let sampleTimer = null;
|
|
154
|
+
let stopped = false;
|
|
155
|
+
|
|
156
|
+
const depth = () => {
|
|
157
|
+
try {
|
|
158
|
+
return Number(channel.buffered()) || 0;
|
|
159
|
+
} catch {
|
|
160
|
+
return 0;
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
/** Take a depth sample: update the high-water mark, the per-outage peak, and
|
|
165
|
+
* the at-capacity signal. */
|
|
166
|
+
const sample = () => {
|
|
167
|
+
const d = depth();
|
|
168
|
+
if (d > state.highWaterMark) state.highWaterMark = d;
|
|
169
|
+
if (buffering && d > outageBacklogPeak) outageBacklogPeak = d;
|
|
170
|
+
const atCap = d >= capacity;
|
|
171
|
+
if (atCap) {
|
|
172
|
+
state.atCapacityEvents += 1;
|
|
173
|
+
if (!state.atCapacity) {
|
|
174
|
+
// Transition into the at-capacity state — warn ONCE so the operator sees
|
|
175
|
+
// the bound is full and further low-priority frames may be dropped
|
|
176
|
+
// (bulk relay before interactive before control), but we don't spam
|
|
177
|
+
// every sample. We only observe depth, so we don't assert a drop has
|
|
178
|
+
// already happened: depth can reach capacity before any overflow.
|
|
179
|
+
try {
|
|
180
|
+
log.warn?.(
|
|
181
|
+
`agentic outbound buffer full (${d}/${capacity} frames): the hub is unreachable and further low-priority frames may be dropped until it reconnects. Raise NANO_AGENTIC_BUFFER_CAPACITY for a longer expected outage.`,
|
|
182
|
+
);
|
|
183
|
+
} catch {
|
|
184
|
+
/* a logger failure must never break sampling */
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
state.atCapacity = atCap;
|
|
189
|
+
return d;
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const startSampler = () => {
|
|
193
|
+
if (stopped || sampleTimer !== null || sampleIntervalMs <= 0) return;
|
|
194
|
+
sampleTimer = timers.setInterval(() => sample(), sampleIntervalMs);
|
|
195
|
+
// Don't keep the event loop alive just to sample a buffer.
|
|
196
|
+
if (sampleTimer && typeof sampleTimer.unref === 'function') sampleTimer.unref();
|
|
197
|
+
};
|
|
198
|
+
const stopSampler = () => {
|
|
199
|
+
if (sampleTimer !== null) {
|
|
200
|
+
timers.clearInterval(sampleTimer);
|
|
201
|
+
sampleTimer = null;
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
// Enter an outage: begin (or continue) buffering and start watching depth.
|
|
206
|
+
const beginOutage = () => {
|
|
207
|
+
buffering = true;
|
|
208
|
+
outageBacklogPeak = 0;
|
|
209
|
+
state.atCapacity = false;
|
|
210
|
+
startSampler();
|
|
211
|
+
sample();
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
// A (re)connect happened. The client fires this listener BEFORE it pumps the
|
|
215
|
+
// ring, so depth() here is the pre-drain backlog (captured below); the
|
|
216
|
+
// client's onDrain then fires and records the completed flush. If we are still
|
|
217
|
+
// buffering and the buffer is already empty, the outage carried nothing to
|
|
218
|
+
// flush — just clear it.
|
|
219
|
+
const settleOnOpen = () => {
|
|
220
|
+
stopSampler();
|
|
221
|
+
// Capture the pre-drain backlog. The client fires our connect/reconnect
|
|
222
|
+
// listeners BEFORE it pumps the ring (see the module header), so depth()
|
|
223
|
+
// here is the backlog about to flush. Recording it into the outage peak
|
|
224
|
+
// makes the flush count (onDrain) reflect the real drained depth even when
|
|
225
|
+
// no periodic sample happened to catch the peak — under production
|
|
226
|
+
// timer-based sampling a short outage would otherwise leave the peak at 0
|
|
227
|
+
// and fall back to 1.
|
|
228
|
+
const d = depth();
|
|
229
|
+
if (d > state.highWaterMark) state.highWaterMark = d;
|
|
230
|
+
if (buffering && d > outageBacklogPeak) outageBacklogPeak = d;
|
|
231
|
+
if (buffering && d === 0) {
|
|
232
|
+
buffering = false;
|
|
233
|
+
outageBacklogPeak = 0;
|
|
234
|
+
}
|
|
235
|
+
state.atCapacity = false;
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
const unsub = [];
|
|
239
|
+
|
|
240
|
+
// First connect (worker-before-app: the pre-app backlog flushes here too).
|
|
241
|
+
unsub.push(
|
|
242
|
+
channel.onConnect(() => {
|
|
243
|
+
settleOnOpen();
|
|
244
|
+
}),
|
|
245
|
+
);
|
|
246
|
+
// Every recovery after a drop (hub restart / transient outage).
|
|
247
|
+
unsub.push(
|
|
248
|
+
channel.onReconnect(() => {
|
|
249
|
+
state.reconnects += 1;
|
|
250
|
+
settleOnOpen();
|
|
251
|
+
}),
|
|
252
|
+
);
|
|
253
|
+
// The channel went down: begin (or continue) buffering; sample the backlog as
|
|
254
|
+
// it grows so a bound hit is noticed even during a long outage.
|
|
255
|
+
unsub.push(
|
|
256
|
+
channel.onDisconnect(() => {
|
|
257
|
+
state.outages += 1;
|
|
258
|
+
beginOutage();
|
|
259
|
+
}),
|
|
260
|
+
);
|
|
261
|
+
// The client's outbound ring emptied after sending: if we were flushing an
|
|
262
|
+
// outage backlog, the drain is now complete.
|
|
263
|
+
if (channel.client && typeof channel.client.onDrain === 'function') {
|
|
264
|
+
unsub.push(
|
|
265
|
+
channel.client.onDrain(() => {
|
|
266
|
+
if (buffering) {
|
|
267
|
+
state.flushes += 1;
|
|
268
|
+
state.lastFlushFrames = Math.max(outageBacklogPeak, 1);
|
|
269
|
+
state.lastFlushAt = now();
|
|
270
|
+
buffering = false;
|
|
271
|
+
outageBacklogPeak = 0;
|
|
272
|
+
stopSampler();
|
|
273
|
+
}
|
|
274
|
+
}),
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Worker-before-app: if we're created while the channel is still down, we are
|
|
279
|
+
// already buffering — start sampling immediately so a pre-connect bound hit is
|
|
280
|
+
// observed and the first-connect drain is recorded as a flush.
|
|
281
|
+
let connectedNow = false;
|
|
282
|
+
try {
|
|
283
|
+
connectedNow = typeof channel.connected === 'function' ? Boolean(channel.connected()) : false;
|
|
284
|
+
} catch {
|
|
285
|
+
connectedNow = false;
|
|
286
|
+
}
|
|
287
|
+
if (!connectedNow) {
|
|
288
|
+
buffering = true;
|
|
289
|
+
outageBacklogPeak = 0;
|
|
290
|
+
startSampler();
|
|
291
|
+
sample();
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return {
|
|
295
|
+
health() {
|
|
296
|
+
return {
|
|
297
|
+
capacity,
|
|
298
|
+
buffered: depth(),
|
|
299
|
+
connected: (() => {
|
|
300
|
+
try {
|
|
301
|
+
return typeof channel.connected === 'function' ? Boolean(channel.connected()) : false;
|
|
302
|
+
} catch {
|
|
303
|
+
return false;
|
|
304
|
+
}
|
|
305
|
+
})(),
|
|
306
|
+
highWaterMark: state.highWaterMark,
|
|
307
|
+
outages: state.outages,
|
|
308
|
+
reconnects: state.reconnects,
|
|
309
|
+
flushes: state.flushes,
|
|
310
|
+
lastFlushFrames: state.lastFlushFrames,
|
|
311
|
+
lastFlushAt: state.lastFlushAt,
|
|
312
|
+
atCapacityEvents: state.atCapacityEvents,
|
|
313
|
+
atCapacity: state.atCapacity,
|
|
314
|
+
};
|
|
315
|
+
},
|
|
316
|
+
sample,
|
|
317
|
+
stop() {
|
|
318
|
+
if (stopped) return;
|
|
319
|
+
stopped = true;
|
|
320
|
+
stopSampler();
|
|
321
|
+
for (const off of unsub) {
|
|
322
|
+
try {
|
|
323
|
+
off?.();
|
|
324
|
+
} catch {
|
|
325
|
+
/* best effort */
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
unsub.length = 0;
|
|
329
|
+
},
|
|
330
|
+
};
|
|
331
|
+
}
|
package/work-channel.mjs
CHANGED
|
@@ -36,6 +36,7 @@ const DEFAULT_HEARTBEAT_MS = 10_000;
|
|
|
36
36
|
// before the app from losing its early presence/relay frames.
|
|
37
37
|
const DEFAULT_BUFFER_CAPACITY = 1024;
|
|
38
38
|
|
|
39
|
+
export { DEFAULT_BUFFER_CAPACITY };
|
|
39
40
|
/**
|
|
40
41
|
* Build the worker's agentic-channel WebSocket URL from the app's HTTP base URL
|
|
41
42
|
* plus the ADR 0028 identity token and capability credential, carried as query
|
package/work-relay.mjs
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// The `work` command's live-terminal relay seam (ADR 0056 — slice C3,
|
|
2
|
+
// jwulf/c8ctl-plugin-nano#42).
|
|
3
|
+
//
|
|
4
|
+
// This module streams a running agent harness's terminal on the agentic
|
|
5
|
+
// channel's RELAY lane, tagged with the originating `jobKey`, and accepts
|
|
6
|
+
// steer-in: bytes an operator's cockpit sends back on the same relay stream are
|
|
7
|
+
// written into the harness's PTY so the run can be steered live.
|
|
8
|
+
//
|
|
9
|
+
// It BUILDS ON C2's merged channel seam (`work-channel.mjs`): the single
|
|
10
|
+
// connected + authenticated channel client is instantiated once in `workAgent`,
|
|
11
|
+
// and this slice consumes the accessors on that holder — it does NOT open,
|
|
12
|
+
// authenticate, or re-instantiate the channel:
|
|
13
|
+
//
|
|
14
|
+
// - {@link createRelaySession} publishes framed terminal output through
|
|
15
|
+
// `channel.relayLane().relay(stream, chunk)` (C2's bulk-lane sink), and
|
|
16
|
+
// subscribes to inbound relay frames via `channel.client.onFrame` for
|
|
17
|
+
// steer-in.
|
|
18
|
+
//
|
|
19
|
+
// Everything on the wire (the `relay` message family + its `{ stream, offset,
|
|
20
|
+
// chunk }` payload) is CONSUMED through C2's client — nothing is re-declared
|
|
21
|
+
// here. PTY allocation itself is a local concern (see `openTerminal` /
|
|
22
|
+
// `spawnCapturePty` in the plugin); this module is transport-agnostic and takes
|
|
23
|
+
// a duck-typed terminal handle, so it is unit-testable with a fake terminal and
|
|
24
|
+
// a fake channel.
|
|
25
|
+
|
|
26
|
+
/** Prefix for a per-job relay stream name. One stream carries a job's terminal. */
|
|
27
|
+
export const RELAY_STREAM_PREFIX = 'job:';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The canonical relay stream name for a job. Both the worker's produced output
|
|
31
|
+
* frames and the cockpit's steer-in frames ride this one stream, so the two
|
|
32
|
+
* ends agree on routing from the `jobKey` alone (the `jobKey` is available from
|
|
33
|
+
* `activateJobs` when the job is activated). Direction distinguishes them: the
|
|
34
|
+
* worker PRODUCES output frames on it and READS inbound frames on it as steer.
|
|
35
|
+
*
|
|
36
|
+
* @param {string|number} jobKey
|
|
37
|
+
* @returns {string}
|
|
38
|
+
*/
|
|
39
|
+
export function relayStreamName(jobKey) {
|
|
40
|
+
return `${RELAY_STREAM_PREFIX}${String(jobKey)}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Resolve a role's terminal mode — whether the agent harness for this role gets
|
|
45
|
+
* a full PTY or a plain pipe. Honors the vocab's per-role opt-in: a role may set
|
|
46
|
+
* `terminal: 'pty' | 'pipe'` (preferred) or the boolean shorthand `pty: true`.
|
|
47
|
+
* Defaults to `'pipe'` — a pipe is the safe, non-interactive default; a PTY is
|
|
48
|
+
* opt-in per role because it changes the harness's I/O semantics (a TTY, line
|
|
49
|
+
* discipline, echo).
|
|
50
|
+
*
|
|
51
|
+
* The lookup is deliberately structural so it works whether it is fed a vocab
|
|
52
|
+
* `VocabRole` (forward-compatible: the schema tolerates extra fields) or this
|
|
53
|
+
* repo's local role notion (a hire profile).
|
|
54
|
+
*
|
|
55
|
+
* @param {{ terminal?: unknown, pty?: unknown } | null | undefined} role
|
|
56
|
+
* @returns {'pty' | 'pipe'}
|
|
57
|
+
*/
|
|
58
|
+
export function roleTerminalMode(role) {
|
|
59
|
+
if (role && typeof role === 'object') {
|
|
60
|
+
const t = role.terminal;
|
|
61
|
+
if (typeof t === 'string') {
|
|
62
|
+
const norm = t.trim().toLowerCase();
|
|
63
|
+
if (norm === 'pty') return 'pty';
|
|
64
|
+
if (norm === 'pipe') return 'pipe';
|
|
65
|
+
}
|
|
66
|
+
if (role.pty === true) return 'pty';
|
|
67
|
+
}
|
|
68
|
+
return 'pipe';
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Narrow an inbound channel {@link Frame} to the steer-in chunk destined for a
|
|
73
|
+
* given relay stream, or `null` when it is not one. Consumes the shared `relay`
|
|
74
|
+
* family payload (`{ stream, offset, chunk }`) — never re-declares it.
|
|
75
|
+
*
|
|
76
|
+
* @param {{ family?: unknown, payload?: unknown } | null | undefined} frame
|
|
77
|
+
* @param {string} stream the relay stream this session listens on
|
|
78
|
+
* @returns {string | null} the steer bytes (as the payload's `chunk` string), or null
|
|
79
|
+
*/
|
|
80
|
+
export function parseInboundRelayChunk(frame, stream) {
|
|
81
|
+
if (!frame || frame.family !== 'relay') return null;
|
|
82
|
+
const payload = frame.payload;
|
|
83
|
+
if (!payload || typeof payload !== 'object') return null;
|
|
84
|
+
if (payload.stream !== stream) return null;
|
|
85
|
+
const chunk = payload.chunk;
|
|
86
|
+
return typeof chunk === 'string' ? chunk : null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* @typedef {object} RelaySession
|
|
91
|
+
* @property {string} stream the relay stream name (derived from the jobKey)
|
|
92
|
+
* @property {(chunk: string|Uint8Array) => void} relay publish one framed, jobKey-tagged output chunk on the relay lane
|
|
93
|
+
* @property {(write: (chunk: string) => void) => (() => void)} attachSteer wire inbound steer bytes for this stream to `write`; returns a detach fn
|
|
94
|
+
* @property {() => void} close detach any steer subscription
|
|
95
|
+
*/
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Create the live-terminal relay session for one job. Ties C2's connected
|
|
99
|
+
* channel to a single job's terminal:
|
|
100
|
+
*
|
|
101
|
+
* - {@link RelaySession.relay} frames each stdout/PTY chunk and streams it on
|
|
102
|
+
* the relay lane tagged with this job's `jobKey` (the stream name), through
|
|
103
|
+
* C2's `channel.relayLane()` sink — so it rides the shared, buffered,
|
|
104
|
+
* QoS-ordered outbound path (and survives a hub outage via C4's ring).
|
|
105
|
+
* - {@link RelaySession.attachSteer} subscribes to inbound relay frames on the
|
|
106
|
+
* same stream (via C2's `channel.client.onFrame`) and hands their bytes to a
|
|
107
|
+
* writer that feeds the harness's PTY — the operator's steer-in.
|
|
108
|
+
*
|
|
109
|
+
* @param {object} opts
|
|
110
|
+
* @param {import('./work-channel.mjs').WorkChannel} opts.channel the C2 channel holder (NOT re-instantiated)
|
|
111
|
+
* @param {string|number} opts.jobKey the activated job's key; tags every frame and names the stream
|
|
112
|
+
* @param {{ warn?: Function, debug?: Function }} [opts.logger]
|
|
113
|
+
* @returns {RelaySession}
|
|
114
|
+
*/
|
|
115
|
+
export function createRelaySession({ channel, jobKey, logger } = {}) {
|
|
116
|
+
if (!channel || typeof channel.relayLane !== 'function') {
|
|
117
|
+
throw new Error('createRelaySession requires a WorkChannel with a relayLane() accessor');
|
|
118
|
+
}
|
|
119
|
+
if (jobKey === undefined || jobKey === null || String(jobKey) === '') {
|
|
120
|
+
throw new Error('createRelaySession requires a jobKey');
|
|
121
|
+
}
|
|
122
|
+
const stream = relayStreamName(jobKey);
|
|
123
|
+
const log = logger || {};
|
|
124
|
+
// Bind the sink once. C2's relayLane() delegates to the single connected
|
|
125
|
+
// client, so relay frames coalesce onto the one buffered outbound ring.
|
|
126
|
+
const sink = channel.relayLane();
|
|
127
|
+
|
|
128
|
+
const relay = (chunk) => {
|
|
129
|
+
if (chunk == null) return;
|
|
130
|
+
const text = typeof chunk === 'string'
|
|
131
|
+
? chunk
|
|
132
|
+
: Buffer.isBuffer(chunk)
|
|
133
|
+
? chunk.toString('utf8')
|
|
134
|
+
: Buffer.from(chunk).toString('utf8');
|
|
135
|
+
if (text === '') return;
|
|
136
|
+
try {
|
|
137
|
+
sink.relay(stream, text);
|
|
138
|
+
} catch (err) {
|
|
139
|
+
try {
|
|
140
|
+
log.warn?.(`relay produce failed for ${stream}: ${err?.message || err}`);
|
|
141
|
+
} catch {
|
|
142
|
+
/* never let a logging failure escape the relay path */
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
// Each attachSteer call owns its own subscription + detach fn; close() tears
|
|
148
|
+
// down every one. Tracking them individually (rather than a single shared
|
|
149
|
+
// handle) means a second attachSteer can't clobber an earlier subscription's
|
|
150
|
+
// detach — every returned fn detaches exactly the subscription it created.
|
|
151
|
+
const activeDetaches = new Set();
|
|
152
|
+
const attachSteer = (write) => {
|
|
153
|
+
if (typeof write !== 'function') return () => {};
|
|
154
|
+
const client = channel.client;
|
|
155
|
+
if (!client || typeof client.onFrame !== 'function') {
|
|
156
|
+
// No inbound frame surface (e.g. a channel without a client) — steer is a
|
|
157
|
+
// no-op rather than a crash; output relay still works.
|
|
158
|
+
return () => {};
|
|
159
|
+
}
|
|
160
|
+
const detachFrame = client.onFrame((frame) => {
|
|
161
|
+
const chunk = parseInboundRelayChunk(frame, stream);
|
|
162
|
+
if (chunk === null) return;
|
|
163
|
+
try {
|
|
164
|
+
write(chunk);
|
|
165
|
+
} catch (err) {
|
|
166
|
+
try {
|
|
167
|
+
log.warn?.(`steer-in write failed for ${stream}: ${err?.message || err}`);
|
|
168
|
+
} catch {
|
|
169
|
+
/* swallow */
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
let detached = false;
|
|
174
|
+
const detach = () => {
|
|
175
|
+
if (detached) return;
|
|
176
|
+
detached = true;
|
|
177
|
+
activeDetaches.delete(detach);
|
|
178
|
+
try {
|
|
179
|
+
detachFrame?.();
|
|
180
|
+
} catch {
|
|
181
|
+
/* swallow */
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
activeDetaches.add(detach);
|
|
185
|
+
return detach;
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const close = () => {
|
|
189
|
+
for (const detach of [...activeDetaches]) detach();
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
return { stream, relay, attachSteer, close };
|
|
193
|
+
}
|