Skip to content
BardiaNPublic

About

Zero-dependency terminal coding agent for local LLMs served by Kronk. Streaming chat, tool calling with approval gating, MCP client, and automatic context compaction.

Resources

Contributing

Security policy

Stars

4 stars

Watchers

0 watching

Forks

Repository files navigation

kronk-cli

A Claude-Code-style terminal agent for local models served by Kronk. Reads your files, runs your commands, writes code — entirely on your machine. No network, no API key, no per-token cost.

› refactor the SSE parser in src/client.js to handle multi-line data fields

  ┄ thinking ┄
  The parser splits on newlines and only handles `data: ` prefixes…
  ┄─────────┄

  1 ⚙ read src/client.js
  ✓ 118 lines
  2 ⚙ write src/client.js
    + export async function* streamChat({ model, messages, tools, signal }) {
    …42 more lines
  approve write_file? [y/N] y
  ✓ 1 lines

  Done — the parser now buffers continuation lines before dispatching.
  2104→812 tok · 61.3 tok/s · ttft 240ms · 1980 cached

A real session transcript, kept here to show the UI format. For numbers you can reproduce yourself on your own hardware, see Performance.

ci security codeql OpenSSF Scorecard npm

Zero dependencies. It is fetch and readline against Kronk's OpenAI-compatible API.

It talks to your Kronk server and nothing else — enforced in CI by running the whole thing inside a network namespace with only loopback. See Verifying this build.


Verifying this build

This tool reads your files and runs your commands. You should not have to take anyone's word for what it does with them — so every claim here is machine-checkable.

Check the artifact came from this source

Every release tarball carries a signed attestation binding it to the repository, commit and workflow run that produced it:

gh attestation verify kronk-cli-*.tgz --repo BardiaN/kronk-cli

The same attestation is attached to every release as a file, so it can be checked without GitHub's attestations API in the loop — <tarball>.sigstore.json for gh attestation verify and cosign, <tarball>.intoto.jsonl for SLSA tooling:

gh release download v0.1.3 --repo BardiaN/kronk-cli
gh attestation verify kronk-cli-0.1.3.tgz --repo BardiaN/kronk-cli \
  --bundle kronk-cli-0.1.3.tgz.sigstore.json

npm packages carry the same provenance, shown as a Provenance panel on the package page, and verifiable locally:

npm audit signatures

Both fail if the artifact was built anywhere other than this repository's CI.

Read the scan results yourself

Security tab every CodeQL and Scorecard finding, open and closed
Scorecard report per-check supply-chain scores, updated weekly
Actions full logs of every scan, including the egress proof

These are published by GitHub and the OpenSSF, not by this project. Nobody here can edit them.

Check it does not phone home

The strongest claim, and the easiest to test yourself:

# Linux — run it with no network except loopback
sudo unshare --net bash -c 'ip link set lo up; kronk-cli --models'

CI runs exactly this on every pull request against a stub server, and a second step proves the namespace was really isolated so a pass cannot be a false negative.

Or just read src/ — it is under 1,500 lines with no dependencies, and every network call goes through one function in src/client.js.


Requirements

  • Node 20+
  • A running Kronk server with at least one chat model
kronk model pull unsloth/Qwen3.6-35B-A3B-UD-Q4_K_M
kronk server start --detach

Install

From source — recommended while you're editing it

git clone https://github.com/you/kronk-cli && cd kronk-cli
npm link

npm link symlinks the repo into your global bin, so kronk-cli works from any directory and your edits take effect immediately:

$ which kronk-cli
/Users/you/.nvm/versions/node/v24.13.0/bin/kronk-cli

$ ls -l $(which kronk-cli)
… -> ../lib/node_modules/kronk-cli/src/index.js

Uninstall

npm unlink -g kronk-cli        # removes the global symlink
npm unlink                     # run inside the repo, clears the local link

Verify it's gone with which kronk-cli — no output means success.

Other ways

npm install -g kronk-cli       # from the registry
npm install -g .               # from source, COPIES — you must redo it after every edit
npx kronk-cli                  # no install
node src/index.js              # no install, from the repo

Using nvm?

Global bins live inside the active Node version, so switching versions hides the command.

nvm alias default v24.13.0     # pin, and stay on it
npm link                       # …or re-link under each version you use

To make it survive version switches entirely, skip npm and symlink somewhere neutral:

ln -s "$PWD/src/index.js" ~/.local/bin/kronk-cli

Scope

The agent roots itself at the directory you launch it from. cd into a project first.

Two separate things keep it there, and they are worth telling apart:

Enforced by Covers
Path containment kronk-cli read_file, write_file, list_dir, search — resolved through symlinks, so a link inside the project cannot point out of it
Shell confinement the kernel — sandbox-exec on macOS, bwrap on Linux bash: writes outside the project are denied, and key material (~/.ssh, ~/.gnupg, ~/.password-store, the macOS keychain, ~/.netrc, ~/.npmrc) is unreadable

The startup banner says which is in force:

  sandbox  paths + seatbelt

If no backend is available, it says so rather than implying one:

  sandbox  paths only — bwrap not installed, shell commands are unconfined

What shell confinement does not do. Reads stay open outside the deny-list, because denying them wholesale breaks every compiler and runtime the agent needs. The network is not blocked — the agent has to be able to run npm install. So it stops a command from writing outside your project or reading your keys; it does not make a hostile command harmless.

The write half is categorical: both backends start from "nothing is writable" and hand back the project, the temp directories and the build caches. The read half is a deny-list, and a deny-list is only as good as its entries — which is exactly why it is kept narrow rather than broad. See Authenticated CLIs for the trade that produced it.

Two more limits worth stating:

  • Symlinks out of the project are refused, even benign ones. An npm linked package under node_modules resolves outside the root, so read_file and write_file will decline it. Use the real path, which is inside a project the agent was launched in.
  • Only filesystem operations are constrained. The macOS profile allows everything else by design, so a command that persuades an already-running unsandboxed process to act on its behalf is not covered by it. Confinement limits what a command reaches directly; it is not a substitute for reading the command before approving it.

KRONK_SANDBOX=strict refuses to run bash at all when no backend is available, which is the setting to use if you need the guarantee rather than the best effort. KRONK_SANDBOX=off disables confinement.

On Linux, install bubblewrap to get it: apt install bubblewrap / dnf install bubblewrap.

Authenticated CLIs

Tools you are already logged in to keep working. kubectl, argocd, aws, docker and the like read their session tokens from ~/.kube, ~/.config/argocd, ~/.aws and so on, and those stay readable:

› run lint on all apps, then show me the current kube context
  1 ⚙ bash: npx nx run-many -t lint
  ✓ 174 lines
  2 ⚙ bash: kubectl config current-context
  ✓ prod

An earlier version of this denied those directories too. It broke kubectl and gh outright while still missing argocd, whose token lives in ~/.config/argocd and which nobody had thought to add — a deny-list that blocks the tools you use and misses the ones you forgot costs real work and buys little, since an attacker just takes whichever store was not on the list. So the default covers material that is pivot-grade and never legitimately read by a build.

The one exception is the macOS keychain, which is denied by default. gh stores its token there, so it reports Failed to log in under the sandbox even though you are logged in.

You no longer have to know that in advance. When the agent is about to run a command that needs a credential store the sandbox denies, it asks first:

  1 ⚙ bash: gh issue view 34
  gh reads ~/Library/Keychains, which the sandbox denies.
  allow read-only access for this session? [y/N/always]
  • y grants it for the rest of the session. The seatbelt profile is rebuilt for every command, so it takes effect on the next one — no restart.
  • always also writes the path to ~/.kronk-cli.json under sandboxReadable, so later sessions do not ask. It says which file it wrote.
  • Anything else declines, and the command still runs. Declining is not an error; the command fails the way it would have failed anyway, and the result carries a note saying a denial is the likely cause.

You are asked at most once per path per session, whichever way you answered. With --yes or --auto there is nobody to ask, so the grant is automatic and announced on stderr — piping stdout still gets only the answer.

The grant is read-only. That is the difference from the older escape hatch, which still works and is still the right tool if you would rather set it before you start:

KRONK_SANDBOX_ALLOW=~/Library/Keychains kronk-cli

KRONK_SANDBOX_ALLOW makes a path fully available — readable and writable. For a credential store that is a poor trade: reading it is what a logged-in CLI needs, and writing to it is what an attacker needs. The prompt grants only the read.

Be honest with yourself about what you are granting either way: it is the whole ~/Library/Keychains directory, not one tool's item in it. macOS securityd gates individual items by per-application ACL independently of file reads, so a process still cannot read another application's item without your consent — but that is a mitigation on top, not the boundary you drew here.

Logging in from inside the agent will not work, by design — argocd login, gh auth login and kubectl config set-context all write outside the project. Log in yourself, in your own shell, before starting a session. If a tool genuinely must write to its config directory, allow just that:

KRONK_SANDBOX_ALLOW=~/.config/argocd kronk-cli

KRONK_SANDBOX_ALLOW makes a path fully available — writable, and readable even if it is denied by default. KRONK_SANDBOX_DENY goes the other way and hides more, if you would rather the agent could not read your cluster credentials at all:

KRONK_SANDBOX_DENY=~/.kube,~/.aws kronk-cli     # kubectl and aws will now fail

Both take a comma- or colon-separated list, and ~ expands.

⚠️ bwrap needs unprivileged user namespaces, which several hardened distros and most CI runners disable — GitHub's included. Where they are off, bwrap is installed but cannot start, and the banner will say the shell is unconfined. That is why the backend is probed rather than assumed, and why the check is worth reading rather than trusting the presence of the binary.


Project awareness

On startup kronk-cli scans the directory once and puts a primer in the system prompt, so the model knows where it is before it calls a single tool:

  • working directory and platform
  • git branch, uncommitted files, last 5 commits
  • top-level layout (skipping node_modules, dist, .git, …)
  • the contents of the first agent file it finds: AGENTS.md, CLAUDE.md, KRONK.md, .cursorrules, CONVENTIONS.md

The REPL confirms what it picked up:

$ kronk-cli

  ██ kronk-cli  · local agent, no network
  model  unsloth/Qwen3.6-35B-A3B-UD-Q4_K_M/AGENT
  server http://localhost:11435/v1

  context  ~/Projects/api · git · AGENTS.md

Which means this needs no tools at all:

$ kronk-cli "what branch am I on and what indentation does this project use?"

  - Branch: main, 1 uncommitted file (src/more.js)
  - Indentation: tabs, never spaces — per AGENTS.md

An AGENTS.md is the way to give the model standing instructions. It is read on every run, so conventions stick without you restating them:

# Project conventions

- This project uses tabs, never spaces.
- Every exported function needs a JSDoc block.
- Never add dependencies without asking.

The primer costs roughly 150–800 tokens and sits at the front of the system message, so Kronk's prompt cache reuses it across every turn. Disable it with --no-context.

It is a primer, not a preload — the model still reads files with read_file when it needs their contents. Nothing beyond the listing and the agent file is sent up front.


Usage

kronk-cli                                   # interactive REPL
kronk-cli "explain src/agent.js"            # one shot, prints and exits
git diff | kronk-cli "review this diff"     # stdin as extra context
git log --oneline -20 | kronk-cli           # stdin as the whole prompt
kronk-cli --auto "make the tests pass"      # unattended, runs the whole task
kronk-cli setup                             # first run: model, profile, restart

Subcommands

kronk-cli setup

The three things a fresh install needs — the model on disk, an /AGENT profile for it, and a server restart so Kronk reads that profile — walked in order, announcing each step. --dry-run walks the whole thing and prints what it would do, which is the safest way to see it:

$ kronk-cli setup --dry-run --model unsloth/Qwen3-0.6B-Q8_0/AGENT

  kronk-cli setup  · dry run, nothing will change

  1) Checking the Kronk server
     http://localhost:11435/v1 · serving 2 models

  2) Resolving the target
     profile  unsloth/Qwen3-0.6B-Q8_0/AGENT
     catalog  unsloth/Qwen3-0.6B-Q8_0
     binary   /opt/homebrew/bin/kronk

  3) Checking whether the model is downloaded
     would run: kronk catalog show unsloth/Qwen3-0.6B-Q8_0 --local

  4) Downloading the model
     would run: kronk model pull unsloth/Qwen3-0.6B-Q8_0

  5) Writing the /AGENT profile
     file  ~/.kronk/models/model_config.yaml
     This block will be added:

       unsloth/Qwen3-0.6B-Q8_0/AGENT:
         context-window: 131072
         nseq-max: 2
         chat-template-kwargs:
           preserve_thinking: true
         sampling-parameters:
           max_tokens: 16384

     would update: ~/.kronk/models/model_config.yaml
     would back up first: ~/.kronk/models/model_config.yaml.bak…

  6) Restarting Kronk
     model_config.yaml is read only when the server starts, so the new
     profile does nothing until Kronk is restarted.
     would run: kronk server stop
     would run: kronk server start --detach

  Dry run complete — nothing was written and nothing was started.
