kronk-cli 0.1.2 → 0.2.0

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/src/setup.js ADDED
@@ -0,0 +1,535 @@
1
+ /**
2
+ * `kronk-cli setup` — the three things a first-time user has to know and
3
+ * currently has to do by hand: pull the model, give it an /AGENT profile in
4
+ * ~/.kronk/models/model_config.yaml, and restart Kronk, because that file is
5
+ * only read at server start.
6
+ *
7
+ * The YAML writer here is deliberately not a parser. It locates one insertion
8
+ * point by a structural scan and refuses whenever the answer is not obvious.
9
+ * A half-written parser would round-trip — and quietly corrupt — a file it does
10
+ * not understand, which is worse than printing the block and letting the user
11
+ * paste it.
12
+ */
13
+ import readline from 'node:readline/promises';
14
+ import { spawn } from 'node:child_process';
15
+ import { accessSync, constants, copyFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
16
+ import { delimiter, dirname, join } from 'node:path';
17
+ import { setTimeout as sleep } from 'node:timers/promises';
18
+ import { config, DEFAULT_MODEL } from './config.js';
19
+ import { listModels, modelLimits } from './client.js';
20
+ import { c } from './ui.js';
21
+
22
+ /** The documented defaults for an agent profile. See README, "Tip: use an /AGENT profile". */
23
+ const DEFAULT_CONTEXT = 131072;
24
+ const PROFILE_MAX_TOKENS = 16384;
25
+ const NSEQ_MAX = 2;
26
+
27
+ /** How long to wait for the server to answer again after a restart. */
28
+ const RESTART_TIMEOUT_MS = 90_000;
29
+ const RESTART_POLL_MS = 1000;
30
+
31
+ /**
32
+ * `catalog show` prints the whole tokenizer vocabulary — megabytes of it. Only
33
+ * the header is ever read, so stop accumulating once it cannot still be there.
34
+ */
35
+ const CAPTURE_CAP = 64 * 1024;
36
+
37
+ // ---- pure helpers -------------------------------------------------------
38
+
39
+ /**
40
+ * The catalog id for a model id. A catalog entry is `owner/name`; anything past
41
+ * that is a Kronk profile suffix (`/AGENT`), which the catalog cannot resolve.
42
+ */
43
+ export function baseModelId(id) {
44
+ const parts = id.split('/');
45
+ return parts.length > 2 ? parts.slice(0, -1).join('/') : id;
46
+ }
47
+
48
+ /** Ids are plain enough to sit unquoted in YAML, but do not bet the file on it. */
49
+ const YAML_SAFE = /^[A-Za-z0-9._/@+-]+$/;
50
+ const yamlKey = (id) => (YAML_SAFE.test(id) ? id : `'${id.replace(/'/g, "''")}'`);
51
+
52
+ /**
53
+ * The profile block, as lines, at the two-space indent of a `models:` child.
54
+ *
55
+ * No `temperature`, `top_k` or `top_p`: current GGUFs carry the values their
56
+ * authors recommend and Kronk's AutoTune reads them, so an explicit block here
57
+ * would only override the model's own advice.
58
+ */
59
+ export function profileEntry(id, contextWindow) {
60
+ return [
61
+ ` ${yamlKey(id)}:`,
62
+ ` context-window: ${contextWindow}`,
63
+ ` nseq-max: ${NSEQ_MAX}`,
64
+ ' chat-template-kwargs:',
65
+ ' preserve_thinking: true',
66
+ ' sampling-parameters:',
67
+ ` max_tokens: ${PROFILE_MAX_TOKENS}`,
68
+ ];
69
+ }
70
+
71
+ /** Every line with its byte offsets and its own terminator, so nothing is re-joined. */
72
+ function splitLines(text) {
73
+ const rows = [];
74
+ let start = 0;
75
+ for (let i = 0; i <= text.length; i++) {
76
+ if (i < text.length && text[i] !== '\n') continue;
77
+ const raw = text.slice(start, i);
78
+ const crlf = raw.endsWith('\r');
79
+ rows.push({
80
+ text: crlf ? raw.slice(0, -1) : raw,
81
+ end: crlf ? i - 1 : i, // first byte of the terminator
82
+ term: i < text.length ? (crlf ? '\r\n' : '\n') : '',
83
+ });
84
+ start = i + 1;
85
+ }
86
+ return rows;
87
+ }
88
+
89
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
90
+
91
+ /**
92
+ * Where the entry for `id` goes in an existing model_config.yaml.
93
+ *
94
+ * Top-level keys are lines matching `^[A-Za-z_]` with no leading whitespace, as
95
+ * the issue specifies; the children of `models:` run to the next such line.
96
+ *
97
+ * kind is one of:
98
+ * 'present' the profile key is already there — nothing to do
99
+ * 'insert' splice the entry in directly beneath the one `models:` key
100
+ * 'append' no `models:` key at all — add the key and the entry at the end
101
+ * 'refuse' more than one insertion point, or none that is unambiguous
102
+ */
103
+ export function scanConfig(text, id) {
104
+ const eol = text.includes('\r\n') ? '\r\n' : '\n';
105
+ const rows = splitLines(text);
106
+ const heads = rows
107
+ .map((row, index) => ({ row, index }))
108
+ .filter(({ row }) => /^[A-Za-z_]/.test(row.text) && /^models\s*:/.test(row.text));
109
+
110
+ if (heads.length > 1) {
111
+ return { eol, kind: 'refuse', reason: `it has ${heads.length} top-level "models:" keys` };
112
+ }
113
+ if (!heads.length) return { eol, kind: 'append' };
114
+
115
+ const { row, index } = heads[0];
116
+ // `models: {}` or `models: [x]` is a mapping written inline. Its children are
117
+ // not lines, so there is no line to insert one beneath.
118
+ const inline = row.text.slice(row.text.indexOf(':') + 1).trim();
119
+ if (inline && !inline.startsWith('#')) {
120
+ return { eol, kind: 'refuse', reason: 'its "models:" key holds an inline value' };
121
+ }
122
+
123
+ let end = rows.length;
124
+ for (let i = index + 1; i < rows.length; i++) {
125
+ if (/^[A-Za-z_]/.test(rows[i].text)) { end = i; break; }
126
+ }
127
+ // An exact match on the indented `<id>:` line. A commented-out `# <id>:` has a
128
+ // `#` where the id would start, so it never matches.
129
+ const key = new RegExp(`^\\s+${escapeRe(yamlKey(id))}\\s*:\\s*(#.*)?$`);
130
+ for (const child of rows.slice(index + 1, end)) {
131
+ if (key.test(child.text)) return { eol, kind: 'present' };
132
+ }
133
+
134
+ return { eol, kind: 'insert', head: row, next: rows[index + 1] };
135
+ }
136
+
137
+ /**
138
+ * The file as it will be on disk. Every line the writer did not add is copied
139
+ * across untouched, terminator included — the entry is spliced into the string
140
+ * rather than the file being re-serialised from parsed lines.
141
+ */
142
+ export function applyEntry(text, scan, entry) {
143
+ const { eol } = scan;
144
+ const block = entry.map((l) => l + eol).join('');
145
+
146
+ if (scan.kind === 'insert') {
147
+ const at = scan.head.end + scan.head.term.length;
148
+ // A file that ended on `models:` with no newline still needs one.
149
+ const lead = scan.head.term ? '' : eol;
150
+ // Keep whatever followed from being glued to the new entry.
151
+ const gap = scan.next && scan.next.text.trim() !== '' ? eol : '';
152
+ return text.slice(0, at) + lead + block + gap + text.slice(at);
153
+ }
154
+
155
+ let out = text;
156
+ if (out.length && !out.endsWith(eol)) out += eol; // finish the last line
157
+ if (out.length && !out.endsWith(eol + eol)) out += eol; // one blank line before the key
158
+ return `${out}models:${eol}${block}`;
159
+ }
160
+
161
+ /** A brand-new file. Nothing to preserve, so it gets the documented shape. */
162
+ export function newConfig(entry) {
163
+ return ['version: 1', '', 'models:', ...entry, ''].join('\n');
164
+ }
165
+
166
+ /**
167
+ * Copy the file aside before touching it, never over a backup that already
168
+ * exists — `.bak` and `.bak2` are sitting next to real configs in the wild.
169
+ * COPYFILE_EXCL makes "does it exist" and "claim it" one operation.
170
+ */
171
+ export function backupFile(path) {
172
+ for (let n = 1; n <= 50; n++) {
173
+ const dest = `${path}.bak${n === 1 ? '' : n}`;
174
+ try {
175
+ copyFileSync(path, dest, constants.COPYFILE_EXCL);
176
+ return dest;
177
+ } catch (e) {
178
+ if (e.code !== 'EEXIST') throw e;
179
+ }
180
+ }
181
+ throw new Error(`${path}.bak … .bak50 all exist — clean some up first`);
182
+ }
183
+
184
+ /**
185
+ * Resolve an executable on PATH without spawning anything, so `--dry-run` can
186
+ * report a missing binary while keeping its promise to start no process.
187
+ */
188
+ export function findOnPath(name, env = process.env) {
189
+ for (const dir of (env.PATH ?? '').split(delimiter)) {
190
+ if (!dir) continue;
191
+ try {
192
+ const full = join(dir, name);
193
+ accessSync(full, constants.X_OK);
194
+ return full;
195
+ } catch {
196
+ // not here, keep looking
197
+ }
198
+ }
199
+ return null;
200
+ }
201
+
202
+ // ---- the kronk binary ---------------------------------------------------
203
+
204
+ /**
205
+ * The only place this program starts the `kronk` binary. One door means a test
206
+ * can put a stub named `kronk` first on PATH and assert on the argv it saw.
207
+ *
208
+ * `stream: true` hands the child our terminal, which is what a 21 GB pull wants;
209
+ * otherwise its output is captured (up to CAPTURE_CAP) for parsing.
210
+ */
211
+ export function runKronk(argv, { stream = false } = {}) {
212
+ return new Promise((resolve, reject) => {
213
+ const child = spawn('kronk', argv, {
214
+ stdio: ['ignore', stream ? 'inherit' : 'pipe', stream ? 'inherit' : 'pipe'],
215
+ });
216
+ let output = '';
217
+ const take = (d) => { if (output.length < CAPTURE_CAP) output += d; };
218
+ child.stdout?.setEncoding('utf8').on('data', take);
219
+ child.stderr?.setEncoding('utf8').on('data', take);
220
+ child.on('error', reject);
221
+ child.on('close', (code) => resolve({ code: code ?? 1, output }));
222
+ });
223
+ }
224
+
225
+ /**
226
+ * What the local catalog knows about a base id.
227
+ *
228
+ * `catalog show` exits 0 even for an id it cannot resolve, so presence is read
229
+ * from the body, not the status.
230
+ */
231
+ async function catalogEntry(base) {
232
+ const { output } = await runKronk(['catalog', 'show', base, '--local']);
233
+ const size = /^Total Size:\s*(.+)$/m.exec(output)?.[1].trim() ?? null;
234
+ return { known: size !== null, size, downloaded: /^Downloaded:\s*true\s*$/m.test(output) };
235
+ }
236
+
237
+ // ---- output -------------------------------------------------------------
238
+
239
+ const line = (s = '') => console.log(s);
240
+ const step = (n, title) => line(`\n ${c.bold(`${n})`)} ${title}`);
241
+ const detail = (s) => line(c.grey(` ${s}`));
242
+ const note = (s) => line(` ${s}`);
243
+ const cmd = (s) => line(c.cyan(` ${s}`));
244
+
245
+ /** Show exactly the lines that are being added, or that the user must paste. */
246
+ function printBlock(lines) {
247
+ line();
248
+ for (const l of lines) line(c.grey(` ${l}`));
249
+ line();
250
+ }
251
+
252
+ // ---- the walk -----------------------------------------------------------
253
+
254
+ /**
255
+ * Run the whole setup path. Returns the process exit code rather than calling
256
+ * process.exit, so the caller stays in charge of how the program ends.
257
+ */
258
+ export async function runSetup({ model, context, yes = false, dryRun = false } = {}) {
259
+ if (context !== null && context !== undefined && !/^[1-9]\d*$/.test(String(context))) {
260
+ console.error(c.red('\n --context takes a positive integer\n'));
261
+ return 2;
262
+ }
263
+
264
+ let rl = null;
265
+ let ended = false;
266
+ const ask = async (question) => {
267
+ if (yes) { note(`${question} ${c.grey('yes (--yes)')}`); return true; }
268
+ if (dryRun) { note(`${question} ${c.grey('skipped (--dry-run)')}`); return false; }
269
+ if (!rl) {
270
+ rl = readline.createInterface({ input: process.stdin, output: process.stdout });
271
+ rl.once('close', () => { ended = true; });
272
+ }
273
+ // Silence is not consent. An input that has already ended — a closed pipe, a
274
+ // background job, CI without --yes — must decline, and say why: asking a
275
+ // dead readline yields a promise that never settles, and the program would
276
+ // otherwise exit 0 in the middle of the walk having said nothing.
277
+ if (ended) { note(`${question} ${c.grey('no answer — stdin ended, assuming no')}`); return false; }
278
+ let answer;
279
+ try {
280
+ answer = await Promise.race([
281
+ rl.question(` ${c.yellow(question)} `),
282
+ new Promise((resolve) => rl.once('close', () => resolve(''))),
283
+ ]);
284
+ } catch { return false; }
285
+ // A pipe does not echo, so the transcript would otherwise run the question
286
+ // and its consequence together on one line with no answer between them.
287
+ if (!process.stdin.isTTY) line(answer.trim());
288
+ return /^y(es)?$/i.test(answer.trim());
289
+ };
290
+
291
+ try {
292
+ return await walk({ model, context, dryRun, ask });
293
+ } finally {
294
+ rl?.close();
295
+ }
296
+ }
297
+
298
+ async function walk({ model, context, dryRun, ask }) {
299
+ line(`\n ${c.bold('kronk-cli setup')}${dryRun ? c.grey(' · dry run, nothing will change') : ''}`);
300
+
301
+ // 1 — the server. Setup never starts it: that is the user's decision to make.
302
+ step(1, 'Checking the Kronk server');
303
+ try {
304
+ const ids = await listModels();
305
+ detail(`${config.baseUrl} · serving ${ids.length} model${ids.length === 1 ? '' : 's'}`);
306
+ } catch (e) {
307
+ console.error(c.red(`\n Cannot reach Kronk at ${config.baseUrl}`));
308
+ console.error(c.grey(` ${e.message}`));
309
+ console.error(c.grey(' Start it with: kronk server start --detach\n'));
310
+ return 1;
311
+ }
312
+
313
+ // 2 — the target, and the catalog id underneath it.
314
+ const target = model ?? config.model ?? DEFAULT_MODEL;
315
+ const base = baseModelId(target);
316
+ step(2, 'Resolving the target');
317
+ detail(`profile ${target}`);
318
+ detail(`catalog ${base}`);
319
+
320
+ // Every later step shells out, so find the binary before promising anything.
321
+ const binary = findOnPath('kronk');
322
+ const entry = profileEntry(target, await contextWindow(base, context));
323
+ if (!binary) {
324
+ console.error(c.red('\n No `kronk` binary on PATH.'));
325
+ console.error(c.grey(' Setup drives the real CLI; install it, then re-run, or do this by hand:\n'));
326
+ console.error(c.cyan(` kronk model pull ${base}`));
327
+ console.error(c.grey(`\n then add to ${config.modelConfigPath}:\n`));
328
+ console.error(c.grey(' models:'));
329
+ for (const l of entry) console.error(c.grey(` ${l}`));
330
+ console.error(c.grey('\n and restart the server:\n'));
331
+ console.error(c.cyan(' kronk server stop'));
332
+ console.error(c.cyan(' kronk server start --detach\n'));
333
+ return 1;
334
+ }
335
+ detail(`binary ${binary}`);
336
+
337
+ // 3 — is it already on disk?
338
+ step(3, 'Checking whether the model is downloaded');
339
+ let downloaded = false;
340
+ if (dryRun) {
341
+ cmd(`would run: kronk catalog show ${base} --local`);
342
+ } else {
343
+ const found = await catalogEntry(base);
344
+ downloaded = found.downloaded;
345
+ if (!found.known) detail('not in the local catalog — a pull will resolve it');
346
+ else detail(`${found.size ?? 'unknown size'} · downloaded: ${found.downloaded}`);
347
+ }
348
+
349
+ // 4 — the pull.
350
+ step(4, 'Downloading the model');
351
+ if (downloaded) {
352
+ detail('already downloaded — nothing to pull');
353
+ } else if (dryRun) {
354
+ cmd(`would run: kronk model pull ${base}`);
355
+ } else if (!await ask(`Pull ${base} now? [y/N]`)) {
356
+ note('Declined. Pull it later with:');
357
+ cmd(`kronk model pull ${base}`);
358
+ return 0;
359
+ } else {
360
+ line();
361
+ const { code } = await runKronk(['model', 'pull', base], { stream: true });
362
+ if (code !== 0) {
363
+ console.error(c.red(`\n kronk model pull ${base} exited ${code} — stopping here.`));
364
+ console.error(c.grey(' Nothing has been written to model_config.yaml.\n'));
365
+ return 1;
366
+ }
367
+ detail('pull finished');
368
+ }
369
+
370
+ // 5 — the profile.
371
+ step(5, 'Writing the /AGENT profile');
372
+ const wrote = await writeProfile({ path: config.modelConfigPath, entry, target, dryRun, ask });
373
+ if (wrote.code !== 0) return wrote.code;
374
+ if (wrote.declined) return 0;
375
+ if (!wrote.changed) {
376
+ line(c.green(`\n Nothing to do — ${target} is already set up.\n`));
377
+ return 0;
378
+ }
379
+
380
+ // 6 — the restart. model_config.yaml is read at server start and never again.
381
+ step(6, 'Restarting Kronk');
382
+ detail('model_config.yaml is read only when the server starts, so the new');
383
+ detail('profile does nothing until Kronk is restarted.');
384
+ if (dryRun) {
385
+ cmd('would run: kronk server stop');
386
+ cmd('would run: kronk server start --detach');
387
+ line(c.grey('\n Dry run complete — nothing was written and nothing was started.\n'));
388
+ return 0;
389
+ }
390
+ if (!await ask('Restart Kronk now? [y/N]')) {
391
+ note('Declined. The profile is written but not live. Restart with:');
392
+ cmd('kronk server stop');
393
+ cmd('kronk server start --detach');
394
+ return 0;
395
+ }
396
+
397
+ line();
398
+ const stopped = await runKronk(['server', 'stop'], { stream: true });
399
+ if (stopped.code !== 0) detail(`kronk server stop exited ${stopped.code} — continuing`);
400
+ const started = await runKronk(['server', 'start', '--detach'], { stream: true });
401
+ if (started.code !== 0) {
402
+ console.error(c.red(`\n kronk server start --detach exited ${started.code}.`));
403
+ console.error(c.grey(` The profile is written to ${config.modelConfigPath}; start the server by hand.\n`));
404
+ return 1;
405
+ }
406
+
407
+ detail('waiting for the server to answer…');
408
+ if (!await waitForServer()) {
409
+ console.error(c.yellow(`\n Kronk did not answer within ${RESTART_TIMEOUT_MS / 1000}s.`));
410
+ console.error(c.grey(' Check: kronk server logs\n'));
411
+ return 1;
412
+ }
413
+ detail('server is back');
414
+ line(c.green(`\n Done. ${target} is configured.`));
415
+ line(c.grey(` Try it: kronk-cli -m ${target}\n`));
416
+ return 0;
417
+ }
418
+
419
+ /** 131072 unless the model says it cannot, or the user says otherwise. */
420
+ async function contextWindow(base, override) {
421
+ if (override !== null && override !== undefined) {
422
+ const want = Number(override);
423
+ const { native } = await modelLimits(base);
424
+ if (native && want > native) {
425
+ console.error(c.yellow(` warning: --context ${want} exceeds ${base}'s native maximum of ${native}`));
426
+ }
427
+ return want;
428
+ }
429
+ const { native } = await modelLimits(base);
430
+ return native && native < DEFAULT_CONTEXT ? native : DEFAULT_CONTEXT;
431
+ }
432
+
433
+ /**
434
+ * Step 5 on its own: read, scan, back up, write. Returns the exit code and
435
+ * whether anything changed — an unchanged file means there is nothing to
436
+ * restart for.
437
+ */
438
+ async function writeProfile({ path, entry, target, dryRun, ask }) {
439
+ detail(`file ${path}`);
440
+
441
+ let text = null;
442
+ try {
443
+ text = readFileSync(path, 'utf8');
444
+ } catch (e) {
445
+ if (e.code !== 'ENOENT') {
446
+ console.error(c.red(`\n Cannot read ${path}`));
447
+ console.error(c.grey(` ${e.message}\n`));
448
+ return { code: 1, changed: false };
449
+ }
450
+ }
451
+
452
+ if (text === null) {
453
+ const dir = dirname(path);
454
+ if (!existsSync(dir)) {
455
+ // Kronk creates this directory on its first run. Its absence is a
456
+ // different problem from a missing profile, so do not paper over it.
457
+ console.error(c.red(`\n ${dir} does not exist, so Kronk has never run here.`));
458
+ console.error(c.grey(' Start it once first: kronk server start --detach'));
459
+ console.error(c.grey(' Setup will not create Kronk\'s data directory for it.\n'));
460
+ return { code: 1, changed: false };
461
+ }
462
+ return commit({ path, next: newConfig(entry), shown: ['models:', ...entry], dryRun, ask, made: 'created' });
463
+ }
464
+
465
+ const scan = scanConfig(text, target);
466
+
467
+ if (scan.kind === 'present') {
468
+ detail(`${target} is already a profile in this file — leaving it alone`);
469
+ return { code: 0, changed: false };
470
+ }
471
+ if (scan.kind === 'refuse') {
472
+ console.error(c.red(`\n Refusing to edit ${path}:`));
473
+ console.error(c.grey(` ${scan.reason}, so there is no unambiguous place to add the profile.`));
474
+ console.error(c.grey(' Nothing was written. Add this to the right "models:" section by hand:'));
475
+ printBlock(entry);
476
+
477
+ return { code: 1, changed: false };
478
+ }
479
+
480
+ const shown = scan.kind === 'append' ? ['models:', ...entry] : entry;
481
+ return commit({ path, next: applyEntry(text, scan, entry), shown, dryRun, ask, made: 'updated' });
482
+ }
483
+
484
+ async function commit({ path, next, shown, dryRun, ask, made }) {
485
+ note(`This block will be ${made === 'created' ? 'written' : 'added'}:`);
486
+ printBlock(shown);
487
+
488
+ if (dryRun) {
489
+ cmd(`would ${made === 'created' ? 'create' : 'update'}: ${path}`);
490
+ cmd(`would back up first: ${path}.bak…`);
491
+ return { code: 0, changed: true };
492
+ }
493
+
494
+ if (!await ask(`${made === 'created' ? 'Create' : 'Update'} ${path}? [y/N]`)) {
495
+ note('Declined. Nothing was written — paste the block above by hand if you prefer.');
496
+ return { code: 0, changed: false, declined: true };
497
+ }
498
+
499
+ if (made === 'created') {
500
+ detail('no backup needed — the file does not exist yet');
501
+ } else {
502
+ try {
503
+ detail(`backup ${backupFile(path)}`);
504
+ } catch (e) {
505
+ console.error(c.red(`\n Cannot back up ${path} — refusing to write without one.`));
506
+ console.error(c.grey(` ${e.message}\n`));
507
+ return { code: 1, changed: false };
508
+ }
509
+ }
510
+
511
+ try {
512
+ writeFileSync(path, next, 'utf8');
513
+ } catch (e) {
514
+ console.error(c.red(`\n Cannot write ${path}`));
515
+ console.error(c.grey(` ${e.message}\n`));
516
+ return { code: 1, changed: false };
517
+ }
518
+ detail(`${made} ${path}`);
519
+ return { code: 0, changed: true };
520
+ }
521
+
522
+ /** Poll /models until the restarted server answers, or give up and say so. */
523
+ async function waitForServer() {
524
+ const deadline = Date.now() + RESTART_TIMEOUT_MS;
525
+ for (;;) {
526
+ try {
527
+ await listModels();
528
+ return true;
529
+ } catch {
530
+ // still down
531
+ }
532
+ if (Date.now() >= deadline) return false;
533
+ await sleep(RESTART_POLL_MS);
534
+ }
535
+ }
package/src/tools.js CHANGED
@@ -5,6 +5,7 @@ import { resolve, relative, dirname, basename, isAbsolute } from 'node:path';
5
5
  import { realpathSync, mkdirSync } from 'node:fs';
