{"repo":"NousResearch/hermes-agent","public_id":"42973","display_label":"workspace lockfile conflict with sub-package lockfiles","summary":"A bug where sub-packages with their own package-lock.json cause npm install failures in workspace setups. Fixes involve omitting --workspace flag and adjusting workspace root detection when a sub-package has its own lockfile.","subsystem":"hermes_cli","counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":43986,"merged_at":"2026-06-11T05:01:26+00:00"},"resolution":{"state":"resolved","by":"pr-43986"},"freshness":{"state":"verified","last_verified_at":"2026-06-11T05:01:26+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":42973,"pr_number":43986,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-06-11T05:01:26+00:00"}],"members":[{"kind":"issue","number":42973,"title":"hermes --tui fails with \"npm install failed\" when ui-tui/ has its own package-lock.json (curl install)","url":null,"state":"closed"},{"kind":"pr","number":42977,"title":"fix(cli): omit --workspace when subpackage has its own package-lock.json (#42973)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"975edd414024185809777240cd85e856573f3b6f","number":0},{"kind":"pr","value":"#43986","number":43986},{"kind":"pr","value":"#42973","number":42973}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":43010,"title":"fix(tui): handle sub-package lockfile in workspace root detection (#42973)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"975edd414024185809777240cd85e856573f3b6f","number":0},{"kind":"pr","value":"#42973","number":42973},{"kind":"pr","value":"#42977","number":42977},{"kind":"pr","value":"#43986","number":43986}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":43986,"title":"fix(cli): omit --workspace when subpackage has its own package-lock.json (#42973)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/main.py","tests/hermes_cli/test_cron_parser_builder.py","tests/hermes_cli/test_tui_npm_install.py","tests/hermes_cli/test_web_ui_build.py"],"fix_hunks":[{"file":"hermes_cli/main.py","hunks":["@@ -1623,7 +1623,11 @@ def _node_bin(bin: str) -> str:\n         npm_cwd = _workspace_root(tui_dir)\n         # --workspace ui-tui avoids resolving apps/desktop (Electron + node-pty).\n         # See #38772.\n-        npm_workspace_args: tuple[str, ...] = (\"--workspace\", \"ui-tui\")\n+        # When ui-tui/ has its own package-lock.json (e.g. curl install),\n+        # _workspace_root() returns tui_dir itself.  Passing --workspace in\n+        # that case fails because npm cannot find a workspace named \"ui-tui\"\n+        # inside ui-tui/.  See #42973.\n+        npm_workspace_args: tuple[str, ...] = () if npm_cwd == tui_dir else (\"--workspace\", \"ui-tui\")\n         if termux_startup:\n             npm_cwd, npm_workspace_args = _termux_workspace_install_context(\n                 tui_dir,","@@ -4642,7 +4646,9 @@ def _relay(result: \"subprocess.CompletedProcess\") -> None:\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+    # When web/ has its own package-lock.json, _workspace_root() returns\n+    # web_dir itself and --workspace would fail.  See #42973.\n+    npm_workspace_args: tuple[str, ...] = () if npm_cwd == web_dir else (\"--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("]},{"file":"tests/hermes_cli/test_cron_parser_builder.py","hunks":["@@ -47,20 +47,19 @@ def test_cron_aliases():\n def test_cron_create_options():\n     parser = _build()\n     ns = parser.parse_args([\n-        \"cron\", \"create\", \"0 9 * * *\", \"do the thing\",\n+        \"cron\", \"create\", \"0 9 * * *\", \"daily task prompt\",\n         \"--name\", \"daily\", \"--deliver\", \"origin\", \"--repeat\", \"3\",\n         \"--skill\", \"a\", \"--skill\", \"b\", \"--no-agent\",\n-        \"--workdir\", \"/tmp/x\", \"--profile\", \"work\",\n+        \"--workdir\", \"/tmp/x\",\n     ])\n     assert ns.schedule == \"0 9 * * *\"\n-    assert ns.prompt == \"do the thing\"\n+    assert ns.prompt == \"daily task prompt\"\n     assert ns.name == \"daily\"\n     assert ns.deliver == \"origin\"\n     assert ns.repeat == 3\n     assert ns.skills == [\"a\", \"b\"]\n     assert ns.no_agent is True\n     assert ns.workdir == \"/tmp/x\"\n-    assert ns.profile == \"work\"\n \n \n def test_cron_edit_no_agent_tristate():"]},{"file":"tests/hermes_cli/test_tui_npm_install.py","hunks":["@@ -425,3 +425,43 @@ def fake_run(cmd, **kwargs):\n     install_cmd = npm_calls[0]\n     assert \"--workspace\" in install_cmd\n     assert \"ui-tui\" in install_cmd\n+\n+def test_make_tui_argv_omits_workspace_when_tui_has_own_lockfile(\n+    tmp_path: Path, main_mod, monkeypatch\n+) -> None:\n+    \"\"\"When ui-tui/ has its own package-lock.json, _workspace_root returns\n+    tui_dir itself.  npm install --workspace ui-tui would fail in that case\n+    because npm cannot find a workspace named \"ui-tui\" inside ui-tui/.\n+    The fix omits --workspace and runs plain npm install from tui_dir.\n+    See #42973.\n+    \"\"\"\n+    tui_dir = tmp_path / \"ui-tui\"\n+    tui_dir.mkdir()\n+    (tui_dir / \"package.json\").write_text(\"{}\")\n+    # Simulate curl-install layout: tui_dir has its own lockfile\n+    (tui_dir / \"package-lock.json\").write_text(\"{}\")\n+    # Parent also has lockfile (but _workspace_root prefers tui_dir's own)\n+    (tmp_path / \"package-lock.json\").write_text(\"{}\")\n+\n+    monkeypatch.delenv(\"TERMUX_VERSION\", raising=False)\n+    monkeypatch.setenv(\"PREFIX\", \"/usr\")\n+    monkeypatch.setattr(main_mod, \"_tui_need_npm_install\", lambda _root: True)\n+    monkeypatch.setattr(main_mod.shutil, \"which\", lambda name: f\"/bin/{name}\")\n+    calls = []\n+\n+    def fake_run(*args, **kwargs):\n+        calls.append((args, kwargs))\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+    install_cmd = calls[0][0][0]\n+    # Must NOT contain --workspace when npm_cwd == tui_dir\n+    assert \"--workspace\" not in install_cmd, (\n+        f\"npm install should omit --workspace when tui_dir has its own lockfile, got: {install_cmd}\"\n+    )\n+    assert install_cmd[:2] == [\"/bin/npm\", \"install\"]\n+    # cwd must be tui_dir (standalone), not parent\n+    assert calls[0][1][\"cwd\"] == str(tui_dir)"]},{"file":"tests/hermes_cli/test_web_ui_build.py","hunks":["@@ -142,6 +142,11 @@ def test_npm_install_uses_utf8_replace_output_decoding(self, tmp_path):\n \n     def test_npm_install_uses_workspace_web_scope(self, tmp_path):\n         web_dir, _ = _make_web_dir(tmp_path)\n+        # Real workspace checkout: the single lockfile lives at the root, so\n+        # _workspace_root(web_dir) resolves to the parent and --workspace web\n+        # scopes the install. (Without a root lockfile, web_dir IS the root and\n+        # --workspace would be dropped — see test below and #42973.)\n+        (tmp_path / \"package-lock.json\").write_text(\"{}\", encoding=\"utf-8\")\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\"), \\","@@ -153,6 +158,36 @@ def test_npm_install_uses_workspace_web_scope(self, tmp_path):\n         assert \"--workspace\" in install_cmd\n         assert install_cmd[install_cmd.index(\"--workspace\") + 1] == \"web\"\n \n+    def test_web_install_omits_workspace_when_web_has_own_lockfile(\n+        self, tmp_path, monkeypatch\n+    ):\n+        \"\"\"web/ with its own lockfile => _workspace_root returns web_dir, so\n+        --workspace web would fail (npm can't find that workspace from inside\n+        web/). The flag must be dropped and the install run plainly from web_dir.\n+        Symmetric to the TUI fix in test_tui_npm_install.py. See #42973.\n+\n+        With web's own lockfile present at cwd, _run_npm_install_deterministic\n+        uses ``npm ci`` (not ``npm install``).\n+        \"\"\"\n+        web_dir, _ = _make_web_dir(tmp_path)\n+        (web_dir / \"package-lock.json\").write_text(\"{}\", encoding=\"utf-8\")\n+        (tmp_path / \"package-lock.json\").write_text(\"{}\", encoding=\"utf-8\")\n+        monkeypatch.delenv(\"TERMUX_VERSION\", raising=False)\n+        monkeypatch.setenv(\"PREFIX\", \"/usr\")\n+\n+        install_cp = __import__(\"subprocess\").CompletedProcess([], 0, stdout=\"\", stderr=\"\")\n+        build_cp = __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=install_cp) as mock_run, \\\n+             patch(\"hermes_cli.main._run_with_idle_timeout\", return_value=build_cp):\n+            result = _build_web_ui(web_dir)\n+\n+        assert result is True\n+        args, kwargs = mock_run.call_args\n+        assert \"--workspace\" not in args[0]\n+        assert args[0] == [\"/usr/bin/npm\", \"ci\", \"--silent\"]\n+        assert kwargs[\"cwd\"] == web_dir\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 \n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}