c8ctl-plugin-nano 1.28.0 → 1.30.0

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