pullfrog 0.1.55 → 0.1.56

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -98334,14 +98334,14 @@ var require_turndown_cjs = __commonJS({
98334
98334
  } else if (node2.nodeType === 1) {
98335
98335
  replacement = replacementForNode.call(self2, node2);
98336
98336
  }
98337
- return join26(output, replacement);
98337
+ return join27(output, replacement);
98338
98338
  }, "");
98339
98339
  }
98340
98340
  function postProcess(output) {
98341
98341
  var self2 = this;
98342
98342
  this.rules.forEach(function(rule) {
98343
98343
  if (typeof rule.append === "function") {
98344
- output = join26(output, rule.append(self2.options));
98344
+ output = join27(output, rule.append(self2.options));
98345
98345
  }
98346
98346
  });
98347
98347
  return output.replace(/^[\t\r\n]+/, "").replace(/[\t\r\n\s]+$/, "");
@@ -98353,7 +98353,7 @@ var require_turndown_cjs = __commonJS({
98353
98353
  if (whitespace.leading || whitespace.trailing) content = content.trim();
98354
98354
  return whitespace.leading + rule.replacement(content, node2, this.options) + whitespace.trailing;
98355
98355
  }
98356
- function join26(output, replacement) {
98356
+ function join27(output, replacement) {
98357
98357
  var s1 = trimTrailingNewlines(output);
98358
98358
  var s2 = trimLeadingNewlines(replacement);
98359
98359
  var nls = Math.max(output.length - s1.length, replacement.length - s2.length);
@@ -103146,7 +103146,7 @@ var init_file_type = __esm({
103146
103146
  // main.ts
103147
103147
  import { existsSync as existsSync8, readdirSync as readdirSync2 } from "node:fs";
103148
103148
  import { readFile as readFile5 } from "node:fs/promises";
103149
- import { join as join25 } from "node:path";
103149
+ import { join as join26 } from "node:path";
103150
103150
 
103151
103151
  // node_modules/.pnpm/package-manager-detector@1.8.0/node_modules/package-manager-detector/dist/commands.mjs
103152
103152
  function splitRunArgs(args2, valueFlags = []) {
@@ -103735,12 +103735,22 @@ var providers = {
103735
103735
  displayName: "DeepSeek",
103736
103736
  envVars: ["DEEPSEEK_API_KEY"],
103737
103737
  models: {
103738
+ // same fork trap as `deepseek-flash` below, and Pro fell into it on
103739
+ // 2026-08-12: OpenRouter forked the 0813 release into its own id and left
103740
+ // the April preview live under the unversioned one at 2.7x the input and
103741
+ // 27x the CACHE-READ rate ($1.168/$2.336/$0.09855 vs
103742
+ // $0.435/$0.87/$0.003625). the direct route upgraded in place, so only the
103743
+ // OpenRouter side was stale — and that side is what OSS and Router runs
103744
+ // use. measured: $0.1719/run authoritative against the funded default's
103745
+ // $0.0405. there is no `~deepseek/*-pro-latest` pointer to track, so this
103746
+ // one has to be a dated pin; the `models-catalog` drift test is what
103747
+ // catches the next release.
103738
103748
  "deepseek-pro": {
103739
103749
  displayName: "DeepSeek Pro",
103740
103750
  resolve: "deepseek/deepseek-v4-pro",
103741
103751
  effort: ["high", "max"],
103742
- openRouterEffort: ["high", "xhigh"],
103743
- openRouterResolve: "openrouter/deepseek/deepseek-v4-pro",
103752
+ openRouterEffort: ["low", "high", "max"],
103753
+ openRouterResolve: "openrouter/deepseek/deepseek-v4-pro-0813",
103744
103754
  preferred: true
103745
103755
  },
103746
103756
  // DeepSeek upgrades the `deepseek-v4-flash` API model in place, so the
@@ -103748,14 +103758,19 @@ var providers = {
103748
103758
  // release into its own id and left the April preview live under the
103749
103759
  // unversioned one at a HIGHER price ($0.14/$0.28 vs $0.09/$0.18) — so the
103750
103760
  // OpenRouter route can never use the bare id. it tracks the rolling
103751
- // `~deepseek/*-latest` pointer (rule 4), which resolves to 0731 at the
103752
- // same price today and follows the next in-place upgrade on its own,
103753
- // rather than pinning a dated snapshot that goes stale every release.
103754
- // 0731 gained a `low` rung and renamed the top to `max`, unlike Pro
103755
- // (`high, max` direct / `high, xhigh` via OpenRouter). that ladder now
103756
- // reads the same on BOTH routes — the in-place direct upgrade caught up
103757
- // to the fork so `low`, not `high`, is position 0 either way, which is
103758
- // why the OSS effort floor in `main.ts` pins the `high` rung by name.
103761
+ // `~deepseek/*-latest` pointer (rule 4), which follows the next in-place
103762
+ // upgrade on its own rather than pinning a dated snapshot that goes stale
103763
+ // every release. that pointer is NOT priced like 0731 its listed rates
103764
+ // run +40% on output and +57.5% on cache read, and it costs 12% more per
103765
+ // run on authoritative spend ($0.0405 vs $0.0361). kept anyway: ~$45/yr
103766
+ // buys auto-following the upgrade, and the listed gap overstates the real
103767
+ // one because a rolling alias prices at its routing ceiling.
103768
+ // 0731 gained a `low` rung and renamed the top to `max`, and that ladder
103769
+ // now reads the same on BOTH routes — the in-place direct upgrade caught
103770
+ // up to the fork. so `low`, not `high`, is position 0 either way, which is
103771
+ // why the OSS effort floor in `main.ts` pins the `high` rung by name. Pro
103772
+ // is the same shape via OpenRouter since 0813; only its direct route still
103773
+ // starts at `high`.
103759
103774
  "deepseek-flash": {
103760
103775
  displayName: "DeepSeek Flash",
103761
103776
  resolve: "deepseek/deepseek-v4-flash",
@@ -104154,11 +104169,13 @@ var providers = {
104154
104169
  effort: ["low", "medium", "high"],
104155
104170
  openRouterResolve: "openrouter/x-ai/grok-4.5"
104156
104171
  },
104172
+ // dated pin, not the bare id — see `deepseek/deepseek-pro` for why
104173
+ // OpenRouter's unversioned `deepseek-v4-pro` is the stale April preview.
104157
104174
  "deepseek-pro": {
104158
104175
  displayName: "DeepSeek Pro",
104159
- resolve: "openrouter/deepseek/deepseek-v4-pro",
104160
- effort: ["high", "xhigh"],
104161
- openRouterResolve: "openrouter/deepseek/deepseek-v4-pro"
104176
+ resolve: "openrouter/deepseek/deepseek-v4-pro-0813",
104177
+ effort: ["low", "high", "max"],
104178
+ openRouterResolve: "openrouter/deepseek/deepseek-v4-pro-0813"
104162
104179
  },
104163
104180
  // rolling pointer, not the bare id — see `deepseek/deepseek-flash` for why
104164
104181
  // OpenRouter's unversioned `deepseek-v4-flash` is the stale April preview.
@@ -104349,6 +104366,19 @@ function formatMcpToolRef(agentId, toolName) {
104349
104366
  return agentId;
104350
104367
  }
104351
104368
  }
104369
+ function extractMcpToolRefs(agentId, text) {
104370
+ const prefix = formatMcpToolRef(agentId, "");
104371
+ const escaped = prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
104372
+ return [...text.matchAll(new RegExp(`${escaped}([a-z0-9_]+)`, "g"))].map((match3) => match3[1]);
104373
+ }
104374
+ function assertPromptToolRefs(params) {
104375
+ const registered = new Set(params.toolNames);
104376
+ const dangling = [...new Set(extractMcpToolRefs(params.agentId, params.prompt))].filter((name) => !registered.has(name)).sort();
104377
+ if (dangling.length === 0) return;
104378
+ throw new Error(
104379
+ `prompt advertises ${dangling.length} tool(s) the MCP server did not register: ${dangling.join(", ")}. registered: ${[...registered].sort().join(", ")}`
104380
+ );
104381
+ }
104352
104382
  function hasSimilarIssues(params) {
104353
104383
  return params.repoIntelligence && !!params.event.issue_number && !params.event.is_pr;
104354
104384
  }
@@ -105122,7 +105152,7 @@ var import_semver = __toESM(require_semver2(), 1);
105122
105152
  // package.json
105123
105153
  var package_default = {
105124
105154
  name: "pullfrog",
105125
- version: "0.1.55",
105155
+ version: "0.1.56",
105126
105156
  type: "module",
105127
105157
  bin: {
105128
105158
  pullfrog: "dist/cli.mjs",
@@ -112797,11 +112827,12 @@ function bootstrapPullfrogDataDir() {
112797
112827
  }
112798
112828
 
112799
112829
  // utils/worktree.ts
112800
- async function dirtyTrackedPaths() {
112830
+ async function dirtyTrackedPaths(params = {}) {
112801
112831
  const result = await spawn({
112802
112832
  cmd: "git",
112803
112833
  args: ["diff", "--name-only", "HEAD"],
112804
112834
  env: process.env,
112835
+ cwd: params.cwd ?? process.cwd(),
112805
112836
  activityTimeout: 0
112806
112837
  });
112807
112838
  if (result.exitCode !== 0) {
@@ -112812,12 +112843,15 @@ async function dirtyTrackedPaths() {
112812
112843
  return new Set(result.stdout.split("\n").filter(Boolean));
112813
112844
  }
112814
112845
  async function restoreDirtiedSince(params) {
112815
- const dirtied = [...await dirtyTrackedPaths()].filter((path4) => !params.before.has(path4));
112846
+ const dirtied = [...await dirtyTrackedPaths({ cwd: params.cwd ?? process.cwd() })].filter(
112847
+ (path4) => !params.before.has(path4)
112848
+ );
112816
112849
  if (dirtied.length === 0) return;
112817
112850
  const result = await spawn({
112818
112851
  cmd: "git",
112819
112852
  args: ["restore", "--staged", "--worktree", "--", ...dirtied],
112820
112853
  env: process.env,
112854
+ cwd: params.cwd ?? process.cwd(),
112821
112855
  activityTimeout: 0
112822
112856
  });
112823
112857
  if (result.exitCode !== 0) {
@@ -188745,6 +188779,116 @@ function CommitInfoTool(ctx) {
188745
188779
  });
188746
188780
  }
188747
188781
 
188782
+ // utils/roleMirror.ts
188783
+ function mirrorRolePermissions(role) {
188784
+ switch (role) {
188785
+ case "admin":
188786
+ case "maintain":
188787
+ case "write":
188788
+ case "triage":
188789
+ return { contents: "read", issues: "write", pull_requests: "write" };
188790
+ case "read":
188791
+ case "none":
188792
+ case void 0:
188793
+ return null;
188794
+ default:
188795
+ return role;
188796
+ }
188797
+ }
188798
+ function formatPermissions(permissions) {
188799
+ return Object.entries(permissions).map((entry) => entry.join(":")).join(", ");
188800
+ }
188801
+
188802
+ // mcp/currentObject.ts
188803
+ async function resolveCurrentObject(ctx) {
188804
+ const issueNumber = ctx.payload.event.issue_number;
188805
+ if (!issueNumber) {
188806
+ throw new Error("this run was not triggered on an issue or pull request");
188807
+ }
188808
+ if (mirrorRolePermissions(ctx.payload.event.authorPermission)) {
188809
+ return { issueNumber, isPr: ctx.payload.event.is_pr === true };
188810
+ }
188811
+ const issue3 = await ctx.octokit.rest.issues.get({
188812
+ owner: ctx.repo.owner,
188813
+ repo: ctx.repo.name,
188814
+ issue_number: issueNumber
188815
+ });
188816
+ const author = issue3.data.user?.login;
188817
+ const triggerer = ctx.payload.triggerer;
188818
+ if (!author || !triggerer || author.toLowerCase() !== triggerer.toLowerCase()) {
188819
+ throw new Error(
188820
+ `${triggerer ?? "the triggering user"} did not open #${issueNumber} and has no write access to ${ctx.repo.owner}/${ctx.repo.name}, so this run cannot change its state.`
188821
+ );
188822
+ }
188823
+ return { issueNumber, isPr: issue3.data.pull_request !== void 0 };
188824
+ }
188825
+ var CloseCurrent = type({
188826
+ state_reason: type.enumerated("completed", "not_planned", "duplicate").describe(
188827
+ "why it is being closed: 'completed' (resolved), 'not_planned' (won't fix, out of scope), or 'duplicate' (already tracked elsewhere). ignored for pull requests."
188828
+ )
188829
+ });
188830
+ function CloseCurrentTool(ctx) {
188831
+ return tool({
188832
+ name: "close_current",
188833
+ mutates: true,
188834
+ description: 'Close the issue or pull request this run was triggered on. Example: `close_current({ state_reason: "duplicate" })`. Comment first to explain the decision \u2014 closing alone is opaque to the author.',
188835
+ parameters: CloseCurrent,
188836
+ execute: execute(async (params) => {
188837
+ const target = await resolveCurrentObject(ctx);
188838
+ const result = target.isPr ? await ctx.octokit.rest.pulls.update({
188839
+ owner: ctx.repo.owner,
188840
+ repo: ctx.repo.name,
188841
+ pull_number: target.issueNumber,
188842
+ state: "closed"
188843
+ }) : await ctx.octokit.rest.issues.update({
188844
+ owner: ctx.repo.owner,
188845
+ repo: ctx.repo.name,
188846
+ issue_number: target.issueNumber,
188847
+ state: "closed",
188848
+ state_reason: params.state_reason
188849
+ });
188850
+ ctx.toolState.wasUpdated = true;
188851
+ log.info(`\xBB closed #${target.issueNumber} (${params.state_reason})`);
188852
+ return {
188853
+ success: true,
188854
+ number: result.data.number,
188855
+ url: result.data.html_url,
188856
+ state: result.data.state
188857
+ };
188858
+ }, "close_current")
188859
+ });
188860
+ }
188861
+ function ReopenCurrentTool(ctx) {
188862
+ return tool({
188863
+ name: "reopen_current",
188864
+ mutates: true,
188865
+ description: "Reopen the issue or pull request this run was triggered on.",
188866
+ parameters: type({}),
188867
+ execute: execute(async () => {
188868
+ const target = await resolveCurrentObject(ctx);
188869
+ const result = target.isPr ? await ctx.octokit.rest.pulls.update({
188870
+ owner: ctx.repo.owner,
188871
+ repo: ctx.repo.name,
188872
+ pull_number: target.issueNumber,
188873
+ state: "open"
188874
+ }) : await ctx.octokit.rest.issues.update({
188875
+ owner: ctx.repo.owner,
188876
+ repo: ctx.repo.name,
188877
+ issue_number: target.issueNumber,
188878
+ state: "open"
188879
+ });
188880
+ ctx.toolState.wasUpdated = true;
188881
+ log.info(`\xBB reopened #${target.issueNumber}`);
188882
+ return {
188883
+ success: true,
188884
+ number: result.data.number,
188885
+ url: result.data.html_url,
188886
+ state: result.data.state
188887
+ };
188888
+ }, "reopen_current")
188889
+ });
188890
+ }
188891
+
188748
188892
  // prep/index.ts
188749
188893
  import { performance as performance8 } from "node:perf_hooks";
188750
188894
 
@@ -189371,6 +189515,456 @@ function AwaitDependencyInstallationTool(ctx) {
189371
189515
  });
189372
189516
  }
189373
189517
 
189518
+ // mcp/gh.ts
189519
+ import { mkdirSync as mkdirSync8 } from "node:fs";
189520
+ import { join as join20 } from "node:path";
189521
+
189522
+ // mcp/shell.ts
189523
+ import { spawn as spawn3, spawnSync as spawnSync6 } from "node:child_process";
189524
+ import { randomUUID as randomUUID4 } from "node:crypto";
189525
+ import { closeSync, openSync, writeFileSync as writeFileSync11 } from "node:fs";
189526
+ import { userInfo as userInfo2 } from "node:os";
189527
+ import { join as join19 } from "node:path";
189528
+ import { setTimeout as sleep3 } from "node:timers/promises";
189529
+ var ShellParams = type({
189530
+ command: "string",
189531
+ // advisory and unread — no code path consumes it. kept in the schema only
189532
+ // because it nudges the model to state intent before running a command;
189533
+ // REQUIRING it cost a full rejected turn every time one was omitted. #1140.
189534
+ "description?": "string",
189535
+ "timeout?": type.number.describe(
189536
+ "Timeout in MILLISECONDS (not seconds). Default 30000 (30s), max 120000 (2m). e.g. timeout: 180000 for 3 minutes; timeout: 180 means 180ms and will kill the process almost immediately."
189537
+ ),
189538
+ "working_directory?": "string",
189539
+ "background?": "boolean"
189540
+ });
189541
+ var detectedSandboxMethod;
189542
+ var usernsProbeError;
189543
+ function getSandboxMethod() {
189544
+ return detectSandboxMethod();
189545
+ }
189546
+ function detectSandboxMethod() {
189547
+ if (detectedSandboxMethod !== void 0) {
189548
+ return detectedSandboxMethod;
189549
+ }
189550
+ if (process.env.CI !== "true") {
189551
+ detectedSandboxMethod = "none";
189552
+ log.debug("sandbox disabled (CI !== true)");
189553
+ return "none";
189554
+ }
189555
+ try {
189556
+ const result = spawnSync6("unshare", ["--pid", "--fork", "--mount-proc", "true"], {
189557
+ timeout: 5e3,
189558
+ stdio: "ignore"
189559
+ });
189560
+ if (result.status === 0) {
189561
+ detectedSandboxMethod = "unshare";
189562
+ log.debug("PID namespace isolation enabled (unprivileged unshare)");
189563
+ return "unshare";
189564
+ }
189565
+ } catch {
189566
+ }
189567
+ try {
189568
+ const result = spawnSync6("sudo", ["unshare", "--pid", "--fork", "--mount-proc", "true"], {
189569
+ timeout: 5e3,
189570
+ stdio: "ignore"
189571
+ });
189572
+ if (result.status === 0) {
189573
+ detectedSandboxMethod = "sudo-unshare";
189574
+ log.debug("PID namespace isolation enabled (sudo unshare)");
189575
+ return "sudo-unshare";
189576
+ }
189577
+ } catch {
189578
+ }
189579
+ try {
189580
+ const result = spawnSync6(
189581
+ "unshare",
189582
+ [
189583
+ "--user",
189584
+ "--map-root-user",
189585
+ "--pid",
189586
+ "--fork",
189587
+ "--mount",
189588
+ "setpriv",
189589
+ "--no-new-privs",
189590
+ "--bounding-set",
189591
+ "-all",
189592
+ "--inh-caps",
189593
+ "-all",
189594
+ "--ambient-caps",
189595
+ "-all",
189596
+ "true"
189597
+ ],
189598
+ { timeout: 5e3, stdio: ["ignore", "ignore", "pipe"] }
189599
+ );
189600
+ if (result.status === 0) {
189601
+ detectedSandboxMethod = "userns-unshare";
189602
+ log.debug("PID namespace isolation enabled (unprivileged userns unshare)");
189603
+ return "userns-unshare";
189604
+ }
189605
+ usernsProbeError = result.stderr?.toString().trim() || result.error?.message;
189606
+ } catch {
189607
+ }
189608
+ detectedSandboxMethod = "none";
189609
+ log.info("PID namespace isolation not available");
189610
+ return "none";
189611
+ }
189612
+ var PROC_CLEANUP = "umount /proc 2>/dev/null; umount /proc 2>/dev/null; mount -t proc proc /proc 2>/dev/null;";
189613
+ var SOCKET_CLEANUP = [
189614
+ "/var/run/docker.sock",
189615
+ "/run/docker.sock",
189616
+ "/var/run/podman/podman.sock",
189617
+ "/run/podman/podman.sock",
189618
+ "/run/containerd/containerd.sock",
189619
+ "/var/run/crio/crio.sock"
189620
+ ].map((path4) => `mount --bind /dev/null ${path4} 2>/dev/null;`).join(" ");
189621
+ function buildFsMounts(repoDir) {
189622
+ const escaped = repoDir.replace(/'/g, "'\\''");
189623
+ return [
189624
+ `mkdir -p /var/lib/pullfrog 2>/dev/null;`,
189625
+ `mount -t tmpfs tmpfs /var/lib/pullfrog 2>/dev/null;`,
189626
+ `[ -n "$RUNNER_TEMP" ] && [ -d "$RUNNER_TEMP/_runner_file_commands" ] && mount -t tmpfs tmpfs "$RUNNER_TEMP/_runner_file_commands" 2>/dev/null;`,
189627
+ `[ -e '${escaped}/.git' ] && mount --bind '${escaped}/.git' '${escaped}/.git' 2>/dev/null && mount -o remount,bind,ro '${escaped}/.git' 2>/dev/null;`
189628
+ ].join(" ");
189629
+ }
189630
+ var _repoRoot;
189631
+ function resolveRepoRoot() {
189632
+ if (_repoRoot) return _repoRoot;
189633
+ const fromEnv = process.env.GITHUB_WORKSPACE;
189634
+ if (fromEnv) {
189635
+ _repoRoot = fromEnv;
189636
+ return _repoRoot;
189637
+ }
189638
+ try {
189639
+ _repoRoot = spawnSync6("git", ["rev-parse", "--show-toplevel"], {
189640
+ cwd: process.cwd(),
189641
+ stdio: ["ignore", "pipe", "ignore"],
189642
+ encoding: "utf-8"
189643
+ }).stdout?.trim();
189644
+ } catch {
189645
+ }
189646
+ if (!_repoRoot) _repoRoot = process.cwd();
189647
+ return _repoRoot;
189648
+ }
189649
+ function spawnShell(params) {
189650
+ const spawnOpts = { env: params.env, cwd: params.cwd, stdio: params.stdio, detached: true };
189651
+ const sandboxMethod = detectSandboxMethod();
189652
+ const ci = process.env.CI === "true";
189653
+ if (ci && sandboxMethod === "none") {
189654
+ throw new Error(
189655
+ "PID-namespace isolation is required in CI but unavailable: unprivileged unshare, sudo unshare, and userns-nested unshare all failed. " + (usernsProbeError ? `userns probe said: ${usernsProbeError}. ` : "") + "on a containerized / Kubernetes self-hosted runner the cause is usually one of: the pod's seccomp profile blocks user-namespace creation (Pod Security Standards baseline/restricted); the pod runs as root without CAP_SETFCAP, which the kernel requires to map uid 0; or the node kernel disallows unprivileged user namespaces (Ubuntu >= 23.10, Bottlerocket, Talos). elsewhere, check that `unshare` and `setpriv` (util-linux >= 2.31, which is what added --ambient-caps) are on PATH. see https://docs.pullfrog.com/security#self-hosted-runners"
189656
+ );
189657
+ }
189658
+ const repoRoot = resolveRepoRoot();
189659
+ const fsMounts = buildFsMounts(repoRoot);
189660
+ if (sandboxMethod === "unshare") {
189661
+ return spawn3(
189662
+ "unshare",
189663
+ [
189664
+ "--pid",
189665
+ "--fork",
189666
+ "--mount-proc",
189667
+ "bash",
189668
+ "-c",
189669
+ `${PROC_CLEANUP} ${SOCKET_CLEANUP} ${fsMounts} ${params.command}`
189670
+ ],
189671
+ spawnOpts
189672
+ );
189673
+ }
189674
+ if (sandboxMethod === "userns-unshare") {
189675
+ const escaped = params.command.replace(/'/g, "'\\''");
189676
+ return spawn3(
189677
+ "unshare",
189678
+ [
189679
+ "--user",
189680
+ "--map-root-user",
189681
+ "--pid",
189682
+ "--fork",
189683
+ "--mount",
189684
+ "bash",
189685
+ "-c",
189686
+ `${PROC_CLEANUP} ${SOCKET_CLEANUP} ${fsMounts} exec setpriv --no-new-privs --bounding-set -all --inh-caps -all --ambient-caps -all bash -c '${escaped}'`
189687
+ ],
189688
+ spawnOpts
189689
+ );
189690
+ }
189691
+ if (sandboxMethod === "sudo-unshare") {
189692
+ const envArgs = [];
189693
+ for (const [k2, v] of Object.entries(params.env)) {
189694
+ if (v !== void 0) {
189695
+ envArgs.push(`${k2}=${v}`);
189696
+ }
189697
+ }
189698
+ const username = userInfo2().username;
189699
+ const pathRestore = 'export PATH="${SANDBOX_PATH:-$PATH}"; ';
189700
+ const escaped = (pathRestore + params.command).replace(/'/g, "'\\''");
189701
+ envArgs.push(`SANDBOX_PATH=${params.env.PATH ?? ""}`);
189702
+ return spawn3(
189703
+ "sudo",
189704
+ [
189705
+ "env",
189706
+ ...envArgs,
189707
+ "unshare",
189708
+ "--pid",
189709
+ "--fork",
189710
+ "--mount-proc",
189711
+ "bash",
189712
+ "-c",
189713
+ `${PROC_CLEANUP} ${SOCKET_CLEANUP} ${fsMounts} exec su -p -s /bin/bash ${username} -c '${escaped}'`
189714
+ ],
189715
+ { ...spawnOpts, env: {} }
189716
+ );
189717
+ }
189718
+ return spawn3("bash", ["-c", params.command], spawnOpts);
189719
+ }
189720
+ async function killProcessGroup(proc) {
189721
+ if (!proc.pid) return;
189722
+ try {
189723
+ process.kill(-proc.pid, "SIGTERM");
189724
+ await new Promise((r) => setTimeout(r, 200));
189725
+ process.kill(-proc.pid, "SIGKILL");
189726
+ } catch {
189727
+ try {
189728
+ proc.kill("SIGKILL");
189729
+ } catch {
189730
+ }
189731
+ }
189732
+ }
189733
+ function getTempDir() {
189734
+ const tempDir = process.env.PULLFROG_TEMP_DIR;
189735
+ if (!tempDir) {
189736
+ throw new Error("PULLFROG_TEMP_DIR not set");
189737
+ }
189738
+ return tempDir;
189739
+ }
189740
+ var MAX_OUTPUT_CHARS = 5e3;
189741
+ async function runSandboxed(params) {
189742
+ const proc = spawnShell({
189743
+ command: params.command,
189744
+ env: params.env,
189745
+ cwd: params.cwd,
189746
+ stdio: ["ignore", "pipe", "pipe"]
189747
+ });
189748
+ let stdout = "", stderr = "", timedOut = false, exited = false, spawnError = "";
189749
+ proc.stdout?.on("data", (chunk) => {
189750
+ stdout += chunk.toString();
189751
+ });
189752
+ proc.stderr?.on("data", (chunk) => {
189753
+ stderr += chunk.toString();
189754
+ });
189755
+ const timeoutId = setTimeout(async () => {
189756
+ if (!exited) {
189757
+ timedOut = true;
189758
+ await killProcessGroup(proc);
189759
+ }
189760
+ }, params.timeout);
189761
+ const exitCode = await new Promise((resolve3) => {
189762
+ const done = (code) => {
189763
+ exited = true;
189764
+ clearTimeout(timeoutId);
189765
+ resolve3(code);
189766
+ };
189767
+ proc.on("exit", done);
189768
+ proc.on("error", (err) => {
189769
+ spawnError = err.message;
189770
+ done(null);
189771
+ });
189772
+ });
189773
+ proc.stdout?.destroy();
189774
+ proc.stderr?.destroy();
189775
+ let output = stderr ? stdout ? `${stdout}
189776
+ ${stderr}` : stderr : stdout;
189777
+ if (spawnError) output = output ? `${output}
189778
+ ${spawnError}` : spawnError;
189779
+ if (timedOut)
189780
+ output = output ? `${output}
189781
+ [timed out after ${params.timeout}ms]` : `[timed out after ${params.timeout}ms]`;
189782
+ return {
189783
+ output: output.trim(),
189784
+ exitCode: exitCode ?? (timedOut ? 124 : -1),
189785
+ timedOut
189786
+ };
189787
+ }
189788
+ function capOutput(output) {
189789
+ if (output.length <= MAX_OUTPUT_CHARS) return output;
189790
+ const fullPath = join19(getTempDir(), `shell-${randomUUID4().slice(0, 8)}.log`);
189791
+ writeFileSync11(fullPath, output);
189792
+ const elided = output.length - MAX_OUTPUT_CHARS;
189793
+ return `... [${elided} chars truncated; full output saved to ${fullPath}] ...
189794
+ ${output.slice(-MAX_OUTPUT_CHARS)}`;
189795
+ }
189796
+ function isGitCommand(command) {
189797
+ const trimmed = command.trim();
189798
+ if (trimmed === "git" || trimmed.startsWith("git ")) return true;
189799
+ if (trimmed.startsWith("sudo git")) return true;
189800
+ return /[;&|]\s*(?:sudo\s+)?git(?:\s|$)/.test(trimmed);
189801
+ }
189802
+ function ShellTool(ctx) {
189803
+ return tool({
189804
+ name: "shell",
189805
+ timeoutMs: 12e4,
189806
+ description: `Execute shell commands securely. Environment is filtered to remove API keys and secrets.
189807
+
189808
+ Example: \`shell({ command: "pnpm test", description: "run the test suite" })\`.
189809
+
189810
+ Use this tool to:
189811
+ - Run shell commands (ls, cat, grep, find, etc.)
189812
+ - Execute build tools (npm, pnpm, cargo, make, etc.)
189813
+ - Run tests and linters
189814
+
189815
+ Output is capped at ${MAX_OUTPUT_CHARS} chars: if exceeded, only the tail is returned and the full body is saved to a tempfile (path included in the response). Re-read the tempfile with cat/tail/grep when you need more.
189816
+
189817
+ Do NOT use this tool for git commands \u2014 use the dedicated git tools instead.`,
189818
+ parameters: ShellParams,
189819
+ execute: execute(async (params) => {
189820
+ if (isGitCommand(params.command)) {
189821
+ throw new Error(
189822
+ 'git commands are not allowed in the shell tool. use the dedicated git tools instead:\n- git: local operations, e.g. git({ command: "log", args: ["--oneline"] }) \u2014 `command` is the subcommand ONLY\n- push_branch: push to remote (handles authentication)\n- git_fetch: fetch from remote (handles authentication)\n- checkout_pr: check out PR branches'
189823
+ );
189824
+ }
189825
+ const timeout = Math.min(params.timeout ?? 3e4, 12e4);
189826
+ const cwd = params.working_directory ?? process.cwd();
189827
+ const env2 = resolveEnv(ctx.payload.shell === "enabled" ? "inherit" : "restricted");
189828
+ if (params.command.includes("agent-browser")) {
189829
+ const daemonError = ensureBrowserDaemon(ctx.toolState);
189830
+ if (daemonError) {
189831
+ return {
189832
+ output: `browser daemon unavailable: ${daemonError}`,
189833
+ exit_code: 1,
189834
+ timed_out: false
189835
+ };
189836
+ }
189837
+ const binDir = ctx.toolState.browserDaemon?.binDir;
189838
+ if (binDir) {
189839
+ env2.PATH = `${binDir}:${env2.PATH ?? ""}`;
189840
+ }
189841
+ }
189842
+ if (params.background) {
189843
+ const tempDir = getTempDir();
189844
+ const handle = `bg-${randomUUID4().slice(0, 8)}`;
189845
+ const outputPath = join19(tempDir, `${handle}.log`);
189846
+ const pidPath = join19(tempDir, `${handle}.pid`);
189847
+ const logFd = openSync(outputPath, "a");
189848
+ let proc;
189849
+ try {
189850
+ proc = spawnShell({
189851
+ command: params.command,
189852
+ env: env2,
189853
+ cwd,
189854
+ stdio: ["ignore", logFd, logFd]
189855
+ });
189856
+ } finally {
189857
+ closeSync(logFd);
189858
+ }
189859
+ if (!proc.pid) {
189860
+ throw new Error("failed to start background process");
189861
+ }
189862
+ proc.unref();
189863
+ writeFileSync11(pidPath, `${proc.pid}
189864
+ `);
189865
+ ctx.toolState.backgroundProcesses.set(handle, { pid: proc.pid, outputPath, pidPath });
189866
+ return {
189867
+ handle,
189868
+ outputPath,
189869
+ pidPath,
189870
+ message: `started background process ${handle} (pid ${proc.pid})`
189871
+ };
189872
+ }
189873
+ const result = await runSandboxed({ command: params.command, env: env2, cwd, timeout });
189874
+ if (result.exitCode !== 0) {
189875
+ log.info(`shell command failed with exit code ${result.exitCode}: ${params.command}`);
189876
+ if (result.output) log.info(`output: ${result.output}`);
189877
+ }
189878
+ return {
189879
+ output: capOutput(result.output),
189880
+ exit_code: result.exitCode,
189881
+ timed_out: result.timedOut
189882
+ };
189883
+ })
189884
+ });
189885
+ }
189886
+ var KillBackgroundParams = type({
189887
+ handle: type.string.describe("The handle of the background process to kill (e.g., bg-a1b2c3d4)")
189888
+ });
189889
+ function KillBackgroundTool(ctx) {
189890
+ return tool({
189891
+ name: "kill_background",
189892
+ mutates: true,
189893
+ description: `Kill a background process by its handle. Use this to stop dev servers or other long-running processes started with shell({ background: true }).`,
189894
+ parameters: KillBackgroundParams,
189895
+ execute: execute(async (params) => {
189896
+ const proc = ctx.toolState.backgroundProcesses.get(params.handle);
189897
+ if (!proc) {
189898
+ return {
189899
+ success: false,
189900
+ message: `no background process with handle ${params.handle}`
189901
+ };
189902
+ }
189903
+ try {
189904
+ process.kill(-proc.pid, "SIGTERM");
189905
+ } catch {
189906
+ }
189907
+ await sleep3(200);
189908
+ try {
189909
+ process.kill(-proc.pid, "SIGKILL");
189910
+ } catch {
189911
+ }
189912
+ ctx.toolState.backgroundProcesses.delete(params.handle);
189913
+ return {
189914
+ success: true,
189915
+ message: `killed background process ${params.handle} (pid ${proc.pid})`
189916
+ };
189917
+ })
189918
+ });
189919
+ }
189920
+
189921
+ // mcp/gh.ts
189922
+ var GH_TIMEOUT_MS = 12e4;
189923
+ var GhParams = type({
189924
+ args: type.string.array().describe(
189925
+ 'gh arguments as an argv array, e.g. ["pr", "list", "--json", "number,title"] or ["api", "graphql", "-f", "query=..."]'
189926
+ )
189927
+ });
189928
+ function shellQuote(arg) {
189929
+ return `'${arg.replace(/'/g, "'\\''")}'`;
189930
+ }
189931
+ function GhTool(ctx) {
189932
+ const permissions = mirrorRolePermissions(ctx.payload.event.authorPermission);
189933
+ return tool({
189934
+ name: "gh",
189935
+ // denied to subagents. `shell` is deliberately unmarked because git is
189936
+ // blocked inside it, but `gh` reaches PR and issue state changes — the
189937
+ // shape of the 2026-05-18 cross-branch clobber the gate exists for.
189938
+ mutates: true,
189939
+ timeoutMs: GH_TIMEOUT_MS,
189940
+ description: `Run the GitHub CLI against ${ctx.repo.owner}/${ctx.repo.name}. Pass argv as an array \u2014 there is no shell, so pipes, redirects and \`&&\` do not work; use gh's own \`--json\`/\`--jq\` flags instead. \`gh api graphql\` is available. Authenticated with ${permissions ? formatPermissions(permissions) : "no permissions"} on this repo only; anything else 403s. Pushing, merging and ref writes are not available \u2014 use the git tools.`,
189941
+ parameters: GhParams,
189942
+ execute: execute(async (params) => {
189943
+ const configDir = join20(ctx.tmpdir, "gh-config");
189944
+ mkdirSync8(configDir, { recursive: true });
189945
+ const command = ["gh", ...params.args.map(shellQuote)].join(" ");
189946
+ const result = await runSandboxed({
189947
+ command,
189948
+ cwd: process.cwd(),
189949
+ timeout: GH_TIMEOUT_MS,
189950
+ env: {
189951
+ ...filterEnvForUntrustedCode(),
189952
+ GH_TOKEN: ctx.ghToken,
189953
+ GH_CONFIG_DIR: configDir,
189954
+ GH_PROMPT_DISABLED: "1",
189955
+ GH_NO_UPDATE_NOTIFIER: "1"
189956
+ }
189957
+ });
189958
+ log.info(`\xBB gh ${params.args.join(" ")} (exit ${result.exitCode})`);
189959
+ return {
189960
+ output: capOutput(result.output),
189961
+ exit_code: result.exitCode,
189962
+ timed_out: result.timedOut
189963
+ };
189964
+ }, "gh")
189965
+ });
189966
+ }
189967
+
189374
189968
  // mcp/issue.ts
189375
189969
  var Issue = type({
189376
189970
  title: type.string.describe("the title of the issue"),
@@ -189415,65 +190009,6 @@ function IssueTool(ctx) {
189415
190009
  })
189416
190010
  });
189417
190011
  }
189418
- var CloseIssue = type({
189419
- issue_number: type.number.describe("the issue number to close"),
189420
- state_reason: type.enumerated("completed", "not_planned", "duplicate").describe(
189421
- "why the issue is being closed: 'completed' (resolved/done), 'not_planned' (won't fix, out of scope), or 'duplicate' (already tracked elsewhere)."
189422
- )
189423
- });
189424
- function CloseIssueTool(ctx) {
189425
- return tool({
189426
- name: "close_issue",
189427
- mutates: true,
189428
- description: 'Close a GitHub issue with a reason. Example: `close_issue({ issue_number: 1234, state_reason: "not_planned" })`. Comment first to explain the decision \u2014 closing alone is opaque to the author.',
189429
- parameters: CloseIssue,
189430
- execute: execute(async (params) => {
189431
- const result = await ctx.octokit.rest.issues.update({
189432
- owner: ctx.repo.owner,
189433
- repo: ctx.repo.name,
189434
- issue_number: params.issue_number,
189435
- state: "closed",
189436
- state_reason: params.state_reason
189437
- });
189438
- ctx.toolState.wasUpdated = true;
189439
- log.info(`\xBB closed issue #${params.issue_number} (${params.state_reason})`);
189440
- return {
189441
- success: true,
189442
- number: result.data.number,
189443
- url: result.data.html_url,
189444
- state: result.data.state,
189445
- stateReason: result.data.state_reason
189446
- };
189447
- })
189448
- });
189449
- }
189450
- var ReopenIssue = type({
189451
- issue_number: type.number.describe("the issue number to reopen")
189452
- });
189453
- function ReopenIssueTool(ctx) {
189454
- return tool({
189455
- name: "reopen_issue",
189456
- mutates: true,
189457
- description: "Reopen a previously closed GitHub issue. Example: `reopen_issue({ issue_number: 1234 })`.",
189458
- parameters: ReopenIssue,
189459
- execute: execute(async (params) => {
189460
- const result = await ctx.octokit.rest.issues.update({
189461
- owner: ctx.repo.owner,
189462
- repo: ctx.repo.name,
189463
- issue_number: params.issue_number,
189464
- state: "open"
189465
- });
189466
- ctx.toolState.wasUpdated = true;
189467
- log.info(`\xBB reopened issue #${params.issue_number}`);
189468
- return {
189469
- success: true,
189470
- number: result.data.number,
189471
- url: result.data.html_url,
189472
- state: result.data.state
189473
- };
189474
- })
189475
- });
189476
- }
189477
190012
 
189478
190013
  // utils/body.ts
189479
190014
  var import_turndown = __toESM(require_turndown_cjs(), 1);
@@ -189970,33 +190505,6 @@ function buildPrBodyWithFooter(ctx, body) {
189970
190505
  const bodyWithoutFooter = stripExistingFooter(fixDoubleEscapedString(body));
189971
190506
  return `${bodyWithoutFooter}${footer}`;
189972
190507
  }
189973
- var ClosePullRequest = type({
189974
- pull_number: type.number.describe("the pull request number to close")
189975
- });
189976
- function ClosePullRequestTool(ctx) {
189977
- return tool({
189978
- name: "close_pull_request",
189979
- mutates: true,
189980
- description: "Close an open pull request WITHOUT merging it. Example: `close_pull_request({ pull_number: 1234 })`. Comment first to explain why \u2014 closing alone is opaque to the author. Merging is a human action and is never done here.",
189981
- parameters: ClosePullRequest,
189982
- execute: execute(async (params) => {
189983
- const result = await ctx.octokit.rest.pulls.update({
189984
- owner: ctx.repo.owner,
189985
- repo: ctx.repo.name,
189986
- pull_number: params.pull_number,
189987
- state: "closed"
189988
- });
189989
- ctx.toolState.wasUpdated = true;
189990
- log.info(`\xBB closed pull request #${params.pull_number}`);
189991
- return {
189992
- success: true,
189993
- number: result.data.number,
189994
- url: result.data.html_url,
189995
- state: result.data.state
189996
- };
189997
- })
189998
- });
189999
- }
190000
190508
  var UpdatePullRequestBody = type({
190001
190509
  pull_number: type.number.describe("the pull request number to update"),
190002
190510
  body: type.string.describe("the new body content for the pull request")
@@ -190156,8 +190664,8 @@ function PullRequestInfoTool(ctx) {
190156
190664
  }
190157
190665
 
190158
190666
  // mcp/reviewComments.ts
190159
- import { writeFileSync as writeFileSync11 } from "node:fs";
190160
- import { join as join19 } from "node:path";
190667
+ import { writeFileSync as writeFileSync12 } from "node:fs";
190668
+ import { join as join21 } from "node:path";
190161
190669
  var COMMENTS_PER_THREAD = 50;
190162
190670
  var REVIEW_THREADS_QUERY = `
190163
190671
  query ($owner: String!, $name: String!, $prNumber: Int!, $cursor: String) {
@@ -190630,8 +191138,8 @@ function GetReviewCommentsTool(ctx) {
190630
191138
  throw new Error("PULLFROG_TEMP_DIR not set");
190631
191139
  }
190632
191140
  const filename = `review-${params.review_id}-threads.md`;
190633
- const commentsPath = join19(tempDir, filename);
190634
- writeFileSync11(commentsPath, formatted.content);
191141
+ const commentsPath = join21(tempDir, filename);
191142
+ writeFileSync12(commentsPath, formatted.content);
190635
191143
  log.debug(`wrote ${threadBlocks.length} threads to ${commentsPath}`);
190636
191144
  return {
190637
191145
  review_id: params.review_id,
@@ -190877,394 +191385,6 @@ ${addendum}`,
190877
191385
  });
190878
191386
  }
190879
191387
 
190880
- // mcp/shell.ts
190881
- import { spawn as spawn3, spawnSync as spawnSync6 } from "node:child_process";
190882
- import { randomUUID as randomUUID4 } from "node:crypto";
190883
- import { closeSync, openSync, writeFileSync as writeFileSync12 } from "node:fs";
190884
- import { userInfo as userInfo2 } from "node:os";
190885
- import { join as join20 } from "node:path";
190886
- import { setTimeout as sleep3 } from "node:timers/promises";
190887
- var ShellParams = type({
190888
- command: "string",
190889
- // advisory and unread — no code path consumes it. kept in the schema only
190890
- // because it nudges the model to state intent before running a command;
190891
- // REQUIRING it cost a full rejected turn every time one was omitted. #1140.
190892
- "description?": "string",
190893
- "timeout?": type.number.describe(
190894
- "Timeout in MILLISECONDS (not seconds). Default 30000 (30s), max 120000 (2m). e.g. timeout: 180000 for 3 minutes; timeout: 180 means 180ms and will kill the process almost immediately."
190895
- ),
190896
- "working_directory?": "string",
190897
- "background?": "boolean"
190898
- });
190899
- var detectedSandboxMethod;
190900
- var usernsProbeError;
190901
- function getSandboxMethod() {
190902
- return detectSandboxMethod();
190903
- }
190904
- function detectSandboxMethod() {
190905
- if (detectedSandboxMethod !== void 0) {
190906
- return detectedSandboxMethod;
190907
- }
190908
- if (process.env.CI !== "true") {
190909
- detectedSandboxMethod = "none";
190910
- log.debug("sandbox disabled (CI !== true)");
190911
- return "none";
190912
- }
190913
- try {
190914
- const result = spawnSync6("unshare", ["--pid", "--fork", "--mount-proc", "true"], {
190915
- timeout: 5e3,
190916
- stdio: "ignore"
190917
- });
190918
- if (result.status === 0) {
190919
- detectedSandboxMethod = "unshare";
190920
- log.debug("PID namespace isolation enabled (unprivileged unshare)");
190921
- return "unshare";
190922
- }
190923
- } catch {
190924
- }
190925
- try {
190926
- const result = spawnSync6("sudo", ["unshare", "--pid", "--fork", "--mount-proc", "true"], {
190927
- timeout: 5e3,
190928
- stdio: "ignore"
190929
- });
190930
- if (result.status === 0) {
190931
- detectedSandboxMethod = "sudo-unshare";
190932
- log.debug("PID namespace isolation enabled (sudo unshare)");
190933
- return "sudo-unshare";
190934
- }
190935
- } catch {
190936
- }
190937
- try {
190938
- const result = spawnSync6(
190939
- "unshare",
190940
- [
190941
- "--user",
190942
- "--map-root-user",
190943
- "--pid",
190944
- "--fork",
190945
- "--mount",
190946
- "setpriv",
190947
- "--no-new-privs",
190948
- "--bounding-set",
190949
- "-all",
190950
- "--inh-caps",
190951
- "-all",
190952
- "--ambient-caps",
190953
- "-all",
190954
- "true"
190955
- ],
190956
- { timeout: 5e3, stdio: ["ignore", "ignore", "pipe"] }
190957
- );
190958
- if (result.status === 0) {
190959
- detectedSandboxMethod = "userns-unshare";
190960
- log.debug("PID namespace isolation enabled (unprivileged userns unshare)");
190961
- return "userns-unshare";
190962
- }
190963
- usernsProbeError = result.stderr?.toString().trim() || result.error?.message;
190964
- } catch {
190965
- }
190966
- detectedSandboxMethod = "none";
190967
- log.info("PID namespace isolation not available");
190968
- return "none";
190969
- }
190970
- var PROC_CLEANUP = "umount /proc 2>/dev/null; umount /proc 2>/dev/null; mount -t proc proc /proc 2>/dev/null;";
190971
- var SOCKET_CLEANUP = [
190972
- "/var/run/docker.sock",
190973
- "/run/docker.sock",
190974
- "/var/run/podman/podman.sock",
190975
- "/run/podman/podman.sock",
190976
- "/run/containerd/containerd.sock",
190977
- "/var/run/crio/crio.sock"
190978
- ].map((path4) => `mount --bind /dev/null ${path4} 2>/dev/null;`).join(" ");
190979
- function buildFsMounts(repoDir) {
190980
- const escaped = repoDir.replace(/'/g, "'\\''");
190981
- return [
190982
- `mkdir -p /var/lib/pullfrog 2>/dev/null;`,
190983
- `mount -t tmpfs tmpfs /var/lib/pullfrog 2>/dev/null;`,
190984
- `[ -n "$RUNNER_TEMP" ] && [ -d "$RUNNER_TEMP/_runner_file_commands" ] && mount -t tmpfs tmpfs "$RUNNER_TEMP/_runner_file_commands" 2>/dev/null;`,
190985
- `[ -e '${escaped}/.git' ] && mount --bind '${escaped}/.git' '${escaped}/.git' 2>/dev/null && mount -o remount,bind,ro '${escaped}/.git' 2>/dev/null;`
190986
- ].join(" ");
190987
- }
190988
- var _repoRoot;
190989
- function resolveRepoRoot() {
190990
- if (_repoRoot) return _repoRoot;
190991
- const fromEnv = process.env.GITHUB_WORKSPACE;
190992
- if (fromEnv) {
190993
- _repoRoot = fromEnv;
190994
- return _repoRoot;
190995
- }
190996
- try {
190997
- _repoRoot = spawnSync6("git", ["rev-parse", "--show-toplevel"], {
190998
- cwd: process.cwd(),
190999
- stdio: ["ignore", "pipe", "ignore"],
191000
- encoding: "utf-8"
191001
- }).stdout?.trim();
191002
- } catch {
191003
- }
191004
- if (!_repoRoot) _repoRoot = process.cwd();
191005
- return _repoRoot;
191006
- }
191007
- function spawnShell(params) {
191008
- const spawnOpts = { env: params.env, cwd: params.cwd, stdio: params.stdio, detached: true };
191009
- const sandboxMethod = detectSandboxMethod();
191010
- const ci = process.env.CI === "true";
191011
- if (ci && sandboxMethod === "none") {
191012
- throw new Error(
191013
- "PID-namespace isolation is required in CI but unavailable: unprivileged unshare, sudo unshare, and userns-nested unshare all failed. " + (usernsProbeError ? `userns probe said: ${usernsProbeError}. ` : "") + "on a containerized / Kubernetes self-hosted runner the cause is usually one of: the pod's seccomp profile blocks user-namespace creation (Pod Security Standards baseline/restricted); the pod runs as root without CAP_SETFCAP, which the kernel requires to map uid 0; or the node kernel disallows unprivileged user namespaces (Ubuntu >= 23.10, Bottlerocket, Talos). elsewhere, check that `unshare` and `setpriv` (util-linux >= 2.31, which is what added --ambient-caps) are on PATH. see https://docs.pullfrog.com/security#self-hosted-runners"
191014
- );
191015
- }
191016
- const repoRoot = resolveRepoRoot();
191017
- const fsMounts = buildFsMounts(repoRoot);
191018
- if (sandboxMethod === "unshare") {
191019
- return spawn3(
191020
- "unshare",
191021
- [
191022
- "--pid",
191023
- "--fork",
191024
- "--mount-proc",
191025
- "bash",
191026
- "-c",
191027
- `${PROC_CLEANUP} ${SOCKET_CLEANUP} ${fsMounts} ${params.command}`
191028
- ],
191029
- spawnOpts
191030
- );
191031
- }
191032
- if (sandboxMethod === "userns-unshare") {
191033
- const escaped = params.command.replace(/'/g, "'\\''");
191034
- return spawn3(
191035
- "unshare",
191036
- [
191037
- "--user",
191038
- "--map-root-user",
191039
- "--pid",
191040
- "--fork",
191041
- "--mount",
191042
- "bash",
191043
- "-c",
191044
- `${PROC_CLEANUP} ${SOCKET_CLEANUP} ${fsMounts} exec setpriv --no-new-privs --bounding-set -all --inh-caps -all --ambient-caps -all bash -c '${escaped}'`
191045
- ],
191046
- spawnOpts
191047
- );
191048
- }
191049
- if (sandboxMethod === "sudo-unshare") {
191050
- const envArgs = [];
191051
- for (const [k2, v] of Object.entries(params.env)) {
191052
- if (v !== void 0) {
191053
- envArgs.push(`${k2}=${v}`);
191054
- }
191055
- }
191056
- const username = userInfo2().username;
191057
- const pathRestore = 'export PATH="${SANDBOX_PATH:-$PATH}"; ';
191058
- const escaped = (pathRestore + params.command).replace(/'/g, "'\\''");
191059
- envArgs.push(`SANDBOX_PATH=${params.env.PATH ?? ""}`);
191060
- return spawn3(
191061
- "sudo",
191062
- [
191063
- "env",
191064
- ...envArgs,
191065
- "unshare",
191066
- "--pid",
191067
- "--fork",
191068
- "--mount-proc",
191069
- "bash",
191070
- "-c",
191071
- `${PROC_CLEANUP} ${SOCKET_CLEANUP} ${fsMounts} exec su -p -s /bin/bash ${username} -c '${escaped}'`
191072
- ],
191073
- { ...spawnOpts, env: {} }
191074
- );
191075
- }
191076
- return spawn3("bash", ["-c", params.command], spawnOpts);
191077
- }
191078
- async function killProcessGroup(proc) {
191079
- if (!proc.pid) return;
191080
- try {
191081
- process.kill(-proc.pid, "SIGTERM");
191082
- await new Promise((r) => setTimeout(r, 200));
191083
- process.kill(-proc.pid, "SIGKILL");
191084
- } catch {
191085
- try {
191086
- proc.kill("SIGKILL");
191087
- } catch {
191088
- }
191089
- }
191090
- }
191091
- function getTempDir() {
191092
- const tempDir = process.env.PULLFROG_TEMP_DIR;
191093
- if (!tempDir) {
191094
- throw new Error("PULLFROG_TEMP_DIR not set");
191095
- }
191096
- return tempDir;
191097
- }
191098
- var MAX_OUTPUT_CHARS = 5e3;
191099
- function capOutput(output) {
191100
- if (output.length <= MAX_OUTPUT_CHARS) return output;
191101
- const fullPath = join20(getTempDir(), `shell-${randomUUID4().slice(0, 8)}.log`);
191102
- writeFileSync12(fullPath, output);
191103
- const elided = output.length - MAX_OUTPUT_CHARS;
191104
- return `... [${elided} chars truncated; full output saved to ${fullPath}] ...
191105
- ${output.slice(-MAX_OUTPUT_CHARS)}`;
191106
- }
191107
- function isGitCommand(command) {
191108
- const trimmed = command.trim();
191109
- if (trimmed === "git" || trimmed.startsWith("git ")) return true;
191110
- if (trimmed.startsWith("sudo git")) return true;
191111
- return /[;&|]\s*(?:sudo\s+)?git(?:\s|$)/.test(trimmed);
191112
- }
191113
- function ShellTool(ctx) {
191114
- return tool({
191115
- name: "shell",
191116
- timeoutMs: 12e4,
191117
- description: `Execute shell commands securely. Environment is filtered to remove API keys and secrets.
191118
-
191119
- Example: \`shell({ command: "pnpm test", description: "run the test suite" })\`.
191120
-
191121
- Use this tool to:
191122
- - Run shell commands (ls, cat, grep, find, etc.)
191123
- - Execute build tools (npm, pnpm, cargo, make, etc.)
191124
- - Run tests and linters
191125
-
191126
- Output is capped at ${MAX_OUTPUT_CHARS} chars: if exceeded, only the tail is returned and the full body is saved to a tempfile (path included in the response). Re-read the tempfile with cat/tail/grep when you need more.
191127
-
191128
- Do NOT use this tool for git commands \u2014 use the dedicated git tools instead.`,
191129
- parameters: ShellParams,
191130
- execute: execute(async (params) => {
191131
- if (isGitCommand(params.command)) {
191132
- throw new Error(
191133
- 'git commands are not allowed in the shell tool. use the dedicated git tools instead:\n- git: local operations, e.g. git({ command: "log", args: ["--oneline"] }) \u2014 `command` is the subcommand ONLY\n- push_branch: push to remote (handles authentication)\n- git_fetch: fetch from remote (handles authentication)\n- checkout_pr: check out PR branches'
191134
- );
191135
- }
191136
- const timeout = Math.min(params.timeout ?? 3e4, 12e4);
191137
- const cwd = params.working_directory ?? process.cwd();
191138
- const env2 = resolveEnv(ctx.payload.shell === "enabled" ? "inherit" : "restricted");
191139
- if (params.command.includes("agent-browser")) {
191140
- const daemonError = ensureBrowserDaemon(ctx.toolState);
191141
- if (daemonError) {
191142
- return {
191143
- output: `browser daemon unavailable: ${daemonError}`,
191144
- exit_code: 1,
191145
- timed_out: false
191146
- };
191147
- }
191148
- const binDir = ctx.toolState.browserDaemon?.binDir;
191149
- if (binDir) {
191150
- env2.PATH = `${binDir}:${env2.PATH ?? ""}`;
191151
- }
191152
- }
191153
- if (params.background) {
191154
- const tempDir = getTempDir();
191155
- const handle = `bg-${randomUUID4().slice(0, 8)}`;
191156
- const outputPath = join20(tempDir, `${handle}.log`);
191157
- const pidPath = join20(tempDir, `${handle}.pid`);
191158
- const logFd = openSync(outputPath, "a");
191159
- let proc2;
191160
- try {
191161
- proc2 = spawnShell({
191162
- command: params.command,
191163
- env: env2,
191164
- cwd,
191165
- stdio: ["ignore", logFd, logFd]
191166
- });
191167
- } finally {
191168
- closeSync(logFd);
191169
- }
191170
- if (!proc2.pid) {
191171
- throw new Error("failed to start background process");
191172
- }
191173
- proc2.unref();
191174
- writeFileSync12(pidPath, `${proc2.pid}
191175
- `);
191176
- ctx.toolState.backgroundProcesses.set(handle, { pid: proc2.pid, outputPath, pidPath });
191177
- return {
191178
- handle,
191179
- outputPath,
191180
- pidPath,
191181
- message: `started background process ${handle} (pid ${proc2.pid})`
191182
- };
191183
- }
191184
- const proc = spawnShell({
191185
- command: params.command,
191186
- env: env2,
191187
- cwd,
191188
- stdio: ["ignore", "pipe", "pipe"]
191189
- });
191190
- let stdout = "", stderr = "", timedOut = false, exited = false;
191191
- proc.stdout?.on("data", (chunk) => {
191192
- stdout += chunk.toString();
191193
- });
191194
- proc.stderr?.on("data", (chunk) => {
191195
- stderr += chunk.toString();
191196
- });
191197
- const timeoutId = setTimeout(async () => {
191198
- if (!exited) {
191199
- timedOut = true;
191200
- await killProcessGroup(proc);
191201
- }
191202
- }, timeout);
191203
- const exitCode = await new Promise((resolve3) => {
191204
- const done = (code) => {
191205
- exited = true;
191206
- clearTimeout(timeoutId);
191207
- resolve3(code);
191208
- };
191209
- proc.on("exit", done);
191210
- proc.on("error", () => done(null));
191211
- });
191212
- proc.stdout?.destroy();
191213
- proc.stderr?.destroy();
191214
- let output = stderr ? stdout ? `${stdout}
191215
- ${stderr}` : stderr : stdout;
191216
- if (timedOut)
191217
- output = output ? `${output}
191218
- [timed out after ${timeout}ms]` : `[timed out after ${timeout}ms]`;
191219
- const finalExitCode = exitCode ?? (timedOut ? 124 : -1);
191220
- const trimmed = output.trim();
191221
- if (finalExitCode !== 0) {
191222
- log.info(`shell command failed with exit code ${finalExitCode}: ${params.command}`);
191223
- if (trimmed) log.info(`output: ${trimmed}`);
191224
- }
191225
- return {
191226
- output: capOutput(trimmed),
191227
- exit_code: finalExitCode,
191228
- timed_out: timedOut
191229
- };
191230
- })
191231
- });
191232
- }
191233
- var KillBackgroundParams = type({
191234
- handle: type.string.describe("The handle of the background process to kill (e.g., bg-a1b2c3d4)")
191235
- });
191236
- function KillBackgroundTool(ctx) {
191237
- return tool({
191238
- name: "kill_background",
191239
- mutates: true,
191240
- description: `Kill a background process by its handle. Use this to stop dev servers or other long-running processes started with shell({ background: true }).`,
191241
- parameters: KillBackgroundParams,
191242
- execute: execute(async (params) => {
191243
- const proc = ctx.toolState.backgroundProcesses.get(params.handle);
191244
- if (!proc) {
191245
- return {
191246
- success: false,
191247
- message: `no background process with handle ${params.handle}`
191248
- };
191249
- }
191250
- try {
191251
- process.kill(-proc.pid, "SIGTERM");
191252
- } catch {
191253
- }
191254
- await sleep3(200);
191255
- try {
191256
- process.kill(-proc.pid, "SIGKILL");
191257
- } catch {
191258
- }
191259
- ctx.toolState.backgroundProcesses.delete(params.handle);
191260
- return {
191261
- success: true,
191262
- message: `killed background process ${params.handle} (pid ${proc.pid})`
191263
- };
191264
- })
191265
- });
191266
- }
191267
-
191268
191388
  // mcp/similarIssues.ts
191269
191389
  var SimilarIssues = type({
191270
191390
  issue_number: type.number.describe("The issue number to find older duplicate candidates for")
@@ -191352,8 +191472,8 @@ function UploadFileTool(ctx) {
191352
191472
  }
191353
191473
 
191354
191474
  // mcp/xrepo.ts
191355
- import { mkdirSync as mkdirSync8, rmSync as rmSync2 } from "node:fs";
191356
- import { join as join21 } from "node:path";
191475
+ import { mkdirSync as mkdirSync9, rmSync as rmSync2 } from "node:fs";
191476
+ import { join as join22 } from "node:path";
191357
191477
  function assertValidRepoName(name) {
191358
191478
  if (!/^[A-Za-z0-9._-]+$/.test(name) || name.startsWith("-") || name.includes("..")) {
191359
191479
  throw new Error(
@@ -191433,8 +191553,8 @@ function CheckoutRepoTool(ctx) {
191433
191553
  return { path: existing.dir, access: existing.access, note: "already checked out." };
191434
191554
  }
191435
191555
  const access = accessFor(ctx, repo);
191436
- const dir = join21(ctx.tmpdir, "xrepo", repo);
191437
- mkdirSync8(dir, { recursive: true });
191556
+ const dir = join22(ctx.tmpdir, "xrepo", repo);
191557
+ mkdirSync9(dir, { recursive: true });
191438
191558
  const state = ensureRepoState(ctx.toolState, { owner, name: repo, dir, access });
191439
191559
  const rc = resolveRepoCtx(ctx, repo);
191440
191560
  try {
@@ -191512,8 +191632,8 @@ function buildCommonTools(ctx, outputSchema) {
191512
191632
  EditCommentTool(ctx),
191513
191633
  ReplyToReviewCommentTool(ctx),
191514
191634
  IssueTool(ctx),
191515
- CloseIssueTool(ctx),
191516
- ReopenIssueTool(ctx),
191635
+ CloseCurrentTool(ctx),
191636
+ ReopenCurrentTool(ctx),
191517
191637
  IssueInfoTool(ctx),
191518
191638
  GetIssueCommentsTool(ctx),
191519
191639
  GetIssueEventsTool(ctx),
@@ -191545,6 +191665,9 @@ function buildCommonTools(ctx, outputSchema) {
191545
191665
  tools.push(ShellTool(ctx));
191546
191666
  tools.push(KillBackgroundTool(ctx));
191547
191667
  }
191668
+ if (ctx.ghToken) {
191669
+ tools.push(GhTool(ctx));
191670
+ }
191548
191671
  return tools;
191549
191672
  }
191550
191673
  function buildOrchestratorTools(ctx, outputSchema) {
@@ -191556,8 +191679,7 @@ function buildOrchestratorTools(ctx, outputSchema) {
191556
191679
  PushTagsTool(ctx),
191557
191680
  DeleteBranchTool(ctx),
191558
191681
  CreatePullRequestTool(ctx),
191559
- UpdatePullRequestBodyTool(ctx),
191560
- ClosePullRequestTool(ctx)
191682
+ UpdatePullRequestBodyTool(ctx)
191561
191683
  ];
191562
191684
  if (ctx.signedCommits) {
191563
191685
  tools.push(CommitChangesTool(ctx));
@@ -191647,6 +191769,10 @@ async function startMcpHttpServer(ctx, options) {
191647
191769
  let disposed = false;
191648
191770
  return {
191649
191771
  url: startResult.url,
191772
+ // the names actually registered, handed back so the prompt can be checked
191773
+ // against them rather than against a second derivation. see
191774
+ // assertPromptToolRefs.
191775
+ toolNames: tools.map((registered) => registered.name),
191650
191776
  [Symbol.asyncDispose]: async () => {
191651
191777
  if (disposed) return;
191652
191778
  disposed = true;
@@ -192334,7 +192460,7 @@ async function checkConfiguredCredentials(params) {
192334
192460
  import { randomUUID as randomUUID5 } from "node:crypto";
192335
192461
  import { writeFileSync as writeFileSync13 } from "node:fs";
192336
192462
  import { createServer as createServer3 } from "node:http";
192337
- import { join as join22 } from "node:path";
192463
+ import { join as join23 } from "node:path";
192338
192464
  var REVOKED_TRAP_MS = 6e4;
192339
192465
  function revokeGitHubToken(token) {
192340
192466
  fetch("https://api.github.com/installation/token", {
@@ -192403,7 +192529,7 @@ async function startGitAuthServer(tmpdir3) {
192403
192529
  function writeAskpassScript(code) {
192404
192530
  const scriptId = randomUUID5();
192405
192531
  const scriptName = `askpass-${scriptId}.js`;
192406
- const scriptPath = join22(tmpdir3, scriptName);
192532
+ const scriptPath = join23(tmpdir3, scriptName);
192407
192533
  const content = [
192408
192534
  `#!/usr/bin/env node`,
192409
192535
  `var a=process.argv[2]||"";`,
@@ -192855,7 +192981,7 @@ function resolveInstructions(ctx) {
192855
192981
 
192856
192982
  // utils/learnings.ts
192857
192983
  import { mkdir as mkdir2, readFile as readFile3, writeFile as writeFile2 } from "node:fs/promises";
192858
- import { dirname as dirname4, join as join23 } from "node:path";
192984
+ import { dirname as dirname4, join as join24 } from "node:path";
192859
192985
 
192860
192986
  // utils/learningsTruncate.ts
192861
192987
  var MAX_LEARNINGS_LENGTH = 1e5;
@@ -192873,10 +192999,10 @@ function truncateAtLineBoundary(body, cap) {
192873
192999
  var LEARNINGS_FILE_NAME = "pullfrog-learnings.md";
192874
193000
  var XREPO_LEARNINGS_FILE_NAME = "pullfrog-xrepo-learnings.md";
192875
193001
  function learningsFilePath(tmpdir3) {
192876
- return join23(tmpdir3, LEARNINGS_FILE_NAME);
193002
+ return join24(tmpdir3, LEARNINGS_FILE_NAME);
192877
193003
  }
192878
193004
  function xrepoLearningsFilePath(tmpdir3) {
192879
- return join23(tmpdir3, XREPO_LEARNINGS_FILE_NAME);
193005
+ return join24(tmpdir3, XREPO_LEARNINGS_FILE_NAME);
192880
193006
  }
192881
193007
  async function seedLearningsFile(params) {
192882
193008
  const path4 = learningsFilePath(params.tmpdir);
@@ -193420,6 +193546,9 @@ async function resolveTokens(params) {
193420
193546
  mcpToken: externalToken,
193421
193547
  // external token is whatever scope the user granted — reuse for reads too.
193422
193548
  readToken: params.xrepo ? externalToken : void 0,
193549
+ // the scope is the user's to choose here, but the role threshold still
193550
+ // decides whether the agent gets a CLI credential at all.
193551
+ ghToken: mirrorRolePermissions(params.authorPermission) ? externalToken : void 0,
193423
193552
  async [Symbol.asyncDispose]() {
193424
193553
  mcpTokenValue = void 0;
193425
193554
  }
@@ -193451,6 +193580,15 @@ async function resolveTokens(params) {
193451
193580
  log.info(
193452
193581
  `\xBB acquired scoped MCP token (${Object.entries(mcpPermissions).map((e) => e.join(":")).join(", ")})`
193453
193582
  );
193583
+ const ghPermissions = mirrorRolePermissions(params.authorPermission);
193584
+ let ghToken;
193585
+ if (ghPermissions) {
193586
+ ghToken = await acquireNewToken({ permissions: ghPermissions });
193587
+ if (isGitHubActions) core7.setSecret(ghToken);
193588
+ log.info(
193589
+ `\xBB acquired gh token mirroring ${params.authorPermission} (${formatPermissions(ghPermissions)})`
193590
+ );
193591
+ }
193454
193592
  let readToken;
193455
193593
  if (params.xrepo) {
193456
193594
  readToken = await acquireNewToken({
@@ -193540,7 +193678,9 @@ async function resolveTokens(params) {
193540
193678
  await Promise.all([
193541
193679
  revokeGitHubInstallationToken(currentGitToken),
193542
193680
  revokeGitHubInstallationToken(currentMcpToken),
193543
- ...currentReadToken ? [revokeGitHubInstallationToken(currentReadToken)] : []
193681
+ ...currentReadToken ? [revokeGitHubInstallationToken(currentReadToken)] : [],
193682
+ // revoking this one is what bounds a `gh` leak to the run's lifetime
193683
+ ...ghToken ? [revokeGitHubInstallationToken(ghToken)] : []
193544
193684
  ]);
193545
193685
  } finally {
193546
193686
  removeSignalHandler();
@@ -193555,6 +193695,7 @@ async function resolveTokens(params) {
193555
193695
  },
193556
193696
  mcpToken,
193557
193697
  readToken,
193698
+ ghToken,
193558
193699
  refreshGitToken,
193559
193700
  [Symbol.asyncDispose]: dispose
193560
193701
  };
@@ -193796,7 +193937,7 @@ async function runProxyResolution(ctx) {
193796
193937
 
193797
193938
  // utils/prSummary.ts
193798
193939
  import { mkdir as mkdir3, readFile as readFile4, writeFile as writeFile3 } from "node:fs/promises";
193799
- import { dirname as dirname5, join as join24 } from "node:path";
193940
+ import { dirname as dirname5, join as join25 } from "node:path";
193800
193941
  var SUMMARY_FILE_NAME = "pullfrog-summary.md";
193801
193942
  var SUMMARY_SCAFFOLD = `# PR summary
193802
193943
 
@@ -193806,7 +193947,7 @@ var SUMMARY_SCAFFOLD = `# PR summary
193806
193947
  var MIN_SNAPSHOT_LENGTH = 60;
193807
193948
  var MAX_SNAPSHOT_LENGTH = 32768;
193808
193949
  function summaryFilePath(tmpdir3) {
193809
- return join24(tmpdir3, SUMMARY_FILE_NAME);
193950
+ return join25(tmpdir3, SUMMARY_FILE_NAME);
193810
193951
  }
193811
193952
  async function seedSummaryFile(params) {
193812
193953
  const path4 = summaryFilePath(params.tmpdir);
@@ -194028,7 +194169,10 @@ function warnIfPinnedToSha() {
194028
194169
  }
194029
194170
  }
194030
194171
  async function resolveRunContextData(params) {
194031
- log.info(`\xBB running Pullfrog v${package_default.version}...`);
194172
+ const actionRef = process.env.GITHUB_ACTION_REF;
194173
+ log.info(
194174
+ `\xBB running Pullfrog v${package_default.version}${actionRef ? ` (ref: ${actionRef})` : ""}...`
194175
+ );
194032
194176
  warnIfPinnedToSha();
194033
194177
  const repoContext = parseRepoContext();
194034
194178
  let oidcToken;
@@ -195118,6 +195262,7 @@ async function main() {
195118
195262
  try {
195119
195263
  const _commentTokenRef = __using(_stack, await resolveTokens({
195120
195264
  push: "disabled",
195265
+ authorPermission: void 0,
195121
195266
  oidc: oidcCredentials
195122
195267
  }), true);
195123
195268
  const errorMessage = runContext.commercialRefused === "subscription_unpaid" ? "Pro renewal failed for this organization" : "Pro plan required for this organization";
@@ -195136,6 +195281,8 @@ async function main() {
195136
195281
  }
195137
195282
  }
195138
195283
  const tmpdir3 = createTempDirectory();
195284
+ const repoDir = payload.cwd;
195285
+ const preIntrospectionDirty = repoDir ? await dirtyTrackedPaths({ cwd: repoDir }) : null;
195139
195286
  const opencodeCliPath = await agents.opencode.install();
195140
195287
  captureBaselineModels(opencodeCliPath);
195141
195288
  if (runContext.dbSecrets) {
@@ -195150,6 +195297,13 @@ async function main() {
195150
195297
  }
195151
195298
  installCodexAuth();
195152
195299
  captureAuthorizedModels(opencodeCliPath);
195300
+ if (preIntrospectionDirty && repoDir) {
195301
+ await restoreDirtiedSince({
195302
+ before: preIntrospectionDirty,
195303
+ actor: "model introspection",
195304
+ cwd: repoDir
195305
+ });
195306
+ }
195153
195307
  if (runContext.repoSettings.envAllowlist) {
195154
195308
  setEnvAllowlist(runContext.repoSettings.envAllowlist);
195155
195309
  }
@@ -195161,6 +195315,7 @@ async function main() {
195161
195315
  }
195162
195316
  const tokenRef = __using(_stack3, await resolveTokens({
195163
195317
  push: payload.push,
195318
+ authorPermission: payload.event.authorPermission,
195164
195319
  xrepo: payload.xrepo,
195165
195320
  oidc: oidcCredentials
195166
195321
  }), true);
@@ -195327,6 +195482,7 @@ async function main() {
195327
195482
  },
195328
195483
  refreshGitToken: tokenRef.refreshGitToken,
195329
195484
  readToken: tokenRef.readToken,
195485
+ ghToken: tokenRef.ghToken,
195330
195486
  xrepo: payload.xrepo,
195331
195487
  apiToken: runContext.apiToken,
195332
195488
  modes: modes2,
@@ -195418,6 +195574,11 @@ async function main() {
195418
195574
  xrepoLearningsFilePath: toolState.xrepoLearningsFilePath ?? null,
195419
195575
  xrepoLearningsHeadings: runContext.repoSettings.xrepoLearningsHeadings
195420
195576
  });
195577
+ assertPromptToolRefs({
195578
+ agentId,
195579
+ prompt: instructions.full,
195580
+ toolNames: mcpHttpServer.toolNames
195581
+ });
195421
195582
  const logParts = [
195422
195583
  instructions.eventInstructions ? `EVENT-LEVEL INSTRUCTIONS:
195423
195584
  ${instructions.eventInstructions}` : null,
@@ -195432,7 +195593,7 @@ ${instructions.user}` : null,
195432
195593
  log.info(instructions.full);
195433
195594
  });
195434
195595
  if (agentId === "opencode") {
195435
- const pluginDir = join25(process.cwd(), ".opencode", "plugin");
195596
+ const pluginDir = join26(process.cwd(), ".opencode", "plugin");
195436
195597
  const hasPlugins = existsSync8(pluginDir) && readdirSync2(pluginDir).some((f) => /\.[jt]sx?$/.test(f));
195437
195598
  if (hasPlugins && toolState.dependencyInstallation?.promise) {
195438
195599
  log.info(