Flag
--model <id> Set up a model other than the default. A trailing /AGENT is a Kronk profile name, not a catalog id, so it is stripped before pulling
--context <n> Override the profile's context-window. Default 131072, capped at the model's native maximum when Kronk reports one
-y, --yes Answer every prompt yes. Required in CI
--dry-run Print every action, including the exact YAML and the exact commands, and change nothing

Nothing slow or destructive happens without an answer: the pull, the file write and the restart are each confirmed. Declining exits 0 and prints the commands you would run by hand. If the kronk binary is not on PATH, setup prints the whole manual recipe instead of failing with a spawn error. Run it twice and the second run does nothing.

Piped or unattended input answers the first question only — readline discards lines nobody is waiting for — so a question with no answer left is a no: setup says stdin ended, assuming no, prints the command, and exits 0. Scripts and CI want -y.

What it writes. Only the models: mapping of ~/.kronk/models/model_config.yaml — point it elsewhere with KRONK_MODEL_CONFIG. This project has no YAML parser and will not gain one, so the writer is a structural scan with a single job — find the one models: key and splice the entry beneath it — and it refuses whenever the answer is not obvious:

  • The file is copied aside before any write, to model_config.yaml.bak, and never over a backup that already exists: .bak2, .bak3, and so on until a free name is found.
  • Every line it did not add survives byte for byte — comments, blank lines and key order included. LF and CRLF files each keep their own endings.
  • Missing file → created with version: 1, models: and the entry. No models: key → both are appended. A models: key with no children → the entry becomes its first child.
  • More than one top-level models: key — two concatenated documents, which does happen in the wild — is refused outright. It prints the block, names the file, and exits non-zero without touching anything.
  • A missing ~/.kronk/models/ means Kronk has never run. Setup says so and stops, rather than creating Kronk's data directory on its behalf.

The profile it writes is the one documented under Tip: use an /AGENT profile — context-window, nseq-max, preserve_thinking and max_tokens, and deliberately no sampling parameters.

kronk-cli rescue

Everywhere else in this document, kronk-cli is something you talk to. rescue is the other direction: a job queue so anything that can spawn a process — a script, another CLI, anything external that wants to hand off a token-heavy job without sitting on it — can submit a self-contained task to the local model, go do something else, and come back for the answer.

$ kronk-cli rescue "trace how the SSE parser handles multi-line data fields" --role explore
9k2m1x-4d2-a1b2c3

$ kronk-cli rescue status 9k2m1x-4d2-a1b2c3
running (step 4)

$ kronk-cli rescue wait 9k2m1x-4d2-a1b2c3
Multi-line `data:` fields are joined with `\n` in src/sse.js:41-53 before the
frame is handed to the caller — see accumulateToolCalls's buffer.
$ echo $?
0

It is the same delegation kronk-cli already does internally — one self-contained task, its own context, a report that stands alone — with the caller moved outside the process instead of inside it. See Sub-agents for the contract a rescue job runs under; it is the identical one.

Command
kronk-cli rescue "<prompt>" [--role explore|code] [--model <id>] [-y|-a] [--steps <n>] [--json] Submit. Runs detached; prints a job id and exits immediately — nothing here blocks
kronk-cli rescue status <id> [--json] A snapshot: queued, running (step n), done, stopped, interrupted or failed
kronk-cli rescue wait <id> [--json] Block until the job reaches one of those last four states, then print its report
kronk-cli rescue result <id> [--json] Print the report. Refuses (exit 4) with the current state if the job has not finished yet — use wait instead of polling result in a loop

--role is the same split kronk-cli gives its own sub-agents: explore can only read — read_file, list_dir, search — and code can also write files and run commands. Default is explore, the read-only one. --model is resolved exactly as -m resolves it everywhere else in this program — exact id, else the closest substring, preferring a served /AGENT profile — against what Kronk is serving right now; a name that matches nothing is refused at submit time rather than silently falling back, because a caller that asked for a specific model should hear about a bad name immediately, on the process it is still attached to, not from a job that failed twenty minutes later. Leaving --model out defers the choice to the job itself, the same default the session would otherwise use.

