kronk-cli 0.6.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +226 -21
- package/package.json +1 -1
- package/src/agent.js +16 -4
- package/src/argv.js +11 -1
- package/src/boot.js +35 -2
- package/src/config.js +21 -3
- package/src/index.js +87 -28
- package/src/mcp.js +4 -1
- package/src/pane.js +276 -0
- package/src/rescue.js +306 -0
- package/src/rescueStore.js +277 -0
- package/src/resolve.js +27 -0
- package/src/setup.js +16 -3
- package/src/subagent.js +186 -30
- package/src/tasklog.js +7 -1
- package/src/ui.js +20 -6
- package/src/update.js +291 -0
- package/src/version.js +35 -0
package/src/index.js
CHANGED
|
@@ -4,7 +4,8 @@ import { homedir } from 'node:os';
|
|
|
4
4
|
import { readFile } from 'node:fs/promises';
|
|
5
5
|
import { config, DEFAULT_MODEL, applyLimits, warnIfInsecure } from './config.js';
|
|
6
6
|
import { listModels, listModelDetails, listLoaded, modelLimits, tokenize } from './client.js';
|
|
7
|
-
import { pickDefault, ensureLoaded } from './boot.js';
|
|
7
|
+
import { pickDefault, chatModels, ensureLoaded } from './boot.js';
|
|
8
|
+
import { resolveModel } from './resolve.js';
|
|
8
9
|
import { runTurn, SYSTEM, SYSTEM_AUTO } from './agent.js';
|
|
9
10
|
import { c, banner, fmtContext, statusLine } from './ui.js';
|
|
10
11
|
import { resolveTheme, theme, useTheme } from './theme.js';
|
|
@@ -15,9 +16,12 @@ import { loadServers, McpHub, reportFailures } from './mcp.js';
|
|
|
15
16
|
import { resolveSandbox, sandbox } from './tools.js';
|
|
16
17
|
import { parseArgv } from './argv.js';
|
|
17
18
|
import { deferredPrompt } from './prompt.js';
|
|
18
|
-
import { AGENTS } from './subagent.js';
|
|
19
|
+
import { AGENTS, setServedModels } from './subagent.js';
|
|
19
20
|
import { runLines, transcriptLines, sweep, cleanup } from './tasklog.js';
|
|
20
|
-
import { runSetup } from './setup.js';
|
|
21
|
+
import { runSetup, runKronk } from './setup.js';
|
|
22
|
+
import { runRescue } from './rescue.js';
|
|
23
|
+
import { VERSION, parseKronkVersion } from './version.js';
|
|
24
|
+
import { updateStatus } from './update.js';
|
|
21
25
|
|
|
22
26
|
// ---- argv -------------------------------------------------------------
|
|
23
27
|
const args = parseArgv(process.argv.slice(2));
|
|
@@ -41,6 +45,22 @@ if (args.help) {
|
|
|
41
45
|
pull the model, write its /AGENT profile to
|
|
42
46
|
~/.kronk/models/model_config.yaml, restart Kronk
|
|
43
47
|
|
|
48
|
+
kronk-cli rescue "<prompt>" [--role explore|code] [--model <id>] [-y|-a] [--steps <n>] [--json]
|
|
49
|
+
submit a self-contained task; runs detached,
|
|
50
|
+
prints a job id and exits immediately
|
|
51
|
+
kronk-cli rescue status <id> [--json]
|
|
52
|
+
queued | running (step n) | done | stopped
|
|
53
|
+
| interrupted | failed
|
|
54
|
+
kronk-cli rescue wait <id> [--json]
|
|
55
|
+
block until the job lands, then print its
|
|
56
|
+
report; exit code is done 0, stopped 1,
|
|
57
|
+
interrupted 2, failed 3
|
|
58
|
+
kronk-cli rescue result <id> [--json]
|
|
59
|
+
print the report; refuses (exit 4) if the
|
|
60
|
+
job has not reached one of those four states yet
|
|
61
|
+
without -y/-a, a rescue job refuses any tool
|
|
62
|
+
that needs approval rather than asking nobody
|
|
63
|
+
|
|
44
64
|
OPTIONS
|
|
45
65
|
-l, --models list the models Kronk is serving, then exit
|
|
46
66
|
--no-context skip the startup scan of the working directory
|
|
@@ -56,6 +76,8 @@ if (args.help) {
|
|
|
56
76
|
--no-subagents remove the task tool; no delegation
|
|
57
77
|
--steps <n> cap tool calls per task (default: unlimited)
|
|
58
78
|
-h, --help this message
|
|
79
|
+
-v, --version print the kronk-cli version, and Kronk's if it can be
|
|
80
|
+
had without a network call, then exit
|
|
59
81
|
-- end option parsing; everything after is the prompt
|
|
60
82
|
|
|
61
83
|
ENVIRONMENT
|
|
@@ -77,15 +99,37 @@ if (args.help) {
|
|
|
77
99
|
KRONK_COMPACT_AT fraction of the window that triggers it (default 0.85)
|
|
78
100
|
KRONK_SUBAGENTS false to remove the task tool
|
|
79
101
|
KRONK_SUBAGENT_MODEL
|
|
80
|
-
model
|
|
102
|
+
default model for a task that names none itself
|
|
103
|
+
(default: the main model) — a task can pick its own
|
|
104
|
+
from what Kronk is serving, see /agents
|
|
81
105
|
KRONK_SUBAGENT_STEPS
|
|
82
106
|
tool-call cap for one delegated task (default 40)
|
|
107
|
+
KRONK_UPDATE_CHECK false to never ask the package manager whether a
|
|
108
|
+
newer kronk-cli exists (default true)
|
|
83
109
|
|
|
84
110
|
Config file: ~/.kronk-cli.json
|
|
85
111
|
`);
|
|
86
112
|
process.exit(0);
|
|
87
113
|
}
|
|
88
114
|
|
|
115
|
+
if (args.version) {
|
|
116
|
+
// package.json's version, not a network call, not the server — this must
|
|
117
|
+
// print with no Kronk running, which is the common case for `--version`.
|
|
118
|
+
console.log(`kronk-cli ${VERSION}`);
|
|
119
|
+
// A cheap, short-timeout, local-only ask: no server round trip, and never
|
|
120
|
+
// held open past the timeout if `kronk` itself hangs. Kronk's own API
|
|
121
|
+
// exposes no version field anywhere client.js already reads (checked
|
|
122
|
+
// before writing this), so the binary on disk is the only source there is.
|
|
123
|
+
try {
|
|
124
|
+
const { code, output } = await runKronk(['--version'], { timeoutMs: 3000 });
|
|
125
|
+
const v = code === 0 ? parseKronkVersion(output) : null;
|
|
126
|
+
if (v) console.log(`kronk ${v}`);
|
|
127
|
+
} catch {
|
|
128
|
+
// No `kronk` on PATH, or it did not behave — say nothing rather than guess.
|
|
129
|
+
}
|
|
130
|
+
process.exit(0);
|
|
131
|
+
}
|
|
132
|
+
|
|
89
133
|
// --auto: run the whole task unattended (implies --yes)
|
|
90
134
|
const AUTO = args.auto;
|
|
91
135
|
const AUTO_YES = args.yes || AUTO;
|
|
@@ -124,15 +168,12 @@ async function boot() {
|
|
|
124
168
|
// Fall back to the configured default before guessing.
|
|
125
169
|
if (!config.model && ids.includes(DEFAULT_MODEL)) config.model = DEFAULT_MODEL;
|
|
126
170
|
|
|
171
|
+
// Exact id wins. Otherwise take a substring match, preferring an /AGENT
|
|
172
|
+
// profile — each profile is a SEPARATE resident copy in the pool, so
|
|
173
|
+
// picking the wrong one silently loads a second 20 GB instance. One
|
|
174
|
+
// resolver (src/resolve.js) for this and for the identical problem below.
|
|
127
175
|
if (config.model) {
|
|
128
|
-
|
|
129
|
-
// Exact id wins. Otherwise take a substring match, preferring an /AGENT
|
|
130
|
-
// profile — each profile is a SEPARATE resident copy in the pool, so
|
|
131
|
-
// picking the wrong one silently loads a second 20 GB instance.
|
|
132
|
-
const subs = ids.filter((id) => id.includes(config.model));
|
|
133
|
-
const hit = ids.find((id) => id === config.model)
|
|
134
|
-
?? subs.find((id) => id.endsWith('/AGENT'))
|
|
135
|
-
?? subs[0];
|
|
176
|
+
const hit = resolveModel(config.model, ids);
|
|
136
177
|
if (hit) config.model = hit;
|
|
137
178
|
else {
|
|
138
179
|
console.error(c.yellow(` no model matching "${config.model}" — falling back`));
|
|
@@ -145,10 +186,7 @@ async function boot() {
|
|
|
145
186
|
// model: a substring in the config file that matches nothing would otherwise
|
|
146
187
|
// only surface as a 404 in the middle of somebody's first delegated task.
|
|
147
188
|
if (config.subagentModel) {
|
|
148
|
-
const
|
|
149
|
-
const hit = ids.find((id) => id === config.subagentModel)
|
|
150
|
-
?? subs.find((id) => id.endsWith('/AGENT'))
|
|
151
|
-
?? subs[0];
|
|
189
|
+
const hit = resolveModel(config.subagentModel, ids);
|
|
152
190
|
if (hit) config.subagentModel = hit;
|
|
153
191
|
else {
|
|
154
192
|
console.error(c.yellow(` no model matching "${config.subagentModel}" for sub-agents`
|
|
@@ -157,6 +195,14 @@ async function boot() {
|
|
|
157
195
|
}
|
|
158
196
|
}
|
|
159
197
|
|
|
198
|
+
// What a delegated task's own `model` argument may name — see the `task`
|
|
199
|
+
// tool in src/subagent.js. `chatModels` is a wider list than the one
|
|
200
|
+
// `pickDefault` above chose the session's model from, and deliberately so:
|
|
201
|
+
// picking one default prefers a tuned /AGENT profile outright, while
|
|
202
|
+
// offering a menu must keep the models that have no profile, because the
|
|
203
|
+
// small fast one a survey should be sent to is usually one of them.
|
|
204
|
+
setServedModels(chatModels(ids));
|
|
205
|
+
|
|
160
206
|
if (config.warm) await ensureLoaded(ids);
|
|
161
207
|
|
|
162
208
|
// After `ensureLoaded`, which may have fallen back to a different model
|
|
@@ -164,11 +210,10 @@ async function boot() {
|
|
|
164
210
|
// describe a model this session is not using.
|
|
165
211
|
applyLimits(await modelLimits(config.model));
|
|
166
212
|
|
|
167
|
-
// The sub-agent model's profile is
|
|
168
|
-
// on
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
: null;
|
|
213
|
+
// The sub-agent model's own profile is no longer resolved here: a task can
|
|
214
|
+
// run on any served model now, not only the one configured for delegation,
|
|
215
|
+
// so src/subagent.js resolves and caches each one's limits the first time a
|
|
216
|
+
// task actually runs on it — see `limitsFor` there.
|
|
172
217
|
return ids;
|
|
173
218
|
}
|
|
174
219
|
|
|
@@ -416,6 +461,13 @@ async function main() {
|
|
|
416
461
|
return;
|
|
417
462
|
}
|
|
418
463
|
|
|
464
|
+
// Same reasoning as `setup` just above: reserved before the one-shot path
|
|
465
|
+
// gets a chance to read the rest of the line as a prompt.
|
|
466
|
+
if (args.words[0] === 'rescue') {
|
|
467
|
+
process.exitCode = await runRescue(args);
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
|
|
419
471
|
if (SHOW_MODELS) { await showModels(); return; }
|
|
420
472
|
if (SHOW_MCP) { await showMcp(); process.exit(0); }
|
|
421
473
|
|
|
@@ -456,9 +508,14 @@ async function main() {
|
|
|
456
508
|
},
|
|
457
509
|
});
|
|
458
510
|
|
|
511
|
+
// Synchronous and cheap — a cache-file read, never a subprocess on this
|
|
512
|
+
// path — so it costs nothing to call before `boot()`'s network round trips.
|
|
513
|
+
// See src/update.js for why this never waits on a package manager.
|
|
514
|
+
const update = updateStatus();
|
|
515
|
+
|
|
459
516
|
await boot();
|
|
460
517
|
const { content, ctx } = await systemMessage(AUTO);
|
|
461
|
-
console.log(banner(config.model, config.baseUrl));
|
|
518
|
+
console.log(banner(config.model, config.baseUrl, update));
|
|
462
519
|
|
|
463
520
|
// Information, not a failure: the profile is doing exactly what it was
|
|
464
521
|
// told to, it's just not what the model's own GGUF recommends. One-shot
|
|
@@ -580,8 +637,9 @@ async function main() {
|
|
|
580
637
|
console.log(` ${c.green('●')} ${c.bold(name)} ${c.grey(`· ${a.tools.join(', ')}`)}`);
|
|
581
638
|
console.log(c.grey(` for ${a.use}`));
|
|
582
639
|
}
|
|
583
|
-
console.log(c.grey(` model: ${config.subagentModel ?? config.model} · `
|
|
584
|
-
+ `cap ${config.subagentSteps} steps per task
|
|
640
|
+
console.log(c.grey(` default model: ${config.subagentModel ?? config.model} · `
|
|
641
|
+
+ `cap ${config.subagentSteps} steps per task`));
|
|
642
|
+
console.log(c.grey(' a task can name a different served model itself, when the default is the wrong size for it\n'));
|
|
585
643
|
continue;
|
|
586
644
|
}
|
|
587
645
|
if (input === '/theme' || input.startsWith('/theme ')) {
|
|
@@ -635,12 +693,13 @@ async function main() {
|
|
|
635
693
|
if (input.startsWith('/model ')) {
|
|
636
694
|
const want = input.slice(7).trim();
|
|
637
695
|
const all = await listModels();
|
|
638
|
-
const
|
|
639
|
-
const hit = all.find((m) => m === want)
|
|
640
|
-
?? subs.find((m) => m.endsWith('/AGENT'))
|
|
641
|
-
?? subs[0];
|
|
696
|
+
const hit = resolveModel(want, all);
|
|
642
697
|
if (!hit) { console.log(c.red(` no model matching "${want}"`)); continue; }
|
|
643
698
|
await switchModel(hit);
|
|
699
|
+
// Kronk may be serving something new since boot — a model pulled or
|
|
700
|
+
// unloaded mid-session — so a task's own `model` enum is refreshed from
|
|
701
|
+
// the same list this command just asked for, not left stale from boot.
|
|
702
|
+
setServedModels(chatModels(all));
|
|
644
703
|
console.log(c.green(` switched to ${hit}`));
|
|
645
704
|
console.log(c.grey(` window ${config.contextWindow?.toLocaleString() ?? 'unknown'}`
|
|
646
705
|
+ ` · output cap ${config.maxTokens.toLocaleString()}`
|
package/src/mcp.js
CHANGED
|
@@ -3,9 +3,12 @@ import { readFile } from 'node:fs/promises';
|
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { c } from './ui.js';
|
|
6
|
+
import { VERSION } from './version.js';
|
|
6
7
|
|
|
7
8
|
const PROTOCOL = '2025-06-18';
|
|
8
|
-
|
|
9
|
+
// Six releases carried '0.1.0' here regardless of what package.json actually
|
|
10
|
+
// said, because nothing read the real number. VERSION is that number.
|
|
11
|
+
const CLIENT = { name: 'kronk-cli', version: VERSION };
|
|
9
12
|
const CALL_TIMEOUT = 120_000;
|
|
10
13
|
const START_TIMEOUT = 20_000;
|
|
11
14
|
|
package/src/pane.js
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
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', model, 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
|
+
// The label carries the model too, the same way the opening line above a
|
|
160
|
+
// pipe does (src/subagent.js's `openLine`) — a task on a model that had to
|
|
161
|
+
// be loaded cold is exactly the run a person watches the box wondering why
|
|
162
|
+
// it is slow, and the header is where that question gets answered. The last
|
|
163
|
+
// two path segments, not one: a served id's last segment alone (`AGENT`,
|
|
164
|
+
// `base`) is shared by every profile of every model and says nothing.
|
|
165
|
+
const label = model ? `${agent} · ${String(model).split('/').slice(-2).join('/')}` : agent;
|
|
166
|
+
|
|
167
|
+
const started = now();
|
|
168
|
+
const fed = [];
|
|
169
|
+
let steps = 0;
|
|
170
|
+
let height = 0; // rows the box currently occupies on screen
|
|
171
|
+
let collapsed = false;
|
|
172
|
+
let timer = null;
|
|
173
|
+
let unwatch = null;
|
|
174
|
+
let unabort = null;
|
|
175
|
+
|
|
176
|
+
const status = () => {
|
|
177
|
+
const at = Number.isFinite(maxSteps) ? `${steps}/${maxSteps}` : String(steps);
|
|
178
|
+
return c.grey(`${at} · ${elapsed(now() - started)}`);
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const paint = () => {
|
|
182
|
+
if (collapsed) return;
|
|
183
|
+
const width = paneWidth(stream.columns);
|
|
184
|
+
const hint = c.grey(unwatch ? 'esc to collapse · ctrl-c to stop' : 'ctrl-c to stop');
|
|
185
|
+
const frame = renderPane({ agent: label, rows: fed, status: status(), hint, width });
|
|
186
|
+
// Up to the top of the old box, then repaint row by row. `\x1b[K` on each
|
|
187
|
+
// row so a shorter line does not leave the tail of a longer one behind.
|
|
188
|
+
let buf = height ? `\r\x1b[${height}A` : '';
|
|
189
|
+
for (const l of frame) buf += `${l}\x1b[K\n`;
|
|
190
|
+
// The box only ever grows, but a narrower terminal can shorten it; clear
|
|
191
|
+
// whatever the previous frame left below this one.
|
|
192
|
+
if (height > frame.length) buf += '\x1b[J';
|
|
193
|
+
stream.write(buf);
|
|
194
|
+
height = frame.length;
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const erase = () => {
|
|
198
|
+
if (!height) return;
|
|
199
|
+
stream.write(`\r\x1b[${height}A\x1b[J`);
|
|
200
|
+
height = 0;
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const tick = () => {
|
|
204
|
+
if (timer) return;
|
|
205
|
+
timer = setInterval(paint, tickMs);
|
|
206
|
+
// Never a reason to keep the process alive: the run is what we are waiting on.
|
|
207
|
+
timer.unref?.();
|
|
208
|
+
};
|
|
209
|
+
const untick = () => { clearInterval(timer); timer = null; };
|
|
210
|
+
|
|
211
|
+
const stop = () => {
|
|
212
|
+
untick();
|
|
213
|
+
unwatch?.();
|
|
214
|
+
unwatch = null;
|
|
215
|
+
unabort?.();
|
|
216
|
+
unabort = null;
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
return {
|
|
220
|
+
enabled: true,
|
|
221
|
+
|
|
222
|
+
open() {
|
|
223
|
+
unwatch = watchEsc({ input, onEsc: () => this.collapse() });
|
|
224
|
+
// Ctrl-C aborts the run and then prints `interrupted` — which would land
|
|
225
|
+
// on top of the box, leaving the next repaint counting rows from the
|
|
226
|
+
// wrong place and erasing lines of the conversation above. Abort fires
|
|
227
|
+
// this listener synchronously, so the box is gone before that line
|
|
228
|
+
// prints. Nothing is lost: the transcript has everything it showed.
|
|
229
|
+
const onAbort = () => this.collapse();
|
|
230
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
231
|
+
// Released with everything else: one turn can delegate several times, and
|
|
232
|
+
// a listener left on that turn's signal each time is a leak node warns about.
|
|
233
|
+
unabort = () => signal?.removeEventListener('abort', onAbort);
|
|
234
|
+
paint();
|
|
235
|
+
tick();
|
|
236
|
+
},
|
|
237
|
+
|
|
238
|
+
/** One line of sub-agent activity. Only the last `keep` survive. */
|
|
239
|
+
line(text) {
|
|
240
|
+
if (collapsed) return;
|
|
241
|
+
for (const l of String(text).split('\n')) fed.push(l);
|
|
242
|
+
if (fed.length > keep) fed.splice(0, fed.length - keep);
|
|
243
|
+
paint();
|
|
244
|
+
},
|
|
245
|
+
|
|
246
|
+
step(n) { steps = n; },
|
|
247
|
+
|
|
248
|
+
/** `esc`: give the screen back now. The run carries on into the transcript. */
|
|
249
|
+
collapse() {
|
|
250
|
+
if (collapsed) return;
|
|
251
|
+
erase();
|
|
252
|
+
collapsed = true;
|
|
253
|
+
stop();
|
|
254
|
+
},
|
|
255
|
+
|
|
256
|
+
close() {
|
|
257
|
+
erase();
|
|
258
|
+
collapsed = true;
|
|
259
|
+
stop();
|
|
260
|
+
},
|
|
261
|
+
|
|
262
|
+
shield(fn) {
|
|
263
|
+
if (!fn) return fn;
|
|
264
|
+
return async (...args) => {
|
|
265
|
+
const wasUp = !collapsed && height > 0;
|
|
266
|
+
erase();
|
|
267
|
+
untick();
|
|
268
|
+
try {
|
|
269
|
+
return await fn(...args);
|
|
270
|
+
} finally {
|
|
271
|
+
if (wasUp && !collapsed) { paint(); tick(); }
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
},
|
|
275
|
+
};
|
|
276
|
+
}
|