kronk-cli 0.1.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/LICENSE +202 -0
- package/NOTICE +8 -0
- package/README.md +712 -0
- package/package.json +48 -0
- package/src/agent.js +190 -0
- package/src/client.js +115 -0
- package/src/compact.js +108 -0
- package/src/config.js +41 -0
- package/src/context.js +95 -0
- package/src/distill.js +112 -0
- package/src/index.js +485 -0
- package/src/mcp.js +268 -0
- package/src/sse.js +45 -0
- package/src/tools.js +270 -0
- package/src/ui.js +119 -0
package/src/mcp.js
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { c } from './ui.js';
|
|
6
|
+
|
|
7
|
+
const PROTOCOL = '2025-06-18';
|
|
8
|
+
const CLIENT = { name: 'kronk-cli', version: '0.1.0' };
|
|
9
|
+
const CALL_TIMEOUT = 120_000;
|
|
10
|
+
const START_TIMEOUT = 20_000;
|
|
11
|
+
|
|
12
|
+
/* ------------------------------------------------------------------ config */
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Read MCP servers from the places a developer already keeps them:
|
|
16
|
+
* Claude Code's global and per-project config, a project `.mcp.json`, and
|
|
17
|
+
* kronk-cli's own rc file. Later sources win on name collision.
|
|
18
|
+
*/
|
|
19
|
+
export async function loadServers(cwd = process.cwd()) {
|
|
20
|
+
const out = {};
|
|
21
|
+
const merge = (obj) => { for (const [k, v] of Object.entries(obj ?? {})) out[k] = v; };
|
|
22
|
+
|
|
23
|
+
const readJson = async (p) => {
|
|
24
|
+
try { return JSON.parse(await readFile(p, 'utf8')); } catch { return null; }
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const claude = await readJson(join(homedir(), '.claude.json'));
|
|
28
|
+
if (claude) {
|
|
29
|
+
merge(claude.mcpServers);
|
|
30
|
+
merge(claude.projects?.[cwd]?.mcpServers);
|
|
31
|
+
}
|
|
32
|
+
merge((await readJson(join(cwd, '.mcp.json')))?.mcpServers);
|
|
33
|
+
merge((await readJson(join(homedir(), '.kronk-cli.json')))?.mcpServers);
|
|
34
|
+
merge((await readJson(join(cwd, '.kronk-cli.json')))?.mcpServers);
|
|
35
|
+
|
|
36
|
+
// `"disabled": true` keeps an entry in the file but out of this session.
|
|
37
|
+
for (const [k, v] of Object.entries(out)) if (v?.disabled) delete out[k];
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/* --------------------------------------------------------------- transports */
|
|
42
|
+
|
|
43
|
+
/** Newline-delimited JSON-RPC over a child process's stdio. */
|
|
44
|
+
function stdioTransport(spec, name) {
|
|
45
|
+
const child = spawn(spec.command, spec.args ?? [], {
|
|
46
|
+
env: { ...process.env, ...(spec.env ?? {}) },
|
|
47
|
+
cwd: spec.cwd ?? process.cwd(),
|
|
48
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const pending = new Map();
|
|
52
|
+
let buf = '';
|
|
53
|
+
let stderrTail = '';
|
|
54
|
+
|
|
55
|
+
child.stdout.on('data', (d) => {
|
|
56
|
+
buf += d.toString();
|
|
57
|
+
const lines = buf.split('\n');
|
|
58
|
+
buf = lines.pop() ?? '';
|
|
59
|
+
for (const line of lines) {
|
|
60
|
+
if (!line.trim()) continue;
|
|
61
|
+
let msg;
|
|
62
|
+
try { msg = JSON.parse(line); } catch { continue; } // servers log to stdout sometimes
|
|
63
|
+
const p = pending.get(msg.id);
|
|
64
|
+
if (!p) continue;
|
|
65
|
+
pending.delete(msg.id);
|
|
66
|
+
msg.error ? p.reject(new Error(msg.error.message ?? 'mcp error')) : p.resolve(msg.result);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// Keep the last of stderr so a crash can be explained.
|
|
71
|
+
child.stderr.on('data', (d) => { stderrTail = (stderrTail + d.toString()).slice(-2000); });
|
|
72
|
+
|
|
73
|
+
const fail = (e) => {
|
|
74
|
+
for (const p of pending.values()) p.reject(e);
|
|
75
|
+
pending.clear();
|
|
76
|
+
};
|
|
77
|
+
child.on('error', (e) => fail(new Error(`${name}: ${e.message}`)));
|
|
78
|
+
child.on('exit', (code) => fail(new Error(`${name}: exited (${code}) ${stderrTail.trim()}`)));
|
|
79
|
+
|
|
80
|
+
// A live child with piped stdio holds the event loop open, so the CLI would
|
|
81
|
+
// print its answer and then hang instead of exiting. Unref everything; we
|
|
82
|
+
// kill the child explicitly on close.
|
|
83
|
+
child.unref();
|
|
84
|
+
child.stdout.unref?.();
|
|
85
|
+
child.stderr.unref?.();
|
|
86
|
+
child.stdin.unref?.();
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
kind: 'stdio',
|
|
90
|
+
async send(msg, timeout) {
|
|
91
|
+
if (msg.id === undefined) { // notification
|
|
92
|
+
child.stdin.write(`${JSON.stringify(msg)}\n`);
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
return new Promise((resolve, reject) => {
|
|
96
|
+
const t = setTimeout(() => {
|
|
97
|
+
pending.delete(msg.id);
|
|
98
|
+
reject(new Error(`${name}: timed out after ${timeout}ms`));
|
|
99
|
+
}, timeout);
|
|
100
|
+
pending.set(msg.id, {
|
|
101
|
+
resolve: (v) => { clearTimeout(t); resolve(v); },
|
|
102
|
+
reject: (e) => { clearTimeout(t); reject(e); },
|
|
103
|
+
});
|
|
104
|
+
child.stdin.write(`${JSON.stringify(msg)}\n`);
|
|
105
|
+
});
|
|
106
|
+
},
|
|
107
|
+
close() { child.kill(); },
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Streamable HTTP: POST JSON-RPC, accept either JSON or an SSE stream back. */
|
|
112
|
+
function httpTransport(spec, name) {
|
|
113
|
+
let sessionId = null;
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
kind: 'http',
|
|
117
|
+
async send(msg, timeout) {
|
|
118
|
+
const ac = new AbortController();
|
|
119
|
+
const t = setTimeout(() => ac.abort(), timeout);
|
|
120
|
+
try {
|
|
121
|
+
const res = await fetch(spec.url, {
|
|
122
|
+
method: 'POST',
|
|
123
|
+
signal: ac.signal,
|
|
124
|
+
headers: {
|
|
125
|
+
'Content-Type': 'application/json',
|
|
126
|
+
Accept: 'application/json, text/event-stream',
|
|
127
|
+
...(sessionId ? { 'Mcp-Session-Id': sessionId } : {}),
|
|
128
|
+
...(spec.headers ?? {}),
|
|
129
|
+
},
|
|
130
|
+
body: JSON.stringify(msg),
|
|
131
|
+
});
|
|
132
|
+
const sid = res.headers.get('mcp-session-id');
|
|
133
|
+
if (sid) sessionId = sid;
|
|
134
|
+
if (msg.id === undefined) return undefined;
|
|
135
|
+
if (!res.ok) throw new Error(`${name}: HTTP ${res.status} ${(await res.text()).slice(0, 200)}`);
|
|
136
|
+
|
|
137
|
+
const body = await res.text();
|
|
138
|
+
// SSE framing when the server streams; plain JSON otherwise.
|
|
139
|
+
const payload = body.startsWith('event:') || body.startsWith('data:')
|
|
140
|
+
? body.split('\n').filter((l) => l.startsWith('data:')).map((l) => l.slice(5).trim()).join('')
|
|
141
|
+
: body;
|
|
142
|
+
const parsed = JSON.parse(payload);
|
|
143
|
+
if (parsed.error) throw new Error(parsed.error.message ?? 'mcp error');
|
|
144
|
+
return parsed.result;
|
|
145
|
+
} finally { clearTimeout(t); }
|
|
146
|
+
},
|
|
147
|
+
close() {},
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/* ------------------------------------------------------------------ client */
|
|
152
|
+
|
|
153
|
+
class Server {
|
|
154
|
+
constructor(name, spec) {
|
|
155
|
+
this.name = name;
|
|
156
|
+
this.spec = spec;
|
|
157
|
+
this.tools = [];
|
|
158
|
+
this.nextId = 1;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async start() {
|
|
162
|
+
const isHttp = this.spec.url || this.spec.type === 'http' || this.spec.type === 'sse';
|
|
163
|
+
if (!isHttp && !this.spec.command) throw new Error('needs a command or a url');
|
|
164
|
+
this.transport = isHttp
|
|
165
|
+
? httpTransport(this.spec, this.name)
|
|
166
|
+
: stdioTransport(this.spec, this.name);
|
|
167
|
+
|
|
168
|
+
await this.rpc('initialize', {
|
|
169
|
+
protocolVersion: PROTOCOL,
|
|
170
|
+
capabilities: { tools: {} },
|
|
171
|
+
clientInfo: CLIENT,
|
|
172
|
+
}, START_TIMEOUT);
|
|
173
|
+
|
|
174
|
+
await this.transport.send({ jsonrpc: '2.0', method: 'notifications/initialized' }, START_TIMEOUT);
|
|
175
|
+
|
|
176
|
+
const { tools } = await this.rpc('tools/list', {}, START_TIMEOUT);
|
|
177
|
+
this.tools = tools ?? [];
|
|
178
|
+
return this.tools;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
rpc(method, params, timeout = CALL_TIMEOUT) {
|
|
182
|
+
return this.transport.send(
|
|
183
|
+
{ jsonrpc: '2.0', id: this.nextId++, method, params },
|
|
184
|
+
timeout,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
close() { this.transport?.close(); }
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Function names must match ^[a-zA-Z0-9_-]{1,64}$ for the chat API. */
|
|
192
|
+
export const qualify = (server, tool) =>
|
|
193
|
+
`${server}__${tool}`.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64);
|
|
194
|
+
|
|
195
|
+
export class McpHub {
|
|
196
|
+
constructor() {
|
|
197
|
+
this.servers = new Map();
|
|
198
|
+
this.routes = new Map(); // qualified name -> { server, tool }
|
|
199
|
+
this.failures = [];
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Start every configured server. One failing never blocks the others. */
|
|
203
|
+
async connect(specs) {
|
|
204
|
+
const entries = Object.entries(specs);
|
|
205
|
+
await Promise.all(entries.map(async ([name, spec]) => {
|
|
206
|
+
const server = new Server(name, spec);
|
|
207
|
+
try {
|
|
208
|
+
const tools = await server.start();
|
|
209
|
+
this.servers.set(name, server);
|
|
210
|
+
for (const t of tools) this.routes.set(qualify(name, t.name), { server, tool: t.name });
|
|
211
|
+
} catch (e) {
|
|
212
|
+
this.failures.push({ name, error: e.message });
|
|
213
|
+
server.close();
|
|
214
|
+
}
|
|
215
|
+
}));
|
|
216
|
+
return this;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** MCP tool definitions in OpenAI function-calling shape. */
|
|
220
|
+
toolDefs() {
|
|
221
|
+
const defs = [];
|
|
222
|
+
for (const [qualified, { server, tool }] of this.routes) {
|
|
223
|
+
const def = server.tools.find((t) => t.name === tool);
|
|
224
|
+
defs.push({
|
|
225
|
+
type: 'function',
|
|
226
|
+
function: {
|
|
227
|
+
name: qualified,
|
|
228
|
+
description: `[${server.name}] ${def?.description ?? tool}`.slice(0, 1024),
|
|
229
|
+
parameters: def?.inputSchema ?? { type: 'object', properties: {} },
|
|
230
|
+
},
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
return defs;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
has(name) { return this.routes.has(name); }
|
|
237
|
+
|
|
238
|
+
async call(name, args) {
|
|
239
|
+
const route = this.routes.get(name);
|
|
240
|
+
if (!route) return `error: unknown MCP tool ${name}`;
|
|
241
|
+
try {
|
|
242
|
+
const res = await route.server.rpc('tools/call', { name: route.tool, arguments: args ?? {} });
|
|
243
|
+
const text = (res?.content ?? [])
|
|
244
|
+
.map((part) => {
|
|
245
|
+
if (part.type === 'text') return part.text;
|
|
246
|
+
if (part.type === 'resource') return part.resource?.text ?? `[resource ${part.resource?.uri}]`;
|
|
247
|
+
return `[${part.type}]`;
|
|
248
|
+
})
|
|
249
|
+
.join('\n')
|
|
250
|
+
.trim();
|
|
251
|
+
const body = text || JSON.stringify(res?.structuredContent ?? res ?? {}).slice(0, 4000);
|
|
252
|
+
return res?.isError ? `error: ${body}` : body;
|
|
253
|
+
} catch (e) {
|
|
254
|
+
return `error: ${e.message}`;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
summary() {
|
|
259
|
+
const parts = [...this.servers.values()].map((s) => `${s.name}(${s.tools.length})`);
|
|
260
|
+
return parts.join(' · ');
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
close() { for (const s of this.servers.values()) s.close(); }
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function reportFailures(failures) {
|
|
267
|
+
for (const f of failures) console.log(c.yellow(` mcp ${f.name}: ${f.error.slice(0, 160)}`));
|
|
268
|
+
}
|
package/src/sse.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Incremental Server-Sent Events parser.
|
|
3
|
+
*
|
|
4
|
+
* Kept separate from the HTTP call so the protocol logic can be tested without
|
|
5
|
+
* a server: chunk boundaries fall in arbitrary places, and a parser that only
|
|
6
|
+
* works when each read lands on a line boundary will fail in production and
|
|
7
|
+
* pass every naive test.
|
|
8
|
+
*/
|
|
9
|
+
export function createSseParser() {
|
|
10
|
+
let buf = '';
|
|
11
|
+
return {
|
|
12
|
+
/** Feed a chunk; get back the complete `data:` payloads it completed. */
|
|
13
|
+
push(chunk) {
|
|
14
|
+
buf += chunk;
|
|
15
|
+
const lines = buf.split('\n');
|
|
16
|
+
buf = lines.pop() ?? '';
|
|
17
|
+
const out = [];
|
|
18
|
+
for (const line of lines) {
|
|
19
|
+
if (!line.startsWith('data: ')) continue;
|
|
20
|
+
const payload = line.slice(6).trim();
|
|
21
|
+
if (payload) out.push(payload);
|
|
22
|
+
}
|
|
23
|
+
return out;
|
|
24
|
+
},
|
|
25
|
+
/** Anything left in the buffer that never saw a newline. */
|
|
26
|
+
flush() {
|
|
27
|
+
const rest = buf;
|
|
28
|
+
buf = '';
|
|
29
|
+
return rest.startsWith('data: ') ? [rest.slice(6).trim()].filter(Boolean) : [];
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Merge streamed tool-call deltas, accumulating arguments by index. */
|
|
35
|
+
export function accumulateToolCalls(calls, deltas) {
|
|
36
|
+
for (const tc of deltas ?? []) {
|
|
37
|
+
const idx = tc.index ?? 0;
|
|
38
|
+
const cur = calls.get(idx) ?? { id: '', name: '', args: '' };
|
|
39
|
+
if (tc.id) cur.id = tc.id;
|
|
40
|
+
if (tc.function?.name) cur.name = tc.function.name;
|
|
41
|
+
if (tc.function?.arguments) cur.args += tc.function.arguments;
|
|
42
|
+
calls.set(idx, cur);
|
|
43
|
+
}
|
|
44
|
+
return calls;
|
|
45
|
+
}
|
package/src/tools.js
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { readFile, writeFile, readdir, stat } from 'node:fs/promises';
|
|
2
|
+
import { execFile, spawn } from 'node:child_process';
|
|
3
|
+
import { promisify } from 'node:util';
|
|
4
|
+
import { resolve, relative } from 'node:path';
|
|
5
|
+
import { realpathSync } from 'node:fs';
|
|
6
|
+
import { c } from './ui.js';
|
|
7
|
+
|
|
8
|
+
const exec = promisify(execFile);
|
|
9
|
+
export const MAX_OUT = 30_000;
|
|
10
|
+
const MAX_CAPTURE = 400_000; // keep the tail of chatty builds
|
|
11
|
+
const TOOL_TIMEOUT = Number(process.env.KRONK_TOOL_TIMEOUT ?? 900) * 1000; // 15 min
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* One shell session for the whole run.
|
|
15
|
+
*
|
|
16
|
+
* Each bash call is a fresh `bash -c`, so a bare `cd` would evaporate and the
|
|
17
|
+
* next command would silently run somewhere else — the model then flails,
|
|
18
|
+
* re-running `pwd` and `cd` trying to work out where it is. We keep the cwd
|
|
19
|
+
* here and hand it back to every subsequent call.
|
|
20
|
+
*/
|
|
21
|
+
export const session = { root: real(process.cwd()), cwd: real(process.cwd()) };
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Resolve symlinks before comparing paths.
|
|
25
|
+
*
|
|
26
|
+
* macOS maps /tmp and /var onto /private/*, so a shell reporting its own `pwd`
|
|
27
|
+
* hands back a path that no longer looks like a child of the launch root. The
|
|
28
|
+
* containment check then fails and `cd` silently stops persisting.
|
|
29
|
+
*/
|
|
30
|
+
function real(p) {
|
|
31
|
+
try { return realpathSync(p); } catch { return p; }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Trim from the MIDDLE, never the end.
|
|
36
|
+
*
|
|
37
|
+
* Build output puts its errors last, so head-only truncation silently dropped
|
|
38
|
+
* exactly the lines that mattered and left 30k characters of progress chatter.
|
|
39
|
+
*/
|
|
40
|
+
export const clip = (s) => {
|
|
41
|
+
if (s.length <= MAX_OUT) return s;
|
|
42
|
+
const head = Math.floor(MAX_OUT * 0.3);
|
|
43
|
+
const tail = MAX_OUT - head;
|
|
44
|
+
const dropped = s.length - head - tail;
|
|
45
|
+
return `${s.slice(0, head)}\n…[${dropped.toLocaleString()} chars elided from the middle]…\n${s.slice(-tail)}`;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** Resolve against the session cwd, and keep the agent inside the launch root. */
|
|
49
|
+
export function safe(p) {
|
|
50
|
+
const abs = resolve(real(session.cwd), p);
|
|
51
|
+
const rel = relative(real(session.root), abs);
|
|
52
|
+
if (rel.startsWith('..')) throw new Error(`refusing to touch path outside ${session.root}: ${p}`);
|
|
53
|
+
return abs;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const def = (name, description, properties, required) => ({
|
|
57
|
+
type: 'function',
|
|
58
|
+
function: { name, description, parameters: { type: 'object', properties, required } },
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
export const TOOLS = [
|
|
62
|
+
def('read_file', 'Read a UTF-8 text file relative to the working directory.',
|
|
63
|
+
{ path: { type: 'string' } }, ['path']),
|
|
64
|
+
|
|
65
|
+
def('write_file', 'Create or overwrite a text file. Requires user approval.',
|
|
66
|
+
{ path: { type: 'string' }, content: { type: 'string' } }, ['path', 'content']),
|
|
67
|
+
|
|
68
|
+
def('list_dir', 'List entries in a directory. Defaults to the working directory.',
|
|
69
|
+
{ path: { type: 'string' } }, []),
|
|
70
|
+
|
|
71
|
+
def('search', 'Search file contents with a regular expression (ripgrep-style). Returns matching lines with file:line prefixes.',
|
|
72
|
+
{ pattern: { type: 'string' }, path: { type: 'string' } }, ['pattern']),
|
|
73
|
+
|
|
74
|
+
def('bash', 'Run a shell command in the working directory. Requires user approval.',
|
|
75
|
+
{ cmd: { type: 'string' } }, ['cmd']),
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
/** Tools that mutate state or run arbitrary code must be confirmed. */
|
|
79
|
+
export const NEEDS_APPROVAL = new Set(['write_file', 'bash']);
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* MCP tools are arbitrary third-party code, so anything that looks like it
|
|
83
|
+
* writes gets gated. Read-only lookups stay frictionless — prompting for
|
|
84
|
+
* `nx__nx_docs` would train you to hit `y` without reading.
|
|
85
|
+
*/
|
|
86
|
+
const MUTATING = /(^|_)(create|update|delete|remove|write|edit|apply|sync|run|exec|deploy|restart|scale|patch|set|add|move|rename|push|merge|close|assign)(_|$)/i;
|
|
87
|
+
|
|
88
|
+
export function mcpNeedsApproval(qualifiedName) {
|
|
89
|
+
return MUTATING.test(qualifiedName.split('__').slice(1).join('__'));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function preview(name, args) {
|
|
93
|
+
if (name === 'write_file') {
|
|
94
|
+
const lines = (args.content ?? '').split('\n');
|
|
95
|
+
const head = lines.slice(0, 12).map((l) => c.green(`+ ${l}`)).join('\n');
|
|
96
|
+
const more = lines.length > 12 ? c.grey(`\n …${lines.length - 12} more lines`) : '';
|
|
97
|
+
return `${c.bold(args.path)}\n${head}${more}`;
|
|
98
|
+
}
|
|
99
|
+
if (name === 'bash') return c.yellow(`$ ${args.cmd}`);
|
|
100
|
+
return '';
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function describe(name, args) {
|
|
104
|
+
switch (name) {
|
|
105
|
+
case 'read_file': return `read ${args.path}`;
|
|
106
|
+
case 'write_file': return `write ${args.path}`;
|
|
107
|
+
case 'list_dir': return `ls ${args.path ?? '.'}`;
|
|
108
|
+
case 'search': return `search /${args.pattern}/ in ${args.path ?? '.'}`;
|
|
109
|
+
case 'bash': return `bash: ${args.cmd}`;
|
|
110
|
+
default: return `${name}(${JSON.stringify(args)})`;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Strip the cwd marker off command output and record where we ended up. */
|
|
115
|
+
function applyCwd(out, mark) {
|
|
116
|
+
const i = out.lastIndexOf(mark);
|
|
117
|
+
if (i === -1) return out;
|
|
118
|
+
const next = real(out.slice(i + mark.length).trim());
|
|
119
|
+
const rel = relative(real(session.root), next);
|
|
120
|
+
if (next && !rel.startsWith('..')) session.cwd = next;
|
|
121
|
+
return out.slice(0, i).replace(/\n$/, '');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const MARK = '__KRONK_CWD__';
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Run a shell command, streaming progress to `onProgress` as it goes.
|
|
128
|
+
*
|
|
129
|
+
* `execFile` buffers silently, so a ten-minute build looked identical to a
|
|
130
|
+
* hang, and when it was killed the rejection carried `code: undefined` — the
|
|
131
|
+
* infamous `exit code ?`. Spawning directly gives us live output, the real
|
|
132
|
+
* signal, and whatever the command managed to print before it died.
|
|
133
|
+
*/
|
|
134
|
+
export function runBash(cmd, { onProgress, timeoutMs = TOOL_TIMEOUT } = {}) {
|
|
135
|
+
return new Promise((resolve) => {
|
|
136
|
+
const started = Date.now();
|
|
137
|
+
// Own process group: killing bash alone leaves its children running and
|
|
138
|
+
// holding the stdout pipe open, so `close` would not fire until they
|
|
139
|
+
// finished anyway — a 5s timeout that returned after 30s.
|
|
140
|
+
// Capture the real status BEFORE the marker runs, then exit with it.
|
|
141
|
+
// Appending `printf` naively made every command look successful, so
|
|
142
|
+
// failures never reached the agent at all.
|
|
143
|
+
const script = `${cmd}\n__kronk_st=$?\nprintf '\\n${MARK}%s' "$(pwd)"\nexit $__kronk_st`;
|
|
144
|
+
const child = spawn('bash', ['-c', script], {
|
|
145
|
+
cwd: session.cwd,
|
|
146
|
+
env: { ...process.env, TERM: 'dumb', CI: process.env.CI ?? '1' },
|
|
147
|
+
detached: true,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const killTree = (sig) => {
|
|
151
|
+
try { process.kill(-child.pid, sig); }
|
|
152
|
+
catch { try { child.kill(sig); } catch { /* already gone */ } }
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
let out = '';
|
|
156
|
+
let err = '';
|
|
157
|
+
let truncated = false;
|
|
158
|
+
let lines = 0;
|
|
159
|
+
let produced = 0; // total bytes the command has emitted
|
|
160
|
+
|
|
161
|
+
const take = (chunk, into) => {
|
|
162
|
+
const text = chunk.toString();
|
|
163
|
+
produced += text.length;
|
|
164
|
+
lines += (text.match(/\n/g) ?? []).length;
|
|
165
|
+
if (into === 'out') out += text; else err += text;
|
|
166
|
+
if (out.length + err.length > MAX_CAPTURE) {
|
|
167
|
+
truncated = true;
|
|
168
|
+
if (out.length > MAX_CAPTURE) out = out.slice(-MAX_CAPTURE);
|
|
169
|
+
if (err.length > MAX_CAPTURE) err = err.slice(-MAX_CAPTURE);
|
|
170
|
+
}
|
|
171
|
+
const lastLine = text.trimEnd().split('\n').pop() ?? '';
|
|
172
|
+
onProgress?.({
|
|
173
|
+
seconds: (Date.now() - started) / 1000,
|
|
174
|
+
lines,
|
|
175
|
+
bytes: produced,
|
|
176
|
+
kept: Math.min(produced, MAX_OUT),
|
|
177
|
+
capped: produced > MAX_OUT,
|
|
178
|
+
lastLine: lastLine.slice(0, 90),
|
|
179
|
+
});
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
child.stdout.on('data', (d) => take(d, 'out'));
|
|
183
|
+
child.stderr.on('data', (d) => take(d, 'err'));
|
|
184
|
+
|
|
185
|
+
let timedOut = false;
|
|
186
|
+
const timer = setTimeout(() => { timedOut = true; killTree('SIGKILL'); }, timeoutMs);
|
|
187
|
+
|
|
188
|
+
child.on('error', (e) => {
|
|
189
|
+
clearTimeout(timer);
|
|
190
|
+
resolve(`error: could not start command — ${e.message}\ncwd: ${session.cwd}`);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
child.on('close', (code, signal) => {
|
|
194
|
+
clearTimeout(timer);
|
|
195
|
+
const secs = ((Date.now() - started) / 1000).toFixed(1);
|
|
196
|
+
const body = applyCwd(out, MARK);
|
|
197
|
+
const tail = [
|
|
198
|
+
body.trim() && `stdout:\n${body.trim()}`,
|
|
199
|
+
err.trim() && `stderr:\n${err.trim()}`,
|
|
200
|
+
].filter(Boolean).join('\n\n');
|
|
201
|
+
|
|
202
|
+
if (timedOut) {
|
|
203
|
+
return resolve(clip([
|
|
204
|
+
`error: killed after ${secs}s (timeout ${Math.round(timeoutMs / 1000)}s).`,
|
|
205
|
+
'The command may simply be slow — re-run a narrower scope, or raise KRONK_TOOL_TIMEOUT.',
|
|
206
|
+
`cwd: ${session.cwd}`,
|
|
207
|
+
tail || '(no output before it was killed)',
|
|
208
|
+
].join('\n')));
|
|
209
|
+
}
|
|
210
|
+
if (code === 0) {
|
|
211
|
+
const okBody = clip(body + err);
|
|
212
|
+
return resolve(`${okBody.trim() || '(no output)'}${truncated ? '\n[earlier output dropped]' : ''}`);
|
|
213
|
+
}
|
|
214
|
+
return resolve(clip([
|
|
215
|
+
signal
|
|
216
|
+
? `error: killed by ${signal} after ${secs}s`
|
|
217
|
+
: `error: exit code ${code} after ${secs}s`,
|
|
218
|
+
`cwd: ${session.cwd}`,
|
|
219
|
+
tail || '(no output)',
|
|
220
|
+
truncated ? '[earlier output dropped]' : '',
|
|
221
|
+
].filter(Boolean).join('\n')));
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export async function runTool(name, args, opts = {}) {
|
|
227
|
+
try {
|
|
228
|
+
switch (name) {
|
|
229
|
+
case 'read_file':
|
|
230
|
+
return clip(await readFile(safe(args.path), 'utf8'));
|
|
231
|
+
|
|
232
|
+
case 'write_file':
|
|
233
|
+
await writeFile(safe(args.path), args.content ?? '');
|
|
234
|
+
return `wrote ${args.path} (${(args.content ?? '').length} bytes)`;
|
|
235
|
+
|
|
236
|
+
case 'list_dir': {
|
|
237
|
+
const dir = safe(args.path ?? '.');
|
|
238
|
+
const names = await readdir(dir);
|
|
239
|
+
const rows = await Promise.all(names.map(async (n) => {
|
|
240
|
+
try {
|
|
241
|
+
const s = await stat(resolve(dir, n));
|
|
242
|
+
return s.isDirectory() ? `${n}/` : `${n} ${s.size}b`;
|
|
243
|
+
} catch { return n; }
|
|
244
|
+
}));
|
|
245
|
+
return clip(rows.join('\n')) || '(empty)';
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
case 'search': {
|
|
249
|
+
const where = args.path ?? '.';
|
|
250
|
+
try {
|
|
251
|
+
const { stdout } = await exec('rg', ['-n', '--no-heading', '-m', '200', args.pattern, where]);
|
|
252
|
+
return clip(stdout) || '(no matches)';
|
|
253
|
+
} catch (e) {
|
|
254
|
+
if (e.code === 1) return '(no matches)';
|
|
255
|
+
const { stdout } = await exec('grep', ['-rn', '-m', '200', args.pattern, where]);
|
|
256
|
+
return clip(stdout) || '(no matches)';
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
case 'bash':
|
|
261
|
+
return runBash(args.cmd, opts);
|
|
262
|
+
|
|
263
|
+
default:
|
|
264
|
+
return `error: unknown tool ${name}`;
|
|
265
|
+
}
|
|
266
|
+
} catch (e) {
|
|
267
|
+
// Errors are data, not crashes — hand them back so the model can recover.
|
|
268
|
+
return `error: ${e.message}${e.stdout ? `\n${e.stdout}` : ''}${e.stderr ? `\n${e.stderr}` : ''}`;
|
|
269
|
+
}
|
|
270
|
+
}
|