Without -y/-a, a tool that needs approval is refused and reported, never auto-allowed. Nobody is at the prompt a rescue job would otherwise show, so there is no "ask anyway" — a refused tool call comes back in the transcript exactly like a human said no, and the job carries on (or does not) from there. This is deliberate and does not have a flag to turn it off: delegating a job from outside the process must not become a way past the approval prompts that delegating from inside it already cannot get past.

One at a time. Kronk holds one resident copy of the model, so a second rescue submitted while one is running does not run alongside it — its status reports queued until the first job finishes, exactly as the module comment on delegation in src/subagent.js already argues for sub-agents. A runner that dies mid-job (killed, the machine restarting) does not wedge the queue behind it: the next job to check finds the lock's process gone, marks the abandoned job interrupted, and takes its turn.

--json on any of the four commands prints one object instead of prose:

{ "id": "9k2m1x-4d2-a1b2c3", "state": "done", "role": "explore",
  "model": "unsloth/Qwen3.6-35B-A3B-UD-Q4_K_M/AGENT", "steps": 3, "ms": 11421,
  "report": "Multi-line `data:` fields are joined with…", "transcript": [ /* the full run */ ] }

wait's exit code is the one place a script gets the state without parsing anything:

State Meaning wait exit code
done Finished on its own terms 0
stopped Hit its step cap before finishing 1
interrupted Killed, or recovered from a runner that died mid-job 2
failed The model call itself errored 3

Records outlive the process that submitted them, on purpose. They are not sub-agent transcripts (src/tasklog.js) and do not live in the same place or on the same clock — that directory is swept on this session's own schedule specifically because nothing outside the process is meant to still want it once the session ends, and a rescue job's whole point is that something outside the process still does. A rescue record is kept indefinitely until wait or result actually delivers it, and only then aged out, 24 hours later — KRONK_RESCUE_DIR overrides where they live, same idea as KRONK_TASK_LOG_DIR.


Startup: the model is loaded before you type

Kronk has no load command. It lists a model in GET /v1/models as soon as the server starts, but the weights only reach VRAM on the first inference request — so on a fresh server the first prompt you type pays a 10–30 s cold load, and looks like a hang.

kronk-cli pays it at boot instead. It asks Kronk what is resident, and if the selected model is not, sends the cheapest completion there is — one token, no reasoning — to trigger admission:

$ kronk-cli
  loaded   unsloth/Qwen3.6-35B-A3B-UD-Q4_K_M/AGENT · 11.4s

  ██ kronk-cli  · local agent, no network

A model already in the pool is left alone; nothing is sent. If the selected one cannot be admitted — it will not fit next to what is already resident — the fallback runs the same order the CLI uses when you name nothing: the configured default, then the best id Kronk is serving. Each is tried once, and a failure says why:

$ kronk-cli -m Qwen3.6-27B
  unsloth/Qwen3.6-27B-Q4_K_M failed to load — 507 /chat/completions — insufficient VRAM
  loaded   unsloth/Qwen3.6-35B-A3B-UD-Q4_K_M/AGENT · 2.0s · fallback

If nothing loads, the original pick stands and the first turn reports the real error. A warm-up is a convenience, not a gate — it never decides whether the CLI starts.

Skip it with --no-warm or KRONK_WARM=false and the first prompt pays the load, as before.


Sampling override warning

The same startup lookup that finds the context window also carries the model's own sampling recommendations — general.sampling.temp, top_k and top_p, straight from the GGUF — next to the effective sampling-parameters Kronk is actually applying once any profile has been merged in. kronk-cli compares the two and, if a profile is overriding what the model ships, says so in one grey line under the startup banner:

$ kronk-cli
  ██ kronk-cli  · local agent, no network
  model  unsloth/Qwen3.6-35B-A3B-UD-Q4_K_M/AGENT
  server http://localhost:11435/v1
  /help for commands · Ctrl-C to interrupt · /exit to quit

  note     profile overrides the model's own sampling: temperature 0.6 (model recommends 1)

One line, however many of the three parameters disagree — not one per parameter. It stays silent when the values agree, when the model ships no general.sampling.* metadata at all, or when the model-info lookup fails: this is information, not a failure, and it never blocks, prompts, or changes the exit code.

The fix is the one described in Tip: use an /AGENT profile: remove temperature, top_k and top_p from the profile's sampling-parameters block so Kronk applies what the model itself recommends.

Since Kronk 1.32.2 this line can fire on a profile you never wrote. That release changed Kronk's own shipped default for unsloth/Qwen3.8-27B-UD-Q4_K_XL/AGENT to pin temperature: 0.7, top_p: 0.80 and top_k: 20, where it previously carried none of the three. The warning is still accurate — something is overriding the GGUF — but the something is the server's authors making a deliberate choice for that model, not a stale hand-edit. Read it as a fact worth knowing rather than a defect to repair, and leave it alone unless you have measured otherwise.

Like the rest of the banner, the line is part of the interactive REPL's startup — a one-shot run (kronk-cli "prompt") prints no banner and prints nothing here either.


Checking for updates

kronk-cli --version prints what is actually running:

$ kronk-cli --version
kronk-cli 0.7.0
kronk    1.32.7

The second line is kronk --version, read through the same runKronk helper setup uses, capped at a few seconds so a stuck binary cannot hang the command. It is omitted, not guessed at, when kronk is not on PATH or does not answer in time — this has to work with no server running, which is the ordinary case for asking a program what it is.

The startup banner adds a third line when a newer kronk-cli exists:

  ██ kronk-cli  · local agent, no network
  model   unsloth/Qwen3.6-35B-A3B-UD-Q4_K_M/AGENT
  server  http://localhost:11435/v1
  update  kronk-cli 0.7.0 → 0.8.1 · run: brew upgrade kronk-cli
  /help for commands · Ctrl-C to interrupt · /exit to quit

It asks the package manager, never the network directly. No fetch, no URL literal — the version comes from whatever tool installed this copy, detected from the path src/index.js itself resolved to:

Route How it's detected What answers
Homebrew a Cellar/kronk-cli/ path brew outdated --json
npm global install a node_modules/kronk-cli path npm view kronk-cli version
git checkout a .git above the file git fetch --tags + the newest tag
packaging/install.sh a lib/kronk-cli path nothing — see below

The hint always matches the route: a Homebrew install is told brew upgrade kronk-cli, an npm one npm install -g kronk-cli@latest, a checkout git pull. Suggesting the wrong one would send someone to a manager that has never heard of this copy, which is worse than saying nothing.

The git checkout route is the one that makes a real network call, through git fetch --tags rather than any request this program issues itself, and it honours the same off-switch as everything else here.

The install script has nothing to ask. It unpacks a tarball into $PREFIX/lib/kronk-cli and, unlike the other three routes, there is no registry behind it that knows the latest release. It writes a small receipt ($PREFIX/lib/kronk-cli/.install-receipt.json, version and source) at install time so this route is not silent forever; when that receipt is present the banner shows a plain nudge — update kronk-cli 0.7.0 · re-run the install script — with no version comparison, because there is genuinely nothing to compare against. A copy installed before the receipt existed, or one someone assembled by hand, shows no update line at all, which is the honest answer for a route with no manager behind it.

Never on the path that slows startup down. The check result is cached to disk with a timestamp and reused for up to a day; a stale or missing cache still renders instantly (from whatever was cached last, or nothing) while a fresh check for the next session runs unawaited in the background. Nothing about this can delay, block, or fail the REPL — a package manager that hangs, errors, or prints garbage simply produces no line.

KRONK_UPDATE_CHECK=false (or "updateCheck": false in the config file) turns all of this off: no cache read, no subprocess, ever.


Performance

The transcripts above are real sessions, kept to show what the UI looks like — not a benchmark table, and nothing here reproduced them until now. scripts/bench.mjs does: a fixed prompt run twice for generation speed, a long deterministic filler prompt carried across three turns for prompt-cache retention, and — only when the target model is not already resident — a cold-load timing using the same request warm() sends at boot. It is a dev tool, excluded from the published package; run it straight from a checkout:

node scripts/bench.mjs               # table
node scripts/bench.mjs --json        # same numbers, one JSON object, diffable between runs
node scripts/bench.mjs --skip-cold-load

It talks to whatever KRONK_URL / KRONK_MODEL you already have configured, so the numbers below are specific to one machine — measure your own rather than trusting these across different hardware.

Measured 2026-08-23 on an Apple M4 Max, 64 GB RAM (macOS), against unsloth/Qwen3.6-35B-A3B-UD-Q4_K_M/AGENT, kronk 1.31.9, llama.cpp b10549 — from node scripts/bench.mjs:

kronk-bench · model unsloth/Qwen3.6-35B-A3B-UD-Q4_K_M/AGENT · kronk 1.31.9 · llama.cpp b10549

1. Generation speed
   run 1: 229 tok · 2.97s · 77.1 tok/s · ttft 133ms
   run 2: 220 tok · 2.82s · 78 tok/s · ttft 115ms

2. Prompt cache retention (filler prompt: 5491 tok)
   turn 1: 5518 tok prompt · 5496 tok cached (100%) · 149ms
   turn 2: 5544 tok prompt · 5511 tok cached (99%) · 185ms
   turn 3: 5571 tok prompt · 5537 tok cached (99%) · 160ms

3. Cold load
   skipped — unsloth/Qwen3.6-35B-A3B-UD-Q4_K_M/AGENT is already resident — a cold load number
   here would be misleading

That generation speed and cache-retention region matches the transcripts above (60–80 tok/s, prompt cache reused above 99% on repeat turns) — this machine's Kronk server is shared with other work, so a run under contention lands lower in that range and a quiet run lands higher; that variance is expected, which is why the script reports each run rather than a single averaged number. The cold-load section only prints a number the one time a run finds the model not resident — do not stop or unload a shared server's model just to force that path; skip it, the same way this run did.


Command-line options

Flag Default
-m, --model <id> unsloth/Qwen3.6-35B-A3B-UD-Q4_K_M/AGENT Model to use. A substring is enough; /AGENT profiles win ties
-l, --models, --list — List the models Kronk is serving, then exit
--no-context off Skip the startup scan of the working directory
--no-compact off Never auto-compact; fail when the window fills instead
--no-warm off Don't preload the model at startup; let the first prompt trigger the load
--mcp [names] off Attach MCP servers — bare for all, or a comma list
--mcp-list — Show configured MCP servers and their tools, then exit
-a, --auto off Autonomous: auto-approve tools and run until the task is done. Implies --yes
-y, --yes off Auto-approve write_file and bash without the autonomous prompt
--no-think off Disable the model's reasoning pass server-side. Much faster
--no-subagents off Remove the task tool, so the model cannot delegate — see Sub-agents
--steps <n> unlimited Cap tool calls per task. 0, off, none, inf, unlimited all mean no cap
-h, --help — Print all options and exit
-v, --version — Print the kronk-cli version, and Kronk's if it can be had without a network call, then exit
-- — End option parsing; everything after it is prompt text, dashes and all

Anything not consumed as an option becomes the prompt, except a token that looks like an option and is not one: kronk-cli -auto "…" exits 2 with unknown option: -auto and a suggestion, rather than folding the typo into the prompt and running with the mode off. Prose is untouched — a dash followed by a space is not option-shaped, so kronk-cli "- fix the dashes bug" still asks the question — and -- ends option parsing, so kronk-cli -- --explain this sends --explain this. With both an inline prompt and piped stdin, the two are concatenated.

Ctrl-C aborts the in-flight response and the tool loop without killing the session.


The status line

Above every prompt, reflecting current state rather than what you launched with:

⏵ AGENT · auto · no-think · mcp nx,kronk · steps 50 · 22k/131k 17% ▓▓░░░░░░░░
›

Model, active modes, attached MCP servers, any step cap, and the context meter — grey under 70%, yellow past 70%, red past 90%. Toggling /auto, /think or /steps updates it immediately.

The per-turn usage line still prints after each response; this one is the running picture.

It follows your terminal's background

That grey is the one colour the whole line depends on, and the sixteen-colour palette has exactly one of them — bright black, which most terminals render around #555. On a light background it reads; on a dark one the status line was the hardest thing on screen to see.

So the palette is chosen per background, from the 256-colour cube: a lighter grey on a dark terminal, a darker one on a light terminal, and every other colour shifted the same way. Which background you have is settled once, at startup, in this order:

  1. KRONK_THEME, or "theme" in ~/.kronk-cli.json — dark or light pins it
  2. COLORFGBG, when the terminal exports it
  3. the terminal's own answer to an OSC 11 background query, waited on for 100 ms
  4. failing all of that, dark — what most terminals ship as

/theme dark or /theme light switches it mid-session, for when step 3 guessed wrong. NO_COLOR still turns every escape off, and a terminal that advertises no 256-colour support gets a sixteen-colour palette picked the same way.


Markdown in answers

The models write markdown whether or not anything renders it, so an answer used to arrive with the punctuation still in your way — ### The letter is a notification, **binding**, backticks around every identifier. Those constructs are rendered into the attributes the terminal already has: headings lose their hashes and gain bold, emphasis becomes bold or italic, code spans and fenced blocks get their own colour and a gutter, lists get a •, quotes a │, and --- a rule.

It is a line renderer, not a markdown parser — one bit of state, whether a fenced block is open — and anything it does not recognise is left exactly as written. Prose that only looks like markup survives: snake_case, 3 * 4, a lone asterisk, **kwargs inside backticks.

Two consequences worth knowing:

  • Output appears a line at a time, not a token at a time, because ** is bold or two asterisks depending on what follows it. At local speeds that is still a line every half second.
  • With colour off the bytes are the model's own. Piped, redirected, or NO_COLOR — nothing is rewritten, so anything parsing kronk-cli's output sees exactly what it saw before.

REPL commands

Command
/help list these commands
/models what Kronk is serving, with sizes and what is resident
/model <id> switch model — substring match, /AGENT preferred; re-reads that model's window and caps
/file <path> add a file to the conversation as context
/auto toggle autonomous mode (auto-approve + run to completion)
/steps [n|off] show or set the tool-call cap
/thinking show or hide the model's reasoning
/think turn reasoning off entirely — much faster
/agents the sub-agents task can delegate to, what each may touch, and the default model
/tasks [n] sub-agent runs this session; with n, that one in full
/mcp list attached MCP servers and their tools
/context how much of the context window is used
/theme [dark|light] show or pin the palette, for when the terminal was read wrong
/compact replace the conversation with a summary of itself
/clear reset the conversation, keep the model
/exit, /quit quit

Environment variables

Variable Default
KRONK_URL http://localhost:11435/v1 Kronk API base
KRONK_TOKEN kronk Any non-empty value while Kronk runs open; a real JWT when protected
KRONK_MODEL unsloth/Qwen3.6-35B-A3B-UD-Q4_K_M/AGENT Model id
KRONK_MODEL_CONFIG ~/.kronk/models/model_config.yaml Kronk's per-model config, the file kronk-cli setup writes
KRONK_MAX_TOKENS the model's profile Output cap per response. Unset, the selected model's own max_tokens decides — 8192 only when its profile sets none
KRONK_MAX_STEPS unlimited Cap on tool calls per task
KRONK_THINKING true false hides reasoning but still generates it
KRONK_NO_THINK — 1 disables reasoning server-side
KRONK_PRESERVE_THINKING true false stops pinning earlier think blocks in the prompt
KRONK_REPLAY_REASONING autonomous-only true/false overrides the default in either direction — see What reasoning gets sent back
KRONK_TOOL_TIMEOUT 900 Seconds before a shell command is killed
KRONK_SANDBOX auto auto confines bash when the OS can, strict refuses to run it when it cannot, off disables it
KRONK_SANDBOX_ALLOW — Paths to make fully available inside the sandbox, comma or colon separated
KRONK_SANDBOX_DENY — Extra paths to hide from bash, comma or colon separated
KRONK_CONFIG ~/.kronk-cli.json Path to the config file, so tests and throwaway runs stay off the real one
KRONK_DISTILL true false disables tool-output distillation
KRONK_DISTILL_AT 8000 Characters of output that trigger distillation
KRONK_SUBAGENTS true false removes the task tool
KRONK_SUBAGENT_MODEL the main model Default model for a task that names none itself — substring match, same as KRONK_MODEL. A task can pick its own from what Kronk is serving, see Picking a model per task
KRONK_SUBAGENT_STEPS 40 Tool-call cap for one delegated task
KRONK_TASK_LOG true false stops writing sub-agent transcripts, and /tasks says so
KRONK_TASK_LOG_DIR the temp dir Where transcripts are written
KRONK_RESCUE_DIR the temp dir Where kronk-cli rescue keeps its job records — see kronk-cli rescue
KRONK_WARM true false skips the boot-time model preload
KRONK_AUTO_COMPACT true false disables automatic compaction
KRONK_COMPACT_AT 0.85 Fraction of the window that triggers compaction
KRONK_THEME auto dark or light pins the palette; auto reads the terminal's background
KRONK_UPDATE_CHECK true false never asks the package manager whether a newer kronk-cli exists — no subprocess, ever
NO_COLOR — Any value disables colour
FORCE_COLOR — 0 disables colour; any other value keeps it on through a pipe

