@moikapy/lich 0.7.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.
@@ -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,34 +2241,39 @@ 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] ?? "";
1902
- }
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;
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;
1907
2266
  }
1908
- const value = process.env[key];
1909
- if (typeof value !== "string" || value.length === 0) {
1910
- return void 0;
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;
2270
+ }
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;
1911
2274
  }
1912
- return value;
2275
+ logger.warn(`gateway denied ${platform} chat=${chat_id} user=${user_id}`);
2276
+ return false;
1913
2277
  }
1914
2278
 
1915
2279
  // src/mcp/mcp_pin.ts
@@ -2052,10 +2416,17 @@ function refuse_mcp_entry(name, entry) {
2052
2416
  }
2053
2417
 
2054
2418
  // src/agent/config.ts
2419
+ var gateway_allowlist = z.record(z.string(), z.array(z.string())).default({});
2055
2420
  var gateway_schema = z.object({
2056
2421
  platforms: z.array(z.enum(["webhook", "telegram", "discord", "twitch"])).default([]),
2057
2422
  /** Env-var names that hold tokens. Never store the secrets themselves. */
2058
- 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])
2059
2430
  }).optional();
2060
2431
  var stdio_mcp_schema = z.object({
2061
2432
  enabled: z.boolean().default(false),
@@ -2134,6 +2505,11 @@ function freeze_config(config) {
2134
2505
  if (config.gateway !== void 0) {
2135
2506
  Object.freeze(config.gateway.platforms);
2136
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
+ }
2137
2513
  Object.freeze(config.gateway);
2138
2514
  }
2139
2515
  if (config.mcp_servers !== void 0) {
@@ -2614,6 +2990,7 @@ function node_line_child(command, args, env) {
2614
2990
  const queue = create_line_queue();
2615
2991
  let failure;
2616
2992
  const child = spawn3(command, [...args], { stdio: ["pipe", "pipe", "pipe"], env: child_env(env) });
2993
+ child.unref();
2617
2994
  const reader = createInterface({ input: child.stdout });
2618
2995
  reader.on("line", (line) => {
2619
2996
  queue.push(line);
@@ -2658,25 +3035,27 @@ function default_line_spawner(command, args, env) {
2658
3035
  async function open_and_register(registry, name, enabled, session) {
2659
3036
  try {
2660
3037
  register_listed(registry, name, await session.list_tools(), enabled, session);
3038
+ return session;
2661
3039
  } catch (error) {
2662
3040
  session.close();
2663
3041
  const message = error instanceof Error ? error.message : "mcp skipped";
2664
3042
  logger.warn(`mcp ${name} skipped: ${message}`);
3043
+ return void 0;
2665
3044
  }
2666
3045
  }
2667
3046
  async function attach_stdio(registry, name, entry, config, runtime) {
2668
3047
  const planned = plan_stdio(name, entry.command, entry.args, runtime?.env_path ?? process.env.PATH);
2669
3048
  if (typeof planned === "string") {
2670
3049
  logger.warn(`mcp ${name} skipped: ${planned}`);
2671
- return;
3050
+ return void 0;
2672
3051
  }
2673
3052
  const spawn5 = runtime?.spawn ?? default_line_spawner;
2674
3053
  const session = new McpSession(stdio_pipe(spawn5(planned.command, planned.args, entry.env)));
2675
- await open_and_register(registry, name, config.tools_enabled, session);
3054
+ return open_and_register(registry, name, config.tools_enabled, session);
2676
3055
  }
2677
3056
  async function attach_http(registry, name, url, config, runtime) {
2678
3057
  const session = new McpSession(http_pipe(url, runtime?.fetch_fn ?? fetch));
2679
- await open_and_register(registry, name, config.tools_enabled, session);
3058
+ return open_and_register(registry, name, config.tools_enabled, session);
2680
3059
  }
2681
3060
 
2682
3061
  // src/mcp/mcp_tools.ts
@@ -2687,25 +3066,26 @@ function allowlist_wants_mcp(enabled) {
2687
3066
  return enabled.some((name) => name.startsWith("mcp_") === true);
2688
3067
  }
2689
3068
  async function attach_enabled_mcp_tools(registry, config, runtime) {
3069
+ const sessions = [];
2690
3070
  const servers = config.mcp_servers;
2691
3071
  if (servers === void 0 || allowlist_wants_mcp(config.tools_enabled) === false) {
2692
- return;
3072
+ return sessions;
2693
3073
  }
2694
3074
  for (const [name, entry] of Object.entries(servers)) {
2695
3075
  if (entry.enabled !== true) {
2696
3076
  continue;
2697
3077
  }
2698
3078
  try {
2699
- if ("url" in entry) {
2700
- await attach_http(registry, name, entry.url, config, runtime);
2701
- continue;
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);
2702
3082
  }
2703
- await attach_stdio(registry, name, entry, config, runtime);
2704
3083
  } catch (error) {
2705
3084
  const message = error instanceof Error ? error.message : "mcp skipped";
2706
3085
  logger.warn(`mcp ${name} skipped: ${message}`);
2707
3086
  }
2708
3087
  }
3088
+ return sessions;
2709
3089
  }
2710
3090
 
2711
3091
  // src/plugins/builtin/gatekeeper.plugin.ts
@@ -2912,8 +3292,12 @@ function gatekeeper_hooks(allow_self_commit) {
2912
3292
  if (info.tool_name === "write_file" || info.tool_name === "edit_file") {
2913
3293
  ctx.state?.set("dirty", true);
2914
3294
  } else if (info.tool_name === "run_tests") {
2915
- ctx.state?.set("tests_ok", true);
2916
- 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
+ }
2917
3301
  } else if (info.tool_name === "git_commit") {
2918
3302
  ctx.state?.set("commits", state_count(ctx, "commits") + 1);
2919
3303
  }
@@ -3055,6 +3439,7 @@ var MAX_ERROR_BODY_CHARS = 500;
3055
3439
  var OVERFLOW_BODY_PATTERN = /context|token|maximum/i;
3056
3440
  var OVERLOADED_STATUS = 529;
3057
3441
  var UNPARSEABLE_ARGS_NOTE = "[unparseable tool arguments]";
3442
+ var EMPTY_TEXT_PLACEHOLDER = "(empty)";
3058
3443
  var DEFAULT_MAX_TOKENS = 4096;
3059
3444
  var AnthropicProvider = class {
3060
3445
  name;
@@ -3168,7 +3553,7 @@ function to_anthropic_turns(messages) {
3168
3553
  }
3169
3554
  flush_tool_results(turns, pending_tool_results);
3170
3555
  if (message.role === "user") {
3171
- turns.push({ role: "user", content: [{ type: "text", text: message.content }] });
3556
+ turns.push({ role: "user", content: [text_block(message.content)] });
3172
3557
  } else {
3173
3558
  turns.push({ role: "assistant", content: assistant_to_blocks(message) });
3174
3559
  }
@@ -3182,11 +3567,14 @@ function flush_tool_results(turns, pending_tool_results) {
3182
3567
  }
3183
3568
  turns.push({ role: "user", content: pending_tool_results.splice(0, pending_tool_results.length) });
3184
3569
  }
3570
+ function text_block(text) {
3571
+ return { type: "text", text: text.length > 0 ? text : EMPTY_TEXT_PLACEHOLDER };
3572
+ }
3185
3573
  function tool_message_to_block(message) {
3186
3574
  const block = {
3187
3575
  type: "tool_result",
3188
3576
  tool_use_id: message.tool_call_id,
3189
- content: [{ type: "text", text: message.content }]
3577
+ content: [text_block(message.content)]
3190
3578
  };
3191
3579
  if (message.is_error === true) {
3192
3580
  return { ...block, is_error: true };
@@ -3202,7 +3590,7 @@ function assistant_to_blocks(message) {
3202
3590
  blocks.push({ type: "tool_use", id: tool_call.id, name: tool_call.name, input: tool_call.args });
3203
3591
  }
3204
3592
  if (blocks.length === 0) {
3205
- blocks.push({ type: "text", text: "" });
3593
+ blocks.push(text_block(""));
3206
3594
  }
3207
3595
  return blocks;
3208
3596
  }
@@ -4046,6 +4434,9 @@ async function chat_with_failover(router, messages, tools, options) {
4046
4434
  if (result.ok === true) {
4047
4435
  return result.value;
4048
4436
  }
4437
+ if (is_abort_failure(result.error, options?.signal) === true) {
4438
+ throw result.error;
4439
+ }
4049
4440
  last_error = result.error;
4050
4441
  log_fail_over(result.error);
4051
4442
  }
@@ -4079,9 +4470,18 @@ async function attempt_provider(provider, messages, tools, options) {
4079
4470
  })
4080
4471
  };
4081
4472
  } catch (error) {
4473
+ if (is_abort_failure(error, options?.signal) === true) {
4474
+ throw error;
4475
+ }
4082
4476
  return { ok: false, error: to_provider_error(error, provider.name) };
4083
4477
  }
4084
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
+ }
4085
4485
  function to_provider_error(error, fallback_name) {
4086
4486
  if (error instanceof ProviderError) {
4087
4487
  return error;
@@ -4163,12 +4563,18 @@ function log_compression_failure(error) {
4163
4563
  }
4164
4564
  logger.warn("context compression failed", error);
4165
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
+ }
4166
4573
  async function compress_messages(deps, messages, params) {
4167
4574
  const system_messages = messages.filter((message) => message.role === "system");
4168
4575
  const non_system = messages.filter((message) => message.role !== "system");
4169
4576
  const keep_recent = Math.max(0, params.keep_recent);
4170
- const recent = non_system.slice(-keep_recent);
4171
- 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);
4172
4578
  if (older.length === 0) {
4173
4579
  return { messages: [...messages], summary_chars: 0 };
4174
4580
  }
@@ -4221,8 +4627,12 @@ function format_tool_result_content(result) {
4221
4627
  }
4222
4628
  return result.output;
4223
4629
  }
4224
- async function run_tool_calls(deps, history, turn, calls, emitter) {
4630
+ async function run_tool_calls(deps, history, turn, calls, emitter, signal) {
4225
4631
  for (const call of calls) {
4632
+ if (signal_aborted(signal) === true) {
4633
+ history.push(cancelled_tool_message(call));
4634
+ continue;
4635
+ }
4226
4636
  emitter?.emit({ type: "tool_call_start", turn, call });
4227
4637
  const result = await deps.tools.execute(call.name, call.args, deps.tool_context);
4228
4638
  const tool_message = {
@@ -4237,6 +4647,16 @@ async function run_tool_calls(deps, history, turn, calls, emitter) {
4237
4647
  history.push(tool_message);
4238
4648
  emitter?.emit({ type: "tool_call_end", turn, call, result });
4239
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
+ };
4240
4660
  }
4241
4661
  async function call_chat(deps, history, params, emitter) {
4242
4662
  try {
@@ -4246,6 +4666,9 @@ async function call_chat(deps, history, params, emitter) {
4246
4666
  signal: params.signal
4247
4667
  });
4248
4668
  } catch (error) {
4669
+ if (signal_aborted(params.signal) === true) {
4670
+ throw error;
4671
+ }
4249
4672
  if (error instanceof ProviderError) {
4250
4673
  logger.error(`provider error kind=${error.kind} provider=${error.provider_name}`, error);
4251
4674
  } else {
@@ -4283,24 +4706,38 @@ async function compress_if_needed(deps, history, params, emitter) {
4283
4706
  function find_last_assistant(messages) {
4284
4707
  return [...messages].reverse().find((message) => message.role === "assistant");
4285
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
+ }
4286
4722
  async function run_conversation(deps, messages, params) {
4287
4723
  const history = seed_system_prompt(messages, params.system_prompt);
4288
4724
  const emitter = deps.emitter;
4289
4725
  for (const turn of turn_range(params.max_turns)) {
4290
- if (params.signal?.aborted === true) {
4291
- emitter?.emit({ type: "error", error: new DOMException("agent loop aborted", "AbortError") });
4292
- return {
4293
- messages: history,
4294
- final: find_last_assistant(history),
4295
- result: void 0,
4296
- turns_used: turn - 1,
4297
- stopped_reason: "aborted"
4298
- };
4726
+ if (signal_aborted(params.signal) === true) {
4727
+ return aborted_outcome(history, turn - 1, emitter);
4299
4728
  }
4300
4729
  emitter?.emit({ type: "turn_start", turn });
4301
4730
  await compress_if_needed(deps, history, params, emitter);
4302
4731
  emitter?.emit({ type: "llm_start", turn });
4303
- 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
+ }
4304
4741
  emitter?.emit({ type: "llm_end", turn, result });
4305
4742
  history.push(result.message);
4306
4743
  const calls = result.message.tool_calls ?? [];
@@ -4309,7 +4746,10 @@ async function run_conversation(deps, messages, params) {
4309
4746
  emitter?.emit({ type: "turn_end", turn });
4310
4747
  return { messages: history, final: result.message, result, turns_used: turn, stopped_reason: "final" };
4311
4748
  }
4312
- 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
+ }
4313
4753
  }
4314
4754
  emitter?.emit({ type: "budget_exhausted", turns_used: params.max_turns });
4315
4755
  emitter?.emit({ type: "turn_end", turn: params.max_turns });
@@ -4389,7 +4829,8 @@ var Agent = class {
4389
4829
  executor;
4390
4830
  hook_runner;
4391
4831
  mcp_runtime;
4392
- mcp_attached = false;
4832
+ mcp_sessions = [];
4833
+ mcp_attach;
4393
4834
  constructor(config, plugins = [], runtime) {
4394
4835
  this.config = config;
4395
4836
  this.mcp_runtime = runtime?.mcp;
@@ -4418,14 +4859,20 @@ var Agent = class {
4418
4859
  async run(options) {
4419
4860
  await this.attach_mcp_once();
4420
4861
  const usage_total = { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 };
4421
- 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));
4422
4865
  await this.call_plugin_run_start(options.input);
4423
4866
  let outcome;
4424
4867
  try {
4425
4868
  const seed_messages = [...options.history ?? []];
4426
4869
  seed_messages.push({ role: "user", content: options.input });
4427
- const tool_context = { work_dir: this.config.work_dir, env: tool_env(this.config) };
4428
- 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, {
4429
4876
  system_prompt: this.config.system_prompt ?? DEFAULT_AGENT_SYSTEM_PROMPT,
4430
4877
  max_turns: this.config.max_turns,
4431
4878
  temperature: this.config.temperature,
@@ -4436,29 +4883,36 @@ var Agent = class {
4436
4883
  });
4437
4884
  } finally {
4438
4885
  stop_collecting();
4886
+ stop_forwarding();
4439
4887
  if (outcome !== void 0) {
4440
4888
  await this.call_plugin_run_end(outcome);
4441
4889
  }
4442
4890
  }
4443
4891
  const session_path = await this.persist_session(outcome, options, usage_total);
4444
- const full_messages = [...options.history ?? [], ...outcome.messages];
4445
- 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 = [];
4446
4900
  }
4447
4901
  /** tools/list once, before the model sees definitions. Empty allowlists never connect. */
4448
4902
  async attach_mcp_once() {
4449
- if (this.mcp_attached === true) {
4450
- return;
4451
- }
4452
- this.mcp_attached = true;
4453
- await attach_enabled_mcp_tools(this.registry, this.config, this.mcp_runtime);
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);
4454
4908
  }
4455
4909
  /** Per-run deps: the built-once ToolContext threads through every tool execution. */
4456
- loop_deps(tool_context) {
4910
+ loop_deps(tool_context, emitter = this.events) {
4457
4911
  return {
4458
4912
  chat: (messages, tools, chat_options) => this.router.chat_with_failover(messages, tools, chat_options),
4459
4913
  tools: this.executor,
4460
4914
  definitions: () => this.registry.definitions(),
4461
- emitter: this.events,
4915
+ emitter,
4462
4916
  tool_context
4463
4917
  };
4464
4918
  }
@@ -4513,12 +4967,20 @@ async function create_agent_with_plugins(raw_config) {
4513
4967
  }
4514
4968
  async function run_agent(raw_config, input, options) {
4515
4969
  const agent = await create_agent_with_plugins(raw_config);
4516
- 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
+ }
4517
4975
  }
4518
4976
 
4519
4977
  export {
4520
4978
  safe_json_parse,
4521
4979
  truncate_text,
4980
+ DEFAULT_GATEWAY_TOKEN_ENVS,
4981
+ is_env_var_name,
4982
+ platform_token_env,
4983
+ read_platform_token,
4522
4984
  logger,
4523
4985
  register_builtin_tools,
4524
4986
  catalog_by_name,
@@ -4530,10 +4992,8 @@ export {
4530
4992
  plugin_errors_summary,
4531
4993
  sleep,
4532
4994
  ProviderError,
4533
- DEFAULT_GATEWAY_TOKEN_ENVS,
4534
- is_env_var_name,
4535
- platform_token_env,
4536
- read_platform_token,
4995
+ gateway_tools_enabled,
4996
+ check_gateway_sender,
4537
4997
  parse_agent_config,
4538
4998
  AgentEmitter,
4539
4999
  Agent,
@@ -4541,4 +5001,4 @@ export {
4541
5001
  create_agent_with_plugins,
4542
5002
  run_agent
4543
5003
  };
4544
- //# sourceMappingURL=chunk-QVJCIZIF.js.map
5004
+ //# sourceMappingURL=chunk-WNFBIX4E.js.map