klyro 1.0.0 → 1.0.2
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/dist/agent/anthropic-adapter.d.ts +49 -5
- package/dist/agent/anthropic-adapter.js +86 -17
- package/dist/agent/capabilities.d.ts +23 -0
- package/dist/agent/capabilities.js +53 -6
- package/dist/agent/child-worker.d.ts +104 -0
- package/dist/agent/child-worker.js +250 -0
- package/dist/agent/orchestrator.d.ts +124 -6
- package/dist/agent/orchestrator.js +425 -58
- package/dist/agent/provider-adapter.d.ts +8 -0
- package/dist/agent/provider-adapter.js +12 -3
- package/dist/agent/retry.d.ts +1 -1
- package/dist/agent/retry.js +54 -12
- package/dist/agent/runtime.d.ts +84 -8
- package/dist/agent/runtime.js +352 -39
- package/dist/agent/stream-budget.d.ts +36 -0
- package/dist/agent/stream-budget.js +121 -0
- package/dist/agent/worktree-manager.d.ts +74 -0
- package/dist/agent/worktree-manager.js +189 -0
- package/dist/checkpoints/store.d.ts +9 -0
- package/dist/checkpoints/store.js +56 -5
- package/dist/cli/auth.js +16 -1
- package/dist/cli/commit.d.ts +31 -0
- package/dist/cli/commit.js +142 -0
- package/dist/cli/config.d.ts +54 -3
- package/dist/cli/config.js +146 -3
- package/dist/cli/doctor.d.ts +1 -0
- package/dist/cli/doctor.js +71 -6
- package/dist/cli/eval.d.ts +6 -1
- package/dist/cli/eval.js +9 -0
- package/dist/cli/hooks.d.ts +47 -0
- package/dist/cli/hooks.js +181 -0
- package/dist/cli/repl.js +196 -29
- package/dist/cli/run.d.ts +13 -11
- package/dist/cli/run.js +144 -20
- package/dist/cli/update.d.ts +5 -0
- package/dist/cli/update.js +62 -10
- package/dist/context/import-graph.d.ts +2 -0
- package/dist/context/import-graph.js +31 -3
- package/dist/context/klyro-md.js +4 -1
- package/dist/context/memory.d.ts +8 -0
- package/dist/context/memory.js +50 -2
- package/dist/context/project-map.d.ts +6 -0
- package/dist/context/project-map.js +50 -2
- package/dist/context/repo-map.d.ts +2 -0
- package/dist/context/repo-map.js +31 -1
- package/dist/events/catalog.d.ts +37 -0
- package/dist/events/catalog.js +9 -0
- package/dist/index.js +177 -8
- package/dist/mcp/client.d.ts +6 -4
- package/dist/mcp/client.js +83 -14
- package/dist/mcp/config.d.ts +10 -0
- package/dist/mcp/config.js +18 -1
- package/dist/mcp/registry.d.ts +23 -19
- package/dist/mcp/registry.js +127 -8
- package/dist/mcp/schema.d.ts +11 -4
- package/dist/mcp/schema.js +27 -16
- package/dist/mcp/trust.d.ts +20 -0
- package/dist/mcp/trust.js +74 -0
- package/dist/persistence/audit.d.ts +28 -0
- package/dist/persistence/audit.js +101 -1
- package/dist/persistence/store.d.ts +26 -2
- package/dist/persistence/store.js +140 -13
- package/dist/policy/approval.d.ts +14 -0
- package/dist/policy/approval.js +44 -2
- package/dist/policy/engine.d.ts +17 -0
- package/dist/policy/engine.js +162 -9
- package/dist/policy/path-guard.d.ts +24 -0
- package/dist/policy/path-guard.js +46 -0
- package/dist/policy/secret-redactor.js +4 -0
- package/dist/providers/model-info.d.ts +23 -0
- package/dist/providers/model-info.js +43 -2
- package/dist/repl.d.ts +6 -0
- package/dist/repl.js +12 -7
- package/dist/tools/agent/spawn-agent.js +5 -5
- package/dist/tools/agent/task-apply.d.ts +4 -0
- package/dist/tools/agent/task-apply.js +44 -0
- package/dist/tools/agent/task-stop.d.ts +6 -0
- package/dist/tools/agent/task-stop.js +39 -0
- package/dist/tools/agent/task-wait.d.ts +17 -0
- package/dist/tools/agent/task-wait.js +79 -0
- package/dist/tools/fs/apply-patch.js +77 -1
- package/dist/tools/fs/edit-file.js +69 -1
- package/dist/tools/fs/multi-edit.d.ts +4 -0
- package/dist/tools/fs/multi-edit.js +70 -1
- package/dist/tools/fs/write-file.js +83 -6
- package/dist/tools/plan/todo-write.js +1 -1
- package/dist/tools/registry.js +6 -0
- package/dist/tools/shell/background.js +6 -3
- package/dist/tools/shell/sandbox.d.ts +51 -0
- package/dist/tools/shell/sandbox.js +143 -0
- package/dist/tools/shell/shell-exec.d.ts +29 -0
- package/dist/tools/shell/shell-exec.js +170 -12
- package/dist/tools/shell/worker-entry.d.ts +12 -0
- package/dist/tools/shell/worker-entry.js +43 -0
- package/dist/tools/types.d.ts +6 -0
- package/dist/tools/verify/run-verify.js +3 -1
- package/dist/trace/writer.d.ts +20 -0
- package/dist/trace/writer.js +62 -4
- package/dist/tui/app.js +1 -1
- package/dist/tui/approval.js +20 -21
- package/dist/util.d.ts +1 -0
- package/dist/util.js +1 -0
- package/dist/verification/baseline.js +17 -3
- package/dist/verification/classify.js +27 -15
- package/dist/verification/engine.d.ts +8 -0
- package/dist/verification/engine.js +28 -1
- package/dist/verification/registry.d.ts +2 -0
- package/dist/verification/registry.js +44 -0
- package/dist/verification/scoped.js +64 -11
- package/package.json +1 -1
|
@@ -383,7 +383,8 @@ function lockfileHash(root) {
|
|
|
383
383
|
async function getCachedProjectMap(root) {
|
|
384
384
|
const head = await gitHead(root);
|
|
385
385
|
const hash = lockfileHash(root);
|
|
386
|
-
const
|
|
386
|
+
const dirty = await dirtyMtime(root);
|
|
387
|
+
const p = path.join(root, '.klyro', 'cache', `project-map-${head.slice(0, 8)}-${hash}-${dirty.toString(36)}.json`);
|
|
387
388
|
try {
|
|
388
389
|
const raw = await fs.readFile(p, 'utf-8');
|
|
389
390
|
const j = JSON.parse(raw);
|
|
@@ -396,13 +397,60 @@ async function getCachedProjectMap(root) {
|
|
|
396
397
|
async function setCachedProjectMap(root, m) {
|
|
397
398
|
const head = await gitHead(root);
|
|
398
399
|
const hash = lockfileHash(root);
|
|
399
|
-
const
|
|
400
|
+
const dirty = await dirtyMtime(root);
|
|
401
|
+
const p = path.join(root, '.klyro', 'cache', `project-map-${head.slice(0, 8)}-${hash}-${dirty.toString(36)}.json`);
|
|
400
402
|
try {
|
|
401
403
|
await fs.mkdir(path.dirname(p), { recursive: true });
|
|
402
404
|
await fs.writeFile(p, JSON.stringify(m, null, 2), 'utf-8');
|
|
403
405
|
}
|
|
404
406
|
catch { /* ignore */ }
|
|
405
407
|
}
|
|
408
|
+
/**
|
|
409
|
+
* Dirty-tree fingerprint: max mtimeMs over a bounded walk (≤200 files,
|
|
410
|
+
* depth ≤4, ignored dirs skipped). Cheap enough to run on every cache
|
|
411
|
+
* lookup; any edit under the tree changes the key and busts the cache.
|
|
412
|
+
*/
|
|
413
|
+
export async function dirtyMtime(root) {
|
|
414
|
+
let max = 0;
|
|
415
|
+
let seen = 0;
|
|
416
|
+
let stop = false;
|
|
417
|
+
async function walk(dir, depth) {
|
|
418
|
+
if (stop || depth > 4)
|
|
419
|
+
return;
|
|
420
|
+
let entries;
|
|
421
|
+
try {
|
|
422
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
423
|
+
}
|
|
424
|
+
catch {
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
for (const e of entries) {
|
|
428
|
+
if (stop)
|
|
429
|
+
return;
|
|
430
|
+
if (IGNORED_DIRS.has(e.name))
|
|
431
|
+
continue;
|
|
432
|
+
const full = path.join(dir, e.name);
|
|
433
|
+
if (e.isDirectory()) {
|
|
434
|
+
await walk(full, depth + 1);
|
|
435
|
+
}
|
|
436
|
+
else if (e.isFile()) {
|
|
437
|
+
seen++;
|
|
438
|
+
try {
|
|
439
|
+
const s = await fs.stat(full);
|
|
440
|
+
if (s.mtimeMs > max)
|
|
441
|
+
max = s.mtimeMs;
|
|
442
|
+
}
|
|
443
|
+
catch { /* race */ }
|
|
444
|
+
if (seen >= 200) {
|
|
445
|
+
stop = true;
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
await walk(root, 0);
|
|
452
|
+
return Math.floor(max);
|
|
453
|
+
}
|
|
406
454
|
function detectMonorepo(root, rootFiles, rootDirs) {
|
|
407
455
|
if (rootFiles.has('pnpm-workspace.yaml') || rootFiles.has('lerna.json') || rootFiles.has('nx.json'))
|
|
408
456
|
return true;
|
|
@@ -18,6 +18,8 @@ export interface RepoFile {
|
|
|
18
18
|
path: string;
|
|
19
19
|
symbols: Symbol[];
|
|
20
20
|
}
|
|
21
|
+
/** Invalidate the repo-map cache (tests + callers that mutate the tree). */
|
|
22
|
+
export declare function clearRepoMapCache(): void;
|
|
21
23
|
/** Heuristic: walk cwd, return at most `maxFiles` files with symbols. */
|
|
22
24
|
export declare function buildRepoMap(opts: {
|
|
23
25
|
cwd: string;
|
package/dist/context/repo-map.js
CHANGED
|
@@ -12,11 +12,38 @@ import * as fs from 'node:fs/promises';
|
|
|
12
12
|
import * as path from 'node:path';
|
|
13
13
|
const SUPPORTED_EXTS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.py', '.go', '.rs']);
|
|
14
14
|
const IGNORED_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.klyro', 'coverage', 'out']);
|
|
15
|
+
const REPO_MAP_TTL_MS = 60_000;
|
|
16
|
+
let repoMapCache = null;
|
|
17
|
+
/** Invalidate the repo-map cache (tests + callers that mutate the tree). */
|
|
18
|
+
export function clearRepoMapCache() {
|
|
19
|
+
repoMapCache = null;
|
|
20
|
+
}
|
|
21
|
+
/** Re-stat every file in the cached map; true when all stats still match. */
|
|
22
|
+
async function repoMapStatsMatch(cwd, stats) {
|
|
23
|
+
for (const [rel, prev] of stats) {
|
|
24
|
+
try {
|
|
25
|
+
const s = await fs.stat(path.join(cwd, rel));
|
|
26
|
+
if (s.mtimeMs !== prev.mtimeMs || s.size !== prev.size)
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return false; // deleted (or unreadable) — rebuild
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
15
35
|
/** Heuristic: walk cwd, return at most `maxFiles` files with symbols. */
|
|
16
36
|
export async function buildRepoMap(opts) {
|
|
17
37
|
const maxFiles = opts.maxFiles ?? 500;
|
|
18
38
|
const maxFileBytes = opts.maxFileBytes ?? 256 * 1024;
|
|
39
|
+
const cached = repoMapCache;
|
|
40
|
+
if (cached && cached.cwd === opts.cwd && cached.maxFiles === maxFiles &&
|
|
41
|
+
cached.maxFileBytes === maxFileBytes && Date.now() - cached.at < REPO_MAP_TTL_MS &&
|
|
42
|
+
(await repoMapStatsMatch(opts.cwd, cached.stats))) {
|
|
43
|
+
return cached.files;
|
|
44
|
+
}
|
|
19
45
|
const out = [];
|
|
46
|
+
const stats = new Map();
|
|
20
47
|
await walk(opts.cwd, async (file) => {
|
|
21
48
|
if (out.length >= maxFiles)
|
|
22
49
|
return false;
|
|
@@ -39,9 +66,12 @@ export async function buildRepoMap(opts) {
|
|
|
39
66
|
catch {
|
|
40
67
|
return true;
|
|
41
68
|
}
|
|
42
|
-
|
|
69
|
+
const rel = path.relative(opts.cwd, file);
|
|
70
|
+
stats.set(rel, { mtimeMs: stat.mtimeMs, size: stat.size });
|
|
71
|
+
out.push({ path: rel, symbols: extractSymbols(content, ext) });
|
|
43
72
|
return true;
|
|
44
73
|
});
|
|
74
|
+
repoMapCache = { cwd: opts.cwd, maxFiles, maxFileBytes, files: out, stats, at: Date.now() };
|
|
45
75
|
return out;
|
|
46
76
|
}
|
|
47
77
|
async function walk(dir, visit) {
|
package/dist/events/catalog.d.ts
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 3.1 — KlyroEvent catalog (Appendix C)
|
|
3
3
|
* Every observable action in the harness is a typed event.
|
|
4
|
+
*
|
|
5
|
+
* Reserved-future members (declared but intentionally unproduced — no
|
|
6
|
+
* emitter exists yet, so do not treat their absence as a bug):
|
|
7
|
+
* `subtask.tool_call`, `subtask.tool_result`.
|
|
8
|
+
* `subtask.progress` IS emitted (throttled: max 1 per child tool call,
|
|
9
|
+
* in-process children only; process-isolated children don't run the emitter).
|
|
10
|
+
* The orchestrator otherwise emits started/completed/failed/cancelled/
|
|
11
|
+
* timed_out/merged only; grep for emitters before assuming one of the
|
|
12
|
+
* reserved members fires.
|
|
4
13
|
*/
|
|
5
14
|
export type KlyroEvent = {
|
|
6
15
|
type: 'session.start';
|
|
@@ -167,6 +176,28 @@ export type KlyroEvent = {
|
|
|
167
176
|
code: string;
|
|
168
177
|
message: string;
|
|
169
178
|
};
|
|
179
|
+
} | {
|
|
180
|
+
type: 'subtask.cancelled';
|
|
181
|
+
ts: number;
|
|
182
|
+
sessionId: string;
|
|
183
|
+
taskId: string;
|
|
184
|
+
status: 'cancelled';
|
|
185
|
+
durationMs: number;
|
|
186
|
+
error?: {
|
|
187
|
+
code: string;
|
|
188
|
+
message: string;
|
|
189
|
+
};
|
|
190
|
+
} | {
|
|
191
|
+
type: 'subtask.timed_out';
|
|
192
|
+
ts: number;
|
|
193
|
+
sessionId: string;
|
|
194
|
+
taskId: string;
|
|
195
|
+
status: 'timed_out';
|
|
196
|
+
durationMs: number;
|
|
197
|
+
error?: {
|
|
198
|
+
code: string;
|
|
199
|
+
message: string;
|
|
200
|
+
};
|
|
170
201
|
} | {
|
|
171
202
|
type: 'subtask.tool_call';
|
|
172
203
|
ts: number;
|
|
@@ -189,6 +220,7 @@ export type KlyroEvent = {
|
|
|
189
220
|
sessionId: string;
|
|
190
221
|
taskId: string;
|
|
191
222
|
changedFiles: string[];
|
|
223
|
+
merged?: boolean;
|
|
192
224
|
} | {
|
|
193
225
|
type: 'provider.retry';
|
|
194
226
|
ts: number;
|
|
@@ -203,5 +235,10 @@ export type KlyroEvent = {
|
|
|
203
235
|
path: string;
|
|
204
236
|
reason: 'unknown' | 'changed';
|
|
205
237
|
trusted: boolean;
|
|
238
|
+
} | {
|
|
239
|
+
type: 'context.compacted';
|
|
240
|
+
ts: number;
|
|
241
|
+
sessionId: string;
|
|
242
|
+
dropped: number;
|
|
206
243
|
};
|
|
207
244
|
export type KlyroEventType = KlyroEvent['type'];
|
package/dist/events/catalog.js
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 3.1 — KlyroEvent catalog (Appendix C)
|
|
3
3
|
* Every observable action in the harness is a typed event.
|
|
4
|
+
*
|
|
5
|
+
* Reserved-future members (declared but intentionally unproduced — no
|
|
6
|
+
* emitter exists yet, so do not treat their absence as a bug):
|
|
7
|
+
* `subtask.tool_call`, `subtask.tool_result`.
|
|
8
|
+
* `subtask.progress` IS emitted (throttled: max 1 per child tool call,
|
|
9
|
+
* in-process children only; process-isolated children don't run the emitter).
|
|
10
|
+
* The orchestrator otherwise emits started/completed/failed/cancelled/
|
|
11
|
+
* timed_out/merged only; grep for emitters before assuming one of the
|
|
12
|
+
* reserved members fires.
|
|
4
13
|
*/
|
|
5
14
|
export {};
|
package/dist/index.js
CHANGED
|
@@ -23,6 +23,7 @@ import { runCompletion } from './cli/completion.js';
|
|
|
23
23
|
import { runUpdate } from './cli/update.js';
|
|
24
24
|
import { runLogin, runLogout } from './cli/auth.js';
|
|
25
25
|
import { readVersion } from './version.js';
|
|
26
|
+
import { verifyAuditChain } from './persistence/audit.js';
|
|
26
27
|
const VERSION = readVersion();
|
|
27
28
|
function parsePositiveInt(name, v) {
|
|
28
29
|
const n = Number(v);
|
|
@@ -61,8 +62,21 @@ async function main() {
|
|
|
61
62
|
.option('--output-format <fmt>', 'Headless output format: text|json|stream-json (default text)')
|
|
62
63
|
.option('--no-stream', 'Disable streaming (buffer full response)')
|
|
63
64
|
.option('--show-thinking', 'Show thinking blocks');
|
|
65
|
+
// Unknown commands / unknown options are usage errors (exit 2 per PRD).
|
|
66
|
+
// Help and version still exit 0; anything else rethrows to the last-resort
|
|
67
|
+
// handler (exit 1).
|
|
68
|
+
program.exitOverride((err) => {
|
|
69
|
+
if (err.code === 'commander.unknownCommand' || err.code === 'commander.unknownOption') {
|
|
70
|
+
process.stderr.write(`klyro: ${err.message}\n`);
|
|
71
|
+
process.exit(2);
|
|
72
|
+
}
|
|
73
|
+
if (err.code === 'commander.helpDisplayed' || err.code === 'commander.version') {
|
|
74
|
+
process.exit(0);
|
|
75
|
+
}
|
|
76
|
+
throw err;
|
|
77
|
+
});
|
|
64
78
|
// Handle global flags before any command runs
|
|
65
|
-
program.hook('preAction', (thisCommand) => {
|
|
79
|
+
program.hook('preAction', async (thisCommand) => {
|
|
66
80
|
const opts = thisCommand.optsWithGlobals();
|
|
67
81
|
if (opts.cwd) {
|
|
68
82
|
try {
|
|
@@ -73,6 +87,13 @@ async function main() {
|
|
|
73
87
|
process.exit(2);
|
|
74
88
|
}
|
|
75
89
|
}
|
|
90
|
+
// Load <cwd>/.env globally (after chdir) so KLYRO_* vars resolve for
|
|
91
|
+
// every command. Never throws; explicit env wins (no-clobber).
|
|
92
|
+
try {
|
|
93
|
+
const { loadDotenv } = await import('./cli/dotenv.js');
|
|
94
|
+
loadDotenv(process.cwd());
|
|
95
|
+
}
|
|
96
|
+
catch { /* ignore */ }
|
|
76
97
|
if (opts.config)
|
|
77
98
|
process.env.KLYRO_CONFIG = opts.config;
|
|
78
99
|
if (opts.debug)
|
|
@@ -271,6 +292,7 @@ async function main() {
|
|
|
271
292
|
.option('--resume-session <id>', 'Resume from a persisted session (Level 9) by id prefix')
|
|
272
293
|
.option('--verify', 'Enable verification after edits (Level 8, default: enabled)')
|
|
273
294
|
.option('--verify-command <cmd>', 'Custom verification command (default: auto-detected)')
|
|
295
|
+
.option('--verify-mode <mode>', 'Verification mode: strict (default), advisory, off')
|
|
274
296
|
.option('--max-repairs <n>', 'Max autonomous repair attempts (default 3)', (v) => parsePositiveInt('--max-repairs', v))
|
|
275
297
|
.option('--persist', 'Enable session persistence (Level 9, default: enabled)')
|
|
276
298
|
.option('--require-verify', 'Fail with exit 8 if no verification passed after edits (6.5)')
|
|
@@ -287,6 +309,11 @@ async function main() {
|
|
|
287
309
|
process.stderr.write(`klyro: invalid --output: ${output} (expected human|json|silent)\n`);
|
|
288
310
|
process.exit(2);
|
|
289
311
|
}
|
|
312
|
+
const verifyMode = opts.verifyMode ?? 'strict';
|
|
313
|
+
if (verifyMode !== 'strict' && verifyMode !== 'advisory' && verifyMode !== 'off') {
|
|
314
|
+
process.stderr.write(`klyro: invalid --verify-mode: ${opts.verifyMode} (expected strict|advisory|off)\n`);
|
|
315
|
+
process.exit(2);
|
|
316
|
+
}
|
|
290
317
|
// Provider validation is handled inside runOnce (single source of truth)
|
|
291
318
|
try {
|
|
292
319
|
const code = await runOnce({
|
|
@@ -306,6 +333,7 @@ async function main() {
|
|
|
306
333
|
sessionId: opts.resumeSession,
|
|
307
334
|
verify: opts.verify,
|
|
308
335
|
verifyCommand: opts.verifyCommand,
|
|
336
|
+
verifyMode: verifyMode,
|
|
309
337
|
maxRepairAttempts: opts.maxRepairs,
|
|
310
338
|
persist: opts.persist,
|
|
311
339
|
requireVerify: !!opts.requireVerify,
|
|
@@ -321,7 +349,7 @@ async function main() {
|
|
|
321
349
|
});
|
|
322
350
|
program
|
|
323
351
|
.command('chat [prompt]')
|
|
324
|
-
.description('Legacy streamed chat. Without a prompt, start an interactive REPL.')
|
|
352
|
+
.description('Legacy streamed chat. Without a prompt, start an interactive REPL. (deprecated: history truncation is approximate; prefer `klyro tui`)')
|
|
325
353
|
.option('-s, --system <text>', 'System message', 'You are a helpful assistant.')
|
|
326
354
|
.option('-m, --model <id>', 'Override the model (default: env KLYRO_MODEL)')
|
|
327
355
|
.option('-t, --timeout <ms>', 'Request timeout in ms (default: env KLYRO_TIMEOUT_MS or 60000)', (v) => parsePositiveInt('-t/--timeout', v))
|
|
@@ -570,15 +598,156 @@ async function main() {
|
|
|
570
598
|
});
|
|
571
599
|
// 10.1 — MCP
|
|
572
600
|
const mcp = program.command('mcp').description('MCP client/server (10.1)');
|
|
573
|
-
mcp.command('list').description('List MCP servers').action(async () => {
|
|
601
|
+
mcp.command('list').description('List MCP servers').action(async () => {
|
|
602
|
+
const { loadMcpServers } = await import('./mcp/config.js');
|
|
603
|
+
const cfg = loadMcpServers(process.cwd());
|
|
604
|
+
const names = Object.keys(cfg.servers);
|
|
605
|
+
if (names.length === 0) {
|
|
606
|
+
process.stdout.write('mcp servers: none configured (use .mcp.json)\n');
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
for (const name of names) {
|
|
610
|
+
const spec = cfg.servers[name];
|
|
611
|
+
const source = cfg.sources[name] ?? 'unknown';
|
|
612
|
+
process.stdout.write(`${name} source=${source}${spec?.disabled ? ' disabled' : ''}\n`);
|
|
613
|
+
}
|
|
614
|
+
});
|
|
574
615
|
mcp.command('add <name> <url>').description('Add MCP server').action(async (name) => { process.stdout.write(`added mcp ${name} (stub)\n`); });
|
|
616
|
+
mcp.command('probe <name>').description('Connect to an MCP server (15s timeout), list its tools, print count+names').action(async (name) => {
|
|
617
|
+
const { loadMcpServers } = await import('./mcp/config.js');
|
|
618
|
+
const cfg = loadMcpServers(process.cwd());
|
|
619
|
+
const spec = cfg.servers[name];
|
|
620
|
+
if (!spec) {
|
|
621
|
+
process.stderr.write(`klyro: mcp server not found: ${name}\n`);
|
|
622
|
+
process.exit(2);
|
|
623
|
+
}
|
|
624
|
+
const { McpClient } = await import('./mcp/client.js');
|
|
625
|
+
const client = new McpClient(name, spec);
|
|
626
|
+
// 15s overall probe budget (connect has its own internal timeout too).
|
|
627
|
+
const timer = setTimeout(() => {
|
|
628
|
+
process.stderr.write(`klyro: mcp probe ${name} timed out after 15s\n`);
|
|
629
|
+
process.exit(2);
|
|
630
|
+
}, 15_000);
|
|
631
|
+
try {
|
|
632
|
+
await client.connect();
|
|
633
|
+
const tools = await client.listTools();
|
|
634
|
+
const names = tools.map((t) => t.name);
|
|
635
|
+
process.stdout.write(`${name}: ${tools.length} tool(s)${names.length > 0 ? `: ${names.join(', ')}` : ''}\n`);
|
|
636
|
+
process.exit(0);
|
|
637
|
+
}
|
|
638
|
+
catch (err) {
|
|
639
|
+
process.stderr.write(`klyro: mcp probe ${name} failed: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
640
|
+
process.exit(2);
|
|
641
|
+
}
|
|
642
|
+
finally {
|
|
643
|
+
clearTimeout(timer);
|
|
644
|
+
try {
|
|
645
|
+
await client.close();
|
|
646
|
+
}
|
|
647
|
+
catch { /* ignore */ }
|
|
648
|
+
}
|
|
649
|
+
});
|
|
575
650
|
mcp.command('serve').description('Serve as MCP server').action(async () => { process.stdout.write('klyro mcp serve — exposing tools (stub)\n'); });
|
|
576
|
-
// 10.2 — Hooks /
|
|
577
|
-
program.command('hooks').description('
|
|
578
|
-
|
|
651
|
+
// 10.2 — Hooks: list configured preToolUse/postToolUse hooks.
|
|
652
|
+
program.command('hooks [cmd]').description('Hooks (10.2): `klyro hooks` or `klyro hooks list` prints configured hooks').action(async (cmd) => {
|
|
653
|
+
if (cmd && cmd !== 'list') {
|
|
654
|
+
process.stderr.write(`klyro: unknown hooks command: ${cmd} (usage: klyro hooks [list])\n`);
|
|
655
|
+
process.exit(2);
|
|
656
|
+
}
|
|
657
|
+
const { loadHooks } = await import('./cli/hooks.js');
|
|
658
|
+
const hooks = loadHooks(process.cwd());
|
|
659
|
+
if (hooks.length === 0) {
|
|
660
|
+
process.stdout.write('hooks: none configured (.klyro/hooks.json, ~/.klyro/hooks.json)\n');
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
for (const h of hooks)
|
|
664
|
+
process.stdout.write(`${h.name} ${h.event} ${h.command}\n`);
|
|
665
|
+
});
|
|
666
|
+
program.command('agents [name] [extra...]').description('List agents (10.2), show one, or run: agents run <name> <task...>').action(async (name, extra) => {
|
|
667
|
+
const { BUILTIN_AGENTS } = await import('./agent/orchestrator.js');
|
|
668
|
+
// `klyro agents run <name> <task...>`: one-shot run under a named agent.
|
|
669
|
+
if (name === 'run') {
|
|
670
|
+
const [agentName, ...taskParts] = extra ?? [];
|
|
671
|
+
if (!agentName || !BUILTIN_AGENTS.some((a) => a.id === agentName)) {
|
|
672
|
+
process.stderr.write(`klyro: unknown agent: ${agentName ?? '(missing)'} (known: ${BUILTIN_AGENTS.map((a) => a.id).join(', ')})\n`);
|
|
673
|
+
process.exit(2);
|
|
674
|
+
}
|
|
675
|
+
const task = (taskParts ?? []).join(' ').trim();
|
|
676
|
+
if (!task) {
|
|
677
|
+
process.stderr.write('klyro: agents run requires a task (usage: klyro agents run <name> <task...>)\n');
|
|
678
|
+
process.exit(2);
|
|
679
|
+
}
|
|
680
|
+
const model = process.env.KLYRO_MODEL;
|
|
681
|
+
if (!model) {
|
|
682
|
+
process.stderr.write('klyro: KLYRO_MODEL is not set (or pass --model via klyro run)\n');
|
|
683
|
+
process.exit(2);
|
|
684
|
+
}
|
|
685
|
+
const code = await runOnce({ task, cwd: process.cwd(), model, agent: agentName });
|
|
686
|
+
process.exit(code);
|
|
687
|
+
}
|
|
688
|
+
if (!name) {
|
|
689
|
+
for (const a of BUILTIN_AGENTS)
|
|
690
|
+
process.stdout.write(`${a.id} — ${a.description}\n`);
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
const def = BUILTIN_AGENTS.find((a) => a.id === name);
|
|
694
|
+
if (!def) {
|
|
695
|
+
process.stderr.write(`klyro: unknown agent: ${name} (known: ${BUILTIN_AGENTS.map((a) => a.id).join(', ')})\n`);
|
|
696
|
+
process.exit(2);
|
|
697
|
+
}
|
|
698
|
+
const tools = def.allowedTools ?? ['<inherited: all parent tools>'];
|
|
699
|
+
const lines = [
|
|
700
|
+
`agent: ${def.id}`,
|
|
701
|
+
`description: ${def.description}`,
|
|
702
|
+
`tools (${tools.length}): ${tools.join(', ')}`,
|
|
703
|
+
`readonly: ${def.readonly ?? false}`,
|
|
704
|
+
`canSpawn: ${def.canSpawn ?? false}`,
|
|
705
|
+
`model: ${def.model ?? '<session default>'}`,
|
|
706
|
+
`maxSteps: ${def.maxSteps ?? '<default>'}`,
|
|
707
|
+
`maxTokens: ${def.maxTokens ?? '<default>'}`,
|
|
708
|
+
];
|
|
709
|
+
process.stdout.write(lines.join('\n') + '\n');
|
|
710
|
+
});
|
|
579
711
|
// 10.3 — Web / git workflows / SDK
|
|
580
|
-
program
|
|
581
|
-
|
|
712
|
+
program
|
|
713
|
+
.command('commit')
|
|
714
|
+
.description('Commit staged changes with a conventional message (verification hooks always run)')
|
|
715
|
+
.option('--dry-run', 'Print the message + files without committing')
|
|
716
|
+
.option('--message <msg>', 'Summary for the conventional message (default: update <n> files)')
|
|
717
|
+
.option('--force-secret', 'Commit even if the staged diff looks like it contains a secret')
|
|
718
|
+
.action(async (opts) => {
|
|
719
|
+
const { runCommit } = await import('./cli/commit.js');
|
|
720
|
+
const globalYes = program.opts().yes ?? process.env.KLYRO_YES === '1';
|
|
721
|
+
const code = await runCommit({
|
|
722
|
+
cwd: process.cwd(),
|
|
723
|
+
yes: !!globalYes,
|
|
724
|
+
dryRun: !!opts.dryRun,
|
|
725
|
+
...(opts.message !== undefined ? { message: opts.message } : {}),
|
|
726
|
+
forceSecret: !!opts.forceSecret,
|
|
727
|
+
});
|
|
728
|
+
process.exit(code);
|
|
729
|
+
});
|
|
730
|
+
program.command('audit [session]').description('Verify audit chain (13.4)').action(async (session) => {
|
|
731
|
+
if (!session) {
|
|
732
|
+
process.stderr.write('klyro: audit requires a session id (usage: klyro audit <session>)\n');
|
|
733
|
+
process.exit(2);
|
|
734
|
+
}
|
|
735
|
+
try {
|
|
736
|
+
const { getDefaultSessionsDir } = await import('./persistence/session.js');
|
|
737
|
+
const res = await verifyAuditChain(getDefaultSessionsDir(), session);
|
|
738
|
+
if (res.ok) {
|
|
739
|
+
process.stdout.write(`audit ok: ${res.events} events verified\n`);
|
|
740
|
+
process.exit(0);
|
|
741
|
+
}
|
|
742
|
+
process.stderr.write(`klyro: audit failed: ${res.error ?? 'chain broken'}\n`);
|
|
743
|
+
process.exit(1);
|
|
744
|
+
}
|
|
745
|
+
catch (err) {
|
|
746
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
747
|
+
process.stderr.write(`klyro: audit failed: ${msg}\n`);
|
|
748
|
+
process.exit(1);
|
|
749
|
+
}
|
|
750
|
+
});
|
|
582
751
|
// 10.4 — Benchmark parity (10.5)
|
|
583
752
|
program.command('benchmark').description('Run benchmark (10.5)').action(async () => {
|
|
584
753
|
const { runHarness } = await import('./eval/harness.js');
|
package/dist/mcp/client.d.ts
CHANGED
|
@@ -16,9 +16,9 @@ export interface McpCallResult {
|
|
|
16
16
|
raw: unknown;
|
|
17
17
|
}
|
|
18
18
|
export interface McpClientLike {
|
|
19
|
-
listTools(): Promise<McpToolDef[]>;
|
|
19
|
+
listTools(signal?: AbortSignal): Promise<McpToolDef[]>;
|
|
20
20
|
callTool(name: string, args: unknown, signal?: AbortSignal): Promise<McpCallResult>;
|
|
21
|
-
listResources(): Promise<McpResource[]>;
|
|
21
|
+
listResources(signal?: AbortSignal): Promise<McpResource[]>;
|
|
22
22
|
close(): Promise<void>;
|
|
23
23
|
}
|
|
24
24
|
export declare class McpError extends Error {
|
|
@@ -39,11 +39,13 @@ export declare class McpClient implements McpClientLike {
|
|
|
39
39
|
constructor(name: string, spec: McpServerSpec);
|
|
40
40
|
connect(): Promise<void>;
|
|
41
41
|
private connected;
|
|
42
|
-
listTools(): Promise<McpToolDef[]>;
|
|
42
|
+
listTools(signal?: AbortSignal): Promise<McpToolDef[]>;
|
|
43
43
|
callTool(name: string, args: unknown, signal?: AbortSignal): Promise<McpCallResult>;
|
|
44
|
-
listResources(): Promise<McpResource[]>;
|
|
44
|
+
listResources(signal?: AbortSignal): Promise<McpResource[]>;
|
|
45
45
|
readResource(uri: string, signal?: AbortSignal): Promise<string>;
|
|
46
46
|
close(): Promise<void>;
|
|
47
|
+
/** Test hook: number of in-flight JSON-RPC requests. */
|
|
48
|
+
pendingCount(): number;
|
|
47
49
|
private timeout;
|
|
48
50
|
private assertLive;
|
|
49
51
|
private onStdout;
|
package/dist/mcp/client.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* failures and server errors surface as typed `McpError`s — never throws
|
|
8
8
|
* raw across the boundary.
|
|
9
9
|
*/
|
|
10
|
-
import { spawn } from 'node:child_process';
|
|
10
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
11
11
|
export class McpError extends Error {
|
|
12
12
|
code;
|
|
13
13
|
details;
|
|
@@ -21,6 +21,10 @@ export class McpError extends Error {
|
|
|
21
21
|
const PROTOCOL_VERSION = '2024-11-05';
|
|
22
22
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
23
23
|
const MAX_STDERR_BYTES = 8192;
|
|
24
|
+
/** Upper bound on spawn + initialize handshake; partial children are killed. */
|
|
25
|
+
const CONNECT_TIMEOUT_MS = 15_000;
|
|
26
|
+
/** Grace period between SIGTERM and SIGKILL in close(). */
|
|
27
|
+
const CLOSE_SIGKILL_AFTER_MS = 2000;
|
|
24
28
|
export class McpClient {
|
|
25
29
|
name;
|
|
26
30
|
spec;
|
|
@@ -54,6 +58,12 @@ export class McpClient {
|
|
|
54
58
|
return;
|
|
55
59
|
}
|
|
56
60
|
this.child = child;
|
|
61
|
+
// Swallow async stream errors (e.g. EPIPE on write-after-exit):
|
|
62
|
+
// request promises already surface failures via write callbacks and
|
|
63
|
+
// the exit handler, so these must never become uncaught exceptions.
|
|
64
|
+
child.stdin?.on('error', () => { });
|
|
65
|
+
child.stdout?.on('error', () => { });
|
|
66
|
+
child.stderr?.on('error', () => { });
|
|
57
67
|
child.on('error', (err) => {
|
|
58
68
|
this.failAll(new McpError(`mcp server "${this.name}" process error: ${err.message}`, 'SPAWN_FAILED'));
|
|
59
69
|
if (!this.connected)
|
|
@@ -73,13 +83,29 @@ export class McpClient {
|
|
|
73
83
|
resolve();
|
|
74
84
|
});
|
|
75
85
|
try {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
86
|
+
let connectTimer;
|
|
87
|
+
try {
|
|
88
|
+
await Promise.race([
|
|
89
|
+
(async () => {
|
|
90
|
+
await this.request('initialize', {
|
|
91
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
92
|
+
capabilities: {},
|
|
93
|
+
clientInfo: { name: 'klyro', version: '1.0.2' },
|
|
94
|
+
}, timeoutMs);
|
|
95
|
+
this.notify('notifications/initialized', {});
|
|
96
|
+
this.connected = true;
|
|
97
|
+
})(),
|
|
98
|
+
new Promise((_resolve, reject) => {
|
|
99
|
+
connectTimer = setTimeout(() => {
|
|
100
|
+
reject(new McpError(`mcp server "${this.name}" connect timed out after ${CONNECT_TIMEOUT_MS}ms`, 'TIMEOUT'));
|
|
101
|
+
}, CONNECT_TIMEOUT_MS);
|
|
102
|
+
}),
|
|
103
|
+
]);
|
|
104
|
+
}
|
|
105
|
+
finally {
|
|
106
|
+
if (connectTimer)
|
|
107
|
+
clearTimeout(connectTimer);
|
|
108
|
+
}
|
|
83
109
|
}
|
|
84
110
|
catch (err) {
|
|
85
111
|
await this.close();
|
|
@@ -87,9 +113,9 @@ export class McpClient {
|
|
|
87
113
|
}
|
|
88
114
|
}
|
|
89
115
|
connected = false;
|
|
90
|
-
async listTools() {
|
|
116
|
+
async listTools(signal) {
|
|
91
117
|
this.assertLive();
|
|
92
|
-
const res = (await this.request('tools/list', {}, this.timeout()));
|
|
118
|
+
const res = (await this.request('tools/list', {}, this.timeout(), signal));
|
|
93
119
|
return Array.isArray(res.tools) ? res.tools : [];
|
|
94
120
|
}
|
|
95
121
|
async callTool(name, args, signal) {
|
|
@@ -99,9 +125,9 @@ export class McpClient {
|
|
|
99
125
|
const text = blocks.map((b) => (typeof b.text === 'string' ? b.text : JSON.stringify(b))).join('\n');
|
|
100
126
|
return { text, isError: res.isError === true, raw: res };
|
|
101
127
|
}
|
|
102
|
-
async listResources() {
|
|
128
|
+
async listResources(signal) {
|
|
103
129
|
this.assertLive();
|
|
104
|
-
const res = (await this.request('resources/list', {}, this.timeout()));
|
|
130
|
+
const res = (await this.request('resources/list', {}, this.timeout(), signal));
|
|
105
131
|
return Array.isArray(res.resources) ? res.resources : [];
|
|
106
132
|
}
|
|
107
133
|
async readResource(uri, signal) {
|
|
@@ -111,16 +137,49 @@ export class McpClient {
|
|
|
111
137
|
return contents.map((c) => (typeof c.text === 'string' ? c.text : JSON.stringify(c))).join('\n');
|
|
112
138
|
}
|
|
113
139
|
async close() {
|
|
140
|
+
// Idempotent vs the exit handler: closed is set FIRST (the on('exit')
|
|
141
|
+
// callback checks it before failAll), and a second close() with no child
|
|
142
|
+
// left is a no-op.
|
|
143
|
+
if (this.closed && this.child === null)
|
|
144
|
+
return;
|
|
114
145
|
this.closed = true;
|
|
115
146
|
this.failAll(new McpError(`mcp server "${this.name}" closed`, 'CLOSED'));
|
|
116
147
|
const child = this.child;
|
|
117
148
|
this.child = null;
|
|
118
|
-
if (
|
|
149
|
+
if (!child)
|
|
150
|
+
return;
|
|
151
|
+
// Already reaped — no escalation timer needed.
|
|
152
|
+
if (child.exitCode !== null || child.signalCode !== null)
|
|
153
|
+
return;
|
|
154
|
+
if (process.platform === 'win32') {
|
|
155
|
+
try {
|
|
156
|
+
if (child.pid !== undefined) {
|
|
157
|
+
execFileSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
catch { /* ignore — fall through to kill() */ }
|
|
119
161
|
try {
|
|
120
162
|
child.kill();
|
|
121
163
|
}
|
|
122
164
|
catch { /* ignore */ }
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
try {
|
|
168
|
+
child.kill('SIGTERM');
|
|
123
169
|
}
|
|
170
|
+
catch { /* already gone */ }
|
|
171
|
+
const timer = setTimeout(() => {
|
|
172
|
+
try {
|
|
173
|
+
child.kill('SIGKILL');
|
|
174
|
+
}
|
|
175
|
+
catch { /* already gone */ }
|
|
176
|
+
}, CLOSE_SIGKILL_AFTER_MS);
|
|
177
|
+
timer.unref?.();
|
|
178
|
+
child.once('exit', () => clearTimeout(timer));
|
|
179
|
+
}
|
|
180
|
+
/** Test hook: number of in-flight JSON-RPC requests. */
|
|
181
|
+
pendingCount() {
|
|
182
|
+
return this.pending.size;
|
|
124
183
|
}
|
|
125
184
|
/* ---------------- internals ---------------- */
|
|
126
185
|
timeout() {
|
|
@@ -154,7 +213,8 @@ export class McpClient {
|
|
|
154
213
|
continue;
|
|
155
214
|
this.pending.delete(msg.id);
|
|
156
215
|
clearTimeout(pend.timer);
|
|
157
|
-
pend.signal
|
|
216
|
+
if (pend.signal && pend.onAbort)
|
|
217
|
+
pend.signal.removeEventListener('abort', pend.onAbort);
|
|
158
218
|
if (msg.error) {
|
|
159
219
|
pend.reject(new McpError(`mcp server "${this.name}" error: ${msg.error.message ?? 'unknown'}`, 'SERVER_ERROR', msg.error));
|
|
160
220
|
}
|
|
@@ -181,6 +241,9 @@ export class McpClient {
|
|
|
181
241
|
}
|
|
182
242
|
const id = this.nextId++;
|
|
183
243
|
const timer = setTimeout(() => {
|
|
244
|
+
const p = this.pending.get(id);
|
|
245
|
+
if (p?.signal && p.onAbort)
|
|
246
|
+
p.signal.removeEventListener('abort', p.onAbort);
|
|
184
247
|
this.pending.delete(id);
|
|
185
248
|
reject(new McpError(`mcp call "${method}" timed out after ${timeoutMs}ms`, 'TIMEOUT'));
|
|
186
249
|
}, timeoutMs);
|
|
@@ -202,6 +265,8 @@ export class McpClient {
|
|
|
202
265
|
if (err) {
|
|
203
266
|
this.pending.delete(id);
|
|
204
267
|
clearTimeout(timer);
|
|
268
|
+
if (signal && pend.onAbort)
|
|
269
|
+
signal.removeEventListener('abort', pend.onAbort);
|
|
205
270
|
reject(new McpError(`mcp call "${method}" write failed: ${err.message}`, 'IO_ERROR'));
|
|
206
271
|
}
|
|
207
272
|
});
|
|
@@ -209,6 +274,8 @@ export class McpClient {
|
|
|
209
274
|
catch (err) {
|
|
210
275
|
this.pending.delete(id);
|
|
211
276
|
clearTimeout(timer);
|
|
277
|
+
if (signal && pend.onAbort)
|
|
278
|
+
signal.removeEventListener('abort', pend.onAbort);
|
|
212
279
|
reject(new McpError(`mcp call "${method}" write failed: ${err instanceof Error ? err.message : String(err)}`, 'IO_ERROR'));
|
|
213
280
|
}
|
|
214
281
|
});
|
|
@@ -219,6 +286,8 @@ export class McpClient {
|
|
|
219
286
|
for (const [id, pend] of [...this.pending]) {
|
|
220
287
|
this.pending.delete(id);
|
|
221
288
|
clearTimeout(pend.timer);
|
|
289
|
+
if (pend.signal && pend.onAbort)
|
|
290
|
+
pend.signal.removeEventListener('abort', pend.onAbort);
|
|
222
291
|
pend.reject(err);
|
|
223
292
|
}
|
|
224
293
|
}
|