From 9e3d3b8fb7ea11838c5a4700124fcdac85347b22 Mon Sep 17 00:00:00 2001 From: funkyotc Date: Thu, 27 Aug 2026 22:53:22 +1000 Subject: [PATCH 1/2] fix(init): target the session's active directory, not the process cwd MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /init resolved its project directory via os.getcwd() — the process launch directory — on all three dispatch surfaces (CLI, gateway, TUI/desktop). On the desktop app the process launches from the home directory, so /init scanned the HOME and merge-updated the home directory's AGENTS.md even though the session was attached to a project workspace (its terminal commands ran in the right directory). Resolve the session's ACTIVE directory instead: the terminal tool's per-session cwd record (seeded when a workspace attaches, updated after every cd) first, then resolve_agent_cwd()'s canonical ladder (session contextvar -> TERMINAL_CWD -> process cwd). Dispatch sites pass the session key so multi-session hosts consult the right record. The stale-record guard lives in build_init_prompt_for_cwd: a cwd that names a deleted directory (removed project, linked worktree) falls through to the session ladder on every surface instead of each dispatch site validating its own record. Without it the TUI/desktop path handed a truthy-but-dead record straight through as cwd, so /init targeted a directory that no longer existed — write_file would have recreated it and written a fresh AGENTS.md into it. Regression tests cover: session record wins over the process cwd, stale-record fallback, explicit cwd precedence, ambient session key, a stale explicit cwd falling through to the session record, and the TUI/desktop command.dispatch path end-to-end (live record, and a stale record falling through to the session's attached workspace). Fixes #96376 --- gateway/run_inbound.py | 8 +- hermes_cli/cli_commands_mixin.py | 4 +- hermes_cli/init_command.py | 45 ++++++- tests/hermes_cli/test_init_command.py | 113 ++++++++++++++++++ .../test_init_targets_session_cwd.py | 66 ++++++++++ tui_gateway/methods_tools.py | 19 ++- 6 files changed, 249 insertions(+), 6 deletions(-) create mode 100644 tests/tui_gateway/test_init_targets_session_cwd.py diff --git a/gateway/run_inbound.py b/gateway/run_inbound.py index f6e990badfb07..9acda7d20ae43 100644 --- a/gateway/run_inbound.py +++ b/gateway/run_inbound.py @@ -939,7 +939,13 @@ async def _hm_cmd_init(self, event, source, _quick_key): from hermes_cli.init_command import build_init_prompt_for_cwd try: - _init_prompt = build_init_prompt_for_cwd(extra=event.get_command_args().strip()) + # The SESSION's active directory, not this process's launch dir: the desktop app + # launches the backend from the home directory, so a bare os.getcwd() scanned and + # updated the HOME's AGENTS.md instead of the workspace attached to the session. + _init_prompt = build_init_prompt_for_cwd( + extra=event.get_command_args().strip(), + session_key=_quick_key or self._session_key_for_source(source), + ) except Exception: return True, t("gateway.init.start_failed") _ack = ( diff --git a/hermes_cli/cli_commands_mixin.py b/hermes_cli/cli_commands_mixin.py index 1bbf4c75f9441..0d693a5b9f2f2 100644 --- a/hermes_cli/cli_commands_mixin.py +++ b/hermes_cli/cli_commands_mixin.py @@ -1927,7 +1927,9 @@ def _handle_init_command(self, cmd: str): """Handle /init — generate or update AGENTS.md from a project scan performed by the live agent with its own read-only tools.""" from hermes_cli.init_command import build_init_prompt_for_cwd - msg = build_init_prompt_for_cwd(extra=_command_arg(cmd)) # optional user emphasis + # session_key="" targets the single-session CLI's "default" cwd record, which tracks + # `cd` and workspace switches, so /init follows the directory the user works in. + msg = build_init_prompt_for_cwd(extra=_command_arg(cmd), session_key="") # optional user emphasis print("\n" + _t("init.updating" if "UPDATE the existing AGENTS.md" in msg else "init.generating")) self._queue_prompt_turn(msg, "/init") diff --git a/hermes_cli/init_command.py b/hermes_cli/init_command.py index f752117f53777..76691ef01efab 100644 --- a/hermes_cli/init_command.py +++ b/hermes_cli/init_command.py @@ -79,9 +79,48 @@ def build_init_prompt(cwd: str, existing_file: str | None = None, extra: str = " return "\n".join(parts) -def build_init_prompt_for_cwd(cwd: str | None = None, extra: str = "") -> str: - """Convenience wrapper used by the dispatch surfaces.""" - resolved = os.path.abspath(cwd or os.getcwd()) +def _resolve_session_cwd(session_key: str | None) -> str: + """The session's ACTIVE directory — where /init should scan and write. + + Ladder: the terminal tool's per-session cwd record (seeded when a surface attaches a + workspace to the session — desktop project picker, ``project_create``/``project_switch``, + gateway ``terminal.cwd`` — and updated after every completed command, so it tracks + ``cd``), then ``agent.runtime_cwd.resolve_agent_cwd()`` (session-pinned cwd → + ``TERMINAL_CWD`` → process cwd). A bare ``os.getcwd()`` is only right for a CLI launched + inside a project: on the desktop app it is the home directory, so /init scanned and + updated the HOME's AGENTS.md instead of the workspace attached to the session. + + ``session_key`` is the surface's own key (multi-session hosts must pass it); an empty + string reads the single-session CLI's ``"default"`` record, and ``None`` falls back to + the ambient ``HERMES_SESSION_KEY``. + """ + try: + from gateway.session_context import get_session_env + from tools.terminal_tool import get_session_cwd + + key = session_key if session_key is not None else get_session_env("HERMES_SESSION_KEY", "") + recorded = get_session_cwd(key) + if recorded and os.path.isdir(recorded): + return recorded + except Exception: + pass + try: + from agent.runtime_cwd import resolve_agent_cwd + + return str(resolve_agent_cwd()) + except Exception: + return os.getcwd() + + +def build_init_prompt_for_cwd(cwd: str | None = None, extra: str = "", + session_key: str | None = None) -> str: + """Convenience wrapper used by the dispatch surfaces. + + An explicit ``cwd`` wins while it still exists; a stale one (deleted project, removed + worktree) falls through to :func:`_resolve_session_cwd` — the guard lives here so every + dispatch surface shares it rather than each one validating its own record. + """ + resolved = os.path.abspath(cwd if cwd and os.path.isdir(cwd) else _resolve_session_cwd(session_key)) existing: str | None = None agents_path = os.path.join(resolved, "AGENTS.md") try: diff --git a/tests/hermes_cli/test_init_command.py b/tests/hermes_cli/test_init_command.py index 1235a1a2d783e..677b001b42d29 100644 --- a/tests/hermes_cli/test_init_command.py +++ b/tests/hermes_cli/test_init_command.py @@ -30,3 +30,116 @@ def test_reads_existing_agents_md(self, tmp_path): def test_passes_extra_through(self, tmp_path): prompt = build_init_prompt_for_cwd(cwd=str(tmp_path), extra="keep it short") assert "keep it short" in prompt + + +class TestSessionActiveDirectory: + """/init must target the session's ACTIVE directory, not the process cwd. + + Regression: on the desktop app the process launches from the home + directory, and /init with no explicit cwd scanned/updated the HOME's + AGENTS.md instead of the workspace attached to the session. The session's + live directory is the terminal tool's per-session cwd record (seeded when + a workspace attaches, updated after every `cd`). + """ + + def test_session_cwd_record_wins_over_process_cwd(self, tmp_path, monkeypatch): + from tools.terminal_tool import clear_session_cwd, record_session_cwd + + workspace = tmp_path / "proj" + workspace.mkdir() + launch_dir = tmp_path / "home" + launch_dir.mkdir() + monkeypatch.chdir(launch_dir) # desktop app launches from HOME + + record_session_cwd("desktop:123", str(workspace)) + try: + prompt = build_init_prompt_for_cwd(session_key="desktop:123") + assert f"for the project at: {workspace}" in prompt + assert str(launch_dir) not in prompt + finally: + clear_session_cwd("desktop:123") + + def test_stale_record_falls_back_to_process_cwd(self, tmp_path, monkeypatch): + from tools.terminal_tool import clear_session_cwd, record_session_cwd + + launch_dir = tmp_path / "home" + launch_dir.mkdir() + monkeypatch.chdir(launch_dir) + monkeypatch.delenv("TERMINAL_CWD", raising=False) + + record_session_cwd("desktop:123", str(tmp_path / "deleted-project")) + try: + prompt = build_init_prompt_for_cwd(session_key="desktop:123") + assert f"for the project at: {launch_dir}" in prompt + finally: + clear_session_cwd("desktop:123") + + def test_explicit_cwd_wins_over_session_record(self, tmp_path): + from tools.terminal_tool import clear_session_cwd, record_session_cwd + + record_session_cwd("desktop:123", str(tmp_path / "other")) + try: + prompt = build_init_prompt_for_cwd( + cwd=str(tmp_path), session_key="desktop:123" + ) + assert f"for the project at: {tmp_path}" in prompt + finally: + clear_session_cwd("desktop:123") + + def test_ambient_session_key_used_when_none(self, tmp_path, monkeypatch): + # Dispatch surfaces that don't pass an explicit key (e.g. a CLI + # inheriting a bound session env) resolve the key from + # HERMES_SESSION_KEY. + from gateway.session_context import set_session_vars + from tools.terminal_tool import clear_session_cwd, record_session_cwd + + workspace = tmp_path / "proj" + workspace.mkdir() + launch_dir = tmp_path / "home" + launch_dir.mkdir() + monkeypatch.chdir(launch_dir) + + record_session_cwd("gateway:999", str(workspace)) + tokens = set_session_vars(session_key="gateway:999") + try: + prompt = build_init_prompt_for_cwd() + assert f"for the project at: {workspace}" in prompt + finally: + clear_session_cwd("gateway:999") + + def test_stale_explicit_cwd_falls_through_to_the_session_record(self, tmp_path): + # The guard lives in the builder so every surface shares it: a ``cwd`` naming a + # directory that is gone (deleted project, removed linked worktree) must not win — + # write_file would otherwise recreate the directory and write AGENTS.md into it. + from tools.terminal_tool import clear_session_cwd, record_session_cwd + + workspace = tmp_path / "proj" + workspace.mkdir() + gone = tmp_path / "deleted-project" + gone.mkdir() + gone.rmdir() + + record_session_cwd("desktop:123", str(workspace)) + try: + prompt = build_init_prompt_for_cwd(cwd=str(gone), session_key="desktop:123") + assert f"for the project at: {workspace}" in prompt + assert str(gone) not in prompt + finally: + clear_session_cwd("desktop:123") + + +class TestInitRegistryWiring: + def test_init_is_registered_and_resolves(self): + from hermes_cli.commands import resolve_command + + cmd = resolve_command("init") + assert cmd is not None + assert cmd.name == "init" + + + def test_init_works_on_the_gateway(self): + # /init is a both-surfaces command like /learn, not CLI-only. + from hermes_cli.commands import GATEWAY_KNOWN_COMMANDS + + assert "init" in GATEWAY_KNOWN_COMMANDS + diff --git a/tests/tui_gateway/test_init_targets_session_cwd.py b/tests/tui_gateway/test_init_targets_session_cwd.py new file mode 100644 index 0000000000000..8f06f9ae49c72 --- /dev/null +++ b/tests/tui_gateway/test_init_targets_session_cwd.py @@ -0,0 +1,66 @@ +"""``/init`` on the TUI/desktop surface must target the SESSION's active directory. + +The desktop app launches the backend from the home directory, so the old bare-``os.getcwd()`` +resolution scanned and merge-updated the HOME's ``AGENTS.md`` while the session's own +workspace sat elsewhere. These drive the real ``command.dispatch`` handler (the surface the +desktop uses) rather than the builder alone, so a regression at the call site — passing a +stale terminal record straight through as ``cwd`` — cannot slip past the builder's own guard. +""" + +from __future__ import annotations + +from tools.terminal_tool import clear_session_cwd, record_session_cwd +from tui_gateway import server + + +def _register(sid: str, session_cwd: str) -> None: + """A minimal session record: the handler needs ``session_key`` and its attached ``cwd``.""" + server._sessions[sid] = {"agent": None, "cwd": session_cwd, "history": [], "session_key": sid} + + +def _dispatch_init(sid: str) -> str: + envelope = server._methods["command.dispatch"](1, {"name": "init", "arg": "", "session_id": sid}) + return envelope["result"]["message"] + + +def test_init_targets_the_sessions_recorded_cwd(tmp_path, monkeypatch): + workspace = tmp_path / "proj" + workspace.mkdir() + launch_dir = tmp_path / "home" # the desktop backend's launch dir + launch_dir.mkdir() + monkeypatch.chdir(launch_dir) + + sid = "init-live-record" + _register(sid, str(launch_dir)) + record_session_cwd(sid, str(workspace)) + try: + prompt = _dispatch_init(sid) + assert f"for the project at: {workspace}" in prompt + assert str(launch_dir) not in prompt + finally: + clear_session_cwd(sid) + server._sessions.pop(sid, None) + + +def test_init_ignores_a_stale_record_in_favour_of_the_session_workspace(tmp_path, monkeypatch): + """A record for a deleted directory (removed worktree) falls through to the session cwd.""" + workspace = tmp_path / "proj" + workspace.mkdir() + launch_dir = tmp_path / "home" + launch_dir.mkdir() + monkeypatch.chdir(launch_dir) + + gone = tmp_path / "removed-worktree" + gone.mkdir() + gone.rmdir() + + sid = "init-stale-record" + _register(sid, str(workspace)) + record_session_cwd(sid, str(gone)) + try: + prompt = _dispatch_init(sid) + assert f"for the project at: {workspace}" in prompt + assert str(gone) not in prompt + finally: + clear_session_cwd(sid) + server._sessions.pop(sid, None) diff --git a/tui_gateway/methods_tools.py b/tui_gateway/methods_tools.py index 48345a5d1432a..3a2b27858538b 100644 --- a/tui_gateway/methods_tools.py +++ b/tui_gateway/methods_tools.py @@ -747,7 +747,24 @@ def cmd(rid, params, session, name, arg): _cmd_learn = _prompt_builtin("agent.learn_prompt", "build_learn_prompt") _cmd_plan = _prompt_builtin("agent.plan_prompt", "build_plan_prompt") -_cmd_init = _prompt_builtin("hermes_cli.init_command", "build_init_prompt_for_cwd", kw="extra") + + +def _cmd_init(rid, params, session, name, arg): + """/init: build the AGENTS.md prompt against the SESSION's active directory, then submit it + as a normal turn (the live agent does the scan and the write). The desktop app launches the + backend from the home directory, so a process-cwd fallback scans and updates the HOME's + AGENTS.md instead of the workspace attached to the session.""" + from hermes_cli.init_command import build_init_prompt_for_cwd + from tools.terminal_tool import get_session_cwd + + skey = session.get("session_key") if session else None + cwd = None + with contextlib.suppress(Exception): # no record → the builder's ladder decides + cwd = get_session_cwd(skey) if skey else None + if not (cwd and os.path.isdir(cwd)): # a deleted project/removed worktree must not win + cwd = _session_cwd(session) if session else None + return _ok(rid, {"type": "send", "message": build_init_prompt_for_cwd( + extra=arg, cwd=cwd, session_key=skey)}) def _cmd_moa(rid, params, session, name, arg): From e8f8f94ce0c1c5166f9cb032e361368e247ebf3c Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 29 Sep 2026 11:39:13 -0500 Subject: [PATCH 2/2] chore(contributors): map funkyotc@gmail.com to @funkyotc --- contributors/emails/funkyotc@gmail.com | 1 + 1 file changed, 1 insertion(+) create mode 100644 contributors/emails/funkyotc@gmail.com diff --git a/contributors/emails/funkyotc@gmail.com b/contributors/emails/funkyotc@gmail.com new file mode 100644 index 0000000000000..e1fb605fb0153 --- /dev/null +++ b/contributors/emails/funkyotc@gmail.com @@ -0,0 +1 @@ +funkyotc