Config file

~/.kronk-cli.json. Command line beats environment beats this file.

{
  "baseUrl": "http://localhost:11435/v1",
  "token": "kronk",
  "model": "unsloth/Qwen3.6-35B-A3B-UD-Q4_K_M/AGENT",
  "maxTokens": 16384,
  "maxSteps": 200,
  "showThinking": false,
  "theme": "auto",
  "autoCompact": true,
  "compactAt": 0.85,
  "noThink": true,
  "preserveThinking": true,
  "replayReasoning": true,
  "subagentModel": "Qwen3.6-4B",
  "subagentSteps": 40,
  "sandboxReadable": ["~/Library/Keychains"]
}

sandboxReadable is the list always writes at the credential prompt — paths the sandbox lifts its read denial on, and nothing more. Unlike KRONK_SANDBOX_ALLOW it never grants writes. Entries may be absolute or ~-relative, and one that no longer exists is ignored in silence.


Choosing a model

Resolution order:

  1. -m / --model
  2. KRONK_MODEL, or model in ~/.kronk-cli.json
  3. unsloth/Qwen3.6-35B-A3B-UD-Q4_K_M/AGENT, if Kronk is serving it
  4. otherwise the largest chat model available, preferring an /AGENT profile

A substring is enough — -m Qwen3.6 resolves to the full id. An unrecognised value warns and falls back rather than failing.

Listing models

$ kronk-cli --models

  ○ unsloth/Qwen3.6-35B-A3B-UD-Q4_K_M        22.1 GB · vision
  ● unsloth/Qwen3.6-35B-A3B-UD-Q4_K_M/AGENT  22.1 GB · vision · loaded 28.1 GB

  resident: 28.1 GB
  default:  unsloth/Qwen3.6-35B-A3B-UD-Q4_K_M/AGENT
  select:   kronk-cli -m <substring>

● is the model you will get. loaded means it is resident in Kronk's pool, and how much memory it holds.

⚠️ Profiles share one file on disk but are separate resident copies in RAM. Asking for the base id while /AGENT is loaded pulls a second 22 GB instance. Pick one and stay on it. Free a stray one with:

curl -X POST localhost:11435/v1/kronk/models/unload \
  -H 'Content-Type: application/json' -d '{"id":"<model-id>"}'

Tip: use an /AGENT profile

Kronk lets one GGUF serve several runtime configurations. kronk-cli setup writes this profile for you, backs the file up first, and offers the restart — see Subcommands. To do it by hand, add this to ~/.kronk/models/model_config.yaml and restart the server:

version: 1
models:
  unsloth/Qwen3.6-35B-A3B-UD-Q4_K_M/AGENT:
    context-window: 131072
    nseq-max: 2
    chat-template-kwargs:
      preserve_thinking: true
    sampling-parameters:
      max_tokens: 16384

Do not set temperature, top_k or top_p here. Most current GGUFs carry the values their authors recommend, Kronk reads them, and an explicit block only overrides the model's own advice. This model ships general.sampling.temp: 1, top_k: 20, top_p: 0.95; an earlier version of this page recommended temperature: 0.6, which quietly fought that. Check what your model carries with kronk model show <id> --local. If a profile does pin one of these, kronk-cli says so at startup — see Sampling override warning.

preserve_thinking earns its place in an agent profile. The chat template decides per assistant message whether to render its <think> block, and the decision depends on which user message is the most recent real query. During a tool loop that boundary moves, so the same earlier turn renders one way now and another way after your next prompt — the prefix changes and the cached prompt is thrown away. preserve_thinking: true renders those blocks the same way every time, so the prefix stays stable and the server keeps the cache.

Measured on Kronk 1.31.8, on a 13.4k-token conversation with the profile above: the first turn prefilled in 10.9 s with nothing cached, and every following turn reported 13,4xx of 13,4xx tokens cached and answered in 1.6 s. A one-off session, not reproducible from this repo as run — see Performance for the current, scripted measurement of the same effect.

You no longer have to set it. kronk-cli sends chat_template_kwargs: {preserve_thinking: true} on every chat request, regardless of what the profile says, so an existing profile that predates this line gets the stable prefix anyway. It is sent only when the selected model's chat template actually declares the parameter — read from tokenizer.chat_template in GET /v1/kronk/models/<id> at startup — and never guessed at when that lookup fails.

It is a trade, not a free win: the retained <think> blocks stay in the prompt and cost tokens. On a small window you may prefer to pay the prefill instead, so KRONK_PRESERVE_THINKING=false (or "preserveThinking": false in ~/.kronk-cli.json) turns it off, and the status line says no-preserve when it is off. With --no-think there is no reasoning to preserve and the field is not sent at all.

What reasoning gets sent back

preserve_thinking decides how the template renders a think block. What is in that block is a separate question, and kronk-cli answers it like this: the model's reasoning is replayed for the current task and dropped for everything before it — by default in --auto, and never by default in the interactive REPL.

Concretely, an assistant message keeps its reasoning_content on the wire while it sits after the most recent real user prompt. A tool result is role: tool, so it does not end the task — one prompt and the whole tool loop it started share the boundary. The moment a new user prompt arrives, the previous task's blocks are stripped and render empty. That is the same boundary the chat template computes as ns.last_query_index.

Within a task the model reasons about tool result N before it picks tool N+1, and dropping that makes it re-derive its plan from tool output alone at every step. Those tokens are also append-only — new on every step, never part of the cached prefix — so replaying them costs nothing in cache terms. Reasoning from earlier turns is the opposite: it sits in the prefix for the rest of the session, grows without bound, and brings automatic compaction forward.

It is a trade, and the bill arrives at your next prompt: stripping the previous task's blocks at the boundary rewrites the prefix that prompt sits after, so the request resets to the cached system prompt instead of the cached conversation and re-prefills the rest. Measured on a live server, first turn after a second prompt, prompt tokens cached vs. re-prefilled:

cached re-prefilled
never replay 906 60
replay current task 757 516
replay everything 1,827 367

--auto runs exactly one user prompt through a whole tool loop, so that boundary is never crossed in a run: the within-task benefit above is free there, and the reset never happens. The REPL is a user typing repeatedly, so every prompt after the first pays it. Measured over five paired sessions on Kronk 1.31.9 with unsloth/Qwen3.6-35B-A3B-UD-Q4_K_M/AGENT (llama.cpp b10549, darwin/arm64/metal) with replay forced on for the whole session — the same multi-step task, nine to twenty-four tool calls depending on the run, followed by the same follow-up question:

replay on replay off
Prompt tokens at end of session 10,179 / 11,238 / 7,960 / 10,018 / 9,470 20,640 / 8,889 / 10,648 / 10,259 / 13,209
Model turns to finish the first task 6 / 7 / 2 / 7 / 8 9 / 9 / 7 / 7 / 5
Cached tokens on the first turn after the second prompt 1,222 every run the whole prefix every run
Time to first token on that turn 5.9 / 7.4 / 4.4 / 5.9 / 6.5 s 1.2 / 0.8 / 0.9 / 0.7 / 1.1 s
Wall clock, three timed pairs 51 / 63 / 50 s 68 / 49 / 55 s

So: fewer turns and fewer prompt tokens over a session, paid for with one re-prefill per prompt — 4.4-7.4 s to first token instead of 0.7-1.2 s once cached. That cost is only worth paying when there is exactly one prompt to begin with, which is why the default is autonomous-only rather than on everywhere. Sampling is at the model's own temperature: 1, so the turn counts above are indicative rather than reproducible.

KRONK_REPLAY_REASONING, or "replayReasoning" in ~/.kronk-cli.json, overrides the default in either direction rather than just turning it off: true replays reasoning in the REPL too, false turns it off even in --auto. Leaving it unset is what gives you the autonomous-only default. It is off regardless of --auto when --no-think is set — there is no reasoning to replay — and when the selected model's chat template does not declare preserve_thinking, because such a template does not read reasoning_content either and would discard the blocks.


Context window

Kronk reports the effective window for whichever model id you selected — that comes from context-window in model_config.yaml, so a /AGENT profile and its base model can differ.

Every limit is read from the selected model's profile, and re-read when the selection changes: the window, the output cap (sampling-parameters.max_tokens), whether the chat template understands preserve_thinking, and the model's native maximum. /model mid-session moves all of them, and a sub-agent — whether it is running on KRONK_SUBAGENT_MODEL or on a model a task named for itself, see Picking a model per task — compacts against its own window rather than the main model's. The one number that is not taken from the profile is a KRONK_MAX_TOKENS you set yourself — yours always wins.

