kronk-cli 0.5.1 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kronk-cli",
3
- "version": "0.5.1",
3
+ "version": "0.6.1",
4
4
  "description": "A Claude-Code-style terminal agent for local models served by Kronk.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
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
- const done = await run({
189
- messages,
190
- // A smaller model for the grunt work is the whole reason this is a knob:
191
- // the survey is reading and grepping, the synthesis is not.
192
- model: config.subagentModel ?? model,
193
- window: own ? own.contextWindow : config.contextWindow,
194
- maxTokens: own?.maxTokens ?? config.maxTokens,
195
- signal,
196
- approve,
197
- grant,
198
- // No MCP: a local model already struggles past ~25 tools, and the
199
- // sub-agent's list is meant to be the short one.
200
- mcp: null,
201
- tools: subagentTools(agent),
202
- // The checklist is module state belonging to the task the user asked for.
203
- // A sub-agent that called set_plan would clear it out from under the agent
204
- // that delegated to it.
205
- plan: false,
206
- // Its reasoning and prose are not printed: they are not the answer, and a
207
- // second stream interleaved with the caller's is unreadable. Tool calls
208
- // still show, indented, so the run is not a black box.
209
- stream: false,
210
- out: (line) => out(nest(line)),
211
- maxSteps,
212
- depth: 1,
213
- });
214
-
215
- const report = reportFrom(done);
216
- reportLines(report).forEach((l) => out(l));
217
- return report;
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/tools.js CHANGED
@@ -561,13 +561,31 @@ export async function runTool(name, args, opts = {}) {
561
561
  // Went straight to ripgrep unchecked, so `search` with an absolute path
562
562
  // read anything on the machine while read_file was busy refusing to.
563
563
  const where = safe(args.path ?? '.');
564
+ // Both tools exit 1 to mean "nothing matched", which is an answer and
565
+ // not a failure. Reported as an error the model stops believing the
566
+ // tool, and spends its step budget re-phrasing and re-reading rather
567
+ // than using the fact it was just handed.
568
+ const NOTHING = '(no matches)';
564
569
  try {
565
570
  const { stdout } = await exec('rg', ['-n', '--no-heading', '-m', '200', args.pattern, where]);
566
- return clip(stdout) || '(no matches)';
571
+ return clip(stdout) || NOTHING;
567
572
  } catch (e) {
568
- if (e.code === 1) return '(no matches)';
569
- const { stdout } = await exec('grep', ['-rn', '-m', '200', args.pattern, where]);
570
- return clip(stdout) || '(no matches)';
573
+ if (e.code === 1) return NOTHING;
574
+ // No ripgrep on this machine, or it refused the pattern. grep is the
575
+ // floor, and it needs the same exit-1 reading the call above has —
576
+ // this is the branch CI never reaches, because its images ship rg.
577
+ try {
578
+ // -E because this tool advertises ripgrep-style patterns, and basic
579
+ // grep reads `a|b` as the three literal characters. -H because GNU
580
+ // grep drops the filename when it is given exactly one file, and
581
+ // this tool promises `file:line` prefixes — BSD grep prints them
582
+ // either way, which is why a macOS-only check would not see it.
583
+ const { stdout } = await exec('grep', ['-rn', '-H', '-E', '-m', '200', args.pattern, where]);
584
+ return clip(stdout) || NOTHING;
585
+ } catch (err) {
586
+ if (err.code === 1) return NOTHING;
587
+ throw err;
588
+ }
571
589
  }
572
590
  }
573
591
 
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 '';