kronk-cli 0.3.1 → 0.5.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 CHANGED
@@ -595,6 +595,7 @@ same way this run did.
595
595
  | `-a`, `--auto` | off | Autonomous: auto-approve tools **and** run until the task is done. Implies `--yes` |
596
596
  | `-y`, `--yes` | off | Auto-approve `write_file` and `bash` without the autonomous prompt |
597
597
  | `--no-think` | off | Disable the model's reasoning pass server-side. Much faster |
598
+ | `--no-subagents` | off | Remove the `task` tool, so the model cannot delegate — see [Sub-agents](#sub-agents) |
598
599
  | `--steps <n>` | unlimited | Cap tool calls per task. `0`, `off`, `none`, `inf`, `unlimited` all mean no cap |
599
600
  | `-h`, `--help` | — | Print all options and exit |
600
601
  | `--` | — | End option parsing; everything after it is prompt text, dashes and all |
@@ -625,6 +626,46 @@ yellow past 70%, red past 90%. Toggling `/auto`, `/think` or `/steps` updates it
625
626
 
626
627
  The per-turn usage line still prints after each response; this one is the running picture.
627
628
 
629
+ ### It follows your terminal's background
630
+
631
+ That grey is the one colour the whole line depends on, and the sixteen-colour palette has exactly
632
+ one of them — bright black, which most terminals render around `#555`. On a light background it
633
+ reads; on a dark one the status line was the hardest thing on screen to see.
634
+
635
+ So the palette is chosen per background, from the 256-colour cube: a lighter grey on a dark
636
+ terminal, a darker one on a light terminal, and every other colour shifted the same way. Which
637
+ background you have is settled once, at startup, in this order:
638
+
639
+ 1. `KRONK_THEME`, or `"theme"` in `~/.kronk-cli.json` — `dark` or `light` pins it
640
+ 2. `COLORFGBG`, when the terminal exports it
641
+ 3. the terminal's own answer to an `OSC 11` background query, waited on for 100 ms
642
+ 4. failing all of that, dark — what most terminals ship as
643
+
644
+ `/theme dark` or `/theme light` switches it mid-session, for when step 3 guessed wrong. `NO_COLOR`
645
+ still turns every escape off, and a terminal that advertises no 256-colour support gets a
646
+ sixteen-colour palette picked the same way.
647
+
648
+ ---
649
+
650
+ ## Markdown in answers
651
+
652
+ The models write markdown whether or not anything renders it, so an answer used to arrive with the
653
+ punctuation still in your way — `### The letter is a notification`, `**binding**`, backticks around
654
+ every identifier. Those constructs are rendered into the attributes the terminal already has:
655
+ headings lose their hashes and gain bold, emphasis becomes bold or italic, code spans and fenced
656
+ blocks get their own colour and a gutter, lists get a `•`, quotes a `│`, and `---` a rule.
657
+
658
+ It is a line renderer, not a markdown parser — one bit of state, whether a fenced block is open —
659
+ and anything it does not recognise is left exactly as written. Prose that only looks like markup
660
+ survives: `snake_case`, `3 * 4`, a lone asterisk, `**kwargs` inside backticks.
661
+
662
+ Two consequences worth knowing:
663
+
664
+ - **Output appears a line at a time**, not a token at a time, because `**` is bold or two
665
+ asterisks depending on what follows it. At local speeds that is still a line every half second.
666
+ - **With colour off the bytes are the model's own.** Piped, redirected, or `NO_COLOR` — nothing is
667
+ rewritten, so anything parsing kronk-cli's output sees exactly what it saw before.
668
+
628
669
  ---
629
670
 
630
671
  ## REPL commands
@@ -633,14 +674,16 @@ The per-turn usage line still prints after each response; this one is the runnin
633
674
  |---|---|
634
675
  | `/help` | list these commands |
635
676
  | `/models` | what Kronk is serving, with sizes and what is resident |
636
- | `/model <id>` | switch model — substring match, `/AGENT` preferred |
677
+ | `/model <id>` | switch model — substring match, `/AGENT` preferred; re-reads that model's window and caps |
637
678
  | `/file <path>` | add a file to the conversation as context |
638
679
  | `/auto` | toggle autonomous mode (auto-approve + run to completion) |
639
680
  | `/steps [n\|off]` | show or set the tool-call cap |
640
681
  | `/thinking` | show or hide the model's reasoning |
641
682
  | `/think` | turn reasoning off entirely — much faster |
683
+ | `/agents` | the sub-agents `task` can delegate to, and what each may touch |
642
684
  | `/mcp` | list attached MCP servers and their tools |
643
685
  | `/context` | how much of the context window is used |
686
+ | `/theme [dark\|light]` | show or pin the palette, for when the terminal was read wrong |
644
687
  | `/compact` | replace the conversation with a summary of itself |
645
688
  | `/clear` | reset the conversation, keep the model |
646
689
  | `/exit`, `/quit` | quit |
@@ -655,7 +698,7 @@ The per-turn usage line still prints after each response; this one is the runnin
655
698
  | `KRONK_TOKEN` | `kronk` | Any non-empty value while Kronk runs open; a real JWT when protected |
656
699
  | `KRONK_MODEL` | `unsloth/Qwen3.6-35B-A3B-UD-Q4_K_M/AGENT` | Model id |
657
700
  | `KRONK_MODEL_CONFIG` | `~/.kronk/models/model_config.yaml` | Kronk's per-model config, the file `kronk-cli setup` writes |
658
- | `KRONK_MAX_TOKENS` | `8192` | Output cap per response |
701
+ | `KRONK_MAX_TOKENS` | the model's profile | Output cap per response. Unset, the selected model's own `max_tokens` decides — `8192` only when its profile sets none |
659
702
  | `KRONK_MAX_STEPS` | unlimited | Cap on tool calls per task |
660
703
  | `KRONK_THINKING` | `true` | `false` hides reasoning but still generates it |
661
704
  | `KRONK_NO_THINK` | — | `1` disables reasoning server-side |
@@ -668,10 +711,15 @@ The per-turn usage line still prints after each response; this one is the runnin
668
711
  | `KRONK_CONFIG` | `~/.kronk-cli.json` | Path to the config file, so tests and throwaway runs stay off the real one |
669
712
  | `KRONK_DISTILL` | `true` | `false` disables tool-output distillation |
670
713
  | `KRONK_DISTILL_AT` | `8000` | Characters of output that trigger distillation |
714
+ | `KRONK_SUBAGENTS` | `true` | `false` removes the `task` tool |
715
+ | `KRONK_SUBAGENT_MODEL` | the main model | Model sub-agents run on — substring match, same as `KRONK_MODEL` |
716
+ | `KRONK_SUBAGENT_STEPS` | `40` | Tool-call cap for one delegated task |
671
717
  | `KRONK_WARM` | `true` | `false` skips the boot-time model preload |
672
718
  | `KRONK_AUTO_COMPACT` | `true` | `false` disables automatic compaction |
673
719
  | `KRONK_COMPACT_AT` | `0.85` | Fraction of the window that triggers compaction |
720
+ | `KRONK_THEME` | `auto` | `dark` or `light` pins the palette; `auto` reads the terminal's background |
674
721
  | `NO_COLOR` | — | Any value disables colour |
722
+ | `FORCE_COLOR` | — | `0` disables colour; any other value keeps it on through a pipe |
675
723
 
676
724
  ### Config file
677
725
 
@@ -685,11 +733,14 @@ The per-turn usage line still prints after each response; this one is the runnin
685
733
  "maxTokens": 16384,
686
734
  "maxSteps": 200,
687
735
  "showThinking": false,
736
+ "theme": "auto",
688
737
  "autoCompact": true,
689
738
  "compactAt": 0.85,
690
739
  "noThink": true,
691
740
  "preserveThinking": true,
692
741
  "replayReasoning": true,
742
+ "subagentModel": "Qwen3.6-4B",
743
+ "subagentSteps": 40,
693
744
  "sandboxReadable": ["~/Library/Keychains"]
694
745
  }