6
6
  import { homedir, tmpdir } from 'node:os';
7
7
  import { detectBackend, sandboxArgv, cacheDirs } from './sandbox.js';
8
+ import { setPlan, MAX_ITEMS } from './plan.js';
8
9
  import { c } from './ui.js';
9
10
 
10
11
  const exec = promisify(execFile);
@@ -101,6 +102,29 @@ export const TOOLS = [
101
102
 
102
103
  def('bash', 'Run a shell command in the working directory. Requires user approval.',
103
104
  { cmd: { type: 'string' } }, ['cmd']),
105
+
106
+ // The description is the only instruction a small model reliably reads, so it
107
+ // carries the whole protocol: call it first, one item per criterion, resend
108
+ // the entire list every time.
109
+ def('set_plan',
110
+ 'Record the checklist for the current task and keep it updated. Call this first, with one '
111
+ + 'item per acceptance criterion in the request. Call it again after each item is finished. '
112
+ + 'The list you send replaces the stored one, so always send every item.',
113
+ {
114
+ items: {
115
+ type: 'array',
116
+ description: `The whole checklist, in order. At most ${MAX_ITEMS} items.`,
117
+ items: {
118
+ type: 'object',
119
+ properties: {
120
+ text: { type: 'string', description: 'The requirement, in the words of the request.' },
121
+ status: { type: 'string', enum: ['todo', 'doing', 'done'] },
122
+ },
123
+ required: ['text'],
124
+ },
125
+ },
126
+ },
127
+ ['items']),
104
128
  ];
