npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

c8ctl-plugin-nano

v1.70.3

Published

c8ctl plugin to start, inspect, and stop a local Nano BPM (nanobpmn) cluster

Readme

c8ctl-plugin-nano

A c8ctl plugin that starts, inspects, and stops a local Nano BPM (nanobpmn) cluster.

About Nano BPM

A Rust research engine exploring high-performance BPMN execution and Camunda 8 compatibility.

Nano BPM (nanobpmn) is a single self-contained binary that runs BPMN processes behind a Camunda 8-compatible v2 REST API. It embeds a deterministic, event-sourced BPMN engine (engine-core), an append-only journal for crash durability, an SQLite-backed read model, optional multi-node Raft replication, and a built-in web console — all in one executable with no runtime dependencies.

It is an advanced research prototype: a place to explore what a faster, smaller, faster-to-iterate-on process engine can do, while staying API-compatible with existing Camunda 8 clients and tooling. This plugin is the easiest way to run and manage it — single node or a whole cluster — shipping a prebuilt binary for your platform so there is nothing to compile.

It adds a single nano command:

c8ctl nano start|status|stop|restart|logs|pause|resume|clean|set|config|update
c8ctl nano hire|assign|work   # hire/assign manage agent profiles; work runs one as a Nano job worker

nano start N spawns N nanobpmn node processes wired to talk to each other on localhost (round-robin partition ownership), tracks them in a state file, and waits until every node is reachable.

Installation

This is a plugin for the Camunda 8 CLI (c8ctl). Install the CLI, then load the plugin from npm:

# 1. Install the Camunda 8 CLI (once); requires Node.js 18+
npm install -g @camunda8/cli

# 2. Load this plugin from the npm registry
c8ctl load plugin c8ctl-plugin-nano

# 3. Verify it's available
c8ctl nano --help

The prebuilt Nano BPM server binary for your platform is pulled in automatically as an npm optionalDependency, so there is nothing to compile. To pull a newer release later, run c8ctl nano update (see Updating to a new release).

Loading from a local checkout instead? Use c8ctl load plugin --from file:///path/to/c8ctl-nano.

Usage

# Start a single-node cluster (port 8080)
c8ctl nano start

# Start a 3-node cluster (ports 8080, 8081, 8082)
c8ctl nano start 3

# Start a 3-node Raft-replicated cluster (RF=3 enables Raft automatically)
c8ctl nano start 3 --rf 3

# Choose a different base port (nodes -> 9000, 9001, 9002)
c8ctl nano start 3 --port 9000

# Override the partition count (default = node count)
c8ctl nano start 3 --partitions 6

# Show cluster status and per-node health
c8ctl nano status

# Inspect a cluster c8ctl did NOT start (queries /v2/topology on the given port)
c8ctl nano status --port 8080

# Tail a node's log (-f / --follow to stream). Node ids are 0-indexed,
# so a single-node cluster is node 0.
c8ctl nano logs 0 --follow

# Simulate a node failing (freeze it) and recovering (resume it)
c8ctl nano pause 0
c8ctl nano resume 0

# Stop the cluster (engine data is retained)
c8ctl nano stop

# Stop the cluster and delete per-node engine data
c8ctl nano stop --purge

# Stop then start fresh
c8ctl nano restart 3

# Restart from a clean slate (delete engine data, keep models & workers)
c8ctl nano restart --purge

# Wipe journal/data + logs from disk (keeps models & workers)
c8ctl nano clean

# Persist settings
c8ctl nano set bin   ~/workspace/nanobpmn/server/target/release/nanobpm-gateway-rest-server
c8ctl nano set model-dir ~/bpmn-workspace

# Clear a custom setting (back to the managed/release binary, or default workspace)
c8ctl nano unset bin
c8ctl nano unset model-dir

# Show current configuration and on-disk locations
c8ctl nano config

Persistent assets: models & workers

Nano BPM separates persistent authoring assets (BPMN models and worker code) from ephemeral engine data (journal, snapshots, variable spill):

  • Workspace (NANOBPMN_WORKSPACE_DIR) — holds models/ and workers/. It is the authoring source of truth, shared by every node, and is never deleted by stop or clean.
  • Engine data (NANOBPMN_DATA_DIR) — per-node journal/snapshots/spill. Ephemeral; removed by stop --purge and clean.

The plugin points every node at one shared workspace so a model deployed once is visible cluster-wide and survives restarts. By default it lives at <state home>/workspace; change it with:

c8ctl nano set model-dir ~/bpmn-workspace

This creates ~/bpmn-workspace/models/ and ~/bpmn-workspace/workers/. Restart a running cluster for a workspace change to take effect.

CLI agent workers: hire / work

Beyond BPMN service-task workers (code in the workspace workers/ dir), the plugin can turn an interactive CLI agent harness (Copilot CLI, Claude CLI, pi, "little coder", …) into a Nano job worker.

hire persists an agent profile — a name, a rank (principal|senior|junior|decider), the command that starts the CLI, a model name, and a list of capabilities:

# Interactive
c8ctl nano hire

# Or non-interactively
c8ctl nano hire --name reviewer --rank senior --command copilot \
  --model gpt-5 --capabilities code-review,testing

# Give the harness command-line switches (e.g. run copilot with --allow-all)
c8ctl nano hire --name coder --rank senior --command copilot --arg --allow-all

# Opt a role into the ACP harness mode (JSON-RPC/stdio) — see "ACP harness mode" below
c8ctl nano hire --name coder --rank senior --command copilot --protocol acp

# List profiles
c8ctl nano hire --list

assign <name> [capabilities...] grants new capabilities (roles) to an existing hire without re-running hire. Capabilities are added to (unioned with) the profile's current set — assign never removes a role — and the updated job-type matrix is printed. Restart the profile's workers so they pick up the new job types:

# Give an existing reviewer two more capabilities
c8ctl nano assign reviewer triage refactoring

# --capabilities works too (comma-separated), equivalent to the positionals above
c8ctl nano assign reviewer --capabilities triage,refactoring

# then restart its workers to service the new job types
c8ctl nano work reviewer

work <name> loads the profile, connects with the c8ctl SDK client, and registers one job worker per token in the rank × capability matrix, then polls for work in the foreground until Ctrl-C. For rank senior and capabilities code-review, testing the matrix is:

| Token | Meaning | | --- | --- | | senior | rank alone | | senior:code-review | rank + one capability (spread) | | senior:testing | rank + one capability (spread) | | senior:code-review+testing | rank + all capabilities, sorted (combined) |

so a BPMN service task can target a worker at any granularity by setting its job type to the matching token.

Choosing which engine a worker connects to. By default work (and supervisor start --worker) connects to your active c8ctl session profile (c8ctl use profile <name>). To point a single invocation at a different cluster without switching the active session, pass c8ctl's global --profile <name> — it is honoured the same way it is for core c8ctl commands:

c8ctl nano work fleet --profile nano-validate            # this worker only → nano-validate's engine
c8ctl nano supervisor start --worker fleet --profile nano-validate   # the whole fleet → nano-validate

The named profile wins over the active session for that run only; a supervised fleet is pinned to it (the daemon forwards it to every worker it spawns), and supervisor status reports the matching ENGINE.

To also service a job type the matrix can't express — for example a code-first @nanobpm/workflow flow whose external task type is <flowId>:<taskName>, or any bespoke token — add one or more --job-type <token> flags. They are serviced in addition to the rank×capability matrix, so a single hired reviewer can drive both a model-first senior:pr-review task and a code-first flow's task without re-hiring:

c8ctl nano work reviewer --job-type convergence-loop:review-round
c8ctl nano work reviewer --job-type senior:pr-review --job-type senior:triage
c8ctl nano work reviewer                     # poll for work until Ctrl-C
c8ctl nano work reviewer --recovery-window 300000
c8ctl nano work reviewer --name reviewer-eu  # name this worker (else auto ‹host›-‹profile›-‹random›)

Zero-config enrolment: --auto (serve every deployed agent job type)

--auto is the "Borland Delphi on your machine" onboarding ramp: skip the capability wiring entirely and subscribe the worker to all deployed agent job types, read straight from the engine.

c8ctl nano work coder --auto                  # serve every agent job type on the engine
c8ctl nano work coder --auto --auto-scope my-app   # scope to one app/network (process-id prefix)

How it works and why it needs no wiring:

  • Engine-read demand. The worker already holds the engine (C8 REST) endpoint from its c8ctl profile. --auto enumerates the deployed process definitions (process-definitions/search → /{key}/xml) and scans their <zeebe:taskDefinition type> leaves for the job types the engine matches. The engine is the guaranteed shared rendezvous: if a worker can execute an app's agent jobs at all, it and the app are already on the same engine, so what agent job types exist is answerable from that engine alone — no cross-machine app discovery, no app enrol endpoint, no channel connection.
  • Agent-task marker filter. Not every service task is an agent task — connectors and record-keepers (e.g. pr.record-plan) are plain workers. --auto keeps only leaves whose service task carries the single-convention external-agent marker <zeebe:agentDefinition agentType="external" /> (the same marker every external agent task already declares). A task can opt out of --auto with <zeebe:property name="io.nanobpm.agentTask.autoSubscribe" value="false" /> — it is then served only by explicit --job-type/profile subscription.
  • One poller per agent job type, reconciled on change. It opens one poller per agent job type and re-reads the engine periodically, adding pollers for newly deployed agent processes and draining pollers for undeployed ones — the same in-place reconcile the profile watch uses, sourced from the engine instead of the profile.
  • Raw job-type grammar. The job type the engine matches (senior:plan) is advertised verbatim — colon-named types are not forced through any dot-grammar.

--auto is mutually exclusive with capability-resolved serving: it bypasses the rank×capability matrix (any --job-type extras are still added). The prompt a worker needs already rides the job header (io.nanobpm.agentTask.task.prompt) plus per-instance context, so a generic --auto worker needs no baked specialisation.

Trust. Engine-read has no capability gate — an --auto worker will serve any deployed agent job on its engine. That is the accepted trade for the local/zero-config target; capability-gated serving is the specialised (capability-declared) enrolment path. Use --auto-scope <process-id | prefix> to narrow the blast radius to one app/network.

Dual-stack / IPv6-first engine hosts. The engine client races IPv4 and IPv6 (Happy-Eyeballs, RFC 8305) at connect time, so a worker whose engine host resolves to an unreachable IPv6 address first — common with macOS mDNS (merlin.local → fe80::… (dead) → 192.168.x.x), a stray/misordered AAAA, or an IPv6-advertised host — transparently falls back to IPv4 instead of failing the activation / --auto engine read (fetch failed / write EPIPE / UND_ERR_CONNECT_TIMEOUT). Without this, the --auto autoscaler would read 0 job types and silently scale the fleet to zero (workers vanish from the Nano console) even though curl/fetch reach the same host fine (jwulf/c8ctl-plugin-nano#139). No configuration is needed; if you still want to pin a family you can set NODE_OPTIONS=--dns-result-order=ipv4first or point the engine URL at the IPv4 literal.

The optional --name sets this worker's name — the workerName it registers under at the broker (‹name›:‹jobType›) and how it shows up in supervisor status/logs. Omit it and a distinct ‹host›-‹profile›-‹random› name is generated, so two work reviewer processes never collide at the broker. (--name names the worker; the profile to run is always the positional argument.)

Live agent visibility: the /agentic channel + live terminals

A running worker can appear live on the Workforce visibility page and stream its agents' terminals to an operator's cockpit. This rides the app's agentic channel (ADR 0056), served same-port on the app's own HTTP base URL at path /agentic — not a sidecar, so there's no extra port to open.

Connecting — on by default (LAN-first). Nano is designed to run on a trusted network, so visibility is on by default. Run a worker against a Nano app and it appears live with zero configuration — it joins the channel with a well-known token in LOCAL mode (no secret). The worker presents this token to whatever NANO_AGENTIC_URL you point it at; the hub honours the well-known LOCAL token from any origin (matching the open trusted-LAN posture of the engine itself), so a worker on another box on the LAN appears live too — exposure is governed by the server's bind address, not by a shared secret:

# LOCAL mode (default): appears live with no secrets
export NANO_AGENTIC_URL=http://localhost:8080     # app base URL; channel is served at /agentic
c8ctl nano work reviewer
#   agentic channel (local): announcing presence as ‹worker› on ws://localhost:8080/agentic

Zero-config hub auto-discovery. You usually don't even set NANO_AGENTIC_URL. When nwf runs embedded, the engine (:8080) serves the console but the /agentic channel is served by the embedded app on its own port (e.g. :3000); the engine's console proxy deliberately refuses WebSocket upgrades (nanobpmn ADR 0057 §3 → 501), so the channel is unreachable via the engine URL. With no agentic target configured, work therefore auto-discovers it: it reads GET <engine>/console/api/projects and, for each running app that advertises an agentic UI port (appUi.enabled === true and appUi.port), probes that app's direct ws://<engine-host>:<port>/agentic. Discovery runs against the engine's own host — a local engine keeps probing 127.0.0.1, while a remote/LAN engine (e.g. merlin.local:8080) steers the probe back at itself (merlin.local:<port>), never at the worker's own loopback services. It is time-bounded (≤2s) and never meaningfully delays job polling. The discovered host and port are printed for debugging. (An IPv6 literal engine host is bracketed in the URL authority, e.g. ws://[2001:db8::1]:3000/agentic.)

  • Exactly one app → the worker connects directly to ws://<engine-host>:<appUi.port>/agentic (bypassing the WS-incapable console proxy) and appears live with zero configuration — including cross-machine on a trusted LAN.
  • Two or more apps → the worker does not guess: it prints an ambiguous error naming each discovered project → :port and stops. Pin the one you want and re-run: export NANO_AGENTIC_URL=http://<engine-host>:<port> (or persist agenticUrl).
  • Nothing discoverable (e.g. pointed at Camunda, or an API-only gateway) → the worker prints a one-line advisory naming NANO_AGENTIC_URL and continues doing real work with the channel simply absent — discovery never fails the worker's actual job.

Setting NANO_AGENTIC_URL (or persisted agenticUrl) skips discovery and is used verbatim — so an explicit target always wins, and it's also how you disambiguate when several apps are running. NANO_AGENTIC=off disables the channel entirely and attempts no discovery.

Seeing it work — supervisor status. Supervised workers report both the engine they poll and their agentic-channel state to c8ctl nano supervisor status (and the interactive console), so you don't have to read raw worker logs to tell whether presence actually reached the Workforce hub. The table gains an ENGINE column (the engine's host:port) and an AGENTIC column whose value is one of:

| AGENTIC | meaning | | --- | --- | | starting | transient: the worker just spawned and hasn't resolved its channel target yet (pre-connecting) | | connected | presence is live on the hub — you should see this worker in the Cockpit | | connecting | resolved a hub, socket not open yet (or the hub is unreachable) | | disconnected | an established channel dropped (hub restart/outage) — it auto-reconnects, and a worker-side liveness watchdog force-re-discovers the hub if it stays down (see below); also set if the channel failed to start (bad URL/refused socket), which is not auto-recovered by the watchdog (it only guards a channel that has connected) — fix the target and restart the worker | | advisory | nothing discoverable at the engine — not in the Cockpit; set NANO_AGENTIC_URL | | off | visibility disabled (NANO_AGENTIC=off) | | ? | a live worker not yet reporting, or an older build predating these fields |

If workers show advisory (or stay connecting) while jobs still run, that's the "connected to the engine but empty Cockpit" case: point them at the app with export NANO_AGENTIC_URL=http://<engine-host>:<appUi.port> (e.g. :3000).

settlement-pending in the JOB column. A worker's JOB cell normally shows the job key it is running (or idle). If an agent finished its external side effect but the worker then lost its activation lease around completeJob/failJob — so the fenced settle failed and the engine may still project the job as CREATED — the cell instead shows <jobKey> settlement-pending (<age>), where <age> is how long settlement has been pending (measured from the failed settle, not the job's start). This is observational only: recovery is already handled by the lease-fence + transcript-resume path, and such a job is not counted as busy/in-flight (it doesn't hold up a drain). The state lets you see a job caught between side effect and settlement rather than inferring it from a silently-idle worker plus a stuck CREATED job; it clears when the job is re-activated on this worker, self-expires after a bounded TTL (so a re-activation that lands on a different worker can't leave a ghost lingering here), or the worker restarts.

Liveness watchdog (auto-recovery from a wedged channel). If the nano server restarts, crashes, or a network partition drops the connection without a clean close (a half-open socket), a worker's channel client can sit disconnected forever — the worker vanishes from the Nano Workers view / Cockpit and, before this, only a supervisor restart brought it back. Each worker now runs a belt-and-suspenders watchdog: once a channel that had connected stays down past a threshold (the client library's own reconnect never recovered it), the worker tears the wedged channel down and re-runs full hub discovery + reopen — no restart needed. The thresholds are tunable via env (sensible defaults; you rarely need these):

export NANO_AGENTIC_STALE_MS=60000     # force re-discovery if a drop hasn't recovered within 60s (default)
export NANO_AGENTIC_WATCHDOG_MS=15000  # how often the watchdog checks channel liveness (default)

Lossy-link hardening (reconnect churn that never re-lands presence). A distinct failure mode shows up on a lossy/roaming WiFi (or NAT) link: the drop is detected (an abnormal closure, code 1006), the client does reconnect and re-announce — yet presence never re-lands on the hub, so the worker stays absent even though its log shows it re-announcing. The sustained-drop watchdog above does not catch this, because each brief reconnect keeps resetting its drop clock. Two extra safeguards close the gap:

  • Presence-keyed watchdog trigger. A reconnect only counts as recovered once the socket holds for a short grace window (so a 1006 blip that immediately re-drops does not mask an unrecovered presence). When presence has not been confirmed within a threshold — regardless of the reconnect flapping — the watchdog forces the same full re-discovery + reopen.
  • Jittered reconnect backoff. Reconnect attempts are spread with equal-jitter backoff so a fleet dropped on the same link does not reconnect in lockstep and re-congest it.
export NANO_AGENTIC_PRESENCE_STALE_MS=60000  # force re-discovery if presence isn't re-confirmed within 60s (default)
export NANO_AGENTIC_PRESENCE_GRACE_MS=5000   # how long a reconnect must hold before presence counts as landed (default)

Secure mode (opt-in). For a deployment where you want the visibility channel authenticated (rather than open on the LAN), start the server and every worker box with the same NANO_AGENTIC_SECRET — same env-var name, same value on both sides (Tab A → Slot A). The worker presents it as its identity token and the hub verifies it against its own NANO_AGENTIC_SECRET. Setting it switches the worker into SECURE mode:

# SECURE mode: same NANO_AGENTIC_SECRET on the server and every worker box
export NANO_AGENTIC_URL=http://localhost:8080
export NANO_AGENTIC_SECRET=<shared-secret>         # must equal the server's NANO_AGENTIC_SECRET
c8ctl nano work reviewer
#   agentic channel (secure): announcing presence as ‹worker› on ws://localhost:8080/agentic

The shared secret can also be persisted (via config) as agenticSecret, so it need not be exported into the environment on every run; env still wins over the persisted value.

The legacy NANO_AGENTIC_TOKEN env var (and persisted agenticToken) is still accepted as a deprecated alias for the shared secret. The capability credential (NANO_AGENTIC_CREDENTIAL) is no longer required — it was removed from the hub contract (it was accept-any, pure friction). It remains optional and is forwarded only if still configured, for forward-compatibility with a future per-peer capability verifier.

To opt out entirely, set NANO_AGENTIC=off (or persisted agentic: false) — the worker then runs with no visibility, no relay, nothing else changed. In secure mode a matching secret connects; a wrong secret is rejected (unauthorized).

How presence appears. On connect the worker announces its identity, its host, and the set of jobKeys it is currently running, then heartbeats to stay live and re-announces after a reconnect so its row survives a hub restart. When work stops it deregisters cleanly, so the worker disappears from the visibility page on exit.

