@sanchaymittal/cnk
v0.1.1
Published
Cross-provider subagent relay CLI, daemon, and TUI
Readme
cnk — cross-provider subagent relay
Daemon + thin CLI that lets any parent coding agent spawn another coding-agent adapter as an ephemeral subagent using that provider's own subscription auth, with provider-specific model selection, push-notify on terminal events, and optional cursor-streamed events back to the parent.
For the launch-level overview, start with the root
README.md. This file is the daemon/product reference.
Adapter defaults and effort routing live in
packages/adapters/src/adapters/catalog.ts;
the generated table is in docs/model-catalog.md.
Architecture
- Daemon — long-running supervisor. Owns the subagent process tree,
adapter event streams (
~/.cnk/subagents/<id>.jsonl), CloudEvents terminal logs (~/.cnk/events/<subject>.jsonl), 4-JSONL append-only control-plane stores, and notification dispatch. - CLI —
cnk <subcommand>opens a UDS connection to the daemon and speaks JSON-RPC. Auto-spawns the daemon on first call. - TUI —
cnk,cnk tui, and compatibility aliascnkcliopen the integrated OpenTUI interface. It uses the same daemon RPC surface as the CLI.
Sockets and state live under ~/.cnk/ (cnkd.sock, cnkd.json,
cnkd.lock.d/, subs.jsonl, pids.jsonl, sub-seqs.jsonl,
subject-seqs.jsonl, subagents/, events/). Adapter event streams used by
cnk tail, cnk usage, and the TUI LiveTail live under subagents/.
Storage layout
~/.cnk/
├── cnkd.json # daemon PID + port registry
├── cnkd.sock # UDS socket
├── cnkd.lock.d/ # atomic-mkdir singleton lock
│ └── pid # owning daemon PID
├── subs.jsonl # subscription registry mutations
├── subs.snap.json # subs projection snapshot
├── pids.jsonl # active-pid registry mutations
├── pids.snap.json
├── sub-seqs.jsonl # per-subscription sequence counters
├── sub-seqs.snap.json
├── subject-seqs.jsonl # per-subject sequence counters
├── subject-seqs.snap.json
├── subagents/
│ └── <id>.jsonl # adapter event stream
└── events/
└── <subject>.jsonl # per-subagent CloudEvents log (CEEnvelope per line)All files are created with mode 0o600; ~/.cnk/ itself is 0o700.
Signing keys are base64-encoded inside subs.jsonl records — no sidecar
key files.
Concurrency and singleton lock
The daemon acquires a singleton lock via fs.mkdir(cnkd.lock.d/, {recursive:false})
before writing to any control-plane file. If the lockdir already exists, the
daemon reads the pid file inside it, checks whether that process is alive
(kill(0, pid)), and either yields or heals the stale lock and retries once.
This is cross-runtime safe (Node ≥ 20 and Bun ≥ 1.3 both honour POSIX mkdir
atomicity).
Upgrade / migration note
The old SQLite control plane is gone. Fresh boots now initialise the JSONL
stores under ~/.cnk/, and the daemon heals any stale lock or registry files
from prior runs.
Runtime requirements
- Bun ≥ 1.3 — the daemon runs under Bun.
@opentui/corenative FFI and Bun-nativefdatasync/ atomicmkdirare used throughout. Nobetter-sqlite3orproper-lockfiledependencies. - Node ≥ 20 — used for the published
cnkCLI wrapper. The wrapper checks that Bun is available, then launches the Bun CLI entrypoint. The daemon process itself is spawned under Bun.
When a client auto-starts the daemon, cnk preserves the caller's PATH and
adds common user CLI locations (~/.bun/bin, ~/.local/bin,
/opt/homebrew/bin, /usr/local/bin, and the current runtime's bin dir).
Set CNK_EXTRA_PATH to a path-delimited list when provider CLIs live
elsewhere; cnk consumes it during daemon boot and appends those entries to the
daemon PATH before launching adapter subprocesses.
Install
npm install -g @sanchaymittal/cnk
cnk --helpSource checkout:
npm install
npm run build
npm link --workspace @sanchaymittal/cnkQuickstart — CLI (recommended)
For an interactive terminal session, run cnk to open the TUI. For scripts
and agent harnesses, use the subcommands below. cnk run is sync — block until terminal,
print the CE envelope on stdout, exit with a status code derived from the
terminal kind. Modeled on gh pr view: one command, no temp-file plumbing
exposed to the caller.
# Single-command sync — blocks until done, prints CE envelope JSON, exit 0
cnk run --agent codex --model gpt-5.5 'Review this diff for correctness regressions. Findings first.'
# Just the summary string (data.summary)
cnk run --agent codex --model gpt-5.5 --format summary 'Review this README for launch clarity. Return top fixes.'
# Summary + trailing newline
cnk run --agent codex --model gpt-5.5 --format text 'Review this README for launch clarity. Return top fixes.'Exit codes: 0 done, 1 error, 124 timeout, 130 cancelled (SIGINT),
2 invalid input.
cnk run blocks via OS-level push (fs.watch → kqueue/inotify) — no
polling, ~zero CPU and zero tokens during the subagent run. --timeout <ms>
is opt-in (no default — wait forever). Cancel via SIGINT or wrap in shell
timeout(1) if a hard bound is needed.
For AI orchestrator / harness callers: prefer --format summary — the
exit code carries the terminal kind, and stdout becomes a short string
(typically ~5-50 tokens) instead of the full ~500-token CE envelope. The
default --format json is for human/scripting use where you want the full
envelope.
For async / fan-out use cnk dispatch (returns {subagentId} immediately,
caller registers a notify target):
# Push-on-terminal via exec (true push, CE envelope on stdin)
OUT=$(mktemp -t cnk-out.jsonl)
cnk dispatch \
--intent 'long-running task' \
--agent claude-code \
--notify-exec /bin/sh \
--notify-exec-args "[\"-c\",\"cat >> $OUT\"]"
# → {"subagentId":"<uuid>"}--notify-exec <cmd> runs cmd directly (no shell). Pass --notify-exec-args
'<json-array>' for arguments — daemon spawns cmd with those argv, CE
envelope on stdin, env vars CNK_SUBAGENT_ID, CNK_EVENT_TYPE, CNK_SEQUENCE.
--notify-file <path> is the other shape: the daemon appends the CE envelope
to <path> (NDJSON) on terminal — consumer poll-detects (until [ -s ]; cat).
Prefer exec for true push (process wakes on the event); use file for ad-hoc
shell where you don't need argv control.
To stream events instead of (or in addition to) notify:
cnk tail <subagentId> # blocks, prints JSONL until terminal
cnk cancel <subagentId> # writes terminal error event
cnk usage --runId <subagentId> # token / cost rollup
cnk transcript <subagentId> # readable full-stream transcript
cnk status # last 20 live runscnk run --format transcript ... is the sync form when you want the same
full-stream transcript immediately after terminal. Use --format summary for
cheap orchestration handoffs; use transcript when judging result quality,
debugging adapter behavior, or comparing providers.
Persistent subscriptions (vs the per-dispatch --notify-* flags above):
cnk sub create --kind file --path /abs/inbox.jsonl --filter '<event-type>'
cnk sub create --kind exec --cmd /usr/bin/true --args '["-c","cat >> /tmp/out.json"]' --filter '<event-type>'
cnk sub list
cnk sub delete --subId <subId>
cnk sub ping --subId <subId>Daemon lifecycle is normally automatic, but exposed:
cnk daemon start [--detach]
cnk daemon status
cnk daemon stop--input-json '<full payload>' on cnk dispatch is an escape hatch that
bypasses individual flags — useful from scripts that already serialize.
--skills-json carries a compact skill manifest for
adapter-native access and terminal metadata. The daemon does not prefeed skill
content or manifest text into the child prompt; each adapter must deliberately
support provider-native skill access.
Same-provider routing is available through --parent-agent <name> and
--route-preference auto|relay|native. auto is the default; relay
forces the generic path for parity checks; native is currently codex-only
and requires the parent agent to match.
Autonomous cnk-via-cnk smoke
If you want to verify that cnk can launch real subagents from cnk itself,
run the live smoke matrix:
cd packages/daemon
npm run smoke:autonomousWhat it does:
- creates a temporary git worktree for adapters that require one
- launches real
cnk runcalls at multiple effort tiers - uses effort-based routing instead of pinning a model directly
- verifies the subagent actually wrote the requested file
- records which adapter won each complexity tier and which adapters failed
- records terminal burn data (
wallMs, token totals, estimated cost when the adapter emits it) so cnk-vs-direct comparisons use time, cost, and result success instead of vibes
Default routing policy in the script:
low→antigravity, fallbackcodexmedium→antigravity, fallbackcodexhigh→antigravity, fallbackcodexxhigh→antigravity, fallbackcodexmax→antigravity, fallbackcodex
The script prints JSON so an orchestrator can consume it directly. A non-zero exit means at least one requested complexity tier had no working adapter route.
cnk-bench alpha
cnk-bench is the measured-routing successor to the autonomous smoke. It runs
task directories through real cnk run, verifies each task with verify.sh,
records rows, and emits a per-class routing table. The alpha corpus intentionally
contains only a hermetic seed task; treat its output as harness validation, not
production routing policy.
Build first, because the script imports compiled bench modules from dist:
npm run build --workspace @sanchaymittal/cnkSafe no-provider smoke using the synthetic mock adapter:
cd packages/daemon
CNK_BIN="$PWD/dist/bin.js" npm run bench -- \
--tasks bench/tasks \
--classes edit-localized \
--adapters mock \
--efforts low \
--n 1 \
--out-dir /tmp/cnk-benchThis writes /tmp/cnk-bench/rows.jsonl and
/tmp/cnk-bench/routing-table.json, and also prints the routing table to
stdout. Without --out-dir, the routing table defaults to
${CNK_HOME:-~/.cnk}/bench/routing-table.json. For a live authenticated run,
replace mock with adapters such as
codex,claude-code and keep CNK_BIN pointed at the built local binary.
cnk vs direct provider CLI
cnk adds value when you need lifecycle supervision, push terminal delivery, cross-provider routing, uniform transcripts, and comparable telemetry across adapters. A direct provider CLI can be better for a one-off interactive session where that provider already has the exact UX you want and no parent harness needs to track the run.
For Antigravity specifically, the adapter intentionally wraps Google's
official agy --print single-prompt surface. That means direct CLI and cnk use
the same provider-side agent behavior; cnk's advantage is the normalized daemon
contract, transcript storage under ~/.cnk/subagents/, and push completion
delivery. The current agy print surface is text-only, so cnk cannot observe
tool-call or provider token metadata from Antigravity runs.
Quickstart — from a parent agent
cnk is invoked as a plain CLI. A parent coding agent (Claude Code, Codex,
etc.) shells out to cnk the same way a human does — there is no MCP server
to wire. Use cnk run for blocking calls and cnk dispatch for async
fan-out with push notify:
# blocking: exit code carries the terminal kind
cnk run --agent antigravity --effort medium --format summary 'Review this diff. Findings first.'
# async: returns {subagentId}; daemon pushes the terminal envelope to --notify-*
cnk dispatch --agent codex --effort high \
--intent 'Investigate X. Do not edit files.' \
--notify-file /abs/inbox/done.jsonlThe cnk-subagents skill (.agents/skills/cnk-subagents/SKILL.md) is the
canonical guidance for when and how a host agent should delegate through cnk.
Adapters
cnk supports aider, amp, antigravity, auggie, claude-code, codex,
cursor, gemini, goose, opencode, openhands, qwen, and a mock
adapter for tests. Add a new agent by dropping a file into
packages/adapters/src/adapters/ and registering it in
packages/adapters/src/adapters/index.ts.
The model catalog is the single source of truth for adapter default models,
effort routing, and manual-only model pins. Update
packages/adapters/src/adapters/catalog.ts first, then regenerate
docs/model-catalog.md. The catalog tests fail
if the checked-in docs table drifts.
Auth: each adapter reads its provider's home dir.
aider→ user-configured Aider model/API settings; cnk passes--no-auto-commits --no-dirty-commitsamp→ Amp login orAMP_API_KEY; cnk uses execute modeauggie→ Augment/Auggie login orAUGMENT_SESSION_AUTH; cnk uses quiet print modeclaude-code→ OAuth subscription via~/.claude/codex→ ChatGPT-tier subscription via~/.codex/config.tomlOROPENAI_API_KEYenvcursor→ Cursor Agent login orCURSOR_API_KEY; cnk uses print mode with--output-format stream-jsonantigravity→ officialagyCLI login/session; cnk uses print modegemini→ legacy Gemini CLI OAuth via~/.gemini/oauth_creds.jsonORGEMINI_API_KEYgoose→ Goose provider/model settings; cnk usesgoose run --no-sessionqwen→ Qwen Codeqwen auth/ API-key configuration; cnk uses headlessstream-jsonwith partial messages enabledopenhands→ OpenHands settings orLLM_*environment; cnk uses headless modeopencode→ provider home dir
cnk itself never holds an API key.
Local adapter readiness snapshot
Last probed: 2026-05-31 with a default-route cnk run --agent <adapter>
--cwd <temp-git-repo> --timeout 120000 --format json smoke prompt after
daemon PATH enrichment. This is an environment snapshot, not a provider
guarantee. Working means the adapter reached terminal done; env blocked
means the local daemon could not start the provider CLI; auth blocked means
the adapter started far enough to report provider authentication failure.
| Adapter | Default route/model | 2026-05-31 status | Probe result |
|---|---|---:|---|
| aider | configured | env blocked | aider executable not found in $PATH |
| amp | configured | env blocked | amp executable not found in $PATH |
| antigravity | auto | not reprobed | switched to official agy --print after this snapshot |
| auggie | configured | env blocked | auggie executable not found in $PATH |
| claude-code | claude-sonnet-4-6 | auth blocked | authentication_failed |
| codex | gpt-5.5 | Working | returned CNK_ADAPTER_SMOKE_OK |
| cursor | auto | env blocked | cursor-agent executable not found in $PATH |
| gemini | gemini-2.5-flash | Working | returned CNK_ADAPTER_SMOKE_OK |
| goose | configured | env blocked | goose executable not found in $PATH |
| mock | synthetic test adapter | Working | returned mock done |
| opencode | opencode/nemotron-3-super-free | Working | returned CNK_ADAPTER_SMOKE_OK |
| openhands | configured | env blocked | openhands executable not found in $PATH |
| qwen | qwen3-coder-plus | env blocked | qwen executable not found in $PATH |
What's intentionally NOT here
No state machine, no DAG, no per-task git worktree, no decision cache, no scoring, no PR creation, no observability adapters, no retry loop. Caller pins agent + model. Caller decides retry policy. Caller commits + opens PRs themselves.
Adapter notes
Antigravity adapter — official CLI surface
The Antigravity adapter wraps the official agy binary:
agy --dangerously-skip-permissions --print-timeout 30m0s --add-dir <cwd> --print '<intent>'When cnk's model catalog resolves auto, cnk omits --model and lets
Antigravity choose the configured/default model. Manual model pins are passed
through with --model; the built-in catalog intentionally exposes only
auto because agy models is account/auth dependent. Operators can publish
site-specific model IDs through $CNK_CATALOG.
The current public agy --print surface returns plain text, not structured
JSONL. cnk therefore emits stdout lines as message events and synthetic
session usage; it cannot currently surface Antigravity tool calls, tool
results, or provider token counts.
Gemini adapter — legacy limitation
The gemini adapter wraps the gemini CLI binary (--output-format
stream-json), which is Google's documented programmatic interface. The
consumer Gemini CLI path stops serving requests on June 18, 2026; prefer
antigravity for Google-backed consumer runs. Enterprise/API-key Gemini users
can still pin --agent gemini.
The CLI applies a default system prompt and autoloads ~/.gemini/gemini.md on
every run, costing ~20K input tokens before the user prompt. The adapter
cannot suppress this overhead without leaving the documented public
surface.
User workaround: write an empty ~/.gemini/gemini.md to drop ~4-5K of the
overhead. The CLI default sysprompt remains.
Structural fix: pending publication of @google/gemini-cli-sdk (currently
source-only at github.com/google-gemini/gemini-cli packages/sdk/), which
exposes a custom instructions: field that would replace the default sysprompt
and allow the adapter to suppress the overhead entirely.
