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
2 changes: 1 addition & 1 deletion emrg/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ def _build_parser() -> argparse.ArgumentParser:
)
rant_parser.add_argument(
"-p", "--project",
help="Target project (from projects.yml). Omit for emrg itself.",
help="Target project (from agents.yml). Omit for emrg itself.",
default=None,
)
rant_parser.add_argument(
Expand Down
2 changes: 1 addition & 1 deletion emrg/client/widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -660,7 +660,7 @@ def _format_tool_input(arguments: dict) -> list[str]:
class ProjectSelector(Widget):
"""Interactive project picker — arrow-key navigation with highlight.

Renders a list of projects from projects.yml with the selected one in
Renders a list of projects from agents.yml with the selected one in
reverse video. Used by /rant when invoked without @project.
"""

Expand Down
60 changes: 60 additions & 0 deletions emrg/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,66 @@ def logs_dir() -> Path:
return config_dir() / "logs"


#: The register's name before an Agent became the record of the directory a
#: session runs in (#2521). Spelled here and nowhere else in the package: the
#: move below is what retires it.
_LEGACY_AGENTS_NAME = "projects.yml"


def agents_file(base: Path | None = None) -> Path:
"""Returns the path of the agent registry (`~/.emrg/agents.yml`).

**The one derivation of that path** (#2521): every reader and writer of
the register asks this rather than spelling `config_dir() / <name>` again —
the daemon, the scheduler, the rant tool's candidate-name reader, the
sandbox's protected-files list and `scripts/check-memory-rotation.py` —
which is the treatment :func:`logs_dir` already gives the log directory.

A pure path: asking where the register lives opens and moves nothing.
:func:`migrate_legacy_registry` is the one call that moves it, from the one
process that owns it.

``base`` is the caller's own data directory, for a reader whose directory
is not the host's (the rant tool's tests point it at a temp directory).
"""
directory = config_dir() if base is None else base
return directory / "agents.yml"


def migrate_legacy_registry(base: Path | None = None) -> bool:
"""Move a register still at the pre-rename name onto the new one.

Returns whether this call moved it. ``Path.rename`` within one directory is
a single filesystem operation, so the register is wholly at the old name or
wholly at the new one and never half of each: the count of entries, every
field and ``last_active`` travel with the file because nothing is read or
rewritten to carry them.

Called from the daemon's startup and nowhere else — the one process that
owns the register — and not from :func:`agents_file`, so that asking for
the path never mutates a host's state. A test suite constructs the objects
that read the register but never runs that startup, which is exactly the
separation that keeps a run of the suite from renaming the host's file
under a daemon that is still reading the old name.

Nothing moves once the new name exists (an older daemon that re-created the
legacy file leaves both, and the new name governs), a register that never
existed is not created, and a move that cannot happen — a read-only home, a
permission boundary, a concurrent caller that got there first — answers
``False`` and leaves the legacy file exactly where it is.
"""
directory = config_dir() if base is None else base
target = agents_file(directory)
legacy = directory / _LEGACY_AGENTS_NAME
try:
if target.exists() or not legacy.exists():
return False
legacy.rename(target)
return True
except OSError:
return False


def config_path() -> Path:
"""Returns the config file path."""
home = Path.home()
Expand Down
2 changes: 1 addition & 1 deletion emrg/gui/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -908,7 +908,7 @@ vision = false
return { ok: true, path: p };
});

// P5 slice 2:删除项目 = 只删 projects.yml 条目(保留磁盘数据)+ 关闭该项目已打开会话
// P5 slice 2:删除项目 = 只删 agents.yml 条目(保留磁盘数据)+ 关闭该项目已打开会话
// 受保护项目不可删除(内置 project emrg / 内置 task emrg-task → 演化依赖,删了悬空);
// `.emrg` 是 _touch_project 历史记录**非内置 → 可删**(再次访问重新注册)。
// 返回 { ok, removed, closed: [sids] };renderer 收到后负责切换激活会话。
Expand Down
9 changes: 8 additions & 1 deletion emrg/server/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import traceback
from logging.handlers import RotatingFileHandler

