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.
@@ -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. The slice that
84
- // actually opens the channel (C2, #41) awaits it from the code path that runs
85
- // under the appropriate loader/build.
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
- logger.info(` ${name} [${p.rank}] ${buildAgentCommandLine(p.command, p.args)} (model: ${p.model || '-'}; caps: ${normalizeCapabilities(p.capabilities).join(', ') || '-'})`);
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: { ...process.env, ...staticEnv, ...secretEnv, ...agentEnv, ...extraEnv, ...resultEnv },
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.27.0",
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
- "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.27.0",
56
- "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.27.0",
57
- "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.27.0",
58
- "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.27.0",
59
- "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.27.0",
60
- "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.27.0",
61
- "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.27.0"
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
  }
@@ -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
+ }