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
20 changes: 13 additions & 7 deletions docs/guides/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,13 +94,19 @@ 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 <path>`
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 <path>` for another project, or `--pi-scope user`
to install the discoverable `index.ts` and runtime under
`<agent-dir>/extensions/loopx/` for all projects. `<agent-dir>` 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:
Expand Down
26 changes: 22 additions & 4 deletions loopx/cli_commands/slash_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -122,6 +125,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",
Expand All @@ -137,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),
Expand All @@ -152,6 +169,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
Expand Down
12 changes: 12 additions & 0 deletions loopx/pi_goal_mode/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -62,6 +65,15 @@ installer at the target project so the command is correct even when run from
another directory; `agent-onboard --agent-type pi --project <path>` emits the
resolved project automatically.

The optional user scope installs the same adapter as
`<agent-dir>/extensions/loopx/index.ts` with the runtime beside it. Pi
discovers `index.ts` inside extension subdirectories. `<agent-dir>` defaults to
`~/.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

Bindings persist under `<project>/.loopx/pi/` (gitignored), keyed by session.
Expand Down
113 changes: 113 additions & 0 deletions loopx/pi_goal_mode/installation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
"""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),
}

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"
42 changes: 29 additions & 13 deletions loopx/slash_command_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -759,14 +765,6 @@ 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_runtime_path(project_root: Path) -> Path:
return project_root / ".pi" / "extensions" / "pi-goal-loop-runtime.mjs"


def install_slash_commands(
*,
execute: bool,
Expand All @@ -785,6 +783,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)
Expand All @@ -796,7 +796,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_agent_dir = _pi_agent_dir(pi_user_home)
pi_extension_root = _pi_extension_root(
pi_project_root, scope=pi_scope, agent_dir=pi_agent_dir
)
installed: list[dict[str, Any]] = []

if with_goal_bridge and "opencode" not in effective_surfaces:
Expand Down Expand Up @@ -1308,11 +1314,15 @@ 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, scope=pi_scope)
runtime_path = _pi_runtime_path(pi_extension_root)
extension_content = pi_extension_source()
runtime_content = pi_runtime_source()
if uninstall:
# 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),
Expand Down Expand Up @@ -1382,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 <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,
Expand All @@ -1408,8 +1423,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, 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": (
"Uninstall removes only LoopX-managed files; user files without a LoopX managed marker are preserved"
Expand All @@ -1430,7 +1446,7 @@ def install_slash_commands(
f"Kiro CLI discovers global skills from {_KIRO_SKILLS_ROOT_LABEL}/<name>/SKILL.md (default ~/.kiro/skills) and exposes each as a `/<skill-name>` 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.",
Expand Down
Loading
Loading