Three places surface it:

The banner

  context  ~/Projects/api · git · AGENTS.md · 131k ctx

Every usage line, as a meter that fills as the conversation grows:

  18471→57 tok · 69.0 tok/s · ttft 397ms · 18260 cached   18k/131k 14% ▓░░░░░░░░░

Grey under 70%, yellow past 70%, red past 90%. Also a real transcript — see Performance for how to reproduce numbers like these.

/context, on demand:

› /context
  3.8k/131k 3% ░░░░░░░░░░
  window: 131,072 tokens · model supports up to 262,144
  messages: 2

model supports up to is the model's trained maximum from its GGUF metadata. If it exceeds your configured window, you can raise context-window in ~/.kronk/models/model_config.yaml — at the cost of KV-cache memory.

The model knows its own budget

The startup primer tells it, so you can plan work against the number without stating it:

Your context window is 131,072 tokens, shared by everything in this conversation: these instructions, file contents you read, command output, and your own replies. When you plan work that must fit in one context, size it against that number and say what you assumed.

$ kronk-cli "how many tokens is your context window?"
  131,072 tokens

That makes prompts like "break this refactor into tickets, each sized to fit one context" resolve against a real number instead of a guess.

⚠️ It knows the window, not the live fill level — no model can see its own usage mid-turn. The meter is for you. If you need it to plan against remaining space, tell it what /context reports.

What happens at 100%

Kronk rejects the request — no silent truncation, no sliding window:

400  input tokens [40021] exceed context window [32768]

Because history only grows, every later turn would fail the same way. So kronk-cli compacts.

Compaction

/compact replaces the conversation with a summary of itself, keeping the system message and the project primer:

› /compact
  compacted 18,412 → 1,204 tokens (−93%)

The summary is written for the model, not for you — goal, decisions made and rejected, files touched and what they now do, commands run and what they showed, what is outstanding.

Automatic, in two situations:

Trigger
Past KRONK_COMPACT_AT of the window (default 85%) compacts between turns
A 400 … exceed context window compacts and retries the same turn once
  context full — compacting and retrying
  transcript too large for one pass — elided 92,415 chars from the middle
  compacted 38,102 → 143 tokens (−100%)

Disable with --no-compact or KRONK_AUTO_COMPACT=false if you would rather see the failure.

Details worth knowing

  • Tool messages are dropped, not carried over — they are only valid beside the assistant tool_calls that produced them, and a partial carry leaves orphaned tool_call_ids that the API rejects. The summary is what survives.
  • The summarizer runs against the same window that just overflowed, so an oversized transcript is first trimmed from the middle — the goal sits at the start, current state at the end.
  • If a summary comes out no shorter than the original, the conversation is left alone.
  • Compaction is lossy. Anything the summary omits is gone. /context before a long run, and /clear when you switch tasks, both beat relying on it.

Tools

Tool Approval
read_file — Read a UTF-8 file
list_dir — List a directory
search — Regex search via ripgrep, falling back to grep
write_file ✋ Create or overwrite; shows a diff preview first
bash ✋ Run a command; shows it first
set_plan — Record the task checklist; replaces the stored one
task — Delegate one job to a sub-agent with its own context

set_plan writes nothing and runs nothing — it hands the harness a list, which is why it is never gated. See Autonomous mode for what the harness then does with it.

task is not gated either, because it cannot do anything by itself: every write and every command a sub-agent asks for comes back to the same prompt you would have seen had the main agent asked for it.

--yes and --auto skip the prompts. Paths resolve against the session directory and cannot escape the launch root — including through a symlink. bash additionally runs under an OS sandbox where one is available; see Scope for exactly what that covers. bash keeps its working directory between calls, so a bare cd sticks the way it would in a real shell.


MCP servers

kronk-cli is an MCP client. It speaks both transports — stdio for local servers, Streamable HTTP for remote ones — with no extra dependency.

Off by default. Attach with --mcp:

kronk-cli --mcp                  # everything configured
kronk-cli --mcp nx,kronk         # just these
kronk-cli --mcp-list             # what is configured, what connects, what it exposes
$ kronk-cli --mcp nx "what does 'nx affected' do?"
  mcp      nx(1) · 1 tools
  1 ⚙ nx__nx_docs {"userQuery":"what does 'nx affected' do"}
  ✓ 44 lines
  `nx affected` identifies projects changed by a PR and runs tasks only on those…

Tools are namespaced server__tool so they cannot collide with the built-ins, and /mcp lists what is attached.

Where to put the config

Four files are read, later ones winning on name collision:

File Scope
~/.claude.json user Claude Code's global mcpServers, reused as-is
~/.claude.json → projects[cwd].mcpServers user, per-project
./.mcp.json project committed to the repo
~/.kronk-cli.json user mcpServers key
./.kronk-cli.json project mcpServers key

Best practice: put project tooling in ./.mcp.json and commit it. A teammate cloning the repo gets the same servers with no setup, and the config is versioned with the code that needs it. Keep account-level services that carry your credentials — Jira, GitLab, cloud providers — in ~/.claude.json so they follow you between projects and never land in a repo.

// .mcp.json — commit this
{
  "mcpServers": {
    "nx":       { "type": "stdio", "command": "npx", "args": ["-y", "nx-mcp@latest"] },
    "kronk":    { "type": "http",  "url": "http://localhost:9000/mcp" },
    "postgres": {
      "type": "stdio",
      "command": "npx",
      "args": ["-y", "@modelcontextprotocol/server-postgres"],
      "env": { "DATABASE_URL": "postgres://localhost/dev" }
    }
  }
}

Add "disabled": true to any entry to keep it in the file but out of the session.

Approval

MCP tools are third-party code, so anything whose name looks like a write — create, update, delete, apply, sync, run, deploy, … — prompts before it runs. Read-only lookups do not, because a prompt you always accept is not a safety control. --yes and --auto bypass both.

Keep the tool count down

Every attached tool goes into the request, and a local model's tool selection degrades well before a frontier model's does. --mcp with everything configured can easily mean 35+ tools; kronk-cli warns past 25. Name the two or three servers a task actually needs.

A server that fails to start is reported and skipped — the rest still work. Servers needing OAuth (Atlassian, GitLab) return 401 here, since kronk-cli has no browser auth flow.


Long-running commands

While it runs, a live line redraws in place — elapsed time, lines produced, and the last line printed, so a ten-minute build shows movement instead of looking like a hang:

  3 ⚙ bash: npx nx run-many -t lint
    ⠹ 84s · 12,403 lines · →7.5k ctx 6% capped   [42/97] Linting @acme/api…

→7.5k ctx 6% is what this command will cost your context once it finishes — not what it has printed. capped means it has already produced more than will be kept, so the number has stopped climbing and the excess is being discarded rather than silently eating your window.

Timeout is 15 minutes (KRONK_TOOL_TIMEOUT, in seconds), not the 2 minutes that used to kill real builds. The command runs in its own process group, so a timeout kills the whole tree rather than leaving orphans holding the pipe open.

When it fails, you get the reason, the duration, the directory, and whatever it printed before dying:

  ✗ error: exit code 1 after 184.2s
    cwd: /Users/you/Projects/api
    stderr:
    src/api/handlers.ts:142:11 - TS2345: Argument of type 'string' is not assignable…

or, on a timeout:

  ✗ error: killed after 900.0s (timeout 900s).
    The command may simply be slow — re-run a narrower scope, or raise KRONK_TOOL_TIMEOUT.
    stdout:
    [42/97] Linting @acme/api…

Do tool calls cost tokens?

Yes — the output does. Every tool result is appended to the conversation as a tool message and re-sent with each subsequent turn. The command itself is a few dozen tokens; a build log can be tens of thousands. One nx run-many on a large monorepo can take more than half a context window on its own.

Distillation

So large results are summarized in a separate model call whose context is thrown away. Only the digest reaches your conversation — the raw log never enters it.

  3 ⚙ bash: ./build.sh
    distilled 9,988 → 2,153 tokens (separate context)
  ✓ 80 lines

The digest is structured and deliberately blunt:

STATUS: failed — 14 of 217 tests failed, 400 packages built
FAILURES: src/api/handlers.ts:142:11 - TS2345: Argument of type 'string' is not assignable…
          src/api/handlers.ts:207:3 - TS2554: Expected 2 arguments, but got 1.
NOTES:

