lithe-cli 0.1.0__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lithe-cli
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Command-line interface for the lithe agent kernel: chat, one-shot runs, undo, and run inspection.
5
5
  License: MIT License
6
6
 
@@ -49,13 +49,14 @@ Dynamic: license-file
49
49
 
50
50
  [![PyPI](https://img.shields.io/pypi/v/lithe-cli.svg)](https://pypi.org/project/lithe-cli/)
51
51
  [![Python](https://img.shields.io/pypi/pyversions/lithe-cli.svg)](https://pypi.org/project/lithe-cli/)
52
- [![License: MIT](https://img.shields.io/pypi/l/lithe-cli.svg)](https://pypi.org/project/lithe-cli/)
52
+ [![License: MIT](https://img.shields.io/pypi/lithe-cli.svg)](https://pypi.org/project/lithe-cli/)
53
53
 
54
54
  **lithe-cli** is the command-line interface for
55
55
  [lithe](https://pypi.org/project/lithe/) — the storage-free ReAct agent
56
56
  kernel. Installing it pulls in `lithe` automatically and gives you a `lithe`
57
57
  command: chat with an agent that reads and writes files in your workspace,
58
- run one-shot tasks, inspect stored runs, and undo a run's mutations.
58
+ runs code, searches the web through MCP tools, inspects stored runs, and
59
+ undoes a run's mutations.
59
60
 
60
61
  ```bash
61
62
  pip install lithe-cli
@@ -76,6 +77,10 @@ Flags (`--api-key`, `--base-url`, `--model`) override the environment.
76
77
  `LITHE_HOME` (default `~/.lithe`) locates the run store; the agent's
77
78
  workspace defaults to the current directory (`--workspace` to change).
78
79
 
80
+ `lithe doctor` prints the effective configuration — endpoint (with a
81
+ masked key), store, workspace, sandbox backend, skills, MCP servers — so
82
+ you can see what a run would use before starting one.
83
+
79
84
  ## Use
80
85
 
81
86
  ### One-shot task
@@ -84,10 +89,11 @@ workspace defaults to the current directory (`--workspace` to change).
84
89
  $ cd my-project
85
90
  $ lithe run "总结 README.md 的要点,存到 SUMMARY.md"
86
91
  ⚒ read_file {"path": "README.md"}
87
- ✓ 已读取 README.md(120 行)
92
+ ✓ 已读取 README.md(120 行) 0.1s
88
93
  ⚒ write_file {"path": "SUMMARY.md", "content": "..."}
89
- ✓ 写入 SUMMARY.md
94
+ ✓ 写入 SUMMARY.md 0.0s
90
95
  已把要点写入 SUMMARY.md。
96
+ ── done · steps 3 · tokens 2100 · cost 0.0042
91
97
  ```
92
98
 
93
99
  `--stream` streams tokens as they generate; `-v` adds per-call
@@ -98,16 +104,40 @@ usage/context gauges; `--max-steps` caps the tool loop;
98
104
 
99
105
  ```bash
100
106
  $ lithe chat
101
- lithe 0.1.0 — /exit 退出,输入任务开始。
102
- lithe> 把 a.txt 改成三行待办清单
107
+ ╭──────────────────────────────────────╮
108
+ │ lithe 0.2.0 │
109
+ │ model your-model │
110
+ │ workdir /home/me/my-project │
111
+ │ /help 查看会话命令,/exit 退出 │
112
+ ╰──────────────────────────────────────╯
113
+ lithe ❯ 把 a.txt 改成三行待办清单
103
114
  ⚒ write_file {"path": "a.txt", ...}
104
- ✓ 写入 a.txt
115
+ ✓ 写入 a.txt 0.0s
105
116
  已完成。
106
- lithe> /exit
117
+ ── done · steps 2 · tokens 180
118
+ lithe ❯ /exit
107
119
  ```
108
120
 
109
121
  History carries across turns within a session (each turn is its own run in
110
- the store, replayed as context for the next).
122
+ the store, replayed as context for the next). `/new` clears the session,
123
+ `/tools` lists the registered tools, `/help` lists the commands.
124
+
125
+ ### Extra capabilities
126
+
127
+ The kernel ships these as bundles; the CLI grants them per flag:
128
+
129
+ | Flag | Tools granted | Notes |
130
+ | --- | --- | --- |
131
+ | `--code` | `run_code` / `run_file` | Python under bubblewrap when installed (passthrough otherwise); `doctor` shows which |
132
+ | `--skills DIR` | `load_skill` | markdown skill library; defaults to `$LITHE_HOME/skills` when it exists, `--skills ""` disables |
133
+ | `--download` | `download_file` | SSRF-guarded, size-capped network fetch |
134
+ | `--vision` | `image_info` / `analyze_image` | image probe is stdlib-only; analysis routes one vision call to the main endpoint |
135
+ | `--mcp SPEC` | whatever the servers expose | JSON array/object or `@file` (env `LITHE_MCP`); stdio and streamable-http; failed servers degrade gracefully |
136
+
137
+ ```bash
138
+ $ lithe run --code "用 run_code 验证 results.csv 的行数"
139
+ $ lithe chat --skills ~/my-skills --mcp @~/mcp.json
140
+ ```
111
141
 
112
142
  ### Undo a run
113
143
 
@@ -116,35 +146,47 @@ configuration:
116
146
 
117
147
  ```bash
118
148
  $ lithe runs # find the run
119
- abc123def456 done steps=3 ...
149
+ run status steps cost task
150
+ ──────────── ────── ───── ──── ────────────────
151
+ abc123def456 done 3 0.01 总结 README.md …
120
152
  $ lithe undo abc123def456
121
- 已撤销 2 个操作(run abc123def456)
153
+ ✓ 已撤销 2 个操作(run abc123def456)
122
154
  ```
123
155
 
124
156
  A file the run created is deleted; a file it overwrote is restored.
157
+ (`run_code` side effects are not revertible — its tool description warns
158
+ the model.)
125
159
 
126
160
  ### Inspect
127
161
 
128
162
  ```bash
129
163
  $ lithe runs # stored runs (newest last)
130
164
  $ lithe log abc123def456 # messages + actions of one run
165
+ $ lithe doctor # config + capability status
131
166
  ```
132
167
 
133
168
  ### Tools
134
169
 
135
170
  ```bash
136
171
  $ lithe tools # what the agent can do
137
- apply_patch WRITE 以行级 patch 一次修改多个文件...
138
- edit_file WRITE 局部替换文件中的文本...
139
- read_file READ 读取文件内容...
172
+ tool category description
173
+ ──────────── ──────── ──────────────────────────────
174
+ apply_patch WRITE 以行级 patch 一次修改多个文件…
175
+ read_file READ 读取文件内容…
140
176
  ...
141
177
  ```
142
178
 
143
179
  The default tool set is the workspace bundle (`read_file` / `write_file` /
144
180
  `edit_file` / `list_files` / `search_files` / `glob_files` /
145
- `apply_patch`) plus todos (`update_todos` / `list_todos`).
146
- `--download` additionally grants `download_file` — the SSRF-guarded,
147
- size-capped network fetch — for runs and chats.
181
+ `apply_patch`) plus todos (`update_todos` / `list_todos`); the flags above
182
+ add capabilities. (MCP tools attach at run time, so `lithe tools` lists
183
+ them only after a session has started the servers.)
184
+
185
+ ### Colors
186
+
187
+ Output is colored when stdout is a TTY and `NO_COLOR` is unset; `--color`
188
+ / `--no-color` force either way. Rendering has zero dependencies — plain
189
+ text degrades cleanly through pipes and cron.
148
190
 
149
191
  ## Where things land
150
192
 
@@ -153,6 +195,7 @@ size-capped network fetch — for runs and chats.
153
195
  | current dir (or `--workspace`) | the agent's sandboxed workspace — every tool path resolves strictly inside it |
154
196
  | `~/.lithe/runs` (or `--store`) | JSONL run store: messages, actions, undo records |
155
197
  | `~/.lithe/runs/todos-<user>.json` | the agent's task list |
198
+ | `~/.lithe/skills` (or `--skills`) | the markdown skill library, when enabled |
156
199
 
157
200
  ## Design notes
158
201
 
@@ -0,0 +1,164 @@
1
+ # lithe-cli
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/lithe-cli.svg)](https://pypi.org/project/lithe-cli/)
4
+ [![Python](https://img.shields.io/pypi/pyversions/lithe-cli.svg)](https://pypi.org/project/lithe-cli/)
5
+ [![License: MIT](https://img.shields.io/pypi/lithe-cli.svg)](https://pypi.org/project/lithe-cli/)
6
+
7
+ **lithe-cli** is the command-line interface for
8
+ [lithe](https://pypi.org/project/lithe/) — the storage-free ReAct agent
9
+ kernel. Installing it pulls in `lithe` automatically and gives you a `lithe`
10
+ command: chat with an agent that reads and writes files in your workspace,
11
+ runs code, searches the web through MCP tools, inspects stored runs, and
12
+ undoes a run's mutations.
13
+
14
+ ```bash
15
+ pip install lithe-cli
16
+ ```
17
+
18
+ ## Configure
19
+
20
+ There is no default endpoint — the CLI refuses to run rather than silently
21
+ hitting some third-party URL. Point it at any OpenAI-compatible endpoint:
22
+
23
+ ```bash
24
+ export LITHE_API_KEY=sk-...
25
+ export LITHE_BASE_URL=https://your-endpoint/api/v1
26
+ export LITHE_MODEL=your-model
27
+ ```
28
+
29
+ Flags (`--api-key`, `--base-url`, `--model`) override the environment.
30
+ `LITHE_HOME` (default `~/.lithe`) locates the run store; the agent's
31
+ workspace defaults to the current directory (`--workspace` to change).
32
+
33
+ `lithe doctor` prints the effective configuration — endpoint (with a
34
+ masked key), store, workspace, sandbox backend, skills, MCP servers — so
35
+ you can see what a run would use before starting one.
36
+
37
+ ## Use
38
+
39
+ ### One-shot task
40
+
41
+ ```bash
42
+ $ cd my-project
43
+ $ lithe run "总结 README.md 的要点,存到 SUMMARY.md"
44
+ ⚒ read_file {"path": "README.md"}
45
+ ✓ 已读取 README.md(120 行) 0.1s
46
+ ⚒ write_file {"path": "SUMMARY.md", "content": "..."}
47
+ ✓ 写入 SUMMARY.md 0.0s
48
+ 已把要点写入 SUMMARY.md。
49
+ ── done · steps 3 · tokens 2100 · cost 0.0042
50
+ ```
51
+
52
+ `--stream` streams tokens as they generate; `-v` adds per-call
53
+ usage/context gauges; `--max-steps` caps the tool loop;
54
+ `--context-window` enables fullness gauges.
55
+
56
+ ### Interactive chat
57
+
58
+ ```bash
59
+ $ lithe chat
60
+ ╭──────────────────────────────────────╮
61
+ │ lithe 0.2.0 │
62
+ │ model your-model │
63
+ │ workdir /home/me/my-project │
64
+ │ /help 查看会话命令,/exit 退出 │
65
+ ╰──────────────────────────────────────╯
66
+ lithe ❯ 把 a.txt 改成三行待办清单
67
+ ⚒ write_file {"path": "a.txt", ...}
68
+ ✓ 写入 a.txt 0.0s
69
+ 已完成。
70
+ ── done · steps 2 · tokens 180
71
+ lithe ❯ /exit
72
+ ```
73
+
74
+ History carries across turns within a session (each turn is its own run in
75
+ the store, replayed as context for the next). `/new` clears the session,
76
+ `/tools` lists the registered tools, `/help` lists the commands.
77
+
78
+ ### Extra capabilities
79
+
80
+ The kernel ships these as bundles; the CLI grants them per flag:
81
+
82
+ | Flag | Tools granted | Notes |
83
+ | --- | --- | --- |
84
+ | `--code` | `run_code` / `run_file` | Python under bubblewrap when installed (passthrough otherwise); `doctor` shows which |
85
+ | `--skills DIR` | `load_skill` | markdown skill library; defaults to `$LITHE_HOME/skills` when it exists, `--skills ""` disables |
86
+ | `--download` | `download_file` | SSRF-guarded, size-capped network fetch |
87
+ | `--vision` | `image_info` / `analyze_image` | image probe is stdlib-only; analysis routes one vision call to the main endpoint |
88
+ | `--mcp SPEC` | whatever the servers expose | JSON array/object or `@file` (env `LITHE_MCP`); stdio and streamable-http; failed servers degrade gracefully |
89
+
90
+ ```bash
91
+ $ lithe run --code "用 run_code 验证 results.csv 的行数"
92
+ $ lithe chat --skills ~/my-skills --mcp @~/mcp.json
93
+ ```
94
+
95
+ ### Undo a run
96
+
97
+ The bundled file and todo tools register reverters, so undo works with zero
98
+ configuration:
99
+
100
+ ```bash
101
+ $ lithe runs # find the run
102
+ run status steps cost task
103
+ ──────────── ────── ───── ──── ────────────────
104
+ abc123def456 done 3 0.01 总结 README.md …
105
+ $ lithe undo abc123def456
106
+ ✓ 已撤销 2 个操作(run abc123def456)
107
+ ```
108
+
109
+ A file the run created is deleted; a file it overwrote is restored.
110
+ (`run_code` side effects are not revertible — its tool description warns
111
+ the model.)
112
+
113
+ ### Inspect
114
+
115
+ ```bash
116
+ $ lithe runs # stored runs (newest last)
117
+ $ lithe log abc123def456 # messages + actions of one run
118
+ $ lithe doctor # config + capability status
119
+ ```
120
+
121
+ ### Tools
122
+
123
+ ```bash
124
+ $ lithe tools # what the agent can do
125
+ tool category description
126
+ ──────────── ──────── ──────────────────────────────
127
+ apply_patch WRITE 以行级 patch 一次修改多个文件…
128
+ read_file READ 读取文件内容…
129
+ ...
130
+ ```
131
+
132
+ The default tool set is the workspace bundle (`read_file` / `write_file` /
133
+ `edit_file` / `list_files` / `search_files` / `glob_files` /
134
+ `apply_patch`) plus todos (`update_todos` / `list_todos`); the flags above
135
+ add capabilities. (MCP tools attach at run time, so `lithe tools` lists
136
+ them only after a session has started the servers.)
137
+
138
+ ### Colors
139
+
140
+ Output is colored when stdout is a TTY and `NO_COLOR` is unset; `--color`
141
+ / `--no-color` force either way. Rendering has zero dependencies — plain
142
+ text degrades cleanly through pipes and cron.
143
+
144
+ ## Where things land
145
+
146
+ | Path | Contents |
147
+ | --- | --- |
148
+ | current dir (or `--workspace`) | the agent's sandboxed workspace — every tool path resolves strictly inside it |
149
+ | `~/.lithe/runs` (or `--store`) | JSONL run store: messages, actions, undo records |
150
+ | `~/.lithe/runs/todos-<user>.json` | the agent's task list |
151
+ | `~/.lithe/skills` (or `--skills`) | the markdown skill library, when enabled |
152
+
153
+ ## Design notes
154
+
155
+ - The CLI is a thin host: it supplies tools, a system prompt, and the
156
+ kernel's `JsonlRunStore`; everything else (ReAct loop, streaming, budgets,
157
+ replay, undo engine) is reused from lithe.
158
+ - End-to-end behavior is tested offline against a scripted transport — no
159
+ test spends tokens.
160
+ - `python -m lithe_cli` works alongside the `lithe` console script.
161
+
162
+ ## License
163
+
164
+ MIT
@@ -1,3 +1,3 @@
1
1
  """lithe-cli: the command-line interface for the lithe agent kernel."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.2.0"
@@ -0,0 +1,207 @@
1
+ """Host wiring: tools, store, LLM config, and the event renderer.
2
+
3
+ Everything here maps lithe's host contract onto CLI defaults:
4
+
5
+ - workspace — the sandboxed file root the agent reads/writes (``--workspace``,
6
+ default the current directory);
7
+ - store — ``JsonlRunStore`` under ``~/.lithe/runs`` (``--store``), the
8
+ kernel's zero-database default;
9
+ - tools — the workspace bundle (read/write/edit/list/search/glob +
10
+ apply_patch) and todos, plus opt-in capabilities: ``--code`` (sandboxed
11
+ Python), ``--skills`` (markdown skill library), ``--vision`` (image
12
+ probe/analysis), ``--download`` (SSRF-guarded network fetch) and
13
+ ``--mcp`` (external MCP servers);
14
+ - undo — the bundled tools register reverters, so ``lithe undo`` works with
15
+ zero configuration.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import shutil
21
+ import sys
22
+ import time
23
+ import uuid
24
+ from typing import Any
25
+
26
+ from lithe import AgentContext, LLMConfig, ToolRegistry
27
+ from lithe.bundles import (
28
+ AgentHost, JsonlRunStore, JsonTodoStore, Workspace, undo_run,
29
+ )
30
+ from lithe.bundles.download import register_download_tools
31
+ from lithe.bundles.patch import register_apply_patch_tool
32
+ from lithe.bundles.workspace import register_file_tools
33
+ from lithe.bundles.todos import register_todo_tools
34
+
35
+ from .config import Config
36
+ from .ui import CYAN, GREEN, YELLOW, ui
37
+
38
+ SYSTEM_PROMPT_BASE = (
39
+ "你是运行在命令行里的助理,工作区是用户的当前目录。"
40
+ "用提供的文件工具完成任务(读写前先读、谨慎修改),"
41
+ "需要多步任务时先用 update_todos 列出计划,最后用简洁中文汇报结果。"
42
+ )
43
+
44
+
45
+ def sandbox_backend() -> str:
46
+ """bwrap when present (full isolation), else passthrough."""
47
+ return "bwrap" if shutil.which("bwrap") else "passthrough"
48
+
49
+
50
+ def build_system_prompt(cfg: Config) -> str:
51
+ """Base prompt plus one line per enabled capability."""
52
+ extras = []
53
+ if cfg.code:
54
+ extras.append("可以用 run_code 执行 Python 来验证想法、计算或测试。")
55
+ if cfg.download:
56
+ extras.append("可以用 download_file 下载网络文件到工作区。")
57
+ if cfg.skills_dir is not None:
58
+ extras.append("可以先 load_skill 查看可用技能并按需加载规范。")
59
+ if not extras:
60
+ return SYSTEM_PROMPT_BASE
61
+ return SYSTEM_PROMPT_BASE + "".join(extras)
62
+
63
+
64
+ def build_llm(cfg: Config) -> LLMConfig:
65
+ return LLMConfig(
66
+ model=cfg.model or "unused",
67
+ base_url=cfg.base_url or "unused",
68
+ api_key=cfg.api_key or "unused",
69
+ timeout=cfg.timeout,
70
+ attempts=cfg.attempts,
71
+ stream=cfg.stream,
72
+ context_window=cfg.context_window,
73
+ transport=cfg.transport,
74
+ )
75
+
76
+
77
+ def build_registry(cfg: Config) -> ToolRegistry:
78
+ """Register the CLI's tool set against the configured workspace."""
79
+ reg = ToolRegistry()
80
+
81
+ def workspace_for(ctx) -> Workspace:
82
+ return Workspace(cfg.workspace_dir.resolve())
83
+
84
+ register_file_tools(reg, workspace_for)
85
+ register_apply_patch_tool(reg, workspace_for)
86
+ todo_path = cfg.store_dir / f"todos-{cfg.user_id}.json"
87
+ register_todo_tools(reg, lambda ctx: JsonTodoStore(todo_path))
88
+ if cfg.download:
89
+ register_download_tools(reg, workspace_for)
90
+ if cfg.code:
91
+ from lithe.bundles.sandbox import CodeRunner, register_code_tools
92
+
93
+ runner = CodeRunner(sys.executable, backend=sandbox_backend())
94
+ register_code_tools(
95
+ reg, lambda ctx: str(cfg.workspace_dir.resolve()), runner)
96
+ if cfg.skills_dir is not None:
97
+ from lithe.bundles.skills import SkillLibrary, register_skill_tool
98
+
99
+ register_skill_tool(reg, SkillLibrary(cfg.skills_dir))
100
+ if cfg.vision:
101
+ from lithe.bundles.images import register_image_tools
102
+
103
+ register_image_tools(reg, workspace_for, llm_config=build_llm(cfg))
104
+ return reg
105
+
106
+
107
+ def build_host(cfg: Config, reg: ToolRegistry) -> AgentHost:
108
+ """Build the AgentHost; the store lands under cfg.store_dir."""
109
+ store = JsonlRunStore(cfg.store_dir)
110
+ prompt = build_system_prompt(cfg)
111
+ return AgentHost(
112
+ reg, build_llm(cfg), store, max_steps=cfg.max_steps,
113
+ build_system_prompt=lambda ctx, mode, anchor: prompt,
114
+ )
115
+
116
+
117
+ def render_event(
118
+ ev: dict, verbose: bool = False, stream: bool = False,
119
+ pending: dict | None = None,
120
+ ) -> None:
121
+ """Render one runtime event onto the terminal.
122
+
123
+ ``pending`` maps tool-call ids to their start time so results can show
124
+ elapsed seconds. ``stream`` suppresses the full-text echo of the final
125
+ answer when its tokens were already streamed.
126
+ """
127
+ t = ev.get("type")
128
+ pending = pending if pending is not None else {}
129
+ if t == "assistant_delta":
130
+ ui.delta(ev.get("text", ""))
131
+ pending["_deltas"] = pending.get("_deltas", 0) + 1
132
+ elif t == "assistant":
133
+ text = ev.get("text") or ""
134
+ # Streaming already showed every token; echoing the whole answer
135
+ # again would duplicate it.
136
+ if text and not (stream and pending.get("_deltas")):
137
+ ui.assistant(text)
138
+ elif t == "tool_call":
139
+ args = json.dumps(ev.get("args", {}), ensure_ascii=False)
140
+ ui.tool_call(str(ev.get("name")), args[:120])
141
+ if ev.get("id") is not None:
142
+ pending[ev["id"]] = time.monotonic()
143
+ elif t == "tool_result":
144
+ summary = (ev.get("summary") or "").replace("\n", " ")
145
+ started = pending.pop(ev.get("id"), None) if ev.get("id") else None
146
+ elapsed = (time.monotonic() - started) if started is not None else None
147
+ ui.tool_result(bool(ev.get("ok")), summary[:160], elapsed)
148
+ elif t == "error":
149
+ ui.error(str(ev.get("message", "")))
150
+ elif t == "cancelled":
151
+ ui.warn("已取消")
152
+ elif verbose and t == "usage":
153
+ print(ui.usage(ev.get("prompt_tokens"), ev.get("completion_tokens"),
154
+ ev.get("context_tokens"), ev.get("context_percent")))
155
+ elif verbose and t == "reasoning":
156
+ digest = (ev.get("summary") or "").replace("\n", " ")
157
+ if digest:
158
+ print(ui.reasoning(digest[:120]))
159
+
160
+
161
+ async def execute(
162
+ cfg: Config, task: str, history: list[dict] | None = None,
163
+ run_id: str | None = None,
164
+ ) -> tuple[str, dict, Any]:
165
+ """Run one task end-to-end; returns (run_id, done_event, host)."""
166
+ reg = build_registry(cfg)
167
+ host = build_host(cfg, reg)
168
+ rid = run_id or uuid.uuid4().hex[:12]
169
+ ctx = AgentContext(run_id=rid, user_id=cfg.user_id)
170
+ manager = None
171
+ if cfg.mcp_servers:
172
+ from lithe.bundles.mcp import MCPManager
173
+
174
+ manager = MCPManager(cfg.mcp_servers)
175
+ try:
176
+ report = await manager.attach(reg)
177
+ except Exception as exc: # degrade gracefully, like the bundle
178
+ ui.error(f"MCP attach 失败:{exc}")
179
+ report = {}
180
+ for name, res in report.items():
181
+ if isinstance(res, list):
182
+ print(ui.s(f" ⚙ MCP {name}: {len(res)} 个工具", CYAN))
183
+ else:
184
+ ui.error(f"MCP {name}: {res}")
185
+ ev: dict = {}
186
+ pending: dict[str, float] = {}
187
+ try:
188
+ async for ev in host.run(ctx, task, history=history):
189
+ render_event(ev, cfg.verbose, cfg.stream, pending)
190
+ if ev.get("type") == "done":
191
+ if cfg.stream and pending.get("_deltas"):
192
+ print() # close the streaming line before the footer
193
+ ui.footer(ev)
194
+ finally:
195
+ if manager is not None:
196
+ await manager.close()
197
+ return rid, ev, host
198
+
199
+
200
+ async def undo(cfg: Config, run_id: str) -> int:
201
+ """Revert a run's mutations with the bundled tools' reverters."""
202
+ reg = build_registry(cfg)
203
+ host = build_host(cfg, reg)
204
+ report = await undo_run(host, run_id, cfg.user_id)
205
+ mark = ui.s("✓", GREEN) if report.reverted else ui.s("·", YELLOW)
206
+ print(f"{mark} 已撤销 {report.reverted} 个操作(run {run_id})")
207
+ return 0