Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions contributors/emails/funkyotc@gmail.com
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
funkyotc
8 changes: 7 additions & 1 deletion gateway/run_inbound.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand Down
4 changes: 3 additions & 1 deletion hermes_cli/cli_commands_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
45 changes: 42 additions & 3 deletions hermes_cli/init_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
113 changes: 113 additions & 0 deletions tests/hermes_cli/test_init_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

66 changes: 66 additions & 0 deletions tests/tui_gateway/test_init_targets_session_cwd.py
Original file line number Diff line number Diff line change
@@ -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)
19 changes: 18 additions & 1 deletion tui_gateway/methods_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading