c8ctl-plugin-nano 1.27.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/agentic-loader-hook.mjs +42 -0
- package/agentic.mjs +30 -3
- package/c8ctl-plugin.js +341 -7
- package/package.json +12 -8
- package/work-channel.mjs +290 -0
- package/work-relay.mjs +193 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Module-customization RESOLVE hook (Node `module.register`) — the single
|
|
2
|
+
// mechanism that makes `@nanobpm/urban-agent-client` loadable under stock Node.
|
|
3
|
+
//
|
|
4
|
+
// Why this exists (the C0 constraint, jwulf/c8ctl-plugin-nano#39):
|
|
5
|
+
// the published worker client funnels the S0 wire contract through
|
|
6
|
+
// `dist/protocol.js`, which imports `@nanobpm/agentic/source/protocol` — raw
|
|
7
|
+
// TypeScript — on the assumption the consumer runs under a type-stripping
|
|
8
|
+
// loader. This repo runs on stock Node (`node --test`, `nano work`), which
|
|
9
|
+
// REFUSES to type-strip `.ts` under `node_modules`
|
|
10
|
+
// (ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING), so a bare `import` of the
|
|
11
|
+
// client throws before any of C2's channel code can run.
|
|
12
|
+
//
|
|
13
|
+
// The redirect below rewrites every `@nanobpm/agentic/source/*` specifier to
|
|
14
|
+
// the package's COMPILED `@nanobpm/agentic/*` `dist` export. Source and dist are
|
|
15
|
+
// the same S0 contract — both are held to the one shared conformance corpus
|
|
16
|
+
// (`agentic-conformance.test.mjs`) — so this is a pure packaging redirect, not a
|
|
17
|
+
// behavioural change: the client ends up bound to the exact codec/grammar this
|
|
18
|
+
// repo already runs green. When the client is republished to import agentic's
|
|
19
|
+
// `dist` directly, this hook becomes a no-op and can be retired.
|
|
20
|
+
//
|
|
21
|
+
// Registered lazily from `loadAgenticClient()` in `agentic.mjs` (the single
|
|
22
|
+
// client swap point) so it is active before the client's module graph loads.
|
|
23
|
+
|
|
24
|
+
const SOURCE_PREFIX = '@nanobpm/agentic/source/';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Node ESM resolve hook. Redirects the client's raw-`.ts` source imports to the
|
|
28
|
+
* compiled dist subpath exports; passes everything else through untouched.
|
|
29
|
+
*
|
|
30
|
+
* @param {string} specifier the requested module specifier
|
|
31
|
+
* @param {import('node:module').ResolveHookContext} context resolution context
|
|
32
|
+
* @param {(s: string, c?: object) => unknown} next the next hook in the chain
|
|
33
|
+
*/
|
|
34
|
+
export async function resolve(specifier, context, next) {
|
|
35
|
+
if (specifier === '@nanobpm/agentic/source') {
|
|
36
|
+
return next('@nanobpm/agentic', context);
|
|
37
|
+
}
|
|
38
|
+
if (specifier.startsWith(SOURCE_PREFIX)) {
|
|
39
|
+
return next(`@nanobpm/agentic/${specifier.slice(SOURCE_PREFIX.length)}`, context);
|
|
40
|
+
}
|
|
41
|
+
return next(specifier, context);
|
|
42
|
+
}
|
package/agentic.mjs
CHANGED
|
@@ -15,6 +15,10 @@
|
|
|
15
15
|
// this repo's consumption in lock-step with the hub — see
|
|
16
16
|
// `agentic-conformance.test.mjs`.
|
|
17
17
|
|
|
18
|
+
// Node's module-customization registrar, used lazily by `loadAgenticClient()`
|
|
19
|
+
// to install the source→dist resolve hook before the worker client loads.
|
|
20
|
+
import { register as moduleRegister } from 'node:module';
|
|
21
|
+
|
|
18
22
|
// ---------------------------------------------------------------------------
|
|
19
23
|
// Wire contract — @nanobpm/agentic/protocol (S0, the single source of truth).
|
|
20
24
|
// The codec, routing-token grammar, vocab schema and per-family payload
|
|
@@ -80,14 +84,36 @@ export * as transcript from '@nanobpm/agentic/transcript';
|
|
|
80
84
|
// (ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING), so a *static* re-export of the
|
|
81
85
|
// client would make this whole surface fail to load. To keep C0's surface
|
|
82
86
|
// loadable everywhere while still routing all client consumption through one
|
|
83
|
-
// swap point, the client is exposed behind a lazy async loader.
|
|
84
|
-
//
|
|
85
|
-
//
|
|
87
|
+
// swap point, the client is exposed behind a lazy async loader.
|
|
88
|
+
//
|
|
89
|
+
// C2 (#41) resolves that constraint HERE, at the single swap point: before the
|
|
90
|
+
// client's module graph loads, `loadAgenticClient()` registers a resolve hook
|
|
91
|
+
// (`agentic-loader-hook.mjs`) that redirects the client's raw-`.ts`
|
|
92
|
+
// `@nanobpm/agentic/source/*` imports to the compiled `@nanobpm/agentic/*`
|
|
93
|
+
// `dist` exports. Source and dist are the same S0 contract (one shared
|
|
94
|
+
// conformance corpus), so the client loads and runs under stock Node with no
|
|
95
|
+
// change in behaviour. When the client is republished to import agentic's dist
|
|
96
|
+
// directly, the redirect self-neutralises.
|
|
86
97
|
//
|
|
87
98
|
// @typedef {import('@nanobpm/urban-agent-client')} AgenticClientModule
|
|
88
99
|
/** @type {Promise<AgenticClientModule> | undefined} */
|
|
89
100
|
let clientModulePromise;
|
|
90
101
|
|
|
102
|
+
// Guards single registration of the source→dist resolve hook (idempotent).
|
|
103
|
+
let sourceRedirectRegistered = false;
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Register the `@nanobpm/agentic/source/*` → `dist` resolve hook exactly once,
|
|
107
|
+
* so the worker client is importable under stock Node. Safe to call repeatedly;
|
|
108
|
+
* only the first call registers. Runs before any `import()` of the client so the
|
|
109
|
+
* hook is active for the client's whole module graph.
|
|
110
|
+
*/
|
|
111
|
+
function ensureClientLoadable() {
|
|
112
|
+
if (sourceRedirectRegistered) return;
|
|
113
|
+
sourceRedirectRegistered = true;
|
|
114
|
+
moduleRegister('./agentic-loader-hook.mjs', import.meta.url);
|
|
115
|
+
}
|
|
116
|
+
|
|
91
117
|
/**
|
|
92
118
|
* Load the published worker-side agentic channel client
|
|
93
119
|
* (`@nanobpm/urban-agent-client`). Memoised so repeated calls share one module
|
|
@@ -100,6 +126,7 @@ let clientModulePromise;
|
|
|
100
126
|
*/
|
|
101
127
|
export function loadAgenticClient() {
|
|
102
128
|
if (clientModulePromise === undefined) {
|
|
129
|
+
ensureClientLoadable();
|
|
103
130
|
clientModulePromise = import('@nanobpm/urban-agent-client');
|
|
104
131
|
}
|
|
105
132
|
return clientModulePromise;
|
package/c8ctl-plugin.js
CHANGED
|
@@ -57,6 +57,8 @@ import { fileURLToPath } from 'node:url';
|
|
|
57
57
|
import { createInterface } from 'node:readline/promises';
|
|
58
58
|
import { createInterface as createReadline } from 'node:readline';
|
|
59
59
|
import { platformForHost } from './platforms.mjs';
|
|
60
|
+
import { createWorkChannel, redactAgenticUrl, buildAgenticUrl } from './work-channel.mjs';
|
|
61
|
+
import { createRelaySession, roleTerminalMode } from './work-relay.mjs';
|
|
60
62
|
|
|
61
63
|
const requireFromHere = createRequire(import.meta.url);
|
|
62
64
|
const pluginDir = dirname(fileURLToPath(import.meta.url));
|
|
@@ -1513,6 +1515,11 @@ function showConfig() {
|
|
|
1513
1515
|
|
|
1514
1516
|
const RANKS = ['principal', 'senior', 'junior', 'decider'];
|
|
1515
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
|
+
|
|
1516
1523
|
/** Normalize a capability list: trim, drop empties, de-dupe, sort (canonical). */
|
|
1517
1524
|
function normalizeCapabilities(input) {
|
|
1518
1525
|
const raw = Array.isArray(input)
|
|
@@ -1731,6 +1738,10 @@ function normalizeStoredProfile(name, profile) {
|
|
|
1731
1738
|
if (CONTAINER_SANDBOXES.has(sandbox) && !image) {
|
|
1732
1739
|
return { error: `profile "${name}" uses sandbox "${sandbox}" but has no image` };
|
|
1733
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';
|
|
1734
1745
|
return {
|
|
1735
1746
|
profile: {
|
|
1736
1747
|
name,
|
|
@@ -1741,6 +1752,7 @@ function normalizeStoredProfile(name, profile) {
|
|
|
1741
1752
|
capabilities: normalizeCapabilities(profile.capabilities),
|
|
1742
1753
|
sandbox,
|
|
1743
1754
|
image,
|
|
1755
|
+
terminal,
|
|
1744
1756
|
env: normalizeEnvMap(profile.env),
|
|
1745
1757
|
},
|
|
1746
1758
|
};
|
|
@@ -1858,7 +1870,8 @@ async function hireWorker(req, flags) {
|
|
|
1858
1870
|
logger.info('Hired agent profiles:');
|
|
1859
1871
|
for (const name of names.sort()) {
|
|
1860
1872
|
const p = hires[name];
|
|
1861
|
-
|
|
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})`);
|
|
1862
1875
|
}
|
|
1863
1876
|
logger.info('');
|
|
1864
1877
|
logger.info('Put one to work with: c8ctl nano work <name>');
|
|
@@ -1874,6 +1887,7 @@ async function hireWorker(req, flags) {
|
|
|
1874
1887
|
let capabilities = flags?.capabilities !== undefined ? flags.capabilities : undefined;
|
|
1875
1888
|
let sandbox = flags?.sandbox !== undefined ? String(flags.sandbox).trim().toLowerCase() : undefined;
|
|
1876
1889
|
let image = flags?.image !== undefined ? String(flags.image).trim() : undefined;
|
|
1890
|
+
let terminal = flags?.terminal !== undefined ? String(flags.terminal).trim().toLowerCase() : undefined;
|
|
1877
1891
|
// Structured command-line switches appended to the command when spawned, e.g.
|
|
1878
1892
|
// `--arg --allow-all` for `copilot`. Repeatable; each --arg is one argv token.
|
|
1879
1893
|
const commandArgs = normalizeArgList(flags?.arg);
|
|
@@ -1951,11 +1965,17 @@ async function hireWorker(req, flags) {
|
|
|
1951
1965
|
if (capabilities === undefined) capabilities = '';
|
|
1952
1966
|
if (sandbox === undefined || sandbox === '') sandbox = 'none';
|
|
1953
1967
|
if (image === undefined) image = '';
|
|
1968
|
+
if (terminal === undefined || terminal === '') terminal = 'pipe';
|
|
1954
1969
|
|
|
1955
1970
|
if (!SANDBOXES.includes(sandbox)) {
|
|
1956
1971
|
logger.error(`Invalid --sandbox "${sandbox}". Use one of: ${SANDBOXES.join(', ')}`);
|
|
1957
1972
|
process.exit(1);
|
|
1958
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
|
+
|
|
1959
1979
|
if (CONTAINER_SANDBOXES.has(sandbox) && !image) {
|
|
1960
1980
|
logger.error(`--sandbox ${sandbox} requires --image <ref> (the container image the agent runs in).`);
|
|
1961
1981
|
process.exit(1);
|
|
@@ -1984,6 +2004,7 @@ async function hireWorker(req, flags) {
|
|
|
1984
2004
|
capabilities: normalizeCapabilities(capabilities),
|
|
1985
2005
|
sandbox,
|
|
1986
2006
|
image: image || '',
|
|
2007
|
+
terminal,
|
|
1987
2008
|
env: profileEnv,
|
|
1988
2009
|
createdAt: new Date().toISOString(),
|
|
1989
2010
|
};
|
|
@@ -1995,6 +2016,7 @@ async function hireWorker(req, flags) {
|
|
|
1995
2016
|
logger.info(` capabilities: ${profile.capabilities.join(', ') || '(none)'}`);
|
|
1996
2017
|
if (profile.args.length > 0) logger.info(` args: ${profile.args.map(shQuote).join(' ')}`);
|
|
1997
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)' : ''}`);
|
|
1998
2020
|
const envKeys = Object.keys(profile.env);
|
|
1999
2021
|
if (envKeys.length > 0) logger.info(` env: ${envKeys.join(', ')}`);
|
|
2000
2022
|
logger.info(` job types (${matrix.length}): ${matrix.join(' ')}`);
|
|
@@ -2902,7 +2924,7 @@ const MAX_CAPTURE_BYTES = 1_048_576; // 1 MiB per stream
|
|
|
2902
2924
|
// Spawn a child, pipe `stdinData`, capture byte-capped stdout/stderr, enforce a
|
|
2903
2925
|
// timeout (invoking `onTimeout(child)` to tear the child down), and resolve to a
|
|
2904
2926
|
// uniform result. Used by both the host and container executors.
|
|
2905
|
-
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 }) {
|
|
2906
2928
|
return new Promise((resolve) => {
|
|
2907
2929
|
let child;
|
|
2908
2930
|
const stdoutChunks = [];
|
|
@@ -2991,6 +3013,7 @@ function spawnCaptureOneShot({ command, args = [], shell = false, detached = fal
|
|
|
2991
3013
|
armIdle();
|
|
2992
3014
|
const buf = Buffer.isBuffer(d) ? d : Buffer.from(d);
|
|
2993
3015
|
if (teeOut) teeOut(buf.toString('utf8'), false);
|
|
3016
|
+
if (relayTap && typeof relayTap.onData === 'function') relayTap.onData(buf);
|
|
2994
3017
|
const remaining = MAX_CAPTURE_BYTES - stdoutBytes;
|
|
2995
3018
|
if (remaining <= 0) { stdoutTruncated = true; return; }
|
|
2996
3019
|
if (buf.length > remaining) { stdoutChunks.push(buf.subarray(0, remaining)); stdoutBytes = MAX_CAPTURE_BYTES; stdoutTruncated = true; }
|
|
@@ -3000,6 +3023,7 @@ function spawnCaptureOneShot({ command, args = [], shell = false, detached = fal
|
|
|
3000
3023
|
armIdle();
|
|
3001
3024
|
const buf = Buffer.isBuffer(d) ? d : Buffer.from(d);
|
|
3002
3025
|
if (teeErr) teeErr(buf.toString('utf8'), false);
|
|
3026
|
+
if (relayTap && typeof relayTap.onData === 'function') relayTap.onData(buf);
|
|
3003
3027
|
const remaining = MAX_CAPTURE_BYTES - stderrBytes;
|
|
3004
3028
|
if (remaining <= 0) { stderrTruncated = true; return; }
|
|
3005
3029
|
if (buf.length > remaining) { stderrChunks.push(buf.subarray(0, remaining)); stderrBytes = MAX_CAPTURE_BYTES; stderrTruncated = true; }
|
|
@@ -3014,6 +3038,11 @@ function spawnCaptureOneShot({ command, args = [], shell = false, detached = fal
|
|
|
3014
3038
|
});
|
|
3015
3039
|
|
|
3016
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.
|
|
3017
3046
|
try {
|
|
3018
3047
|
if (stdinData != null) child.stdin.write(stdinData);
|
|
3019
3048
|
child.stdin.end();
|
|
@@ -3021,6 +3050,152 @@ function spawnCaptureOneShot({ command, args = [], shell = false, detached = fal
|
|
|
3021
3050
|
});
|
|
3022
3051
|
}
|
|
3023
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
|
+
|
|
3024
3199
|
function buildAgentPayload(profile, job, envelope) {
|
|
3025
3200
|
const variables = job.variables && typeof job.variables === 'object' ? job.variables : {};
|
|
3026
3201
|
return {
|
|
@@ -3100,7 +3275,7 @@ function startLockExtender(job, windowMs, intervalMs, tag, logger) {
|
|
|
3100
3275
|
* Both paths resolve to the same result contract.
|
|
3101
3276
|
*/
|
|
3102
3277
|
function runAgentJob(profile, job, opts = {}) {
|
|
3103
|
-
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;
|
|
3104
3279
|
const payload = JSON.stringify(buildAgentPayload(profile, job, envelope));
|
|
3105
3280
|
const agentEnv = baseAgentEnv(profile, job);
|
|
3106
3281
|
// The harness command line: the profile command plus its structured switches
|
|
@@ -3113,6 +3288,16 @@ function runAgentJob(profile, job, opts = {}) {
|
|
|
3113
3288
|
// resolved secrets are layered on top so user env can never shadow them.
|
|
3114
3289
|
const staticEnv = { ...normalizeEnvMap(profileEnv), ...normalizeEnvMap(envelope?.setup?.env) };
|
|
3115
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
|
+
|
|
3116
3301
|
if (!CONTAINER_SANDBOXES.has(sandbox)) {
|
|
3117
3302
|
// Host: hand the agent the result file by its real path.
|
|
3118
3303
|
// Defense in depth: --arg tokens are POSIX single-quoted, which cmd.exe on
|
|
@@ -3123,6 +3308,29 @@ function runAgentJob(profile, job, opts = {}) {
|
|
|
3123
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 });
|
|
3124
3309
|
}
|
|
3125
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
|
+
|
|
3126
3334
|
return spawnCaptureOneShot({
|
|
3127
3335
|
command: commandLine,
|
|
3128
3336
|
shell: true,
|
|
@@ -3132,7 +3340,7 @@ function runAgentJob(profile, job, opts = {}) {
|
|
|
3132
3340
|
cwd,
|
|
3133
3341
|
// Reserved harness env (AGENT_* + the result-file path) is layered AFTER
|
|
3134
3342
|
// resolved secrets so a task-supplied secret NAME can never shadow it.
|
|
3135
|
-
env:
|
|
3343
|
+
env: harnessEnv,
|
|
3136
3344
|
stdinData: payload,
|
|
3137
3345
|
timeoutMs,
|
|
3138
3346
|
idleTimeoutMs,
|
|
@@ -3141,6 +3349,7 @@ function runAgentJob(profile, job, opts = {}) {
|
|
|
3141
3349
|
streamPrefix,
|
|
3142
3350
|
onStreamOut,
|
|
3143
3351
|
onStreamErr,
|
|
3352
|
+
relayTap,
|
|
3144
3353
|
});
|
|
3145
3354
|
}
|
|
3146
3355
|
|
|
@@ -3196,6 +3405,7 @@ function runAgentJob(profile, job, opts = {}) {
|
|
|
3196
3405
|
streamPrefix,
|
|
3197
3406
|
onStreamOut,
|
|
3198
3407
|
onStreamErr,
|
|
3408
|
+
relayTap,
|
|
3199
3409
|
onTimeout: (child) => {
|
|
3200
3410
|
try { spawnSync(engine, ['rm', '-f', containerName], { timeout: 15_000 }); } catch { /* best effort */ }
|
|
3201
3411
|
try { killTree(child); } catch { /* best effort */ }
|
|
@@ -3236,6 +3446,32 @@ function buildResultEnvelope(result, { sandbox, image, git, result: agentResult
|
|
|
3236
3446
|
return env;
|
|
3237
3447
|
}
|
|
3238
3448
|
|
|
3449
|
+
/**
|
|
3450
|
+
* Resolve the agentic-visibility channel connection target + credentials for a
|
|
3451
|
+
* worker (ADR 0056 — slice C2). The channel is served same-port on the app's own
|
|
3452
|
+
* HTTP base URL at `/agentic`; the identity token + capability credential follow
|
|
3453
|
+
* the blackboard's `?token=…` pattern.
|
|
3454
|
+
*
|
|
3455
|
+
* Env wins over persisted config; the base URL falls back to the configured nano
|
|
3456
|
+
* URL (the app's own port). A worker only connects when BOTH an identity token
|
|
3457
|
+
* and a capability credential are present (enrolment) — absent either, it runs
|
|
3458
|
+
* exactly as before, off the visibility page. Returns `null` when not enrolled.
|
|
3459
|
+
*
|
|
3460
|
+
* @returns {{ url: string, token: string, credential: string } | null}
|
|
3461
|
+
*/
|
|
3462
|
+
function resolveAgenticConfig() {
|
|
3463
|
+
const cfg = readConfig();
|
|
3464
|
+
const url = process.env.NANO_AGENTIC_URL
|
|
3465
|
+
|| cfg.agenticUrl
|
|
3466
|
+
|| cfg.nanoUrl
|
|
3467
|
+
|| process.env.NANO_BASE_URL
|
|
3468
|
+
|| DEFAULT_NANO_URL;
|
|
3469
|
+
const token = process.env.NANO_AGENTIC_TOKEN || cfg.agenticToken || '';
|
|
3470
|
+
const credential = process.env.NANO_AGENTIC_CREDENTIAL || cfg.agenticCredential || '';
|
|
3471
|
+
if (!url || !token || !credential) return null;
|
|
3472
|
+
return { url, token, credential };
|
|
3473
|
+
}
|
|
3474
|
+
|
|
3239
3475
|
/**
|
|
3240
3476
|
* work — turn a hire profile into live Nano job workers (one per job-type in
|
|
3241
3477
|
* the rank×capability matrix) and poll for work in the foreground until Ctrl-C.
|
|
@@ -3480,19 +3716,82 @@ async function workAgent(req, flags) {
|
|
|
3480
3716
|
/* best effort — activity is advisory, never fail a job over it */
|
|
3481
3717
|
}
|
|
3482
3718
|
};
|
|
3719
|
+
// The agentic-visibility channel, wired below. Declared here so the job
|
|
3720
|
+
// recorders can refresh presence with the live job set as jobs start/end.
|
|
3721
|
+
/** @type {import('./work-channel.mjs').WorkChannel | null} */
|
|
3722
|
+
let workChannel = null;
|
|
3723
|
+
// Maintain `activeJobs` unconditionally: it feeds both the supervisor activity
|
|
3724
|
+
// file (gated inside writeActivity) AND the agentic presence frame's live
|
|
3725
|
+
// jobKey set, so a standalone worker (no NANO_SUPERVISOR_ACTIVITY_FILE) still
|
|
3726
|
+
// reports its current jobs on the visibility page.
|
|
3483
3727
|
const recordJobStart = (job, jobType) => {
|
|
3484
|
-
if (!activityFile) return;
|
|
3485
3728
|
activeJobs.set(String(job.jobKey), { type: jobType, since: Date.now() });
|
|
3486
3729
|
writeActivity();
|
|
3730
|
+
workChannel?.refreshPresence();
|
|
3487
3731
|
};
|
|
3488
3732
|
const recordJobEnd = (job) => {
|
|
3489
|
-
if (!activityFile) return;
|
|
3490
3733
|
activeJobs.delete(String(job.jobKey));
|
|
3491
3734
|
writeActivity();
|
|
3735
|
+
workChannel?.refreshPresence();
|
|
3492
3736
|
};
|
|
3493
3737
|
// Seed an initial idle marker so status reports 'idle' immediately after spawn.
|
|
3494
3738
|
writeActivity();
|
|
3495
3739
|
|
|
3740
|
+
// ---- Agentic visibility channel (ADR 0056 — slice C2, #41) ----------------
|
|
3741
|
+
// Connect this worker to the app's same-port `/agentic` channel and announce
|
|
3742
|
+
// presence (identity, host, live jobs), heartbeat, and deregister on exit, so
|
|
3743
|
+
// it appears live on the Workforce visibility page. This is the SINGLE place
|
|
3744
|
+
// the connected+authenticated channel client is instantiated in `work`: the
|
|
3745
|
+
// sibling slices C3 (PTY relay, #42) and C4 (buffer, #43) attach to the
|
|
3746
|
+
// accessors on `workChannel` (relay-lane sink + connect/disconnect/reconnect
|
|
3747
|
+
// lifecycle events) rather than opening their own connection.
|
|
3748
|
+
//
|
|
3749
|
+
// Enrolment is opt-in: without an identity token + capability credential the
|
|
3750
|
+
// worker runs exactly as before, off the channel (see resolveAgenticConfig).
|
|
3751
|
+
const agenticCfg = resolveAgenticConfig();
|
|
3752
|
+
if (agenticCfg) {
|
|
3753
|
+
try {
|
|
3754
|
+
workChannel = await createWorkChannel({
|
|
3755
|
+
instance: workerName,
|
|
3756
|
+
host: hostname(),
|
|
3757
|
+
capability: {
|
|
3758
|
+
cognition: profile.rank,
|
|
3759
|
+
family: profile.model || undefined,
|
|
3760
|
+
host: hostname(),
|
|
3761
|
+
},
|
|
3762
|
+
listJobKeys: () => [...activeJobs.keys()],
|
|
3763
|
+
url: agenticCfg.url,
|
|
3764
|
+
token: agenticCfg.token,
|
|
3765
|
+
credential: agenticCfg.credential,
|
|
3766
|
+
logger,
|
|
3767
|
+
});
|
|
3768
|
+
const shown = redactAgenticUrl(buildAgenticUrl(agenticCfg.url, {}));
|
|
3769
|
+
logger.info(` agentic channel: announcing presence as ${workerName} on ${shown}`);
|
|
3770
|
+
} catch (err) {
|
|
3771
|
+
// Never let a channel failure stop the worker from doing its actual job.
|
|
3772
|
+
workChannel = null;
|
|
3773
|
+
logger.warn(` agentic channel unavailable (${err?.message || err}); continuing without visibility.`);
|
|
3774
|
+
}
|
|
3775
|
+
} else {
|
|
3776
|
+
logger.info(' agentic channel: not enrolled (set NANO_AGENTIC_URL + NANO_AGENTIC_TOKEN + NANO_AGENTIC_CREDENTIAL to appear on the visibility page).');
|
|
3777
|
+
}
|
|
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
|
+
|
|
3496
3795
|
// A per-job-type worker factory. Captures all the CLI-local + profile context
|
|
3497
3796
|
// in closure scope so the profile watcher below can (re)spawn a poller for any
|
|
3498
3797
|
// job type on demand without re-reading the flags.
|
|
@@ -3584,6 +3883,19 @@ async function workAgent(req, flags) {
|
|
|
3584
3883
|
|
|
3585
3884
|
let result;
|
|
3586
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
|
+
}
|
|
3587
3899
|
// Private structured-result channel: hand the agent a file (outside any
|
|
3588
3900
|
// repo clone so it can't be `git add`ed) to write its job-result vars to.
|
|
3589
3901
|
let resultDir = null;
|
|
@@ -3614,6 +3926,11 @@ async function workAgent(req, flags) {
|
|
|
3614
3926
|
stream,
|
|
3615
3927
|
streamPrefix: `[${jobType} ${job.jobKey}] `,
|
|
3616
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,
|
|
3617
3934
|
// Route the --stream tee through c8ctl's output-mode-aware logger so
|
|
3618
3935
|
// spying never corrupts a structured/JSON output mode.
|
|
3619
3936
|
onStreamOut: stream ? (line) => logger.info(line) : undefined,
|
|
@@ -3642,6 +3959,9 @@ async function workAgent(req, flags) {
|
|
|
3642
3959
|
if (isContainer) liveRunIds.delete(runId);
|
|
3643
3960
|
if (runDir && !keepRuns) { try { rmSync(runDir, { recursive: true, force: true }); } catch { /* best effort */ } }
|
|
3644
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 */ } }
|
|
3645
3965
|
}
|
|
3646
3966
|
|
|
3647
3967
|
// Read the agent's structured result: the file it wrote, else a stdout
|
|
@@ -3865,6 +4185,17 @@ async function workAgent(req, flags) {
|
|
|
3865
4185
|
} else {
|
|
3866
4186
|
logger.info('All workers stopped.');
|
|
3867
4187
|
}
|
|
4188
|
+
// Deregister from the visibility channel LAST, so the worker disappears
|
|
4189
|
+
// from the page only once its jobs have drained. Best-effort — a channel
|
|
4190
|
+
// teardown must never hang shutdown.
|
|
4191
|
+
if (workChannel) {
|
|
4192
|
+
try {
|
|
4193
|
+
await workChannel.stop(`worker stopped (${signal})`);
|
|
4194
|
+
logger.info('Deregistered from the agentic visibility channel.');
|
|
4195
|
+
} catch (err) {
|
|
4196
|
+
logger.warn(`agentic channel deregister failed: ${err?.message || err}`);
|
|
4197
|
+
}
|
|
4198
|
+
}
|
|
3868
4199
|
resolve();
|
|
3869
4200
|
};
|
|
3870
4201
|
process.once('SIGINT', () => { stop('SIGINT'); });
|
|
@@ -6553,6 +6884,7 @@ export {
|
|
|
6553
6884
|
diskBudgetOk,
|
|
6554
6885
|
containerEngineAvailable,
|
|
6555
6886
|
runAgentJob,
|
|
6887
|
+
spawnCapturePty,
|
|
6556
6888
|
startLockExtender,
|
|
6557
6889
|
provisionRepo,
|
|
6558
6890
|
finalizeGit,
|
|
@@ -6708,6 +7040,7 @@ export const commands = {
|
|
|
6708
7040
|
capabilities: { type: 'string', description: 'hire/assign: comma-separated capability list' },
|
|
6709
7041
|
sandbox: { type: 'string', description: 'hire/work: execution sandbox none|docker|podman (default none). Containers isolate each job.' },
|
|
6710
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.' },
|
|
6711
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.' },
|
|
6712
7045
|
'secret-resolver': { type: 'string', description: 'work: secret resolver for task secretRefs (host = process env; default host)' },
|
|
6713
7046
|
'reap-age': { type: 'string', description: 'work: age in ms before a finished agent container or job workspace is reaped (default 3600000)' },
|
|
@@ -6879,7 +7212,7 @@ function printUsage() {
|
|
|
6879
7212
|
console.log(' c8ctl nano unset <bin|model-dir>');
|
|
6880
7213
|
console.log(' c8ctl nano config');
|
|
6881
7214
|
console.log(' c8ctl nano update [--check]');
|
|
6882
|
-
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]');
|
|
6883
7216
|
console.log(' c8ctl nano assign <profileName> <cap[,cap...]> [--name <n>] [--capabilities <a,b>]');
|
|
6884
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]');
|
|
6885
7218
|
console.log(' c8ctl nano supervisor [start|status|add|remove|restart|stop|logs|attach] ... (manage many workers from one terminal)');
|
|
@@ -6923,6 +7256,7 @@ function printUsage() {
|
|
|
6923
7256
|
console.log(' --capabilities <a,b> hire/assign: comma-separated capability list');
|
|
6924
7257
|
console.log(' --sandbox <s> hire/work: execution sandbox none|docker|podman (default none)');
|
|
6925
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');
|
|
6926
7260
|
console.log(' --env NAME=VALUE hire/work: static env var for the harness (repeatable); persisted on hire, work extends/overrides');
|
|
6927
7261
|
console.log(' --list hire: list existing agent profiles instead of creating one');
|
|
6928
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",
|
|
@@ -23,6 +23,9 @@
|
|
|
23
23
|
"c8ctl-plugin.js",
|
|
24
24
|
"platforms.mjs",
|
|
25
25
|
"agentic.mjs",
|
|
26
|
+
"agentic-loader-hook.mjs",
|
|
27
|
+
"work-channel.mjs",
|
|
28
|
+
"work-relay.mjs",
|
|
26
29
|
"nanobpmn-binary.json",
|
|
27
30
|
"README.md"
|
|
28
31
|
],
|
|
@@ -52,12 +55,13 @@
|
|
|
52
55
|
"@nanobpm/urban-agent-client": "^0.1.0"
|
|
53
56
|
},
|
|
54
57
|
"optionalDependencies": {
|
|
55
|
-
"
|
|
56
|
-
"@nanobpm/c8ctl-plugin-nano-darwin-
|
|
57
|
-
"@nanobpm/c8ctl-plugin-nano-
|
|
58
|
-
"@nanobpm/c8ctl-plugin-nano-linux-
|
|
59
|
-
"@nanobpm/c8ctl-plugin-nano-linux-
|
|
60
|
-
"@nanobpm/c8ctl-plugin-nano-linux-
|
|
61
|
-
"@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"
|
|
62
66
|
}
|
|
63
67
|
}
|
package/work-channel.mjs
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
// The `work` command's agentic-visibility channel seam (ADR 0056 — slice C2,
|
|
2
|
+
// jwulf/c8ctl-plugin-nano#41).
|
|
3
|
+
//
|
|
4
|
+
// This module owns the SINGLE connected + authenticated channel client a
|
|
5
|
+
// running worker (`c8ctl nano work <profile>`) uses to appear on the Workforce
|
|
6
|
+
// visibility page. It is the wave-1 scaffold the sibling slices build on:
|
|
7
|
+
//
|
|
8
|
+
// - C3 (#42, PTY relay) publishes framed terminal output through the
|
|
9
|
+
// relay-lane sink exposed by {@link WorkChannel.relayLane}.
|
|
10
|
+
// - C4 (#43, buffer) subscribes to the connect / disconnect / reconnect
|
|
11
|
+
// lifecycle events exposed by {@link WorkChannel.onConnect} /
|
|
12
|
+
// {@link WorkChannel.onDisconnect} / {@link WorkChannel.onReconnect} to
|
|
13
|
+
// drive its buffer flush at the transport seam.
|
|
14
|
+
//
|
|
15
|
+
// Both siblings EXTEND this holder; neither opens, authenticates, or
|
|
16
|
+
// re-instantiates the channel. The connected client is created in exactly one
|
|
17
|
+
// place — {@link createWorkChannel} — alongside the worker's existing
|
|
18
|
+
// `camunda.createClient()` wiring.
|
|
19
|
+
//
|
|
20
|
+
// Everything on the wire (frame codec, lanes, presence payloads) is CONSUMED
|
|
21
|
+
// through this plugin's single import surface (`./agentic.mjs`), which in turn
|
|
22
|
+
// consumes `@nanobpm/agentic` + `@nanobpm/urban-agent-client`. Nothing is
|
|
23
|
+
// re-declared here.
|
|
24
|
+
//
|
|
25
|
+
// SCOPE (C2): presence + the shared seam only. Capability→SERVE-token
|
|
26
|
+
// resolution (REGISTER/SERVE enrolment) is the separate epic #58 and is NOT
|
|
27
|
+
// done here — the worker announces presence (identity, host, live jobs),
|
|
28
|
+
// heartbeats, and deregisters, and the SERVE handshake is deliberately left
|
|
29
|
+
// disabled (`serveTimeoutMs: 0`, the announce is fire-and-forget).
|
|
30
|
+
|
|
31
|
+
import { loadAgenticClient } from './agentic.mjs';
|
|
32
|
+
|
|
33
|
+
const DEFAULT_HEARTBEAT_MS = 10_000;
|
|
34
|
+
// Outbound ring size (frames) the client buffers while the hub is unreachable.
|
|
35
|
+
// C4 (#43) tunes/uses this seam; a sensible default keeps a worker that starts
|
|
36
|
+
// before the app from losing its early presence/relay frames.
|
|
37
|
+
const DEFAULT_BUFFER_CAPACITY = 1024;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Build the worker's agentic-channel WebSocket URL from the app's HTTP base URL
|
|
41
|
+
* plus the ADR 0028 identity token and capability credential, carried as query
|
|
42
|
+
* params — the same `?token=…` pattern the blackboard hook uses (and exactly
|
|
43
|
+
* what `sharedSecretAuthenticator` reads on the hub side). `http`→`ws`,
|
|
44
|
+
* `https`→`wss`; the channel is served same-port at `/agentic`.
|
|
45
|
+
*
|
|
46
|
+
* @param {string} baseUrl the app's own HTTP(S) base URL, e.g. `http://localhost:8080`
|
|
47
|
+
* @param {{ token: string, credential: string, path?: string }} auth
|
|
48
|
+
* @returns {string} the `ws(s)://…/agentic?token=…&capability=…` URL
|
|
49
|
+
*/
|
|
50
|
+
export function buildAgenticUrl(baseUrl, { token, credential, path = '/agentic' } = {}) {
|
|
51
|
+
if (typeof baseUrl !== 'string' || baseUrl.trim() === '') {
|
|
52
|
+
throw new Error('buildAgenticUrl requires a non-empty base URL');
|
|
53
|
+
}
|
|
54
|
+
const u = new URL(baseUrl);
|
|
55
|
+
if (u.protocol === 'http:') u.protocol = 'ws:';
|
|
56
|
+
else if (u.protocol === 'https:') u.protocol = 'wss:';
|
|
57
|
+
else if (u.protocol !== 'ws:' && u.protocol !== 'wss:') {
|
|
58
|
+
throw new Error(`Unsupported agentic base URL protocol "${u.protocol}" (expected http/https/ws/wss)`);
|
|
59
|
+
}
|
|
60
|
+
// Preserve any base path, then append the same-port channel path.
|
|
61
|
+
const basePath = u.pathname.replace(/\/+$/, '');
|
|
62
|
+
u.pathname = `${basePath}${path}`;
|
|
63
|
+
if (token !== undefined && token !== null && token !== '') u.searchParams.set('token', String(token));
|
|
64
|
+
if (credential !== undefined && credential !== null && credential !== '') {
|
|
65
|
+
u.searchParams.set('capability', String(credential));
|
|
66
|
+
}
|
|
67
|
+
return u.toString();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Redact the token/capability query params from a channel URL for logging.
|
|
72
|
+
* @param {string} url
|
|
73
|
+
* @returns {string}
|
|
74
|
+
*/
|
|
75
|
+
export function redactAgenticUrl(url) {
|
|
76
|
+
try {
|
|
77
|
+
const u = new URL(url);
|
|
78
|
+
if (u.searchParams.has('token')) u.searchParams.set('token', '***');
|
|
79
|
+
if (u.searchParams.has('capability')) u.searchParams.set('capability', '***');
|
|
80
|
+
return u.toString();
|
|
81
|
+
} catch {
|
|
82
|
+
return url;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Normalise a capability object for the `register` presence frame. Drops
|
|
88
|
+
* undefined/empty attributes so the enrolment attribute stays minimal, and
|
|
89
|
+
* carries the worker's live `jobs` (jobKeys) as a forward-compatible nested
|
|
90
|
+
* field — the S0 register validator only requires `capability` to be an object
|
|
91
|
+
* and ignores extra fields ("a later slice may enrich a payload without
|
|
92
|
+
* breaking older peers"), so the visibility page can surface `capability.jobs`
|
|
93
|
+
* without any wire-contract change.
|
|
94
|
+
*
|
|
95
|
+
* @param {{ cognition?: string, weight?: number, family?: string, host?: string }} capability
|
|
96
|
+
* @param {readonly string[]} jobs
|
|
97
|
+
* @returns {object}
|
|
98
|
+
*/
|
|
99
|
+
function presenceCapability(capability, jobs) {
|
|
100
|
+
const out = {};
|
|
101
|
+
if (capability) {
|
|
102
|
+
if (typeof capability.cognition === 'string' && capability.cognition !== '') out.cognition = capability.cognition;
|
|
103
|
+
if (typeof capability.weight === 'number' && Number.isFinite(capability.weight)) out.weight = capability.weight;
|
|
104
|
+
if (typeof capability.family === 'string' && capability.family !== '') out.family = capability.family;
|
|
105
|
+
if (typeof capability.host === 'string' && capability.host !== '') out.host = capability.host;
|
|
106
|
+
}
|
|
107
|
+
out.jobs = Array.isArray(jobs) ? jobs.map(String) : [];
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* @typedef {object} WorkChannel
|
|
113
|
+
* @property {import('@nanobpm/urban-agent-client').AgenticClient} client the one connected channel client
|
|
114
|
+
* @property {() => void} refreshPresence re-announce presence (call when the live job set changes)
|
|
115
|
+
* @property {() => { relay: (stream: string, chunk: string) => void }} relayLane C3's relay-lane sink accessor
|
|
116
|
+
* @property {(fn: () => void) => () => void} onConnect subscribe to the first successful connect
|
|
117
|
+
* @property {(fn: (info: object) => void) => () => void} onDisconnect subscribe to channel close
|
|
118
|
+
* @property {(fn: () => void) => () => void} onReconnect subscribe to reconnects (every open after the first)
|
|
119
|
+
* @property {() => boolean} connected whether the channel is currently open
|
|
120
|
+
* @property {() => number} buffered outbound frames currently buffered awaiting the channel
|
|
121
|
+
* @property {(reason?: string) => Promise<void>} stop deregister + close cleanly
|
|
122
|
+
*/
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Create the worker's single connected + authenticated agentic channel client
|
|
126
|
+
* and announce presence. The connection begins immediately; because the client
|
|
127
|
+
* buffers outbound frames, presence is announced (and relay is usable) even
|
|
128
|
+
* before the socket is open — it drains on connect.
|
|
129
|
+
*
|
|
130
|
+
* This is the ONLY place the channel client is instantiated in `work`. Sibling
|
|
131
|
+
* slices consume the accessors on the returned holder; they do not connect.
|
|
132
|
+
*
|
|
133
|
+
* @param {object} opts
|
|
134
|
+
* @param {string} opts.instance stable worker instance id (the worker name) carried on every presence frame
|
|
135
|
+
* @param {string} opts.host the worker's host label
|
|
136
|
+
* @param {{ cognition?: string, weight?: number, family?: string, host?: string }} [opts.capability] declared enrolment capability
|
|
137
|
+
* @param {() => readonly string[]} [opts.listJobKeys] reads the live jobKey set from the worker's activeJobs map
|
|
138
|
+
* @param {string} opts.url the app's HTTP(S) base URL (the channel is served same-port at `/agentic`)
|
|
139
|
+
* @param {string} opts.token ADR 0028 identity token
|
|
140
|
+
* @param {string} opts.credential capability credential
|
|
141
|
+
* @param {number} [opts.heartbeatIntervalMs] presence heartbeat cadence (ms)
|
|
142
|
+
* @param {number} [opts.bufferCapacity] outbound ring size in frames
|
|
143
|
+
* @param {import('@nanobpm/urban-agent-client').TransportFactory} [opts.transport] injectable transport (tests)
|
|
144
|
+
* @param {import('@nanobpm/urban-agent-client').ReconnectOptions} [opts.reconnect] reconnect/backoff policy passthrough
|
|
145
|
+
* @param {(fn: () => void, ms: number) => void} [opts.schedule] injectable backoff scheduler (tests)
|
|
146
|
+
* @param {{ info?: Function, warn?: Function, debug?: Function }} [opts.logger] optional logger
|
|
147
|
+
* @returns {Promise<WorkChannel>}
|
|
148
|
+
*/
|
|
149
|
+
export async function createWorkChannel(opts) {
|
|
150
|
+
const {
|
|
151
|
+
instance,
|
|
152
|
+
host,
|
|
153
|
+
capability,
|
|
154
|
+
listJobKeys = () => [],
|
|
155
|
+
url,
|
|
156
|
+
token,
|
|
157
|
+
credential,
|
|
158
|
+
heartbeatIntervalMs = DEFAULT_HEARTBEAT_MS,
|
|
159
|
+
bufferCapacity = DEFAULT_BUFFER_CAPACITY,
|
|
160
|
+
transport,
|
|
161
|
+
reconnect,
|
|
162
|
+
schedule,
|
|
163
|
+
logger,
|
|
164
|
+
} = opts || {};
|
|
165
|
+
|
|
166
|
+
if (typeof instance !== 'string' || instance.trim() === '') {
|
|
167
|
+
throw new Error('createWorkChannel requires a non-empty instance id');
|
|
168
|
+
}
|
|
169
|
+
if (typeof url !== 'string' || url.trim() === '') {
|
|
170
|
+
throw new Error('createWorkChannel requires an agentic channel base url');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const channelUrl = buildAgenticUrl(url, { token, credential });
|
|
174
|
+
const declaredCapability = { ...(capability || {}) };
|
|
175
|
+
if (typeof host === 'string' && host !== '' && !declaredCapability.host) {
|
|
176
|
+
declaredCapability.host = host;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const { connectAgenticChannel } = await loadAgenticClient();
|
|
180
|
+
|
|
181
|
+
// The single connected client. serveTimeoutMs:0 disables the SERVE handshake
|
|
182
|
+
// wait — SERVE-token resolution is the enrolment epic (#58), out of scope for
|
|
183
|
+
// C2; we only need presence to land, which the REGISTER frame does on its own.
|
|
184
|
+
const client = connectAgenticChannel({
|
|
185
|
+
url: channelUrl,
|
|
186
|
+
instance,
|
|
187
|
+
heartbeatIntervalMs,
|
|
188
|
+
serveTimeoutMs: 0,
|
|
189
|
+
bufferCapacity,
|
|
190
|
+
...(transport ? { transport } : {}),
|
|
191
|
+
...(reconnect ? { reconnect } : {}),
|
|
192
|
+
...(schedule ? { schedule } : {}),
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
const log = logger || {};
|
|
196
|
+
|
|
197
|
+
/** (Re)announce presence with the current live job set. Fire-and-forget: the
|
|
198
|
+
* REGISTER frame is what makes the worker appear; the returned promise only
|
|
199
|
+
* resolves on a SERVE (disabled here), so we never await it and swallow its
|
|
200
|
+
* rejection so a missing SERVE is not an unhandled rejection. */
|
|
201
|
+
const refreshPresence = () => {
|
|
202
|
+
let jobs = [];
|
|
203
|
+
try {
|
|
204
|
+
jobs = listJobKeys() || [];
|
|
205
|
+
} catch {
|
|
206
|
+
jobs = [];
|
|
207
|
+
}
|
|
208
|
+
const cap = presenceCapability(declaredCapability, jobs);
|
|
209
|
+
// register() enqueues the frame even while the channel is down (it drains on
|
|
210
|
+
// connect); catch guards the deliberately-never-resolving SERVE promise.
|
|
211
|
+
Promise.resolve(client.register({ capability: cap })).catch(() => {});
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
// Lifecycle fan-out: the client fires onOpen on the first connect AND on every
|
|
215
|
+
// reconnect. Split that into a one-shot "connect" and a repeated "reconnect"
|
|
216
|
+
// so C4 can distinguish the initial attach from a recovery flush.
|
|
217
|
+
let hasConnected = false;
|
|
218
|
+
const connectListeners = new Set();
|
|
219
|
+
const reconnectListeners = new Set();
|
|
220
|
+
const disconnectListeners = new Set();
|
|
221
|
+
const fan = (set, arg) => {
|
|
222
|
+
for (const fn of set) {
|
|
223
|
+
try {
|
|
224
|
+
fn(arg);
|
|
225
|
+
} catch (err) {
|
|
226
|
+
try {
|
|
227
|
+
log.warn?.(`work-channel listener threw: ${err?.message || err}`);
|
|
228
|
+
} catch {
|
|
229
|
+
/* never let a listener failure escape the lifecycle dispatch */
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
client.onOpen(() => {
|
|
236
|
+
if (!hasConnected) {
|
|
237
|
+
hasConnected = true;
|
|
238
|
+
fan(connectListeners);
|
|
239
|
+
// The presence announce buffered before connect drains on this first open,
|
|
240
|
+
// so no re-announce is needed here — avoid a redundant duplicate register.
|
|
241
|
+
} else {
|
|
242
|
+
fan(reconnectListeners);
|
|
243
|
+
// Re-announce presence on RECONNECT so the durable presence row reflects
|
|
244
|
+
// this worker's current identity/host/jobs after a hub restart/outage.
|
|
245
|
+
refreshPresence();
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
client.onClose((info) => {
|
|
249
|
+
fan(disconnectListeners, info);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
// Announce presence immediately; the frame buffers and drains on connect.
|
|
253
|
+
refreshPresence();
|
|
254
|
+
|
|
255
|
+
const subscribe = (set) => (fn) => {
|
|
256
|
+
if (typeof fn !== 'function') return () => {};
|
|
257
|
+
set.add(fn);
|
|
258
|
+
return () => set.delete(fn);
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
/** @type {WorkChannel} */
|
|
262
|
+
const channel = {
|
|
263
|
+
client,
|
|
264
|
+
refreshPresence,
|
|
265
|
+
// C3 (#42): the relay-lane sink. Delegates to the one connected client so
|
|
266
|
+
// relay frames ride the shared, buffered, QoS-ordered outbound path.
|
|
267
|
+
relayLane: () => ({
|
|
268
|
+
relay: (stream, chunk) => client.relay(stream, chunk),
|
|
269
|
+
}),
|
|
270
|
+
onConnect: subscribe(connectListeners),
|
|
271
|
+
onReconnect: subscribe(reconnectListeners),
|
|
272
|
+
onDisconnect: subscribe(disconnectListeners),
|
|
273
|
+
connected: () => client.connected,
|
|
274
|
+
buffered: () => client.buffered,
|
|
275
|
+
async stop(reason = 'worker stopped') {
|
|
276
|
+
try {
|
|
277
|
+
client.deregister(reason);
|
|
278
|
+
} catch (err) {
|
|
279
|
+
try {
|
|
280
|
+
log.warn?.(`agentic deregister failed: ${err?.message || err}`);
|
|
281
|
+
client.close();
|
|
282
|
+
} catch {
|
|
283
|
+
/* best effort — never let shutdown hang on the channel */
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
},
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
return channel;
|
|
290
|
+
}
|
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
|
+
}
|