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.
Files changed (110) hide show
  1. package/dist/agent/anthropic-adapter.d.ts +49 -5
  2. package/dist/agent/anthropic-adapter.js +86 -17
  3. package/dist/agent/capabilities.d.ts +23 -0
  4. package/dist/agent/capabilities.js +53 -6
  5. package/dist/agent/child-worker.d.ts +104 -0
  6. package/dist/agent/child-worker.js +250 -0
  7. package/dist/agent/orchestrator.d.ts +124 -6
  8. package/dist/agent/orchestrator.js +425 -58
  9. package/dist/agent/provider-adapter.d.ts +8 -0
  10. package/dist/agent/provider-adapter.js +12 -3
  11. package/dist/agent/retry.d.ts +1 -1
  12. package/dist/agent/retry.js +54 -12
  13. package/dist/agent/runtime.d.ts +84 -8
  14. package/dist/agent/runtime.js +352 -39
  15. package/dist/agent/stream-budget.d.ts +36 -0
  16. package/dist/agent/stream-budget.js +121 -0
  17. package/dist/agent/worktree-manager.d.ts +74 -0
  18. package/dist/agent/worktree-manager.js +189 -0
  19. package/dist/checkpoints/store.d.ts +9 -0
  20. package/dist/checkpoints/store.js +56 -5
  21. package/dist/cli/auth.js +16 -1
  22. package/dist/cli/commit.d.ts +31 -0
  23. package/dist/cli/commit.js +142 -0
  24. package/dist/cli/config.d.ts +54 -3
  25. package/dist/cli/config.js +146 -3
  26. package/dist/cli/doctor.d.ts +1 -0
  27. package/dist/cli/doctor.js +71 -6
  28. package/dist/cli/eval.d.ts +6 -1
  29. package/dist/cli/eval.js +9 -0
  30. package/dist/cli/hooks.d.ts +47 -0
  31. package/dist/cli/hooks.js +181 -0
  32. package/dist/cli/repl.js +196 -29
  33. package/dist/cli/run.d.ts +13 -11
  34. package/dist/cli/run.js +144 -20
  35. package/dist/cli/update.d.ts +5 -0
  36. package/dist/cli/update.js +62 -10
  37. package/dist/context/import-graph.d.ts +2 -0
  38. package/dist/context/import-graph.js +31 -3
  39. package/dist/context/klyro-md.js +4 -1
  40. package/dist/context/memory.d.ts +8 -0
  41. package/dist/context/memory.js +50 -2
  42. package/dist/context/project-map.d.ts +6 -0
  43. package/dist/context/project-map.js +50 -2
  44. package/dist/context/repo-map.d.ts +2 -0
  45. package/dist/context/repo-map.js +31 -1
  46. package/dist/events/catalog.d.ts +37 -0
  47. package/dist/events/catalog.js +9 -0
  48. package/dist/index.js +177 -8
  49. package/dist/mcp/client.d.ts +6 -4
  50. package/dist/mcp/client.js +83 -14
  51. package/dist/mcp/config.d.ts +10 -0
  52. package/dist/mcp/config.js +18 -1
  53. package/dist/mcp/registry.d.ts +23 -19
  54. package/dist/mcp/registry.js +127 -8
  55. package/dist/mcp/schema.d.ts +11 -4
  56. package/dist/mcp/schema.js +27 -16
  57. package/dist/mcp/trust.d.ts +20 -0
  58. package/dist/mcp/trust.js +74 -0
  59. package/dist/persistence/audit.d.ts +28 -0
  60. package/dist/persistence/audit.js +101 -1
  61. package/dist/persistence/store.d.ts +26 -2
  62. package/dist/persistence/store.js +140 -13
  63. package/dist/policy/approval.d.ts +14 -0
  64. package/dist/policy/approval.js +44 -2
  65. package/dist/policy/engine.d.ts +17 -0
  66. package/dist/policy/engine.js +162 -9
  67. package/dist/policy/path-guard.d.ts +24 -0
  68. package/dist/policy/path-guard.js +46 -0
  69. package/dist/policy/secret-redactor.js +4 -0
  70. package/dist/providers/model-info.d.ts +23 -0
  71. package/dist/providers/model-info.js +43 -2
  72. package/dist/repl.d.ts +6 -0
  73. package/dist/repl.js +12 -7
  74. package/dist/tools/agent/spawn-agent.js +5 -5
  75. package/dist/tools/agent/task-apply.d.ts +4 -0
  76. package/dist/tools/agent/task-apply.js +44 -0
  77. package/dist/tools/agent/task-stop.d.ts +6 -0
  78. package/dist/tools/agent/task-stop.js +39 -0
  79. package/dist/tools/agent/task-wait.d.ts +17 -0
  80. package/dist/tools/agent/task-wait.js +79 -0
  81. package/dist/tools/fs/apply-patch.js +77 -1
  82. package/dist/tools/fs/edit-file.js +69 -1
  83. package/dist/tools/fs/multi-edit.d.ts +4 -0
  84. package/dist/tools/fs/multi-edit.js +70 -1
  85. package/dist/tools/fs/write-file.js +83 -6
  86. package/dist/tools/plan/todo-write.js +1 -1
  87. package/dist/tools/registry.js +6 -0
  88. package/dist/tools/shell/background.js +6 -3
  89. package/dist/tools/shell/sandbox.d.ts +51 -0
  90. package/dist/tools/shell/sandbox.js +143 -0
  91. package/dist/tools/shell/shell-exec.d.ts +29 -0
  92. package/dist/tools/shell/shell-exec.js +170 -12
  93. package/dist/tools/shell/worker-entry.d.ts +12 -0
  94. package/dist/tools/shell/worker-entry.js +43 -0
  95. package/dist/tools/types.d.ts +6 -0
  96. package/dist/tools/verify/run-verify.js +3 -1
  97. package/dist/trace/writer.d.ts +20 -0
  98. package/dist/trace/writer.js +62 -4
  99. package/dist/tui/app.js +1 -1
  100. package/dist/tui/approval.js +20 -21
  101. package/dist/util.d.ts +1 -0
  102. package/dist/util.js +1 -0
  103. package/dist/verification/baseline.js +17 -3
  104. package/dist/verification/classify.js +27 -15
  105. package/dist/verification/engine.d.ts +8 -0
  106. package/dist/verification/engine.js +28 -1
  107. package/dist/verification/registry.d.ts +2 -0
  108. package/dist/verification/registry.js +44 -0
  109. package/dist/verification/scoped.js +64 -11
  110. 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 p = path.join(root, '.klyro', 'cache', `project-map-${head.slice(0, 8)}-${hash}.json`);
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 p = path.join(root, '.klyro', 'cache', `project-map-${head.slice(0, 8)}-${hash}.json`);
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;
@@ -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
- out.push({ path: path.relative(opts.cwd, file), symbols: extractSymbols(content, ext) });
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) {
@@ -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'];
@@ -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 () => { process.stdout.write('mcp servers: (stub) github filesystem — use .mcp.json\n'); });
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 / agents
577
- program.command('hooks').description('List hooks (10.2)').action(async () => { process.stdout.write('hooks: SessionStart UserPromptSubmit PreToolUse PostToolUse (stub)\n'); });
578
- program.command('agents').description('List agents (10.2)').action(async () => { process.stdout.write('agents: explorer implementer tester reviewer (stub)\n'); });
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.command('commit').description('Create commit (10.3)').action(async () => { process.stdout.write('commit — conventional message (stub, use /commit)\n'); });
581
- program.command('audit').description('Audit log (13.4)').action(async () => { process.stdout.write('audit — hash-chained JSONL (stub)\n'); });
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');
@@ -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;
@@ -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
- await this.request('initialize', {
77
- protocolVersion: PROTOCOL_VERSION,
78
- capabilities: {},
79
- clientInfo: { name: 'klyro', version: '1.0.0' },
80
- }, timeoutMs);
81
- this.notify('notifications/initialized', {});
82
- this.connected = true;
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 (child && !child.killed) {
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?.removeEventListener('abort', pend.onAbort);
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
  }