c8ctl-plugin-nano 1.41.0 → 1.42.1

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/README.md CHANGED
@@ -157,6 +157,9 @@ c8ctl nano hire --name reviewer --rank senior --command copilot \
157
157
  # Give the harness command-line switches (e.g. run copilot with --allow-all)
158
158
  c8ctl nano hire --name coder --rank senior --command copilot --arg --allow-all
159
159
 
160
+ # Opt a role into the ACP harness mode (JSON-RPC/stdio) — see "ACP harness mode" below
161
+ c8ctl nano hire --name coder --rank senior --command copilot --protocol acp
162
+
160
163
  # List profiles
161
164
  c8ctl nano hire --list
162
165
  ```
@@ -409,6 +412,86 @@ bound is operator-tunable for a long expected outage; raise it with
409
412
  `NANO_AGENTIC_BUFFER_CAPACITY` (frames). When the bound is hit the worker warns
410
413
  rather than silently shedding.
411
414
 
415
+ ### ACP harness mode (opt-in)
416
+
417
+ Alongside the `pipe`/`pty` terminal choice above, a role can opt into driving its
418
+ harness over the **[Agent Client Protocol (ACP)](https://agentclientprotocol.com)**
419
+ — JSON-RPC 2.0 over stdio — instead of the default stdin/scrape **pipe**. ACP is
420
+ **additive, not a switch**: `pipe` stays the default floor, so every CLI harness
421
+ (and non-agent record-keepers) keeps working unchanged, and **YOLO stays the
422
+ default** via auto-approve. You turn ACP on per role, exactly like `--terminal`:
423
+
424
+ ```bash
425
+ # Drive a role's harness over ACP (JSON-RPC/stdio) instead of the pipe
426
+ c8ctl nano hire --name coder --rank senior --command copilot --protocol acp
427
+
428
+ # Override protocol/permission for a one-off worker without re-hiring
429
+ NANO_AGENTIC_PROTOCOL=pipe c8ctl nano work coder
430
+ NANO_AGENTIC_PERMISSION=escalate c8ctl nano work coder
431
+ ```
432
+
433
+ - `--protocol pipe|acp` (default `pipe`) selects the harness protocol.
434
+ `NANO_AGENTIC_PROTOCOL` overrides it at work time (mirroring
435
+ `NANO_AGENTIC_TERMINAL`).
436
+ - `--permission yolo|escalate|filter` (default `yolo`) selects the ACP permission
437
+ policy. `NANO_AGENTIC_PERMISSION` overrides it at work time.
438
+
439
+ **What ACP unlocks.** Because the harness speaks a structured protocol rather than
440
+ a scraped terminal, the ACP path gives you a **structured turn/tool event stream**
441
+ (today serialized to text chunks on the relay lane — a *minimal* mode, not yet
442
+ typed turn/tool envelopes), **native permission handling**, and **PTY-free
443
+ steering** — an operator's
444
+ steer text is delivered as a `session/prompt` and an interrupt as a
445
+ `session/cancel`, with no keystroke injection. The ACP path therefore does **not**
446
+ need the optional native `node-pty` dependency at all.
447
+
448
+ **Permission policies — mind the status.** Only `yolo` is enforced today:
449
+
450
+ - **`yolo`** *(default, the only enforced policy today)* — auto-allows every
451
+ permission request the agent raises: full speed, no human in the loop. This is
452
+ the same auto-approve posture the pipe/PTY paths already run with.
453
+ - **`escalate`** and **`filter`** are **RESERVED / not-yet-active** in this build.
454
+ They are **accepted and persisted** for forward-compatibility, but they are
455
+ **not yet enforced**: at work time they fall back to a **safe interim policy**
456
+ (currently auto-allow, like `yolo`) and the CLI **emits a warning** so an
457
+ operator is never misled into thinking destructive operations are gated. Their
458
+ intended future behavior — **`escalate`** blocking a permission request until a
459
+ human answers it, and **`filter`** auto-allowing reads/edits while escalating
460
+ destructive operations — is **not available yet**; it lands once the companion
461
+ permission-event + escalation bridge (nanobpm/nano-workforce#559) ships.
462
+
463
+ **Per-CLI hire examples.** Some CLIs speak ACP natively; others ride a thin
464
+ adapter binary. In every case the profile's command (plus any `--arg`s) assembles
465
+ the ACP invocation; a default `--acp` switch is appended only when the assembled
466
+ command line doesn't already select ACP, so a native/adapter invocation is never
467
+ doubled. All of these run with the enforced default `yolo` policy:
468
+
469
+ ```bash
470
+ # Copilot CLI — native ACP (assembles `copilot --acp`)
471
+ c8ctl nano hire --name coder --rank senior --command copilot --protocol acp --permission yolo
472
+
473
+ # OpenCode — native ACP server (assembles `opencode acp`)
474
+ c8ctl nano hire --name coder --rank senior --command opencode --arg acp --protocol acp --permission yolo
475
+
476
+ # Claude Code — via the `claude-agent-acp` adapter
477
+ c8ctl nano hire --name coder --rank senior --command claude-agent-acp --protocol acp --permission yolo
478
+
479
+ # Pi — via the `pi-acp` adapter
480
+ c8ctl nano hire --name coder --rank senior --command pi-acp --protocol acp --permission yolo
481
+ ```
482
+
483
+ > `--permission yolo` is the default, so you can omit it. You **may** hire with
484
+ > `--permission escalate` or `--permission filter` today — the value is persisted
485
+ > — but it is **reserved / not-yet-active** (pending nanobpm/nano-workforce#559)
486
+ > and currently behaves as the safe interim policy with a warning, so do **not**
487
+ > rely on it to gate destructive operations yet.
488
+
489
+ **Non-goals.** ACP does not replace anything: the **pipe/PTY** surface stays the
490
+ default floor and every existing harness keeps working unchanged (ACP is enforced
491
+ on the host executor; container sandboxes remain **pipe-only** for now). There is
492
+ **no change to the Camunda-8 worker⇄engine job protocol** — ACP governs only how a
493
+ worker drives its local agent harness, not how it talks to the engine.
494
+
412
495
  ### Live profile reload (no restart on `assign`)
413
496
 
414
497
  A running `c8ctl nano work <name>` **watches** the profile it is servicing. When
package/c8ctl-plugin.js CHANGED
@@ -4026,11 +4026,15 @@ const ACP_MAX_LINE_BYTES = 8 * 1024 * 1024; // 8 MiB
4026
4026
 
4027
4027
  // Drive an ACP (Agent Client Protocol) agent over JSON-RPC 2.0 on stdio.
4028
4028
  //
4029
- // This is the "minimal mode" executor (#110, step 1): it proves ACP end-to-end
4030
- // with ZERO downstream changes by serialising each `session/update` to a short
4031
- // human-readable TEXT chunk and feeding it to the SAME relay/tee lane the pipe
4032
- // path uses (`relayTap.onData`) it never tee's the raw JSON-RPC. Typed
4033
- // transcript envelopes are a separate downstream task.
4029
+ // This executor drives ACP end-to-end. Each `session/update` is mapped to a
4030
+ // typed `nwfTranscriptEvent` envelope (#110, step 2) and published on the relay
4031
+ // session's typed publish seam (`relayTap.relayEnvelope`) the rich cockpit
4032
+ // format its derive+render consumes. The raw relay TRANSPORT is untouched (still
4033
+ // `relaySession.relay(text)`); only the payload shape on the lane changes. When
4034
+ // an update has no typed mapping, or the relay exposes no typed seam (minimal
4035
+ // mode / a plain tap), it falls back to the step-1 human-TEXT chunk on the same
4036
+ // lane (`relayTap.onData`) so nothing is dropped. The raw JSON-RPC is never
4037
+ // tee'd either way.
4034
4038
  //
4035
4039
  // Framing: ACP frames are newline-delimited JSON-RPC 2.0 messages on stdio (one
4036
4040
  // compact JSON object per line, `\n`-terminated). We implement a tiny inline
@@ -4111,22 +4115,35 @@ function spawnCaptureAcp({ command, args = [], cwd, env, stdinData, timeoutMs, i
4111
4115
 
4112
4116
  const humanStdout = () => joinCapped(humanChunks);
4113
4117
 
4114
- // Emit a human-meaningful text chunk on the SAME lanes the pipe/pty paths
4115
- // use: the relay tap (framed + jobKey-tagged by the caller) and the local
4116
- // --stream spy tee. Byte-capped like the raw captures.
4117
- const emitHuman = (text) => {
4118
+ // Local mirrors of a human text chunk: the --stream spy tee and the byte-
4119
+ // capped stdout capture (what the result envelope carries). Deliberately does
4120
+ // NOT touch the relay lane, so a typed-transcript update can mirror its human
4121
+ // text locally (for the result + spy) WITHOUT also re-emitting raw text onto
4122
+ // the relay lane — which, in step 2, carries the typed envelope instead.
4123
+ const captureHuman = (text) => {
4118
4124
  if (!text) return;
4119
4125
  const buf = Buffer.from(text, 'utf8');
4120
4126
  if (teeSink) tee(text, false);
4121
- if (relayTap && typeof relayTap.onData === 'function') {
4122
- try { relayTap.onData(text); } catch { /* relay best-effort */ }
4123
- }
4124
4127
  const remaining = MAX_CAPTURE_BYTES - humanBytes;
4125
4128
  if (remaining <= 0) { humanTruncated = true; return; }
4126
4129
  if (buf.length > remaining) { humanChunks.push(buf.subarray(0, remaining)); humanBytes = MAX_CAPTURE_BYTES; humanTruncated = true; }
4127
4130
  else { humanChunks.push(buf); humanBytes += buf.length; }
4128
4131
  };
4129
4132
 
4133
+ // Emit a human-meaningful text chunk on the SAME lanes the pipe/pty paths
4134
+ // use: the relay tap (framed + jobKey-tagged by the caller) and the local
4135
+ // --stream spy tee. Byte-capped like the raw captures. This is the minimal-
4136
+ // mode text path — used for stderr, and as the fallback for any session/update
4137
+ // that has no typed nwfTranscriptEvent mapping (or when the relay exposes no
4138
+ // typed publish seam).
4139
+ const emitHuman = (text) => {
4140
+ if (!text) return;
4141
+ if (relayTap && typeof relayTap.onData === 'function') {
4142
+ try { relayTap.onData(text); } catch { /* relay best-effort */ }
4143
+ }
4144
+ captureHuman(text);
4145
+ };
4146
+
4130
4147
  const finish = (result) => {
4131
4148
  if (settled) return;
4132
4149
  settled = true;
@@ -4200,25 +4217,30 @@ function spawnCaptureAcp({ command, args = [], cwd, env, stdinData, timeoutMs, i
4200
4217
  }
4201
4218
  };
4202
4219
 
4203
- // Serialise an ACP session/update into a short human-readable line. Minimal
4204
- // mode: this is TEXT for the existing cockpit lane, not a typed envelope.
4220
+ // Extract plain text from an ACP content value (string, {type,text}, or an
4221
+ // array of content blocks). Shared by the typed-envelope mapper and the
4222
+ // human-text describer so both agree on what the "text" of an update is.
4223
+ const acpTextOf = (content) => {
4224
+ if (content == null) return '';
4225
+ if (typeof content === 'string') return content;
4226
+ if (Array.isArray(content)) return content.map(acpTextOf).join('');
4227
+ if (typeof content === 'object') return typeof content.text === 'string' ? content.text : '';
4228
+ return '';
4229
+ };
4230
+
4231
+ // Serialise an ACP session/update into a short human-readable line. This is
4232
+ // the minimal-mode TEXT for the existing cockpit lane (the fallback), not a
4233
+ // typed envelope.
4205
4234
  const describeUpdate = (update) => {
4206
4235
  if (!update || typeof update !== 'object') return '';
4207
4236
  const kind = update.sessionUpdate || update.type || 'update';
4208
- const textOf = (content) => {
4209
- if (content == null) return '';
4210
- if (typeof content === 'string') return content;
4211
- if (Array.isArray(content)) return content.map(textOf).join('');
4212
- if (typeof content === 'object') return typeof content.text === 'string' ? content.text : '';
4213
- return '';
4214
- };
4215
4237
  switch (kind) {
4216
4238
  case 'agent_message_chunk':
4217
- return textOf(update.content);
4239
+ return acpTextOf(update.content);
4218
4240
  case 'agent_thought_chunk':
4219
- return `\u{1F4AD} ${textOf(update.content)}`;
4241
+ return `\u{1F4AD} ${acpTextOf(update.content)}`;
4220
4242
  case 'user_message_chunk':
4221
- return textOf(update.content);
4243
+ return acpTextOf(update.content);
4222
4244
  case 'tool_call': {
4223
4245
  const title = update.title || update.toolCallId || 'tool';
4224
4246
  return `\u2699 [tool: ${title}${update.status ? ` — ${update.status}` : ''}]\n`;
@@ -4234,6 +4256,80 @@ function spawnCaptureAcp({ command, args = [], cwd, env, stdinData, timeoutMs, i
4234
4256
  }
4235
4257
  };
4236
4258
 
4259
+ // #110 step 2: map an ACP session/update to a typed `nwfTranscriptEvent`
4260
+ // envelope — the rich cockpit wire format (the existing downstream
4261
+ // derive+render consumes it). Returns null for an update kind we don't model,
4262
+ // so the caller falls back to the minimal human-text path (nothing dropped,
4263
+ // no regression vs step 1). The `text` field carries the same plain text the
4264
+ // fallback would relay, so a lightweight consumer can still render it.
4265
+ const TRANSCRIPT_EVENT_TYPE = 'nwfTranscriptEvent';
4266
+ const TRANSCRIPT_EVENT_VERSION = 1;
4267
+ const mapTranscriptEnvelope = (update) => {
4268
+ if (!update || typeof update !== 'object') return null;
4269
+ const kind = update.sessionUpdate || update.type;
4270
+ if (!kind) return null;
4271
+ const base = { type: TRANSCRIPT_EVENT_TYPE, v: TRANSCRIPT_EVENT_VERSION, ts: Date.now() };
4272
+ // Optional fields stay `undefined` (JSON encoding omits them) rather than
4273
+ // becoming explicit `null`s, and `??` preserves empty strings — so
4274
+ // consumers see omitted/optional strings, not coerced nulls. `status`
4275
+ // falls through to the kind's default only when genuinely absent.
4276
+ const toolOf = (u, defaultStatus) => ({
4277
+ id: u.toolCallId ?? undefined,
4278
+ title: u.title ?? undefined,
4279
+ status: u.status ?? defaultStatus ?? undefined,
4280
+ kind: u.kind ?? undefined,
4281
+ });
4282
+ switch (kind) {
4283
+ case 'agent_message_chunk':
4284
+ return { ...base, kind: 'message', role: 'agent', text: acpTextOf(update.content) };
4285
+ case 'agent_thought_chunk':
4286
+ return { ...base, kind: 'thought', role: 'agent', text: acpTextOf(update.content) };
4287
+ case 'user_message_chunk':
4288
+ return { ...base, kind: 'message', role: 'user', text: acpTextOf(update.content) };
4289
+ case 'tool_call':
4290
+ // `text` mirrors the fallback's plain text so the typed envelope stays
4291
+ // self-contained for lightweight renderers (matches the stated contract).
4292
+ return { ...base, kind: 'tool_call', text: describeUpdate(update), tool: toolOf(update, 'pending') };
4293
+ case 'tool_call_update':
4294
+ return { ...base, kind: 'tool_call_update', text: describeUpdate(update), tool: toolOf(update, undefined) };
4295
+ case 'plan':
4296
+ // Carry the actual plan entries (rich cockpit renders them), not a
4297
+ // count — an absent/malformed payload stays `undefined` (omitted).
4298
+ return { ...base, kind: 'plan', entries: Array.isArray(update.entries) ? update.entries : undefined };
4299
+ default:
4300
+ // Unmodelled kind → no typed envelope; caller uses the text fallback.
4301
+ return null;
4302
+ }
4303
+ };
4304
+
4305
+ // Publish a session/update: prefer the typed nwfTranscriptEvent envelope on
4306
+ // the relay's typed publish seam; fall back to the minimal human-text path
4307
+ // when the update has no typed mapping OR the relay exposes no typed seam, so
4308
+ // nothing is ever dropped (no regression vs minimal mode).
4309
+ const emitTranscript = (update) => {
4310
+ const env = mapTranscriptEnvelope(update);
4311
+ if (env && relayTap && typeof relayTap.relayEnvelope === 'function') {
4312
+ // Only skip the text fallback when the typed publish ACTUALLY succeeded.
4313
+ // If the seam throws (a downstream tap implementation, not just the
4314
+ // built-in best-effort stringify guard), the envelope never reached the
4315
+ // relay lane — so we must fall through to the text path or that update
4316
+ // would be silently dropped, breaking the "nothing is ever dropped"
4317
+ // guarantee.
4318
+ let published = false;
4319
+ try { relayTap.relayEnvelope(env); published = true; } catch { /* relay best-effort */ }
4320
+ if (published) {
4321
+ // Mirror the human text locally (spy tee + captured stdout) so the
4322
+ // result envelope and --stream spy are unchanged — without re-emitting
4323
+ // raw text onto the relay lane, which now carries the typed envelope.
4324
+ captureHuman(describeUpdate(update));
4325
+ return;
4326
+ }
4327
+ // Typed publish threw → fall through to the text lane below.
4328
+ }
4329
+ // Fallback: minimal text-chunk path (relay text + spy tee + capture).
4330
+ emitHuman(describeUpdate(update));
4331
+ };
4332
+
4237
4333
  const handleMessage = (msg) => {
4238
4334
  if (!msg || typeof msg !== 'object') return;
4239
4335
  // A response to one of OUR requests.
@@ -4248,7 +4344,7 @@ function spawnCaptureAcp({ command, args = [], cwd, env, stdinData, timeoutMs, i
4248
4344
  }
4249
4345
  // A request or notification FROM the agent.
4250
4346
  if (typeof msg.method === 'string') {
4251
- if (msg.method === 'session/update') { emitHuman(describeUpdate(msg.params?.update)); return; }
4347
+ if (msg.method === 'session/update') { emitTranscript(msg.params?.update); return; }
4252
4348
  if (msg.method === 'session/request_permission') {
4253
4349
  if (msg.id !== undefined) handlePermission(msg.id, msg.params);
4254
4350
  return;
@@ -4590,6 +4686,18 @@ function runAgentJob(profile, job, opts = {}) {
4590
4686
  const relayTap = relaySession
4591
4687
  ? {
4592
4688
  onData: (buf) => relaySession.relay(buf),
4689
+ // #110 step 2: typed transcript publish seam. The ACP producer maps each
4690
+ // session/update to an `nwfTranscriptEvent` envelope and publishes it
4691
+ // here; we JSON-encode it (newline-delimited) onto the SAME relay lane —
4692
+ // the raw relay TRANSPORT (ring/QoS/offsets/jobKey routing) is unchanged,
4693
+ // still `relaySession.relay(text)`. Consumers (cockpit derive+render)
4694
+ // parse the envelope; unmapped updates fall back to the `onData` text path
4695
+ // so nothing is dropped (no regression vs the minimal-mode floor).
4696
+ // Best-effort: a bad envelope (circular refs / BigInt making
4697
+ // JSON.stringify throw) must never crash the worker, so swallow here.
4698
+ relayEnvelope: (env) => {
4699
+ try { relaySession.relay(`${JSON.stringify(env)}\n`); } catch { /* relay best-effort */ }
4700
+ },
4593
4701
  attachSteer: (write) => relaySession.attachSteer(write),
4594
4702
  }
4595
4703
  : null;
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.0.15",
3
- "commit": "a2d2d77",
4
- "updated": "2026-08-18T06:11:07Z"
2
+ "version": "0.0.16",
3
+ "commit": "9014e90",
4
+ "updated": "2026-08-26T23:27:46Z"
5
5
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "c8ctl-plugin-nano",
3
- "version": "1.41.0",
3
+ "version": "1.42.1",
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",
@@ -57,12 +57,12 @@
57
57
  },
58
58
  "optionalDependencies": {
59
59
  "node-pty": "^1.0.0",
60
- "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.41.0",
61
- "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.41.0",
62
- "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.41.0",
63
- "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.41.0",
64
- "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.41.0",
65
- "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.41.0",
66
- "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.41.0"
60
+ "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.42.1",
61
+ "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.42.1",
62
+ "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.42.1",
63
+ "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.42.1",
64
+ "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.42.1",
65
+ "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.42.1",
66
+ "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.42.1"
67
67
  }
68
68
  }