kronk-cli 0.7.0 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +187 -5
- package/package.json +2 -1
- package/src/agent.js +7 -2
- package/src/argv.js +11 -1
- package/src/boot.js +35 -2
- package/src/config.js +21 -3
- package/src/index.js +87 -28
- package/src/mcp.js +4 -1
- package/src/pane.js +10 -2
- package/src/rescue.js +306 -0
- package/src/rescueStore.js +277 -0
- package/src/resolve.js +27 -0
- package/src/setup.js +16 -3
- package/src/subagent.js +148 -21
- package/src/tasklog.js +7 -1
- package/src/ui.js +20 -6
- package/src/update.js +291 -0
- package/src/version.js +35 -0
package/README.md
CHANGED
|
@@ -450,6 +450,88 @@ The profile it writes is the one documented under
|
|
|
450
450
|
[Tip: use an `/AGENT` profile](#tip-use-an-agent-profile) — `context-window`, `nseq-max`,
|
|
451
451
|
`preserve_thinking` and `max_tokens`, and deliberately no sampling parameters.
|
|
452
452
|
|
|
453
|
+
### `kronk-cli rescue`
|
|
454
|
+
|
|
455
|
+
Everywhere else in this document, `kronk-cli` is something *you* talk to. `rescue` is the other
|
|
456
|
+
direction: a job queue so anything that can spawn a process — a script, another CLI, anything
|
|
457
|
+
external that wants to hand off a token-heavy job without sitting on it — can submit a
|
|
458
|
+
self-contained task to the local model, go do something else, and come back for the answer.
|
|
459
|
+
|
|
460
|
+
```console
|
|
461
|
+
$ kronk-cli rescue "trace how the SSE parser handles multi-line data fields" --role explore
|
|
462
|
+
9k2m1x-4d2-a1b2c3
|
|
463
|
+
|
|
464
|
+
$ kronk-cli rescue status 9k2m1x-4d2-a1b2c3
|
|
465
|
+
running (step 4)
|
|
466
|
+
|
|
467
|
+
$ kronk-cli rescue wait 9k2m1x-4d2-a1b2c3
|
|
468
|
+
Multi-line `data:` fields are joined with `\n` in src/sse.js:41-53 before the
|
|
469
|
+
frame is handed to the caller — see accumulateToolCalls's buffer.
|
|
470
|
+
$ echo $?
|
|
471
|
+
0
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
It is the same delegation `kronk-cli` already does internally — one self-contained task, its own
|
|
475
|
+
context, a report that stands alone — with the caller moved outside the process instead of inside
|
|
476
|
+
it. See [Sub-agents](#sub-agents) for the contract a rescue job runs under; it is the identical
|
|
477
|
+
one.
|
|
478
|
+
|
|
479
|
+
| Command | |
|
|
480
|
+
|---|---|
|
|
481
|
+
| `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 |
|
|
482
|
+
| `kronk-cli rescue status <id> [--json]` | A snapshot: `queued`, `running (step n)`, `done`, `stopped`, `interrupted` or `failed` |
|
|
483
|
+
| `kronk-cli rescue wait <id> [--json]` | Block until the job reaches one of those last four states, then print its report |
|
|
484
|
+
| `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 |
|
|
485
|
+
|
|
486
|
+
**`--role`** is the same split `kronk-cli` gives its own sub-agents: `explore` can only read —
|
|
487
|
+
`read_file`, `list_dir`, `search` — and `code` can also write files and run commands. Default is
|
|
488
|
+
`explore`, the read-only one. **`--model`** is resolved exactly as `-m` resolves it everywhere
|
|
489
|
+
else in this program — exact id, else the closest substring, preferring a served `/AGENT`
|
|
490
|
+
profile — against what Kronk is serving *right now*; a name that matches nothing is refused at
|
|
491
|
+
submit time rather than silently falling back, because a caller that asked for a specific model
|
|
492
|
+
should hear about a bad name immediately, on the process it is still attached to, not from a job
|
|
493
|
+
that failed twenty minutes later. Leaving `--model` out defers the choice to the job itself, the
|
|
494
|
+
same default the session would otherwise use.
|
|
495
|
+
|
|
496
|
+
**Without `-y`/`-a`, a tool that needs approval is refused and reported, never auto-allowed.**
|
|
497
|
+
Nobody is at the prompt a rescue job would otherwise show, so there is no "ask anyway" — a refused
|
|
498
|
+
tool call comes back in the transcript exactly like a human said no, and the job carries on (or
|
|
499
|
+
does not) from there. This is deliberate and does not have a flag to turn it off: delegating a
|
|
500
|
+
job from outside the process must not become a way past the approval prompts that delegating from
|
|
501
|
+
inside it already cannot get past.
|
|
502
|
+
|
|
503
|
+
**One at a time.** Kronk holds one resident copy of the model, so a second `rescue` submitted
|
|
504
|
+
while one is running does not run alongside it — its `status` reports `queued` until the first
|
|
505
|
+
job finishes, exactly as the module comment on delegation in `src/subagent.js` already argues for
|
|
506
|
+
sub-agents. A runner that dies mid-job (killed, the machine restarting) does not wedge the queue
|
|
507
|
+
behind it: the next job to check finds the lock's process gone, marks the abandoned job
|
|
508
|
+
`interrupted`, and takes its turn.
|
|
509
|
+
|
|
510
|
+
**`--json`** on any of the four commands prints one object instead of prose:
|
|
511
|
+
|
|
512
|
+
```json
|
|
513
|
+
{ "id": "9k2m1x-4d2-a1b2c3", "state": "done", "role": "explore",
|
|
514
|
+
"model": "unsloth/Qwen3.6-35B-A3B-UD-Q4_K_M/AGENT", "steps": 3, "ms": 11421,
|
|
515
|
+
"report": "Multi-line `data:` fields are joined with…", "transcript": [ /* the full run */ ] }
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
`wait`'s exit code is the one place a script gets the state without parsing anything:
|
|
519
|
+
|
|
520
|
+
| State | Meaning | `wait` exit code |
|
|
521
|
+
|---|---|---|
|
|
522
|
+
| `done` | Finished on its own terms | `0` |
|
|
523
|
+
| `stopped` | Hit its step cap before finishing | `1` |
|
|
524
|
+
| `interrupted` | Killed, or recovered from a runner that died mid-job | `2` |
|
|
525
|
+
| `failed` | The model call itself errored | `3` |
|
|
526
|
+
|
|
527
|
+
**Records outlive the process that submitted them, on purpose.** They are not sub-agent
|
|
528
|
+
transcripts (`src/tasklog.js`) and do not live in the same place or on the same clock — that
|
|
529
|
+
directory is swept on this session's own schedule specifically because nothing outside the
|
|
530
|
+
process is meant to still want it once the session ends, and a rescue job's whole point is that
|
|
531
|
+
something outside the process still does. A rescue record is kept indefinitely until `wait` or
|
|
532
|
+
`result` actually delivers it, and only then aged out, 24 hours later — `KRONK_RESCUE_DIR`
|
|
533
|
+
overrides where they live, same idea as `KRONK_TASK_LOG_DIR`.
|
|
534
|
+
|
|
453
535
|
---
|
|
454
536
|
|
|
455
537
|
## Startup: the model is loaded before you type
|
|
@@ -530,6 +612,70 @@ run (`kronk-cli "prompt"`) prints no banner and prints nothing here either.
|
|
|
530
612
|
|
|
531
613
|
---
|
|
532
614
|
|
|
615
|
+
## Checking for updates
|
|
616
|
+
|
|
617
|
+
`kronk-cli --version` prints what is actually running:
|
|
618
|
+
|
|
619
|
+
```console
|
|
620
|
+
$ kronk-cli --version
|
|
621
|
+
kronk-cli 0.7.0
|
|
622
|
+
kronk 1.32.7
|
|
623
|
+
```
|
|
624
|
+
|
|
625
|
+
The second line is `kronk --version`, read through the same `runKronk` helper `setup` uses,
|
|
626
|
+
capped at a few seconds so a stuck binary cannot hang the command. It is omitted, not guessed
|
|
627
|
+
at, when `kronk` is not on `PATH` or does not answer in time — this has to work with **no server
|
|
628
|
+
running**, which is the ordinary case for asking a program what it is.
|
|
629
|
+
|
|
630
|
+
The startup banner adds a third line when a newer `kronk-cli` exists:
|
|
631
|
+
|
|
632
|
+
```console
|
|
633
|
+
██ kronk-cli · local agent, no network
|
|
634
|
+
model unsloth/Qwen3.6-35B-A3B-UD-Q4_K_M/AGENT
|
|
635
|
+
server http://localhost:11435/v1
|
|
636
|
+
update kronk-cli 0.7.0 → 0.8.1 · run: brew upgrade kronk-cli
|
|
637
|
+
/help for commands · Ctrl-C to interrupt · /exit to quit
|
|
638
|
+
```
|
|
639
|
+
|
|
640
|
+
**It asks the package manager, never the network directly.** No `fetch`, no URL literal — the
|
|
641
|
+
version comes from whatever tool installed this copy, detected from the path `src/index.js`
|
|
642
|
+
itself resolved to:
|
|
643
|
+
|
|
644
|
+
| Route | How it's detected | What answers |
|
|
645
|
+
|---|---|---|
|
|
646
|
+
| Homebrew | a `Cellar/kronk-cli/` path | `brew outdated --json` |
|
|
647
|
+
| npm global install | a `node_modules/kronk-cli` path | `npm view kronk-cli version` |
|
|
648
|
+
| git checkout | a `.git` above the file | `git fetch --tags` + the newest tag |
|
|
649
|
+
| `packaging/install.sh` | a `lib/kronk-cli` path | nothing — see below |
|
|
650
|
+
|
|
651
|
+
The hint always matches the route: a Homebrew install is told `brew upgrade kronk-cli`, an npm
|
|
652
|
+
one `npm install -g kronk-cli@latest`, a checkout `git pull`. Suggesting the wrong one would send
|
|
653
|
+
someone to a manager that has never heard of this copy, which is worse than saying nothing.
|
|
654
|
+
|
|
655
|
+
The git checkout route is the one that makes a real network call, through `git fetch --tags`
|
|
656
|
+
rather than any request this program issues itself, and it honours the same off-switch as
|
|
657
|
+
everything else here.
|
|
658
|
+
|
|
659
|
+
**The install script has nothing to ask.** It unpacks a tarball into `$PREFIX/lib/kronk-cli` and,
|
|
660
|
+
unlike the other three routes, there is no registry behind it that knows the latest release. It
|
|
661
|
+
writes a small receipt (`$PREFIX/lib/kronk-cli/.install-receipt.json`, version and source) at
|
|
662
|
+
install time so this route is not silent forever; when that receipt is present the banner shows
|
|
663
|
+
a plain nudge — `update kronk-cli 0.7.0 · re-run the install script` — with no version
|
|
664
|
+
comparison, because there is genuinely nothing to compare against. A copy installed before the
|
|
665
|
+
receipt existed, or one someone assembled by hand, shows no update line at all, which is the
|
|
666
|
+
honest answer for a route with no manager behind it.
|
|
667
|
+
|
|
668
|
+
**Never on the path that slows startup down.** The check result is cached to disk with a
|
|
669
|
+
timestamp and reused for up to a day; a stale or missing cache still renders instantly (from
|
|
670
|
+
whatever was cached last, or nothing) while a fresh check for the *next* session runs
|
|
671
|
+
unawaited in the background. Nothing about this can delay, block, or fail the REPL — a package
|
|
672
|
+
manager that hangs, errors, or prints garbage simply produces no line.
|
|
673
|
+
|
|
674
|
+
`KRONK_UPDATE_CHECK=false` (or `"updateCheck": false` in the config file) turns all of this off:
|
|
675
|
+
no cache read, no subprocess, ever.
|
|
676
|
+
|
|
677
|
+
---
|
|
678
|
+
|
|
533
679
|
## Performance
|
|
534
680
|
|
|
535
681
|
The transcripts above are real sessions, kept to show what the UI looks like — not a benchmark
|
|
@@ -598,6 +744,7 @@ same way this run did.
|
|
|
598
744
|
| `--no-subagents` | off | Remove the `task` tool, so the model cannot delegate — see [Sub-agents](#sub-agents) |
|
|
599
745
|
| `--steps <n>` | unlimited | Cap tool calls per task. `0`, `off`, `none`, `inf`, `unlimited` all mean no cap |
|
|
600
746
|
| `-h`, `--help` | — | Print all options and exit |
|
|
747
|
+
| `-v`, `--version` | — | Print the kronk-cli version, and Kronk's if it can be had without a network call, then exit |
|
|
601
748
|
| `--` | — | End option parsing; everything after it is prompt text, dashes and all |
|
|
602
749
|
|
|
603
750
|
Anything not consumed as an option becomes the prompt, except a token that looks like an
|
|
@@ -680,7 +827,7 @@ Two consequences worth knowing:
|
|
|
680
827
|
| `/steps [n\|off]` | show or set the tool-call cap |
|
|
681
828
|
| `/thinking` | show or hide the model's reasoning |
|
|
682
829
|
| `/think` | turn reasoning off entirely — much faster |
|
|
683
|
-
| `/agents` | the sub-agents `task` can delegate to,
|
|
830
|
+
| `/agents` | the sub-agents `task` can delegate to, what each may touch, and the default model |
|
|
684
831
|
| `/tasks [n]` | sub-agent runs this session; with `n`, that one in full |
|
|
685
832
|
| `/mcp` | list attached MCP servers and their tools |
|
|
686
833
|
| `/context` | how much of the context window is used |
|
|
@@ -713,14 +860,16 @@ Two consequences worth knowing:
|
|
|
713
860
|
| `KRONK_DISTILL` | `true` | `false` disables tool-output distillation |
|
|
714
861
|
| `KRONK_DISTILL_AT` | `8000` | Characters of output that trigger distillation |
|
|
715
862
|
| `KRONK_SUBAGENTS` | `true` | `false` removes the `task` tool |
|
|
716
|
-
| `KRONK_SUBAGENT_MODEL` | the main model |
|
|
863
|
+
| `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](#picking-a-model-per-task) |
|
|
717
864
|
| `KRONK_SUBAGENT_STEPS` | `40` | Tool-call cap for one delegated task |
|
|
718
865
|
| `KRONK_TASK_LOG` | `true` | `false` stops writing sub-agent transcripts, and `/tasks` says so |
|
|
719
866
|
| `KRONK_TASK_LOG_DIR` | the temp dir | Where transcripts are written |
|
|
867
|
+
| `KRONK_RESCUE_DIR` | the temp dir | Where `kronk-cli rescue` keeps its job records — see [`kronk-cli rescue`](#kronk-cli-rescue) |
|
|
720
868
|
| `KRONK_WARM` | `true` | `false` skips the boot-time model preload |
|
|
721
869
|
| `KRONK_AUTO_COMPACT` | `true` | `false` disables automatic compaction |
|
|
722
870
|
| `KRONK_COMPACT_AT` | `0.85` | Fraction of the window that triggers compaction |
|
|
723
871
|
| `KRONK_THEME` | `auto` | `dark` or `light` pins the palette; `auto` reads the terminal's background |
|
|
872
|
+
| `KRONK_UPDATE_CHECK` | `true` | `false` never asks the package manager whether a newer kronk-cli exists — no subprocess, ever |
|
|
724
873
|
| `NO_COLOR` | — | Any value disables colour |
|
|
725
874
|
| `FORCE_COLOR` | — | `0` disables colour; any other value keeps it on through a pipe |
|
|
726
875
|
|
|
@@ -908,9 +1057,10 @@ Kronk reports the **effective** window for whichever model id you selected — t
|
|
|
908
1057
|
Every limit is read from the selected model's profile, and re-read when the selection changes:
|
|
909
1058
|
the window, the output cap (`sampling-parameters.max_tokens`), whether the chat template
|
|
910
1059
|
understands `preserve_thinking`, and the model's native maximum. `/model` mid-session moves all
|
|
911
|
-
of them, and a sub-agent running on `KRONK_SUBAGENT_MODEL`
|
|
912
|
-
|
|
913
|
-
|
|
1060
|
+
of them, and a sub-agent — whether it is running on `KRONK_SUBAGENT_MODEL` or on a model a task
|
|
1061
|
+
named for itself, see [Picking a model per task](#picking-a-model-per-task) — compacts against
|
|
1062
|
+
*its own* window rather than the main model's. The one number that is not taken from the profile
|
|
1063
|
+
is a `KRONK_MAX_TOKENS` you set yourself — yours always wins.
|
|
914
1064
|
|
|
915
1065
|
Three places surface it:
|
|
916
1066
|
|
|
@@ -1425,6 +1575,38 @@ the delegation *and* the work.
|
|
|
1425
1575
|
> works — *"use a sub-agent to survey the test suite first"* — and is usually how you will
|
|
1426
1576
|
> drive this.
|
|
1427
1577
|
|
|
1578
|
+
### Picking a model per task
|
|
1579
|
+
|
|
1580
|
+
`KRONK_SUBAGENT_MODEL` is a session-wide setting — every delegated task ran on it, or on the main
|
|
1581
|
+
model, and changing your mind meant leaving the session. But which model suits a task is a
|
|
1582
|
+
property of the *task*, not of the session: a survey that reads thirty files and answers one
|
|
1583
|
+
question wants the small fast model; reproducing a build failure and fixing it wants the big one.
|
|
1584
|
+
|
|
1585
|
+
So the `task` tool takes an optional `model`, and the delegating model picks it from what Kronk is
|
|
1586
|
+
actually serving — every chat model it has, with a bare id dropped when its own `/AGENT` profile
|
|
1587
|
+
is served beside it, offered as a fixed list rather than free
|
|
1588
|
+
text, so it can never ask for a model that does not exist. Leaving it out is unchanged:
|
|
1589
|
+
`KRONK_SUBAGENT_MODEL` if one is set, otherwise the main model.
|
|
1590
|
+
|
|
1591
|
+
```console
|
|
1592
|
+
1 ⚙ task explore: survey every call site of parseLimits · Qwen3.6-4B/AGENT
|
|
1593
|
+
┌─ explore · Qwen3.6-4B/AGENT ──────────────────────────────── 3/40 · 9s ─┐
|
|
1594
|
+
```
|
|
1595
|
+
|
|
1596
|
+
The model actually used is named on the opening line and in the box, because naming one that is
|
|
1597
|
+
not already resident is not free. Kronk holds one large model at a time, so a task that asks for
|
|
1598
|
+
a different one pays a real cold load — the same 10–30s spinner `kronk-cli` itself waits through
|
|
1599
|
+
at startup — and may evict the model the rest of the session is running on. `/tasks` records which
|
|
1600
|
+
model each run was on, so a delegation that took a minute longer than the others is explained by
|
|
1601
|
+
the list, not a mystery.
|
|
1602
|
+
|
|
1603
|
+
A task's own model gets *its* window and output cap, not the session's, read from that model's own
|
|
1604
|
+
limits the first time anything runs on it and kept for the rest of the session — the same
|
|
1605
|
+
argument [Context window](#context-window) already makes for `KRONK_SUBAGENT_MODEL`: a 32k
|
|
1606
|
+
sub-agent must not compact against the main model's 131k window. A model named that Kronk turns
|
|
1607
|
+
out not to be serving any more — unloaded mid-session, say — is not a failed delegation: the task
|
|
1608
|
+
still runs, on the same fallback as no `model` at all, with a note saying so in its report.
|
|
1609
|
+
|
|
1428
1610
|
## Autonomous mode
|
|
1429
1611
|
|
|
1430
1612
|
`--auto`, or `/auto` in the REPL, swaps in a system prompt that tells the model to finish the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kronk-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.1",
|
|
4
4
|
"description": "A Claude-Code-style terminal agent for local models served by Kronk.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
"scripts": {
|
|
30
30
|
"start": "node src/index.js",
|
|
31
31
|
"test": "node --test test/*.test.js",
|
|
32
|
+
"smoke": "node --test test/smoke/*.smoke.js",
|
|
32
33
|
"changelog": "node scripts/changelog.mjs",
|
|
33
34
|
"lint": "eslint .",
|
|
34
35
|
"check": "npm run lint && npm test"
|
package/src/agent.js
CHANGED
|
@@ -12,7 +12,7 @@ import { maybeDistill } from './distill.js';
|
|
|
12
12
|
import {
|
|
13
13
|
carryChecklist, clearPlan, openItems, outstandingLines, planLines, pushNudge,
|
|
14
14
|
} from './plan.js';
|
|
15
|
-
import { runTask, taskTools
|
|
15
|
+
import { runTask, taskTools } from './subagent.js';
|
|
16
16
|
|
|
17
17
|
export const SYSTEM = `You are kronk-cli, a terse coding assistant running fully offline on the user's machine.
|
|
18
18
|
|
|
@@ -285,7 +285,12 @@ export async function runTurn({
|
|
|
285
285
|
// The same list the model was offered, not just the name it used: with
|
|
286
286
|
// delegation off, or below the top level, a `task` call is a call to a
|
|
287
287
|
// tool that does not exist and falls through to runTool saying so.
|
|
288
|
-
|
|
288
|
+
//
|
|
289
|
+
// By name, not by identity — src/subagent.js rebuilds the task tool's
|
|
290
|
+
// definition object whenever the served-model list changes, so an
|
|
291
|
+
// object captured in `tools` at the top of this turn need not be the
|
|
292
|
+
// same reference `taskTools` would hand back if asked again now.
|
|
293
|
+
const isTask = !isMcp && call.name === 'task' && tools.some((t) => t.function.name === 'task');
|
|
289
294
|
const label = isMcp
|
|
290
295
|
? `${c.magenta('⚙')} ${call.name} ${c.grey(JSON.stringify(args).slice(0, 120))}`
|
|
291
296
|
: `${c.blue(`⚙ ${describe(call.name, args)}`)}`;
|
package/src/argv.js
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
const SPECS = [
|
|
20
20
|
{ names: ['-h', '--help'], kind: 'flag', key: 'help' },
|
|
21
|
+
{ names: ['-v', '--version'], kind: 'flag', key: 'version' },
|
|
21
22
|
{ names: ['-l', '--models', '--list'], kind: 'flag', key: 'models' },
|
|
22
23
|
{ names: ['--mcp-list'], kind: 'flag', key: 'mcpList' },
|
|
23
24
|
{ names: ['--no-context'], kind: 'flag', key: 'noContext' },
|
|
@@ -32,6 +33,12 @@ const SPECS = [
|
|
|
32
33
|
{ names: ['--steps'], kind: 'value', key: 'steps' },
|
|
33
34
|
{ names: ['--context'], kind: 'value', key: 'context' },
|
|
34
35
|
{ names: ['--mcp'], kind: 'optional', key: 'mcp' },
|
|
36
|
+
{ names: ['--role'], kind: 'value', key: 'role' },
|
|
37
|
+
{ names: ['--json'], kind: 'flag', key: 'json' },
|
|
38
|
+
// Internal: how `kronk-cli rescue` re-invokes itself as the detached
|
|
39
|
+
// worker for one job id (src/rescue.js). Not documented in --help — it is
|
|
40
|
+
// not a thing anyone types, only a thing the CLI passes to itself.
|
|
41
|
+
{ names: ['--rescue-worker'], kind: 'value', key: 'rescueWorker' },
|
|
35
42
|
];
|
|
36
43
|
|
|
37
44
|
const BY_NAME = new Map(SPECS.flatMap((s) => s.names.map((n) => [n, s])));
|
|
@@ -101,7 +108,7 @@ const unknown = (token) => {
|
|
|
101
108
|
export function parseArgv(argv) {
|
|
102
109
|
const out = {
|
|
103
110
|
error: null,
|
|
104
|
-
help: false, models: false, mcpList: false,
|
|
111
|
+
help: false, version: false, models: false, mcpList: false,
|
|
105
112
|
noContext: false, noCompact: false, noWarm: false, noThink: false,
|
|
106
113
|
noSubagents: false,
|
|
107
114
|
auto: false, yes: false, dryRun: false,
|
|
@@ -109,6 +116,9 @@ export function parseArgv(argv) {
|
|
|
109
116
|
context: null,
|
|
110
117
|
steps: null,
|
|
111
118
|
mcp: false, mcpNames: null,
|
|
119
|
+
role: null,
|
|
120
|
+
json: false,
|
|
121
|
+
rescueWorker: null,
|
|
112
122
|
words: [],
|
|
113
123
|
};
|
|
114
124
|
const fail = (message) => ({ ...out, error: message });
|
package/src/boot.js
CHANGED
|
@@ -2,10 +2,43 @@ import { config, DEFAULT_MODEL } from './config.js';
|
|
|
2
2
|
import { listLoaded, warm } from './client.js';
|
|
3
3
|
import { c, spinner } from './ui.js';
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
const PROFILE = '/AGENT';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Every model Kronk is serving that a delegated task may be offered: the chat
|
|
9
|
+
* models, minus the embedding and reranking ones, and minus a bare id whose
|
|
10
|
+
* own /AGENT profile is also being served — Kronk holds each profile as a
|
|
11
|
+
* separate resident copy of the same weights, so offering both would let a
|
|
12
|
+
* task load a second copy of a model that is already in the pool.
|
|
13
|
+
*
|
|
14
|
+
* Deliberately *not* `pickDefault`'s rule. That one collapses to "the /AGENT
|
|
15
|
+
* profiles, if there are any", which is right when choosing a single default
|
|
16
|
+
* — a tuned profile beats an untuned model — and wrong for building a menu,
|
|
17
|
+
* because it throws away every model that has no profile of its own. The
|
|
18
|
+
* small fast model a survey wants is exactly the one least likely to have had
|
|
19
|
+
* a profile written for it, so applying the default-picking rule here would
|
|
20
|
+
* drop the most useful entry in the list and leave a menu of nothing but
|
|
21
|
+
* large models — on a pool that holds one large model at a time, a menu where
|
|
22
|
+
* every choice costs an eviction.
|
|
23
|
+
*/
|
|
24
|
+
export function chatModels(ids) {
|
|
25
|
+
const chat = ids.filter((id) => !/embedding|rerank/i.test(id));
|
|
26
|
+
const profiled = new Set(chat
|
|
27
|
+
.filter((id) => id.endsWith(PROFILE))
|
|
28
|
+
.map((id) => id.slice(0, -PROFILE.length)));
|
|
29
|
+
return chat.filter((id) => !profiled.has(id));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Last resort when neither the flag nor DEFAULT_MODEL is being served.
|
|
34
|
+
*
|
|
35
|
+
* Its own filter, unchanged: one model is being chosen on the user's behalf
|
|
36
|
+
* with nothing to go on, and there a tuned /AGENT profile wins over an
|
|
37
|
+
* untuned model outright rather than merely being listed beside it.
|
|
38
|
+
*/
|
|
6
39
|
export function pickDefault(ids) {
|
|
7
40
|
const chat = ids.filter((id) => !/embedding|rerank/i.test(id));
|
|
8
|
-
const agent = chat.filter((id) => id.endsWith(
|
|
41
|
+
const agent = chat.filter((id) => id.endsWith(PROFILE));
|
|
9
42
|
const pool = agent.length ? agent : chat;
|
|
10
43
|
return pool.sort((a, b) => b.length - a.length)[0] ?? null;
|
|
11
44
|
}
|
package/src/config.js
CHANGED
|
@@ -77,6 +77,12 @@ export const config = {
|
|
|
77
77
|
// one is not: nobody is watching a sub-agent, and its report is worth less
|
|
78
78
|
// than the window it would spend earning it.
|
|
79
79
|
subagents: (process.env.KRONK_SUBAGENTS ?? String(file.subagents ?? 'true')) !== 'false',
|
|
80
|
+
// The default model for a task that names none. A task's own `model`
|
|
81
|
+
// argument, when it has one, wins over this — see the `task` tool in
|
|
82
|
+
// src/subagent.js. Either way, the chosen model's window and output cap
|
|
83
|
+
// are resolved per model, not stored here: src/subagent.js keeps its own
|
|
84
|
+
// cache, because which limits apply now depends on the task, not the
|
|
85
|
+
// session.
|
|
80
86
|
subagentModel: process.env.KRONK_SUBAGENT_MODEL ?? file.subagentModel ?? null,
|
|
81
87
|
subagentSteps: Number(process.env.KRONK_SUBAGENT_STEPS ?? file.subagentSteps ?? 40),
|
|
82
88
|
// Each sub-agent run is written to a file as it goes, so `/tasks` can show
|
|
@@ -86,9 +92,24 @@ export const config = {
|
|
|
86
92
|
taskLog: (process.env.KRONK_TASK_LOG ?? String(file.taskLog ?? 'true')) !== 'false',
|
|
87
93
|
// Same reason as modelConfigPath: so tests never go near the real one.
|
|
88
94
|
taskLogDir: process.env.KRONK_TASK_LOG_DIR ?? file.taskLogDir ?? null,
|
|
95
|
+
// Where `kronk-cli rescue` keeps its job records — src/rescueStore.js.
|
|
96
|
+
// Deliberately not taskLogDir: that directory's lifetime is owned by
|
|
97
|
+
// src/tasklog.js's sweep() and cleanup(), and a rescue record has to
|
|
98
|
+
// survive both, so it lives somewhere neither of them has ever heard of.
|
|
99
|
+
rescueDir: process.env.KRONK_RESCUE_DIR ?? file.rescueDir ?? null,
|
|
89
100
|
// Kronk's per-model runtime settings. `setup` is the only thing that writes
|
|
90
101
|
// it; the override exists so tests never go near the real one.
|
|
91
102
|
modelConfigPath: process.env.KRONK_MODEL_CONFIG ?? file.modelConfigPath ?? MODEL_CONFIG,
|
|
103
|
+
// The startup banner's "a newer kronk-cli exists" line asks the package
|
|
104
|
+
// manager that installed this copy, never the network directly — see
|
|
105
|
+
// src/update.js. Off entirely for anyone who wants a program that runs no
|
|
106
|
+
// subprocess it was not asked to run.
|
|
107
|
+
updateCheck: (process.env.KRONK_UPDATE_CHECK ?? String(file.updateCheck ?? 'true')) !== 'false',
|
|
108
|
+
// Same reason as modelConfigPath and taskLogDir: so tests never touch the
|
|
109
|
+
// real one, and a cold machine's first run does not have to guess where to
|
|
110
|
+
// put it.
|
|
111
|
+
updateCacheFile: process.env.KRONK_UPDATE_CACHE ?? file.updateCacheFile
|
|
112
|
+
?? join(homedir(), '.kronk-cli-update-cache.json'),
|
|
92
113
|
// Paths a previous session granted read-only to the sandbox, via `always` at
|
|
93
114
|
// the credential prompt. Read-only is the whole point: KRONK_SANDBOX_ALLOW
|
|
94
115
|
// grants writes as well, and these are credential stores.
|
|
@@ -101,9 +122,6 @@ export const config = {
|
|
|
101
122
|
projectPrimer: null,
|
|
102
123
|
contextWindow: null, // filled in at boot from the chosen model's profile
|
|
103
124
|
nativeContext: null,
|
|
104
|
-
// The sub-agent model's own limits, when one is configured. A sub-agent on
|
|
105
|
-
// a 32k model must not compact against the main model's 131k window.
|
|
106
|
-
subagentLimits: null,
|
|
107
125
|
templatePreservesThinking: false, // filled in at boot from the model's template
|
|
108
126
|
samplingOverride: null, // filled in at boot: params where the profile overrides the model's own
|
|
109
127
|
rcPath: RC,
|
package/src/index.js
CHANGED
|
@@ -4,7 +4,8 @@ import { homedir } from 'node:os';
|
|
|
4
4
|
import { readFile } from 'node:fs/promises';
|
|
5
5
|
import { config, DEFAULT_MODEL, applyLimits, warnIfInsecure } from './config.js';
|
|
6
6
|
import { listModels, listModelDetails, listLoaded, modelLimits, tokenize } from './client.js';
|
|
7
|
-
import { pickDefault, ensureLoaded } from './boot.js';
|
|
7
|
+
import { pickDefault, chatModels, ensureLoaded } from './boot.js';
|
|
8
|
+
import { resolveModel } from './resolve.js';
|
|
8
9
|
import { runTurn, SYSTEM, SYSTEM_AUTO } from './agent.js';
|
|
9
10
|
import { c, banner, fmtContext, statusLine } from './ui.js';
|
|
10
11
|
import { resolveTheme, theme, useTheme } from './theme.js';
|
|
@@ -15,9 +16,12 @@ import { loadServers, McpHub, reportFailures } from './mcp.js';
|
|
|
15
16
|
import { resolveSandbox, sandbox } from './tools.js';
|
|
16
17
|
import { parseArgv } from './argv.js';
|
|
17
18
|
import { deferredPrompt } from './prompt.js';
|
|
18
|
-
import { AGENTS } from './subagent.js';
|
|
19
|
+
import { AGENTS, setServedModels } from './subagent.js';
|
|
19
20
|
import { runLines, transcriptLines, sweep, cleanup } from './tasklog.js';
|
|
20
|
-
import { runSetup } from './setup.js';
|
|
21
|
+
import { runSetup, runKronk } from './setup.js';
|
|
22
|
+
import { runRescue } from './rescue.js';
|
|
23
|
+
import { VERSION, parseKronkVersion } from './version.js';
|
|
24
|
+
import { updateStatus } from './update.js';
|
|
21
25
|
|
|
22
26
|
// ---- argv -------------------------------------------------------------
|
|
23
27
|
const args = parseArgv(process.argv.slice(2));
|
|
@@ -41,6 +45,22 @@ if (args.help) {
|
|
|
41
45
|
pull the model, write its /AGENT profile to
|
|
42
46
|
~/.kronk/models/model_config.yaml, restart Kronk
|
|
43
47
|
|
|
48
|
+
kronk-cli rescue "<prompt>" [--role explore|code] [--model <id>] [-y|-a] [--steps <n>] [--json]
|
|
49
|
+
submit a self-contained task; runs detached,
|
|
50
|
+
prints a job id and exits immediately
|
|
51
|
+
kronk-cli rescue status <id> [--json]
|
|
52
|
+
queued | running (step n) | done | stopped
|
|
53
|
+
| interrupted | failed
|
|
54
|
+
kronk-cli rescue wait <id> [--json]
|
|
55
|
+
block until the job lands, then print its
|
|
56
|
+
report; exit code is done 0, stopped 1,
|
|
57
|
+
interrupted 2, failed 3
|
|
58
|
+
kronk-cli rescue result <id> [--json]
|
|
59
|
+
print the report; refuses (exit 4) if the
|
|
60
|
+
job has not reached one of those four states yet
|
|
61
|
+
without -y/-a, a rescue job refuses any tool
|
|
62
|
+
that needs approval rather than asking nobody
|
|
63
|
+
|
|
44
64
|
OPTIONS
|
|
45
65
|
-l, --models list the models Kronk is serving, then exit
|
|
46
66
|
--no-context skip the startup scan of the working directory
|
|
@@ -56,6 +76,8 @@ if (args.help) {
|
|
|
56
76
|
--no-subagents remove the task tool; no delegation
|
|
57
77
|
--steps <n> cap tool calls per task (default: unlimited)
|
|
58
78
|
-h, --help this message
|
|
79
|
+
-v, --version print the kronk-cli version, and Kronk's if it can be
|
|
80
|
+
had without a network call, then exit
|
|
59
81
|
-- end option parsing; everything after is the prompt
|
|
60
82
|
|
|
61
83
|
ENVIRONMENT
|
|
@@ -77,15 +99,37 @@ if (args.help) {
|
|
|
77
99
|
KRONK_COMPACT_AT fraction of the window that triggers it (default 0.85)
|
|
78
100
|
KRONK_SUBAGENTS false to remove the task tool
|
|
79
101
|
KRONK_SUBAGENT_MODEL
|
|
80
|
-
model
|
|
102
|
+
default model for a task that names none itself
|
|
103
|
+
(default: the main model) — a task can pick its own
|
|
104
|
+
from what Kronk is serving, see /agents
|
|
81
105
|
KRONK_SUBAGENT_STEPS
|
|
82
106
|
tool-call cap for one delegated task (default 40)
|
|
107
|
+
KRONK_UPDATE_CHECK false to never ask the package manager whether a
|
|
108
|
+
newer kronk-cli exists (default true)
|
|
83
109
|
|
|
84
110
|
Config file: ~/.kronk-cli.json
|
|
85
111
|
`);
|
|
86
112
|
process.exit(0);
|
|
87
113
|
}
|
|
88
114
|
|
|
115
|
+
if (args.version) {
|
|
116
|
+
// package.json's version, not a network call, not the server — this must
|
|
117
|
+
// print with no Kronk running, which is the common case for `--version`.
|
|
118
|
+
console.log(`kronk-cli ${VERSION}`);
|
|
119
|
+
// A cheap, short-timeout, local-only ask: no server round trip, and never
|
|
120
|
+
// held open past the timeout if `kronk` itself hangs. Kronk's own API
|
|
121
|
+
// exposes no version field anywhere client.js already reads (checked
|
|
122
|
+
// before writing this), so the binary on disk is the only source there is.
|
|
123
|
+
try {
|
|
124
|
+
const { code, output } = await runKronk(['--version'], { timeoutMs: 3000 });
|
|
125
|
+
const v = code === 0 ? parseKronkVersion(output) : null;
|
|
126
|
+
if (v) console.log(`kronk ${v}`);
|
|
127
|
+
} catch {
|
|
128
|
+
// No `kronk` on PATH, or it did not behave — say nothing rather than guess.
|
|
129
|
+
}
|
|
130
|
+
process.exit(0);
|
|
131
|
+
}
|
|
132
|
+
|
|
89
133
|
// --auto: run the whole task unattended (implies --yes)
|
|
90
134
|
const AUTO = args.auto;
|
|
91
135
|
const AUTO_YES = args.yes || AUTO;
|
|
@@ -124,15 +168,12 @@ async function boot() {
|
|
|
124
168
|
// Fall back to the configured default before guessing.
|
|
125
169
|
if (!config.model && ids.includes(DEFAULT_MODEL)) config.model = DEFAULT_MODEL;
|
|
126
170
|
|
|
171
|
+
// Exact id wins. Otherwise take a substring match, preferring an /AGENT
|
|
172
|
+
// profile — each profile is a SEPARATE resident copy in the pool, so
|
|
173
|
+
// picking the wrong one silently loads a second 20 GB instance. One
|
|
174
|
+
// resolver (src/resolve.js) for this and for the identical problem below.
|
|
127
175
|
if (config.model) {
|
|
128
|
-
|
|
129
|
-
// Exact id wins. Otherwise take a substring match, preferring an /AGENT
|
|
130
|
-
// profile — each profile is a SEPARATE resident copy in the pool, so
|
|
131
|
-
// picking the wrong one silently loads a second 20 GB instance.
|
|
132
|
-
const subs = ids.filter((id) => id.includes(config.model));
|
|
133
|
-
const hit = ids.find((id) => id === config.model)
|
|
134
|
-
?? subs.find((id) => id.endsWith('/AGENT'))
|
|
135
|
-
?? subs[0];
|
|
176
|
+
const hit = resolveModel(config.model, ids);
|
|
136
177
|
if (hit) config.model = hit;
|
|
137
178
|
else {
|
|
138
179
|
console.error(c.yellow(` no model matching "${config.model}" — falling back`));
|
|
@@ -145,10 +186,7 @@ async function boot() {
|
|
|
145
186
|
// model: a substring in the config file that matches nothing would otherwise
|
|
146
187
|
// only surface as a 404 in the middle of somebody's first delegated task.
|
|
147
188
|
if (config.subagentModel) {
|
|
148
|
-
const
|
|
149
|
-
const hit = ids.find((id) => id === config.subagentModel)
|
|
150
|
-
?? subs.find((id) => id.endsWith('/AGENT'))
|
|
151
|
-
?? subs[0];
|
|
189
|
+
const hit = resolveModel(config.subagentModel, ids);
|
|
152
190
|
if (hit) config.subagentModel = hit;
|
|
153
191
|
else {
|
|
154
192
|
console.error(c.yellow(` no model matching "${config.subagentModel}" for sub-agents`
|
|
@@ -157,6 +195,14 @@ async function boot() {
|
|
|
157
195
|
}
|
|
158
196
|
}
|
|
159
197
|
|
|
198
|
+
// What a delegated task's own `model` argument may name — see the `task`
|
|
199
|
+
// tool in src/subagent.js. `chatModels` is a wider list than the one
|
|
200
|
+
// `pickDefault` above chose the session's model from, and deliberately so:
|
|
201
|
+
// picking one default prefers a tuned /AGENT profile outright, while
|
|
202
|
+
// offering a menu must keep the models that have no profile, because the
|
|
203
|
+
// small fast one a survey should be sent to is usually one of them.
|
|
204
|
+
setServedModels(chatModels(ids));
|
|
205
|
+
|
|
160
206
|
if (config.warm) await ensureLoaded(ids);
|
|
161
207
|
|
|
162
208
|
// After `ensureLoaded`, which may have fallen back to a different model
|
|
@@ -164,11 +210,10 @@ async function boot() {
|
|
|
164
210
|
// describe a model this session is not using.
|
|
165
211
|
applyLimits(await modelLimits(config.model));
|
|
166
212
|
|
|
167
|
-
// The sub-agent model's profile is
|
|
168
|
-
// on
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
: null;
|
|
213
|
+
// The sub-agent model's own profile is no longer resolved here: a task can
|
|
214
|
+
// run on any served model now, not only the one configured for delegation,
|
|
215
|
+
// so src/subagent.js resolves and caches each one's limits the first time a
|
|
216
|
+
// task actually runs on it — see `limitsFor` there.
|
|
172
217
|
return ids;
|
|
173
218
|
}
|
|
174
219
|
|
|
@@ -416,6 +461,13 @@ async function main() {
|
|
|
416
461
|
return;
|
|
417
462
|
}
|
|
418
463
|
|
|
464
|
+
// Same reasoning as `setup` just above: reserved before the one-shot path
|
|
465
|
+
// gets a chance to read the rest of the line as a prompt.
|
|
466
|
+
if (args.words[0] === 'rescue') {
|
|
467
|
+
process.exitCode = await runRescue(args);
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
|
|
419
471
|
if (SHOW_MODELS) { await showModels(); return; }
|
|
420
472
|
if (SHOW_MCP) { await showMcp(); process.exit(0); }
|
|
421
473
|
|
|
@@ -456,9 +508,14 @@ async function main() {
|
|
|
456
508
|
},
|
|
457
509
|
});
|
|
458
510
|
|
|
511
|
+
// Synchronous and cheap — a cache-file read, never a subprocess on this
|
|
512
|
+
// path — so it costs nothing to call before `boot()`'s network round trips.
|
|
513
|
+
// See src/update.js for why this never waits on a package manager.
|
|
514
|
+
const update = updateStatus();
|
|
515
|
+
|
|
459
516
|
await boot();
|
|
460
517
|
const { content, ctx } = await systemMessage(AUTO);
|
|
461
|
-
console.log(banner(config.model, config.baseUrl));
|
|
518
|
+
console.log(banner(config.model, config.baseUrl, update));
|
|
462
519
|
|
|
463
520
|
// Information, not a failure: the profile is doing exactly what it was
|
|
464
521
|
// told to, it's just not what the model's own GGUF recommends. One-shot
|
|
@@ -580,8 +637,9 @@ async function main() {
|
|
|
580
637
|
console.log(` ${c.green('●')} ${c.bold(name)} ${c.grey(`· ${a.tools.join(', ')}`)}`);
|
|
581
638
|
console.log(c.grey(` for ${a.use}`));
|
|
582
639
|
}
|
|
583
|
-
console.log(c.grey(` model: ${config.subagentModel ?? config.model} · `
|
|
584
|
-
+ `cap ${config.subagentSteps} steps per task
|
|
640
|
+
console.log(c.grey(` default model: ${config.subagentModel ?? config.model} · `
|
|
641
|
+
+ `cap ${config.subagentSteps} steps per task`));
|
|
642
|
+
console.log(c.grey(' a task can name a different served model itself, when the default is the wrong size for it\n'));
|
|
585
643
|
continue;
|
|
586
644
|
}
|
|
587
645
|
if (input === '/theme' || input.startsWith('/theme ')) {
|
|
@@ -635,12 +693,13 @@ async function main() {
|
|
|
635
693
|
if (input.startsWith('/model ')) {
|
|
636
694
|
const want = input.slice(7).trim();
|
|
637
695
|
const all = await listModels();
|
|
638
|
-
const
|
|
639
|
-
const hit = all.find((m) => m === want)
|
|
640
|
-
?? subs.find((m) => m.endsWith('/AGENT'))
|
|
641
|
-
?? subs[0];
|
|
696
|
+
const hit = resolveModel(want, all);
|
|
642
697
|
if (!hit) { console.log(c.red(` no model matching "${want}"`)); continue; }
|
|
643
698
|
await switchModel(hit);
|
|
699
|
+
// Kronk may be serving something new since boot — a model pulled or
|
|
700
|
+
// unloaded mid-session — so a task's own `model` enum is refreshed from
|
|
701
|
+
// the same list this command just asked for, not left stale from boot.
|
|
702
|
+
setServedModels(chatModels(all));
|
|
644
703
|
console.log(c.green(` switched to ${hit}`));
|
|
645
704
|
console.log(c.grey(` window ${config.contextWindow?.toLocaleString() ?? 'unknown'}`
|
|
646
705
|
+ ` · output cap ${config.maxTokens.toLocaleString()}`
|
package/src/mcp.js
CHANGED
|
@@ -3,9 +3,12 @@ import { readFile } from 'node:fs/promises';
|
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { c } from './ui.js';
|
|
6
|
+
import { VERSION } from './version.js';
|
|
6
7
|
|
|
7
8
|
const PROTOCOL = '2025-06-18';
|
|
8
|
-
|
|
9
|
+
// Six releases carried '0.1.0' here regardless of what package.json actually
|
|
10
|
+
// said, because nothing read the real number. VERSION is that number.
|
|
11
|
+
const CLIENT = { name: 'kronk-cli', version: VERSION };
|
|
9
12
|
const CALL_TIMEOUT = 120_000;
|
|
10
13
|
const START_TIMEOUT = 20_000;
|
|
11
14
|
|