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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **`restartMcp` key on custom ops.** A custom op that invalidates state the warm MCP daemons cache (autoload map, PHPStan/PHPUnit result caches, …) can declare `"restartMcp": true` (every server in the `mcp` block) or `"restartMcp": ["name", …]` (a subset). After the op's `cmd` **succeeds**, supertool SIGTERMs those daemons via the same `stop.py` path the new-file invalidation uses; each cold-starts on its next call with a fresh index. Skipped when the cmd fails. Lets a single cache-clearing op (e.g. `dvsi_clearcache`) also reset the warm LSP layer in one round-trip, instead of leaving daemons serving stale analysis until the idle timeout.
- **`gl-mrs` — MR triage board.** Lists MRs (default `author=@me`), sorted failing-first then stalest, each enriched in parallel with everything you'd otherwise round-trip for: pipeline status (a failure shows the failed **job name** — `phpstan2`, `test_unit_dpt` — i.e. the failure class, pre-empting a `gl-job` call), approval state, age, diff size, watch-state cross-reference (which MRs already have a live `watch` poller), and `conflict`/`draft`/`threads` flags. Footer points at the first failing-and-unwatched MR. Filters: `author`/`reviewer`/`assignee`/`label`/`milestone`/`state`/`per`. Flags: `nopipe`, `iids` (bare id list), `failed` (only failing). Tunable via `enrich_workers`/`enrich_cap`/`per_page`. "Mine" is just the default filter, not a special op — enumeration is a platform concern, kept out of the generic `watch` preset. See [docs/presets/gitlab.md](docs/presets/gitlab.md).
- **`presets/watch/watch-mine.sh` — watch-a-whole-query supervisor.** Glue between a "list mine" op (`gl-mrs`/`gh-prs`) and the `watch` preset: runs the feed op, extracts ids, spawns one watcher each. Idempotent, so it's safe on `/loop`. `gl-mrs:failed,iids | watch` turns "ping me when any of my MRs goes red" into one looped command. See [docs/presets/watch.md](docs/presets/watch.md).
- **`watch` preset — background event pollers with async wake into Claude Code.** New ops `watch:SOURCE:ID[:only=...]`, `unwatch:SOURCE:ID`, `watches`. Each invocation forks a detached poller that emits state-change events to a UDS socket, a status file, and macOS Notification Center. Source-plugin contract makes new sources ~50 lines. Bundled sources: `github-pr` (PR state, checks, reviews, comments, merge/close, conflicts) and `gitlab-mr` (MR state, pipeline, merge/close, conflicts). Closes [#165](https://github.com/Digital-Process-Tools/claude-supertool/issues/165). See [docs/presets/watch.md](docs/presets/watch.md).
Expand Down
43 changes: 41 additions & 2 deletions supertool.py
Original file line number Diff line number Diff line change
Expand Up @@ -867,7 +867,7 @@ def _resolve_custom_op(op: str, parts: List[str]) -> str | None:
cmd = cmd.replace("{argjoin}", shlex.quote(arg_join))

# Pass extra config keys as SUPERTOOL_ env vars
_RESERVED_KEYS = {"cmd", "timeout", "description", "syntax", "example", "status"}
_RESERVED_KEYS = {"cmd", "timeout", "description", "syntax", "example", "status", "restartMcp"}
env = dict(os.environ)
if isinstance(entry, dict):
for k, v in entry.items():
Expand All @@ -893,14 +893,53 @@ def _resolve_custom_op(op: str, parts: List[str]) -> str | None:
if result.stderr:
output += result.stderr
return f"FAIL ({elapsed:.2f}s)\n{output}"
return f"PASS ({elapsed:.2f}s)\n{output}"
return f"PASS ({elapsed:.2f}s)\n{output}{_maybe_restart_mcp(entry)}"
except subprocess.TimeoutExpired:
elapsed = time.monotonic() - t0
return f"FAIL (timeout {elapsed:.1f}s > {timeout}s)\n"
except OSError as e:
return f"FAIL: {e}\n"


def _maybe_restart_mcp(entry: object) -> str:
"""SIGTERM the warm MCP daemons a custom op asks to restart via `restartMcp`.

A custom op that invalidates state the warm LSP daemons cache (autoload map,
PHPStan result cache, etc.) can declare `restartMcp` so the daemons are
stopped after the cmd succeeds; the next op that touches each server
cold-starts a fresh daemon that re-reads the cleared state. Accepts:
true -> every server in the config "mcp" block
["a","b"] -> only those servers
"name" -> a single server
Names not present in the config "mcp" block are reported separately instead
of being counted as restarted, so the status line never claims to have
stopped a daemon that was never configured. Returns a one-line status suffix
(empty when nothing to restart). Best-effort like the new-file path — a stop
failure never fails the op.
"""
if not isinstance(entry, dict):
return ""
spec = entry.get("restartMcp")
if not spec:
return ""
if spec is True:
names = list(_mcp_specs.keys())
elif isinstance(spec, list):
names = [str(n) for n in spec]
else:
names = [str(spec)]
known = [n for n in names if n in _mcp_specs]
unknown = [n for n in names if n not in _mcp_specs]
for name in known:
_mcp_stop_server(name)
note = ""
if known:
note += f"mcp: restarted {len(known)} daemon(s) ({', '.join(known)})\n"
if unknown:
note += f"mcp: unknown server(s) ignored ({', '.join(unknown)})\n"
return note


_IN_ALIAS = False # recursion guard — prevents alias-from-alias expansion


Expand Down
68 changes: 68 additions & 0 deletions tests/test_custom_ops.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,74 @@ def test_empty_cmd_returns_error(self) -> None:
assert result is not None
assert "ERROR" in result

def test_restart_mcp_true_stops_all_configured(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""restartMcp: true stops every server in the mcp block after a successful cmd."""
stopped: list[str] = []
monkeypatch.setattr(supertool, "_mcp_stop_server", lambda name: stopped.append(name))
monkeypatch.setattr(supertool, "_mcp_specs", {"phpstan-warm": {}, "rector-warm": {}})
supertool._CONFIG = {"ops": {"clean": {"cmd": "echo ok", "restartMcp": True}}}
result = supertool._resolve_custom_op("clean", ["clean", "x"])
assert result is not None
assert "PASS" in result
assert sorted(stopped) == ["phpstan-warm", "rector-warm"]
assert "restarted 2 daemon(s)" in result

def test_restart_mcp_list_stops_only_named(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""restartMcp: [names] stops only the listed servers."""
stopped: list[str] = []
monkeypatch.setattr(supertool, "_mcp_stop_server", lambda name: stopped.append(name))
monkeypatch.setattr(supertool, "_mcp_specs", {"phpstan-warm": {}, "rector-warm": {}})
supertool._CONFIG = {"ops": {"clean": {"cmd": "echo ok", "restartMcp": ["rector-warm"]}}}
result = supertool._resolve_custom_op("clean", ["clean", "x"])
assert result is not None
assert stopped == ["rector-warm"]
assert "phpstan-warm" not in stopped

def test_restart_mcp_single_string(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""restartMcp: "name" (a single string) stops just that server."""
stopped: list[str] = []
monkeypatch.setattr(supertool, "_mcp_stop_server", lambda name: stopped.append(name))
monkeypatch.setattr(supertool, "_mcp_specs", {"phpstan-warm": {}, "rector-warm": {}})
supertool._CONFIG = {"ops": {"clean": {"cmd": "echo ok", "restartMcp": "phpstan-warm"}}}
result = supertool._resolve_custom_op("clean", ["clean", "x"])
assert result is not None
assert stopped == ["phpstan-warm"]
assert "restarted 1 daemon(s)" in result

def test_restart_mcp_unknown_name_not_counted(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""A name absent from the mcp block is reported as ignored, not restarted."""
stopped: list[str] = []
monkeypatch.setattr(supertool, "_mcp_stop_server", lambda name: stopped.append(name))
monkeypatch.setattr(supertool, "_mcp_specs", {"phpstan-warm": {}})
supertool._CONFIG = {"ops": {"clean": {"cmd": "echo ok", "restartMcp": ["phpstan-warm", "typo-warm"]}}}
result = supertool._resolve_custom_op("clean", ["clean", "x"])
assert result is not None
assert stopped == ["phpstan-warm"]
assert "restarted 1 daemon(s)" in result
assert "unknown server(s) ignored (typo-warm)" in result

def test_restart_mcp_skipped_on_cmd_failure(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""A failed cmd must not restart any daemon."""
stopped: list[str] = []
monkeypatch.setattr(supertool, "_mcp_stop_server", lambda name: stopped.append(name))
monkeypatch.setattr(supertool, "_mcp_specs", {"phpstan-warm": {}})
supertool._CONFIG = {"ops": {"clean": {"cmd": "false", "restartMcp": True}}}
result = supertool._resolve_custom_op("clean", ["clean", "x"])
assert result is not None
assert "FAIL" in result
assert stopped == []

def test_no_restart_mcp_leaves_daemons(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""An op without restartMcp never touches the daemons."""
stopped: list[str] = []
monkeypatch.setattr(supertool, "_mcp_stop_server", lambda name: stopped.append(name))
monkeypatch.setattr(supertool, "_mcp_specs", {"phpstan-warm": {}})
supertool._CONFIG = {"ops": {"plain": {"cmd": "echo ok"}}}
result = supertool._resolve_custom_op("plain", ["plain", "x"])
assert result is not None
assert "PASS" in result
assert stopped == []

def test_cmd_without_file_placeholder(self) -> None:
"""A cmd without {file} runs as-is (global command)."""
supertool._CONFIG = {
Expand Down
Loading