kronk-cli 0.6.1 → 0.7.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 +39 -16
- package/package.json +1 -1
- package/src/agent.js +9 -2
- package/src/pane.js +268 -0
- package/src/subagent.js +40 -11
package/README.md
CHANGED
|
@@ -1238,30 +1238,53 @@ The model can hand one job to a **sub-agent**: a fresh agent with its own contex
|
|
|
1238
1238
|
own tool loop, and no memory of your conversation. It runs the task, and the only thing that
|
|
1239
1239
|
ever comes back is its report.
|
|
1240
1240
|
|
|
1241
|
+
While it runs, it draws in a box below the prompt that redraws in place:
|
|
1242
|
+
|
|
1241
1243
|
```console
|
|
1242
1244
|
› which tools in this repo need approval, and where is that decided?
|
|
1243
1245
|
|
|
1244
1246
|
1 ⚙ task explore: list every tool definition in src/, say which are gated and where
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1247
|
+
┌─ explore ──────────────────────────────────────── 6/40 · 48s ─┐
|
|
1248
|
+
│ 4/40 ⚙ read src/tools.js │
|
|
1249
|
+
│ ✓ 587 lines │
|
|
1250
|
+
│ 5/40 ⚙ search /NEEDS_APPROVAL/ in src │
|
|
1251
|
+
│ ✓ 3 lines │
|
|
1252
|
+
│ 6/40 ⚙ read src/subagent.js │
|
|
1253
|
+
└─ esc to collapse · ctrl-c to stop ────────────────────────────┘
|
|
1254
|
+
```
|
|
1255
|
+
|
|
1256
|
+
When it finishes the box is erased, and what is left in the scrollback is the answer and one
|
|
1257
|
+
line saying what the run cost:
|
|
1258
|
+
|
|
1259
|
+
```console
|
|
1260
|
+
1 ⚙ task explore: list every tool definition in src/, say which are gated and where
|
|
1253
1261
|
Six built-in tools. write_file and bash are gated, by NEEDS_APPROVAL at
|
|
1254
1262
|
src/tools.js:261. MCP tools are gated by name, mcpNeedsApproval at :270.
|
|
1255
1263
|
read_file, list_dir, search, set_plan and task are not gated.
|
|
1256
|
-
└ 3 steps · 11.4s · /
|
|
1264
|
+
└ 3 steps · 11.4s · /tasks 1 for the transcript
|
|
1257
1265
|
✓ 3 lines
|
|
1258
1266
|
|
|
1259
1267
|
`write_file` and `bash`, from the `NEEDS_APPROVAL` set in src/tools.js:246…
|
|
1260
1268
|
```
|
|
1261
1269
|
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1270
|
+
The box is the run's only appearance on screen, and that is deliberate. A nine-step survey
|
|
1271
|
+
printed into the conversation leaves forty lines of someone else's work between the question
|
|
1272
|
+
you asked and the answer you got; scroll back ten minutes later and the conversation is mostly
|
|
1273
|
+
sub-agent. While the run is going a live view is the only sign the thing is alive, and it is
|
|
1274
|
+
where you notice one thrashing — but once it has ended, almost none of it is worth keeping.
|
|
1275
|
+
|
|
1276
|
+
Nothing is lost by erasing it. Everything the box shows is written to the run's transcript as
|
|
1277
|
+
it happens, so the box is a view of a file that outlives it, and `/tasks 1` below is how you
|
|
1278
|
+
read it back. Three keys and one fallback are worth knowing:
|
|
1279
|
+
|
|
1280
|
+
- **`esc` collapses the box immediately** and the run carries on. For when you have seen enough
|
|
1281
|
+
and want your screen back.
|
|
1282
|
+
- **`ctrl-c`** stops the run, as it stops anything else.
|
|
1283
|
+
- **An approval prompt wipes the box first.** A `code` sub-agent's `write_file` and `bash` stop
|
|
1284
|
+
for the same question the main agent's do, and that question gets the terminal to itself.
|
|
1285
|
+
- **On a pipe or a dumb terminal there is no box at all.** `kronk-cli "…" | grep`, a CI job and
|
|
1286
|
+
an `--auto` run nobody is watching each get the plain nested lines instead, in order, with no
|
|
1287
|
+
cursor control in them.
|
|
1265
1288
|
|
|
1266
1289
|
Three files were read. None of them is in your conversation: the 800-odd lines they cost were
|
|
1267
1290
|
spent in a context that no longer exists, and all that is left in your window is the four-line
|
|
@@ -1273,10 +1296,10 @@ already leaving it.
|
|
|
1273
1296
|
|
|
1274
1297
|
### Reading a run back
|
|
1275
1298
|
|
|
1276
|
-
The report on screen is dimmed and capped at fourteen lines
|
|
1277
|
-
the sub-agent read, what it ran, and any line fifteen of its
|
|
1278
|
-
context. Delegating is supposed to cost the *conversation*
|
|
1279
|
-
cost you the ability to check.
|
|
1299
|
+
The report on screen is dimmed and capped at fourteen lines, and the box it ran in is gone. That
|
|
1300
|
+
used to be the end of it: what the sub-agent read, what it ran, and any line fifteen of its
|
|
1301
|
+
report went out of scope with its context. Delegating is supposed to cost the *conversation*
|
|
1302
|
+
nothing — it was never supposed to cost you the ability to check.
|
|
1280
1303
|
|
|
1281
1304
|
So each run is written to a file as it goes, and `/tasks` reads them back.
|
|
1282
1305
|
|
package/package.json
CHANGED
package/src/agent.js
CHANGED
|
@@ -75,6 +75,9 @@ function endTurn(messages, { out = console.log, plan = true } = {}) {
|
|
|
75
75
|
return messages;
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
/** Stands in for `liveLine()` under a pane, which is drawing the same seconds itself. */
|
|
79
|
+
const QUIET_LIVE = { update() {}, done() {} };
|
|
80
|
+
|
|
78
81
|
/**
|
|
79
82
|
* Run one user turn to completion, looping while the model requests tools.
|
|
80
83
|
*
|
|
@@ -89,6 +92,10 @@ function endTurn(messages, { out = console.log, plan = true } = {}) {
|
|
|
89
92
|
export async function runTurn({
|
|
90
93
|
messages, model, signal, approve, grant, mcp, auto = false, maxSteps = config.maxSteps,
|
|
91
94
|
tools: toolset = TOOLS, plan: usePlan = true, out = console.log, stream = true, depth = 0,
|
|
95
|
+
// Print nothing that redraws the current line — the spinner and the live
|
|
96
|
+
// command line. Set by src/subagent.js when a pane is drawing the run, and
|
|
97
|
+
// the pane owns the bottom of the screen for as long as it is up.
|
|
98
|
+
quiet = false,
|
|
92
99
|
// Called with the transcript so far after every round. Only src/subagent.js
|
|
93
100
|
// passes one: a delegated run is the transcript nobody would otherwise keep,
|
|
94
101
|
// and the round that ends it may be a throw or a step cap rather than a
|
|
@@ -125,7 +132,7 @@ export async function runTurn({
|
|
|
125
132
|
: '(stopped: step cap reached)' });
|
|
126
133
|
return endTurn(messages, { out, plan: usePlan });
|
|
127
134
|
}
|
|
128
|
-
let sp = spinner(depth ? 'sub-agent' : 'thinking');
|
|
135
|
+
let sp = quiet ? null : spinner(depth ? 'sub-agent' : 'thinking');
|
|
129
136
|
let text = '';
|
|
130
137
|
let reasoning = '';
|
|
131
138
|
let calls = [];
|
|
@@ -342,7 +349,7 @@ export async function runTurn({
|
|
|
342
349
|
result = `error: the sub-agent failed — ${e.message}`;
|
|
343
350
|
}
|
|
344
351
|
} else {
|
|
345
|
-
const live = liveLine();
|
|
352
|
+
const live = quiet ? QUIET_LIVE : liveLine();
|
|
346
353
|
try {
|
|
347
354
|
result = isMcp
|
|
348
355
|
? await mcp.call(call.name, args)
|
package/src/pane.js
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import readline from 'node:readline';
|
|
2
|
+
import { c, elapsed } from './ui.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The transient pane a delegated run draws in while it is running.
|
|
6
|
+
*
|
|
7
|
+
* A sub-agent's tool calls used to go straight into the conversation, nested
|
|
8
|
+
* under ` │`, and stayed there. That is the wrong trade in both directions.
|
|
9
|
+
* While the run is going a live view is the only sign the thing is alive, and
|
|
10
|
+
* it is where you notice a sub-agent thrashing; once it has finished, almost
|
|
11
|
+
* none of it is worth keeping, because the question you asked and the report
|
|
12
|
+
* you got are now forty lines apart. The window never paid for any of it —
|
|
13
|
+
* that is the point of delegating — but the screen paid for all of it.
|
|
14
|
+
*
|
|
15
|
+
* So: draw it in a box that redraws in place, and erase the box when the run
|
|
16
|
+
* ends. Nothing is lost by erasing it. Every line the pane shows was already
|
|
17
|
+
* written to the run's transcript as it happened (src/tasklog.js), so the pane
|
|
18
|
+
* is a view of a file that outlives it, and `/tasks <n>` is how you get it
|
|
19
|
+
* back. That is what makes discarding the pane safe.
|
|
20
|
+
*
|
|
21
|
+
* Scrollback, not the alternate screen. A full takeover would be easier to
|
|
22
|
+
* draw and worse to live with, because it hides the conversation you are in
|
|
23
|
+
* the middle of. A fixed block redrawn in place keeps the history above it.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** Rows of sub-agent activity kept on screen. The box is bounded; the file is not. */
|
|
27
|
+
const BODY_ROWS = 6;
|
|
28
|
+
|
|
29
|
+
const MIN_WIDTH = 40;
|
|
30
|
+
const MAX_WIDTH = 100;
|
|
31
|
+
|
|
32
|
+
// eslint-disable-next-line no-control-regex -- matching the escapes is the point
|
|
33
|
+
const SGR = /\x1b\[[0-9;]*m/g;
|
|
34
|
+
|
|
35
|
+
/** How wide a string prints, once its colour escapes are discounted. */
|
|
36
|
+
export const visibleWidth = (s) => String(s).replace(SGR, '').length;
|
|
37
|
+
|
|
38
|
+
/** The box's outer width for a terminal this wide, clamped both ways. */
|
|
39
|
+
export const paneWidth = (columns) => Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (columns || 80) - 2));
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Cut a coloured string to `width` printed characters.
|
|
43
|
+
*
|
|
44
|
+
* Escapes are copied through without being counted — cutting in the middle of
|
|
45
|
+
* one would leave the rest of the terminal painted in whatever colour the
|
|
46
|
+
* fragment happened to open. The trailing reset is only added when something
|
|
47
|
+
* was actually opened, so a no-colour terminal still gets no escapes at all.
|
|
48
|
+
*/
|
|
49
|
+
export function clip(line, width) {
|
|
50
|
+
const s = String(line);
|
|
51
|
+
if (visibleWidth(s) <= width) return s;
|
|
52
|
+
if (width <= 0) return '';
|
|
53
|
+
let out = '';
|
|
54
|
+
let seen = 0;
|
|
55
|
+
let i = 0;
|
|
56
|
+
while (i < s.length && seen < width - 1) {
|
|
57
|
+
if (s[i] === '\x1b') {
|
|
58
|
+
SGR.lastIndex = i;
|
|
59
|
+
const m = SGR.exec(s);
|
|
60
|
+
if (m && m.index === i) { out += m[0]; i += m[0].length; continue; }
|
|
61
|
+
}
|
|
62
|
+
out += s[i];
|
|
63
|
+
i += 1;
|
|
64
|
+
seen += 1;
|
|
65
|
+
}
|
|
66
|
+
return `${out}…${out.includes('\x1b') ? '\x1b[0m' : ''}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Right-pad to `width` printed characters, so every row ends on the same column. */
|
|
70
|
+
export const pad = (line, width) => `${line}${' '.repeat(Math.max(0, width - visibleWidth(line)))}`;
|
|
71
|
+
|
|
72
|
+
const rule = (n) => '─'.repeat(Math.max(1, n));
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* One frame of the box, as lines — pure, so the geometry is testable without a
|
|
76
|
+
* terminal. Every line it returns is exactly `width` printed characters wide;
|
|
77
|
+
* `test/pane.test.js` asserts that, because an off-by-one in the arithmetic
|
|
78
|
+
* below is invisible in a diff and unmissable on screen.
|
|
79
|
+
*/
|
|
80
|
+
export function renderPane({ agent, rows = [], status = '', hint = '', width = 60 }) {
|
|
81
|
+
const inner = Math.max(4, width - 4);
|
|
82
|
+
// Printed width, not string length: the status and the hint arrive coloured,
|
|
83
|
+
// and counting their escapes would pull the right-hand corner off the edge.
|
|
84
|
+
const w = (s) => (s ? visibleWidth(s) + 1 : 0);
|
|
85
|
+
|
|
86
|
+
// `┌─ agent ──…── 6/40 · 48s ─┐`
|
|
87
|
+
const headFixed = 3 + w(agent) + 1 + w(status) + 2;
|
|
88
|
+
const top = c.grey('┌─ ') + c.bold(agent) + c.grey(` ${rule(width - headFixed)} ${status ? `${status} ` : ''}─┐`);
|
|
89
|
+
|
|
90
|
+
// `└─ esc to collapse · ctrl-c to stop ───┘`
|
|
91
|
+
const footFixed = 3 + w(hint) + 2;
|
|
92
|
+
const bottom = c.grey(`└─ ${hint ? `${hint} ` : ''}${rule(width - footFixed)}─┘`);
|
|
93
|
+
|
|
94
|
+
const body = rows.map((r) => `${c.grey('│')} ${pad(clip(r, inner), inner)} ${c.grey('│')}`);
|
|
95
|
+
return [top, ...body, bottom];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Watch for a bare `esc` while the pane is up.
|
|
100
|
+
*
|
|
101
|
+
* Deliberately not `setRawMode`. The REPL's readline already owns stdin and
|
|
102
|
+
* already holds it in raw mode, and it is still reading during a turn — that
|
|
103
|
+
* is how typing ahead survives (see src/prompt.js). Taking the mode away from
|
|
104
|
+
* it and handing back what we swallowed is the kind of thing that eats a
|
|
105
|
+
* user's typing when it goes wrong, and there is no need: readline has already
|
|
106
|
+
* made stdin emit `keypress`, so a listener is purely additive. Ctrl-C keeps
|
|
107
|
+
* working because we never intercepted it.
|
|
108
|
+
*
|
|
109
|
+
* Armed only when stdin is *already* raw, which is exactly "a readline is
|
|
110
|
+
* open and reading". In one-shot and `--auto` runs it is not, nobody is
|
|
111
|
+
* watching, and the footer does not offer a key that would do nothing.
|
|
112
|
+
*/
|
|
113
|
+
export function watchEsc({ input = process.stdin, onEsc } = {}) {
|
|
114
|
+
if (!onEsc || !input?.isTTY || !input.isRaw) return null;
|
|
115
|
+
// Idempotent in Node: it returns early if the stream already has a decoder.
|
|
116
|
+
readline.emitKeypressEvents(input);
|
|
117
|
+
// `meta` is true on a bare esc and false on `esc [ A`, because node cannot
|
|
118
|
+
// know which it has until the next byte arrives or `escapeCodeTimeout`
|
|
119
|
+
// (500 ms) expires — so this is the key, and an arrow key is not. That half
|
|
120
|
+
// second of lag is invisible next to a run that takes tens of seconds.
|
|
121
|
+
const onKey = (_s, key) => { if (key?.name === 'escape' && !key.ctrl) onEsc(); };
|
|
122
|
+
input.on('keypress', onKey);
|
|
123
|
+
return () => input.off('keypress', onKey);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Whether to draw at all.
|
|
128
|
+
*
|
|
129
|
+
* `node src/index.js "hello" | grep` and every CI run land here with a pipe,
|
|
130
|
+
* and a pipe must get exactly what it got before the pane existed: the nested
|
|
131
|
+
* lines, in order, with no cursor control in them. A dumb terminal cannot move
|
|
132
|
+
* a cursor either, so it takes the same path.
|
|
133
|
+
*/
|
|
134
|
+
export const paneUsable = (stream = process.stdout, env = process.env) =>
|
|
135
|
+
Boolean(stream?.isTTY) && env.TERM !== 'dumb';
|
|
136
|
+
|
|
137
|
+
/** The inert pane: every call a no-op, so the caller needs no branch of its own. */
|
|
138
|
+
const OFF = {
|
|
139
|
+
enabled: false,
|
|
140
|
+
open() {}, line() {}, step() {}, close() {}, collapse() {},
|
|
141
|
+
shield: (fn) => fn,
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The live pane. `open` draws it, `line` feeds it, `close` erases it.
|
|
146
|
+
*
|
|
147
|
+
* `shield(fn)` is how the approval prompt stays readable: it wipes the box
|
|
148
|
+
* before `fn` runs and redraws it after. An approval prompt has to be exactly
|
|
149
|
+
* as visible and as blocking as it is without a pane, so it gets the terminal
|
|
150
|
+
* to itself rather than competing with a redraw every 250 ms.
|
|
151
|
+
*/
|
|
152
|
+
export function livePane({
|
|
153
|
+
agent = 'agent', maxSteps, rows: keep = BODY_ROWS, signal,
|
|
154
|
+
stream = process.stdout, input = process.stdin, env = process.env,
|
|
155
|
+
now = Date.now, tickMs = 250,
|
|
156
|
+
} = {}) {
|
|
157
|
+
if (!paneUsable(stream, env)) return OFF;
|
|
158
|
+
|
|
159
|
+
const started = now();
|
|
160
|
+
const fed = [];
|
|
161
|
+
let steps = 0;
|
|
162
|
+
let height = 0; // rows the box currently occupies on screen
|
|
163
|
+
let collapsed = false;
|
|
164
|
+
let timer = null;
|
|
165
|
+
let unwatch = null;
|
|
166
|
+
let unabort = null;
|
|
167
|
+
|
|
168
|
+
const status = () => {
|
|
169
|
+
const at = Number.isFinite(maxSteps) ? `${steps}/${maxSteps}` : String(steps);
|
|
170
|
+
return c.grey(`${at} · ${elapsed(now() - started)}`);
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const paint = () => {
|
|
174
|
+
if (collapsed) return;
|
|
175
|
+
const width = paneWidth(stream.columns);
|
|
176
|
+
const hint = c.grey(unwatch ? 'esc to collapse · ctrl-c to stop' : 'ctrl-c to stop');
|
|
177
|
+
const frame = renderPane({ agent, rows: fed, status: status(), hint, width });
|
|
178
|
+
// Up to the top of the old box, then repaint row by row. `\x1b[K` on each
|
|
179
|
+
// row so a shorter line does not leave the tail of a longer one behind.
|
|
180
|
+
let buf = height ? `\r\x1b[${height}A` : '';
|
|
181
|
+
for (const l of frame) buf += `${l}\x1b[K\n`;
|
|
182
|
+
// The box only ever grows, but a narrower terminal can shorten it; clear
|
|
183
|
+
// whatever the previous frame left below this one.
|
|
184
|
+
if (height > frame.length) buf += '\x1b[J';
|
|
185
|
+
stream.write(buf);
|
|
186
|
+
height = frame.length;
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const erase = () => {
|
|
190
|
+
if (!height) return;
|
|
191
|
+
stream.write(`\r\x1b[${height}A\x1b[J`);
|
|
192
|
+
height = 0;
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const tick = () => {
|
|
196
|
+
if (timer) return;
|
|
197
|
+
timer = setInterval(paint, tickMs);
|
|
198
|
+
// Never a reason to keep the process alive: the run is what we are waiting on.
|
|
199
|
+
timer.unref?.();
|
|
200
|
+
};
|
|
201
|
+
const untick = () => { clearInterval(timer); timer = null; };
|
|
202
|
+
|
|
203
|
+
const stop = () => {
|
|
204
|
+
untick();
|
|
205
|
+
unwatch?.();
|
|
206
|
+
unwatch = null;
|
|
207
|
+
unabort?.();
|
|
208
|
+
unabort = null;
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
return {
|
|
212
|
+
enabled: true,
|
|
213
|
+
|
|
214
|
+
open() {
|
|
215
|
+
unwatch = watchEsc({ input, onEsc: () => this.collapse() });
|
|
216
|
+
// Ctrl-C aborts the run and then prints `interrupted` — which would land
|
|
217
|
+
// on top of the box, leaving the next repaint counting rows from the
|
|
218
|
+
// wrong place and erasing lines of the conversation above. Abort fires
|
|
219
|
+
// this listener synchronously, so the box is gone before that line
|
|
220
|
+
// prints. Nothing is lost: the transcript has everything it showed.
|
|
221
|
+
const onAbort = () => this.collapse();
|
|
222
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
223
|
+
// Released with everything else: one turn can delegate several times, and
|
|
224
|
+
// a listener left on that turn's signal each time is a leak node warns about.
|
|
225
|
+
unabort = () => signal?.removeEventListener('abort', onAbort);
|
|
226
|
+
paint();
|
|
227
|
+
tick();
|
|
228
|
+
},
|
|
229
|
+
|
|
230
|
+
/** One line of sub-agent activity. Only the last `keep` survive. */
|
|
231
|
+
line(text) {
|
|
232
|
+
if (collapsed) return;
|
|
233
|
+
for (const l of String(text).split('\n')) fed.push(l);
|
|
234
|
+
if (fed.length > keep) fed.splice(0, fed.length - keep);
|
|
235
|
+
paint();
|
|
236
|
+
},
|
|
237
|
+
|
|
238
|
+
step(n) { steps = n; },
|
|
239
|
+
|
|
240
|
+
/** `esc`: give the screen back now. The run carries on into the transcript. */
|
|
241
|
+
collapse() {
|
|
242
|
+
if (collapsed) return;
|
|
243
|
+
erase();
|
|
244
|
+
collapsed = true;
|
|
245
|
+
stop();
|
|
246
|
+
},
|
|
247
|
+
|
|
248
|
+
close() {
|
|
249
|
+
erase();
|
|
250
|
+
collapsed = true;
|
|
251
|
+
stop();
|
|
252
|
+
},
|
|
253
|
+
|
|
254
|
+
shield(fn) {
|
|
255
|
+
if (!fn) return fn;
|
|
256
|
+
return async (...args) => {
|
|
257
|
+
const wasUp = !collapsed && height > 0;
|
|
258
|
+
erase();
|
|
259
|
+
untick();
|
|
260
|
+
try {
|
|
261
|
+
return await fn(...args);
|
|
262
|
+
} finally {
|
|
263
|
+
if (wasUp && !collapsed) { paint(); tick(); }
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
},
|
|
267
|
+
};
|
|
268
|
+
}
|
package/src/subagent.js
CHANGED
|
@@ -2,6 +2,7 @@ import { def, TOOLS } from './tools.js';
|
|
|
2
2
|
import { config } from './config.js';
|
|
3
3
|
import { c, elapsed } from './ui.js';
|
|
4
4
|
import { startRun } from './tasklog.js';
|
|
5
|
+
import { livePane } from './pane.js';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Sub-agents: one delegated task, one throwaway context.
|
|
@@ -152,13 +153,17 @@ export function openLine(agent, prompt) {
|
|
|
152
153
|
*
|
|
153
154
|
* This is what makes `reportLines`' "…N more lines" actionable: the report on
|
|
154
155
|
* 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
|
+
* for everything the sub-agent read on the way. Once the pane erases itself it
|
|
157
|
+
* carries more than that — it is all that is left of the run on screen.
|
|
156
158
|
*/
|
|
157
|
-
export function closeLine({ ms, steps,
|
|
159
|
+
export function closeLine({ ms, steps, n, ok }) {
|
|
158
160
|
// A run that threw or was interrupted is the one worth reading, so the line
|
|
159
161
|
// that points at its transcript must not read like a clean finish.
|
|
160
162
|
const bits = [`${steps} step${steps === 1 ? '' : 's'}`, elapsed(ms), ...(ok ? [] : ['stopped'])];
|
|
161
|
-
|
|
163
|
+
// The command that reads the transcript, not the file it reads. The absolute
|
|
164
|
+
// path under the temp dir is long, mostly noise, and not a thing anyone would
|
|
165
|
+
// type; `/tasks 3` is both shorter and the answer to "how do I see it".
|
|
166
|
+
if (n) bits.push(`/tasks ${n} for the transcript`);
|
|
162
167
|
const line = ` └ ${bits.join(' · ')}`;
|
|
163
168
|
return ok ? c.grey(line) : c.yellow(line);
|
|
164
169
|
}
|
|
@@ -201,6 +206,9 @@ function reportFrom(messages) {
|
|
|
201
206
|
*/
|
|
202
207
|
export async function runTask(args, {
|
|
203
208
|
run, model, signal, approve, grant, out = console.log, maxSteps = config.subagentSteps,
|
|
209
|
+
// Injected the same way `run` and `out` are, and for the same reason: the
|
|
210
|
+
// pane's behaviour is a screen behaviour, and a test has no screen.
|
|
211
|
+
pane: makePane = livePane,
|
|
204
212
|
}) {
|
|
205
213
|
const agent = args?.agent ?? 'explore';
|
|
206
214
|
const prompt = String(args?.prompt ?? '').trim();
|
|
@@ -223,7 +231,15 @@ export async function runTask(args, {
|
|
|
223
231
|
// model call still leaves a file saying which task was asked for.
|
|
224
232
|
const log = startRun({ agent, prompt, model: config.subagentModel ?? model });
|
|
225
233
|
const started = Date.now();
|
|
226
|
-
|
|
234
|
+
|
|
235
|
+
// On a terminal the run is drawn in a box that erases itself; on a pipe, a
|
|
236
|
+
// dumb terminal or an `--auto` run nobody is watching, `livePane` hands back
|
|
237
|
+
// an inert one and everything below takes exactly the path it took before
|
|
238
|
+
// the pane existed. The transcript is written either way, which is what
|
|
239
|
+
// makes erasing the box safe — see src/pane.js.
|
|
240
|
+
const pane = makePane({ agent, maxSteps, signal });
|
|
241
|
+
if (pane.enabled) pane.open();
|
|
242
|
+
else out(openLine(agent, prompt));
|
|
227
243
|
|
|
228
244
|
let steps = 0;
|
|
229
245
|
let report;
|
|
@@ -236,8 +252,11 @@ export async function runTask(args, {
|
|
|
236
252
|
window: own ? own.contextWindow : config.contextWindow,
|
|
237
253
|
maxTokens: own?.maxTokens ?? config.maxTokens,
|
|
238
254
|
signal,
|
|
239
|
-
|
|
240
|
-
|
|
255
|
+
// Wrapped, not replaced: delegation is still not a way to get a command
|
|
256
|
+
// past the user. The wrapper only wipes the box first, so the question is
|
|
257
|
+
// asked on a clear screen and nothing redraws over it while it waits.
|
|
258
|
+
approve: pane.shield(approve),
|
|
259
|
+
grant: pane.shield(grant),
|
|
241
260
|
// No MCP: a local model already struggles past ~25 tools, and the
|
|
242
261
|
// sub-agent's list is meant to be the short one.
|
|
243
262
|
mcp: null,
|
|
@@ -250,23 +269,33 @@ export async function runTask(args, {
|
|
|
250
269
|
// second stream interleaved with the caller's is unreadable. Tool calls
|
|
251
270
|
// still show, indented, so the run is not a black box.
|
|
252
271
|
stream: false,
|
|
253
|
-
out: (line) => out(nest(line)),
|
|
272
|
+
out: pane.enabled ? (line) => pane.line(line) : (line) => out(nest(line)),
|
|
273
|
+
// The pane is already the live view, and the spinner and the per-command
|
|
274
|
+
// live line both rewrite the current line with `\r` — which is the last
|
|
275
|
+
// row of the box. Two things redrawing the same row is the one way this
|
|
276
|
+
// can look broken, so below a pane there is only one of them.
|
|
277
|
+
quiet: pane.enabled,
|
|
254
278
|
maxSteps,
|
|
255
279
|
depth: 1,
|
|
256
280
|
// Written as it goes rather than at the end. The runs worth reading are
|
|
257
281
|
// the ones that were interrupted, threw, or ran out of steps, and none
|
|
258
282
|
// of those reach the line below.
|
|
259
|
-
onStep: (msgs, n) => { steps = n; log?.step(msgs, n); },
|
|
283
|
+
onStep: (msgs, n) => { steps = n; pane.step(n); log?.step(msgs, n); },
|
|
260
284
|
});
|
|
261
285
|
|
|
262
286
|
report = reportFrom(done);
|
|
287
|
+
// Before the report, so the box is gone by the time the answer lands in
|
|
288
|
+
// the scrollback it is keeping clean.
|
|
289
|
+
pane.close();
|
|
263
290
|
reportLines(report).forEach((l) => out(l));
|
|
264
291
|
return report;
|
|
265
292
|
} finally {
|
|
266
293
|
// In `finally` so an abort or a throw still closes the record and still
|
|
267
294
|
// tells the user where the partial transcript is — that is the case the
|
|
268
|
-
// transcript exists for.
|
|
269
|
-
|
|
270
|
-
|
|
295
|
+
// transcript exists for. `close` is idempotent; the success path already
|
|
296
|
+
// called it above.
|
|
297
|
+
pane.close();
|
|
298
|
+
log?.finish({ report, steps });
|
|
299
|
+
out(closeLine({ ms: Date.now() - started, steps, n: log?.n, ok: report !== undefined }));
|
|
271
300
|
}
|
|
272
301
|
}
|