kronk-cli 0.5.1 → 0.6.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 +53 -1
- package/package.json +1 -1
- package/src/agent.js +6 -0
- package/src/config.js +7 -0
- package/src/index.js +19 -0
- package/src/subagent.js +85 -31
- package/src/tasklog.js +274 -0
- package/src/ui.js +5 -0
package/README.md
CHANGED
|
@@ -681,6 +681,7 @@ Two consequences worth knowing:
|
|
|
681
681
|
| `/thinking` | show or hide the model's reasoning |
|
|
682
682
|
| `/think` | turn reasoning off entirely — much faster |
|
|
683
683
|
| `/agents` | the sub-agents `task` can delegate to, and what each may touch |
|
|
684
|
+
| `/tasks [n]` | sub-agent runs this session; with `n`, that one in full |
|
|
684
685
|
| `/mcp` | list attached MCP servers and their tools |
|
|
685
686
|
| `/context` | how much of the context window is used |
|
|
686
687
|
| `/theme [dark\|light]` | show or pin the palette, for when the terminal was read wrong |
|
|
@@ -714,6 +715,8 @@ Two consequences worth knowing:
|
|
|
714
715
|
| `KRONK_SUBAGENTS` | `true` | `false` removes the `task` tool |
|
|
715
716
|
| `KRONK_SUBAGENT_MODEL` | the main model | Model sub-agents run on — substring match, same as `KRONK_MODEL` |
|
|
716
717
|
| `KRONK_SUBAGENT_STEPS` | `40` | Tool-call cap for one delegated task |
|
|
718
|
+
| `KRONK_TASK_LOG` | `true` | `false` stops writing sub-agent transcripts, and `/tasks` says so |
|
|
719
|
+
| `KRONK_TASK_LOG_DIR` | the temp dir | Where transcripts are written |
|
|
717
720
|
| `KRONK_WARM` | `true` | `false` skips the boot-time model preload |
|
|
718
721
|
| `KRONK_AUTO_COMPACT` | `true` | `false` disables automatic compaction |
|
|
719
722
|
| `KRONK_COMPACT_AT` | `0.85` | Fraction of the window that triggers compaction |
|
|
@@ -1239,6 +1242,7 @@ ever comes back is its report.
|
|
|
1239
1242
|
› which tools in this repo need approval, and where is that decided?
|
|
1240
1243
|
|
|
1241
1244
|
1 ⚙ task explore: list every tool definition in src/, say which are gated and where
|
|
1245
|
+
┌ explore · list every tool definition in src/, say which are gated…
|
|
1242
1246
|
│ 1/40 ⚙ search /def\(/ in src
|
|
1243
1247
|
│ ✓ 9 lines
|
|
1244
1248
|
│ 2/40 ⚙ read src/tools.js
|
|
@@ -1249,13 +1253,15 @@ ever comes back is its report.
|
|
|
1249
1253
|
Six built-in tools. write_file and bash are gated, by NEEDS_APPROVAL at
|
|
1250
1254
|
src/tools.js:261. MCP tools are gated by name, mcpNeedsApproval at :270.
|
|
1251
1255
|
read_file, list_dir, search, set_plan and task are not gated.
|
|
1256
|
+
└ 3 steps · 11.4s · /tmp/kronk-cli/mfa1k2-8817/task-001.json
|
|
1252
1257
|
✓ 3 lines
|
|
1253
1258
|
|
|
1254
1259
|
`write_file` and `bash`, from the `NEEDS_APPROVAL` set in src/tools.js:246…
|
|
1255
1260
|
```
|
|
1256
1261
|
|
|
1257
1262
|
`│` marks the sub-agent's own loop; the unprefixed lines are its report, which is what the main
|
|
1258
|
-
agent receives as the tool result.
|
|
1263
|
+
agent receives as the tool result. `┌` and `└` fence one run, so two in a row are told apart in
|
|
1264
|
+
scrollback, and the closing line says what it cost and where the rest of it went.
|
|
1259
1265
|
|
|
1260
1266
|
Three files were read. None of them is in your conversation: the 800-odd lines they cost were
|
|
1261
1267
|
spent in a context that no longer exists, and all that is left in your window is the four-line
|
|
@@ -1265,6 +1271,51 @@ command output, one level up.
|
|
|
1265
1271
|
Both agents run against the same Kronk server, so nothing leaves the machine that was not
|
|
1266
1272
|
already leaving it.
|
|
1267
1273
|
|
|
1274
|
+
### Reading a run back
|
|
1275
|
+
|
|
1276
|
+
The report on screen is dimmed and capped at fourteen lines. That used to be the end of it: what
|
|
1277
|
+
the sub-agent read, what it ran, and any line fifteen of its report went out of scope with its
|
|
1278
|
+
context. Delegating is supposed to cost the *conversation* nothing — it was never supposed to
|
|
1279
|
+
cost you the ability to check.
|
|
1280
|
+
|
|
1281
|
+
So each run is written to a file as it goes, and `/tasks` reads them back.
|
|
1282
|
+
|
|
1283
|
+
```console
|
|
1284
|
+
› /tasks
|
|
1285
|
+
|
|
1286
|
+
● 1 explore 6 steps · 11.4s list every tool definition in src/, say whi…
|
|
1287
|
+
● 2 code 23 steps · 1m4s reproduce the compaction failure in test/co…
|
|
1288
|
+
● 3 explore 2 steps · stopped trace where the sandbox root is decided
|
|
1289
|
+
|
|
1290
|
+
/tasks <n> for one in full · /tmp/kronk-cli/mfa1k2-8817
|
|
1291
|
+
```
|
|
1292
|
+
|
|
1293
|
+
Green finished, yellow stopped before it reported, blue still running.
|
|
1294
|
+
|
|
1295
|
+
`/tasks 2` prints that run whole: the prompt as it was delegated, every tool call with its
|
|
1296
|
+
arguments, every result in full, and the untruncated report. Nothing is cut — the bounded report
|
|
1297
|
+
is what sent you looking, so a second ceiling would only move the wall.
|
|
1298
|
+
|
|
1299
|
+
It is **printed, not re-injected**. Looking at what a sub-agent did costs the window nothing,
|
|
1300
|
+
which is what keeps the trade above intact.
|
|
1301
|
+
|
|
1302
|
+
One thing it is not: a record of what the sub-agent's tools *returned*. A transcript is the
|
|
1303
|
+
sub-agent's own context, and [distillation](#distillation) rewrites a large tool result before it
|
|
1304
|
+
enters that context — so a `read_file` that came back as 24k of source appears here as the
|
|
1305
|
+
summary the sub-agent actually worked from, marked as distilled. That is the honest rendering:
|
|
1306
|
+
the raw text was never in its conversation either, and the line says so.
|
|
1307
|
+
|
|
1308
|
+
Run 3 is the case this exists for. A run that was interrupted, that threw, or that ran out of
|
|
1309
|
+
steps never reaches a tidy end at which to write a transcript, so the file is rewritten after
|
|
1310
|
+
every step instead of once at the finish, and the record says how it ended rather than only that
|
|
1311
|
+
it did. The run that went wrong is the one you want to read.
|
|
1312
|
+
|
|
1313
|
+
Transcripts live in the system temp directory, one directory per session, `0700` with `0600`
|
|
1314
|
+
files — they hold your project's file contents and command output. A clean exit removes the
|
|
1315
|
+
session's own; startup sweeps anything older than a day, which is what catches the sessions a
|
|
1316
|
+
crash left behind. Fifty runs are kept per session, oldest dropped first, so a long autonomous
|
|
1317
|
+
run cannot fill the disk. `KRONK_TASK_LOG=false` writes nothing at all.
|
|
1318
|
+
|
|
1268
1319
|
### It is not parallelism
|
|
1269
1320
|
|
|
1270
1321
|
Kronk holds one resident copy of the model, so two sub-agents at once queue behind each other
|
|
@@ -1334,6 +1385,7 @@ earning it.
|
|
|
1334
1385
|
kronk-cli --no-subagents # remove the task tool entirely
|
|
1335
1386
|
KRONK_SUBAGENT_MODEL=<id> kronk-cli # run the grunt work on a different model
|
|
1336
1387
|
KRONK_SUBAGENT_STEPS=80 kronk-cli # a longer leash per task
|
|
1388
|
+
KRONK_TASK_LOG=false kronk-cli # keep no transcripts of what sub-agents did
|
|
1337
1389
|
```
|
|
1338
1390
|
|
|
1339
1391
|
A sub-agent gets the same startup scan of your project the main agent got, so it starts knowing
|
package/package.json
CHANGED
package/src/agent.js
CHANGED
|
@@ -89,6 +89,11 @@ function endTurn(messages, { out = console.log, plan = true } = {}) {
|
|
|
89
89
|
export async function runTurn({
|
|
90
90
|
messages, model, signal, approve, grant, mcp, auto = false, maxSteps = config.maxSteps,
|
|
91
91
|
tools: toolset = TOOLS, plan: usePlan = true, out = console.log, stream = true, depth = 0,
|
|
92
|
+
// Called with the transcript so far after every round. Only src/subagent.js
|
|
93
|
+
// passes one: a delegated run is the transcript nobody would otherwise keep,
|
|
94
|
+
// and the round that ends it may be a throw or a step cap rather than a
|
|
95
|
+
// return, so it has to be written as it happens.
|
|
96
|
+
onStep = null,
|
|
92
97
|
// The window and the output cap belong to `model`, not to the session: a
|
|
93
98
|
// sub-agent may be running on a different model with a different profile.
|
|
94
99
|
window = config.contextWindow, maxTokens = config.maxTokens,
|
|
@@ -368,6 +373,7 @@ export async function runTurn({
|
|
|
368
373
|
// On the last tool result rather than in a message of its own, so the
|
|
369
374
|
// round only ever adds to the prompt. Nothing already sent is touched.
|
|
370
375
|
if (usePlan) carryChecklist(messages);
|
|
376
|
+
onStep?.(messages, step);
|
|
371
377
|
// loop: the model now sees the tool output
|
|
372
378
|
}
|
|
373
379
|
}
|
package/src/config.js
CHANGED
|
@@ -79,6 +79,13 @@ export const config = {
|
|
|
79
79
|
subagents: (process.env.KRONK_SUBAGENTS ?? String(file.subagents ?? 'true')) !== 'false',
|
|
80
80
|
subagentModel: process.env.KRONK_SUBAGENT_MODEL ?? file.subagentModel ?? null,
|
|
81
81
|
subagentSteps: Number(process.env.KRONK_SUBAGENT_STEPS ?? file.subagentSteps ?? 40),
|
|
82
|
+
// Each sub-agent run is written to a file as it goes, so `/tasks` can show
|
|
83
|
+
// what a delegated task actually did — see src/tasklog.js. It costs a few
|
|
84
|
+
// small files under the temp dir and nothing in the window: a transcript is
|
|
85
|
+
// never read back into a conversation, only printed.
|
|
86
|
+
taskLog: (process.env.KRONK_TASK_LOG ?? String(file.taskLog ?? 'true')) !== 'false',
|
|
87
|
+
// Same reason as modelConfigPath: so tests never go near the real one.
|
|
88
|
+
taskLogDir: process.env.KRONK_TASK_LOG_DIR ?? file.taskLogDir ?? null,
|
|
82
89
|
// Kronk's per-model runtime settings. `setup` is the only thing that writes
|
|
83
90
|
// it; the override exists so tests never go near the real one.
|
|
84
91
|
modelConfigPath: process.env.KRONK_MODEL_CONFIG ?? file.modelConfigPath ?? MODEL_CONFIG,
|
package/src/index.js
CHANGED
|
@@ -16,6 +16,7 @@ import { resolveSandbox, sandbox } from './tools.js';
|
|
|
16
16
|
import { parseArgv } from './argv.js';
|
|
17
17
|
import { deferredPrompt } from './prompt.js';
|
|
18
18
|
import { AGENTS } from './subagent.js';
|
|
19
|
+
import { runLines, transcriptLines, sweep, cleanup } from './tasklog.js';
|
|
19
20
|
import { runSetup } from './setup.js';
|
|
20
21
|
|
|
21
22
|
// ---- argv -------------------------------------------------------------
|
|
@@ -290,6 +291,7 @@ const HELP = `
|
|
|
290
291
|
${c.bold('/auto')} autonomous mode: auto-approve tools, run to completion
|
|
291
292
|
${c.bold('/steps [n|off]')} cap tool calls per task (default: unlimited)
|
|
292
293
|
${c.bold('/agents')} sub-agents you can delegate to with the task tool
|
|
294
|
+
${c.bold('/tasks [n]')} sub-agent runs this session; with n, one in full
|
|
293
295
|
${c.bold('/mcp')} list attached MCP servers and their tools
|
|
294
296
|
${c.bold('/context')} how much of the context window is used
|
|
295
297
|
${c.bold('/theme [d|l]')} dark or light palette, for when the terminal guessed wrong
|
|
@@ -392,6 +394,11 @@ async function main() {
|
|
|
392
394
|
// Before anything reaches the network, on every path through the program.
|
|
393
395
|
warnIfInsecure();
|
|
394
396
|
|
|
397
|
+
// At startup, not only at exit: the transcripts worth sweeping are the ones
|
|
398
|
+
// a crash left behind, and the exit path that would have removed them is the
|
|
399
|
+
// path that did not run.
|
|
400
|
+
sweep();
|
|
401
|
+
|
|
395
402
|
// Subcommands are dispatched before the one-shot path below, so `setup` is
|
|
396
403
|
// never mistaken for a one-word prompt and sent to the model.
|
|
397
404
|
if (args.words[0] === 'setup') {
|
|
@@ -588,6 +595,14 @@ async function main() {
|
|
|
588
595
|
continue;
|
|
589
596
|
}
|
|
590
597
|
if (input === '/models') { await showModels(); continue; }
|
|
598
|
+
if (input === '/tasks' || input.startsWith('/tasks ')) {
|
|
599
|
+
const raw = input.split(/\s+/)[1];
|
|
600
|
+
// Printed, never pushed into `messages`. Reading what a sub-agent did
|
|
601
|
+
// must not cost the window the delegation just saved.
|
|
602
|
+
const lines = raw === undefined ? runLines() : transcriptLines(Number(raw));
|
|
603
|
+
lines.forEach((l) => console.log(l));
|
|
604
|
+
continue;
|
|
605
|
+
}
|
|
591
606
|
if (input === '/mcp') {
|
|
592
607
|
if (!mcp || !mcp.routes.size) { console.log(c.grey(' no MCP servers attached — start with --mcp')); continue; }
|
|
593
608
|
for (const [name, server] of mcp.servers) {
|
|
@@ -659,6 +674,10 @@ async function main() {
|
|
|
659
674
|
|
|
660
675
|
rl.close();
|
|
661
676
|
mcp?.close();
|
|
677
|
+
// Transcripts are disposable by construction; a session that ends tidily
|
|
678
|
+
// takes its own with it. `sweep` at startup is what catches the ones a crash
|
|
679
|
+
// left behind, because this line is exactly what a crash skips.
|
|
680
|
+
cleanup();
|
|
662
681
|
console.log(c.grey(' bye'));
|
|
663
682
|
}
|
|
664
683
|
|
package/src/subagent.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { def, TOOLS } from './tools.js';
|
|
2
2
|
import { config } from './config.js';
|
|
3
|
-
import { c } from './ui.js';
|
|
3
|
+
import { c, elapsed } from './ui.js';
|
|
4
|
+
import { startRun } from './tasklog.js';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Sub-agents: one delegated task, one throwaway context.
|
|
@@ -129,6 +130,39 @@ export function systemFor(agent) {
|
|
|
129
130
|
const nest = (line) => String(line).split('\n')
|
|
130
131
|
.map((l) => `${c.grey(' │')} ${l}`).join('\n');
|
|
131
132
|
|
|
133
|
+
/** How much of the prompt fits on the opening line. */
|
|
134
|
+
const GIST = 60;
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The line that says a run has started.
|
|
138
|
+
*
|
|
139
|
+
* Without it a delegated run is an unlabelled block of nested tool calls, and
|
|
140
|
+
* two of them in a row are indistinguishable in scrollback. The gist is the
|
|
141
|
+
* prompt's first line, cut: enough to recognise which task this was, never
|
|
142
|
+
* enough to be worth reading instead of the report.
|
|
143
|
+
*/
|
|
144
|
+
export function openLine(agent, prompt) {
|
|
145
|
+
const first = String(prompt).split('\n')[0].trim();
|
|
146
|
+
const gist = first.length > GIST ? `${first.slice(0, GIST - 1)}…` : first;
|
|
147
|
+
return `${c.grey(' ┌')} ${c.bold(agent)} ${c.grey(`· ${gist}`)}`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The line that closes one, and the only place the transcript is advertised.
|
|
152
|
+
*
|
|
153
|
+
* This is what makes `reportLines`' "…N more lines" actionable: the report on
|
|
154
|
+
* 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.
|
|
156
|
+
*/
|
|
157
|
+
export function closeLine({ ms, steps, path, ok }) {
|
|
158
|
+
// A run that threw or was interrupted is the one worth reading, so the line
|
|
159
|
+
// that points at its transcript must not read like a clean finish.
|
|
160
|
+
const bits = [`${steps} step${steps === 1 ? '' : 's'}`, elapsed(ms), ...(ok ? [] : ['stopped'])];
|
|
161
|
+
if (path) bits.push(path);
|
|
162
|
+
const line = ` └ ${bits.join(' · ')}`;
|
|
163
|
+
return ok ? c.grey(line) : c.yellow(line);
|
|
164
|
+
}
|
|
165
|
+
|
|
132
166
|
const MAX_REPORT_LINES = 14;
|
|
133
167
|
|
|
134
168
|
/** The report on screen, dimmed and bounded — the caller received all of it. */
|
|
@@ -185,34 +219,54 @@ export async function runTask(args, {
|
|
|
185
219
|
// means never compacting it at all.
|
|
186
220
|
const own = config.subagentModel ? config.subagentLimits : null;
|
|
187
221
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
222
|
+
// Recorded from here, not from the first step: a run that dies in its first
|
|
223
|
+
// model call still leaves a file saying which task was asked for.
|
|
224
|
+
const log = startRun({ agent, prompt, model: config.subagentModel ?? model });
|
|
225
|
+
const started = Date.now();
|
|
226
|
+
out(openLine(agent, prompt));
|
|
227
|
+
|
|
228
|
+
let steps = 0;
|
|
229
|
+
let report;
|
|
230
|
+
try {
|
|
231
|
+
const done = await run({
|
|
232
|
+
messages,
|
|
233
|
+
// A smaller model for the grunt work is the whole reason this is a knob:
|
|
234
|
+
// the survey is reading and grepping, the synthesis is not.
|
|
235
|
+
model: config.subagentModel ?? model,
|
|
236
|
+
window: own ? own.contextWindow : config.contextWindow,
|
|
237
|
+
maxTokens: own?.maxTokens ?? config.maxTokens,
|
|
238
|
+
signal,
|
|
239
|
+
approve,
|
|
240
|
+
grant,
|
|
241
|
+
// No MCP: a local model already struggles past ~25 tools, and the
|
|
242
|
+
// sub-agent's list is meant to be the short one.
|
|
243
|
+
mcp: null,
|
|
244
|
+
tools: subagentTools(agent),
|
|
245
|
+
// The checklist is module state belonging to the task the user asked for.
|
|
246
|
+
// A sub-agent that called set_plan would clear it out from under the agent
|
|
247
|
+
// that delegated to it.
|
|
248
|
+
plan: false,
|
|
249
|
+
// Its reasoning and prose are not printed: they are not the answer, and a
|
|
250
|
+
// second stream interleaved with the caller's is unreadable. Tool calls
|
|
251
|
+
// still show, indented, so the run is not a black box.
|
|
252
|
+
stream: false,
|
|
253
|
+
out: (line) => out(nest(line)),
|
|
254
|
+
maxSteps,
|
|
255
|
+
depth: 1,
|
|
256
|
+
// Written as it goes rather than at the end. The runs worth reading are
|
|
257
|
+
// the ones that were interrupted, threw, or ran out of steps, and none
|
|
258
|
+
// of those reach the line below.
|
|
259
|
+
onStep: (msgs, n) => { steps = n; log?.step(msgs, n); },
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
report = reportFrom(done);
|
|
263
|
+
reportLines(report).forEach((l) => out(l));
|
|
264
|
+
return report;
|
|
265
|
+
} finally {
|
|
266
|
+
// In `finally` so an abort or a throw still closes the record and still
|
|
267
|
+
// tells the user where the partial transcript is — that is the case the
|
|
268
|
+
// transcript exists for.
|
|
269
|
+
const path = log?.finish({ report, steps });
|
|
270
|
+
out(closeLine({ ms: Date.now() - started, steps, path, ok: report !== undefined }));
|
|
271
|
+
}
|
|
218
272
|
}
|
package/src/tasklog.js
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync, readFileSync, readdirSync, rmSync, statSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { config } from './config.js';
|
|
5
|
+
import { c, elapsed } from './ui.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Sub-agent transcripts: the one part of a session you could not go back to.
|
|
9
|
+
*
|
|
10
|
+
* src/subagent.js is built on throwing context away, and it should be — a
|
|
11
|
+
* survey that reads thirty files costs the conversation one report instead of
|
|
12
|
+
* thirty files. But "the caller's window must not hold it" and "the user may
|
|
13
|
+
* never see it" are two different claims, and the code used to make both:
|
|
14
|
+
* `runTask` took the last assistant message and let the rest go out of scope.
|
|
15
|
+
*
|
|
16
|
+
* So the transcript goes to a file instead of the window. Nothing here is ever
|
|
17
|
+
* read back into a conversation on its own — `/tasks` prints, it does not
|
|
18
|
+
* re-inject — which is what keeps the context economy intact. The user gets to
|
|
19
|
+
* look; the model still pays one report.
|
|
20
|
+
*
|
|
21
|
+
* Temp, not $HOME. These are disposable by construction: a transcript is
|
|
22
|
+
* interesting for as long as you are still wondering what that sub-agent did,
|
|
23
|
+
* which is the session it ran in and maybe the morning after. Somewhere that
|
|
24
|
+
* gets swept is the honest place for that, and it needs no new dotfile.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** Everything this module writes lives under one directory, so cleanup is one call. */
|
|
28
|
+
const ROOT = () => config.taskLogDir ?? join(tmpdir(), 'kronk-cli');
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* One directory per session, named so two sessions cannot collide and a stale
|
|
32
|
+
* one can be aged out by its own timestamp rather than by stat'ing it.
|
|
33
|
+
*/
|
|
34
|
+
const SESSION = `${Date.now().toString(36)}-${process.pid}`;
|
|
35
|
+
|
|
36
|
+
/** Sessions older than this are swept at startup. */
|
|
37
|
+
const KEEP_MS = 24 * 60 * 60 * 1000;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Runs kept per session.
|
|
41
|
+
*
|
|
42
|
+
* An autonomous run that delegates forty times should not quietly fill the
|
|
43
|
+
* temp dir with forty 40-step transcripts. The oldest go first: the run you
|
|
44
|
+
* want to read is almost always a recent one, and the alternative — refusing
|
|
45
|
+
* to write past the cap — loses exactly the transcripts most likely to matter.
|
|
46
|
+
*/
|
|
47
|
+
const MAX_RUNS = 50;
|
|
48
|
+
|
|
49
|
+
let counter = 0;
|
|
50
|
+
|
|
51
|
+
/** Where this session's transcripts go. Created on first write, not at import. */
|
|
52
|
+
export const sessionDir = () => join(ROOT(), SESSION);
|
|
53
|
+
|
|
54
|
+
const file = (n) => join(sessionDir(), `task-${String(n).padStart(3, '0')}.json`);
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 0700 and 0600 throughout.
|
|
58
|
+
*
|
|
59
|
+
* A transcript holds file contents and command output from the user's project,
|
|
60
|
+
* which is the same material `~/.kronk-cli.json` guards, in a directory that on
|
|
61
|
+
* a shared machine is world-readable by default.
|
|
62
|
+
*/
|
|
63
|
+
function ensureDir() {
|
|
64
|
+
mkdirSync(sessionDir(), { recursive: true, mode: 0o700 });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Drop the oldest transcripts once the cap is exceeded. */
|
|
68
|
+
function trim() {
|
|
69
|
+
const files = readdirSync(sessionDir()).filter((f) => /^task-\d+\.json$/.test(f)).sort();
|
|
70
|
+
for (const f of files.slice(0, Math.max(0, files.length - MAX_RUNS))) {
|
|
71
|
+
try { rmSync(join(sessionDir(), f)); } catch { /* already gone */ }
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* A run in progress.
|
|
77
|
+
*
|
|
78
|
+
* `step` is called as the sub-agent works and rewrites the whole record, which
|
|
79
|
+
* is the point: a run that is interrupted, that throws, or that hits its step
|
|
80
|
+
* cap is exactly the run whose transcript is worth having, and it never reaches
|
|
81
|
+
* a tidy end at which to write one. Rewriting a small JSON file a few dozen
|
|
82
|
+
* times costs nothing next to the model call that produced each step.
|
|
83
|
+
*/
|
|
84
|
+
class Run {
|
|
85
|
+
constructor(n, meta) {
|
|
86
|
+
this.n = n;
|
|
87
|
+
this.path = file(n);
|
|
88
|
+
this.record = { n, ...meta, startedAt: new Date().toISOString(), endedAt: null, state: 'running', steps: 0, messages: [], report: null };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Never throw into the sub-agent: a full disk must not fail a delegated task. */
|
|
92
|
+
#flush() {
|
|
93
|
+
try {
|
|
94
|
+
ensureDir();
|
|
95
|
+
writeFileSync(this.path, `${JSON.stringify(this.record, null, 2)}\n`, { mode: 0o600 });
|
|
96
|
+
} catch { /* the transcript is a convenience, not the work */ }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
step(messages, steps) {
|
|
100
|
+
this.record.messages = messages;
|
|
101
|
+
this.record.steps = steps;
|
|
102
|
+
this.#flush();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
finish({ report, steps }) {
|
|
106
|
+
this.record.endedAt = new Date().toISOString();
|
|
107
|
+
// No report means the run threw or was interrupted: runTask closes the
|
|
108
|
+
// record from a `finally`, so this is the only signal that it did not end
|
|
109
|
+
// on its own terms.
|
|
110
|
+
this.record.state = report === undefined ? 'stopped' : 'done';
|
|
111
|
+
if (report !== undefined) this.record.report = report;
|
|
112
|
+
if (steps !== undefined) this.record.steps = steps;
|
|
113
|
+
this.#flush();
|
|
114
|
+
try { trim(); } catch { /* nothing worth reporting */ }
|
|
115
|
+
return this.path;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Begin recording one delegated task, or don't.
|
|
121
|
+
*
|
|
122
|
+
* Returns null when logging is off, and every caller is written to treat that
|
|
123
|
+
* as ordinary rather than as an error — the feature switching off should not
|
|
124
|
+
* put a branch in the middle of the delegation path.
|
|
125
|
+
*/
|
|
126
|
+
export function startRun(meta) {
|
|
127
|
+
if (!config.taskLog) return null;
|
|
128
|
+
counter += 1;
|
|
129
|
+
const run = new Run(counter, meta);
|
|
130
|
+
run.step([], 0);
|
|
131
|
+
return run;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Every run this session recorded, oldest first. Summaries only — not the messages. */
|
|
135
|
+
export function listRuns() {
|
|
136
|
+
let names;
|
|
137
|
+
try { names = readdirSync(sessionDir()); } catch { return []; }
|
|
138
|
+
return names.filter((f) => /^task-\d+\.json$/.test(f)).sort()
|
|
139
|
+
.map((f) => {
|
|
140
|
+
try {
|
|
141
|
+
const r = JSON.parse(readFileSync(join(sessionDir(), f), 'utf8'));
|
|
142
|
+
return {
|
|
143
|
+
n: r.n,
|
|
144
|
+
agent: r.agent,
|
|
145
|
+
prompt: r.prompt ?? '',
|
|
146
|
+
model: r.model ?? null,
|
|
147
|
+
steps: r.steps ?? 0,
|
|
148
|
+
ms: r.endedAt ? Date.parse(r.endedAt) - Date.parse(r.startedAt) : null,
|
|
149
|
+
state: r.state ?? (r.endedAt ? 'done' : 'running'),
|
|
150
|
+
path: join(sessionDir(), f),
|
|
151
|
+
};
|
|
152
|
+
} catch { return null; } // a half-written file is not a crash
|
|
153
|
+
})
|
|
154
|
+
.filter(Boolean);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** One run in full, or null if there is no such run. */
|
|
158
|
+
export function readRun(n) {
|
|
159
|
+
try { return JSON.parse(readFileSync(file(n), 'utf8')); }
|
|
160
|
+
catch { return null; }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Remove this session's transcripts, and any session that has aged out.
|
|
165
|
+
*
|
|
166
|
+
* Called at startup rather than only at exit, because the transcripts worth
|
|
167
|
+
* sweeping are precisely the ones a crash left behind — the exit path that
|
|
168
|
+
* would have cleaned them is the path that did not run.
|
|
169
|
+
*/
|
|
170
|
+
export function sweep({ all = false } = {}) {
|
|
171
|
+
const root = ROOT();
|
|
172
|
+
let dirs;
|
|
173
|
+
try { dirs = readdirSync(root); } catch { return 0; }
|
|
174
|
+
let removed = 0;
|
|
175
|
+
for (const d of dirs) {
|
|
176
|
+
const full = join(root, d);
|
|
177
|
+
if (!all) {
|
|
178
|
+
if (d === SESSION) continue;
|
|
179
|
+
try { if (Date.now() - statSync(full).mtimeMs < KEEP_MS) continue; } catch { continue; }
|
|
180
|
+
}
|
|
181
|
+
try { rmSync(full, { recursive: true, force: true }); removed += 1; } catch { /* in use */ }
|
|
182
|
+
}
|
|
183
|
+
return removed;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Drop this session's own directory on a clean exit. */
|
|
187
|
+
export function cleanup() {
|
|
188
|
+
try { rmSync(sessionDir(), { recursive: true, force: true }); } catch { /* nothing to do */ }
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** `4.2s`, or an em dash for a run with no end time yet. */
|
|
192
|
+
const took = (ms) => (ms === null ? '\u2014' : elapsed(ms));
|
|
193
|
+
|
|
194
|
+
const gist = (prompt, width) => {
|
|
195
|
+
const first = String(prompt).split('\n')[0].trim();
|
|
196
|
+
return first.length > width ? `${first.slice(0, width - 1)}\u2026` : first;
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* `/tasks` — one line per run this session.
|
|
201
|
+
*
|
|
202
|
+
* Numbered, because the number is how the next command names one. A run that
|
|
203
|
+
* never finished says so rather than reporting a duration it does not have:
|
|
204
|
+
* "still running" and "died three steps in" are the two states where the
|
|
205
|
+
* transcript is worth the most, and both would otherwise render as a blank.
|
|
206
|
+
*/
|
|
207
|
+
export function runLines() {
|
|
208
|
+
if (!config.taskLog) {
|
|
209
|
+
return [c.grey(' transcripts are off \u2014 drop KRONK_TASK_LOG=false')];
|
|
210
|
+
}
|
|
211
|
+
const runs = listRuns();
|
|
212
|
+
if (!runs.length) {
|
|
213
|
+
return [c.grey(' no sub-agent runs yet \u2014 the model starts one with the task tool')];
|
|
214
|
+
}
|
|
215
|
+
const lines = runs.map((r) => {
|
|
216
|
+
const how = { done: took(r.ms), stopped: 'stopped', running: 'running' }[r.state] ?? took(r.ms);
|
|
217
|
+
const state = `${r.steps} step${r.steps === 1 ? '' : 's'} \u00b7 ${how}`.padEnd(20);
|
|
218
|
+
const dot = { done: c.green('\u25cf'), stopped: c.yellow('\u25cf'), running: c.blue('\u25cf') }[r.state] ?? c.grey('\u25cf');
|
|
219
|
+
return ` ${dot} ${c.bold(String(r.n).padStart(2))} ${r.agent.padEnd(7)} ${c.grey(state)} ${c.grey(gist(r.prompt, 44))}`;
|
|
220
|
+
});
|
|
221
|
+
lines.push(c.grey(`\n /tasks <n> for one in full \u00b7 ${sessionDir()}`));
|
|
222
|
+
return lines;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* `/tasks <n>` — one run, whole.
|
|
227
|
+
*
|
|
228
|
+
* Nothing is cut here, and that is the point: the bounded report on screen is
|
|
229
|
+
* what sent the user looking, so a second ceiling would only move the wall.
|
|
230
|
+
* This is printed, never pushed back into the conversation — looking at what a
|
|
231
|
+
* sub-agent did must not cost the window the delegation just saved.
|
|
232
|
+
*/
|
|
233
|
+
export function transcriptLines(n) {
|
|
234
|
+
const r = readRun(n);
|
|
235
|
+
if (!r) return [c.red(` no run ${n} \u2014 /tasks to list them`)];
|
|
236
|
+
|
|
237
|
+
const ms = r.endedAt ? Date.parse(r.endedAt) - Date.parse(r.startedAt) : null;
|
|
238
|
+
const out = [
|
|
239
|
+
'',
|
|
240
|
+
` ${c.bold(`task ${r.n}`)} ${c.grey(`\u00b7 ${r.agent} \u00b7 ${r.model ?? 'session model'} \u00b7 ${r.steps} step${r.steps === 1 ? '' : 's'} \u00b7 ${took(ms)}${r.state === 'done' ? '' : ` \u00b7 ${r.state}`}`)}`,
|
|
241
|
+
'',
|
|
242
|
+
c.grey(' \u2500\u2500 prompt \u2500\u2500'),
|
|
243
|
+
...String(r.prompt ?? '').split('\n').map((l) => ` ${l}`),
|
|
244
|
+
];
|
|
245
|
+
|
|
246
|
+
// The system prompt is the contract in src/subagent.js and the same every
|
|
247
|
+
// time; the user message is the prompt already printed above.
|
|
248
|
+
const body = (r.messages ?? []).slice(2);
|
|
249
|
+
// The last assistant message *is* the report — `reportFrom` takes it. It gets
|
|
250
|
+
// its own heading below, so printing it here too would only be twice.
|
|
251
|
+
if (r.report != null && body.at(-1)?.role === 'assistant') body.pop();
|
|
252
|
+
|
|
253
|
+
for (const m of body) {
|
|
254
|
+
if (m.role === 'assistant') {
|
|
255
|
+
for (const t of m.tool_calls ?? []) {
|
|
256
|
+
out.push('', ` ${c.blue(`\u2699 ${t.function?.name}`)} ${c.grey(t.function?.arguments ?? '')}`);
|
|
257
|
+
}
|
|
258
|
+
const text = String(m.content ?? '').trim();
|
|
259
|
+
if (text && !(m.tool_calls ?? []).length) {
|
|
260
|
+
out.push('', ...text.split('\n').map((l) => ` ${l}`));
|
|
261
|
+
}
|
|
262
|
+
} else if (m.role === 'tool') {
|
|
263
|
+
out.push(...String(m.content ?? '').split('\n').map((l) => c.grey(` \u2502 ${l}`)));
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (r.report != null) {
|
|
268
|
+
out.push('', c.grey(' \u2500\u2500 report \u2500\u2500'), ...String(r.report).split('\n').map((l) => ` ${l}`));
|
|
269
|
+
} else if (r.state === 'stopped') {
|
|
270
|
+
out.push('', c.yellow(' \u2500\u2500 stopped before it reported \u2500\u2500'));
|
|
271
|
+
}
|
|
272
|
+
out.push('', c.grey(` ${join(sessionDir(), `task-${String(r.n).padStart(3, '0')}.json`)}`));
|
|
273
|
+
return out;
|
|
274
|
+
}
|
package/src/ui.js
CHANGED
|
@@ -33,6 +33,11 @@ ${c.cyan(' ██ kronk-cli')} ${c.grey('· local agent, no network')}
|
|
|
33
33
|
|
|
34
34
|
const k = (n) => (n >= 1000 ? `${(n / 1000).toFixed(n >= 10000 ? 0 : 1)}k` : String(n));
|
|
35
35
|
|
|
36
|
+
/** `4.2s` under a minute, `1m4s` above it. */
|
|
37
|
+
export const elapsed = (ms) => (ms < 60000
|
|
38
|
+
? `${(ms / 1000).toFixed(1)}s`
|
|
39
|
+
: `${Math.floor(ms / 60000)}m${Math.round((ms % 60000) / 1000)}s`);
|
|
40
|
+
|
|
36
41
|
/** `18k/131k 14% ▓▓░░░░░░░░` — how full the window is after this turn. */
|
|
37
42
|
export function fmtContext(used, window) {
|
|
38
43
|
if (!window || !used) return '';
|