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 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, and what each may touch |
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 | Model sub-agents run on — substring match, same as `KRONK_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` compacts against *its* window rather
912
- than the main model's. The one number that is not taken from the profile is a `KRONK_MAX_TOKENS`
913
- you set yourself — yours always wins.
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.7.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, TASK_TOOL } from './subagent.js';
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
- const isTask = !isMcp && call.name === 'task' && tools.includes(TASK_TOOL);
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
- /** Last resort when neither the flag nor DEFAULT_MODEL is being served. */
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('/AGENT'));
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 sub-agents run on (default: the main 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
- // exact id wins; otherwise accept a unique-enough substring
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 subs = ids.filter((id) => id.includes(config.subagentModel));
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 its own. Resolved once here rather than
168
- // on the first delegation, where the wait would land mid-task.
169
- config.subagentLimits = config.subagentModel
170
- ? await modelLimits(config.subagentModel)
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\n`));
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 subs = all.filter((m) => m.includes(want));
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
- const CLIENT = { name: 'kronk-cli', version: '0.1.0' };
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