@moikapy/lich 0.6.0 → 0.7.1

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 (39) hide show
  1. package/CHANGELOG.md +75 -1
  2. package/README.md +59 -7
  3. package/dist/{chunk-JC2G3XH2.js → chunk-CVX7LZWC.js} +2 -2
  4. package/dist/chunk-PZNYVGD4.js +58 -0
  5. package/dist/chunk-PZNYVGD4.js.map +1 -0
  6. package/dist/{chunk-7HLVKVIG.js → chunk-WNFBIX4E.js} +1344 -162
  7. package/dist/chunk-WNFBIX4E.js.map +1 -0
  8. package/dist/cli.js +246 -18
  9. package/dist/cli.js.map +1 -1
  10. package/dist/{gateway-RJFZJEUZ.js → gateway-44QTIJTJ.js} +108 -49
  11. package/dist/gateway-44QTIJTJ.js.map +1 -0
  12. package/dist/index.d.ts +288 -154
  13. package/dist/index.js +5 -3
  14. package/dist/{tui-LOUJVZ6A.js → tui-MEJGEILU.js} +25 -10
  15. package/dist/tui-MEJGEILU.js.map +1 -0
  16. package/docs/.vitepress/config.mts +4 -0
  17. package/docs/architecture/overview.md +30 -18
  18. package/docs/architecture/plugins.md +1 -1
  19. package/docs/architecture/tools.md +36 -4
  20. package/docs/getting-started.md +7 -6
  21. package/docs/index.md +5 -4
  22. package/docs/user-guide/cli.md +21 -8
  23. package/docs/user-guide/games.md +1 -1
  24. package/docs/user-guide/gateway.md +70 -16
  25. package/docs/user-guide/godot.md +3 -1
  26. package/docs/user-guide/library.md +4 -2
  27. package/docs/user-guide/plugins.md +2 -2
  28. package/docs/user-guide/redot.md +93 -0
  29. package/docs/user-guide/tui.md +2 -2
  30. package/examples/game_bridge/README.md +2 -0
  31. package/optional-mcps/godot/manifest.json +6 -0
  32. package/optional-mcps/redot/manifest.json +18 -0
  33. package/package.json +2 -1
  34. package/dist/chunk-6M6OAQGN.js +0 -17
  35. package/dist/chunk-6M6OAQGN.js.map +0 -1
  36. package/dist/chunk-7HLVKVIG.js.map +0 -1
  37. package/dist/gateway-RJFZJEUZ.js.map +0 -1
  38. package/dist/tui-LOUJVZ6A.js.map +0 -1
  39. /package/dist/{chunk-JC2G3XH2.js.map → chunk-CVX7LZWC.js.map} +0 -0
@@ -4,6 +4,7 @@ import { readdir } from "fs/promises";
4
4
  import path2 from "path";
5
5
 
6
6
  // src/tools/guard.ts
7
+ import fs from "fs";
7
8
  import path from "path";
8
9
 
9
10
  // src/util/json.ts
