kronk-cli 0.1.2 → 0.1.3

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
@@ -50,6 +50,16 @@ workflow run that produced it:
50
50
  gh attestation verify kronk-cli-*.tgz --repo BardiaN/kronk-cli
51
51
  ```
52
52
 
53
+ The same attestation is attached to every release as a file, so it can be checked without
54
+ GitHub's attestations API in the loop — `<tarball>.sigstore.json` for `gh attestation verify`
55
+ and cosign, `<tarball>.intoto.jsonl` for SLSA tooling:
56
+
57
+ ```bash
58
+ gh release download v0.1.3 --repo BardiaN/kronk-cli
59
+ gh attestation verify kronk-cli-0.1.3.tgz --repo BardiaN/kronk-cli \
60
+ --bundle kronk-cli-0.1.3.tgz.sigstore.json
61
+ ```
62
+
53
63
  npm packages carry the same provenance, shown as a **Provenance** panel on the
54
64
  [package page](https://www.npmjs.com/package/kronk-cli), and verifiable locally:
55
65
 
@@ -315,6 +325,45 @@ kronk-cli --auto "make the tests pass" # unattended, runs the whole task
315
325
 
316
326
  ---
317
327
 
328
+ ## Startup: the model is loaded before you type
329
+
330
+ Kronk has no load command. It lists a model in `GET /v1/models` as soon as the
331
+ server starts, but the weights only reach VRAM on the first inference request —
332
+ so on a fresh server the first prompt you type pays a 10–30 s cold load, and
333
+ looks like a hang.
334
+
335
+ `kronk-cli` pays it at boot instead. It asks Kronk what is resident, and if the
336
+ selected model is not, sends the cheapest completion there is — one token, no
337
+ reasoning — to trigger admission:
338
+
339
+ ```console
340
+ $ kronk-cli
341
+ loaded unsloth/Qwen3.6-35B-A3B-UD-Q4_K_M/AGENT · 11.4s
342
+
343
+ ██ kronk-cli · local agent, no network
344
+ ```
345
+
346
+ A model already in the pool is left alone; nothing is sent. If the selected one
347
+ cannot be admitted — it will not fit next to what is already resident — the
348
+ fallback runs the same order the CLI uses when you name nothing: the configured
349
+ default, then the best id Kronk is serving. Each is tried once, and a failure
350
+ says why:
351
+
352
+ ```console
353
+ $ kronk-cli -m Qwen3.6-27B
354
+ unsloth/Qwen3.6-27B-Q4_K_M failed to load — 507 /chat/completions — insufficient VRAM
355
+ loaded unsloth/Qwen3.6-35B-A3B-UD-Q4_K_M/AGENT · 2.0s · fallback
356
+ ```
357
+
358
+ If nothing loads, the original pick stands and the first turn reports the real
359
+ error. A warm-up is a convenience, not a gate — it never decides whether the CLI
360
+ starts.
361
+
362
+ Skip it with `--no-warm` or `KRONK_WARM=false` and the first prompt pays the load,
363
+ as before.
364
+
365
+ ---
366
+
318
367
  ## Command-line options
319
368
 
320
369
  | Flag | Default | |
@@ -323,6 +372,7 @@ kronk-cli --auto "make the tests pass" # unattended, runs the whole task
323
372
  | `-l`, `--models`, `--list` | — | List the models Kronk is serving, then exit |
324
373
  | `--no-context` | off | Skip the startup scan of the working directory |
325
374
  | `--no-compact` | off | Never auto-compact; fail when the window fills instead |
375
+ | `--no-warm` | off | Don't preload the model at startup; let the first prompt trigger the load |
326
376
  | `--mcp [names]` | off | Attach MCP servers — bare for all, or a comma list |
327
377
  | `--mcp-list` | — | Show configured MCP servers and their tools, then exit |
328
378
  | `-a`, `--auto` | off | Autonomous: auto-approve tools **and** run until the task is done. Implies `--yes` |
@@ -391,6 +441,7 @@ The per-turn usage line still prints after each response; this one is the runnin
391
441
  | `KRONK_SANDBOX_DENY` | — | Extra paths to hide from `bash`, comma or colon separated |
392
442
  | `KRONK_DISTILL` | `true` | `false` disables tool-output distillation |
393
443
  | `KRONK_DISTILL_AT` | `8000` | Characters of output that trigger distillation |
444
+ | `KRONK_WARM` | `true` | `false` skips the boot-time model preload |
394
445
  | `KRONK_AUTO_COMPACT` | `true` | `false` disables automatic compaction |
395
446
  | `KRONK_COMPACT_AT` | `0.85` | Fraction of the window that triggers compaction |
396
447
  | `NO_COLOR` | — | Any value disables colour |
@@ -847,7 +898,7 @@ previous prompt prefix — watch `cached` climb in the usage line.
847
898
  |---|---|
848
899
  | `Cannot reach Kronk` | `kronk server start --detach` |
849
900
  | `Kronk is running but has no models` | `kronk model pull <id>` |
850
- | First response takes ~25 s | Cold model load. Keep it warm with `--pool-ttl 1h` on the server |
901
+ | 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 |
851
902
  | Long silence before text | The model is reasoning. `--no-think`, or `/thinking` to watch it |
852
903
  | `(model produced no answer)` | Reasoning consumed the whole budget. Raise `KRONK_MAX_TOKENS` or use `--no-think` |
853
904
  | `kronk-cli: command not found` after an nvm switch | Re-run `npm link`, or see [Using nvm?](#using-nvm) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kronk-cli",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
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",
package/src/boot.js ADDED
@@ -0,0 +1,59 @@
1
+ import { config, DEFAULT_MODEL } from './config.js';
2
+ import { listLoaded, warm } from './client.js';
3
+ import { c, spinner } from './ui.js';
4
+
5
+ /** Last resort when neither the flag nor DEFAULT_MODEL is being served. */
6
+ export function pickDefault(ids) {
7
+ const chat = ids.filter((id) => !/embedding|rerank/i.test(id));
8
+ const agent = chat.filter((id) => id.endsWith('/AGENT'));
9
+ const pool = agent.length ? agent : chat;
10
+ return pool.sort((a, b) => b.length - a.length)[0] ?? null;
11
+ }
12
+
13
+ /**
14
+ * Get the chosen model resident before the first prompt.
15
+ *
16
+ * A freshly started Kronk serves model *ids* but holds nothing in VRAM — it
17
+ * admits a model on its first inference request, and there is no endpoint that
18
+ * does it sooner. Left alone, that 10–25 s cold load lands on the first thing
19
+ * you type, looking like a hang. Do it here, where a spinner explains the wait
20
+ * and where a model that will not fit can still fall back to one that will.
21
+ *
22
+ * Never fatal: if nothing warms, the original pick stands and the first turn
23
+ * reports the real error. A warm-up is a convenience, not a gate.
24
+ *
25
+ * Returns the id left in `config.model`.
26
+ */
27
+ export async function ensureLoaded(ids, log = console.error) {
28
+ const loaded = await listLoaded();
29
+ const resident = new Set((Array.isArray(loaded) ? loaded : []).map((l) => l.id));
30
+ if (resident.has(config.model)) return config.model;
31
+
32
+ const chosen = config.model;
33
+ // chosen → configured default → best guess; each distinct id tried once.
34
+ const chain = [...new Set([
35
+ chosen,
36
+ ids.includes(DEFAULT_MODEL) ? DEFAULT_MODEL : null,
37
+ pickDefault(ids),
38
+ ])].filter(Boolean);
39
+
40
+ for (const id of chain) {
41
+ if (resident.has(id)) { config.model = id; return id; }
42
+ const t0 = Date.now();
43
+ const spin = spinner(`loading ${id.split('/').pop()} — first run takes 10-30s`);
44
+ try {
45
+ await warm(id);
46
+ spin.stop();
47
+ const how = `${((Date.now() - t0) / 1000).toFixed(1)}s${id === chosen ? '' : ' · fallback'}`;
48
+ log(c.grey(` loaded ${id} · ${how}`));
49
+ config.model = id;
50
+ return id;
51
+ } catch (e) {
52
+ spin.stop();
53
+ log(c.yellow(` ${id} failed to load — ${e.message.split('\n')[0].slice(0, 160)}`));
54
+ }
55
+ }
56
+ // Nothing would load. Keep the original pick and let the first turn say why.
57
+ config.model = chosen;
58
+ return chosen;
59
+ }
package/src/client.js CHANGED
@@ -113,3 +113,25 @@ export async function* streamChat({ model, messages, tools, signal, maxTokens, n
113
113
 
114
114
  yield { type: 'done', calls: [...calls.values()], finish };
115
115
  }
116
+
117
+ /**
118
+ * Load a model into the pool.
119
+ *
120
+ * Kronk has no explicit "load" endpoint — admission happens on the first
121
+ * inference request, so the cheapest possible completion *is* the load
122
+ * command. A 23 GB MoE takes ~10–25 s off disk. The reply is discarded;
123
+ * only whether it succeeded matters.
124
+ */
125
+ export async function warm(id, signal) {
126
+ const res = await req('/chat/completions', {
127
+ method: 'POST',
128
+ signal,
129
+ body: JSON.stringify({
130
+ model: id,
131
+ messages: [{ role: 'user', content: 'hi' }],
132
+ max_completion_tokens: 1,
133
+ enable_thinking: false,
134
+ }),
135
+ });
136
+ await res.text();
137
+ }
package/src/config.js CHANGED
@@ -23,6 +23,9 @@ export const config = {
23
23
  maxSteps: Number(process.env.KRONK_MAX_STEPS ?? file.maxSteps ?? Infinity),
24
24
  showThinking: (process.env.KRONK_THINKING ?? String(file.showThinking ?? 'true')) !== 'false',
25
25
  noThink: (process.env.KRONK_NO_THINK ?? String(file.noThink ?? '')) === '1',
26
+ // Kronk admits a model on its first inference request, not at server start.
27
+ // Pay that cold load at boot rather than on the first typed prompt.
28
+ warm: (process.env.KRONK_WARM ?? String(file.warm ?? 'true')) !== 'false',
26
29
  autoCompact: (process.env.KRONK_AUTO_COMPACT ?? String(file.autoCompact ?? 'true')) !== 'false',
27
30
  compactAt: Number(process.env.KRONK_COMPACT_AT ?? file.compactAt ?? 0.85),
28
31
  // Large tool output is summarized in a throwaway context so the raw text
package/src/index.js CHANGED
@@ -4,6 +4,7 @@ import { stdin, stdout } from 'node:process';
4
4
  import { readFile } from 'node:fs/promises';
5
5
  import { config, DEFAULT_MODEL, warnIfInsecure } from './config.js';
6
6
  import { listModels, listModelDetails, listLoaded, modelLimits, tokenize } from './client.js';
7
+ import { pickDefault, ensureLoaded } from './boot.js';
7
8
  import { runTurn, SYSTEM, SYSTEM_AUTO } from './agent.js';
8
9
  import { c, banner, fmtContext, statusLine } from './ui.js';
9
10
  import { projectContext } from './context.js';
@@ -29,6 +30,7 @@ const SHOW_MODELS = flag('--models', '-l', '--list');
29
30
  const SHOW_MCP = flag('--mcp-list');
30
31
  const NO_CONTEXT = flag('--no-context');
31
32
  if (flag('--no-compact')) config.autoCompact = false;
33
+ if (flag('--no-warm')) config.warm = false;
32
34
 
33
35
  if (flag('-h', '--help')) {
34
36
  console.log(`
