From 32bbfa20c4a48640a4281e44e8cae7333f680096 Mon Sep 17 00:00:00 2001 From: luw2007 Date: Mon, 14 Sep 2026 13:39:50 +0800 Subject: [PATCH 1/5] feat(pi): support user-global extension scope Signed-off-by: luw2007 --- docs/guides/getting-started.md | 14 +++++------ loopx/cli_commands/slash_commands.py | 7 ++++++ loopx/slash_command_install.py | 31 +++++++++++++++++------- tests/test_slash_command_install.py | 36 ++++++++++++++++++++++++++++ 4 files changed, 73 insertions(+), 15 deletions(-) diff --git a/docs/guides/getting-started.md b/docs/guides/getting-started.md index d9cabc9f27..b973dde208 100644 --- a/docs/guides/getting-started.md +++ b/docs/guides/getting-started.md @@ -94,13 +94,13 @@ can discover user-installed skills: which drives the session over the OpenCode 2 HTTP API and owns the loop timers, so long runs survive TUI close. OpenCode 1 plugins do not run under OpenCode 2; see `loopx/opencode2_goal_mode/README.md`. -- Pi: the self-contained goal extension under `.pi/extensions/loopx-goal.ts` - (with its loop core in `.pi/extensions/pi-goal-loop-runtime.mjs`) exposes - `/loopx` after restart and runs the quota-gated goal loop through - `loopx_goal_activate`. It is installed explicitly with - `loopx slash-commands --install --surface pi` (pass `--pi-project ` - to target another project from a different directory); private binding state - stays under each project's `.loopx/pi/` (already gitignored via `.loopx/`). +- Pi: the self-contained goal extension exposes `/loopx` after `/reload` or a + restart and runs the quota-gated goal loop through `loopx_goal_activate`. + `loopx slash-commands --install --surface pi` installs it for the current + project; add `--pi-project ` for another project, or `--pi-scope user` + to install it under `~/.pi/agent/extensions/loopx/` for all projects. Only + extension code is global: private bindings remain under each project's + `.loopx/pi/` (already gitignored via `.loopx/`). The command family is the same across surfaces, even when the host-specific entry point is different: diff --git a/loopx/cli_commands/slash_commands.py b/loopx/cli_commands/slash_commands.py index 5a9604a9e9..28893d3891 100644 --- a/loopx/cli_commands/slash_commands.py +++ b/loopx/cli_commands/slash_commands.py @@ -122,6 +122,12 @@ def register_slash_commands_command( default=".", help="Project directory for the Pi goal extension install. Defaults to the current directory.", ) + parser.add_argument( + "--pi-scope", + choices=("project", "user"), + default="project", + help="Install Pi extension code for one project or the current user. Defaults to project.", + ) parser.add_argument( "--dry-run", action="store_true", @@ -152,6 +158,7 @@ def handle_slash_commands_command( cursor_home=args.cursor_home, zcode_home=getattr(args, "zcode_home", None), pi_project=args.pi_project, + pi_scope=args.pi_scope, ) print_payload(payload, output_format(args), render_slash_command_install_markdown) return 0 if payload.get("ok") is True else 1 diff --git a/loopx/slash_command_install.py b/loopx/slash_command_install.py index c36b124d43..802abe8e8a 100644 --- a/loopx/slash_command_install.py +++ b/loopx/slash_command_install.py @@ -759,12 +759,18 @@ def _merge_cursor_mcp(cursor_root: Path, *, uninstall: bool, execute: bool) -> s return "written" -def _pi_extension_path(project_root: Path) -> Path: - return project_root / ".pi" / "extensions" / "loopx-goal.ts" +def _pi_extension_root(project_root: Path, *, scope: str, user_home: Path) -> Path: + if scope == "user": + return user_home / ".pi" / "agent" / "extensions" / "loopx" + return project_root / ".pi" / "extensions" -def _pi_runtime_path(project_root: Path) -> Path: - return project_root / ".pi" / "extensions" / "pi-goal-loop-runtime.mjs" +def _pi_extension_path(extension_root: Path) -> Path: + return extension_root / "loopx-goal.ts" + + +def _pi_runtime_path(extension_root: Path) -> Path: + return extension_root / "pi-goal-loop-runtime.mjs" def install_slash_commands( @@ -785,6 +791,8 @@ def install_slash_commands( agy_home: str | None = None, kiro_home: str | None = None, pi_project: str | None = None, + pi_scope: str = "project", + pi_user_home: str | None = None, ) -> dict[str, Any]: specs = _command_prompt_specs(cli_bin=cli_bin, include_legacy_aliases=include_legacy_aliases) effective_surfaces = _normalize_surfaces(surfaces) @@ -796,7 +804,13 @@ def install_slash_commands( zcode_root = _zcode_home(zcode_home or zcode_agents_home) agy_root = _agy_home(agy_home) kiro_root = _kiro_home(kiro_home) + if pi_scope not in {"project", "user"}: + raise ValueError("pi_scope must be 'project' or 'user'") pi_project_root = Path(pi_project or ".").expanduser().resolve() + pi_home = Path(pi_user_home).expanduser().resolve() if pi_user_home else Path.home() + pi_extension_root = _pi_extension_root( + pi_project_root, scope=pi_scope, user_home=pi_home + ) installed: list[dict[str, Any]] = [] if with_goal_bridge and "opencode" not in effective_surfaces: @@ -1308,8 +1322,8 @@ def install_slash_commands( ) if "pi" in effective_surfaces: - extension_path = _pi_extension_path(pi_project_root) - runtime_path = _pi_runtime_path(pi_project_root) + extension_path = _pi_extension_path(pi_extension_root) + runtime_path = _pi_runtime_path(pi_extension_root) extension_content = pi_extension_source() runtime_content = pi_runtime_source() if uninstall: @@ -1408,8 +1422,9 @@ def install_slash_commands( "opencode_command_dir": str(opencode_root / "commands") if "opencode" in effective_surfaces else None, "opencode_plugin_path": str(opencode_root / "plugins" / "loopx-goal.js") if "opencode" in effective_surfaces and with_goal_bridge else None, "opencode_package_path": str(opencode_root / "package.json") if "opencode" in effective_surfaces and with_goal_bridge else None, - "pi_extension_path": str(_pi_extension_path(pi_project_root)) if "pi" in effective_surfaces else None, - "pi_runtime_path": str(_pi_runtime_path(pi_project_root)) if "pi" in effective_surfaces else None, + "pi_scope": pi_scope if "pi" in effective_surfaces else None, + "pi_extension_path": str(_pi_extension_path(pi_extension_root)) if "pi" in effective_surfaces else None, + "pi_runtime_path": str(_pi_runtime_path(pi_extension_root)) if "pi" in effective_surfaces else None, "status_counts": status_counts, "skip_policy": ( "Uninstall removes only LoopX-managed files; user files without a LoopX managed marker are preserved" diff --git a/tests/test_slash_command_install.py b/tests/test_slash_command_install.py index 9186c56daa..d2310006cf 100644 --- a/tests/test_slash_command_install.py +++ b/tests/test_slash_command_install.py @@ -598,6 +598,42 @@ def test_pi_install_writes_self_contained_extension_into_project( assert not (tmp_path / ".pi" / "extensions" / "package.json").exists() +def test_pi_user_scope_installs_atomic_extension_unit(tmp_path: Path) -> None: + home = tmp_path / "home" + payload = install_slash_commands( + execute=True, + surfaces=["pi"], + pi_scope="user", + pi_user_home=str(home), + ) + + root = home / ".pi" / "agent" / "extensions" / "loopx" + assert payload["summary"]["pi_scope"] == "user" + assert payload["summary"]["pi_extension_path"] == str(root / "loopx-goal.ts") + assert payload["summary"]["pi_runtime_path"] == str(root / "pi-goal-loop-runtime.mjs") + assert (root / "loopx-goal.ts").is_file() + assert (root / "pi-goal-loop-runtime.mjs").is_file() + + +def test_pi_user_scope_preflight_blocks_both_files(tmp_path: Path) -> None: + home = tmp_path / "home" + root = home / ".pi" / "agent" / "extensions" / "loopx" + root.mkdir(parents=True) + runtime = root / "pi-goal-loop-runtime.mjs" + runtime.write_text("user owned\n", encoding="utf-8") + + payload = install_slash_commands( + execute=True, + surfaces=["pi"], + pi_scope="user", + pi_user_home=str(home), + ) + + assert payload["ok"] is False + assert not (root / "loopx-goal.ts").exists() + assert runtime.read_text(encoding="utf-8") == "user owned\n" + + def test_pi_install_does_not_touch_default_all_surfaces(tmp_path: Path) -> None: payload = install_slash_commands( execute=True, From fe64c64018862881c23ed6e76a3823fc35f25b38 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Fri, 25 Sep 2026 22:13:59 +0800 Subject: [PATCH 2/5] fix(pi): make user scope discoverable and inspectable Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- loopx/cli_commands/slash_commands.py | 19 +++- loopx/pi_goal_mode/installation.py | 119 +++++++++++++++++++++++ loopx/slash_command_install.py | 90 +++++++++++++----- tests/test_slash_command_install.py | 136 ++++++++++++++++++++++++++- 4 files changed, 332 insertions(+), 32 deletions(-) create mode 100644 loopx/pi_goal_mode/installation.py diff --git a/loopx/cli_commands/slash_commands.py b/loopx/cli_commands/slash_commands.py index 28893d3891..79a0f94b47 100644 --- a/loopx/cli_commands/slash_commands.py +++ b/loopx/cli_commands/slash_commands.py @@ -3,10 +3,8 @@ import argparse from collections.abc import Callable -from ..slash_command_install import ( - install_slash_commands, - render_slash_command_install_markdown, -) +from ..pi_goal_mode.installation import inspect_pi_installations, render_pi_installation_markdown +from ..slash_command_install import install_slash_commands, render_slash_command_install_markdown from ..slash_commands import build_slash_command_catalog, render_slash_command_catalog_markdown @@ -47,6 +45,11 @@ def register_slash_commands_command( action="store_true", help="Remove LoopX-managed command skill files for supported hosts while preserving user-owned files.", ) + install_group.add_argument( + "--inspect", + action="store_true", + help="Read project and user Pi extension installation state without writing files.", + ) parser.add_argument( "--surface", action="append", @@ -143,6 +146,14 @@ def handle_slash_commands_command( ) -> int | None: if args.command != "slash-commands": return None + if args.inspect: + if args.surface != ["pi"]: + raise ValueError("--inspect requires exactly --surface pi") + if args.dry_run or args.with_goal_bridge: + raise ValueError("--inspect cannot be combined with --dry-run or --with-goal-bridge") + payload = inspect_pi_installations(pi_project=args.pi_project) + print_payload(payload, output_format(args), render_pi_installation_markdown) + return 0 if payload["ok"] else 1 if args.install or args.uninstall or args.dry_run: payload = install_slash_commands( execute=bool((args.install or args.uninstall) and not args.dry_run), diff --git a/loopx/pi_goal_mode/installation.py b/loopx/pi_goal_mode/installation.py new file mode 100644 index 0000000000..f53bf06bf6 --- /dev/null +++ b/loopx/pi_goal_mode/installation.py @@ -0,0 +1,119 @@ +"""Managed Pi extension locations and read-only installation state.""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import Any + +from ..slash_command_files import managed_marker as _managed_marker +from . import extension_source as pi_extension_source +from . import runtime_source as pi_runtime_source + + +def _pi_agent_dir(user_home: str | None = None) -> Path: + if user_home is not None: + return Path(user_home).expanduser().resolve() / ".pi" / "agent" + configured = os.environ.get("PI_CODING_AGENT_DIR") + if configured: + return Path(configured).expanduser().resolve() + return Path.home() / ".pi" / "agent" + + +def _pi_extension_root(project_root: Path, *, scope: str, agent_dir: Path) -> Path: + if scope == "user": + return agent_dir / "extensions" / "loopx" + return project_root / ".pi" / "extensions" + + +def _pi_extension_path(extension_root: Path, *, scope: str) -> Path: + # Pi discovers direct project files, but only index.ts/index.js (or a + # package manifest) inside a global extension subdirectory. + return extension_root / ("index.ts" if scope == "user" else "loopx-goal.ts") + + +def _pi_runtime_path(extension_root: Path) -> Path: + return extension_root / "pi-goal-loop-runtime.mjs" + + +def inspect_pi_installations( + *, pi_project: str | None = None, pi_user_home: str | None = None +) -> dict[str, Any]: + """Read both Pi discovery locations without mutating either installation.""" + project_root = Path(pi_project or ".").expanduser().resolve() + agent_dir = _pi_agent_dir(pi_user_home) + scopes: dict[str, dict[str, Any]] = {} + for scope in ("project", "user"): + root = _pi_extension_root(project_root, scope=scope, agent_dir=agent_dir) + extension = _pi_extension_path(root, scope=scope) + runtime = _pi_runtime_path(root) + files = (extension, runtime) + present = [path.exists() for path in files] + if not any(present): + status = "absent" + elif any(present) and not all(present): + status = "partial" + elif any( + _managed_marker(command="/loopx", surface=surface) + not in path.read_text(encoding="utf-8") + for path, surface in ((extension, "pi-extension"), (runtime, "pi-extension-runtime")) + ): + status = "user_owned" + elif ( + extension.read_text(encoding="utf-8") == pi_extension_source() + and runtime.read_text(encoding="utf-8") == pi_runtime_source() + ): + status = "current" + else: + status = "stale" + scopes[scope] = { + "status": status, + "extension_path": str(extension), + "runtime_path": str(runtime), + } + + legacy_user_path = Path(scopes["user"]["extension_path"]).with_name("loopx-goal.ts") + legacy_user_entry = legacy_user_path.exists() + scopes["user"]["legacy_entry_path"] = str(legacy_user_path) if legacy_user_entry else None + if legacy_user_entry and scopes["user"]["status"] == "absent": + scopes["user"]["status"] = "stale" + + project_entry = Path(scopes["project"]["extension_path"]).exists() + user_entry = Path(scopes["user"]["extension_path"]).exists() + duplicate_load = project_entry and user_entry + if duplicate_load: + location = "dual-scope" + elif any(row["status"] in {"stale", "partial", "user_owned"} for row in scopes.values()): + location = "stale" + elif scopes["user"]["status"] == "current": + location = "user-global" + elif scopes["project"]["status"] == "current": + location = "project-local" + else: + location = "absent" + return { + "ok": all(row["status"] in {"absent", "current"} for row in scopes.values()) + and not duplicate_load, + "schema_version": "loopx_pi_installation_readback_v0", + "location": location, + "scopes": scopes, + "duplicate_load_warning": ( + "Pi discovers both project and user LoopX entries; uninstall one scope to avoid duplicate command/tool registration." + if duplicate_load else None + ), + } + + +def render_pi_installation_markdown(payload: dict[str, Any]) -> str: + lines = ["# Pi installation readback", "", f"Location: `{payload['location']}`"] + for scope, row in payload["scopes"].items(): + lines.extend( + [ + f"- {scope}: `{row['status']}`", + f" - extension: `{row['extension_path']}`", + f" - runtime: `{row['runtime_path']}`", + ] + ) + if payload["duplicate_load_warning"]: + lines.append(f"\n{payload['duplicate_load_warning']}") + return "\n".join(lines) + "\n" diff --git a/loopx/slash_command_install.py b/loopx/slash_command_install.py index 802abe8e8a..14c4fcceae 100644 --- a/loopx/slash_command_install.py +++ b/loopx/slash_command_install.py @@ -16,6 +16,12 @@ from .opencode_goal_mode import plugin_source, runtime_source from .pi_goal_mode import extension_source as pi_extension_source from .pi_goal_mode import runtime_source as pi_runtime_source +from .pi_goal_mode.installation import ( + _pi_agent_dir, + _pi_extension_path, + _pi_extension_root, + _pi_runtime_path, +) from .slash_command_files import ( front_matter as _front_matter, install_skill_facade as _install_skill_facade, @@ -759,20 +765,6 @@ def _merge_cursor_mcp(cursor_root: Path, *, uninstall: bool, execute: bool) -> s return "written" -def _pi_extension_root(project_root: Path, *, scope: str, user_home: Path) -> Path: - if scope == "user": - return user_home / ".pi" / "agent" / "extensions" / "loopx" - return project_root / ".pi" / "extensions" - - -def _pi_extension_path(extension_root: Path) -> Path: - return extension_root / "loopx-goal.ts" - - -def _pi_runtime_path(extension_root: Path) -> Path: - return extension_root / "pi-goal-loop-runtime.mjs" - - def install_slash_commands( *, execute: bool, @@ -807,9 +799,9 @@ def install_slash_commands( if pi_scope not in {"project", "user"}: raise ValueError("pi_scope must be 'project' or 'user'") pi_project_root = Path(pi_project or ".").expanduser().resolve() - pi_home = Path(pi_user_home).expanduser().resolve() if pi_user_home else Path.home() + pi_agent_dir = _pi_agent_dir(pi_user_home) pi_extension_root = _pi_extension_root( - pi_project_root, scope=pi_scope, user_home=pi_home + pi_project_root, scope=pi_scope, agent_dir=pi_agent_dir ) installed: list[dict[str, Any]] = [] @@ -1322,26 +1314,60 @@ def install_slash_commands( ) if "pi" in effective_surfaces: - extension_path = _pi_extension_path(pi_extension_root) + extension_path = _pi_extension_path(pi_extension_root, scope=pi_scope) runtime_path = _pi_runtime_path(pi_extension_root) + legacy_user_path = pi_extension_root / "loopx-goal.ts" if pi_scope == "user" else None extension_content = pi_extension_source() runtime_content = pi_runtime_source() if uninstall: - for mechanism, path in ( - ("pi_goal_extension", extension_path), - ("pi_goal_extension_runtime", runtime_path), - ): + retire_targets = [extension_path, runtime_path] + if legacy_user_path is not None and legacy_user_path.exists(): + retire_targets.append(legacy_user_path) + user_owned_pi_paths = [ + str(path) for path in retire_targets + if _retire_status(path, execute=False) == "skipped_user_file" + ] + if user_owned_pi_paths: installed.append( { "surface": "pi", "host_surfaces": ["pi"], - "mechanism": mechanism, + "mechanism": "pi_goal_extension", "command": "/loopx", - "path": str(path), - "status": _retire_status(path, execute=execute), + "path": str(extension_path), + "status": "blocked_user_owned_pi_file", "invoke_as": ["/loopx", "loopx_goal_activate"], + "conflicts": user_owned_pi_paths, } ) + else: + for mechanism, path in ( + ("pi_goal_extension", extension_path), + ("pi_goal_extension_runtime", runtime_path), + ): + installed.append( + { + "surface": "pi", + "host_surfaces": ["pi"], + "mechanism": mechanism, + "command": "/loopx", + "path": str(path), + "status": _retire_status(path, execute=execute), + "invoke_as": ["/loopx", "loopx_goal_activate"], + } + ) + if legacy_user_path is not None and legacy_user_path.exists(): + installed.append( + { + "surface": "pi", + "host_surfaces": ["pi"], + "mechanism": "pi_goal_legacy_user_extension", + "command": "/loopx", + "path": str(legacy_user_path), + "status": _retire_status(legacy_user_path, execute=execute), + "invoke_as": [], + } + ) else: # The adapter and its loop runtime are one atomic delivery unit: # preflight both targets and fail closed with zero writes when any @@ -1390,6 +1416,20 @@ def install_slash_commands( "invoke_as": ["/loopx", "loopx_goal_activate"], } ) + if legacy_user_path is not None: + retired = _retire_managed_file(legacy_user_path, execute=execute) + if retired: + installed.append( + { + "surface": "pi", + "host_surfaces": ["pi"], + "mechanism": "pi_goal_legacy_user_extension", + "command": "/loopx", + "path": str(legacy_user_path), + "status": retired, + "invoke_as": [], + } + ) status_counts: dict[str, int] = {} for item in installed: @@ -1423,7 +1463,7 @@ def install_slash_commands( "opencode_plugin_path": str(opencode_root / "plugins" / "loopx-goal.js") if "opencode" in effective_surfaces and with_goal_bridge else None, "opencode_package_path": str(opencode_root / "package.json") if "opencode" in effective_surfaces and with_goal_bridge else None, "pi_scope": pi_scope if "pi" in effective_surfaces else None, - "pi_extension_path": str(_pi_extension_path(pi_extension_root)) if "pi" in effective_surfaces else None, + "pi_extension_path": str(_pi_extension_path(pi_extension_root, scope=pi_scope)) if "pi" in effective_surfaces else None, "pi_runtime_path": str(_pi_runtime_path(pi_extension_root)) if "pi" in effective_surfaces else None, "status_counts": status_counts, "skip_policy": ( diff --git a/tests/test_slash_command_install.py b/tests/test_slash_command_install.py index d2310006cf..a6bb532b23 100644 --- a/tests/test_slash_command_install.py +++ b/tests/test_slash_command_install.py @@ -3,7 +3,9 @@ import pytest +from loopx.entrypoint import main as loopx_main from loopx import slash_command_install +from loopx.pi_goal_mode.installation import inspect_pi_installations from loopx.slash_command_install import ( install_slash_commands, materialize_loopx_entry_skill, @@ -609,10 +611,11 @@ def test_pi_user_scope_installs_atomic_extension_unit(tmp_path: Path) -> None: root = home / ".pi" / "agent" / "extensions" / "loopx" assert payload["summary"]["pi_scope"] == "user" - assert payload["summary"]["pi_extension_path"] == str(root / "loopx-goal.ts") + assert payload["summary"]["pi_extension_path"] == str(root / "index.ts") assert payload["summary"]["pi_runtime_path"] == str(root / "pi-goal-loop-runtime.mjs") - assert (root / "loopx-goal.ts").is_file() + assert (root / "index.ts").is_file() assert (root / "pi-goal-loop-runtime.mjs").is_file() + assert inspect_pi_installations(pi_project=str(tmp_path), pi_user_home=str(home))["location"] == "user-global" def test_pi_user_scope_preflight_blocks_both_files(tmp_path: Path) -> None: @@ -630,10 +633,137 @@ def test_pi_user_scope_preflight_blocks_both_files(tmp_path: Path) -> None: ) assert payload["ok"] is False - assert not (root / "loopx-goal.ts").exists() + assert not (root / "index.ts").exists() assert runtime.read_text(encoding="utf-8") == "user owned\n" +def test_pi_user_scope_preserves_user_entry_and_upgrades_managed_files(tmp_path: Path) -> None: + home = tmp_path / "home" + root = home / ".pi/agent/extensions/loopx" + root.mkdir(parents=True) + entry = root / "index.ts" + runtime = root / "pi-goal-loop-runtime.mjs" + entry.write_text("// user entry\n", encoding="utf-8") + + blocked = install_slash_commands( + execute=True, surfaces=["pi"], pi_scope="user", pi_user_home=str(home) + ) + assert blocked["ok"] is False + assert entry.read_text(encoding="utf-8") == "// user entry\n" + assert not runtime.exists() + + entry.write_text(slash_command_install.pi_extension_source() + "\n// old\n", encoding="utf-8") + stale = inspect_pi_installations(pi_project=str(tmp_path), pi_user_home=str(home)) + assert stale["scopes"]["user"]["status"] == "partial" + updated = install_slash_commands( + execute=True, surfaces=["pi"], pi_scope="user", pi_user_home=str(home) + ) + assert updated["ok"] is True + assert _row(updated, "pi_goal_extension")["status"] == "updated" + assert _row(updated, "pi_goal_extension_runtime")["status"] == "created" + assert inspect_pi_installations(pi_project=str(tmp_path), pi_user_home=str(home))["scopes"]["user"]["status"] == "current" + + +def test_pi_user_scope_follows_pi_agent_dir_override( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + agent_dir = tmp_path / "custom-agent-dir" + monkeypatch.setenv("PI_CODING_AGENT_DIR", str(agent_dir)) + payload = install_slash_commands(execute=True, surfaces=["pi"], pi_scope="user") + + assert payload["summary"]["pi_extension_path"] == str(agent_dir / "extensions/loopx/index.ts") + assert (agent_dir / "extensions/loopx/index.ts").is_file() + assert inspect_pi_installations(pi_project=str(tmp_path))["scopes"]["user"]["status"] == "current" + + +def test_pi_inspect_cli_reads_selected_project_and_user_scope( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + agent_dir = tmp_path / "agent" + monkeypatch.setenv("PI_CODING_AGENT_DIR", str(agent_dir)) + install_slash_commands( + execute=True, surfaces=["pi"], pi_scope="user", pi_project=str(tmp_path) + ) + + exit_code = loopx_main( + ["--format", "json", "slash-commands", "--inspect", "--surface", "pi", "--pi-project", str(tmp_path)] + ) + payload = json.loads(capsys.readouterr().out) + assert exit_code == 0 + assert payload["location"] == "user-global" + assert payload["scopes"]["project"]["status"] == "absent" + assert payload["scopes"]["user"]["extension_path"] == str(agent_dir / "extensions/loopx/index.ts") + + +def test_pi_readback_distinguishes_absent_stale_partial_and_dual_scope(tmp_path: Path) -> None: + home = tmp_path / "home" + + def readback() -> dict[str, object]: + return inspect_pi_installations(pi_project=str(tmp_path), pi_user_home=str(home)) + + assert readback()["location"] == "absent" + + install_slash_commands(execute=True, surfaces=["pi"], pi_project=str(tmp_path)) + assert readback()["location"] == "project-local" + assert readback()["scopes"]["project"]["status"] == "current" + + project_runtime = tmp_path / ".pi/extensions/pi-goal-loop-runtime.mjs" + project_runtime.write_text(project_runtime.read_text(encoding="utf-8") + "\n// old\n") + assert readback()["scopes"]["project"]["status"] == "stale" + project_runtime.unlink() + assert readback()["scopes"]["project"]["status"] == "partial" + + install_slash_commands(execute=True, surfaces=["pi"], pi_project=str(tmp_path)) + install_slash_commands( + execute=True, surfaces=["pi"], pi_scope="user", pi_user_home=str(home) + ) + dual = readback() + assert dual["location"] == "dual-scope" + assert dual["ok"] is False + assert "duplicate" in dual["duplicate_load_warning"] + + install_slash_commands( + execute=True, uninstall=True, surfaces=["pi"], pi_scope="user", pi_user_home=str(home) + ) + assert readback()["location"] == "project-local" + + +def test_pi_user_scope_migrates_managed_legacy_entry(tmp_path: Path) -> None: + home = tmp_path / "home" + root = home / ".pi/agent/extensions/loopx" + root.mkdir(parents=True) + legacy = root / "loopx-goal.ts" + legacy.write_text(slash_command_install.pi_extension_source(), encoding="utf-8") + assert inspect_pi_installations(pi_project=str(tmp_path), pi_user_home=str(home))["scopes"]["user"]["status"] == "stale" + + payload = install_slash_commands( + execute=True, surfaces=["pi"], pi_scope="user", pi_user_home=str(home) + ) + assert payload["ok"] is True + assert not legacy.exists() + assert (root / "index.ts").is_file() + assert _row(payload, "pi_goal_legacy_user_extension")["status"] == "retired_managed_file" + + +def test_pi_user_uninstall_blocks_atomically_on_user_owned_file(tmp_path: Path) -> None: + home = tmp_path / "home" + install_slash_commands( + execute=True, surfaces=["pi"], pi_scope="user", pi_user_home=str(home) + ) + root = home / ".pi/agent/extensions/loopx" + extension = root / "index.ts" + runtime = root / "pi-goal-loop-runtime.mjs" + runtime.write_text("// user replacement\n", encoding="utf-8") + + payload = install_slash_commands( + execute=True, uninstall=True, surfaces=["pi"], pi_scope="user", pi_user_home=str(home) + ) + assert payload["ok"] is False + assert _row(payload, "pi_goal_extension")["status"] == "blocked_user_owned_pi_file" + assert extension.exists() + assert runtime.read_text(encoding="utf-8") == "// user replacement\n" + + def test_pi_install_does_not_touch_default_all_surfaces(tmp_path: Path) -> None: payload = install_slash_commands( execute=True, From bcbb07d72daeb8af56fbdc2e6b3e6bc9ec0a98ed Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Fri, 25 Sep 2026 22:14:04 +0800 Subject: [PATCH 3/5] docs(pi): explain global install and scope readback Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- docs/guides/getting-started.md | 12 +++++++++--- loopx/pi_goal_mode/README.md | 13 +++++++++++++ 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/docs/guides/getting-started.md b/docs/guides/getting-started.md index b973dde208..f9fdcd3bdb 100644 --- a/docs/guides/getting-started.md +++ b/docs/guides/getting-started.md @@ -98,9 +98,15 @@ can discover user-installed skills: restart and runs the quota-gated goal loop through `loopx_goal_activate`. `loopx slash-commands --install --surface pi` installs it for the current project; add `--pi-project ` for another project, or `--pi-scope user` - to install it under `~/.pi/agent/extensions/loopx/` for all projects. Only - extension code is global: private bindings remain under each project's - `.loopx/pi/` (already gitignored via `.loopx/`). + to install the discoverable `index.ts` and runtime under + `/extensions/loopx/` for all projects. `` defaults to + `~/.pi/agent` and follows `PI_CODING_AGENT_DIR`. Run + `loopx slash-commands --inspect --surface pi` to read both scopes, including + stale or partial installs and a warning when both scopes would load. To + remove one scope, use `--uninstall --surface pi` with that scope; reinstall + with `--install` to upgrade managed files. Only extension code is global: + private bindings remain under each project's `.loopx/pi/` (already + gitignored via `.loopx/`). The command family is the same across surfaces, even when the host-specific entry point is different: diff --git a/loopx/pi_goal_mode/README.md b/loopx/pi_goal_mode/README.md index fe386bbe6e..26c14f29ef 100644 --- a/loopx/pi_goal_mode/README.md +++ b/loopx/pi_goal_mode/README.md @@ -45,6 +45,9 @@ into a LoopX-governed visible goal loop. ```bash loopx slash-commands --install --surface pi --pi-project . loopx slash-commands --uninstall --surface pi --pi-project . +loopx slash-commands --install --surface pi --pi-scope user +loopx slash-commands --inspect --surface pi --pi-project . +loopx slash-commands --uninstall --surface pi --pi-scope user ``` Installs two LoopX-managed files into the project (loaded after project @@ -62,6 +65,16 @@ installer at the target project so the command is correct even when run from another directory; `agent-onboard --agent-type pi --project ` emits the resolved project automatically. +The optional user scope installs the same adapter as +`/extensions/loopx/index.ts` with the runtime beside it. Pi +discovers `index.ts` inside extension subdirectories. `` defaults to +`~/.pi/agent` and follows `PI_CODING_AGENT_DIR`. A managed older user file +named `loopx-goal.ts` in that directory is retired during upgrade. Inspect +reports both scopes as absent, current, stale, partial, or user-owned, and +warns when both entries would load in the same project. Run Pi `/reload` or +restart after install, upgrade, or uninstall. Only the adapter is global: +bindings remain under the active project's `.loopx/pi/`. + ## State Bindings persist under `/.loopx/pi/` (gitignored), keyed by session. From 913ec2b8fc0dc89ae60a6299d6d70186f080bb84 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Sat, 26 Sep 2026 02:06:50 +0800 Subject: [PATCH 4/5] fix(pi): restore per-file uninstall and drop never-shipped legacy entry Review findings on #4369 asked for two repairs before merge. Uninstall now keeps the per-file semantics every other surface uses: a user-owned Pi file is reported as skipped instead of aborting the whole scope. The previous atomic abort left the managed adapter loaded while `loopx slash-commands --uninstall --surface pi` could no longer remove it, so a user who edited the runtime lost the supported way to uninstall. The `extensions/loopx/loopx-goal.ts` retirement branch covered a layout that only existed in earlier heads of this pull request; no released LoopX ever wrote it, so the compatibility path, its readback field, and its test are removed. Verified: 51 installer tests pass, slash-command-install smoke ok, ruff clean, and the reported project-scope repro now matches base on both exit code and file outcome with the real CLI. Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- loopx/pi_goal_mode/README.md | 11 ++--- loopx/pi_goal_mode/installation.py | 6 --- loopx/slash_command_install.py | 66 +++++--------------------- tests/test_slash_command_install.py | 73 ++++++++++++++++++++--------- 4 files changed, 68 insertions(+), 88 deletions(-) diff --git a/loopx/pi_goal_mode/README.md b/loopx/pi_goal_mode/README.md index 26c14f29ef..6e666880ca 100644 --- a/loopx/pi_goal_mode/README.md +++ b/loopx/pi_goal_mode/README.md @@ -68,12 +68,11 @@ resolved project automatically. The optional user scope installs the same adapter as `/extensions/loopx/index.ts` with the runtime beside it. Pi discovers `index.ts` inside extension subdirectories. `` defaults to -`~/.pi/agent` and follows `PI_CODING_AGENT_DIR`. A managed older user file -named `loopx-goal.ts` in that directory is retired during upgrade. Inspect -reports both scopes as absent, current, stale, partial, or user-owned, and -warns when both entries would load in the same project. Run Pi `/reload` or -restart after install, upgrade, or uninstall. Only the adapter is global: -bindings remain under the active project's `.loopx/pi/`. +`~/.pi/agent` and follows `PI_CODING_AGENT_DIR`. Inspect reports both scopes as +absent, current, stale, partial, or user-owned, and warns when both entries +would load in the same project. Run Pi `/reload` or restart after install, +upgrade, or uninstall. Only the adapter is global: bindings remain under the +active project's `.loopx/pi/`. ## State diff --git a/loopx/pi_goal_mode/installation.py b/loopx/pi_goal_mode/installation.py index f53bf06bf6..c33f270419 100644 --- a/loopx/pi_goal_mode/installation.py +++ b/loopx/pi_goal_mode/installation.py @@ -72,12 +72,6 @@ def inspect_pi_installations( "runtime_path": str(runtime), } - legacy_user_path = Path(scopes["user"]["extension_path"]).with_name("loopx-goal.ts") - legacy_user_entry = legacy_user_path.exists() - scopes["user"]["legacy_entry_path"] = str(legacy_user_path) if legacy_user_entry else None - if legacy_user_entry and scopes["user"]["status"] == "absent": - scopes["user"]["status"] = "stale" - project_entry = Path(scopes["project"]["extension_path"]).exists() user_entry = Path(scopes["user"]["extension_path"]).exists() duplicate_load = project_entry and user_entry diff --git a/loopx/slash_command_install.py b/loopx/slash_command_install.py index 14c4fcceae..91c540e8a0 100644 --- a/loopx/slash_command_install.py +++ b/loopx/slash_command_install.py @@ -1316,58 +1316,28 @@ def install_slash_commands( if "pi" in effective_surfaces: extension_path = _pi_extension_path(pi_extension_root, scope=pi_scope) runtime_path = _pi_runtime_path(pi_extension_root) - legacy_user_path = pi_extension_root / "loopx-goal.ts" if pi_scope == "user" else None extension_content = pi_extension_source() runtime_content = pi_runtime_source() if uninstall: - retire_targets = [extension_path, runtime_path] - if legacy_user_path is not None and legacy_user_path.exists(): - retire_targets.append(legacy_user_path) - user_owned_pi_paths = [ - str(path) for path in retire_targets - if _retire_status(path, execute=False) == "skipped_user_file" - ] - if user_owned_pi_paths: + # Uninstall stays per-file like every other surface: LoopX-managed + # files are removed and a user-owned file is reported as skipped. + # Aborting the whole scope instead would leave the managed adapter + # loaded and remove the only supported way to uninstall it. + for mechanism, path in ( + ("pi_goal_extension", extension_path), + ("pi_goal_extension_runtime", runtime_path), + ): installed.append( { "surface": "pi", "host_surfaces": ["pi"], - "mechanism": "pi_goal_extension", + "mechanism": mechanism, "command": "/loopx", - "path": str(extension_path), - "status": "blocked_user_owned_pi_file", + "path": str(path), + "status": _retire_status(path, execute=execute), "invoke_as": ["/loopx", "loopx_goal_activate"], - "conflicts": user_owned_pi_paths, } ) - else: - for mechanism, path in ( - ("pi_goal_extension", extension_path), - ("pi_goal_extension_runtime", runtime_path), - ): - installed.append( - { - "surface": "pi", - "host_surfaces": ["pi"], - "mechanism": mechanism, - "command": "/loopx", - "path": str(path), - "status": _retire_status(path, execute=execute), - "invoke_as": ["/loopx", "loopx_goal_activate"], - } - ) - if legacy_user_path is not None and legacy_user_path.exists(): - installed.append( - { - "surface": "pi", - "host_surfaces": ["pi"], - "mechanism": "pi_goal_legacy_user_extension", - "command": "/loopx", - "path": str(legacy_user_path), - "status": _retire_status(legacy_user_path, execute=execute), - "invoke_as": [], - } - ) else: # The adapter and its loop runtime are one atomic delivery unit: # preflight both targets and fail closed with zero writes when any @@ -1416,20 +1386,6 @@ def install_slash_commands( "invoke_as": ["/loopx", "loopx_goal_activate"], } ) - if legacy_user_path is not None: - retired = _retire_managed_file(legacy_user_path, execute=execute) - if retired: - installed.append( - { - "surface": "pi", - "host_surfaces": ["pi"], - "mechanism": "pi_goal_legacy_user_extension", - "command": "/loopx", - "path": str(legacy_user_path), - "status": retired, - "invoke_as": [], - } - ) status_counts: dict[str, int] = {} for item in installed: diff --git a/tests/test_slash_command_install.py b/tests/test_slash_command_install.py index a6bb532b23..290d312cec 100644 --- a/tests/test_slash_command_install.py +++ b/tests/test_slash_command_install.py @@ -728,24 +728,10 @@ def readback() -> dict[str, object]: assert readback()["location"] == "project-local" -def test_pi_user_scope_migrates_managed_legacy_entry(tmp_path: Path) -> None: - home = tmp_path / "home" - root = home / ".pi/agent/extensions/loopx" - root.mkdir(parents=True) - legacy = root / "loopx-goal.ts" - legacy.write_text(slash_command_install.pi_extension_source(), encoding="utf-8") - assert inspect_pi_installations(pi_project=str(tmp_path), pi_user_home=str(home))["scopes"]["user"]["status"] == "stale" - - payload = install_slash_commands( - execute=True, surfaces=["pi"], pi_scope="user", pi_user_home=str(home) - ) - assert payload["ok"] is True - assert not legacy.exists() - assert (root / "index.ts").is_file() - assert _row(payload, "pi_goal_legacy_user_extension")["status"] == "retired_managed_file" - - -def test_pi_user_uninstall_blocks_atomically_on_user_owned_file(tmp_path: Path) -> None: +def test_pi_user_uninstall_retires_managed_entry_and_keeps_user_runtime( + tmp_path: Path, +) -> None: + """A user-owned runtime must not make the managed adapter unremovable.""" home = tmp_path / "home" install_slash_commands( execute=True, surfaces=["pi"], pi_scope="user", pi_user_home=str(home) @@ -758,9 +744,54 @@ def test_pi_user_uninstall_blocks_atomically_on_user_owned_file(tmp_path: Path) payload = install_slash_commands( execute=True, uninstall=True, surfaces=["pi"], pi_scope="user", pi_user_home=str(home) ) - assert payload["ok"] is False - assert _row(payload, "pi_goal_extension")["status"] == "blocked_user_owned_pi_file" - assert extension.exists() + assert payload["ok"] is True + assert _row(payload, "pi_goal_extension")["status"] == "retired_managed_file" + assert _row(payload, "pi_goal_extension_runtime")["status"] == "skipped_user_file" + assert not extension.exists() + assert runtime.read_text(encoding="utf-8") == "// user replacement\n" + + +def test_pi_project_uninstall_survives_edited_runtime( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """Regression: the default project scope must keep its per-file uninstall.""" + installed = loopx_main( + [ + "--format", + "json", + "slash-commands", + "--install", + "--surface", + "pi", + "--pi-project", + str(tmp_path), + ] + ) + capsys.readouterr() + assert installed == 0 + extension = tmp_path / ".pi/extensions/loopx-goal.ts" + runtime = tmp_path / ".pi/extensions/pi-goal-loop-runtime.mjs" + assert extension.is_file() and runtime.is_file() + runtime.write_text("// user replacement\n", encoding="utf-8") + + exit_code = loopx_main( + [ + "--format", + "json", + "slash-commands", + "--uninstall", + "--surface", + "pi", + "--pi-project", + str(tmp_path), + ] + ) + payload = json.loads(capsys.readouterr().out) + assert exit_code == 0 + assert payload["ok"] is True + assert _row(payload, "pi_goal_extension")["status"] == "retired_managed_file" + assert _row(payload, "pi_goal_extension_runtime")["status"] == "skipped_user_file" + assert not extension.exists() assert runtime.read_text(encoding="utf-8") == "// user replacement\n" From 6a4e38946ad5fe5222aa25dc136cfc076e06518e Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Sat, 26 Sep 2026 02:22:29 +0800 Subject: [PATCH 5/5] fix(pi): report the resolved Pi install target in the install notes The install payload told every caller that the Pi surface lands in the project's `.pi/extensions/`, which is wrong for `--pi-scope user` and was left stale when the scope-aware docs were added. The note now names the scope it actually used, so the CLI readback cannot contradict the file that was just written. Verified: 51 installer tests pass (new assertion pins scope=user), the installer smoke passes, and ruff is clean. Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- loopx/slash_command_install.py | 7 ++++++- tests/test_slash_command_install.py | 1 + 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/loopx/slash_command_install.py b/loopx/slash_command_install.py index 91c540e8a0..589be9f807 100644 --- a/loopx/slash_command_install.py +++ b/loopx/slash_command_install.py @@ -1392,6 +1392,11 @@ def install_slash_commands( status = str(item["status"]) status_counts[status] = status_counts.get(status, 0) + 1 + pi_target_note = ( + "the user agent dir /extensions/loopx/ (scope=user)" + if pi_scope == "user" + else "the project's .pi/extensions/ (scope=project)" + ) return { "ok": not any(status.startswith("blocked_") for status in status_counts), "schema_version": SCHEMA_VERSION, @@ -1441,7 +1446,7 @@ def install_slash_commands( f"Kiro CLI discovers global skills from {_KIRO_SKILLS_ROOT_LABEL}//SKILL.md (default ~/.kiro/skills) and exposes each as a `/` slash command; the kiro-cli surface is opt-in and resolves KIRO_HOME so install and uninstall target the profile the running host reads. Kiro resolves .kiro/prompts and KIRO_HOME/prompts before skills, so a same-named user prompt shadows the managed skill.", "OpenCode discovers global skills from OPENCODE_CONFIG_DIR/skills in addition to the static command facade; a command is typed by the user, a skill can be reached by the model itself.", "The default all surface installs only OpenCode's static command facade; the executable goal bridge requires --with-goal-bridge.", - "The Pi surface is opt-in and installs the self-contained goal extension and its loop runtime into the project's .pi/extensions/; it is not part of the default all surface.", + f"The Pi surface is opt-in and installs the self-contained goal extension and its loop runtime into {pi_target_note}; it is not part of the default all surface.", "The OpenCode goal bridge uses Bun-managed config-directory dependencies and must replace any direct goal-plugin registration.", "OpenCode bridge uninstall preserves package.json dependencies because they may be shared by user-owned local plugins.", "Uninstall is fail-closed: it retires only files carrying the LoopX managed marker and leaves user-owned files in place.", diff --git a/tests/test_slash_command_install.py b/tests/test_slash_command_install.py index 290d312cec..605b9ad9bc 100644 --- a/tests/test_slash_command_install.py +++ b/tests/test_slash_command_install.py @@ -613,6 +613,7 @@ def test_pi_user_scope_installs_atomic_extension_unit(tmp_path: Path) -> None: assert payload["summary"]["pi_scope"] == "user" assert payload["summary"]["pi_extension_path"] == str(root / "index.ts") assert payload["summary"]["pi_runtime_path"] == str(root / "pi-goal-loop-runtime.mjs") + assert any("scope=user" in note for note in payload["notes"]) assert (root / "index.ts").is_file() assert (root / "pi-goal-loop-runtime.mjs").is_file() assert inspect_pi_installations(pi_project=str(tmp_path), pi_user_home=str(home))["location"] == "user-global"