@@ -33,14 +34,73 @@ function truncate_text(text, max_chars) {
33
34
  // src/tools/guard.ts
34
35
  var DEFAULT_MAX_OUTPUT_CHARS = 2e4;
35
36
  var DEFAULT_TOOL_TIMEOUT_MS = 3e4;
36
- function resolve_safe_path(base_dir, target) {
37
+ function is_inside(base, candidate) {
38
+ const relative = path.relative(base, candidate);
39
+ return relative.startsWith("..") === false && path.isAbsolute(relative) === false;
40
+ }
41
+ function deepest_existing(target) {
42
+ let current = target;
43
+ while (fs.existsSync(current) === false) {
44
+ const parent = path.dirname(current);
45
+ if (parent === current) {
46
+ return current;
47
+ }
48
+ current = parent;
49
+ }
50
+ return current;
51
+ }
52
+ function resolve_safe_path(base_dir, target, for_write = false) {
37
53
  const base = path.resolve(base_dir);
38
54
  const resolved = path.resolve(base, target);
39
- const relative = path.relative(base, resolved);
40
- if (relative.startsWith("..") === true || path.isAbsolute(relative) === true) {
55
+ if (is_inside(base, resolved) === false) {
56
+ throw new Error(`path_escape: ${target} escapes ${base_dir}`);
57
+ }
58
+ const real_base = fs.realpathSync(base);
59
+ const existing = deepest_existing(resolved);
60
+ const real_existing = fs.realpathSync(existing);
61
+ const suffix = path.relative(existing, resolved);
62
+ const real_resolved = suffix.length === 0 ? real_existing : path.resolve(real_existing, suffix);
63
+ if (is_inside(real_base, real_resolved) === false) {
64
+ throw new Error(`path_escape: ${target} escapes ${base_dir}`);
65
+ }
66
+ if (for_write === true) {
67
+ reject_symlink_leaf(resolved, target, base_dir);
68
+ }
69
+ return real_resolved;
70
+ }
71
+ function reject_symlink_leaf(resolved, target, base_dir) {
72
+ let info;
73
+ try {
74
+ info = fs.lstatSync(resolved);
75
+ } catch (err) {
76
+ if (is_enoent(err) === true) {
77
+ return;
78
+ }
79
+ throw err;
80
+ }
81
+ if (info.isSymbolicLink() === true) {
41
82
  throw new Error(`path_escape: ${target} escapes ${base_dir}`);
42
83
  }
43
- return resolved;
84
+ }
85
+ function assert_file_tool_access(work_dir, resolved, mode) {
86
+ const base = fs.realpathSync(path.resolve(work_dir));
87
+ const rel = path.relative(base, resolved);
88
+ const parts = rel.split(path.sep).filter((part) => part.length > 0);
89
+ if (parts[0] === ".lich" && parts[1] === "config.json" && parts.length === 2) {
90
+ throw new Error("forbidden_path: .lich/config.json");
91
+ }
92
+ if (mode === "write" && parts[0] === ".lich") {
93
+ const allowed = parts[1] === "skills" || parts[1] === "plugins";
94
+ if (allowed === false) {
95
+ throw new Error("forbidden_path: .lich writes limited to skills/ and plugins/");
96
+ }
97
+ }
98
+ if (mode === "write") {
99
+ const leaf = path.basename(resolved);
100
+ if (leaf === ".env" || leaf.startsWith(".env.")) {
101
+ throw new Error("forbidden_path: .env*");
102
+ }
103
+ }
44
104
  }
45
105
  function require_string_arg(args, key) {
46
106
  const value = args[key];
@@ -116,6 +176,233 @@ async function capture_errors(run) {
116
176
  }
117
177
  }
118
178
 
179
+ // src/tools/url_guard.ts
180
+ import dns from "dns/promises";
181
+ import http from "http";
182
+ import https from "https";
183
+ import net from "net";
184
+ var MAX_REDIRECTS = 5;
185
+ var REDIRECT_STATUSES = /* @__PURE__ */ new Set([301, 302, 303, 307, 308]);
186
+ var fetch_override;
187
+ function private_urls_allowed() {
188
+ return process.env["LICH_ALLOW_PRIVATE_URLS"] === "1";
189
+ }
190
+ function is_blocked_ipv4(address) {
191
+ const parts = address.split(".").map((part) => Number(part));
192
+ if (parts.length !== 4 || parts.some((n) => Number.isFinite(n) === false)) {
193
+ return true;
194
+ }
195
+ const [a, b] = parts;
196
+ if (a === 0 || a === 10 || a === 127) {
197
+ return true;
198
+ }
199
+ if (a === 169 && b === 254) {
200
+ return true;
201
+ }
202
+ if (a === 172 && b >= 16 && b <= 31) {
203
+ return true;
204
+ }
205
+ if (a === 192 && b === 168) {
206
+ return true;
207
+ }
208
+ if (a === 100 && b >= 64 && b <= 127) {
209
+ return true;
210
+ }
211
+ return a >= 224;
212
+ }
213
+ function is_blocked_ipv6(address) {
214
+ const normalized = address.toLowerCase();
215
+ if (normalized === "::" || normalized === "::1") {
216
+ return true;
217
+ }
218
+ if (normalized.startsWith("::ffff:")) {
219
+ const mapped = normalized.slice("::ffff:".length);
220
+ return net.isIPv4(mapped) === true ? is_blocked_ipv4(mapped) : true;
221
+ }
222
+ const head = Number.parseInt(normalized.split(":")[0] ?? "", 16);
223
+ if (Number.isFinite(head) === false) {
224
+ return true;
225
+ }
226
+ if ((head & 65472) === 65152) {
227
+ return true;
228
+ }
229
+ if ((head & 65024) === 64512) {
230
+ return true;
231
+ }
232
+ return false;
233
+ }
234
+ function is_blocked_ip(address) {
235
+ if (net.isIPv4(address) === true) {
236
+ return is_blocked_ipv4(address);
237
+ }
238
+ if (net.isIPv6(address) === true) {
239
+ return is_blocked_ipv6(address);
240
+ }
241
+ return true;
242
+ }
243
+ function parse_http_url(raw) {
244
+ let parsed;
245
+ try {
246
+ parsed = new URL(raw);
247
+ } catch {
248
+ throw new Error(`invalid_url: ${raw}`);
249
+ }
250
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
251
+ throw new Error(`invalid_url: unsupported protocol ${parsed.protocol}`);
252
+ }
253
+ return parsed;
254
+ }
255
+ function normalize_hostname(hostname) {
256
+ if (hostname.startsWith("[") === true && hostname.endsWith("]") === true) {
257
+ return hostname.slice(1, -1);
258
+ }
259
+ return hostname;
260
+ }
261
+ async function resolve_public_ip(raw_hostname) {
262
+ const hostname = normalize_hostname(raw_hostname);
263
+ if (private_urls_allowed() === true) {
264
+ if (net.isIP(hostname) !== 0) {
265
+ return hostname;
266
+ }
267
+ const hit = await dns.lookup(hostname);
268
+ return hit.address;
269
+ }
270
+ if (hostname === "localhost" || hostname.endsWith(".localhost") || hostname.endsWith(".local")) {
271
+ throw new Error(`blocked_url: ${hostname}`);
272
+ }
273
+ if (net.isIP(hostname) !== 0) {
274
+ if (is_blocked_ip(hostname) === true) {
275
+ throw new Error(`blocked_url: ${hostname}`);
276
+ }
277
+ return hostname;
278
+ }
279
+ const records = await dns.lookup(hostname, { all: true, verbatim: true });
280
+ if (records.length === 0) {
281
+ throw new Error(`blocked_url: ${hostname}`);
282
+ }
283
+ for (const record of records) {
284
+ if (is_blocked_ip(record.address) === true) {
285
+ throw new Error(`blocked_url: ${hostname}`);
286
+ }
287
+ }
288
+ return records[0]?.address ?? hostname;
289
+ }
290
+ function pinned_lookup(ip) {
291
+ const family = net.isIPv6(ip) === true ? 6 : 4;
292
+ return ((_hostname, options, callback) => {
293
+ const cb = typeof options === "function" ? options : callback;
294
+ const opts = typeof options === "function" ? void 0 : options;
295
+ if (typeof cb !== "function") {
296
+ return;
297
+ }
298
+ if (opts !== void 0 && opts.all === true) {
299
+ cb(null, [{ address: ip, family }]);
300
+ return;
301
+ }
302
+ cb(null, ip, family);
303
+ });
304
+ }
305
+ function request_headers(init) {
306
+ const headers = {};
307
+ new Headers(init?.headers).forEach((value, key) => {
308
+ headers[key] = value;
309
+ });
310
+ return headers;
311
+ }
312
+ function request_body(init) {
313
+ const body = init?.body;
314
+ if (body === void 0 || body === null) {
315
+ return void 0;
316
+ }
317
+ if (typeof body === "string" || body instanceof Uint8Array) {
318
+ return body;
319
+ }
320
+ throw new Error("blocked_url: unsupported_body");
321
+ }
322
+ function pinned_http_fetch(url, ip, init) {
323
+ const lib = url.protocol === "https:" ? https : http;
324
+ const method = (init.method ?? "GET").toUpperCase();
325
+ const headers = request_headers(init);
326
+ const body = request_body(init);
327
+ return new Promise((resolve, reject) => {
328
+ const req = lib.request(
329
+ {
330
+ protocol: url.protocol,
331
+ hostname: url.hostname,
332
+ port: url.port.length > 0 ? Number(url.port) : void 0,
333
+ path: `${url.pathname}${url.search}`,
334
+ method,
335
+ headers,
336
+ lookup: pinned_lookup(ip)
337
+ },
338
+ (incoming) => {
339
+ const chunks = [];
340
+ incoming.on("data", (chunk) => {
341
+ chunks.push(chunk);
342
+ });
343
+ incoming.on("end", () => {
344
+ const status = incoming.statusCode ?? 0;
345
+ const response_headers = new Headers();
346
+ for (const [key, value] of Object.entries(incoming.headers)) {
347
+ if (typeof value === "string") {
348
+ response_headers.set(key, value);
349
+ } else if (Array.isArray(value) === true) {
350
+ for (const part of value) {
351
+ response_headers.append(key, part);
352
+ }
353
+ }
354
+ }
355
+ resolve(new Response(Buffer.concat(chunks), { status, headers: response_headers }));
356
+ });
357
+ }
358
+ );
359
+ req.on("error", reject);
360
+ const signal = init.signal;
361
+ if (signal !== void 0 && signal !== null) {
362
+ if (signal.aborted === true) {
363
+ req.destroy(new Error("aborted"));
364
+ return;
365
+ }
366
+ signal.addEventListener(
367
+ "abort",
368
+ () => {
369
+ req.destroy(new Error("aborted"));
370
+ },
371
+ { once: true }
372
+ );
373
+ }
374
+ if (body !== void 0) {
375
+ req.write(body);
376
+ }
377
+ req.end();
378
+ });
379
+ }
380
+ function redirect_target(current, response) {
381
+ if (REDIRECT_STATUSES.has(response.status) === false) {
382
+ return void 0;
383
+ }
384
+ const location = response.headers.get("location");
385
+ if (location === null || location.length === 0) {
386
+ return void 0;
387
+ }
388
+ return new URL(location, current);
389
+ }
390
+ async function safe_fetch(raw_url, init) {
391
+ let current = parse_http_url(raw_url);
392
+ let request_init = { ...init ?? {}, redirect: "manual" };
393
+ for (let hop = 0; hop < MAX_REDIRECTS; hop += 1) {
394
+ const ip = await resolve_public_ip(current.hostname);
395
+ const response = fetch_override !== void 0 ? await fetch_override(current.href, { ...request_init, redirect: "manual" }) : await pinned_http_fetch(current, ip, request_init);
396
+ const next = redirect_target(current, response);
397
+ if (next === void 0) {
398
+ return response;
399
+ }
400
+ current = next;
401
+ request_init = { ...request_init, method: "GET", body: void 0 };
402
+ }
403
+ throw new Error("blocked_url: too_many_redirects");
404
+ }
405
+
119
406
  // src/tools/builtin/fetch_url.ts
120
407
  var DEFAULT_MAX_CHARS = 2e4;
121
408
  var MAX_MAX_CHARS = 1e5;
@@ -136,15 +423,7 @@ function clamp_int_arg(args, key, fallback, max) {
136
423
  return Math.min(max, Math.max(1, Math.floor(optional_number_arg(args, key, fallback))));
137
424
  }
138
425
  function valid_http_url(raw) {
139
- let parsed;
140
- try {
141
- parsed = new URL(raw);
142
- } catch {
143
- throw new Error(`invalid_url: ${raw}`);
144
- }
145
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
146
- throw new Error(`invalid_url: unsupported protocol ${parsed.protocol}`);
147
- }
426
+ parse_http_url(raw);
148
427
  }
149
428
  function compose_abort_signal(timeout_ms, external) {
150
429
  const timeout_signal = AbortSignal.timeout(timeout_ms);
@@ -155,8 +434,7 @@ async function run_fetch_url(args, external) {
155
434
  valid_http_url(url);
156
435
  const max_chars = clamp_int_arg(args, "max_chars", DEFAULT_MAX_CHARS, MAX_MAX_CHARS);
157
436
  const timeout_ms = clamp_int_arg(args, "timeout_ms", DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS);
158
- const response = await fetch(url, {
159
- redirect: "follow",
437
+ const response = await safe_fetch(url, {
160
438
  headers: { "user-agent": USER_AGENT },
161
439
  signal: compose_abort_signal(timeout_ms, external)
162
440
  });
@@ -618,10 +896,11 @@ function replacement_for(content, old_string, new_string, replace_all) {
618
896
  if (replace_all === true) {
619
897
  return content.split(old_string).join(new_string);
620
898
  }
621
- return content.replace(old_string, new_string);
899
+ return content.replace(old_string, () => new_string);
622
900
  }
623
901
  async function apply_edit(work_dir, target, old_string, new_string, replace_all) {
624
- const file_path = resolve_safe_path(work_dir, target);
902
+ const file_path = resolve_safe_path(work_dir, target, true);
903
+ assert_file_tool_access(work_dir, file_path, "write");
625
904
  let content;
626
905
  try {
627
906
  content = await readFile(file_path, "utf8");
@@ -650,9 +929,12 @@ var edit_file_tool = {
650
929
  execute: async (args, context) => capture_errors(async () => {
651
930
  const target = require_string_arg(args, "path");
652
931
  const old_string = require_string_arg(args, "old_string");
653
- const new_string = require_string_arg(args, "new_string");
932
+ const new_raw = args["new_string"];
933
+ if (typeof new_raw !== "string") {
934
+ throw new Error("missing_arg: new_string");
935
+ }
654
936
  const replace_all = optional_boolean_arg(args, "replace_all", false);
655
- const result = await apply_edit(context.work_dir, target, old_string, new_string, replace_all);
937
+ const result = await apply_edit(context.work_dir, target, old_string, new_raw, replace_all);
656
938
  return { ok: true, output: result.output };
657
939
  })
658
940
  };
@@ -812,7 +1094,7 @@ async function scan_dir(frame, matcher) {
812
1094
  return { files, dirs };
813
1095
  }
814
1096
  for (const entry of entries) {
815
- if (SKIP_DIRS.has(entry.name) === true) {
1097
+ if (SKIP_DIRS.has(entry.name) === true || entry.isSymbolicLink() === true) {
816
1098
  continue;
817
1099
  }
818
1100
  const full = path5.join(frame.dir, entry.name);
@@ -824,13 +1106,25 @@ async function scan_dir(frame, matcher) {
824
1106
  }
825
1107
  return { files, dirs };
826
1108
  }
827
- async function search_file(frame, relative_root, regex, collected, max_results) {
828
- const size = await file_size(frame.dir);
829
- const lines = await read_if_text(frame.dir, size);
1109
+ function guard_grep_target(work_dir, absolute) {
1110
+ const relative = path5.relative(work_dir, absolute);
1111
+ const safe = resolve_safe_path(work_dir, relative);
1112
+ assert_file_tool_access(work_dir, safe, "read");
1113
+ return safe;
1114
+ }
1115
+ async function search_file(frame, work_dir, relative_root, regex, collected, max_results) {
1116
+ let safe;
1117
+ try {
1118
+ safe = guard_grep_target(work_dir, frame.dir);
1119
+ } catch {
1120
+ return false;
1121
+ }
1122
+ const size = await file_size(safe);
1123
+ const lines = await read_if_text(safe, size);
830
1124
  if (lines === void 0) {
831
1125
  return false;
832
1126
  }
833
- const relative = path5.relative(relative_root, frame.dir);
1127
+ const relative = path5.relative(relative_root, safe);
834
1128
  for (const hit of match_lines(lines, regex)) {
835
1129
  collected.push(`${relative}:${hit.line_no}: ${hit.text}`);
836
1130
  if (collected.length >= max_results) {
@@ -839,7 +1133,7 @@ async function search_file(frame, relative_root, regex, collected, max_results)
839
1133
  }
840
1134
  return false;
841
1135
  }
842
- async function search_tree(root, regex, matcher, max_results) {
1136
+ async function search_tree(root, work_dir, regex, matcher, max_results) {
843
1137
  const collected = [];
844
1138
  const stack = [{ dir: root, name: root }];
845
1139
  while (stack.length > 0 && collected.length < max_results) {
@@ -849,7 +1143,7 @@ async function search_tree(root, regex, matcher, max_results) {
849
1143
  }
850
1144
  const found = await scan_dir(frame, matcher);
851
1145
  for (const file of found.files) {
852
- const hit_cap = await search_file(file, root, regex, collected, max_results);
1146
+ const hit_cap = await search_file(file, work_dir, root, regex, collected, max_results);
853
1147
  if (hit_cap === true) {
854
1148
  break;
855
1149
  }
@@ -869,13 +1163,20 @@ function finalize_output(matches, max_results) {
869
1163
  }
870
1164
  return matches.join("\n");
871
1165
  }
872
- function search_file_direct(file_path, regex, collected, max_results) {
873
- return search_file({ dir: file_path, name: path5.basename(file_path) }, path5.dirname(file_path), regex, collected, max_results);
1166
+ function search_file_direct(file_path, work_dir, regex, collected, max_results) {
1167
+ return search_file(
1168
+ { dir: file_path, name: path5.basename(file_path) },
1169
+ work_dir,
1170
+ path5.dirname(file_path),
1171
+ regex,
1172
+ collected,
1173
+ max_results
1174
+ );
874
1175
  }
875
- async function collect_file_matches(root, regex, matcher, max_results) {
1176
+ async function collect_file_matches(root, work_dir, regex, matcher, max_results) {
876
1177
  const collected = [];
877
1178
  if (matcher(path5.basename(root)) === true) {
878
- await search_file_direct(root, regex, collected, max_results);
1179
+ await search_file_direct(root, work_dir, regex, collected, max_results);
879
1180
  }
880
1181
  return collected;
881
1182
  }
@@ -892,9 +1193,10 @@ async function run_grep(args, work_dir) {
892
1193
  }
893
1194
  const matcher = glob.length > 0 ? glob_matcher(glob) : () => true;
894
1195
  const root = resolve_safe_path(work_dir, target);
1196
+ assert_file_tool_access(work_dir, root, "read");
895
1197
  const root_stat = await stat(root);
896
1198
  const cap = max_results + 1;
897
- const matches = root_stat.isDirectory() === true ? await search_tree(root, regex, matcher, cap) : await collect_file_matches(root, regex, matcher, cap);
1199
+ const matches = root_stat.isDirectory() === true ? await search_tree(root, work_dir, regex, matcher, cap) : await collect_file_matches(root, work_dir, regex, matcher, cap);
898
1200
  return finalize_output(matches, max_results);
899
1201
  }
900
1202
  var grep_files_tool = {
@@ -966,7 +1268,6 @@ async function run_http(args, external) {
966
1268
  const init = {
967
1269
  method,
968
1270
  headers: read_headers(args),
969
- redirect: "follow",
970
1271
  signal: compose_abort_signal(timeout_ms, external)
971
1272
  };
972
1273
  if (method !== "GET" && method !== "HEAD") {
@@ -975,7 +1276,7 @@ async function run_http(args, external) {
975
1276
  init.body = body;
976
1277
  }
977
1278
  }
978
- const response = await fetch(url, init);
1279
+ const response = await safe_fetch(url, init);
979
1280
  const content_type = response.headers.get("content-type") ?? "unknown";
980
1281
  const text = await response.text();
981
1282
  const sections = [
@@ -1187,6 +1488,7 @@ function slice_lines2(content, offset, limit) {
1187
1488
  }
1188
1489
  async function read_target(args, work_dir, target) {
1189
1490
  const file_path = resolve_safe_path(work_dir, target);
1491
+ assert_file_tool_access(work_dir, file_path, "read");
1190
1492
  const content = await readFile3(file_path, "utf8");
1191
1493
  const offset = optional_number_arg(args, "offset", 1);
1192
1494
  const limit = optional_number_arg(args, "limit", Number.MAX_SAFE_INTEGER);
@@ -1211,78 +1513,47 @@ var read_file_tool = {
1211
1513
  };
1212
1514
 
1213
1515
  // src/tools/builtin/run_tests.ts
1516
+ import { spawn as spawn2 } from "child_process";
1517
+
1518
+ // src/tools/builtin/terminal.ts
1214
1519
  import { spawn } from "child_process";
1215
- var MAX_OUTPUT_CHARS = 2e3;
1216
- var DEFAULT_TEST_COMMAND = "node node_modules/vitest/vitest.mjs run";
1217
- var parameters12 = {
1218
- type: "object",
1219
- properties: {
1220
- filter: {
1221
- type: "string",
1222
- description: "Optional test-file filter appended to the test command (e.g. a vitest file filter)"
1223
- }
1224
- },
1225
- additionalProperties: false
1520
+
1521
+ // src/gateway/token_env.ts
1522
+ var DEFAULT_GATEWAY_TOKEN_ENVS = {
1523
+ webhook: "LICH_GATEWAY_TOKEN",
1524
+ telegram: "LICH_TELEGRAM_BOT_TOKEN",
1525
+ discord: "LICH_DISCORD_BOT_TOKEN",
1526
+ twitch: "LICH_TWITCH_OAUTH_TOKEN"
1226
1527
  };
1227
- var busy = false;
1228
- var run_test_command = default_runner;
1229
- function default_runner(command, cwd, on_chunk) {
1230
- const child = spawn("bash", ["-lc", command], {
1231
- cwd,
1232
- env: process.env,
1233
- stdio: ["ignore", "pipe", "pipe"]
1234
- });
1235
- child.stdout?.on("data", (chunk) => on_chunk("stdout", chunk));
1236
- child.stderr?.on("data", (chunk) => on_chunk("stderr", chunk));
1237
- return new Promise((resolve) => {
1238
- child.on("close", (code) => resolve({ exit_code: code ?? -1 }));
1239
- child.on("error", () => resolve({ exit_code: -1 }));
1240
- });
1528
+ var ENV_VAR_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
1529
+ function is_env_var_name(value) {
1530
+ return ENV_VAR_NAME.test(value) === true;
1241
1531
  }
1242
- function shell_quote(token) {
1243
- return `'${token.replaceAll("'", "'\\''")}'`;
1532
+ function platform_token_env(config, platform) {
1533
+ const named = config.gateway?.token_envs[platform];
1534
+ if (named !== void 0 && named.length > 0) {
1535
+ return is_env_var_name(named) === true ? named : "";
1536
+ }
1537
+ return DEFAULT_GATEWAY_TOKEN_ENVS[platform] ?? "";
1244
1538
  }
1245
- function build_command(filter, env) {
1246
- const base = optional_string_arg(env, "LICH_TEST_COMMAND", DEFAULT_TEST_COMMAND);
1247
- return filter === void 0 ? base : `${base} ${shell_quote(filter)}`;
1539
+ function read_platform_token(config, platform) {
1540
+ const key = platform_token_env(config, platform);
1541
+ if (is_env_var_name(key) === false) {
1542
+ return void 0;
1543
+ }
1544
+ const value = process.env[key];
1545
+ if (typeof value !== "string" || value.length === 0) {
1546
+ return void 0;
1547
+ }
1548
+ return value;
1248
1549
  }
1249
- var run_tests_tool = {
1250
- name: "run_tests",
1251
- description: "Run the project's test suite via LICH_TEST_COMMAND (default: vitest) in work_dir and report a structured pass/fail result with clamped output.",
1252
- parameters: parameters12,
1253
- timeout_ms: 6e5,
1254
- execute: async (args, context) => capture_errors(async () => {
1255
- if (busy === true) {
1256
- return { ok: false, output: "", error: "run_tests_busy" };
1257
- }
1258
- busy = true;
1259
- try {
1260
- const filter = optional_string_arg(args, "filter", "");
1261
- const command = build_command(filter === "" ? void 0 : filter, context.env);
1262
- const streams = { stdout: "", stderr: "" };
1263
- const on_chunk = (stream, chunk) => {
1264
- streams[stream] = streams[stream] + chunk.toString("utf8");
1265
- };
1266
- const outcome = await run_test_command(command, context.work_dir, on_chunk);
1267
- const ok = outcome.exit_code === 0;
1268
- return {
1269
- ok,
1270
- output: clamp_output(`${streams.stdout}${streams.stderr}
1271
- [exit ${outcome.exit_code}]`, MAX_OUTPUT_CHARS),
1272
- ...ok ? {} : { error: "tests_failed" }
1273
- };
1274
- } finally {
1275
- busy = false;
1276
- }
1277
- })
1278
- };
1279
1550
 
1280
1551
  // src/tools/builtin/terminal.ts
1281
- import { spawn as spawn2 } from "child_process";
1282
1552
  var MAX_STREAM_CHARS = 5e4;
1283
1553
  var DEFAULT_TIMEOUT_MS3 = 6e4;
1284
1554
  var MAX_TIMEOUT_MS3 = 3e5;
1285
- var parameters13 = {
1555
+ var PROVIDER_KEY_ENVS = ["OPENAI_API_KEY", "ANTHROPIC_API_KEY", "LICH_API_KEY"];
1556
+ var parameters12 = {
1286
1557
  type: "object",
1287
1558
  properties: {
1288
1559
  command: { type: "string", description: "Shell command to run via bash -lc" },
@@ -1303,6 +1574,21 @@ function stream_chunk(current, chunk) {
1303
1574
  function clamp_timeout(raw) {
1304
1575
  return Math.min(MAX_TIMEOUT_MS3, Math.max(1, Math.floor(raw)));
1305
1576
  }
1577
+ function scrub_spawn_env(process_env, context_env) {
1578
+ const drop = /* @__PURE__ */ new Set([...Object.values(DEFAULT_GATEWAY_TOKEN_ENVS), ...PROVIDER_KEY_ENVS]);
1579
+ const merged = { ...process_env, ...context_env };
1580
+ const scrubbed = {};
1581
+ for (const [name, value] of Object.entries(merged)) {
1582
+ if (value === void 0) {
1583
+ continue;
1584
+ }
1585
+ if (drop.has(name) === true || SECRET_PATTERN.test(name) === true) {
1586
+ continue;
1587
+ }
1588
+ scrubbed[name] = value;
1589
+ }
1590
+ return scrubbed;
1591
+ }
1306
1592
  function wire_kill(child, timeout_signal, external) {
1307
1593
  timeout_signal.addEventListener("abort", () => child.kill("SIGKILL"), { once: true });
1308
1594
  external?.addEventListener("abort", () => child.kill("SIGKILL"), { once: true });
@@ -1316,7 +1602,10 @@ function wait_close(child) {
1316
1602
  async function run_command(command, work_dir, env, timeout_ms, external) {
1317
1603
  const stdout = { text: "" };
1318
1604
  const stderr = { text: "" };
1319
- const child = spawn2("bash", ["-lc", command], { cwd: work_dir, env: { ...process.env, ...env } });
1605
+ const child = spawn("bash", ["-lc", command], {
1606
+ cwd: work_dir,
1607
+ env: scrub_spawn_env(process.env, env)
1608
+ });
1320
1609
  child.stdout.on("data", (chunk) => stream_chunk(stdout, chunk));
1321
1610
  child.stderr.on("data", (chunk) => stream_chunk(stderr, chunk));
1322
1611
  const close_promise = wait_close(child);
@@ -1354,7 +1643,7 @@ function terminal_result(outcome) {
1354
1643
  var terminal_tool = {
1355
1644
  name: "terminal",
1356
1645
  description: "Run a shell command with bash -lc and capture combined stdout/stderr plus the exit code.",
1357
- parameters: parameters13,
1646
+ parameters: parameters12,
1358
1647
  timeout_ms: MAX_TIMEOUT_MS3,
1359
1648
  execute: async (args, context) => capture_errors(async () => {
1360
1649
  const command = require_string_arg(args, "command");
@@ -1364,6 +1653,75 @@ var terminal_tool = {
1364
1653
  })
1365
1654
  };
1366
1655
 
1656
+ // src/tools/builtin/run_tests.ts
1657
+ var MAX_OUTPUT_CHARS = 2e3;
1658
+ var DEFAULT_TEST_COMMAND = "node node_modules/vitest/vitest.mjs run";
1659
+ var parameters13 = {
1660
+ type: "object",
1661
+ properties: {
1662
+ filter: {
1663
+ type: "string",
1664
+ description: "Optional test-file filter appended to the test command (e.g. a vitest file filter)"
1665
+ }
1666
+ },
1667
+ additionalProperties: false
1668
+ };
1669
+ var busy = false;
1670
+ var run_test_command = default_runner;
1671
+ function default_runner(command, cwd, on_chunk) {
1672
+ const child = spawn2("bash", ["-lc", command], {
1673
+ cwd,
1674
+ env: scrub_spawn_env(process.env, {}),
1675
+ stdio: ["ignore", "pipe", "pipe"]
1676
+ });
1677
+ child.stdout?.on("data", (chunk) => on_chunk("stdout", chunk));
1678
+ child.stderr?.on("data", (chunk) => on_chunk("stderr", chunk));
1679
+ return new Promise((resolve) => {
1680
+ child.on("close", (code) => resolve({ exit_code: code ?? -1 }));
1681
+ child.on("error", () => resolve({ exit_code: -1 }));
1682
+ });
1683
+ }
1684
+ function shell_quote(token) {
1685
+ return `'${token.replaceAll("'", "'\\''")}'`;
1686
+ }
1687
+ function build_command(filter, env) {
1688
+ const base = optional_string_arg(env, "LICH_TEST_COMMAND", DEFAULT_TEST_COMMAND);
1689
+ return filter === void 0 ? base : `${base} ${shell_quote(filter)}`;
1690
+ }
1691
+ var run_tests_tool = {
1692
+ name: "run_tests",
1693
+ description: "Run the project's test suite via LICH_TEST_COMMAND (default: vitest) in work_dir and report a structured pass/fail result with clamped output.",
1694
+ parameters: parameters13,
1695
+ timeout_ms: 6e5,
1696
+ execute: async (args, context) => capture_errors(async () => {
1697
+ if (busy === true) {
1698
+ return { ok: false, output: "", error: "run_tests_busy" };
1699
+ }
1700
+ busy = true;
1701
+ try {
1702
+ const filter = optional_string_arg(args, "filter", "");
1703
+ if (filter.startsWith("-") === true) {
1704
+ return { ok: false, output: "", error: "invalid_filter: must not start with -" };
1705
+ }
1706
+ const command = build_command(filter === "" ? void 0 : filter, context.env);
1707
+ const streams = { stdout: "", stderr: "" };
1708
+ const on_chunk = (stream, chunk) => {
1709
+ streams[stream] = streams[stream] + chunk.toString("utf8");
1710
+ };
1711
+ const outcome = await run_test_command(command, context.work_dir, on_chunk);
1712
+ const ok = outcome.exit_code === 0;
1713
+ return {
1714
+ ok,
1715
+ output: clamp_output(`${streams.stdout}${streams.stderr}
1716
+ [exit ${outcome.exit_code}]`, MAX_OUTPUT_CHARS),
1717
+ ...ok ? {} : { error: "tests_failed" }
1718
+ };
1719
+ } finally {
1720
+ busy = false;
1721
+ }
1722
+ })
1723
+ };
1724
+
1367
1725
  // src/tools/builtin/web_search.ts
1368
1726
  var DEFAULT_MAX_RESULTS4 = 8;
1369
1727
  var MAX_RESULTS = 20;
@@ -1490,7 +1848,8 @@ function read_content_arg(args) {
1490
1848
  return content;
1491
1849
  }
1492
1850
  async function write_target(work_dir, target, content) {
1493
- const file_path = resolve_safe_path(work_dir, target);
1851
+ const file_path = resolve_safe_path(work_dir, target, true);
1852
+ assert_file_tool_access(work_dir, file_path, "write");
1494
1853
  await mkdir(path7.dirname(file_path), { recursive: true });
1495
1854
  await writeFile2(file_path, content, "utf8");
1496
1855
  return `wrote ${content.length} chars to ${target}`;
@@ -1882,41 +2241,219 @@ var ProviderError = class extends Error {
1882
2241
  // src/agent/config.ts
1883
2242
  import { z } from "zod";
1884
2243
 
1885
- // src/gateway/token_env.ts
1886
- var DEFAULT_GATEWAY_TOKEN_ENVS = {
1887
- webhook: "LICH_GATEWAY_TOKEN",
1888
- telegram: "LICH_TELEGRAM_BOT_TOKEN",
1889
- discord: "LICH_DISCORD_BOT_TOKEN",
1890
- twitch: "LICH_TWITCH_OAUTH_TOKEN"
1891
- };
1892
- var ENV_VAR_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
1893
- function is_env_var_name(value) {
1894
- return ENV_VAR_NAME.test(value) === true;
2244
+ // src/gateway/access.ts
2245
+ var PUBLIC_PLATFORMS = /* @__PURE__ */ new Set(["telegram", "discord", "twitch"]);
2246
+ var DEFAULT_GATEWAY_TOOLS_ENABLED = [
2247
+ "read_file",
2248
+ "list_dir",
2249
+ "grep_files",
2250
+ "fetch_url",
2251
+ "web_search",
2252
+ "docs_read",
2253
+ "docs_search"
2254
+ ];
2255
+ function gateway_tools_enabled(config) {
2256
+ return config.gateway?.tools_enabled ?? DEFAULT_GATEWAY_TOOLS_ENABLED;
1895
2257
  }
1896
- function platform_token_env(config, platform) {
1897
- const named = config.gateway?.token_envs[platform];
1898
- if (named !== void 0 && named.length > 0) {
1899
- return is_env_var_name(named) === true ? named : "";
2258
+ function is_gateway_sender_allowed(config, platform, chat_id, user_id) {
2259
+ if (PUBLIC_PLATFORMS.has(platform) === false) {
2260
+ return true;
1900
2261
  }
1901
- return DEFAULT_GATEWAY_TOKEN_ENVS[platform] ?? "";
2262
+ const users = config.gateway?.allowed_users?.[platform] ?? [];
2263
+ const chats = config.gateway?.allowed_chats?.[platform] ?? [];
2264
+ if (users.length === 0 && chats.length === 0) {
2265
+ return false;
2266
+ }
2267
+ const user_ok = users.length === 0 || users.includes(user_id);
2268
+ const chat_ok = chats.length === 0 || chats.includes(chat_id);
2269
+ return user_ok && chat_ok;
1902
2270
  }
1903
- function read_platform_token(config, platform) {
1904
- const key = platform_token_env(config, platform);
1905
- if (is_env_var_name(key) === false) {
1906
- return void 0;
2271
+ function check_gateway_sender(config, platform, chat_id, user_id) {
2272
+ if (is_gateway_sender_allowed(config, platform, chat_id, user_id) === true) {
2273
+ return true;
1907
2274
  }
1908
- const value = process.env[key];
1909
- if (typeof value !== "string" || value.length === 0) {
2275
+ logger.warn(`gateway denied ${platform} chat=${chat_id} user=${user_id}`);
2276
+ return false;
2277
+ }
2278
+
2279
+ // src/mcp/mcp_pin.ts
2280
+ import path11 from "path";
2281
+
2282
+ // src/mcp/mcp_catalog.ts
2283
+ import { existsSync as existsSync2, readFileSync as readFileSync4, readdirSync as readdirSync3 } from "fs";
2284
+ import path9 from "path";
2285
+ import { fileURLToPath as fileURLToPath2 } from "url";
2286
+ var cached;
2287
+ function catalog_dir() {
2288
+ let dir = path9.dirname(fileURLToPath2(import.meta.url));
2289
+ for (let hop = 0; hop < 6; hop += 1) {
2290
+ const candidate = path9.join(dir, "optional-mcps");
2291
+ if (existsSync2(path9.join(candidate, "redot", "manifest.json")) === true) {
2292
+ return candidate;
2293
+ }
2294
+ dir = path9.dirname(dir);
2295
+ }
2296
+ throw new Error("mcp catalog not found");
2297
+ }
2298
+ function read_manifest(file) {
2299
+ const parsed = safe_json_parse(readFileSync4(file, "utf8"));
2300
+ if (parsed === void 0 || typeof parsed.name !== "string") {
2301
+ throw new Error("mcp catalog manifest rejected");
2302
+ }
2303
+ return parsed;
2304
+ }
2305
+ function load_catalog() {
2306
+ if (cached !== void 0) {
2307
+ return cached;
2308
+ }
2309
+ const manifests = [];
2310
+ for (const name of readdirSync3(catalog_dir())) {
2311
+ const file = path9.join(catalog_dir(), name, "manifest.json");
2312
+ if (existsSync2(file) === true) {
2313
+ manifests.push(read_manifest(file));
2314
+ }
2315
+ }
2316
+ cached = manifests;
2317
+ return cached;
2318
+ }
2319
+ function catalog_by_name(name) {
2320
+ return load_catalog().find((entry) => entry.name === name);
2321
+ }
2322
+
2323
+ // src/mcp/mcp_refuse.ts
2324
+ import path10 from "path";
2325
+ var SHELL = /[;&|`$<>]/;
2326
+ var DOWNLOADERS = /* @__PURE__ */ new Set(["npx", "npm", "bunx", "uvx", "curl", "wget"]);
2327
+ function refuse_stdio_command(command) {
2328
+ if (command.includes("://") === true) {
2329
+ return "refused url; only a local binary is allowed";
2330
+ }
2331
+ if (SHELL.test(command) === true) {
2332
+ return "refused shell metacharacters in mcp command";
2333
+ }
2334
+ if (command.length === 0 || command.trim() !== command || /\s/.test(command) === true) {
2335
+ return "refused mcp command";
2336
+ }
2337
+ const base = path10.basename(command);
2338
+ if (DOWNLOADERS.has(base) === true) {
2339
+ return `refused download command '${base}'`;
2340
+ }
2341
+ return void 0;
2342
+ }
2343
+ function refuse_stdio_arg(arg) {
2344
+ if (arg.includes("://") === true) {
2345
+ return "refused url in mcp args";
2346
+ }
2347
+ if (SHELL.test(arg) === true) {
2348
+ return "refused shell metacharacters in mcp args";
2349
+ }
2350
+ return void 0;
2351
+ }
2352
+
2353
+ // src/mcp/mcp_url.ts
2354
+ function refuse_http_url(url) {
2355
+ let parsed;
2356
+ try {
2357
+ parsed = new URL(url);
2358
+ } catch {
2359
+ return "refused mcp url";
2360
+ }
2361
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
2362
+ return "refused mcp url";
2363
+ }
2364
+ if (parsed.username.length > 0 || parsed.password.length > 0) {
2365
+ return "refused mcp url credentials";
2366
+ }
2367
+ const host = parsed.hostname.toLowerCase();
2368
+ if (host !== "127.0.0.1" && host !== "localhost") {
2369
+ return "refused mcp url; loopback only";
2370
+ }
2371
+ return void 0;
2372
+ }
2373
+
2374
+ // src/mcp/mcp_pin.ts
2375
+ function refuse_catalog_stdio(name, command, args) {
2376
+ const pin = catalog_by_name(name);
2377
+ if (pin?.command_basename === void 0) {
1910
2378
  return void 0;
1911
2379
  }
1912
- return value;
2380
+ if (path11.basename(command) !== pin.command_basename) {
2381
+ return `refused command basename '${path11.basename(command)}'; only '${pin.command_basename}' is allowed`;
2382
+ }
2383
+ const prefix = pin.args_prefix ?? [];
2384
+ if (args.length !== prefix.length + 1) {
2385
+ return `refused ${name} args; expected ${prefix.join(" ")} <project>`;
2386
+ }
2387
+ for (let index = 0; index < prefix.length; index += 1) {
2388
+ if (args[index] !== prefix[index]) {
2389
+ return `refused ${name} args; expected ${prefix.join(" ")} <project>`;
2390
+ }
2391
+ }
2392
+ const project = args[prefix.length];
2393
+ if (project === void 0 || project.length === 0 || project.includes("://") === true) {
2394
+ return "refused project path; pass a local project directory";
2395
+ }
2396
+ return void 0;
2397
+ }
2398
+ function refuse_arg_list(args) {
2399
+ for (const arg of args) {
2400
+ const refused = refuse_stdio_arg(arg);
2401
+ if (refused !== void 0) {
2402
+ return refused;
2403
+ }
2404
+ }
2405
+ return void 0;
2406
+ }
2407
+ function refuse_mcp_entry(name, entry) {
2408
+ if (typeof entry.url === "string") {
2409
+ return refuse_http_url(entry.url);
2410
+ }
2411
+ if (typeof entry.command !== "string") {
2412
+ return "refused mcp entry";
2413
+ }
2414
+ const args = entry.args ?? [];
2415
+ return refuse_stdio_command(entry.command) ?? refuse_catalog_stdio(name, entry.command, args) ?? refuse_arg_list(args);
1913
2416
  }
1914
2417
 
1915
2418
  // src/agent/config.ts
2419
+ var gateway_allowlist = z.record(z.string(), z.array(z.string())).default({});
1916
2420
  var gateway_schema = z.object({
1917
2421
  platforms: z.array(z.enum(["webhook", "telegram", "discord", "twitch"])).default([]),
1918
2422
  /** Env-var names that hold tokens. Never store the secrets themselves. */
1919
- token_envs: z.record(z.string(), z.string().regex(ENV_VAR_NAME, "invalid env var name")).default({})
2423
+ token_envs: z.record(z.string(), z.string().regex(ENV_VAR_NAME, "invalid env var name")).default({}),
2424
+ /** Per-platform user ids allowed to talk to the bot (default-deny on public platforms). */
2425
+ allowed_users: gateway_allowlist,
2426
+ /** Per-platform chat/channel ids allowed (default-deny on public platforms). */
2427
+ allowed_chats: gateway_allowlist,
2428
+ /** Tool allowlist for the gateway agent; defaults to a read-only safe subset. */
2429
+ tools_enabled: z.union([z.literal("all"), z.array(z.string())]).default([...DEFAULT_GATEWAY_TOOLS_ENABLED])
2430
+ }).optional();
2431
+ var stdio_mcp_schema = z.object({
2432
+ enabled: z.boolean().default(false),
2433
+ command: z.string().min(1),
2434
+ args: z.array(z.string()).default([]),
2435
+ env: z.record(z.string().regex(ENV_VAR_NAME, "invalid env var name"), z.string()).optional()
2436
+ }).strict();
2437
+ var http_mcp_schema = z.object({
2438
+ enabled: z.boolean().default(false),
2439
+ url: z.string().min(1)
2440
+ }).strict();
2441
+ var mcp_server_schema = z.union([stdio_mcp_schema, http_mcp_schema]);
2442
+ var SERVER_NAME = /^[a-z][a-z0-9_]*$/;
2443
+ var mcp_servers_schema = z.record(z.string(), mcp_server_schema).superRefine((servers, ctx) => {
2444
+ for (const [name, entry] of Object.entries(servers)) {
2445
+ if (SERVER_NAME.test(name) === false) {
2446
+ ctx.addIssue({
2447
+ code: z.ZodIssueCode.custom,
2448
+ path: [name],
2449
+ message: "mcp server name must be snake_case"
2450
+ });
2451
+ }
2452
+ const refused = refuse_mcp_entry(name, entry);
2453
+ if (refused !== void 0) {
2454
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: [name], message: refused });
2455
+ }
2456
+ }
1920
2457
  }).optional();
1921
2458
  var provider_schema = z.object({
1922
2459
  kind: z.enum(["openai_compat", "anthropic", "ollama"]),
@@ -1947,7 +2484,9 @@ var agent_config_schema = z.object({
1947
2484
  plugins: z.array(z.string()).default([]),
1948
2485
  gateway: gateway_schema,
1949
2486
  log_level: z.enum(["debug", "info", "warn", "error"]).default("info"),
1950
- theme: z.string().min(1).default("lich")
2487
+ theme: z.string().min(1).default("lich"),
2488
+ /** Named MCP servers. Each entry is stdio or loopback http. Default off. */
2489
+ mcp_servers: mcp_servers_schema
1951
2490
  }).transform((config) => {
1952
2491
  const work_dir = config.work_dir ?? process.cwd();
1953
2492
  return {
@@ -1966,8 +2505,25 @@ function freeze_config(config) {
1966
2505
  if (config.gateway !== void 0) {
1967
2506
  Object.freeze(config.gateway.platforms);
1968
2507
  Object.freeze(config.gateway.token_envs);
2508
+ Object.freeze(config.gateway.allowed_users);
2509
+ Object.freeze(config.gateway.allowed_chats);
2510
+ if (Array.isArray(config.gateway.tools_enabled) === true) {
2511
+ Object.freeze(config.gateway.tools_enabled);
2512
+ }
1969
2513
  Object.freeze(config.gateway);
1970
2514
  }
2515
+ if (config.mcp_servers !== void 0) {
2516
+ for (const entry of Object.values(config.mcp_servers)) {
2517
+ if ("args" in entry) {
2518
+ Object.freeze(entry.args);
2519
+ }
2520
+ if ("env" in entry && entry.env !== void 0) {
2521
+ Object.freeze(entry.env);
2522
+ }
2523
+ Object.freeze(entry);
2524
+ }
2525
+ Object.freeze(config.mcp_servers);
2526
+ }
1971
2527
  return config;
1972
2528
  }
1973
2529
  function parse_agent_config(raw) {
@@ -2000,15 +2556,547 @@ var AgentEmitter = class {
2000
2556
  }
2001
2557
  };
2002
2558
 
2003
- // src/plugins/builtin/gatekeeper.plugin.ts
2559
+ // src/mcp/mcp_http.ts
2560
+ async function post_rpc(url, fetch_fn, body) {
2561
+ const response = await fetch_fn(url, {
2562
+ method: "POST",
2563
+ redirect: "error",
2564
+ headers: { "content-type": "application/json", accept: "application/json" },
2565
+ body: JSON.stringify(body)
2566
+ });
2567
+ const parsed = await response.json();
2568
+ if (parsed.error !== void 0) {
2569
+ const detail = parsed.error.message;
2570
+ throw new Error(typeof detail === "string" && detail.length > 0 ? detail : "mcp error");
2571
+ }
2572
+ return parsed.result;
2573
+ }
2574
+ function http_pipe(url, fetch_fn) {
2575
+ let next_id = 1;
2576
+ return {
2577
+ request(method, params) {
2578
+ const id = next_id;
2579
+ next_id += 1;
2580
+ return post_rpc(url, fetch_fn, { jsonrpc: "2.0", id, method, params });
2581
+ },
2582
+ notify(method) {
2583
+ void fetch_fn(url, {
2584
+ method: "POST",
2585
+ redirect: "error",
2586
+ headers: { "content-type": "application/json" },
2587
+ body: JSON.stringify({ jsonrpc: "2.0", method })
2588
+ }).catch(() => void 0);
2589
+ },
2590
+ close() {
2591
+ return void 0;
2592
+ }
2593
+ };
2594
+ }
2595
+
2596
+ // src/mcp/mcp_plan.ts
2597
+ import { existsSync as existsSync3 } from "fs";
2598
+ import path12 from "path";
2599
+ function find_on_path(command, env_path) {
2600
+ if (env_path === void 0 || env_path.length === 0) {
2601
+ return void 0;
2602
+ }
2603
+ for (const dir of env_path.split(path12.delimiter)) {
2604
+ if (dir.length === 0) {
2605
+ continue;
2606
+ }
2607
+ const candidate = path12.join(dir, command);
2608
+ if (existsSync3(candidate) === true) {
2609
+ return candidate;
2610
+ }
2611
+ }
2612
+ return void 0;
2613
+ }
2614
+ function locate(command, env_path) {
2615
+ if (command.includes("/") === true || command.includes("\\") === true) {
2616
+ return existsSync3(command) === true ? command : void 0;
2617
+ }
2618
+ return find_on_path(command, env_path);
2619
+ }
2620
+ function plan_stdio(name, command, args, env_path) {
2621
+ const refused = refuse_mcp_entry(name, { command, args });
2622
+ if (refused !== void 0) {
2623
+ return refused;
2624
+ }
2625
+ const binary = locate(command, env_path);
2626
+ if (binary === void 0) {
2627
+ return catalog_by_name(name)?.missing_hint ?? "mcp command not found";
2628
+ }
2629
+ return { command: binary, args };
2630
+ }
2631
+
2632
+ // src/mcp/mcp_pipe.ts
2633
+ var SKIP_LIMIT = 32;
2634
+ function parse_rpc_line(line) {
2635
+ const parsed = safe_json_parse(line);
2636
+ if (typeof parsed !== "object" || parsed === null) {
2637
+ return void 0;
2638
+ }
2639
+ return parsed;
2640
+ }
2641
+ async function read_id(child, id) {
2642
+ for (let skipped = 0; skipped < SKIP_LIMIT; skipped += 1) {
2643
+ const line = await child.read_line();
2644
+ const failure = child.failed();
2645
+ if (failure !== void 0) {
2646
+ throw new Error(failure);
2647
+ }
2648
+ if (line === void 0) {
2649
+ throw new Error("mcp closed the pipe");
2650
+ }
2651
+ const parsed = parse_rpc_line(line);
2652
+ if (parsed === void 0 || parsed.id !== id) {
2653
+ continue;
2654
+ }
2655
+ if (parsed.error !== void 0) {
2656
+ const detail = parsed.error.message;
2657
+ throw new Error(typeof detail === "string" && detail.length > 0 ? detail : "mcp error");
2658
+ }
2659
+ return parsed.result;
2660
+ }
2661
+ throw new Error("mcp sent no matching response");
2662
+ }
2663
+ function stdio_pipe(child) {
2664
+ let next_id = 1;
2665
+ return {
2666
+ request(method, params) {
2667
+ const id = next_id;
2668
+ next_id += 1;
2669
+ child.write_line(JSON.stringify({ jsonrpc: "2.0", id, method, params }));
2670
+ return read_id(child, id);
2671
+ },
2672
+ notify(method) {
2673
+ child.write_line(JSON.stringify({ jsonrpc: "2.0", method }));
2674
+ },
2675
+ close() {
2676
+ child.stop();
2677
+ }
2678
+ };
2679
+ }
2680
+
2681
+ // src/mcp/mcp_names.ts
2682
+ function mcp_tool_name(server, tool) {
2683
+ return `mcp_${sanitize(server)}_${sanitize(tool)}`;
2684
+ }
2685
+ function sanitize(value) {
2686
+ return value.replace(/[^a-zA-Z0-9_]/g, "_");
2687
+ }
2688
+
2689
+ // src/mcp/mcp_register.ts
2690
+ function name_allowed(enabled, name) {
2691
+ if (enabled === "all") {
2692
+ return true;
2693
+ }
2694
+ return enabled.includes(name);
2695
+ }
2696
+ function run_call(session, wire_name, args, context) {
2697
+ return capture_errors(async () => {
2698
+ if (context.signal?.aborted === true) {
2699
+ throw new Error("cancelled");
2700
+ }
2701
+ return { ok: true, output: await session.call_tool(wire_name, args) };
2702
+ });
2703
+ }
2704
+ function tool_for(registered, wire_name, spec, session) {
2705
+ return {
2706
+ name: registered,
2707
+ description: spec.description,
2708
+ parameters: spec.parameters,
2709
+ timeout_ms: 12e4,
2710
+ execute: (args, context) => run_call(session, wire_name, args, context)
2711
+ };
2712
+ }
2713
+ function register_listed(registry, server, listed, enabled, session) {
2714
+ const excluded = new Set(catalog_by_name(server)?.exclude_tools ?? []);
2715
+ for (const spec of listed) {
2716
+ if (excluded.has(spec.name) === true) {
2717
+ continue;
2718
+ }
2719
+ const registered = mcp_tool_name(server, spec.name);
2720
+ if (name_allowed(enabled, registered) === false || registry.has(registered) === true) {
2721
+ continue;
2722
+ }
2723
+ registry.register(tool_for(registered, spec.name, spec, session));
2724
+ }
2725
+ }
2726
+
2727
+ // src/mcp/mcp_handshake.ts
2728
+ var PROTOCOL_VERSION = "2024-11-05";
2729
+ function init_params() {
2730
+ return {
2731
+ protocolVersion: PROTOCOL_VERSION,
2732
+ capabilities: {},
2733
+ clientInfo: { name: "lich", version: "1" }
2734
+ };
2735
+ }
2736
+ function assert_handshake(result) {
2737
+ if (typeof result !== "object" || result === null) {
2738
+ throw new Error("mcp handshake rejected");
2739
+ }
2740
+ const body = result;
2741
+ if (typeof body.protocolVersion !== "string" || body.protocolVersion.length === 0) {
2742
+ throw new Error("mcp handshake rejected");
2743
+ }
2744
+ if (typeof body.serverInfo?.name !== "string" || body.serverInfo.name.length === 0) {
2745
+ throw new Error("mcp handshake rejected");
2746
+ }
2747
+ }
2748
+
2749
+ // src/mcp/mcp_content.ts
2750
+ function content_text(result) {
2751
+ if (typeof result !== "object" || result === null) {
2752
+ return "";
2753
+ }
2754
+ const body = result;
2755
+ const parts = [];
2756
+ if (Array.isArray(body.content) === true) {
2757
+ for (const item of body.content) {
2758
+ if (typeof item !== "object" || item === null) {
2759
+ continue;
2760
+ }
2761
+ const chunk = item;
2762
+ if (chunk.type === "text" && typeof chunk.text === "string") {
2763
+ parts.push(chunk.text);
2764
+ }
2765
+ if (chunk.type === "image") {
2766
+ parts.push("[image omitted]");
2767
+ }
2768
+ }
2769
+ }
2770
+ const text = parts.join("\n");
2771
+ if (body.isError === true) {
2772
+ throw new Error(text.length > 0 ? text : "mcp tool failed");
2773
+ }
2774
+ return text;
2775
+ }
2776
+
2777
+ // src/mcp/mcp_result.ts
2778
+ function tool_schema(raw) {
2779
+ if (typeof raw !== "object" || raw === null) {
2780
+ return { type: "object" };
2781
+ }
2782
+ const body = raw;
2783
+ const schema = { type: "object" };
2784
+ if (typeof body.properties === "object" && body.properties !== null) {
2785
+ schema.properties = body.properties;
2786
+ }
2787
+ if (Array.isArray(body.required) === true) {
2788
+ schema.required = body.required.filter((item) => typeof item === "string");
2789
+ }
2790
+ if (typeof body.additionalProperties === "boolean") {
2791
+ schema.additionalProperties = body.additionalProperties;
2792
+ }
2793
+ return schema;
2794
+ }
2795
+ function parse_tools(result) {
2796
+ if (typeof result !== "object" || result === null || Array.isArray(result.tools) === false) {
2797
+ throw new Error("mcp tools/list rejected");
2798
+ }
2799
+ const tools = [];
2800
+ for (const item of result.tools) {
2801
+ if (typeof item !== "object" || item === null) {
2802
+ continue;
2803
+ }
2804
+ const tool = item;
2805
+ if (typeof tool.name !== "string" || tool.name.length === 0) {
2806
+ continue;
2807
+ }
2808
+ tools.push({
2809
+ name: tool.name,
2810
+ description: typeof tool.description === "string" ? tool.description : tool.name,
2811
+ parameters: tool_schema(tool.inputSchema)
2812
+ });
2813
+ }
2814
+ return tools;
2815
+ }
2816
+
2817
+ // src/mcp/mcp_session.ts
2818
+ var McpSession = class {
2819
+ constructor(pipe) {
2820
+ this.pipe = pipe;
2821
+ }
2822
+ pipe;
2823
+ ready_done = false;
2824
+ closed = false;
2825
+ close() {
2826
+ if (this.closed === true) {
2827
+ return;
2828
+ }
2829
+ this.closed = true;
2830
+ this.pipe.close();
2831
+ }
2832
+ async list_tools() {
2833
+ await this.ensure_ready();
2834
+ return parse_tools(await this.pipe.request("tools/list", {}));
2835
+ }
2836
+ async call_tool(name, args) {
2837
+ await this.ensure_ready();
2838
+ return content_text(await this.pipe.request("tools/call", { name, arguments: args }));
2839
+ }
2840
+ async ensure_ready() {
2841
+ if (this.ready_done === true) {
2842
+ return;
2843
+ }
2844
+ try {
2845
+ assert_handshake(await this.pipe.request("initialize", init_params()));
2846
+ this.pipe.notify("notifications/initialized");
2847
+ this.ready_done = true;
2848
+ } catch (error) {
2849
+ this.close();
2850
+ throw error;
2851
+ }
2852
+ }
2853
+ };
2854
+
2855
+ // src/mcp/mcp_lines.ts
2856
+ function create_line_queue() {
2857
+ const pending = [];
2858
+ const waiters = [];
2859
+ let closed = false;
2860
+ return {
2861
+ push(line) {
2862
+ const waiter = waiters.shift();
2863
+ if (waiter !== void 0) {
2864
+ waiter(line);
2865
+ return;
2866
+ }
2867
+ pending.push(line);
2868
+ },
2869
+ close() {
2870
+ closed = true;
2871
+ for (const waiter of waiters.splice(0)) {
2872
+ waiter(void 0);
2873
+ }
2874
+ },
2875
+ read() {
2876
+ const next = pending.shift();
2877
+ if (next !== void 0) {
2878
+ return Promise.resolve(next);
2879
+ }
2880
+ if (closed === true) {
2881
+ return Promise.resolve(void 0);
2882
+ }
2883
+ return new Promise((resolve) => {
2884
+ waiters.push(resolve);
2885
+ });
2886
+ }
2887
+ };
2888
+ }
2889
+
2890
+ // src/mcp/mcp_child.ts
2891
+ function spawn_failure(code) {
2892
+ if (code === "ENOENT") {
2893
+ return "mcp command not found";
2894
+ }
2895
+ return "mcp spawn failed";
2896
+ }
2897
+ function failed_child(message) {
2898
+ return {
2899
+ write_line() {
2900
+ return void 0;
2901
+ },
2902
+ read_line() {
2903
+ return Promise.resolve(void 0);
2904
+ },
2905
+ stop() {
2906
+ return void 0;
2907
+ },
2908
+ failed() {
2909
+ return message;
2910
+ }
2911
+ };
2912
+ }
2913
+
2914
+ // src/mcp/mcp_stdio_bun.ts
2915
+ async function pump_stdout(stream, queue) {
2916
+ const reader = stream.getReader();
2917
+ const decoder = new TextDecoder();
2918
+ let buffer = "";
2919
+ for (; ; ) {
2920
+ const next = await reader.read();
2921
+ if (next.done === true) {
2922
+ if (buffer.length > 0) {
2923
+ queue.push(buffer);
2924
+ }
2925
+ queue.close();
2926
+ return;
2927
+ }
2928
+ buffer += decoder.decode(next.value, { stream: true });
2929
+ const parts = buffer.split("\n");
2930
+ buffer = parts.pop() ?? "";
2931
+ for (const part of parts) {
2932
+ queue.push(part);
2933
+ }
2934
+ }
2935
+ }
2936
+ async function drain_stderr(stream) {
2937
+ const reader = stream.getReader();
2938
+ while ((await reader.read()).done === false) {
2939
+ continue;
2940
+ }
2941
+ }
2942
+ function bun_line_child(command, args, env) {
2943
+ const queue = create_line_queue();
2944
+ try {
2945
+ const options = { stdin: "pipe", stdout: "pipe", stderr: "pipe" };
2946
+ const child = env === void 0 ? Bun.spawn([command, ...args], options) : Bun.spawn([command, ...args], { ...options, env });
2947
+ void pump_stdout(child.stdout, queue);
2948
+ void drain_stderr(child.stderr);
2949
+ return {
2950
+ write_line(line) {
2951
+ child.stdin.write(`${line}
2952
+ `);
2953
+ void child.stdin.flush();
2954
+ },
2955
+ read_line() {
2956
+ return queue.read();
2957
+ },
2958
+ stop() {
2959
+ child.kill();
2960
+ },
2961
+ failed() {
2962
+ return void 0;
2963
+ }
2964
+ };
2965
+ } catch (error) {
2966
+ const coded = error;
2967
+ return failed_child(spawn_failure(coded.code));
2968
+ }
2969
+ }
2970
+
2971
+ // src/mcp/mcp_stdio_node.ts
2004
2972
  import { spawn as spawn3 } from "child_process";
2973
+ import { createInterface } from "readline";
2974
+ function child_env(extra) {
2975
+ if (extra === void 0) {
2976
+ return void 0;
2977
+ }
2978
+ const merged = {};
2979
+ for (const [key, value] of Object.entries(process.env)) {
2980
+ if (typeof value === "string") {
2981
+ merged[key] = value;
2982
+ }
2983
+ }
2984
+ for (const [key, value] of Object.entries(extra)) {
2985
+ merged[key] = value;
2986
+ }
2987
+ return merged;
2988
+ }
2989
+ function node_line_child(command, args, env) {
2990
+ const queue = create_line_queue();
2991
+ let failure;
2992
+ const child = spawn3(command, [...args], { stdio: ["pipe", "pipe", "pipe"], env: child_env(env) });
2993
+ child.unref();
2994
+ const reader = createInterface({ input: child.stdout });
2995
+ reader.on("line", (line) => {
2996
+ queue.push(line);
2997
+ });
2998
+ reader.on("close", () => {
2999
+ queue.close();
3000
+ });
3001
+ child.stderr?.resume();
3002
+ child.on("error", (error) => {
3003
+ failure = spawn_failure(error.code);
3004
+ queue.close();
3005
+ });
3006
+ return {
3007
+ write_line(line) {
3008
+ child.stdin?.write(`${line}
3009
+ `);
3010
+ },
3011
+ read_line() {
3012
+ return queue.read();
3013
+ },
3014
+ stop() {
3015
+ child.kill();
3016
+ },
3017
+ failed() {
3018
+ return failure;
3019
+ }
3020
+ };
3021
+ }
3022
+
3023
+ // src/mcp/mcp_stdio.ts
3024
+ function running_under_bun() {
3025
+ return process.versions.bun !== void 0;
3026
+ }
3027
+ function default_line_spawner(command, args, env) {
3028
+ if (running_under_bun() === true) {
3029
+ return bun_line_child(command, args, env);
3030
+ }
3031
+ return node_line_child(command, args, env);
3032
+ }
3033
+
3034
+ // src/mcp/mcp_attach.ts
3035
+ async function open_and_register(registry, name, enabled, session) {
3036
+ try {
3037
+ register_listed(registry, name, await session.list_tools(), enabled, session);
3038
+ return session;
3039
+ } catch (error) {
3040
+ session.close();
3041
+ const message = error instanceof Error ? error.message : "mcp skipped";
3042
+ logger.warn(`mcp ${name} skipped: ${message}`);
3043
+ return void 0;
3044
+ }
3045
+ }
3046
+ async function attach_stdio(registry, name, entry, config, runtime) {
3047
+ const planned = plan_stdio(name, entry.command, entry.args, runtime?.env_path ?? process.env.PATH);
3048
+ if (typeof planned === "string") {
3049
+ logger.warn(`mcp ${name} skipped: ${planned}`);
3050
+ return void 0;
3051
+ }
3052
+ const spawn5 = runtime?.spawn ?? default_line_spawner;
3053
+ const session = new McpSession(stdio_pipe(spawn5(planned.command, planned.args, entry.env)));
3054
+ return open_and_register(registry, name, config.tools_enabled, session);
3055
+ }
3056
+ async function attach_http(registry, name, url, config, runtime) {
3057
+ const session = new McpSession(http_pipe(url, runtime?.fetch_fn ?? fetch));
3058
+ return open_and_register(registry, name, config.tools_enabled, session);
3059
+ }
3060
+
3061
+ // src/mcp/mcp_tools.ts
3062
+ function allowlist_wants_mcp(enabled) {
3063
+ if (enabled === "all") {
3064
+ return true;
3065
+ }
3066
+ return enabled.some((name) => name.startsWith("mcp_") === true);
3067
+ }
3068
+ async function attach_enabled_mcp_tools(registry, config, runtime) {
3069
+ const sessions = [];
3070
+ const servers = config.mcp_servers;
3071
+ if (servers === void 0 || allowlist_wants_mcp(config.tools_enabled) === false) {
3072
+ return sessions;
3073
+ }
3074
+ for (const [name, entry] of Object.entries(servers)) {
3075
+ if (entry.enabled !== true) {
3076
+ continue;
3077
+ }
3078
+ try {
3079
+ const session = "url" in entry ? await attach_http(registry, name, entry.url, config, runtime) : await attach_stdio(registry, name, entry, config, runtime);
3080
+ if (session !== void 0) {
3081
+ sessions.push(session);
3082
+ }
3083
+ } catch (error) {
3084
+ const message = error instanceof Error ? error.message : "mcp skipped";
3085
+ logger.warn(`mcp ${name} skipped: ${message}`);
3086
+ }
3087
+ }
3088
+ return sessions;
3089
+ }
3090
+
3091
+ // src/plugins/builtin/gatekeeper.plugin.ts
3092
+ import { spawn as spawn4 } from "child_process";
2005
3093
  import { statSync as statSync2 } from "fs";
2006
- import path9 from "path";
3094
+ import path13 from "path";
2007
3095
  var HOOKS_OFF = ["-c", "core.hooksPath=/dev/null"];
2008
3096
  var PATHSPEC_MAGIC = /[:*?[]/;
2009
3097
  var SECRET_BASENAMES = [".env", ".env.local", "id_rsa"];
2010
3098
  function is_secret_path(file_path) {
2011
- const base = path9.basename(file_path);
3099
+ const base = path13.basename(file_path);
2012
3100
  if (SECRET_BASENAMES.includes(base) === true) {
2013
3101
  return true;
2014
3102
  }
@@ -2035,7 +3123,7 @@ function matches_git_denylist(command) {
2035
3123
  }
2036
3124
  function run_git(args, work_dir, signal) {
2037
3125
  return new Promise((resolve) => {
2038
- const child = spawn3("git", args, { cwd: work_dir, env: process.env });
3126
+ const child = spawn4("git", args, { cwd: work_dir, env: process.env });
2039
3127
  let settled = false;
2040
3128
  let out = "";
2041
3129
  const on_abort = () => {
@@ -2086,7 +3174,7 @@ function invalid_commit_path(raw, work_dir) {
2086
3174
  return `invalid_path: ${raw}`;
2087
3175
  }
2088
3176
  const resolved = resolve_safe_path(work_dir, raw);
2089
- if (resolved === path9.resolve(work_dir)) {
3177
+ if (resolved === path13.resolve(work_dir)) {
2090
3178
  return `invalid_path: ${raw} resolves to work_dir`;
2091
3179
  }
2092
3180
  if (is_secret_path(raw) === true) {
@@ -2204,8 +3292,12 @@ function gatekeeper_hooks(allow_self_commit) {
2204
3292
  if (info.tool_name === "write_file" || info.tool_name === "edit_file") {
2205
3293
  ctx.state?.set("dirty", true);
2206
3294
  } else if (info.tool_name === "run_tests") {
2207
- ctx.state?.set("tests_ok", true);
2208
- ctx.state?.set("dirty", false);
3295
+ const filter = info.args["filter"];
3296
+ const filtered = typeof filter === "string" && filter.length > 0;
3297
+ if (filtered === false) {
3298
+ ctx.state?.set("tests_ok", true);
3299
+ ctx.state?.set("dirty", false);
3300
+ }
2209
3301
  } else if (info.tool_name === "git_commit") {
2210
3302
  ctx.state?.set("commits", state_count(ctx, "commits") + 1);
2211
3303
  }
@@ -2347,6 +3439,7 @@ var MAX_ERROR_BODY_CHARS = 500;
2347
3439
  var OVERFLOW_BODY_PATTERN = /context|token|maximum/i;
2348
3440
  var OVERLOADED_STATUS = 529;
2349
3441
  var UNPARSEABLE_ARGS_NOTE = "[unparseable tool arguments]";
3442
+ var EMPTY_TEXT_PLACEHOLDER = "(empty)";
2350
3443
  var DEFAULT_MAX_TOKENS = 4096;
2351
3444
  var AnthropicProvider = class {
2352
3445
  name;
@@ -2460,7 +3553,7 @@ function to_anthropic_turns(messages) {
2460
3553
  }
2461
3554
  flush_tool_results(turns, pending_tool_results);
2462
3555
  if (message.role === "user") {
2463
- turns.push({ role: "user", content: [{ type: "text", text: message.content }] });
3556
+ turns.push({ role: "user", content: [text_block(message.content)] });
2464
3557
  } else {
2465
3558
  turns.push({ role: "assistant", content: assistant_to_blocks(message) });
2466
3559
  }
@@ -2474,11 +3567,14 @@ function flush_tool_results(turns, pending_tool_results) {
2474
3567
  }
2475
3568
  turns.push({ role: "user", content: pending_tool_results.splice(0, pending_tool_results.length) });
2476
3569
  }
3570
+ function text_block(text) {
3571
+ return { type: "text", text: text.length > 0 ? text : EMPTY_TEXT_PLACEHOLDER };
3572
+ }
2477
3573
  function tool_message_to_block(message) {
2478
3574
  const block = {
2479
3575
  type: "tool_result",
2480
3576
  tool_use_id: message.tool_call_id,
2481
- content: [{ type: "text", text: message.content }]
3577
+ content: [text_block(message.content)]
2482
3578
  };
2483
3579
  if (message.is_error === true) {
2484
3580
  return { ...block, is_error: true };
@@ -2494,7 +3590,7 @@ function assistant_to_blocks(message) {
2494
3590
  blocks.push({ type: "tool_use", id: tool_call.id, name: tool_call.name, input: tool_call.args });
2495
3591
  }
2496
3592
  if (blocks.length === 0) {
2497
- blocks.push({ type: "text", text: "" });
3593
+ blocks.push(text_block(""));
2498
3594
  }
2499
3595
  return blocks;
2500
3596
  }
@@ -3338,6 +4434,9 @@ async function chat_with_failover(router, messages, tools, options) {
3338
4434
  if (result.ok === true) {
3339
4435
  return result.value;
3340
4436
  }
4437
+ if (is_abort_failure(result.error, options?.signal) === true) {
4438
+ throw result.error;
4439
+ }
3341
4440
  last_error = result.error;
3342
4441
  log_fail_over(result.error);
3343
4442
  }
@@ -3371,9 +4470,18 @@ async function attempt_provider(provider, messages, tools, options) {
3371
4470
  })
3372
4471
  };
3373
4472
  } catch (error) {
4473
+ if (is_abort_failure(error, options?.signal) === true) {
4474
+ throw error;
4475
+ }
3374
4476
  return { ok: false, error: to_provider_error(error, provider.name) };
3375
4477
  }
3376
4478
  }
4479
+ function is_abort_failure(error, signal) {
4480
+ if (signal?.aborted === true) {
4481
+ return true;
4482
+ }
4483
+ return error instanceof Error && error.name === "AbortError";
4484
+ }
3377
4485
  function to_provider_error(error, fallback_name) {
3378
4486
  if (error instanceof ProviderError) {
3379
4487
  return error;
@@ -3388,7 +4496,7 @@ function to_provider_error(error, fallback_name) {
3388
4496
 
3389
4497
  // src/session/store.ts
3390
4498
  import { appendFile, mkdir as mkdir2, readFile as readFile4 } from "fs/promises";
3391
- import path10 from "path";
4499
+ import path14 from "path";
3392
4500
  var counter_state = { value: 0 };
3393
4501
  function slugify_label(label) {
3394
4502
  const slug = label.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40);
@@ -3399,7 +4507,7 @@ async function open_session(dir, label) {
3399
4507
  counter_state.value += 1;
3400
4508
  const label_part = label === void 0 ? "" : slugify_label(label);
3401
4509
  const id = `${Date.now().toString(36)}-${counter_state.value}${label_part}`;
3402
- const file_path = path10.join(dir, `${id}.jsonl`);
4510
+ const file_path = path14.join(dir, `${id}.jsonl`);
3403
4511
  return {
3404
4512
  id,
3405
4513
  path: file_path,
@@ -3455,12 +4563,18 @@ function log_compression_failure(error) {
3455
4563
  }
3456
4564
  logger.warn("context compression failed", error);
3457
4565
  }
4566
+ function split_keep_recent(non_system, keep_recent) {
4567
+ let cut = Math.max(0, non_system.length - keep_recent);
4568
+ while (cut > 0 && non_system[cut]?.role === "tool") {
4569
+ cut -= 1;
4570
+ }
4571
+ return { recent: non_system.slice(cut), older: non_system.slice(0, cut) };
4572
+ }
3458
4573
  async function compress_messages(deps, messages, params) {
3459
4574
  const system_messages = messages.filter((message) => message.role === "system");
3460
4575
  const non_system = messages.filter((message) => message.role !== "system");
3461
4576
  const keep_recent = Math.max(0, params.keep_recent);
3462
- const recent = non_system.slice(-keep_recent);
3463
- const older = non_system.slice(0, Math.max(0, non_system.length - recent.length));
4577
+ const { recent, older } = split_keep_recent(non_system, keep_recent);
3464
4578
  if (older.length === 0) {
3465
4579
  return { messages: [...messages], summary_chars: 0 };
3466
4580
  }
@@ -3513,8 +4627,12 @@ function format_tool_result_content(result) {
3513
4627
  }
3514
4628
  return result.output;
3515
4629
  }
3516
- async function run_tool_calls(deps, history, turn, calls, emitter) {
4630
+ async function run_tool_calls(deps, history, turn, calls, emitter, signal) {
3517
4631
  for (const call of calls) {
4632
+ if (signal_aborted(signal) === true) {
4633
+ history.push(cancelled_tool_message(call));
4634
+ continue;
4635
+ }
3518
4636
  emitter?.emit({ type: "tool_call_start", turn, call });
3519
4637
  const result = await deps.tools.execute(call.name, call.args, deps.tool_context);
3520
4638
  const tool_message = {
@@ -3529,6 +4647,16 @@ async function run_tool_calls(deps, history, turn, calls, emitter) {
3529
4647
  history.push(tool_message);
3530
4648
  emitter?.emit({ type: "tool_call_end", turn, call, result });
3531
4649
  }
4650
+ return signal_aborted(signal) === true ? "aborted" : "continued";
4651
+ }
4652
+ function cancelled_tool_message(call) {
4653
+ return {
4654
+ role: "tool",
4655
+ tool_call_id: call.id,
4656
+ name: call.name,
4657
+ content: format_tool_result_content({ ok: false, output: "", error: "cancelled" }),
4658
+ is_error: true
4659
+ };
3532
4660
  }
3533
4661
  async function call_chat(deps, history, params, emitter) {
3534
4662
  try {
@@ -3538,6 +4666,9 @@ async function call_chat(deps, history, params, emitter) {
3538
4666
  signal: params.signal
3539
4667
  });
3540
4668
  } catch (error) {
4669
+ if (signal_aborted(params.signal) === true) {
4670
+ throw error;
4671
+ }
3541
4672
  if (error instanceof ProviderError) {
3542
4673
  logger.error(`provider error kind=${error.kind} provider=${error.provider_name}`, error);
3543
4674
  } else {
@@ -3575,24 +4706,38 @@ async function compress_if_needed(deps, history, params, emitter) {
3575
4706
  function find_last_assistant(messages) {
3576
4707
  return [...messages].reverse().find((message) => message.role === "assistant");
3577
4708
  }
4709
+ function signal_aborted(signal) {
4710
+ return signal?.aborted === true;
4711
+ }
4712
+ function aborted_outcome(history, turns_used, emitter) {
4713
+ emitter?.emit({ type: "error", error: new DOMException("agent loop aborted", "AbortError") });
4714
+ return {
4715
+ messages: history,
4716
+ final: find_last_assistant(history),
4717
+ result: void 0,
4718
+ turns_used,
4719
+ stopped_reason: "aborted"
4720
+ };
4721
+ }
3578
4722
  async function run_conversation(deps, messages, params) {
3579
4723
  const history = seed_system_prompt(messages, params.system_prompt);
3580
4724
  const emitter = deps.emitter;
3581
4725
  for (const turn of turn_range(params.max_turns)) {
3582
- if (params.signal?.aborted === true) {
3583
- emitter?.emit({ type: "error", error: new DOMException("agent loop aborted", "AbortError") });
3584
- return {
3585
- messages: history,
3586
- final: find_last_assistant(history),
3587
- result: void 0,
3588
- turns_used: turn - 1,
3589
- stopped_reason: "aborted"
3590
- };
4726
+ if (signal_aborted(params.signal) === true) {
4727
+ return aborted_outcome(history, turn - 1, emitter);
3591
4728
  }
3592
4729
  emitter?.emit({ type: "turn_start", turn });
3593
4730
  await compress_if_needed(deps, history, params, emitter);
3594
4731
  emitter?.emit({ type: "llm_start", turn });
3595
- const result = await call_chat(deps, history, params, emitter);
4732
+ let result;
4733
+ try {
4734
+ result = await call_chat(deps, history, params, emitter);
4735
+ } catch (error) {
4736
+ if (signal_aborted(params.signal) === true) {
4737
+ return aborted_outcome(history, turn - 1, emitter);
4738
+ }
4739
+ throw error;
4740
+ }
3596
4741
  emitter?.emit({ type: "llm_end", turn, result });
3597
4742
  history.push(result.message);
3598
4743
  const calls = result.message.tool_calls ?? [];
@@ -3601,7 +4746,10 @@ async function run_conversation(deps, messages, params) {
3601
4746
  emitter?.emit({ type: "turn_end", turn });
3602
4747
  return { messages: history, final: result.message, result, turns_used: turn, stopped_reason: "final" };
3603
4748
  }
3604
- await run_tool_calls(deps, history, turn, calls, emitter);
4749
+ const tool_status = await run_tool_calls(deps, history, turn, calls, emitter, params.signal);
4750
+ if (tool_status === "aborted") {
4751
+ return aborted_outcome(history, turn, emitter);
4752
+ }
3605
4753
  }
3606
4754
  emitter?.emit({ type: "budget_exhausted", turns_used: params.max_turns });
3607
4755
  emitter?.emit({ type: "turn_end", turn: params.max_turns });
@@ -3680,8 +4828,12 @@ var Agent = class {
3680
4828
  registry;
3681
4829
  executor;
3682
4830
  hook_runner;
3683
- constructor(config, plugins = []) {
4831
+ mcp_runtime;
4832
+ mcp_sessions = [];
4833
+ mcp_attach;
4834
+ constructor(config, plugins = [], runtime) {
3684
4835
  this.config = config;
4836
+ this.mcp_runtime = runtime?.mcp;
3685
4837
  this.events = new AgentEmitter();
3686
4838
  this.router = new ProviderRouter(config.providers);
3687
4839
  const base_registry = new ToolRegistry();
@@ -3705,15 +4857,22 @@ var Agent = class {
3705
4857
  }
3706
4858
  }
3707
4859
  async run(options) {
4860
+ await this.attach_mcp_once();
3708
4861
  const usage_total = { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 };
3709
- const stop_collecting = this.events.on(collect_usage(usage_total));
4862
+ const run_events = new AgentEmitter();
4863
+ const stop_forwarding = run_events.on((event) => this.events.emit(event));
4864
+ const stop_collecting = run_events.on(collect_usage(usage_total));
3710
4865
  await this.call_plugin_run_start(options.input);
3711
4866
  let outcome;
3712
4867
  try {
3713
4868
  const seed_messages = [...options.history ?? []];
3714
4869
  seed_messages.push({ role: "user", content: options.input });
3715
- const tool_context = { work_dir: this.config.work_dir, env: tool_env(this.config) };
3716
- outcome = await run_conversation(this.loop_deps(tool_context), seed_messages, {
4870
+ const tool_context = {
4871
+ work_dir: this.config.work_dir,
4872
+ env: tool_env(this.config),
4873
+ signal: options.signal
4874
+ };
4875
+ outcome = await run_conversation(this.loop_deps(tool_context, run_events), seed_messages, {
3717
4876
  system_prompt: this.config.system_prompt ?? DEFAULT_AGENT_SYSTEM_PROMPT,
3718
4877
  max_turns: this.config.max_turns,
3719
4878
  temperature: this.config.temperature,
@@ -3724,21 +4883,36 @@ var Agent = class {
3724
4883
  });
3725
4884
  } finally {
3726
4885
  stop_collecting();
4886
+ stop_forwarding();
3727
4887
  if (outcome !== void 0) {
3728
4888
  await this.call_plugin_run_end(outcome);
3729
4889
  }
3730
4890
  }
3731
4891
  const session_path = await this.persist_session(outcome, options, usage_total);
3732
- const full_messages = [...options.history ?? [], ...outcome.messages];
3733
- return { outcome, messages: full_messages, usage_total, session_path };
4892
+ return { outcome, messages: outcome.messages, usage_total, session_path };
4893
+ }
4894
+ /** Close MCP sessions so stdio children do not keep the event loop alive. */
4895
+ close() {
4896
+ for (const session of this.mcp_sessions) {
4897
+ session.close();
4898
+ }
4899
+ this.mcp_sessions = [];
4900
+ }
4901
+ /** tools/list once, before the model sees definitions. Empty allowlists never connect. */
4902
+ async attach_mcp_once() {
4903
+ this.mcp_attach ??= this.do_attach_mcp();
4904
+ await this.mcp_attach;
4905
+ }
4906
+ async do_attach_mcp() {
4907
+ this.mcp_sessions = await attach_enabled_mcp_tools(this.registry, this.config, this.mcp_runtime);
3734
4908
  }
3735
4909
  /** Per-run deps: the built-once ToolContext threads through every tool execution. */
3736
- loop_deps(tool_context) {
4910
+ loop_deps(tool_context, emitter = this.events) {
3737
4911
  return {
3738
4912
  chat: (messages, tools, chat_options) => this.router.chat_with_failover(messages, tools, chat_options),
3739
4913
  tools: this.executor,
3740
4914
  definitions: () => this.registry.definitions(),
3741
- emitter: this.events,
4915
+ emitter,
3742
4916
  tool_context
3743
4917
  };
3744
4918
  }
@@ -3793,14 +4967,24 @@ async function create_agent_with_plugins(raw_config) {
3793
4967
  }
3794
4968
  async function run_agent(raw_config, input, options) {
3795
4969
  const agent = await create_agent_with_plugins(raw_config);
3796
- return agent.run({ input, signal: options?.signal, label: options?.label });
4970
+ try {
4971
+ return await agent.run({ input, signal: options?.signal, label: options?.label });
4972
+ } finally {
4973
+ agent.close();
4974
+ }
3797
4975
  }
3798
4976
 
3799
4977
  export {
3800
4978
  safe_json_parse,
3801
4979
  truncate_text,
4980
+ DEFAULT_GATEWAY_TOKEN_ENVS,
4981
+ is_env_var_name,
4982
+ platform_token_env,
4983
+ read_platform_token,
3802
4984
  logger,
3803
4985
  register_builtin_tools,
4986
+ catalog_by_name,
4987
+ refuse_mcp_entry,
3804
4988
  ToolRegistry,
3805
4989
  ToolExecutor,
3806
4990
  HookedToolRunner,
@@ -3808,10 +4992,8 @@ export {
3808
4992
  plugin_errors_summary,
3809
4993
  sleep,
3810
4994
  ProviderError,
3811
- DEFAULT_GATEWAY_TOKEN_ENVS,
3812
- is_env_var_name,
3813
- platform_token_env,
3814
- read_platform_token,
4995
+ gateway_tools_enabled,
4996
+ check_gateway_sender,
3815
4997
  parse_agent_config,
3816
4998
  AgentEmitter,
3817
4999
  Agent,
@@ -3819,4 +5001,4 @@ export {
3819
5001
  create_agent_with_plugins,
3820
5002
  run_agent
3821
5003
  };
3822
- //# sourceMappingURL=chunk-7HLVKVIG.js.map
5004
+ //# sourceMappingURL=chunk-WNFBIX4E.js.map