105
129
 
106
130
  /** Tools that mutate state or run arbitrary code must be confirmed. */
@@ -135,18 +159,40 @@ export function describe(name, args) {
135
159
  case 'list_dir': return `ls ${args.path ?? '.'}`;
136
160
  case 'search': return `search /${args.pattern}/ in ${args.path ?? '.'}`;
137
161
  case 'bash': return `bash: ${args.cmd}`;
162
+ case 'set_plan': return `plan: ${args.items?.length ?? 0} items`;
138
163
  default: return `${name}(${JSON.stringify(args)})`;
139
164
  }
140
165
  }
141
166
 
142
- /** Strip the cwd marker off command output and record where we ended up. */
167
+ /**
168
+ * Strip the cwd marker off command output and report where we ended up.
169
+ *
170
+ * A command that walks out of the launch root does not move the session, but
171
+ * the caller still has to say where it ran: reporting `session.cwd` for a
172
+ * command that ran somewhere else handed the model a flat contradiction —
173
+ * "you are in the project" next to "this is not a git repository" — and it
174
+ * resolved it by going looking for the project elsewhere.
175
+ *
176
+ * `pwd` is null when the marker never printed, which is what `exec` does.
177
+ */
143
178
  function applyCwd(out, mark) {
144
179
  const i = out.lastIndexOf(mark);
145
- if (i === -1) return out;
180
+ if (i === -1) return { body: out, pwd: null, escaped: false };
181
+ const body = out.slice(0, i).replace(/\n$/, '');
146
182
  const next = real(out.slice(i + mark.length).trim());
147
- const rel = relative(real(session.root), next);
148
- if (next && !rel.startsWith('..')) session.cwd = next;
149
- return out.slice(0, i).replace(/\n$/, '');
183
+ if (!next) return { body, pwd: null, escaped: false };
184
+ const escaped = relative(real(session.root), next).startsWith('..');
185
+ if (!escaped) session.cwd = next;
186
+ return { body, pwd: next, escaped };
187
+ }
188
+
189
+ /** The directory a command ran in, and a warning when that was outside the root. */
190
+ function whereLines(pwd, escaped) {
191
+ const ran = pwd ? `ran in: ${pwd}` : `ran in: ${session.cwd} (final directory unknown)`;
192
+ if (!escaped) return [ran];
193
+ return [ran, `note: this command left the launch root ${session.root} and the session `
194
+ + 'directory is unchanged. Work inside the root; paths above it are outside this '
195
+ + "agent's scope."];
150
196
  }