from emrg.config import logs_dir
from emrg.config import logs_dir, migrate_legacy_registry
from emrg.logfiles import migrate_legacy_logs
from emrg.server.daemon import DaemonExit, _non_regular_kind, run_server
from emrg.server.logcontext import session_label
Expand Down Expand Up @@ -195,6 +195,13 @@ def main() -> None:
# whatever the crash log keeps as the low-level sink.
try:
config = load_config()
# The agent register's move from its pre-rename name runs here and only here
# (#2521): this is the one process that owns the file, and it runs before the
# server object below asks `emrg.config.agents_file()` for the path. It is
# placed after `load_config()` so a startup whose configuration is broken
# fails without touching the host's register at all, and it sits in no path
# derivation — asking where the register lives must never move it.
migrate_legacy_registry()
result = asyncio.run(run_server(config.llm))
except KeyboardInterrupt:
# SIGINT delivered at the event-loop poll point escapes the main
Expand Down
25 changes: 13 additions & 12 deletions emrg/server/daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
from emrg.config import (
LlmConfig,
SandboxConfig,
agents_file,
config_dir,
logs_dir,
find_model_entry,
Expand Down Expand Up @@ -868,7 +869,7 @@ def __init__(self, llm_config: LlmConfig) -> None:
# (session_id → (text, injected_ms)) so unchanged context is not
# re-sent (context_refresh_interval_ms gating).
self._context_snapshots: dict[str, tuple[str, float]] = {}
self._projects_log = runtime_dir / "projects.yml"
self._projects_log = agents_file(runtime_dir)
self._rants_log = runtime_dir / "rants.jsonl"

# ── Phase 2 broadcast model (protocol-contract §2.6) ──
Expand Down Expand Up @@ -1248,7 +1249,7 @@ def _rebuild_sessions_index(self) -> None:
"""Backfill the global cross-project session index at startup.

Rant 2026-08-13T16:42:22: sessions created before this feature (or in
projects not registered in projects.yml) would otherwise be invisible
projects not registered in agents.yml) would otherwise be invisible
to other projects. Best-effort — failures are logged at debug level
and never crash the daemon.
"""
Expand Down Expand Up @@ -2103,7 +2104,7 @@ async def _broadcast_all(self, data: dict, exclude=None) -> None:
# ── Project tracking ─────────────────────────────────────

def _touch_project(self, cwd: str) -> None:
"""Record a project as active in ~/.emrg/projects.yml.
"""Record a project as active in ~/.emrg/agents.yml.

Used by the evolution cycle to discover which projects have
recent user activity and analyze their sessions for improvement ideas.
Expand Down Expand Up @@ -3456,7 +3457,7 @@ async def _process_message(

elif msg_type == "remove_project":
# P1 GUI multi-session (rant 2026-08-10T15:07:19): drop a
# projects.yml entry by name (disk session data preserved).
# agents.yml entry by name (disk session data preserved).
name = msg.get("name", "").strip()
if not name:
await self._send(ws, {
Expand Down Expand Up @@ -6684,14 +6685,14 @@ async def _handle_list_sessions(
async def _handle_list_projects(
self, ws
) -> None:
"""Read projects.yml and return all project entries.
"""Read agents.yml and return all project entries.

Ordered by each project's latest session activity (created_at desc,
parallel scan) — P6 of the GUI multi-session rant (2026-08-10T15:07:19)
so the open/new-session dialogs show the most recently active projects
first.

No evolution-workspace filter (rant 2026-08-07T10:48:00): projects.yml
No evolution-workspace filter (rant 2026-08-07T10:48:00): agents.yml
only contains explicitly registered entries, and on packaged installs
the emrg project's only path IS ~/.emrg/evolution/emrg — filtering it
hid emrg from /rant entirely.
Expand Down Expand Up @@ -6738,17 +6739,17 @@ async def _latest_session_at(entry: dict) -> str:
for (p, at), repo in zip(ordered, repos)
]
except (yaml.YAMLError, OSError):
logger.exception("Failed to read projects.yml")
logger.exception("Failed to read agents.yml")
await self._send(ws, {
"type": "projects_list",
"projects": projects,
})

async def _handle_remove_project(self, name: str, ws) -> None:
"""Remove a project entry from projects.yml by name.
"""Remove a project entry from agents.yml by name.

P1 of the GUI multi-session feature (rant 2026-08-10T15:07:19):
deletes only the projects.yml entry — on-disk session data under
deletes only the agents.yml entry — on-disk session data under
<path>/.emrg/sessions/ is preserved (a later _touch_project
re-registers the project). Mirrors _touch_project's read path and
atomic_write_yaml. Responses:
Expand All @@ -6774,7 +6775,7 @@ async def _handle_remove_project(self, name: str, ws) -> None:
"type": "project_removed",
"removed": False,
"name": name,
"error": "failed to read projects.yml",
"error": "failed to read agents.yml",
})
return
if not isinstance(data, list):
Expand Down Expand Up @@ -6803,10 +6804,10 @@ async def _handle_remove_project(self, name: str, ws) -> None:
"type": "project_removed",
"removed": False,
"name": name,
"error": "failed to write projects.yml",
"error": "failed to write agents.yml",
})
return
logger.info("remove_project: removed %s from projects.yml", name)
logger.info("remove_project: removed %s from agents.yml", name)
await self._send(ws, {
"type": "project_removed",
"removed": True,
Expand Down
2 changes: 1 addition & 1 deletion emrg/server/evolution_prompt.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ Read what others landed, why, and whether it needs follow-up. Use `FETCH_HEAD`,
**Coverage is not negotiable; depth is.** Every cycle asks about **every** project whether anything changed — step 0 reads no content, and it is the step that is never skipped. How far into a project the cycle then goes is decided by evidence. Nothing here shrinks §1: every step of §1–§4 is still executed, and R10's rule — a cycle saves rounds by asking for several things at once, never by asking for less — is not relaxed by a word of it.

```bash
cat ~/.emrg/projects.yml
cat ~/.emrg/agents.yml
# step 0, per project: what under its memory is newer than its bookmark time?
find <project>/.emrg -newermt '<bookmark-time>' \
\( -path '*/memory/*.md' -o -name 'MEMORY.md' \) 2>/dev/null | head
Expand Down
6 changes: 3 additions & 3 deletions emrg/server/promote_prompt.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ Run this loop every round; never stop at "executed":
---

### Current State
- Promotion target: {{ project.name }}({% if project.description %}{{ project.description }}{% else %}see projects.yml for description{% endif %})
- Promotion target: {{ project.name }}({% if project.description %}{{ project.description }}{% else %}see agents.yml for description{% endif %})
- Project path: `{{ project.path }}`
- Session ID: `{{ session_id }}`

Expand Down Expand Up @@ -63,7 +63,7 @@ Durable facts — `channel accounts`, `blog posts`, `blog drafts`, `banned list`

#### 0.2 Read the project config

Read the full config of `{{ project.name }}` from `~/.emrg/projects.yml` (path, name, description, custom fields) as context. The `project` variable in the template (name/path/description and other fields) is already injected into this prompt.
Read the full config of `{{ project.name }}` from `~/.emrg/agents.yml` (path, name, description, custom fields) as context. The `project` variable in the template (name/path/description and other fields) is already injected into this prompt.

#### 0.3 Confirm promotion channel availability

Expand Down Expand Up @@ -307,7 +307,7 @@ here, because a second copy of a rule is a copy that can disagree. So there is
nothing for this prompt to write by hand — and no reason to open the file at all,
not even to read it.

- `project` = the promoted project's registered short name (as in `~/.emrg/projects.yml`); the rant is then handled by that project's evolution task. The tool warns when the name is not registered.
- `project` = the promoted project's registered short name (as in `~/.emrg/agents.yml`); the rant is then handled by that project's evolution task. The tool warns when the name is not registered.
- Deduplicate before submitting: `submit_rant(action="list", project="{{ project.name }}")` and skip feedback that is already queued.
- Each message notes the source (channel + link) so the evolution task can trace back.

Expand Down
Loading
Loading