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: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
21 changes: 11 additions & 10 deletions api/services.go
Original file line number Diff line number Diff line change
@@ -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
Expand Down
16 changes: 16 additions & 0 deletions api/spawn.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"`
}
13 changes: 13 additions & 0 deletions api/testdata/contract.golden
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -1191,6 +1193,17 @@ OpenSubagentOptions {
ultracode bool
}

ProjectRefOptions {
from string
project string
}

ProjectRef {
id string
name string
path string
}

RenameSessionOptions {
from string
target string
Expand Down
51 changes: 50 additions & 1 deletion docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-or-path>] [--name <name>] [--command <command>] [--start-with-lich[=false]] [--json] <verb> [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 <name> <command>` saves a new one, stopped; `--start-with-lich` starts
it whenever lich starts with the project open.
- `edit <service>` 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 <service>` removes a stopped one; a running one is refused.
- `start <service>` and `stop <service>`. Stop asks as Ctrl+C at the console
does, so the row printed may still read `stopping`.
- `force-stop <service>` kills one that ignores the stop or takes too long.
- `restart <service>` 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 <service>` 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 <name-or-path>] [--json] [<session>] <label>`

Renames a session — the name on its card, which is also the name it is addressed
Expand Down Expand Up @@ -1007,6 +1045,17 @@ at lich.
| `open_session` | optional `project` (a name, or an absolute directory path, which is opened as a project first), `kind`, `worktree`, `base`, `model`, `effort`, `ultracode`, `folder` — `lich open` — plus optional `prompt` and `private` — `lich open --prompt [--private]`, the same hand-off in the same call. |
| `close_session` | `session`, optional `project`, `worktree` (`keep`/`remove`), `force`. |
| `close_project` | `project` (a name or a directory path) — `lich close-project`. |
| `list_services` | optional `project`: the project's services with their state, as JSON. `lich service list`. |
| `create_service` | `name`, `command`, optional `start_with_lich` and `project`. Saved, not started. `lich service add`. |
| `edit_service` | `service`, optional `name`, `command`, `start_with_lich` and `project`; what is left out stays. `lich service edit`. |
| `delete_service` | `service`, optional `project`; a running one is refused. `lich service delete`. |
| `start_service` | `service`, optional `project`. `lich service start`. |
| `stop_service` | `service`, optional `project`; asks it to stop as Ctrl+C does, so the answer may read `stopping`. `lich service stop`. |
| `force_stop_service` | `service`, optional `project`; kills it without waiting. `lich service force-stop`. |
| `restart_service` | `service`, optional `project`. `lich service restart`. |
| `start_all_services` | optional `project`; answers with the project's services. `lich service start-all`. |
| `stop_all_services` | optional `project`; answers with the project's services. `lich service stop-all`. |
| `read_service_output` | `service`, optional `project`: the console as it is now, a running one's so far or a stopped one's last run. `lich service output`. |
| `rename_session` | `label`, optional `session` (omitted renames the caller's own) and `project` — `lich rename`. |
| `control_session` | Experimental. `session`, `action` (`prompt`, `abort`, `model`, `effort`, `command`), optional `value`, `args` (`command` only) and `project`. `lich control`. Delivered is a result, not an error; a command the session never took is withdrawn and fails. |
| `ask_session` | Experimental. `session`, `question`, optional `project`. `lich ask`: the answer is the result, and every way it ends without one is an error. |
Expand All @@ -1023,7 +1072,7 @@ error: the agent should read what went wrong and act on it, not lose the turn.
clients inject into the agent's system prompt. It is the one place the whole
journey (fan out into worktree sessions, carry on, collect) is told as one;
the tool descriptions each only explain their own door. `list_sessions`,
`list_worktrees` and `list_folders` are annotated `readOnlyHint`, so a client
`list_worktrees`, `list_folders`, `list_services` and `read_service_output` are annotated `readOnlyHint`, so a client
may auto-allow them.

`file_session`, `rename_folder` and `color_folder` refuse a call that leaves out
Expand Down
2 changes: 2 additions & 0 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,8 @@ func dispatch(args []string, c *client) int {
return c.run(c.close, args[1:])
case "close-project":
return c.run(c.closeProject, args[1:])
case "service":
return c.run(c.service, args[1:])
case "rename":
return c.run(c.rename, args[1:])
case "control":
Expand Down
12 changes: 12 additions & 0 deletions internal/cli/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,18 @@ var commands = []command{
"it, so opening a session on its path reopens it. The project the command\n" +
"runs in and the home project are refused.",
},
{
name: "service",
args: "[--project <name-or-path>] [--name <name>] [--command <command>]\n" +
" [--start-with-lich[=false]] [--json]\n" +
" list | add <name> <command> | edit <service> | delete <service>\n" +
" | start <service> | stop <service> | force-stop <service>\n" +
" | restart <service> | start-all | stop-all | output <service>",
about: "Manage a project's services, the commands lich runs on a console of their\n" +
"own: list, save, change, delete, start, stop, kill or restart one, start\n" +
"or stop them all, or print its console as it is now. Flags go before\n" +
"the verb; edit changes only the ones given.",
},
{
name: "rename",
args: "[--project <name-or-path>] [--json] [<session>] <label>",
Expand Down
4 changes: 2 additions & 2 deletions internal/cli/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -465,7 +465,7 @@ func property(kind, description string) map[string]any {
}

// mcpTools is the whole tool surface. Adding one is an entry here, or in
// folderTools, controlTools, askTools, closeProjectTools or browserTools. That is why registering
// folderTools, controlTools, askTools, closeProjectTools, serviceTools or browserTools. That is why registering
// MCP once buys every tool lich grows later, on every provider that took the
// registration, with no further per-provider work.
var mcpTools = append([]mcpTool{
Expand Down Expand Up @@ -761,7 +761,7 @@ var mcpTools = append([]mcpTool{
return "Answer sent.", nil
},
},
}, append(append(append(append(folderTools, controlTools...), askTools...), closeProjectTools...), browserTools...)...)
}, append(append(append(append(append(folderTools, controlTools...), askTools...), closeProjectTools...), serviceTools...), browserTools...)...)

// handOver gives a just-opened session its first task, wording the outcome the
// way send_to_session words it — the caller should not have to learn two
Expand Down
75 changes: 43 additions & 32 deletions internal/cli/mcp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -228,42 +228,53 @@ func TestMCPListsEveryTool(t *testing.T) {
resultOf(t, replies[0], &result)

want := map[string][]string{
"list_sessions": {},
"open_session": {},
"close_session": {"session"},
"close_project": {"project"},
"rename_session": {"label"},
"list_worktrees": {},
"send_to_session": {"session", "prompt"},
"wait_for_answer": {},
"reply_to_session": {"answer"},
"list_folders": {},
"file_session": {"folder"},
"rename_folder": {"folder", "to"},
"color_folder": {"folder", "color"},
"control_session": {"session", "action"},
"ask_session": {"session", "question"},
"browser_open": {},
"browser_info": {},
"browser_click": {},
"browser_type": {"text"},
"browser_press": {"key"},
"browser_screenshot": {},
"browser_navigate": {"url"},
"browser_reload": {},
"browser_back": {},
"browser_forward": {},
"browser_scroll": {},
"browser_dialog": {"accept"},
"browser_list": {},
"browser_close": {},
"browser_tabs": {},
"browser_tab": {"action"},
"list_sessions": {},
"open_session": {},
"close_session": {"session"},
"close_project": {"project"},
"list_services": {},
"create_service": {"name", "command"},
"edit_service": {"service"},
"delete_service": {"service"},
"start_service": {"service"},
"stop_service": {"service"},
"force_stop_service": {"service"},
"restart_service": {"service"},
"start_all_services": {},
"stop_all_services": {},
"read_service_output": {"service"},
"rename_session": {"label"},
"list_worktrees": {},
"send_to_session": {"session", "prompt"},
"wait_for_answer": {},
"reply_to_session": {"answer"},
"list_folders": {},
"file_session": {"folder"},
"rename_folder": {"folder", "to"},
"color_folder": {"folder", "color"},
"control_session": {"session", "action"},
"ask_session": {"session", "question"},
"browser_open": {},
"browser_info": {},
"browser_click": {},
"browser_type": {"text"},
"browser_press": {"key"},
"browser_screenshot": {},
"browser_navigate": {"url"},
"browser_reload": {},
"browser_back": {},
"browser_forward": {},
"browser_scroll": {},
"browser_dialog": {"accept"},
"browser_list": {},
"browser_close": {},
"browser_tabs": {},
"browser_tab": {"action"},
}
// The read-only ones, which a client may auto-allow. wait_for_answer is
// not among them: collecting drains the inbox.
readOnly := map[string]bool{
"list_sessions": true, "list_worktrees": true, "list_folders": true,
"list_sessions": true, "list_worktrees": true, "list_folders": true, "list_services": true, "read_service_output": true,
"browser_info": true, "browser_list": true, "browser_screenshot": true, "browser_tabs": true,
}
if len(result.Tools) != len(want) {
Expand Down
Loading
Loading