Before the model sees anything, error and warning lines are extracted from the raw text by regex, and those go into the digest verbatim and marked authoritative. A summarizer reading ten thousand tokens of build chatter can miss three error lines at the end — mine did, and confidently reported FAILURES: none. Grep is not clever, but it does not overlook things.

The last 2,000 characters of raw output are appended untouched as well, since the tail is usually the failure itself.

Output longer than 30,000 characters is trimmed from the middle, never the end — build tools put their errors last.

Setting Default
KRONK_DISTILL true false keeps every result whole
KRONK_DISTILL_AT 8000 Result size in characters that triggers it

What if a command outputs more than the context window?

It cannot reach your conversation. Output passes three bounds before it becomes a message:

  command prints 4.2 MB
        │
        ▼  capture cap — keep the last 400 KB          (MAX_CAPTURE)
      400 KB
        │
        ▼  trim the middle, keep head + tail            (MAX_OUT, 30 KB ≈ 7.5k tokens)
       30 KB
        │
        ▼  distil in a throwaway context                (KRONK_DISTILL_AT)
      ~750 tokens  ──▶ your conversation

Every stage keeps the end of the output, because that is where build tools report failures, and error lines are pulled out by regex before any of it is summarized. So the worst case for a single command is roughly 7.5k tokens with distillation off, or under 1k with it on — never more, however much the command prints.

The live meter shows you which stage you are in: the projection climbs, then reads capped once the command has outrun what will be kept.

It applies to any tool result, not just bash — a large search or MCP response is treated the same way.

The distiller runs on the same model, so it costs a little time and output tokens. What it buys is that those tokens are spent once in a throwaway context, instead of sitting in your window and being re-sent on every turn for the rest of the session.


Sub-agents

The model can hand one job to a sub-agent: a fresh agent with its own context window, its own tool loop, and no memory of your conversation. It runs the task, and the only thing that ever comes back is its report.

While it runs, it draws in a box below the prompt that redraws in place:

› which tools in this repo need approval, and where is that decided?

  1 ⚙ task explore: list every tool definition in src/, say which are gated and where
  ┌─ explore ──────────────────────────────────────── 6/40 · 48s ─┐
  │ 4/40 ⚙ read src/tools.js                                      │
  │ ✓ 587 lines                                                   │
  │ 5/40 ⚙ search /NEEDS_APPROVAL/ in src                         │
  │ ✓ 3 lines                                                     │
  │ 6/40 ⚙ read src/subagent.js                                   │
  └─ esc to collapse · ctrl-c to stop ────────────────────────────┘

When it finishes the box is erased, and what is left in the scrollback is the answer and one line saying what the run cost:

  1 ⚙ task explore: list every tool definition in src/, say which are gated and where
   Six built-in tools. write_file and bash are gated, by NEEDS_APPROVAL at
   src/tools.js:261. MCP tools are gated by name, mcpNeedsApproval at :270.
   read_file, list_dir, search, set_plan and task are not gated.
   └ 3 steps · 11.4s · /tasks 1 for the transcript
  ✓ 3 lines

  `write_file` and `bash`, from the `NEEDS_APPROVAL` set in src/tools.js:246…

The box is the run's only appearance on screen, and that is deliberate. A nine-step survey printed into the conversation leaves forty lines of someone else's work between the question you asked and the answer you got; scroll back ten minutes later and the conversation is mostly sub-agent. While the run is going a live view is the only sign the thing is alive, and it is where you notice one thrashing — but once it has ended, almost none of it is worth keeping.

Nothing is lost by erasing it. Everything the box shows is written to the run's transcript as it happens, so the box is a view of a file that outlives it, and /tasks 1 below is how you read it back. Three keys and one fallback are worth knowing:

  • esc collapses the box immediately and the run carries on. For when you have seen enough and want your screen back.
  • ctrl-c stops the run, as it stops anything else.
  • An approval prompt wipes the box first. A code sub-agent's write_file and bash stop for the same question the main agent's do, and that question gets the terminal to itself.
  • On a pipe or a dumb terminal there is no box at all. kronk-cli "…" | grep, a CI job and an --auto run nobody is watching each get the plain nested lines instead, in order, with no cursor control in them.

Three files were read. None of them is in your conversation: the 800-odd lines they cost were spent in a context that no longer exists, and all that is left in your window is the four-line answer. That is the whole point, and it is the trade distillation makes for command output, one level up.

Both agents run against the same Kronk server, so nothing leaves the machine that was not already leaving it.

Reading a run back

The report on screen is dimmed and capped at fourteen lines, and the box it ran in is gone. That used to be the end of it: what the sub-agent read, what it ran, and any line fifteen of its report went out of scope with its context. Delegating is supposed to cost the conversation nothing — it was never supposed to cost you the ability to check.

So each run is written to a file as it goes, and /tasks reads them back.

› /tasks

  ●  1  explore 6 steps · 11.4s      list every tool definition in src/, say whi…
  ●  2  code    23 steps · 1m4s       reproduce the compaction failure in test/co…
  ●  3  explore 2 steps · stopped     trace where the sandbox root is decided

  /tasks <n> for one in full · /tmp/kronk-cli/mfa1k2-8817

Green finished, yellow stopped before it reported, blue still running.

/tasks 2 prints that run whole: the prompt as it was delegated, every tool call with its arguments, every result in full, and the untruncated report. Nothing is cut — the bounded report is what sent you looking, so a second ceiling would only move the wall.

It is printed, not re-injected. Looking at what a sub-agent did costs the window nothing, which is what keeps the trade above intact.

One thing it is not: a record of what the sub-agent's tools returned. A transcript is the sub-agent's own context, and distillation rewrites a large tool result before it enters that context — so a read_file that came back as 24k of source appears here as the summary the sub-agent actually worked from, marked as distilled. That is the honest rendering: the raw text was never in its conversation either, and the line says so.

Run 3 is the case this exists for. A run that was interrupted, that threw, or that ran out of steps never reaches a tidy end at which to write a transcript, so the file is rewritten after every step instead of once at the finish, and the record says how it ended rather than only that it did. The run that went wrong is the one you want to read.

Transcripts live in the system temp directory, one directory per session, 0700 with 0600 files — they hold your project's file contents and command output. A clean exit removes the session's own; startup sweeps anything older than a day, which is what catches the sessions a crash left behind. Fifty runs are kept per session, oldest dropped first, so a long autonomous run cannot fill the disk. KRONK_TASK_LOG=false writes nothing at all.

It is not parallelism

Kronk holds one resident copy of the model, so two sub-agents at once queue behind each other and finish no sooner than two in sequence. Delegation here buys context, not wall-clock: give away the work that is expensive to read and cheap to conclude.

What it costs, measured

The same survey of src/ — one table row per module, its responsibility and the gotcha its own comments call out, plus three facts quoted with file:line — run twice per model on this machine, once with --no-subagents and once told to orchestrate. main ctx peak is the largest prompt the main conversation ever sent, which is the number the feature exists to move:

model mode wall main ctx peak tokens generated answer
Qwen3.6-35B-A3B /AGENT plain 143s 30.8k 5.5k 23/23
Qwen3.6-35B-A3B /AGENT orchestrated, 5 sub-agents 352s 12.9k 16.2k 22/23
Ornith-1.5-35B /AGENT plain 154s 34.4k 5.8k 23/23
Ornith-1.5-35B /AGENT orchestrated, 5 sub-agents 357s 5.9k 16.5k 23/23

Delegation cut the main conversation's high-water mark by 58% and 83%, and cost roughly 2.4× the wall clock and 3× the generated tokens to do it. That is the trade in one line: you are buying window, and paying for it in time and tokens. It is worth it when the conversation has to keep going afterwards, and not worth it for a question you were going to ask once.

The one answer that got worse is worth reading too. Qwen's orchestrated run scored 22/23 because the sub-agent it asked for "the constant that caps a tool result" came back with a plausible wrong one from a different module, and the orchestrator had no way to know: it never read the file. The plain run made the same mistake, spent three rounds re-reading tools.js, and caught it. A sub-agent's report is evidence you cannot cross-examine — ask for file:line and verbatim quotes in the prompt, and check the ones that matter.

The two agents

Agent Tools For
explore read_file, list_dir, search Reading, searching, tracing how something works
code those plus write_file, bash Making a change, running a build, reproducing a failure

/agents prints this in the REPL, with the model and step cap in force.

Two roles rather than a directory of them, because every extra one is a line the model has to read and choose between on each call, and a local model asked to pick between eight near-synonyms picks badly. The split that changes what can actually happen is whether it may touch anything, so that is the split.

