From c041a521731e59713c91a795212003b8ae10b6dc Mon Sep 17 00:00:00 2001 From: omartelo Date: Sun, 1 Nov 2026 18:14:20 -0300 Subject: [PATCH] feat(mcp): manage project services from an agent with the *_service tools and lich service An agent can do to a project's services what the Services footer does: list, create, edit, delete, start, stop, force-stop and restart one, start or stop them all, and read its console as it is at the call. A service is named by its name in the project, the caller's own unless one is named; spawn.Project resolves the project to the id the services methods take. services.LiveOutput reads a running service's replay tail, leaving Output as the window reads it: nil while the service runs. --- CHANGELOG.md | 2 + api/services.go | 21 +- api/spawn.go | 16 + api/testdata/contract.golden | 13 + docs/cli.md | 51 ++- internal/cli/cli.go | 2 + internal/cli/help.go | 12 + internal/cli/mcp.go | 4 +- internal/cli/mcp_test.go | 75 +++-- internal/cli/services.go | 426 ++++++++++++++++++++++++ internal/cli/services_test.go | 316 ++++++++++++++++++ internal/routes/routes.go | 1 + internal/routes/services.go | 1 + internal/services/rpc.go | 6 + internal/services/run.go | 9 + internal/services/services_unix_test.go | 30 ++ internal/spawn/project_ref.go | 22 ++ internal/spawn/project_ref_test.go | 33 ++ internal/terminal/service.go | 12 + 19 files changed, 1007 insertions(+), 45 deletions(-) create mode 100644 internal/cli/services.go create mode 100644 internal/cli/services_test.go create mode 100644 internal/spawn/project_ref.go create mode 100644 internal/spawn/project_ref_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 379e1563f..68a231997 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - opencode 2 runs in lich. lich tells it from opencode 1 by its version and opens it the way 2 expects: each session on a server of its own, so its profile, its model and lich's tools reach it, with forks, Unfold long pastes, profile logins and the OpenCode Go gauge working as they do on opencode 1. Conversations started in opencode 2, and opencode 1 ones it has continued, show their cost, closing words and search results again, and are listed in History. The card's spinner, ring, bell and title need the lich plugin's next release, installed from Settings › Providers, which now goes where opencode 2 reads plugins and takes the place of the copy an older lich installed. A session lich opens is not on opencode 2's shared background server, so `opencode pair` and the desktop app don't see it. - The Description heading on the Pulls screen's Overview has a ⋯ menu, as GitHub's comments do: Copy link copies the pull request's address, Copy Markdown its description as written, and Edit opens the description for editing, which the Edit button beside the heading did before. +- An agent can manage a project's services as the Services footer does: list, create, edit, delete, start, stop, force-stop and restart one, start or stop them all, and read what one has printed, running or stopped, with the `*_service` MCP tools or `lich service` from a terminal. A service is named by its name in the project, the agent's own unless it names another. + ### Changed - Pull request descriptions, comments and Markdown previews are easier to read, with brighter text, clearer headings and links, inline code highlighted, more room between lines and blocks, and wrapped list items aligned with their text. diff --git a/api/services.go b/api/services.go index 0e704fd99..0ad6d4728 100644 --- a/api/services.go +++ b/api/services.go @@ -1,16 +1,17 @@ package api var ( - ServicesList = define[None, []Service]("services.List") - ServicesSave = define[SaveServiceOptions, Service]("services.Save") - ServicesDelete = define[ServiceIDOptions, None]("services.Delete") - ServicesStart = define[ServiceIDOptions, None]("services.Start") - ServicesStop = define[ServiceIDOptions, None]("services.Stop") - ServicesForceStop = define[ServiceIDOptions, None]("services.ForceStop") - ServicesRestart = define[ServiceIDOptions, None]("services.Restart") - ServicesStartAll = define[ProjectIDOptions, None]("services.StartAll") - ServicesStopAll = define[ProjectIDOptions, None]("services.StopAll") - ServicesOutput = define[ServiceIDOptions, []byte]("services.Output") + ServicesList = define[None, []Service]("services.List") + ServicesSave = define[SaveServiceOptions, Service]("services.Save") + ServicesDelete = define[ServiceIDOptions, None]("services.Delete") + ServicesStart = define[ServiceIDOptions, None]("services.Start") + ServicesStop = define[ServiceIDOptions, None]("services.Stop") + ServicesForceStop = define[ServiceIDOptions, None]("services.ForceStop") + ServicesRestart = define[ServiceIDOptions, None]("services.Restart") + ServicesStartAll = define[ProjectIDOptions, None]("services.StartAll") + ServicesStopAll = define[ProjectIDOptions, None]("services.StopAll") + ServicesOutput = define[ServiceIDOptions, []byte]("services.Output") + ServicesLiveOutput = define[ServiceIDOptions, []byte]("services.LiveOutput") ) // The states a Service is in. A service that is not running is stopped when it diff --git a/api/spawn.go b/api/spawn.go index 2418278e3..0b310971a 100644 --- a/api/spawn.go +++ b/api/spawn.go @@ -13,6 +13,7 @@ var ( SpawnRenameFolder = define[RenameFolderOptions, RenamedFolder]("spawn.RenameFolder") SpawnColorFolder = define[ColorFolderOptions, ColoredFolder]("spawn.ColorFolder") SpawnRename = define[RenameSessionOptions, RenamedSession]("spawn.Rename") + SpawnProject = define[ProjectRefOptions, ProjectRef]("spawn.Project") ) // OpenSessionOptions is one Open call. It travels as a single JSON object rather than @@ -303,3 +304,18 @@ type RenamedSession struct { Label string `json:"label"` Previous string `json:"previous"` } + +// ProjectRefOptions names an open project the way every spawn call does: by +// name or by directory path, empty for the caller's own. +type ProjectRefOptions struct { + From string `json:"from"` + Project string `json:"project"` +} + +// ProjectRef is the open project a ProjectRefOptions resolved to: the id the +// methods keyed by project (services.Save, services.StartAll) take. +type ProjectRef struct { + ID string `json:"id"` + Name string `json:"name"` + Path string `json:"path"` +} diff --git a/api/testdata/contract.golden b/api/testdata/contract.golden index 42ca8bdfb..f126ca6f5 100644 --- a/api/testdata/contract.golden +++ b/api/testdata/contract.golden @@ -157,6 +157,7 @@ relay.Wait(WaitOptions) SendResult services.Delete(ServiceIDOptions) None services.ForceStop(ServiceIDOptions) None services.List(None) []Service +services.LiveOutput(ServiceIDOptions) base64 services.Output(ServiceIDOptions) base64 services.Restart(ServiceIDOptions) None services.Save(SaveServiceOptions) Service @@ -173,6 +174,7 @@ spawn.File(FileSessionOptions) FiledSession spawn.Folders(FoldersOptions) []SessionFolder spawn.Open(OpenSessionOptions) OpenedSession spawn.OpenSubagent(OpenSubagentOptions) OpenedSession +spawn.Project(ProjectRefOptions) ProjectRef spawn.Rename(RenameSessionOptions) RenamedSession spawn.RenameFolder(RenameFolderOptions) RenamedFolder spawn.Worktrees(WorktreesOptions) []WorktreeCheckout @@ -1191,6 +1193,17 @@ OpenSubagentOptions { ultracode bool } +ProjectRefOptions { + from string + project string +} + +ProjectRef { + id string + name string + path string +} + RenameSessionOptions { from string target string diff --git a/docs/cli.md b/docs/cli.md index b95507afc..2568ca1c9 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -672,6 +672,44 @@ session on /home/you/src/revu again reopens the project with them. - **The home project is refused**, as the window refuses it. - Unlike the window's close, nothing asks first when sessions are running. +### `lich service [--project ] [--name ] [--command ] [--start-with-lich[=false]] [--json] [args]` + +Manages a project's services, the commands the window's Services footer runs +on a console of their own. The project defaults to the caller's own; a service +is named by its name in the project, matched without regard to case. Flags go +before the verb. + +- `list` prints every service with its state (`running`, `stopping`, + `stopped`, `failed`): + + ``` + service state command + web running pnpm dev + db stopped docker compose up db + ``` + +- `add ` saves a new one, stopped; `--start-with-lich` starts + it whenever lich starts with the project open. +- `edit ` changes only what `--name`, `--command` and + `--start-with-lich` (`=false` to clear it) give. A running service keeps its + old command until its next start. +- `delete ` removes a stopped one; a running one is refused. +- `start ` and `stop `. Stop asks as Ctrl+C at the console + does, so the row printed may still read `stopping`. +- `force-stop ` kills one that ignores the stop or takes too long. +- `restart ` stops a running one and starts it again once it exits, + picking up an edited command; one that is not running is started. +- `start-all` and `stop-all` start or stop every service of the project, and + print the list as it is afterwards. +- `output ` prints the console as it is at that moment, terminal + escapes included: what a running one has printed so far (its last 2 MiB), + or what a stopped or failed one printed in its last run. One read, not a + stream. + +`list`, `start-all` and `stop-all` print the project's services; every other +verb but `output` prints the one service as it is afterwards. `--json` prints +them as the `services` RPC result shapes them, and `output` as a JSON string. + ### `lich rename [--project ] [--json] []