Opting a role into a live terminal (PTY vs pipe). Each role chooses whether its agent harness runs on a full PTY (a real terminal — streamed on the relay lane and steerable: an operator's keystrokes reach the running agent) or a plain pipe (streamed, not interactive). It's a per-role opt-in, set on the hire profile and defaulting to pipe:

# Hire a role whose harness runs on a full, steerable PTY
c8ctl nano hire --name coder --rank senior --command copilot --terminal pty

# Override the mode for a one-off worker without re-hiring
NANO_AGENTIC_TERMINAL=pipe c8ctl nano work coder

A PTY needs the optional native node-pty dependency; when it is unavailable (not installed, or Windows) a pty role gracefully falls back to a pipe that still relays. (If node-pty is present but a PTY can't be spawned at runtime, that job fails rather than falling back.) Each job's terminal streams on its own relay stream named job:‹jobKey›, so output and steer-in are routed from the jobKey alone.

Surviving a hub outage. A worker that starts before the app, or survives a hub restart, buffers its outbound frames in a bounded local ring and drains them in order on reconnect (no loss or reorder within the bound). The bound is operator-tunable for a long expected outage; raise it with NANO_AGENTIC_BUFFER_CAPACITY (frames). When the bound is hit the worker warns rather than silently shedding.

ACP harness mode (opt-in)

Alongside the pipe/pty terminal choice above, a role can opt into driving its harness over the Agent Client Protocol (ACP) — JSON-RPC 2.0 over stdio — instead of the default stdin/scrape pipe. ACP is additive, not a switch: pipe stays the default floor, so every CLI harness (and non-agent record-keepers) keeps working unchanged, and YOLO stays the default via auto-approve. You turn ACP on per role, exactly like --terminal:

# Drive a role's harness over ACP (JSON-RPC/stdio) instead of the pipe
c8ctl nano hire --name coder --rank senior --command copilot --protocol acp

# Override protocol/permission for a one-off worker without re-hiring
NANO_AGENTIC_PROTOCOL=pipe c8ctl nano work coder
NANO_AGENTIC_PERMISSION=escalate c8ctl nano work coder
  • --protocol pipe|acp (default pipe) selects the harness protocol. NANO_AGENTIC_PROTOCOL overrides it at work time (mirroring NANO_AGENTIC_TERMINAL).
  • --permission yolo|escalate|filter (default yolo) selects the ACP permission policy. NANO_AGENTIC_PERMISSION overrides it at work time.

Protocol must match the command. The command and the protocol have to agree: a command that runs the harness in ACP mode (an --acp / acp token or a *-acp adapter binary) hired with protocol: pipe would have plain JSON piped to an ACP-speaking binary that rejects it and exits 0 empty — and the worker would silently complete every job it took with no work done (issue #275). To prevent that, hire refuses an ACP-mode command on protocol: pipe, and work refuses to start a worker with the same mismatch (a pre-existing or hand-edited profile). Re-hire with --protocol acp (or drop the ACP selector from the command). protocol: acp with no selector on the command is fine — the worker appends --acp at spawn time.

Container workers are pipe-only. A --sandbox docker|podman worker runs the harness over the pipe transport regardless of --protocol, so an ACP-mode command is a mismatch there even with --protocol acp (the container would still pipe plain JSON to an ACP-speaking harness). Both hire and work evaluate the effective protocol, so such a container profile is rejected at hire time and refused at work time — drop the ACP selector from the command for a container role.

An empty job is failed, not completed. If a harness exits 0 but produced nothing — no result vars, no output, no transcript turns, no commits and no push — the worker fails the job (preserving its retries, so it reactivates and surfaces as an incident) instead of completing it. Completing an empty job would silently drop whatever it carried (an escalation answer, a review verdict) because downstream gateways see no status and fall through to their default. A run with any evidence of real work completes exactly as before.

What ACP unlocks. Because the harness speaks a structured protocol rather than a scraped terminal, the ACP path gives you a structured turn/tool event stream (today serialized to text chunks on the relay lane — a minimal mode, not yet typed turn/tool envelopes), native permission handling, and PTY-free steering — an operator's steer text is delivered as a session/prompt and an interrupt as a session/cancel, with no keystroke injection. The ACP path therefore does not need the optional native node-pty dependency at all.

Permission policies — mind the status. Only yolo is enforced today:

  • yolo (default, the only enforced policy today) — auto-allows every permission request the agent raises: full speed, no human in the loop. This is the same auto-approve posture the pipe/PTY paths already run with.
  • escalate and filter are RESERVED / not-yet-active in this build. They are accepted and persisted for forward-compatibility, but they are not yet enforced: at work time they fall back to a safe interim policy (currently auto-allow, like yolo) and the CLI emits a warning so an operator is never misled into thinking destructive operations are gated. Their intended future behavior — escalate blocking a permission request until a human answers it, and filter auto-allowing reads/edits while escalating destructive operations — is not available yet; it lands once the companion permission-event + escalation bridge (nanobpm/nano-workforce#559) ships.

Per-CLI hire examples. Some CLIs speak ACP natively; others ride a thin adapter binary. In every case the profile's command (plus any --args) assembles the ACP invocation; a default --acp switch is appended only when the assembled command line doesn't already select ACP, so a native/adapter invocation is never doubled. All of these run with the enforced default yolo policy:

# Copilot CLI — native ACP (assembles `copilot --acp`)
c8ctl nano hire --name coder --rank senior --command copilot --protocol acp --permission yolo

# OpenCode — native ACP server (assembles `opencode acp`)
c8ctl nano hire --name coder --rank senior --command opencode --arg acp --protocol acp --permission yolo

# Qwen — native ACP behind a HIDDEN flag (`qwen --experimental-acp`; present in
# the shipped cli.js but not in `qwen --help`). Detection recognises the
# switch, so no second `--acp` is appended.
c8ctl nano hire --name coder --rank senior --command qwen --arg --experimental-acp --protocol acp --permission yolo

# Claude Code — via the `claude-code-acp` adapter (npm @zed-industries/claude-code-acp)
c8ctl nano hire --name coder --rank senior --command claude-code-acp --protocol acp --permission yolo

# Pi — via the `pi-acp` adapter
c8ctl nano hire --name coder --rank senior --command pi-acp --protocol acp --permission yolo

--permission yolo is the default, so you can omit it. You may hire with --permission escalate or --permission filter today — the value is persisted — but it is reserved / not-yet-active (pending nanobpm/nano-workforce#559) and currently behaves as the safe interim policy with a warning, so do not rely on it to gate destructive operations yet.

Non-goals. ACP does not replace anything: the pipe/PTY surface stays the default floor and every existing harness keeps working unchanged (ACP is enforced on the host executor; container sandboxes remain pipe-only for now). There is no change to the Camunda-8 worker⇄engine job protocol — ACP governs only how a worker drives its local agent harness, not how it talks to the engine.

Selecting the model under ACP. --model only exports AGENT_MODEL into the harness environment; it is not injected into the harness argv. And structured --arg values are POSIX single-quoted by the plugin, so a literal $AGENT_MODEL in an --arg does not expand (only the --command string is shell-interpolated, since the harness is spawned with shell: true). The deterministic, supported way to pin a model for an ACP hire is therefore to bake it into --command, where the shell does interpolate:

# The --command string IS shell-interpolated, so $AGENT_MODEL (or a literal name) works here:
c8ctl nano hire --name coder --rank senior --command 'copilot --acp --model gpt-5.4' --protocol acp

(Equivalently, set a harness-specific env var with --env when the harness reads the model from its environment.) This is the seam the nano-workforce install script uses to route a detected model to each harness deterministically.

Live profile reload (no restart on assign)

A running c8ctl nano work <name> watches the profile it is servicing. When you extend or reduce that profile's capabilities in another terminal —

c8ctl nano assign reviewer fix-ci     # add a capability to the live profile

— the supervisor reconciles its pollers in place: it starts pollers for the newly added rank×capability job types and gracefully drains the pollers for removed types — best-effort: each draining poller is given a bounded grace window (STOP_GRACE_MS) for its in-flight jobs to finish before it is stopped, so long-running work exceeding that window may still be interrupted. Unchanged job types keep running undisturbed, so there is no need to stop and restart the worker.

Only job types (rank + capabilities, plus any --job-type extras) reconcile live. Changes to the profile's command, model, sandbox/image, or env still require a restart to take effect. If the profile is deleted or the config file is mid-write when the reload fires, the running workers are kept (never torn down) and a warning is logged.

Each activated job runs the profile's command once (one-shot): the job is serialized to JSON and piped to the CLI's stdin —

{ "jobKey": "...", "jobType": "senior:code-review", "processInstanceKey": "...",
  "prompt": "<variables.prompt ?? variables.task>", "variables": {},
  "profile": { "name": "reviewer", "rank": "senior", "model": "gpt-5",
               "capabilities": ["code-review", "testing"] } }

and the profile/model are also exported as AGENT_PROFILE, AGENT_RANK, AGENT_MODEL, AGENT_CAPABILITIES, AGENT_JOB_TYPE env vars. On exit 0 the job is completed with { output: <stdout>, exitCode: 0 } (captured output is capped at 1 MiB, with a truncated flag when exceeded); any other exit fails the job with a decremented retry count. Profiles are stored in the plugin's config.json (see c8ctl nano config).

Self-managing activation lock (no hardcoded job timeout). An agent job's duration is unpredictable, so the worker does not ask you to pick a fixed timeout up front. It keeps the broker's job-activation lock a bounded --recovery-window (default 300000ms) ahead of now, refreshing it every ~1/3 of that window for as long as the harness is alive and producing output. Consequences:

  • Long jobs never lose their lock. A run that takes hours keeps going — the lock is continuously extended, so the broker never re-activates the job while you're still working on it (which would start a second agent and get the stale complete/fail rejected with a 409 "job cannot be failed in the current state").
  • Fast recovery on death. Because each refresh sets the deadline to now+window (the UpdateJobTimeout contract is a duration-from-now, not a cumulative delta), the moment the worker stops refreshing — the process dies, the node is lost, the harness is idle-killed, or it hits the hard cap — the lock lapses within one --recovery-window and the broker reclaims the job. This is deliberately optimised for quick reclaim, not for holding a stale lock.
  • --idle-timeout (default 300000ms) is the liveness gate — but it is progress-aware, not a blunt silence timer. When the window elapses the worker probes the harness's process subtree: if a descendant is still doing work (aggregate CPU time across the tree is advancing — e.g. a long, quiet ./mvnw -q build that emits nothing for minutes) the agent is treated as live and the kill is deferred. Only a genuinely quiescent tree — no live descendant, or descendants burning no CPU across the window — is killed as wedged, so a hung agent still can't hold a job forever while a silent-but-busy one keeps the cheapest, zero-token build pattern. Reclaim timing: because a single CPU sample can't reveal progress, the first elapse with live descendants only baselines and re-probes — the kill-vs-defer decision then lands on the following --recovery-window, so a quiescent-but-live subtree ages out roughly --idle-timeout plus one recovery window after the last output, not the instant the idle window first elapses. (A tree with no live descendants is killed immediately when the idle window elapses.) A task can widen this per-task class via the envelope's task.idleTimeoutMs / task.recoveryWindowMs (see the task envelope below) instead of a global flag change.
  • --job-timeout is now an optional absolute hard cap on total harness runtime (default 0 = unlimited), for when you want a ceiling regardless of output. --lock-grace is deprecated and ignored — the lock is auto-managed.

Long-poll window. --poll-timeout (default 30000ms) is how long the broker holds each activateJobs request open waiting for work before returning empty. A longer window keeps an idle worker on one connection for that whole window instead of reconnecting every few seconds — cutting the number of connection establishments, and thus the chances of hitting a transient connect error (ECONNREFUSED / connect-timeout) on a flaky link. It maps straight to the SDK's pollTimeoutMs → the broker's requestTimeout: 0 selects the broker's own default (~5s) and a negative value returns immediately when no job is available.

Trust boundary. The profile command is run through a shell so you can write a full invocation (args, pipes, multi-word commands). It is operator-authored — only what you put in your own config.json is shell-interpreted. Untrusted job data reaches the harness solely as stdin JSON and AGENT_* env vars, never interpolated into the command line, so process variables cannot inject shell commands.

Resume on re-activation — a continuation, not a duplicate. When the broker re-activates an agent job (its lock lapsed after a worker death, node loss, or idle-kill — see the activation-lock note above), the new worker does not cold-rerun from scratch. Instead it resumes from the previous agent's state, so at-least-once delivery stops being harmful — a re-activation becomes a continuation rather than a duplicate of the agent's external side effects. On re-activation, before spawning the harness, the worker:

  • Fetches the prior engine-native AgentInstance transcript for this elementInstanceKey (the durable, append-only AgentHistory minted while the previous instance ran). Because it is engine-backed it is cross-machine — the resumed agent can pick up on a completely different worker box, unlike a local same-host journal.
  • Seeds the harness prompt with a rendered continuation of that transcript, so the new agent continues from where the prior one left off and does not repeat already-completed steps or re-perform side effects (comments, pushes, PRs).

Recovery scope (what survives a re-activation):

  • Committed work is durable only when this activation re-checks-out the exact branch the prior run pushed onto — and only when the envelope alone can prove that. One envelope shape qualifies: repository.ref names a stable non-base branch and branch.create names that same branch (create === ref). The clone lands the workspace on ref (prior commits present) and provisionRepo's honored git checkout -B <create> is a no-op that keeps it there and pushes it back, so the resumed agent can inspect it for the last pushed commit (git log / the open PR for what landed). The ref-only shape (no branch.create, the PR-based review/fix-ci/rebase shape) is deliberately transcript-only even though provisionRepo often does push the checked-out PR head: that decision hinges on provisionRepo resolving the remote default (a checked-out branch that is the default is base-like and gets fallback-branched, and an unresolvable default fails closed), and resume gating runs before the clone with no network, so it can never prove a bare ref is not the repo's (possibly custom, e.g. trunk) default — it fails closed to transcript-only rather than over-claim recovery for a fallback-branched run. Every other shape is likewise not durable and classified transcript-only: a branch.create that differs from ref does checkout -B <create> off the freshly re-cloned base and never fetches the existing remote <create>; and a base-like ref/create (including a conventional main/master head, or a ref-only job with no configured base), a bare-URL / base-only clone, branch.push=false, or a repository.sha-detached checkout likewise recover nothing. For every transcript-only run the resume preamble points at the transcript (VERIFY-first) rather than promising a branch to check out. (The #270 provisioning fix — a ref-only PR head is pushed to, not diverted onto a per-run fallback — is unaffected; only the resume prompt wording is conservative here.)
  • Uncommitted working-tree changes are not recovered by default — the throwaway workspace does not persist across activations, so any delta the previous run had not committed is lost and the resumed agent re-derives it. The resume preamble states this explicitly. WIP checkpoints (below, on by default for pushing jobs) recover them.

Resume is best-effort and gated to external agent jobs (the only ones with a durable transcript). A read failure, an SDK without an AgentInstance read surface, no prior work to continue, or the NANO_AGENT_RESUME=off kill switch all fall through to the legacy cold rerun with no behaviour change.

Agent plans. When the harness publishes an ACP plan (e.g. rusty-harness's plan_* tools), each distinct plan is recorded in the AgentInstance history as an assistant checklist turn. A resumed run gets the latest plan restated in the resume prompt (VERIFY-first, like the transcript), and a harness that advertises agentCapabilities._meta.planSeed also receives it in session/new _meta.plan so it can keep working the same plan. NANO_AGENT_PLAN=off disables both.

Durable transcripts need an ACP harness and a recent host c8ctl. The engine-native AgentInstance transcript is built from the harness's ACP session/update stream, so only a harness running in ACP mode records a body — a non-ACP harness (e.g. copilot --allow-all without --acp) emits no update stream, so its instance carries just the opening CONFIGURATION turn. Recording also needs the host c8ctl's SDK to expose the AgentInstance verbs (createAgentInstance/updateAgentInstance): @camunda8/cli must bundle @camunda8/orchestration-cluster-api >= 10.0.0-alpha.45. When it doesn't, every job silently records nothing — so nano work now fails loudly at startup on an agentic profile, naming the host SDK versions and the fix (the plugin version is irrelevant; upgrade c8ctl). Set NANO_AGENT_INSTANCE=off to disable transcripts intentionally. Note that token metrics (inputTokens/outputTokens) are 0 for ACP-mode runs: ACP reports context-window occupancy, not per-turn token counts (a protocol limitation), so the producer never fabricates them.

WIP checkpoints (NANO_AGENT_CHECKPOINT=auto|on|off, default auto). The worker periodically snapshots the agent's workspace — uncommitted changes and unpushed local commits — to refs/nano/wip/<elementInstanceKey> on the job's remote, without touching the agent's HEAD, index or branch (a "shadow commit" built through a temporary index). auto enables this only for jobs that already publish their work: a provisioned, authenticated clone on a working branch with branch.push not false (read-only, anonymous, detached and repo-less jobs are skipped). on forces it for any provisioned job; off disables it.

  • What is left out: .gitignored paths; secret-looking paths (.env*, *.pem, id_*, .npmrc, …); files over NANO_AGENT_CHECKPOINT_MAX_FILE_BYTES (5 MiB); and any changed file whose content contains one of the job's own injected secret values, the repo token, or a common credential format (private keys, GitHub / AWS / Slack / OpenAI-style tokens).
  • When: after a completed ACP tool call or plan change (at most one per NANO_AGENT_CHECKPOINT_MIN_INTERVAL_MS, default 60s), on a safety-net timer (NANO_AGENT_CHECKPOINT_INTERVAL_MS, default 300s, 0 disables — the only trigger for pipe/PTY harnesses), and once more when a run fails or is force-stopped. Each checkpoint also sets a best-effort local agentCheckpoint {ref, sha, head, at, reason, branch} variable on the element instance.
  • Restore: a later activation of the same element instance restores the snapshot into the fresh clone as uncommitted changes (recovering local commits too) and tells the agent to inspect git status/git diff first.
  • Cleanup: the ref is deleted once the engine acknowledges job.complete for a run whose branch push succeeded. Until that ack a lost lease or failed settle redelivers the job, so the ref stays restorable. Orphans (cancelled instances, failed acks, exhausted retries) are reclaimed by a background sweep (at most hourly per remote per worker) that deletes WIP refs older than NANO_AGENT_CHECKPOINT_TTL_MS (7 days), or older than 1h whose element instance the engine reports COMPLETED/TERMINATED.
  • Safety: pushes use --force-with-lease, so a superseded worker can't clobber a newer run's checkpoint. A remote policy refusal (repository rules, secret push protection) or a permission error stops checkpointing for that job with a warning; network errors are retried on the next trigger.

Caveats: WIP refs are visible to anyone who can read the repo; the worker can't pause the agent, so a snapshot may catch a half-written file. Everything is best-effort — a failure degrades to transcript-only resume.

Agent outcomes. A harness may report an explicit end-of-task outcome in its session/prompt result as _meta.outcome — { "status": "completed" | "blocked", "summary": "…" } (rusty-harness's report_outcome tool). It is recorded in io.nanobpm.agentResult.outcome. If the agent returned no result of its own, a blocked outcome becomes the result { "status": "blocked", "summary", "question" }, so the job escalates instead of taking a re-emit nudge turn. A result written to $AGENT_RESULT_FILE (or the ::nano:result:: sentinel) always wins, and agents that send no outcome are handled exactly as before.

Task envelope, sandboxes & disk hygiene

For agentic jobs (an agent that clones a repo, works a task, pushes a branch) the job carries a structured task envelope under the reserved io.nanobpm.agentTask namespace. It is assembled from the job's static customHeaders (model-authored defaults) deep-merged with per-instance variables (overrides win), then normalized to schema v1 and included in the stdin payload as task:

{
  "io.nanobpm.agentTask.repository.url": "https://github.com/o/r.git", // header
  "io.nanobpm.agentTask.repository.ref": "main",
  "io.nanobpm.agentTask.branch.push":    "true",
  "io.nanobpm.agentTask.task.allowPr":   "false"
}

Element templates emit flat dotpath header keys (strings); the plugin expands them into a nested object and coerces "true"/"false" → bool and numeric strings → int. The normalized shape is { schemaVersion, repository{provider,url,ref,sha,depth,singleBranch,filter,baseRef,baseSha,cloneTimeoutMs,submodules,authRef}, branch{base,create,push}, setup{commands,env,secretRefs}, task{prompt,promptFile,maxIterations,timeoutMs,idleTimeoutMs,recoveryWindowMs,allowPr,prBase} }.

Per-task liveness overrides. task.idleTimeoutMs and task.recoveryWindowMs let a specific task class (e.g. an implementation job on a large monorepo) widen the idle-liveness / broker-lock recovery window without touching the worker's global --idle-timeout / --recovery-window flags, and task.timeoutMs raises the absolute hard cap (--job-timeout). Precedence is envelope override → worker flag → built-in default, each clamped to a sane maximum so a task can't request an unbounded window; absent fields leave the worker-flag behaviour unchanged.

Prompt = base + optional verbatim append. The agent's prompt resolves to task.prompt (typically a model header filled at deploy time), falling back to a plain prompt/task variable. Because a header-delivered base prompt can't be composed in FEEL, a task may supply per-instance context via task.appendPrompt (reserved) or a plain appendPrompt variable — it is concatenated onto the base verbatim, with no injected separator (the model's ioMapping owns any leading separator/preamble), so a null/empty append leaves the base untouched. This lets the static prompt live in a model header/side-car while the dynamic tail (e.g. plan-revision feedback, a per-task brief) is built per instance.

Live prompts via linked resources. A service task can declare a Zeebe/Camunda-parity linked resource for its prompt instead of baking it into a model header:

<zeebe:linkedResources>
  <zeebe:linkedResource resourceId="plan.md" bindingType="latest" linkName="prompt"/>
</zeebe:linkedResources>

At job activation the engine resolves the resourceId to the latest deployed key and delivers a linkedResources custom header ([{resourceKey, resourceType, linkName}]). The header carries the key, not the content — the worker fetches the bytes over the broker REST API (GET /v2/resources/{resourceKey}/content/binary, reusing the same nano endpoint the worker already talks to; override with NANO_REST_URL/NANO_REST_TOKEN) and uses the UTF-8 content as the base prompt. The entry whose linkName is prompt wins over the header-baked task.prompt chain; appendPrompt still composes onto it. Redeploying just the resource updates the prompt for the next activation — no process redeploy, no worker restart. Jobs without linkedResources behave exactly as before (fallback chain). A declared prompt resource that can't be fetched fails the job (retryable provisioning error) rather than running an agent with an empty prompt, and the resolved resourceKey is logged and echoed on the output envelope as promptResourceKey for audit (the engine keeps only latest, so the key is the reproducibility handle).

On completion the plugin writes an output envelope back under io.nanobpm.agentResult ({schemaVersion, status, sandbox, image, output, truncated, stderrTruncated, exitCode, signal, error, outcome?, promptResourceKey?}). When a repository was provisioned (below) it also carries {repository, branch, baseSha, headSha, commits[], pushed, pushError?, pushFailed?, strandedCommits?, branchMismatch?, scanError?, gitError?, pr?}. pushFailed is the explicit "push failed" flag — set for a non-zero git push whose result could not be confirmed as landed at the remote (a non-fast-forward rejection, or an auth, hook, or network error), not only a server rejection. pushFailed does not by itself prove a remote push was attempted or rejected: finalizeGit also sets it (with strandedCommits and a branchMismatch {expected, actual}) when it refuses to push at all because the harness moved HEAD off the provisioned work branch — or left commits abandoned on another local branch — so pushing the work branch would publish stale work and strand those commits (no git push runs, so pushError is absent in that case). As a guard against false strands, a non-zero push is re-checked with ls-remote: if origin/<branch> already points at headSha — or the remote tip is a descendant of headSha (another actor pushed a further commit after ours landed) — the push is treated as a transport hiccup that landed after the ref was accepted (pushed: true, no pushFailed/pushError); otherwise pushFailed is set and strandedCommits lists the SHAs of the new commits left UNPUSHED in the throwaway workspace — together they are the recovery handle for a failed push, so consumers must not treat pushed: false alone as the only signal. On such a failure (a rejected push or a branch-mismatch refusal) the throwaway workspace is preserved best-effort (even under the default --keep-runs=false) so those SHAs stay recoverable, but this is a recovery window, not a durable archive: the run-directory reaper still ages it out by mtime and worker shutdown removes the namespace — copy the stranded commits out promptly (or run with --keep-runs). scanError flags a best-effort/PARTIAL strandedCommits list: a commit or remote-reachability scan did not complete, so a consumer should recover the whole preserved workspace rather than trust the list exactly. It arises two ways: (a) a pre-push commit-enumeration scan failed, so finalizeGit refused to push on an incomplete graph — no push was attempted (pushError is absent) and strandedCommits is a partial surfacing; or (b) a push was ATTEMPTED and rejected but the remote-reachability FILTER that trims already-published commits from the strand set itself failed — so scanError is present alongside pushError, marking the rejected-push strand list as best-effort (it may falsely include an already-landed commit). Either way pushFailed stays authoritative; scanError is how a consumer knows the strand list is inexact (recover the whole workspace) versus an exact list (a rejected push with no scanError).

Git provisioning (host). When --sandbox none (the default) and the envelope carries a repository.url, the plugin provisions a workspace on the host around the harness:

  1. resolve the optional repo credential (repository.authRef, or GITHUB_TOKEN for GitHub) — absent ⇒ anonymous clone;
  2. git clone (honouring depth/submodules, and repository.ref/branch.base as the checkout target). ref is always a branch/tag name (there is no hex heuristic, so a legitimately hex-named branch like deadbeef is cloned via --branch, never misread as a commit); to pin a raw commit use the dedicated repository.sha field, which clones ref/branch.base (if any) then fetches + checks the commit out as a detached HEAD. The clone lands in a throwaway workspace under <state>/agent-runs/run-*. For a huge monorepo the clone envelope can be scoped so it finishes inside the clone timeout: singleBranch adds --single-branch (fetch only ref, not every branch — a plain clone --branch still pulls all branches/history); filter (e.g. "blob:none") adds --filter=<spec> for a partial/treeless clone (full commit graph, lazy blobs — so merge-base/git diff base...head still work); baseRef/baseSha additionally git fetch the base (respecting depth/filter) so a single-branch/shallow clone can still diff base...head (exported as AGENT_REPO_BASE). baseRef is always treated as a branch/tag name (even one that looks hex-like) and is mapped into refs/remotes/origin/<baseRef>; baseSha is the field for a raw commit SHA (fetched by id, exposed as the SHA itself). baseRef and baseSha are mutually exclusive — setting both is ambiguous, so the base fetch is skipped and a non-fatal baseFetchError records the misconfiguration. A --depth 1 --single-branch of only the head otherwise has NO base and NO merge-base, so a naive git diff main fails. A failed base fetch is non-fatal — the head clone still succeeds and the failure is logged. cloneTimeoutMs overrides the clone/fetch timeout per envelope (default 120s, or the --clone-timeout worker flag) as a backstop for repos big enough to approach the cap even when shallow; a timeout is now reported as a timeout rather than an opaque exit 128;
  3. create branch.create (if set) off that target — or, when branch.create is absent (or names the effective base itself) and push is enabled, cut a generated fallback work branch nano/agent-work/<base>-<runId> so commits are never made directly on the base branch; the branch actually used (configured or generated) rides back in the result envelope (branch) and is exported to the harness as AGENT_REPO_BRANCH. Exception — a detached tag/SHA checkout with no branch.create: with no symbolic branch to name a fallback from, it stays branch: null and its commits are not pushed even when push is enabled — this holds for any detached no-branch.create checkout, including when a branch.base is configured (the base name is not used to synthesize a fallback here). Supply branch.create to publish work committed off a tag/SHA base;
  4. set a committer identity on the workspace, preferring the operator's own (GIT_AUTHOR_* env → global git config user.name/email → the gh-authenticated GitHub user), and only falling back to nano-agent when none resolve — so autonomous commits are authored by the human running the fleet (who has signed any CLA/DCO), not an anonymous bot;
  5. run the harness in the workspace (cwd), with AGENT_WORKSPACE, AGENT_REPO_URL, AGENT_REPO_BRANCH, AGENT_REPO_REF, AGENT_REPO_BASE exported and the job envelope on stdin;
  6. on success, enumerate new commits, git push the branch when branch.push (default true), and — when task.allowPr — reconcile the PR the agent opened for the branch (gh pr list --head <branch>; openedBy reports the PR's actual author login, or null when none is found), then post a one-time attribution comment recording that the change was agent-generated (marker- guarded so convergence rounds don't repeat it; disable with NANO_AGENT_ATTRIBUTION=0, rename the agent with NANO_AGENT_NAME).

The token is delivered to git via GIT_ASKPASS (env), never on argv or in the remote URL, and is redacted from all logs/results. Credential helpers are always disabled for the clone/fetch/push (-c credential.helper=), even when a token is present, so a helper like store/keychain can never persist the job's token to disk — GIT_ASKPASS supplies the secret directly. When no token is resolved the clone is additionally anonymous for HTTPS remotes: inherited GIT_ASKPASS/SSH_ASKPASS are cleared, and the operator's global git config is neutralized (GIT_CONFIG_GLOBAL → the platform null device, /dev/null or NUL on Windows) so knobs like http.*.extraHeader or url.*.insteadOf can't silently inject operator credentials. (An SSH remote — git@…/ssh://… — can still authenticate via the host's SSH agent/config; use HTTPS URLs if you need a guaranteed-anonymous clone.) Token-backed jobs keep global config (e.g. http.proxy). pushFailed is the authoritative "work is stranded, workspace preserved" flag (the job still completes, so a later BPMN step can drive the merge); a clone/checkout failure sheds the job (retryable). pushError is present only for a push that was ATTEMPTED and rejected/unconfirmed — when finalizeGit refuses to push at all (a branch mismatch, or a partial/incomplete commit scan), it sets pushFailed (with branchMismatch or scanError) but no pushError, so consumers must key recovery off pushFailed, not pushError. scanError flags a best-effort/PARTIAL strandedCommits list — a commit/reachability scan did not complete, so the list may omit or (after a rejected push) falsely include commits and a consumer should recover the whole preserved workspace rather than trust the list exactly. It arises two ways: (a) a pre-push commit-enumeration scan failed, so finalizeGit refused to push on an incomplete graph (no git push ran, so pushError is absent); or (b) a push was ATTEMPTED and rejected but the remote-reachability FILTER that trims already-published commits from the strand set itself failed — so scanError is present alongside pushError, marking the otherwise-complete rejected-push strand list as best-effort. Either way pushFailed stays authoritative. Workspaces are deleted after each job (keep them with --keep-runs) — except a job whose git push could not be confirmed as landed (a non-fast-forward rejection, or an auth/hook/network error whose ls-remote re-check did not find origin/<branch> at or ahead of headSha — pushFailed + pushError), or one where finalizeGit refused to push (no git push ran, so pushError is absent) because HEAD moved off the provisioned work branch — or left commits on another local branch or an abandoned detached HEAD reachable only via the reflog (a branchMismatch strand) — or because a critical local commit scan did not complete (a scanError strand, with a best-effort PARTIAL strandedCommits), whose workspace is preserved best-effort so its strandedCommits stay recoverable; that preservation is still age-gated by the reaper and cleared on worker shutdown, so recover the SHAs promptly.

# The harness sees a cloned repo at $AGENT_WORKSPACE; branch/push/PR are handled for it.
c8ctl nano work coder            # sandbox=none: repository-bearing jobs are provisioned on the host

Repo-less jobs run in a throwaway temp cwd (host). When --sandbox none and the envelope carries no repository block at all, there is nothing to clone, but the job still gets a fresh, empty run-* workspace under <state>/agent-runs/ as its cwd (tracked and auto-reaped by the same run-dir lifecycle as a provisioned clone). This makes "each job runs in a throwaway per-job workspace" true by default, not only for repo-bearing tasks: the worker's launch dir is never polluted with scratch files or a stray git init, and an agent that must clone the repo itself gets a clean base. This is hygiene, not a sandbox — the harness runs as your user with full filesystem access, so an empty cwd does not confine it (an agent can still cd to a known absolute path); true confinement remains the container increment, and a provisioned repository envelope stays the preferred path (it also avoids a full-monorepo self-clone, defeating the singleBranch+filter shaping above). A job whose envelope carries a repository block that declares intent (any repository.* field set) but whose url is absent or not a usable clone target is treated as an orchestrator bug and fails closed (retryable) rather than silently degrading to the temp cwd — surfacing the half-specified envelope loudly instead of running an agent with no repo where one was expected.

Sandbox. By default the command runs on the host (--sandbox none). Pass --sandbox docker (or podman) with an --image to run each job in a throwaway container instead:

c8ctl nano hire --name coder --rank senior --command "agent-harness" \
  --sandbox docker --image ghcr.io/acme/agent:1
c8ctl nano work coder                 # uses the profile's sandbox/image
c8ctl nano work coder --sandbox docker --image ghcr.io/acme/agent:1   # or override

Containers are labelled (nano.managed=1, nano.worker, nano.jobKey, nano.run=<uuid>), log-capped (max-size=10m max-file=3), run with --rm, and a run that is idle-killed (or that outlives an optional --job-timeout hard cap) is force-removed. The envelope is piped on the container's stdin exactly as on the host. (Container-side git provisioning — strong isolation — is a later increment; container jobs don't clone yet.)

Host workers inherit your credentials. A host worker (--sandbox none, the default) runs as your user and inherits your full environment and $HOME, so your existing gh CLI login (from gh auth login) or a GH_TOKEN/GITHUB_TOKEN env var is available to the harness with no extra setup — handy when the agent command shells out to gh. A container sandbox is isolated and does not inherit that host login; provide the token explicitly via setup.secretRefs / --secret-resolver host (see Secrets below) instead.

Refreshing a gh login while workers run. When no GITHUB_TOKEN is exported, a worker falls back to gh auth token and caches it (a synchronous CLI read that can't run on the per-job path). The cache is not process-lifetime: it is re-read on a short TTL (NANO_GH_TOKEN_TTL_MS, default 5 minutes) off the job path — the timer DEFERS a tick while a job is active — and a clone/push that fails with an auth or OAuth-scope error (401/403, "refusing to allow an OAuth App … without workflow scope") forces a fresh re-read and retries once before failing — the re-read is non-destructive (it does not drop the cache first