c8ctl-plugin-nano 1.28.0 → 1.29.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 +254 -5
- package/package.json +10 -8
- package/work-relay.mjs +193 -0
package/c8ctl-plugin.js
CHANGED
|
@@ -58,6 +58,7 @@ 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';
|
|
61
62
|
|
|
62
63
|
const requireFromHere = createRequire(import.meta.url);
|
|
63
64
|
const pluginDir = dirname(fileURLToPath(import.meta.url));
|
|
@@ -1514,6 +1515,11 @@ function showConfig() {
|
|
|
1514
1515
|
|
|
1515
1516
|
const RANKS = ['principal', 'senior', 'junior', 'decider'];
|
|
1516
1517
|
|
|
1518
|
+
// C3 (#42): a role's live-terminal mode — a full PTY (streamed + steerable) or a
|
|
1519
|
+
// plain pipe. Default is `pipe` (the safe non-interactive default); `pty` is
|
|
1520
|
+
// opt-in per role because a TTY changes the harness's I/O semantics.
|
|
1521
|
+
const TERMINAL_MODES = ['pipe', 'pty'];
|
|
1522
|
+
|
|
1517
1523
|
/** Normalize a capability list: trim, drop empties, de-dupe, sort (canonical). */
|
|
1518
1524
|
function normalizeCapabilities(input) {
|
|
1519
1525
|
const raw = Array.isArray(input)
|
|
@@ -1732,6 +1738,10 @@ function normalizeStoredProfile(name, profile) {
|
|
|
1732
1738
|
if (CONTAINER_SANDBOXES.has(sandbox) && !image) {
|
|
1733
1739
|
return { error: `profile "${name}" uses sandbox "${sandbox}" but has no image` };
|
|
1734
1740
|
}
|
|
1741
|
+
// C3 (#42): live-terminal mode. Tolerant — an unknown/legacy value falls back
|
|
1742
|
+
// to the safe `pipe` default rather than failing the whole profile.
|
|
1743
|
+
const terminalRaw = typeof profile.terminal === 'string' ? profile.terminal.trim().toLowerCase() : '';
|
|
1744
|
+
const terminal = TERMINAL_MODES.includes(terminalRaw) ? terminalRaw : 'pipe';
|
|
1735
1745
|
return {
|
|
1736
1746
|
profile: {
|
|
1737
1747
|
name,
|
|
@@ -1742,6 +1752,7 @@ function normalizeStoredProfile(name, profile) {
|
|
|
1742
1752
|
capabilities: normalizeCapabilities(profile.capabilities),
|
|
1743
1753
|
sandbox,
|
|
1744
1754
|
image,
|
|
1755
|
+
terminal,
|
|
1745
1756
|
env: normalizeEnvMap(profile.env),
|
|
1746
1757
|
},
|
|
1747
1758
|
};
|
|
@@ -1859,7 +1870,8 @@ async function hireWorker(req, flags) {
|
|
|
1859
1870
|
logger.info('Hired agent profiles:');
|
|
1860
1871
|
for (const name of names.sort()) {
|
|
1861
1872
|
const p = hires[name];
|
|
1862
|
-
|
|
1873
|
+
const term = String(p.terminal || '').trim().toLowerCase() === 'pty' ? '; terminal: pty' : '';
|
|
1874
|
+
logger.info(` ${name} [${p.rank}] ${buildAgentCommandLine(p.command, p.args)} (model: ${p.model || '-'}; caps: ${normalizeCapabilities(p.capabilities).join(', ') || '-'}${term})`);
|
|
1863
1875
|
}
|
|
1864
1876
|
logger.info('');
|
|
1865
1877
|
logger.info('Put one to work with: c8ctl nano work <name>');
|
|
@@ -1875,6 +1887,7 @@ async function hireWorker(req, flags) {
|
|
|
1875
1887
|
let capabilities = flags?.capabilities !== undefined ? flags.capabilities : undefined;
|
|
1876
1888
|
let sandbox = flags?.sandbox !== undefined ? String(flags.sandbox).trim().toLowerCase() : undefined;
|
|
1877
1889
|
let image = flags?.image !== undefined ? String(flags.image).trim() : undefined;
|
|
1890
|
+
let terminal = flags?.terminal !== undefined ? String(flags.terminal).trim().toLowerCase() : undefined;
|
|
1878
1891
|
// Structured command-line switches appended to the command when spawned, e.g.
|
|
1879
1892
|
// `--arg --allow-all` for `copilot`. Repeatable; each --arg is one argv token.
|
|
1880
1893
|
const commandArgs = normalizeArgList(flags?.arg);
|
|
@@ -1952,11 +1965,17 @@ async function hireWorker(req, flags) {
|
|
|
1952
1965
|
if (capabilities === undefined) capabilities = '';
|
|
1953
1966
|
if (sandbox === undefined || sandbox === '') sandbox = 'none';
|
|
1954
1967
|
if (image === undefined) image = '';
|
|
1968
|
+
if (terminal === undefined || terminal === '') terminal = 'pipe';
|
|
1955
1969
|
|
|
1956
1970
|
if (!SANDBOXES.includes(sandbox)) {
|
|
1957
1971
|
logger.error(`Invalid --sandbox "${sandbox}". Use one of: ${SANDBOXES.join(', ')}`);
|
|
1958
1972
|
process.exit(1);
|
|
1959
1973
|
}
|
|
1974
|
+
if (!TERMINAL_MODES.includes(terminal)) {
|
|
1975
|
+
logger.error(`Invalid --terminal "${terminal}". Use one of: ${TERMINAL_MODES.join(', ')}`);
|
|
1976
|
+
process.exit(1);
|
|
1977
|
+
}
|
|
1978
|
+
|
|
1960
1979
|
if (CONTAINER_SANDBOXES.has(sandbox) && !image) {
|
|
1961
1980
|
logger.error(`--sandbox ${sandbox} requires --image <ref> (the container image the agent runs in).`);
|
|
1962
1981
|
process.exit(1);
|
|
@@ -1985,6 +2004,7 @@ async function hireWorker(req, flags) {
|
|
|
1985
2004
|
capabilities: normalizeCapabilities(capabilities),
|
|
1986
2005
|
sandbox,
|
|
1987
2006
|
image: image || '',
|
|
2007
|
+
terminal,
|
|
1988
2008
|
env: profileEnv,
|
|
1989
2009
|
createdAt: new Date().toISOString(),
|
|
1990
2010
|
};
|
|
@@ -1996,6 +2016,7 @@ async function hireWorker(req, flags) {
|
|
|
1996
2016
|
logger.info(` capabilities: ${profile.capabilities.join(', ') || '(none)'}`);
|
|
1997
2017
|
if (profile.args.length > 0) logger.info(` args: ${profile.args.map(shQuote).join(' ')}`);
|
|
1998
2018
|
logger.info(` sandbox: ${profile.sandbox}${CONTAINER_SANDBOXES.has(profile.sandbox) ? ` (image ${profile.image})` : ''}`);
|
|
2019
|
+
logger.info(` live terminal: ${profile.terminal}${profile.terminal === 'pty' ? ' (streamed + steerable on the relay lane)' : ''}`);
|
|
1999
2020
|
const envKeys = Object.keys(profile.env);
|
|
2000
2021
|
if (envKeys.length > 0) logger.info(` env: ${envKeys.join(', ')}`);
|
|
2001
2022
|
logger.info(` job types (${matrix.length}): ${matrix.join(' ')}`);
|
|
@@ -2903,7 +2924,7 @@ const MAX_CAPTURE_BYTES = 1_048_576; // 1 MiB per stream
|
|
|
2903
2924
|
// Spawn a child, pipe `stdinData`, capture byte-capped stdout/stderr, enforce a
|
|
2904
2925
|
// timeout (invoking `onTimeout(child)` to tear the child down), and resolve to a
|
|
2905
2926
|
// 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 }) {
|
|
2927
|
+
function spawnCaptureOneShot({ command, args = [], shell = false, detached = false, cwd, env, stdinData, timeoutMs, idleTimeoutMs, onTimeout, stream = false, streamPrefix = '', onStreamOut, onStreamErr, relayTap = null }) {
|
|
2907
2928
|
return new Promise((resolve) => {
|
|
2908
2929
|
let child;
|
|
2909
2930
|
const stdoutChunks = [];
|
|
@@ -2992,6 +3013,7 @@ function spawnCaptureOneShot({ command, args = [], shell = false, detached = fal
|
|
|
2992
3013
|
armIdle();
|
|
2993
3014
|
const buf = Buffer.isBuffer(d) ? d : Buffer.from(d);
|
|
2994
3015
|
if (teeOut) teeOut(buf.toString('utf8'), false);
|
|
3016
|
+
if (relayTap && typeof relayTap.onData === 'function') relayTap.onData(buf);
|
|
2995
3017
|
const remaining = MAX_CAPTURE_BYTES - stdoutBytes;
|
|
2996
3018
|
if (remaining <= 0) { stdoutTruncated = true; return; }
|
|
2997
3019
|
if (buf.length > remaining) { stdoutChunks.push(buf.subarray(0, remaining)); stdoutBytes = MAX_CAPTURE_BYTES; stdoutTruncated = true; }
|
|
@@ -3001,6 +3023,7 @@ function spawnCaptureOneShot({ command, args = [], shell = false, detached = fal
|
|
|
3001
3023
|
armIdle();
|
|
3002
3024
|
const buf = Buffer.isBuffer(d) ? d : Buffer.from(d);
|
|
3003
3025
|
if (teeErr) teeErr(buf.toString('utf8'), false);
|
|
3026
|
+
if (relayTap && typeof relayTap.onData === 'function') relayTap.onData(buf);
|
|
3004
3027
|
const remaining = MAX_CAPTURE_BYTES - stderrBytes;
|
|
3005
3028
|
if (remaining <= 0) { stderrTruncated = true; return; }
|
|
3006
3029
|
if (buf.length > remaining) { stderrChunks.push(buf.subarray(0, remaining)); stderrBytes = MAX_CAPTURE_BYTES; stderrTruncated = true; }
|
|
@@ -3015,6 +3038,11 @@ function spawnCaptureOneShot({ command, args = [], shell = false, detached = fal
|
|
|
3015
3038
|
});
|
|
3016
3039
|
|
|
3017
3040
|
child.stdin.on('error', () => {});
|
|
3041
|
+
// C3 (#42): pipe mode is one-shot — the job is written to stdin which is then
|
|
3042
|
+
// closed (below), so there is no open channel to feed later steer-in frames
|
|
3043
|
+
// into. We therefore do NOT attach steer-in here: steer-in requires a PTY
|
|
3044
|
+
// (see spawnCapturePty), where stdin stays open for the life of the child.
|
|
3045
|
+
// Pipe-mode roles still stream their output on the relay lane via the tee.
|
|
3018
3046
|
try {
|
|
3019
3047
|
if (stdinData != null) child.stdin.write(stdinData);
|
|
3020
3048
|
child.stdin.end();
|
|
@@ -3022,6 +3050,152 @@ function spawnCaptureOneShot({ command, args = [], shell = false, detached = fal
|
|
|
3022
3050
|
});
|
|
3023
3051
|
}
|
|
3024
3052
|
|
|
3053
|
+
// ---- PTY capture (C3 #42 — full terminal for roles opted into `terminal: pty`)
|
|
3054
|
+
// node-pty is a NATIVE, OPTIONAL dependency: a role that runs its harness on a
|
|
3055
|
+
// real PTY needs it, but the vast majority of workers run on plain pipes, and we
|
|
3056
|
+
// must never let a missing/failed native build break `npm install` or the test
|
|
3057
|
+
// suite on stock Node. It is therefore an optionalDependency, lazily required
|
|
3058
|
+
// only when a PTY role actually runs, and memoized. Returns null when it is not
|
|
3059
|
+
// installed so the caller can fall back to a pipe.
|
|
3060
|
+
let ptyModuleCache; // undefined = not tried; null = unavailable; object = loaded
|
|
3061
|
+
function loadPtyModule() {
|
|
3062
|
+
if (ptyModuleCache !== undefined) return ptyModuleCache;
|
|
3063
|
+
try {
|
|
3064
|
+
ptyModuleCache = requireFromHere('node-pty');
|
|
3065
|
+
} catch {
|
|
3066
|
+
ptyModuleCache = null;
|
|
3067
|
+
}
|
|
3068
|
+
return ptyModuleCache;
|
|
3069
|
+
}
|
|
3070
|
+
|
|
3071
|
+
/**
|
|
3072
|
+
* Whether a real PTY can be allocated on this host: node-pty is installed AND we
|
|
3073
|
+
* are on a POSIX platform (the PTY path spawns `sh -c <commandLine>`, mirroring
|
|
3074
|
+
* the container executor; Windows conpty is out of scope for this slice).
|
|
3075
|
+
*/
|
|
3076
|
+
function ptyAvailable(ptyFactory) {
|
|
3077
|
+
if (process.platform === 'win32') return false;
|
|
3078
|
+
// An injected factory only counts if it actually looks like a node-pty
|
|
3079
|
+
// factory (has a spawn()); a bad injection degrades to the pipe fallback
|
|
3080
|
+
// rather than routing to the PTY path and failing the job.
|
|
3081
|
+
if (ptyFactory) return typeof ptyFactory.spawn === 'function';
|
|
3082
|
+
return loadPtyModule() != null;
|
|
3083
|
+
}
|
|
3084
|
+
|
|
3085
|
+
// Spawn the harness on a PTY, capture byte-capped output for the job result,
|
|
3086
|
+
// tee every chunk to the relay tap (framed + jobKey-tagged by the caller), and
|
|
3087
|
+
// feed steer-in bytes back into the PTY. Same result contract as
|
|
3088
|
+
// spawnCaptureOneShot. A PTY merges stdout+stderr into one stream, so stderr is
|
|
3089
|
+
// always '' here; that is expected for a live terminal. `ptyFactory` is
|
|
3090
|
+
// injectable for tests (defaults to node-pty).
|
|
3091
|
+
function spawnCapturePty({ command, args = [], cwd, env, stdinData, timeoutMs, idleTimeoutMs, cols = 120, rows = 30, ptyFactory, relayTap = null, stream = false, streamPrefix = '', onStreamOut }) {
|
|
3092
|
+
return new Promise((resolve) => {
|
|
3093
|
+
const factory = ptyFactory || loadPtyModule();
|
|
3094
|
+
if (!factory || typeof factory.spawn !== 'function') {
|
|
3095
|
+
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 });
|
|
3096
|
+
return;
|
|
3097
|
+
}
|
|
3098
|
+
|
|
3099
|
+
const chunks = [];
|
|
3100
|
+
let bytes = 0;
|
|
3101
|
+
let truncated = false;
|
|
3102
|
+
let settled = false;
|
|
3103
|
+
let timer = null;
|
|
3104
|
+
let idleTimer = null;
|
|
3105
|
+
let detachSteer = null;
|
|
3106
|
+
let term;
|
|
3107
|
+
|
|
3108
|
+
// Live "spy" tee (--stream), line-buffered, mirroring spawnCaptureOneShot.
|
|
3109
|
+
const STREAM_TEE_LINE_CAP = 64 * 1024;
|
|
3110
|
+
let teePartial = '';
|
|
3111
|
+
const teeSink = stream ? (onStreamOut || ((line) => process.stdout.write(`${line}\n`))) : null;
|
|
3112
|
+
const tee = (text, final) => {
|
|
3113
|
+
if (!teeSink) return;
|
|
3114
|
+
teePartial += text;
|
|
3115
|
+
let nl;
|
|
3116
|
+
while ((nl = teePartial.indexOf('\n')) !== -1) {
|
|
3117
|
+
teeSink(`${streamPrefix}${teePartial.slice(0, nl)}`);
|
|
3118
|
+
teePartial = teePartial.slice(nl + 1);
|
|
3119
|
+
}
|
|
3120
|
+
while (teePartial.length >= STREAM_TEE_LINE_CAP) {
|
|
3121
|
+
teeSink(`${streamPrefix}${teePartial.slice(0, STREAM_TEE_LINE_CAP)}`);
|
|
3122
|
+
teePartial = teePartial.slice(STREAM_TEE_LINE_CAP);
|
|
3123
|
+
}
|
|
3124
|
+
if (final && teePartial) { teeSink(`${streamPrefix}${teePartial}`); teePartial = ''; }
|
|
3125
|
+
};
|
|
3126
|
+
|
|
3127
|
+
const killTerm = () => {
|
|
3128
|
+
try { term?.kill(); } catch { /* already gone */ }
|
|
3129
|
+
};
|
|
3130
|
+
|
|
3131
|
+
const finish = (result) => {
|
|
3132
|
+
if (settled) return;
|
|
3133
|
+
settled = true;
|
|
3134
|
+
if (timer) clearTimeout(timer);
|
|
3135
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
3136
|
+
if (detachSteer) { try { detachSteer(); } catch { /* best effort */ } detachSteer = null; }
|
|
3137
|
+
if (teeSink) tee('', true);
|
|
3138
|
+
resolve(result);
|
|
3139
|
+
};
|
|
3140
|
+
|
|
3141
|
+
try {
|
|
3142
|
+
term = factory.spawn(command, args, { name: 'xterm-256color', cols, rows, cwd, env });
|
|
3143
|
+
} catch (err) {
|
|
3144
|
+
finish({ ok: false, exitCode: null, stdout: '', stderr: '', error: `pty spawn failed: ${err?.message || err}`, truncated: false, stderrTruncated: false });
|
|
3145
|
+
return;
|
|
3146
|
+
}
|
|
3147
|
+
|
|
3148
|
+
timer = timeoutMs && timeoutMs > 0
|
|
3149
|
+
? setTimeout(() => {
|
|
3150
|
+
killTerm();
|
|
3151
|
+
finish({ ok: false, exitCode: null, stdout: joinCapped(chunks), stderr: '', error: `timed out after ${timeoutMs}ms`, timedOut: true, truncated, stderrTruncated: false });
|
|
3152
|
+
}, timeoutMs)
|
|
3153
|
+
: null;
|
|
3154
|
+
|
|
3155
|
+
const armIdle = () => {
|
|
3156
|
+
if (settled) return;
|
|
3157
|
+
if (!(idleTimeoutMs && idleTimeoutMs > 0)) return;
|
|
3158
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
3159
|
+
idleTimer = setTimeout(() => {
|
|
3160
|
+
killTerm();
|
|
3161
|
+
finish({ ok: false, exitCode: null, stdout: joinCapped(chunks), stderr: '', error: `no output for ${idleTimeoutMs}ms (idle)`, timedOut: true, idle: true, truncated, stderrTruncated: false });
|
|
3162
|
+
}, idleTimeoutMs);
|
|
3163
|
+
};
|
|
3164
|
+
armIdle();
|
|
3165
|
+
|
|
3166
|
+
term.onData((d) => {
|
|
3167
|
+
armIdle();
|
|
3168
|
+
const buf = Buffer.isBuffer(d) ? d : Buffer.from(String(d), 'utf8');
|
|
3169
|
+
if (teeSink) tee(buf.toString('utf8'), false);
|
|
3170
|
+
if (relayTap && typeof relayTap.onData === 'function') relayTap.onData(buf);
|
|
3171
|
+
const remaining = MAX_CAPTURE_BYTES - bytes;
|
|
3172
|
+
if (remaining <= 0) { truncated = true; return; }
|
|
3173
|
+
if (buf.length > remaining) { chunks.push(buf.subarray(0, remaining)); bytes = MAX_CAPTURE_BYTES; truncated = true; }
|
|
3174
|
+
else { chunks.push(buf); bytes += buf.length; }
|
|
3175
|
+
});
|
|
3176
|
+
|
|
3177
|
+
term.onExit(({ exitCode, signal }) => {
|
|
3178
|
+
finish({ ok: exitCode === 0, exitCode: typeof exitCode === 'number' ? exitCode : null, signal: signal || null, stdout: joinCapped(chunks), stderr: '', truncated, stderrTruncated: false });
|
|
3179
|
+
});
|
|
3180
|
+
|
|
3181
|
+
// Steer-in: write cockpit bytes straight into the PTY so an operator can
|
|
3182
|
+
// drive the running agent.
|
|
3183
|
+
if (relayTap && typeof relayTap.attachSteer === 'function') {
|
|
3184
|
+
detachSteer = relayTap.attachSteer((data) => {
|
|
3185
|
+
try { term.write(typeof data === 'string' ? data : Buffer.from(data).toString('utf8')); } catch { /* term gone */ }
|
|
3186
|
+
});
|
|
3187
|
+
}
|
|
3188
|
+
|
|
3189
|
+
// Deliver the task envelope on the PTY, then an EOT (Ctrl-D) so a harness
|
|
3190
|
+
// that reads its payload from stdin sees an end-of-input, while the PTY
|
|
3191
|
+
// itself stays open for interactive steer-in.
|
|
3192
|
+
try {
|
|
3193
|
+
if (stdinData != null) term.write(String(stdinData));
|
|
3194
|
+
term.write('\x04');
|
|
3195
|
+
} catch { /* onExit resolves on failure */ }
|
|
3196
|
+
});
|
|
3197
|
+
}
|
|
3198
|
+
|
|
3025
3199
|
function buildAgentPayload(profile, job, envelope) {
|
|
3026
3200
|
const variables = job.variables && typeof job.variables === 'object' ? job.variables : {};
|
|
3027
3201
|
return {
|
|
@@ -3101,7 +3275,7 @@ function startLockExtender(job, windowMs, intervalMs, tag, logger) {
|
|
|
3101
3275
|
* Both paths resolve to the same result contract.
|
|
3102
3276
|
*/
|
|
3103
3277
|
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;
|
|
3278
|
+
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
3279
|
const payload = JSON.stringify(buildAgentPayload(profile, job, envelope));
|
|
3106
3280
|
const agentEnv = baseAgentEnv(profile, job);
|
|
3107
3281
|
// The harness command line: the profile command plus its structured switches
|
|
@@ -3114,6 +3288,16 @@ function runAgentJob(profile, job, opts = {}) {
|
|
|
3114
3288
|
// resolved secrets are layered on top so user env can never shadow them.
|
|
3115
3289
|
const staticEnv = { ...normalizeEnvMap(profileEnv), ...normalizeEnvMap(envelope?.setup?.env) };
|
|
3116
3290
|
|
|
3291
|
+
// C3 (#42): when a relay session is present, tap the harness terminal onto the
|
|
3292
|
+
// relay lane (framed + tagged with this job's jobKey) and accept steer-in. The
|
|
3293
|
+
// tap is inert when there is no session, preserving legacy behaviour exactly.
|
|
3294
|
+
const relayTap = relaySession
|
|
3295
|
+
? {
|
|
3296
|
+
onData: (buf) => relaySession.relay(buf),
|
|
3297
|
+
attachSteer: (write) => relaySession.attachSteer(write),
|
|
3298
|
+
}
|
|
3299
|
+
: null;
|
|
3300
|
+
|
|
3117
3301
|
if (!CONTAINER_SANDBOXES.has(sandbox)) {
|
|
3118
3302
|
// Host: hand the agent the result file by its real path.
|
|
3119
3303
|
// Defense in depth: --arg tokens are POSIX single-quoted, which cmd.exe on
|
|
@@ -3124,6 +3308,29 @@ function runAgentJob(profile, job, opts = {}) {
|
|
|
3124
3308
|
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
3309
|
}
|
|
3126
3310
|
const resultEnv = resultFile ? { [AGENT_RESULT_FILE_ENV]: resultFile } : {};
|
|
3311
|
+
const harnessEnv = { ...process.env, ...staticEnv, ...secretEnv, ...agentEnv, ...extraEnv, ...resultEnv };
|
|
3312
|
+
|
|
3313
|
+
// A role opted into a full PTY (`terminal: pty`) runs the harness on a real
|
|
3314
|
+
// terminal when one can be allocated — so its live output streams as a true
|
|
3315
|
+
// terminal and cockpit steer-in reaches it. Falls back to a pipe (still
|
|
3316
|
+
// relayed) when node-pty is unavailable or on Windows.
|
|
3317
|
+
if (terminal === 'pty' && ptyAvailable(ptyFactory)) {
|
|
3318
|
+
return spawnCapturePty({
|
|
3319
|
+
command: 'sh',
|
|
3320
|
+
args: ['-c', commandLine],
|
|
3321
|
+
cwd,
|
|
3322
|
+
env: harnessEnv,
|
|
3323
|
+
stdinData: payload,
|
|
3324
|
+
timeoutMs,
|
|
3325
|
+
idleTimeoutMs,
|
|
3326
|
+
ptyFactory,
|
|
3327
|
+
relayTap,
|
|
3328
|
+
stream,
|
|
3329
|
+
streamPrefix,
|
|
3330
|
+
onStreamOut,
|
|
3331
|
+
});
|
|
3332
|
+
}
|
|
3333
|
+
|
|
3127
3334
|
return spawnCaptureOneShot({
|
|
3128
3335
|
command: commandLine,
|
|
3129
3336
|
shell: true,
|
|
@@ -3133,7 +3340,7 @@ function runAgentJob(profile, job, opts = {}) {
|
|
|
3133
3340
|
cwd,
|
|
3134
3341
|
// Reserved harness env (AGENT_* + the result-file path) is layered AFTER
|
|
3135
3342
|
// resolved secrets so a task-supplied secret NAME can never shadow it.
|
|
3136
|
-
env:
|
|
3343
|
+
env: harnessEnv,
|
|
3137
3344
|
stdinData: payload,
|
|
3138
3345
|
timeoutMs,
|
|
3139
3346
|
idleTimeoutMs,
|
|
@@ -3142,6 +3349,7 @@ function runAgentJob(profile, job, opts = {}) {
|
|
|
3142
3349
|
streamPrefix,
|
|
3143
3350
|
onStreamOut,
|
|
3144
3351
|
onStreamErr,
|
|
3352
|
+
relayTap,
|
|
3145
3353
|
});
|
|
3146
3354
|
}
|
|
3147
3355
|
|
|
@@ -3197,6 +3405,7 @@ function runAgentJob(profile, job, opts = {}) {
|
|
|
3197
3405
|
streamPrefix,
|
|
3198
3406
|
onStreamOut,
|
|
3199
3407
|
onStreamErr,
|
|
3408
|
+
relayTap,
|
|
3200
3409
|
onTimeout: (child) => {
|
|
3201
3410
|
try { spawnSync(engine, ['rm', '-f', containerName], { timeout: 15_000 }); } catch { /* best effort */ }
|
|
3202
3411
|
try { killTree(child); } catch { /* best effort */ }
|
|
@@ -3567,6 +3776,22 @@ async function workAgent(req, flags) {
|
|
|
3567
3776
|
logger.info(' agentic channel: not enrolled (set NANO_AGENTIC_URL + NANO_AGENTIC_TOKEN + NANO_AGENTIC_CREDENTIAL to appear on the visibility page).');
|
|
3568
3777
|
}
|
|
3569
3778
|
|
|
3779
|
+
// C3 (#42): the role's live-terminal mode — a full PTY (streamed on the relay
|
|
3780
|
+
// lane when a relay session exists, steerable) or a plain pipe. Honors the
|
|
3781
|
+
// vocab's per-role opt-in read off the hire profile (`terminal: pty|pipe`),
|
|
3782
|
+
// with an env override for a one-off worker (`NANO_AGENTIC_TERMINAL`). The PTY
|
|
3783
|
+
// itself is allocated locally regardless of enrollment; relay streaming (and
|
|
3784
|
+
// steer-in) only engages when the worker is enrolled on the channel, so
|
|
3785
|
+
// without the channel there's simply no relay tap — the harness still runs on
|
|
3786
|
+
// the chosen local transport.
|
|
3787
|
+
const envTerminal = (process.env.NANO_AGENTIC_TERMINAL || '').trim().toLowerCase();
|
|
3788
|
+
const roleTerminal = (envTerminal === 'pty' || envTerminal === 'pipe')
|
|
3789
|
+
? envTerminal
|
|
3790
|
+
: roleTerminalMode(profile);
|
|
3791
|
+
if (workChannel) {
|
|
3792
|
+
logger.info(` live terminal: ${roleTerminal === 'pty' ? 'PTY (streamed + steerable)' : 'pipe (streamed)'} on the relay lane.`);
|
|
3793
|
+
}
|
|
3794
|
+
|
|
3570
3795
|
// A per-job-type worker factory. Captures all the CLI-local + profile context
|
|
3571
3796
|
// in closure scope so the profile watcher below can (re)spawn a poller for any
|
|
3572
3797
|
// job type on demand without re-reading the flags.
|
|
@@ -3658,6 +3883,19 @@ async function workAgent(req, flags) {
|
|
|
3658
3883
|
|
|
3659
3884
|
let result;
|
|
3660
3885
|
let gitResult = null;
|
|
3886
|
+
// C3 (#42): the per-job live-terminal relay session. Streams this job's
|
|
3887
|
+
// harness terminal on the relay lane tagged with its jobKey, and accepts
|
|
3888
|
+
// steer-in. Only when the worker is enrolled on the channel; closed in
|
|
3889
|
+
// the finally so its inbound-frame subscription never leaks across jobs.
|
|
3890
|
+
let relaySession = null;
|
|
3891
|
+
if (workChannel) {
|
|
3892
|
+
try {
|
|
3893
|
+
relaySession = createRelaySession({ channel: workChannel, jobKey: job.jobKey, logger });
|
|
3894
|
+
} catch (err) {
|
|
3895
|
+
relaySession = null;
|
|
3896
|
+
logger.warn(`[${jobType}] job ${job.jobKey}: relay session unavailable (${err?.message || err}); continuing without live terminal.`);
|
|
3897
|
+
}
|
|
3898
|
+
}
|
|
3661
3899
|
// Private structured-result channel: hand the agent a file (outside any
|
|
3662
3900
|
// repo clone so it can't be `git add`ed) to write its job-result vars to.
|
|
3663
3901
|
let resultDir = null;
|
|
@@ -3688,6 +3926,11 @@ async function workAgent(req, flags) {
|
|
|
3688
3926
|
stream,
|
|
3689
3927
|
streamPrefix: `[${jobType} ${job.jobKey}] `,
|
|
3690
3928
|
args: effectiveArgs,
|
|
3929
|
+
// C3 (#42): a full PTY for a role that opted in, else a pipe. Both
|
|
3930
|
+
// stream on the relay lane when a relay session exists (skipped when
|
|
3931
|
+
// relaySession is null); only a PTY is interactively steerable.
|
|
3932
|
+
terminal: roleTerminal,
|
|
3933
|
+
relaySession,
|
|
3691
3934
|
// Route the --stream tee through c8ctl's output-mode-aware logger so
|
|
3692
3935
|
// spying never corrupts a structured/JSON output mode.
|
|
3693
3936
|
onStreamOut: stream ? (line) => logger.info(line) : undefined,
|
|
@@ -3716,6 +3959,9 @@ async function workAgent(req, flags) {
|
|
|
3716
3959
|
if (isContainer) liveRunIds.delete(runId);
|
|
3717
3960
|
if (runDir && !keepRuns) { try { rmSync(runDir, { recursive: true, force: true }); } catch { /* best effort */ } }
|
|
3718
3961
|
if (runDir) liveRunDirs.delete(runDir);
|
|
3962
|
+
// Detach the relay session's inbound-frame subscription so it never
|
|
3963
|
+
// outlives the job or leaks a steer listener across jobs.
|
|
3964
|
+
if (relaySession) { try { relaySession.close(); } catch { /* best effort */ } }
|
|
3719
3965
|
}
|
|
3720
3966
|
|
|
3721
3967
|
// Read the agent's structured result: the file it wrote, else a stdout
|
|
@@ -6638,6 +6884,7 @@ export {
|
|
|
6638
6884
|
diskBudgetOk,
|
|
6639
6885
|
containerEngineAvailable,
|
|
6640
6886
|
runAgentJob,
|
|
6887
|
+
spawnCapturePty,
|
|
6641
6888
|
startLockExtender,
|
|
6642
6889
|
provisionRepo,
|
|
6643
6890
|
finalizeGit,
|
|
@@ -6793,6 +7040,7 @@ export const commands = {
|
|
|
6793
7040
|
capabilities: { type: 'string', description: 'hire/assign: comma-separated capability list' },
|
|
6794
7041
|
sandbox: { type: 'string', description: 'hire/work: execution sandbox none|docker|podman (default none). Containers isolate each job.' },
|
|
6795
7042
|
image: { type: 'string', description: 'hire/work: container image the agent runs in (required for --sandbox docker|podman)' },
|
|
7043
|
+
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
7044
|
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
7045
|
'secret-resolver': { type: 'string', description: 'work: secret resolver for task secretRefs (host = process env; default host)' },
|
|
6798
7046
|
'reap-age': { type: 'string', description: 'work: age in ms before a finished agent container or job workspace is reaped (default 3600000)' },
|
|
@@ -6964,7 +7212,7 @@ function printUsage() {
|
|
|
6964
7212
|
console.log(' c8ctl nano unset <bin|model-dir>');
|
|
6965
7213
|
console.log(' c8ctl nano config');
|
|
6966
7214
|
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]');
|
|
7215
|
+
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
7216
|
console.log(' c8ctl nano assign <profileName> <cap[,cap...]> [--name <n>] [--capabilities <a,b>]');
|
|
6969
7217
|
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
7218
|
console.log(' c8ctl nano supervisor [start|status|add|remove|restart|stop|logs|attach] ... (manage many workers from one terminal)');
|
|
@@ -7008,6 +7256,7 @@ function printUsage() {
|
|
|
7008
7256
|
console.log(' --capabilities <a,b> hire/assign: comma-separated capability list');
|
|
7009
7257
|
console.log(' --sandbox <s> hire/work: execution sandbox none|docker|podman (default none)');
|
|
7010
7258
|
console.log(' --image <ref> hire/work: container image the agent runs in (required for docker|podman)');
|
|
7259
|
+
console.log(' --terminal <m> hire: live-terminal mode pty|pipe (default pipe); pty streams a steerable terminal on the relay lane');
|
|
7011
7260
|
console.log(' --env NAME=VALUE hire/work: static env var for the harness (repeatable); persisted on hire, work extends/overrides');
|
|
7012
7261
|
console.log(' --list hire: list existing agent profiles instead of creating one');
|
|
7013
7262
|
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.29.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,7 @@
|
|
|
25
25
|
"agentic.mjs",
|
|
26
26
|
"agentic-loader-hook.mjs",
|
|
27
27
|
"work-channel.mjs",
|
|
28
|
+
"work-relay.mjs",
|
|
28
29
|
"nanobpmn-binary.json",
|
|
29
30
|
"README.md"
|
|
30
31
|
],
|
|
@@ -54,12 +55,13 @@
|
|
|
54
55
|
"@nanobpm/urban-agent-client": "^0.1.0"
|
|
55
56
|
},
|
|
56
57
|
"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-
|
|
58
|
+
"node-pty": "^1.0.0",
|
|
59
|
+
"@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.29.0",
|
|
60
|
+
"@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.29.0",
|
|
61
|
+
"@nanobpm/c8ctl-plugin-nano-linux-x64": "1.29.0",
|
|
62
|
+
"@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.29.0",
|
|
63
|
+
"@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.29.0",
|
|
64
|
+
"@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.29.0",
|
|
65
|
+
"@nanobpm/c8ctl-plugin-nano-win32-x64": "1.29.0"
|
|
64
66
|
}
|
|
65
67
|
}
|
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
|
+
}
|