kronk-cli 0.6.1 → 0.8.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/README.md +226 -21
- package/package.json +1 -1
- package/src/agent.js +16 -4
- 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 +276 -0
- 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 +186 -30
- 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/src/subagent.js
CHANGED
|
@@ -2,6 +2,9 @@ import { def, TOOLS } from './tools.js';
|
|
|
2
2
|
import { config } from './config.js';
|
|
3
3
|
import { c, elapsed } from './ui.js';
|
|
4
4
|
import { startRun } from './tasklog.js';
|
|
5
|
+
import { livePane } from './pane.js';
|
|
6
|
+
import { modelLimits as fetchModelLimits } from './client.js';
|
|
7
|
+
import { resolveModel } from './resolve.js';
|
|
5
8
|
|
|
6
9
|
/**
|
|
7
10
|
* Sub-agents: one delegated task, one throwaway context.
|
|
@@ -66,6 +69,18 @@ export const AGENTS = {
|
|
|
66
69
|
const agentList = () => Object.entries(AGENTS)
|
|
67
70
|
.map(([name, a]) => `"${name}" — ${a.use}`).join('; ');
|
|
68
71
|
|
|
72
|
+
/**
|
|
73
|
+
* The ids a task's `model` parameter may name, or null when nobody has said.
|
|
74
|
+
*
|
|
75
|
+
* Set once at boot, from `chatModels(await listModels())` in src/boot.js, by
|
|
76
|
+
* `setServedModels` below. A test that builds `runTask` by hand, as every
|
|
77
|
+
* test in test/subagent.test.js does, never calls that setter, so this stays
|
|
78
|
+
* null and the tool is offered exactly as it always was: no `model` property
|
|
79
|
+
* at all, because an enum with no members is not a real choice, it is a tool
|
|
80
|
+
* a model would call and then have no valid value to put in it.
|
|
81
|
+
*/
|
|
82
|
+
let servedModels = null;
|
|
83
|
+
|
|
69
84
|
/**
|
|
70
85
|
* The instruction the delegating model actually reads.
|
|
71
86
|
*
|
|
@@ -73,16 +88,14 @@ const agentList = () => Object.entries(AGENTS)
|
|
|
73
88
|
* the one place a small model reliably looks, so the whole protocol lives
|
|
74
89
|
* here — what it is for, that the sub-agent starts blank, and that a vague
|
|
75
90
|
* prompt is the failure mode.
|
|
91
|
+
*
|
|
92
|
+
* `model` is built from `servedModels` rather than written once: the whole
|
|
93
|
+
* point of the enum is that the delegating model can only ever name a real
|
|
94
|
+
* id, so the property has to be rebuilt whenever the served list changes,
|
|
95
|
+
* and left off entirely when there is no list to build it from.
|
|
76
96
|
*/
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
+ 'Use it when getting the answer means reading or running a lot but the answer itself is '
|
|
80
|
-
+ 'small — surveys, searches across many files, reproducing a failure. What the sub-agent '
|
|
81
|
-
+ 'reads never enters this conversation; only its report does. The sub-agent cannot see this '
|
|
82
|
-
+ 'conversation and cannot ask you anything, so the prompt must carry everything it needs and '
|
|
83
|
-
+ 'must say exactly what to report back. It cannot delegate further. Prefer one well-scoped '
|
|
84
|
-
+ 'task over several vague ones.',
|
|
85
|
-
{
|
|
97
|
+
function buildTaskTool(models) {
|
|
98
|
+
const properties = {
|
|
86
99
|
agent: {
|
|
87
100
|
type: 'string',
|
|
88
101
|
enum: Object.keys(AGENTS),
|
|
@@ -93,8 +106,50 @@ export const TASK_TOOL = def('task',
|
|
|
93
106
|
description: 'The whole task, written for someone who knows nothing about this '
|
|
94
107
|
+ 'conversation: what to do, where to look, and exactly what to report back.',
|
|
95
108
|
},
|
|
96
|
-
}
|
|
97
|
-
|
|
109
|
+
};
|
|
110
|
+
if (models?.length) {
|
|
111
|
+
properties.model = {
|
|
112
|
+
type: 'string',
|
|
113
|
+
enum: models,
|
|
114
|
+
description: 'Which model to run this task on, when the default is not the right size '
|
|
115
|
+
+ 'for it. A survey that reads a lot and concludes little wants a small, fast model; '
|
|
116
|
+
+ 'reproducing a failure or making a change wants a large one. Omit it to use the '
|
|
117
|
+
+ "session's own default. Naming a model that is not already loaded costs a real "
|
|
118
|
+
+ 'load — seconds, not instant — so only ask for a different one when the task actually '
|
|
119
|
+
+ 'needs it.',
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
return def('task',
|
|
123
|
+
'Delegate one self-contained piece of work to a sub-agent that runs in its own context. '
|
|
124
|
+
+ 'Use it when getting the answer means reading or running a lot but the answer itself is '
|
|
125
|
+
+ 'small — surveys, searches across many files, reproducing a failure. What the sub-agent '
|
|
126
|
+
+ 'reads never enters this conversation; only its report does. The sub-agent cannot see this '
|
|
127
|
+
+ 'conversation and cannot ask you anything, so the prompt must carry everything it needs and '
|
|
128
|
+
+ 'must say exactly what to report back. It cannot delegate further. Prefer one well-scoped '
|
|
129
|
+
+ 'task over several vague ones.',
|
|
130
|
+
properties,
|
|
131
|
+
['agent', 'prompt']);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export let TASK_TOOL = buildTaskTool(servedModels);
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Tell src/subagent.js what Kronk is actually serving, so the task tool's
|
|
138
|
+
* `model` parameter can only ever name something real.
|
|
139
|
+
*
|
|
140
|
+
* Called once at boot with `chatModels(ids)` from src/boot.js — never the
|
|
141
|
+
* raw list from `listModels()`, so an embedding model, or a bare id sitting
|
|
142
|
+
* next to its own /AGENT profile, can never appear as something a delegated
|
|
143
|
+
* task is allowed to ask for. What it does keep is every chat model that has
|
|
144
|
+
* no profile of its own; src/boot.js has the argument for why that matters
|
|
145
|
+
* more here than it does when one default is being chosen. `null` (or an
|
|
146
|
+
* empty list) puts the tool back to its no-`model` shape, which is also its
|
|
147
|
+
* starting shape — nothing to degrade to, because nothing was ever added.
|
|
148
|
+
*/
|
|
149
|
+
export function setServedModels(ids) {
|
|
150
|
+
servedModels = Array.isArray(ids) && ids.length ? [...ids] : null;
|
|
151
|
+
TASK_TOOL = buildTaskTool(servedModels);
|
|
152
|
+
}
|
|
98
153
|
|
|
99
154
|
/**
|
|
100
155
|
* The delegation tool, when it is allowed at all.
|
|
@@ -133,6 +188,14 @@ const nest = (line) => String(line).split('\n')
|
|
|
133
188
|
/** How much of the prompt fits on the opening line. */
|
|
134
189
|
const GIST = 60;
|
|
135
190
|
|
|
191
|
+
/**
|
|
192
|
+
* The tail of a model id — enough of it to actually distinguish one on screen.
|
|
193
|
+
* A served id's own name is the second-to-last segment (`unsloth/Qwen3.6-…
|
|
194
|
+
* /AGENT`); the last segment alone is often just `AGENT` or `base`, which
|
|
195
|
+
* every profile of every model shares and which therefore says nothing.
|
|
196
|
+
*/
|
|
197
|
+
const short = (id) => String(id).split('/').slice(-2).join('/');
|
|
198
|
+
|
|
136
199
|
/**
|
|
137
200
|
* The line that says a run has started.
|
|
138
201
|
*
|
|
@@ -140,11 +203,18 @@ const GIST = 60;
|
|
|
140
203
|
* two of them in a row are indistinguishable in scrollback. The gist is the
|
|
141
204
|
* prompt's first line, cut: enough to recognise which task this was, never
|
|
142
205
|
* enough to be worth reading instead of the report.
|
|
206
|
+
*
|
|
207
|
+
* `model` is named here too, always, not only when it differs from the
|
|
208
|
+
* session's own — residency is the real cost of letting a task pick one
|
|
209
|
+
* (src/subagent.js's module comment on `setServedModels`), and "which model
|
|
210
|
+
* just cost you a cold load" is a question this line should answer without
|
|
211
|
+
* making anyone go read /agents first.
|
|
143
212
|
*/
|
|
144
|
-
export function openLine(agent, prompt) {
|
|
213
|
+
export function openLine(agent, prompt, model) {
|
|
145
214
|
const first = String(prompt).split('\n')[0].trim();
|
|
146
215
|
const gist = first.length > GIST ? `${first.slice(0, GIST - 1)}…` : first;
|
|
147
|
-
|
|
216
|
+
const tag = model ? c.grey(` · ${short(model)}`) : '';
|
|
217
|
+
return `${c.grey(' ┌')} ${c.bold(agent)}${tag} ${c.grey(`· ${gist}`)}`;
|
|
148
218
|
}
|
|
149
219
|
|
|
150
220
|
/**
|
|
@@ -152,13 +222,17 @@ export function openLine(agent, prompt) {
|
|
|
152
222
|
*
|
|
153
223
|
* This is what makes `reportLines`' "…N more lines" actionable: the report on
|
|
154
224
|
* screen is bounded, and now there is somewhere to go for the rest of it and
|
|
155
|
-
* for everything the sub-agent read on the way.
|
|
225
|
+
* for everything the sub-agent read on the way. Once the pane erases itself it
|
|
226
|
+
* carries more than that — it is all that is left of the run on screen.
|
|
156
227
|
*/
|
|
157
|
-
export function closeLine({ ms, steps,
|
|
228
|
+
export function closeLine({ ms, steps, n, ok }) {
|
|
158
229
|
// A run that threw or was interrupted is the one worth reading, so the line
|
|
159
230
|
// that points at its transcript must not read like a clean finish.
|
|
160
231
|
const bits = [`${steps} step${steps === 1 ? '' : 's'}`, elapsed(ms), ...(ok ? [] : ['stopped'])];
|
|
161
|
-
|
|
232
|
+
// The command that reads the transcript, not the file it reads. The absolute
|
|
233
|
+
// path under the temp dir is long, mostly noise, and not a thing anyone would
|
|
234
|
+
// type; `/tasks 3` is both shorter and the answer to "how do I see it".
|
|
235
|
+
if (n) bits.push(`/tasks ${n} for the transcript`);
|
|
162
236
|
const line = ` └ ${bits.join(' · ')}`;
|
|
163
237
|
return ok ? c.grey(line) : c.yellow(line);
|
|
164
238
|
}
|
|
@@ -191,6 +265,29 @@ function reportFrom(messages) {
|
|
|
191
265
|
return text;
|
|
192
266
|
}
|
|
193
267
|
|
|
268
|
+
/**
|
|
269
|
+
* One model's window and output cap, looked up once per distinct id and kept
|
|
270
|
+
* for the rest of the session.
|
|
271
|
+
*
|
|
272
|
+
* Used to be a single field, `config.subagentLimits`, resolved once at boot
|
|
273
|
+
* for the one id `KRONK_SUBAGENT_MODEL` named. Now that a task can name a
|
|
274
|
+
* different model on every delegation, "once at boot for one id" is not
|
|
275
|
+
* enough — but "once per delegation" is worse: a survey and a build fix
|
|
276
|
+
* delegated back to back on the same small model would ask Kronk for the
|
|
277
|
+
* same profile twice for no reason. A `Map` keyed by id is the middle
|
|
278
|
+
* ground — paid once per model actually used, not once per model that exists
|
|
279
|
+
* and not once per task that runs.
|
|
280
|
+
*/
|
|
281
|
+
const limitsCache = new Map();
|
|
282
|
+
|
|
283
|
+
/** Test-only: the cache is a session-lifetime optimisation, not a session. */
|
|
284
|
+
export function resetLimitsCache() { limitsCache.clear(); }
|
|
285
|
+
|
|
286
|
+
async function limitsFor(id, fetchLimits) {
|
|
287
|
+
if (!limitsCache.has(id)) limitsCache.set(id, await fetchLimits(id));
|
|
288
|
+
return limitsCache.get(id);
|
|
289
|
+
}
|
|
290
|
+
|
|
194
291
|
/**
|
|
195
292
|
* Run one delegated task to completion and hand back only its report.
|
|
196
293
|
*
|
|
@@ -201,6 +298,12 @@ function reportFrom(messages) {
|
|
|
201
298
|
*/
|
|
202
299
|
export async function runTask(args, {
|
|
203
300
|
run, model, signal, approve, grant, out = console.log, maxSteps = config.subagentSteps,
|
|
301
|
+
// Injected the same way `run` and `out` are, and for the same reason: the
|
|
302
|
+
// pane's behaviour is a screen behaviour, and a test has no screen.
|
|
303
|
+
pane: makePane = livePane,
|
|
304
|
+
// Real Kronk by default; a test hands in a stub so resolving a named
|
|
305
|
+
// model's limits never touches a network nobody promised would be there.
|
|
306
|
+
modelLimits: fetchLimits = fetchModelLimits,
|
|
204
307
|
}) {
|
|
205
308
|
const agent = args?.agent ?? 'explore';
|
|
206
309
|
const prompt = String(args?.prompt ?? '').trim();
|
|
@@ -214,16 +317,52 @@ export async function runTask(args, {
|
|
|
214
317
|
{ role: 'user', content: prompt },
|
|
215
318
|
];
|
|
216
319
|
|
|
320
|
+
// The model this task actually runs on: what it asked for, else the
|
|
321
|
+
// session's configured default for delegation, else the session's own.
|
|
322
|
+
let taskModel = config.subagentModel ?? model;
|
|
323
|
+
const wanted = args?.model ? String(args.model).trim() : '';
|
|
324
|
+
// A note for the tool result, not a return in itself — a task that asked
|
|
325
|
+
// for a model that turns out not to be served still runs, on the fallback
|
|
326
|
+
// above, because there is nothing the delegating model can do to fix an id
|
|
327
|
+
// that came straight out of the enum it was offered. Failing the whole
|
|
328
|
+
// delegation over it would only cost a retry with the same bad value; a
|
|
329
|
+
// note it can read and carry forward is strictly more useful.
|
|
330
|
+
let modelNote = null;
|
|
331
|
+
if (wanted) {
|
|
332
|
+
if (servedModels) {
|
|
333
|
+
const resolved = resolveModel(wanted, servedModels);
|
|
334
|
+
if (resolved) taskModel = resolved;
|
|
335
|
+
else modelNote = `note: "${wanted}" is not being served right now — ran on ${short(taskModel)} instead.`;
|
|
336
|
+
} else {
|
|
337
|
+
// Nothing to check the name against — a unit test that built `runTask`
|
|
338
|
+
// by hand, or a session where `setServedModels` was never called. The
|
|
339
|
+
// id is trusted exactly as `model` and `config.subagentModel` already
|
|
340
|
+
// are in that situation: a plain string, handed to `run` unvalidated.
|
|
341
|
+
taskModel = wanted;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
217
345
|
// A sub-agent on another model gets that model's window and output cap, not
|
|
218
346
|
// the session's. Compacting a 32k sub-agent at 85% of the main model's 131k
|
|
219
|
-
// means never compacting it at all.
|
|
220
|
-
|
|
347
|
+
// means never compacting it at all. The session's own model needs no
|
|
348
|
+
// lookup — config.contextWindow and config.maxTokens already are its
|
|
349
|
+
// limits, kept current by applyLimits whenever the session switches models.
|
|
350
|
+
const limits = taskModel === model ? null : await limitsFor(taskModel, fetchLimits);
|
|
221
351
|
|
|
222
352
|
// Recorded from here, not from the first step: a run that dies in its first
|
|
223
353
|
// model call still leaves a file saying which task was asked for.
|
|
224
|
-
const log = startRun({ agent, prompt, model:
|
|
354
|
+
const log = startRun({ agent, prompt, model: taskModel });
|
|
225
355
|
const started = Date.now();
|
|
226
|
-
|
|
356
|
+
|
|
357
|
+
// On a terminal the run is drawn in a box that erases itself; on a pipe, a
|
|
358
|
+
// dumb terminal or an `--auto` run nobody is watching, `livePane` hands back
|
|
359
|
+
// an inert one and everything below takes exactly the path it took before
|
|
360
|
+
// the pane existed. The transcript is written either way, which is what
|
|
361
|
+
// makes erasing the box safe — see src/pane.js.
|
|
362
|
+
const pane = makePane({ agent, model: taskModel, maxSteps, signal });
|
|
363
|
+
if (pane.enabled) pane.open();
|
|
364
|
+
else out(openLine(agent, prompt, taskModel));
|
|
365
|
+
if (modelNote) out(c.yellow(` ${modelNote}`));
|
|
227
366
|
|
|
228
367
|
let steps = 0;
|
|
229
368
|
let report;
|
|
@@ -232,12 +371,15 @@ export async function runTask(args, {
|
|
|
232
371
|
messages,
|
|
233
372
|
// A smaller model for the grunt work is the whole reason this is a knob:
|
|
234
373
|
// the survey is reading and grepping, the synthesis is not.
|
|
235
|
-
model:
|
|
236
|
-
window:
|
|
237
|
-
maxTokens:
|
|
374
|
+
model: taskModel,
|
|
375
|
+
window: limits ? limits.configured : config.contextWindow,
|
|
376
|
+
maxTokens: limits?.maxTokens ?? config.maxTokens,
|
|
238
377
|
signal,
|
|
239
|
-
|
|
240
|
-
|
|
378
|
+
// Wrapped, not replaced: delegation is still not a way to get a command
|
|
379
|
+
// past the user. The wrapper only wipes the box first, so the question is
|
|
380
|
+
// asked on a clear screen and nothing redraws over it while it waits.
|
|
381
|
+
approve: pane.shield(approve),
|
|
382
|
+
grant: pane.shield(grant),
|
|
241
383
|
// No MCP: a local model already struggles past ~25 tools, and the
|
|
242
384
|
// sub-agent's list is meant to be the short one.
|
|
243
385
|
mcp: null,
|
|
@@ -250,23 +392,37 @@ export async function runTask(args, {
|
|
|
250
392
|
// second stream interleaved with the caller's is unreadable. Tool calls
|
|
251
393
|
// still show, indented, so the run is not a black box.
|
|
252
394
|
stream: false,
|
|
253
|
-
out: (line) => out(nest(line)),
|
|
395
|
+
out: pane.enabled ? (line) => pane.line(line) : (line) => out(nest(line)),
|
|
396
|
+
// The pane is already the live view, and the spinner and the per-command
|
|
397
|
+
// live line both rewrite the current line with `\r` — which is the last
|
|
398
|
+
// row of the box. Two things redrawing the same row is the one way this
|
|
399
|
+
// can look broken, so below a pane there is only one of them.
|
|
400
|
+
quiet: pane.enabled,
|
|
254
401
|
maxSteps,
|
|
255
402
|
depth: 1,
|
|
256
403
|
// Written as it goes rather than at the end. The runs worth reading are
|
|
257
404
|
// the ones that were interrupted, threw, or ran out of steps, and none
|
|
258
405
|
// of those reach the line below.
|
|
259
|
-
onStep: (msgs, n) => { steps = n; log?.step(msgs, n); },
|
|
406
|
+
onStep: (msgs, n) => { steps = n; pane.step(n); log?.step(msgs, n); },
|
|
260
407
|
});
|
|
261
408
|
|
|
262
409
|
report = reportFrom(done);
|
|
410
|
+
// The delegating model reads the tool result, not the scrollback, so the
|
|
411
|
+
// note about a model it asked for and did not get belongs in both —
|
|
412
|
+
// printed above when the run started, and carried in what it gets back.
|
|
413
|
+
if (modelNote) report = `${modelNote}\n\n${report}`;
|
|
414
|
+
// Before the report, so the box is gone by the time the answer lands in
|
|
415
|
+
// the scrollback it is keeping clean.
|
|
416
|
+
pane.close();
|
|
263
417
|
reportLines(report).forEach((l) => out(l));
|
|
264
418
|
return report;
|
|
265
419
|
} finally {
|
|
266
420
|
// In `finally` so an abort or a throw still closes the record and still
|
|
267
421
|
// tells the user where the partial transcript is — that is the case the
|
|
268
|
-
// transcript exists for.
|
|
269
|
-
|
|
270
|
-
|
|
422
|
+
// transcript exists for. `close` is idempotent; the success path already
|
|
423
|
+
// called it above.
|
|
424
|
+
pane.close();
|
|
425
|
+
log?.finish({ report, steps });
|
|
426
|
+
out(closeLine({ ms: Date.now() - started, steps, n: log?.n, ok: report !== undefined }));
|
|
271
427
|
}
|
|
272
428
|
}
|
package/src/tasklog.js
CHANGED
|
@@ -203,6 +203,11 @@ const gist = (prompt, width) => {
|
|
|
203
203
|
* never finished says so rather than reporting a duration it does not have:
|
|
204
204
|
* "still running" and "died three steps in" are the two states where the
|
|
205
205
|
* transcript is worth the most, and both would otherwise render as a blank.
|
|
206
|
+
*
|
|
207
|
+
* The model is named on every line, not only the ones that used a different
|
|
208
|
+
* one: a task can now pick its own (src/subagent.js), and a run that took a
|
|
209
|
+
* minute longer than the others is otherwise a mystery — this is the list
|
|
210
|
+
* where that gets explained, without opening the run in full.
|
|
206
211
|
*/
|
|
207
212
|
export function runLines() {
|
|
208
213
|
if (!config.taskLog) {
|
|
@@ -216,7 +221,8 @@ export function runLines() {
|
|
|
216
221
|
const how = { done: took(r.ms), stopped: 'stopped', running: 'running' }[r.state] ?? took(r.ms);
|
|
217
222
|
const state = `${r.steps} step${r.steps === 1 ? '' : 's'} \u00b7 ${how}`.padEnd(20);
|
|
218
223
|
const dot = { done: c.green('\u25cf'), stopped: c.yellow('\u25cf'), running: c.blue('\u25cf') }[r.state] ?? c.grey('\u25cf');
|
|
219
|
-
|
|
224
|
+
const model = r.model ? `${r.model.split('/').slice(-2).join('/')} \u00b7 ` : '';
|
|
225
|
+
return ` ${dot} ${c.bold(String(r.n).padStart(2))} ${r.agent.padEnd(7)} ${c.grey(state)} ${c.grey(model)}${c.grey(gist(r.prompt, 40))}`;
|
|
220
226
|
});
|
|
221
227
|
lines.push(c.grey(`\n /tasks <n> for one in full \u00b7 ${sessionDir()}`));
|
|
222
228
|
return lines;
|
package/src/ui.js
CHANGED
|
@@ -24,12 +24,26 @@ export const c = {
|
|
|
24
24
|
grey: wrap('grey'),
|
|
25
25
|
};
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
${
|
|
30
|
-
${
|
|
31
|
-
|
|
32
|
-
|
|
27
|
+
/** `{current, latest, hint}` when a newer release exists; `{current, hint}` when only the route is known (see src/update.js). */
|
|
28
|
+
const fmtCliUpdate = (u) => (u.latest
|
|
29
|
+
? `kronk-cli ${u.current} → ${u.latest} · run: ${u.hint}`
|
|
30
|
+
: `kronk-cli ${u.current} · ${u.hint}`);
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* `update` is optional and, when there is nothing to report, this renders
|
|
34
|
+
* exactly what it always has — a session with no news prints no extra line.
|
|
35
|
+
* See src/update.js for how `update.cli` is decided; this function only lays
|
|
36
|
+
* it out.
|
|
37
|
+
*/
|
|
38
|
+
export const banner = (model, url, update) => [
|
|
39
|
+
'',
|
|
40
|
+
`${c.cyan(' ██ kronk-cli')} ${c.grey('· local agent, no network')}`,
|
|
41
|
+
` ${c.grey('model')} ${c.bold(model)}`,
|
|
42
|
+
` ${c.grey('server')} ${url}`,
|
|
43
|
+
...(update?.cli ? [` ${c.grey('update')} ${fmtCliUpdate(update.cli)}`] : []),
|
|
44
|
+
` ${c.grey('/help for commands · Ctrl-C to interrupt · /exit to quit')}`,
|
|
45
|
+
'',
|
|
46
|
+
].join('\n');
|
|
33
47
|
|
|
34
48
|
const k = (n) => (n >= 1000 ? `${(n / 1000).toFixed(n >= 10000 ? 0 : 1)}k` : String(n));
|
|
35
49
|
|
package/src/update.js
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "A newer kronk-cli is available" — asked of the package manager that put
|
|
3
|
+
* this copy on disk, never of the network directly. `test/egress.test.js`
|
|
4
|
+
* forbids `fetch` to anything but the configured Kronk host and forbids a raw
|
|
5
|
+
* `node:http` import; this module respects both by going through `npm`,
|
|
6
|
+
* `brew` and `git` subprocesses the same way `src/setup.js` goes through
|
|
7
|
+
* `kronk` — a manager knows the version graph for the thing it manages, and
|
|
8
|
+
* asking it is the whole point of the issue this module exists for.
|
|
9
|
+
*
|
|
10
|
+
* It is a fifth entry in the `child_process` allowlist that test enforces,
|
|
11
|
+
* alongside `context.js`, `mcp.js`, `setup.js` and `tools.js`. It earns its
|
|
12
|
+
* own entry rather than borrowing one of theirs: `setup.js`'s door is
|
|
13
|
+
* `runKronk`, which only ever spawns the `kronk` binary, and reusing it here
|
|
14
|
+
* would mean either widening what that door lets through or lying about what
|
|
15
|
+
* command is actually running. `context.js` spawns `git` too, but only ever
|
|
16
|
+
* `-C <the user's project>` to describe *their* repository for the system
|
|
17
|
+
* prompt — pointing it at kronk-cli's own install directory to ask a
|
|
18
|
+
* different question would overload what that module is for. A dedicated
|
|
19
|
+
* module keeps each spawner's job legible from its name, which is what the
|
|
20
|
+
* allowlist is trying to preserve in the first place.
|
|
21
|
+
*
|
|
22
|
+
* Never on the path that blocks the prompt. `updateStatus` reads a small
|
|
23
|
+
* cache file synchronously (cheap, local, not a subprocess) and returns
|
|
24
|
+
* immediately; when that cache is missing or a day or more old it also kicks
|
|
25
|
+
* off `refreshCliUpdate` for next time, deliberately unawaited. A stale line
|
|
26
|
+
* shown instantly beats a fresh one that made the REPL wait for `npm view` to
|
|
27
|
+
* come back over whatever network the machine has today.
|
|
28
|
+
*/
|
|
29
|
+
import { execFile } from 'node:child_process';
|
|
30
|
+
import { promisify } from 'node:util';
|
|
31
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
32
|
+
import { dirname, join, sep } from 'node:path';
|
|
33
|
+
import { config } from './config.js';
|
|
34
|
+
import { VERSION } from './version.js';
|
|
35
|
+
|
|
36
|
+
const execFileAsync = promisify(execFile);
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Run a manager and let go of it.
|
|
40
|
+
*
|
|
41
|
+
* `unref` is the whole reason this wrapper exists. `updateStatus` fires the
|
|
42
|
+
* refresh deliberately unawaited, and a live child keeps a handle on the event
|
|
43
|
+
* loop: without it, quitting the REPL in the first few seconds of a session
|
|
44
|
+
* prints "bye" and then sits there until `npm view` answers or the timeout
|
|
45
|
+
* kills it. A cache written for next time is never worth delaying this exit
|
|
46
|
+
* for — if the answer is lost because the process went away, the next session
|
|
47
|
+
* simply asks again.
|
|
48
|
+
*/
|
|
49
|
+
function exec(cmd, argv) {
|
|
50
|
+
const p = execFileAsync(cmd, argv, { timeout: CHECK_TIMEOUT_MS });
|
|
51
|
+
p.child?.unref?.();
|
|
52
|
+
return p;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A cold `npm view` or `brew outdated` over a slow link still has to give up. */
|
|
56
|
+
const CHECK_TIMEOUT_MS = 4000;
|
|
57
|
+
|
|
58
|
+
/** "At most once a day" from the issue, in milliseconds. */
|
|
59
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
60
|
+
|
|
61
|
+
/** What `packaging/install.sh` leaves behind. The only record that route has. */
|
|
62
|
+
const RECEIPT = '.install-receipt.json';
|
|
63
|
+
|
|
64
|
+
const HINTS = {
|
|
65
|
+
brew: 'brew upgrade kronk-cli',
|
|
66
|
+
npm: 'npm install -g kronk-cli@latest',
|
|
67
|
+
git: 'git pull',
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// ---- install-route detection --------------------------------------------
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* How far up from the running file to look for a `.git` before giving up on
|
|
74
|
+
* calling this a checkout. `src/index.js` is one level under the repository
|
|
75
|
+
* root in every layout this project ships or is developed in; the extra
|
|
76
|
+
* headroom is for a checkout nested a few directories deeper than that.
|
|
77
|
+
*/
|
|
78
|
+
const GIT_SEARCH_DEPTH = 6;
|
|
79
|
+
|
|
80
|
+
function findGitRoot(startDir) {
|
|
81
|
+
let dir = startDir;
|
|
82
|
+
for (let i = 0; i < GIT_SEARCH_DEPTH; i++) {
|
|
83
|
+
if (existsSync(join(dir, '.git'))) return dir;
|
|
84
|
+
const parent = dirname(dir);
|
|
85
|
+
if (parent === dir) return null;
|
|
86
|
+
dir = parent;
|
|
87
|
+
}
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const toPosix = (p) => p.split(sep).join('/');
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Where this running copy of kronk-cli came from, read off the path Node
|
|
95
|
+
* resolved for `src/index.js` rather than asked of any registry — the issue's
|
|
96
|
+
* own framing: "detect the route from where the running src/index.js
|
|
97
|
+
* resolves". Each package manager lays its files out in a shape distinctive
|
|
98
|
+
* enough to tell apart without guessing:
|
|
99
|
+
*
|
|
100
|
+
* - Homebrew nests a formula's version under `Cellar/<name>/<version>`.
|
|
101
|
+
* - npm's global install nests every package under `lib/node_modules`.
|
|
102
|
+
* - `packaging/install.sh` has exactly one job — unpack straight into
|
|
103
|
+
* `$PREFIX/lib/kronk-cli` — and that shape is otherwise unclaimed.
|
|
104
|
+
* - A git checkout is the one route that carries its own `.git` upward
|
|
105
|
+
* from wherever the file sits, so it is checked by content rather than by
|
|
106
|
+
* path shape.
|
|
107
|
+
*
|
|
108
|
+
* Path shape alone is only suggestive, so the routes that can produce
|
|
109
|
+
* *evidence* are asked first, and the install script's receipt is evidence of
|
|
110
|
+
* exactly the same rank as a `.git` directory. That ordering is not a detail:
|
|
111
|
+
* `install.sh` defaults to `$PREFIX=$HOME/.local`, a great many people keep
|
|
112
|
+
* `$HOME` itself under version control for their dotfiles, and walking up
|
|
113
|
+
* from `~/.local/lib/kronk-cli/src` reaches `~/.git` well inside
|
|
114
|
+
* `GIT_SEARCH_DEPTH`. Asking for the `.git` first would tell every one of
|
|
115
|
+
* those people to run `git pull` to upgrade a tarball install — precisely the
|
|
116
|
+
* failure the issue means by "the hint must match how it was installed, or it
|
|
117
|
+
* is worse than nothing". With no receipt there is nothing to go on but the
|
|
118
|
+
* path, and then a `.git` ancestor is the better guess and wins.
|
|
119
|
+
*
|
|
120
|
+
* Returns null when none of the above matches — a shape this function was
|
|
121
|
+
* never taught, which is the honest answer, not a guess dressed as one.
|
|
122
|
+
*/
|
|
123
|
+
export function detectInstall(entryPath) {
|
|
124
|
+
const norm = toPosix(entryPath);
|
|
125
|
+
if (/\/Cellar\/kronk-cli\//.test(norm)) return { route: 'brew' };
|
|
126
|
+
if (/\/node_modules\/kronk-cli(\/|$)/.test(norm)) return { route: 'npm' };
|
|
127
|
+
|
|
128
|
+
const libDir = /\/lib\/kronk-cli\//.test(norm) ? dirname(dirname(entryPath)) : null;
|
|
129
|
+
if (libDir && existsSync(join(libDir, RECEIPT))) return { route: 'install-sh', dir: libDir };
|
|
130
|
+
|
|
131
|
+
const gitRoot = findGitRoot(dirname(entryPath));
|
|
132
|
+
if (gitRoot) return { route: 'git', dir: gitRoot };
|
|
133
|
+
|
|
134
|
+
if (libDir) return { route: 'install-sh', dir: libDir };
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ---- the cache ------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
function readCache(path) {
|
|
141
|
+
try {
|
|
142
|
+
const data = JSON.parse(readFileSync(path, 'utf8'));
|
|
143
|
+
return typeof data?.checkedAt === 'number' ? data : null;
|
|
144
|
+
} catch {
|
|
145
|
+
// Missing, unreadable, or not JSON — treated exactly like "never checked",
|
|
146
|
+
// which is what it functionally is. A corrupt cache must never crash the
|
|
147
|
+
// one thing on the startup path that is supposed to be purely cosmetic.
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function writeCache(path, data) {
|
|
153
|
+
try {
|
|
154
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
155
|
+
writeFileSync(path, JSON.stringify(data), 'utf8');
|
|
156
|
+
} catch {
|
|
157
|
+
// The cache is a convenience, not the check itself — losing this write
|
|
158
|
+
// only means the next session asks again instead of reading an answer.
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ---- semver, just enough of it --------------------------------------------
|
|
163
|
+
|
|
164
|
+
/** `a` and `b` as dotted numeric components; a leading `v` is not data. */
|
|
165
|
+
function isNewer(current, latest) {
|
|
166
|
+
if (!latest) return false;
|
|
167
|
+
const a = current.replace(/^v/, '').split('.').map(Number);
|
|
168
|
+
const b = latest.replace(/^v/, '').split('.').map(Number);
|
|
169
|
+
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
|
170
|
+
const x = a[i] ?? 0;
|
|
171
|
+
const y = b[i] ?? 0;
|
|
172
|
+
if (Number.isNaN(x) || Number.isNaN(y)) return false; // an unparseable
|
|
173
|
+
if (y > x) return true; // component is
|
|
174
|
+
if (y < x) return false; // "no opinion"
|
|
175
|
+
}
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ---- asking each manager ---------------------------------------------------
|
|
180
|
+
|
|
181
|
+
async function npmLatest() {
|
|
182
|
+
try {
|
|
183
|
+
const { stdout } = await exec('npm', ['view', 'kronk-cli', 'version']);
|
|
184
|
+
const v = stdout.trim();
|
|
185
|
+
return /^\d+\.\d+\.\d+/.test(v) ? v : null;
|
|
186
|
+
} catch {
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function brewLatest() {
|
|
192
|
+
try {
|
|
193
|
+
const { stdout } = await exec('brew', ['outdated', '--json']);
|
|
194
|
+
const data = JSON.parse(stdout);
|
|
195
|
+
// Absent means "not outdated", which is not a failure — most days this
|
|
196
|
+
// is the answer, and it renders exactly like "nothing to report".
|
|
197
|
+
return data.formulae?.find((f) => f.name === 'kronk-cli')?.current_version ?? null;
|
|
198
|
+
} catch {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* `git fetch --tags` is the one network call this module makes through a
|
|
205
|
+
* manager rather than being blocked outright — the issue asks for it by
|
|
206
|
+
* name ("though the fetch is a network call made through git and should
|
|
207
|
+
* follow the same off-switch as the rest"), which `refreshCliUpdate`'s
|
|
208
|
+
* `config.updateCheck` guard already covers before this is ever called.
|
|
209
|
+
*/
|
|
210
|
+
async function gitLatest(dir) {
|
|
211
|
+
try {
|
|
212
|
+
await exec('git', ['-C', dir, 'fetch', '--tags', '--quiet']);
|
|
213
|
+
const { stdout } = await exec('git', ['-C', dir, 'tag', '--sort=-v:refname']);
|
|
214
|
+
return stdout.split('\n').map((l) => l.trim()).find(Boolean) ?? null;
|
|
215
|
+
} catch {
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function latestForRoute(route, dir) {
|
|
221
|
+
if (route === 'brew') return brewLatest();
|
|
222
|
+
if (route === 'npm') return npmLatest();
|
|
223
|
+
if (route === 'git') return gitLatest(dir);
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* The install script leaves no record of where a copy came from unless it
|
|
229
|
+
* chose to write one — see `packaging/install.sh`'s receipt. Its presence is
|
|
230
|
+
* the only thing this route can honestly report: not a version comparison
|
|
231
|
+
* (there is no registry to ask), just a nudge to re-run the same script,
|
|
232
|
+
* exactly as the issue specifies: "without claiming to know whether a newer
|
|
233
|
+
* release exists". No cache, no subprocess — a receipt is either sitting
|
|
234
|
+
* next to this install or it is not.
|
|
235
|
+
*/
|
|
236
|
+
function installScriptHint(install) {
|
|
237
|
+
if (!existsSync(join(install.dir, RECEIPT))) return null;
|
|
238
|
+
return { current: VERSION, hint: 're-run the install script' };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Ask the detected manager what the newest kronk-cli is, and cache the
|
|
243
|
+
* answer. Exported so a test can await it directly; `updateStatus` below
|
|
244
|
+
* fires it without awaiting, which is the shape the startup path actually
|
|
245
|
+
* uses.
|
|
246
|
+
*/
|
|
247
|
+
export async function refreshCliUpdate({ entryPath = process.argv[1], install, cacheFile = config.updateCacheFile } = {}) {
|
|
248
|
+
const found = install ?? detectInstall(entryPath);
|
|
249
|
+
const cache = { checkedAt: Date.now(), cli: null };
|
|
250
|
+
if (found && found.route !== 'install-sh') {
|
|
251
|
+
// A git tag is conventionally `v0.8.1`; npm and Homebrew both answer in
|
|
252
|
+
// bare `0.8.1`. Stripped here, once, so the banner's arrow reads as one
|
|
253
|
+
// version pointing at another and not at a spelling difference.
|
|
254
|
+
const latest = (await latestForRoute(found.route, found.dir))?.replace(/^v/, '') ?? null;
|
|
255
|
+
if (isNewer(VERSION, latest)) {
|
|
256
|
+
cache.cli = { current: VERSION, latest, hint: HINTS[found.route] };
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
writeCache(cacheFile, cache);
|
|
260
|
+
return cache.cli;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* What the startup banner should say about kronk-cli's own version, decided
|
|
265
|
+
* without ever making the REPL wait for it.
|
|
266
|
+
*
|
|
267
|
+
* `KRONK_UPDATE_CHECK=false` (or the matching config key) turns the whole
|
|
268
|
+
* thing off: no cache read, no subprocess, ever — for anyone who wants a
|
|
269
|
+
* program that runs nothing it was not asked to run.
|
|
270
|
+
*/
|
|
271
|
+
export function updateStatus({
|
|
272
|
+
entryPath = process.argv[1], now = Date.now(), cacheFile = config.updateCacheFile,
|
|
273
|
+
} = {}) {
|
|
274
|
+
if (!config.updateCheck) return null;
|
|
275
|
+
|
|
276
|
+
const install = detectInstall(entryPath);
|
|
277
|
+
if (!install) return null;
|
|
278
|
+
|
|
279
|
+
if (install.route === 'install-sh') {
|
|
280
|
+
const hint = installScriptHint(install);
|
|
281
|
+
return hint ? { cli: hint } : null;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const cache = readCache(cacheFile);
|
|
285
|
+
const stale = !cache || now - cache.checkedAt >= DAY_MS;
|
|
286
|
+
if (stale) {
|
|
287
|
+
// Next session's answer, not this one's wait.
|
|
288
|
+
refreshCliUpdate({ entryPath, install, cacheFile }).catch(() => {});
|
|
289
|
+
}
|
|
290
|
+
return cache?.cli ? { cli: cache.cli } : null;
|
|
291
|
+
}
|