What it cannot do

  • Ask you anything. There is nobody in its loop. It is told to take the most reasonable reading of the task, act, and say what it assumed.
  • Delegate further. The task tool is only ever offered at the top level, which is the entire recursion guard.
  • Get a command past you. Its write_file and bash calls arrive at the same prompt the main agent's do, nested under the call that started them. --yes and --auto skip them exactly as they always did.
  • Touch the checklist. set_plan belongs to the task you asked for; a sub-agent has no access to it.
  • Reach MCP servers. Its tool list is deliberately the short one.

It also runs under a step cap — 40 by default, where the main agent's is unlimited — because nobody is watching it, and a report is worth less than the window a runaway loop would spend earning it.

Knobs

kronk-cli --no-subagents             # remove the task tool entirely
KRONK_SUBAGENT_MODEL=<id> kronk-cli  # run the grunt work on a different model
KRONK_SUBAGENT_STEPS=80 kronk-cli    # a longer leash per task
KRONK_TASK_LOG=false kronk-cli       # keep no transcripts of what sub-agents did

A sub-agent gets the same startup scan of your project the main agent got, so it starts knowing what the repo is instead of spending its first two steps finding out.

KRONK_SUBAGENT_MODEL needs a model that can actually drive a tool loop, and "smaller" is not the same thing as "cheaper" here. Measured on the survey task below with qwen2.5-coder-1.5b as the sub-agent model: every sub-agent returned prose without calling a single tool, the main agent noticed, read all eighteen modules itself, and finished with 47.1k of context against the 30.8k it uses when you never delegate at all. A sub-agent that cannot do the work costs you the delegation and the work.

A small model will not delegate unprompted as often as a frontier one does. Asking for it works — "use a sub-agent to survey the test suite first" — and is usually how you will drive this.

Picking a model per task

KRONK_SUBAGENT_MODEL is a session-wide setting — every delegated task ran on it, or on the main model, and changing your mind meant leaving the session. But which model suits a task is a property of the task, not of the session: a survey that reads thirty files and answers one question wants the small fast model; reproducing a build failure and fixing it wants the big one.

So the task tool takes an optional model, and the delegating model picks it from what Kronk is actually serving — every chat model it has, with a bare id dropped when its own /AGENT profile is served beside it, offered as a fixed list rather than free text, so it can never ask for a model that does not exist. Leaving it out is unchanged: KRONK_SUBAGENT_MODEL if one is set, otherwise the main model.

  1 ⚙ task explore: survey every call site of parseLimits · Qwen3.6-4B/AGENT
  ┌─ explore · Qwen3.6-4B/AGENT ──────────────────────────────── 3/40 · 9s ─┐

The model actually used is named on the opening line and in the box, because naming one that is not already resident is not free. Kronk holds one large model at a time, so a task that asks for a different one pays a real cold load — the same 10–30s spinner kronk-cli itself waits through at startup — and may evict the model the rest of the session is running on. /tasks records which model each run was on, so a delegation that took a minute longer than the others is explained by the list, not a mystery.

A task's own model gets its window and output cap, not the session's, read from that model's own limits the first time anything runs on it and kept for the rest of the session — the same argument Context window already makes for KRONK_SUBAGENT_MODEL: a 32k sub-agent must not compact against the main model's 131k window. A model named that Kronk turns out not to be serving any more — unloaded mid-session, say — is not a failed delegation: the task still runs, on the same fallback as no model at all, with a note saying so in its report.

Autonomous mode

--auto, or /auto in the REPL, swaps in a system prompt that tells the model to finish the whole task — write code, run it, read the failure, fix it — and never claim success it has not executed. Tools are auto-approved and the run continues as long as the task needs.

$ kronk-cli --auto "write a CSV stats script, add a node:test, run it, fix what breaks"

  1 ⚙ ls .
  2 ⚙ read data.csv
  3 ⚙ write stats.js
  4 ⚙ write test.js
  5 ⚙ bash: node --test
  ✓ 10 lines

  Test passes. stats.js auto-detects numeric columns and skips non-numeric values.

Ctrl-C stops it. Add --steps N for unattended runs where nobody is watching.

The task checklist. Long tickets used to end early: the model satisfied one criterion of a dozen, wrote a confident summary, and stopped. The autonomous prompt now tells it to call set_plan before its first edit, with one item per acceptance criterion, and to update the list as it goes. The harness holds that list and does two things with it:

  • Re-states it every round. The open items, verbatim, and how many of how many are done, are appended to the last tool result of the round that has just run — so the original request is never thousands of tokens behind. Appended, never moved: the conversation only ever grows at the end, which is what keeps Kronk's prompt cache alive across a long run. Earlier rounds keep the snapshot they were given, and each one says it is a point in time; the last is the current one. The list is held outside the message list as well, so compaction cannot summarise it away.
  • Declines the first premature "done". A reply with no tool calls, while items are still open, gets the open list handed back instead of ending the run. Twice at most: after that the turn ends and the unfinished items are printed in yellow. --steps still wins, and Ctrl-C still stops everything.
  1 ⚙ plan: 4 items
    · keep the push trigger on master
    ▸ add the concurrency guard
    · document the change in README.md
    · run the linter

None of this fires without a plan: if the model never calls set_plan, a run behaves exactly as it did before, and interactive (non---auto) conversation is never held to a checklist. The model writes the plan; the harness only holds it to it.

⚠️ --auto runs shell commands without asking. Use it where git checkout can save you.


How it works

  REPL ──▶ messages[] ──▶ POST /v1/chat/completions (stream:true)
   ▲                            │
   │                            ▼
   │                   SSE ──▶ text / reasoning / tool_calls
   │                            │
   │                            ▼
   └──── tool results ◀──── approval ──▶ execute

The loop repeats while the model requests tools, so one prompt can read files, run commands, and write code before answering. Kronk's incremental message cache means each turn re-uses the previous prompt prefix — watch cached climb in the usage line.

File
src/index.js argv, REPL, one-shot mode
src/agent.js the tool loop and system prompts
src/client.js SSE streaming and Kronk endpoints
src/tools.js tool definitions, sandbox, shell session
src/context.js startup scan: git, layout, AGENTS.md
src/compact.js summarizing the conversation when the window fills
src/plan.js the task checklist and the snapshot the model sees
src/subagent.js delegation: agent roles, their tools, the report that comes back
src/mcp.js MCP client: stdio + HTTP transports, tool routing
src/distill.js summarizing large tool output in a throwaway context
src/config.js precedence of flags, env, config file
src/ui.js colour, spinner, usage formatting
src/theme.js which palette to print in, and how the background is worked out
src/markdown.js rendering the model's markdown for the terminal, as it streams
src/prompt.js the line reader: opened on the first question, keeping what was typed before it

Troubleshooting

Symptom
Cannot reach Kronk kronk server start --detach
Kronk is running but has no models kronk model pull <id>
First response takes ~25 s Cold model load, and you started with --no-warm. Drop the flag, or keep the model warm with --pool-ttl 1h on the server
Long silence before text The model is reasoning. --no-think, or /thinking to watch it
(model produced no answer) Reasoning consumed the whole budget. Raise KRONK_MAX_TOKENS or use --no-think
kronk-cli: command not found after an nvm switch Re-run npm link, or see Using nvm?
Memory climbing Two profiles resident at once. kronk-cli --models to confirm
Meter turning red It self-compacts at 85%. /compact sooner, /clear to reset, or raise context-window in model_config.yaml
An MCP server shows 401 It needs OAuth; kronk-cli has no browser flow. Use a token-based server config instead
A build times out Raise KRONK_TOOL_TIMEOUT (seconds), or narrow the command's scope
A command's failure went unnoticed Should not happen — exit status is captured before the cwd marker runs. Check ✗ error: exit code N appeared
Context vanishing after one command Its output is large. Distillation is on by default — check KRONK_DISTILL_AT
Model picks the wrong tool Too many attached. Narrow with --mcp <names>
One message alone exceeds the window Compaction cannot help — nothing survives trimming a single oversized input. Split the file, or raise context-window
Model ignores your conventions Put them in AGENTS.md at the project root, and check the context line names it
Slow start in a huge repo --no-context skips the scan

Contributing

See CONTRIBUTING.md for setup and what gets merged quickly. Commits and PR titles follow Conventional Commits, and releases follow npm semver — a feature or fix PR leaves package.json alone, and a separate release commit bumps the version. Details are in CONTRIBUTING.md.


License

Apache-2.0 — see LICENSE and NOTICE.

About

Zero-dependency terminal coding agent for local LLMs served by Kronk. Streaming chat, tool calling with approval gating, MCP client, and automatic context compaction.

Resources

Contributing

Security policy

Stars

4 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages