{"repo":"NousResearch/hermes-agent","public_id":"38772","display_label":"TUI/dashboard spurious npm reinstall on every launch","summary":"Multiple bugs and fixes around the TUI and dashboard triggering unnecessary npm installs and rebuilds on every launch, primarily because freshness checks compared against the entire monorepo lockfile rather than the scoped workspace closure. Several fixes attempt to narrow the install and audit scope to avoid pulling in unrelated workspace packages.","subsystem":null,"counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":40543,"merged_at":"2026-06-07T01:22:21+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-06-07T01:22:21+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":38358,"pr_number":40543,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-06-07T01:22:21+00:00"},{"issue_number":38772,"pr_number":40543,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-06-07T01:22:21+00:00"}],"members":[{"kind":"issue","number":38772,"title":"[Bug]: Root package.json workspaces glob unconditionally pulls apps/desktop (and bootstrap-installer) into every unscoped npm command","url":null,"state":"closed"},{"kind":"pr","number":38810,"title":"fix(install): scope npm installs/audits to avoid pulling in apps/desktop","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#40543","number":40543}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":40543,"title":"fix(install): scope npm installs/audits to avoid pulling in apps/desktop (#38358)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["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":[{"file":"hermes_cli/doctor.py","hunks":["@@ -1454,18 +1454,29 @@ def run_doctor(args):\n     # npm audit for all Node.js packages\n     _npm_bin = _safe_which(\"npm\")\n     if _npm_bin:\n-        npm_dirs = [\n-            (PROJECT_ROOT, \"Browser tools (agent-browser)\"),\n-            (PROJECT_ROOT / \"scripts\" / \"whatsapp-bridge\", \"WhatsApp bridge\"),\n+        # Each entry: (cwd, label, extra_audit_args)\n+        # PROJECT_ROOT is audited with --workspaces=false so that the apps/*\n+        # glob (which pulls in Electron, node-pty, etc.) is never resolved\n+        # for a routine security check. The web and ui-tui workspaces are\n+        # audited separately via --workspace flags. See #38772.\n+        npm_audit_targets = [\n+            (PROJECT_ROOT, \"Browser tools (agent-browser)\", [\"--workspaces=false\"]),\n+            (PROJECT_ROOT, \"web workspace\", [\"--workspace\", \"web\"]),\n+            (PROJECT_ROOT, \"ui-tui workspace\", [\"--workspace\", \"ui-tui\"]),\n+            (PROJECT_ROOT / \"scripts\" / \"whatsapp-bridge\", \"WhatsApp bridge\", []),\n         ]\n-        for npm_dir, label in npm_dirs:\n-            if not (npm_dir / \"node_modules\").exists():\n+        for npm_dir, label, audit_extra in npm_audit_targets:\n+            # For workspace-scoped audits run from PROJECT_ROOT the\n+            # node_modules check must use the workspace root; standalone dirs\n+            # (whatsapp-bridge) check their own node_modules.\n+            check_dir = PROJECT_ROOT if audit_extra else npm_dir\n+            if not (check_dir / \"node_modules\").exists():\n                 continue\n             try:\n                 # Use resolved absolute path so Windows can execute\n                 # npm.cmd (CreateProcessW can't run bare .cmd names).\n                 audit_result = subprocess.run(\n-                    [_npm_bin, \"audit\", \"--json\"],\n+                    [_npm_bin, \"audit\", \"--json\", *audit_extra],\n                     cwd=str(npm_dir),\n                     capture_output=True, text=True, timeout=30,\n                 )","@@ -1476,12 +1487,20 @@ def run_doctor(args):\n                 high = vuln_count.get(\"high\", 0)\n                 moderate = vuln_count.get(\"moderate\", 0)\n                 total = critical + high + moderate\n+                # Determine a scoped fix command for the remediation hint.\n+                if audit_extra and audit_extra[0] == \"--workspace\":\n+                    fix_scope = \" \".join(audit_extra)\n+                    fix_cmd = f\"cd {npm_dir} && npm audit fix {fix_scope}\"\n+                elif audit_extra == [\"--workspaces=false\"]:\n+                    fix_cmd = f\"cd {npm_dir} && npm audit fix --workspaces=false\"\n+                else:\n+                    fix_cmd = f\"cd {npm_dir} && npm audit fix\"\n                 if total == 0:\n                     check_ok(f\"{label} deps\", \"(no known vulnerabilities)\")\n                 elif critical > 0 or high > 0:\n                     check_warn(\n                         f\"{label} deps\",\n-                        f\"({critical} critical, {high} high, {moderate} moderate — run: cd {npm_dir} && npm audit fix)\"\n+                        f\"({critical} critical, {high} high, {moderate} moderate — run: {fix_cmd})\"\n                     )\n                     issues.append(\n                         f\"{label} has {total} npm \""]},{"file":"hermes_cli/main.py","hunks":["@@ -1570,7 +1570,9 @@ def _node_bin(bin: str) -> str:\n         if not os.environ.get(\"HERMES_QUIET\"):\n             print(\"Installing TUI dependencies…\")\n         npm_cwd = _workspace_root(tui_dir)\n-        npm_workspace_args: tuple[str, ...] = ()\n+        # --workspace ui-tui avoids resolving apps/desktop (Electron + node-pty).\n+        # See #38772.\n+        npm_workspace_args: tuple[str, ...] = (\"--workspace\", \"ui-tui\")\n         if termux_startup:\n             npm_cwd, npm_workspace_args = _termux_workspace_install_context(\n                 tui_dir,","@@ -7090,7 +7092,11 @@ def _relay(result: \"subprocess.CompletedProcess\") -> None:\n                 _say(text)\n \n     npm_cwd = _workspace_root(web_dir)\n-    npm_workspace_args: tuple[str, ...] = ()\n+    # Scope the install to the web workspace only so that the full workspace\n+    # graph (including apps/desktop with its Electron + node-pty deps) is never\n+    # resolved here.  Without --workspace the root package.json's apps/* glob\n+    # would pull in desktop on every web build. See #38772.\n+    npm_workspace_args: tuple[str, ...] = (\"--workspace\", \"web\")\n     if _is_termux_startup_environment():\n         npm_cwd, npm_workspace_args = _termux_workspace_install_context(web_dir)\n     r1 = _run_npm_install_deterministic(","@@ -7105,7 +7111,7 @@ def _relay(result: \"subprocess.CompletedProcess\") -> None:\n         )\n         _relay(r1)\n         if fatal:\n-            _say(\"  Run manually:  cd web && npm install && npm run build\")\n+            _say(\"  Run manually:  npm install --workspace web && npm run build -w web\")\n         return False\n     # First attempt — stream output via idle-timeout helper (issue #33788).\n     # capture_output=True on a long Vite build looks identical to a hang;","@@ -7147,7 +7153,7 @@ def _relay(result: \"subprocess.CompletedProcess\") -> None:\n         )\n         _relay(r2)\n         if fatal:\n-            _say(\"  Run manually:  cd web && npm install && npm run build\")\n+            _say(\"  Run manually:  npm install --workspace web && npm run build -w web\")\n         return False\n     _say(\"  ✓ Web UI built\")\n     return True","@@ -12373,7 +12379,7 @@ def cmd_dashboard(args):\n         )\n         if not (_dist_root / \"index.html\").exists():\n             print(f\"✗ --skip-build was passed but no web dist found at: {_dist_root}\")\n-            print(\"  Pre-build first:  cd web && npm install && npm run build\")\n+            print(\"  Pre-build first:  npm install --workspace web && npm run build -w web\")\n             print(\"  Or drop --skip-build to build automatically.\")\n             sys.exit(1)\n         print(f\"→ Skipping web UI build (--skip-build); using dist at {_dist_root}\")"]},{"file":"hermes_cli/tools_config.py","hunks":["@@ -846,14 +846,17 @@ def _run_post_setup(post_setup_key: str):\n             # batch shims).  On POSIX npm_bin is the plain path — same\n             # behaviour as before.\n             result = subprocess.run(\n-                [npm_bin, \"install\", \"--silent\"],\n+                # --workspaces=false restricts the install to the repo root\n+                # only, avoiding the apps/* glob which would pull in\n+                # apps/desktop (Electron + node-pty) unnecessarily. See #38772.\n+                [npm_bin, \"install\", \"--silent\", \"--workspaces=false\"],\n                 capture_output=True, text=True, cwd=str(PROJECT_ROOT)\n             )\n             if result.returncode == 0:\n                 _print_success(\"    Node.js dependencies installed\")\n             else:\n                 from hermes_constants import display_hermes_home\n-                _print_warning(f\"    npm install failed - run manually: cd {display_hermes_home()}/hermes-agent && npm install\")\n+                _print_warning(f\"    npm install failed - run manually: cd {display_hermes_home()}/hermes-agent && npm install --workspaces=false\")\n                 if result.stderr:\n                     _print_info(f\"      {result.stderr.strip()[:200]}\")\n         elif not node_modules.exists():","@@ -951,13 +954,14 @@ def _run_post_setup(post_setup_key: str):\n             import subprocess\n             # Absolute npm path so .cmd shim executes on Windows.\n             result = subprocess.run(\n-                [_npm_bin, \"install\", \"--silent\"],\n+                # --workspaces=false avoids resolving apps/desktop. See #38772.\n+                [_npm_bin, \"install\", \"--silent\", \"--workspaces=false\"],\n                 capture_output=True, text=True, cwd=str(PROJECT_ROOT)\n             )\n             if result.returncode == 0:\n                 _print_success(\"    Camofox installed\")\n             else:\n-                _print_warning(\"    npm install failed - run manually: npm install\")\n+                _print_warning(\"    npm install failed - run manually: npm install --workspaces=false\")\n         if camofox_dir.exists():\n             _print_info(\"    Start the Camofox server:\")\n             _print_info(\"      npx @askjo/camofox-browser\")"]},{"file":"package.json","hunks":["@@ -10,7 +10,17 @@\n     \"web\"\n   ],\n   \"scripts\": {\n-    \"postinstall\": \"echo '✅ Browser tools ready. Run: python run_agent.py --help'\"\n+    \"postinstall\": \"echo '✅ Browser tools ready. Run: python run_agent.py --help'\",\n+    \"install:root\": \"npm install --workspaces=false\",\n+    \"install:web\": \"npm install --workspace web\",\n+    \"install:tui\": \"npm install --workspace ui-tui\",\n+    \"install:desktop\": \"npm install --workspace apps/desktop\",\n+    \"audit:root\": \"npm audit --workspaces=false\",\n+    \"audit:web\": \"npm audit --workspace web\",\n+    \"audit:tui\": \"npm audit --workspace ui-tui\",\n+    \"audit:fix:root\": \"npm audit fix --workspaces=false\",\n+    \"audit:fix:web\": \"npm audit fix --workspace web\",\n+    \"audit:fix:tui\": \"npm audit fix --workspace ui-tui\"\n   },\n   \"repository\": {\n     \"type\": \"git\","]},{"file":"scripts/release.py","hunks":["@@ -46,6 +46,7 @@\n # Auto-extracted from noreply emails + manual overrides\n AUTHOR_MAP = {\n     \"[redacted:email]\": \"randomsnowflake\",\n+    \"[redacted:email]\": \"zakame\",\n     \"[redacted:email]\": \"ViewWay\",\n     \"[redacted:email]\": \"harjothkhara\",\n     \"129007007+HeLLGURD@users.noreply.github.com\": \"HeLLGURD\","]},{"file":"tests/hermes_cli/test_cmd_update.py","hunks":["@@ -272,7 +272,7 @@ def test_update_refreshes_repo_and_tui_node_dependencies(\n             # The web/ install runs from the workspace root when the root\n             # lockfile exists (npm workspaces hoist node_modules upward).\n             assert npm_calls[2:] == [\n-                ([\"/usr/bin/npm\", \"ci\", \"--silent\"], PROJECT_ROOT),\n+                ([\"/usr/bin/npm\", \"ci\", \"--workspace\", \"web\", \"--silent\"], PROJECT_ROOT),\n             ]\n \n         # The web UI build itself went through the streaming helper."]},{"file":"tests/hermes_cli/test_tui_npm_install.py","hunks":["@@ -255,6 +255,8 @@ def fake_run(*args, **kwargs):\n     assert calls[0][0][0] == [\n         \"/bin/npm\",\n         \"install\",\n+        \"--workspace\",\n+        \"ui-tui\",\n         \"--silent\",\n         \"--no-fund\",\n         \"--no-audit\",","@@ -390,3 +392,36 @@ def test_no_stray_lockfiles_in_workspace_subdirs(main_mod) -> None:\n         \"delete them and run `npm install` from the repo root instead: \"\n         + \", \".join(str(d / \"package-lock.json\") for d in stray)\n     )\n+\n+\n+def test_tui_launch_install_uses_workspace_scope(\n+    tmp_path: Path, main_mod, monkeypatch\n+) -> None:\n+    \"\"\"TUI launch npm install must pass --workspace ui-tui to avoid pulling apps/desktop.\"\"\"\n+    tui_dir = tmp_path / \"ui-tui\"\n+    tui_dir.mkdir()\n+    (tui_dir / \"package.json\").write_text(\"{}\")\n+    (tui_dir / \"dist\" / \"entry.js\").parent.mkdir(parents=True)\n+    (tui_dir / \"dist\" / \"entry.js\").write_text(\"console.log('tui')\")\n+    # workspace root: parent has lockfile, tui_dir does not\n+    (tmp_path / \"package-lock.json\").write_text(\"{}\")\n+\n+    monkeypatch.setattr(main_mod, \"_tui_need_npm_install\", lambda _root: True)\n+    monkeypatch.setattr(main_mod, \"_tui_need_rebuild\", lambda _root: False)\n+    monkeypatch.setattr(main_mod.shutil, \"which\", lambda name: f\"/usr/bin/{name}\")\n+\n+    npm_calls = []\n+\n+    def fake_run(cmd, **kwargs):\n+        if cmd[0].endswith(\"npm\"):\n+            npm_calls.append(cmd)\n+        return types.SimpleNamespace(returncode=0, stdout=\"\", stderr=\"\")\n+\n+    monkeypatch.setattr(main_mod.subprocess, \"run\", fake_run)\n+\n+    main_mod._make_tui_argv(tui_dir, tui_dev=False)\n+\n+    assert npm_calls, \"expected npm install to be called\"\n+    install_cmd = npm_calls[0]\n+    assert \"--workspace\" in install_cmd\n+    assert \"ui-tui\" in install_cmd"]},{"file":"tests/hermes_cli/test_web_ui_build.py","hunks":["@@ -140,6 +140,19 @@ def test_npm_install_uses_utf8_replace_output_decoding(self, tmp_path):\n         assert kwargs[\"encoding\"] == \"utf-8\"\n         assert kwargs[\"errors\"] == \"replace\"\n \n+    def test_npm_install_uses_workspace_web_scope(self, tmp_path):\n+        web_dir, _ = _make_web_dir(tmp_path)\n+        mock_cp = __import__(\"subprocess\").CompletedProcess([], 0, stdout=\"\", stderr=\"\")\n+        build_ok = __import__(\"subprocess\").CompletedProcess([], 0, stdout=\"\", stderr=\"\")\n+        with patch(\"hermes_cli.main.shutil.which\", return_value=\"/usr/bin/npm\"), \\\n+             patch(\"hermes_cli.main.subprocess.run\", return_value=mock_cp) as mock_run, \\\n+             patch(\"hermes_cli.main._run_with_idle_timeout\", return_value=build_ok):\n+            result = _build_web_ui(web_dir)\n+        assert result is True\n+        install_cmd = mock_run.call_args[0][0]\n+        assert \"--workspace\" in install_cmd\n+        assert install_cmd[install_cmd.index(\"--workspace\") + 1] == \"web\"\n+\n     def test_web_build_uses_idle_timeout_helper(self, tmp_path):\n         \"\"\"npm run build now goes through _run_with_idle_timeout (issue #33788).\n ","@@ -205,7 +218,7 @@ def test_desktop_web_install_uses_existing_workspace_root(\n \n         assert result is True\n         args, kwargs = mock_run.call_args\n-        assert args[0] == [\"/usr/bin/npm\", \"ci\", \"--silent\"]\n+        assert args[0] == [\"/usr/bin/npm\", \"ci\", \"--workspace\", \"web\", \"--silent\"]\n         assert kwargs[\"cwd\"] == tmp_path\n \n \n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}