# NousResearch/hermes-agent · Scope npm install to web workspace only

## Open now

- Nothing open on GitHub.

A bug where npm install commands lacked the --workspace web flag caused issues in multi-workspace repositories. The fix scopes npm install and audit commands to the correct workspace to avoid unintended side effects.

## TL;DR

- 3 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 1 PR(s) closed without merging.
- A fix was merged to main in PR #40543. Verify it addresses your case.
- **Recommended:** read PR #40543 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `resolved` · deciding PR(s): `pr-40543`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.67`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #38358: _build_web_ui() npm install missing --workspace web flag breaks "hermes update" on multi-workspace repos

## Prior & competing PRs

- **#38396: fix(cli): scope web UI npm install to --workspace web**. closed without merge · fix tier `suggested`
  - maintainer direction: category `superseded_by_pr`; implication `superseded`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#40543: fix(install): scope npm installs/audits to avoid pulling in apps/desktop (#38358)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `hermes_cli/doctor.py`, `hermes_cli/main.py`, `hermes_cli/tools_config.py`, `package.json`, `scripts/release.py`, `tests/hermes_cli/test_cmd_update.py`, `tests/hermes_cli/test_tui_npm_install.py`, `tests/hermes_cli/test_web_ui_build.py`
  - fix hunks:
  - `hermes_cli/doctor.py`

    ```diff
    @@ -1454,18 +1454,29 @@ def run_doctor(args):
         # npm audit for all Node.js packages
         _npm_bin = _safe_which("npm")
         if _npm_bin:
    -        npm_dirs = [
    -            (PROJECT_ROOT, "Browser tools (agent-browser)"),
    -            (PROJECT_ROOT / "scripts" / "whatsapp-bridge", "WhatsApp bridge"),
    +        # Each entry: (cwd, label, extra_audit_args)
    +        # PROJECT_ROOT is audited with --workspaces=false so that the apps/*
    +        # glob (which pulls in Electron, node-pty, etc.) is never resolved
    +        # for a routine security check. The web and ui-tui workspaces are
    +        # audited separately via --workspace flags. See #38772.
    +        npm_audit_targets = [
    +            (PROJECT_ROOT, "Browser tools (agent-browser)", ["--workspaces=false"]),
    +            (PROJECT_ROOT, "web workspace", ["--workspace", "web"]),
    +            (PROJECT_ROOT, "ui-tui workspace", ["--workspace", "ui-tui"]),
    +            (PROJECT_ROOT / "scripts" / "whatsapp-bridge", "WhatsApp bridge", []),
             ]
    -        for npm_dir, label in npm_dirs:
    -            if not (npm_dir / "node_modules").exists():
    +        for npm_dir, label, audit_extra in npm_audit_targets:
    +            # For workspace-scoped audits run from PROJECT_ROOT the
    +            # node_modules check must use the workspace root; standalone dirs
    +            # (whatsapp-bridge) check their own node_modules.
    +            check_dir = PROJECT_ROOT if audit_extra else npm_dir
    +            if not (check_dir / "node_modules").exists():
                     continue
                 try:
                     # Use resolved absolute path so Windows can execute
                     # npm.cmd (CreateProcessW can't run bare .cmd names).
                     audit_result = subprocess.run(
    -                    [_npm_bin, "audit", "--json"],
    +                    [_npm_bin, "audit", "--json", *audit_extra],
                         cwd=str(npm_dir),
                         capture_output=True, text=True, timeout=30,
                     )
    ```

    ```diff
    @@ -1476,12 +1487,20 @@ def run_doctor(args):
                     high = vuln_count.get("high", 0)
                     moderate = vuln_count.get("moderate", 0)
                     total = critical + high + moderate
    +                # Determine a scoped fix command for the remediation hint.
    +                if audit_extra and audit_extra[0] == "--workspace":
    +                    fix_scope = " ".join(audit_extra)
    +                    fix_cmd = f"cd {npm_dir} && npm audit fix {fix_scope}"
    +                elif audit_extra == ["--workspaces=false"]:
    +                    fix_cmd = f"cd {npm_dir} && npm audit fix --workspaces=false"
    +                else:
    +                    fix_cmd = f"cd {npm_dir} && npm audit fix"
                     if total == 0:
                         check_ok(f"{label} deps", "(no known vulnerabilities)")
                     elif critical > 0 or high > 0:
                         check_warn(
                             f"{label} deps",
    -                        f"({critical} critical, {high} high, {moderate} moderate — run: cd {npm_dir} && npm audit fix)"
    +                        f"({critical} critical, {high} high, {moderate} moderate — run: {fix_cmd})"
                         )
                         issues.append(
                             f"{label} has {total} npm "
    ```
  - `hermes_cli/main.py`

    ```diff
    @@ -1570,7 +1570,9 @@ def _node_bin(bin: str) -> str:
             if not os.environ.get("HERMES_QUIET"):
                 print("Installing TUI dependencies…")
             npm_cwd = _workspace_root(tui_dir)
    -        npm_workspace_args: tuple[str, ...] = ()
    +        # --workspace ui-tui avoids resolving apps/desktop (Electron + node-pty).
    +        # See #38772.
    +        npm_workspace_args: tuple[str, ...] = ("--workspace", "ui-tui")
             if termux_startup:
                 npm_cwd, npm_workspace_args = _termux_workspace_install_context(
                     tui_dir,
    ```

    ```diff
    @@ -7090,7 +7092,11 @@ def _relay(result: "subprocess.CompletedProcess") -> None:
                     _say(text)
     
         npm_cwd = _workspace_root(web_dir)
    -    npm_workspace_args: tuple[str, ...] = ()
    +    # Scope the install to the web workspace only so that the full workspace
    +    # graph (including apps/desktop with its Electron + node-pty deps) is never
    +    # resolved here.  Without --workspace the root package.json's apps/* glob
    +    # would pull in desktop on every web build. See #38772.
    +    npm_workspace_args: tuple[str, ...] = ("--workspace", "web")
         if _is_termux_startup_environment():
             npm_cwd, npm_workspace_args = _termux_workspace_install_context(web_dir)
         r1 = _run_npm_install_deterministic(
    ```

    ```diff
    @@ -7105,7 +7111,7 @@ def _relay(result: "subprocess.CompletedProcess") -> None:
             )
             _relay(r1)
             if fatal:
    -            _say("  Run manually:  cd web && npm install && npm run build")
    +            _say("  Run manually:  npm install --workspace web && npm run build -w web")
             return False
         # First attempt — stream output via idle-timeout helper (issue #33788).
         # capture_output=True on a long Vite build looks identical to a hang;
    ```

    ```diff
    @@ -7147,7 +7153,7 @@ def _relay(result: "subprocess.CompletedProcess") -> None:
             )
             _relay(r2)
             if fatal:
    -            _say("  Run manually:  cd web && npm install && npm run build")
    +            _say("  Run manually:  npm install --workspace web && npm run build -w web")
             return False
         _say("  ✓ Web UI built")
         return True
    ```

    ```diff
    @@ -12373,7 +12379,7 @@ def cmd_dashboard(args):
             )
             if not (_dist_root / "index.html").exists():
                 print(f"✗ --skip-build was passed but no web dist found at: {_dist_root}")
    -            print("  Pre-build first:  cd web && npm install && npm run build")
    +            print("  Pre-build first:  npm install --workspace web && npm run build -w web")
                 print("  Or drop --skip-build to build automatically.")
                 sys.exit(1)
             print(f"→ Skipping web UI build (--skip-build); using dist at {_dist_root}")
    ```
  - `hermes_cli/tools_config.py`

    ```diff
    @@ -846,14 +846,17 @@ def _run_post_setup(post_setup_key: str):
                 # batch shims).  On POSIX npm_bin is the plain path — same
                 # behaviour as before.
                 result = subprocess.run(
    -                [npm_bin, "install", "--silent"],
    +                # --workspaces=false restricts the install to the repo root
    +                # only, avoiding the apps/* glob which would pull in
    +                # apps/desktop (Electron + node-pty) unnecessarily. See #38772.
    +                [npm_bin, "install", "--silent", "--workspaces=false"],
                     capture_output=True, text=True, cwd=str(PROJECT_ROOT)
                 )
                 if result.returncode == 0:
                     _print_success("    Node.js dependencies installed")
                 else:
                     from hermes_constants import display_hermes_home
    -                _print_warning(f"    npm install failed - run manually: cd {display_hermes_home()}/hermes-agent && npm install")
    +                _print_warning(f"    npm install failed - run manually: cd {display_hermes_home()}/hermes-agent && npm install --workspaces=false")
                     if result.stderr:
                         _print_info(f"      {result.stderr.strip()[:200]}")
             elif not node_modules.exists():
    ```

    ```diff
    @@ -951,13 +954,14 @@ def _run_post_setup(post_setup_key: str):
                 import subprocess
                 # Absolute npm path so .cmd shim executes on Windows.
                 result = subprocess.run(
    -                [_npm_bin, "install", "--silent"],
    +                # --workspaces=false avoids resolving apps/desktop. See #38772.
    +                [_npm_bin, "install", "--silent", "--workspaces=false"],
                     capture_output=True, text=True, cwd=str(PROJECT_ROOT)
                 )
                 if result.returncode == 0:
                     _print_success("    Camofox installed")
                 else:
    -                _print_warning("    npm install failed - run manually: npm install")
    +                _print_warning("    npm install failed - run manually: npm install --workspaces=false")
             if camofox_dir.exists():
                 _print_info("    Start the Camofox server:")
                 _print_info("      npx @askjo/camofox-browser")
    ```
  - `package.json`

    ```diff
    @@ -10,7 +10,17 @@
         "web"
       ],
       "scripts": {
    -    "postinstall": "echo '✅ Browser tools ready. Run: python run_agent.py --help'"
    +    "postinstall": "echo '✅ Browser tools ready. Run: python run_agent.py --help'",
    +    "install:root": "npm install --workspaces=false",
    +    "install:web": "npm install --workspace web",
    +    "install:tui": "npm install --workspace ui-tui",
    +    "install:desktop": "npm install --workspace apps/desktop",
    +    "audit:root": "npm audit --workspaces=false",
    +    "audit:web": "npm audit --workspace web",
    +    "audit:tui": "npm audit --workspace ui-tui",
    +    "audit:fix:root": "npm audit fix --workspaces=false",
    +    "audit:fix:web": "npm audit fix --workspace web",
    +    "audit:fix:tui": "npm audit fix --workspace ui-tui"
       },
       "repository": {
         "type": "git",
    ```
  - `scripts/release.py`

    ```diff
    @@ -46,6 +46,7 @@
     # Auto-extracted from noreply emails + manual overrides
     AUTHOR_MAP = {
         "[redacted:email]": "randomsnowflake",
    +    "[redacted:email]": "zakame",
         "[redacted:email]": "ViewWay",
         "[redacted:email]": "harjothkhara",
         "129007007+HeLLGURD@users.noreply.github.com": "HeLLGURD",
    ```
  - `tests/hermes_cli/test_cmd_update.py`

    ```diff
    @@ -272,7 +272,7 @@ def test_update_refreshes_repo_and_tui_node_dependencies(
                 # The web/ install runs from the workspace root when the root
                 # lockfile exists (npm workspaces hoist node_modules upward).
                 assert npm_calls[2:] == [
    -                (["/usr/bin/npm", "ci", "--silent"], PROJECT_ROOT),
    +                (["/usr/bin/npm", "ci", "--workspace", "web", "--silent"], PROJECT_ROOT),
                 ]
     
             # The web UI build itself went through the streaming helper.
    ```
  - `tests/hermes_cli/test_tui_npm_install.py`

    ```diff
    @@ -255,6 +255,8 @@ def fake_run(*args, **kwargs):
         assert calls[0][0][0] == [
             "/bin/npm",
             "install",
    +        "--workspace",
    +        "ui-tui",
             "--silent",
             "--no-fund",
             "--no-audit",
    ```

    ```diff
    @@ -390,3 +392,36 @@ def test_no_stray_lockfiles_in_workspace_subdirs(main_mod) -> None:
             "delete them and run `npm install` from the repo root instead: "
             + ", ".join(str(d / "package-lock.json") for d in stray)
         )
    +
    +
    +def test_tui_launch_install_uses_workspace_scope(
    +    tmp_path: Path, main_mod, monkeypatch
    +) -> None:
    +    """TUI launch npm install must pass --workspace ui-tui to avoid pulling apps/desktop."""
    +    tui_dir = tmp_path / "ui-tui"
    +    tui_dir.mkdir()
    +    (tui_dir / "package.json").write_text("{}")
    +    (tui_dir / "dist" / "entry.js").parent.mkdir(parents=True)
    +    (tui_dir / "dist" / "entry.js").write_text("console.log('tui')")
    +    # workspace root: parent has lockfile, tui_dir does not
    +    (tmp_path / "package-lock.json").write_text("{}")
    +
    +    monkeypatch.setattr(main_mod, "_tui_need_npm_install", lambda _root: True)
    +    monkeypatch.setattr(main_mod, "_tui_need_rebuild", lambda _root: False)
    +    monkeypatch.setattr(main_mod.shutil, "which", lambda name: f"/usr/bin/{name}")
    +
    +    npm_calls = []
    +
    +    def fake_run(cmd, **kwargs):
    +        if cmd[0].endswith("npm"):
    +            npm_calls.append(cmd)
    +        return types.SimpleNamespace(returncode=0, stdout="", stderr="")
    +
    +    monkeypatch.setattr(main_mod.subprocess, "run", fake_run)
    +
    +    main_mod._make_tui_argv(tui_dir, tui_dev=False)
    +
    +    assert npm_calls, "expected npm install to be called"
    +    install_cmd = npm_calls[0]
    +    assert "--workspace" in install_cmd
    +    assert "ui-tui" in install_cmd
    ```
  - `tests/hermes_cli/test_web_ui_build.py`

    ```diff
    @@ -140,6 +140,19 @@ def test_npm_install_uses_utf8_replace_output_decoding(self, tmp_path):
             assert kwargs["encoding"] == "utf-8"
             assert kwargs["errors"] == "replace"
     
    +    def test_npm_install_uses_workspace_web_scope(self, tmp_path):
    +        web_dir, _ = _make_web_dir(tmp_path)
    +        mock_cp = __import__("subprocess").CompletedProcess([], 0, stdout="", stderr="")
    +        build_ok = __import__("subprocess").CompletedProcess([], 0, stdout="", stderr="")
    +        with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \
    +             patch("hermes_cli.main.subprocess.run", return_value=mock_cp) as mock_run, \
    +             patch("hermes_cli.main._run_with_idle_timeout", return_value=build_ok):
    +            result = _build_web_ui(web_dir)
    +        assert result is True
    +        install_cmd = mock_run.call_args[0][0]
    +        assert "--workspace" in install_cmd
    +        assert install_cmd[install_cmd.index("--workspace") + 1] == "web"
    +
         def test_web_build_uses_idle_timeout_helper(self, tmp_path):
             """npm run build now goes through _run_with_idle_timeout (issue #33788).
     
    ```

    ```diff
    @@ -205,7 +218,7 @@ def test_desktop_web_install_uses_existing_workspace_root(
     
             assert result is True
             args, kwargs = mock_run.call_args
    -        assert args[0] == ["/usr/bin/npm", "ci", "--silent"]
    +        assert args[0] == ["/usr/bin/npm", "ci", "--workspace", "web", "--silent"]
             assert kwargs["cwd"] == tmp_path
     
     
    ```

## Fix status

- Merged to main in PR #40543 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-06-07`.