695
746
  ```
@@ -851,6 +902,13 @@ not read `reasoning_content` either and would discard the blocks.
851
902
  Kronk reports the **effective** window for whichever model id you selected — that comes from
852
903
  `context-window` in `model_config.yaml`, so a `/AGENT` profile and its base model can differ.
853
904
 
905
+ Every limit is read from the selected model's profile, and re-read when the selection changes:
906
+ the window, the output cap (`sampling-parameters.max_tokens`), whether the chat template
907
+ understands `preserve_thinking`, and the model's native maximum. `/model` mid-session moves all
908
+ of them, and a sub-agent running on `KRONK_SUBAGENT_MODEL` compacts against *its* window rather
909
+ than the main model's. The one number that is not taken from the profile is a `KRONK_MAX_TOKENS`
910
+ you set yourself — yours always wins.
911
+
854
912
  Three places surface it:
855
913
 
856
914
  **The banner**
@@ -962,10 +1020,15 @@ Disable with `--no-compact` or `KRONK_AUTO_COMPACT=false` if you would rather se
962
1020
  | `write_file` | ✋ | Create or overwrite; shows a diff preview first |
963
1021
  | `bash` | ✋ | Run a command; shows it first |
964
1022
  | `set_plan` | — | Record the task checklist; replaces the stored one |
1023
+ | `task` | — | Delegate one job to a [sub-agent](#sub-agents) with its own context |
965
1024
 
966
1025
  `set_plan` writes nothing and runs nothing — it hands the harness a list, which is why it is
967
1026
  never gated. See [Autonomous mode](#autonomous-mode) for what the harness then does with it.
968
1027
 
1028
+ `task` is not gated either, because it cannot do anything by itself: every write and every
1029
+ command a sub-agent asks for comes back to the same prompt you would have seen had the main
1030
+ agent asked for it.
1031
+
969
1032
  `--yes` and `--auto` skip the prompts. Paths resolve against the session directory and cannot
970
1033
  escape the launch root — including through a symlink. `bash` additionally runs under an OS
971
1034
  sandbox where one is available; see [Scope](#scope) for exactly what that covers. `bash` keeps its working directory **between calls**, so a bare `cd`
@@ -1166,6 +1229,127 @@ same way.
1166
1229
 
1167
1230
  ---
1168
1231
 
1232
+ ## Sub-agents
1233
+
1234
+ The model can hand one job to a **sub-agent**: a fresh agent with its own context window, its
1235
+ own tool loop, and no memory of your conversation. It runs the task, and the only thing that
1236
+ ever comes back is its report.
1237
+
1238
+ ```console
1239
+ › which tools in this repo need approval, and where is that decided?
1240
+
1241
+ 1 ⚙ task explore: list every tool definition in src/, say which are gated and where
1242
+ │ 1/40 ⚙ search /def\(/ in src
1243
+ │ ✓ 9 lines
1244
+ │ 2/40 ⚙ read src/tools.js
1245
+ │ ✓ 587 lines
1246
+ │ 3/40 ⚙ read src/subagent.js
1247
+ │ ✓ 211 lines
1248
+ │ 2104→380 tok · 58.1 tok/s · 1980 cached
1249
+ Six built-in tools. write_file and bash are gated, by NEEDS_APPROVAL at
1250
+ src/tools.js:261. MCP tools are gated by name, mcpNeedsApproval at :270.
1251
+ read_file, list_dir, search, set_plan and task are not gated.
1252
+ ✓ 3 lines
1253
+
1254
+ `write_file` and `bash`, from the `NEEDS_APPROVAL` set in src/tools.js:246…
1255
+ ```
1256
+
1257
+ `│` 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.
1259
+
1260
+ Three files were read. None of them is in your conversation: the 800-odd lines they cost were
1261
+ spent in a context that no longer exists, and all that is left in your window is the four-line
1262
+ answer. That is the whole point, and it is the trade [distillation](#distillation) makes for
1263
+ command output, one level up.
1264
+
1265
+ Both agents run against the same Kronk server, so nothing leaves the machine that was not
1266
+ already leaving it.
1267
+
1268
+ ### It is not parallelism
1269
+
1270
+ Kronk holds one resident copy of the model, so two sub-agents at once queue behind each other
1271
+ and finish no sooner than two in sequence. Delegation here buys **context**, not wall-clock:
1272
+ give away the work that is expensive to read and cheap to conclude.
1273
+
1274
+ ### What it costs, measured
1275
+
1276
+ The same survey of `src/` — one table row per module, its responsibility and the gotcha its own
1277
+ comments call out, plus three facts quoted with `file:line` — run twice per model on this
1278
+ machine, once with `--no-subagents` and once told to orchestrate. `main ctx peak` is the largest
1279
+ prompt the *main* conversation ever sent, which is the number the feature exists to move:
1280
+
1281
+ | model | mode | wall | main ctx peak | tokens generated | answer |
1282
+ |---|---|---|---|---|---|
1283
+ | Qwen3.6-35B-A3B `/AGENT` | plain | 143s | 30.8k | 5.5k | 23/23 |
1284
+ | Qwen3.6-35B-A3B `/AGENT` | orchestrated, 5 sub-agents | 352s | **12.9k** | 16.2k | 22/23 |
1285
+ | Ornith-1.5-35B `/AGENT` | plain | 154s | 34.4k | 5.8k | 23/23 |
1286
+ | Ornith-1.5-35B `/AGENT` | orchestrated, 5 sub-agents | 357s | **5.9k** | 16.5k | 23/23 |
1287
+
1288
+ Delegation cut the main conversation's high-water mark by 58% and 83%, and cost roughly 2.4×
1289
+ the wall clock and 3× the generated tokens to do it. That is the trade in one line: **you are
1290
+ buying window, and paying for it in time and tokens.** It is worth it when the conversation has
1291
+ to keep going afterwards, and not worth it for a question you were going to ask once.
1292
+
1293
+ The one answer that got worse is worth reading too. Qwen's orchestrated run scored 22/23 because
1294
+ the sub-agent it asked for "the constant that caps a tool result" came back with a plausible
1295
+ wrong one from a different module, and the orchestrator had no way to know: it never read the
1296
+ file. The plain run made the same mistake, spent three rounds re-reading `tools.js`, and caught
1297
+ it. **A sub-agent's report is evidence you cannot cross-examine** — ask for `file:line` and
1298
+ verbatim quotes in the prompt, and check the ones that matter.
1299
+
1300
+ ### The two agents
1301
+
1302
+ | Agent | Tools | For |
1303
+ |---|---|---|
1304
+ | `explore` | `read_file`, `list_dir`, `search` | Reading, searching, tracing how something works |
1305
+ | `code` | those plus `write_file`, `bash` | Making a change, running a build, reproducing a failure |
1306
+
1307
+ `/agents` prints this in the REPL, with the model and step cap in force.
1308
+
1309
+ Two roles rather than a directory of them, because every extra one is a line the model has to
1310
+ read and choose between on each call, and a local model asked to pick between eight
1311
+ near-synonyms picks badly. The split that changes what can actually happen is whether it may
1312
+ touch anything, so that is the split.
1313
+
1314
+ ### What it cannot do
1315
+
1316
+ - **Ask you anything.** There is nobody in its loop. It is told to take the most reasonable
1317
+ reading of the task, act, and say what it assumed.
1318
+ - **Delegate further.** The `task` tool is only ever offered at the top level, which is the
1319
+ entire recursion guard.
1320
+ - **Get a command past you.** Its `write_file` and `bash` calls arrive at the same prompt the
1321
+ main agent's do, nested under the call that started them. `--yes` and `--auto` skip them
1322
+ exactly as they always did.
1323
+ - **Touch the checklist.** `set_plan` belongs to the task you asked for; a sub-agent has no
1324
+ access to it.
1325
+ - **Reach MCP servers.** Its tool list is deliberately the short one.
1326
+
1327
+ It also runs under a step cap — 40 by default, where the main agent's is unlimited — because
1328
+ nobody is watching it, and a report is worth less than the window a runaway loop would spend
1329
+ earning it.
1330
+
1331
+ ### Knobs
1332
+
1333
+ ```bash
1334
+ kronk-cli --no-subagents # remove the task tool entirely
1335
+ KRONK_SUBAGENT_MODEL=<id> kronk-cli # run the grunt work on a different model
1336
+ KRONK_SUBAGENT_STEPS=80 kronk-cli # a longer leash per task
1337
+ ```
1338
+
1339
+ A sub-agent gets the same startup scan of your project the main agent got, so it starts knowing
1340
+ what the repo is instead of spending its first two steps finding out.
1341
+
1342
+ `KRONK_SUBAGENT_MODEL` needs a model that can actually drive a tool loop, and "smaller" is not
1343
+ the same thing as "cheaper" here. Measured on the survey task below with `qwen2.5-coder-1.5b`
1344
+ as the sub-agent model: every sub-agent returned prose without calling a single tool, the main
1345
+ agent noticed, read all eighteen modules itself, and finished with **47.1k** of context against
1346
+ the 30.8k it uses when you never delegate at all. A sub-agent that cannot do the work costs you
1347
+ the delegation *and* the work.
1348
+
1349
+ > A small model will not delegate unprompted as often as a frontier one does. Asking for it
1350
+ > works — *"use a sub-agent to survey the test suite first"* — and is usually how you will
1351
+ > drive this.
1352
+
1169
1353
  ## Autonomous mode
1170
1354
 
1171
1355
  `--auto`, or `/auto` in the REPL, swaps in a system prompt that tells the model to finish the
@@ -1245,10 +1429,14 @@ previous prompt prefix — watch `cached` climb in the usage line.
1245
1429
  | `src/context.js` | startup scan: git, layout, `AGENTS.md` |
1246
1430
  | `src/compact.js` | summarizing the conversation when the window fills |
1247
1431
  | `src/plan.js` | the task checklist and the snapshot the model sees |
1432
+ | `src/subagent.js` | delegation: agent roles, their tools, the report that comes back |
1248
1433
  | `src/mcp.js` | MCP client: stdio + HTTP transports, tool routing |
1249
1434
  | `src/distill.js` | summarizing large tool output in a throwaway context |
1250
1435
  | `src/config.js` | precedence of flags, env, config file |
1251
1436
  | `src/ui.js` | colour, spinner, usage formatting |
1437
+ | `src/theme.js` | which palette to print in, and how the background is worked out |
1438
+ | `src/markdown.js` | rendering the model's markdown for the terminal, as it streams |
1439
+ | `src/prompt.js` | the line reader: opened on the first question, keeping what was typed before it |
1252
1440
 
1253
1441
  ---
1254
1442
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kronk-cli",
3
- "version": "0.3.1",
3
+ "version": "0.5.0",
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
@@ -6,11 +6,13 @@ import {
6
6
  import { config, shouldPreserveThinking } from './config.js';
7
7
  import { forRequest } from './reasoning.js';
8
8
  import { c, fmtUsage, spinner, liveLine, toolResultLines } from './ui.js';
9
+ import { markdownStream } from './markdown.js';
9
10
  import { compact, isOverflow, report } from './compact.js';
10
11
  import { maybeDistill } from './distill.js';
11
12
  import {
12
13
  carryChecklist, clearPlan, openItems, outstandingLines, planLines, pushNudge,
13
14
  } from './plan.js';
15
+ import { runTask, taskTools, TASK_TOOL } from './subagent.js';
14
16
 
15
17
  export const SYSTEM = `You are kronk-cli, a terse coding assistant running fully offline on the user's machine.
16
18
 
@@ -50,11 +52,11 @@ const thought = (reasoning) => (reasoning ? { reasoning_content: reasoning } : {
50
52
  * view as the wire request that just overflowed — see `compact` in
51
53
  * src/compact.js.
52
54
  */
53
- async function compactInto(messages, model, signal, auto) {
54
- const res = await compact(messages, { model, signal, auto });
55
- if (res.failed || res.skipped) { console.log(report(res)); return false; }
55
+ async function compactInto(messages, { out = console.log, ...opts }) {
56
+ const res = await compact(messages, opts);
57
+ if (res.failed || res.skipped) { out(report(res)); return false; }
56
58
  messages.splice(0, messages.length, ...res.messages);
57
- console.log(report(res));
59
+ out(report(res));
58
60
  return true;
59
61
  }
60
62
 
@@ -68,8 +70,8 @@ const MAX_NUDGES = 2;
68
70
  * message — the reply, or the step-cap note — so a nudge is never left sitting
69
71
  * directly before the next typed prompt.
70
72
  */
71
- function endTurn(messages) {
72
- outstandingLines().forEach((l) => console.log(l));
73
+ function endTurn(messages, { out = console.log, plan = true } = {}) {
74
+ if (plan) outstandingLines().forEach((l) => out(l));
73
75
  return messages;
74
76
  }
75
77
 
@@ -86,8 +88,14 @@ function endTurn(messages) {
86
88
  */
87
89
  export async function runTurn({
88
90
  messages, model, signal, approve, grant, mcp, auto = false, maxSteps = config.maxSteps,
91
+ tools: toolset = TOOLS, plan: usePlan = true, out = console.log, stream = true, depth = 0,
92
+ // The window and the output cap belong to `model`, not to the session: a
93
+ // sub-agent may be running on a different model with a different profile.
94
+ window = config.contextWindow, maxTokens = config.maxTokens,
89
95
  }) {
90
- const tools = mcp ? [...TOOLS, ...mcp.toolDefs()] : TOOLS;
96
+ // `taskTools` is empty below the top level, which is the whole recursion
97
+ // guard: a sub-agent is never handed the tool that spawns one.
98
+ const tools = [...toolset, ...taskTools(depth), ...(mcp ? mcp.toolDefs() : [])];
91
99
  let totalUsage = null;
92
100
  let step = 0;
93
101
  let compacted = false;
@@ -97,22 +105,30 @@ export async function runTurn({
97
105
  // a checklist the previous turn left in the transcript is history, it is
98
106
  // already in the prompt prefix, and rewriting history is what evicts the
99
107
  // prompt cache — see `carryChecklist`.
100
- clearPlan();
108
+ if (usePlan) clearPlan();
101
109
 
102
110
  for (;;) {
103
111
  step += 1;
104
112
  if (Number.isFinite(maxSteps) && step > maxSteps) {
105
- console.log(c.yellow(` ⛔ step cap reached (${maxSteps}). Stopping.`));
106
- console.log(c.grey(' raise it with --steps N, or /steps N in the REPL'));
107
- messages.push({ role: 'assistant', content: '(stopped: step cap reached)' });
108
- return endTurn(messages);
113
+ out(c.yellow(` ⛔ step cap reached (${maxSteps}). Stopping.`));
114
+ out(c.grey(depth
115
+ ? ' raise it with KRONK_SUBAGENT_STEPS'
116
+ : ' raise it with --steps N, or /steps N in the REPL'));
117
+ messages.push({ role: 'assistant',
118
+ content: depth
119
+ ? `(stopped: the sub-agent reached its step cap of ${maxSteps} before finishing)`
120
+ : '(stopped: step cap reached)' });
121
+ return endTurn(messages, { out, plan: usePlan });
109
122
  }
110
- let sp = spinner('thinking');
123
+ let sp = spinner(depth ? 'sub-agent' : 'thinking');
111
124
  let text = '';
112
125
  let reasoning = '';
113
126
  let calls = [];
114
127
  let wroteAnything = false;
115
128
  let inReasoning = false;
129
+ // One renderer per step: a fence left open by one answer must not colour
130
+ // the next one as code.
131
+ const md = markdownStream();
116
132
 
117
133
  try {
118
134
  for await (const ev of streamChat({
@@ -122,7 +138,7 @@ export async function runTurn({
122
138
  messages: forRequest(messages, auto),
123
139
  tools,
124
140
  signal,
125
- maxTokens: config.maxTokens,
141
+ maxTokens,
126
142
  noThink: config.noThink,
127
143
  // Re-read every step: this has to hold for the tool-loop follow-ups too,
128
144
  // and `/think` can flip the answer between one turn and the next.
@@ -132,7 +148,9 @@ export async function runTurn({
132
148
  // Accumulated before the display check: whether the user watches the
133
149
  // model think has nothing to do with whether the model gets it back.
134
150
  reasoning += ev.value;
135
- if (!config.showThinking) continue;
151
+ // A sub-agent's reasoning is not the answer, and a second stream
152
+ // interleaved with the caller's is unreadable. See runTask.
153
+ if (!stream || !config.showThinking) continue;
136
154
  if (sp) { sp.stop(); sp = null; }
137
155
  if (!inReasoning) { process.stdout.write(c.grey('\n ┄ thinking ┄\n ')); inReasoning = true; }
138
156
  process.stdout.write(c.grey(ev.value.replace(/\n/g, '\n ')));
@@ -140,10 +158,14 @@ export async function runTurn({
140
158
  }
141
159
 
142
160
  else if (ev.type === 'text') {
161
+ text += ev.value;
162
+ if (!stream) continue;
143
163
  if (sp) { sp.stop(); sp = null; }
144
164
  if (inReasoning) { process.stdout.write(c.grey('\n ┄─────────┄\n\n')); inReasoning = false; }
145
- text += ev.value;
146
- process.stdout.write(ev.value);
165
+ // Rendered a line at a time — see src/markdown.js. `text` keeps the
166
+ // model's own copy: what goes back in the conversation is never the
167
+ // screen's version of it.
168
+ process.stdout.write(md.write(ev.value));
147
169
  wroteAnything = true;
148
170
  }
149
171
 
@@ -159,13 +181,13 @@ export async function runTurn({
159
181
  if (sp) { sp.stop(); sp = null; }
160
182
  if (isOverflow(e) && !compacted) {
161
183
  compacted = true;
162
- console.log(c.yellow('\n context full — compacting and retrying'));
184
+ out(c.yellow('\n context full — compacting and retrying'));
163
185
  // The plan itself is module state, so compaction cannot reach it. The
164
186
  // snapshot it just summarised away is not put back: there is no tool
165
187
  // result left to carry one, and inserting a message here would be the
166
188
  // rewrite `carryChecklist` exists to avoid. The next round's tool
167
189
  // results carry the plan again.
168
- if (await compactInto(messages, model, signal, auto)) {
190
+ if (await compactInto(messages, { model, signal, auto, out, window, maxTokens })) {
169
191
  step -= 1;
170
192
  continue;
171
193
  }
@@ -176,17 +198,26 @@ export async function runTurn({
176
198
  }
177
199
 
178
200
  if (inReasoning) process.stdout.write(c.grey('\n ┄─────────┄\n'));
201
+ // The last line has no newline to trigger it: an answer that ends mid-line
202
+ // would otherwise be held in the renderer and never printed.
203
+ if (stream) process.stdout.write(md.flush());
179
204
  if (wroteAnything) process.stdout.write('\n');
180
205
  if (totalUsage) {
181
- console.log(fmtUsage(totalUsage, config.contextWindow));
182
- config.lastUsed = (totalUsage.prompt_tokens ?? 0) + (totalUsage.completion_tokens ?? 0);
206
+ out(fmtUsage(totalUsage, window));
207
+ // The status line reports the conversation the user is typing into. A
208
+ // sub-agent's window is its own and is thrown away with it.
209
+ if (!depth) {
210
+ config.lastUsed = (totalUsage.prompt_tokens ?? 0) + (totalUsage.completion_tokens ?? 0);
211
+ }
183
212
  }
184
213
 
185
- if (config.autoCompact && config.contextWindow && totalUsage) {
214
+ if (config.autoCompact && window && totalUsage) {
186
215
  const used = (totalUsage.prompt_tokens ?? 0) + (totalUsage.completion_tokens ?? 0);
187
- if (used / config.contextWindow >= config.compactAt) {
188
- console.log(c.yellow(` context ${Math.round((used / config.contextWindow) * 100)}% full — compacting`));
189
- if (!await compactInto(messages, model, signal, auto)) config.autoCompact = false;
216
+ if (used / window >= config.compactAt) {
217
+ out(c.yellow(` context ${Math.round((used / window) * 100)}% full — compacting`));
218
+ if (!await compactInto(messages, { model, signal, auto, out, window, maxTokens })) {
219
+ config.autoCompact = false;
220
+ }
190
221
  }
191
222
  }
192
223
 
@@ -195,7 +226,7 @@ export async function runTurn({
195
226
  if (!text.trim()) {
196
227
  // Reasoning models sometimes spend the whole budget thinking and emit no
197
228
  // answer. Say so rather than returning silence.
198
- console.log(c.yellow(' (model produced no answer — raise KRONK_MAX_TOKENS or /thinking off)'));
229
+ out(c.yellow(' (model produced no answer — raise KRONK_MAX_TOKENS or /thinking off)'));
199
230
  messages.push({ role: 'assistant', content: '(no answer produced)', ...thought(reasoning) });
200
231
  } else {
201
232
  messages.push({ role: 'assistant', content: text, ...thought(reasoning) });
@@ -205,13 +236,13 @@ export async function runTurn({
205
236
  // keep going — but only when nobody is watching, and only twice: past
206
237
  // that the model is not going to finish, and looping is worse than
207
238
  // reporting what is left.
208
- if (auto && openItems().length && nudges < MAX_NUDGES) {
239
+ if (usePlan && auto && openItems().length && nudges < MAX_NUDGES) {
209
240
  nudges += 1;
210
241
  pushNudge(messages);
211
- console.log(c.yellow(` ⚠ ${openItems().length} checklist items still open — continuing`));
242
+ out(c.yellow(` ⚠ ${openItems().length} checklist items still open — continuing`));
212
243
  continue;
213
244
  }
214
- return endTurn(messages);
245
+ return endTurn(messages, { out, plan: usePlan });
215
246
  }
216
247
 
217
248
  messages.push({
@@ -239,17 +270,21 @@ export async function runTurn({
239
270
 
240
271
  const at = Number.isFinite(maxSteps) ? `${step}/${maxSteps}` : `${step}`;
241
272
  const isMcp = Boolean(mcp?.has(call.name));
273
+ // The same list the model was offered, not just the name it used: with
274
+ // delegation off, or below the top level, a `task` call is a call to a
275
+ // tool that does not exist and falls through to runTool saying so.
276
+ const isTask = !isMcp && call.name === 'task' && tools.includes(TASK_TOOL);
242
277
  const label = isMcp
243
278
  ? `${c.magenta('⚙')} ${call.name} ${c.grey(JSON.stringify(args).slice(0, 120))}`
244
279
  : `${c.blue(`⚙ ${describe(call.name, args)}`)}`;
245
- console.log(`${c.grey(` ${at}`)} ${label}`);
280
+ out(`${c.grey(` ${at}`)} ${label}`);
246
281
 
247
282
  if (isMcp ? mcpNeedsApproval(call.name) : NEEDS_APPROVAL.has(call.name)) {
248
283
  const body = preview(call.name, args);
249
- if (body) console.log(body.split('\n').map((l) => ` ${l}`).join('\n'));
284
+ if (body) out(body.split('\n').map((l) => ` ${l}`).join('\n'));
250
285
  const ok = await approve(call.name, args);
251
286
  if (!ok) {
252
- console.log(c.red(' ✗ denied'));
287
+ out(c.red(' ✗ denied'));
253
288
  messages.push({ role: 'tool', tool_call_id: call.id,
254
289
  content: 'error: the user denied this action. Ask what to do instead.' });
255
290
  continue;
@@ -276,11 +311,11 @@ export async function runTurn({
276
311
  if (answer === 'yes' || answer === 'always') {
277
312
  try {
278
313
  const wrote = rememberGrants(needed, { persist: answer === 'always' });
279
- if (wrote) console.log(c.grey(` remembered in ${wrote}`));
314
+ if (wrote) out(c.grey(` remembered in ${wrote}`));
280
315
  } catch (e) {
281
316
  // Failing to persist must not fail the command. The grant is
282
317
  // already in force for this session either way.
283
- console.log(c.yellow(` ${e.message}`));
318
+ out(c.yellow(` ${e.message}`));
284
319
  }
285
320
  } else {
286
321
  declineGrants(needed);
@@ -288,35 +323,51 @@ export async function runTurn({
288
323
  }
289
324
  }
290
325
 
291
- const live = liveLine();
292
326
  let result;
293
- try {
294
- result = isMcp
295
- ? await mcp.call(call.name, args)
296
- : await runTool(call.name, args, {
297
- onProgress: (info) => live.update({ ...info, window: config.contextWindow }),
298
- });
299
- } finally {
300
- live.done();
327
+ if (isTask) {
328
+ // No `liveLine` around this one. It rewrites the current line every
329
+ // 120 ms, which would erase the sub-agent's own output as fast as it
330
+ // is printed.
331
+ try {
332
+ result = await runTask(args, { run: runTurn, model, signal, approve, grant, out });
333
+ } catch (e) {
334
+ // Errors are data here exactly as they are in `runTool`: a
335
+ // sub-agent that died does not take the turn that delegated to it
336
+ // down with it.
337
+ result = `error: the sub-agent failed — ${e.message}`;
338
+ }
339
+ } else {
340
+ const live = liveLine();
341
+ try {
342
+ result = isMcp
343
+ ? await mcp.call(call.name, args)
344
+ : await runTool(call.name, args, {
345
+ onProgress: (info) => live.update({ ...info, window }),
346
+ });
347
+ } finally {
348
+ live.done();
349
+ }
301
350
  }
302
351
 
303
352
  // set_plan hands back a rendering of what the harness just stored; paying a
304
353
  // second model call to paraphrase our own text would be pure waste.
305
- if (call.name !== 'set_plan') {
354
+ // A sub-agent's report is already the distillation of everything it
355
+ // read; paying a second model call to summarise a summary is waste.
356
+ if (call.name !== 'set_plan' && !isTask) {
306
357
  result = await maybeDistill(result, {
307
- model, signal, command: args.cmd ?? describe(call.name, args),
358
+ model, signal, out, command: args.cmd ?? describe(call.name, args),
308
359
  });
309
360
  }
310
361
  const failed = result.startsWith('error:');
311
- if (failed) toolResultLines(result).forEach((l) => console.log(l));
312
- else if (call.name === 'set_plan') planLines().forEach((l) => console.log(l));
313
- else console.log(c.grey(` ✓ ${result.split('\n').length} lines`));
362
+ if (failed) toolResultLines(result).forEach((l) => out(l));
363
+ else if (call.name === 'set_plan') planLines().forEach((l) => out(l));
364
+ else out(c.grey(` ✓ ${result.split('\n').length} lines`));
314
365
  messages.push({ role: 'tool', tool_call_id: call.id, content: result });
315
366
  }
316
367
 
317
368
  // On the last tool result rather than in a message of its own, so the
318
369
  // round only ever adds to the prompt. Nothing already sent is touched.
319
- carryChecklist(messages);
370
+ if (usePlan) carryChecklist(messages);
320
371
  // loop: the model now sees the tool output
321
372
  }
322
373
  }
package/src/argv.js CHANGED
@@ -24,6 +24,7 @@ const SPECS = [
24
24
  { names: ['--no-compact'], kind: 'flag', key: 'noCompact' },
25
25
  { names: ['--no-warm'], kind: 'flag', key: 'noWarm' },
26
26
  { names: ['--no-think'], kind: 'flag', key: 'noThink' },
27
+ { names: ['--no-subagents'], kind: 'flag', key: 'noSubagents' },
27
28
  { names: ['-a', '--auto'], kind: 'flag', key: 'auto' },
28
29
  { names: ['-y', '--yes'], kind: 'flag', key: 'yes' },
29
30
  { names: ['--dry-run'], kind: 'flag', key: 'dryRun' },
@@ -102,6 +103,7 @@ export function parseArgv(argv) {
102
103
  error: null,
103
104
  help: false, models: false, mcpList: false,
104
105
  noContext: false, noCompact: false, noWarm: false, noThink: false,
106
+ noSubagents: false,
105
107
  auto: false, yes: false, dryRun: false,
106
108
  model: null,
107
109
  context: null,