@@ -43,6 +45,7 @@ if (flag('-h', '--help')) {
43
45
  -l, --models list the models Kronk is serving, then exit
44
46
  --no-context skip the startup scan of the working directory
45
47
  --no-compact never auto-compact; fail instead when the window fills
48
+ --no-warm don't preload the model; let the first prompt trigger it
46
49
  --mcp [names] attach MCP servers; bare for all, or a comma list
47
50
  --mcp-list show configured MCP servers and their tools, then exit
48
51
  -m, --model <id> model to use; substring is enough, /AGENT profiles win
@@ -60,6 +63,7 @@ if (flag('-h', '--help')) {
60
63
  KRONK_MAX_TOKENS output cap per response (default 8192)
61
64
  KRONK_MAX_STEPS cap on tool calls per task (default unlimited)
62
65
  KRONK_NO_THINK set to 1 to disable reasoning
66
+ KRONK_WARM false to skip the boot-time model preload
63
67
  KRONK_AUTO_COMPACT false to disable automatic compaction
64
68
  KRONK_COMPACT_AT fraction of the window that triggers it (default 0.85)
65
69
 
@@ -96,14 +100,6 @@ let MCP_WANTED = null;
96
100
  const stepsArg = opt('--steps');
97
101
  if (stepsArg) config.maxSteps = /^(0|off|none|inf|unlimited)$/i.test(stepsArg) ? Infinity : Number(stepsArg);
98
102
 
99
- /** Last resort when neither the flag nor DEFAULT_MODEL is being served. */
100
- function pickDefault(ids) {
101
- const chat = ids.filter((id) => !/embedding|rerank/i.test(id));
102
- const agent = chat.filter((id) => id.endsWith('/AGENT'));
103
- const pool = agent.length ? agent : chat;
104
- return pool.sort((a, b) => b.length - a.length)[0] ?? null;
105
- }
106
-
107
103
  async function boot() {
108
104
  let ids;
109
105
  try {
@@ -139,6 +135,8 @@ async function boot() {
139
135
  }
140
136
  if (!config.model) config.model = pickDefault(ids);
141
137
 
138
+ if (config.warm) await ensureLoaded(ids);
139
+
142
140
  const { configured, native } = await modelLimits(config.model);
143
141
  config.contextWindow = configured;
144
142
  config.nativeContext = native;
@@ -343,7 +341,16 @@ async function main() {
343
341
  // Without one, stdin IS the prompt, so wait longer before giving up.
344
342
  const piped = await readStdin(inline ? 200 : 10_000);
345
343
  const oneShotPrompt = inline && piped ? `${inline}\n\n${piped}` : (inline || piped);
346
- if (oneShotPrompt) { await oneShot(oneShotPrompt); return; }
344
+ if (oneShotPrompt) {
345
+ // stdin has given us everything it is going to. When it is a pipe the
346
+ // caller never closes — a script, an editor task, a CI step — the read
347
+ // above stays pending and its handle would keep the process alive long
348
+ // after the answer was printed. Let go of it before answering.
349
+ stdin.pause();
350
+ stdin.unref?.();
351
+ await oneShot(oneShotPrompt);
352
+ return;
353
+ }
347
354
 
348
355
  const rl = readline.createInterface({ input: stdin, output: stdout, historySize: 500 });
349
356
  await boot();