151
197
 
152
198
  const MARK = '__KRONK_CWD__';
@@ -214,10 +260,13 @@ export function runBash(cmd, { onProgress, timeoutMs = TOOL_TIMEOUT } = {}) {
214
260
  // Own process group: killing bash alone leaves its children running and
215
261
  // holding the stdout pipe open, so `close` would not fire until they
216
262
  // finished anyway — a 5s timeout that returned after 30s.
217
- // Capture the real status BEFORE the marker runs, then exit with it.
218
- // Appending `printf` naively made every command look successful, so
219
- // failures never reached the agent at all.
220
- const script = `${cmd}\n__kronk_st=$?\nprintf '\\n${MARK}%s' "$(pwd)"\nexit $__kronk_st`;
263
+ // Print the marker from an EXIT trap rather than appending it after the
264
+ // command. A command ending in `exit 1` never reaches an appended line, so
265
+ // the shell's final directory was unobservable for exactly the failures
266
+ // that most need reporting. The trap fires whatever route the shell takes
267
+ // out, and bash exits with the status in effect when it ran — so, unlike an
268
+ // appended `printf`, it cannot make a failure look successful.
269
+ const script = `__kronk_mark() { printf '\\n${MARK}%s' "$(pwd)"; }\ntrap __kronk_mark EXIT\n${cmd}`;
221
270
 
222
271
  const backend = resolveSandbox();
223
272
  if (backend === 'none' && (process.env.KRONK_SANDBOX ?? 'auto') === 'strict') {
@@ -273,13 +322,17 @@ export function runBash(cmd, { onProgress, timeoutMs = TOOL_TIMEOUT } = {}) {
273
322
 
274
323
  child.on('error', (e) => {
275
324
  clearTimeout(timer);
325
+ // `cwd:`, not `ran in:` — this fires before any output exists, so nothing
326
+ // ever ran and there is no final directory to report. Naming one would be
327
+ // a worse lie than the stale cwd this file otherwise stopped printing.
276
328
  resolve(`error: could not start command — ${e.message}\ncwd: ${session.cwd}`);
277
329
  });
278
330
 
279
331
  child.on('close', (code, signal) => {
280
332
  clearTimeout(timer);
281
333
  const secs = ((Date.now() - started) / 1000).toFixed(1);
282
- const body = applyCwd(out, MARK);
334
+ const { body, pwd, escaped } = applyCwd(out, MARK);
335
+ const where = whereLines(pwd, escaped);
283
336
  const tail = [
284
337
  body.trim() && `stdout:\n${body.trim()}`,
285
338
  err.trim() && `stderr:\n${err.trim()}`,
@@ -289,19 +342,23 @@ export function runBash(cmd, { onProgress, timeoutMs = TOOL_TIMEOUT } = {}) {
289
342
  return resolve(clip([
290
343
  `error: killed after ${secs}s (timeout ${Math.round(timeoutMs / 1000)}s).`,
291
344
  'The command may simply be slow — re-run a narrower scope, or raise KRONK_TOOL_TIMEOUT.',
292
- `cwd: ${session.cwd}`,
345
+ ...where,
293
346
  tail || '(no output before it was killed)',
294
347
  ].join('\n')));
295
348
  }
296
349
  if (code === 0) {
297
350
  const okBody = clip(body + err);
298
- return resolve(`${okBody.trim() || '(no output)'}${truncated ? '\n[earlier output dropped]' : ''}`);
351
+ const text = `${okBody.trim() || '(no output)'}${truncated ? '\n[earlier output dropped]' : ''}`;
352
+ // Prepended, and outside clip(): a command that succeeded on the way out
353
+ // of the root is exactly how a model talks itself into believing the
354
+ // project lives somewhere else, so the warning leads and cannot be elided.
355
+ return resolve(escaped ? `${where.join('\n')}\n${text}` : text);
299
356
  }
300
357
  return resolve(clip([
301
358
  signal
302
359
  ? `error: killed by ${signal} after ${secs}s`
303
360
  : `error: exit code ${code} after ${secs}s`,
304
- `cwd: ${session.cwd}`,
361
+ ...where,
305
362
  tail || '(no output)',
306
363
  truncated ? '[earlier output dropped]' : '',
307
364
  ].filter(Boolean).join('\n')));
@@ -348,6 +405,9 @@ export async function runTool(name, args, opts = {}) {
348
405
  case 'bash':
349
406
  return runBash(args.cmd, opts);
350
407
 
408
+ case 'set_plan':
409
+ return setPlan(args.items);
410
+
351
411
  default:
352
412
  return `error: unknown tool ${name}`;
353
413
  }